Merge "Revert "Add topic to polygerrit index paths""
diff --git a/.bazelignore b/.bazelignore
index 69c04b1..aac80af 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -1,2 +1,5 @@
 eclipse-out
 node_modules
+polygerrit-ui/node_modules
+plugins/node_modules
+tools/node_tools/node_modules
diff --git a/.bazelproject b/.bazelproject
index a7f5450..ad7b022 100644
--- a/.bazelproject
+++ b/.bazelproject
@@ -16,7 +16,7 @@
 targets:
   //...:all
 
-java_language_level: 8
+java_language_level: 11
 
 workspace_type: java
 
diff --git a/.bazelrc b/.bazelrc
index 6ccd56a..407b005 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -2,7 +2,30 @@
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
 build --action_env=PATH
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
-build --java_toolchain=//tools:error_prone_warnings_toolchain_java11
+
+# Builds using remotejdk_11, executes using remotejdk_11 or local_jdk
+build --java_language_version=11
+build --java_runtime_version=remotejdk_11
+build --tool_java_language_version=11
+build --tool_java_runtime_version=remotejdk_11
+
+# Builds using remotejdk_17, executes using remotejdk_17 or local_jdk
+build:java17 --java_language_version=17
+build:java17 --java_runtime_version=remotejdk_17
+build:java17 --tool_java_language_version=17
+build:java17 --tool_java_runtime_version=remotejdk_17
+
+# Builds and executes on RBE using remotejdk_11
+build:remote --java_language_version=11
+build:remote --java_runtime_version=remotejdk_11
+build:remote --tool_java_language_version=11
+build:remote --tool_java_runtime_version=remotejdk_11
+
+# Builds and executes on RBE using remotejdk_17
+build:remote17 --java_language_version=17
+build:remote17 --java_runtime_version=remotejdk_17
+build:remote17 --tool_java_language_version=17
+build:remote17 --tool_java_runtime_version=remotejdk_17
 
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
@@ -13,12 +36,7 @@
 
 build --announce_rc
 
-# Workaround Bazel worker crash (remove after upgrading to 4.1.0)
-# https://github.com/bazelbuild/bazel/issues/13333
-build --experimental_worker_multiplex=false
-
 test --build_tests_only
-test --test_output=errors
-test --java_toolchain=//tools:error_prone_warnings_toolchain_java11
+test --test_output=all
 
 import %workspace%/tools/remote-bazelrc
diff --git a/.bazelversion b/.bazelversion
index fcdb2e1..c7cb131 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-4.0.0
+5.3.1
diff --git a/.gitignore b/.gitignore
index 95f94ba..0bbcaba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 # Keep following lines sorted according to `LC_COLLATE=C sort`
+*.code-workspace
 *.eml
 *.iml
 *.log
@@ -30,8 +31,13 @@
 /infer-out
 /local.properties
 /node_modules/
+/polygerrit-ui/node_modules/
+/polygerrit-ui/app/node_modules/
 /package-lock.json
 /plugins/*
+/polygerrit-ui/coverage/
+/polygerrit-ui/app/plugins/*
+/polygerrit-ui/screenshots/Chrome/failed/
 !/plugins/.eslintignore
 !/plugins/.eslintrc.js
 !/plugins/.prettierrc.js
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 8efbd11..09ce63b 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -10,9 +10,9 @@
 org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
 org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
 org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=11
 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
-org.eclipse.jdt.core.compiler.compliance=1.8
+org.eclipse.jdt.core.compiler.compliance=11
 org.eclipse.jdt.core.compiler.debug.lineNumber=generate
 org.eclipse.jdt.core.compiler.debug.localVariable=generate
 org.eclipse.jdt.core.compiler.debug.sourceFile=generate
@@ -29,6 +29,7 @@
 org.eclipse.jdt.core.compiler.problem.deprecationWhenOverridingDeprecatedMethod=disabled
 org.eclipse.jdt.core.compiler.problem.discouragedReference=warning
 org.eclipse.jdt.core.compiler.problem.emptyStatement=warning
+org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
 org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
 org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=warning
 org.eclipse.jdt.core.compiler.problem.fallthroughCase=warning
@@ -87,6 +88,7 @@
 org.eclipse.jdt.core.compiler.problem.redundantSuperinterface=ignore
 org.eclipse.jdt.core.compiler.problem.reportMethodCanBePotentiallyStatic=ignore
 org.eclipse.jdt.core.compiler.problem.reportMethodCanBeStatic=ignore
+org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning
 org.eclipse.jdt.core.compiler.problem.specialParameterHidingField=disabled
 org.eclipse.jdt.core.compiler.problem.staticAccessReceiver=warning
 org.eclipse.jdt.core.compiler.problem.suppressOptionalErrors=disabled
@@ -126,4 +128,5 @@
 org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
 org.eclipse.jdt.core.compiler.processAnnotations=enabled
-org.eclipse.jdt.core.compiler.source=1.8
+org.eclipse.jdt.core.compiler.release=enabled
+org.eclipse.jdt.core.compiler.source=11
diff --git a/BUILD b/BUILD
index 084d383..984fd955 100644
--- a/BUILD
+++ b/BUILD
@@ -4,24 +4,17 @@
 package(default_visibility = ["//visibility:public"])
 
 config_setting(
-    name = "java11",
+    name = "java17",
     values = {
-        "java_toolchain": "@bazel_tools//tools/jdk:toolchain_java11",
-    },
-)
-
-config_setting(
-    name = "java_next",
-    values = {
-        "java_toolchain": "//tools:toolchain_vanilla",
+        "java_language_version": "17",
     },
 )
 
 genrule(
     name = "gen_version",
     outs = ["version.txt"],
-    cmd = ("cat bazel-out/volatile-status.txt bazel-out/stable-status.txt | " +
-           "grep STABLE_BUILD_GERRIT_LABEL | cut -d ' ' -f 2 > $@"),
+    cmd = ("(cat bazel-out/volatile-status.txt bazel-out/stable-status.txt | " +
+           "grep STABLE_BUILD_GERRIT_LABEL | cut -d ' ' -f 2) > $@ || echo 'UNKNOWN' > $@"),
     stamp = 1,
 )
 
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 3da69df..185fa07 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -800,6 +800,22 @@
 Users without this access right who are able to upload changes can
 still do the revert locally and upload the revert commit as a new change.
 
+[[category_remove_label]]
+=== Remove Label (Remove Vote)
+
+For every configured label `My-Name` in the project, there is a
+corresponding permission `removeLabel-My-Name` with a range corresponding to
+the defined values. For these values, the users are permitted to remove
+other users' votes from a change.
+
+Change owners can always remove zero or positive votes (even without
+having the `Remove Vote` access right assigned).
+
+Project owners and site administrators can always remove any vote (even
+without having the `Remove Vote` access right assigned).
+
+Users without this access right can still remove their own votes.
+
 [[category_remove_reviewer]]
 === Remove Reviewer
 
@@ -890,6 +906,9 @@
 the Work In Progress bit of the change (even without having the
 `Toggle Work In Progress state` access right assigned).
 
+Must be assigned on the target branch ref (i.e. on 'refs/heads/*', not on
+'refs/for/*').
+
 [[category_delete_own_changes]]
 === Delete Own Changes
 
@@ -1351,10 +1370,11 @@
 [[capability_createProject]]
 === Create Project
 
-Allow project creation.  This capability allows the granted group to
-either link:cmd-create-project.html[create new git projects via ssh]
-or via the web UI.
+Allow project creation.
 
+This capability allows the granted group to create projects via the web UI, via
+link:rest-api-projects.html#create-project][REST] and via
+link:cmd-create-project.html[SSH].
 
 [[capability_emailReviewers]]
 === Email Reviewers
diff --git a/Documentation/backend_licenses.txt b/Documentation/backend_licenses.txt
index 3113682..586406f 100755
--- a/Documentation/backend_licenses.txt
+++ b/Documentation/backend_licenses.txt
@@ -61,11 +61,8 @@
 * guice:guice-library
 * guice:guice-servlet
 * guice:javax_inject
-* httpcomponents:httpasyncclient
 * httpcomponents:httpclient
 * httpcomponents:httpcore
-* httpcomponents:httpcore-nio
-* jackson:jackson-core
 * jetty:http
 * jetty:io
 * jetty:jmx
@@ -1113,22 +1110,6 @@
 ----
 
 
-[[elasticsearch]]
-elasticsearch
-
-* elasticsearch-rest-client:elasticsearch-rest-client
-
-[[elasticsearch_license]]
-----
-Elasticsearch
-Copyright 2009-2015 Elasticsearch
-
-This product includes software developed by The Apache Software
-Foundation (http://www.apache.org/).
-
-----
-
-
 [[flexmark]]
 flexmark
 
diff --git a/Documentation/backup.txt b/Documentation/backup.txt
index dd47035..9139e71 100644
--- a/Documentation/backup.txt
+++ b/Documentation/backup.txt
@@ -45,10 +45,6 @@
 It can be recomputed from primary data in the git repositories but
 reindexing may take a long time hence backing up the index makes sense
 for production installations.
-+
-If you have chosen to use _Elastic Search_ for indexing,
-refer to its
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html[backup documentation,role=external,window=_blank].
 
 [#optional-backup-cache]
 Caches::
diff --git a/Documentation/cmd-check-project-access.txt b/Documentation/cmd-check-project-access.txt
new file mode 100644
index 0000000..885993a
--- /dev/null
+++ b/Documentation/cmd-check-project-access.txt
@@ -0,0 +1,74 @@
+= gerrit check-project-access
+
+== NAME
+gerrit check-project-access - Check project readability of all users in a
+matching the given link:rest-api-accounts.html#account-id[account identifier]
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit check-project-access_
+  [--project <PROJECT> | -p <PROJECT>]
+  [--user <USER> | -u <USER>]
+--
+
+== DESCRIPTION
+Allow users to check if user has access to a project’s changes, comments, code
+differences, and Git access over SSH or HTTP.
+
+It returns all users in given input String, where it includes: username, email
+address and full name.
+
+== ACCESS
+Users who have view access and administrate server capability.
+
+== EXAMPLES
+Check if users can read all references in the repository called "test_project",
+in given input String TestUser.
+
+Given that there are
+a user with username "test_user1", email "one@email.com", and Full name as
+TestUser,
+a user with username "test_user2", email "two@email.com", and Full name as
+TestUser,
+a user with username "test_user3", email "TestUser@email.com", and Full name
+as John Doe
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project
+  -u TestUser
+
+Username: 'test_user1', Email: 'one@example.com',  Full Name: 'TestUser'
+, Result: TRUE
+Username: 'test_user3', Email: 'TestUser@example.com',  Full Name: 'John Doe'
+, Result: FALSE
+Username: 'test_user2', Email: 'two@example.com',  Full Name: 'TestUser'
+, Result: FALSE
+----
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project_doesnt_exist
+  -u TestUser
+
+fatal: project 'test_project_doesnt_exist' is unavailable
+----
+
+----
+$ ssh -p @SSH_PORT@ @SSH_HOST@ gerrit check-project-access
+  -p test_project
+  -u test_user_doesnt_exist
+
+fatal: No accounts found for your query: "test_user_doesnt_exist"
+Tip: Try double-escaping spaces, for example: "--user Last,\\ First"
+----
+
+
+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 0575eb9..87f3851 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -108,6 +108,7 @@
 	Action used by Gerrit to submit an approved change to its
 	destination branch.  Supported options are:
 +
+* INHERIT: inherits the submit-type from the parent project.
 * FAST_FORWARD_ONLY: produces a strictly linear history.
 * MERGE_IF_NECESSARY: create a merge commit when required.
 * REBASE_IF_NECESSARY: rebase the commit when required.
@@ -116,7 +117,7 @@
 * CHERRY_PICK: always cherry-pick the commit.
 
 +
-Defaults to MERGE_IF_NECESSARY unless
+Defaults to INHERIT unless
 link:config-gerrit.html#repository.name.defaultSubmitType[
 repository.<name>.defaultSubmitType] is set to a different value.
 For more details see link:config-project-config.html#submit-type[
@@ -136,13 +137,13 @@
 	project.  Disabled by default.
 
 --use-signed-off-by::
---so:
+--so::
 	If enabled, each change must contain a Signed-off-by line
 	from either the author or the uploader in the commit message.
 	Disabled by default.
 
 --create-new-change-for-all-not-in-target::
---ncfa:
+--ncfa::
 	If enabled, a new change is created for every commit that is not in
 	the target branch. If the pushed commit is a merge commit, this flag is
 	ignored for that push. To avoid accidental creation of a large number
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index ab341e8..c959a07 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -58,6 +58,12 @@
 link:cmd-ban-commit.html[gerrit ban-commit]::
 	Bans a commit from a project's repository.
 
+link:cmd-copy-approvals.html[gerrit copy-approvals]::
+	Copy all inferred approvals labels to the latest patch-set.
+
+link:cmd-check-project-access.html[gerrit check-project-access]::
+	Check if user(s) can read non-config refs of a project
+
 link:cmd-create-branch.html[gerrit create-branch]::
 	Create a new project branch.
 
@@ -157,6 +163,9 @@
 link:cmd-ls-user-refs.html[gerrit ls-user-refs]::
 	Lists refs visible for a specified user.
 
+link:cmd-migrate-externalids-to-insensitive.html[gerrit migrate-externalids-to-insensitive]::
+	Migrate external-ids to case insensitive.
+
 link:cmd-plugin-install.html[gerrit plugin add]::
 	Alias for 'gerrit plugin install'.
 
diff --git a/Documentation/cmd-ls-groups.txt b/Documentation/cmd-ls-groups.txt
index 8a4845c..0a06c28 100644
--- a/Documentation/cmd-ls-groups.txt
+++ b/Documentation/cmd-ls-groups.txt
@@ -11,7 +11,7 @@
   [--user <NAME> | -u <NAME>]
   [--owned]
   [--visible-to-all]
-  [-q <GROUP>]
+  [-g <GROUP>]
   [--verbose | -v]
 --
 
@@ -67,8 +67,8 @@
 	(groups that are explicitly marked as visible to all registered
 	users).
 
--q::
-	Group that should be inspected. The `-q` option can be specified
+-g::
+	Group that should be inspected. The `-g` option can be specified
 	multiple times to define several groups to be inspected. If
 	specified the listed groups will only contain groups that were
 	specified to be inspected. This is e.g. useful in combination with
diff --git a/Documentation/cmd-migrate-externalids-to-insensitive.txt b/Documentation/cmd-migrate-externalids-to-insensitive.txt
new file mode 100644
index 0000000..b023089
--- /dev/null
+++ b/Documentation/cmd-migrate-externalids-to-insensitive.txt
@@ -0,0 +1,44 @@
+= gerrit migrate-externalids-to-insensitive
+
+== NAME
+gerrit migrate-externalids-to-insensitive - Migrate external-ids to case insensitive.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit migrate-externalids-to-insensitive_
+--
+
+== DESCRIPTION
+This command allows to trigger online conversion of `username` and
+`gerrit` external IDs to be handled case insensitively. This is done by
+recomputing the name of the note from the sha1 sum of the all lowercase
+external ID key, instead of preserving the key capitalization.
+
+The command requires link:#auth.userNameCaseInsensitive[auth.userNameCaseInsensitive] and
+link:#auth.userNameCaseInsensitiveMigrationMode[auth.userNameCaseInsensitiveMigrationMode] to
+be set to true to perform the migration.
+
+After the successful migration
+link:#auth.userNameCaseInsensitiveMigrationMode[auth.userNameCaseInsensitiveMigrationMode] is
+set to false.
+
+== ACCESS
+Caller must be a member of the privileged 'Administrators' group.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== EXAMPLES
+Start the online external ids migration:
+
+----
+$ ssh -p 29418 review.example.com gerrit migrate-externalids-to-insensitive
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index f0ad460..5fd0bfc 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -324,6 +324,19 @@
 
 comment:: Review comment cover message.
 
+=== Project Head Updated
+
+Sent when project's head is updated.
+
+type:: "project-head-updated"
+
+oldHead:: The old project head name
+
+newHead:: The new project head name
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 == SEE ALSO
 
 * link:json.html[JSON Data Formats]
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 0444fab..6e76a8a 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -34,6 +34,11 @@
 |Owner
 |The contributor who created the change.
 
+|Uploader
+|The user that uploaded the current patch set (e.g. the user that executed the
+`git push` command, or the user that triggered the patch set creation through
+an action in the UI).
+
 |Assignee
 |The contributor responsible for the change. Often used when a change has
 mulitple reviewers to identify the individual responsible for final approval.
diff --git a/Documentation/concept-patch-sets.txt b/Documentation/concept-patch-sets.txt
index 274fbb0..e39d091 100644
--- a/Documentation/concept-patch-sets.txt
+++ b/Documentation/concept-patch-sets.txt
@@ -88,8 +88,8 @@
 evolves, such as "Added more unit tests." Unlike the change description, a patch
 set description does not become a part of the project's history.
 
-To add a patch set description, click *Add a patch set description*, located in
-the file list, or provide it link:user-upload.html#patch_set_description[on upload].
+To add a patch set description provide it
+link:user-upload.html#patch_set_description[on upload].
 
 GERRIT
 ------
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 7a7cef2..716fa2f 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -111,8 +111,8 @@
 Accessing the account data in Git is not fast enough for account
 queries, since it requires accessing all user branches and parsing
 all files in each of them. To overcome this Gerrit has a secondary
-index for accounts. The account index is either based on
-link:config-gerrit.html#index.type[Lucene or Elasticsearch].
+index for accounts. The account index is based on
+link:config-gerrit.html#index.type[Lucene].
 
 Via the link:rest-api-accounts.html#query-account[Query Account] REST
 endpoint link:user-search-accounts.html[generic account queries] are
@@ -383,8 +383,8 @@
 [[starred-changes]]
 == Starred Changes
 
-link:dev-stars.html[Starred changes] allow users to mark changes as
-favorites and receive email notifications for them.
+Starred changes allow users to mark changes as favorites and receive email
+notifications for them.
 
 Each starred change is a tuple of an account ID, a change ID and a
 label.
@@ -402,8 +402,7 @@
 when the prefix ends with '/', this ref format is optimized to find
 starred changes by change ID. Finding starred changes by change ID is
 e.g. needed when a change is updated so that all users that have
-the link:dev-stars.html#default-star[default star] on the change can be
-notified by email.
+the star on the change can be notified by email.
 
 Gerrit also needs an efficient way to find all changes that were
 starred by an account, e.g. to provide results for the
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 3109ec7..8d30db2 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -256,8 +256,8 @@
 if link:#auth.gitBasicAuthPolicy[`auth.gitBasicAuthPolicy`] is set to `HTTP_LDAP`,
 the password in the request is first checked against the HTTP password and, if
 it does not match, it is then validated against the LDAP password.
-Service users that only exist in the Gerrit database are authenticated by their
-HTTP passwords.
+Service users that are link:cmd-create-account.html[internal-only] are
+authenticated by their HTTP passwords.
 
 * `LDAP_BIND`
 +
@@ -561,7 +561,8 @@
 +
 To enable the actual usage of contributor agreement the project
 specific config option in the `project.config` must be set:
-link:config-project-config.html[receive.requireContributorAgreement].
+link:config-project-config.html#receive.requireContributorAgreement[
+receive.requireContributorAgreement].
 
 [[auth.trustContainerAuth]]auth.trustContainerAuth::
 +
@@ -611,7 +612,7 @@
 single call would trigger a full LDAP authentication and groups resolution
 which could introduce a noticeable latency on the overall execution
 and produce unwanted load to the LDAP server.
-+
+
 [[auth.gitOAuthProvider]]auth.gitOAuthProvider::
 +
 Selects the OAuth 2 provider to authenticate git over HTTP traffic with.
@@ -666,8 +667,9 @@
 the link:config-accounts.html#external-ids[External ID documentation].
 +
 Gerrit provides the
-link:pgm-ChangeExternalIdCaseSensitivity.html[ChangeExternalIdCaseSensitivity tool]
-to migrate existing accounts to match the new scheme.
+link:pgm-ChangeExternalIdCaseSensitivity.html[offline]
+and the online link:externalid-case-insensitivity.html#online-migration[online]
+tools to migrate existing accounts to match the new scheme.
 +
 Naturally, if there were two accounts only different in capitalization,
 e.g. `johndoe` and `JohnDoe`, the account `JohnDoe` will not be able
@@ -676,6 +678,16 @@
 note name would be identical and thus conflict. These duplicates thus
 have to be deleted manually by deleting the respective external ID.
 +
+For newly initialized sites this option defaults to true.
++
+Default is false.
+
+[[auth.userNameCaseInsensitiveMigrationMode]]auth.userNameCaseInsensitiveMigrationMode::
++
+Setting migration mode to true allows to fallback to case sensitive
+behaviour if the migrated external ID cannot be found. This allows to
+trigger the migration while Gerrit process is running.
++
 Default is false.
 
 [[auth.enableRunAs]]auth.enableRunAs::
@@ -703,8 +715,10 @@
 [[auth.autoUpdateAccountActiveStatus]]auth.autoUpdateAccountActiveStatus::
 +
 Whether to allow automatic synchronization of an account's inactive flag upon login.
++
 If set to true, upon login, if the authentication back-end reports the account as active,
-the account's inactive flag in the internal Gerrit database will be updated to be active.
+the account's inactive flag in NoteDb will be updated to be active.
++
 If the authentication back-end reports the account as inactive, the account's flag will be
 updated to be inactive and the login attempt will be blocked. Users enabling this feature
 should ensure that their authentication back-end is supported. Currently, only
@@ -832,18 +846,21 @@
 entry is relatively the same, memoryLimit is currently defined to be
 the number of entries held by the cache (each entry costs 1).
 +
-For caches where the size of an entry can vary significantly between
-individual entries (notably `"diff"`, `"diff_intraline"`), memoryLimit
-is an approximation of the total number of bytes stored by the cache.
-Larger entries that represent bigger patch sets or longer source files
-will consume a bigger portion of the memoryLimit. For these caches the
-memoryLimit should be set to roughly the amount of RAM (in bytes) the
-administrator can dedicate to the cache.
+For caches where the size of an entry can vary significantly between individual
+entries (notably `"git_modified_files"`, `"modified_files"`, `"git_file_diff"`,
+`"gerrit_file_diff"`, `"diff_intraline"`), memoryLimit is an approximation of
+the total number of bytes stored by the cache.  Larger entries that represent
+bigger patch sets or longer source files will consume a bigger portion of the
+memoryLimit. For these caches the memoryLimit should be set to roughly the
+amount of RAM (in bytes) the administrator can dedicate to the cache.
 +
 Default is 1024 for most caches, except:
 +
 * `"adv_bases"`: default is `4096`
-* `"diff"`: default is `10m` (10 MiB of memory)
+* `"git_modified_files"`: default is `10m` (10 MiB of memory)
+* `"modified_files"`: default is `10m` (10 MiB of memory)
+* `"git_file_diff"`: default is `10m` (10 MiB of memory)
+* `"gerrit_file_diff"`: default is `10m` (10 MiB of memory)
 * `"diff_intraline"`: default is `10m` (10 MiB of memory)
 * `"diff_summary"`: default is `10m` (10 MiB of memory)
 * `"external_ids_map"`: default is `2` and should not be changed
@@ -854,8 +871,14 @@
 * `"plugin_resources"`: default is 2m (2 MiB of memory)
 
 +
-If set to 0 the cache is disabled. Entries are removed immediately
-after being stored by the cache. This is primarily useful for testing.
+If set to 0 the cache is disabled; entries are loaded but not stored
+in-memory.
++
+**NOTE**: When the cache is disabled, there is no locking when accessing
+the same key/value, and therefore multiple threads may
+load the same value concurrently with a higher memory footprint.
+To keep a minimum caching and avoid concurrent loading of the same
+key/value, set `memoryLimit` to `1` and `maxAge` to `1`.
 
 [[cache.name.expireFromMemoryAfterAccess]]cache.<name>.expireFromMemoryAfterAccess::
 +
@@ -940,12 +963,6 @@
 +
 If direct updates are made to `All-Users`, this cache should be flushed.
 
-cache `"approvals"`::
-+
-Cache entries contain approvals for a given patch set. This includes
-approvals granted on this patch set as well as approvals copied from
-earlier patch sets.
-
 cache `"adv_bases"`::
 +
 Used only for push over smart HTTP when branch level access controls
@@ -971,19 +988,41 @@
 this cache should be disabled in a cluster setup using multiple primary
 or multiple replica nodes.
 +
-The cache should be flushed whenever the database changes table is modified
-outside of Gerrit.
+The cache should be flushed whenever NoteDb change metadata in a repository is
+modified outside of Gerrit.
 
-cache `"diff"`::
+cache `"git_modified_files"`::
 +
-Each item caches the differences between two commits, at both the
-directory and file levels.  Gerrit uses this cache to accelerate
-the display of affected file names, as well as file contents.
+Each item caches the list of git modified files between two git trees
+corresponding to two different commits. This cache does not read the actual
+file contents nor does it include the edits (modified regions) of the files.
+
+cache `"modified_files"`::
++
+Each item caches the list of modified files between two commits. This cache
+is similar to the `git_modified_files` cache but performs extra logic including
+filtering out files that are untouched by both commits because they were purely
+modified between the parent commits.
+
+cache `"git_file_diff"`::
++
+Each item caches the pure git diff between two git trees for a specific file
+path. The diff includes all the file attributes (old/new paths, change/patch
+types) as well as the list of edits corresponding to the modified regions in
+the file.
+
+cache `"gerrit_file_diff"`::
++
+Each item caches the diff between two git commits for a specific file path.
+This cache is similar to the `git_file_diff` cache but performs extra logic
+including identifying the edits that are due to rebase. The diff for the
+"commit message" and "merge list" can also be requested from this cache.
 +
 Entries in this cache are relatively large, so memoryLimit is an
 estimate in bytes of memory used. Administrators should try to target
 cache.diff.memoryLimit to fit all changes users will view in a 1 or 2
-day span.
+day span. The same applies for other diff caches: `"git_modified_files"`,
+`"modified_files"` and `"git_file_diff"`.
 
 cache `"diff_intraline"`::
 +
@@ -1017,12 +1056,6 @@
 away from the defaults. The cache may be persisted by setting
 `diskLimit`, which is only recommended if cold start performance is
 problematic.
-+
-`external_ids_map` supports computing the new cache value based on a
-previously cached state. This applies modifications based on the Git
-diff and is almost always faster.
-`cache.external_ids_map.enablePartialReloads` turns this behavior on
-or off. The default is `true`.
 
 cache `"git_tags"`::
 +
@@ -1162,7 +1195,7 @@
 cache `"prolog_rules"`::
 +
 Caches parsed `rules.pl` contents for each project. This cache uses the same
-size as the `projects` cache, and cannot be configured independently.
+size as the `projects` cache when `cache.prolog_rules.memoryLimit` is not set.
 
 cache `"pure_revert"`::
 +
@@ -1206,9 +1239,9 @@
 
 ==== [[cache_options]]Cache Options
 
-[[cache.diff.timeout]]cache.diff.timeout::
+[[cache.git_file_diff.timeout]]cache.git_file_diff.timeout::
 +
-Maximum number of milliseconds to wait for diff data before giving up and
+Maximum number of milliseconds to wait for git diff data before giving up and
 falling back on a simpler diff algorithm that will not be able to break down
 modified regions into smaller ones. This is a work around for an infinite loop
 bug in the default difference algorithm implementation.
@@ -1283,6 +1316,21 @@
 +
 Default is the number of CPUs.
 
+[[cache.project_list.interval]]cache.project_list.interval::
++
+The link:#schedule-configuration-interval[interval] for running
+the project_list cache warmer.
+
+By default, if `cache.project_list.maxAge` is set, `interval` will be set to
+half its value. If `cache.project_list.maxAge` is not set or `interval` is set
+to `-1`, it is disabled.
+
+[[cache.project_list.startTime]]cache.project_list.startTime::
++
+The link:#schedule-configuration-startTime[start time] for running
+the project_list cache warmer.
+
+Default is 00:00 if the project_list cache warmer is enabled.
 
 [[capability]]
 === Section capability
@@ -1314,7 +1362,7 @@
 Whether the first user that logs in to the Gerrit server should
 automatically be added to the administrator group and hence get the
 `administrateServer` capability assigned. This is useful to bootstrap
-the authentication database.
+the link:config-accounts.html[account data].
 +
 Default is true.
 
@@ -1330,15 +1378,16 @@
 
 [[change.cacheAutomerge]]change.cacheAutomerge::
 +
-When reviewing merge commits, the left-hand side shows the output of the
-result of JGit's automatic merge algorithm. This option controls whether
-this output is cached in the change repository, or if only the diff is
-cached in the persistent `diff` cache.
+When reviewing merge commits, the left-hand side shows the output of the result
+of JGit's automatic merge algorithm. This option controls whether this output is
+cached in the change repository, or if only the diff is cached in the persistent
+diff caches (`"git_modified_files"`, `modified_files`, `"git_file_diff"`,
+`"file_diff"`).
 +
 If true, automerge results are stored in the repository under
 `refs/cache-automerge/*`; the results of diffing the change against its
-automerge base are stored in the diff cache. If false, no extra data is
-stored in the repository, only the diff cache. This can result in slight
+automerge base are stored in the diff caches. If false, no extra data is
+stored in the repository, only the diff caches. This can result in slight
 performance improvements by reducing the number of refs in the repo.
 +
 Default is true.
@@ -1399,7 +1448,7 @@
 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.
+By default 1000.
 
 [[change.maxUpdates]]change.maxUpdates::
 +
@@ -1465,6 +1514,13 @@
 
 Default is true.
 
+[[change.maxSubmittableAtOnce]]change.maxSubmittableAtOnce::
++
+Maximum number of changes that can be chained together in the same repository
+to be submitted at once.
++
+Default is 32767.
+
 [[change.move]]change.move::
 +
 Whether the link:rest-api-changes.html#move-change[Move Change] REST
@@ -1672,9 +1728,7 @@
 will match typical Gerrit Change-Id values and create a hyperlink
 to changes which reference it.  The second configuration 'bugzilla'
 will hyperlink terms such as 'bug 42' to an external bug tracker,
-supplying the argument record number '42' for display.  The third
-configuration 'tracker' uses raw HTML to more precisely control
-how the replacement is displayed to the user.
+supplying the argument record number '42' for display.
 
 commentlinks supports link:#reloadConfig[configuration reloads]. Though a
 link:cmd-flush-caches.html[flush-caches] of "projects" is needed for the
@@ -1686,18 +1740,15 @@
   link = "#/q/$1"
 
 [commentlink "bugzilla"]
-  match = "(bug\\s+#?)(\\d+)"
-  link = http://bugs.example.com/show_bug.cgi?id=$2
-
-[commentlink "tracker"]
-  match = ([Bb]ug:\\s+)(\\d+)
-  html = $1<a href=\"http://trak.example.com/$2\">$2</a>
+  match = "(^|\\s)(bug\\s+#?)(\\d+)($|\\s)"
+  link = http://bugs.example.com/show_bug.cgi?id=$3
+  prefix = $1
+  suffix = $4
+  text = $2$3
 ----
 
 Comment links can also be specified in `project.config` and sections in
-children override those in parents. The only restriction is that to
-avoid injecting arbitrary user-supplied HTML in the page, comment links
-defined in `project.config` may only supply `link`, not `html`.
+children override those in parents.
 
 [[commentlink.name.match]]commentlink.<name>.match::
 +
@@ -1722,22 +1773,32 @@
 be updated to match text formats.
 +
 A common pattern to match is `bug\\s+(\\d+)`.
++
+In order to better control the visual presentation of the link `prefix`,
+`suffix` and `text` is used. With the generated link html looking like:
+`prefix<a ...>text</a>suffix`.
 
 [[commentlink.name.link]]commentlink.<name>.link::
 +
 The URL to direct the user to whenever the regular expression is
 matched.  Groups in the match expression may be accessed as `$'n'`.
-+
-The link property is used only when the html property is not present.
 
-[[commentlink.name.html]]commentlink.<name>.html::
+[[commentlink.name.prefix]]commentlink.<name>.prefix::
 +
-HTML to replace the entire matched string with.  If present,
-this property overrides the link property above.  Groups in the
-match expression may be accessed as `$'n'`.
+The text inserted before the link. Groups in the match expression may be
+accessed as `$'n'`.
+
+[[commentlink.name.suffix]]commentlink.<name>.suffix::
 +
-The configuration file eats double quotes, so escaping them as
-`\"` is necessary to protect them from the parser.
+The text inserted after the link. Groups in the match expression may be
+accessed as `$'n'`.
+
+[[commentlink.name.text]]commentlink.<name>.text::
++
+The text content of the link. Groups in the match expression may be
+accessed as `$'n'`.
++
+If not specified defaults to `$&` (the matched text).
 
 [[commentlink.name.enabled]]commentlink.<name>.enabled::
 +
@@ -1745,11 +1806,6 @@
 section in a parent or the site-wide config that is disabled by
 specifying `enabled = true`.
 +
-Disabling sections in `gerrit.config` can be used by site administrators
-to create a library of comment links with `html` set that are not
-user-supplied and thus can be verified to be XSS-free, but are only
-enabled for a subset of projects.
-+
 By default, true.
 +
 Note that the names and contents of disabled sections are visible even
@@ -1997,6 +2053,14 @@
 +
 Default is 1 hour.
 
+[[dashboard]]
+=== Section dashboard
+
+[[dashboard.submitRequirementColumns]]dashboard.submitRequirementColumns::
++
+The list of submit requirement names that should be displayed as separate
+columns in the dashboard.
+
 [[download]]
 === Section download
 
@@ -2072,7 +2136,7 @@
 Gerrit advertises patch set downloads with the `repo download`
 command, assuming that all projects managed by this instance are
 generally worked on with the
-[repo multi-repository tool](https://gerrit.googlesource.com/git-repo).
+https://gerrit.googlesource.com/git-repo[repo multi-repository tool].
 This is not default, as not all instances will deploy repo.
 
 +
@@ -2183,6 +2247,9 @@
 other project managed by the running server. The name is
 relative to `gerrit.basePath`.
 +
+The link:#cache_names[persisted_projects cache] must be
+flushed after this setting is changed.
++
 Defaults to `All-Projects` if not set.
 
 [[gerrit.defaultBranch]]gerrit.defaultBranch::
@@ -2207,7 +2274,7 @@
 or "http://example.com:8080/gerrit/" so Gerrit can output links that point
 back to itself.
 +
-Setting this is highly recommended, as its necessary for the upload
+Setting this is highly recommended, as it is necessary for the upload
 code invoked by "git push" or "repo upload" to output hyperlinks
 to the newly uploaded changes.
 
@@ -2267,7 +2334,7 @@
 By default unset, as the HTTP daemon must be configured externally
 by the system administrator, and might not even be running on the
 same host as Gerrit.
-+
+
 [[gerrit.installBatchModule]]gerrit.installBatchModule::
 +
 Repeatable list of class name of additional Guice modules to load as
@@ -2277,7 +2344,7 @@
 located under the `/lib` directory.
 +
 By default unset.
-+
+
 [[gerrit.installDbModule]]gerrit.installDbModule::
 +
 Repeatable list of class name of additional Guice modules to load at
@@ -2288,6 +2355,25 @@
 +
 By default unset.
 
+[[gerrit.installIndexModule]]gerrit.installIndexModule::
++
+Class name of the Guice modules to load as alternate implementation
+for the Gerrit indexes backend.
+Classes are resolved using the primary Gerrit class loader, hence the
+class needs to be either declared in Gerrit or an additional JAR
+located under the `/lib` directory.
++
+NOTE: The `gerrit.installIndexModule` has precedence over the
+`index.type`.
++
+By default unset.
++
+Example:
+----
+[gerrit]
+  installIndexModule = com.google.gerrit.elasticsearch.ElasticIndexModule
+----
++
 [[gerrit.installModule]]gerrit.installModule::
 +
 Repeatable list of class name of additional Guice modules to load at
@@ -2443,6 +2529,26 @@
 by the target version of the upgrade. Refer to the release notes and check whether
 the rolling upgrade is possible or not and the associated constraints.
 
+[[gerrit.importedServerId]]gerrit.importedServerId::
++
+ServerId of the repositories imported from other Gerrit servers. Changes coming
+associated with the imported serverIds are indexed and displayed in the UI.
++
+Specify multiple `gerrit.importedServerId` for allowing the import from multiple
+Gerrit servers with different serverIds.
++
+[NOTE]
+The account-ids referenced in the imported changes are used for looking up the
+associated account-id locally, using the `imported:` external-id.
+Example: the account-id 1000 from the imported server-id 59a4964e-6376-4ed9-beef
+will be looked up in the local accounts using the `imported:1000@59a4964e-6376-4ed9-beef`
+external-id.
++
+If this value is not set, all changes imported from other Gerrit servers will be
+ignored.
++
+By default empty.
+
 [[gerrit.serverId]]gerrit.serverId::
 +
 Used by NoteDb to, amongst other things, identify author identities from
@@ -2538,8 +2644,9 @@
 set to `custom`.
 +
 Valid replacements are `${project}` for the project name in Gerrit,
-`${file}` for the file name and `${commit}` for the SHA-1 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::
 +
@@ -2633,6 +2740,39 @@
 when this parameter is removed and the system group uses the default
 name again.
 
+[[groups.relevantGroup]]groups.relevantGroup::
++
+UUID of an external group that should always be considered as relevant
+when checking whether an account is visible.
++
+This setting is only relevant for external group backends and only if
+the link:#accounts.visibility[account visibility] is set to
+`SAME_GROUP` or `VISIBLE_GROUP`.
++
+If the link:#accounts.visibility[account visibility] is set to
+`SAME_GROUP` or `VISIBLE_GROUP` users should see all accounts that are
+a member of a group that contains themselves or that is visible to
+them. Checking this would require getting all groups of the current
+user and all groups of the accounts for which the visibility is being
+checked, but since getting all groups that a user is a member of is
+expensive for external group backends Gerrit doesn't query these groups
+but instead guesses the relevant groups. Guessing relevant groups
+limits the inspected groups to all groups that are mentioned in the
+ACLs of the projects that are currently cached (i.e. all groups that
+are listed in the link:config-project-config.html#file-groups[groups]
+files of the cached projects). This is not very reliable since it
+depends on which groups are mentioned in the ACLs and which projects
+are currently cached. To make this more reliable this configuration
+parameter allows to configure external groups that should always be
+considered as relevant.
++
+As said this setting is only relevant for external group backends. In
+Gerrit core this is only the LDAP backend, but it may apply to further
+group backends that are added by plugins.
++
+This parameter may be added multiple times to specify multiple relevant
+groups.
+
 [[has-operand-alias]]
 === Section has operand alias
 
@@ -3107,23 +3247,13 @@
 
 [[index.type]]index.type::
 +
-Type of secondary indexing employed by Gerrit.  The supported
-values are:
+*(DEPRECATED)* The only supported value is `LUCENE`, which is the default,
+that means a link:http://lucene.apache.org/[Lucene]
+index is used.
 +
-* `LUCENE`
+For using other indexing backends (e.g. ElasticSearch), refer to
+`gerrit.installIndexModule` setting.
 +
-A link:http://lucene.apache.org/[Lucene] index is used.
-+
-+
-* `ELASTICSEARCH` look into link:#elasticsearch[Elasticsearch section]
-+
-An link:https://www.elastic.co/products/elasticsearch[Elasticsearch,role=external,window=_blank] index is
-used. Refer to the link:#elasticsearch[Elasticsearch section] for further
-configuration details.
-
-+
-By default, `LUCENE`.
-
 [[index.threads]]index.threads::
 +
 Number of threads to use for indexing in normal interactive operations. Setting
@@ -3136,11 +3266,20 @@
 [[index.batchThreads]]index.batchThreads::
 +
 Number of threads to use for indexing in background operations, such as
-online schema upgrades, and also for offline reindexing.
+online schema upgrades, and also the default for offline reindexing.
 +
 If not set or set to a zero, defaults to the number of logical CPUs as returned
 by the JVM. If set to a negative value, defaults to a direct executor.
 
+[[index.cacheQueryResultsByChangeNum]]index.cacheQueryResultsByChangeNum::
++
+Allow to cache and reuse the change JSON elements by their Change number.
+This improves the performance of queries that are returning Changes duplicates.
+It needs to be turned off when having Changes imported from other servers
+because of the potential conflicts of change numbers.
++
+Defaults to true.
+
 [[index.onlineUpgrade]]index.onlineUpgrade::
 +
 Whether to upgrade to new index schema versions while the server is
@@ -3153,17 +3292,31 @@
 +
 Defaults to true.
 
+[[index.paginationType]]index.paginationType::
++
+The pagination type to use when index queries are repeated to
+obtain the next set of results. Supported values are:
++
+* `OFFSET`
++
+Index queries are repeated with a non-zero offset to obtain the
+next set of results.
++
+* `SEARCH_AFTER`
++
+Index queries are repeated using a search-after object. Index
+backends can provide their custom implementations for search-after.
+Note that, `SEARCH_AFTER` does not impact using offsets in Gerrit
+query APIs.
++
+Defaults to `OFFSET`.
+
 [[index.maxLimit]]index.maxLimit::
 +
 Maximum limit to allow for search queries. Requesting results above this
 limit will truncate the list (but will still set `_more_changes` on
 result lists). Set to 0 for no limit.
 +
-When `index.type` is set to `ELASTICSEARCH`, this value should not exceed
-the `index.max_result_window` value configured on the Elasticsearch
-server. If a value is not configured during site initialization, defaults to
-10000, which is the default value of `index.max_result_window` in Elasticsearch.
-+
 When `index.type` is set to `LUCENE`, defaults to no limit.
 
 [[index.maxPages]]index.maxPages::
@@ -3176,6 +3329,44 @@
 +
 Defaults to no limit.
 
+[[index.pageSizeMultiplier]]index.pageSizeMultiplier::
++
+When index queries are repeated to obtain more results, this multiplier
+will be used to determine the limit for the next query. Using a page
+multiplier allows queries to start off small and thus provide good
+latency for queries which may end up only having very few results, and
+then scaling up to have better throughput to handle queries with larger
+result sets without incurring the overhead of making as many queries as
+would be required with a smaller limit. This strategy of using a multiplier
+attempts to create a balance between latency and throughput by dynamically
+adjusting the query size to the number of results being returned by each
+query in the pagination.
++
+The larger the multiplier, the better the throughput on large queries, and
+it also improves latency on large queries by scaling up quickly. However, a
+larger multiplier can hurt latencies a bit by making the "last" query in a
+series longer than needed. The impact of this depends on how much the backend
+latency goes up when specifying a large limit and few results are returned.
+Setting link:#index.maxPageSize[index.maxPageSize] that isn't too large, can
+likely help reduce the impacts of this.
++
+For example, if the limit of the previous query was 500 and pageSizeMultiplier
+is configured to 5, the next query will have a limit of 2500.
++
+Defaults to 1 which effectively turns this feature off.
+
+[[index.maxPageSize]]index.maxPageSize::
++
+Maximum size to allow when index queries are repeated to obtain more results. Note
+that, link:#index.maxLimit[index.maxLimit] will be used to limit page size if it
+is configured to a value lower than maxPageSize.
++
+For example, if the limit of previous query was 500, pageSizeMultiplier is
+configured to 5 and maxPageSize to 2000, the next query will have a limit of
+2000 (instead of 2500).
++
+Defaults to no limit.
+
 [[index.maxTerms]]index.maxTerms::
 +
 Maximum number of leaf terms to allow in a query. Too-large queries may
@@ -3232,7 +3423,7 @@
 +
 Whether the scheduled indexer is enabled. If the scheduled indexer is
 disabled you must implement other means to keep the group index for the
-replica up-to-date (e.g. by using ElasticSearch for the indexes).
+replica up-to-date.
 +
 Defaults to `true`.
 
@@ -3339,6 +3530,11 @@
 +
 Defaults to true (throttling enabled).
 
+During offline reindexing, setting ramBufferSize greater than the size
+of index (size of specific index folder under <site_dir>/index) and
+maxBufferedDocs as -1 avoids unnecessary flushes and triggers only a
+single flush at the end of the process.
+
 Sample Lucene index configuration:
 ----
 [index]
@@ -3360,95 +3556,6 @@
 
 ----
 
-[[elasticsearch]]
-=== Section elasticsearch
-
-WARNING: Support for Elasticsearch is still experimental and is not recommended
-for production use. For compatibility information, please refer to the
-link:https://www.gerritcodereview.com/elasticsearch.html[project homepage,role=external,window=_blank].
-
-Note that when Gerrit is configured to use Elasticsearch, the Elasticsearch
-server(s) must be reachable during the site initialization.
-
-[[elasticsearch.prefix]]elasticsearch.prefix::
-+
-This setting can be used to prefix index names to allow multiple Gerrit
-instances in a single Elasticsearch cluster. Prefix `gerrit1_` would result in a
-change index named `gerrit1_changes_0001`.
-+
-Not set by default.
-
-[[elasticsearch.server]]elasticsearch.server::
-+
-Elasticsearch server URI in the form `http[s]://hostname:port`. The `port` is
-optional and defaults to `9200` if not specified.
-+
-At least one server must be specified. May be specified multiple times to
-configure multiple Elasticsearch servers.
-+
-Note that the site initialization program only allows to configure a single
-server. To configure multiple servers the `gerrit.config` file must be edited
-manually.
-
-[[elasticsearch.numberOfShards]]elasticsearch.numberOfShards::
-+
-Sets the number of shards to use per index. Refer to the
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#_static_index_settings[
-Elasticsearch documentation,role=external,window=_blank] for details.
-+
-Defaults to 1.
-
-[[elasticsearch.numberOfReplicas]]elasticsearch.numberOfReplicas::
-+
-Sets the number of replicas to use per index. Refer to the
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#dynamic-index-settings[
-Elasticsearch documentation,role=external,window=_blank] for details.
-+
-Defaults to 1.
-
-[[elasticsearch.maxResultWindow]]elasticsearch.maxResultWindow::
-+
-Sets the maximum value of `from + size` for searches to use per index. Refer to the
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#dynamic-index-settings[
-Elasticsearch documentation,role=external,window=_blank] for details.
-+
-Defaults to 10000.
-
-[[elasticsearch.connectTimeout]]elasticsearch.connectTimeout::
-+
-Sets the timeout for connecting to elasticsearch.
-+
-Defaults to `1 second`.
-
-[[elasticsearch.socketTimeout]]elasticsearch.socketTimeout::
-+
-Sets the timeout for the underlying connection. For more information, refer to
-link:#httpd.idleTimeout[`httpd.idleTimeout`].
-+
-Defaults to `30 seconds`.
-
-==== Elasticsearch Security
-
-When security is enabled in Elasticsearch, the username and password must be provided.
-Note that the same username and password are used for all servers.
-
-For further information about Elasticsearch security, please refer to
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/security-getting-started.html[the documentation,role=external,window=_blank].
-This is the current documentation link. Select another Elasticsearch version
-from the dropdown menu available on that page if need be.
-
-[[elasticsearch.username]]elasticsearch.username::
-+
-Username used to connect to Elasticsearch.
-+
-If a password is set, defaults to `elastic`, otherwise not set by default.
-
-[[elasticsearch.password]]elasticsearch.password::
-+
-Password used to connect to Elasticsearch.
-+
-Not set by default.
-
 [[event]]
 === Section event
 
@@ -3973,6 +4080,46 @@
 +
 Defaults to true.
 
+[[metrics]]
+=== Section metrics
+
+[[metrics.reservoir]]metrics.reservoir::
++
+The type of data reservoir used by the metrics system to calculate the percentile
+values for timers and histograms.
+It can be set to one of the following values:
++
+* ExponentiallyDecaying: An exponentially-decaying random reservoir based on
+  Cormode et al's forward-decaying priority reservoir sampling method to produce
+  a statistically representative sampling reservoir, exponentially biased towards
+  newer entries.
+* SlidingTimeWindowArray: A sliding window that stores only the measurements made
+  in the last window using chunks of 512 samples.
+* SlidingTimeWindow: A sliding window that stores only the measurements made in
+  the last window using a skip list.
+* SlidingWindow: A sliding window that stores only the last measurements.
+* Uniform: A random sampling reservoir that uses Vitter's Algorithm R to produce
+  a statistically representative sample.
++
+Defaults to ExponentiallyDecaying.
+
+[[metrics.ExponentiallyDecaying.alpha]]metrics.ExponentiallyDecaying.alpha::
++
+The exponential decay factor; the higher this is, the more biased the reservoir
+will be towards newer values.
+
+[[metrics.reservoirType.size]]metrics.<reservoirType>.size::
++
+The number of samples to keep in the reservoir. Applies to all reservoir types
+except the sliding time-based ones.
++
+Defaults to 1028.
+
+[[metrics.reservoirType.window]]metrics.<reservoirType>.window::
++
+The window of time for keeping data in the reservoir. It only applies to sliding
+time-based reservoir types.
+
 [[mimetype]]
 === Section mimetype
 
@@ -4156,6 +4303,14 @@
 +
 Default is 5 seconds. Negative values will be converted to 0.
 
+[[plugins.transitionalPushOptions]]plugins.transitionalPushOptions::
++
+Additional push options which should be accepted by gerrit as valid
+options even if they are not registered by any plugin(e.g. "myplugin~foo").
++
+This config can be used when gerrit migrates from a deprecated plugin to the new one. The new plugin
+can (temporary) accept push options of the old plugin without registering such options.
+
 [[receive]]
 === Section receive
 
@@ -4194,18 +4349,6 @@
 +
 Default is 5 minutes.
 
-[[receive.changeUpdateThreads]]receive.changeUpdateThreads::
-+
-Number of threads to perform change creation or patch set updates
-concurrently. Each thread uses its own database connection from
-the database connection pool, and if all threads are busy then
-main receive thread will also perform a change creation or patch
-set update.
-+
-Defaults to 1, using only the main receive thread. This feature is for
-databases with very high latency that can benefit from concurrent
-operations when multiple changes are impacted at once.
-
 [[receive.checkMagicRefs]]receive.checkMagicRefs::
 +
 If true, Gerrit will verify the destination repository has
@@ -4447,9 +4590,8 @@
 
 [[repository.name.ownerGroup]]repository.<name>.ownerGroup::
 +
-A name of a group which exists in the database. Zero, one or many
-groups are allowed.  Each on its own line.  Groups which don't exist
-in the database are ignored.
+A name of a link:config-groups.html[group] which exists. Zero, one or many
+groups are allowed.  Each on its own line.  Groups which don't exist are ignored.
 
 [[retry]]
 === Section retry
@@ -4902,16 +5044,6 @@
   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
 
@@ -5132,22 +5264,27 @@
 +
 Supported ciphers:
 +
-* `aes128-ctr`
-* `aes192-ctr`
-* `aes256-ctr`
 * `aes128-cbc`
+* `aes128-ctr`
+* `aes128-gcm@openssh.com`
 * `aes192-cbc`
+* `aes192-ctr`
 * `aes256-cbc`
-* `blowfish-cbc`
-* `3des-cbc`
+* `aes256-ctr`
+* `aes256-gcm@openssh.com`
 * `arcfour128`
 * `arcfour256`
+* `blowfish-cbc`
+* `chacha20-poly1305@openssh.com`
+* `3des-cbc`
 * `none`
 +
-By default, all supported ciphers except `none` are available.
-+
 If your setup allows for it, it's recommended to disable all ciphers except
 the AES-CTR modes.
++
+See also link:https://github.com/apache/mina-sshd/tree/master#ciphers[ciphers,role=external,window=_blank].
++
+By default, all supported ciphers except `none` are available.
 
 [[sshd.mac]]sshd.mac::
 +
@@ -5165,6 +5302,11 @@
 * `hmac-sha1-96`
 * `hmac-sha2-256`
 * `hmac-sha2-512`
+* `hmac-sha1-etm@openssh.com`
+* `hmac-sha2-256-etm@openssh.com`
+* `hmac-sha2-512-etm@openssh.com`
++
+See also link:https://github.com/apache/mina-sshd/tree/master#macs[macs,role=external,window=_blank].
 +
 By default, all supported MACs are available.
 
@@ -5193,6 +5335,9 @@
 * `ecdh-sha2-nistp521`
 * `ecdh-sha2-nistp384`
 * `ecdh-sha2-nistp256`
+* `curve25519-sha256`
+* `curve25519-sha256@libssh.org`
+* `curve448-sha512`
 * `diffie-hellman-group-exchange-sha256`
 * `diffie-hellman-group18-sha512`
 * `diffie-hellman-group17-sha512`
@@ -5203,12 +5348,14 @@
 See link:#sshd.enableDeprecatedKexAlgorithms[sshd.enableDeprecatedKexAlgorithms]
 for deprecated key algorithms and how to enable them.
 
-By default, all supported key exchange algorithms are available.
-
 It is strongly recommended to disable at least `diffie-hellman-group1-sha1`
 as it's known to be vulnerable (logjam attack). Additionally, if your setup
 allows for it, it is recommended to disable the remaining two `sha1` key
 exchange algorithms.
+
+See also link:https://github.com/apache/mina-sshd/tree/master#key-exchange[key exchange,role=external,window=_blank].
+
+By default, all supported key exchange algorithms are available.
 --
 
 [[sshd.kerberosKeytab]]sshd.kerberosKeytab::
@@ -5288,6 +5435,7 @@
 used for suggesting accounts when adding members to a group.
 +
 By default 0.
+
 [[suggest.relevantChanges]]suggest.relevantChanges::
 +
 When suggesting reviewers, we go over recent changes of the user, and
@@ -5319,13 +5467,25 @@
 end of the request the performance events are handed over to the
 link:dev-plugins.html#performance-logger[PerformanceLogger] plugins.
 This means if performance logging is enabled, the memory footprint of
-requests is slightly increased.
+requests can be markedly increased.
+In one recorded case the impact was an overall heap increase of 40%
+(using the metrics-reporter-graphite plugin), in other instances the
+heap increase wasn't nearly as dramatic and the impact is most likely
+dependent on which plugin is used.
 +
-This setting has no effect if no
-link:dev-plugins.html#performance-logger[PerformanceLogger] plugins are
-installed, because then performance logging is always disabled.
+By default, false.
+
+[[tracing.exportPerformanceMetrics]]tracing.exportPerformanceMetrics::
 +
-By default, true.
+Whether to export performance metrics.
++
+Performace logged when link:#tracing.performanceLogging[`performanceLogging`] is
+enabled, can be exported as metrics.
++
+NOTE: Since the payload returned could be of tens of thousands metrics,
+assess the latency of the metrics endpoint before enabling this option.
++
+By default, false.
 
 [[tracing.traceid]]
 ==== Subsection tracing.<trace-id>
@@ -5526,7 +5686,7 @@
 
 [[trackingid.name.system]]trackingid.<name>.system::
 +
-The name of the external tracking system (maximum 10 characters).
+The name of the external tracking system (maximum 20 characters).
 It is possible to have several trackingid entries for the same
 tracking system.
 
@@ -5786,10 +5946,6 @@
 [auth]
   registerEmailPrivateKey = 2zHNrXE2bsoylzUqDxZp0H1cqUmjgWb6
 
-[database]
-  username = webuser
-  password = s3kr3t
-
 [ldap]
   password = l3tm3srch
 
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 0917515..4abb223 100644
--- a/Documentation/config-groups.txt
+++ b/Documentation/config-groups.txt
@@ -34,7 +34,7 @@
 group, there is a ref, stored as a sharded UUID, e.g.
 
 ----
-  refs/groups/ef/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
+  refs/groups/de/deafbeefdeafbeefdeafbeefdeafbeefdeafbeef
 ----
 
 The ref points to commits holding files. The files are
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 5889c75..ce63295 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -6,7 +6,47 @@
 link:access-control.html#category_review_labels[access controls].  Gerrit
 comes pre-configured with the Code-Review label that can be granted to
 groups within projects, enabling functionality for that group's members.
+Project owners and admins might also need to configure rules which require
+labels to be voted before a change can be submittable. See the
+link:config-submit-requirements.html[submit requirements] documentation to
+configure such rules.
 
+[[sticky_votes]]
+== Sticky Votes
+
+Whether votes are sticky when a new patch set is created depends on the
+link:#label_copyCondition[copyCondition] of the label. If an approval
+matches the configured condition it is copied from the old current
+patch set to the new current patch set. Votes that are not copied to
+the new patch set, are called `outdated`.
+
+If votes get outdated due to pushing a new patch set the uploader is
+informed about this by a message in the git output. In addition,
+outdated votes are also listed in the email notification that is sent
+for the new patch set (unless this is disabled by a custom email
+template). Note, that the uploader only get this email notification if
+they have configured `Every Comment` for `Email notifications` in their
+user preferences. With any other email preference the email sender, the
+uploader in this case, is not included in the email recipients.
+
+If votes get outdated due to creating a new patch set the user of the
+removed vote is added to the attention set of the change, as they need
+to re-review the change and renew their vote.
+
+If a vote is applied on an outdated patch set (i.e. a patch set that is
+not the current patch set) the vote is copied forward to follow-up
+patch sets if possible. A newly added or updated vote on an outdated
+patch set is copied to follow-up patch sets if:
+
+* the vote is copyable (i.e. it matches the
+link:#label_copyCondition[copyCondition] of the label)
+* neither the follow-up patch set nor an intermediate patch set has a
+  non-copied vote or a deletion vote (vote with value `0`) that
+  overrides the copy vote
+
+If an approval on an outdated patch set is removed or updated to a
+value that is not copyable, existing copies of that approval on
+follow-up patch sets are removed.
 
 [[label_Code-Review]]
 == Label: Code-Review
@@ -18,7 +58,7 @@
 
 The range of values is:
 
-* -2 This shall not be merged
+* -2 This shall not be submitted
 +
 The code is so horribly incorrect/buggy/broken that it must not be
 submitted to this project, or to this branch.  This value is valid
@@ -28,7 +68,7 @@
 +
 *Any -2 blocks submit.*
 
-* -1 I would prefer this is not merged as is
+* -1 I would prefer this is not submitted as is
 +
 The code doesn't look right, or could be done differently, but
 the reviewer is willing to live with it as-is if another reviewer
@@ -61,8 +101,8 @@
 
 For a change to be submittable, the latest patch set must have a
 `+2 Looks good to me, approved` in this category, and no
-`-2 Do not submit`.  Thus `-2` on any patch set can block a submit,
-while `+2` on the latest patch set can enable it.
+`-2 This shall not be submitted`.  Thus `-2` on any patch set can
+block a submit, while `+2` on the latest patch set can enable it.
 
 If a Gerrit installation does not wish to use this label in any project,
 the `[label "Code-Review"]` section can be deleted from `project.config`
@@ -97,7 +137,7 @@
       value = -1 Fails
       value = 0 No score
       value = +1 Verified
-      copyAllScoresIfNoCodeChange = true
+      copyCondition = changekind:NO_CODE_CHANGE
 ----
 
 The range of values is:
@@ -160,9 +200,9 @@
 properties in the child project's configuration; all properties from
 the parent definition must be redefined in the child.
 
-To remove a label in a child project, add an empty label with the same
-name as in the parent. This will override the parent label with
-a label containing the defaults (`function = MaxWithBlock`,
+To remove a label in a child project, add an empty label with a single "0"
+value, with the same name as in the parent. This will override the parent label
+with a label containing the defaults (`function = NoBlock`,
 `defaultValue = 0` and no further allowed values)
 
 [[label_layout]]
@@ -176,6 +216,11 @@
 The name for a label, consisting only of alphanumeric characters and
 `-`.
 
+[[label_description]]
+=== `label.Label-Name.description`
+
+The label description. This field can provide extra information of what the
+label is supposed to do.
 
 [[label_value]]
 === `label.Label-Name.value`
@@ -200,7 +245,21 @@
 
 
 [[label_function]]
-=== `label.Label-Name.function`
+=== `label.Label-Name.function (deprecated)`
+
+Label functions dictate the rules for requiring certain label votes before a
+change is allowed for submission. Label functions are **deprecated** and updates
+that set `function` to a blocking value {`MaxWithBlock`, `MaxNoBlock`,
+`AnyWithBlock`} will be rejected. Existing label function definitions can only
+be updated to {`NoBlock`, `NoOp`, `PatchSetLock`}. New label defintions should
+also explicitly set the `function` attribute to a non-blocking value since the
+default is `MaxWithBlock`.
+
+If your project has a
+blocking label function, we highly encourage you to change it to `NoBlock` and
+add a submit-requirement for the same label. See the
+link:config-submit-requirements.html#code-review-example[submit-requirements
+documentation] for more details.
 
 The name of a function for evaluating multiple votes for a label.  This
 function is only applied if the default submit rule is used for a label.
@@ -262,12 +321,6 @@
 
 Defaults to true.
 
-[[label_copyAnyScore]]
-=== `label.Label-Name.copyAnyScore`
-
-If true, any score for the label is copied forward when a new patch
-set is uploaded. Defaults to false.
-
 [[label_copyCondition]]
 === `label.Label-Name.copyCondition`
 
@@ -280,39 +333,164 @@
 
 Gerrit currently supports the following predicates:
 
-==== changekind:{REWORK,TRIVIAL_REBASE,MERGE_FIRST_PARENT_UPDATE,NO_CODE_CHANGE,NO_CHANGE}
+[[changekind]]
+==== changekind:{NO_CHANGE,NO_CODE_CHANGE,MERGE_FIRST_PARENT_UPDATE,REWORK,TRIVIAL_REBASE}
 
-Matches if the diff between two patch sets was of a certain change kind.
+Matches if the diff between two patch sets was of a certain change kind:
 
+* [[no_change]]`NO_CHANGE`:
++
+Matches 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 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, the Code-Review and
+the Verified labels.
++
+`NO_CHANGE` is more trivial than a trivial rebase, no code change and
+a first parent update, hence this change kind is also matched by
+`changekind:TRIVIAL_REBASE`, `changekind:NO_CODE_CHANGE` and if it's
+a merge commit by `changekind:MERGE_FIRST_PARENT_UPDATE`.
+
+
+* [[no_code_change]]`NO_CODE_CHANGE`:
++
+Matches when a new patch set is uploaded that has the same parent tree
+as the previous patch set and the same code diff (including context
+lines) as the previous patch set. This means only the commit message
+may be different; the change hasn't even been rebased. Also matches if
+the commit message is not different, which means this includes matching
+patch sets that have `NO_CHANGE` as the change kind.
++
+This predicate can be used to enable sticky approvals on labels that
+only depend on the code, reducing turn-around if only the commit
+message is changed prior to submitting a change.
++
+For the Verified label that is optionally installed by the
+link:pgm-init.html[init] site program this predicate is used by
+default.
+
+* [[merge_first_parent_update]]`MERGE_FIRST_PARENT_UPDATE`:
++
+Matches when a new patch set is uploaded that is a new merge commit
+which only differs from the merge commit in the previous patch set in
+its first parent, or has identical parents (aka the change kind of the
+merge commit is `NO_CHANGE`).
++
+The first parent of the merge commit is part of the change's target
+branch, whereas the other parent(s) refer to the feature branch(es) to
+be merged.
++
+Matching this change kind is useful if you don't want to trigger CI or
+human verification again if your target branch moved on but the feature
+branch(es) being merged into the target branch did not change.
++
+This predicate does not match if the patch set is not a merge commit.
+
+* [[trivial_rebase]]`TRIVIAL_REBASE`:
++
+Matches when a new patch set is uploaded that is a trivial rebase. A
+new patch set is considered to be trivial rebase if the commit message
+is the same as in the previous patch set and if it has the same diff
+(including context lines) as the previous patch set. This is the case
+if the change was rebased onto a different parent and that rebase did
+not require git to perform any conflict resolution, or if the parent
+did not change at all (aka the change kind of the commit is
+`NO_CHANGE`).
++
+This predicate can be used to enable sticky approvals, reducing
+turn-around for trivial rebases prior to submitting a change.
++
+For the pre-installed Code-Review label this predicate is used by
+default.
+
+* [[rework]]`REWORK`:
++
+Matches all kind of change kinds because any other change kind
+is just a more trivial version of a rework. This means setting
+`changekind:REWORK` is equivalent to setting `is:ANY`.
+
+[[is_magic]]
 ==== is:{MIN,MAX,ANY}
 
-Matches votes that are equal to the minimal or maximal voting range. Or any votes.
+Matches approvals that have a minimal, maximal or any score:
 
-==== approverin:link:rest-api-groups.html#group-id[\{group-id\}]
+* [[is_min]]`MIN`:
++
+Matches approvals that have a minimal score, i.e. the lowest possible
+(negative) value for this label.
+
+* [[is_max]]`MAX`:
++
+Matches approvals that a maximal score, i.e. the highest possible
+(positive) value for this label.
+
+* [[is_any]]`ANY`:
++
+Matches any approval when a new patch set is uploaded.
+
+[[is_value]]
+==== is:'VALUE'
+
+Matches approvals that have a voting value that is equal to 'VALUE'.
+
+Negative values need to be quoted, e.g.: is:"-1"
+
+[[approverin]]
+==== approverin:link:#group-id[\{group-id\}]
 
 Matches votes granted by a user who is a member of
-link:rest-api-groups.html#group-id[\{group-id\}].
+link:#group-id[\{group-id\}].
 
-Avoid using a group name with spaces (if it has spaces, use the group uuid).
-Although supported for convenience, it's better to use group uuid than group
-name since using names only works as long as the names are unique (and future
-groups with the same name will break the query).
+[[uploaderin]]
+==== uploaderin:link:#group-id[\{group-id\}]
 
-==== uploaderin:link:rest-api-groups.html#group-id[\{group-id\}]
+Matches all votes if the new patch set was uploaded by a member of
+link:#group-id[\{group-id\}].
 
-Matches votes where the new patch set was uploaded by a member of
-link:rest-api-groups.html#group-id[\{group-id\}].
-
-Avoid using a group name with spaces (if it has spaces, use the group uuid).
-Although supported for convenience, it's better to use group uuid than group
-name since using names only works as long as the names are unique (and future
-groups with the same name will break the query).
-
+[[has_unchanged_files]]
 ==== has:unchanged-files
 
-Matches when the new patch-set includes the same files as the old patch-set.
+Matches when the new patch-set has the same list of files as the
+previous patch-set.
 
-Only 'unchanged-files' is supported for 'has'.
+Votes are not copied in the following cases:
+
+  * If one more files are renamed in the new patch set. These files are counted
+  as a deletion of the file at the old path and an addition of the file at the
+  new path. This means the list of files did change.
+  * If one or more files are reverted to their original content, that is files
+  that become same as in the base revision.
+
+This predicate is useful if you don't want to trigger CI or human
+verification again if the list of files didn't change.
+
+Note, "unchanged-files" is the only value that is supported for the
+"has" operator.
+
+[[group-id]]
+==== Group ID
+
+Some predicates (link:#approverin[approverin], link:#uploaderin[uploaderin])
+expect a group ID as value. This group ID can be any of the
+link:rest-api-groups.html#group-id[group identifiers] that are supported in the
+REST API: group UUID, group ID (for Gerrit internal groups only) and group name
+
+It's preferred to reference groups by UUID, rather than name. Referencing
+groups by name is not recommended because:
+
+* Groups may be renamed and then the group reference can no longer be resolved.
+  If this happens another group with different members can take over the group
+  name, so that exemptions which have been granted by this predicate apply to
+  the other group. This is a security concern.
+* Group names that contain spaces are not supported.
+* Ambiguous group names cannot be resolved. This means if another group with
+  the same name gets created at a later point in time, the group name can no
+  longer be resolved and the predicate breaks.
+
+Using the group UUID has a small drawback though, since it makes the condition
+less human-readable.
 
 ==== Example
 
@@ -320,105 +498,6 @@
 copyCondition = is:MIN OR -change-kind:REWORK OR uploaderin:dead...beef
 ----
 
-[[label_copyMinScore]]
-=== `label.Label-Name.copyMinScore`
-
-If true, the lowest possible negative value for the label is copied
-forward when a new patch set is uploaded. Defaults to false, except
-for All-Projects which has it true by default.
-
-[[label_copyMaxScore]]
-=== `label.Label-Name.copyMaxScore`
-
-If true, the highest possible positive value for the label is copied
-forward when a new patch set is uploaded. This can be used to enable
-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`
-
-This policy is useful if you don't want to trigger CI or human
-verification again if your target branch moved on but the feature
-branch being merged into the target branch did not change. It only
-applies if the patch set is a merge commit.
-
-If true, all scores for the label are copied forward when a new
-patch set is uploaded that is a new merge commit which only
-differs from the previous patch set in its first parent, or has
-identical parents. The first parent would be the parent of the merge
-commit that is part of the change's target branch, whereas the other
-parent(s) refer to the feature branch(es) to be merged.
-
-Defaults to false.
-
-[[label_copyAllScoresOnTrivialRebase]]
-=== `label.Label-Name.copyAllScoresOnTrivialRebase`
-
-If true, all scores for the label are copied forward when a new patch set is
-uploaded that is a trivial rebase. A new patch set is considered to be trivial
-rebase if the commit message is the same as in the previous patch set and if it
-has the same diff (including context lines) as the previous patch set. This is
-the case if the change was rebased onto a different parent and that rebase did
-not require git to perform any conflict resolution, or if the parent did not
-change at all.
-
-This can be used to enable sticky approvals, reducing turn-around for
-trivial rebases prior to submitting a change.
-For the pre-installed Code-Review label this is enabled by default.
-
-Defaults to false.
-
-[[label_copyAllScoresIfNoCodeChange]]
-=== `label.Label-Name.copyAllScoresIfNoCodeChange`
-
-If true, all scores for the label are copied forward when a new patch set is
-uploaded that has the same parent tree as the previous patch set and the same
-code diff (including context lines) as the previous patch set. This means only
-the commit message is different; the change hasn't even been rebased. This can
-be used to enable sticky approvals on labels that only depend on the code,
-reducing turn-around if only the commit message is changed prior to submitting a
-change. For the Verified label that is optionally installed by the
-link:pgm-init.html[init] site program this is enabled by default.
-
-Defaults to false.
-
-[[label_copyAllScoresIfNoChange]]
-=== `label.Label-Name.copyAllScoresIfNoChange`
-
-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 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.
-
-Defaults to true.
-
-[[label_copyValue]]
-=== `label.Label-Name.copyValue`
-
-Value that should be copied forward when a new patch set is uploaded.
-This can be used to enable sticky votes. Can be specified multiple
-times. By default not set.
-
 [[label_canOverride]]
 === `label.Label-Name.canOverride`
 
@@ -457,7 +536,7 @@
 `${shardeduserid}`.
 
 [[label_ignoreSelfApproval]]
-=== `label.Label-Name.ignoreSelfApproval`
+=== `label.Label-Name.ignoreSelfApproval (deprecated)`
 
 If true, the label may be voted on by the uploader of the latest patch set,
 but their approval does not make a change submittable. Instead, a
@@ -465,6 +544,13 @@
 
 Defaults to false.
 
+The `ignoreSelfApproval` attribute is **deprecated**, favour
+using link:config-submit-requirements.html[submit requirements] and
+define the `submittableIf` expression with the `label` operator and
+the `user=non_uploader` argument. See the
+link:config-submit-requirements.html#code-review-example[Code Review] submit
+requirement example.
+
 [[label_example]]
 === Example
 
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 85006dc..8bd5dc7 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -20,6 +20,11 @@
 example template to an equivalently named file without the `.example` extension
 and modifying it will allow an administrator to customize the template.
 
+[NOTE]
+The content of the templates at `'$site_path'/etc/mail/.*\.soy` are cached at
+startup by Gerrit. If they are modified Gerrit needs to be restarted before the
+changes takes effect.
+
 == Supported Mail Templates
 
 Each mail that Gerrit sends out is controlled by at least one template.  These
@@ -221,6 +226,10 @@
 The original subject limited to 72 characters, with an ellipsis if it exceeds
 that.
 
+$change.sizeBucket::
++
+Human-readable size bucket of the current change.
+
 $change.ownerEmail::
 +
 The email address of the owner of the change.
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 4dff685..31008f6 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -9,6 +9,7 @@
 relevant in an automation scenario of the access controls.
 
 
+[[refs-meta-config]]
 == The +refs/meta/config+ namespace
 
 The namespace contains three different files that play different
@@ -142,7 +143,7 @@
 [[receive.requireContributorAgreement]]receive.requireContributorAgreement::
 +
 Controls whether or not a user must complete a contributor agreement before
-they can upload changes. Default is `INHERIT`. If `All-Project` enables this
+they can upload changes. Default is `INHERIT`. If `All-Projects` enables this
 option then the dependent project must set it to false if users are not
 required to sign a contributor agreement prior to submitting changes for that
 specific project. To use that feature the global option in `gerrit.config`
@@ -318,17 +319,306 @@
 
 [[content_merge]]submit.mergeContent::
 +
-Defines whether Gerrit will try to
-do a content merge when a path conflict occurs. Valid values are
-'true', 'false', or 'INHERIT'.  Default is 'INHERIT'. This option can
-be modified by any project owner through the project console, `Browse`
-> `Repositories` > my/project > `Allow content merges`.
+Defines whether Gerrit will try to do a content merge when a path conflict
+occurs while submitting a change.
++
+A path conflict occurs when the same file has been changed on both sides of a
+merge, e.g. when the same file has been touched in a change and concurrently in
+the target branch.
++
+Doing a content merge means that Gerrit attempts to merge the conflicting file
+contents from both sides of the merge. This is successful if the touched lines
+(plus some surrounding context lines) do not overlap (i.e. both sides touch
+distinct lines).
++
+NOTE: The content merge setting is not relevant when
+link:#fast_forward_only[fast forward only] is configured as the
+link:#submit.action[submit action] because in this case Gerrit will never
+perform a merge, rebase or cherry-pick on submit.
++
+If content merges are disabled, the submit button in the Gerrit web UI is
+disabled, if any path conflict would occur on submitting the change. Users then
+need to rebase the change manually to resolve the path conflict and then get
+the change re-approved so that they can submit it.
++
+NOTE: If only distinct lines have been touched on both sides, rebasing the
+change from the Gerrit UI is sufficient to resolve the path conflict, since the
+rebase action always does the rebase with content merge enabled.
++
+The advantage of enabling content merges on submit is that it makes it less
+likely that change submissions are rejected due to conflicts. Each change
+submission that goes through with content merge, but would be rejected
+otherwise, saves the user from needing to do extra work to get the change
+submitted (rebase the change, get it re-approved and then submit it again).
++
+On the other hand, disabling content merges decreases the chance of breaking
+branches by submitting content merges of incompatible modifications in the same
+file, e.g. a function is removed on one side and a new usage of that function
+is added on the other side. Note, that the chance of breaking a branch by
+incompatible modifications is only reduced, but not eliminated, e.g. even with
+content merges disabled it's possible that a function is removed in one file
+and a new usage of that function is added in another file.
++
+The huge drawback of disabling content merge is that users need to do extra
+work when a change isn't submittable due to a path conflict which could be
+avoided if content merge was enabled (see above). In addition to this, it also
+confuses and frustrates users if a change submission is rejected by Gerrit due
+to a path conflict, but then when they rebase the change manually they do not
+see any conflict (because manual rebases are always done with content merge
+enabled).
++
+In general, the stability gain of disabling content merges is not worth the
+overhead and confusion that this adds for users, which is why disabling content
+merges is highly discouraged.
++
+Valid values are `true`, `false`, or `INHERIT`.
++
+The default is `INHERIT`.
++
+NOTE: Project owners can also set this option in the Gerrit UI:
+`Browse` > `Repositories` > my/repository > `Allow content merges`.
 
 [[submit.action]]submit.action::
 +
-Defines the link:#submit-type[submit type].  Valid
-values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
-'rebase always', 'merge always' and 'cherry pick'.  The default is 'merge if necessary'.
+Defines the submit action aka submit type aka submit strategy that Gerrit
+should use to integrate changes into the target branch when they are submitted.
++
+In general, submitting a change only merges the change if all its dependencies
+are also submitted. The only exception is the `cherry pick` submit action which
+ignores dependencies and hence is not recommended to be used (see
+link:#cherry_pick[below]).
++
+[[submit-type]]
+--
+The following submit actions are supported:
+
+[[merge_if_necessary]]
+* 'merge if necessary':
++
+With this action, when a change is being submitted, Gerrit fast-forwards the
+target branch if possible, and otherwise creates a merge commit automatically.
++
+A fast-forward is possible if the commit that represents the current patch set
+of the change has the current head of the target branch in its parent lineage.
++
+If a fast-forward is not possible, Gerrit automatically creates a merge commit
+that merges the current patch set of the change into the target branch and then
+the target branch is fast-forwarded to the merge commit.
++
+The behavior of this submit action is identical with the classical `git merge`
+behavior, or
+link:https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---ff[git
+merge --ff].
++
+With this submit action the commits that have been reviewed and approved are
+retained in the git history of the target branch. This means, by looking at the
+history of the target branch, you can see for all commits when they were
+originally committed and on which parent commit they were originally based.
+
+[[always_merge]]
+[[merge_always]]
+* 'merge always':
++
+With this action, when a change is being submitted, Gerrit always creates a
+merge commit, even if a fast-forward is possible.
++
+This is identical to the behavior of
+link:https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---no-ff[git merge --no-ff].
++
+With this submit action the commits that have been reviewed and approved are
+retained in the git history of the target branch. This means, by looking at the
+history of the target branch, you can see for all commits when they were
+originally committed and on which parent commit they were originally based. In
+addition, from the merge commits you can see when the changes were submitted
+and it's possible to follow submissions with `git log --first-parent`.
+
+[[rebase_if_necessary]]
+* 'rebase if necessary':
++
+With this action, when a change is being submitted, Gerrit fast-forwards the
+target branch if possible, and otherwise does a rebase automatically.
++
+A fast-forward is possible if the commit that represents the current patch set
+of the change has the current head of the target branch in its parent lineage.
++
+If a fast-forward is not possible, Gerrit automatically rebases the current
+patch set of the change on top of the current head of the target branch and
+then the target branch is fast-forwarded to the rebased commit.
++
+With this submit action, when a rebase is performed, the original commits that
+have been reviewed and approved do not become part of the target branch's
+history. This means the information on when the original commits were committed
+and on which parent they were based is not retained in the branch history.
++
+Using this submit action results in a linear history of the target branch,
+unless merge commits are being submitted. For some people this is an advantage
+since they find the linear history easier to read.
++
+NOTE: Rebasing merge commits is not supported. If a change with a merge commit
+is submitted the link:#merge_if_necessary[merge if necessary] submit action is
+applied.
++
+When rebasing the patchset, Gerrit automatically appends onto the end of the
+commit message a short summary of the change's approvals, and a URL link back
+to the change in the web UI (see link:#submit-footers[below]). If a fast-forward
+is done no footers are added.
+
+[[rebase_always]]
+* 'rebase always':
++
+With this action, when a change is being submitted, Gerrit always does a
+rebase, even if a fast-forward is possible.
++
+With this submit action, the original commits that have been reviewed and
+approved do not become part of the target branch's history. This means the
+information on when the original commits were committed and on which parent
+they were based is not retained in the branch history.
++
+Using this submit action results in a linear history of the target branch,
+unless merge commits are being submitted. For some people this is an advantage
+since they find the linear history easier to read.
++
+NOTE: Rebasing merge commits is not supported. If a change with a merge commit
+is submitted the link:#merge_if_necessary[merge if necessary] submit action is
+applied.
++
+When rebasing the patchset, Gerrit automatically appends onto the end of the
+commit message a short summary of the change's approvals, and a URL link back
+to the change in the web UI (see link:#submit-footers[below]).
++
+The footers that are added are exactly the same footers that are also added by
+the link:cherry_pick[cherry pick] action. Thus, the `rebase always` action can
+be considered similar to the `cherry pick` action, but with the important
+distinction that `rebase always` does not ignore dependencies, which is why
+using the `rebase always` action should be preferred over the `cherry pick`
+submit action.
+
+[[fast_forward_only]]
+* 'fast forward only' (usage generally not recommended):
++
+With this action a change can only be submitted if at submit time the target
+branch can be fast-forwarded to the commit that represents the current patch
+set of the change. This means in order for a change to be submittable its
+current patch set must have the current head of the target branch in its parent
+lineage.
++
+The advantage of using this action is that the target branch is always updated
+to the exact commit that has been reviewed and approved. In particular, if CI
+verification is configured, this means that the CI verified the exact commit to
+which the target branch is being fast-forwarded on submit (assuming no approval
+copying is configured for CI votes).
++
+The huge drawback of using this action is that whenever one change is submitted
+all other open changes for the same branch, that are not successors of the
+submitted change, become non-submittable, since the target branch can no longer
+be fast-forwarded to their current patch sets. Making these changes submittable
+again requires rebasing and re-approving/re-verifying them. For most projects
+this causes an unreasonable amount of overhead that doesn't justify the
+stability gain by verifying exact commits so that using this submit action is
+generally discouraged. Using this action should only be considered for projects
+that have a low frequency of changes and that have special requirements to
+never break any target branch.
++
+NOTE: With this submit action Gerrit does not create merge commits on
+submitting a change, but merge commits that are created on the client, prior to
+uploading to Gerrit for review, may still be submitted.
+
+[[cherry_pick]]
+* 'cherry pick' (usage not recommended, use link:#rebase_always[rebase always]
+instead):
++
+With this submit action Gerrit always performs a cherry pick of the current
+patch set when a change is submitted. This ignores the parent lineage and
+instead creates a brand new commit on top of the current head of the target
+branch. The submitter becomes the committer of the new commit and the original
+commit author is retained.
++
+Ignoring change dependencies on submit is often confusing for users. For users
+that stack changes on top of each other, it's unexpected that these
+dependencies are ignored on submit. Ignoring dependencies also means that
+submitters need to submit the changes individually in the correct order.
+Otherwise it's likely that submissions fail due to conflicts or that the
+target branch gets broken (because it contains the submitted change, but not
+its predecessors which may be required for the submitted change to work
+correctly).
++
+If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+is enabled changes that have the same topic are submitted together, the same as
+with all other submit actions. This means by setting the same topic on all
+dependent changes it's possible to submit a stack of changes together and
+overcome the limitation that change dependencies are ignored.
++
+When cherry picking the patchset, Gerrit automatically appends onto the end of
+the commit message a short summary of the change's approvals, and a URL link
+back to the change in the web UI (see link:#submit-footers[below]).
++
+Using this submit action is not recommended because it ignores change
+dependencies, instead link:#rebase_always[rebase always] should be used which
+behaves the same way except that it respects change dependencies (in particular
+`rebase always` adds the same kind of footers to the merged commit as
+`cherry pick`).
+
+--
++
+[[submit_type_inherit]]
+In addition the submit action can be set to `Inherit`, which means that the
+value that is configured in the parent project applies. For new projects
+`Inherit` is the default, unless the default is overridden by the global
+link:config-gerrit.html#repository.name.defaultSubmitType[defaultSubmitType]
+configuration. Configuring `Inherit` for the `All-Projects` root project is
+equivalent to configuring link:#merge_if_necessary[merge if necessary].
++
+If `submit.action` is not set, the default is 'merge if necessary'.
++
+NOTE: The different submit actions are also described in the
+link:https://docs.google.com/presentation/d/1C73UgQdzZDw0gzpaEqIC6SPujZJhqamyqO1XOHjH-uk/edit#slide=id.g4d6c16487b_1_800[Gerrit - Concepts and Workflows]
+presentation, where their behavior is visualized by git commit graphs.
++
+NOTE: If Gerrit performs a merge, rebase or cherry-pick as part of the
+change submission (true for all submit actions, except for
+link:#fast_forward_only[fast forward only]), it is controlled by the
+link:#submit.mergeContent[mergeContent] setting whether a content merge is
+performed when there is a path conflict.
++
+NOTE: If Gerrit performs a merge, rebase or cherry-pick as part of the
+change submission (true for all submit actions, except for
+link:#fast_forward_only[fast forward only]), it can be that trying to submit
+a change would fail due to Git conflicts (if the same lines were modified
+concurrently, or if link:#submit.mergeContent[mergeContent] is disabled also if
+the same files were modified concurrently). In this case the submit button in
+the Gerrit web UI is disabled and a tooltip on the disabled submit button
+informs about the change being non-mergeable.
++
+[[submit-footers]]
+--
+NOTE: If Gerrit performs a rebase or cherry-pick as part of the change
+submission (true for link:#rebase_if_necessary[rebase if necessary],
+link:#rebase_always[rebase always] and link:#cherry_pick[cherry pick]) Gerrit
+inserts additional footers into the commit message of the newly created
+commit: +
+ +
+* `Change-Id: <change-id>` (only if this footer is not already present, see
+  link:user-changeid.html[Change-Id]) +
+* `Reviewed-on: <change-url>` (links to the change in Gerrit where this commit
+  was reviewed) +
+* `Reviewed-by: <reviewer>` (once for every reviewer with a positive
+  `Code-Review` vote) +
+* `Tested-by: <reviewer>` (once for every reviewer with a positive `Verified`
+  vote) +
+* `<label-name>: <reviewer>` (once for every reviewer with a positive vote on
+  any other label) +
+ +
+In addition, plugins that implement a
+link:dev-plugins.html#change-message-modifier[Change Message Modifier] may add
+additional custom footers.
+--
++
+NOTE: For the value of `submit.action` in `project.config` use the exact
+spelling as given above, e.g. 'merge if necessary' (without the single quotes,
+but with the spaces).
++
+NOTE: Project owners can also set the submit action in the Gerrit UI:
+`Browse` > `Repositories` > my/repository > `Submit type`
 
 [[submit.matchAuthorToCommitterDate]]submit.matchAuthorToCommitterDate::
 +
@@ -346,98 +636,6 @@
 is set to 'true' the merge would fail in such a case. An empty commit is still allowed as the
 initial commit on a branch.
 
-[[submit-type]]
-==== Submit Type
-
-'submit.action': The method Gerrit uses to submit a change to a project.
-
-The submit type can also be modified by any project owner through the
-project console, `Browse` > `Repositories` > my/project > 'Submit type'.
-In general, a submitting a change only merges the change if all its
-dependencies are also submitted, with exceptions documented below.
-
-The following submit types are supported:
-
-[[submit_type_inherit]]
-* Inherit
-+
-This is the default for new projects, unless overridden by a global
-link:config-gerrit.html#repository.name.defaultSubmitType[`defaultSubmitType` option].
-+
-Inherit the submit type from the parent project. In `All-Projects`, this
-is equivalent to link:#merge_if_necessary[Merge If Necessary].
-
-[[fast_forward_only]]
-* Fast Forward Only
-+
-With this method Gerrit does not create merge commits on submitting a
-change. Merge commits may still be submitted, but they must be created
-on the client prior to uploading to Gerrit for review.
-+
-To submit a change, the change must be a strict superset of the
-destination branch.  That is, the change must already contain the
-tip of the destination branch at submit time.
-
-[[merge_if_necessary]]
-* Merge If Necessary
-+
-If the change being submitted is a strict superset of the destination
-branch, then the branch is fast-forwarded to the change.  If not,
-then a merge commit is automatically created.  This is identical
-to the classical `git merge` behavior, or `git merge --ff`.
-
-[[always_merge]]
-* Always Merge
-+
-Always produce a merge commit, even if the change is a strict
-superset of the destination branch.  This is identical to the
-behavior of `git merge --no-ff`, and may be useful if the
-project needs to follow submits with `git log --first-parent`.
-
-[[cherry_pick]]
-* Cherry Pick
-+
-Always cherry pick the patch set, ignoring the parent lineage
-and instead creating a brand new commit on top of the current
-branch head.
-+
-When cherry picking a change, Gerrit automatically appends onto the
-end of the commit message a short summary of the change's approvals,
-and a URL link back to the change on the web.  The committer header
-is also set to the submitter, while the author header retains the
-original patch set author.
-+
-Note that Gerrit ignores dependencies between changes when using this
-submit type unless
-link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
-is enabled and depending changes share the same topic. So generally
-submitters must remember to submit changes in the right order when using this
-submit type. If all you want is extra information in the commit message,
-consider using the Rebase Always submit strategy.
-
-[[rebase_if_necessary]]
-* Rebase If Necessary
-+
-If the change being submitted is a strict superset of the destination
-branch, then the branch is fast-forwarded to the change.  If not,
-then the change is automatically rebased and then the branch is
-fast-forwarded to the change.
-+
-When Gerrit tries to do a merge, by default the merge will only
-succeed if there is no path conflict.  A path conflict occurs when
-the same file has also been changed on the other side of the merge.
-
-[[rebase_always]]
-* Rebase Always
-+
-Basically, the same as Rebase If Necessary, but it creates a new patchset even
-if fast forward is possible AND like Cherry Pick it ensures footers such as
-Change-Id, Reviewed-On, and others are present in resulting commit that is
-merged.
-+
-Thus, Rebase Always can be considered similar to Cherry Pick, but with
-the important distinction that Rebase Always does not ignore dependencies.
-
 
 [[access-section]]
 === Access section
@@ -481,6 +679,12 @@
 
 Please refer to link:config-labels.html#label_custom[Custom Labels] documentation.
 
+[[submit-requirement-section]]
+=== Submit Requirement section
+
+Please refer to link:config-submit-requirements.html[Configuring Submit
+Requirements] documentation.
+
 [[branchOrder-section]]
 === branchOrder section
 
@@ -526,7 +730,16 @@
 A boolean indicating if reviewers and CCs that do not currently have a Gerrit
 account can be added to a change by providing their email address.
 +
-This setting only takes affect for changes that are readable by anonymous users.
+This setting only takes effect for changes that are readable by anonymous users.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project. If the property is not set in any parent project, the
+default value is `FALSE`.
+
+[[reviewer.skipAddingAuthorAndCommitterAsReviewers]]reviewer.skipAddingAuthorAndCommitterAsReviewers::
++
+Whether to skip adding the Git commit author and committer as reviewers for
+a new change.
 +
 Default is `INHERIT`, which means that this property is inherited from
 the parent project. If the property is not set in any parent project, the
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
new file mode 100644
index 0000000..fb37287
--- /dev/null
+++ b/Documentation/config-submit-requirements.txt
@@ -0,0 +1,347 @@
+= Gerrit Code Review - Submit Requirements
+
+As part of the code review process, project owners need to configure rules that
+govern when changes become submittable. For example, an admin might want to
+prevent changes from being submittable until at least a “+2” vote on the
+“Code-Review” label is granted on a change. Admins can define submit
+requirements to enforce submittability rules for changes.
+
+[[configuring_submit_requirements]]
+== Configuring Submit Requirements
+
+Site administrators and project owners can define submit requirements in the
+link:config-project-config.html[project config]. A submit requirement has the
+following fields:
+
+
+[[submit_requirement_name]]
+=== submit-requirement.Name
+
+A name that uniquely identifies the submit requirement. Submit requirements
+can be overridden in child projects if they are defined with the same name in
+the child project. See the link:#inheritance[inheritance] section for more
+details.
+
+[[submit_requirement_description]]
+=== submit-requirement.Name.description
+
+A detailed description of what the submit requirement is supposed to do. This
+field is optional. The description is visible to the users in the change page
+upon hovering on the submit requirement to help them understand what the
+requirement is about and how it can be fulfilled.
+
+[[submit_requirement_applicable_if]]
+=== submit-requirement.Name.applicableIf
+
+A link:#query_expression_syntax[query expression] that determines if the submit
+requirement is applicable for a change. For example, administrators can exclude
+submit requirements for certain branch patterns. See the
+link:#exempt-branch-example[exempt branch] example.
+
+Often submit requirements should only apply to branches that contain source
+code. In this case this parameter can be used to exclude the
+link:config-project-config.html#refs-meta-config[refs/meta/config] branch from
+a submit requirement:
+
+----
+  applicableIf = -branch:refs/meta/config
+----
+
+This field is optional, and if not specified, the submit requirement is
+considered applicable for all changes in the project.
+
+[[submit_requirement_submittable_if]]
+=== submit-requirement.Name.submittableIf
+
+A link:#query_expression_syntax[query expression] that determines when the
+change becomes submittable. This field is mandatory.
+
+
+[[submit_requirement_override_if]]
+=== submit-requirement.Name.overrideIf
+
+A link:#query_expression_syntax[query expression] that controls when the
+submit requirement is overridden. When this expression is evaluated to true,
+the submit requirement state becomes `OVERRIDDEN` and the submit requirement
+is no longer blocking the change submission.
+This expression can be used to enable bypassing the requirement in some
+circumstances, for example if the change owner is a power user or to allow
+change submission in case of emergencies. +
+
+This field is optional.
+
+[[submit_requirement_can_override_in_child_projects]]
+=== submit-requirement.Name.canOverrideInChildProjects
+
+A boolean (true, false) that determines if child projects can override the
+submit requirement. +
+
+The default value is `false`.
+
+[[evaluation_results]]
+== Evaluation Results
+
+When submit requirements are configured, their results are returned for all
+changes requested by the REST API with the
+link:rest-api-changes.html#submit-requirement-result-info[SubmitRequirementResultInfo]
+entity. +
+
+Submit requirement results are produced from the evaluation of the submit
+requirements in the project config (
+See link:#configuring_submit_requirements[Configuring Submit Requirements])
+as well as the conversion of the results of the legacy submit rules to submit
+requirement results. Legacy submit rules are label functions
+(see link:config-labels.html[config labels]), custom and
+link:prolog-cookbook.html[prolog] submit rules.
+
+The `status` field can be one of:
+
+* `NOT_APPLICABLE`
++
+The link:#submit_requirement_applicable_if[applicableIf] expression evaluates
+to false for the change.
+
+* `UNSATISFIED`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true), but
+the evaluation of the link:#submit_requirement_submittable_if[submittableIf] and
+link:#submit_requirement_override_if[overrideIf] expressions return false for
+the change.
+
+* `SATISFIED`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true), the
+link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
+true, and the link:#submit_requirement_override_if[overrideIf] evaluates to
+false for the change.
+
+* `OVERRIDDEN`
++
+The submit requirement is applicable
+(link:#submit_requirement_applicable_if[applicableIf] evaluates to true) and the
+link:#submit_requirement_override_if[overrideIf] expression evaluates to true.
++
+Note that in this case, the change is overridden whether the
+link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
+true or not.
+
+* `BYPASSED`
++
+The change was merged directly bypassing code review by supplying the
+link:user-upload.html#auto_merge[submit] push option while doing a git push.
+
+* `ERROR`
++
+The evaluation of any of the
+link:#submit_requirement_applicable_if[applicableIf],
+link:#submit_requirement_submittable_if[submittableIf] or
+link:#submit_requirement_override_if[overrideIf] expressions resulted in an
+error.
+
+
+[[query_expression_syntax]]
+== Query Expression Syntax
+
+All applicableIf, submittableIf and overrideIf expressions use the same syntax
+and operators available for link:user-search.html[searching changes]. In
+addition to that, submit requirements support extra operators.
+
+
+[[submit_requirements_operators]]
+=== Submit Requirements Operators
+
+[[operator_authoremail]]
+authoremail:'EMAIL_PATTERN'::
++
+An operator that returns true if the change author's email address matches a
+specific regular expression pattern. The
+link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns.
+
+[[operator_distinctvoters]]
+distinctvoters:'[Label1,Label2,...,LabelN],value=MAX,count>1'::
++
+An operator that allows checking for distinct voters across more than one label.
++
+2..N labels are supported, filtering by a value (MIN,MAX,integer) is optional.
+Count is mandatory.
++
+Examples:
+`distinctvoters:[Code-Review,Trust],value=MAX,count>1`
++
+`distinctvoters:[Code-Review,Trust,API-Review],count>2`
+
+[[operator_is_true]]
+is:true::
++
+An operator that always returns true for all changes. An example usage is to
+redefine a submit requirement in a child project and make the submit requirement
+always applicable.
+
+[[operator_is_false]]
+is:false::
++
+An operator that always returns false for all changes. An example usage is to
+redefine a submit requirement in a child project and make the submit requirement
+always non-applicable.
+
+[[operator_has_submodule_update]]
+has:submodule-update::
++
+An operator that returns true if the diff of the latest patchset against the
+default parent has a submodule modified file, that is, a ".gitmodules" or a
+git link file.
++
+The optional `base` parameter can also be supplied for merge commits like
+`has:submodule-update,base=1`, or `has:submodule-update,base=2`. In these cases,
+the operator returns true if the diff of the latest patchset against parent
+number identified by `base` has a submodule modified file. Note that the
+operator will return false if the base parameter is greater than the number of
+parents for the latest patchset for the change.
+
+[[operator_file]]
+file:"'<filePattern>',withDiffContaining='<contentPattern>'"::
++
+An operator that returns true if the latest patchset contained a modified file
+matching `<filePattern>` with a modified region matching `<contentPattern>`.
+
+[[unsupported_operators]]
+=== Unsupported Operators
+
+Some operators are not supported with submit requirement expressions.
+
+[[operator_is_submittable]]
+is:submittable::
++
+Cannot be used since it will result in recursive evaluation of expressions.
+
+[[inheritance]]
+== Inheritance
+
+Child projects can override a submit requirement defined in any of their parent
+projects. Overriding a submit requirement overrides all of its properties and
+values. The overriding project needs to define all mandatory fields.
+
+Submit requirements are looked up from the current project up the inheritance
+hierarchy to “All-Projects”. The first project in the hierarchy chain that sets
+link:#submit_requirement_can_override_in_child_projects[canOverrideInChildProjects]
+to false prevents all descendant projects from overriding it.
+
+If a project disallows a submit requirement from being overridden in child
+projects, all definitions of this submit requirement in descendant projects are
+ignored.
+
+To remove a submit requirement in a child project, administrators can redefine
+the requirement with the same name in the child project and set the
+link:#submit_requirement_applicable_if[applicableIf] expression to `is:false`.
+Since the link:#submit_requirement_submittable_if[submittableIf] field is
+mandatory, administrators need to provide it in the child project but can set it
+to anything, for example `is:false` but it will have no effect anyway.
+
+
+[[trigger-votes]]
+== Trigger Votes
+
+Trigger votes are label votes that are not associated with any submit
+requirement expressions. Trigger votes are displayed in a separate section in
+the change page. For more about configuring labels, see the
+link:config-labels.html[config labels] documentation.
+
+
+[[examples]]
+== Examples
+
+[[code-review-example]]
+=== Code-Review Example
+
+To define a submit requirement for code-review that requires a maximum vote for
+the “Code-Review” label from a non-uploader without a maximum negative vote:
+
+----
+[submit-requirement "Code-Review"]
+	description = A maximum vote from a non-uploader is required for the \
+	              'Code-Review' label. A minimum vote is blocking.
+	submittableIf = label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN
+	canOverrideInChildProjects = true
+----
+
+[[exempt-branch-example]]
+=== Exempt a branch Example
+
+We could exempt a submit requirement from certain branches. For example,
+project owners might want to skip the 'Code-Style' requirement from the
+refs/meta/config branch.
+
+----
+[submit-requirement "Code-Style"]
+  description = Code is properly styled and formatted
+  applicableIf = -branch:refs/meta/config
+  submittableIf = label:Code-Style=+1 AND -label:Code-Style=-1
+  canOverrideInChildProjects = true
+----
+
+Branch configuration supports regular expressions as well, e.g. to exempt 'refs/heads/release/*' pattern,
+when migrating from the label Submit-Rule:
+
+----
+[label "Verified"]
+  branch = refs/heads/release/*
+----
+
+The following SR can be configured:
+
+----
+[submit-requirement "Verified"]
+  submittableIf = label:Verified=MAX AND -label:Verified=MIN
+  applicableIf = branch:^refs/heads/release/.*
+----
+
+
+[[test-submit-requirements]]
+== Testing Submit Requirements
+
+The link:rest-api-changes.html#check-submit-requirement[Check Submit Requirement]
+change endpoint can be used to test submit requirements on any change. Users
+are encouraged to test submit requirements before adding them to the project
+to ensure they work as intended.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/check.submit_requirement HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+    {
+      "name": "Code-Review",
+      "submittability_expression": "label:Code-Review=+2"
+    }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "status": "SATISFIED",
+    "submittability_expression_result": {
+      "expression": "label:Code-Review=+2",
+      "fulfilled": true,
+      "passingAtoms": [
+        "label:Code-Review=+2"
+      ]
+    },
+    "is_legacy": false
+  }
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/config-themes.txt b/Documentation/config-themes.txt
index a83c747..73cfc55 100644
--- a/Documentation/config-themes.txt
+++ b/Documentation/config-themes.txt
@@ -4,11 +4,12 @@
 the browser, allowing organizations to alter the look and
 feel of the application to fit with their general scheme.
 
-== HTML Header/Footer and CSS
+== HTML Header/Footer and CSS for login screens
 
-The HTML header, footer and CSS may be customized for login
-screens (LDAP, OAuth, OpenId) and the internally managed
-Gitweb servlet.
+The HTML header, footer, and CSS may be customized for login screens (LDAP,
+OAuth, OpenId) and the internally managed Gitweb servlet. See
+link:pg-plugin-dev.html[JavaScript Plugin Development and API] for documentation
+on modifying styles for the rest of Gerrit (not login screens).
 
 At startup Gerrit reads the following files (if they exist) and
 uses them to customize the HTML page it sends to clients:
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index c3237ed..d1a5bcf 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -18,7 +18,7 @@
 To build Gerrit from source, you need:
 
 * A Linux or macOS system (Windows is not supported at this time)
-* A JDK for Java 8|11|...
+* A JDK for Java 11 or Java 17
 * Python 3
 * link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
 * Bower (`npm install -g bower`)
@@ -48,73 +48,30 @@
 
 `java -version`
 
-[[java-8]]
-==== Java 8 support (deprecated)
-
-Java 8 is a legacy Java release and support for Java 8 will be discontinued
-in future gerrit releases. To build Gerrit with Java 8 language level, run:
-
-```
-  $ bazel build --java_toolchain //tools:error_prone_warnings_toolchain :release
-```
-
 [[java-11]]
 ==== Java 11 support
 
-Java language level 11 is the default. To build Gerrit with Java 11 language
-level, run:
+To build Gerrit with Java 11 language level, run:
 
 ```
-  $ bazel build :release
+  $ bazel build --java_toolchain=//tools:error_prone_warnings_toolchain_java11 :release
 ```
 
-[[java-13]]
-==== Java 13 support
+[[java-17]]
+==== Java 17 support
 
-Java 13 (and newer) is supported through vanilla java toolchain
-link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option,role=external,window=_blank].
-To build Gerrit with Java 13 and newer, specify vanilla java toolchain and
-provide the path to JDK home:
+Java 17 is supported. To build Gerrit with Java 17, run:
 
 ```
-  $ bazel build \
-    --define=ABSOLUTE_JAVABASE=<path-to-java-13> \
-    --javabase=@bazel_tools//tools/jdk:absolute_javabase \
-    --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
-    --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
-    --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
-    :release
+  $ bazel build --config=java17 :release
 ```
 
-To run the tests, `--javabase` option must be passed as well, because
-bazel test runs the test using the target javabase:
+To run the tests with Java 17, run:
 
 ```
-  $ bazel test \
-    --define=ABSOLUTE_JAVABASE=<path-to-java-13> \
-    --javabase=@bazel_tools//tools/jdk:absolute_javabase \
-    --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
-    --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
-    --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
-    //...
+  $ bazel test --config=java17 //...
 ```
 
-To avoid passing all those options on every Bazel build invocation,
-they could be added to ~/.bazelrc resource file:
-
-```
-$ cat << EOF > ~/.bazelrc
-build --define=ABSOLUTE_JAVABASE=<path-to-java-13>
-build --javabase=@bazel_tools//tools/jdk:absolute_javabase
-build --host_javabase=@bazel_tools//tools/jdk:absolute_javabase
-build --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
-build --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
-EOF
-```
-
-Now, invoking Bazel with just `bazel build :release` would include
-all those options.
-
 === Node.js and npm packages
 See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md#installing-node_js-and-npm-packages[Installing Node.js and npm packages,role=external,window=_blank].
 
@@ -268,7 +225,15 @@
   bazel-bin/Documentation/searchfree.zip
 ----
 
-To build the executable WAR with the documentation included:
+To generate HTML files skipping the zip archiving:
+
+----
+  bazel build Documentation
+----
+
+And open `bazel-bin/Documentation/index.html`.
+
+To build the Gerrit executable WAR with the documentation included:
 
 ----
   bazel build withdocs
@@ -317,18 +282,6 @@
   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:
 
 ----
@@ -341,12 +294,6 @@
   bazel test --test_tag_filters=-flaky //...
 ----
 
-To exclude tests that require a Docker host:
-
-----
-  bazel test --test_tag_filters=-docker //...
-----
-
 To exclude tests that require very recent git client version:
 
 ----
@@ -370,15 +317,11 @@
   bazel test --test_env=GERRIT_INDEX_TYPE=LUCENE //...
 ----
 
-Elastic search is not currently supported in integration tests.
-
 The following values are currently supported for the group name:
 
 * annotation
 * api
-* docker
 * edit
-* elastic
 * git
 * git-protocol-v2
 * git-upload-archive
@@ -411,23 +354,6 @@
 Now attach with a debugger to the port `5005`. For example use "Remote Java Application" launch
 configuration in Eclipe and specify the port `5005`.
 
-[[elasticsearch]]
-=== Elasticsearch
-
-Successfully running the Elasticsearch tests requires Docker, and
-may require setting the local virtual memory on
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/vm-max-map-count.html[linux,role=external,window=_blank] and
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#_set_vm_max_map_count_to_at_least_262144[macOS,role=external,window=_blank].
-
-On macOS, if using link:https://docs.docker.com/docker-for-mac/[Docker Desktop,role=external,window=_blank],
-the effective memory value can be set in the Preferences, under the Advanced tab.
-The default value usually does not suffice and is causing premature container exits.
-That default is currently 2 GB and should be set to at least 5 (GB).
-
-If Docker is not available, the Elasticsearch tests will be skipped.
-Note that Bazel currently does not show
-link:https://github.com/bazelbuild/bazel/issues/3476[the skipped tests,role=external,window=_blank].
-
 [[logging]]
 === Controlling logging level
 
@@ -681,6 +607,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 7488f74..47c0be2 100644
--- a/Documentation/dev-community.txt
+++ b/Documentation/dev-community.txt
@@ -54,7 +54,6 @@
 * link:dev-build-plugins.html[Building Gerrit plugins]
 * 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]
 
 [[maintainer]]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index fcc8b7e..107473a 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -24,33 +24,31 @@
 link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
 review them adhoc.
 
-For large/complex features, it is required to follow the
-link:#design-driven-contribution-process[design-driven contribution
-process] and specify the feature in a link:dev-design-docs.html[design
-doc,role=external,window=_blank] before starting with the implementation.
+For large/complex features, it is required to specify the feature in a
+link:dev-design-docs.html[design document,role=external,window=_blank] before
+starting implementation, as per the
+link:#design-driven-contribution-process[design-driven contribution process].
 
 If link:dev-roles.html#contributor[contributors,role=external,window=_blank]
-choose the lightweight contribution process and during the review it turns out
-that the feature is too large or complex,
-link:dev-roles.html#maintainer[maintainers,role=external,window=_blank] can
-require to follow the design-driven contribution process instead.
+choose the lightweight contribution process but the feature is found to be 
+large or complex, link:dev-roles.html#maintainer[maintainers,role=external,window=_blank]
+can require that the design-driven contribution process be followed instead.
 
 If you are in doubt which process is right for you, consult the
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list.
 
 These contribution processes apply to everyone who contributes code to
-the Gerrit project, including link:dev-roles.html#maintainer[
-maintainers,role=external,window=_blank]. When reading this document, keep in
-mind that maintainers are also contributors when they contribute code.
+the Gerrit project. link:dev-roles.html#maintainer[
+Maintainers,role=external,window=_blank] are also considered contributors
+when they contribute code.
 
 If a new feature is large or complex, it is often difficult to find a
-maintainer who can take the time that is needed for a thorough review,
-and who can help with getting the changes submitted. To avoid that this
-results in unpredictable long waiting times during code review,
-contributors can ask for link:#mentorship[mentor support]. A mentor
-helps with timely code reviews and technical guidance. Doing the
-implementation is still the responsibility of the contributor.
+maintainer who can take the time that is needed for a thorough review. This
+can result in unpredictably long waiting times before the changes are
+submitted. To avoid that, contributors can ask for link:#mentorship[mentor support].
+A mentor helps with timely code reviews and technical guidance, though the 
+implementation itself is still the responsibility of the contributor.
 
 [[comparison]]
 === Quick Comparison
@@ -66,8 +64,8 @@
 |Review  |adhoc (when reviewer is available)|by a dedicated mentor (if
 a link:#mentorship[mentor] was assigned)
 |Caveats |features may get vetoed after the implementation was already
-done, maintainers may make the design-driven contribution process
-required if a change gets too complex/large|design doc must stay open
+done, maintainers may require the design-driven contribution process
+be followed if a change gets too complex/large|design doc must stay open
 for a minimum of 10 calendar days, a mentor may not be available
 immediately
 |Applicable to|documentation updates, bug fixes, small features|
@@ -83,40 +81,32 @@
 link:#design-driven-contribution-process[design-driven contribution
 process] is required.
 
-As Gerrit is a code review tool, naturally contributions will
-be reviewed before they will get submitted to the code base.  To
-start your contribution, please make a git commit and upload it
-for review to the link:https://gerrit-review.googlesource.com/[
-gerrit-review.googlesource.com,role=external,window=_blank] Gerrit server.  To
-help speed up the review of your change, review these link:dev-crafting-changes.html[
+To start contributing to Gerrit, upload your git commit for review to the
+link:https://gerrit-review.googlesource.com/[gerrit-review.googlesource.com,
+role=external,window=_blank] Gerrit server. Review these link:dev-crafting-changes.html[
 guidelines,role=external,window=_blank] before submitting your change.  You can
-view the pending Gerrit contributions and their statuses
-link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
+view pending contributions link:https://gerrit-review.googlesource.com/#/q/status:open+project:gerrit[here,role=external,window=_blank].
 
 Depending on the size of that list it might take a while for
-your change to get reviewed.  Naturally there are fewer
-link:dev-roles.html#maintainer[maintainers,role=external,window=_blank], that
-can approve changes, than link:dev-roles.html#contributor[contributors,role=external,window=_blank];
-so anything that you can do to ensure that your contribution will undergo fewer
-revisions will speed up the contribution process.  This includes
-helping out reviewing other people's changes to relieve the load from
-the maintainers.  Even if you are not familiar with Gerrit's internals,
+your change to get reviewed. Anything that you can do to ensure that your
+contribution will undergo fewer revisions will speed up the contribution process.
+This includes helping out reviewing other people's changes to relieve the
+load from the maintainers. Even if you are not familiar with Gerrit's internals,
 it would be of great help if you can download, try out, and comment on
-new features.  If it works as advertised, say so, and if you have the
+new features. If it works as advertised, say so, and if you have the
 privileges to do so, go ahead and give it a `+1 Verified`.  If you
 would find the feature useful, say so and give it a `+1 Code Review`.
 
-And finally, the quicker you respond to the comments of your reviewers,
-the quicker your change might get merged!  Try to reply to every
-comment after submitting your new patch, particularly if you decided
-against making the suggested change. Reviewers don't want to seem like
-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.
+Finally, the quicker you respond to the comments of your reviewers, the
+quicker your change can be merged! Try to reply to every comment after
+submitting your new patch, particularly if you decided against making the
+suggested change. Reviewers don't want to seem like 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.
+A new feature or API extension, even if small, can incur a long-time
+maintenance and support burden and should be left pending for 24 hours
+to give maintainers in all time zones a chance to evaluate the change.
 
 [[design-driven-contribution-process]]
 === Design-driven Contribution Process
@@ -126,19 +116,19 @@
 
 For large/complex features it is important to:
 
-* agree on the functionality and scope before spending too much time
-  on the implementation
+* agree on functionality and scope before spending too much time on
+  implementation
 * ensure that they are in line with Gerrit's project scope and vision
 * ensure that they are well aligned with other features
-* think about possibilities how the feature could be evolved over time
+* consider how the feature could be evolved over time
 
 This is why for large/complex features it is required to describe the
 feature in a link:dev-design-docs.html[design doc,role=external,window=_blank]
 and get it approved by the
-link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
+link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank]
 before starting the implementation.
 
-The design-driven contribution process has the following steps:
+The design-driven contribution process consists of the following steps:
 
 * A link:dev-roles.html#contributor[contributor,role=external,window=_blank]
   link:dev-design-docs.html#propose[proposes,role=external,window=_blank] a new
@@ -155,40 +145,31 @@
   be accepted.
 * To be submitted, the design doc needs to be approved by the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank].
-* After the design was approved, the implementation is done by pushing
-  changes for review, see link:#lightweight-contribution-process[
+* After the design is approved, it is implemented by pushing
+  changes for review, see the link:#lightweight-contribution-process[
   lightweight contribution process]. Changes that are associated with
   a design should all share a common hashtag. The contributor is the
-  main driver of the implementation and responsible that it is done.
-  Others from the Gerrit community are usually much welcome to help
-  with the implementation.
+  main driver of the implementation and responsible for its completion.
+  Others from the Gerrit community are usually welcome to help.
 
-In order to be accepted/submitted, it is not necessary that the design
-doc fully specifies all the details, but the idea of the feature and
-how it fits into Gerrit should be sufficiently clear (judged by the
-steering committee). Contributors are expected to keep the design doc
-updated and fill in gaps while they go forward with the implementation.
-We expect that implementing the feature and updating the design doc
-will be an iterative process.
+The design doc does not need to fully specify each detail of the feature,
+but its concept and how it fits into Gerrit should be sufficiently clear,
+as judged by the steering committee. Contributors are expected to keep
+the design doc updated and fill in gaps while they go forward with the
+implementation. We expect that implementing the feature and updating the
+design doc will be an iterative process.
 
-While the design doc is still in review, contributors may already start
-with the implementation (e.g. do some prototyping to demonstrate parts
-of the proposed design), but those changes should not be submitted
-while the design wasn't approved yet. Another way to demonstrate the
-design can be to add screenshots or the like, early enough in the doc.
+While the design doc is still in review, contributors may start with the
+implementation (e.g. do some prototyping to demonstrate parts of the
+proposed design), but those changes should not be submitted while the
+design is not yet approved. Another way to demonstrate the design can be
+mocking screenshots in the doc.
 
 By approving a design, the steering committee commits to:
 
 * Accepting the feature when it is implemented.
 * Supporting the feature by assigning a link:dev-roles.html#mentor[
-  mentor,role=external,window=_blank] (if requested, see link:#mentorship[mentorship]).
-
-If the implementation of a feature gets stuck and it's unclear whether
-the feature gets fully done, it should be discussed with the steering
-committee how to proceed. If the contributor cannot commit to finish
-the implementation and no other contributor can take over, changes that
-have already been submitted for the feature might get reverted so that
-there is no unused or half-finished code in the code base.
+  mentor,role=external,window=_blank] if requested (see link:#mentorship[mentorship]).
 
 For contributors, the design-driven contribution process has the
 following advantages:
@@ -196,12 +177,11 @@
 * By writing a design doc, the feature gets more attention. During the
   design review, feedback from various sides can be collected, which
   likely leads to improvements of the feature.
-* Once a design was approved by the
+* Once a design is approved by the
   link:dev-processes.html#steering-committee[steering committee,role=external,window=_blank],
   the contributor can be almost certain that the feature will be accepted.
-  Hence, there is only a low risk to invest into implementing a feature
-  and see it being rejected later during the code review, as it can
-  happen with the lightweight contribution process.
+  Hence, there little risk of the feature being rejected later in code review,
+  as can occur with the lightweight contribution process.
 * The contributor can link:#mentorship[get a dedicated mentor assigned]
   who provides timely reviews and serves as a contact person for
   technical questions and discussing details of the design.
@@ -249,12 +229,11 @@
 * done criteria that define when the feature is done and the mentorship
   ends
 
-If a feature is not finished in time, it should be discussed with the
-steering committee how to proceed. If the contributor cannot commit to
-finish the implementation in time and no other contributor can take
-over, changes that have already been submitted for the feature might
-get reverted so that there is no unused or half-finished code in the
-code base.
+If a feature implementation is not completed in time and no contributors
+can commit to finishing the implementation, changes that have already been
+submitted for the feature may be reverted to avoid unused or half-finished
+code in the code base. In these circumstances, the steering committee
+determines how to proceed.
 
 [[esc-dd-evaluation]]
 == How the ESC evaluates design documents
@@ -314,7 +293,7 @@
 === Core vs. Plugin decision
 Q: `Would this fit better in a plugin?`
 
-* Yes:The proposed feature or rework is an implementation (e.g. Lucene
+* Yes: The proposed feature or rework is an implementation (e.g. Lucene
   is an index implementation) of a generic concept that others
   might want to implement differently.
 * Yes: The proposed feature or rework is very specific to a custom setup.
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
index 6dc6f5f..efba520 100644
--- a/Documentation/dev-design-docs.txt
+++ b/Documentation/dev-design-docs.txt
@@ -13,7 +13,7 @@
 * Use-Cases:
   The interactions between a user and a system to attain particular
   goals.
-* Acceptance Criteria
+* Acceptance Criteria:
   Conditions that must be satisfied to consider the feature as done.
 * Background:
   Stuff one needs to know to understand the use-cases (e.g. motivating
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index c50a293..1151f1c 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -102,8 +102,7 @@
 === SSH keys
 
 If you are running SSH commands, the private keys of the users used for testing need to go in
-`/tmp/ssh-keys`. The keys need to be generated this way (JSch won't validate them
-link:https://stackoverflow.com/questions/53134212/invalid-privatekey-when-using-jsch[otherwise,role=external,window=_blank]):
+`/tmp/ssh-keys`. The keys need to be generated this way and won't be validated.
 
 ----
 mkdir /tmp/ssh-keys
@@ -174,6 +173,9 @@
 * `-Dcom.google.gerrit.scenarios.ssh_port=29418`
 * `-Dcom.google.gerrit.scenarios.http_port=8080`
 * `-Dcom.google.gerrit.scenarios.http_scheme=http`
+* `-Dcom.google.gerrit.scenarios.username=admin`
+* `-Dcom.google.gerrit.scenarios.replica_hostname=localhost`
+* `-Dcom.google.gerrit.scenarios.project_prefix=`
 
 Above, the properties can be set with values matching specific deployment topologies under test.
 The name of the property corresponds to the uppercase keyword found in the json file. For example,
@@ -195,6 +197,20 @@
 That whole replication time depends on the system under test. Therefore, this property here should
 be set to a value high enough, so that the test checks for a done replication at the right time.
 
+==== Context path
+
+The `context_path` property allows test scenarios to send Gerrit REST requests to Gerrit instances
+that use a context path in the URL. Its default is no context path and can be set using another value:
+
+* `-Dcom.google.gerrit.scenarios.context_path=/context`
+
+==== Authentication
+
+The `authenticated` property allows test scenarios to use authenticated HTTP clones. Its default is
+no authentication:
+
+* `-Dcom.google.gerrit.scenarios.authenticated=false`
+
 ==== Automatic properties
 
 The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index e18d7b0..79febe4 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -31,9 +31,6 @@
 ----
 
 First, generate the Eclipse project by running the `tools/eclipse/project.py` script.
-If running Eclipse on Java 8, add the extra parameter
-`-e='--java_toolchain=//tools:error_prone_warnings_toolchain'`
-for generating a compatible project.
 
 Then, in Eclipse, choose 'Import existing project' and select the `gerrit` project
 from the current working directory.
@@ -118,11 +115,11 @@
 
 == Testing
 
-=== The Gerrit web app UI is served by `server.go` process. To launch it,
+=== The Gerrit web app UI is served by `Web Dev Server`. To launch it,
 run this command:
 
 ----
-  $ bazel run polygerrit-ui:devserver
+  $ npm run start
 ----
 
 === Running the Daemon
diff --git a/Documentation/dev-plugins-lifecycle.txt b/Documentation/dev-plugins-lifecycle.txt
index d5bd791..2f5ffb7 100644
--- a/Documentation/dev-plugins-lifecycle.txt
+++ b/Documentation/dev-plugins-lifecycle.txt
@@ -15,14 +15,14 @@
 The idea of creating a new plugin is posted and discussed on the
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank] mailing list.
 +
-Also see section link#ideation_discussion[Ideation and discussion] below.
+Also see section <<ideation_discussion>> below.
 
 - Prototyping (optional):
 +
 The author of the plugin creates a working prototype on a public repository
 accessible to the community.
 +
-Also see section link#plugin_prototyping[Plugin Prototyping] below.
+Also see section <<plugin_prototyping>> below.
 
 - Proposal and Hosting:
 +
@@ -37,7 +37,7 @@
 plugins path on link:https://gerrit-review.googlesource.com[the Gerrit project
 site,role=external,window=_blank].
 +
-Also see section link#plugin_proposal[Plugin Proposal] below.
+Also see section <<plugin_proposal>> below.
 
 - Build:
 +
@@ -46,14 +46,14 @@
 link:https://gerrit-ci.gerritforge.com[the GerritForge CI,role=external,window=_blank] that build the
 plugin for each Gerrit version that it supports.
 +
-Also see section link#build[Build] below.
+Also see section <<build>> below.
 
 - Development and Contribution:
 +
 The author develops a production-ready code base of the plugin, with
 contributions, reviews, and help from the Gerrit community.
 +
-Also see section link#development_contribution[Development and contribution]
+Also see section <<development_contribution>>
 below.
 
 - Release:
@@ -62,7 +62,7 @@
 on the link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list.
 +
-Also see section link#plugin_release[Plugin release] below.
+Also see section <<plugin_release>> below.
 
 - Maintenance:
 +
@@ -75,7 +75,7 @@
 The author declares that the plugin is not maintained anymore or is deprecated
 and should not be used anymore.
 +
-Also see section link#plugin_deprecation[Plugin deprecation] below.
+Also see section <<plugin_deprecation>> below.
 
 [[ideation_discussion]]
 == Ideation and Discussion
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index fa2b78c..33c5bbd 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2,8 +2,9 @@
 = Gerrit Code Review - Plugin Development
 
 The Gerrit server functionality can be extended by installing plugins.
-This page describes how plugins for Gerrit can be developed and hosted
-on gerrit-review.googlesource.com.
+This page describes how plugins for Gerrit can be developed. See the overall
+link:dev-plugins-lifecycle.html[Plugin Lifecycle] document for how plugins can
+be hosted on gerrit-review.googlesource.com.
 
 For JavaScript plugin development, consult with
 link:pg-plugin-dev.html[JavaScript Plugin Development] guide.
@@ -207,6 +208,28 @@
 The canonical web URL may be injected into any .jar plugin regardless of
 whether or not the plugin provides an HTTP servlet.
 
+[[plugin_resources]]
+=== Plugin resources
+
+Plugins are able to access their own resources without having to go through
+the implementation details on how they are packaged or deployed to Gerrit.
+
+The following example shows a MyClass in a plugin that is able to access the
+last modified time of the "myresource" loaded.
+
+[source,java]
+----
+public class MyClass {
+
+  @Inject
+  public MyClass(Plugin plugin) {
+    long myresourceTime = plugin.getContentScanner().getEntry("myresource").getTime();
+  }
+
+  [...]
+}
+----
+
 [[reload_method]]
 === Reload Method
 
@@ -415,6 +438,17 @@
 +
 Publication of usage data
 
+* `com.google.gerrit.extensions.events.GitReferenceUpdatedListener`:
++
+A git reference was updated. A separate event for every ref updated in
+a BatchRefUpdate will be fired.
+
+* `com.google.gerrit.extensions.events.GitBatchRefUpdateListener`:
++
+One or more git references were updated. Alternative to GitReferenceUpdatedListener.
+A single event will inform about all refs updated by a BatchRefUpdate. Will also be
+fired, if only a single ref was updated.
+
 * `com.google.gerrit.extensions.events.GarbageCollectorListener`:
 +
 Garbage collection ran on a project
@@ -499,6 +533,24 @@
 Certain operations in Gerrit can be validated by plugins by
 implementing the corresponding link:config-validation.html[listeners].
 
+[[taskListeners]]
+== WorkQueue.TaskListeners
+
+It is possible for plugins to listen to
+`com.google.gerrit.server.git.WorkQueue$Task`s directly before they run, and
+directly after they complete. This may be used to delay task executions based
+on custom criteria by blocking, likely on a lock or semaphore, inside
+onStart(), and a lock/semaphore release in onStop(). Plugins may listen to
+tasks by implementing a `com.google.gerrit.server.git.WorkQueue$TaskListener`
+and registering the new listener like this:
+
+[source,java]
+----
+bind(TaskListener.class)
+    .annotatedWith(Exports.named("MyListener"))
+    .to(MyListener.class);
+----
+
 [[change-message-modifier]]
 == Change Message Modifier
 
@@ -838,6 +890,63 @@
 }
 ----
 
+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
+example shows a plugin that always limits the number of projects returned
+by the ls-projects SSH command.
+
+[source, java]
+----
+protected static class PluginModule extends AbstractModule {
+  @Override
+  public void configure() {
+    bind(DynamicOptions.DynamicBean.class)
+        .annotatedWith(Exports.named(ListProjectsCommand.class))
+        .to(ListProjectsCommandBeanListener.class);
+  }
+
+  protected static class ListProjectsCommandBeanListener
+      implements DynamicOptions.BeanParseListener {
+    @Override
+    public void onBeanParseStart(String plugin, Object bean) {
+      ListProjectsCommand command = (ListProjectsCommand) bean;
+      command.impl.setLimit(1);
+    }
+
+    @Override
+    public void onBeanParseEnd(String plugin, Object bean) {}
+  }
+}
+----
+
+The below example shows a plugin that always limits the number of projects
+returned by the /projects/ REST API.
+
+[source, java]
+----
+protected static class PluginModule extends AbstractModule {
+  @Override
+  public void configure() {
+    bind(DynamicOptions.DynamicBean.class)
+        .annotatedWith(Exports.named(ListProjects.class))
+        .to(ListProjectsBeanListener.class);
+  }
+
+  protected static class ListProjectsBeanListener
+      implements DynamicOptions.BeanParseListener {
+    @Override
+    public void onBeanParseStart(String plugin, Object bean) {
+      ListProjects listProjects = (ListProjects) bean;
+      listProjects.setLimit(1);
+    }
+
+    @Override
+    public void onBeanParseEnd(String plugin, Object bean) {}
+  }
+}
+----
+
 === Calling Command Options ===
 
 Within an OptionHandler, during the processing of an option, plugins can
@@ -899,7 +1008,7 @@
 [[query_attributes]]
 == Change Attributes
 
-==== ChangePluginDefinedInfoFactory
+=== ChangePluginDefinedInfoFactory
 
 Plugins can provide additional attributes to be returned from the Get Change and
 Query Change APIs by implementing the `ChangePluginDefinedInfoFactory` interface
@@ -2131,6 +2240,8 @@
 DiffWebLinks will appear in the side-by-side and unified diff screen in
 the header next to the navigation icons.
 
+EditWebLinks will appear in the top-right part of the file diff page.
+
 ProjectWebLinks will appear in the project list in the
 `Repository Browser` column.
 
@@ -2209,8 +2320,7 @@
 DropWizard Metrics,role=external,window=_blank].
 
 Metric reporting plugin implementations are provided for
-link:https://gerrit.googlesource.com/plugins/metrics-reporter-jmx/[JMX,role=external,window=_blank],
-link:https://gerrit.googlesource.com/plugins/metrics-reporter-elasticsearch/[Elastic Search,role=external,window=_blank],
+link:https://gerrit.googlesource.com/plugins/metrics-reporter-jmx/[JMX,role=external,window=_blank]
 and link:https://gerrit.googlesource.com/plugins/metrics-reporter-graphite/[Graphite,role=external,window=_blank].
 
 There is also a working example of reporting metrics to the console in the
@@ -2628,7 +2738,7 @@
 Plugins may also decide not to vote on a given change by returning an
 `Optional.empty()` (ie: the plugin is not enabled for this repository).
 
-If a plugin decides not to vote, it's name will not be displayed in the UI and
+If a plugin decides not to vote, its name will not be displayed in the UI and
 it will not be recoded in the database.
 
 .Gerrit's Pre-submit handling with three plugins
@@ -2712,6 +2822,23 @@
 prevent callers using ETags from potentially seeing outdated submittability
 information.
 
+`SubmitRule` interface will soon deprecated. Instead, a global `SubmitRequirement`
+can be bound by plugin.
+
+[source, java]
+----
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.inject.AbstractModule;
+
+public class MyPluginModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(SubmitRequirement.class).annotatedWith(Exports.named("myPlugin"))
+        .toInstance(myPluginSubmitRequirement);
+  }
+}
+----
+
 [[change-etag-computation]]
 == Change ETag Computation
 
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index e43e021..3bd88f5 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -366,7 +366,10 @@
 * If the library is used within Google, the version of the library must be compatible with the
   version that is used at Google.
 
-Only maintainers from Google can vote on the `Library-Compliance` label.
+Only maintainers from Google can vote on the `Library-Compliance` label. The
+Gerrit team at Google uses this
+link:https://gerrit-review.googlesource.com/q/label:%2522Library-Compliance%253Dneed%2522+-ownerin:google-gerrit-team+status:open+project:gerrit+-age:4week+-is:wip+-is:private+label:Code-Review%252B2[change query]
+to find changes that require a `Library-Compliance` approval.
 
 Gerrit's library dependencies should only be upgraded if the new version contains
 something we need in Gerrit. This includes new features, API changes as well as bug
@@ -378,6 +381,12 @@
 that they are vetted long enough before they go into a release and we can be sure
 that the update doesn't introduce a regression.
 
+[[escalation-channel-to-google]]
+== Escalation channel to Google
+
+If anything urgent is blocking that requires the attention of a Googler you may
+escalate this by writing an email to Han-Wen Nienhuys: hanwen@google.com
+
 [[deprecating-features]]
 == Deprecating features
 
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index f045ab8..6ff064c 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -178,6 +178,9 @@
 NOTE: To learn why using `java -jar` isn't sufficient, see
 <<special_bazel_java_version,this explanation>>.
 
+NOTE: When launching the daemong this way, the settings from the `[container]` section from the
+`$GERRIT_SITE/etc/gerrit.config` are not honored.
+
 To debug the Gerrit server of this test site:
 
 .  Open a debug port (such as port 5005). To do so, insert the following code
@@ -188,6 +191,49 @@
 -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005
 ----
 
+=== Running the Daemon honoring the [container] section settings
+
+To run the Daemon and honor the `[container]` section settings use the `gerrit.sh` script:
+
+----
+  $ cd $GERRIT_SITE
+  $ bin/gerrit.sh run
+----
+
+To run the Daemon in debug mode use the `--debug` option:
+
+----
+  $ bin/gerrit.sh run --debug
+----
+
+The default debug port is `8000`. To specify a different debug port use the `--debug-port` option:
+
+----
+  $ bin/gerrit.sh run --debug --debug-port=5005
+----
+
+The `--debug-address` option also exists and is a synonym for the ``--debug-port` option:
+
+----
+  $ bin/gerrit.sh run --debug --debug-address=5005
+----
+
+Note that, by default, the debugger will only accept connections from the localhost. To enable
+debug connections from other host(s) provide also a host name or wildcard in the `--debug-address`
+value:
+
+----
+  $ bin/gerrit.sh run --debug --debug-address=*:5005
+----
+
+Debugging the Daemon startup requires starting the JVM in suspended debug mode. The JVM will await
+for a debugger to attach before proceeding with the start. Use the `--suspend` option for that
+scenario:
+
+----
+  $ bin/gerrit.sh run --debug --suspend
+----
+
 === Running the Daemon with Gerrit Inspector
 
 link:dev-inspector.html[Gerrit Inspector] is an interactive scriptable
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt
index a4ccccf..db08da5 100644
--- a/Documentation/dev-release-deploy-config.txt
+++ b/Documentation/dev-release-deploy-config.txt
@@ -44,15 +44,13 @@
 +
 Generate and publish a PGP key as described in
 link:http://central.sonatype.org/pages/working-with-pgp-signatures.html[
-Working with PGP Signatures,role=external,window=_blank]. In addition to the keyserver mentioned
-there it is recommended to also publish the key to the
-link:https://keyserver.ubuntu.com/[Ubuntu key server].
+Working with PGP Signatures,role=external,window=_blank].
 +
 Please be aware that after publishing your public key it may take a
 while until it is visible to the Sonatype server.
 +
 Add an entry for the public key in the
-link:https://gerrit.googlesource.com/homepage/+/md-pages/releases/public-keys.md[key list,role=external,window=_blank]
+link:https://gerrit.googlesource.com/homepage/+/master/pages/site/releases/public-keys.md[key list,role=external,window=_blank]
 on the homepage.
 +
 The PGP passphrase can be put in `~/.m2/settings.xml`:
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 0849c56..40470a6 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -327,6 +327,20 @@
   git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git push gerrit-review tag "v$version"'
 ----
 
+[[publish-typescript-plugin-api]]
+==== Publish TypeScript Plugin API
+
+Only applies to major and minor releases! Not required for patch releases.
+
+* Publish a new version of the npm package `@gerritcodereview/typescript-api`.
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/README.md[api/README.md,role=external,window=_blank]
+for details.
+
+* The plugins in the stable branch of the minor release and the master branch
+be changed to use the new API version, see
+link:https://gerrit-review.googlesource.com/c/gerrit/+/340069[
+example change,role=external,window=_blank]
+
 [[upload-documentation]]
 ==== Upload the Documentation
 
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index cecaedc..d8f7e11 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -66,11 +66,12 @@
 link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
 mailing list):
 
-* become member of the `gerrit-verifiers` group, which allows to:
+* become member of the `gerrit-trusted-contributors` group, which allows to:
 ** vote on the `Verified` and `Code-Style` labels
 ** edit hashtags on all changes
 ** edit topics on all open changes
 ** abandon changes
+** revert changes
 * approve posts to the
   link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank]
   mailing list
@@ -106,14 +107,14 @@
 have. In addition they have signed a link:dev-cla.html[contributor
 license agreement] which enables them to push changes.
 
-Regular contributors can ask to be added to the `gerrit-verifiers`
+Regular contributors can ask to be added to the `gerrit-trusted-contributors`
 group, which allows to:
 
 * add patch sets to changes of other users
 * propose project config changes (push changes for the
   `refs/meta/config` branch
 
-Being member of the `gerrit-verifiers` group includes further
+Being member of the `gerrit-trusted-contributors` group includes further
 permissions (see link:#supporter[supporter] section above).
 
 It's highly appreciated if contributors engage in code reviews,
diff --git a/Documentation/dev-stars.txt b/Documentation/dev-stars.txt
deleted file mode 100644
index 764e326..0000000
--- a/Documentation/dev-stars.txt
+++ /dev/null
@@ -1,82 +0,0 @@
-= Gerrit Code Review - Stars
-
-== Description
-
-Changes can be starred with labels that behave like private hashtags.
-Any label can be applied to a change, but these labels are only visible
-to the user for which the labels have been set.
-
-Stars allow users to categorize changes by self-defined criteria and
-then build link:user-dashboards.html[dashboards] for them by making use
-of the link:#query-stars[star query operators].
-
-[[star-api]]
-== Star API
-
-The link:rest-api-accounts.html#star-endpoints[star REST API] supports:
-
-* link:rest-api-accounts.html#get-stars[
-  get star labels from a change]
-* link:rest-api-accounts.html#set-stars[
-  update star labels on a change]
-* link:rest-api-accounts.html#get-starred-changes[
-  list changes that are starred by any label]
-
-Star labels are also included in
-link:rest-api-changes.html#change-info[ChangeInfo] entities that are
-returned by the link:rest-api-changes.html[changes REST API].
-
-There are link:rest-api-accounts.html#default-star-endpoints[
-additional REST endpoints] for the link:#default-star[default star].
-
-[[default-star]]
-== Default Star
-
-If the default star is set by a user, this user is automatically
-notified by email whenever updates are made to that change.
-
-The default star is the star that is shown in the WebUI and which can
-be updated from there.
-
-The default star is represented by the special star label 'star'.
-
-[[ignore-star]]
-== Ignore Star
-
-If the ignore star is set by a user, this user gets no email
-notifications for updates of that change, even if this user is a
-reviewer of the change or the change is matched by a project watch of
-the user.
-
-Since changes can only be ignored once they are created, users that
-watch a project will always get the email notifications for the change
-creation. Only then the change can be ignored.
-
-Users that are added as reviewer or assignee to a change that they have
-ignored will be notified about this, so that they know about the review
-request. They can then decide to remove the ignore star.
-
-The ignore star is represented by the special star label 'ignore'.
-
-[[query-stars]]
-== Query Stars
-
-There are several query operators to find changes with stars:
-
-* link:user-search.html#is-starred[is:starred] /
-  link:user-search.html#has-star[has:star]:
-  Matches any change that was starred by the current user with the
-  link:#default-star[default star].
-
-[[syntax]]
-== Syntax
-
-Star labels cannot contain whitespace characters. All other characters
-are allowed.
-
-GERRIT
-------
-Part of link:index.html[Gerrit Code Review]
-
-SEARCHBOX
----------
diff --git a/Documentation/externalid-case-insensitivity.txt b/Documentation/externalid-case-insensitivity.txt
new file mode 100644
index 0000000..b4e8140
--- /dev/null
+++ b/Documentation/externalid-case-insensitivity.txt
@@ -0,0 +1,128 @@
+:linkattrs:
+= Gerrit Code Review - ExternalId case insensitivity
+
+Gerrit usernames are case insensitive by default: e.g. johndoe and JohnDoe
+represents the same account. However, for installations older than v3.5.x,
+the usernames were case sensitive, e.g. johndoe and JohnDoe can both exist
+as separate accounts. This could lead to issues when migrating an account
+from LDAP to an internal account, if ldap.localUsernameToLowerCase was set.
+Such usernames can also be rather confusing for users, if they try to identify
+authors of comments or changes.
+
+When Gerrit handles case insensitive usernames (external IDs using the
+`gerrit:` or `username:` scheme, their external IDs SHA-1 is always computed
+using the lowercase external ID, hence there cannot be any account differing
+only in the capitalization of their usernames.
+
+Gerrit installations older than v3.5.x that are switching to the case-insensitive
+username need to migrating all their existing accounts SHA-1s.
+
+[[migration]]
+== Migration
+
+Migrating external ID notes can take several minutes for large sites(for example
+migration ~45000 accounts can take up to five minutes), so administrators choose
+whether to do the migration offline or online, depending on their available
+resources and tolerance for downtime.
+
+NOTE: Migration is required only on Gerrit primary instances.
+
+[[offline-migration]]
+=== Offline
+
+To run the offline migration execute following steps:
+* Stop all Gerrit primary instances
+* Set the `auth.userNameCaseInsensitive` to false
+----
+[auth]
+  userNameCaseInsensitive = false
+----
+
+* Run:
+[verse]
+--
+_java_ -jar gerrit.war _ChangeExternalIdCaseSensitivity_
+  -d <SITE_PATH>
+  [--batch]
+--
+
+See: link:pgm-ChangeExternalIdCaseSensitivity.html
+
+* During the migration `auth.userNameCaseInsensitive` will be set to true
+on a node which is executing the migration. When the migration is finished,
+on all other primary nodes set `auth.userNameCaseInsensitive` to true
+* Start all Gerrit primary instances
+
+[[online-migration]]
+=== Online
+
+To start the online migration, set the `auth.userNameCaseInsensitive` and
+`auth.userNameCaseInsensitiveMigrationMode` options in `gerrit.config` and
+restart Gerrit:
+----
+[auth]
+  userNameCaseInsensitive = true
+  userNameCaseInsensitiveMigrationMode = true
+----
+* Trigger online migration:
+----
+$ ssh -p <port> <host> gerrit migrate-externalids-to-insensitive
+----
+
+See: link:cmd-migrate-externalids-to-insensitive.html
+
+[online-ha-migration]
+== Online migration for high-availability setup
+
+To start the online migration with a setup containing multiple primary
+instances execute following steps:
+* On all Gerrit primary instances set `auth.userNameCaseInsensitive` and
+`auth.userNameCaseInsensitiveMigrationMode` and perform a rolling restart
+----
+[auth]
+  userNameCaseInsensitive = true
+  userNameCaseInsensitiveMigrationMode = true
+----
+* Trigger online migration:
+----
+$ ssh -p <port> <host> gerrit migrate-externalids-to-insensitive
+----
+
+See: link:cmd-migrate-externalids-to-insensitive.html
+
+* When the migration is finished, on all other primary nodes set
+`auth.userNameCaseInsensitiveMigrationMode` to false and perform a
+rolling restart
+----
+[auth]
+  userNameCaseInsensitive = true
+  userNameCaseInsensitiveMigrationMode = false
+----
+
+== External ID case insensitivity rollback
+
+The offline migration tool allows to calculate external ID notes named with the SHA-1
+from the case sensitive external ID.
+
+To rollback external ID notes migration execute following steps:
+* Stop all Gerrit primary instances
+* Set the `auth.userNameCaseInsensitive` to true
+----
+[auth]
+  userNameCaseInsensitive = true
+----
+
+* Run:
+[verse]
+--
+_java_ -jar gerrit.war _ChangeExternalIdCaseSensitivity_
+  -d <SITE_PATH>
+  [--batch]
+--
+
+See: link:pgm-ChangeExternalIdCaseSensitivity.html
+
+* During the migration `auth.userNameCaseInsensitive` will be set to false
+on a node which is executing the migration. When the migration is finished,
+on all other primary nodes set `auth.userNameCaseInsensitive` to false
+* Start all Gerrit primary instances
diff --git a/Documentation/images/browser-notification-example.png b/Documentation/images/browser-notification-example.png
new file mode 100644
index 0000000..2b60054
--- /dev/null
+++ b/Documentation/images/browser-notification-example.png
Binary files differ
diff --git a/Documentation/images/browser-notification-preference.png b/Documentation/images/browser-notification-preference.png
new file mode 100644
index 0000000..57d5fd6
--- /dev/null
+++ b/Documentation/images/browser-notification-preference.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-apply-fix.png b/Documentation/images/user-review-ui-apply-fix.png
new file mode 100644
index 0000000..d838d48
--- /dev/null
+++ b/Documentation/images/user-review-ui-apply-fix.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-metadata.png b/Documentation/images/user-review-ui-change-metadata.png
new file mode 100644
index 0000000..23abc07
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-metadata.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
index 5c3f80a..4e12c96 100644
--- a/Documentation/images/user-review-ui-change-screen-annotated.png
+++ 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-labels.png b/Documentation/images/user-review-ui-change-screen-change-info-labels.png
deleted file mode 100644
index 61e2b25..0000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-labels.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-comments-tab.png b/Documentation/images/user-review-ui-change-screen-comments-tab.png
new file mode 100644
index 0000000..d522f60
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-comments-tab.png
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 721b229..b0c2af3 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-keyboard-shortcuts.png b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
index 9ef8f27..224de2d 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-reply.png b/Documentation/images/user-review-ui-change-screen-reply.png
index 1c50fc5..201db13 100644
--- a/Documentation/images/user-review-ui-change-screen-reply.png
+++ b/Documentation/images/user-review-ui-change-screen-reply.png
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
index a1f7813..b3bf8e7f 100644
--- a/Documentation/images/user-review-ui-change-screen-topleft.png
+++ 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 ff2570b..98a5d6d 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-copy-links.png b/Documentation/images/user-review-ui-copy-links.png
new file mode 100644
index 0000000..f8fa114
--- /dev/null
+++ b/Documentation/images/user-review-ui-copy-links.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
index 047034c..98cf7af 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen.png b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
index 74d02e3..ebdd177 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-submit-requirements.png b/Documentation/images/user-review-ui-submit-requirements.png
new file mode 100644
index 0000000..e4b88c1
--- /dev/null
+++ b/Documentation/images/user-review-ui-submit-requirements.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-suggest-fix.png b/Documentation/images/user-review-ui-suggest-fix.png
new file mode 100644
index 0000000..e08fb26
--- /dev/null
+++ b/Documentation/images/user-review-ui-suggest-fix.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 782a6a9..89b88aa 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -15,6 +15,7 @@
 == Contributor Guides
 . link:dev-community.html[Gerrit Community]
 . link:dev-community.html#how-to-contribute[How to Contribute]
+.. link:dev-readme.html[Developer Setup]
 
 == User Guides
 . link:intro-user.html[User Guide]
@@ -23,7 +24,7 @@
 
 == Tutorials
 . Web
-.. link:user-review-ui.html[Reviewing Changes]
+.. link:user-review-ui.html[Review UI Overview]
 .. link:user-search.html[Searching Changes]
 .. link:user-inline-edit.html[Manipulating Changes in Browser]
 .. link:user-notify.html[Subscribing to Email Notifications]
@@ -43,6 +44,7 @@
 == Project Management
 . link:project-configuration.html[Project Configuration]
 .. link:config-labels.html[Review Labels]
+.. link:config-submit-requirements.html[Submit Requirements]
 .. link:config-project-config.html[Project Configuration File Format]
 . link:access-control.html[Access Controls]
 . Multi-project management
diff --git a/Documentation/intro-gerrit-walkthrough.txt b/Documentation/intro-gerrit-walkthrough.txt
index 6565ba4..345eb1c 100644
--- a/Documentation/intro-gerrit-walkthrough.txt
+++ b/Documentation/intro-gerrit-walkthrough.txt
@@ -130,7 +130,7 @@
 In general, the *Code-Review* check requires an individual to look at the code,
 while the *Verified* check is done by an automated build server, through a
 mechanism such as the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger
+link:https://plugins.jenkins.io/gerrit-trigger/[Gerrit Trigger
 Jenkins Plugin,role=external,window=_blank].
 
 IMPORTANT: The Code-Review and Verified checks require different permissions
@@ -161,8 +161,8 @@
 * `+2 Looks good to me, approved`
 * `+1 Looks good to me, but someone else must approve`
 * `0 No score`
-* `-1 I would prefer that you didn't submit this`
-* `-2 Do not submit`
+* `-1 I would prefer this is not submitted as is`
+* `-2 This shall not be submitted`
 
 In addition, a change must have at least one `+2` vote and no `-2` votes before
 it can be submitted. These numerical values do not accumulate. Two
@@ -253,7 +253,7 @@
 can add custom checks or even remove the Verified check entirely.
 
 Verification is typically an automated process using the
-link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[Gerrit Trigger Jenkins Plugin,role=external,window=_blank]
+link:https://plugins.jenkins.io/gerrit-trigger/[Gerrit Trigger Jenkins Plugin,role=external,window=_blank]
 or a similar mechanism. However, there are still times when a change requires
 manual verification, or a reviewer needs to check how or if a change works.
 To accommodate these and other similar circumstances, Gerrit exposes each change
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 8a3b10e..f13bc22 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -316,9 +316,9 @@
 
 A useful feature on labels is the possibility to automatically copy
 scores forward to new patch sets if it was a
-link:config-labels.html#label_copyAllScoresOnTrivialRebase[trivial
-rebase] or if link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-there was no code change] (e.g. only the commit message was edited).
+link:config-labels.html#trivial_rebase[trivial rebase] or if
+link:config-labels.html#no_code_change[there was no code change] (e.g.
+only the commit message was edited).
 
 [[submit-rules]]
 == Submit Rules
@@ -374,7 +374,7 @@
 There are several solutions for integrating continuous integration
 systems. The most commonly used are:
 
-- link:https://wiki.jenkins-ci.org/display/JENKINS/Gerrit+Trigger[
+- link:https://plugins.jenkins.io/gerrit-trigger/[
   Gerrit Trigger,role=external,window=_blank] plugin for link:http://jenkins-ci.org/[Jenkins,role=external,window=_blank]
 
 - link:http://www.mediawiki.org/wiki/Continuous_integration/Zuul[
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index f619c99..7a042ba 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -368,6 +368,10 @@
 project-level]. The project dashboards can be seen in the web UI under
 `Projects` > `List` > <project-name> > `Dashboards`.
 
+All dashboards and search pages allow an action to be applied to multiple
+changes at once. Select the changes with the checkboxes on the left side and
+choose the action from the action bar at the top of the change section.
+
 [[submit]]
 == Submit a Change
 
@@ -459,7 +463,7 @@
 push permission] on the destination branch.
 
 The move operation will not update the change's parent and users will have
-to link:#rebase[rebase] the change. Also, merge commits cannot be moved.
+to link:#rebase[rebase] the change.
 
 [[abandon]]
 [[restore]]
@@ -662,19 +666,10 @@
 * If you have a series of private changes and share one with reviewers,
   the reviewers can also see the commits of the predecessor private
   changes through the commit parent relationship.
-
-[[ignore]]
-== Ignoring Or Marking Changes As 'Reviewed'
-
-Changes can be ignored, which means they will not appear in the 'Incoming
-Reviews' dashboard and any related email notifications will be suppressed.
-This can be useful when you are added as a reviewer to a change on which
-you do not actively participate in the review, but do not want to completely
-remove yourself.
-
-Alternatively, rather than completely ignoring the change, it can be marked
-as 'Reviewed'. Marking a change as 'Reviewed' means it will not be highlighted
-in the dashboard, until a new patch set is uploaded.
+* Users who would have permission to access the change except for its
+  private status and knowledge of its commit ID (e.g. through CI logs
+  or build artifacts containing build numbers) can fetch the code
+  using the commit ID.
 
 [[inline-edit]]
 == Inline Edit
@@ -807,16 +802,21 @@
 +
 This setting controls the email notifications.
 +
-** `Enabled`:
-+
-Email notifications are enabled.
-+
 ** [[cc-me]]`Every comment`:
 +
 Email notifications are enabled and you get notified by email as CC
 on comments that you write yourself.
 +
-** `Disabled`:
+** `Only comments left by others`
++
+Email notifications are enabled for all activities excluding comments or
+reviews authored by you.
++
+** `Only when I am in the attention set`
++
+Email notifications are only sent if the recipient is in the attention set.
++
+** `None`:
 +
 Email notifications are disabled.
 
@@ -1014,7 +1014,7 @@
 inline comment ("Yeah, I see why, let me try again.").
 
 [[security-fixes]]
--- Security Fixes
+== Security Fixes
 
 If a security vulnerability is discovered you normally want to have an
 embargo about it until fixed releases have been made available. This
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index ab79c8f..e2afbf5 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -2,6 +2,7 @@
 [[Apache2_0]]
 Apache2.0
 
+* fonts:material-icons
 * fonts:robotofonts
 
 [[Apache2_0_license]]
@@ -403,6 +404,7 @@
 * @polymer/iron-resizable-behavior
 * @polymer/iron-selector
 * @polymer/iron-validatable-behavior
+* @polymer/marked-element
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
@@ -1120,6 +1122,126 @@
 ----
 
 
+[[highlight_js]]
+highlight.js
+
+* highlight.js
+
+[[highlight_js_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2006, Ivan Sagalaev.
+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.
+
+----
+
+
+[[highlightjs-closure-templates]]
+highlightjs-closure-templates
+
+* highlightjs-closure-templates
+
+[[highlightjs-closure-templates_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2006, Ivan Sagalaev.
+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.
+
+----
+
+
+[[highlightjs-structured-text]]
+highlightjs-structured-text
+
+* highlightjs-structured-text
+
+[[highlightjs-structured-text_license]]
+----
+BSD 3-Clause License

+

+Copyright (c) 2006, Ivan Sagalaev

+All rights reserved.

+

+Redistribution and use in source and binary forms, with or without

+modification, are permitted provided that the following conditions are met:

+

+1. Redistributions of source code must retain the above copyright notice, this

+   list of conditions and the following disclaimer.

+

+2. Redistributions in binary form must reproduce the above copyright notice,

+   this list of conditions and the following disclaimer in the documentation

+   and/or other materials provided with the distribution.

+

+3. Neither the name of the copyright holder nor the names of its

+   contributors may be used to endorse or promote products derived from

+   this software without specific prior written permission.

+

+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"

+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE

+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE

+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE

+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL

+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR

+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER

+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,

+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE

+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[immer]]
 immer
 
@@ -1184,6 +1306,60 @@
 ----
 
 
+[[marked]]
+marked
+
+* marked
+
+[[marked_license]]
+----
+# License information
+
+## Contribution License Agreement
+
+If you contribute code to this project, you are implicitly allowing your code
+to be distributed under the MIT license. You are also implicitly verifying that
+all code is your original work. `</legalese>`
+
+## Marked
+
+Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
+
+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.
+
+## Markdown
+
+Copyright © 2004, John Gruber 
+http://daringfireball.net/ 
+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 “Markdown” 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.
+
+----
+
+
 [[page]]
 page
 
@@ -1491,6 +1667,219 @@
 ----
 
 
+[[safevalues]]
+safevalues
+
+* safevalues
+
+[[safevalues_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
 [[tslib]]
 tslib
 
@@ -1513,3 +1902,216 @@
 
 ----
 
+
+[[web-vitals]]
+web-vitals
+
+* web-vitals
+
+[[web-vitals_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 2020 Google LLC
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       https://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 86ff904..8ccbcab 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -32,7 +32,7 @@
 Gerrit includes an SSH daemon (Apache SSHD), to support authenticated
 uploads of changes directly from `git push` command line clients.
 
-Gerrit includes an SSH client (JSch), to support authenticated
+Gerrit includes an SSH client (Apache SSHD), to support authenticated
 replication of changes to remote systems, such as for automatic
 updates of mirror servers, or realtime backups.
 
@@ -42,31 +42,30 @@
 [[Apache2_0]]
 Apache2.0
 
+* auto:auto-factory
 * 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:text
 * commons:validator
 * dropwizard:dropwizard-core
 * errorprone:annotations
 * flogger:api
+* fonts:material-icons
 * fonts:robotofonts
 * guice:guice
 * guice:guice-assistedinject
 * guice:guice-library
 * guice:guice-servlet
 * guice:javax_inject
-* httpcomponents:httpasyncclient
 * httpcomponents:httpclient
 * httpcomponents:httpcore
-* httpcomponents:httpcore-nio
-* jackson:jackson-core
 * jetty:http
 * jetty:io
 * jetty:jmx
@@ -1114,22 +1113,6 @@
 ----
 
 
-[[elasticsearch]]
-elasticsearch
-
-* elasticsearch-rest-client:elasticsearch-rest-client
-
-[[elasticsearch_license]]
-----
-Elasticsearch
-Copyright 2009-2015 Elasticsearch
-
-This product includes software developed by The Apache Software
-Foundation (http://www.apache.org/).
-
-----
-
-
 [[flexmark]]
 flexmark
 
@@ -2394,43 +2377,6 @@
 ----
 
 
-[[jsch]]
-jsch
-
-* jsch
-
-[[jsch_license]]
-----
-Copyright (c) 2002-2012 Atsuhiko Yamanaka, JCraft,Inc.
-All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-  1. Redistributions of source code must retain the above copyright notice,
-     this list of conditions and the following disclaimer.
-
-  2. Redistributions in binary form must reproduce the above copyright
-     notice, this list of conditions and the following disclaimer in
-     the documentation and/or other materials provided with the distribution.
-
-  3. The names of the authors may not be used to endorse or promote products
-     derived from this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES,
-INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
-FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT,
-INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT,
-INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
-OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
-LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
-NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
-EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
 [[jsoup]]
 jsoup
 
@@ -3362,6 +3308,7 @@
 * @polymer/iron-resizable-behavior
 * @polymer/iron-selector
 * @polymer/iron-validatable-behavior
+* @polymer/marked-element
 * @polymer/neon-animation
 * @polymer/paper-behaviors
 * @polymer/paper-button
@@ -4079,6 +4026,126 @@
 ----
 
 
+[[highlight_js]]
+highlight.js
+
+* highlight.js
+
+[[highlight_js_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2006, Ivan Sagalaev.
+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.
+
+----
+
+
+[[highlightjs-closure-templates]]
+highlightjs-closure-templates
+
+* highlightjs-closure-templates
+
+[[highlightjs-closure-templates_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2006, Ivan Sagalaev.
+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.
+
+----
+
+
+[[highlightjs-structured-text]]
+highlightjs-structured-text
+
+* highlightjs-structured-text
+
+[[highlightjs-structured-text_license]]
+----
+BSD 3-Clause License

+

+Copyright (c) 2006, Ivan Sagalaev

+All rights reserved.

+

+Redistribution and use in source and binary forms, with or without

+modification, are permitted provided that the following conditions are met:

+

+1. Redistributions of source code must retain the above copyright notice, this

+   list of conditions and the following disclaimer.

+

+2. Redistributions in binary form must reproduce the above copyright notice,

+   this list of conditions and the following disclaimer in the documentation

+   and/or other materials provided with the distribution.

+

+3. Neither the name of the copyright holder nor the names of its

+   contributors may be used to endorse or promote products derived from

+   this software without specific prior written permission.

+

+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"

+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE

+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE

+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE

+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL

+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR

+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER

+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,

+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE

+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
 [[immer]]
 immer
 
@@ -4143,6 +4210,60 @@
 ----
 
 
+[[marked]]
+marked
+
+* marked
+
+[[marked_license]]
+----
+# License information
+
+## Contribution License Agreement
+
+If you contribute code to this project, you are implicitly allowing your code
+to be distributed under the MIT license. You are also implicitly verifying that
+all code is your original work. `</legalese>`
+
+## Marked
+
+Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
+
+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.
+
+## Markdown
+
+Copyright © 2004, John Gruber 
+http://daringfireball.net/ 
+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 “Markdown” 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.
+
+----
+
+
 [[page]]
 page
 
@@ -4450,6 +4571,219 @@
 ----
 
 
+[[safevalues]]
+safevalues
+
+* safevalues
+
+[[safevalues_license]]
+----
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
 [[tslib]]
 tslib
 
@@ -4473,6 +4807,219 @@
 ----
 
 
+[[web-vitals]]
+web-vitals
+
+* web-vitals
+
+[[web-vitals_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 2020 Google LLC
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       https://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+----
+
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index c45de05..13873ed 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -29,10 +29,10 @@
 . Download the desired Gerrit archive.
 
 To view previous archives, see
-link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.1.3:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.5.1:
 
 ....
-wget https://gerrit-releases.storage.googleapis.com/gerrit-3.1.3.war
+wget https://gerrit-releases.storage.googleapis.com/gerrit-3.5.1.war
 ....
 
 NOTE: To build and install Gerrit from the source files, see
diff --git a/Documentation/logs.txt b/Documentation/logs.txt
index ba370b1..e120fdd 100644
--- a/Documentation/logs.txt
+++ b/Documentation/logs.txt
@@ -56,6 +56,11 @@
   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:
+** `-1`: The `git-upload-pack` transfer was ultimately not successful
+** `0`: The `git-upload-pack` transfer was ultimately successful
 
 Example:
 ```
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index d8b6250..70352dc 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -93,6 +93,9 @@
 ** `plugin`:
    The name of the plugin that performed the operation.
 
+Performance metrics can be enabled via the
+link:config.gerrit.html#tracing.exportPerformanceMetrics[`tracing.exportPerformanceMetrics`]
+setting.
 
 === Pushes
 
@@ -453,6 +456,15 @@
 === Group
 
 * `group/guess_relevant_groups_latency`: Latency for guessing relevant groups.
+* `group/handles_count`: Number of calls to GroupBackend.handles.
+* `group/get_count`: Number of calls to GroupBackend.get.
+* `group/suggest_count`: Number of calls to GroupBackend.suggest.
+* `group/contains_count`: Number of calls to GroupMemberships.contains.
+* `group/contains_any_of_count`: Number of calls to
+  GroupMemberships.containsAnyOf.
+* `group/intersection_count`: Number of calls to GroupMemberships.intersection.
+* `group/known_groups_count`: Number of calls to GroupMemberships.getKnownGroups.
+
 
 === Replication Plugin
 
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index b376d6e..0e1dfd0 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -253,5 +253,5 @@
 
 In case of rollback from NoteDB to ReviewDB, all the meta refs and the
 sequence ref need to be removed.
-The [remove-notedb-refs.sh,role=external,window=_blank](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/contrib/remove-notedb-refs.sh)
+The link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/contrib/remove-notedb-refs.sh[remove-notedb-refs.sh,role=external,window=_blank]
 script has been written to automate this process.
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
index 4e93da1..e4cb5d0 100644
--- a/Documentation/pg-plugin-checks-api.txt
+++ b/Documentation/pg-plugin-checks-api.txt
@@ -10,6 +10,10 @@
 when a change page is loaded. Such a call would return a list of `Runs` and each
 run can contain a list of `Results`.
 
+`Results` messages will render as markdown. It follows the
+[CommonMark](https://commonmark.org/help/) spec except inline images and direct
+HTML are not rendered and kept as plaintext.
+
 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
@@ -24,8 +28,8 @@
 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]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/buildbucket/+/main/web/plugin.ts[Chromium Buildbucket Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/code-coverage/+/main/web/plugin.ts[Chromium Coverage Plugin]
 
 [[register]]
 == register
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index dc7986f..d7343c2 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -30,10 +30,6 @@
 });
 ```
 
-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
 
@@ -95,31 +91,36 @@
 [[low-level-style]]
 === Styling DOM Elements
 
-A plugin may provide Polymer's
-https://polymer-library.polymer-project.org/3.0/docs/devguide/style-shadow-dom[style
-modules,role=external,window=_blank] to style individual endpoints using
-`plugin.registerStyleModule(endpointName, moduleName)`. A style must be defined
-as a standalone `<dom-module>` defined in the same .js file.
+Gerrit only offers customized CSS styling by setting
+link:https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_[custom_properties]
+(aka css variables).
 
-See
-link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples/theme-plugin.js[samples/theme-plugin.js]
-for an example.
+See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/styles/themes/[app-theme.ts]
+for the list of available variables.
+
+You can just create `<style>` elements yourself and add them to the
+`document.head`, but for your convenience the Plugin API provides a simple
+`styleApi().insertCSSRule()` method for doing just that. Typically you would
+define a CSS rule for `html`, which is always applied, or for a specific theme
+such as `html.lightTheme`. 
 
 ``` js
-const styleElement = document.createElement('dom-module');
-styleElement.innerHTML =
- `<template>
-    <style>
-    html {
-      --primary-text-color: red;
-    }
-   </style>
- </template>`;
-
-styleElement.register('some-style-module');
-
 Gerrit.install(plugin => {
-  plugin.registerStyleModule('change-metadata', 'some-style-module');
+  plugin.styleApi().insertCSSRule(`
+    html {
+      --header-text-color: black;
+    }
+  `);
+  plugin.styleApi().insertCSSRule(`
+    html.lightTheme {
+      --header-background-color: red;
+    }
+  `);
+  plugin.styleApi().insertCSSRule(`
+    html.darkTheme {
+      --header-background-color: blue;
+    }
+  `);
 });
 ```
 
@@ -146,10 +147,6 @@
 binding,role=external,window=_blank] for plugins that don't use Polymer. Can be used to bind element
 attribute changes to callbacks.
 
-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)`
 
@@ -166,11 +163,6 @@
 
 See link:pg-plugin-endpoints.html[endpoints].
 
-=== registerStyleModule
-`plugin.registerStyleModule(endpointName, moduleName)`
-
-See link:#low-level-style[above].
-
 === on
 Register a JavaScript callback to be invoked when events occur within
 the web interface. Signature
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index c16d0d4..dd82f27 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -132,6 +132,10 @@
 === settings-screen
 This endpoint is situated at the end of the body of the settings screen.
 
+=== profile
+This endpoint is situated at the top of the Profile section of the settings
+screen below the section description text.
+
 === reply-text
 This endpoint wraps the textarea in the reply dialog.
 
@@ -141,6 +145,11 @@
 === header-title
 This endpoint wraps the title-text in the application header.
 
+=== cherrypick-main
+This endpoint is located in the cherrypick dialog. It has two slots `top`
+and `bottom` and `changes` as a parameter with the list of changes (or
+just the one change) to be cherrypicked.
+
 === confirm-revert-change
 This endpoint is inside the confirm revert dialog. By default it displays a
 generic confirmation message regarding reverting the change. Plugins may add
@@ -228,3 +237,13 @@
 +
 current revision displayed, an instance of
 link:rest-api-changes.html#revision-info[RevisionInfo]
+
+=== account-status-icon
+The `account-status-icon` extension point adds an icon to all account chips and
+labels.
+
+In addition to default parameters, the following are available:
+
+* `accountId`
++
+the Id of the account that the status icon should correspond to.
\ No newline at end of file
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index 586f685..560fb92 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -10,6 +10,10 @@
   -d <SITE_PATH>
   [--enable-httpd | --disable-httpd]
   [--enable-sshd | --disable-sshd]
+  [--debug]
+  [--debug-port]
+  [--debug_address]
+  [--suspend]
   [--console-log]
   [--replica]
   [--headless]
@@ -39,6 +43,17 @@
 	Enable (or disable) the internal SSH daemon, answering SSH
 	clients and remotely executed commands.  Enabled by default.
 
+--debug::
+	Start JVM in debug mode.
+
+--debug-port::
+--debug_address:
+	Specify which JVM debug port/address to use. The default debug address is 8000.
+
+--suspend::
+	Start JVM debug in suspended mode. The JVM will await for a debugger
+	to attach before proceeding with the start.
+
 --replica::
 	Run in replica mode, permitting only read operations
     by clients.  Commands which modify state such as
diff --git a/Documentation/pgm-reindex.txt b/Documentation/pgm-reindex.txt
index 5167277..0653d8d 100644
--- a/Documentation/pgm-reindex.txt
+++ b/Documentation/pgm-reindex.txt
@@ -20,7 +20,8 @@
 
 == OPTIONS
 --threads::
-	Number of threads to use for indexing.
+	Number of threads to use for indexing. Default is
+	link:config-gerrit.html#index.batchThreads[index.batchThreads]
 
 --changes-schema-version::
 	Schema version to reindex; default is most recent version.
@@ -35,6 +36,10 @@
 	Reindex only index with given name. This option can be supplied
 	more than once to reindex multiple indices.
 
+--disable-cache-stats::
+	Disables printing cache statistics at the end of program to reduce
+	noise. Defaulted when reindex is run from init on a new site.
+
 == CONTEXT
 The secondary index must be enabled. See
 link:config-gerrit.html#index.type[index.type].
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 45a39d8..991f36c 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -388,7 +388,7 @@
 |`revision`           ||
 The revision of the `refs/meta/config` branch from which the access
 rights were loaded.
-|`inherits_from`      |not set for the `All-Project` project|
+|`inherits_from`      |not set for the `All-Projects` project|
 The parent project from which permissions are inherited as a
 link:rest-api-projects.html#project-info[ProjectInfo] entity.
 |`local`              ||
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index ae0c0a6..7e06e4a 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1284,7 +1284,7 @@
   )]}'
   {
     "changes_per_page": 25,
-    "theme": "LIGHT",
+    "theme": "AUTO",
     "date_format": "STD",
     "time_format": "HHMM_12",
     "diff_view": "SIDE_BY_SIDE",
@@ -1292,6 +1292,7 @@
     "mute_common_path_prefixes": true,
     "publish_comments_on_push": true,
     "work_in_progress_by_default": true,
+    "allow_browser_notifications": true,
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
@@ -1344,6 +1345,7 @@
     "size_bar_in_change_table": true,
     "disable_keyboard_shortcuts": true,
     "disable_token_highlighting": true,
+    "allow_browser_notifications": false,
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
     "my": [
@@ -2658,7 +2660,7 @@
 Allowed values are `10`, `25`, `50`, `100`.
 |`theme`                        ||
 Which theme to use.
-Allowed values are `DARK` or `LIGHT`.
+Allowed values are `AUTO` or `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (Gerrit web app UI only).
@@ -2686,6 +2688,8 @@
 |`signed_off_by`                |not set if `false`|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
+|`allow_browser_notifications`  |not set if `false`|
+Whether to prompt user to enable browser notification in browser.
 |`my`                           ||
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
@@ -2729,7 +2733,7 @@
 Allowed values are `10`, `25`, `50`, `100`.
 |`theme`                        |optional|
 Which theme to use.
-Allowed values are `DARK` or `LIGHT`.
+Allowed values are `AUTO` or `DARK` or `LIGHT`.
 |`expand_inline_diffs`          |not set if `false`|
 Whether to expand diffs inline instead of opening as separate page
 (Gerrit web app UI only).
@@ -2755,6 +2759,8 @@
 |`signed_off_by`                |optional|
 Whether to insert Signed-off-by footer in changes created with the
 inline edit feature.
+|`allow_browser_notifications`  |not set if `false`|
+Whether to prompt user to enable browser notification in browser.
 |`my`                           |optional|
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 33e7804..2f144c6 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -77,8 +77,8 @@
 link:user-search.html#_search_operators[query string] must be provided
 by the `q` parameter. The `n` parameter can be used to limit the
 returned results. The `no-limit` parameter can be used remove the default
-limit on queries and return all results. This might not be supported by
-all index backends.
+limit on queries and return all results (does not apply to anonymous requests).
+This might not be supported by all index backends.
 
 As result a list of link:#change-info[ChangeInfo] entries is returned.
 The change output is sorted by the last update time, most recently
@@ -103,15 +103,17 @@
       "id": "demo~master~Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "project": "demo",
       "branch": "master",
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2012-07-17 07:19:27.766000000",
-         "reason": "reviewer or cc replied"
+          "last_update": "2012-07-17 07:19:27.766000000",
+          "reason": "reviewer or cc replied"
         }
-      ]
+      },
       "change_id": "Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
       "subject": "One change",
       "status": "NEW",
@@ -224,19 +226,18 @@
 
 [[labels]]
 --
-* `LABELS`: a summary of each label required for submit, and
-  approvers that have granted (or rejected) with that label
-  as well as all reviewers by state, and reviewers that may
-  be removed by the current user.
+* `LABELS`: Includes the `labels` field which contains all information about
+labels and approvers that have granted (or rejected) that label. Does not
+include the `permitted_voting_range` attribute with the list of
+link:#approval-info[ApprovalInfo] of the `all` attribute.
 --
 
 [[detailed-labels]]
 --
-* `DETAILED_LABELS`: detailed label information, including numeric
-  values of all existing approvals, recognized label values, values
-  permitted to be set by any reviewer and the change owner, all
-  reviewers by state, and reviewers that may be removed by the
-  current user.
+* `DETAILED_LABELS`: Includes the `labels` field which contains all information
+about labels and approvers that have granted (or rejected) that label, including
+the `permitted_voting_range` attribute with the list of
+link:#approval-info[ApprovalInfo] of the `all` attribute.
 --
 
 [[submit-requirements]]
@@ -546,15 +547,17 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
-    "attention_set": [
-      {
+    "attention_set": {
+      "1000096": {
         "account": {
-          "name": "John Doe"
+          "_account_id": 1000096,
+          "name": "John Doe",
+          "email": "john.doe@example.com"
         },
-       "last_update": "2013-02-21 11:16:36.775000000",
-       "reason": "reviewer or cc replied"
+        "last_update": "2013-02-21 11:16:36.775000000",
+        "reason": "reviewer or cc replied"
       }
-    ]
+    },
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -613,15 +616,17 @@
   )]}'
   {
     "added": {
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2013-02-21 11:16:36.775000000",
-         "reason": "reviewer or cc replied"
+          "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"
     },
@@ -652,15 +657,17 @@
       "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
       "project": "myProject",
       "branch": "master",
-      "attention_set": [
-        {
+      "attention_set": {
+        "1000096": {
           "account": {
-            "name": "John Doe"
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com"
           },
-         "last_update": "2013-02-21 11:16:36.775000000",
-         "reason": "reviewer or cc replied"
+          "last_update": "2013-02-21 11:16:36.775000000",
+          "reason": "reviewer or cc replied"
         }
-      ],
+      },
       "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
       "subject": "Implementing Feature X",
       "status": "NEW",
@@ -720,18 +727,18 @@
     "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
     "project": "myProject",
     "branch": "master",
-    "attention_set": [
-      {
+    "attention_set": {
+      "1000096": {
         "account": {
           "_account_id": 1000096,
           "name": "John Doe",
           "email": "john.doe@example.com",
           "username": "jdoe"
         },
-       "last_update": "2013-02-21 11:16:36.775000000",
-       "reason": "reviewer or cc replied"
+        "last_update": "2013-02-21 11:16:36.775000000",
+        "reason": "reviewer or cc replied"
       }
-    ]
+    },
     "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
     "subject": "Implementing Feature X",
     "status": "NEW",
@@ -795,8 +802,8 @@
           }
         ]
         "values": {
-          "-2": "This shall not be merged",
-          "-1": "I would prefer this is not merged as is",
+          "-2": "This shall not be submitted",
+          "-1": "I would prefer this is not submitted as is",
           " 0": "No score",
           "+1": "Looks good to me, but someone else must approve",
           "+2": "Looks good to me, approved"
@@ -817,6 +824,26 @@
         "+2"
       ]
     },
+    "removable_labels": {
+      "Code-Review": {
+        "-1": [
+          {
+            "_account_id": 1000096,
+            "name": "John Doe",
+            "email": "john.doe@example.com",
+            "username": "jdoe"
+          }
+        ],
+        "+1": [
+          {
+            "_account_id": 1000097,
+            "name": "Jane Roe",
+            "email": "jane.roe@example.com",
+            "username": "jroe"
+          }
+        ]
+      }
+    },
     "removable_reviewers": [
       {
         "_account_id": 1000096,
@@ -1506,6 +1533,223 @@
   The change could not be rebased due to a path conflict during merge.
 ----
 
+[[rebase-chain]]
+=== Rebase Chain
+--
+'POST /changes/link:#change-id[\{change-id\}]/rebase:chain'
+--
+
+Rebases an ancestry chain of changes.
+
+The operated change is treated as the chain tip. All unsubmitted ancestors are rebased.
+
+Requires a linear ancestry relation (single parenting throughout the chain).
+
+Optionally, the parent revision (of the oldest ancestor to be rebased) can be changed to another
+change, revision or branch through the link:#rebase-input[RebaseInput] entity.
+
+If the chain is outdated, i.e., there's a change that depends on an old revision of its parent, the
+result is the same as individually rebasing all outdated changes on top of their parent's latest
+revision before running the rebase chain action.
+
+.Request
+----
+  POST /changes/myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f/rebase:chain HTTP/1.0
+  Content-Type: application/json;charset=UTF-8
+
+  {
+    "base" : "1234",
+  }
+----
+
+As response a link:#rebase-chain-info[RebaseChainInfo] entity is returned that
+describes the rebased changes. Information about the current patch sets
+are included.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "rebased_changes": [
+      {
+        "id": "myProject~master~I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+        "project": "myProject",
+        "branch": "master",
+        "hashtags": [
+
+        ],
+        "change_id": "I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+        "subject": "456",
+        "status": "NEW",
+        "created": "2022-11-21 20: 51: 31.000000000",
+        "updated": "2022-11-21 20: 56: 49.000000000",
+        "submit_type": "MERGE_IF_NECESSARY",
+        "insertions": 0,
+        "deletions": 0,
+        "total_comment_count": 0,
+        "unresolved_comment_count": 0,
+        "has_review_started": true,
+        "meta_rev_id": "a2a6692213f546e1045ecf4647439fac8d6d8faa",
+        "_number": 21,
+        "owner": {
+          "_account_id": 1000000
+        },
+        "current_revision": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+        "revisions": {
+          "c3b2ba222d42a56e05c90f88d4509a124620517d": {
+            "kind": "NO_CHANGE",
+            "_number": 2,
+            "created": "2022-11-21 20: 56: 49.000000000",
+            "uploader": {
+              "_account_id": 1000000
+            },
+            "ref": "refs/changes/21/21/2",
+            "fetch": {
+
+            },
+            "commit": {
+              "parents": [
+                {
+                  "commit": "7803f427dd7c4a2441466e4d740a1850dcee1af4",
+                  "subject": "123"
+                }
+              ],
+              "author": {
+                "name": "Nitzan Gur-Furman",
+                "email": "nitzan@google.com",
+                "date": "2022-11-21 20: 49: 39.000000000",
+                "tz": 60
+              },
+              "committer": {
+                "name": "Administrator",
+                "email": "admin@example.com",
+                "date": "2022-11-21 20: 56: 49.000000000",
+                "tz": 60
+              },
+              "subject": "456",
+              "message": "456\n"
+            },
+            "description": "Rebase"
+          }
+        },
+        "requirements": [
+
+        ],
+        "submit_records": [
+          {
+            "rule_name": "gerrit~DefaultSubmitRule",
+            "status": "NOT_READY",
+            "labels": [
+              {
+                "label": "Code-Review",
+                "status": "NEED"
+              }
+            ]
+          }
+        ]
+      },
+      {
+        "id": "myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+        "project": "myProject",
+        "branch": "master",
+        "hashtags": [
+
+        ],
+        "change_id": "I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+        "subject": "789",
+        "status": "NEW",
+        "created": "2022-11-21 20: 51: 31.000000000",
+        "updated": "2022-11-21 20: 56: 49.000000000",
+        "submit_type": "MERGE_IF_NECESSARY",
+        "insertions": 0,
+        "deletions": 0,
+        "total_comment_count": 0,
+        "unresolved_comment_count": 0,
+        "has_review_started": true,
+        "meta_rev_id": "3bfb843fea471f96e16b9199c3a30fff0285bc45",
+        "_number": 22,
+        "owner": {
+          "_account_id": 1000000
+        },
+        "current_revision": "77eb17a9501a5c21963bc6af56085e60f281acbb",
+        "revisions": {
+          "77eb17a9501a5c21963bc6af56085e60f281acbb": {
+            "kind": "NO_CHANGE",
+            "_number": 2,
+            "created": "2022-11-21 20: 56: 49.000000000",
+            "uploader": {
+              "_account_id": 1000000
+            },
+            "ref": "refs/changes/22/22/2",
+            "fetch": {
+
+            },
+            "commit": {
+              "parents": [
+                {
+                  "commit": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+                  "subject": "456"
+                }
+              ],
+              "author": {
+                "name": "Nitzan Gur-Furman",
+                "email": "nitzan@google.com",
+                "date": "2022-11-21 20: 51: 07.000000000",
+                "tz": 60
+              },
+              "committer": {
+                "name": "Administrator",
+                "email": "admin@example.com",
+                "date": "2022-11-21 20: 56: 49.000000000",
+                "tz": 60
+              },
+              "subject": "789",
+              "message": "789\n"
+            },
+            "description": "Rebase"
+          }
+        },
+        "requirements": [
+
+        ],
+        "submit_records": [
+          {
+            "rule_name": "gerrit~DefaultSubmitRule",
+            "status": "NOT_READY",
+            "labels": [
+              {
+                "label": "Code-Review",
+                "status": "NEED"
+              }
+            ]
+          }
+        ]
+      }
+    ],
+  }
+----
+
+If the change cannot be rebased, e.g. due to conflicts, the response is
+"`409 Conflict`" and the error message is contained in the response
+body.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  Change I0e534de9d7f0d6f35b71f7d726acf835b2110c66 could not be rebased due to a conflict during
+  merge.
+
+  merge conflict(s):
+  a.txt
+----
+
 [[move-change]]
 === Move Change
 --
@@ -1857,7 +2101,8 @@
 
 * The given change.
 * If link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
-  is enabled, include all open changes with the same topic.
+  is enabled OR if the `o=TOPIC_CLOSURE` query parameter is passed, include all
+  open changes with the same topic.
 * For each change whose submit type is not CHERRY_PICK, include unmerged
   ancestors targeting the same branch.
 
@@ -1884,7 +2129,7 @@
 
 Standard link:#query-options[formatting options] can be specified
 with the `o` parameter, as well as the `submitted_together` specific
-option `NON_VISIBLE_CHANGES`.
+options `NON_VISIBLE_CHANGES` and `TOPIC_CLOSURE`.
 
 .Response
 ----
@@ -1925,8 +2170,8 @@
             }
           ],
           "values": {
-            "-2": "This shall not be merged",
-            "-1": "I would prefer this is not merged as is",
+            "-2": "This shall not be submitted",
+            "-1": "I would prefer this is not submitted as is",
             " 0": "No score",
             "+1": "Looks good to me, but someone else must approve",
             "+2": "Looks good to me, approved"
@@ -2022,8 +2267,8 @@
             }
           ],
           "values": {
-            "-2": "This shall not be merged",
-            "-1": "I would prefer this is not merged as is",
+            "-2": "This shall not be submitted",
+            "-1": "I would prefer this is not submitted as is",
             " 0": "No score",
             "+1": "Looks good to me, but someone else must approve",
             "+2": "Looks good to me, approved"
@@ -2124,6 +2369,66 @@
   HTTP/1.1 204 No Content
 ----
 
+[[apply-patch]]
+=== Create patch-set from patch
+--
+'POST /changes/link:#change-id[\{change-id\}]/patch:apply'
+--
+
+Creates a new patch set on a destination change from the provided patch.
+
+The patch must be provided in the request body, inside a
+link:#applypatchpatchset-input[ApplyPatchPatchSetInput] entity.
+
+If a base commit is given, the patch is applied on top of it. Otherwise, the
+patch is applied on top of the target change's branch tip.
+
+Applying the patch will fail if the destination change is closed, or in case of any conflicts.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/patch:apply HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "patch": {
+      "patch": "new file mode 100644\n--- /dev/null\n+++ b/a_new_file.txt\n@@ -0,0 +1,2 @@ \
++Patch compatible `git diff` output \
++For example: `link:#get-patch[<gerrit patch>] | base64 -d | sed -z 's/\n/\\n/g'`"
+    }
+  }
+----
+
+As response a link:#change-info[ChangeInfo] entity is returned that
+describes the destination change after applying the patch.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "project": "myProject",
+    "branch": "release-branch",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
+    "subject": "Original change subject",
+    "status": "NEW",
+    "created": "2013-02-01 09:59:32.126000000",
+    "updated": "2013-02-21 11:16:36.775000000",
+    "mergeable": true,
+    "insertions": 12,
+    "deletions": 11,
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    },
+    "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c"
+  }
+----
+
 [[get-included-in]]
 === Get Included In
 --
@@ -2579,35 +2884,6 @@
   }
 ----
 
-[[ignore]]
-=== Ignore
---
-'PUT /changes/link:#change-id[\{change-id\}]/ignore'
---
-
-Marks a change as ignored. The change will not be shown in the incoming
-reviews dashboard, and email notifications will be suppressed. Ignoring
-a change does not cause the change's "updated" timestamp to be modified,
-and the owner is not notified.
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/ignore HTTP/1.0
-----
-
-[[unignore]]
-=== Unignore
---
-'PUT /changes/link:#change-id[\{change-id\}]/unignore'
---
-
-Un-marks a change as ignored.
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unignore HTTP/1.0
-----
-
 [[get-hashtags]]
 === Get Hashtags
 --
@@ -2825,8 +3101,18 @@
 --
 
 Tests a submit requirement and returns the result as a
-link:#submit-requirement-result-info[SubmitRequirementResultInfo]. The request
-body must contain a link:#submit-requirement-input[SubmitRequirementInput].
+link:#submit-requirement-result-info[SubmitRequirementResultInfo].
+
+The submit requirement can be specified in one of the following ways:
+
+  * In the request body as a link:#submit-requirement-input[SubmitRequirementInput].
+  * By passing the two request parameters `sr-name` and
+ `refs-config-change-id`. The submit requirement will then be loaded from
+ the project config pointed to by the latest patchset of this change ID.
+ The `sr-name` should point to an existing submit-requirement and the
+ `refs-config-change-id` must be a valid change identifier for a change in the
+ refs/meta/config branch, otherwise the request would fail with
+ `400 Bad Request`.
 
 Note that this endpoint does not modify the change resource.
 
@@ -2946,12 +3232,19 @@
   Content-Type: application/json; charset=UTF-8
 
   {
-    "binary_content": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
+    "binary_content": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==",
+    "file_mode": 100755
   }
 ----
 
 Note that it must be base-64 encoded data uri.
 
+The "file_mode" field is optional, and if provided must be in octal format. The field
+indicates whether the file is executable or not and has a value of either 100755
+(executable) or 100644 (not executable). If it's unset, this indicates no change
+has been made. New files default to not being executable if this parameter is not
+provided
+
 When change edit doesn't exist for this change yet it is created. When file
 content isn't provided, it is wiped out for that file. As response
 "`204 No Content`" is returned.
@@ -3973,8 +4266,8 @@
           }
         ]
         "values": {
-          "-2": "This shall not be merged",
-          "-1": "I would prefer this is not merged as is",
+          "-2": "This shall not be submitted",
+          "-1": "I would prefer this is not submitted as is",
           " 0": "No score",
           "+1": "Looks good to me, but someone else must approve",
           "+2": "Looks good to me, approved"
@@ -4047,6 +4340,16 @@
 Retrieves related changes of a revision.  Related changes are changes that either
 depend on, or are dependencies of the revision.
 
+Additional fields can be obtained by adding `o` parameters. Since these may slow
+down processing they are disabled by default. Currently a single parameter is
+supported:
+
+[[get-related-changes-submittable]]
+--
+* `SUBMITTABLE`: Compute the `submittable` field in the returned
+  link:#related-change-and-commit-info[RelatedChangeAndCommitInfo] entities.
+--
+
 .Request
 ----
   GET /changes/gerrit~master~I5e4fc08ce34d33c090c9e0bf320de1b17309f774/revisions/b1cb4caa6be46d12b94c25aa68aebabcbb3f53fe/related HTTP/1.0
@@ -4581,60 +4884,6 @@
 If the `path` parameter is set, the returned content is a diff of the single
 file that the path refers to.
 
-[[submit-preview]]
-=== Submit Preview
---
-'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/preview_submit'
---
-Gets a file containing thin bundles of all modified projects if this
-change was submitted. The bundles are named `${ProjectName}.git`.
-Each thin bundle contains enough to construct the state in which a project would
-be in if this change were submitted. The base of the thin bundles are the
-current target branches, so to make use of this call in a non-racy way, first
-get the bundles and then fetch all projects contained in the bundle.
-(This assumes no non-fastforward pushes).
-
-You need to give a parameter '?format=zip' or '?format=tar' to specify the
-format for the outer container. It is always possible to use tgz, even if
-tgz is not in the list of allowed archive formats.
-
-To make good use of this call, you would roughly need code as found at:
-----
- $ curl -Lo preview_submit_test.sh http://review.example.com:8080/tools/scripts/preview_submit_test.sh
-----
-.Request
-----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/current/preview_submit?zip HTTP/1.0
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Date: Tue, 13 Sep 2016 19:13:46 GMT
-  Content-Disposition: attachment; filename="submit-preview-147.zip"
-  X-Content-Type-Options: nosniff
-  Cache-Control: no-cache, no-store, max-age=0, must-revalidate
-  Pragma: no-cache
-  Expires: Mon, 01 Jan 1990 00:00:00 GMT
-  Content-Type: application/x-zip
-  Transfer-Encoding: chunked
-
-  [binary stuff]
-----
-
-In case of an error, the response is not a zip file but a regular json response,
-containing only the error message:
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  "Anonymous users cannot submit"
-----
-
 [[get-mergeable]]
 === Get Mergeable
 --
@@ -5084,6 +5333,9 @@
 with a new message, which contains the name of the user who deletes
 the comment and the reason why it's deleted.
 
+This endpoint also marks the comment as resolved since deleted comments are not
+actionable.
+
 Note that only users with the
 link:access-control.html#capability_administrateServer[Administrate Server]
 global capability are permitted to delete a comment.
@@ -5224,8 +5476,8 @@
   }
 ----
 
-[[get-ported-comments]]
-=== Get Ported Comments
+[[list-ported-comments]]
+=== List Ported Comments
 --
 'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/ported_comments'
 --
@@ -5308,24 +5560,24 @@
   }
 ----
 
-[[get-ported-drafts]]
-=== Get Ported Drafts
+[[list-ported-drafts]]
+=== List Ported Drafts
 --
 'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/ported_drafts'
 --
 
 Ports draft comments of other revisions to the requested revision.
 
-This endpoint behaves similarly to the link:#get-ported-comments[Get Ported Comments] endpoint.
+This endpoint behaves similarly to the link:#list-ported-comments[List Ported Comments] endpoint.
 With this endpoint, only draft comments of the calling user are ported, though. If a draft comment
 is a reply to a published comment, only the ported draft comment is returned.
 
 Depending on the filtering rules, it's possible that this endpoint returns a draft comment which is
 a reply to a comment thread which is not returned by the
-link:#get-ported-comments[Get Ported Comments] endpoint. That's intended behavior. Callers must be
+link:#list-ported-comments[List Ported Comments] endpoint. That's intended behavior. Callers must be
 able to handle this situation. The same holds for drafts which are a reply to a robot comment.
 
-Different than the link:#get-ported-comments[Get Ported Comments] endpoint, the `author` of the
+Different than the link:#list-ported-comments[List 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.
@@ -5357,8 +5609,8 @@
   }
 ----
 
-[[apply-fix]]
-=== Apply Fix
+[[apply-stored-fix]]
+=== Apply Stored Fix
 --
 'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/apply'
 --
@@ -5425,6 +5677,101 @@
   The existing change edit could not be merged with another tree.
 ----
 
+[[apply-provided-fix]]
+==== Apply Provided Fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fix:apply'
+--
+Applies a list of <<fix-replacement-info,FixReplacementInfo>> loaded from the
+<<apply-provided-fix-input,ApplyProvidedFixInput>> entity. The fixes are passed as part of the request body. The
+application of the fixes creates a new change edit. `Apply Provided Fix` can only be applied on the current
+patchset.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fix:apply HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "fix_replacement_infos": [
+      {
+        "path": "abcd.txt",
+        "range": {
+          "start_line": 2,
+          "start_character": 2,
+          "end_line": 2,
+          "end_character": 5
+        },
+        "replacement": "abcdefg"
+      }
+    ]
+  }
+----
+
+If the `ApplyProvidedFixInput` was successfully applied, an link:#edit-info[EditInfo] describing the
+resulting change edit is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "commit": {
+      "commit": "bd43e48c33d2b1a03485040eba38cefc505f7997",
+      "parents": [
+        {
+          "commit": "9825962f8ab6da89afebad3f5034db05fb4b7560"
+        }
+      ],
+      "author": {
+          "name": "John Doe",
+          "email": "john.doe@example.com",
+          "date": "2022-05-07 15:21:27.000000000",
+          "tz": 120
+      },
+      "committer": {
+           "name": "Jane Doe",
+           "email": "jane.doe@example.com",
+           "date": "2022-05-07 15:35:43.000000000",
+           "tz": 120
+      },
+      "subject": "Implement feature X",
+      "message": "Implement feature X\n\nWith this feature ..."
+    },
+    "base_patch_set_number": 1,
+    "base_revision": "86d87686ce0ef7f7c536bfc7e9a66f5a6fa5d658",
+    "ref": "refs/users/01/1000001/edit-1/1"
+  }
+----
+
+If the application failed due to presence of an existing change edit,
+the response "`400 Bad Request`" including an error message in the response body
+is returned.
+
+.Response
+----
+  HTTP/1.1 400 Bad Request
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  Change edit already exists. A new change edit can't be created
+----
+
+If the application failed due to application on a previous patch set, the response
+"`409 Conflict`" including an error message in the response body is returned.
+
+.Response
+----
+  HTTP/1.1 409 Conflict
+  Content-Disposition: attachment
+  Content-Type: text/plain; charset=UTF-8
+
+  A change edit may only be created for the current patch set 1,2 (and not for 1,1)
+----
+
 [[list-files]]
 === List Files
 --
@@ -5781,8 +6128,8 @@
 differences are reported in the result.  Valid values are `IGNORE_NONE`,
 `IGNORE_TRAILING`, `IGNORE_LEADING_AND_TRAILING` or `IGNORE_ALL`.
 
-[[preview-fix]]
-=== Preview fix
+[[preview-stored-fix]]
+=== Preview Stored Fix
 --
 'GET /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fixes/<<fix-id,\{fix-id\}>>/preview'
 --
@@ -5792,6 +6139,102 @@
 
 Each link:#diff-info[DiffInfo] is the differences between the patch set indicated by revision-id and a virtual patch set with the applied fix.
 
+[[preview-provided-fix]]
+=== Preview Provided fix
+--
+'POST /changes/<<change-id,\{change-id\}>>/revisions/<<revision-id,\{revision-id\}>>/fix:preview'
+--
+
+Gets the diffs of all files for a list of <<fix-replacement-info,FixReplacementInfo>> loaded from
+the <<apply-provided-fix-input,ApplyProvidedFixInput>> entity. The fixes are passed as part of the
+request body.
+As response, a map of link:#diff-info[DiffInfo] is returned that describes the diffs.
+
+Each link:#diff-info[DiffInfo] is the differences between the patch set indicated by revision-id
+and a virtual patch set with the applied fix. No content on the server will be modified as part of this request.
+
+.Request
+----
+  POST /changes/myProject~master~Id6f0b9d946791f8aba90ace53074eda565983452/revisions/1/fix:preview HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "fix_replacement_infos": [
+      {
+        "path": "abcd.txt",
+        "range": {
+          "start_line": 2,
+          "start_character": 2,
+          "end_line": 2,
+          "end_character": 5
+        },
+        "replacement": "abcdefg"
+      }
+    ]
+  }
+----
+
+If the `Preview Provided Fix` operation was successful, a link:#diff-info[DiffInfo] previewing the
+change is returned.
+
+.Response
+----
+  HTTP/1.1 200 OK
+    Content-Disposition: attachment
+    Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "abcd.txt": {
+      "meta_a": {
+        "name": "abcd.txt",
+        "content_type": "text/plain",
+        "lines": 3
+      },
+      "meta_b": {
+        "name": "abcd.txt",
+        "content_type": "text/plain",
+        "lines": 3
+      },
+      "intraline_status": "OK",
+      "change_type": "MODIFIED",
+      "content": [
+        {
+          "ab": [
+            "ABCDEFGHI"
+          ]
+        },
+        {
+          "a": [
+            "JKLMNOPQR"
+          ],
+          "b": [
+            "JKabcdefgOPQR"
+          ],
+          "edit_a": [
+            [
+              2,
+              3
+            ]
+          ],
+          "edit_b": [
+            [
+              2,
+              7
+            ]
+          ]
+        },
+        {
+          "ab": [
+            ""
+          ]
+        }
+      ]
+    }
+  }
+----
+
+
 [[get-blame]]
 === Get Blame
 --
@@ -6322,7 +6765,7 @@
 * a commit ID ("674ac754f91e64a0efb8087e59a176484bd534d1")
 * an abbreviated commit ID that uniquely identifies one revision of the
   change ("674ac754"), at least 4 digits are required
-* a legacy numeric patch number ("1" for first patch set of the change)
+* a numeric patch number ("1" for first patch set of the change)
 * "0" or the literal `edit` for a change edit
 
 [[json-entities]]
@@ -6375,6 +6818,46 @@
 at the server or permissions are modified. Not present if false.
 |====================================
 
+[[applypatch-input]]
+=== ApplyPatchInput
+The `ApplyPatchInput` entity contains information about a patch to apply.
+
+A new commit will be created from the patch, and saved as a new patch set.
+
+[options="header",cols="1,^1,5"]
+|=================================
+|Field Name          ||Description
+|`patch`             |required|
+The patch to be applied. Must be compatible with `git diff` output.
+For example, link:#get-patch[Get Patch] output.
+|=================================
+
+[[applypatchpatchset-input]]
+=== ApplyPatchPatchSetInput
+The `ApplyPatchPatchSetInput` entity contains information for creating a new patch set from a
+given patch.
+
+[options="header",cols="1,^1,5"]
+|=================================
+|Field Name           ||Description
+|`patch`              |required|
+The details of the patch to be applied as a link:#applypatch-input[ApplyPatchInput] entity.
+|`commit_message`     |optional|
+The commit message for the new patch set. If not specified, a predefined message will be used.
+|`base`               |optional|
+40-hex digit SHA-1 of the commit which will be the parent commit of the newly created patch set.
+If set, it must be a merged commit or a change revision on the destination branch.
+Otherwise, the target change's branch tip will be used.
+|`author`             |optional|
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
+The caller needs "Forge Author" permission when using this field, unless specifies their own details.
+This field does not affect the owner of the change, which will continue to use the identity of the
+caller.
+|=================================
+
+
 [[approval-info]]
 === ApprovalInfo
 The `ApprovalInfo` entity contains information about an approval from a
@@ -6520,7 +7003,14 @@
 |`topic`              |optional|The topic to which this change belongs.
 |`attention_set`      |optional|
 The map that maps link:rest-api-accounts.html#account-id[account IDs]
-to link:#attention-set-info[AttentionSetInfo] of that account.
+to link:#attention-set-info[AttentionSetInfo] of that account. Those are all
+accounts that are currently in the attention set.
+|`removed_from_attention_set`      |optional|
+The map that maps link:rest-api-accounts.html#account-id[account IDs]
+to link:#attention-set-info[AttentionSetInfo] of that account. Those are all
+accounts that were in the attention set but were removed. The
+link:#attention-set-info[AttentionSetInfo] is the latest and most recent removal
+ of the account from the attention set.
 |`assignee`           |optional|
 The assignee of the change as an link:rest-api-accounts.html#account-info[
 AccountInfo] entity.
@@ -6601,6 +7091,13 @@
 A map of the permitted labels that maps a label name to the list of
 values that are allowed for that label. +
 Only set if link:#detailed-labels[detailed labels] are requested.
+|`removable_labels`   |optional|
+A map of the removable labels that maps a label name to the map of
+values and reviewers (
+link:rest-api-accounts.html#account-info[AccountInfo] entities)
+that are allowed to be removed from the change. +
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
 |`removable_reviewers`|optional|
 The reviewers that can be removed by the calling user as a list of
 link:rest-api-accounts.html#account-info[AccountInfo] entities. +
@@ -6685,7 +7182,8 @@
 by one of the following REST endpoints: link:#create-change[Create
 Change], link:#create-merge-patch-set-for-change[Create Merge Patch Set
 For Change], link:#cherry-pick[Cherry Pick Revision],
-link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit]
+link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit],
+link:#rebase-change[Rebase Change]
 |==================================
 
 [[change-input]]
@@ -6718,11 +7216,16 @@
 Whether the new change should be set to work in progress.
 |`base_change`        |optional|
 A link:#change-id[\{change-id\}] that identifies the base change for a create
-change operation. Mutually exclusive with `base_commit`.
+change operation. +
+Mutually exclusive with `base_commit`. +
+If neither `base_commit` nor `base_change` are set, the target branch tip will
+be used as the parent commit.
 |`base_commit`        |optional|
 A 40-digit hex SHA-1 of the commit which will be the parent commit of the newly
-created change. If set, it must be a merged commit on the destination branch.
-Mutually exclusive with `base_change`.
+created change. If set, it must be a merged commit on the destination branch. +
+Mutually exclusive with `base_change`. +
+If neither `base_commit` nor `base_change` are set, the target branch tip will
+be used as the parent commit.
 |`new_branch`         |optional, default to `false`|
 Allow creating a new branch when set to `true`. Using this option is
 only possible for non-merge commits (if the `merge` field is not set).
@@ -6739,10 +7242,12 @@
 If set, the target branch (see  `branch` field) must exist (it is not
 possible to create it automatically by setting the `new_branch` field
 to `true`.
+|`patch`              |optional|
+The detail of a patch to be applied as an link:#applypatch-input[ApplyPatchInput] entity.
 |`author`             |optional|
-An link:rest-api-accounts.html#account-input[AccountInput] entity
-that will set the author of the commit to create. The author must be
-specified as name/email combination.
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
 The caller needs "Forge Author" permission when using this field.
 This field does not affect the owner of the change, which will
 continue to use the identity of the caller.
@@ -6798,28 +7303,28 @@
 
 [options="header",cols="1,^1,5"]
 |===========================
-|Field Name         ||Description
-|`message`          |optional|
+|Field Name          ||Description
+|`message`           |optional|
 Commit message for the cherry-pick change. If not set, the commit message of
 the cherry-picked commit is used.
-|`destination`      ||Destination branch
-|`base`             |optional|
+|`destination`       ||Destination branch
+|`base`              |optional|
 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created change.
 If set, it must be a merged commit or a change revision on the destination branch.
-|`parent`           |optional, defaults to 1|
+|`parent`            |optional, defaults to 1|
 Number of the parent relative to which the cherry-pick should be considered.
-|`notify`           |optional|
+|`notify`            |optional|
 Notify handling that defines to whom email notifications should be sent
 after the cherry-pick. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|`notify_details`   |optional|
+|`notify_details`    |optional|
 Additional information about whom to notify about the update as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
-|`keep_reviewers`   |optional, defaults to false|
+|`keep_reviewers`    |optional, defaults to false|
 If `true`, carries reviewers and ccs over from original change to newly created one.
-|`allow_conflicts`  |optional, defaults to false|
+|`allow_conflicts`   |optional, defaults to false|
 If `true`, the cherry-pick uses content merge and succeeds also if
 there are conflicts. If there are conflicts the file contents of the
 created change contain git conflict markers to indicate the conflicts.
@@ -6827,7 +7332,7 @@
 `contains_git_conflicts` field in the link:#change-info[ChangeInfo]. If
 there are conflicts the cherry-pick change is marked as
 work-in-progress.
-|`topic`            |optional|
+|`topic`             |optional|
 The topic of the created cherry-picked change. If not set, the default depends
 on the source. If the source is a change with a topic, the resulting topic
 of the cherry-picked change will be {source_change_topic}-{destination_branch}.
@@ -6835,10 +7340,18 @@
 the created change will have no topic.
 If the change already exists, the topic will not change if not set. If set, the
 topic will be overridden.
-|`allow_empty`      |optional, defaults to false|
+|`allow_empty`       |optional, defaults to false|
 If `true`, the cherry-pick succeeds also if the created commit will be empty.
 If `false`, a cherry-pick that would create an empty commit fails without creating
 the commit.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
 |===========================
 
 [[comment-info]]
@@ -7104,6 +7617,12 @@
 Additional information about whom to notify about the update as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
+|`ignore_automatic_attention_set_rules`|optional|
+If set to true, ignore all automatic attention set rules described in the
+link:#attention-set[attention set]. When not set, the default is false.
+|`reason`         |optional|
+The reason why this vote is deleted. Will +
+go into the change message.
 |=============================
 
 [[description-input]]
@@ -7155,7 +7674,11 @@
 |==========================
 |Field Name    ||Description
 |`name`        ||The name of the file.
-|`content_type`||The content type of the file.
+|`content_type`||The content type of the file. For the commit message and merge
+list the value is `text/x-gerrit-commit-message` and `text/x-gerrit-merge-list`
+respectively. For git links the value is `x-git/gitlink`. For symlinks the value
+is `x-git/symlink`. For regular files the value is the file mime type (e.g.
+`text/x-java`, `text/x-c++src`, etc...).
 |`lines`       ||The total number of lines in the file.
 |`web_links`   |optional|
 Links to the file in external sites as a list of
@@ -7227,6 +7750,16 @@
 Whether the web link should be shown on the unified diff screen.
 |=======================
 
+[[apply-provided-fix-input]]
+=== ApplyProvidedFixInput
+The `ApplyProvidedFixInput` entity contains the fixes to be applied on a review.
+
+[options="header",cols="1,6"]
+|=======================
+|Field Name              |Description
+|`fix_replacement_infos` |A list of <<fix-replacement-info,FixReplacementInfo>>.
+|=======================
+
 [[edit-file-info]]
 === EditFileInfo
 The `EditFileInfo` entity contains additional information
@@ -7296,16 +7829,21 @@
 Number of inserted lines. +
 Not set for binary files or if no lines were inserted. +
 An empty last line is not included in the count and hence this number can
-differ by one from details provided in <<#diff-info,DiffInfo>>.
+differ by one from details provided in <<diff-info,DiffInfo>>.
 |`lines_deleted` |optional|
 Number of deleted lines. +
 Not set for binary files or if no lines were deleted. +
 An empty last line is not included in the count and hence this number can
-differ by one from details provided in <<#diff-info,DiffInfo>>.
+differ by one from details provided in <<diff-info,DiffInfo>>.
 |`size_delta`    ||
 Number of bytes by which the file size increased/decreased.
-|`size`          ||
-File size in bytes.
+|`size`          || File size in bytes.
+|`old_mode`        |optional|File mode in octal (e.g. 100644) at the old commit.
+The first three digits indicate the file type and the last three digits contain
+the file permission bits. For added files, this field will not be present.
+|`new_mode`        |optional|File mode in octal (e.g. 100644) at the new commit.
+The first three digits indicate the file type and the last three digits contain
+the file permission bits. For deleted files, this field will not be present.
 |=============================
 
 [[fix-input]]
@@ -7424,11 +7962,10 @@
 There are two options that control the contents of `LabelInfo`:
 link:#labels[`LABELS`] and link:#detailed-labels[`DETAILED_LABELS`].
 
-* For a quick summary of the state of labels, use `LABELS`.
-* For detailed information about labels, including exact numeric votes for all
-  users and the allowed range of votes for the current user, use `DETAILED_LABELS`.
+* Using `LABELS` will skip the `all.permitted_voting_range` attribute.
+* Using `DETAILED_LABELS` will include the `all.permitted_voting_range`
+  attribute.
 
-==== Common fields
 [options="header",cols="1,^1,5"]
 |===========================
 |Field Name    ||Description
@@ -7436,12 +7973,7 @@
 Whether the label is optional. Optional means the label may be set, but
 it's neither necessary for submission nor does it block submission if
 set.
-|===========================
-
-==== Fields set by `LABELS`
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
+|`description` |optional| The description of the label.
 |`approved`    |optional|One user who approved this label on the change
 (voted the maximum value) as an
 link:rest-api-accounts.html#account-info[AccountInfo] entity.
@@ -7461,16 +7993,14 @@
 "`+1`"/"`-1`".
 |`default_value`|optional|The default voting value for the label.
 This value may be outside the range specified in permitted_labels.
-|===========================
-
-==== Fields set by `DETAILED_LABELS`
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
+|votes|optional|A list of integers containing the vote values applied to this
+label at the latest patchset.
 |`all`         |optional|List of all approvals for this label as a list
 of link:#approval-info[ApprovalInfo] entities. Items in this list may
 not represent actual votes cast by users; if a user votes on any label,
-a corresponding ApprovalInfo will appear in this list for all labels.
+a corresponding ApprovalInfo will appear in this list for all labels. +
+The `permitted_voting_range` attribute is only set if the `DETAILED_LABELS`
+option is requested.
 |`values`      |optional|A map of all values that are allowed for this
 label. The map maps the values ("`-2`", "`-1`", " `0`", "`+1`", "`+2`")
 to the value descriptions.
@@ -7555,11 +8085,11 @@
 The detail of the source commit for merge as a link:#merge-input[MergeInput]
 entity.
 |`author`             |optional|
-An link:rest-api-accounts.html#account-input[AccountInput] entity
-that will set the author of the commit to create. The author must be
-specified as name/email combination.
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
 The caller needs "Forge Author" permission when using this field.
-This field does not affect the owner of the change, which will
+This field does not affect the owner or the committer of the change, which will
 continue to use the identity of the caller.
 |==================================
 
@@ -7688,22 +8218,46 @@
 === RebaseInput
 The `RebaseInput` entity contains information for changing parent when rebasing.
 
-[options="header",width="50%",cols="1,^1,5"]
+[options="header",cols="1,^1,5"]
 |===========================
-|Field Name    ||Description
-|`base`        |optional|
+|Field Name          ||Description
+|`base`              |optional|
 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|
+|`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.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
+|===========================
+
+[[rebase-chain-info]]
+=== RebaseChainInfo
+
+The `RebaseChainInfo` entity contains information about a chain of changes
+that were rebased.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name                ||Description
+|`rebased_changes`         ||List of the unsubmitted ancestors, as link:#change-info[ChangeInfo]
+entities. Includes both rebased changes, and previously up-to-date ancestors. The list is ordered by
+ancestry, where the oldest ancestor is the first.
+|`contains_git_conflicts`  ||Whether any of the rebased changes has conflicts
+due to rebasing.
 |===========================
 
 [[related-change-and-commit-info]]
@@ -7724,6 +8278,9 @@
 |`_current_revision_number`|optional|The current revision number.
 |`status`                  |optional|The status of the change. The status of
 the change is one of (`NEW`, `MERGED`, `ABANDONED`).
+|`submittable`             |optional|Boolean indicating whether the change is
+submittable. +
+Only populated if link:#get-related-changes-submittable[requested].
 |===========================
 
 [[related-changes-info]]
@@ -7774,30 +8331,40 @@
 The `RevertInput` entity contains information for reverting a change.
 
 [options="header",cols="1,^1,5"]
-|=============================
-|Field Name      ||Description
-|`message`       |optional|
+|=================================
+|Field Name          ||Description
+|`message`           |optional|
 Message to be added as review comment to the change when reverting the
 change.
-|`notify`        |optional|
+|`notify`            |optional|
 Notify handling that defines to whom email notifications should be sent
 for reverting the change. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
 If not set, the default is `ALL`.
-|`notify_details`|optional|
+|`notify_details`    |optional|
 Additional information about whom to notify about the revert as a map
 of link:user-notify.html#recipient-types[recipient type] to
 link:#notify-info[NotifyInfo] entity.
-|`topic`         |optional|
+|`topic`             |optional|
 Name of the topic for the revert change. If not set, the default for Revert
 endpoint is the topic of the change being reverted, and the default for the
 RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
 Topic can't contain quotation marks.
-|`work_in_progress` |optional|
+|`work_in_progress`  |optional|
 When present, change is marked as Work In Progress. The `notify` input is
-used if it's present, otherwise it will be overridden to `OWNER`. +
+used if it's present, otherwise it will be overridden to `NONE`. +
+Notifications for the reverted change will only sent once the result change is
+no longer WIP. +
 If not set, the default is false.
-|=============================
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
+|=================================
 
 [[revert-submission-info]]
 === RevertSubmissionInfo
@@ -7943,6 +8510,8 @@
 |`ready`                  |optional|
 If true, the change was moved from WIP to ready for review as a result of this
 action. Not set if false.
+|`error`                  |optional|
+Error message for non-200 responses.
 |============================
 
 [[reviewer-info]]
@@ -8183,7 +8752,13 @@
 Notify handling that defines to whom email notifications should be sent after
 the change is submitted. +
 Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
-If not set, the default is `ALL`.
+If not set, the default is `ALL`.+
+Ignored if a post approval diff is present (i.e. if the change is submitted
+with copied approvals), because in this case everyone should be informed
+about the non-reviewed diff which has been applied after the change has been
+approved so that they can take action if the post approval diff looks
+unexpected. In other words if a post approval diff is present `ALL` is
+enforced.
 |`notify_details`|optional|
 Additional information about whom to notify about the update as a map
 of link:user-notify.html#recipient-types[recipient type] to
@@ -8271,6 +8846,13 @@
 `branch:refs/heads/foo and label:verified=+1`.
 |`fulfilled`||
 True if the submit requirement is fulfilled for the change.
+|`status`||
+A string containing the status of evaluating the expression which can be one
+of the following: +
+  * `PASS` - expression was evaluated and result is true. +
+  * `FAIL` - expression was evaluated and result is false. +
+  * `ERROR` - an error occurred while evaluating the expression. +
+  * `NOT_EVALUATED` - expression was not evaluated.
 |`passing_atoms`|optional|
 A list of passing atoms as strings. For the above expression,
 `passing_atoms` can contain ["branch:refs/heads/foo"] if the branch predicate is
@@ -8278,6 +8860,9 @@
 |`failing_atoms`|optional|
 A list of failing atoms. This is similar to `passing_atoms` except that it
 contains the list of predicates that are not fulfilled for the change.
+|`error_message`|optional|
+If the submit requirement fails during evaluation, this string will contain
+an error message describing why it failed.
 |===========================
 
 [[submit-requirement-input]]
@@ -8320,7 +8905,8 @@
 Description of the submit requirement.
 |`status`||
 Status describing the result of evaluating the submit requirement. The status
-is one of (`SATISFIED`, `UNSATISFIED`, `OVERRIDDEN`, `NOT_APPLICABLE`, `ERROR`).
+is one of (`SATISFIED`, `UNSATISFIED`, `OVERRIDDEN`, `NOT_APPLICABLE`, `ERROR`,
+`FORCED`).
 |`is_legacy`||
 If true, this submit requirement result was created from a legacy
 link:#submit-record[SubmitRecord]. Otherwise, it was created by evaluating a
@@ -8333,11 +8919,15 @@
 omitted for the `applicability_expression_result`.
 |`submittability_expression_result`||
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
-containing the result of evaluating the submittability expression.
+containing the result of evaluating the submittability expression. +
+If the submit requirement does not apply, the `status` field of the result
+will be set to `NOT_EVALUATED`.
 |`override_expression_result`|optional|
 A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
-containing the result of evaluating the override expression. Not set if the
-submit requirement did not define an override expression.
+containing the result of evaluating the override expression. +
+Not set if the submit requirement did not define an override expression. If the
+submit requirement does not apply, the `status` field of the result will be set
+to `NOT_EVALUATED`.
 |===========================
 
 [[submitted-together-info]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index bd93b8b..505def0 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1380,7 +1380,14 @@
   POST /config/server/index.changes HTTP/1.0
   Content-Type: application/json; charset=UTF-8
 
-  {changes: ["foo~101", "bar~202"]}
+  {
+    "changes": [
+      "foo~101",
+      "bar~202",
+      "303"
+    ],
+    "delete_missing": "true"
+  }
 ----
 
 .Response
@@ -1389,6 +1396,9 @@
   Content-Disposition: attachment
 ----
 
+When `delete_missing` is set to `true` changes to be reindexed which are missing in NoteDb
+will be deleted in the index.
+
 
 [[ids]]
 == IDs
@@ -1876,6 +1886,10 @@
 |Field Name         ||Description
 |`changes`   ||
 List of link:rest-api-changes.html#change-id[change-ids]
+|`delete_missing`  |optional|
+Delete changes which are missing in NoteDb from the index. This can be used
+to get rid of stale index entries. Possible values are `true` and `false`.
+By default set to `false`.
 |================================
 
 [[jvm-summary-info]]
@@ -2008,6 +2022,10 @@
 |`default_theme`           |optional|
 URL to a default Gerrit UI theme plugin, if available.
 Located in `/static/gerrit-theme.js` by default.
+|`submit_requirement_dashboard_columns` ||
+The list of submit requirement names that should be displayed as separate
+columns in the dashboard. If empty, the default is to display all submit
+requirements that are applicable for changes appearing in the dashboard.
 |=======================================
 
 [[sshd-info]]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 92c6dbf..9e71df7 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -402,12 +402,12 @@
   Content-Type: application/json; charset=UTF-8
 
   )]}'
-  {
-    "test": {
+  [
+    {
       "id": "test",
       "description": "\u003chtml\u003e is escaped"
     }
-  }
+  ]
 ----
 
 [[project-query-limit]]
@@ -421,11 +421,11 @@
 ----
 
 The `/projects/` URL also accepts a start integer in the `start`
-parameter. The results will skip `start` groups from project list.
+parameter. The results will skip `start` projects from project list.
 
 Query 25 projects starting from index 50.
 ----
-  GET /groups/?query=<query>&limit=25&start=50 HTTP/1.0
+  GET /projects/?query=<query>&limit=25&start=50 HTTP/1.0
 ----
 
 [[project-query-options]]
@@ -1541,6 +1541,47 @@
   }
 ----
 
+[[commits-included-in]]
+=== Get Commits Included In Refs
+--
+'GET /projects/link:#project-name[\{project-name\}]/commits:in'
+--
+
+Gets refs in which the specified commits were merged into. Returns a map of commits
+to sets of full ref names.
+
+One or more `commit` query parameters are required and each specifies a
+commit-id (SHA-1 in 40 digit hex representation). Commits will not be contained
+in the map if they do not exist or are not reachable from visible, specified refs.
+
+One or more `ref` query parameters are required and each specifies a full ref name.
+Refs which are not visible to the calling user according to the project's read
+permissions and refs which do not exist will be filtered out from the result.
+
+.Request
+----
+  GET /projects/work%2Fmy-project/commits:in?commit=a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96&commit=6d2a3adb10e844c33617fc948dbeb88e868d396e&ref=refs/heads/master&ref=refs/heads/branch1 HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96": [
+      "refs/heads/master"
+    ],
+    "6d2a3adb10e844c33617fc948dbeb88e868d396e": [
+      "refs/heads/branch1",
+      "refs/heads/master"
+    ]
+  }
+
+----
+
 [[branch-endpoints]]
 == Branch Endpoints
 
@@ -3031,16 +3072,14 @@
       "function": "MaxWithBlock",
       "values": {
         " 0": "No score",
-        "-1": "I would prefer this is not merged as is",
-        "-2": "This shall not be merged",
+        "-1": "I would prefer this is not submitted as is",
+        "-2": "This shall not be submitted",
         "+1": "Looks good to me, but someone else must approve",
         "+2": "Looks good to me, approved"
       },
       "default_value": 0,
       "can_override": true,
-      "copy_min_score": true,
-      "copy_all_scores_if_no_change": true,
-      "copy_all_scores_on_trivial_rebase": true,
+      "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE or is:MIN",
       "allow_post_submit": true
     }
   ]
@@ -3078,16 +3117,14 @@
       "function": "MaxWithBlock",
       "values": {
         " 0": "No score",
-        "-1": "I would prefer this is not merged as is",
-        "-2": "This shall not be merged",
+        "-1": "I would prefer this is not submitted as is",
+        "-2": "This shall not be submitted",
         "+1": "Looks good to me, but someone else must approve",
         "+2": "Looks good to me, approved"
       },
       "default_value": 0,
       "can_override": true,
-      "copy_min_score": true,
-      "copy_all_scores_if_no_change": true,
-      "copy_all_scores_on_trivial_rebase": true,
+      "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE or is:MIN",
       "allow_post_submit": true
     },
     {
@@ -3096,14 +3133,14 @@
       "function": "MaxWithBlock",
       "values": {
         " 0": "No score",
-        "-1": "I would prefer this is not merged as is",
-        "-2": "This shall not be merged",
+        "-1": "I would prefer this is not submitted as is",
+        "-2": "This shall not be submitted",
         "+1": "Looks good to me, but someone else must approve",
         "+2": "Looks good to me, approved"
       },
       "default_value": 0,
       "can_override": true,
-      "copy_any_score": true,
+      "copy_condition": "is:ANY",
       "allow_post_submit": true
     }
   ]
@@ -3141,16 +3178,14 @@
     "function": "MaxWithBlock",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     },
     "default_value": 0,
     "can_override": true,
-    "copy_min_score": true,
-    "copy_all_scores_if_no_change": true,
-    "copy_all_scores_on_trivial_rebase": true,
+    "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
     "allow_post_submit": true
   }
 ----
@@ -3178,8 +3213,8 @@
     "commit_message": "Create Foo Label",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     }
@@ -3202,14 +3237,14 @@
     "function": "MaxWithBlock",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     },
     "default_value": 0,
     "can_override": true,
-    "copy_all_scores_if_no_change": true,
+    "copy_condition": "changekind:NO_CHANGE",
     "allow_post_submit": true
   }
 ----
@@ -3254,16 +3289,14 @@
     "function": "MaxWithBlock",
     "values": {
       " 0": "No score",
-      "-1": "I would prefer this is not merged as is",
-      "-2": "This shall not be merged",
+      "-1": "I would prefer this is not submitted as is",
+      "-2": "This shall not be submitted",
       "+1": "Looks good to me, but someone else must approve",
       "+2": "Looks good to me, approved"
     },
     "default_value": 0,
     "can_override": true,
-    "copy_min_score": true,
-    "copy_all_scores_if_no_change": true,
-    "copy_all_scores_on_trivial_rebase": true,
+    "copy_condition": "changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
     "allow_post_submit": true,
     "ignore_self_approval": true
   }
@@ -3336,8 +3369,8 @@
         "name": "Foo-Review",
         "values": {
           " 0": "No score",
-          "-1": "I would prefer this is not merged as is",
-          "-2": "This shall not be merged",
+          "-1": "I would prefer this is not submitted as is",
+          "-2": "This shall not be submitted",
           "+1": "Looks good to me, but someone else must approve",
           "+2": "Looks good to me, approved"
       }
@@ -3347,7 +3380,7 @@
         "function": "MaxWithBlock"
       },
       "Baz-Review": {
-        "copy_min_score": true
+        "copy_condition": "is:MIN"
       }
     }
   }
@@ -3360,6 +3393,198 @@
   HTTP/1.1 200 OK
 ----
 
+[[submit-requirement-endpoints]]
+== Submit Requirement Endpoints
+
+[[create-submit-requirement]]
+=== Create Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Creates a new submit requirement definition in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+If a submit requirement with this name is already defined in this project, this
+submit requirement definition is updated
+(see link:#update-submit-requirement[Update Submit Requirement]).
+
+The submit requirement data must be provided in the request body as
+link:#submit-requirement-input[SubmitRequirementInput].
+
+.Request
+----
+  PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Code-Review",
+    "description": "At least one maximum vote for the Code-Review label is required",
+    "submittability_expression": "label:Code-Review=+2"
+  }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[update-submit-requirement]]
+=== Update Submit Requirement
+
+--
+'PUT /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Updates the definition of a submit requirement that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The new submit requirement will overwrite the existing submit requirement.
+That is, unspecified attributes will be set to their defaults.
+
+.Request
+----
+  PUT /projects/My-Project/submit_requirements/Code-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "name": "Code-Review",
+    "description": "At least one maximum vote for the Code-Review label is required",
+    "submittability_expression": "label:Code-Review=+2"
+  }
+----
+
+As response a link:#submit-requirement-info[SubmitRequirementInfo] entity is
+returned that describes the created submit requirement.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[get-submit-requirement]]
+=== Get Submit Requirement
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Retrieves the definition of a submit requirement that is defined in this project.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project.
+
+.Request
+----
+  GET /projects/All-Projects/submit-requirement/Code-Review HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+   }
+----
+
+[[list-submit-requirements]]
+=== List Submit Requirements
+--
+'GET /projects/link:#project-name[\{project-name\}]/submit_requirements'
+--
+
+Retrieves a list of all submit requirements for this project. The `inherited`
+parameter can be supplied to also list submit requirements from parent projects.
+
+The calling user must have read access to the `refs/meta/config` branch of the
+project. If the `inherited` option is used, the caller must have read access to
+the `refs/meta/config` branch of all parent projects as well.
+
+As response a list of link:#submit-requirement-info[SubmitRequirementInfo]
+entities is returned.
+
+.Request
+----
+  GET /projects/All-Projects/submit-requirements HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "name": "Code-Review",
+      "description": "At least one maximum vote for the Code-Review label is required",
+      "submittability_expression": "label:Code-Review=+2",
+      "allow_override_in_child_projects": false
+    }
+  ]
+----
+
+[[delete-submit-requirement]]
+=== Delete Submit Requirement
+--
+'DELETE /projects/link:#project-name[\{project-name\}]/submit_requirements/link:#submit-requirement-name[\{submit-requirement-name\}]'
+--
+
+Deletes the definition of a submit requirement that is defined in this project.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project. The request would fail with `404 Not Found` if there is no submit
+requirement with this name for this project.
+
+No request body is needed.
+
+.Request
+----
+  DELETE /projects/My-Project/submit_requirements/Foo-Review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+----
+
+If a submit requirement was deleted the response is "`204 No Content`".
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
 
 [[ids]]
 == IDs
@@ -3388,6 +3613,11 @@
 === \{label-name\}
 The name of a review label.
 
+[[submit-requirement-name]]
+=== \{submit-requirement-name\}
+The name of a submit requirement.
+
+
 [[project-name]]
 === \{project-name\}
 The name of the project.
@@ -3506,14 +3736,22 @@
 
 [options="header",cols="1,^2,4"]
 |=======================
-|Field Name||Description
-|`ref`     |optional|
+|Field Name          ||Description
+|`ref`               |optional|
 The name of the branch. The prefix `refs/heads/` can be
 omitted. +
 If set, must match the branch ID in the URL.
-|`revision`|optional|
+|`revision`          |optional|
 The base revision of the new branch. +
 If not set, `HEAD` will be used as base revision.
+|`validation_options`|optional|
+Map with key-value pairs that are forwarded as options to the ref operation
+validation listeners (e.g. can be used to skip certain validations). Which
+validation options are supported depends on the installed ref operation
+validation listeners. Gerrit core doesn't support any validation options, but
+ref operation validation listeners that are implemented in plugins may. Please
+refer to the documentation of the installed plugins to learn whether they
+support validation options. Unknown validation options are silently ignored.
 |=======================
 
 [[check-project-input]]
@@ -3560,16 +3798,19 @@
 |`link`     |        |The URL to direct the user to whenever the
 regular expression is matched, as documented in
 link:config-gerrit.html#commentlink.name.link[commentlink.name.link].
+|`prefix`   |optional|Text inserted before the link.
+|`suffix`   |optional|Text inserted after the link.
+|`text`     |optional|Text of the link.
 |`enabled`  |optional|Whether the commentlink is enabled, as documented
 in link:config-gerrit.html#commentlink.name.enabled[
 commentlink.name.enabled]. If not set the commentlink is enabled.
+|==================================================
 
 [[commentlink-input]]
 === CommentLinkInput
 The `CommentLinkInput` entity describes the input for a
 link:config-gerrit.html#commentlink[commentlink].
 
-|==================================================
 [options="header",cols="1,^2,4"]
 |==================================================
 |Field Name |        |Description
@@ -3579,6 +3820,9 @@
 |`link`     |        |The URL to direct the user to whenever the
 regular expression is matched, as documented in
 link:config-gerrit.html#commentlink.name.link[commentlink.name.link].
+|`prefix`   |optional|Text inserted before the link.
+|`suffix`   |optional|Text inserted after the link.
+|`text`     |optional|Text of the link.
 |`enabled`  |optional|Whether the commentlink is enabled, as documented
 in link:config-gerrit.html#commentlink.name.enabled[
 commentlink.name.enabled]. If not set the commentlink is enabled.
@@ -3652,17 +3896,19 @@
 Map with the comment link configurations of the project. The name of
 the comment link configuration is mapped to a link:#commentlink-info[
 CommentlinkInfo] entity.
-|`plugin_config`                           |optional|
+|`plugin_config`                                    |optional|
 Plugin configuration as map which maps the plugin name to a map of
 parameter names to link:#config-parameter-info[ConfigParameterInfo]
 entities. Only filled for users who have read access to `refs/meta/config`.
-|`actions`                                 |optional|
+|`actions`                                          |optional|
 Actions the caller might be able to perform on this project. The
 information is a map of view names to
-|`reject_empty_commit`                     |optional|
+|`reject_empty_commit`                              |optional|
 link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
 empty commits should be rejected when a change is merged.
 link:rest-api-changes.html#action-info[ActionInfo] entities.
+|`skip_adding_author_and_committer_as_reviewers`    |optional|
+Whether to skip adding the Git commit author and committer as reviewers for a new change.
 |=======================================================
 
 [[config-input]]
@@ -3929,7 +4175,7 @@
 |`value`            ||
 The effective boolean value.
 |`configured_value` ||
-The configured value, can be `TRUE`, `FALSE` or `INHERITED`.
+The configured value, can be `TRUE`, `FALSE` or `INHERIT`.
 |`inherited_value`  |optional|
 The boolean value inherited from the parent. +
 Not set if there is no parent.
@@ -3945,6 +4191,8 @@
 |Field Name      ||Description
 |`name`          ||
 The link:config-labels.html#label_name[name] of the label.
+|`description`   |optional|
+The description of the label.
 |`project_name`  ||
 The name of the project in which this label is defined.
 |`function`      ||
@@ -3964,34 +4212,8 @@
 |`can_override`  |`false` if not set|
 Whether this label can be link:config-labels.html#label_canOverride[overridden]
 by child projects.
-|`copy_any_score`|`false` if not set|
-Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
-label.
 |`copy_condition`|optional|
 See link:config-labels.html#label_copyCondition[copyCondition].
-|`copy_min_score`|`false` if not set|
-Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
-label.
-|`copy_max_score`|`false` if not set|
-Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
-label.
-|`copy_all_scores_if_no_change`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresIfNoChange[
-copyAllScoresIfNoChange] is set on the label.
-|`copy_all_scores_if_no_code_change`|`false` if not set|
-Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-copyAllScoresIfNoCodeChange] is set on the label.
-|`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.
-|`copy_values`   |optional|
-List of values that should be copied forward when a new patch set is uploaded.
 |`allow_post_submit`|`false` if not set|
 Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
 on the label.
@@ -4018,6 +4240,8 @@
 For label creation the name is required if this `LabelDefinitionInput` entity
 is contained in a link:#batch-label-input[BatchLabelInput]
 entity.
+|`description`          |optional|
+The new description for the label.
 |`function`      |optional|
 The new link:config-labels.html#label_function[function] of the label (can be
 `MaxWithBlock`, `AnyWithBlock`, `MaxNoBlock`, `NoBlock`, `NoOp` and `PatchSetLock`.
@@ -4036,36 +4260,10 @@
 |`can_override`  |optional|
 Whether this label can be link:config-labels.html#label_canOverride[overridden]
 by child projects.
-|`copy_any_score`|optional|
-Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
-label.
 |`copy_condition`|optional|
 See link:config-labels.html#label_copyCondition[copyCondition].
 |`unset_copy_condition`|optional|
 If true, clears the value stored in `copy_condition`.
-|`copy_min_score`|optional|
-Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
-label.
-|`copy_max_score`|optional|
-Whether link:config-labels.html#label_copyMaxScore[copyMaxScore] is set on the
-label.
-|`copy_all_scores_if_no_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfNoChange[
-copyAllScoresIfNoChange] is set on the label.
-|`copy_all_scores_if_no_code_change`|optional|
-Whether link:config-labels.html#label_copyAllScoresIfNoCodeChange[
-copyAllScoresIfNoCodeChange] is set on the label.
-|`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.
-|`copy_values`   |optional|
-List of values that should be copied forward when a new patch set is uploaded.
 |`allow_post_submit`|optional|
 Whether link:config-labels.html#label_allowPostSubmit[allowPostSubmit] is set
 on the label.
@@ -4319,6 +4517,57 @@
 |`size_of_packed_objects`  |Size of packed objects in bytes.
 |======================================
 
+[[submit-requirement-info]]
+=== SubmitRequirementInfo
+The `SubmitRequirementInfo` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`||
+Whether this submit requirement can be overridden in child projects.
+|===========================
+
+[[submit-requirement-input]]
+=== SubmitRequirementInput
+The `SubmitRequirementInput` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`|optional|
+Whether this submit requirement can be overridden in child projects. Default is
+`false`.
+|===========================
+
 [[submit-type-info]]
 === SubmitTypeInfo
 Information about the link:config-project-config.html#submit-type[default submit
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 469bee5..348af76 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -76,6 +76,16 @@
 headers. If the named resource already exists the server will respond
 with HTTP 412 Precondition Failed.
 
+[[backwards-compatibility]]
+=== Backwards Compatibility
+
+The REST API is regularly extended (e.g. addition of new REST endpoints or new fields in existing
+JSON entities). Callers of the REST API must be able to deal with this (e.g. ignore unknown fields
+in the REST responses). Incompatible changes (e.g. removal of REST endpoints, altering/removal of
+existing fields in JSON entities) are avoided if possible, but can happen in rare cases. If they
+happen, they are announced in the link:https://www.gerritcodereview.com/releases-readme.html[release
+notes].
+
 [[output]]
 === Output Format
 JSON responses are encoded using UTF-8 and use content type
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 1f67fc7..5e5d3f8 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -2,8 +2,6 @@
 
 Report a bug or send feedback using
 link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set[this Monorail template].
-You can also report a bug through the bug icon in the user hovercard and in the
-reply dialog.
 
 [[whose-turn]]
 == Whose turn is it?
@@ -49,6 +47,9 @@
   an unresolved comment.
 * If another user removed a user's vote, the user with the deleted vote will be
   added to the attention set.
+* If a vote becomes outdated by uploading a new patch set (vote is not sticky),
+  the user whose vote has been removed is added to the attention set, as they
+  need to re-review the change and vote newly.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
 * The rules for service accounts are different, see link:#bots[Bots].
 * Users are not added by automatic rules when the change is work in progress.
@@ -133,23 +134,26 @@
 Gerrit-Attention: Marian Harbach <mharbach@google.com>
 ----
 
-=== Assignee
+=== Browser notifications
 
-While the "Assignee" feature can still be used together with the attention set,
-we do not recommend doing so. Using both features is likely confusing. The
-distinct feature of the "Assignee" compared to the attention set is that only
-one user can be the assignee at the same time. So the assignee can be used to
-single out one person or escalate, if there are multiple reviewers. Since
-*every* reviewer in the attention set is expected to take action, singling out
-is not likely to be important and also still achievable with the attention set.
-Otherwise "Assignee" and "Attention Set" are very much overlapping, so we
-recommend to only use one of them.
+You'll automatically get notifications when you are in the attention set. You
+must enable desktop notifications on your browser to see them.
 
-If you don't expect action from reviewers, then consider adding them to CC
-instead.
+image::images/browser-notification-example.png["browser notification example", align="center"]
 
-The "Assignee" feature can be turned on/off with the
-link:config-gerrit.html#change.enableAssignee[enableAssignee] config option.
+You can turn off automatic notifications in user preferences. They are enabled
+by default.
+
+image::images/browser-notification-preference.png["user preference for browser notifications", align="center"]
+
+The notifications work only when Gerrit is open in one of the browser tabs.
+The latency to get the notification is up to 5 minutes.
+
+If you are not getting notifications:
+ - Check your user preferences - Allow browser notification setting
+ - Make sure notifications are turned on for the Gerrit site in the browser
+ - Make sure browser notifications are turned on in your operating system
+ - Your host can have browser notifications disabled for some user groups
 
 === Bold Changes / Mark Reviewed
 
diff --git a/Documentation/user-dashboards.txt b/Documentation/user-dashboards.txt
index e64d625..364b0d9 100644
--- a/Documentation/user-dashboards.txt
+++ b/Documentation/user-dashboards.txt
@@ -12,7 +12,7 @@
 
 Dashboards are available via URLs like:
 ----
-  /#/dashboard/?title=Custom+View&To+Review=reviewer:john.doe@example.com&Pending+In+myproject=project:myproject+is:open
+  /dashboard/?title=Custom+View&To+Review=reviewer:john.doe@example.com&Pending+In+myproject=project:myproject+is:open
 ----
 This opens a view showing the title "Custom View" with two sections,
 "To Review" and "Pending in myproject":
@@ -51,7 +51,7 @@
 to changes for the current user:
 
 ----
-  /#/dashboard/?title=Mine&foreach=owner:self&My+Pending=is:open&My+Merged=is:merged
+  /dashboard/?title=Mine&foreach=owner:self&My+Pending=is:open&My+Merged=is:merged
 ----
 
 
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 20ad07c..24c35f0 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -18,8 +18,8 @@
 [[user]]
 == User Level Settings
 
-Individual users can configure email subscriptions by editing
-watched projects through Settings > Watched Projects with the web UI.
+Individual users can configure email subscriptions by editing their
+notifications in the Web UI under `Settings` > `Notifications`.
 
 Specific projects may be watched, or the special project
 `All-Projects` can be watched to watch all projects that
diff --git a/Documentation/user-privacy.txt b/Documentation/user-privacy.txt
index d61ee76..afedb7e 100644
--- a/Documentation/user-privacy.txt
+++ b/Documentation/user-privacy.txt
@@ -8,7 +8,7 @@
 |===
 | 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
+  manipulate user data. This document only focuses on the behavior of Gerrit
   core and its link:dev-core-plugins.html[core plugins].
 |===
 
@@ -98,11 +98,6 @@
 * 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
 
@@ -110,4 +105,4 @@
 required by applicable law or agreed to in writing, software distributed under
 the License is distributed on an "AS IS" BASIS, WITHOUT 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
+language governing permissions and limitations under the License.
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 6f5f7297..73668d7 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -1,21 +1,15 @@
 :linkattrs:
-= Review UI
+= Review UI Overview
 
 Reviewing changes is an important task and the Gerrit Web UI provides
 many functionalities to make the review process comfortable and
 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
 
-The change screen shows the details of a single change and provides
-various actions on it.
+The change screen is the main view for a change. It shows the details of a
+single change and allows various actions on it.
 
 image::images/user-review-ui-change-screen.png[width=800, link="images/user-review-ui-change-screen.png"]
 
@@ -28,44 +22,81 @@
 
 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"]
+image::images/user-review-ui-change-screen-topleft.png[width=600, link="images/user-review-ui-change-screen-topleft.png"]
 
 [[change-status]]
 The change status shows the state of the change:
 
-- [[active]]`Active`:
+- `Active`:
 +
 The change is under active review.
 
-- [[merge-conflict]]`Merge Conflict`:
+- `Merge Conflict`:
 +
-The change can't be merged due to conflicts.
+The change can't be merged into the destination branch due to conflicts.
 
-- [[ready-to-submit]]`Ready to Submit`:
+- `Ready to Submit`:
 +
-The change has all necessary approvals and may be submitted.
+The change has all necessary approvals and fulfils all other submit
+requirements. It can be submitted.
 
-- [[merged]]`Merged`:
+- `Merged`:
 +
 The change was successfully merged into the destination branch.
 
-- [[abandoned]]`Abandoned`:
+- `Abandoned`:
 +
-The change was abandoned.
+The change was abandoned. It is not intended to be updated, reviewed or
+submitted anymore.
+
+- `Private`:
++
+The change is marked as link:intro-user.html#private-changes[Private]. And has
+reduced visibility.
+
+- `Revert Created|Revert Submitted`:
++
+The change has a corresponding revert change. Revert changes can be created
+through UI (see <<actions, Actions section>>).
+
+- `WIP`:
++
+The change was marked as "Work in Progress". For example to indicate to
+reviewers that they shouldn't review the change yet.
 
 [[star]]
 === Star Change
 
-Clicking the star icon marks the change as a favorite: it turns on
+Clicking the star icon bookmarks the change: 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.
 
+[[quick-links]]
+=== Links Menu
+
+Links menu contains various change related strings for quick copying. Such as:
+Change Number, URL, Title+Url, etc. The lines in this menu can also be accessed
+via shortcuts for convenience.
+
+image::images/user-review-ui-copy-links.png[width=600, link="images/user-review-ui-copy-links.png"]
+
 [[change-info]]
 === Change metadata
 
-The change metadata block contains detailed information about the change
-and offers actions on the change.
+The change metadata block contains detailed information about the change.
+
+image::images/user-review-ui-change-metadata.png[width=600, link="images/user-review-ui-change-metadata.png"]
+
+- [[owner]]Owner/Uploader/Author/Committer
++
+Owner is the person who created the change
++
+Uploader is the person who uploaded the latest patchset (the patchset that will
+be merged if the change is submitted)
++
+Author/Committer are concepts from Git and are retrieved from the commit when
+it's sent for review.
 
 - [[reviewers]]Reviewers:
 +
@@ -74,16 +105,36 @@
 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 pencil icon. Typing
-into the pop-up text field activates auto completion of user and group
-names.
+New reviewers can be added through reply dialog that is opened by clicking on
+the pencil icon or on "Reply" button. Typing into the reviewer text field
+activates auto completion of user and group names.
 +
+
+- [[cc-list]]CC:
++
+Accounts in CC receive notifications for the updates on the change, but don't
+need to vote/review. If the CC'ed user votes they are moved to reviewers.
++
+
+- [[attention-set]]Attention set:
++
+Users in attention set are marked by "chevron" symbol (see screenshot above).
+The mark indicates that there are actions their attention is required on the
+change: Something updated/changed since last review, their vote is required,
+etc.
++
+Changes for which you are currently in attention set can be found using
+`attention:<User>` in search and show up in a separate category of personal
+dashboard.
++
+Clicking on the mark removes the user from attention set.
+
+
 [[remove-reviewer]]
-Reviewers can be removed from the change by clicking on the `x` icon
-in the reviewer's chip token. Removing a reviewer also removes the
-current votes of the reviewer. The removal of votes is recorded as a
-message on the change.
-+
+Reviewers can be removed from the change by selecting the appropriate option on
+the chip's hovercard. Removing a reviewer also removes current votes of the
+reviewer. The removal of votes is recorded in the change log.
+
 Removing reviewers is protected by permissions:
 
 ** Users can always remove themselves.
@@ -92,10 +143,7 @@
    Remove Reviewer] access right, the branch owner, the project owner
    and Gerrit administrators may remove anyone.
 
-+
-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:
+- [[repo-branch-topic]]Project (Repo) / Branch / Topic:
 +
 The name of the project for which the change was done is displayed as a
 link to the link:user-dashboards.html#project-default-dashboard[default
@@ -112,15 +160,55 @@
 access right. To be able to set a topic on a closed change, the
 `Edit Topic Name` must be assigned with the `force` flag.
 
+- [[parent]]Parent:
++
+Parent commit of the latest uploaded patchset. Or if the change has been merged
+the parent of the commit it was merged as into the destination branch.
+
+- [[merged-as]]Merged As:
++
+The SHA of the commit corresponding to the merged change on the destination
+branch.
+
+- [[revert-created-as]]Revert (Created|Submitted) As
++
+Points to the revert change, if one was created.
+
+- [[cherry-pick-of]]Cherry-pick of
++
+If the change was created as cherry-pick of some other change to a different
+branch, points to the original change.
+
 - [[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.
 
-- [[actions]]Actions:
+- [[hastags]]Hashtags:
 +
-Actions buttons are at the top, and in the overflow menu.
+Arbitrary string hashtags, that can be used to categorize changes and later use
+hashtags for search queries.
+
+[[submit-requirements]]
+=== Submit Requirements
+
+image::images/user-review-ui-submit-requirements.png[width=600, link="images/user-review-ui-copy-links.png"]
+
+Submit Requirements describe various conditions that must be fulfilled before
+the change can be submitted. Hovering over the requirement will show the
+description of the requirement, as well as additional information, such as:
+corresponding expression that is being evaluated, who can vote on the related
+labels etc.
+
+Approving votes are colored green; negative votes are colored red.
+
+For more detail on Submit Requirements see
+link:config-submit-requirements.html[Submit Requirement Configuration] page.
+
+[[actions]]
+=== Actions:
+Actions buttons are at the top right and in the overflow menu.
 Depending on the change state and the permissions of the user, different
 actions are available on the change:
 
@@ -220,13 +308,7 @@
 +
 image::images/user-review-ui-change-screen-change-info-actions.png[width=400, link="images/user-review-ui-change-screen-change-info-actions.png"]
 
-- [[labels]]Labels & Votes:
-+
-Approving votes are colored green; negative votes are colored red.
-+
-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]]
+[[files-tab]]
 === File List
 
 The file list shows the files that are modified in the currently viewed
@@ -251,17 +333,40 @@
 The list of commits that are being integrated into the destination
 branch by submitting the merge commit.
 
+Every file is accompanied by a number of extra information, such as status
+(modified, added, deleted, etc.), number of changed lines, type (executable,
+link, plain), comments and others. Hovering over most icons and columns reveals
+additional information.
+
+Each file can be expanded to view the contents of the file and diff. For more
+information see <<diff-view, Diff View>> section.
+
+[[comments-tab]]
+=== Comments Tab
+
+Instead of the file list, a comments tab can be selected. Comments tab presents
+comments along with related file/diff snippets. It also offers some filtering
+opportunities at the top (ex. only unresolved, only comments from user X, etc.)
+
+image::images/user-review-ui-change-screen-comments-tab.png[width=800, link="images/user-review-ui-change-screen-comments-tab.png"]
+
+[[checks-tab]]
+=== Checks Tab
+Checks tab contains results of different "Check Runs" installed by plugins. For
+more information see link:pg-plugin-checks-api.html[Checks API] page.
 
 [[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.
+The change screen only presents one pair of patch sets (`Patchset A` and
+`Patchset B`) at a time. `A` is always an earlier upload than `B` and serves as
+a base for diffing when viewing changes in the files. Which patch
+sets is currently viewed can be seen from the `Patch Sets` drop-down
+panel in the change header. If patchset 'A' is not selected a parent commit of
+patchset 'B' is used by default.
 
 image::images/user-review-ui-change-screen-patch-sets.png[width=300, link="images/user-review-ui-change-screen-patch-sets.png"]
 
-
 [[download]]
 === Download
 
@@ -278,7 +383,8 @@
 
 Each command has a copy-to-clipboard icon that allows the command to be
 copied into the clipboard. This makes it easy to paste and execute the
-command on a Git command line.
+command on a Git command line. Additionally each line can copied to clipboard
+using number (1..9) of the appropriate line as a keyboard shortcut.
 
 If several download schemes are configured on the server (e.g. SSH and
 HTTP) there is a drop-down list to switch between the download schemes.
@@ -306,22 +412,20 @@
 
 image::images/user-review-ui-change-screen-included-in.png[width=800, link="images/user-review-ui-change-screen-included-in.png"]
 
-
-
 [[related-changes]]
 === Related Changes
 
 If there are changes that are related to the currently viewed change
 they are displayed in the third column of the change screen.
 
-There are several lists of related changes and a tab control is used to
-display each list of related changes in its own tab.
+There are several lists of related changes that are displayed in separate
+sectionsunder each other.
 
-The following tabs may be displayed:
+The following sections may be displayed:
 
-- [[related-changes-tab]]`Related Changes`:
+- [[related-changes-section]]`Related Changes`:
 +
-This tab page shows changes on which the current change depends
+This section shows changes on which the current change depends
 (ancestors) and open changes that depend on the current change
 (descendants). For merge commits it also shows the closed changes that
 will be merged into the destination branch by submitting the merge
@@ -341,10 +445,10 @@
 +
 ** [[not-current]]Not current:
 +
-The selected patch set of the change is outdated; it is not the current
-patch set of the change.
+The patch set of the related change which is related to the current change is
+outdated; it is not the current patch set of the change.
 +
-It means that the
+For ancestor 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
@@ -364,20 +468,24 @@
 note that following the link to an indirect descendant change may
 result in a completely different related changes listing.
 
-** [[closed-ancestor]]Closed ancestor:
+** [[merged-related-change]]Merged
 +
-Indicates a closed ancestor, e.g. the commit was directly pushed into
-the repository bypassing code review, or the ancestor change was
-reviewed and submitted on another branch. The latter may indicate that
-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`.
+The change has been  merged.
++
+If the relationship to submitted change falls under conditions described in
+<<not-current, Not current>> the status is orange. Such changes can appear as
+both ancestors and descendants of the change.
+
+** [[submittable-related-change]]Submittable
++
+All the submit requirements are fulfilled for the related change and it can be
+submitted when all of its ancestors are submitted.
 
 ** [[closed-ancestor-abandoned]]Abandoned:
 +
 Indicates an abandoned change.
 
-- [[conflicts-with]]`Conflicts With`:
+- [[conflicts-with]]`Merge Conflicts`:
 +
 This section shows changes that conflict with the current change.
 Non-mergeable changes are filtered out; only conflicting changes that
@@ -393,10 +501,9 @@
 currently viewed change, when clicking the submit button. It includes
 ancestors of the current patch set.
 +
-This may include changes and its ancestors with the same topic if
-`change.submitWholeTopic` is enabled. Only open changes with the
-same topic are included in the list.
-+
+If `change.submitWholeTopic` is enabled this section also includes changes with
+the same topic. The list recursively includes all changes that can be reached by
+ancestor and topic relationships. Only open changes are included in the result.
 
 - [[cherry-picks]]`Cherry-Picks`:
 +
@@ -411,12 +518,18 @@
 
 If there are no related changes for a tab, the tab is not displayed.
 
+- [[same-topic]]`Same Topic`:
++
+This section shows changes which are part of the same topic. If
+`change.submitWholeTopic` is enabled, then this section is omitted and changes
+are included as part of <<submitted-together, `Submitted Together`>>
+
 [[reply]]
 === Reply
 
 The `Reply...` button in the change header allows to reply to the
 currently viewed patch set; one can add a summary comment, publish
-inline draft comments, and vote on the labels.
+inline draft comments, vote on the labels and adjust attention set.
 
 image::images/user-review-ui-change-screen-reply.png[width=800, link="images/user-review-ui-change-screen-reply.png"]
 
@@ -424,10 +537,8 @@
 
 [[summary-comment]]
 A text box allows to type a summary comment for the currently viewed
-patch set. Some basic markdown-like syntax is supported which renders
-indented lines preformatted, lines starting with "- " or "* " as list
-items, and lines starting with "> " as block quotes (also see replying to
-link:#reply-to-message[messages] and link:#reply-inline-comment[inline comments]).
+patch set. Markdown syntax is supported same as in other
+<<comments-markdown, Comments>>.
 
 [[vote]]
 If the current patch set is viewed, buttons are displayed for
@@ -439,7 +550,7 @@
 are links to navigate to the inline comments which can be used if a
 comment needs to be edited.
 
-The `Post` button publishes the comments and the votes.
+The `SEND` button publishes the comments and the votes.
 
 [[quick-approve]]
 If a user can approve a label that is still required, a quick approve
@@ -460,12 +571,12 @@
 
 image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/user-review-ui-change-screen-quick-approve.png"]
 
-[[history]]
-=== History
+[[change-log]]
+=== Change Log
 
 The history of the change can be seen in the lower part of the screen.
 
-The history contains messages for all kinds of change updates, e.g. a
+The log contains messages for all kinds of change updates, e.g. a
 message is added when a new patch set is uploaded or when a review was
 done.
 
@@ -491,12 +602,12 @@
 
 image::images/user-review-ui-change-screen-plugin-extensions.png[width=300, link="images/user-review-ui-change-screen-plugin-extensions.png"]
 
-[[side-by-side]]
+[[diff-view]]
 == Side-by-Side Diff Screen
 
-The side-by-side diff screen shows a single patch; the old file version
-is displayed on the left side of the screen; the new file version is
-displayed on the right side of the screen.
+The side-by-side diff screen shows a single patch (or difference between two
+patchsets); the old file version is displayed on the left side of the screen;
+the new file version is displayed on the right side of the screen.
 
 This screen allows to review a patch and to comment on it.
 
@@ -557,6 +668,10 @@
 Code blocks with comments may overlap. This means it is possible to
 attach several comments to the same code.
 
+[[comments-markdown]]
+The comments support markdown. It follows the CommonMark spec, except inline
+images and direct HTML are not rendered and kept as plaintext.
+
 [[line-links]]
 The lines of the patch file are linkable: simply append
 '#<linenumber>' to the URL, or click on the line-number. This not only
@@ -565,15 +680,14 @@
 [[reply-inline-comment]]
 Clicking on the `Reply` button opens an editor to type the reply.
 
-Quoting is supported, but only by manually copying & pasting the old
-comment that should be quoted and prefixing every line by "> ". Please
-note that for a correct rendering it is important to leave a blank line
-between a quoted block and the reply to it.
+Previous comment can be quoted using "Quote" button. A new draft would be open
+on the same comment thread with the text of the previoused comment quoted using
+markdown syntax.
 
 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"]
 
 Comments are first saved as drafts, and you can revisit the drafts as
-you read through code review. Finally, they should be published by
+you read through code review. Finally, they will be published by
 clicking the "Reply".
 
 [[done]]
@@ -610,6 +724,21 @@
 make it visible to other users it must be published from the change
 screen by link:#reply[replying] to the change.
 
+[[suggest-fix]]
+=== Suggest fix (WIP)
+Comments can contain suggested fixes.
+
+Clicking "Suggest Fix" will insert a special code-block in the text of the
+comment. The contents of this code block will replace the lines the comment is
+attached to (what gets highlighted when hovering over comment).
+
+image::images/user-review-ui-suggest-fix.png[width=400, link="images/user-review-ui-suggest-fix.png"]
+
+The author of the change can then preview and apply the change. This will created
+a new patchset with changes applied.
+
+image::images/user-review-ui-apply-fix.png[width=800, link="images/user-review-ui-apply-fix.png"]
+
 [[file-level-comments]]
 === File Level Comments
 
diff --git a/Documentation/user-search-accounts.txt b/Documentation/user-search-accounts.txt
index 6bcd18e..d5318c9 100644
--- a/Documentation/user-search-accounts.txt
+++ b/Documentation/user-search-accounts.txt
@@ -26,7 +26,10 @@
 [[cansee]]
 cansee:'CHANGE'::
 +
-Matches accounts that can see the change 'CHANGE'.
+Matches accounts that can see the change 'CHANGE'. If the change is private,
+this operator will match with the owner/reviewers/ccs of the change if the
+caller is in owner/reviewers/ccs of the change. Otherwise, the request will fail
+with 404 `Bad Request` with "change not found" message.
 
 [[email]]
 email:'EMAIL'::
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 6e6b9d7..e12c27c 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -8,16 +8,18 @@
 query, execute it, and present the results.
 
 [options="header"]
-|=================================================
-|Description          | Default Query
-|All > Open           | status:open '(or is:open)'
-|All > Merged         | status:merged
-|All > Abandoned      | status:abandoned
-|My > Watched Changes | is:watched is:open
-|My > Starred Changes | is:starred
-|My > Draft Comments  | has:draft
-|Open changes in Foo  | status:open project:Foo
-|=================================================
+|=======================================================
+|Description                | Default Query
+|Changes > Open             | status:open '(or is:open)'
+|Changes > Merged           | status:merged
+|Changes > Abandoned        | status:abandoned
+|Your > Watched Changes     | is:watched is:open
+|Your > Starred Changes     | is:starred
+|Your > Draft Comments      | has:draft
+|Your > Edits               | has:edit
+|Your > All Visible Changes | is:visible
+|Open changes in Foo        | status:open project:Foo
+|=======================================================
 
 
 == Basic Change Search
@@ -41,6 +43,17 @@
 For more predictable results, use explicit search operators as described
 in the following section.
 
+[IMPORTANT]
+--
+The change search API is backed by a secondary index and might sometimes return
+stale results if the re-indexing operation failed for a change update.
+
+Please also note that changes are not re-indexed if the project configuration
+is updated with newly added or modified
+link:config-submit-requirements.html[submit requirements].
+--
+
+
 [[search-operators]]
 == Search Operators
 
@@ -49,6 +62,10 @@
 returned results. Search can also be performed by typing only a
 text with no operator, which will match against a variety of fields.
 
+Characters in operator values can be escaped by enclosing the value with
+double quotes and escaping characters with a backslash. For example
+`message:"This \"is\" fixing a bug"`.
+
 [[age]]
 age:'AGE'::
 +
@@ -264,6 +281,11 @@
 often combined with 'branch:' and 'project:' operators to select
 all related changes in a series.
 
+[[prefixtopic]]
+prefixtopic:'TOPIC'::
++
+Changes whose designated topic start with 'TOPIC'.
+
 [[inhashtag]]
 inhashtag:'HASHTAG'::
 +
@@ -280,6 +302,12 @@
 Changes whose link:intro-user.html#hashtags[hashtag] matches 'HASHTAG'.
 The match is case-insensitive.
 
+[[prefixhashtag]]
+prefixhashtag:'HASHTAG'::
++
+Changes whose link:intro-user.html#hashtags[hashtag] start with 'HASHTAG'.
+The match is case-insensitive.
+
 [[cherrypickof]]
 cherrypickof:'CHANGE[,PATCHSET]'::
 +
@@ -323,6 +351,24 @@
 message:'MESSAGE'::
 +
 Changes that match 'MESSAGE' arbitrary string in the commit message body.
+By default full text matching is used, but regular expressions can be
+enabled by starting with `^`.
+The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns. Note, that searching with
+regular expressions is limited to the first 32766 bytes of the
+commit message due to limitations in Lucene.
+
+[[subject]]
+subject:'SUBJECT'::
++
+Changes that have a commit message where the first line (aka the subject)
+matches 'SUBJECT'. The matching is done by full text search over the subject.
+
+[[prefixsubject]]
+prefixsubject:'PREFIX'::
++
+Changes that have a commit message where the first line (aka the subject)
+has the prefix 'PREFIX'.
 
 [[comment]]
 comment:'TEXT'::
@@ -344,7 +390,7 @@
 files, use `file:^.*\.java`.
 +
 The entire regular expression pattern, including the `^` character,
-should be double quoted. For example, to match all XML
+can be double quoted. For example, to match all XML
 files named like 'name1.xml', 'name2.xml', and 'name3.xml' use
 `file:"^name[1-3].xml"`.
 +
@@ -352,8 +398,8 @@
 +
 *More examples:*
 
-* `-file:^path/.*` - changes that do not modify files from `path/`.
-* `file:{^~(path/.*)}` - changes that modify files not from `path/` (but may
+* `-path:^path/.*` - changes that do not modify files from `path/`.
+* `path:{^~(path/.*)}` - changes that modify files not from `path/` (but may
 contain files from `path/`).
 
 [[file]]
@@ -408,18 +454,11 @@
 current patch set. 'FOOTER' can be specified verbatim ('<key>: <value>', must
 be quoted) or as '<key>=<value>'. The matching is done case-insensitive.
 
-[[star]]
-star:'LABEL'::
+[[hasfooter-operator]]
+hasfooter:'FOOTERNAME'::
 +
-Matches any change that was starred by the current user with the label
-'LABEL'.
-+
-E.g. if changes that are not interesting are marked with an `ignore`
-star, they could be filtered out by '-star:ignore'.
-+
-'star:star' is the same as 'has:star' and 'is:starred'.
-
-Only "ignore" and "star" are supported labels.
+Matches any change that has a commit message with a footer where the footer
+name is equal to 'FOOTERNAME'.The matching is done case-sensitive.
 
 [[has]]
 has:draft::
@@ -429,8 +468,8 @@
 [[has-star]]
 has:star::
 +
-Same as 'is:starred' and 'star:star', true if the change has been
-starred by the current user with the default label.
+Same as 'is:starred', true if the change has been starred by the current user
+with the default label.
 
 has:edit::
 +
@@ -507,6 +546,7 @@
 +
 Same as <<status,status:'STATE'>>.
 
+[[is-submittable]]
 is:submittable::
 +
 True if the change is submittable according to the submit rules for
@@ -518,8 +558,6 @@
 use the
 link:rest-api-changes.html#get-revision-actions[Get Revision Actions]
 API.
-+
-Equivalent to <<submittable,submittable:ok>>.
 
 [[mergeable]]
 is:mergeable::
@@ -531,14 +569,9 @@
 not find any abandoned but mergeable changes.
 +
 This operator only works if Gerrit indexes 'mergeable'. See
-link:config-gerrit.html#index.change.indexMergeable[indexMergeable]
+link:config-gerrit.html#change.mergeabilityComputationBehavior[change.mergeabilityComputationBehavior]
 for details.
 
-[[ignored]]
-is:ignored::
-+
-True if the change is ignored. Same as `star:ignore`.
-
 [[private]]
 is:private::
 +
@@ -610,7 +643,7 @@
 +
 Changes containing a top-level or inline comment by 'USER'. The special
 case of `commentby:self` will find changes where the caller has
-commented.
+commented. Note that setting a vote is also considered as a comment.
 
 [[from]]
 from:'USER'::
@@ -639,16 +672,6 @@
 email address. The special case of `committer:self` will find changes committed
 by the caller.
 
-
-[[submittable]]
-submittable:'SUBMIT_STATUS'::
-+
-Changes having the given submit record status after applying submit
-rules. Valid statuses are in the `status` field of
-link:rest-api-changes.html#submit-record[SubmitRecord]. This operator
-only applies to the top-level status; individual label statuses can be
-searched link:#labels[by label].
-
 [[rule]]
 rule:'SUBMIT_RULE_NAME'::
 +
@@ -656,8 +679,7 @@
 FORCED}. This means that the submit rule has passed and is not blocking the
 change submission. 'SUBMIT_RULE_NAME' should be in the form of
 '$plugin_name~$rule_name'. For gerrit core rules, 'SUBMIT_RULE_NAME' should
-be in the form of '$rule_name' (example: `DefaultSubmitRule`), or
-'gerrit~$rule_name' (example: `gerrit~DefaultSubmitRule`).
+be in the form of 'gerrit~$rule_name' (example: `gerrit~DefaultSubmitRule`).
 
 rule:'SUBMIT_RULE_NAME'='STATUS'::
 +
@@ -699,17 +721,20 @@
 `-is:starred` is the exact opposite of `is:starred` and will
 therefore return changes that are *not* starred by the current user.
 
-The operator `NOT` (in all caps) is a synonym.
+The operator `NOT` (in all caps) or `not` (all lower case) is a
+synonym.
 
 === AND
-The boolean operator `AND` (in all caps) can be used to join two
-other operators together.  This results in a restriction of the
-results, returning only changes that match both operators.
+The boolean operator `AND` (in all caps) or `and` (all lower case)
+can be used to join two other operators together.  This results in
+a restriction of the results, returning only changes that match both
+operators.
 
 === OR
-The boolean operator `OR` (in all caps) can be used to find changes
-that match either operator.  This increases the number of results
-that are returned, as more changes are considered.
+The boolean operator `OR` (in all caps) or `or` (all lower case)
+can be used to find changes that match either operator. This
+increases the number of results that are returned, as more changes
+are considered.
 
 
 [[labels]]
@@ -793,12 +818,25 @@
 Note that a query like `label:Code-Review=+1,count<x` will not match with
 changes having zero +1 votes to this label.
 
-`label:Non-Author-Code-Review=need`::
+`label:Non-Author-Code-Review=need` (deprecated)::
 +
 Matches changes where the submit rules indicate that a label named
 `Non-Author-Code-Review` is needed. (See the
 link:prolog-cookbook.html#NonAuthorCodeReview[Prolog Cookbook] for how
 this label can be configured.)
++
+This operator is also compatible with
+link:config-submit-requirements.html[submit requirement] results. A submit
+requirement name could be used instead of the label name. The submit record
+statuses are mapped to submit requirement result statuses as follows:
++
+  * {`need`, `reject`} -> {`UNSATISFED`}
+  * {`ok`, `may`} -> {`SATISFIED`, `OVERRIDDEN`}
++
+For example, a query like `label:Code-Review=ok` will also match changes
+having a submit requirement with a result that is either `SATISFIED` or
+`OVERRIDDEN`. Users are encouraged not to rely on this operator since submit
+records are deprecated.
 
 `label:Code-Review=+2,aname`::
 `label:Code-Review=ok,aname`::
@@ -836,7 +874,7 @@
 +
 Matches changes that are ready to be submitted according to one common
 label configuration. (For a more general check, use
-link:#submittable[submittable:ok].)
+link:#is-submittable[is:submittable].)
 
 `is:open (label:Verified-1 OR label:Code-Review-2)`::
 `is:open (label:Verified=reject OR label:Code-Review=reject)`::
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 2bfc62d..8c51207 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -340,7 +340,7 @@
 To avoid confusion in parsing the git ref, at least the following characters
 must be percent-encoded: " %^@.~-+_:/!". Note that some of the reserved
 characters (like tilde) are not escaped in the standard URL encoding rules,
-so a language-provided function (e.g. encodeURIComponent(), in javascript)
+so a language-provided function (e.g. encodeURIComponent(), in JavaScript)
 might not suffice. To be safest, you might consider percent-encoding all
 non-alphanumeric characters (and all multibyte UTF-8 code points).
 
diff --git a/README.md b/README.md
index 8a4379b..c8f0b70 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
 [Gerrit](https://www.gerritcodereview.com) is a code review and project
 management tool for Git based projects.
 
-[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-java11-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-java11-master/)
+[![Build Status](https://gerrit-ci.gerritforge.com/view/Gerrit/job/Gerrit-bazel-master/badge/icon)](https://gerrit-ci.gerritforge.com/view/Gerrit/job/Gerrit-bazel-master/)
 ![Maven Central](https://img.shields.io/maven-central/v/com.google.gerrit/gerrit-war)
 
 ## Objective
@@ -60,7 +60,7 @@
 
 On Debian/Ubuntu run:
 
-        apt-get update & apt-get install gerrit=<version>-<release>
+        apt-get update && apt-get install gerrit=<version>-<release>
 
 _NOTE: release is a counter that starts with 1 and indicates the number of packages that have
 been released with the same version of the software._
diff --git a/WORKSPACE b/WORKSPACE
index 93cae7d..047da6a 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,62 +1,61 @@
-# npm packages are split into different node_modules directories based on their usage.
-# 1. /node_modules (referenced as @npm) - contains packages to run tests, check code, etc...
-#    It is expected that @npm is used ONLY to run tools. No packages from @npm are used by
-#    other code in gerrit.
-# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder contains self-written tools
-#    which are run for building and/or testing. The @tools_npm directory contains all the packages needed to
-#    run this tools.
-# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which are necessary to run polygerrit
-#    and to bundle it. Only code from these packages can be included in the final bundle for polygerrit.
-#    @ui_npm folder must not have devDependencies. All dev dependencies must be placed in @ui_dev_npm
-# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit. The packages from these
-#    folder can be used for testing, but must not be included in the final bundle.
-# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit plugins.
-#    The packages here are expected to be used in plugins.
-# Note: separation between @ui_npm and @ui_dev_npm is necessary because with bazel we can't generate
-#    two managed directories from the same package.json. At the same time we want to avoid accidental
-#    usages of code from devDependencies in polygerrit bundle.
+# npm packages are split into different node_modules directories based on their
+# usage.
+# 1. @npm (node_modules) - contains packages to run tests, check code, etc...
+#    It is expected that @npm is used ONLY to run tools. No packages from @npm
+#    are used by other code in gerrit.
+# 2. @tools_npm (tools/node_tools/node_modules) - the tools/node_tools folder
+#    contains self-written tools which are run for building and/or testing. The
+#    @tools_npm directory contains all the packages needed to run this tools.
+# 3. @ui_npm (polygerrit-ui/app/node_modules) - packages with source code which
+#    are necessary to run polygerrit and to bundle it. Only code from these
+#    packages can be included in the final bundle for polygerrit. @ui_npm folder
+#    must not have devDependencies. All devDependencies must be placed in
+#    @ui_dev_npm.
+# 4. @ui_dev_npm (polygerrit-ui/node_modules) - devDependencies for polygerrit.
+#    The packages from these folder can be used for testing, but must not be
+#    included in the final bundle.
+# 5. @plugins_npm (plugins/node_modules) - plugin dependencies for polygerrit
+#    plugins. The packages here are expected to be used in plugins.
+# Note: separation between @ui_npm and @ui_dev_npm is necessary because with
+#    rules_nodejs we can't generate two external repositories from the same
+#    package.json. At the same time we want to avoid accidental usages of code
+#    from devDependencies in polygerrit bundle.
 workspace(
     name = "gerrit",
-    managed_directories = {
-        "@npm": ["node_modules"],
-        "@ui_npm": ["polygerrit-ui/app/node_modules"],
-        "@ui_dev_npm": ["polygerrit-ui/node_modules"],
-        "@tools_npm": ["tools/node_tools/node_modules"],
-        "@plugins_npm": ["plugins/node_modules"],
-    },
 )
 
 load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file")
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "maven_jar")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
-load("//tools:nongoogle.bzl", "TESTCONTAINERS_VERSION", "declare_nongoogle_deps")
+load("//tools:nongoogle.bzl", "declare_nongoogle_deps")
+load("//tools:deps.bzl", "CAFFEINE_VERS", "java_dependencies")
 
 http_archive(
-    name = "bazel_toolchains",
-    sha256 = "1adf7a8e9901287c644dcf9ca08dd8d67a69df94bedbd57a841490a84dc1e9ed",
-    strip_prefix = "bazel-toolchains-5.0.0",
+    name = "platforms",
+    sha256 = "379113459b0feaf6bfbb584a91874c065078aa673222846ac765f86661c27407",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/archive/v5.0.0.tar.gz",
-        "https://github.com/bazelbuild/bazel-toolchains/archive/v5.0.0.tar.gz",
+        "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
+        "https://github.com/bazelbuild/platforms/releases/download/0.0.5/platforms-0.0.5.tar.gz",
     ],
 )
 
-load("@bazel_toolchains//rules:rbe_repo.bzl", "rbe_autoconfig")
-
-# Creates a default toolchain config for RBE.
-rbe_autoconfig(
+http_archive(
     name = "rbe_jdk11",
-    java_home = "/usr/lib/jvm/11.29.3-ca-jdk11.0.2/reduced",
-    use_checked_in_confs = "Force",
+    sha256 = "dbcfd6f26589ef506b91fe03a12dc559ca9c84699e4cf6381150522287f0e6f6",
+    strip_prefix = "rbe_autoconfig-3.1.0",
+    urls = [
+        "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v3.1.0.tar.gz",
+        "https://github.com/davido/rbe_autoconfig/archive/v3.1.0.tar.gz",
+    ],
 )
 
 http_archive(
     name = "com_google_protobuf",
-    sha256 = "d0f5f605d0d656007ce6c8b5a82df3037e1d8fe8b121ed42e536f569dec16113",
-    strip_prefix = "protobuf-3.14.0",
+    sha256 = "3bd7828aa5af4b13b99c191e8b1e884ebfa9ad371b0ce264605d347f135d2568",
+    strip_prefix = "protobuf-3.19.4",
     urls = [
-        "https://github.com/protocolbuffers/protobuf/archive/v3.14.0.tar.gz",
+        "https://github.com/protocolbuffers/protobuf/archive/v3.19.4.tar.gz",
     ],
 )
 
@@ -66,58 +65,43 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "10f534e1c80f795cffe1f2822becd4897754d18564612510c59b3c73544ae7c6",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/3.5.0/rules_nodejs-3.5.0.tar.gz"],
+    sha256 = "c29944ba9b0b430aadcaf3bf2570fece6fc5ebfb76df145c6cdad40d65c20811",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.7.0/rules_nodejs-5.7.0.tar.gz"],
 )
 
+load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
+
+build_bazel_rules_nodejs_dependencies()
+
+# This is required just because we have a dependency on @bazel/concatjs.
+# We don't actually use any of this web_testing stuff.
+# TODO: Remove this dependency.
 http_archive(
-    name = "rules_pkg",
-    sha256 = "038f1caa773a7e35b3663865ffb003169c6a71dc995e39bf4815792f385d837d",
+    name = "io_bazel_rules_webtesting",
+    sha256 = "e9abb7658b6a129740c0b3ef6f5a2370864e102a5ba5ffca2cea565829ed825a",
     urls = [
-        "https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
-        "https://github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
+        "https://github.com/bazelbuild/rules_webtesting/releases/download/0.3.5/rules_webtesting.tar.gz",
     ],
 )
 
-load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies")
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+load("@io_bazel_rules_webtesting//web:repositories.bzl", "web_test_repositories")
 
-rules_pkg_dependencies()
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+web_test_repositories()
 
-# Golang support for PolyGerrit local dev server.
-http_archive(
-    name = "io_bazel_rules_go",
-    sha256 = "4d838e2d70b955ef9dd0d0648f673141df1bc1d7ecf5c2d621dcc163f47dd38a",
-    urls = [
-        "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",
-    ],
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+load("@io_bazel_rules_webtesting//web/versioned:browsers-0.3.3.bzl", "browser_repositories")
+
+# TODO: Remove this, see comments on `io_bazel_rules_webtesting`.
+browser_repositories(
+    chromium = True,
+    firefox = True,
 )
 
-load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
+register_toolchains("//tools:error_prone_warnings_toolchain_java11_definition")
 
-go_rules_dependencies()
-
-go_register_toolchains()
-
-http_archive(
-    name = "bazel_gazelle",
-    sha256 = "222e49f034ca7a1d1231422cdb67066b885819885c356673cb1f72f748a3c9d4",
-    urls = [
-        "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",
-    ],
-)
-
-load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies", "go_repository")
-
-gazelle_dependencies()
-
-# Dependencies for PolyGerrit local dev server.
-go_repository(
-    name = "com_github_howeyc_fsnotify",
-    commit = "441bbc86b167f3c1f4786afae9931403b99fdacf",
-    importpath = "github.com/howeyc/fsnotify",
-)
+register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
 
 # JGit external repository consumed from git submodule
 local_repository(
@@ -125,92 +109,9 @@
     path = "modules/jgit",
 )
 
-ANTLR_VERS = "3.5.2"
+java_dependencies()
 
-maven_jar(
-    name = "java-runtime",
-    artifact = "org.antlr:antlr-runtime:" + ANTLR_VERS,
-    sha1 = "cd9cd41361c155f3af0f653009dcecb08d8b4afd",
-)
-
-maven_jar(
-    name = "stringtemplate",
-    artifact = "org.antlr:stringtemplate:4.0.2",
-    sha1 = "e28e09e2d44d60506a7bcb004d6c23ff35c6ac08",
-)
-
-maven_jar(
-    name = "org-antlr",
-    artifact = "org.antlr:antlr:" + ANTLR_VERS,
-    sha1 = "c4a65c950bfc3e7d04309c515b2177c00baf7764",
-)
-
-maven_jar(
-    name = "antlr27",
-    artifact = "antlr:antlr:2.7.7",
-    attach_source = False,
-    sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0",
-)
-
-maven_jar(
-    name = "aopalliance",
-    artifact = "aopalliance:aopalliance:1.0",
-    sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8",
-)
-
-maven_jar(
-    name = "javax_inject",
-    artifact = "javax.inject:javax.inject:1",
-    sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
-)
-
-maven_jar(
-    name = "servlet-api",
-    artifact = "org.apache.tomcat:tomcat-servlet-api:8.5.23",
-    sha1 = "021a212688ec94fe77aff74ab34cc74f6f940e60",
-)
-
-# 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",
-    sha1 = "a1551373315ffc2f96130a0e5704f74e151777ba",
-)
-
-maven_jar(
-    name = "javaewah",
-    artifact = "com.googlecode.javaewah:JavaEWAH:1.1.6",
-    attach_source = False,
-    sha1 = "94ad16d728b374d65bd897625f3fbb3da223a2b6",
-)
-
-maven_jar(
-    name = "error-prone-annotations",
-    artifact = "com.google.errorprone:error_prone_annotations:2.3.3",
-    sha1 = "42aa5155a54a87d70af32d4b0d06bf43779de0e2",
-)
-
-maven_jar(
-    name = "gson",
-    artifact = "com.google.code.gson:gson:2.8.5",
-    sha1 = "f645ed69d595b24d4cf8b3fbb64cc505bede8829",
-)
-
-CAFFEINE_VERS = "2.8.5"
-
-maven_jar(
-    name = "caffeine",
-    artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
-    sha1 = "f0eafef6e1529a44e36549cd9d1fc06d3a57f384",
-)
-
-CAFFEINE_GUAVA_SHA256 = "a7ce6d29c40bccd688815a6734070c55b20cd326351a06886a6144005aa32299"
+CAFFEINE_GUAVA_SHA256 = "6e48965614557ba4d3c55a197e20c38f23a20032ef8aace37e95ed64d2ebc9a6"
 
 # TODO(davido): Rename guava.jar to caffeine-guava.jar on fetch to prevent potential
 # naming collision between caffeine guava adapter and guava library itself.
@@ -230,701 +131,22 @@
     ],
 )
 
-maven_jar(
-    name = "guava-failureaccess",
-    artifact = "com.google.guava:failureaccess:1.0.1",
-    sha1 = "1dcf1de382a0bf95a3d8b0849546c88bac1292c9",
-)
-
-maven_jar(
-    name = "jsch",
-    artifact = "com.jcraft:jsch:0.1.54",
-    sha1 = "da3584329a263616e277e15462b387addd1b208d",
-)
-
-maven_jar(
-    name = "juniversalchardet",
-    artifact = "com.github.albfernandez:juniversalchardet:2.0.0",
-    sha1 = "28c59f58f5adcc307604602e2aa89e2aca14c554",
-)
-
-SLF4J_VERS = "1.7.26"
-
-maven_jar(
-    name = "log-api",
-    artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS,
-    sha1 = "77100a62c2e6f04b53977b9f541044d7d722693d",
-)
-
-maven_jar(
-    name = "log-ext",
-    artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS,
-    sha1 = "31cdf122e000322e9efcb38913e9ab07825b17ef",
-)
-
-maven_jar(
-    name = "impl-log4j",
-    artifact = "org.slf4j:slf4j-log4j12:" + SLF4J_VERS,
-    sha1 = "12f5c685b71c3027fd28bcf90528ec4ec74bf818",
-)
-
-maven_jar(
-    name = "jcl-over-slf4j",
-    artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS,
-    sha1 = "33fbc2d93de829fa5e263c5ce97f5eab8f57d53e",
-)
-
-maven_jar(
-    name = "log4j",
-    artifact = "log4j:log4j:1.2.17",
-    sha1 = "5af35056b4d257e4b64b9e8069c0746e8b08629f",
-)
-
-maven_jar(
-    name = "json-smart",
-    artifact = "net.minidev:json-smart:1.1.1",
-    sha1 = "24a2f903d25e004de30ac602c5b47f2d4e420a59",
-)
-
-maven_jar(
-    name = "args4j",
-    artifact = "args4j:args4j:2.33",
-    sha1 = "bd87a75374a6d6523de82fef51fc3cfe9baf9fc9",
-)
-
-maven_jar(
-    name = "commons-codec",
-    artifact = "commons-codec:commons-codec:1.10",
-    sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
-)
-
-# When upgrading commons-compress, also upgrade tukaani-xz
-maven_jar(
-    name = "commons-compress",
-    artifact = "org.apache.commons:commons-compress:1.18",
-    sha1 = "1191f9f2bc0c47a8cce69193feb1ff0a8bcb37d5",
-)
-
-maven_jar(
-    name = "commons-lang",
-    artifact = "commons-lang:commons-lang:2.6",
-    sha1 = "0ce1edb914c94ebc388f086c6827e8bdeec71ac2",
-)
-
-maven_jar(
-    name = "commons-lang3",
-    artifact = "org.apache.commons:commons-lang3:3.8.1",
-    sha1 = "6505a72a097d9270f7a9e7bf42c4238283247755",
-)
-
-maven_jar(
-    name = "commons-text",
-    artifact = "org.apache.commons:commons-text:1.2",
-    sha1 = "74acdec7237f576c4803fff0c1008ab8a3808b2b",
-)
-
-maven_jar(
-    name = "commons-dbcp",
-    artifact = "commons-dbcp:commons-dbcp:1.4",
-    sha1 = "30be73c965cc990b153a100aaaaafcf239f82d39",
-)
-
-# Transitive dependency of commons-dbcp, do not update without
-# also updating commons-dbcp
-maven_jar(
-    name = "commons-pool",
-    artifact = "commons-pool:commons-pool:1.5.5",
-    sha1 = "7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b",
-)
-
-maven_jar(
-    name = "commons-net",
-    artifact = "commons-net:commons-net:3.6",
-    sha1 = "b71de00508dcb078d2b24b5fa7e538636de9b3da",
-)
-
-maven_jar(
-    name = "commons-validator",
-    artifact = "commons-validator:commons-validator:1.6",
-    sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908",
-)
-
-maven_jar(
-    name = "automaton",
-    artifact = "dk.brics:automaton:1.12-1",
-    sha1 = "959a0c62f9a5c2309e0ad0b0589c74d69e101241",
-)
-
-COMMONMARK_VERS = "0.10.0"
-
-# commonmark must match the version used in Gitiles
-maven_jar(
-    name = "commonmark",
-    artifact = "com.atlassian.commonmark:commonmark:" + COMMONMARK_VERS,
-    sha1 = "119cb7bedc3570d9ecb64ec69ab7686b5c20559b",
-)
-
-maven_jar(
-    name = "cm-autolink",
-    artifact = "com.atlassian.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERS,
-    sha1 = "a6056a5efbd68f57d420bc51bbc54b28a5d3c56b",
-)
-
-maven_jar(
-    name = "gfm-strikethrough",
-    artifact = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERS,
-    sha1 = "40837da951b421b545edddac57012e15fcc9e63c",
-)
-
-maven_jar(
-    name = "gfm-tables",
-    artifact = "com.atlassian.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERS,
-    sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
-)
-
-FLEXMARK_VERS = "0.50.42"
-
-maven_jar(
-    name = "flexmark",
-    artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
-    sha1 = "ed537d7bc31883b008cc17d243a691c7efd12a72",
-)
-
-maven_jar(
-    name = "flexmark-ext-abbreviation",
-    artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
-    sha1 = "dc27c3e7abbc8d2cfb154f41c68645c365bb9d22",
-)
-
-maven_jar(
-    name = "flexmark-ext-anchorlink",
-    artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
-    sha1 = "6a8edb0165f695c9c19b7143a7fbd78c25c3b99c",
-)
-
-maven_jar(
-    name = "flexmark-ext-autolink",
-    artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
-    sha1 = "5da7a4d009ea08ef2d8714cc73e54a992c6d2d9a",
-)
-
-maven_jar(
-    name = "flexmark-ext-definition",
-    artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
-    sha1 = "862d17812654624ed81ce8fc89c5ef819ff45f87",
-)
-
-maven_jar(
-    name = "flexmark-ext-emoji",
-    artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
-    sha1 = "f0d7db64cb546798742b1ffc6db316a33f6acd76",
-)
-
-maven_jar(
-    name = "flexmark-ext-escaped-character",
-    artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
-    sha1 = "6fd9ab77619df417df949721cb29c45914b326f8",
-)
-
-maven_jar(
-    name = "flexmark-ext-footnotes",
-    artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
-    sha1 = "e36bd69e43147cc6e19c3f55e4b27c0fc5a3d88c",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-issues",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
-    sha1 = "5c825dd4e4fa4f7ccbe30dc92d7e35cdcb8a8c24",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-strikethrough",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
-    sha1 = "3256735fd77e7228bf40f7888b4d3dc56787add4",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-tables",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
-    sha1 = "62f0efcfb974756940ebe749fd4eb01323babc29",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-tasklist",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
-    sha1 = "76d4971ad9ce02f0e70351ab6bd06ad8e405e40d",
-)
-
-maven_jar(
-    name = "flexmark-ext-gfm-users",
-    artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
-    sha1 = "7b0fc7e42e4da508da167fcf8e1cbf9ba7e21147",
-)
-
-maven_jar(
-    name = "flexmark-ext-ins",
-    artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
-    sha1 = "9e51809867b9c4db0fb1c29599b4574e3d2a78e9",
-)
-
-maven_jar(
-    name = "flexmark-ext-jekyll-front-matter",
-    artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
-    sha1 = "44eb6dbb33b3831d3b40af938ddcd99c9c16a654",
-)
-
-maven_jar(
-    name = "flexmark-ext-superscript",
-    artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
-    sha1 = "35815b8cb91000344d1fe5df21cacde8553d2994",
-)
-
-maven_jar(
-    name = "flexmark-ext-tables",
-    artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
-    sha1 = "f6768e98c7210b79d5e8bab76fff27eec6db51e6",
-)
-
-maven_jar(
-    name = "flexmark-ext-toc",
-    artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
-    sha1 = "1968d038fc6c8156f244f5a7eecb34e7e2f33705",
-)
-
-maven_jar(
-    name = "flexmark-ext-typographic",
-    artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
-    sha1 = "6549b9862b61c4434a855a733237103df9162849",
-)
-
-maven_jar(
-    name = "flexmark-ext-wikilink",
-    artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
-    sha1 = "e105b09dd35aab6e6f5c54dfe062ee59bd6f786a",
-)
-
-maven_jar(
-    name = "flexmark-ext-yaml-front-matter",
-    artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
-    sha1 = "b2d3a1e7f3985841062e8d3203617e29c6c21b52",
-)
-
-maven_jar(
-    name = "flexmark-formatter",
-    artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
-    sha1 = "a50c6cb10f6d623fc4354a572c583de1372d217f",
-)
-
-maven_jar(
-    name = "flexmark-html-parser",
-    artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
-    sha1 = "46c075f30017e131c1ada8538f1d8eacf652b044",
-)
-
-maven_jar(
-    name = "flexmark-profile-pegdown",
-    artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
-    sha1 = "d9aafd47629959cbeddd731f327ae090fc92b60f",
-)
-
-maven_jar(
-    name = "flexmark-util",
-    artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
-    sha1 = "417a9821d5d80ddacbfecadc6843ae7b259d5112",
-)
-
-# Transitive dependency of flexmark and gitiles
-maven_jar(
-    name = "autolink",
-    artifact = "org.nibor.autolink:autolink:0.7.0",
-    sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
-)
-
-GREENMAIL_VERS = "1.5.5"
-
-maven_jar(
-    name = "greenmail",
-    artifact = "com.icegreen:greenmail:" + GREENMAIL_VERS,
-    sha1 = "9ea96384ad2cb8118c22f493b529eb72c212691c",
-)
-
-MAIL_VERS = "1.6.0"
-
-maven_jar(
-    name = "mail",
-    artifact = "com.sun.mail:javax.mail:" + MAIL_VERS,
-    sha1 = "a055c648842c4954c1f7db7254f45d9ad565e278",
-)
-
-MIME4J_VERS = "0.8.1"
-
-maven_jar(
-    name = "mime4j-core",
-    artifact = "org.apache.james:apache-mime4j-core:" + MIME4J_VERS,
-    sha1 = "c62dfe18a3b827a2c626ade0ffba44562ddf3f61",
-)
-
-maven_jar(
-    name = "mime4j-dom",
-    artifact = "org.apache.james:apache-mime4j-dom:" + MIME4J_VERS,
-    sha1 = "f2d653c617004193f3350330d907f77b60c88c56",
-)
-
-maven_jar(
-    name = "jsoup",
-    artifact = "org.jsoup:jsoup:1.9.2",
-    sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
-)
-
-OW2_VERS = "9.0"
-
-maven_jar(
-    name = "ow2-asm",
-    artifact = "org.ow2.asm:asm:" + OW2_VERS,
-    sha1 = "af582ff60bc567c42d931500c3fdc20e0141ddf9",
-)
-
-maven_jar(
-    name = "ow2-asm-analysis",
-    artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
-    sha1 = "4630afefbb43939c739445dde0af1a5729a0fb4e",
-)
-
-maven_jar(
-    name = "ow2-asm-commons",
-    artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
-    sha1 = "5a34a3a9ac44f362f35d1b27932380b0031a3334",
-)
-
-maven_jar(
-    name = "ow2-asm-tree",
-    artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
-    sha1 = "9df939f25c556b0c7efe00701d47e77a49837f24",
-)
-
-maven_jar(
-    name = "ow2-asm-util",
-    artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
-    sha1 = "7c059a94ab5eed3347bf954e27fab58e52968848",
-)
-
-AUTO_VALUE_VERSION = "1.7.4"
-
-maven_jar(
-    name = "auto-value",
-    artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
-    sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
-)
-
-maven_jar(
-    name = "auto-value-annotations",
-    artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
-    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()
 
-maven_jar(
-    name = "mime-util",
-    artifact = "eu.medsea.mimeutil:mime-util:2.1.3",
-    attach_source = False,
-    sha1 = "0c9cfae15c74f62491d4f28def0dff1dabe52a47",
+load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
+
+node_repositories(
+    node_version = "17.9.1",
+    yarn_version = "1.22.19",
 )
 
-PROLOG_VERS = "1.4.4"
-
-PROLOG_REPO = GERRIT
-
-maven_jar(
-    name = "prolog-runtime",
-    artifact = "com.googlecode.prolog-cafe:prolog-runtime:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "e9a364f4233481cce63239e8e68a6190c8f58acd",
-)
-
-maven_jar(
-    name = "prolog-compiler",
-    artifact = "com.googlecode.prolog-cafe:prolog-compiler:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "570295026f6aa7b905e423d107cb2e081eecdc04",
-)
-
-maven_jar(
-    name = "prolog-io",
-    artifact = "com.googlecode.prolog-cafe:prolog-io:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "1f25c4e27d22bdbc31481ee0c962a2a2853e4428",
-)
-
-maven_jar(
-    name = "cafeteria",
-    artifact = "com.googlecode.prolog-cafe:prolog-cafeteria:" + PROLOG_VERS,
-    attach_source = False,
-    repository = PROLOG_REPO,
-    sha1 = "0e6c2deeaf5054815a561cbd663566fd59b56c6c",
-)
-
-maven_jar(
-    name = "guava-retrying",
-    artifact = "com.github.rholder:guava-retrying:2.0.0",
-    sha1 = "974bc0a04a11cc4806f7c20a34703bd23c34e7f4",
-)
-
-maven_jar(
-    name = "jsr305",
-    artifact = "com.google.code.findbugs:jsr305:3.0.1",
-    sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
-)
-
-GITILES_VERS = "0.4-1"
-
-GITILES_REPO = GERRIT
-
-maven_jar(
-    name = "blame-cache",
-    artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
-    attach_source = False,
-    repository = GITILES_REPO,
-    sha1 = "0df80c6b8822147e1f116fd7804b8a0de544f402",
-)
-
-maven_jar(
-    name = "gitiles-servlet",
-    artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
-    repository = GITILES_REPO,
-    sha1 = "60870897d22b840e65623fd024eabd9cc9706ebe",
-)
-
-# prettify must match the version used in Gitiles
-maven_jar(
-    name = "prettify",
-    artifact = "com.github.twalcari:java-prettify:1.2.2",
-    sha1 = "b8ba1c1eb8b2e45cfd465d01218c6060e887572e",
-)
-
-maven_jar(
-    name = "html-types",
-    artifact = "com.google.common.html.types:types:1.0.8",
-    sha1 = "9e9cf7bc4b2a60efeb5f5581fe46d17c068e0777",
-)
-
-maven_jar(
-    name = "icu4j",
-    artifact = "com.ibm.icu:icu4j:57.1",
-    sha1 = "198ea005f41219f038f4291f0b0e9f3259730e92",
-)
-
-# When updating Bouncy Castle, also update it in bazlets.
-BC_VERS = "1.61"
-
-maven_jar(
-    name = "bcprov",
-    artifact = "org.bouncycastle:bcprov-jdk15on:" + BC_VERS,
-    sha1 = "00df4b474e71be02c1349c3292d98886f888d1f7",
-)
-
-maven_jar(
-    name = "bcpg",
-    artifact = "org.bouncycastle:bcpg-jdk15on:" + BC_VERS,
-    sha1 = "422656435514ab8a28752b117d5d2646660a0ace",
-)
-
-maven_jar(
-    name = "bcpkix",
-    artifact = "org.bouncycastle:bcpkix-jdk15on:" + BC_VERS,
-    sha1 = "89bb3aa5b98b48e584eee2a7401b7682a46779b4",
-)
-
-maven_jar(
-    name = "h2",
-    artifact = "com.h2database:h2:1.3.176",
-    sha1 = "fd369423346b2f1525c413e33f8cf95b09c92cbd",
-)
-
-# Base the following org.apache.httpcomponents versions on what
-# elasticsearch-rest-client explicitly depends on, except for
-# commons-codec (non-http) which is not necessary yet. Note that
-# below httpcore version(s) differs from the HTTPCOMP_VERS range,
-# upstream: that specific dependency has no HTTPCOMP_VERS version
-# equivalent currently.
-HTTPCOMP_VERS = "4.5.2"
-
-maven_jar(
-    name = "fluent-hc",
-    artifact = "org.apache.httpcomponents:fluent-hc:" + HTTPCOMP_VERS,
-    sha1 = "7bfdfa49de6d720ad3c8cedb6a5238eec564dfed",
-)
-
-maven_jar(
-    name = "httpclient",
-    artifact = "org.apache.httpcomponents:httpclient:" + HTTPCOMP_VERS,
-    sha1 = "733db77aa8d9b2d68015189df76ab06304406e50",
-)
-
-maven_jar(
-    name = "httpcore",
-    artifact = "org.apache.httpcomponents:httpcore:4.4.4",
-    sha1 = "b31526a230871fbe285fbcbe2813f9c0839ae9b0",
-)
-
-# Test-only dependencies below.
-
-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",
-)
-
-maven_jar(
-    name = "diffutils",
-    artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0",
-    sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
-)
-
-JETTY_VERS = "9.4.36.v20210114"
-
-maven_jar(
-    name = "jetty-servlet",
-    artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
-    sha1 = "b189e52a5ee55ae172e4e99e29c5c314f5daf4b9",
-)
-
-maven_jar(
-    name = "jetty-security",
-    artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
-    sha1 = "42030d6ed7dfc0f75818cde0adcf738efc477574",
-)
-
-maven_jar(
-    name = "jetty-server",
-    artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
-    sha1 = "88a7d342974aadca658e7386e8d0fcc5c0788f41",
-)
-
-maven_jar(
-    name = "jetty-jmx",
-    artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
-    sha1 = "bb3847eabe085832aeaedd30e872b40931632e54",
-)
-
-maven_jar(
-    name = "jetty-http",
-    artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
-    sha1 = "1eee89a55e04ff94df0f85d95200fc48acb43d86",
-)
-
-maven_jar(
-    name = "jetty-io",
-    artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
-    sha1 = "84a8faf9031eb45a5a2ddb7681e22c483d81ab3a",
-)
-
-maven_jar(
-    name = "jetty-util",
-    artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
-    sha1 = "925257fbcca6b501a25252c7447dbedb021f7404",
-)
-
-maven_jar(
-    name = "jetty-util-ajax",
-    artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS,
-    sha1 = "2f478130c21787073facb64d7242e06f94980c60",
-    src_sha1 = "7153d7ca38878d971fd90992c303bb7719ba7a21",
-)
-
-maven_jar(
-    name = "asciidoctor",
-    artifact = "org.asciidoctor:asciidoctorj:1.5.7",
-    sha1 = "8e8c1d8fc6144405700dd8df3b177f2801ac5987",
-)
-
-maven_jar(
-    name = "javax-activation",
-    artifact = "javax.activation:activation:1.1.1",
-    sha1 = "485de3a253e23f645037828c07f1d7f1af40763a",
-)
-
-maven_jar(
-    name = "javax-annotation",
-    artifact = "javax.annotation:javax.annotation-api:1.3.2",
-    sha1 = "934c04d3cfef185a8008e7bf34331b79730a9d43",
-)
-
-maven_jar(
-    name = "mockito",
-    artifact = "org.mockito:mockito-core:3.3.3",
-    sha1 = "4878395d4e63173f3825e17e5e0690e8054445f1",
-)
-
-BYTE_BUDDY_VERSION = "1.10.7"
-
-maven_jar(
-    name = "bytebuddy",
-    artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
-    sha1 = "1eefb7dd1b032b33c773ca0a17d5cc9e6b56ea1a",
-)
-
-maven_jar(
-    name = "bytebuddy-agent",
-    artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
-    sha1 = "c472fad33f617228601172682aa64f8b78508045",
-)
-
-maven_jar(
-    name = "objenesis",
-    artifact = "org.objenesis:objenesis:3.0.1",
-    sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808",
-)
-
-load("@build_bazel_rules_nodejs//:index.bzl", "yarn_install")
-
 yarn_install(
     name = "npm",
+    exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:package.json",
     package_path = "",
+    symlink_node_modules = True,
     yarn_lock = "//:yarn.lock",
 )
 
@@ -941,51 +163,43 @@
         # explicitly added to package.json.
         "--ignore-optional",
     ],
+    exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:polygerrit-ui/app/package.json",
     package_path = "polygerrit-ui/app",
+    symlink_node_modules = True,
     yarn_lock = "//:polygerrit-ui/app/yarn.lock",
 )
 
 yarn_install(
     name = "ui_dev_npm",
+    exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:polygerrit-ui/package.json",
     package_path = "polygerrit-ui",
+    symlink_node_modules = True,
     yarn_lock = "//:polygerrit-ui/yarn.lock",
 )
 
 yarn_install(
     name = "tools_npm",
+    exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:tools/node_tools/package.json",
     package_path = "tools/node_tools",
+    symlink_node_modules = True,
     yarn_lock = "//:tools/node_tools/yarn.lock",
 )
 
 yarn_install(
     name = "plugins_npm",
     args = ["--prod"],
+    exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:plugins/package.json",
     package_path = "plugins",
+    symlink_node_modules = True,
     yarn_lock = "//:plugins/yarn.lock",
 )
 
 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 = "595e3a50f59cd3c1d281ca6c1bc4037e277a1353",
-)
diff --git a/antlr3/BUILD b/antlr3/BUILD
index 549946a..23641e3 100644
--- a/antlr3/BUILD
+++ b/antlr3/BUILD
@@ -22,6 +22,7 @@
     srcs = [":query"],
     visibility = [
         "//java/com/google/gerrit/index:__subpackages__",
+        "//java/com/google/gerrit/server:__subpackages__",
         "//javatests/com/google/gerrit:__subpackages__",
         "//javatests/com/google/gerrit/index:__pkg__",
         "//plugins:__pkg__",
diff --git a/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g
index 1bf20aa..ea521f9 100644
--- a/antlr3/com/google/gerrit/index/query/Query.g
+++ b/antlr3/com/google/gerrit/index/query/Query.g
@@ -142,9 +142,9 @@
   | EXACT_PHRASE
   ;
 
-AND: 'AND' ;
-OR:  'OR'  ;
-NOT: 'NOT' ;
+AND: 'AND' | 'and';
+OR:  'OR' | 'or'  ;
+NOT: 'NOT' | 'not' ;
 
 COLON: ':' ;
 
@@ -152,18 +152,19 @@
   :  ( ' ' | '\r' | '\t' | '\n' ) { $channel=HIDDEN; }
   ;
 
+fragment LOWERCASE_AND_UNDERSCORE: ('a'..'z' | '_')+ ;
+
 FIELD_NAME
-  : ('a'..'z' | '_')+
+  : LOWERCASE_AND_UNDERSCORE ( '-' LOWERCASE_AND_UNDERSCORE )*
   ;
 
 EXACT_PHRASE
-  : '"' ( ~('"') )* '"' {
-      String s = $text;
-      setText(s.substring(1, s.length() - 1));
+@init { final StringBuilder buf = new StringBuilder(); }
+  : '"' ( ESCAPE[buf] | i = ~('\\'|'"') { buf.appendCodePoint(i); } )* '"' {
+      setText(buf.toString());
     }
-  | '{' ( ~('{'|'}') )* '}' {
-      String s = $text;
-      setText(s.substring(1, s.length() - 1));
+  | '{' ( ESCAPE[buf] | i = ~('\\'|'{'|'}') { buf.appendCodePoint(i); } )* '}' {
+      setText(buf.toString());
     }
   ;
 
@@ -197,3 +198,11 @@
      // | '~' permit
      )
   ;
+
+fragment ESCAPE[StringBuilder buf] :
+    '\\'
+    ( 't' { buf.append('\t'); }
+    | 'n' { buf.append('\n'); }
+    | 'r' { buf.append('\r'); }
+    | i = ~('t'|'n'|'r') { buf.appendCodePoint(i); }
+    );
diff --git a/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java b/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java
deleted file mode 100644
index 08a529c..0000000
--- a/contrib/convertkey/src/main/java/com/googlesource/gerrit/convertkey/ConvertKey.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.googlesource.gerrit.convertkey;
-
-import com.jcraft.jsch.HostKey;
-import com.jcraft.jsch.JSchException;
-import java.io.File;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.security.GeneralSecurityException;
-import java.security.KeyPair;
-import org.apache.sshd.common.util.Buffer;
-import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
-import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
-
-public class ConvertKey {
-  public static void main(String[] args)
-      throws GeneralSecurityException, JSchException, IOException {
-    SimpleGeneratorHostKeyProvider p;
-
-    if (args.length != 1) {
-      System.err.println("Error: requires path to the SSH host key");
-      return;
-    } else {
-      File file = new File(args[0]);
-      if (!file.exists() || !file.isFile() || !file.canRead()) {
-        System.err.println("Error: ssh key should exist and be readable");
-        return;
-      }
-    }
-
-    p = new SimpleGeneratorHostKeyProvider();
-    // Gerrit's SSH "simple" keys are always RSA.
-    p.setPath(args[0]);
-    p.setAlgorithm("RSA");
-    Iterable<KeyPair> keys = p.loadKeys(); // forces the key to generate.
-    for (KeyPair k : keys) {
-      System.out.println("Public Key (" + k.getPublic().getAlgorithm() + "):");
-      // From Gerrit's SshDaemon class; use JSch to get the public
-      // key/type
-      final Buffer buf = new Buffer();
-      buf.putRawPublicKey(k.getPublic());
-      final byte[] keyBin = buf.getCompactData();
-      HostKey pub = new HostKey("localhost", keyBin);
-      System.out.println(pub.getType() + " " + pub.getKey());
-      System.out.println("Private Key:");
-      // Use Bouncy Castle to write the private key back in PEM format
-      // (PKCS#1)
-      // http://stackoverflow.com/questions/25129822/export-rsa-public-key-to-pem-string-using-java
-      StringWriter privout = new StringWriter();
-      JcaPEMWriter privWriter = new JcaPEMWriter(privout);
-      privWriter.writeObject(k.getPrivate());
-      privWriter.close();
-      System.out.println(privout);
-    }
-  }
-}
diff --git a/contrib/find-duplicate-usernames.sh b/contrib/find-duplicate-usernames.sh
index b59e5be..7a5750f 100755
--- a/contrib/find-duplicate-usernames.sh
+++ b/contrib/find-duplicate-usernames.sh
@@ -29,6 +29,18 @@
   usage
 fi
 
+if [ -z "$(git ls-remote . refs/meta/external-ids)" ]; then
+  cat <<EOF
+Could not find 'refs/meta/external-ids' in the local repository.
+
+Please fetch it using:
+
+  git fetch "$(git remote)" refs/meta/external-ids:refs/meta/external-ids
+
+EOF
+  exit 1
+fi
+
 # 1. find lines with user name and subsequent line in external-ids notes branch
 #    example output of git grep -A1 "\[externalId \"username:" refs/meta/external-ids:
 #    refs/meta/external-ids:00/1d/abd037e437f71d42134e6ad532a06948a2ba:[externalId "username:johndoe"]
@@ -45,12 +57,12 @@
 # 7. flip columns
 # 8. uniq case-insensitive, only show duplicates, avoid comparing first field
 # 9. flip columns back
-git grep -A1 "\[externalId \"$1:" refs/meta/external-ids \
+git grep -A1 "\[externalId \"$1:" refs/meta/external-ids -- \
   | sed -E "/$1/,/accountId/!d" \
   | paste -d ' ' - - \
   | tr \"= : \
   | cut -d: --output-delimiter="" -f 5,8 \
   | sort -f \
-  | sed -E "s/(.*) (.*)/\2 \1/" \
+  | sed -E "s/(.*) ([0-9]+)/\2 \1/" \
   | uniq -Di -f1 \
-  | sed -E "s/(.*) (.*)/\2 \1/"
+  | sed -E "s/([0-9]+) (.*)/\2 \1/"
diff --git a/contrib/git-gc-preserve b/contrib/git-gc-preserve
new file mode 100755
index 0000000..a886721
--- /dev/null
+++ b/contrib/git-gc-preserve
@@ -0,0 +1,149 @@
+#!/bin/bash
+# Copyright (C) 2022 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+usage() { # exit code
+  cat <<-EOF
+NAME
+    git-gc-preserve - Run git gc and preserve old packs to avoid races for JGit
+
+SYNOPSIS
+    git gc-preserve
+
+DESCRIPTION
+    Runs git gc and can preserve old packs to avoid races with concurrently
+    executed commands in JGit.
+
+    This command uses custom git config options to configure if preserved packs
+    from the last run of git gc should be pruned and if packs should be preserved.
+
+    This is similar to the implementation in JGit [1] which is used by
+    JGit to avoid errors [2] in such situations.
+
+    The command prevents concurrent runs of the command on the same repository
+    by acquiring an exclusive file lock on the file
+      "\$repopath/gc-preserve.pid"
+    If it cannot acquire the lock it fails immediately with exit code 3.
+
+    Failure Exit Codes
+        1: General failure
+        2: Couldn't determine repository path. If the current working directory
+           is outside of the working tree of the git repository use git option
+           --git-dir to pass the root path of the repository.
+           E.g.
+              $ git --git-dir ~/git/foo gc-preserve
+        3: Another process already runs $0 on the same repository
+
+    [1] https://git.eclipse.org/r/c/jgit/jgit/+/87969
+    [2] https://git.eclipse.org/r/c/jgit/jgit/+/122288
+
+CONFIGURATION
+    "gc.prunepreserved": if set to "true" preserved packs from the last gc run
+      are pruned before current packs are preserved.
+
+    "gc.preserveoldpacks": if set to "true" current packs will be hard linked
+      to objects/pack/preserved before git gc is executed. JGit will
+      fallback to the preserved packs in this directory in case it comes
+      across missing objects which might be caused by a concurrent run of
+      git gc.
+EOF
+  exit "$1"
+}
+
+# acquire file lock, unlock when the script exits
+lock() { # repo
+  readonly LOCKFILE="$1/gc-preserve.pid"
+  test -f "$LOCKFILE" || touch "$LOCKFILE"
+  exec 9> "$LOCKFILE"
+  if flock -nx 9; then
+    echo -n "$$ $USERNAME@$HOSTNAME" >&9
+    trap unlock EXIT
+  else
+    echo "$0 is already running"
+    exit 3
+  fi
+}
+
+unlock() {
+  # only delete if the file descriptor 9 is open
+  if { : >&9 ; } &> /dev/null; then
+    rm -f "$LOCKFILE"
+  fi
+  # close the file handle to release file lock
+  exec 9>&-
+}
+
+# prune preserved packs if gc.prunepreserved == true
+prune_preserved() { # repo
+  configured=$(git --git-dir="$1" config --get gc.prunepreserved)
+  if [ "$configured" != "true" ]; then
+    return 0
+  fi
+  local preserved=$1/objects/pack/preserved
+  if [ -d "$preserved" ]; then
+    printf "Pruning old preserved packs: "
+    count=$(find "$preserved" -name "*.old-pack" | wc -l)
+    rm -rf "$preserved"
+    echo "$count, done."
+  fi
+}
+
+# preserve packs if gc.preserveoldpacks == true
+preserve_packs() { # repo
+  configured=$(git --git-dir="$1" config --get gc.preserveoldpacks)
+  if [ "$configured" != "true" ]; then
+    return 0
+  fi
+  local packdir=$1/objects/pack
+  pushd "$packdir" >/dev/null || exit 1
+  mkdir -p preserved
+  printf "Preserving packs: "
+  count=0
+  for file in pack-*{.pack,.idx} ; do
+    ln -f "$file" preserved/"$(get_preserved_packfile_name "$file")"
+    if [[ "$file" == pack-*.pack ]]; then
+      ((count++))
+    fi
+  done
+  echo "$count, done."
+  popd >/dev/null || exit 1
+}
+
+# pack-0...2.pack to pack-0...2.old-pack
+# pack-0...2.idx to pack-0...2.old-idx
+get_preserved_packfile_name() { # packfile > preserved_packfile
+  local old=${1/%\.pack/.old-pack}
+  old=${old/%\.idx/.old-idx}
+  echo "$old"
+}
+
+# main
+
+while [ $# -gt 0 ] ; do
+    case "$1" in
+        -u|-h)  usage 0 ;;
+    esac
+    shift
+done
+args=$(git rev-parse --sq-quote "$@")
+
+repopath=$(git rev-parse --git-dir)
+if [ -z "$repopath" ]; then
+  usage 2
+fi
+
+lock "$repopath"
+prune_preserved "$repopath"
+preserve_packs "$repopath"
+git gc ${args:+"$args"} || { echo "git gc failed"; exit "$?"; }
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
index 5b892aa..594903a 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/a/projects/PROJECT/branches/master"
+    "url": "HTTP_SCHEME://REPLICA_HOSTNAME:HTTP_PORT1/a/projects/PROJECT/branches/master"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
index f15ddae..5221d95 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/_PROJECT",
+    "url": "HTTP_SCHEME://REPLICA_HOSTNAME:HTTP_PORT1/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
index 467661b..75e895e 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects",
     "entries": "PROJECTS_ENTRIES"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
index 30f5f23..195ed09 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
@@ -1,10 +1,10 @@
 [
   {
-    "url": "ssh://admin@HOSTNAME:SSH_PORT/_PROJECT",
+    "url": "ssh://USERNAME@HOSTNAME:SSH_PORT/_PROJECT",
     "cmd": "clone"
   },
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
index 5459f11..487cf02 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/branches/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/branches/",
     "project": "PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
index 70e79ca..8b8a163 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "project": "PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
index c141bb8..756313a 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT",
     "parent": "PARENT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
index 5720f53..8bec9de 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/delete-project~delete"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/delete-project~delete"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
index e30a2cf..c1dc674 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects/flush"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
index e30a2cf..c1dc674 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects/flush"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
index 86a3c28..4f6a104 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/branches/master"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/branches/master"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
index e4e2643..e77d83b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
index f6350be..528ef3e 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
index 30f5f23..195ed09 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
@@ -1,10 +1,10 @@
 [
   {
-    "url": "ssh://admin@HOSTNAME:SSH_PORT/_PROJECT",
+    "url": "ssh://USERNAME@HOSTNAME:SSH_PORT/_PROJECT",
     "cmd": "clone"
   },
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
index 301c65b..13d5802 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
index 301c65b..13d5802 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/"
   }
 ]
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
index d387a3e..d8ebf7f 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
@@ -24,7 +24,6 @@
 
 class AbandonChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private var numbersCopy: mutable.Queue[Int] = mutable.Queue[Int]()
   private var createChange: Option[CreateChange] = Some(new CreateChange(projectName))
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
index ae4fa80..692d576 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
@@ -22,7 +22,6 @@
 
 class CheckNewProjectReplica1 extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
 
   private lazy val replicationDuration = replicationDelay + SecondsPerWeightUnit
 
@@ -30,7 +29,6 @@
 
   override def replaceOverride(in: String): String = {
     var next = replaceProperty("http_port1", 8081, in)
-    next = replaceKeyWith("_project", projectName, next)
     super.replaceOverride(next)
   }
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
index c283861..d1d9c88 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
@@ -22,12 +22,8 @@
 
 class CloneUsingBothProtocols extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private val duration = 2 * numberOfUsers
 
-  override def replaceOverride(in: String): String = {
-    replaceKeyWith("_project", projectName, in)
-  }
 
   private val test: ScenarioBuilder = scenario(uniqueName)
       .feed(data)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
index 9fef2cf..a7bda3c 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
@@ -22,7 +22,6 @@
 
 class FlushProjectsCache extends CacheFlushSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
 
   override def relativeRuntimeWeight = 2
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index c199dd9..580ae81 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -23,6 +23,7 @@
 class GerritSimulation extends Simulation {
   implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
 
+  private val defaultHostname: String = "localhost"
   protected val numberKey: String = "number"
 
   private val packageName = getClass.getPackage.getName
@@ -42,7 +43,7 @@
   protected val SecondsPerWeightUnit = 2
   val maxExecutionTime: Int = (SecondsPerWeightUnit * relativeRuntimeWeight * powerFactor).toInt
   private var cumulativeWaitTime = 0
-
+  protected var projectName: String = className
   /**
    * How long a scenario step should wait before starting to execute.
    * This is also registering that step's resulting wait time, so that time
@@ -73,16 +74,23 @@
       replaceProperty("parent", "All-Projects", parent.toString)
     case ("project", project) =>
       var precedes = replaceKeyWith("_project", className, project.toString)
-      precedes = replaceOverride(precedes)
+      precedes = replaceProperty("project", getFullProjectName(projectName), precedes)
       replaceProperty("project", precedes)
     case ("url", url) =>
       var in = replaceOverride(url.toString)
-      in = replaceProperty("hostname", "localhost", in)
+      in = replaceProperty("replica_hostname", getProperty("hostname", defaultHostname), in)
+      in = replaceProperty("hostname", defaultHostname, in)
       in = replaceProperty("http_port", 8080, in)
       in = replaceProperty("http_scheme", "http", in)
+      in = replaceProperty("username", "admin", in)
+      in = replaceProperty("context_path", "", in)
       replaceProperty("ssh_port", 29418, in)
   }
 
+  protected def getFullProjectName(projectName: String): String = {
+    getProperty("project_prefix", "") + projectName
+  }
+
   private def replaceProperty(term: String, in: String): String = {
     replaceProperty(term, term, in)
   }
@@ -100,7 +108,7 @@
     val property = packageName + "." + term
     var value = default
     default match {
-      case _: String | _: Double =>
+      case _: String | _: Double | _: Boolean =>
         val propertyValue = Option(System.getProperty(property))
         if (propertyValue.nonEmpty) {
           value = propertyValue.get
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
index e2f13a4..53f942d 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.scenarios
 
+import static java.nio.charset.StandardCharsets.UTF_8
 import java.io.{File, IOException}
+import java.net.URLEncoder
 
 import com.github.barbasa.gatling.git.GitRequestSession
 import com.github.barbasa.gatling.git.protocol.GitProtocol
@@ -29,6 +31,14 @@
   protected val gitRequest = new GitRequestBuilder(GitRequestSession("${cmd}", "${url}"))
   protected val gitProtocol: GitProtocol = GitProtocol()
 
+  override def replaceOverride(in: String): String = {
+    var next = replaceKeyWith("_project", URLEncoder.encode(getFullProjectName(projectName), UTF_8), in)
+    val authenticated = getProperty("authenticated", false).toBoolean
+    val value = "CONTEXT_PATH" + (if (authenticated) "/a" else "")
+    next = replaceKeyWith("context_path", value, next)
+    super.replaceOverride(next)
+  }
+
   after {
     Thread.sleep(5000)
     val path = conf.tmpBasePath
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
index d6cb937..f0c6f68 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.scenarios
 
+import static java.nio.charset.StandardCharsets.UTF_8
+import java.net.URLEncoder
+
 class ProjectSimulation extends GerritSimulation {
-  protected var projectName: String = "defaultTestProject"
+  projectName = "defaultTestProject"
 
   override def replaceOverride(in: String): String = {
-    replaceProperty("project", projectName, in)
+    replaceProperty("project", URLEncoder.encode(getFullProjectName(projectName), UTF_8, in)
   }
 }
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
index 0bd9e4a..c049f09 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
@@ -22,13 +22,9 @@
 
 class ReplayRecordsFromFeeder extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
 
   override def relativeRuntimeWeight = 30
 
-  override def replaceOverride(in: String): String = {
-    replaceKeyWith("_project", projectName, in)
-  }
 
   private val test: ScenarioBuilder = scenario(uniqueName)
       .repeat(10) {
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
index 81096b0..756a239 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
@@ -24,7 +24,6 @@
 
 class RestoreChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private var numbersCopy: mutable.Queue[Int] = mutable.Queue[Int]()
 
   override def relativeRuntimeWeight = 10
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
index 20be28a..49f0c4b 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
@@ -23,7 +23,6 @@
 
 class SubmitChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
   private var createChange = new CreateChange(projectName)
 
   override def relativeRuntimeWeight = 10
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
index 9e1431b..ca618c3 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
@@ -25,7 +25,6 @@
 class SubmitChangeInBranch extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
   private var changesCopy: mutable.Queue[Int] = mutable.Queue[Int]()
-  private val projectName = className
 
   override def relativeRuntimeWeight = 10
 
diff --git a/java/Main.java b/java/Main.java
index 11d8234..09c8c76 100644
--- a/java/Main.java
+++ b/java/Main.java
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+@SuppressWarnings("DefaultPackage")
 public final class Main {
   private static final String FLOGGER_BACKEND_PROPERTY = "flogger.backend_factory";
   private static final String FLOGGER_LOGGING_CONTEXT = "flogger.logging_context";
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index f7b343b..ccf74d1 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -26,11 +26,13 @@
 import static com.google.gerrit.entities.Patch.COMMIT_MSG;
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.project.testing.TestLabels.label;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
@@ -41,9 +43,9 @@
 import com.google.common.base.Ticker;
 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.Lists;
-import com.google.common.jimfs.Jimfs;
 import com.google.common.primitives.Chars;
 import com.google.common.testing.FakeTicker;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
@@ -80,6 +82,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
+import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -87,6 +90,7 @@
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -96,9 +100,8 @@
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.index.project.ProjectIndexCollection;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -125,11 +128,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -157,15 +156,7 @@
 import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
 import java.lang.reflect.Modifier;
-import java.nio.file.DirectoryStream;
-import java.nio.file.FileSystem;
-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;
@@ -179,6 +170,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.UUID;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
@@ -187,7 +179,6 @@
 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;
 import org.eclipse.jgit.lib.Ref;
@@ -197,11 +188,7 @@
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.transport.FetchResult;
-import org.eclipse.jgit.transport.RefSpec;
 import org.eclipse.jgit.transport.Transport;
-import org.eclipse.jgit.transport.TransportBundleStream;
-import org.eclipse.jgit.transport.URIish;
 import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
@@ -313,14 +300,11 @@
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
   @Inject private AbstractChangeNotes.Args changeNotesArgs;
-  @Inject private AccountIndexCollection accountIndexes;
   @Inject private AccountIndexer accountIndexer;
-  @Inject private ChangeIndexCollection changeIndexes;
   @Inject private EventRecorder.Factory eventRecorderFactory;
   @Inject private InProcessProtocol inProcessProtocol;
   @Inject private PluginGuiceEnvironment pluginGuiceEnvironment;
   @Inject private PluginUser.Factory pluginUserFactory;
-  @Inject private ProjectIndexCollection projectIndexes;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private SitePaths sitePaths;
   @Inject private ProjectOperations projectOperations;
@@ -461,7 +445,6 @@
 
     baseConfig.setInt("index", null, "batchThreads", -1);
 
-    baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
     Module module = createModule();
     Module auditModule = createAuditModule();
     Module sshModule = createSshModule();
@@ -583,8 +566,7 @@
         && SshMode.useSsh()
         && (adminSshSession == null || userSshSession == null)) {
       // Create Ssh sessions
-      KeyPair adminKeyPair = sshKeys.getKeyPair(admin);
-      SshSessionFactory.initSsh(adminKeyPair);
+      SshSessionFactory.initSsh();
       Context ctx = newRequestContext(user);
       atrScope.set(ctx);
       userSshSession = ctx.getSession();
@@ -659,7 +641,10 @@
   }
 
   protected Project.NameKey createProjectOverAPI(
-      String nameSuffix, Project.NameKey parent, boolean createEmptyCommit, SubmitType submitType)
+      String nameSuffix,
+      @Nullable Project.NameKey parent,
+      boolean createEmptyCommit,
+      @Nullable SubmitType submitType)
       throws RestApiException {
     ProjectInput in = new ProjectInput();
     in.name = name(nameSuffix);
@@ -972,7 +957,7 @@
             repo,
             "new subject",
             "new file",
-            "new content");
+            "new content " + UUID.randomUUID());
     return result;
   }
 
@@ -1069,87 +1054,6 @@
     };
   }
 
-  protected void disableChangeIndexWrites() {
-    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
-      if (!(i instanceof ReadOnlyChangeIndex)) {
-        changeIndexes.addWriteIndex(new ReadOnlyChangeIndex(i));
-      }
-    }
-  }
-
-  protected void enableChangeIndexWrites() {
-    for (ChangeIndex i : changeIndexes.getWriteIndexes()) {
-      if (i instanceof ReadOnlyChangeIndex) {
-        changeIndexes.addWriteIndex(((ReadOnlyChangeIndex) i).unwrap());
-      }
-    }
-  }
-
-  protected AutoCloseable disableChangeIndex() {
-    disableChangeIndexWrites();
-    ChangeIndex maybeDisabledSearchIndex = changeIndexes.getSearchIndex();
-    if (!(maybeDisabledSearchIndex instanceof DisabledChangeIndex)) {
-      changeIndexes.setSearchIndex(new DisabledChangeIndex(maybeDisabledSearchIndex), false);
-    }
-
-    return () -> {
-      enableChangeIndexWrites();
-      ChangeIndex maybeEnabledSearchIndex = changeIndexes.getSearchIndex();
-      if (maybeEnabledSearchIndex instanceof DisabledChangeIndex) {
-        changeIndexes.setSearchIndex(
-            ((DisabledChangeIndex) maybeEnabledSearchIndex).unwrap(), false);
-      }
-    };
-  }
-
-  protected AutoCloseable disableAccountIndex() {
-    AccountIndex maybeDisabledSearchIndex = accountIndexes.getSearchIndex();
-    if (!(maybeDisabledSearchIndex instanceof DisabledAccountIndex)) {
-      accountIndexes.setSearchIndex(new DisabledAccountIndex(maybeDisabledSearchIndex), false);
-    }
-
-    return () -> {
-      AccountIndex maybeEnabledSearchIndex = accountIndexes.getSearchIndex();
-      if (maybeEnabledSearchIndex instanceof DisabledAccountIndex) {
-        accountIndexes.setSearchIndex(
-            ((DisabledAccountIndex) maybeEnabledSearchIndex).unwrap(), false);
-      }
-    };
-  }
-
-  protected AutoCloseable disableProjectIndex() {
-    disableProjectIndexWrites();
-    ProjectIndex maybeDisabledSearchIndex = projectIndexes.getSearchIndex();
-    if (!(maybeDisabledSearchIndex instanceof DisabledProjectIndex)) {
-      projectIndexes.setSearchIndex(new DisabledProjectIndex(maybeDisabledSearchIndex), false);
-    }
-
-    return () -> {
-      enableProjectIndexWrites();
-      ProjectIndex maybeEnabledSearchIndex = projectIndexes.getSearchIndex();
-      if (maybeEnabledSearchIndex instanceof DisabledProjectIndex) {
-        projectIndexes.setSearchIndex(
-            ((DisabledProjectIndex) maybeEnabledSearchIndex).unwrap(), false);
-      }
-    };
-  }
-
-  protected void disableProjectIndexWrites() {
-    for (ProjectIndex i : projectIndexes.getWriteIndexes()) {
-      if (!(i instanceof DisabledProjectIndex)) {
-        projectIndexes.addWriteIndex(new DisabledProjectIndex(i));
-      }
-    }
-  }
-
-  protected void enableProjectIndexWrites() {
-    for (ProjectIndex i : projectIndexes.getWriteIndexes()) {
-      if (i instanceof DisabledProjectIndex) {
-        projectIndexes.addWriteIndex(((DisabledProjectIndex) i).unwrap());
-      }
-    }
-  }
-
   protected static Gson newGson() {
     return OutputFormat.JSON_COMPACT.newGson();
   }
@@ -1163,7 +1067,7 @@
       ProjectConfig config = projectConfigFactory.read(md);
       config.updateProject(p -> p.setBooleanConfig(BooleanProjectConfig.USE_SIGNED_OFF_BY, value));
       config.commit(md);
-      projectCache.evict(config.getProject());
+      projectCache.evictAndReindex(config.getProject());
     }
   }
 
@@ -1172,7 +1076,20 @@
       ProjectConfig config = projectConfigFactory.read(md);
       config.updateProject(p -> p.setBooleanConfig(BooleanProjectConfig.REQUIRE_CHANGE_ID, value));
       config.commit(md);
-      projectCache.evict(config.getProject());
+      projectCache.evictAndReindex(config.getProject());
+    }
+  }
+
+  protected void setSkipAddingAuthorAndCommitterAsReviewers(InheritableBoolean value)
+      throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+      ProjectConfig config = projectConfigFactory.read(md);
+      config.updateProject(
+          p ->
+              p.setBooleanConfig(
+                  BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS, value));
+      config.commit(md);
+      projectCache.evictAndReindex(config.getProject());
     }
   }
 
@@ -1199,10 +1116,74 @@
     gApi.changes().id(id).current().review(ReviewInput.recommend());
   }
 
+  protected void assertThatAccountIsNotVisible(TestAccount... testAccounts) {
+    for (TestAccount testAccount : testAccounts) {
+      assertThrows(
+          ResourceNotFoundException.class, () -> gApi.accounts().id(testAccount.id().get()).get());
+    }
+  }
+
+  protected void assertReviewers(String changeId, TestAccount... expectedReviewers)
+      throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).containsKey(ReviewerState.REVIEWER);
+    List<Integer> reviewers =
+        reviewerMap.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+    assertThat(reviewers)
+        .containsExactlyElementsIn(
+            Arrays.stream(expectedReviewers).map(a -> a.id().get()).collect(toList()));
+  }
+
+  protected void assertCcs(String changeId, TestAccount... expectedCcs) throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).containsKey(ReviewerState.CC);
+    List<Integer> ccs =
+        reviewerMap.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+    assertThat(ccs)
+        .containsExactlyElementsIn(
+            Arrays.stream(expectedCcs).map(a -> a.id().get()).collect(toList()));
+  }
+
+  protected void assertNoCcs(String changeId) throws RestApiException {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+        gApi.changes().id(changeId).get().reviewers;
+    assertThat(reviewerMap).doesNotContainKey(ReviewerState.CC);
+  }
+
   protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
-    List<ChangeInfo> actual = gApi.changes().id(chId).submittedTogether();
+    assertSubmittedTogether(chId, ImmutableSet.of(), expected);
+  }
+
+  protected void assertSubmittedTogetherWithTopicClosure(String chId, String... expected)
+      throws Exception {
+    assertSubmittedTogether(chId, ImmutableSet.of(TOPIC_CLOSURE), expected);
+  }
+
+  protected void assertSubmittedTogether(
+      String chId,
+      ImmutableSet<SubmittedTogetherOption> submittedTogetherOptions,
+      String... expected)
+      throws Exception {
+    // This does not include NON_VISIBILE_CHANGES
+    List<ChangeInfo> actual =
+        submittedTogetherOptions.isEmpty()
+            ? gApi.changes().id(chId).submittedTogether()
+            : gApi.changes()
+                .id(chId)
+                .submittedTogether(EnumSet.copyOf(submittedTogetherOptions))
+                .changes;
+
+    EnumSet<SubmittedTogetherOption> enumSetIncludingNonVisibleChanges =
+        submittedTogetherOptions.isEmpty()
+            ? EnumSet.of(NON_VISIBLE_CHANGES)
+            : EnumSet.copyOf(submittedTogetherOptions);
+    enumSetIncludingNonVisibleChanges.add(NON_VISIBLE_CHANGES);
+
+    // This includes NON_VISIBLE_CHANGES for comparison.
     SubmittedTogetherInfo info =
-        gApi.changes().id(chId).submittedTogether(EnumSet.of(NON_VISIBLE_CHANGES));
+        gApi.changes().id(chId).submittedTogether(enumSetIncludingNonVisibleChanges);
 
     assertThat(info.nonVisibleChanges).isEqualTo(0);
     assertThat(Iterables.transform(actual, i1 -> i1.changeId))
@@ -1256,62 +1237,10 @@
     assertThat(replyTo.getString()).contains(email);
   }
 
-  protected Map<BranchNameKey, ObjectId> fetchFromSubmitPreview(String changeId) throws Exception {
-    try (BinaryResult result = gApi.changes().id(changeId).current().submitPreview()) {
-      return fetchFromBundles(result);
-    }
-  }
-
-  /**
-   * Fetches each bundle into a newly cloned repository, then it applies the bundle, and returns the
-   * resulting tree id.
-   *
-   * <p>Omits NoteDb meta refs.
-   */
-  protected Map<BranchNameKey, ObjectId> fetchFromBundles(BinaryResult bundles) throws Exception {
-    assertThat(bundles.getContentType()).isEqualTo("application/x-zip");
-
-    FileSystem fs = Jimfs.newFileSystem();
-    Path previewPath = fs.getPath("preview.zip");
-    try (OutputStream out = Files.newOutputStream(previewPath)) {
-      bundles.writeTo(out);
-    }
-    Map<BranchNameKey, ObjectId> ret = new HashMap<>();
-    try (FileSystem zipFs = FileSystems.newFileSystem(previewPath, (ClassLoader) null);
-        DirectoryStream<Path> dirStream =
-            Files.newDirectoryStream(Iterables.getOnlyElement(zipFs.getRootDirectories()))) {
-      for (Path p : dirStream) {
-        if (!Files.isRegularFile(p)) {
-          continue;
-        }
-        String bundleName = p.getFileName().toString();
-        int len = bundleName.length();
-        assertThat(bundleName).endsWith(".git");
-        String repoName = bundleName.substring(0, len - 4);
-        Project.NameKey proj = Project.nameKey(repoName);
-        TestRepository<?> localRepo = cloneProject(proj);
-
-        try (InputStream bundleStream = Files.newInputStream(p);
-            TransportBundleStream tbs =
-                new TransportBundleStream(
-                    localRepo.getRepository(), new URIish(bundleName), bundleStream)) {
-          FetchResult fr =
-              tbs.fetch(
-                  NullProgressMonitor.INSTANCE,
-                  Arrays.asList(new RefSpec("refs/*:refs/preview/*")));
-          for (Ref r : fr.getAdvertisedRefs()) {
-            String refName = r.getName();
-            if (RefNames.isNoteDbMetaRef(refName)) {
-              continue;
-            }
-            RevCommit c = localRepo.getRevWalk().parseCommit(r.getObjectId());
-            ret.put(BranchNameKey.create(proj, refName), c.getTree().copy());
-          }
-        }
-      }
-    }
-    assertThat(ret).isNotEmpty();
-    return ret;
+  protected void assertMailNotReplyTo(Message message, String email) throws Exception {
+    assertThat(message.headers()).containsKey("Reply-To");
+    StringEmailHeader replyTo = (StringEmailHeader) message.headers().get("Reply-To");
+    assertThat(replyTo.getString()).doesNotContain(email);
   }
 
   /** Assert that the given branches have the given tree ids. */
@@ -1337,34 +1266,61 @@
     assertThat(refValues.keySet()).containsAnyIn(trees.keySet());
   }
 
-  protected void assertDiffForNewFile(
-      DiffInfo diff, RevCommit commit, String path, String expectedContentSideB) throws Exception {
-    List<String> expectedLines = ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+  protected void assertDiffForFullyModifiedFile(
+      DiffInfo diff,
+      String commitName,
+      String path,
+      String expectedContentSideA,
+      String expectedContentSideB)
+      throws Exception {
+    assertDiffForFile(diff, commitName, path);
 
-    assertThat(diff.binary).isNull();
+    ImmutableList<String> expectedOldLines =
+        ImmutableList.copyOf(expectedContentSideA.split("\n", -1));
+    ImmutableList<String> expectedNewLines =
+        ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+
+    assertThat(diff.changeType).isEqualTo(ChangeType.MODIFIED);
+
+    assertThat(diff.metaA).isNotNull();
+    assertThat(diff.metaB).isNotNull();
+
+    assertThat(diff.metaA.name).isEqualTo(path);
+    assertThat(diff.metaA.lines).isEqualTo(expectedOldLines.size());
+    assertThat(diff.metaB.name).isEqualTo(path);
+    assertThat(diff.metaB.lines).isEqualTo(expectedNewLines.size());
+
+    DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+    assertThat(contentEntry.a).containsExactlyElementsIn(expectedOldLines).inOrder();
+    assertThat(contentEntry.b).containsExactlyElementsIn(expectedNewLines).inOrder();
+    assertThat(contentEntry.ab).isNull();
+    assertThat(contentEntry.common).isNull();
+    assertThat(contentEntry.editA).isNull();
+    assertThat(contentEntry.editB).isNull();
+    assertThat(contentEntry.skip).isNull();
+  }
+
+  protected void assertDiffForNewFile(
+      DiffInfo diff, @Nullable RevCommit commit, String path, String expectedContentSideB)
+      throws Exception {
+    assertDiffForNewFile(diff, commit.name(), path, expectedContentSideB);
+  }
+
+  protected void assertDiffForNewFile(
+      DiffInfo diff, String commitName, String path, String expectedContentSideB) throws Exception {
+    assertDiffForFile(diff, commitName, path);
+
+    ImmutableList<String> expectedLines =
+        ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+
     assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
-    assertThat(diff.diffHeader).isNotNull();
-    assertThat(diff.intralineStatus).isNull();
-    assertThat(diff.webLinks).isNull();
-    assertThat(diff.editWebLinks).isNull();
 
     assertThat(diff.metaA).isNull();
     assertThat(diff.metaB).isNotNull();
-    assertThat(diff.metaB.commitId).isEqualTo(commit.name());
 
-    String expectedContentType = "text/plain";
-    if (COMMIT_MSG.equals(path)) {
-      expectedContentType = FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE;
-    } else if (MERGE_LIST.equals(path)) {
-      expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
-    }
-    assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
-
-    assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
     assertThat(diff.metaB.name).isEqualTo(path);
-    assertThat(diff.metaB.webLinks).isNull();
+    assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
 
-    assertThat(diff.content).hasSize(1);
     DiffInfo.ContentEntry contentEntry = diff.content.get(0);
     assertThat(contentEntry.b).containsExactlyElementsIn(expectedLines).inOrder();
     assertThat(contentEntry.a).isNull();
@@ -1375,6 +1331,57 @@
     assertThat(contentEntry.skip).isNull();
   }
 
+  protected void assertDiffForDeletedFile(DiffInfo diff, String path, String expectedContentSideA)
+      throws Exception {
+    assertDiffHeaders(diff);
+
+    ImmutableList<String> expectedOriginalLines =
+        ImmutableList.copyOf(expectedContentSideA.split("\n", -1));
+
+    assertThat(diff.changeType).isEqualTo(ChangeType.DELETED);
+
+    assertThat(diff.metaA).isNotNull();
+    assertThat(diff.metaB).isNull();
+
+    assertThat(diff.metaA.name).isEqualTo(path);
+    assertThat(diff.metaA.lines).isEqualTo(expectedOriginalLines.size());
+
+    DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+    assertThat(contentEntry.a).containsExactlyElementsIn(expectedOriginalLines).inOrder();
+    assertThat(contentEntry.b).isNull();
+    assertThat(contentEntry.ab).isNull();
+    assertThat(contentEntry.common).isNull();
+    assertThat(contentEntry.editA).isNull();
+    assertThat(contentEntry.editB).isNull();
+    assertThat(contentEntry.skip).isNull();
+  }
+
+  private void assertDiffForFile(DiffInfo diff, String commitName, String path) throws Exception {
+    assertDiffHeaders(diff);
+
+    assertThat(diff.metaB.commitId).isEqualTo(commitName);
+
+    String expectedContentType = "text/plain";
+    if (COMMIT_MSG.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE;
+    } else if (MERGE_LIST.equals(path)) {
+      expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
+    }
+
+    assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
+
+    assertThat(diff.metaB.name).isEqualTo(path);
+    assertThat(diff.metaB.webLinks).isNull();
+  }
+
+  private void assertDiffHeaders(DiffInfo diff) throws Exception {
+    assertThat(diff.binary).isNull();
+    assertThat(diff.diffHeader).isNotNull();
+    assertThat(diff.intralineStatus).isNull();
+    assertThat(diff.webLinks).isNull();
+    assertThat(diff.editWebLinks).isNull();
+  }
+
   protected void assertPermitted(ChangeInfo info, String label, Integer... expected) {
     assertThat(info.permittedLabels).isNotNull();
     Collection<String> strs = info.permittedLabels.get(label);
@@ -1386,6 +1393,17 @@
     }
   }
 
+  protected void assertOnlyRemovableLabel(
+      ChangeInfo info, String labelId, String labelValue, TestAccount reviewer) {
+    assertThat(info.removableLabels).hasSize(1);
+    assertThat(info.removableLabels).containsKey(labelId);
+    assertThat(info.removableLabels.get(labelId)).hasSize(1);
+    assertThat(info.removableLabels.get(labelId)).containsKey(labelValue);
+    assertThat(info.removableLabels.get(labelId).get(labelValue)).hasSize(1);
+    assertThat(info.removableLabels.get(labelId).get(labelValue).get(0).email)
+        .isEqualTo(reviewer.email());
+  }
+
   protected void assertPermissions(
       Project.NameKey project,
       GroupReference groupReference,
@@ -1614,6 +1632,13 @@
     }
   }
 
+  protected void clearSubmitRequirements(Project.NameKey project) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().clearSubmitRequirements();
+      u.save();
+    }
+  }
+
   protected void configLabel(String label, LabelFunction func) throws Exception {
     configLabel(label, func, ImmutableList.of());
   }
@@ -1686,7 +1711,7 @@
       projectConfig.commit(metaDataUpdate);
       metaDataUpdate.close();
       metaDataUpdate = null;
-      projectCache.evict(projectConfig.getProject());
+      projectCache.evictAndReindex(projectConfig.getProject());
     }
 
     @Override
diff --git a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
index a4ed80a..4e8d20d 100644
--- a/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDynamicOptionsTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -50,7 +51,7 @@
 
     public void display(OutputStream displayOutputStream) throws Exception {
       PrintWriter stdout =
-          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, "UTF-8")));
+          new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
       try {
         OutputFormat.JSON
             .newGson()
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 6c9a2b1..da033c1 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -98,6 +98,7 @@
 
   protected static class FakeEmailSenderSubject extends Subject {
     private final FakeEmailSender fakeEmailSender;
+    private String emailTitle;
     private Message message;
     private StagedUsers users;
     private Map<RecipientType, List<String>> recipients = new HashMap<>();
@@ -145,6 +146,10 @@
                     ? ((StringEmailHeader) header).getString()
                     : header));
       }
+      EmailHeader titleHeader = message.headers().get("Subject");
+      if (titleHeader instanceof StringEmailHeader) {
+        emailTitle = ((StringEmailHeader) titleHeader).getString();
+      }
 
       return this;
     }
@@ -190,6 +195,15 @@
       return rcpt(users.supportReviewersByEmail ? BCC : null, emails);
     }
 
+    public FakeEmailSenderSubject title(String expectedEmailTitle) {
+      if (!emailTitle.equals(expectedEmailTitle)) {
+        failWithoutActual(
+            fact("Expected email title", expectedEmailTitle),
+            fact("but actual title is", emailTitle));
+      }
+      return this;
+    }
+
     private FakeEmailSenderSubject rcpt(@Nullable RecipientType type, String[] emails) {
       for (String email : emails) {
         rcpt(type, email);
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index 91fbf9e..fe845c0 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -303,6 +303,7 @@
     return pluginInfoFromChangeInfo(changeInfos.get(0));
   }
 
+  @Nullable
   protected static List<PluginDefinedInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
     List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
     if (pluginInfo == null) {
@@ -331,6 +332,7 @@
    * @param plugins list of {@code MyInfo} objects, each as a raw map returned from Gson.
    * @return decoded list of {@code MyInfo}s.
    */
+  @Nullable
   protected static List<PluginDefinedInfo> decodeRawPluginsList(
       Gson gson, @Nullable Object plugins) {
     if (plugins == null) {
diff --git a/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
index 50536d8..c4bf20c 100644
--- a/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
+++ b/java/com/google/gerrit/acceptance/AcceptanceTestRequestScope.java
@@ -162,7 +162,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               return requireContext().get(key, creator);
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index c67991d..ff5bc00 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -141,6 +141,10 @@
     return create(username, null, username, null, (String[]) null);
   }
 
+  public TestAccount createValid(String username) throws Exception {
+    return create(username, username + "@example.com", username, username);
+  }
+
   public TestAccount admin() throws Exception {
     return create("admin", "admin@example.com", "Administrator", "Adminny", "Administrators");
   }
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index fe6e160..9d237af 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -39,11 +39,9 @@
     "//lib:gson",
     "//lib:guava-retrying",
     "//lib:jgit",
-    "//lib:jgit-ssh-jsch",
     "//lib:jgit-ssh-apache",
-    "//lib:jsch",
     "//lib/commons:compress",
-    "//lib/commons:lang",
+    "//lib/commons:lang3",
     "//lib/flogger:api",
     "//lib/guice",
     "//lib/guice:guice-assistedinject",
@@ -76,12 +74,12 @@
     "//java/com/google/gerrit/gpg/testing:gpg-test-util",
     "//java/com/google/gerrit/git/testing",
     "//java/com/google/gerrit/index/testing",
+    "//lib/errorprone:annotations",
 ]
 
 PGM_DEPLOY_ENV = [
     "//lib:caffeine",
     "//lib:caffeine-guava",
-    "//lib/jackson:jackson-core",
     "//lib/prolog:cafeteria",
 ]
 
diff --git a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
index 1ff7d0e..5991646 100644
--- a/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
+++ b/java/com/google/gerrit/acceptance/ChangeIndexedCounter.java
@@ -46,6 +46,10 @@
   }
 
   public void assertReindexOf(ChangeInfo info, long expectedCount) {
+    if (expectedCount == 0) {
+      assertThat(countsByChange.asMap()).isEmpty();
+      return;
+    }
     assertThat(countsByChange.asMap()).containsExactly(info._number, expectedCount);
     clear();
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
index 271d15c..7660948 100644
--- a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
@@ -45,11 +45,21 @@
   }
 
   @Override
+  public void insert(AccountState obj) {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
   public void replace(AccountState obj) {
     throw new UnsupportedOperationException("AccountIndex is disabled");
   }
 
   @Override
+  public void deleteByValue(AccountState value) {
+    throw new UnsupportedOperationException("AccountIndex is disabled");
+  }
+
+  @Override
   public void delete(Account.Id key) {
     throw new UnsupportedOperationException("AccountIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
index 34f72f5c..c028a8e 100644
--- a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
@@ -52,11 +52,21 @@
   }
 
   @Override
+  public void insert(ChangeData obj) {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
   public void replace(ChangeData obj) {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
 
   @Override
+  public void deleteByValue(ChangeData value) {
+    throw new UnsupportedOperationException("ChangeIndex is disabled");
+  }
+
+  @Override
   public void delete(Change.Id key) {
     throw new UnsupportedOperationException("ChangeIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
index ed119ff..f2aad4a 100644
--- a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
@@ -50,11 +50,21 @@
   }
 
   @Override
+  public void insert(ProjectData obj) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
   public void replace(ProjectData obj) {
     throw new UnsupportedOperationException("ProjectIndex is disabled");
   }
 
   @Override
+  public void deleteByValue(ProjectData value) {
+    throw new UnsupportedOperationException("ProjectIndex is disabled");
+  }
+
+  @Override
   public void delete(Project.NameKey key) {
     throw new UnsupportedOperationException("ProjectIndex is disabled");
   }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 1e5598e..3d90bf0 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -14,14 +14,17 @@
 
 package com.google.gerrit.acceptance;
 
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
 import com.google.gerrit.extensions.events.AccountActivationListener;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
@@ -36,6 +39,7 @@
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
+import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.ExceptionHook;
@@ -48,6 +52,8 @@
 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.query.change.ChangeQueryBuilder.ChangeHasOperandFactory;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeIsOperandFactory;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
@@ -70,6 +76,7 @@
   private final DynamicSet<PerformanceLogger> performanceLoggers;
   private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
   private final DynamicSet<SubmitRule> submitRules;
+  private final DynamicSet<SubmitRequirement> submitRequirements;
   private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
   private final DynamicSet<ChangeETagComputation> changeETagComputations;
   private final DynamicSet<ActionVisitor> actionVisitors;
@@ -77,10 +84,12 @@
   private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
   private final DynamicSet<CommentAddedListener> commentAddedListeners;
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
+  private final DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
   private final DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks;
   private final DynamicSet<EditWebLink> editWebLinks;
+  private final DynamicSet<FileWebLink> fileWebLinks;
   private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
   private final DynamicSet<GroupBackend> groupBackends;
   private final DynamicSet<AccountActivationValidationListener>
@@ -95,6 +104,10 @@
   private final DynamicSet<OnPostReview> onPostReviews;
   private final DynamicSet<ReviewerAddedListener> reviewerAddedListeners;
   private final DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners;
+  private final DynamicSet<AttentionSetListener> attentionSetListeners;
+
+  private final DynamicMap<ChangeHasOperandFactory> hasOperands;
+  private final DynamicMap<ChangeIsOperandFactory> isOperands;
 
   @Inject
   ExtensionRegistry(
@@ -108,6 +121,7 @@
       DynamicSet<PerformanceLogger> performanceLoggers,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
       DynamicSet<SubmitRule> submitRules,
+      DynamicSet<SubmitRequirement> submitRequirements,
       DynamicSet<ChangeMessageModifier> changeMessageModifiers,
       DynamicSet<ChangeETagComputation> changeETagComputations,
       DynamicSet<ActionVisitor> actionVisitors,
@@ -115,10 +129,12 @@
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
       DynamicSet<CommentAddedListener> commentAddedListeners,
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
+      DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
       DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
       DynamicSet<EditWebLink> editWebLinks,
+      DynamicSet<FileWebLink> fileWebLinks,
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
       DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
@@ -131,7 +147,10 @@
       DynamicSet<PluginPushOption> pluginPushOption,
       DynamicSet<OnPostReview> onPostReviews,
       DynamicSet<ReviewerAddedListener> reviewerAddedListeners,
-      DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners) {
+      DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners,
+      DynamicMap<ChangeHasOperandFactory> hasOperands,
+      DynamicMap<ChangeIsOperandFactory> isOperands,
+      DynamicSet<AttentionSetListener> attentionSetListeners) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -142,6 +161,7 @@
     this.performanceLoggers = performanceLoggers;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
     this.submitRules = submitRules;
+    this.submitRequirements = submitRequirements;
     this.changeMessageModifiers = changeMessageModifiers;
     this.changeETagComputations = changeETagComputations;
     this.actionVisitors = actionVisitors;
@@ -149,9 +169,11 @@
     this.refOperationValidationListeners = refOperationValidationListeners;
     this.commentAddedListeners = commentAddedListeners;
     this.refUpdatedListeners = refUpdatedListeners;
+    this.batchRefUpdateListeners = batchRefUpdateListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
     this.editWebLinks = editWebLinks;
+    this.fileWebLinks = fileWebLinks;
     this.resolveConflictsWebLinks = resolveConflictsWebLinks;
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
@@ -166,6 +188,9 @@
     this.onPostReviews = onPostReviews;
     this.reviewerAddedListeners = reviewerAddedListeners;
     this.reviewerDeletedListeners = reviewerDeletedListeners;
+    this.hasOperands = hasOperands;
+    this.isOperands = isOperands;
+    this.attentionSetListeners = attentionSetListeners;
   }
 
   public Registration newRegistration() {
@@ -216,6 +241,18 @@
       return add(submitRules, submitRule);
     }
 
+    public Registration add(SubmitRequirement submitRequirement) {
+      return add(submitRequirements, submitRequirement);
+    }
+
+    public Registration add(ChangeHasOperandFactory hasOperand, String exportName) {
+      return add(hasOperands, hasOperand, exportName);
+    }
+
+    public Registration add(ChangeIsOperandFactory isOperand, String exportName) {
+      return add(isOperands, isOperand, exportName);
+    }
+
     public Registration add(ChangeMessageModifier changeMessageModifier) {
       return add(changeMessageModifiers, changeMessageModifier);
     }
@@ -248,6 +285,10 @@
       return add(refUpdatedListeners, refUpdatedListener);
     }
 
+    public Registration add(GitBatchRefUpdateListener batchRefUpdateListener) {
+      return add(batchRefUpdateListeners, batchRefUpdateListener);
+    }
+
     public Registration add(FileHistoryWebLink fileHistoryWebLink) {
       return add(fileHistoryWebLinks, fileHistoryWebLink);
     }
@@ -264,6 +305,10 @@
       return add(editWebLinks, editWebLink);
     }
 
+    public Registration add(FileWebLink fileWebLink) {
+      return add(fileWebLinks, fileWebLink);
+    }
+
     public Registration add(RevisionCreatedListener revisionCreatedListener) {
       return add(revisionCreatedListeners, revisionCreatedListener);
     }
@@ -289,6 +334,10 @@
       return add(workInProgressStateChangedListeners, workInProgressStateChangedListener);
     }
 
+    public Registration add(AttentionSetListener attentionSetListener) {
+      return add(attentionSetListeners, attentionSetListener);
+    }
+
     public Registration add(CapabilityDefinition capabilityDefinition, String exportName) {
       return add(capabilityDefinitions, capabilityDefinition, exportName);
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 402d21d..a149f29 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -61,6 +61,7 @@
 import com.google.gerrit.server.experiments.ConfigExperimentFeatures.ConfigExperimentFeaturesModule;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits.AsyncReceiveCommitsModule;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.util.ReplicaUtil;
@@ -492,11 +493,11 @@
     }
     if (indexType.isLucene()) {
       daemon.setIndexModule(
-          LuceneIndexModule.singleVersionAllLatest(0, ReplicaUtil.isReplica(baseConfig)));
+          LuceneIndexModule.singleVersionAllLatest(
+              0, ReplicaUtil.isReplica(baseConfig), AutoFlush.ENABLED));
     } else {
       daemon.setIndexModule(FakeIndexModule.latestVersion(false));
     }
-    // Elastic search is not supported in integration tests yet.
 
     daemon.setEnableHttpd(desc.httpd());
     daemon.setInMemory(true);
diff --git a/java/com/google/gerrit/acceptance/GitClientVersion.java b/java/com/google/gerrit/acceptance/GitClientVersion.java
index 4c9a32d..2415781 100644
--- a/java/com/google/gerrit/acceptance/GitClientVersion.java
+++ b/java/com/google/gerrit/acceptance/GitClientVersion.java
@@ -16,6 +16,8 @@
 
 import static java.util.stream.Collectors.joining;
 
+import com.google.common.base.Splitter;
+import java.util.List;
 import java.util.stream.IntStream;
 
 /** Class to parse and represent version of git-core client */
@@ -38,11 +40,11 @@
    */
   public GitClientVersion(String version) {
     // "git version x.y.z", at Google "git version x.y.z.gXXXXXXXXXX-goog"
-    String parts[] = version.split(" ")[2].split("\\.");
-    int numParts = Math.min(parts.length, 3); // ignore Google-specific part of the version
+    List<String> parts = Splitter.on(".").splitToList(Splitter.on(" ").splitToList(version).get(2));
+    int numParts = Math.min(parts.size(), 3); // ignore Google-specific part of the version
     v = new int[numParts];
     for (int i = 0; i < numParts; i++) {
-      v[i] = Integer.valueOf(parts[i]);
+      v[i] = Integer.valueOf(parts.get(i));
     }
   }
 
diff --git a/java/com/google/gerrit/acceptance/HttpResponse.java b/java/com/google/gerrit/acceptance/HttpResponse.java
index 88079a4..76c0f04 100644
--- a/java/com/google/gerrit/acceptance/HttpResponse.java
+++ b/java/com/google/gerrit/acceptance/HttpResponse.java
@@ -19,6 +19,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.Reader;
@@ -59,6 +60,7 @@
     return getHeader("X-FYI-Content-Type");
   }
 
+  @Nullable
   public String getHeader(String name) {
     Header hdr = response.getFirstHeader(name);
     return hdr != null ? hdr.getValue() : null;
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index 373246a..9a652e3 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -21,11 +21,13 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.MetricsReservoirConfig;
 import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
 import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GlobalPluginConfigProvider;
+import com.google.gerrit.server.config.MetricsReservoirConfigImpl;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.TrackingFooters;
@@ -36,6 +38,7 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
+import com.google.inject.Scopes;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -69,6 +72,7 @@
       bind(InMemoryRepositoryManager.class).in(SINGLETON);
     }
 
+    bind(MetricsReservoirConfig.class).to(MetricsReservoirConfigImpl.class).in(Scopes.SINGLETON);
     bind(MetricMaker.class).to(TestMetricMaker.class);
 
     listener().to(CreateSchema.class);
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java
index 83c63f9..17ce595 100644
--- a/java/com/google/gerrit/acceptance/InProcessProtocol.java
+++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -100,7 +100,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               Context ctx = current.get();
@@ -235,9 +235,9 @@
 
       PermissionBackend.ForProject perm = permissionBackend.currentUser().project(req.project);
       try {
-        perm.check(ProjectPermission.RUN_UPLOAD_PACK);
-      } catch (AuthException e) {
-        throw new ServiceNotAuthorizedException(e.getMessage(), e);
+        if (!perm.test(ProjectPermission.RUN_UPLOAD_PACK)) {
+          throw new ServiceNotAuthorizedException("upload pack not permitted");
+        }
       } catch (PermissionBackendException e) {
         throw new RuntimeException(e);
       }
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index d885303..46f7496 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -307,7 +307,7 @@
         Sets.union(
             projectsWithConfigChanges(restoredRefsByProject),
             projectsWithConfigChanges(deletedRefsByProject))) {
-      projectCache.evict(project);
+      projectCache.evictAndReindex(project);
     }
   }
 
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index d46fb78..5b1fa9b 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
@@ -263,7 +264,11 @@
     if (changeId != null) {
       commitBuilder = testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
     } else {
-      commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
+      if (subject.contains("\nChange-Id: ")) {
+        commitBuilder = testRepo.amendRef("HEAD");
+      } else {
+        commitBuilder = testRepo.branch("HEAD").commit().insertChangeId(nextChangeId());
+      }
     }
     commitBuilder.message(subject).author(i).committer(new PersonIdent(i, testRepo.getDate()));
   }
@@ -276,6 +281,12 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
+  public PushOneCommit setTopLevelTreeId(ObjectId treeId) throws Exception {
+    commitBuilder.setTopLevelTree(treeId);
+    return this;
+  }
+
   public PushOneCommit setParent(RevCommit parent) throws Exception {
     commitBuilder.noParents();
     commitBuilder.parent(parent);
@@ -287,6 +298,19 @@
     return this;
   }
 
+  public PushOneCommit addFile(String path, String content, int fileMode) throws Exception {
+    RevBlob blobId = testRepo.blob(content);
+    commitBuilder.edit(
+        new PathEdit(path) {
+          @Override
+          public void apply(DirCacheEntry ent) {
+            ent.setFileMode(FileMode.fromBits(fileMode));
+            ent.setObjectId(blobId);
+          }
+        });
+    return this;
+  }
+
   public PushOneCommit addSymlink(String path, String target) throws Exception {
     RevBlob blobId = testRepo.blob(target);
     commitBuilder.edit(
diff --git a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java b/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
deleted file mode 100644
index e943519..0000000
--- a/java/com/google/gerrit/acceptance/ReadOnlyChangeIndex.java
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.query.change.ChangeData;
-
-class ReadOnlyChangeIndex implements ChangeIndex {
-  private final ChangeIndex index;
-
-  ReadOnlyChangeIndex(ChangeIndex index) {
-    this.index = index;
-  }
-
-  ChangeIndex unwrap() {
-    return index;
-  }
-
-  @Override
-  public Schema<ChangeData> getSchema() {
-    return index.getSchema();
-  }
-
-  @Override
-  public void close() {
-    index.close();
-  }
-
-  @Override
-  public void replace(ChangeData obj) {
-    // do nothing
-  }
-
-  @Override
-  public void delete(Change.Id key) {
-    // do nothing
-  }
-
-  @Override
-  public void deleteAll() {
-    // do nothing
-  }
-
-  @Override
-  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    return index.getSource(p, opts);
-  }
-
-  @Override
-  public void markReady(boolean ready) {
-    // do nothing
-  }
-}
diff --git a/java/com/google/gerrit/acceptance/SshSessionJsch.java b/java/com/google/gerrit/acceptance/SshSessionJsch.java
deleted file mode 100644
index a86c2d6..0000000
--- a/java/com/google/gerrit/acceptance/SshSessionJsch.java
+++ /dev/null
@@ -1,174 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance;
-
-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
index 3b0ba3b..89096e4 100644
--- a/java/com/google/gerrit/acceptance/SshSessionMina.java
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.nio.file.Files.createTempDirectory;
 
 import com.google.common.io.CharSink;
 import com.google.common.io.Files;
@@ -140,7 +141,7 @@
                   + addr.getPort());
 
       // TODO(davido): Switch to memory only key resolving mode.
-      File userhome = Files.createTempDir();
+      File userhome = createTempDirectory("home-").toFile();
 
       FS fs = FS.DETECTED.setUserHome(userhome);
       File sshDir = new File(userhome, ".ssh");
@@ -168,6 +169,7 @@
                 MoreFiles.deleteRecursively(userhome.toPath(), ALLOW_INSECURE);
               } catch (IOException e) {
                 e.printStackTrace();
+                throw new RuntimeException("Failed to cleanup userhome", e);
               }
             });
       }
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java
index 2620f99..85c5b6d 100644
--- a/java/com/google/gerrit/acceptance/TestMetricMaker.java
+++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -18,9 +18,8 @@
 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;
+import java.util.concurrent.ConcurrentHashMap;
+import org.apache.commons.lang3.mutable.MutableLong;
 
 /**
  * {@link com.google.gerrit.metrics.MetricMaker} to be bound in tests.
@@ -49,7 +48,7 @@
  */
 @Singleton
 public class TestMetricMaker extends DisabledMetricMaker {
-  private final Map<String, MutableLong> counts = new HashMap<>();
+  private final ConcurrentHashMap<String, MutableLong> counts = new ConcurrentHashMap<>();
 
   public long getCount(String counter0Name) {
     return get(counter0Name).longValue();
diff --git a/java/com/google/gerrit/acceptance/TestOnStoreSubmitRequirementResultModifier.java b/java/com/google/gerrit/acceptance/TestOnStoreSubmitRequirementResultModifier.java
new file mode 100644
index 0000000..0138ffa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/TestOnStoreSubmitRequirementResultModifier.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.ChangeContext;
+import java.util.Optional;
+
+/** Implementation of {@link OnStoreSubmitRequirementResultModifier} that is used in tests. */
+public class TestOnStoreSubmitRequirementResultModifier
+    implements OnStoreSubmitRequirementResultModifier {
+
+  private ModificationStrategy modificationStrategy = ModificationStrategy.KEEP;
+
+  private boolean hide = false;
+
+  /**
+   * The strategy, used by this modifier to transform {@link SubmitRequirementResult} on {@link
+   * OnStoreSubmitRequirementResultModifier#modifyResultOnStore} invocations.
+   */
+  public enum ModificationStrategy {
+    KEEP,
+    FAIL,
+    PASS,
+    OVERRIDE
+  }
+
+  public void setModificationStrategy(ModificationStrategy modificationStrategy) {
+    this.modificationStrategy = modificationStrategy;
+  }
+
+  public void hide(boolean hide) {
+    this.hide = hide;
+  }
+
+  @Override
+  public SubmitRequirementResult modifyResultOnStore(
+      SubmitRequirement submitRequirement,
+      SubmitRequirementResult result,
+      ChangeData cd,
+      ChangeContext ctx) {
+    if (modificationStrategy.equals(ModificationStrategy.KEEP)) {
+      return result;
+    }
+    SubmitRequirementResult.Builder srResultBuilder = result.toBuilder().hidden(Optional.of(hide));
+    if (modificationStrategy.equals(ModificationStrategy.OVERRIDE)) {
+      return srResultBuilder
+          .overrideExpressionResult(
+              Optional.of(
+                  SubmitRequirementExpressionResult.create(
+                      submitRequirement.submittabilityExpression(),
+                      SubmitRequirementExpressionResult.Status.PASS,
+                      ImmutableList.of(),
+                      ImmutableList.of(
+                          submitRequirement.submittabilityExpression().expressionString()))))
+          .build();
+    }
+    return srResultBuilder
+        .submittabilityExpressionResult(
+            SubmitRequirementExpressionResult.create(
+                submitRequirement.submittabilityExpression(),
+                modificationStrategy.equals(ModificationStrategy.FAIL)
+                    ? SubmitRequirementExpressionResult.Status.FAIL
+                    : SubmitRequirementExpressionResult.Status.PASS,
+                ImmutableList.of(),
+                ImmutableList.of(submitRequirement.submittabilityExpression().expressionString())))
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/config/BUILD b/java/com/google/gerrit/acceptance/config/BUILD
index a8ccc1f..0da68b0 100644
--- a/java/com/google/gerrit/acceptance/config/BUILD
+++ b/java/com/google/gerrit/acceptance/config/BUILD
@@ -7,6 +7,7 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
index 24a2117..27ce857 100644
--- a/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
+++ b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -27,6 +28,7 @@
 public class ConfigAnnotationParser {
   private static Splitter splitter = Splitter.on(".").trimResults();
 
+  @Nullable
   public static Config parse(Config base, GerritConfigs annotation) {
     if (annotation == null) {
       return null;
@@ -45,6 +47,7 @@
     return cfg;
   }
 
+  @Nullable
   public static Map<String, Config> parse(GlobalPluginConfigs annotation) {
     if (annotation == null || annotation.value().length < 1) {
       return null;
@@ -67,6 +70,7 @@
     return result;
   }
 
+  @Nullable
   public static Map<String, Config> parse(GlobalPluginConfig annotation) {
     if (annotation == null) {
       return null;
diff --git a/java/com/google/gerrit/acceptance/rest/PluginResource.java b/java/com/google/gerrit/acceptance/rest/PluginResource.java
index 745d4fa..56710b9 100644
--- a/java/com/google/gerrit/acceptance/rest/PluginResource.java
+++ b/java/com/google/gerrit/acceptance/rest/PluginResource.java
@@ -20,6 +20,5 @@
 
 public class PluginResource extends ConfigResource {
 
-  static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND =
-      new TypeLiteral<RestView<PluginResource>>() {};
+  static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND = new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 3b15b57..c1029be 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -47,9 +47,8 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.Objects;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -138,15 +137,16 @@
     try (Repository repository = repositoryManager.openRepository(project);
         ObjectInserter objectInserter = repository.newObjectInserter();
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
       IdentifiedUser changeOwner = getChangeOwner(changeCreation);
-      PersonIdent authorAndCommitter =
-          changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
+      PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
       ObjectId commitId =
           createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
 
       String refName = RefNames.fullName(changeCreation.branch());
       ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+      changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
+      inserter.setApprovals(changeCreation.approvals());
 
       try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
         batchUpdate.setRepository(repository, revWalk, objectInserter);
@@ -431,7 +431,7 @@
       try (Repository repository = repositoryManager.openRepository(project);
           ObjectInserter objectInserter = repository.newObjectInserter();
           RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-        Timestamp now = TimeUtil.nowTs();
+        Instant now = TimeUtil.now();
         ObjectId newPatchsetCommit =
             createPatchsetCommit(
                 repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
@@ -457,7 +457,7 @@
         ObjectInserter objectInserter,
         ChangeNotes changeNotes,
         TestPatchsetCreation patchsetCreation,
-        Timestamp now)
+        Instant now)
         throws IOException, BadRequestException {
       ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
       RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId);
@@ -494,10 +494,10 @@
       return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
     }
 
-    private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Timestamp now) {
+    private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Instant now) {
       PersonIdent oldPatchsetCommitter =
           Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
-      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhen())) {
+      if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhenAsInstant())) {
         /* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
          * In real situations, this automatically happens as two patchsets won't have exactly the
          * same commit timestamp even when the tree and commit message are the same. In tests,
@@ -505,13 +505,13 @@
          * We could of course require that tests must use TestTimeUtil#setClockStep but
          * that would be an unnecessary nuisance for test writers. Hence, go with a simple solution
          * here and simply add a second. */
-        now = Timestamp.from(now.toInstant().plusSeconds(1));
+        now = now.plusSeconds(1);
       }
       return new PersonIdent(oldPatchsetCommitter, now);
     }
 
-    private long asSeconds(Date date) {
-      return date.getTime() / 1000;
+    private long asSeconds(Instant date) {
+      return date.getEpochSecond();
     }
 
     private ImmutableList<ObjectId> getParents(
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
index d0ccd5b..1971c57 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
@@ -28,13 +28,18 @@
 public class FileContentBuilder<T> {
   private final T builder;
   private final String filePath;
+  private final int newGitFileMode;
   private final Consumer<TreeModification> modificationToBuilderAdder;
 
   FileContentBuilder(
-      T builder, String filePath, Consumer<TreeModification> modificationToBuilderAdder) {
+      T builder,
+      String filePath,
+      int newGitFileMode,
+      Consumer<TreeModification> modificationToBuilderAdder) {
     checkNotNull(Strings.emptyToNull(filePath), "File path must not be null or empty.");
     this.builder = builder;
     this.filePath = filePath;
+    this.newGitFileMode = newGitFileMode;
     this.modificationToBuilderAdder = modificationToBuilderAdder;
   }
 
@@ -44,7 +49,7 @@
         Strings.emptyToNull(content),
         "Empty file content is not supported. Adjust test API if necessary.");
     modificationToBuilderAdder.accept(
-        new ChangeFileContentModification(filePath, RawInputUtil.create(content)));
+        new ChangeFileContentModification(filePath, RawInputUtil.create(content), newGitFileMode));
     return builder;
   }
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/IndexOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/IndexOperations.java
new file mode 100644
index 0000000..cba9b15
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/IndexOperations.java
@@ -0,0 +1,162 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.testsuite.change;
+
+import com.google.gerrit.acceptance.DisabledAccountIndex;
+import com.google.gerrit.acceptance.DisabledChangeIndex;
+import com.google.gerrit.acceptance.DisabledProjectIndex;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.inject.Inject;
+
+/** Helpers to enable and disable reads/writes to secondary indices during testing. */
+public interface IndexOperations {
+  /**
+   * Disables reads from the secondary index that this instance is scoped to. Reads fail with {@code
+   * UnsupportedOperationException}.
+   */
+  AutoCloseable disableReads();
+
+  /**
+   * Disables writes to the secondary index that this instance is scoped to. Writes fail with {@code
+   * UnsupportedOperationException}.
+   */
+  AutoCloseable disableWrites();
+
+  /** Disables reads from and writes to the secondary index that this instance is scoped to. */
+  default AutoCloseable disableReadsAndWrites() {
+    AutoCloseable reads = disableReads();
+    AutoCloseable writes = disableWrites();
+    return () -> {
+      reads.close();
+      writes.close();
+    };
+  }
+
+  class Change implements IndexOperations {
+    @Inject private ChangeIndexCollection indices;
+
+    @Override
+    public AutoCloseable disableReads() {
+      ChangeIndex maybeDisabledSearchIndex = indices.getSearchIndex();
+      if (!(maybeDisabledSearchIndex instanceof DisabledChangeIndex)) {
+        indices.setSearchIndex(
+            new DisabledChangeIndex(maybeDisabledSearchIndex), /* closeOld */ false);
+      }
+
+      return () -> {
+        ChangeIndex maybeEnabledSearchIndex = indices.getSearchIndex();
+        if (maybeEnabledSearchIndex instanceof DisabledChangeIndex) {
+          indices.setSearchIndex(
+              ((DisabledChangeIndex) maybeEnabledSearchIndex).unwrap(), /* closeOld */ false);
+        }
+      };
+    }
+
+    @Override
+    public AutoCloseable disableWrites() {
+      for (ChangeIndex i : indices.getWriteIndexes()) {
+        if (!(i instanceof DisabledChangeIndex)) {
+          indices.addWriteIndex(new DisabledChangeIndex(i));
+        }
+      }
+      return () -> {
+        for (ChangeIndex i : indices.getWriteIndexes()) {
+          if (i instanceof DisabledChangeIndex) {
+            indices.addWriteIndex(((DisabledChangeIndex) i).unwrap());
+          }
+        }
+      };
+    }
+  }
+
+  class Account implements IndexOperations {
+    @Inject private AccountIndexCollection indices;
+
+    @Override
+    public AutoCloseable disableReads() {
+      AccountIndex maybeDisabledSearchIndex = indices.getSearchIndex();
+      if (!(maybeDisabledSearchIndex instanceof DisabledAccountIndex)) {
+        indices.setSearchIndex(
+            new DisabledAccountIndex(maybeDisabledSearchIndex), /* closeOld */ false);
+      }
+
+      return () -> {
+        AccountIndex maybeEnabledSearchIndex = indices.getSearchIndex();
+        if (maybeEnabledSearchIndex instanceof DisabledAccountIndex) {
+          indices.setSearchIndex(
+              ((DisabledAccountIndex) maybeEnabledSearchIndex).unwrap(), /* closeOld */ false);
+        }
+      };
+    }
+
+    @Override
+    public AutoCloseable disableWrites() {
+      for (AccountIndex i : indices.getWriteIndexes()) {
+        if (!(i instanceof DisabledAccountIndex)) {
+          indices.addWriteIndex(new DisabledAccountIndex(i));
+        }
+      }
+      return () -> {
+        for (AccountIndex i : indices.getWriteIndexes()) {
+          if (i instanceof DisabledAccountIndex) {
+            indices.addWriteIndex(((DisabledAccountIndex) i).unwrap());
+          }
+        }
+      };
+    }
+  }
+
+  class Project implements IndexOperations {
+    @Inject private ProjectIndexCollection indices;
+
+    @Override
+    public AutoCloseable disableReads() {
+      ProjectIndex maybeDisabledSearchIndex = indices.getSearchIndex();
+      if (!(maybeDisabledSearchIndex instanceof DisabledProjectIndex)) {
+        indices.setSearchIndex(
+            new DisabledProjectIndex(maybeDisabledSearchIndex), /* closeOld */ false);
+      }
+
+      return () -> {
+        ProjectIndex maybeEnabledSearchIndex = indices.getSearchIndex();
+        if (maybeEnabledSearchIndex instanceof DisabledProjectIndex) {
+          indices.setSearchIndex(
+              ((DisabledProjectIndex) maybeEnabledSearchIndex).unwrap(), /* closeOld */ false);
+        }
+      };
+    }
+
+    @Override
+    public AutoCloseable disableWrites() {
+      for (ProjectIndex i : indices.getWriteIndexes()) {
+        if (!(i instanceof DisabledProjectIndex)) {
+          indices.addWriteIndex(new DisabledProjectIndex(i));
+        }
+      }
+      return () -> {
+        for (ProjectIndex i : indices.getWriteIndexes()) {
+          if (i instanceof DisabledProjectIndex) {
+            indices.addWriteIndex(((DisabledProjectIndex) i).unwrap());
+          }
+        }
+      };
+    }
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
index eda6c7e..9b393ef 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -38,7 +38,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -106,7 +106,7 @@
     try (Repository repository = repositoryManager.openRepository(project);
         ObjectInserter objectInserter = repository.newObjectInserter();
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
 
       IdentifiedUser author = getAuthor(commentCreation);
       CommentAdditionOp commentAdditionOp = new CommentAdditionOp(commentCreation);
@@ -165,8 +165,7 @@
       short side = commentCreation.side().orElse(CommentSide.PATCHSET_COMMIT).getNumericSide();
       Boolean unresolved = commentCreation.unresolved().orElse(null);
       String parentUuid = commentCreation.parentUuid().orElse(null);
-      Timestamp createdOn =
-          commentCreation.createdOn().map(Timestamp::from).orElse(context.getWhen());
+      Instant createdOn = commentCreation.createdOn().orElse(context.getWhen());
       HumanComment newComment =
           commentsUtil.newHumanComment(
               context.getNotes(),
@@ -202,7 +201,7 @@
     try (Repository repository = repositoryManager.openRepository(project);
         ObjectInserter objectInserter = repository.newObjectInserter();
         RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
 
       IdentifiedUser author = getAuthor(robotCommentCreation);
       RobotCommentAdditionOp robotCommentAdditionOp =
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index 5871e17..f01a138 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -34,6 +35,10 @@
 
   public abstract Optional<Account.Id> owner();
 
+  public abstract Optional<String> topic();
+
+  public abstract ImmutableMap<String, Short> approvals();
+
   public abstract String commitMessage();
 
   public abstract ImmutableList<TreeModification> treeModifications();
@@ -50,7 +55,8 @@
         .branch(Constants.R_HEADS + Constants.MASTER)
         .commitMessage("A test change")
         // Which value we choose here doesn't matter. All relevant code paths set the desired value.
-        .mergeStrategy(MergeStrategy.OURS);
+        .mergeStrategy(MergeStrategy.OURS)
+        .approvals(ImmutableMap.of());
   }
 
   @AutoValue.Builder
@@ -66,6 +72,15 @@
     /** The change owner. Must be an existing user account. */
     public abstract Builder owner(Account.Id owner);
 
+    /** The topic to add this change to. */
+    public abstract Builder topic(String topic);
+
+    /**
+     * The approvals to apply to this change. Map of label name to value. All approvals will be
+     * granted by the uploader.
+     */
+    public abstract Builder approvals(ImmutableMap<String, Short> approvals);
+
     /**
      * The commit message. The message may contain a {@code Change-Id} footer but does not need to.
      * If the footer is absent, it will be generated.
@@ -74,7 +89,18 @@
 
     /** Modified file of the change. The file content is specified via the returned builder. */
     public FileContentBuilder<Builder> file(String filePath) {
-      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+      return new FileContentBuilder<>(this, filePath, 0, treeModificationsBuilder()::add);
+    }
+
+    /**
+     * Modified file of the change. The file content is specified via the returned builder. The
+     * second parameter indicates the git file mode for the modified file if it has been changed.
+     *
+     * @see org.eclipse.jgit.lib.FileMode
+     */
+    public FileContentBuilder<Builder> file(String filePath, int newGitFileMode) {
+      return new FileContentBuilder<>(
+          this, filePath, newGitFileMode, treeModificationsBuilder()::add);
     }
 
     abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
index fe9d909..22a4da6 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -45,7 +45,13 @@
 
     /** Modified file of the patchset. The file content is specified via the returned builder. */
     public FileContentBuilder<Builder> file(String filePath) {
-      return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+      return new FileContentBuilder<>(this, filePath, 0, treeModificationsBuilder()::add);
+    }
+
+    /** Modified file of the patchset. The file content is specified via the returned builder. */
+    public FileContentBuilder<Builder> file(String filePath, int newGitFileMode) {
+      return new FileContentBuilder<>(
+          this, filePath, newGitFileMode, treeModificationsBuilder()::add);
     }
 
     abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
index c885353..0b21e2c 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroup.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 @AutoValue
@@ -40,7 +40,7 @@
 
   public abstract boolean visibleToAll();
 
-  public abstract Timestamp createdOn();
+  public abstract Instant createdOn();
 
   public abstract ImmutableSet<Account.Id> members();
 
@@ -67,7 +67,7 @@
 
     public abstract Builder visibleToAll(boolean visibleToAll);
 
-    public abstract Builder createdOn(Timestamp createdOn);
+    public abstract Builder createdOn(Instant createdOn);
 
     public abstract Builder members(ImmutableSet<Account.Id> members);
 
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
index f9e2fb5..d34b79a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -8,6 +8,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/acceptance:function",
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
@@ -17,7 +18,7 @@
         "//lib:jgit-junit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 16dca66..69139ce 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
@@ -43,7 +44,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -147,7 +148,7 @@
         setExclusiveGroupPermissions(projectConfig, projectUpdate.exclusiveGroupPermissions());
         projectConfig.commit(metaDataUpdate);
       }
-      projectCache.evict(nameKey);
+      projectCache.evictAndReindex(nameKey);
     }
 
     private void removePermissions(
@@ -196,8 +197,13 @@
         PermissionRule.Builder rule = newRule(projectConfig, p.group());
         rule.setAction(p.action());
         rule.setRange(p.min(), p.max());
-        String permissionName =
-            p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+        String permissionName;
+        if (p.isAddPermission()) {
+          permissionName =
+              p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+        } else {
+          permissionName = Permission.forRemoveLabel(p.name());
+        }
         projectConfig.upsertAccessSection(
             p.ref(), as -> as.upsertPermission(permissionName).add(rule));
       }
@@ -213,6 +219,7 @@
                   as -> as.upsertPermission(key.name()).setExclusiveGroup(exclusive)));
     }
 
+    @Nullable
     private RevCommit headOrNull(String branch) {
       branch = RefNames.fullName(branch);
 
@@ -292,7 +299,7 @@
 
         setConfig(projectConfig);
         try {
-          projectCache.evict(nameKey);
+          projectCache.evictAndReindex(nameKey);
         } catch (Exception e) {
           // Evicting the project from the cache, also triggers a reindex of the project.
           // The reindex step fails if the project config is invalid. That's fine, since it was our
@@ -310,7 +317,7 @@
         testProjectInvalidation.projectConfigUpdater().forEach(c -> c.accept(projectConfig));
         setConfig(projectConfig);
         try {
-          projectCache.evict(nameKey);
+          projectCache.evictAndReindex(nameKey);
         } catch (Exception e) {
           // Evicting the project from the cache, also triggers a reindex of the project.
           // The reindex step fails if the project config is invalid. That's fine, since it was our
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index 9a9a21a..5634c78 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -162,12 +162,34 @@
 
   /** Starts a builder for allowing a label permission. */
   public static TestLabelPermission.Builder allowLabel(String name) {
-    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(true)
+        .action(PermissionRule.Action.ALLOW);
   }
 
   /** Starts a builder for denying a label permission. */
   public static TestLabelPermission.Builder blockLabel(String name) {
-    return TestLabelPermission.builder().name(name).action(PermissionRule.Action.BLOCK);
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(true)
+        .action(PermissionRule.Action.BLOCK);
+  }
+
+  /** Starts a builder for allowing a remove-label permission. */
+  public static TestLabelPermission.Builder allowLabelRemoval(String name) {
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(false)
+        .action(PermissionRule.Action.ALLOW);
+  }
+
+  /** Starts a builder for denying a remove-label permission. */
+  public static TestLabelPermission.Builder blockLabelRemoval(String name) {
+    return TestLabelPermission.builder()
+        .name(name)
+        .isAddPermission(false)
+        .action(PermissionRule.Action.BLOCK);
   }
 
   /** Records a label permission to be updated. */
@@ -191,6 +213,8 @@
 
     abstract boolean impersonation();
 
+    abstract boolean isAddPermission();
+
     /** Builder for {@link TestLabelPermission}. */
     @AutoValue.Builder
     public abstract static class Builder {
@@ -208,6 +232,8 @@
 
       abstract Builder max(int max);
 
+      abstract Builder isAddPermission(boolean isAddPermission);
+
       /** Sets the minimum and maximum values for the permission. */
       public Builder range(int min, int max) {
         checkArgument(min != 0 || max != 0, "empty range");
@@ -243,6 +269,12 @@
     return TestPermissionKey.builder().name(Permission.forLabel(name));
   }
 
+  /** Starts a builder for describing a label removal permission key for deletion. */
+  public static TestPermissionKey.Builder labelRemovalPermissionKey(String name) {
+    checkLabelName(name);
+    return TestPermissionKey.builder().name(Permission.forRemoveLabel(name));
+  }
+
   /** Starts a builder for describing a capability key for deletion. */
   public static TestPermissionKey.Builder capabilityKey(String name) {
     return TestPermissionKey.builder().name(name).section(GLOBAL_CAPABILITIES);
diff --git a/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java b/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
index d5dd28a..3442b6e 100644
--- a/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
+++ b/java/com/google/gerrit/acceptance/testsuite/request/SshSessionFactory.java
@@ -14,10 +14,7 @@
 
 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;
@@ -28,25 +25,16 @@
 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);
+    return new SshSessionMina(testSshKeys, sshAddress, testAccount);
   }
 
-  public static void initSsh(KeyPair keyPair) {
-    if (getFromEnvironment().isMina()) {
-      SshSessionMina.initClient();
-    } else {
-      SshSessionJsch.initClient(keyPair);
-    }
+  public static void initSsh() {
+    SshSessionMina.initClient();
   }
 
   private SshSessionFactory() {}
 
   public static KeyPair genSshKey() throws GeneralSecurityException {
-    return (getFromEnvironment().isMina()
-            ? SshSessionMina.initKeyPairGenerator()
-            : SshSessionJsch.initKeyPairGenerator())
-        .generateKeyPair();
+    return SshSessionMina.initKeyPairGenerator().generateKeyPair();
   }
 }
diff --git a/java/com/google/gerrit/asciidoctor/DocIndexer.java b/java/com/google/gerrit/asciidoctor/DocIndexer.java
index 513bdd7..acd6aad 100644
--- a/java/com/google/gerrit/asciidoctor/DocIndexer.java
+++ b/java/com/google/gerrit/asciidoctor/DocIndexer.java
@@ -42,8 +42,8 @@
 import org.apache.lucene.index.IndexWriter;
 import org.apache.lucene.index.IndexWriterConfig;
 import org.apache.lucene.index.IndexWriterConfig.OpenMode;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.IndexInput;
-import org.apache.lucene.store.RAMDirectory;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
@@ -92,9 +92,9 @@
     }
   }
 
-  private RAMDirectory index()
+  private ByteBuffersDirectory index()
       throws IOException, UnsupportedEncodingException, FileNotFoundException {
-    RAMDirectory directory = new RAMDirectory();
+    ByteBuffersDirectory directory = new ByteBuffersDirectory();
     IndexWriterConfig config = new IndexWriterConfig(new StandardAnalyzer(CharArraySet.EMPTY_SET));
     config.setOpenMode(OpenMode.CREATE);
     config.setCommitOnClose(true);
@@ -107,11 +107,19 @@
 
         String title;
         try (BufferedReader titleReader = Files.newBufferedReader(file.toPath(), UTF_8)) {
-          title = titleReader.readLine();
-          if (title != null && title.startsWith("[[")) {
+          while ((title = titleReader.readLine()) != null) {
             // Generally the first line of the txt is the title. In a few cases the
-            // first line is a "[[tag]]" and the second line is the title.
-            title = titleReader.readLine();
+            // first lines are "[[tag]]" and or ":attribute:" and the next line
+            // after those  is the title.
+            if (title.startsWith("[[")) {
+              continue;
+            }
+            // Skip attributes such as :linkattrs:
+            if (title.startsWith(":") && title.endsWith(":")) {
+              continue;
+            }
+            // We found the title
+            break;
           }
         }
         Matcher matcher = SECTION_HEADER.matcher(title);
@@ -132,7 +140,7 @@
     return directory;
   }
 
-  private byte[] zip(RAMDirectory dir) throws IOException {
+  private byte[] zip(ByteBuffersDirectory dir) throws IOException {
     ByteArrayOutputStream buf = new ByteArrayOutputStream();
     try (ZipOutputStream zip = new ZipOutputStream(buf)) {
       for (String name : dir.listAll()) {
diff --git a/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
index 8fb4d35..c40baba 100644
--- a/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
@@ -39,6 +39,7 @@
     return uuid.get().startsWith(LDAP_UUID);
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     if (!handles(uuid)) {
diff --git a/java/com/google/gerrit/auth/ldap/Helper.java b/java/com/google/gerrit/auth/ldap/Helper.java
index a939c72..c11d045 100644
--- a/java/com/google/gerrit/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/auth/ldap/Helper.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.metrics.Description;
@@ -224,6 +225,7 @@
     return ctx;
   }
 
+  @Nullable
   private DirContext kerberosOpen(Properties env)
       throws IOException, LoginException, NamingException {
     LoginContext ctx = new LoginContext("KerberosLogin");
diff --git a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
index 2947efd..bb6480a 100644
--- a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
@@ -117,6 +117,7 @@
     return isLdapUUID(uuid);
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     if (!handles(uuid)) {
@@ -125,7 +126,7 @@
 
     String groupDn = uuid.get().substring(LDAP_UUID.length());
     CurrentUser user = userProvider.get();
-    if (!(user.isIdentifiedUser()) || !membershipsOf(user.asIdentifiedUser()).contains(uuid)) {
+    if (!user.isIdentifiedUser() || !membershipsOf(user.asIdentifiedUser()).contains(uuid)) {
       try {
         if (!existsCache.get(groupDn)) {
           return null;
diff --git a/java/com/google/gerrit/auth/ldap/LdapQuery.java b/java/com/google/gerrit/auth/ldap/LdapQuery.java
index 409c9f5..71dc141 100644
--- a/java/com/google/gerrit/auth/ldap/LdapQuery.java
+++ b/java/com/google/gerrit/auth/ldap/LdapQuery.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.auth.ldap;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.metrics.Timer0;
 import java.util.ArrayList;
@@ -114,6 +115,7 @@
       return get("dn");
     }
 
+    @Nullable
     String get(String attName) throws NamingException {
       final Attribute att = getAll(attName);
       return att != null && 0 < att.size() ? String.valueOf(att.get(0)) : null;
diff --git a/java/com/google/gerrit/auth/ldap/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
index 9a9f309..7dc2b1b 100644
--- a/java/com/google/gerrit/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -20,6 +20,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.ParameterizedString;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -162,6 +163,7 @@
     return vlist;
   }
 
+  @Nullable
   static String optdef(Config c, String n, String d) {
     final String[] v = c.getStringList("ldap", null, n);
     if (v == null || v.length == 0) {
@@ -184,6 +186,7 @@
     return v;
   }
 
+  @Nullable
   static ParameterizedString paramString(Config c, String n, String d) {
     String expression = optdef(c, n, d);
     if (expression == null) {
@@ -199,7 +202,7 @@
       String configOption, String suppliedValue, boolean disabledByBackend) {
     if (disabledByBackend && !Strings.isNullOrEmpty(suppliedValue)) {
       String msg = String.format("LDAP backend doesn't support: ldap.%s", configOption);
-      logger.atSevere().log(msg);
+      logger.atSevere().log("%s", msg);
       throw new IllegalArgumentException(msg);
     }
   }
@@ -209,6 +212,7 @@
     return !readOnlyAccountFields.contains(field);
   }
 
+  @Nullable
   static String apply(ParameterizedString p, LdapQuery.Result m) throws NamingException {
     if (p == null) {
       return null;
@@ -306,6 +310,7 @@
     usernameCache.put(who.getLocalUser(), Optional.of(account.id()));
   }
 
+  @Nullable
   @Override
   public Account.Id lookup(String accountName) {
     if (Strings.isNullOrEmpty(accountName)) {
diff --git a/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
index b0c1f51..ab53cde 100644
--- a/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Converter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
@@ -109,6 +110,7 @@
     this.encrypter = encrypter;
   }
 
+  @Nullable
   public OAuthToken get(Account.Id id) {
     OAuthToken accessToken = cache.getIfPresent(id);
     if (accessToken == null) {
diff --git a/java/com/google/gerrit/common/IoUtil.java b/java/com/google/gerrit/common/IoUtil.java
index 37f6c2c..09a8993 100644
--- a/java/com/google/gerrit/common/IoUtil.java
+++ b/java/com/google/gerrit/common/IoUtil.java
@@ -32,16 +32,27 @@
 public final class IoUtil {
   public static void copyWithThread(InputStream src, OutputStream dst) {
     new Thread("IoUtil-Copy") {
+      // We cannot propagate the exception since this code is running in a background thread.
+      // Printing the stacktrace is the best we can do. Hence ignoring the exception after printing
+      // the stacktrace is OK and it's fine to suppress the warning for the CatchAndPrintStackTrace
+      // bug pattern here.
+      @SuppressWarnings("CatchAndPrintStackTrace")
       @Override
       public void run() {
         try {
+          copyIo();
+        } catch (IOException e) {
+          e.printStackTrace();
+        }
+      }
+
+      private void copyIo() throws IOException {
+        try {
           final byte[] buf = new byte[256];
           int n;
           while (0 < (n = src.read(buf))) {
             dst.write(buf, 0, n);
           }
-        } catch (IOException e) {
-          e.printStackTrace();
         } finally {
           try {
             src.close();
diff --git a/java/com/google/gerrit/common/RawInputUtil.java b/java/com/google/gerrit/common/RawInputUtil.java
index 4a676e6..23e4a23 100644
--- a/java/com/google/gerrit/common/RawInputUtil.java
+++ b/java/com/google/gerrit/common/RawInputUtil.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.common;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
@@ -31,7 +30,6 @@
 
   public static RawInput create(byte[] bytes, String contentType) {
     requireNonNull(bytes);
-    checkArgument(bytes.length > 0);
     return new RawInput() {
       @Override
       public InputStream getInputStream() throws IOException {
diff --git a/java/com/google/gerrit/common/RuntimeVersion.java b/java/com/google/gerrit/common/RuntimeVersion.java
new file mode 100644
index 0000000..9ab1b96
--- /dev/null
+++ b/java/com/google/gerrit/common/RuntimeVersion.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.common;
+
+/** JDK version string utilities. */
+public final class RuntimeVersion {
+
+  private static final int FEATURE = Runtime.version().feature();
+
+  /** Returns true if the current runtime is JDK 17 or newer. */
+  public static boolean isAtLeast17() {
+    return FEATURE >= 17;
+  }
+
+  /** Returns true if the current runtime is JDK 18 or newer. */
+  public static boolean isAtLeast18() {
+    return FEATURE >= 18;
+  }
+
+  private RuntimeVersion() {}
+}
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 73b1d40..1b87f32 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common;
 
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
 import static java.lang.annotation.ElementType.FIELD;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.ElementType.TYPE;
@@ -25,24 +26,26 @@
 import java.lang.annotation.Target;
 
 /**
- * A marker to say a method/type/field is added or is increased to public solely because it is
- * called from inside a project or an organisation using Gerrit.
+ * A marker to say a method/type/field/constructor is added or is increased to public solely because
+ * it is called from inside a project or an organisation using Gerrit.
  */
-@Target({METHOD, TYPE, FIELD})
+@Target({METHOD, TYPE, FIELD, CONSTRUCTOR})
 @Retention(RUNTIME)
 @Repeatable(UsedAt.Uses.class)
 public @interface UsedAt {
   /** Enumeration of projects that call a method/type/field. */
   enum Project {
-    GOOGLE,
     COLLABNET,
+    GOOGLE,
+    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
     PLUGIN_CHECKS,
     PLUGIN_CODE_OWNERS,
     PLUGIN_DELETE_PROJECT,
-    PLUGIN_SERVICEUSER,
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
-    PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
+    PLUGIN_SERVICEUSER,
+    PLUGIN_WEBSESSION_FLATFILE,
+    MODULE_GIT_REFS_FILTER
   }
 
   /** Reference to the project that uses the method annotated with this annotation. */
diff --git a/java/com/google/gerrit/common/Version.java b/java/com/google/gerrit/common/Version.java
index 6197be5..bfca4d0 100644
--- a/java/com/google/gerrit/common/Version.java
+++ b/java/com/google/gerrit/common/Version.java
@@ -54,7 +54,7 @@
         return vs;
       }
     } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
+      logger.atSevere().withCause(e).log("%s", e.getMessage());
       return "(unknown version)";
     }
   }
diff --git a/java/com/google/gerrit/common/data/FilenameComparator.java b/java/com/google/gerrit/common/data/FilenameComparator.java
index ebf423c..0b188df 100644
--- a/java/com/google/gerrit/common/data/FilenameComparator.java
+++ b/java/com/google/gerrit/common/data/FilenameComparator.java
@@ -24,7 +24,7 @@
   public static final FilenameComparator INSTANCE = new FilenameComparator();
 
   private static final Set<String> cppHeaderSuffixes =
-      new HashSet<>(Arrays.asList(".h", ".hxx", ".hpp"));
+      new HashSet<>(Arrays.asList(".h", ".hh", ".hxx", ".hpp"));
 
   private FilenameComparator() {}
 
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 253266d..0a42d09 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRange;
 import java.util.ArrayList;
@@ -190,6 +191,7 @@
   }
 
   /** Returns the valid range for the capability if it has one, otherwise null. */
+  @Nullable
   public static PermissionRange.WithDefaults getRange(String varName) {
     if (QUERY_LIMIT.equalsIgnoreCase(varName)) {
       return new PermissionRange.WithDefaults(
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
deleted file mode 100644
index 562464d..0000000
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ /dev/null
@@ -1,408 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Streams;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
-import com.google.common.io.CharStreams;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.builders.QueryBuilder;
-import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
-import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
-import com.google.gerrit.entities.converter.ProtoConverter;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.FieldBundle;
-import com.google.gerrit.index.query.ListResultSet;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.index.query.ResultSet;
-import com.google.gerrit.proto.Protos;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import com.google.protobuf.MessageLite;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.io.UnsupportedEncodingException;
-import java.net.URLEncoder;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Function;
-import org.apache.http.HttpEntity;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.apache.http.client.methods.HttpPost;
-import org.apache.http.entity.ContentType;
-import org.apache.http.nio.entity.NStringEntity;
-import org.elasticsearch.client.Request;
-import org.elasticsearch.client.Response;
-
-abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  protected static final String BULK = "_bulk";
-  protected static final String MAPPINGS = "mappings";
-  protected static final String ORDER = "order";
-  protected static final String DESC_SORT_ORDER = "desc";
-  protected static final String ASC_SORT_ORDER = "asc";
-  protected static final String UNMAPPED_TYPE = "unmapped_type";
-  protected static final String SEARCH = "_search";
-  protected static final String SETTINGS = "settings";
-
-  static byte[] decodeBase64(String base64String) {
-    return BaseEncoding.base64().decode(base64String);
-  }
-
-  protected static <T> List<T> decodeProtos(
-      JsonObject doc, String fieldName, ProtoConverter<?, T> converter) {
-    JsonArray field = doc.getAsJsonArray(fieldName);
-    if (field == null) {
-      return null;
-    }
-    return Streams.stream(field)
-        .map(JsonElement::getAsString)
-        .map(AbstractElasticIndex::decodeBase64)
-        .map(bytes -> parseProtoFrom(bytes, converter))
-        .collect(toImmutableList());
-  }
-
-  protected static <P extends MessageLite, T> T parseProtoFrom(
-      byte[] bytes, ProtoConverter<P, T> converter) {
-    P message = Protos.parseUnchecked(converter.getParser(), bytes);
-    return converter.fromProto(message);
-  }
-
-  static String getContent(Response response) throws IOException {
-    HttpEntity responseEntity = response.getEntity();
-    String content = "";
-    if (responseEntity != null) {
-      InputStream contentStream = responseEntity.getContent();
-      try (Reader reader = new InputStreamReader(contentStream, UTF_8)) {
-        content = CharStreams.toString(reader);
-      }
-    }
-    return content;
-  }
-
-  private final ElasticConfiguration config;
-  private final Schema<V> schema;
-  private final SitePaths sitePaths;
-  private final String indexNameRaw;
-
-  protected final ElasticRestClientProvider client;
-  protected final String indexName;
-  protected final Gson gson;
-  protected final ElasticQueryBuilder queryBuilder;
-
-  AbstractElasticIndex(
-      ElasticConfiguration config,
-      SitePaths sitePaths,
-      Schema<V> schema,
-      ElasticRestClientProvider client,
-      String indexName) {
-    this.config = config;
-    this.sitePaths = sitePaths;
-    this.schema = schema;
-    this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
-    this.queryBuilder = new ElasticQueryBuilder();
-    this.indexName = config.getIndexName(indexName, schema.getVersion());
-    this.indexNameRaw = indexName;
-    this.client = client;
-  }
-
-  @Override
-  public Schema<V> getSchema() {
-    return schema;
-  }
-
-  @Override
-  public void close() {
-    // Do nothing. Client is closed by the provider.
-  }
-
-  @Override
-  public void markReady(boolean ready) {
-    IndexUtils.setReady(sitePaths, indexNameRaw, schema.getVersion(), ready);
-  }
-
-  @Override
-  public void delete(K id) {
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, getDeleteActions(id), getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format("Failed to delete %s from index %s: %s", id, indexName, statusCode));
-    }
-  }
-
-  @Override
-  public void deleteAll() {
-    // Delete the index, if it exists.
-    String endpoint = indexName + client.adapter().indicesExistParams();
-    Response response = performRequest("HEAD", endpoint);
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode == HttpStatus.SC_OK) {
-      response = performRequest("DELETE", indexName);
-      statusCode = response.getStatusLine().getStatusCode();
-      if (statusCode != HttpStatus.SC_OK) {
-        throw new StorageException(
-            String.format("Failed to delete index %s: %s", indexName, statusCode));
-      }
-    }
-
-    // Recreate the index.
-    String indexCreationFields = concatJsonString(getSettings(), getMappings());
-    response = performRequest("PUT", indexName, indexCreationFields);
-    statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      String error = String.format("Failed to create index %s: %s", indexName, statusCode);
-      throw new StorageException(error);
-    }
-  }
-
-  protected abstract String getDeleteActions(K id);
-
-  protected abstract String getMappings();
-
-  private String getSettings() {
-    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting(config)));
-  }
-
-  protected abstract String getId(V v);
-
-  protected String getMappingsForSingleType(MappingProperties properties) {
-    return getMappingsFor(properties);
-  }
-
-  protected String getMappingsFor(MappingProperties properties) {
-    JsonObject mappings = new JsonObject();
-
-    mappings.add(MAPPINGS, gson.toJsonTree(properties));
-    return gson.toJson(mappings);
-  }
-
-  protected String getDeleteRequest(K id) {
-    return new DeleteRequest(id.toString(), indexName).toString();
-  }
-
-  protected abstract V fromDocument(JsonObject doc, Set<String> fields);
-
-  protected FieldBundle toFieldBundle(JsonObject doc) {
-    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
-    ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
-    for (Map.Entry<String, JsonElement> element :
-        doc.get(client.adapter().rawFieldsKey()).getAsJsonObject().entrySet()) {
-      checkArgument(
-          allFields.containsKey(element.getKey()), "Unrecognized field " + element.getKey());
-      FieldType<?> type = allFields.get(element.getKey()).getType();
-      Iterable<JsonElement> innerItems =
-          element.getValue().isJsonArray()
-              ? element.getValue().getAsJsonArray()
-              : Collections.singleton(element.getValue());
-      for (JsonElement inner : innerItems) {
-        if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
-          rawFields.put(element.getKey(), inner.getAsString());
-        } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
-          rawFields.put(element.getKey(), inner.getAsInt());
-        } else if (type == FieldType.LONG) {
-          rawFields.put(element.getKey(), inner.getAsLong());
-        } else if (type == FieldType.TIMESTAMP) {
-          rawFields.put(element.getKey(), new Timestamp(inner.getAsLong()));
-        } else if (type == FieldType.STORED_ONLY) {
-          rawFields.put(element.getKey(), decodeBase64(inner.getAsString()));
-        } else {
-          throw FieldType.badFieldType(type);
-        }
-      }
-    }
-    return new FieldBundle(rawFields);
-  }
-
-  protected String toAction(String type, String id, String action) {
-    JsonObject properties = new JsonObject();
-    properties.addProperty("_id", id);
-    properties.addProperty("_index", indexName);
-    properties.addProperty("_type", type);
-
-    JsonObject jsonAction = new JsonObject();
-    jsonAction.add(action, properties);
-    return jsonAction.toString() + System.lineSeparator();
-  }
-
-  protected void addNamedElement(String name, JsonObject element, JsonArray array) {
-    JsonObject arrayElement = new JsonObject();
-    arrayElement.add(name, element);
-    array.add(arrayElement);
-  }
-
-  protected Map<String, String> getRefreshParam() {
-    Map<String, String> params = new HashMap<>();
-    params.put("refresh", "true");
-    return params;
-  }
-
-  protected String getSearch(SearchSourceBuilder searchSource, JsonArray sortArray) {
-    JsonObject search = new JsonParser().parse(searchSource.toString()).getAsJsonObject();
-    search.add("sort", sortArray);
-    return gson.toJson(search);
-  }
-
-  protected JsonArray getSortArray(String idFieldName) {
-    JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, ASC_SORT_ORDER);
-
-    JsonArray sortArray = new JsonArray();
-    addNamedElement(idFieldName, properties, sortArray);
-    return sortArray;
-  }
-
-  protected String getURI(String request) {
-    try {
-      return URLEncoder.encode(indexName, UTF_8.toString()) + "/" + request;
-    } catch (UnsupportedEncodingException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  protected Response postRequest(String uri, Object payload) {
-    return performRequest("POST", uri, payload);
-  }
-
-  protected Response postRequest(String uri, Object payload, Map<String, String> params) {
-    return performRequest("POST", uri, payload, params);
-  }
-
-  private String concatJsonString(String target, String addition) {
-    return target.substring(0, target.length() - 1) + "," + addition.substring(1);
-  }
-
-  private Response performRequest(String method, String uri) {
-    return performRequest(method, uri, null);
-  }
-
-  private Response performRequest(String method, String uri, @Nullable Object payload) {
-    return performRequest(method, uri, payload, Collections.emptyMap());
-  }
-
-  private Response performRequest(
-      String method, String uri, @Nullable Object payload, Map<String, String> params) {
-    Request request = new Request(method, uri.startsWith("/") ? uri : "/" + uri);
-    if (payload != null) {
-      String payloadStr = payload instanceof String ? (String) payload : payload.toString();
-      request.setEntity(new NStringEntity(payloadStr, ContentType.APPLICATION_JSON));
-    }
-    for (Map.Entry<String, String> entry : params.entrySet()) {
-      request.addParameter(entry.getKey(), entry.getValue());
-    }
-    try {
-      return client.get().performRequest(request);
-    } catch (IOException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  protected class ElasticQuerySource implements DataSource<V> {
-    private final QueryOptions opts;
-    private final String search;
-
-    ElasticQuerySource(Predicate<V> p, QueryOptions opts, JsonArray sortArray)
-        throws QueryParseException {
-      this.opts = opts;
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder(client.adapter())
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(opts.fields()));
-      search = getSearch(searchSource, sortArray);
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<V> read() {
-      return readImpl(doc -> AbstractElasticIndex.this.fromDocument(doc, opts.fields()));
-    }
-
-    @Override
-    public ResultSet<FieldBundle> readRaw() {
-      return readImpl(AbstractElasticIndex.this::toFieldBundle);
-    }
-
-    private <T> ResultSet<T> readImpl(Function<JsonObject, T> mapper) {
-      try {
-        String uri = getURI(SEARCH);
-        Response response =
-            performRequest(HttpPost.METHOD_NAME, uri, search, Collections.emptyMap());
-        StatusLine statusLine = response.getStatusLine();
-        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
-          String content = getContent(response);
-          JsonObject obj =
-              new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
-          if (obj.get("hits") != null) {
-            JsonArray json = obj.getAsJsonArray("hits");
-            ImmutableList.Builder<T> results = ImmutableList.builderWithExpectedSize(json.size());
-            for (int i = 0; i < json.size(); i++) {
-              T mapperResult = mapper.apply(json.get(i).getAsJsonObject());
-              if (mapperResult != null) {
-                results.add(mapperResult);
-              }
-            }
-            return new ListResultSet<>(results.build());
-          }
-        } else {
-          logger.atSevere().log(statusLine.getReasonPhrase());
-        }
-        return new ListResultSet<>(ImmutableList.of());
-      } catch (IOException e) {
-        throw new StorageException(e);
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
deleted file mode 100644
index 8bab80b..0000000
--- a/java/com/google/gerrit/elasticsearch/BUILD
+++ /dev/null
@@ -1,33 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-java_library(
-    name = "elasticsearch",
-    srcs = glob(["**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/common:annotations",
-        "//java/com/google/gerrit/entities",
-        "//java/com/google/gerrit/exceptions",
-        "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/index",
-        "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/lifecycle",
-        "//java/com/google/gerrit/proto",
-        "//java/com/google/gerrit/server",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:jgit",
-        "//lib:protobuf",
-        "//lib/commons:lang",
-        "//lib/elasticsearch-rest-client",
-        "//lib/flogger:api",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/httpcomponents:httpasyncclient",
-        "//lib/httpcomponents:httpclient",
-        "//lib/httpcomponents:httpcore",
-        "//lib/httpcomponents:httpcore-nio",
-        "//lib/jackson:jackson-core",
-    ],
-)
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
deleted file mode 100644
index 8967789..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ /dev/null
@@ -1,143 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.account.AccountField;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.elasticsearch.client.Response;
-
-public class ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
-    implements AccountIndex {
-  static class AccountMapping {
-    final MappingProperties accounts;
-
-    AccountMapping(Schema<AccountState> schema, ElasticQueryAdapter adapter) {
-      this.accounts = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  private static final String ACCOUNTS = "accounts";
-
-  private final AccountMapping mapping;
-  private final Provider<AccountCache> accountCache;
-  private final Schema<AccountState> schema;
-
-  @Inject
-  ElasticAccountIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<AccountCache> accountCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<AccountState> schema) {
-    super(cfg, sitePaths, schema, client, ACCOUNTS);
-    this.accountCache = accountCache;
-    this.mapping = new AccountMapping(schema, client.adapter());
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(AccountState as) {
-    BulkRequest bulk =
-        new IndexRequest(getId(as), indexName)
-            .add(new UpdateRequest<>(schema, as, ImmutableSet.of()));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace account %s in index %s: %s",
-              as.account().id(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
-      throws QueryParseException {
-    JsonArray sortArray =
-        getSortArray(
-            schema.useLegacyNumericFields()
-                ? AccountField.ID.getName()
-                : AccountField.ID_STR.getName());
-    return new ElasticQuerySource(
-        p,
-        opts.filterFields(o -> IndexUtils.accountFields(o, schema.useLegacyNumericFields())),
-        sortArray);
-  }
-
-  @Override
-  protected String getDeleteActions(Account.Id a) {
-    return getDeleteRequest(a);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(mapping.accounts);
-  }
-
-  @Override
-  protected String getId(AccountState as) {
-    return as.account().id().toString();
-  }
-
-  @Override
-  protected AccountState fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement source = json.get("_source");
-    if (source == null) {
-      source = json.getAsJsonObject().get("fields");
-    }
-
-    Account.Id id =
-        Account.id(
-            source
-                .getAsJsonObject()
-                .get(
-                    schema.useLegacyNumericFields()
-                        ? AccountField.ID.getName()
-                        : AccountField.ID_STR.getName())
-                .getAsInt());
-    // Use the AccountCache rather than depending on any stored fields in the document (of which
-    // there shouldn't be any). The most expensive part to compute anyway is the effective group
-    // IDs, and we don't have a good way to reindex when those change.
-    // If the account doesn't exist return an empty AccountState to represent the missing account
-    // to account the fact that the account exists in the index.
-    return accountCache.get().getEvenIfMissing(id);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
deleted file mode 100644
index 7d4e0c7..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ /dev/null
@@ -1,181 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.converter.ChangeProtoConverter;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.change.MergeabilityComputationBehavior;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.client.Response;
-
-/** Secondary index implementation using Elasticsearch. */
-class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
-    implements ChangeIndex {
-  static class ChangeMapping {
-    final MappingProperties changes;
-    final MappingProperties openChanges;
-    final MappingProperties closedChanges;
-
-    ChangeMapping(Schema<ChangeData> schema, ElasticQueryAdapter adapter) {
-      MappingProperties mapping = ElasticMapping.createMapping(schema, adapter);
-      this.changes = mapping;
-      this.openChanges = mapping;
-      this.closedChanges = mapping;
-    }
-  }
-
-  private static final String CHANGES = "changes";
-
-  private final ChangeMapping mapping;
-  private final ChangeData.Factory changeDataFactory;
-  private final Schema<ChangeData> schema;
-  private final FieldDef<ChangeData, ?> idField;
-  private final ImmutableSet<String> skipFields;
-
-  @Inject
-  ElasticChangeIndex(
-      ElasticConfiguration cfg,
-      ChangeData.Factory changeDataFactory,
-      SitePaths sitePaths,
-      ElasticRestClientProvider clientBuilder,
-      @GerritServerConfig Config gerritConfig,
-      @Assisted Schema<ChangeData> schema) {
-    super(cfg, sitePaths, schema, clientBuilder, CHANGES);
-    this.changeDataFactory = changeDataFactory;
-    this.schema = schema;
-    this.mapping = new ChangeMapping(schema, client.adapter());
-    this.idField =
-        this.schema.useLegacyNumericFields() ? ChangeField.LEGACY_ID : ChangeField.LEGACY_ID_STR;
-    this.skipFields =
-        MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex()
-            ? ImmutableSet.of()
-            : ImmutableSet.of(ChangeField.MERGEABLE.getName());
-  }
-
-  @Override
-  public void replace(ChangeData cd) {
-    BulkRequest bulk =
-        new IndexRequest(getId(cd), indexName).add(new UpdateRequest<>(schema, cd, skipFields));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace change %s in index %s: %s", cd.getId(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    QueryOptions filteredOpts =
-        opts.filterFields(o -> IndexUtils.changeFields(o, schema.useLegacyNumericFields()));
-    return new ElasticQuerySource(p, filteredOpts, getSortArray());
-  }
-
-  private JsonArray getSortArray() {
-    JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, DESC_SORT_ORDER);
-
-    JsonArray sortArray = new JsonArray();
-    addNamedElement(ChangeField.UPDATED.getName(), properties, sortArray);
-    addNamedElement(ChangeField.MERGED_ON.getName(), getMergedOnSortOptions(), sortArray);
-    addNamedElement(idField.getName(), properties, sortArray);
-    return sortArray;
-  }
-
-  private JsonObject getMergedOnSortOptions() {
-    JsonObject sortOptions = new JsonObject();
-    sortOptions.addProperty(ORDER, DESC_SORT_ORDER);
-    // Ignore the sort field if it does not exist in index. Otherwise the search would fail on open
-    // changes, because the corresponding documents do not have mergedOn field.
-    sortOptions.addProperty(UNMAPPED_TYPE, ElasticMapping.TIMESTAMP_FIELD_TYPE);
-    return sortOptions;
-  }
-
-  @Override
-  protected String getDeleteActions(Change.Id c) {
-    return getDeleteRequest(c);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsFor(mapping.changes);
-  }
-
-  @Override
-  protected String getId(ChangeData cd) {
-    return cd.getId().toString();
-  }
-
-  @Override
-  protected ChangeData fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement sourceElement = json.get("_source");
-    if (sourceElement == null) {
-      sourceElement = json.getAsJsonObject().get("fields");
-    }
-    JsonObject source = sourceElement.getAsJsonObject();
-    JsonElement c = source.get(ChangeField.CHANGE.getName());
-
-    if (c == null) {
-      int id = source.get(idField.getName()).getAsInt();
-      // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
-      String projectName = requireNonNull(source.get(ChangeField.PROJECT.getName()).getAsString());
-      return changeDataFactory.create(Project.nameKey(projectName), Change.id(id));
-    }
-
-    ChangeData cd =
-        changeDataFactory.create(
-            parseProtoFrom(decodeBase64(c.getAsString()), ChangeProtoConverter.INSTANCE));
-
-    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
-      if (fields.contains(field.getName()) && source.get(field.getName()) != null) {
-        field.setIfPossible(cd, new ElasticStoredValue(source.get(field.getName())));
-      }
-    }
-
-    return cd;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java b/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
deleted file mode 100644
index c4435297..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ /dev/null
@@ -1,137 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-
-import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import org.apache.http.HttpHost;
-import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.client.RestClientBuilder;
-
-@Singleton
-class ElasticConfiguration {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  static final String SECTION_ELASTICSEARCH = "elasticsearch";
-  static final String KEY_PASSWORD = "password";
-  static final String KEY_USERNAME = "username";
-  static final String KEY_PREFIX = "prefix";
-  static final String KEY_SERVER = "server";
-  static final String KEY_NUMBER_OF_SHARDS = "numberOfShards";
-  static final String KEY_NUMBER_OF_REPLICAS = "numberOfReplicas";
-  static final String KEY_MAX_RESULT_WINDOW = "maxResultWindow";
-  static final String KEY_CONNECT_TIMEOUT = "connectTimeout";
-  static final String KEY_SOCKET_TIMEOUT = "socketTimeout";
-
-  static final String DEFAULT_PORT = "9200";
-  static final String DEFAULT_USERNAME = "elastic";
-  static final int DEFAULT_NUMBER_OF_SHARDS = 1;
-  static final int DEFAULT_NUMBER_OF_REPLICAS = 1;
-  static final int DEFAULT_MAX_RESULT_WINDOW = 10000;
-  static final int DEFAULT_CONNECT_TIMEOUT = RestClientBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS;
-  static final int DEFAULT_SOCKET_TIMEOUT = RestClientBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS;
-
-  private final Config cfg;
-  private final List<HttpHost> hosts;
-
-  final String username;
-  final String password;
-  final int numberOfShards;
-  final int numberOfReplicas;
-  final int maxResultWindow;
-  final int connectTimeout;
-  final int socketTimeout;
-  final String prefix;
-
-  @Inject
-  ElasticConfiguration(@GerritServerConfig Config cfg) {
-    this.cfg = cfg;
-    this.password = cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD);
-    this.username =
-        password == null
-            ? null
-            : firstNonNull(
-                cfg.getString(SECTION_ELASTICSEARCH, null, KEY_USERNAME), DEFAULT_USERNAME);
-    this.prefix = Strings.nullToEmpty(cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PREFIX));
-    this.numberOfShards =
-        cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_NUMBER_OF_SHARDS, DEFAULT_NUMBER_OF_SHARDS);
-    this.numberOfReplicas =
-        cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_NUMBER_OF_REPLICAS, DEFAULT_NUMBER_OF_REPLICAS);
-    this.maxResultWindow =
-        cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_MAX_RESULT_WINDOW, DEFAULT_MAX_RESULT_WINDOW);
-    this.connectTimeout =
-        (int)
-            cfg.getTimeUnit(
-                SECTION_ELASTICSEARCH,
-                null,
-                KEY_CONNECT_TIMEOUT,
-                DEFAULT_CONNECT_TIMEOUT,
-                TimeUnit.MILLISECONDS);
-    this.socketTimeout =
-        (int)
-            cfg.getTimeUnit(
-                SECTION_ELASTICSEARCH,
-                null,
-                KEY_SOCKET_TIMEOUT,
-                DEFAULT_SOCKET_TIMEOUT,
-                TimeUnit.MILLISECONDS);
-    this.hosts = new ArrayList<>();
-    for (String server : cfg.getStringList(SECTION_ELASTICSEARCH, null, KEY_SERVER)) {
-      try {
-        URI uri = new URI(server);
-        int port = uri.getPort();
-        HttpHost httpHost =
-            new HttpHost(
-                uri.getHost(), port == -1 ? Integer.valueOf(DEFAULT_PORT) : port, uri.getScheme());
-        this.hosts.add(httpHost);
-      } catch (URISyntaxException | IllegalArgumentException e) {
-        logger.atSevere().log("Invalid server URI %s: %s", server, e.getMessage());
-      }
-    }
-
-    if (hosts.isEmpty()) {
-      throw new ProvisionException("No valid Elasticsearch servers configured");
-    }
-
-    logger.atInfo().log("Elasticsearch servers: %s", hosts);
-  }
-
-  Config getConfig() {
-    return cfg;
-  }
-
-  HttpHost[] getHosts() {
-    return hosts.toArray(new HttpHost[hosts.size()]);
-  }
-
-  String getIndexName(String name, int schemaVersion) {
-    return String.format("%s%s_%04d", prefix, name, schemaVersion);
-  }
-
-  int getNumberOfShards() {
-    return numberOfShards;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticException.java b/java/com/google/gerrit/elasticsearch/ElasticException.java
deleted file mode 100644
index d4baf75..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticException.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-class ElasticException extends RuntimeException {
-  private static final long serialVersionUID = 1L;
-
-  ElasticException(String message) {
-    super(message);
-  }
-
-  ElasticException(String message, Throwable cause) {
-    super(message, cause);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
deleted file mode 100644
index 781ed43..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.InternalGroup;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.elasticsearch.client.Response;
-
-public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, InternalGroup>
-    implements GroupIndex {
-  static class GroupMapping {
-    final MappingProperties groups;
-
-    GroupMapping(Schema<InternalGroup> schema, ElasticQueryAdapter adapter) {
-      this.groups = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  private static final String GROUPS = "groups";
-
-  private final GroupMapping mapping;
-  private final Provider<GroupCache> groupCache;
-  private final Schema<InternalGroup> schema;
-
-  @Inject
-  ElasticGroupIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<GroupCache> groupCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<InternalGroup> schema) {
-    super(cfg, sitePaths, schema, client, GROUPS);
-    this.groupCache = groupCache;
-    this.mapping = new GroupMapping(schema, client.adapter());
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(InternalGroup group) {
-    BulkRequest bulk =
-        new IndexRequest(getId(group), indexName)
-            .add(new UpdateRequest<>(schema, group, ImmutableSet.of()));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace group %s in index %s: %s",
-              group.getGroupUUID().get(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts)
-      throws QueryParseException {
-    JsonArray sortArray = getSortArray(GroupField.UUID.getName());
-    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::groupFields), sortArray);
-  }
-
-  @Override
-  protected String getDeleteActions(AccountGroup.UUID g) {
-    return getDeleteRequest(g);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(mapping.groups);
-  }
-
-  @Override
-  protected String getId(InternalGroup group) {
-    return group.getGroupUUID().get();
-  }
-
-  @Override
-  protected InternalGroup fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement source = json.get("_source");
-    if (source == null) {
-      source = json.getAsJsonObject().get("fields");
-    }
-
-    AccountGroup.UUID uuid =
-        AccountGroup.uuid(source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
-    // Use the GroupCache rather than depending on any stored fields in the
-    // document (of which there shouldn't be any).
-    return groupCache.get().get(uuid).orElse(null);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
deleted file mode 100644
index 15d6126..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.server.index.AbstractIndexModule;
-import com.google.gerrit.server.index.VersionManager;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.group.GroupIndex;
-import java.util.Map;
-
-public class ElasticIndexModule extends AbstractIndexModule {
-  public static ElasticIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads, boolean slave) {
-    return new ElasticIndexModule(versions, threads, slave);
-  }
-
-  public static ElasticIndexModule latestVersion(boolean slave) {
-    return new ElasticIndexModule(null, 0, slave);
-  }
-
-  private ElasticIndexModule(Map<String, Integer> singleVersions, int threads, boolean slave) {
-    super(singleVersions, threads, slave);
-  }
-
-  @Override
-  public void configure() {
-    super.configure();
-    install(ElasticRestClientProvider.module());
-  }
-
-  @Override
-  protected Class<? extends AccountIndex> getAccountIndex() {
-    return ElasticAccountIndex.class;
-  }
-
-  @Override
-  protected Class<? extends ChangeIndex> getChangeIndex() {
-    return ElasticChangeIndex.class;
-  }
-
-  @Override
-  protected Class<? extends GroupIndex> getGroupIndex() {
-    return ElasticGroupIndex.class;
-  }
-
-  @Override
-  protected Class<? extends ProjectIndex> getProjectIndex() {
-    return ElasticProjectIndex.class;
-  }
-
-  @Override
-  protected Class<? extends VersionManager> getVersionManager() {
-    return ElasticIndexVersionManager.class;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
deleted file mode 100644
index 100022a..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gson.JsonParser;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.elasticsearch.client.Request;
-import org.elasticsearch.client.Response;
-
-@Singleton
-class ElasticIndexVersionDiscovery {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final ElasticRestClientProvider client;
-
-  @Inject
-  ElasticIndexVersionDiscovery(ElasticRestClientProvider client) {
-    this.client = client;
-  }
-
-  List<String> discover(String prefix, String indexName) throws IOException {
-    String name = prefix + indexName + "_";
-    Request request = new Request("GET", client.adapter().getVersionDiscoveryUrl(name));
-    Response response = client.get().performRequest(request);
-
-    StatusLine statusLine = response.getStatusLine();
-    if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
-      String message =
-          String.format(
-              "Failed to discover index versions for %s: %d: %s",
-              name, statusLine.getStatusCode(), statusLine.getReasonPhrase());
-      logger.atSevere().log(message);
-      throw new IOException(message);
-    }
-
-    return new JsonParser()
-        .parse(AbstractElasticIndex.getContent(response)).getAsJsonObject().entrySet().stream()
-            .map(e -> e.getKey().replace(name, ""))
-            .collect(toList());
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
deleted file mode 100644
index b9d86d5..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.GerritIndexStatus;
-import com.google.gerrit.server.index.OnlineUpgradeListener;
-import com.google.gerrit.server.index.VersionManager;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.List;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class ElasticIndexVersionManager extends VersionManager {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final String prefix;
-  private final ElasticIndexVersionDiscovery versionDiscovery;
-
-  @Inject
-  ElasticIndexVersionManager(
-      @GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      PluginSetContext<OnlineUpgradeListener> listeners,
-      Collection<IndexDefinition<?, ?, ?>> defs,
-      ElasticIndexVersionDiscovery versionDiscovery) {
-    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
-    this.versionDiscovery = versionDiscovery;
-    prefix = Strings.nullToEmpty(cfg.getString("elasticsearch", null, "prefix"));
-  }
-
-  @Override
-  protected <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>> scanVersions(
-      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
-    TreeMap<Integer, Version<V>> versions = new TreeMap<>();
-    try {
-      List<String> discovered = versionDiscovery.discover(prefix, def.getName());
-      logger.atFine().log("Discovered versions for %s: %s", def.getName(), discovered);
-      for (String version : discovered) {
-        Integer v = Ints.tryParse(version);
-        if (v == null || version.length() != 4) {
-          logger.atWarning().log("Unrecognized version in index %s: %s", def.getName(), version);
-          continue;
-        }
-        versions.put(v, new Version<>(null, v, true, cfg.getReady(def.getName(), v)));
-      }
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Error scanning index: %s", def.getName());
-    }
-
-    for (Schema<V> schema : def.getSchemas().values()) {
-      int v = schema.getVersion();
-      boolean exists = versions.containsKey(v);
-      versions.put(v, new Version<>(schema, v, exists, cfg.getReady(def.getName(), v)));
-    }
-    return versions;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
deleted file mode 100644
index edd05c9..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
-import com.google.gerrit.index.Schema;
-import java.util.Map;
-
-class ElasticMapping {
-
-  protected static final String TIMESTAMP_FIELD_TYPE = "date";
-  protected static final String TIMESTAMP_FIELD_FORMAT = "dateOptionalTime";
-
-  static MappingProperties createMapping(Schema<?> schema, ElasticQueryAdapter adapter) {
-    ElasticMapping.Builder mapping = new ElasticMapping.Builder(adapter);
-    for (FieldDef<?, ?> field : schema.getFields().values()) {
-      String name = field.getName();
-      FieldType<?> fieldType = field.getType();
-      if (fieldType == FieldType.EXACT) {
-        mapping.addExactField(name);
-      } else if (fieldType == FieldType.TIMESTAMP) {
-        mapping.addTimestamp(name);
-      } else if (fieldType == FieldType.INTEGER
-          || fieldType == FieldType.INTEGER_RANGE
-          || fieldType == FieldType.LONG) {
-        mapping.addNumber(name);
-      } else if (fieldType == FieldType.FULL_TEXT) {
-        mapping.addStringWithAnalyzer(name);
-      } else if (fieldType == FieldType.PREFIX || fieldType == FieldType.STORED_ONLY) {
-        mapping.addString(name);
-      } else {
-        throw new IllegalStateException("Unsupported field type: " + fieldType.getName());
-      }
-    }
-    return mapping.build();
-  }
-
-  static class Builder {
-    private final ElasticQueryAdapter adapter;
-    private final ImmutableMap.Builder<String, FieldProperties> fields =
-        new ImmutableMap.Builder<>();
-
-    Builder(ElasticQueryAdapter adapter) {
-      this.adapter = adapter;
-    }
-
-    MappingProperties build() {
-      MappingProperties properties = new MappingProperties();
-      properties.properties = fields.build();
-      return properties;
-    }
-
-    Builder addExactField(String name) {
-      FieldProperties key = new FieldProperties(adapter.exactFieldType());
-      key.index = adapter.indexProperty();
-      FieldProperties properties;
-      properties = new FieldProperties(adapter.exactFieldType());
-      properties.fields = ImmutableMap.of("key", key);
-      fields.put(name, properties);
-      return this;
-    }
-
-    Builder addTimestamp(String name) {
-      FieldProperties properties = new FieldProperties(TIMESTAMP_FIELD_TYPE);
-      properties.type = TIMESTAMP_FIELD_TYPE;
-      properties.format = TIMESTAMP_FIELD_FORMAT;
-      fields.put(name, properties);
-      return this;
-    }
-
-    Builder addNumber(String name) {
-      fields.put(name, new FieldProperties("long"));
-      return this;
-    }
-
-    Builder addString(String name) {
-      fields.put(name, new FieldProperties(adapter.stringFieldType()));
-      return this;
-    }
-
-    Builder addStringWithAnalyzer(String name) {
-      FieldProperties key = new FieldProperties(adapter.stringFieldType());
-      key.analyzer = "custom_with_char_filter";
-      fields.put(name, key);
-      return this;
-    }
-
-    Builder add(String name, String type) {
-      fields.put(name, new FieldProperties(type));
-      return this;
-    }
-  }
-
-  static class MappingProperties {
-    Map<String, FieldProperties> properties;
-  }
-
-  static class FieldProperties {
-    String type;
-    String index;
-    String format;
-    String analyzer;
-    Map<String, FieldProperties> fields;
-
-    FieldProperties(String type) {
-      this.type = type;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
deleted file mode 100644
index b8bfc38..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.project.ProjectData;
-import com.google.gerrit.index.project.ProjectField;
-import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Optional;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.elasticsearch.client.Response;
-
-public class ElasticProjectIndex extends AbstractElasticIndex<Project.NameKey, ProjectData>
-    implements ProjectIndex {
-  static class ProjectMapping {
-    MappingProperties projects;
-
-    ProjectMapping(Schema<ProjectData> schema, ElasticQueryAdapter adapter) {
-      this.projects = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  static final String PROJECTS = "projects";
-
-  private final ProjectMapping mapping;
-  private final Provider<ProjectCache> projectCache;
-  private final Schema<ProjectData> schema;
-
-  @Inject
-  ElasticProjectIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<ProjectCache> projectCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<ProjectData> schema) {
-    super(cfg, sitePaths, schema, client, PROJECTS);
-    this.projectCache = projectCache;
-    this.schema = schema;
-    this.mapping = new ProjectMapping(schema, client.adapter());
-  }
-
-  @Override
-  public void replace(ProjectData projectState) {
-    BulkRequest bulk =
-        new IndexRequest(projectState.getProject().getName(), indexName)
-            .add(new UpdateRequest<>(schema, projectState, ImmutableSet.of()));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace project %s in index %s: %s",
-              projectState.getProject().getName(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts)
-      throws QueryParseException {
-    JsonArray sortArray = getSortArray(ProjectField.NAME.getName());
-    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::projectFields), sortArray);
-  }
-
-  @Override
-  protected String getDeleteActions(Project.NameKey nameKey) {
-    return getDeleteRequest(nameKey);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(mapping.projects);
-  }
-
-  @Override
-  protected String getId(ProjectData projectState) {
-    return projectState.getProject().getName();
-  }
-
-  @Override
-  protected ProjectData fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement source = json.get("_source");
-    if (source == null) {
-      source = json.getAsJsonObject().get("fields");
-    }
-
-    Project.NameKey nameKey =
-        Project.nameKey(source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
-    Optional<ProjectState> state = projectCache.get().get(nameKey);
-    if (!state.isPresent()) {
-      return null;
-    }
-    return state.get().toProjectData();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
deleted file mode 100644
index 19d9901..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-public class ElasticQueryAdapter {
-  private static final String INDICES = "?allow_no_indices=false";
-
-  private final String searchFilteringName;
-  private final String exactFieldType;
-  private final String stringFieldType;
-  private final String indexProperty;
-  private final String rawFieldsKey;
-  private final String versionDiscoveryUrl;
-
-  ElasticQueryAdapter() {
-    this.versionDiscoveryUrl = "/%s*";
-    this.searchFilteringName = "_source";
-    this.exactFieldType = "keyword";
-    this.stringFieldType = "text";
-    this.indexProperty = "true";
-    this.rawFieldsKey = "_source";
-  }
-
-  public String searchFilteringName() {
-    return searchFilteringName;
-  }
-
-  String indicesExistParams() {
-    return INDICES;
-  }
-
-  String exactFieldType() {
-    return exactFieldType;
-  }
-
-  String stringFieldType() {
-    return stringFieldType;
-  }
-
-  String indexProperty() {
-    return indexProperty;
-  }
-
-  String rawFieldsKey() {
-    return rawFieldsKey;
-  }
-
-  String getVersionDiscoveryUrl(String name) {
-    return String.format(versionDiscoveryUrl, name);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
deleted file mode 100644
index 40ac603..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ /dev/null
@@ -1,163 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.elasticsearch.builders.BoolQueryBuilder;
-import com.google.gerrit.elasticsearch.builders.QueryBuilder;
-import com.google.gerrit.elasticsearch.builders.QueryBuilders;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
-import com.google.gerrit.index.query.AndPredicate;
-import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.IntegerRangePredicate;
-import com.google.gerrit.index.query.NotPredicate;
-import com.google.gerrit.index.query.OrPredicate;
-import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.index.query.RegexPredicate;
-import com.google.gerrit.index.query.TimestampRangePredicate;
-import java.time.Instant;
-
-public class ElasticQueryBuilder {
-
-  <T> QueryBuilder toQueryBuilder(Predicate<T> p) throws QueryParseException {
-    if (p instanceof AndPredicate) {
-      return and(p);
-    } else if (p instanceof OrPredicate) {
-      return or(p);
-    } else if (p instanceof NotPredicate) {
-      return not(p);
-    } else if (p instanceof IndexPredicate) {
-      return fieldQuery((IndexPredicate<T>) p);
-    } else if (p instanceof PostFilterPredicate) {
-      return QueryBuilders.matchAllQuery();
-    } else {
-      throw new QueryParseException("cannot create query for index: " + p);
-    }
-  }
-
-  private <T> BoolQueryBuilder and(Predicate<T> p) throws QueryParseException {
-    BoolQueryBuilder b = QueryBuilders.boolQuery();
-    for (Predicate<T> c : p.getChildren()) {
-      b.must(toQueryBuilder(c));
-    }
-    return b;
-  }
-
-  private <T> BoolQueryBuilder or(Predicate<T> p) throws QueryParseException {
-    BoolQueryBuilder q = QueryBuilders.boolQuery();
-    for (Predicate<T> c : p.getChildren()) {
-      q.should(toQueryBuilder(c));
-    }
-    return q;
-  }
-
-  private <T> QueryBuilder not(Predicate<T> p) throws QueryParseException {
-    Predicate<T> n = p.getChild(0);
-    if (n instanceof TimestampRangePredicate) {
-      return notTimestamp((TimestampRangePredicate<T>) n);
-    }
-
-    // Lucene does not support negation, start with all and subtract.
-    BoolQueryBuilder q = QueryBuilders.boolQuery();
-    q.must(QueryBuilders.matchAllQuery());
-    q.mustNot(toQueryBuilder(n));
-    return q;
-  }
-
-  private <T> QueryBuilder fieldQuery(IndexPredicate<T> p) throws QueryParseException {
-    FieldType<?> type = p.getType();
-    FieldDef<?, ?> field = p.getField();
-    String name = field.getName();
-    String value = p.getValue();
-
-    if (type == FieldType.INTEGER) {
-      // QueryBuilder encodes integer fields as prefix coded bits,
-      // which elasticsearch's queryString can't handle.
-      // Create integer terms with string representations instead.
-      return QueryBuilders.termQuery(name, value);
-    } else if (type == FieldType.INTEGER_RANGE) {
-      return intRangeQuery(p);
-    } else if (type == FieldType.TIMESTAMP) {
-      return timestampQuery(p);
-    } else if (type == FieldType.EXACT) {
-      return exactQuery(p);
-    } else if (type == FieldType.PREFIX) {
-      return QueryBuilders.matchPhrasePrefixQuery(name, value);
-    } else if (type == FieldType.FULL_TEXT) {
-      return QueryBuilders.matchPhraseQuery(name, value);
-    } else {
-      throw FieldType.badFieldType(p.getType());
-    }
-  }
-
-  private <T> QueryBuilder intRangeQuery(IndexPredicate<T> p) throws QueryParseException {
-    if (p instanceof IntegerRangePredicate) {
-      IntegerRangePredicate<T> r = (IntegerRangePredicate<T>) p;
-      int minimum = r.getMinimumValue();
-      int maximum = r.getMaximumValue();
-      if (minimum == maximum) {
-        // Just fall back to a standard integer query.
-        return QueryBuilders.termQuery(p.getField().getName(), minimum);
-      }
-      return QueryBuilders.rangeQuery(p.getField().getName()).gte(minimum).lte(maximum);
-    }
-    throw new QueryParseException("not an integer range: " + p);
-  }
-
-  private <T> QueryBuilder notTimestamp(TimestampRangePredicate<T> r) throws QueryParseException {
-    if (r.getMinTimestamp().getTime() == 0) {
-      return QueryBuilders.rangeQuery(r.getField().getName())
-          .gt(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
-    }
-    throw new QueryParseException("cannot negate: " + r);
-  }
-
-  private <T> QueryBuilder timestampQuery(IndexPredicate<T> p) throws QueryParseException {
-    if (p instanceof TimestampRangePredicate) {
-      TimestampRangePredicate<T> r = (TimestampRangePredicate<T>) p;
-      if (r.getMaxTimestamp().getTime() == Long.MAX_VALUE) {
-        // The time range only has the start value, search from the start to the max supported value
-        // Long.MAX_VALUE
-        return QueryBuilders.rangeQuery(r.getField().getName())
-            .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()));
-      }
-      return QueryBuilders.rangeQuery(r.getField().getName())
-          .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()))
-          .lte(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
-    }
-    throw new QueryParseException("not a timestamp: " + p);
-  }
-
-  private <T> QueryBuilder exactQuery(IndexPredicate<T> p) {
-    String name = p.getField().getName();
-    String value = p.getValue();
-
-    if (!p.getField().isRepeatable() && value.isEmpty()) {
-      return new BoolQueryBuilder().mustNot(QueryBuilders.existsQuery(name));
-    } else if (p instanceof RegexPredicate) {
-      if (value.startsWith("^")) {
-        value = value.substring(1);
-      }
-      if (value.endsWith("$") && !value.endsWith("\\$") && !value.endsWith("\\\\$")) {
-        value = value.substring(0, value.length() - 1);
-      }
-      return QueryBuilders.regexpQuery(name + ".key", value);
-    } else {
-      return QueryBuilders.termQuery(name + ".key", value);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
deleted file mode 100644
index b41f365..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ /dev/null
@@ -1,157 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gson.JsonParser;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.apache.http.auth.AuthScope;
-import org.apache.http.auth.UsernamePasswordCredentials;
-import org.apache.http.client.CredentialsProvider;
-import org.apache.http.client.config.RequestConfig;
-import org.apache.http.impl.client.BasicCredentialsProvider;
-import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
-import org.elasticsearch.client.Request;
-import org.elasticsearch.client.Response;
-import org.elasticsearch.client.RestClient;
-import org.elasticsearch.client.RestClientBuilder;
-
-@Singleton
-class ElasticRestClientProvider implements Provider<RestClient>, LifecycleListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final ElasticConfiguration cfg;
-
-  private volatile RestClient client;
-  private ElasticQueryAdapter adapter;
-
-  @Inject
-  ElasticRestClientProvider(ElasticConfiguration cfg) {
-    this.cfg = cfg;
-  }
-
-  public static LifecycleModule module() {
-    return new LifecycleModule() {
-      @Override
-      protected void configure() {
-        listener().to(ElasticRestClientProvider.class);
-      }
-    };
-  }
-
-  @Override
-  public RestClient get() {
-    if (client == null) {
-      synchronized (this) {
-        if (client == null) {
-          client = build();
-          ElasticVersion version = getVersion();
-          logger.atInfo().log("Elasticsearch integration version %s", version);
-          adapter = new ElasticQueryAdapter();
-        }
-      }
-    }
-    return client;
-  }
-
-  @Override
-  public void start() {}
-
-  @Override
-  public void stop() {
-    if (client != null) {
-      try {
-        client.close();
-      } catch (IOException e) {
-        // Ignore. We can't do anything about it.
-      }
-    }
-  }
-
-  ElasticQueryAdapter adapter() {
-    get(); // Make sure we're connected
-    return adapter;
-  }
-
-  public static class FailedToGetVersion extends ElasticException {
-    private static final long serialVersionUID = 1L;
-    private static final String MESSAGE = "Failed to get Elasticsearch version";
-
-    FailedToGetVersion(StatusLine status) {
-      super(String.format("%s: %d %s", MESSAGE, status.getStatusCode(), status.getReasonPhrase()));
-    }
-
-    FailedToGetVersion(Throwable cause) {
-      super(MESSAGE, cause);
-    }
-  }
-
-  private ElasticVersion getVersion() throws ElasticException {
-    try {
-      Response response = client.performRequest(new Request("GET", "/"));
-      StatusLine statusLine = response.getStatusLine();
-      if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
-        throw new FailedToGetVersion(statusLine);
-      }
-      String version =
-          new JsonParser()
-              .parse(AbstractElasticIndex.getContent(response))
-              .getAsJsonObject()
-              .get("version")
-              .getAsJsonObject()
-              .get("number")
-              .getAsString();
-      logger.atInfo().log("Connected to Elasticsearch version %s", version);
-      return ElasticVersion.forVersion(version);
-    } catch (IOException e) {
-      throw new FailedToGetVersion(e);
-    }
-  }
-
-  private RestClient build() {
-    RestClientBuilder builder = RestClient.builder(cfg.getHosts());
-    setConfiguredTimeouts(builder);
-    setConfiguredCredentialsIfAny(builder);
-    return builder.build();
-  }
-
-  private void setConfiguredTimeouts(RestClientBuilder builder) {
-    builder.setRequestConfigCallback(
-        (RequestConfig.Builder requestConfigBuilder) ->
-            requestConfigBuilder
-                .setConnectTimeout(cfg.connectTimeout)
-                .setSocketTimeout(cfg.socketTimeout));
-  }
-
-  private void setConfiguredCredentialsIfAny(RestClientBuilder builder) {
-    String username = cfg.username;
-    String password = cfg.password;
-    if (username != null && password != null) {
-      CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
-      credentialsProvider.setCredentials(
-          AuthScope.ANY, new UsernamePasswordCredentials(username, password));
-      builder.setHttpClientConfigCallback(
-          (HttpAsyncClientBuilder httpClientBuilder) ->
-              httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticSetting.java b/java/com/google/gerrit/elasticsearch/ElasticSetting.java
deleted file mode 100644
index 7ec0566..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticSetting.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.common.collect.ImmutableMap;
-import java.util.Map;
-
-class ElasticSetting {
-  /** The custom char mappings of "." to " " and "_" to " " in the form of UTF-8 */
-  private static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
-      ImmutableMap.of("\\u002E", "\\u0020", "\\u005F", "\\u0020");
-
-  static SettingProperties createSetting(ElasticConfiguration config) {
-    return new ElasticSetting.Builder().addCharFilter().addAnalyzer().build(config);
-  }
-
-  static class Builder {
-    private final ImmutableMap.Builder<String, FieldProperties> fields =
-        new ImmutableMap.Builder<>();
-
-    SettingProperties build(ElasticConfiguration config) {
-      SettingProperties properties = new SettingProperties();
-      properties.analysis = fields.build();
-      properties.numberOfShards = config.getNumberOfShards();
-      properties.numberOfReplicas = config.numberOfReplicas;
-      properties.maxResultWindow = config.maxResultWindow;
-      return properties;
-    }
-
-    Builder addCharFilter() {
-      FieldProperties charMapping = new FieldProperties("mapping");
-      charMapping.mappings = getCustomCharMappings(CUSTOM_CHAR_MAPPING);
-
-      FieldProperties charFilter = new FieldProperties();
-      charFilter.customMapping = charMapping;
-      fields.put("char_filter", charFilter);
-      return this;
-    }
-
-    Builder addAnalyzer() {
-      FieldProperties customAnalyzer = new FieldProperties("custom");
-      customAnalyzer.tokenizer = "standard";
-      customAnalyzer.charFilter = new String[] {"custom_mapping"};
-      customAnalyzer.filter = new String[] {"lowercase"};
-
-      FieldProperties analyzer = new FieldProperties();
-      analyzer.customWithCharFilter = customAnalyzer;
-      fields.put("analyzer", analyzer);
-      return this;
-    }
-
-    private static String[] getCustomCharMappings(ImmutableMap<String, String> map) {
-      int mappingIndex = 0;
-      int numOfMappings = map.size();
-      String[] mapping = new String[numOfMappings];
-      for (Map.Entry<String, String> e : map.entrySet()) {
-        mapping[mappingIndex++] = e.getKey() + "=>" + e.getValue();
-      }
-      return mapping;
-    }
-  }
-
-  static class SettingProperties {
-    Map<String, FieldProperties> analysis;
-    Integer numberOfShards;
-    Integer numberOfReplicas;
-    Integer maxResultWindow;
-  }
-
-  static class FieldProperties {
-    String tokenizer;
-    String type;
-    String[] charFilter;
-    String[] filter;
-    String[] mappings;
-    FieldProperties customMapping;
-    FieldProperties customWithCharFilter;
-
-    FieldProperties() {}
-
-    FieldProperties(String type) {
-      this.type = type;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java b/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java
deleted file mode 100644
index a02a715..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.gerrit.index.StoredValue;
-import com.google.gson.JsonElement;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.format.DateTimeFormatter;
-import java.util.stream.StreamSupport;
-
-/** Bridge to recover fields from the elastic index. */
-public class ElasticStoredValue implements StoredValue {
-  private final JsonElement field;
-
-  ElasticStoredValue(JsonElement field) {
-    this.field = field;
-  }
-
-  @Override
-  public String asString() {
-    return field.getAsString();
-  }
-
-  @Override
-  public Iterable<String> asStrings() {
-    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
-        .map(f -> f.getAsString())
-        .collect(toImmutableList());
-  }
-
-  @Override
-  public Integer asInteger() {
-    return field.getAsInt();
-  }
-
-  @Override
-  public Iterable<Integer> asIntegers() {
-    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
-        .map(f -> f.getAsInt())
-        .collect(toImmutableList());
-  }
-
-  @Override
-  public Long asLong() {
-    return field.getAsLong();
-  }
-
-  @Override
-  public Iterable<Long> asLongs() {
-    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
-        .map(f -> f.getAsLong())
-        .collect(toImmutableList());
-  }
-
-  @Override
-  public Timestamp asTimestamp() {
-    return Timestamp.from(Instant.from(DateTimeFormatter.ISO_INSTANT.parse(field.getAsString())));
-  }
-
-  @Override
-  public byte[] asByteArray() {
-    return AbstractElasticIndex.decodeBase64(field.getAsString());
-  }
-
-  @Override
-  public Iterable<byte[]> asByteArrays() {
-    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
-        .map(f -> AbstractElasticIndex.decodeBase64(f.getAsString()))
-        .collect(toImmutableList());
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
deleted file mode 100644
index b5bf44b..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.common.base.Joiner;
-import java.util.regex.Pattern;
-
-public enum ElasticVersion {
-  V7_6("7.6.*"),
-  V7_7("7.7.*"),
-  V7_8("7.8.*");
-
-  private final String version;
-  private final Pattern pattern;
-
-  ElasticVersion(String version) {
-    this.version = version;
-    this.pattern = Pattern.compile(version);
-  }
-
-  public static class UnsupportedVersion extends ElasticException {
-    private static final long serialVersionUID = 1L;
-
-    UnsupportedVersion(String version) {
-      super(
-          String.format(
-              "Unsupported version: [%s]. Supported versions: %s", version, supportedVersions()));
-    }
-  }
-
-  /**
-   * Convert a version String to an ElasticVersion if supported.
-   *
-   * @param version for which to return an ElasticVersion
-   * @return the corresponding ElasticVersion if supported
-   */
-  public static ElasticVersion forVersion(String version) {
-    for (ElasticVersion value : ElasticVersion.values()) {
-      if (value.pattern.matcher(version).matches()) {
-        return value;
-      }
-    }
-    throw new UnsupportedVersion(version);
-  }
-
-  public static String supportedVersions() {
-    return Joiner.on(", ").join(ElasticVersion.values());
-  }
-
-  @Override
-  public String toString() {
-    return version;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
deleted file mode 100644
index a204919..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * A Query that matches documents matching boolean combinations of other queries.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.BoolQueryBuilder.
- */
-public class BoolQueryBuilder extends QueryBuilder {
-
-  private final List<QueryBuilder> mustClauses = new ArrayList<>();
-
-  private final List<QueryBuilder> mustNotClauses = new ArrayList<>();
-
-  private final List<QueryBuilder> filterClauses = new ArrayList<>();
-
-  private final List<QueryBuilder> shouldClauses = new ArrayList<>();
-
-  /**
-   * Adds a query that <b>must</b> appear in the matching documents and will contribute to scoring.
-   */
-  public BoolQueryBuilder must(QueryBuilder queryBuilder) {
-    mustClauses.add(queryBuilder);
-    return this;
-  }
-
-  /**
-   * Adds a query that <b>must not</b> appear in the matching documents and will not contribute to
-   * scoring.
-   */
-  public BoolQueryBuilder mustNot(QueryBuilder queryBuilder) {
-    mustNotClauses.add(queryBuilder);
-    return this;
-  }
-
-  /**
-   * Adds a query that <i>should</i> appear in the matching documents. For a boolean query with no
-   * <tt>MUST</tt> clauses one or more <code>SHOULD</code> clauses must match a document for the
-   * BooleanQuery to match.
-   */
-  public BoolQueryBuilder should(QueryBuilder queryBuilder) {
-    shouldClauses.add(queryBuilder);
-    return this;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("bool");
-    doXArrayContent("must", mustClauses, builder);
-    doXArrayContent("filter", filterClauses, builder);
-    doXArrayContent("must_not", mustNotClauses, builder);
-    doXArrayContent("should", shouldClauses, builder);
-    builder.endObject();
-  }
-
-  private void doXArrayContent(String field, List<QueryBuilder> clauses, XContentBuilder builder)
-      throws IOException {
-    if (clauses.isEmpty()) {
-      return;
-    }
-    if (clauses.size() == 1) {
-      builder.field(field);
-      clauses.get(0).toXContent(builder);
-    } else {
-      builder.startArray(field);
-      for (QueryBuilder clause : clauses) {
-        clause.toXContent(builder);
-      }
-      builder.endArray();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
deleted file mode 100644
index 1b058d7..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import java.io.IOException;
-
-/**
- * Constructs a query that only match on documents that the field has a value in them.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.ExistsQueryBuilder.
- */
-class ExistsQueryBuilder extends QueryBuilder {
-
-  private final String name;
-
-  ExistsQueryBuilder(String name) {
-    this.name = name;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("exists");
-    builder.field("field", name);
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
deleted file mode 100644
index a3b303c..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import java.io.IOException;
-
-/**
- * A query that matches on all documents.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.MatchAllQueryBuilder.
- */
-class MatchAllQueryBuilder extends QueryBuilder {
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("match_all");
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
deleted file mode 100644
index c0becd1..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import java.io.IOException;
-import java.util.Locale;
-
-/**
- * Match query is a query that analyzes the text and constructs a query as the result of the
- * analysis. It can construct different queries based on the type provided.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.MatchQueryBuilder.
- */
-class MatchQueryBuilder extends QueryBuilder {
-
-  enum Type {
-    /** The text is analyzed and used as a phrase query. */
-    MATCH_PHRASE,
-    /** The text is analyzed and used in a phrase query, with the last term acting as a prefix. */
-    MATCH_PHRASE_PREFIX;
-
-    @Override
-    public String toString() {
-      return name().toLowerCase(Locale.US);
-    }
-  }
-
-  private final String name;
-
-  private final Object text;
-
-  private Type type;
-
-  /** Constructs a new text query. */
-  MatchQueryBuilder(String name, Object text) {
-    this.name = name;
-    this.text = text;
-  }
-
-  /** Sets the type of the text query. */
-  MatchQueryBuilder type(Type type) {
-    this.type = type;
-    return this;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject(type.toString()).field(name, text).endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
deleted file mode 100644
index d6f154e..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import java.io.IOException;
-
-/** A trimmed down version of org.elasticsearch.index.query.QueryBuilder. */
-public abstract class QueryBuilder {
-
-  protected QueryBuilder() {}
-
-  protected void toXContent(XContentBuilder builder) throws IOException {
-    builder.startObject();
-    doXContent(builder);
-    builder.endObject();
-  }
-
-  protected abstract void doXContent(XContentBuilder builder) throws IOException;
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java b/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
deleted file mode 100644
index 940146f..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-/**
- * A static factory for simple "import static" usage.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.QueryBuilders.
- */
-public abstract class QueryBuilders {
-
-  /** A query that match on all documents. */
-  public static MatchAllQueryBuilder matchAllQuery() {
-    return new MatchAllQueryBuilder();
-  }
-
-  /**
-   * Creates a text query with type "PHRASE" for the provided field name and text.
-   *
-   * @param name The field name.
-   * @param text The query text (to be analyzed).
-   */
-  public static MatchQueryBuilder matchPhraseQuery(String name, Object text) {
-    return new MatchQueryBuilder(name, text).type(MatchQueryBuilder.Type.MATCH_PHRASE);
-  }
-
-  /**
-   * Creates a match query with type "PHRASE_PREFIX" for the provided field name and text.
-   *
-   * @param name The field name.
-   * @param text The query text (to be analyzed).
-   */
-  public static MatchQueryBuilder matchPhrasePrefixQuery(String name, Object text) {
-    return new MatchQueryBuilder(name, text).type(MatchQueryBuilder.Type.MATCH_PHRASE_PREFIX);
-  }
-
-  /**
-   * A Query that matches documents containing a term.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  public static TermQueryBuilder termQuery(String name, String value) {
-    return new TermQueryBuilder(name, value);
-  }
-
-  /**
-   * A Query that matches documents containing a term.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  public static TermQueryBuilder termQuery(String name, int value) {
-    return new TermQueryBuilder(name, value);
-  }
-
-  /**
-   * A Query that matches documents within an range of terms.
-   *
-   * @param name The field name
-   */
-  public static RangeQueryBuilder rangeQuery(String name) {
-    return new RangeQueryBuilder(name);
-  }
-
-  /**
-   * A Query that matches documents containing terms with a specified regular expression.
-   *
-   * @param name The name of the field
-   * @param regexp The regular expression
-   */
-  public static RegexpQueryBuilder regexpQuery(String name, String regexp) {
-    return new RegexpQueryBuilder(name, regexp);
-  }
-
-  /** A Query that matches documents matching boolean combinations of other queries. */
-  public static BoolQueryBuilder boolQuery() {
-    return new BoolQueryBuilder();
-  }
-
-  /**
-   * A filter to filter only documents where a field exists in them.
-   *
-   * @param name The name of the field
-   */
-  public static ExistsQueryBuilder existsQuery(String name) {
-    return new ExistsQueryBuilder(name);
-  }
-
-  private QueryBuilders() {}
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java b/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
deleted file mode 100644
index 1cb5c82..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import java.io.IOException;
-
-/** A trimmed down and modified version of org.elasticsearch.action.support.QuerySourceBuilder. */
-class QuerySourceBuilder {
-
-  private final QueryBuilder queryBuilder;
-
-  QuerySourceBuilder(QueryBuilder queryBuilder) {
-    this.queryBuilder = queryBuilder;
-  }
-
-  void innerToXContent(XContentBuilder builder) throws IOException {
-    builder.field("query");
-    queryBuilder.toXContent(builder);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
deleted file mode 100644
index 32dbc0e..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import java.io.IOException;
-
-/**
- * A Query that matches documents within an range of terms.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.RangeQueryBuilder.
- */
-public class RangeQueryBuilder extends QueryBuilder {
-
-  private final String name;
-  private Object from;
-  private Object to;
-  private boolean includeLower = true;
-  private boolean includeUpper = true;
-
-  /**
-   * A Query that matches documents within an range of terms.
-   *
-   * @param name The field name
-   */
-  RangeQueryBuilder(String name) {
-    this.name = name;
-  }
-
-  /** The from part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder gt(Object from) {
-    this.from = from;
-    this.includeLower = false;
-    return this;
-  }
-
-  /** The from part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder gte(Object from) {
-    this.from = from;
-    this.includeLower = true;
-    return this;
-  }
-
-  /** The from part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder gte(int from) {
-    this.from = from;
-    this.includeLower = true;
-    return this;
-  }
-
-  /** The to part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder lte(Object to) {
-    this.to = to;
-    this.includeUpper = true;
-    return this;
-  }
-
-  /** The to part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder lte(int to) {
-    this.to = to;
-    this.includeUpper = true;
-    return this;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("range");
-    builder.startObject(name);
-
-    builder.field("from", from);
-    builder.field("to", to);
-    builder.field("include_lower", includeLower);
-    builder.field("include_upper", includeUpper);
-
-    builder.endObject();
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
deleted file mode 100644
index b81ec20..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import java.io.IOException;
-
-/**
- * A Query that does fuzzy matching for a specific value.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.RegexpQueryBuilder.
- */
-class RegexpQueryBuilder extends QueryBuilder {
-
-  private final String name;
-  private final String regexp;
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param regexp The regular expression
-   */
-  RegexpQueryBuilder(String name, String regexp) {
-    this.name = name;
-    this.regexp = regexp;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("regexp");
-    builder.startObject(name);
-
-    builder.field("value", regexp);
-    builder.field("flags_value", 65535);
-
-    builder.endObject();
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java b/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
deleted file mode 100644
index 35cbea9..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import com.google.gerrit.elasticsearch.ElasticQueryAdapter;
-import java.io.IOException;
-import java.util.List;
-
-/**
- * A search source builder allowing to easily build search source.
- *
- * <p>A trimmed down and modified version of org.elasticsearch.search.builder.SearchSourceBuilder.
- */
-public class SearchSourceBuilder {
-  private final ElasticQueryAdapter adapter;
-
-  private QuerySourceBuilder querySourceBuilder;
-
-  private int from = -1;
-
-  private int size = -1;
-
-  private List<String> fieldNames;
-
-  /** Constructs a new search source builder. */
-  public SearchSourceBuilder(ElasticQueryAdapter adapter) {
-    this.adapter = adapter;
-  }
-
-  /** Constructs a new search source builder with a search query. */
-  public SearchSourceBuilder query(QueryBuilder query) {
-    if (this.querySourceBuilder == null) {
-      this.querySourceBuilder = new QuerySourceBuilder(query);
-    }
-    return this;
-  }
-
-  /** From index to start the search from. Defaults to <tt>0</tt>. */
-  public SearchSourceBuilder from(int from) {
-    this.from = from;
-    return this;
-  }
-
-  /** The number of search hits to return. Defaults to <tt>10</tt>. */
-  public SearchSourceBuilder size(int size) {
-    this.size = size;
-    return this;
-  }
-
-  /**
-   * Sets the fields to load and return as part of the search request. If none are specified, the
-   * source of the document will be returned.
-   */
-  public SearchSourceBuilder fields(List<String> fields) {
-    this.fieldNames = fields;
-    return this;
-  }
-
-  @Override
-  public final String toString() {
-    try {
-      XContentBuilder builder = new XContentBuilder();
-      toXContent(builder);
-      return builder.string();
-    } catch (IOException ioe) {
-      return "";
-    }
-  }
-
-  private void toXContent(XContentBuilder builder) throws IOException {
-    builder.startObject();
-    innerToXContent(builder);
-    builder.endObject();
-  }
-
-  private void innerToXContent(XContentBuilder builder) throws IOException {
-    if (from != -1) {
-      builder.field("from", from);
-    }
-    if (size != -1) {
-      builder.field("size", size);
-    }
-
-    if (querySourceBuilder != null) {
-      querySourceBuilder.innerToXContent(builder);
-    }
-
-    if (fieldNames != null) {
-      if (fieldNames.size() == 1) {
-        builder.field(adapter.searchFilteringName(), fieldNames.get(0));
-      } else {
-        builder.startArray(adapter.searchFilteringName());
-        for (String fieldName : fieldNames) {
-          builder.value(fieldName);
-        }
-        builder.endArray();
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
deleted file mode 100644
index 2b407c6..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import java.io.IOException;
-
-/**
- * A Query that matches documents containing a term.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.TermQueryBuilder.
- */
-class TermQueryBuilder extends QueryBuilder {
-
-  private final String name;
-
-  private final Object value;
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  TermQueryBuilder(String name, String value) {
-    this(name, (Object) value);
-  }
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  TermQueryBuilder(String name, int value) {
-    this(name, (Object) value);
-  }
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  private TermQueryBuilder(String name, Object value) {
-    this.name = name;
-    this.value = value;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("term");
-    builder.field(name, value);
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java b/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
deleted file mode 100644
index 9c44583..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
+++ /dev/null
@@ -1,168 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.builders;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.time.format.DateTimeFormatter.ISO_INSTANT;
-
-import com.fasterxml.jackson.core.JsonEncoding;
-import com.fasterxml.jackson.core.JsonFactory;
-import com.fasterxml.jackson.core.JsonGenerator;
-import com.fasterxml.jackson.core.json.JsonReadFeature;
-import com.fasterxml.jackson.core.json.JsonWriteFeature;
-import java.io.ByteArrayOutputStream;
-import java.io.Closeable;
-import java.io.IOException;
-import java.util.Date;
-
-/** A trimmed down and modified version of org.elasticsearch.common.xcontent.XContentBuilder. */
-public final class XContentBuilder implements Closeable {
-
-  private final JsonGenerator generator;
-
-  private final ByteArrayOutputStream bos = new ByteArrayOutputStream();
-
-  /**
-   * Constructs a new builder. Make sure to call {@link #close()} when the builder is done with.
-   * Inspired from org.elasticsearch.common.xcontent.json.JsonXContent static block.
-   */
-  public XContentBuilder() throws IOException {
-    this.generator =
-        JsonFactory.builder()
-            .configure(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES, true)
-            .configure(JsonWriteFeature.QUOTE_FIELD_NAMES, true)
-            .configure(JsonReadFeature.ALLOW_JAVA_COMMENTS, true)
-            .configure(JsonFactory.Feature.FAIL_ON_SYMBOL_HASH_OVERFLOW, false)
-            .build()
-            .createGenerator(bos, JsonEncoding.UTF8);
-  }
-
-  public XContentBuilder startObject(String name) throws IOException {
-    field(name);
-    startObject();
-    return this;
-  }
-
-  public XContentBuilder startObject() throws IOException {
-    generator.writeStartObject();
-    return this;
-  }
-
-  public XContentBuilder endObject() throws IOException {
-    generator.writeEndObject();
-    return this;
-  }
-
-  public void startArray(String name) throws IOException {
-    field(name);
-    startArray();
-  }
-
-  private void startArray() throws IOException {
-    generator.writeStartArray();
-  }
-
-  public void endArray() throws IOException {
-    generator.writeEndArray();
-  }
-
-  public XContentBuilder field(String name) throws IOException {
-    generator.writeFieldName(name);
-    return this;
-  }
-
-  public XContentBuilder field(String name, String value) throws IOException {
-    field(name);
-    generator.writeString(value);
-    return this;
-  }
-
-  public XContentBuilder field(String name, int value) throws IOException {
-    field(name);
-    generator.writeNumber(value);
-    return this;
-  }
-
-  public XContentBuilder field(String name, Iterable<?> value) throws IOException {
-    startArray(name);
-    for (Object o : value) {
-      value(o);
-    }
-    endArray();
-    return this;
-  }
-
-  public XContentBuilder field(String name, Object value) throws IOException {
-    field(name);
-    writeValue(value);
-    return this;
-  }
-
-  public XContentBuilder value(Object value) throws IOException {
-    writeValue(value);
-    return this;
-  }
-
-  public XContentBuilder field(String name, boolean value) throws IOException {
-    field(name);
-    generator.writeBoolean(value);
-    return this;
-  }
-
-  public XContentBuilder value(String value) throws IOException {
-    generator.writeString(value);
-    return this;
-  }
-
-  @Override
-  public void close() {
-    try {
-      generator.close();
-    } catch (IOException e) {
-      // ignore
-    }
-  }
-
-  /** Returns a string representation of the builder (only applicable for text based xcontent). */
-  public String string() {
-    close();
-    byte[] bytesArray = bos.toByteArray();
-    return new String(bytesArray, UTF_8);
-  }
-
-  private void writeValue(Object value) throws IOException {
-    if (value == null) {
-      generator.writeNull();
-      return;
-    }
-    Class<?> type = value.getClass();
-    if (type == String.class) {
-      generator.writeString((String) value);
-    } else if (type == Integer.class) {
-      generator.writeNumber(((Integer) value));
-    } else if (type == byte[].class) {
-      generator.writeBinary((byte[]) value);
-    } else if (value instanceof Date) {
-      generator.writeString(ISO_INSTANT.format(((Date) value).toInstant()));
-    } else {
-      // if this is a "value" object, like enum, DistanceUnit, ..., just toString it
-      // yea, it can be misleading when toString a Java class, but really, jackson should be used in
-      // that case
-      generator.writeString(value.toString());
-      // throw new ElasticsearchIllegalArgumentException("type not supported for generic value
-      // conversion: " + type);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java b/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
deleted file mode 100644
index 16b821c..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.bulk;
-
-import com.google.gson.JsonObject;
-
-abstract class ActionRequest extends BulkRequest {
-
-  private final String action;
-  private final String id;
-  private final String index;
-
-  protected ActionRequest(String action, String id, String index) {
-    this.action = action;
-    this.id = id;
-    this.index = index;
-  }
-
-  @Override
-  protected String getRequest() {
-    JsonObject properties = new JsonObject();
-    properties.addProperty("_id", id);
-    properties.addProperty("_index", index);
-
-    JsonObject jsonAction = new JsonObject();
-    jsonAction.add(action, properties);
-    return jsonAction.toString() + System.lineSeparator();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java b/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
deleted file mode 100644
index be5ad8d..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.bulk;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public abstract class BulkRequest {
-
-  private final List<BulkRequest> requests = new ArrayList<>();
-
-  protected BulkRequest() {
-    add(this);
-  }
-
-  public BulkRequest add(BulkRequest request) {
-    requests.add(request);
-    return this;
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder builder = new StringBuilder();
-    for (BulkRequest request : requests) {
-      builder.append(request.getRequest());
-    }
-    return builder.toString();
-  }
-
-  protected abstract String getRequest();
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java b/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
deleted file mode 100644
index 6451b0f..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.bulk;
-
-public class DeleteRequest extends ActionRequest {
-
-  public DeleteRequest(String id, String index) {
-    super("delete", id, index);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java b/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
deleted file mode 100644
index d90b80f..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.bulk;
-
-public class IndexRequest extends ActionRequest {
-
-  public IndexRequest(String id, String index) {
-    super("index", id, index);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java b/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
deleted file mode 100644
index 196b8d6..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch.bulk;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
-import com.google.gerrit.elasticsearch.builders.XContentBuilder;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.Schema.Values;
-import java.io.IOException;
-
-public class UpdateRequest<V> extends BulkRequest {
-
-  private final Schema<V> schema;
-  private final V v;
-  private final ImmutableSet<String> skipFields;
-
-  public UpdateRequest(Schema<V> schema, V v, ImmutableSet<String> skipFields) {
-    this.schema = schema;
-    this.v = v;
-    this.skipFields = skipFields;
-  }
-
-  @Override
-  protected String getRequest() {
-    try (XContentBuilder closeable = new XContentBuilder()) {
-      XContentBuilder builder = closeable.startObject();
-      for (Values<V> values : schema.buildFields(v, skipFields)) {
-        String name = values.getField().getName();
-        if (values.getField().isRepeatable()) {
-          builder.field(name, Streams.stream(values.getValues()).collect(toList()));
-        } else {
-          Object element = Iterables.getOnlyElement(values.getValues(), "");
-          if (shouldAddElement(element)) {
-            builder.field(name, element);
-          }
-        }
-      }
-      return builder.endObject().string() + System.lineSeparator();
-    } catch (IOException e) {
-      return e.toString();
-    }
-  }
-
-  private boolean shouldAddElement(Object element) {
-    return !(element instanceof String) || !((String) element).isEmpty();
-  }
-}
diff --git a/java/com/google/gerrit/entities/AccessSection.java b/java/com/google/gerrit/entities/AccessSection.java
index 69a234a..8ae0a5d 100644
--- a/java/com/google/gerrit/entities/AccessSection.java
+++ b/java/com/google/gerrit/entities/AccessSection.java
@@ -18,12 +18,14 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.function.Consumer;
+import java.util.regex.Pattern;
 
 /** Portion of a {@link Project} describing access rules. */
 @AutoValue
@@ -42,6 +44,20 @@
   /** Name of the access section. It could be a ref pattern or something else. */
   public abstract String getName();
 
+  /**
+   * A compiled regular expression in case {@link #getName()} is a regular expression. This is
+   * memoized to save callers from compiling patterns for every use.
+   */
+  @Memoized
+  public Optional<Pattern> getNamePattern() {
+    if (isValidRefSectionName(getName())
+        && getName().startsWith(REGEX_PREFIX)
+        && !getName().contains("${")) {
+      return Optional.of(Pattern.compile(getName()));
+    }
+    return Optional.empty();
+  }
+
   public abstract ImmutableList<Permission> getPermissions();
 
   public static AccessSection create(String name) {
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 18fcef3..699acc0 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -22,7 +22,7 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /**
@@ -46,6 +46,10 @@
  */
 @AutoValue
 public abstract class Account {
+
+  /** Placeholder for indicating an account-id that does not correspond to any local account */
+  public static final Id UNKNOWN_ACCOUNT_ID = id(0);
+
   public static Id id(int id) {
     return new AutoValue_Account_Id(id);
   }
@@ -58,6 +62,7 @@
       return Optional.ofNullable(Ints.tryParse(str)).map(Account::id);
     }
 
+    @Nullable
     public static Id fromRef(String name) {
       if (name == null) {
         return null;
@@ -78,11 +83,13 @@
      * @param name a ref name with the following syntax: {@code "34/1234..."}. We assume that the
      *     caller has trimmed any prefix.
      */
+    @Nullable
     public static Id fromRefPart(String name) {
       Integer id = RefNames.parseShardedRefPart(name);
       return id != null ? Account.id(id) : null;
     }
 
+    @Nullable
     public static Id parseAfterShardedRefPart(String name) {
       Integer id = RefNames.parseAfterShardedRefPart(name);
       return id != null ? Account.id(id) : null;
@@ -98,6 +105,7 @@
      * @param name ref name
      * @return account ID, or null if not numeric.
      */
+    @Nullable
     public static Id fromRefSuffix(String name) {
       Integer id = RefNames.parseRefSuffix(name);
       return id != null ? Account.id(id) : null;
@@ -123,7 +131,7 @@
   public abstract Id id();
 
   /** Date and time the user registered with the review server. */
-  public abstract Timestamp registeredOn();
+  public abstract Instant registeredOn();
 
   /** Full name of the user ("Given-name Surname" style). */
   @Nullable
@@ -157,7 +165,7 @@
    * @param newId unique id, see {@link com.google.gerrit.server.notedb.Sequences#nextAccountId()}.
    * @param registeredOn when the account was registered.
    */
-  public static Account.Builder builder(Account.Id newId, Timestamp registeredOn) {
+  public static Account.Builder builder(Account.Id newId, Instant registeredOn) {
     return new AutoValue_Account.Builder()
         .setInactive(false)
         .setId(newId)
@@ -230,9 +238,9 @@
 
     abstract Builder setId(Id id);
 
-    public abstract Timestamp registeredOn();
+    public abstract Instant registeredOn();
 
-    abstract Builder setRegisteredOn(Timestamp registeredOn);
+    abstract Builder setRegisteredOn(Instant registeredOn);
 
     @Nullable
     public abstract String fullName();
diff --git a/java/com/google/gerrit/entities/AccountGroup.java b/java/com/google/gerrit/entities/AccountGroup.java
index 001a544..b5c97da 100644
--- a/java/com/google/gerrit/entities/AccountGroup.java
+++ b/java/com/google/gerrit/entities/AccountGroup.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
 
 public final class AccountGroup {
   public static NameKey nameKey(String n) {
@@ -65,6 +66,7 @@
     }
 
     /** Parse an {@link AccountGroup.UUID} out of a ref-name. */
+    @Nullable
     public static UUID fromRef(String ref) {
       if (ref == null) {
         return null;
@@ -81,6 +83,7 @@
      * @param refPart a ref name with the following syntax: {@code "12/1234..."}. We assume that the
      *     caller has trimmed any prefix.
      */
+    @Nullable
     public static UUID fromRefPart(String refPart) {
       String uuid = RefNames.parseShardedUuidFromRefPart(refPart);
       return uuid != null ? AccountGroup.uuid(uuid) : null;
diff --git a/java/com/google/gerrit/entities/AccountGroupByIdAudit.java b/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
index 17ddf51..0ef51e5 100644
--- a/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
+++ b/java/com/google/gerrit/entities/AccountGroupByIdAudit.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /** Inclusion of an {@link AccountGroup} in another {@link AccountGroup}. */
@@ -33,13 +33,13 @@
 
     public abstract Builder addedBy(Account.Id addedBy);
 
-    public abstract Builder addedOn(Timestamp addedOn);
+    public abstract Builder addedOn(Instant addedOn);
 
     abstract Builder removedBy(Account.Id removedBy);
 
-    abstract Builder removedOn(Timestamp removedOn);
+    abstract Builder removedOn(Instant removedOn);
 
-    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+    public Builder removed(Account.Id removedBy, Instant removedOn) {
       return removedBy(removedBy).removedOn(removedOn);
     }
 
@@ -52,11 +52,11 @@
 
   public abstract Account.Id addedBy();
 
-  public abstract Timestamp addedOn();
+  public abstract Instant addedOn();
 
   public abstract Optional<Account.Id> removedBy();
 
-  public abstract Optional<Timestamp> removedOn();
+  public abstract Optional<Instant> removedOn();
 
   public abstract Builder toBuilder();
 
diff --git a/java/com/google/gerrit/entities/AccountGroupMemberAudit.java b/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
index 4d191b8..913956e 100644
--- a/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
+++ b/java/com/google/gerrit/entities/AccountGroupMemberAudit.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /** Membership of an {@link Account} in an {@link AccountGroup}. */
@@ -35,15 +35,15 @@
 
     abstract Account.Id addedBy();
 
-    public abstract Builder addedOn(Timestamp addedOn);
+    public abstract Builder addedOn(Instant addedOn);
 
-    abstract Timestamp addedOn();
+    abstract Instant addedOn();
 
     abstract Builder removedBy(Account.Id removedBy);
 
-    abstract Builder removedOn(Timestamp removedOn);
+    abstract Builder removedOn(Instant removedOn);
 
-    public Builder removed(Account.Id removedBy, Timestamp removedOn) {
+    public Builder removed(Account.Id removedBy, Instant removedOn) {
       return removedBy(removedBy).removedOn(removedOn);
     }
 
@@ -60,11 +60,11 @@
 
   public abstract Account.Id addedBy();
 
-  public abstract Timestamp addedOn();
+  public abstract Instant addedOn();
 
   public abstract Optional<Account.Id> removedBy();
 
-  public abstract Optional<Timestamp> removedOn();
+  public abstract Optional<Instant> removedOn();
 
   public abstract Builder toBuilder();
 
diff --git a/java/com/google/gerrit/entities/Address.java b/java/com/google/gerrit/entities/Address.java
index 5d63476..eb1da46 100644
--- a/java/com/google/gerrit/entities/Address.java
+++ b/java/com/google/gerrit/entities/Address.java
@@ -46,6 +46,7 @@
     throw new IllegalArgumentException("Invalid email address: " + in);
   }
 
+  @Nullable
   public static Address tryParse(String in) {
     try {
       return parse(in);
diff --git a/java/com/google/gerrit/entities/BooleanProjectConfig.java b/java/com/google/gerrit/entities/BooleanProjectConfig.java
index 5201f6d..605c40c 100644
--- a/java/com/google/gerrit/entities/BooleanProjectConfig.java
+++ b/java/com/google/gerrit/entities/BooleanProjectConfig.java
@@ -41,7 +41,9 @@
   ENABLE_REVIEWER_BY_EMAIL("reviewer", "enableByEmail"),
   MATCH_AUTHOR_TO_COMMITTER_DATE("submit", "matchAuthorToCommitterDate"),
   REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit"),
-  WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault");
+  WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault"),
+  SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS(
+      "reviewer", "skipAddingAuthorAndCommitterAsReviewers");
 
   // Git config
   private final String section;
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 8740235..be4a1cf 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.flogger.FluentLogger;
 import java.util.Collection;
 import java.util.List;
@@ -69,7 +70,7 @@
   public abstract AccountsSection getAccountsSection();
 
   /** Returns a map of {@link AccessSection}s keyed by their name. */
-  public abstract ImmutableMap<String, AccessSection> getAccessSections();
+  public abstract ImmutableSortedMap<String, AccessSection> getAccessSections();
 
   /** Returns the {@link AccessSection} with to the given name. */
   public Optional<AccessSection> getAccessSection(String refName) {
@@ -226,7 +227,7 @@
       try {
         parsedProjectLevelConfigsBuilder().put(configFileName, ImmutableConfig.parse(config));
       } catch (ConfigInvalidException e) {
-        logger.atInfo().withCause(e).log("Config for " + configFileName + " not parsable");
+        logger.atInfo().withCause(e).log("Config for %s not parsable", configFileName);
       }
       return this;
     }
@@ -235,7 +236,7 @@
 
     protected abstract ImmutableMap.Builder<AccountGroup.UUID, GroupReference> groupsBuilder();
 
-    protected abstract ImmutableMap.Builder<String, AccessSection> accessSectionsBuilder();
+    protected abstract ImmutableSortedMap.Builder<String, AccessSection> accessSectionsBuilder();
 
     protected abstract ImmutableMap.Builder<String, ContributorAgreement>
         contributorAgreementsBuilder();
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index d1826bc..55220f3 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.entities;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 
 import com.google.auto.value.AutoValue;
@@ -24,7 +23,7 @@
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import com.google.gson.annotations.SerializedName;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Optional;
 
@@ -109,18 +108,6 @@
     /**
      * Parse a Change.Id out of a string representation.
      *
-     * @deprecated use {@link #tryParse(String)} instead.
-     */
-    @Deprecated
-    public static Id parse(String str) {
-      Integer id = Ints.tryParse(str);
-      checkArgument(id != null, "invalid change ID: %s", str);
-      return Change.id(id);
-    }
-
-    /**
-     * Parse a Change.Id out of a string representation.
-     *
      * @param str the string to parse
      * @return Optional containing the Change.Id, or {@code Optional.empty()} if str does not
      *     represent a valid Change.Id.
@@ -130,6 +117,7 @@
       return id != null ? Optional.of(Change.id(id)) : Optional.empty();
     }
 
+    @Nullable
     public static Id fromRef(String ref) {
       if (RefNames.isRefsEdit(ref)) {
         return fromEditRefPart(ref);
@@ -147,6 +135,7 @@
       return null;
     }
 
+    @Nullable
     public static Id fromAllUsersRef(String ref) {
       if (ref == null) {
         return null;
@@ -182,6 +171,7 @@
       return true;
     }
 
+    @Nullable
     public static Id fromEditRefPart(String ref) {
       int startChangeId = ref.indexOf(RefNames.EDIT_PREFIX) + RefNames.EDIT_PREFIX.length();
       int endChangeId = nextNonDigit(ref, startChangeId);
@@ -192,6 +182,7 @@
       return null;
     }
 
+    @Nullable
     public static Id fromRefPart(String ref) {
       Integer id = RefNames.parseShardedRefPart(ref);
       return id != null ? Change.id(id) : null;
@@ -417,6 +408,7 @@
       return changeStatus;
     }
 
+    @Nullable
     public static Status forCode(char c) {
       for (Status s : Status.values()) {
         if (s.code == c) {
@@ -427,6 +419,7 @@
       return null;
     }
 
+    @Nullable
     public static Status forChangeStatus(ChangeStatus cs) {
       for (Status s : Status.values()) {
         if (s.changeStatus == cs) {
@@ -438,37 +431,37 @@
   }
 
   /** Locally assigned unique identifier of the change */
-  protected Id changeId;
+  private Id changeId;
 
   /** Globally assigned unique identifier of the change */
-  protected Key changeKey;
+  private Key changeKey;
 
   /** When this change was first introduced into the database. */
-  protected Timestamp createdOn;
+  private Instant createdOn;
 
   /**
    * When was a meaningful modification last made to this record's data
    *
    * <p>Note, this update timestamp includes its children.
    */
-  protected Timestamp lastUpdatedOn;
+  private Instant lastUpdatedOn;
 
-  protected Account.Id owner;
+  private Account.Id owner;
 
   /** The branch (and project) this change merges into. */
-  protected BranchNameKey dest;
+  private BranchNameKey dest;
 
   /** Current state code; see {@link Status}. */
-  protected char status;
+  private char status;
 
   /** The current patch set. */
-  protected int currentPatchSetId;
+  private int currentPatchSetId;
 
   /** Subject from the current patch set. */
-  protected String subject;
+  private String subject;
 
   /** Topic name assigned by the user, if any. */
-  @Nullable protected String topic;
+  @Nullable private String topic;
 
   /**
    * First line of first patch set's commit message.
@@ -476,40 +469,36 @@
    * <p>Unlike {@link #subject}, this string does not change if future patch sets change the first
    * line.
    */
-  @Nullable protected String originalSubject;
+  @Nullable private String originalSubject;
 
   /**
    * Unique id for the changes submitted together assigned during merging. Only set if the status is
    * MERGED.
    */
-  @Nullable protected String submissionId;
+  @Nullable private String submissionId;
 
   /** Allows assigning a change to a user. */
-  @Nullable protected Account.Id assignee;
+  @Nullable private Account.Id assignee;
 
   /** Whether the change is private. */
-  protected boolean isPrivate;
+  private boolean isPrivate;
 
   /** Whether the change is work in progress. */
-  protected boolean workInProgress;
+  private boolean workInProgress;
 
   /** Whether the change has started review. */
-  protected boolean reviewStarted;
+  private boolean reviewStarted;
 
   /** References a change that this change reverts. */
-  @Nullable protected Id revertOf;
+  @Nullable private Id revertOf;
 
   /** References the source change and patchset that this change was cherry-picked from. */
-  @Nullable protected PatchSet.Id cherryPickOf;
+  @Nullable private PatchSet.Id cherryPickOf;
 
-  protected Change() {}
+  Change() {}
 
   public Change(
-      Change.Key newKey,
-      Change.Id newId,
-      Account.Id ownedBy,
-      BranchNameKey forBranch,
-      Timestamp ts) {
+      Change.Key newKey, Change.Id newId, Account.Id ownedBy, BranchNameKey forBranch, Instant ts) {
     changeKey = newKey;
     changeId = newId;
     createdOn = ts;
@@ -567,19 +556,19 @@
     assignee = a;
   }
 
-  public Timestamp getCreatedOn() {
+  public Instant getCreatedOn() {
     return createdOn;
   }
 
-  public void setCreatedOn(Timestamp ts) {
+  public void setCreatedOn(Instant ts) {
     createdOn = ts;
   }
 
-  public Timestamp getLastUpdatedOn() {
+  public Instant getLastUpdatedOn() {
     return lastUpdatedOn;
   }
 
-  public void setLastUpdatedOn(Timestamp now) {
+  public void setLastUpdatedOn(Instant now) {
     lastUpdatedOn = now;
   }
 
@@ -616,6 +605,7 @@
   }
 
   /** Get the id of the most current {@link PatchSet} in this change. */
+  @Nullable
   public PatchSet.Id currentPatchSetId() {
     if (currentPatchSetId > 0) {
       return PatchSet.id(changeId, currentPatchSetId);
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index cb56c31..609b54c 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -16,7 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.common.Nullable;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -40,40 +40,40 @@
     public abstract String uuid();
   }
 
-  protected Key key;
+  private Key key;
 
   /** Who wrote this comment; null if it was written by the Gerrit system. */
-  @Nullable protected Account.Id author;
+  @Nullable private Account.Id author;
 
   /** When this comment was drafted. */
-  protected Timestamp writtenOn;
+  private Instant writtenOn;
 
   /**
    * The text left by the user or Gerrit system in template form, that is free of Gerrit User
    * Identifiable Information and can be persisted in data storage.
    */
-  @Nullable protected String message;
+  @Nullable private String message;
 
   /** Which patchset (if any) was this message generated from? */
-  @Nullable protected PatchSet.Id patchset;
+  @Nullable private PatchSet.Id patchset;
 
   /** Tag associated with change message */
-  @Nullable protected String tag;
+  @Nullable private String tag;
 
   /** Real user that added this message on behalf of the user recorded in {@link #author}. */
-  @Nullable protected Account.Id realAuthor;
+  @Nullable private Account.Id realAuthor;
 
-  protected ChangeMessage() {}
+  private ChangeMessage() {}
 
   public static ChangeMessage create(
-      final ChangeMessage.Key k, @Nullable Account.Id a, Timestamp wo, @Nullable PatchSet.Id psid) {
+      final ChangeMessage.Key k, @Nullable Account.Id a, Instant wo, @Nullable PatchSet.Id psid) {
     return create(k, a, wo, psid, /*messageTemplate=*/ null, /*realAuthor=*/ null, /*tag=*/ null);
   }
 
   public static ChangeMessage create(
       final ChangeMessage.Key k,
       @Nullable Account.Id a,
-      Timestamp wo,
+      Instant wo,
       @Nullable PatchSet.Id psid,
       @Nullable String messageTemplate,
       @Nullable Account.Id realAuthor,
@@ -103,7 +103,7 @@
     return realAuthor != null ? realAuthor : getAuthor();
   }
 
-  public Timestamp getWrittenOn() {
+  public Instant getWrittenOn() {
     return writtenOn;
   }
 
diff --git a/java/com/google/gerrit/entities/ChangeSizeBucket.java b/java/com/google/gerrit/entities/ChangeSizeBucket.java
new file mode 100644
index 0000000..4d01ebd
--- /dev/null
+++ b/java/com/google/gerrit/entities/ChangeSizeBucket.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.entities;
+
+/**
+ * A human-readable change size bucket.
+ *
+ * <p>Should be kept in sync with
+ * polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+ */
+public class ChangeSizeBucket {
+
+  /**
+   * The upper bounds for the different size buckets.
+   *
+   * <p>Same as gr-change-list-item.ts::ChangeSize.
+   */
+  private enum BucketThresholds {
+    XS(10),
+    SMALL(50),
+    MEDIUM(250),
+    LARGE(1000);
+
+    public final long delta;
+
+    BucketThresholds(long delta) {
+      this.delta = delta;
+    }
+  }
+
+  /**
+   * Gets the correlative size bucket for the given change delta.
+   *
+   * <p>Same as gr-change-list-item.ts::computeChangeSize().
+   *
+   * @param delta the total number of changed lines (additions+deletions) of the change.
+   * @return a short human-readable size bucket.
+   */
+  public static String getChangeSizeBucket(long delta) {
+    if (delta == 0) {
+      return "NoOp";
+    } else if (delta < BucketThresholds.XS.delta) {
+      return "XS";
+    } else if (delta < BucketThresholds.SMALL.delta) {
+      return "S";
+    } else if (delta < BucketThresholds.MEDIUM.delta) {
+      return "M";
+    } else if (delta < BucketThresholds.LARGE.delta) {
+      return "L";
+    }
+    return "XL";
+  }
+}
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 37b8620..e1e143c 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -18,6 +18,7 @@
 import com.google.common.base.MoreObjects.ToStringHelper;
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Comparator;
 import java.util.Objects;
 import org.eclipse.jgit.lib.AnyObjectId;
@@ -48,6 +49,7 @@
       return code;
     }
 
+    @Nullable
     public static Status forCode(char c) {
       for (Status s : Status.values()) {
         if (s.code == c) {
@@ -88,7 +90,7 @@
         Key k = (Key) o;
         return Objects.equals(uuid, k.uuid)
             && Objects.equals(filename, k.filename)
-            && Objects.equals(patchSetId, k.patchSetId);
+            && patchSetId == k.patchSetId;
       }
       return false;
     }
@@ -113,7 +115,7 @@
     @Override
     public boolean equals(Object o) {
       if (o instanceof Identity) {
-        return Objects.equals(id, ((Identity) o).id);
+        return id == ((Identity) o).id;
       }
       return false;
     }
@@ -180,10 +182,10 @@
     public boolean equals(Object o) {
       if (o instanceof Range) {
         Range r = (Range) o;
-        return Objects.equals(startLine, r.startLine)
-            && Objects.equals(startChar, r.startChar)
-            && Objects.equals(endLine, r.endLine)
-            && Objects.equals(endChar, r.endChar);
+        return startLine == r.startLine
+            && startChar == r.startChar
+            && endLine == r.endLine
+            && endChar == r.endChar;
       }
       return false;
     }
@@ -215,7 +217,10 @@
 
   public Identity author;
   protected Identity realAuthor;
+
+  // TODO(issue-15525): Migrate this field from Timestamp to Instant
   public Timestamp writtenOn;
+
   public short side;
   public String message;
   public String parentUuid;
@@ -233,13 +238,7 @@
   public String serverId;
 
   public Comment(Comment c) {
-    this(
-        new Key(c.key),
-        c.author.getId(),
-        new Timestamp(c.writtenOn.getTime()),
-        c.side,
-        c.message,
-        c.serverId);
+    this(new Key(c.key), c.author.getId(), c.writtenOn.toInstant(), c.side, c.message, c.serverId);
     this.lineNbr = c.lineNbr;
     this.realAuthor = c.realAuthor;
     this.parentUuid = c.parentUuid;
@@ -249,24 +248,23 @@
   }
 
   public Comment(
-      Key key,
-      Account.Id author,
-      Timestamp writtenOn,
-      short side,
-      String message,
-      String serverId) {
+      Key key, Account.Id author, Instant writtenOn, short side, String message, String serverId) {
     this.key = key;
     this.author = new Comment.Identity(author);
     this.realAuthor = this.author;
-    this.writtenOn = writtenOn;
+    this.writtenOn = Timestamp.from(writtenOn);
     this.side = side;
     this.message = message;
     this.serverId = serverId;
   }
 
+  public void setWrittenOn(Instant writtenOn) {
+    this.writtenOn = Timestamp.from(writtenOn);
+  }
+
   public void setLineNbrAndRange(
       Integer lineNbr, com.google.gerrit.extensions.client.Comment.Range range) {
-    this.lineNbr = lineNbr != null ? lineNbr : range != null ? range.endLine : 0;
+    this.lineNbr = range != null ? range.endLine : lineNbr != null ? lineNbr : 0;
     if (range != null) {
       this.range = new Comment.Range(range);
     }
diff --git a/java/com/google/gerrit/entities/EmailHeader.java b/java/com/google/gerrit/entities/EmailHeader.java
index bf5a644..e43b6a3 100644
--- a/java/com/google/gerrit/entities/EmailHeader.java
+++ b/java/com/google/gerrit/entities/EmailHeader.java
@@ -19,7 +19,9 @@
 import com.google.common.base.MoreObjects;
 import java.io.IOException;
 import java.io.Writer;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -127,13 +129,13 @@
   }
 
   public static class Date extends EmailHeader {
-    private final java.util.Date value;
+    private final Instant value;
 
-    public Date(java.util.Date v) {
+    public Date(Instant v) {
       value = v;
     }
 
-    public java.util.Date getDate() {
+    public Instant getDate() {
       return value;
     }
 
@@ -144,10 +146,12 @@
 
     @Override
     public void write(Writer w) throws IOException {
-      final SimpleDateFormat fmt;
-      // Mon, 1 Jun 2009 10:49:44 -0700
-      fmt = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z", Locale.US);
-      w.write(fmt.format(value));
+      // Mon, 1 Jun 2009 10:49:44 +0000
+      w.write(
+          DateTimeFormatter.ofPattern("EEE, d MMM yyyy HH:mm:ss Z")
+              .withLocale(Locale.US)
+              .withZone(ZoneId.of("UTC"))
+              .format(value));
     }
 
     @Override
diff --git a/java/com/google/gerrit/entities/GroupDescription.java b/java/com/google/gerrit/entities/GroupDescription.java
index 7054bed..666e8f6 100644
--- a/java/com/google/gerrit/entities/GroupDescription.java
+++ b/java/com/google/gerrit/entities/GroupDescription.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.gerrit.common.Nullable;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Set;
 
 /** Group methods exposed by the GroupBackend. */
@@ -55,7 +55,7 @@
 
     boolean isVisibleToAll();
 
-    Timestamp getCreatedOn();
+    Instant getCreatedOn();
 
     Set<Account.Id> getMembers();
 
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
index 50bee8d..d287fa0 100644
--- a/java/com/google/gerrit/entities/HumanComment.java
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.entities;
 
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -33,7 +33,7 @@
   public HumanComment(
       Key key,
       Account.Id author,
-      Timestamp writtenOn,
+      Instant writtenOn,
       short side,
       String message,
       String serverId,
diff --git a/java/com/google/gerrit/entities/InternalGroup.java b/java/com/google/gerrit/entities/InternalGroup.java
index ebfa36a..43c3af3 100644
--- a/java/com/google/gerrit/entities/InternalGroup.java
+++ b/java/com/google/gerrit/entities/InternalGroup.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import java.io.Serializable;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectId;
 
 @AutoValue
@@ -42,7 +42,7 @@
 
   public abstract AccountGroup.UUID getGroupUUID();
 
-  public abstract Timestamp getCreatedOn();
+  public abstract Instant getCreatedOn();
 
   public abstract ImmutableSet<Account.Id> getMembers();
 
@@ -71,7 +71,7 @@
 
     public abstract Builder setGroupUUID(AccountGroup.UUID groupUuid);
 
-    public abstract Builder setCreatedOn(Timestamp createdOn);
+    public abstract Builder setCreatedOn(Instant createdOn);
 
     public abstract Builder setMembers(ImmutableSet<Account.Id> members);
 
diff --git a/java/com/google/gerrit/entities/KeyUtil.java b/java/com/google/gerrit/entities/KeyUtil.java
index 40fb757..0f14cd9 100644
--- a/java/com/google/gerrit/entities/KeyUtil.java
+++ b/java/com/google/gerrit/entities/KeyUtil.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.entities;
 
-import java.io.UnsupportedEncodingException;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import java.util.Arrays;
 
 public class KeyUtil {
@@ -48,14 +49,8 @@
     for (char i = 'a'; i <= 'f'; i++) hexb[i] = (byte) ((i - 'a') + 10);
   }
 
-  public static String encode(final String e) {
-    final byte[] b;
-    try {
-      b = e.getBytes("UTF-8");
-    } catch (UnsupportedEncodingException e1) {
-      throw new RuntimeException("No UTF-8 support", e1);
-    }
-
+  public static String encode(final String key) {
+    final byte[] b = key.getBytes(UTF_8);
     final StringBuilder r = new StringBuilder(b.length);
     for (int i = 0; i < b.length; i++) {
       final int c = b[i] & 0xff;
@@ -71,20 +66,20 @@
     return r.toString();
   }
 
-  public static String decode(final String e) {
-    if (e.indexOf('%') < 0) {
-      return e.replace('+', ' ');
+  public static String decode(final String key) {
+    if (key.indexOf('%') < 0) {
+      return key.replace('+', ' ');
     }
 
-    final byte[] b = new byte[e.length()];
+    final byte[] b = new byte[key.length()];
     int bPtr = 0;
     try {
-      for (int i = 0; i < e.length(); ) {
-        final char c = e.charAt(i);
-        if (c == '%' && i + 2 < e.length()) {
-          final int v = (hexb[e.charAt(i + 1)] << 4) | hexb[e.charAt(i + 2)];
+      for (int i = 0; i < key.length(); ) {
+        final char c = key.charAt(i);
+        if (c == '%' && i + 2 < key.length()) {
+          final int v = (hexb[key.charAt(i + 1)] << 4) | hexb[key.charAt(i + 2)];
           if (v < 0) {
-            throw new IllegalArgumentException(e.substring(i, i + 3));
+            throw new IllegalArgumentException(key.substring(i, i + 3));
           }
           b[bPtr++] = (byte) v;
           i += 3;
@@ -97,12 +92,8 @@
         }
       }
     } catch (ArrayIndexOutOfBoundsException err) {
-      throw new IllegalArgumentException("Bad encoding" + e, err);
+      throw new IllegalArgumentException("Bad encoding" + key, err);
     }
-    try {
-      return new String(b, 0, bPtr, "UTF-8");
-    } catch (UnsupportedEncodingException e1) {
-      throw new RuntimeException("No UTF-8 support", e1);
-    }
+    return new String(b, 0, bPtr, UTF_8);
   }
 }
diff --git a/java/com/google/gerrit/entities/LabelFunction.java b/java/com/google/gerrit/entities/LabelFunction.java
index f361741..d49ab0f 100644
--- a/java/com/google/gerrit/entities/LabelFunction.java
+++ b/java/com/google/gerrit/entities/LabelFunction.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.SubmitRecord.Label;
 import java.util.Collections;
@@ -48,6 +49,16 @@
     ALL = Collections.unmodifiableMap(all);
   }
 
+  public static final Map<String, LabelFunction> ALL_NON_DEPRECATED;
+
+  static {
+    Map<String, LabelFunction> allNonDeprecated = new LinkedHashMap<>();
+    for (LabelFunction f : ImmutableSet.of(NO_BLOCK, NO_OP, PATCH_SET_LOCK)) {
+      allNonDeprecated.put(f.getFunctionName(), f);
+    }
+    ALL_NON_DEPRECATED = Collections.unmodifiableMap(allNonDeprecated);
+  }
+
   public static Optional<LabelFunction> parse(@Nullable String str) {
     return Optional.ofNullable(ALL.get(str));
   }
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index d254752..f009872 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
 
@@ -30,15 +29,6 @@
 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;
-  public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
-  public static final boolean DEF_COPY_ANY_SCORE = false;
-  public static final boolean DEF_COPY_MAX_SCORE = false;
-  public static final boolean DEF_COPY_MIN_SCORE = false;
-  public static final ImmutableList<Short> DEF_COPY_VALUES = ImmutableList.of();
   public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
 
   public static LabelType withDefaultValues(String name) {
@@ -95,26 +85,10 @@
 
   public abstract String getName();
 
+  public abstract Optional<String> getDescription();
+
   public abstract LabelFunction getFunction();
 
-  public abstract boolean isCopyAnyScore();
-
-  public abstract boolean isCopyMinScore();
-
-  public abstract boolean isCopyMaxScore();
-
-  public abstract boolean isCopyAllScoresIfListOfFilesDidNotChange();
-
-  public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
-
-  public abstract boolean isCopyAllScoresOnTrivialRebase();
-
-  public abstract boolean isCopyAllScoresIfNoCodeChange();
-
-  public abstract boolean isCopyAllScoresIfNoChange();
-
-  public abstract ImmutableList<Short> getCopyValues();
-
   public abstract boolean isAllowPostSubmit();
 
   public abstract boolean isIgnoreSelfApproval();
@@ -141,24 +115,15 @@
   }
 
   public static LabelType.Builder builder(String name, List<LabelValue> valueList) {
-    return (new AutoValue_LabelType.Builder())
+    return new AutoValue_LabelType.Builder()
         .setName(name)
+        .setDescription(Optional.empty())
         .setValues(valueList)
         .setDefaultValue((short) 0)
         .setFunction(LabelFunction.MAX_WITH_BLOCK)
         .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)
-        .setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
-        .setCopyAnyScore(DEF_COPY_ANY_SCORE)
-        .setCopyMaxScore(DEF_COPY_MAX_SCORE)
-        .setCopyMinScore(DEF_COPY_MIN_SCORE)
-        .setCopyValues(DEF_COPY_VALUES)
         .setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT)
         .setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
   }
@@ -167,6 +132,7 @@
     return psa.labelId().get().equalsIgnoreCase(getName());
   }
 
+  @Nullable
   public LabelValue getMin() {
     if (getValues().isEmpty()) {
       return null;
@@ -174,6 +140,7 @@
     return getValues().get(0);
   }
 
+  @Nullable
   public LabelValue getMax() {
     if (getValues().isEmpty()) {
       return null;
@@ -182,11 +149,19 @@
   }
 
   public boolean isMaxNegative(PatchSetApproval ca) {
-    return getMaxNegative() == ca.value();
+    return isMaxNegative(ca.value());
+  }
+
+  public boolean isMaxNegative(short value) {
+    return getMaxNegative() == value;
   }
 
   public boolean isMaxPositive(PatchSetApproval ca) {
-    return getMaxPositive() == ca.value();
+    return isMaxPositive(ca.value());
+  }
+
+  public boolean isMaxPositive(short value) {
+    return getMaxPositive() == value;
   }
 
   public LabelValue getValue(short value) {
@@ -226,6 +201,8 @@
   public abstract static class Builder {
     public abstract Builder setName(String name);
 
+    public abstract Builder setDescription(Optional<String> description);
+
     public abstract Builder setFunction(LabelFunction function);
 
     public abstract Builder setCanOverride(boolean canOverride);
@@ -240,28 +217,8 @@
 
     public abstract Builder setDefaultValue(short defaultValue);
 
-    public abstract Builder setCopyAnyScore(boolean copyAnyScore);
-
     public abstract Builder setCopyCondition(@Nullable String copyCondition);
 
-    public abstract Builder setCopyMinScore(boolean copyMinScore);
-
-    public abstract Builder setCopyMaxScore(boolean copyMaxScore);
-
-    public abstract Builder setCopyAllScoresIfListOfFilesDidNotChange(
-        boolean copyAllScoresIfListOfFilesDidNotChange);
-
-    public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
-        boolean copyAllScoresOnMergeFirstParentUpdate);
-
-    public abstract Builder setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase);
-
-    public abstract Builder setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange);
-
-    public abstract Builder setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange);
-
-    public abstract Builder setCopyValues(Collection<Short> copyValues);
-
     public abstract Builder setMaxNegative(short maxNegative);
 
     public abstract Builder setMaxPositive(short maxPositive);
@@ -270,8 +227,6 @@
 
     protected abstract String getName();
 
-    protected abstract ImmutableList<Short> getCopyValues();
-
     protected abstract Builder setByValue(ImmutableMap<Short, LabelValue> byValue);
 
     @Nullable
@@ -303,8 +258,6 @@
       }
       setByValue(byValue.build());
 
-      setCopyValues(ImmutableList.sortedCopyOf(getCopyValues()));
-
       return autoBuild();
     }
   }
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
index 55a9976..a2f2e0b 100644
--- a/java/com/google/gerrit/entities/LabelTypes.java
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -69,7 +69,7 @@
 
   public Comparator<String> nameComparator() {
     final Map<String, Integer> positions = positions();
-    return new Comparator<String>() {
+    return new Comparator<>() {
       @Override
       public int compare(String left, String right) {
         int lp = position(left);
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index 2d28046..bef6580 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Splitter;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import java.util.List;
 
@@ -112,6 +113,7 @@
     }
 
     @UsedAt(UsedAt.Project.COLLABNET)
+    @Nullable
     public static ChangeType forCode(char c) {
       for (ChangeType s : ChangeType.values()) {
         if (s.code == c) {
@@ -168,33 +170,40 @@
    */
   public enum FileMode implements CodedEnum {
     /** Mode indicating an entry is a tree (aka directory). */
-    TREE('T'),
+    TREE('T', 0040000),
 
     /** Mode indicating an entry is a symbolic link. */
-    SYMLINK('S'),
+    SYMLINK('S', 0120000),
 
     /** Mode indicating an entry is a non-executable file. */
-    REGULAR_FILE('R'),
+    REGULAR_FILE('R', 0100644),
 
     /** Mode indicating an entry is an executable file. */
-    EXECUTABLE_FILE('E'),
+    EXECUTABLE_FILE('E', 0100755),
 
     /** Mode indicating an entry is a submodule commit in another repository. */
-    GITLINK('G'),
+    GITLINK('G', 0160000),
 
     /** Mode indicating an entry is missing during parallel walks. */
-    MISSING('M');
+    MISSING('M', 0000000);
 
     private final char code;
 
-    FileMode(char c) {
+    private final int mode;
+
+    FileMode(char c, int m) {
       code = c;
+      mode = m;
     }
 
     @Override
     public char getCode() {
       return code;
     }
+
+    public int getMode() {
+      return mode;
+    }
   }
 
   private Patch() {}
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index b26e5c3..354981c 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -22,7 +22,9 @@
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Ints;
-import java.sql.Timestamp;
+import com.google.errorprone.annotations.InlineMe;
+import com.google.gerrit.common.Nullable;
+import java.time.Instant;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -41,6 +43,9 @@
    * @deprecated use isChangeRef instead.
    */
   @Deprecated
+  @InlineMe(
+      replacement = "PatchSet.isChangeRef(name)",
+      imports = "com.google.gerrit.entities.PatchSet")
   public static boolean isRef(String name) {
     return isChangeRef(name);
   }
@@ -62,7 +67,7 @@
   }
 
   @AutoValue
-  public abstract static class Id {
+  public abstract static class Id implements Comparable<Id> {
     /** Parse a PatchSet.Id out of a string representation. */
     public static Id parse(String str) {
       List<String> parts = Splitter.on(',').splitToList(str);
@@ -79,6 +84,7 @@
     }
 
     /** Parse a PatchSet.Id from a {@link #refName()} result. */
+    @Nullable
     public static Id fromRef(String ref) {
       int cs = Change.Id.startIndex(ref);
       if (cs < 0) {
@@ -141,6 +147,11 @@
     public final String toString() {
       return getCommaSeparatedChangeAndPatchSetId();
     }
+
+    @Override
+    public int compareTo(Id other) {
+      return Ints.compare(get(), other.get());
+    }
   }
 
   public static Builder builder() {
@@ -159,7 +170,7 @@
 
     public abstract Builder uploader(Account.Id uploader);
 
-    public abstract Builder createdOn(Timestamp createdOn);
+    public abstract Builder createdOn(Instant createdOn);
 
     public abstract Builder groups(Iterable<String> groups);
 
@@ -206,7 +217,7 @@
    * Gerrit, and the old data erroneously did not include a {@code createdOn}, then this method will
    * return a timestamp of 0.
    */
-  public abstract Timestamp createdOn();
+  public abstract Instant createdOn();
 
   /**
    * Opaque group identifier, usually assigned during creation.
diff --git a/java/com/google/gerrit/entities/PatchSetApproval.java b/java/com/google/gerrit/entities/PatchSetApproval.java
index f853f77..608cf0d 100644
--- a/java/com/google/gerrit/entities/PatchSetApproval.java
+++ b/java/com/google/gerrit/entities/PatchSetApproval.java
@@ -16,8 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.primitives.Shorts;
-import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 import java.util.Optional;
 
 /** An approval (or negative approval) on a patch set. */
@@ -40,6 +39,36 @@
     }
   }
 
+  /**
+   * Globally unique identifier.
+   *
+   * <p>The identifier is unique to each granted approval, i.e. approvals, re-added within same
+   * {@link Change} or even {@link PatchSet} have different {@link UUID}.
+   */
+  @AutoValue
+  public abstract static class UUID implements Comparable<UUID> {
+
+    abstract String uuid();
+
+    public String get() {
+      return uuid();
+    }
+
+    @Override
+    public final int compareTo(UUID o) {
+      return uuid().compareTo(o.uuid());
+    }
+
+    @Override
+    public final String toString() {
+      return get();
+    }
+  }
+
+  public static UUID uuid(String n) {
+    return new AutoValue_PatchSetApproval_UUID(n);
+  }
+
   public static Builder builder() {
     return new AutoValue_PatchSetApproval.Builder().postSubmit(false).copied(false);
   }
@@ -50,17 +79,24 @@
 
     public abstract Key key();
 
+    /**
+     * {@link UUID} of {@link PatchSetApproval}.
+     *
+     * <p>Optional, since it might be missing for approvals, granted (persisted in NoteDB), before
+     * {@link UUID} was introduced and does not apply to removals ( represented as approval with
+     * {@link #value}, set to '0').
+     */
+    public abstract Builder uuid(Optional<UUID> uuid);
+
+    public abstract Builder uuid(UUID uuid);
+
     public abstract Builder value(short value);
 
     public Builder value(int value) {
       return value(Shorts.checkedCast(value));
     }
 
-    public abstract Builder granted(Timestamp granted);
-
-    public Builder granted(Date granted) {
-      return granted(new Timestamp(granted.getTime()));
-    }
+    public abstract Builder granted(Instant granted);
 
     public abstract Builder tag(String tag);
 
@@ -86,6 +122,8 @@
 
   public abstract Key key();
 
+  public abstract Optional<UUID> uuid();
+
   /**
    * Value assigned by the user.
    *
@@ -104,7 +142,7 @@
    */
   public abstract short value();
 
-  public abstract Timestamp granted();
+  public abstract Instant granted();
 
   public abstract Optional<String> tag();
 
@@ -117,8 +155,24 @@
 
   public abstract Builder toBuilder();
 
+  /**
+   * Makes a copy of {@link PatchSetApproval} that applies to {@code psId}.
+   *
+   * <p>The returned {@link PatchSetApproval} has the same {@link UUID} as the original {@link
+   * PatchSetApproval}, which is generated when it is originally granted.
+   *
+   * <p>This is needed since we want to keep the link between the original {@link PatchSetApproval}
+   * and the {@link #copied} one.
+   *
+   * @param psId {@link PatchSet.Id} of {@link PatchSet} that the copy should be applied to.
+   * @return {@link #copied} {@link PatchSetApproval} that applies to {@code psId}.
+   */
   public PatchSetApproval copyWithPatchSet(PatchSet.Id psId) {
-    return toBuilder().key(key(psId, key().accountId(), key().labelId())).copied(true).build();
+    return toBuilder()
+        .key(key(psId, key().accountId(), key().labelId()))
+        .uuid(uuid())
+        .copied(true)
+        .build();
   }
 
   public PatchSet.Id patchSetId() {
diff --git a/java/com/google/gerrit/entities/PatchSetApprovals.java b/java/com/google/gerrit/entities/PatchSetApprovals.java
new file mode 100644
index 0000000..8fed9c0
--- /dev/null
+++ b/java/com/google/gerrit/entities/PatchSetApprovals.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Multimaps;
+
+/** All approvals of a change by patch set. */
+@AutoValue
+public abstract class PatchSetApprovals {
+  /**
+   * Returns all approvals by patch set, including copied approvals
+   *
+   * <p>Approvals that have been copied from a previous patch set are returned as part of the
+   * result. These approvals can be identified by looking at {@link PatchSetApproval#copied()}.
+   */
+  public abstract ImmutableListMultimap<PatchSet.Id, PatchSetApproval> all();
+
+  /**
+   * Returns non-copied approvals by patch set.
+   *
+   * <p>Approvals that have been copied from a previous patch set are filtered out.
+   */
+  @Memoized
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> onlyNonCopied() {
+    return ImmutableListMultimap.copyOf(
+        Multimaps.filterEntries(all(), entry -> !entry.getValue().copied()));
+  }
+
+  /**
+   * Returns copied approvals by patch set.
+   *
+   * <p>Approvals that have not been copied from a previous patch set are filtered out.
+   */
+  @Memoized
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> onlyCopied() {
+    return ImmutableListMultimap.copyOf(
+        Multimaps.filterEntries(all(), entry -> entry.getValue().copied()));
+  }
+
+  public static PatchSetApprovals create(
+      ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsByPatchSet) {
+    return new AutoValue_PatchSetApprovals(approvalsByPatchSet);
+  }
+}
diff --git a/java/com/google/gerrit/entities/PatchSetInfo.java b/java/com/google/gerrit/entities/PatchSetInfo.java
index e3c6613..5770a7e 100644
--- a/java/com/google/gerrit/entities/PatchSetInfo.java
+++ b/java/com/google/gerrit/entities/PatchSetInfo.java
@@ -31,33 +31,33 @@
       this.shortMessage = requireNonNull(shortMessage);
     }
 
-    protected ParentInfo() {}
+    ParentInfo() {}
   }
 
-  protected PatchSet.Id key;
+  private PatchSet.Id key;
 
   /** First line of {@link #message}. */
-  protected String subject;
+  private String subject;
 
   /** The complete description of the change the patch set introduces. */
-  protected String message;
+  private String message;
 
   /** Identity of who wrote the patch set. May differ from {@link #committer}. */
-  protected UserIdentity author;
+  private UserIdentity author;
 
   /** Identity of who committed the patch set to the VCS. */
-  protected UserIdentity committer;
+  private UserIdentity committer;
 
   /** List of parents of the patch set. */
-  protected List<ParentInfo> parents;
+  private List<ParentInfo> parents;
 
   /** ID of commit. */
-  protected ObjectId commitId;
+  private ObjectId commitId;
 
   /** Optional user-supplied description for the patch set. */
-  protected String description;
+  private String description;
 
-  protected PatchSetInfo() {}
+  PatchSetInfo() {}
 
   public PatchSetInfo(PatchSet.Id k) {
     key = k;
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 95164bd..d029fad 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -43,6 +43,7 @@
   public static final String FORGE_SERVER = "forgeServerAsCommitter";
   public static final String LABEL = "label-";
   public static final String LABEL_AS = "labelAs-";
+  public static final String REMOVE_LABEL = "removeLabel-";
   public static final String OWNER = "owner";
   public static final String PUSH = "push";
   public static final String PUSH_MERGE = "pushMerge";
@@ -60,6 +61,7 @@
   private static final List<String> NAMES_LC;
   private static final int LABEL_INDEX;
   private static final int LABEL_AS_INDEX;
+  private static final int REMOVE_LABEL_INDEX;
 
   static {
     NAMES_LC = new ArrayList<>();
@@ -79,6 +81,7 @@
     NAMES_LC.add(FORGE_SERVER.toLowerCase());
     NAMES_LC.add(LABEL.toLowerCase());
     NAMES_LC.add(LABEL_AS.toLowerCase());
+    NAMES_LC.add(REMOVE_LABEL.toLowerCase());
     NAMES_LC.add(OWNER.toLowerCase());
     NAMES_LC.add(PUSH.toLowerCase());
     NAMES_LC.add(PUSH_MERGE.toLowerCase());
@@ -93,15 +96,19 @@
 
     LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
     LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
+    REMOVE_LABEL_INDEX = NAMES_LC.indexOf(Permission.REMOVE_LABEL.toLowerCase());
   }
 
   /** Returns true if the name is recognized as a permission name. */
   public static boolean isPermission(String varName) {
-    return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
+    return isLabel(varName)
+        || isLabelAs(varName)
+        || isRemoveLabel(varName)
+        || NAMES_LC.contains(varName.toLowerCase());
   }
 
   public static boolean hasRange(String varName) {
-    return isLabel(varName) || isLabelAs(varName);
+    return isLabel(varName) || isLabelAs(varName) || isRemoveLabel(varName);
   }
 
   /** Returns true if the permission name is actually for a review label. */
@@ -114,6 +121,11 @@
     return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
   }
 
+  /** Returns true if the permission is for impersonated review labels. */
+  public static boolean isRemoveLabel(String var) {
+    return var.startsWith(REMOVE_LABEL) && REMOVE_LABEL.length() < var.length();
+  }
+
   /** Returns permission name for the given review label. */
   public static String forLabel(String labelName) {
     return LABEL + labelName;
@@ -124,11 +136,19 @@
     return LABEL_AS + labelName;
   }
 
+  /** Returns permission name to remove a label for another user. */
+  public static String forRemoveLabel(String labelName) {
+    return REMOVE_LABEL + labelName;
+  }
+
+  @Nullable
   public static String extractLabel(String varName) {
     if (isLabel(varName)) {
       return varName.substring(LABEL.length());
     } else if (isLabelAs(varName)) {
       return varName.substring(LABEL_AS.length());
+    } else if (isRemoveLabel(varName)) {
+      return varName.substring(REMOVE_LABEL.length());
     }
     return null;
   }
@@ -204,6 +224,8 @@
       return LABEL_INDEX;
     } else if (isLabelAs(a.getName())) {
       return LABEL_AS_INDEX;
+    } else if (isRemoveLabel(a.getName())) {
+      return REMOVE_LABEL_INDEX;
     }
 
     int index = NAMES_LC.indexOf(a.getName().toLowerCase());
@@ -277,7 +299,10 @@
 
     public Permission build() {
       setRules(
-          rulesBuilders.stream().map(PermissionRule.Builder::build).collect(toImmutableList()));
+          rulesBuilders.stream()
+              .map(PermissionRule.Builder::build)
+              .distinct()
+              .collect(toImmutableList()));
       return autoBuild();
     }
 
diff --git a/java/com/google/gerrit/entities/PermissionRule.java b/java/com/google/gerrit/entities/PermissionRule.java
index 9a2d31e..1665c1c 100644
--- a/java/com/google/gerrit/entities/PermissionRule.java
+++ b/java/com/google/gerrit/entities/PermissionRule.java
@@ -202,6 +202,9 @@
         int dotdot = range.indexOf("..");
         int min = parseInt(range.substring(0, dotdot));
         int max = parseInt(range.substring(dotdot + 2));
+        if (min > max) {
+          throw new IllegalArgumentException("Invalid range in rule: " + orig);
+        }
         rule.setRange(min, max);
       } else {
         throw new IllegalArgumentException("Invalid range in rule: " + orig);
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 617b827..b587b1d 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -166,6 +166,7 @@
    * @return name key of the parent project, {@code null} if this project is the All-Projects
    *     project
    */
+  @Nullable
   public Project.NameKey getParent(Project.NameKey allProjectsName) {
     if (getParent() != null) {
       return getParent();
@@ -178,6 +179,7 @@
     return allProjectsName;
   }
 
+  @Nullable
   public String getParentName() {
     return getParent() != null ? getParent().get() : null;
   }
diff --git a/java/com/google/gerrit/entities/ProjectChangeKey.java b/java/com/google/gerrit/entities/ProjectChangeKey.java
new file mode 100644
index 0000000..15dc5e9
--- /dev/null
+++ b/java/com/google/gerrit/entities/ProjectChangeKey.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import com.google.auto.value.AutoValue;
+
+/** Stores together numeric {@link Change.Id} and a project name for the change */
+@AutoValue
+public abstract class ProjectChangeKey {
+  public static ProjectChangeKey create(Project.NameKey projectName, Change.Id changeId) {
+    return new AutoValue_ProjectChangeKey(projectName, changeId);
+  }
+
+  public abstract Project.NameKey projectName();
+
+  public abstract Change.Id changeId();
+}
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 349b67e..9745fc5 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.entities;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
+import java.util.List;
 
 /** Constants and utilities for Gerrit-specific ref names. */
 public class RefNames {
@@ -126,6 +129,8 @@
           REFS_STARRED_CHANGES,
           REFS_REJECT_COMMITS);
 
+  private static final Splitter SPLITTER = Splitter.on("/");
+
   public static String fullName(String ref) {
     return (ref.startsWith(REFS) || ref.equals(HEAD)) ? ref : REFS_HEADS + ref;
   }
@@ -218,6 +223,7 @@
     return REFS_CACHE_AUTOMERGE + hash.substring(0, 2) + '/' + hash.substring(2);
   }
 
+  @Nullable
   public static String shard(int id) {
     if (id < 0) {
       return null;
@@ -339,21 +345,22 @@
     return GERRIT_REFS.stream().anyMatch(internalRef -> ref.startsWith(internalRef));
   }
 
+  @Nullable
   static Integer parseShardedRefPart(String name) {
     if (name == null) {
       return null;
     }
 
-    String[] parts = name.split("/");
-    int n = parts.length;
+    List<String> parts = SPLITTER.splitToList(name);
+    int n = parts.size();
     if (n < 2) {
       return null;
     }
 
     // Last 2 digits.
     int le;
-    for (le = 0; le < parts[0].length(); le++) {
-      if (!Character.isDigit(parts[0].charAt(le))) {
+    for (le = 0; le < parts.get(0).length(); le++) {
+      if (!Character.isDigit(parts.get(0).charAt(le))) {
         return null;
       }
     }
@@ -363,8 +370,8 @@
 
     // Full ID.
     int ie;
-    for (ie = 0; ie < parts[1].length(); ie++) {
-      if (!Character.isDigit(parts[1].charAt(ie))) {
+    for (ie = 0; ie < parts.get(1).length(); ie++) {
+      if (!Character.isDigit(parts.get(1).charAt(ie))) {
         if (ie == 0) {
           return null;
         }
@@ -372,8 +379,8 @@
       }
     }
 
-    int shard = Integer.parseInt(parts[0]);
-    int id = Integer.parseInt(parts[1].substring(0, ie));
+    int shard = Integer.parseInt(parts.get(0));
+    int id = Integer.parseInt(parts.get(1).substring(0, ie));
 
     if (id % 100 != shard) {
       return null;
@@ -382,25 +389,26 @@
   }
 
   @UsedAt(UsedAt.Project.PLUGINS_ALL)
+  @Nullable
   public static String parseShardedUuidFromRefPart(String name) {
     if (name == null) {
       return null;
     }
 
-    String[] parts = name.split("/");
-    int n = parts.length;
+    List<String> parts = SPLITTER.splitToList(name);
+    int n = parts.size();
     if (n != 2) {
       return null;
     }
 
     // First 2 chars.
-    if (parts[0].length() != 2) {
+    if (parts.get(0).length() != 2) {
       return null;
     }
 
     // Full UUID.
-    String uuid = parts[1];
-    if (!uuid.startsWith(parts[0])) {
+    String uuid = parts.get(1);
+    if (!uuid.startsWith(parts.get(0))) {
       return null;
     }
 
@@ -416,21 +424,22 @@
    * @return the rest of the name, {@code null} if the ref name part doesn't start with a valid
    *     sharded ID
    */
+  @Nullable
   static String skipShardedRefPart(String name) {
     if (name == null) {
       return null;
     }
 
-    String[] parts = name.split("/");
-    int n = parts.length;
+    List<String> parts = SPLITTER.splitToList(name);
+    int n = parts.size();
     if (n < 2) {
       return null;
     }
 
     // Last 2 digits.
     int le;
-    for (le = 0; le < parts[0].length(); le++) {
-      if (!Character.isDigit(parts[0].charAt(le))) {
+    for (le = 0; le < parts.get(0).length(); le++) {
+      if (!Character.isDigit(parts.get(0).charAt(le))) {
         return null;
       }
     }
@@ -440,8 +449,8 @@
 
     // Full ID.
     int ie;
-    for (ie = 0; ie < parts[1].length(); ie++) {
-      if (!Character.isDigit(parts[1].charAt(ie))) {
+    for (ie = 0; ie < parts.get(1).length(); ie++) {
+      if (!Character.isDigit(parts.get(1).charAt(ie))) {
         if (ie == 0) {
           return null;
         }
@@ -449,8 +458,8 @@
       }
     }
 
-    int shard = Integer.parseInt(parts[0]);
-    int id = Integer.parseInt(parts[1].substring(0, ie));
+    int shard = Integer.parseInt(parts.get(0));
+    int id = Integer.parseInt(parts.get(1).substring(0, ie));
 
     if (id % 100 != shard) {
       return null;
@@ -469,6 +478,7 @@
    *     ref name part doesn't start with a valid sharded ID or if no valid ID follows the sharded
    *     ref part
    */
+  @Nullable
   static Integer parseAfterShardedRefPart(String name) {
     String rest = skipShardedRefPart(name);
     if (rest == null || !rest.startsWith("/")) {
@@ -489,6 +499,7 @@
     return Integer.parseInt(rest.substring(0, ie));
   }
 
+  @Nullable
   public static Integer parseRefSuffix(String name) {
     if (name == null) {
       return null;
diff --git a/java/com/google/gerrit/entities/RobotComment.java b/java/com/google/gerrit/entities/RobotComment.java
index e2e4114..1d46d3b 100644
--- a/java/com/google/gerrit/entities/RobotComment.java
+++ b/java/com/google/gerrit/entities/RobotComment.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.entities;
 
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -29,7 +29,7 @@
   public RobotComment(
       Key key,
       Account.Id author,
-      Timestamp writtenOn,
+      Instant writtenOn,
       short side,
       String message,
       String serverId,
diff --git a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
index f298782..5ee76da 100644
--- a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -30,13 +30,28 @@
   @Nullable
   public abstract String getMatch();
 
-  /** The link to replace the match with. This can only be set if html is {@code null}. */
+  /**
+   * The link to replace the match with.
+   *
+   * <p>The constructed link is using {@link #getLink()} {@link #getPrefix()} {@link #getSuffix()}
+   * and {@link #getText()}, and has the shape of
+   *
+   * <p>{@code PREFIX<a href="LINK">TEXT</a>SUFFIX}
+   */
   @Nullable
   public abstract String getLink();
 
-  /** The html to replace the match with. This can only be set if link is {@code null}. */
+  /** The optional text before the link tag that the match is replaced with. */
   @Nullable
-  public abstract String getHtml();
+  public abstract String getPrefix();
+
+  /** The optional text after the link tag that the match is replaced with. */
+  @Nullable
+  public abstract String getSuffix();
+
+  /** The content of the link tag that the match is replaced with. If not set full match is used. */
+  @Nullable
+  public abstract String getText();
 
   /** Weather this comment link is active. {@code null} means true. */
   @Nullable
@@ -58,7 +73,7 @@
    * on it's own.
    */
   public static StoredCommentLinkInfo disabled(String name) {
-    return builder(name).setOverrideOnly(true).build();
+    return builder(name).setOverrideOnly(true).setEnabled(false).build();
   }
 
   /** Creates and returns a new {@link StoredCommentLinkInfo.Builder} instance. */
@@ -72,7 +87,9 @@
     return builder(src.name)
         .setMatch(src.match)
         .setLink(src.link)
-        .setHtml(src.html)
+        .setPrefix(src.prefix)
+        .setSuffix(src.suffix)
+        .setText(src.text)
         .setEnabled(enabled)
         .setOverrideOnly(false)
         .build();
@@ -84,7 +101,9 @@
     info.name = getName();
     info.match = getMatch();
     info.link = getLink();
-    info.html = getHtml();
+    info.prefix = getPrefix();
+    info.suffix = getSuffix();
+    info.text = getText();
     info.enabled = getEnabled();
     return info;
   }
@@ -97,7 +116,11 @@
 
     public abstract Builder setLink(@Nullable String value);
 
-    public abstract Builder setHtml(@Nullable String value);
+    public abstract Builder setPrefix(@Nullable String value);
+
+    public abstract Builder setSuffix(@Nullable String value);
+
+    public abstract Builder setText(@Nullable String value);
 
     public abstract Builder setEnabled(@Nullable Boolean value);
 
@@ -105,14 +128,15 @@
 
     public StoredCommentLinkInfo build() {
       checkArgument(getName() != null, "invalid commentlink.name");
-      setLink(Strings.emptyToNull(getLink()));
-      setHtml(Strings.emptyToNull(getHtml()));
+      setPrefix(Strings.emptyToNull(getPrefix()));
+      setSuffix(Strings.emptyToNull(getSuffix()));
+      setText(Strings.emptyToNull(getText()));
       if (!getOverrideOnly()) {
         checkArgument(
             !Strings.isNullOrEmpty(getMatch()), "invalid commentlink.%s.match", getName());
         checkArgument(
-            (getLink() != null && getHtml() == null) || (getLink() == null && getHtml() != null),
-            "commentlink.%s must have either link or html",
+            !Strings.isNullOrEmpty(getLink()),
+            "commentlink.%s must have link specified",
             getName());
       }
       return autoBuild();
@@ -126,7 +150,11 @@
 
     protected abstract String getLink();
 
-    protected abstract String getHtml();
+    protected abstract String getPrefix();
+
+    protected abstract String getSuffix();
+
+    protected abstract String getText();
 
     protected abstract boolean getOverrideOnly();
   }
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
index 95ad9f8..4142b42 100644
--- a/java/com/google/gerrit/entities/SubmitRecord.java
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -70,7 +70,7 @@
 
   // Name of the rule that created this submit record, formatted as '$pluginName~$ruleName'
   public String ruleName;
-  public Status status;
+  public SubmitRecord.Status status;
   public List<Label> labels;
   public List<LegacySubmitRequirement> requirements;
   public String errorMessage;
@@ -113,7 +113,7 @@
     }
 
     public String label;
-    public Status status;
+    public Label.Status status;
     public Account.Id appliedBy;
 
     /**
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
index 13e0b53..523b993 100644
--- a/java/com/google/gerrit/entities/SubmitRequirement.java
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -1,25 +1,37 @@
-//  Copyright (C) 2021 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.
-//  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.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import java.util.Optional;
 
-/** Entity describing a requirement that should be met for a change to become submittable. */
+/**
+ * Entity describing a requirement that should be met for a change to become submittable.
+ *
+ * <p>There are two ways to contribute {@link SubmitRequirement}:
+ *
+ * <ul>
+ *   <li>Set per-project in project.config (see {@link
+ *       com.google.gerrit.server.project.ProjectState#getSubmitRequirements()}
+ *   <li>Bind a global {@link SubmitRequirement} that will be evaluated for all projects.
+ * </ul>
+ */
+@ExtensionPoint
 @AutoValue
 public abstract class SubmitRequirement {
   /** Requirement name. */
@@ -32,7 +44,7 @@
    * Expression of the condition that makes the requirement applicable. The expression should be
    * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
    * irrelevant for the change (i.e. {@link #submittabilityExpression()} and {@link
-   * #overrideExpression()} become irrelevant).
+   * #overrideExpression()} are not evaluated).
    *
    * <p>An empty {@link Optional} indicates that the requirement is applicable for any change.
    */
@@ -56,7 +68,12 @@
 
   /**
    * Boolean value indicating if the {@link SubmitRequirement} definition can be overridden in child
-   * projects. Default is false.
+   * projects.
+   *
+   * <p>For globally bound {@link SubmitRequirement}, indicates if can be overridden by {@link
+   * SubmitRequirement} in project.config.
+   *
+   * <p>Default is false.
    */
   public abstract boolean allowOverrideInChildProjects();
 
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpression.java b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
index 2af1379..abac3e6 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpression.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
@@ -47,4 +47,12 @@
   public static TypeAdapter<SubmitRequirementExpression> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementExpression.GsonTypeAdapter(gson);
   }
+
+  /**
+   * Returns a "submit requirement" expression that requires the maximum vote on the Code-Review
+   * label.
+   */
+  public static SubmitRequirementExpression maxCodeReview() {
+    return SubmitRequirementExpression.create(String.format("label:Code-Review=MAX"));
+  }
 }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index 9e4416b..fbb2fd7 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -72,8 +72,17 @@
       Status status,
       ImmutableList<String> passingAtoms,
       ImmutableList<String> failingAtoms) {
+    return create(expression, status, passingAtoms, failingAtoms, Optional.empty());
+  }
+
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression,
+      Status status,
+      ImmutableList<String> passingAtoms,
+      ImmutableList<String> failingAtoms,
+      Optional<String> errorMessage) {
     return new AutoValue_SubmitRequirementExpressionResult(
-        expression, status, Optional.empty(), passingAtoms, failingAtoms);
+        expression, status, errorMessage, passingAtoms, failingAtoms);
   }
 
   public static SubmitRequirementExpressionResult error(
@@ -86,10 +95,32 @@
         ImmutableList.of());
   }
 
+  public static SubmitRequirementExpressionResult notEvaluated(SubmitRequirementExpression expr) {
+    return SubmitRequirementExpressionResult.create(
+        expr, Status.NOT_EVALUATED, ImmutableList.of(), ImmutableList.of());
+  }
+
   public static TypeAdapter<SubmitRequirementExpressionResult> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementExpressionResult.GsonTypeAdapter(gson);
   }
 
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder expression(SubmitRequirementExpression expression);
+
+    public abstract Builder status(Status status);
+
+    public abstract Builder errorMessage(Optional<String> errorMessage);
+
+    public abstract Builder passingAtoms(ImmutableList<String> passingAtoms);
+
+    public abstract Builder failingAtoms(ImmutableList<String> failingAtoms);
+
+    public abstract SubmitRequirementExpressionResult build();
+  }
+
   public enum Status {
     /** Submit requirement expression is fulfilled for a given change. */
     PASS,
@@ -98,7 +129,10 @@
     FAIL,
 
     /** Submit requirement expression contains invalid syntax and is not parsable. */
-    ERROR
+    ERROR,
+
+    /** Submit requirement expression was not evaluated. */
+    NOT_EVALUATED
   }
 
   /**
@@ -163,8 +197,6 @@
 
     @AutoValue.Builder
     public abstract static class Builder {
-      public abstract Builder childPredicateResults(ImmutableList<PredicateResult> value);
-
       protected abstract ImmutableList.Builder<PredicateResult> childPredicateResultsBuilder();
 
       public abstract Builder predicateString(String value);
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
index 13625c1..d9bb162 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -16,6 +16,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.extension.memoized.Memoized;
+import com.google.gerrit.common.Nullable;
 import com.google.gson.Gson;
 import com.google.gson.TypeAdapter;
 import java.util.Optional;
@@ -31,11 +32,18 @@
   public abstract Optional<SubmitRequirementExpressionResult> applicabilityExpressionResult();
 
   /**
-   * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} ()} on a change.
+   * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} on a change.
+   *
+   * <p>Empty if submit requirement does not apply.
    */
-  public abstract SubmitRequirementExpressionResult submittabilityExpressionResult();
+  public abstract Optional<SubmitRequirementExpressionResult> submittabilityExpressionResult();
 
-  /** Result of evaluating a {@link SubmitRequirement#overrideExpression()} ()} on a change. */
+  /**
+   * Result of evaluating a {@link SubmitRequirement#overrideExpression()} on a change.
+   *
+   * <p>Empty if submit requirement does not apply, or if the submit requirement did not define an
+   * override expression.
+   */
   public abstract Optional<SubmitRequirementExpressionResult> overrideExpressionResult();
 
   /** SHA-1 of the patchset commit ID for which the submit requirement was evaluated. */
@@ -53,9 +61,56 @@
     return legacy().orElse(false);
   }
 
+  /**
+   * Boolean indicating if the "submit requirement" was bypassed during submission, e.g. by
+   * performing a push with the %submit option.
+   */
+  public abstract Optional<Boolean> forced();
+
+  /**
+   * Whether this result should be filtered out when returned from REST API.
+   *
+   * <p>This can be used by {@link
+   * com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier}. It can override the
+   * {@code SubmitRequirementResult} status and might want to hide the SR from the API as if it was
+   * non-applicable (non-applicable SRs are currently hidden on UI).
+   */
+  public abstract Optional<Boolean> hidden();
+
+  public boolean isHidden() {
+    return hidden().orElse(false);
+  }
+
+  public Optional<String> errorMessage() {
+    if (!status().equals(Status.ERROR)) {
+      return Optional.empty();
+    }
+    if (applicabilityExpressionResult().isPresent()
+        && applicabilityExpressionResult().get().errorMessage().isPresent()) {
+      return Optional.of(
+          "Applicability expression result has an error: "
+              + applicabilityExpressionResult().get().errorMessage().get());
+    }
+    if (submittabilityExpressionResult().isPresent()
+        && submittabilityExpressionResult().get().errorMessage().isPresent()) {
+      return Optional.of(
+          "Submittability expression result has an error: "
+              + submittabilityExpressionResult().get().errorMessage().get());
+    }
+    if (overrideExpressionResult().isPresent()
+        && overrideExpressionResult().get().errorMessage().isPresent()) {
+      return Optional.of(
+          "Override expression result has an error: "
+              + overrideExpressionResult().get().errorMessage().get());
+    }
+    return Optional.of("No error logged.");
+  }
+
   @Memoized
   public Status status() {
-    if (assertError(submittabilityExpressionResult())
+    if (forced().orElse(false)) {
+      return Status.FORCED;
+    } else if (assertError(submittabilityExpressionResult())
         || assertError(applicabilityExpressionResult())
         || assertError(overrideExpressionResult())) {
       return Status.ERROR;
@@ -74,13 +129,18 @@
   @Memoized
   public boolean fulfilled() {
     Status s = status();
-    return s == Status.SATISFIED || s == Status.OVERRIDDEN || s == Status.NOT_APPLICABLE;
+    return s == Status.SATISFIED
+        || s == Status.OVERRIDDEN
+        || s == Status.NOT_APPLICABLE
+        || s == Status.FORCED;
   }
 
   public static Builder builder() {
     return new AutoValue_SubmitRequirementResult.Builder();
   }
 
+  public abstract Builder toBuilder();
+
   public static TypeAdapter<SubmitRequirementResult> typeAdapter(Gson gson) {
     return new AutoValue_SubmitRequirementResult.GsonTypeAdapter(gson);
   }
@@ -108,10 +168,16 @@
     NOT_APPLICABLE,
 
     /**
-     * Any of the applicability, blocking or override expressions contain invalid syntax and are not
-     * parsable.
+     * Any of the applicability, submittability or override expressions contain invalid syntax and
+     * are not parsable.
      */
-    ERROR
+    ERROR,
+
+    /**
+     * The "submit requirement" was bypassed during submission, e.g. by pushing for review with the
+     * %submit option.
+     */
+    FORCED
   }
 
   @AutoValue.Builder
@@ -121,7 +187,11 @@
     public abstract Builder applicabilityExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
 
-    public abstract Builder submittabilityExpressionResult(SubmitRequirementExpressionResult value);
+    public abstract Builder submittabilityExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract Builder submittabilityExpressionResult(
+        @Nullable SubmitRequirementExpressionResult value);
 
     public abstract Builder overrideExpressionResult(
         Optional<SubmitRequirementExpressionResult> value);
@@ -130,36 +200,32 @@
 
     public abstract Builder legacy(Optional<Boolean> value);
 
+    public abstract Builder forced(Optional<Boolean> value);
+
+    public abstract Builder hidden(Optional<Boolean> value);
+
     public abstract SubmitRequirementResult build();
   }
 
-  private boolean assertPass(Optional<SubmitRequirementExpressionResult> expressionResult) {
+  public static boolean assertPass(Optional<SubmitRequirementExpressionResult> expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
   }
 
-  private boolean assertPass(SubmitRequirementExpressionResult expressionResult) {
-    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
-  }
-
-  private boolean assertFail(Optional<SubmitRequirementExpressionResult> expressionResult) {
+  public static boolean assertFail(Optional<SubmitRequirementExpressionResult> expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.FAIL);
   }
 
-  private boolean assertError(Optional<SubmitRequirementExpressionResult> expressionResult) {
+  public static boolean assertError(Optional<SubmitRequirementExpressionResult> expressionResult) {
     return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
   }
 
-  private boolean assertError(SubmitRequirementExpressionResult expressionResult) {
-    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
-  }
-
-  private boolean assertStatus(
+  private static boolean assertStatus(
       SubmitRequirementExpressionResult expressionResult,
       SubmitRequirementExpressionResult.Status status) {
     return expressionResult.status() == status;
   }
 
-  private boolean assertStatus(
+  private static boolean assertStatus(
       Optional<SubmitRequirementExpressionResult> expressionResult,
       SubmitRequirementExpressionResult.Status status) {
     return expressionResult.isPresent() && assertStatus(expressionResult.get(), status);
diff --git a/java/com/google/gerrit/entities/SubmoduleSubscription.java b/java/com/google/gerrit/entities/SubmoduleSubscription.java
index 5ea1b1e..db26eb3 100644
--- a/java/com/google/gerrit/entities/SubmoduleSubscription.java
+++ b/java/com/google/gerrit/entities/SubmoduleSubscription.java
@@ -25,11 +25,11 @@
  * <p>A subscriber operates a submodule in defined path.
  */
 public final class SubmoduleSubscription {
-  protected BranchNameKey superProject;
+  private BranchNameKey superProject;
 
-  protected String submodulePath;
+  private String submodulePath;
 
-  protected BranchNameKey submodule;
+  private BranchNameKey submodule;
 
   public SubmoduleSubscription(BranchNameKey superProject, BranchNameKey submodule, String path) {
     this.superProject = superProject;
diff --git a/java/com/google/gerrit/entities/UserIdentity.java b/java/com/google/gerrit/entities/UserIdentity.java
index e07d21a..8334157 100644
--- a/java/com/google/gerrit/entities/UserIdentity.java
+++ b/java/com/google/gerrit/entities/UserIdentity.java
@@ -14,26 +14,26 @@
 
 package com.google.gerrit.entities;
 
-import java.sql.Timestamp;
+import java.time.Instant;
 
 public final class UserIdentity {
   /** Full name of the user. */
-  protected String name;
+  private String name;
 
   /** Email address (or user@host style string anyway). */
-  protected String email;
+  private String email;
 
   /** Username of the user. */
-  protected String username;
+  private String username;
 
   /** Time (in UTC) when the identity was constructed. */
-  protected Timestamp when;
+  private Instant when;
 
   /** Offset from UTC */
-  protected int tz;
+  private int tz;
 
   /** If the user has a Gerrit account, their account identity. */
-  protected Account.Id accountId;
+  private Account.Id accountId;
 
   public String getName() {
     return name;
@@ -55,11 +55,11 @@
     return username;
   }
 
-  public Timestamp getDate() {
+  public Instant getDate() {
     return when;
   }
 
-  public void setDate(Timestamp d) {
+  public void setDate(Instant d) {
     when = d;
   }
 
diff --git a/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
index eb2a381..edf921e 100644
--- a/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 @Immutable
@@ -44,9 +44,9 @@
     if (author != null) {
       builder.setAuthorId(accountIdConverter.toProto(author));
     }
-    Timestamp writtenOn = changeMessage.getWrittenOn();
+    Instant writtenOn = changeMessage.getWrittenOn();
     if (writtenOn != null) {
-      builder.setWrittenOn(writtenOn.getTime());
+      builder.setWrittenOn(writtenOn.toEpochMilli());
     }
     // Build proto with template representation of the message. Templates are parsed when message is
     // extracted from cache.
@@ -78,7 +78,7 @@
         proto.hasKey() ? changeMessageKeyConverter.fromProto(proto.getKey()) : null;
     Account.Id author =
         proto.hasAuthorId() ? accountIdConverter.fromProto(proto.getAuthorId()) : null;
-    Timestamp writtenOn = proto.hasWrittenOn() ? new Timestamp(proto.getWrittenOn()) : null;
+    Instant writtenOn = proto.hasWrittenOn() ? Instant.ofEpochMilli(proto.getWrittenOn()) : null;
     PatchSet.Id patchSetId =
         proto.hasPatchset() ? patchSetIdConverter.fromProto(proto.getPatchset()) : null;
     // Only template representation of the message is stored in entity. Templates should be replaced
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 689b4aa..4903364 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @Immutable
 public enum ChangeProtoConverter implements ProtoConverter<Entities.Change, Change> {
@@ -44,8 +44,8 @@
         Entities.Change.newBuilder()
             .setChangeId(changeIdConverter.toProto(change.getId()))
             .setChangeKey(changeKeyConverter.toProto(change.getKey()))
-            .setCreatedOn(change.getCreatedOn().getTime())
-            .setLastUpdatedOn(change.getLastUpdatedOn().getTime())
+            .setCreatedOn(change.getCreatedOn().toEpochMilli())
+            .setLastUpdatedOn(change.getLastUpdatedOn().toEpochMilli())
             .setOwnerAccountId(accountIdConverter.toProto(change.getOwner()))
             .setDest(branchNameConverter.toProto(change.getDest()))
             .setStatus(change.getStatus().getCode())
@@ -96,9 +96,9 @@
     BranchNameKey destination =
         proto.hasDest() ? branchNameConverter.fromProto(proto.getDest()) : null;
     Change change =
-        new Change(key, changeId, owner, destination, new Timestamp(proto.getCreatedOn()));
+        new Change(key, changeId, owner, destination, Instant.ofEpochMilli(proto.getCreatedOn()));
     if (proto.hasLastUpdatedOn()) {
-      change.setLastUpdatedOn(new Timestamp(proto.getLastUpdatedOn()));
+      change.setLastUpdatedOn(Instant.ofEpochMilli(proto.getLastUpdatedOn()));
     }
     Change.Status status = Change.Status.forCode((char) proto.getStatus());
     if (status != null) {
diff --git a/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
index 9e77025..e8ef346 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 @Immutable
@@ -38,10 +38,11 @@
         Entities.PatchSetApproval.newBuilder()
             .setKey(patchSetApprovalKeyProtoConverter.toProto(patchSetApproval.key()))
             .setValue(patchSetApproval.value())
-            .setGranted(patchSetApproval.granted().getTime())
+            .setGranted(patchSetApproval.granted().toEpochMilli())
             .setPostSubmit(patchSetApproval.postSubmit())
             .setCopied(patchSetApproval.copied());
 
+    patchSetApproval.uuid().ifPresent(uuid -> builder.setUuid(uuid.get()));
     patchSetApproval.tag().ifPresent(builder::setTag);
     Account.Id realAccountId = patchSetApproval.realAccountId();
     // PatchSetApproval#getRealAccountId automatically delegates to PatchSetApproval#getAccountId if
@@ -61,9 +62,12 @@
         PatchSetApproval.builder()
             .key(patchSetApprovalKeyProtoConverter.fromProto(proto.getKey()))
             .value(proto.getValue())
-            .granted(new Timestamp(proto.getGranted()))
+            .granted(Instant.ofEpochMilli(proto.getGranted()))
             .postSubmit(proto.getPostSubmit())
             .copied(proto.getCopied());
+    if (proto.hasUuid()) {
+      builder.uuid(PatchSetApproval.uuid(proto.getUuid()));
+    }
     if (proto.hasTag()) {
       builder.tag(proto.getTag());
     }
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index 13a6e71..210972d 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.protobuf.Parser;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -42,7 +42,7 @@
             .setId(patchSetIdConverter.toProto(patchSet.id()))
             .setCommitId(objectIdConverter.toProto(patchSet.commitId()))
             .setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
-            .setCreatedOn(patchSet.createdOn().getTime());
+            .setCreatedOn(patchSet.createdOn().toEpochMilli());
     List<String> groups = patchSet.groups();
     if (!groups.isEmpty()) {
       builder.setGroups(PatchSet.joinGroups(groups));
@@ -84,7 +84,8 @@
             proto.hasUploaderAccountId()
                 ? accountIdConverter.fromProto(proto.getUploaderAccountId())
                 : Account.id(0))
-        .createdOn(proto.hasCreatedOn() ? new Timestamp(proto.getCreatedOn()) : new Timestamp(0));
+        .createdOn(
+            proto.hasCreatedOn() ? Instant.ofEpochMilli(proto.getCreatedOn()) : Instant.EPOCH);
 
     return builder.build();
   }
diff --git a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java b/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
deleted file mode 100644
index 452192c..0000000
--- a/java/com/google/gerrit/exceptions/InternalServerWithUserMessageException.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.exceptions;
-
-public class InternalServerWithUserMessageException extends RuntimeException {
-  private static final long serialVersionUID = 1L;
-
-  public InternalServerWithUserMessageException(String msg, Throwable cause) {
-    super(msg, cause);
-  }
-}
diff --git a/java/com/google/gerrit/exceptions/MergeUpdateException.java b/java/com/google/gerrit/exceptions/MergeUpdateException.java
new file mode 100644
index 0000000..b60ca57
--- /dev/null
+++ b/java/com/google/gerrit/exceptions/MergeUpdateException.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.exceptions;
+
+/**
+ * An exception used for changes that fail to merge. This exception has a user visible message
+ * unlike other {@link RuntimeException}s, because this is our way to improve the UX when
+ * submission/merges fail.
+ */
+public class MergeUpdateException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public MergeUpdateException(String userVisibleMessage, Throwable cause) {
+    super(userVisibleMessage, cause);
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
index a62fc63..b3680ea 100644
--- a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
@@ -34,7 +34,7 @@
     requireNonNull(pluginName, "pluginName");
     requireNonNull(permission, "permission");
     checkArgument(
-        isValidPluginPermissionName(permission), "invalid plugin permission name: ", permission);
+        isValidPluginPermissionName(permission), "invalid plugin permission name: %s", permission);
 
     this.pluginName = pluginName;
     this.permission = permission;
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
new file mode 100644
index 0000000..493329c
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+/** Information about a patch to apply. */
+public class ApplyPatchInput {
+  /**
+   * Required. The patch to be applied.
+   *
+   * <p>Must be compatible with `git diff` output. For example, Gerrit API `Get Patch` output.
+   */
+  public String patch;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
new file mode 100644
index 0000000..872ea42
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+
+/** Information for creating a new patch set from a given patch. */
+public class ApplyPatchPatchSetInput {
+
+  /** The patch to be applied. */
+  public ApplyPatchInput patch;
+
+  /**
+   * The commit message for the new patch set. If not specified, a predefined message will be used.
+   */
+  @Nullable public String commitMessage;
+
+  /**
+   * 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created patch
+   * set. If set, it must be a merged commit or a change revision on the destination branch.
+   * Otherwise, the target change's branch tip will be used.
+   */
+  @Nullable public String base;
+
+  /**
+   * The author of the new patch set. Must include both {@link AccountInput#name} and {@link
+   * AccountInput#email} fields.
+   */
+  @Nullable public AccountInput author;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 2224649..0ebb859 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -27,12 +27,14 @@
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.Arrays;
 import java.util.Collection;
@@ -128,20 +130,6 @@
   }
 
   /**
-   * Ignore or un-ignore this change.
-   *
-   * @param ignore ignore the change if true
-   */
-  void ignore(boolean ignore) throws RestApiException;
-
-  /**
-   * Check if this change is ignored.
-   *
-   * @return true if the change is ignored
-   */
-  boolean ignored() throws RestApiException;
-
-  /**
    * Create a new change that reverts this change.
    *
    * @see Changes#id(int)
@@ -166,6 +154,8 @@
   /** Create a merge patch set for the change. */
   ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
 
+  ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException;
+
   default List<ChangeInfo> submittedTogether() throws RestApiException {
     SubmittedTogetherInfo info =
         submittedTogether(
@@ -190,6 +180,24 @@
   /** Rebase the current revision of a change. */
   void rebase(RebaseInput in) throws RestApiException;
 
+  /**
+   * Rebase the current revisions of a change's chain using default options.
+   *
+   * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+   *     chain
+   */
+  default Response<RebaseChainInfo> rebaseChain() throws RestApiException {
+    return rebaseChain(new RebaseInput());
+  }
+
+  /**
+   * Rebase the current revisions of a change's chain.
+   *
+   * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+   *     chain
+   */
+  Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException;
+
   /** Deletes a change. */
   void delete() throws RestApiException;
 
@@ -427,6 +435,39 @@
 
   ChangeInfo check(FixInput fix) throws RestApiException;
 
+  abstract class CheckSubmitRequirementRequest {
+    /** Submit requirement name. */
+    private String name;
+
+    /**
+     * A change ID for a change in {@link com.google.gerrit.entities.RefNames#REFS_CONFIG} branch
+     * from which the submit-requirement will be loaded.
+     */
+    private String refsConfigChangeId;
+
+    public abstract SubmitRequirementResultInfo get() throws RestApiException;
+
+    public CheckSubmitRequirementRequest srName(String srName) {
+      this.name = srName;
+      return this;
+    }
+
+    public CheckSubmitRequirementRequest refsConfigChangeId(String changeId) {
+      this.refsConfigChangeId = changeId;
+      return this;
+    }
+
+    protected String srName() {
+      return name;
+    }
+
+    protected String getRefsConfigChangeId() {
+      return refsConfigChangeId;
+    }
+  }
+
+  CheckSubmitRequirementRequest checkSubmitRequirementRequest() throws RestApiException;
+
   /** Returns the result of evaluating the {@link SubmitRequirementInput} input on the change. */
   SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
       throws RestApiException;
@@ -613,6 +654,11 @@
     }
 
     @Override
+    public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void delete() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -767,6 +813,11 @@
     }
 
     @Override
+    public CheckSubmitRequirementRequest checkSubmitRequirementRequest() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
         throws RestApiException {
       throw new NotImplementedException();
@@ -800,12 +851,7 @@
     }
 
     @Override
-    public void ignore(boolean ignore) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public boolean ignored() throws RestApiException {
+    public ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index fb03bc5..232b2b5 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -31,4 +31,5 @@
   public boolean allowConflicts;
   public String topic;
   public boolean allowEmpty;
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
index ee10a1d..f75443e 100644
--- a/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/DeleteVoteInput.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import java.util.Map;
 
@@ -25,4 +26,13 @@
   public NotifyHandling notify = NotifyHandling.ALL;
 
   public Map<RecipientType, NotifyInfo> notifyDetails;
+
+  /**
+   * Users in the attention set will not be added/removed from this endpoint call. Normally, users
+   * are added to the attention set upon deletion of their vote by other users.
+   */
+  public boolean ignoreAutomaticAttentionSetRules;
+
+  /** Reason for this vote deletion. Will appear in the change message. */
+  @Nullable public String reason;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
index 0cfe908..6349595 100644
--- a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
@@ -21,4 +21,5 @@
 public class FileContentInput {
   @DefaultInput public RawInput content;
   public String binary_content;
+  public Integer fileMode;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java b/java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java
new file mode 100644
index 0000000..ab81c60
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/GetRelatedOption.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+/** Options for "Get Related Changes" requests. */
+public enum GetRelatedOption {
+  /** Compute submittability boolean for all returned changes. */
+  SUBMITTABLE
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index 10559a3..e9b05cc 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import java.util.Map;
+
 public class RebaseInput {
   public String base;
 
@@ -24,4 +26,6 @@
    * to indicate the conflicts.
    */
   public boolean allowConflicts;
+
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
index 5bf22aa..740caa8 100644
--- a/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/RelatedChangeAndCommitInfo.java
@@ -25,6 +25,7 @@
   public Integer _revisionNumber;
   public Integer _currentRevisionNumber;
   public String status;
+  public Boolean submittable;
 
   public RelatedChangeAndCommitInfo() {}
 
@@ -38,6 +39,7 @@
         .add("_revisionNumber", _revisionNumber)
         .add("_currentRevisionNumber", _currentRevisionNumber)
         .add("status", status)
+        .add("submittable", submittable)
         .toString();
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index 148d24a..613e48e 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -36,4 +36,6 @@
    * {@link NotifyHandling#OWNER}
    */
   public boolean workInProgress;
+
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 11999ab..8bfe468 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
@@ -117,11 +119,13 @@
     public List<FixSuggestionInfo> fixSuggestions;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput message(String msg) {
     message = msg != null && !msg.isEmpty() ? msg : null;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput patchSetLevelComment(String message) {
     Objects.requireNonNull(message);
     CommentInput comment = new CommentInput();
@@ -131,6 +135,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput label(String name, short value) {
     if (name == null || name.isEmpty()) {
       throw new IllegalArgumentException();
@@ -142,6 +147,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput label(String name, int value) {
     if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) {
       throw new IllegalArgumentException();
@@ -149,14 +155,22 @@
     return label(name, (short) value);
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput label(String name) {
     return label(name, (short) 1);
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput reviewer(String reviewer) {
-    return reviewer(reviewer, REVIEWER, false);
+    return reviewer(reviewer, REVIEWER, /* confirmed= */ false);
   }
 
+  @CanIgnoreReturnValue
+  public ReviewInput cc(String cc) {
+    return reviewer(cc, CC, /* confirmed= */ false);
+  }
+
+  @CanIgnoreReturnValue
   public ReviewInput reviewer(String reviewer, ReviewerState state, boolean confirmed) {
     ReviewerInput input = new ReviewerInput();
     input.reviewer = reviewer;
@@ -169,6 +183,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput addUserToAttentionSet(String user, String reason) {
     AttentionSetInput input = new AttentionSetInput();
     input.user = user;
@@ -180,6 +195,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput removeUserFromAttentionSet(String user, String reason) {
     AttentionSetInput input = new AttentionSetInput();
     input.user = user;
@@ -191,17 +207,20 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput blockAutomaticAttentionSetRules() {
     ignoreAutomaticAttentionSetRules = true;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput setWorkInProgress(boolean workInProgress) {
     this.workInProgress = workInProgress;
     ready = !workInProgress;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ReviewInput setReady(boolean ready) {
     this.ready = ready;
     workInProgress = !ready;
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 1307516..73fc170 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -33,6 +34,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -51,12 +53,6 @@
 
   ChangeInfo submit(SubmitInput in) throws RestApiException;
 
-  default BinaryResult submitPreview() throws RestApiException {
-    return submitPreview("zip");
-  }
-
-  BinaryResult submitPreview(String format) throws RestApiException;
-
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
 
   ChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException;
@@ -123,8 +119,20 @@
    */
   EditInfo applyFix(String fixId) throws RestApiException;
 
+  /**
+   * Applies fix similar to {@code applyFix} method. Instead of using a fix stored in the server,
+   * this applies the fix provided in {@code ApplyProvidedFixInput}
+   *
+   * @param applyProvidedFixInput The fix(es) to apply to a new change edit.
+   * @throws RestApiException if the fix couldn't be applied.
+   */
+  EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException;
+
   Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException;
 
+  Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+      throws RestApiException;
+
   DraftApi createDraft(DraftInput in) throws RestApiException;
 
   DraftApi draft(String id) throws RestApiException;
@@ -152,6 +160,8 @@
 
   RelatedChangesInfo related() throws RestApiException;
 
+  RelatedChangesInfo related(EnumSet<GetRelatedOption> listOptions) throws RestApiException;
+
   /** Returns votes on the revision. */
   ListMultimap<String, ApprovalInfo> votes() throws RestApiException;
 
@@ -319,11 +329,22 @@
     }
 
     @Override
+    public EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -369,11 +390,6 @@
     }
 
     @Override
-    public BinaryResult submitPreview(String format) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -394,6 +410,11 @@
     }
 
     @Override
+    public RelatedChangesInfo related(EnumSet<GetRelatedOption> options) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public ListMultimap<String, ApprovalInfo> votes() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
index e2cab4d..68a4e88 100644
--- a/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
+++ b/java/com/google/gerrit/extensions/api/changes/SubmittedTogetherOption.java
@@ -16,5 +16,6 @@
 
 /** Output options available for submitted_together requests. */
 public enum SubmittedTogetherOption {
-  NON_VISIBLE_CHANGES;
+  NON_VISIBLE_CHANGES,
+  TOPIC_CLOSURE;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchInput.java b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
index aaf69d9..aeefcd1 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchInput.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.extensions.api.projects;
 
 import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
 
 public class BranchInput {
   @DefaultInput public String revision;
   public String ref;
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
index 23849e4..5c47ac3 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
@@ -17,10 +17,13 @@
 import com.google.common.base.MoreObjects;
 import java.util.Objects;
 
+/** See {@link com.google.gerrit.entities.StoredCommentLinkInfo} for field documentation. */
 public class CommentLinkInfo {
   public String match;
   public String link;
-  public String html;
+  public String prefix;
+  public String suffix;
+  public String text;
   public Boolean enabled; // null means true
 
   public transient String name;
@@ -34,7 +37,9 @@
       CommentLinkInfo that = (CommentLinkInfo) o;
       return Objects.equals(this.match, that.match)
           && Objects.equals(this.link, that.link)
-          && Objects.equals(this.html, that.html)
+          && Objects.equals(this.prefix, that.prefix)
+          && Objects.equals(this.suffix, that.suffix)
+          && Objects.equals(this.text, that.text)
           && Objects.equals(this.enabled, that.enabled);
     }
     return false;
@@ -42,7 +47,7 @@
 
   @Override
   public int hashCode() {
-    return Objects.hash(match, link, html, enabled);
+    return Objects.hash(match, link, prefix, suffix, text, enabled);
   }
 
   @Override
@@ -51,7 +56,9 @@
         .add("name", name)
         .add("match", match)
         .add("link", link)
-        .add("html", html)
+        .add("prefix", prefix)
+        .add("suffix", suffix)
+        .add("text", text)
         .add("enabled", enabled)
         .toString();
   }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
index 3aad7e1..1c964a4 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInput.java
@@ -14,14 +14,22 @@
 
 package com.google.gerrit.extensions.api.projects;
 
-/*
+/**
  * Input for a commentlink configuration on a project.
+ *
+ * <p>See {@link com.google.gerrit.entities.StoredCommentLinkInfo} for additional details.
  */
 public class CommentLinkInput {
   /** A JavaScript regular expression to match positions to be replaced with a hyperlink. */
   public String match;
   /** The URL to direct the user to whenever the regular expression is matched. */
   public String link;
+  /** Text inserted before the link if the regular expression is matched. */
+  public String prefix;
+  /** Text inserted after the link if the regular expression is matched. */
+  public String suffix;
+  /** Text of the link. */
+  public String text;
   /** Whether the commentlink is enabled. */
   public Boolean enabled;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index 3ba1277..1a51c15 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -40,6 +40,7 @@
   public InheritedBooleanInfo enableReviewerByEmail;
   public InheritedBooleanInfo matchAuthorToCommitterDate;
   public InheritedBooleanInfo rejectEmptyCommit;
+  public InheritedBooleanInfo skipAddingAuthorAndCommitterAsReviewers;
 
   public MaxObjectSizeLimitInfo maxObjectSizeLimit;
   @Deprecated // Equivalent to defaultSubmitType.value
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 8005fc5..906fc4c 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -34,6 +34,7 @@
   public InheritableBoolean enableReviewerByEmail;
   public InheritableBoolean matchAuthorToCommitterDate;
   public InheritableBoolean rejectEmptyCommit;
+  public InheritableBoolean skipAddingAuthorAndCommitterAsReviewers;
   public String maxObjectSizeLimit;
   public SubmitType submitType;
   public ProjectState state;
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 9873995..f6408b6 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -22,9 +22,13 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Collection;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 public interface ProjectApi {
   ProjectApi create() throws RestApiException;
@@ -51,6 +55,9 @@
 
   ConfigInfo config(ConfigInput in) throws RestApiException;
 
+  Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
+      throws RestApiException;
+
   ListRefsRequest<BranchInfo> branches();
 
   ListRefsRequest<TagInfo> tags();
@@ -221,6 +228,21 @@
 
   LabelApi label(String labelName) throws RestApiException;
 
+  ListSubmitRequirementsRequest submitRequirements() throws RestApiException;
+
+  abstract class ListSubmitRequirementsRequest {
+    protected boolean inherited;
+
+    public abstract List<SubmitRequirementInfo> get() throws RestApiException;
+
+    public ListSubmitRequirementsRequest withInherited(boolean inherited) {
+      this.inherited = inherited;
+      return this;
+    }
+  }
+
+  SubmitRequirementApi submitRequirement(String name) throws RestApiException;
+
   /**
    * Adds, updates and deletes label definitions in a batch.
    *
@@ -289,6 +311,12 @@
     }
 
     @Override
+    public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void description(DescriptionInput in) throws RestApiException {
       throw new NotImplementedException();
     }
@@ -409,11 +437,21 @@
     }
 
     @Override
+    public ListSubmitRequirementsRequest submitRequirements() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public LabelApi label(String labelName) throws RestApiException {
       throw new NotImplementedException();
     }
 
     @Override
+    public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void labels(BatchLabelInput input) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
new file mode 100644
index 0000000..a6e79db
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.projects;
+
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface SubmitRequirementApi {
+  /** Create a new submit requirement. */
+  SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException;
+
+  /** Get existing submit requirement. */
+  SubmitRequirementInfo get() throws RestApiException;
+
+  /** Update existing submit requirement. */
+  SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException;
+
+  /** Delete existing submit requirement. */
+  void delete() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements SubmitRequirementApi {
+    @Override
+    public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitRequirementInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/projects/TagInfo.java b/java/com/google/gerrit/extensions/api/projects/TagInfo.java
index a6269fe..61ea518 100644
--- a/java/com/google/gerrit/extensions/api/projects/TagInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/TagInfo.java
@@ -14,16 +14,22 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 
 public class TagInfo extends RefInfo {
   public String object;
   public String message;
   public GitPerson tagger;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp created;
+
   public List<WebLinkInfo> webLinks;
 
   public TagInfo(
@@ -39,8 +45,22 @@
     this.created = created;
   }
 
+  @SuppressWarnings("JdkObsolete")
+  public TagInfo(
+      String ref,
+      String revision,
+      Boolean canDelete,
+      List<WebLinkInfo> webLinks,
+      @Nullable Instant created) {
+    this.ref = ref;
+    this.revision = revision;
+    this.canDelete = canDelete;
+    this.webLinks = webLinks;
+    this.created = created != null ? Timestamp.from(created) : null;
+  }
+
   public TagInfo(String ref, String revision, Boolean canDelete, List<WebLinkInfo> webLinks) {
-    this(ref, revision, canDelete, webLinks, null);
+    this(ref, revision, canDelete, webLinks, (Instant) null);
   }
 
   public TagInfo(
@@ -66,8 +86,24 @@
       String message,
       GitPerson tagger,
       Boolean canDelete,
+      List<WebLinkInfo> webLinks,
+      Instant created) {
+    this(ref, revision, canDelete, webLinks, created);
+    this.object = object;
+    this.message = message;
+    this.tagger = tagger;
+    this.webLinks = webLinks;
+  }
+
+  public TagInfo(
+      String ref,
+      String revision,
+      String object,
+      String message,
+      GitPerson tagger,
+      Boolean canDelete,
       List<WebLinkInfo> webLinks) {
-    this(ref, revision, object, message, tagger, canDelete, webLinks, null);
+    this(ref, revision, object, message, tagger, canDelete, webLinks, (Instant) null);
     this.object = object;
     this.message = message;
     this.tagger = tagger;
diff --git a/java/com/google/gerrit/extensions/client/ChangeKind.java b/java/com/google/gerrit/extensions/client/ChangeKind.java
index e58e005..6240bba 100644
--- a/java/com/google/gerrit/extensions/client/ChangeKind.java
+++ b/java/com/google/gerrit/extensions/client/ChangeKind.java
@@ -29,5 +29,42 @@
   NO_CODE_CHANGE,
 
   /** Same tree, parent tree, same commit message. */
-  NO_CHANGE
+  NO_CHANGE;
+
+  public boolean matches(ChangeKind changeKind, boolean isMerge) {
+    switch (changeKind) {
+      case REWORK:
+        // REWORK inlcudes all other change kinds, since those are just more trivial cases of a
+        // rework
+        return true;
+      case TRIVIAL_REBASE:
+        return isTrivialRebase();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return isMergeFirstParentUpdate(isMerge);
+      case NO_CHANGE:
+        return this == NO_CHANGE;
+      case NO_CODE_CHANGE:
+        return isNoCodeChange();
+    }
+    throw new IllegalStateException("unexpected change kind: " + changeKind);
+  }
+
+  public boolean isNoCodeChange() {
+    // NO_CHANGE is a more trivial case of NO_CODE_CHANGE and hence matched as well
+    return this == NO_CHANGE || this == NO_CODE_CHANGE;
+  }
+
+  public boolean isTrivialRebase() {
+    // NO_CHANGE is a more trivial case of TRIVIAL_REBASE and hence matched as well
+    return this == NO_CHANGE || this == TRIVIAL_REBASE;
+  }
+
+  public boolean isMergeFirstParentUpdate(boolean isMerge) {
+    if (!isMerge) {
+      return false;
+    }
+
+    // NO_CHANGE is a more trivial case of MERGE_FIRST_PARENT_UPDATE and hence matched as well
+    return this == NO_CHANGE || this == MERGE_FIRST_PARENT_UPDATE;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index 634992e..b8843d3 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.client;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Comparator;
 import java.util.Objects;
 
@@ -35,7 +36,11 @@
 
   public Range range;
   public String inReplyTo;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp updated;
+
   public String message;
 
   /**
@@ -44,6 +49,20 @@
    */
   public String commitId;
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getUpdated() {
+    return updated.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setUpdated(Instant when) {
+    updated = Timestamp.from(when);
+  }
+
   public static class Range implements Comparable<Range> {
     private static final Comparator<Range> RANGE_COMPARATOR =
         Comparator.<Range>comparingInt(range -> range.startLine)
@@ -70,10 +89,10 @@
     public boolean equals(Object o) {
       if (o instanceof Range) {
         Range r = (Range) o;
-        return Objects.equals(startLine, r.startLine)
-            && Objects.equals(startCharacter, r.startCharacter)
-            && Objects.equals(endLine, r.endLine)
-            && Objects.equals(endCharacter, r.endCharacter);
+        return startLine == r.startLine
+            && startCharacter == r.startCharacter
+            && endLine == r.endLine
+            && endCharacter == r.endCharacter;
       }
       return false;
     }
@@ -110,6 +129,11 @@
     return 1;
   }
 
+  // This is a value class that allows adding attributes by subclassing.
+  // Doing this is discouraged and using composition rather than inheritance to add fields to value
+  // types is preferred. However this class is part of the extension API, hence we cannot change it
+  // without breaking the API. Hence suppress the EqualsGetClass warning here.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object o) {
     if (this == o) {
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index b26f435..1ee2cd8 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -22,9 +22,6 @@
   /** Default number of items to display per page. */
   public static final int DEFAULT_PAGESIZE = 25;
 
-  /** Valid choices for the page size. */
-  public static final int[] PAGESIZE_CHOICES = {10, 25, 50, 100};
-
   /** Preferred method to download a change. */
   public enum DownloadCommand {
     PULL,
@@ -105,6 +102,7 @@
   }
 
   public enum Theme {
+    AUTO,
     DARK,
     LIGHT
   }
@@ -152,6 +150,7 @@
   public Boolean workInProgressByDefault;
   public List<MenuItem> my;
   public List<String> changeTable;
+  public Boolean allowBrowserNotifications;
 
   public DateFormat getDateFormat() {
     if (dateFormat == null) {
@@ -192,7 +191,7 @@
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
     p.downloadScheme = null;
-    p.theme = Theme.LIGHT;
+    p.theme = Theme.AUTO;
     p.dateFormat = DateFormat.STD;
     p.timeFormat = TimeFormat.HHMM_12;
     p.expandInlineDiffs = false;
@@ -210,6 +209,7 @@
     p.disableKeyboardShortcuts = false;
     p.disableTokenHighlighting = false;
     p.workInProgressByDefault = false;
+    p.allowBrowserNotifications = true;
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/Side.java b/java/com/google/gerrit/extensions/client/Side.java
index e077df2..a87b37a 100644
--- a/java/com/google/gerrit/extensions/client/Side.java
+++ b/java/com/google/gerrit/extensions/client/Side.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.gerrit.common.Nullable;
+
 public enum Side {
   PARENT,
   REVISION;
 
+  @Nullable
   public static Side fromShort(short s) {
     if (s <= 0) {
       return PARENT;
diff --git a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
index a2aeab2..a76a7f9 100644
--- a/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountDetailInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 
 /**
  * Representation of a (detailed) account in the REST API.
@@ -27,9 +28,18 @@
  */
 public class AccountDetailInfo extends AccountInfo {
   /** The timestamp of when the account was registered. */
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp registeredOn;
 
   public AccountDetailInfo(Integer id) {
     super(id);
   }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setRegisteredOn(Instant registeredOn) {
+    this.registeredOn = Timestamp.from(registeredOn);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
index e3e0fc8..b51e195 100644
--- a/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountExternalIdInfo.java
@@ -57,10 +57,10 @@
   public boolean equals(Object o) {
     if (o instanceof AccountExternalIdInfo) {
       AccountExternalIdInfo a = (AccountExternalIdInfo) o;
-      return (Objects.equals(a.identity, identity))
-          && (Objects.equals(a.emailAddress, emailAddress))
-          && (Objects.equals(a.trusted, trusted))
-          && (Objects.equals(a.canDelete, canDelete));
+      return Objects.equals(a.identity, identity)
+          && Objects.equals(a.emailAddress, emailAddress)
+          && Objects.equals(a.trusted, trusted)
+          && Objects.equals(a.canDelete, canDelete);
     }
     return false;
   }
diff --git a/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java b/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
new file mode 100644
index 0000000..cd28d83
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ApplyProvidedFixInput.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,//
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+/**
+ * Input containing fix definitions to apply the provided fix to the change, on the patchset
+ * specified by revision id.
+ */
+public class ApplyProvidedFixInput {
+  public ApplyProvidedFixInput() {}
+
+  public List<FixReplacementInfo> fixReplacementInfos;
+}
diff --git a/java/com/google/gerrit/extensions/common/ApprovalInfo.java b/java/com/google/gerrit/extensions/common/ApprovalInfo.java
index bf72e83..4519add 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.time.Instant;
 import java.util.Objects;
 
 /**
@@ -43,6 +44,8 @@
   public Integer value;
 
   /** The time and date describing when the approval was made. */
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
 
   /** Whether this vote was made after the change was submitted. */
@@ -62,10 +65,10 @@
 
   public ApprovalInfo(
       Integer id,
-      Integer value,
+      @Nullable Integer value,
       @Nullable VotingRangeInfo permittedVotingRange,
       @Nullable String tag,
-      Timestamp date) {
+      @Nullable Timestamp date) {
     super(id);
     this.value = value;
     this.permittedVotingRange = permittedVotingRange;
@@ -73,6 +76,28 @@
     this.tag = tag;
   }
 
+  public ApprovalInfo(
+      Integer id,
+      @Nullable Integer value,
+      @Nullable VotingRangeInfo permittedVotingRange,
+      @Nullable String tag,
+      @Nullable Instant date) {
+    super(id);
+    this.value = value;
+    this.permittedVotingRange = permittedVotingRange;
+    this.tag = tag;
+    if (date != null) {
+      setDate(date);
+    }
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setDate(Instant date) {
+    this.date = Timestamp.from(date);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ApprovalInfo) {
@@ -88,6 +113,11 @@
   }
 
   @Override
+  public String toString() {
+    return super.toString() + ", value=" + this.value;
+  }
+
+  @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 d34ba6d..81dbc88 100644
--- a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
+++ b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 /**
@@ -28,8 +29,12 @@
 public class AttentionSetInfo {
   /** The user included in the attention set. */
   public AccountInfo account;
+
   /** The timestamp of the last update. */
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp lastUpdate;
+
   /** The human readable reason why the user was added. */
   public String reason;
 
@@ -51,6 +56,17 @@
     this.reasonAccount = reasonAccount;
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public AttentionSetInfo(
+      AccountInfo account, Instant lastUpdate, String reason, @Nullable AccountInfo reasonAccount) {
+    this.account = account;
+    this.lastUpdate = Timestamp.from(lastUpdate);
+    this.reason = reason;
+    this.reasonAccount = reasonAccount;
+  }
+
   protected AttentionSetInfo() {}
 
   @Override
diff --git a/java/com/google/gerrit/extensions/common/BlameInfo.java b/java/com/google/gerrit/extensions/common/BlameInfo.java
index df3f373..6ee677e 100644
--- a/java/com/google/gerrit/extensions/common/BlameInfo.java
+++ b/java/com/google/gerrit/extensions/common/BlameInfo.java
@@ -28,7 +28,7 @@
     if (this == o) {
       return true;
     }
-    if (o == null || getClass() != o.getClass()) {
+    if (o == null || !(o instanceof BlameInfo)) {
       return false;
     }
     BlameInfo blameInfo = (BlameInfo) o;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 2bb3dd7..a865187 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
@@ -46,14 +47,20 @@
    */
   public Map<Integer, AttentionSetInfo> attentionSet;
 
+  public Map<Integer, AttentionSetInfo> removedFromAttentionSet;
+
   public AccountInfo assignee;
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
   public ChangeStatus status;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp created;
   public Timestamp updated;
   public Timestamp submitted;
+
   public AccountInfo submitter;
   public Boolean starred;
   public Collection<String> stars;
@@ -98,6 +105,7 @@
   public Map<String, ActionInfo> actions;
   public Map<String, LabelInfo> labels;
   public Map<String, Collection<String>> permittedLabels;
+  public Map<String, Map<String, List<AccountInfo>>> removableLabels;
   public Collection<AccountInfo> removableReviewers;
   public Map<ReviewerState, Collection<AccountInfo>> reviewers;
   public Map<ReviewerState, Collection<AccountInfo>> pendingReviewers;
@@ -124,4 +132,47 @@
   public ChangeInfo(Map<String, RevisionInfo> revisions) {
     this.revisions = ImmutableMap.copyOf(revisions);
   }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getCreated() {
+    return created.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setCreated(Instant when) {
+    created = Timestamp.from(when);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getUpdated() {
+    return updated.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setUpdated(Instant when) {
+    updated = Timestamp.from(when);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getSubmitted() {
+    return submitted.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setSubmitted(Instant when, AccountInfo who) {
+    submitted = Timestamp.from(when);
+    submitter = who;
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index ad112d3..51c35dc 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -21,6 +21,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Field;
 import java.sql.Timestamp;
@@ -147,16 +148,19 @@
   }
 
   /** Returns {@code null} if nothing has been added to {@code oldCollection} */
+  @Nullable
   private static ImmutableList<?> getAddedForCollection(
-      Collection<?> oldCollection, Collection<?> newCollection) {
-    ImmutableList<?> notInOldCollection = getAdditions(oldCollection, newCollection);
+      @Nullable Collection<?> oldCollection, Collection<?> newCollection) {
+    ImmutableList<?> notInOldCollection = getAdditionsForCollection(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;
+  @Nullable
+  private static ImmutableList<Object> getAdditionsForCollection(
+      @Nullable Collection<?> oldCollection, Collection<?> newCollection) {
+    if (oldCollection == null) {
+      return ImmutableList.copyOf(newCollection);
+    }
 
     Map<Object, List<Object>> duplicatesMap = newCollection.stream().collect(groupingBy(v -> v));
     oldCollection.forEach(
@@ -169,7 +173,19 @@
   }
 
   /** Returns {@code null} if nothing has been added to {@code oldMap} */
-  private static ImmutableMap<Object, Object> getAddedForMap(Map<?, ?> oldMap, Map<?, ?> newMap) {
+  @Nullable
+  private static ImmutableMap<Object, Object> getAddedForMap(
+      @Nullable Map<?, ?> oldMap, Map<?, ?> newMap) {
+    ImmutableMap<Object, Object> notInOldMap = getAdditionsForMap(oldMap, newMap);
+    return notInOldMap.isEmpty() ? null : notInOldMap;
+  }
+
+  @Nullable
+  private static ImmutableMap<Object, Object> getAdditionsForMap(
+      @Nullable Map<?, ?> oldMap, Map<?, ?> newMap) {
+    if (oldMap == null) {
+      return ImmutableMap.copyOf(newMap);
+    }
     ImmutableMap.Builder<Object, Object> additionsBuilder = ImmutableMap.builder();
     for (Map.Entry<?, ?> entry : newMap.entrySet()) {
       Object added = getAdded(oldMap.get(entry.getKey()), entry.getValue());
@@ -177,8 +193,7 @@
         additionsBuilder.put(entry.getKey(), added);
       }
     }
-    ImmutableMap<Object, Object> additions = additionsBuilder.build();
-    return additions.isEmpty() ? null : additions;
+    return additionsBuilder.build();
   }
 
   private static Object get(Field field, Object obj) {
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index ea12ef1..55a5883 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RecipientType;
@@ -35,6 +36,7 @@
   public Boolean newBranch;
   public Map<String, String> validationOptions;
   public MergeInput merge;
+  public ApplyPatchInput patch;
 
   public AccountInput author;
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index c1cb1627..51fe57c 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.common.collect.Iterables;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collection;
 import java.util.Objects;
 
@@ -24,7 +26,11 @@
   public String tag;
   public AccountInfo author;
   public AccountInfo realAuthor;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
+
   public String message;
   public Collection<AccountInfo> accountsInMessage;
   public Integer _revisionNumber;
@@ -35,6 +41,13 @@
     this.message = message;
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setDate(Instant when) {
+    date = Timestamp.from(when);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ChangeMessageInfo) {
@@ -45,7 +58,10 @@
           && Objects.equals(realAuthor, cmi.realAuthor)
           && Objects.equals(date, cmi.date)
           && Objects.equals(message, cmi.message)
-          && Objects.equals(accountsInMessage, cmi.accountsInMessage)
+          && ((accountsInMessage == null && cmi.accountsInMessage == null)
+              || (accountsInMessage != null
+                  && cmi.accountsInMessage != null
+                  && Iterables.elementsEqual(accountsInMessage, cmi.accountsInMessage)))
           && Objects.equals(_revisionNumber, cmi._revisionNumber);
     }
     return false;
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index c732663..9526fbb 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -18,6 +18,8 @@
 
 public class FileInfo {
   public Character status;
+  public Integer oldMode;
+  public Integer newMode;
   public Boolean binary;
   public String oldPath;
   public Integer linesInserted;
diff --git a/java/com/google/gerrit/extensions/common/GitPerson.java b/java/com/google/gerrit/extensions/common/GitPerson.java
index 8ed919e..df3e488 100644
--- a/java/com/google/gerrit/extensions/common/GitPerson.java
+++ b/java/com/google/gerrit/extensions/common/GitPerson.java
@@ -15,14 +15,26 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 public class GitPerson {
   public String name;
   public String email;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
+
   public int tz;
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setDate(Instant when) {
+    date = Timestamp.from(when);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (!(o instanceof GitPerson)) {
diff --git a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
index 711337a..9a13713 100644
--- a/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
+++ b/java/com/google/gerrit/extensions/common/GroupAuditEventInfo.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 public abstract class GroupAuditEventInfo {
@@ -27,25 +29,62 @@
 
   public Type type;
   public AccountInfo user;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp date;
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   public static UserMemberAuditEventInfo createAddUserEvent(
       AccountInfo user, Timestamp date, AccountInfo member) {
-    return new UserMemberAuditEventInfo(Type.ADD_USER, user, Optional.of(date), member);
+    return new UserMemberAuditEventInfo(Type.ADD_USER, user, date.toInstant(), member);
+  }
+
+  public static UserMemberAuditEventInfo createAddUserEvent(
+      AccountInfo user, Instant date, AccountInfo member) {
+    return new UserMemberAuditEventInfo(Type.ADD_USER, user, date, member);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public static UserMemberAuditEventInfo createRemoveUserEvent(
+      AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
+    return new UserMemberAuditEventInfo(
+        Type.REMOVE_USER, user, date.map(Timestamp::toInstant).orElse(null), member);
   }
 
   public static UserMemberAuditEventInfo createRemoveUserEvent(
-      AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
+      AccountInfo user, @Nullable Instant date, AccountInfo member) {
     return new UserMemberAuditEventInfo(Type.REMOVE_USER, user, date, member);
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   public static GroupMemberAuditEventInfo createAddGroupEvent(
       AccountInfo user, Timestamp date, GroupInfo member) {
-    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, Optional.of(date), member);
+    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, date.toInstant(), member);
+  }
+
+  public static GroupMemberAuditEventInfo createAddGroupEvent(
+      AccountInfo user, Instant date, GroupInfo member) {
+    return new GroupMemberAuditEventInfo(Type.ADD_GROUP, user, date, member);
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public static GroupMemberAuditEventInfo createRemoveGroupEvent(
+      AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
+    return new GroupMemberAuditEventInfo(
+        Type.REMOVE_GROUP, user, date.map(Timestamp::toInstant).orElse(null), member);
   }
 
   public static GroupMemberAuditEventInfo createRemoveGroupEvent(
-      AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
+      AccountInfo user, @Nullable Instant date, GroupInfo member) {
     return new GroupMemberAuditEventInfo(Type.REMOVE_GROUP, user, date, member);
   }
 
@@ -55,11 +94,20 @@
     this.date = date.orElse(null);
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  protected GroupAuditEventInfo(Type type, AccountInfo user, @Nullable Instant date) {
+    this.type = type;
+    this.user = user;
+    this.date = date != null ? Timestamp.from(date) : null;
+  }
+
   public static class UserMemberAuditEventInfo extends GroupAuditEventInfo {
     public AccountInfo member;
 
     private UserMemberAuditEventInfo(
-        Type type, AccountInfo user, Optional<Timestamp> date, AccountInfo member) {
+        Type type, AccountInfo user, @Nullable Instant date, AccountInfo member) {
       super(type, user, date);
       this.member = member;
     }
@@ -69,7 +117,7 @@
     public GroupInfo member;
 
     private GroupMemberAuditEventInfo(
-        Type type, AccountInfo user, Optional<Timestamp> date, GroupInfo member) {
+        Type type, AccountInfo user, @Nullable Instant date, GroupInfo member) {
       super(type, user, date);
       this.member = member;
     }
diff --git a/java/com/google/gerrit/extensions/common/GroupInfo.java b/java/com/google/gerrit/extensions/common/GroupInfo.java
index b21475c..edbaa01 100644
--- a/java/com/google/gerrit/extensions/common/GroupInfo.java
+++ b/java/com/google/gerrit/extensions/common/GroupInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 
 public class GroupInfo extends GroupBaseInfo {
@@ -26,10 +27,28 @@
   public Integer groupId;
   public String owner;
   public String ownerId;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp createdOn;
+
   public Boolean _moreGroups;
 
   // These fields are only supplied for internal groups, and only if requested.
   public List<AccountInfo> members;
   public List<GroupInfo> includes;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public Instant getCreatedOn() {
+    return createdOn.toInstant();
+  }
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setCreatedOn(Instant when) {
+    createdOn = Timestamp.from(when);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index 6f733d6..975061b 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -19,22 +19,14 @@
 
 public class LabelDefinitionInfo {
   public String name;
+  public String description;
   public String projectName;
   public String function;
   public Map<String, String> values;
   public short defaultValue;
   public List<String> branches;
   public Boolean canOverride;
-  public Boolean copyAnyScore;
   public String copyCondition;
-  public Boolean copyMinScore;
-  public Boolean copyMaxScore;
-  public Boolean copyAllScoresIfListOfFilesDidNotChange;
-  public Boolean copyAllScoresIfNoChange;
-  public Boolean copyAllScoresIfNoCodeChange;
-  public Boolean copyAllScoresOnTrivialRebase;
-  public Boolean copyAllScoresOnMergeFirstParentUpdate;
-  public List<Short> copyValues;
   public Boolean allowPostSubmit;
   public Boolean ignoreSelfApproval;
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 38b76c1..277fccd 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -19,22 +19,14 @@
 
 public class LabelDefinitionInput extends InputWithCommitMessage {
   public String name;
+  public String description;
   public String function;
   public Map<String, String> values;
   public Short defaultValue;
   public List<String> branches;
   public Boolean canOverride;
-  public Boolean copyAnyScore;
   public String copyCondition;
   public Boolean unsetCopyCondition;
-  public Boolean copyMinScore;
-  public Boolean copyMaxScore;
-  public Boolean copyAllScoresIfListOfFilesDidNotChange;
-  public Boolean copyAllScoresIfNoChange;
-  public Boolean copyAllScoresIfNoCodeChange;
-  public Boolean copyAllScoresOnTrivialRebase;
-  public Boolean copyAllScoresOnMergeFirstParentUpdate;
-  public List<Short> copyValues;
   public Boolean allowPostSubmit;
   public Boolean ignoreSelfApproval;
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelInfo.java b/java/com/google/gerrit/extensions/common/LabelInfo.java
index 44bcdaf..cdd3b18 100644
--- a/java/com/google/gerrit/extensions/common/LabelInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelInfo.java
@@ -27,6 +27,7 @@
 
   public Map<String, String> values;
 
+  public String description;
   public Short value;
   public Short defaultValue;
   public Boolean optional;
diff --git a/java/com/google/gerrit/extensions/common/RebaseChainInfo.java b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
new file mode 100644
index 0000000..b327007
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+public class RebaseChainInfo {
+  public List<ChangeInfo> rebasedChanges;
+  /**
+   * Whether any of the changes contain conflicts.
+   *
+   * <p>If {@code true}, some of the rebased changes are marked with conflicts.
+   */
+  public Boolean containsGitConflicts;
+}
diff --git a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
index 37e1ceb..36682f6 100644
--- a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
+++ b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
@@ -16,14 +16,31 @@
 
 import com.google.gerrit.extensions.client.ReviewerState;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 
 public class ReviewerUpdateInfo {
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp updated;
+
   public AccountInfo updatedBy;
   public AccountInfo reviewer;
   public ReviewerState state;
 
+  public ReviewerUpdateInfo() {}
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public ReviewerUpdateInfo(
+      Instant updated, AccountInfo updatedBy, AccountInfo reviewer, ReviewerState state) {
+    this.updated = Timestamp.from(updated);
+    this.updatedBy = updatedBy;
+    this.reviewer = reviewer;
+    this.state = state;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ReviewerUpdateInfo) {
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index f710ab7..7c52c8c 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Map;
 import java.util.Objects;
 
@@ -25,7 +26,11 @@
   public transient boolean isCurrent;
   public ChangeKind kind;
   public int _number;
+
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
   public Timestamp created;
+
   public AccountInfo uploader;
   public String ref;
   public Map<String, FetchInfo> fetch;
@@ -51,6 +56,13 @@
     this.uploader = uploader;
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
+  public void setCreated(Instant date) {
+    this.created = Timestamp.from(date);
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof RevisionInfo) {
diff --git a/java/com/google/gerrit/extensions/common/ServerInfo.java b/java/com/google/gerrit/extensions/common/ServerInfo.java
index bc7fcfd..ce65240 100644
--- a/java/com/google/gerrit/extensions/common/ServerInfo.java
+++ b/java/com/google/gerrit/extensions/common/ServerInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.List;
+
 /** API response containing values from {@code gerrit.config} as nested objects. */
 public class ServerInfo {
   public AccountsInfo accounts;
@@ -28,4 +30,5 @@
   public UserConfigInfo user;
   public ReceiveInfo receive;
   public String defaultTheme;
+  public List<String> submitRequirementDashboardColumns;
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
index 09c9841..d957733 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
 /** API response containing a {@link com.google.gerrit.entities.SubmitRecord} entity. */
 public class SubmitRecordInfo {
@@ -36,8 +37,27 @@
     }
 
     public String label;
-    public Status status;
+    public Label.Status status;
     public AccountInfo appliedBy;
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof Label)) {
+        return false;
+      }
+      Label that = (Label) o;
+      return Objects.equals(label, that.label)
+          && status == that.status
+          && Objects.equals(appliedBy, that.appliedBy);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(label, status, appliedBy);
+    }
   }
 
   public String ruleName;
@@ -45,4 +65,25 @@
   public List<Label> labels;
   public List<LegacySubmitRequirementInfo> requirements;
   public String errorMessage;
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SubmitRecordInfo)) {
+      return false;
+    }
+    SubmitRecordInfo that = (SubmitRecordInfo) o;
+    return Objects.equals(ruleName, that.ruleName)
+        && status == that.status
+        && Objects.equals(labels, that.labels)
+        && Objects.equals(requirements, that.requirements)
+        && Objects.equals(errorMessage, that.errorMessage);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(ruleName, status, labels, requirements, errorMessage);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
index 4d1fce2..742d0c8 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.extensions.common;
 
 import java.util.List;
+import java.util.Objects;
 
-/** Result of evaluating a single submit requirement expression. */
+/**
+ * Result of evaluating a single submit requirement expression. This API entity is populated from
+ * {@link com.google.gerrit.entities.SubmitRequirementExpressionResult}.
+ */
 public class SubmitRequirementExpressionInfo {
 
   /** Submit requirement expression as a String. */
@@ -25,6 +29,9 @@
   /** A boolean indicating if the expression is fulfilled on a change. */
   public boolean fulfilled;
 
+  /** A status indicating if the expression is fulfilled, non-fulfilled or not evaluated. */
+  public Status status;
+
   /**
    * A list of all atoms that are passing, for example query "branch:refs/heads/foo and project:bar"
    * has two atoms: ["branch:refs/heads/foo", "project:bar"].
@@ -36,4 +43,49 @@
    * has two atoms: ["branch:refs/heads/foo", "project:bar"].
    */
   public List<String> failingAtoms;
+
+  /**
+   * Optional error message. Contains an explanation of why the submit requirement expression failed
+   * during its evaluation.
+   */
+  public String errorMessage;
+
+  /**
+   * Values in this enum should match with values in {@link
+   * com.google.gerrit.entities.SubmitRequirementExpressionResult.Status}.
+   */
+  public enum Status {
+    /** Expression was evaluated and the result was true. */
+    PASS,
+
+    /** Expression was evaluated and the result was false. */
+    FAIL,
+
+    /** An error occurred while evaluating the expression. */
+    ERROR,
+
+    /** Expression was not evaluated. */
+    NOT_EVALUATED
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SubmitRequirementExpressionInfo)) {
+      return false;
+    }
+    SubmitRequirementExpressionInfo that = (SubmitRequirementExpressionInfo) o;
+    return fulfilled == that.fulfilled
+        && Objects.equals(expression, that.expression)
+        && Objects.equals(passingAtoms, that.passingAtoms)
+        && Objects.equals(failingAtoms, that.failingAtoms)
+        && Objects.equals(errorMessage, that.errorMessage);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(expression, fulfilled, passingAtoms, failingAtoms, errorMessage);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
new file mode 100644
index 0000000..9347e7e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInfo.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class SubmitRequirementInfo {
+  /** Name of the submit requirement. */
+  public String name;
+
+  /** Description of the submit requirement. */
+  public String description;
+
+  /**
+   * Expression string to be evaluated on a change. Decides if this submit requirement is applicable
+   * on the given change.
+   */
+  public String applicabilityExpression;
+
+  /**
+   * Expression string to be evaluated on a change. When evaluated to true, this submit requirement
+   * becomes fulfilled for this change.
+   */
+  public String submittabilityExpression;
+
+  /**
+   * Expression string to be evaluated on a change. When evaluated to true, this submit requirement
+   * becomes fulfilled for this change regardless of the evaluation of the {@link
+   * #submittabilityExpression}.
+   */
+  public String overrideExpression;
+
+  /** Boolean indicating if this submit requirement can be overridden in child projects. */
+  public boolean allowOverrideInChildProjects;
+}
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
index 3d50f13..cf0d53c 100644
--- a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.common.Nullable;
+import java.util.Objects;
+
 /** Result of evaluating a submit requirement on a change. */
 public class SubmitRequirementResultInfo {
   public enum Status {
@@ -41,7 +44,13 @@
      * Any of the applicability, submittability or override expressions contain invalid syntax and
      * are not parsable.
      */
-    ERROR
+    ERROR,
+
+    /**
+     * The "submit requirement" was bypassed during submission, e.g. by pushing for review with the
+     * %submit option.
+     */
+    FORCED
   }
 
   /** Submit requirement name. */
@@ -57,11 +66,41 @@
   public boolean isLegacy;
 
   /** Result of evaluating the applicability expression. */
-  public SubmitRequirementExpressionInfo applicabilityExpressionResult;
+  @Nullable public SubmitRequirementExpressionInfo applicabilityExpressionResult;
 
   /** Result of evaluating the submittability expression. */
-  public SubmitRequirementExpressionInfo submittabilityExpressionResult;
+  @Nullable public SubmitRequirementExpressionInfo submittabilityExpressionResult;
 
   /** Result of evaluating the override expression. */
-  public SubmitRequirementExpressionInfo overrideExpressionResult;
+  @Nullable public SubmitRequirementExpressionInfo overrideExpressionResult;
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof SubmitRequirementResultInfo)) {
+      return false;
+    }
+    SubmitRequirementResultInfo that = (SubmitRequirementResultInfo) o;
+    return isLegacy == that.isLegacy
+        && Objects.equals(name, that.name)
+        && Objects.equals(description, that.description)
+        && status == that.status
+        && Objects.equals(applicabilityExpressionResult, that.applicabilityExpressionResult)
+        && Objects.equals(submittabilityExpressionResult, that.submittabilityExpressionResult)
+        && Objects.equals(overrideExpressionResult, that.overrideExpressionResult);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        name,
+        description,
+        status,
+        isLegacy,
+        applicabilityExpressionResult,
+        submittabilityExpressionResult,
+        overrideExpressionResult);
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
index d011d5d..180a94691 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
@@ -45,6 +45,16 @@
     return check("linesDeleted").that(fileInfo.linesDeleted);
   }
 
+  public IntegerSubject oldMode() {
+    isNotNull();
+    return check("oldMode").that(fileInfo.oldMode);
+  }
+
+  public IntegerSubject newMode() {
+    isNotNull();
+    return check("newMode").that(fileInfo.newMode);
+  }
+
   public ComparableSubject<Character> status() {
     isNotNull();
     return check("status").that(fileInfo.status);
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index d827d5d..f75ec66 100644
--- a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -24,7 +24,6 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.GitPerson;
 import java.sql.Timestamp;
-import java.util.Date;
 import org.eclipse.jgit.lib.PersonIdent;
 
 public class GitPersonSubject extends Subject {
@@ -71,11 +70,16 @@
     tz().isEqualTo(other.tz);
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   public void matches(PersonIdent ident) {
     isNotNull();
     name().isEqualTo(ident.getName());
     email().isEqualTo(ident.getEmailAddress());
-    check("roundedDate()").that(new Date(gitPerson.date.getTime())).isEqualTo(ident.getWhen());
+    check("roundedDate()")
+        .that(gitPerson.date.getTime())
+        .isEqualTo(ident.getWhenAsInstant().toEpochMilli());
     tz().isEqualTo(ident.getTimeZoneOffset());
   }
 }
diff --git a/java/com/google/gerrit/extensions/events/AttentionSetListener.java b/java/com/google/gerrit/extensions/events/AttentionSetListener.java
new file mode 100644
index 0000000..ada30ce
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/AttentionSetListener.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.Set;
+
+/** Notified whenever the attention set is changed. */
+@ExtensionPoint
+public interface AttentionSetListener {
+  interface Event extends ChangeEvent {
+
+    /**
+     * Returns the users added to the attention set because of this change
+     *
+     * @return Account IDs
+     */
+    Set<Integer> usersAdded();
+
+    /**
+     * Returns the users removed from the attention set because of this change
+     *
+     * @return Account IDs
+     */
+    Set<Integer> usersRemoved();
+  }
+
+  /**
+   * This function will be called when the attention set changes
+   *
+   * @param event The event that changed the attention set
+   */
+  void onAttentionSetChanged(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/events/ChangeEvent.java b/java/com/google/gerrit/extensions/events/ChangeEvent.java
index def75b7..6542d8e 100644
--- a/java/com/google/gerrit/extensions/events/ChangeEvent.java
+++ b/java/com/google/gerrit/extensions/events/ChangeEvent.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Interface to be extended by Events with a Change. */
 public interface ChangeEvent extends GerritEvent {
@@ -29,5 +29,5 @@
 
   AccountInfo getWho();
 
-  Timestamp getWhen();
+  Instant getWhen();
 }
diff --git a/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java b/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java
new file mode 100644
index 0000000..3d638c8
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/GitBatchRefUpdateListener.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.AccountInfo;
+import java.util.Set;
+
+/** Notified when one or more references are modified. */
+@ExtensionPoint
+public interface GitBatchRefUpdateListener {
+  interface Event extends ProjectEvent {
+    Set<UpdatedRef> getUpdatedRefs();
+
+    Set<String> getRefNames();
+
+    /** The updater, could be null if it's the server. */
+    @Nullable
+    AccountInfo getUpdater();
+  }
+
+  interface UpdatedRef {
+    public String getRefName();
+
+    public String getOldObjectId();
+
+    public String getNewObjectId();
+
+    public boolean isCreate();
+
+    public boolean isDelete();
+
+    public boolean isNonFastForward();
+  }
+
+  void onGitBatchRefUpdate(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java b/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
index bf922f8..0fec0f0 100644
--- a/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
+++ b/java/com/google/gerrit/extensions/events/GitReferenceUpdatedListener.java
@@ -21,18 +21,7 @@
 /** Notified when one or more references are modified. */
 @ExtensionPoint
 public interface GitReferenceUpdatedListener {
-  interface Event extends ProjectEvent {
-    String getRefName();
-
-    String getOldObjectId();
-
-    String getNewObjectId();
-
-    boolean isCreate();
-
-    boolean isDelete();
-
-    boolean isNonFastForward();
+  interface Event extends ProjectEvent, GitBatchRefUpdateListener.UpdatedRef {
     /** The updater, could be null if it's the server. */
     @Nullable
     AccountInfo getUpdater();
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
index d8dd1f9..7ed7077 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -39,6 +40,7 @@
     return new DynamicItem<>(key, find(injector, type), PluginName.GERRIT);
   }
 
+  @Nullable
   private static <T> Provider<T> find(Injector src, TypeLiteral<T> type) {
     List<Binding<T>> bindings = src.findBindingsByType(type);
     if (bindings != null && bindings.size() == 1) {
diff --git a/java/com/google/gerrit/extensions/registration/DynamicMap.java b/java/com/google/gerrit/extensions/registration/DynamicMap.java
index 48b1279..6fd2c03 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicMap.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicMap.java
@@ -24,8 +24,8 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.NavigableMap;
+import java.util.NavigableSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
@@ -116,14 +116,14 @@
   /**
    * Get the names of all running plugins supplying this type.
    *
-   * @return sorted set of active plugins that supply at least one item.
+   * @return navigatable set of active plugins that supply at least one item.
    */
-  public SortedSet<String> plugins() {
-    SortedSet<String> r = new TreeSet<>();
+  public NavigableSet<String> plugins() {
+    NavigableSet<String> r = new TreeSet<>();
     for (NamePair p : items.keySet()) {
       r.add(p.pluginName);
     }
-    return Collections.unmodifiableSortedSet(r);
+    return Collections.unmodifiableNavigableSet(r);
   }
 
   /**
@@ -132,21 +132,21 @@
    * @param pluginName name of the plugin.
    * @return items exported by a plugin, keyed by the export name.
    */
-  public SortedMap<String, Provider<T>> byPlugin(String pluginName) {
-    SortedMap<String, Provider<T>> r = new TreeMap<>();
+  public NavigableMap<String, Provider<T>> byPlugin(String pluginName) {
+    NavigableMap<String, Provider<T>> r = new TreeMap<>();
     for (Map.Entry<NamePair, Provider<T>> e : items.entrySet()) {
       if (e.getKey().pluginName.equals(pluginName)) {
         r.put(e.getKey().exportName, e.getValue());
       }
     }
-    return Collections.unmodifiableSortedMap(r);
+    return Collections.unmodifiableNavigableMap(r);
   }
 
   /** Iterate through all entries in an undefined order. */
   @Override
   public Iterator<Extension<T>> iterator() {
     final Iterator<Map.Entry<NamePair, Provider<T>>> i = items.entrySet().iterator();
-    return new Iterator<Extension<T>>() {
+    return new Iterator<>() {
       @Override
       public boolean hasNext() {
         return i.hasNext();
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index b2e871e..6dc8c6a 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -20,6 +20,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Binder;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -153,7 +154,7 @@
   @Override
   public Iterator<T> iterator() {
     Iterator<Extension<T>> entryIterator = entries().iterator();
-    return new Iterator<T>() {
+    return new Iterator<>() {
       @Override
       public boolean hasNext() {
         return entryIterator.hasNext();
@@ -170,7 +171,7 @@
   public Iterable<Extension<T>> entries() {
     final Iterator<AtomicReference<Extension<T>>> itr = items.iterator();
     return () ->
-        new Iterator<Extension<T>>() {
+        new Iterator<>() {
           private Extension<T> next;
 
           @Override
@@ -313,6 +314,7 @@
       return key;
     }
 
+    @Nullable
     @Override
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
       Extension<T> n = new Extension<>(item.getPluginName(), newItem);
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
index 832933b..d8999e3 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSetProvider.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.common.collect.ImmutableList;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
 
@@ -38,11 +38,12 @@
     return new DynamicSet<>(find(injector, type));
   }
 
-  private static <T> List<AtomicReference<Extension<T>>> find(Injector src, TypeLiteral<T> type) {
+  private static <T> ImmutableList<AtomicReference<Extension<T>>> find(
+      Injector src, TypeLiteral<T> type) {
     List<Binding<T>> bindings = src.findBindingsByType(type);
     int cnt = bindings != null ? bindings.size() : 0;
     if (cnt == 0) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
     List<AtomicReference<Extension<T>>> r = new ArrayList<>(cnt);
     for (Binding<T> b : bindings) {
@@ -50,6 +51,6 @@
         r.add(new AtomicReference<>(new Extension<>(PluginName.GERRIT, b.getProvider())));
       }
     }
-    return r;
+    return ImmutableList.copyOf(r);
   }
 }
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
index fb520b4..67fc068 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
@@ -16,6 +16,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.inject.Key;
 import com.google.inject.Provider;
@@ -79,6 +80,7 @@
       return key;
     }
 
+    @Nullable
     @Override
     public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
       if (items.replace(np, item, newItem)) {
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
index fd31fcd..5b528cb 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.registration;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.inject.Binding;
 import com.google.inject.Inject;
@@ -80,10 +81,10 @@
     return Collections.unmodifiableMap(m);
   }
 
-  public static List<RegistrationHandle> attachItems(
+  public static ImmutableList<RegistrationHandle> attachItems(
       Injector src, String pluginName, Map<TypeLiteral<?>, DynamicItem<?>> items) {
     if (src == null || items == null || items.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<RegistrationHandle> handles = new ArrayList<>(4);
@@ -103,13 +104,13 @@
       remove(handles);
       throw e;
     }
-    return handles;
+    return ImmutableList.copyOf(handles);
   }
 
-  public static List<RegistrationHandle> attachSets(
+  public static ImmutableList<RegistrationHandle> attachSets(
       Injector src, String pluginName, Map<TypeLiteral<?>, DynamicSet<?>> sets) {
     if (src == null || sets == null || sets.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<RegistrationHandle> handles = new ArrayList<>(4);
@@ -131,13 +132,13 @@
       remove(handles);
       throw e;
     }
-    return handles;
+    return ImmutableList.copyOf(handles);
   }
 
-  public static List<RegistrationHandle> attachMaps(
+  public static ImmutableList<RegistrationHandle> attachMaps(
       Injector src, String pluginName, Map<TypeLiteral<?>, DynamicMap<?>> maps) {
     if (src == null || maps == null || maps.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<RegistrationHandle> handles = new ArrayList<>(4);
@@ -160,7 +161,7 @@
       remove(handles);
       throw e;
     }
-    return handles;
+    return ImmutableList.copyOf(handles);
   }
 
   public static LifecycleListener registerInParentInjectors() {
diff --git a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
index 86b821b..1b88b1a 100644
--- a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
+++ b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
@@ -25,7 +25,7 @@
 
   /**
    * @param msg message to return to the client describing the error.
-   * @param cause cause of this exception.
+   * @param cause original cause of the failed precondition.
    */
   public PreconditionFailedException(String msg, Throwable cause) {
     super(msg, cause);
diff --git a/java/com/google/gerrit/extensions/restapi/Url.java b/java/com/google/gerrit/extensions/restapi/Url.java
index 9c69376..09def84 100644
--- a/java/com/google/gerrit/extensions/restapi/Url.java
+++ b/java/com/google/gerrit/extensions/restapi/Url.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.common.Nullable;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
 import java.net.URLEncoder;
@@ -40,6 +41,7 @@
    * @param component a string containing text to encode.
    * @return a string with all invalid URL characters escaped.
    */
+  @Nullable
   public static String encode(String component) {
     if (component != null) {
       try {
@@ -52,6 +54,7 @@
   }
 
   /** Decode a URL encoded string, e.g. from {@code "%2F"} to {@code "/"}. */
+  @Nullable
   public static String decode(String str) {
     if (str != null) {
       try {
diff --git a/java/com/google/gerrit/extensions/validators/CommentValidationContext.java b/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
index db08058..2c9d8f2 100644
--- a/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
+++ b/java/com/google/gerrit/extensions/validators/CommentValidationContext.java
@@ -34,7 +34,10 @@
   /** Returns the project the comment is being added to. */
   public abstract String getProject();
 
-  public static CommentValidationContext create(int changeId, String project) {
-    return new AutoValue_CommentValidationContext(changeId, project);
+  /** Returns the ref name the comment is being added to. */
+  public abstract String getRefName();
+
+  public static CommentValidationContext create(int changeId, String project, String refName) {
+    return new AutoValue_CommentValidationContext(changeId, project, refName);
   }
 }
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/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index 0e8e28e..74bccbd 100644
--- a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -37,6 +37,34 @@
    * @return WebLinkInfo that links to patch set in external service, null if there should be no
    *     link.
    */
+  @Deprecated
   WebLinkInfo getPatchSetWebLink(
       String projectName, String commit, String commitMessage, String branchName);
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a patch set to
+   * an external service.
+   *
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
+   *
+   * @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
+   * @param changeKey the changeID for this change
+   * @return WebLinkInfo that links to patch set in external service, null if there should be no
+   *     link.
+   */
+  default WebLinkInfo getPatchSetWebLink(
+      String projectName,
+      String commit,
+      String commitMessage,
+      String branchName,
+      String changeKey) {
+    return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
+  }
 }
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 9f804c4..b2173c4 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
@@ -13,6 +14,7 @@
         "//java/com/google/gerrit/server/api",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/auto:auto-factory",
         "//lib/bouncycastle:bcpg-neverlink",
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
index c3dec61..874f1dc 100644
--- a/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPushCertificateChecker.java
@@ -14,28 +14,24 @@
 
 package com.google.gerrit.gpg;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Repository;
 
+@AutoFactory
 public class GerritPushCertificateChecker extends PushCertificateChecker {
-  public interface Factory {
-    GerritPushCertificateChecker create(IdentifiedUser expectedUser);
-  }
-
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
 
-  @Inject
   GerritPushCertificateChecker(
-      GerritPublicKeyChecker.Factory keyCheckerFactory,
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      @Assisted IdentifiedUser expectedUser) {
+      @Provided GerritPublicKeyChecker.Factory keyCheckerFactory,
+      @Provided GitRepositoryManager repoManager,
+      @Provided AllUsersName allUsers,
+      IdentifiedUser expectedUser) {
     super(keyCheckerFactory.create().setExpectedUser(expectedUser));
     this.repoManager = repoManager;
     this.allUsers = allUsers;
diff --git a/java/com/google/gerrit/gpg/GpgModule.java b/java/com/google/gerrit/gpg/GpgModule.java
index 45c1ab5..623b5f0 100644
--- a/java/com/google/gerrit/gpg/GpgModule.java
+++ b/java/com/google/gerrit/gpg/GpgModule.java
@@ -42,7 +42,6 @@
     }
     if (enableSignedPush) {
       install(new SignedPushModule());
-      factory(GerritPushCertificateChecker.Factory.class);
     }
     install(new GpgApiModule(enableSignedPush && configEditGpgKeys));
   }
diff --git a/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 27530e7..5347398 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -30,11 +30,12 @@
 import static org.bouncycastle.openpgp.PGPSignature.KEY_REVOCATION;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -62,7 +63,7 @@
   private PublicKeyStore store;
   private Map<Long, Fingerprint> trusted;
   private int maxTrustDepth;
-  private Date effectiveTime = new Date();
+  private Instant effectiveTime = Instant.now();
 
   /**
    * Enable web-of-trust checks.
@@ -111,12 +112,12 @@
    * @param effectiveTime effective time.
    * @return a reference to this object.
    */
-  public PublicKeyChecker setEffectiveTime(Date effectiveTime) {
+  public PublicKeyChecker setEffectiveTime(Instant effectiveTime) {
     this.effectiveTime = effectiveTime;
     return this;
   }
 
-  protected Date getEffectiveTime() {
+  protected Instant getEffectiveTime() {
     return effectiveTime;
   }
 
@@ -183,13 +184,13 @@
     return CheckResult.create(status, problems);
   }
 
-  private CheckResult checkBasic(PGPPublicKey key, Date now) {
+  private CheckResult checkBasic(PGPPublicKey key, Instant now) {
     List<String> problems = new ArrayList<>(2);
     gatherRevocationProblems(key, now, problems);
 
     long validMs = key.getValidSeconds() * 1000;
     if (validMs != 0) {
-      long msSinceCreation = now.getTime() - key.getCreationTime().getTime();
+      long msSinceCreation = now.toEpochMilli() - getCreationTime(key).toEpochMilli();
       if (msSinceCreation > validMs) {
         problems.add("Key is expired");
       }
@@ -197,7 +198,7 @@
     return CheckResult.create(problems);
   }
 
-  private void gatherRevocationProblems(PGPPublicKey key, Date now, List<String> problems) {
+  private void gatherRevocationProblems(PGPPublicKey key, Instant now, List<String> problems) {
     try {
       List<PGPSignature> revocations = new ArrayList<>();
       Map<Long, RevocationKey> revokers = new HashMap<>();
@@ -216,7 +217,7 @@
   }
 
   private static boolean isRevocationValid(
-      PGPSignature revocation, RevocationReason reason, Date now) {
+      PGPSignature revocation, RevocationReason reason, Instant now) {
     // RFC4880 states:
     // "If a key has been revoked because of a compromise, all signatures
     // created by that key are suspect. However, if it was merely superseded or
@@ -226,11 +227,15 @@
     // consider the revocation reason and timestamp when checking whether a
     // signature (data or certification) is valid.
     return reason.getRevocationReason() == KEY_COMPROMISED
-        || revocation.getCreationTime().before(now);
+        || PushCertificateChecker.getCreationTime(revocation).isBefore(now);
   }
 
+  @Nullable
   private PGPSignature scanRevocations(
-      PGPPublicKey key, Date now, List<PGPSignature> revocations, Map<Long, RevocationKey> revokers)
+      PGPPublicKey key,
+      Instant now,
+      List<PGPSignature> revocations,
+      Map<Long, RevocationKey> revokers)
       throws PGPException {
     @SuppressWarnings("unchecked")
     Iterator<PGPSignature> allSigs = key.getSignatures();
@@ -261,6 +266,7 @@
     return null;
   }
 
+  @Nullable
   private RevocationKey getRevocationKey(PGPPublicKey key, PGPSignature sig) throws PGPException {
     if (sig.getKeyID() != key.getKeyID()) {
       return null;
@@ -305,7 +311,7 @@
       if (rk.getAlgorithm() != revoker.getAlgorithm()) {
         continue;
       }
-      if (!checkBasic(rk, revocation.getCreationTime()).isOk()) {
+      if (!checkBasic(rk, PushCertificateChecker.getCreationTime(revocation)).isOk()) {
         // Revoker's key was expired or revoked at time of revocation, so the
         // revocation is invalid.
         continue;
@@ -317,6 +323,7 @@
     }
   }
 
+  @Nullable
   private static RevocationReason getRevocationReason(PGPSignature sig) {
     if (sig.getSignatureType() != KEY_REVOCATION) {
       throw new IllegalArgumentException(
@@ -422,6 +429,7 @@
     return CheckResult.create(OK, problems);
   }
 
+  @Nullable
   private static PGPPublicKey getSigner(
       PublicKeyStore store,
       PGPSignature sig,
@@ -452,6 +460,7 @@
     }
   }
 
+  @Nullable
   private String checkTrustSubpacket(PGPSignature sig, int depth) {
     SignatureSubpacket trustSub =
         sig.getHashedSubPackets().getSubpacket(SignatureSubpacketTags.TRUST_SIG);
@@ -469,4 +478,9 @@
     }
     return null;
   }
+
+  @SuppressWarnings("JdkObsolete")
+  private static Instant getCreationTime(PGPPublicKey key) {
+    return key.getCreationTime().toInstant();
+  }
 }
diff --git a/java/com/google/gerrit/gpg/PublicKeyStore.java b/java/com/google/gerrit/gpg/PublicKeyStore.java
index 2cce480..d167ac8 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStore.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStore.java
@@ -21,6 +21,7 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.ObjectIds;
 import java.io.ByteArrayInputStream;
@@ -92,6 +93,7 @@
    *     null} if none was found.
    * @throws PGPException if an error occurred verifying the signature.
    */
+  @Nullable
   public static PGPPublicKey getSigner(
       Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, byte[] data) throws PGPException {
     for (PGPPublicKeyRing kr : keyRings) {
@@ -126,6 +128,7 @@
    *     {@code null} if none was found.
    * @throws PGPException if an error occurred verifying the certification.
    */
+  @Nullable
   public static PGPPublicKey getSigner(
       Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, String userId, PGPPublicKey key)
       throws PGPException {
@@ -210,6 +213,7 @@
    * @throws PGPException if an error occurred parsing the key data.
    * @throws IOException if an error occurred reading the repository data.
    */
+  @Nullable
   public PGPPublicKeyRing get(byte[] fingerprint) throws PGPException, IOException {
     List<PGPPublicKeyRing> keyRings = get(Fingerprint.getId(fingerprint), fingerprint);
     return !keyRings.isEmpty() ? keyRings.get(0) : null;
diff --git a/java/com/google/gerrit/gpg/PushCertificateChecker.java b/java/com/google/gerrit/gpg/PushCertificateChecker.java
index 36a4af7..b9ff50b 100644
--- a/java/com/google/gerrit/gpg/PushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/PushCertificateChecker.java
@@ -22,9 +22,11 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import org.bouncycastle.bcpg.ArmoredInputStream;
@@ -106,7 +108,7 @@
       }
     } catch (PGPException | IOException e) {
       String msg = "Internal error checking push certificate";
-      logger.atSevere().withCause(e).log(msg);
+      logger.atSevere().withCause(e).log("%s", msg);
       results.add(CheckResult.bad(msg));
     }
 
@@ -175,6 +177,7 @@
     return CheckResult.ok();
   }
 
+  @Nullable
   private PGPSignature readSignature(PushCertificate cert) throws IOException {
     ArmoredInputStream in =
         new ArmoredInputStream(new ByteArrayInputStream(Constants.encode(cert.getSignature())));
@@ -205,7 +208,7 @@
           null, CheckResult.bad("Signature by " + keyIdToString(sig.getKeyID()) + " is not valid"));
     }
     CheckResult result =
-        publicKeyChecker.setStore(store).setEffectiveTime(sig.getCreationTime()).check(signer);
+        publicKeyChecker.setStore(store).setEffectiveTime(getCreationTime(sig)).check(signer);
     if (!result.getProblems().isEmpty()) {
       StringBuilder err =
           new StringBuilder("Invalid public key ")
@@ -216,4 +219,9 @@
     }
     return new Result(signer, result);
   }
+
+  @SuppressWarnings("JdkObsolete")
+  public static Instant getCreationTime(PGPSignature signature) {
+    return signature.getCreationTime().toInstant();
+  }
 }
diff --git a/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
index 21a5b6e..abc51c2 100644
--- a/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
+++ b/java/com/google/gerrit/gpg/SignedPushPreReceiveHook.java
@@ -48,11 +48,11 @@
   }
 
   private final Provider<IdentifiedUser> user;
-  private final GerritPushCertificateChecker.Factory checkerFactory;
+  private final GerritPushCertificateCheckerFactory checkerFactory;
 
   @Inject
   public SignedPushPreReceiveHook(
-      Provider<IdentifiedUser> user, GerritPushCertificateChecker.Factory checkerFactory) {
+      Provider<IdentifiedUser> user, GerritPushCertificateCheckerFactory checkerFactory) {
     this.user = user;
     this.checkerFactory = checkerFactory;
   }
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 652afea..6ae0334 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.gpg.GerritPushCertificateChecker;
+import com.google.gerrit.gpg.GerritPushCertificateCheckerFactory;
 import com.google.gerrit.gpg.PushCertificateChecker;
 import com.google.gerrit.gpg.server.GpgKeys;
 import com.google.gerrit.gpg.server.PostGpgKeys;
@@ -44,14 +44,14 @@
   private final Provider<PostGpgKeys> postGpgKeys;
   private final Provider<GpgKeys> gpgKeys;
   private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
-  private final GerritPushCertificateChecker.Factory pushCertCheckerFactory;
+  private final GerritPushCertificateCheckerFactory pushCertCheckerFactory;
 
   @Inject
   GpgApiAdapterImpl(
       Provider<PostGpgKeys> postGpgKeys,
       Provider<GpgKeys> gpgKeys,
       GpgKeyApiImpl.Factory gpgKeyApiFactory,
-      GerritPushCertificateChecker.Factory pushCertCheckerFactory) {
+      GerritPushCertificateCheckerFactory pushCertCheckerFactory) {
     this.postGpgKeys = postGpgKeys;
     this.gpgKeys = gpgKeys;
     this.gpgKeyApiFactory = gpgKeyApiFactory;
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index e0c921d..bcc8631 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -95,7 +95,7 @@
 
       CommitBuilder cb = new CommitBuilder();
       PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
+      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer));
       cb.setCommitter(committer);
       cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
 
diff --git a/java/com/google/gerrit/gpg/server/GpgKey.java b/java/com/google/gerrit/gpg/server/GpgKey.java
index aa6b6f4..fbe97ad 100644
--- a/java/com/google/gerrit/gpg/server/GpgKey.java
+++ b/java/com/google/gerrit/gpg/server/GpgKey.java
@@ -21,8 +21,7 @@
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
 
 public class GpgKey extends AccountResource {
-  public static final TypeLiteral<RestView<GpgKey>> GPG_KEY_KIND =
-      new TypeLiteral<RestView<GpgKey>>() {};
+  public static final TypeLiteral<RestView<GpgKey>> GPG_KEY_KIND = new TypeLiteral<>() {};
 
   private final PGPPublicKeyRing keyRing;
 
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index d46b344..d51ee6a 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -29,6 +29,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
@@ -164,7 +165,7 @@
     }
   }
 
-  private Map<ExternalId, Fingerprint> readKeysToRemove(
+  private ImmutableMap<ExternalId, Fingerprint> readKeysToRemove(
       GpgKeysInput input, Collection<ExternalId> existingExtIds) {
     if (input.delete == null || input.delete.isEmpty()) {
       return ImmutableMap.of();
@@ -179,10 +180,11 @@
         // Skip removal.
       }
     }
-    return fingerprints;
+    return ImmutableMap.copyOf(fingerprints);
   }
 
-  private List<PGPPublicKeyRing> readKeysToAdd(GpgKeysInput input, Collection<Fingerprint> toRemove)
+  private ImmutableList<PGPPublicKeyRing> readKeysToAdd(
+      GpgKeysInput input, Collection<Fingerprint> toRemove)
       throws BadRequestException, IOException {
     if (input.add == null || input.add.isEmpty()) {
       return ImmutableList.of();
@@ -206,7 +208,7 @@
         throw new BadRequestException("Failed to parse GPG keys", e);
       }
     }
-    return keyRings;
+    return ImmutableList.copyOf(keyRings);
   }
 
   private void storeKeys(
@@ -249,7 +251,7 @@
       }
       CommitBuilder cb = new CommitBuilder();
       PersonIdent committer = serverIdent.get();
-      cb.setAuthor(user.newCommitterIdent(committer.getWhen(), committer.getTimeZone()));
+      cb.setAuthor(user.newCommitterIdent(committer));
       cb.setCommitter(committer);
 
       RefUpdate.Result saveResult = store.save(cb);
@@ -298,6 +300,7 @@
     return externalIdKeyFactory.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
   }
 
+  @Nullable
   private Account getAccountByExternalId(ExternalId.Key extIdKey) {
     List<AccountState> accountStates = accountQueryProvider.get().byExternalId(extIdKey);
 
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index ea7c609..0142031 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -37,10 +37,12 @@
         "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
+        "//lib/jsoup",
     ],
 )
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 92e16ce..9625039 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -19,6 +19,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.httpd.restapi.ParameterParser;
@@ -40,7 +41,9 @@
 
 public abstract class CacheBasedWebSession extends WebSession {
   @VisibleForTesting public static final String ACCOUNT_COOKIE = "GerritAccount";
-  protected static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
+
+  @UsedAt(UsedAt.Project.PLUGIN_WEBSESSION_FLATFILE)
+  public static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
 
   private final HttpServletRequest request;
   private final HttpServletResponse response;
@@ -113,6 +116,7 @@
     }
   }
 
+  @Nullable
   private static String readCookie(HttpServletRequest request) {
     Cookie[] all = request.getCookies();
     if (all != null) {
@@ -216,6 +220,7 @@
     }
   }
 
+  @Nullable
   @Override
   public String getSessionId() {
     return val != null ? val.getSessionId() : null;
diff --git a/java/com/google/gerrit/httpd/CanonicalWebUrl.java b/java/com/google/gerrit/httpd/CanonicalWebUrl.java
index 437ddf3..3b04884 100644
--- a/java/com/google/gerrit/httpd/CanonicalWebUrl.java
+++ b/java/com/google/gerrit/httpd/CanonicalWebUrl.java
@@ -37,6 +37,7 @@
     return url != null ? url : computeFromRequest(req);
   }
 
+  @SuppressWarnings("JdkObsolete")
   static String computeFromRequest(HttpServletRequest req) {
     StringBuffer url = req.getRequestURL();
     try {
diff --git a/java/com/google/gerrit/httpd/CookieBase64.java b/java/com/google/gerrit/httpd/CookieBase64.java
index 52cfde7..376ae1d 100644
--- a/java/com/google/gerrit/httpd/CookieBase64.java
+++ b/java/com/google/gerrit/httpd/CookieBase64.java
@@ -75,7 +75,7 @@
         out.append(enc[(inBuff >>> 18)]);
         out.append(enc[(inBuff >>> 12) & 0x3f]);
         out.append(enc[(inBuff >>> 6) & 0x3f]);
-        out.append(enc[(inBuff) & 0x3f]);
+        out.append(enc[inBuff & 0x3f]);
         break;
 
       case 2:
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index dcdbc13..e513a72 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.httpd;
 
+import static org.eclipse.jgit.http.server.GitSmartHttpTools.sendError;
+
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -54,6 +58,7 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.text.MessageFormat;
 import java.time.Duration;
 import java.util.Arrays;
 import java.util.Collections;
@@ -70,10 +75,13 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponseWrapper;
+import org.eclipse.jgit.errors.PackProtocolException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.http.server.GitServlet;
 import org.eclipse.jgit.http.server.GitSmartHttpTools;
+import org.eclipse.jgit.http.server.HttpServerText;
 import org.eclipse.jgit.http.server.ServletUtils;
+import org.eclipse.jgit.http.server.UploadPackErrorHandler;
 import org.eclipse.jgit.http.server.resolver.AsIsFileService;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -100,6 +108,23 @@
   private static final String ID_CACHE = "adv_bases";
 
   public static final String URL_REGEX;
+  public static final String GIT_COMMAND_STATUS_HEADER = "X-git-command-status";
+
+  private enum GIT_COMMAND_STATUS {
+    OK(0),
+    FAIL(-1);
+
+    private final int exitStatus;
+
+    GIT_COMMAND_STATUS(int exitStatus) {
+      this.exitStatus = exitStatus;
+    }
+
+    @Override
+    public String toString() {
+      return Integer.toString(exitStatus);
+    }
+  }
 
   static {
     StringBuilder url = new StringBuilder();
@@ -212,12 +237,14 @@
       Resolver resolver,
       UploadFactory upload,
       UploadFilter uploadFilter,
+      GerritUploadPackErrorHandler uploadPackErrorHandler,
       ReceivePackFactory<HttpServletRequest> receive,
       ReceiveFilter receiveFilter) {
     setRepositoryResolver(resolver);
     setAsIsFileService(AsIsFileService.DISABLED);
 
     setUploadPackFactory(upload);
+    setUploadPackErrorHandler(uploadPackErrorHandler);
     addUploadPackFilter(uploadFilter);
 
     setReceivePackFactory(receive);
@@ -406,14 +433,14 @@
         requestListeners.runEach(l -> l.onRequest(requestInfo));
 
         try {
-          perm.check(ProjectPermission.RUN_UPLOAD_PACK);
-        } catch (AuthException e) {
-          GitSmartHttpTools.sendError(
-              (HttpServletRequest) request,
-              responseWrapper,
-              HttpServletResponse.SC_FORBIDDEN,
-              "upload-pack not permitted on this server");
-          return;
+          if (!perm.test(ProjectPermission.RUN_UPLOAD_PACK)) {
+            GitSmartHttpTools.sendError(
+                (HttpServletRequest) request,
+                responseWrapper,
+                HttpServletResponse.SC_FORBIDDEN,
+                "upload-pack not permitted on this server");
+            return;
+          }
         } catch (PermissionBackendException e) {
           responseWrapper.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
           throw new ServletException(e);
@@ -456,6 +483,36 @@
     public void destroy() {}
   }
 
+  static class GerritUploadPackErrorHandler implements UploadPackErrorHandler {
+    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+    @Override
+    public void upload(HttpServletRequest req, HttpServletResponse rsp, UploadPackRunnable r)
+        throws IOException {
+      rsp.setHeader(GIT_COMMAND_STATUS_HEADER, GIT_COMMAND_STATUS.FAIL.toString());
+      try {
+        r.upload();
+        rsp.setHeader(GIT_COMMAND_STATUS_HEADER, GIT_COMMAND_STATUS.OK.toString());
+      } catch (ServiceMayNotContinueException e) {
+        if (!e.isOutput() && !rsp.isCommitted()) {
+          rsp.reset();
+          sendError(req, rsp, e.getStatusCode(), e.getMessage());
+        }
+      } catch (Throwable e) {
+        logger.atSevere().withCause(e).log(
+            "%s",
+            MessageFormat.format(
+                HttpServerText.get().internalErrorDuringUploadPack,
+                ServletUtils.getRepository(req)));
+        if (!rsp.isCommitted()) {
+          rsp.reset();
+          String msg = e instanceof PackProtocolException ? e.getMessage() : null;
+          sendError(req, rsp, UploadPackErrorHandler.statusCodeForThrowable(e), msg);
+        }
+      }
+    }
+  }
+
   static class ReceiveFactory implements ReceivePackFactory<HttpServletRequest> {
     private final AsyncReceiveCommits.Factory factory;
     private final Provider<CurrentUser> userProvider;
@@ -471,7 +528,7 @@
         throws ServiceNotAuthorizedException {
       final ProjectState state = (ProjectState) req.getAttribute(ATT_STATE);
 
-      if (!(userProvider.get().isIdentifiedUser())) {
+      if (!userProvider.get().isIdentifiedUser()) {
         // Anonymous users are not permitted to push.
         throw new ServiceNotAuthorizedException();
       }
@@ -535,18 +592,18 @@
       Capable canUpload;
       try {
         try {
-          permissionBackend
+          if (!permissionBackend
               .currentUser()
               .project(state.getNameKey())
-              .check(ProjectPermission.RUN_RECEIVE_PACK);
+              .test(ProjectPermission.RUN_RECEIVE_PACK)) {
+            GitSmartHttpTools.sendError(
+                httpRequest,
+                responseWrapper,
+                HttpServletResponse.SC_FORBIDDEN,
+                "receive-pack not permitted on this server");
+            return;
+          }
           canUpload = arc.canUpload();
-        } catch (AuthException e) {
-          GitSmartHttpTools.sendError(
-              httpRequest,
-              responseWrapper,
-              HttpServletResponse.SC_FORBIDDEN,
-              "receive-pack not permitted on this server");
-          return;
         } catch (PermissionBackendException e) {
           throw new RuntimeException(e);
         }
@@ -578,7 +635,7 @@
         return;
       }
 
-      if (!(userProvider.get().isIdentifiedUser())) {
+      if (!userProvider.get().isIdentifiedUser()) {
         chain.doFilter(request, responseWrapper);
         return;
       }
@@ -610,6 +667,7 @@
     public void destroy() {}
   }
 
+  @Nullable
   private static String getSessionIdOrNull(Provider<WebSession> sessionProvider) {
     WebSession session = sessionProvider.get();
     if (session.isSignedIn()) {
diff --git a/java/com/google/gerrit/httpd/GuiceRequestScopePropagator.java b/java/com/google/gerrit/httpd/GuiceRequestScopePropagator.java
new file mode 100644
index 0000000..7c8094a
--- /dev/null
+++ b/java/com/google/gerrit/httpd/GuiceRequestScopePropagator.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.RemotePeer;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.Key;
+import com.google.inject.Provider;
+import com.google.inject.servlet.ServletScopes;
+import com.google.inject.util.Providers;
+import com.google.inject.util.Types;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.net.SocketAddress;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import javax.servlet.http.HttpServletRequest;
+
+/** Propagator for Guice's built-in servlet scope. */
+public class GuiceRequestScopePropagator extends RequestScopePropagator {
+
+  private final String url;
+  private final SocketAddress peer;
+  private final Provider<HttpServletRequest> request;
+
+  @Inject
+  GuiceRequestScopePropagator(
+      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
+      @RemotePeer Provider<SocketAddress> remotePeerProvider,
+      ThreadLocalRequestContext local,
+      Provider<HttpServletRequest> request) {
+    super(ServletScopes.REQUEST, local);
+    this.url = urlProvider != null ? urlProvider.get() : null;
+    this.peer = remotePeerProvider.get();
+    this.request = request;
+  }
+
+  /** @see RequestScopePropagator#wrap(Callable) */
+  // ServletScopes#continueRequest is deprecated, but it's not obvious their
+  // recommended replacement is an appropriate drop-in solution; see
+  // https://gerrit-review.googlesource.com/83971
+  @SuppressWarnings("deprecation")
+  @Override
+  protected <T> Callable<T> wrapImpl(Callable<T> callable) {
+    Map<Key<?>, Object> seedMap = new HashMap<>();
+
+    // Request scopes appear to use specific keys in their map, instead of only
+    // providers. Add bindings for both the key to the instance directly and the
+    // provider to the instance to be safe.
+    seedMap.put(Key.get(typeOfProvider(String.class), CanonicalWebUrl.class), Providers.of(url));
+    seedMap.put(Key.get(String.class, CanonicalWebUrl.class), url);
+
+    seedMap.put(Key.get(typeOfProvider(SocketAddress.class), RemotePeer.class), Providers.of(peer));
+    seedMap.put(Key.get(SocketAddress.class, RemotePeer.class), peer);
+
+    Key<?> webSessionAttrKey = Key.get(WebSession.class);
+    Object webSessionAttrValue = request.get().getAttribute(webSessionAttrKey.toString());
+    seedMap.put(webSessionAttrKey, webSessionAttrValue);
+    seedMap.put(Key.get(typeOfProvider(WebSession.class)), Providers.of(webSessionAttrValue));
+
+    return ServletScopes.continueRequest(callable, seedMap);
+  }
+
+  private ParameterizedType typeOfProvider(Type type) {
+    return Types.newParameterizedType(Provider.class, type);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/HtmlDomUtil.java b/java/com/google/gerrit/httpd/HtmlDomUtil.java
index 57f2664..16e0938 100644
--- a/java/com/google/gerrit/httpd/HtmlDomUtil.java
+++ b/java/com/google/gerrit/httpd/HtmlDomUtil.java
@@ -16,7 +16,9 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -25,6 +27,8 @@
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 import java.util.zip.GZIPOutputStream;
 import javax.xml.parsers.DocumentBuilder;
 import javax.xml.parsers.DocumentBuilderFactory;
@@ -39,6 +43,7 @@
 import javax.xml.xpath.XPathExpression;
 import javax.xml.xpath.XPathExpressionException;
 import javax.xml.xpath.XPathFactory;
+import org.jsoup.parser.Parser;
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
 import org.w3c.dom.Node;
@@ -47,6 +52,8 @@
 
 /** Utility functions to deal with HTML using W3C DOM operations. */
 public class HtmlDomUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   /** Standard character encoding we prefer (UTF-8). */
   public static final Charset ENC = UTF_8;
 
@@ -89,6 +96,7 @@
   }
 
   /** Find an element by its "id" attribute; null if no element is found. */
+  @Nullable
   public static Element find(Node parent, String name) {
     NodeList list = parent.getChildNodes();
     for (int i = 0; i < list.getLength(); i++) {
@@ -139,6 +147,7 @@
   }
 
   /** Parse an XHTML file from our CLASSPATH and return the instance. */
+  @Nullable
   public static Document parseFile(Class<?> context, String name) throws IOException {
     try (InputStream in = context.getResourceAsStream(name)) {
       if (in == null) {
@@ -168,6 +177,7 @@
   }
 
   /** Read a Read a UTF-8 text file from our CLASSPATH and return it. */
+  @Nullable
   public static String readFile(Class<?> context, String name) throws IOException {
     try (InputStream in = context.getResourceAsStream(name)) {
       if (in == null) {
@@ -180,6 +190,7 @@
   }
 
   /** Parse an XHTML file from the local drive and return the instance. */
+  @Nullable
   public static Document parseFile(Path path) throws IOException {
     try (InputStream in = Files.newInputStream(path)) {
       Document doc = newBuilder().parse(in);
@@ -193,6 +204,7 @@
   }
 
   /** Read a UTF-8 text file from the local drive. */
+  @Nullable
   public static String readFile(Path parentDir, String name) throws IOException {
     if (parentDir == null) {
       return null;
@@ -215,4 +227,27 @@
     factory.setCoalescing(true);
     return factory.newDocumentBuilder();
   }
+
+  /**
+   * Attaches nonce to all script elements in html.
+   *
+   * <p>The returned html is not guaranteed to have the same formatting as the input.
+   *
+   * @return Updated html or {#link Optional.empty()} if parsing failed.
+   */
+  public static Optional<String> attachNonce(String html, String nonce) {
+    Parser parser = Parser.htmlParser();
+    org.jsoup.nodes.Document document = parser.parseInput(html, "");
+    if (!parser.getErrors().isEmpty()) {
+      logger.atSevere().atMostEvery(5, TimeUnit.MINUTES).log(
+          "Html couldn't be parsed to attach nonce. Errors: %s", parser.getErrors());
+      return Optional.empty();
+    }
+    document.getElementsByTag("script").attr("nonce", nonce);
+    return Optional.of(
+        document
+            .outputSettings(
+                new org.jsoup.nodes.Document.OutputSettings().prettyPrint(false).indentAmount(0))
+            .outerHtml());
+  }
 }
diff --git a/java/com/google/gerrit/httpd/HttpdModule.java b/java/com/google/gerrit/httpd/HttpdModule.java
index 1f1ec2f..41cef7a 100644
--- a/java/com/google/gerrit/httpd/HttpdModule.java
+++ b/java/com/google/gerrit/httpd/HttpdModule.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.httpd;
 
 import com.google.gerrit.extensions.annotations.Exports;
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index a421139..b0c7615 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -186,7 +186,7 @@
       if (passwordVerifier.checkPassword(who.externalIds(), username, password)) {
         return succeedAuthentication(who, null);
       }
-      logger.atWarning().withCause(e).log(authenticationFailedMsg(username, req));
+      logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     } catch (AuthenticationFailedException e) {
@@ -200,7 +200,7 @@
       rsp.sendError(SC_SERVICE_UNAVAILABLE);
       return false;
     } catch (AccountException e) {
-      logger.atWarning().withCause(e).log(authenticationFailedMsg(username, req));
+      logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
@@ -214,8 +214,8 @@
   private boolean failAuthentication(Response rsp, String username, HttpServletRequest req)
       throws IOException {
     logger.atWarning().log(
-        authenticationFailedMsg(username, req)
-            + ": password does not match the one stored in Gerrit");
+        "%s: password does not match the one stored in Gerrit",
+        authenticationFailedMsg(username, req));
     rsp.sendError(SC_UNAUTHORIZED);
     return false;
   }
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index fa53053..5a99cab 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -158,8 +159,8 @@
         accountCache.getByUsername(authInfo.username).filter(a -> a.account().isActive());
     if (!who.isPresent()) {
       logger.atWarning().log(
-          authenticationFailedMsg(authInfo.username, req)
-              + ": account inactive or not provisioned in Gerrit");
+          "%s: account inactive or not provisioned in Gerrit",
+          authenticationFailedMsg(authInfo.username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
@@ -180,7 +181,7 @@
       ws.setAccessPathOk(AccessPath.REST_API, true);
       return true;
     } catch (AccountException e) {
-      logger.atWarning().withCause(e).log(authenticationFailedMsg(authInfo.username, req));
+      logger.atWarning().withCause(e).log("%s", authenticationFailedMsg(authInfo.username, req));
       rsp.sendError(SC_UNAUTHORIZED);
       return false;
     }
@@ -226,6 +227,7 @@
     }
   }
 
+  @Nullable
   private AuthInfo extractAuthInfo(String hdr, String encoding)
       throws UnsupportedEncodingException {
     byte[] decoded = BaseEncoding.base64().decode(hdr.substring(BASIC.length()));
@@ -241,6 +243,7 @@
         defaultAuthProvider);
   }
 
+  @Nullable
   private AuthInfo extractAuthInfo(Cookie cookie) throws UnsupportedEncodingException {
     String username =
         URLDecoder.decode(cookie.getName().substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
@@ -272,6 +275,7 @@
     return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
   }
 
+  @Nullable
   private static Cookie findGitCookie(HttpServletRequest req) {
     Cookie[] cookies = req.getCookies();
     if (cookies != null) {
diff --git a/java/com/google/gerrit/httpd/RemoteUserUtil.java b/java/com/google/gerrit/httpd/RemoteUserUtil.java
index 84954dc..6f3e9c4 100644
--- a/java/com/google/gerrit/httpd/RemoteUserUtil.java
+++ b/java/com/google/gerrit/httpd/RemoteUserUtil.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
 import javax.servlet.http.HttpServletRequest;
 
 public class RemoteUserUtil {
@@ -62,6 +63,7 @@
    * @param auth header value which is used for extracting.
    * @return username if available or null.
    */
+  @Nullable
   public static String extractUsername(String auth) {
     auth = emptyToNull(auth);
 
diff --git a/java/com/google/gerrit/httpd/RequireSslFilter.java b/java/com/google/gerrit/httpd/RequireSslFilter.java
index a4a87e2..ca3c3d8 100644
--- a/java/com/google/gerrit/httpd/RequireSslFilter.java
+++ b/java/com/google/gerrit/httpd/RequireSslFilter.java
@@ -78,10 +78,7 @@
       //
       final String url;
       if (isLocalHost(req)) {
-        final StringBuffer b = req.getRequestURL();
-        b.replace(0, b.indexOf(":"), "https");
-        url = b.toString();
-
+        url = getLocalHostUrl(req);
       } else {
         url = urlProvider.get() + req.getServletPath();
       }
@@ -90,6 +87,13 @@
     }
   }
 
+  @SuppressWarnings("JdkObsolete")
+  private static String getLocalHostUrl(HttpServletRequest req) {
+    StringBuffer b = req.getRequestURL();
+    b.replace(0, b.indexOf(":"), "https");
+    return b.toString();
+  }
+
   private static boolean isSecure(HttpServletRequest req) {
     return "https".equals(req.getScheme()) || req.isSecure();
   }
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 029efba..69adf82 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -92,11 +92,17 @@
     // which is bound in HttpPluginModule. We cannot bind it here again although
     // this means that plugins can't add REST views on PLUGIN_KIND.
     serveRegex("^/(?:a/)?access/(.*)$").with(AccessRestApiServlet.class);
+    serveRegex("^/(?:a/)?access$").with(AccessRestApiServlet.class);
     serveRegex("^/(?:a/)?accounts/(.*)$").with(AccountsRestApiServlet.class);
+    serveRegex("^/(?:a/)?accounts$").with(AccountsRestApiServlet.class);
     serveRegex("^/(?:a/)?changes/(.*)$").with(ChangesRestApiServlet.class);
+    serveRegex("^/(?:a/)?changes$").with(ChangesRestApiServlet.class);
     serveRegex("^/(?:a/)?config/(.*)$").with(ConfigRestApiServlet.class);
+    serveRegex("^/(?:a/)?config$").with(ConfigRestApiServlet.class);
     serveRegex("^/(?:a/)?groups/(.*)?$").with(GroupsRestApiServlet.class);
+    serveRegex("^/(?:a/)?groups$").with(GroupsRestApiServlet.class);
     serveRegex("^/(?:a/)?projects/(.*)?$").with(ProjectsRestApiServlet.class);
+    serveRegex("^/(?:a/)?projects$").with(ProjectsRestApiServlet.class);
 
     serveRegex("^/Documentation$").with(redirectDocumentation());
     serveRegex("^/Documentation/$").with(redirectDocumentation());
diff --git a/java/com/google/gerrit/httpd/WebModule.java b/java/com/google/gerrit/httpd/WebModule.java
index da485cc..79dde85 100644
--- a/java/com/google/gerrit/httpd/WebModule.java
+++ b/java/com/google/gerrit/httpd/WebModule.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.git.receive.AsyncReceiveCommits.AsyncReceiveCommitsModule;
-import com.google.gerrit.server.util.GuiceRequestScopePropagator;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
diff --git a/java/com/google/gerrit/httpd/WebSessionManager.java b/java/com/google/gerrit/httpd/WebSessionManager.java
index 87bf3a6..1137b65 100644
--- a/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -30,6 +30,7 @@
 
 import com.google.common.cache.Cache;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
@@ -149,6 +150,7 @@
     return -1;
   }
 
+  @Nullable
   Val get(Key key) {
     Val val = self.getIfPresent(key.token);
     if (val != null && val.expiresAt <= nowMs()) {
diff --git a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 2f760f0..bc8a01a 100644
--- a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -185,6 +186,7 @@
     return account.map(a -> new AuthResult(a.account().id(), null, false));
   }
 
+  @Nullable
   private AuthResult auth(Account.Id account) {
     if (account != null) {
       return new AuthResult(account, null, false);
@@ -192,6 +194,7 @@
     return null;
   }
 
+  @Nullable
   private AuthResult byUserName(String userName) {
     List<AccountState> accountStates = queryProvider.get().byExternalId(SCHEME_USERNAME, userName);
     if (accountStates.isEmpty()) {
@@ -223,6 +226,7 @@
     }
   }
 
+  @Nullable
   private AuthResult create() throws IOException {
     try {
       return accountManager.authenticate(
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index acb3282..be833ea 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -21,6 +21,7 @@
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.httpd.RemoteUserUtil;
@@ -143,6 +144,7 @@
         : remoteUser;
   }
 
+  @Nullable
   String getRemoteDisplayname(HttpServletRequest req) {
     if (displaynameHeader != null) {
       String raw = req.getHeader(displaynameHeader);
@@ -151,6 +153,7 @@
     return null;
   }
 
+  @Nullable
   String getRemoteEmail(HttpServletRequest req) {
     if (emailHeader != null) {
       return emptyToNull(req.getHeader(emailHeader));
@@ -158,6 +161,7 @@
     return null;
   }
 
+  @Nullable
   String getRemoteExternalIdToken(HttpServletRequest req) {
     if (externalIdHeader != null) {
       return emptyToNull(req.getHeader(externalIdHeader));
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
index 820c7a2..59a7379 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.httpd.auth.container;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.server.account.AccountException;
@@ -36,8 +35,6 @@
 
 @Singleton
 class HttpsClientSslCertAuthFilter implements Filter {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final Pattern REGEX_USERID = Pattern.compile("CN=([^,]*)");
 
   private final DynamicItem<WebSession> webSession;
@@ -79,9 +76,7 @@
     try {
       arsp = accountManager.authenticate(areq);
     } catch (AccountException e) {
-      String err = "Unable to authenticate user \"" + userName + "\"";
-      logger.atSevere().withCause(e).log(err);
-      throw new ServletException(err, e);
+      throw new ServletException("Unable to authenticate user \"" + userName + "\"", e);
     }
     webSession.get().login(arsp, true);
     chain.doFilter(req, rsp);
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index a3f8fbda..297505a 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -190,7 +190,7 @@
     } else if (claimedId.isPresent() && !actualId.isPresent()) {
       // Claimed account already exists: link to it.
       //
-      logger.atInfo().log("OAuth2: linking claimed identity to %s", claimedId.get().toString());
+      logger.atInfo().log("OAuth2: linking claimed identity to %s", claimedId.get());
       try {
         accountManager.link(claimedId.get(), req);
       } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
index 2642a543..935762f 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthWebFilter.java
@@ -31,9 +31,9 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Map;
+import java.util.NavigableMap;
+import java.util.NavigableSet;
 import java.util.Set;
-import java.util.SortedMap;
-import java.util.SortedSet;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -175,12 +175,12 @@
   }
 
   private void pickSSOServiceProvider() throws ServletException {
-    SortedSet<String> plugins = oauthServiceProviders.plugins();
+    NavigableSet<String> plugins = oauthServiceProviders.plugins();
     if (plugins.isEmpty()) {
       throw new ServletException("OAuth service provider wasn't installed");
     }
     if (plugins.size() == 1) {
-      SortedMap<String, Provider<OAuthServiceProvider>> services =
+      NavigableMap<String, Provider<OAuthServiceProvider>> services =
           oauthServiceProviders.byPlugin(Iterables.getOnlyElement(plugins));
       if (services.size() == 1) {
         ssoProvider = Iterables.getOnlyElement(services.values()).get();
diff --git a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
index 0b6008c..e7057ad 100644
--- a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
+++ b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
@@ -334,6 +334,7 @@
     form.appendChild(div);
   }
 
+  @Nullable
   private OAuthServiceProvider lookupOAuthServiceProvider(String providerId) {
     if (providerId.startsWith("http://")) {
       providerId = providerId.substring("http://".length());
@@ -350,6 +351,7 @@
     return null;
   }
 
+  @Nullable
   private static String getLastId(HttpServletRequest req) {
     Cookie[] cookies = req.getCookies();
     if (cookies != null) {
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
index 2e8585d..3d9c819 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthWebFilterOverOpenID.java
@@ -21,8 +21,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.NavigableMap;
+import java.util.NavigableSet;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -79,9 +79,9 @@
   }
 
   private void pickSSOServiceProvider() {
-    SortedSet<String> plugins = oauthServiceProviders.plugins();
+    NavigableSet<String> plugins = oauthServiceProviders.plugins();
     if (plugins.size() == 1) {
-      SortedMap<String, Provider<OAuthServiceProvider>> services =
+      NavigableMap<String, Provider<OAuthServiceProvider>> services =
           oauthServiceProviders.byPlugin(Iterables.getOnlyElement(plugins));
       if (services.size() == 1) {
         ssoProvider = Iterables.getOnlyElement(services.values()).get();
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index cf3562f..0c71d68 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.httpd.auth.openid;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.auth.openid.OpenIdUrls;
 import com.google.gerrit.entities.Account;
@@ -178,7 +179,7 @@
         aReq.addExtension(pape);
       }
     } catch (MessageException | ConsumerException e) {
-      logger.atSevere().withCause(e).log("Cannot create OpenID redirect for %s" + openidIdentifier);
+      logger.atSevere().withCause(e).log("Cannot create OpenID redirect for %s", openidIdentifier);
       return new DiscoveryResult(DiscoveryResult.Status.ERROR);
     }
 
@@ -518,6 +519,7 @@
     rsp.sendRedirect(rdr.toString());
   }
 
+  @Nullable
   private State init(
       HttpServletRequest req,
       final String openidIdentifier,
diff --git a/java/com/google/gerrit/httpd/auth/restapi/BUILD b/java/com/google/gerrit/httpd/auth/restapi/BUILD
index d499768..9ab51c5 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/BUILD
+++ b/java/com/google/gerrit/httpd/auth/restapi/BUILD
@@ -6,6 +6,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/auth",
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
         "//lib/flogger:api",
diff --git a/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
index 3594c7c..2eee415 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
+++ b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.auth.oauth.OAuthToken;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -70,6 +71,7 @@
     return Response.ok(accessTokenInfo);
   }
 
+  @Nullable
   private static String getHostName(String canonicalWebUrl) {
     if (canonicalWebUrl == null) {
       logger.atSevere().log(
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 897d96f..4270150 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -43,12 +43,13 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitwebCgiConfig;
 import com.google.gerrit.server.config.GitwebConfig;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.DelegateRepository;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LocalDiskRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
@@ -73,7 +74,7 @@
 import java.net.URISyntaxException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.Enumeration;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -85,6 +86,7 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
@@ -101,13 +103,14 @@
   private final Set<String> deniedActions;
   private final Path gitwebCgi;
   private final URI gitwebUrl;
-  private final LocalDiskRepositoryManager repoManager;
+  private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
   private final Provider<AnonymousUser> anonymousUserProvider;
   private final Provider<CurrentUser> userProvider;
   private final EnvList _env;
 
+  @SuppressWarnings("CheckReturnValue")
   @Inject
   GitwebServlet(
       GitRepositoryManager repoManager,
@@ -119,12 +122,10 @@
       SshInfo sshInfo,
       Provider<AnonymousUser> anonymousUserProvider,
       GitwebConfig gitwebConfig,
-      GitwebCgiConfig gitwebCgiConfig)
+      GitwebCgiConfig gitwebCgiConfig,
+      AllProjectsName allProjects)
       throws IOException {
-    if (!(repoManager instanceof LocalDiskRepositoryManager)) {
-      throw new ProvisionException("Gitweb can only be used with LocalDiskRepositoryManager");
-    }
-    this.repoManager = (LocalDiskRepositoryManager) repoManager;
+    this.repoManager = repoManager;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.anonymousUserProvider = anonymousUserProvider;
@@ -132,8 +133,11 @@
     this.gitwebCgi = gitwebCgiConfig.getGitwebCgi();
     this.deniedActions = new HashSet<>();
 
+    // ensure that Gitweb works on supported repository type by checking All-Projects project
+    getProjectRoot(allProjects);
+
     final String url = gitwebConfig.getUrl();
-    if ((url != null) && (!url.equals("gitweb"))) {
+    if (url != null && !url.equals("gitweb")) {
       URI uri = null;
       try {
         uri = new URI(url);
@@ -537,7 +541,8 @@
     }
   }
 
-  private String[] makeEnv(HttpServletRequest req, ProjectState projectState) {
+  private String[] makeEnv(HttpServletRequest req, ProjectState projectState)
+      throws RepositoryNotFoundException, IOException {
     final EnvList env = new EnvList(_env);
     final int contentLength = Math.max(0, req.getContentLength());
 
@@ -568,9 +573,7 @@
     env.set("SERVER_PROTOCOL", req.getProtocol());
     env.set("SERVER_SOFTWARE", getServletContext().getServerInfo());
 
-    final Enumeration<String> hdrs = enumerateHeaderNames(req);
-    while (hdrs.hasMoreElements()) {
-      final String name = hdrs.nextElement();
+    for (String name : getHeaderNames(req)) {
       final String value = req.getHeader(name);
       env.set("HTTP_" + name.toUpperCase().replace('-', '_'), value);
     }
@@ -579,7 +582,7 @@
     env.set("GERRIT_CONTEXT_PATH", req.getContextPath() + "/");
     env.set("GERRIT_PROJECT_NAME", nameKey.get());
 
-    env.set("GITWEB_PROJECTROOT", repoManager.getBasePath(nameKey).toAbsolutePath().toString());
+    env.set("GITWEB_PROJECTROOT", getProjectRoot(nameKey));
 
     if (projectState.statePermitsRead()
         && permissionBackend
@@ -636,6 +639,25 @@
     return env.getEnvArray();
   }
 
+  private String getProjectRoot(Project.NameKey nameKey)
+      throws RepositoryNotFoundException, IOException {
+    try (Repository repo = repoManager.openRepository(nameKey)) {
+      return getProjectRoot(repo);
+    }
+  }
+
+  private String getProjectRoot(Repository repo) {
+    if (repo instanceof DelegateRepository) {
+      return getProjectRoot(((DelegateRepository) repo).delegate());
+    }
+
+    if (repo instanceof FileRepository) {
+      return repo.getDirectory().getAbsolutePath();
+    }
+
+    throw new ProvisionException("Gitweb can only be used with FileRepository");
+  }
+
   private void copyContentToCGI(HttpServletRequest req, OutputStream dst) throws IOException {
     final int contentLength = req.getContentLength();
     final InputStream src = req.getInputStream();
@@ -677,7 +699,7 @@
                         .collect(Collectors.joining("\n"))
                         .trim();
                 if (!err.isEmpty()) {
-                  logger.atSevere().log(err);
+                  logger.atSevere().log("%s", err);
                 }
               } catch (IOException e) {
                 logger.atSevere().withCause(e).log("Unexpected error copying stderr from CGI");
@@ -687,10 +709,6 @@
         .start();
   }
 
-  private static Enumeration<String> enumerateHeaderNames(HttpServletRequest req) {
-    return req.getHeaderNames();
-  }
-
   private void readCgiHeaders(HttpServletResponse res, InputStream in) throws IOException {
     String line;
     while (!(line = readLine(in)).isEmpty()) {
@@ -731,6 +749,11 @@
     return buf.toString().trim();
   }
 
+  @SuppressWarnings("JdkObsolete")
+  private static Iterable<String> getHeaderNames(HttpServletRequest req) {
+    return Collections.list(req.getHeaderNames());
+  }
+
   /** private utility class that manages the Environment passed to exec. */
   private static class EnvList {
     private Map<String, String> envMap;
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index adfbdcc..990b5d7 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -6,7 +6,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/auth",
-        "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/httpd",
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 13df520..df2c5cb 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -19,7 +19,6 @@
 import com.google.common.base.Splitter;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.auth.AuthModule;
-import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
@@ -53,8 +52,10 @@
 import com.google.gerrit.server.StartupChecks.StartupChecksModule;
 import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
 import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -82,6 +83,7 @@
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.OnlineUpgrader.OnlineUpgraderModule;
 import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
 import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
 import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
@@ -106,6 +108,7 @@
 import com.google.gerrit.sshd.SshModule;
 import com.google.gerrit.sshd.SshSessionFactoryInitializer;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
+import com.google.gerrit.sshd.commands.ExternalIdCommandsModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
 import com.google.gerrit.sshd.commands.SequenceCommandsModule;
 import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand.LfsPluginAuthCommandModule;
@@ -208,7 +211,7 @@
           buf.append("\nResolve above errors before continuing.");
           buf.append("\nComplete stack trace follows:");
         }
-        logger.atSevere().withCause(first.getCause()).log(buf.toString());
+        logger.atSevere().withCause(first.getCause()).log("%s", buf);
         throw new CreationException(Collections.singleton(first));
       }
 
@@ -290,7 +293,7 @@
     modules.add(new AuthConfigModule());
     return cfgInjector.createChildInjector(
         ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE)));
+            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE_TYPE)));
   }
 
   private Injector createSysInjector() {
@@ -306,6 +309,7 @@
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
+    modules.add(new ProjectQueryBuilderModule());
     modules.add(new PluginApiModule());
     modules.add(new SearchingChangeCacheImplModule());
     modules.add(new InternalAccountDirectoryModule());
@@ -343,7 +347,7 @@
         });
     modules.add(new DefaultUrlFormatterModule());
 
-    SshSessionFactoryInitializer.init(config);
+    SshSessionFactoryInitializer.init();
     modules.add(SshKeyCacheImpl.module());
     modules.add(
         new AbstractModule() {
@@ -357,16 +361,15 @@
     modules.add(new ChangeCleanupRunnerModule());
     modules.add(new AccountDeactivatorModule());
     modules.add(new DefaultProjectNameLockManagerModule());
+    modules.add(new ExternalIdCaseSensitivityMigrator.ExternalIdCaseSensitivityMigratorModule());
     return dbInjector.createChildInjector(
         ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE)));
+            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE_TYPE)));
   }
 
   private Module createIndexModule() {
     if (indexType.isLucene()) {
-      return LuceneIndexModule.latestVersion(false);
-    } else if (indexType.isElasticsearch()) {
-      return ElasticIndexModule.latestVersion(false);
+      return LuceneIndexModule.latestVersion(false, AutoFlush.ENABLED);
     } else if (indexType.isFake()) {
       // Use Reflection so that we can omit the fake index binary in production code. Test code does
       // compile the component in.
@@ -400,6 +403,7 @@
             sysInjector.getInstance(LfsPluginAuthCommandModule.class)));
     modules.add(new IndexCommandsModule(sysInjector));
     modules.add(new SequenceCommandsModule());
+    modules.add(new ExternalIdCommandsModule());
     return sysInjector.createChildInjector(modules);
   }
 
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index ef37fc5..9b8f4c6 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -35,6 +35,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
 import com.google.common.net.HttpHeaders;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.httpd.resources.ResourceKey;
 import com.google.gerrit.httpd.resources.SmallResource;
@@ -68,7 +69,6 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
@@ -86,7 +86,7 @@
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.IO;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -175,6 +175,7 @@
     plugins.put(name, holder);
   }
 
+  @Nullable
   private GuiceFilter load(Plugin plugin) {
     if (plugin.getHttpInjector() != null) {
       final String name = plugin.getName();
@@ -328,6 +329,7 @@
     }
   }
 
+  @Nullable
   private static Pattern makeAllowOrigin(Config cfg) {
     String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
     if (allow.length > 0) {
@@ -447,8 +449,7 @@
           return false;
         };
 
-    List<PluginEntry> entries =
-        Collections.list(scanner.entries()).stream().filter(filter).collect(toList());
+    List<PluginEntry> entries = scanner.entries().filter(filter).collect(toList());
     for (PluginEntry entry : entries) {
       String name = entry.getName().substring(prefix.length());
       if (name.startsWith("cmd-")) {
@@ -491,7 +492,7 @@
     }
 
     if (toc != null) {
-      appendPageAsSection(scanner, toc, "Documentaion", md);
+      appendPageAsSection(scanner, toc, "Documentation", md);
     } else {
       appendEntriesSection(scanner, docs, "Documentation", md, prefix, 0);
       appendEntriesSection(scanner, servlets, "Servlets", md, prefix, "servlet-".length());
@@ -520,7 +521,7 @@
     macros.put("URL", url);
 
     Matcher m = Pattern.compile("(\\\\)?@([A-Z_]+)@").matcher(md);
-    StringBuffer sb = new StringBuffer();
+    StringBuilder sb = new StringBuilder();
     while (m.find()) {
       String key = m.group(2);
       String val = macros.get(key);
@@ -722,6 +723,7 @@
       this.docPrefix = getPrefix(plugin, "Gerrit-HttpDocumentationPrefix", "Documentation/");
     }
 
+    @Nullable
     private static String getPrefix(Plugin plugin, String attr, String def) {
       Path path = plugin.getSrcFile();
       PluginContentScanner scanner = plugin.getContentScanner();
diff --git a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
index fc0ec39..ed29629 100644
--- a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
@@ -19,6 +19,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.resources.Resource;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.plugins.Plugin;
@@ -119,6 +120,7 @@
     filter.set(guiceFilter);
   }
 
+  @Nullable
   private GuiceFilter load(Plugin plugin) {
     if (plugin.getHttpInjector() != null) {
       final String name = plugin.getName();
diff --git a/java/com/google/gerrit/httpd/plugins/PluginServletContext.java b/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
index 40083e4..5e875d7 100644
--- a/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
+++ b/java/com/google/gerrit/httpd/plugins/PluginServletContext.java
@@ -61,11 +61,11 @@
       try {
         handler = API.class.getDeclaredMethod(method.getName(), method.getParameterTypes());
       } catch (NoSuchMethodException e) {
-        String msg =
-            String.format(
-                "%s does not implement %s", PluginServletContext.class, method.toGenericString());
-        logger.atSevere().withCause(e).log(msg);
-        throw new NoSuchMethodError(msg);
+        throw new NoSuchMethodError(
+                String.format(
+                    "%s does not implement %s",
+                    PluginServletContext.class, method.toGenericString()))
+            .initCause(e);
       }
       return handler.invoke(this, args);
     }
@@ -199,6 +199,11 @@
       String v = Version.getVersion();
       return "Gerrit Code Review/" + (v != null ? v : "dev");
     }
+
+    @Override
+    public String getVirtualServerName() {
+      return null;
+    }
   }
 
   interface API {
@@ -255,5 +260,7 @@
     int getMinorVersion();
 
     String getServerInfo();
+
+    String getVirtualServerName();
   }
 }
diff --git a/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java b/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
index c13286e..3f59084 100644
--- a/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
+++ b/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
@@ -15,15 +15,17 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import java.nio.file.Path;
 
-class DirectoryDocServlet extends ResourceServlet {
+class DirectoryDocServlet extends DocServlet {
   private static final long serialVersionUID = 1L;
 
   private final Path doc;
 
-  DirectoryDocServlet(Cache<Path, Resource> cache, Path unpackedWar) {
-    super(cache, true);
+  DirectoryDocServlet(
+      Cache<Path, Resource> cache, Path unpackedWar, ExperimentFeatures experimentFeatures) {
+    super(cache, true, experimentFeatures);
     this.doc = unpackedWar.resolve("Documentation");
   }
 
diff --git a/java/com/google/gerrit/httpd/raw/DocServlet.java b/java/com/google/gerrit/httpd/raw/DocServlet.java
new file mode 100644
index 0000000..d5027ba
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/DocServlet.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.httpd.HtmlDomUtil;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Optional;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+abstract class DocServlet extends ResourceServlet {
+  private static final long serialVersionUID = 1L;
+
+  private final ExperimentFeatures experimentFeatures;
+
+  DocServlet(Cache<Path, Resource> cache, boolean refresh, ExperimentFeatures experimentFeatures) {
+    super(cache, refresh);
+    this.experimentFeatures = experimentFeatures;
+  }
+
+  @Override
+  protected boolean shouldProcessResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Path p) {
+    String nonce = (String) req.getAttribute("nonce");
+    if (!experimentFeatures.isFeatureEnabled(GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION)
+        || nonce == null) {
+      return false;
+    }
+    return ResourceServlet.contentType(p.toString()).equals("text/html");
+  }
+
+  @Override
+  protected Resource processResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Resource resource) {
+    // ResourceServlet doesn't set character encoding for a resource. Gerrit will
+    // default to setting charset to utf-8, if none provided. So we guess UTF_8 here.
+    Optional<String> updatedHtml =
+        HtmlDomUtil.attachNonce(
+            new String(resource.raw, StandardCharsets.UTF_8), (String) req.getAttribute("nonce"));
+    if (updatedHtml.isEmpty()) {
+      return resource;
+    }
+    return new Resource(
+        resource.lastModified,
+        resource.contentType,
+        updatedHtml.get().getBytes(StandardCharsets.UTF_8));
+  }
+}
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 445a73a..72bfe40 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -39,6 +39,7 @@
 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;
@@ -68,8 +69,10 @@
             staticTemplateData(
                 canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
         .putAll(dynamicTemplateData(gerritApi, requestedURL));
-    Set<String> enabledExperiments = experimentFeatures.getEnabledExperimentFeatures();
-
+    Set<String> enabledExperiments = new HashSet<>();
+    enabledExperiments.addAll(experimentFeatures.getEnabledExperimentFeatures());
+    // Add all experiments enabled through url
+    enabledExperiments.addAll(IndexHtmlUtil.experimentData(urlParameterMap));
     if (!enabledExperiments.isEmpty()) {
       data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
     }
@@ -90,17 +93,13 @@
     IndexPreloadingUtil.RequestedPage page = IndexPreloadingUtil.parseRequestedPage(requestedPath);
     switch (page) {
       case CHANGE:
+      case DIFF:
         data.put(
             "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
-        break;
-      case DIFF:
-        data.put("defaultDiffDetailHex", ListOption.toHex(IndexPreloadingUtil.DIFF_OPTIONS));
-        data.put(
-            "changeRequestsPath",
-            IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
+        data.put("changeNum", IndexPreloadingUtil.computeChangeNum(requestedPath, page).get());
         break;
       case DASHBOARD:
         // Dashboard is preloaded queries are added later when we check user is authenticated.
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 3bdcb1a..5cf63d9 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -59,16 +59,15 @@
   public static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft limit:10";
   public static final String YOUR_TURN = "attention:${user} limit:25";
   public static final String DASHBOARD_ASSIGNED_QUERY =
-      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open -is:ignored limit:25";
+      "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open limit:25";
   public static final String DASHBOARD_WORK_IN_PROGRESS_QUERY =
       "is:open owner:${user} is:wip limit:25";
-  public static final String DASHBOARD_OUTGOING_QUERY =
-      "is:open owner:${user} -is:wip -is:ignored limit:25";
+  public static final String DASHBOARD_OUTGOING_QUERY = "is:open owner:${user} -is:wip limit:25";
   public static final String DASHBOARD_INCOMING_QUERY =
-      "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user}) limit:25";
-  public static final String CC_QUERY = "is:open -is:ignored -is:wip cc:${user} limit:10";
+      "is:open -owner:${user} -is:wip (reviewer:${user} OR assignee:${user}) limit:25";
+  public static final String CC_QUERY = "is:open -is:wip cc:${user} limit:10";
   public static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
-      "is:closed -is:ignored (-is:wip OR owner:self) "
+      "is:closed (-is:wip OR owner:self) "
           + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
           + "OR cc:${user}) -age:4w limit:10";
   public static final String NEW_USER = "owner:${user} limit:1";
@@ -89,7 +88,10 @@
           .map(query -> query.replaceAll("\\$\\{user}", "self"))
           .collect(toImmutableList());
   public static final ImmutableSet<ListChangesOption> DASHBOARD_OPTIONS =
-      ImmutableSet.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS);
+      ImmutableSet.of(
+          ListChangesOption.LABELS,
+          ListChangesOption.DETAILED_ACCOUNTS,
+          ListChangesOption.SUBMIT_REQUIREMENTS);
 
   public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS =
       ImmutableSet.of(
@@ -101,14 +103,10 @@
           ListChangesOption.MESSAGES,
           ListChangesOption.SUBMITTABLE,
           ListChangesOption.WEB_LINKS,
-          ListChangesOption.SKIP_DIFFSTAT);
+          ListChangesOption.SKIP_DIFFSTAT,
+          ListChangesOption.SUBMIT_REQUIREMENTS);
 
-  public static final ImmutableSet<ListChangesOption> DIFF_OPTIONS =
-      ImmutableSet.of(
-          ListChangesOption.ALL_COMMITS,
-          ListChangesOption.ALL_REVISIONS,
-          ListChangesOption.SKIP_DIFFSTAT);
-
+  @Nullable
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
     if (requestedURL == null) {
       return null;
@@ -170,6 +168,30 @@
     return Optional.empty();
   }
 
+  public static Optional<Integer> computeChangeNum(String requestedURL, RequestedPage page) {
+    Matcher matcher;
+    switch (page) {
+      case CHANGE:
+        matcher = CHANGE_URL_PATTERN.matcher(requestedURL);
+        break;
+      case DIFF:
+        matcher = DIFF_URL_PATTERN.matcher(requestedURL);
+        break;
+      case DASHBOARD:
+      case PAGE_WITHOUT_PRELOADING:
+      default:
+        return Optional.empty();
+    }
+
+    if (matcher.matches()) {
+      Integer changeNum = Ints.tryParse(matcher.group("changeNum"));
+      if (changeNum != null) {
+        return Optional.of(changeNum);
+      }
+    }
+    return Optional.empty();
+  }
+
   public static List<String> computeDashboardQueryList(Server serverApi) throws RestApiException {
     List<String> queryList = new ArrayList<>();
     queryList.add(SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY);
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index 3f2c202..fcb821e 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -38,6 +38,8 @@
 
 public class IndexServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
+  private static final String POLY_GERRIT_INDEX_HTML_SOY =
+      "com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy";
 
   @Nullable private final String canonicalUrl;
   @Nullable private final String cdnPath;
@@ -60,7 +62,7 @@
     this.experimentFeatures = experimentFeatures;
     this.soySauce =
         SoyFileSet.builder()
-            .add(Resources.getResource("com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy"))
+            .add(Resources.getResource(POLY_GERRIT_INDEX_HTML_SOY), POLY_GERRIT_INDEX_HTML_SOY)
             .build()
             .compileTemplates();
     this.urlOrdainer =
@@ -74,7 +76,6 @@
     SoySauce.Renderer renderer;
     try {
       Map<String, String[]> parameterMap = req.getParameterMap();
-      String requestUrl = req.getRequestURL() == null ? null : req.getRequestURL().toString();
       // TODO(hiesel): Remove URL ordainer as parameter once Soy is consistent
       ImmutableMap<String, Object> templateData =
           IndexHtmlUtil.templateData(
@@ -85,7 +86,7 @@
               faviconPath,
               parameterMap,
               urlOrdainer,
-              requestUrl);
+              getRequestUrl(req));
       renderer = soySauce.renderTemplate("com.google.gerrit.httpd.raw.Index").setData(templateData);
     } catch (URISyntaxException | RestApiException e) {
       throw new IOException(e);
@@ -98,4 +99,13 @@
       w.write(renderer.renderHtml().get().toString().getBytes(UTF_8));
     }
   }
+
+  @SuppressWarnings("JdkObsolete")
+  @Nullable
+  private static String getRequestUrl(HttpServletRequest req) {
+    if (req.getRequestURL() == null) {
+      return null;
+    }
+    return req.getRequestURL().toString();
+  }
 }
diff --git a/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 8be4abc..871ec78 100644
--- a/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -126,6 +126,34 @@
    */
   protected abstract Path getResourcePath(String pathInfo) throws IOException;
 
+  /**
+   * Indicates that resource requires some processing before being served.
+   *
+   * <p>If true, the caching headers in response are set to not cache. Additionally, streaming
+   * option is disabled.
+   *
+   * @param req the HTTP servlet request
+   * @param rsp the HTTP servlet response
+   * @param p URL path
+   * @return true if the {@link #processResourceBeforeServe(HttpServletRequest, HttpServletResponse,
+   *     Resource)} should be called.
+   */
+  protected boolean shouldProcessResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Path p) {
+    return false;
+  }
+
+  /**
+   * Edits the resource before adding it to the response.
+   *
+   * @param req the HTTP servlet request
+   * @param rsp the HTTP servlet response
+   */
+  protected Resource processResourceBeforeServe(
+      HttpServletRequest req, HttpServletResponse rsp, Resource resource) {
+    return resource;
+  }
+
   protected FileTime getLastModifiedTime(Path p) throws IOException {
     return Files.getLastModifiedTime(p);
   }
@@ -148,10 +176,11 @@
       return;
     }
 
+    boolean requiresPostProcess = shouldProcessResourceBeforeServe(req, rsp, p);
     Resource r = cache.getIfPresent(p);
     try {
       if (r == null) {
-        if (maybeStream(p, req, rsp)) {
+        if (!requiresPostProcess && maybeStream(p, req, rsp)) {
           return; // Bypass cache for large resource.
         }
         r = cache.get(p, newLoader(p));
@@ -176,11 +205,16 @@
       CacheHeaders.setNotCacheable(rsp);
       rsp.setStatus(SC_NOT_FOUND);
       return;
-    } else if (cacheOnClient && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
+    } else if (!requiresPostProcess
+        && cacheOnClient
+        && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
       rsp.setStatus(SC_NOT_MODIFIED);
       return;
     }
 
+    if (requiresPostProcess) {
+      r = processResourceBeforeServe(req, rsp, r);
+    }
     byte[] tosend = r.raw;
     if (!r.contentType.equals(JS) && RequestUtil.acceptsGzipEncoding(req)) {
       byte[] gz = HtmlDomUtil.compress(tosend);
@@ -190,7 +224,7 @@
       }
     }
 
-    if (cacheOnClient) {
+    if (!requiresPostProcess && cacheOnClient) {
       rsp.setHeader(ETAG, r.etag);
     } else {
       CacheHeaders.setNotCacheable(rsp);
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index aa32169..961bf9b 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -93,10 +93,12 @@
           "/elements/*",
           "/fonts/*",
           "/scripts/*",
-          "/styles/*");
+          "/styles/*",
+          "/workers/*");
 
   private static final String DOC_SERVLET = "DocServlet";
   private static final String FAVICON_SERVLET = "FaviconServlet";
+  private static final String SERVICE_WORKER_SERVLET = "ServiceWorkerServlet";
   private static final String POLYGERRIT_INDEX_SERVLET = "PolyGerritUiIndexServlet";
   private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet";
 
@@ -141,12 +143,13 @@
   @Provides
   @Singleton
   @Named(DOC_SERVLET)
-  HttpServlet getDocServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+  HttpServlet getDocServlet(
+      @Named(CACHE) Cache<Path, Resource> cache, ExperimentFeatures experimentFeatures) {
     Paths p = getPaths();
     if (p.warFs != null) {
-      return new WarDocServlet(cache, p.warFs);
+      return new WarDocServlet(cache, p.warFs, experimentFeatures);
     } else if (p.unpackedWar != null && !p.isDev()) {
-      return new DirectoryDocServlet(cache, p.unpackedWar);
+      return new DirectoryDocServlet(cache, p.unpackedWar, experimentFeatures);
     } else {
       return new HttpServlet() {
         private static final long serialVersionUID = 1L;
@@ -165,6 +168,7 @@
     public void configureServlets() {
       serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET));
       serve("/favicon.ico").with(named(FAVICON_SERVLET));
+      serve("/service-worker.js").with(named(SERVICE_WORKER_SERVLET));
     }
 
     @Provides
@@ -199,6 +203,19 @@
       return new SingleFileServlet(cache, webappSourcePath("favicon.ico"), true);
     }
 
+    @Provides
+    @Singleton
+    @Named(SERVICE_WORKER_SERVLET)
+    HttpServlet getServiceWorkerServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+      Paths p = getPaths();
+      if (p.warFs != null) {
+        return new SingleFileServlet(
+            cache, p.warFs.getPath("/polygerrit_ui/workers/service-worker.js"), false);
+      }
+      return new SingleFileServlet(
+          cache, webappSourcePath("polygerrit_ui/workers/service-worker.js"), true);
+    }
+
     private Path webappSourcePath(String name) {
       Paths p = getPaths();
       if (p.unpackedWar != null) {
@@ -288,6 +305,7 @@
       sourceRoot = getSourceRootOrNull();
     }
 
+    @Nullable
     private static Path getSourceRootOrNull() {
       try {
         return GerritLauncher.resolveInSourceRoot(".");
@@ -296,6 +314,7 @@
       }
     }
 
+    @Nullable
     private FileSystem getDistributionArchive(File war) throws IOException {
       if (war == null) {
         return null;
@@ -303,6 +322,7 @@
       return GerritLauncher.getZipFileSystem(war.toPath());
     }
 
+    @Nullable
     private File getLauncherLoadedFrom() {
       File war;
       try {
@@ -424,6 +444,7 @@
       super(req);
     }
 
+    @Nullable
     @Override
     public String getPathInfo() {
       String uri = getRequestURI();
diff --git a/java/com/google/gerrit/httpd/raw/WarDocServlet.java b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
index 27520e3..718d46d 100644
--- a/java/com/google/gerrit/httpd/raw/WarDocServlet.java
+++ b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
@@ -15,20 +15,22 @@
 package com.google.gerrit.httpd.raw;
 
 import com.google.common.cache.Cache;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
 import java.nio.file.attribute.FileTime;
 
-class WarDocServlet extends ResourceServlet {
+class WarDocServlet extends DocServlet {
   private static final long serialVersionUID = 1L;
 
   private static final FileTime NOW = FileTime.fromMillis(TimeUtil.nowMs());
 
   private final FileSystem warFs;
 
-  WarDocServlet(Cache<Path, Resource> cache, FileSystem warFs) {
-    super(cache, false);
+  WarDocServlet(
+      Cache<Path, Resource> cache, FileSystem warFs, ExperimentFeatures experimentFeatures) {
+    super(cache, false, experimentFeatures);
     this.warFs = warFs;
   }
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 369ea29..44e7854 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -30,7 +30,6 @@
 import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
 import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.net.HttpHeaders.VARY;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -120,9 +119,7 @@
 import com.google.gerrit.server.cancellation.RequestStateContext;
 import com.google.gerrit.server.cancellation.RequestStateProvider;
 import com.google.gerrit.server.change.ChangeFinder;
-import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.group.GroupAuditService;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogContext;
@@ -270,7 +267,6 @@
     final PluginSetContext<ExceptionHook> exceptionHooks;
     final Injector injector;
     final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
-    final ExperimentFeatures experimentFeatures;
     final DeadlineChecker.Factory deadlineCheckerFactory;
     final CancellationMetrics cancellationMetrics;
 
@@ -291,7 +287,6 @@
         PluginSetContext<ExceptionHook> exceptionHooks,
         Injector injector,
         DynamicMap<DynamicOptions.DynamicBean> dynamicBeans,
-        ExperimentFeatures experimentFeatures,
         DeadlineChecker.Factory deadlineCheckerFactory,
         CancellationMetrics cancellationMetrics) {
       this.currentUser = currentUser;
@@ -310,11 +305,11 @@
       allowOrigin = makeAllowOrigin(config);
       this.injector = injector;
       this.dynamicBeans = dynamicBeans;
-      this.experimentFeatures = experimentFeatures;
       this.deadlineCheckerFactory = deadlineCheckerFactory;
       this.cancellationMetrics = cancellationMetrics;
     }
 
+    @Nullable
     private static Pattern makeAllowOrigin(Config cfg) {
       String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
       if (allow.length > 0) {
@@ -363,7 +358,7 @@
 
       try (PerThreadCache ignored = PerThreadCache.create()) {
         List<IdString> path = splitPath(req);
-        RequestInfo requestInfo = createRequestInfo(traceContext, requestUri, path);
+        RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
         // It's important that the PerformanceLogContext is closed before the response is sent to
@@ -825,7 +820,7 @@
       if (isRead(request)) {
         logger.atWarning().log(
             "request %s performed a ref update %s although the request is a READ request",
-            request.getRequestURL().toString(), refUpdateFormat);
+            request.getRequestURL(), refUpdateFormat);
       }
       response.addHeader(X_GERRIT_UPDATED_REF, refUpdateFormat);
     }
@@ -854,17 +849,13 @@
     }
   }
 
+  @Nullable
   private String getEtagWithRetry(
       HttpServletRequest req, TraceContext traceContext, RestResource.HasETag rsrc) {
     try (TraceTimer ignored =
         TraceContext.newTimer(
             "RestApiServlet#getEtagWithRetry:resource",
             Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
-      if (rsrc instanceof RevisionResource
-          && globals.experimentFeatures.isFeatureEnabled(
-              GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG)) {
-        return null;
-      }
       return invokeRestEndpointWithRetry(
           req,
           traceContext,
@@ -1277,6 +1268,7 @@
     return ((ParameterizedType) supertype).getActualTypeArguments()[2];
   }
 
+  @Nullable
   private Object parseRequest(HttpServletRequest req, Type type)
       throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
           NoSuchMethodException, IllegalAccessException, InstantiationException,
@@ -1394,22 +1386,21 @@
     throw new BadRequestException("Expected JSON object");
   }
 
-  @SuppressWarnings("unchecked")
   private static Object createInstance(Type type)
       throws NoSuchMethodException, InstantiationException, IllegalAccessException,
           InvocationTargetException {
     if (type instanceof Class) {
-      Class<Object> clazz = (Class<Object>) type;
-      Constructor<Object> c = clazz.getDeclaredConstructor();
+      Class<?> clazz = (Class<?>) type;
+      Constructor<?> c = clazz.getDeclaredConstructor();
       c.setAccessible(true);
       return c.newInstance();
     }
     if (type instanceof ParameterizedType) {
       Type rawType = ((ParameterizedType) type).getRawType();
-      if (rawType instanceof Class && List.class.isAssignableFrom((Class<Object>) rawType)) {
+      if (rawType instanceof Class && List.class.isAssignableFrom((Class<?>) rawType)) {
         return new ArrayList<>();
       }
-      if (rawType instanceof Class && Map.class.isAssignableFrom((Class<Object>) rawType)) {
+      if (rawType instanceof Class && Map.class.isAssignableFrom((Class<?>) rawType)) {
         return new HashMap<>();
       }
     }
@@ -1714,7 +1705,7 @@
   private static List<IdString> splitPath(HttpServletRequest req) {
     String path = RequestUtil.getEncodedPathInfo(req);
     if (Strings.isNullOrEmpty(path)) {
-      return Collections.emptyList();
+      return new ArrayList<>();
     }
     List<IdString> out = new ArrayList<>();
     for (String p : Splitter.on('/').split(path)) {
diff --git a/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java b/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
index 655f4ca..2065a31 100644
--- a/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
+++ b/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.httpd.HtmlDomUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -125,6 +126,7 @@
       return cssFile.isStale() || headerFile.isStale() || footerFile.isStale();
     }
 
+    @Nullable
     private static Element readXml(FileInfo src) throws IOException {
       Document d = HtmlDomUtil.parseFile(src.path);
       return d != null ? d.getDocumentElement() : null;
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index 95b3581..ba1c8bd 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -28,9 +28,11 @@
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:jgit",
+        "//lib:protobuf",
         "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/java/com/google/gerrit/index/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
index 76aa7cc..1c2074b 100644
--- a/java/com/google/gerrit/index/FieldDef.java
+++ b/java/com/google/gerrit/index/FieldDef.java
@@ -21,6 +21,9 @@
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Optional;
@@ -28,11 +31,18 @@
 /**
  * Definition of a field stored in the secondary index.
  *
+ * <p>{@link FieldDef}-s must not be changed once introduced to the codebase. Instead, a new
+ * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}).
+ *
+ * <p>Note that {@link FieldDef} does not override {@link Object#equals(Object)}. It relies on
+ * instances being singletons so that the default (i.e. reference) comparison works.
+ *
  * @param <I> input type from which documents are created and search results are returned.
  * @param <T> type that should be extracted from the input object when converting to an index
  *     document.
  */
-public final class FieldDef<I, T> {
+public final class FieldDef<I, T> implements SchemaField<I, T> {
   public static FieldDef.Builder<String> exact(String name) {
     return new FieldDef.Builder<>(FieldType.EXACT, name);
   }
@@ -61,17 +71,6 @@
     return new FieldDef.Builder<>(FieldType.TIMESTAMP, name);
   }
 
-  @FunctionalInterface
-  public interface Getter<I, T> {
-    @Nullable
-    T get(I input) throws IOException;
-  }
-
-  @FunctionalInterface
-  public interface Setter<I, T> {
-    void set(I object, T value);
-  }
-
   public static class Builder<T> {
     private final FieldType<T> type;
     private final String name;
@@ -139,16 +138,19 @@
   }
 
   /** Returns name of the field. */
+  @Override
   public String getName() {
     return name;
   }
 
   /** Returns type of the field; for repeatable fields, the inner type, not the iterable type. */
+  @Override
   public FieldType<?> getType() {
     return type;
   }
 
   /** Returns whether the field should be stored in the index. */
+  @Override
   public boolean isStored() {
     return stored;
   }
@@ -159,6 +161,7 @@
    * @param input input object.
    * @return the field value(s) to index.
    */
+  @Override
   @Nullable
   public T get(I input) {
     try {
@@ -177,6 +180,7 @@
    * @return {@code true} if the field was set, {@code false} otherwise
    */
   @SuppressWarnings("unchecked")
+  @Override
   public boolean setIfPossible(I object, StoredValue doc) {
     if (!setter.isPresent()) {
       return false;
@@ -204,6 +208,7 @@
   }
 
   /** Returns whether the field is repeatable. */
+  @Override
   public boolean isRepeatable() {
     return repeatable;
   }
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index ead302d..870d827 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -40,6 +40,16 @@
   void close();
 
   /**
+   * Insert a document into the index.
+   *
+   * <p>Results may not be immediately visible to searchers, but should be visible within a
+   * reasonable amount of time.
+   *
+   * @param obj document object
+   */
+  void insert(V obj);
+
+  /**
    * Update a document in the index.
    *
    * <p>Semantically equivalent to deleting the document and reinserting it with new field values. A
@@ -50,6 +60,9 @@
    */
   void replace(V obj);
 
+  /** Delete a document from the index by value */
+  void deleteByValue(V value);
+
   /**
    * Delete a document from the index by key.
    *
@@ -143,4 +156,14 @@
   default boolean isEnabled() {
     return true;
   }
+
+  /**
+   * Rewriter that should be invoked on queries to this index.
+   *
+   * <p>The default implementation does not do anything. Should be overridden by implementation, if
+   * needed.
+   */
+  default IndexRewriter<V> getIndexRewriter() {
+    return (in, opts) -> in;
+  }
 }
diff --git a/java/com/google/gerrit/index/IndexConfig.java b/java/com/google/gerrit/index/IndexConfig.java
index 8676fb2..c21f32e 100644
--- a/java/com/google/gerrit/index/IndexConfig.java
+++ b/java/com/google/gerrit/index/IndexConfig.java
@@ -30,6 +30,7 @@
 @AutoValue
 public abstract class IndexConfig {
   private static final int DEFAULT_MAX_TERMS = 1024;
+  private static final int DEFAULT_PAGE_SIZE_MULTIPLIER = 1;
 
   public static IndexConfig createDefault() {
     return builder().build();
@@ -40,7 +41,10 @@
     setIfPresent(cfg, "maxLimit", b::maxLimit);
     setIfPresent(cfg, "maxPages", b::maxPages);
     setIfPresent(cfg, "maxTerms", b::maxTerms);
+    setIfPresent(cfg, "pageSizeMultiplier", b::pageSizeMultiplier);
+    setIfPresent(cfg, "maxPageSize", b::maxPageSize);
     setTypeOrDefault(cfg, b::type);
+    setPaginationTypeOrDefault(cfg, b::paginationType);
     return b;
   }
 
@@ -56,13 +60,21 @@
     setter.accept(new IndexType(type).toString());
   }
 
+  private static void setPaginationTypeOrDefault(Config cfg, Consumer<PaginationType> setter) {
+    setter.accept(
+        cfg != null ? cfg.getEnum("index", null, "paginationType", PaginationType.OFFSET) : null);
+  }
+
   public static Builder builder() {
     return new AutoValue_IndexConfig.Builder()
         .maxLimit(Integer.MAX_VALUE)
         .maxPages(Integer.MAX_VALUE)
         .maxTerms(DEFAULT_MAX_TERMS)
+        .pageSizeMultiplier(DEFAULT_PAGE_SIZE_MULTIPLIER)
+        .maxPageSize(Integer.MAX_VALUE)
         .type(IndexType.getDefault())
-        .separateChangeSubIndexes(false);
+        .separateChangeSubIndexes(false)
+        .paginationType(PaginationType.OFFSET);
   }
 
   @AutoValue.Builder
@@ -85,6 +97,12 @@
 
     public abstract Builder separateChangeSubIndexes(boolean separate);
 
+    public abstract Builder paginationType(PaginationType type);
+
+    public abstract Builder pageSizeMultiplier(int pageSizeMultiplier);
+
+    public abstract Builder maxPageSize(int maxPageSize);
+
     abstract IndexConfig autoBuild();
 
     public IndexConfig build() {
@@ -92,6 +110,8 @@
       checkLimit(cfg.maxLimit(), "maxLimit");
       checkLimit(cfg.maxPages(), "maxPages");
       checkLimit(cfg.maxTerms(), "maxTerms");
+      checkLimit(cfg.pageSizeMultiplier(), "pageSizeMultiplier");
+      checkLimit(cfg.maxPageSize(), "maxPageSize");
       return cfg;
     }
   }
@@ -124,4 +144,21 @@
    * Returns whether different subsets of changes may be stored in different physical sub-indexes.
    */
   public abstract boolean separateChangeSubIndexes();
+
+  /**
+   * Returns pagination type to use when index queries are repeated to obtain the next set of
+   * results.
+   */
+  public abstract PaginationType paginationType();
+
+  /**
+   * Returns multiplier to be used to determine the limit when queries are repeated to obtain the
+   * next set of results.
+   */
+  public abstract int pageSizeMultiplier();
+
+  /**
+   * Returns maximum allowed limit when repeating index queries to obtain the next set of results.
+   */
+  public abstract int maxPageSize();
 }
diff --git a/java/com/google/gerrit/index/IndexType.java b/java/com/google/gerrit/index/IndexType.java
index 0c3a76a..75f8351 100644
--- a/java/com/google/gerrit/index/IndexType.java
+++ b/java/com/google/gerrit/index/IndexType.java
@@ -24,8 +24,7 @@
 /**
  * Index types supported by the secondary index.
  *
- * <p>The explicitly known index types are Lucene (the default), Elasticsearch and a fake index used
- * in tests.
+ * <p>The explicitly known index types are Lucene (the default) and a fake index used in tests.
  *
  * <p>The third supported index type is any other type String value, deemed as custom. This is for
  * configuring index types that are internal or not to be disclosed. Supporting custom index types
@@ -36,7 +35,6 @@
   private static final String ENV_VAR = "GERRIT_INDEX_TYPE";
 
   private static final String LUCENE = "lucene";
-  private static final String ELASTICSEARCH = "elasticsearch";
   private static final String FAKE = "fake";
 
   private final String type;
@@ -77,17 +75,13 @@
   }
 
   public static ImmutableSet<String> getKnownTypes() {
-    return ImmutableSet.of(LUCENE, ELASTICSEARCH, FAKE);
+    return ImmutableSet.of(LUCENE, FAKE);
   }
 
   public boolean isLucene() {
     return type.equals(LUCENE);
   }
 
-  public boolean isElasticsearch() {
-    return type.equals(ELASTICSEARCH);
-  }
-
   public boolean isFake() {
     return type.equals(FAKE);
   }
diff --git a/java/com/google/gerrit/index/IndexedField.java b/java/com/google/gerrit/index/IndexedField.java
new file mode 100644
index 0000000..99004bb
--- /dev/null
+++ b/java/com/google/gerrit/index/IndexedField.java
@@ -0,0 +1,531 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.reflect.TypeToken;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.converter.ProtoConverter;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
+import com.google.gerrit.proto.Protos;
+import com.google.protobuf.MessageLite;
+import java.io.IOException;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.StreamSupport;
+
+/**
+ * Definition of a field stored in the secondary index.
+ *
+ * <p>Each IndexedField, stored in index, may have multiple {@link SearchSpec} which defines how it
+ * can be searched and how the index tokens are generated.
+ *
+ * <p>Index implementations may choose to store IndexedField and {@link SearchSpec} (search tokens)
+ * separately, however {@link com.google.gerrit.index.query.IndexedQuery} always issues the queries
+ * to {@link SearchSpec}.
+ *
+ * <p>This allows index implementations to store IndexedField once, while enabling multiple
+ * tokenization strategies on the same IndexedField with {@link SearchSpec}
+ *
+ * @param <I> input type from which documents are created and search results are returned.
+ * @param <T> type that should be extracted from the input object when converting to an index
+ *     document.
+ */
+// TODO(mariasavtchouk): revisit the class name after migration is done.
+@SuppressWarnings("serial")
+@AutoValue
+public abstract class IndexedField<I, T> {
+
+  public static final TypeToken<Integer> INTEGER_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<Integer>> ITERABLE_INTEGER_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Long> LONG_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<Long>> ITERABLE_LONG_TYPE = new TypeToken<>() {};
+  public static final TypeToken<String> STRING_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<String>> ITERABLE_STRING_TYPE = new TypeToken<>() {};
+  public static final TypeToken<byte[]> BYTE_ARRAY_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Iterable<byte[]>> ITERABLE_BYTE_ARRAY_TYPE = new TypeToken<>() {};
+  public static final TypeToken<Timestamp> TIMESTAMP_TYPE = new TypeToken<>() {};
+
+  // Should not be used directly, only used to check if the proto is stored
+  private static final TypeToken<MessageLite> MESSAGE_TYPE = new TypeToken<>() {};
+
+  public static <I, T> Builder<I, T> builder(String name, TypeToken<T> fieldType) {
+    return new AutoValue_IndexedField.Builder<I, T>()
+        .name(name)
+        .fieldType(fieldType)
+        .stored(false)
+        .required(false);
+  }
+
+  public static <I> Builder<I, Iterable<String>> iterableStringBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_STRING_TYPE);
+  }
+
+  public static <I> Builder<I, String> stringBuilder(String name) {
+    return builder(name, IndexedField.STRING_TYPE);
+  }
+
+  public static <I> Builder<I, Integer> integerBuilder(String name) {
+    return builder(name, IndexedField.INTEGER_TYPE);
+  }
+
+  public static <I> Builder<I, Long> longBuilder(String name) {
+    return builder(name, IndexedField.LONG_TYPE);
+  }
+
+  public static <I> Builder<I, Iterable<Integer>> iterableIntegerBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_INTEGER_TYPE);
+  }
+
+  public static <I> Builder<I, Timestamp> timestampBuilder(String name) {
+    return builder(name, IndexedField.TIMESTAMP_TYPE);
+  }
+
+  public static <I> Builder<I, byte[]> byteArrayBuilder(String name) {
+    return builder(name, IndexedField.BYTE_ARRAY_TYPE);
+  }
+
+  public static <I> Builder<I, Iterable<byte[]>> iterableByteArrayBuilder(String name) {
+    return builder(name, IndexedField.ITERABLE_BYTE_ARRAY_TYPE);
+  }
+
+  /**
+   * Defines how {@link IndexedField} can be searched and how the index tokens are generated.
+   *
+   * <p>Multiple {@link SearchSpec} can be defined on a single {@link IndexedField}.
+   *
+   * <p>Depending on the implementation, indexes can choose to store {@link IndexedField} and {@link
+   * SearchSpec} separately. The searches are issues to {@link SearchSpec}.
+   */
+  public class SearchSpec implements SchemaField<I, T> {
+    private final String name;
+    private final SearchOption searchOption;
+
+    public SearchSpec(String name, SearchOption searchOption) {
+      checkName(name);
+      this.name = name;
+      this.searchOption = searchOption;
+    }
+
+    @Override
+    public boolean isStored() {
+      return getField().stored();
+    }
+
+    @Override
+    public boolean isRepeatable() {
+      return getField().repeatable();
+    }
+
+    @Override
+    @Nullable
+    public T get(I obj) {
+      return getField().get(obj);
+    }
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public FieldType<?> getType() {
+      SearchOption searchOption = getSearchOption();
+      TypeToken<?> fieldType = getField().fieldType();
+      if (searchOption.equals(SearchOption.STORE_ONLY)) {
+        return FieldType.STORED_ONLY;
+      } else if ((fieldType.equals(IndexedField.INTEGER_TYPE)
+              || fieldType.equals(IndexedField.ITERABLE_INTEGER_TYPE))
+          && searchOption.equals(SearchOption.EXACT)) {
+        return FieldType.INTEGER;
+      } else if (fieldType.equals(IndexedField.INTEGER_TYPE)
+          && searchOption.equals(SearchOption.RANGE)) {
+        return FieldType.INTEGER_RANGE;
+      } else if (fieldType.equals(IndexedField.LONG_TYPE)) {
+        return FieldType.LONG;
+      } else if (fieldType.equals(IndexedField.TIMESTAMP_TYPE)) {
+        return FieldType.TIMESTAMP;
+      } else if (fieldType.equals(IndexedField.STRING_TYPE)
+          || fieldType.equals(IndexedField.ITERABLE_STRING_TYPE)) {
+        if (searchOption.equals(SearchOption.EXACT)) {
+          return FieldType.EXACT;
+        } else if (searchOption.equals(SearchOption.FULL_TEXT)) {
+          return FieldType.FULL_TEXT;
+        } else if (searchOption.equals(SearchOption.PREFIX)) {
+          return FieldType.PREFIX;
+        }
+      }
+      throw new IllegalArgumentException(
+          String.format(
+              "search spec [%s, %s] is not supported on field [%s, %s]",
+              getName(), getSearchOption(), getField().name(), getField().fieldType()));
+    }
+
+    @Override
+    public boolean setIfPossible(I object, StoredValue doc) {
+      return getField().setIfPossible(object, doc);
+    }
+
+    /**
+     * Returns {@link SearchOption} enabled on this field.
+     *
+     * @return {@link SearchOption}
+     */
+    public SearchOption getSearchOption() {
+      return searchOption;
+    }
+
+    /**
+     * Returns {@link IndexedField} on which this spec was created.
+     *
+     * @return original {@link IndexedField} of this spec.
+     */
+    public IndexedField<I, T> getField() {
+      return IndexedField.this;
+    }
+
+    private String checkName(String name) {
+      CharMatcher m = CharMatcher.anyOf("abcdefghijklmnopqrstuvwxyz0123456789_");
+      checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
+      return name;
+    }
+  }
+
+  /**
+   * Adds {@link SearchSpec} to this {@link IndexedField}
+   *
+   * @param name the name to use for in the search.
+   * @param searchOption the tokenization option, enabled by the new {@link SearchSpec}
+   * @return the added {@link SearchSpec}.
+   */
+  public SearchSpec addSearchSpec(String name, SearchOption searchOption) {
+    SearchSpec searchSpec = new SearchSpec(name, searchOption);
+    checkArgument(
+        !searchSpecs.containsKey(searchSpec.getName()),
+        "Can not add search spec %s, because it is already defined on field %s",
+        searchSpec.getName(),
+        name());
+    searchSpecs.put(searchSpec.getName(), searchSpec);
+    return searchSpec;
+  }
+
+  public SearchSpec exact(String name) {
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec fullText(String name) {
+    return addSearchSpec(name, SearchOption.FULL_TEXT);
+  }
+
+  public SearchSpec range(String name) {
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  public SearchSpec integerRange(String name) {
+    checkState(fieldType().equals(INTEGER_TYPE));
+    // we currently store all integer range fields, this may change in the future
+    checkState(stored());
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  public SearchSpec integer(String name) {
+    checkState(fieldType().equals(INTEGER_TYPE) || fieldType().equals(ITERABLE_INTEGER_TYPE));
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec longSearch(String name) {
+    checkState(fieldType().equals(LONG_TYPE) || fieldType().equals(ITERABLE_LONG_TYPE));
+    return addSearchSpec(name, SearchOption.EXACT);
+  }
+
+  public SearchSpec prefix(String name) {
+    return addSearchSpec(name, SearchOption.PREFIX);
+  }
+
+  public SearchSpec storedOnly(String name) {
+    checkState(stored());
+    return addSearchSpec(name, SearchOption.STORE_ONLY);
+  }
+
+  public SearchSpec timestamp(String name) {
+    checkState(fieldType().equals(TIMESTAMP_TYPE));
+    return addSearchSpec(name, SearchOption.RANGE);
+  }
+
+  /** A builder for {@link IndexedField}. */
+  @AutoValue.Builder
+  public abstract static class Builder<I, T> {
+
+    public abstract IndexedField.Builder<I, T> name(String name);
+
+    public abstract IndexedField.Builder<I, T> description(Optional<String> description);
+
+    public abstract IndexedField.Builder<I, T> description(String description);
+
+    public abstract Builder<I, T> required(boolean required);
+
+    public Builder<I, T> required() {
+      required(true);
+      return this;
+    }
+
+    /** Allow reading the actual data from the index. */
+    public abstract Builder<I, T> stored(boolean stored);
+
+    public Builder<I, T> stored() {
+      stored(true);
+      return this;
+    }
+
+    abstract Builder<I, T> repeatable(boolean repeatable);
+
+    public abstract Builder<I, T> size(Optional<Integer> value);
+
+    public abstract Builder<I, T> size(Integer value);
+
+    public abstract Builder<I, T> getter(Getter<I, T> getter);
+
+    public abstract Builder<I, T> fieldSetter(Optional<Setter<I, T>> setter);
+
+    abstract TypeToken<T> fieldType();
+
+    public abstract Builder<I, T> fieldType(TypeToken<T> type);
+
+    public abstract Builder<I, T> protoConverter(
+        Optional<ProtoConverter<? extends MessageLite, ?>> value);
+
+    abstract IndexedField<I, T> autoBuild(); // not public
+
+    public final IndexedField<I, T> build() {
+      boolean isRepeatable = fieldType().isSubtypeOf(Iterable.class);
+      repeatable(isRepeatable);
+      IndexedField<I, T> field = autoBuild();
+      checkName(field.name());
+      checkArgument(!field.size().isPresent() || field.size().get() > 0);
+      return field;
+    }
+
+    public final IndexedField<I, T> build(Getter<I, T> getter, Setter<I, T> setter) {
+      return this.getter(getter).fieldSetter(Optional.of(setter)).build();
+    }
+
+    public final IndexedField<I, T> build(
+        Getter<I, T> getter,
+        Setter<I, T> setter,
+        ProtoConverter<? extends MessageLite, ?> protoConverter) {
+      return this.getter(getter)
+          .fieldSetter(Optional.of(setter))
+          .protoConverter(Optional.of(protoConverter))
+          .build();
+    }
+
+    public final IndexedField<I, T> build(Getter<I, T> getter) {
+      return this.getter(getter).fieldSetter(Optional.empty()).build();
+    }
+
+    private static String checkName(String name) {
+      String allowedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789_";
+      CharMatcher m = CharMatcher.anyOf(allowedCharacters + allowedCharacters.toUpperCase());
+      checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
+      return name;
+    }
+  }
+
+  private Map<String, SearchSpec> searchSpecs = new HashMap<>();
+
+  /**
+   * The name to store this field under.
+   *
+   * <p>The name should use the UpperCamelCase format, see {@link Builder#checkName}.
+   */
+  public abstract String name();
+
+  /** Optional description of the field data. */
+  public abstract Optional<String> description();
+
+  /**
+   * True if this field is mandatory. Default is false.
+   *
+   * <p>This property is not enforced by the common indexing logic. It is up to the index
+   * implementations to enforce that the field is required.
+   */
+  public abstract boolean required();
+
+  /** Allow reading the actual data from the index. Default is false. */
+  public abstract boolean stored();
+
+  /** True if this field is repeatable. */
+  public abstract boolean repeatable();
+
+  /**
+   * Optional size constrain on the field. The size is not constrained if this property is {@link
+   * Optional#empty()}
+   *
+   * <p>This property is not enforced by the common indexing logic. It is up to the index
+   * implementations to enforce the size.
+   *
+   * <p>If the field is {@link #repeatable()}, the constraint applies to each element separately.
+   */
+  public abstract Optional<Integer> size();
+
+  /** See {@link Getter} */
+  public abstract Getter<I, T> getter();
+
+  /** See {@link Setter} */
+  public abstract Optional<Setter<I, T>> fieldSetter();
+
+  /**
+   * The {@link TypeToken} describing the contents of the field. See static constants for the common
+   * supported types.
+   *
+   * @return {@link TypeToken} of this field.
+   */
+  public abstract TypeToken<T> fieldType();
+
+  /** If the {@link #fieldType()} is proto, the converter to use on byte/proto conversions. */
+  public abstract Optional<ProtoConverter<? extends MessageLite, ?>> protoConverter();
+
+  /**
+   * Returns all {@link SearchSpec}, enabled on this field.
+   *
+   * <p>Note: weather or not a search is supported by the index depends on {@link Schema} version.
+   */
+  public ImmutableMap<String, SearchSpec> getSearchSpecs() {
+    return ImmutableMap.copyOf(searchSpecs);
+  }
+
+  /**
+   * Get the field contents from the input object.
+   *
+   * @param input input object.
+   * @return the field value(s) to index.
+   */
+  @Nullable
+  public T get(I input) {
+    try {
+      return getter().get(input);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public boolean setIfPossible(I object, StoredValue doc) {
+    if (!fieldSetter().isPresent()) {
+      return false;
+    }
+
+    if (this.fieldType().equals(STRING_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asString());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_STRING_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asStrings());
+      return true;
+    } else if (this.fieldType().equals(INTEGER_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asInteger());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_INTEGER_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asIntegers());
+      return true;
+    } else if (this.fieldType().equals(LONG_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asLong());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_LONG_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asLongs());
+      return true;
+    } else if (this.fieldType().equals(BYTE_ARRAY_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asByteArray());
+      return true;
+    } else if (this.fieldType().equals(ITERABLE_BYTE_ARRAY_TYPE)) {
+      fieldSetter().get().set(object, (T) doc.asByteArrays());
+      return true;
+    } else if (this.fieldType().equals(TIMESTAMP_TYPE)) {
+      checkState(!repeatable(), "can't repeat timestamp values");
+      fieldSetter().get().set(object, (T) doc.asTimestamp());
+      return true;
+    } else if (isProtoType()) {
+      MessageLite proto = doc.asProto();
+      if (proto != null) {
+        fieldSetter().get().set(object, (T) proto);
+        return true;
+      }
+      byte[] bytes = doc.asByteArray();
+      if (bytes != null && protoConverter().isPresent()) {
+        fieldSetter().get().set(object, (T) parseProtoFrom(bytes));
+        return true;
+      }
+    } else if (isProtoIterableType()) {
+      Iterable<MessageLite> protos = doc.asProtos();
+      if (protos != null) {
+        fieldSetter().get().set(object, (T) protos);
+        return true;
+      }
+      Iterable<byte[]> bytes = doc.asByteArrays();
+      if (bytes != null && protoConverter().isPresent()) {
+        fieldSetter().get().set(object, (T) decodeProtos(bytes));
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Returns true if the {@link #fieldType} is a proto message. */
+  public boolean isProtoType() {
+    if (repeatable()) {
+      return false;
+    }
+    return MESSAGE_TYPE.isSupertypeOf(fieldType());
+  }
+
+  /** Returns true if the {@link #fieldType} is a list of proto messages. */
+  public boolean isProtoIterableType() {
+    if (!repeatable()) {
+      return false;
+    }
+    if (!(fieldType().getType() instanceof ParameterizedType)) {
+      return false;
+    }
+    ParameterizedType parameterizedType = (ParameterizedType) fieldType().getType();
+    if (parameterizedType.getActualTypeArguments().length != 1) {
+      return false;
+    }
+    Type type = parameterizedType.getActualTypeArguments()[0];
+    return MESSAGE_TYPE.isSupertypeOf(type);
+  }
+
+  private ImmutableList<MessageLite> decodeProtos(Iterable<byte[]> raw) {
+    return StreamSupport.stream(raw.spliterator(), false)
+        .map(bytes -> parseProtoFrom(bytes))
+        .collect(toImmutableList());
+  }
+
+  private MessageLite parseProtoFrom(byte[] bytes) {
+    return Protos.parseUnchecked(protoConverter().get().getParser(), bytes);
+  }
+}
diff --git a/java/com/google/gerrit/index/PaginationType.java b/java/com/google/gerrit/index/PaginationType.java
new file mode 100644
index 0000000..e7e34fd
--- /dev/null
+++ b/java/com/google/gerrit/index/PaginationType.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+public enum PaginationType {
+  /** Index queries are restarted at a non-zero offset to obtain the next set of results */
+  OFFSET,
+
+  /**
+   * Index queries are restarted using a search-after object. Supported index backends can provide
+   * their custom implementations for search-after.
+   *
+   * <p>For example, Lucene implementation uses the last doc from the previous search as
+   * search-after object and uses the IndexSearcher.searchAfter API to get the next set of results.
+   */
+  SEARCH_AFTER
+}
diff --git a/java/com/google/gerrit/index/QueryOptions.java b/java/com/google/gerrit/index/QueryOptions.java
index 0401dab..91c8d1a 100644
--- a/java/com/google/gerrit/index/QueryOptions.java
+++ b/java/com/google/gerrit/index/QueryOptions.java
@@ -19,15 +19,52 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import java.util.Set;
 import java.util.function.Function;
 
 @AutoValue
 public abstract class QueryOptions {
   public static QueryOptions create(IndexConfig config, int start, int limit, Set<String> fields) {
+    return create(config, start, null, limit, config.pageSizeMultiplier(), limit, fields);
+  }
+
+  public static QueryOptions create(
+      IndexConfig config, int start, int pageSize, int limit, Set<String> fields) {
+    return create(config, start, null, pageSize, config.pageSizeMultiplier(), limit, fields);
+  }
+
+  public static QueryOptions create(
+      IndexConfig config,
+      int start,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      Set<String> fields) {
+    return create(config, start, null, pageSize, pageSizeMultiplier, limit, fields);
+  }
+
+  public static QueryOptions create(
+      IndexConfig config,
+      int start,
+      Object searchAfter,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      Set<String> fields) {
     checkArgument(start >= 0, "start must be nonnegative: %s", start);
     checkArgument(limit > 0, "limit must be positive: %s", limit);
-    return new AutoValue_QueryOptions(config, start, limit, ImmutableSet.copyOf(fields));
+    if (searchAfter != null) {
+      checkArgument(start == 0, "start must be 0 when searchAfter is specified: %s", start);
+    }
+    return new AutoValue_QueryOptions(
+        config,
+        start,
+        searchAfter,
+        pageSize,
+        pageSizeMultiplier,
+        limit,
+        ImmutableSet.copyOf(fields));
   }
 
   public QueryOptions convertForBackend() {
@@ -36,26 +73,56 @@
     int backendLimit = config().maxLimit();
     int limit = Ints.saturatedCast((long) limit() + start());
     limit = Math.min(limit, backendLimit);
-    return create(config(), 0, limit, fields());
+    int pageSize = Math.min(Ints.saturatedCast((long) pageSize() + start()), backendLimit);
+    return create(config(), 0, null, pageSize, pageSizeMultiplier(), limit, fields());
   }
 
   public abstract IndexConfig config();
 
   public abstract int start();
 
+  @Nullable
+  public abstract Object searchAfter();
+
+  public abstract int pageSize();
+
+  public abstract int pageSizeMultiplier();
+
   public abstract int limit();
 
   public abstract ImmutableSet<String> fields();
 
+  public QueryOptions withPageSize(int pageSize) {
+    return create(
+        config(), start(), searchAfter(), pageSize, pageSizeMultiplier(), limit(), fields());
+  }
+
   public QueryOptions withLimit(int newLimit) {
-    return create(config(), start(), newLimit, fields());
+    return create(
+        config(), start(), searchAfter(), pageSize(), pageSizeMultiplier(), newLimit, fields());
   }
 
   public QueryOptions withStart(int newStart) {
-    return create(config(), newStart, limit(), fields());
+    return create(
+        config(), newStart, searchAfter(), pageSize(), pageSizeMultiplier(), limit(), fields());
+  }
+
+  public QueryOptions withSearchAfter(Object newSearchAfter) {
+    // Index search-after APIs don't use 'start', so set it to 0 to be safe. ElasticSearch for
+    // example, expects it to be 0 when using search-after APIs.
+    return create(
+            config(), start(), newSearchAfter, pageSize(), pageSizeMultiplier(), limit(), fields())
+        .withStart(0);
   }
 
   public QueryOptions filterFields(Function<QueryOptions, Set<String>> filter) {
-    return create(config(), start(), limit(), filter.apply(this));
+    return create(
+        config(),
+        start(),
+        searchAfter(),
+        pageSize(),
+        pageSizeMultiplier(),
+        limit(),
+        filter.apply(this));
   }
 }
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 91c3f70..25d7cf3 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -14,67 +14,134 @@
 
 package com.google.gerrit.index;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 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.exceptions.StorageException;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
 
 /** Specific version of a secondary index schema. */
 public class Schema<T> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class Builder<T> {
-    private final List<FieldDef<T, ?>> fields = new ArrayList<>();
-    private boolean useLegacyNumericFields;
+    private final List<SchemaField<T, ?>> searchFields = new ArrayList<>();
+    private final List<IndexedField<T, ?>> indexedFields = new ArrayList<>();
+
+    private Optional<Integer> version = Optional.empty();
+
+    public Builder<T> version(int version) {
+      this.version = Optional.of(version);
+      return this;
+    }
 
     public Builder<T> add(Schema<T> schema) {
-      this.fields.addAll(schema.getFields().values());
+      this.indexedFields.addAll(schema.getIndexFields().values());
+      this.searchFields.addAll(schema.getSchemaFields().values());
+      if (!version.isPresent()) {
+        version(schema.getVersion() + 1);
+      }
       return this;
     }
 
     @SafeVarargs
     public final Builder<T> add(FieldDef<T, ?>... fields) {
-      this.fields.addAll(Arrays.asList(fields));
+      return add(ImmutableList.copyOf(fields));
+    }
+
+    public final Builder<T> add(ImmutableList<FieldDef<T, ?>> fields) {
+      this.searchFields.addAll(fields);
       return this;
     }
 
     @SafeVarargs
     public final Builder<T> remove(FieldDef<T, ?>... fields) {
-      this.fields.removeAll(Arrays.asList(fields));
+      this.searchFields.removeAll(Arrays.asList(fields));
       return this;
     }
 
-    public Builder<T> legacyNumericFields(boolean useLegacyNumericFields) {
-      this.useLegacyNumericFields = useLegacyNumericFields;
+    @SafeVarargs
+    public final Builder<T> addSearchSpecs(IndexedField<T, ?>.SearchSpec... searchSpecs) {
+      return addSearchSpecs(ImmutableList.copyOf(searchSpecs));
+    }
+
+    public Builder<T> addSearchSpecs(ImmutableList<IndexedField<T, ?>.SearchSpec> searchSpecs) {
+      for (IndexedField<T, ?>.SearchSpec searchSpec : searchSpecs) {
+        checkArgument(
+            this.indexedFields.contains(searchSpec.getField()),
+            "%s spec can only be added to the schema that contains %s field",
+            searchSpec.getName(),
+            searchSpec.getField().name());
+      }
+      this.searchFields.addAll(searchSpecs);
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> addIndexedFields(IndexedField<T, ?>... fields) {
+      return addIndexedFields(ImmutableList.copyOf(fields));
+    }
+
+    public Builder<T> addIndexedFields(ImmutableList<IndexedField<T, ?>> indexedFields) {
+      this.indexedFields.addAll(indexedFields);
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> remove(IndexedField<T, ?>.SearchSpec... searchSpecs) {
+      this.searchFields.removeAll(Arrays.asList(searchSpecs));
+      return this;
+    }
+
+    @SafeVarargs
+    public final Builder<T> remove(IndexedField<T, ?>... indexedFields) {
+      for (IndexedField<T, ?> field : indexedFields) {
+        ImmutableMap<String, ? extends IndexedField<T, ?>.SearchSpec> searchSpecs =
+            field.getSearchSpecs();
+        checkArgument(
+            !searchSpecs.values().stream().anyMatch(this.searchFields::contains),
+            "Field %s can be only removed from schema after all of its searches are removed.",
+            field.name());
+      }
+      this.indexedFields.removeAll(Arrays.asList(indexedFields));
       return this;
     }
 
     public Schema<T> build() {
-      return new Schema<>(useLegacyNumericFields, ImmutableList.copyOf(fields));
+      checkState(version.isPresent());
+      return new Schema<>(
+          version.get(), ImmutableList.copyOf(indexedFields), ImmutableList.copyOf(searchFields));
     }
   }
 
   public static class Values<T> {
-    private final FieldDef<T, ?> field;
+    private final SchemaField<T, ?> field;
     private final Iterable<?> values;
 
-    private Values(FieldDef<T, ?> field, Iterable<?> values) {
+    private Values(SchemaField<T, ?> field, Iterable<?> values) {
       this.field = field;
       this.values = values;
     }
 
-    public FieldDef<T, ?> getField() {
+    public SchemaField<T, ?> getField() {
       return field;
     }
 
@@ -83,59 +150,70 @@
     }
   }
 
-  private static <T> FieldDef<T, ?> checkSame(FieldDef<T, ?> f1, FieldDef<T, ?> f2) {
+  private static <T> SchemaField<T, ?> checkSame(SchemaField<T, ?> f1, SchemaField<T, ?> f2) {
     checkState(f1 == f2, "Mismatched %s fields: %s != %s", f1.getName(), f1, f2);
     return f1;
   }
 
-  private final ImmutableMap<String, FieldDef<T, ?>> fields;
-  private final ImmutableMap<String, FieldDef<T, ?>> storedFields;
-  private final boolean useLegacyNumericFields;
+  private final ImmutableSet<String> storedFields;
+
+  private final ImmutableMap<String, SchemaField<T, ?>> schemaFields;
+  private final ImmutableMap<String, IndexedField<T, ?>> indexedFields;
 
   private int version;
 
-  public Schema(boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
-    this(0, useLegacyNumericFields, fields);
-  }
-
-  public Schema(int version, boolean useLegacyNumericFields, Iterable<FieldDef<T, ?>> fields) {
+  private Schema(
+      int version,
+      ImmutableList<IndexedField<T, ?>> indexedFields,
+      ImmutableList<SchemaField<T, ?>> schemaFields) {
     this.version = version;
-    ImmutableMap.Builder<String, FieldDef<T, ?>> b = ImmutableMap.builder();
-    ImmutableMap.Builder<String, FieldDef<T, ?>> sb = ImmutableMap.builder();
-    for (FieldDef<T, ?> f : fields) {
-      b.put(f.getName(), f);
-      if (f.isStored()) {
-        sb.put(f.getName(), f);
-      }
-    }
-    this.fields = b.build();
-    this.storedFields = sb.build();
-    this.useLegacyNumericFields = useLegacyNumericFields;
+
+    this.indexedFields =
+        indexedFields.stream().collect(toImmutableMap(IndexedField::name, Function.identity()));
+    this.schemaFields =
+        schemaFields.stream().collect(toImmutableMap(SchemaField::getName, Function.identity()));
+
+    Set<String> duplicateKeys =
+        Sets.intersection(this.schemaFields.keySet(), this.indexedFields.keySet());
+    checkArgument(
+        duplicateKeys.isEmpty(),
+        "DuplicateKeys found %s, indexFields:%s, schemaFields: %s",
+        duplicateKeys,
+        this.indexedFields.keySet(),
+        this.schemaFields.keySet());
+    this.storedFields =
+        schemaFields.stream()
+            .filter(SchemaField::isStored)
+            .map(SchemaField::getName)
+            .collect(toImmutableSet());
   }
 
   public final int getVersion() {
     return version;
   }
 
-  public final boolean useLegacyNumericFields() {
-    return useLegacyNumericFields;
-  }
-
   /**
    * Get all fields in this schema.
    *
    * <p>This is primarily useful for iteration. Most callers should prefer one of the helper methods
-   * {@link #getField(FieldDef, FieldDef...)} or {@link #hasField(FieldDef)} to looking up fields by
-   * name
+   * {@link #getField(SchemaField, SchemaField...)} or {@link #hasField(SchemaField)} to looking up
+   * fields by name
    *
    * @return all fields in this schema indexed by name.
    */
-  public final ImmutableMap<String, FieldDef<T, ?>> getFields() {
-    return fields;
+  public final ImmutableMap<String, SchemaField<T, ?>> getSchemaFields() {
+    return schemaFields;
   }
 
-  /** Returns all fields in this schema where {@link FieldDef#isStored()} is true. */
-  public final ImmutableMap<String, FieldDef<T, ?>> getStoredFields() {
+  public final ImmutableMap<String, IndexedField<T, ?>> getIndexFields() {
+    return indexedFields;
+  }
+
+  /**
+   * Returns names of {@link SchemaField} fields in this schema where {@link SchemaField#isStored()}
+   * is true.
+   */
+  public final ImmutableSet<String> getStoredFields() {
     return storedFields;
   }
 
@@ -148,13 +226,14 @@
    *     absent if no field matches.
    */
   @SafeVarargs
-  public final Optional<FieldDef<T, ?>> getField(FieldDef<T, ?> first, FieldDef<T, ?>... rest) {
-    FieldDef<T, ?> field = fields.get(first.getName());
+  public final Optional<SchemaField<T, ?>> getField(
+      SchemaField<T, ?> first, SchemaField<T, ?>... rest) {
+    SchemaField<T, ?> field = getSchemaField(first);
     if (field != null) {
       return Optional.of(checkSame(field, first));
     }
-    for (FieldDef<T, ?> f : rest) {
-      field = fields.get(f.getName());
+    for (SchemaField<T, ?> f : rest) {
+      field = getSchemaField(first);
       if (field != null) {
         return Optional.of(checkSame(field, f));
       }
@@ -168,8 +247,8 @@
    * @param field field to look up.
    * @return whether the field is present.
    */
-  public final boolean hasField(FieldDef<T, ?> field) {
-    FieldDef<T, ?> f = fields.get(field.getName());
+  public final boolean hasField(SchemaField<T, ?> field) {
+    SchemaField<T, ?> f = getSchemaField(field);
     if (f == null) {
       return false;
     }
@@ -177,7 +256,20 @@
     return true;
   }
 
-  private Values<T> fieldValues(T obj, FieldDef<T, ?> f, ImmutableSet<String> skipFields) {
+  public final boolean hasField(String fieldName) {
+    return this.getSchemaField(fieldName) != null;
+  }
+
+  private SchemaField<T, ?> getSchemaField(SchemaField<T, ?> field) {
+    return getSchemaField(field.getName());
+  }
+
+  public SchemaField<T, ?> getSchemaField(String fieldName) {
+    return schemaFields.get(fieldName);
+  }
+
+  private @Nullable Values<T> fieldValues(
+      T obj, SchemaField<T, ?> f, ImmutableSet<String> skipFields) {
     if (skipFields.contains(f.getName())) {
       return null;
     }
@@ -215,10 +307,11 @@
    */
   public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
     try {
-      return fields.values().stream()
+      return schemaFields.values().stream()
           .map(f -> fieldValues(obj, f, skipFields))
           .filter(Objects::nonNull)
           .collect(toImmutableList());
+
     } catch (StorageException e) {
       return ImmutableList.of();
     }
@@ -226,10 +319,9 @@
 
   @Override
   public String toString() {
-    return MoreObjects.toStringHelper(this).addValue(fields.keySet()).toString();
-  }
-
-  public void setVersion(int version) {
-    this.version = version;
+    return MoreObjects.toStringHelper(this)
+        .addValue(indexedFields.keySet())
+        .addValue(schemaFields.keySet())
+        .toString();
   }
 }
diff --git a/java/com/google/gerrit/index/SchemaDefinitions.java b/java/com/google/gerrit/index/SchemaDefinitions.java
index e8efd22..4e58d91 100644
--- a/java/com/google/gerrit/index/SchemaDefinitions.java
+++ b/java/com/google/gerrit/index/SchemaDefinitions.java
@@ -42,6 +42,7 @@
     return name;
   }
 
+  /** Returns all schemas sorted by version (ascending). */
   public final ImmutableSortedMap<Integer, Schema<V>> getSchemas() {
     return schemas;
   }
diff --git a/java/com/google/gerrit/index/SchemaFieldDefs.java b/java/com/google/gerrit/index/SchemaFieldDefs.java
new file mode 100644
index 0000000..db45b8d
--- /dev/null
+++ b/java/com/google/gerrit/index/SchemaFieldDefs.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import com.google.gerrit.common.Nullable;
+import java.io.IOException;
+
+/** Interfaces that define properties of fields in {@link Schema}. */
+public class SchemaFieldDefs {
+
+  /**
+   * Definition of a field stored in the secondary index.
+   *
+   * <p>{@link SchemaField}-s must not be changed once introduced to the codebase. Instead, a new
+   * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see
+   * {@code com.google.gerrit.index.IndexUpgradeValidator}).
+   *
+   * @param <I> input type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object when converting to an index
+   *     document.
+   */
+  public interface SchemaField<T, I> {
+
+    /** Returns whether the field should be stored in the index. */
+    boolean isStored();
+
+    /** Returns whether the field is repeatable. */
+    boolean isRepeatable();
+
+    /**
+     * Get the field contents from the input object.
+     *
+     * @param input input object.
+     * @return the field value(s) to index.
+     */
+    @Nullable
+    I get(T input);
+
+    /** Returns the name of the field. */
+    String getName();
+
+    /**
+     * Returns type of the field; for repeatable fields, the inner type, not the iterable type.
+     * TODO(mariasavtchuk): remove after migrating to the new field formats
+     */
+    FieldType<?> getType();
+
+    /**
+     * Set the field contents back to an object. Used to reconstruct fields from indexed values.
+     * No-op if the field can't be reconstructed.
+     *
+     * @param object input object.
+     * @param doc indexed document
+     * @return {@code true} if the field was set, {@code false} otherwise
+     */
+    boolean setIfPossible(T object, StoredValue doc);
+  }
+
+  /**
+   * Getter to extract value that should be stored in index from the input object.
+   *
+   * <p>This interface allows to specify a method or lambda for populating an index field. Note that
+   * for existing fields, changing the code of either the {@link Getter} implementation or the
+   * method(s) that it calls would invalidate existing index data. Therefore, instead of changing
+   * the semantics of an existing field, a new field must be added using the new semantics from the
+   * start. The old field can be removed in another upgrade step (cf. {@code
+   * com.google.gerrit.index.IndexUpgradeValidator}).
+   *
+   * @param <I> type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object to an index field.
+   */
+  @FunctionalInterface
+  public interface Getter<I, T> {
+    @Nullable
+    T get(I input) throws IOException;
+  }
+
+  /**
+   * Setter to reconstruct fields from indexed values back to an object.
+   *
+   * <p>See {@link Getter} for restrictions on changing the implementation.
+   *
+   * @param <I> type from which documents are created and search results are returned.
+   * @param <T> type that should be extracted from the input object when converting toto an index
+   *     field.
+   */
+  @FunctionalInterface
+  public interface Setter<I, T> {
+    void set(I object, T value);
+  }
+
+  public static boolean isProtoField(SchemaField<?, ?> schemaField) {
+    if (!(schemaField instanceof IndexedField<?, ?>.SearchSpec)) {
+      return false;
+    }
+    IndexedField<?, ?> indexedField = ((IndexedField<?, ?>.SearchSpec) schemaField).getField();
+    return indexedField.isProtoType() || indexedField.isProtoIterableType();
+  }
+}
diff --git a/java/com/google/gerrit/index/SchemaUtil.java b/java/com/google/gerrit/index/SchemaUtil.java
index 9599d6a..079f8be 100644
--- a/java/com/google/gerrit/index/SchemaUtil.java
+++ b/java/com/google/gerrit/index/SchemaUtil.java
@@ -25,7 +25,6 @@
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.lang.reflect.ParameterizedType;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -49,7 +48,12 @@
             @SuppressWarnings("unchecked")
             Schema<V> schema = (Schema<V>) f.get(null);
             checkArgument(f.getName().startsWith("V"));
-            schema.setVersion(Integer.parseInt(f.getName().substring(1)));
+            int versionName = Integer.parseInt(f.getName().substring(1));
+            checkArgument(
+                versionName == schema.getVersion(),
+                "Schema version %s does not match its name %s",
+                schema.getVersion(),
+                f.getName());
             schemas.put(schema.getVersion(), schema);
           } catch (IllegalAccessException e) {
             throw new IllegalArgumentException(e);
@@ -66,29 +70,78 @@
     return ImmutableSortedMap.copyOf(schemas);
   }
 
-  public static <V> Schema<V> schema(Collection<FieldDef<V, ?>> fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
+  @SafeVarargs
+  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
+    return new Schema.Builder<V>().version(0).add(fields).build();
   }
 
-  public static <V> Schema<V> schema(Schema<V> schema, boolean useLegacyNumericFields) {
-    return new Schema<>(
-        useLegacyNumericFields,
-        new ImmutableList.Builder<FieldDef<V, ?>>().addAll(schema.getFields().values()).build());
+  @SafeVarargs
+  public static <V> Schema<V> schema(int version, FieldDef<V, ?>... fields) {
+    return new Schema.Builder<V>().version(version).add(fields).build();
+  }
+
+  public static <V> Schema<V> schema(int version, ImmutableList<FieldDef<V, ?>> fields) {
+    return new Schema.Builder<V>().version(version).add(fields).build();
   }
 
   @SafeVarargs
   public static <V> Schema<V> schema(Schema<V> schema, FieldDef<V, ?>... moreFields) {
-    return new Schema<>(
-        true,
-        new ImmutableList.Builder<FieldDef<V, ?>>()
-            .addAll(schema.getFields().values())
-            .addAll(ImmutableList.copyOf(moreFields))
-            .build());
+    return new Schema.Builder<V>().add(schema).add(moreFields).build();
   }
 
-  @SafeVarargs
-  public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
-    return new Schema<>(true, ImmutableList.copyOf(fields));
+  public static <V> Schema<V> schema(
+      int version,
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .version(version)
+        .add(fieldDefs)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      int version,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .version(version)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      Schema<V> schema,
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .add(schema)
+        .add(fieldDefs)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      Schema<V> schema,
+      ImmutableList<IndexedField<V, ?>> indexedFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return new Schema.Builder<V>()
+        .add(schema)
+        .addIndexedFields(indexedFields)
+        .addSearchSpecs(searchSpecs)
+        .build();
+  }
+
+  public static <V> Schema<V> schema(
+      ImmutableList<FieldDef<V, ?>> fieldDefs,
+      ImmutableList<IndexedField<V, ?>> indexFields,
+      ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
+    return schema(/* version= */ 0, fieldDefs, indexFields, searchSpecs);
   }
 
   public static Set<String> getPersonParts(PersonIdent person) {
diff --git a/java/com/google/gerrit/index/SearchOption.java b/java/com/google/gerrit/index/SearchOption.java
new file mode 100644
index 0000000..3fbb68a
--- /dev/null
+++ b/java/com/google/gerrit/index/SearchOption.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+/** Tokenization options enabled on {@link IndexedField}. */
+public enum SearchOption {
+  /** Enables range queries on the field. */
+  RANGE,
+  /** Enables prefix-match search on the field. */
+  PREFIX,
+  /** Enables exact-match search on the field. */
+  EXACT,
+  /** Enables fuzzy-match search on the field. */
+  FULL_TEXT,
+  /** The field can not be searched and is only returned as a payload from the index. */
+  STORE_ONLY,
+}
diff --git a/java/com/google/gerrit/index/StoredValue.java b/java/com/google/gerrit/index/StoredValue.java
index fe790c5..a7e7c26 100644
--- a/java/com/google/gerrit/index/StoredValue.java
+++ b/java/com/google/gerrit/index/StoredValue.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.index;
 
+import com.google.gerrit.common.Nullable;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 
 /**
@@ -47,4 +49,22 @@
 
   /** Returns the {@code byte[]} values of the field. */
   Iterable<byte[]> asByteArrays();
+
+  /**
+   * Returns the {@code MessageLite} value of the field.
+   *
+   * <p>Returns {@code null} if value is not stored as protos (e.g. stored as bytes). {@link
+   * #asByteArray} can be called instead to obtain the value.
+   */
+  @Nullable
+  MessageLite asProto();
+
+  /**
+   * Returns the {@code MessageLite} values of the field.
+   *
+   * <p>Returns {@code null} if value is not stored as protos (e.g. stored as bytes). {@link
+   * #asByteArrays} can be called instead to obtain the value.
+   */
+  @Nullable
+  Iterable<MessageLite> asProtos();
 }
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index c2c8986..e050f53 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -15,41 +15,74 @@
 package com.google.gerrit.index.project;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.prefix;
 import static com.google.gerrit.index.FieldDef.storedOnly;
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 
-/** Index schema for projects. */
+/**
+ * Index schema for projects.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class ProjectField {
   private static byte[] toRefState(Project project) {
     return RefState.create(RefNames.REFS_CONFIG, project.getConfigRefState())
         .toByteArray(project.getNameKey());
   }
 
-  public static final FieldDef<ProjectData, String> NAME =
-      exact("name").stored().build(p -> p.getProject().getName());
+  public static final IndexedField<ProjectData, String> NAME_FIELD =
+      IndexedField.<ProjectData>stringBuilder("RepoName")
+          .required()
+          .size(200)
+          .stored()
+          .build(p -> p.getProject().getName());
 
-  public static final FieldDef<ProjectData, String> DESCRIPTION =
-      fullText("description").stored().build(p -> p.getProject().getDescription());
+  public static final IndexedField<ProjectData, String>.SearchSpec NAME_SPEC =
+      NAME_FIELD.exact("name");
 
-  public static final FieldDef<ProjectData, String> PARENT_NAME =
-      exact("parent_name").build(p -> p.getProject().getParentName());
+  public static final IndexedField<ProjectData, String> DESCRIPTION_FIELD =
+      IndexedField.<ProjectData>stringBuilder("Description")
+          .stored()
+          .build(p -> p.getProject().getDescription());
 
-  public static final FieldDef<ProjectData, Iterable<String>> NAME_PART =
-      prefix("name_part").buildRepeatable(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+  public static final IndexedField<ProjectData, String>.SearchSpec DESCRIPTION_SPEC =
+      DESCRIPTION_FIELD.fullText("description");
 
-  public static final FieldDef<ProjectData, String> STATE =
-      exact("state").stored().build(p -> p.getProject().getState().name());
+  public static final IndexedField<ProjectData, String> PARENT_NAME_FIELD =
+      IndexedField.<ProjectData>stringBuilder("ParentName")
+          .build(p -> p.getProject().getParentName());
 
-  public static final FieldDef<ProjectData, Iterable<String>> ANCESTOR_NAME =
-      exact("ancestor_name").buildRepeatable(ProjectData::getParentNames);
+  public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_SPEC =
+      PARENT_NAME_FIELD.exact("parent_name");
+
+  public static final IndexedField<ProjectData, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<ProjectData>iterableStringBuilder("NamePart")
+          .size(200)
+          .build(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+
+  public static final IndexedField<ProjectData, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name_part");
+
+  public static final IndexedField<ProjectData, String> STATE_FIELD =
+      IndexedField.<ProjectData>stringBuilder("State")
+          .stored()
+          .build(p -> p.getProject().getState().name());
+
+  public static final IndexedField<ProjectData, String>.SearchSpec STATE_SPEC =
+      STATE_FIELD.exact("state");
+
+  public static final IndexedField<ProjectData, Iterable<String>> ANCESTOR_NAME_FIELD =
+      IndexedField.<ProjectData>iterableStringBuilder("AncestorName")
+          .build(ProjectData::getParentNames);
+
+  public static final IndexedField<ProjectData, Iterable<String>>.SearchSpec ANCESTOR_NAME_SPEC =
+      ANCESTOR_NAME_FIELD.exact("ancestor_name");
 
   /**
    * All values of all refs that were used in the course of indexing this document. This covers
diff --git a/java/com/google/gerrit/index/project/ProjectIndex.java b/java/com/google/gerrit/index/project/ProjectIndex.java
index b2ddaff..0aa7393 100644
--- a/java/com/google/gerrit/index/project/ProjectIndex.java
+++ b/java/com/google/gerrit/index/project/ProjectIndex.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit projects (repositories). This class is mainly used for typing the generic parent
@@ -30,6 +31,8 @@
 
   @Override
   default Predicate<ProjectData> keyPredicate(Project.NameKey nameKey) {
-    return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+    return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
   }
+
+  Function<ProjectData, Project.NameKey> ENTITY_TO_KEY = (p) -> p.getProject().getNameKey();
 }
diff --git a/java/com/google/gerrit/index/project/ProjectPredicate.java b/java/com/google/gerrit/index/project/ProjectPredicate.java
index 11875ef..0eaf2b6 100644
--- a/java/com/google/gerrit/index/project/ProjectPredicate.java
+++ b/java/com/google/gerrit/index/project/ProjectPredicate.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.index.project;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 
 /** Predicate that is mapped to a field in the project index. */
 public class ProjectPredicate extends IndexPredicate<ProjectData> {
-  public ProjectPredicate(FieldDef<ProjectData, ?> def, String value) {
+  public ProjectPredicate(SchemaField<ProjectData, ?> def, String value) {
     super(def, value);
   }
 }
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 3cc5f9b..05c23e1 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -16,29 +16,52 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
-/** Definition of project index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of project index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class ProjectSchemaDefinitions extends SchemaDefinitions<ProjectData> {
 
   @Deprecated
   static final Schema<ProjectData> V1 =
       schema(
-          ProjectField.NAME,
-          ProjectField.DESCRIPTION,
-          ProjectField.PARENT_NAME,
-          ProjectField.NAME_PART,
-          ProjectField.ANCESTOR_NAME);
+          /* version= */ 1,
+          ImmutableList.of(
+              ProjectField.NAME_FIELD,
+              ProjectField.DESCRIPTION_FIELD,
+              ProjectField.PARENT_NAME_FIELD,
+              ProjectField.NAME_PART_FIELD,
+              ProjectField.ANCESTOR_NAME_FIELD),
+          ImmutableList.<IndexedField<ProjectData, ?>.SearchSpec>of(
+              ProjectField.NAME_SPEC,
+              ProjectField.DESCRIPTION_SPEC,
+              ProjectField.PARENT_NAME_SPEC,
+              ProjectField.NAME_PART_SPEC,
+              ProjectField.ANCESTOR_NAME_SPEC));
 
   @Deprecated
-  static final Schema<ProjectData> V2 = schema(V1, ProjectField.STATE, ProjectField.REF_STATE);
+  static final Schema<ProjectData> V2 =
+      schema(
+          V1,
+          ImmutableList.of(ProjectField.REF_STATE),
+          ImmutableList.<IndexedField<ProjectData, ?>>of(ProjectField.STATE_FIELD),
+          ImmutableList.<IndexedField<ProjectData, ?>.SearchSpec>of(ProjectField.STATE_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<ProjectData> V3 = schema(V2);
 
   // Lucene index was changed to add an additional field for sorting.
-  static final Schema<ProjectData> V4 = schema(V3);
+  @Deprecated static final Schema<ProjectData> V4 = schema(V3);
+
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<ProjectData> V5 = schema(V4);
 
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/index/query/AndCardinalPredicate.java b/java/com/google/gerrit/index/query/AndCardinalPredicate.java
new file mode 100644
index 0000000..cf0e8c3
--- /dev/null
+++ b/java/com/google/gerrit/index/query/AndCardinalPredicate.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import java.util.Collection;
+import java.util.Optional;
+
+public class AndCardinalPredicate<T> extends AndPredicate<T> implements HasCardinality {
+  private final int cardinality;
+
+  public AndCardinalPredicate(Collection<? extends Predicate<T>> that) {
+    super(that);
+    Optional<Predicate<T>> atLeastOneCardinalPredicate =
+        getChildren().stream().filter(p -> (p instanceof HasCardinality)).findAny();
+    if (!atLeastOneCardinalPredicate.isPresent()) {
+      throw new IllegalArgumentException("No HasCardinality Found");
+    }
+    int minCardinality = Integer.MAX_VALUE;
+    for (Predicate<T> child : getChildren()) {
+      if (child instanceof HasCardinality) {
+        minCardinality = Math.min(((HasCardinality) child).getCardinality(), minCardinality);
+      }
+    }
+    cardinality = minCardinality;
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    return new AndCardinalPredicate<>(children);
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index ae13fb3..fda961d 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -15,15 +15,19 @@
 package com.google.gerrit.index.query;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.common.collect.ImmutableList;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
 
 /** Requires all predicates to be true. */
-public class AndPredicate<T> extends Predicate<T> implements Matchable<T> {
+public class AndPredicate<T> extends Predicate<T>
+    implements Matchable<T>, Comparator<Predicate<T>> {
   private final List<Predicate<T>> children;
   private final int cost;
 
@@ -35,7 +39,7 @@
   protected AndPredicate(Collection<? extends Predicate<T>> that) {
     List<Predicate<T>> t = new ArrayList<>(that.size());
     int c = 0;
-    for (Predicate<T> p : that) {
+    for (Predicate<T> p : sort(that)) {
       if (getClass() == p.getClass()) {
         for (Predicate<T> gp : p.getChildren()) {
           t.add(gp);
@@ -105,6 +109,8 @@
     return getChild(0).hashCode() * 31 + getChild(1).hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
@@ -114,6 +120,28 @@
         && getChildren().equals(((Predicate<?>) other).getChildren());
   }
 
+  private ImmutableList<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
+    return that.stream().sorted(this).collect(toImmutableList());
+  }
+
+  @Override
+  public int compare(Predicate<T> a, Predicate<T> b) {
+    int ai = a instanceof DataSource ? 0 : 1;
+    int bi = b instanceof DataSource ? 0 : 1;
+    int cmp = ai - bi;
+
+    if (cmp == 0) {
+      cmp = a.estimateCost() - b.estimateCost();
+    }
+
+    if (cmp == 0 && a instanceof DataSource) {
+      DataSource<?> as = (DataSource<?>) a;
+      DataSource<?> bs = (DataSource<?>) b;
+      cmp = as.getCardinality() - bs.getCardinality();
+    }
+    return cmp;
+  }
+
   @Override
   public String toString() {
     final StringBuilder r = new StringBuilder();
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index 538e11b..f5f30bd 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -15,124 +15,81 @@
 package com.google.gerrit.index.query;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.collect.ImmutableList.toImmutableList;
 
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.exceptions.StorageException;
-import java.util.ArrayList;
+import com.google.gerrit.index.IndexConfig;
 import java.util.Collection;
-import java.util.Comparator;
 import java.util.List;
 
-public class AndSource<T> extends AndPredicate<T>
-    implements DataSource<T>, Comparator<Predicate<T>> {
+public class AndSource<T> extends AndPredicate<T> implements DataSource<T> {
   protected final DataSource<T> source;
 
   private final IsVisibleToPredicate<T> isVisibleToPredicate;
   private final int start;
   private final int cardinality;
+  private final IndexConfig indexConfig;
 
-  public AndSource(Collection<? extends Predicate<T>> that) {
-    this(that, null, 0);
+  public AndSource(Collection<? extends Predicate<T>> that, IndexConfig indexConfig) {
+    this(that, null, 0, indexConfig);
   }
 
-  public AndSource(Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate) {
-    this(that, isVisibleToPredicate, 0);
+  public AndSource(
+      Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate, IndexConfig indexConfig) {
+    this(that, isVisibleToPredicate, 0, indexConfig);
   }
 
-  public AndSource(Predicate<T> that, IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
-    this(ImmutableList.of(that), isVisibleToPredicate, start);
+  public AndSource(
+      Predicate<T> that,
+      IsVisibleToPredicate<T> isVisibleToPredicate,
+      int start,
+      IndexConfig indexConfig) {
+    this(ImmutableList.of(that), isVisibleToPredicate, start, indexConfig);
   }
 
   public AndSource(
       Collection<? extends Predicate<T>> that,
       IsVisibleToPredicate<T> isVisibleToPredicate,
-      int start) {
+      int start,
+      IndexConfig indexConfig) {
     super(that);
     checkArgument(start >= 0, "negative start: %s", start);
     this.isVisibleToPredicate = isVisibleToPredicate;
     this.start = start;
+    this.indexConfig = indexConfig;
 
     int c = Integer.MAX_VALUE;
-    DataSource<T> s = null;
-    int minCost = Integer.MAX_VALUE;
-    for (Predicate<T> p : sort(getChildren())) {
+    Predicate<T> selectedSource = null;
+    int minCardinality = Integer.MAX_VALUE;
+    for (Predicate<T> p : getChildren()) {
       if (p instanceof DataSource) {
-        c = Math.min(c, ((DataSource<?>) p).getCardinality());
+        DataSource<?> source = (DataSource<?>) p;
+        int cardinality = source.getCardinality();
+        c = Math.min(c, source.getCardinality());
 
-        int cost = p.estimateCost();
-        if (cost < minCost) {
-          s = toDataSource(p);
-          minCost = cost;
+        if (selectedSource == null
+            || cardinality < minCardinality
+            || (cardinality == minCardinality
+                && p.estimateCost() < selectedSource.estimateCost())) {
+          selectedSource = p;
+          minCardinality = cardinality;
         }
       }
     }
-    this.source = s;
+    if (selectedSource == null) {
+      throw new IllegalArgumentException("No DataSource Found");
+    }
+    this.source = toPaginatingSource(selectedSource);
     this.cardinality = c;
   }
 
   @Override
   public ResultSet<T> read() {
-    if (source == null) {
-      throw new StorageException("No DataSource: " + this);
-    }
-
-    // ResultSets are lazy. Calling #read here first and then dealing with ResultSets only when
-    // requested allows the index to run asynchronous queries.
-    ResultSet<T> resultSet = source.read();
-    return new LazyResultSet<>(
-        () -> {
-          List<T> r = new ArrayList<>();
-          T last = null;
-          int nextStart = 0;
-          boolean skipped = false;
-          for (T data : buffer(resultSet)) {
-            if (!isMatchable() || match(data)) {
-              r.add(data);
-            } else {
-              skipped = true;
-            }
-            last = data;
-            nextStart++;
-          }
-
-          if (skipped && last != null && source instanceof Paginated) {
-            // If our source is a paginated source and we skipped at
-            // least one of its results, we may not have filled the full
-            // limit the caller wants.  Restart the source and continue.
-            //
-            @SuppressWarnings("unchecked")
-            Paginated<T> p = (Paginated<T>) source;
-            while (skipped && r.size() < p.getOptions().limit() + start) {
-              skipped = false;
-              ResultSet<T> next = p.restart(nextStart);
-
-              for (T data : buffer(next)) {
-                if (match(data)) {
-                  r.add(data);
-                } else {
-                  skipped = true;
-                }
-                nextStart++;
-              }
-            }
-          }
-
-          if (start >= r.size()) {
-            return ImmutableList.of();
-          } else if (start > 0) {
-            return ImmutableList.copyOf(r.subList(start, r.size()));
-          }
-          return ImmutableList.copyOf(r);
-        });
+    return source.read();
   }
 
   @Override
   public ResultSet<FieldBundle> readRaw() {
-    // TOOD(hiesel): Implement
-    throw new UnsupportedOperationException("not implemented");
+    return source.readRaw();
   }
 
   @Override
@@ -153,11 +110,6 @@
     return true;
   }
 
-  private Iterable<T> buffer(ResultSet<T> scanner) {
-    return FluentIterable.from(Iterables.partition(scanner, 50))
-        .transformAndConcat(this::transformBuffer);
-  }
-
   protected List<T> transformBuffer(List<T> buffer) {
     return buffer;
   }
@@ -167,30 +119,18 @@
     return cardinality;
   }
 
-  private ImmutableList<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
-    return that.stream().sorted(this).collect(toImmutableList());
-  }
-
-  @Override
-  public int compare(Predicate<T> a, Predicate<T> b) {
-    int ai = a instanceof DataSource ? 0 : 1;
-    int bi = b instanceof DataSource ? 0 : 1;
-    int cmp = ai - bi;
-
-    if (cmp == 0) {
-      cmp = a.estimateCost() - b.estimateCost();
-    }
-
-    if (cmp == 0 && a instanceof DataSource && b instanceof DataSource) {
-      DataSource<?> as = (DataSource<?>) a;
-      DataSource<?> bs = (DataSource<?>) b;
-      cmp = as.getCardinality() - bs.getCardinality();
-    }
-    return cmp;
-  }
-
   @SuppressWarnings("unchecked")
-  private DataSource<T> toDataSource(Predicate<T> pred) {
-    return (DataSource<T>) pred;
+  private PaginatingSource<T> toPaginatingSource(Predicate<T> pred) {
+    return new PaginatingSource<>((DataSource<T>) pred, start, indexConfig) {
+      @Override
+      protected boolean match(T object) {
+        return AndSource.this.match(object);
+      }
+
+      @Override
+      protected boolean isMatchable() {
+        return AndSource.this.isMatchable();
+      }
+    };
   }
 }
diff --git a/java/com/google/gerrit/index/query/DataSource.java b/java/com/google/gerrit/index/query/DataSource.java
index 518d153..3b83478 100644
--- a/java/com/google/gerrit/index/query/DataSource.java
+++ b/java/com/google/gerrit/index/query/DataSource.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.index.query;
 
-public interface DataSource<T> {
-  /** Returns an estimate of the number of results from {@link #read()}. */
-  int getCardinality();
-
-  /** Returns read from the database and return the results. */
+public interface DataSource<T> extends HasCardinality {
+  /** Returns read from the index and return the results. */
   ResultSet<T> read();
 
-  /** Returns read from the database and return the raw results. */
+  /** Returns read from the index and return the raw results. */
   ResultSet<FieldBundle> readRaw();
 }
diff --git a/java/com/google/gerrit/index/query/FieldBundle.java b/java/com/google/gerrit/index/query/FieldBundle.java
index 6ecb6e6..551de92 100644
--- a/java/com/google/gerrit/index/query/FieldBundle.java
+++ b/java/com/google/gerrit/index/query/FieldBundle.java
@@ -16,10 +16,13 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.IndexedField.SearchSpec;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 /** FieldBundle is an abstraction that allows retrieval of raw values from different sources. */
 public class FieldBundle {
@@ -27,15 +30,29 @@
   // Map String => List{Integer, Long, Timestamp, String, byte[]}
   private ImmutableListMultimap<String, Object> fields;
 
-  public FieldBundle(ListMultimap<String, Object> fields) {
+  /**
+   * Depending on the index implementation 1) either {@link IndexedField} are stored once and
+   * referenced by {@link com.google.gerrit.index.IndexedField.SearchSpec} on the queries, 2) or
+   * each {@link com.google.gerrit.index.IndexedField.SearchSpec} is stored individually.
+   *
+   * <p>In case #1 {@link #storesIndexedFields} is set to {@code true} and the {@link #fields}
+   * contain a map from {@link IndexedField#name()} to a stored value.
+   *
+   * <p>In case #2 {@link #storesIndexedFields} is set to {@code false} and the {@link #fields}
+   * contain a map from {@link SearchSpec#name()} to a stored value.
+   */
+  private final boolean storesIndexedFields;
+
+  public FieldBundle(ListMultimap<String, Object> fields, boolean storesIndexedFields) {
     this.fields = ImmutableListMultimap.copyOf(fields);
+    this.storesIndexedFields = storesIndexedFields;
   }
 
   /**
    * Get a field's value based on the field definition.
    *
-   * @param fieldDef the definition of the field of which the value should be retrieved. The field
-   *     must be stored and contained in the result set as specified by {@link
+   * @param schemaField the definition of the field of which the value should be retrieved. The
+   *     field must be stored and contained in the result set as specified by {@link
    *     com.google.gerrit.index.QueryOptions}.
    * @param <T> Data type of the returned object based on the field definition
    * @return Either a single element or an Iterable based on the field definition. An empty list is
@@ -44,16 +61,20 @@
    *     check is only enforced on non-repeatable fields.
    */
   @SuppressWarnings("unchecked")
-  public <T> T getValue(FieldDef<?, T> fieldDef) {
-    checkArgument(fieldDef.isStored(), "Field must be stored");
+  public <T> T getValue(SchemaField<?, T> schemaField) {
+    checkArgument(schemaField.isStored(), "Field must be stored");
+    String storedFieldName =
+        storesIndexedFields && schemaField instanceof IndexedField<?, ?>.SearchSpec
+            ? ((IndexedField<?, ?>.SearchSpec) schemaField).getField().name()
+            : schemaField.getName();
     checkArgument(
-        fields.containsKey(fieldDef.getName()) || fieldDef.isRepeatable(),
+        fields.containsKey(storedFieldName) || schemaField.isRepeatable(),
         "Field %s is not in result set %s",
-        fieldDef.getName(),
+        storedFieldName,
         fields.keySet());
 
-    Iterable<Object> result = fields.get(fieldDef.getName());
-    if (fieldDef.isRepeatable()) {
+    ImmutableList<Object> result = fields.get(storedFieldName);
+    if (schemaField.isRepeatable()) {
       return (T) result;
     }
     return (T) Iterables.getOnlyElement(result);
diff --git a/java/com/google/gerrit/index/query/HasCardinality.java b/java/com/google/gerrit/index/query/HasCardinality.java
new file mode 100644
index 0000000..140ba4b
--- /dev/null
+++ b/java/com/google/gerrit/index/query/HasCardinality.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+public interface HasCardinality {
+  /** Returns an estimate of the number of results a source can return. */
+  int getCardinality();
+}
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index 18d7fbc..de81c47 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -21,8 +21,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
 import com.google.common.primitives.Longs;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.Objects;
 import java.util.Set;
 import java.util.stream.StreamSupport;
@@ -35,21 +35,21 @@
    * complexity was reduced to the bare minimum at the cost of small discrepancies to the Unicode
    * spec.
    */
-  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_\n"));
+  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_=\n"));
 
-  private final FieldDef<I, ?> def;
+  private final SchemaField<I, ?> def;
 
-  protected IndexPredicate(FieldDef<I, ?> def, String value) {
+  protected IndexPredicate(SchemaField<I, ?> def, String value) {
     super(def.getName(), value);
     this.def = def;
   }
 
-  protected IndexPredicate(FieldDef<I, ?> def, String name, String value) {
+  protected IndexPredicate(SchemaField<I, ?> def, String name, String value) {
     super(name, value);
     this.def = def;
   }
 
-  public FieldDef<I, ?> getField() {
+  public SchemaField<I, ?> getField() {
     return def;
   }
 
diff --git a/java/com/google/gerrit/index/query/IndexedQuery.java b/java/com/google/gerrit/index/query/IndexedQuery.java
index d9e33ea..ee25ef9 100644
--- a/java/com/google/gerrit/index/query/IndexedQuery.java
+++ b/java/com/google/gerrit/index/query/IndexedQuery.java
@@ -87,19 +87,15 @@
   }
 
   @Override
-  public ResultSet<T> restart(int start) {
-    opts = opts.withStart(start);
-    try {
-      source = index.getSource(pred, opts);
-    } catch (QueryParseException e) {
-      // Don't need to show this exception to the user; the only thing that
-      // changed about pred was its start, and any other QPEs that might happen
-      // should have already thrown from the constructor.
-      throw new StorageException(e);
-    }
-    // Don't convert start to a limit, since the caller of this method (see
-    // AndSource) has calculated the actual number to skip.
-    return read();
+  public ResultSet<T> restart(int start, int pageSize) {
+    opts = opts.withStart(start).withPageSize(pageSize);
+    return search();
+  }
+
+  @Override
+  public ResultSet<T> restart(Object searchAfter, int pageSize) {
+    opts = opts.withSearchAfter(searchAfter).withPageSize(pageSize);
+    return search();
   }
 
   @Override
@@ -112,6 +108,8 @@
     return pred.hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null || getClass() != other.getClass()) {
@@ -125,4 +123,18 @@
   public String toString() {
     return MoreObjects.toStringHelper("index").add("p", pred).add("opts", opts).toString();
   }
+
+  private ResultSet<T> search() {
+    try {
+      source = index.getSource(pred, opts);
+    } catch (QueryParseException e) {
+      // Don't need to show this exception to the user; the only thing that
+      // changed about pred was its start, and any other QPEs that might happen
+      // should have already thrown from the constructor.
+      throw new StorageException(e);
+    }
+    // Don't convert start to a limit, since the caller of this method (see
+    // AndSource) has calculated the actual number to skip.
+    return read();
+  }
 }
diff --git a/java/com/google/gerrit/index/query/IntPredicate.java b/java/com/google/gerrit/index/query/IntPredicate.java
index 16e59e7..a98e0b1 100644
--- a/java/com/google/gerrit/index/query/IntPredicate.java
+++ b/java/com/google/gerrit/index/query/IntPredicate.java
@@ -37,6 +37,8 @@
     return getOperator().hashCode() * 31 + intValue;
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
index 850c4a5..278d2af 100644
--- a/java/com/google/gerrit/index/query/IntegerRangePredicate.java
+++ b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.RangeUtil.Range;
 
 public abstract class IntegerRangePredicate<T> extends IndexPredicate<T> {
   private final Range range;
 
-  protected IntegerRangePredicate(FieldDef<T, Integer> type, String value)
+  protected IntegerRangePredicate(SchemaField<T, Integer> type, String value)
       throws QueryParseException {
     super(type, value);
     range = RangeUtil.getRange(value, Integer.MIN_VALUE, Integer.MAX_VALUE);
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index 5c003bc..b6418a9 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -20,12 +20,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.Arrays;
 import java.util.List;
 import java.util.function.Supplier;
@@ -76,10 +77,10 @@
   }
 
   @SafeVarargs
-  public final Q setRequestedFields(FieldDef<T, ?>... fields) {
+  public final Q setRequestedFields(SchemaField<T, ?>... fields) {
     checkArgument(fields.length > 0, "requested field list is empty");
     queryProcessor.setRequestedFields(
-        Arrays.stream(fields).map(FieldDef::getName).collect(toSet()));
+        Arrays.stream(fields).map(SchemaField::getName).collect(toSet()));
     return self();
   }
 
@@ -118,6 +119,7 @@
     }
   }
 
+  @Nullable
   protected final Schema<T> schema() {
     Index<?, T> index = indexes != null ? indexes.getSearchIndex() : null;
     return index != null ? index.getSchema() : null;
diff --git a/java/com/google/gerrit/index/query/LazyResultSet.java b/java/com/google/gerrit/index/query/LazyResultSet.java
index f3fab5f..a7d71f0 100644
--- a/java/com/google/gerrit/index/query/LazyResultSet.java
+++ b/java/com/google/gerrit/index/query/LazyResultSet.java
@@ -53,4 +53,9 @@
 
   @Override
   public void close() {}
+
+  @Override
+  public Object searchAfter() {
+    return null;
+  }
 }
diff --git a/java/com/google/gerrit/index/query/LimitPredicate.java b/java/com/google/gerrit/index/query/LimitPredicate.java
index 23e0f6d..9196811 100644
--- a/java/com/google/gerrit/index/query/LimitPredicate.java
+++ b/java/com/google/gerrit/index/query/LimitPredicate.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.index.query;
 
+import com.google.gerrit.common.Nullable;
+
 public class LimitPredicate<T> extends IntPredicate<T> implements Matchable<T> {
   @SuppressWarnings("unchecked")
+  @Nullable
   public static Integer getLimit(String fieldName, Predicate<?> p) {
     IntPredicate<?> ip = QueryBuilder.find(p, IntPredicate.class, fieldName);
     return ip != null ? ip.intValue() : null;
diff --git a/java/com/google/gerrit/index/query/ListResultSet.java b/java/com/google/gerrit/index/query/ListResultSet.java
index 9d7eadf..f09fda0 100644
--- a/java/com/google/gerrit/index/query/ListResultSet.java
+++ b/java/com/google/gerrit/index/query/ListResultSet.java
@@ -54,4 +54,9 @@
   public void close() {
     results = null;
   }
+
+  @Override
+  public Object searchAfter() {
+    return null;
+  }
 }
diff --git a/java/com/google/gerrit/index/query/NotPredicate.java b/java/com/google/gerrit/index/query/NotPredicate.java
index 14cb740..fa8e01b 100644
--- a/java/com/google/gerrit/index/query/NotPredicate.java
+++ b/java/com/google/gerrit/index/query/NotPredicate.java
@@ -21,10 +21,10 @@
 import java.util.List;
 
 /** Negates the result of another predicate. */
-public class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
+public final class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
   private final Predicate<T> that;
 
-  protected NotPredicate(Predicate<T> that) {
+  NotPredicate(Predicate<T> that) {
     if (that instanceof NotPredicate) {
       throw new IllegalArgumentException("Double negation unsupported");
     }
@@ -87,7 +87,7 @@
     if (other == null) {
       return false;
     }
-    return getClass() == other.getClass()
+    return other instanceof NotPredicate
         && getChildren().equals(((Predicate<?>) other).getChildren());
   }
 
diff --git a/java/com/google/gerrit/index/query/OperatorPredicate.java b/java/com/google/gerrit/index/query/OperatorPredicate.java
index 368ee24..ea7717f 100644
--- a/java/com/google/gerrit/index/query/OperatorPredicate.java
+++ b/java/com/google/gerrit/index/query/OperatorPredicate.java
@@ -47,6 +47,8 @@
     return getOperator().hashCode() * 31 + getValue().hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/OrCardinalPredicate.java b/java/com/google/gerrit/index/query/OrCardinalPredicate.java
new file mode 100644
index 0000000..9d7913a
--- /dev/null
+++ b/java/com/google/gerrit/index/query/OrCardinalPredicate.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import java.util.Collection;
+import java.util.Optional;
+
+public class OrCardinalPredicate<T> extends OrPredicate<T> implements HasCardinality {
+  private final int cardinality;
+
+  public OrCardinalPredicate(Collection<? extends Predicate<T>> that) {
+    super(that);
+    Optional<Predicate<T>> nonHasCardinality =
+        getChildren().stream().filter(p -> !(p instanceof HasCardinality)).findAny();
+    if (nonHasCardinality.isPresent()) {
+      throw new IllegalArgumentException("No HasCardinality: " + nonHasCardinality.get());
+    }
+    int aggregateCardinality = 0;
+    for (Predicate<T> p : getChildren()) {
+      if (p instanceof HasCardinality) {
+        aggregateCardinality += ((HasCardinality) p).getCardinality();
+      }
+    }
+    cardinality = aggregateCardinality;
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    return new OrCardinalPredicate<>(children);
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/OrPredicate.java b/java/com/google/gerrit/index/query/OrPredicate.java
index 9bc3769..1c31af3 100644
--- a/java/com/google/gerrit/index/query/OrPredicate.java
+++ b/java/com/google/gerrit/index/query/OrPredicate.java
@@ -105,6 +105,8 @@
     return getChild(0).hashCode() * 31 + getChild(1).hashCode();
   }
 
+  // Suppress the EqualsGetClass warning as this is legacy code.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object other) {
     if (other == null) {
diff --git a/java/com/google/gerrit/index/query/Paginated.java b/java/com/google/gerrit/index/query/Paginated.java
index e61dd53..5521990 100644
--- a/java/com/google/gerrit/index/query/Paginated.java
+++ b/java/com/google/gerrit/index/query/Paginated.java
@@ -19,5 +19,7 @@
 public interface Paginated<T> {
   QueryOptions getOptions();
 
-  ResultSet<T> restart(int start);
+  ResultSet<T> restart(int start, int pageSize);
+
+  ResultSet<T> restart(Object searchAfter, int pageSize);
 }
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
new file mode 100644
index 0000000..337332f
--- /dev/null
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.PaginationType;
+import com.google.gerrit.index.QueryOptions;
+import java.util.ArrayList;
+import java.util.List;
+
+public class PaginatingSource<T> implements DataSource<T> {
+  protected final DataSource<T> source;
+  private final int start;
+  private final int cardinality;
+  private final IndexConfig indexConfig;
+
+  public PaginatingSource(DataSource<T> source, int start, IndexConfig indexConfig) {
+    checkArgument(start >= 0, "negative start: %s", start);
+    this.source = source;
+    this.start = start;
+    this.cardinality = source.getCardinality();
+    this.indexConfig = indexConfig;
+  }
+
+  @Override
+  public ResultSet<T> read() {
+    if (source == null) {
+      throw new StorageException("No DataSource: " + this);
+    }
+
+    // ResultSets are lazy. Calling #read here first and then dealing with ResultSets only when
+    // requested allows the index to run asynchronous queries.
+    ResultSet<T> resultSet = source.read();
+    return new LazyResultSet<>(
+        () -> {
+          List<T> r = new ArrayList<>();
+          T last = null;
+          int pageResultSize = 0;
+          for (T data : buffer(resultSet)) {
+            if (!isMatchable() || match(data)) {
+              r.add(data);
+            }
+            last = data;
+            pageResultSize++;
+          }
+
+          if (last != null && source instanceof Paginated) {
+            // Restart source and continue if we have not filled the
+            // full limit the caller wants.
+            //
+            @SuppressWarnings("unchecked")
+            Paginated<T> p = (Paginated<T>) source;
+            QueryOptions opts = p.getOptions();
+            final int limit = opts.limit();
+            int pageSize = opts.pageSize();
+            int pageSizeMultiplier = opts.pageSizeMultiplier();
+            Object searchAfter = resultSet.searchAfter();
+            int nextStart = pageResultSize;
+            while (pageResultSize == pageSize && r.size() <= limit) { // get 1 more than the limit
+              pageSize = getNextPageSize(pageSize, pageSizeMultiplier);
+              ResultSet<T> next =
+                  indexConfig.paginationType().equals(PaginationType.SEARCH_AFTER)
+                      ? p.restart(searchAfter, pageSize)
+                      : p.restart(nextStart, pageSize);
+              pageResultSize = 0;
+              for (T data : buffer(next)) {
+                if (match(data)) {
+                  r.add(data);
+                }
+                pageResultSize++;
+              }
+              nextStart += pageResultSize;
+              searchAfter = next.searchAfter();
+            }
+          }
+
+          if (start >= r.size()) {
+            return ImmutableList.of();
+          } else if (start > 0) {
+            return ImmutableList.copyOf(r.subList(start, r.size()));
+          }
+          return ImmutableList.copyOf(r);
+        });
+  }
+
+  @Override
+  public ResultSet<FieldBundle> readRaw() {
+    // TODO(hiesel): Implement
+    throw new UnsupportedOperationException("not implemented");
+  }
+
+  private Iterable<T> buffer(ResultSet<T> scanner) {
+    return FluentIterable.from(Iterables.partition(scanner, 50))
+        .transformAndConcat(this::transformBuffer);
+  }
+
+  /**
+   * Checks whether the given object matches.
+   *
+   * @param object the object to be matched
+   * @return whether the given object matches
+   */
+  protected boolean match(T object) {
+    return true;
+  }
+
+  protected boolean isMatchable() {
+    return true;
+  }
+
+  protected List<T> transformBuffer(List<T> buffer) {
+    return buffer;
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+
+  private int getNextPageSize(int pageSize, int pageSizeMultiplier) {
+    List<Integer> possiblePageSizes = new ArrayList<>(3);
+    try {
+      possiblePageSizes.add(Math.multiplyExact(pageSize, pageSizeMultiplier));
+    } catch (ArithmeticException e) {
+      possiblePageSizes.add(Integer.MAX_VALUE);
+    }
+    if (indexConfig.maxPageSize() > 0) {
+      possiblePageSizes.add(indexConfig.maxPageSize());
+    }
+    if (indexConfig.maxLimit() > 0) {
+      possiblePageSizes.add(indexConfig.maxLimit());
+    }
+    return Ordering.natural().min(possiblePageSizes);
+  }
+}
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index 9dc7689..e251b00 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -18,10 +18,10 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.collect.Iterables;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Queue;
 
@@ -152,7 +152,7 @@
   /** Returns a list of this predicate and all its descendants. */
   public List<Predicate<T>> getFlattenedPredicateList() {
     List<Predicate<T>> result = new ArrayList<>();
-    Queue<Predicate<T>> queue = new LinkedList<>();
+    Queue<Predicate<T>> queue = new ArrayDeque<>();
     queue.add(this);
     while (!queue.isEmpty()) {
       Predicate<T> current = queue.poll();
diff --git a/java/com/google/gerrit/index/query/QueryBuilder.java b/java/com/google/gerrit/index/query/QueryBuilder.java
index ffa7ce4..987c7d3 100644
--- a/java/com/google/gerrit/index/query/QueryBuilder.java
+++ b/java/com/google/gerrit/index/query/QueryBuilder.java
@@ -31,6 +31,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -46,6 +47,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.antlr.runtime.CharStream;
 import org.antlr.runtime.CommonToken;
 import org.antlr.runtime.tree.CommonTree;
@@ -364,7 +366,7 @@
    * @throws QueryParseException the parser does not recognize this value.
    */
   protected Predicate<T> defaultField(String value) throws QueryParseException {
-    throw error("Unsupported query:" + value);
+    throw error("Unsupported query: " + value);
   }
 
   private List<Predicate<T>> children(Tree r) throws QueryParseException, IllegalArgumentException {
@@ -416,8 +418,13 @@
       } catch (RuntimeException | IllegalAccessException e) {
         throw error("Error in operator " + name + ":" + value, e);
       } catch (InvocationTargetException e) {
-        if (e.getCause() instanceof QueryParseException) {
-          throw (QueryParseException) e.getCause();
+        Optional<QueryParseException> queryParseException =
+            Throwables.getCausalChain(e).stream()
+                .filter(QueryParseException.class::isInstance)
+                .map(QueryParseException.class::cast)
+                .findAny();
+        if (queryParseException.isPresent()) {
+          throw queryParseException.get();
         }
         throw error("Error in operator " + name + ":" + value, e.getCause());
       }
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index ea23d91..1c8bbc3 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -79,7 +79,7 @@
   private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
   private final IndexRewriter<T> rewriter;
   private final String limitField;
-  private final IntSupplier permittedLimit;
+  private final IntSupplier userQueryLimit;
   private final CallerFinder callerFinder;
 
   // This class is not generally thread-safe, but programmer error may result in it being shared
@@ -100,14 +100,14 @@
       IndexCollection<?, T, ? extends Index<?, T>> indexes,
       IndexRewriter<T> rewriter,
       String limitField,
-      IntSupplier permittedLimit) {
+      IntSupplier userQueryLimit) {
     this.metrics = new Metrics(metricMaker);
     this.schemaDef = schemaDef;
     this.indexConfig = indexConfig;
     this.indexes = indexes;
     this.rewriter = rewriter;
     this.limitField = limitField;
-    this.permittedLimit = permittedLimit;
+    this.userQueryLimit = userQueryLimit;
     this.used = new AtomicBoolean(false);
     this.callerFinder =
         CallerFinder.builder()
@@ -230,9 +230,10 @@
         checkSupportedForQueries(q);
         int limit = getEffectiveLimit(q);
         limits.add(limit);
+        int initialPageSize = getInitialPageSize(limit);
 
-        if (limit == getBackendSupportedLimit()) {
-          limit--;
+        if (initialPageSize == getBackendSupportedLimit()) {
+          initialPageSize--;
         }
 
         int page = (start / limit) + 1;
@@ -241,12 +242,35 @@
               "Cannot go beyond page " + indexConfig.maxPages() + " of results");
         }
 
-        // Always bump limit by 1, even if this results in exceeding the permitted
-        // max for this user. The only way to see if there are more entities is to
-        // ask for one more result from the query.
-        QueryOptions opts = createOptions(indexConfig, start, limit + 1, getRequestedFields());
-        logger.atFine().log("Query options: " + opts);
-        Predicate<T> pred = rewriter.rewrite(q, opts);
+        // Always bump initial page size by 1, even if this results in exceeding the
+        // permitted max for this user. The only way to see if there are more entities
+        // is to ask for one more result from the query.
+        try {
+          initialPageSize = Math.addExact(initialPageSize, 1);
+        } catch (ArithmeticException e) {
+          initialPageSize = Integer.MAX_VALUE;
+        }
+
+        // If pageSizeMultiplier is set to 1 (default), update it to 10 for no-limit queries as
+        // it helps improve performance and also prevents no-limit queries from severely degrading
+        // when pagination type is OFFSET.
+        int pageSizeMultiplier = indexConfig.pageSizeMultiplier();
+        if (isNoLimit && pageSizeMultiplier == 1) {
+          pageSizeMultiplier = 10;
+        }
+
+        QueryOptions opts =
+            createOptions(
+                indexConfig,
+                start,
+                initialPageSize,
+                pageSizeMultiplier,
+                limit,
+                getRequestedFields());
+        logger.atFine().log("Query options: %s", opts);
+        // Apply index-specific rewrite first
+        Predicate<T> pred = indexes.getSearchIndex().getIndexRewriter().rewrite(q, opts);
+        pred = rewriter.rewrite(pred, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
@@ -259,6 +283,9 @@
 
         @SuppressWarnings("unchecked")
         DataSource<T> s = (DataSource<T>) pred;
+        if (initialPageSize < limit && !(pred instanceof AndSource)) {
+          s = new PaginatingSource<>(s, start, indexConfig);
+        }
         sources.add(s);
       }
 
@@ -318,8 +345,14 @@
   }
 
   protected QueryOptions createOptions(
-      IndexConfig indexConfig, int start, int limit, Set<String> requestedFields) {
-    return QueryOptions.create(indexConfig, start, limit, requestedFields);
+      IndexConfig indexConfig,
+      int start,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      Set<String> requestedFields) {
+    return QueryOptions.create(
+        indexConfig, start, pageSize, pageSizeMultiplier, limit, requestedFields);
   }
 
   /**
@@ -336,7 +369,7 @@
       return requestedFields;
     }
     Index<?, T> index = indexes.getSearchIndex();
-    return index != null ? index.getSchema().getStoredFields().keySet() : ImmutableSet.of();
+    return index != null ? index.getSchema().getStoredFields() : ImmutableSet.of();
   }
 
   /**
@@ -357,14 +390,14 @@
   }
 
   private int getPermittedLimit() {
-    return enforceVisibility ? permittedLimit.getAsInt() : Integer.MAX_VALUE;
+    return enforceVisibility ? userQueryLimit.getAsInt() : Integer.MAX_VALUE;
   }
 
   private int getBackendSupportedLimit() {
     return indexConfig.maxLimit();
   }
 
-  private int getEffectiveLimit(Predicate<T> p) {
+  public int getEffectiveLimit(Predicate<T> p) {
     if (isNoLimit == true) {
       return Integer.MAX_VALUE;
     }
@@ -383,6 +416,7 @@
     int result = Ordering.natural().min(possibleLimits);
     // Should have short-circuited from #query or thrown some other exception before getting here.
     checkState(result > 0, "effective limit should be positive");
+
     return result;
   }
 
@@ -393,5 +427,17 @@
         .findFirst();
   }
 
+  protected IntSupplier getUserQueryLimit() {
+    return userQueryLimit;
+  }
+
+  protected int getInitialPageSize(int queryLimit) {
+    return queryLimit;
+  }
+
   protected abstract String formatForLogging(T t);
+
+  protected abstract int getIndexSize();
+
+  protected abstract int getBatchSize();
 }
diff --git a/java/com/google/gerrit/index/query/RegexPredicate.java b/java/com/google/gerrit/index/query/RegexPredicate.java
index 60a2a9e..4c76770 100644
--- a/java/com/google/gerrit/index/query/RegexPredicate.java
+++ b/java/com/google/gerrit/index/query/RegexPredicate.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 public abstract class RegexPredicate<I> extends IndexPredicate<I> {
-  protected RegexPredicate(FieldDef<I, ?> def, String value) {
+  protected RegexPredicate(SchemaField<I, ?> def, String value) {
     super(def, value);
   }
 
-  protected RegexPredicate(FieldDef<I, ?> def, String name, String value) {
+  protected RegexPredicate(SchemaField<I, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/java/com/google/gerrit/index/query/ResultSet.java b/java/com/google/gerrit/index/query/ResultSet.java
index 65fcd45..b4bd19e 100644
--- a/java/com/google/gerrit/index/query/ResultSet.java
+++ b/java/com/google/gerrit/index/query/ResultSet.java
@@ -49,4 +49,6 @@
    * the iterator has finished.
    */
   void close();
+
+  Object searchAfter();
 }
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index 42f8aa8..1fd81a6 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -14,23 +14,23 @@
 
 package com.google.gerrit.index.query;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.json.JavaSqlTimestampHelper;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 
 // TODO: Migrate this to IntegerRangePredicate
 public abstract class TimestampRangePredicate<I> extends IndexPredicate<I> {
-  protected static Timestamp parse(String value) throws QueryParseException {
+  protected static Instant parse(String value) throws QueryParseException {
     try {
-      return JavaSqlTimestampHelper.parseTimestamp(value);
+      return JavaSqlTimestampHelper.parseTimestamp(value).toInstant();
     } catch (IllegalArgumentException e) {
       // parseTimestamp's errors are specific and helpful, so preserve them.
       throw new QueryParseException(e.getMessage(), e);
     }
   }
 
-  protected TimestampRangePredicate(FieldDef<I, Timestamp> def, String name, String value) {
+  protected TimestampRangePredicate(SchemaField<I, Timestamp> def, String name, String value) {
     super(def, name, value);
   }
 
@@ -38,7 +38,7 @@
     return (Timestamp) this.getField().get(object);
   }
 
-  public abstract Date getMinTimestamp();
+  public abstract Instant getMinTimestamp();
 
-  public abstract Date getMaxTimestamp();
+  public abstract Instant getMaxTimestamp();
 }
diff --git a/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java b/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
index b0a394e..9158a39 100644
--- a/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
+++ b/java/com/google/gerrit/index/query/TooManyTermsInQueryException.java
@@ -14,16 +14,24 @@
 
 package com.google.gerrit.index.query;
 
+import com.google.gerrit.common.UsedAt;
+
 public class TooManyTermsInQueryException extends QueryParseException {
   private static final long serialVersionUID = 1L;
 
   private static final String MESSAGE = "too many terms in query";
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public TooManyTermsInQueryException() {
     super(MESSAGE);
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public TooManyTermsInQueryException(Throwable why) {
     super(MESSAGE, why);
   }
+
+  public TooManyTermsInQueryException(int numTerms, int maxConfiguredTerms) {
+    super(MESSAGE + String.format(": %d terms (max = %d)", numTerms, maxConfiguredTerms));
+  }
 }
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index b727e96..92bc126 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -19,15 +19,17 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.query.DataSource;
@@ -47,11 +49,14 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import org.eclipse.jgit.annotations.Nullable;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -69,12 +74,14 @@
 
   private final String indexName;
   private final Map<K, D> indexedDocuments;
+  private int queryCount;
 
   AbstractFakeIndex(Schema<V> schema, SitePaths sitePaths, String indexName) {
     this.schema = schema;
     this.sitePaths = sitePaths;
     this.indexName = indexName;
     this.indexedDocuments = new HashMap<>();
+    this.queryCount = 0;
   }
 
   @Override
@@ -108,20 +115,35 @@
     }
   }
 
+  public int getQueryCount() {
+    return queryCount;
+  }
+
   @Override
   public DataSource<V> getSource(Predicate<V> p, QueryOptions opts) {
     List<V> results;
     synchronized (indexedDocuments) {
-      results =
+      Stream<V> valueStream =
           indexedDocuments.values().stream()
               .map(doc -> valueFor(doc))
               .filter(doc -> p.asMatchable().match(doc))
-              .sorted(sortingComparator())
-              .skip(opts.start())
-              .limit(opts.limit())
-              .collect(toImmutableList());
+              .sorted(sortingComparator());
+      if (opts.searchAfter() != null) {
+        ImmutableList<V> valueList = valueStream.collect(toImmutableList());
+        int fromIndex =
+            IntStream.range(0, valueList.size())
+                    .filter(i -> keyFor(valueList.get(i)).equals(opts.searchAfter()))
+                    .findFirst()
+                    .orElse(-1)
+                + 1;
+        int toIndex = Math.min(fromIndex + opts.pageSize(), valueList.size());
+        results = valueList.subList(fromIndex, toIndex);
+      } else {
+        results = valueStream.skip(opts.start()).limit(opts.pageSize()).collect(toImmutableList());
+      }
+      queryCount++;
     }
-    return new DataSource<V>() {
+    return new DataSource<>() {
       @Override
       public int getCardinality() {
         return results.size();
@@ -129,15 +151,23 @@
 
       @Override
       public ResultSet<V> read() {
-        return new ListResultSet<>(results);
+        return new ListResultSet<>(results) {
+          @Nullable
+          @Override
+          public Object searchAfter() {
+            @Nullable V last = Iterables.getLast(results, null);
+            return last != null ? keyFor(last) : null;
+          }
+        };
       }
 
       @Override
       public ResultSet<FieldBundle> readRaw() {
         ImmutableList.Builder<FieldBundle> fieldBundles = ImmutableList.builder();
+        K searchAfter = null;
         for (V result : results) {
           ImmutableListMultimap.Builder<String, Object> fields = ImmutableListMultimap.builder();
-          for (FieldDef<V, ?> field : getSchema().getFields().values()) {
+          for (SchemaField<V, ?> field : getSchema().getSchemaFields().values()) {
             if (field.get(result) == null) {
               continue;
             }
@@ -147,9 +177,17 @@
               fields.put(field.getName(), field.get(result));
             }
           }
-          fieldBundles.add(new FieldBundle(fields.build()));
+          fieldBundles.add(new FieldBundle(fields.build(), /* storesIndexedFields= */ false));
+          searchAfter = keyFor(result);
         }
-        return new ListResultSet<>(fieldBundles.build());
+        ImmutableList<FieldBundle> resultSet = fieldBundles.build();
+        K finalSearchAfter = searchAfter;
+        return new ListResultSet<>(resultSet) {
+          @Override
+          public Object searchAfter() {
+            return finalSearchAfter;
+          }
+        };
       }
     };
   }
@@ -205,7 +243,7 @@
       Comparator<ChangeData> lastUpdated =
           Comparator.comparing(cd -> cd.change().getLastUpdatedOn());
       Comparator<ChangeData> merged =
-          Comparator.comparing(cd -> cd.getMergedOn().orElse(new Timestamp(0)));
+          Comparator.comparing(cd -> cd.getMergedOn().orElse(Instant.EPOCH));
       Comparator<ChangeData> id = Comparator.comparing(cd -> cd.getId().get());
       return lastUpdated.thenComparing(merged).thenComparing(id).reversed();
     }
@@ -213,8 +251,8 @@
     @Override
     protected Map<String, Object> docFor(ChangeData value) {
       ImmutableMap.Builder<String, Object> doc = ImmutableMap.builder();
-      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
-        if (ChangeField.MERGEABLE.getName().equals(field.getName()) && skipMergable) {
+      for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
+        if (ChangeField.MERGEABLE_SPEC.getName().equals(field.getName()) && skipMergable) {
           continue;
         }
         Object docifiedValue = field.get(value);
@@ -229,13 +267,23 @@
     protected ChangeData valueFor(Map<String, Object> doc) {
       ChangeData cd =
           changeDataFactory.create(
-              Project.nameKey((String) doc.get(ChangeField.PROJECT.getName())),
-              Change.id(Integer.valueOf((String) doc.get(ChangeField.LEGACY_ID_STR.getName()))));
-      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
-        field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName())));
+              Project.nameKey((String) doc.get(ChangeField.PROJECT_SPEC.getName())),
+              Change.id(
+                  Integer.valueOf((String) doc.get(ChangeField.NUMERIC_ID_STR_SPEC.getName()))));
+      for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
+        boolean isProtoField = SchemaFieldDefs.isProtoField(field);
+        field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName()), isProtoField));
       }
       return cd;
     }
+
+    @Override
+    public void insert(ChangeData obj) {}
+
+    @Override
+    public void deleteByValue(ChangeData value) {
+      delete(ChangeIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link AccountIndex} where all filtering happens in-memory. */
@@ -265,6 +313,14 @@
     protected Comparator<AccountState> sortingComparator() {
       return Comparator.comparing(a -> a.account().id().get());
     }
+
+    @Override
+    public void insert(AccountState obj) {}
+
+    @Override
+    public void deleteByValue(AccountState value) {
+      delete(AccountIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link GroupIndex} where all filtering happens in-memory. */
@@ -295,6 +351,14 @@
     protected Comparator<InternalGroup> sortingComparator() {
       return Comparator.comparing(g -> g.getId().get());
     }
+
+    @Override
+    public void insert(InternalGroup obj) {}
+
+    @Override
+    public void deleteByValue(InternalGroup value) {
+      delete(GroupIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 
   /** Fake implementation of {@link ProjectIndex} where all filtering happens in-memory. */
@@ -324,5 +388,13 @@
     protected Comparator<ProjectData> sortingComparator() {
       return Comparator.comparing(p -> p.getProject().getName());
     }
+
+    @Override
+    public void insert(ProjectData obj) {}
+
+    @Override
+    public void deleteByValue(ProjectData value) {
+      delete(ProjectIndex.ENTITY_TO_KEY.apply(value));
+    }
   }
 }
diff --git a/java/com/google/gerrit/index/testing/BUILD b/java/com/google/gerrit/index/testing/BUILD
index a30eaca..44bf70d 100644
--- a/java/com/google/gerrit/index/testing/BUILD
+++ b/java/com/google/gerrit/index/testing/BUILD
@@ -10,18 +10,15 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
-        "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/index",
-        "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
+        "//proto:entities_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/index/testing/FakeStoredValue.java b/java/com/google/gerrit/index/testing/FakeStoredValue.java
index ca46ed1..133e0ab 100644
--- a/java/com/google/gerrit/index/testing/FakeStoredValue.java
+++ b/java/com/google/gerrit/index/testing/FakeStoredValue.java
@@ -14,15 +14,29 @@
 
 package com.google.gerrit.index.testing;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.StoredValue;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 
 /** Bridge to recover fields from the fake index. */
 public class FakeStoredValue implements StoredValue {
   private final Object field;
+  /**
+   * Some index implementations store protos, some convert them to bytes first.
+   *
+   * <p>This property is true if field is stored in proto format.
+   */
+  private final boolean isProto;
 
-  FakeStoredValue(Object field) {
+  public FakeStoredValue(Object field) {
     this.field = field;
+    this.isProto = false;
+  }
+
+  public FakeStoredValue(Object field, boolean isProto) {
+    this.field = field;
+    this.isProto = isProto;
   }
 
   @Override
@@ -69,6 +83,25 @@
   }
 
   @Override
+  @Nullable
+  public MessageLite asProto() {
+    if (isProto) {
+      return (MessageLite) field;
+    }
+    return null;
+  }
+
+  @Override
+  @Nullable
+  @SuppressWarnings("unchecked")
+  public Iterable<MessageLite> asProtos() {
+    if (isProto) {
+      return (Iterable<MessageLite>) field;
+    }
+    return null;
+  }
+
+  @Override
   @SuppressWarnings("unchecked")
   public Iterable<byte[]> asByteArrays() {
     return (Iterable<byte[]>) field;
diff --git a/java/com/google/gerrit/index/testing/TestIndexedFields.java b/java/com/google/gerrit/index/testing/TestIndexedFields.java
new file mode 100644
index 0000000..51440fb
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/TestIndexedFields.java
@@ -0,0 +1,221 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.testing;
+
+import com.google.common.reflect.TypeToken;
+import com.google.gerrit.entities.converter.ChangeProtoConverter;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.index.SchemaFieldDefs.Setter;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change;
+import com.google.gerrit.proto.Entities.Change_Id;
+import java.io.IOException;
+import java.sql.Timestamp;
+
+/**
+ * Collection of {@link IndexedField}, used in unit tests.
+ *
+ * <p>The list of {@link IndexedField} below are field types, that are currently supported and used
+ * in different index implementations
+ *
+ * <p>They are used in unit tests to make sure these types can be extracted to index and assigned
+ * back to object.
+ */
+public final class TestIndexedFields {
+
+  /** Test input object for {@link IndexedField} */
+  public static class TestIndexedData {
+
+    /** Key that is used to index to identify indexed object */
+    private Object key;
+
+    /** Field value that is extracted from this indexed object to the index document. */
+    private Object testFieldValue;
+
+    public Object getTestField() {
+      return testFieldValue;
+    }
+
+    public void setTestFieldValue(Object testFieldValue) {
+      this.testFieldValue = testFieldValue;
+    }
+
+    public Object getKey() {
+      return key;
+    }
+
+    public void setKey(Object key) {
+      this.key = key;
+    }
+  }
+
+  /** Setter for {@link TestIndexedData} */
+  private static class TestIndexedDataSetter<T> implements Setter<TestIndexedData, T> {
+    @Override
+    public void set(TestIndexedData testIndexedData, T value) {
+      testIndexedData.setTestFieldValue(value);
+    }
+  }
+
+  /** Getter for {@link TestIndexedData} */
+  @SuppressWarnings("unchecked")
+  private static class TestIndexedDataGetter<T> implements Getter<TestIndexedData, T> {
+    @Override
+    public T get(TestIndexedData input) throws IOException {
+      return (T) input.getTestField();
+    }
+  }
+
+  public static <T> TestIndexedDataSetter<T> setter() {
+    return new TestIndexedDataSetter<>();
+  }
+
+  public static <T> TestIndexedDataGetter<T> getter() {
+    return new TestIndexedDataGetter<>();
+  }
+
+  public static final IndexedField<TestIndexedData, Integer> INTEGER_FIELD =
+      IndexedField.<TestIndexedData>integerBuilder("IntegerTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Integer>.SearchSpec INTEGER_FIELD_SPEC =
+      INTEGER_FIELD.integer("integer_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>> ITERABLE_INTEGER_FIELD =
+      IndexedField.<TestIndexedData>iterableIntegerBuilder("IterableIntegerTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>.SearchSpec
+      ITERABLE_INTEGER_FIELD_SPEC = ITERABLE_INTEGER_FIELD.integer("iterable_integer_test");
+
+  public static final IndexedField<TestIndexedData, Integer> INTEGER_RANGE_FIELD =
+      IndexedField.<TestIndexedData>integerBuilder("IntegerRangeTestField")
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+  public static final IndexedField<TestIndexedData, Integer>.SearchSpec INTEGER_RANGE_FIELD_SPEC =
+      INTEGER_RANGE_FIELD.range("integer_range_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>
+      ITERABLE_INTEGER_RANGE_FIELD =
+          IndexedField.<TestIndexedData>iterableIntegerBuilder("IterableIntegerRangeTestField")
+              .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Integer>>.SearchSpec
+      ITERABLE_INTEGER_RANGE_FIELD_SPEC =
+          ITERABLE_INTEGER_RANGE_FIELD.range("iterable_integer_range_test");
+
+  public static final IndexedField<TestIndexedData, Long> LONG_FIELD =
+      IndexedField.<TestIndexedData>longBuilder("LongTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Long>.SearchSpec LONG_FIELD_SPEC =
+      LONG_FIELD.longSearch("long_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>> ITERABLE_LONG_FIELD =
+      IndexedField.<TestIndexedData, Iterable<Long>>builder(
+              "IterableLongTestField", IndexedField.ITERABLE_LONG_TYPE)
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>>.SearchSpec
+      ITERABLE_LONG_FIELD_SPEC = ITERABLE_LONG_FIELD.longSearch("iterable_long_test");
+
+  public static final IndexedField<TestIndexedData, Long> LONG_RANGE_FIELD =
+      IndexedField.<TestIndexedData>longBuilder("LongRangeTestField")
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Long>.SearchSpec LONG_RANGE_FIELD_SPEC =
+      LONG_RANGE_FIELD.range("long_range_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>> ITERABLE_LONG_RANGE_FIELD =
+      IndexedField.<TestIndexedData, Iterable<Long>>builder(
+              "IterableLongRangeTestField", IndexedField.ITERABLE_LONG_TYPE)
+          .build(TestIndexedFields.getter(), TestIndexedFields.setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<Long>>.SearchSpec
+      ITERABLE_LONG_RANGE_FIELD_SPEC = ITERABLE_LONG_RANGE_FIELD.range("iterable_long_range_test");
+
+  public static final IndexedField<TestIndexedData, Timestamp> TIMESTAMP_FIELD =
+      IndexedField.<TestIndexedData>timestampBuilder("TimestampTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Timestamp>.SearchSpec TIMESTAMP_FIELD_SPEC =
+      TIMESTAMP_FIELD.timestamp("timestamp_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<String>> ITERABLE_STRING_FIELD =
+      IndexedField.<TestIndexedData>iterableStringBuilder("IterableStringTestField")
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<String>>.SearchSpec
+      ITERABLE_STRING_FIELD_SPEC = ITERABLE_STRING_FIELD.fullText("iterable_test_string");
+
+  public static final IndexedField<TestIndexedData, String> STRING_FIELD =
+      IndexedField.<TestIndexedData>stringBuilder("StringTestField").build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, String>.SearchSpec STRING_FIELD_SPEC =
+      STRING_FIELD.fullText("string_test");
+
+  public static final IndexedField<TestIndexedData, String>.SearchSpec PREFIX_STRING_FIELD_SPEC =
+      STRING_FIELD.prefix("prefix_string_test");
+
+  public static final IndexedField<TestIndexedData, String>.SearchSpec EXACT_STRING_FIELD_SPEC =
+      STRING_FIELD.exact("exact_string_test");
+
+  public static final IndexedField<TestIndexedData, Iterable<byte[]>> ITERABLE_STORED_BYTE_FIELD =
+      IndexedField.<TestIndexedData>iterableByteArrayBuilder("IterableByteTestField")
+          .stored()
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, Iterable<byte[]>>.SearchSpec
+      ITERABLE_STORED_BYTE_SPEC = ITERABLE_STORED_BYTE_FIELD.storedOnly("iterable_byte_test");
+
+  public static final IndexedField<TestIndexedData, byte[]> STORED_BYTE_FIELD =
+      IndexedField.<TestIndexedData>byteArrayBuilder("ByteTestField")
+          .stored()
+          .build(getter(), setter());
+
+  public static final IndexedField<TestIndexedData, byte[]>.SearchSpec STORED_BYTE_SPEC =
+      STORED_BYTE_FIELD.storedOnly("byte_test");
+
+  public static final IndexedField<TestIndexedData, Entities.Change> STORED_PROTO_FIELD =
+      IndexedField.<TestIndexedData, Entities.Change>builder(
+              "TestChange",
+              new TypeToken<Entities.Change>() {
+                private static final long serialVersionUID = 1L;
+              })
+          .stored()
+          .build(getter(), setter(), ChangeProtoConverter.INSTANCE);
+
+  public static final IndexedField<TestIndexedData, Entities.Change>.SearchSpec
+      STORED_PROTO_FIELD_SPEC = STORED_PROTO_FIELD.storedOnly("test_change");
+
+  public static final IndexedField<TestIndexedData, Iterable<Entities.Change>>
+      ITERABLE_STORED_PROTO_FIELD =
+          IndexedField.<TestIndexedData, Iterable<Entities.Change>>builder(
+                  "IterableTestChange",
+                  new TypeToken<Iterable<Entities.Change>>() {
+                    private static final long serialVersionUID = 1L;
+                  })
+              .stored()
+              .build(getter(), setter(), ChangeProtoConverter.INSTANCE);
+
+  public static final IndexedField<TestIndexedData, Iterable<Entities.Change>>.SearchSpec
+      ITERABLE_PROTO_FIELD_SPEC = ITERABLE_STORED_PROTO_FIELD.storedOnly("iterable_test_change");
+
+  public static Change createChangeProto(int id) {
+    return Entities.Change.newBuilder()
+        .setChangeId(Change_Id.newBuilder().setId(id).build())
+        .build();
+  }
+
+  private TestIndexedFields() {}
+}
diff --git a/java/com/google/gerrit/json/BUILD b/java/com/google/gerrit/json/BUILD
index d9cec45..7b2fe2f 100644
--- a/java/com/google/gerrit/json/BUILD
+++ b/java/com/google/gerrit/json/BUILD
@@ -5,6 +5,10 @@
     srcs = glob(["*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
         "//lib:gson",
+        "//lib:guava",
+        "//lib/guice",
     ],
 )
diff --git a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
index 9c32aa8..b6cb5f9 100644
--- a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
+++ b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.json;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gson.Gson;
 import com.google.gson.JsonSyntaxException;
 import com.google.gson.TypeAdapter;
@@ -34,6 +35,7 @@
 public class EnumTypeAdapterFactory implements TypeAdapterFactory {
 
   @SuppressWarnings({"rawtypes", "unchecked"})
+  @Nullable
   @Override
   public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
     TypeAdapter<T> defaultEnumAdapter = TypeAdapters.ENUM_FACTORY.create(gson, typeToken);
diff --git a/java/com/google/gerrit/json/JavaSqlTimestampHelper.java b/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
index b59cbd0d..35429f1 100644
--- a/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
+++ b/java/com/google/gerrit/json/JavaSqlTimestampHelper.java
@@ -14,11 +14,19 @@
 
 package com.google.gerrit.json;
 
+import com.google.common.base.Splitter;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.util.Calendar;
+import java.util.List;
+import java.util.TimeZone;
 
 /** Utility to parse Timestamp from a string. */
 public class JavaSqlTimestampHelper {
+
+  private static final Splitter TIMESTAMP_SPLITTER = Splitter.on(" ");
+  private static final Splitter DATE_SPLITTER = Splitter.on("-");
+  private static final Splitter TIME_SPLITTER = Splitter.on(":");
+
   /**
    * Parse a string into a timestamp.
    *
@@ -31,22 +39,22 @@
    * @return resulting timestamp.
    */
   public static Timestamp parseTimestamp(String s) {
-    String[] components = s.split(" ");
-    if (components.length < 1 || components.length > 3) {
+    List<String> components = TIMESTAMP_SPLITTER.splitToList(s);
+    if (components.size() < 1 || components.size() > 3) {
       throw new IllegalArgumentException("Expected date and optional time: " + s);
     }
-    String date = components[0];
-    String time = components.length >= 2 ? components[1] : null;
-    int off = components.length == 3 ? parseTimeZone(components[2]) : 0;
-    String[] dSplit = date.split("-");
-    if (dSplit.length != 3) {
+    String date = components.get(0);
+    String time = components.size() >= 2 ? components.get(1) : null;
+    int off = components.size() == 3 ? parseTimeZone(components.get(2)) : 0;
+    List<String> dSplit = DATE_SPLITTER.splitToList(date);
+    if (dSplit.size() != 3) {
       throw new IllegalArgumentException("Invalid date format: " + date);
     }
     int yy, mm, dd;
     try {
-      yy = Integer.parseInt(dSplit[0]) - 1900;
-      mm = Integer.parseInt(dSplit[1]) - 1;
-      dd = Integer.parseInt(dSplit[2]);
+      yy = Integer.parseInt(dSplit.get(0));
+      mm = Integer.parseInt(dSplit.get(1)) - 1;
+      dd = Integer.parseInt(dSplit.get(2));
     } catch (NumberFormatException e) {
       throw new IllegalArgumentException("Invalid date format: " + date, e);
     }
@@ -64,13 +72,13 @@
           t = time;
           f = 0;
         }
-        String[] tSplit = t.split(":");
-        if (tSplit.length != 3) {
+        List<String> tSplit = TIME_SPLITTER.splitToList(t);
+        if (tSplit.size() != 3) {
           throw new IllegalArgumentException("Invalid time format: " + time);
         }
-        hh = Integer.parseInt(tSplit[0]);
-        mi = Integer.parseInt(tSplit[1]);
-        ss = Integer.parseInt(tSplit[2]);
+        hh = Integer.parseInt(tSplit.get(0));
+        mi = Integer.parseInt(tSplit.get(1));
+        ss = Integer.parseInt(tSplit.get(2));
         ns = (int) Math.round(f * 1e9);
       } catch (NumberFormatException e) {
         throw new IllegalArgumentException("Invalid time format: " + time, e);
@@ -81,8 +89,9 @@
       ss = 0;
       ns = 0;
     }
-    @SuppressWarnings("deprecation")
-    Timestamp result = new Timestamp(Date.UTC(yy, mm, dd, hh, mi, ss) - off);
+    Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+    calendar.set(yy, mm, dd, hh, mi, ss);
+    Timestamp result = new Timestamp(calendar.toInstant().toEpochMilli() - off);
     result.setNanos(ns);
     return result;
   }
diff --git a/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
new file mode 100644
index 0000000..2557515
--- /dev/null
+++ b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
@@ -0,0 +1,151 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.json;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * A {@code TypeAdapterFactory} for Optional {@code SubmitRequirementExpressionResult}.
+ *
+ * <p>{@link SubmitRequirementResult#submittabilityExpressionResult} was previously serialized as a
+ * mandatory field, but was later on migrated to an optional field. The server needs to handle
+ * deserializing of both formats.
+ */
+public class OptionalSubmitRequirementExpressionResultAdapterFactory implements TypeAdapterFactory {
+
+  private static final TypeToken<?> OPTIONAL_SR_EXPRESSION_RESULT_TOKEN =
+      TypeToken.get(new TypeLiteral<Optional<SubmitRequirementExpressionResult>>() {}.getType());
+
+  private static final TypeToken<?> SR_EXPRESSION_RESULT_TOKEN =
+      TypeToken.get(SubmitRequirementExpressionResult.class);
+
+  @SuppressWarnings({"unchecked"})
+  @Nullable
+  @Override
+  public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
+    if (typeToken.equals(OPTIONAL_SR_EXPRESSION_RESULT_TOKEN)) {
+      return (TypeAdapter<T>)
+          new OptionalSubmitRequirementExpressionResultTypeAdapter(
+              SubmitRequirementExpressionResult.typeAdapter(gson));
+    } else if (typeToken.equals(SR_EXPRESSION_RESULT_TOKEN)) {
+      return (TypeAdapter<T>)
+          new SubmitRequirementExpressionResultTypeAdapter(
+              SubmitRequirementExpressionResult.typeAdapter(gson));
+    }
+    return null;
+  }
+
+  /**
+   * Reads json representation of either {@code Optional<SubmitRequirementExpressionResult>} or
+   * {@code SubmitRequirementExpressionResult}, converting it to {@code Nullable} {@code
+   * SubmitRequirementExpressionResult}.
+   */
+  @Nullable
+  private static SubmitRequirementExpressionResult readOptionalOrMandatory(
+      TypeAdapter<SubmitRequirementExpressionResult> submitRequirementExpressionResultAdapter,
+      JsonReader in) {
+    JsonElement parsed = JsonParser.parseReader(in);
+    if (parsed == null) {
+      return null;
+    }
+    // If it does not have 'value' field, then it was serialized as
+    // SubmitRequirementExpressionResult directly
+    if (parsed.getAsJsonObject().has("value")) {
+      parsed = parsed.getAsJsonObject().get("value");
+    }
+    if (parsed == null || parsed.isJsonNull() || parsed.getAsJsonObject().entrySet().isEmpty()) {
+      return null;
+    }
+    return submitRequirementExpressionResultAdapter.fromJsonTree(parsed);
+  }
+
+  /**
+   * A {@code TypeAdapter} that provides backward compatibility for reading previously non-optional
+   * {@code SubmitRequirementExpressionResult} field.
+   */
+  private static class OptionalSubmitRequirementExpressionResultTypeAdapter
+      extends TypeAdapter<Optional<SubmitRequirementExpressionResult>> {
+
+    private final TypeAdapter<SubmitRequirementExpressionResult>
+        submitRequirementExpressionResultAdapter;
+
+    public OptionalSubmitRequirementExpressionResultTypeAdapter(
+        TypeAdapter<SubmitRequirementExpressionResult> submitRequirementResultAdapter) {
+      this.submitRequirementExpressionResultAdapter = submitRequirementResultAdapter;
+    }
+
+    @Override
+    public Optional<SubmitRequirementExpressionResult> read(JsonReader in) throws IOException {
+      return Optional.ofNullable(
+          readOptionalOrMandatory(submitRequirementExpressionResultAdapter, in));
+    }
+
+    @Override
+    public void write(JsonWriter out, Optional<SubmitRequirementExpressionResult> value)
+        throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.beginObject();
+      out.name("value");
+      if (value.isPresent()) {
+        out.jsonValue(submitRequirementExpressionResultAdapter.toJson(value.get()));
+      } else {
+        out.nullValue();
+      }
+      out.endObject();
+    }
+  }
+
+  /**
+   * A {@code TypeAdapter} that provides forward compatibility for reading the optional {@code
+   * SubmitRequirementExpressionResult} field.
+   *
+   * <p>TODO(mariasavtchouk): Remove once updated to read the new format only.
+   */
+  private static class SubmitRequirementExpressionResultTypeAdapter
+      extends TypeAdapter<SubmitRequirementExpressionResult> {
+
+    private final TypeAdapter<SubmitRequirementExpressionResult>
+        submitRequirementExpressionResultAdapter;
+
+    public SubmitRequirementExpressionResultTypeAdapter(
+        TypeAdapter<SubmitRequirementExpressionResult> submitRequirementResultAdapter) {
+      this.submitRequirementExpressionResultAdapter = submitRequirementResultAdapter;
+    }
+
+    @Override
+    public SubmitRequirementExpressionResult read(JsonReader in) throws IOException {
+      return readOptionalOrMandatory(submitRequirementExpressionResultAdapter, in);
+    }
+
+    @Override
+    public void write(JsonWriter out, SubmitRequirementExpressionResult value) throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.jsonValue(submitRequirementExpressionResultAdapter.toJson(value));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/json/OptionalTypeAdapter.java b/java/com/google/gerrit/json/OptionalTypeAdapter.java
new file mode 100644
index 0000000..9bfa72d
--- /dev/null
+++ b/java/com/google/gerrit/json/OptionalTypeAdapter.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.json;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.google.inject.TypeLiteral;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Optional;
+
+public class OptionalTypeAdapter
+    implements JsonSerializer<Optional<?>>, JsonDeserializer<Optional<?>> {
+
+  private static final String VALUE = "value";
+
+  @Override
+  public JsonElement serialize(Optional<?> src, Type typeOfSrc, JsonSerializationContext context) {
+    Optional<?> optional = src == null ? Optional.empty() : src;
+    JsonObject json = new JsonObject();
+    json.add(VALUE, optional.map(context::serialize).orElse(JsonNull.INSTANCE));
+    return json;
+  }
+
+  @Override
+  public Optional<?> deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+      throws JsonParseException {
+    if (!json.getAsJsonObject().has(VALUE)) {
+      return Optional.empty();
+    }
+
+    JsonElement value = json.getAsJsonObject().get(VALUE);
+    if (value == null || value.isJsonNull()) {
+      return Optional.empty();
+    }
+
+    // handle the situation when one uses Optional without type parameter which is an equivalent of
+    // <?> type
+    ParameterizedType parameterizedType =
+        (ParameterizedType) new TypeLiteral<Optional<?>>() {}.getType();
+    if (typeOfT instanceof ParameterizedType) {
+      parameterizedType = (ParameterizedType) typeOfT;
+      if (parameterizedType.getActualTypeArguments().length != 1) {
+        throw new JsonParseException("Expected one parameter type in Optional.");
+      }
+    }
+
+    Type optionalOf = parameterizedType.getActualTypeArguments()[0];
+    return Optional.of(context.deserialize(value, optionalOf));
+  }
+}
diff --git a/java/com/google/gerrit/json/SqlTimestampDeserializer.java b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
index e1cf382..9aeda2b 100644
--- a/java/com/google/gerrit/json/SqlTimestampDeserializer.java
+++ b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.json;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gson.JsonDeserializationContext;
 import com.google.gson.JsonDeserializer;
 import com.google.gson.JsonElement;
@@ -30,6 +31,7 @@
 class SqlTimestampDeserializer implements JsonDeserializer<Timestamp>, JsonSerializer<Timestamp> {
   private static final TimeZone UTC = TimeZone.getTimeZone("UTC");
 
+  @Nullable
   @Override
   public Timestamp deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
       throws JsonParseException {
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index f6c395e..6be78d9 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -41,12 +41,12 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.NavigableMap;
 import java.util.Properties;
-import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.jar.Attributes;
 import java.util.jar.JarFile;
@@ -266,11 +266,11 @@
       throw e;
     }
 
-    final SortedMap<String, URL> jars = new TreeMap<>();
+    final NavigableMap<String, URL> jars = new TreeMap<>();
     try (ZipFile zf = new ZipFile(path)) {
-      final Enumeration<? extends ZipEntry> e = zf.entries();
-      while (e.hasMoreElements()) {
-        final ZipEntry ze = e.nextElement();
+      Iterator<? extends ZipEntry> zipEntryIt = zf.stream().iterator();
+      while (zipEntryIt.hasNext()) {
+        final ZipEntry ze = zipEntryIt.next();
         if (ze.isDirectory()) {
           continue;
         }
@@ -301,7 +301,7 @@
     move(jars, "javax.inject-1.jar", extapi);
     move(jars, "aopalliance-1.0.jar", extapi);
     move(jars, "guice-servlet-", extapi);
-    move(jars, "tomcat-servlet-api-", extapi);
+    move(jars, "servlet-api-", extapi);
 
     ClassLoader parent = ClassLoader.getSystemClassLoader();
     if (!extapi.isEmpty()) {
@@ -310,7 +310,7 @@
     return URLClassLoader.newInstance(jars.values().toArray(new URL[jars.size()]), parent);
   }
 
-  private static void extractJar(ZipFile zf, ZipEntry ze, SortedMap<String, URL> jars)
+  private static void extractJar(ZipFile zf, ZipEntry ze, NavigableMap<String, URL> jars)
       throws IOException {
     File tmp = createTempFile(safeName(ze), ".jar");
     try (OutputStream out = Files.newOutputStream(tmp.toPath());
@@ -326,8 +326,8 @@
     jars.put(name.substring(name.lastIndexOf('/')), tmp.toURI().toURL());
   }
 
-  private static void move(SortedMap<String, URL> jars, String prefix, List<URL> extapi) {
-    SortedMap<String, URL> matches = jars.tailMap(prefix);
+  private static void move(NavigableMap<String, URL> jars, String prefix, List<URL> extapi) {
+    NavigableMap<String, URL> matches = jars.tailMap(prefix, /* inclusive= */ true);
     if (!matches.isEmpty()) {
       String first = matches.firstKey();
       if (first.startsWith(prefix)) {
@@ -533,6 +533,7 @@
     return myHome;
   }
 
+  @SuppressWarnings("ReturnMissingNullable")
   private static File tmproot() {
     File tmp;
     String gerritTemp = System.getenv("GERRIT_TMP");
@@ -572,6 +573,7 @@
     }
   }
 
+  @SuppressWarnings("ReturnMissingNullable")
   private static File locateHomeDirectory() {
     // Try to find the user's home directory. If we can't find it
     // return null so the JVM's default temporary directory is used
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 5392ab4..f9dc31a 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -33,23 +33,26 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.ListResultSet;
 import com.google.gerrit.index.query.ResultSet;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
+import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.sql.Timestamp;
-import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -65,8 +68,6 @@
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.Field.Store;
 import org.apache.lucene.document.IntPoint;
-import org.apache.lucene.document.LegacyIntField;
-import org.apache.lucene.document.LegacyLongField;
 import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.document.StoredField;
 import org.apache.lucene.document.StringField;
@@ -87,11 +88,10 @@
 import org.apache.lucene.store.Directory;
 
 /** Basic Lucene index implementation. */
-@SuppressWarnings("deprecation")
 public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  static String sortFieldName(FieldDef<?, ?> f) {
+  static String sortFieldName(SchemaField<?, ?> f) {
     return f.getName() + "_SORT";
   }
 
@@ -105,8 +105,11 @@
   private final ReferenceManager<IndexSearcher> searcherManager;
   private final ControlledRealTimeReopenThread<IndexSearcher> reopenThread;
   private final Set<NrtFuture> notDoneNrtFutures;
+  private final AutoFlush autoFlush;
   private ScheduledExecutorService autoCommitExecutor;
+  private final Function<V, K> valueToKeyFunction;
 
+  @SuppressWarnings("ThreadPriorityCheck")
   AbstractLuceneIndex(
       Schema<V> schema,
       SitePaths sitePaths,
@@ -115,13 +118,17 @@
       ImmutableSet<String> skipFields,
       String subIndex,
       GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory)
+      SearcherFactory searcherFactory,
+      AutoFlush autoFlush,
+      Function<V, K> valueToKeyFunction)
       throws IOException {
     this.schema = schema;
     this.sitePaths = sitePaths;
     this.dir = dir;
     this.name = name;
     this.skipFields = skipFields;
+    this.autoFlush = autoFlush;
+    this.valueToKeyFunction = valueToKeyFunction;
     String index = Joiner.on('_').skipNulls().join(name, subIndex);
     long commitPeriod = writerConfig.getCommitWithinMs();
 
@@ -215,7 +222,9 @@
           }
         });
 
-    reopenThread.start();
+    if (autoFlush.equals(AutoFlush.ENABLED)) {
+      reopenThread.start();
+    }
   }
 
   @Override
@@ -293,6 +302,11 @@
   }
 
   @Override
+  public void deleteByValue(V value) {
+    delete(valueToKeyFunction.apply(value));
+  }
+
+  @Override
   public void deleteAll() {
     try {
       writer.deleteAll();
@@ -341,13 +355,9 @@
     if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
       for (Object value : values.getValues()) {
         Integer intValue = (Integer) value;
-        if (schema.useLegacyNumericFields()) {
-          doc.add(new LegacyIntField(name, intValue, store));
-        } else {
-          doc.add(new IntPoint(name, intValue));
-          if (store == Store.YES) {
-            doc.add(new StoredField(name, intValue));
-          }
+        doc.add(new IntPoint(name, intValue));
+        if (store == Store.YES) {
+          doc.add(new StoredField(name, intValue));
         }
       }
     } else if (type == FieldType.LONG) {
@@ -367,8 +377,12 @@
         doc.add(new TextField(name, (String) value, store));
       }
     } else if (type == FieldType.STORED_ONLY) {
+      boolean isProtoField = SchemaFieldDefs.isProtoField(values.getField());
       for (Object value : values.getValues()) {
-        doc.add(new StoredField(name, (byte[]) value));
+        // Lucene stores protos as bytes
+        doc.add(
+            new StoredField(
+                name, isProtoField ? Protos.toByteArray((MessageLite) value) : (byte[]) value));
       }
     } else {
       throw FieldType.badFieldType(type);
@@ -376,22 +390,17 @@
   }
 
   private void addLongField(Document doc, String name, Store store, Long longValue) {
-    if (schema.useLegacyNumericFields()) {
-      doc.add(new LegacyLongField(name, longValue, store));
-    } else {
-      doc.add(new LongPoint(name, longValue));
-      if (store == Store.YES) {
-        doc.add(new StoredField(name, longValue));
-      }
+    doc.add(new LongPoint(name, longValue));
+    if (store == Store.YES) {
+      doc.add(new StoredField(name, longValue));
     }
   }
 
   protected FieldBundle toFieldBundle(Document doc) {
-    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
     ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
     for (IndexableField field : doc.getFields()) {
-      checkArgument(allFields.containsKey(field.name()), "Unrecognized field " + field.name());
-      FieldType<?> type = allFields.get(field.name()).getType();
+      checkArgument(getSchema().hasField(field.name()), "Unrecognized field " + field.name());
+      FieldType<?> type = getSchema().getSchemaField(field.name()).getType();
       if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
         rawFields.put(field.name(), field.stringValue());
       } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
@@ -406,10 +415,10 @@
         throw FieldType.badFieldType(type);
       }
     }
-    return new FieldBundle(rawFields);
+    return new FieldBundle(rawFields, /* storesIndexedFields= */ false);
   }
 
-  private static Field.Store store(FieldDef<?, ?> f) {
+  private static Field.Store store(SchemaField<?, ?> f) {
     return f.isStored() ? Field.Store.YES : Field.Store.NO;
   }
 
@@ -484,6 +493,9 @@
     }
 
     private boolean isGenAvailableNowForCurrentSearcher() {
+      if (autoFlush.equals(AutoFlush.DISABLED)) {
+        return true;
+      }
       try {
         return reopenThread.waitForGeneration(gen, 0);
       } catch (InterruptedException e) {
@@ -526,20 +538,31 @@
 
     private <T> ResultSet<T> readImpl(Function<Document, T> mapper) {
       IndexSearcher searcher = null;
+      ScoreDoc scoreDoc = null;
       try {
         searcher = acquire();
-        int realLimit = opts.start() + opts.limit();
-        TopFieldDocs docs = searcher.search(query, realLimit, sort);
+        int realLimit = opts.start() + opts.pageSize();
+        TopFieldDocs docs =
+            opts.searchAfter() != null
+                ? searcher.searchAfter(
+                    (ScoreDoc) opts.searchAfter(), query, realLimit, sort, false, false)
+                : searcher.search(query, realLimit, sort);
         ImmutableList.Builder<T> b = ImmutableList.builderWithExpectedSize(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
-          ScoreDoc sd = docs.scoreDocs[i];
-          Document doc = searcher.doc(sd.doc, opts.fields());
+          scoreDoc = docs.scoreDocs[i];
+          Document doc = searcher.doc(scoreDoc.doc, opts.fields());
           T mapperResult = mapper.apply(doc);
           if (mapperResult != null) {
             b.add(mapperResult);
           }
         }
-        return new ListResultSet<>(b.build());
+        ScoreDoc searchAfter = scoreDoc;
+        return new ListResultSet<>(b.build()) {
+          @Override
+          public Object searchAfter() {
+            return searchAfter;
+          }
+        };
       } catch (IOException e) {
         throw new StorageException(e);
       } finally {
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 43daf25..024b102 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -15,18 +15,17 @@
 package com.google.gerrit.lucene;
 
 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.ID_STR_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;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
@@ -34,6 +33,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
 import java.nio.file.Path;
@@ -52,7 +52,8 @@
       Path path,
       ImmutableSet<String> skipFields,
       GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory)
+      SearcherFactory searcherFactory,
+      AutoFlush autoFlush)
       throws IOException {
     this(
         schema,
@@ -61,7 +62,8 @@
         path.getFileName().toString(),
         skipFields,
         writerConfig,
-        searcherFactory);
+        searcherFactory,
+        autoFlush);
   }
 
   ChangeSubIndex(
@@ -71,9 +73,25 @@
       String subIndex,
       ImmutableSet<String> skipFields,
       GerritIndexWriterConfig writerConfig,
-      SearcherFactory searcherFactory)
+      SearcherFactory searcherFactory,
+      AutoFlush autoFlush)
       throws IOException {
-    super(schema, sitePaths, dir, NAME, skipFields, subIndex, writerConfig, searcherFactory);
+    super(
+        schema,
+        sitePaths,
+        dir,
+        NAME,
+        skipFields,
+        subIndex,
+        writerConfig,
+        searcherFactory,
+        autoFlush,
+        ChangeIndex.ENTITY_TO_KEY);
+  }
+
+  @Override
+  public void insert(ChangeData obj) {
+    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
   }
 
   @Override
@@ -101,17 +119,14 @@
   @Override
   void add(Document doc, Values<ChangeData> values) {
     // Add separate DocValues fields for those fields needed for sorting.
-    FieldDef<ChangeData, ?> f = values.getField();
-    if (f == ChangeField.LEGACY_ID) {
-      int v = (Integer) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
-    } else if (f == ChangeField.LEGACY_ID_STR) {
+    SchemaField<ChangeData, ?> f = values.getField();
+    if (f == ChangeField.NUMERIC_ID_STR_SPEC) {
       String v = (String) getOnlyElement(values.getValues());
-      doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
-    } else if (f == ChangeField.UPDATED) {
+      doc.add(new NumericDocValuesField(ID_STR_SORT_FIELD, Integer.valueOf(v)));
+    } else if (f == ChangeField.UPDATED_SPEC) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
-    } else if (f == ChangeField.MERGED_ON) {
+    } else if (f == ChangeField.MERGED_ON_SPEC) {
       long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
       doc.add(new NumericDocValuesField(MERGED_ON_SORT_FIELD, t));
     }
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 242cffd..9c0baa8 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -15,18 +15,18 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.server.index.account.AccountField.FULL_NAME;
-import static com.google.gerrit.server.index.account.AccountField.ID;
-import static com.google.gerrit.server.index.account.AccountField.ID_STR;
-import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT;
+import static com.google.gerrit.server.index.account.AccountField.FULL_NAME_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.ID_FIELD_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.ID_STR_FIELD_SPEC;
+import static com.google.gerrit.server.index.account.AccountField.PREFERRED_EMAIL_EXACT_SPEC;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -49,9 +50,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
-import org.apache.lucene.store.RAMDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -59,19 +60,20 @@
     implements AccountIndex {
   private static final String ACCOUNTS = "accounts";
 
-  private static final String FULL_NAME_SORT_FIELD = sortFieldName(FULL_NAME);
-  private static final String EMAIL_SORT_FIELD = sortFieldName(PREFERRED_EMAIL_EXACT);
-  private static final String ID_SORT_FIELD = sortFieldName(ID);
-  private static final String ID2_SORT_FIELD = sortFieldName(ID_STR);
+  private static final String FULL_NAME_SORT_FIELD = sortFieldName(FULL_NAME_SPEC);
+  private static final String EMAIL_SORT_FIELD = sortFieldName(PREFERRED_EMAIL_EXACT_SPEC);
+  private static final String ID_SORT_FIELD = sortFieldName(ID_FIELD_SPEC);
+  private static final String ID2_SORT_FIELD = sortFieldName(ID_STR_FIELD_SPEC);
 
   private static Term idTerm(boolean useLegacyNumericFields, AccountState as) {
     return idTerm(useLegacyNumericFields, as.account().id());
   }
 
   private static Term idTerm(boolean useLegacyNumericFields, Account.Id id) {
-    FieldDef<AccountState, ?> idField = useLegacyNumericFields ? ID : ID_STR;
+    SchemaField<AccountState, ?> idField =
+        useLegacyNumericFields ? ID_FIELD_SPEC : ID_STR_FIELD_SPEC;
     if (useLegacyNumericFields) {
-      return QueryBuilder.intTerm(idField.getName(), id.get());
+      return QueryBuilder.intTerm(idField.getName());
     }
     return QueryBuilder.stringTerm(idField.getName(), Integer.toString(id.get()));
   }
@@ -83,7 +85,7 @@
   private static Directory dir(Schema<AccountState> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS, schema);
     return FSDirectory.open(indexDir);
@@ -94,7 +96,8 @@
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       Provider<AccountCache> accountCache,
-      @Assisted Schema<AccountState> schema)
+      @Assisted Schema<AccountState> schema,
+      AutoFlush autoFlush)
       throws IOException {
     super(
         schema,
@@ -104,7 +107,9 @@
         ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, ACCOUNTS),
-        new SearcherFactory());
+        new SearcherFactory(),
+        autoFlush,
+        AccountIndex.ENTITY_TO_KEY);
     this.accountCache = accountCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, ACCOUNTS);
@@ -114,17 +119,17 @@
   @Override
   void add(Document doc, Values<AccountState> values) {
     // Add separate DocValues fields for those fields needed for sorting.
-    FieldDef<AccountState, ?> f = values.getField();
-    if (f == ID) {
+    SchemaField<AccountState, ?> f = values.getField();
+    if (f == ID_FIELD_SPEC) {
       int v = (Integer) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID_SORT_FIELD, v));
-    } else if (f == ID_STR) {
+    } else if (f == ID_STR_FIELD_SPEC) {
       String v = (String) getOnlyElement(values.getValues());
       doc.add(new NumericDocValuesField(ID2_SORT_FIELD, Integer.valueOf(v)));
-    } else if (f == FULL_NAME) {
+    } else if (f == FULL_NAME_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(FULL_NAME_SORT_FIELD, new BytesRef(value)));
-    } else if (f == PREFERRED_EMAIL_EXACT) {
+    } else if (f == PREFERRED_EMAIL_EXACT_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(EMAIL_SORT_FIELD, new BytesRef(value)));
     }
@@ -134,7 +139,16 @@
   @Override
   public void replace(AccountState as) {
     try {
-      replace(idTerm(getSchema().useLegacyNumericFields(), as), toDocument(as)).get();
+      replace(idTerm(getSchema().hasField(ID_FIELD_SPEC), as), toDocument(as)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
+  public void insert(AccountState as) {
+    try {
+      insert(toDocument(as)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -143,7 +157,7 @@
   @Override
   public void delete(Account.Id key) {
     try {
-      delete(idTerm(getSchema().useLegacyNumericFields(), key)).get();
+      delete(idTerm(getSchema().hasField(ID_FIELD_SPEC), key)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -152,15 +166,14 @@
   @Override
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
-    queryBuilder.getSchema().useLegacyNumericFields();
     return new LuceneQuerySource(
-        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().useLegacyNumericFields())),
+        opts.filterFields(o -> IndexUtils.accountFields(o, getSchema().hasField(ID_FIELD_SPEC))),
         queryBuilder.toQuery(p),
         getSort());
   }
 
   private Sort getSort() {
-    String idSortField = getSchema().useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
+    String idSortField = getSchema().hasField(ID_FIELD_SPEC) ? ID_SORT_FIELD : ID2_SORT_FIELD;
     return new Sort(
         new SortField(FULL_NAME_SORT_FIELD, SortField.Type.STRING, false),
         new SortField(EMAIL_SORT_FIELD, SortField.Type.STRING, false),
@@ -169,10 +182,11 @@
 
   @Override
   protected AccountState fromDocument(Document doc) {
-    FieldDef<AccountState, ?> idField = getSchema().useLegacyNumericFields() ? ID : ID_STR;
+    SchemaField<AccountState, ?> idField =
+        getSchema().hasField(ID_FIELD_SPEC) ? ID_STR_FIELD_SPEC : ID_STR_FIELD_SPEC;
     Account.Id id =
         Account.id(
-            getSchema().useLegacyNumericFields()
+            getSchema().hasField(ID_FIELD_SPEC)
                 ? doc.getField(idField.getName()).numericValue().intValue()
                 : Integer.valueOf(doc.getField(idField.getName()).stringValue()));
     // Use the AccountCache rather than depending on any stored fields in the document (of which
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index ac616ca..6365260 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -17,9 +17,8 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 import static java.util.Objects.requireNonNull;
@@ -34,15 +33,18 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.PaginationType;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.HasCardinality;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.ResultSet;
@@ -55,6 +57,7 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.inject.Inject;
@@ -63,8 +66,11 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -83,7 +89,7 @@
 import org.apache.lucene.search.SortField;
 import org.apache.lucene.search.TopDocs;
 import org.apache.lucene.search.TopFieldDocs;
-import org.apache.lucene.store.RAMDirectory;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -97,32 +103,21 @@
 public class LuceneChangeIndex implements ChangeIndex {
   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);
+  static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED_SPEC);
+  static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON_SPEC);
+  static final String ID_STR_SORT_FIELD = sortFieldName(ChangeField.NUMERIC_ID_STR_SPEC);
 
   private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
-  private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
+  private static final String CHANGE_FIELD = ChangeField.CHANGE_SPEC.getName();
 
-  @FunctionalInterface
-  interface IdTerm {
-    Term get(String name, int id);
+  static Term idTerm(ChangeData cd) {
+    return idTerm(cd.getId());
   }
 
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, ChangeData cd) {
-    return idTerm(idTerm, idField, cd.getId());
-  }
-
-  static Term idTerm(IdTerm idTerm, FieldDef<ChangeData, ?> idField, Change.Id id) {
-    return idTerm.get(idField.getName(), id.get());
-  }
-
-  @FunctionalInterface
-  interface ChangeIdExtractor {
-    Change.Id extract(IndexableField f);
+  static Term idTerm(Change.Id id) {
+    return QueryBuilder.stringTerm(NUMERIC_ID_STR_SPEC.getName(), Integer.toString(id.get()));
   }
 
   private final ListeningExecutorService executor;
@@ -131,12 +126,6 @@
   private final QueryBuilder<ChangeData> queryBuilder;
   private final ChangeSubIndex openIndex;
   private final ChangeSubIndex closedIndex;
-
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final FieldDef<ChangeData, ?> idField;
-  private final String idSortFieldName;
-  private final IdTerm idTerm;
-  private final ChangeIdExtractor extractor;
   private final ImmutableSet<String> skipFields;
 
   @Inject
@@ -145,7 +134,8 @@
       SitePaths sitePaths,
       @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
       ChangeData.Factory changeDataFactory,
-      @Assisted Schema<ChangeData> schema)
+      @Assisted Schema<ChangeData> schema,
+      AutoFlush autoFlush)
       throws IOException {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
@@ -153,7 +143,7 @@
     this.skipFields =
         MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex()
             ? ImmutableSet.of()
-            : ImmutableSet.of(ChangeField.MERGEABLE.getName());
+            : ImmutableSet.of(ChangeField.MERGEABLE_SPEC.getName());
 
     GerritIndexWriterConfig openConfig = new GerritIndexWriterConfig(cfg, "changes_open");
     GerritIndexWriterConfig closedConfig = new GerritIndexWriterConfig(cfg, "changes_closed");
@@ -166,20 +156,22 @@
           new ChangeSubIndex(
               schema,
               sitePaths,
-              new RAMDirectory(),
+              new ByteBuffersDirectory(),
               "ramOpen",
               skipFields,
               openConfig,
-              searcherFactory);
+              searcherFactory,
+              autoFlush);
       closedIndex =
           new ChangeSubIndex(
               schema,
               sitePaths,
-              new RAMDirectory(),
+              new ByteBuffersDirectory(),
               "ramClosed",
               skipFields,
               closedConfig,
-              searcherFactory);
+              searcherFactory,
+              autoFlush);
     } else {
       Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES, schema);
       openIndex =
@@ -189,7 +181,8 @@
               dir.resolve(CHANGES_OPEN),
               skipFields,
               openConfig,
-              searcherFactory);
+              searcherFactory,
+              autoFlush);
       closedIndex =
           new ChangeSubIndex(
               schema,
@@ -197,22 +190,9 @@
               dir.resolve(CHANGES_CLOSED),
               skipFields,
               closedConfig,
-              searcherFactory);
+              searcherFactory,
+              autoFlush);
     }
-
-    idField = this.schema.useLegacyNumericFields() ? LEGACY_ID : LEGACY_ID_STR;
-    idSortFieldName = schema.useLegacyNumericFields() ? ID_SORT_FIELD : ID2_SORT_FIELD;
-    idTerm =
-        (name, id) ->
-            this.schema.useLegacyNumericFields()
-                ? QueryBuilder.intTerm(name, id)
-                : QueryBuilder.stringTerm(name, Integer.toString(id));
-    extractor =
-        (f) ->
-            Change.id(
-                this.schema.useLegacyNumericFields()
-                    ? f.numericValue().intValue()
-                    : Integer.valueOf(f.stringValue()));
   }
 
   @Override
@@ -231,7 +211,7 @@
 
   @Override
   public void replace(ChangeData cd) {
-    Term id = LuceneChangeIndex.idTerm(idTerm, idField, cd);
+    Term id = LuceneChangeIndex.idTerm(cd);
     // toDocument is essentially static and doesn't depend on the specific
     // sub-index, so just pick one.
     Document doc = openIndex.toDocument(cd);
@@ -247,10 +227,31 @@
   }
 
   @Override
-  public void delete(Change.Id changeId) {
-    Term id = LuceneChangeIndex.idTerm(idTerm, idField, changeId);
+  public void insert(ChangeData cd) {
+    // toDocument is essentially static and doesn't depend on the specific
+    // sub-index, so just pick one.
+    Document doc = openIndex.toDocument(cd);
     try {
-      Futures.allAsList(openIndex.delete(id), closedIndex.delete(id)).get();
+      if (cd.change().isNew()) {
+        openIndex.insert(doc).get();
+      } else {
+        closedIndex.insert(doc).get();
+      }
+    } catch (ExecutionException | InterruptedException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
+  public void deleteByValue(ChangeData value) {
+    delete(ChangeIndex.ENTITY_TO_KEY.apply(value));
+  }
+
+  @Override
+  public void delete(Change.Id changeId) {
+    Term idTerm = LuceneChangeIndex.idTerm(changeId);
+    try {
+      Futures.allAsList(openIndex.delete(idTerm), closedIndex.delete(idTerm)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new StorageException(e);
     }
@@ -287,7 +288,7 @@
     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));
+        new SortField(ID_STR_SORT_FIELD, SortField.Type.LONG, true));
   }
 
   private class QuerySource implements ChangeDataSource {
@@ -297,6 +298,7 @@
     private final QueryOptions opts;
     private final Sort sort;
     private final Function<Document, FieldBundle> rawDocumentMapper;
+    private final boolean isSearchAfterPagination;
 
     private QuerySource(
         List<ChangeSubIndex> indexes,
@@ -311,11 +313,16 @@
       this.opts = opts;
       this.sort = sort;
       this.rawDocumentMapper = rawDocumentMapper;
+      this.isSearchAfterPagination =
+          opts.config().paginationType().equals(PaginationType.SEARCH_AFTER);
     }
 
     @Override
     public int getCardinality() {
-      return 10; // TODO(dborowitz): estimate from Lucene?
+      if (predicate instanceof HasCardinality) {
+        return ((HasCardinality) predicate).getCardinality();
+      }
+      return 10;
     }
 
     @Override
@@ -335,12 +342,12 @@
         throw new StorageException("interrupted");
       }
 
-      final Set<String> fields = IndexUtils.changeFields(opts, schema.useLegacyNumericFields());
+      final Set<String> fields = IndexUtils.changeFields(opts);
       return new ChangeDataResults(
           executor.submit(
-              new Callable<List<Document>>() {
+              new Callable<Results>() {
                 @Override
-                public List<Document> call() throws IOException {
+                public Results call() throws IOException {
                   return doRead(fields);
                 }
 
@@ -355,14 +362,18 @@
     @Override
     public ResultSet<FieldBundle> readRaw() {
       List<Document> documents;
+      Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex;
+
       try {
-        documents = doRead(IndexUtils.changeFields(opts, schema.useLegacyNumericFields()));
+        Results r = doRead(IndexUtils.changeFields(opts));
+        documents = r.docs;
+        searchAfterBySubIndex = r.searchAfterBySubIndex;
       } catch (IOException e) {
         throw new StorageException(e);
       }
       ImmutableList<FieldBundle> fieldBundles =
           documents.stream().map(rawDocumentMapper).collect(toImmutableList());
-      return new ResultSet<FieldBundle>() {
+      return new ResultSet<>() {
         @Override
         public Iterator<FieldBundle> iterator() {
           return fieldBundles.iterator();
@@ -377,29 +388,57 @@
         public void close() {
           // Do nothing.
         }
+
+        @Override
+        public Object searchAfter() {
+          return searchAfterBySubIndex;
+        }
       };
     }
 
-    private List<Document> doRead(Set<String> fields) throws IOException {
+    private Results doRead(Set<String> fields) throws IOException {
       IndexSearcher[] searchers = new IndexSearcher[indexes.size()];
+      Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex = new HashMap<>();
       try {
-        int realLimit = opts.start() + opts.limit();
-        if (Integer.MAX_VALUE - opts.limit() < opts.start()) {
-          realLimit = Integer.MAX_VALUE;
+        int realPageSize = opts.start() + opts.pageSize();
+        if (Integer.MAX_VALUE - opts.pageSize() < opts.start()) {
+          realPageSize = Integer.MAX_VALUE;
         }
-        TopFieldDocs[] hits = new TopFieldDocs[indexes.size()];
+        List<TopFieldDocs> hits = new ArrayList<>();
+        int searchAfterHitsCount = 0;
         for (int i = 0; i < indexes.size(); i++) {
-          searchers[i] = indexes.get(i).acquire();
-          hits[i] = searchers[i].search(query, realLimit, sort);
+          ChangeSubIndex subIndex = indexes.get(i);
+          searchers[i] = subIndex.acquire();
+          if (isSearchAfterPagination) {
+            ScoreDoc searchAfter = getSearchAfter(subIndex);
+            int maxRemainingHits = realPageSize - searchAfterHitsCount;
+            if (maxRemainingHits > 0) {
+              TopFieldDocs subIndexHits =
+                  searchers[i].searchAfter(
+                      searchAfter,
+                      query,
+                      maxRemainingHits,
+                      sort,
+                      /* doDocScores= */ false,
+                      /* doMaxScore= */ false);
+              searchAfterHitsCount += subIndexHits.scoreDocs.length;
+              hits.add(subIndexHits);
+              searchAfterBySubIndex.put(
+                  subIndex, Iterables.getLast(Arrays.asList(subIndexHits.scoreDocs), searchAfter));
+            }
+          } else {
+            hits.add(searchers[i].search(query, realPageSize, sort));
+          }
         }
-        TopDocs docs = TopDocs.merge(sort, realLimit, hits);
+        TopDocs docs =
+            TopDocs.merge(sort, realPageSize, hits.stream().toArray(TopFieldDocs[]::new));
 
         List<Document> result = new ArrayList<>(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
           ScoreDoc sd = docs.scoreDocs[i];
           result.add(searchers[sd.shardIndex].doc(sd.doc, fields));
         }
-        return result;
+        return new Results(result, searchAfterBySubIndex);
       } finally {
         for (int i = 0; i < indexes.size(); i++) {
           if (searchers[i] != null) {
@@ -412,13 +451,43 @@
         }
       }
     }
+
+    /**
+     * Returns null for the first page or when pagination type is not {@link
+     * PaginationType#SEARCH_AFTER search-after}, otherwise returns the last doc from previous
+     * search on the given change sub-index.
+     *
+     * @param subIndex change sub-index
+     * @return the score doc that can be used to page result sets
+     */
+    @Nullable
+    private ScoreDoc getSearchAfter(ChangeSubIndex subIndex) {
+      if (isSearchAfterPagination
+          && opts.searchAfter() != null
+          && opts.searchAfter() instanceof Map
+          && ((Map<?, ?>) opts.searchAfter()).get(subIndex) instanceof ScoreDoc) {
+        return (ScoreDoc) ((Map<?, ?>) opts.searchAfter()).get(subIndex);
+      }
+      return null;
+    }
+  }
+
+  private static class Results {
+    List<Document> docs;
+    Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex;
+
+    public Results(List<Document> docs, Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex) {
+      this.docs = docs;
+      this.searchAfterBySubIndex = searchAfterBySubIndex;
+    }
   }
 
   private class ChangeDataResults implements ResultSet<ChangeData> {
-    private final Future<List<Document>> future;
+    private final Future<Results> future;
     private final Set<String> fields;
+    private Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex;
 
-    ChangeDataResults(Future<List<Document>> future, Set<String> fields) {
+    ChangeDataResults(Future<Results> future, Set<String> fields) {
       this.future = future;
       this.fields = fields;
     }
@@ -431,11 +500,13 @@
     @Override
     public ImmutableList<ChangeData> toList() {
       try {
-        List<Document> docs = future.get();
+        Results r = future.get();
+        List<Document> docs = r.docs;
+        searchAfterBySubIndex = r.searchAfterBySubIndex;
         ImmutableList.Builder<ChangeData> result =
             ImmutableList.builderWithExpectedSize(docs.size());
         for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, idField.getName()));
+          result.add(toChangeData(fields(doc, fields), fields, NUMERIC_ID_STR_SPEC.getName()));
         }
         return result.build();
       } catch (InterruptedException e) {
@@ -451,6 +522,11 @@
     public void close() {
       future.cancel(false /* do not interrupt Lucene */);
     }
+
+    @Override
+    public Object searchAfter() {
+      return searchAfterBySubIndex;
+    }
   }
 
   private static ListMultimap<String, IndexableField> fields(Document doc, Set<String> fields) {
@@ -477,12 +553,13 @@
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
 
+      Change.Id id = Change.id(Integer.valueOf(f.stringValue()));
       // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
-      IndexableField project = doc.get(PROJECT.getName()).iterator().next();
-      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), extractor.extract(f));
+      IndexableField project = doc.get(PROJECT_SPEC.getName()).iterator().next();
+      cd = changeDataFactory.create(Project.nameKey(project.stringValue()), id);
     }
 
-    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+    for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
       if (fields.contains(field.getName()) && doc.get(field.getName()) != null) {
         field.setIfPossible(cd, new LuceneStoredValue(doc.get(field.getName())));
       }
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 5cad588..6301421 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -15,16 +15,17 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.server.index.group.GroupField.UUID;
+import static com.google.gerrit.server.index.group.GroupField.UUID_FIELD_SPEC;
 
 import com.google.common.collect.ImmutableSet;
+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.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -33,6 +34,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
@@ -45,9 +47,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
-import org.apache.lucene.store.RAMDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -56,14 +58,14 @@
 
   private static final String GROUPS = "groups";
 
-  private static final String UUID_SORT_FIELD = sortFieldName(UUID);
+  private static final String UUID_SORT_FIELD = sortFieldName(UUID_FIELD_SPEC);
 
   private static Term idTerm(InternalGroup group) {
     return idTerm(group.getGroupUUID());
   }
 
   private static Term idTerm(AccountGroup.UUID uuid) {
-    return QueryBuilder.stringTerm(UUID.getName(), uuid.get());
+    return QueryBuilder.stringTerm(UUID_FIELD_SPEC.getName(), uuid.get());
   }
 
   private final GerritIndexWriterConfig indexWriterConfig;
@@ -73,7 +75,7 @@
   private static Directory dir(Schema<?> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS, schema);
     return FSDirectory.open(indexDir);
@@ -84,7 +86,8 @@
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       Provider<GroupCache> groupCache,
-      @Assisted Schema<InternalGroup> schema)
+      @Assisted Schema<InternalGroup> schema,
+      AutoFlush autoFlush)
       throws IOException {
     super(
         schema,
@@ -94,7 +97,9 @@
         ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, GROUPS),
-        new SearcherFactory());
+        new SearcherFactory(),
+        autoFlush,
+        GroupIndex.ENTITY_TO_KEY);
     this.groupCache = groupCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, GROUPS);
@@ -104,8 +109,8 @@
   @Override
   void add(Document doc, Values<InternalGroup> values) {
     // Add separate DocValues field for the field that is needed for sorting.
-    FieldDef<InternalGroup, ?> f = values.getField();
-    if (f == UUID) {
+    SchemaField<InternalGroup, ?> f = values.getField();
+    if (f == UUID_FIELD_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(UUID_SORT_FIELD, new BytesRef(value)));
     }
@@ -122,6 +127,15 @@
   }
 
   @Override
+  public void insert(InternalGroup group) {
+    try {
+      insert(toDocument(group)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public void delete(AccountGroup.UUID key) {
     try {
       delete(idTerm(key)).get();
@@ -139,9 +153,11 @@
         new Sort(new SortField(UUID_SORT_FIELD, SortField.Type.STRING, false)));
   }
 
+  @Nullable
   @Override
   protected InternalGroup fromDocument(Document doc) {
-    AccountGroup.UUID uuid = AccountGroup.uuid(doc.getField(UUID.getName()).stringValue());
+    AccountGroup.UUID uuid =
+        AccountGroup.uuid(doc.getField(UUID_FIELD_SPEC.getName()).stringValue());
     // Use the GroupCache rather than depending on any stored fields in the
     // document (of which there shouldn't be any).
     return groupCache.get().get(uuid).orElse(null);
diff --git a/java/com/google/gerrit/lucene/LuceneIndexModule.java b/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 302a2da..3aa9c6e 100644
--- a/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -14,39 +14,60 @@
 
 package com.google.gerrit.lucene;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.server.ModuleImpl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.options.AutoFlush;
 import java.util.Map;
 import org.apache.lucene.search.BooleanQuery;
 import org.eclipse.jgit.lib.Config;
 
+@ModuleImpl(name = AbstractIndexModule.INDEX_MODULE)
 public class LuceneIndexModule extends AbstractIndexModule {
-  public static LuceneIndexModule singleVersionAllLatest(int threads, boolean slave) {
-    return new LuceneIndexModule(ImmutableMap.of(), threads, slave);
+  private final AutoFlush autoFlush;
+
+  public static LuceneIndexModule singleVersionAllLatest(
+      int threads, boolean slave, AutoFlush autoFlush) {
+    return new LuceneIndexModule(ImmutableMap.of(), threads, slave, autoFlush);
+  }
+
+  @VisibleForTesting
+  public static LuceneIndexModule singleVersionWithExplicitVersions(
+      Map<String, Integer> versions, int threads, boolean slave) {
+    return new LuceneIndexModule(versions, threads, slave, AutoFlush.ENABLED);
   }
 
   public static LuceneIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads, boolean slave) {
-    return new LuceneIndexModule(versions, threads, slave);
+      Map<String, Integer> versions, int threads, boolean slave, AutoFlush autoFlush) {
+    return new LuceneIndexModule(versions, threads, slave, autoFlush);
   }
 
-  public static LuceneIndexModule latestVersion(boolean slave) {
-    return new LuceneIndexModule(null, 0, slave);
+  public static LuceneIndexModule latestVersion(boolean slave, AutoFlush autoFlush) {
+    return new LuceneIndexModule(null, 0, slave, autoFlush);
   }
 
   static boolean isInMemoryTest(Config cfg) {
     return cfg.getBoolean("index", "lucene", "testInmemory", false);
   }
 
-  private LuceneIndexModule(Map<String, Integer> singleVersions, int threads, boolean slave) {
+  private LuceneIndexModule(
+      Map<String, Integer> singleVersions, int threads, boolean slave, AutoFlush autoFlush) {
     super(singleVersions, threads, slave);
+    this.autoFlush = autoFlush;
+  }
+
+  @Override
+  protected void configure() {
+    super.configure();
+    bind(AutoFlush.class).toInstance(autoFlush);
   }
 
   @Override
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 2a418ca..911d91f 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.index.project.ProjectField.NAME;
+import static com.google.gerrit.index.project.ProjectField.NAME_SPEC;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.query.DataSource;
@@ -32,6 +33,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -46,9 +48,9 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
-import org.apache.lucene.store.RAMDirectory;
 import org.apache.lucene.util.BytesRef;
 import org.eclipse.jgit.lib.Config;
 
@@ -56,14 +58,14 @@
     implements ProjectIndex {
   private static final String PROJECTS = "projects";
 
-  private static final String NAME_SORT_FIELD = sortFieldName(NAME);
+  private static final String NAME_SORT_FIELD = sortFieldName(NAME_SPEC);
 
   private static Term idTerm(ProjectData projectState) {
     return idTerm(projectState.getProject().getNameKey());
   }
 
   private static Term idTerm(Project.NameKey nameKey) {
-    return QueryBuilder.stringTerm(NAME.getName(), nameKey.get());
+    return QueryBuilder.stringTerm(NAME_SPEC.getName(), nameKey.get());
   }
 
   private final GerritIndexWriterConfig indexWriterConfig;
@@ -73,7 +75,7 @@
   private static Directory dir(Schema<ProjectData> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
-      return new RAMDirectory();
+      return new ByteBuffersDirectory();
     }
     Path indexDir = LuceneVersionManager.getDir(sitePaths, PROJECTS, schema);
     return FSDirectory.open(indexDir);
@@ -84,7 +86,8 @@
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       Provider<ProjectCache> projectCache,
-      @Assisted Schema<ProjectData> schema)
+      @Assisted Schema<ProjectData> schema,
+      AutoFlush autoFlush)
       throws IOException {
     super(
         schema,
@@ -94,7 +97,9 @@
         ImmutableSet.of(),
         null,
         new GerritIndexWriterConfig(cfg, PROJECTS),
-        new SearcherFactory());
+        new SearcherFactory(),
+        autoFlush,
+        ProjectIndex.ENTITY_TO_KEY);
     this.projectCache = projectCache;
 
     indexWriterConfig = new GerritIndexWriterConfig(cfg, PROJECTS);
@@ -104,8 +109,8 @@
   @Override
   void add(Document doc, Values<ProjectData> values) {
     // Add separate DocValues field for the field that is needed for sorting.
-    FieldDef<ProjectData, ?> f = values.getField();
-    if (f == NAME) {
+    SchemaField<ProjectData, ?> f = values.getField();
+    if (f == NAME_SPEC) {
       String value = (String) getOnlyElement(values.getValues());
       doc.add(new SortedDocValuesField(NAME_SORT_FIELD, new BytesRef(value)));
     }
@@ -122,6 +127,15 @@
   }
 
   @Override
+  public void insert(ProjectData projectState) {
+    try {
+      insert(toDocument(projectState)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public void delete(Project.NameKey nameKey) {
     try {
       delete(idTerm(nameKey)).get();
@@ -139,9 +153,10 @@
         new Sort(new SortField(NAME_SORT_FIELD, SortField.Type.STRING, false)));
   }
 
+  @Nullable
   @Override
   protected ProjectData fromDocument(Document doc) {
-    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME.getName()).stringValue());
+    Project.NameKey nameKey = Project.nameKey(doc.getField(NAME_SPEC.getName()).stringValue());
     return projectCache.get().get(nameKey).map(ProjectState::toProjectData).orElse(null);
   }
 }
diff --git a/java/com/google/gerrit/lucene/LuceneStoredValue.java b/java/com/google/gerrit/lucene/LuceneStoredValue.java
index efe489b..58ae3e0 100644
--- a/java/com/google/gerrit/lucene/LuceneStoredValue.java
+++ b/java/com/google/gerrit/lucene/LuceneStoredValue.java
@@ -18,7 +18,9 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.index.StoredValue;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.util.List;
 import org.apache.lucene.index.IndexableField;
@@ -36,6 +38,7 @@
     this.field = field;
   }
 
+  @Nullable
   @Override
   public String asString() {
     return Iterables.getFirst(asStrings(), null);
@@ -46,6 +49,7 @@
     return field.stream().map(f -> f.stringValue()).collect(toImmutableList());
   }
 
+  @Nullable
   @Override
   public Integer asInteger() {
     return Iterables.getFirst(asIntegers(), null);
@@ -56,6 +60,7 @@
     return field.stream().map(f -> f.numericValue().intValue()).collect(toImmutableList());
   }
 
+  @Nullable
   @Override
   public Long asLong() {
     return Iterables.getFirst(asLongs(), null);
@@ -66,11 +71,13 @@
     return field.stream().map(f -> f.numericValue().longValue()).collect(toImmutableList());
   }
 
+  @Nullable
   @Override
   public Timestamp asTimestamp() {
     return asLong() == null ? null : new Timestamp(asLong());
   }
 
+  @Nullable
   @Override
   public byte[] asByteArray() {
     return Iterables.getFirst(asByteArrays(), null);
@@ -81,6 +88,20 @@
     return copyAsBytes(field);
   }
 
+  @Override
+  @Nullable
+  public MessageLite asProto() {
+    // Lucene does not store protos
+    return null;
+  }
+
+  @Override
+  @Nullable
+  public Iterable<MessageLite> asProtos() {
+    // Lucene does not store protos
+    return null;
+  }
+
   private static List<byte[]> copyAsBytes(List<IndexableField> fields) {
     return fields.stream()
         .map(
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index 7d82bf5..14ad528 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST;
 import static org.apache.lucene.search.BooleanClause.Occur.MUST_NOT;
@@ -33,43 +34,24 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.RegexPredicate;
 import com.google.gerrit.index.query.TimestampRangePredicate;
-import java.util.Date;
 import java.util.List;
 import org.apache.lucene.analysis.Analyzer;
 import org.apache.lucene.document.IntPoint;
 import org.apache.lucene.document.LongPoint;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.search.BooleanQuery;
-import org.apache.lucene.search.LegacyNumericRangeQuery;
 import org.apache.lucene.search.MatchAllDocsQuery;
 import org.apache.lucene.search.PrefixQuery;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.RegexpQuery;
 import org.apache.lucene.search.TermQuery;
 import org.apache.lucene.util.BytesRefBuilder;
-import org.apache.lucene.util.LegacyNumericUtils;
 
-@SuppressWarnings("deprecation")
 public class QueryBuilder<V> {
-  @FunctionalInterface
-  static interface IntTermQuery {
-    Query get(String name, int value);
-  }
-
-  @FunctionalInterface
-  static interface IntRangeQuery {
-    Query get(String name, int min, int max);
-  }
-
-  @FunctionalInterface
-  static interface LongRangeQuery {
-    Query get(String name, long min, long max);
-  }
-
-  static Term intTerm(String name, int value) {
-    BytesRefBuilder builder = new BytesRefBuilder();
-    LegacyNumericUtils.intToPrefixCoded(value, 0, builder);
-    return new Term(name, builder.get());
+  /** @param name field name qparam i key value */
+  static Term intTerm(String name) {
+    checkState(false, "Lucene index implementation removed legacy numeric type");
+    return null;
   }
 
   static Term stringTerm(String name, String value) {
@@ -85,29 +67,9 @@
   private final Schema<V> schema;
   private final org.apache.lucene.util.QueryBuilder queryBuilder;
 
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final IntTermQuery intTermQuery;
-  private final IntRangeQuery intRangeTermQuery;
-  private final LongRangeQuery longRangeQuery;
-
   public QueryBuilder(Schema<V> schema, Analyzer analyzer) {
     this.schema = schema;
     queryBuilder = new org.apache.lucene.util.QueryBuilder(analyzer);
-    intTermQuery =
-        (name, value) ->
-            this.schema.useLegacyNumericFields()
-                ? new TermQuery(intTerm(name, value))
-                : intPoint(name, value);
-    intRangeTermQuery =
-        (name, min, max) ->
-            this.schema.useLegacyNumericFields()
-                ? LegacyNumericRangeQuery.newIntRange(name, min, max, true, true)
-                : IntPoint.newRangeQuery(name, min, max);
-    longRangeQuery =
-        (name, min, max) ->
-            this.schema.useLegacyNumericFields()
-                ? LegacyNumericRangeQuery.newLongRange(name, min, max, true, true)
-                : LongPoint.newRangeQuery(name, min, max);
   }
 
   public Query toQuery(Predicate<V> p) throws QueryParseException {
@@ -210,7 +172,7 @@
     } catch (NumberFormatException e) {
       throw new QueryParseException("not an integer: " + p.getValue(), e);
     }
-    return intTermQuery.get(p.getField().getName(), value);
+    return intPoint(p.getField().getName(), value);
   }
 
   private Query intRangeQuery(IndexPredicate<V> p) throws QueryParseException {
@@ -221,9 +183,9 @@
       int maximum = r.getMaximumValue();
       if (minimum == maximum) {
         // Just fall back to a standard integer query.
-        return intTermQuery.get(name, minimum);
+        return intPoint(name, minimum);
       }
-      return intRangeTermQuery.get(name, minimum, maximum);
+      return IntPoint.newRangeQuery(name, minimum, maximum);
     }
     throw new QueryParseException("not an integer range: " + p);
   }
@@ -231,16 +193,18 @@
   private Query timestampQuery(IndexPredicate<V> p) throws QueryParseException {
     if (p instanceof TimestampRangePredicate) {
       TimestampRangePredicate<V> r = (TimestampRangePredicate<V>) p;
-      return longRangeQuery.get(
-          r.getField().getName(), r.getMinTimestamp().getTime(), r.getMaxTimestamp().getTime());
+      return LongPoint.newRangeQuery(
+          r.getField().getName(),
+          r.getMinTimestamp().toEpochMilli(),
+          r.getMaxTimestamp().toEpochMilli());
     }
     throw new QueryParseException("not a timestamp: " + p);
   }
 
   private Query notTimestamp(TimestampRangePredicate<V> r) throws QueryParseException {
-    if (r.getMinTimestamp().getTime() == 0) {
-      return longRangeQuery.get(
-          r.getField().getName(), r.getMaxTimestamp().getTime(), Long.MAX_VALUE);
+    if (r.getMinTimestamp().toEpochMilli() == 0) {
+      return LongPoint.newRangeQuery(
+          r.getField().getName(), r.getMaxTimestamp().toEpochMilli(), Long.MAX_VALUE);
     }
     throw new QueryParseException("cannot negate: " + r);
   }
@@ -279,10 +243,6 @@
     return query;
   }
 
-  public int toIndexTimeInMinutes(Date ts) {
-    return (int) (ts.getTime() / 60000);
-  }
-
   public Schema<V> getSchema() {
     return schema;
   }
diff --git a/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index c164b29..56cb220 100644
--- a/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -17,6 +17,7 @@
  * limitations under the License.
  */
 
+import com.google.gerrit.common.Nullable;
 import java.io.IOException;
 import org.apache.lucene.index.DirectoryReader;
 import org.apache.lucene.index.FilterDirectoryReader;
@@ -132,6 +133,7 @@
     reference.getIndexReader().decRef();
   }
 
+  @Nullable
   @Override
   protected IndexSearcher refreshIfNeeded(IndexSearcher referenceToRefresh) throws IOException {
     final IndexReader r = referenceToRefresh.getIndexReader();
diff --git a/java/com/google/gerrit/mail/BUILD b/java/com/google/gerrit/mail/BUILD
index 59d8227..0fe6c43 100644
--- a/java/com/google/gerrit/mail/BUILD
+++ b/java/com/google/gerrit/mail/BUILD
@@ -12,7 +12,6 @@
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/jsoup",
-        "//lib/log:log4j",
         "//lib/mime4j:core",
         "//lib/mime4j:dom",
     ],
diff --git a/java/com/google/gerrit/mail/ParserUtil.java b/java/com/google/gerrit/mail/ParserUtil.java
index 4b292f3..40c5a95 100644
--- a/java/com/google/gerrit/mail/ParserUtil.java
+++ b/java/com/google/gerrit/mail/ParserUtil.java
@@ -115,7 +115,8 @@
     int numConsecutiveDigits = 0;
     int maxConsecutiveDigits = 0;
     int numDigitGroups = 0;
-    for (char c : s.toCharArray()) {
+    for (int i = 0; i < s.length(); i++) {
+      char c = s.charAt(i);
       if (c >= '0' && c <= '9') {
         numConsecutiveDigits++;
       } else if (numConsecutiveDigits > 0) {
diff --git a/java/com/google/gerrit/mail/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
index 213cc3f..929e9f9 100644
--- a/java/com/google/gerrit/mail/RawMailParser.java
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -25,6 +25,7 @@
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
+import java.time.Instant;
 import org.apache.james.mime4j.MimeException;
 import org.apache.james.mime4j.dom.Entity;
 import org.apache.james.mime4j.dom.Message;
@@ -66,7 +67,9 @@
       messageBuilder.subject(mimeMessage.getSubject());
     }
     if (mimeMessage.getDate() != null) {
-      messageBuilder.dateReceived(mimeMessage.getDate().toInstant());
+      @SuppressWarnings("JdkObsolete")
+      Instant mimeMessageInstant = mimeMessage.getDate().toInstant();
+      messageBuilder.dateReceived(mimeMessageInstant);
     }
 
     // Add From, To and Cc
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index 0cb0275..0f80a0c 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
@@ -14,6 +15,7 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
     ],
diff --git a/java/com/google/gerrit/metrics/DisabledMetricMaker.java b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
index 1fb8c57..234378b 100644
--- a/java/com/google/gerrit/metrics/DisabledMetricMaker.java
+++ b/java/com/google/gerrit/metrics/DisabledMetricMaker.java
@@ -33,7 +33,7 @@
 
   @Override
   public <F1> Counter1<F1> newCounter(String name, Description desc, Field<F1> field1) {
-    return new Counter1<F1>() {
+    return new Counter1<>() {
       @Override
       public void incrementBy(F1 field1, long value) {}
 
@@ -45,7 +45,7 @@
   @Override
   public <F1, F2> Counter2<F1, F2> newCounter(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Counter2<F1, F2>() {
+    return new Counter2<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, long value) {}
 
@@ -57,7 +57,7 @@
   @Override
   public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Counter3<F1, F2, F3>() {
+    return new Counter3<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {}
 
@@ -79,7 +79,7 @@
 
   @Override
   public <F1> Timer1<F1> newTimer(String name, Description desc, Field<F1> field1) {
-    return new Timer1<F1>(name, field1) {
+    return new Timer1<>(name, field1) {
       @Override
       protected void doRecord(F1 field1, long value, TimeUnit unit) {}
 
@@ -91,7 +91,7 @@
   @Override
   public <F1, F2> Timer2<F1, F2> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Timer2<F1, F2>(name, field1, field2) {
+    return new Timer2<>(name, field1, field2) {
       @Override
       protected void doRecord(F1 field1, F2 field2, long value, TimeUnit unit) {}
 
@@ -103,7 +103,7 @@
   @Override
   public <F1, F2, F3> Timer3<F1, F2, F3> newTimer(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Timer3<F1, F2, F3>(name, field1, field2, field3) {
+    return new Timer3<>(name, field1, field2, field3) {
       @Override
       protected void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {}
 
@@ -125,7 +125,7 @@
 
   @Override
   public <F1> Histogram1<F1> newHistogram(String name, Description desc, Field<F1> field1) {
-    return new Histogram1<F1>() {
+    return new Histogram1<>() {
       @Override
       public void record(F1 field1, long value) {}
 
@@ -137,7 +137,7 @@
   @Override
   public <F1, F2> Histogram2<F1, F2> newHistogram(
       String name, Description desc, Field<F1> field1, Field<F2> field2) {
-    return new Histogram2<F1, F2>() {
+    return new Histogram2<>() {
       @Override
       public void record(F1 field1, F2 field2, long value) {}
 
@@ -149,7 +149,7 @@
   @Override
   public <F1, F2, F3> Histogram3<F1, F2, F3> newHistogram(
       String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
-    return new Histogram3<F1, F2, F3>() {
+    return new Histogram3<>() {
       @Override
       public void record(F1 field1, F2 field2, F3 field3, long value) {}
 
@@ -161,7 +161,7 @@
   @Override
   public <V> CallbackMetric0<V> newCallbackMetric(
       String name, Class<V> valueClass, Description desc) {
-    return new CallbackMetric0<V>() {
+    return new CallbackMetric0<>() {
       @Override
       public void set(V value) {}
 
@@ -173,7 +173,7 @@
   @Override
   public <F1, V> CallbackMetric1<F1, V> newCallbackMetric(
       String name, Class<V> valueClass, Description desc, Field<F1> field1) {
-    return new CallbackMetric1<F1, V>() {
+    return new CallbackMetric1<>() {
       @Override
       public void set(F1 field1, V value) {}
 
diff --git a/java/com/google/gerrit/metrics/MetricMaker.java b/java/com/google/gerrit/metrics/MetricMaker.java
index 42ec8a0..3f9bab1 100644
--- a/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/java/com/google/gerrit/metrics/MetricMaker.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Supplier;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import java.util.Set;
 
@@ -78,14 +79,16 @@
    * @param name unique name of the metric.
    * @param value only value of the metric.
    * @param desc description of the metric.
+   * @return registration handle
    */
-  public <V> void newConstantMetric(String name, V value, Description desc) {
+  @CanIgnoreReturnValue
+  public <V> RegistrationHandle newConstantMetric(String name, V value, Description desc) {
     desc.setConstant();
 
     @SuppressWarnings("unchecked")
     Class<V> type = (Class<V>) value.getClass();
     CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
-    newTrigger(metric, () -> metric.set(value));
+    return newTrigger(metric, () -> metric.set(value));
   }
 
   /**
@@ -107,11 +110,13 @@
    * @param valueClass type of value recorded by the metric.
    * @param desc description of the metric.
    * @param trigger function to compute the value of the metric.
+   * @return registration handle
    */
-  public <V> void newCallbackMetric(
+  @CanIgnoreReturnValue
+  public <V> RegistrationHandle newCallbackMetric(
       String name, Class<V> valueClass, Description desc, Supplier<V> trigger) {
     CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
-    newTrigger(metric, () -> metric.set(trigger.get()));
+    return newTrigger(metric, () -> metric.set(trigger.get()));
   }
 
   /**
diff --git a/java/com/google/gerrit/metrics/MetricsReservoirConfig.java b/java/com/google/gerrit/metrics/MetricsReservoirConfig.java
new file mode 100644
index 0000000..ca4cb09
--- /dev/null
+++ b/java/com/google/gerrit/metrics/MetricsReservoirConfig.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+import java.time.Duration;
+
+/** Configuration of the Metrics' reservoir type and size. */
+public interface MetricsReservoirConfig {
+
+  /** Returns the reservoir type. */
+  ReservoirType reservoirType();
+
+  /** Returns the reservoir window duration. */
+  Duration reservoirWindow();
+
+  /** Returns the number of samples that the reservoir can contain */
+  int reservoirSize();
+
+  /** Returns the alpha parameter of the ExponentiallyDecaying reservoir */
+  double reservoirAlpha();
+}
diff --git a/java/com/google/gerrit/metrics/ReservoirType.java b/java/com/google/gerrit/metrics/ReservoirType.java
new file mode 100644
index 0000000..fe89752
--- /dev/null
+++ b/java/com/google/gerrit/metrics/ReservoirType.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics;
+
+/** Type of reservoir for collecting metrics into. */
+public enum ReservoirType {
+  ExponentiallyDecaying,
+  SlidingTimeWindowArray,
+  SlidingTimeWindow,
+  SlidingWindow,
+  Uniform;
+}
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
index 0e554a8..92aeb4c 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImpl1.java
@@ -26,7 +26,7 @@
   }
 
   Counter1<F1> counter() {
-    return new Counter1<F1>() {
+    return new Counter1<>() {
       @Override
       public void incrementBy(F1 field1, long value) {
         total.incrementBy(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
index 07afc2a..e9199d9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/CounterImplN.java
@@ -29,7 +29,7 @@
   }
 
   <F1, F2> Counter2<F1, F2> counter2() {
-    return new Counter2<F1, F2>() {
+    return new Counter2<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, long value) {
         total.incrementBy(value);
@@ -44,7 +44,7 @@
   }
 
   <F1, F2, F3> Counter3<F1, F2, F3> counter3() {
-    return new Counter3<F1, F2, F3>() {
+    return new Counter3<>() {
       @Override
       public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {
         total.incrementBy(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
index fcba0ee..32be18d 100644
--- a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
+++ b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -18,8 +18,10 @@
 import static com.google.gerrit.metrics.dropwizard.MetricResource.METRIC_KIND;
 import static com.google.gerrit.server.config.ConfigResource.CONFIG_KIND;
 
+import com.codahale.metrics.Histogram;
 import com.codahale.metrics.Metric;
 import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Timer;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -41,6 +43,7 @@
 import com.google.gerrit.metrics.Histogram2;
 import com.google.gerrit.metrics.Histogram3;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.MetricsReservoirConfig;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.metrics.Timer2;
@@ -48,10 +51,12 @@
 import com.google.gerrit.metrics.proc.JGitMetricModule;
 import com.google.gerrit.metrics.proc.ProcMetricModule;
 import com.google.gerrit.server.cache.CacheMetrics;
+import com.google.gerrit.server.config.MetricsReservoirConfigImpl;
 import com.google.inject.Inject;
 import com.google.inject.Scopes;
 import com.google.inject.Singleton;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
@@ -65,8 +70,25 @@
 @Singleton
 public class DropWizardMetricMaker extends MetricMaker {
   public static class ApiModule extends RestApiModule {
+    private final Optional<MetricsReservoirConfig> metricsReservoirConfig;
+
+    public ApiModule(MetricsReservoirConfig metricsReservoirConfig) {
+      this.metricsReservoirConfig = Optional.of(metricsReservoirConfig);
+    }
+
+    public ApiModule() {
+      this.metricsReservoirConfig = Optional.empty();
+    }
+
     @Override
     protected void configure() {
+      if (metricsReservoirConfig.isPresent()) {
+        bind(MetricsReservoirConfig.class).toInstance(metricsReservoirConfig.get());
+      } else {
+        bind(MetricsReservoirConfig.class)
+            .to(MetricsReservoirConfigImpl.class)
+            .in(Scopes.SINGLETON);
+      }
       bind(MetricRegistry.class).in(Scopes.SINGLETON);
       bind(DropWizardMetricMaker.class).in(Scopes.SINGLETON);
       bind(MetricMaker.class).to(DropWizardMetricMaker.class);
@@ -89,12 +111,14 @@
   private final MetricRegistry registry;
   private final Map<String, BucketedMetric> bucketed;
   private final Map<String, ImmutableMap<String, String>> descriptions;
+  private final MetricsReservoirConfig reservoirConfig;
 
   @Inject
-  DropWizardMetricMaker(MetricRegistry registry) {
+  DropWizardMetricMaker(MetricRegistry registry, MetricsReservoirConfig reservoirConfig) {
     this.registry = registry;
     this.bucketed = new ConcurrentHashMap<>();
     this.descriptions = new ConcurrentHashMap<>();
+    this.reservoirConfig = reservoirConfig;
   }
 
   Iterable<String> getMetricNames() {
@@ -222,7 +246,9 @@
   }
 
   TimerImpl newTimerImpl(String name) {
-    return new TimerImpl(name, registry.timer(name));
+    return new TimerImpl(
+        name,
+        registry.timer(name, () -> new Timer(DropWizardReservoirProvider.get(reservoirConfig))));
   }
 
   @Override
@@ -271,7 +297,10 @@
   }
 
   HistogramImpl newHistogramImpl(String name) {
-    return new HistogramImpl(name, registry.histogram(name));
+    return new HistogramImpl(
+        name,
+        registry.histogram(
+            name, () -> new Histogram(DropWizardReservoirProvider.get(reservoirConfig))));
   }
 
   @Override
diff --git a/java/com/google/gerrit/metrics/dropwizard/DropWizardReservoirProvider.java b/java/com/google/gerrit/metrics/dropwizard/DropWizardReservoirProvider.java
new file mode 100644
index 0000000..3089068
--- /dev/null
+++ b/java/com/google/gerrit/metrics/dropwizard/DropWizardReservoirProvider.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.Reservoir;
+import com.codahale.metrics.SlidingTimeWindowArrayReservoir;
+import com.codahale.metrics.SlidingTimeWindowReservoir;
+import com.codahale.metrics.SlidingWindowReservoir;
+import com.codahale.metrics.UniformReservoir;
+import com.google.gerrit.metrics.MetricsReservoirConfig;
+import com.google.gerrit.metrics.ReservoirType;
+import java.util.concurrent.TimeUnit;
+
+class DropWizardReservoirProvider {
+
+  private DropWizardReservoirProvider() {}
+
+  static Reservoir get(MetricsReservoirConfig config) {
+    ReservoirType reservoirType = config.reservoirType();
+    switch (reservoirType) {
+      case ExponentiallyDecaying:
+        return new ExponentiallyDecayingReservoir(config.reservoirSize(), config.reservoirAlpha());
+      case SlidingTimeWindowArray:
+        return new SlidingTimeWindowArrayReservoir(
+            config.reservoirWindow().toMillis(), TimeUnit.MILLISECONDS);
+      case SlidingTimeWindow:
+        return new SlidingTimeWindowReservoir(
+            config.reservoirWindow().toMillis(), TimeUnit.MILLISECONDS);
+      case SlidingWindow:
+        return new SlidingWindowReservoir(config.reservoirSize());
+      case Uniform:
+        return new UniformReservoir(config.reservoirSize());
+
+      default:
+        throw new IllegalArgumentException(
+            "Unsupported metrics reservoir type " + reservoirType.name());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
index 4578db1..91e36b9 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImpl1.java
@@ -26,7 +26,7 @@
   }
 
   Histogram1<F1> histogram1() {
-    return new Histogram1<F1>() {
+    return new Histogram1<>() {
       @Override
       public void record(F1 field1, long value) {
         total.record(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
index 446590c..2caa4c5 100644
--- a/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/HistogramImplN.java
@@ -29,7 +29,7 @@
   }
 
   <F1, F2> Histogram2<F1, F2> histogram2() {
-    return new Histogram2<F1, F2>() {
+    return new Histogram2<>() {
       @Override
       public void record(F1 field1, F2 field2, long value) {
         total.record(value);
@@ -44,7 +44,7 @@
   }
 
   <F1, F2, F3> Histogram3<F1, F2, F3> histogram3() {
-    return new Histogram3<F1, F2, F3>() {
+    return new Histogram3<>() {
       @Override
       public void record(F1 field1, F2 field2, F3 field3, long value) {
         total.record(value);
diff --git a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
index 7e472c9..6b17456 100644
--- a/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
+++ b/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -26,7 +26,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.SortedMap;
+import java.util.NavigableMap;
 import java.util.TreeMap;
 import org.kohsuke.args4j.Option;
 
@@ -55,7 +55,7 @@
       throws AuthException, PermissionBackendException {
     permissionBackend.currentUser().check(GlobalPermission.VIEW_CACHES);
 
-    SortedMap<String, MetricJson> out = new TreeMap<>();
+    NavigableMap<String, MetricJson> out = new TreeMap<>();
     List<String> prefixes = new ArrayList<>(query.size());
     for (String q : query) {
       if (q.endsWith("/")) {
diff --git a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
index d59a1d9..27e9377 100644
--- a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
+++ b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
@@ -23,6 +23,7 @@
 import com.codahale.metrics.Timer;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import java.util.ArrayList;
@@ -144,6 +145,7 @@
     }
   }
 
+  @Nullable
   private static Boolean toBool(ImmutableMap<String, String> atts, String key) {
     return Description.TRUE_VALUE.equals(atts.get(key)) ? true : null;
   }
diff --git a/java/com/google/gerrit/metrics/dropwizard/MetricResource.java b/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
index 226edc7..8a6db67 100644
--- a/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
+++ b/java/com/google/gerrit/metrics/dropwizard/MetricResource.java
@@ -20,8 +20,7 @@
 import com.google.inject.TypeLiteral;
 
 class MetricResource extends ConfigResource {
-  static final TypeLiteral<RestView<MetricResource>> METRIC_KIND =
-      new TypeLiteral<RestView<MetricResource>>() {};
+  static final TypeLiteral<RestView<MetricResource>> METRIC_KIND = new TypeLiteral<>() {};
 
   private final String name;
   private final Metric metric;
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
index b7d535b..36b52e1 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImpl1.java
@@ -28,7 +28,7 @@
 
   @SuppressWarnings("unchecked")
   Timer1<F1> timer() {
-    return new Timer1<F1>(name, (Field<F1>) fields[0]) {
+    return new Timer1<>(name, (Field<F1>) fields[0]) {
       @Override
       protected void doRecord(F1 field1, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
index dee800e..77ce8cd 100644
--- a/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
+++ b/java/com/google/gerrit/metrics/dropwizard/TimerImplN.java
@@ -31,7 +31,7 @@
 
   @SuppressWarnings("unchecked")
   <F1, F2> Timer2<F1, F2> timer2() {
-    return new Timer2<F1, F2>(name, (Field<F1>) fields[0], (Field<F2>) fields[1]) {
+    return new Timer2<>(name, (Field<F1>) fields[0], (Field<F2>) fields[1]) {
       @Override
       protected void doRecord(F1 field1, F2 field2, long value, TimeUnit unit) {
         total.record(value, unit);
@@ -47,8 +47,7 @@
 
   @SuppressWarnings("unchecked")
   <F1, F2, F3> Timer3<F1, F2, F3> timer3() {
-    return new Timer3<F1, F2, F3>(
-        name, (Field<F1>) fields[0], (Field<F2>) fields[1], (Field<F3>) fields[2]) {
+    return new Timer3<>(name, (Field<F1>) fields[0], (Field<F2>) fields[1], (Field<F3>) fields[2]) {
       @Override
       protected void doRecord(F1 field1, F2 field2, F3 field3, long value, TimeUnit unit) {
         total.record(value, unit);
diff --git a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
index ef0ced6..84f2320 100644
--- a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
+++ b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.metrics.proc;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.sun.management.UnixOperatingSystemMXBean;
 import java.lang.management.ManagementFactory;
 import java.lang.management.OperatingSystemMXBean;
@@ -25,6 +26,7 @@
 
   private OperatingSystemMXBeanFactory() {}
 
+  @Nullable
   static OperatingSystemMXBeanInterface create() {
     OperatingSystemMXBean sys = ManagementFactory.getOperatingSystemMXBean();
     if (sys instanceof UnixOperatingSystemMXBean) {
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
index 9e43a05..f5555b5 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -35,8 +35,6 @@
 
   @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());
+    return sys.getCurrentThreadAllocatedBytes();
   }
 }
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 387ff2d..df64bc7 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -10,7 +10,6 @@
         "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
@@ -49,12 +48,11 @@
         "//lib:servlet-api-without-neverlink",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
-        "//lib/log:log4j",
         "//lib/prolog:cafeteria",
         "//lib/prolog:compiler",
         "//lib/prolog:runtime",
diff --git a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
index 01c76c1..2b7f23e 100644
--- a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
+++ b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
@@ -14,11 +14,7 @@
 
 package com.google.gerrit.pgm;
 
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleManager;
@@ -26,28 +22,23 @@
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 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.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Key;
-import com.google.inject.Provider;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
 import java.io.IOException;
 import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TextProgressMonitor;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
@@ -73,12 +64,8 @@
   private boolean isUserNameCaseInsensitive;
   private ConsoleUI ui;
 
-  @Inject private GitRepositoryManager repoManager;
-  @Inject private AllUsersName allUsersName;
-  @Inject private Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
-  @Inject private ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
   @Inject private ExternalIds externalIds;
-  @Inject private ExternalIdFactory externalIdFactory;
+  @Inject private ExternalIdCaseSensitivityMigrator.Factory migratorFactory;
 
   @Override
   public int run() throws Exception {
@@ -93,6 +80,9 @@
               @Override
               protected void configure() {
                 bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+                install(
+                    new FactoryModuleBuilder()
+                        .build(ExternalIdCaseSensitivityMigrator.Factory.class));
                 factory(MetaDataUpdate.InternalFactory.class);
                 DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
 
@@ -122,23 +112,9 @@
 
     manager.start();
     try {
-      try (Repository repo = repoManager.openRepository(allUsersName)) {
-        ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
-        for (ExternalId extId : todo) {
-          recomputeExternalIdNoteId(extIdNotes, extId);
-          monitor.update(1);
-        }
-        if (!dryrun) {
-          try (MetaDataUpdate metaDataUpdate =
-              metaDataUpdateServerFactory.get().create(allUsersName)) {
-            metaDataUpdate.setMessage(
-                String.format(
-                    "Migration to case %ssensitive usernames",
-                    isUserNameCaseInsensitive ? "" : "in"));
-            extIdNotes.commit(metaDataUpdate);
-          }
-        }
-      }
+      migratorFactory
+          .create(!isUserNameCaseInsensitive, dryrun)
+          .migrate(todo, () -> monitor.update(1));
     } finally {
       manager.stop();
       monitor.endTask();
@@ -155,28 +131,6 @@
     return exitCode;
   }
 
-  private void recomputeExternalIdNoteId(ExternalIdNotes extIdNotes, ExternalId extId)
-      throws DuplicateKeyException, IOException {
-    if (extId.isScheme(SCHEME_GERRIT) || extId.isScheme(SCHEME_USERNAME)) {
-      ExternalIdKeyFactory keyFactory =
-          new ExternalIdKeyFactory(
-              new ExternalIdKeyFactory.Config() {
-                @Override
-                public boolean isUserNameCaseInsensitive() {
-                  return !isUserNameCaseInsensitive;
-                }
-              });
-      ExternalId.Key updatedKey = keyFactory.create(extId.key().scheme(), extId.key().id());
-      if (!extId.key().sha1().getName().equals(updatedKey.sha1().getName())) {
-        logger.atInfo().log("Converting note name of external ID: %s", extId.key());
-        ExternalId updatedExtId =
-            externalIdFactory.create(
-                updatedKey, extId.accountId(), extId.email(), extId.password(), extId.blobId());
-        extIdNotes.replace(extId, updatedExtId);
-      }
-    }
-  }
-
   private void updateGerritConfig() throws IOException, ConfigInvalidException {
     logger.atInfo().log("Setting auth.userNameCaseInsensitive to true in gerrit.config.");
     FileBasedConfig config =
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index f3691ed..0342fe5 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -23,7 +23,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.auth.AuthModule;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
@@ -62,8 +61,10 @@
 import com.google.gerrit.server.StartupChecks.StartupChecksModule;
 import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
 import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -91,6 +92,7 @@
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.OnlineUpgrader.OnlineUpgraderModule;
 import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
 import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
 import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
@@ -118,6 +120,7 @@
 import com.google.gerrit.sshd.SshModule;
 import com.google.gerrit.sshd.SshSessionFactoryInitializer;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
+import com.google.gerrit.sshd.commands.ExternalIdCommandsModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
 import com.google.gerrit.sshd.commands.SequenceCommandsModule;
 import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand.LfsPluginAuthCommandModule;
@@ -444,6 +447,7 @@
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
+    modules.add(new ProjectQueryBuilderModule());
     modules.add(new PluginApiModule());
 
     modules.add(new SearchingChangeCacheImplModule(replica));
@@ -491,7 +495,7 @@
           });
     }
     modules.add(new DefaultUrlFormatterModule());
-    SshSessionFactoryInitializer.init(config);
+    SshSessionFactoryInitializer.init();
     if (sshd) {
       modules.add(SshKeyCacheImpl.module());
     } else {
@@ -520,12 +524,16 @@
     modules.add(new LocalMergeSuperSetComputationModule());
     modules.add(new DefaultProjectNameLockManagerModule());
 
-    List<Module> libModules = LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE);
+    List<Module> libModules =
+        LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE_TYPE);
+    libModules.addAll(LibModuleLoader.loadModules(cfgInjector, LibModuleType.INDEX_MODULE_TYPE));
     libModules.addAll(testSysModules);
 
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     modules.add(new AuthModule(authConfig));
 
+    modules.add(new ExternalIdCaseSensitivityMigrator.ExternalIdCaseSensitivityMigratorModule());
+
     return cfgInjector.createChildInjector(ModuleOverloader.override(modules, libModules));
   }
 
@@ -534,10 +542,7 @@
       return indexModule;
     }
     if (indexType.isLucene()) {
-      return LuceneIndexModule.latestVersion(replica);
-    }
-    if (indexType.isElasticsearch()) {
-      return ElasticIndexModule.latestVersion(replica);
+      return LuceneIndexModule.latestVersion(replica, AutoFlush.ENABLED);
     }
     if (indexType.isFake()) {
       // Use Reflection so that we can omit the fake index binary in production code. Test code does
@@ -578,6 +583,7 @@
     if (!replica) {
       modules.add(new IndexCommandsModule(sysInjector));
       modules.add(new SequenceCommandsModule());
+      modules.add(new ExternalIdCommandsModule());
     }
     return sysInjector.createChildInjector(modules);
   }
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index 4e62a0f..4c7b47b 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.IoUtil;
 import com.google.gerrit.common.PageLinks;
@@ -91,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;
@@ -157,11 +161,13 @@
     modules.add(new GerritServerConfigModule());
     Guice.createInjector(modules).injectMembers(this);
     if (!ReplicaUtil.isReplica(run.flags.cfg)) {
+      List<String> indicesToReindex = new ArrayList<>();
       for (SchemaDefinitions<?> schemaDef : schemaDefs) {
         if (!indexStatus.exists(schemaDef.getName())) {
-          reindex(schemaDef);
+          indicesToReindex.add(schemaDef.getName());
         }
       }
+      reindex(indicesToReindex, run.flags.isNew);
     }
     start(run);
   }
@@ -274,17 +280,23 @@
     }
   }
 
-  private void reindex(SchemaDefinitions<?> schemaDef) throws Exception {
+  private void reindex(List<String> indices, boolean isNewSite) throws Exception {
+    if (indices.isEmpty()) {
+      return;
+    }
     List<String> reindexArgs =
-        ImmutableList.of(
-            "--site-path",
-            getSitePath().toString(),
-            "--threads",
-            Integer.toString(1),
-            "--index",
-            schemaDef.getName());
+        Lists.newArrayList(
+            "--site-path", getSitePath().toString(), "--threads", Integer.toString(reindexThreads));
+    for (String index : indices) {
+      reindexArgs.add("--index");
+      reindexArgs.add(index);
+    }
+    if (isNewSite) {
+      reindexArgs.add("--disable-cache-stats");
+    }
+
     getConsoleUI()
-        .message(String.format("Init complete, reindexing %s with:", schemaDef.getName()));
+        .message(String.format("Init complete, reindexing %s with:", String.join(",", indices)));
     getConsoleUI().message(" reindex " + reindexArgs.stream().collect(joining(" ")));
     Reindex reindexPgm = new Reindex();
     reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
diff --git a/java/com/google/gerrit/pgm/JythonShell.java b/java/com/google/gerrit/pgm/JythonShell.java
index 88f7b5d..d85bdc0 100644
--- a/java/com/google/gerrit/pgm/JythonShell.java
+++ b/java/com/google/gerrit/pgm/JythonShell.java
@@ -173,7 +173,7 @@
         logger.atSevere().log("Cannot load resource %s", p);
       }
     } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage());
+      logger.atSevere().withCause(e).log("%s", e.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index f651994..b7ff1f7 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -38,7 +38,7 @@
 import java.util.Collection;
 import java.util.Locale;
 import java.util.Optional;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Repository;
diff --git a/java/com/google/gerrit/pgm/Ls.java b/java/com/google/gerrit/pgm/Ls.java
index 4211c17..4b48148 100644
--- a/java/com/google/gerrit/pgm/Ls.java
+++ b/java/com/google/gerrit/pgm/Ls.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.pgm.util.AbstractProgram;
 import java.io.IOException;
-import java.util.Enumeration;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
 
@@ -26,9 +27,7 @@
   @Override
   public int run() throws IOException {
     try (ZipFile zf = new ZipFile(GerritLauncher.getDistributionArchive())) {
-      final Enumeration<? extends ZipEntry> e = zf.entries();
-      while (e.hasMoreElements()) {
-        final ZipEntry ze = e.nextElement();
+      for (ZipEntry ze : entriesOf(zf)) {
         String name = ze.getName();
         boolean show = false;
         show |= name.startsWith("WEB-INF/");
@@ -49,4 +48,8 @@
     }
     return 0;
   }
+
+  private static Iterable<? extends ZipEntry> entriesOf(ZipFile zipFile) {
+    return zipFile.stream().collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 6e99007..762d988 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -17,10 +17,11 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.cache.Cache;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Die;
-import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.IndexType;
@@ -29,16 +30,27 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.util.BatchProgramModule;
 import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.cache.CacheDisplay;
+import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.options.AutoFlush;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.util.ReplicaUtil;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
+import com.google.inject.multibindings.OptionalBinder;
+import java.io.StringWriter;
+import java.io.Writer;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.util.ArrayList;
@@ -49,13 +61,17 @@
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.util.io.NullOutputStream;
 import org.kohsuke.args4j.Option;
 
 public class Reindex extends SiteProgram {
-  @Option(name = "--threads", usage = "Number of threads to use for indexing")
-  private int threads = Runtime.getRuntime().availableProcessors();
+  @Option(
+      name = "--threads",
+      usage = "Number of threads to use for indexing. Default is index.batchThreads from config.")
+  private int threads = 0;
 
   @Option(
       name = "--changes-schema-version",
@@ -71,12 +87,20 @@
   @Option(name = "--index", usage = "Only reindex specified indices")
   private List<String> indices = new ArrayList<>();
 
+  @Option(
+      name = "--disable-cache-stats",
+      usage =
+          "Disables printing the cache statistics."
+              + "Defaults to true when reindex is run from init on a new site, false otherwise")
+  private boolean disableCacheStats;
+
   private Injector dbInjector;
   private Injector sysInjector;
   private Injector cfgInjector;
   private Config globalConfig;
 
   @Inject private Collection<IndexDefinition<?, ?, ?>> indexDefs;
+  @Inject private DynamicMap<Cache<?, ?>> cacheMap;
 
   @Override
   public int run() throws Exception {
@@ -100,6 +124,9 @@
 
     try {
       boolean ok = list ? list() : reindex();
+      if (!disableCacheStats) {
+        printCacheStats();
+      }
       return ok ? 0 : 1;
     } catch (Exception e) {
       throw die(e.getMessage(), e);
@@ -149,13 +176,14 @@
     }
     boolean replica = ReplicaUtil.isReplica(globalConfig);
     List<Module> modules = new ArrayList<>();
+    modules.add(new WorkQueueModule());
+
     Module indexModule;
     IndexType indexType = IndexModule.getIndexType(dbInjector);
     if (indexType.isLucene()) {
-      indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(versions, threads, replica);
-    } else if (indexType.isElasticsearch()) {
       indexModule =
-          ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads, replica);
+          LuceneIndexModule.singleVersionWithExplicitVersions(
+              versions, threads, replica, AutoFlush.DISABLED);
     } else if (indexType.isFake()) {
       // Use Reflection so that we can omit the fake index binary in production code. Test code does
       // compile the component in.
@@ -175,6 +203,16 @@
       throw new IllegalStateException("unsupported index.type = " + indexType);
     }
     modules.add(indexModule);
+    modules.add(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            super.configure();
+            OptionalBinder.newOptionalBinder(binder(), IsFirstInsertForEntry.class)
+                .setBinding()
+                .toInstance(IsFirstInsertForEntry.YES);
+          }
+        });
     modules.add(new BatchProgramModule(dbInjector));
     modules.add(
         new FactoryModule() {
@@ -184,7 +222,9 @@
           }
         });
 
-    return dbInjector.createChildInjector(modules);
+    return dbInjector.createChildInjector(
+        ModuleOverloader.override(
+            modules, LibModuleLoader.loadReindexModules(cfgInjector, versions, threads, replica)));
   }
 
   private void overrideConfig() {
@@ -222,6 +262,22 @@
     System.out.format(
         "Index %s in version %d is %sready\n",
         def.getName(), index.getSchema().getVersion(), result.success() ? "" : "NOT ");
+
     return result.success();
   }
+
+  private void printCacheStats() {
+    try (Writer sw = new StringWriter()) {
+      sw.write("Cache Statistics at the end of reindexing\n");
+      new CacheDisplay(
+              sw,
+              StreamSupport.stream(cacheMap.spliterator(), false)
+                  .map(e -> new CacheInfo(e.getExportName(), e.get()))
+                  .collect(Collectors.toList()))
+          .displayCaches();
+      System.out.print(sw.toString());
+    } catch (Exception e) {
+      System.out.format("Error displaying the cache statistics\n" + e.getMessage());
+    }
+  }
 }
diff --git a/java/com/google/gerrit/pgm/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
index 733c9d1..6dec2d8 100644
--- a/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.SiteLibraryLoaderUtil;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.config.SitePaths;
@@ -185,6 +186,7 @@
     }
   }
 
+  @Nullable
   private Path findJarWithSecureStore(SitePaths sitePaths, String secureStoreClass)
       throws IOException {
     List<Path> jars = SiteLibraryLoaderUtil.listJars(sitePaths.lib_dir);
@@ -196,7 +198,7 @@
           return jar;
         }
       } catch (IOException e) {
-        logger.atSevere().withCause(e).log(e.getMessage());
+        logger.atSevere().withCause(e).log("%s", e.getMessage());
       }
     }
     return null;
diff --git a/java/com/google/gerrit/pgm/WarDistribution.java b/java/com/google/gerrit/pgm/WarDistribution.java
index 257fb4e..013c850 100644
--- a/java/com/google/gerrit/pgm/WarDistribution.java
+++ b/java/com/google/gerrit/pgm/WarDistribution.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.pgm.init.InitPlugins.JAR;
 import static com.google.gerrit.pgm.init.InitPlugins.PLUGIN_DIR;
 
@@ -25,7 +26,6 @@
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
-import java.util.Enumeration;
 import java.util.List;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipFile;
@@ -39,9 +39,7 @@
     File myWar = GerritLauncher.getDistributionArchive();
     if (myWar.isFile()) {
       try (ZipFile zf = new ZipFile(myWar)) {
-        Enumeration<? extends ZipEntry> e = zf.entries();
-        while (e.hasMoreElements()) {
-          ZipEntry ze = e.nextElement();
+        for (ZipEntry ze : entriesOf(zf)) {
           if (ze.isDirectory()) {
             continue;
           }
@@ -65,4 +63,8 @@
     // not yet used
     throw new UnsupportedOperationException();
   }
+
+  private static Iterable<? extends ZipEntry> entriesOf(ZipFile zipFile) {
+    return zipFile.stream().collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
index 56523dd..e88bb88 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.pgm.http.jetty;
 
+import static com.google.gerrit.httpd.GitOverHttpServlet.GIT_COMMAND_STATUS_HEADER;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.httpd.GetUserFilter;
 import com.google.gerrit.httpd.RequestMetricsFilter;
@@ -55,6 +57,7 @@
   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;
 
@@ -117,6 +120,7 @@
     set(event, P_LATENCY, System.currentTimeMillis() - req.getTimeStamp());
     set(event, P_REFERER, req.getHeader("Referer"));
     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);
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
index 5f4ec43..54c587b 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm.http.jetty;
 
+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;
@@ -56,6 +57,7 @@
     public String memory;
     public String referer;
     public String userAgent;
+    public String commandStatus;
 
     public HttpJsonLogEntry(LoggingEvent event) {
       this.host = getMdcString(event, P_HOST);
@@ -73,6 +75,7 @@
       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 e9e6866..ddc1b5e 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
@@ -80,6 +80,9 @@
     buf.append(' ');
     opt(buf, event, HttpLog.P_MEMORY);
 
+    buf.append(' ');
+    dq_opt(buf, event, HttpLog.P_COMMAND_STATUS);
+
     buf.append('\n');
     return buf.toString();
   }
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index d9e3a6a..be5fe1a 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -110,7 +110,7 @@
       if (result != Result.NEW) {
         throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
       }
-      account.setMetaId(id.name()).build();
+      account.setMetaId(id.name());
     }
     return account.build();
   }
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
index 62c9526..5849711 100644
--- a/java/com/google/gerrit/pgm/init/BUILD
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -9,7 +9,6 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index c083296..b59b924 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -22,6 +22,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Die;
 import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.metrics.DisabledMetricMaker;
@@ -33,7 +34,6 @@
 import com.google.gerrit.pgm.init.api.LibraryDownload;
 import com.google.gerrit.pgm.init.index.IndexManagerOnInit;
 import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
-import com.google.gerrit.pgm.init.index.elasticsearch.ElasticIndexModuleOnInit;
 import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.config.GerritServerConfigModule;
@@ -125,7 +125,7 @@
         } catch (StorageException e) {
           String msg = "Couldn't upgrade schema. Expected if slave and read-only database";
           System.err.println(msg);
-          logger.atSevere().withCause(e).log(msg);
+          logger.atSevere().withCause(e).log("%s", msg);
         }
 
         init.initializer.postRun(sysInjector);
@@ -176,6 +176,7 @@
    */
   protected void afterInit(SiteRun run) throws Exception {}
 
+  @Nullable
   protected List<String> getInstallPlugins() {
     try {
       if (pluginsToInstall != null && pluginsToInstall.isEmpty()) {
@@ -305,6 +306,7 @@
     return ConsoleUI.getInstance(false);
   }
 
+  @Nullable
   private SecureStoreInitData discoverSecureStoreClass() {
     String secureStore = getSecureStoreLib();
     if (Strings.isNullOrEmpty(secureStore)) {
@@ -416,8 +418,6 @@
       IndexType indexType = IndexModule.getIndexType(dbInjector);
       if (indexType.isLucene()) {
         modules.add(new LuceneIndexModuleOnInit());
-      } else if (indexType.isElasticsearch()) {
-        modules.add(new ElasticIndexModuleOnInit());
       } else if (indexType.isFake()) {
         try {
           Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModuleOnInit");
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index e2a1f04..35892f2 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -41,17 +42,20 @@
   private final SitePaths site;
   private final AllUsersName allUsers;
   private final ExternalIdFactory externalIdFactory;
+  private final AuthConfig authConfig;
 
   @Inject
   public ExternalIdsOnInit(
       InitFlags flags,
       SitePaths site,
       AllUsersNameOnInitProvider allUsers,
-      ExternalIdFactory externalIdFactory) {
+      ExternalIdFactory externalIdFactory,
+      AuthConfig authConfig) {
     this.flags = flags;
     this.site = site;
     this.allUsers = new AllUsersName(allUsers.get());
     this.externalIdFactory = externalIdFactory;
+    this.authConfig = authConfig;
   }
 
   public synchronized void insert(String commitMessage, Collection<ExternalId> extIds)
@@ -60,7 +64,11 @@
     if (path != null) {
       try (Repository allUsersRepo = new FileRepository(path)) {
         ExternalIdNotes extIdNotes =
-            ExternalIdNotes.loadNoCacheUpdate(allUsers, allUsersRepo, externalIdFactory);
+            ExternalIdNotes.load(
+                allUsers,
+                allUsersRepo,
+                externalIdFactory,
+                authConfig.isUserNameCaseInsensitiveMigrationMode());
         extIdNotes.insert(extIds);
         try (MetaDataUpdate metaDataUpdate =
             new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, allUsersRepo)) {
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 2f12abb..f8fcadd 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -40,7 +40,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
@@ -165,7 +165,7 @@
     return AuditLogFormatter.createBackedBy(ImmutableSet.of(account), ImmutableSet.of(), serverId);
   }
 
-  private void commit(Repository repository, GroupConfig groupConfig, Timestamp groupCreatedOn)
+  private void commit(Repository repository, GroupConfig groupConfig, Instant groupCreatedOn)
       throws IOException {
     PersonIdent personIdent =
         new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), groupCreatedOn);
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index d6a0133..3dce974 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -17,6 +17,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.InternalGroup;
@@ -120,7 +121,7 @@
 
         Account persistedAccount =
             accounts.insert(
-                Account.builder(id, TimeUtil.nowTs()).setFullName(name).setPreferredEmail(email));
+                Account.builder(id, TimeUtil.now()).setFullName(name).setPreferredEmail(email));
         // Only two groups should exist at this point in time and hence iterating over all of them
         // is cheap.
         Optional<GroupReference> adminGroupReference =
@@ -182,6 +183,7 @@
     return email;
   }
 
+  @Nullable
   private AccountSshKey readSshKey(Account.Id id) throws IOException {
     String defaultPublicSshKeyFile = "";
     Path defaultPublicSshKeyPath = Paths.get(System.getProperty("user.home"), ".ssh", "id_rsa.pub");
diff --git a/java/com/google/gerrit/pgm/init/InitIndex.java b/java/com/google/gerrit/pgm/init/InitIndex.java
index 83d9261..a6254fd 100644
--- a/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -39,7 +39,6 @@
   private final SitePaths site;
   private final InitFlags initFlags;
   private final Section gerrit;
-  private final Section.Factory sections;
 
   @Inject
   InitIndex(ConsoleUI ui, Section.Factory sections, SitePaths site, InitFlags initFlags) {
@@ -48,7 +47,6 @@
     this.gerrit = sections.get("gerrit", null);
     this.site = site;
     this.initFlags = initFlags;
-    this.sections = sections;
   }
 
   @Override
@@ -58,13 +56,6 @@
         new IndexType(
             index.select("Type", "type", IndexType.getDefault(), IndexType.getKnownTypes()));
 
-    if (type.isElasticsearch()) {
-      Section elasticsearch = sections.get("elasticsearch", null);
-      elasticsearch.string("Index Prefix", "prefix", "gerrit_");
-      elasticsearch.string("Server", "server", "http://localhost:9200");
-      index.string("Result window size", "maxLimit", "10000");
-    }
-
     if ((site.isNew || isEmptySite()) && type.isLucene()) {
       for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
         IndexUtils.setReady(site, def.getName(), def.getLatest().getVersion(), true);
diff --git a/java/com/google/gerrit/pgm/init/InitLabels.java b/java/com/google/gerrit/pgm/init/InitLabels.java
index 3edc732..f862e12 100644
--- a/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -26,7 +26,7 @@
 
 @Singleton
 public class InitLabels implements InitStep {
-  private static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+  private static final String KEY_COPY_CONDITION = "copyCondition";
   private static final String KEY_LABEL = "label";
   private static final String KEY_FUNCTION = "function";
   private static final String KEY_VALUE = "value";
@@ -62,7 +62,11 @@
           LABEL_VERIFIED,
           KEY_VALUE,
           Arrays.asList("-1 Fails", "0 No score", "+1 Verified"));
-      cfg.setBoolean(KEY_LABEL, LABEL_VERIFIED, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, true);
+      cfg.setString(
+          KEY_LABEL,
+          LABEL_VERIFIED,
+          KEY_COPY_CONDITION,
+          "changekind:NO_CHANGE OR changekind:NO_CODE_CHANGE");
       allProjectsConfig.save("Configure 'Verified' label");
     }
   }
diff --git a/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
index a7f9c5d..16c4ce7 100644
--- a/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
+++ b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -62,6 +63,7 @@
     return pluginsInitSteps;
   }
 
+  @Nullable
   private InitStep loadInitStep(Path jar) {
     try {
       URLClassLoader pluginLoader =
diff --git a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index dffdde7..7666076 100644
--- a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -17,6 +17,7 @@
 import com.google.errorprone.annotations.FormatMethod;
 import com.google.errorprone.annotations.FormatString;
 import com.google.gerrit.common.Die;
+import com.google.gerrit.common.Nullable;
 import java.io.Console;
 import java.util.EnumSet;
 import java.util.Set;
@@ -179,6 +180,7 @@
 
     @Override
     @FormatMethod
+    @Nullable
     public String password(String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
       for (; ; ) {
diff --git a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
index 8e69eb9..fabad49 100644
--- a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
@@ -23,7 +23,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -65,7 +65,7 @@
   }
 
   @Override
-  public SortedSet<Project.NameKey> list() {
+  public NavigableSet<Project.NameKey> list() {
     throw new UnsupportedOperationException("not implemented");
   }
 
diff --git a/java/com/google/gerrit/pgm/init/api/InitUtil.java b/java/com/google/gerrit/pgm/init/api/InitUtil.java
index d038de7..7688728 100644
--- a/java/com/google/gerrit/pgm/init/api/InitUtil.java
+++ b/java/com/google/gerrit/pgm/init/api/InitUtil.java
@@ -18,6 +18,7 @@
 
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.common.Die;
+import com.google.gerrit.common.Nullable;
 import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.FileNotFoundException;
@@ -127,6 +128,7 @@
     }
   }
 
+  @Nullable
   private static InputStream open(Class<?> sibling, String name) {
     final InputStream in = sibling.getResourceAsStream(name);
     if (in == null) {
diff --git a/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
index b5d35f4..5cc4b5d 100644
--- a/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/java/com/google/gerrit/pgm/init/api/Section.java
@@ -166,6 +166,7 @@
     return nv;
   }
 
+  @Nullable
   public String password(String username, String password) {
     final String ov = getSecure(password);
 
diff --git a/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
index 80de1e5..d1d0729 100644
--- a/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
+++ b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
@@ -50,8 +50,7 @@
 
   @Override
   protected void configure() {
-    // The AccountIndex implementations (LuceneAccountIndex and
-    // ElasticAccountIndex) need AccountCache only for reading from the index.
+    // The LuceneAccountIndex needs AccountCache only for reading from the index.
     // On init we only want to write to the index, hence we don't need the
     // account cache.
     bind(AccountCache.class).toProvider(Providers.of(null));
@@ -63,8 +62,8 @@
 
     bind(AccountIndexCollection.class);
 
-    // The GroupIndex implementations (LuceneGroupIndex and ElasticGroupIndex)
-    // need GroupCache only for reading from the index. On init we only want to
+    // The LuceneGroupIndex needs GroupCache only for reading from the index. On init we only want
+    // to
     // write to the index, hence we don't need the group cache.
     bind(GroupCache.class).toProvider(Providers.of(null));
 
diff --git a/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
deleted file mode 100644
index f086ab1..0000000
--- a/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init.index.elasticsearch;
-
-import com.google.gerrit.elasticsearch.ElasticAccountIndex;
-import com.google.gerrit.elasticsearch.ElasticGroupIndex;
-import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.inject.AbstractModule;
-import com.google.inject.assistedinject.FactoryModuleBuilder;
-
-public class ElasticIndexModuleOnInit extends AbstractModule {
-
-  @Override
-  protected void configure() {
-    install(
-        new FactoryModuleBuilder()
-            .implement(AccountIndex.class, ElasticAccountIndex.class)
-            .build(AccountIndex.Factory.class));
-
-    install(
-        new FactoryModuleBuilder()
-            .implement(GroupIndex.class, ElasticGroupIndex.class)
-            .build(GroupIndex.Factory.class));
-
-    install(new IndexModuleOnInit());
-  }
-}
diff --git a/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
index 12a44dc..078e648 100644
--- a/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
+++ b/java/com/google/gerrit/pgm/init/index/lucene/LuceneIndexModuleOnInit.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.inject.AbstractModule;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 
@@ -36,5 +37,7 @@
             .build(GroupIndex.Factory.class));
 
     install(new IndexModuleOnInit());
+
+    bind(AutoFlush.class).toInstance(AutoFlush.DISABLED);
   }
 }
diff --git a/java/com/google/gerrit/pgm/rules/PrologCompiler.java b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
index 0a41db5..de08116 100644
--- a/java/com/google/gerrit/pgm/rules/PrologCompiler.java
+++ b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.pgm.rules;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.Version;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -182,6 +183,7 @@
     }
   }
 
+  @Nullable
   private String getMyClasspath() {
     StringBuilder cp = new StringBuilder();
     appendClasspath(cp, getClass().getClassLoader());
diff --git a/java/com/google/gerrit/pgm/util/BatchGitModule.java b/java/com/google/gerrit/pgm/util/BatchGitModule.java
index d39c2fd..fa585d3 100644
--- a/java/com/google/gerrit/pgm/util/BatchGitModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchGitModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.pgm.util;
 
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.git.GitModule;
@@ -24,6 +25,7 @@
 public class BatchGitModule extends FactoryModule {
   @Override
   protected void configure() {
+    DynamicSet.setOf(binder(), GitBatchRefUpdateListener.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     install(new GitModule());
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index b24fdad..5bffce7 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -28,6 +29,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
@@ -40,12 +42,12 @@
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
-import com.google.gerrit.server.approval.ApprovalCacheImpl;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.change.RebaseChangeOp;
@@ -56,18 +58,18 @@
 import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecordProvider;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
 import com.google.gerrit.server.config.SysExecutorModule;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.EventUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -81,11 +83,14 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.query.change.DistinctVotersPredicate;
+import com.google.gerrit.server.query.change.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
@@ -93,6 +98,7 @@
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Injector;
+import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
@@ -100,6 +106,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 
 /** Module for programs that perform batch operations on a site. */
 public class BatchProgramModule extends FactoryModule {
@@ -133,7 +140,7 @@
     // We're just running through each change
     // once, so don't worry about cache removal.
     bind(new TypeLiteral<DynamicSet<CacheRemovalListener>>() {}).toInstance(DynamicSet.emptySet());
-    bind(new TypeLiteral<DynamicMap<Cache<?, ?>>>() {}).toInstance(DynamicMap.emptyMap());
+    DynamicMap.mapOf(binder(), new TypeLiteral<Cache<?, ?>>() {});
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
@@ -148,9 +155,8 @@
         .in(SINGLETON);
     bind(Realm.class).to(FakeRealm.class);
     bind(IdentifiedUser.class).toProvider(Providers.of(null));
-    bind(ReplacePatchSetSender.Factory.class).toProvider(Providers.of(null));
-    bind(CurrentUser.class).to(IdentifiedUser.class);
-    factory(MergeUtil.Factory.class);
+    bind(EmailNewPatchSet.Factory.class).toProvider(Providers.of(null));
+    bind(CurrentUser.class).to(InternalUser.class);
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
 
@@ -176,7 +182,6 @@
     modules.add(new GroupModule());
     modules.add(new NoteDbModule());
     modules.add(AccountCacheImpl.module());
-    modules.add(ApprovalCacheImpl.module());
     modules.add(ConflictsCacheImpl.module());
     modules.add(DefaultPreferencesCacheImpl.module());
     modules.add(GroupCacheImpl.module());
@@ -193,6 +198,8 @@
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
+    factory(DistinctVotersPredicate.Factory.class);
+    factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(ProjectState.Factory.class);
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
@@ -202,20 +209,31 @@
     // Submit rules
     DynamicSet.setOf(binder(), SubmitRule.class);
     factory(SubmitRuleEvaluator.Factory.class);
-    modules.add(new PrologModule());
+    modules.add(new PrologModule(getConfig()));
     modules.add(new DefaultSubmitRuleModule());
     modules.add(new IgnoreSelfApprovalRuleModule());
 
+    // Global submit requirements
+    DynamicSet.setOf(binder(), SubmitRequirement.class);
+
+    factory(FileEditsPredicate.Factory.class);
+
     bind(ChangeJson.Factory.class).toProvider(Providers.of(null));
     bind(EventUtil.class).toProvider(Providers.of(null));
     bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
     bind(RevisionCreated.class).toInstance(RevisionCreated.DISABLED);
     bind(WorkInProgressStateChanged.class).toInstance(WorkInProgressStateChanged.DISABLED);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
+    bind(AttentionSetObserver.class).toInstance(AttentionSetObserver.DISABLED);
 
     ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(parentInjector, LibModuleType.SYS_BATCH_MODULE))
+            modules,
+            LibModuleLoader.loadModules(parentInjector, LibModuleType.SYS_BATCH_MODULE_TYPE))
         .stream()
         .forEach(this::install);
   }
+
+  protected Config getConfig() {
+    return parentInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+  }
 }
diff --git a/java/com/google/gerrit/pgm/util/ProxyUtil.java b/java/com/google/gerrit/pgm/util/ProxyUtil.java
index 3eb8187..c2c1141 100644
--- a/java/com/google/gerrit/pgm/util/ProxyUtil.java
+++ b/java/com/google/gerrit/pgm/util/ProxyUtil.java
@@ -59,7 +59,7 @@
       return;
     }
 
-    final URL u = new URL((!s.contains("://")) ? "http://" + s : s);
+    final URL u = new URL(!s.contains("://") ? "http://" + s : s);
     if (!"http".equals(u.getProtocol())) {
       throw new MalformedURLException("Invalid http_proxy: " + s + ": Only http supported.");
     }
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index ed6bce6..ff0b31e 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -137,7 +137,7 @@
       return Guice.createInjector(
           PRODUCTION,
           ModuleOverloader.override(
-              modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE)));
+              modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE_TYPE)));
     } catch (CreationException ce) {
       Message first = ce.getErrorMessages().iterator().next();
       Throwable why = first.getCause();
diff --git a/java/com/google/gerrit/prettify/BUILD b/java/com/google/gerrit/prettify/BUILD
index 0a15fda..a5c8b77 100644
--- a/java/com/google/gerrit/prettify/BUILD
+++ b/java/com/google/gerrit/prettify/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["common/**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/prettify/common/SparseFileContent.java b/java/com/google/gerrit/prettify/common/SparseFileContent.java
index 1249b65..f40222a 100644
--- a/java/com/google/gerrit/prettify/common/SparseFileContent.java
+++ b/java/com/google/gerrit/prettify/common/SparseFileContent.java
@@ -17,6 +17,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 
 /**
  * A class to store subset of a file's lines in a memory efficient way. Internally, it stores lines
@@ -134,6 +135,7 @@
       return getSize();
     }
 
+    @Nullable
     private String getLine(int idx) {
       // Most requests are sequential in nature, fetching the next
       // line from the current range, or the next range.
diff --git a/java/com/google/gerrit/server/AssigneeStatusUpdate.java b/java/com/google/gerrit/server/AssigneeStatusUpdate.java
index 3d6242b..812aad1 100644
--- a/java/com/google/gerrit/server/AssigneeStatusUpdate.java
+++ b/java/com/google/gerrit/server/AssigneeStatusUpdate.java
@@ -16,18 +16,18 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.entities.Account;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 
 /** Change to an assignee's status. */
 @AutoValue
 public abstract class AssigneeStatusUpdate {
   public static AssigneeStatusUpdate create(
-      Timestamp ts, Account.Id updatedBy, Optional<Account.Id> currentAssignee) {
+      Instant ts, Account.Id updatedBy, Optional<Account.Id> currentAssignee) {
     return new AutoValue_AssigneeStatusUpdate(ts, updatedBy, currentAssignee);
   }
 
-  public abstract Timestamp date();
+  public abstract Instant date();
 
   public abstract Account.Id updatedBy();
 
diff --git a/java/com/google/gerrit/server/AuditEvent.java b/java/com/google/gerrit/server/AuditEvent.java
index 773a307..54bbe23 100644
--- a/java/com/google/gerrit/server/AuditEvent.java
+++ b/java/com/google/gerrit/server/AuditEvent.java
@@ -82,6 +82,12 @@
     return uuid.hashCode();
   }
 
+  // This is a value class that allows adding attributes by subclassing.
+  // Doing this is discouraged and using composition rather than inheritance to add fields to value
+  // types is preferred. However this class is part of the plugin API (used in the AuditListener
+  // extension point), hence we cannot change it without breaking plugins. Hence suppress the
+  // EqualsGetClass warning here.
+  @SuppressWarnings("EqualsGetClass")
   @Override
   public boolean equals(Object obj) {
     if (this == obj) {
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 7080417..2be3383 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -104,6 +104,7 @@
         "//lib:servlet-api",
         "//lib:soy",
         "//lib:tukaani-xz",
+        "//lib/auto:auto-factory",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcpkix-neverlink",
@@ -111,15 +112,16 @@
         "//lib/commons:codec",
         "//lib/commons:compress",
         "//lib/commons:dbcp",
-        "//lib/commons:lang",
         "//lib/commons:lang3",
         "//lib/commons:net",
+        "//lib/commons:text",
         "//lib/commons:validator",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
+        "//lib/httpcomponents:httpclient",
         "//lib/jsoup",
         "//lib/log:log4j",
         "//lib/lucene:lucene-analyzers-common",
@@ -144,6 +146,7 @@
     visibility = ["//visibility:public"],
     deps = [
         ":server",
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 8366b09..81cff6e 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -145,7 +145,7 @@
     ChangeMessageInfo cmi = new ChangeMessageInfo();
     cmi.id = message.getKey().uuid();
     cmi.author = accountLoader.get(message.getAuthor());
-    cmi.date = message.getWrittenOn();
+    cmi.setDate(message.getWrittenOn());
     cmi.message = message.getMessage();
     cmi.tag = message.getTag();
     cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
diff --git a/java/com/google/gerrit/server/CmdLineParserModule.java b/java/com/google/gerrit/server/CmdLineParserModule.java
index d943889..be6b4cd8 100644
--- a/java/com/google/gerrit/server/CmdLineParserModule.java
+++ b/java/com/google/gerrit/server/CmdLineParserModule.java
@@ -23,17 +23,17 @@
 import com.google.gerrit.server.args4j.AccountGroupUUIDHandler;
 import com.google.gerrit.server.args4j.AccountIdHandler;
 import com.google.gerrit.server.args4j.ChangeIdHandler;
+import com.google.gerrit.server.args4j.InstantHandler;
 import com.google.gerrit.server.args4j.ObjectIdHandler;
 import com.google.gerrit.server.args4j.PatchSetIdHandler;
 import com.google.gerrit.server.args4j.ProjectHandler;
 import com.google.gerrit.server.args4j.SocketAddressHandler;
-import com.google.gerrit.server.args4j.TimestampHandler;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.OptionHandlerUtil;
 import com.google.gerrit.util.cli.OptionHandlers;
 import java.net.SocketAddress;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.spi.OptionHandler;
 
@@ -49,11 +49,11 @@
     registerOptionHandler(AccountGroup.Id.class, AccountGroupIdHandler.class);
     registerOptionHandler(AccountGroup.UUID.class, AccountGroupUUIDHandler.class);
     registerOptionHandler(Change.Id.class, ChangeIdHandler.class);
+    registerOptionHandler(Instant.class, InstantHandler.class);
     registerOptionHandler(ObjectId.class, ObjectIdHandler.class);
     registerOptionHandler(PatchSet.Id.class, PatchSetIdHandler.class);
     registerOptionHandler(ProjectState.class, ProjectHandler.class);
     registerOptionHandler(SocketAddress.class, SocketAddressHandler.class);
-    registerOptionHandler(Timestamp.class, TimestampHandler.class);
   }
 
   private <T> void registerOptionHandler(Class<T> type, Class<? extends OptionHandler<T>> impl) {
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 1d38877..285657e 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -44,12 +44,13 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -66,7 +67,7 @@
 @Singleton
 public class CommentsUtil {
   public static final Ordering<Comment> COMMENT_ORDER =
-      new Ordering<Comment>() {
+      new Ordering<>() {
         @Override
         public int compare(Comment c1, Comment c2) {
           return ComparisonChain.start()
@@ -80,7 +81,7 @@
       };
 
   public static final Ordering<CommentInfo> COMMENT_INFO_ORDER =
-      new Ordering<CommentInfo>() {
+      new Ordering<>() {
         @Override
         public int compare(CommentInfo a, CommentInfo b) {
           return ComparisonChain.start()
@@ -103,6 +104,7 @@
     return PatchSet.id(changeId, comment.key.patchSetId);
   }
 
+  @Nullable
   public static String extractMessageId(@Nullable String tag) {
     if (tag == null || !tag.startsWith("mailMessageId=")) {
       return null;
@@ -132,7 +134,7 @@
   public HumanComment newHumanComment(
       ChangeNotes changeNotes,
       CurrentUser currentUser,
-      Timestamp when,
+      Instant when,
       String path,
       PatchSet.Id psId,
       short side,
@@ -304,7 +306,7 @@
   }
 
   private static boolean isAfter(CommentInfo c, ChangeMessage cm) {
-    return c.updated.after(cm.getWrittenOn());
+    return c.getUpdated().isAfter(cm.getWrittenOn());
   }
 
   /**
@@ -337,7 +339,7 @@
   }
 
   public void putHumanComments(
-      ChangeUpdate update, HumanComment.Status status, Iterable<HumanComment> comments) {
+      ChangeUpdate update, Comment.Status status, Iterable<HumanComment> comments) {
     for (HumanComment c : comments) {
       update.putComment(status, c);
     }
@@ -440,7 +442,7 @@
       // unignore the test in PortedCommentsIT.
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchset.commitId(), /* parentNum= */ 0);
+              change.getProject(), patchset.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
       return modifiedFiles.isEmpty()
           ? null
           : modifiedFiles.values().iterator().next().oldCommitId();
diff --git a/java/com/google/gerrit/server/CommonConverters.java b/java/com/google/gerrit/server/CommonConverters.java
index 2b48169..4ad143b 100644
--- a/java/com/google/gerrit/server/CommonConverters.java
+++ b/java/com/google/gerrit/server/CommonConverters.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server;
 
 import com.google.gerrit.extensions.common.GitPerson;
-import java.sql.Timestamp;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /**
@@ -30,7 +29,7 @@
     GitPerson result = new GitPerson();
     result.name = ident.getName();
     result.email = ident.getEmailAddress();
-    result.date = new Timestamp(ident.getWhen().getTime());
+    result.setDate(ident.getWhenAsInstant());
     result.tz = ident.getTimeZoneOffset();
     return result;
   }
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index 6c76de7..60f3d4b 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -121,7 +121,7 @@
           });
 
       config.commit(md);
-      projectCache.evict(config.getProject());
+      projectCache.evictAndReindex(config.getProject());
     }
   }
 
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index db0aa70..7f9fbd2 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -225,7 +225,7 @@
     Class<?> beanClass =
         (bean instanceof BeanReceiver)
             ? ((BeanReceiver) bean).getExportedBeanReceiver()
-            : getClass();
+            : bean.getClass();
     for (String plugin : dynamicBeans.plugins()) {
       Provider<DynamicBean> provider =
           dynamicBeans.byPlugin(plugin).get(beanClass.getCanonicalName());
diff --git a/java/com/google/gerrit/server/ExceptionHookImpl.java b/java/com/google/gerrit/server/ExceptionHookImpl.java
index 3986842..781f196 100644
--- a/java/com/google/gerrit/server/ExceptionHookImpl.java
+++ b/java/com/google/gerrit/server/ExceptionHookImpl.java
@@ -18,7 +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.exceptions.MergeUpdateException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.project.ProjectConfig;
 import java.util.Optional;
@@ -74,7 +74,7 @@
               + "\n"
               + CONTACT_PROJECT_OWNER_USER_MESSAGE);
     }
-    if (throwable instanceof InternalServerWithUserMessageException) {
+    if (throwable instanceof MergeUpdateException) {
       return ImmutableList.of(throwable.getMessage());
     }
     return ImmutableList.of();
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index eb3e324..eda6e09 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -49,10 +49,10 @@
 import java.net.MalformedURLException;
 import java.net.SocketAddress;
 import java.net.URL;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Optional;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
@@ -100,30 +100,30 @@
           enablePeerIPInReflogRecord,
           Providers.of(null),
           state,
-          null);
+          /* realUser= */ null);
     }
 
     public IdentifiedUser create(Account.Id id) {
-      return create(null, id);
+      return create(/* remotePeer= */ null, id);
     }
 
     @VisibleForTesting
     @UsedAt(UsedAt.Project.GOOGLE)
     public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
-      return runAs(null, id, null, properties);
+      return runAs(/* remotePeer= */ null, id, /* caller= */ null, properties);
     }
 
-    public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
-      return runAs(remotePeer, id, null);
+    public IdentifiedUser create(@Nullable SocketAddress remotePeer, Account.Id id) {
+      return runAs(remotePeer, id, /* caller= */ null);
     }
 
     public IdentifiedUser runAs(
-        SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+        @Nullable SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
       return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
     }
 
     private IdentifiedUser runAs(
-        SocketAddress remotePeer,
+        @Nullable SocketAddress remotePeer,
         Account.Id id,
         @Nullable CurrentUser caller,
         PropertyMap properties) {
@@ -244,7 +244,7 @@
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       AccountState state,
       @Nullable CurrentUser realUser) {
     this(
@@ -270,7 +270,7 @@
       AccountCache accountCache,
       GroupBackend groupBackend,
       Boolean enablePeerIPInReflogRecord,
-      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
       @Nullable CurrentUser realUser,
       PropertyMap properties) {
@@ -427,10 +427,10 @@
   }
 
   public PersonIdent newRefLogIdent() {
-    return newRefLogIdent(new Date(), TimeZone.getDefault());
+    return newRefLogIdent(Instant.now(), ZoneId.systemDefault());
   }
 
-  public PersonIdent newRefLogIdent(Date when, TimeZone tz) {
+  public PersonIdent newRefLogIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
 
     String name = ua.fullName();
@@ -450,14 +450,19 @@
               ? constructMailAddress(ua, "unknown")
               : ua.preferredEmail();
     }
-    return new PersonIdent(name, user, when, tz);
+
+    return new PersonIdent(name, user, when, zoneId);
   }
 
   private String constructMailAddress(Account ua, String host) {
     return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
   }
 
-  public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
+  public PersonIdent newCommitterIdent(PersonIdent ident) {
+    return newCommitterIdent(ident.getWhenAsInstant(), ident.getZoneId());
+  }
+
+  public PersonIdent newCommitterIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
     String name = ua.fullName();
     String email = ua.preferredEmail();
@@ -492,7 +497,7 @@
       }
     }
 
-    return new PersonIdent(name, email, when, tz);
+    return new PersonIdent(name, email, when, zoneId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/LibModuleLoader.java b/java/com/google/gerrit/server/LibModuleLoader.java
index 0a6fb9f..5c0f8e4 100644
--- a/java/com/google/gerrit/server/LibModuleLoader.java
+++ b/java/com/google/gerrit/server/LibModuleLoader.java
@@ -18,12 +18,15 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.ProvisionException;
+import java.lang.reflect.Method;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
 /** Loads configured Guice modules from {@code gerrit.installModule}. */
@@ -37,6 +40,38 @@
         .collect(toList());
   }
 
+  public static List<Module> loadReindexModules(
+      Injector parent, Map<String, Integer> versions, int threads, boolean replica) {
+    Config cfg = getConfig(parent);
+    return Arrays.stream(
+            cfg.getStringList(
+                "gerrit", null, "install" + LibModuleType.INDEX_MODULE_TYPE.getConfigKey()))
+        .map(m -> createReindexModule(m, versions, threads, replica))
+        .collect(toList());
+  }
+
+  private static Module createReindexModule(
+      String className, Map<String, Integer> versions, int threads, boolean replica) {
+    Class<Module> clazz = loadModule(className);
+    try {
+
+      Method m =
+          clazz.getMethod(
+              "singleVersionWithExplicitVersions",
+              Map.class,
+              int.class,
+              boolean.class,
+              AutoFlush.class);
+
+      Module module = (Module) m.invoke(null, versions, threads, replica, AutoFlush.DISABLED);
+      logger.atInfo().log("Installed module %s", className);
+      return module;
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Unable to load libModule for %s", className);
+      throw new IllegalStateException(e);
+    }
+  }
+
   private static Config getConfig(Injector i) {
     return i.getInstance(Key.get(Config.class, GerritServerConfig.class));
   }
@@ -52,9 +87,7 @@
     try {
       return (Class<Module>) Class.forName(className);
     } catch (ClassNotFoundException | LinkageError e) {
-      String msg = "Cannot load LibModule " + className;
-      logger.atSevere().withCause(e).log(msg);
-      throw new ProvisionException(msg, e);
+      throw new ProvisionException("Cannot load LibModule " + className, e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/LibModuleType.java b/java/com/google/gerrit/server/LibModuleType.java
index b9cb196..57206aa 100644
--- a/java/com/google/gerrit/server/LibModuleType.java
+++ b/java/com/google/gerrit/server/LibModuleType.java
@@ -18,13 +18,16 @@
 public enum LibModuleType {
 
   /** Module for the sysInjector. */
-  SYS_MODULE("Module"),
+  SYS_MODULE_TYPE("Module"),
 
   /** BatchModule for the sysInjector */
-  SYS_BATCH_MODULE("BatchModule"),
+  SYS_BATCH_MODULE_TYPE("BatchModule"),
 
   /** Module for the dbInjector. */
-  DB_MODULE("DbModule");
+  DB_MODULE_TYPE("DbModule"),
+
+  /** Module for the implementation of the indexing backend. */
+  INDEX_MODULE_TYPE("IndexModule");
 
   private final String configKey;
 
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 326ddf4..2962108 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -35,12 +35,13 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.RepoContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
@@ -106,7 +107,7 @@
         .id(psId)
         .commitId(commit)
         .uploader(update.getAccountId())
-        .createdOn(new Timestamp(update.getWhen().getTime()))
+        .createdOn(update.getWhen())
         .groups(groups)
         .pushCertificate(Optional.ofNullable(pushCertificate))
         .description(Optional.ofNullable(description))
@@ -170,4 +171,54 @@
       return src;
     }
   }
+
+  /**
+   * Gets the commit ID for the latest patch-set of a given change.
+   *
+   * <p>This also takes into account the patch sets that are added in the provided {@link
+   * RepoContext}.
+   *
+   * @param ctx to look for pending updates in.
+   * @param notesFactory to fetch existing patch sets with.
+   * @param changeId to get the latest commit for.
+   * @return the latest commit ID.
+   * @throws IOException if no committed nor pending commits found for the change.
+   */
+  public static RevCommit getCurrentRevCommitIncludingPending(
+      RepoContext ctx, ChangeNotes.Factory notesFactory, Change.Id changeId) throws IOException {
+    Map<String, ObjectId> refUpdates = ctx.getRepoView().getRefs(changeId.toRefPrefix());
+    refUpdates.remove("meta");
+    if (!refUpdates.isEmpty()) {
+      Optional<PatchSet.Id> latestPendingPatchSet =
+          refUpdates.keySet().stream()
+              .map(r -> PatchSet.Id.fromRef(changeId.toRefPrefix() + r))
+              .max(PatchSet.Id::compareTo);
+      if (latestPendingPatchSet.isPresent()) {
+        return ctx.getRevWalk().parseCommit(refUpdates.get(latestPendingPatchSet.get().getId()));
+      }
+    }
+    return getCurrentCommittedRevCommit(ctx.getProject(), ctx.getRevWalk(), notesFactory, changeId);
+  }
+
+  /**
+   * Gets the commit ID for the latest committed patch-set of a given change.
+   *
+   * <p>This DOES NOT take into account the patch sets that are added in the provided {@link
+   * RepoContext}.
+   *
+   * @param project name.
+   * @param notesFactory to fetch existing patch sets with.
+   * @param changeId to get the latest commit for.
+   * @return the latest commit ID.
+   * @throws IOException if no committed commits found for the change.
+   */
+  public static RevCommit getCurrentCommittedRevCommit(
+      Project.NameKey project,
+      RevWalk revWalk,
+      ChangeNotes.Factory notesFactory,
+      Change.Id changeId)
+      throws IOException {
+    ChangeNotes notes = notesFactory.createChecked(project, changeId);
+    return revWalk.parseCommit(notes.getCurrentPatchSet().commitId());
+  }
 }
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 4d19dd0..de5f023 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.sql.Timestamp;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Map;
@@ -90,7 +91,7 @@
             draftComment, psIdOfDraftComment, notes.getProjectName());
         continue;
       }
-      draftComment.writtenOn = ctx.getWhen();
+      draftComment.writtenOn = Timestamp.from(ctx.getWhen());
       draftComment.tag = tag;
       // Draft may have been created by a different real user; copy the current real user. (Only
       // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 84afe8c..830928a 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -15,15 +15,14 @@
 package com.google.gerrit.server;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -32,13 +31,12 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.update.RepoView;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * A {@link BatchUpdateOp} that can be used to publish draft comments
@@ -52,15 +50,14 @@
   private final CommentAdded commentAdded;
   private final CommentsUtil commentsUtil;
   private final EmailReviewComments.Factory email;
-  private final List<LabelVote> labelDelta = new ArrayList<>();
   private final Project.NameKey projectNameKey;
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
   private final ChangeMessagesUtil changeMessagesUtil;
 
+  private ObjectId preUpdateMetaId;
   private List<HumanComment> comments = new ArrayList<>();
   private String mailMessage;
-  private IdentifiedUser user;
 
   public interface Factory {
     PublishCommentsOp create(PatchSet.Id psId, Project.NameKey projectNameKey);
@@ -92,7 +89,7 @@
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, UnprocessableEntityException, IOException,
           PatchListNotAvailableException, CommentsRejectedException {
-    user = ctx.getIdentifiedUser();
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     comments = commentsUtil.draftByChangeAuthor(ctx.getNotes(), ctx.getUser().getAccountId());
 
     // PublishCommentsOp should update a separate ChangeUpdate Object than the one used by other ops
@@ -103,7 +100,7 @@
     //   2. Each ChangeUpdate results in 1 commit in NoteDb
     // We do it this way so that the execution results in 2 different commits in NoteDb
     ChangeUpdate changeUpdate = ctx.getDistinctUpdate(psId);
-    publishCommentUtil.publish(ctx, changeUpdate, comments, null);
+    publishCommentUtil.publish(ctx, changeUpdate, comments, /* tag= */ null);
     return insertMessage(changeUpdate);
   }
 
@@ -114,29 +111,16 @@
     }
     ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
     PatchSet ps = psUtil.get(changeNotes, psId);
-    NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
-    if (notify.shouldNotify()) {
-      RepoView repoView;
-      try {
-        repoView = ctx.getRepoView();
-      } catch (IOException ex) {
-        throw new StorageException(
-            String.format("Repository %s not found", ctx.getProject().get()), ex);
-      }
-      email
-          .create(
-              notify,
-              changeNotes,
-              ps,
-              user,
-              mailMessage,
-              ctx.getWhen(),
-              comments,
-              null,
-              labelDelta,
-              repoView)
-          .sendAsync();
-    }
+    email
+        .create(
+            ctx,
+            ps,
+            preUpdateMetaId,
+            mailMessage,
+            comments,
+            /* patchSetComment= */ null,
+            /* labels= */ ImmutableList.of())
+        .sendAsync();
     commentAdded.fire(
         ctx.getChangeData(changeNotes),
         ps,
@@ -159,7 +143,7 @@
     }
     mailMessage =
         changeMessagesUtil.setChangeMessage(
-            changeUpdate, "Patch Set " + psId.get() + ":" + buf, null);
+            changeUpdate, "Patch Set " + psId.get() + ":" + buf, /* tag= */ null);
     return true;
   }
 }
diff --git a/java/com/google/gerrit/server/RequestCleanup.java b/java/com/google/gerrit/server/RequestCleanup.java
index e07d148..1d421ed 100644
--- a/java/com/google/gerrit/server/RequestCleanup.java
+++ b/java/com/google/gerrit/server/RequestCleanup.java
@@ -16,8 +16,8 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.inject.servlet.RequestScoped;
+import java.util.ArrayList;
 import java.util.Iterator;
-import java.util.LinkedList;
 import java.util.List;
 
 /** Registers cleanup activities to be completed when a scope ends. */
@@ -25,7 +25,7 @@
 public class RequestCleanup implements Runnable {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final List<Runnable> cleanup = new LinkedList<>();
+  private final List<Runnable> cleanup = new ArrayList<>();
   private boolean ran;
 
   /** Register a task to be completed after the request ends. */
diff --git a/java/com/google/gerrit/server/ReviewerByEmailSet.java b/java/com/google/gerrit/server/ReviewerByEmailSet.java
index 4a317c3..c6ba7b5 100644
--- a/java/com/google/gerrit/server/ReviewerByEmailSet.java
+++ b/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -19,7 +19,7 @@
 import com.google.common.collect.Table;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /**
  * Set of reviewers on a change that do not have a Gerrit account and were added by email instead.
@@ -30,8 +30,7 @@
 public class ReviewerByEmailSet {
   private static final ReviewerByEmailSet EMPTY = new ReviewerByEmailSet(ImmutableTable.of());
 
-  public static ReviewerByEmailSet fromTable(
-      Table<ReviewerStateInternal, Address, Timestamp> table) {
+  public static ReviewerByEmailSet fromTable(Table<ReviewerStateInternal, Address, Instant> table) {
     return new ReviewerByEmailSet(table);
   }
 
@@ -39,10 +38,10 @@
     return EMPTY;
   }
 
-  private final ImmutableTable<ReviewerStateInternal, Address, Timestamp> table;
+  private final ImmutableTable<ReviewerStateInternal, Address, Instant> table;
   private ImmutableSet<Address> users;
 
-  private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Timestamp> table) {
+  private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Instant> table) {
     this.table = ImmutableTable.copyOf(table);
   }
 
@@ -58,7 +57,7 @@
     return table.row(state).keySet();
   }
 
-  public ImmutableTable<ReviewerStateInternal, Address, Timestamp> asTable() {
+  public ImmutableTable<ReviewerStateInternal, Address, Instant> asTable() {
     return table;
   }
 
diff --git a/java/com/google/gerrit/server/ReviewerSet.java b/java/com/google/gerrit/server/ReviewerSet.java
index 0f6bf29..0ff68e0 100644
--- a/java/com/google/gerrit/server/ReviewerSet.java
+++ b/java/com/google/gerrit/server/ReviewerSet.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /**
  * Set of reviewers on a change.
@@ -38,7 +38,7 @@
 
   public static ReviewerSet fromApprovals(Iterable<PatchSetApproval> approvals) {
     PatchSetApproval first = null;
-    Table<ReviewerStateInternal, Account.Id, Timestamp> reviewers = HashBasedTable.create();
+    Table<ReviewerStateInternal, Account.Id, Instant> reviewers = HashBasedTable.create();
     for (PatchSetApproval psa : approvals) {
       if (first == null) {
         first = psa;
@@ -58,7 +58,7 @@
     return new ReviewerSet(reviewers);
   }
 
-  public static ReviewerSet fromTable(Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
+  public static ReviewerSet fromTable(Table<ReviewerStateInternal, Account.Id, Instant> table) {
     return new ReviewerSet(table);
   }
 
@@ -66,10 +66,10 @@
     return EMPTY;
   }
 
-  private final ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp> table;
+  private final ImmutableTable<ReviewerStateInternal, Account.Id, Instant> table;
   private ImmutableSet<Account.Id> accounts;
 
-  private ReviewerSet(Table<ReviewerStateInternal, Account.Id, Timestamp> table) {
+  private ReviewerSet(Table<ReviewerStateInternal, Account.Id, Instant> table) {
     this.table = ImmutableTable.copyOf(table);
   }
 
@@ -85,7 +85,7 @@
     return table.row(state).keySet();
   }
 
-  public ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp> asTable() {
+  public ImmutableTable<ReviewerStateInternal, Account.Id, Instant> asTable() {
     return table;
   }
 
diff --git a/java/com/google/gerrit/server/ReviewerStatusUpdate.java b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
index 938d985..1e0aa43 100644
--- a/java/com/google/gerrit/server/ReviewerStatusUpdate.java
+++ b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
@@ -17,17 +17,17 @@
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Change to a reviewer's status. */
 @AutoValue
 public abstract class ReviewerStatusUpdate {
   public static ReviewerStatusUpdate create(
-      Timestamp ts, Account.Id updatedBy, Account.Id reviewer, ReviewerStateInternal state) {
+      Instant ts, Account.Id updatedBy, Account.Id reviewer, ReviewerStateInternal state) {
     return new AutoValue_ReviewerStatusUpdate(ts, updatedBy, reviewer, state);
   }
 
-  public abstract Timestamp date();
+  public abstract Instant date();
 
   public abstract Account.Id updatedBy();
 
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 7c61c92..8f413f9 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -23,7 +23,6 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
@@ -32,32 +31,25 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.GitUpdateFailureException;
 import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.server.change.ChangeResource;
 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.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndexer;
 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.project.NoSuchChangeException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
+import java.util.Collections;
+import java.util.NavigableSet;
+import java.util.Objects;
 import java.util.Set;
-import java.util.SortedSet;
 import java.util.TreeSet;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
@@ -82,6 +74,7 @@
   public abstract static class StarField {
     private static final String SEPARATOR = ":";
 
+    @Nullable
     public static StarField parse(String s) {
       int p = s.indexOf(SEPARATOR);
       if (p >= 0) {
@@ -110,10 +103,15 @@
     }
   }
 
+  public enum Operation {
+    ADD,
+    REMOVE
+  }
+
   @AutoValue
   public abstract static class StarRef {
     private static final StarRef MISSING =
-        new AutoValue_StarredChangesUtil_StarRef(null, ImmutableSortedSet.of());
+        new AutoValue_StarredChangesUtil_StarRef(null, Collections.emptyNavigableSet());
 
     private static StarRef create(Ref ref, Iterable<String> labels) {
       return new AutoValue_StarredChangesUtil_StarRef(
@@ -123,7 +121,7 @@
     @Nullable
     public abstract Ref ref();
 
-    public abstract ImmutableSortedSet<String> labels();
+    public abstract NavigableSet<String> labels();
 
     public ObjectId objectId() {
       return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
@@ -158,34 +156,25 @@
   }
 
   public static final String DEFAULT_LABEL = "star";
-  public static final String IGNORE_LABEL = "ignore";
-  public static final ImmutableSortedSet<String> DEFAULT_LABELS =
-      ImmutableSortedSet.of(DEFAULT_LABEL);
 
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsers;
   private final Provider<PersonIdent> serverIdent;
-  private final ChangeIndexer indexer;
-  private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
   StarredChangesUtil(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsers,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      ChangeIndexer indexer,
-      Provider<InternalChangeQuery> queryProvider) {
+      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
     this.repoManager = repoManager;
     this.gitRefUpdated = gitRefUpdated;
     this.allUsers = allUsers;
     this.serverIdent = serverIdent;
-    this.indexer = indexer;
-    this.queryProvider = queryProvider;
   }
 
-  public ImmutableSortedSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
+  public NavigableSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)).labels();
     } catch (IOException e) {
@@ -197,34 +186,27 @@
     }
   }
 
-  public ImmutableSortedSet<String> star(
-      Account.Id accountId,
-      Project.NameKey project,
-      Change.Id changeId,
-      Set<String> labelsToAdd,
-      Set<String> labelsToRemove)
+  public void star(Account.Id accountId, Change.Id changeId, Operation op)
       throws IllegalLabelException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
       StarRef old = readLabels(repo, refName);
 
-      Set<String> labels = new HashSet<>(old.labels());
-      if (labelsToAdd != null) {
-        labels.addAll(labelsToAdd);
-      }
-      if (labelsToRemove != null) {
-        labels.removeAll(labelsToRemove);
+      NavigableSet<String> labels = new TreeSet<>(old.labels());
+      switch (op) {
+        case ADD:
+          labels.add(DEFAULT_LABEL);
+          break;
+        case REMOVE:
+          labels.remove(DEFAULT_LABEL);
+          break;
       }
 
       if (labels.isEmpty()) {
         deleteRef(repo, refName, old.objectId());
       } else {
-        checkMutuallyExclusiveLabels(labels);
         updateLabels(repo, refName, old.objectId(), labels);
       }
-
-      indexer.index(project, changeId);
-      return ImmutableSortedSet.copyOf(labels);
     } catch (IOException e) {
       throw new StorageException(
           String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
@@ -248,7 +230,7 @@
       batchUpdate.setAllowNonFastForwards(true);
       batchUpdate.setRefLogIdent(serverIdent.get());
       batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
-      for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
+      for (Account.Id accountId : getStars(repo, changeId)) {
         String refName = RefNames.refsStarredChanges(changeId, accountId);
         Ref ref = repo.getRefDatabase().exactRef(refName);
         if (ref != null) {
@@ -274,12 +256,7 @@
   public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
-      for (String refPart : getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId))) {
-        Integer id = Ints.tryParse(refPart);
-        if (id == null) {
-          continue;
-        }
-        Account.Id accountId = Account.id(id);
+      for (Account.Id accountId : getStars(repo, changeId)) {
         builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
       }
       return builder.build();
@@ -318,22 +295,15 @@
     }
   }
 
-  public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId) {
-    List<ChangeData> changeData =
-        queryProvider
-            .get()
-            .setRequestedFields(ChangeField.ID, ChangeField.STAR)
-            .byLegacyChangeId(changeId);
-    if (changeData.size() != 1) {
-      throw new NoSuchChangeException(changeId);
-    }
-    return changeData.get(0).stars();
-  }
-
-  private static Set<String> getRefNames(Repository repo, String prefix) throws IOException {
-    RefDatabase refDb = repo.getRefDatabase();
+  private static Set<Account.Id> getStars(Repository allUsers, Change.Id changeId)
+      throws IOException {
+    String prefix = RefNames.refsStarredChangesPrefix(changeId);
+    RefDatabase refDb = allUsers.getRefDatabase();
     return refDb.getRefsByPrefix(prefix).stream()
         .map(r -> r.getName().substring(prefix.length()))
+        .map(refPart -> Ints.tryParse(refPart))
+        .filter(Objects::nonNull)
+        .map(id -> Account.id(id))
         .collect(toSet());
   }
 
@@ -349,49 +319,29 @@
     }
   }
 
-  public void ignore(ChangeResource rsrc) throws IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(IGNORE_LABEL),
-        ImmutableSet.of());
-  }
-
-  public void unignore(ChangeResource rsrc) throws IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(),
-        ImmutableSet.of(IGNORE_LABEL));
-  }
-
-  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) {
-    return getLabels(accountId, changeId).contains(IGNORE_LABEL);
-  }
-
-  public boolean isIgnored(ChangeResource rsrc) {
-    return isIgnoredBy(rsrc.getChange().getId(), rsrc.getUser().asIdentifiedUser().getAccountId());
-  }
-
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
             "Read star labels", Metadata.builder().noteDbRefName(refName).build())) {
       Ref ref = repo.exactRef(refName);
-      if (ref == null) {
-        return StarRef.MISSING;
-      }
+      return readLabels(repo, ref);
+    }
+  }
 
-      try (ObjectReader reader = repo.newObjectReader()) {
-        ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
-        return StarRef.create(
-            ref,
-            Splitter.on(CharMatcher.whitespace())
-                .omitEmptyStrings()
-                .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
-      }
+  public static StarRef readLabels(Repository repo, Ref ref) throws IOException {
+    if (ref == null) {
+      return StarRef.MISSING;
+    }
+    try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                String.format("Read star labels from %s (without ref lookup)", ref.getName()));
+        ObjectReader reader = repo.newObjectReader()) {
+      ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
+      return StarRef.create(
+          ref,
+          Splitter.on(CharMatcher.whitespace())
+              .omitEmptyStrings()
+              .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
     }
   }
 
@@ -408,19 +358,12 @@
     }
   }
 
-  private static void checkMutuallyExclusiveLabels(Set<String> labels)
-      throws MutuallyExclusiveLabelsException {
-    if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
-      throw new MutuallyExclusiveLabelsException(DEFAULT_LABEL, IGNORE_LABEL);
-    }
-  }
-
   private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
     if (labels == null) {
       return;
     }
 
-    SortedSet<String> invalidLabels = new TreeSet<>();
+    NavigableSet<String> invalidLabels = new TreeSet<>();
     for (String label : labels) {
       if (CharMatcher.whitespace().matchesAnyOf(label)) {
         invalidLabels.add(label);
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 3c69573..58396f5 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -39,23 +39,11 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.function.Function;
-import java.util.function.Predicate;
 
 @Singleton
 public class WebLinks {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final Predicate<WebLinkInfo> INVALID_WEBLINK =
-      link -> {
-        if (link == null) {
-          return false;
-        } else if (Strings.isNullOrEmpty(link.name) || Strings.isNullOrEmpty(link.url)) {
-          logger.atWarning().log("%s is missing name and/or url", link.getClass().getName());
-          return false;
-        }
-        return true;
-      };
-
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
   private final DynamicSet<ResolveConflictsWebLink> resolveConflictsLinks;
   private final DynamicSet<ParentWebLink> parentLinks;
@@ -98,12 +86,19 @@
    * @param commit SHA1 of commit.
    * @param commitMessage the commit message of the commit.
    * @param branchName branch of the commit.
+   * @param changeKey change Identifier for this change
    */
   public ImmutableList<WebLinkInfo> getPatchSetLinks(
-      Project.NameKey project, String commit, String commitMessage, String branchName) {
+      Project.NameKey project,
+      String commit,
+      String commitMessage,
+      String branchName,
+      String changeKey) {
     return filterLinks(
         patchSetLinks,
-        webLink -> webLink.getPatchSetWebLink(project.get(), commit, commitMessage, branchName));
+        webLink ->
+            webLink.getPatchSetWebLink(
+                project.get(), commit, commitMessage, branchName, changeKey));
   }
 
   /**
@@ -154,13 +149,15 @@
    * Returns links for files
    *
    * @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.
    */
-  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));
   }
 
   /**
@@ -177,7 +174,7 @@
     }
     return Streams.stream(fileHistoryLinks)
         .map(webLink -> webLink.getFileHistoryWebLink(project, revision, file))
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
 
@@ -216,7 +213,7 @@
                     patchSetIdB,
                     revisionB,
                     fileB))
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
 
@@ -253,7 +250,17 @@
       DynamicSet<T> links, Function<T, WebLinkInfo> transformer) {
     return Streams.stream(links)
         .map(transformer)
-        .filter(INVALID_WEBLINK)
+        .filter(WebLinks::isValid)
         .collect(toImmutableList());
   }
+
+  private static boolean isValid(WebLinkInfo link) {
+    if (link == null) {
+      return false;
+    } else if (Strings.isNullOrEmpty(link.name) || Strings.isNullOrEmpty(link.url)) {
+      logger.atWarning().log("%s is missing name and/or url", link.getClass().getName());
+      return false;
+    }
+    return true;
+  }
 }
diff --git a/java/com/google/gerrit/server/account/AccountAttributeLoader.java b/java/com/google/gerrit/server/account/AccountAttributeLoader.java
new file mode 100644
index 0000000..ae57941
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountAttributeLoader.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.Map;
+
+public class AccountAttributeLoader {
+
+  public interface Factory {
+    AccountAttributeLoader create();
+  }
+
+  private final InternalAccountDirectory directory;
+  private final Map<Account.Id, AccountAttribute> created = new HashMap<>();
+
+  @Inject
+  AccountAttributeLoader(InternalAccountDirectory directory) {
+    this.directory = directory;
+  }
+
+  @Nullable
+  public synchronized AccountAttribute get(@Nullable Account.Id id) {
+    if (id == null) {
+      return null;
+    }
+    return created.computeIfAbsent(id, k -> new AccountAttribute(k.get()));
+  }
+
+  public void fill() {
+    directory.fillAccountAttributeInfo(created.values());
+  }
+}
diff --git a/java/com/google/gerrit/server/account/AccountCache.java b/java/com/google/gerrit/server/account/AccountCache.java
index 54bfa56..5ae7345 100644
--- a/java/com/google/gerrit/server/account/AccountCache.java
+++ b/java/com/google/gerrit/server/account/AccountCache.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.Account;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Caches important (but small) account state to avoid database hits. */
 public interface AccountCache {
@@ -72,4 +75,18 @@
    *     exists or if loading the external ID fails {@link Optional#empty()} is returned
    */
   Optional<AccountState> getByUsername(String username);
+
+  /**
+   * Returns an {@code AccountState} instance for the given account ID at the given {@code metaId}
+   * of {@link com.google.gerrit.entities.RefNames#refsUsers} ref.
+   *
+   * <p>The caller is responsible to ensure the presence of {@code metaId} and the corresponding
+   * meta ref. The method does not populate {@link AccountState#defaultPreferences}.
+   *
+   * @param accountId ID of the account that should be retrieved.
+   * @param metaId the sha1 of commit in {@link com.google.gerrit.entities.RefNames#refsUsers} ref.
+   * @return {@code AccountState} instance for the given account ID at specific sha1 {@code metaId}.
+   */
+  @UsedAt(Project.GOOGLE)
+  AccountState getFromMetaId(Account.Id accountId, ObjectId metaId);
 }
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 093af68..66a36f6 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -45,6 +45,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -106,6 +107,18 @@
   }
 
   @Override
+  public AccountState getFromMetaId(Account.Id id, ObjectId metaId) {
+    try {
+      CachedAccountDetails.Key key = CachedAccountDetails.Key.create(id, metaId);
+
+      CachedAccountDetails accountDetails = accountDetailsCache.get(key);
+      return AccountState.forCachedAccount(accountDetails, CachedPreferences.EMPTY, externalIds);
+    } catch (IOException | ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
     try {
       try (Repository allUsers = repoManager.openRepository(allUsersName)) {
@@ -153,7 +166,7 @@
   }
 
   private AccountState missing(Account.Id accountId) {
-    Account.Builder account = Account.builder(accountId, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(accountId, TimeUtil.now());
     account.setActive(false);
     return AccountState.forAccount(account.build());
   }
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 45f1f35..4143f77 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -34,7 +34,7 @@
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -177,7 +177,7 @@
    * @throws DuplicateKeyException if the user branch already exists
    */
   public Account getNewAccount() throws DuplicateKeyException {
-    return getNewAccount(TimeUtil.nowTs());
+    return getNewAccount(TimeUtil.now());
   }
 
   /**
@@ -186,7 +186,7 @@
    * @return the new account
    * @throws DuplicateKeyException if the user branch already exists
    */
-  Account getNewAccount(Timestamp registeredOn) throws DuplicateKeyException {
+  Account getNewAccount(Instant registeredOn) throws DuplicateKeyException {
     checkLoaded();
     if (revision != null) {
       throw new DuplicateKeyException(String.format("account %s already exists", accountId));
@@ -216,7 +216,7 @@
       rw.reset();
       rw.markStart(revision);
       rw.sort(RevSort.REVERSE);
-      Timestamp registeredOn = new Timestamp(rw.next().getCommitTime() * 1000L);
+      Instant registeredOn = Instant.ofEpochMilli(rw.next().getCommitTime() * 1000L);
 
       Config accountConfig = readConfig(AccountProperties.ACCOUNT_CONFIG);
       loadedAccountProperties =
@@ -274,7 +274,7 @@
         commit.setMessage("Create account\n");
       }
 
-      Timestamp registeredOn = loadedAccountProperties.get().getRegisteredOn();
+      Instant registeredOn = loadedAccountProperties.get().getRegisteredOn();
       commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
       commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
     }
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index 3f7f3f2..ca63565 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -83,12 +82,12 @@
      * accounts.
      */
     @UsedAt(UsedAt.Project.PLUGIN_CODE_OWNERS)
-    public AccountControl get(IdentifiedUser identifiedUser) {
+    public AccountControl get(CurrentUser user) {
       return new AccountControl(
           permissionBackend,
           projectCache,
           groupControlFactory,
-          identifiedUser,
+          user,
           userFactory,
           accountVisibility);
     }
@@ -177,7 +176,7 @@
       logger.atFine().log(
           "user %s can see own account %d", user.getLoggableName(), otherUser.getId().get());
       return true;
-    } else if (viewAll()) {
+    } else if (canViewAll()) {
       logger.atFine().log(
           "user %s can see account %d (view all accounts = true)",
           user.getLoggableName(), otherUser.getId().get());
@@ -256,13 +255,10 @@
     }
   }
 
-  private boolean viewAll() {
+  public boolean canViewAll() {
     if (viewAll == null) {
       try {
-        perm.check(GlobalPermission.VIEW_ALL_ACCOUNTS);
-        viewAll = true;
-      } catch (AuthException e) {
-        viewAll = false;
+        viewAll = perm.test(GlobalPermission.VIEW_ALL_ACCOUNTS);
       } catch (PermissionBackendException e) {
         logger.atFine().withCause(e).log(
             "Failed to check %s global capability for user %s",
diff --git a/java/com/google/gerrit/server/account/AccountDeactivator.java b/java/com/google/gerrit/server/account/AccountDeactivator.java
index c7f6496..a4d7608 100644
--- a/java/com/google/gerrit/server/account/AccountDeactivator.java
+++ b/java/com/google/gerrit/server/account/AccountDeactivator.java
@@ -117,7 +117,7 @@
         return true;
       }
     } catch (ResourceConflictException e) {
-      logger.atInfo().log("Account %s already deactivated, continuing...", userName);
+      logger.atInfo().withCause(e).log("Account %s already deactivated, continuing...", userName);
     } catch (Exception e) {
       logger.atSevere().withCause(e).log(
           "Error deactivating account: %s (%s) %s",
diff --git a/java/com/google/gerrit/server/account/AccountDirectory.java b/java/com/google/gerrit/server/account/AccountDirectory.java
index 98b2ca9..10aecd3 100644
--- a/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.util.Set;
 
@@ -24,7 +25,7 @@
  * <p>Implementations supply data to Gerrit about user accounts.
  */
 public abstract class AccountDirectory {
-  /** Fields to be populated for a REST API response. */
+  /** Fields to be populated for SSH or REST API response. */
   public enum FillOptions {
     /** Full name or username. */
     NAME,
@@ -59,4 +60,6 @@
 
   public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
       throws PermissionBackendException;
+
+  public abstract void fillAccountAttributeInfo(Iterable<? extends AccountAttribute> in);
 }
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index 5549d28..d97563a 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.PermissionRange;
 import com.google.gerrit.entities.PermissionRule;
@@ -105,6 +106,7 @@
   }
 
   /** The range of permitted values associated with a label permission. */
+  @Nullable
   public PermissionRange getRange(String permission) {
     if (GlobalCapability.hasRange(permission)) {
       return toRange(permission, getRules(permission));
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 407d2f7..891a467 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -264,14 +264,16 @@
     }
 
     if (!accountUpdates.isEmpty()) {
-      accountsUpdateProvider
-          .get()
-          .update(
-              "Update Account on Login",
-              user.getAccountId(),
-              AccountsUpdate.joinConsumers(accountUpdates))
-          .orElseThrow(
-              () -> new StorageException("Account " + user.getAccountId() + " has been deleted"));
+      Optional<AccountState> updatedAccount =
+          accountsUpdateProvider
+              .get()
+              .update(
+                  "Update Account on Login",
+                  user.getAccountId(),
+                  AccountsUpdate.joinConsumers(accountUpdates));
+      if (!updatedAccount.isPresent()) {
+        throw new StorageException("Account " + user.getAccountId() + " has been deleted");
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 9b7ca81..928d851 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -17,7 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -57,16 +57,13 @@
   public static final String KEY_STATUS = "status";
 
   private final Account.Id accountId;
-  private final Timestamp registeredOn;
+  private final Instant registeredOn;
   private final Config accountConfig;
   private @Nullable ObjectId metaId;
   private Account account;
 
   AccountProperties(
-      Account.Id accountId,
-      Timestamp registeredOn,
-      Config accountConfig,
-      @Nullable ObjectId metaId) {
+      Account.Id accountId, Instant registeredOn, Config accountConfig, @Nullable ObjectId metaId) {
     this.accountId = accountId;
     this.registeredOn = registeredOn;
     this.accountConfig = accountConfig;
@@ -80,7 +77,7 @@
     return account;
   }
 
-  public Timestamp getRegisteredOn() {
+  public Instant getRegisteredOn() {
     return registeredOn;
   }
 
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 68f5a85..fcfc805 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -27,7 +27,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.server.CurrentUser;
@@ -133,12 +132,18 @@
     private final String input;
     private final ImmutableList<AccountState> list;
     private final ImmutableList<AccountState> filteredInactive;
+    private final CurrentUser searchedAsUser;
 
     @VisibleForTesting
-    Result(String input, List<AccountState> list, List<AccountState> filteredInactive) {
+    Result(
+        String input,
+        List<AccountState> list,
+        List<AccountState> filteredInactive,
+        CurrentUser searchedAsUser) {
       this.input = requireNonNull(input);
       this.list = canonicalize(list);
       this.filteredInactive = canonicalize(filteredInactive);
+      this.searchedAsUser = requireNonNull(searchedAsUser);
     }
 
     private ImmutableList<AccountState> canonicalize(List<AccountState> list) {
@@ -181,13 +186,21 @@
       }
     }
 
-    public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
+    private void ensureSelfIsUniqueIdentifiedUser() throws UnresolvableAccountException {
       ensureUnique();
+      if (!searchedAsUser.isIdentifiedUser()) {
+        throw new UnresolvableAccountException(this);
+      }
+    }
+
+    public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
       if (isSelf()) {
+        ensureSelfIsUniqueIdentifiedUser();
         // In the special case of "self", use the exact IdentifiedUser from the request context, to
         // preserve the peer address and any other per-request state.
-        return self.get().asIdentifiedUser();
+        return searchedAsUser.asIdentifiedUser();
       }
+      ensureUnique();
       return userFactory.create(asUnique());
     }
 
@@ -195,11 +208,10 @@
         throws UnresolvableAccountException {
       ensureUnique();
       if (isSelf()) {
-        // TODO(dborowitz): This preserves old behavior, but it seems wrong to discard the caller.
-        return self.get().asIdentifiedUser();
+        return searchedAsUser.asIdentifiedUser();
       }
       return userFactory.runAs(
-          null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
+          /* remotePeer= */ null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
     }
 
     @VisibleForTesting
@@ -222,16 +234,57 @@
       return false;
     }
 
+    /**
+     * Searches can be done on behalf of either the current user or another provided user. The
+     * results of some searchers, such as BySelf, are affected by the context user.
+     */
+    default boolean requiresContextUser() {
+      return false;
+    }
+
     Optional<I> tryParse(String input) throws IOException;
 
-    Stream<AccountState> search(I input) throws IOException, ConfigInvalidException;
+    /**
+     * This method should be implemented for every searcher which doesn't require a context user.
+     *
+     * @param input to search for
+     * @return stream of the matching accounts
+     * @throws IOException by some subclasses
+     * @throws ConfigInvalidException by some subclasses
+     */
+    default Stream<AccountState> search(I input) throws IOException, ConfigInvalidException {
+      throw new IllegalStateException("search(I) default implementation should never be called.");
+    }
+
+    /**
+     * This method should be implemented for every searcher which requires a context user.
+     *
+     * @param input to search for
+     * @param asUser the context user for the search
+     * @return stream of the matching accounts
+     * @throws IOException by some subclasses
+     * @throws ConfigInvalidException by some subclasses
+     */
+    default Stream<AccountState> search(I input, CurrentUser asUser)
+        throws IOException, ConfigInvalidException {
+      if (!requiresContextUser()) {
+        return search(input);
+      }
+      throw new IllegalStateException(
+          "The searcher requires a context user, but doesn't implement search(input, asUser).");
+    }
 
     boolean shortCircuitIfNoResults();
 
-    default Optional<Stream<AccountState>> trySearch(String input)
+    default Optional<Stream<AccountState>> trySearch(String input, CurrentUser asUser)
         throws IOException, ConfigInvalidException {
       Optional<I> parsed = tryParse(input);
-      return parsed.isPresent() ? Optional.of(search(parsed.get())) : Optional.empty();
+      if (parsed.isEmpty()) {
+        return Optional.empty();
+      }
+      return requiresContextUser()
+          ? Optional.of(search(parsed.get(), asUser))
+          : Optional.of(search(parsed.get()));
     }
   }
 
@@ -252,7 +305,7 @@
     }
   }
 
-  private class BySelf extends StringSearcher {
+  private static class BySelf extends StringSearcher {
     @Override
     public boolean callerShouldFilterOutInactiveCandidates() {
       return false;
@@ -264,17 +317,21 @@
     }
 
     @Override
+    public boolean requiresContextUser() {
+      return true;
+    }
+
+    @Override
     protected boolean matches(String input) {
       return "self".equals(input) || "me".equals(input);
     }
 
     @Override
-    public Stream<AccountState> search(String input) {
-      CurrentUser user = self.get();
-      if (!user.isIdentifiedUser()) {
+    public Stream<AccountState> search(String input, CurrentUser asUser) {
+      if (!asUser.isIdentifiedUser()) {
         return Stream.empty();
       }
-      return Stream.of(user.asIdentifiedUser().state());
+      return Stream.of(asUser.asIdentifiedUser().state());
     }
 
     @Override
@@ -401,9 +458,20 @@
   }
 
   private class ByFullName implements Searcher<AccountState> {
+    boolean allowSkippingVisibilityCheck = true;
+
+    ByFullName() {
+      super();
+    }
+
+    ByFullName(boolean allowSkippingVisibilityCheck) {
+      this();
+      this.allowSkippingVisibilityCheck = allowSkippingVisibilityCheck;
+    }
+
     @Override
     public boolean callerMayAssumeCandidatesAreVisible() {
-      return true; // Rely on enforceVisibility from the index.
+      return allowSkippingVisibilityCheck;
     }
 
     @Override
@@ -425,9 +493,25 @@
   }
 
   private class ByDefaultSearch extends StringSearcher {
+    boolean allowSkippingVisibilityCheck = true;
+
+    ByDefaultSearch() {
+      super();
+    }
+
+    ByDefaultSearch(boolean allowSkippingVisibilityCheck) {
+      this();
+      this.allowSkippingVisibilityCheck = allowSkippingVisibilityCheck;
+    }
+
     @Override
     public boolean callerMayAssumeCandidatesAreVisible() {
-      return true; // Rely on enforceVisibility from the index.
+      return allowSkippingVisibilityCheck;
+    }
+
+    @Override
+    public boolean requiresContextUser() {
+      return true;
     }
 
     @Override
@@ -436,16 +520,17 @@
     }
 
     @Override
-    public Stream<AccountState> search(String input) {
+    public Stream<AccountState> search(String input, CurrentUser asUser) {
       // At this point we have no clue. Just perform a whole bunch of suggestions and pray we come
       // up with a reasonable result list.
       // TODO(dborowitz): This doesn't match the documentation; consider whether it's possible to be
       // more strict here.
       boolean canSeeSecondaryEmails = false;
       try {
-        permissionBackend.user(self.get()).check(GlobalPermission.MODIFY_ACCOUNT);
-        canSeeSecondaryEmails = true;
-      } catch (AuthException | PermissionBackendException e) {
+        if (permissionBackend.user(asUser).test(GlobalPermission.MODIFY_ACCOUNT)) {
+          canSeeSecondaryEmails = true;
+        }
+      } catch (PermissionBackendException e) {
         // remains false
       }
       return accountQueryProvider.get().enforceVisibility(true)
@@ -477,6 +562,18 @@
           .addAll(nameOrEmailSearchers)
           .build();
 
+  private final ImmutableList<Searcher<?>> forcedVisibilitySearchers =
+      ImmutableList.of(
+          new ByNameAndEmail(),
+          new ByEmail(),
+          new FromRealm(),
+          new ByFullName(false),
+          new ByDefaultSearch(false),
+          new BySelf(),
+          new ByExactAccountId(),
+          new ByParenthesizedAccountId(),
+          new ByUsername());
+
   private final AccountCache accountCache;
   private final AccountControl.Factory accountControlFactory;
   private final Emails emails;
@@ -538,12 +635,63 @@
    * @throws IOException if an error occurs.
    */
   public Result resolve(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate());
+    return searchImpl(
+        input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive);
   }
 
   public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate);
+    return searchImpl(
+        input, searchers, self.get(), this::currentUserCanSeePredicate, accountActivityPredicate);
+  }
+
+  /**
+   * Resolves all accounts matching the input string, visible to the provided user.
+   *
+   * <p>The following input formats are recognized:
+   *
+   * <ul>
+   *   <li>The strings {@code "self"} and {@code "me"}, if the provided user is an {@link
+   *       IdentifiedUser}. In this case, may return exactly one inactive account.
+   *   <li>A bare account ID ({@code "18419"}). In this case, may return exactly one inactive
+   *       account. This case short-circuits if the input matches.
+   *   <li>An account ID in parentheses following a full name ({@code "Full Name (18419)"}). This
+   *       case short-circuits if the input matches.
+   *   <li>A username ({@code "username"}).
+   *   <li>A full name and email address ({@code "Full Name <email@example>"}). This case
+   *       short-circuits if the input matches.
+   *   <li>An email address ({@code "email@example"}. This case short-circuits if the input matches.
+   *   <li>An account name recognized by the configured {@link Realm#lookup(String)} Realm}.
+   *   <li>A full name ({@code "Full Name"}).
+   *   <li>As a fallback, a {@link
+   *       com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema,
+   *       boolean, String) default search} against the account index.
+   * </ul>
+   *
+   * @param asUser user to resolve the users by.
+   * @param input input string.
+   * @param forceVisibilityCheck whether to force all searchers to check for visibility.
+   * @return a result describing matching accounts. Never null even if the result set is empty.
+   * @throws ConfigInvalidException if an error occurs.
+   * @throws IOException if an error occurs.
+   */
+  public Result resolveAsUser(CurrentUser asUser, String input, boolean forceVisibilityCheck)
+      throws ConfigInvalidException, IOException {
+    return resolveAsUser(asUser, input, AccountResolver::isActive, forceVisibilityCheck);
+  }
+
+  public Result resolveAsUser(
+      CurrentUser asUser,
+      String input,
+      Predicate<AccountState> accountActivityPredicate,
+      boolean forceVisibilityCheck)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input,
+        forceVisibilityCheck ? forcedVisibilitySearchers : searchers,
+        asUser,
+        new ProvidedUserCanSeePredicate(asUser),
+        accountActivityPredicate);
   }
 
   /**
@@ -556,17 +704,23 @@
    * instead will be stored as a link to the corresponding Gerrit Account.
    */
   public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierCanSee(), all());
+    return searchImpl(
+        input,
+        searchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::allVisible);
+  }
+
+  public Result resolveIncludeInactiveIgnoreVisibility(String input)
+      throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::allVisible);
   }
 
   public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate());
-  }
-
-  public Result resolveIgnoreVisibility(
-      String input, Predicate<AccountState> accountActivityPredicate)
-      throws ConfigInvalidException, IOException {
-    return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate);
+    return searchImpl(
+        input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive);
   }
 
   /**
@@ -595,7 +749,11 @@
   @Deprecated
   public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException {
     return searchImpl(
-        input, nameOrEmailSearchers, visibilitySupplierCanSee(), accountActivityPredicate());
+        input,
+        nameOrEmailSearchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::isActive);
   }
 
   /**
@@ -614,40 +772,55 @@
     return searchImpl(
         input,
         ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()),
-        visibilitySupplierCanSee(),
-        accountActivityPredicate());
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::isActive);
   }
 
-  private Supplier<Predicate<AccountState>> visibilitySupplierCanSee() {
-    return () -> accountControlFactory.get()::canSee;
+  private Predicate<AccountState> currentUserCanSeePredicate() {
+    return accountControlFactory.get()::canSee;
   }
 
-  private Supplier<Predicate<AccountState>> visibilitySupplierAll() {
-    return () -> all();
+  private class ProvidedUserCanSeePredicate implements Supplier<Predicate<AccountState>> {
+    CurrentUser asUser;
+
+    ProvidedUserCanSeePredicate(CurrentUser asUser) {
+      this.asUser = asUser;
+    }
+
+    @Override
+    public Predicate<AccountState> get() {
+      return accountControlFactory.get(asUser.asIdentifiedUser())::canSee;
+    }
   }
 
-  private Predicate<AccountState> all() {
-    return accountState -> {
-      return true;
-    };
+  private Predicate<AccountState> allVisiblePredicate() {
+    return AccountResolver::allVisible;
   }
 
-  private Predicate<AccountState> accountActivityPredicate() {
-    return (AccountState accountState) -> accountState.account().isActive();
+  /** @param accountState account state for which the visibility should be checked */
+  private static boolean allVisible(AccountState accountState) {
+    return true;
+  }
+
+  private static boolean isActive(AccountState accountState) {
+    return accountState.account().isActive();
   }
 
   @VisibleForTesting
   Result searchImpl(
       String input,
       ImmutableList<Searcher<?>> searchers,
+      CurrentUser asUser,
       Supplier<Predicate<AccountState>> visibilitySupplier,
       Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
+    requireNonNull(asUser);
     visibilitySupplier = Suppliers.memoize(visibilitySupplier::get);
     List<AccountState> inactive = new ArrayList<>();
 
     for (Searcher<?> searcher : searchers) {
-      Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input);
+      Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input, asUser);
       if (!maybeResults.isPresent()) {
         continue;
       }
@@ -669,22 +842,25 @@
       }
 
       if (!list.isEmpty()) {
-        return createResult(input, list);
+        return createResult(input, list, asUser);
       }
       if (searcher.shortCircuitIfNoResults()) {
         // For a short-circuiting searcher, return results even if empty.
-        return !inactive.isEmpty() ? emptyResult(input, inactive) : createResult(input, list);
+        return !inactive.isEmpty()
+            ? emptyResult(input, inactive, asUser)
+            : createResult(input, list, asUser);
       }
     }
-    return emptyResult(input, inactive);
+    return emptyResult(input, inactive, asUser);
   }
 
-  private Result createResult(String input, List<AccountState> list) {
-    return new Result(input, list, ImmutableList.of());
+  private Result createResult(String input, List<AccountState> list, CurrentUser searchedAsUser) {
+    return new Result(input, list, ImmutableList.of(), searchedAsUser);
   }
 
-  private Result emptyResult(String input, List<AccountState> inactive) {
-    return new Result(input, ImmutableList.of(), inactive);
+  private Result emptyResult(
+      String input, List<AccountState> inactive, CurrentUser searchedAsUser) {
+    return new Result(input, ImmutableList.of(), inactive, searchedAsUser);
   }
 
   private Stream<AccountState> toAccountStates(Set<Account.Id> ids) {
diff --git a/java/com/google/gerrit/server/account/AccountResource.java b/java/com/google/gerrit/server/account/AccountResource.java
index 4fb69bd..9629809 100644
--- a/java/com/google/gerrit/server/account/AccountResource.java
+++ b/java/com/google/gerrit/server/account/AccountResource.java
@@ -23,20 +23,16 @@
 import java.util.Set;
 
 public class AccountResource implements RestResource {
-  public static final TypeLiteral<RestView<AccountResource>> ACCOUNT_KIND =
-      new TypeLiteral<RestView<AccountResource>>() {};
+  public static final TypeLiteral<RestView<AccountResource>> ACCOUNT_KIND = new TypeLiteral<>() {};
 
-  public static final TypeLiteral<RestView<Capability>> CAPABILITY_KIND =
-      new TypeLiteral<RestView<Capability>>() {};
+  public static final TypeLiteral<RestView<Capability>> CAPABILITY_KIND = new TypeLiteral<>() {};
 
-  public static final TypeLiteral<RestView<Email>> EMAIL_KIND =
-      new TypeLiteral<RestView<Email>>() {};
+  public static final TypeLiteral<RestView<Email>> EMAIL_KIND = new TypeLiteral<>() {};
 
-  public static final TypeLiteral<RestView<SshKey>> SSH_KEY_KIND =
-      new TypeLiteral<RestView<SshKey>>() {};
+  public static final TypeLiteral<RestView<SshKey>> SSH_KEY_KIND = new TypeLiteral<>() {};
 
   public static final TypeLiteral<RestView<StarredChange>> STARRED_CHANGE_KIND =
-      new TypeLiteral<RestView<StarredChange>>() {};
+      new TypeLiteral<>() {};
 
   private final IdentifiedUser user;
 
@@ -106,8 +102,7 @@
   }
 
   public static class Star implements RestResource {
-    public static final TypeLiteral<RestView<Star>> STAR_KIND =
-        new TypeLiteral<RestView<Star>>() {};
+    public static final TypeLiteral<RestView<Star>> STAR_KIND = new TypeLiteral<>() {};
 
     private final IdentifiedUser user;
     private final ChangeResource change;
diff --git a/java/com/google/gerrit/server/account/AccountTagProvider.java b/java/com/google/gerrit/server/account/AccountTagProvider.java
index ddb1331..e74bde7 100644
--- a/java/com/google/gerrit/server/account/AccountTagProvider.java
+++ b/java/com/google/gerrit/server/account/AccountTagProvider.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.entities.Account;
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 93738b0..d6ea294 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -49,7 +49,6 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
@@ -61,6 +60,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
  * Creates and updates accounts.
@@ -201,10 +201,7 @@
   /** Single instance that accumulates updates from the batch. */
   private ExternalIdNotes externalIdNotes;
 
-  private static final Runnable DO_NOTHING = () -> {};
-
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -225,12 +222,11 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.empty()),
-        DO_NOTHING,
-        DO_NOTHING);
+        AccountsUpdate::doNothing,
+        AccountsUpdate::doNothing);
   }
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -252,8 +248,8 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.of(currentUser)),
-        DO_NOTHING,
-        DO_NOTHING);
+        AccountsUpdate::doNothing,
+        AccountsUpdate::doNothing);
   }
 
   @VisibleForTesting
@@ -297,9 +293,7 @@
 
   private static PersonIdent createPersonIdent(
       PersonIdent serverIdent, Optional<IdentifiedUser> user) {
-    return user.isPresent()
-        ? user.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone())
-        : serverIdent;
+    return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
   }
 
   /**
@@ -330,9 +324,7 @@
             ImmutableList.of(
                 repo -> {
                   AccountConfig accountConfig = read(repo, accountId);
-                  Account account =
-                      accountConfig.getNewAccount(
-                          new Timestamp(committerIdent.getWhen().getTime()));
+                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
                   AccountState accountState = AccountState.forAccount(account);
                   AccountDelta.Builder deltaBuilder = AccountDelta.builder();
                   init.configure(accountState, deltaBuilder);
@@ -516,23 +508,31 @@
         updatedAccounts.size() == 1
             ? Iterables.getOnlyElement(updatedAccounts).message
             : "Batch update for " + updatedAccounts.size() + " accounts";
+    ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
+    // These update the same ref, so they need to be stacked on top of one another using the same
+    // ExternalIdNotes instance.
+    RevCommit revCommit =
+        commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+    boolean externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
     for (UpdatedAccount updatedAccount : updatedAccounts) {
+
       // These updates are all for different refs (because batches never update the same account
       // more than once), so there can be multiple commits in the same batch, all with the same base
       // revision in their AccountConfig.
+      // We allow empty commits:
+      // 1) When creating a new account, so that the user branch gets created with an empty commit
+      // when no account properties are set and hence no
+      // 'account.config' file will be created.
+      // 2) When updating "refs/meta/external-ids", so that refs/users/* meta ref is updated too.
+      // This allows to schedule reindexing of account transactionally  on refs/users/* meta
+      // updates.
+      boolean allowEmptyCommit = externalIdsUpdated || updatedAccount.created;
       commitAccountConfig(
           updatedAccount.message,
           allUsersRepo,
           batchRefUpdate,
           updatedAccount.accountConfig,
-          updatedAccount.created /* allowEmptyCommit */);
-      // When creating a new account we must allow empty commits so that the user branch gets
-      // created with an empty commit when no account properties are set and hence no
-      // 'account.config' file will be created.
-
-      // These update the same ref, so they need to be stacked on top of one another using the same
-      // ExternalIdNotes instance.
-      commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+          allowEmptyCommit);
     }
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
@@ -565,10 +565,10 @@
     }
   }
 
-  private void commitExternalIdUpdates(
+  private RevCommit commitExternalIdUpdates(
       String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
     try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      externalIdNotes.commit(md);
+      return externalIdNotes.commit(md);
     }
   }
 
@@ -586,6 +586,8 @@
     return metaDataUpdate;
   }
 
+  private static void doNothing() {}
+
   @FunctionalInterface
   private interface ExecutableUpdate {
     UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
diff --git a/java/com/google/gerrit/server/account/AuthRequest.java b/java/com/google/gerrit/server/account/AuthRequest.java
index cceda70..b4fbcdb 100644
--- a/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/java/com/google/gerrit/server/account/AuthRequest.java
@@ -102,6 +102,7 @@
     return externalId;
   }
 
+  @Nullable
   public String getLocalUser() {
     if (externalId.isScheme(SCHEME_GERRIT)) {
       return externalId.id();
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
index f23a766..2ab6174 100644
--- a/java/com/google/gerrit/server/account/CachedAccountDetails.java
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.config.CachedPreferences;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.Map;
 import org.eclipse.jgit.lib.ObjectId;
@@ -107,7 +106,7 @@
       Cache.AccountProto.Builder accountProto =
           Cache.AccountProto.newBuilder()
               .setId(account.id().get())
-              .setRegisteredOn(account.registeredOn().toInstant().toEpochMilli())
+              .setRegisteredOn(account.registeredOn().toEpochMilli())
               .setInactive(account.inactive())
               .setFullName(Strings.nullToEmpty(account.fullName()))
               .setDisplayName(Strings.nullToEmpty(account.displayName()))
@@ -143,7 +142,7 @@
       Account account =
           Account.builder(
                   Account.id(proto.getAccount().getId()),
-                  Timestamp.from(Instant.ofEpochMilli(proto.getAccount().getRegisteredOn())))
+                  Instant.ofEpochMilli(proto.getAccount().getRegisteredOn()))
               .setFullName(Strings.emptyToNull(proto.getAccount().getFullName()))
               .setDisplayName(Strings.emptyToNull(proto.getAccount().getDisplayName()))
               .setPreferredEmail(Strings.emptyToNull(proto.getAccount().getPreferredEmail()))
diff --git a/java/com/google/gerrit/server/account/CreateGroupArgs.java b/java/com/google/gerrit/server/account/CreateGroupArgs.java
index ba58c3f..9d9fe9d 100644
--- a/java/com/google/gerrit/server/account/CreateGroupArgs.java
+++ b/java/com/google/gerrit/server/account/CreateGroupArgs.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import java.util.Collection;
@@ -30,6 +31,7 @@
     return groupName;
   }
 
+  @Nullable
   public String getGroupName() {
     return groupName != null ? groupName.get() : null;
   }
diff --git a/java/com/google/gerrit/server/account/DefaultRealm.java b/java/com/google/gerrit/server/account/DefaultRealm.java
index 329825f..cfffceb 100644
--- a/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -16,6 +16,7 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
@@ -79,6 +80,7 @@
   @Override
   public void onCreateAccount(AuthRequest who, Account account) {}
 
+  @Nullable
   @Override
   public Account.Id lookup(String accountName) throws IOException {
     if (emailExpander.canExpand(accountName)) {
diff --git a/java/com/google/gerrit/server/account/DestinationList.java b/java/com/google/gerrit/server/account/DestinationList.java
index 15c1e25..084a3ac 100644
--- a/java/com/google/gerrit/server/account/DestinationList.java
+++ b/java/com/google/gerrit/server/account/DestinationList.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.ValidationError;
@@ -39,6 +40,7 @@
     destinations.replaceValues(label, toSet(parse(text, DIR_NAME + label, TRIM, null, errors)));
   }
 
+  @Nullable
   String asText(String label) {
     Set<BranchNameKey> dests = destinations.get(label);
     if (dests == null) {
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 98d0d50..8c3f033 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -29,7 +29,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
@@ -118,7 +117,7 @@
     UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
-    u.setDate(new Timestamp(who.getWhen().getTime()));
+    u.setDate(who.getWhenAsInstant());
     u.setTimeZone(who.getTimeZoneOffset());
 
     // If only one account has access to this email address, select it
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index 1e28d7d..46c730c 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.exceptions.StorageException;
 import java.util.Collection;
 import java.util.Map;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Tracks group objects in memory for efficient access. */
 public interface GroupCache {
@@ -62,6 +66,22 @@
   Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids);
 
   /**
+   * Returns an {@code InternalGroup} instance for the given {@code AccountGroup.UUID} at the given
+   * {@code metaId} of {@link com.google.gerrit.entities.RefNames#refsGroups} ref.
+   *
+   * <p>The caller is responsible to ensure the presence of {@code metaId} and the corresponding
+   * meta ref.
+   *
+   * @param groupUuid the UUID of the internal group
+   * @param metaId the sha1 of commit in {@link com.google.gerrit.entities.RefNames#refsGroups} ref.
+   * @return the internal group at specific sha1 {@code metaId}
+   * @throws StorageException if no internal group with this UUID exists on this server at the
+   *     specific sha1, or if an error occurred during lookup.
+   */
+  @UsedAt(Project.GOOGLE)
+  InternalGroup getFromMetaId(AccountGroup.UUID groupUuid, ObjectId metaId) throws StorageException;
+
+  /**
    * 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.
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index eaec9ba..6f4fce9 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -23,9 +23,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache;
@@ -121,15 +123,19 @@
   private final LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId;
   private final LoadingCache<String, Optional<InternalGroup>> byName;
   private final LoadingCache<String, Optional<InternalGroup>> byUUID;
+  private final LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedByUuidCache;
 
   @Inject
   GroupCacheImpl(
       @Named(BYID_NAME) LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId,
       @Named(BYNAME_NAME) LoadingCache<String, Optional<InternalGroup>> byName,
-      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID) {
+      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID,
+      @Named(BYUUID_NAME_PERSISTED)
+          LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedByUuidCache) {
     this.byId = byId;
     this.byName = byName;
     this.byUUID = byUUID;
+    this.persistedByUuidCache = persistedByUuidCache;
   }
 
   @Override
@@ -184,6 +190,21 @@
   }
 
   @Override
+  public InternalGroup getFromMetaId(AccountGroup.UUID groupUuid, ObjectId metaId)
+      throws StorageException {
+    Cache.GroupKeyProto key =
+        Cache.GroupKeyProto.newBuilder()
+            .setUuid(groupUuid.get())
+            .setRevision(ObjectIdConverter.create().toByteString(metaId))
+            .build();
+    try {
+      return persistedByUuidCache.get(key);
+    } catch (ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Override
   public void evict(AccountGroup.Id groupId) {
     if (groupId != null) {
       logger.atFine().log("Evict group %s by ID", groupId.get());
@@ -346,6 +367,7 @@
       return Protos.toByteArray(InternalGroupSerializer.serialize(value));
     }
 
+    @Nullable
     @Override
     public InternalGroup deserialize(byte[] in) {
       if (Strings.fromByteArray(in).isEmpty()) {
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
index d42db60..fd18d3e 100644
--- a/java/com/google/gerrit/server/account/GroupControl.java
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.exceptions.NoSuchGroupException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -189,10 +188,7 @@
 
   private boolean canAdministrateServer() {
     try {
-      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
-      return true;
-    } catch (AuthException e) {
-      return false;
+      return perm.test(GlobalPermission.ADMINISTRATE_SERVER);
     } catch (PermissionBackendException e) {
       logger.atFine().log(
           "Failed to check %s global capability for user %s",
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 130fa44..b895834 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -27,11 +27,11 @@
 import com.google.gerrit.extensions.common.AvatarInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.avatar.AvatarProvider;
+import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -52,6 +52,9 @@
 @Singleton
 public class InternalAccountDirectory extends AccountDirectory {
   static final Set<FillOptions> ID_ONLY = Collections.unmodifiableSet(EnumSet.of(FillOptions.ID));
+  static final Set<FillOptions> ALL_ACCOUNT_ATTRIBUTES =
+      Collections.unmodifiableSet(
+          EnumSet.of(FillOptions.NAME, FillOptions.EMAIL, FillOptions.USERNAME));
 
   public static class InternalAccountDirectoryModule extends AbstractModule {
     @Override
@@ -97,12 +100,8 @@
     Account.Id currentUserId = null;
     if (self.get().isIdentifiedUser()) {
       currentUserId = self.get().getAccountId();
-
-      try {
-        permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+      if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
         canModifyAccount = true;
-      } catch (AuthException e) {
-        canModifyAccount = false;
       }
     }
 
@@ -129,6 +128,44 @@
     }
   }
 
+  @Override
+  public void fillAccountAttributeInfo(Iterable<? extends AccountAttribute> in) {
+    Set<Account.Id> ids = stream(in).map(a -> Account.id(a.accountId)).collect(toSet());
+    Map<Account.Id, AccountState> accountStates = accountCache.get(ids);
+    for (AccountAttribute accountAttribute : in) {
+      Account.Id id = Account.id(accountAttribute.accountId);
+      AccountState accountState = accountStates.get(id);
+      if (accountState != null) {
+        fill(accountAttribute, accountState, ALL_ACCOUNT_ATTRIBUTES);
+      } else {
+        accountAttribute.accountId = null;
+      }
+    }
+  }
+
+  private void fill(
+      AccountAttribute accountAttribute, AccountState accountState, Set<FillOptions> options) {
+    Account account = accountState.account();
+    if (options.contains(FillOptions.NAME)) {
+      accountAttribute.name = Strings.emptyToNull(account.fullName());
+      if (accountAttribute.name == null) {
+        accountAttribute.name = accountState.userName().orElse(null);
+      }
+    }
+    if (options.contains(FillOptions.EMAIL)) {
+      accountAttribute.email = account.preferredEmail();
+    }
+    if (options.contains(FillOptions.USERNAME)) {
+      accountAttribute.username = accountState.userName().orElse(null);
+    }
+    if (options.contains(FillOptions.ID)) {
+      accountAttribute.accountId = account.id().get();
+    } else {
+      // Was previously set to look up account for filling.
+      accountAttribute.accountId = null;
+    }
+  }
+
   private void fill(AccountInfo info, AccountState accountState, Set<FillOptions> options) {
     Account account = accountState.account();
     if (options.contains(FillOptions.ID)) {
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index 91fe701..01254a0 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
@@ -60,6 +61,7 @@
     return ObjectId.isId(uuid.get()); // [0-9a-f]{40};
   }
 
+  @Nullable
   @Override
   public GroupDescription.Internal get(AccountGroup.UUID uuid) {
     if (!handles(uuid)) {
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index 42137c1..86132d3 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -201,6 +201,7 @@
 
   @AutoValue
   public abstract static class NotifyValue {
+    @Nullable
     public static NotifyValue parse(
         Account.Id accountId,
         String project,
diff --git a/java/com/google/gerrit/server/account/Realm.java b/java/com/google/gerrit/server/account/Realm.java
index 3f642f7..ffc95a3 100644
--- a/java/com/google/gerrit/server/account/Realm.java
+++ b/java/com/google/gerrit/server/account/Realm.java
@@ -56,7 +56,14 @@
    */
   Account.Id lookup(String accountName) throws IOException;
 
-  /** Returns true if the account is active. */
+  /**
+   * Returns true if the account is active.
+   *
+   * @throws LoginException thrown if login is required and fails
+   * @throws NamingException may be thrown if the name is invalid
+   * @throws AccountException may be thrown in case the username is ambiguous
+   * @throws IOException thrown in case of IO errors
+   */
   default boolean isActive(@SuppressWarnings("unused") String username)
       throws LoginException, NamingException, AccountException, IOException {
     return true;
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index 4b68198..5babebd 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -55,26 +55,30 @@
       throws RestApiException, IOException, ConfigInvalidException {
     AtomicBoolean alreadyInactive = new AtomicBoolean(false);
     AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty());
-    accountsUpdateProvider
-        .get()
-        .update(
-            "Deactivate Account via API",
-            accountId,
-            (a, u) -> {
-              if (!a.account().isActive()) {
-                alreadyInactive.set(true);
-              } else {
-                try {
-                  accountActivationValidationListeners.runEach(
-                      l -> l.validateDeactivation(a), ValidationException.class);
-                } catch (ValidationException e) {
-                  exception.set(Optional.of(new ResourceConflictException(e.getMessage(), e)));
-                  return;
-                }
-                u.setActive(false);
-              }
-            })
-        .orElseThrow(() -> new ResourceNotFoundException("account not found"));
+    Optional<AccountState> updatedAccount =
+        accountsUpdateProvider
+            .get()
+            .update(
+                "Deactivate Account via API",
+                accountId,
+                (a, u) -> {
+                  if (!a.account().isActive()) {
+                    alreadyInactive.set(true);
+                  } else {
+                    try {
+                      accountActivationValidationListeners.runEach(
+                          l -> l.validateDeactivation(a), ValidationException.class);
+                    } catch (ValidationException e) {
+                      exception.set(Optional.of(new ResourceConflictException(e.getMessage(), e)));
+                      return;
+                    }
+                    u.setActive(false);
+                  }
+                });
+    if (!updatedAccount.isPresent()) {
+      throw new ResourceNotFoundException("account not found");
+    }
+
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
@@ -94,26 +98,30 @@
       throws RestApiException, IOException, ConfigInvalidException {
     AtomicBoolean alreadyActive = new AtomicBoolean(false);
     AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty());
-    accountsUpdateProvider
-        .get()
-        .update(
-            "Activate Account via API",
-            accountId,
-            (a, u) -> {
-              if (a.account().isActive()) {
-                alreadyActive.set(true);
-              } else {
-                try {
-                  accountActivationValidationListeners.runEach(
-                      l -> l.validateActivation(a), ValidationException.class);
-                } catch (ValidationException e) {
-                  exception.set(Optional.of(new ResourceConflictException(e.getMessage(), e)));
-                  return;
-                }
-                u.setActive(true);
-              }
-            })
-        .orElseThrow(() -> new ResourceNotFoundException("account not found"));
+    Optional<AccountState> updatedAccount =
+        accountsUpdateProvider
+            .get()
+            .update(
+                "Activate Account via API",
+                accountId,
+                (a, u) -> {
+                  if (a.account().isActive()) {
+                    alreadyActive.set(true);
+                  } else {
+                    try {
+                      accountActivationValidationListeners.runEach(
+                          l -> l.validateActivation(a), ValidationException.class);
+                    } catch (ValidationException e) {
+                      exception.set(Optional.of(new ResourceConflictException(e.getMessage(), e)));
+                      return;
+                    }
+                    u.setActive(true);
+                  }
+                });
+    if (!updatedAccount.isPresent()) {
+      throw new ResourceNotFoundException("account not found");
+    }
+
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 5bd9bea..476ca79 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -27,10 +27,16 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+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.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
 import com.google.gerrit.server.project.ProjectState;
@@ -49,11 +55,57 @@
 public class UniversalGroupBackend implements GroupBackend {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Field<String> SYSTEM_FIELD =
+      Field.ofString("system", Metadata.Builder::groupSystem).build();
+
   private final PluginSetContext<GroupBackend> backends;
+  private final Counter1<String> handlesCount;
+  private final Counter1<String> getCount;
+  private final Counter2<String, Integer> suggestCount;
+  private final Counter2<String, Boolean> containsCount;
+  private final Counter2<String, Boolean> containsAnyCount;
+  private final Counter2<String, Integer> intersectionCount;
+  private final Counter2<String, Integer> knownGroupsCount;
 
   @Inject
-  UniversalGroupBackend(PluginSetContext<GroupBackend> backends) {
+  UniversalGroupBackend(PluginSetContext<GroupBackend> backends, MetricMaker metricMaker) {
     this.backends = backends;
+    this.handlesCount =
+        metricMaker.newCounter(
+            "group/handles_count", new Description("Calls to GroupBackend.handles"), SYSTEM_FIELD);
+    this.getCount =
+        metricMaker.newCounter(
+            "group/get_count", new Description("Calls to GroupBackend.get"), SYSTEM_FIELD);
+    this.suggestCount =
+        metricMaker.newCounter(
+            "group/suggest_count",
+            new Description("Calls to GroupBackend.suggest"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_suggested", (meta, value) -> {}).build());
+    this.containsCount =
+        metricMaker.newCounter(
+            "group/contains_count",
+            new Description("Calls to GroupMemberships.contains"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains", (meta, value) -> {}).build());
+    this.containsAnyCount =
+        metricMaker.newCounter(
+            "group/contains_any_of_count",
+            new Description("Calls to GroupMemberships.containsAnyOf"),
+            SYSTEM_FIELD,
+            Field.ofBoolean("contains_any_of", (meta, value) -> {}).build());
+    this.intersectionCount =
+        metricMaker.newCounter(
+            "group/intersection_count",
+            new Description("Calls to GroupMemberships.intersection"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_intersection", (meta, value) -> {}).build());
+    this.knownGroupsCount =
+        metricMaker.newCounter(
+            "group/known_groups_count",
+            new Description("Calls to GroupMemberships.getKnownGroups"),
+            SYSTEM_FIELD,
+            Field.ofInteger("num_known_groups", (meta, value) -> {}).build());
   }
 
   @Nullable
@@ -70,9 +122,15 @@
 
   @Override
   public boolean handles(AccountGroup.UUID uuid) {
-    return backend(uuid) != null;
+    GroupBackend b = backend(uuid);
+    if (b == null) {
+      return false;
+    }
+    handlesCount.increment(name(b));
+    return true;
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     if (uuid == null) {
@@ -83,13 +141,19 @@
       logger.atFine().log("Unknown GroupBackend for UUID: %s", uuid);
       return null;
     }
+    getCount.increment(name(b));
     return b.get(uuid);
   }
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     Set<GroupReference> groups = Sets.newTreeSet(GROUP_REF_NAME_COMPARATOR);
-    backends.runEach(g -> groups.addAll(g.suggest(name, project)));
+    backends.runEach(
+        g -> {
+          Collection<GroupReference> suggestions = g.suggest(name, project);
+          suggestCount.increment(name(g), suggestions.size());
+          groups.addAll(suggestions);
+        });
     return groups;
   }
 
@@ -108,11 +172,11 @@
     }
 
     @Nullable
-    private GroupMembership membership(AccountGroup.UUID uuid) {
+    private Map.Entry<GroupBackend, GroupMembership> membership(AccountGroup.UUID uuid) {
       if (uuid != null) {
         for (Map.Entry<GroupBackend, GroupMembership> m : memberships.entrySet()) {
           if (m.getKey().handles(uuid)) {
-            return m.getValue();
+            return m;
           }
         }
       }
@@ -125,51 +189,57 @@
       if (uuid == null) {
         return false;
       }
-      GroupMembership m = membership(uuid);
+      Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
       if (m == null) {
         return false;
       }
-      return m.contains(uuid);
+      boolean contains = m.getValue().contains(uuid);
+      containsCount.increment(name(m.getKey()), contains);
+      return contains;
     }
 
     @Override
     public boolean containsAnyOf(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           continue;
         }
         lookups.put(m, uuid);
       }
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        GroupMembership m = entry.getKey();
-        Collection<AccountGroup.UUID> ids = entry.getValue();
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackends : lookups.asMap().keySet()) {
+
+        GroupMembership m = groupBackends.getValue();
+        Collection<AccountGroup.UUID> ids = lookups.asMap().get(groupBackends);
         if (ids.size() == 1) {
           if (m.contains(Iterables.getOnlyElement(ids))) {
+            containsAnyCount.increment(name(groupBackends.getKey()), true);
             return true;
           }
         } else if (m.containsAnyOf(ids)) {
+          containsAnyCount.increment(name(groupBackends.getKey()), true);
           return true;
         }
+        // We would have returned if contains was true.
+        containsAnyCount.increment(name(groupBackends.getKey()), false);
       }
       return false;
     }
 
     @Override
     public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> uuids) {
-      ListMultimap<GroupMembership, AccountGroup.UUID> lookups =
+      ListMultimap<Map.Entry<GroupBackend, GroupMembership>, AccountGroup.UUID> lookups =
           MultimapBuilder.hashKeys().arrayListValues().build();
       for (AccountGroup.UUID uuid : uuids) {
         if (uuid == null) {
           continue;
         }
-        GroupMembership m = membership(uuid);
+        Map.Entry<GroupBackend, GroupMembership> m = membership(uuid);
         if (m == null) {
           logger.atFine().log("Unknown GroupMembership for UUID: %s", uuid);
           continue;
@@ -177,9 +247,11 @@
         lookups.put(m, uuid);
       }
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (Map.Entry<GroupMembership, Collection<AccountGroup.UUID>> entry :
-          lookups.asMap().entrySet()) {
-        groups.addAll(entry.getKey().intersection(entry.getValue()));
+      for (Map.Entry<GroupBackend, GroupMembership> groupBackend : lookups.asMap().keySet()) {
+        Set<AccountGroup.UUID> intersection =
+            groupBackend.getValue().intersection(lookups.asMap().get(groupBackend));
+        intersectionCount.increment(name(groupBackend.getKey()), intersection.size());
+        groups.addAll(intersection);
       }
       return groups;
     }
@@ -187,8 +259,10 @@
     @Override
     public Set<AccountGroup.UUID> getKnownGroups() {
       Set<AccountGroup.UUID> groups = new HashSet<>();
-      for (GroupMembership m : memberships.values()) {
-        groups.addAll(m.getKnownGroups());
+      for (Map.Entry<GroupBackend, GroupMembership> entry : memberships.entrySet()) {
+        Set<AccountGroup.UUID> knownGroups = entry.getValue().getKnownGroups();
+        knownGroupsCount.increment(name(entry.getKey()), knownGroups.size());
+        groups.addAll(knownGroups);
       }
       return groups;
     }
@@ -204,6 +278,13 @@
     return false;
   }
 
+  private static String name(GroupBackend backend) {
+    if (backend == null) {
+      return "none";
+    }
+    return backend.getClass().getSimpleName();
+  }
+
   public static class ConfigCheck implements StartupCheck {
     private final Config cfg;
     private final UniversalGroupBackend universalGroupBackend;
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 555a2c1..1fce3d5 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -20,6 +20,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Ordering;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.InvalidSshKeyException;
@@ -194,6 +195,7 @@
    * @return the SSH key, <code>null</code> if there is no SSH key with this sequence number, or if
    *     the SSH key with this sequence number has been deleted
    */
+  @Nullable
   private AccountSshKey getKey(int seq) {
     checkLoaded();
     return keys.get(seq - 1).orElse(null);
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
index e718bcb..14aa368 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -45,6 +45,13 @@
     return new AutoValue_AllExternalIds(byKey.build(), byAccount.build(), byEmail.build());
   }
 
+  static AllExternalIds create(
+      ImmutableMap<ExternalId.Key, ExternalId> byKey,
+      ImmutableSetMultimap<Account.Id, ExternalId> byAccount,
+      ImmutableSetMultimap<String, ExternalId> byEmail) {
+    return new AutoValue_AllExternalIds(byKey, byAccount, byEmail);
+  }
+
   public abstract ImmutableMap<ExternalId.Key, ExternalId> byKey();
 
   public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
diff --git a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
index 1cd3de8..2d1ec1a 100644
--- a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
@@ -20,7 +20,6 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -36,13 +35,6 @@
   }
 
   @Override
-  public void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd) {}
-
-  @Override
   public Optional<ExternalId> byKey(ExternalId.Key key) throws IOException {
     throw new UnsupportedOperationException();
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 30f4094..1616198 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -106,8 +106,10 @@
   static final String PASSWORD_KEY = "password";
 
   /**
-   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
-   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
+   * Scheme used to label accounts created, when using the LDAP-based authentication types {@link
+   * AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link AuthType#HTTP_LDAP}, and {@link
+   * AuthType#LDAP_BIND}. The external ID stores the username. Accounts with such an external ID
+   * will be authenticated against the configured LDAP identity provider.
    *
    * <p>The name {@code gerrit:} was a very poor choice.
    *
@@ -133,6 +135,9 @@
   /** Scheme used for GPG public keys. */
   public static final String SCHEME_GPGKEY = "gpgkey";
 
+  /** Scheme for imported accounts from other servers with different GerritServerId */
+  public static final String SCHEME_IMPORTED = "imported";
+
   /** Scheme for external auth used during authentication, e.g. OAuth Token */
   public static final String SCHEME_EXTERNAL = "external";
 
@@ -186,17 +191,26 @@
       return scheme.equals(scheme());
     }
 
+    @Memoized
+    public ObjectId sha1() {
+      return sha1(isCaseInsensitive());
+    }
+
     /**
      * Returns the SHA1 of the external ID that is used as note ID in the refs/meta/external-ids
      * notes branch.
      */
     @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
-    @Memoized
-    public ObjectId sha1() {
-      String keyString = isCaseInsensitive() ? get().toLowerCase(Locale.US) : get();
+    private ObjectId sha1(Boolean isCaseInsensitive) {
+      String keyString = isCaseInsensitive ? get().toLowerCase(Locale.US) : get();
       return ObjectId.fromRaw(Hashing.sha1().hashString(keyString, UTF_8).asBytes());
     }
 
+    @Memoized
+    public ObjectId caseSensitiveSha1() {
+      return sha1(false);
+    }
+
     /**
      * Exports this external ID key as string with the format "scheme:id", or "id" if scheme is
      * null.
@@ -299,7 +313,7 @@
     ExternalId o = (ExternalId) obj;
     return Objects.equals(key(), o.key())
         && Objects.equals(accountId(), o.accountId())
-        && Objects.equals(isCaseInsensitive(), o.isCaseInsensitive())
+        && isCaseInsensitive() == o.isCaseInsensitive()
         && Objects.equals(email(), o.email())
         && Objects.equals(password(), o.password());
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 0029557..fe8feac 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -31,22 +30,7 @@
  *
  * <p>All returned collections are unmodifiable.
  */
-interface ExternalIdCache {
-
-  /**
-   * Updates the cache.
-   *
-   * @param oldNotesRev current revision against which the below updates are applied
-   * @param newNotesRev key for the new cache revision
-   * @param toRemove external IDs to remove
-   * @param toAdd external IDs to add
-   */
-  void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd);
-
+public interface ExternalIdCache {
   Optional<ExternalId> byKey(ExternalId.Key key) throws IOException;
 
   ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index e6db593..af8e19f 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -14,66 +14,42 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.SetMultimap;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.Collection;
 import java.util.Optional;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
-import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Caches external IDs of all accounts. The external IDs are always loaded from NoteDb. */
 @Singleton
 class ExternalIdCacheImpl implements ExternalIdCache {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public static final String CACHE_NAME = "external_ids_map";
 
-  private final LoadingCache<ObjectId, AllExternalIds> extIdsByAccount;
+  private final Cache<ObjectId, AllExternalIds> extIdsByAccount;
   private final ExternalIdReader externalIdReader;
+  private final ExternalIdCacheLoader externalIdCacheLoader;
   private final Lock lock;
 
   @Inject
   ExternalIdCacheImpl(
-      @Named(CACHE_NAME) LoadingCache<ObjectId, AllExternalIds> extIdsByAccount,
-      ExternalIdReader externalIdReader) {
+      @Named(CACHE_NAME) Cache<ObjectId, AllExternalIds> extIdsByAccount,
+      ExternalIdReader externalIdReader,
+      ExternalIdCacheLoader externalIdCacheLoader) {
     this.extIdsByAccount = extIdsByAccount;
     this.externalIdReader = externalIdReader;
+    this.externalIdCacheLoader = externalIdCacheLoader;
     this.lock = new ReentrantLock(true /* fair */);
   }
 
   @Override
-  public void onReplace(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Collection<ExternalId> toRemove,
-      Collection<ExternalId> toAdd) {
-    updateCache(
-        oldNotesRev,
-        newNotesRev,
-        m -> {
-          for (ExternalId extId : toRemove) {
-            m.remove(extId.accountId(), extId);
-          }
-          for (ExternalId extId : toAdd) {
-            extId.checkThatBlobIdIsSet();
-            m.put(extId.accountId(), extId);
-          }
-        });
-  }
-
-  @Override
   public Optional<ExternalId> byKey(ExternalId.Key key) throws IOException {
     return Optional.ofNullable(get().byKey().get(key));
   }
@@ -112,38 +88,39 @@
     return get(externalIdReader.readRevision());
   }
 
+  /**
+   * Returns the cached value or a freshly loaded value that will be cached with this call in case
+   * the value was absent from the cache.
+   *
+   * <p>This method will load the value using {@link ExternalIdCacheLoader} in case it is not
+   * already cached. {@link ExternalIdCacheLoader} requires loading older versions of the cached
+   * value and Caffeine does not support recursive calls to the cache from loaders. Hence, we use a
+   * Cache instead of a LoadingCache and perform the loading ourselves here similar to what a
+   * loading cache would do.
+   */
   private AllExternalIds get(ObjectId rev) throws IOException {
-    try {
-      return extIdsByAccount.get(rev);
-    } catch (ExecutionException e) {
-      throw new IOException("Cannot load external ids", e);
-    }
-  }
-
-  private void updateCache(
-      ObjectId oldNotesRev,
-      ObjectId newNotesRev,
-      Consumer<SetMultimap<Account.Id, ExternalId>> update) {
-    if (oldNotesRev.equals(newNotesRev)) {
-      // No need to update external id cache since there is no update to those external ids.
-      return;
+    AllExternalIds cachedValue = extIdsByAccount.getIfPresent(rev);
+    if (cachedValue != null) {
+      return cachedValue;
     }
 
+    // Load the value and put it in the cache.
     lock.lock();
     try {
-      SetMultimap<Account.Id, ExternalId> m;
-      if (!ObjectId.zeroId().equals(oldNotesRev)) {
-        m =
-            MultimapBuilder.hashKeys()
-                .hashSetValues()
-                .build(extIdsByAccount.get(oldNotesRev).byAccount());
-      } else {
-        m = MultimapBuilder.hashKeys().hashSetValues().build();
+      // Check if value was already loaded while waiting for the lock.
+      cachedValue = extIdsByAccount.getIfPresent(rev);
+      if (cachedValue != null) {
+        return cachedValue;
       }
-      update.accept(m);
-      extIdsByAccount.put(newNotesRev, AllExternalIds.create(m.values().stream()));
-    } catch (ExecutionException e) {
-      logger.atWarning().withCause(e).log("Cannot update external IDs");
+
+      AllExternalIds newlyLoadedValue;
+      try {
+        newlyLoadedValue = externalIdCacheLoader.load(rev);
+      } catch (ConfigInvalidException e) {
+        throw new IOException("Cannot load external ids", e);
+      }
+      extIdsByAccount.put(rev, newlyLoadedValue);
+      return newlyLoadedValue;
     } finally {
       lock.unlock();
     }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
index 72d703b..1edb284 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
@@ -16,7 +16,6 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.cache.Cache;
-import com.google.common.cache.CacheLoader;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
@@ -36,7 +35,6 @@
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
@@ -58,7 +56,7 @@
 
 /** Loads cache values for the external ID cache using either a full or a partial reload. */
 @Singleton
-public class ExternalIdCacheLoader extends CacheLoader<ObjectId, AllExternalIds> {
+public class ExternalIdCacheLoader {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   // Maximum number of prior states we inspect to find a base for differential. If no cached state
@@ -66,12 +64,11 @@
   private static final int MAX_HISTORY_LOOKBACK = 10;
 
   private final ExternalIdReader externalIdReader;
-  private final Provider<Cache<ObjectId, AllExternalIds>> externalIdCache;
+  private final Cache<ObjectId, AllExternalIds> externalIdCache;
   private final GitRepositoryManager gitRepositoryManager;
   private final AllUsersName allUsersName;
   private final Counter1<Boolean> reloadCounter;
   private final Timer0 reloadDifferential;
-  private final boolean enablePartialReloads;
   private final boolean isPersistentCache;
   private final ExternalIdFactory externalIdFactory;
 
@@ -80,8 +77,7 @@
       GitRepositoryManager gitRepositoryManager,
       AllUsersName allUsersName,
       ExternalIdReader externalIdReader,
-      @Named(ExternalIdCacheImpl.CACHE_NAME)
-          Provider<Cache<ObjectId, AllExternalIds>> externalIdCache,
+      @Named(ExternalIdCacheImpl.CACHE_NAME) Cache<ObjectId, AllExternalIds> externalIdCache,
       MetricMaker metricMaker,
       @GerritServerConfig Config config,
       ExternalIdFactory externalIdFactory) {
@@ -105,23 +101,13 @@
                     "Latency for generating a new external ID cache state from a prior state.")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS));
-    this.enablePartialReloads =
-        config.getBoolean("cache", ExternalIdCacheImpl.CACHE_NAME, "enablePartialReloads", true);
     this.isPersistentCache =
         config.getInt("cache", ExternalIdCacheImpl.CACHE_NAME, "diskLimit", 0) > 0;
     this.externalIdFactory = externalIdFactory;
   }
 
-  @Override
   public AllExternalIds load(ObjectId notesRev) throws IOException, ConfigInvalidException {
-    if (!enablePartialReloads) {
-      logger.atInfo().log(
-          "Partial reloads of "
-              + ExternalIdCacheImpl.CACHE_NAME
-              + " disabled. Falling back to full reload.");
-      return reloadAllExternalIds(notesRev);
-    }
-
+    externalIdReader.checkReadEnabled();
     // The requested value was not in the cache (hence, this loader was invoked). Therefore, try to
     // create this entry from a past value using the minimal amount of Git operations possible to
     // reduce latency.
@@ -158,7 +144,7 @@
       while ((parentWithCacheValue = rw.next()) != null
           && i++ < MAX_HISTORY_LOOKBACK
           && parentWithCacheValue.getParentCount() < 2) {
-        oldExternalIds = externalIdCache.get().getIfPresent(parentWithCacheValue.getId());
+        oldExternalIds = externalIdCache.getIfPresent(parentWithCacheValue.getId());
         if (oldExternalIds != null) {
           // We found a previously cached state.
           break;
@@ -267,7 +253,7 @@
         }
       }
     }
-    return new AutoValue_AllExternalIds(byKey.build(), byAccount.build(), byEmail.build());
+    return AllExternalIds.create(byKey.build(), byAccount.build(), byEmail.build());
   }
 
   private AllExternalIds reloadAllExternalIds(ObjectId notesRev)
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
index f0ad1b2..1873ea0 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
@@ -35,7 +35,6 @@
         // Guava calls the loader first and evicts later on.
         .maximumWeight(2)
         .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
-        .loader(ExternalIdCacheLoader.class)
         .diskLimit(-1)
         .version(1)
         .keySerializer(ObjectIdCacheSerializer.INSTANCE)
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
new file mode 100644
index 0000000..a6ee366c
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+
+public class ExternalIdCaseSensitivityMigrator {
+
+  public static class ExternalIdCaseSensitivityMigratorModule extends AbstractModule {
+    @Override
+    public void configure() {
+      install(new FactoryModuleBuilder().build(ExternalIdCaseSensitivityMigrator.Factory.class));
+    }
+  }
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    ExternalIdCaseSensitivityMigrator create(
+        @Assisted("isUserNameCaseInsensitive") Boolean isUserNameCaseInsensitive,
+        @Assisted("dryRun") Boolean dryRun);
+  }
+
+  private GitRepositoryManager repoManager;
+  private AllUsersName allUsersName;
+  private Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
+  private ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
+
+  private ExternalIdFactory externalIdFactory;
+  private Boolean isUserNameCaseInsensitive;
+  private Boolean dryRun;
+
+  @Inject
+  public ExternalIdCaseSensitivityMigrator(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory,
+      ExternalIdNotes.FactoryNoReindex externalIdNotesFactory,
+      ExternalIdFactory externalIdFactory,
+      @Assisted("isUserNameCaseInsensitive") Boolean isUserNameCaseInsensitive,
+      @Assisted("dryRun") Boolean dryRun) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.metaDataUpdateServerFactory = metaDataUpdateServerFactory;
+    this.externalIdNotesFactory = externalIdNotesFactory;
+    this.externalIdFactory = externalIdFactory;
+
+    this.isUserNameCaseInsensitive = isUserNameCaseInsensitive;
+    this.dryRun = dryRun;
+  }
+
+  private void recomputeExternalIdNoteId(ExternalIdNotes extIdNotes, ExternalId extId)
+      throws DuplicateKeyException, IOException {
+    if (extId.isScheme(SCHEME_GERRIT) || extId.isScheme(SCHEME_USERNAME)) {
+      ExternalIdKeyFactory keyFactory =
+          new ExternalIdKeyFactory(
+              new ExternalIdKeyFactory.Config() {
+                @Override
+                public boolean isUserNameCaseInsensitive() {
+                  return isUserNameCaseInsensitive;
+                }
+              });
+      ExternalId.Key updatedKey = keyFactory.create(extId.key().scheme(), extId.key().id());
+      ExternalId.Key oldKey =
+          keyFactory.create(extId.key().scheme(), extId.key().id(), !isUserNameCaseInsensitive);
+      if (!oldKey.sha1().getName().equals(updatedKey.sha1().getName())
+          && !extId.key().sha1().getName().equals(updatedKey.sha1().getName())) {
+        logger.atInfo().log("Converting note name of external ID: %s", oldKey);
+        ExternalId updatedExtId =
+            externalIdFactory.create(
+                updatedKey, extId.accountId(), extId.email(), extId.password(), extId.blobId());
+        ExternalId oldExtId =
+            externalIdFactory.create(
+                oldKey, extId.accountId(), extId.email(), extId.password(), extId.blobId());
+        extIdNotes.replace(
+            Collections.singleton(oldExtId),
+            Collections.singleton(updatedExtId),
+            (externalId) -> externalId.key().sha1());
+      }
+    }
+  }
+
+  public void migrate(Collection<ExternalId> todo, Runnable monitor)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
+      for (ExternalId extId : todo) {
+        recomputeExternalIdNoteId(extIdNotes, extId);
+        monitor.run();
+      }
+      if (!dryRun) {
+        try (MetaDataUpdate metaDataUpdate =
+            metaDataUpdateServerFactory.get().create(allUsersName)) {
+          metaDataUpdate.setMessage(
+              String.format(
+                  "Migration to case %ssensitive usernames",
+                  isUserNameCaseInsensitive ? "" : "in"));
+          extIdNotes.commit(metaDataUpdate);
+        } catch (Exception e) {
+          logger.atSevere().withCause(e).log("%s", e.getMessage());
+        }
+      }
+    } catch (DuplicateExternalIdKeyException e) {
+      logger.atSevere().withCause(e).log("%s", e.getMessage());
+      throw e;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
index 502bab9..b16f73f 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
@@ -19,10 +19,10 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.config.AuthConfig;
 import java.util.Set;
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -32,12 +32,13 @@
 
 @Singleton
 public class ExternalIdFactory {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private final ExternalIdKeyFactory externalIdKeyFactory;
+  private AuthConfig authConfig;
 
   @Inject
-  public ExternalIdFactory(ExternalIdKeyFactory externalIdKeyFactory) {
+  public ExternalIdFactory(ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
     this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
   }
 
   /**
@@ -250,10 +251,23 @@
     }
 
     if (!externalIdKey.sha1().getName().equals(noteId)) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
+      if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
+      }
+
+      if (!externalIdKey.caseSensitiveSha1().getName().equals(noteId)) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID"
+                    + " '%s'",
+                externalIdKeyStr, noteId));
+      }
+      externalIdKey =
+          externalIdKeyFactory.create(externalIdKey.scheme(), externalIdKey.id(), false);
     }
 
     String email =
@@ -301,15 +315,17 @@
       }
       return accountId;
     } catch (IllegalArgumentException e) {
-      String msg =
-          String.format(
-              "Value %s for '%s.%s.%s' is invalid, expected account ID",
-              accountIdStr,
-              ExternalId.EXTERNAL_ID_SECTION,
-              externalIdKeyStr,
-              ExternalId.ACCOUNT_ID_KEY);
-      logger.atSevere().withCause(e).log(msg);
-      throw invalidConfig(noteId, msg);
+      ConfigInvalidException newException =
+          invalidConfig(
+              noteId,
+              String.format(
+                  "Value %s for '%s.%s.%s' is invalid, expected account ID",
+                  accountIdStr,
+                  ExternalId.EXTERNAL_ID_SECTION,
+                  externalIdKeyStr,
+                  ExternalId.ACCOUNT_ID_KEY));
+      newException.initCause(e);
+      throw newException;
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
index 95df4a9..68d8b0c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
@@ -65,9 +65,23 @@
    * @return the created external ID key
    */
   public ExternalId.Key create(@Nullable String scheme, String id) {
+    return create(scheme, id, isUserNameCaseInsensitive);
+  }
+
+  /**
+   * Creates an external ID key.
+   *
+   * @param scheme the scheme name, must not contain colons (':'). E.g. {@link
+   *     ExternalId#SCHEME_USERNAME}.
+   * @param id the external ID, must not contain colons (':')
+   * @param userNameCaseInsensitive whether the external ID key is matched case insensitively
+   * @return the created external ID key
+   */
+  public ExternalId.Key create(
+      @Nullable String scheme, String id, boolean userNameCaseInsensitive) {
     if (scheme != null
         && (scheme.equals(ExternalId.SCHEME_USERNAME) || scheme.equals(ExternalId.SCHEME_GERRIT))) {
-      return ExternalId.Key.create(scheme, id, isUserNameCaseInsensitive);
+      return ExternalId.Key.create(scheme, id, userNameCaseInsensitive);
     }
 
     return ExternalId.Key.create(scheme, id, false);
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index 2b9c00a9..48c403c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.index.account.AccountIndexer;
@@ -53,6 +54,7 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Function;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BlobBasedConfig;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -100,18 +102,21 @@
     protected final AllUsersName allUsersName;
     protected final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
     protected final ExternalIdFactory externalIdFactory;
+    protected final AuthConfig authConfig;
 
     protected ExternalIdNotesLoader(
         ExternalIdCache externalIdCache,
         MetricMaker metricMaker,
         AllUsersName allUsersName,
         DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-        ExternalIdFactory externalIdFactory) {
+        ExternalIdFactory externalIdFactory,
+        AuthConfig authConfig) {
       this.externalIdCache = externalIdCache;
       this.metricMaker = metricMaker;
       this.allUsersName = allUsersName;
       this.upsertPreprocessors = upsertPreprocessors;
       this.externalIdFactory = externalIdFactory;
+      this.authConfig = authConfig;
     }
 
     /**
@@ -162,17 +167,6 @@
         cacheUpdate.execute(updates);
       }
 
-      // Perform the cache update.
-      if (!externalIdNotes.noCacheUpdate) {
-        // Regardless of noCacheUpdate it's still possible that the ExternalIdCache instance is of
-        // type DisabledExternalIdCache, making this call a no-op.
-        externalIdCache.onReplace(
-            externalIdNotes.oldRev,
-            externalIdNotes.getRevision(),
-            updates.getRemoved(),
-            updates.getAdded());
-      }
-
       // Reindex accounts (if the subclass implements reindexAccount()).
       if (!externalIdNotes.noReindex) {
         Streams.concat(updates.getAdded().stream(), updates.getRemoved().stream())
@@ -203,8 +197,15 @@
         MetricMaker metricMaker,
         AllUsersName allUsersName,
         DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-        ExternalIdFactory externalIdFactory) {
-      super(externalIdCache, metricMaker, allUsersName, upsertPreprocessors, externalIdFactory);
+        ExternalIdFactory externalIdFactory,
+        AuthConfig authConfig) {
+      super(
+          externalIdCache,
+          metricMaker,
+          allUsersName,
+          upsertPreprocessors,
+          externalIdFactory,
+          authConfig);
       this.accountIndexer = accountIndexer;
     }
 
@@ -212,7 +213,12 @@
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              metricMaker, allUsersName, allUsersRepo, upsertPreprocessors, externalIdFactory)
+              metricMaker,
+              allUsersName,
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
           .load();
     }
 
@@ -220,7 +226,12 @@
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              metricMaker, allUsersName, allUsersRepo, upsertPreprocessors, externalIdFactory)
+              metricMaker,
+              allUsersName,
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
           .load(rev);
     }
 
@@ -239,15 +250,27 @@
         MetricMaker metricMaker,
         AllUsersName allUsersName,
         DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-        ExternalIdFactory externalIdFactory) {
-      super(externalIdCache, metricMaker, allUsersName, upsertPreprocessors, externalIdFactory);
+        ExternalIdFactory externalIdFactory,
+        AuthConfig authConfig) {
+      super(
+          externalIdCache,
+          metricMaker,
+          allUsersName,
+          upsertPreprocessors,
+          externalIdFactory,
+          authConfig);
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              metricMaker, allUsersName, allUsersRepo, upsertPreprocessors, externalIdFactory)
+              metricMaker,
+              allUsersName,
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
           .setNoReindex()
           .load();
     }
@@ -256,7 +279,12 @@
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              metricMaker, allUsersName, allUsersRepo, upsertPreprocessors, externalIdFactory)
+              metricMaker,
+              allUsersName,
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
           .setNoReindex()
           .load(rev);
     }
@@ -281,23 +309,24 @@
       AllUsersName allUsersName,
       Repository allUsersRepo,
       @Nullable ObjectId rev,
-      ExternalIdFactory externalIdFactory)
+      ExternalIdFactory externalIdFactory,
+      boolean isUserNameCaseInsensitiveMigrationMode)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
             new DisabledMetricMaker(),
             allUsersName,
             allUsersRepo,
             DynamicMap.emptyMap(),
-            externalIdFactory)
+            externalIdFactory,
+            isUserNameCaseInsensitiveMigrationMode)
         .setReadOnly()
-        .setNoCacheUpdate()
         .setNoReindex()
         .load(rev);
   }
 
   /**
-   * Loads the external ID notes for updates without cache evictions. The external ID notes are
-   * loaded from the current tip of the {@code refs/meta/external-ids} branch.
+   * Loads the external ID notes for updates. The external ID notes are loaded from the current tip
+   * of the {@code refs/meta/external-ids} branch.
    *
    * <p>Use this only from init, schema upgrades and tests.
    *
@@ -305,16 +334,19 @@
    *
    * @return {@link ExternalIdNotes} instance that doesn't updates caches on save
    */
-  public static ExternalIdNotes loadNoCacheUpdate(
-      AllUsersName allUsersName, Repository allUsersRepo, ExternalIdFactory externalIdFactory)
+  public static ExternalIdNotes load(
+      AllUsersName allUsersName,
+      Repository allUsersRepo,
+      ExternalIdFactory externalIdFactory,
+      boolean isUserNameCaseInsensitiveMigrationMode)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
             new DisabledMetricMaker(),
             allUsersName,
             allUsersRepo,
             DynamicMap.emptyMap(),
-            externalIdFactory)
-        .setNoCacheUpdate()
+            externalIdFactory,
+            isUserNameCaseInsensitiveMigrationMode)
         .setNoReindex()
         .load();
   }
@@ -348,15 +380,28 @@
 
   private Runnable afterReadRevision;
   private boolean readOnly = false;
-  private boolean noCacheUpdate = false;
   private boolean noReindex = false;
+  private boolean isUserNameCaseInsensitiveMigrationMode = false;
+  protected final Function<ExternalId, ObjectId> defaultNoteIdResolver =
+      (extId) -> {
+        ObjectId noteId = extId.key().sha1();
+        try {
+          if (isUserNameCaseInsensitiveMigrationMode && !noteMap.contains(noteId)) {
+            noteId = extId.key().caseSensitiveSha1();
+          }
+        } catch (IOException e) {
+          return noteId;
+        }
+        return noteId;
+      };
 
   private ExternalIdNotes(
       MetricMaker metricMaker,
       AllUsersName allUsersName,
       Repository allUsersRepo,
       DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-      ExternalIdFactory externalIdFactory) {
+      ExternalIdFactory externalIdFactory,
+      boolean isUserNameCaseInsensitiveMigrationMode) {
     this.updateCount =
         metricMaker.newCounter(
             "notedb/external_id_update_count",
@@ -378,6 +423,7 @@
             .addTarget(ExternalIdNotes.class)
             .build();
     this.externalIdFactory = externalIdFactory;
+    this.isUserNameCaseInsensitiveMigrationMode = isUserNameCaseInsensitiveMigrationMode;
   }
 
   public ExternalIdNotes setAfterReadRevision(Runnable afterReadRevision) {
@@ -390,11 +436,6 @@
     return this;
   }
 
-  private ExternalIdNotes setNoCacheUpdate() {
-    noCacheUpdate = true;
-    return this;
-  }
-
   private ExternalIdNotes setNoReindex() {
     noReindex = true;
     return this;
@@ -449,16 +490,26 @@
    */
   public Optional<ExternalId> get(ExternalId.Key key) throws IOException, ConfigInvalidException {
     checkLoaded();
+    ObjectId noteId = getNoteId(key);
+    if (noteMap.contains(noteId)) {
+
+      try (RevWalk rw = new RevWalk(repo)) {
+        ObjectId noteDataId = noteMap.get(noteId);
+        byte[] raw = readNoteData(rw, noteDataId);
+        return Optional.of(externalIdFactory.parse(noteId.name(), raw, noteDataId));
+      }
+    }
+    return Optional.empty();
+  }
+
+  protected ObjectId getNoteId(ExternalId.Key key) throws IOException {
     ObjectId noteId = key.sha1();
-    if (!noteMap.contains(noteId)) {
-      return Optional.empty();
+
+    if (!noteMap.contains(noteId) && isUserNameCaseInsensitiveMigrationMode) {
+      noteId = key.caseSensitiveSha1();
     }
 
-    try (RevWalk rw = new RevWalk(repo)) {
-      ObjectId noteDataId = noteMap.get(noteId);
-      byte[] raw = readNoteData(rw, noteDataId);
-      return Optional.of(externalIdFactory.parse(noteId.name(), raw, noteDataId));
-    }
+    return noteId;
   }
 
   /**
@@ -651,6 +702,12 @@
     cacheUpdates.add(cu -> cu.remove(removedExtIds));
   }
 
+  public void replace(
+      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    replace(accountId, toDelete, toAdd, defaultNoteIdResolver);
+  }
+
   /**
    * Replaces external IDs for an account by external ID keys.
    *
@@ -663,7 +720,10 @@
    *     the specified account.
    */
   public void replace(
-      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      Account.Id accountId,
+      Collection<ExternalId.Key> toDelete,
+      Collection<ExternalId> toAdd,
+      Function<ExternalId, ObjectId> noteIdResolver)
       throws IOException, DuplicateExternalIdKeyException {
     checkLoaded();
     checkSameAccount(toAdd, accountId);
@@ -681,7 +741,7 @@
           }
 
           for (ExternalId extId : toAdd) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId, noteIdResolver);
             preprocessUpsert(insertedExtId);
             updatedExtIds.add(insertedExtId);
           }
@@ -757,6 +817,32 @@
     replace(accountId, toDelete.stream().map(ExternalId::key).collect(toSet()), toAdd);
   }
 
+  /**
+   * Replaces external IDs.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID is specified for deletion and an external ID with the same key is specified to be
+   * added, the old external ID with that key is deleted first and then the new external ID is added
+   * (so the external ID for that key is replaced).
+   *
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
+   */
+  public void replace(
+      Collection<ExternalId> toDelete,
+      Collection<ExternalId> toAdd,
+      Function<ExternalId, ObjectId> noteIdResolver)
+      throws IOException, DuplicateExternalIdKeyException {
+    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
+    if (accountId == null) {
+      // toDelete and toAdd are empty -> nothing to do
+      return;
+    }
+
+    replace(
+        accountId, toDelete.stream().map(ExternalId::key).collect(toSet()), toAdd, noteIdResolver);
+  }
+
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     if (revision != null) {
@@ -865,9 +951,26 @@
    */
   private ExternalId upsert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
+    return upsert(rw, ins, noteMap, extId, defaultNoteIdResolver);
+  }
+
+  /**
+   * Inserts or updates a new external ID and sets it in the note map.
+   *
+   * <p>If the external ID already exists, it is overwritten.
+   */
+  private ExternalId upsert(
+      RevWalk rw,
+      ObjectInserter ins,
+      NoteMap noteMap,
+      ExternalId extId,
+      Function<ExternalId, ObjectId> noteIdResolver)
+      throws IOException, ConfigInvalidException {
     ObjectId noteId = extId.key().sha1();
     Config c = new Config();
-    if (noteMap.contains(noteId)) {
+    ObjectId resolvedNoteId = noteIdResolver.apply(extId);
+    if (noteMap.contains(resolvedNoteId)) {
+      noteId = resolvedNoteId;
       ObjectId noteDataId = noteMap.get(noteId);
       byte[] raw = readNoteData(rw, noteDataId);
       try {
@@ -892,7 +995,8 @@
    */
   private void remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
-    ObjectId noteId = extId.key().sha1();
+    ObjectId noteId = getNoteId(extId.key());
+
     if (!noteMap.contains(noteId)) {
       return;
     }
@@ -916,10 +1020,12 @@
    * @return the external ID that was removed, {@code null} if no external ID with the specified key
    *     exists
    */
+  @Nullable
   private ExternalId remove(
       RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
-    ObjectId noteId = extIdKey.sha1();
+    ObjectId noteId = getNoteId(extIdKey);
+
     if (!noteMap.contains(noteId)) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index 0d715ae..fb7f6c4 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -70,13 +71,15 @@
   private final Timer0 readAllLatency;
   private final Timer0 readSingleLatency;
   private final ExternalIdFactory externalIdFactory;
+  private final AuthConfig authConfig;
 
   @Inject
   ExternalIdReader(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       MetricMaker metricMaker,
-      ExternalIdFactory externalIdFactory) {
+      ExternalIdFactory externalIdFactory,
+      AuthConfig authConfig) {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.readAllLatency =
@@ -92,6 +95,7 @@
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS));
     this.externalIdFactory = externalIdFactory;
+    this.authConfig = authConfig;
   }
 
   @VisibleForTesting
@@ -99,6 +103,12 @@
     this.failOnLoad = failOnLoad;
   }
 
+  public void checkReadEnabled() throws IOException {
+    if (failOnLoad) {
+      throw new IOException("Reading from external IDs is disabled");
+    }
+  }
+
   ObjectId readRevision() throws IOException {
     try (Repository repo = repoManager.openRepository(allUsersName)) {
       return readRevision(repo);
@@ -111,7 +121,13 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo, null, externalIdFactory).all();
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              null,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .all();
     }
   }
 
@@ -130,7 +146,13 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev, externalIdFactory).all();
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              rev,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .all();
     }
   }
 
@@ -140,7 +162,13 @@
 
     try (Timer0.Context ctx = readSingleLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo, null, externalIdFactory).get(key);
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              null,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .get(key);
     }
   }
 
@@ -151,13 +179,13 @@
 
     try (Timer0.Context ctx = readSingleLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev, externalIdFactory).get(key);
-    }
-  }
-
-  private void checkReadEnabled() throws IOException {
-    if (failOnLoad) {
-      throw new IOException("Reading from external IDs is disabled");
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              rev,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .get(key);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
index 4e1e524..9450ff5 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -35,11 +36,19 @@
 public class ExternalIds {
   private final ExternalIdReader externalIdReader;
   private final ExternalIdCache externalIdCache;
+  private final AuthConfig authConfig;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
-  public ExternalIds(ExternalIdReader externalIdReader, ExternalIdCache externalIdCache) {
+  public ExternalIds(
+      ExternalIdReader externalIdReader,
+      ExternalIdCache externalIdCache,
+      ExternalIdKeyFactory externalIdKeyFactory,
+      AuthConfig authConfig) {
     this.externalIdReader = externalIdReader;
     this.externalIdCache = externalIdCache;
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
   }
 
   /** Returns all external IDs. */
@@ -54,7 +63,15 @@
 
   /** Returns the specified external ID. */
   public Optional<ExternalId> get(ExternalId.Key key) throws IOException {
-    return externalIdCache.byKey(key);
+    Optional<ExternalId> externalId = Optional.empty();
+    if (authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+      externalId =
+          externalIdCache.byKey(externalIdKeyFactory.create(key.scheme(), key.id(), false));
+    }
+    if (!externalId.isPresent()) {
+      externalId = externalIdCache.byKey(key);
+    }
+    return externalId;
   }
 
   /** Returns the specified external ID from the given revision. */
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index cf0e5d3..4e67e3d 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -61,14 +61,14 @@
 
   public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, null, externalIdFactory));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, null, externalIdFactory, false));
     }
   }
 
   public List<ConsistencyProblemInfo> check(ObjectId rev)
       throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, rev, externalIdFactory));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, rev, externalIdFactory, false));
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigratiorExecutor.java b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigratiorExecutor.java
new file mode 100644
index 0000000..8a3e4f1
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigratiorExecutor.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface OnlineExternalIdCaseSensivityMigratiorExecutor {}
diff --git a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
new file mode 100644
index 0000000..72e7e90
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.index.ReindexerAlreadyRunningException;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+@Singleton
+public class OnlineExternalIdCaseSensivityMigrator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private Executor executor;
+  private ExternalIdCaseSensitivityMigrator.Factory migratorFactory;
+  private ExternalIds externalIds;
+  private VersionManager versionManager;
+  private Config globalConfig;
+  private Path sitePath;
+  private final TextProgressMonitor monitor = new TextProgressMonitor();
+  private boolean isUserNameCaseInsensitive;
+  private boolean isUserNameCaseInsensitiveMigrationMode;
+
+  @Inject
+  public OnlineExternalIdCaseSensivityMigrator(
+      @OnlineExternalIdCaseSensivityMigratiorExecutor ExecutorService executor,
+      ExternalIdCaseSensitivityMigrator.Factory migratorFactory,
+      ExternalIds externalIds,
+      VersionManager versionManager,
+      @GerritServerConfig Config globalConfig,
+      @SitePath Path sitePath) {
+    this.migratorFactory = migratorFactory;
+    this.externalIds = externalIds;
+    this.versionManager = versionManager;
+    this.globalConfig = globalConfig;
+    this.sitePath = sitePath;
+    this.executor = executor;
+    this.isUserNameCaseInsensitiveMigrationMode =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitiveMigrationMode", false);
+    this.isUserNameCaseInsensitive =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitive", false);
+  }
+
+  public void migrate() {
+    if (!isUserNameCaseInsensitive || !isUserNameCaseInsensitiveMigrationMode) {
+      logger.atSevere().log(
+          "External IDs online migration requires auth.userNameCaseInsensitive and"
+              + " auth.userNameCaseInsensitiveMigrationMode to be set to true. Skipping"
+              + " migration!");
+      return;
+    }
+    executor.execute(
+        () -> {
+          try {
+            Collection<ExternalId> todo = externalIds.all();
+            try {
+              monitor.beginTask("Converting external ID note names", todo.size());
+              migratorFactory
+                  .create(isUserNameCaseInsensitive, false)
+                  .migrate(todo, () -> monitor.update(1));
+            } finally {
+              monitor.endTask();
+            }
+            try {
+              updateGerritConfig();
+              monitor.beginTask("Reindex accounts", ProgressMonitor.UNKNOWN);
+              versionManager.startReindexer("accounts", true);
+            } finally {
+              monitor.endTask();
+            }
+            logger.atInfo().log("External IDs migration completed!");
+          } catch (IOException | ConfigInvalidException e) {
+            logger.atSevere().withCause(e).log(
+                "Exception during the external ids migration, cause %s", e.getMessage());
+          } catch (ReindexerAlreadyRunningException e) {
+            logger.atSevere().log("Failed to reindex external ids: %s", e.getMessage());
+          }
+        });
+  }
+
+  private void updateGerritConfig() throws IOException, ConfigInvalidException {
+    logger.atInfo().log(
+        "Setting auth.userNameCaseInsensitiveMigrationMode to false in gerrit.config.");
+
+    FileBasedConfig config =
+        new FileBasedConfig(
+            globalConfig, sitePath.resolve("etc/gerrit.config").toFile(), FS.DETECTED);
+    config.load();
+    config.setBoolean("auth", null, "userNameCaseInsensitiveMigrationMode", false);
+
+    config.save();
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
index 33443c1..eb2bea9 100644
--- a/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
+++ b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
@@ -20,6 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import java.util.Collection;
 
@@ -29,9 +30,12 @@
 
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
+  private AuthConfig authConfig;
+
   @Inject
-  public PasswordVerifier(ExternalIdKeyFactory externalIdKeyFactory) {
+  public PasswordVerifier(ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
     this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
   }
 
   /** Returns {@code true} if there is an external ID matching both the username and password. */
@@ -40,13 +44,23 @@
     if (password == null) {
       return false;
     }
+
     for (ExternalId id : externalIds) {
       // Only process the "username:$USER" entry, which is unique.
-      if (!id.isScheme(SCHEME_USERNAME)
-          || !id.key().equals(externalIdKeyFactory.create(SCHEME_USERNAME, username))) {
+      if (!id.isScheme(SCHEME_USERNAME)) {
         continue;
       }
 
+      if (!id.key().equals(externalIdKeyFactory.create(SCHEME_USERNAME, username))) {
+        if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+          continue;
+        }
+
+        if (!id.key().equals(externalIdKeyFactory.create(SCHEME_USERNAME, username, false))) {
+          continue;
+        }
+      }
+
       String hashedStr = id.password();
       if (!Strings.isNullOrEmpty(hashedStr)) {
         try {
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index b23782f..828f868 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.AgreementInput;
@@ -575,6 +576,7 @@
     }
   }
 
+  @Nullable
   @Override
   public String generateHttpPassword() throws RestApiException {
     HttpPasswordInput input = new HttpPasswordInput();
@@ -589,6 +591,7 @@
     }
   }
 
+  @Nullable
   @Override
   public String setHttpPassword(String password) throws RestApiException {
     HttpPasswordInput input = new HttpPasswordInput();
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a49061d..66a845a 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -19,8 +19,8 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -54,6 +54,7 @@
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
@@ -65,13 +66,12 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.DynamicOptions;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.change.ChangeMessageResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.restapi.change.Abandon;
 import com.google.gerrit.server.restapi.change.AddToAttentionSet;
+import com.google.gerrit.server.restapi.change.ApplyPatch;
 import com.google.gerrit.server.restapi.change.AttentionSet;
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
 import com.google.gerrit.server.restapi.change.ChangeMessages;
@@ -88,7 +88,6 @@
 import com.google.gerrit.server.restapi.change.GetPastAssignees;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
 import com.google.gerrit.server.restapi.change.GetTopic;
-import com.google.gerrit.server.restapi.change.Ignore;
 import com.google.gerrit.server.restapi.change.Index;
 import com.google.gerrit.server.restapi.change.ListChangeComments;
 import com.google.gerrit.server.restapi.change.ListChangeDrafts;
@@ -102,6 +101,7 @@
 import com.google.gerrit.server.restapi.change.PutMessage;
 import com.google.gerrit.server.restapi.change.PutTopic;
 import com.google.gerrit.server.restapi.change.Rebase;
+import com.google.gerrit.server.restapi.change.RebaseChain;
 import com.google.gerrit.server.restapi.change.Restore;
 import com.google.gerrit.server.restapi.change.Revert;
 import com.google.gerrit.server.restapi.change.RevertSubmission;
@@ -111,7 +111,6 @@
 import com.google.gerrit.server.restapi.change.SetWorkInProgress;
 import com.google.gerrit.server.restapi.change.SubmittedTogether;
 import com.google.gerrit.server.restapi.change.SuggestChangeReviewers;
-import com.google.gerrit.server.restapi.change.Unignore;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -144,8 +143,10 @@
   private final RevertSubmission revertSubmission;
   private final Restore restore;
   private final CreateMergePatchSet updateByMerge;
+  private final ApplyPatch applyPatch;
   private final Provider<SubmittedTogether> submittedTogether;
   private final Rebase.CurrentRevision rebase;
+  private final RebaseChain rebaseChain;
   private final DeleteChange deleteChange;
   private final GetTopic getTopic;
   private final PutTopic putTopic;
@@ -167,18 +168,15 @@
   private final Provider<ListChangeDrafts> listDraftsProvider;
   private final ChangeEditApiImpl.Factory changeEditApi;
   private final Check check;
-  private final CheckSubmitRequirement checkSubmitRequirement;
+  private final Provider<CheckSubmitRequirement> checkSubmitRequirementProvider;
   private final Index index;
   private final Move move;
   private final PostPrivate postPrivate;
   private final DeletePrivate deletePrivate;
-  private final Ignore ignore;
-  private final Unignore unignore;
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
   private final Provider<GetPureRevert> getPureRevertProvider;
-  private final StarredChangesUtil stars;
   private final DynamicOptionParser dynamicOptionParser;
   private final Injector injector;
   private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
@@ -199,8 +197,10 @@
       RevertSubmission revertSubmission,
       Restore restore,
       CreateMergePatchSet updateByMerge,
+      ApplyPatch applyPatch,
       Provider<SubmittedTogether> submittedTogether,
       Rebase.CurrentRevision rebase,
+      RebaseChain rebaseChain,
       DeleteChange deleteChange,
       GetTopic getTopic,
       PutTopic putTopic,
@@ -222,18 +222,15 @@
       Provider<ListChangeDrafts> listDraftsProvider,
       ChangeEditApiImpl.Factory changeEditApi,
       Check check,
-      CheckSubmitRequirement checkSubmitRequirement,
+      Provider<CheckSubmitRequirement> checkSubmitRequirement,
       Index index,
       Move move,
       PostPrivate postPrivate,
       DeletePrivate deletePrivate,
-      Ignore ignore,
-      Unignore unignore,
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
       PutMessage putMessage,
       Provider<GetPureRevert> getPureRevertProvider,
-      StarredChangesUtil stars,
       DynamicOptionParser dynamicOptionParser,
       @Assisted ChangeResource change,
       Injector injector,
@@ -252,8 +249,10 @@
     this.abandon = abandon;
     this.restore = restore;
     this.updateByMerge = updateByMerge;
+    this.applyPatch = applyPatch;
     this.submittedTogether = submittedTogether;
     this.rebase = rebase;
+    this.rebaseChain = rebaseChain;
     this.deleteChange = deleteChange;
     this.getTopic = getTopic;
     this.putTopic = putTopic;
@@ -275,18 +274,15 @@
     this.listDraftsProvider = listDraftsProvider;
     this.changeEditApi = changeEditApi;
     this.check = check;
-    this.checkSubmitRequirement = checkSubmitRequirement;
+    this.checkSubmitRequirementProvider = checkSubmitRequirement;
     this.index = index;
     this.move = move;
     this.postPrivate = postPrivate;
     this.deletePrivate = deletePrivate;
-    this.ignore = ignore;
-    this.unignore = unignore;
     this.setWip = setWip;
     this.setReady = setReady;
     this.putMessage = putMessage;
     this.getPureRevertProvider = getPureRevertProvider;
-    this.stars = stars;
     this.dynamicOptionParser = dynamicOptionParser;
     this.change = change;
     this.injector = injector;
@@ -403,6 +399,15 @@
   }
 
   @Override
+  public ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException {
+    try {
+      return applyPatch.apply(change, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot apply patch", e);
+    }
+  }
+
+  @Override
   public SubmittedTogetherInfo submittedTogether(
       EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
       throws RestApiException {
@@ -427,6 +432,15 @@
   }
 
   @Override
+  public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+    try {
+      return rebaseChain.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase chain", e);
+    }
+  }
+
+  @Override
   public void delete() throws RestApiException {
     try {
       deleteChange.apply(change, null);
@@ -597,6 +611,7 @@
     }
   }
 
+  @Nullable
   @Override
   public AccountInfo getAssignee() throws RestApiException {
     try {
@@ -616,6 +631,7 @@
     }
   }
 
+  @Nullable
   @Override
   public AccountInfo deleteAssignee() throws RestApiException {
     try {
@@ -714,10 +730,27 @@
   }
 
   @Override
+  public CheckSubmitRequirementRequest checkSubmitRequirementRequest() {
+    return new CheckSubmitRequirementRequest() {
+      @Override
+      public SubmitRequirementResultInfo get() throws RestApiException {
+        try {
+          CheckSubmitRequirement check = checkSubmitRequirementProvider.get();
+          check.setSrName(this.srName());
+          check.setRefsConfigChangeId(this.getRefsConfigChangeId());
+          return check.apply(change, null).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot check submit requirement", e);
+        }
+      }
+    };
+  }
+
+  @Override
   public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
       throws RestApiException {
     try {
-      return checkSubmitRequirement.apply(change, input).value();
+      return checkSubmitRequirementProvider.get().apply(change, input).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot check submit requirement", e);
     }
@@ -733,30 +766,6 @@
   }
 
   @Override
-  public void ignore(boolean ignore) throws RestApiException {
-    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
-    // StarredChangesUtil.
-    try {
-      if (ignore) {
-        this.ignore.apply(change, new Input());
-      } else {
-        unignore.apply(change, new Input());
-      }
-    } catch (StorageException | IllegalLabelException e) {
-      throw asRestApiException("Cannot ignore change", e);
-    }
-  }
-
-  @Override
-  public boolean ignored() throws RestApiException {
-    try {
-      return stars.isIgnored(change);
-    } catch (StorageException e) {
-      throw asRestApiException("Cannot check if ignored", e);
-    }
-  }
-
-  @Override
   public PureRevertInfo pureRevert() throws RestApiException {
     return pureRevert(null);
   }
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 764c46d..a7931f1 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.api.changes.DraftApi;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.FileApi;
+import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -40,6 +41,7 @@
 import com.google.gerrit.extensions.client.ArchiveFormat;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -64,7 +66,8 @@
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.restapi.change.ApplyFix;
+import com.google.gerrit.server.restapi.change.ApplyProvidedFix;
+import com.google.gerrit.server.restapi.change.ApplyStoredFix;
 import com.google.gerrit.server.restapi.change.CherryPick;
 import com.google.gerrit.server.restapi.change.Comments;
 import com.google.gerrit.server.restapi.change.CreateDraftComment;
@@ -74,7 +77,6 @@
 import com.google.gerrit.server.restapi.change.GetArchive;
 import com.google.gerrit.server.restapi.change.GetCommit;
 import com.google.gerrit.server.restapi.change.GetDescription;
-import com.google.gerrit.server.restapi.change.GetFixPreview;
 import com.google.gerrit.server.restapi.change.GetMergeList;
 import com.google.gerrit.server.restapi.change.GetPatch;
 import com.google.gerrit.server.restapi.change.GetRelated;
@@ -86,7 +88,7 @@
 import com.google.gerrit.server.restapi.change.ListRobotComments;
 import com.google.gerrit.server.restapi.change.Mergeable;
 import com.google.gerrit.server.restapi.change.PostReview;
-import com.google.gerrit.server.restapi.change.PreviewSubmit;
+import com.google.gerrit.server.restapi.change.PreviewFix;
 import com.google.gerrit.server.restapi.change.PutDescription;
 import com.google.gerrit.server.restapi.change.Rebase;
 import com.google.gerrit.server.restapi.change.Reviewed;
@@ -119,7 +121,6 @@
   private final Rebase rebase;
   private final RebaseUtil rebaseUtil;
   private final Submit submit;
-  private final PreviewSubmit submitPreview;
   private final Reviewed.PutReviewed putReviewed;
   private final Reviewed.DeleteReviewed deleteReviewed;
   private final RevisionResource revision;
@@ -134,8 +135,10 @@
   private final ListRobotComments listRobotComments;
   private final ListPortedComments listPortedComments;
   private final ListPortedDrafts listPortedDrafts;
-  private final ApplyFix applyFix;
-  private final GetFixPreview getFixPreview;
+  private final ApplyStoredFix applyStoredFix;
+  private final PreviewFix.Stored previewStoredFix;
+  private final ApplyProvidedFix applyProvidedFix;
+  private final PreviewFix.Provided previewProvidedFix;
   private final Fixes fixes;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
@@ -167,7 +170,6 @@
       Rebase rebase,
       RebaseUtil rebaseUtil,
       Submit submit,
-      PreviewSubmit submitPreview,
       Reviewed.PutReviewed putReviewed,
       Reviewed.DeleteReviewed deleteReviewed,
       Files files,
@@ -181,8 +183,10 @@
       ListRobotComments listRobotComments,
       ListPortedComments listPortedComments,
       ListPortedDrafts listPortedDrafts,
-      ApplyFix applyFix,
-      GetFixPreview getFixPreview,
+      ApplyStoredFix applyStoredFix,
+      PreviewFix.Stored previewStoredFix,
+      ApplyProvidedFix applyProvidedFix,
+      PreviewFix.Provided previewProvidedFix,
       Fixes fixes,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
@@ -213,7 +217,6 @@
     this.rebaseUtil = rebaseUtil;
     this.review = review;
     this.submit = submit;
-    this.submitPreview = submitPreview;
     this.files = files;
     this.putReviewed = putReviewed;
     this.deleteReviewed = deleteReviewed;
@@ -227,8 +230,10 @@
     this.listRobotComments = listRobotComments;
     this.listPortedComments = listPortedComments;
     this.listPortedDrafts = listPortedDrafts;
-    this.applyFix = applyFix;
-    this.getFixPreview = getFixPreview;
+    this.applyStoredFix = applyStoredFix;
+    this.previewStoredFix = previewStoredFix;
+    this.applyProvidedFix = applyProvidedFix;
+    this.previewProvidedFix = previewProvidedFix;
     this.fixes = fixes;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
@@ -270,16 +275,6 @@
   }
 
   @Override
-  public BinaryResult submitPreview(String format) throws RestApiException {
-    try {
-      submitPreview.setFormat(format);
-      return submitPreview.apply(revision).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get submit preview", e);
-    }
-  }
-
-  @Override
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
       return changes.id(rebaseAsInfo(in)._number);
@@ -491,7 +486,16 @@
   @Override
   public EditInfo applyFix(String fixId) throws RestApiException {
     try {
-      return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+      return applyStoredFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot apply stored fix", e);
+    }
+  }
+
+  @Override
+  public EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException {
+    try {
+      return applyProvidedFix.apply(revision, applyProvidedFixInput).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot apply fix", e);
     }
@@ -500,9 +504,19 @@
   @Override
   public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException {
     try {
-      return getFixPreview.apply(fixes.parse(revision, IdString.fromDecoded(fixId))).value();
+      return previewStoredFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId))).value();
     } catch (Exception e) {
-      throw asRestApiException("Cannot get fix preview", e);
+      throw asRestApiException("Cannot preview stored fix", e);
+    }
+  }
+
+  @Override
+  public Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput)
+      throws RestApiException {
+    try {
+      return previewProvidedFix.apply(revision, applyProvidedFixInput).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot preview provided fix", e);
     }
   }
 
@@ -629,7 +643,13 @@
 
   @Override
   public RelatedChangesInfo related() throws RestApiException {
+    return related(EnumSet.noneOf(GetRelatedOption.class));
+  }
+
+  @Override
+  public RelatedChangesInfo related(EnumSet<GetRelatedOption> options) throws RestApiException {
     try {
+      options.forEach(getRelated::addOption);
       return getRelated.apply(revision).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot get related changes", e);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 6d7fc15..3a892bc 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.extensions.api.projects.ParentInput;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.BatchLabelInput;
@@ -48,6 +49,7 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -63,6 +65,7 @@
 import com.google.gerrit.server.restapi.project.CheckAccess;
 import com.google.gerrit.server.restapi.project.ChildProjectsCollection;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
+import com.google.gerrit.server.restapi.project.CommitsIncludedInRefs;
 import com.google.gerrit.server.restapi.project.CreateAccessChange;
 import com.google.gerrit.server.restapi.project.CreateProject;
 import com.google.gerrit.server.restapi.project.DeleteBranches;
@@ -77,6 +80,7 @@
 import com.google.gerrit.server.restapi.project.ListBranches;
 import com.google.gerrit.server.restapi.project.ListDashboards;
 import com.google.gerrit.server.restapi.project.ListLabels;
+import com.google.gerrit.server.restapi.project.ListSubmitRequirements;
 import com.google.gerrit.server.restapi.project.ListTags;
 import com.google.gerrit.server.restapi.project.PostLabels;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
@@ -88,8 +92,11 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 public class ProjectApiImpl implements ProjectApi {
   interface Factory {
@@ -116,6 +123,7 @@
   private final CreateAccessChange createAccessChange;
   private final GetConfig getConfig;
   private final PutConfig putConfig;
+  private final CommitsIncludedInRefs commitsIncludedInRefs;
   private final Provider<ListBranches> listBranches;
   private final Provider<ListTags> listTags;
   private final DeleteBranches deleteBranches;
@@ -133,8 +141,10 @@
   private final Index index;
   private final IndexChanges indexChanges;
   private final Provider<ListLabels> listLabels;
+  private final Provider<ListSubmitRequirements> listSubmitRequirements;
   private final PostLabels postLabels;
   private final LabelApiImpl.Factory labelApi;
+  private final SubmitRequirementApiImpl.Factory submitRequirementApi;
 
   @AssistedInject
   ProjectApiImpl(
@@ -154,6 +164,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
@@ -171,8 +182,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       @Assisted ProjectResource project) {
     this(
         permissionBackend,
@@ -191,6 +204,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        commitsIncludedInRefs,
         listBranches,
         listTags,
         deleteBranches,
@@ -209,8 +223,10 @@
         index,
         indexChanges,
         listLabels,
+        listSubmitRequirements,
         postLabels,
         labelApi,
+        submitRequirementApi,
         null);
   }
 
@@ -232,6 +248,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
@@ -249,8 +266,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       @Assisted String name) {
     this(
         permissionBackend,
@@ -269,6 +288,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        commitsIncludedInRefs,
         listBranches,
         listTags,
         deleteBranches,
@@ -287,8 +307,10 @@
         index,
         indexChanges,
         listLabels,
+        listSubmitRequirements,
         postLabels,
         labelApi,
+        submitRequirementApi,
         name);
   }
 
@@ -309,6 +331,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
@@ -327,8 +350,10 @@
       Index index,
       IndexChanges indexChanges,
       Provider<ListLabels> listLabels,
+      Provider<ListSubmitRequirements> listSubmitRequirements,
       PostLabels postLabels,
       LabelApiImpl.Factory labelApi,
+      SubmitRequirementApiImpl.Factory submitRequirementApi,
       String name) {
     this.permissionBackend = permissionBackend;
     this.createProject = createProject;
@@ -346,6 +371,7 @@
     this.setAccess = setAccess;
     this.getConfig = getConfig;
     this.putConfig = putConfig;
+    this.commitsIncludedInRefs = commitsIncludedInRefs;
     this.listBranches = listBranches;
     this.listTags = listTags;
     this.deleteBranches = deleteBranches;
@@ -365,8 +391,10 @@
     this.index = index;
     this.indexChanges = indexChanges;
     this.listLabels = listLabels;
+    this.listSubmitRequirements = listSubmitRequirements;
     this.postLabels = postLabels;
     this.labelApi = labelApi;
+    this.submitRequirementApi = submitRequirementApi;
   }
 
   @Override
@@ -483,8 +511,20 @@
   }
 
   @Override
+  public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
+      throws RestApiException {
+    try {
+      commitsIncludedInRefs.addCommits(commits);
+      commitsIncludedInRefs.addRefs(refs);
+      return commitsIncludedInRefs.apply(project).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list commits included in refs", e);
+    }
+  }
+
+  @Override
   public ListRefsRequest<BranchInfo> branches() {
-    return new ListRefsRequest<BranchInfo>() {
+    return new ListRefsRequest<>() {
       @Override
       public List<BranchInfo> get() throws RestApiException {
         try {
@@ -498,7 +538,7 @@
 
   @Override
   public ListRefsRequest<TagInfo> tags() {
-    return new ListRefsRequest<TagInfo>() {
+    return new ListRefsRequest<>() {
       @Override
       public List<TagInfo> get() throws RestApiException {
         try {
@@ -714,6 +754,20 @@
   }
 
   @Override
+  public ListSubmitRequirementsRequest submitRequirements() {
+    return new ListSubmitRequirementsRequest() {
+      @Override
+      public List<SubmitRequirementInfo> get() throws RestApiException {
+        try {
+          return listSubmitRequirements.get().withInherited(inherited).apply(checkExists()).value();
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list submit requirements", e);
+        }
+      }
+    };
+  }
+
+  @Override
   public LabelApi label(String labelName) throws RestApiException {
     try {
       return labelApi.create(checkExists(), labelName);
@@ -723,6 +777,15 @@
   }
 
   @Override
+  public SubmitRequirementApi submitRequirement(String name) throws RestApiException {
+    try {
+      return submitRequirementApi.create(checkExists(), name);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse submit requirement", e);
+    }
+  }
+
+  @Override
   public void labels(BatchLabelInput input) throws RestApiException {
     try {
       postLabels.apply(checkExists(), input);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java b/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java
new file mode 100644
index 0000000..8ed1175
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import com.google.gerrit.server.query.project.ProjectQueryBuilder;
+import com.google.gerrit.server.query.project.ProjectQueryBuilderImpl;
+import com.google.inject.AbstractModule;
+
+public class ProjectQueryBuilderModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ProjectQueryBuilder.class).to(ProjectQueryBuilderImpl.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsModule.java b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
index 987c71f..9f7e1b4 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsModule.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
@@ -29,5 +29,6 @@
     factory(CommitApiImpl.Factory.class);
     factory(DashboardApiImpl.Factory.class);
     factory(LabelApiImpl.Factory.class);
+    factory(SubmitRequirementApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
new file mode 100644
index 0000000..aa6ef71
--- /dev/null
+++ b/java/com/google/gerrit/server/api/projects/SubmitRequirementApiImpl.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.restapi.project.CreateSubmitRequirement;
+import com.google.gerrit.server.restapi.project.DeleteSubmitRequirement;
+import com.google.gerrit.server.restapi.project.GetSubmitRequirement;
+import com.google.gerrit.server.restapi.project.SubmitRequirementsCollection;
+import com.google.gerrit.server.restapi.project.UpdateSubmitRequirement;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SubmitRequirementApiImpl implements SubmitRequirementApi {
+  interface Factory {
+    SubmitRequirementApiImpl create(ProjectResource project, String name);
+  }
+
+  private final SubmitRequirementsCollection submitRequirements;
+  private final CreateSubmitRequirement createSubmitRequirement;
+  private final UpdateSubmitRequirement updateSubmitRequirement;
+  private final DeleteSubmitRequirement deleteSubmitRequirement;
+  private final GetSubmitRequirement getSubmitRequirement;
+  private final String name;
+  private final ProjectCache projectCache;
+
+  private ProjectResource project;
+
+  @Inject
+  SubmitRequirementApiImpl(
+      SubmitRequirementsCollection submitRequirements,
+      CreateSubmitRequirement createSubmitRequirement,
+      UpdateSubmitRequirement updateSubmitRequirement,
+      DeleteSubmitRequirement deleteSubmitRequirement,
+      GetSubmitRequirement getSubmitRequirement,
+      ProjectCache projectCache,
+      @Assisted ProjectResource project,
+      @Assisted String name) {
+    this.submitRequirements = submitRequirements;
+    this.createSubmitRequirement = createSubmitRequirement;
+    this.updateSubmitRequirement = updateSubmitRequirement;
+    this.deleteSubmitRequirement = deleteSubmitRequirement;
+    this.getSubmitRequirement = getSubmitRequirement;
+    this.projectCache = projectCache;
+    this.project = project;
+    this.name = name;
+  }
+
+  @Override
+  public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException {
+    try {
+      createSubmitRequirement.apply(project, IdString.fromDecoded(name), input);
+
+      // recreate project resource because project state was updated
+      project =
+          new ProjectResource(
+              projectCache
+                  .get(project.getNameKey())
+                  .orElseThrow(illegalState(project.getNameKey())),
+              project.getUser());
+
+      return this;
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create submit requirement", e);
+    }
+  }
+
+  @Override
+  public SubmitRequirementInfo get() throws RestApiException {
+    try {
+      return getSubmitRequirement.apply(resource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit requirement", e);
+    }
+  }
+
+  @Override
+  public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException {
+    try {
+      return updateSubmitRequirement.apply(resource(), input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update submit requirement", e);
+    }
+  }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteSubmitRequirement.apply(resource(), new Input());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete submit requirement", e);
+    }
+  }
+
+  private SubmitRequirementResource resource() throws RestApiException, PermissionBackendException {
+    return submitRequirements.parse(project, IdString.fromDecoded(name));
+  }
+}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCache.java b/java/com/google/gerrit/server/approval/ApprovalCache.java
deleted file mode 100644
index 5637249..0000000
--- a/java/com/google/gerrit/server/approval/ApprovalCache.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.approval;
-
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.server.notedb.ChangeNotes;
-
-/**
- * Cache that holds approvals per patch set and NoteDb state. This includes approvals copied forward
- * from older patch sets.
- */
-public interface ApprovalCache {
-  /** Returns {@link PatchSetApproval}s for the given patch set. */
-  Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId);
-}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java b/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
deleted file mode 100644
index fd31da9..0000000
--- a/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.approval;
-
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.proto.Entities;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.proto.Cache;
-import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
-import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Singleton;
-import com.google.inject.name.Named;
-import com.google.protobuf.ByteString;
-import java.util.concurrent.ExecutionException;
-
-/** Implementation of the {@link ApprovalCache} interface */
-public class ApprovalCacheImpl implements ApprovalCache {
-  private static final String CACHE_NAME = "approvals";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        bind(ApprovalCache.class).to(ApprovalCacheImpl.class);
-        persist(
-                CACHE_NAME,
-                Cache.PatchSetApprovalsKeyProto.class,
-                Cache.AllPatchSetApprovalsProto.class)
-            .version(2)
-            .loader(Loader.class)
-            .keySerializer(new ProtobufSerializer<>(Cache.PatchSetApprovalsKeyProto.parser()))
-            .valueSerializer(new ProtobufSerializer<>(Cache.AllPatchSetApprovalsProto.parser()));
-      }
-    };
-  }
-
-  private final LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto>
-      cache;
-
-  @Inject
-  ApprovalCacheImpl(
-      @Named(CACHE_NAME)
-          LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> cache) {
-    this.cache = cache;
-  }
-
-  @Override
-  public Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId) {
-    try {
-      return fromProto(
-          cache.get(
-              Cache.PatchSetApprovalsKeyProto.newBuilder()
-                  .setChangeId(notes.getChangeId().get())
-                  .setPatchSetId(psId.get())
-                  .setProject(notes.getProjectName().get())
-                  .setId(
-                      ByteString.copyFrom(
-                          ObjectIdCacheSerializer.INSTANCE.serialize(notes.getMetaId())))
-                  .build()));
-    } catch (ExecutionException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  @Singleton
-  static class Loader
-      extends CacheLoader<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> {
-    private final ApprovalInference approvalInference;
-    private final ChangeNotes.Factory changeNotesFactory;
-
-    @Inject
-    Loader(ApprovalInference approvalInference, ChangeNotes.Factory changeNotesFactory) {
-      this.approvalInference = approvalInference;
-      this.changeNotesFactory = changeNotesFactory;
-    }
-
-    @Override
-    public Cache.AllPatchSetApprovalsProto load(Cache.PatchSetApprovalsKeyProto key)
-        throws Exception {
-      Change.Id changeId = Change.id(key.getChangeId());
-      return toProto(
-          approvalInference.forPatchSet(
-              changeNotesFactory.createChecked(
-                  Project.nameKey(key.getProject()),
-                  changeId,
-                  ObjectIdCacheSerializer.INSTANCE.deserialize(key.getId().toByteArray())),
-              PatchSet.id(changeId, key.getPatchSetId()),
-              null
-              /* revWalk= */ ,
-              null
-              /* repoConfig= */ ));
-    }
-  }
-
-  private static Iterable<PatchSetApproval> fromProto(Cache.AllPatchSetApprovalsProto proto) {
-    ImmutableList.Builder<PatchSetApproval> builder = ImmutableList.builder();
-    for (Entities.PatchSetApproval psa : proto.getApprovalList()) {
-      builder.add(PatchSetApprovalProtoConverter.INSTANCE.fromProto(psa));
-    }
-    return builder.build();
-  }
-
-  private static Cache.AllPatchSetApprovalsProto toProto(Iterable<PatchSetApproval> autoValue) {
-    Cache.AllPatchSetApprovalsProto.Builder builder = Cache.AllPatchSetApprovalsProto.newBuilder();
-    for (PatchSetApproval psa : autoValue) {
-      builder.addApproval(PatchSetApprovalProtoConverter.INSTANCE.toProto(psa));
-    }
-    return builder.build();
-  }
-}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
new file mode 100644
index 0000000..a1889da
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -0,0 +1,583 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.approval;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.PatchSetUtil;
+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.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.approval.ApprovalContext;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Computes copied approvals for a given patch set.
+ *
+ * <p>Approvals are copied if:
+ *
+ * <ul>
+ *   <li>the approval on the previous patch set matches the copy condition of its label
+ *   <li>the approval is not overridden by a current approval on the patch set
+ * </ul>
+ *
+ * <p>Callers should store the copied approvals in NoteDb when a new patch set is created.
+ */
+@Singleton
+@VisibleForTesting
+public class ApprovalCopier {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @AutoValue
+  public abstract static class Result {
+    /**
+     * Approvals that have been copied from the previous patch set.
+     *
+     * <p>An approval is copied if:
+     *
+     * <ul>
+     *   <li>the approval on the previous patch set matches the copy condition of its label
+     *   <li>the approval is not overridden by a current approval on the patch set
+     * </ul>
+     */
+    public abstract ImmutableSet<PatchSetApprovalData> copiedApprovals();
+
+    /**
+     * Approvals on the previous patch set that have not been copied to the patch set.
+     *
+     * <p>These approvals didn't match the copy condition of their labels and hence haven't been
+     * copied.
+     *
+     * <p>Only returns non-copied approvals of the previous patch set. Approvals from earlier patch
+     * sets that were outdated before are not included.
+     */
+    public abstract ImmutableSet<PatchSetApprovalData> outdatedApprovals();
+
+    static Result empty() {
+      return create(
+          /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    }
+
+    @VisibleForTesting
+    public static Result create(
+        ImmutableSet<PatchSetApprovalData> copiedApprovals,
+        ImmutableSet<PatchSetApprovalData> outdatedApprovals) {
+      return new AutoValue_ApprovalCopier_Result(copiedApprovals, outdatedApprovals);
+    }
+
+    /**
+     * A {@link PatchSetApproval} with information about which atoms of the copy condition are
+     * passing/failing.
+     */
+    @AutoValue
+    public abstract static class PatchSetApprovalData {
+      /** The approval. */
+      public abstract PatchSetApproval patchSetApproval();
+
+      /**
+       * Lists the leaf predicates of the copy condition that are fulfilled.
+       *
+       * <p>Example: The expression
+       *
+       * <pre>
+       * changekind:TRIVIAL_REBASE OR is:MIN
+       * </pre>
+       *
+       * has two leaf predicates:
+       *
+       * <ul>
+       *   <li>changekind:TRIVIAL_REBASE
+       *   <li>is:MIN
+       * </ul>
+       *
+       * This method will return the leaf predicates that are fulfilled, for example if only the
+       * first predicate is fulfilled, the returned list will be equal to
+       * ["changekind:TRIVIAL_REBASE"].
+       *
+       * <p>Empty if the label type is missing, if there is no copy condition or if the copy
+       * condition is not parseable.
+       */
+      public abstract ImmutableSet<String> passingAtoms();
+
+      /**
+       * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
+       * #passingAtoms()} for more details.
+       *
+       * <p>Empty if the label type is missing, if there is no copy condition or if the copy
+       * condition is not parseable.
+       */
+      public abstract ImmutableSet<String> failingAtoms();
+
+      @VisibleForTesting
+      public static PatchSetApprovalData create(
+          PatchSetApproval approval,
+          ImmutableSet<String> passingAtoms,
+          ImmutableSet<String> failingAtoms) {
+        return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
+            approval, passingAtoms, failingAtoms);
+      }
+
+      private static PatchSetApprovalData createForMissingLabelType(PatchSetApproval approval) {
+        return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
+            approval, ImmutableSet.of(), ImmutableSet.of());
+      }
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final ProjectCache projectCache;
+  private final ChangeKindCache changeKindCache;
+  private final PatchSetUtil psUtil;
+  private final LabelNormalizer labelNormalizer;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
+  private final OneOffRequestContext requestContext;
+
+  @Inject
+  ApprovalCopier(
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      ChangeKindCache changeKindCache,
+      PatchSetUtil psUtil,
+      LabelNormalizer labelNormalizer,
+      ApprovalQueryBuilder approvalQueryBuilder,
+      OneOffRequestContext requestContext) {
+    this.repoManager = repoManager;
+    this.projectCache = projectCache;
+    this.changeKindCache = changeKindCache;
+    this.psUtil = psUtil;
+    this.labelNormalizer = labelNormalizer;
+    this.approvalQueryBuilder = approvalQueryBuilder;
+    this.requestContext = requestContext;
+  }
+
+  /**
+   * Returns all copied approvals that apply to the given patch set.
+   *
+   * <p>Approvals are copied if:
+   *
+   * <ul>
+   *   <li>the approval on the previous patch set matches the copy condition of its label
+   *   <li>the approval is not overridden by a current approval on the patch set
+   * </ul>
+   */
+  @VisibleForTesting
+  public Result forPatchSet(ChangeNotes notes, PatchSet ps, RevWalk rw, Config repoConfig) {
+    ProjectState project;
+    try (TraceTimer traceTimer =
+        TraceContext.newTimer(
+            "Computing labels for patch set",
+            Metadata.builder()
+                .changeId(notes.load().getChangeId().get())
+                .patchSetId(ps.id().get())
+                .build())) {
+      project =
+          projectCache
+              .get(notes.getProjectName())
+              .orElseThrow(illegalState(notes.getProjectName()));
+      return computeForPatchSet(project.getLabelTypes(), notes, ps, rw, repoConfig);
+    }
+  }
+
+  /**
+   * Returns all follow-up patch sets of the given patch set to which the given approval is
+   * copyable.
+   *
+   * <p>An approval is considered as copyable to a follow-up patch set if it matches the copy rules
+   * of the label and it is copyable to all intermediate follow-up patch sets as well.
+   *
+   * <p>The returned follow-up patch sets are returned in the order of their patch set IDs.
+   *
+   * <p>Note: This method only checks the copy rules to detect if the approval is copyable. There
+   * are other factors, not checked here, that can prevent the copying of the approval to the
+   * returned follow-up patch sets (e.g. if they already have a matching non-copy approval that
+   * prevents the copying).
+   *
+   * @param changeNotes the change notes
+   * @param sourcePatchSet the patch set on which the approval was applied
+   * @param approverId the account ID of the user that applied the approval
+   * @param label the label of the approval that was applied
+   * @param approvalValue the value of the approval that was applied
+   * @return the follow-up patch sets to which the approval is copyable, ordered by patch set ID
+   */
+  public ImmutableList<PatchSet.Id> forApproval(
+      ChangeNotes changeNotes,
+      PatchSet sourcePatchSet,
+      Account.Id approverId,
+      String label,
+      short approvalValue)
+      throws IOException {
+    ImmutableList.Builder<PatchSet.Id> targetPatchSetsBuilder = ImmutableList.builder();
+
+    Optional<LabelType> labelType =
+        projectCache
+            .get(changeNotes.getProjectName())
+            .orElseThrow(illegalState(changeNotes.getProjectName()))
+            .getLabelTypes()
+            .byLabel(label);
+    if (!labelType.isPresent()) {
+      // no label type exists for this label, hence this approval cannot be copied
+      return ImmutableList.of();
+    }
+
+    try (Repository repo = repoManager.openRepository(changeNotes.getProjectName());
+        RevWalk revWalk = new RevWalk(repo)) {
+      ImmutableList<PatchSet.Id> followUpPatchSets =
+          changeNotes.getPatchSets().keySet().stream()
+              .filter(psId -> psId.get() > sourcePatchSet.id().get())
+              .collect(toImmutableList());
+      PatchSet priorPatchSet = sourcePatchSet;
+
+      // Iterate over the follow-up patch sets in order to copy the approval from their prior patch
+      // set if possible (copy from PS N-1 to PS N).
+      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
+        PatchSet followUpPatchSet = psUtil.get(changeNotes, followUpPatchSetId);
+        ChangeKind changeKind =
+            changeKindCache.getChangeKind(
+                changeNotes.getProjectName(),
+                revWalk,
+                repo.getConfig(),
+                priorPatchSet.commitId(),
+                followUpPatchSet.commitId());
+        boolean isMerge = isMerge(changeNotes.getProjectName(), revWalk, followUpPatchSet);
+
+        if (computeCopyResult(
+                changeNotes,
+                priorPatchSet.id(),
+                followUpPatchSet,
+                approverId,
+                labelType.get(),
+                approvalValue,
+                changeKind,
+                isMerge,
+                revWalk,
+                repo.getConfig())
+            .canCopy()) {
+          targetPatchSetsBuilder.add(followUpPatchSetId);
+        } else {
+          // The approval is not copyable to this follow-up patch set.
+          // This means it's also not copyable to any further follow-up patch set and we should stop
+          // the loop here.
+          break;
+        }
+        priorPatchSet = followUpPatchSet;
+      }
+    }
+    return targetPatchSetsBuilder.build();
+  }
+
+  /**
+   * Checks whether a given approval can be copied from the given source patch set to the given
+   * target patch set.
+   *
+   * <p>The returned result also informs about which atoms of the copy condition are
+   * passing/failing.
+   */
+  private ApprovalCopyResult computeCopyResult(
+      ChangeNotes changeNotes,
+      PatchSet.Id sourcePatchSetId,
+      PatchSet targetPatchSet,
+      Account.Id approverId,
+      LabelType labelType,
+      short approvalValue,
+      ChangeKind changeKind,
+      boolean isMerge,
+      RevWalk revWalk,
+      Config repoConfig) {
+    if (!labelType.getCopyCondition().isPresent()) {
+      return ApprovalCopyResult.createForMissingCopyCondition();
+    }
+    ApprovalContext ctx =
+        ApprovalContext.create(
+            changeNotes,
+            sourcePatchSetId,
+            approverId,
+            labelType,
+            approvalValue,
+            targetPatchSet,
+            changeKind,
+            isMerge,
+            revWalk,
+            repoConfig);
+    try {
+      // Use a request context to run checks as an internal user with expanded visibility. This is
+      // so that the output of the copy condition does not depend on who is running the current
+      // request (e.g. a group used in this query might not be visible to the person sending this
+      // request).
+      try (ManualRequestContext ignored = requestContext.open()) {
+        Predicate<ApprovalContext> copyConditionPredicate =
+            approvalQueryBuilder.parse(labelType.getCopyCondition().get());
+        boolean canCopy = copyConditionPredicate.asMatchable().match(ctx);
+        ImmutableSet.Builder<String> passingAtomsBuilder = ImmutableSet.builder();
+        ImmutableSet.Builder<String> failingAtomsBuilder = ImmutableSet.builder();
+        evaluateAtoms(copyConditionPredicate, ctx, passingAtomsBuilder, failingAtomsBuilder);
+        ImmutableSet<String> passingAtoms = passingAtomsBuilder.build();
+        ImmutableSet<String> failingAtoms = failingAtomsBuilder.build();
+        logger.atFine().log(
+            "%s copy %s of account %d on change %d from patch set %d to patch set %d"
+                + " (copyCondition = %s, passingAtoms = %s, failingAtoms = %s, changeKind = %s)",
+            canCopy ? "Can" : "Cannot",
+            LabelVote.create(labelType.getName(), approvalValue).format(),
+            approverId.get(),
+            changeNotes.getChangeId().get(),
+            sourcePatchSetId.get(),
+            targetPatchSet.id().get(),
+            labelType.getCopyCondition().get(),
+            passingAtoms,
+            failingAtoms,
+            changeKind.name());
+        return ApprovalCopyResult.create(canCopy, passingAtoms, failingAtoms);
+      }
+    } catch (QueryParseException e) {
+      logger.atWarning().withCause(e).log(
+          "Unable to copy label because config is invalid. This should have been caught before.");
+      return ApprovalCopyResult.createForNonParseableCopyCondition();
+    }
+  }
+
+  private Result computeForPatchSet(
+      LabelTypes labelTypes,
+      ChangeNotes notes,
+      PatchSet targetPatchSet,
+      RevWalk rw,
+      Config repoConfig) {
+    Project.NameKey projectName = notes.getProjectName();
+    PatchSet.Id targetPsId = targetPatchSet.id();
+
+    // Bail out immediately if this is the first patch set. Return only approvals granted on the
+    // given patch set.
+    if (targetPsId.get() == 1) {
+      return Result.empty();
+    }
+    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet =
+        notes.load().getPatchSets().lowerEntry(targetPsId);
+    if (priorPatchSet == null) {
+      return Result.empty();
+    }
+
+    Table<String, Account.Id, PatchSetApproval> currentApprovalsByUser = HashBasedTable.create();
+    ImmutableList<PatchSetApproval> nonCopiedApprovalsForGivenPatchSet =
+        notes.load().getApprovals().onlyNonCopied().get(targetPatchSet.id());
+    nonCopiedApprovalsForGivenPatchSet.forEach(
+        psa -> currentApprovalsByUser.put(psa.label(), psa.accountId(), psa));
+
+    Table<String, Account.Id, Result.PatchSetApprovalData> copiedApprovalsByUser =
+        HashBasedTable.create();
+    ImmutableSet.Builder<Result.PatchSetApprovalData> outdatedApprovalsBuilder =
+        ImmutableSet.builder();
+
+    ImmutableList<PatchSetApproval> priorApprovals =
+        notes.load().getApprovals().all().get(priorPatchSet.getKey());
+
+    // Add labels from the previous patch set to the result in case the label isn't already there
+    // and settings as well as change kind allow copying.
+    ChangeKind changeKind =
+        changeKindCache.getChangeKind(
+            projectName,
+            rw,
+            repoConfig,
+            priorPatchSet.getValue().commitId(),
+            targetPatchSet.commitId());
+    boolean isMerge = isMerge(projectName, rw, targetPatchSet);
+    logger.atFine().log(
+        "change kind for patch set %d of change %d against prior patch set %s is %s",
+        targetPatchSet.id().get(),
+        targetPatchSet.id().changeId().get(),
+        priorPatchSet.getValue().id().changeId(),
+        changeKind);
+
+    for (PatchSetApproval priorPsa : priorApprovals) {
+      if (priorPsa.value() == 0) {
+        // approvals with a zero vote record the deletion of a vote,
+        // they should neither be copied nor be reported as outdated, hence just skip them
+        continue;
+      }
+
+      Optional<LabelType> labelType = labelTypes.byLabel(priorPsa.labelId());
+      if (!labelType.isPresent()) {
+        logger.atFine().log(
+            "approval %d on label %s of patch set %d of change %d cannot be copied"
+                + " to patch set %d because the label no longer exists on project %s",
+            priorPsa.value(),
+            priorPsa.label(),
+            priorPsa.key().patchSetId().get(),
+            priorPsa.key().patchSetId().changeId().get(),
+            targetPsId.get(),
+            projectName);
+        outdatedApprovalsBuilder.add(
+            Result.PatchSetApprovalData.createForMissingLabelType(priorPsa));
+        continue;
+      }
+      ApprovalCopyResult approvalCopyResult =
+          computeCopyResult(
+              notes,
+              priorPsa.patchSetId(),
+              targetPatchSet,
+              priorPsa.accountId(),
+              labelType.get(),
+              priorPsa.value(),
+              changeKind,
+              isMerge,
+              rw,
+              repoConfig);
+      if (approvalCopyResult.canCopy()) {
+        if (!currentApprovalsByUser.contains(priorPsa.label(), priorPsa.accountId())) {
+          PatchSetApproval copiedApproval = priorPsa.copyWithPatchSet(targetPatchSet.id());
+
+          // Normalize the copied approval.
+          Optional<PatchSetApproval> copiedApprovalNormalized =
+              labelNormalizer.normalize(notes, copiedApproval);
+          logger.atFine().log(
+              "Copied approval %s has been normalized to %s",
+              copiedApproval,
+              copiedApprovalNormalized.map(PatchSetApproval::toString).orElse("n/a"));
+          if (!copiedApprovalNormalized.isPresent()) {
+            continue;
+          }
+
+          copiedApprovalsByUser.put(
+              priorPsa.label(),
+              priorPsa.accountId(),
+              Result.PatchSetApprovalData.create(
+                  copiedApprovalNormalized.get(),
+                  approvalCopyResult.passingAtoms(),
+                  approvalCopyResult.failingAtoms()));
+        }
+      } else {
+        outdatedApprovalsBuilder.add(
+            Result.PatchSetApprovalData.create(
+                priorPsa, approvalCopyResult.passingAtoms(), approvalCopyResult.failingAtoms()));
+        continue;
+      }
+    }
+
+    return Result.create(
+        ImmutableSet.copyOf(copiedApprovalsByUser.values()), outdatedApprovalsBuilder.build());
+  }
+
+  private boolean isMerge(Project.NameKey project, RevWalk rw, PatchSet patchSet) {
+    try {
+      return rw.parseCommit(patchSet.commitId()).getParentCount() > 1;
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "failed to check if patch set %d of change %s in project %s is a merge commit",
+              patchSet.id().get(), patchSet.id().changeId(), project),
+          e);
+    }
+  }
+
+  /**
+   * Evaluates a predicate of the copy condition and adds its passing and failing atoms to the given
+   * builders.
+   *
+   * @param predicate a predicate of the copy condition that should be evaluated
+   * @param approvalContext the approval context against which the predicate should be evaluated
+   * @param passingAtoms a builder to which passing atoms should be added
+   * @param failingAtoms a builder to which failing atoms should be added
+   */
+  private static void evaluateAtoms(
+      Predicate<ApprovalContext> predicate,
+      ApprovalContext approvalContext,
+      ImmutableSet.Builder<String> passingAtoms,
+      ImmutableSet.Builder<String> failingAtoms) {
+    if (predicate.isLeaf()) {
+      boolean isPassing = predicate.asMatchable().match(approvalContext);
+      (isPassing ? passingAtoms : failingAtoms).add(predicate.getPredicateString());
+      return;
+    }
+    predicate
+        .getChildren()
+        .forEach(
+            childPredicate ->
+                evaluateAtoms(childPredicate, approvalContext, passingAtoms, failingAtoms));
+  }
+
+  /** Result for checking if an approval can be copied to the next patch set. */
+  @AutoValue
+  abstract static class ApprovalCopyResult {
+    /** Whether the approval can be copied to the next patch set. */
+    abstract boolean canCopy();
+
+    /**
+     * Lists the leaf predicates of the copy condition that are fulfilled. See {@link
+     * Result.PatchSetApprovalData#passingAtoms()} for more details.
+     *
+     * <p>Empty if there is no copy condition or if the copy condition is not parseable.
+     */
+    abstract ImmutableSet<String> passingAtoms();
+
+    /**
+     * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link
+     * Result.PatchSetApprovalData#passingAtoms()} for more details.
+     *
+     * <p>Empty if there is no copy condition or if the copy condition is not parseable.
+     */
+    abstract ImmutableSet<String> failingAtoms();
+
+    private static ApprovalCopyResult create(
+        boolean canCopy, ImmutableSet<String> passingAtoms, ImmutableSet<String> failingAtoms) {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(canCopy, passingAtoms, failingAtoms);
+    }
+
+    private static ApprovalCopyResult createForMissingCopyCondition() {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(
+          /* canCopy= */ false,
+          /* passingAtoms= */ ImmutableSet.of(),
+          /* failingAtoms= */ ImmutableSet.of());
+    }
+
+    private static ApprovalCopyResult createForNonParseableCopyCondition() {
+      return new AutoValue_ApprovalCopier_ApprovalCopyResult(
+          /* canCopy= */ false,
+          /* passingAtoms= */ ImmutableSet.of(),
+          /* failingAtoms= */ ImmutableSet.of());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/approval/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalInference.java
deleted file mode 100644
index 695997a..0000000
--- a/java/com/google/gerrit/server/approval/ApprovalInference.java
+++ /dev/null
@@ -1,492 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.approval;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-
-import com.google.common.collect.HashBasedTable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Table;
-import com.google.common.flogger.FluentLogger;
-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.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.change.LabelNormalizer;
-import com.google.gerrit.server.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.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.approval.ApprovalContext;
-import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
-import com.google.gerrit.server.query.approval.ListOfFilesUnchangedPredicate;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * Computes approvals for a given patch set by looking at approvals applied to the given patch set
- * and by additionally inferring approvals from the patch set's parents. The latter is done by
- * asserting a change's kind and checking the project config for allowed forward-inference.
- *
- * <p>The result of a copy may either be stored, as when stamping approvals in the database at
- * submit time, or refreshed on demand, as when reading approvals from the NoteDb. TODO(ghareeb):
- * migrate to new diff cache
- */
-@Singleton
-class ApprovalInference {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final DiffOperations diffOperations;
-  private final ProjectCache projectCache;
-  private final ChangeKindCache changeKindCache;
-  private final LabelNormalizer labelNormalizer;
-  private final ApprovalQueryBuilder approvalQueryBuilder;
-  private final OneOffRequestContext requestContext;
-  private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
-
-  @Inject
-  ApprovalInference(
-      DiffOperations diffOperations,
-      ProjectCache projectCache,
-      ChangeKindCache changeKindCache,
-      LabelNormalizer labelNormalizer,
-      ApprovalQueryBuilder approvalQueryBuilder,
-      OneOffRequestContext requestContext,
-      ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
-    this.diffOperations = diffOperations;
-    this.projectCache = projectCache;
-    this.changeKindCache = changeKindCache;
-    this.labelNormalizer = labelNormalizer;
-    this.approvalQueryBuilder = approvalQueryBuilder;
-    this.requestContext = requestContext;
-    this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
-  }
-
-  /**
-   * Returns all approvals that apply to the given patch set. Honors direct and indirect (approval
-   * on parents) approvals.
-   */
-  Iterable<PatchSetApproval> forPatchSet(
-      ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
-    PatchSet patchset = notes.getPatchSets().get(psId);
-    if (patchset == null) {
-      return Collections.emptyList();
-    }
-    return forPatchSet(notes, patchset, rw, repoConfig);
-  }
-
-  Iterable<PatchSetApproval> forPatchSet(
-      ChangeNotes notes, PatchSet ps, @Nullable RevWalk rw, @Nullable Config repoConfig) {
-    ProjectState project;
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Computing labels for patch set",
-            Metadata.builder()
-                .changeId(notes.load().getChangeId().get())
-                .patchSetId(ps.id().get())
-                .build())) {
-      project =
-          projectCache
-              .get(notes.getProjectName())
-              .orElseThrow(illegalState(notes.getProjectName()));
-      Collection<PatchSetApproval> approvals =
-          getForPatchSetWithoutNormalization(notes, project, ps, rw, repoConfig);
-      return labelNormalizer.normalize(notes, approvals).getNormalized();
-    }
-  }
-
-  private boolean canCopyBasedOnBooleanLabelConfigs(
-      ProjectState project,
-      PatchSetApproval psa,
-      PatchSet.Id psId,
-      ChangeKind kind,
-      LabelType type,
-      @Nullable Map<String, FileDiffOutput> baseVsCurrentDiff,
-      @Nullable Map<String, FileDiffOutput> baseVsPriorDiff,
-      @Nullable Map<String, FileDiffOutput> priorVsCurrentDiff) {
-    int n = psa.key().patchSetId().get();
-    checkArgument(n != psId.get());
-
-    if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
-      logger.atFine().log(
-          "veto approval %s on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyMinScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.isCopyMaxScore() && type.isMaxPositive(psa)) {
-      logger.atFine().log(
-          "max approval %s on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set copyMaxScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.isCopyAnyScore()) {
-      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 copyAnyScore = true on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return true;
-    } else if (type.getCopyValues().contains(psa.value())) {
-      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 copyValue = %d on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          psa.value(),
-          project.getName());
-      return true;
-    } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
-        && listOfFilesUnchangedPredicate.match(
-            baseVsCurrentDiff, baseVsPriorDiff, priorVsCurrentDiff)) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d can be copied"
-              + " to patch set %d because the label has set "
-              + "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:
-        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case NO_CODE_CHANGE:
-        if (type.isCopyAllScoresIfNoCodeChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case TRIVIAL_REBASE:
-        if (type.isCopyAllScoresOnTrivialRebase()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnTrivialRebase = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case NO_CHANGE:
-        if (type.isCopyAllScoresIfNoChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresOnTrivialRebase()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnTrivialRebase = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresOnMergeFirstParentUpdate()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresOnMergeFirstParentUpdate = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        if (type.isCopyAllScoresIfNoCodeChange()) {
-          logger.atFine().log(
-              "approval %d on label %s of patch set %d of change %d can be copied"
-                  + " to patch set %d because change kind is %s and the label has set"
-                  + " copyAllScoresIfNoCodeChange = true on project %s",
-              psa.value(),
-              psa.label(),
-              n,
-              psa.key().patchSetId().changeId().get(),
-              psId.get(),
-              kind,
-              project.getName());
-          return true;
-        }
-        return false;
-      case REWORK:
-      default:
-        logger.atFine().log(
-            "approval %d on label %s of patch set %d of change %d cannot be copied"
-                + " to patch set %d because change kind is %s",
-            psa.value(), psa.label(), n, psa.key().patchSetId().changeId().get(), psId.get(), kind);
-        return false;
-    }
-  }
-
-  private boolean canCopyBasedOnCopyCondition(
-      ChangeNotes changeNotes,
-      PatchSetApproval psa,
-      PatchSet patchSet,
-      LabelType type,
-      ChangeKind changeKind) {
-    if (!type.getCopyCondition().isPresent()) {
-      return false;
-    }
-    ApprovalContext ctx = ApprovalContext.create(changeNotes, psa, patchSet, changeKind);
-    try {
-      // Use a request context to run checks as an internal user with expanded visibility. This is
-      // so that the output of the copy condition does not depend on who is running the current
-      // request (e.g. a group used in this query might not be visible to the person sending this
-      // request).
-      try (ManualRequestContext ignored = requestContext.open()) {
-        return approvalQueryBuilder.parse(type.getCopyCondition().get()).asMatchable().match(ctx);
-      }
-    } catch (QueryParseException e) {
-      logger.atWarning().withCause(e).log(
-          "Unable to copy label because config is invalid. This should have been caught before.");
-      return false;
-    }
-  }
-
-  private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
-      ChangeNotes notes,
-      ProjectState project,
-      PatchSet patchSet,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig) {
-    checkState(
-        project.getNameKey().equals(notes.getProjectName()),
-        "project must match %s, %s",
-        project.getNameKey(),
-        notes.getProjectName());
-
-    PatchSet.Id psId = patchSet.id();
-    // Add approvals on the given patch set to the result
-    Table<String, Account.Id, PatchSetApproval> resultByUser = HashBasedTable.create();
-    ImmutableList<PatchSetApproval> approvalsForGivenPatchSet =
-        notes.load().getApprovals().get(patchSet.id());
-    approvalsForGivenPatchSet.forEach(psa -> resultByUser.put(psa.label(), psa.accountId(), psa));
-
-    // Bail out immediately if this is the first patch set. Return only approvals granted on the
-    // given patch set.
-    if (psId.get() == 1) {
-      return resultByUser.values();
-    }
-
-    // Call this algorithm recursively to check if the prior patch set had approvals. This has the
-    // advantage that all caches - most importantly ChangeKindCache - have values cached for what we
-    // need for this computation.
-    // The way this algorithm is written is that any approval will be copied forward by one patch
-    // set at a time if configs and change kind allow so. Once an approval is held back - for
-    // example because the patch set is a REWORK - it will not be picked up again in a future
-    // patch set.
-    Map.Entry<PatchSet.Id, PatchSet> priorPatchSet = notes.load().getPatchSets().lowerEntry(psId);
-    if (priorPatchSet == null) {
-      return resultByUser.values();
-    }
-
-    Iterable<PatchSetApproval> priorApprovals =
-        getForPatchSetWithoutNormalization(
-            notes, project, priorPatchSet.getValue(), rw, repoConfig);
-    if (!priorApprovals.iterator().hasNext()) {
-      return resultByUser.values();
-    }
-
-    // Add labels from the previous patch set to the result in case the label isn't already there
-    // and settings as well as change kind allow copying.
-    ChangeKind changeKind =
-        changeKindCache.getChangeKind(
-            project.getNameKey(),
-            rw,
-            repoConfig,
-            priorPatchSet.getValue().commitId(),
-            patchSet.commitId());
-    logger.atFine().log(
-        "change kind for patch set %d of change %d against prior patch set %s is %s",
-        patchSet.id().get(),
-        patchSet.id().changeId().get(),
-        priorPatchSet.getValue().id().changeId(),
-        changeKind);
-
-    Map<String, FileDiffOutput> baseVsCurrent = null;
-    Map<String, FileDiffOutput> baseVsPrior = null;
-    Map<String, FileDiffOutput> priorVsCurrent = null;
-    LabelTypes labelTypes = project.getLabelTypes();
-    for (PatchSetApproval psa : priorApprovals) {
-      if (resultByUser.contains(psa.label(), psa.accountId())) {
-        continue;
-      }
-      Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
-      // Only compute modified files if there is a relevant label, since this is expensive.
-      if (baseVsCurrent == null
-          && type.isPresent()
-          && type.get().isCopyAllScoresIfListOfFilesDidNotChange()) {
-        baseVsCurrent = listModifiedFiles(project, patchSet);
-        baseVsPrior = listModifiedFiles(project, priorPatchSet.getValue());
-        priorVsCurrent =
-            listModifiedFiles(project, priorPatchSet.getValue().commitId(), patchSet.commitId());
-      }
-      if (!type.isPresent()) {
-        logger.atFine().log(
-            "approval %d on label %s of patch set %d of change %d cannot be copied"
-                + " to patch set %d because the label no longer exists on project %s",
-            psa.value(),
-            psa.label(),
-            psa.key().patchSetId().get(),
-            psa.key().patchSetId().changeId().get(),
-            psId.get(),
-            project.getName());
-        continue;
-      }
-      if (!canCopyBasedOnBooleanLabelConfigs(
-              project,
-              psa,
-              patchSet.id(),
-              changeKind,
-              type.get(),
-              baseVsCurrent,
-              baseVsPrior,
-              priorVsCurrent)
-          && !canCopyBasedOnCopyCondition(notes, psa, patchSet, type.get(), changeKind)) {
-        continue;
-      }
-      resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(patchSet.id()));
-    }
-    return resultByUser.values();
-  }
-
-  /**
-   * Gets the modified files between the two latest patch-sets. Can be used to compute difference in
-   * files between those two patch-sets .
-   */
-  private Map<String, FileDiffOutput> listModifiedFiles(ProjectState project, PatchSet ps) {
-    try {
-      Integer parentNum =
-          listOfFilesUnchangedPredicate.isInitialCommit(project.getNameKey(), ps.commitId())
-              ? 0
-              : 1;
-      return diffOperations.listModifiedFilesAgainstParent(
-          project.getNameKey(), ps.commitId(), parentNum);
-    } catch (DiffNotAvailableException ex) {
-      throw new StorageException(
-          "failed to compute difference in files, so won't copy"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
-          ex);
-    }
-  }
-
-  /**
-   * Gets the modified files between two commits corresponding to different patchsets of the same
-   * change.
-   */
-  private Map<String, FileDiffOutput> listModifiedFiles(
-      ProjectState project, ObjectId sourceCommit, ObjectId targetCommit) {
-    try {
-      return diffOperations.listModifiedFiles(project.getNameKey(), sourceCommit, targetCommit);
-    } catch (DiffNotAvailableException ex) {
-      throw new StorageException(
-          "failed to compute difference in files, so won't copy"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
-          ex);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index c2e35d2..09820b1 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -15,19 +15,33 @@
 package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
+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.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.Multimaps;
 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.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
@@ -39,9 +53,13 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.change.LabelNormalizer;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -50,19 +68,27 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.query.approval.UserInPredicate;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Date;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.StringTokenizer;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -80,7 +106,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static PatchSetApproval.Builder newApproval(
-      PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Date when) {
+      PatchSet.Id psId, CurrentUser user, LabelId labelId, int value, Instant when) {
     PatchSetApproval.Builder b =
         PatchSetApproval.builder()
             .key(PatchSetApproval.key(psId, user.getAccountId(), labelId))
@@ -95,22 +121,34 @@
     return Iterables.filter(psas, a -> Objects.equals(a.accountId(), accountId));
   }
 
-  private final ApprovalInference approvalInference;
+  private final AccountCache accountCache;
+  private final String anonymousCowardName;
+  private final ApprovalCopier approvalCopier;
+  private final Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
-  private final ApprovalCache approvalCache;
+  private final LabelNormalizer labelNormalizer;
+  private final OneOffRequestContext requestContext;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
-      ApprovalInference approvalInference,
+      AccountCache accountCache,
+      @AnonymousCowardName String anonymousCowardName,
+      ApprovalCopier approvalCopier,
+      Provider<ApprovalQueryBuilder> approvalQueryBuilderProvider,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
-      ApprovalCache approvalCache) {
-    this.approvalInference = approvalInference;
+      LabelNormalizer labelNormalizer,
+      OneOffRequestContext requestContext) {
+    this.accountCache = accountCache;
+    this.anonymousCowardName = anonymousCowardName;
+    this.approvalCopier = approvalCopier;
+    this.approvalQueryBuilderProvider = approvalQueryBuilderProvider;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
-    this.approvalCache = approvalCache;
+    this.labelNormalizer = labelNormalizer;
+    this.requestContext = requestContext;
   }
 
   /**
@@ -223,10 +261,7 @@
           .statePermitsRead()) {
         return false;
       }
-      permissionBackend.absentUser(accountId).change(notes).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
+      return permissionBackend.absentUser(accountId).change(notes).test(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log(
           "Failed to check if account %d can see change %d",
@@ -297,7 +332,7 @@
     }
     checkApprovals(approvals, permissionBackend.user(user).change(update.getNotes()));
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
-    Date ts = update.getWhen();
+    Instant ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       Optional<LabelType> lt = labelTypes.byLabel(vote.getKey());
       if (!lt.isPresent()) {
@@ -330,39 +365,500 @@
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       String name = vote.getKey();
       Short value = vote.getValue();
-      try {
-        forChange.check(new LabelPermission.WithValue(name, value));
-      } catch (AuthException e) {
+      if (!forChange.test(new LabelPermission.WithValue(name, value))) {
         throw new AuthException(
-            String.format("applying label \"%s\": %d is restricted", name, value), e);
+            String.format("applying label \"%s\": %d is restricted", name, value));
       }
     }
   }
 
-  public ListMultimap<PatchSet.Id, PatchSetApproval> byChange(ChangeNotes notes) {
-    return notes.load().getApprovals();
+  public ListMultimap<PatchSet.Id, PatchSetApproval> byChangeExcludingCopiedApprovals(
+      ChangeNotes notes) {
+    return notes.load().getApprovals().onlyNonCopied();
   }
 
-  public Iterable<PatchSetApproval> byPatchSet(
-      ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
-    return approvalInference.forPatchSet(notes, psId, rw, repoConfig);
-  }
-
-  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet patchSet) {
-    return approvalInference.forPatchSet(notes, patchSet, /* rw= */ null, /* repoConfig= */ null);
-  }
-
-  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    return approvalCache.get(notes, psId);
-  }
-
-  public Iterable<PatchSetApproval> byPatchSetUser(
+  /**
+   * Copies approvals to a new patch set.
+   *
+   * <p>Computes the approvals of the prior patch set that should be copied to the new patch set and
+   * stores them in NoteDb.
+   *
+   * <p>For outdated approvals (approvals on the prior patch set which are outdated by the new patch
+   * set and hence not copied) the approvers are added to the attention set since they need to
+   * re-review the change and renew their approvals.
+   *
+   * @param notes the change notes
+   * @param patchSet the newly created patch set
+   * @param revWalk {@link RevWalk} that can see the new patch set revision
+   * @param repoConfig the repo config
+   * @param changeUpdate changeUpdate that is used to persist the copied approvals and update the
+   *     attention set
+   * @return the result of the approval copying
+   */
+  public ApprovalCopier.Result copyApprovalsToNewPatchSet(
       ChangeNotes notes,
-      PatchSet.Id psId,
-      Account.Id accountId,
-      @Nullable RevWalk rw,
-      @Nullable Config repoConfig) {
-    return filterApprovals(byPatchSet(notes, psId, rw, repoConfig), accountId);
+      PatchSet patchSet,
+      RevWalk revWalk,
+      Config repoConfig,
+      ChangeUpdate changeUpdate) {
+    ApprovalCopier.Result approvalCopierResult =
+        approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
+    approvalCopierResult
+        .copiedApprovals()
+        .forEach(approvalData -> changeUpdate.putCopiedApproval(approvalData.patchSetApproval()));
+
+    if (!notes.getChange().isWorkInProgress()) {
+      // The attention set should not be updated when the change is work-in-progress.
+      addAttentionSetUpdatesForOutdatedApprovals(
+          changeUpdate,
+          approvalCopierResult.outdatedApprovals().stream()
+              .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+              .collect(toImmutableSet()));
+    }
+
+    return approvalCopierResult;
+  }
+
+  private void addAttentionSetUpdatesForOutdatedApprovals(
+      ChangeUpdate changeUpdate, ImmutableSet<PatchSetApproval> outdatedApprovals) {
+    Set<AttentionSetUpdate> updates = new HashSet<>();
+
+    Multimap<Account.Id, PatchSetApproval> outdatedApprovalsByUser = ArrayListMultimap.create();
+    outdatedApprovals.forEach(psa -> outdatedApprovalsByUser.put(psa.accountId(), psa));
+    for (Map.Entry<Account.Id, Collection<PatchSetApproval>> e :
+        outdatedApprovalsByUser.asMap().entrySet()) {
+      Account.Id approverId = e.getKey();
+      Collection<PatchSetApproval> outdatedUserApprovals = e.getValue();
+
+      String message;
+      if (outdatedUserApprovals.size() == 1) {
+        PatchSetApproval outdatedUserApproval = Iterables.getOnlyElement(outdatedUserApprovals);
+        message =
+            String.format(
+                "Vote got outdated and was removed: %s",
+                LabelVote.create(outdatedUserApproval.label(), outdatedUserApproval.value())
+                    .format());
+      } else {
+        message =
+            String.format(
+                "Votes got outdated and were removed: %s",
+                outdatedUserApprovals.stream()
+                    .map(
+                        outdatedUserApproval ->
+                            LabelVote.create(
+                                    outdatedUserApproval.label(), outdatedUserApproval.value())
+                                .format())
+                    .sorted()
+                    .collect(joining(", ")));
+      }
+
+      updates.add(
+          AttentionSetUpdate.createForWrite(approverId, AttentionSetUpdate.Operation.ADD, message));
+    }
+    changeUpdate.addToPlannedAttentionSetUpdates(updates);
+  }
+
+  public Optional<String> formatApprovalCopierResult(
+      ApprovalCopier.Result approvalCopierResult, LabelTypes labelTypes) {
+    requireNonNull(approvalCopierResult, "approvalCopierResult");
+    requireNonNull(labelTypes, "labelTypes");
+
+    if (approvalCopierResult.copiedApprovals().isEmpty()
+        && approvalCopierResult.outdatedApprovals().isEmpty()) {
+      return Optional.empty();
+    }
+
+    StringBuilder message = new StringBuilder();
+
+    if (!approvalCopierResult.copiedApprovals().isEmpty()) {
+      message.append("Copied Votes:\n");
+      message.append(
+          formatApprovalListWithCopyCondition(approvalCopierResult.copiedApprovals(), labelTypes));
+    }
+    if (!approvalCopierResult.outdatedApprovals().isEmpty()) {
+      if (!approvalCopierResult.copiedApprovals().isEmpty()) {
+        message.append("\n");
+      }
+      message.append("Outdated Votes:\n");
+      message.append(
+          formatApprovalListWithCopyCondition(
+              approvalCopierResult.outdatedApprovals(), labelTypes));
+    }
+
+    return Optional.of(message.toString());
+  }
+
+  /**
+   * Formats the given approvals as a bullet list, each approval with the corresponding copy
+   * condition if available.
+   *
+   * <p>E.g.:
+   *
+   * <pre>
+   * * Code-Review+1, Code-Review+2 (copy condition: "is:MIN")
+   * * Verified+1 (copy condition: "is:MIN")
+   * </pre>
+   *
+   * <p>Entries in the list can have the following formats:
+   *
+   * <ul>
+   *   <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
+   *       "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
+   *       is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *   <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
+   *       "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
+   *       present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
+   *       (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label>} (if no copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (label type is missing)} (if
+   *       the label type is missing), e.g.: {@code Code-Review+1, Code-Review+2 (label type is
+   *       missing)}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
+   *       condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
+   *       "is:FOO")}
+   * </ul>
+   *
+   * @param approvalDatas the approvals that should be formatted, with approval meta data
+   * @param labelTypes the label types
+   * @return bullet list with the formatted approvals
+   */
+  private String formatApprovalListWithCopyCondition(
+      ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas,
+      LabelTypes labelTypes) {
+    StringBuilder message = new StringBuilder();
+
+    // sort approvals by label vote so that we list them in a deterministic order
+    ImmutableList<ApprovalCopier.Result.PatchSetApprovalData> approvalsSortedByLabelVote =
+        approvalDatas.stream()
+            .sorted(
+                comparing(
+                    approvalData ->
+                        LabelVote.create(
+                                approvalData.patchSetApproval().label(),
+                                approvalData.patchSetApproval().value())
+                            .format()))
+            .collect(toImmutableList());
+
+    ImmutableListMultimap<String, ApprovalCopier.Result.PatchSetApprovalData> approvalsByLabel =
+        Multimaps.index(
+            approvalsSortedByLabelVote, approvalData -> approvalData.patchSetApproval().label());
+
+    for (Map.Entry<String, Collection<ApprovalCopier.Result.PatchSetApprovalData>>
+        approvalsByLabelEntry : approvalsByLabel.asMap().entrySet()) {
+      String label = approvalsByLabelEntry.getKey();
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> approvalsForSameLabel =
+          approvalsByLabelEntry.getValue();
+
+      if (!labelTypes.byLabel(label).isPresent()) {
+        message
+            .append("* ")
+            .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
+            .append(" (label type is missing)\n");
+        continue;
+      }
+
+      LabelType labelType = labelTypes.byLabel(label).get();
+      if (!labelType.getCopyCondition().isPresent()) {
+        message
+            .append("* ")
+            .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
+            .append("\n");
+        continue;
+      }
+
+      // Group the approvals that have the same label by the passing atoms. If approvals have the
+      // same label, but have different passing atoms, we need to list them in separate lines
+      // (because in each line we will highlight different passing atoms that matched). Approvals
+      // with the same label and the same passing atoms are formatted as a single line.
+      ImmutableListMultimap<ImmutableSet<String>, ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsForSameLabelByPassingAndFailingAtoms =
+              Multimaps.index(
+                  approvalsForSameLabel, ApprovalCopier.Result.PatchSetApprovalData::passingAtoms);
+
+      // Approvals with the same label that have the same passing atoms should have the same failing
+      // atoms (since the label is the same they have the same copy condition).
+      approvalsForSameLabelByPassingAndFailingAtoms
+          .asMap()
+          .values()
+          .forEach(
+              approvalsForSameLabelAndSamePassingAtoms ->
+                  checkThatPropertyIsTheSameForAllApprovals(
+                      approvalsForSameLabelAndSamePassingAtoms,
+                      "failing atoms",
+                      approvalData -> approvalData.failingAtoms()));
+
+      // The order in which we add lines for approvals with the same label but different passing
+      // atoms needs to be deterministic for tests. Just sort them by the string representation of
+      // the passing atoms.
+      for (Collection<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsForSameLabelWithSamePassingAndFailingAtoms :
+              approvalsForSameLabelByPassingAndFailingAtoms.asMap().entrySet().stream()
+                  .sorted(
+                      comparing(
+                          (Map.Entry<
+                                      ImmutableSet<String>,
+                                      Collection<ApprovalCopier.Result.PatchSetApprovalData>>
+                                  e) -> e.getKey().toString()))
+                  .map(Map.Entry::getValue)
+                  .collect(toImmutableList())) {
+        message
+            .append("* ")
+            .append(
+                formatApprovalsWithCopyCondition(
+                    approvalsForSameLabelWithSamePassingAndFailingAtoms,
+                    labelType.getCopyCondition().get()))
+            .append("\n");
+      }
+    }
+
+    return message.toString();
+  }
+
+  /**
+   * Formats the given approvals with the given copy condition.
+   *
+   * <p>The given approvals must have the same label and the same passing and failing atoms.
+   *
+   * <p>E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *
+   * <p>The following format may be returned:
+   *
+   * <ul>
+   *   <li>{@code <comma-separated-list-of-approvals-for-the-same-label> (copy condition:
+   *       "<copy-condition-without-UserInPredicate>")} (if a copy condition without UserInPredicate
+   *       is present), e.g.: {@code Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
+   *   <li>{@code <approval> by <comma-separated-list-of-approvers> (copy condition:
+   *       "<copy-condition-with-UserInPredicate>")} (if a copy condition with UserInPredicate is
+   *       present), e.g. {@code Code-Review+1 by <GERRIT_ACCOUNT_1000000>, <GERRIT_ACCOUNT_1000001>
+   *       (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")}
+   *   <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy
+   *       condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is
+   *       present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition:
+   *       "is:FOO")}
+   * </ul>
+   *
+   * @param approvalsWithSameLabelAndSamePassingAndFailingAtoms the approvals that should be
+   *     formatted, must be for the same label
+   * @param copyCondition the copy condition of the label
+   * @return the formatted approvals
+   */
+  private String formatApprovalsWithCopyCondition(
+      Collection<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+      String copyCondition) {
+    // Check that all given approvals have the same label and the same passing and failing atoms.
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "label",
+        approvalData -> approvalData.patchSetApproval().label());
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "passing atoms",
+        approvalData -> approvalData.passingAtoms());
+    checkThatPropertyIsTheSameForAllApprovals(
+        approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+        "failing atoms",
+        approvalData -> approvalData.failingAtoms());
+
+    StringBuilder message = new StringBuilder();
+
+    boolean containsUserInPredicate;
+    try {
+      containsUserInPredicate = containsUserInPredicate(copyCondition);
+    } catch (QueryParseException e) {
+      logger.atWarning().withCause(e).log("Non-parsable query condition");
+      message.append(
+          formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms));
+      message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition));
+      return message.toString();
+    }
+
+    if (containsUserInPredicate) {
+      // If a UserInPredicate is used (e.g. 'approverin:<group>' or 'uploaderin:<group>') we need to
+      // include the approvers into the change message since they are relevant for the matching. For
+      // example it can happen that the same approval of different users is copied for the one user
+      // but not for the other user (since the one user is a member of the approverin group and the
+      // other user isn't).
+      //
+      // Example:
+      // * label Foo has the copy condition 'is:ANY approverin:123'
+      // * group 123 contains UserA as member, but not UserB
+      // * a change has the following approvals: Foo+1 by UserA and Foo+1 by UserB
+      //
+      // In this case Foo+1 by UserA is copied because UserA is a member of group 123 and the copy
+      // condition matches, while Foo+1 by UserB is not copied because UserB is not a member of
+      // group 123 and the copy condition doesn't match.
+      //
+      // So it can happen that the same approval Foo+1, but by different users, is copied and
+      // outdated at the same time. To allow users to understand that the copying depends on who did
+      // the approval, the approvers must be included into the change message.
+
+      // sort the approvals by their approvers name-email so that the approvers always appear in a
+      // deterministic order
+      ImmutableList<ApprovalCopier.Result.PatchSetApprovalData>
+          approvalsSortedByLabelVoteAndApprover =
+              approvalsWithSameLabelAndSamePassingAndFailingAtoms.stream()
+                  .sorted(
+                      comparing(
+                              (ApprovalCopier.Result.PatchSetApprovalData approvalData) ->
+                                  LabelVote.create(
+                                          approvalData.patchSetApproval().label(),
+                                          approvalData.patchSetApproval().value())
+                                      .format())
+                          .thenComparing(
+                              approvalData ->
+                                  accountCache
+                                      .getEvenIfMissing(approvalData.patchSetApproval().accountId())
+                                      .account()
+                                      .getNameEmail(anonymousCowardName)))
+                  .collect(toImmutableList());
+
+      ImmutableListMultimap<LabelVote, Account.Id> approversByLabelVote =
+          Multimaps.index(
+                  approvalsSortedByLabelVoteAndApprover,
+                  approvalData ->
+                      LabelVote.create(
+                          approvalData.patchSetApproval().label(),
+                          approvalData.patchSetApproval().value()))
+              .entries().stream()
+              .collect(
+                  toImmutableListMultimap(
+                      e -> e.getKey(), e -> e.getValue().patchSetApproval().accountId()));
+      message.append(
+          approversByLabelVote.asMap().entrySet().stream()
+              .map(
+                  approversByLabelVoteEntry ->
+                      formatLabelVoteWithApprovers(
+                          approversByLabelVoteEntry.getKey(), approversByLabelVoteEntry.getValue()))
+              .collect(joining(", ")));
+    } else {
+      // copy condition doesn't contain a UserInPredicate
+      message.append(
+          formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms));
+    }
+    ImmutableSet<String> passingAtoms =
+        !approvalsWithSameLabelAndSamePassingAndFailingAtoms.isEmpty()
+            ? approvalsWithSameLabelAndSamePassingAndFailingAtoms.iterator().next().passingAtoms()
+            : ImmutableSet.of();
+    message.append(
+        String.format(
+            " (copy condition: \"%s\")",
+            formatCopyConditionAsMarkdown(copyCondition, passingAtoms)));
+    return message.toString();
+  }
+
+  /** Checks that all given approvals have the same value for a given property. */
+  private void checkThatPropertyIsTheSameForAllApprovals(
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> approvals,
+      String propertyName,
+      Function<ApprovalCopier.Result.PatchSetApprovalData, ?> propertyExtractor) {
+    if (approvals.isEmpty()) {
+      return;
+    }
+
+    Object propertyOfFirstEntry = propertyExtractor.apply(approvals.iterator().next());
+    approvals.forEach(
+        approvalData ->
+            checkState(
+                propertyExtractor.apply(approvalData).equals(propertyOfFirstEntry),
+                "property %s of approval %s does not match, expected value: %s",
+                propertyName,
+                approvalData,
+                propertyOfFirstEntry));
+  }
+
+  /**
+   * Formats the given copy condition as a Markdown string.
+   *
+   * <p>Passing atoms are formatted as bold.
+   *
+   * @param copyCondition the copy condition that should be formatted
+   * @param passingAtoms atoms of the copy conditions which are passing/matching
+   * @return the formatted copy condition as a Markdown string
+   */
+  private String formatCopyConditionAsMarkdown(
+      String copyCondition, ImmutableSet<String> passingAtoms) {
+    StringBuilder formattedCopyCondition = new StringBuilder();
+    StringTokenizer tokenizer = new StringTokenizer(copyCondition, " ()", /* returnDelims= */ true);
+    while (tokenizer.hasMoreTokens()) {
+      String token = tokenizer.nextToken();
+      if (passingAtoms.contains(token)) {
+        formattedCopyCondition.append("**" + token.replace("*", "\\*") + "**");
+      } else {
+        formattedCopyCondition.append(token);
+      }
+    }
+    return formattedCopyCondition.toString();
+  }
+
+  private boolean containsUserInPredicate(String copyCondition) throws QueryParseException {
+    // Use a request context to run checks as an internal user with expanded visibility. This is
+    // so that the output of the copy condition does not depend on who is running the current
+    // request (e.g. a group used in this query might not be visible to the person sending this
+    // request).
+    try (ManualRequestContext ignored = requestContext.open()) {
+      return approvalQueryBuilderProvider.get().parse(copyCondition).getFlattenedPredicateList()
+          .stream()
+          .anyMatch(UserInPredicate.class::isInstance);
+    }
+  }
+
+  /**
+   * Formats the given approvals as a comma-separated list of label votes.
+   *
+   * <p>E.g.: {@code Code-Review+1, CodeReview+2}
+   *
+   * @param sortedApprovalsForSameLabel the approvals that should be formatted as a comma-separated
+   *     list of label votes, must be sorted
+   * @return the given approvals as a comma-separated list of label votes
+   */
+  private String formatApprovalsAsLabelVotesList(
+      Collection<ApprovalCopier.Result.PatchSetApprovalData> sortedApprovalsForSameLabel) {
+    return sortedApprovalsForSameLabel.stream()
+        .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+        .map(psa -> LabelVote.create(psa.label(), psa.value()))
+        .distinct()
+        .map(LabelVote::format)
+        .collect(joining(", "));
+  }
+
+  /**
+   * Formats the given label vote with a comma-separated list of the given approvers.
+   *
+   * <p>E.g.: {@code Code-Review+1 by <user1-placeholder>, <user2-placeholder>}
+   *
+   * @param labelVote the label vote that should be formatted with a comma-separated list of the
+   *     given approver
+   * @param sortedApprovers the approvers that should be formatted as a comma-separated list for the
+   *     given label vote
+   * @return the given label vote with a comma-separated list of the given approvers
+   */
+  private String formatLabelVoteWithApprovers(
+      LabelVote labelVote, Collection<Account.Id> sortedApprovers) {
+    return new StringBuilder()
+        .append(labelVote.format())
+        .append(" by ")
+        .append(
+            sortedApprovers.stream()
+                .map(AccountTemplateUtil::getAccountTemplate)
+                .collect(joining(", ")))
+        .toString();
+  }
+
+  /**
+   * Gets {@link PatchSetApproval}s for a specified patch-set. The result includes copied votes but
+   * does not include deleted labels.
+   *
+   * @param notes changenotes of the change.
+   * @param psId patch-set id for the change and patch-set we want to get approvals.
+   * @return all approvals for the specified patch-set, including copied votes, not including
+   *     deleted labels.
+   */
+  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+    List<PatchSetApproval> approvalsNotNormalized = notes.load().getApprovals().all().get(psId);
+    return labelNormalizer.normalize(notes, approvalsNotNormalized).getNormalized();
   }
 
   public Iterable<PatchSetApproval> byPatchSetUser(
@@ -370,18 +866,20 @@
     return filterApprovals(byPatchSet(notes, psId), accountId);
   }
 
+  @Nullable
   public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) {
     if (c == null) {
       return null;
     }
     try {
-      // Submit approval is never copied, so bypass expensive byPatchSet call.
-      return getSubmitter(c, byChange(notes).get(c));
+      // Submit approval is never copied.
+      return getSubmitter(c, byChangeExcludingCopiedApprovals(notes).get(c));
     } catch (StorageException e) {
       return null;
     }
   }
 
+  @Nullable
   public static PatchSetApproval getSubmitter(PatchSet.Id c, Iterable<PatchSetApproval> approvals) {
     if (c == null) {
       return null;
diff --git a/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGenerator.java
new file mode 100644
index 0000000..128bee4
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGenerator.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.server.approval;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval.UUID;
+import com.google.inject.ImplementedBy;
+import java.time.Instant;
+
+/**
+ * Generator for {@link com.google.gerrit.entities.PatchSetApproval.UUID}.
+ *
+ * <p>Since {@link com.google.gerrit.entities.PatchSetApproval.UUID} must be unique for each granted
+ * {@link PatchSetApproval}, implementations must generate globally unique UUID for each {@link
+ * #get} invocation.
+ */
+@ImplementedBy(PatchSetApprovalUuidGeneratorImpl.class)
+public interface PatchSetApprovalUuidGenerator {
+
+  /**
+   * Generates {@link com.google.gerrit.entities.PatchSetApproval.UUID} based on the properties of
+   * {@link PatchSetApproval} that is being granted.
+   */
+  UUID get(
+      PatchSet.Id patchSetId, Account.Id accountId, String label, short value, Instant granted);
+}
diff --git a/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java
new file mode 100644
index 0000000..afa0384
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/PatchSetApprovalUuidGeneratorImpl.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.approval;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval.UUID;
+import com.google.inject.Singleton;
+import java.security.MessageDigest;
+import java.time.Instant;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Default implementation of {@link
+ * com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator}.
+ */
+@Singleton
+public class PatchSetApprovalUuidGeneratorImpl implements PatchSetApprovalUuidGenerator {
+  @Override
+  public UUID get(
+      PatchSet.Id patchSetId, Account.Id accountId, String label, short value, Instant granted) {
+    MessageDigest md = Constants.newMessageDigest();
+    md.update(
+        Constants.encode("patchSetId " + patchSetId.getCommaSeparatedChangeAndPatchSetId() + "\n"));
+    md.update(Constants.encode("accountId " + accountId + "\n"));
+    md.update(Constants.encode("label " + label + "\n"));
+    md.update(Constants.encode("value " + value + "\n"));
+    md.update(Constants.encode("granted " + granted.toEpochMilli() + "\n"));
+    md.update(Constants.encode(String.valueOf(Math.random())));
+    return PatchSetApproval.uuid(ObjectId.fromRaw(md.digest()).name());
+  }
+}
diff --git a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
new file mode 100644
index 0000000..89727c7
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.approval.testing;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApproval.UUID;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import java.time.Instant;
+import javax.inject.Singleton;
+
+/**
+ * Implementation of {@link PatchSetApprovalUuidGenerator} that returns predictable {@link UUID}.
+ */
+@Singleton
+public class TestPatchSetApprovalUuidGenerator implements PatchSetApprovalUuidGenerator {
+
+  private int invocationCount = 0;
+
+  @Override
+  public UUID get(
+      PatchSet.Id patchSetId, Account.Id accountId, String label, short value, Instant granted) {
+    invocationCount++;
+    return PatchSetApproval.uuid(
+        String.format(
+                "%s_%s_%s_%s_%s_%s",
+                patchSetId.changeId().get(),
+                patchSetId.get(),
+                accountId.get(),
+                label,
+                value,
+                invocationCount)
+            .replace("-", "_")
+            .toLowerCase());
+  }
+}
diff --git a/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 5df4d28..94dc4c3 100644
--- a/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -90,9 +90,9 @@
         }
       }
     } catch (StorageException e) {
-      String msg = "database is down";
-      logger.atSevere().withCause(e).log(msg);
-      throw new CmdLineException(owner, localizable(msg));
+      CmdLineException newException = new CmdLineException(owner, localizable("database is down"));
+      newException.initCause(e);
+      throw newException;
     } catch (IOException e) {
       throw new CmdLineException(owner, "Failed to load account", e);
     } catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/args4j/InstantHandler.java b/java/com/google/gerrit/server/args4j/InstantHandler.java
new file mode 100644
index 0000000..bfca0f6
--- /dev/null
+++ b/java/com/google/gerrit/server/args4j/InstantHandler.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.args4j;
+
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.OptionHandler;
+import org.kohsuke.args4j.spi.Parameters;
+import org.kohsuke.args4j.spi.Setter;
+
+public class InstantHandler extends OptionHandler<Instant> {
+  public static final String TIMESTAMP_FORMAT = "yyyyMMdd_HHmm";
+
+  @Inject
+  public InstantHandler(
+      @Assisted CmdLineParser parser,
+      @Assisted OptionDef option,
+      @Assisted Setter<Instant> setter) {
+    super(parser, option, setter);
+  }
+
+  @Override
+  public int parseArguments(Parameters params) throws CmdLineException {
+    String timestamp = params.getParameter(0);
+    try {
+      setter.addValue(
+          DateTimeFormatter.ofPattern(TIMESTAMP_FORMAT)
+              .withZone(ZoneId.of("UTC"))
+              .parse(timestamp, Instant::from));
+      return 1;
+    } catch (DateTimeParseException e) {
+      throw new CmdLineException(
+          owner,
+          String.format("Invalid timestamp: %s; expected format: %s", timestamp, TIMESTAMP_FORMAT),
+          e);
+    }
+  }
+
+  @Override
+  public String getDefaultMetaVariable() {
+    return "TIMESTAMP";
+  }
+}
diff --git a/java/com/google/gerrit/server/args4j/ObjectIdHandler.java b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
index aa8a958..3cad7ce 100644
--- a/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
@@ -16,9 +16,11 @@
 
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.NamedOptionDef;
 import org.kohsuke.args4j.OptionDef;
 import org.kohsuke.args4j.spi.OptionHandler;
 import org.kohsuke.args4j.spi.Parameters;
@@ -37,7 +39,14 @@
   @Override
   public int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    setter.addValue(ObjectId.fromString(n));
+    try {
+      setter.addValue(ObjectId.fromString(n));
+    } catch (InvalidObjectIdException e) {
+      throw new CmdLineException(
+          owner,
+          String.format("expected SHA1 for option %s: %s", ((NamedOptionDef) option).name(), n),
+          e);
+    }
     return 1;
   }
 
diff --git a/java/com/google/gerrit/server/args4j/TimestampHandler.java b/java/com/google/gerrit/server/args4j/TimestampHandler.java
deleted file mode 100644
index eddfbcd..0000000
--- a/java/com/google/gerrit/server/args4j/TimestampHandler.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.args4j;
-
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
-import java.text.DateFormat;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.TimeZone;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.CmdLineParser;
-import org.kohsuke.args4j.OptionDef;
-import org.kohsuke.args4j.spi.OptionHandler;
-import org.kohsuke.args4j.spi.Parameters;
-import org.kohsuke.args4j.spi.Setter;
-
-public class TimestampHandler extends OptionHandler<Timestamp> {
-  public static final String TIMESTAMP_FORMAT = "yyyyMMdd_HHmm";
-
-  @Inject
-  public TimestampHandler(
-      @Assisted CmdLineParser parser,
-      @Assisted OptionDef option,
-      @Assisted Setter<Timestamp> setter) {
-    super(parser, option, setter);
-  }
-
-  @Override
-  public int parseArguments(Parameters params) throws CmdLineException {
-    String timestamp = params.getParameter(0);
-    try {
-      DateFormat fmt = new SimpleDateFormat(TIMESTAMP_FORMAT);
-      fmt.setTimeZone(TimeZone.getTimeZone("UTC"));
-      setter.addValue(new Timestamp(fmt.parse(timestamp).getTime()));
-      return 1;
-    } catch (ParseException e) {
-      throw new CmdLineException(
-          owner,
-          String.format("Invalid timestamp: %s; expected format: %s", timestamp, TIMESTAMP_FORMAT),
-          e);
-    }
-  }
-
-  @Override
-  public String getDefaultMetaVariable() {
-    return "TIMESTAMP";
-  }
-}
diff --git a/java/com/google/gerrit/server/audit/AuditService.java b/java/com/google/gerrit/server/audit/AuditService.java
index 2a5d868..695c32a 100644
--- a/java/com/google/gerrit/server/audit/AuditService.java
+++ b/java/com/google/gerrit/server/audit/AuditService.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @Singleton
 public class AuditService implements GroupAuditService {
@@ -50,7 +50,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> addedMembers,
-      Timestamp addedOn) {
+      Instant addedOn) {
     GroupMemberAuditEvent event =
         GroupMemberAuditEvent.create(actor, updatedGroup, addedMembers, addedOn);
     groupAuditListeners.runEach(l -> l.onAddMembers(event));
@@ -61,7 +61,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> deletedMembers,
-      Timestamp deletedOn) {
+      Instant deletedOn) {
     GroupMemberAuditEvent event =
         GroupMemberAuditEvent.create(actor, updatedGroup, deletedMembers, deletedOn);
     groupAuditListeners.runEach(l -> l.onDeleteMembers(event));
@@ -72,7 +72,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> addedSubgroups,
-      Timestamp addedOn) {
+      Instant addedOn) {
     GroupSubgroupAuditEvent event =
         GroupSubgroupAuditEvent.create(actor, updatedGroup, addedSubgroups, addedOn);
     groupAuditListeners.runEach(l -> l.onAddSubgroups(event));
@@ -83,7 +83,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> deletedSubgroups,
-      Timestamp deletedOn) {
+      Instant deletedOn) {
     GroupSubgroupAuditEvent event =
         GroupSubgroupAuditEvent.create(actor, updatedGroup, deletedSubgroups, deletedOn);
     groupAuditListeners.runEach(l -> l.onDeleteSubgroups(event));
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index 3faa259..bde7404 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -58,7 +58,7 @@
         "//lib/bouncycastle:bcprov-neverlink",
         "//lib/commons:compress",
         "//lib/commons:dbcp",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/commons:net",
         "//lib/commons:validator",
         "//lib/flogger:api",
@@ -66,7 +66,6 @@
         "//lib/guice:guice-assistedinject",
         "//lib/guice:guice-servlet",
         "//lib/jsoup",
-        "//lib/log:log4j",
         "//lib/lucene:lucene-analyzers-common",
         "//lib/lucene:lucene-queryparser",
         "//lib/mime4j:core",
diff --git a/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
index 252a1e2..c37c583 100644
--- a/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupAuditEvent.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** An audit event for groups. */
 public interface GroupAuditEvent {
@@ -35,9 +35,9 @@
   AccountGroup.UUID getUpdatedGroup();
 
   /**
-   * Gets the {@link Timestamp} of the action.
+   * Gets the {@link Instant} of the action.
    *
-   * @return the {@link Timestamp} of the action.
+   * @return the {@link Instant} of the action.
    */
-  Timestamp getTimestamp();
+  Instant getTimestamp();
 }
diff --git a/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
index eccfbf4..95a2dce 100644
--- a/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupMemberAuditEvent.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @AutoValue
 public abstract class GroupMemberAuditEvent implements GroupAuditEvent {
@@ -26,7 +26,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> modifiedMembers,
-      Timestamp timestamp) {
+      Instant timestamp) {
     return new AutoValue_GroupMemberAuditEvent(actor, updatedGroup, modifiedMembers, timestamp);
   }
 
@@ -40,5 +40,5 @@
   public abstract ImmutableSet<Account.Id> getModifiedMembers();
 
   @Override
-  public abstract Timestamp getTimestamp();
+  public abstract Instant getTimestamp();
 }
diff --git a/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java b/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
index 0fe3962..ace8312 100644
--- a/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
+++ b/java/com/google/gerrit/server/audit/group/GroupSubgroupAuditEvent.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 @AutoValue
 public abstract class GroupSubgroupAuditEvent implements GroupAuditEvent {
@@ -26,7 +26,7 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> modifiedSubgroups,
-      Timestamp timestamp) {
+      Instant timestamp) {
     return new AutoValue_GroupSubgroupAuditEvent(actor, updatedGroup, modifiedSubgroups, timestamp);
   }
 
@@ -40,5 +40,5 @@
   public abstract ImmutableSet<AccountGroup.UUID> getModifiedSubgroups();
 
   @Override
-  public abstract Timestamp getTimestamp();
+  public abstract Instant getTimestamp();
 }
diff --git a/java/com/google/gerrit/server/cache/CacheBackend.java b/java/com/google/gerrit/server/cache/CacheBackend.java
deleted file mode 100644
index ec9876f..0000000
--- a/java/com/google/gerrit/server/cache/CacheBackend.java
+++ /dev/null
@@ -1,25 +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.cache;
-
-/** Caffeine is used as default cache backend, but can be overridden with Guava backend. */
-public enum CacheBackend {
-  CAFFEINE,
-  GUAVA;
-
-  public boolean isLegacyBackend() {
-    return this == GUAVA;
-  }
-}
diff --git a/java/com/google/gerrit/server/cache/CacheDisplay.java b/java/com/google/gerrit/server/cache/CacheDisplay.java
new file mode 100644
index 0000000..60f5186
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/CacheDisplay.java
@@ -0,0 +1,129 @@
+// 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.base.Strings;
+import java.io.IOException;
+import java.io.Writer;
+import java.util.Collection;
+
+public class CacheDisplay {
+
+  private final Writer stdout;
+  private final int nw;
+  private final Collection<CacheInfo> caches;
+
+  public CacheDisplay(Writer stdout, int nw, Collection<CacheInfo> caches) {
+    this.stdout = stdout;
+    this.nw = nw;
+    this.caches = caches;
+  }
+
+  public CacheDisplay(Writer stdout, Collection<CacheInfo> caches) {
+    this(stdout, 30, caches);
+  }
+
+  public void displayCaches() throws IOException {
+    stdout.write(
+        String.format( //
+            "%1s %-" + nw + "s|%-21s|  %-5s |%-9s|\n" //
+            ,
+            "" //
+            ,
+            "Name" //
+            ,
+            "Entries" //
+            ,
+            "AvgGet" //
+            ,
+            "Hit Ratio" //
+            ));
+    stdout.write(
+        String.format( //
+            "%1s %-" + nw + "s|%6s %6s %7s|  %-5s  |%-4s %-4s|\n" //
+            ,
+            "" //
+            ,
+            "" //
+            ,
+            "Mem" //
+            ,
+            "Disk" //
+            ,
+            "Space" //
+            ,
+            "" //
+            ,
+            "Mem" //
+            ,
+            "Disk" //
+            ));
+    stdout.write("--");
+    for (int i = 0; i < nw; i++) {
+      stdout.write('-');
+    }
+    stdout.write("+---------------------+---------+---------+\n");
+    printMemoryCoreCaches(caches);
+    printMemoryPluginCaches(caches);
+    printDiskCaches(caches);
+    stdout.write('\n');
+  }
+
+  private void printMemoryCoreCaches(Collection<CacheInfo> caches) throws IOException {
+    for (CacheInfo cache : caches) {
+      if (!cache.name.contains("-") && CacheInfo.CacheType.MEM.equals(cache.type)) {
+        printCache(cache);
+      }
+    }
+  }
+
+  private void printMemoryPluginCaches(Collection<CacheInfo> caches) throws IOException {
+    for (CacheInfo cache : caches) {
+      if (cache.name.contains("-") && CacheInfo.CacheType.MEM.equals(cache.type)) {
+        printCache(cache);
+      }
+    }
+  }
+
+  private void printDiskCaches(Collection<CacheInfo> caches) throws IOException {
+    for (CacheInfo cache : caches) {
+      if (CacheInfo.CacheType.DISK.equals(cache.type)) {
+        printCache(cache);
+      }
+    }
+  }
+
+  private void printCache(CacheInfo cache) throws IOException {
+    stdout.write(
+        String.format(
+            "%1s %-" + nw + "s|%6s %6s %7s| %7s |%4s %4s|\n",
+            CacheInfo.CacheType.DISK.equals(cache.type) ? "D" : "",
+            cache.name,
+            nullToEmpty(cache.entries.mem),
+            nullToEmpty(cache.entries.disk),
+            Strings.nullToEmpty(cache.entries.space),
+            Strings.nullToEmpty(cache.averageGet),
+            formatAsPercent(cache.hitRatio.mem),
+            formatAsPercent(cache.hitRatio.disk)));
+  }
+
+  private static String nullToEmpty(Long l) {
+    return l != null ? String.valueOf(l) : "";
+  }
+
+  private static String formatAsPercent(Integer i) {
+    return i != null ? i + "%" : "";
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/CacheInfo.java b/java/com/google/gerrit/server/cache/CacheInfo.java
new file mode 100644
index 0000000..94a9e05
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/CacheInfo.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheStats;
+import com.google.gerrit.common.Nullable;
+
+public class CacheInfo {
+
+  public String name;
+  public CacheType type;
+  public EntriesInfo entries;
+  public String averageGet;
+  public HitRatioInfo hitRatio;
+
+  public CacheInfo(Cache<?, ?> cache) {
+    this(null, cache);
+  }
+
+  public CacheInfo(String name, Cache<?, ?> cache) {
+    this.name = name;
+
+    CacheStats stat = cache.stats();
+
+    entries = new EntriesInfo();
+    entries.setMem(cache.size());
+
+    averageGet = duration(stat.averageLoadPenalty());
+
+    hitRatio = new HitRatioInfo();
+    hitRatio.setMem(stat.hitCount(), stat.requestCount());
+
+    if (cache instanceof PersistentCache) {
+      type = CacheType.DISK;
+      PersistentCache.DiskStats diskStats = ((PersistentCache) cache).diskStats();
+      entries.setDisk(diskStats.size());
+      entries.setSpace(diskStats.space());
+      hitRatio.setDisk(diskStats.hitCount(), diskStats.requestCount());
+    } else {
+      type = CacheType.MEM;
+    }
+  }
+
+  @Nullable
+  private static String duration(double ns) {
+    if (ns < 0.5) {
+      return null;
+    }
+    String suffix = "ns";
+    if (ns >= 1000.0) {
+      ns /= 1000.0;
+      suffix = "us";
+    }
+    if (ns >= 1000.0) {
+      ns /= 1000.0;
+      suffix = "ms";
+    }
+    if (ns >= 1000.0) {
+      ns /= 1000.0;
+      suffix = "s";
+    }
+    return String.format("%4.1f%s", ns, suffix).trim();
+  }
+
+  public static class EntriesInfo {
+    public Long mem;
+    public Long disk;
+    public String space;
+
+    public void setMem(long mem) {
+      this.mem = mem != 0 ? mem : null;
+    }
+
+    public void setDisk(long disk) {
+      this.disk = disk != 0 ? disk : null;
+    }
+
+    public void setSpace(double value) {
+      space = bytes(value);
+    }
+
+    private static String bytes(double value) {
+      value /= 1024;
+      String suffix = "k";
+
+      if (value > 1024) {
+        value /= 1024;
+        suffix = "m";
+      }
+      if (value > 1024) {
+        value /= 1024;
+        suffix = "g";
+      }
+      return String.format("%1$6.2f%2$s", value, suffix).trim();
+    }
+  }
+
+  public static class HitRatioInfo {
+    public Integer mem;
+    public Integer disk;
+
+    public void setMem(long value, long total) {
+      mem = percent(value, total);
+    }
+
+    public void setDisk(long value, long total) {
+      disk = percent(value, total);
+    }
+
+    @Nullable
+    private static Integer percent(long value, long total) {
+      if (total <= 0) {
+        return null;
+      }
+      return (int) ((100 * value) / total);
+    }
+  }
+
+  public enum CacheType {
+    MEM,
+    DISK
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java
index 0fdc6f5..d4e4509 100644
--- a/java/com/google/gerrit/server/cache/CacheModule.java
+++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -35,7 +35,7 @@
   public static final String MEMORY_MODULE = "cache-memory";
   public static final String PERSISTENT_MODULE = "cache-persistent";
 
-  private static final TypeLiteral<Cache<?, ?>> ANY_CACHE = new TypeLiteral<Cache<?, ?>>() {};
+  private static final TypeLiteral<Cache<?, ?>> ANY_CACHE = new TypeLiteral<>() {};
 
   /**
    * Declare a named in-memory cache.
@@ -68,8 +68,7 @@
    */
   protected <K, V> CacheBinding<K, V> cache(
       String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    CacheProvider<K, V> m =
-        new CacheProvider<>(this, name, keyType, valType, CacheBackend.CAFFEINE);
+    CacheProvider<K, V> m = new CacheProvider<>(this, name, keyType, valType);
     bindCache(m, name, keyType, valType);
     return m;
   }
@@ -124,20 +123,7 @@
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, Class<K> keyType, Class<V> valType) {
-    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType), CacheBackend.CAFFEINE);
-  }
-
-  /**
-   * Declare a named in-memory/on-disk cache.
-   *
-   * @param <K> type of key used to lookup entries.
-   * @param <V> type of value stored by the cache.
-   * @param backend cache backend.
-   * @return binding to describe the cache.
-   */
-  protected <K, V> PersistentCacheBinding<K, V> persist(
-      String name, Class<K> keyType, Class<V> valType, CacheBackend backend) {
-    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType), backend);
+    return persist(name, TypeLiteral.get(keyType), TypeLiteral.get(valType));
   }
 
   /**
@@ -149,7 +135,7 @@
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
       String name, Class<K> keyType, TypeLiteral<V> valType) {
-    return persist(name, TypeLiteral.get(keyType), valType, CacheBackend.CAFFEINE);
+    return persist(name, TypeLiteral.get(keyType), valType);
   }
 
   /**
@@ -160,9 +146,8 @@
    * @return binding to describe the cache.
    */
   protected <K, V> PersistentCacheBinding<K, V> persist(
-      String name, TypeLiteral<K> keyType, TypeLiteral<V> valType, CacheBackend backend) {
-    PersistentCacheProvider<K, V> m =
-        new PersistentCacheProvider<>(this, name, keyType, valType, backend);
+      String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
+    PersistentCacheProvider<K, V> m = new PersistentCacheProvider<>(this, name, keyType, valType);
     bindCache(m, name, keyType, valType);
 
     Type cacheDefType =
diff --git a/java/com/google/gerrit/server/cache/CacheProvider.java b/java/com/google/gerrit/server/cache/CacheProvider.java
index 2dd9e1f..94504b6 100644
--- a/java/com/google/gerrit/server/cache/CacheProvider.java
+++ b/java/com/google/gerrit/server/cache/CacheProvider.java
@@ -30,7 +30,6 @@
 
 class CacheProvider<K, V> implements Provider<Cache<K, V>>, CacheBinding<K, V>, CacheDef<K, V> {
   private final CacheModule module;
-  private final CacheBackend backend;
   final String name;
   private final TypeLiteral<K> keyType;
   private final TypeLiteral<V> valType;
@@ -46,17 +45,11 @@
   private MemoryCacheFactory memoryCacheFactory;
   private boolean frozen;
 
-  CacheProvider(
-      CacheModule module,
-      String name,
-      TypeLiteral<K> keyType,
-      TypeLiteral<V> valType,
-      CacheBackend backend) {
+  CacheProvider(CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
     this.module = module;
     this.name = name;
     this.keyType = keyType;
     this.valType = valType;
-    this.backend = backend;
   }
 
   @Inject(optional = true)
@@ -179,9 +172,7 @@
   public Cache<K, V> get() {
     freeze();
     CacheLoader<K, V> ldr = loader();
-    return ldr != null
-        ? memoryCacheFactory.build(this, ldr, backend)
-        : memoryCacheFactory.build(this, backend);
+    return ldr != null ? memoryCacheFactory.build(this, ldr) : memoryCacheFactory.build(this);
   }
 
   protected void checkNotFrozen() {
diff --git a/java/com/google/gerrit/server/cache/MemoryCacheFactory.java b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
index 558380d..fc55753 100644
--- a/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/MemoryCacheFactory.java
@@ -19,8 +19,7 @@
 import com.google.common.cache.LoadingCache;
 
 public interface MemoryCacheFactory {
-  <K, V> Cache<K, V> build(CacheDef<K, V> def, CacheBackend backend);
+  <K, V> Cache<K, V> build(CacheDef<K, V> def);
 
-  <K, V> LoadingCache<K, V> build(
-      CacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend);
+  <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader);
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
index 9553acc..e9b254b 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
@@ -18,6 +18,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import java.io.IOException;
@@ -45,33 +46,31 @@
     this.config = config;
   }
 
-  protected abstract <K, V> Cache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, long diskLimit, CacheBackend backend);
+  protected abstract <K, V> Cache<K, V> buildImpl(PersistentCacheDef<K, V> in, long diskLimit);
 
   protected abstract <K, V> LoadingCache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long diskLimit, CacheBackend backend);
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long diskLimit);
 
   @Override
-  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in, CacheBackend backend) {
+  public <K, V> Cache<K, V> build(PersistentCacheDef<K, V> in) {
     long limit = getDiskLimit(in);
 
     if (isInMemoryCache(limit)) {
-      return memCacheFactory.build(in, backend);
+      return memCacheFactory.build(in);
     }
 
-    return buildImpl(in, limit, backend);
+    return buildImpl(in, limit);
   }
 
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, CacheBackend backend) {
+  public <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> in, CacheLoader<K, V> loader) {
     long limit = getDiskLimit(in);
 
     if (isInMemoryCache(limit)) {
-      return memCacheFactory.build(in, loader, backend);
+      return memCacheFactory.build(in, loader);
     }
 
-    return buildImpl(in, loader, limit, backend);
+    return buildImpl(in, loader, limit);
   }
 
   private <K, V> long getDiskLimit(PersistentCacheDef<K, V> in) {
@@ -82,6 +81,7 @@
     return !diskEnabled || diskLimit <= 0;
   }
 
+  @Nullable
   private static Path getCacheDir(SitePaths site, String name) {
     if (name == null) {
       return null;
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
index 93f91ef..27fa9ca 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheFactory.java
@@ -19,10 +19,9 @@
 import com.google.common.cache.LoadingCache;
 
 public interface PersistentCacheFactory {
-  <K, V> Cache<K, V> build(PersistentCacheDef<K, V> def, CacheBackend backend);
+  <K, V> Cache<K, V> build(PersistentCacheDef<K, V> def);
 
-  <K, V> LoadingCache<K, V> build(
-      PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend);
+  <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> def, CacheLoader<K, V> loader);
 
   void onStop(String plugin);
 }
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
index 4fc107f..59d66e3 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheProvider.java
@@ -30,7 +30,6 @@
 
 class PersistentCacheProvider<K, V> extends CacheProvider<K, V>
     implements Provider<Cache<K, V>>, PersistentCacheBinding<K, V>, PersistentCacheDef<K, V> {
-  private final CacheBackend backend;
   private int version;
   private long diskLimit;
   private CacheSerializer<K> keySerializer;
@@ -40,19 +39,9 @@
 
   PersistentCacheProvider(
       CacheModule module, String name, TypeLiteral<K> keyType, TypeLiteral<V> valType) {
-    this(module, name, keyType, valType, CacheBackend.CAFFEINE);
-  }
-
-  PersistentCacheProvider(
-      CacheModule module,
-      String name,
-      TypeLiteral<K> keyType,
-      TypeLiteral<V> valType,
-      CacheBackend backend) {
-    super(module, name, keyType, valType, backend);
+    super(module, name, keyType, valType);
     version = -1;
     diskLimit = 128 << 20;
-    this.backend = backend;
   }
 
   @Inject(optional = true)
@@ -141,8 +130,8 @@
     freeze();
     CacheLoader<K, V> ldr = loader();
     return ldr != null
-        ? persistentCacheFactory.build(this, ldr, backend)
-        : persistentCacheFactory.build(this, backend);
+        ? persistentCacheFactory.build(this, ldr)
+        : persistentCacheFactory.build(this);
   }
 
   private static <T> void checkSerializer(
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index aa62745..b744058 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -47,6 +47,7 @@
     return source.refreshAfterWrite();
   }
 
+  @Nullable
   @Override
   public Weigher<K, V> weigher() {
     Weigher<K, V> weigher = source.weigher();
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 16d62b3..445d8a0 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -21,7 +21,6 @@
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import com.google.gerrit.extensions.events.LifecycleListener;
 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;
@@ -34,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.LinkedList;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
@@ -68,7 +67,7 @@
     super(memCacheFactory, cfg, site);
     h2CacheSize = cfg.getLong("cache", null, "h2CacheSize", -1);
     h2AutoServer = cfg.getBoolean("cache", null, "h2AutoServer", false);
-    caches = new LinkedList<>();
+    caches = new ArrayList<>();
     this.cacheMap = cacheMap;
 
     if (diskEnabled) {
@@ -132,34 +131,28 @@
 
   @SuppressWarnings({"unchecked"})
   @Override
-  public <K, V> Cache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, long limit, CacheBackend backend) {
+  public <K, V> Cache<K, V> buildImpl(PersistentCacheDef<K, V> in, long limit) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     H2CacheImpl<K, V> cache =
         new H2CacheImpl<>(
-            executor,
-            store,
-            def.keyType(),
-            (Cache<K, ValueHolder<V>>) memCacheFactory.build(def, backend));
+            executor, store, def.keyType(), (Cache<K, ValueHolder<V>>) memCacheFactory.build(def));
     synchronized (caches) {
       caches.add(cache);
     }
     return cache;
   }
 
-  @SuppressWarnings("unchecked")
+  @SuppressWarnings({"unchecked"})
   @Override
   public <K, V> LoadingCache<K, V> buildImpl(
-      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long limit, CacheBackend backend) {
+      PersistentCacheDef<K, V> in, CacheLoader<K, V> loader, long limit) {
     H2CacheDefProxy<K, V> def = new H2CacheDefProxy<>(in);
     SqlStore<K, V> store = newSqlStore(def, limit);
     Cache<K, ValueHolder<V>> mem =
         (Cache<K, ValueHolder<V>>)
             memCacheFactory.build(
-                def,
-                (CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader),
-                backend);
+                def, (CacheLoader<K, V>) new H2CacheImpl.Loader<>(executor, store, loader));
     H2CacheImpl<K, V> cache = new H2CacheImpl<>(executor, store, def.keyType(), mem);
     synchronized (caches) {
       caches.add(cache);
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 13b8b12..8327b88 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -103,6 +103,7 @@
     this.mem = mem;
   }
 
+  @Nullable
   @Override
   public V getIfPresent(Object objKey) {
     if (!keyType.getRawType().isInstance(objKey)) {
@@ -423,6 +424,7 @@
       return b == null || b.mightContain(key);
     }
 
+    @Nullable
     private BloomFilter<K> buildBloomFilter() {
       SqlHandle c = null;
       try {
@@ -472,6 +474,7 @@
       }
     }
 
+    @Nullable
     ValueHolder<V> getIfPresent(K key) {
       SqlHandle c = null;
       try {
@@ -548,7 +551,7 @@
         c.touch = c.conn.prepareStatement("UPDATE data SET accessed=? WHERE k=? AND version=?");
       }
       try {
-        c.touch.setTimestamp(1, TimeUtil.nowTs());
+        c.touch.setTimestamp(1, new Timestamp(TimeUtil.nowMs()));
         keyType.set(c.touch, 2, key);
         c.touch.setInt(3, version);
         c.touch.executeUpdate();
@@ -581,7 +584,7 @@
           c.put.setBytes(2, valueSerializer.serialize(holder.value));
           c.put.setInt(3, version);
           c.put.setTimestamp(4, Timestamp.from(holder.created));
-          c.put.setTimestamp(5, TimeUtil.nowTs());
+          c.put.setTimestamp(5, new Timestamp(TimeUtil.nowMs()));
           c.put.executeUpdate();
           holder.clean = true;
         } finally {
@@ -717,6 +720,7 @@
       }
     }
 
+    @Nullable
     private SqlHandle close(SqlHandle h) {
       if (h != null) {
         h.close();
@@ -776,6 +780,7 @@
       }
     }
 
+    @Nullable
     private PreparedStatement closeStatement(PreparedStatement ps) {
       if (ps != null) {
         try {
diff --git a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
index 591883e..1812043 100644
--- a/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/ObjectKeyTypeImpl.java
@@ -47,7 +47,7 @@
 
   @Override
   public Funnel<K> funnel() {
-    return new Funnel<K>() {
+    return new Funnel<>() {
       private static final long serialVersionUID = 1L;
 
       @Override
diff --git a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
index 23caca7..28a2ede 100644
--- a/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
+++ b/java/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactory.java
@@ -23,12 +23,10 @@
 import com.github.benmanes.caffeine.guava.CaffeinatedGuava;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
-import com.google.common.cache.CacheBuilder;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.cache.RemovalNotification;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.cache.CacheBackend;
 import com.google.gerrit.server.cache.CacheDef;
 import com.google.gerrit.server.cache.ForwardingRemovalListener;
 import com.google.gerrit.server.cache.MemoryCacheFactory;
@@ -51,34 +49,23 @@
   }
 
   @Override
-  public <K, V> Cache<K, V> build(CacheDef<K, V> def, CacheBackend backend) {
-    return backend.isLegacyBackend()
-        ? createLegacy(def).build()
-        : CaffeinatedGuava.build(create(def));
+  public <K, V> Cache<K, V> build(CacheDef<K, V> def) {
+    return CaffeinatedGuava.build(create(def));
   }
 
   @Override
-  public <K, V> LoadingCache<K, V> build(
-      CacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
-    return backend.isLegacyBackend()
-        ? createLegacy(def).build(loader)
+  public <K, V> LoadingCache<K, V> build(CacheDef<K, V> def, CacheLoader<K, V> loader) {
+    return cacheMaximumWeight(def) == 0
+        ? new PassthroughLoadingCache<>(loader)
         : CaffeinatedGuava.build(create(def), loader);
   }
 
-  @SuppressWarnings("unchecked")
-  private <K, V> CacheBuilder<K, V> createLegacy(CacheDef<K, V> def) {
-    CacheBuilder<K, V> builder = newLegacyCacheBuilder();
+  private <K, V> Caffeine<K, V> create(CacheDef<K, V> def) {
+    Caffeine<K, V> builder = newCacheBuilder();
     builder.recordStats();
-    builder.maximumWeight(
-        cfg.getLong("cache", def.configKey(), "memoryLimit", def.maximumWeight()));
-
-    builder = builder.removalListener(forwardingRemovalListenerFactory.create(def.name()));
-
-    com.google.common.cache.Weigher<K, V> weigher = def.weigher();
-    if (weigher == null) {
-      weigher = unitWeight();
-    }
-    builder.weigher(weigher);
+    builder.maximumWeight(cacheMaximumWeight(def));
+    builder = builder.removalListener(newRemovalListener(def.name()));
+    builder.weigher(newWeigher(def.weigher()));
 
     Duration expireAfterWrite = def.expireAfterWrite();
     if (has(def.configKey(), "maxAge")) {
@@ -123,55 +110,8 @@
     return builder;
   }
 
-  private <K, V> Caffeine<K, V> create(CacheDef<K, V> def) {
-    Caffeine<K, V> builder = newCacheBuilder();
-    builder.recordStats();
-    builder.maximumWeight(
-        cfg.getLong("cache", def.configKey(), "memoryLimit", def.maximumWeight()));
-    builder = builder.removalListener(newRemovalListener(def.name()));
-    builder.weigher(newWeigher(def.weigher()));
-
-    Duration expireAfterWrite = def.expireAfterWrite();
-    if (has(def.configKey(), "maxAge")) {
-      builder.expireAfterWrite(
-          ConfigUtil.getTimeUnit(
-              cfg, "cache", def.configKey(), "maxAge", toSeconds(expireAfterWrite), SECONDS),
-          SECONDS);
-    } else if (expireAfterWrite != null) {
-      builder.expireAfterWrite(expireAfterWrite.toNanos(), NANOSECONDS);
-    }
-
-    Duration expireAfterAccess = def.expireFromMemoryAfterAccess();
-    if (has(def.configKey(), "expireFromMemoryAfterAccess")) {
-      builder.expireAfterAccess(
-          ConfigUtil.getTimeUnit(
-              cfg,
-              "cache",
-              def.configKey(),
-              "expireFromMemoryAfterAccess",
-              toSeconds(expireAfterAccess),
-              SECONDS),
-          SECONDS);
-    } else if (expireAfterAccess != null) {
-      builder.expireAfterAccess(expireAfterAccess.toNanos(), NANOSECONDS);
-    }
-
-    Duration refreshAfterWrite = def.refreshAfterWrite();
-    if (has(def.configKey(), "refreshAfterWrite")) {
-      builder.expireAfterAccess(
-          ConfigUtil.getTimeUnit(
-              cfg,
-              "cache",
-              def.configKey(),
-              "refreshAfterWrite",
-              toSeconds(refreshAfterWrite),
-              SECONDS),
-          SECONDS);
-    } else if (refreshAfterWrite != null) {
-      builder.refreshAfterWrite(refreshAfterWrite.toNanos(), NANOSECONDS);
-    }
-
-    return builder;
+  private <K, V> long cacheMaximumWeight(CacheDef<K, V> def) {
+    return cfg.getLong("cache", def.configKey(), "memoryLimit", def.maximumWeight());
   }
 
   private static long toSeconds(@Nullable Duration duration) {
@@ -183,15 +123,6 @@
   }
 
   @SuppressWarnings("unchecked")
-  private static <K, V> CacheBuilder<K, V> newLegacyCacheBuilder() {
-    return (CacheBuilder<K, V>) CacheBuilder.newBuilder();
-  }
-
-  private static <K, V> com.google.common.cache.Weigher<K, V> unitWeight() {
-    return (key, value) -> 1;
-  }
-
-  @SuppressWarnings("unchecked")
   private static <K, V> Caffeine<K, V> newCacheBuilder() {
     return (Caffeine<K, V>) Caffeine.newBuilder();
   }
diff --git a/java/com/google/gerrit/server/cache/mem/PassthroughLoadingCache.java b/java/com/google/gerrit/server/cache/mem/PassthroughLoadingCache.java
new file mode 100644
index 0000000..ded21aa
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/mem/PassthroughLoadingCache.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.mem;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.CacheLoader.UnsupportedLoadingOperationException;
+import com.google.common.cache.CacheStats;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ExecutionException;
+
+/** Implementation of a NOOP cache that just passes all gets to the loader */
+public class PassthroughLoadingCache<K, V> implements LoadingCache<K, V> {
+
+  private final CacheLoader<? super K, V> cacheLoader;
+
+  public PassthroughLoadingCache(CacheLoader<? super K, V> cacheLoader) {
+    this.cacheLoader = cacheLoader;
+  }
+
+  @Override
+  public @Nullable V getIfPresent(Object key) {
+    return null;
+  }
+
+  @Override
+  public V get(K key, Callable<? extends V> loader) throws ExecutionException {
+    try {
+      return loader.call();
+    } catch (Exception e) {
+      throw new ExecutionException(e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<K, V> getAllPresent(Iterable<?> keys) {
+    return ImmutableMap.of();
+  }
+
+  @Override
+  public void put(K key, V value) {}
+
+  @Override
+  public void putAll(Map<? extends K, ? extends V> m) {}
+
+  @Override
+  public void invalidate(Object key) {}
+
+  @Override
+  public void invalidateAll(Iterable<?> keys) {}
+
+  @Override
+  public void invalidateAll() {}
+
+  @Override
+  public long size() {
+    return 0;
+  }
+
+  @Override
+  public CacheStats stats() {
+    return new CacheStats(0, 0, 0, 0, 0, 0);
+  }
+
+  @Override
+  public void cleanUp() {}
+
+  @Override
+  public V get(K key) throws ExecutionException {
+    try {
+      return cacheLoader.load(key);
+    } catch (Exception e) {
+      throw new ExecutionException(e);
+    }
+  }
+
+  @Override
+  public V getUnchecked(K key) {
+    try {
+      return cacheLoader.load(key);
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<K, V> getAll(Iterable<? extends K> keys) throws ExecutionException {
+    try {
+      try {
+        return getAllBulk(keys);
+      } catch (UnsupportedLoadingOperationException e) {
+        return getAllIndividually(keys);
+      }
+    } catch (Exception e) {
+      throw new ExecutionException(e);
+    }
+  }
+
+  private ImmutableMap<K, V> getAllIndividually(Iterable<? extends K> keys) throws Exception {
+    ImmutableMap.Builder<K, V> builder = ImmutableMap.builder();
+    for (K k : keys) {
+      builder.put(k, cacheLoader.load(k));
+    }
+    return builder.build();
+  }
+
+  @SuppressWarnings("unchecked")
+  private ImmutableMap<K, V> getAllBulk(Iterable<? extends K> keys) throws Exception {
+    return (ImmutableMap<K, V>) ImmutableMap.copyOf(cacheLoader.loadAll(keys));
+  }
+
+  @Override
+  public V apply(K key) {
+    return getUnchecked(key);
+  }
+
+  @Override
+  public void refresh(K key) {}
+
+  @Override
+  public ConcurrentMap<K, V> asMap() {
+    return new ConcurrentHashMap<>();
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
index 5377fc1..bb28a6d 100644
--- a/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/CacheSerializer.java
@@ -31,7 +31,7 @@
    * @return serializer of type {@code T}.
    */
   static <T, D> CacheSerializer<T> convert(CacheSerializer<D> delegate, Converter<T, D> converter) {
-    return new CacheSerializer<T>() {
+    return new CacheSerializer<>() {
       @Override
       public byte[] serialize(T object) {
         return delegate.serialize(converter.convert(object));
diff --git a/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
index ee71846..57aea22 100644
--- a/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/JavaCacheSerializer.java
@@ -42,7 +42,7 @@
     }
   }
 
-  @SuppressWarnings("unchecked")
+  @SuppressWarnings({"unchecked", "BanSerializableRead"})
   @Override
   public T deserialize(byte[] in) {
     Object object;
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
index 7449917..09f3543 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializer.java
@@ -21,7 +21,7 @@
 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;
+import java.time.Instant;
 
 /** Helper to (de)serialize values for caches. */
 public class InternalGroupSerializer {
@@ -33,7 +33,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid(proto.getOwnerGroupUuid()))
             .setVisibleToAll(proto.getIsVisibleToAll())
             .setGroupUUID(AccountGroup.uuid(proto.getGroupUuid()))
-            .setCreatedOn(new Timestamp(proto.getCreatedOn()))
+            .setCreatedOn(Instant.ofEpochMilli(proto.getCreatedOn()))
             .setMembers(
                 proto.getMembersIdsList().stream()
                     .map(a -> Account.id(a))
@@ -62,7 +62,7 @@
             .setOwnerGroupUuid(autoValue.getOwnerGroupUUID().get())
             .setIsVisibleToAll(autoValue.isVisibleToAll())
             .setGroupUuid(autoValue.getGroupUUID().get())
-            .setCreatedOn(autoValue.getCreatedOn().getTime());
+            .setCreatedOn(autoValue.getCreatedOn().toEpochMilli());
 
     autoValue.getMembers().stream().forEach(m -> builder.addMembersIds(m.get()));
     autoValue.getSubgroups().stream().forEach(s -> builder.addSubgroupUuids(s.get()));
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 c00961f..eb3949e 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.server.cache.proto.Cache;
+import java.util.Optional;
 
 /** Helper to (de)serialize values for caches. */
 public class LabelTypeSerializer {
@@ -36,24 +37,12 @@
             proto.getValuesList().stream()
                 .map(LabelValueSerializer::deserialize)
                 .collect(toImmutableList()))
+        .setDescription(Optional.of(proto.getDescription()))
         .setFunction(FUNCTION_CONVERTER.convert(proto.getFunction()))
         .setAllowPostSubmit(proto.getAllowPostSubmit())
         .setIgnoreSelfApproval(proto.getIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(proto.getDefaultValue()))
         .setCopyCondition(Strings.emptyToNull(proto.getCopyCondition()))
-        .setCopyAnyScore(proto.getCopyAnyScore())
-        .setCopyMinScore(proto.getCopyMinScore())
-        .setCopyMaxScore(proto.getCopyMaxScore())
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            proto.getCopyAllScoresIfListOfFilesDidNotChange())
-        .setCopyAllScoresOnMergeFirstParentUpdate(proto.getCopyAllScoresOnMergeFirstParentUpdate())
-        .setCopyAllScoresOnTrivialRebase(proto.getCopyAllScoresOnTrivialRebase())
-        .setCopyAllScoresIfNoCodeChange(proto.getCopyAllScoresIfNoCodeChange())
-        .setCopyAllScoresIfNoChange(proto.getCopyAllScoresIfNoChange())
-        .setCopyValues(
-            proto.getCopyValuesList().stream()
-                .map(Shorts::saturatedCast)
-                .collect(toImmutableList()))
         .setMaxNegative(Shorts.saturatedCast(proto.getMaxNegative()))
         .setMaxPositive(Shorts.saturatedCast(proto.getMaxPositive()))
         .setRefPatterns(proto.getRefPatternsList())
@@ -68,20 +57,9 @@
             autoValue.getValues().stream()
                 .map(LabelValueSerializer::serialize)
                 .collect(toImmutableList()))
+        .setDescription(autoValue.getDescription().orElse(""))
         .setFunction(FUNCTION_CONVERTER.reverse().convert(autoValue.getFunction()))
         .setCopyCondition(autoValue.getCopyCondition().orElse(""))
-        .setCopyAnyScore(autoValue.isCopyAnyScore())
-        .setCopyMinScore(autoValue.isCopyMinScore())
-        .setCopyMaxScore(autoValue.isCopyMaxScore())
-        .setCopyAllScoresIfListOfFilesDidNotChange(
-            autoValue.isCopyAllScoresIfListOfFilesDidNotChange())
-        .setCopyAllScoresOnMergeFirstParentUpdate(
-            autoValue.isCopyAllScoresOnMergeFirstParentUpdate())
-        .setCopyAllScoresOnTrivialRebase(autoValue.isCopyAllScoresOnTrivialRebase())
-        .setCopyAllScoresIfNoCodeChange(autoValue.isCopyAllScoresIfNoCodeChange())
-        .setCopyAllScoresIfNoChange(autoValue.isCopyAllScoresIfNoChange())
-        .addAllCopyValues(
-            autoValue.getCopyValues().stream().map(c -> (int) c).collect(toImmutableList()))
         .setAllowPostSubmit(autoValue.isAllowPostSubmit())
         .setIgnoreSelfApproval(autoValue.isIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(autoValue.getDefaultValue()))
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
index a7a84f7..5ac9ac4 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
@@ -27,7 +27,9 @@
     return StoredCommentLinkInfo.builder(proto.getName())
         .setMatch(emptyToNull(proto.getMatch()))
         .setLink(emptyToNull(proto.getLink()))
-        .setHtml(emptyToNull(proto.getHtml()))
+        .setPrefix(emptyToNull(proto.getPrefix()))
+        .setSuffix(emptyToNull(proto.getSuffix()))
+        .setText(emptyToNull(proto.getText()))
         .setEnabled(proto.getEnabled())
         .setOverrideOnly(proto.getOverrideOnly())
         .build();
@@ -38,7 +40,9 @@
         .setName(autoValue.getName())
         .setMatch(nullToEmpty(autoValue.getMatch()))
         .setLink(nullToEmpty(autoValue.getLink()))
-        .setHtml(nullToEmpty(autoValue.getHtml()))
+        .setPrefix(nullToEmpty(autoValue.getPrefix()))
+        .setSuffix(nullToEmpty(autoValue.getSuffix()))
+        .setText(nullToEmpty(autoValue.getText()))
         .setEnabled(Optional.ofNullable(autoValue.getEnabled()).orElse(true))
         .setOverrideOnly(autoValue.getOverrideOnly())
         .build();
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
index 4e997b4..f61e261 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -14,32 +14,48 @@
 
 package com.google.gerrit.server.cache.serialize.entities;
 
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+import java.util.Optional;
 
 /**
  * Serializer of a {@link SubmitRequirementExpressionResult} to {@link
  * SubmitRequirementExpressionResultProto}.
  */
 public class SubmitRequirementExpressionResultSerializer {
+
+  private static final Converter<String, SubmitRequirementExpressionResult.Status>
+      STATUS_CONVERTER = Enums.stringConverter(SubmitRequirementExpressionResult.Status.class);
+
   public static SubmitRequirementExpressionResult deserialize(
       SubmitRequirementExpressionResultProto proto) {
+    SubmitRequirementExpressionResult.Status status;
+    try {
+      status = STATUS_CONVERTER.convert(proto.getStatus());
+    } catch (IllegalArgumentException e) {
+      status = SubmitRequirementExpressionResult.Status.ERROR;
+    }
     return SubmitRequirementExpressionResult.create(
         SubmitRequirementExpression.create(proto.getExpression()),
-        SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
+        status,
         proto.getPassingAtomsList().stream().collect(ImmutableList.toImmutableList()),
-        proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()));
+        proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()),
+        Optional.ofNullable(Strings.emptyToNull(proto.getErrorMessage())));
   }
 
   public static SubmitRequirementExpressionResultProto serialize(
       SubmitRequirementExpressionResult r) {
     return SubmitRequirementExpressionResultProto.newBuilder()
         .setExpression(r.expression().expressionString())
-        .setStatus(r.status().name())
+        .setStatus(STATUS_CONVERTER.reverse().convert(r.status()))
         .addAllPassingAtoms(r.passingAtoms())
         .addAllFailingAtoms(r.failingAtoms())
+        .setErrorMessage(r.errorMessage().orElse(""))
         .build();
   }
 }
diff --git a/java/com/google/gerrit/server/cancellation/BUILD b/java/com/google/gerrit/server/cancellation/BUILD
index 05530a5..40557b1 100644
--- a/java/com/google/gerrit/server/cancellation/BUILD
+++ b/java/com/google/gerrit/server/cancellation/BUILD
@@ -9,6 +9,6 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
-        "//lib/commons:lang",
+        "//lib/commons:text",
     ],
 )
diff --git a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
index d89701f..dc01567 100644
--- a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -17,7 +17,7 @@
 import com.google.common.base.Throwables;
 import com.google.gerrit.common.Nullable;
 import java.util.Optional;
-import org.apache.commons.lang.WordUtils;
+import org.apache.commons.text.WordUtils;
 
 /** Exception to signal that the current request is cancelled and should be aborted. */
 public class RequestCancelledException extends RuntimeException {
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index d030ec1..59b32c2 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -40,10 +40,7 @@
 
   private final ChangeCleanupConfig cfg;
   private final Provider<ChangeQueryProcessor> queryProvider;
-  // Provider is needed, because AbandonUtil is singleton, but ChangeQueryBuilder accesses
-  // index collection, that is only provided when multiversion index module is started.
-  // TODO(davido); Remove provider again, when support for legacy numeric fields is removed.
-  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final ChangeQueryBuilder queryBuilder;
   private final BatchAbandon batchAbandon;
   private final InternalUser internalUser;
 
@@ -52,11 +49,11 @@
       ChangeCleanupConfig cfg,
       InternalUser.Factory internalUserFactory,
       Provider<ChangeQueryProcessor> queryProvider,
-      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      ChangeQueryBuilder queryBuilder,
       BatchAbandon batchAbandon) {
     this.cfg = cfg;
     this.queryProvider = queryProvider;
-    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryBuilder = queryBuilder;
     this.batchAbandon = batchAbandon;
     internalUser = internalUserFactory.create();
   }
@@ -74,11 +71,7 @@
       }
 
       List<ChangeData> changesToAbandon =
-          queryProvider
-              .get()
-              .enforceVisibility(false)
-              .query(queryBuilderProvider.get().parse(query))
-              .entities();
+          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
       ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
           ImmutableListMultimap.builder();
       for (ChangeData cd : changesToAbandon) {
@@ -99,7 +92,7 @@
             msg.append(" ").append(change.getId().get());
           }
           msg.append(".");
-          logger.atSevere().withCause(e).log(msg.toString());
+          logger.atSevere().withCause(e).log("%s", msg);
         }
       }
       logger.atInfo().log("Auto-Abandoned %d of %d changes.", count, changesToAbandon.size());
@@ -118,7 +111,7 @@
           queryProvider
               .get()
               .enforceVisibility(false)
-              .query(queryBuilderProvider.get().parse(newQuery))
+              .query(queryBuilder.parse(newQuery))
               .entities();
       if (!changesToAbandon.isEmpty()) {
         validChanges.add(cd);
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 54ebf40..ed6d53d 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -106,6 +106,7 @@
     to.actions = toActionMap(rsrc, visitors, changeInfo, copy(visitors, to));
   }
 
+  @Nullable
   private ChangeInfo copy(List<ActionVisitor> visitors, ChangeInfo changeInfo) {
     if (visitors.isEmpty()) {
       return null;
@@ -118,6 +119,10 @@
     copy.topic = changeInfo.topic;
     copy.attentionSet =
         changeInfo.attentionSet == null ? null : ImmutableMap.copyOf(changeInfo.attentionSet);
+    copy.removedFromAttentionSet =
+        changeInfo.removedFromAttentionSet == null
+            ? null
+            : ImmutableMap.copyOf(changeInfo.removedFromAttentionSet);
     copy.assignee = changeInfo.assignee;
     copy.hashtags = changeInfo.hashtags;
     copy.changeId = changeInfo.changeId;
@@ -148,6 +153,7 @@
     return copy;
   }
 
+  @Nullable
   private RevisionInfo copy(List<ActionVisitor> visitors, RevisionInfo revisionInfo) {
     if (visitors.isEmpty()) {
       return null;
@@ -202,7 +208,7 @@
     return out;
   }
 
-  private Map<String, ActionInfo> toActionMap(
+  private ImmutableMap<String, ActionInfo> toActionMap(
       RevisionResource rsrc,
       List<ActionVisitor> visitors,
       ChangeInfo changeInfo,
@@ -222,6 +228,6 @@
       }
       out.put(d.getId(), actionInfo);
     }
-    return out;
+    return ImmutableMap.copyOf(out);
   }
 }
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index a980c32..ec90bec 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -16,13 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -31,18 +29,14 @@
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 
 /** Add a specified user to the attention set. */
 public class AddToAttentionSetOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     AddToAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final MessageIdGenerator messageIdGenerator;
   private final AddToAttentionSetSender.Factory addToAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
@@ -63,14 +57,12 @@
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
       AddToAttentionSetSender.Factory addToAttentionSetSender,
-      MessageIdGenerator messageIdGenerator,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
     this.addToAttentionSetSender = addToAttentionSetSender;
-    this.messageIdGenerator = messageIdGenerator;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
 
     this.attentionUserId = requireNonNull(attentionUserId, "user");
@@ -93,8 +85,8 @@
 
     change = ctx.getChange();
 
-    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.addToPlannedAttentionSetUpdates(
+    ChangeUpdate changeUpdate = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    changeUpdate.addToPlannedAttentionSetUpdates(
         AttentionSetUpdate.createForWrite(
             attentionUserId, AttentionSetUpdate.Operation.ADD, reason));
     return true;
@@ -105,18 +97,13 @@
     if (!notify) {
       return;
     }
-    try {
-      attentionSetEmailFactory
-          .create(
-              addToAttentionSetSender.create(ctx.getProject(), change.getId()),
-              ctx,
-              change,
-              reason,
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
-              attentionUserId)
-          .sendAsync();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
-    }
+    attentionSetEmailFactory
+        .create(
+            addToAttentionSetSender.create(ctx.getProject(), change.getId()),
+            ctx,
+            change,
+            reason,
+            attentionUserId)
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/AttentionSetEntryResource.java b/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
index 6c6c765..e27a1a7 100644
--- a/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
+++ b/java/com/google/gerrit/server/change/AttentionSetEntryResource.java
@@ -22,7 +22,7 @@
 /** REST resource that represents an entry in the attention set of a change. */
 public class AttentionSetEntryResource implements RestResource {
   public static final TypeLiteral<RestView<AttentionSetEntryResource>> ATTENTION_SET_ENTRY_KIND =
-      new TypeLiteral<RestView<AttentionSetEntryResource>>() {};
+      new TypeLiteral<>() {};
 
   public interface Factory {
     AttentionSetEntryResource create(ChangeResource change, Account.Id id);
diff --git a/java/com/google/gerrit/server/change/BatchAbandon.java b/java/com/google/gerrit/server/change/BatchAbandon.java
index 9a3c388..2efa027 100644
--- a/java/com/google/gerrit/server/change/BatchAbandon.java
+++ b/java/com/google/gerrit/server/change/BatchAbandon.java
@@ -68,7 +68,7 @@
       return;
     }
     AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
-    try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.now())) {
       u.setNotify(notify);
       for (ChangeData change : changes) {
         if (!project.equals(change.project())) {
@@ -78,7 +78,9 @@
                   change.project().get(), project.get()));
         }
         u.addOp(change.getId(), abandonOpFactory.create(accountState, msgTxt));
-        u.addOp(change.getId(), storeSubmitRequirementsOpFactory.create());
+        u.addOp(
+            change.getId(),
+            storeSubmitRequirementsOpFactory.create(change.submitRequirements().values(), change));
       }
       u.execute();
 
diff --git a/java/com/google/gerrit/server/change/ChangeEditResource.java b/java/com/google/gerrit/server/change/ChangeEditResource.java
index 392709e..67edb56 100644
--- a/java/com/google/gerrit/server/change/ChangeEditResource.java
+++ b/java/com/google/gerrit/server/change/ChangeEditResource.java
@@ -31,7 +31,7 @@
  */
 public class ChangeEditResource implements RestResource {
   public static final TypeLiteral<RestView<ChangeEditResource>> CHANGE_EDIT_KIND =
-      new TypeLiteral<RestView<ChangeEditResource>>() {};
+      new TypeLiteral<>() {};
 
   private final ChangeResource change;
   private final ChangeEdit edit;
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index c8001bb..8773bb7 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -29,7 +29,10 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -242,32 +245,38 @@
     return change;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setTopic(String topic) {
     checkState(change == null, "setTopic(String) only valid before creating change");
     this.topic = topic;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setCherryPickOf(PatchSet.Id cherryPickOf) {
     this.cherryPickOf = cherryPickOf;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setMessage(String message) {
     this.message = message;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setPatchSetDescription(String patchSetDescription) {
     this.patchSetDescription = patchSetDescription;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setValidate(boolean validate) {
     this.validate = validate;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setReviewersAndCcs(
       Iterable<Account.Id> reviewers, Iterable<Account.Id> ccs) {
     return setReviewersAndCcsAsStrings(
@@ -275,35 +284,57 @@
         Iterables.transform(ccs, Account.Id::toString));
   }
 
+  @CanIgnoreReturnValue
+  public ChangeInserter setReviewersAndCcsIgnoreVisibility(
+      Iterable<Account.Id> reviewers, Iterable<Account.Id> ccs) {
+    return setReviewersAndCcsAsStrings(
+        Iterables.transform(reviewers, Account.Id::toString),
+        Iterables.transform(ccs, Account.Id::toString),
+        /* skipVisibilityCheck= */ true);
+  }
+
+  @CanIgnoreReturnValue
   public ChangeInserter setReviewersAndCcsAsStrings(
       Iterable<String> reviewers, Iterable<String> ccs) {
+    return setReviewersAndCcsAsStrings(reviewers, ccs, /* skipVisibilityCheck= */ false);
+  }
+
+  @CanIgnoreReturnValue
+  private ChangeInserter setReviewersAndCcsAsStrings(
+      Iterable<String> reviewers, Iterable<String> ccs, boolean skipVisibilityCheck) {
     reviewerInputs =
         Streams.concat(
                 Streams.stream(reviewers)
                     .distinct()
-                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER)),
-                Streams.stream(ccs).distinct().map(id -> newReviewerInput(id, ReviewerState.CC)))
+                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER, skipVisibilityCheck)),
+                Streams.stream(ccs)
+                    .distinct()
+                    .map(id -> newReviewerInput(id, ReviewerState.CC, skipVisibilityCheck)))
             .collect(toImmutableList());
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setPrivate(boolean isPrivate) {
     checkState(change == null, "setPrivate(boolean) only valid before creating change");
     this.isPrivate = isPrivate;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setWorkInProgress(boolean workInProgress) {
     this.workInProgress = workInProgress;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setStatus(Change.Status status) {
     checkState(change == null, "setStatus(Change.Status) only valid before creating change");
     this.status = status;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setGroups(List<String> groups) {
     requireNonNull(groups, "groups may not be empty");
     checkState(patchSet == null, "setGroups(List<String>) only valid before creating change");
@@ -311,8 +342,10 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setValidationOptions(
       ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
     checkState(
         patchSet == null,
         "setValidationOptions(ImmutableListMultimap<String, String>) only valid before creating a"
@@ -321,21 +354,25 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setSendMail(boolean sendMail) {
     this.sendMail = sendMail;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setRequestScopePropagator(RequestScopePropagator r) {
     this.requestScopePropagator = r;
     return this;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setRevertOf(Change.Id revertOf) {
     this.revertOf = revertOf;
     return this;
@@ -350,6 +387,7 @@
     return patchSet;
   }
 
+  @CanIgnoreReturnValue
   public ChangeInserter setApprovals(Map<String, Short> approvals) {
     this.approvals = approvals;
     return this;
@@ -367,11 +405,13 @@
    * @param updateRef whether to update the ref during {@link #updateRepo(RepoContext)}.
    */
   @Deprecated
+  @CanIgnoreReturnValue
   public ChangeInserter setUpdateRef(boolean updateRef) {
     this.updateRef = updateRef;
     return this;
   }
 
+  @Nullable
   public String getChangeMessage() {
     if (message == null) {
       return null;
@@ -466,10 +506,10 @@
     approvalsUtil.addApprovalsForNewPatchSet(
         update, labelTypes, patchSet, ctx.getUser(), approvals);
 
-    // Check if approvals are changing in with this update. If so, add current user to reviewers.
-    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // Check if approvals are changing with this update. If so, add the current user (aka the
+    // approver) as a reviewers because all approvers must also be reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as a
     // reviewer which is needed in several other code paths.
-    // TODO(dborowitz): Still necessary?
     if (!approvals.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
@@ -485,7 +525,7 @@
   public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
-    if (sendMail && notify.shouldNotify()) {
+    if (sendMail) {
       Runnable sender =
           new Runnable() {
             @Override
@@ -497,15 +537,15 @@
                 emailSender.setPatchSet(patchSet, patchSetInfo);
                 emailSender.setNotify(notify);
                 emailSender.addReviewers(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
                 emailSender.addReviewersByEmail(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewersByEmail));
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewersByEmail));
                 emailSender.addExtraCC(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs));
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs));
                 emailSender.addExtraCCByEmail(
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCsByEmail));
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCsByEmail));
                 emailSender.setMessageId(
                     messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
                 emailSender.send();
@@ -594,7 +634,8 @@
     }
   }
 
-  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
+  private static InternalReviewerInput newReviewerInput(
+      String reviewer, ReviewerState state, boolean skipVisibilityCheck) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new change email.
     InternalReviewerInput input =
@@ -605,12 +646,17 @@
     // certain commit footers: putting a nonexistent user in a footer should not cause an error. In
     // theory we could provide finer control to do this for some reviewers and not others, but it's
     // not worth complicating the ChangeInserter interface further at this time.
-    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
+
+    input.skipVisibilityCheck = skipVisibilityCheck;
 
     return input;
   }
 
   private ImmutableList<InternalReviewerInput> getReviewerInputs() {
+    if (projectState.is(BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS)) {
+      return reviewerInputs;
+    }
     return Streams.concat(
             reviewerInputs.stream(),
             Streams.stream(
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 328c5de..912d202 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -35,14 +35,17 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
 import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+import static com.google.gerrit.server.util.AttentionSetUtil.removalsOnly;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Throwables;
+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.ImmutableSortedMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
@@ -60,7 +63,6 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Status;
-import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
@@ -99,8 +101,6 @@
 import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -146,7 +146,7 @@
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().build();
 
-  static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
+  public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
       ImmutableSet.of(
           ALL_COMMITS,
           ALL_REVISIONS,
@@ -236,6 +236,7 @@
   private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
   private final boolean includeMergeable;
   private final boolean lazyLoad;
+  private final boolean cacheQueryResultsByChangeNum;
 
   private AccountLoader accountLoader;
   private FixInput fix;
@@ -255,7 +256,6 @@
       TrackingFooters trackingFooters,
       Metrics metrics,
       RevisionJson.Factory revisionJsonFactory,
-      ExperimentFeatures experimentFeatures,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
@@ -274,10 +274,10 @@
     this.revisionJson = revisionJsonFactory.create(options);
     this.options = Sets.immutableEnumSet(options);
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
-    this.lazyLoad =
-        containsAnyOf(this.options, REQUIRE_LAZY_LOAD)
-            || lazyloadSubmitRequirements(this.options, experimentFeatures);
+    this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
+    this.cacheQueryResultsByChangeNum =
+        cfg.getBoolean("index", "cacheQueryResultsByChangeNum", true);
 
     logger.atFine().log("options = %s", options);
   }
@@ -382,10 +382,10 @@
 
   private Collection<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
     Collection<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
-    Map<SubmitRequirement, SubmitRequirementResult> requirements = cd.submitRequirements();
-    for (Map.Entry<SubmitRequirement, SubmitRequirementResult> entry : requirements.entrySet()) {
-      reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue()));
-    }
+    cd.submitRequirementsIncludingLegacy().entrySet().stream()
+        .filter(entry -> !entry.getValue().isHidden())
+        .forEach(
+            entry -> reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue())));
     return reqInfos;
   }
 
@@ -500,7 +500,7 @@
         // This problem has two sides where 'last in the list' has to be respected:
         // (1) Caching
         // (2) Reusing
-        boolean isCacheable = i != changes.size() - 1;
+        boolean isCacheable = cacheQueryResultsByChangeNum && (i != changes.size() - 1);
         ChangeData cd = changes.get(i);
         ChangeInfo info = cache.get(cd.getId());
         if (info != null && isCacheable) {
@@ -556,8 +556,8 @@
       info.subject = c.getSubject();
       info.status = c.getStatus().asChangeStatus();
       info.owner = new AccountInfo(c.getOwner().get());
-      info.created = c.getCreatedOn();
-      info.updated = c.getLastUpdatedOn();
+      info.setCreated(c.getCreatedOn());
+      info.setUpdated(c.getLastUpdatedOn());
       info._number = c.getId().get();
       info.problems = result.problems();
       info.isPrivate = c.isPrivate() ? true : null;
@@ -603,6 +603,12 @@
     out.branch = in.getDest().shortName();
     out.topic = in.getTopic();
     if (!cd.attentionSet().isEmpty()) {
+      out.removedFromAttentionSet =
+          removalsOnly(cd.attentionSet()).stream()
+              .collect(
+                  toImmutableMap(
+                      a -> a.account().get(),
+                      a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
       out.attentionSet =
           // This filtering should match GetAttentionSet.
           additionsOnly(cd.attentionSet()).stream()
@@ -639,8 +645,8 @@
     out.subject = in.getSubject();
     out.status = in.getStatus().asChangeStatus();
     out.owner = accountLoader.get(in.getOwner());
-    out.created = in.getCreatedOn();
-    out.updated = in.getLastUpdatedOn();
+    out.setCreated(in.getCreatedOn());
+    out.setUpdated(in.getLastUpdatedOn());
     out._number = in.getId().get();
     out.totalCommentCount = cd.totalCommentCount();
     out.unresolvedCommentCount = cd.unresolvedCommentCount();
@@ -682,6 +688,7 @@
             !cd.change().isAbandoned()
                 ? labelsJson.permittedLabels(user.getAccountId(), cd)
                 : ImmutableMap.of();
+        out.removableLabels = labelsJson.removableLabels(accountLoader, user, cd);
       }
     }
 
@@ -772,18 +779,20 @@
     List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
     List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
     for (ReviewerStatusUpdate c : reviewerUpdates) {
-      ReviewerUpdateInfo change = new ReviewerUpdateInfo();
-      change.updated = c.date();
-      change.state = c.state().asReviewerState();
-      change.updatedBy = accountLoader.get(c.updatedBy());
-      change.reviewer = accountLoader.get(c.reviewer());
+      ReviewerUpdateInfo change =
+          new ReviewerUpdateInfo(
+              c.date(),
+              accountLoader.get(c.updatedBy()),
+              accountLoader.get(c.reviewer()),
+              c.state().asReviewerState());
       result.add(change);
     }
     return result;
   }
 
   private boolean submittable(ChangeData cd) {
-    return SubmitRecord.allRecordsOK(cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT));
+    return cd.submitRequirementsIncludingLegacy().values().stream()
+        .allMatch(SubmitRequirementResult::fulfilled);
   }
 
   private void setSubmitter(ChangeData cd, ChangeInfo out) {
@@ -791,21 +800,20 @@
     if (!s.isPresent()) {
       return;
     }
-    out.submitted = s.get().granted();
-    out.submitter = accountLoader.get(s.get().accountId());
+    out.setSubmitted(s.get().granted(), accountLoader.get(s.get().accountId()));
   }
 
-  private Collection<ChangeMessageInfo> messages(ChangeData cd) {
+  private ImmutableList<ChangeMessageInfo> messages(ChangeData cd) {
     List<ChangeMessage> messages = cmUtil.byChange(cd.notes());
     if (messages.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
     for (ChangeMessage message : messages) {
       result.add(createChangeMessageInfo(message, accountLoader));
     }
-    return result;
+    return ImmutableList.copyOf(result);
   }
 
   private Collection<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
@@ -925,11 +933,12 @@
       }
       src = Collections.singletonList(ps);
     }
-    Map<PatchSet.Id, PatchSet> map = Maps.newHashMapWithExpectedSize(src.size());
+    // Sort by patch set ID in increasing order to have a stable output.
+    ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> map = ImmutableSortedMap.naturalOrder();
     for (PatchSet patchSet : src) {
       map.put(patchSet.id(), patchSet);
     }
-    return map;
+    return map.build();
   }
 
   private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
@@ -943,20 +952,4 @@
     }
     return ImmutableListMultimap.of();
   }
-
-  private static boolean lazyloadSubmitRequirements(
-      Set<ListChangesOption> changeOptions, ExperimentFeatures experimentFeatures) {
-    // TODO(ghareeb,hiesel): Remove this method.
-    // We are testing the new submit requirements with users in lieu of upgrading the change index
-    // to a version that supports the new requirements.
-    // Upgrading now, before the feature is finalized would be counter productive, because the index
-    // format might change while we iterate over the feature.
-    // Allowing changes to lazyload parameters will slow down dashboards for users who have this
-    // feature enabled, but will backfill submit requirements that weren't loaded from the index by
-    // simply computing them.
-    return changeOptions.contains(SUBMIT_REQUIREMENTS)
-        && experimentFeatures.isFeatureEnabled(
-            ExperimentFeaturesConstants
-                .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD);
-  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeMessageResource.java b/java/com/google/gerrit/server/change/ChangeMessageResource.java
index 25f952d..751faa0 100644
--- a/java/com/google/gerrit/server/change/ChangeMessageResource.java
+++ b/java/com/google/gerrit/server/change/ChangeMessageResource.java
@@ -23,7 +23,7 @@
 /** A change message resource. */
 public class ChangeMessageResource implements RestResource {
   public static final TypeLiteral<RestView<ChangeMessageResource>> CHANGE_MESSAGE_KIND =
-      new TypeLiteral<RestView<ChangeMessageResource>>() {};
+      new TypeLiteral<>() {};
 
   private final ChangeResource changeResource;
   private final ChangeMessageInfo changeMessage;
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 970f1b5..919586e 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -62,8 +62,7 @@
    */
   public static final int JSON_FORMAT_VERSION = 1;
 
-  public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND =
-      new TypeLiteral<RestView<ChangeResource>>() {};
+  public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND = new TypeLiteral<>() {};
 
   public interface Factory {
     ChangeResource create(ChangeNotes notes, CurrentUser user);
@@ -166,7 +165,7 @@
   // unrelated to the UI.
   public void prepareETag(Hasher h, CurrentUser user) {
     h.putInt(JSON_FORMAT_VERSION)
-        .putLong(getChange().getLastUpdatedOn().getTime())
+        .putLong(getChange().getLastUpdatedOn().toEpochMilli())
         .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
 
     if (user.isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 7d0bda1..38efc44 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -95,7 +95,7 @@
   public abstract static class Result {
     private static Result create(ChangeNotes notes, List<ProblemInfo> problems) {
       return new AutoValue_ConsistencyChecker_Result(
-          notes.getChangeId(), notes.getChange(), problems);
+          notes.getChangeId(), notes.getChange(), ImmutableList.copyOf(problems));
     }
 
     public abstract Change.Id id();
@@ -103,7 +103,7 @@
     @Nullable
     public abstract Change change();
 
-    public abstract List<ProblemInfo> problems();
+    public abstract ImmutableList<ProblemInfo> problems();
   }
 
   private final ChangeNotes.Factory notesFactory;
@@ -626,7 +626,7 @@
   }
 
   private BatchUpdate newBatchUpdate() {
-    return updateFactory.create(change().getProject(), user.get(), TimeUtil.nowTs());
+    return updateFactory.create(change().getProject(), user.get(), TimeUtil.now());
   }
 
   private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
@@ -764,6 +764,7 @@
     return serverIdent.get();
   }
 
+  @Nullable
   private RevCommit parseCommit(ObjectId objId, String desc) {
     try {
       return rw.parseCommit(objId);
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index b512a2d..f3fd68e 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -76,9 +76,6 @@
     if (sendEmail) {
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        if (!notify.shouldNotify()) {
-          return;
-        }
         DeleteReviewerSender emailSender =
             deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 1e40429..afb9d76 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -193,10 +193,13 @@
         notify = notify.withHandling(NotifyHandling.OWNER);
       }
       try {
-        if (notify.shouldNotify()) {
-          emailReviewers(
-              ctx.getProject(), currChange, mailMessage, ctx.getWhen(), notify, ctx.getRepoView());
-        }
+        emailReviewers(
+            ctx.getProject(),
+            currChange,
+            mailMessage,
+            Timestamp.from(ctx.getWhen()),
+            notify,
+            ctx.getRepoView());
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot email update for change %s", currChange.getId());
@@ -223,7 +226,7 @@
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
     Iterable<PatchSetApproval> approvals;
-    approvals = approvalsUtil.byChange(ctx.getNotes()).values();
+    approvals = ctx.getNotes().getApprovals().all().values();
     return Iterables.filter(approvals, psa -> accountId.equals(psa.accountId()));
   }
 
@@ -251,7 +254,7 @@
         deleteReviewerSenderFactory.create(projectName, change.getId());
     emailSender.setFrom(userId);
     emailSender.addReviewers(Collections.singleton(reviewer.id()));
-    emailSender.setChangeMessage(mailMessage, timestamp);
+    emailSender.setChangeMessage(mailMessage, timestamp.toInstant());
     emailSender.setNotify(notify);
     emailSender.setMessageId(
         messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
diff --git a/java/com/google/gerrit/server/change/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
index 19a495d..2e40f2c 100644
--- a/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -25,7 +25,7 @@
 
 public class DraftCommentResource implements RestResource {
   public static final TypeLiteral<RestView<DraftCommentResource>> DRAFT_COMMENT_KIND =
-      new TypeLiteral<RestView<DraftCommentResource>>() {};
+      new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final HumanComment comment;
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
new file mode 100644
index 0000000..f67ce4a
--- /dev/null
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -0,0 +1,242 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class EmailNewPatchSet {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    EmailNewPatchSet create(
+        PostUpdateContext postUpdateContext,
+        PatchSet patchSet,
+        @Nullable String message,
+        ImmutableSet<PatchSetApproval> outdatedApprovals,
+        @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
+        @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId);
+  }
+
+  private final ExecutorService sendEmailExecutor;
+  private final ThreadLocalRequestContext threadLocalRequestContext;
+  private final AsyncSender asyncSender;
+
+  private RequestScopePropagator requestScopePropagator;
+
+  @Inject
+  EmailNewPatchSet(
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      ThreadLocalRequestContext threadLocalRequestContext,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      PatchSetInfoFactory patchSetInfoFactory,
+      MessageIdGenerator messageIdGenerator,
+      @Assisted PostUpdateContext postUpdateContext,
+      @Assisted PatchSet patchSet,
+      @Nullable @Assisted String message,
+      @Assisted ImmutableSet<PatchSetApproval> outdatedApprovals,
+      @Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
+      @Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
+      @Assisted ChangeKind changeKind,
+      @Assisted ObjectId preUpdateMetaId) {
+    this.sendEmailExecutor = sendEmailExecutor;
+    this.threadLocalRequestContext = threadLocalRequestContext;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              postUpdateContext.getRepoView(), patchSet.id(), "EmailReplacePatchSet");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    Change.Id changeId = patchSet.id().changeId();
+
+    // Getting the change data from PostUpdateContext retrieves a cached ChangeData
+    // instance. This ChangeData instance has been created when the change was (re)indexed
+    // due to the update, and hence has submit requirement results already cached (since
+    // (re)indexing triggers the evaluation of the submit requirements).
+    Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults =
+        postUpdateContext
+            .getChangeData(postUpdateContext.getProject(), changeId)
+            .submitRequirementsIncludingLegacy();
+    this.asyncSender =
+        new AsyncSender(
+            postUpdateContext.getIdentifiedUser(),
+            replacePatchSetFactory,
+            patchSetInfoFactory,
+            messageId,
+            postUpdateContext.getNotify(changeId),
+            postUpdateContext.getProject(),
+            changeId,
+            patchSet,
+            message,
+            postUpdateContext.getWhen(),
+            outdatedApprovals,
+            reviewers,
+            extraCcs,
+            changeKind,
+            preUpdateMetaId,
+            postUpdateSubmitRequirementResults);
+  }
+
+  public EmailNewPatchSet setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
+  public void sendAsync() {
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        sendEmailExecutor.submit(
+            requestScopePropagator != null
+                ? requestScopePropagator.wrap(asyncSender)
+                : () -> {
+                  RequestContext old = threadLocalRequestContext.setContext(asyncSender);
+                  try {
+                    asyncSender.run();
+                  } finally {
+                    threadLocalRequestContext.setContext(old);
+                  }
+                });
+  }
+
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final IdentifiedUser user;
+    private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Project.NameKey projectName;
+    private final Change.Id changeId;
+    private final PatchSet patchSet;
+    private final String message;
+    private final Instant timestamp;
+    private final ImmutableSet<PatchSetApproval> outdatedApprovals;
+    private final ImmutableSet<Account.Id> reviewers;
+    private final ImmutableSet<Account.Id> extraCcs;
+    private final ChangeKind changeKind;
+    private final ObjectId preUpdateMetaId;
+    private final Map<SubmitRequirement, SubmitRequirementResult>
+        postUpdateSubmitRequirementResults;
+
+    AsyncSender(
+        IdentifiedUser user,
+        ReplacePatchSetSender.Factory replacePatchSetFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        MessageId messageId,
+        NotifyResolver.Result notify,
+        Project.NameKey projectName,
+        Change.Id changeId,
+        PatchSet patchSet,
+        String message,
+        Instant timestamp,
+        ImmutableSet<PatchSetApproval> outdatedApprovals,
+        ImmutableSet<Account.Id> reviewers,
+        ImmutableSet<Account.Id> extraCcs,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      this.user = user;
+      this.replacePatchSetFactory = replacePatchSetFactory;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.projectName = projectName;
+      this.changeId = changeId;
+      this.patchSet = patchSet;
+      this.message = message;
+      this.timestamp = timestamp;
+      this.outdatedApprovals = outdatedApprovals;
+      this.reviewers = reviewers;
+      this.extraCcs = extraCcs;
+      this.changeKind = changeKind;
+      this.preUpdateMetaId = preUpdateMetaId;
+      this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
+    }
+
+    @Override
+    public void run() {
+      try {
+        ReplacePatchSetSender emailSender =
+            replacePatchSetFactory.create(
+                projectName,
+                changeId,
+                changeKind,
+                preUpdateMetaId,
+                postUpdateSubmitRequirementResults);
+        emailSender.setFrom(user.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        emailSender.setChangeMessage(message, timestamp);
+        emailSender.setNotify(notify);
+        emailSender.addReviewers(reviewers);
+        emailSender.addExtraCC(extraCcs);
+        emailSender.addOutdatedApproval(outdatedApprovals);
+        emailSender.setMessageId(messageId);
+        emailSender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot send email for new patch set %s", patchSet.id());
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "send-email newpatchset";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user.getRealUser();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index 3c7ea44..a9886c7 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -16,29 +16,38 @@
 
 import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.update.RepoView;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.time.Instant;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
 
-public class EmailReviewComments implements Runnable, RequestContext {
+public class EmailReviewComments {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -47,13 +56,11 @@
     /**
      * Creates handle for sending email
      *
-     * @param notify setting for handling notification.
-     * @param notes change notes.
+     * @param postUpdateContext the post update context from the calling BatchUpdateOp
      * @param patchSet patch set corresponding to the top-level op
-     * @param user user the email should come from.
+     * @param preUpdateMetaId the SHA1 to which the notes branch pointed before the update
      * @param message used by text template only. The contents of this message typically include the
      *     "Patch set N" header and "(M comments)".
-     * @param timestamp timestamp when the comments were added.
      * @param comments inline comments.
      * @param patchSetComment used by HTML template only: some quasi-human-generated text. The
      *     contents should *not* include a "Patch set N" header or "(M comments)" footer, as these
@@ -61,34 +68,17 @@
      * @param labels labels applied as part of this review operation.
      */
     EmailReviewComments create(
-        NotifyResolver.Result notify,
-        ChangeNotes notes,
+        PostUpdateContext postUpdateContext,
         PatchSet patchSet,
-        IdentifiedUser user,
+        ObjectId preUpdateMetaId,
         @Assisted("message") String message,
-        Timestamp timestamp,
         List<? extends Comment> comments,
-        @Assisted("patchSetComment") String patchSetComment,
-        List<LabelVote> labels,
-        RepoView repoView);
+        @Nullable @Assisted("patchSetComment") String patchSetComment,
+        List<LabelVote> labels);
   }
 
   private final ExecutorService sendEmailsExecutor;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final CommentSender.Factory commentSenderFactory;
-  private final ThreadLocalRequestContext requestContext;
-  private final MessageIdGenerator messageIdGenerator;
-
-  private final NotifyResolver.Result notify;
-  private final ChangeNotes notes;
-  private final PatchSet patchSet;
-  private final IdentifiedUser user;
-  private final String message;
-  private final Timestamp timestamp;
-  private final List<? extends Comment> comments;
-  private final String patchSetComment;
-  private final List<LabelVote> labels;
-  private final RepoView repoView;
+  private final AsyncSender asyncSender;
 
   @Inject
   EmailReviewComments(
@@ -97,69 +87,151 @@
       CommentSender.Factory commentSenderFactory,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
-      @Assisted NotifyResolver.Result notify,
-      @Assisted ChangeNotes notes,
+      @Assisted PostUpdateContext postUpdateContext,
       @Assisted PatchSet patchSet,
-      @Assisted IdentifiedUser user,
+      @Assisted ObjectId preUpdateMetaId,
       @Assisted("message") String message,
-      @Assisted Timestamp timestamp,
       @Assisted List<? extends Comment> comments,
       @Nullable @Assisted("patchSetComment") String patchSetComment,
-      @Assisted List<LabelVote> labels,
-      @Assisted RepoView repoView) {
+      @Assisted List<LabelVote> labels) {
     this.sendEmailsExecutor = executor;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.commentSenderFactory = commentSenderFactory;
-    this.requestContext = requestContext;
-    this.messageIdGenerator = messageIdGenerator;
-    this.notify = notify;
-    this.notes = notes;
-    this.patchSet = patchSet;
-    this.user = user;
-    this.message = message;
-    this.timestamp = timestamp;
-    this.comments = COMMENT_ORDER.sortedCopy(comments);
-    this.patchSetComment = patchSetComment;
-    this.labels = labels;
-    this.repoView = repoView;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              postUpdateContext.getRepoView(), patchSet.id(), "EmailReviewComments");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    Change.Id changeId = patchSet.id().changeId();
+
+    // Getting the change data from PostUpdateContext retrieves a cached ChangeData
+    // instance. This ChangeData instance has been created when the change was (re)indexed
+    // due to the update, and hence has submit requirement results already cached (since
+    // (re)indexing triggers the evaluation of the submit requirements).
+    Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults =
+        postUpdateContext
+            .getChangeData(postUpdateContext.getProject(), changeId)
+            .submitRequirementsIncludingLegacy();
+    this.asyncSender =
+        new AsyncSender(
+            requestContext,
+            commentSenderFactory,
+            patchSetInfoFactory,
+            postUpdateContext.getUser().asIdentifiedUser(),
+            messageId,
+            postUpdateContext.getNotify(changeId),
+            postUpdateContext.getProject(),
+            changeId,
+            patchSet,
+            preUpdateMetaId,
+            message,
+            postUpdateContext.getWhen(),
+            ImmutableList.copyOf(COMMENT_ORDER.sortedCopy(comments)),
+            patchSetComment,
+            ImmutableList.copyOf(labels),
+            postUpdateSubmitRequirementResults);
   }
 
   public void sendAsync() {
     @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender);
   }
 
-  @Override
-  public void run() {
-    RequestContext old = requestContext.setContext(this);
-    try {
-      CommentSender emailSender =
-          commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
-      emailSender.setFrom(user.getAccountId());
-      emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
-      emailSender.setChangeMessage(message, timestamp);
-      emailSender.setComments(comments);
-      emailSender.setPatchSetComment(patchSetComment);
-      emailSender.setLabels(labels);
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdateAndReason(
-              repoView, patchSet.id(), "EmailReviewComments"));
-      emailSender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
-    } finally {
-      requestContext.setContext(old);
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  // TODO: The passed in Comment class is not thread-safe, replace it with an AutoValue type.
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final ThreadLocalRequestContext requestContext;
+    private final CommentSender.Factory commentSenderFactory;
+    private final PatchSetInfoFactory patchSetInfoFactory;
+    private final IdentifiedUser user;
+    private final MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Project.NameKey projectName;
+    private final Change.Id changeId;
+    private final PatchSet patchSet;
+    private final ObjectId preUpdateMetaId;
+    private final String message;
+    private final Instant timestamp;
+    private final ImmutableList<? extends Comment> comments;
+    @Nullable private final String patchSetComment;
+    private final ImmutableList<LabelVote> labels;
+    private final Map<SubmitRequirement, SubmitRequirementResult>
+        postUpdateSubmitRequirementResults;
+
+    AsyncSender(
+        ThreadLocalRequestContext requestContext,
+        CommentSender.Factory commentSenderFactory,
+        PatchSetInfoFactory patchSetInfoFactory,
+        IdentifiedUser user,
+        MessageId messageId,
+        NotifyResolver.Result notify,
+        Project.NameKey projectName,
+        Change.Id changeId,
+        PatchSet patchSet,
+        ObjectId preUpdateMetaId,
+        String message,
+        Instant timestamp,
+        ImmutableList<? extends Comment> comments,
+        @Nullable String patchSetComment,
+        ImmutableList<LabelVote> labels,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      this.requestContext = requestContext;
+      this.commentSenderFactory = commentSenderFactory;
+      this.patchSetInfoFactory = patchSetInfoFactory;
+      this.user = user;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.projectName = projectName;
+      this.changeId = changeId;
+      this.patchSet = patchSet;
+      this.preUpdateMetaId = preUpdateMetaId;
+      this.message = message;
+      this.timestamp = timestamp;
+      this.comments = comments;
+      this.patchSetComment = patchSetComment;
+      this.labels = labels;
+      this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
     }
-  }
 
-  @Override
-  public String toString() {
-    return "send-email comments";
-  }
+    @Override
+    public void run() {
+      RequestContext old = requestContext.setContext(this);
+      try {
+        CommentSender emailSender =
+            commentSenderFactory.create(
+                projectName, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
+        emailSender.setFrom(user.getAccountId());
+        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        emailSender.setChangeMessage(message, timestamp);
+        emailSender.setComments(comments);
+        emailSender.setPatchSetComment(patchSetComment);
+        emailSender.setLabels(labels);
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(messageId);
+        emailSender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
+      } finally {
+        requestContext.setContext(old);
+      }
+    }
 
-  @Override
-  public CurrentUser getUser() {
-    return user.getRealUser();
+    @Override
+    public String toString() {
+      return "send-email comments";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user.getRealUser();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
index b729c11..d9c30d7 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
@@ -23,6 +23,7 @@
 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.DiffOptions;
 import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -41,6 +42,7 @@
     this.diffs = diffOperations;
   }
 
+  @Nullable
   @Override
   public Map<String, FileInfo> getFileInfoMap(
       Change change, ObjectId objectId, @Nullable PatchSet base)
@@ -51,22 +53,25 @@
         // single-parent commits, or the auto-merge otherwise
         return asFileInfo(
             diffs.listModifiedFilesAgainstParent(
-                change.getProject(), objectId, /* parentNum= */ 0));
+                change.getProject(), objectId, /* parentNum= */ 0, DiffOptions.DEFAULTS));
       }
-      return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
+      return asFileInfo(
+          diffs.listModifiedFiles(
+              change.getProject(), base.commitId(), objectId, DiffOptions.DEFAULTS));
     } catch (DiffNotAvailableException e) {
       convertException(e);
       return null; // unreachable. handleAndThrow will throw an exception anyway
     }
   }
 
+  @Nullable
   @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);
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent, DiffOptions.DEFAULTS);
       return asFileInfo(modifiedFiles);
     } catch (DiffNotAvailableException e) {
       convertException(e);
@@ -99,6 +104,14 @@
       fileInfo.oldPath = FilePathAdapter.getOldPath(fileDiff.oldPath(), fileDiff.changeType());
       fileInfo.sizeDelta = fileDiff.sizeDelta();
       fileInfo.size = fileDiff.size();
+      fileInfo.oldMode =
+          fileDiff.oldMode().isPresent() && !fileDiff.oldMode().get().equals(Patch.FileMode.MISSING)
+              ? fileDiff.oldMode().get().getMode()
+              : null;
+      fileInfo.newMode =
+          fileDiff.newMode().isPresent() && !fileDiff.newMode().get().equals(Patch.FileMode.MISSING)
+              ? fileDiff.newMode().get().getMode()
+              : null;
       if (fileDiff.patchType().get() == Patch.PatchType.BINARY) {
         fileInfo.binary = true;
       } else {
diff --git a/java/com/google/gerrit/server/change/FileResource.java b/java/com/google/gerrit/server/change/FileResource.java
index 5402338..22fe39c 100644
--- a/java/com/google/gerrit/server/change/FileResource.java
+++ b/java/com/google/gerrit/server/change/FileResource.java
@@ -21,8 +21,7 @@
 import com.google.inject.TypeLiteral;
 
 public class FileResource implements RestResource {
-  public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
-      new TypeLiteral<RestView<FileResource>>() {};
+  public static final TypeLiteral<RestView<FileResource>> FILE_KIND = new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final Patch.Key key;
diff --git a/java/com/google/gerrit/server/change/FixResource.java b/java/com/google/gerrit/server/change/FixResource.java
index b6b5894..0c5ee23 100644
--- a/java/com/google/gerrit/server/change/FixResource.java
+++ b/java/com/google/gerrit/server/change/FixResource.java
@@ -21,8 +21,7 @@
 import java.util.List;
 
 public class FixResource implements RestResource {
-  public static final TypeLiteral<RestView<FixResource>> FIX_KIND =
-      new TypeLiteral<RestView<FixResource>>() {};
+  public static final TypeLiteral<RestView<FixResource>> FIX_KIND = new TypeLiteral<>() {};
 
   private final List<FixReplacement> fixReplacements;
   private final RevisionResource revisionResource;
diff --git a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
index b1f9726..9a75469 100644
--- a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
+++ b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
@@ -65,6 +65,33 @@
    */
   public List<RelatedChangesSorter.PatchSetData> getRelated(ChangeData changeData, PatchSet basePs)
       throws IOException, PermissionBackendException {
+    List<ChangeData> cds = getUnsortedRelated(changeData, basePs, false);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return sorter.sort(cds, basePs);
+  }
+
+  /**
+   * Gets ancestor changes of a specific change revision.
+   *
+   * @param changeData the change of the inputted revision.
+   * @param basePs the revision that the method checks for related changes.
+   * @param alwaysIncludeOriginalChange whether to return the given change when no ancestors found.
+   * @return list of ancestor changes, sorted via {@link RelatedChangesSorter}
+   */
+  public List<RelatedChangesSorter.PatchSetData> getAncestors(
+      ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange)
+      throws IOException, PermissionBackendException {
+    List<ChangeData> cds = getUnsortedRelated(changeData, basePs, alwaysIncludeOriginalChange);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    return sorter.sortAncestors(cds, basePs);
+  }
+
+  private List<ChangeData> getUnsortedRelated(
+      ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange) {
     Set<String> groups = getAllGroups(changeData.patchSets());
     logger.atFine().log("groups = %s", groups);
     if (groups.isEmpty()) {
@@ -72,18 +99,16 @@
     }
 
     List<ChangeData> cds =
-        InternalChangeQuery.byProjectGroups(
-            queryProvider, indexConfig, changeData.project(), groups);
+        InternalChangeQuery.byBranchGroups(
+            queryProvider, indexConfig, changeData.change().getDest(), groups);
     if (cds.isEmpty()) {
       return Collections.emptyList();
     }
     if (cds.size() == 1 && cds.get(0).getId().equals(changeData.getId())) {
-      return Collections.emptyList();
+      return alwaysIncludeOriginalChange ? cds : Collections.emptyList();
     }
 
-    cds = reloadChangeIfStale(cds, changeData, basePs);
-
-    return sorter.sort(cds, basePs);
+    return reloadChangeIfStale(cds, changeData, basePs);
   }
 
   private List<ChangeData> reloadChangeIfStale(
diff --git a/java/com/google/gerrit/server/change/HumanCommentResource.java b/java/com/google/gerrit/server/change/HumanCommentResource.java
index 1611aaa..93a0698 100644
--- a/java/com/google/gerrit/server/change/HumanCommentResource.java
+++ b/java/com/google/gerrit/server/change/HumanCommentResource.java
@@ -23,7 +23,7 @@
 
 public class HumanCommentResource implements RestResource {
   public static final TypeLiteral<RestView<HumanCommentResource>> COMMENT_KIND =
-      new TypeLiteral<RestView<HumanCommentResource>>() {};
+      new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final HumanComment comment;
diff --git a/java/com/google/gerrit/server/change/IncludedIn.java b/java/com/google/gerrit/server/change/IncludedIn.java
index c06ce82..94498d7 100644
--- a/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/java/com/google/gerrit/server/change/IncludedIn.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
@@ -37,10 +38,15 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -75,13 +81,26 @@
         throw new ResourceConflictException(err.getMessage());
       }
 
-      IncludedInResolver.Result d = IncludedInResolver.resolve(r, rw, rev);
+      RefDatabase refDb = r.getRefDatabase();
+      Collection<Ref> tags = refDb.getRefsByPrefix(Constants.R_TAGS);
+      Collection<Ref> branches = refDb.getRefsByPrefix(Constants.R_HEADS);
+      List<Ref> allTagsAndBranches = Lists.newArrayListWithCapacity(tags.size() + branches.size());
+      allTagsAndBranches.addAll(tags);
+      allTagsAndBranches.addAll(branches);
+
+      Set<String> allMatchingTagsAndBranches =
+          rw.getMergedInto(rev, IncludedInUtil.getSortedRefs(allTagsAndBranches, rw)).stream()
+              .map(Ref::getName)
+              .collect(Collectors.toSet());
 
       // Filter branches and tags according to their visbility by the user
       ImmutableSortedSet<String> filteredBranches =
-          sortedShortNames(filterReadableRefs(project, d.branches()));
+          sortedShortNames(
+              filterReadableRefs(
+                  project, getMatchingRefNames(allMatchingTagsAndBranches, branches)));
       ImmutableSortedSet<String> filteredTags =
-          sortedShortNames(filterReadableRefs(project, d.tags()));
+          sortedShortNames(
+              filterReadableRefs(project, getMatchingRefNames(allMatchingTagsAndBranches, tags)));
 
       ListMultimap<String, String> external = MultimapBuilder.hashKeys().arrayListValues().build();
       externalIncludedIn.runEach(
@@ -115,6 +134,18 @@
     }
   }
 
+  /**
+   * Returns the short names of refs which are as well in the matchingRefs list as well as in the
+   * allRef list.
+   */
+  private static ImmutableList<Ref> getMatchingRefNames(
+      Set<String> matchingRefs, Collection<Ref> allRefs) {
+    return allRefs.stream()
+        .filter(r -> matchingRefs.contains(r.getName()))
+        .distinct()
+        .collect(toImmutableList());
+  }
+
   private ImmutableSortedSet<String> sortedShortNames(Collection<String> refs) {
     return refs.stream()
         .map(Repository::shortenRefName)
diff --git a/java/com/google/gerrit/server/change/IncludedInRefs.java b/java/com/google/gerrit/server/change/IncludedInRefs.java
new file mode 100644
index 0000000..17a0c9d
--- /dev/null
+++ b/java/com/google/gerrit/server/change/IncludedInRefs.java
@@ -0,0 +1,134 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class IncludedInRefs {
+  protected final GitRepositoryManager repoManager;
+  protected final PermissionBackend permissionBackend;
+
+  @Inject
+  IncludedInRefs(GitRepositoryManager repoManager, PermissionBackend permissionBackend) {
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+  }
+
+  public Map<String, Set<String>> apply(
+      Project.NameKey project, Set<String> commits, Set<String> refNames)
+      throws IOException, PermissionBackendException {
+    try (Repository repo = repoManager.openRepository(project)) {
+      Set<Ref> visibleRefs = getVisibleRefs(repo, refNames, project);
+
+      if (!visibleRefs.isEmpty()) {
+        try (RevWalk revWalk = new RevWalk(repo)) {
+          revWalk.setRetainBody(false);
+          Set<RevCommit> revCommits = getRevCommits(commits, revWalk);
+
+          if (!revCommits.isEmpty()) {
+            return commitsIncludedIn(
+                revCommits, IncludedInUtil.getSortedRefs(visibleRefs, revWalk), revWalk);
+          }
+        }
+      }
+    }
+    return ImmutableMap.of();
+  }
+
+  private Set<Ref> getVisibleRefs(Repository repo, Set<String> refNames, Project.NameKey project)
+      throws PermissionBackendException {
+    RefDatabase refDb = repo.getRefDatabase();
+    Set<Ref> refs = new HashSet<>();
+    for (String refName : refNames) {
+      try {
+        Ref ref = refDb.exactRef(refName);
+        if (ref != null) {
+          refs.add(ref);
+        }
+      } catch (IOException e) {
+        // Ignore and continue to process rest of the refs so as to keep
+        // the behavior similar to the ref not being visible to the user.
+        // This will ensure that there is no information leak about the
+        // ref when the ref is corrupted and is not visible to the user.
+      }
+    }
+    return filterReadableRefs(project, refs, repo);
+  }
+
+  private Set<RevCommit> getRevCommits(Set<String> commits, RevWalk revWalk) throws IOException {
+    Set<RevCommit> revCommits = new HashSet<>();
+    for (String commit : commits) {
+      try {
+        revCommits.add(revWalk.parseCommit(ObjectId.fromString(commit)));
+      } catch (MissingObjectException | IncorrectObjectTypeException | IllegalArgumentException e) {
+        // Ignore and continue to process the rest of the commits so as to keep
+        // the behavior similar to the commit not being included in any of the
+        // visible specified refs. This will ensure that there is no information
+        // leak about the commit when the commit is not visible to the user.
+      }
+    }
+    return revCommits;
+  }
+
+  private Map<String, Set<String>> commitsIncludedIn(
+      Collection<RevCommit> commits, Collection<Ref> refs, RevWalk revWalk) throws IOException {
+    Map<String, Set<String>> refsByCommit = new HashMap<>();
+    for (RevCommit commit : commits) {
+      List<Ref> matchingRefs = revWalk.getMergedInto(commit, refs);
+      if (matchingRefs.size() > 0) {
+        refsByCommit.put(
+            commit.getName(), matchingRefs.stream().map(Ref::getName).collect(toSet()));
+      }
+    }
+    return refsByCommit;
+  }
+
+  /**
+   * Filter readable refs according to the caller's refs visibility.
+   *
+   * @param project specific Gerrit project.
+   * @param inputRefs a list of refs
+   * @param repo repository opened for the Gerrit project.
+   * @return set of visible refs to the caller
+   */
+  private Set<Ref> filterReadableRefs(Project.NameKey project, Set<Ref> inputRefs, Repository repo)
+      throws PermissionBackendException {
+    PermissionBackend.ForProject perm = permissionBackend.currentUser().project(project);
+    return perm.filter(inputRefs, repo, RefFilterOptions.defaults()).stream().collect(toSet());
+  }
+}
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
deleted file mode 100644
index b2b0a64..0000000
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ /dev/null
@@ -1,161 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevFlag;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/** Resolve in which tags and branches a commit is included. */
-public class IncludedInResolver {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static Result resolve(Repository repo, RevWalk rw, RevCommit commit) throws IOException {
-    RevFlag flag = newFlag(rw);
-    try {
-      return new IncludedInResolver(repo, rw, commit, flag).resolve();
-    } finally {
-      rw.disposeFlag(flag);
-    }
-  }
-
-  private static RevFlag newFlag(RevWalk rw) {
-    return rw.newFlag("CONTAINS_TARGET");
-  }
-
-  private final Repository repo;
-  private final RevWalk rw;
-  private final RevCommit target;
-
-  private final RevFlag containsTarget;
-  private ListMultimap<RevCommit, String> commitToRef;
-  private List<RevCommit> tipsByCommitTime;
-
-  private IncludedInResolver(
-      Repository repo, RevWalk rw, RevCommit target, RevFlag containsTarget) {
-    this.repo = repo;
-    this.rw = rw;
-    this.target = target;
-    this.containsTarget = containsTarget;
-  }
-
-  private Result resolve() throws IOException {
-    RefDatabase refDb = repo.getRefDatabase();
-    Collection<Ref> tags = refDb.getRefsByPrefix(Constants.R_TAGS);
-    Collection<Ref> branches = refDb.getRefsByPrefix(Constants.R_HEADS);
-    List<Ref> allTagsAndBranches = Lists.newArrayListWithCapacity(tags.size() + branches.size());
-    allTagsAndBranches.addAll(tags);
-    allTagsAndBranches.addAll(branches);
-    parseCommits(allTagsAndBranches);
-    Set<String> allMatchingTagsAndBranches = includedIn(tipsByCommitTime, 0);
-
-    return new AutoValue_IncludedInResolver_Result(
-        getMatchingRefNames(allMatchingTagsAndBranches, branches),
-        getMatchingRefNames(allMatchingTagsAndBranches, tags));
-  }
-
-  /** Resolves which tip refs include the target commit. */
-  private Set<String> includedIn(Collection<RevCommit> tips, int limit)
-      throws IOException, MissingObjectException, IncorrectObjectTypeException {
-    Set<String> result = new HashSet<>();
-    for (RevCommit tip : tips) {
-      boolean commitFound = false;
-      rw.resetRetain(RevFlag.UNINTERESTING, containsTarget);
-      rw.markStart(tip);
-      for (RevCommit commit : rw) {
-        if (commit.equals(target) || commit.has(containsTarget)) {
-          commitFound = true;
-          tip.add(containsTarget);
-          result.addAll(commitToRef.get(tip));
-          break;
-        }
-      }
-      if (!commitFound) {
-        rw.markUninteresting(tip);
-      } else if (0 < limit && limit < result.size()) {
-        break;
-      }
-    }
-    return result;
-  }
-
-  /**
-   * Returns the short names of refs which are as well in the matchingRefs list as well as in the
-   * allRef list.
-   */
-  private static ImmutableList<Ref> getMatchingRefNames(
-      Set<String> matchingRefs, Collection<Ref> allRefs) {
-    return allRefs.stream()
-        .filter(r -> matchingRefs.contains(r.getName()))
-        .distinct()
-        .collect(ImmutableList.toImmutableList());
-  }
-
-  /** Parse commit of ref and store the relation between ref and commit. */
-  private void parseCommits(Collection<Ref> refs) throws IOException {
-    if (commitToRef != null) {
-      return;
-    }
-    commitToRef = LinkedListMultimap.create();
-    for (Ref ref : refs) {
-      final RevCommit commit;
-      try {
-        commit = rw.parseCommit(ref.getObjectId());
-      } catch (IncorrectObjectTypeException notCommit) {
-        // Its OK for a tag reference to point to a blob or a tree, this
-        // is common in the Linux kernel or git.git repository.
-        //
-        continue;
-      } catch (MissingObjectException notHere) {
-        // Log the problem with this branch, but keep processing.
-        //
-        logger.atWarning().log(
-            "Reference %s in %s points to dangling object %s",
-            ref.getName(), repo.getDirectory(), ref.getObjectId());
-        continue;
-      }
-      commitToRef.put(commit, ref.getName());
-    }
-    tipsByCommitTime =
-        commitToRef.keySet().stream().sorted(comparing(RevCommit::getCommitTime)).collect(toList());
-  }
-
-  @AutoValue
-  public abstract static class Result {
-    public abstract ImmutableList<Ref> branches();
-
-    public abstract ImmutableList<Ref> tags();
-  }
-}
diff --git a/java/com/google/gerrit/server/change/IncludedInUtil.java b/java/com/google/gerrit/server/change/IncludedInUtil.java
new file mode 100644
index 0000000..6f75e0f
--- /dev/null
+++ b/java/com/google/gerrit/server/change/IncludedInUtil.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class IncludedInUtil {
+
+  /**
+   * Sorts the collection of {@code Ref} instances by its tip commit time.
+   *
+   * @param refs collection to be sorted
+   * @param revWalk {@code RevWalk} instance for parsing ref's tip commit
+   * @return sorted list of refs
+   */
+  public static List<Ref> getSortedRefs(Collection<Ref> refs, RevWalk revWalk) {
+    return refs.stream()
+        .sorted(
+            comparing(
+                ref -> {
+                  try {
+                    return revWalk.parseCommit(ref.getObjectId()).getCommitTime();
+                  } catch (IOException e) {
+                    // Ignore and continue to sort
+                  }
+                  return 0;
+                }))
+        .collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index aeb9db0..b1fcf48 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -15,13 +15,14 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
@@ -32,8 +33,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
-import java.util.List;
+import java.util.HashSet;
 import java.util.Optional;
+import java.util.Set;
 
 /**
  * Normalizes votes on labels according to project config.
@@ -42,6 +44,16 @@
  * what labels are defined for the project. The label definition can change between the time a vote
  * is originally made and a later point, for example when a change is submitted. This class
  * normalizes old votes against current project configuration.
+ *
+ * <p>Normalizing a vote means making it compliant with the current label definition:
+ *
+ * <ul>
+ *   <li>If the voting value is greater than the max allowed value according to the label
+ *       definition, the voting value is changed to the max allowed value.
+ *   <li>If the voting value is lower than the min allowed value according to the label definition,
+ *       the voting value is changed to the min allowed value.
+ *   <li>If the label definition for a vote is missing, the vote is deleted.
+ * </ul>
  */
 @Singleton
 public class LabelNormalizer {
@@ -49,23 +61,25 @@
   public abstract static class Result {
     @VisibleForTesting
     static Result create(
-        List<PatchSetApproval> unchanged,
-        List<PatchSetApproval> updated,
-        List<PatchSetApproval> deleted) {
+        Set<PatchSetApproval> unchanged,
+        Set<PatchSetApproval> updated,
+        Set<PatchSetApproval> deleted) {
       return new AutoValue_LabelNormalizer_Result(
-          ImmutableList.copyOf(unchanged),
-          ImmutableList.copyOf(updated),
-          ImmutableList.copyOf(deleted));
+          ImmutableSet.copyOf(unchanged),
+          ImmutableSet.copyOf(updated),
+          ImmutableSet.copyOf(deleted));
     }
 
-    public abstract ImmutableList<PatchSetApproval> unchanged();
+    public abstract ImmutableSet<PatchSetApproval> unchanged();
 
-    public abstract ImmutableList<PatchSetApproval> updated();
+    public abstract ImmutableSet<PatchSetApproval> updated();
 
-    public abstract ImmutableList<PatchSetApproval> deleted();
+    public abstract ImmutableSet<PatchSetApproval> deleted();
 
-    public Iterable<PatchSetApproval> getNormalized() {
-      return Iterables.concat(unchanged(), updated());
+    public ImmutableSet<PatchSetApproval> getNormalized() {
+      return Streams.concat(unchanged().stream(), updated().stream())
+          .distinct()
+          .collect(toImmutableSet());
     }
   }
 
@@ -84,9 +98,9 @@
    * @param approvals list of approvals.
    */
   public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals) {
-    List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
-    List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
+    Set<PatchSetApproval> unchanged = new HashSet<>(approvals.size());
+    Set<PatchSetApproval> updated = new HashSet<>(approvals.size());
+    Set<PatchSetApproval> deleted = new HashSet<>(approvals.size());
     LabelTypes labelTypes =
         projectCache
             .get(notes.getProjectName())
@@ -118,6 +132,20 @@
     return Result.create(unchanged, updated, deleted);
   }
 
+  /**
+   * Returns a copy of the given approval normalized to the defined ranges for the label type. If
+   * the approval is for an unknown label {@link Optional#empty()} is returned
+   *
+   * @param notes change notes containing the given approval
+   * @param approval approval that should be normalized
+   */
+  public Optional<PatchSetApproval> normalize(ChangeNotes notes, PatchSetApproval approval) {
+    Result result = normalize(notes, ImmutableSet.of(approval));
+    return Optional.ofNullable(
+        Iterables.getFirst(
+            result.unchanged(), Iterables.getFirst(result.updated(), /* defaultValue= */ null)));
+  }
+
   private PatchSetApproval applyTypeFloor(LabelType lt, PatchSetApproval a) {
     PatchSetApproval.Builder b = a.toBuilder();
     LabelValue atMin = lt.getMin();
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 5ce121b..5555ba6 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -36,19 +36,23 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.extensions.common.AccountInfo;
 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.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLoader;
 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.project.DeleteVoteControl;
+import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -69,10 +73,17 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
+  private final DeleteVoteControl deleteVoteControl;
+  private final RemoveReviewerControl removeReviewerControl;
 
   @Inject
-  LabelsJson(PermissionBackend permissionBackend) {
+  LabelsJson(
+      PermissionBackend permissionBackend,
+      DeleteVoteControl deleteVoteControl,
+      RemoveReviewerControl removeReviewerControl) {
     this.permissionBackend = permissionBackend;
+    this.deleteVoteControl = deleteVoteControl;
+    this.removeReviewerControl = removeReviewerControl;
   }
 
   /**
@@ -80,6 +91,7 @@
    * lazily populate accounts. Callers have to call {@link AccountLoader#fill()} afterwards to
    * populate all accounts in the returned {@link LabelInfo}s.
    */
+  @Nullable
   Map<String, LabelInfo> labelsFor(
       AccountLoader accountLoader, ChangeData cd, boolean standard, boolean detailed)
       throws PermissionBackendException {
@@ -95,53 +107,84 @@
     return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
   }
 
-  /** Returns all labels that the provided user has permission to vote on. */
+  /**
+   * Returns A map of all label names and the values that the provided user has permission to vote
+   * on.
+   *
+   * @param filterApprovalsBy a Gerrit user ID.
+   * @param cd {@link ChangeData} corresponding to a specific gerrit change.
+   * @return A Map where the key contain a label name, and the value is a list of the permissible
+   *     vote values that the user can vote on.
+   */
   Map<String, Collection<String>> permittedLabels(Account.Id filterApprovalsBy, ChangeData cd)
       throws PermissionBackendException {
-    boolean isMerged = cd.change().isMerged();
-    LabelTypes labelTypes = cd.getLabelTypes();
-    Map<String, LabelType> toCheck = new HashMap<>();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels != null) {
-        for (SubmitRecord.Label r : rec.labels) {
-          Optional<LabelType> type = labelTypes.byLabel(r.label);
-          if (type.isPresent() && (!isMerged || type.get().isAllowPostSubmit())) {
-            toCheck.put(type.get().getName(), type.get());
-          }
-        }
-      }
-    }
-
-    Map<String, Short> labels = null;
-    Set<LabelPermission.WithValue> can =
-        permissionBackend.absentUser(filterApprovalsBy).change(cd).testLabels(toCheck.values());
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
-    for (SubmitRecord rec : submitRecords(cd)) {
-      if (rec.labels == null) {
+    boolean isMerged = cd.change().isMerged();
+    Map<String, Short> currentUserVotes = currentLabels(filterApprovalsBy, cd);
+    for (LabelType labelType : cd.getLabelTypes().getLabelTypes()) {
+      if (isMerged && !labelType.isAllowPostSubmit()) {
         continue;
       }
-      for (SubmitRecord.Label r : rec.labels) {
-        Optional<LabelType> type = labelTypes.byLabel(r.label);
-        if (!type.isPresent() || (isMerged && !type.get().isAllowPostSubmit())) {
-          continue;
+      Set<LabelPermission.WithValue> can =
+          permissionBackend.absentUser(filterApprovalsBy).change(cd).test(labelType);
+      for (LabelValue v : labelType.getValues()) {
+        boolean ok = can.contains(new LabelPermission.WithValue(labelType, v));
+        if (isMerged) {
+          // Votes cannot be decreased if the change is merged. Only accept the label value if it's
+          // greater or equal than the user's latest vote.
+          short prev = currentUserVotes.getOrDefault(labelType.getName(), (short) 0);
+          ok &= v.getValue() >= prev;
         }
-
-        for (LabelValue v : type.get().getValues()) {
-          boolean ok = can.contains(new LabelPermission.WithValue(type.get(), v));
-          if (isMerged) {
-            if (labels == null) {
-              labels = currentLabels(filterApprovalsBy, cd);
-            }
-            short prev = labels.getOrDefault(type.get().getName(), (short) 0);
-            ok &= v.getValue() >= prev;
-          }
-          if (ok) {
-            permitted.put(r.label, v.formatValue());
-          }
+        if (ok) {
+          permitted.put(labelType.getName(), v.formatValue());
         }
       }
     }
+    clearOnlyZerosEntries(permitted);
+    return permitted.asMap();
+  }
 
+  /**
+   * Returns A map of all labels that the provided user has permission to remove.
+   *
+   * @param accountLoader to load the reviewers' data with.
+   * @param user a Gerrit user.
+   * @param cd {@link ChangeData} corresponding to a specific gerrit change.
+   * @return A Map of {@code labelName} -> {Map of {@code value} -> List of {@link AccountInfo}}
+   *     that the user can remove votes from.
+   */
+  Map<String, Map<String, List<AccountInfo>>> removableLabels(
+      AccountLoader accountLoader, CurrentUser user, ChangeData cd)
+      throws PermissionBackendException {
+    if (cd.change().isMerged()) {
+      return new HashMap<>();
+    }
+
+    Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>();
+    LabelTypes labelTypes = cd.getLabelTypes();
+    for (PatchSetApproval approval : cd.currentApprovals()) {
+      Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId());
+      if (!labelType.isPresent()) {
+        continue;
+      }
+      if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get())
+          || removeReviewerControl.testRemoveReviewer(
+              cd, user, approval.accountId(), approval.value()))) {
+        continue;
+      }
+      if (!res.containsKey(approval.label())) {
+        res.put(approval.label(), new HashMap<>());
+      }
+      String labelValue = LabelValue.formatValue(approval.value());
+      if (!res.get(approval.label()).containsKey(labelValue)) {
+        res.get(approval.label()).put(labelValue, new ArrayList<>());
+      }
+      res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId()));
+    }
+    return res;
+  }
+
+  private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) {
     List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
     for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
       if (isOnlyZero(e.getValue())) {
@@ -151,7 +194,6 @@
     for (String label : toClear) {
       permitted.removeAll(label);
     }
-    return permitted.asMap();
   }
 
   private static boolean isOnlyZero(Collection<String> values) {
@@ -173,9 +215,8 @@
       boolean detailed)
       throws PermissionBackendException {
     Map<String, LabelWithStatus> labels = initLabels(accountLoader, cd, labelTypes, standard);
-    if (detailed) {
-      setAllApprovals(accountLoader, cd, labels);
-    }
+    setAllApprovals(accountLoader, cd, labels, detailed);
+
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
       Optional<LabelType> type = labelTypes.byLabel(e.getKey());
       if (!type.isPresent()) {
@@ -190,9 +231,7 @@
           }
         }
       }
-      if (detailed) {
-        setLabelValues(type.get(), e.getValue());
-      }
+      setLabelValues(type.get(), e.getValue());
     }
     return labels;
   }
@@ -209,10 +248,10 @@
   private ApprovalInfo approvalInfo(
       AccountLoader accountLoader,
       Account.Id id,
-      Integer value,
-      VotingRangeInfo permittedVotingRange,
-      String tag,
-      Timestamp date) {
+      @Nullable Integer value,
+      @Nullable VotingRangeInfo permittedVotingRange,
+      @Nullable String tag,
+      @Nullable Instant date) {
     ApprovalInfo ai = new ApprovalInfo(id.get(), value, permittedVotingRange, tag, date);
     accountLoader.put(ai);
     return ai;
@@ -229,10 +268,10 @@
     }
   }
 
-  private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
+  private Map<String, Short> currentLabels(@Nullable Account.Id accountId, ChangeData cd) {
     Map<String, Short> result = new HashMap<>();
     for (PatchSetApproval psa : cd.currentApprovals()) {
-      if (psa.accountId().equals(accountId)) {
+      if (accountId == null || psa.accountId().equals(accountId)) {
         result.put(psa.label(), psa.value());
       }
     }
@@ -291,22 +330,20 @@
       }
     }
 
-    if (detailed) {
-      labels.entrySet().stream()
-          .filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
-          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue()));
-    }
+    labels.entrySet().stream()
+        .filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
+        .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue()));
 
     for (Account.Id accountId : allUsers) {
       Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
       Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
       if (detailed) {
         pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
-        for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
-          ApprovalInfo ai = approvalInfo(accountLoader, accountId, 0, null, null, null);
-          byLabel.put(entry.getKey(), ai);
-          addApproval(entry.getValue().label(), ai);
-        }
+      }
+      for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
+        ApprovalInfo ai = approvalInfo(accountLoader, accountId, 0, null, null, null);
+        byLabel.put(entry.getKey(), ai);
+        addApproval(entry.getValue().label(), ai);
       }
       for (PatchSetApproval psa : current.get(accountId)) {
         Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
@@ -319,7 +356,7 @@
         if (info != null) {
           info.value = Integer.valueOf(val);
           info.permittedVotingRange = pvr.getOrDefault(type.get().getName(), null);
-          info.date = psa.granted();
+          info.setDate(psa.granted());
           info.tag = psa.tag().orElse(null);
           if (psa.postSubmit()) {
             info.postSubmit = true;
@@ -368,9 +405,23 @@
         }
       }
     }
+    setLabelsDescription(labels, labelTypes);
     return labels;
   }
 
+  private void setLabelsDescription(
+      Map<String, LabelsJson.LabelWithStatus> labels, LabelTypes labelTypes) {
+    for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
+      String labelName = entry.getKey();
+      Optional<LabelType> type = labelTypes.byLabel(labelName);
+      if (!type.isPresent()) {
+        continue;
+      }
+      LabelWithStatus labelWithStatus = entry.getValue();
+      labelWithStatus.label().description = type.get().getDescription().orElse(null);
+    }
+  }
+
   private void setLabelScores(
       AccountLoader accountLoader,
       LabelType type,
@@ -402,7 +453,10 @@
   }
 
   private void setAllApprovals(
-      AccountLoader accountLoader, ChangeData cd, Map<String, LabelWithStatus> labels)
+      AccountLoader accountLoader,
+      ChangeData cd,
+      Map<String, LabelWithStatus> labels,
+      boolean detailed)
       throws PermissionBackendException {
     checkState(
         !cd.change().isMerged(),
@@ -426,8 +480,12 @@
 
     LabelTypes labelTypes = cd.getLabelTypes();
     for (Account.Id accountId : allUsers) {
-      PermissionBackend.ForChange perm = permissionBackend.absentUser(accountId).change(cd);
-      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+      Map<String, VotingRangeInfo> pvr = null;
+      PermissionBackend.ForChange perm = null;
+      if (detailed) {
+        perm = permissionBackend.absentUser(accountId).change(cd);
+        pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
+      }
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
         Optional<LabelType> lt = labelTypes.byLabel(e.getKey());
         if (!lt.isPresent()) {
@@ -436,9 +494,10 @@
           continue;
         }
         Integer value;
-        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.get().getName(), null);
+        VotingRangeInfo permittedVotingRange =
+            pvr == null ? null : pvr.getOrDefault(lt.get().getName(), null);
         String tag = null;
-        Timestamp date = null;
+        Instant date = null;
         PatchSetApproval psa = current.get(accountId, lt.get().getName());
         if (psa != null) {
           value = Integer.valueOf(psa.value());
@@ -446,7 +505,7 @@
             // This may be a dummy approval that was inserted when the reviewer
             // was added. Explicitly check whether the user can vote on this
             // label.
-            value = perm.test(new LabelPermission(lt.get())) ? 0 : null;
+            value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null;
           }
           tag = psa.tag().orElse(null);
           date = psa.granted();
@@ -457,7 +516,7 @@
           // Either the user cannot vote on this label, or they were added as a
           // reviewer but have not responded yet. Explicitly check whether the
           // user can vote on this label.
-          value = perm.test(new LabelPermission(lt.get())) ? 0 : null;
+          value = perm != null && perm.test(new LabelPermission(lt.get())) ? 0 : null;
         }
         addApproval(
             e.getValue().label(),
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 209901d..cff3de2 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -15,17 +15,20 @@
 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.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 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.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -33,14 +36,13 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-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;
@@ -65,8 +67,6 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class PatchSetInserter implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     PatchSetInserter create(ChangeNotes notes, PatchSet.Id psId, ObjectId commitId);
   }
@@ -74,15 +74,15 @@
   // Injected fields.
   private final PermissionBackend permissionBackend;
   private final PatchSetInfoFactory patchSetInfoFactory;
+  private final ChangeKindCache changeKindCache;
   private final CommitValidators.Factory commitValidatorsFactory;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
   private final ProjectCache projectCache;
   private final RevisionCreated revisionCreated;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
-  private final MessageIdGenerator messageIdGenerator;
   private final AutoMerger autoMerger;
 
   // Assisted-injected fields.
@@ -100,6 +100,7 @@
   private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private List<String> groups = Collections.emptyList();
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
   private boolean fireRevisionCreated = true;
   private boolean allowClosed;
   private boolean sendEmail = true;
@@ -110,9 +111,12 @@
   private Change change;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
+  private ChangeKind changeKind;
   private String mailMessage;
   private ReviewerSet oldReviewers;
   private boolean oldWorkInProgressState;
+  private ApprovalCopier.Result approvalCopierResult;
+  private ObjectId preUpdateMetaId;
 
   @Inject
   public PatchSetInserter(
@@ -120,13 +124,13 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       PatchSetInfoFactory patchSetInfoFactory,
+      ChangeKindCache changeKindCache,
       CommitValidators.Factory commitValidatorsFactory,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      EmailNewPatchSet.Factory emailNewPatchSetFactory,
       PatchSetUtil psUtil,
       RevisionCreated revisionCreated,
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
-      MessageIdGenerator messageIdGenerator,
       AutoMerger autoMerger,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
@@ -135,13 +139,13 @@
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
     this.patchSetInfoFactory = patchSetInfoFactory;
+    this.changeKindCache = changeKindCache;
     this.commitValidatorsFactory = commitValidatorsFactory;
-    this.replacePatchSetFactory = replacePatchSetFactory;
+    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.psUtil = psUtil;
     this.revisionCreated = revisionCreated;
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
-    this.messageIdGenerator = messageIdGenerator;
     this.autoMerger = autoMerger;
 
     this.origNotes = notes;
@@ -184,6 +188,13 @@
     return this;
   }
 
+  public PatchSetInserter setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   public PatchSetInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
@@ -230,6 +241,15 @@
       throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
     validate(ctx);
     ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
+
+    changeKind =
+        changeKindCache.getChangeKind(
+            ctx.getProject(),
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig(),
+            psUtil.current(origNotes).commitId(),
+            commitId);
+
     Optional<ReceiveCommand> autoMerge =
         autoMerger.createAutoMergeCommitIfNecessary(
             ctx.getRepoView(),
@@ -244,6 +264,7 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, IOException, BadRequestException {
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(psId);
     update.setSubjectForCommit("Create patch set " + psId.get());
@@ -270,12 +291,6 @@
       oldReviewers = approvalsUtil.getReviewers(ctx.getNotes());
     }
 
-    if (message != null) {
-      mailMessage =
-          cmUtil.setChangeMessage(
-              update, message, ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
-    }
-
     oldWorkInProgressState = change.isWorkInProgress();
     if (workInProgress != null) {
       change.setWorkInProgress(workInProgress);
@@ -298,36 +313,69 @@
       }
     }
 
-    // Approvals that are being set in the new patch-set during this operation are not available yet
-    // outside of the scope of this method. Only copied approvals are set here.
     if (storeCopiedVotes) {
-      approvalsUtil.byPatchSet(ctx.getNotes(), patchSet).forEach(a -> update.putCopiedApproval(a));
+      approvalCopierResult =
+          approvalsUtil.copyApprovalsToNewPatchSet(
+              ctx.getNotes(), patchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
     }
 
+    mailMessage = insertChangeMessage(update, ctx);
+
     return true;
   }
 
+  @Nullable
+  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx) {
+    StringBuilder messageBuilder = new StringBuilder();
+    if (message != null) {
+      messageBuilder.append(message);
+    }
+
+    if (approvalCopierResult != null) {
+      approvalsUtil
+          .formatApprovalCopierResult(
+              approvalCopierResult,
+              projectCache
+                  .get(ctx.getProject())
+                  .orElseThrow(illegalState(ctx.getProject()))
+                  .getLabelTypes())
+          .ifPresent(
+              msg -> {
+                if (message != null && !message.endsWith("\n")) {
+                  messageBuilder.append("\n");
+                }
+                messageBuilder.append("\n").append(msg);
+              });
+    }
+
+    String changeMessage = messageBuilder.toString();
+    if (changeMessage.isEmpty()) {
+      return null;
+    }
+
+    return cmUtil.setChangeMessage(
+        update,
+        messageBuilder.toString(),
+        ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
+  }
+
   @Override
   public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
-    if (notify.shouldNotify() && sendEmail) {
-      requireNonNull(mailMessage);
-      try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setPatchSet(patchSet, patchSetInfo);
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.addReviewers(oldReviewers.byState(REVIEWER));
-        emailSender.addExtraCC(oldReviewers.byState(CC));
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-        emailSender.send();
-      } catch (Exception err) {
-        logger.atSevere().withCause(err).log(
-            "Cannot send email for new patch set on change %s", change.getId());
-      }
+    if (sendEmail) {
+      emailNewPatchSetFactory
+          .create(
+              ctx,
+              patchSet,
+              mailMessage,
+              approvalCopierResult.outdatedApprovals().stream()
+                  .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+                  .collect(toImmutableSet()),
+              oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(REVIEWER),
+              oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(CC),
+              changeKind,
+              preUpdateMetaId)
+          .sendAsync();
     }
 
     if (fireRevisionCreated) {
@@ -368,7 +416,7 @@
                 .orElseThrow(illegalState(origNotes.getProjectName()))
                 .getProject(),
             origNotes.getChange().getDest().branch(),
-            ImmutableListMultimap.of(),
+            validationOptions,
             ctx.getRepoView().getConfig(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 3e67cca..49ec812 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -17,10 +17,14 @@
 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 static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -28,11 +32,14 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.PatchSetUtil;
 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.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
@@ -43,8 +50,8 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
-import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -70,19 +77,24 @@
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
     RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
+
+    RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, Change.Id baseChangeId);
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeNotes.Factory notesFactory;
 
   private final ChangeNotes notes;
   private final PatchSet originalPatchSet;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final ProjectCache projectCache;
+  private final Project.NameKey projectName;
 
   private ObjectId baseCommitId;
+  private Change.Id baseChangeId;
   private PersonIdent committerIdent;
   private boolean fireRevisionCreated = true;
   private boolean validate = true;
@@ -94,32 +106,85 @@
   private boolean sendEmail = true;
   private boolean storeCopiedVotes = true;
   private boolean matchAuthorToCommitterDate = false;
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
 
   private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
 
-  @Inject
+  @AssistedInject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       RebaseUtil rebaseUtil,
       ChangeResource.Factory changeResourceFactory,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
       ProjectCache projectCache,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet originalPatchSet,
       @Assisted ObjectId baseCommitId) {
+    this(
+        patchSetInserterFactory,
+        mergeUtilFactory,
+        rebaseUtil,
+        changeResourceFactory,
+        notesFactory,
+        identifiedUserFactory,
+        projectCache,
+        notes,
+        originalPatchSet);
+    this.baseCommitId = baseCommitId;
+    this.baseChangeId = null;
+  }
+
+  @AssistedInject
+  RebaseChangeOp(
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtilFactory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
+      @Assisted PatchSet originalPatchSet,
+      @Assisted Change.Id baseChangeId) {
+    this(
+        patchSetInserterFactory,
+        mergeUtilFactory,
+        rebaseUtil,
+        changeResourceFactory,
+        notesFactory,
+        identifiedUserFactory,
+        projectCache,
+        notes,
+        originalPatchSet);
+    this.baseChangeId = baseChangeId;
+    this.baseCommitId = null;
+  }
+
+  private RebaseChangeOp(
+      PatchSetInserter.Factory patchSetInserterFactory,
+      MergeUtilFactory mergeUtilFactory,
+      RebaseUtil rebaseUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeNotes.Factory notesFactory,
+      GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      ChangeNotes notes,
+      PatchSet originalPatchSet) {
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.rebaseUtil = rebaseUtil;
     this.changeResourceFactory = changeResourceFactory;
+    this.notesFactory = notesFactory;
     this.identifiedUserFactory = identifiedUserFactory;
     this.projectCache = projectCache;
     this.notes = notes;
+    this.projectName = notes.getProjectName();
     this.originalPatchSet = originalPatchSet;
-    this.baseCommitId = baseCommitId;
   }
 
   public RebaseChangeOp setCommitterIdent(PersonIdent committerIdent) {
@@ -191,16 +256,32 @@
     return this;
   }
 
+  public RebaseChangeOp setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    requireNonNull(validationOptions, "validationOptions may not be null");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx)
-      throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
-          NoSuchChangeException, PermissionBackendException {
+      throws InvalidChangeOperationException, RestApiException, IOException, NoSuchChangeException,
+          PermissionBackendException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
     RevWalk rw = ctx.getRevWalk();
     RevCommit original = rw.parseCommit(originalPatchSet.commitId());
     rw.parseBody(original);
-    RevCommit baseCommit = rw.parseCommit(baseCommitId);
+    RevCommit baseCommit;
+    if (baseCommitId != null && baseChangeId == null) {
+      baseCommit = rw.parseCommit(baseCommitId);
+    } else if (baseChangeId != null) {
+      baseCommit =
+          PatchSetUtil.getCurrentRevCommitIncludingPending(ctx, notesFactory, baseChangeId);
+    } else {
+      throw new IllegalStateException(
+          "Exactly one of base commit and base change must be provided.");
+    }
     CurrentUser changeOwner = identifiedUserFactory.create(notes.getChange().getOwner());
 
     String newCommitMessage;
@@ -213,12 +294,12 @@
       newCommitMessage = original.getFullMessage();
     }
 
-    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
+    rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage, notes.getChangeId());
     Base base =
         rebaseUtil.parseBase(
             new RevisionResource(
                 changeResourceFactory.create(notes, changeOwner), originalPatchSet),
-            baseCommitId.name());
+            baseCommit.getName());
 
     rebasedPatchSetId =
         ChangeUtil.nextPatchSetIdFromChangeRefs(
@@ -241,6 +322,8 @@
       patchSetInserter.setWorkInProgress(true);
     }
 
+    patchSetInserter.setValidationOptions(validationOptions);
+
     if (postMessage) {
       patchSetInserter.setMessage(
           messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
@@ -307,8 +390,7 @@
   }
 
   private MergeUtil newMergeUtil() {
-    ProjectState project =
-        projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
+    ProjectState project = projectCache.get(projectName).orElseThrow(illegalState(projectName));
     return forceContentMerge
         ? mergeUtilFactory.create(project, true)
         : mergeUtilFactory.create(project);
@@ -325,7 +407,11 @@
    * @throws IOException the merge failed for another reason.
    */
   private CodeReviewCommit rebaseCommit(
-      RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
+      RepoContext ctx,
+      RevCommit original,
+      ObjectId base,
+      String commitMessage,
+      Change.Id originalChangeId)
       throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
 
@@ -359,8 +445,9 @@
 
       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));
+            String.format(
+                "Change %s could not be rebased due to a conflict during merge.\n\n%s",
+                originalChangeId.toString(), MergeUtil.createConflictMessage(conflicts)));
       }
 
       Map<String, MergeResult<? extends Sequence>> mergeResults =
@@ -392,7 +479,7 @@
     if (committerIdent != null) {
       cb.setCommitter(committerIdent);
     } else {
-      cb.setCommitter(ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone()));
+      cb.setCommitter(ctx.newCommitterIdent());
     }
     if (matchAuthorToCommitterDate) {
       cb.setAuthor(
@@ -400,7 +487,6 @@
               cb.getAuthor(), cb.getCommitter().getWhen(), cb.getCommitter().getTimeZone()));
     }
     ObjectId objectId = ctx.getInserter().insert(cb);
-    ctx.getInserter().flush();
     CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
     commit.setFilesWithGitConflicts(filesWithGitConflicts);
     return commit;
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 2d36df2..8acc925 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -17,16 +17,24 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.restapi.AuthException;
 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.git.ObjectIds;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 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.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
@@ -45,20 +53,65 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final PatchSetUtil psUtil;
+  private final RebaseChangeOp.Factory rebaseFactory;
 
   @Inject
   RebaseUtil(
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      RebaseChangeOp.Factory rebaseFactory) {
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.psUtil = psUtil;
+    this.rebaseFactory = rebaseFactory;
+  }
+
+  /**
+   * Checks whether the given change fulfills all preconditions to be rebased.
+   *
+   * <p>This method does not check whether the calling user is allowed to rebase the change.
+   */
+  public void verifyRebasePreconditions(RevWalk rw, ChangeNotes changeNotes, PatchSet patchSet)
+      throws ResourceConflictException, IOException {
+    // Not allowed to rebase if the current patch set is locked.
+    psUtil.checkPatchSetNotLocked(changeNotes);
+
+    Change change = changeNotes.getChange();
+    if (!change.isNew()) {
+      throw new ResourceConflictException(
+          String.format("Change %s is %s", change.getId(), ChangeUtil.status(change)));
+    }
+
+    if (!hasOneParent(rw, patchSet)) {
+      throw new ResourceConflictException(
+          String.format(
+              "Error rebasing %s. Cannot rebase %s",
+              change.getId(),
+              countParents(rw, patchSet) > 1 ? "merge commits" : "commit with no ancestor"));
+    }
+  }
+
+  public static boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
+    // Prevent rebase of exotic changes (merge commit, no ancestor).
+    return countParents(rw, ps) == 1;
+  }
+
+  private static int countParents(RevWalk rw, PatchSet ps) throws IOException {
+    RevCommit c = rw.parseCommit(ps.commitId());
+    return c.getParentCount();
+  }
+
+  private static boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
+    ObjectId baseId = base.commitId();
+    ObjectId tipId = tip.commitId();
+    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
   }
 
   public boolean canRebase(PatchSet patchSet, BranchNameKey dest, Repository git, RevWalk rw) {
     try {
-      findBaseRevision(patchSet, dest, git, rw);
+      @SuppressWarnings("unused")
+      ObjectId base = findBaseRevision(patchSet, dest, git, rw, true);
       return true;
     } catch (RestApiException e) {
       return false;
@@ -71,6 +124,7 @@
 
   @AutoValue
   public abstract static class Base {
+    @Nullable
     private static Base create(ChangeNotes notes, PatchSet ps) {
       if (notes == null) {
         return null;
@@ -127,6 +181,100 @@
   }
 
   /**
+   * Parse or find the commit onto which a patch set should be rebased.
+   *
+   * <p>If a {@code rebaseInput.base} is provided, parse it. Otherwise, finds the latest patch set
+   * of the change corresponding to this commit's parent, or the destination branch tip in the case
+   * where the parent's change is merged.
+   *
+   * @param git the repository.
+   * @param rw the RevWalk.
+   * @param permissionBackend to check base reading permissions with.
+   * @param rsrc to find the base for
+   * @param rebaseInput to optionally parse the base from.
+   * @param verifyNeedsRebase whether to verify if the change base is not already up to date
+   * @return the commit onto which the patch set should be rebased.
+   * @throws RestApiException if rebase is not possible.
+   * @throws IOException if accessing the repository fails.
+   * @throws PermissionBackendException if the user don't have permissions to read the base change.
+   */
+  public ObjectId parseOrFindBaseRevision(
+      Repository git,
+      RevWalk rw,
+      PermissionBackend permissionBackend,
+      RevisionResource rsrc,
+      RebaseInput rebaseInput,
+      boolean verifyNeedsRebase)
+      throws RestApiException, IOException, PermissionBackendException {
+    Change change = rsrc.getChange();
+
+    if (rebaseInput == null || rebaseInput.base == null) {
+      return findBaseRevision(rsrc.getPatchSet(), change.getDest(), git, rw, verifyNeedsRebase);
+    }
+
+    String inputBase = rebaseInput.base.trim();
+
+    if (inputBase.isEmpty()) {
+      return getDestRefTip(git, change.getDest());
+    }
+
+    Base base;
+    try {
+      base = parseBase(rsrc, inputBase);
+    } catch (NoSuchChangeException e) {
+      throw new UnprocessableEntityException(
+          String.format("Base change not found: %s", inputBase), e);
+    }
+    if (base == null) {
+      throw new ResourceConflictException(
+          "base revision is missing from the destination branch: " + inputBase);
+    }
+    return getLatestRevisionForBaseChange(rw, permissionBackend, rsrc, base);
+  }
+
+  private ObjectId getDestRefTip(Repository git, BranchNameKey destRefKey)
+      throws ResourceConflictException, IOException {
+    // Remove existing dependency to other patch set.
+    Ref destRef = git.exactRef(destRefKey.branch());
+    if (destRef == null) {
+      throw new ResourceConflictException(
+          "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
+    }
+    return destRef.getObjectId();
+  }
+
+  private ObjectId getLatestRevisionForBaseChange(
+      RevWalk rw, PermissionBackend permissionBackend, RevisionResource childRsrc, Base base)
+      throws ResourceConflictException, AuthException, PermissionBackendException, IOException {
+
+    Change child = childRsrc.getChange();
+    PatchSet.Id baseId = base.patchSet().id();
+    if (child.getId().equals(baseId.changeId())) {
+      throw new ResourceConflictException(
+          String.format("cannot rebase change %s onto itself", childRsrc.getChange().getId()));
+    }
+
+    permissionBackend.user(childRsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
+
+    Change baseChange = base.notes().getChange();
+    if (!baseChange.getProject().equals(child.getProject())) {
+      throw new ResourceConflictException(
+          "base change is in wrong project: " + baseChange.getProject());
+    } else if (!baseChange.getDest().equals(child.getDest())) {
+      throw new ResourceConflictException(
+          "base change is targeting wrong branch: " + baseChange.getDest());
+    } else if (baseChange.isAbandoned()) {
+      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
+    } else if (isMergedInto(rw, childRsrc.getPatchSet(), base.patchSet())) {
+      throw new ResourceConflictException(
+          "base change "
+              + baseChange.getKey()
+              + " is a descendant of the current change - recursion not allowed");
+    }
+    return base.patchSet().commitId();
+  }
+
+  /**
    * Find the commit onto which a patch set should be rebased.
    *
    * <p>This is defined as the latest patch set of the change corresponding to this commit's parent,
@@ -136,12 +284,17 @@
    * @param destBranch the destination branch.
    * @param git the repository.
    * @param rw the RevWalk.
+   * @param verifyNeedsRebase whether to verify if the change base is not already up to date
    * @return the commit onto which the patch set should be rebased.
    * @throws RestApiException if rebase is not possible.
    * @throws IOException if accessing the repository fails.
    */
   public ObjectId findBaseRevision(
-      PatchSet patchSet, BranchNameKey destBranch, Repository git, RevWalk rw)
+      PatchSet patchSet,
+      BranchNameKey destBranch,
+      Repository git,
+      RevWalk rw,
+      boolean verifyNeedsRebase)
       throws RestApiException, IOException {
     ObjectId baseId = null;
     RevCommit commit = rw.parseCommit(patchSet.commitId());
@@ -168,7 +321,7 @@
         }
 
         if (depChange.isNew()) {
-          if (depPatchSet.id().equals(depChange.currentPatchSetId())) {
+          if (verifyNeedsRebase && depPatchSet.id().equals(depChange.currentPatchSetId())) {
             throw new ResourceConflictException(
                 "Change is already based on the latest patch set of the dependent change.");
           }
@@ -187,10 +340,29 @@
             "The destination branch does not exist: " + destBranch.branch());
       }
       baseId = destRef.getObjectId();
-      if (baseId.equals(parentId)) {
+      if (verifyNeedsRebase && baseId.equals(parentId)) {
         throw new ResourceConflictException("Change is already up to date.");
       }
     }
     return baseId;
   }
+
+  public RebaseChangeOp getRebaseOp(RevisionResource revRsrc, RebaseInput input, ObjectId baseRev) {
+    return applyRebaseInputToOp(
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseRev), input);
+  }
+
+  public RebaseChangeOp getRebaseOp(
+      RevisionResource revRsrc, RebaseInput input, Change.Id baseChange) {
+    return applyRebaseInputToOp(
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseChange), input);
+  }
+
+  private RebaseChangeOp applyRebaseInputToOp(RebaseChangeOp op, RebaseInput input) {
+    return op.setForceContentMerge(true)
+        .setAllowConflicts(input.allowConflicts)
+        .setValidationOptions(
+            ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions))
+        .setFireRevisionCreated(true);
+  }
 }
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 547452e..f4b1a83c 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -28,8 +28,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -77,16 +75,7 @@
     checkArgument(!in.isEmpty(), "Input may not be empty");
     // Map of all patch sets, keyed by commit SHA-1.
     Map<ObjectId, PatchSetData> byId = collectById(in);
-    PatchSetData start = byId.get(startPs.commitId());
-    requireNonNull(
-        start,
-        () ->
-            String.format(
-                "commit %s of patch set %s not found in %s",
-                startPs.commitId().name(),
-                startPs.id(),
-                byId.entrySet().stream()
-                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+    PatchSetData start = getCheckedPatchSetData(byId, startPs);
 
     // Map of patch set -> immediate parent.
     ListMultimap<PatchSetData, PatchSetData> parents =
@@ -122,6 +111,34 @@
     return result;
   }
 
+  public List<PatchSetData> sortAncestors(List<ChangeData> in, PatchSet startPs)
+      throws IOException, PermissionBackendException {
+    checkArgument(!in.isEmpty(), "Input may not be empty");
+    // Map of all patch sets, keyed by commit SHA-1.
+    Map<ObjectId, PatchSetData> byId = collectById(in);
+    PatchSetData start = getCheckedPatchSetData(byId, startPs);
+
+    // Map of patch set -> immediate parent.
+    ListMultimap<PatchSetData, PatchSetData> parents =
+        MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
+
+    for (ChangeData cd : in) {
+      for (PatchSet ps : cd.patchSets()) {
+        PatchSetData thisPsd = requireNonNull(byId.get(ps.commitId()));
+
+        for (RevCommit p : thisPsd.commit().getParents()) {
+          PatchSetData parentPsd = byId.get(p);
+          if (parentPsd != null) {
+            parents.put(thisPsd, parentPsd);
+          }
+        }
+      }
+    }
+
+    Collection<PatchSetData> ancestors = walkAncestors(parents, start);
+    return List.copyOf(ancestors);
+  }
+
   private Map<ObjectId, PatchSetData> collectById(List<ChangeData> in) throws IOException {
     Project.NameKey project = in.get(0).change().getProject();
     Map<ObjectId, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
@@ -145,6 +162,19 @@
     return result;
   }
 
+  private PatchSetData getCheckedPatchSetData(Map<ObjectId, PatchSetData> byId, PatchSet ps) {
+    PatchSetData psData = byId.get(ps.commitId());
+    return requireNonNull(
+        psData,
+        () ->
+            String.format(
+                "commit %s of patch set %s not found in %s",
+                ps.commitId().name(),
+                ps.id(),
+                byId.entrySet().stream()
+                    .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+  }
+
   private Collection<PatchSetData> walkAncestors(
       ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
       throws PermissionBackendException {
@@ -196,7 +226,7 @@
       List<PatchSetData> start)
       throws PermissionBackendException {
     if (start.isEmpty()) {
-      return ImmutableList.of();
+      return new ArrayList<>();
     }
     Map<Change.Id, PatchSet.Id> maxPatchSetIds = new HashMap<>();
     Set<PatchSetData> seen = new HashSet<>();
@@ -238,12 +268,8 @@
 
   private boolean isVisible(PatchSetData psd) throws PermissionBackendException {
     PermissionBackend.WithUser perm = permissionBackend.currentUser();
-    try {
-      perm.change(psd.data()).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      return false;
-    }
-    return projectCache.get(psd.data().project()).map(ProjectState::statePermitsRead).orElse(false);
+    return perm.change(psd.data()).test(ChangePermission.READ)
+        && projectCache.get(psd.data().project()).map(ProjectState::statePermitsRead).orElse(false);
   }
 
   @AutoValue
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 50ee9d4..1d92521 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -16,13 +16,11 @@
 
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
 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.extensions.restapi.RestApiException;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -32,19 +30,15 @@
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.Optional;
 
 /** Remove a specified user from the attention set. */
 public class RemoveFromAttentionSetOp implements BatchUpdateOp {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final MessageIdGenerator messageIdGenerator;
   private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
@@ -64,14 +58,12 @@
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      MessageIdGenerator messageIdGenerator,
       RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.messageIdGenerator = messageIdGenerator;
     this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
@@ -94,8 +86,8 @@
 
     change = ctx.getChange();
 
-    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    update.addToPlannedAttentionSetUpdates(
+    ChangeUpdate changeUpdate = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    changeUpdate.addToPlannedAttentionSetUpdates(
         AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason));
     return true;
   }
@@ -105,18 +97,13 @@
     if (!notify) {
       return;
     }
-    try {
-      attentionSetEmailFactory
-          .create(
-              removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
-              ctx,
-              change,
-              reason,
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
-              attentionUserId)
-          .sendAsync();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
-    }
+    attentionSetEmailFactory
+        .create(
+            removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
+            ctx,
+            change,
+            reason,
+            attentionUserId)
+        .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 6189708..f0f3a8f 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.permissions.LabelPermission;
@@ -129,11 +128,8 @@
             continue;
           }
 
-          try {
-            perm.check(new LabelPermission(type.get()));
+          if (perm.test(new LabelPermission(type.get()))) {
             out.approvals.put(name, formatValue((short) 0));
-          } catch (AuthException e) {
-            // Do nothing.
           }
         }
       }
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index fffb107..b5e0181 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -48,7 +48,6 @@
 import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.AnonymousUser;
@@ -84,6 +83,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -94,9 +94,21 @@
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
 
+  /**
+   * Controls which failures should be ignored.
+   *
+   * <p>If a failure is ignored the operation succeeds, but the reviewer is not added. If not
+   * ignored a failure means that the operation fails.
+   */
   public enum FailureBehavior {
+    // All failures cause the operation to fail.
     FAIL,
-    IGNORE;
+
+    // Only not found failures cause the operation to fail, all other failures are ignored.
+    IGNORE_EXCEPT_NOT_FOUND,
+
+    // All failures are ignored.
+    IGNORE_ALL;
   }
 
   private enum FailureType {
@@ -113,6 +125,9 @@
      * resolving to an account/group/email.
      */
     public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
+
+    /** Whether the visibility check for the reviewer account should be skipped. */
+    public boolean skipVisibilityCheck = false;
   }
 
   public static InternalReviewerInput newReviewerInput(
@@ -143,7 +158,7 @@
     in.reviewer = accountId.toString();
     in.state = CC;
     in.notify = notify;
-    in.otherFailureBehavior = FailureBehavior.IGNORE;
+    in.otherFailureBehavior = FailureBehavior.IGNORE_ALL;
     return Optional.of(in);
   }
 
@@ -262,7 +277,14 @@
     IdentifiedUser reviewerUser;
     boolean exactMatchFound = false;
     try {
-      reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
+      if (ReviewerState.REMOVED.equals(input.state)
+          || (input instanceof InternalReviewerInput
+              && ((InternalReviewerInput) input).skipVisibilityCheck)) {
+        reviewerUser =
+            accountResolver.resolveIncludeInactiveIgnoreVisibility(input.reviewer).asUniqueUser();
+      } else {
+        reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
+      }
       if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
           || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
         exactMatchFound = true;
@@ -378,9 +400,10 @@
   @Nullable
   private ReviewerModification addByEmail(ReviewerInput input, ChangeNotes notes, CurrentUser user)
       throws PermissionBackendException {
-    try {
-      permissionBackend.user(anonymousProvider.get()).change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
+    if (!permissionBackend
+        .user(anonymousProvider.get())
+        .change(notes)
+        .test(ChangePermission.READ)) {
       return fail(
           input,
           FailureType.OTHER,
@@ -399,15 +422,10 @@
 
   private boolean isValidReviewer(BranchNameKey branch, Account member)
       throws PermissionBackendException {
-    try {
-      // Check ref permission instead of change permission, since change permissions take into
-      // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
-      // see private changes.
-      permissionBackend.absentUser(member.id()).ref(branch).check(RefPermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    // Check ref permission instead of change permission, since change permissions take into
+    // account the private bit, whereas adding a user as a reviewer is explicitly allowing them to
+    // see private changes.
+    return permissionBackend.absentUser(member.id()).ref(branch).test(RefPermission.READ);
   }
 
   private ReviewerModification fail(ReviewerInput input, FailureType failureType, String error) {
@@ -458,9 +476,13 @@
       this.input = input;
       this.failureType = null;
       result = new ReviewerResult(input.reviewer);
-      // Always silently ignore adding the owner as any type of reviewer on their own change. They
-      // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
-      this.reviewers = omitOwner(notes, reviewers);
+      if (!state().equals(REMOVED)) {
+        // Always silently ignore adding the owner as any type of reviewer on their own change. They
+        // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
+        this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ true);
+      } else {
+        this.reviewers = reviewersAsList(notes, reviewers, /* omitChangeOwner= */ false);
+      }
       this.reviewersByEmail =
           reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
       this.caller = caller.asIdentifiedUser();
@@ -494,12 +516,18 @@
       this.exactMatchFound = exactMatchFound;
     }
 
-    private ImmutableSet<Account> omitOwner(ChangeNotes notes, Iterable<Account> reviewers) {
-      return reviewers != null
-          ? Streams.stream(reviewers)
-              .filter(account -> !account.id().equals(notes.getChange().getOwner()))
-              .collect(toImmutableSet())
-          : ImmutableSet.of();
+    private ImmutableSet<Account> reviewersAsList(
+        ChangeNotes notes, @Nullable Iterable<Account> reviewers, boolean omitChangeOwner) {
+      if (reviewers == null) {
+        return ImmutableSet.of();
+      }
+
+      Stream<Account> reviewerStream = Streams.stream(reviewers);
+      if (omitChangeOwner) {
+        reviewerStream =
+            reviewerStream.filter(account -> !account.id().equals(notes.getChange().getOwner()));
+      }
+      return reviewerStream.collect(toImmutableSet());
     }
 
     public void gatherResults(ChangeData cd) throws PermissionBackendException {
@@ -571,7 +599,9 @@
           (input instanceof InternalReviewerInput)
               ? ((InternalReviewerInput) input).otherFailureBehavior
               : FailureBehavior.FAIL;
-      return failureType == FailureType.OTHER && behavior == FailureBehavior.IGNORE;
+      return behavior == FailureBehavior.IGNORE_ALL
+          || (failureType == FailureType.OTHER
+              && behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND);
     }
   }
 
@@ -648,7 +678,7 @@
     }
 
     public <T> ImmutableSet<T> flattenResults(
-        Function<AddReviewersOp.Result, ? extends Collection<T>> func) {
+        Function<ReviewerOp.Result, ? extends Collection<T>> func) {
       modifications()
           .forEach(
               a ->
diff --git a/java/com/google/gerrit/server/change/ReviewerResource.java b/java/com/google/gerrit/server/change/ReviewerResource.java
index 7a98f2b..e688a7b 100644
--- a/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -29,7 +29,7 @@
 
 public class ReviewerResource implements RestResource {
   public static final TypeLiteral<RestView<ReviewerResource>> REVIEWER_KIND =
-      new TypeLiteral<RestView<ReviewerResource>>() {};
+      new TypeLiteral<>() {};
 
   public interface Factory {
     ReviewerResource create(ChangeResource change, Account.Id id);
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index 33f3d4f..5469b51 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -48,7 +48,6 @@
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.Extension;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
@@ -58,7 +57,7 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -88,7 +87,7 @@
     RevisionJson create(Iterable<ListChangesOption> options);
   }
 
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final IdentifiedUser.GenericFactory userFactory;
   private final FileInfoJson fileInfoJson;
   private final GpgApiAdapter gpgApi;
@@ -112,7 +111,7 @@
       AnonymousUser anonymous,
       ProjectCache projectCache,
       IdentifiedUser.GenericFactory userFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       FileInfoJson fileInfoJson,
       AccountLoader.Factory accountLoaderFactory,
       DynamicMap<DownloadScheme> downloadSchemes,
@@ -169,7 +168,8 @@
       RevCommit commit,
       boolean addLinks,
       boolean fillCommit,
-      String branchName)
+      String branchName,
+      String changeKey)
       throws IOException {
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -183,7 +183,8 @@
 
     if (addLinks) {
       ImmutableList<WebLinkInfo> patchSetLinks =
-          webLinks.getPatchSetLinks(project, commit.name(), commit.getFullMessage(), branchName);
+          webLinks.getPatchSetLinks(
+              project, commit.name(), commit.getFullMessage(), branchName, changeKey);
       info.webLinks = patchSetLinks.isEmpty() ? null : patchSetLinks;
       ImmutableList<WebLinkInfo> resolveConflictsLinks =
           webLinks.getResolveConflictsLinks(
@@ -285,7 +286,7 @@
     out.isCurrent = in.id().equals(c.currentPatchSetId());
     out._number = in.id().get();
     out.ref = in.refName();
-    out.created = in.createdOn();
+    out.setCreated(in.createdOn());
     out.uploader = accountLoader.get(in.uploader());
     out.fetch = makeFetchMap(cd, in);
     out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
@@ -302,7 +303,9 @@
       rw.parseBody(commit);
       String branchName = cd.change().getDest().branch();
       if (setCommit) {
-        out.commit = getCommitInfo(project, rw, commit, has(WEB_LINKS), fillCommit, branchName);
+        out.commit =
+            getCommitInfo(
+                project, rw, commit, has(WEB_LINKS), fillCommit, branchName, c.getKey().get());
       }
       if (addFooters) {
         Ref ref = repo.exactRef(branchName);
@@ -353,9 +356,7 @@
   }
 
   private boolean isWorldReadable(ChangeData cd) throws PermissionBackendException {
-    try {
-      permissionBackend.user(anonymous).change(cd).check(ChangePermission.READ);
-    } catch (AuthException ae) {
+    if (!permissionBackend.user(anonymous).change(cd).test(ChangePermission.READ)) {
       return false;
     }
     ProjectState projectState =
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index 30fa593..e5a57b2 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -35,7 +35,7 @@
 
 public class RevisionResource implements RestResource, HasETag {
   public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
-      new TypeLiteral<RestView<RevisionResource>>() {};
+      new TypeLiteral<>() {};
 
   public static RevisionResource createNonCacheable(ChangeResource change, PatchSet ps) {
     return new RevisionResource(change, ps, Optional.empty(), false);
diff --git a/java/com/google/gerrit/server/change/RobotCommentResource.java b/java/com/google/gerrit/server/change/RobotCommentResource.java
index b12727d..3662575 100644
--- a/java/com/google/gerrit/server/change/RobotCommentResource.java
+++ b/java/com/google/gerrit/server/change/RobotCommentResource.java
@@ -23,7 +23,7 @@
 
 public class RobotCommentResource implements RestResource {
   public static final TypeLiteral<RestView<RobotCommentResource>> ROBOT_COMMENT_KIND =
-      new TypeLiteral<RestView<RobotCommentResource>>() {};
+      new TypeLiteral<>() {};
 
   private final RevisionResource rev;
   private final RobotComment comment;
diff --git a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
index 4833197..fcd9e90 100644
--- a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
@@ -40,18 +40,20 @@
               result.applicabilityExpressionResult().get(),
               /* hide= */ true); // Always hide applicability expressions on the API
     }
-    if (req.overrideExpression().isPresent()) {
+    if (req.overrideExpression().isPresent() && result.overrideExpressionResult().isPresent()) {
       info.overrideExpressionResult =
           submitRequirementExpressionToInfo(
               req.overrideExpression().get(),
               result.overrideExpressionResult().get(),
               /* hide= */ false);
     }
-    info.submittabilityExpressionResult =
-        submitRequirementExpressionToInfo(
-            req.submittabilityExpression(),
-            result.submittabilityExpressionResult(),
-            /* hide= */ false);
+    if (result.submittabilityExpressionResult().isPresent()) {
+      info.submittabilityExpressionResult =
+          submitRequirementExpressionToInfo(
+              req.submittabilityExpression(),
+              result.submittabilityExpressionResult().get(),
+              /* hide= */ false);
+    }
     info.status = SubmitRequirementResultInfo.Status.valueOf(result.status().toString());
     info.isLegacy = result.isLegacy();
     return info;
@@ -63,9 +65,13 @@
       boolean hide) {
     SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
     info.expression = hide ? null : expression.expressionString();
-    info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
+    info.fulfilled =
+        result.status().equals(SubmitRequirementExpressionResult.Status.PASS)
+            || result.status().equals(SubmitRequirementExpressionResult.Status.NOT_EVALUATED);
+    info.status = SubmitRequirementExpressionInfo.Status.valueOf(result.status().name());
     info.passingAtoms = hide ? null : result.passingAtoms();
     info.failingAtoms = hide ? null : result.failingAtoms();
+    info.errorMessage = result.errorMessage().isPresent() ? result.errorMessage().get() : null;
     return info;
   }
 }
diff --git a/java/com/google/gerrit/server/change/ValidationOptionsUtil.java b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
new file mode 100644
index 0000000..137239c
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+
+/** Utilities for validation options parsing. */
+public final class ValidationOptionsUtil {
+  public static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+      @Nullable Map<String, String> validationOptions) {
+    if (validationOptions == null) {
+      return ImmutableListMultimap.of();
+    }
+
+    ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+        ImmutableListMultimap.builder();
+    validationOptions
+        .entrySet()
+        .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+    return validationOptionsBuilder.build();
+  }
+
+  private ValidationOptionsUtil() {}
+}
diff --git a/java/com/google/gerrit/server/change/VoteResource.java b/java/com/google/gerrit/server/change/VoteResource.java
index 27b5bec..3f5ec4c 100644
--- a/java/com/google/gerrit/server/change/VoteResource.java
+++ b/java/com/google/gerrit/server/change/VoteResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class VoteResource implements RestResource {
-  public static final TypeLiteral<RestView<VoteResource>> VOTE_KIND =
-      new TypeLiteral<RestView<VoteResource>>() {};
+  public static final TypeLiteral<RestView<VoteResource>> VOTE_KIND = new TypeLiteral<>() {};
 
   private final ReviewerResource reviewer;
   private final String label;
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
index 816a904..44a3d16 100644
--- a/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -112,8 +112,8 @@
     return Iterables.concat(sortedByProject);
   }
 
-  private List<PatchSetData> sortProject(Project.NameKey project, Collection<ChangeData> in)
-      throws IOException {
+  private ImmutableList<PatchSetData> sortProject(
+      Project.NameKey project, Collection<ChangeData> in) throws IOException {
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       rw.setRetainBody(retainBody);
@@ -181,7 +181,7 @@
           }
         }
       }
-      return result;
+      return ImmutableList.copyOf(result);
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 1409170..04fd1c0 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -19,21 +19,18 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
 
 /* Set work in progress or ready for review state on a change */
 public class WorkInProgressOp implements BatchUpdateOp {
@@ -61,8 +58,8 @@
   private final WorkInProgressStateChanged stateChanged;
 
   private boolean sendEmail = true;
+  private ObjectId preUpdateMetaId;
   private Change change;
-  private ChangeNotes notes;
   private PatchSet ps;
   private String mailMessage;
 
@@ -88,8 +85,8 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx) {
+    preUpdateMetaId = ctx.getNotes().getMetaId();
     change = ctx.getChange();
-    notes = ctx.getNotes();
     ps = psUtil.get(ctx.getNotes(), change.currentPatchSetId());
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     change.setWorkInProgress(workInProgress);
@@ -131,25 +128,15 @@
         || !sendEmail) {
       return;
     }
-    RepoView repoView;
-    try {
-      repoView = ctx.getRepoView();
-    } catch (IOException ex) {
-      throw new StorageException(
-          String.format("Repository %s not found", ctx.getProject().get()), ex);
-    }
     email
         .create(
-            notify,
-            notes,
+            ctx,
             ps,
-            ctx.getIdentifiedUser(),
-            mailMessage,
-            ctx.getWhen(),
-            ImmutableList.of(),
+            preUpdateMetaId,
             mailMessage,
             ImmutableList.of(),
-            repoView)
+            mailMessage,
+            ImmutableList.of())
         .sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
index af2ae92..ce9aa78 100644
--- a/java/com/google/gerrit/server/comment/CommentContextKey.java
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.auto.value.AutoValue;
diff --git a/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java b/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
index 27ae41f..0e377d3 100644
--- a/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
+++ b/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.server.config;
 
 import java.util.Optional;
diff --git a/java/com/google/gerrit/server/config/AuthConfig.java b/java/com/google/gerrit/server/config/AuthConfig.java
index 1760378..b6ffcee 100644
--- a/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/java/com/google/gerrit/server/config/AuthConfig.java
@@ -65,6 +65,7 @@
   private final SignedToken emailReg;
   private final boolean allowRegisterNewEmail;
   private final boolean userNameCaseInsensitive;
+  private final boolean userNameCaseInsensitiveMigrationMode;
   private GitBasicAuthPolicy gitBasicAuthPolicy;
 
   @Inject
@@ -97,6 +98,8 @@
     userNameToLowerCase = cfg.getBoolean("auth", "userNameToLowerCase", false);
     allowRegisterNewEmail = cfg.getBoolean("auth", "allowRegisterNewEmail", true);
     userNameCaseInsensitive = cfg.getBoolean("auth", "userNameCaseInsensitive", false);
+    userNameCaseInsensitiveMigrationMode =
+        cfg.getBoolean("auth", "userNameCaseInsensitiveMigrationMode", false);
 
     if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP
         && authType != AuthType.LDAP
@@ -244,6 +247,11 @@
     return userNameCaseInsensitive;
   }
 
+  /** Whether user name case insensitive migration is in progress */
+  public boolean isUserNameCaseInsensitiveMigrationMode() {
+    return userNameCaseInsensitiveMigrationMode;
+  }
+
   public GitBasicAuthPolicy getGitBasicAuthPolicy() {
     return gitBasicAuthPolicy;
   }
diff --git a/java/com/google/gerrit/server/config/CacheResource.java b/java/com/google/gerrit/server/config/CacheResource.java
index 7a835b1..b2f790c 100644
--- a/java/com/google/gerrit/server/config/CacheResource.java
+++ b/java/com/google/gerrit/server/config/CacheResource.java
@@ -21,8 +21,7 @@
 import com.google.inject.TypeLiteral;
 
 public class CacheResource extends ConfigResource {
-  public static final TypeLiteral<RestView<CacheResource>> CACHE_KIND =
-      new TypeLiteral<RestView<CacheResource>>() {};
+  public static final TypeLiteral<RestView<CacheResource>> CACHE_KIND = new TypeLiteral<>() {};
 
   private final String name;
   private final Provider<Cache<?, ?>> cacheProvider;
diff --git a/java/com/google/gerrit/server/config/CapabilityResource.java b/java/com/google/gerrit/server/config/CapabilityResource.java
index 7e3c87e..5a1977b 100644
--- a/java/com/google/gerrit/server/config/CapabilityResource.java
+++ b/java/com/google/gerrit/server/config/CapabilityResource.java
@@ -19,5 +19,5 @@
 
 public class CapabilityResource extends ConfigResource {
   public static final TypeLiteral<RestView<CapabilityResource>> CAPABILITY_KIND =
-      new TypeLiteral<RestView<CapabilityResource>>() {};
+      new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/server/config/ConfigResource.java b/java/com/google/gerrit/server/config/ConfigResource.java
index f2b7c8e..93efd19 100644
--- a/java/com/google/gerrit/server/config/ConfigResource.java
+++ b/java/com/google/gerrit/server/config/ConfigResource.java
@@ -21,8 +21,7 @@
 import java.util.concurrent.TimeUnit;
 
 public class ConfigResource implements RestResource {
-  public static final TypeLiteral<RestView<ConfigResource>> CONFIG_KIND =
-      new TypeLiteral<RestView<ConfigResource>>() {};
+  public static final TypeLiteral<RestView<ConfigResource>> CONFIG_KIND = new TypeLiteral<>() {};
 
   /**
    * Default cache control that gets set on the 'Cache-Control' header for responses on this
diff --git a/java/com/google/gerrit/server/config/ConfigSection.java b/java/com/google/gerrit/server/config/ConfigSection.java
deleted file mode 100644
index 057ce99..0000000
--- a/java/com/google/gerrit/server/config/ConfigSection.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.config;
-
-import org.eclipse.jgit.lib.Config;
-
-/** Provides access to one section from {@link Config} */
-public class ConfigSection {
-
-  private final Config cfg;
-  private final String section;
-
-  public ConfigSection(Config cfg, String section) {
-    this.cfg = cfg;
-    this.section = section;
-  }
-
-  public String optional(String name) {
-    return cfg.getString(section, null, name);
-  }
-
-  public String required(String name) {
-    return ConfigUtil.getRequired(cfg, section, name);
-  }
-}
diff --git a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
index 4032e63..7fd075e 100644
--- a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
+++ b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
@@ -20,7 +20,7 @@
 import java.util.LinkedHashSet;
 import java.util.Objects;
 import java.util.Set;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.lib.Config;
 
 /**
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index c44b0fd..5d94255 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -145,7 +145,7 @@
             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());
+            logger.atWarning().log("%s", ex.getMessage());
           }
         }
       }
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 00df1e6..d581675 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.CoreDownloadSchemes;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
 import com.google.gerrit.server.change.ArchiveFormatInternal;
@@ -23,7 +24,6 @@
 import com.google.inject.Singleton;
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
-import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
@@ -43,23 +43,24 @@
   private final ImmutableSet<ArchiveFormatInternal> archiveFormats;
 
   @Inject
-  DownloadConfig(@GerritServerConfig Config cfg) {
+  public DownloadConfig(@GerritServerConfig Config cfg) {
     String[] allSchemes = cfg.getStringList("download", null, "scheme");
     if (allSchemes.length == 0) {
       downloadSchemes =
           ImmutableSet.of(
               CoreDownloadSchemes.SSH, CoreDownloadSchemes.HTTP, CoreDownloadSchemes.ANON_HTTP);
     } else {
-      List<String> normalized = new ArrayList<>(allSchemes.length);
+      ImmutableSet.Builder<String> normalized =
+          ImmutableSet.builderWithExpectedSize(allSchemes.length);
       for (String s : allSchemes) {
         String core = toCoreScheme(s);
         if (core == null) {
-          logger.atWarning().log("not a core download scheme: " + s);
+          logger.atWarning().log("not a core download scheme: %s", s);
           continue;
         }
         normalized.add(core);
       }
-      downloadSchemes = ImmutableSet.copyOf(normalized);
+      downloadSchemes = normalized.build();
     }
 
     DownloadCommand[] downloadCommandValues = DownloadCommand.values();
@@ -87,6 +88,7 @@
     return list.size() == 1 && list.get(0) == null;
   }
 
+  @Nullable
   private static String toCoreScheme(String s) {
     try {
       Field f = CoreDownloadSchemes.class.getField(s.toUpperCase());
diff --git a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
index ebb0e50..db21e1f 100644
--- a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
+++ b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.server.config;
 
 import com.google.common.annotations.VisibleForTesting;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 24882cb..f442500 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Ticker;
 import com.google.common.cache.Cache;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ActionVisitor;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
@@ -36,6 +37,7 @@
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
+import com.google.gerrit.extensions.events.AttentionSetListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
@@ -44,6 +46,7 @@
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
@@ -107,7 +110,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
-import com.google.gerrit.server.approval.ApprovalCacheImpl;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
@@ -120,6 +122,7 @@
 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.EmailNewPatchSet;
 import com.google.gerrit.server.change.FileInfoJsonModule;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.change.ReviewerSuggestion;
@@ -129,11 +132,11 @@
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.events.UserScopedEventListener;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.GitModule;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.NotesBranchUtil;
@@ -172,6 +175,7 @@
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.mime.MimeUtilFileTypeRegistry;
+import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -187,14 +191,17 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.FileEditsPredicate;
 import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.query.change.DistinctVotersPredicate;
+import com.google.gerrit.server.query.change.HasSubmoduleUpdatePredicate;
 import com.google.gerrit.server.quota.QuotaEnforcer;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
@@ -249,7 +256,6 @@
     bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
     install(AccountCacheImpl.module());
-    install(ApprovalCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
     install(ChangeFinder.module());
@@ -279,7 +285,7 @@
     install(new GroupDbModule());
     install(new GroupModule());
     install(new NoteDbModule());
-    install(new PrologModule());
+    install(new PrologModule(cfg));
     install(new DefaultSubmitRuleModule());
     install(new IgnoreSelfApprovalRuleModule());
     install(new ReceiveCommitsModule());
@@ -293,8 +299,10 @@
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
+    factory(DistinctVotersPredicate.Factory.class);
+    factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(DeadlineChecker.Factory.class);
-    factory(MergeUtil.Factory.class);
+    factory(EmailNewPatchSet.Factory.class);
     factory(MultiProgressMonitor.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(PatchScriptFactoryForAutoFix.Factory.class);
@@ -309,6 +317,7 @@
     bind(AccountDefaultDisplayName.class).toInstance(accountDefaultDisplayName);
     factory(ProjectOwnerGroupsProvider.Factory.class);
     factory(SubmitRuleEvaluator.Factory.class);
+    factory(DeleteZombieCommentsRefs.Factory.class);
 
     bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
     DynamicSet.setOf(binder(), AuthBackend.class);
@@ -350,6 +359,7 @@
     DynamicMap.mapOf(binder(), CapabilityDefinition.class);
     DynamicMap.mapOf(binder(), PluginProjectPermissionDefinition.class);
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
+    DynamicSet.setOf(binder(), GitBatchRefUpdateListener.class);
     DynamicSet.setOf(binder(), AssigneeChangedListener.class);
     DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
     DynamicSet.setOf(binder(), ChangeDeletedListener.class);
@@ -385,7 +395,7 @@
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
     DynamicSet.setOf(binder(), HeadUpdatedListener.class);
     DynamicSet.setOf(binder(), UsageDataPublishedListener.class);
-    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterRefUpdate.class);
+    DynamicSet.bind(binder(), GitBatchRefUpdateListener.class).to(ReindexAfterRefUpdate.class);
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ProjectConfigEntry.UpdateChecker.class);
     DynamicSet.setOf(binder(), EventListener.class);
@@ -393,7 +403,7 @@
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
     DynamicSet.bind(binder(), CommitValidationListener.class)
-        .to(SubmitRequirementExpressionsValidator.class);
+        .to(SubmitRequirementConfigValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
@@ -436,9 +446,12 @@
     DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
     DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
     DynamicSet.setOf(binder(), SubmitRule.class);
+    DynamicSet.setOf(binder(), SubmitRequirement.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
     DynamicSet.setOf(binder(), PerformanceLogger.class);
-    DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
+    if (cfg.getBoolean("tracing", "exportPerformanceMetrics", false)) {
+      DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
+    }
     DynamicSet.setOf(binder(), RequestListener.class);
     DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
     DynamicSet.setOf(binder(), ChangeETagComputation.class);
@@ -447,6 +460,7 @@
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
     DynamicSet.setOf(binder(), OnPostReview.class);
     DynamicMap.mapOf(binder(), AccountTagProvider.class);
+    DynamicSet.setOf(binder(), AttentionSetListener.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
@@ -489,6 +503,7 @@
     factory(GitModules.Factory.class);
     factory(VersionedAuthorizedKeys.Factory.class);
     factory(StoreSubmitRequirementsOp.Factory.class);
+    factory(FileEditsPredicate.Factory.class);
 
     bind(AccountManager.class);
     bind(SubscriptionGraph.Factory.class).to(ConfiguredSubscriptionGraphFactory.class);
@@ -500,5 +515,7 @@
         .annotatedWith(UniqueAnnotations.create())
         .to(PluginConfigFactory.class);
     DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
+
+    bind(AttentionSetObserver.class);
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIds.java b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
new file mode 100644
index 0000000..c47d3be
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * List of ServerIds of the Gerrit data imported from other servers.
+ *
+ * <p>This values correspond to the {@code GerritServerId} of other servers.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface GerritImportedServerIds {}
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
new file mode 100644
index 0000000..f3f7645
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+
+public class GerritImportedServerIdsProvider implements Provider<ImmutableSet<String>> {
+  public static final String SECTION = "gerrit";
+  public static final String KEY = "importedServerId";
+
+  private final ImmutableSet<String> importedIds;
+
+  @Inject
+  public GerritImportedServerIdsProvider(@GerritServerConfig Config cfg) {
+    importedIds = ImmutableSet.copyOf(cfg.getStringList(SECTION, null, KEY));
+  }
+
+  @Override
+  public ImmutableSet<String> get() {
+    return importedIds;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index 5632978..0a213b4 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.extensions.webui.BranchWebLink;
-import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.ParentWebLink;
@@ -73,7 +72,6 @@
         }
 
         if (!isNullOrEmpty(type.getFile()) || !isNullOrEmpty(type.getRootTree())) {
-          DynamicSet.bind(binder(), EditWebLink.class).to(GitwebLinks.class);
           DynamicSet.bind(binder(), FileWebLink.class).to(GitwebLinks.class);
         }
 
@@ -101,6 +99,7 @@
     return values.length > 0 && isNullOrEmpty(values[0]);
   }
 
+  @Nullable
   private static GitwebType typeFromConfig(Config cfg) {
     GitwebType defaultType = defaultType(cfg.getString("gitweb", null, "type"));
     if (defaultType == null) {
@@ -138,6 +137,7 @@
     return type;
   }
 
+  @Nullable
   private static GitwebType defaultType(String typeName) {
     GitwebType type = new GitwebType();
     switch (nullToEmpty(typeName)) {
@@ -146,7 +146,7 @@
         type.setProject("?p=${project}.git;a=summary");
         type.setRevision("?p=${project}.git;a=commit;h=${commit}");
         type.setBranch("?p=${project}.git;a=shortlog;h=${branch}");
-        type.setTag("?p=${project}.git;a=tag;h=${tag}");
+        type.setTag("?p=${project}.git;a=shortlog;h=${tag}");
         type.setRootTree("?p=${project}.git;a=tree;hb=${commit}");
         type.setFile("?p=${project}.git;hb=${commit};f=${file}");
         type.setFileHistory("?p=${project}.git;a=history;hb=${branch};f=${file}");
@@ -257,7 +257,6 @@
   @Singleton
   static class GitwebLinks
       implements BranchWebLink,
-          EditWebLink,
           FileHistoryWebLink,
           FileWebLink,
           PatchSetWebLink,
@@ -286,6 +285,7 @@
       this.tag = parse(type.getTag());
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getBranchWebLink(String projectName, String branchName) {
       if (branch != null) {
@@ -298,6 +298,7 @@
       return null;
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getTagWebLink(String projectName, String tagName) {
       if (tag != null) {
@@ -307,6 +308,7 @@
       return null;
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getFileHistoryWebLink(String projectName, String revision, String fileName) {
       if (fileHistory != null) {
@@ -320,24 +322,22 @@
       return null;
     }
 
+    @Nullable
     @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());
       }
       return null;
     }
 
-    @Override
-    public WebLinkInfo getEditWebLink(String projectName, String revision, String fileName) {
-      // For Gitweb treat edit links the same as file links
-      return getFileWebLink(projectName, revision, fileName);
-    }
-
+    @Nullable
     @Override
     public WebLinkInfo getPatchSetWebLink(
         String projectName, String commit, String commitMessage, String branchName) {
@@ -366,6 +366,7 @@
       return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
     }
 
+    @Nullable
     @Override
     public WebLinkInfo getProjectWeblink(String projectName) {
       if (project != null) {
@@ -385,6 +386,7 @@
       return new WebLinkInfo(type.getLinkName(), null, url + rest, null);
     }
 
+    @Nullable
     private static ParameterizedString parse(String pattern) {
       if (!isNullOrEmpty(pattern)) {
         return new ParameterizedString(pattern);
diff --git a/java/com/google/gerrit/server/config/MetricsReservoirConfigImpl.java b/java/com/google/gerrit/server/config/MetricsReservoirConfigImpl.java
new file mode 100644
index 0000000..ac3c53a
--- /dev/null
+++ b/java/com/google/gerrit/server/config/MetricsReservoirConfigImpl.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.gerrit.metrics.MetricsReservoirConfig;
+import com.google.gerrit.metrics.ReservoirType;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+/** Define metrics reservoir settings based on gerrit.config */
+@Singleton
+public class MetricsReservoirConfigImpl implements MetricsReservoirConfig {
+  private static final double RESERVOIR_ALPHA_DEFAULT = 0.015;
+  private static final int METRICS_RESERVOIR_SIZE_DEFAULT = 1028;
+  private static final long METRICS_RESERVOIR_WINDOW_MSEC_DEFAULT = 60000L;
+  private static final String METRICS_SECTION = "metrics";
+  private static final String METRICS_RESERVOIR = "reservoir";
+
+  private final ReservoirType reservoirType;
+
+  private final Duration reservoirWindow;
+  private final int reservoirSize;
+  private final double reservoirAlpha;
+
+  @Inject
+  MetricsReservoirConfigImpl(@GerritServerConfig Config gerritConfig) {
+    this.reservoirType =
+        gerritConfig.getEnum(
+            METRICS_SECTION, null, METRICS_RESERVOIR, ReservoirType.ExponentiallyDecaying);
+
+    reservoirWindow =
+        Duration.ofMillis(
+            ConfigUtil.getTimeUnit(
+                gerritConfig,
+                METRICS_SECTION,
+                reservoirType.name(),
+                "window",
+                METRICS_RESERVOIR_WINDOW_MSEC_DEFAULT,
+                TimeUnit.MILLISECONDS));
+    reservoirSize =
+        gerritConfig.getInt(
+            METRICS_SECTION, reservoirType.name(), "size", METRICS_RESERVOIR_SIZE_DEFAULT);
+    reservoirAlpha =
+        Optional.ofNullable(gerritConfig.getString(METRICS_SECTION, reservoirType.name(), "alpha"))
+            .map(Double::parseDouble)
+            .orElse(RESERVOIR_ALPHA_DEFAULT);
+  }
+
+  /* (non-Javadoc)
+   * @see com.google.gerrit.server.config.MetricsConfig#reservoirType()
+   */
+  @Override
+  public ReservoirType reservoirType() {
+    return reservoirType;
+  }
+
+  /* (non-Javadoc)
+   * @see com.google.gerrit.server.config.MetricsConfig#reservoirWindow()
+   */
+  @Override
+  public Duration reservoirWindow() {
+    return reservoirWindow;
+  }
+
+  /* (non-Javadoc)
+   * @see com.google.gerrit.server.config.MetricsConfig#reservoirSize()
+   */
+  @Override
+  public int reservoirSize() {
+    return reservoirSize;
+  }
+
+  /* (non-Javadoc)
+   * @see com.google.gerrit.server.config.MetricsConfig#reservoirAlpha()
+   */
+  @Override
+  public double reservoirAlpha() {
+    return reservoirAlpha;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
index 69d75be..fbdb324 100644
--- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -182,6 +182,7 @@
       my.add(new MenuItem("Edits", "#/q/has:edit", null));
       my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
       my.add(new MenuItem("Starred Changes", "#/q/is:starred", null));
+      my.add(new MenuItem("All Visible Changes", "#/q/is:visible", null));
       my.add(new MenuItem("Groups", "#/settings/#Groups", null));
     }
     return my;
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index c09988e3..e11d6aa 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
@@ -360,6 +361,7 @@
       }
     }
 
+    @Nullable
     private ProjectConfig parseConfig(Project.NameKey p, String idStr)
         throws IOException, ConfigInvalidException, RepositoryNotFoundException {
       ObjectId id = ObjectId.fromString(idStr);
@@ -382,14 +384,17 @@
     }
   }
 
+  @Nullable
   private static Boolean toBoolean(String value) {
     return value != null ? Boolean.parseBoolean(value) : null;
   }
 
+  @Nullable
   private static Integer toInt(String value) {
     return value != null ? Integer.parseInt(value) : null;
   }
 
+  @Nullable
   private static Long toLong(String value) {
     return value != null ? Long.parseLong(value) : null;
   }
diff --git a/java/com/google/gerrit/server/config/RepositoryConfig.java b/java/com/google/gerrit/server/config/RepositoryConfig.java
index f722321..d569c87 100644
--- a/java/com/google/gerrit/server/config/RepositoryConfig.java
+++ b/java/com/google/gerrit/server/config/RepositoryConfig.java
@@ -55,6 +55,7 @@
         cfg.getStringList(SECTION_NAME, findSubSection(project.get()), OWNER_GROUP_NAME));
   }
 
+  @Nullable
   public Path getBasePath(Project.NameKey project) {
     String basePath = cfg.getString(SECTION_NAME, findSubSection(project.get()), BASE_PATH_NAME);
     return basePath != null ? Paths.get(basePath) : null;
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
index 5e268da..2cd24ba 100644
--- a/java/com/google/gerrit/server/config/SitePaths.java
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -140,6 +141,7 @@
    * @param path the path string to resolve. May be null.
    * @return the resolved path; null if {@code path} was null or empty.
    */
+  @Nullable
   public Path resolve(String path) {
     if (path != null && !path.isEmpty()) {
       Path loc = site_path.resolve(path).normalize();
diff --git a/java/com/google/gerrit/server/config/SshClientImplementation.java b/java/com/google/gerrit/server/config/SshClientImplementation.java
deleted file mode 100644
index 5811e4d..0000000
--- a/java/com/google/gerrit/server/config/SshClientImplementation.java
+++ /dev/null
@@ -1,61 +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.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/config/TaskResource.java b/java/com/google/gerrit/server/config/TaskResource.java
index 7b69533..dac455f 100644
--- a/java/com/google/gerrit/server/config/TaskResource.java
+++ b/java/com/google/gerrit/server/config/TaskResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class TaskResource extends ConfigResource {
-  public static final TypeLiteral<RestView<TaskResource>> TASK_KIND =
-      new TypeLiteral<RestView<TaskResource>>() {};
+  public static final TypeLiteral<RestView<TaskResource>> TASK_KIND = new TypeLiteral<>() {};
 
   private final Task<?> task;
 
diff --git a/java/com/google/gerrit/server/config/TopMenuResource.java b/java/com/google/gerrit/server/config/TopMenuResource.java
index bca6331..f5c71ed 100644
--- a/java/com/google/gerrit/server/config/TopMenuResource.java
+++ b/java/com/google/gerrit/server/config/TopMenuResource.java
@@ -18,6 +18,5 @@
 import com.google.inject.TypeLiteral;
 
 public class TopMenuResource extends ConfigResource {
-  public static final TypeLiteral<RestView<TopMenuResource>> TOP_MENU_KIND =
-      new TypeLiteral<RestView<TopMenuResource>>() {};
+  public static final TypeLiteral<RestView<TopMenuResource>> TOP_MENU_KIND = new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/server/config/TrackingFootersProvider.java b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
index 1611da9..778ab4c 100644
--- a/java/com/google/gerrit/server/config/TrackingFootersProvider.java
+++ b/java/com/google/gerrit/server/config/TrackingFootersProvider.java
@@ -32,7 +32,7 @@
 public class TrackingFootersProvider implements Provider<TrackingFooters> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final int MAX_LENGTH = 10;
+  private static final int MAX_LENGTH = 20;
 
   private static final String TRACKING_ID_TAG = "trackingid";
   private static final String FOOTER_TAG = "footer";
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index 5054da6..04ea438 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -83,6 +83,12 @@
     return getWebUrl().map(url -> url + "Documentation/" + page + "#" + anchor);
   }
 
+  /** Returns a URL pointing to a plugin documentation page, at a given named anchor. */
+  default Optional<String> getPluginDocUrl(String pluginName, String page, String anchor) {
+    return getWebUrl()
+        .map(url -> url + "plugins/" + pluginName + "/Documentation/" + page + "#" + anchor);
+  }
+
   /** Returns a REST API URL for a given suffix (eg. "accounts/self/details") */
   default Optional<String> getRestUrl(String suffix) {
     return getWebUrl().map(url -> url + suffix);
diff --git a/java/com/google/gerrit/server/data/AccountAttribute.java b/java/com/google/gerrit/server/data/AccountAttribute.java
index 19605a2..9be221b 100644
--- a/java/com/google/gerrit/server/data/AccountAttribute.java
+++ b/java/com/google/gerrit/server/data/AccountAttribute.java
@@ -18,4 +18,11 @@
   public String name;
   public String email;
   public String username;
+  public Integer accountId;
+
+  public AccountAttribute(Integer id) {
+    this.accountId = id;
+  }
+
+  public AccountAttribute() {}
 }
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index d71f83e..0e911b9 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -21,6 +21,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.vladsch.flexmark.ast.Heading;
 import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
 import com.vladsch.flexmark.html.HtmlRenderer;
@@ -36,7 +37,7 @@
 import java.net.URL;
 import java.nio.charset.Charset;
 import java.util.concurrent.atomic.AtomicBoolean;
-import org.apache.commons.lang.StringEscapeUtils;
+import org.apache.commons.text.StringEscapeUtils;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
 
@@ -78,12 +79,12 @@
   }
 
   public MarkdownFormatter setCss(String css) {
-    this.css = StringEscapeUtils.escapeHtml(css);
+    this.css = StringEscapeUtils.escapeHtml4(css);
     return this;
   }
 
   private MutableDataHolder markDownOptions() {
-    int options = ALL & ~(HARDWRAPS);
+    int options = ALL & ~HARDWRAPS;
     if (suppressHtml) {
       options |= SUPPRESS_ALL_HTML;
     }
@@ -126,6 +127,7 @@
     return findTitle(parseMarkdown(md));
   }
 
+  @Nullable
   private String findTitle(Node root) {
     if (root instanceof Heading) {
       Heading h = (Heading) root;
diff --git a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index 59ae6f8..cd49ea6 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -33,9 +34,9 @@
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.IndexOutput;
-import org.apache.lucene.store.RAMDirectory;
 
 @Singleton
 public class QueryDocumentationExecutor {
@@ -99,8 +100,9 @@
     }
   }
 
+  @Nullable
   protected Directory readIndexDirectory() throws IOException {
-    Directory dir = new RAMDirectory();
+    Directory dir = new ByteBuffersDirectory();
     byte[] buffer = new byte[4096];
     InputStream index = getClass().getResourceAsStream(Constants.INDEX_ZIP);
     if (index == null) {
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index bc905c2..903a4c0 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.base.Charsets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -52,11 +53,11 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.diff.DiffAlgorithm;
 import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm;
 import org.eclipse.jgit.diff.RawText;
@@ -91,7 +92,7 @@
 @Singleton
 public class ChangeEditModifier {
 
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final Provider<CurrentUser> currentUser;
   private final PermissionBackend permissionBackend;
   private final ChangeEditUtil changeEditUtil;
@@ -110,12 +111,12 @@
       ProjectCache projectCache) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
     this.projectCache = projectCache;
 
-    noteDbEdits = new NoteDbEdits(tz, indexer, currentUser);
+    noteDbEdits = new NoteDbEdits(zoneId, indexer, currentUser);
   }
 
   /**
@@ -139,7 +140,7 @@
 
     PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
     ObjectId patchSetCommitId = currentPatchSet.commitId();
-    noteDbEdits.createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
+    noteDbEdits.createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.now());
   }
 
   /**
@@ -187,7 +188,7 @@
     RevTree basePatchSetTree = basePatchSetCommit.getTree();
 
     ObjectId newTreeId = merge(repository, changeEdit, basePatchSetTree);
-    Timestamp nowTimestamp = TimeUtil.nowTs();
+    Instant nowTimestamp = TimeUtil.now();
     String commitMessage = currentEditCommit.getFullMessage();
     ObjectId newEditCommitId =
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
@@ -226,13 +227,18 @@
    * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
    * @param filePath the path of the file whose contents should be modified
    * @param newContent the new file content
+   * @param newGitFileMode the new file mode in octal format. {@code null} indicates no change
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
    * @throws InvalidChangeOperationException if the file already had the specified content
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void modifyFile(
-      Repository repository, ChangeNotes notes, String filePath, RawInput newContent)
+      Repository repository,
+      ChangeNotes notes,
+      String filePath,
+      RawInput newContent,
+      @Nullable Integer newGitFileMode)
       throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceConflictException {
     modifyCommit(
@@ -240,7 +246,8 @@
         notes,
         new ModificationIntention.LatestCommit(),
         CommitModification.builder()
-            .addTreeModification(new ChangeFileContentModification(filePath, newContent))
+            .addTreeModification(
+                new ChangeFileContentModification(filePath, newContent, newGitFileMode))
             .build());
   }
 
@@ -385,7 +392,7 @@
       return unmodifiedEdit.get();
     }
 
-    Timestamp nowTimestamp = TimeUtil.nowTs();
+    Instant nowTimestamp = TimeUtil.now();
     ObjectId newEditCommit =
         createCommit(repository, basePatchsetCommit, newTreeId, newCommitMessage, nowTimestamp);
 
@@ -407,14 +414,15 @@
 
     // Not allowed to edit if the current patch set is locked.
     patchSetUtil.checkPatchSetNotLocked(notes);
-    try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.ADD_PATCH_SET);
-      projectCache
-          .get(notes.getProjectName())
-          .orElseThrow(illegalState(notes.getProjectName()))
-          .checkStatePermitsWrite();
-    } catch (AuthException denied) {
-      throw new AuthException("edit not permitted", denied);
+    boolean canEdit =
+        permissionBackend.currentUser().change(notes).test(ChangePermission.ADD_PATCH_SET);
+    canEdit &=
+        projectCache
+            .get(notes.getProjectName())
+            .orElseThrow(illegalState(notes.getProjectName()))
+            .statePermitsWrite();
+    if (!canEdit) {
+      throw new AuthException("edit not permitted");
     }
   }
 
@@ -501,7 +509,7 @@
       RevCommit basePatchsetCommit,
       ObjectId tree,
       String commitMessage,
-      Timestamp timestamp)
+      Instant timestamp)
       throws IOException {
     try (ObjectInserter objectInserter = repository.newObjectInserter()) {
       CommitBuilder builder = new CommitBuilder();
@@ -516,9 +524,9 @@
     }
   }
 
-  private PersonIdent getCommitterIdent(Timestamp commitTimestamp) {
+  private PersonIdent getCommitterIdent(Instant commitTimestamp) {
     IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newCommitterIdent(commitTimestamp, tz);
+    return user.newCommitterIdent(commitTimestamp, zoneId);
   }
 
   /**
@@ -547,7 +555,7 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException;
   }
 
@@ -647,7 +655,7 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       return noteDbEdits.updateEdit(
           notes.getProjectName(), repository, changeEdit, newEditCommitId, timestamp);
@@ -701,19 +709,19 @@
         ChangeNotes notes,
         PatchSet basePatchSet,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       return noteDbEdits.createEdit(repository, notes, basePatchSet, newEditCommitId, timestamp);
     }
   }
 
   private static class NoteDbEdits {
-    private final TimeZone tz;
+    private final ZoneId zoneId;
     private final ChangeIndexer indexer;
     private final Provider<CurrentUser> currentUser;
 
-    NoteDbEdits(TimeZone tz, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
-      this.tz = tz;
+    NoteDbEdits(ZoneId zoneId, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
+      this.zoneId = zoneId;
       this.indexer = indexer;
       this.currentUser = currentUser;
     }
@@ -723,7 +731,7 @@
         ChangeNotes notes,
         PatchSet basePatchset,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       Change change = notes.getChange();
       String editRefName = getEditRefName(change, basePatchset);
@@ -750,7 +758,7 @@
         Repository repository,
         ChangeEdit changeEdit,
         ObjectId newEditCommitId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       String editRefName = changeEdit.getRefName();
       RevCommit currentEditCommit = changeEdit.getEditCommit();
@@ -769,7 +777,7 @@
         String refName,
         ObjectId currentObjectId,
         ObjectId targetObjectId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       RefUpdate ru = repository.updateRef(refName);
       ru.setExpectedOldObjectId(currentObjectId);
@@ -795,7 +803,7 @@
         PatchSet currentPatchSet,
         ObjectId currentEditCommit,
         ObjectId newEditCommitId,
-        Timestamp nowTimestamp)
+        Instant nowTimestamp)
         throws IOException {
       String newEditRefName = getEditRefName(changeEdit.getChange(), currentPatchSet);
       updateReferenceWithNameChange(
@@ -814,7 +822,7 @@
         ObjectId currentObjectId,
         String newRefName,
         ObjectId targetObjectId,
-        Timestamp timestamp)
+        Instant timestamp)
         throws IOException {
       BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
       batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
@@ -838,9 +846,9 @@
       }
     }
 
-    private PersonIdent getRefLogIdent(Timestamp timestamp) {
+    private PersonIdent getRefLogIdent(Instant timestamp) {
       IdentifiedUser user = currentUser.get().asIdentifiedUser();
-      return user.newRefLogIdent(timestamp, tz);
+      return user.newRefLogIdent(timestamp, zoneId);
     }
 
     private void reindex(Change change) {
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 6b018ce..74834ab 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -185,7 +185,7 @@
         message.append("Published edit on patch set ").append(basePatchSet.number()).append(".");
       }
 
-      try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.nowTs())) {
+      try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.now())) {
         bu.setRepository(repo, rw, oi);
         bu.setNotify(notify);
         bu.addOp(change.getId(), inserter.setMessage(message.toString()));
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 39ab041..96c6685 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RawInput;
 import java.io.IOException;
 import java.io.InputStream;
@@ -42,16 +43,26 @@
 
   private final String filePath;
   private final RawInput newContent;
+  private final Integer newGitFileMode;
 
   public ChangeFileContentModification(String filePath, RawInput newContent) {
     this.filePath = filePath;
     this.newContent = requireNonNull(newContent, "new content required");
+    this.newGitFileMode = null;
+  }
+
+  public ChangeFileContentModification(
+      String filePath, RawInput newContent, @Nullable Integer newGitFileMode) {
+    this.filePath = filePath;
+    this.newContent = requireNonNull(newContent, "new content required");
+    this.newGitFileMode = newGitFileMode;
   }
 
   @Override
   public List<DirCacheEditor.PathEdit> getPathEdits(
       Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
-    DirCacheEditor.PathEdit changeContentEdit = new ChangeContent(filePath, newContent, repository);
+    DirCacheEditor.PathEdit changeContentEdit =
+        new ChangeContent(filePath, newContent, repository, newGitFileMode);
     return Collections.singletonList(changeContentEdit);
   }
 
@@ -70,16 +81,32 @@
 
     private final RawInput newContent;
     private final Repository repository;
+    private final Integer newGitFileMode;
 
-    ChangeContent(String filePath, RawInput newContent, Repository repository) {
+    ChangeContent(
+        String filePath,
+        RawInput newContent,
+        Repository repository,
+        @Nullable Integer newGitFileMode) {
       super(filePath);
       this.newContent = newContent;
       this.repository = repository;
+      this.newGitFileMode = newGitFileMode;
+    }
+
+    private boolean isValidGitFileMode(int gitFileMode) {
+      return (gitFileMode == 100755) || (gitFileMode == 100644);
     }
 
     @Override
     public void apply(DirCacheEntry dirCacheEntry) {
       try {
+        if (newGitFileMode != null && newGitFileMode != 0) {
+          if (!isValidGitFileMode(newGitFileMode)) {
+            throw new IllegalStateException("GitFileMode " + newGitFileMode + " is invalid");
+          }
+          dirCacheEntry.setFileMode(FileMode.fromBits(newGitFileMode));
+        }
         if (dirCacheEntry.getFileMode() == FileMode.GITLINK) {
           dirCacheEntry.setLength(0);
           dirCacheEntry.setLastModified(Instant.EPOCH);
@@ -98,7 +125,7 @@
       } catch (IOException e) {
         String message =
             String.format("Could not change the content of %s", dirCacheEntry.getPathString());
-        logger.atSevere().withCause(e).log(message);
+        logger.atSevere().withCause(e).log("%s", message);
       } catch (InvalidObjectIdException e) {
         logger.atSevere().withCause(e).log("Invalid object id in submodule link");
       }
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index 4001a48..2697da5 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritInstanceId;
@@ -170,9 +169,8 @@
         return false;
       }
 
-      permissionBackend.user(user).project(project).check(ProjectPermission.ACCESS);
-      return true;
-    } catch (AuthException | PermissionBackendException e) {
+      return permissionBackend.user(user).project(project).test(ProjectPermission.ACCESS);
+    } catch (PermissionBackendException e) {
       return false;
     }
   }
@@ -185,15 +183,10 @@
     if (!pe.isPresent() || !pe.get().statePermitsRead()) {
       return false;
     }
-    try {
-      permissionBackend
-          .user(user)
-          .change(notesFactory.createChecked(change))
-          .check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return permissionBackend
+        .user(user)
+        .change(notesFactory.createChecked(change))
+        .test(ChangePermission.READ);
   }
 
   protected boolean isVisibleTo(BranchNameKey branchName, CurrentUser user)
@@ -203,12 +196,7 @@
       return false;
     }
 
-    try {
-      permissionBackend.user(user).ref(branchName).check(RefPermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return permissionBackend.user(user).ref(branchName).test(RefPermission.READ);
   }
 
   protected boolean isVisibleTo(Event event, CurrentUser user) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index a7fea3c..cd7e29a 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -36,6 +37,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountAttributeLoader;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
@@ -58,6 +60,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -120,7 +123,7 @@
     this.accountTemplateUtil = accountTemplateUtil;
   }
 
-  public ChangeAttribute asChangeAttribute(Change change) {
+  public ChangeAttribute asChangeAttribute(Change change, AccountAttributeLoader accountLoader) {
     ChangeAttribute a = new ChangeAttribute();
     a.project = change.getProject().get();
     a.branch = change.getDest().shortName();
@@ -128,17 +131,11 @@
     a.id = change.getKey().get();
     a.number = change.getId().get();
     a.subject = change.getSubject();
-    try {
-      a.commitMessage = changeDataFactory.create(change).commitMessage();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log(
-          "Error while getting full commit message for change %d", a.number);
-    }
     a.url = getChangeUrl(change);
-    a.owner = asAccountAttribute(change.getOwner());
-    a.assignee = asAccountAttribute(change.getAssignee());
+    a.owner = asAccountAttribute(change.getOwner(), accountLoader);
+    a.assignee = asAccountAttribute(change.getAssignee(), accountLoader);
     a.status = change.getStatus();
-    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.createdOn = change.getCreatedOn().getEpochSecond();
     a.wip = change.isWorkInProgress() ? true : null;
     a.isPrivate = change.isPrivate() ? true : null;
     a.cherryPickOfChange =
@@ -150,12 +147,9 @@
 
   /** Create a {@link ChangeAttribute} instance from the specified change. */
   public ChangeAttribute asChangeAttribute(Change change, ChangeNotes notes) {
-    ChangeAttribute a = asChangeAttribute(change);
-    Set<String> hashtags = notes.load().getHashtags();
-    if (!hashtags.isEmpty()) {
-      a.hashtags = new ArrayList<>(hashtags.size());
-      a.hashtags.addAll(hashtags);
-    }
+    ChangeAttribute a = asChangeAttribute(change, (AccountAttributeLoader) null);
+    addHashTags(a, notes);
+    addCommitMessage(a, notes);
     return a;
   }
   /**
@@ -174,30 +168,32 @@
 
   /** Extend the existing {@link ChangeAttribute} with additional fields. */
   public void extend(ChangeAttribute a, Change change) {
-    a.lastUpdated = change.getLastUpdatedOn().getTime() / 1000L;
+    a.lastUpdated = change.getLastUpdatedOn().getEpochSecond();
     a.open = change.isNew();
   }
 
   /** Add allReviewers to an existing {@link ChangeAttribute}. */
-  public void addAllReviewers(ChangeAttribute a, ChangeNotes notes) {
+  public void addAllReviewers(
+      ChangeAttribute a, ChangeNotes notes, AccountAttributeLoader accountLoader) {
     Collection<Account.Id> reviewers = approvalsUtil.getReviewers(notes).all();
     if (!reviewers.isEmpty()) {
       a.allReviewers = Lists.newArrayListWithCapacity(reviewers.size());
       for (Account.Id id : reviewers) {
-        a.allReviewers.add(asAccountAttribute(id));
+        a.allReviewers.add(asAccountAttribute(id, accountLoader));
       }
     }
   }
 
   /** Add submitRecords to an existing {@link ChangeAttribute}. */
-  public void addSubmitRecords(ChangeAttribute ca, List<SubmitRecord> submitRecords) {
+  public void addSubmitRecords(
+      ChangeAttribute ca, List<SubmitRecord> submitRecords, AccountAttributeLoader accountLoader) {
     ca.submitRecords = new ArrayList<>();
 
     for (SubmitRecord submitRecord : submitRecords) {
       SubmitRecordAttribute sa = new SubmitRecordAttribute();
       sa.status = submitRecord.status.name();
       if (submitRecord.status != SubmitRecord.Status.RULE_ERROR) {
-        addSubmitRecordLabels(submitRecord, sa);
+        addSubmitRecordLabels(submitRecord, sa, accountLoader);
         addSubmitRecordRequirements(submitRecord, sa);
       }
       ca.submitRecords.add(sa);
@@ -208,7 +204,8 @@
     }
   }
 
-  private void addSubmitRecordLabels(SubmitRecord submitRecord, SubmitRecordAttribute sa) {
+  private void addSubmitRecordLabels(
+      SubmitRecord submitRecord, SubmitRecordAttribute sa, AccountAttributeLoader accountLoader) {
     if (submitRecord.labels != null && !submitRecord.labels.isEmpty()) {
       sa.labels = new ArrayList<>();
       for (SubmitRecord.Label lbl : submitRecord.labels) {
@@ -216,7 +213,7 @@
         la.label = lbl.label;
         la.status = lbl.status.name();
         if (lbl.appliedBy != null) {
-          la.by = asAccountAttribute(lbl.appliedBy);
+          la.by = asAccountAttribute(lbl.appliedBy, accountLoader);
         }
         sa.labels.add(la);
       }
@@ -298,8 +295,8 @@
     // Find changes in the same related group as this patch set, having a patch
     // set whose parent matches this patch set's revision.
     for (ChangeData cd :
-        InternalChangeQuery.byProjectGroups(
-            queryProvider, indexConfig, change.getProject(), currentPs.groups())) {
+        InternalChangeQuery.byBranchGroups(
+            queryProvider, indexConfig, change.getDest(), currentPs.groups())) {
       PATCH_SETS:
       for (PatchSet ps : cd.patchSets()) {
         RevCommit commit = rw.parseCommit(ps.commitId());
@@ -351,13 +348,23 @@
     a.commitMessage = commitMessage;
   }
 
+  private void addCommitMessage(ChangeAttribute changeAttribute, ChangeNotes notes) {
+    try {
+      addCommitMessage(changeAttribute, changeDataFactory.create(notes).commitMessage());
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log(
+          "Error while getting full commit message for change %d", changeAttribute.number);
+    }
+  }
+
   public void addPatchSets(
       RevWalk revWalk,
       ChangeAttribute ca,
       Collection<PatchSet> ps,
       Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
-      LabelTypes labelTypes) {
-    addPatchSets(revWalk, ca, ps, approvals, false, null, labelTypes);
+      LabelTypes labelTypes,
+      AccountAttributeLoader accountLoader) {
+    addPatchSets(revWalk, ca, ps, approvals, false, null, labelTypes, accountLoader);
   }
 
   public void addPatchSets(
@@ -367,13 +374,14 @@
       Map<PatchSet.Id, Collection<PatchSetApproval>> approvals,
       boolean includeFiles,
       Change change,
-      LabelTypes labelTypes) {
+      LabelTypes labelTypes,
+      AccountAttributeLoader accountLoader) {
     if (!ps.isEmpty()) {
       ca.patchSets = new ArrayList<>(ps.size());
       for (PatchSet p : ps) {
-        PatchSetAttribute psa = asPatchSetAttribute(revWalk, change, p);
+        PatchSetAttribute psa = asPatchSetAttribute(revWalk, change, p, accountLoader);
         if (approvals != null) {
-          addApprovals(psa, p.id(), approvals, labelTypes);
+          addApprovals(psa, p.id(), approvals, labelTypes, accountLoader);
         }
         ca.patchSets.add(psa);
         if (includeFiles) {
@@ -384,13 +392,15 @@
   }
 
   public void addPatchSetComments(
-      PatchSetAttribute patchSetAttribute, Collection<HumanComment> comments) {
+      PatchSetAttribute patchSetAttribute,
+      Collection<HumanComment> comments,
+      AccountAttributeLoader accountLoader) {
     for (HumanComment comment : comments) {
       if (comment.key.patchSetId == patchSetAttribute.number) {
         if (patchSetAttribute.comments == null) {
           patchSetAttribute.comments = new ArrayList<>();
         }
-        patchSetAttribute.comments.add(asPatchSetLineAttribute(comment));
+        patchSetAttribute.comments.add(asPatchSetLineAttribute(comment, accountLoader));
       }
     }
   }
@@ -400,7 +410,7 @@
     try {
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
+              change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
 
       for (FileDiffOutput diff : modifiedFiles.values()) {
         if (patchSetAttribute.files == null) {
@@ -420,23 +430,31 @@
     }
   }
 
-  public void addComments(ChangeAttribute ca, Collection<ChangeMessage> messages) {
+  public void addComments(
+      ChangeAttribute ca,
+      Collection<ChangeMessage> messages,
+      AccountAttributeLoader accountLoader) {
     if (!messages.isEmpty()) {
       ca.comments = new ArrayList<>();
       for (ChangeMessage message : messages) {
-        ca.comments.add(asMessageAttribute(message));
+        ca.comments.add(asMessageAttribute(message, accountLoader));
       }
     }
   }
 
-  /** Create a PatchSetAttribute for the given patchset suitable for serialization to JSON. */
   public PatchSetAttribute asPatchSetAttribute(RevWalk revWalk, Change change, PatchSet patchSet) {
+    return asPatchSetAttribute(revWalk, change, patchSet, null);
+  }
+
+  /** Create a PatchSetAttribute for the given patchset suitable for serialization to JSON. */
+  public PatchSetAttribute asPatchSetAttribute(
+      RevWalk revWalk, Change change, PatchSet patchSet, AccountAttributeLoader accountLoader) {
     PatchSetAttribute p = new PatchSetAttribute();
     p.revision = patchSet.commitId().name();
     p.number = patchSet.number();
     p.ref = patchSet.refName();
-    p.uploader = asAccountAttribute(patchSet.uploader());
-    p.createdOn = patchSet.createdOn().getTime() / 1000L;
+    p.uploader = asAccountAttribute(patchSet.uploader(), accountLoader);
+    p.createdOn = patchSet.createdOn().getEpochSecond();
     PatchSet.Id pId = patchSet.id();
     try {
       p.parents = new ArrayList<>();
@@ -452,12 +470,12 @@
         p.author.name = author.getName();
         p.author.username = "";
       } else {
-        p.author = asAccountAttribute(author.getAccount());
+        p.author = asAccountAttribute(author.getAccount(), accountLoader);
       }
 
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
+              change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
       for (FileDiffOutput fileDiff : modifiedFiles.values()) {
         p.sizeDeletions += fileDiff.deletions();
         p.sizeInsertions += fileDiff.insertions();
@@ -475,20 +493,24 @@
       PatchSetAttribute p,
       PatchSet.Id id,
       Map<PatchSet.Id, Collection<PatchSetApproval>> all,
-      LabelTypes labelTypes) {
+      LabelTypes labelTypes,
+      AccountAttributeLoader accountLoader) {
     Collection<PatchSetApproval> list = all.get(id);
     if (list != null) {
-      addApprovals(p, list, labelTypes);
+      addApprovals(p, list, labelTypes, accountLoader);
     }
   }
 
   public void addApprovals(
-      PatchSetAttribute p, Collection<PatchSetApproval> list, LabelTypes labelTypes) {
+      PatchSetAttribute p,
+      Collection<PatchSetApproval> list,
+      LabelTypes labelTypes,
+      AccountAttributeLoader accountLoader) {
     if (!list.isEmpty()) {
       p.approvals = new ArrayList<>(list.size());
       for (PatchSetApproval a : list) {
         if (a.value() != 0) {
-          p.approvals.add(asApprovalAttribute(a, labelTypes));
+          p.approvals.add(asApprovalAttribute(a, labelTypes, accountLoader));
         }
       }
       if (p.approvals.isEmpty()) {
@@ -497,7 +519,12 @@
     }
   }
 
+  public AccountAttribute asAccountAttribute(Account.Id id, AccountAttributeLoader accountLoader) {
+    return accountLoader != null ? accountLoader.get(id) : asAccountAttribute(id);
+  }
+
   /** Create an AuthorAttribute for the given account suitable for serialization to JSON. */
+  @Nullable
   public AccountAttribute asAccountAttribute(Account.Id id) {
     if (id == null) {
       return null;
@@ -528,12 +555,13 @@
    * @param labelTypes label types for the containing project
    * @return object suitable for serialization to JSON
    */
-  public ApprovalAttribute asApprovalAttribute(PatchSetApproval approval, LabelTypes labelTypes) {
+  public ApprovalAttribute asApprovalAttribute(
+      PatchSetApproval approval, LabelTypes labelTypes, AccountAttributeLoader accountLoader) {
     ApprovalAttribute a = new ApprovalAttribute();
     a.type = approval.labelId().get();
     a.value = Short.toString(approval.value());
-    a.by = asAccountAttribute(approval.accountId());
-    a.grantedOn = approval.granted().getTime() / 1000L;
+    a.by = asAccountAttribute(approval.accountId(), accountLoader);
+    a.grantedOn = approval.granted().getEpochSecond();
     a.oldValue = null;
 
     Optional<LabelType> lt = labelTypes.byLabel(approval.labelId());
@@ -541,20 +569,22 @@
     return a;
   }
 
-  public MessageAttribute asMessageAttribute(ChangeMessage message) {
+  public MessageAttribute asMessageAttribute(
+      ChangeMessage message, AccountAttributeLoader accountLoader) {
     MessageAttribute a = new MessageAttribute();
-    a.timestamp = message.getWrittenOn().getTime() / 1000L;
+    a.timestamp = message.getWrittenOn().getEpochSecond();
     a.reviewer =
         message.getAuthor() != null
-            ? asAccountAttribute(message.getAuthor())
+            ? asAccountAttribute(message.getAuthor(), accountLoader)
             : asAccountAttribute(myIdent.get());
     a.message = accountTemplateUtil.replaceTemplates(message.getMessage());
     return a;
   }
 
-  public PatchSetCommentAttribute asPatchSetLineAttribute(HumanComment c) {
+  public PatchSetCommentAttribute asPatchSetLineAttribute(
+      HumanComment c, AccountAttributeLoader accountLoader) {
     PatchSetCommentAttribute a = new PatchSetCommentAttribute();
-    a.reviewer = asAccountAttribute(c.author.getId());
+    a.reviewer = asAccountAttribute(c.author.getId(), accountLoader);
     a.file = c.key.filename;
     a.line = c.lineNbr;
     a.message = c.message;
@@ -562,10 +592,19 @@
   }
 
   /** Get a link to the change; null if the server doesn't know its own address. */
+  @Nullable
   private String getChangeUrl(Change change) {
     if (change != null) {
       return urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId()).orElse(null);
     }
     return null;
   }
+
+  private void addHashTags(ChangeAttribute changeAttribute, ChangeNotes notes) {
+    Set<String> hashtags = notes.load().getHashtags();
+    if (!hashtags.isEmpty()) {
+      changeAttribute.hashtags = new ArrayList<>(hashtags.size());
+      changeAttribute.hashtags.addAll(hashtags);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
index 27be2f3..bd784cf 100644
--- a/java/com/google/gerrit/server/events/EventGsonProvider.java
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -29,8 +29,8 @@
         .registerTypeAdapter(Event.class, new EventDeserializer())
         .registerTypeAdapter(Supplier.class, new SupplierSerializer())
         .registerTypeAdapter(Supplier.class, new SupplierDeserializer())
-        .registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
         .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
+        .registerTypeHierarchyAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
         .create();
   }
 }
diff --git a/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
index 5498ec8..229ef86 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.events;
 
+import com.google.common.collect.ImmutableMap;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -61,4 +62,15 @@
   public static Class<?> getClass(String type) {
     return typesByString.get(type);
   }
+
+  /**
+   * Get a copy of all currently registered events.
+   *
+   * <p>The key is the one given to the evenType parameter of the {@link #register} method.
+   *
+   * @return ImmutableMap of event types, Event classes.
+   */
+  public static Map<String, Class<?>> getRegisteredEvents() {
+    return ImmutableMap.copyOf(typesByString);
+  }
 }
diff --git a/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java b/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java
new file mode 100644
index 0000000..90ed285
--- /dev/null
+++ b/java/com/google/gerrit/server/events/ProjectHeadUpdatedEvent.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+
+public class ProjectHeadUpdatedEvent extends ProjectEvent {
+
+  static final String TYPE = "project-head-updated";
+
+  public String projectName;
+  public String oldHead;
+  public String newHead;
+
+  public ProjectHeadUpdatedEvent() {
+    super(TYPE);
+  }
+
+  @Override
+  public NameKey getProjectNameKey() {
+    return Project.nameKey(projectName);
+  }
+}
diff --git a/java/com/google/gerrit/server/events/RefReceivedEvent.java b/java/com/google/gerrit/server/events/RefReceivedEvent.java
index 18783aa..84e57dc 100644
--- a/java/com/google/gerrit/server/events/RefReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/RefReceivedEvent.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 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 org.eclipse.jgit.transport.ReceiveCommand;
@@ -22,6 +23,7 @@
   public ReceiveCommand command;
   public Project project;
   public IdentifiedUser user;
+  public ImmutableListMultimap<String, String> pushOptions;
 
   public RefReceivedEvent() {
     super(TYPE);
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index abacb85..18f3d7a 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -20,6 +20,7 @@
 import com.google.common.base.Suppliers;
 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.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -39,6 +40,7 @@
 import com.google.gerrit.extensions.events.CommentAddedListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
+import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
@@ -86,7 +88,8 @@
         ReviewerDeletedListener,
         RevisionCreatedListener,
         TopicEditedListener,
-        VoteDeletedListener {
+        VoteDeletedListener,
+        HeadUpdatedListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class StreamEventsApiListenerModule extends AbstractModule {
@@ -111,6 +114,7 @@
       DynamicSet.bind(binder(), VoteDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), WorkInProgressStateChangedListener.class)
           .to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), HeadUpdatedListener.class).to(StreamEventsApiListener.class);
     }
   }
 
@@ -231,6 +235,7 @@
         });
   }
 
+  @Nullable
   String[] hashtagArray(Collection<String> hashtags) {
     if (hashtags != null && !hashtags.isEmpty()) {
       return Sets.newHashSet(hashtags).toArray(new String[hashtags.size()]);
@@ -339,6 +344,16 @@
   }
 
   @Override
+  public void onHeadUpdated(HeadUpdatedListener.Event ev) {
+    ProjectHeadUpdatedEvent event = new ProjectHeadUpdatedEvent();
+    event.projectName = ev.getProjectName();
+    event.oldHead = ev.getOldHeadName();
+    event.newHead = ev.getNewHeadName();
+
+    dispatcher.run(d -> d.postEvent(event.getProjectNameKey(), event));
+  }
+
+  @Override
   public void onHashtagsEdited(HashtagsEditedListener.Event ev) {
     try {
       ChangeNotes notes = getNotes(ev.getChange());
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index 65f8f2d..5a4580c 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -22,37 +22,12 @@
   /** Features that are known experiments and can be referenced in the code. */
   public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
 
-  public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
-      "GerritBackendRequestFeature__remove_revision_etag";
+  public static String UI_FEATURE_SUBMIT_REQUIREMENTS_UI = "UiFeature__submit_requirements_ui";
 
-  /** Enable storing submit requirements in NoteDb when the change is merged. */
-  public static final String GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE =
-      "GerritBackendRequestFeature__store_submit_requirements_on_merge";
-
-  /**
-   * Allow legacy {@link com.google.gerrit.entities.SubmitRecord}s to be converted and returned as
-   * submit requirements by the {@link
-   * com.google.gerrit.server.project.SubmitRequirementsEvaluator}.
-   */
-  public static final String GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS =
-      "GerritBackendRequestFeature__enable_submit_requirements";
-
-  /**
-   * Allow SubmitRequirements to be computed freshly on dashboards irrespective of the value we
-   * retrieved from the change index.
-   */
-  public static final String
-      GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD =
-          "GerritBackendRequestFeature__enable_submit_requirements_backfilling_on_dashboard";
-
-  /**
-   * When set, we compute information from All-Users repository if able, instead of computing it
-   * from the change index.
-   */
-  public static final String GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY =
-      "GerritBackendRequestFeature__compute_from_all_users_repository";
+  public static String GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION =
+      "GerritBackendFeature__attach_nonce_to_documentation";
 
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
-      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS);
+      ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS, UI_FEATURE_SUBMIT_REQUIREMENTS_UI);
 }
diff --git a/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
index b7ee043..fde4088 100644
--- a/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/AbstractChangeEvent.java
@@ -18,17 +18,17 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.ChangeEvent;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Base class for all change events. */
 public abstract class AbstractChangeEvent implements ChangeEvent {
   private final ChangeInfo changeInfo;
   private final AccountInfo who;
-  private final Timestamp when;
+  private final Instant when;
   private final NotifyHandling notify;
 
   protected AbstractChangeEvent(
-      ChangeInfo change, AccountInfo who, Timestamp when, NotifyHandling notify) {
+      ChangeInfo change, AccountInfo who, Instant when, NotifyHandling notify) {
     this.changeInfo = change;
     this.who = who;
     this.when = when;
@@ -46,7 +46,7 @@
   }
 
   @Override
-  public Timestamp getWhen() {
+  public Instant getWhen() {
     return when;
   }
 
diff --git a/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java b/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
index 9d4d299..421a5ad 100644
--- a/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
+++ b/java/com/google/gerrit/server/extensions/events/AbstractRevisionEvent.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.RevisionEvent;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Base class for all revision events. */
 public abstract class AbstractRevisionEvent extends AbstractChangeEvent implements RevisionEvent {
@@ -30,7 +30,7 @@
       ChangeInfo change,
       RevisionInfo revision,
       AccountInfo who,
-      Timestamp when,
+      Instant when,
       NotifyHandling notify) {
     super(change, who, when, notify);
     revisionInfo = revision;
diff --git a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
index e31a1b5..8e4d1e2 100644
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a user has been set as assignee on a change. */
 @Singleton
@@ -42,7 +42,7 @@
   }
 
   public void fire(
-      ChangeData changeData, AccountState accountState, AccountState oldAssignee, Timestamp when) {
+      ChangeData changeData, AccountState accountState, AccountState oldAssignee, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -63,7 +63,7 @@
   private static class Event extends AbstractChangeEvent implements AssigneeChangedListener.Event {
     private final AccountInfo oldAssignee;
 
-    Event(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee, Timestamp when) {
+    Event(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee, Instant when) {
       super(change, editor, when, NotifyHandling.ALL);
       this.oldAssignee = oldAssignee;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
new file mode 100644
index 0000000..27e0a5e
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/AttentionSetObserver.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.AttentionSetListener;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Helper class to fire an event when an attention set changes. */
+@Singleton
+public class AttentionSetObserver {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PluginSetContext<AttentionSetListener> listeners;
+  private final EventUtil util;
+  private final AccountCache accountCache;
+
+  public static final AttentionSetObserver DISABLED =
+      new AttentionSetObserver() {
+        @Override
+        public void fire(
+            ChangeData changeData,
+            AccountState accountState,
+            AttentionSetUpdate update,
+            Instant when) {}
+      };
+
+  @Inject
+  AttentionSetObserver(
+      PluginSetContext<AttentionSetListener> listeners, EventUtil util, AccountCache accountCache) {
+    this.listeners = listeners;
+    this.util = util;
+    this.accountCache = accountCache;
+  }
+
+  /** Constructor only for DISABLED version of the AttentionSetObserver. */
+  private AttentionSetObserver() {
+    this.listeners = null;
+    this.util = null;
+    this.accountCache = null;
+  }
+
+  /**
+   * Notify all listening plugins
+   *
+   * @param changeData is current data of the change
+   * @param accountState is the initiator of the change
+   * @param update is the update that caused the event
+   * @param when is the time of the event
+   */
+  public void fire(
+      ChangeData changeData, AccountState accountState, AttentionSetUpdate update, Instant when) {
+    if (listeners.isEmpty()) {
+      return;
+    }
+    AccountState target = accountCache.get(update.account()).get();
+
+    HashSet<Integer> added = new HashSet<>();
+    HashSet<Integer> removed = new HashSet<>();
+    switch (update.operation()) {
+      case ADD:
+        added.add(target.account().id().get());
+        break;
+      case REMOVE:
+        removed.add(target.account().id().get());
+        break;
+    }
+
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(changeData), util.accountInfo(accountState), added, removed, when);
+      listeners.runEach(l -> l.onAttentionSetChanged(event));
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Exception while firing AttentionSet changed event");
+    }
+  }
+
+  /** Event to be fired when an attention set changes */
+  public static class Event extends AbstractChangeEvent implements AttentionSetListener.Event {
+    private final Set<Integer> added;
+    private final Set<Integer> removed;
+
+    public Event(
+        ChangeInfo change,
+        AccountInfo editor,
+        Set<Integer> added,
+        Set<Integer> removed,
+        Instant when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.added = added;
+      this.removed = removed;
+    }
+
+    @Override
+    public Set<Integer> usersAdded() {
+      return added;
+    }
+
+    @Override
+    public Set<Integer> usersRemoved() {
+      return removed;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index cbe7c6b..ca1a742 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -32,7 +32,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been abandoned. */
 @Singleton
@@ -53,7 +53,7 @@
       PatchSet ps,
       AccountState abandoner,
       String reason,
-      Timestamp when,
+      Instant when,
       NotifyHandling notifyHandling) {
     if (listeners.isEmpty()) {
       return;
@@ -89,7 +89,7 @@
         RevisionInfo revision,
         AccountInfo abandoner,
         String reason,
-        Timestamp when,
+        Instant when,
         NotifyHandling notifyHandling) {
       super(change, revision, abandoner, when, notifyHandling);
       this.reason = reason;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
index 23a4583..acca491 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been deleted. */
 @Singleton
@@ -41,7 +41,7 @@
     this.util = util;
   }
 
-  public void fire(ChangeData changeData, AccountState deleter, Timestamp when) {
+  public void fire(ChangeData changeData, AccountState deleter, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -55,7 +55,7 @@
 
   /** Event to be fired when a change has been deleted. */
   private static class Event extends AbstractChangeEvent implements ChangeDeletedListener.Event {
-    Event(ChangeInfo change, AccountInfo deleter, Timestamp when) {
+    Event(ChangeInfo change, AccountInfo deleter, Instant when) {
       super(change, deleter, when, NotifyHandling.ALL);
     }
   }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index e4896df..870d850 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -32,7 +32,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been merged. */
 @Singleton
@@ -49,11 +49,7 @@
   }
 
   public void fire(
-      ChangeData changeData,
-      PatchSet ps,
-      AccountState merger,
-      String newRevisionId,
-      Timestamp when) {
+      ChangeData changeData, PatchSet ps, AccountState merger, String newRevisionId, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -86,7 +82,7 @@
         RevisionInfo revision,
         AccountInfo merger,
         String newRevisionId,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, merger, when, NotifyHandling.ALL);
       this.newRevisionId = newRevisionId;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 8bd222a..c71360b 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -32,7 +32,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been restored. */
 @Singleton
@@ -49,7 +49,7 @@
   }
 
   public void fire(
-      ChangeData changeData, PatchSet ps, AccountState restorer, String reason, Timestamp when) {
+      ChangeData changeData, PatchSet ps, AccountState restorer, String reason, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -83,7 +83,7 @@
         RevisionInfo revision,
         AccountInfo restorer,
         String reason,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, restorer, when, NotifyHandling.ALL);
       this.reason = reason;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index 4a46eb0..1abbebb 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a change has been reverted. */
 @Singleton
@@ -39,7 +39,7 @@
     this.util = util;
   }
 
-  public void fire(ChangeData changeData, ChangeData revertChangeData, Timestamp when) {
+  public void fire(ChangeData changeData, ChangeData revertChangeData, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -55,7 +55,7 @@
   private static class Event extends AbstractChangeEvent implements ChangeRevertedListener.Event {
     private final ChangeInfo revertChange;
 
-    Event(ChangeInfo change, ChangeInfo revertChange, Timestamp when) {
+    Event(ChangeInfo change, ChangeInfo revertChange, Instant when) {
       super(change, revertChange.owner, when, NotifyHandling.ALL);
       this.revertChange = revertChange;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 20c54cf..79544f2 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -33,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Map;
 
 /** Helper class to fire an event when a comment or vote has been added to a change. */
@@ -57,7 +57,7 @@
       String comment,
       Map<String, Short> approvals,
       Map<String, Short> oldApprovals,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -97,7 +97,7 @@
         String comment,
         Map<String, ApprovalInfo> approvals,
         Map<String, ApprovalInfo> oldApprovals,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, author, when, NotifyHandling.ALL);
       this.comment = comment;
       this.approvals = approvals;
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index f0d038a..7c8777f 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -17,6 +17,7 @@
 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.entities.Account;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -36,7 +37,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
@@ -98,7 +99,8 @@
     return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
   }
 
-  public AccountInfo accountInfo(AccountState accountState) {
+  @Nullable
+  public AccountInfo accountInfo(@Nullable AccountState accountState) {
     if (accountState == null || accountState.account().id() == null) {
       return null;
     }
@@ -111,7 +113,7 @@
   }
 
   public Map<String, ApprovalInfo> approvals(
-      AccountState accountState, Map<String, Short> approvals, Timestamp ts) {
+      AccountState accountState, Map<String, Short> approvals, Instant ts) {
     Map<String, ApprovalInfo> result = new HashMap<>();
     for (Map.Entry<String, Short> e : approvals.entrySet()) {
       Integer value = e.getValue() != null ? Integer.valueOf(e.getValue()) : null;
diff --git a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 6ed0a08..814390b 100644
--- a/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -17,11 +17,15 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -58,17 +62,23 @@
             Project.NameKey project, BatchRefUpdate batchRefUpdate, AccountState updater) {}
       };
 
-  private final PluginSetContext<GitReferenceUpdatedListener> listeners;
+  private final PluginSetContext<GitBatchRefUpdateListener> batchRefUpdateListeners;
+  private final PluginSetContext<GitReferenceUpdatedListener> refUpdatedListeners;
   private final EventUtil util;
 
   @Inject
-  GitReferenceUpdated(PluginSetContext<GitReferenceUpdatedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
+  GitReferenceUpdated(
+      PluginSetContext<GitBatchRefUpdateListener> batchRefUpdateListeners,
+      PluginSetContext<GitReferenceUpdatedListener> refUpdatedListeners,
+      EventUtil util) {
+    this.batchRefUpdateListeners = batchRefUpdateListeners;
+    this.refUpdatedListeners = refUpdatedListeners;
     this.util = util;
   }
 
   private GitReferenceUpdated() {
-    this.listeners = null;
+    this.batchRefUpdateListeners = null;
+    this.refUpdatedListeners = null;
     this.util = null;
   }
 
@@ -79,20 +89,19 @@
       AccountState updater) {
     fire(
         project,
-        refUpdate.getName(),
-        refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(),
-        type,
+        new UpdatedRef(
+            refUpdate.getName(), refUpdate.getOldObjectId(), refUpdate.getNewObjectId(), type),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, RefUpdate refUpdate, AccountState updater) {
     fire(
         project,
-        refUpdate.getName(),
-        refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(),
-        ReceiveCommand.Type.UPDATE,
+        new UpdatedRef(
+            refUpdate.getName(),
+            refUpdate.getOldObjectId(),
+            refUpdate.getNewObjectId(),
+            ReceiveCommand.Type.UPDATE),
         util.accountInfo(updater));
   }
 
@@ -104,83 +113,80 @@
       AccountState updater) {
     fire(
         project,
-        ref,
-        oldObjectId,
-        newObjectId,
-        ReceiveCommand.Type.UPDATE,
+        new UpdatedRef(ref, oldObjectId, newObjectId, ReceiveCommand.Type.UPDATE),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, ReceiveCommand cmd, AccountState updater) {
     fire(
         project,
-        cmd.getRefName(),
-        cmd.getOldId(),
-        cmd.getNewId(),
-        cmd.getType(),
+        new UpdatedRef(cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType()),
         util.accountInfo(updater));
   }
 
   public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate, AccountState updater) {
-    if (listeners.isEmpty()) {
+    if (batchRefUpdateListeners.isEmpty() && refUpdatedListeners.isEmpty()) {
       return;
     }
+    Set<GitBatchRefUpdateListener.UpdatedRef> updates = new HashSet<>();
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
       if (cmd.getResult() == ReceiveCommand.Result.OK) {
-        fire(
-            project,
-            cmd.getRefName(),
-            cmd.getOldId(),
-            cmd.getNewId(),
-            cmd.getType(),
-            util.accountInfo(updater));
+        updates.add(
+            new UpdatedRef(cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType()));
       }
     }
+    fireBatchRefUpdateEvent(project, updates, util.accountInfo(updater));
+    fireRefUpdatedEvents(project, updates, util.accountInfo(updater));
   }
 
-  private void fire(
+  private void fire(Project.NameKey project, UpdatedRef updatedRef, AccountInfo updater) {
+    fireBatchRefUpdateEvent(project, Set.of(updatedRef), updater);
+    fireRefUpdatedEvent(project, updatedRef, updater);
+  }
+
+  private void fireBatchRefUpdateEvent(
       Project.NameKey project,
-      String ref,
-      ObjectId oldObjectId,
-      ObjectId newObjectId,
-      ReceiveCommand.Type type,
+      Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
       AccountInfo updater) {
-    if (listeners.isEmpty()) {
+    if (batchRefUpdateListeners.isEmpty()) {
       return;
     }
-    ObjectId o = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
-    ObjectId n = newObjectId != null ? newObjectId : ObjectId.zeroId();
-    Event event = new Event(project, ref, o.name(), n.name(), type, updater);
-    listeners.runEach(l -> l.onGitReferenceUpdated(event));
+    GitBatchRefUpdateEvent event = new GitBatchRefUpdateEvent(project, updatedRefs, updater);
+    batchRefUpdateListeners.runEach(l -> l.onGitBatchRefUpdate(event));
   }
 
-  /** Event to be fired when a Git reference has been updated. */
-  public static class Event implements GitReferenceUpdatedListener.Event {
-    private final String projectName;
-    private final String ref;
-    private final String oldObjectId;
-    private final String newObjectId;
-    private final ReceiveCommand.Type type;
-    private final AccountInfo updater;
-
-    Event(
-        Project.NameKey project,
-        String ref,
-        String oldObjectId,
-        String newObjectId,
-        ReceiveCommand.Type type,
-        AccountInfo updater) {
-      this.projectName = project.get();
-      this.ref = ref;
-      this.oldObjectId = oldObjectId;
-      this.newObjectId = newObjectId;
-      this.type = type;
-      this.updater = updater;
+  private void fireRefUpdatedEvents(
+      Project.NameKey project,
+      Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
+      AccountInfo updater) {
+    for (GitBatchRefUpdateListener.UpdatedRef updatedRef : updatedRefs) {
+      fireRefUpdatedEvent(project, updatedRef, updater);
     }
+  }
 
-    @Override
-    public String getProjectName() {
-      return projectName;
+  private void fireRefUpdatedEvent(
+      Project.NameKey project,
+      GitBatchRefUpdateListener.UpdatedRef updatedRef,
+      AccountInfo updater) {
+    if (refUpdatedListeners.isEmpty()) {
+      return;
+    }
+    GitReferenceUpdatedEvent event = new GitReferenceUpdatedEvent(project, updatedRef, updater);
+    refUpdatedListeners.runEach(l -> l.onGitReferenceUpdated(event));
+  }
+
+  public static class UpdatedRef implements GitBatchRefUpdateListener.UpdatedRef {
+    private final String ref;
+    private final ObjectId oldObjectId;
+    private final ObjectId newObjectId;
+    private final ReceiveCommand.Type type;
+
+    public UpdatedRef(
+        String ref, ObjectId oldObjectId, ObjectId newObjectId, ReceiveCommand.Type type) {
+      this.ref = ref;
+      this.oldObjectId = oldObjectId != null ? oldObjectId : ObjectId.zeroId();
+      this.newObjectId = newObjectId != null ? newObjectId : ObjectId.zeroId();
+      this.type = type;
     }
 
     @Override
@@ -190,12 +196,12 @@
 
     @Override
     public String getOldObjectId() {
-      return oldObjectId;
+      return oldObjectId.name();
     }
 
     @Override
     public String getNewObjectId() {
-      return newObjectId;
+      return newObjectId.name();
     }
 
     @Override
@@ -214,15 +220,51 @@
     }
 
     @Override
+    public String toString() {
+      return String.format("{%s: %s -> %s}", ref, oldObjectId, newObjectId);
+    }
+  }
+
+  /** Event to be fired when a Git reference has been updated. */
+  public static class GitBatchRefUpdateEvent implements GitBatchRefUpdateListener.Event {
+    private final String projectName;
+    private final Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs;
+    private final AccountInfo updater;
+
+    public GitBatchRefUpdateEvent(
+        Project.NameKey project,
+        Set<GitBatchRefUpdateListener.UpdatedRef> updatedRefs,
+        AccountInfo updater) {
+      this.projectName = project.get();
+      this.updatedRefs = updatedRefs;
+      this.updater = updater;
+    }
+
+    @Override
+    public String getProjectName() {
+      return projectName;
+    }
+
+    @Override
+    public Set<GitBatchRefUpdateListener.UpdatedRef> getUpdatedRefs() {
+      return updatedRefs;
+    }
+
+    @Override
+    public Set<String> getRefNames() {
+      return updatedRefs.stream()
+          .map(GitBatchRefUpdateListener.UpdatedRef::getRefName)
+          .collect(Collectors.toSet());
+    }
+
+    @Override
     public AccountInfo getUpdater() {
       return updater;
     }
 
     @Override
     public String toString() {
-      return String.format(
-          "%s[%s,%s: %s -> %s]",
-          getClass().getSimpleName(), projectName, ref, oldObjectId, newObjectId);
+      return String.format("%s[%s,%s]", getClass().getSimpleName(), projectName, updatedRefs);
     }
 
     @Override
@@ -230,4 +272,65 @@
       return NotifyHandling.ALL;
     }
   }
+
+  public static class GitReferenceUpdatedEvent implements GitReferenceUpdatedListener.Event {
+
+    private final String projectName;
+    private final GitBatchRefUpdateListener.UpdatedRef updatedRef;
+    private final AccountInfo updater;
+
+    public GitReferenceUpdatedEvent(
+        Project.NameKey project,
+        GitBatchRefUpdateListener.UpdatedRef updatedRef,
+        AccountInfo updater) {
+      this.projectName = project.get();
+      this.updatedRef = updatedRef;
+      this.updater = updater;
+    }
+
+    @Override
+    public String getProjectName() {
+      return projectName;
+    }
+
+    @Override
+    public NotifyHandling getNotify() {
+      return NotifyHandling.ALL;
+    }
+
+    @Override
+    public String getRefName() {
+      return updatedRef.getRefName();
+    }
+
+    @Override
+    public String getOldObjectId() {
+      return updatedRef.getOldObjectId();
+    }
+
+    @Override
+    public String getNewObjectId() {
+      return updatedRef.getNewObjectId();
+    }
+
+    @Override
+    public boolean isCreate() {
+      return updatedRef.isCreate();
+    }
+
+    @Override
+    public boolean isDelete() {
+      return updatedRef.isDelete();
+    }
+
+    @Override
+    public boolean isNonFastForward() {
+      return updatedRef.isNonFastForward();
+    }
+
+    @Override
+    public AccountInfo getUpdater() {
+      return updater;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index 846257c..e7903a2 100644
--- a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collection;
 import java.util.Set;
 
@@ -50,7 +50,7 @@
       ImmutableSortedSet<String> hashtags,
       Set<String> added,
       Set<String> removed,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -82,7 +82,7 @@
         Collection<String> updated,
         Collection<String> added,
         Collection<String> removed,
-        Timestamp when) {
+        Instant when) {
       super(change, editor, when, NotifyHandling.ALL);
       this.updatedHashtags = updated;
       this.addedHashtags = added;
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index d81068c..c6076fd 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -31,7 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when the private flag of a change has been toggled. */
 @Singleton
@@ -47,7 +47,7 @@
     this.util = util;
   }
 
-  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -72,7 +72,7 @@
   private static class Event extends AbstractRevisionEvent
       implements PrivateStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Instant when) {
       super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index ba73ca1..147e372 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -33,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.List;
 
 /** Helper class to fire an event when reviewers have been added to a change. */
@@ -55,7 +55,7 @@
       PatchSet patchSet,
       List<AccountState> reviewers,
       AccountState adder,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty() || reviewers.isEmpty()) {
       return;
     }
@@ -89,7 +89,7 @@
         RevisionInfo revision,
         List<AccountInfo> reviewers,
         AccountInfo adder,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, adder, when, NotifyHandling.ALL);
       this.reviewers = reviewers;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 80037bc..5f9179a 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -33,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Map;
 
 /** Helper class to fire an event when a reviewer has been deleted from a change. */
@@ -59,7 +59,7 @@
       Map<String, Short> newApprovals,
       Map<String, Short> oldApprovals,
       NotifyHandling notify,
-      Timestamp when) {
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -104,7 +104,7 @@
         Map<String, ApprovalInfo> newApprovals,
         Map<String, ApprovalInfo> oldApprovals,
         NotifyHandling notify,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, remover, when, notify);
       this.reviewer = reviewer;
       this.comment = comment;
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 4c78216..a60d982 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -33,7 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when a revision has been created for a change. */
 @Singleton
@@ -47,7 +47,7 @@
             ChangeData changeData,
             PatchSet patchSet,
             AccountState uploader,
-            Timestamp when,
+            Instant when,
             NotifyResolver.Result notify) {}
       };
 
@@ -69,7 +69,7 @@
       ChangeData changeData,
       PatchSet patchSet,
       AccountState uploader,
-      Timestamp when,
+      Instant when,
       NotifyResolver.Result notify) {
     if (listeners.isEmpty()) {
       return;
@@ -102,7 +102,7 @@
         ChangeInfo change,
         RevisionInfo revision,
         AccountInfo uploader,
-        Timestamp when,
+        Instant when,
         NotifyHandling notify) {
       super(change, revision, uploader, when, notify);
     }
diff --git a/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index 08b47f1..008ead5 100644
--- a/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when the topic of a change has been edited. */
 @Singleton
@@ -41,8 +41,7 @@
     this.util = util;
   }
 
-  public void fire(
-      ChangeData changeData, AccountState account, String oldTopicName, Timestamp when) {
+  public void fire(ChangeData changeData, AccountState account, String oldTopicName, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -59,7 +58,7 @@
   private static class Event extends AbstractChangeEvent implements TopicEditedListener.Event {
     private final String oldTopic;
 
-    Event(ChangeInfo change, AccountInfo editor, String oldTopic, Timestamp when) {
+    Event(ChangeInfo change, AccountInfo editor, String oldTopic, Instant when) {
       super(change, editor, when, NotifyHandling.ALL);
       this.oldTopic = oldTopic;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index 244e46c..d127260 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -33,7 +34,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Map;
 
 /** Helper class to fire an event when a vote has been deleted from a change. */
@@ -58,8 +59,8 @@
       Map<String, Short> oldApprovals,
       NotifyHandling notify,
       String message,
-      AccountState remover,
-      Timestamp when) {
+      @Nullable AccountState remover,
+      Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -69,8 +70,8 @@
               util.changeInfo(changeData),
               util.revisionInfo(changeData.project(), ps),
               util.accountInfo(reviewer),
-              util.approvals(remover, approvals, when),
-              util.approvals(remover, oldApprovals, when),
+              util.approvals(reviewer, approvals, when),
+              util.approvals(reviewer, oldApprovals, when),
               notify,
               message,
               util.accountInfo(remover),
@@ -103,7 +104,7 @@
         NotifyHandling notify,
         String message,
         AccountInfo remover,
-        Timestamp when) {
+        Instant when) {
       super(change, revision, remover, when, notify);
       this.reviewer = reviewer;
       this.approvals = approvals;
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index bfc068d..5e20c45 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -31,7 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 /** Helper class to fire an event when the work-in-progress state of a change has been toggled. */
 @Singleton
@@ -42,7 +42,7 @@
       new WorkInProgressStateChanged() {
         @Override
         public void fire(
-            ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {}
+            ChangeData changeData, PatchSet patchSet, AccountState account, Instant when) {}
       };
 
   private final PluginSetContext<WorkInProgressStateChangedListener> listeners;
@@ -60,7 +60,7 @@
     this.util = null;
   }
 
-  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Instant when) {
     if (listeners.isEmpty()) {
       return;
     }
@@ -85,7 +85,7 @@
   private static class Event extends AbstractRevisionEvent
       implements WorkInProgressStateChangedListener.Event {
 
-    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Instant when) {
       super(change, revision, who, when, NotifyHandling.ALL);
     }
   }
diff --git a/java/com/google/gerrit/server/fixes/FixCalculator.java b/java/com/google/gerrit/server/fixes/FixCalculator.java
index 9ea628e..df20fbf 100644
--- a/java/com/google/gerrit/server/fixes/FixCalculator.java
+++ b/java/com/google/gerrit/server/fixes/FixCalculator.java
@@ -355,6 +355,11 @@
     }
 
     void processLineToColumn(int to, boolean append) throws IndexOutOfBoundsException {
+      int from = srcPosition.column;
+      if (from > to) {
+        throw new IndexOutOfBoundsException(
+            String.format("The parameter from is greater than to. from: %d, to: %d", from, to));
+      }
       if (to == 0) {
         return;
       }
@@ -366,7 +371,6 @@
           throw new IndexOutOfBoundsException("The processLineToColumn shouldn't add end of line");
         }
       }
-      int from = srcPosition.column;
       int charCount = to - from;
       srcPosition.appendStringWithoutEOLMark(charCount);
       if (append) {
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index 242c11b..e27197c 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -30,9 +30,9 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Constants;
@@ -78,7 +78,7 @@
 
   private final Provider<IdentifiedUser> currentUser;
   private final GitRepositoryManager repoManager;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PermissionBackend permissionBackend;
   private final NotesBranchUtil.Factory notesBranchUtilFactory;
 
@@ -93,7 +93,7 @@
     this.repoManager = repoManager;
     this.notesBranchUtilFactory = notesBranchUtilFactory;
     this.permissionBackend = permissionBackend;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
   }
 
   /**
@@ -155,8 +155,7 @@
   }
 
   private PersonIdent createPersonIdent() {
-    Date now = new Date();
-    return currentUser.get().newCommitterIdent(now, tz);
+    return currentUser.get().newCommitterIdent(Instant.now(), zoneId);
   }
 
   private static String buildCommitMessage(List<ObjectId> bannedCommits, String reason) {
diff --git a/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java
index d7538ba..79df21a 100644
--- a/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -24,6 +24,9 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.Serializable;
 import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -35,7 +38,10 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Extended commit entity with code review specific metadata. */
-public class CodeReviewCommit extends RevCommit {
+public class CodeReviewCommit extends RevCommit implements Serializable {
+
+  private static final long serialVersionUID = 1L;
+
   /**
    * Default ordering when merging multiple topologically-equivalent commits.
    *
@@ -126,7 +132,7 @@
    * Message for the status that is returned to the calling user if the status indicates a problem
    * that prevents submit.
    */
-  private Optional<String> statusMessage = Optional.empty();
+  private transient Optional<String> statusMessage = Optional.empty();
 
   /** List of files in this commit that contain Git conflict markers. */
   private ImmutableSet<String> filesWithGitConflicts;
@@ -191,4 +197,22 @@
   public void setNotes(ChangeNotes notes) {
     this.notes = notes;
   }
+
+  /** Custom serialization due to {@link #statusMessage} not being Serializable by default. */
+  private void writeObject(ObjectOutputStream oos) throws IOException {
+    oos.defaultWriteObject();
+    if (this.statusMessage.isPresent()) {
+      oos.writeUTF(this.statusMessage.get());
+    }
+  }
+
+  /** Custom deserialization due to {@link #statusMessage} not being Serializable by default. */
+  private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException {
+    ois.defaultReadObject();
+    String statusMessage = null;
+    if (ois.available() > 0) {
+      statusMessage = ois.readUTF();
+    }
+    this.statusMessage = Optional.ofNullable(statusMessage);
+  }
 }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 73378f6..f0b2a78 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -25,10 +25,13 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -37,12 +40,15 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -53,18 +59,22 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.CommitBuilder;
 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;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -83,6 +93,7 @@
   private final NotifyResolver notifyResolver;
   private final RevertedSender.Factory revertedSenderFactory;
   private final ChangeMessagesUtil cmUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeReverted changeReverted;
   private final BatchUpdate.Factory updateFactory;
   private final MessageIdGenerator messageIdGenerator;
@@ -97,6 +108,7 @@
       NotifyResolver notifyResolver,
       RevertedSender.Factory revertedSenderFactory,
       ChangeMessagesUtil cmUtil,
+      ChangeNotes.Factory changeNotesFactory,
       ChangeReverted changeReverted,
       BatchUpdate.Factory updateFactory,
       MessageIdGenerator messageIdGenerator) {
@@ -108,6 +120,7 @@
     this.notifyResolver = notifyResolver;
     this.revertedSenderFactory = revertedSenderFactory;
     this.cmUtil = cmUtil;
+    this.changeNotesFactory = changeNotesFactory;
     this.changeReverted = changeReverted;
     this.updateFactory = updateFactory;
     this.messageIdGenerator = messageIdGenerator;
@@ -146,7 +159,7 @@
    * @return ObjectId that represents the newly created commit.
    */
   public Change.Id createRevertChange(
-      ChangeNotes notes, CurrentUser user, RevertInput input, Timestamp timestamp)
+      ChangeNotes notes, CurrentUser user, RevertInput input, Instant timestamp)
       throws RestApiException, UpdateException, ConfigInvalidException, IOException {
     String message = Strings.emptyToNull(input.message);
 
@@ -174,7 +187,7 @@
    * @return ObjectId that represents the newly created commit.
    */
   public ObjectId createRevertCommit(
-      String message, ChangeNotes notes, CurrentUser user, Timestamp ts)
+      String message, ChangeNotes notes, CurrentUser user, Instant ts)
       throws RestApiException, IOException {
 
     try (Repository git = repoManager.openRepository(notes.getProjectName());
@@ -188,6 +201,41 @@
   }
 
   /**
+   * Creates a commit with the specified tree ID.
+   *
+   * @param oi ObjectInserter for inserting the newly created commit.
+   * @param authorIdent of the new commit
+   * @param committerIdent of the new commit
+   * @param parentCommit of the new commit. Can be null.
+   * @param commitMessage for the new commit.
+   * @param treeId of the content for the new commit.
+   * @return the newly created commit.
+   * @throws IOException if fails to insert the commit.
+   */
+  public static ObjectId createCommitWithTree(
+      ObjectInserter oi,
+      PersonIdent authorIdent,
+      PersonIdent committerIdent,
+      @Nullable RevCommit parentCommit,
+      String commitMessage,
+      ObjectId treeId)
+      throws IOException {
+    logger.atFine().log("Creating commit with tree: %s", treeId.getName());
+    CommitBuilder commit = new CommitBuilder();
+    commit.setTreeId(treeId);
+    if (parentCommit != null) {
+      commit.setParentId(parentCommit);
+    }
+    commit.setAuthor(authorIdent);
+    commit.setCommitter(committerIdent);
+    commit.setMessage(commitMessage);
+
+    ObjectId id = oi.insert(commit);
+    oi.flush();
+    return id;
+  }
+
+  /**
    * Creates a revert commit.
    *
    * @param message Commit message for the revert commit.
@@ -206,7 +254,7 @@
       String message,
       ChangeNotes notes,
       CurrentUser user,
-      Timestamp ts,
+      Instant ts,
       ObjectInserter oi,
       RevWalk revWalk,
       @Nullable ObjectId generatedChangeId)
@@ -220,17 +268,11 @@
 
     PersonIdent committerIdent = serverIdent.get();
     PersonIdent authorIdent =
-        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getTimeZone());
+        user.asIdentifiedUser().newCommitterIdent(ts, committerIdent.getZoneId());
 
     RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
     revWalk.parseHeaders(parentToCommitToRevert);
 
-    CommitBuilder revertCommitBuilder = new CommitBuilder();
-    revertCommitBuilder.addParentId(commitToRevert);
-    revertCommitBuilder.setTreeId(parentToCommitToRevert.getTree());
-    revertCommitBuilder.setAuthor(authorIdent);
-    revertCommitBuilder.setCommitter(authorIdent);
-
     Change changeToRevert = notes.getChange();
     String subject = changeToRevert.getSubject();
     if (subject.length() > 63) {
@@ -242,11 +284,11 @@
               ChangeMessages.get().revertChangeDefaultMessage, subject, patch.commitId().name());
     }
     if (generatedChangeId != null) {
-      revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, generatedChangeId, true));
+      message = ChangeIdUtil.insertId(message, generatedChangeId, true);
     }
-    ObjectId id = oi.insert(revertCommitBuilder);
-    oi.flush();
-    return id;
+
+    return createCommitWithTree(
+        oi, authorIdent, committerIdent, commitToRevert, message, parentToCommitToRevert.getTree());
   }
 
   private Change.Id createRevertChangeFromCommit(
@@ -255,25 +297,27 @@
       ChangeNotes notes,
       CurrentUser user,
       @Nullable ObjectId generatedChangeId,
-      Timestamp ts,
+      Instant ts,
       ObjectInserter oi,
       RevWalk revWalk,
       Repository git)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException {
     RevCommit revertCommit = revWalk.parseCommit(revertCommitId);
-    Change changeToRevert = notes.getChange();
     Change.Id changeId = Change.id(seq.nextChangeId());
     if (input.workInProgress) {
-      input.notify = firstNonNull(input.notify, NotifyHandling.OWNER);
+      input.notify = firstNonNull(input.notify, NotifyHandling.NONE);
     }
     NotifyResolver.Result notify =
         notifyResolver.resolve(firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
 
+    Change changeToRevert = notes.getChange();
     ChangeInserter ins =
         changeInserterFactory
-            .create(changeId, revertCommit, notes.getChange().getDest().branch())
+            .create(changeId, revertCommit, changeToRevert.getDest().branch())
             .setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
     ins.setMessage("Uploaded patch set 1.");
+    ins.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
 
     ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
 
@@ -283,7 +327,7 @@
     reviewers.remove(user.getAccountId());
     Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
     ccs.remove(user.getAccountId());
-    ins.setReviewersAndCcs(reviewers, ccs);
+    ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
     ins.setRevertOf(notes.getChangeId());
     ins.setWorkInProgress(input.workInProgress);
 
@@ -291,54 +335,149 @@
       bu.setRepository(git, revWalk, oi);
       bu.setNotify(notify);
       bu.insertChange(ins);
-      bu.addOp(changeId, new NotifyOp(changeToRevert, ins));
-      bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(generatedChangeId));
+      if (!input.workInProgress) {
+        addChangeRevertedNotificationOps(
+            bu, changeToRevert.getId(), changeId, generatedChangeId.name());
+      }
       bu.execute();
     }
     return changeId;
   }
 
-  private class NotifyOp implements BatchUpdateOp {
-    private final Change change;
-    private final ChangeInserter ins;
+  /**
+   * Notify the owners of a change that their change is being reverted.
+   *
+   * @param bu to append the notification actions to.
+   * @param revertedChangeId to be notified.
+   * @param revertingChangeId to notify about.
+   * @param revertingChangeKey to notify about.
+   */
+  public void addChangeRevertedNotificationOps(
+      BatchUpdate bu,
+      Change.Id revertedChangeId,
+      Change.Id revertingChangeId,
+      String revertingChangeKey) {
+    bu.addOp(revertingChangeId, new ChangeRevertedNotifyOp(revertedChangeId, revertingChangeId));
+    bu.addOp(revertedChangeId, new PostRevertedMessageOp(revertingChangeKey));
+  }
 
-    NotifyOp(Change change, ChangeInserter ins) {
-      this.change = change;
-      this.ins = ins;
+  private class ChangeRevertedNotifyOp implements BatchUpdateOp {
+    private final Change.Id revertedChangeId;
+    private final Change.Id revertingChangeId;
+
+    ChangeRevertedNotifyOp(Change.Id revertedChangeId, Change.Id revertingChangeId) {
+      this.revertedChangeId = revertedChangeId;
+      this.revertingChangeId = revertingChangeId;
     }
 
     @Override
     public void postUpdate(PostUpdateContext ctx) throws Exception {
-      changeReverted.fire(
-          ctx.getChangeData(change), ctx.getChangeData(ins.getChange()), ctx.getWhen());
+      ChangeData revertedChange =
+          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertedChangeId));
+      ChangeData revertingChange =
+          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertingChangeId));
+      changeReverted.fire(revertedChange, revertingChange, ctx.getWhen());
       try {
-        RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
+        RevertedSender emailSender =
+            revertedSenderFactory.create(ctx.getProject(), revertedChange.getId());
         emailSender.setFrom(ctx.getAccountId());
-        emailSender.setNotify(ctx.getNotify(change.getId()));
+        emailSender.setNotify(ctx.getNotify(revertedChangeId));
         emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+            messageIdGenerator.fromChangeUpdate(
+                ctx.getRepoView(), revertedChange.currentPatchSet().id()));
         emailSender.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
-            "Cannot send email for revert change %s", change.getId());
+            "Cannot send email for revert change %s", revertedChangeId);
       }
     }
   }
 
   private class PostRevertedMessageOp implements BatchUpdateOp {
-    private final ObjectId computedChangeId;
+    private final String revertingChangeKey;
 
-    PostRevertedMessageOp(ObjectId computedChangeId) {
-      this.computedChangeId = computedChangeId;
+    PostRevertedMessageOp(String revertingChangeKey) {
+      this.revertingChangeKey = revertingChangeKey;
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx) {
       cmUtil.setChangeMessage(
           ctx,
-          "Created a revert of this change as I" + computedChangeId.name(),
+          "Created a revert of this change as I" + revertingChangeKey,
           ChangeMessagesUtil.TAG_REVERT);
       return true;
     }
   }
+
+  /**
+   * Returns the parent commit for a new commit.
+   *
+   * <p>If {@code baseSha1} is provided, the method verifies it can be used as a base. If {@code
+   * baseSha1} is not provided the tip of the {@code destRef} is returned.
+   *
+   * @param project The name of the project.
+   * @param changeQuery Used for looking up the base commit.
+   * @param revWalk Used for parsing the base commit.
+   * @param destRef The destination branch.
+   * @param baseSha1 The hash of the base commit. Nullable.
+   * @return the base commit. Either the commit matching the provided hash, or the direct parent if
+   *     a hash was not provided.
+   * @throws IOException if the branch reference cannot be parsed.
+   * @throws RestApiException if the base commit cannot be fetched.
+   */
+  public static RevCommit getBaseCommit(
+      String project,
+      InternalChangeQuery changeQuery,
+      RevWalk revWalk,
+      Ref destRef,
+      @Nullable String baseSha1)
+      throws IOException, RestApiException {
+    RevCommit destRefTip = revWalk.parseCommit(destRef.getObjectId());
+    // The tip commit of the destination ref is the default base for the newly created change.
+    if (Strings.isNullOrEmpty(baseSha1)) {
+      return destRefTip;
+    }
+
+    ObjectId baseObjectId;
+    try {
+      baseObjectId = ObjectId.fromString(baseSha1);
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException(
+          String.format("Base %s doesn't represent a valid SHA-1", baseSha1), e);
+    }
+
+    RevCommit baseCommit;
+    try {
+      baseCommit = revWalk.parseCommit(baseObjectId);
+    } catch (MissingObjectException e) {
+      throw new UnprocessableEntityException(
+          String.format("Base %s doesn't exist", baseObjectId.name()), e);
+    }
+
+    changeQuery.enforceVisibility(true);
+    List<ChangeData> changeDatas = changeQuery.byBranchCommit(project, destRef.getName(), baseSha1);
+
+    if (changeDatas.isEmpty()) {
+      if (revWalk.isMergedInto(baseCommit, destRefTip)) {
+        // The base commit is a merged commit with no change associated.
+        return baseCommit;
+      }
+      throw new UnprocessableEntityException(
+          String.format("Commit %s does not exist on branch %s", baseSha1, destRef.getName()));
+    } else if (changeDatas.size() != 1) {
+      throw new ResourceConflictException("Multiple changes found for commit " + baseSha1);
+    }
+
+    Change change = changeDatas.get(0).change();
+    if (!change.isAbandoned()) {
+      // The base commit is a valid change revision.
+      return baseCommit;
+    }
+
+    throw new ResourceConflictException(
+        String.format(
+            "Change %s with commit %s is %s",
+            change.getChangeId(), baseSha1, ChangeUtil.status(change)));
+  }
 }
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index ddfc115..9046d9d 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -65,6 +65,11 @@
     this.delegate = delegate;
   }
 
+  /** Returns the wrapped {@link Repository} instance. */
+  public Repository delegate() {
+    return delegate;
+  }
+
   @Override
   public void create(boolean bare) throws IOException {
     delegate.create(bare);
@@ -210,12 +215,12 @@
   }
 
   @Override
-  public Set<ObjectId> getAdditionalHaves() {
+  public Set<ObjectId> getAdditionalHaves() throws IOException {
     return delegate.getAdditionalHaves();
   }
 
   @Override
-  public Map<AnyObjectId, Set<Ref>> getAllRefsByPeeledObjectId() {
+  public Map<AnyObjectId, Set<Ref>> getAllRefsByPeeledObjectId() throws IOException {
     return delegate.getAllRefsByPeeledObjectId();
   }
 
diff --git a/java/com/google/gerrit/server/git/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
index a2942fe..30330eb 100644
--- a/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -85,7 +85,12 @@
       try (Repository repo = repoManager.openRepository(p)) {
         logGcConfiguration(p, repo, aggressive);
         print(writer, "collecting garbage for \"" + p + "\":\n");
-        GarbageCollectCommand gc = Git.wrap(repo).gc();
+        GarbageCollectCommand gc =
+            Git.wrap(
+                    repo instanceof DelegateRepository
+                        ? ((DelegateRepository) repo).delegate()
+                        : repo)
+                .gc();
         gc.setAggressive(aggressive);
         logGcInfo(p, "before:", gc.getStatistics());
         gc.setProgressMonitor(
@@ -131,7 +136,7 @@
       }
       b.append(s);
     }
-    logger.atInfo().log(b.toString());
+    logger.atInfo().log("%s", b);
   }
 
   private static void logGcConfiguration(
@@ -148,7 +153,7 @@
     }
 
     logGcInfo(projectName, "gc config: " + b.toString());
-    logGcInfo(projectName, "pack config: " + (new PackConfig(repo)).toString());
+    logGcInfo(projectName, "pack config: " + new PackConfig(repo).toString());
   }
 
   private static String formatConfigValues(Config config, String section, String subsection) {
@@ -171,7 +176,7 @@
     print(writer, "failed.\n\n");
     StringBuilder b = new StringBuilder();
     b.append("[").append(projectName.get()).append("]");
-    logger.atSevere().withCause(e).log(b.toString());
+    logger.atSevere().withCause(e).log("%s", b);
   }
 
   private static void print(PrintWriter writer, String message) {
diff --git a/java/com/google/gerrit/server/git/GitRepositoryManager.java b/java/com/google/gerrit/server/git/GitRepositoryManager.java
index 8dba3e1..d045baa 100644
--- a/java/com/google/gerrit/server/git/GitRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/GitRepositoryManager.java
@@ -18,7 +18,7 @@
 import com.google.inject.ImplementedBy;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 
@@ -74,7 +74,7 @@
       throws RepositoryNotFoundException, RepositoryExistsException, IOException;
 
   /** Returns set of all known projects, sorted by natural NameKey order. */
-  SortedSet<Project.NameKey> list();
+  NavigableSet<Project.NameKey> list();
 
   /**
    * Check if garbage collection can be performed by the repository manager.
diff --git a/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
index 6266925..dfbe663 100644
--- a/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
+++ b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.ModuleImpl;
 import com.google.gerrit.server.config.RepositoryConfig;
 import com.google.gerrit.server.git.LocalDiskRepositoryManager.LocalDiskRepositoryManagerModule;
 import com.google.gerrit.server.git.MultiBaseLocalDiskRepositoryManager.MultiBaseLocalDiskRepositoryManagerModule;
@@ -24,7 +25,9 @@
  * Module to install {@link MultiBaseLocalDiskRepositoryManager} rather than {@link
  * LocalDiskRepositoryManager} if needed.
  */
+@ModuleImpl(name = GitRepositoryManagerModule.MANAGER_MODULE)
 public class GitRepositoryManagerModule extends LifecycleModule {
+  public static final String MANAGER_MODULE = "git-manager";
 
   private final RepositoryConfig repoConfig;
 
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 5bbe5e2..455b221 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.PatchSetUtil;
@@ -258,6 +259,7 @@
     return actual;
   }
 
+  @Nullable
   private ObjectId parseGroup(ObjectId forCommit, String group) {
     try {
       return ObjectId.fromString(group);
diff --git a/java/com/google/gerrit/server/git/HookUtil.java b/java/com/google/gerrit/server/git/HookUtil.java
index fd29c8deb..cafa18e 100644
--- a/java/com/google/gerrit/server/git/HookUtil.java
+++ b/java/com/google/gerrit/server/git/HookUtil.java
@@ -43,12 +43,12 @@
       refs =
           rp.getRepository().getRefDatabase().getRefs().stream()
               .collect(toMap(Ref::getName, r -> r));
+      rp.setAdvertisedRefs(refs, rp.getAdvertisedObjects());
     } catch (ServiceMayNotContinueException e) {
       throw e;
     } catch (IOException e) {
       throw new ServiceMayNotContinueException(e);
     }
-    rp.setAdvertisedRefs(refs, rp.getAdvertisedObjects());
     return refs;
   }
 
@@ -70,12 +70,12 @@
       refs =
           up.getRepository().getRefDatabase().getRefs().stream()
               .collect(toMap(Ref::getName, r -> r));
+      up.setAdvertisedRefs(refs);
     } catch (ServiceMayNotContinueException e) {
       throw e;
     } catch (IOException e) {
       throw new ServiceMayNotContinueException(e);
     }
-    up.setAdvertisedRefs(refs);
     return refs;
   }
 
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 8527ff8..57d37fa 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -34,8 +34,10 @@
 import java.nio.file.attribute.BasicFileAttributes;
 import java.util.Collections;
 import java.util.EnumSet;
-import java.util.SortedSet;
+import java.util.Map;
+import java.util.NavigableSet;
 import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ConfigConstants;
@@ -109,6 +111,7 @@
   }
 
   private final Path basePath;
+  private final Map<Project.NameKey, FileKey> fileKeyByProject = new ConcurrentHashMap<>();
 
   @Inject
   LocalDiskRepositoryManager(SitePaths site, @GerritServerConfig Config cfg) {
@@ -153,17 +156,23 @@
 
   @Override
   public Repository openRepository(Project.NameKey name) throws RepositoryNotFoundException {
-    return openRepository(getBasePath(name), name);
-  }
+    FileKey cachedLocation = fileKeyByProject.get(name);
+    if (cachedLocation != null) {
+      try {
+        return RepositoryCache.open(cachedLocation);
+      } catch (IOException e) {
+        fileKeyByProject.remove(name, cachedLocation);
+      }
+    }
 
-  private Repository openRepository(Path path, Project.NameKey name)
-      throws RepositoryNotFoundException {
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
-    FileKey loc = FileKey.lenient(path.resolve(name.get()).toFile(), FS.DETECTED);
+    FileKey location = FileKey.lenient(getBasePath(name).resolve(name.get()).toFile(), FS.DETECTED);
     try {
-      return RepositoryCache.open(loc);
+      Repository repo = RepositoryCache.open(location);
+      fileKeyByProject.put(name, location);
+      return repo;
     } catch (IOException e) {
       throw new RepositoryNotFoundException("Cannot open repository " + name, e);
     }
@@ -254,10 +263,10 @@
   }
 
   @Override
-  public SortedSet<Project.NameKey> list() {
+  public NavigableSet<Project.NameKey> list() {
     ProjectVisitor visitor = new ProjectVisitor(basePath);
     scanProjects(visitor);
-    return Collections.unmodifiableSortedSet(visitor.found);
+    return Collections.unmodifiableNavigableSet(visitor.found);
   }
 
   protected void scanProjects(ProjectVisitor visitor) {
@@ -286,7 +295,7 @@
   }
 
   protected class ProjectVisitor extends SimpleFileVisitor<Path> {
-    private final SortedSet<Project.NameKey> found = new TreeSet<>();
+    private final NavigableSet<Project.NameKey> found = new TreeSet<>();
     private Path startFolder;
 
     public ProjectVisitor(Path startFolder) {
@@ -309,7 +318,7 @@
 
     @Override
     public FileVisitResult visitFileFailed(Path file, IOException e) {
-      logger.atWarning().log(e.getMessage());
+      logger.atWarning().log("%s", e.getMessage());
       return FileVisitResult.CONTINUE;
     }
 
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 99cb9b0..6922efb 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -23,6 +23,8 @@
 import static java.util.Comparator.naturalOrder;
 import static java.util.stream.Collectors.joining;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -60,8 +62,6 @@
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.gerrit.server.submit.MergeIdenticalTreeException;
 import com.google.gerrit.server.submit.MergeSorter;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
@@ -115,6 +115,7 @@
  * flushing should use {@link ObjectInserter#newReader()}. This is already the default behavior of
  * {@code BatchUpdate}.
  */
+@AutoFactory
 public class MergeUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -135,12 +136,6 @@
     return useRecursiveMerge(cfg) ? MergeStrategy.RECURSIVE : MergeStrategy.RESOLVE;
   }
 
-  public interface Factory {
-    MergeUtil create(ProjectState project);
-
-    MergeUtil create(ProjectState project, boolean useContentMerge);
-  }
-
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final ApprovalsUtil approvalsUtil;
@@ -149,40 +144,38 @@
   private final boolean useRecursiveMerge;
   private final PluggableCommitMessageGenerator commitMessageGenerator;
 
-  @AssistedInject
   MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      DynamicItem<UrlFormatter> urlFormatter,
-      ApprovalsUtil approvalsUtil,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted ProjectState project) {
+      @Provided @GerritServerConfig Config serverConfig,
+      @Provided IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Provided DynamicItem<UrlFormatter> urlFormatter,
+      @Provided ApprovalsUtil approvalsUtil,
+      @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      ProjectState project) {
     this(
         serverConfig,
         identifiedUserFactory,
         urlFormatter,
         approvalsUtil,
-        project,
         commitMessageGenerator,
+        project,
         project.is(BooleanProjectConfig.USE_CONTENT_MERGE));
   }
 
-  @AssistedInject
   MergeUtil(
-      @GerritServerConfig Config serverConfig,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
-      DynamicItem<UrlFormatter> urlFormatter,
-      ApprovalsUtil approvalsUtil,
-      @Assisted ProjectState project,
-      PluggableCommitMessageGenerator commitMessageGenerator,
-      @Assisted boolean useContentMerge) {
+      @Provided @GerritServerConfig Config serverConfig,
+      @Provided IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Provided DynamicItem<UrlFormatter> urlFormatter,
+      @Provided ApprovalsUtil approvalsUtil,
+      @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      ProjectState project,
+      boolean useContentMerge) {
     this.identifiedUserFactory = identifiedUserFactory;
     this.urlFormatter = urlFormatter;
     this.approvalsUtil = approvalsUtil;
+    this.commitMessageGenerator = commitMessageGenerator;
     this.project = project;
     this.useContentMerge = useContentMerge;
     this.useRecursiveMerge = useRecursiveMerge(serverConfig);
-    this.commitMessageGenerator = commitMessageGenerator;
   }
 
   public CodeReviewCommit getFirstFastForward(
@@ -307,13 +300,13 @@
     int nameLength = Math.max(oursName.length(), theirsName.length());
     String oursNameFormatted =
         String.format(
-            "%0$-" + nameLength + "s (%s %s)",
+            "%-" + nameLength + "s (%s %s)",
             oursName,
             abbreviateName(ours, NAME_ABBREV_LEN),
             oursMsg.substring(0, Math.min(oursMsg.length(), 60)));
     String theirsNameFormatted =
         String.format(
-            "%0$-" + nameLength + "s (%s %s)",
+            "%-" + nameLength + "s (%s %s)",
             theirsName,
             abbreviateName(theirs, NAME_ABBREV_LEN),
             theirsMsg.substring(0, Math.min(theirsMsg.length(), 60)));
@@ -785,8 +778,7 @@
       try {
         failed(rw, mergeTip, n, getCommitMergeStatus(e.getReason()));
       } catch (IOException e2) {
-        logger.atSevere().withCause(e2).log("Failed to set merge failure status for " + n.name());
-        throw new StorageException("Cannot merge " + n.name(), e);
+        throw new StorageException("Cannot merge " + n.name(), e2);
       }
     } catch (IOException e) {
       throw new StorageException("Cannot merge " + n.name(), e);
@@ -1035,6 +1027,7 @@
     }
   }
 
+  @Nullable
   public static CodeReviewCommit findAnyMergedInto(
       CodeReviewRevWalk rw, Iterable<CodeReviewCommit> commits, CodeReviewCommit tip)
       throws IOException {
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 09f08bd..c76c78e 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -16,8 +16,10 @@
 
 import static com.google.gerrit.server.DeadlineChecker.getTimeoutFormatter;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
+import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.base.Ticker;
 import com.google.common.flogger.FluentLogger;
@@ -36,6 +38,8 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ProgressMonitor;
 
@@ -154,6 +158,64 @@
         return count;
       }
     }
+
+    public int getTotal() {
+      return total;
+    }
+
+    public String getName() {
+      return name;
+    }
+
+    public String getTotalDisplay(int total) {
+      return String.valueOf(total);
+    }
+  }
+
+  /** Handle for a sub-task whose total work can be updated while the task is in progress. */
+  public class VolatileTask extends Task {
+    protected AtomicInteger volatileTotal;
+    protected AtomicBoolean isTotalFinalized = new AtomicBoolean(false);
+
+    public VolatileTask(String subTaskName) {
+      super(subTaskName, UNKNOWN);
+      volatileTotal = new AtomicInteger(UNKNOWN);
+    }
+
+    /**
+     * Update the total work for this sub-task.
+     *
+     * <p>Intended to be called from a worker thread.
+     *
+     * @param workUnits number of work units to be added to existing total work.
+     */
+    public void updateTotal(int workUnits) {
+      if (!isTotalFinalized.get()) {
+        volatileTotal.addAndGet(workUnits);
+      } else {
+        logger.atWarning().log(
+            "Total work has been finalized on sub-task %s and cannot be updated", getName());
+      }
+    }
+
+    /**
+     * Mark the total on this sub-task as unmodifiable.
+     *
+     * <p>Intended to be called from a worker thread.
+     */
+    public void finalizeTotal() {
+      isTotalFinalized.set(true);
+    }
+
+    @Override
+    public int getTotal() {
+      return volatileTotal.get();
+    }
+
+    @Override
+    public String getTotalDisplay(int total) {
+      return super.getTotalDisplay(total) + (isTotalFinalized.get() ? "" : "+");
+    }
   }
 
   public interface Factory {
@@ -189,6 +251,7 @@
    * @param out stream for writing progress messages.
    * @param taskName name of the overall task.
    */
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private MultiProgressMonitor(
       CancellationMetrics cancellationMetrics,
@@ -249,6 +312,7 @@
    * calls {@link #end()}, the future has an additional {@code maxInterval} to finish before it is
    * forcefully cancelled and {@link ExecutionException} is thrown.
    *
+   * @see #waitForNonFinalTask(Future, long, TimeUnit, long, TimeUnit)
    * @param workerFuture a future that returns when worker threads are finished.
    * @param taskTimeoutTime overall timeout for the task; the future gets a cancellation signal
    *     after this timeout is exceeded; non-positive values indicate no timeout.
@@ -267,6 +331,60 @@
       long cancellationTimeoutTime,
       TimeUnit cancellationTimeoutUnit)
       throws TimeoutException {
+    T t =
+        waitForNonFinalTask(
+            workerFuture,
+            taskTimeoutTime,
+            taskTimeoutUnit,
+            cancellationTimeoutTime,
+            cancellationTimeoutUnit);
+    synchronized (this) {
+      if (!done) {
+        // The worker may not have called end() explicitly, which is likely a
+        // programming error.
+        logger.atWarning().log("MultiProgressMonitor worker did not call end() before returning");
+        end();
+      }
+    }
+    sendDone();
+    return t;
+  }
+
+  /**
+   * Wait for a non-final task managed by a {@link Future}, with no timeout.
+   *
+   * @see #waitForNonFinalTask(Future, long, TimeUnit, long, TimeUnit)
+   */
+  public <T> T waitForNonFinalTask(Future<T> workerFuture) {
+    try {
+      return waitForNonFinalTask(workerFuture, 0, null, 0, null);
+    } catch (TimeoutException e) {
+      throw new IllegalStateException("timout exception without setting a timeout", e);
+    }
+  }
+
+  /**
+   * Wait for a task managed by a {@link Future}. This call does not expect the worker thread to
+   * call {@link #end()}. It is intended to be used to track a non-final task.
+   *
+   * @param workerFuture a future that returns when worker threads are finished.
+   * @param taskTimeoutTime overall timeout for the task; the future is forcefully cancelled if the
+   *     task exceeds the timeout. Non-positive values indicate no timeout.
+   * @param taskTimeoutUnit unit for overall task timeout.
+   * @param cancellationTimeoutTime timeout for the task to react to the cancellation signal; if the
+   *     task doesn't terminate within this time it is forcefully cancelled; non-positive values
+   *     indicate no timeout.
+   * @param cancellationTimeoutUnit unit for the cancellation timeout.
+   * @throws TimeoutException if this thread or a worker thread was interrupted, the worker was
+   *     cancelled, or timed out waiting for a worker to call {@link #end()}.
+   */
+  public <T> T waitForNonFinalTask(
+      Future<T> workerFuture,
+      long taskTimeoutTime,
+      TimeUnit taskTimeoutUnit,
+      long cancellationTimeoutTime,
+      TimeUnit cancellationTimeoutUnit)
+      throws TimeoutException {
     long overallStart = ticker.read();
     long cancellationNanos =
         cancellationTimeoutTime > 0
@@ -282,7 +400,7 @@
 
     synchronized (this) {
       long left = maxIntervalNanos;
-      while (!done) {
+      while (!workerFuture.isDone() && !done) {
         long start = ticker.read();
         try {
           // Conditions below gives better granularity for timeouts.
@@ -349,19 +467,11 @@
           left = maxIntervalNanos;
         }
         sendUpdate();
-        if (!done && workerFuture.isDone()) {
-          // The worker may not have called end() explicitly, which is likely a
-          // programming error.
-          logger.atWarning().log(
-              "MultiProgressMonitor worker did not call end() before returning (task=%s(%s))",
-              taskKind, taskName);
-          end();
-        }
       }
       if (deadlineExceeded && !forcefulTermination && taskKind == TaskKind.RECEIVE_COMMITS) {
         cancellationMetrics.countGracefulReceiveTimeout();
       }
-      sendDone();
+      wakeUp();
     }
 
     // The loop exits as soon as the worker calls end(), but we give it another
@@ -398,6 +508,18 @@
   }
 
   /**
+   * Begin a sub-task whose total work can be updated.
+   *
+   * @param subTask sub-task name.
+   * @return sub-task handle.
+   */
+  public VolatileTask beginVolatileSubTask(String subTask) {
+    VolatileTask task = new VolatileTask(subTask);
+    tasks.add(task);
+    return task;
+  }
+
+  /**
    * End the overall task.
    *
    * <p>Must be called from a worker thread.
@@ -440,6 +562,7 @@
       boolean first = true;
       for (Task t : tasks) {
         int count = t.getCount();
+        int total = t.getTotal();
         if (count == 0) {
           continue;
         }
@@ -454,10 +577,11 @@
         if (!Strings.isNullOrEmpty(t.name)) {
           s.append(t.name).append(": ");
         }
-        if (t.total == UNKNOWN) {
+        if (total == UNKNOWN) {
           s.append(count);
         } else {
-          s.append(String.format("%d%% (%d/%d)", count * 100 / t.total, count, t.total));
+          s.append(
+              String.format("%d%% (%d/%s)", count * 100 / total, count, t.getTotalDisplay(total)));
         }
       }
     }
@@ -470,9 +594,12 @@
   }
 
   private void send(StringBuilder s) {
+    String progress = s.toString();
+    logger.atInfo().atMostEvery(1, MINUTES).log(
+        "%s", CharMatcher.javaIsoControl().removeFrom(progress));
     if (!clientDisconnected) {
       try {
-        out.write(Constants.encode(s.toString()));
+        out.write(Constants.encode(progress));
         out.flush();
       } catch (IOException e) {
         logger.atWarning().withCause(e).log(
diff --git a/java/com/google/gerrit/server/git/PerThreadRequestScope.java b/java/com/google/gerrit/server/git/PerThreadRequestScope.java
index b7db542..9b5a674 100644
--- a/java/com/google/gerrit/server/git/PerThreadRequestScope.java
+++ b/java/com/google/gerrit/server/git/PerThreadRequestScope.java
@@ -89,7 +89,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               return requireContext().get(key, creator);
diff --git a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
index 99a66f8..fb34753 100644
--- a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
+++ b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.git;
 
-import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
 
 import com.google.common.base.Preconditions;
@@ -83,6 +82,7 @@
     throw new UnsupportedOperationException("PermissionAwareReadOnlyRefDatabase is read-only");
   }
 
+  @Nullable
   @Override
   public Ref exactRef(String name) throws IOException {
     Ref ref = getDelegate().getRefDatabase().exactRef(name);
@@ -108,20 +108,11 @@
     return Iterables.getOnlyElement(result);
   }
 
+  // WARNING: This method is deprecated in JGit's RefDatabase and it will be removed on master.
+  // Do not add any logic here but rather enrich the getRefsByPrefix method below.
   @Override
   public Map<String, Ref> getRefs(String prefix) throws IOException {
-    List<Ref> refs = getDelegate().getRefDatabase().getRefsByPrefix(prefix);
-    if (refs.isEmpty()) {
-      return Collections.emptyMap();
-    }
-
-    Collection<Ref> result;
-    try {
-      result = forProject.filter(refs, getDelegate(), RefFilterOptions.defaults());
-    } catch (PermissionBackendException e) {
-      throw new IOException("", e);
-    }
-    return buildPrefixRefMap(prefix, result);
+    return buildPrefixRefMap(prefix, getRefsByPrefix(prefix));
   }
 
   private Map<String, Ref> buildPrefixRefMap(String prefix, Collection<Ref> refs) {
@@ -138,26 +129,18 @@
 
   @Override
   public List<Ref> getRefsByPrefix(String prefix) throws IOException {
-    Map<String, Ref> coarseRefs;
-    int lastSlash = prefix.lastIndexOf('/');
-    if (lastSlash == -1) {
-      coarseRefs = getRefs(ALL);
-    } else {
-      coarseRefs = getRefs(prefix.substring(0, lastSlash + 1));
+    List<Ref> refs = getDelegate().getRefDatabase().getRefsByPrefix(prefix);
+    if (refs.isEmpty()) {
+      return Collections.emptyList();
     }
 
-    List<Ref> result;
-    if (lastSlash + 1 == prefix.length()) {
-      result = coarseRefs.values().stream().collect(toList());
-    } else {
-      String p = prefix.substring(lastSlash + 1);
-      result =
-          coarseRefs.entrySet().stream()
-              .filter(e -> e.getKey().startsWith(p))
-              .map(e -> e.getValue())
-              .collect(toList());
+    Collection<Ref> result;
+    try {
+      result = forProject.filter(refs, getDelegate(), RefFilterOptions.defaults());
+    } catch (PermissionBackendException e) {
+      throw new IOException("", e);
     }
-    return Collections.unmodifiableList(result);
+    return result.stream().collect(Collectors.toList());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
index 3910393..90eadf3 100644
--- a/java/com/google/gerrit/server/git/PureRevertCache.java
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -138,13 +138,13 @@
 
   static class Loader extends CacheLoader<PureRevertKeyProto, Boolean> {
     private final GitRepositoryManager repoManager;
-    private final MergeUtil.Factory mergeUtilFactory;
+    private final MergeUtilFactory mergeUtilFactory;
     private final ProjectCache projectCache;
 
     @Inject
     Loader(
         GitRepositoryManager repoManager,
-        MergeUtil.Factory mergeUtilFactory,
+        MergeUtilFactory mergeUtilFactory,
         ProjectCache projectCache) {
       this.repoManager = repoManager;
       this.mergeUtilFactory = mergeUtilFactory;
diff --git a/java/com/google/gerrit/server/git/RefCache.java b/java/com/google/gerrit/server/git/RefCache.java
index 5a5cae9..2dee427 100644
--- a/java/com/google/gerrit/server/git/RefCache.java
+++ b/java/com/google/gerrit/server/git/RefCache.java
@@ -37,4 +37,7 @@
    *     present with a value of {@link ObjectId#zeroId()}.
    */
   Optional<ObjectId> get(String refName) throws IOException;
+
+  /** Closes this cache, releasing the references to any underlying resources. */
+  void close();
 }
diff --git a/java/com/google/gerrit/server/git/RepoRefCache.java b/java/com/google/gerrit/server/git/RepoRefCache.java
index c69f9a6..d2b3c32 100644
--- a/java/com/google/gerrit/server/git/RepoRefCache.java
+++ b/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -28,8 +28,11 @@
 public class RepoRefCache implements RefCache {
   private final RefDatabase refdb;
   private final Map<String, Optional<ObjectId>> ids;
+  private final Repository repo;
 
   public RepoRefCache(Repository repo) {
+    repo.incrementOpen();
+    this.repo = repo;
     this.refdb = repo.getRefDatabase();
     this.ids = new HashMap<>();
   }
@@ -50,4 +53,9 @@
   public Map<String, Optional<ObjectId>> getCachedRefs() {
     return Collections.unmodifiableMap(ids);
   }
+
+  @Override
+  public void close() {
+    repo.close();
+  }
 }
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index ff5bcc2..cfeec70 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -41,10 +41,11 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import com.google.inject.util.Providers;
-import java.util.ArrayList;
-import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.ExecutionException;
+import java.util.stream.Stream;
 
 /**
  * Cache based on an index query of the most recent changes. The number of cached items depends on
@@ -116,22 +117,23 @@
    * Additional stored fields are not loaded from the index.
    *
    * @param project project to read.
-   * @return list of known changes; empty if no changes.
+   * @return stream of known changes; empty if no changes.
    */
-  public List<ChangeData> getChangeData(Project.NameKey project) {
+  public Stream<ChangeData> getChangeData(Project.NameKey project) {
+    List<CachedChange> cached;
     try {
-      List<CachedChange> cached = cache.get(project);
-      List<ChangeData> cds = new ArrayList<>(cached.size());
-      for (CachedChange cc : cached) {
-        ChangeData cd = changeDataFactory.create(cc.change());
-        cd.setReviewers(cc.reviewers());
-        cds.add(cd);
-      }
-      return Collections.unmodifiableList(cds);
+      cached = cache.get(project);
     } catch (ExecutionException e) {
       logger.atWarning().withCause(e).log("Cannot fetch changes for %s", project);
-      return Collections.emptyList();
+      return Stream.empty();
     }
+    return cached.stream()
+        .map(
+            cc -> {
+              ChangeData cd = changeDataFactory.create(cc.change());
+              cd.setReviewers(cc.reviewers());
+              return cd;
+            });
   }
 
   @Override
@@ -160,14 +162,20 @@
         List<ChangeData> cds =
             queryProvider
                 .get()
-                .setRequestedFields(ChangeField.CHANGE, ChangeField.REVIEWER)
+                .setRequestedFields(ChangeField.CHANGE_SPEC, ChangeField.REVIEWER_SPEC)
                 .byProject(key);
-        List<CachedChange> result = new ArrayList<>(cds.size());
+        Map<Change.Id, CachedChange> result = new HashMap<>(cds.size());
         for (ChangeData cd : cds) {
-          result.add(
+          if (result.containsKey(cd.getId())) {
+            logger.atWarning().log(
+                "Duplicate changes returned from change query by project %s: %s, %s",
+                key, cd.change(), result.get(cd.getId()).change());
+          }
+          result.put(
+              cd.getId(),
               new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.reviewers()));
         }
-        return Collections.unmodifiableList(result);
+        return List.copyOf(result.values());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 8b59474..e8b7c62 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -18,8 +18,10 @@
 
 import com.google.common.base.CaseFormat;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
@@ -27,14 +29,14 @@
 import com.google.gerrit.server.config.ScheduleConfig.Schedule;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.logging.LoggingContextAwareRunnable;
+import com.google.gerrit.server.plugincontext.PluginMapContext;
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.lang.Thread.UncaughtExceptionHandler;
 import java.lang.reflect.Field;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Date;
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -50,8 +52,8 @@
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.lib.Config;
 
 /** Delayed execution of tasks using a background thread pool. */
@@ -59,6 +61,30 @@
 public class WorkQueue {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  /**
+   * To register a TaskListener, which will be called directly before Tasks run, and directly after
+   * they complete, bind the TaskListener like this:
+   *
+   * <p><code>
+   *   bind(TaskListener.class)
+   *       .annotatedWith(Exports.named("MyListener"))
+   *       .to(MyListener.class);
+   * </code>
+   */
+  public interface TaskListener {
+    public static class NoOp implements TaskListener {
+      @Override
+      public void onStart(Task<?> task) {}
+
+      @Override
+      public void onStop(Task<?> task) {}
+    }
+
+    void onStart(Task<?> task);
+
+    void onStop(Task<?> task);
+  }
+
   public static class Lifecycle implements LifecycleListener {
     private final WorkQueue workQueue;
 
@@ -79,31 +105,42 @@
   public static class WorkQueueModule extends LifecycleModule {
     @Override
     protected void configure() {
+      DynamicMap.mapOf(binder(), WorkQueue.TaskListener.class);
       bind(WorkQueue.class);
       listener().to(Lifecycle.class);
     }
   }
 
-  private static final UncaughtExceptionHandler LOG_UNCAUGHT_EXCEPTION =
-      (t, e) ->
-          logger.atSevere().withCause(e).log("WorkQueue thread %s threw exception", t.getName());
-
   private final ScheduledExecutorService defaultQueue;
   private final IdGenerator idGenerator;
   private final MetricMaker metrics;
   private final CopyOnWriteArrayList<Executor> queues;
+  private final PluginMapContext<TaskListener> listeners;
 
   @Inject
-  WorkQueue(IdGenerator idGenerator, @GerritServerConfig Config cfg, MetricMaker metrics) {
-    this(idGenerator, Math.max(cfg.getInt("execution", "defaultThreadPoolSize", 2), 2), metrics);
+  WorkQueue(
+      IdGenerator idGenerator,
+      @GerritServerConfig Config cfg,
+      MetricMaker metrics,
+      PluginMapContext<TaskListener> listeners) {
+    this(
+        idGenerator,
+        Math.max(cfg.getInt("execution", "defaultThreadPoolSize", 2), 2),
+        metrics,
+        listeners);
   }
 
   /** Constructor to allow binding the WorkQueue more explicitly in a vhost setup. */
-  public WorkQueue(IdGenerator idGenerator, int defaultThreadPoolSize, MetricMaker metrics) {
+  public WorkQueue(
+      IdGenerator idGenerator,
+      int defaultThreadPoolSize,
+      MetricMaker metrics,
+      PluginMapContext<TaskListener> listeners) {
     this.idGenerator = idGenerator;
     this.metrics = metrics;
     this.queues = new CopyOnWriteArrayList<>();
     this.defaultQueue = createQueue(defaultThreadPoolSize, "WorkQueue", true);
+    this.listeners = listeners;
   }
 
   /** Get the default work queue, for miscellaneous tasks. */
@@ -152,6 +189,7 @@
    * @param threadPriority thread priority.
    * @param withMetrics whether to create metrics.
    */
+  @SuppressWarnings("ThreadPriorityCheck")
   public ScheduledThreadPoolExecutor createQueue(
       int poolsize, String queueName, int threadPriority, boolean withMetrics) {
     Executor executor = new Executor(poolsize, queueName);
@@ -204,6 +242,7 @@
   }
 
   /** Locate a task by its unique id, null if no task matches. */
+  @Nullable
   public Task<?> getTask(int id) {
     Task<?> result = null;
     for (Executor e : queues) {
@@ -219,6 +258,7 @@
     return result;
   }
 
+  @Nullable
   public ScheduledThreadPoolExecutor getExecutor(String queueName) {
     for (Executor e : queues) {
       if (e.queueName.equals(queueName)) {
@@ -259,7 +299,7 @@
             public Thread newThread(Runnable task) {
               final Thread t = parent.newThread(task);
               t.setName(queueName + "-" + tid.getAndIncrement());
-              t.setUncaughtExceptionHandler(LOG_UNCAUGHT_EXCEPTION);
+              t.setUncaughtExceptionHandler(WorkQueue::logUncaughtException);
               return t;
             }
           });
@@ -442,6 +482,18 @@
     Collection<Task<?>> getTasks() {
       return all.values();
     }
+
+    public void onStart(Task<?> task) {
+      listeners.runEach(extension -> extension.getProvider().get().onStart(task));
+    }
+
+    public void onStop(Task<?> task) {
+      listeners.runEach(extension -> extension.getProvider().get().onStop(task));
+    }
+  }
+
+  private static void logUncaughtException(Thread t, Throwable e) {
+    logger.atSevere().withCause(e).log("WorkQueue thread %s threw exception", t.getName());
   }
 
   /**
@@ -474,18 +526,23 @@
      * <ol>
      *   <li>{@link #SLEEPING}: if scheduled with a non-zero delay.
      *   <li>{@link #READY}: waiting for an available worker thread.
+     *   <li>{@link #STARTING}: onStart() actively executing on a worker thread.
      *   <li>{@link #RUNNING}: actively executing on a worker thread.
+     *   <li>{@link #STOPPING}: onStop() actively executing on a worker thread.
      *   <li>{@link #DONE}: finished executing, if not periodic.
      * </ol>
      */
     public enum State {
       // Ordered like this so ordinal matches the order we would
       // prefer to see tasks sorted in: done before running,
-      // running before ready, ready before sleeping.
+      // stopping before running, running before starting,
+      // starting before ready, ready before sleeping.
       //
       DONE,
       CANCELLED,
+      STOPPING,
       RUNNING,
+      STARTING,
       READY,
       SLEEPING,
       OTHER
@@ -495,16 +552,17 @@
     private final RunnableScheduledFuture<V> task;
     private final Executor executor;
     private final int taskId;
-    private final AtomicBoolean running;
-    private final Date startTime;
+    private final Instant startTime;
+
+    // runningState is non-null when listener or task code is running in an executor thread
+    private final AtomicReference<State> runningState = new AtomicReference<>();
 
     Task(Runnable runnable, RunnableScheduledFuture<V> task, Executor executor, int taskId) {
       this.runnable = runnable;
       this.task = task;
       this.executor = executor;
       this.taskId = taskId;
-      this.running = new AtomicBoolean();
-      this.startTime = new Date();
+      this.startTime = Instant.now();
     }
 
     public int getTaskId() {
@@ -514,10 +572,13 @@
     public State getState() {
       if (isCancelled()) {
         return State.CANCELLED;
+      }
+
+      State r = runningState.get();
+      if (r != null) {
+        return r;
       } else if (isDone() && !isPeriodic()) {
         return State.DONE;
-      } else if (running.get()) {
-        return State.RUNNING;
       }
 
       final long delay = getDelay(TimeUnit.MILLISECONDS);
@@ -527,7 +588,7 @@
       return State.SLEEPING;
     }
 
-    public Date getStartTime() {
+    public Instant getStartTime() {
       return startTime;
     }
 
@@ -538,14 +599,14 @@
     @Override
     public boolean cancel(boolean mayInterruptIfRunning) {
       if (task.cancel(mayInterruptIfRunning)) {
-        // Tiny abuse of running: if the task needs to know it was
-        // canceled (to clean up resources) and it hasn't started
+        // Tiny abuse of runningState: if the task needs to know it
+        // was canceled (to clean up resources) and it hasn't started
         // yet the task's run method won't execute. So we tag it
         // as running and allow it to clean up. This ensures we do
         // not invoke cancel twice.
         //
         if (runnable instanceof CancelableRunnable) {
-          if (running.compareAndSet(false, true)) {
+          if (runningState.compareAndSet(null, State.RUNNING)) {
             ((CancelableRunnable) runnable).cancel();
           } else if (runnable instanceof CanceledWhileRunning) {
             ((CanceledWhileRunning) runnable).setCanceledWhileRunning();
@@ -605,16 +666,21 @@
 
     @Override
     public void run() {
-      if (running.compareAndSet(false, true)) {
+      if (runningState.compareAndSet(null, State.STARTING)) {
         String oldThreadName = Thread.currentThread().getName();
         try {
+          executor.onStart(this);
+          runningState.set(State.RUNNING);
           Thread.currentThread().setName(oldThreadName + "[" + task.toString() + "]");
           task.run();
         } finally {
           Thread.currentThread().setName(oldThreadName);
+          runningState.set(State.STOPPING);
+          executor.onStop(this);
           if (isPeriodic()) {
-            running.set(false);
+            runningState.set(null);
           } else {
+            runningState.set(State.DONE);
             executor.remove(this);
           }
         }
diff --git a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
index 27d5da9..befdb58 100644
--- a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
+++ b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
@@ -135,7 +135,7 @@
 
     private PersonIdent createPersonIdent(IdentifiedUser user) {
       PersonIdent serverIdent = serverIdentProvider.get();
-      return user.newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
+      return user.newCommitterIdent(serverIdent);
     }
   }
 
@@ -215,11 +215,7 @@
 
   public void setAuthor(IdentifiedUser author) {
     this.author = author;
-    getCommitBuilder()
-        .setAuthor(
-            author.newCommitterIdent(
-                getCommitBuilder().getCommitter().getWhen(),
-                getCommitBuilder().getCommitter().getTimeZone()));
+    getCommitBuilder().setAuthor(author.newCommitterIdent(getCommitBuilder().getCommitter()));
   }
 
   public void setAllowEmpty(boolean allowEmpty) {
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index 80570a5..5f76b39 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.git.ValidationError;
 import java.io.BufferedReader;
 import java.io.IOException;
@@ -84,6 +85,7 @@
     return map;
   }
 
+  @Nullable
   protected static String asText(String left, String right, Map<String, String> entries) {
     if (entries.isEmpty()) {
       return null;
@@ -96,6 +98,7 @@
     return asText(left, right, rows);
   }
 
+  @Nullable
   protected static String asText(String left, String right, List<Row> rows) {
     if (rows.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index a42ab8f..61bd8a8 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -210,6 +210,27 @@
   }
 
   /**
+   * Update this metadata branch, recording a new commit on its reference. This method mutates its
+   * receiver.
+   *
+   * @param update helper information to define the update that will occur.
+   * @param objInserter Shared object inserter.
+   * @param objReader Shared object reader.
+   * @param revWalk Shared rev walk.
+   * @return the commit that was created
+   * @throws IOException if there is a storage problem and the update cannot be executed as
+   *     requested or if it failed because of a concurrent update to the same reference
+   */
+  public RevCommit commit(
+      MetaDataUpdate update, ObjectInserter objInserter, ObjectReader objReader, RevWalk revWalk)
+      throws IOException {
+    try (BatchMetaDataUpdate batch = openUpdate(update, objInserter, objReader, revWalk)) {
+      batch.write(update.getCommitBuilder());
+      return batch.commit();
+    }
+  }
+
+  /**
    * Creates a new commit and a new ref based on this commit. This method mutates its receiver.
    *
    * @param update helper information to define the update that will occur.
@@ -256,11 +277,39 @@
    * @throws IOException if the update failed.
    */
   public BatchMetaDataUpdate openUpdate(MetaDataUpdate update) throws IOException {
+    return openUpdate(update, null, null, null);
+  }
+
+  /**
+   * Open a batch of updates to the same metadata ref.
+   *
+   * <p>This allows making multiple commits to a single metadata ref, at the end of which is a
+   * single ref update. For batching together updates to multiple refs (each consisting of one or
+   * more commits against their respective refs), create the {@link MetaDataUpdate} with a {@link
+   * BatchRefUpdate}.
+   *
+   * <p>A ref update produced by this {@link BatchMetaDataUpdate} is only committed if there is no
+   * associated {@link BatchRefUpdate}. As a result, the configured ref updated event is not fired
+   * if there is an associated batch.
+   *
+   * <p>If object inserter, reader and revwalk are provided, then the updates are not flushed,
+   * allowing callers the flexibility to flush only once after several updates.
+   *
+   * @param update helper info about the update.
+   * @param objInserter Shared object inserter.
+   * @param objReader Shared object reader.
+   * @param revWalk Shared rev walk.
+   * @throws IOException if the update failed.
+   */
+  public BatchMetaDataUpdate openUpdate(
+      MetaDataUpdate update, ObjectInserter objInserter, ObjectReader objReader, RevWalk revWalk)
+      throws IOException {
     final Repository db = update.getRepository();
 
-    inserter = db.newObjectInserter();
-    reader = inserter.newReader();
-    final RevWalk rw = new RevWalk(reader);
+    inserter = objInserter == null ? db.newObjectInserter() : objInserter;
+    reader = objReader == null ? inserter.newReader() : objReader;
+    final RevWalk rw = revWalk == null ? new RevWalk(reader) : revWalk;
+
     final RevTree tree = revision != null ? rw.parseTree(revision) : null;
     newTree = readTree(tree);
     return new BatchMetaDataUpdate() {
@@ -372,13 +421,16 @@
       public void close() {
         newTree = null;
 
-        rw.close();
-        if (inserter != null) {
+        if (revWalk == null) {
+          rw.close();
+        }
+
+        if (objInserter == null && inserter != null) {
           inserter.close();
           inserter = null;
         }
 
-        if (reader != null) {
+        if (objReader == null && reader != null) {
           reader.close();
           reader = null;
         }
@@ -389,7 +441,9 @@
         BatchRefUpdate bru = update.getBatch();
         if (bru != null) {
           bru.addCommand(new ReceiveCommand(oldId.toObjectId(), newId.toObjectId(), refName));
-          inserter.flush();
+          if (objInserter == null) {
+            inserter.flush();
+          }
           revision = rw.parseCommit(newId);
           return revision;
         }
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index 85d7db0..08849348 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -36,6 +35,7 @@
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PublishCommentsOp;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -290,10 +290,7 @@
     receivePack.setPreReceiveHook(asHook());
     receivePack.setPostReceiveHook(lazyPostReceive.create(user, projectName));
 
-    try {
-      projectState.checkStatePermitsRead();
-      this.perm.check(ProjectPermission.READ);
-    } catch (AuthException | ResourceConflictException e) {
+    if (!projectState.statePermitsRead() || !this.perm.test(ProjectPermission.READ)) {
       receivePack.setCheckReferencedObjectsAreReachable(
           receiveConfig.checkReferencedObjectsAreReachable);
     }
@@ -325,9 +322,7 @@
 
   /** Determine if the user can upload commits. */
   public Capable canUpload() throws IOException, PermissionBackendException {
-    try {
-      perm.check(ProjectPermission.PUSH_AT_LEAST_ONE_REF);
-    } catch (AuthException e) {
+    if (!perm.test(ProjectPermission.PUSH_AT_LEAST_ONE_REF)) {
       return new Capable("Upload denied for project '" + projectState.getName() + "'");
     }
 
@@ -387,7 +382,7 @@
         () -> {
           String oldName = Thread.currentThread().getName();
           Thread.currentThread().setName(oldName + "-for-" + currentThreadName);
-          try {
+          try (PerThreadCache threadLocalCache = PerThreadCache.create()) {
             return receiveCommits.processCommands(commands, monitor);
           } finally {
             Thread.currentThread().setName(oldName);
diff --git a/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java b/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
index 72483af..12666f9 100644
--- a/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
+++ b/java/com/google/gerrit/server/git/receive/HackPushNegotiateHook.java
@@ -80,13 +80,13 @@
         r =
             rp.getRepository().getRefDatabase().getRefs().stream()
                 .collect(toMap(Ref::getName, x -> x));
+        rp.setAdvertisedRefs(r, history(r.values(), rp));
       } catch (ServiceMayNotContinueException e) {
         throw e;
       } catch (IOException e) {
         throw new ServiceMayNotContinueException(e);
       }
     }
-    rp.setAdvertisedRefs(r, history(r.values(), rp));
   }
 
   private Set<ObjectId> history(Collection<Ref> refs, ReceivePack rp) {
diff --git a/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
index a19dbac..a562659 100644
--- a/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
+++ b/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
@@ -72,7 +72,7 @@
             String.format(
                 "%s request failed for project %s with [%s]",
                 REPOSITORY_SIZE_GROUP, project, a.errorMessage());
-        logger.atWarning().log(msg);
+        logger.atWarning().log("%s", msg);
         throw new RuntimeException(msg);
       }
     }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index f4c7a92..093ca78 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -439,6 +439,7 @@
   private MessageSender messageSender;
   private ReceiveCommitsResult.Builder result;
   private ImmutableMap<String, String> loggingTags;
+  private ImmutableList<String> transitionalPluginOptions;
 
   /** This object is for single use only. */
   private boolean used;
@@ -590,6 +591,8 @@
         useRefCache
             ? ReceivePackRefCache.withAdvertisedRefs(() -> allRefsWatcher.getAllRefs())
             : ReceivePackRefCache.noCache(receivePack.getRepository().getRefDatabase());
+    this.transitionalPluginOptions =
+        ImmutableList.copyOf(config.getStringList("plugins", null, "transitionalPushOptions"));
   }
 
   void init() {
@@ -781,7 +784,7 @@
     Task newProgress = progress.beginSubTask("new", UNKNOWN);
     Task replaceProgress = progress.beginSubTask("updated", UNKNOWN);
 
-    List<CreateRequest> newChanges = Collections.emptyList();
+    ImmutableList<CreateRequest> newChanges = ImmutableList.of();
     try {
       if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
         try {
@@ -840,7 +843,7 @@
       Map<BranchNameKey, ReceiveCommand> branches;
       try (BatchUpdate bu =
               batchUpdateFactory.create(
-                  project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+                  project.getNameKey(), user.materializedCopy(), TimeUtil.now());
           ObjectInserter ins = repo.newObjectInserter();
           ObjectReader reader = ins.newReader();
           RevWalk rw = new RevWalk(reader);
@@ -862,7 +865,7 @@
 
         submissionExecutor.execute(ImmutableList.of(bu));
 
-        orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
+        orm.setContext(TimeUtil.now(), user, NotifyResolver.Result.none());
         submissionExecutor.afterExecutions(orm);
 
         branches = bu.getSuccessfullyUpdatedBranches(false);
@@ -1007,9 +1010,11 @@
             .setIsWorkInProgress(wip)
             .build();
     addMessage(changeFormatter.changeUpdated(input));
+    u.getOutdatedApprovalsMessage().map(msg -> "\n" + msg + "\n").ifPresent(this::addMessage);
   }
 
-  private void insertChangesAndPatchSets(List<CreateRequest> newChanges, Task replaceProgress) {
+  private void insertChangesAndPatchSets(
+      ImmutableList<CreateRequest> newChanges, Task replaceProgress) {
     try (TraceTimer traceTimer =
         newTimer(
             "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
@@ -1025,7 +1030,7 @@
 
       try (BatchUpdate bu =
               batchUpdateFactory.create(
-                  project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+                  project.getNameKey(), user.materializedCopy(), TimeUtil.now());
           ObjectInserter ins = repo.newObjectInserter();
           ObjectReader reader = ins.newReader();
           RevWalk rw = new RevWalk(reader)) {
@@ -1252,9 +1257,7 @@
                   + NoteDbPushOption.ALLOW.value());
           return;
         }
-        try {
-          permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
-        } catch (AuthException e) {
+        if (!permissionBackend.user(user).test(GlobalPermission.ACCESS_DATABASE)) {
           reject(cmd, "NoteDb update requires access database permission");
           return;
         }
@@ -1296,9 +1299,7 @@
   private void validateConfigPush(ReceiveCommand cmd) throws PermissionBackendException {
     try (TraceTimer traceTimer = newTimer("validateConfigPush")) {
       logger.atFine().log("Processing %s command", cmd.getRefName());
-      try {
-        permissions.check(ProjectPermission.WRITE_CONFIG);
-      } catch (AuthException e) {
+      if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
         reject(
             cmd,
             String.format(
@@ -1336,20 +1337,16 @@
             } else {
               if (!oldParent.equals(newParent)) {
                 if (allowProjectOwnersToChangeParent) {
-                  try {
-                    permissionBackend
-                        .user(user)
-                        .project(project.getNameKey())
-                        .check(ProjectPermission.WRITE_CONFIG);
-                  } catch (AuthException e) {
+                  if (!permissionBackend
+                      .user(user)
+                      .project(project.getNameKey())
+                      .test(ProjectPermission.WRITE_CONFIG)) {
                     reject(
                         cmd, "invalid project configuration: only project owners can set parent");
                     return;
                   }
                 } else {
-                  try {
-                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-                  } catch (AuthException e) {
+                  if (!permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
                     reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
                     return;
                   }
@@ -1430,6 +1427,12 @@
   private void parseCreate(ReceiveCommand cmd)
       throws PermissionBackendException, NoSuchProjectException, IOException {
     try (TraceTimer traceTimer = newTimer("parseCreate")) {
+      if (repo.resolve(cmd.getRefName()) != null) {
+        reject(
+            cmd,
+            String.format("Cannot create ref '%s' because it already exists.", cmd.getRefName()));
+        return;
+      }
       RevObject obj;
       try {
         obj = receivePack.getRevWalk().parseAny(cmd.getNewId());
@@ -1450,7 +1453,7 @@
         // Must pass explicit user instead of injecting a provider into CreateRefControl, since
         // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
         createRefControl.checkCreateRef(
-            Providers.of(user), receivePack.getRepository(), branch, obj);
+            Providers.of(user), receivePack.getRepository(), branch, obj, /* forPush= */ true);
       } catch (AuthException denied) {
         rejectProhibited(cmd, denied);
         return;
@@ -2132,6 +2135,9 @@
   }
 
   private boolean isPluginPushOption(String pushOptionName) {
+    if (transitionalPluginOptions.contains(pushOptionName)) {
+      return true;
+    }
     return StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false)
         .anyMatch(e -> pushOptionName.equals(e.getPluginName() + "~" + e.get().getName()));
   }
@@ -2232,7 +2238,8 @@
                             comment.message.length()))
                 .collect(toImmutableList());
         CommentValidationContext ctx =
-            CommentValidationContext.create(change.getChangeId(), change.getProject().get());
+            CommentValidationContext.create(
+                change.getChangeId(), change.getProject().get(), change.getDest().branch());
         ImmutableList<CommentValidationFailure> commentValidationFailures =
             PublishCommentUtil.findInvalidComments(ctx, commentValidators, draftsForValidation);
         magicBranch.setWithholdComments(!commentValidationFailures.isEmpty());
@@ -2248,7 +2255,7 @@
     }
   }
 
-  private void warnAboutMissingChangeId(List<CreateRequest> newChanges) {
+  private void warnAboutMissingChangeId(ImmutableList<CreateRequest> newChanges) {
     for (CreateRequest create : newChanges) {
       try {
         receivePack.getRevWalk().parseBody(create.commit);
@@ -2265,7 +2272,7 @@
     }
   }
 
-  private List<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress)
+  private ImmutableList<CreateRequest> selectNewAndReplacedChangesFromMagicBranch(Task newProgress)
       throws IOException {
     try (TraceTimer traceTimer = newTimer("selectNewAndReplacedChangesFromMagicBranch")) {
       logger.atFine().log("Finding new and replaced changes");
@@ -2280,7 +2287,7 @@
       try {
         RevCommit start = setUpWalkForSelectingChanges();
         if (start == null) {
-          return Collections.emptyList();
+          return ImmutableList.of();
         }
 
         LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
@@ -2358,7 +2365,7 @@
             reject(
                 magicBranch.cmd,
                 "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           if (commitAlreadyTracked) {
@@ -2391,7 +2398,7 @@
           if (!validationResult.isValid()) {
             // Not a change the user can propose? Abort as early as possible.
             logger.atFine().log("Aborting early due to invalid commit");
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           // Don't allow merges to be uploaded in commit chain via all-not-in-target
@@ -2428,7 +2435,7 @@
           if (newChangeIds.contains(p.changeKey)) {
             logger.atFine().log("Multiple commits with Change-Id %s", p.changeKey);
             reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           List<ChangeData> changes = p.destChanges;
@@ -2444,7 +2451,7 @@
             // this error message as Change-Id should be unique per branch.
             //
             reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           if (changes.size() == 1) {
@@ -2469,13 +2476,13 @@
                 magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
               continue;
             }
-            return Collections.emptyList();
+            return ImmutableList.of();
           }
 
           if (changes.isEmpty()) {
             if (!isValidChangeId(p.changeKey.get())) {
               reject(magicBranch.cmd, "invalid Change-Id");
-              return Collections.emptyList();
+              return ImmutableList.of();
             }
 
             // In case the change look up from the index failed,
@@ -2483,7 +2490,7 @@
             if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
               if (pending.size() == 1) {
                 reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-                return Collections.emptyList();
+                return ImmutableList.of();
               }
               itr.remove();
               continue;
@@ -2503,11 +2510,11 @@
 
       if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
         reject(magicBranch.cmd, "no new changes");
-        return Collections.emptyList();
+        return ImmutableList.of();
       }
       if (!newChanges.isEmpty() && magicBranch.edit) {
         reject(magicBranch.cmd, "edit is not supported for new changes");
-        return newChanges;
+        return ImmutableList.copyOf(newChanges);
       }
 
       SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
@@ -2521,10 +2528,10 @@
         replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
       }
       for (UpdateGroupsRequest update : updateGroups) {
-        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
+        update.groups = ImmutableList.copyOf(groups.get(update.commit));
       }
       logger.atFine().log("Finished updating groups from GroupCollector");
-      return newChanges;
+      return ImmutableList.copyOf(newChanges);
     }
   }
 
@@ -2665,7 +2672,11 @@
 
   private ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) {
     try (TraceTimer traceTimer = newTimer("lookupByChangeKey")) {
-      return new ChangeLookup(c, key, queryProvider.get().byBranchKey(magicBranch.dest, key));
+      List<ChangeData> byBranchKeyExactMatch =
+          queryProvider.get().byBranchKey(magicBranch.dest, key).stream()
+              .filter(cd -> cd.change().getKey().equals(key))
+              .collect(toList());
+      return new ChangeLookup(c, key, byBranchKeyExactMatch);
     }
   }
 
@@ -2827,7 +2838,7 @@
     }
   }
 
-  private void preparePatchSetsForReplace(List<CreateRequest> newChanges) {
+  private void preparePatchSetsForReplace(ImmutableList<CreateRequest> newChanges) {
     try (TraceTimer traceTimer =
         newTimer(
             "preparePatchSetsForReplace", Metadata.builder().resourceCount(newChanges.size()))) {
@@ -2862,7 +2873,7 @@
     try (TraceTimer traceTimer = newTimer("readChangesForReplace")) {
       replaceByChange.values().stream()
           .map(r -> r.ontoChange)
-          .map(id -> notesFactory.create(project.getNameKey(), id))
+          .map(id -> notesFactory.create(repo, project.getNameKey(), id))
           .forEach(notes -> replaceByChange.get(notes.getChangeId()).notes = notes);
     }
   }
@@ -2990,9 +3001,7 @@
           return false;
         }
 
-        try {
-          permissions.change(notes).check(ChangePermission.ADD_PATCH_SET);
-        } catch (AuthException no) {
+        if (!permissions.change(notes).test(ChangePermission.ADD_PATCH_SET)) {
           reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
           return false;
         }
@@ -3039,18 +3048,8 @@
       if ((magicBranch.workInProgress || magicBranch.ready)
           && magicBranch.workInProgress != change.isWorkInProgress()
           && !user.getAccountId().equals(change.getOwner())) {
-        boolean hasWriteConfigPermission = false;
-        try {
-          permissions.check(ProjectPermission.WRITE_CONFIG);
-          hasWriteConfigPermission = true;
-        } catch (AuthException e) {
-          // Do nothing.
-        }
-
-        if (!hasWriteConfigPermission) {
-          try {
-            permissions.change(notes).check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
-          } catch (AuthException e1) {
+        if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
+          if (!permissions.change(notes).test(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE)) {
             reject(inputCommand, ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP);
           }
         }
@@ -3179,22 +3178,20 @@
 
         RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
         replaceOp =
-            replaceOpFactory
-                .create(
-                    projectState,
-                    notes.getChange().getDest(),
-                    checkMergedInto,
-                    checkMergedInto ? inputCommand.getNewId().name() : null,
-                    priorPatchSet,
-                    priorCommit,
-                    psId,
-                    newCommit,
-                    info,
-                    groups,
-                    magicBranch,
-                    receivePack.getPushCertificate(),
-                    notes.getChange())
-                .setRequestScopePropagator(requestScopePropagator);
+            replaceOpFactory.create(
+                projectState,
+                notes.getChange(),
+                checkMergedInto,
+                checkMergedInto ? inputCommand.getNewId().name() : null,
+                priorPatchSet,
+                priorCommit,
+                psId,
+                newCommit,
+                info,
+                groups,
+                magicBranch,
+                receivePack.getPushCertificate(),
+                requestScopePropagator);
         bu.addOp(notes.getChangeId(), replaceOp);
         if (progress != null) {
           bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
@@ -3221,9 +3218,14 @@
       }
     }
 
+    @Nullable
     String getRejectMessage() {
       return replaceOp != null ? replaceOp.getRejectMessage() : null;
     }
+
+    Optional<String> getOutdatedApprovalsMessage() {
+      return replaceOp != null ? replaceOp.getOutdatedApprovalsMessage() : Optional.empty();
+    }
   }
 
   private class UpdateGroupsRequest {
@@ -3283,7 +3285,7 @@
       }
       if (isConfig(cmd)) {
         logger.atFine().log("Reloading project in cache");
-        projectCache.evict(project);
+        projectCache.evictAndReindex(project);
         ProjectState ps =
             projectCache.get(project.getNameKey()).orElseThrow(illegalState(project.getNameKey()));
         try {
@@ -3341,7 +3343,9 @@
   // REJECTED, and the return value is 'false'
   private boolean validRefOperation(ReceiveCommand cmd) {
     try (TraceTimer traceTimer = newTimer("validRefOperation")) {
-      RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
+      RefOperationValidators refValidators =
+          refValidatorsFactory.create(
+              getProject(), user, cmd, ImmutableListMultimap.copyOf(pushOptions));
 
       try {
         messages.addAll(refValidators.validateForRefOperation());
@@ -3454,7 +3458,7 @@
                 "autoCloseChanges",
                 updateFactory -> {
                   try (BatchUpdate bu =
-                          updateFactory.create(projectState.getNameKey(), user, TimeUtil.nowTs());
+                          updateFactory.create(projectState.getNameKey(), user, TimeUtil.now());
                       ObjectInserter ins = repo.newObjectInserter();
                       ObjectReader reader = ins.newReader();
                       RevWalk rw = new RevWalk(reader)) {
@@ -3476,7 +3480,7 @@
                     rw.markStart(newTip);
                     rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
 
-                    Map<Change.Key, ChangeNotes> byKey = null;
+                    Map<Change.Key, ChangeData> changeDataByKey = null;
                     List<ReplaceRequest> replaceAndClose = new ArrayList<>();
 
                     int existingPatchSets = 0;
@@ -3512,8 +3516,8 @@
 
                       for (String changeId :
                           ChangeUtil.getChangeIdsFromFooter(c, urlFormatter.get())) {
-                        if (byKey == null) {
-                          byKey =
+                        if (changeDataByKey == null) {
+                          changeDataByKey =
                               retryHelper
                                   .changeIndexQuery(
                                       "queryOpenChangesByKeyByBranch",
@@ -3521,14 +3525,15 @@
                                   .call();
                         }
 
-                        ChangeNotes onto = byKey.get(Change.key(changeId.trim()));
+                        ChangeData onto = changeDataByKey.get(Change.key(changeId.trim()));
                         if (onto != null) {
                           newPatchSets++;
                           // Hold onto this until we're done with the walk, as the call to
                           // req.validate below calls isMergedInto which resets the walk.
+                          ChangeNotes ontoNotes = onto.notes();
                           ReplaceRequest req =
-                              new ReplaceRequest(onto.getChangeId(), c, cmd, false);
-                          req.notes = onto;
+                              new ReplaceRequest(ontoNotes.getChangeId(), c, cmd, false);
+                          req.notes = ontoNotes;
                           replaceAndClose.add(req);
                           continue COMMIT;
                         }
@@ -3601,14 +3606,17 @@
     }
   }
 
-  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(
+  private Map<Change.Key, ChangeData> openChangesByKeyByBranch(
       InternalChangeQuery internalChangeQuery, BranchNameKey branch) {
     try (TraceTimer traceTimer =
         newTimer("openChangesByKeyByBranch", Metadata.builder().branchName(branch.branch()))) {
-      Map<Change.Key, ChangeNotes> r = new HashMap<>();
+      Map<Change.Key, ChangeData> r = new HashMap<>();
       for (ChangeData cd : internalChangeQuery.byBranchOpen(branch)) {
         try {
-          r.put(cd.change().getKey(), cd.notes());
+          // ChangeData is not materialised into a ChangeNotes for avoiding
+          // to load a potentially large number of changes meta-data into memory
+          // which would cause unnecessary disk I/O, CPU and heap utilisation.
+          r.put(cd.change().getKey(), cd);
         } catch (NoSuchChangeException e) {
           // Ignore deleted change
         }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index cc203ad..7c22bd8 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -91,7 +91,11 @@
         .filter(ReceiveCommitsAdvertiseRefsHook::skip)
         .collect(toImmutableList())
         .forEach(r -> advertisedRefs.remove(r));
-    rp.setAdvertisedRefs(advertisedRefs, advertiseOpenChanges(rp.getRepository()));
+    try {
+      rp.setAdvertisedRefs(advertisedRefs, advertiseOpenChanges(rp.getRepository()));
+    } catch (IOException e) {
+      throw new ServiceMayNotContinueException(e);
+    }
   }
 
   private Set<ObjectId> advertiseOpenChanges(Repository repo)
@@ -107,10 +111,10 @@
               .get()
               .setRequestedFields(
                   // Required for ChangeIsVisibleToPrdicate.
-                  ChangeField.CHANGE,
-                  ChangeField.REVIEWER,
+                  ChangeField.CHANGE_SPEC,
+                  ChangeField.REVIEWER_SPEC,
                   // Required during advertiseOpenChanges.
-                  ChangeField.PATCH_SET)
+                  ChangeField.PATCH_SET_SPEC)
               .enforceVisibility(true)
               .setLimit(limit)
               .query(
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index a9ef70e..0e17342 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.joining;
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
 import com.google.common.base.Strings;
@@ -28,7 +29,7 @@
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
@@ -46,24 +47,26 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalCopier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.EmailNewPatchSet;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
-import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.change.ReviewerOp;
+import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-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.permissions.PermissionBackendException;
@@ -75,6 +78,7 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -86,8 +90,6 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -102,7 +104,7 @@
   public interface Factory {
     ReplaceOp create(
         ProjectState projectState,
-        BranchNameKey dest,
+        Change change,
         boolean checkMergedInto,
         @Nullable String mergeResultRevId,
         @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -113,30 +115,29 @@
         List<String> groups,
         @Nullable MagicBranchInput magicBranch,
         @Nullable PushCertificate pushCertificate,
-        Change change);
+        RequestScopePropagator requestScopePropagator);
   }
 
   private static final String CHANGE_IS_CLOSED = "change is closed";
 
+  private final AccountCache accountCache;
   private final AccountResolver accountResolver;
+  private final String anonymousCowardName;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeKindCache changeKindCache;
   private final ChangeMessagesUtil cmUtil;
-  private final ExecutorService sendEmailExecutor;
+  private final EmailNewPatchSet.Factory emailNewPatchSetFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
   private final PatchSetUtil psUtil;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
   private final ReviewerModifier reviewerModifier;
-  private final Change change;
-  private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
 
   private final ProjectState projectState;
-  private final BranchNameKey dest;
+  private final Change change;
   private final boolean checkMergedInto;
   private final String mergeResultRevId;
   private final PatchSet.Id priorPatchSetId;
@@ -146,6 +147,7 @@
   private final PatchSetInfo info;
   private final MagicBranchInput magicBranch;
   private final PushCertificate pushCertificate;
+  private final RequestScopePropagator requestScopePropagator;
   private List<String> groups;
 
   private final Map<String, Short> approvals = new HashMap<>();
@@ -155,15 +157,17 @@
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
   private String mailMessage;
+  private ApprovalCopier.Result approvalCopierResult;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
-  private RequestScopePropagator requestScopePropagator;
   private ReviewerModificationList reviewerAdditions;
   private MailRecipients oldRecipients;
 
   @Inject
   ReplaceOp(
+      AccountCache accountCache,
       AccountResolver accountResolver,
+      @AnonymousCowardName String anonymousCowardName,
       ApprovalsUtil approvalsUtil,
       ChangeData.Factory changeDataFactory,
       ChangeKindCache changeKindCache,
@@ -172,15 +176,12 @@
       CommentAdded commentAdded,
       MergedByPushOp.Factory mergedByPushOpFactory,
       PatchSetUtil psUtil,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
-      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      EmailNewPatchSet.Factory emailNewPatchSetFactory,
       ReviewerModifier reviewerModifier,
-      Change change,
-      MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
       @Assisted ProjectState projectState,
-      @Assisted BranchNameKey dest,
+      @Assisted Change change,
       @Assisted boolean checkMergedInto,
       @Assisted @Nullable String mergeResultRevId,
       @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
@@ -190,8 +191,11 @@
       @Assisted PatchSetInfo info,
       @Assisted List<String> groups,
       @Assisted @Nullable MagicBranchInput magicBranch,
-      @Assisted @Nullable PushCertificate pushCertificate) {
+      @Assisted @Nullable PushCertificate pushCertificate,
+      @Assisted RequestScopePropagator requestScopePropagator) {
+    this.accountCache = accountCache;
     this.accountResolver = accountResolver;
+    this.anonymousCowardName = anonymousCowardName;
     this.approvalsUtil = approvalsUtil;
     this.changeDataFactory = changeDataFactory;
     this.changeKindCache = changeKindCache;
@@ -200,16 +204,13 @@
     this.commentAdded = commentAdded;
     this.mergedByPushOpFactory = mergedByPushOpFactory;
     this.psUtil = psUtil;
-    this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
-    this.sendEmailExecutor = sendEmailExecutor;
+    this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.reviewerModifier = reviewerModifier;
-    this.change = change;
-    this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
 
     this.projectState = projectState;
-    this.dest = dest;
+    this.change = change;
     this.checkMergedInto = checkMergedInto;
     this.mergeResultRevId = mergeResultRevId;
     this.priorPatchSetId = priorPatchSetId;
@@ -220,6 +221,7 @@
     this.groups = groups;
     this.magicBranch = magicBranch;
     this.pushCertificate = pushCertificate;
+    this.requestScopePropagator = requestScopePropagator;
   }
 
   @Override
@@ -235,7 +237,7 @@
             commitId);
 
     if (checkMergedInto) {
-      String mergedInto = findMergedInto(ctx, dest.branch(), commit);
+      String mergedInto = findMergedInto(ctx, change.getDest().branch(), commit);
       if (mergedInto != null) {
         mergedByPushOp =
             mergedByPushOpFactory.create(
@@ -338,16 +340,17 @@
     }
     reviewerAdditions.updateChange(ctx, newPatchSet);
 
-    // Check if approvals are changing in with this update. If so, add current user to reviewers.
-    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // Check if approvals are changing with this update. If so, add the current user (aka the
+    // approver) as a reviewers because all approvers must also be reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as a
     // reviewer which is needed in several other code paths.
     if (magicBranch != null && !magicBranch.labels.isEmpty()) {
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
 
-    // Approvals that are being set in the new patch-set during this operation are not available yet
-    // outside of the scope of this method. Only copied approvals are set here.
-    approvalsUtil.byPatchSet(ctx.getNotes(), newPatchSet).forEach(a -> update.putCopiedApproval(a));
+    approvalCopierResult =
+        approvalsUtil.copyApprovalsToNewPatchSet(
+            ctx.getNotes(), newPatchSet, ctx.getRevWalk(), ctx.getRepoView().getConfig(), update);
 
     mailMessage = insertChangeMessage(update, ctx, reviewMessage);
     if (mergedByPushOp == null) {
@@ -402,13 +405,12 @@
 
     // Ignore failures for reasons like the reviewer being inactive or being unable to see the
     // change. See discussion in ChangeInserter.
-    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
 
     return input;
   }
 
-  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx, String reviewMessage)
-      throws IOException {
+  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx, String reviewMessage) {
     String approvalMessage =
         ApprovalsUtil.renderMessageWithApprovals(
             patchSetId.get(), approvals, scanLabels(ctx, approvals));
@@ -422,6 +424,15 @@
     if (!Strings.isNullOrEmpty(reviewMessage)) {
       message.append("\n\n").append(reviewMessage);
     }
+    approvalsUtil
+        .formatApprovalCopierResult(approvalCopierResult, projectState.getLabelTypes())
+        .ifPresent(
+            msg -> {
+              if (Strings.isNullOrEmpty(reviewMessage) || !reviewMessage.endsWith("\n")) {
+                message.append("\n");
+              }
+              message.append("\n").append(msg);
+            });
     boolean workInProgress = ctx.getChange().isWorkInProgress();
     if (magicBranch != null && magicBranch.workInProgress) {
       workInProgress = true;
@@ -430,6 +441,7 @@
         update, message.toString(), ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
   }
 
+  @Nullable
   private String changeKindMessage(ChangeKind changeKind) {
     switch (changeKind) {
       case MERGE_FIRST_PARENT_UPDATE:
@@ -439,8 +451,8 @@
       case TRIVIAL_REBASE:
         return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
       case NO_CHANGE:
-        return ": New patch set was added with same tree, parent"
-            + (commit.getParentCount() != 1 ? "s" : "")
+        return ": New patch set was added with same tree, parent "
+            + (commit.getParentCount() != 1 ? "trees" : "tree")
             + ", and commit message as Patch Set "
             + priorPatchSetId.get()
             + ".";
@@ -452,18 +464,13 @@
     }
   }
 
-  private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, Map<String, Short> approvals)
-      throws IOException {
+  private Map<String, PatchSetApproval> scanLabels(
+      ChangeContext ctx, Map<String, Short> approvals) {
     Map<String, PatchSetApproval> current = new HashMap<>();
     // We optimize here and only retrieve current when approvals provided
     if (!approvals.isEmpty()) {
       for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getNotes(),
-              priorPatchSetId,
-              ctx.getAccountId(),
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
+          approvalsUtil.byPatchSetUser(ctx.getNotes(), priorPatchSetId, ctx.getAccountId())) {
         if (a.isLegacySubmit()) {
           continue;
         }
@@ -496,16 +503,30 @@
   @Override
   public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
-    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-      // TODO(dborowitz): Merge email templates so we only have to send one.
-      Runnable e = new ReplaceEmailTask(ctx);
-      if (requestScopePropagator != null) {
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
-      } else {
-        e.run();
-      }
-    }
+
+    // TODO(dborowitz): Merge email templates so we only have to send one.
+    emailNewPatchSetFactory
+        .create(
+            ctx,
+            newPatchSet,
+            mailMessage,
+            approvalCopierResult.outdatedApprovals().stream()
+                .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+                .collect(toImmutableSet()),
+            Streams.concat(
+                    oldRecipients.getReviewers().stream(),
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
+                        .map(PatchSetApproval::accountId))
+                .collect(toImmutableSet()),
+            Streams.concat(
+                    oldRecipients.getCcOnly().stream(),
+                    reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs).stream())
+                .collect(toImmutableSet()),
+            changeKind,
+            notes.getMetaId())
+        .setRequestScopePropagator(requestScopePropagator)
+        .sendAsync();
+
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
     revisionCreated.fire(
         ctx.getChangeData(notes), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
@@ -519,49 +540,6 @@
     }
   }
 
-  private class ReplaceEmailTask implements Runnable {
-    private final Context ctx;
-
-    private ReplaceEmailTask(Context ctx) {
-      this.ctx = ctx;
-    }
-
-    @Override
-    public void run() {
-      try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
-        emailSender.setFrom(ctx.getAccount().account().id());
-        emailSender.setPatchSet(newPatchSet, info);
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setNotify(ctx.getNotify(notes.getChangeId()));
-        emailSender.addReviewers(
-            Streams.concat(
-                    oldRecipients.getReviewers().stream(),
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedReviewers).stream()
-                        .map(PatchSetApproval::accountId))
-                .collect(toImmutableSet()));
-        emailSender.addExtraCC(
-            Streams.concat(
-                    oldRecipients.getCcOnly().stream(),
-                    reviewerAdditions.flattenResults(AddReviewersOp.Result::addedCCs).stream())
-                .collect(toImmutableSet()));
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSetId));
-        // TODO(dborowitz): Support byEmail
-        emailSender.send();
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log(
-            "Cannot send email for new patch set %s", newPatchSet.id());
-      }
-    }
-
-    @Override
-    public String toString() {
-      return "send-email newpatchset";
-    }
-  }
-
   private void fireApprovalsEvent(PostUpdateContext ctx) {
     if (approvals.isEmpty()) {
       return;
@@ -611,15 +589,46 @@
     return rejectMessage;
   }
 
+  public Optional<String> getOutdatedApprovalsMessage() {
+    if (approvalCopierResult == null || approvalCopierResult.outdatedApprovals().isEmpty()) {
+      return Optional.empty();
+    }
+
+    return Optional.of(
+        "The following approvals got outdated and were removed:\n"
+            + approvalCopierResult.outdatedApprovals().stream()
+                .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+                .map(
+                    outdatedApproval ->
+                        String.format(
+                            "* %s by %s",
+                            LabelVote.create(outdatedApproval.label(), outdatedApproval.value())
+                                .format(),
+                            getNameFor(outdatedApproval.accountId())))
+                .sorted()
+                .collect(joining("\n")));
+  }
+
+  private String getNameFor(Account.Id accountId) {
+    Optional<Account> account = accountCache.get(accountId).map(AccountState::account);
+    String name = null;
+    if (account.isPresent()) {
+      name = account.get().fullName();
+      if (name == null) {
+        name = account.get().preferredEmail();
+      }
+    }
+    if (name == null) {
+      name = anonymousCowardName + " #" + accountId;
+    }
+    return name;
+  }
+
   public ReceiveCommand getCommand() {
     return cmd;
   }
 
-  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
-    this.requestScopePropagator = requestScopePropagator;
-    return this;
-  }
-
+  @Nullable
   private static String findMergedInto(Context ctx, String first, RevCommit commit) {
     try {
       RevWalk rw = ctx.getRevWalk();
diff --git a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
index c54ab25..4d2805d 100644
--- a/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
+++ b/java/com/google/gerrit/server/git/receive/testing/TestRefAdvertiser.java
@@ -19,6 +19,8 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -37,12 +39,13 @@
   @VisibleForTesting
   @AutoValue
   public abstract static class Result {
-    public abstract Map<String, Ref> allRefs();
+    public abstract ImmutableMap<String, Ref> allRefs();
 
-    public abstract Set<ObjectId> additionalHaves();
+    public abstract ImmutableSet<ObjectId> additionalHaves();
 
     public static Result create(Map<String, Ref> allRefs, Set<ObjectId> additionalHaves) {
-      return new AutoValue_TestRefAdvertiser_Result(allRefs, additionalHaves);
+      return new AutoValue_TestRefAdvertiser_Result(
+          ImmutableMap.copyOf(allRefs), ImmutableSet.copyOf(additionalHaves));
     }
   }
 
diff --git a/java/com/google/gerrit/server/git/validators/AccountValidator.java b/java/com/google/gerrit/server/git/validators/AccountValidator.java
index 4755f5f..b38f405 100644
--- a/java/com/google/gerrit/server/git/validators/AccountValidator.java
+++ b/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -28,7 +28,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -77,7 +76,7 @@
       }
     }
 
-    List<String> messages = new ArrayList<>();
+    ImmutableList.Builder<String> messages = ImmutableList.builder();
     Optional<Account> newAccount;
     try {
       newAccount = loadAccount(accountId, allUsersRepo, rw, newId, messages);
@@ -92,7 +91,7 @@
       return ImmutableList.of(String.format("account '%s' does not exist", accountId.get()));
     }
 
-    if (accountId.equals(self.get().getAccountId()) && !newAccount.get().isActive()) {
+    if (!newAccount.get().isActive() && accountId.equals(self.get().getAccountId())) {
       messages.add("cannot deactivate own account");
     }
 
@@ -108,7 +107,7 @@
       }
     }
 
-    return ImmutableList.copyOf(messages);
+    return messages.build();
   }
 
   private Optional<Account> loadAccount(
@@ -116,7 +115,7 @@
       Repository allUsersRepo,
       RevWalk rw,
       ObjectId commit,
-      @Nullable List<String> messages)
+      @Nullable ImmutableList.Builder<String> messages)
       throws IOException, ConfigInvalidException {
     rw.reset();
     AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo);
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
index 6e640f3..b887323 100644
--- a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -23,7 +23,6 @@
 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;
@@ -32,7 +31,8 @@
 
 /**
  * Limits the total size of all comments and change messages to prevent space/time complexity
- * issues. Note that autogenerated change messages are not subject to validation.
+ * issues. Note that autogenerated change messages are not subject to validation. However, we still
+ * count autogenerated messages for the limit (which will be notified on a further comment).
  */
 public class CommentCumulativeSizeValidator implements CommentValidator {
   public static final int DEFAULT_CUMULATIVE_COMMENT_SIZE_LIMIT = 3 << 20;
@@ -60,17 +60,11 @@
                     notes.getRobotComments().values().stream())
                 .mapToInt(Comment::getApproximateSize)
                 .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();
+            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
     int newCumulativeSize =
         comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
     ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
-    if (!comments.isEmpty() && existingCumulativeSize + newCumulativeSize > maxCumulativeSize) {
+    if (!comments.isEmpty() && !isEnoughSpace(notes, newCumulativeSize, maxCumulativeSize)) {
       // This warning really applies to the set of all comments, but we need to pick one to attach
       // the message to.
       CommentForValidation commentForFailureMessage = Iterables.getLast(comments);
@@ -84,4 +78,19 @@
     }
     return failures.build();
   }
+
+  /**
+   * Returns {@code true} if there is available space and the new size that we wish to add is less
+   * than the maximum allowed size. {@code false} otherwise (if there is not enough space).
+   */
+  public static boolean isEnoughSpace(ChangeNotes notes, int addedBytes, int maxCumulativeSize) {
+    int existingCumulativeSize =
+        Stream.concat(
+                    notes.getHumanComments().values().stream(),
+                    notes.getRobotComments().values().stream())
+                .mapToInt(Comment::getApproximateSize)
+                .sum()
+            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+    return existingCumulativeSize + addedBytes < maxCumulativeSize;
+  }
 }
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 056407e..999f810 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -22,6 +22,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FooterConstants;
@@ -48,10 +49,12 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.LabelConfigValidator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
@@ -104,6 +107,7 @@
     private final AccountValidator accountValidator;
     private final ProjectCache projectCache;
     private final ProjectConfig.Factory projectConfigFactory;
+    private final DiffOperations diffOperations;
     private final Config config;
 
     @Inject
@@ -118,7 +122,8 @@
         ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
         AccountValidator accountValidator,
         ProjectCache projectCache,
-        ProjectConfig.Factory projectConfigFactory) {
+        ProjectConfig.Factory projectConfigFactory,
+        DiffOperations diffOperations) {
       this.gerritIdent = gerritIdent;
       this.urlFormatter = urlFormatter;
       this.config = config;
@@ -130,6 +135,7 @@
       this.accountValidator = accountValidator;
       this.projectCache = projectCache;
       this.projectConfigFactory = projectConfigFactory;
+      this.diffOperations = diffOperations;
     }
 
     public CommitValidators forReceiveCommits(
@@ -161,7 +167,8 @@
           .add(new PluginCommitValidationListener(pluginValidators, skipValidation))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
-          .add(new GroupCommitValidator(allUsers));
+          .add(new GroupCommitValidator(allUsers))
+          .add(new LabelConfigValidator(diffOperations));
       return new CommitValidators(validators.build());
     }
 
@@ -190,7 +197,8 @@
           .add(new PluginCommitValidationListener(pluginValidators))
           .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
-          .add(new GroupCommitValidator(allUsers));
+          .add(new GroupCommitValidator(allUsers))
+          .add(new LabelConfigValidator(diffOperations));
       return new CommitValidators(validators.build());
     }
 
@@ -371,14 +379,18 @@
       // If there are no SSH keys, the commit-msg hook must be installed via
       // HTTP(S)
       Optional<String> webUrl = urlFormatter.getWebUrl();
+
+      String httpHook =
+          String.format(
+              "f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"",
+              webUrl.get());
+
       if (hostKeys.isEmpty()) {
         checkState(webUrl.isPresent());
-        return String.format(
-            "  f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"",
-            webUrl.get());
+        return httpHook;
       }
 
-      // SSH keys exist, so the hook can be installed with scp.
+      // SSH keys exist, so the hook might be able to be installed with scp.
       String sshHost;
       int sshPort;
       String host = hostKeys.get(0).getHost();
@@ -396,9 +408,17 @@
         sshPort = 22;
       }
 
-      return String.format(
-          "  gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
-          sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
+      // TODO(15944): Remove once both SFTP/SCP protocol are supported.
+      //
+      // In newer versions of OpenSSH, the default hook installation command will fail with a
+      // cryptic error because the scp binary defaults to a different protocol.
+      String scpFlagHint = "(for OpenSSH >= 9.0 you need to add the flag '-O' to the scp command)";
+
+      String sshHook =
+          String.format(
+              "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
+              sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
+      return String.format("  %s\n%s\nor, for http(s):\n  %s", sshHook, scpFlagHint, httpHook);
     }
   }
 
@@ -441,7 +461,7 @@
         // This happens e.g. for cherrypicks.
         if (!receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
           logger.atWarning().withCause(e).log(
-              "Failed to validate file count for commit: %s", receiveEvent.commit.toString());
+              "Failed to validate file count for commit: %s", receiveEvent.commit);
         }
       }
       return Collections.emptyList();
@@ -502,7 +522,7 @@
             for (ValidationError err : cfg.getValidationErrors()) {
               addError("  " + err.getMessage(), messages);
             }
-            throw new ConfigInvalidException("invalid project configuration");
+            throw new CommitValidationException("invalid project configuration", messages);
           }
           if (allUsers.equals(receiveEvent.project.getNameKey())
               && !allProjects.equals(cfg.getProject().getParent(allProjects))) {
@@ -510,9 +530,12 @@
             addError(
                 String.format("  %s must inherit from %s", allUsers.get(), allProjects.get()),
                 messages);
-            throw new ConfigInvalidException("invalid project configuration");
+            throw new CommitValidationException("invalid project configuration", messages);
           }
         } catch (ConfigInvalidException | IOException e) {
+          if (e instanceof ConfigInvalidException && !Strings.isNullOrEmpty(e.getMessage())) {
+            addError(e.getMessage(), messages);
+          }
           logger.atSevere().withCause(e).log(
               "User %s tried to push an invalid project configuration %s for project %s",
               user.getLoggableName(),
@@ -541,10 +564,10 @@
         return Collections.emptyList();
       }
       try {
-        perm.check(RefPermission.MERGE);
-        return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException("you are not allowed to upload merges", e);
+        if (perm.test(RefPermission.MERGE)) {
+          return Collections.emptyList();
+        }
+        throw new CommitValidationException("you are not allowed to upload merges");
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check MERGE");
         throw new CommitValidationException("internal auth error");
@@ -639,10 +662,10 @@
       }
       if (!sboAuthor && !sboCommitter && !sboMe) {
         try {
-          perm.check(RefPermission.FORGE_COMMITTER);
-        } catch (AuthException denied) {
-          throw new CommitValidationException(
-              "not Signed-off-by author/committer/uploader in message footer", denied);
+          if (!perm.test(RefPermission.FORGE_COMMITTER)) {
+            throw new CommitValidationException(
+                "not Signed-off-by author/committer/uploader in message footer");
+          }
         } catch (PermissionBackendException e) {
           logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
           throw new CommitValidationException("internal auth error");
@@ -673,11 +696,11 @@
         return Collections.emptyList();
       }
       try {
-        perm.check(RefPermission.FORGE_AUTHOR);
+        if (!perm.test(RefPermission.FORGE_AUTHOR)) {
+          throw new CommitValidationException(
+              "invalid author", invalidEmail("author", author, user, urlFormatter));
+        }
         return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException(
-            "invalid author", invalidEmail("author", author, user, urlFormatter), e);
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_AUTHOR");
         throw new CommitValidationException("internal auth error");
@@ -706,11 +729,11 @@
         return Collections.emptyList();
       }
       try {
-        perm.check(RefPermission.FORGE_COMMITTER);
+        if (!perm.test(RefPermission.FORGE_COMMITTER)) {
+          throw new CommitValidationException(
+              "invalid committer", invalidEmail("committer", committer, user, urlFormatter));
+        }
         return Collections.emptyList();
-      } catch (AuthException e) {
-        throw new CommitValidationException(
-            "invalid committer", invalidEmail("committer", committer, user, urlFormatter), e);
       } catch (PermissionBackendException e) {
         logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
         throw new CommitValidationException("internal auth error");
@@ -778,9 +801,7 @@
         }
         return Collections.emptyList();
       } catch (IOException e) {
-        String m = "error checking banned commits";
-        logger.atWarning().withCause(e).log(m);
-        throw new CommitValidationException(m, e);
+        throw new CommitValidationException("error checking banned commits", e);
       }
     }
   }
@@ -819,9 +840,7 @@
           }
           return msgs;
         } catch (IOException | ConfigInvalidException e) {
-          String m = "error validating external IDs";
-          logger.atWarning().withCause(e).log(m);
-          throw new CommitValidationException(m, e);
+          throw new CommitValidationException("error validating external IDs", e);
         }
       }
       return Collections.emptyList();
@@ -876,9 +895,8 @@
                   .collect(toList()));
         }
       } catch (IOException e) {
-        String m = String.format("Validating update for account %s failed", accountId.get());
-        logger.atSevere().withCause(e).log(m);
-        throw new CommitValidationException(m, e);
+        throw new CommitValidationException(
+            String.format("Validating update for account %s failed", accountId.get()), e);
       }
       return Collections.emptyList();
     }
@@ -969,6 +987,9 @@
       try {
         return new URL(canonicalWebUrl).getHost();
       } catch (MalformedURLException ignored) {
+        logger.atWarning().log(
+            "configured canonical web URL is invalid, using system default: %s",
+            ignored.getMessage());
       }
     }
 
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 6b145ca..40ce671 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -195,9 +195,9 @@
             if (!oldParent.equals(newParent)) {
               if (!allowProjectOwnersToChangeParent) {
                 try {
-                  permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
-                } catch (AuthException e) {
-                  throw new MergeValidationException(SET_BY_ADMIN, e);
+                  if (!permissionBackend.user(caller).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+                    throw new MergeValidationException(SET_BY_ADMIN);
+                  }
                 } catch (PermissionBackendException e) {
                   logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
                   throw new MergeValidationException("validation unavailable", e);
@@ -235,7 +235,7 @@
             String oldValue =
                 destProject.getPluginConfig(e.getPluginName()).getString(e.getExportName());
 
-            if ((!Objects.equals(value, oldValue)) && !configEntry.isEditable(destProject)) {
+            if (!Objects.equals(value, oldValue) && !configEntry.isEditable(destProject)) {
               throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
             }
 
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index f3b6983..9ac3c89 100644
--- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -16,6 +16,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
@@ -33,6 +34,7 @@
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Locale;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -46,7 +48,11 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    RefOperationValidators create(Project project, IdentifiedUser user, ReceiveCommand cmd);
+    RefOperationValidators create(
+        Project project,
+        IdentifiedUser user,
+        ReceiveCommand cmd,
+        ImmutableListMultimap<String, String> pushOptions);
   }
 
   public static ReceiveCommand getCommand(RefUpdate update, ReceiveCommand.Type type) {
@@ -66,7 +72,8 @@
       PluginSetContext<RefOperationValidationListener> refOperationValidationListeners,
       @Assisted Project project,
       @Assisted IdentifiedUser user,
-      @Assisted ReceiveCommand cmd) {
+      @Assisted ReceiveCommand cmd,
+      @Assisted ImmutableListMultimap<String, String> pushOptions) {
     this.perm = permissionBackend.user(user);
     this.allUsersName = allUsersName;
     this.refOperationValidationListeners = refOperationValidationListeners;
@@ -74,6 +81,7 @@
     event.command = cmd;
     event.project = project;
     event.user = user;
+    event.pushOptions = pushOptions;
   }
 
   /**
@@ -89,7 +97,7 @@
           new DisallowCreationAndDeletionOfGerritMaintainedBranches(perm, allUsersName)
               .onRefOperation(event));
       refOperationValidationListeners.runEach(
-          l -> l.onRefOperation(event), ValidationException.class);
+          l -> messages.addAll(l.onRefOperation(event)), ValidationException.class);
     } catch (ValidationException e) {
       messages.add(new ValidationMessage(e.getMessage(), true));
       withException = true;
@@ -106,13 +114,30 @@
       throws RefOperationValidationException {
     String header =
         String.format(
-            "Ref \"%s\" %S in project %s validation failed",
-            event.command.getRefName(), event.command.getType(), event.project.getName());
-    logger.atSevere().log(header);
+            "Validation for %s of ref '%s' in project %s failed:",
+            formatReceiveCommandType(event.command.getType()),
+            event.command.getRefName(),
+            event.project.getName());
+    logger.atSevere().log("%s", header);
     throw new RefOperationValidationException(
         header, messages.stream().filter(ValidationMessage::isError).collect(toImmutableList()));
   }
 
+  private static String formatReceiveCommandType(ReceiveCommand.Type type) {
+    switch (type) {
+      case CREATE:
+        return "creation";
+      case DELETE:
+        return "deletion";
+      case UPDATE:
+        return "update";
+      case UPDATE_NONFASTFORWARD:
+        return "non-fast-forward update";
+      default:
+        return type.toString().toLowerCase(Locale.US);
+    }
+  }
+
   private static class DisallowCreationAndDeletionOfGerritMaintainedBranches
       implements RefOperationValidationListener {
     private final PermissionBackend.WithUser perm;
@@ -132,8 +157,10 @@
             && !refEvent.command.getRefName().equals(RefNames.REFS_USERS_DEFAULT)) {
           if (refEvent.command.getType().equals(ReceiveCommand.Type.CREATE)) {
             try {
-              perm.check(GlobalPermission.ACCESS_DATABASE);
-            } catch (AuthException | PermissionBackendException e) {
+              if (!perm.test(GlobalPermission.ACCESS_DATABASE)) {
+                throw new ValidationException("Not allowed to create user branch.");
+              }
+            } catch (PermissionBackendException e) {
               throw new ValidationException("Not allowed to create user branch.", e);
             }
             if (Account.Id.fromRef(refEvent.command.getRefName()) == null) {
diff --git a/java/com/google/gerrit/server/group/GroupAuditService.java b/java/com/google/gerrit/server/group/GroupAuditService.java
index 30e5d3c..21959ec 100644
--- a/java/com/google/gerrit/server/group/GroupAuditService.java
+++ b/java/com/google/gerrit/server/group/GroupAuditService.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.AuditEvent;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 public interface GroupAuditService {
   void dispatch(AuditEvent action);
@@ -27,23 +27,23 @@
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> addedMembers,
-      Timestamp addedOn);
+      Instant addedOn);
 
   void dispatchDeleteMembers(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<Account.Id> deletedMembers,
-      Timestamp deletedOn);
+      Instant deletedOn);
 
   void dispatchAddSubgroups(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> addedSubgroups,
-      Timestamp addedOn);
+      Instant addedOn);
 
   void dispatchDeleteSubgroups(
       Account.Id actor,
       AccountGroup.UUID updatedGroup,
       ImmutableSet<AccountGroup.UUID> deletedSubgroups,
-      Timestamp deletedOn);
+      Instant deletedOn);
 }
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index 546614c..001a153 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
@@ -84,6 +85,7 @@
    * @param id ID of the group, can be a group UUID, a group name or a legacy group ID
    * @return the group, null if no group is found for the given group ID
    */
+  @Nullable
   public GroupDescription.Basic parseId(String id) {
     logger.atFine().log("Parsing group %s", id);
 
diff --git a/java/com/google/gerrit/server/group/GroupResource.java b/java/com/google/gerrit/server/group/GroupResource.java
index b0e81ec..dfcdbd7 100644
--- a/java/com/google/gerrit/server/group/GroupResource.java
+++ b/java/com/google/gerrit/server/group/GroupResource.java
@@ -22,8 +22,7 @@
 import java.util.Optional;
 
 public class GroupResource implements RestResource {
-  public static final TypeLiteral<RestView<GroupResource>> GROUP_KIND =
-      new TypeLiteral<RestView<GroupResource>>() {};
+  public static final TypeLiteral<RestView<GroupResource>> GROUP_KIND = new TypeLiteral<>() {};
 
   private final GroupControl control;
 
diff --git a/java/com/google/gerrit/server/group/InternalGroupDescription.java b/java/com/google/gerrit/server/group/InternalGroupDescription.java
index 62ebcfe..984daea 100644
--- a/java/com/google/gerrit/server/group/InternalGroupDescription.java
+++ b/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.InternalGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 
 public class InternalGroupDescription implements GroupDescription.Internal {
 
@@ -77,7 +77,7 @@
   }
 
   @Override
-  public Timestamp getCreatedOn() {
+  public Instant getCreatedOn() {
     return internalGroup.getCreatedOn();
   }
 
diff --git a/java/com/google/gerrit/server/group/MemberResource.java b/java/com/google/gerrit/server/group/MemberResource.java
index b12cadd..de8cc02 100644
--- a/java/com/google/gerrit/server/group/MemberResource.java
+++ b/java/com/google/gerrit/server/group/MemberResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class MemberResource extends GroupResource {
-  public static final TypeLiteral<RestView<MemberResource>> MEMBER_KIND =
-      new TypeLiteral<RestView<MemberResource>>() {};
+  public static final TypeLiteral<RestView<MemberResource>> MEMBER_KIND = new TypeLiteral<>() {};
 
   private final IdentifiedUser user;
 
diff --git a/java/com/google/gerrit/server/group/SubgroupResource.java b/java/com/google/gerrit/server/group/SubgroupResource.java
index 21356be..7d917a7 100644
--- a/java/com/google/gerrit/server/group/SubgroupResource.java
+++ b/java/com/google/gerrit/server/group/SubgroupResource.java
@@ -21,7 +21,7 @@
 
 public class SubgroupResource extends GroupResource {
   public static final TypeLiteral<RestView<SubgroupResource>> SUBGROUP_KIND =
-      new TypeLiteral<RestView<SubgroupResource>>() {};
+      new TypeLiteral<>() {};
 
   private final GroupDescription.Basic member;
 
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 5d50d22..0471acc 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -21,6 +21,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
@@ -45,9 +46,9 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.NavigableMap;
 import java.util.Optional;
 import java.util.Set;
-import java.util.SortedMap;
 import java.util.TreeMap;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -89,7 +90,7 @@
   }
 
   private final ImmutableSet<String> reservedNames;
-  private final SortedMap<String, GroupReference> namesToGroups;
+  private final NavigableMap<String, GroupReference> namesToGroups;
   private final ImmutableSet<String> names;
   private final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
   private final ImmutableSet<AccountGroup.UUID> externalUserMemberships;
@@ -97,7 +98,7 @@
   @Inject
   @VisibleForTesting
   public SystemGroupBackend(@GerritServerConfig Config cfg) {
-    SortedMap<String, GroupReference> n = new TreeMap<>();
+    NavigableMap<String, GroupReference> n = new TreeMap<>();
     ImmutableMap.Builder<AccountGroup.UUID, GroupReference> u = ImmutableMap.builder();
 
     ImmutableSet.Builder<String> reservedNamesBuilder = ImmutableSet.builder();
@@ -112,7 +113,7 @@
       u.put(ref.getUUID(), ref);
     }
     reservedNames = reservedNamesBuilder.build();
-    namesToGroups = Collections.unmodifiableSortedMap(n);
+    namesToGroups = Collections.unmodifiableNavigableMap(n);
     names =
         ImmutableSet.copyOf(
             namesToGroups.values().stream().map(GroupReference::getName).collect(toSet()));
@@ -140,6 +141,7 @@
     return isSystemGroup(uuid);
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     final GroupReference ref = uuids.get(uuid);
@@ -157,11 +159,13 @@
         return ref.getUUID();
       }
 
+      @Nullable
       @Override
       public String getUrl() {
         return null;
       }
 
+      @Nullable
       @Override
       public String getEmailAddress() {
         return null;
@@ -172,9 +176,10 @@
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     String nameLC = name.toLowerCase(Locale.US);
-    SortedMap<String, GroupReference> matches = namesToGroups.tailMap(nameLC);
+    NavigableMap<String, GroupReference> matches =
+        namesToGroups.tailMap(nameLC, /* inclusive= */ true);
     if (matches.isEmpty()) {
-      return Collections.emptyList();
+      return new ArrayList<>();
     }
 
     List<GroupReference> r = new ArrayList<>(matches.size());
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index d8f0a0f..4c1f69b 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -31,7 +31,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
@@ -166,7 +166,7 @@
     return Optional.of(
         new AutoValue_AuditLogReader_ParsedCommit(
             authorId.get(),
-            new Timestamp(c.getAuthorIdent().getWhen().getTime()),
+            c.getAuthorIdent().getWhenAsInstant(),
             ImmutableList.copyOf(addedMembers),
             ImmutableList.copyOf(removedMembers),
             ImmutableList.copyOf(addedSubgroups),
@@ -257,7 +257,7 @@
   abstract static class ParsedCommit {
     abstract Account.Id authorId();
 
-    abstract Timestamp when();
+    abstract Instant when();
 
     abstract ImmutableList<Account.Id> addedMembers();
 
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index c187186..4f2c049 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Optional;
 import java.util.function.Function;
@@ -279,7 +279,7 @@
       rw.markStart(revision);
       rw.sort(RevSort.REVERSE);
       RevCommit earliestCommit = rw.next();
-      Timestamp createdOn = new Timestamp(earliestCommit.getCommitTime() * 1000L);
+      Instant createdOn = Instant.ofEpochSecond(earliestCommit.getCommitTime());
 
       Config config = readConfig(GROUP_CONFIG_FILE);
       ImmutableSet<Account.Id> members = readMembers();
@@ -314,9 +314,9 @@
 
     // Commit timestamps are internally truncated to seconds. To return the correct 'createdOn' time
     // for new groups, we explicitly need to truncate the timestamp here.
-    Timestamp commitTimestamp =
+    Instant commitTimestamp =
         TimeUtil.truncateToSecond(
-            groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::nowTs));
+            groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::now));
     commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
     commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
 
@@ -346,7 +346,7 @@
     return Optional.empty();
   }
 
-  private InternalGroup updateGroup(Timestamp commitTimestamp)
+  private InternalGroup updateGroup(Instant commitTimestamp)
       throws IOException, ConfigInvalidException {
     Config config = updateGroupProperties();
 
@@ -358,7 +358,7 @@
         loadedGroup.map(InternalGroup::getSubgroups).orElseGet(ImmutableSet::of);
     Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups = updateSubgroups(originalSubgroups);
 
-    Timestamp createdOn = loadedGroup.map(InternalGroup::getCreatedOn).orElse(commitTimestamp);
+    Instant createdOn = loadedGroup.map(InternalGroup::getCreatedOn).orElse(commitTimestamp);
 
     return createFrom(
         groupUuid,
@@ -453,7 +453,7 @@
       Config config,
       ImmutableSet<Account.Id> members,
       ImmutableSet<AccountGroup.UUID> subgroups,
-      Timestamp createdOn,
+      Instant createdOn,
       ObjectId refState)
       throws ConfigInvalidException {
     InternalGroup.Builder group = InternalGroup.builder();
diff --git a/java/com/google/gerrit/server/group/db/GroupDelta.java b/java/com/google/gerrit/server/group/db/GroupDelta.java
index 69cb936..ad9c8bd 100644
--- a/java/com/google/gerrit/server/group/db/GroupDelta.java
+++ b/java/com/google/gerrit/server/group/db/GroupDelta.java
@@ -18,7 +18,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 import java.util.Set;
 
@@ -107,7 +107,7 @@
    * in the audit log. For this reason, specifying this field will have an effect on the resulting
    * audit log.
    */
-  public abstract Optional<Timestamp> getUpdatedOn();
+  public abstract Optional<Instant> getUpdatedOn();
 
   public abstract Builder toBuilder();
 
@@ -184,12 +184,12 @@
     public abstract SubgroupModification getSubgroupModification();
 
     /**
-     * Defines the {@code Timestamp} to be used for the NoteDb commits of the update. If not
-     * specified, the current {@code Timestamp} when creating the commit will be used.
+     * Defines the {@code Instant} to be used for the NoteDb commits of the update. If not
+     * specified, the current {@code Instant} when creating the commit will be used.
      *
      * <p>See {@link #getUpdatedOn()}
      */
-    public abstract Builder setUpdatedOn(Timestamp timestamp);
+    public abstract Builder setUpdatedOn(Instant timestamp);
 
     public abstract GroupDelta build();
   }
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index 24bcaf0..dd8534d 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -231,7 +231,7 @@
 
   public static void ensureConsistentWithGroupNameNotes(
       Repository allUsersRepo, InternalGroup group) throws IOException {
-    List<ConsistencyCheckInfo.ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyCheckInfo.ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, group.getNameKey(), group.getGroupUUID());
     problems.forEach(GroupsNoteDbConsistencyChecker::logConsistencyProblem);
@@ -246,7 +246,7 @@
    * @return a list of {@code ConsistencyProblemInfo} containing the problem details.
    */
   @VisibleForTesting
-  static List<ConsistencyProblemInfo> checkWithGroupNameNotes(
+  static ImmutableList<ConsistencyProblemInfo> checkWithGroupNameNotes(
       Repository allUsersRepo, AccountGroup.NameKey groupName, AccountGroup.UUID groupUUID)
       throws IOException {
     try {
@@ -259,7 +259,7 @@
 
       AccountGroup.UUID uuid = groupRef.get().getUUID();
 
-      List<ConsistencyProblemInfo> problems = new ArrayList<>();
+      ImmutableList.Builder<ConsistencyProblemInfo> problems = ImmutableList.builder();
       if (!Objects.equals(groupUUID, uuid)) {
         problems.add(
             warning(
@@ -273,7 +273,7 @@
         problems.add(
             warning("group note of name '%s' claims to represent name of '%s'", name, actualName));
       }
-      return problems;
+      return problems.build();
     } catch (ConfigInvalidException e) {
       return ImmutableList.of(
           warning("fail to check consistency with group name notes: %s", e.getMessage()));
@@ -287,9 +287,9 @@
 
   public static void logConsistencyProblem(ConsistencyProblemInfo p) {
     if (p.status == ConsistencyProblemInfo.Status.WARNING) {
-      logger.atWarning().log(p.message);
+      logger.atWarning().log("%s", p.message);
     } else {
-      logger.atSevere().log(p.message);
+      logger.atSevere().log("%s", p.message);
     }
   }
 
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 9aa5cfd..87d8db1 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -50,7 +50,7 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -115,7 +115,6 @@
   private final RetryHelper retryHelper;
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -150,7 +149,6 @@
   }
 
   @AssistedInject
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -185,7 +183,6 @@
         Optional.of(currentUser));
   }
 
-  @SuppressWarnings("BindingAnnotationWithoutInject")
   private GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -248,7 +245,7 @@
   }
 
   private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
-    return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+    return user.newCommitterIdent(ident);
   }
 
   /**
@@ -292,9 +289,9 @@
     try (TraceTimer ignored =
         TraceContext.newTimer(
             "Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
-      Optional<Timestamp> updatedOn = groupDelta.getUpdatedOn();
+      Optional<Instant> updatedOn = groupDelta.getUpdatedOn();
       if (!updatedOn.isPresent()) {
-        updatedOn = Optional.of(TimeUtil.nowTs());
+        updatedOn = Optional.of(TimeUtil.now());
         groupDelta = groupDelta.toBuilder().setUpdatedOn(updatedOn.get()).build();
       }
 
@@ -505,7 +502,7 @@
     }
   }
 
-  private void dispatchAuditEventsOnGroupUpdate(UpdateResult result, Timestamp updatedOn) {
+  private void dispatchAuditEventsOnGroupUpdate(UpdateResult result, Instant updatedOn) {
     if (!currentUser.isPresent()) {
       return;
     }
diff --git a/java/com/google/gerrit/server/group/db/RenameGroupOp.java b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
index 843b346..257bc16 100644
--- a/java/com/google/gerrit/server/group/db/RenameGroupOp.java
+++ b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
@@ -123,7 +123,7 @@
       //
       GroupReference ref = config.getGroup(uuid);
       if (ref == null || newName.equals(ref.getName())) {
-        projectCache.evict(config.getProject());
+        projectCache.evictAndReindex(config.getProject());
         return;
       }
 
@@ -132,7 +132,7 @@
       md.setMessage("Rename group " + oldName + " to " + newName + "\n");
       try {
         config.commit(md);
-        projectCache.evict(config.getProject());
+        projectCache.evictAndReindex(config.getProject());
         success = true;
       } catch (IOException e) {
         logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index 77bb777..bb2b20d 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -7,6 +7,7 @@
     testonly = True,
     srcs = glob(["*.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//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 8a1221e..f422f6a 100644
--- a/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
+++ b/java/com/google/gerrit/server/group/testing/InternalGroupSubject.java
@@ -24,7 +24,7 @@
 import com.google.common.truth.Subject;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.eclipse.jgit.lib.ObjectId;
 
 public class InternalGroupSubject extends Subject {
@@ -79,7 +79,7 @@
     return check("isVisibleToAll()").that(group.isVisibleToAll());
   }
 
-  public ComparableSubject<Timestamp> createdOn() {
+  public ComparableSubject<Instant> createdOn() {
     isNotNull();
     return check("getCreatedOn()").that(group.getCreatedOn());
   }
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 601ac59..b0e270c 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -18,6 +18,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
@@ -32,7 +33,7 @@
 
 /** Implementation of GroupBackend for tests. */
 public class TestGroupBackend implements GroupBackend {
-  private static final String PREFIX = "testbackend:";
+  public static final String PREFIX = "testbackend:";
 
   private final Map<AccountGroup.UUID, GroupDescription.Basic> groups = new HashMap<>();
   private final Map<Account.Id, GroupMembership> memberships = new HashMap<>();
@@ -111,6 +112,7 @@
     return false;
   }
 
+  @Nullable
   @Override
   public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     return uuid == null ? null : groups.get(uuid);
@@ -118,6 +120,13 @@
 
   @Override
   public Collection<GroupReference> suggest(String name, ProjectState project) {
+    AccountGroup.UUID uuid = AccountGroup.uuid(name);
+    if (handles(uuid)) {
+      GroupDescription.Basic g = get(uuid);
+      if (g != null) {
+        return ImmutableList.of(GroupReference.forGroup(g));
+      }
+    }
     return ImmutableList.of();
   }
 
diff --git a/java/com/google/gerrit/server/index/AbstractIndexModule.java b/java/com/google/gerrit/server/index/AbstractIndexModule.java
index 352971f..81c517f 100644
--- a/java/com/google/gerrit/server/index/AbstractIndexModule.java
+++ b/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -33,6 +33,7 @@
  * index implementations, such as {@link com.google.gerrit.lucene.LuceneIndexModule}.
  */
 public abstract class AbstractIndexModule extends AbstractModule {
+  public static final String INDEX_MODULE = "index-module";
 
   private final int threads;
   private final Map<String, Integer> singleVersions;
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index a2ef070..9ad7cdb 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -53,6 +53,7 @@
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.GroupIndexerImpl;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.gerrit.server.index.project.ProjectIndexDefinition;
 import com.google.gerrit.server.index.project.ProjectIndexerImpl;
 import com.google.inject.Inject;
@@ -150,6 +151,9 @@
     }
 
     DynamicSet.setOf(binder(), OnlineUpgradeListener.class);
+    OptionalBinder.newOptionalBinder(binder(), IsFirstInsertForEntry.class)
+        .setDefault()
+        .toInstance(IsFirstInsertForEntry.NO);
   }
 
   @Provides
@@ -215,13 +219,14 @@
       return interactiveExecutor;
     }
     int threads = this.threads;
-    if (threads < 0) {
-      return MoreExecutors.newDirectExecutorService();
-    } else if (threads == 0) {
+    if (threads == 0) {
       threads =
           config.getInt(
               "index", null, "threads", Runtime.getRuntime().availableProcessors() / 2 + 1);
     }
+    if (threads < 0) {
+      return MoreExecutors.newDirectExecutorService();
+    }
     return MoreExecutors.listeningDecorator(
         workQueue.createQueue(threads, "Index-Interactive", true));
   }
@@ -234,11 +239,13 @@
     if (batchExecutor != null) {
       return batchExecutor;
     }
-    int threads = config.getInt("index", null, "batchThreads", 0);
+    int threads = this.threads;
+    if (threads == 0) {
+      threads =
+          config.getInt("index", null, "batchThreads", Runtime.getRuntime().availableProcessors());
+    }
     if (threads < 0) {
       return MoreExecutors.newDirectExecutorService();
-    } else if (threads == 0) {
-      threads = Runtime.getRuntime().availableProcessors();
     }
     return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "Index-Batch", true));
   }
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index ee8dfc8..352d376 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -14,22 +14,19 @@
 
 package com.google.gerrit.server.index;
 
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.project.ProjectField;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.SitePaths;
 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.GroupBackedUser;
 import java.io.IOException;
 import java.util.Set;
@@ -65,29 +62,29 @@
    */
   public static Set<String> accountFields(Set<String> fields, boolean useLegacyNumericFields) {
     String idFieldName =
-        useLegacyNumericFields ? AccountField.ID.getName() : AccountField.ID_STR.getName();
+        useLegacyNumericFields
+            ? AccountField.ID_FIELD_SPEC.getName()
+            : AccountField.ID_STR_FIELD_SPEC.getName();
     return fields.contains(idFieldName) ? fields : Sets.union(fields, ImmutableSet.of(idFieldName));
   }
 
   /**
    * Returns a sanitized set of fields for change index queries by removing fields that the current
-   * index version doesn't support and accounting for numeric vs. string primary keys. The primary
-   * key situation is temporary and should be removed after the migration is done.
+   * index version doesn't support.
    */
-  public static Set<String> changeFields(QueryOptions opts, boolean useLegacyNumericFields) {
-    FieldDef<ChangeData, ?> idField = useLegacyNumericFields ? LEGACY_ID : LEGACY_ID_STR;
+  public static Set<String> changeFields(QueryOptions opts) {
     // Ensure we request enough fields to construct a ChangeData. We need both
     // change ID and project, which can either come via the Change field or
     // separate fields.
     Set<String> fs = opts.fields();
-    if (fs.contains(CHANGE.getName())) {
+    if (fs.contains(CHANGE_SPEC.getName())) {
       // A Change is always sufficient.
       return fs;
     }
-    if (fs.contains(PROJECT.getName()) && fs.contains(idField.getName())) {
+    if (fs.contains(PROJECT_SPEC.getName()) && fs.contains(NUMERIC_ID_STR_SPEC.getName())) {
       return fs;
     }
-    return Sets.union(fs, ImmutableSet.of(idField.getName(), PROJECT.getName()));
+    return Sets.union(fs, ImmutableSet.of(NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName()));
   }
 
   /**
@@ -97,9 +94,9 @@
    */
   public static Set<String> groupFields(QueryOptions opts) {
     Set<String> fs = opts.fields();
-    return fs.contains(GroupField.UUID.getName())
+    return fs.contains(GroupField.UUID_FIELD_SPEC.getName())
         ? fs
-        : Sets.union(fs, ImmutableSet.of(GroupField.UUID.getName()));
+        : Sets.union(fs, ImmutableSet.of(GroupField.UUID_FIELD_SPEC.getName()));
   }
 
   /** Returns a index-friendly representation of a {@link CurrentUser} to be used in queries. */
@@ -119,9 +116,9 @@
    */
   public static Set<String> projectFields(QueryOptions opts) {
     Set<String> fs = opts.fields();
-    return fs.contains(ProjectField.NAME.getName())
+    return fs.contains(ProjectField.NAME_SPEC.getName())
         ? fs
-        : Sets.union(fs, ImmutableSet.of(ProjectField.NAME.getName()));
+        : Sets.union(fs, ImmutableSet.of(ProjectField.NAME_SPEC.getName()));
   }
 
   private IndexUtils() {
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index 0dd22ce..ed58a0b 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -14,11 +14,6 @@
 
 package com.google.gerrit.server.index.account;
 
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.FluentIterable;
@@ -26,7 +21,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.server.account.AccountState;
@@ -41,13 +36,31 @@
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 
-/** Secondary index schemas for accounts. */
+/**
+ * Secondary index schemas for accounts.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class AccountField {
-  public static final FieldDef<AccountState, Integer> ID =
-      integer("id").stored().build(a -> a.account().id().get());
 
-  public static final FieldDef<AccountState, String> ID_STR =
-      exact("id_str").stored().build(a -> String.valueOf(a.account().id().get()));
+  public static final IndexedField<AccountState, Integer> ID_FIELD =
+      IndexedField.<AccountState>integerBuilder("Id")
+          .stored()
+          .required()
+          .build(a -> a.account().id().get());
+
+  public static final IndexedField<AccountState, Integer>.SearchSpec ID_FIELD_SPEC =
+      ID_FIELD.integer("id");
+
+  public static final IndexedField<AccountState, String> ID_STR_FIELD =
+      IndexedField.<AccountState>stringBuilder("IdStr")
+          .stored()
+          .required()
+          .build(a -> String.valueOf(a.account().id().get()));
+
+  public static final IndexedField<AccountState, String>.SearchSpec ID_STR_FIELD_SPEC =
+      ID_STR_FIELD.exact("id_str");
 
   /**
    * External IDs.
@@ -55,9 +68,13 @@
    * <p>This field includes secondary emails. Use this field only if the current user is allowed to
    * see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT} capability).
    */
-  public static final FieldDef<AccountState, Iterable<String>> EXTERNAL_ID =
-      exact("external_id")
-          .buildRepeatable(a -> Iterables.transform(a.externalIds(), id -> id.key().get()));
+  public static final IndexedField<AccountState, Iterable<String>> EXTERNAL_ID_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("ExternalId")
+          .required()
+          .build(a -> Iterables.transform(a.externalIds(), id -> id.key().get()));
+
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec
+      EXTERNAL_ID_FIELD_SPEC = EXTERNAL_ID_FIELD.exact("external_id");
 
   /**
    * Fuzzy prefix match on name and email parts.
@@ -66,37 +83,57 @@
    * is allowed to see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT}
    * capability).
    *
-   * <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL} if the current user can't see
+   * <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL_SPEC} if the current user can't see
    * secondary emails.
    */
-  public static final FieldDef<AccountState, Iterable<String>> NAME_PART =
-      prefix("name")
-          .buildRepeatable(
-              a -> getNameParts(a, Iterables.transform(a.externalIds(), ExternalId::email)));
+  public static final IndexedField<AccountState, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("FullNameAndAllEmailsParts")
+          .description("Full name, all linked emails and their parts (split at special characters)")
+          .required()
+          .build(a -> getNameParts(a, Iterables.transform(a.externalIds(), ExternalId::email)));
+
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name");
 
   /**
    * Fuzzy prefix match on name and preferred email parts. Parts of secondary emails are not
    * included.
    */
-  public static final FieldDef<AccountState, Iterable<String>> NAME_PART_NO_SECONDARY_EMAIL =
-      prefix("name2")
-          .buildRepeatable(a -> getNameParts(a, Arrays.asList(a.account().preferredEmail())));
+  public static final IndexedField<AccountState, Iterable<String>>
+      NAME_PART_NO_SECONDARY_EMAIL_FIELD =
+          IndexedField.<AccountState>iterableStringBuilder("FullNameAndPreferredEmailParts")
+              .description(
+                  "Full name, preferred emails and its parts (split at special characters)")
+              .required()
+              .build(a -> getNameParts(a, Arrays.asList(a.account().preferredEmail())));
 
-  public static final FieldDef<AccountState, String> FULL_NAME =
-      exact("full_name").build(a -> a.account().fullName());
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec
+      NAME_PART_NO_SECONDARY_EMAIL_SPEC = NAME_PART_NO_SECONDARY_EMAIL_FIELD.prefix("name2");
 
-  public static final FieldDef<AccountState, String> ACTIVE =
-      exact("inactive").build(a -> a.account().isActive() ? "1" : "0");
+  public static final IndexedField<AccountState, String> FULL_NAME_FIELD =
+      IndexedField.<AccountState>stringBuilder("FullName").build(a -> a.account().fullName());
 
+  public static final IndexedField<AccountState, String>.SearchSpec FULL_NAME_SPEC =
+      FULL_NAME_FIELD.exact("full_name");
+
+  public static final IndexedField<AccountState, String> ACTIVE_FIELD =
+      IndexedField.<AccountState>stringBuilder("Active")
+          .required()
+          .build(a -> a.account().isActive() ? "1" : "0");
+
+  public static final IndexedField<AccountState, String>.SearchSpec ACTIVE_FIELD_SPEC =
+      ACTIVE_FIELD.exact("inactive");
   /**
    * All emails (preferred email + secondary emails). Use this field only if the current user is
    * allowed to see secondary emails (requires the 'Modify Account' capability).
    *
-   * <p>Use the {@link AccountField#PREFERRED_EMAIL} if the current user can't see secondary emails.
+   * <p>Use the {@link AccountField#PREFERRED_EMAIL_LOWER_CASE_SPEC} if the current user can't see
+   * secondary emails.
    */
-  public static final FieldDef<AccountState, Iterable<String>> EMAIL =
-      prefix("email")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<String>> EMAIL_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("Email")
+          .required()
+          .build(
               a ->
                   FluentIterable.from(a.externalIds())
                       .transform(ExternalId::email)
@@ -105,41 +142,66 @@
                       .transform(String::toLowerCase)
                       .toSet());
 
-  public static final FieldDef<AccountState, String> PREFERRED_EMAIL =
-      prefix("preferredemail")
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec EMAIL_SPEC =
+      EMAIL_FIELD.prefix("email");
+
+  public static final IndexedField<AccountState, String> PREFERRED_EMAIL_LOWER_CASE_FIELD =
+      IndexedField.<AccountState>stringBuilder("PreferredEmailLowerCase")
           .build(
               a -> {
                 String preferredEmail = a.account().preferredEmail();
                 return preferredEmail != null ? preferredEmail.toLowerCase() : null;
               });
 
-  public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
-      exact("preferredemail_exact").build(a -> a.account().preferredEmail());
+  public static final IndexedField<AccountState, String>.SearchSpec
+      PREFERRED_EMAIL_LOWER_CASE_SPEC = PREFERRED_EMAIL_LOWER_CASE_FIELD.prefix("preferredemail");
 
-  public static final FieldDef<AccountState, Timestamp> REGISTERED =
-      timestamp("registered").build(a -> a.account().registeredOn());
+  public static final IndexedField<AccountState, String> PREFERRED_EMAIL_EXACT_FIELD =
+      IndexedField.<AccountState>stringBuilder("PreferredEmail")
+          .build(a -> a.account().preferredEmail());
 
-  public static final FieldDef<AccountState, String> USERNAME =
-      exact("username").build(a -> a.userName().map(String::toLowerCase).orElse(""));
+  public static final IndexedField<AccountState, String>.SearchSpec PREFERRED_EMAIL_EXACT_SPEC =
+      PREFERRED_EMAIL_EXACT_FIELD.exact("preferredemail_exact");
 
-  public static final FieldDef<AccountState, Iterable<String>> WATCHED_PROJECT =
-      exact("watchedproject")
-          .buildRepeatable(
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
+  public static final IndexedField<AccountState, Timestamp> REGISTERED_FIELD =
+      IndexedField.<AccountState>timestampBuilder("Registered")
+          .required()
+          .build(a -> Timestamp.from(a.account().registeredOn()));
+
+  public static final IndexedField<AccountState, Timestamp>.SearchSpec REGISTERED_SPEC =
+      REGISTERED_FIELD.timestamp("registered");
+
+  public static final IndexedField<AccountState, String> USERNAME_FIELD =
+      IndexedField.<AccountState>stringBuilder("Username")
+          .build(a -> a.userName().map(String::toLowerCase).orElse(""));
+
+  public static final IndexedField<AccountState, String>.SearchSpec USERNAME_SPEC =
+      USERNAME_FIELD.exact("username");
+
+  public static final IndexedField<AccountState, Iterable<String>> WATCHED_PROJECT_FIELD =
+      IndexedField.<AccountState>iterableStringBuilder("WatchedProject")
+          .build(
               a ->
                   FluentIterable.from(a.projectWatches().keySet())
                       .transform(k -> k.project().get())
                       .toSet());
 
+  public static final IndexedField<AccountState, Iterable<String>>.SearchSpec WATCHED_PROJECT_SPEC =
+      WATCHED_PROJECT_FIELD.exact("watchedproject");
+
   /**
    * All values of all refs that were used in the course of indexing this document, except the
    * refs/meta/external-ids notes branch which is handled specially (see {@link
-   * #EXTERNAL_ID_STATE}).
+   * #EXTERNAL_ID_STATE_SPEC}).
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
    */
-  public static final FieldDef<AccountState, Iterable<byte[]>> REF_STATE =
-      storedOnly("ref_state")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<byte[]>> REF_STATE_FIELD =
+      IndexedField.<AccountState>iterableByteArrayBuilder("RefState")
+          .stored()
+          .required()
+          .build(
               a -> {
                 if (a.account().metaId() == null) {
                   return ImmutableList.of();
@@ -156,21 +218,29 @@
                         .toByteArray(new AllUsersName(AllUsersNameProvider.DEFAULT)));
               });
 
+  public static final IndexedField<AccountState, Iterable<byte[]>>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
+
   /**
    * All note values of all external IDs that were used in the course of indexing this document.
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code [hex sha of external ID]:[hex sha of
    * note blob]}, or with other words {@code [note ID]:[note data ID]}.
    */
-  public static final FieldDef<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE =
-      storedOnly("external_id_state")
-          .buildRepeatable(
+  public static final IndexedField<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE_FIELD =
+      IndexedField.<AccountState>iterableByteArrayBuilder("ExternalIdState")
+          .stored()
+          .required()
+          .build(
               a ->
                   a.externalIds().stream()
                       .filter(e -> e.blobId() != null)
                       .map(ExternalId::toByteArray)
                       .collect(toSet()));
 
+  public static final IndexedField<AccountState, Iterable<byte[]>>.SearchSpec
+      EXTERNAL_ID_STATE_SPEC = EXTERNAL_ID_STATE_FIELD.storedOnly("external_id_state");
+
   private static final Set<String> getNameParts(AccountState a, Iterable<String> emails) {
     String fullName = a.account().fullName();
     Set<String> parts = SchemaUtil.getNameParts(fullName, emails);
diff --git a/java/com/google/gerrit/server/index/account/AccountIndex.java b/java/com/google/gerrit/server/index/account/AccountIndex.java
index ca7264c..66b85af 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndex.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.query.account.AccountPredicates;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit accounts. This class is mainly used for typing the generic parent class that
@@ -32,4 +33,6 @@
   default Predicate<AccountState> keyPredicate(Account.Id id) {
     return AccountPredicates.id(getSchema(), id);
   }
+
+  Function<AccountState, Account.Id> ENTITY_TO_KEY = (a) -> a.account().id();
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
index 8b95f7b..94dfbf1 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -58,19 +58,19 @@
   public void validateMaxTermsInQuery(Predicate<AccountState> predicate)
       throws QueryParseException {
     MutableInteger leafTerms = new MutableInteger();
-    validateMaxTermsInQuery(predicate, leafTerms);
+    countLeafTerms(predicate, leafTerms);
+    if (leafTerms.value > config.maxTerms()) {
+      throw new TooManyTermsInQueryException(leafTerms.value, config.maxTerms());
+    }
   }
 
-  private void validateMaxTermsInQuery(Predicate<AccountState> predicate, MutableInteger leafTerms)
-      throws TooManyTermsInQueryException {
-    if (!(predicate instanceof IndexPredicate)) {
-      if (++leafTerms.value > config.maxTerms()) {
-        throw new TooManyTermsInQueryException();
-      }
+  private void countLeafTerms(Predicate<AccountState> predicate, MutableInteger leafTerms) {
+    if (predicate instanceof IndexPredicate) {
+      ++leafTerms.value;
     }
 
     for (Predicate<AccountState> childPredicate : predicate.getChildren()) {
-      validateMaxTermsInQuery(childPredicate, leafTerms);
+      countLeafTerms(childPredicate, leafTerms);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 5de3ba4..31fbf36 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -16,35 +16,55 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.account.AccountState;
 
-/** Definition of account index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of account index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
+
   @Deprecated
-  static final Schema<AccountState> V4 =
+  static final Schema<AccountState> V8 =
       schema(
-          AccountField.ACTIVE,
-          AccountField.EMAIL,
-          AccountField.EXTERNAL_ID,
-          AccountField.FULL_NAME,
-          AccountField.ID,
-          AccountField.NAME_PART,
-          AccountField.REGISTERED,
-          AccountField.USERNAME,
-          AccountField.WATCHED_PROJECT);
-
-  @Deprecated static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
-
-  @Deprecated
-  static final Schema<AccountState> V6 =
-      schema(V5, AccountField.REF_STATE, AccountField.EXTERNAL_ID_STATE);
-
-  @Deprecated static final Schema<AccountState> V7 = schema(V6, AccountField.PREFERRED_EMAIL_EXACT);
-
-  @Deprecated
-  static final Schema<AccountState> V8 = schema(V7, AccountField.NAME_PART_NO_SECONDARY_EMAIL);
+          /* version= */ 8,
+          ImmutableList.of(),
+          ImmutableList.of(
+              AccountField.ID_FIELD,
+              AccountField.ACTIVE_FIELD,
+              AccountField.EMAIL_FIELD,
+              AccountField.EXTERNAL_ID_FIELD,
+              AccountField.EXTERNAL_ID_STATE_FIELD,
+              AccountField.FULL_NAME_FIELD,
+              AccountField.NAME_PART_FIELD,
+              AccountField.NAME_PART_NO_SECONDARY_EMAIL_FIELD,
+              AccountField.PREFERRED_EMAIL_EXACT_FIELD,
+              AccountField.PREFERRED_EMAIL_LOWER_CASE_FIELD,
+              AccountField.REF_STATE_FIELD,
+              AccountField.REGISTERED_FIELD,
+              AccountField.USERNAME_FIELD,
+              AccountField.WATCHED_PROJECT_FIELD),
+          ImmutableList.<IndexedField<AccountState, ?>.SearchSpec>of(
+              AccountField.ID_FIELD_SPEC,
+              AccountField.ACTIVE_FIELD_SPEC,
+              AccountField.EMAIL_SPEC,
+              AccountField.EXTERNAL_ID_FIELD_SPEC,
+              AccountField.EXTERNAL_ID_STATE_SPEC,
+              AccountField.FULL_NAME_SPEC,
+              AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC,
+              AccountField.NAME_PART_SPEC,
+              AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC,
+              AccountField.PREFERRED_EMAIL_EXACT_SPEC,
+              AccountField.REF_STATE_SPEC,
+              AccountField.REGISTERED_SPEC,
+              AccountField.USERNAME_SPEC,
+              AccountField.WATCHED_PROJECT_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<AccountState> V9 = schema(V8);
@@ -55,14 +75,19 @@
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range. As the consequense, integer
   // document id type is replaced with string document id type.
+  @Deprecated
   static final Schema<AccountState> V11 =
       new Schema.Builder<AccountState>()
           .add(V10)
-          .remove(AccountField.ID)
-          .add(AccountField.ID_STR)
-          .legacyNumericFields(false)
+          .remove(AccountField.ID_FIELD_SPEC)
+          .remove(AccountField.ID_FIELD)
+          .addIndexedFields(AccountField.ID_STR_FIELD)
+          .addSearchSpecs(AccountField.ID_STR_FIELD_SPEC)
           .build();
 
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<AccountState> V12 = schema(V11);
+
   /**
    * Name of the account index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index 63889b7..1f48e35 100644
--- a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -50,15 +51,18 @@
   private final ListeningExecutorService executor;
   private final Accounts accounts;
   private final AccountCache accountCache;
+  private final IsFirstInsertForEntry isFirstInsertForEntry;
 
   @Inject
   AllAccountsIndexer(
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       Accounts accounts,
-      AccountCache accountCache) {
+      AccountCache accountCache,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.accounts = accounts;
     this.accountCache = accountCache;
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
   @Override
@@ -92,7 +96,11 @@
                 try {
                   Optional<AccountState> a = accountCache.get(id);
                   if (a.isPresent()) {
-                    index.replace(a.get());
+                    if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
+                      index.insert(a.get());
+                    } else {
+                      index.replace(a.get());
+                    }
                   } else {
                     index.delete(id);
                   }
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
index 50fdcde..699dfbe 100644
--- a/java/com/google/gerrit/server/index/account/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -58,15 +58,15 @@
 public class StalenessChecker {
   public static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(
-          AccountField.ID.getName(),
-          AccountField.REF_STATE.getName(),
-          AccountField.EXTERNAL_ID_STATE.getName());
+          AccountField.ID_FIELD_SPEC.getName(),
+          AccountField.REF_STATE_SPEC.getName(),
+          AccountField.EXTERNAL_ID_STATE_SPEC.getName());
 
   public static final ImmutableSet<String> FIELDS2 =
       ImmutableSet.of(
-          AccountField.ID_STR.getName(),
-          AccountField.REF_STATE.getName(),
-          AccountField.EXTERNAL_ID_STATE.getName());
+          AccountField.ID_STR_FIELD_SPEC.getName(),
+          AccountField.REF_STATE_SPEC.getName(),
+          AccountField.EXTERNAL_ID_STATE_SPEC.getName());
 
   private final AccountIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -94,13 +94,13 @@
       // No index; caller couldn't do anything if it is stale.
       return StalenessCheckResult.notStale();
     }
-    if (!i.getSchema().hasField(AccountField.REF_STATE)
-        || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE)) {
+    if (!i.getSchema().hasField(AccountField.REF_STATE_SPEC)
+        || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE_SPEC)) {
       // Index version not new enough for this check.
       return StalenessCheckResult.notStale();
     }
 
-    boolean useLegacyNumericFields = i.getSchema().useLegacyNumericFields();
+    boolean useLegacyNumericFields = i.getSchema().hasField(AccountField.ID_FIELD_SPEC);
     ImmutableSet<String> fields = useLegacyNumericFields ? FIELDS : FIELDS2;
     Optional<FieldBundle> result =
         i.getRaw(
@@ -121,8 +121,9 @@
       }
     }
 
-    for (Map.Entry<Project.NameKey, RefState> e :
-        RefState.parseStates(result.get().getValue(AccountField.REF_STATE)).entries()) {
+    Iterable<byte[]> refStates =
+        result.get().<Iterable<byte[]>>getValue(AccountField.REF_STATE_SPEC);
+    for (Map.Entry<Project.NameKey, RefState> e : RefState.parseStates(refStates).entries()) {
       // Custom All-Users repository names are not indexed. Instead, the default name is used.
       // Therefore, defer to the currently configured All-Users name.
       Project.NameKey repoName =
@@ -137,8 +138,10 @@
     }
 
     Set<ExternalId> extIds = externalIds.byAccount(id);
+
     ListMultimap<ObjectId, ObjectId> extIdStates =
-        parseExternalIdStates(result.get().getValue(AccountField.EXTERNAL_ID_STATE));
+        parseExternalIdStates(
+            result.get().<Iterable<byte[]>>getValue(AccountField.EXTERNAL_ID_STATE_SPEC));
     if (extIdStates.size() != extIds.size()) {
       return StalenessCheckResult.stale(
           "External IDs of the account were modified since the account was indexed. (%s != %s)",
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 1b51703..340d956 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -14,45 +14,44 @@
 
 package com.google.gerrit.server.index.change;
 
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.util.concurrent.Futures.successfulAsList;
 import static com.google.common.util.concurrent.Futures.transform;
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Stopwatch;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Ints;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.git.MultiProgressMonitor.TaskKind;
+import com.google.gerrit.server.git.MultiProgressMonitor.VolatileTask;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.OnlineReindexMode;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
+import com.google.gerrit.server.notedb.ChangeNotes.Factory.ScanResult;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Objects;
 import java.util.concurrent.Callable;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TextProgressMonitor;
 
 /**
  * Implementation that can index all changes on a host or within a project. Used by Gerrit's
@@ -61,9 +60,21 @@
  */
 public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, ChangeIndex> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private MultiProgressMonitor mpm;
+  private VolatileTask doneTask;
+  private Task failedTask;
   private static final int PROJECT_SLICE_MAX_REFS = 1000;
 
   private final MultiProgressMonitor.Factory multiProgressMonitorFactory;
+
+  private static class ProjectsCollectionFailure extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    public ProjectsCollectionFailure(String message) {
+      super(message);
+    }
+  }
+
   private final ChangeData.Factory changeDataFactory;
   private final GitRepositoryManager repoManager;
   private final ListeningExecutorService executor;
@@ -89,127 +100,53 @@
     this.projectCache = projectCache;
   }
 
-  private static class ProjectSlice {
-    private final Project.NameKey name;
-    private final int slice;
-    private final int slices;
+  @AutoValue
+  public abstract static class ProjectSlice {
+    public abstract Project.NameKey name();
 
-    ProjectSlice(Project.NameKey name, int slice, int slices) {
-      this.name = name;
-      this.slice = slice;
-      this.slices = slices;
+    public abstract int slice();
+
+    public abstract int slices();
+
+    public abstract ScanResult scanResult();
+
+    private static ProjectSlice create(Project.NameKey name, int slice, int slices, ScanResult sr) {
+      return new AutoValue_AllChangesIndexer_ProjectSlice(name, slice, slices, sr);
     }
 
-    public Project.NameKey getName() {
-      return name;
-    }
-
-    public int getSlice() {
-      return slice;
-    }
-
-    public int getSlices() {
-      return slices;
+    private static ProjectSlice oneSlice(Project.NameKey name, ScanResult sr) {
+      return new AutoValue_AllChangesIndexer_ProjectSlice(name, 0, 1, sr);
     }
   }
 
   @Override
   public Result indexAll(ChangeIndex index) {
-    ProgressMonitor pm = new TextProgressMonitor();
-    pm.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
-    List<ProjectSlice> projectSlices = new ArrayList<>();
-    int changeCount = 0;
+    // The simplest approach to distribute indexing would be to let each thread grab a project
+    // and index it fully. But if a site has one big project and 100s of small projects, then
+    // in the beginning all CPUs would be busy reindexing projects. But soon enough all small
+    // projects have been reindexed, and only the thread that reindexes the big project is
+    // still working. The other threads would idle. Reindexing the big project on a single
+    // thread becomes the critical path. Bringing in more CPUs would not speed up things.
+    //
+    // To avoid such situations, we split big repos into smaller parts and let
+    // the thread pool index these smaller parts. This splitting introduces an overhead in the
+    // workload setup and there might be additional slow-downs from multiple threads
+    // concurrently working on different parts of the same project. But for Wikimedia's Gerrit,
+    // which had 2 big projects, many middle sized ones, and lots of smaller ones, the
+    // splitting of repos into smaller parts reduced indexing time from 1.5 hours to 55 minutes
+    // in 2020.
+
     Stopwatch sw = Stopwatch.createStarted();
-    int projectsFailed = 0;
-    for (Project.NameKey name : projectCache.all()) {
-      try (Repository repo = repoManager.openRepository(name)) {
-        // The simplest approach to distribute indexing would be to let each thread grab a project
-        // and index it fully. But if a site has one big project and 100s of small projects, then
-        // in the beginning all CPUs would be busy reindexing projects. But soon enough all small
-        // projects have been reindexed, and only the thread that reindexes the big project is
-        // still working. The other threads would idle. Reindexing the big project on a single
-        // thread becomes the critical path. Bringing in more CPUs would not speed up things.
-        //
-        // To avoid such situations, we split big repos into smaller parts and let
-        // the thread pool index these smaller parts. This splitting introduces an overhead in the
-        // workload setup and there might be additional slow-downs from multiple threads
-        // concurrently working on different parts of the same project. But for Wikimedia's Gerrit,
-        // which had 2 big projects, many middle sized ones, and lots of smaller ones, the
-        // splitting of repos into smaller parts reduced indexing time from 1.5 hours to 55 minutes
-        // in 2020.
-        int size = estimateSize(repo);
-        changeCount += size;
-        int slices = 1 + size / PROJECT_SLICE_MAX_REFS;
-        if (slices > 1) {
-          verboseWriter.println("Submitting " + name + " for indexing in " + slices + " slices");
-        }
-        for (int slice = 0; slice < slices; slice++) {
-          projectSlices.add(new ProjectSlice(name, slice, slices));
-        }
-      } catch (IOException e) {
-        logger.atSevere().withCause(e).log("Error collecting project %s", name);
-        projectsFailed++;
-        if (projectsFailed > projectCache.all().size() / 2) {
-          logger.atSevere().log("Over 50%% of the projects could not be collected: aborted");
-          return Result.create(sw, false, 0, 0);
-        }
-      }
-      pm.update(1);
-    }
-    pm.endTask();
-    setTotalWork(changeCount);
-
-    // projectSlices are currently grouped by projects. First all slices for project1, followed
-    // by all slices for project2, and so on. As workers pick tasks sequentially, multiple threads
-    // would typically work concurrently on different slices of the same project. While this is not
-    // a big issue, shuffling the list beforehand helps with ungrouping the project slices, so
-    // different slices are less likely to be worked on concurrently.
-    // This shuffling gave a 6% runtime reduction for Wikimedia's Gerrit in 2020.
-    Collections.shuffle(projectSlices);
-    return indexAll(index, projectSlices);
-  }
-
-  private int estimateSize(Repository repo) throws IOException {
-    // Estimate size based on IDs that show up in ref names. This is not perfect, since patch set
-    // refs may exist for changes whose metadata was never successfully stored. But that's ok, as
-    // the estimate is just used as a heuristic for sorting projects.
-    long size =
-        repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES).stream()
-            .map(r -> Change.Id.fromRef(r.getName()))
-            .filter(Objects::nonNull)
-            .distinct()
-            .count();
-    return Ints.saturatedCast(size);
-  }
-
-  private SiteIndexer.Result indexAll(ChangeIndex index, List<ProjectSlice> projectSlices) {
-    Stopwatch sw = Stopwatch.createStarted();
-    MultiProgressMonitor mpm =
-        multiProgressMonitorFactory.create(progressOut, TaskKind.INDEXING, "Reindexing changes");
-    Task projTask = mpm.beginSubTask("project-slices", projectSlices.size());
-    checkState(totalWork >= 0);
-    Task doneTask = mpm.beginSubTask(null, totalWork);
-    Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
-
-    List<ListenableFuture<?>> futures = new ArrayList<>();
     AtomicBoolean ok = new AtomicBoolean(true);
-
-    for (ProjectSlice projectSlice : projectSlices) {
-      Project.NameKey name = projectSlice.getName();
-      int slice = projectSlice.getSlice();
-      int slices = projectSlice.getSlices();
-      ListenableFuture<?> future =
-          executor.submit(
-              reindexProject(
-                  indexerFactory.create(executor, index),
-                  name,
-                  slice,
-                  slices,
-                  doneTask,
-                  failedTask));
-      String description = "project " + name + " (" + slice + "/" + slices + ")";
-      addErrorListener(future, description, projTask, ok);
-      futures.add(future);
+    mpm = multiProgressMonitorFactory.create(progressOut, TaskKind.INDEXING, "Reindexing changes");
+    doneTask = mpm.beginVolatileSubTask("changes");
+    failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
+    List<ListenableFuture<?>> futures;
+    try {
+      futures = new SliceScheduler(index, ok).schedule();
+    } catch (ProjectsCollectionFailure e) {
+      logger.atSevere().log("%s", e.getMessage());
+      return Result.create(sw, false, 0, 0);
     }
 
     try {
@@ -237,64 +174,64 @@
           "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);
   }
 
+  @Nullable
   public Callable<Void> reindexProject(
       ChangeIndexer indexer, Project.NameKey project, Task done, Task failed) {
-    return reindexProject(indexer, project, 0, 1, done, failed);
+    try (Repository repo = repoManager.openRepository(project)) {
+      return reindexProjectSlice(
+          indexer,
+          ProjectSlice.oneSlice(project, ChangeNotes.Factory.scanChangeIds(repo)),
+          done,
+          failed);
+    } catch (IOException e) {
+      logger.atSevere().log("%s", e.getMessage());
+      return null;
+    }
   }
 
-  public Callable<Void> reindexProject(
-      ChangeIndexer indexer,
-      Project.NameKey project,
-      int slice,
-      int slices,
-      Task done,
-      Task failed) {
-    return new ProjectIndexer(indexer, project, slice, slices, done, failed);
+  public Callable<Void> reindexProjectSlice(
+      ChangeIndexer indexer, ProjectSlice projectSlice, Task done, Task failed) {
+    return new ProjectSliceIndexer(indexer, projectSlice, done, failed);
   }
 
-  private class ProjectIndexer implements Callable<Void> {
+  private class ProjectSliceIndexer implements Callable<Void> {
     private final ChangeIndexer indexer;
-    private final Project.NameKey project;
-    private final int slice;
-    private final int slices;
+    private final ProjectSlice projectSlice;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
 
-    private ProjectIndexer(
+    private ProjectSliceIndexer(
         ChangeIndexer indexer,
-        Project.NameKey project,
-        int slice,
-        int slices,
+        ProjectSlice projectSlice,
         ProgressMonitor done,
         ProgressMonitor failed) {
       this.indexer = indexer;
-      this.project = project;
-      this.slice = slice;
-      this.slices = slices;
+      this.projectSlice = projectSlice;
       this.done = done;
       this.failed = failed;
     }
 
     @Override
     public Void call() throws Exception {
-      try (Repository repo = repoManager.openRepository(project)) {
-        OnlineReindexMode.begin();
-
-        // Order of scanning changes is undefined. This is ok if we assume that packfile locality is
-        // not important for indexing, since sites should have a fully populated DiffSummary cache.
-        // It does mean that reindexing after invalidating the DiffSummary cache will be expensive,
-        // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
-        // we don't have concrete proof that improving packfile locality would help.
-        notesFactory.scan(repo, project, id -> (id.get() % slices) == slice).forEach(r -> index(r));
-      } catch (RepositoryNotFoundException rnfe) {
-        logger.atSevere().log(rnfe.getMessage());
-      } finally {
-        OnlineReindexMode.end();
-      }
+      OnlineReindexMode.begin();
+      // Order of scanning changes is undefined. This is ok if we assume that packfile locality is
+      // not important for indexing, since sites should have a fully populated DiffSummary cache.
+      // It does mean that reindexing after invalidating the DiffSummary cache will be expensive,
+      // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
+      // we don't have concrete proof that improving packfile locality would help.
+      notesFactory
+          .scan(
+              projectSlice.scanResult(),
+              projectSlice.name(),
+              id -> (id.get() % projectSlice.slices()) == projectSlice.slice())
+          .forEach(r -> index(r));
+      OnlineReindexMode.end();
       return null;
     }
 
@@ -321,7 +258,7 @@
         this.failed.update(1);
       }
 
-      logger.atWarning().withCause(e).log(error);
+      logger.atWarning().withCause(e).log("%s", error);
       verboseWriter.println(error);
     }
 
@@ -331,7 +268,103 @@
 
     @Override
     public String toString() {
-      return "Index all changes of project " + project.get();
+      return "Index project slice " + projectSlice;
+    }
+  }
+
+  private class SliceScheduler {
+    final ChangeIndex index;
+    final AtomicBoolean ok;
+    final AtomicInteger changeCount = new AtomicInteger(0);
+    final AtomicInteger projectsFailed = new AtomicInteger(0);
+    final List<ListenableFuture<?>> sliceIndexerFutures = new ArrayList<>();
+    final List<ListenableFuture<?>> sliceCreationFutures = new ArrayList<>();
+    VolatileTask projTask = mpm.beginVolatileSubTask("project-slices");
+    Task slicingProjects;
+
+    public SliceScheduler(ChangeIndex index, AtomicBoolean ok) {
+      this.index = index;
+      this.ok = ok;
+    }
+
+    private List<ListenableFuture<?>> schedule() throws ProjectsCollectionFailure {
+      ImmutableSortedSet<Project.NameKey> projects = projectCache.all();
+      int projectCount = projects.size();
+      slicingProjects = mpm.beginSubTask("Slicing projects", projectCount);
+      for (Project.NameKey name : projects) {
+        sliceCreationFutures.add(executor.submit(new ProjectSliceCreator(name)));
+      }
+
+      try {
+        mpm.waitForNonFinalTask(
+            transform(
+                successfulAsList(sliceCreationFutures),
+                x -> {
+                  projTask.finalizeTotal();
+                  doneTask.finalizeTotal();
+                  return null;
+                },
+                directExecutor()));
+      } catch (UncheckedExecutionException e) {
+        logger.atSevere().withCause(e).log("Error project slice creation");
+        ok.set(false);
+      }
+
+      if (projectsFailed.get() > projectCount / 2) {
+        throw new ProjectsCollectionFailure(
+            "Over 50%% of the projects could not be collected: aborted");
+      }
+
+      slicingProjects.endTask();
+      setTotalWork(changeCount.get());
+
+      return sliceIndexerFutures;
+    }
+
+    private class ProjectSliceCreator implements Callable<Void> {
+      final Project.NameKey name;
+
+      public ProjectSliceCreator(Project.NameKey name) {
+        this.name = name;
+      }
+
+      @Override
+      public Void call() throws IOException {
+        try (Repository repo = repoManager.openRepository(name)) {
+          ScanResult sr = ChangeNotes.Factory.scanChangeIds(repo);
+          int size = sr.all().size();
+          if (size > 0) {
+            changeCount.addAndGet(size);
+            int slices = 1 + (size - 1) / PROJECT_SLICE_MAX_REFS;
+            if (slices > 1) {
+              verboseWriter.println(
+                  "Submitting " + name + " for indexing in " + slices + " slices");
+            }
+
+            doneTask.updateTotal(size);
+            projTask.updateTotal(slices);
+
+            for (int slice = 0; slice < slices; slice++) {
+              ProjectSlice projectSlice = ProjectSlice.create(name, slice, slices, sr);
+              ListenableFuture<?> future =
+                  executor.submit(
+                      reindexProjectSlice(
+                          indexerFactory.create(executor, index),
+                          projectSlice,
+                          doneTask,
+                          failedTask));
+              String description = "project " + name + " (" + slice + "/" + slices + ")";
+              addErrorListener(future, description, projTask, ok);
+              sliceIndexerFutures.add(future);
+            }
+          }
+        } catch (IOException e) {
+          logger.atSevere().withCause(e).log("Error collecting project %s", name);
+          projectsFailed.incrementAndGet();
+        }
+        slicingProjects.update(1);
+        return null;
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 2cdb7c8..1c89566f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -18,13 +18,6 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.intRange;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
@@ -44,6 +37,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.Files;
 import com.google.common.primitives.Longs;
+import com.google.common.reflect.TypeToken;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
@@ -56,18 +50,21 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 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.IndexedField;
 import com.google.gerrit.index.RefState;
+import com.google.gerrit.index.SchemaFieldDefs;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.proto.Protos;
+import com.google.gerrit.proto.Entities;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -82,6 +79,7 @@
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
@@ -91,6 +89,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -104,6 +103,9 @@
  *
  * <p>Field names are all lowercase alphanumeric plus underscore; index implementations may create
  * unambiguous derived field names containing other characters.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
  */
 public class ChangeField {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -112,64 +114,127 @@
 
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
 
+  /**
+   * To avoid the non-google dependency on org.apache.lucene.index.IndexWriter.MAX_TERM_LENGTH it is
+   * redefined here.
+   */
+  public static final int MAX_TERM_LENGTH = (1 << 15) - 2;
+
   // TODO: Rename LEGACY_ID to NUMERIC_ID
   /** Legacy change ID. */
-  public static final FieldDef<ChangeData, Integer> LEGACY_ID =
-      integer("legacy_id").stored().build(cd -> cd.getId().get());
+  public static final IndexedField<ChangeData, String> NUMERIC_ID_STR_FIELD =
+      IndexedField.<ChangeData>stringBuilder("NumericIdStr")
+          .stored()
+          .required()
+          // The numeric change id is integer in string form
+          .size(10)
+          .build(cd -> String.valueOf(cd.getId().get()));
 
-  public static final FieldDef<ChangeData, String> LEGACY_ID_STR =
-      exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getId().get()));
+  public static final IndexedField<ChangeData, String>.SearchSpec NUMERIC_ID_STR_SPEC =
+      NUMERIC_ID_STR_FIELD.exact("legacy_id_str");
 
   /** Newer style Change-Id key. */
-  public static final FieldDef<ChangeData, String> ID =
-      prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get()));
+  public static final IndexedField<ChangeData, String> CHANGE_ID_FIELD =
+      IndexedField.<ChangeData>stringBuilder("ChangeId")
+          .required()
+          // The new style key is in form Isha1
+          .size(41)
+          .build(changeGetter(c -> c.getKey().get()));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec CHANGE_ID_SPEC =
+      CHANGE_ID_FIELD.prefix(ChangeQueryBuilder.FIELD_CHANGE_ID);
 
   /** Change status string, in the same format as {@code status:}. */
-  public static final FieldDef<ChangeData, String> STATUS =
-      exact(ChangeQueryBuilder.FIELD_STATUS)
+  public static final IndexedField<ChangeData, String> STATUS_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Status")
+          .required()
+          .size(20)
           .build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus())));
 
+  public static final IndexedField<ChangeData, String>.SearchSpec STATUS_SPEC =
+      STATUS_FIELD.exact(ChangeQueryBuilder.FIELD_STATUS);
+
   /** Project containing the change. */
-  public static final FieldDef<ChangeData, String> PROJECT =
-      exact(ChangeQueryBuilder.FIELD_PROJECT)
+  public static final IndexedField<ChangeData, String> PROJECT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Project")
+          .required()
           .stored()
+          .size(200)
           .build(changeGetter(c -> c.getProject().get()));
 
+  public static final IndexedField<ChangeData, String>.SearchSpec PROJECT_SPEC =
+      PROJECT_FIELD.exact(ChangeQueryBuilder.FIELD_PROJECT);
+
   /** Project containing the change, as a prefix field. */
-  public static final FieldDef<ChangeData, String> PROJECTS =
-      prefix(ChangeQueryBuilder.FIELD_PROJECTS).build(changeGetter(c -> c.getProject().get()));
+  public static final IndexedField<ChangeData, String>.SearchSpec PROJECTS_SPEC =
+      PROJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PROJECTS);
 
   /** Reference (aka branch) the change will submit onto. */
-  public static final FieldDef<ChangeData, String> REF =
-      exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().branch()));
+  public static final IndexedField<ChangeData, String> REF_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Ref")
+          .required()
+          .size(300)
+          .build(changeGetter(c -> c.getDest().branch()));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec REF_SPEC =
+      REF_FIELD.exact(ChangeQueryBuilder.FIELD_REF);
 
   /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> EXACT_TOPIC =
-      exact("topic4").build(ChangeField::getTopic);
+  public static final IndexedField<ChangeData, String> TOPIC_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Topic").size(500).build(ChangeField::getTopic);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec EXACT_TOPIC =
+      TOPIC_FIELD.exact("topic4");
 
   /** Topic, a short annotation on the branch. */
-  public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
-      fullText("topic5").build(ChangeField::getTopic);
+  public static final IndexedField<ChangeData, String>.SearchSpec FUZZY_TOPIC =
+      TOPIC_FIELD.fullText("topic5");
 
-  /** Submission id assigned by MergeOp. */
-  public static final FieldDef<ChangeData, String> SUBMISSIONID =
-      exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId));
+  /** Topic, a short annotation on the branch. */
+  public static final IndexedField<ChangeData, String>.SearchSpec PREFIX_TOPIC =
+      TOPIC_FIELD.prefix("topic6");
+
+  /** {@link com.google.gerrit.entities.SubmissionId} assigned by MergeOp. */
+  public static final IndexedField<ChangeData, String> SUBMISSIONID_FIELD =
+      IndexedField.<ChangeData>stringBuilder("SubmissionId")
+          .size(500)
+          .build(changeGetter(Change::getSubmissionId));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec SUBMISSIONID_SPEC =
+      SUBMISSIONID_FIELD.exact(ChangeQueryBuilder.FIELD_SUBMISSIONID);
 
   /** Last update time since January 1, 1970. */
-  public static final FieldDef<ChangeData, Timestamp> UPDATED =
-      timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn));
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
+  public static final IndexedField<ChangeData, Timestamp> UPDATED_FIELD =
+      IndexedField.<ChangeData>timestampBuilder("LastUpdated")
+          .stored()
+          .build(changeGetter(change -> Timestamp.from(change.getLastUpdatedOn())));
+
+  public static final IndexedField<ChangeData, Timestamp>.SearchSpec UPDATED_SPEC =
+      UPDATED_FIELD.timestamp("updated2");
 
   /** When this change was merged, time since January 1, 1970. */
-  public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
-      timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
+  public static final IndexedField<ChangeData, Timestamp> MERGED_ON_FIELD =
+      IndexedField.<ChangeData>timestampBuilder("MergedOn")
           .stored()
-          .build(cd -> cd.getMergedOn().orElse(null), (cd, field) -> cd.setMergedOn(field));
+          .build(
+              cd -> cd.getMergedOn().map(Timestamp::from).orElse(null),
+              (cd, field) -> cd.setMergedOn(field != null ? field.toInstant() : null));
+
+  public static final IndexedField<ChangeData, Timestamp>.SearchSpec MERGED_ON_SPEC =
+      MERGED_ON_FIELD.timestamp(ChangeQueryBuilder.FIELD_MERGED_ON);
 
   /** List of full file paths modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> PATH =
+  public static final IndexedField<ChangeData, Iterable<String>> PATH_FIELD =
       // Named for backwards compatibility.
-      exact(ChangeQueryBuilder.FIELD_FILE)
-          .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+      IndexedField.<ChangeData>iterableStringBuilder("File")
+          .build(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PATH_SPEC =
+      PATH_FIELD
+          // Named for backwards compatibility.
+          .exact(ChangeQueryBuilder.FIELD_FILE);
 
   public static Set<String> getFileParts(ChangeData cd) {
     List<String> paths = cd.currentFilePaths();
@@ -185,19 +250,27 @@
   }
 
   /** Hashtags tied to a change */
-  public static final FieldDef<ChangeData, Iterable<String>> HASHTAG =
-      exact(ChangeQueryBuilder.FIELD_HASHTAG)
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<String>> HASHTAG_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Hashtag")
+          .size(200)
+          .build(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec HASHTAG_SPEC =
+      HASHTAG_FIELD.exact(ChangeQueryBuilder.FIELD_HASHTAG);
 
   /** Hashtags as fulltext field for in-string search. */
-  public static final FieldDef<ChangeData, Iterable<String>> FUZZY_HASHTAG =
-      fullText("hashtag2")
-          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FUZZY_HASHTAG =
+      HASHTAG_FIELD.fullText("hashtag2");
+
+  /** Hashtags as prefix field for in-string search. */
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PREFIX_HASHTAG =
+      HASHTAG_FIELD.prefix("hashtag3");
 
   /** Hashtags with original case. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
-      storedOnly("_hashtag")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("HashtagCaseAware")
+          .stored()
+          .build(
               cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()),
               (cd, field) ->
                   cd.setHashtags(
@@ -205,13 +278,24 @@
                           .map(f -> new String(f, UTF_8))
                           .collect(toImmutableSet())));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      HASHTAG_CASE_AWARE_SPEC = HASHTAG_CASE_AWARE_FIELD.storedOnly("_hashtag");
+
   /** Components of each file path modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
-      exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts);
+  public static final IndexedField<ChangeData, Iterable<String>> FILE_PART_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("FilePart").build(ChangeField::getFileParts);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FILE_PART_SPEC =
+      FILE_PART_FIELD.exact(ChangeQueryBuilder.FIELD_FILEPART);
 
   /** File extensions of each file modified in the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXTENSION =
-      exact(ChangeQueryBuilder.FIELD_EXTENSION).buildRepeatable(ChangeField::getExtensions);
+  public static final IndexedField<ChangeData, Iterable<String>> EXTENSION_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Extension")
+          .size(100)
+          .build(ChangeField::getExtensions);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXTENSION_SPEC =
+      EXTENSION_FIELD.exact(ChangeQueryBuilder.FIELD_EXTENSION);
 
   public static Set<String> getExtensions(ChangeData cd) {
     return extensions(cd).collect(toSet());
@@ -221,8 +305,12 @@
    * File extensions of each file modified in the current patch set as a sorted list. The purpose of
    * this field is to allow matching changes that only touch files with certain file extensions.
    */
-  public static final FieldDef<ChangeData, String> ONLY_EXTENSIONS =
-      exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS).build(ChangeField::getAllExtensionsAsList);
+  public static final IndexedField<ChangeData, String> ONLY_EXTENSIONS_FIELD =
+      IndexedField.<ChangeData>stringBuilder("OnlyExtensions")
+          .build(ChangeField::getAllExtensionsAsList);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec ONLY_EXTENSIONS_SPEC =
+      ONLY_EXTENSIONS_FIELD.exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS);
 
   public static String getAllExtensionsAsList(ChangeData cd) {
     return extensions(cd).distinct().sorted().collect(joining(","));
@@ -246,8 +334,11 @@
   }
 
   /** Footers from the commit message of the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> FOOTER =
-      exact(ChangeQueryBuilder.FIELD_FOOTER).buildRepeatable(ChangeField::getFooters);
+  public static final IndexedField<ChangeData, Iterable<String>> FOOTER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Footer").build(ChangeField::getFooters);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FOOTER_SPEC =
+      FOOTER_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER);
 
   public static Set<String> getFooters(ChangeData cd) {
     return cd.commitFooters().stream()
@@ -255,9 +346,24 @@
         .collect(toSet());
   }
 
+  /** Footers from the commit message of the current patch set. */
+  public static final IndexedField<ChangeData, Iterable<String>> FOOTER_NAME_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("FooterName")
+          .build(ChangeField::getFootersNames);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FOOTER_NAME =
+      FOOTER_NAME_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER_NAME);
+
+  public static Set<String> getFootersNames(ChangeData cd) {
+    return cd.commitFooters().stream().map(f -> f.getKey()).collect(toSet());
+  }
+
   /** Folders that are touched by the current patch set. */
-  public static final FieldDef<ChangeData, Iterable<String>> DIRECTORY =
-      exact(ChangeQueryBuilder.FIELD_DIRECTORY).buildRepeatable(ChangeField::getDirectories);
+  public static final IndexedField<ChangeData, Iterable<String>> DIRECTORY_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("DirField").build(ChangeField::getDirectories);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec DIRECTORY_SPEC =
+      DIRECTORY_FIELD.exact(ChangeQueryBuilder.FIELD_DIRECTORY);
 
   public static Set<String> getDirectories(ChangeData cd) {
     List<String> paths = cd.currentFilePaths();
@@ -292,31 +398,47 @@
   }
 
   /** Owner/creator of the change. */
-  public static final FieldDef<ChangeData, Integer> OWNER =
-      integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
+  public static final IndexedField<ChangeData, Integer> OWNER_FIELD =
+      IndexedField.<ChangeData>integerBuilder("Owner")
+          .required()
+          .build(changeGetter(c -> c.getOwner().get()));
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec OWNER_SPEC =
+      OWNER_FIELD.integer(ChangeQueryBuilder.FIELD_OWNER);
 
   /** Uploader of the latest patch set. */
-  public static final FieldDef<ChangeData, Integer> UPLOADER =
-      integer(ChangeQueryBuilder.FIELD_UPLOADER).build(cd -> cd.currentPatchSet().uploader().get());
+  public static final IndexedField<ChangeData, Integer> UPLOADER_FIELD =
+      IndexedField.<ChangeData>integerBuilder("Uploader")
+          .required()
+          .build(cd -> cd.currentPatchSet().uploader().get());
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec UPLOADER_SPEC =
+      UPLOADER_FIELD.integer(ChangeQueryBuilder.FIELD_UPLOADER);
 
   /** References the source change number that this change was cherry-picked from. */
-  public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_CHANGE =
-      integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE)
+  public static final IndexedField<ChangeData, Integer> CHERRY_PICK_OF_CHANGE_FIELD =
+      IndexedField.<ChangeData>integerBuilder("CherryPickOfChange")
           .build(
               cd ->
                   cd.change().getCherryPickOf() != null
                       ? cd.change().getCherryPickOf().changeId().get()
                       : null);
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec CHERRY_PICK_OF_CHANGE =
+      CHERRY_PICK_OF_CHANGE_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE);
+
   /** References the source change patch-set that this change was cherry-picked from. */
-  public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_PATCHSET =
-      integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET)
+  public static final IndexedField<ChangeData, Integer> CHERRY_PICK_OF_PATCHSET_FIELD =
+      IndexedField.<ChangeData>integerBuilder("CherryPickOfPatchset")
           .build(
               cd ->
                   cd.change().getCherryPickOf() != null
                       ? cd.change().getCherryPickOf().get()
                       : null);
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec CHERRY_PICK_OF_PATCHSET =
+      CHERRY_PICK_OF_PATCHSET_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET);
+
   /** This class decouples the internal and API types from storage. */
   private static class StoredAttentionSetEntry {
     final long timestampMillis;
@@ -341,25 +463,35 @@
    * Users included in the attention set of the change. This omits timestamp, reason and possible
    * future fields.
    *
-   * @see #ATTENTION_SET_FULL
+   * @see #ATTENTION_SET_FULL_SPEC
    */
-  public static final FieldDef<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS =
-      integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS)
-          .buildRepeatable(ChangeField::getAttentionSetUserIds);
+  public static final IndexedField<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("AttentionSetUsers")
+          .build(ChangeField::getAttentionSetUserIds);
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec ATTENTION_SET_USERS =
+      ATTENTION_SET_USERS_FIELD.integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS);
 
   /** Number of changes that contain attention set. */
-  public static final FieldDef<ChangeData, Integer> ATTENTION_SET_USERS_COUNT =
-      intRange(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT)
+  public static final IndexedField<ChangeData, Integer> ATTENTION_SET_USERS_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("AttentionSetUsersCount")
+          .stored()
           .build(cd -> additionsOnly(cd.attentionSet()).size());
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec ATTENTION_SET_USERS_COUNT =
+      ATTENTION_SET_USERS_COUNT_FIELD.integerRange(
+          ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT);
+
   /**
    * The full attention set data including timestamp, reason and possible future fields.
    *
    * @see #ATTENTION_SET_USERS
    */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL =
-      storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("AttentionSetFull")
+          .stored()
+          .required()
+          .build(
               ChangeField::storedAttentionSet,
               (cd, value) ->
                   parseAttentionSet(
@@ -368,63 +500,107 @@
                           .collect(toImmutableSet()),
                       cd));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      ATTENTION_SET_FULL_SPEC =
+          ATTENTION_SET_FULL_FIELD.storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL);
+
   /** The user assigned to the change. */
-  public static final FieldDef<ChangeData, Integer> ASSIGNEE =
-      integer(ChangeQueryBuilder.FIELD_ASSIGNEE)
+  public static final IndexedField<ChangeData, Integer> ASSIGNEE_FIELD =
+      IndexedField.<ChangeData>integerBuilder("Assignee")
           .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec ASSIGNEE_SPEC =
+      ASSIGNEE_FIELD.integer(ChangeQueryBuilder.FIELD_ASSIGNEE);
+
   /** Reviewer(s) associated with the change. */
-  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
-      exact("reviewer2")
+  public static final IndexedField<ChangeData, Iterable<String>> REVIEWER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Reviewer")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerFieldValues(cd.reviewers()),
               (cd, field) -> cd.setReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec REVIEWER_SPEC =
+      REVIEWER_FIELD.exact("reviewer2");
+
   /** Reviewer(s) associated with the change that do not have a gerrit account. */
-  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
-      exact("reviewer_by_email")
+  public static final IndexedField<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("ReviewerByEmail")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()),
               (cd, field) ->
                   cd.setReviewersByEmail(parseReviewerByEmailFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec REVIEWER_BY_EMAIL =
+      REVIEWER_BY_EMAIL_FIELD.exact("reviewer_by_email");
+
   /** Reviewer(s) modified during change's current WIP phase. */
-  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER =
-      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER)
+  public static final IndexedField<ChangeData, Iterable<String>> PENDING_REVIEWER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("PendingReviewer")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerFieldValues(cd.pendingReviewers()),
               (cd, field) -> cd.setPendingReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PENDING_REVIEWER_SPEC =
+      PENDING_REVIEWER_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER);
+
   /** Reviewer(s) by email modified during change's current WIP phase. */
-  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL =
-      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL)
+  public static final IndexedField<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("PendingReviewerByEmail")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()),
               (cd, field) ->
                   cd.setPendingReviewersByEmail(
                       parseReviewerByEmailFieldValues(cd.getId(), field)));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec
+      PENDING_REVIEWER_BY_EMAIL =
+          PENDING_REVIEWER_BY_EMAIL_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL);
+
   /** References a change that this change reverts. */
-  public static final FieldDef<ChangeData, Integer> REVERT_OF =
-      integer(ChangeQueryBuilder.FIELD_REVERTOF)
+  public static final IndexedField<ChangeData, Integer> REVERT_OF_FIELD =
+      IndexedField.<ChangeData>integerBuilder("RevertOf")
           .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
 
-  public static final FieldDef<ChangeData, String> IS_PURE_REVERT =
-      fullText(ChangeQueryBuilder.FIELD_PURE_REVERT)
+  public static final IndexedField<ChangeData, Integer>.SearchSpec REVERT_OF =
+      REVERT_OF_FIELD.integer(ChangeQueryBuilder.FIELD_REVERTOF);
+
+  public static final IndexedField<ChangeData, String> IS_PURE_REVERT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("IsPureRevert")
+          .size(1)
           .build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
 
+  public static final IndexedField<ChangeData, String>.SearchSpec IS_PURE_REVERT_SPEC =
+      IS_PURE_REVERT_FIELD.fullText(ChangeQueryBuilder.FIELD_PURE_REVERT);
+
+  /**
+   * Determines if a change is submittable based on {@link
+   * com.google.gerrit.entities.SubmitRequirement}s.
+   */
+  public static final IndexedField<ChangeData, String> IS_SUBMITTABLE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("IsSubmittable")
+          .size(1)
+          .build(
+              cd ->
+                  // All submit requirements should be fulfilled
+                  cd.submitRequirementsIncludingLegacy().values().stream()
+                          .allMatch(SubmitRequirementResult::fulfilled)
+                      ? "1"
+                      : "0");
+
+  public static final IndexedField<ChangeData, String>.SearchSpec IS_SUBMITTABLE_SPEC =
+      IS_SUBMITTABLE_FIELD.exact(ChangeQueryBuilder.FIELD_IS_SUBMITTABLE);
+
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
-    for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c :
-        reviewers.asTable().cellSet()) {
+    for (Table.Cell<ReviewerStateInternal, Account.Id, Instant> c : reviewers.asTable().cellSet()) {
       String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey());
       r.add(v);
-      r.add(v + ',' + c.getValue().getTime());
+      r.add(v + ',' + c.getValue().toEpochMilli());
     }
     return r;
   }
@@ -436,7 +612,7 @@
   @VisibleForTesting
   static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
     List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
-    for (Table.Cell<ReviewerStateInternal, Address, Timestamp> c :
+    for (Table.Cell<ReviewerStateInternal, Address, Instant> c :
         reviewersByEmail.asTable().cellSet()) {
       String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
       r.add(v);
@@ -445,7 +621,7 @@
         Address emailOnly = Address.create(c.getColumnKey().email());
         r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
       }
-      r.add(v + ',' + c.getValue().getTime());
+      r.add(v + ',' + c.getValue().toEpochMilli());
     }
     return r;
   }
@@ -455,8 +631,7 @@
   }
 
   public static ReviewerSet parseReviewerFieldValues(Change.Id changeId, Iterable<String> values) {
-    ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
-        ImmutableTable.builder();
+    ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Instant> b = ImmutableTable.builder();
     for (String v : values) {
 
       int i = v.indexOf(',');
@@ -498,7 +673,7 @@
             "Failed to parse timestamp of reviewer field from change %s: %s", changeId.get(), v);
         continue;
       }
-      Timestamp timestamp = new Timestamp(l);
+      Instant timestamp = Instant.ofEpochMilli(l);
 
       b.put(reviewerState.get(), accountId.get(), timestamp);
     }
@@ -507,7 +682,7 @@
 
   public static ReviewerByEmailSet parseReviewerByEmailFieldValues(
       Change.Id changeId, Iterable<String> values) {
-    ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder();
+    ImmutableTable.Builder<ReviewerStateInternal, Address, Instant> b = ImmutableTable.builder();
     for (String v : values) {
       int i = v.indexOf(',');
       if (i < 0) {
@@ -551,7 +726,7 @@
             changeId.get(), v);
         continue;
       }
-      Timestamp timestamp = new Timestamp(l);
+      Instant timestamp = Instant.ofEpochMilli(l);
 
       b.put(reviewerState.get(), address, timestamp);
     }
@@ -594,25 +769,37 @@
   }
 
   /** Commit ID of any patch set on the change, using prefix match. */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
-      prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
+  public static final IndexedField<ChangeData, Iterable<String>> COMMIT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Commit")
+          .size(40)
+          .required()
+          .build(ChangeField::getRevisions);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMIT_SPEC =
+      COMMIT_FIELD.prefix(ChangeQueryBuilder.FIELD_COMMIT);
 
   /** Commit ID of any patch set on the change, using exact match. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
-      exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions);
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_COMMIT_SPEC =
+      COMMIT_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT);
 
   private static ImmutableSet<String> getRevisions(ChangeData cd) {
     return cd.patchSets().stream().map(ps -> ps.commitId().name()).collect(toImmutableSet());
   }
 
   /** Tracking id extracted from a footer. */
-  public static final FieldDef<ChangeData, Iterable<String>> TR =
-      exact(ChangeQueryBuilder.FIELD_TR)
-          .buildRepeatable(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
+  public static final IndexedField<ChangeData, Iterable<String>> TR_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("TrackingFooter")
+          .build(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec TR_SPEC =
+      TR_FIELD.exact(ChangeQueryBuilder.FIELD_TR);
 
   /** List of labels on the current patch set including change owner votes. */
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
-      exact("label2").buildRepeatable(cd -> getLabels(cd));
+  public static final IndexedField<ChangeData, Iterable<String>> LABEL_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Label").required().build(cd -> getLabels(cd));
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec LABEL_SPEC =
+      LABEL_FIELD.exact("label2");
 
   private static Iterable<String> getLabels(ChangeData cd) {
     Set<String> allApprovals = new HashSet<>();
@@ -755,41 +942,90 @@
    * The exact email address, or any part of the author name or email address, in the current patch
    * set.
    */
-  public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
-      fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts);
+  public static final IndexedField<ChangeData, Iterable<String>> AUTHOR_PARTS_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("AuthorParts")
+          .required()
+          .description(
+              "The exact email address, or any part of the author name or email address, in the current patch set.")
+          .build(ChangeField::getAuthorParts);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec AUTHOR_PARTS_SPEC =
+      AUTHOR_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_AUTHOR);
 
   /** The exact name, email address and NameEmail of the author. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_AUTHOR =
-      exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR)
-          .buildRepeatable(ChangeField::getAuthorNameAndEmail);
+  public static final IndexedField<ChangeData, Iterable<String>> EXACT_AUTHOR_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("ExactAuthor")
+          .required()
+          .description("The exact name, email address and NameEmail of the author.")
+          .build(ChangeField::getAuthorNameAndEmail);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_AUTHOR_SPEC =
+      EXACT_AUTHOR_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR);
 
   /**
    * The exact email address, or any part of the committer name or email address, in the current
    * patch set.
    */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
-      fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts);
+  public static final IndexedField<ChangeData, Iterable<String>> COMMITTER_PARTS_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("CommitterParts")
+          .description(
+              "The exact email address, or any part of the committer name or email address, in the current patch set.")
+          .required()
+          .build(ChangeField::getCommitterParts);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMITTER_PARTS_SPEC =
+      COMMITTER_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMITTER);
 
   /** The exact name, email address, and NameEmail of the committer. */
-  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMITTER =
-      exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER)
-          .buildRepeatable(ChangeField::getCommitterNameAndEmail);
+  public static final IndexedField<ChangeData, Iterable<String>> EXACT_COMMITTER_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("ExactCommiter")
+          .required()
+          .description("The exact name, email address, and NameEmail of the committer.")
+          .build(ChangeField::getCommitterNameAndEmail);
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_COMMITTER_SPEC =
+      EXACT_COMMITTER_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER);
 
   /** Serialized change object, used for pre-populating results. */
-  public static final FieldDef<ChangeData, byte[]> CHANGE =
-      storedOnly("_change")
+  private static final TypeToken<Entities.Change> CHANGE_TYPE_TOKEN =
+      new TypeToken<>() {
+        private static final long serialVersionUID = 1L;
+      };
+
+  public static final IndexedField<ChangeData, Entities.Change> CHANGE_FIELD =
+      IndexedField.<ChangeData, Entities.Change>builder("Change", CHANGE_TYPE_TOKEN)
+          .stored()
+          .required()
+          .protoConverter(Optional.of(ChangeProtoConverter.INSTANCE))
           .build(
-              changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)),
-              (cd, field) -> cd.setChange(parseProtoFrom(field, ChangeProtoConverter.INSTANCE)));
+              changeGetter(change -> entityToProto(ChangeProtoConverter.INSTANCE, change)),
+              (cd, value) ->
+                  cd.setChange(decodeProtoToEntity(value, ChangeProtoConverter.INSTANCE)));
+
+  public static final IndexedField<ChangeData, Entities.Change>.SearchSpec CHANGE_SPEC =
+      CHANGE_FIELD.storedOnly("_change");
 
   /** Serialized approvals for the current patch set, used for pre-populating results. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
-      storedOnly("_approval")
-          .buildRepeatable(
-              cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
+  private static final TypeToken<Iterable<Entities.PatchSetApproval>> APPROVAL_TYPE_TOKEN =
+      new TypeToken<>() {
+        private static final long serialVersionUID = 1L;
+      };
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSetApproval>> APPROVAL_FIELD =
+      IndexedField.<ChangeData, Iterable<Entities.PatchSetApproval>>builder(
+              "Approval", APPROVAL_TYPE_TOKEN)
+          .stored()
+          .required()
+          .protoConverter(Optional.of(PatchSetApprovalProtoConverter.INSTANCE))
+          .build(
+              cd ->
+                  entitiesToProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
               (cd, field) ->
                   cd.setCurrentApprovals(
-                      decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
+                      decodeProtosToEntities(field, PatchSetApprovalProtoConverter.INSTANCE)));
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSetApproval>>.SearchSpec
+      APPROVAL_SPEC = APPROVAL_FIELD.storedOnly("_approval");
 
   public static String formatLabel(String label, int value) {
     return formatLabel(label, value, /* accountId= */ null, /* count= */ null);
@@ -831,13 +1067,41 @@
   }
 
   /** Commit message of the current patch set. */
-  public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
-      fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage);
+  public static final IndexedField<ChangeData, String> COMMIT_MESSAGE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("CommitMessage")
+          .required()
+          .build(ChangeData::commitMessage);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE =
+      COMMIT_MESSAGE_FIELD.fullText(ChangeQueryBuilder.FIELD_MESSAGE);
+
+  /** Commit message of the current patch set, used to exactly match the commit message */
+  public static final IndexedField<ChangeData, String> COMMIT_MESSAGE_EXACT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("CommitMessageExact")
+          .required()
+          .description(
+              "Same as CommitMessage, but truncated, since supporting such large tokens may be problematic for indexes.")
+          .build(cd -> truncateStringValueToMaxTermLength(cd.commitMessage()));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE_EXACT =
+      COMMIT_MESSAGE_EXACT_FIELD.exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT);
+
+  /** Subject of the current patch set (aka first line of the commit message). */
+  public static final IndexedField<ChangeData, String> SUBJECT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Subject")
+          .required()
+          .build(changeGetter(Change::getSubject));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec SUBJECT_SPEC =
+      SUBJECT_FIELD.fullText(ChangeQueryBuilder.FIELD_SUBJECT);
+
+  public static final IndexedField<ChangeData, String>.SearchSpec PREFIX_SUBJECT_SPEC =
+      SUBJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
 
   /** Summary or inline comment. */
-  public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
-      fullText(ChangeQueryBuilder.FIELD_COMMENT)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>> COMMENT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Comment")
+          .build(
               cd ->
                   Stream.concat(
                           cd.publishedComments().stream().map(c -> c.message),
@@ -848,22 +1112,35 @@
                           cd.messages().stream().map(ChangeMessage::getMessage))
                       .collect(toSet()));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMENT_SPEC =
+      COMMENT_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMENT);
+
   /** Number of unresolved comment threads of the change, including robot comments. */
-  public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
-      intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
+  public static final IndexedField<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("UnresolvedCommentCount")
+          .stored()
           .build(
               ChangeData::unresolvedCommentCount,
               (cd, field) -> cd.setUnresolvedCommentCount(field));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec UNRESOLVED_COMMENT_COUNT_SPEC =
+      UNRESOLVED_COMMENT_COUNT_FIELD.integerRange(
+          ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT);
+
   /** Total number of published inline comments of the change, including robot comments. */
-  public static final FieldDef<ChangeData, Integer> TOTAL_COMMENT_COUNT =
-      intRange("total_comments")
+  public static final IndexedField<ChangeData, Integer> TOTAL_COMMENT_COUNT_FIELD =
+      IndexedField.<ChangeData>integerBuilder("TotalCommentCount")
+          .stored()
           .build(ChangeData::totalCommentCount, (cd, field) -> cd.setTotalCommentCount(field));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec TOTAL_COMMENT_COUNT_SPEC =
+      TOTAL_COMMENT_COUNT_FIELD.integerRange("total_comments");
+
   /** Whether the change is mergeable. */
-  public static final FieldDef<ChangeData, String> MERGEABLE =
-      exact(ChangeQueryBuilder.FIELD_MERGEABLE)
+  public static final IndexedField<ChangeData, String> MERGEABLE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Mergeable")
           .stored()
+          .size(1)
           .build(
               cd -> {
                 Boolean m = cd.isMergeable();
@@ -874,10 +1151,14 @@
               },
               (cd, field) -> cd.setMergeable(field == null ? false : field.equals("1")));
 
+  public static final IndexedField<ChangeData, String>.SearchSpec MERGEABLE_SPEC =
+      MERGEABLE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGEABLE);
+
   /** Whether the change is a merge commit. */
-  public static final FieldDef<ChangeData, String> MERGE =
-      exact(ChangeQueryBuilder.FIELD_MERGE)
+  public static final IndexedField<ChangeData, String> MERGE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Merge")
           .stored()
+          .size(1)
           .build(
               cd -> {
                 Boolean m = cd.isMerge();
@@ -887,48 +1168,89 @@
                 return m ? "1" : "0";
               });
 
+  public static final IndexedField<ChangeData, String>.SearchSpec MERGE_SPEC =
+      MERGE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGE);
+
   /** Whether the change is a cherry pick of another change. */
-  public static final FieldDef<ChangeData, String> CHERRY_PICK =
-      exact(ChangeQueryBuilder.FIELD_CHERRYPICK)
+  public static final IndexedField<ChangeData, String> CHERRY_PICK_FIELD =
+      IndexedField.<ChangeData>stringBuilder("CherryPick")
           .stored()
+          .size(1)
           .build(cd -> cd.change().getCherryPickOf() != null ? "1" : "0");
 
+  public static final IndexedField<ChangeData, String>.SearchSpec CHERRY_PICK_SPEC =
+      CHERRY_PICK_FIELD.exact(ChangeQueryBuilder.FIELD_CHERRYPICK);
+
   /** The number of inserted lines in this change. */
-  public static final FieldDef<ChangeData, Integer> ADDED =
-      intRange(ChangeQueryBuilder.FIELD_ADDED)
+  public static final IndexedField<ChangeData, Integer> ADDED_LINES_FIELD =
+      IndexedField.<ChangeData>integerBuilder("AddedLines")
+          .stored()
           .build(
               cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null,
-              (cd, field) -> cd.setLinesInserted(field));
+              (cd, field) -> {
+                if (field != null) {
+                  cd.setLinesInserted(field);
+                }
+              });
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec ADDED_LINES_SPEC =
+      ADDED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_ADDED);
 
   /** The number of deleted lines in this change. */
-  public static final FieldDef<ChangeData, Integer> DELETED =
-      intRange(ChangeQueryBuilder.FIELD_DELETED)
+  public static final IndexedField<ChangeData, Integer> DELETED_LINES_FIELD =
+      IndexedField.<ChangeData>integerBuilder("DeletedLines")
+          .stored()
           .build(
               cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null,
-              (cd, field) -> cd.setLinesDeleted(field));
+              (cd, field) -> {
+                if (field != null) {
+                  cd.setLinesDeleted(field);
+                }
+              });
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec DELETED_LINES_SPEC =
+      DELETED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_DELETED);
 
   /** The total number of modified lines in this change. */
-  public static final FieldDef<ChangeData, Integer> DELTA =
-      intRange(ChangeQueryBuilder.FIELD_DELTA)
+  public static final IndexedField<ChangeData, Integer> DELTA_LINES_FIELD =
+      IndexedField.<ChangeData>integerBuilder("DeltaLines")
+          .stored()
           .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
 
+  public static final IndexedField<ChangeData, Integer>.SearchSpec DELTA_LINES_SPEC =
+      DELTA_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_DELTA);
+
   /** Determines if this change is private. */
-  public static final FieldDef<ChangeData, String> PRIVATE =
-      exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0");
+  public static final IndexedField<ChangeData, String> PRIVATE_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Private")
+          .size(1)
+          .build(cd -> cd.change().isPrivate() ? "1" : "0");
+
+  public static final IndexedField<ChangeData, String>.SearchSpec PRIVATE_SPEC =
+      PRIVATE_FIELD.exact(ChangeQueryBuilder.FIELD_PRIVATE);
 
   /** Determines if this change is work in progress. */
-  public static final FieldDef<ChangeData, String> WIP =
-      exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+  public static final IndexedField<ChangeData, String> WIP_FIELD =
+      IndexedField.<ChangeData>stringBuilder("WIP")
+          .size(1)
+          .build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+
+  public static final IndexedField<ChangeData, String>.SearchSpec WIP_SPEC =
+      WIP_FIELD.exact(ChangeQueryBuilder.FIELD_WIP);
 
   /** Determines if this change has started review. */
-  public static final FieldDef<ChangeData, String> STARTED =
-      exact(ChangeQueryBuilder.FIELD_STARTED)
+  public static final IndexedField<ChangeData, String> STARTED_FIELD =
+      IndexedField.<ChangeData>stringBuilder("ReviewStarted")
+          .size(1)
           .build(cd -> cd.change().hasReviewStarted() ? "1" : "0");
 
+  public static final IndexedField<ChangeData, String>.SearchSpec STARTED_SPEC =
+      STARTED_FIELD.exact(ChangeQueryBuilder.FIELD_STARTED);
+
   /** Users who have commented on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
-      integer(ChangeQueryBuilder.FIELD_COMMENTBY)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<Integer>> COMMENTBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("CommentBy")
+          .build(
               cd ->
                   Stream.concat(
                           cd.messages().stream().map(ChangeMessage::getAuthor),
@@ -937,11 +1259,14 @@
                       .map(Account.Id::get)
                       .collect(toSet()));
 
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec COMMENTBY_SPEC =
+      COMMENTBY_FIELD.integer(ChangeQueryBuilder.FIELD_COMMENTBY);
+
   /** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
-  public static final FieldDef<ChangeData, Iterable<String>> STAR =
-      exact(ChangeQueryBuilder.FIELD_STAR)
+  public static final IndexedField<ChangeData, Iterable<String>> STAR_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Star")
           .stored()
-          .buildRepeatable(
+          .build(
               cd ->
                   Iterables.transform(
                       cd.stars().entries(),
@@ -953,33 +1278,61 @@
                           .map(f -> StarredChangesUtil.StarField.parse(f))
                           .collect(toImmutableListMultimap(e -> e.accountId(), e -> e.label()))));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec STAR_SPEC =
+      STAR_FIELD.exact(ChangeQueryBuilder.FIELD_STAR);
+
   /** Users that have starred the change with any label. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
-      integer(ChangeQueryBuilder.FIELD_STARBY)
-          .buildRepeatable(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+  public static final IndexedField<ChangeData, Iterable<Integer>> STARBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("StarBy")
+          .build(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec STARBY_SPEC =
+      STARBY_FIELD.integer(ChangeQueryBuilder.FIELD_STARBY);
 
   /** Opaque group identifiers for this change's patch sets. */
-  public static final FieldDef<ChangeData, Iterable<String>> GROUP =
-      exact(ChangeQueryBuilder.FIELD_GROUP)
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>> GROUP_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("Group")
+          .build(
               cd -> cd.patchSets().stream().flatMap(ps -> ps.groups().stream()).collect(toSet()));
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec GROUP_SPEC =
+      GROUP_FIELD.exact(ChangeQueryBuilder.FIELD_GROUP);
+
   /** Serialized patch set object, used for pre-populating results. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
-      storedOnly("_patch_set")
-          .buildRepeatable(
-              cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
-              (cd, field) -> cd.setPatchSets(decodeProtos(field, PatchSetProtoConverter.INSTANCE)));
+  private static final TypeToken<Iterable<Entities.PatchSet>> PATCH_SET_TYPE_TOKEN =
+      new TypeToken<>() {
+        private static final long serialVersionUID = 1L;
+      };
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSet>> PATCH_SET_FIELD =
+      IndexedField.<ChangeData, Iterable<Entities.PatchSet>>builder(
+              "PatchSet", PATCH_SET_TYPE_TOKEN)
+          .stored()
+          .required()
+          .protoConverter(Optional.of(PatchSetProtoConverter.INSTANCE))
+          .build(
+              cd -> entitiesToProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
+              (cd, value) ->
+                  cd.setPatchSets(decodeProtosToEntities(value, PatchSetProtoConverter.INSTANCE)));
+
+  public static final IndexedField<ChangeData, Iterable<Entities.PatchSet>>.SearchSpec
+      PATCH_SET_SPEC = PATCH_SET_FIELD.storedOnly("_patch_set");
 
   /** Users who have edits on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
-      integer(ChangeQueryBuilder.FIELD_EDITBY)
-          .buildRepeatable(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<Integer>> EDITBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("EditBy")
+          .build(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec EDITBY_SPEC =
+      EDITBY_FIELD.integer(ChangeQueryBuilder.FIELD_EDITBY);
 
   /** Users who have draft comments on this change. */
-  public static final FieldDef<ChangeData, Iterable<Integer>> DRAFTBY =
-      integer(ChangeQueryBuilder.FIELD_DRAFTBY)
-          .buildRepeatable(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
+  public static final IndexedField<ChangeData, Iterable<Integer>> DRAFTBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("DraftBy")
+          .build(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
+
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec DRAFTBY_SPEC =
+      DRAFTBY_FIELD.integer(ChangeQueryBuilder.FIELD_DRAFTBY);
 
   public static final Integer NOT_REVIEWED = -1;
 
@@ -993,10 +1346,10 @@
    * <p>If the latest update is by the change owner, then the special value {@link #NOT_REVIEWED} is
    * emitted.
    */
-  public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
-      integer(ChangeQueryBuilder.FIELD_REVIEWEDBY)
+  public static final IndexedField<ChangeData, Iterable<Integer>> REVIEWEDBY_FIELD =
+      IndexedField.<ChangeData>iterableIntegerBuilder("ReviewedBy")
           .stored()
-          .buildRepeatable(
+          .build(
               cd -> {
                 Set<Account.Id> reviewedBy = cd.reviewedBy();
                 if (reviewedBy.isEmpty()) {
@@ -1010,6 +1363,9 @@
                           .map(Account::id)
                           .collect(toImmutableSet())));
 
+  public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec REVIEWEDBY_SPEC =
+      REVIEWEDBY_FIELD.integer(ChangeQueryBuilder.FIELD_REVIEWEDBY);
+
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
       SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
 
@@ -1017,9 +1373,9 @@
       SubmitRuleOptions.builder().build();
 
   /** All submit rules results in the form of "$ruleName,$status". */
-  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RULE_RESULT =
-      exact("submit_rule_result")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>> SUBMIT_RULE_RESULT_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("SubmitRuleResult")
+          .build(
               cd -> {
                 List<String> result = new ArrayList<>();
                 List<SubmitRecord> submitRecords = cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT);
@@ -1029,6 +1385,9 @@
                 return result;
               });
 
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec
+      SUBMIT_RULE_RESULT_SPEC = SUBMIT_RULE_RESULT_FIELD.exact("submit_rule_result");
+
   /**
    * JSON type for storing SubmitRecords.
    *
@@ -1115,12 +1474,17 @@
     }
   }
 
-  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RECORD =
-      exact("submit_record").buildRepeatable(ChangeField::formatSubmitRecordValues);
+  public static final IndexedField<ChangeData, Iterable<String>> SUBMIT_RECORD_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("SubmitRecord")
+          .build(ChangeField::formatSubmitRecordValues);
 
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
-      storedOnly("full_submit_record_strict")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec SUBMIT_RECORD_SPEC =
+      SUBMIT_RECORD_FIELD.exact("submit_record");
+
+  public static final IndexedField<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("FullSubmitRecordStrict")
+          .stored()
+          .build(
               cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT),
               (cd, field) ->
                   parseSubmitRecords(
@@ -1130,17 +1494,27 @@
                       SUBMIT_RULE_OPTIONS_STRICT,
                       cd));
 
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
-      storedOnly("full_submit_record_lenient")
-          .buildRepeatable(
-              cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
-              (cd, field) ->
-                  parseSubmitRecords(
-                      StreamSupport.stream(field.spliterator(), false)
-                          .map(f -> new String(f, UTF_8))
-                          .collect(toSet()),
-                      SUBMIT_RULE_OPTIONS_LENIENT,
-                      cd));
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      STORED_SUBMIT_RECORD_STRICT_SPEC =
+          STORED_SUBMIT_RECORD_STRICT_FIELD.storedOnly("full_submit_record_strict");
+
+  public static final IndexedField<ChangeData, Iterable<byte[]>>
+      STORED_SUBMIT_RECORD_LENIENT_FIELD =
+          IndexedField.<ChangeData>iterableByteArrayBuilder("FullSubmitRecordLenient")
+              .stored()
+              .build(
+                  cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
+                  (cd, field) ->
+                      parseSubmitRecords(
+                          StreamSupport.stream(field.spliterator(), false)
+                              .map(f -> new String(f, UTF_8))
+                              .collect(toSet()),
+                          SUBMIT_RULE_OPTIONS_LENIENT,
+                          cd));
+
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+      STORED_SUBMIT_RECORD_LENIENT_SPEC =
+          STORED_SUBMIT_RECORD_LENIENT_FIELD.storedOnly("full_submit_record_lenient");
 
   public static void parseSubmitRecords(
       Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
@@ -1165,8 +1539,18 @@
   }
 
   public static List<String> formatSubmitRecordValues(ChangeData cd) {
-    return formatSubmitRecordValues(
-        cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner());
+    Set<String> submitRecordValues = new HashSet<>();
+    submitRecordValues.addAll(
+        formatSubmitRecordValues(
+            cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner()));
+    // Also backfill results of submit requirements such that users can query submit requirement
+    // results using the label operator, for example a query with "label:CR=NEED" will match with
+    // changes that have a submit-requirement with name="CR" and status=UNSATISFIED.
+    // Reason: We are preserving backward compatibility of the operators `label:$name=$status`
+    // which were previously working with submit records. Now admins can configure submit
+    // requirements and continue querying them with the label operator.
+    submitRecordValues.addAll(formatSubmitRequirementValues(cd.submitRequirements().values()));
+    return submitRecordValues.stream().collect(Collectors.toList());
   }
 
   @VisibleForTesting
@@ -1192,23 +1576,79 @@
     return result;
   }
 
-  /** Serialized submit requirements, used for pre-populating results. */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_REQUIREMENTS =
-      storedOnly("full_submit_requirements")
-          .buildRepeatable(
-              cd ->
-                  toProtos(
-                      SubmitRequirementProtoConverter.INSTANCE, cd.submitRequirements().values()),
-              (cd, field) -> parseSubmitRequirements(field, cd));
+  /**
+   * Generate submit requirement result formats that are compatible with the legacy submit record
+   * statuses.
+   */
+  @VisibleForTesting
+  static List<String> formatSubmitRequirementValues(Collection<SubmitRequirementResult> srResults) {
+    List<String> result = new ArrayList<>();
+    for (SubmitRequirementResult srResult : srResults) {
+      switch (srResult.status()) {
+        case SATISFIED:
+        case OVERRIDDEN:
+        case FORCED:
+          result.add(
+              SubmitRecord.Label.Status.OK.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          result.add(
+              SubmitRecord.Label.Status.MAY.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          break;
+        case UNSATISFIED:
+          result.add(
+              SubmitRecord.Label.Status.NEED.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          result.add(
+              SubmitRecord.Label.Status.REJECT.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+          break;
+        case NOT_APPLICABLE:
+        case ERROR:
+          result.add(
+              SubmitRecord.Label.Status.IMPOSSIBLE.name()
+                  + ","
+                  + srResult.submitRequirement().name().toLowerCase());
+      }
+    }
+    return result;
+  }
 
-  private static void parseSubmitRequirements(Iterable<byte[]> values, ChangeData out) {
+  /** Serialized submit requirements, used for pre-populating results. */
+  private static final TypeToken<Iterable<Cache.SubmitRequirementResultProto>>
+      STORED_SUBMIT_REQUIREMENTS_TYPE_TOKEN =
+          new TypeToken<>() {
+            private static final long serialVersionUID = 1L;
+          };
+
+  public static final IndexedField<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>
+      STORED_SUBMIT_REQUIREMENTS_FIELD =
+          IndexedField.<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>builder(
+                  "StoredSubmitRequirements", STORED_SUBMIT_REQUIREMENTS_TYPE_TOKEN)
+              .stored()
+              .required()
+              .protoConverter(Optional.of(SubmitRequirementProtoConverter.INSTANCE))
+              .build(
+                  cd ->
+                      entitiesToProtos(
+                          SubmitRequirementProtoConverter.INSTANCE,
+                          cd.submitRequirements().values()),
+                  (cd, value) -> parseSubmitRequirements(value, cd));
+
+  public static final IndexedField<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>
+          .SearchSpec
+      STORED_SUBMIT_REQUIREMENTS_SPEC =
+          STORED_SUBMIT_REQUIREMENTS_FIELD.storedOnly("full_submit_requirements");
+
+  private static void parseSubmitRequirements(
+      Iterable<Cache.SubmitRequirementResultProto> values, ChangeData out) {
     out.setSubmitRequirements(
-        StreamSupport.stream(values.spliterator(), false)
-            .map(
-                f ->
-                    SubmitRequirementProtoConverter.INSTANCE.fromProto(
-                        Protos.parseUnchecked(
-                            SubmitRequirementProtoConverter.INSTANCE.getParser(), f)))
+        decodeProtosToEntities(values, SubmitRequirementProtoConverter.INSTANCE).stream()
+            .filter(sr -> !sr.isLegacy())
             .collect(
                 ImmutableMap.toImmutableMap(sr -> sr.submitRequirement(), Function.identity())));
   }
@@ -1218,9 +1658,10 @@
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
    */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE =
-      storedOnly("ref_state")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> REF_STATE_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("RefState")
+          .stored()
+          .build(
               cd -> {
                 List<byte[]> result = new ArrayList<>();
                 cd.getRefStates()
@@ -1230,15 +1671,19 @@
               },
               (cd, field) -> cd.setRefStates(RefState.parseStates(field)));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
+
   /**
    * All ref wildcard patterns that were used in the course of indexing this document.
    *
    * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}. See {@link
    * RefStatePattern} for the pattern format.
    */
-  public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN =
-      storedOnly("ref_state_pattern")
-          .buildRepeatable(
+  public static final IndexedField<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN_FIELD =
+      IndexedField.<ChangeData>iterableByteArrayBuilder("RefStatePattern")
+          .stored()
+          .build(
               cd -> {
                 Change.Id id = cd.getId();
                 Project.NameKey project = cd.change().getProject();
@@ -1257,6 +1702,10 @@
               },
               (cd, field) -> cd.setRefStatePatterns(field));
 
+  public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec REF_STATE_PATTERN_SPEC =
+      REF_STATE_PATTERN_FIELD.storedOnly("ref_state_pattern");
+
+  @Nullable
   private static String getTopic(ChangeData cd) {
     Change c = cd.change();
     if (c == null) {
@@ -1265,31 +1714,77 @@
     return firstNonNull(c.getTopic(), "");
   }
 
-  private static <T> List<byte[]> toProtos(ProtoConverter<?, T> converter, Collection<T> objects) {
-    return objects.stream().map(object -> toProto(converter, object)).collect(toImmutableList());
+  private static <V extends MessageLite, T> V entityToProto(
+      ProtoConverter<V, T> converter, T object) {
+    return converter.toProto(object);
   }
 
-  private static <T> byte[] toProto(ProtoConverter<?, T> converter, T object) {
-    return Protos.toByteArray(converter.toProto(object));
-  }
-
-  private static <T> List<T> decodeProtos(Iterable<byte[]> raw, ProtoConverter<?, T> converter) {
-    return StreamSupport.stream(raw.spliterator(), false)
-        .map(bytes -> parseProtoFrom(bytes, converter))
+  private static <V extends MessageLite, T> List<V> entitiesToProtos(
+      ProtoConverter<V, T> converter, Collection<T> objects) {
+    return objects.stream()
+        .map(object -> entityToProto(converter, object))
         .collect(toImmutableList());
   }
 
-  private static <P extends MessageLite, T> T parseProtoFrom(
-      byte[] bytes, ProtoConverter<P, T> converter) {
-    P message = Protos.parseUnchecked(converter.getParser(), bytes, 0, bytes.length);
-    return converter.fromProto(message);
+  private static <V extends MessageLite, T> List<T> decodeProtosToEntities(
+      Iterable<V> raw, ProtoConverter<V, T> converter) {
+    return StreamSupport.stream(raw.spliterator(), false)
+        .map(proto -> decodeProtoToEntity(proto, converter))
+        .collect(toImmutableList());
   }
 
-  private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
+  private static <V extends MessageLite, T> T decodeProtoToEntity(
+      V proto, ProtoConverter<V, T> converter) {
+    return converter.fromProto(proto);
+  }
+
+  private static <T> SchemaFieldDefs.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
     return in -> in.change() != null ? func.apply(in.change()) : null;
   }
 
   private static AllUsersName allUsers(ChangeData cd) {
     return cd.getAllUsersNameForIndexing();
   }
+
+  private static String truncateStringValueToMaxTermLength(String str) {
+    return truncateStringValue(str, MAX_TERM_LENGTH);
+  }
+
+  @VisibleForTesting
+  static String truncateStringValue(String str, int maxBytes) {
+    if (maxBytes < 0) {
+      throw new IllegalArgumentException("maxBytes < 0 not allowed");
+    }
+
+    if (maxBytes == 0) {
+      return "";
+    }
+
+    if (str.length() > maxBytes) {
+      if (Character.isHighSurrogate(str.charAt(maxBytes - 1))) {
+        str = str.substring(0, maxBytes - 1);
+      } else {
+        str = str.substring(0, maxBytes);
+      }
+    }
+    byte[] strBytes = str.getBytes(UTF_8);
+    if (strBytes.length > maxBytes) {
+      while (maxBytes > 0 && (strBytes[maxBytes] & 0xC0) == 0x80) {
+        maxBytes -= 1;
+      }
+      if (maxBytes > 0) {
+        if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xE0) == 0xC0) {
+          maxBytes -= 1;
+        }
+        if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xF0) == 0xE0) {
+          maxBytes -= 1;
+        }
+        if (strBytes.length >= maxBytes && (strBytes[maxBytes - 1] & 0xF8) == 0xF0) {
+          maxBytes -= 1;
+        }
+      }
+      return new String(Arrays.copyOfRange(strBytes, 0, maxBytes), UTF_8);
+    }
+    return str;
+  }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 05c5c77..74e9af1 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
+import java.util.function.Function;
 
 /**
  * Index for Gerrit changes. This class is mainly used for typing the generic parent class that
@@ -30,8 +31,8 @@
 
   @Override
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
-    return getSchema().useLegacyNumericFields()
-        ? ChangePredicates.id(id)
-        : ChangePredicates.idStr(id);
+    return ChangePredicates.idStr(id);
   }
+
+  Function<ChangeData, Change.Id> ENTITY_TO_KEY = ChangeData::getId;
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 63c5297..bb4b24c 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -19,17 +19,21 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.query.AndCardinalPredicate;
 import com.google.gerrit.index.query.AndPredicate;
+import com.google.gerrit.index.query.HasCardinality;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.NotPredicate;
+import com.google.gerrit.index.query.OrCardinalPredicate;
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -39,12 +43,14 @@
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.IsSubmittablePredicate;
 import com.google.gerrit.server.query.change.OrSource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.BitSet;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.util.MutableInteger;
 
@@ -83,7 +89,7 @@
     return s != null ? s : EnumSet.allOf(Change.Status.class);
   }
 
-  private static EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
+  private static @Nullable EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
     if (in instanceof ChangeStatusPredicate) {
       Status status = ((ChangeStatusPredicate) in).getStatus();
       return status != null ? EnumSet.of(status) : null;
@@ -156,6 +162,9 @@
 
     MutableInteger leafTerms = new MutableInteger();
     Predicate<ChangeData> out = rewriteImpl(in, index, opts, leafTerms);
+    if (leafTerms.value > config.maxTerms()) {
+      throw new TooManyTermsInQueryException(leafTerms.value, config.maxTerms());
+    }
     if (isSameInstance(in, out) || out instanceof IndexPredicate) {
       return new IndexedChangeQuery(index, out, opts);
     } else if (out == null /* cannot rewrite */) {
@@ -179,13 +188,13 @@
    * @throws QueryParseException if the underlying index implementation does not support this
    *     predicate.
    */
+  @Nullable
   private Predicate<ChangeData> rewriteImpl(
       Predicate<ChangeData> in, ChangeIndex index, QueryOptions opts, MutableInteger leafTerms)
       throws QueryParseException {
+    in = IsSubmittablePredicate.rewrite(in);
     if (isIndexPredicate(in, index)) {
-      if (++leafTerms.value > config.maxTerms()) {
-        throw new TooManyTermsInQueryException();
-      }
+      ++leafTerms.value;
       return in;
     } else if (in instanceof LimitPredicate) {
       // Replace any limits with the limit provided by the caller. The caller
@@ -243,9 +252,9 @@
     }
     IndexPredicate<ChangeData> p = (IndexPredicate<ChangeData>) in;
 
-    FieldDef<ChangeData, ?> def = p.getField();
+    SchemaField<ChangeData, ?> field = p.getField();
     Schema<ChangeData> schema = index.getSchema();
-    return schema.hasField(def);
+    return schema.hasField(field);
   }
 
   private Predicate<ChangeData> partitionChildren(
@@ -257,7 +266,8 @@
       throws QueryParseException {
     if (isIndexed.cardinality() == 1) {
       int i = isIndexed.nextSetBit(0);
-      newChildren.add(0, new IndexedChangeQuery(index, newChildren.remove(i), opts));
+      Predicate<ChangeData> indexed = newChildren.remove(i);
+      newChildren.add(0, new IndexedChangeQuery(index, copy(indexed, indexed.getChildren()), opts));
       return copy(in, newChildren);
     }
 
@@ -275,15 +285,33 @@
         all.add(c);
       }
     }
-    all.add(0, new IndexedChangeQuery(index, in.copy(indexed), opts));
+    all.add(0, new IndexedChangeQuery(index, copy(in, indexed), opts));
     return copy(in, all);
   }
 
   private Predicate<ChangeData> copy(Predicate<ChangeData> in, List<Predicate<ChangeData>> all) {
     if (in instanceof AndPredicate) {
-      return new AndChangeSource(all);
+      Optional<Predicate<ChangeData>> atLeastOneChangeDataSource =
+          all.stream().filter(p -> (p instanceof ChangeDataSource)).findAny();
+      if (atLeastOneChangeDataSource.isPresent()) {
+        return new AndChangeSource(all, config);
+      }
+      Optional<Predicate<ChangeData>> atLeastOneCardinalPredicate =
+          all.stream().filter(p -> (p instanceof HasCardinality)).findAny();
+      if (atLeastOneCardinalPredicate.isPresent()) {
+        return new AndCardinalPredicate<>(all);
+      }
     } else if (in instanceof OrPredicate) {
-      return new OrSource(all);
+      Optional<Predicate<ChangeData>> nonChangeDataSource =
+          all.stream().filter(p -> !(p instanceof ChangeDataSource)).findAny();
+      if (!nonChangeDataSource.isPresent()) {
+        return new OrSource(all);
+      }
+      Optional<Predicate<ChangeData>> nonHasCardinality =
+          all.stream().filter(p -> !(p instanceof HasCardinality)).findAny();
+      if (!nonHasCardinality.isPresent()) {
+        return new OrCardinalPredicate<>(all);
+      }
     }
     return in.copy(all);
   }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index f7f0f33..dc3907d 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.StalenessCheckResult;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -42,11 +43,10 @@
 import com.google.inject.OutOfScopeException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -79,6 +79,7 @@
   private final PluginSetContext<ChangeIndexedListener> indexedListeners;
   private final StalenessChecker stalenessChecker;
   private final boolean autoReindexIfStale;
+  private final IsFirstInsertForEntry isFirstInsertForEntry;
 
   private final Map<Change.Id, IndexTask> queuedIndexTasks = new ConcurrentHashMap<>();
   private final Set<ReindexIfStaleTask> queuedReindexIfStaleTasks =
@@ -94,7 +95,8 @@
       StalenessChecker stalenessChecker,
       @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted ListeningExecutorService executor,
-      @Assisted ChangeIndex index) {
+      @Assisted ChangeIndex index,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
@@ -105,6 +107,7 @@
     this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = index;
     this.indexes = null;
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
   @AssistedInject
@@ -117,7 +120,8 @@
       StalenessChecker stalenessChecker,
       @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted ListeningExecutorService executor,
-      @Assisted ChangeIndexCollection indexes) {
+      @Assisted ChangeIndexCollection indexes,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
@@ -128,6 +132,7 @@
     this.autoReindexIfStale = autoReindexIfStale(cfg);
     this.index = null;
     this.indexes = indexes;
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
   private static boolean autoReindexIfStale(Config cfg) {
@@ -178,21 +183,6 @@
   }
 
   /**
-   * Start indexing multiple changes in parallel.
-   *
-   * @param ids changes to index.
-   * @return future for completing indexing of all changes.
-   */
-  public ListenableFuture<List<ChangeData>> indexAsync(
-      Project.NameKey project, Collection<Change.Id> ids) {
-    List<ListenableFuture<ChangeData>> futures = new ArrayList<>(ids.size());
-    for (Change.Id id : ids) {
-      futures.add(indexAsync(project, id));
-    }
-    return Futures.allAsList(futures);
-  }
-
-  /**
    * Synchronously index a change, then check if the index is stale due to a race condition.
    *
    * @param cd change to index.
@@ -227,21 +217,25 @@
   }
 
   private void indexImpl(ChangeData cd) {
-    logger.atFine().log("Replace change %d in index.", cd.getId().get());
+    logger.atFine().log("Reindex change %d in index.", cd.getId().get());
     for (Index<?, ChangeData> i : getWriteIndexes()) {
       try (TraceTimer traceTimer =
           TraceContext.newTimer(
-              "Replacing change in index",
+              "Reindexing change in index",
               Metadata.builder()
                   .changeId(cd.getId().get())
                   .patchSetId(cd.currentPatchSet().number())
                   .indexVersion(i.getSchema().getVersion())
                   .build())) {
-        i.replace(cd);
+        if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
+          i.insert(cd);
+        } else {
+          i.replace(cd);
+        }
       } catch (RuntimeException e) {
         throw new StorageException(
             String.format(
-                "Failed to replace change %d in index version %d (current patch set = %d)",
+                "Failed to reindex change %d in index version %d (current patch set = %d)",
                 cd.getId().get(), i.getSchema().getVersion(), cd.currentPatchSet().number()),
             e);
       }
@@ -290,9 +284,9 @@
    * @param id change to delete.
    * @return future for the deleting task, the result of the future is always {@code null}
    */
-  public ListenableFuture<ChangeData> deleteAsync(Change.Id id) {
+  public ListenableFuture<ChangeData> deleteAsync(Project.NameKey project, Change.Id id) {
     fireChangeScheduledForDeletionFromIndexEvent(id.get());
-    return submit(new DeleteTask(id));
+    return submit(new DeleteTask(id, Optional.of(project)));
   }
 
   /**
@@ -305,8 +299,12 @@
     doDelete(id);
   }
 
+  private void doDelete(Project.NameKey project, Change.Id id) {
+    new DeleteTask(id, Optional.of(project)).call();
+  }
+
   private void doDelete(Change.Id id) {
-    new DeleteTask(id).call();
+    new DeleteTask(id, Optional.empty()).call();
   }
 
   /**
@@ -414,6 +412,7 @@
       return future;
     }
 
+    @Nullable
     @Override
     public ChangeData callImpl() throws Exception {
       // Remove this task from queuedIndexTasks. This is done right at the beginning of this task so
@@ -429,7 +428,7 @@
         doIndex(changeData);
         return changeData;
       } catch (NoSuchChangeException e) {
-        doDelete(id);
+        doDelete(project, id);
       }
       return null;
     }
@@ -462,11 +461,14 @@
   // Not AbstractIndexTask as it doesn't need a request context.
   private class DeleteTask implements Callable<ChangeData> {
     private final Change.Id id;
+    private final Optional<Project.NameKey> project;
 
-    private DeleteTask(Change.Id id) {
+    private DeleteTask(Change.Id id, Optional<Project.NameKey> project) {
       this.id = id;
+      this.project = project;
     }
 
+    @Nullable
     @Override
     public ChangeData call() {
       logger.atFine().log("Delete change %d from index.", id.get());
@@ -481,7 +483,12 @@
                     .changeId(id.get())
                     .indexVersion(i.getSchema().getVersion())
                     .build())) {
-          i.delete(id);
+          // Some index implementation require ProjectKey to build a database key
+          // If delete(K) method is used, this will require changeId -> projectKey lookup (index
+          // query), which is expensive.
+          // Use changeData with ProjectKey and deleteByValue(V) method, if possible
+          project.ifPresentOrElse(
+              p -> i.deleteByValue(changeDataFactory.create(p, id)), () -> i.delete(id));
         } catch (RuntimeException e) {
           throw new StorageException(
               String.format(
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index ee93065..895c4d8 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -16,184 +16,227 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.query.change.ChangeData;
 
-/** Definition of change index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of change index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
+  /** Added new field {@link ChangeField#IS_SUBMITTABLE_SPEC} based on submit requirements. */
   @Deprecated
-  static final Schema<ChangeData> V55 =
+  static final Schema<ChangeData> V74 =
       schema(
-          ChangeField.ADDED,
-          ChangeField.APPROVAL,
-          ChangeField.ASSIGNEE,
-          ChangeField.AUTHOR,
-          ChangeField.CHANGE,
-          ChangeField.COMMENT,
-          ChangeField.COMMENTBY,
-          ChangeField.COMMIT,
-          ChangeField.COMMITTER,
-          ChangeField.COMMIT_MESSAGE,
-          ChangeField.DELETED,
-          ChangeField.DELTA,
-          ChangeField.DIRECTORY,
-          ChangeField.DRAFTBY,
-          ChangeField.EDITBY,
-          ChangeField.EXACT_AUTHOR,
-          ChangeField.EXACT_COMMIT,
-          ChangeField.EXACT_COMMITTER,
-          ChangeField.EXACT_TOPIC,
-          ChangeField.EXTENSION,
-          ChangeField.FILE_PART,
-          ChangeField.FOOTER,
-          ChangeField.FUZZY_TOPIC,
-          ChangeField.GROUP,
-          ChangeField.HASHTAG,
-          ChangeField.HASHTAG_CASE_AWARE,
-          ChangeField.ID,
-          ChangeField.LABEL,
-          ChangeField.LEGACY_ID,
-          ChangeField.MERGEABLE,
-          ChangeField.ONLY_EXTENSIONS,
-          ChangeField.OWNER,
-          ChangeField.PATCH_SET,
-          ChangeField.PATH,
-          ChangeField.PENDING_REVIEWER,
-          ChangeField.PENDING_REVIEWER_BY_EMAIL,
-          ChangeField.PRIVATE,
-          ChangeField.PROJECT,
-          ChangeField.PROJECTS,
-          ChangeField.REF,
-          ChangeField.REF_STATE,
-          ChangeField.REF_STATE_PATTERN,
-          ChangeField.REVERT_OF,
-          ChangeField.REVIEWEDBY,
-          ChangeField.REVIEWER,
-          ChangeField.REVIEWER_BY_EMAIL,
-          ChangeField.STAR,
-          ChangeField.STARBY,
-          ChangeField.STARTED,
-          ChangeField.STATUS,
-          ChangeField.STORED_SUBMIT_RECORD_LENIENT,
-          ChangeField.STORED_SUBMIT_RECORD_STRICT,
-          ChangeField.SUBMISSIONID,
-          ChangeField.SUBMIT_RECORD,
-          ChangeField.TOTAL_COMMENT_COUNT,
-          ChangeField.TR,
-          ChangeField.UNRESOLVED_COMMENT_COUNT,
-          ChangeField.UPDATED,
-          ChangeField.WIP);
+          /* version= */ 74,
+          ImmutableList.of(),
+          ImmutableList.<IndexedField<ChangeData, ?>>of(
+              ChangeField.ADDED_LINES_FIELD,
+              ChangeField.APPROVAL_FIELD,
+              ChangeField.ASSIGNEE_FIELD,
+              ChangeField.ATTENTION_SET_FULL_FIELD,
+              ChangeField.ATTENTION_SET_USERS_COUNT_FIELD,
+              ChangeField.ATTENTION_SET_USERS_FIELD,
+              ChangeField.AUTHOR_PARTS_FIELD,
+              ChangeField.CHANGE_FIELD,
+              ChangeField.CHANGE_ID_FIELD,
+              ChangeField.CHERRY_PICK_FIELD,
+              ChangeField.CHERRY_PICK_OF_CHANGE_FIELD,
+              ChangeField.CHERRY_PICK_OF_PATCHSET_FIELD,
+              ChangeField.COMMENTBY_FIELD,
+              ChangeField.COMMENT_FIELD,
+              ChangeField.COMMITTER_PARTS_FIELD,
+              ChangeField.COMMIT_FIELD,
+              ChangeField.COMMIT_MESSAGE_FIELD,
+              ChangeField.DELETED_LINES_FIELD,
+              ChangeField.DELTA_LINES_FIELD,
+              ChangeField.DIRECTORY_FIELD,
+              ChangeField.DRAFTBY_FIELD,
+              ChangeField.EDITBY_FIELD,
+              ChangeField.EXACT_AUTHOR_FIELD,
+              ChangeField.EXACT_COMMITTER_FIELD,
+              ChangeField.EXTENSION_FIELD,
+              ChangeField.FILE_PART_FIELD,
+              ChangeField.FOOTER_FIELD,
+              ChangeField.GROUP_FIELD,
+              ChangeField.HASHTAG_CASE_AWARE_FIELD,
+              ChangeField.HASHTAG_FIELD,
+              ChangeField.IS_PURE_REVERT_FIELD,
+              ChangeField.IS_SUBMITTABLE_FIELD,
+              ChangeField.LABEL_FIELD,
+              ChangeField.MERGEABLE_FIELD,
+              ChangeField.MERGED_ON_FIELD,
+              ChangeField.MERGE_FIELD,
+              ChangeField.NUMERIC_ID_STR_FIELD,
+              ChangeField.ONLY_EXTENSIONS_FIELD,
+              ChangeField.OWNER_FIELD,
+              ChangeField.PATCH_SET_FIELD,
+              ChangeField.PATH_FIELD,
+              ChangeField.PENDING_REVIEWER_BY_EMAIL_FIELD,
+              ChangeField.PENDING_REVIEWER_FIELD,
+              ChangeField.PRIVATE_FIELD,
+              ChangeField.PROJECT_FIELD,
+              ChangeField.REF_FIELD,
+              ChangeField.REF_STATE_FIELD,
+              ChangeField.REF_STATE_PATTERN_FIELD,
+              ChangeField.REVERT_OF_FIELD,
+              ChangeField.REVIEWEDBY_FIELD,
+              ChangeField.REVIEWER_BY_EMAIL_FIELD,
+              ChangeField.REVIEWER_FIELD,
+              ChangeField.STARBY_FIELD,
+              ChangeField.STARTED_FIELD,
+              ChangeField.STAR_FIELD,
+              ChangeField.STATUS_FIELD,
+              ChangeField.STORED_SUBMIT_RECORD_LENIENT_FIELD,
+              ChangeField.STORED_SUBMIT_RECORD_STRICT_FIELD,
+              ChangeField.STORED_SUBMIT_REQUIREMENTS_FIELD,
+              ChangeField.SUBMISSIONID_FIELD,
+              ChangeField.SUBMIT_RECORD_FIELD,
+              ChangeField.SUBMIT_RULE_RESULT_FIELD,
+              ChangeField.TOPIC_FIELD,
+              ChangeField.TOTAL_COMMENT_COUNT_FIELD,
+              ChangeField.TR_FIELD,
+              ChangeField.UNRESOLVED_COMMENT_COUNT_FIELD,
+              ChangeField.UPDATED_FIELD,
+              ChangeField.UPLOADER_FIELD,
+              ChangeField.WIP_FIELD),
+          ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+              ChangeField.ADDED_LINES_SPEC,
+              ChangeField.APPROVAL_SPEC,
+              ChangeField.ASSIGNEE_SPEC,
+              ChangeField.ATTENTION_SET_FULL_SPEC,
+              ChangeField.ATTENTION_SET_USERS,
+              ChangeField.ATTENTION_SET_USERS_COUNT,
+              ChangeField.AUTHOR_PARTS_SPEC,
+              ChangeField.CHANGE_ID_SPEC,
+              ChangeField.CHANGE_SPEC,
+              ChangeField.CHERRY_PICK_OF_CHANGE,
+              ChangeField.CHERRY_PICK_OF_PATCHSET,
+              ChangeField.CHERRY_PICK_SPEC,
+              ChangeField.COMMENTBY_SPEC,
+              ChangeField.COMMENT_SPEC,
+              ChangeField.COMMITTER_PARTS_SPEC,
+              ChangeField.COMMIT_MESSAGE,
+              ChangeField.COMMIT_SPEC,
+              ChangeField.DELETED_LINES_SPEC,
+              ChangeField.DELTA_LINES_SPEC,
+              ChangeField.DIRECTORY_SPEC,
+              ChangeField.DRAFTBY_SPEC,
+              ChangeField.EDITBY_SPEC,
+              ChangeField.EXACT_AUTHOR_SPEC,
+              ChangeField.EXACT_COMMITTER_SPEC,
+              ChangeField.EXACT_COMMIT_SPEC,
+              ChangeField.EXACT_TOPIC,
+              ChangeField.EXTENSION_SPEC,
+              ChangeField.FILE_PART_SPEC,
+              ChangeField.FOOTER_SPEC,
+              ChangeField.FUZZY_HASHTAG,
+              ChangeField.FUZZY_TOPIC,
+              ChangeField.GROUP_SPEC,
+              ChangeField.HASHTAG_CASE_AWARE_SPEC,
+              ChangeField.HASHTAG_SPEC,
+              ChangeField.IS_PURE_REVERT_SPEC,
+              ChangeField.IS_SUBMITTABLE_SPEC,
+              ChangeField.LABEL_SPEC,
+              ChangeField.MERGEABLE_SPEC,
+              ChangeField.MERGED_ON_SPEC,
+              ChangeField.MERGE_SPEC,
+              ChangeField.NUMERIC_ID_STR_SPEC,
+              ChangeField.ONLY_EXTENSIONS_SPEC,
+              ChangeField.OWNER_SPEC,
+              ChangeField.PATCH_SET_SPEC,
+              ChangeField.PATH_SPEC,
+              ChangeField.PENDING_REVIEWER_BY_EMAIL,
+              ChangeField.PENDING_REVIEWER_SPEC,
+              ChangeField.PRIVATE_SPEC,
+              ChangeField.PROJECTS_SPEC,
+              ChangeField.PROJECT_SPEC,
+              ChangeField.REF_SPEC,
+              ChangeField.REF_STATE_PATTERN_SPEC,
+              ChangeField.REF_STATE_SPEC,
+              ChangeField.REVERT_OF,
+              ChangeField.REVIEWEDBY_SPEC,
+              ChangeField.REVIEWER_BY_EMAIL,
+              ChangeField.REVIEWER_SPEC,
+              ChangeField.STARBY_SPEC,
+              ChangeField.STARTED_SPEC,
+              ChangeField.STAR_SPEC,
+              ChangeField.STATUS_SPEC,
+              ChangeField.STORED_SUBMIT_RECORD_LENIENT_SPEC,
+              ChangeField.STORED_SUBMIT_RECORD_STRICT_SPEC,
+              ChangeField.STORED_SUBMIT_REQUIREMENTS_SPEC,
+              ChangeField.SUBMISSIONID_SPEC,
+              ChangeField.SUBMIT_RECORD_SPEC,
+              ChangeField.SUBMIT_RULE_RESULT_SPEC,
+              ChangeField.TOTAL_COMMENT_COUNT_SPEC,
+              ChangeField.TR_SPEC,
+              ChangeField.UNRESOLVED_COMMENT_COUNT_SPEC,
+              ChangeField.UPDATED_SPEC,
+              ChangeField.UPLOADER_SPEC,
+              ChangeField.WIP_SPEC));
 
   /**
-   * The computation of the {@link ChangeField#EXTENSION} field is changed, hence reindexing is
-   * required.
-   */
-  @Deprecated static final Schema<ChangeData> V56 = schema(V55);
-
-  /**
-   * New numeric types: use dimensional points using the k-d tree geo-spatial data structure to
-   * offer fast single- and multi-dimensional numeric range. As the consequense, {@link
-   * ChangeField#LEGACY_ID} is replaced with {@link ChangeField#LEGACY_ID_STR}.
+   * Added new field {@link ChangeField#PREFIX_HASHTAG} and {@link ChangeField#PREFIX_TOPIC} to
+   * allow easier search for topics.
    */
   @Deprecated
-  static final Schema<ChangeData> V57 =
+  static final Schema<ChangeData> V75 =
       new Schema.Builder<ChangeData>()
-          .add(V56)
-          .remove(ChangeField.LEGACY_ID)
-          .add(ChangeField.LEGACY_ID_STR)
-          .legacyNumericFields(false)
+          .add(V74)
+          .addSearchSpecs(ChangeField.PREFIX_HASHTAG)
+          .addSearchSpecs(ChangeField.PREFIX_TOPIC)
           .build();
 
-  /**
-   * Added new fields {@link ChangeField#CHERRY_PICK_OF_CHANGE} and {@link
-   * ChangeField#CHERRY_PICK_OF_PATCHSET}.
-   */
+  /** Added new field {@link ChangeField#FOOTER_NAME}. */
   @Deprecated
-  static final Schema<ChangeData> V58 =
+  static final Schema<ChangeData> V76 =
       new Schema.Builder<ChangeData>()
-          .add(V57)
-          .add(ChangeField.CHERRY_PICK_OF_CHANGE)
-          .add(ChangeField.CHERRY_PICK_OF_PATCHSET)
+          .add(V75)
+          .addIndexedFields(ChangeField.FOOTER_NAME_FIELD)
+          .addSearchSpecs(ChangeField.FOOTER_NAME)
           .build();
 
-  /**
-   * Added new fields {@link ChangeField#ATTENTION_SET_USERS} and {@link
-   * ChangeField#ATTENTION_SET_FULL}.
-   */
+  /** Added new field {@link ChangeField#COMMIT_MESSAGE_EXACT}. */
   @Deprecated
-  static final Schema<ChangeData> V59 =
+  static final Schema<ChangeData> V77 =
       new Schema.Builder<ChangeData>()
-          .add(V58)
-          .add(ChangeField.ATTENTION_SET_USERS)
-          .add(ChangeField.ATTENTION_SET_FULL)
+          .add(V76)
+          .addIndexedFields(ChangeField.COMMIT_MESSAGE_EXACT_FIELD)
+          .addSearchSpecs(ChangeField.COMMIT_MESSAGE_EXACT)
           .build();
 
-  /** Added new fields {@link ChangeField#MERGE} */
+  // Upgrade Lucene to 7.x requires reindexing.
+  @Deprecated static final Schema<ChangeData> V78 = schema(V77);
+
+  /** Remove draft and star fields. */
   @Deprecated
-  static final Schema<ChangeData> V60 =
-      new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
+  static final Schema<ChangeData> V79 =
+      new Schema.Builder<ChangeData>()
+          .add(V78)
+          .remove(ChangeField.STAR_SPEC, ChangeField.STARBY_SPEC, ChangeField.DRAFTBY_SPEC)
+          .remove(ChangeField.STAR_FIELD, ChangeField.STARBY_FIELD, ChangeField.DRAFTBY_FIELD)
+          .build();
 
-  /** Added new field {@link ChangeField#MERGED_ON} */
+  /** Add subject field. */
   @Deprecated
-  static final Schema<ChangeData> V61 =
-      new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
+  static final Schema<ChangeData> V80 =
+      new Schema.Builder<ChangeData>()
+          .add(V79)
+          .addIndexedFields(ChangeField.SUBJECT_FIELD)
+          .addSearchSpecs(ChangeField.SUBJECT_SPEC)
+          .build();
 
-  /** Added new field {@link ChangeField#FUZZY_HASHTAG} */
-  @Deprecated
-  static final Schema<ChangeData> V62 =
-      new Schema.Builder<ChangeData>().add(V61).add(ChangeField.FUZZY_HASHTAG).build();
-
-  /**
-   * The computation of the {@link ChangeField#DIRECTORY} field is changed, hence reindexing is
-   * required.
-   */
-  @Deprecated static final Schema<ChangeData> V63 = schema(V62, false);
-
-  /** Added support for MIN/MAX/ANY for {@link ChangeField#LABEL} */
-  @Deprecated static final Schema<ChangeData> V64 = schema(V63, false);
-
-  /** Added new field for submit requirements. */
-  @Deprecated
-  static final Schema<ChangeData> V65 =
-      new Schema.Builder<ChangeData>().add(V64).add(ChangeField.STORED_SUBMIT_REQUIREMENTS).build();
-
-  /**
-   * The computation of {@link ChangeField#LABEL} has changed: We added the non_uploader arg to the
-   * label field.
-   */
-  @Deprecated static final Schema<ChangeData> V66 = schema(V65, false);
-
-  /** Updated submit records: store the rule name that created the submit record. */
-  @Deprecated static final Schema<ChangeData> V67 = schema(V66, false);
-
-  /** Added new field {@link ChangeField#SUBMIT_RULE_RESULT}. */
-  @Deprecated
-  static final Schema<ChangeData> V68 =
-      new Schema.Builder<ChangeData>().add(V67).add(ChangeField.SUBMIT_RULE_RESULT).build();
-
-  /** Added new field {@link ChangeField#CHERRY_PICK}. */
-  @Deprecated
-  static final Schema<ChangeData> V69 =
-      new Schema.Builder<ChangeData>().add(V68).add(ChangeField.CHERRY_PICK).build();
-
-  /** Added new field {@link ChangeField#ATTENTION_SET_USERS_COUNT}. */
-  @Deprecated
-  static final Schema<ChangeData> V70 =
-      new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
-
-  /** Added new field {@link ChangeField#UPLOADER}. */
-  @Deprecated
-  static final Schema<ChangeData> V71 =
-      new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).build();
-
-  /** Added new field {@link ChangeField#IS_PURE_REVERT}. */
-  @Deprecated
-  static final Schema<ChangeData> V72 =
-      new Schema.Builder<ChangeData>().add(V71).add(ChangeField.IS_PURE_REVERT).build();
-
-  /** Added new "count=$count" argument to the {@link ChangeField#LABEL} operator. */
-  static final Schema<ChangeData> V73 = schema(V72, false);
+  /** Add prefixsubject field. */
+  static final Schema<ChangeData> V81 =
+      new Schema.Builder<ChangeData>()
+          .add(V80)
+          .addSearchSpecs(ChangeField.PREFIX_SUBJECT_SPEC)
+          .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/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 57a2091..8f5e36e 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -15,8 +15,9 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
@@ -34,6 +35,7 @@
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gerrit.server.query.change.ChangeIndexPostFilterPredicate;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -51,24 +53,43 @@
 public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
     implements ChangeDataSource, Matchable<ChangeData> {
   public static QueryOptions oneResult() {
-    return createOptions(IndexConfig.createDefault(), 0, 1, ImmutableSet.of());
+    IndexConfig config = IndexConfig.createDefault();
+    return createOptions(config, 0, 1, config.pageSizeMultiplier(), 1, ImmutableSet.of());
   }
 
   public static QueryOptions createOptions(
       IndexConfig config, int start, int limit, Set<String> fields) {
-    // Always include project since it is needed to load the change from NoteDb.
-    if (!fields.contains(CHANGE.getName()) && !fields.contains(PROJECT.getName())) {
+    return createOptions(config, start, limit, config.pageSizeMultiplier(), limit, fields);
+  }
+
+  public static QueryOptions createOptions(
+      IndexConfig config,
+      int start,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      Set<String> fields) {
+    // Always include project and change id since both are needed to load the change from NoteDb.
+    if (!fields.contains(CHANGE_SPEC.getName())
+        && !(fields.contains(PROJECT_SPEC.getName())
+            && fields.contains(NUMERIC_ID_STR_SPEC.getName()))) {
       fields = new HashSet<>(fields);
-      fields.add(PROJECT.getName());
+      fields.add(PROJECT_SPEC.getName());
+      fields.add(NUMERIC_ID_STR_SPEC.getName());
     }
-    return QueryOptions.create(config, start, limit, fields);
+    return QueryOptions.create(config, start, pageSize, pageSizeMultiplier, limit, fields);
   }
 
   @VisibleForTesting
   static QueryOptions convertOptions(QueryOptions opts) {
     opts = opts.convertForBackend();
     return IndexedChangeQuery.createOptions(
-        opts.config(), opts.start(), opts.limit(), opts.fields());
+        opts.config(),
+        opts.start(),
+        opts.pageSize(),
+        opts.pageSizeMultiplier(),
+        opts.limit(),
+        opts.fields());
   }
 
   private final Map<ChangeData, DataSource<ChangeData>> fromSource;
@@ -84,7 +105,7 @@
     final DataSource<ChangeData> currSource = source;
     final ResultSet<ChangeData> rs = currSource.read();
 
-    return new ResultSet<ChangeData>() {
+    return new ResultSet<>() {
       @Override
       public Iterator<ChangeData> iterator() {
         return Iterables.transform(
@@ -109,16 +130,38 @@
       public void close() {
         rs.close();
       }
+
+      @Override
+      public Object searchAfter() {
+        return rs.searchAfter();
+      }
     };
   }
 
+  public boolean postIndexMatch(Predicate<ChangeData> pred, ChangeData cd) {
+    if (pred instanceof ChangeIndexPostFilterPredicate) {
+      checkState(
+          pred.isMatchable(),
+          "match invoked, but child predicate %s doesn't implement %s",
+          pred,
+          Matchable.class.getName());
+      return pred.asMatchable().match(cd);
+    }
+    for (int i = 0; i < pred.getChildCount(); i++) {
+      if (!postIndexMatch(pred.getChild(i), cd)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
   @Override
   public boolean match(ChangeData cd) {
-    if (source != null && fromSource.get(cd) == source) {
+    Predicate<ChangeData> pred = getChild(0);
+    if (source != null && fromSource.get(cd) == source && postIndexMatch(pred, cd)) {
       return true;
     }
 
-    Predicate<ChangeData> pred = getChild(0);
     checkState(
         pred.isMatchable(),
         "match invoked, but child predicate %s doesn't implement %s",
@@ -137,6 +180,6 @@
 
   @Override
   public boolean hasChange() {
-    return index.getSchema().hasField(ChangeField.CHANGE);
+    return index.getSchema().hasField(ChangeField.CHANGE_SPEC);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 49f6ff9..861a5fa 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -53,7 +53,7 @@
  *
  * <p>Will reindex accounts when the account's NoteDb ref changes.
  */
-public class ReindexAfterRefUpdate implements GitReferenceUpdatedListener {
+public class ReindexAfterRefUpdate implements GitBatchRefUpdateListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final OneOffRequestContext requestContext;
@@ -86,48 +86,56 @@
   }
 
   @Override
-  public void onGitReferenceUpdated(Event event) {
-    if (allUsersName.get().equals(event.getProjectName())
-        && !RefNames.REFS_CONFIG.equals(event.getRefName())) {
-      Account.Id accountId = Account.Id.fromRef(event.getRefName());
-      if (accountId != null && !event.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
-        indexer.get().index(accountId);
+  public void onGitBatchRefUpdate(GitBatchRefUpdateListener.Event event) {
+    if (allUsersName.get().equals(event.getProjectName())) {
+      for (UpdatedRef ref : event.getUpdatedRefs()) {
+        if (!RefNames.REFS_CONFIG.equals(ref.getRefName())) {
+          if (ref.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
+            break;
+          }
+          Account.Id accountId = Account.Id.fromRef(ref.getRefName());
+          if (accountId != null) {
+            indexer.get().index(accountId);
+          }
+        }
       }
       // The update is in All-Users and not on refs/meta/config. So it's not a change. Return early.
       return;
     }
 
-    if (!enabled
-        || event.getRefName().startsWith(RefNames.REFS_CHANGES)
-        || event.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
-        || event.getRefName().startsWith(RefNames.REFS_USERS)) {
-      return;
-    }
-    Futures.addCallback(
-        executor.submit(new GetChanges(event)),
-        new FutureCallback<List<Change>>() {
-          @Override
-          public void onSuccess(List<Change> changes) {
-            for (Change c : changes) {
-              @SuppressWarnings("unused")
-              Future<?> possiblyIgnoredError =
-                  indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
+    for (UpdatedRef ref : event.getUpdatedRefs()) {
+      if (!enabled
+          || ref.getRefName().startsWith(RefNames.REFS_CHANGES)
+          || ref.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
+          || ref.getRefName().startsWith(RefNames.REFS_USERS)) {
+        continue;
+      }
+      Futures.addCallback(
+          executor.submit(new GetChanges(event.getProjectName(), ref)),
+          new FutureCallback<List<Change>>() {
+            @Override
+            public void onSuccess(List<Change> changes) {
+              for (Change c : changes) {
+                @SuppressWarnings("unused")
+                Future<?> possiblyIgnoredError =
+                    indexerFactory.create(executor, indexes).indexAsync(c.getProject(), c.getId());
+              }
             }
-          }
 
-          @Override
-          public void onFailure(Throwable ignored) {
-            // Logged by {@link GetChanges#call()}.
-          }
-        },
-        directExecutor());
+            @Override
+            public void onFailure(Throwable ignored) {
+              // Logged by {@link GetChanges#call()}.
+            }
+          },
+          directExecutor());
+    }
   }
 
   private abstract class Task<V> implements Callable<V> {
-    protected Event event;
+    protected UpdatedRef updatedRef;
 
-    protected Task(Event event) {
-      this.event = event;
+    protected Task(UpdatedRef updatedRef) {
+      this.updatedRef = updatedRef;
     }
 
     @Override
@@ -135,7 +143,7 @@
       try (ManualRequestContext ctx = requestContext.open()) {
         return impl(ctx);
       } catch (Exception e) {
-        logger.atSevere().withCause(e).log("Failed to reindex changes after %s", event);
+        logger.atSevere().withCause(e).log("Failed to reindex changes after %s", updatedRef);
         throw e;
       }
     }
@@ -146,14 +154,17 @@
   }
 
   private class GetChanges extends Task<List<Change>> {
-    private GetChanges(Event event) {
-      super(event);
+    protected String projectName;
+
+    private GetChanges(String projectName, UpdatedRef updatedRef) {
+      super(updatedRef);
+      this.projectName = projectName;
     }
 
     @Override
     protected List<Change> impl(RequestContext ctx) {
-      String ref = event.getRefName();
-      Project.NameKey project = Project.nameKey(event.getProjectName());
+      String ref = updatedRef.getRefName();
+      Project.NameKey project = Project.nameKey(projectName);
       if (ref.equals(RefNames.REFS_CONFIG)) {
         return asChanges(queryProvider.get().byProjectOpen(project));
       }
@@ -163,9 +174,9 @@
     @Override
     public String toString() {
       return "Get changes to reindex caused by "
-          + event.getRefName()
+          + updatedRef.getRefName()
           + " update of project "
-          + event.getProjectName();
+          + projectName;
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index ad5cc2b..eb4af01 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -56,9 +56,9 @@
 
   public static final ImmutableSet<String> FIELDS =
       ImmutableSet.of(
-          ChangeField.CHANGE.getName(),
-          ChangeField.REF_STATE.getName(),
-          ChangeField.REF_STATE_PATTERN.getName());
+          ChangeField.CHANGE_SPEC.getName(),
+          ChangeField.REF_STATE_SPEC.getName(),
+          ChangeField.REF_STATE_PATTERN_SPEC.getName());
 
   private final ChangeIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -82,8 +82,8 @@
       return StalenessCheckResult
           .notStale(); // No index; caller couldn't do anything if it is stale.
     }
-    if (!i.getSchema().hasField(ChangeField.REF_STATE)
-        || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN)) {
+    if (!i.getSchema().hasField(ChangeField.REF_STATE_SPEC)
+        || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN_SPEC)) {
       return StalenessCheckResult.notStale(); // Index version not new enough for this check.
     }
 
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index b3ef679..3773d435 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsNoteDbConsistencyChecker;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -54,15 +55,18 @@
   private final ListeningExecutorService executor;
   private final GroupCache groupCache;
   private final Groups groups;
+  private final IsFirstInsertForEntry isFirstInsertForEntry;
 
   @Inject
   AllGroupsIndexer(
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       GroupCache groupCache,
-      Groups groups) {
+      Groups groups,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.groupCache = groupCache;
     this.groups = groups;
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
   @Override
@@ -96,9 +100,14 @@
           executor.submit(
               () -> {
                 try {
+                  groupCache.evict(uuid);
                   InternalGroup internalGroup = reindexedGroups.get(uuid);
                   if (internalGroup != null) {
-                    index.replace(internalGroup);
+                    if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
+                      index.insert(internalGroup);
+                    } else {
+                      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 df90c0d..7a26f31 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -15,75 +15,125 @@
 package com.google.gerrit.server.index.group;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
 
 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.IndexedField;
 import com.google.gerrit.index.SchemaUtil;
 import java.sql.Timestamp;
 import org.eclipse.jgit.lib.ObjectId;
 
-/** Secondary index schemas for groups. */
+/**
+ * Secondary index schemas for groups.
+ *
+ * <p>Note that this class does not override {@link Object#equals(Object)}. It relies on instances
+ * being singletons so that the default (i.e. reference) comparison works.
+ */
 public class GroupField {
   /** Legacy group ID. */
-  public static final FieldDef<InternalGroup, Integer> ID =
-      integer("id").build(g -> g.getId().get());
+  public static final IndexedField<InternalGroup, Integer> ID_FIELD =
+      IndexedField.<InternalGroup>integerBuilder("Id").required().build(g -> g.getId().get());
+
+  public static final IndexedField<InternalGroup, Integer>.SearchSpec ID_FIELD_SPEC =
+      ID_FIELD.integer("id");
 
   /** Group UUID. */
-  public static final FieldDef<InternalGroup, String> UUID =
-      exact("uuid").stored().build(g -> g.getGroupUUID().get());
+  public static final IndexedField<InternalGroup, String> UUID_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("UUID")
+          .required()
+          .stored()
+          .build(g -> g.getGroupUUID().get());
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec UUID_FIELD_SPEC =
+      UUID_FIELD.exact("uuid");
 
   /** Group owner UUID. */
-  public static final FieldDef<InternalGroup, String> OWNER_UUID =
-      exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
+  public static final IndexedField<InternalGroup, String> OWNER_UUID_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("OwnerUUID")
+          .required()
+          .build(g -> g.getOwnerGroupUUID().get());
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec OWNER_UUID_SPEC =
+      OWNER_UUID_FIELD.exact("owner_uuid");
 
   /** Timestamp indicating when this group was created. */
-  public static final FieldDef<InternalGroup, Timestamp> CREATED_ON =
-      timestamp("created_on").build(InternalGroup::getCreatedOn);
+  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
+  public static final IndexedField<InternalGroup, Timestamp> CREATED_ON_FIELD =
+      IndexedField.<InternalGroup>timestampBuilder("CreatedOn")
+          .required()
+          .build(internalGroup -> Timestamp.from(internalGroup.getCreatedOn()));
+
+  public static final IndexedField<InternalGroup, Timestamp>.SearchSpec CREATED_ON_SPEC =
+      CREATED_ON_FIELD.timestamp("created_on");
 
   /** Group name. */
-  public static final FieldDef<InternalGroup, String> NAME =
-      exact("name").build(InternalGroup::getName);
+  public static final IndexedField<InternalGroup, String> NAME_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("Name")
+          .required()
+          .size(200)
+          .build(InternalGroup::getName);
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec NAME_SPEC =
+      NAME_FIELD.exact("name");
 
   /** Prefix match on group name parts. */
-  public static final FieldDef<InternalGroup, Iterable<String>> NAME_PART =
-      prefix("name_part").buildRepeatable(g -> SchemaUtil.getNameParts(g.getName()));
+  public static final IndexedField<InternalGroup, Iterable<String>> NAME_PART_FIELD =
+      IndexedField.<InternalGroup>iterableStringBuilder("NamePart")
+          .required()
+          .size(200)
+          .build(g -> SchemaUtil.getNameParts(g.getName()));
+
+  public static final IndexedField<InternalGroup, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+      NAME_PART_FIELD.prefix("name_part");
 
   /** Group description. */
-  public static final FieldDef<InternalGroup, String> DESCRIPTION =
-      fullText("description").build(InternalGroup::getDescription);
+  public static final IndexedField<InternalGroup, String> DESCRIPTION_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("Description").build(InternalGroup::getDescription);
+
+  public static final IndexedField<InternalGroup, String>.SearchSpec DESCRIPTION_SPEC =
+      DESCRIPTION_FIELD.fullText("description");
 
   /** Whether the group is visible to all users. */
-  public static final FieldDef<InternalGroup, String> IS_VISIBLE_TO_ALL =
-      exact("is_visible_to_all").build(g -> g.isVisibleToAll() ? "1" : "0");
+  public static final IndexedField<InternalGroup, String> IS_VISIBLE_TO_ALL_FIELD =
+      IndexedField.<InternalGroup>stringBuilder("IsVisibleToAll")
+          .required()
+          .size(1)
+          .build(g -> g.isVisibleToAll() ? "1" : "0");
 
-  public static final FieldDef<InternalGroup, Iterable<Integer>> MEMBER =
-      integer("member")
-          .buildRepeatable(
-              g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+  public static final IndexedField<InternalGroup, String>.SearchSpec IS_VISIBLE_TO_ALL_SPEC =
+      IS_VISIBLE_TO_ALL_FIELD.exact("is_visible_to_all");
 
-  public static final FieldDef<InternalGroup, Iterable<String>> SUBGROUP =
-      exact("subgroup")
-          .buildRepeatable(
+  public static final IndexedField<InternalGroup, Iterable<Integer>> MEMBER_FIELD =
+      IndexedField.<InternalGroup>iterableIntegerBuilder("Member")
+          .build(g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+
+  public static final IndexedField<InternalGroup, Iterable<Integer>>.SearchSpec MEMBER_SPEC =
+      MEMBER_FIELD.integer("member");
+
+  public static final IndexedField<InternalGroup, Iterable<String>> SUBGROUP_FIELD =
+      IndexedField.<InternalGroup>iterableStringBuilder("Subgroup")
+          .build(
               g ->
                   g.getSubgroups().stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
 
+  public static final IndexedField<InternalGroup, Iterable<String>>.SearchSpec SUBGROUP_SPEC =
+      SUBGROUP_FIELD.exact("subgroup");
+
   /** ObjectId of HEAD:refs/groups/<UUID>. */
-  public static final FieldDef<InternalGroup, byte[]> REF_STATE =
-      storedOnly("ref_state")
+  public static final IndexedField<InternalGroup, byte[]> REF_STATE_FIELD =
+      IndexedField.<InternalGroup>byteArrayBuilder("RefState")
+          .stored()
+          .required()
           .build(
               g -> {
                 byte[] a = new byte[ObjectIds.STR_LEN];
                 MoreObjects.firstNonNull(g.getRefState(), ObjectId.zeroId()).copyTo(a, 0);
                 return a;
               });
+
+  public static final IndexedField<InternalGroup, byte[]>.SearchSpec REF_STATE_SPEC =
+      REF_STATE_FIELD.storedOnly("ref_state");
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupIndex.java b/java/com/google/gerrit/server/index/group/GroupIndex.java
index 28c0384..f6a9224 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndex.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.group.GroupPredicates;
+import java.util.function.Function;
 
 /**
  * Index for internal Gerrit groups. This class is mainly used for typing the generic parent class
@@ -33,4 +34,6 @@
   default Predicate<InternalGroup> keyPredicate(AccountGroup.UUID uuid) {
     return GroupPredicates.uuid(uuid);
   }
+
+  Function<InternalGroup, AccountGroup.UUID> ENTITY_TO_KEY = (g) -> g.getGroupUUID();
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index c4d8952..26f9e96 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -16,29 +16,48 @@
 
 import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.SchemaDefinitions;
 
-/** Definition of group index versions (schemata). See {@link SchemaDefinitions}. */
+/**
+ * Definition of group index versions (schemata). See {@link SchemaDefinitions}.
+ *
+ * <p>Upgrades are subject to constraints, see {@code
+ * com.google.gerrit.index.IndexUpgradeValidator}.
+ */
 public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
   @Deprecated
-  static final Schema<InternalGroup> V2 =
+  static final Schema<InternalGroup> V5 =
       schema(
-          GroupField.DESCRIPTION,
-          GroupField.ID,
-          GroupField.IS_VISIBLE_TO_ALL,
-          GroupField.NAME,
-          GroupField.NAME_PART,
-          GroupField.OWNER_UUID,
-          GroupField.UUID);
-
-  @Deprecated static final Schema<InternalGroup> V3 = schema(V2, GroupField.CREATED_ON);
-
-  @Deprecated
-  static final Schema<InternalGroup> V4 = schema(V3, GroupField.MEMBER, GroupField.SUBGROUP);
-
-  @Deprecated static final Schema<InternalGroup> V5 = schema(V4, GroupField.REF_STATE);
+          /* version= */ 5,
+          ImmutableList.of(),
+          ImmutableList.of(
+              GroupField.CREATED_ON_FIELD,
+              GroupField.DESCRIPTION_FIELD,
+              GroupField.ID_FIELD,
+              GroupField.IS_VISIBLE_TO_ALL_FIELD,
+              GroupField.MEMBER_FIELD,
+              GroupField.NAME_FIELD,
+              GroupField.NAME_PART_FIELD,
+              GroupField.OWNER_UUID_FIELD,
+              GroupField.REF_STATE_FIELD,
+              GroupField.SUBGROUP_FIELD,
+              GroupField.UUID_FIELD),
+          ImmutableList.<IndexedField<InternalGroup, ?>.SearchSpec>of(
+              GroupField.CREATED_ON_SPEC,
+              GroupField.DESCRIPTION_SPEC,
+              GroupField.ID_FIELD_SPEC,
+              GroupField.IS_VISIBLE_TO_ALL_SPEC,
+              GroupField.MEMBER_SPEC,
+              GroupField.NAME_SPEC,
+              GroupField.NAME_PART_SPEC,
+              GroupField.OWNER_UUID_SPEC,
+              GroupField.REF_STATE_SPEC,
+              GroupField.SUBGROUP_SPEC,
+              GroupField.UUID_FIELD_SPEC));
 
   // Bump Lucene version requires reindexing
   @Deprecated static final Schema<InternalGroup> V6 = schema(V5);
@@ -48,7 +67,10 @@
 
   // New numeric types: use dimensional points using the k-d tree geo-spatial data structure
   // to offer fast single- and multi-dimensional numeric range.
-  static final Schema<InternalGroup> V8 = schema(V7, false);
+  @Deprecated static final Schema<InternalGroup> V8 = schema(V7);
+
+  // Upgrade Lucene to 7.x requires reindexing.
+  static final Schema<InternalGroup> V9 = schema(V8);
 
   /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index 90070b6..7013e27 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -34,13 +34,13 @@
     implements DataSource<InternalGroup> {
 
   public static QueryOptions createOptions(
-      IndexConfig config, int start, int limit, Set<String> fields) {
+      IndexConfig config, int start, int pageSize, int limit, Set<String> fields) {
     // Always include GroupField.UUID since it is needed to load the group from NoteDb.
-    if (!fields.contains(GroupField.UUID.getName())) {
+    if (!fields.contains(GroupField.UUID_FIELD_SPEC.getName())) {
       fields = new HashSet<>(fields);
-      fields.add(GroupField.UUID.getName());
+      fields.add(GroupField.UUID_FIELD_SPEC.getName());
     }
-    return QueryOptions.create(config, start, limit, fields);
+    return QueryOptions.create(config, start, pageSize, limit, fields);
   }
 
   public IndexedGroupQuery(
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
index 4ce3f5b..72370bb 100644
--- a/java/com/google/gerrit/server/index/group/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -39,7 +39,7 @@
 @Singleton
 public class StalenessChecker {
   public static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(GroupField.UUID.getName(), GroupField.REF_STATE.getName());
+      ImmutableSet.of(GroupField.UUID_FIELD_SPEC.getName(), GroupField.REF_STATE_SPEC.getName());
 
   private final GroupIndexCollection indexes;
   private final GitRepositoryManager repoManager;
@@ -66,7 +66,7 @@
     }
 
     Optional<FieldBundle> result =
-        i.getRaw(uuid, IndexedGroupQuery.createOptions(indexConfig, 0, 1, FIELDS));
+        i.getRaw(uuid, IndexedGroupQuery.createOptions(indexConfig, 0, 1, 1, FIELDS));
     if (!result.isPresent()) {
       // The document is missing in the index.
       try (Repository repo = repoManager.openRepository(allUsers)) {
@@ -84,7 +84,8 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
       ObjectId head = ref == null ? ObjectId.zeroId() : ref.getObjectId();
-      ObjectId idFromIndex = ObjectId.fromString(result.get().getValue(GroupField.REF_STATE), 0);
+      ObjectId idFromIndex =
+          ObjectId.fromString(result.get().getValue(GroupField.REF_STATE_SPEC), 0);
       if (head.equals(idFromIndex)) {
         return StalenessCheckResult.notStale();
       }
diff --git a/java/com/google/gerrit/server/index/options/AutoFlush.java b/java/com/google/gerrit/server/index/options/AutoFlush.java
new file mode 100644
index 0000000..7b82edb
--- /dev/null
+++ b/java/com/google/gerrit/server/index/options/AutoFlush.java
@@ -0,0 +1,20 @@
+// 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.index.options;
+
+public enum AutoFlush {
+  ENABLED,
+  DISABLED
+}
diff --git a/java/com/google/gerrit/server/index/options/IsFirstInsertForEntry.java b/java/com/google/gerrit/server/index/options/IsFirstInsertForEntry.java
new file mode 100644
index 0000000..f943309
--- /dev/null
+++ b/java/com/google/gerrit/server/index/options/IsFirstInsertForEntry.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.server.index.options;
+
+/**
+ * This enum should be injected and checked to decide on which operation ({@link
+ * com.google.gerrit.index.Index#replace(Object) replace()} or {@link
+ * com.google.gerrit.index.Index#insert(Object) insert()}) should be performed on a specific {@link
+ * com.google.gerrit.index.Index index}.
+ */
+public enum IsFirstInsertForEntry {
+  YES,
+  NO
+}
diff --git a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
index 0e4b688..1c977d1 100644
--- a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
+++ b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -48,12 +49,16 @@
 
   private final ListeningExecutorService executor;
   private final ProjectCache projectCache;
+  private final IsFirstInsertForEntry isFirstInsertForEntry;
 
   @Inject
   AllProjectsIndexer(
-      @IndexExecutor(BATCH) ListeningExecutorService executor, ProjectCache projectCache) {
+      @IndexExecutor(BATCH) ListeningExecutorService executor,
+      ProjectCache projectCache,
+      IsFirstInsertForEntry isFirstInsertForEntry) {
     this.executor = executor;
     this.projectCache = projectCache;
+    this.isFirstInsertForEntry = isFirstInsertForEntry;
   }
 
   @Override
@@ -79,8 +84,13 @@
               () -> {
                 try {
                   projectCache.evict(name);
-                  index.replace(
-                      projectCache.get(name).orElseThrow(illegalState(name)).toProjectData());
+                  ProjectData projectData =
+                      projectCache.get(name).orElseThrow(illegalState(name)).toProjectData();
+                  if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
+                    index.insert(projectData);
+                  } else {
+                    index.replace(projectData);
+                  }
                   verboseWriter.println("Reindexed " + desc);
                   done.incrementAndGet();
                 } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
index 9c44c00..9f6bb31 100644
--- a/java/com/google/gerrit/server/index/project/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -40,7 +40,7 @@
  */
 public class StalenessChecker {
   private static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+      ImmutableSet.of(ProjectField.NAME_SPEC.getName(), ProjectField.REF_STATE.getName());
 
   private final ProjectCache projectCache;
   private final ProjectIndexCollection indexes;
diff --git a/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD
index fd0c4f1..6b9ecdf 100644
--- a/java/com/google/gerrit/server/ioutil/BUILD
+++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//lib:automaton",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/ioutil/BasicSerialization.java b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
index 296cf22..b43655a 100644
--- a/java/com/google/gerrit/server/ioutil/BasicSerialization.java
+++ b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
@@ -31,6 +31,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.CodedEnum;
 import java.io.EOFException;
 import java.io.IOException;
@@ -129,6 +130,7 @@
   }
 
   /** Read a UTF-8 string, prefixed by its byte length in a varint. */
+  @Nullable
   public static String readString(InputStream input) throws IOException {
     final byte[] bin = readBytes(input);
     if (bin.length == 0) {
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index ee0168c..7204c07 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -18,6 +18,5 @@
         "//lib/auto:auto-value-annotations",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 3907da5..eac96a6 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -90,8 +90,9 @@
     return tags.get() == null
         && forceLogging.get() == null
         && performanceLogging.get() == null
+        && (performanceLogRecords.get() == null || performanceLogRecords.get().isEmtpy())
         && aclLogging.get() == null
-        && aclLogRecords.get() == null;
+        && (aclLogRecords.get() == null || aclLogRecords.get().isEmpty());
   }
 
   public void clear() {
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 89b5b46..b433e9f 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -102,6 +102,9 @@
   /** The name of a group. */
   public abstract Optional<String> groupName();
 
+  /** The group system being queried. */
+  public abstract Optional<String> groupSystem();
+
   /** The UUID of a group. */
   public abstract Optional<String> groupUuid();
 
@@ -165,6 +168,8 @@
   /** The name of a REST view. */
   public abstract Optional<String> restViewName();
 
+  public abstract Optional<String> submitRequirementName();
+
   /** The SHA1 of Git commit. */
   public abstract Optional<String> revision();
 
@@ -326,6 +331,8 @@
 
     public abstract Builder groupName(@Nullable String groupName);
 
+    public abstract Builder groupSystem(@Nullable String groupSystem);
+
     public abstract Builder groupUuid(@Nullable String groupUuid);
 
     public abstract Builder httpStatus(int httpStatus);
@@ -375,6 +382,8 @@
 
     public abstract Builder revision(@Nullable String revision);
 
+    public abstract Builder submitRequirementName(@Nullable String srName);
+
     public abstract Builder username(@Nullable String username);
 
     public abstract Metadata build();
diff --git a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
index baa9b1f..a692d2b 100644
--- a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
+++ b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
@@ -45,6 +45,10 @@
     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/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
index 65e033b15..90e716f 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogContext.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -56,7 +56,7 @@
     // Do not create performance log entries if performance logging is disabled or if no
     // PerformanceLogger is registered.
     boolean enablePerformanceLogging =
-        gerritConfig.getBoolean("tracing", "performanceLogging", true);
+        gerritConfig.getBoolean("tracing", "performanceLogging", false);
     LoggingContext.getInstance()
         .performanceLogging(
             enablePerformanceLogging && !Iterables.isEmpty(performanceLoggers.entries()));
diff --git a/java/com/google/gerrit/server/logging/RequestId.java b/java/com/google/gerrit/server/logging/RequestId.java
index 543f0a2..3ae9598 100644
--- a/java/com/google/gerrit/server/logging/RequestId.java
+++ b/java/com/google/gerrit/server/logging/RequestId.java
@@ -61,7 +61,7 @@
     h.putLong(Thread.currentThread().getId()).putUnencodedChars(MACHINE_ID);
     str =
         (resourceId != null ? resourceId + "-" : "")
-            + TimeUtil.nowTs().getTime()
+            + TimeUtil.now().toEpochMilli()
             + "-"
             + h.hash().toString().substring(0, 8);
   }
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 0710784..2b8a501 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -85,7 +85,13 @@
 import java.util.Optional;
 import java.util.Set;
 
-/** A service that can attach the comments from a {@link MailMessage} to a change. */
+/**
+ * Users can post comments on gerrit changes by replying directly to gerrit emails. This service
+ * parses the {@link MailMessage} sent by users and attaches the comments to a change.
+ *
+ * <p>This functionality can be configured or disabled by host. See {@link
+ * com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule}
+ */
 @Singleton
 public class MailProcessor {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -301,7 +307,9 @@
               .collect(ImmutableList.toImmutableList());
       CommentValidationContext commentValidationCtx =
           CommentValidationContext.create(
-              cd.change().getChangeId(), cd.change().getProject().get());
+              cd.change().getChangeId(),
+              cd.change().getProject().get(),
+              cd.change().getDest().branch());
       ImmutableList<CommentValidationFailure> commentValidationFailures =
           PublishCommentUtil.findInvalidComments(
               commentValidationCtx, commentValidators, parsedCommentsForValidation);
@@ -311,7 +319,7 @@
       }
 
       Op o = new Op(PatchSet.id(cd.getId(), metadata.patchSet), parsedComments, message.id());
-      BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.nowTs());
+      BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.now());
       batchUpdate.addOp(cd.getId(), o);
       batchUpdate.execute();
     }
@@ -364,22 +372,18 @@
       // Send email notifications
       outgoingMailFactory
           .create(
-              ctx.getNotify(notes.getChangeId()),
-              notes,
+              ctx,
               patchSet,
-              ctx.getUser().asIdentifiedUser(),
+              notes.getMetaId(),
               mailMessage,
-              ctx.getWhen(),
               comments,
               patchSetComment,
-              ImmutableList.of(),
-              ctx.getRepoView())
+              ImmutableList.of())
           .sendAsync();
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
       approvalsUtil
-          .byPatchSetUser(
-              notes, psId, ctx.getAccountId(), ctx.getRevWalk(), ctx.getRepoView().getConfig())
+          .byPatchSetUser(notes, psId, ctx.getAccountId())
           .forEach(a -> approvals.put(a.label(), a.value()));
       // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
       // are always the same here.
diff --git a/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index 23e1cc3..a308168 100644
--- a/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -131,27 +131,20 @@
       if (async) {
         @SuppressWarnings("unused")
         Future<?> possiblyIgnoredError =
-            workQueue
-                .getDefaultQueue()
-                .submit(
-                    () -> {
-                      try {
-                        mailProcessor.process(m);
-                        requestDeletion(m.id());
-                      } catch (RestApiException | UpdateException e) {
-                        logger.atSevere().withCause(e).log(
-                            "Mail: Can't process message %s . Won't delete.", m.id());
-                      }
-                    });
+            workQueue.getDefaultQueue().submit(() -> processMessage(m));
       } else {
         // Synchronous processing is used only in tests.
-        try {
-          mailProcessor.process(m);
-          requestDeletion(m.id());
-        } catch (RestApiException | UpdateException e) {
-          logger.atSevere().withCause(e).log("Mail: Can't process messages. Won't delete.");
-        }
+        processMessage(m);
       }
     }
   }
+
+  private void processMessage(MailMessage m) {
+    try {
+      mailProcessor.process(m);
+      requestDeletion(m.id());
+    } catch (RestApiException | UpdateException e) {
+      logger.atSevere().withCause(e).log("Mail: Can't process message %s . Won't delete.", m.id());
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
index 3ac610d..d8b20ba 100644
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ABANDONED_CHANGES);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 652766a..6fe3cbe 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.IdentifiedUser;
@@ -111,10 +112,12 @@
     return "Unknown";
   }
 
+  @Nullable
   private String getSshKey() {
     return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
   }
 
+  @Nullable
   private String getGpgKeys() {
     if (gpgKeys != null) {
       return Joiner.on("\n").join(gpgKeys);
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
index b13bcf6..f9ef199 100644
--- a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
@@ -30,7 +30,7 @@
   @Inject
   public AddToAttentionSetSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, project, changeId);
+    super(args, "addToAttentionSet", project, changeId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
index 8f898a8..d1ee4ee 100644
--- a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
@@ -11,6 +11,7 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.entities.Account;
@@ -23,8 +24,9 @@
   private Account.Id attentionSetUser;
   private String reason;
 
-  public AttentionSetSender(EmailArguments args, Project.NameKey project, Change.Id changeId) {
-    super(args, "addToAttentionSet", ChangeEmail.newChangeData(args, project, changeId));
+  public AttentionSetSender(
+      EmailArguments args, String messageClass, Project.NameKey project, Change.Id changeId) {
+    super(args, messageClass, ChangeEmail.newChangeData(args, project, changeId));
   }
 
   @Override
@@ -34,7 +36,6 @@
     ccAllApprovals();
     bccStarredBy();
     ccExistingReviewers();
-    removeUsersThatIgnoredTheChange();
   }
 
   public void setAttentionSetUser(Account.Id attentionSetUser) {
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 1a2e150..8be5548 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
 import com.google.common.base.Splitter;
@@ -24,7 +24,9 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeSizeBucket;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
@@ -42,6 +44,7 @@
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -51,17 +54,20 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
+import org.apache.http.client.utils.URIBuilder;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
@@ -72,6 +78,7 @@
 
 /** Sends an email to one or more interested parties. */
 public abstract class ChangeEmail extends NotificationEmail {
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   protected static ChangeData newChangeData(
@@ -79,6 +86,11 @@
     return ea.changeDataFactory.create(project, id);
   }
 
+  protected static ChangeData newChangeData(
+      EmailArguments ea, Project.NameKey project, Change.Id id, ObjectId metaId) {
+    return ea.changeDataFactory.create(ea.changeNotesFactory.createChecked(project, id, metaId));
+  }
+
   private final Set<Account.Id> currentAttentionSet;
   protected final Change change;
   protected final ChangeData changeData;
@@ -86,7 +98,7 @@
   protected PatchSet patchSet;
   protected PatchSetInfo patchSetInfo;
   protected String changeMessage;
-  protected Timestamp timestamp;
+  protected Instant timestamp;
 
   protected ProjectState projectState;
   protected Set<Account.Id> authors;
@@ -96,17 +108,17 @@
   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();
+    change = changeData.change();
+    emailOnlyAuthors = false;
+    emailOnlyAttentionSetIfEnabled = true;
+    currentAttentionSet = getAttentionSet();
   }
 
   @Override
   public void setFrom(Account.Id id) {
     super.setFrom(id);
 
-    /** Is the from user in an email squelching group? */
+    // Is the from user in an email squelching group?
     try {
       args.permissionBackend.absentUser(id).check(GlobalPermission.EMAIL_REVIEWERS);
     } catch (AuthException | PermissionBackendException e) {
@@ -123,7 +135,7 @@
     patchSetInfo = psi;
   }
 
-  public void setChangeMessage(String cm, Timestamp t) {
+  public void setChangeMessage(String cm, Instant t) {
     changeMessage = cm;
     timestamp = t;
   }
@@ -190,7 +202,7 @@
 
     super.init();
     if (timestamp != null) {
-      setHeader(FieldName.DATE, new Date(timestamp.getTime()));
+      setHeader(FieldName.DATE, timestamp);
     }
     setChangeSubjectHeader();
     setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
@@ -229,22 +241,43 @@
     setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
   }
 
-  /** Get a link to the change; null if the server doesn't know its own address. */
+  private int getInsertionsCount() {
+    return listModifiedFiles().values().stream()
+        .map(FileDiffOutput::insertions)
+        .reduce(0, Integer::sum);
+  }
+
+  private int getDeletionsCount() {
+    return listModifiedFiles().values().stream()
+        .map(FileDiffOutput::deletions)
+        .reduce(0, Integer::sum);
+  }
+
+  /**
+   * Get a link to the change; null if the server doesn't know its own address or if the address is
+   * malformed. The link will contain a usp parameter set to "email" to inform the frontend on
+   * clickthroughs where the link came from.
+   */
   @Nullable
   public String getChangeUrl() {
-    return args.urlFormatter
-        .get()
-        .getChangeViewUrl(change.getProject(), change.getId())
-        .orElse(null);
+    Optional<String> changeUrl =
+        args.urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId());
+    if (!changeUrl.isPresent()) return null;
+    try {
+      URI uri = new URIBuilder(changeUrl.get()).addParameter("usp", "email").build();
+      return uri.toString();
+    } catch (URISyntaxException e) {
+      return null;
+    }
   }
 
   public String getChangeMessageThreadId() {
     return "<gerrit."
-        + change.getCreatedOn().getTime()
+        + change.getCreatedOn().toEpochMilli()
         + "."
         + change.getKey().get()
         + "@"
-        + this.getGerritHost()
+        + getGerritHost()
         + ">";
   }
 
@@ -269,7 +302,8 @@
 
       if (patchSet != null) {
         detail.append("---\n");
-        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles();
+        // Sort files by name.
+        TreeMap<String, FileDiffOutput> modifiedFiles = new TreeMap<>(listModifiedFiles());
         for (FileDiffOutput fileDiff : modifiedFiles.values()) {
           if (fileDiff.newPath().isPresent() && Patch.isMagic(fileDiff.newPath().get())) {
             continue;
@@ -282,10 +316,6 @@
                       fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()))
               .append("\n");
         }
-        Integer insertions =
-            modifiedFiles.values().stream().map(FileDiffOutput::insertions).reduce(0, Integer::sum);
-        Integer deletions =
-            modifiedFiles.values().stream().map(FileDiffOutput::deletions).reduce(0, Integer::sum);
         detail.append(
             MessageFormat.format(
                 "" //
@@ -294,8 +324,8 @@
                     + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
                     + "\n",
                 modifiedFiles.size() - 1, //
-                insertions, //
-                deletions));
+                getInsertionsCount(), //
+                getDeletionsCount()));
         detail.append("\n");
       }
       return detail.toString();
@@ -306,29 +336,35 @@
   }
 
   /** Get the patch list corresponding to patch set patchSetId of this change. */
-  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId)
-      throws DiffNotAvailableException {
-    PatchSet ps;
-    if (patchSetId == patchSet.number()) {
-      ps = patchSet;
-    } else {
-      try {
+  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId) {
+    try {
+      PatchSet ps;
+      if (patchSetId == patchSet.number()) {
+        ps = patchSet;
+      } else {
         ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
-      } catch (StorageException e) {
-        throw new DiffNotAvailableException("Failed to get patchSet", e);
       }
+      return args.diffOperations.listModifiedFilesAgainstParent(
+          change.getProject(), ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+    } catch (StorageException | DiffNotAvailableException e) {
+      logger.atSevere().withCause(e).log("Failed to get modified files");
+      return new HashMap<>();
     }
-    return args.diffOperations.listModifiedFilesAgainstParent(
-        change.getProject(), ps.commitId(), /* parentNum= */ 0);
   }
 
   /** Get the patch list corresponding to this patch set. */
-  protected Map<String, FileDiffOutput> listModifiedFiles() throws DiffNotAvailableException {
+  protected Map<String, FileDiffOutput> listModifiedFiles() {
     if (patchSet != null) {
-      return args.diffOperations.listModifiedFilesAgainstParent(
-          change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
+      try {
+        return args.diffOperations.listModifiedFilesAgainstParent(
+            change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
+      } catch (DiffNotAvailableException e) {
+        logger.atSevere().withCause(e).log("Failed to get modified files");
+      }
+    } else {
+      logger.atSevere().log("no patchSet specified");
     }
-    throw new DiffNotAvailableException("no patchSet specified");
+    return new HashMap<>();
   }
 
   /** Get the project entity the change is in; null if its been deleted. */
@@ -356,14 +392,6 @@
     }
   }
 
-  protected void removeUsersThatIgnoredTheChange() {
-    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
-      if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
-        args.accountCache.get(e.getKey()).ifPresent(a -> removeUser(a.account()));
-      }
-    }
-  }
-
   @Override
   protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
     if (!NotifyHandling.ALL.equals(notify.handling())) {
@@ -408,15 +436,25 @@
 
   @Override
   protected void add(RecipientType rt, Account.Id to) {
-    Optional<AccountState> accountState = args.accountCache.get(to);
-    if (!accountState.isPresent()) {
-      return;
-    }
-    if (emailOnlyAttentionSetIfEnabled
-        && accountState.get().generalPreferences().getEmailStrategy()
-            == EmailStrategy.ATTENTION_SET_ONLY
-        && !currentAttentionSet.contains(to)) {
-      return;
+    addRecipient(rt, to, /* isWatcher= */ false);
+  }
+
+  /** This bypasses the EmailStrategy.ATTENTION_SET_ONLY strategy when adding the recipient. */
+  @Override
+  protected void addWatcher(RecipientType rt, Account.Id to) {
+    addRecipient(rt, to, /* isWatcher= */ true);
+  }
+
+  private void addRecipient(RecipientType rt, Account.Id to, boolean isWatcher) {
+    if (!isWatcher) {
+      Optional<AccountState> accountState = args.accountCache.get(to);
+      if (emailOnlyAttentionSetIfEnabled
+          && accountState.isPresent()
+          && accountState.get().generalPreferences().getEmailStrategy()
+              == EmailStrategy.ATTENTION_SET_ONLY
+          && !currentAttentionSet.contains(to)) {
+        return;
+      }
     }
     if (emailOnlyAuthors && !authors.contains(to)) {
       return;
@@ -429,12 +467,7 @@
     if (!projectState.statePermitsRead()) {
       return false;
     }
-    try {
-      args.permissionBackend.absentUser(to).change(changeData).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
   }
 
   /** Find all users who are authors of any part of this change. */
@@ -494,6 +527,9 @@
     changeData.put("ownerName", getNameFor(change.getOwner()));
     changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
     changeData.put("changeNumber", Integer.toString(change.getChangeId()));
+    changeData.put(
+        "sizeBucket",
+        ChangeSizeBucket.getChangeSizeBucket(getInsertionsCount() + getDeletionsCount()));
     soyContext.put("change", changeData);
 
     Map<String, Object> patchSetData = new HashMap<>();
@@ -507,7 +543,7 @@
     soyContext.put("patchSetInfo", patchSetInfoData);
 
     footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
-    footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + Integer.toString(change.getChangeId()));
+    footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
     footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
     footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
     if (change.getAssignee() != null) {
@@ -527,7 +563,7 @@
       // We need names rather than account ids / emails to make it user readable.
       soyContext.put(
           "attentionSet",
-          currentAttentionSet.stream().map(this::getNameFor).collect(toImmutableSet()));
+          currentAttentionSet.stream().map(this::getNameFor).sorted().collect(toImmutableList()));
     }
   }
 
@@ -559,7 +595,7 @@
     try {
       attentionSet =
           additionsOnly(changeData.attentionSet()).stream()
-              .map(a -> a.account())
+              .map(AttentionSetUpdate::account)
               .collect(Collectors.toSet());
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log("Cannot get change attention set");
@@ -576,16 +612,11 @@
   /** Show patch set as unified difference. */
   public String getUnifiedDiff() {
     Map<String, FileDiffOutput> modifiedFiles;
-    try {
-      modifiedFiles = listModifiedFiles();
-      if (modifiedFiles.isEmpty()) {
-        // Octopus merges are not well supported for diff output by Gerrit.
-        // Currently these always have a null oldId in the PatchList.
-        return "[Octopus merge; cannot be formatted as a diff.]\n";
-      }
-    } catch (DiffNotAvailableException e) {
-      logger.atSevere().withCause(e).log("Cannot format patch");
-      return "";
+    modifiedFiles = listModifiedFiles();
+    if (modifiedFiles.isEmpty()) {
+      // Octopus merges are not well supported for diff output by Gerrit.
+      // Currently these always have a null oldId in the PatchList.
+      return "[Empty change (potentially Octopus merge); cannot be formatted as a diff.]\n";
     }
 
     int maxSize = args.settings.maximumDiffSize;
@@ -595,6 +626,11 @@
         try {
           ObjectId oldId = modifiedFiles.values().iterator().next().oldCommitId();
           ObjectId newId = modifiedFiles.values().iterator().next().newCommitId();
+          if (oldId.equals(ObjectId.zeroId())) {
+            // DiffOperations returns ObjectId.zeroId if newCommit is a root commit, i.e. has no
+            // parents.
+            oldId = null;
+          }
           fmt.setRepository(git);
           fmt.setDetectRenames(true);
           fmt.format(oldId, newId);
@@ -621,7 +657,8 @@
    * @param sourceDiff the unified diff that we're converting to the map.
    * @return map of 'type' to a line's content.
    */
-  protected ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(String sourceDiff) {
+  protected static ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(
+      String sourceDiff) {
     ImmutableList.Builder<ImmutableMap<String, String>> result = ImmutableList.builder();
     Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
     for (String diffLine : lineSplitter.split(sourceDiff)) {
diff --git a/java/com/google/gerrit/server/mail/send/CommentFormatter.java b/java/com/google/gerrit/server/mail/send/CommentFormatter.java
index 2590505..71f4a90 100644
--- a/java/com/google/gerrit/server/mail/send/CommentFormatter.java
+++ b/java/com/google/gerrit/server/mail/send/CommentFormatter.java
@@ -17,9 +17,9 @@
 import static com.google.common.base.Strings.isNullOrEmpty;
 
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 
 public class CommentFormatter {
@@ -47,12 +47,12 @@
    * @param source The raw, unescaped comment in the Gerrit wiki-like format.
    * @return List of block objects, each with unescaped comment content.
    */
-  public static List<Block> parse(@Nullable String source) {
+  public static ImmutableList<Block> parse(@Nullable String source) {
     if (isNullOrEmpty(source)) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
-    List<Block> result = new ArrayList<>();
+    ImmutableList.Builder<Block> result = ImmutableList.builder();
     for (String p : Splitter.on("\n\n").split(source)) {
       if (isQuote(p)) {
         result.add(makeQuote(p));
@@ -64,7 +64,7 @@
         result.add(makeParagraph(p));
       }
     }
-    return result;
+    return result.build();
   }
 
   /**
@@ -91,7 +91,7 @@
    * @param p The block containing the list (as well as potential paragraphs).
    * @param out The list of blocks to append to.
    */
-  private static void makeList(String p, List<Block> out) {
+  private static void makeList(String p, ImmutableList.Builder<Block> out) {
     Block block = null;
     StringBuilder textBuilder = null;
     boolean inList = false;
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 5a7352a..79696fe 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -14,11 +14,16 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.FilenameComparator;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -28,6 +33,8 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.NoSuchEntityException;
 import com.google.gerrit.exceptions.StorageException;
@@ -37,7 +44,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.LabelVote;
@@ -56,33 +62,44 @@
 import java.util.Optional;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
 /** Send comments, after the author of them hit used Publish Comments in the UI. */
 public class CommentSender extends ReplyToChangeSender {
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    CommentSender create(Project.NameKey project, Change.Id changeId);
+
+    CommentSender create(
+        Project.NameKey project,
+        Change.Id changeId,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
   }
 
   private class FileCommentGroup {
+
     public String filename;
     public int patchSetId;
     public PatchFile fileData;
     public List<Comment> comments = new ArrayList<>();
 
     /** Returns a web link to a comment for a change. */
+    @Nullable
     public String getCommentLink(String uuid) {
       return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
     }
 
     /** Returns a web link to the comment tab view of a change. */
+    @Nullable
     public String getCommentsTabLink() {
       return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
     }
 
     /** Returns a web link to the findings tab view of a change. */
+    @Nullable
     public String getFindingsTabLink() {
       return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
     }
@@ -104,11 +121,14 @@
   }
 
   private List<? extends Comment> inlineComments = Collections.emptyList();
-  private String patchSetComment;
-  private List<LabelVote> labels = Collections.emptyList();
+  @Nullable private String patchSetComment;
+  private ImmutableList<LabelVote> labels = ImmutableList.of();
   private final CommentsUtil commentsUtil;
   private final boolean incomingEmailEnabled;
   private final String replyToAddress;
+  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+      preUpdateSubmitRequirementResultsSupplier;
+  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
   @Inject
   public CommentSender(
@@ -116,24 +136,35 @@
       CommentsUtil commentsUtil,
       @GerritServerConfig Config cfg,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId) {
+      @Assisted Change.Id changeId,
+      @Assisted ObjectId preUpdateMetaId,
+      @Assisted
+          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     super(args, "comment", newChangeData(args, project, changeId));
     this.commentsUtil = commentsUtil;
     this.incomingEmailEnabled =
         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
             > Protocol.NONE.ordinal();
     this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
+    this.preUpdateSubmitRequirementResultsSupplier =
+        Suppliers.memoize(
+            () ->
+                // Triggers an (expensive) evaluation of the submit requirements. This is OK since
+                // all callers sent this email asynchronously, see EmailReviewComments.
+                newChangeData(args, project, changeId, preUpdateMetaId)
+                    .submitRequirementsIncludingLegacy());
+    this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
 
   public void setComments(List<? extends Comment> comments) {
     inlineComments = comments;
   }
 
-  public void setPatchSetComment(String comment) {
+  public void setPatchSetComment(@Nullable String comment) {
     this.patchSetComment = comment;
   }
 
-  public void setLabels(List<LabelVote> labels) {
+  public void setLabels(ImmutableList<LabelVote> labels) {
     this.labels = labels;
   }
 
@@ -148,7 +179,6 @@
       bccStarredBy();
       includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
     }
-    removeUsersThatIgnoredTheChange();
 
     // Add header that enables identifying comments on parsed email.
     // Grouping is currently done by timestamp.
@@ -199,12 +229,7 @@
         currentGroup.filename = c.key.filename;
         currentGroup.patchSetId = c.key.patchSetId;
         // Get the modified files:
-        Map<String, FileDiffOutput> modifiedFiles = null;
-        try {
-          modifiedFiles = listModifiedFiles(c.key.patchSetId);
-        } catch (DiffNotAvailableException e) {
-          logger.atSevere().withCause(e).log("Failed to get modified files");
-        }
+        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles(c.key.patchSetId);
 
         groups.add(currentGroup);
         if (modifiedFiles != null && !modifiedFiles.isEmpty()) {
@@ -483,6 +508,7 @@
     return false;
   }
 
+  @Nullable
   private Repository getRepository() {
     try {
       return args.server.openRepository(projectState.getNameKey());
@@ -509,6 +535,15 @@
     soyContext.put(
         "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
 
+    if (isChangeNoLongerSubmittable()) {
+      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      soyContext.put(
+          "oldSubmitRequirements",
+          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
+      soyContext.put(
+          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+    }
+
     footers.add(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
     footers.add(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
     footers.add(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
@@ -518,6 +553,59 @@
     }
   }
 
+  /**
+   * Checks whether the change is no longer submittable.
+   *
+   * @return {@code true} if the change has been submittable before the update and is no longer
+   *     submittable after the update has been applied, otherwise {@code false}
+   */
+  private boolean isChangeNoLongerSubmittable() {
+    boolean isSubmittablePreUpdate =
+        preUpdateSubmitRequirementResultsSupplier.get().values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s before the update is %s",
+        change.getId(), isSubmittablePreUpdate);
+    if (!isSubmittablePreUpdate) {
+      return false;
+    }
+
+    boolean isSubmittablePostUpdate =
+        postUpdateSubmitRequirementResults.values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s after the update is %s",
+        change.getId(), isSubmittablePostUpdate);
+    return !isSubmittablePostUpdate;
+  }
+
+  private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
+    return postUpdateSubmitRequirementResults.entrySet().stream()
+        .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
+        .map(Map.Entry::getKey)
+        .map(SubmitRequirement::name)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<String> formatSubmitRequirments(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
+    return submitRequirementResults.entrySet().stream()
+        .map(
+            e -> {
+              if (e.getValue().errorMessage().isPresent()) {
+                return String.format(
+                    "%s: %s (%s)",
+                    e.getKey().name(),
+                    e.getValue().status().name(),
+                    e.getValue().errorMessage().get());
+              }
+              return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
+            })
+        .sorted()
+        .collect(toImmutableList());
+  }
+
   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
     try {
       return fileInfo.getLine(side, lineNbr);
@@ -538,8 +626,8 @@
     }
   }
 
-  private List<Map<String, Object>> getLabelVoteSoyData(List<LabelVote> votes) {
-    List<Map<String, Object>> result = new ArrayList<>();
+  private ImmutableList<Map<String, Object>> getLabelVoteSoyData(ImmutableList<LabelVote> votes) {
+    ImmutableList.Builder<Map<String, Object>> result = ImmutableList.builder();
     for (LabelVote vote : votes) {
       Map<String, Object> data = new HashMap<>();
       data.put("label", vote.label());
@@ -549,12 +637,12 @@
       data.put("value", (int) vote.value());
       result.add(data);
     }
-    return result;
+    return result.build();
   }
 
   private String getCommentTimestamp() {
     // Grouping is currently done by timestamp.
     return MailProcessingUtil.rfcDateformatter.format(
-        ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC")));
+        ZonedDateTime.ofInstant(timestamp, ZoneId.of("UTC")));
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index b78dc62..e327d4d 100644
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -14,71 +14,30 @@
 
 package com.google.gerrit.server.mail.send;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.stream.StreamSupport;
 
 /** Notify interested parties of a brand new change. */
 public class CreateChangeSender extends NewChangeSender {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   public interface Factory {
     CreateChangeSender create(Project.NameKey project, Change.Id changeId);
   }
 
-  private final PermissionBackend permissionBackend;
-
   @Inject
   public CreateChangeSender(
-      EmailArguments args,
-      PermissionBackend permissionBackend,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId) {
+      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
     super(args, newChangeData(args, project, changeId));
-    this.permissionBackend = permissionBackend;
   }
 
   @Override
   protected void init() throws EmailException {
     super.init();
 
-    try {
-      // Upgrade watching owners from CC and BCC to TO.
-      Watchers matching =
-          getWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
-      // TODO(hiesel): Remove special handling for owners
-      StreamSupport.stream(matching.all().accounts.spliterator(), false)
-          .filter(this::isOwnerOfProjectOrBranch)
-          .forEach(acc -> add(RecipientType.TO, acc));
-      // Add everyone else. Owners added above will not be duplicated.
-      add(RecipientType.TO, matching.to);
-      add(RecipientType.CC, matching.cc);
-      add(RecipientType.BCC, matching.bcc);
-    } catch (StorageException err) {
-      // Just don't CC everyone. Better to send a partial message to those
-      // we already have queued up then to fail deliver entirely to people
-      // who have a lower interest in the change.
-      logger.atWarning().withCause(err).log("Cannot notify watchers for new change");
-    }
-
+    includeWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
     includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
   }
-
-  private boolean isOwnerOfProjectOrBranch(Account.Id userId) {
-    return permissionBackend
-        .absentUser(userId)
-        .ref(change.getDest())
-        .testOrFalse(RefPermission.WRITE_CONFIG);
-  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index d6d306c..64a01ff 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.IdentifiedUser;
@@ -109,10 +110,12 @@
     throw new IllegalStateException("key type is not SSH or GPG");
   }
 
+  @Nullable
   private String getSshKey() {
     return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
   }
 
+  @Nullable
   private String getGpgKeyFingerprints() {
     if (!gpgKeyFingerprints.isEmpty()) {
       return Joiner.on("\n").join(gpgKeyFingerprints);
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index 0de0dbe..bd79d3a 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
@@ -63,7 +64,6 @@
     includeWatchers(NotifyType.ALL_COMMENTS);
     reviewers.stream().forEach(r -> add(RecipientType.TO, r));
     addByEmail(RecipientType.TO, reviewersByEmail);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
@@ -74,6 +74,7 @@
     }
   }
 
+  @Nullable
   public List<String> getReviewerNames() {
     if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
index 77efbf8..f71cc00 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index ad1703d..8ee8fc2 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.common.io.CharStreams;
-import com.google.common.io.Resources;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
@@ -26,6 +27,7 @@
 import com.google.template.soy.shared.SoyAstCache;
 import java.io.IOException;
 import java.io.Reader;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -137,6 +139,8 @@
     }
 
     // Otherwise load the template as a resource.
-    builder.add(Resources.getResource(logicalPath), logicalPath);
+    URL resource = this.getClass().getClassLoader().getResource(logicalPath);
+    checkArgument(resource != null, "resource %s not found.", logicalPath);
+    builder.add(resource, logicalPath);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
index a3cf3e3..25b2ebd 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceModule.java
@@ -1,3 +1,17 @@
+// 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.base.Preconditions.checkArgument;
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index cec857d..9717f0e 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
@@ -26,12 +29,16 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.server.change.NotifyResolver;
 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 {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
     MergedSender create(
         Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff);
@@ -52,6 +59,22 @@
   }
 
   @Override
+  public void setNotify(NotifyResolver.Result notify) {
+    checkNotNull(notify);
+    if (!stickyApprovalDiff.isEmpty()) {
+      if (notify.handling() != NotifyHandling.ALL) {
+        logger.atFine().log(
+            "Requested to notify %s, but for change submission with sticky approval diff,"
+                + " Notify=ALL is enforced.",
+            notify.handling().name());
+      }
+      this.notify = NotifyResolver.Result.create(NotifyHandling.ALL, notify.accounts());
+    } else {
+      this.notify = notify;
+    }
+  }
+
+  @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;
@@ -62,7 +85,6 @@
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
     includeWatchers(NotifyType.SUBMITTED_CHANGES);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
index b187f9c..dcf3b6c 100644
--- a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
@@ -37,6 +37,5 @@
     super.init();
 
     ccExistingReviewers();
-    removeUsersThatIgnoredTheChange();
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 001de52..968bb1a 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.exceptions.EmailException;
@@ -96,7 +97,8 @@
     }
   }
 
-  public List<String> getReviewerNames() {
+  @Nullable
+  private List<String> getReviewerNames() {
     if (reviewers.isEmpty()) {
       return null;
     }
@@ -107,7 +109,8 @@
     return names;
   }
 
-  public List<String> getRemovedReviewerNames() {
+  @Nullable
+  private List<String> getRemovedReviewerNames() {
     if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
       return null;
     }
@@ -116,7 +119,7 @@
       names.add(getNameFor(id));
     }
     for (Address address : removedByEmailReviewers) {
-      names.add(address.name());
+      names.add(address.toString());
     }
     return names;
   }
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
index 5ffd928..f023075 100644
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
@@ -17,6 +17,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BranchNameKey;
@@ -26,6 +27,7 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -80,15 +82,18 @@
   protected abstract Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig);
 
   /** Add users or email addresses to the TO, CC, or BCC list. */
-  protected void add(RecipientType type, Watchers.List list) {
-    for (Account.Id user : list.accounts) {
-      add(type, user);
+  protected void add(RecipientType type, WatcherList watcherList) {
+    for (Account.Id user : watcherList.accounts) {
+      addWatcher(type, user);
     }
-    for (Address addr : list.emails) {
+    for (Address addr : watcherList.emails) {
       add(type, addr);
     }
   }
 
+  protected abstract void addWatcher(RecipientType type, Account.Id to);
+
+  @Nullable
   public String getSshHost() {
     String host = Iterables.getFirst(args.sshAddresses, null);
     if (host == null) {
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index effeaea..55f82d4 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -42,9 +42,9 @@
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.net.MalformedURLException;
 import java.net.URL;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -67,6 +67,7 @@
   private final Set<Account.Id> rcptTo = new HashSet<>();
   private final Map<String, EmailHeader> headers;
   private final Set<Address> smtpRcptTo = new HashSet<>();
+  private final Set<Address> smtpBccRcptTo = new HashSet<>();
   private Address smtpFromAddress;
   private StringBuilder textBody;
   private StringBuilder htmlBody;
@@ -228,8 +229,13 @@
             j.add(address.email());
           }
         }
-        smtpRcptTo.stream().forEach(a -> j.add(a.email()));
-        smtpRcptToPlaintextOnly.stream().forEach(a -> j.add(a.email()));
+        // For users who prefer plaintext, this comes at the cost of not being
+        // listed in the multipart To and Cc headers. We work around this by adding
+        // all users to the Reply-To address in both the plaintext and multipart
+        // email. We should exclude any BCC addresses from reply-to, because they should be
+        // invisible to other recipients.
+        Sets.difference(Sets.union(smtpRcptTo, smtpRcptToPlaintextOnly), smtpBccRcptTo).stream()
+            .forEach(a -> j.add(a.email()));
         setHeader(FieldName.REPLY_TO, j.toString());
       }
 
@@ -318,7 +324,7 @@
     setupSoyContext();
 
     smtpFromAddress = args.fromAddressGenerator.get().from(fromId);
-    setHeader(FieldName.DATE, new Date());
+    setHeader(FieldName.DATE, Instant.now());
     headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
     headers.put(FieldName.TO, new EmailHeader.AddressList());
     headers.put(FieldName.CC, new EmailHeader.AddressList());
@@ -374,10 +380,12 @@
     return SystemReader.getInstance().getHostname();
   }
 
+  @Nullable
   public String getSettingsUrl() {
     return args.urlFormatter.get().getSettingsUrl().orElse(null);
   }
 
+  @Nullable
   private String getGerritUrl() {
     return args.urlFormatter.get().getWebUrl().orElse(null);
   }
@@ -392,7 +400,7 @@
     headers.remove(name);
   }
 
-  protected void setHeader(String name, Date date) {
+  protected void setHeader(String name, Instant date) {
     headers.put(name, new EmailHeader.Date(date));
   }
 
@@ -463,9 +471,14 @@
    * username. If no username is set, this function returns null.
    *
    * @param accountId user to fetch.
-   * @return name/email of account, username, or null if unset.
+   * @return name/email of account, username, or null if unset or the accountId is null.
    */
-  protected String getUserNameEmailFor(Account.Id accountId) {
+  @Nullable
+  protected String getUserNameEmailFor(@Nullable Account.Id accountId) {
+    if (accountId == null) {
+      return null;
+    }
+
     Optional<AccountState> accountState = args.accountCache.get(accountId);
     if (!accountState.isPresent()) {
       return null;
@@ -544,6 +557,8 @@
    * Returns whether this email is visible to the given account
    *
    * @param to account.
+   * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
+   *     permission backend
    */
   protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
     return true;
@@ -565,6 +580,7 @@
           }
           ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.email());
           ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.email());
+          smtpBccRcptTo.remove(addr);
         }
         switch (rt) {
           case TO:
@@ -574,12 +590,14 @@
             ((EmailHeader.AddressList) headers.get(FieldName.CC)).add(addr);
             break;
           case BCC:
+            smtpBccRcptTo.add(addr);
             break;
         }
       }
     }
   }
 
+  @Nullable
   private Address toAddress(Account.Id id) {
     Optional<Account> accountState = args.accountCache.get(id).map(AccountState::account);
     if (!accountState.isPresent()) {
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 173b121..cbf47c5 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -24,21 +25,25 @@
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
 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.GroupBackedUser;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class ProjectWatch {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -111,13 +116,13 @@
   }
 
   public static class Watchers {
-    static class List {
+    static class WatcherList {
       protected final Set<Account.Id> accounts = new HashSet<>();
       protected final Set<Address> emails = new HashSet<>();
 
-      private static List union(List... others) {
-        List union = new List();
-        for (List other : others) {
+      private static WatcherList union(WatcherList... others) {
+        WatcherList union = new WatcherList();
+        for (WatcherList other : others) {
           union.accounts.addAll(other.accounts);
           union.emails.addAll(other.emails);
         }
@@ -125,15 +130,15 @@
       }
     }
 
-    protected final List to = new List();
-    protected final List cc = new List();
-    protected final List bcc = new List();
+    protected final WatcherList to = new WatcherList();
+    protected final WatcherList cc = new WatcherList();
+    protected final WatcherList bcc = new WatcherList();
 
-    List all() {
-      return List.union(to, cc, bcc);
+    WatcherList all() {
+      return WatcherList.union(to, cc, bcc);
     }
 
-    List list(NotifyConfig.Header header) {
+    WatcherList list(NotifyConfig.Header header) {
       switch (header) {
         case TO:
           return to;
@@ -171,7 +176,7 @@
     }
   }
 
-  private void deliverToMembers(Watchers.List matching, AccountGroup.UUID startUUID) {
+  private void deliverToMembers(WatcherList matching, AccountGroup.UUID startUUID) {
     Set<AccountGroup.UUID> seen = new HashSet<>();
     List<AccountGroup.UUID> q = new ArrayList<>();
 
@@ -239,15 +244,16 @@
   }
 
   private boolean filterMatch(CurrentUser user, String filter) throws QueryParseException {
-    ChangeQueryBuilder qb;
+    WatcherChangeQueryBuilder qb;
     Predicate<ChangeData> p = null;
 
     if (user == null) {
-      qb = args.queryBuilder.get().asUser(args.anonymousUser.get());
+      qb = WatcherChangeQueryBuilder.asUser(args.queryBuilder.get(), args.anonymousUser.get());
     } else {
-      qb = args.queryBuilder.get().asUser(user);
+      qb = WatcherChangeQueryBuilder.asUser(args.queryBuilder.get(), user);
       p = qb.isVisible();
     }
+    qb.forceAccountVisibilityCheck();
 
     if (filter != null) {
       Predicate<ChangeData> filterPredicate = qb.parse(filter);
@@ -259,4 +265,40 @@
     }
     return p == null || p.asMatchable().match(changeData);
   }
+
+  private static class WatcherChangeQueryBuilder extends ChangeQueryBuilder {
+    private WatcherChangeQueryBuilder(Arguments args) {
+      super(args);
+    }
+
+    public static WatcherChangeQueryBuilder asUser(ChangeQueryBuilder other, CurrentUser user) {
+      return new WatcherChangeQueryBuilder(other.getArgs().asUser(user));
+    }
+
+    @Override
+    protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
+      if (query.startsWith("refs/")) {
+        return ref(query);
+      }
+
+      // Adapt the capacity of this list when adding more default predicates.
+      List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
+      predicates.add(file(query));
+      try {
+        predicates.add(label(query));
+      } catch (StorageException | IOException | ConfigInvalidException | QueryParseException e) {
+        // Skip.
+      }
+      predicates.add(commit(query));
+      predicates.add(message(query));
+      predicates.add(comment(query));
+      predicates.add(projects(query));
+      predicates.add(ref(query));
+      predicates.add(branch(query));
+      predicates.add(topic(query));
+      // Adapt the capacity of the "predicates" list when adding more default
+      // predicates.
+      return Predicate.or(predicates);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
index 6762b7d..5242bfb 100644
--- a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
@@ -30,7 +30,7 @@
   @Inject
   public RemoveFromAttentionSetSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, project, changeId);
+    super(args, "removeFromAttentionSet", project, changeId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 9516b9f..5f31c68 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -14,34 +14,89 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Send notice of new patch sets for reviewers. */
 public class ReplacePatchSetSender extends ReplyToChangeSender {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public interface Factory {
-    ReplacePatchSetSender create(Project.NameKey project, Change.Id changeId);
+    ReplacePatchSetSender create(
+        Project.NameKey project,
+        Change.Id changeId,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
   }
 
   private final Set<Account.Id> reviewers = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
+  private final ChangeKind changeKind;
+  private final Set<PatchSetApproval> outdatedApprovals = new HashSet<>();
+  private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
+      preUpdateSubmitRequirementResultsSupplier;
+  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
   @Inject
   public ReplacePatchSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+      EmailArguments args,
+      @Assisted Project.NameKey project,
+      @Assisted Change.Id changeId,
+      @Assisted ChangeKind changeKind,
+      @Assisted ObjectId preUpdateMetaId,
+      @Assisted
+          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     super(args, "newpatchset", newChangeData(args, project, changeId));
+    this.changeKind = changeKind;
+
+    this.preUpdateSubmitRequirementResultsSupplier =
+        Suppliers.memoize(
+            () ->
+                // Triggers an (expensive) evaluation of the submit requirements. This is OK since
+                // all callers sent this email asynchronously, see EmailNewPatchSet.
+                newChangeData(args, project, changeId, preUpdateMetaId)
+                    .submitRequirementsIncludingLegacy());
+
+    this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
+  }
+
+  @Override
+  protected boolean shouldSendMessage() {
+    if (!isChangeNoLongerSubmittable() && changeKind.isTrivialRebase()) {
+      logger.atFine().log(
+          "skip email because new patch set is a trivial rebase that didn't make the change"
+              + " non-submittable");
+      return false;
+    }
+
+    return super.shouldSendMessage();
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
@@ -52,6 +107,12 @@
     extraCC.addAll(cc);
   }
 
+  public void addOutdatedApproval(@Nullable Collection<PatchSetApproval> outdatedApprovals) {
+    if (outdatedApprovals != null) {
+      this.outdatedApprovals.addAll(outdatedApprovals);
+    }
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -71,7 +132,6 @@
     }
     bccStarredBy();
     includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
@@ -82,7 +142,8 @@
     }
   }
 
-  public List<String> getReviewerNames() {
+  @Nullable
+  public ImmutableList<String> getReviewerNames() {
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
       if (id.equals(fromId)) {
@@ -93,12 +154,87 @@
     if (names.isEmpty()) {
       return null;
     }
-    return names;
+    return names.stream().sorted().collect(toImmutableList());
+  }
+
+  private ImmutableList<String> formatOutdatedApprovals() {
+    return outdatedApprovals.stream()
+        .map(
+            outdatedApproval ->
+                String.format(
+                    "%s by %s",
+                    LabelVote.create(outdatedApproval.label(), outdatedApproval.value()).format(),
+                    getNameFor(outdatedApproval.accountId())))
+        .sorted()
+        .collect(toImmutableList());
   }
 
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContextEmailData.put("reviewerNames", getReviewerNames());
+    soyContextEmailData.put("outdatedApprovals", formatOutdatedApprovals());
+
+    if (isChangeNoLongerSubmittable()) {
+      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      soyContext.put(
+          "oldSubmitRequirements",
+          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
+      soyContext.put(
+          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+    }
+  }
+
+  /**
+   * Checks whether the change is no longer submittable.
+   *
+   * @return {@code true} if the change has been submittable before the update and is no longer
+   *     submittable after the update has been applied, otherwise {@code false}
+   */
+  private boolean isChangeNoLongerSubmittable() {
+    boolean isSubmittablePreUpdate =
+        preUpdateSubmitRequirementResultsSupplier.get().values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s before the update is %s",
+        change.getId(), isSubmittablePreUpdate);
+    if (!isSubmittablePreUpdate) {
+      return false;
+    }
+
+    boolean isSubmittablePostUpdate =
+        postUpdateSubmitRequirementResults.values().stream()
+            .allMatch(SubmitRequirementResult::fulfilled);
+    logger.atFine().log(
+        "the submitability of change %s after the update is %s",
+        change.getId(), isSubmittablePostUpdate);
+    return !isSubmittablePostUpdate;
+  }
+
+  private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
+    return postUpdateSubmitRequirementResults.entrySet().stream()
+        .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
+        .map(Map.Entry::getKey)
+        .map(SubmitRequirement::name)
+        .sorted()
+        .collect(toImmutableList());
+  }
+
+  private static ImmutableList<String> formatSubmitRequirments(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
+    return submitRequirementResults.entrySet().stream()
+        .map(
+            e -> {
+              if (e.getValue().errorMessage().isPresent()) {
+                return String.format(
+                    "%s: %s (%s)",
+                    e.getKey().name(),
+                    e.getValue().status().name(),
+                    e.getValue().errorMessage().get());
+              }
+              return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
+            })
+        .sorted()
+        .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
index ffe70cf..e37d8f9 100644
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ b/java/com/google/gerrit/server/mail/send/RestoredSender.java
@@ -41,7 +41,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
index c11529b..1d7223d 100644
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ b/java/com/google/gerrit/server/mail/send/RevertedSender.java
@@ -40,7 +40,6 @@
     ccAllApprovals();
     bccStarredBy();
     includeWatchers(NotifyType.ALL_COMMENTS);
-    removeUsersThatIgnoredTheChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index d32e6fb..c06cc1e 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -36,10 +36,11 @@
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.Writer;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Date;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -281,9 +282,11 @@
       setMissingHeader(hdrs, "Importance", importance);
     }
     if (expiryDays > 0) {
-      Date expiry = new Date(TimeUtil.nowMs() + expiryDays * 24 * 60 * 60 * 1000L);
-      setMissingHeader(
-          hdrs, "Expiry-Date", new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z").format(expiry));
+      Instant expiry = Instant.ofEpochMilli(TimeUtil.nowMs() + expiryDays * 24 * 60 * 60 * 1000L);
+      DateTimeFormatter fmt =
+          DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z")
+              .withZone(ZoneId.systemDefault());
+      setMissingHeader(hdrs, "Expiry-Date", fmt.format(expiry));
     }
 
     String encodedBody;
@@ -382,7 +385,7 @@
     try (QuotedPrintableOutputStream qp = new QuotedPrintableOutputStream(s, false)) {
       qp.write(input.getBytes(UTF_8));
     }
-    return s.toString();
+    return s.toString(UTF_8);
   }
 
   private static void setMissingHeader(Map<String, EmailHeader> hdrs, String name, String value) {
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index d71f9ff..2edea26 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Change;
@@ -24,6 +25,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritImportedServerIds;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
@@ -37,8 +39,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 /** View of contents at a single ref related to some change. * */
 public abstract class AbstractChangeNotes<T> {
@@ -53,6 +53,7 @@
     public final AllUsersName allUsers;
     public final NoteDbMetrics metrics;
     public final String serverId;
+    public final ImmutableSet<String> importedServerIds;
 
     // Providers required to avoid dependency cycles.
 
@@ -66,7 +67,8 @@
         ChangeNoteJson changeNoteJson,
         NoteDbMetrics metrics,
         Provider<ChangeNotesCache> cache,
-        @GerritServerId String serverId) {
+        @GerritServerId String serverId,
+        @GerritImportedServerIds ImmutableSet<String> importedServerIds) {
       this.failOnLoadForTest = new AtomicBoolean();
       this.repoManager = repoManager;
       this.allUsers = allUsers;
@@ -74,6 +76,7 @@
       this.metrics = metrics;
       this.cache = cache;
       this.serverId = serverId;
+      this.importedServerIds = importedServerIds;
     }
   }
 
@@ -140,6 +143,15 @@
   }
 
   public T load() {
+    try (Repository repo = args.repoManager.openRepository(getProjectName())) {
+      load(repo);
+      return self();
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  public T load(Repository repo) {
     if (loaded) {
       return self();
     }
@@ -148,7 +160,6 @@
       throw new StorageException("Reading from NoteDb is disabled");
     }
     try (Timer0.Context timer = args.metrics.readLatency.start();
-        Repository repo = args.repoManager.openRepository(getProjectName());
         // Call openHandle even if reading is disabled, to trigger
         // auto-rebuilding before this object may get passed to a ChangeUpdate.
         LoadHandle handle = openHandle(repo, revision)) {
@@ -173,17 +184,16 @@
    * <p>Implementations may override this method to provide auto-rebuilding behavior.
    *
    * @param repo open repository.
+   * @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 MissingMetaObjectException specified SHA1 isn't reachable from meta branch.
    * @throws IOException a repo-level error occurred.
    */
   protected LoadHandle openHandle(Repository repo, @Nullable ObjectId id)
-      throws NoSuchChangeException, IOException, MissingMetaObjectException {
+      throws NoSuchChangeException, IOException {
     if (id == null) {
       id = readRef(repo);
-    } else {
-      verifyMetaId(repo, id);
     }
 
     return new LoadHandle(repo, id);
@@ -194,6 +204,7 @@
     return load();
   }
 
+  @Nullable
   public ObjectId loadRevision() {
     if (loaded) {
       return getRevision();
@@ -226,20 +237,4 @@
   protected final T self() {
     return (T) this;
   }
-
-  private void verifyMetaId(Repository repo, ObjectId id)
-      throws IOException, MissingMetaObjectException {
-    try (RevWalk rw = new RevWalk(repo)) {
-      Ref ref = repo.getRefDatabase().exactRef(getRefName());
-      RevCommit tip = rw.parseCommit(ref.getObjectId());
-      rw.markStart(tip);
-      for (RevCommit rev : rw) {
-        if (id.equals(rev)) {
-          return;
-        }
-      }
-    }
-
-    throw new MissingMetaObjectException(id.getName() + " not reachable from " + getRefName());
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 6677490..ba91c68 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import java.io.IOException;
-import java.util.Date;
+import java.time.Instant;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -45,7 +45,7 @@
   protected final Account.Id accountId;
   protected final Account.Id realAccountId;
   protected final PersonIdent authorIdent;
-  protected final Date when;
+  protected final Instant when;
 
   @Nullable private final ChangeNotes notes;
   private final Change change;
@@ -60,7 +60,7 @@
       CurrentUser user,
       PersonIdent serverIdent,
       ChangeNoteUtil noteUtil,
-      Date when) {
+      Instant when) {
     this.noteUtil = noteUtil;
     this.serverIdent = new PersonIdent(serverIdent, when);
     this.notes = notes;
@@ -80,7 +80,7 @@
       Account.Id accountId,
       Account.Id realAccountId,
       PersonIdent authorIdent,
-      Date when) {
+      Instant when) {
     checkArgument(
         (notes != null && change == null) || (notes == null && change != null),
         "exactly one of notes or change required");
@@ -101,13 +101,14 @@
         user);
   }
 
+  @Nullable
   private static Account.Id accountId(CurrentUser u) {
     checkUserType(u);
     return (u instanceof IdentifiedUser) ? u.getAccountId() : null;
   }
 
   private static PersonIdent ident(
-      ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Date when) {
+      ChangeNoteUtil noteUtil, PersonIdent serverIdent, CurrentUser u, Instant when) {
     checkUserType(u);
     if (u instanceof IdentifiedUser) {
       return noteUtil.newAccountIdIdent(u.asIdentifiedUser().getAccount().id(), when, serverIdent);
@@ -137,7 +138,7 @@
     return change;
   }
 
-  public Date getWhen() {
+  public Instant getWhen() {
     return when;
   }
 
@@ -206,6 +207,7 @@
    *     deleted.
    * @throws IOException if a lower-level error occurred.
    */
+  @Nullable
   final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr) throws IOException {
     if (isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 57f6353..0dcf786 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -19,6 +19,7 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
@@ -31,9 +32,9 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -60,14 +61,14 @@
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
 
     ChangeDraftUpdate create(
         Change change,
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
   }
 
   @AutoValue
@@ -92,6 +93,7 @@
   private List<HumanComment> put = new ArrayList<>();
   private Map<Key, DeleteReason> delete = new HashMap<>();
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeDraftUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -101,7 +103,7 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
@@ -115,7 +117,7 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
@@ -186,6 +188,7 @@
     return clonedUpdate;
   }
 
+  @Nullable
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
new file mode 100644
index 0000000..771d72b
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import org.eclipse.jgit.revwalk.FooterKey;
+
+/** Footers, that can be set in NoteDb commits. */
+public class ChangeNoteFooters {
+  public static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
+  public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
+  public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
+  public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
+  public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
+  public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
+  public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
+  public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
+  public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
+  public static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label");
+  public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
+  public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
+      new FooterKey("Patch-set-description");
+  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
+  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
+  public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+  public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
+  public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
+  public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
+  public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
+  public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
+  public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
+  public static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 4c41a12..de401ac 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -16,9 +16,16 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.EntitiesAdapterFactory;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
 import com.google.gerrit.json.EnumTypeAdapterFactory;
+import com.google.gerrit.json.OptionalSubmitRequirementExpressionResultAdapterFactory;
+import com.google.gerrit.json.OptionalTypeAdapter;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
 import com.google.gson.TypeAdapter;
 import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.JsonWriter;
@@ -26,19 +33,43 @@
 import com.google.inject.TypeLiteral;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
+/**
+ * Provides {@link Gson} to parse {@link ChangeRevisionNote}, attached to the change update.
+ *
+ * <p>Apart from the adapters for the custom JSON format, this class also registers adapters that
+ * support forward/backward compatibility when modifying {@link ChangeNotes} storage format.
+ *
+ * <p>NOTE: All changes to the storage format must be both forward and backward compatible, see
+ * comment on {@link ChangeNotesParser}.
+ *
+ * <p>For JSON, such changes include e.g. modifications to the serialized {@code AutoValue} classes.
+ */
 @Singleton
 public class ChangeNoteJson {
   private final Gson gson = newGson();
 
   static Gson newGson() {
     return new GsonBuilder()
+        .registerTypeAdapter(Optional.class, new OptionalTypeAdapter())
         .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
         .registerTypeAdapterFactory(new EnumTypeAdapterFactory())
         .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
         .registerTypeAdapter(
             new TypeLiteral<ImmutableList<String>>() {}.getType(),
             new ImmutableListAdapter().nullSafe())
+        .registerTypeAdapter(
+            new TypeLiteral<Optional<Boolean>>() {}.getType(),
+            new OptionalBooleanAdapter().nullSafe())
+        .registerTypeAdapterFactory(new OptionalSubmitRequirementExpressionResultAdapterFactory())
+        .registerTypeAdapter(ObjectId.class, new ObjectIdAdapter())
+        .registerTypeAdapter(
+            SubmitRequirementExpressionResult.Status.class,
+            new SubmitRequirementExpressionResultStatusAdapter())
         .setPrettyPrinting()
         .create();
   }
@@ -47,6 +78,69 @@
     return gson;
   }
 
+  static class OptionalBooleanAdapter extends TypeAdapter<Optional<Boolean>> {
+    @Override
+    public void write(JsonWriter out, Optional<Boolean> value) throws IOException {
+      // Serialize the field using the same format used by the AutoValue's default Gson serializer.
+      out.beginObject();
+      out.name("value");
+      if (value.isPresent()) {
+        out.value(value.get());
+      } else {
+        out.nullValue();
+      }
+      out.endObject();
+    }
+
+    @Override
+    public Optional<Boolean> read(JsonReader in) throws IOException {
+      JsonElement parsed = JsonParser.parseReader(in);
+      if (parsed == null) {
+        return Optional.empty();
+      }
+      if (parsed.isJsonObject()) {
+        // If it's not a JSON object, then the boolean value is available directly in the Json
+        // element.
+        parsed = parsed.getAsJsonObject().get("value");
+      }
+      if (parsed == null || parsed.isJsonNull()) {
+        return Optional.empty();
+      }
+      return Optional.of(parsed.getAsBoolean());
+    }
+  }
+
+  /** Json serializer for the {@link ObjectId} class. */
+  static class ObjectIdAdapter extends TypeAdapter<ObjectId> {
+    private static final List<String> legacyFields = Arrays.asList("w1", "w2", "w3", "w4", "w5");
+
+    @Override
+    public void write(JsonWriter out, ObjectId value) throws IOException {
+      out.value(value.name());
+    }
+
+    @Override
+    public ObjectId read(JsonReader in) throws IOException {
+      JsonElement parsed = JsonParser.parseReader(in);
+      if (parsed.isJsonObject() && isJGitFormat(parsed)) {
+        // Some object IDs may have been serialized using the JGit format using the five integers
+        // w1, w2, w3, w4, w5. Detect this case so that we can deserialize properly.
+        int[] raw =
+            legacyFields.stream()
+                .mapToInt(field -> parsed.getAsJsonObject().get(field).getAsInt())
+                .toArray();
+        return ObjectId.fromRaw(raw);
+      }
+      return ObjectId.fromString(parsed.getAsString());
+    }
+
+    /** Return true if the json element contains the JGit serialized format of the Object ID. */
+    private boolean isJGitFormat(JsonElement elem) {
+      JsonObject asObj = elem.getAsJsonObject();
+      return legacyFields.stream().allMatch(field -> asObj.has(field));
+    }
+  }
+
   static class ImmutableListAdapter extends TypeAdapter<ImmutableList<String>> {
 
     @Override
@@ -69,4 +163,32 @@
       return builder.build();
     }
   }
+
+  /**
+   * A Json type adapter for the Status enum in {@link SubmitRequirementExpressionResult}. This
+   * adapter is able to parse unrecognized values. Unrecognized values are converted to the value
+   * "ERROR" The adapter is needed to ensure forward compatibility since we want to add more values
+   * to this enum. We do that to ensure safer rollout in distributed setups where some tasks are
+   * updated before others. We make sure that tasks running the old binaries are still able to parse
+   * values written by tasks running the new binaries.
+   *
+   * <p>TODO(ghareeb): Remove this adapter.
+   */
+  static class SubmitRequirementExpressionResultStatusAdapter
+      extends TypeAdapter<SubmitRequirementExpressionResult.Status> {
+    @Override
+    public void write(JsonWriter jsonWriter, Status status) throws IOException {
+      jsonWriter.value(status.name());
+    }
+
+    @Override
+    public Status read(JsonReader jsonReader) throws IOException {
+      String val = jsonReader.nextString();
+      try {
+        return SubmitRequirementExpressionResult.Status.valueOf(val);
+      } catch (IllegalArgumentException e) {
+        return SubmitRequirementExpressionResult.Status.ERROR;
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 28ab711..8f6ad67 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -22,39 +22,13 @@
 import com.google.gson.Gson;
 import com.google.inject.Inject;
 import java.time.Instant;
-import java.util.Date;
 import java.util.Optional;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.util.RawParseUtils;
 
 public class ChangeNoteUtil {
 
-  static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
-  static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
-  static final FooterKey FOOTER_BRANCH = new FooterKey("Branch");
-  static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
-  static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
-  static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
-  static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
-  static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
-  static final FooterKey FOOTER_LABEL = new FooterKey("Label");
-  static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label");
-  static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
-  static final FooterKey FOOTER_PATCH_SET_DESCRIPTION = new FooterKey("Patch-set-description");
-  static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
-  static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
-  static final FooterKey FOOTER_STATUS = new FooterKey("Status");
-  static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
-  static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
-  static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
-  static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
-  static final FooterKey FOOTER_TAG = new FooterKey("Tag");
-  static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
-  static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
-  static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
-
   static final String GERRIT_USER_TEMPLATE = "Gerrit User %d";
 
   private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
@@ -95,12 +69,13 @@
    * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
    * address.
    */
-  public PersonIdent newAccountIdIdent(Account.Id accountId, Date when, PersonIdent serverIdent) {
+  public PersonIdent newAccountIdIdent(
+      Account.Id accountId, Instant when, PersonIdent serverIdent) {
     return new PersonIdent(
         getAccountIdAsUsername(accountId),
         getAccountIdAsEmailAddress(accountId),
         when,
-        serverIdent.getTimeZone());
+        serverIdent.getZoneId());
   }
 
   /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 2d9b014..52f540d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static java.util.Comparator.comparing;
@@ -31,7 +30,6 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
@@ -39,6 +37,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.errorprone.annotations.FormatMethod;
 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.BranchNameKey;
@@ -48,6 +47,7 @@
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.PatchSetApprovals;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
@@ -66,15 +66,17 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.function.Predicate;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -89,9 +91,6 @@
   static final Ordering<PatchSetApproval> PSA_BY_TIME =
       Ordering.from(comparing(PatchSetApproval::granted));
 
-  public static final Ordering<ChangeMessage> MESSAGE_BY_TIME =
-      Ordering.from(comparing(ChangeMessage::getWrittenOn));
-
   @FormatMethod
   public static ConfigInvalidException parseException(
       Change.Id changeId, String fmt, Object... args) {
@@ -113,10 +112,52 @@
       this.projectCache = projectCache;
     }
 
+    @AutoValue
+    public abstract static class ScanResult {
+      abstract ImmutableSet<Change.Id> fromPatchSetRefs();
+
+      abstract ImmutableSet<Change.Id> fromMetaRefs();
+
+      public SetView<Change.Id> all() {
+        return Sets.union(fromPatchSetRefs(), fromMetaRefs());
+      }
+    }
+
+    public static ScanResult scanChangeIds(Repository repo) throws IOException {
+      ImmutableSet.Builder<Change.Id> fromPs = ImmutableSet.builder();
+      ImmutableSet.Builder<Change.Id> fromMeta = ImmutableSet.builder();
+      for (Ref r : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+        Change.Id id = Change.Id.fromRef(r.getName());
+        if (id != null) {
+          (r.getName().endsWith(RefNames.META_SUFFIX) ? fromMeta : fromPs).add(id);
+        }
+      }
+      return new AutoValue_ChangeNotes_Factory_ScanResult(fromPs.build(), fromMeta.build());
+    }
+
     public ChangeNotes createChecked(Change c) {
       return createChecked(c.getProject(), c.getId());
     }
 
+    /**
+     * Load the change-notes associated to a project/change-id using an existing open repository
+     *
+     * @param repo existing open repository
+     * @param project project associated with the repository
+     * @param changeId change-id associated with the change-notes to load
+     * @param metaRevId version of the change-id to load, null for loading the latest
+     * @return change-notes object for the change
+     */
+    @UsedAt(UsedAt.Project.MODULE_GIT_REFS_FILTER)
+    public ChangeNotes createChecked(
+        Repository repo,
+        Project.NameKey project,
+        Change.Id changeId,
+        @Nullable ObjectId metaRevId) {
+      Change change = newChange(project, changeId);
+      return new ChangeNotes(args, change, true, null, metaRevId).load(repo);
+    }
+
     public ChangeNotes createChecked(
         Project.NameKey project, Change.Id changeId, @Nullable ObjectId metaRevId) {
       Change change = newChange(project, changeId);
@@ -137,6 +178,22 @@
       return new ChangeNotes(args, newChange(project, changeId), true, null).load();
     }
 
+    public ChangeNotes create(Repository repository, Project.NameKey project, Change.Id changeId) {
+      checkArgument(project != null, "project is required");
+      return new ChangeNotes(args, newChange(project, changeId), true, null).load(repository);
+    }
+
+    /**
+     * Create change notes for a change that was loaded from index. This method should only be used
+     * when database access is harmful and potentially stale data from the index is acceptable.
+     *
+     * @param change change loaded from secondary index
+     * @return change notes
+     */
+    public ChangeNotes createFromIndexedChange(Change change) {
+      return new ChangeNotes(args, change, true, null);
+    }
+
     public ChangeNotes createForBatchUpdate(Change change, boolean shouldExist) {
       return new ChangeNotes(args, change, shouldExist, null).load();
     }
@@ -151,7 +208,7 @@
      * 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();
+      InternalChangeQuery query = queryProvider.get().setLimit(2).noFields();
       List<ChangeData> changes = query.byLegacyChangeId(changeId);
       if (changes.isEmpty()) {
         throw new NoSuchChangeException(changeId);
@@ -181,13 +238,14 @@
     }
 
     public List<ChangeNotes> create(
+        Repository repo,
         Project.NameKey project,
         Collection<Change.Id> changeIds,
         Predicate<ChangeNotes> predicate) {
       List<ChangeNotes> notes = new ArrayList<>();
       for (Change.Id cid : changeIds) {
         try {
-          ChangeNotes cn = create(project, cid);
+          ChangeNotes cn = create(repo, project, cid);
           if (cn.getChange() != null && predicate.test(cn)) {
             notes.add(cn);
           }
@@ -200,10 +258,32 @@
       return notes;
     }
 
+    /* TODO: This is now unused in the Gerrit code-base, however it is kept in the code
+    /* because it is a public method in a stable branch.
+     * It can be removed in master branch where we have more flexibility to change the API
+     * interface.
+     */
+    public List<ChangeNotes> create(
+        Project.NameKey project,
+        Collection<Change.Id> changeIds,
+        Predicate<ChangeNotes> predicate) {
+      try (Repository repo = args.repoManager.openRepository(project)) {
+        return create(repo, project, changeIds, predicate);
+      } catch (RepositoryNotFoundException e) {
+        // The repository does not exist, hence it does not contain
+        // any change.
+      } catch (IOException e) {
+        logger.atWarning().withCause(e).log(
+            "Unable to open project=%s when trying to retrieve changeId=%s from NoteDb",
+            project, changeIds);
+      }
+      return Collections.emptyList();
+    }
+
     public ListMultimap<Project.NameKey, ChangeNotes> create(Predicate<ChangeNotes> predicate)
         throws IOException {
-      ListMultimap<Project.NameKey, ChangeNotes> m =
-          MultimapBuilder.hashKeys().arrayListValues().build();
+      ImmutableListMultimap.Builder<Project.NameKey, ChangeNotes> m =
+          ImmutableListMultimap.builder();
       for (Project.NameKey project : projectCache.all()) {
         try (Repository repo = args.repoManager.openRepository(project)) {
           scan(repo, project)
@@ -213,7 +293,7 @@
               .forEach(n -> m.put(n.getProjectName(), n));
         }
       }
-      return ImmutableListMultimap.copyOf(m);
+      return m.build();
     }
 
     public Stream<ChangeNotesResult> scan(Repository repo, Project.NameKey project)
@@ -224,8 +304,11 @@
     public Stream<ChangeNotesResult> scan(
         Repository repo, Project.NameKey project, Predicate<Change.Id> changeIdPredicate)
         throws IOException {
-      ScanResult sr = scanChangeIds(repo);
+      return scan(scanChangeIds(repo), project, changeIdPredicate);
+    }
 
+    public Stream<ChangeNotesResult> scan(
+        ScanResult sr, Project.NameKey project, Predicate<Change.Id> changeIdPredicate) {
       Stream<Change.Id> idStream = sr.all().stream();
       if (changeIdPredicate != null) {
         idStream = idStream.filter(changeIdPredicate);
@@ -297,29 +380,6 @@
       @Nullable
       abstract ChangeNotes maybeNotes();
     }
-
-    @AutoValue
-    abstract static class ScanResult {
-      abstract ImmutableSet<Change.Id> fromPatchSetRefs();
-
-      abstract ImmutableSet<Change.Id> fromMetaRefs();
-
-      SetView<Change.Id> all() {
-        return Sets.union(fromPatchSetRefs(), fromMetaRefs());
-      }
-    }
-
-    private static ScanResult scanChangeIds(Repository repo) throws IOException {
-      ImmutableSet.Builder<Change.Id> fromPs = ImmutableSet.builder();
-      ImmutableSet.Builder<Change.Id> fromMeta = ImmutableSet.builder();
-      for (Ref r : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
-        Change.Id id = Change.Id.fromRef(r.getName());
-        if (id != null) {
-          (r.getName().endsWith(RefNames.META_SUFFIX) ? fromMeta : fromPs).add(id);
-        }
-      }
-      return new AutoValue_ChangeNotes_Factory_ScanResult(fromPs.build(), fromMeta.build());
-    }
   }
 
   private final boolean shouldExist;
@@ -338,8 +398,7 @@
   // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
   // ChangeNotesCache from handlers.
   private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
-  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsWithCopied;
+  private PatchSetApprovals approvals;
   private ImmutableSet<Comment.Key> commentKeys;
 
   public ChangeNotes(
@@ -367,44 +426,27 @@
     return state.metaId();
   }
 
+  public String getServerId() {
+    return state.serverId();
+  }
+
   public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
     if (patchSets == null) {
-      ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
-          ImmutableSortedMap.orderedBy(comparing(PatchSet.Id::get));
+      ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b = ImmutableSortedMap.naturalOrder();
       b.putAll(state.patchSets());
       patchSets = b.build();
     }
     return patchSets;
   }
 
-  /**
-   * Gets the approvals, not including the copied approvals. To get copied approvals as well, use
-   * {@link #getApprovalsWithCopied}, or use {@code ApprovalInference}.
-   */
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
+  /** Gets the approvals of all patch sets. */
+  public PatchSetApprovals getApprovals() {
     if (approvals == null) {
-      approvals =
-          state.approvals().stream()
-              .filter(e -> !e.getValue().copied())
-              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue()));
+      approvals = PatchSetApprovals.create(ImmutableListMultimap.copyOf(state.approvals()));
     }
     return approvals;
   }
 
-  /**
-   * This method is currently used only in tests. TODO(paiking): Use this method to fetch approvals
-   * (including copied approvals) instead of computing copied approvals on demand. This will be used
-   * by {@code ApprovalCache}.
-   *
-   * @return all approvals, including copied approvals.
-   */
-  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovalsWithCopied() {
-    if (approvalsWithCopied == null) {
-      approvalsWithCopied = ImmutableListMultimap.copyOf(state.approvals());
-    }
-    return approvalsWithCopied;
-  }
-
   public ReviewerSet getReviewers() {
     return state.reviewers();
   }
@@ -440,11 +482,19 @@
 
   /**
    * Returns the evaluated submit requirements for the change. We only intend to store submit
-   * requirements in NoteDb for closed changes, hence the result will be an empty list for active
-   * changes, or a list of submit requirements results otherwise. For closed changes, the results
-   * represent the state of evaluating submit requirements for this change when it was merged.
+   * requirements in NoteDb for closed changes. For closed changes, the results represent the state
+   * of evaluating submit requirements for this change when it was merged or abandoned.
+   *
+   * @throws UnsupportedOperationException if submit requirements are requested for an open change.
    */
   public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    if (state.columns().status().isOpen()) {
+      throw new UnsupportedOperationException(
+          String.format(
+              "Cannot request stored submit requirements"
+                  + " for an open change: project = %s, change ID = %d",
+              getProjectName(), state.changeId().get()));
+    }
     return state.submitRequirementsResult();
   }
 
@@ -512,7 +562,7 @@
   }
 
   /** Returns {@link Optional} value of time when the change was merged. */
-  public Optional<Timestamp> getMergedOn() {
+  public Optional<Instant> getMergedOn() {
     return Optional.ofNullable(state.mergedOn());
   }
 
@@ -628,7 +678,9 @@
      * be to bump the cache version, but that would invalidate all persistent cache entries, what we
      * rather try to avoid.
      */
-    if (!Strings.isNullOrEmpty(stateServerId) && !args.serverId.equals(stateServerId)) {
+    if (!Strings.isNullOrEmpty(stateServerId)
+        && !args.serverId.equals(stateServerId)
+        && !args.importedServerIds.contains(stateServerId)) {
       throw new InvalidServerIdException(args.serverId, stateServerId);
     }
 
@@ -646,6 +698,7 @@
     return change.getProject();
   }
 
+  @Nullable
   @Override
   protected ObjectId readRef(Repository repo) throws IOException {
     return refs != null ? refs.get(getRefName()).orElse(null) : super.readRef(repo);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index c554ca5..0f2c877 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
@@ -61,7 +62,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(2)
+            .version(5)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
@@ -366,7 +367,13 @@
           "Load change notes for change %s of project %s", key.changeId(), key.project());
       ChangeNotesParser parser =
           new ChangeNotesParser(
-              key.changeId(), key.id(), walkSupplier.get(), args.changeNoteJson, args.metrics);
+              key.changeId(),
+              key.id(),
+              walkSupplier.get(),
+              args.changeNoteJson,
+              args.metrics,
+              args.serverId,
+              externalIdCache);
       ChangeNotesState result = parser.parseAll();
       // This assignment only happens if call() was actually called, which only
       // happens when Cache#get(K, Callable<V>) incurs a cache miss.
@@ -377,11 +384,16 @@
 
   private final Cache<Key, ChangeNotesState> cache;
   private final Args args;
+  private final ExternalIdCache externalIdCache;
 
   @Inject
-  ChangeNotesCache(@Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache, Args args) {
+  ChangeNotesCache(
+      @Named(CACHE_NAME) Cache<Key, ChangeNotesState> cache,
+      Args args,
+      ExternalIdCache externalIdCache) {
     this.cache = cache;
     this.args = args;
+    this.externalIdCache = externalIdCache;
   }
 
   Value get(
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 76573f6..38ab8e9 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java b/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java
new file mode 100644
index 0000000..83ee6ec
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java
@@ -0,0 +1,242 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.revwalk.FooterKey;
+
+/**
+ * Util to extract {@link com.google.gerrit.entities.PatchSetApproval} from {@link
+ * ChangeNoteFooters#FOOTER_LABEL} or {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}.
+ */
+public class ChangeNotesParseApprovalUtil {
+
+  /**
+   * {@link com.google.gerrit.entities.PatchSetApproval}, parsed from {@link
+   * ChangeNoteFooters#FOOTER_LABEL} or {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}.
+   *
+   * <p>In comparison to {@link com.google.gerrit.entities.PatchSetApproval}, this entity represent
+   * the raw fields, parsed from the NoteDB footer line, without any interpretation of the parsed
+   * values. See {@link #parseApproval} and {@link #parseCopiedApproval} for the valid {@link
+   * #footerLine} values.
+   */
+  @AutoValue
+  public abstract static class ParsedPatchSetApproval {
+
+    /** The original footer value, that this entity was parsed from. */
+    public abstract String footerLine();
+
+    public abstract boolean isRemoval();
+
+    /** Either <LABEL>=VOTE or <LABEL> for {@link #isRemoval}. */
+    public abstract String labelVote();
+
+    public abstract Optional<String> uuid();
+
+    public abstract Optional<String> accountIdent();
+
+    public abstract Optional<String> realAccountIdent();
+
+    public abstract Optional<String> tag();
+
+    public static Builder builder() {
+      return new AutoValue_ChangeNotesParseApprovalUtil_ParsedPatchSetApproval.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      abstract Builder footerLine(String labelLine);
+
+      abstract Builder isRemoval(boolean isRemoval);
+
+      abstract Builder labelVote(String labelVote);
+
+      abstract Builder uuid(Optional<String> uuid);
+
+      abstract Builder accountIdent(Optional<String> accountIdent);
+
+      abstract Builder realAccountIdent(Optional<String> realAccountIdent);
+
+      abstract Builder tag(Optional<String> tag);
+
+      abstract ParsedPatchSetApproval build();
+    }
+  }
+
+  /**
+   * Parses {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_LABEL} line.
+   *
+   * <p>Valid added approval footer examples:
+   *
+   * <ul>
+   *   <li>Label: <LABEL>=VOTE
+   *   <li>Label: <LABEL>=VOTE <Gerrit Account>
+   *   <li>Label: <LABEL>=VOTE, <UUID>
+   *   <li>Label: <LABEL>=VOTE, <UUID> <Gerrit Account>
+   * </ul>
+   *
+   * <p>Valid removed approval footer examples:
+   *
+   * <ul>
+   *   <li>-<LABEL>
+   *   <li>-<LABEL> <Gerrit Account>
+   * </ul>
+   *
+   * <p><UUID> is optional, since the approval might have been granted before {@link
+   * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
+   *
+   * <p><Gerrit Account> is only persisted in cases, when the account, that granted the vote does
+   * not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
+   */
+  public static ParsedPatchSetApproval parseApproval(String footerLine)
+      throws ConfigInvalidException {
+    try {
+      ParsedPatchSetApproval.Builder rawPatchSetApproval =
+          ParsedPatchSetApproval.builder().footerLine(footerLine);
+      String labelVoteStr;
+      boolean isRemoval = footerLine.startsWith("-");
+      rawPatchSetApproval.isRemoval(isRemoval);
+      int uuidStart = isRemoval ? -1 : footerLine.indexOf(", ");
+      int reviewerStart = footerLine.indexOf(' ', uuidStart != -1 ? uuidStart + 2 : 0);
+      int labelStart = isRemoval ? 1 : 0;
+      checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, footerLine);
+
+      if (uuidStart != -1) {
+        String uuid =
+            footerLine.substring(
+                uuidStart + 2, reviewerStart > 0 ? reviewerStart : footerLine.length());
+        checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_LABEL, footerLine);
+        labelVoteStr = footerLine.substring(labelStart, uuidStart);
+        rawPatchSetApproval.uuid(Optional.of(uuid));
+      } else if (reviewerStart != -1) {
+        labelVoteStr = footerLine.substring(labelStart, reviewerStart);
+      } else {
+        labelVoteStr = footerLine.substring(labelStart);
+      }
+      rawPatchSetApproval.labelVote(labelVoteStr);
+
+      if (reviewerStart > 0) {
+        String ident = footerLine.substring(reviewerStart + 1);
+        rawPatchSetApproval.accountIdent(Optional.of(ident));
+      }
+      return rawPatchSetApproval.build();
+    } catch (StringIndexOutOfBoundsException ex) {
+      throw parseException(FOOTER_LABEL, footerLine, ex);
+    }
+  }
+
+  /**
+   * Parses copied {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}
+   * line.
+   *
+   * <p>Footer example: Copied-Label: <LABEL>=VOTE, <UUID> <Gerrit Account>,<Gerrit Real Account>
+   * :"<TAG>"
+   *
+   * <ul>
+   *   <li>":<"TAG>"" is optional.
+   *   <li><Gerrit Real Account> is also optional, if it was not set.
+   *   <li><UUID> is optional, since the approval might have been granted before {@link
+   *       com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
+   *   <li>The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
+   *       Account is also optional since by default it's the committer).
+   * </ul>
+   *
+   * <p>Footer example for removal: Copied-Label: -<LABEL> <Gerrit Account>,<Gerrit Real Account>
+   *
+   * <ul>
+   *   <li><Gerrit Real Account> is also optional, if it was not set.
+   * </ul>
+   */
+  public static ParsedPatchSetApproval parseCopiedApproval(String labelLine)
+      throws ConfigInvalidException {
+    try {
+      ParsedPatchSetApproval.Builder rawPatchSetApproval =
+          ParsedPatchSetApproval.builder().footerLine(labelLine);
+
+      boolean isRemoval = labelLine.startsWith("-");
+      rawPatchSetApproval.isRemoval(isRemoval);
+      int labelStart = isRemoval ? 1 : 0;
+      int uuidStart = isRemoval ? -1 : labelLine.indexOf(", ");
+      int tagStart = isRemoval ? -1 : labelLine.indexOf(":\"");
+
+      checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, labelLine);
+
+      // Weird tag that contains uuid delimiter. The uuid is actually not present.
+      if (tagStart != -1 && uuidStart > tagStart) {
+        uuidStart = -1;
+      }
+
+      int identitiesStart = labelLine.indexOf(' ', uuidStart != -1 ? uuidStart + 2 : 0);
+      checkFooter(
+          identitiesStart != -1 && identitiesStart < labelLine.length(),
+          FOOTER_COPIED_LABEL,
+          labelLine);
+
+      String labelVoteStr =
+          labelLine.substring(labelStart, uuidStart != -1 ? uuidStart : identitiesStart);
+      rawPatchSetApproval.labelVote(labelVoteStr);
+      if (uuidStart != -1) {
+        String uuid = labelLine.substring(uuidStart + 2, identitiesStart);
+        checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_COPIED_LABEL, labelLine);
+        rawPatchSetApproval.uuid(Optional.of(uuid));
+      }
+      // The first account is the accountId, and second (if applicable) is the realAccountId.
+      List<String> identities =
+          Splitter.on(',')
+              .splitToList(
+                  labelLine.substring(
+                      identitiesStart + 1, tagStart == -1 ? labelLine.length() : tagStart));
+      checkFooter(identities.size() >= 1, FOOTER_COPIED_LABEL, labelLine);
+
+      rawPatchSetApproval.accountIdent(Optional.of(identities.get(0)));
+
+      if (identities.size() > 1) {
+        rawPatchSetApproval.realAccountIdent(Optional.of(identities.get(1)));
+      }
+
+      if (tagStart != -1) {
+        // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
+        // line.length()-1 skips the last ".
+        String tag = labelLine.substring(tagStart + 2, labelLine.length() - 1);
+        rawPatchSetApproval.tag(Optional.of(tag));
+      }
+      return rawPatchSetApproval.build();
+    } catch (StringIndexOutOfBoundsException ex) {
+      throw parseException(FOOTER_COPIED_LABEL, labelLine, ex);
+    }
+  }
+
+  private static void checkFooter(boolean expr, FooterKey footer, String actual)
+      throws ConfigInvalidException {
+    if (!expr) {
+      throw parseException(footer, actual, /*cause=*/ null);
+    }
+  }
+
+  private static ConfigInvalidException parseException(
+      FooterKey footer, String actual, Throwable cause) {
+    return new ConfigInvalidException(
+        String.format("invalid %s: %s", footer.getName(), actual), cause);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 5cf3a64..77d1c8f 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -15,36 +15,38 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COPIED_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REVERT_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBJECT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMISSION_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
 import static java.util.Comparator.comparing;
+import static java.util.Comparator.comparingInt;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
@@ -70,19 +72,23 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Label.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.notedb.ChangeNotesParseApprovalUtil.ParsedPatchSetApproval;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -95,6 +101,7 @@
 import java.util.Set;
 import java.util.TreeSet;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -104,9 +111,37 @@
 import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.util.RawParseUtils;
 
+/**
+ * Parses {@link ChangeNotesState} out of the change meta ref.
+ *
+ * <p>NOTE: all changes to the change notes storage format must be both forward and backward
+ * compatible, i.e.:
+ *
+ * <ul>
+ *   <li>The server, running the new binary version must be able to parse the data, written by the
+ *       previous binary version.
+ *   <li>The server, running the old binary version must be able to parse the data, written by the
+ *       new binary version.
+ * </ul>
+ *
+ * <p>Thus, when introducing storage format update, the following procedure must be used:
+ *
+ * <ol>
+ *   <li>The read path ({@link ChangeNotesParser}) needs to be updated to handle both the old and
+ *       the new data format.
+ *   <li>In a separate change, the write path (e.g. {@link ChangeUpdate}, {@link ChangeNoteJson}) is
+ *       updated to write the new format, guarded by {@link
+ *       com.google.gerrit.server.experiments.ExperimentFeatures} flag, if possible.
+ *   <li>Once the 'read' change is roll out and is roll back safe, the 'write' change can be
+ *       submitted/the experiment flag can be flipped.
+ * </ol>
+ */
 class ChangeNotesParser {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Splitter RULE_SPLITTER = Splitter.on(": ");
+  private static final Splitter HASHTAG_SPLITTER = Splitter.on(",");
+
   // Private final members initialized in the constructor.
   private final ChangeNoteJson changeNoteJson;
   private final NoteDbMetrics metrics;
@@ -116,8 +151,8 @@
 
   // Private final but mutable members initialized in the constructor and filled
   // in during the parsing process.
-  private final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
-  private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
+  private final Table<Account.Id, ReviewerStateInternal, Instant> reviewers;
+  private final Table<Address, ReviewerStateInternal, Instant> reviewersByEmail;
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   /** Holds only the most recent update per user. Older updates are discarded. */
@@ -142,8 +177,8 @@
   private Change.Status status;
   private String topic;
   private Set<String> hashtags;
-  private Timestamp createdOn;
-  private Timestamp lastUpdatedOn;
+  private Instant createdOn;
+  private Instant lastUpdatedOn;
   private Account.Id ownerId;
   private String serverId;
   private String changeId;
@@ -164,19 +199,25 @@
   // 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;
+  private Instant mergedOn;
+  private final ExternalIdCache externalIdCache;
+  private final String gerritServerId;
 
   ChangeNotesParser(
       Change.Id changeId,
       ObjectId tip,
       ChangeNotesRevWalk walk,
       ChangeNoteJson changeNoteJson,
-      NoteDbMetrics metrics) {
+      NoteDbMetrics metrics,
+      String gerritServerId,
+      ExternalIdCache externalIdCache) {
     this.id = changeId;
     this.tip = tip;
     this.walk = walk;
     this.changeNoteJson = changeNoteJson;
     this.metrics = metrics;
+    this.externalIdCache = externalIdCache;
+    this.gerritServerId = gerritServerId;
     approvals = new LinkedHashMap<>();
     bufferedApprovals = new ArrayList<>();
     reviewers = HashBasedTable.create();
@@ -289,6 +330,7 @@
     return result;
   }
 
+  @Nullable
   private PatchSet.Id buildCurrentPatchSetId() {
     // currentPatchSets are in parse order, i.e. newest first. Pick the first
     // patch set that was marked as current, excluding deleted patch sets.
@@ -312,10 +354,81 @@
       }
       result.put(a.key().patchSetId(), a.build());
     }
+    if (status != null && status.isClosed() && !isAnyApprovalCopied(result)) {
+      // If the change is closed, check if there are "submit records" with approvals that do not
+      // exist on the latest patch-set and copy them to the latest patch-set.
+      // We do not invoke this logic if any approval is copied. This is because prior to change
+      // https://gerrit-review.googlesource.com/c/gerrit/+/318135 we used to copy approvals
+      // dynamically (e.g. when requesting the change page). After that change, we started
+      // persisting copied votes in NoteDb, so we don't need to do this back-filling.
+      // Prior to that change (318135), we could've had changes with dynamically copied approvals
+      // that were merged in NoteDb but these approvals do not exist on the latest patch-set, so
+      // we need to back-fill these approvals.
+      PatchSet.Id latestPs = buildCurrentPatchSetId();
+      backFillMissingCopiedApprovalsFromSubmitRecords(result, latestPs).stream()
+          .forEach(a -> result.put(latestPs, a));
+    }
     result.keySet().forEach(k -> result.get(k).sort(ChangeNotes.PSA_BY_TIME));
     return result;
   }
 
+  /**
+   * Returns patch-set approvals that do not exist on the latest patch-set but for which a submit
+   * record exists in NoteDb when the change was merged.
+   */
+  private List<PatchSetApproval> backFillMissingCopiedApprovalsFromSubmitRecords(
+      ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals, @Nullable PatchSet.Id latestPs) {
+    List<PatchSetApproval> copiedApprovals = new ArrayList<>();
+    if (latestPs == null) {
+      return copiedApprovals;
+    }
+    List<PatchSetApproval> approvalsOnLatestPs = allApprovals.get(latestPs);
+    ListMultimap<Account.Id, PatchSetApproval> approvalsByUser = getApprovalsByUser(allApprovals);
+    List<SubmitRecord.Label> submitRecordLabels =
+        submitRecords.stream()
+            .filter(r -> r.labels != null)
+            .flatMap(r -> r.labels.stream())
+            .filter(label -> Status.OK.equals(label.status) || Status.MAY.equals(label.status))
+            .collect(Collectors.toList());
+    for (SubmitRecord.Label recordLabel : submitRecordLabels) {
+      String labelName = recordLabel.label;
+      Account.Id appliedBy = recordLabel.appliedBy;
+      if (appliedBy == null || labelName == null) {
+        continue;
+      }
+      boolean existsAtLatestPs =
+          approvalsOnLatestPs.stream()
+              .anyMatch(a -> a.accountId().equals(appliedBy) && a.label().equals(labelName));
+      if (existsAtLatestPs) {
+        continue;
+      }
+      // Search for an approval for this label on the max previous patch-set and copy the approval.
+      Collection<PatchSetApproval> userApprovals =
+          approvalsByUser.get(appliedBy).stream()
+              .filter(approval -> approval.label().equals(labelName))
+              .collect(Collectors.toList());
+      if (userApprovals.isEmpty()) {
+        continue;
+      }
+      PatchSetApproval lastApproved =
+          Collections.max(userApprovals, comparingInt(a -> a.patchSetId().get()));
+      copiedApprovals.add(lastApproved.copyWithPatchSet(latestPs));
+    }
+    return copiedApprovals;
+  }
+
+  private boolean isAnyApprovalCopied(ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+    return allApprovals.values().stream().anyMatch(approval -> approval.copied());
+  }
+
+  private ListMultimap<Account.Id, PatchSetApproval> getApprovalsByUser(
+      ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals) {
+    return allApprovals.values().stream()
+        .collect(
+            ImmutableListMultimap.toImmutableListMultimap(
+                PatchSetApproval::accountId, Function.identity()));
+  }
+
   private List<ReviewerStatusUpdate> buildReviewerUpdates() {
     List<ReviewerStatusUpdate> result = new ArrayList<>();
     HashMap<Account.Id, ReviewerStateInternal> lastState = new HashMap<>();
@@ -333,7 +446,7 @@
   }
 
   private void parse(ChangeNotesCommit commit) throws ConfigInvalidException {
-    Timestamp commitTimestamp = getCommitTimestamp(commit);
+    Instant commitTimestamp = getCommitTimestamp(commit);
 
     createdOn = commitTimestamp;
     parseTag(commit);
@@ -387,7 +500,7 @@
 
     parseSubmission(commit, commitTimestamp);
 
-    if (lastUpdatedOn == null || commitTimestamp.after(lastUpdatedOn)) {
+    if (lastUpdatedOn == null || commitTimestamp.isAfter(lastUpdatedOn)) {
       lastUpdatedOn = commitTimestamp;
     }
 
@@ -449,7 +562,7 @@
     }
   }
 
-  private void parseSubmission(ChangeNotesCommit commit, Timestamp commitTimestamp)
+  private void parseSubmission(ChangeNotesCommit commit, Instant commitTimestamp)
       throws ConfigInvalidException {
     // Only parse the most recent sumbit commit (there should be exactly one).
     if (submissionId == null) {
@@ -471,6 +584,7 @@
     return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
   }
 
+  @Nullable
   private String parseBranch(ChangeNotesCommit commit) throws ConfigInvalidException {
     String branch = parseOneFooter(commit, FOOTER_BRANCH);
     return branch != null ? RefNames.fullName(branch) : null;
@@ -498,6 +612,7 @@
     return parseOneFooter(commit, FOOTER_TOPIC);
   }
 
+  @Nullable
   private String parseOneFooter(ChangeNotesCommit commit, FooterKey footerKey)
       throws ConfigInvalidException {
     List<String> footerLines = commit.getFooterLineValues(footerKey);
@@ -518,6 +633,7 @@
     return line;
   }
 
+  @Nullable
   private ObjectId parseRevision(ChangeNotesCommit commit) throws ConfigInvalidException {
     String sha = parseOneFooter(commit, FOOTER_COMMIT);
     if (sha == null) {
@@ -532,7 +648,7 @@
     }
   }
 
-  private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Timestamp ts)
+  private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Instant ts)
       throws ConfigInvalidException {
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
@@ -605,7 +721,7 @@
     } else if (hashtagsLines.get(0).isEmpty()) {
       hashtags = ImmutableSet.of();
     } else {
-      hashtags = Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
+      hashtags = Sets.newHashSet(HASHTAG_SPLITTER.split(hashtagsLines.get(0)));
     }
   }
 
@@ -627,7 +743,7 @@
     }
   }
 
-  private void parseAssigneeUpdates(Timestamp ts, ChangeNotesCommit commit)
+  private void parseAssigneeUpdates(Instant ts, ChangeNotesCommit commit)
       throws ConfigInvalidException {
     String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
     if (assigneeValue != null) {
@@ -655,6 +771,7 @@
     }
   }
 
+  @Nullable
   private Change.Status parseStatus(ChangeNotesCommit commit) throws ConfigInvalidException {
     List<String> statusLines = commit.getFooterLineValues(FOOTER_STATUS);
     if (statusLines.isEmpty()) {
@@ -693,6 +810,7 @@
     return PatchSet.id(id, psId);
   }
 
+  @Nullable
   private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
     String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
     int s = psIdLine.indexOf(' ');
@@ -737,7 +855,7 @@
       Account.Id accountId,
       Account.Id realAccountId,
       ChangeNotesCommit commit,
-      Timestamp ts) {
+      Instant ts) {
     Optional<String> changeMsgString = getChangeMessageString(commit);
     if (!changeMsgString.isPresent()) {
       return false;
@@ -783,8 +901,28 @@
       for (HumanComment c : e.getValue().getEntities()) {
         humanComments.put(e.getKey(), c);
       }
-      for (SubmitRequirementResult sr : e.getValue().getSubmitRequirementsResult()) {
-        submitRequirementResults.add(sr);
+    }
+
+    // Lookup submit requirement results from the revision notes of the last PS that has stored
+    // submit requirements. This is important for cases where the change was abandoned/un-abandoned
+    // multiple times. With each abandon, we store submit requirement results in NoteDb, so we can
+    // end up having stored SRs in many revision notes. We should only return SRs from the last
+    // PS of them.
+    for (PatchSet.Builder ps :
+        patchSets.values().stream()
+            .sorted(comparingInt((PatchSet.Builder p) -> p.id().get()).reversed())
+            .collect(Collectors.toList())) {
+      Optional<ObjectId> maybePsCommitId = ps.commitId();
+      if (!maybePsCommitId.isPresent()) {
+        continue;
+      }
+      ObjectId psCommitId = maybePsCommitId.get();
+      if (rns.containsKey(psCommitId)
+          && rns.get(psCommitId).getSubmitRequirementsResult() != null) {
+        rns.get(psCommitId)
+            .getSubmitRequirementsResult()
+            .forEach(sr -> submitRequirementResults.add(sr));
+        break;
       }
     }
 
@@ -801,61 +939,53 @@
     }
   }
 
-  // Footer example: Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
-  // ":<"TAG>"" is optional. <Gerrit Real Account> is also optional, if it was not set.
-  // The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
-  // Account is also optional since by default it's the committer).
-  private void parseCopiedApproval(PatchSet.Id psId, Timestamp ts, String line)
+  /** Parses copied {@link PatchSetApproval}. */
+  private void parseCopiedApproval(PatchSet.Id psId, Instant ts, String line)
       throws ConfigInvalidException {
-    // Copied approvals can't be explicitly removed. They are removed the same way as non-copied
-    // approvals.
-    checkFooter(!line.startsWith("-"), FOOTER_COPIED_LABEL, line);
+    ParsedPatchSetApproval parsedPatchSetApproval =
+        ChangeNotesParseApprovalUtil.parseCopiedApproval(line);
+    checkFooter(
+        parsedPatchSetApproval.accountIdent().isPresent(),
+        FOOTER_COPIED_LABEL,
+        parsedPatchSetApproval.footerLine());
+    PersonIdent accountIdent =
+        RawParseUtils.parsePersonIdent(parsedPatchSetApproval.accountIdent().get());
 
-    Account.Id accountId, realAccountId = null;
-    String labelVoteStr;
-    String tag = null;
-    int s = line.indexOf(' ');
-    int tagStart = line.indexOf(":\"");
+    checkFooter(accountIdent != null, FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
+    Account.Id accountId = parseIdent(accountIdent);
 
-    // The first account is the accountId, and second (if applicable) is the realAccountId.
-    try {
-      labelVoteStr = line.substring(0, s);
-    } catch (StringIndexOutOfBoundsException ex) {
-      throw new ConfigInvalidException(ex.getMessage(), ex);
-    }
-    String[] identities =
-        line.substring(s + 1, tagStart == -1 ? line.length() : tagStart).split(",");
-    PersonIdent ident = RawParseUtils.parsePersonIdent(identities[0]);
-    checkFooter(ident != null, FOOTER_COPIED_LABEL, line);
-    accountId = parseIdent(ident);
-
-    if (identities.length > 1) {
-      PersonIdent realIdent = RawParseUtils.parsePersonIdent(identities[1]);
-      checkFooter(realIdent != null, FOOTER_COPIED_LABEL, line);
+    Account.Id realAccountId = null;
+    if (parsedPatchSetApproval.realAccountIdent().isPresent()) {
+      PersonIdent realIdent =
+          RawParseUtils.parsePersonIdent(parsedPatchSetApproval.realAccountIdent().get());
+      checkFooter(realIdent != null, FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
       realAccountId = parseIdent(realIdent);
     }
 
-    LabelVote l;
+    LabelVote labelVote;
     try {
-      l = LabelVote.parseWithEquals(labelVoteStr);
+      if (!parsedPatchSetApproval.isRemoval()) {
+        labelVote = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
+      } else {
+        String labelName = parsedPatchSetApproval.labelVote();
+        LabelType.checkNameInternal(labelName);
+        labelVote = LabelVote.create(labelName, (short) 0);
+      }
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_COPIED_LABEL, line);
+      ConfigInvalidException pe =
+          parseException(
+              "invalid %s: %s", FOOTER_COPIED_LABEL, parsedPatchSetApproval.footerLine());
       pe.initCause(e);
       throw pe;
     }
 
-    if (tagStart != -1) {
-      // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
-      // line.length()-1 skips the last ".
-      tag = line.substring(tagStart + 2, line.length() - 1);
-    }
-
     PatchSetApproval.Builder psa =
         PatchSetApproval.builder()
-            .key(PatchSetApproval.key(psId, accountId, LabelId.create(l.label())))
-            .value(l.value())
+            .key(PatchSetApproval.key(psId, accountId, LabelId.create(labelVote.label())))
+            .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
+            .value(labelVote.value())
             .granted(ts)
-            .tag(Optional.ofNullable(tag))
+            .tag(parsedPatchSetApproval.tag())
             .copied(true);
     if (realAccountId != null) {
       psa.realAccountId(realAccountId);
@@ -865,60 +995,47 @@
   }
 
   private void parseApproval(
-      PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId, Timestamp ts, String line)
+      PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId, Instant ts, String line)
       throws ConfigInvalidException {
     if (accountId == null) {
       throw parseException("patch set %s requires an identified user as uploader", psId.get());
     }
     PatchSetApproval.Builder psa;
+    ParsedPatchSetApproval parsedPatchSetApproval =
+        ChangeNotesParseApprovalUtil.parseApproval(line);
     if (line.startsWith("-")) {
-      psa = parseRemoveApproval(psId, accountId, realAccountId, ts, line);
+      psa = parseRemoveApproval(psId, accountId, realAccountId, ts, parsedPatchSetApproval);
     } else {
-      psa = parseAddApproval(psId, accountId, realAccountId, ts, line);
+      psa = parseAddApproval(psId, accountId, realAccountId, ts, parsedPatchSetApproval);
     }
     bufferedApprovals.add(psa);
   }
 
+  /** Parses {@link PatchSetApproval} out of the {@link ChangeNoteFooters#FOOTER_LABEL} value. */
   private PatchSetApproval.Builder parseAddApproval(
-      PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
+      PatchSet.Id psId,
+      Account.Id committerId,
+      Account.Id realAccountId,
+      Instant ts,
+      ParsedPatchSetApproval parsedPatchSetApproval)
       throws ConfigInvalidException {
-    // There are potentially 3 accounts involved here:
-    //  1. The account from the commit, which is the effective IdentifiedUser
-    //     that produced the update.
-    //  2. The account in the label footer itself, which is used during submit
-    //     to copy other users' labels to a new patch set.
-    //  3. The account in the Real-user footer, indicating that the whole
-    //     update operation was executed by this user on behalf of the effective
-    //     user.
-    Account.Id effectiveAccountId;
-    String labelVoteStr;
-    int s = line.indexOf(' ');
-    if (s > 0) {
-      // Account in the label line (2) becomes the effective ID of the
-      // approval. If there is a real user (3) different from the commit user
-      // (2), we actually don't store that anywhere in this case; it's more
-      // important to record that the real user (3) actually initiated submit.
-      labelVoteStr = line.substring(0, s);
-      PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
-      checkFooter(ident != null, FOOTER_LABEL, line);
-      effectiveAccountId = parseIdent(ident);
-    } else {
-      labelVoteStr = line;
-      effectiveAccountId = committerId;
-    }
+
+    Account.Id approverId = parseApprover(committerId, parsedPatchSetApproval);
 
     LabelVote l;
     try {
-      l = LabelVote.parseWithEquals(labelVoteStr);
+      l = LabelVote.parseWithEquals(parsedPatchSetApproval.labelVote());
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_LABEL, line);
+      ConfigInvalidException pe =
+          parseException("invalid %s: %s", FOOTER_LABEL, parsedPatchSetApproval.footerLine());
       pe.initCause(e);
       throw pe;
     }
 
     PatchSetApproval.Builder psa =
         PatchSetApproval.builder()
-            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(l.label())))
+            .key(PatchSetApproval.key(psId, approverId, LabelId.create(l.label())))
+            .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
             .value(l.value())
             .granted(ts)
             .tag(Optional.ofNullable(tag));
@@ -930,26 +1047,24 @@
   }
 
   private PatchSetApproval.Builder parseRemoveApproval(
-      PatchSet.Id psId, Account.Id committerId, Account.Id realAccountId, Timestamp ts, String line)
+      PatchSet.Id psId,
+      Account.Id committerId,
+      Account.Id realAccountId,
+      Instant ts,
+      ParsedPatchSetApproval parsedPatchSetApproval)
       throws ConfigInvalidException {
-    // See comments in parseAddApproval about the various users involved.
-    Account.Id effectiveAccountId;
-    String label;
-    int s = line.indexOf(' ');
-    if (s > 0) {
-      label = line.substring(1, s);
-      PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
-      checkFooter(ident != null, FOOTER_LABEL, line);
-      effectiveAccountId = parseIdent(ident);
-    } else {
-      label = line.substring(1);
-      effectiveAccountId = committerId;
-    }
+
+    checkFooter(
+        parsedPatchSetApproval.footerLine().startsWith("-"),
+        FOOTER_LABEL,
+        parsedPatchSetApproval.footerLine());
+    Account.Id approverId = parseApprover(committerId, parsedPatchSetApproval);
 
     try {
-      LabelType.checkNameInternal(label);
+      LabelType.checkNameInternal(parsedPatchSetApproval.labelVote());
     } catch (IllegalArgumentException e) {
-      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_LABEL, line);
+      ConfigInvalidException pe =
+          parseException("invalid %s: %s", FOOTER_LABEL, parsedPatchSetApproval.footerLine());
       pe.initCause(e);
       throw pe;
     }
@@ -958,7 +1073,9 @@
     // needs an actual approval in order to block copying an earlier approval over a later delete.
     PatchSetApproval.Builder remove =
         PatchSetApproval.builder()
-            .key(PatchSetApproval.key(psId, effectiveAccountId, LabelId.create(label)))
+            .key(
+                PatchSetApproval.key(
+                    psId, approverId, LabelId.create(parsedPatchSetApproval.labelVote())))
             .value(0)
             .granted(ts);
     if (!Objects.equals(realAccountId, committerId)) {
@@ -968,6 +1085,30 @@
     return remove;
   }
 
+  /**
+   * Identifies the {@link com.google.gerrit.entities.Account.Id} that issued the vote.
+   *
+   * <p>There are potentially 3 accounts involved here: 1. The account from the commit, which is the
+   * effective IdentifiedUser that produced the update. 2. The account in the label footer itself,
+   * which is used during submit to copy other users' labels to a new patch set. 3. The account in
+   * the Real-user footer, indicating that the whole update operation was executed by this user on
+   * behalf of the effective user.
+   */
+  private Account.Id parseApprover(
+      Account.Id committerId, ParsedPatchSetApproval parsedPatchSetApproval)
+      throws ConfigInvalidException {
+    Account.Id effectiveAccountId;
+    if (parsedPatchSetApproval.accountIdent().isPresent()) {
+      PersonIdent ident =
+          RawParseUtils.parsePersonIdent(parsedPatchSetApproval.accountIdent().get());
+      checkFooter(ident != null, FOOTER_LABEL, parsedPatchSetApproval.footerLine());
+      effectiveAccountId = parseIdent(ident);
+    } else {
+      effectiveAccountId = committerId;
+    }
+    return effectiveAccountId;
+  }
+
   private void parseSubmitRecords(List<String> lines) throws ConfigInvalidException {
     SubmitRecord rec = null;
 
@@ -986,7 +1127,7 @@
       } else {
         checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
         if (line.startsWith("Rule-Name: ")) {
-          String ruleName = line.split(": ")[1];
+          String ruleName = RULE_SPLITTER.splitToList(line).get(1);
           rec.ruleName = ruleName;
           continue;
         }
@@ -1012,6 +1153,7 @@
     }
   }
 
+  @Nullable
   private Account.Id parseIdent(ChangeNotesCommit commit) throws ConfigInvalidException {
     // Check if the author name/email is the same as the committer name/email,
     // i.e. was the server ident at the time this commit was made.
@@ -1023,7 +1165,7 @@
     return parseIdent(a);
   }
 
-  private void parseReviewer(Timestamp ts, ReviewerStateInternal state, String line)
+  private void parseReviewer(Instant ts, ReviewerStateInternal state, String line)
       throws ConfigInvalidException {
     PersonIdent ident = RawParseUtils.parsePersonIdent(line);
     if (ident == null) {
@@ -1036,7 +1178,7 @@
     }
   }
 
-  private void parseReviewerByEmail(Timestamp ts, ReviewerStateInternal state, String line)
+  private void parseReviewerByEmail(Instant ts, ReviewerStateInternal state, String line)
       throws ConfigInvalidException {
     Address adr;
     try {
@@ -1097,6 +1239,7 @@
     throw invalidFooter(FOOTER_WORK_IN_PROGRESS, raw);
   }
 
+  @Nullable
   private Change.Id parseRevertOf(ChangeNotesCommit commit) throws ConfigInvalidException {
     String footer = parseOneFooter(commit, FOOTER_REVERT_OF);
     if (footer == null) {
@@ -1110,7 +1253,7 @@
   }
 
   /**
-   * Parses {@link ChangeNoteUtil#FOOTER_CHERRY_PICK_OF} of the commit.
+   * Parses {@link ChangeNoteFooters#FOOTER_CHERRY_PICK_OF} of the commit.
    *
    * @param commit the commit to parse.
    * @return {@link Optional} value of the parsed footer or {@code null} if the footer is missing in
@@ -1150,15 +1293,15 @@
    * @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 Instant getCommitTimestamp(ChangeNotesCommit commit) {
+    return commit.getCommitterIdent().getWhenAsInstant();
   }
 
   private void pruneReviewers() {
-    Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
+    Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Instant>> rit =
         reviewers.cellSet().iterator();
     while (rit.hasNext()) {
-      Table.Cell<Account.Id, ReviewerStateInternal, Timestamp> e = rit.next();
+      Table.Cell<Account.Id, ReviewerStateInternal, Instant> e = rit.next();
       if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
         rit.remove();
       }
@@ -1166,10 +1309,10 @@
   }
 
   private void pruneReviewersByEmail() {
-    Iterator<Table.Cell<Address, ReviewerStateInternal, Timestamp>> rit =
+    Iterator<Table.Cell<Address, ReviewerStateInternal, Instant>> rit =
         reviewersByEmail.cellSet().iterator();
     while (rit.hasNext()) {
-      Table.Cell<Address, ReviewerStateInternal, Timestamp> e = rit.next();
+      Table.Cell<Address, ReviewerStateInternal, Instant> e = rit.next();
       if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
         rit.remove();
       }
@@ -1280,7 +1423,7 @@
   }
 
   private Account.Id parseIdent(PersonIdent ident) throws ConfigInvalidException {
-    return NoteDbUtil.parseIdent(ident)
+    return NoteDbUtil.parseIdent(ident, gerritServerId, externalIdCache)
         .orElseThrow(
             () -> parseException("cannot retrieve account id: %s", ident.getEmailAddress()));
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 4d6b9cf..b0079d7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -65,7 +65,6 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.index.change.ChangeField.StoredSubmitRecord;
 import com.google.gson.Gson;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
@@ -103,8 +102,8 @@
       ObjectId metaId,
       Change.Id changeId,
       Change.Key changeKey,
-      Timestamp createdOn,
-      Timestamp lastUpdatedOn,
+      Instant createdOn,
+      Instant lastUpdatedOn,
       Account.Id owner,
       String serverId,
       String branch,
@@ -136,7 +135,7 @@
       @Nullable Change.Id revertOf,
       @Nullable PatchSet.Id cherryPickOf,
       int updateCount,
-      @Nullable Timestamp mergedOn) {
+      @Nullable Instant mergedOn) {
     requireNonNull(
         metaId,
         () ->
@@ -203,9 +202,9 @@
 
     abstract Change.Key changeKey();
 
-    abstract Timestamp createdOn();
+    abstract Instant createdOn();
 
-    abstract Timestamp lastUpdatedOn();
+    abstract Instant lastUpdatedOn();
 
     abstract Account.Id owner();
 
@@ -249,9 +248,9 @@
 
       abstract Builder changeKey(Change.Key changeKey);
 
-      abstract Builder createdOn(Timestamp createdOn);
+      abstract Builder createdOn(Instant createdOn);
 
-      abstract Builder lastUpdatedOn(Timestamp lastUpdatedOn);
+      abstract Builder lastUpdatedOn(Instant lastUpdatedOn);
 
       abstract Builder owner(Account.Id owner);
 
@@ -334,7 +333,7 @@
   abstract int updateCount();
 
   @Nullable
-  abstract Timestamp mergedOn();
+  abstract Instant mergedOn();
 
   Change newChange(Project.NameKey project) {
     ChangeColumns c = requireNonNull(columns(), "columns are required");
@@ -456,7 +455,7 @@
 
     abstract Builder updateCount(int updateCount);
 
-    abstract Builder mergedOn(Timestamp mergedOn);
+    abstract Builder mergedOn(Instant mergedOn);
 
     abstract ChangeNotesState build();
   }
@@ -536,7 +535,7 @@
                       SubmitRequirementProtoConverter.INSTANCE.toProto(sr)));
       b.setUpdateCount(object.updateCount());
       if (object.mergedOn() != null) {
-        b.setMergedOnMillis(object.mergedOn().getTime());
+        b.setMergedOnMillis(object.mergedOn().toEpochMilli());
         b.setHasMergedOn(true);
       }
 
@@ -547,8 +546,8 @@
       ChangeColumnsProto.Builder b =
           ChangeColumnsProto.newBuilder()
               .setChangeKey(cols.changeKey().get())
-              .setCreatedOnMillis(cols.createdOn().getTime())
-              .setLastUpdatedOnMillis(cols.lastUpdatedOn().getTime())
+              .setCreatedOnMillis(cols.createdOn().toEpochMilli())
+              .setLastUpdatedOnMillis(cols.lastUpdatedOn().toEpochMilli())
               .setOwner(cols.owner().get())
               .setBranch(cols.branch());
       if (cols.currentPatchSetId() != null) {
@@ -581,26 +580,26 @@
     }
 
     private static ReviewerSetEntryProto toReviewerSetEntry(
-        Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c) {
+        Table.Cell<ReviewerStateInternal, Account.Id, Instant> c) {
       return ReviewerSetEntryProto.newBuilder()
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(c.getRowKey()))
           .setAccountId(c.getColumnKey().get())
-          .setTimestampMillis(c.getValue().getTime())
+          .setTimestampMillis(c.getValue().toEpochMilli())
           .build();
     }
 
     private static ReviewerByEmailSetEntryProto toReviewerByEmailSetEntry(
-        Table.Cell<ReviewerStateInternal, Address, Timestamp> c) {
+        Table.Cell<ReviewerStateInternal, Address, Instant> c) {
       return ReviewerByEmailSetEntryProto.newBuilder()
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(c.getRowKey()))
           .setAddress(c.getColumnKey().toHeaderString())
-          .setTimestampMillis(c.getValue().getTime())
+          .setTimestampMillis(c.getValue().toEpochMilli())
           .build();
     }
 
     private static ReviewerStatusUpdateProto toReviewerStatusUpdateProto(ReviewerStatusUpdate u) {
       return ReviewerStatusUpdateProto.newBuilder()
-          .setTimestampMillis(u.date().getTime())
+          .setTimestampMillis(u.date().toEpochMilli())
           .setUpdatedBy(u.updatedBy().get())
           .setReviewer(u.reviewer().get())
           .setState(REVIEWER_STATE_CONVERTER.reverse().convert(u.state()))
@@ -620,7 +619,7 @@
     private static AssigneeStatusUpdateProto toAssigneeStatusUpdateProto(AssigneeStatusUpdate u) {
       AssigneeStatusUpdateProto.Builder builder =
           AssigneeStatusUpdateProto.newBuilder()
-              .setTimestampMillis(u.date().getTime())
+              .setTimestampMillis(u.date().toEpochMilli())
               .setUpdatedBy(u.updatedBy().get())
               .setHasCurrentAssignee(u.currentAssignee().isPresent());
 
@@ -678,7 +677,8 @@
                       .map(sr -> SubmitRequirementProtoConverter.INSTANCE.fromProto(sr))
                       .collect(toImmutableList()))
               .updateCount(proto.getUpdateCount())
-              .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
+              .mergedOn(
+                  proto.getHasMergedOn() ? Instant.ofEpochMilli(proto.getMergedOnMillis()) : null);
       return b.build();
     }
 
@@ -686,8 +686,8 @@
       ChangeColumns.Builder b =
           ChangeColumns.builder()
               .changeKey(Change.key(proto.getChangeKey()))
-              .createdOn(new Timestamp(proto.getCreatedOnMillis()))
-              .lastUpdatedOn(new Timestamp(proto.getLastUpdatedOnMillis()))
+              .createdOn(Instant.ofEpochMilli(proto.getCreatedOnMillis()))
+              .lastUpdatedOn(Instant.ofEpochMilli(proto.getLastUpdatedOnMillis()))
               .owner(Account.id(proto.getOwner()))
               .branch(proto.getBranch());
       if (proto.getHasCurrentPatchSetId()) {
@@ -719,26 +719,25 @@
     }
 
     private static ReviewerSet toReviewerSet(List<ReviewerSetEntryProto> protos) {
-      ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
+      ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Instant> b =
           ImmutableTable.builder();
       for (ReviewerSetEntryProto e : protos) {
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
             Account.id(e.getAccountId()),
-            new Timestamp(e.getTimestampMillis()));
+            Instant.ofEpochMilli(e.getTimestampMillis()));
       }
       return ReviewerSet.fromTable(b.build());
     }
 
     private static ReviewerByEmailSet toReviewerByEmailSet(
         List<ReviewerByEmailSetEntryProto> protos) {
-      ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b =
-          ImmutableTable.builder();
+      ImmutableTable.Builder<ReviewerStateInternal, Address, Instant> b = ImmutableTable.builder();
       for (ReviewerByEmailSetEntryProto e : protos) {
         b.put(
             REVIEWER_STATE_CONVERTER.convert(e.getState()),
             Address.parse(e.getAddress()),
-            new Timestamp(e.getTimestampMillis()));
+            Instant.ofEpochMilli(e.getTimestampMillis()));
       }
       return ReviewerByEmailSet.fromTable(b.build());
     }
@@ -749,7 +748,7 @@
       for (ReviewerStatusUpdateProto proto : protos) {
         b.add(
             ReviewerStatusUpdate.create(
-                new Timestamp(proto.getTimestampMillis()),
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
                 Account.id(proto.getUpdatedBy()),
                 Account.id(proto.getReviewer()),
                 REVIEWER_STATE_CONVERTER.convert(proto.getState())));
@@ -791,7 +790,7 @@
       for (AssigneeStatusUpdateProto proto : protos) {
         b.add(
             AssigneeStatusUpdate.create(
-                new Timestamp(proto.getTimestampMillis()),
+                Instant.ofEpochMilli(proto.getTimestampMillis()),
                 Account.id(proto.getUpdatedBy()),
                 proto.getHasCurrentAssignee()
                     ? Optional.of(Account.id(proto.getCurrentAssignee()))
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index 44475db..6d49fc8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -17,6 +17,8 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayInputStream;
@@ -33,18 +35,29 @@
 /** Implements the parsing of comment data, handling JSON decoding and push certificates. */
 class ChangeRevisionNote extends RevisionNote<HumanComment> {
   private final ChangeNoteJson noteJson;
-  private final HumanComment.Status status;
+  private final Comment.Status status;
   private String pushCert;
 
-  private ImmutableList<SubmitRequirementResult> submitRequirementsResult;
+  /**
+   * Submit requirement results stored in this revision note. If null, then no SRs were stored in
+   * the revision note . Otherwise, there were stored SRs in this revision note. The list could be
+   * empty, meaning that no SRs were configured for the project.
+   */
+  @Nullable private ImmutableList<SubmitRequirementResult> submitRequirementsResult;
 
   ChangeRevisionNote(
-      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, HumanComment.Status status) {
+      ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, Comment.Status status) {
     super(reader, noteId);
     this.noteJson = noteJson;
     this.status = status;
   }
 
+  /**
+   * Returns null if no submit requirements were stored in the revision note. Otherwise, this method
+   * returns a list of submit requirements, which can probably be empty if there were no SRs
+   * configured for the project at the time when the SRs were stored.
+   */
+  @Nullable
   public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
     checkParsed();
     return submitRequirementsResult;
@@ -69,7 +82,7 @@
     }
     this.submitRequirementsResult =
         data.submitRequirementResults == null
-            ? ImmutableList.of()
+            ? null
             : ImmutableList.copyOf(data.submitRequirementResults);
     return data.comments;
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 5acea1b..5d43e28 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -18,29 +18,29 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COPIED_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REVERT_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBJECT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMISSION_ID;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.NoteDbUtil.sanitizeFooter;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.naturalOrder;
@@ -52,9 +52,12 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Table;
 import com.google.common.collect.Table.Cell;
 import com.google.common.collect.TreeBasedTable;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
@@ -63,6 +66,7 @@
 import com.google.gerrit.entities.Comment;
 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.Project;
 import com.google.gerrit.entities.RobotComment;
@@ -74,6 +78,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.gerrit.server.util.LabelVote;
@@ -81,10 +86,10 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Comparator;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -115,13 +120,20 @@
  * there is a single author and timestamp for each update.
  *
  * <p>This class is not thread-safe.
+ *
+ * <p>NOTE: This class also serializes the change in a custom storage format, used in NoteDB. All
+ * changes to the storage format must be both forward and backward compatible, see comment on {@link
+ * ChangeNotesParser}.
+ *
+ * <p>Such changes include e.g. introducing/removing footers, modifying footer formats, mutations of
+ * the attached {@link ChangeRevisionNote}.
  */
 public class ChangeUpdate extends AbstractChangeUpdate {
   public interface Factory {
-    ChangeUpdate create(ChangeNotes notes, CurrentUser user, Date when);
+    ChangeUpdate create(ChangeNotes notes, CurrentUser user, Instant when);
 
     ChangeUpdate create(
-        ChangeNotes notes, CurrentUser user, Date when, Comparator<String> labelNameComparator);
+        ChangeNotes notes, CurrentUser user, Instant when, Comparator<String> labelNameComparator);
   }
 
   private final NoteDbUpdateManager.Factory updateManagerFactory;
@@ -129,13 +141,13 @@
   private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
   private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
   private final ServiceUserClassifier serviceUserClassifier;
+  private final PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator;
 
-  private final Table<String, Account.Id, Optional<Short>> approvals;
+  private final Table<String, Account.Id, Optional<PatchSetApproval>> approvals;
   private final List<PatchSetApproval> copiedApprovals = new ArrayList<>();
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<HumanComment> comments = new ArrayList<>();
-  private final List<SubmitRequirementResult> submitRequirementResults = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -169,7 +181,12 @@
   private RobotCommentUpdate robotCommentUpdate;
   private DeleteCommentRewriter deleteCommentRewriter;
   private DeleteChangeMessageRewriter deleteChangeMessageRewriter;
+  private List<SubmitRequirementResult> submitRequirementResults;
 
+  private ImmutableList.Builder<AttentionSetUpdate> attentionSetUpdatesBuilder =
+      ImmutableList.builder();
+
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -179,9 +196,10 @@
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ProjectCache projectCache,
       ServiceUserClassifier serviceUserClassifier,
+      PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
-      @Assisted Date when,
+      @Assisted Instant when,
       ChangeNoteUtil noteUtil) {
     this(
         serverIdent,
@@ -190,6 +208,7 @@
         robotCommentUpdateFactory,
         deleteCommentRewriterFactory,
         serviceUserClassifier,
+        patchSetApprovalUuidGenerator,
         notes,
         user,
         when,
@@ -201,7 +220,7 @@
         noteUtil);
   }
 
-  private static Table<String, Account.Id, Optional<Short>> approvals(
+  private static Table<String, Account.Id, Optional<PatchSetApproval>> approvals(
       Comparator<String> nameComparator) {
     return TreeBasedTable.create(nameComparator, naturalOrder());
   }
@@ -214,9 +233,10 @@
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ServiceUserClassifier serviceUserClassifier,
+      PatchSetApprovalUuidGenerator patchSetApprovalUuidGenerator,
       @Assisted ChangeNotes notes,
       @Assisted CurrentUser user,
-      @Assisted Date when,
+      @Assisted Instant when,
       @Assisted Comparator<String> labelNameComparator,
       ChangeNoteUtil noteUtil) {
     super(notes, user, serverIdent, noteUtil, when);
@@ -225,6 +245,7 @@
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
     this.serviceUserClassifier = serviceUserClassifier;
+    this.patchSetApprovalUuidGenerator = patchSetApprovalUuidGenerator;
     this.approvals = approvals(labelNameComparator);
   }
 
@@ -266,7 +287,18 @@
   }
 
   public void putApprovalFor(Account.Id reviewer, String label, short value) {
-    approvals.put(label, reviewer, Optional.of(value));
+    PatchSetApproval psa =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(getPatchSetId(), reviewer, LabelId.create(label)))
+            .value(value)
+            .granted(when)
+            .uuid(patchSetApprovalUuidGenerator.get(getPatchSetId(), reviewer, label, value, when))
+            .build();
+    approvals.put(label, reviewer, Optional.of(psa));
+  }
+
+  public ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> getApprovals() {
+    return ImmutableTable.copyOf(approvals);
   }
 
   void removeApproval(String label) {
@@ -286,6 +318,23 @@
     copiedApprovals.add(copiedPatchSetApproval);
   }
 
+  public void removeCopiedApprovalFor(
+      @Nullable Account.Id realUserId, Account.Id reviewerId, String label) {
+    PatchSetApproval.Builder psaBuilder =
+        PatchSetApproval.builder()
+            .copied(true)
+            .key(PatchSetApproval.key(getPatchSetId(), reviewerId, LabelId.create(label)))
+            .value(0)
+            .uuid(Optional.empty())
+            .granted(when);
+
+    if (realUserId != null) {
+      psaBuilder.realAccountId(realUserId);
+    }
+
+    copiedApprovals.add(psaBuilder.build());
+  }
+
   public void merge(SubmissionId submissionId, Iterable<SubmitRecord> submitRecords) {
     this.status = Change.Status.MERGED;
     this.submissionId = submissionId.toString();
@@ -319,10 +368,13 @@
   }
 
   public void putSubmitRequirementResults(Collection<SubmitRequirementResult> rs) {
+    if (submitRequirementResults == null) {
+      submitRequirementResults = new ArrayList<>();
+    }
     submitRequirementResults.addAll(rs);
   }
 
-  public void putComment(HumanComment.Status status, HumanComment c) {
+  public void putComment(Comment.Status status, HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
     if (status == HumanComment.Status.DRAFT) {
@@ -408,12 +460,21 @@
   }
 
   /**
-   * All updates must have a timestamp of null since we use the commit's timestamp. There also must
-   * not be multiple updates for a single user. Only the first update takes place because of the
-   * different priorities: e.g, if we want to add someone to the attention set but also want to
-   * remove someone from the attention set, we should ensure to add/remove that user based on the
-   * priority of the addition and removal. If most importantly we want to remove the user, then we
-   * must first create the removal, and the addition will not take effect.
+   * Adds attention set updates that should be stored in NoteDb.
+   *
+   * <p>If invoked multiple times with attention set updates for the same user, only the attention
+   * set update of the first invocation is stored for this user and further attention set updates
+   * for this user are silently ignored. This means if callers invoke this method multiple times
+   * with attention set updates for the same user, they must ensure that the first call is being
+   * done with the attention set update that should take precedence.
+   *
+   * @param updates Attention set updates that should be performed. The updates must not have any
+   *     timestamp set ({@link AttentionSetUpdate#timestamp()} must return {@code null}). This is
+   *     because the timestamp of all performed updates is always the timestamp of when the NoteDb
+   *     commit is created. Each of the provided updates must be for a different user, if there are
+   *     multiple updates for the same user the update is rejected.
+   * @throws IllegalArgumentException thrown if any of the provided updates has a timestamp set, or
+   *     if the provided set of updates contains multiple updates for the same user
    */
   public void addToPlannedAttentionSetUpdates(Set<AttentionSetUpdate> updates) {
     if (updates == null || updates.isEmpty() || ignoreFurtherAttentionSetUpdates) {
@@ -445,6 +506,10 @@
     addToPlannedAttentionSetUpdates(ImmutableSet.of(update));
   }
 
+  public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+    return attentionSetUpdatesBuilder.build();
+  }
+
   public void setAssignee(Account.Id assignee) {
     checkArgument(assignee != null, "use removeAssignee");
     this.assignee = Optional.of(assignee);
@@ -506,9 +571,10 @@
   }
 
   /** Returns the tree id for the updated tree */
+  @Nullable
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
       throws ConfigInvalidException, IOException {
-    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults == null && comments.isEmpty() && pushCert == null) {
       return null;
     }
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
@@ -518,8 +584,23 @@
       c.tag = tag;
       cache.get(c.getCommitId()).putComment(c);
     }
-    for (SubmitRequirementResult sr : submitRequirementResults) {
-      cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr);
+    if (submitRequirementResults != null) {
+      if (submitRequirementResults.isEmpty()) {
+        ObjectId latestPsCommitId =
+            Iterables.getLast(getNotes().getPatchSets().values()).commitId();
+        cache.get(latestPsCommitId).createEmptySubmitRequirementResults();
+      } else {
+        // Clear any previously stored SRs first. The SRs in this update will overwrite any
+        // previously stored SRs (e.g. if the change is abandoned (SRs stored) -> un-abandoned ->
+        // merged).
+        submitRequirementResults.stream()
+            .map(SubmitRequirementResult::patchSetCommitId)
+            .distinct()
+            .forEach(commit -> cache.get(commit).clearSubmitRequirementResults());
+        for (SubmitRequirementResult sr : submitRequirementResults) {
+          cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr);
+        }
+      }
     }
     if (pushCert != null) {
       checkState(commit != null);
@@ -630,12 +711,12 @@
         deleteCommentRewriter == null && deleteChangeMessageRewriter == null,
         "cannot update and rewrite ref in one BatchUpdate");
 
-    int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
+    PatchSet.Id patchSetId = psId != null ? psId : getChange().currentPatchSetId();
     StringBuilder msg = new StringBuilder();
     if (commitSubject != null) {
       msg.append(commitSubject);
     } else {
-      msg.append("Update patch set ").append(ps);
+      msg.append("Update patch set ").append(patchSetId.get());
     }
     msg.append("\n\n");
 
@@ -644,7 +725,7 @@
       msg.append("\n\n");
     }
 
-    addPatchSetFooter(msg, ps);
+    addPatchSetFooter(msg, patchSetId);
 
     if (currentPatchSet) {
       addFooter(msg, FOOTER_CURRENT, Boolean.TRUE);
@@ -717,7 +798,7 @@
       addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
     }
 
-    for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
+    for (Table.Cell<String, Account.Id, Optional<PatchSetApproval>> c : approvals.cellSet()) {
       addLabelFooter(msg, c);
     }
     for (PatchSetApproval patchSetApproval : copiedApprovals) {
@@ -787,7 +868,10 @@
       }
     }
 
-    updateAttentionSet(msg);
+    boolean hasAttentionSeUpdates = updateAttentionSet(msg);
+    if (isEmptyWithoutAttentionSet() && !hasAttentionSeUpdates) {
+      return NO_OP_UPDATE;
+    }
 
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
@@ -802,36 +886,62 @@
     return cb;
   }
 
-  private void addLabelFooter(StringBuilder msg, Cell<String, Account.Id, Optional<Short>> c) {
+  private void addLabelFooter(
+      StringBuilder msg, Cell<String, Account.Id, Optional<PatchSetApproval>> c) {
     addFooter(msg, FOOTER_LABEL);
+    String label = c.getRowKey();
+    Account.Id reviewerId = c.getColumnKey();
     // Label names/values are safe to append without sanitizing.
-    if (!c.getValue().isPresent()) {
-      msg.append('-').append(c.getRowKey());
+    boolean isRemoval = !c.getValue().isPresent();
+    if (isRemoval) {
+      msg.append('-').append(label);
+      // Since vote removals do not need to be referenced, e.g. by the copy approvals, they do not
+      // require a UUID.
     } else {
-      msg.append(LabelVote.create(c.getRowKey(), c.getValue().get()).formatWithEquals());
+      short value = c.getValue().get().value();
+      msg.append(LabelVote.create(label, value).formatWithEquals());
+      msg.append(", ");
+      msg.append(c.getValue().get().uuid().get());
     }
-    Account.Id id = c.getColumnKey();
-    if (!id.equals(getAccountId())) {
-      noteUtil.appendAccountIdIdentString(msg.append(' '), id);
+    if (!reviewerId.equals(getAccountId())) {
+      noteUtil.appendAccountIdIdentString(msg.append(' '), reviewerId);
     }
     msg.append('\n');
   }
 
   private void addCopiedLabelFooter(StringBuilder msg, PatchSetApproval patchSetApproval) {
     if (patchSetApproval.value() == 0) {
-      // Can only happen if we removed a vote. There is no need to persist removed votes.
+      addFooter(msg, FOOTER_COPIED_LABEL);
+
+      // Mark the copied approval as deleted.
+      msg.append('-').append(patchSetApproval.label());
+
+      noteUtil.appendAccountIdIdentString(msg.append(' '), patchSetApproval.accountId());
+
+      // In the non-copied labels, we don't need to pass the real account id since it's already
+      // in FOOTER_REAL_USER. Here, we want to retain the original real account id.
+      if (!patchSetApproval.realAccountId().equals(patchSetApproval.accountId())) {
+        noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId());
+      }
+
+      msg.append('\n');
       return;
     }
     addFooter(msg, FOOTER_COPIED_LABEL);
     // Label names/values are safe to append without sanitizing.
     msg.append(
         LabelVote.create(patchSetApproval.label(), patchSetApproval.value()).formatWithEquals());
+    // Might be copied from the vote that was generated before UUID was introduced.
+    if (patchSetApproval.uuid().isPresent()) {
+      msg.append(", ");
+      msg.append(patchSetApproval.uuid().get());
+    }
     Account.Id id = patchSetApproval.accountId();
     noteUtil.appendAccountIdIdentString(msg.append(' '), id);
 
     // In the non-copied labels, we don't need to pass the real account id since it's already
     // in FOOTER_REAL_USER. Here, we want to retain the original real account id.
-    if (patchSetApproval.realAccountId() != null) {
+    if (!patchSetApproval.realAccountId().equals(patchSetApproval.accountId())) {
       noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId());
     }
 
@@ -840,6 +950,7 @@
     if (patchSetApproval.tag().isPresent()) {
       msg.append(":\"" + sanitizeFooter(patchSetApproval.tag().get()) + "\"");
     }
+
     msg.append('\n');
   }
 
@@ -863,11 +974,13 @@
       // be submitted or when the caller is a robot.
       return;
     }
+
+    Set<AttentionSetUpdate> updates = new HashSet<>();
     Set<Account.Id> currentReviewers =
         getNotes().getReviewers().byState(ReviewerStateInternal.REVIEWER);
-    Set<AttentionSetUpdate> updates = new HashSet<>();
     for (Map.Entry<Account.Id, ReviewerStateInternal> reviewer : reviewers.entrySet()) {
       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.
@@ -907,8 +1020,11 @@
    * <p>Changing the behaviour of this method might affect the way a ChangeUpdate is considered to
    * be an "Attention Set Change Only". Make sure the {@link #isAttentionSetChangeOnly} logic is
    * amended as well if needed.
+   *
+   * @return True if one or more attention set updates are appended to the {@code msg}, and false
+   *     otherwise.
    */
-  private void updateAttentionSet(StringBuilder msg) {
+  private boolean updateAttentionSet(StringBuilder msg) {
     if (plannedAttentionSetUpdates == null) {
       plannedAttentionSetUpdates = new HashMap<>();
     }
@@ -934,6 +1050,8 @@
 
     removeInactiveUsersFromAttentionSet(currentReviewers);
 
+    boolean hasUpdates = false;
+
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
           && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -968,7 +1086,10 @@
       }
 
       addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
+      attentionSetUpdatesBuilder.add(attentionSetUpdate);
+      hasUpdates = true;
     }
+    return hasUpdates;
   }
 
   private void removeInactiveUsersFromAttentionSet(Set<Account.Id> currentReviewers) {
@@ -1021,8 +1142,8 @@
     ignoreFurtherAttentionSetUpdates = true;
   }
 
-  private void addPatchSetFooter(StringBuilder sb, int ps) {
-    addFooter(sb, FOOTER_PATCH_SET).append(ps);
+  private void addPatchSetFooter(StringBuilder sb, PatchSet.Id ps) {
+    addFooter(sb, FOOTER_PATCH_SET).append(ps.get());
     if (psState != null) {
       sb.append(" (").append(psState.name().toLowerCase()).append(')');
     }
@@ -1036,6 +1157,10 @@
 
   @Override
   public boolean isEmpty() {
+    return isEmptyWithoutAttentionSet() && plannedAttentionSetUpdates == null;
+  }
+
+  private boolean isEmptyWithoutAttentionSet() {
     return commitSubject == null
         && approvals.isEmpty()
         && copiedApprovals.isEmpty()
@@ -1048,7 +1173,6 @@
         && status == null
         && submissionId == null
         && submitRecords == null
-        && plannedAttentionSetUpdates == null
         && assignee == null
         && hashtags == null
         && topic == null
diff --git a/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java b/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
index 2f47107..e74af5b 100644
--- a/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
+++ b/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
@@ -16,6 +16,7 @@
 
 import static java.time.format.DateTimeFormatter.ISO_INSTANT;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.gson.TypeAdapter;
 import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.JsonWriter;
@@ -27,7 +28,7 @@
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeParseException;
 import java.time.format.FormatStyle;
-import java.time.temporal.TemporalAccessor;
+import java.util.Locale;
 
 /**
  * Adapter that reads/writes {@link Timestamp}s as ISO 8601 instant in UTC.
@@ -49,6 +50,16 @@
   private static final DateTimeFormatter FALLBACK =
       DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
 
+  /**
+   * Fixed format to parse date/time in the "Feb 7, 2017 2:20:30 AM" format
+   *
+   * <p>Some old comments (created in Jan-Feb 2017) can be stored in legacy format, which can't be
+   * parsed with {@link #FALLBACK} formatter if the system/default locale has been changed. We will
+   * try to parse with a fixed format if {@link #FALLBACK} doesn't work.
+   */
+  private static final DateTimeFormatter FIXED_FORMAT_FALLBACK =
+      DateTimeFormatter.ofPattern("MMM d, yyyy h:mm:ss a").withLocale(Locale.US);
+
   @Override
   public void write(JsonWriter out, Timestamp ts) throws IOException {
     Timestamp truncated = new Timestamp(ts.getTime() / 1000 * 1000);
@@ -58,12 +69,26 @@
   @Override
   public Timestamp read(JsonReader in) throws IOException {
     String str = in.nextString();
-    TemporalAccessor ta;
     try {
-      ta = ISO_INSTANT.parse(str);
+      return Timestamp.from(Instant.from(ISO_INSTANT.parse(str)));
     } catch (DateTimeParseException e) {
-      ta = LocalDateTime.from(FALLBACK.parse(str)).atZone(ZoneId.systemDefault());
+      try {
+        return parseDateTimeWithDefaultLocaleFormat(str);
+      } catch (DateTimeParseException e2) {
+        return parseDateTimeWithFixedFormat(str);
+      }
     }
-    return Timestamp.from(Instant.from(ta));
+  }
+
+  public static Timestamp parseDateTimeWithDefaultLocaleFormat(String str) {
+    return Timestamp.from(
+        Instant.from(LocalDateTime.from(FALLBACK.parse(str)).atZone(ZoneId.systemDefault())));
+  }
+
+  @VisibleForTesting
+  public static Timestamp parseDateTimeWithFixedFormat(String str) {
+    return Timestamp.from(
+        Instant.from(
+            LocalDateTime.from(FIXED_FORMAT_FALLBACK.parse(str)).atZone(ZoneId.systemDefault())));
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 338b984..a67dc07 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -15,16 +15,18 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_PATTERN;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_REGEX;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -69,7 +71,7 @@
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.diff.DiffAlgorithm;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.diff.EditList;
@@ -110,6 +112,8 @@
 public class CommitRewriter {
   /** Options to run {@link #backfillProject}. */
   public static class RunOptions implements Serializable {
+    private static final long serialVersionUID = 1L;
+
     /** Whether to rewrite the commit history or only find refs that need to be fixed. */
     public boolean dryRun = true;
     /**
@@ -123,10 +127,9 @@
     /** Max number of refs to update in a single {@link BatchRefUpdate}. */
     public int maxRefsInBatch = 10000;
     /**
-     * Max number of refs to fix by a single {@link RefsUpdate#backfillProject} run. Since second
-     * run on the same set of refs is a no-op, running with this option in a loop will eventually
-     * fix all refs. Number of executed {@link BatchRefUpdate} depends on {@link #maxRefsInBatch}
-     * option.
+     * Max number of refs to fix by a single {@link RefsUpdate} run. Since the second run on the
+     * same set of refs is a no-op, running with this option in a loop will eventually fix all refs.
+     * The number of executed {@link BatchRefUpdate} depends on {@link #maxRefsInBatch} option.
      */
     public int maxRefsToUpdate = 50000;
   }
@@ -227,6 +230,8 @@
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final Splitter COMMIT_MESSAGE_SPLITTER = Splitter.onPattern("\\r?\\n");
+
   private final ChangeNotes.Factory changeNotesFactory;
   private final AccountCache accountCache;
   private final DiffAlgorithm diffAlgorithm = new HistogramDiff();
@@ -263,6 +268,8 @@
     BackfillResult result = new BackfillResult();
     result.ok = true;
     int refsInUpdate = 0;
+
+    @SuppressWarnings("resource")
     RefsUpdate refsUpdate = null;
     try {
       for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
@@ -349,7 +356,7 @@
   private ImmutableSet<AccountState> collectAccounts(ChangeNotes changeNotes) {
     Set<Account.Id> accounts = new HashSet<>();
     accounts.add(changeNotes.getChange().getOwner());
-    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().values()) {
+    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().all().values()) {
       if (patchSetApproval.accountId() != null) {
         accounts.add(patchSetApproval.accountId());
       }
@@ -474,7 +481,7 @@
           }
           detailedVerificationStatus.append("Commit author:\n");
           detailedVerificationStatus.append(fixedAuthorIdent.toString());
-          logger.atWarning().log(detailedVerificationStatus.toString());
+          logger.atWarning().log("%s", detailedVerificationStatus);
         }
       }
       boolean needsFix =
@@ -573,7 +580,7 @@
 
   private boolean verifyPersonIdent(PersonIdent newIdent, PersonIdent originalIdent) {
     return newIdent.getTimeZoneOffset() == originalIdent.getTimeZoneOffset()
-        && newIdent.getWhen().getTime() == originalIdent.getWhen().getTime()
+        && newIdent.getWhenAsInstant().equals(originalIdent.getWhenAsInstant())
         && newIdent.getEmailAddress().equals(originalIdent.getEmailAddress());
   }
 
@@ -687,15 +694,16 @@
         || !originalChangeMessage.startsWith(REMOVED_VOTES_CHANGE_MESSAGE_START)) {
       return Optional.empty();
     }
-    String[] lines = originalChangeMessage.split("\\r?\\n");
+    List<String> lines = COMMIT_MESSAGE_SPLITTER.splitToList(originalChangeMessage);
     StringBuilder fixedLines = new StringBuilder();
     boolean anyFixed = false;
-    for (int i = 1; i < lines.length; i++) {
-      if (lines[i].isEmpty()) {
+    for (int i = 1; i < lines.size(); i++) {
+      String line = lines.get(i);
+      if (line.isEmpty()) {
         continue;
       }
-      Matcher matcher = REMOVED_VOTES_CHANGE_MESSAGE_PATTERN.matcher(lines[i]);
-      String replacementLine = lines[i];
+      Matcher matcher = REMOVED_VOTES_CHANGE_MESSAGE_PATTERN.matcher(line);
+      String replacementLine = line;
       if (matcher.matches() && !NON_REPLACE_ACCOUNT_PATTERN.matcher(matcher.group(2)).matches()) {
         anyFixed = true;
         Optional<String> reviewerReplacement =
@@ -766,7 +774,7 @@
     // Pre fix, try to replace with something meaningful.
     // Retrieve reviewer accounts from cache and try to match by their name.
     onAddReviewerMatcher.reset();
-    StringBuffer sb = new StringBuffer();
+    StringBuilder sb = new StringBuilder();
     while (onAddReviewerMatcher.find()) {
       String reviewerName = normalizeOnCodeOwnerAddReviewerMatch(onAddReviewerMatcher.group(1));
       Optional<String> replacementName =
@@ -885,7 +893,7 @@
             commitMessageRange.get().subjectEnd());
     Optional<String> fixedChangeMessage = Optional.empty();
     String originalChangeMessage = null;
-    if (commitMessageRange.isPresent() && commitMessageRange.get().hasChangeMessage()) {
+    if (commitMessageRange.get().hasChangeMessage()) {
       originalChangeMessage =
           RawParseUtils.decode(
                   enc,
@@ -943,7 +951,8 @@
           continue;
         }
       } else if (footerKey.equalsIgnoreCase(FOOTER_LABEL.getName())) {
-        int voterIdentStart = footerValue.indexOf(' ');
+        int uuidStart = footerValue.indexOf(", ");
+        int voterIdentStart = footerValue.indexOf(' ', uuidStart != -1 ? uuidStart + 2 : 0);
         FixIdentResult fixedVoter = null;
         if (voterIdentStart > 0) {
           String originalIdentString = footerValue.substring(voterIdentStart + 1);
@@ -1176,7 +1185,8 @@
       // Filter further so we match both email & name
       if (possibleReplacements.size() > 1) {
         logger.atWarning().log(
-            "Fixing ref %s, multiple accounts found with the same email address, while replacing %s",
+            "Fixing ref %s, multiple accounts found with the same email address, while replacing"
+                + " %s",
             changeFixProgress.changeMetaRef, accountInfo);
         possibleReplacements =
             possibleReplacements.entrySet().stream()
@@ -1239,7 +1249,7 @@
       fmt.setContext(0);
       fmt.format(diff, oldBody, newBody);
       fmt.flush();
-      return out.toString();
+      return out.toString(UTF_8);
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index e8c0fda..76871a8 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -159,6 +159,7 @@
         HumanComment comment = curMap.get(key);
         if (key.equals(uuid)) {
           comment.message = newMessage;
+          comment.unresolved = false;
         }
         comments.add(comment);
       }
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 1ead03c..c8d93f8 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -15,19 +15,40 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.entities.RefNames.REFS_DRAFT_COMMENTS;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Sets.SetView;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.function.Consumer;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -39,16 +60,21 @@
  * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233">
  * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a>
  *
- * <p>An earlier bug in the deletion of draft comments {@code
- * refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain in Git
- * and not get deleted. These refs point to an empty tree.
+ * <p>The implementation has two cases for detecting zombie drafts:
+ *
+ * <ul>
+ *   <li>An earlier bug in the deletion of draft comments {@code
+ *       refs/draft-comments/$change_id_short/$change_id/$user_id} caused some draft refs to remain
+ *       in Git and not get deleted. These refs point to an empty tree. We delete such refs.
+ *   <li>Inspecting all draft-comment refs. Check for each draft if there exists a published comment
+ *       with the same UUID. These comments are called zombie drafts. If the program is run in
+ *       {@link #dryRun} mode, the zombie draft IDs will only be logged for tracking, otherwise they
+ *       will also be deleted.
+ * </uL>
  */
 public class DeleteZombieCommentsRefs {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String EMPTY_TREE_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
-  private static final String DRAFT_REFS_PREFIX = "refs/draft-comments";
-
   // Number of refs deleted at once in a batch ref-update.
   // Log progress after deleting every CHUNK_SIZE refs
   private static final int CHUNK_SIZE = 3000;
@@ -56,19 +82,73 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final int cleanupPercentage;
-  private Repository allUsersRepo;
+
+  /**
+   * Run the logic in dry run mode only. That is, detected zombie drafts will be logged only but not
+   * deleted. Creators of this class can use {@link Factory#create(int, boolean)} to specify the dry
+   * run mode. If {@link Factory#create(int)} is used, the dry run mode will be set to its default:
+   * true.
+   */
+  private final boolean dryRun;
+
   private final Consumer<String> uiConsumer;
+  @Nullable private final DraftCommentNotes.Factory draftNotesFactory;
+  @Nullable private final ChangeNotes.Factory changeNotesFactory;
+  @Nullable private final CommentsUtil commentsUtil;
+  @Nullable private final ChangeUpdate.Factory changeUpdateFactory;
+  @Nullable private final IdentifiedUser.GenericFactory userFactory;
 
   public interface Factory {
     DeleteZombieCommentsRefs create(int cleanupPercentage);
+
+    DeleteZombieCommentsRefs create(int cleanupPercentage, boolean dryRun);
   }
 
-  @Inject
+  @AssistedInject
   public DeleteZombieCommentsRefs(
       AllUsersName allUsers,
       GitRepositoryManager repoManager,
+      ChangeNotes.Factory changeNotesFactory,
+      DraftCommentNotes.Factory draftNotesFactory,
+      CommentsUtil commentsUtil,
+      ChangeUpdate.Factory changeUpdateFactory,
+      IdentifiedUser.GenericFactory userFactory,
       @Assisted Integer cleanupPercentage) {
-    this(allUsers, repoManager, cleanupPercentage, (msg) -> {});
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        /* dryRun= */ true,
+        (msg) -> {},
+        changeNotesFactory,
+        draftNotesFactory,
+        commentsUtil,
+        changeUpdateFactory,
+        userFactory);
+  }
+
+  @AssistedInject
+  public DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      ChangeNotes.Factory changeNotesFactory,
+      DraftCommentNotes.Factory draftNotesFactory,
+      CommentsUtil commentsUtil,
+      ChangeUpdate.Factory changeUpdateFactory,
+      IdentifiedUser.GenericFactory userFactory,
+      @Assisted Integer cleanupPercentage,
+      @Assisted boolean dryRun) {
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        dryRun,
+        (msg) -> {},
+        changeNotesFactory,
+        draftNotesFactory,
+        commentsUtil,
+        changeUpdateFactory,
+        userFactory);
   }
 
   public DeleteZombieCommentsRefs(
@@ -76,43 +156,252 @@
       GitRepositoryManager repoManager,
       Integer cleanupPercentage,
       Consumer<String> uiConsumer) {
+    this(
+        allUsers,
+        repoManager,
+        cleanupPercentage,
+        /* dryRun= */ false,
+        uiConsumer,
+        null,
+        null,
+        null,
+        null,
+        null);
+  }
+
+  private DeleteZombieCommentsRefs(
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      Integer cleanupPercentage,
+      boolean dryRun,
+      Consumer<String> uiConsumer,
+      @Nullable ChangeNotes.Factory changeNotesFactory,
+      @Nullable DraftCommentNotes.Factory draftNotesFactory,
+      @Nullable CommentsUtil commentsUtil,
+      @Nullable ChangeUpdate.Factory changeUpdateFactory,
+      @Nullable IdentifiedUser.GenericFactory userFactory) {
     this.allUsers = allUsers;
     this.repoManager = repoManager;
     this.cleanupPercentage = (cleanupPercentage == null) ? 100 : cleanupPercentage;
+    this.dryRun = dryRun;
     this.uiConsumer = uiConsumer;
+    this.draftNotesFactory = draftNotesFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.commentsUtil = commentsUtil;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.userFactory = userFactory;
   }
 
   public void execute() throws IOException {
-    allUsersRepo = repoManager.openRepository(allUsers);
-
-    List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(DRAFT_REFS_PREFIX);
-    List<Ref> zombieRefs = filterZombieRefs(draftRefs);
-
-    logInfo(
-        String.format(
-            "Found a total of %d zombie draft refs in %s repo.",
-            zombieRefs.size(), allUsers.get()));
-
-    logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
-    zombieRefs =
-        zombieRefs.stream()
-            .filter(ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
-            .collect(toImmutableList());
-    logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
-
-    long zombieRefsCnt = zombieRefs.size();
-    long deletedRefsCnt = 0;
-    long startTime = System.currentTimeMillis();
-
-    for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
-      deleteBatchZombieRefs(refsBatch);
-      long elapsed = (System.currentTimeMillis() - startTime) / 1000;
-      deletedRefsCnt += refsBatch.size();
-      logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+    deleteDraftRefsThatPointToEmptyTree();
+    if (draftNotesFactory != null) {
+      deleteDraftCommentsThatAreAlsoPublished();
     }
   }
 
-  private void deleteBatchZombieRefs(List<Ref> refsBatch) throws IOException {
+  private void deleteDraftRefsThatPointToEmptyTree() throws IOException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+      List<Ref> zombieRefs = filterZombieRefs(allUsersRepo, draftRefs);
+
+      logInfo(
+          String.format(
+              "Found a total of %d zombie draft refs in %s repo.",
+              zombieRefs.size(), allUsers.get()));
+
+      logInfo(String.format("Cleanup percentage = %d", cleanupPercentage));
+      zombieRefs =
+          zombieRefs.stream()
+              .filter(
+                  ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
+              .collect(toImmutableList());
+      logInfo(String.format("Number of zombie refs to be cleaned = %d", zombieRefs.size()));
+
+      if (dryRun) {
+        logInfo(
+            "Running in dry run mode. Skipping deletion of draft refs pointing to an empty tree.");
+        return;
+      }
+
+      long zombieRefsCnt = zombieRefs.size();
+      long deletedRefsCnt = 0;
+      long startTime = System.currentTimeMillis();
+
+      for (List<Ref> refsBatch : Iterables.partition(zombieRefs, CHUNK_SIZE)) {
+        deleteBatchZombieRefs(allUsersRepo, refsBatch);
+        long elapsed = (System.currentTimeMillis() - startTime) / 1000;
+        deletedRefsCnt += refsBatch.size();
+        logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
+      }
+    }
+  }
+
+  /**
+   * Iterates over all draft refs in All-Users repository. For each draft ref, checks if there
+   * exists a published comment with the same UUID and deletes the draft ref if that's the case
+   * because it is a zombie draft.
+   *
+   * @return the number of detected and deleted zombie draft comments.
+   */
+  @VisibleForTesting
+  public int deleteDraftCommentsThatAreAlsoPublished() throws IOException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      Timestamp earliestZombieTs = null;
+      Timestamp latestZombieTs = null;
+      int numZombies = 0;
+      List<Ref> draftRefs = allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+      // Filter the number of draft refs to be processed according to the cleanup percentage.
+      draftRefs =
+          draftRefs.stream()
+              .filter(
+                  ref -> Change.Id.fromAllUsersRef(ref.getName()).get() % 100 < cleanupPercentage)
+              .collect(toImmutableList());
+      Set<ChangeUserIDsPair> visitedSet = new HashSet<>();
+      ImmutableSet<Change.Id> changeIds =
+          draftRefs.stream()
+              .map(d -> Change.Id.fromAllUsersRef(d.getName()))
+              .collect(ImmutableSet.toImmutableSet());
+      Map<Change.Id, Project.NameKey> changeProjectMap = mapChangeIdsToProjects(changeIds);
+      for (Ref draftRef : draftRefs) {
+        try {
+          Change.Id changeId = Change.Id.fromAllUsersRef(draftRef.getName());
+          Account.Id accountId = Account.Id.fromRef(draftRef.getName());
+          ChangeUserIDsPair changeUserIDsPair = ChangeUserIDsPair.create(changeId, accountId);
+          if (!visitedSet.add(changeUserIDsPair)) {
+            continue;
+          }
+          if (!changeProjectMap.containsKey(changeId)) {
+            logger.atWarning().log(
+                "Could not find a project associated with change ID %s. Skipping draft ref %s.",
+                changeId, draftRef.getName());
+            continue;
+          }
+          DraftCommentNotes draftNotes = draftNotesFactory.create(changeId, accountId).load();
+          ChangeNotes notes =
+              changeNotesFactory.createChecked(changeProjectMap.get(changeId), changeId);
+          List<HumanComment> drafts = draftNotes.getComments().values().asList();
+          List<HumanComment> published = commentsUtil.publishedHumanCommentsByChange(notes);
+          Set<String> publishedIds = toUuid(published);
+          List<HumanComment> zombieDrafts =
+              drafts.stream()
+                  .filter(draft -> publishedIds.contains(draft.key.uuid))
+                  .collect(Collectors.toList());
+          for (HumanComment zombieDraft : zombieDrafts) {
+            earliestZombieTs = getEarlierTs(earliestZombieTs, zombieDraft.writtenOn);
+            latestZombieTs = getLaterTs(latestZombieTs, zombieDraft.writtenOn);
+          }
+          zombieDrafts.forEach(
+              zombieDraft ->
+                  logger.atWarning().log(
+                      "Draft comment with uuid '%s' of change %s, account %s, written on %s,"
+                          + " is a zombie draft that is already published.",
+                      zombieDraft.key.uuid, changeId, accountId, zombieDraft.writtenOn));
+          if (!zombieDrafts.isEmpty() && !dryRun) {
+            deleteZombieComments(accountId, notes, zombieDrafts);
+          }
+          numZombies += zombieDrafts.size();
+        } catch (Exception e) {
+          logger.atWarning().withCause(e).log("Failed to process ref %s", draftRef.getName());
+        }
+      }
+      if (numZombies > 0) {
+        logger.atWarning().log(
+            "Detected %d additional zombie drafts (earliest at %s, latest at %s).",
+            numZombies, earliestZombieTs, latestZombieTs);
+      }
+      return numZombies;
+    }
+  }
+
+  @AutoValue
+  abstract static class ChangeUserIDsPair {
+    abstract Change.Id changeId();
+
+    abstract Account.Id accountId();
+
+    static ChangeUserIDsPair create(Change.Id changeId, Account.Id accountId) {
+      return new AutoValue_DeleteZombieCommentsRefs_ChangeUserIDsPair(changeId, accountId);
+    }
+  }
+
+  /**
+   * Accepts a list of draft (zombie) comments for the same change and delete them by executing a
+   * {@link ChangeUpdate} on NoteDb. The update is executed using the user account who created this
+   * draft.
+   */
+  private void deleteZombieComments(
+      Account.Id accountId, ChangeNotes changeNotes, List<HumanComment> draftsToDelete)
+      throws IOException {
+    if (changeUpdateFactory == null || userFactory == null) {
+      return;
+    }
+    ChangeUpdate changeUpdate =
+        changeUpdateFactory.create(changeNotes, userFactory.create(accountId), TimeUtil.now());
+    draftsToDelete.forEach(c -> changeUpdate.deleteComment(c));
+    changeUpdate.commit();
+    logger.atInfo().log(
+        "Deleted zombie draft comments with UUIDs %s",
+        draftsToDelete.stream().map(d -> d.key.uuid).collect(Collectors.toList()));
+  }
+
+  /**
+   * Map each change ID to its associated project.
+   *
+   * <p>When doing a ref scan of draft refs
+   * "refs/draft-comments/$change_id_short/$change_id/$user_id" we don't know which project this
+   * draft comment is associated with. The project name is needed to load published comments for the
+   * change, hence we map each change ID to its project here by scanning through the change meta ref
+   * of the change ID in all projects.
+   */
+  private Map<Change.Id, Project.NameKey> mapChangeIdsToProjects(
+      ImmutableSet<Change.Id> changeIds) {
+    Map<Change.Id, Project.NameKey> result = new HashMap<>();
+    for (Project.NameKey project : repoManager.list()) {
+      try (Repository repo = repoManager.openRepository(project)) {
+        SetView<Change.Id> unmappedChangeIds = Sets.difference(changeIds, result.keySet());
+        for (Change.Id changeId : unmappedChangeIds) {
+          Ref ref = repo.getRefDatabase().exactRef(RefNames.changeMetaRef(changeId));
+          if (ref != null) {
+            result.put(changeId, project);
+          }
+        }
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log("Failed to open repository for project '%s'.", project);
+      }
+      if (changeIds.size() == result.size()) {
+        // We do not need to scan the remaining repositories
+        break;
+      }
+    }
+    if (result.size() != changeIds.size()) {
+      logger.atWarning().log(
+          "Failed to associate the following change Ids to a project: %s",
+          Sets.difference(changeIds, result.keySet()));
+    }
+    return result;
+  }
+
+  /** Map the list of input comments to their UUIDs. */
+  private Set<String> toUuid(List<HumanComment> in) {
+    return in.stream().map(c -> c.key.uuid).collect(Collectors.toSet());
+  }
+
+  private Timestamp getEarlierTs(@Nullable Timestamp t1, Timestamp t2) {
+    if (t1 == null) {
+      return t2;
+    }
+    return t1.before(t2) ? t1 : t2;
+  }
+
+  private Timestamp getLaterTs(@Nullable Timestamp t1, Timestamp t2) {
+    if (t1 == null) {
+      return t2;
+    }
+    return t1.after(t2) ? t1 : t2;
+  }
+
+  private void deleteBatchZombieRefs(Repository allUsersRepo, List<Ref> refsBatch)
+      throws IOException {
     List<ReceiveCommand> deleteCommands =
         refsBatch.stream()
             .map(
@@ -126,22 +415,23 @@
     RefUpdateUtil.executeChecked(bru, allUsersRepo);
   }
 
-  private List<Ref> filterZombieRefs(List<Ref> allDraftRefs) throws IOException {
+  private List<Ref> filterZombieRefs(Repository allUsersRepo, List<Ref> allDraftRefs)
+      throws IOException {
     List<Ref> zombieRefs = new ArrayList<>((int) (allDraftRefs.size() * 0.5));
     for (Ref ref : allDraftRefs) {
-      if (isZombieRef(ref)) {
+      if (isZombieRef(allUsersRepo, ref)) {
         zombieRefs.add(ref);
       }
     }
     return zombieRefs;
   }
 
-  private boolean isZombieRef(Ref ref) throws IOException {
-    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getName().equals(EMPTY_TREE_ID);
+  private boolean isZombieRef(Repository allUsersRepo, Ref ref) throws IOException {
+    return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getId().equals(EMPTY_TREE_ID);
   }
 
   private void logInfo(String message) {
-    logger.atInfo().log(message);
+    logger.atInfo().log("%s", message);
     uiConsumer.accept(message);
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 9b403e8..bdfe378 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -20,8 +20,6 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -59,7 +57,7 @@
   }
 
   DraftCommentNotes(Args args, Change.Id changeId, Account.Id author, @Nullable Ref ref) {
-    super(args, changeId);
+    super(args, changeId, null);
     this.author = requireNonNull(author);
     this.ref = ref;
     if (ref != null) {
@@ -124,13 +122,13 @@
             reader,
             NoteMap.read(reader, tipCommit),
             HumanComment.Status.DRAFT);
-    ListMultimap<ObjectId, HumanComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ImmutableListMultimap.Builder<ObjectId, HumanComment> cs = ImmutableListMultimap.builder();
     for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
       for (HumanComment c : rn.getEntities()) {
         cs.put(c.getCommitId(), c);
       }
     }
-    comments = ImmutableListMultimap.copyOf(cs);
+    comments = cs.build();
   }
 
   @Override
@@ -143,6 +141,7 @@
     return args.allUsers;
   }
 
+  @Nullable
   @VisibleForTesting
   NoteMap getNoteMap() {
     return revisionNoteMap != null ? revisionNoteMap.noteMap : null;
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 9345d98..0939ada 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -18,14 +18,18 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableListMultimap.flatteningToImmutableListMultimap;
 import static com.google.gerrit.server.logging.TraceContext.newTimer;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectChangeKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.RefUpdateUtil;
@@ -73,7 +77,7 @@
 public class NoteDbUpdateManager implements AutoCloseable {
   private static final int MAX_UPDATES_DEFAULT = 1000;
   /** Limits the number of patch sets that can be created. Can be overridden in the config. */
-  private static final int MAX_PATCH_SETS_DEFAULT = 1500;
+  private static final int MAX_PATCH_SETS_DEFAULT = 1000;
 
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
@@ -353,6 +357,15 @@
     }
   }
 
+  public ImmutableListMultimap<ProjectChangeKey, AttentionSetUpdate> attentionSetUpdates() {
+    return this.changeUpdates.values().stream()
+        .collect(
+            flatteningToImmutableListMultimap(
+                cu -> ProjectChangeKey.create(cu.getProjectName(), cu.getId()),
+                cu -> cu.getAttentionSetUpdates().stream()));
+  }
+
+  @Nullable
   private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
       throws IOException {
     if (or == null || or.cmds.isEmpty()) {
@@ -377,7 +390,7 @@
     bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
     bru.setAtomic(true);
     or.cmds.addTo(bru);
-    bru.setAllowNonFastForwards(true);
+    bru.setAllowNonFastForwards(allowNonFastForwards(or.cmds));
     for (BatchUpdateListener listener : batchUpdateListeners) {
       bru = listener.beforeUpdateRefs(bru);
     }
@@ -458,4 +471,27 @@
       }
     }
   }
+
+  /**
+   * Returns true if we should allow non-fast-forwards while performing the batch ref update. Non-ff
+   * updates are necessary in some specific cases:
+   *
+   * <p>1. Draft ref updates are non fast-forward, since the ref always points to a single commit
+   * that has no parents.
+   *
+   * <p>2. NoteDb rewriters.
+   *
+   * <p>3. If any of the receive commands is of type {@link
+   * org.eclipse.jgit.transport.ReceiveCommand.Type#UPDATE_NONFASTFORWARD} (for example due to a
+   * force push).
+   *
+   * <p>Note that we don't need to explicitly allow non fast-forward updates for DELETE commands
+   * since JGit forces the update implicitly in this case.
+   */
+  private boolean allowNonFastForwards(ChainedReceiveCommands receiveCommands) {
+    return !draftUpdates.isEmpty()
+        || !rewriters.isEmpty()
+        || receiveCommands.getCommands().values().stream()
+            .anyMatch(cmd -> cmd.getType().equals(ReceiveCommand.Type.UPDATE_NONFASTFORWARD));
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 396e29b..2ad89b2 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -18,10 +18,15 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.GitDateFormatter;
 import org.eclipse.jgit.util.GitDateFormatter.Format;
@@ -48,6 +53,45 @@
     return Optional.empty();
   }
 
+  /**
+   * Returns an AccountId for the given email address and the current serverId. Reverse lookup the
+   * AccountId using the ExternalIdCache if the account has a foreign serverId.
+   *
+   * @param ident the accountId@serverId identity
+   * @param serverId the Gerrit's serverId
+   * @param externalIdCache reference to the cache for looking up the external ids
+   * @return a defined accountId if the account was found, {@link Account#UNKNOWN_ACCOUNT_ID} if the
+   *     lookup via external-id did not return any account, or an empty value if the identity was
+   *     malformed.
+   * @throws ConfigInvalidException when the lookup of the external-id failed
+   */
+  public static Optional<Account.Id> parseIdent(
+      PersonIdent ident, String serverId, ExternalIdCache externalIdCache)
+      throws ConfigInvalidException {
+    String email = ident.getEmailAddress();
+    int at = email.indexOf('@');
+    if (at >= 0) {
+      Integer id = Ints.tryParse(email.substring(0, at));
+      String accountServerId = email.substring(at + 1);
+      if (id != null) {
+        if (accountServerId.equals(serverId)) {
+          return Optional.of(Account.id(id));
+        }
+
+        ExternalId.Key extIdKey = ExternalId.Key.create(ExternalId.SCHEME_IMPORTED, email, false);
+        try {
+          return externalIdCache
+              .byKey(extIdKey)
+              .map(ExternalId::accountId)
+              .or(() -> Optional.of(Account.UNKNOWN_ACCOUNT_ID));
+        } catch (IOException e) {
+          throw new ConfigInvalidException("Unable to lookup external id from cache", e);
+        }
+      }
+    }
+    return Optional.empty();
+  }
+
   public static String extractHostPartFromPersonIdent(PersonIdent ident) {
     String email = ident.getEmailAddress();
     int at = email.indexOf('@');
@@ -68,6 +112,7 @@
    * Returns the name of the REST API handler that is in the stack trace of the caller of this
    * method.
    */
+  @Nullable
   static String guessRestApiHandler() {
     StackTraceElement[] trace = Thread.currentThread().getStackTrace();
     int i = findRestApiServlet(trace);
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index 47e12ff..d743921 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -340,4 +340,16 @@
       counterLock.unlock();
     }
   }
+
+  /**
+   * Retrieves the last returned sequence number.
+   *
+   * <p>Explicitly calls {@link #next()} if this instance didn't return sequence number until now.
+   */
+  public int last() {
+    if (counter == 0) {
+      next();
+    }
+    return counter - 1;
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 7998476..35a014c 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayOutputStream;
@@ -73,7 +74,13 @@
   final Map<Comment.Key, Comment> put;
   private final Set<Comment.Key> delete;
 
-  private List<SubmitRequirementResult> submitRequirementResults;
+  /**
+   * Submit requirement results to be stored in the revision note. If this field is null, we don't
+   * store results in the revision note. Otherwise, we store a "submit requirements" section in the
+   * revision note even if it's empty.
+   */
+  @Nullable private List<SubmitRequirementResult> submitRequirementResults;
+
   private String pushCert;
 
   private RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
@@ -83,6 +90,7 @@
       put = Maps.newHashMapWithExpectedSize(baseComments.size());
       if (base instanceof ChangeRevisionNote) {
         pushCert = ((ChangeRevisionNote) base).getPushCert();
+        submitRequirementResults = ((ChangeRevisionNote) base).getSubmitRequirementsResult();
       }
     } else {
       baseRaw = new byte[0];
@@ -90,7 +98,6 @@
       put = new HashMap<>();
       pushCert = null;
     }
-    submitRequirementResults = new ArrayList<>();
     delete = new HashSet<>();
   }
 
@@ -109,7 +116,22 @@
     put.put(comment.key, comment);
   }
 
+  /**
+   * Call this method to designate that we should store submit requirement results in the revision
+   * note. Even if no results are added, an empty submit requirements section will be added.
+   */
+  void createEmptySubmitRequirementResults() {
+    submitRequirementResults = new ArrayList<>();
+  }
+
+  void clearSubmitRequirementResults() {
+    submitRequirementResults = null;
+  }
+
   void putSubmitRequirementResult(SubmitRequirementResult result) {
+    if (submitRequirementResults == null) {
+      submitRequirementResults = new ArrayList<>();
+    }
     submitRequirementResults.add(result);
   }
 
@@ -140,19 +162,19 @@
 
   private void buildNoteJson(ChangeNoteJson noteUtil, OutputStream out) throws IOException {
     ListMultimap<Integer, Comment> comments = buildCommentMap();
-    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults == null && comments.isEmpty() && pushCert == null) {
       return;
     }
 
     RevisionNoteData data = new RevisionNoteData();
     data.comments = COMMENT_ORDER.sortedCopy(comments.values());
     data.pushCert = pushCert;
-    if (!submitRequirementResults.isEmpty()) {
-      data.submitRequirementResults =
-          submitRequirementResults.stream()
-              .sorted(SUBMIT_REQUIREMENT_RESULT_COMPARATOR)
-              .collect(Collectors.toList());
-    }
+    data.submitRequirementResults =
+        submitRequirementResults == null
+            ? null
+            : submitRequirementResults.stream()
+                .sorted(SUBMIT_REQUIREMENT_RESULT_COMPARATOR)
+                .collect(Collectors.toList());
 
     try (OutputStreamWriter osw = new OutputStreamWriter(out, UTF_8)) {
       noteUtil.getGson().toJson(data, osw);
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 5a0b67b..98c9873 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.HumanComment;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -42,7 +41,7 @@
   }
 
   static RevisionNoteMap<ChangeRevisionNote> parse(
-      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, HumanComment.Status status)
+      ChangeNoteJson noteJson, ObjectReader reader, NoteMap noteMap, Comment.Status status)
       throws ConfigInvalidException, IOException {
     ImmutableMap.Builder<ObjectId, ChangeRevisionNote> result = ImmutableMap.builder();
     for (Note note : noteMap) {
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index fe05643..2ec68f1 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
@@ -47,7 +45,7 @@
 
   @Inject
   RobotCommentNotes(Args args, @Assisted Change change) {
-    super(args, change.getId());
+    super(args, change.getId(), null);
     this.change = change;
   }
 
@@ -94,13 +92,13 @@
     revisionNoteMap =
         RevisionNoteMap.parseRobotComments(
             args.changeNoteJson, reader, NoteMap.read(reader, tipCommit));
-    ListMultimap<ObjectId, RobotComment> cs = MultimapBuilder.hashKeys().arrayListValues().build();
+    ImmutableListMultimap.Builder<ObjectId, RobotComment> cs = ImmutableListMultimap.builder();
     for (RobotCommentsRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
       for (RobotComment c : rn.getEntities()) {
         cs.put(c.getCommitId(), c);
       }
     }
-    comments = ImmutableListMultimap.copyOf(cs);
+    comments = cs.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index 895f378..e1e6305 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -19,6 +19,7 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -28,9 +29,9 @@
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -57,18 +58,19 @@
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
 
     RobotCommentUpdate create(
         Change change,
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
-        Date when);
+        Instant when);
   }
 
   private List<RobotComment> put = new ArrayList<>();
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private RobotCommentUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -77,10 +79,11 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
   }
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private RobotCommentUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
@@ -89,7 +92,7 @@
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
-      @Assisted Date when) {
+      @Assisted Instant when) {
     super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
   }
 
@@ -98,6 +101,7 @@
     put.add(c);
   }
 
+  @Nullable
   private CommitBuilder storeCommentsInNotes(
       RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, IOException {
diff --git a/java/com/google/gerrit/server/notedb/Sequences.java b/java/com/google/gerrit/server/notedb/Sequences.java
index 7ae98778..b42253e 100644
--- a/java/com/google/gerrit/server/notedb/Sequences.java
+++ b/java/com/google/gerrit/server/notedb/Sequences.java
@@ -55,6 +55,9 @@
   private final RepoSequence changeSeq;
   private final RepoSequence groupSeq;
   private final Timer2<SequenceType, Boolean> nextIdLatency;
+  private final int accountBatchSize;
+  private final int changeBatchSize;
+  private final int groupBatchSize = 1;
 
   @Inject
   public Sequences(
@@ -65,7 +68,7 @@
       AllUsersName allUsers,
       MetricMaker metrics) {
 
-    int accountBatchSize =
+    accountBatchSize =
         cfg.getInt(
             SECTION_NOTEDB,
             NAME_ACCOUNTS,
@@ -80,7 +83,7 @@
             () -> FIRST_ACCOUNT_ID,
             accountBatchSize);
 
-    int changeBatchSize =
+    changeBatchSize =
         cfg.getInt(
             SECTION_NOTEDB,
             NAME_CHANGES,
@@ -95,7 +98,6 @@
             () -> FIRST_CHANGE_ID,
             changeBatchSize);
 
-    int groupBatchSize = 1;
     groupSeq =
         new RepoSequence(
             repoManager,
@@ -147,6 +149,18 @@
     }
   }
 
+  public int changeBatchSize() {
+    return changeBatchSize;
+  }
+
+  public int groupBatchSize() {
+    return groupBatchSize;
+  }
+
+  public int accountBatchSize() {
+    return accountBatchSize;
+  }
+
   public int currentChangeId() {
     return changeSeq.current();
   }
@@ -159,6 +173,18 @@
     return groupSeq.current();
   }
 
+  public int lastChangeId() {
+    return changeSeq.last();
+  }
+
+  public int lastGroupId() {
+    return groupSeq.last();
+  }
+
+  public int lastAccountId() {
+    return accountSeq.last();
+  }
+
   public void setChangeIdValue(int value) {
     changeSeq.storeNew(value);
   }
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
index d128633..2e62fc1 100644
--- a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2021 The Android Open Source Project
+// Copyright (C) 2022 The Android Open Source Project
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -14,58 +14,64 @@
 
 package com.google.gerrit.server.notedb;
 
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
-import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
 
 /** A {@link BatchUpdateOp} that stores the evaluated submit requirements of a change in NoteDb. */
 public class StoreSubmitRequirementsOp implements BatchUpdateOp {
-  private final ChangeData.Factory changeDataFactory;
-  private final SubmitRequirementsEvaluator evaluator;
-  private final boolean storeRequirementsInNoteDb;
+  private final Collection<SubmitRequirementResult> submitRequirementResults;
+  private final ChangeData changeData;
+  private final OnStoreSubmitRequirementResultModifier onStoreSubmitRequirementResultModifier;
 
   public interface Factory {
-    StoreSubmitRequirementsOp create();
+
+    /**
+     * {@code submitRequirements} are explicitly passed to the operation so that they are evaluated
+     * before the {@link #updateChange} is called.
+     *
+     * <p>This is because the return results of {@link ChangeData#submitRequirements()} depend on
+     * the status of the change, which can be modified by other {@link BatchUpdateOp}, sharing the
+     * same {@link ChangeContext}.
+     */
+    StoreSubmitRequirementsOp create(
+        Collection<SubmitRequirementResult> submitRequirements, ChangeData changeData);
   }
 
   @Inject
   public StoreSubmitRequirementsOp(
-      ChangeData.Factory changeDataFactory,
-      ExperimentFeatures experimentFeatures,
-      SubmitRequirementsEvaluator evaluator) {
-    this.changeDataFactory = changeDataFactory;
-    this.evaluator = evaluator;
-    this.storeRequirementsInNoteDb =
-        experimentFeatures.isFeatureEnabled(
-            ExperimentFeaturesConstants
-                .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE);
+      OnStoreSubmitRequirementResultModifier onStoreSubmitRequirementResultModifier,
+      @Assisted Collection<SubmitRequirementResult> submitRequirementResults,
+      @Assisted ChangeData changeData) {
+    this.onStoreSubmitRequirementResultModifier = onStoreSubmitRequirementResultModifier;
+    this.submitRequirementResults = submitRequirementResults;
+    this.changeData = changeData;
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws Exception {
-    if (!storeRequirementsInNoteDb) {
-      // Temporarily stop storing submit requirements in NoteDb when the change is merged.
-      return false;
-    }
-    // Create ChangeData using the project/change IDs instead of ctx.getChange(). We do that because
-    // for changes requiring a rebase before submission (e.g. if submit type = RebaseAlways), the
-    // RebaseOp inserts a new patchset that is visible here (via Change#getCurrentPatchset). If we
-    // then try to get ChangeData#currentPatchset it will return null, since it loads patchsets from
-    // NoteDb but tries to find the patchset with the ID of the one just inserted by the rebase op.
-    // Note that this implementation means that, in this case, submit requirement results will be
-    // stored in change notes of the pre last patchset commit. This is fine since submit requirement
-    // results should evaluate to the exact same results for both commits. Additionally, the
-    // pre-last commit is the one for which we displayed the submit requirement results of the last
-    // patchset to the user before it was merged.
-    ChangeData changeData = changeDataFactory.create(ctx.getProject(), ctx.getChange().getId());
     ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
-    // We do not want to store submit requirements in NoteDb for legacy submit records
-    update.putSubmitRequirementResults(
-        evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false).values());
-    return !changeData.submitRequirements().isEmpty();
+    List<SubmitRequirementResult> nonLegacySubmitRequirements =
+        submitRequirementResults.stream()
+            // We don't store results for legacy submit requirements in NoteDb. While
+            // surfacing submit requirements for closed changes, we load submit records
+            // from NoteDb and convert them to submit requirement results. See
+            // ChangeData#submitRequirements().
+            .filter(srResult -> !srResult.isLegacy())
+            .map(
+                // Pass to OnStoreSubmitRequirementResultModifier for override
+                srResult ->
+                    onStoreSubmitRequirementResultModifier.modifyResultOnStore(
+                        srResult.submitRequirement(), srResult, changeData, ctx))
+            .collect(Collectors.toList());
+    update.putSubmitRequirementResults(nonLegacySubmitRequirements);
+    return !nonLegacySubmitRequirements.isEmpty();
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
index 3caa4d4..96d3080 100644
--- a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
+++ b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
@@ -32,10 +32,16 @@
 
   private static final FieldDescriptor SR_APPLICABILITY_EXPR_RESULT_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
+  private static final FieldDescriptor SR_SUBMITTABILITY_EXPR_RESULT_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(3);
   private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
   private static final FieldDescriptor SR_LEGACY_FIELD =
       SubmitRequirementResultProto.getDescriptor().findFieldByNumber(6);
+  private static final FieldDescriptor SR_FORCED_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(7);
+  private static final FieldDescriptor SR_HIDDEN_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(8);
 
   @Override
   public SubmitRequirementResultProto toProto(SubmitRequirementResult r) {
@@ -46,13 +52,22 @@
     if (r.legacy().isPresent()) {
       builder.setLegacy(r.legacy().get());
     }
+    if (r.forced().isPresent()) {
+      builder.setForced(r.forced().get());
+    }
+    if (r.hidden().isPresent()) {
+      builder.setHidden(r.hidden().get());
+    }
     if (r.applicabilityExpressionResult().isPresent()) {
       builder.setApplicabilityExpressionResult(
           SubmitRequirementExpressionResultSerializer.serialize(
               r.applicabilityExpressionResult().get()));
     }
-    builder.setSubmittabilityExpressionResult(
-        SubmitRequirementExpressionResultSerializer.serialize(r.submittabilityExpressionResult()));
+    if (r.submittabilityExpressionResult().isPresent()) {
+      builder.setSubmittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.submittabilityExpressionResult().get()));
+    }
     if (r.overrideExpressionResult().isPresent()) {
       builder.setOverrideExpressionResult(
           SubmitRequirementExpressionResultSerializer.serialize(
@@ -71,15 +86,23 @@
     if (proto.hasField(SR_LEGACY_FIELD)) {
       builder.legacy(Optional.of(proto.getLegacy()));
     }
+    if (proto.hasField(SR_FORCED_FIELD)) {
+      builder.forced(Optional.of(proto.getForced()));
+    }
+    if (proto.hasField(SR_HIDDEN_FIELD)) {
+      builder.hidden(Optional.of(proto.getHidden()));
+    }
     if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
       builder.applicabilityExpressionResult(
           Optional.of(
               SubmitRequirementExpressionResultSerializer.deserialize(
                   proto.getApplicabilityExpressionResult())));
     }
-    builder.submittabilityExpressionResult(
-        SubmitRequirementExpressionResultSerializer.deserialize(
-            proto.getSubmittabilityExpressionResult()));
+    if (proto.hasField(SR_SUBMITTABILITY_EXPR_RESULT_FIELD)) {
+      builder.submittabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.deserialize(
+              proto.getSubmittabilityExpressionResult()));
+    }
     if (proto.hasField(SR_OVERRIDE_EXPR_RESULT_FIELD)) {
       builder.overrideExpressionResult(
           Optional.of(
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 2529c04..1f4720d 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -145,7 +145,7 @@
       return existingCommit.get();
     }
     counter.increment(OperationType.IN_MEMORY_WRITE);
-    logger.atInfo().log("Computing in-memory AutoMerge for " + merge.name());
+    logger.atInfo().log("Computing in-memory AutoMerge for %s", merge.name());
     try (Timer1.Context<OperationType> ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
       return rw.parseCommit(createAutoMergeCommit(repo.getConfig(), rw, ins, merge, mergeStrategy));
     }
@@ -169,22 +169,49 @@
       return Optional.empty();
     }
 
-    if (repoView.getRef(RefNames.refsCacheAutomerge(maybeMergeCommit.name())).isPresent()) {
+    String automergeRef = RefNames.refsCacheAutomerge(maybeMergeCommit.name());
+    logger.atFine().log("AutoMerge ref=%s, mergeCommit=%s", automergeRef, maybeMergeCommit.name());
+    if (repoView.getRef(automergeRef).isPresent()) {
       logger.atFine().log("AutoMerge alredy exists");
       return Optional.empty();
     }
 
+    return Optional.of(
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            createAutoMergeCommit(repoView, rw, ins, maybeMergeCommit),
+            automergeRef));
+  }
+
+  /**
+   * Creates an auto merge commit for the provided merge commit.
+   *
+   * <p>Callers are expected to ensure that the provided commit indeed has 2 parents.
+   *
+   * @return An auto-merge commit. Headers of the returned RevCommit are parsed.
+   */
+  ObjectId createAutoMergeCommit(
+      RepoView repoView, RevWalk rw, ObjectInserter ins, RevCommit mergeCommit) throws IOException {
     ObjectId autoMerge;
     try (Timer1.Context<OperationType> ignored = latency.start(OperationType.ON_DISK_WRITE)) {
       autoMerge =
           createAutoMergeCommit(
-              repoView.getConfig(), rw, ins, maybeMergeCommit, configuredMergeStrategy);
+              repoView.getConfig(), rw, ins, mergeCommit, configuredMergeStrategy);
     }
     counter.increment(OperationType.ON_DISK_WRITE);
     logger.atFine().log("Added %s AutoMerge ref update for commit", autoMerge.name());
-    return Optional.of(
-        new ReceiveCommand(
-            ObjectId.zeroId(), autoMerge, RefNames.refsCacheAutomerge(maybeMergeCommit.name())));
+    return autoMerge;
+  }
+
+  Optional<RevCommit> lookupCommit(Repository repo, RevWalk rw, String refName) throws IOException {
+    Ref ref = repo.getRefDatabase().exactRef(refName);
+    if (ref != null && ref.getObjectId() != null) {
+      RevObject obj = rw.parseAny(ref.getObjectId());
+      if (obj instanceof RevCommit) {
+        return Optional.of((RevCommit) obj);
+      }
+    }
+    return Optional.empty();
   }
 
   /**
@@ -225,6 +252,7 @@
               merge.getParent(1),
               m.getMergeResults());
     }
+    logger.atFine().log("AutoMerge treeId=%s", treeId.name());
 
     rw.parseHeaders(merge);
     // For maximum stability, choose a single ident using the committer time of
@@ -243,28 +271,20 @@
       cb.addParentId(p);
     }
 
+    ObjectId commitId = ins.insert(cb);
+    logger.atFine().log("AutoMerge commitId=%s", commitId.name());
+    ins.flush();
+
     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(ins.insert(cb));
+        return tmpRw.parseCommit(commitId);
       }
     }
 
-    return rw.parseCommit(ins.insert(cb));
-  }
-
-  private Optional<RevCommit> lookupCommit(Repository repo, RevWalk rw, String refName)
-      throws IOException {
-    Ref ref = repo.getRefDatabase().exactRef(refName);
-    if (ref != null && ref.getObjectId() != null) {
-      RevObject obj = rw.parseAny(ref.getObjectId());
-      if (obj instanceof RevCommit) {
-        return Optional.of((RevCommit) obj);
-      }
-    }
-    return Optional.empty();
+    return rw.parseCommit(commitId);
   }
 
   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
index ed68dfd..9a103cd 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -16,11 +16,10 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
-import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,19 +30,15 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.ThreeWayMergeStrategy;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** A utility class for computing the base commit / parent for a specific patchset commit. */
 @Singleton
 class BaseCommitUtil {
   private final AutoMerger autoMerger;
-  private final ThreeWayMergeStrategy mergeStrategy;
   private final GitRepositoryManager repoManager;
 
   /** If true, auto-merge results are stored in the repository. */
@@ -52,7 +47,6 @@
   @Inject
   BaseCommitUtil(AutoMerger am, @GerritServerConfig Config cfg, GitRepositoryManager repoManager) {
     this.autoMerger = am;
-    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
     this.saveAutomerge = AutoMerger.cacheAutomerge(cfg);
     this.repoManager = repoManager;
   }
@@ -97,6 +91,7 @@
    * @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.
    */
+  @Nullable
   RevObject getParentCommit(
       Repository repo,
       ObjectInserter ins,
@@ -123,69 +118,29 @@
                 "diff against auto-merge commits is only supported if 'change.cacheAutomerge' config is set to true.");
           }
           // TODO(ghareeb): Avoid persisting auto-merge commits.
-          RevCommit autoMerge = createAutoMergeInGitIfNecessary(repo, ins, rw, current);
-          return autoMerge == null ? getAutoMergeFromGit(repo, current) : autoMerge;
+          return getAutoMergeFromGitOrCreate(repo, ins, rw, current);
         }
         return null;
     }
   }
 
   /**
-   * Creates the auto-merge commit in git. If the auto-merge already exists, this does nothing.
-   * Otherwise, the auto-merge is created, persisted in git and the cache-automerge ref is updated
-   * for the merge commit.
+   * Gets the auto-merge commit from git if it already exists. If not, the auto-merge is created,
+   * persisted in git and the cache-automerge ref is updated for the merge commit.
    *
-   * @return null if the auto-merge already exists in git, or the auto-merge {@link RevCommit}
-   *     object otherwise.
+   * @return the auto-merge {@link RevCommit}
    */
-  private RevCommit createAutoMergeInGitIfNecessary(
+  private RevCommit getAutoMergeFromGitOrCreate(
       Repository repo, ObjectInserter ins, RevWalk rw, RevCommit mergeCommit) throws IOException {
-    Optional<ReceiveCommand> receive =
-        autoMerger.createAutoMergeCommitIfNecessary(
-            new RepoView(repo, rw, ins), rw, ins, mergeCommit);
-    if (receive.isPresent()) {
-      ins.flush();
-      return updateRef(repo, rw, receive.get().getRefName(), receive.get().getNewId(), mergeCommit);
+    String refName = RefNames.refsCacheAutomerge(mergeCommit.name());
+    Optional<RevCommit> autoMergeCommit = autoMerger.lookupCommit(repo, rw, refName);
+    if (autoMergeCommit.isPresent()) {
+      return autoMergeCommit.get();
     }
-    return null;
-  }
-
-  private RevCommit getAutoMergeFromGit(Repository repo, RevCommit mergeCommit) throws IOException {
-    try (InMemoryInserter inMemoryIns = new InMemoryInserter(repo);
-        RevWalk inMemoryRw = new RevWalk(inMemoryIns.newReader())) {
-      return autoMerger.lookupFromGitOrMergeInMemory(
-          repo, inMemoryRw, inMemoryIns, mergeCommit, mergeStrategy);
-    }
-  }
-
-  private static RevCommit updateRef(
-      Repository repo, RevWalk rw, String refName, ObjectId autoMergeId, RevCommit merge)
-      throws IOException {
-    RefUpdate ru = repo.updateRef(refName);
-    ru.setNewObjectId(autoMergeId);
-    ru.disableRefLog();
-    switch (ru.update()) {
-      case FAST_FORWARD:
-      case FORCED:
-      case NEW:
-      case NO_CHANGE:
-        return rw.parseCommit(autoMergeId);
-      case LOCK_FAILURE:
-        throw new LockFailureException(
-            String.format("Failed to create auto-merge of %s", merge.name()), ru);
-      case IO_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case REJECTED_MISSING_OBJECT:
-      case REJECTED_OTHER_REASON:
-      case RENAMED:
-      default:
-        throw new IOException(
-            String.format(
-                "Failed to create auto-merge of %s: Cannot write %s (%s)",
-                merge.name(), refName, ru.getResult()));
-    }
+    ObjectId autoMergeId =
+        autoMerger.createAutoMergeCommit(new RepoView(repo, rw, ins), rw, ins, mergeCommit);
+    ins.flush();
+    return rw.parseCommit(autoMergeId);
   }
 
   private ObjectInserter newInserter(Repository repo) {
diff --git a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
index ea92a99..5bd8fd4 100644
--- a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
+++ b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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;
 
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index d2da736..a53660a 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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;
 
@@ -18,10 +18,14 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import java.util.Map;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * An interface for all file diff related operations. Clients should use this interface to request:
@@ -56,7 +60,31 @@
    *     an internal error occurred in Git while evaluating the diff.
    */
   Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, int parentNum) throws DiffNotAvailableException;
+      Project.NameKey project, ObjectId newCommit, int parentNum, DiffOptions diffOptions)
+      throws DiffNotAvailableException;
+
+  /**
+   * This method is similar to {@link #listModifiedFilesAgainstParent(NameKey, ObjectId, int,
+   * DiffOptions)} but loads the modified files directly instead of retrieving them from the diff
+   * cache.
+   *
+   * <p>A RevWalk and repoConfig are also supplied and are used to look up the commit IDs. This is
+   * useful in case one the commits is currently being created, that's why the {@code revWalk}
+   * parameter is needed.
+   *
+   * <p>Note that rename detection is disabled for this method.
+   *
+   * @return a map of file paths to {@link ModifiedFile}. The {@link ModifiedFile} contains the
+   *     old/new file paths and the change type (added, deleted, etc...).
+   */
+  Map<String, ModifiedFile> loadModifiedFilesAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      int parentNum,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
+      throws DiffNotAvailableException;
 
   /**
    * Returns the list of added, deleted or modified files between two commits (patchsets). The
@@ -72,7 +100,30 @@
    *     diff.
    */
   Map<String, FileDiffOutput> listModifiedFiles(
-      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, DiffOptions diffOptions)
+      throws DiffNotAvailableException;
+
+  /**
+   * This method is similar to {@link #listModifiedFilesAgainstParent(NameKey, ObjectId, int,
+   * DiffOptions)} but loads the modified files directly instead of retrieving them from the diff
+   * cache.
+   *
+   * <p>A RevWalk and repoConfig are also supplied and are used to look up the commit IDs. This is
+   * useful in case one the commits is currently being created, that's why the {@code revWalk}
+   * parameter is needed.
+   *
+   * <p>Note that rename detection is disabled for this method.
+   *
+   * @return a map of file paths to {@link ModifiedFile}. The {@link ModifiedFile} contains the
+   *     old/new file paths and the change type (added, deleted, etc...).
+   */
+  Map<String, ModifiedFile> loadModifiedFiles(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
       throws DiffNotAvailableException;
 
   /**
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index 3423b32..86f122e 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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;
 
@@ -47,7 +47,16 @@
 import java.util.ArrayList;
 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.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
  * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
@@ -57,6 +66,19 @@
 public class DiffOperationsImpl implements DiffOperations {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private static final ImmutableMap<DiffEntry.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 static final int RENAME_SCORE = 60;
   private static final DiffAlgorithm DEFAULT_DIFF_ALGORITHM =
       DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS;
@@ -91,10 +113,11 @@
 
   @Override
   public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, int parent) throws DiffNotAvailableException {
+      Project.NameKey project, ObjectId newCommit, int parent, DiffOptions diffOptions)
+      throws DiffNotAvailableException {
     try {
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
-      return getModifiedFiles(diffParams);
+      return getModifiedFiles(diffParams, diffOptions);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -102,8 +125,29 @@
   }
 
   @Override
+  public Map<String, ModifiedFile> loadModifiedFilesAgainstParent(
+      Project.NameKey project,
+      ObjectId newCommit,
+      int parentNum,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
+      throws DiffNotAvailableException {
+    try {
+      DiffParameters diffParams = computeDiffParameters(project, newCommit, parentNum);
+      return loadModifiedFilesWithoutCache(project, diffParams, revWalk, repoConfig);
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "Failed to evaluate the parent/base commit for commit '%s' with parentNum=%d",
+              newCommit, parentNum),
+          e);
+    }
+  }
+
+  @Override
   public Map<String, FileDiffOutput> listModifiedFiles(
-      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit)
+      Project.NameKey project, ObjectId oldCommit, ObjectId newCommit, DiffOptions diffOptions)
       throws DiffNotAvailableException {
     DiffParameters params =
         DiffParameters.builder()
@@ -112,7 +156,26 @@
             .baseCommit(oldCommit)
             .comparisonType(ComparisonType.againstOtherPatchSet())
             .build();
-    return getModifiedFiles(params);
+    return getModifiedFiles(params, diffOptions);
+  }
+
+  @Override
+  public Map<String, ModifiedFile> loadModifiedFiles(
+      Project.NameKey project,
+      ObjectId oldCommit,
+      ObjectId newCommit,
+      DiffOptions diffOptions,
+      RevWalk revWalk,
+      Config repoConfig)
+      throws DiffNotAvailableException {
+    DiffParameters params =
+        DiffParameters.builder()
+            .project(project)
+            .newCommit(newCommit)
+            .baseCommit(oldCommit)
+            .comparisonType(ComparisonType.againstOtherPatchSet())
+            .build();
+    return loadModifiedFilesWithoutCache(project, params, revWalk, repoConfig);
   }
 
   @Override
@@ -161,8 +224,8 @@
     return getModifiedFileForKey(key);
   }
 
-  private ImmutableMap<String, FileDiffOutput> getModifiedFiles(DiffParameters diffParams)
-      throws DiffNotAvailableException {
+  private ImmutableMap<String, FileDiffOutput> getModifiedFiles(
+      DiffParameters diffParams, DiffOptions diffOptions) throws DiffNotAvailableException {
     try {
       Project.NameKey project = diffParams.project();
       ObjectId newCommit = diffParams.newCommit();
@@ -211,7 +274,7 @@
                         /* whitespace= */ null))
             .forEach(fileCacheKeys::add);
       }
-      return getModifiedFilesForKeys(fileCacheKeys);
+      return getModifiedFilesForKeys(fileCacheKeys, diffOptions);
     } catch (IOException e) {
       throw new DiffNotAvailableException(e);
     }
@@ -219,7 +282,8 @@
 
   private FileDiffOutput getModifiedFileForKey(FileDiffCacheKey key)
       throws DiffNotAvailableException {
-    Map<String, FileDiffOutput> diffList = getModifiedFilesForKeys(ImmutableList.of(key));
+    Map<String, FileDiffOutput> diffList =
+        getModifiedFilesForKeys(ImmutableList.of(key), DiffOptions.DEFAULTS);
     return diffList.containsKey(key.newFilePath())
         ? diffList.get(key.newFilePath())
         : FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
@@ -230,8 +294,8 @@
    * results, e.g. due to timeouts in the cache loader, this method requests the diff again using
    * the fallback algorithm {@link DiffAlgorithm#HISTOGRAM_NO_FALLBACK}.
    */
-  private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys(List<FileDiffCacheKey> keys)
-      throws DiffNotAvailableException {
+  private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys(
+      List<FileDiffCacheKey> keys, DiffOptions diffOptions) throws DiffNotAvailableException {
     ImmutableMap<FileDiffCacheKey, FileDiffOutput> fileDiffs = fileDiffCache.getAll(keys);
     List<FileDiffCacheKey> fallbackKeys = new ArrayList<>();
 
@@ -260,7 +324,7 @@
       }
     }
     result.addAll(fileDiffCache.getAll(fallbackKeys).values());
-    return mapByFilePath(result.build());
+    return mapByFilePath(result.build(), diffOptions);
   }
 
   /**
@@ -268,11 +332,12 @@
    * represent the old file path for deleted files, or the new path otherwise.
    */
   private ImmutableMap<String, FileDiffOutput> mapByFilePath(
-      ImmutableCollection<FileDiffOutput> fileDiffOutputs) {
+      ImmutableCollection<FileDiffOutput> fileDiffOutputs, DiffOptions diffOptions) {
     ImmutableMap.Builder<String, FileDiffOutput> diffs = ImmutableMap.builder();
 
     for (FileDiffOutput fileDiffOutput : fileDiffOutputs) {
-      if (fileDiffOutput.isEmpty() || allDueToRebase(fileDiffOutput)) {
+      if (fileDiffOutput.isEmpty()
+          || (diffOptions.skipFilesWithAllEditsDueToRebase() && allDueToRebase(fileDiffOutput))) {
         continue;
       }
       if (fileDiffOutput.changeType() == ChangeType.DELETED) {
@@ -286,8 +351,8 @@
 
   private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
     return fileDiffOutput.allEditsDueToRebase()
-        && (!(fileDiffOutput.changeType() == ChangeType.RENAMED
-            || fileDiffOutput.changeType() == ChangeType.COPIED));
+        && !(fileDiffOutput.changeType() == ChangeType.RENAMED
+            || fileDiffOutput.changeType() == ChangeType.COPIED);
   }
 
   private boolean isMergeAgainstParent(ComparisonType cmp, Project.NameKey project, ObjectId commit)
@@ -326,6 +391,53 @@
         .build();
   }
 
+  /** Loads the modified file paths between two commits without inspecting the diff cache. */
+  private static Map<String, ModifiedFile> loadModifiedFilesWithoutCache(
+      Project.NameKey project, DiffParameters diffParams, RevWalk revWalk, Config repoConfig)
+      throws DiffNotAvailableException {
+    ObjectId newCommit = diffParams.newCommit();
+    ObjectId oldCommit = diffParams.baseCommit();
+    try {
+      ObjectReader reader = revWalk.getObjectReader();
+      List<DiffEntry> diffEntries;
+      try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+        df.setReader(reader, repoConfig);
+        df.setDetectRenames(false);
+        diffEntries = df.scan(oldCommit.equals(ObjectId.zeroId()) ? null : oldCommit, newCommit);
+      }
+      List<ModifiedFile> modifiedFiles =
+          diffEntries.stream()
+              .map(
+                  entry ->
+                      ModifiedFile.builder()
+                          .changeType(toChangeType(entry.getChangeType()))
+                          .oldPath(getGitPath(entry.getOldPath()))
+                          .newPath(getGitPath(entry.getNewPath()))
+                          .build())
+              .collect(Collectors.toList());
+      return DiffUtil.mergeRewrittenModifiedFiles(modifiedFiles).stream()
+          .collect(ImmutableMap.toImmutableMap(ModifiedFile::getDefaultPath, Function.identity()));
+    } catch (IOException e) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "Failed to compute the modified files for project '%s',"
+                  + " old commit '%s', new commit '%s'.",
+              project, oldCommit.name(), newCommit.name()),
+          e);
+    }
+  }
+
+  private static Optional<String> getGitPath(String path) {
+    return path.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(path);
+  }
+
+  private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
+    if (!changeTypeMap.containsKey(changeType)) {
+      throw new IllegalArgumentException("Unsupported type " + changeType);
+    }
+    return changeTypeMap.get(changeType);
+  }
+
   @AutoValue
   abstract static class DiffParameters {
     abstract Project.NameKey project();
diff --git a/java/com/google/gerrit/server/patch/DiffOptions.java b/java/com/google/gerrit/server/patch/DiffOptions.java
new file mode 100644
index 0000000..4d54be1
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffOptions.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.patch;
+
+import com.google.auto.value.AutoValue;
+
+@AutoValue
+public abstract class DiffOptions {
+  public static final DiffOptions DEFAULTS =
+      DiffOptions.builder().skipFilesWithAllEditsDueToRebase(true).build();
+
+  public abstract boolean skipFilesWithAllEditsDueToRebase();
+
+  public static DiffOptions.Builder builder() {
+    return new AutoValue_DiffOptions.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder skipFilesWithAllEditsDueToRebase(boolean value);
+
+    public abstract DiffOptions build();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index fcce672..246544b 100644
--- a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -48,8 +48,9 @@
     ObjectId newId = key.toPatchListKey().getNewId();
     Map<String, FileDiffOutput> diffList =
         oldId == null
-            ? diffOperations.listModifiedFilesAgainstParent(project, newId, /* parentNum= */ 0)
-            : diffOperations.listModifiedFiles(project, oldId, newId);
+            ? diffOperations.listModifiedFilesAgainstParent(
+                project, newId, /* parentNum= */ 0, DiffOptions.DEFAULTS)
+            : diffOperations.listModifiedFiles(project, oldId, newId, DiffOptions.DEFAULTS);
     return toDiffSummary(diffList);
   }
 
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
index 1e88f9f..70a3208 100644
--- a/java/com/google/gerrit/server/patch/DiffUtil.java
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -15,9 +15,16 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
 import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import java.io.IOException;
+import java.util.Comparator;
+import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -29,6 +36,41 @@
 public class DiffUtil {
 
   /**
+   * Return the {@code modifiedFiles} input list while merging rewritten entries.
+   *
+   * <p>Background: In some cases, JGit returns two diff entries (ADDED/DELETED, RENAMED/DELETED,
+   * etc...) for the same file path. This happens e.g. when a file's mode is changed between
+   * patchsets, for example converting a symlink file to a regular file. We identify this case and
+   * return a single modified file with changeType = {@link ChangeType#REWRITE}.
+   */
+  public static ImmutableList<ModifiedFile> mergeRewrittenModifiedFiles(
+      List<ModifiedFile> modifiedFiles) {
+    ImmutableList.Builder<ModifiedFile> result = ImmutableList.builder();
+    ListMultimap<String, ModifiedFile> byPath = ArrayListMultimap.create();
+    modifiedFiles.stream()
+        .forEach(
+            f -> {
+              if (f.changeType() == ChangeType.DELETED) {
+                byPath.get(f.oldPath().get()).add(f);
+              } else {
+                byPath.get(f.newPath().get()).add(f);
+              }
+            });
+    for (String path : byPath.keySet()) {
+      List<ModifiedFile> entries = byPath.get(path);
+      if (entries.size() == 1) {
+        result.add(entries.get(0));
+      } else {
+        // More than one. Return a single REWRITE entry.
+        // Convert the first entry (prioritized according to change type enum order) to REWRITE
+        entries.sort(Comparator.comparingInt(o -> o.changeType().ordinal()));
+        result.add(entries.get(0).toBuilder().changeType(ChangeType.REWRITE).build());
+      }
+    }
+    return result.build();
+  }
+
+  /**
    * 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.
diff --git a/java/com/google/gerrit/server/patch/FilePathAdapter.java b/java/com/google/gerrit/server/patch/FilePathAdapter.java
index 2c98f1a..d0b7ac6 100644
--- a/java/com/google/gerrit/server/patch/FilePathAdapter.java
+++ b/java/com/google/gerrit/server/patch/FilePathAdapter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch.ChangeType;
 import java.util.Optional;
 
@@ -30,6 +31,7 @@
   /**
    * Converts the old file path of the new diff cache output to the old diff cache representation.
    */
+  @Nullable
   public static String getOldPath(Optional<String> oldName, ChangeType changeType) {
     switch (changeType) {
       case DELETED:
diff --git a/java/com/google/gerrit/server/patch/MergeListBuilder.java b/java/com/google/gerrit/server/patch/MergeListBuilder.java
index 433fcad..8964956 100644
--- a/java/com/google/gerrit/server/patch/MergeListBuilder.java
+++ b/java/com/google/gerrit/server/patch/MergeListBuilder.java
@@ -16,13 +16,11 @@
 
 import com.google.common.collect.ImmutableList;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class MergeListBuilder {
-  public static List<RevCommit> build(RevWalk rw, RevCommit merge, int uninterestingParent)
+  public static ImmutableList<RevCommit> build(RevWalk rw, RevCommit merge, int uninterestingParent)
       throws IOException {
     rw.reset();
     rw.parseBody(merge);
@@ -40,11 +38,11 @@
       }
     }
 
-    List<RevCommit> result = new ArrayList<>();
+    ImmutableList.Builder<RevCommit> result = ImmutableList.builder();
     RevCommit c;
     while ((c = rw.next()) != null) {
       result.add(c);
     }
-    return result;
+    return result.build();
   }
 }
diff --git a/java/com/google/gerrit/server/patch/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
index 81355cc..7a8180bd 100644
--- a/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/java/com/google/gerrit/server/patch/PatchFile.java
@@ -57,8 +57,9 @@
       throws IOException {
     this.repo = repo;
     this.diff =
-        modifiedFiles.values().stream()
-            .filter(f -> f.newPath().isPresent() && f.newPath().get().equals(fileName))
+        modifiedFiles.entrySet().stream()
+            .filter(f -> f.getKey().equals(fileName))
+            .map(Map.Entry::getValue)
             .findFirst()
             .orElse(FileDiffOutput.empty(fileName, ObjectId.zeroId(), ObjectId.zeroId()));
 
@@ -96,7 +97,13 @@
         bTree = null;
       } else {
         if (diff.oldCommitId() != null) {
-          aTree = rw.parseTree(diff.oldCommitId());
+          if (diff.oldCommitId().equals(ObjectId.zeroId())) {
+            // DiffOperations returns ObjectId.zeroId if newCommit is a root commit, i.e. has no
+            // parents.
+            aTree = null;
+          } else {
+            aTree = rw.parseTree(diff.oldCommitId());
+          }
         } else {
           final RevCommit p = bCommit.getParent(0);
           rw.parseHeaders(p);
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 33300e3..1612925 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
 import com.google.gerrit.entities.FixReplacement;
@@ -209,6 +210,7 @@
     }
   }
 
+  @Nullable
   private static String oldName(PatchFileChange entry) {
     switch (entry.getChangeType()) {
       case ADDED:
@@ -224,6 +226,7 @@
     }
   }
 
+  @Nullable
   private static String newName(PatchFileChange entry) {
     switch (entry.getChangeType()) {
       case DELETED:
@@ -412,6 +415,7 @@
           treeId, path, id, mode, srcContent, src, mimeType, displayMethod, fileMode);
     }
 
+    @Nullable
     private TreeWalk find(ObjectReader reader, String path, ObjectId within) throws IOException {
       if (path == null || within == null) {
         return null;
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 02f125a..3baa3b1 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -175,10 +175,8 @@
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
 
-    try {
-      permissionBackend.user(currentUser).change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new NoSuchChangeException(changeId, e);
+    if (!permissionBackend.user(currentUser).change(notes).test(ChangePermission.READ)) {
+      throw new NoSuchChangeException(changeId);
     }
 
     if (!projectCache
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
index accd2bd..348e244 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactoryForAutoFix.java
@@ -97,10 +97,8 @@
       throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException, ResourceNotFoundException {
 
-    try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new NoSuchChangeException(changeId, e);
+    if (!permissionBackend.currentUser().change(notes).test(ChangePermission.READ)) {
+      throw new NoSuchChangeException(changeId);
     }
 
     if (!projectCache
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 572d73d..7562b49 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -70,6 +70,7 @@
  */
 public class SubmitWithStickyApprovalDiff {
   private static final int HEAP_EST_SIZE = 32 * 1024;
+  private static final int DEFAULT_POST_SUBMIT_SIZE_LIMIT = 300 * 1024; // 300 KiB
 
   private final DiffOperations diffOperations;
   private final ProjectCache projectCache;
@@ -88,6 +89,15 @@
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
     this.repositoryManager = repositoryManager;
+    // (November 2021) We define the max cumulative comment size to 300 KIB since it's a reasonable
+    // size that is large enough for all purposes but not too large to choke the change index by
+    // exceeding the cumulative comment size limit (new comments are not allowed once the limit
+    // is reached). At Google, the change index limit is 5MB, while the cumulative size limit is
+    // set at 3MB. In this example, we can reach at most 3.3MB hence we ensure not to exceed the
+    // limit of 5MB.
+    // The reason we exclude the post submit diff from the cumulative comment size limit is
+    // just because change messages not currently being validated. Change messages are still
+    // counted towards the limit, though.
     maxCumulativeSize =
         serverConfig.getInt(
             "change",
@@ -129,7 +139,9 @@
 
     diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
     TemporaryBuffer.Heap buffer =
-        new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxCumulativeSize), maxCumulativeSize);
+        new TemporaryBuffer.Heap(
+            Math.min(HEAP_EST_SIZE, DEFAULT_POST_SUBMIT_SIZE_LIMIT),
+            DEFAULT_POST_SUBMIT_SIZE_LIMIT);
     try (Repository repository = repositoryManager.openRepository(notes.getProjectName());
         DiffFormatter formatter = new DiffFormatter(buffer)) {
       formatter.setRepository(repository);
@@ -150,6 +162,12 @@
           throw e;
         }
       }
+      if (formatterResult != null) {
+        int addedBytes = formatterResult.stream().mapToInt(String::length).sum();
+        if (!CommentCumulativeSizeValidator.isEnoughSpace(notes, addedBytes, maxCumulativeSize)) {
+          isDiffTooLarge = true;
+        }
+      }
       for (FileDiffOutput fileDiff : modifiedFilesList) {
         diff.append(
             getDiffForFile(
@@ -276,7 +294,7 @@
     ProjectState projectState =
         projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
     PatchSet.Id maxPatchSetId = PatchSet.id(notes.getChangeId(), 1);
-    for (PatchSetApproval patchSetApproval : notes.getApprovals().values()) {
+    for (PatchSetApproval patchSetApproval : notes.getApprovals().onlyNonCopied().values()) {
       if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
         continue;
       }
@@ -299,7 +317,8 @@
   private Map<String, FileDiffOutput> listModifiedFiles(
       Project.NameKey project, PatchSet ps, PatchSet priorPatchSet) {
     try {
-      return diffOperations.listModifiedFiles(project, priorPatchSet.commitId(), ps.commitId());
+      return diffOperations.listModifiedFiles(
+          project, priorPatchSet.commitId(), ps.commitId(), DiffOptions.DEFAULTS);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't post diff messsage on submit although "
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
index 76d1710..28c57cb 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.diff;
 
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
index 460c2e2..41f2fed 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.diff;
 
@@ -18,14 +18,11 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
@@ -40,7 +37,6 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -86,7 +82,7 @@
             .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
             .maximumWeight(10 << 20)
             .weigher(ModifiedFilesWeigher.class)
-            .version(3)
+            .version(4)
             .loader(ModifiedFilesLoader.class);
       }
     };
@@ -143,14 +139,15 @@
               .bTree(bTree)
               .renameScore(key.renameScore())
               .build();
-      List<ModifiedFile> modifiedFiles = mergeRewrittenEntries(gitCache.get(gitKey));
+      ImmutableList<ModifiedFile> modifiedFiles =
+          DiffUtil.mergeRewrittenModifiedFiles(gitCache.get(gitKey));
       if (key.aCommit().equals(ObjectId.zeroId())) {
-        return ImmutableList.copyOf(modifiedFiles);
+        return modifiedFiles;
       }
       RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
       RevCommit revCommitB = DiffUtil.getRevCommit(rw, key.bCommit());
       if (DiffUtil.areRelated(revCommitA, revCommitB)) {
-        return ImmutableList.copyOf(modifiedFiles);
+        return modifiedFiles;
       }
       Set<String> touchedFiles =
           getTouchedFilesWithParents(
@@ -206,37 +203,5 @@
       // value as the set of file paths shouldn't contain it.
       return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
     }
-
-    /**
-     * Return the {@code modifiedFiles} input list while merging rewritten entries.
-     *
-     * <p>Background: In some cases, JGit returns two diff entries (ADDED/DELETED, RENAMED/DELETED,
-     * etc...) for the same file path. This happens e.g. when a file's mode is changed between
-     * patchsets, for example converting a symlink file to a regular file. We identify this case and
-     * return a single modified file with changeType = {@link ChangeType#REWRITE}.
-     */
-    private static List<ModifiedFile> mergeRewrittenEntries(List<ModifiedFile> modifiedFiles) {
-      List<ModifiedFile> result = new ArrayList<>();
-      ListMultimap<String, ModifiedFile> byPath = ArrayListMultimap.create();
-      modifiedFiles.stream()
-          .forEach(
-              f -> {
-                if (f.changeType() == ChangeType.DELETED) {
-                  byPath.get(f.oldPath().get()).add(f);
-                } else {
-                  byPath.get(f.newPath().get()).add(f);
-                }
-              });
-      for (String path : byPath.keySet()) {
-        List<ModifiedFile> entries = byPath.get(path);
-        if (entries.size() == 1) {
-          result.add(entries.get(0));
-        } else {
-          // More than one. Return a single REWRITE entry.
-          result.add(entries.get(0).toBuilder().changeType(ChangeType.REWRITE).build());
-        }
-      }
-      return result;
-    }
   }
 }
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
index 512da6f..c569de8 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.diff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/Edit.java b/java/com/google/gerrit/server/patch/filediff/Edit.java
index 4a698a4..a22857f 100644
--- a/java/com/google/gerrit/server/patch/filediff/Edit.java
+++ b/java/com/google/gerrit/server/patch/filediff/Edit.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
index a9bcf03..df8bc09 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 92c3b39..d1bda5c 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.filediff;
 
@@ -97,7 +97,7 @@
         persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
             .maximumWeight(10 << 20)
             .weigher(FileDiffWeigher.class)
-            .version(8)
+            .version(9)
             .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
             .loader(FileDiffLoader.class);
@@ -443,6 +443,8 @@
                 .patchType(mainGitDiff.patchType())
                 .oldPath(mainGitDiff.oldPath())
                 .newPath(mainGitDiff.newPath())
+                .oldMode(mainGitDiff.oldMode())
+                .newMode(mainGitDiff.newMode())
                 .headerLines(FileHeaderUtil.getHeaderLines(mainGitDiff.fileHeader()))
                 .edits(asTaggedEdits(mainGitDiff.edits(), rebaseEdits))
                 .size(newSize)
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 242c1a4..9286f47 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -1,25 +1,28 @@
-//  Copyright (C) 2020 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.filediff;
 
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 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.FileMode;
 import com.google.gerrit.entities.Patch.PatchType;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
@@ -61,6 +64,18 @@
    */
   public abstract Optional<String> newPath();
 
+  /**
+   * The file mode of the old file at the old git tree diff identified by {@link #oldCommitId()}
+   * ()}.
+   */
+  public abstract Optional<Patch.FileMode> oldMode();
+
+  /**
+   * The file mode of the new file at the new git tree diff identified by {@link #newCommitId()}
+   * ()}.
+   */
+  public abstract Optional<Patch.FileMode> newMode();
+
   /** The change type of the underlying file, e.g. added, deleted, renamed, etc... */
   public abstract Patch.ChangeType changeType();
 
@@ -201,6 +216,10 @@
 
     public abstract Builder newPath(Optional<String> value);
 
+    public abstract Builder oldMode(Optional<Patch.FileMode> oldMode);
+
+    public abstract Builder newMode(Optional<Patch.FileMode> newMode);
+
     public abstract Builder changeType(ChangeType value);
 
     public abstract Builder patchType(Optional<PatchType> value);
@@ -221,6 +240,9 @@
   public enum Serializer implements CacheSerializer<FileDiffOutput> {
     INSTANCE;
 
+    private static final Converter<String, FileMode> FILE_MODE_CONVERTER =
+        Enums.stringConverter(Patch.FileMode.class);
+
     private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(1);
 
@@ -233,6 +255,12 @@
     private static final FieldDescriptor NEGATIVE_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(12);
 
+    private static final FieldDescriptor OLD_MODE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(13);
+
+    private static final FieldDescriptor NEW_MODE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(14);
+
     @Override
     public byte[] serialize(FileDiffOutput fileDiff) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -277,6 +305,13 @@
         builder.setNegative(fileDiff.negative().get());
       }
 
+      if (fileDiff.oldMode().isPresent()) {
+        builder.setOldMode(FILE_MODE_CONVERTER.reverse().convert(fileDiff.oldMode().get()));
+      }
+      if (fileDiff.newMode().isPresent()) {
+        builder.setNewMode(FILE_MODE_CONVERTER.reverse().convert(fileDiff.newMode().get()));
+      }
+
       return Protos.toByteArray(builder.build());
     }
 
@@ -318,6 +353,12 @@
       if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
         builder.negative(Optional.of(proto.getNegative()));
       }
+      if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
+        builder.oldMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getOldMode())));
+      }
+      if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
+        builder.newMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getNewMode())));
+      }
       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
index 8eda234..b92ba8e 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/FileEdits.java b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
index a009a02..eae78aa 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileEdits.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileEdits.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
index 3720680..74ac18f 100644
--- a/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
+++ b/java/com/google/gerrit/server/patch/filediff/TaggedEdit.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.filediff;
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
index d178f22..676b55f 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.gitdiff;
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
index b7144d7..b70f6e1 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.gitdiff;
 
@@ -82,7 +82,7 @@
             .weigher(GitModifiedFilesWeigher.class)
             // The cache is using the default disk limit as per section cache.<name>.diskLimit
             // in the cache documentation link.
-            .version(1)
+            .version(2)
             .loader(GitModifiedFilesCacheImpl.Loader.class);
       }
     };
@@ -131,6 +131,8 @@
           df.setDetectRenames(true);
           df.getRenameDetector().setRenameScore(key.renameScore());
         }
+        // Skip detecting content renames for binary files.
+        df.getRenameDetector().setSkipContentRenamesForBinaryFiles(true);
         // The scan method only returns the file paths that are different. Callers may choose to
         // format these paths themselves.
         return df.scan(key.aTree().equals(ObjectId.zeroId()) ? null : key.aTree(), key.bTree());
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
index a678379..dfe4cf8 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.gitdiff;
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
index f4e7ca3..62d66c0 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -1,20 +1,22 @@
-//  Copyright (C) 2020 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.gitdiff;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.ModifiedFileProto;
@@ -47,6 +49,10 @@
    */
   public abstract Optional<String> newPath();
 
+  public String getDefaultPath() {
+    return newPath().isPresent() ? newPath().get() : oldPath().get();
+  }
+
   public static Builder builder() {
     return new AutoValue_ModifiedFile.Builder();
   }
@@ -80,6 +86,9 @@
   enum Serializer implements CacheSerializer<ModifiedFile> {
     INSTANCE;
 
+    private static final Converter<String, ChangeType> CHANGE_TYPE_CONVERTER =
+        Enums.stringConverter(ChangeType.class);
+
     private static final FieldDescriptor oldPathDescriptor =
         ModifiedFileProto.getDescriptor().findFieldByNumber(2);
 
@@ -93,7 +102,7 @@
 
     public ModifiedFileProto toProto(ModifiedFile modifiedFile) {
       ModifiedFileProto.Builder builder = ModifiedFileProto.newBuilder();
-      builder.setChangeType(modifiedFile.changeType().toString());
+      builder.setChangeType(CHANGE_TYPE_CONVERTER.reverse().convert(modifiedFile.changeType()));
       if (modifiedFile.oldPath().isPresent()) {
         builder.setOldPath(modifiedFile.oldPath().get());
       }
@@ -111,7 +120,7 @@
 
     public ModifiedFile fromProto(ModifiedFileProto modifiedFileProto) {
       ModifiedFile.Builder builder = ModifiedFile.builder();
-      builder.changeType(ChangeType.valueOf(modifiedFileProto.getChangeType()));
+      builder.changeType(CHANGE_TYPE_CONVERTER.convert(modifiedFileProto.getChangeType()));
 
       if (modifiedFileProto.hasField(oldPathDescriptor)) {
         builder.oldPath(Optional.of(modifiedFileProto.getOldPath()));
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
index 7454f81..5b1e343 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.gitfilediff;
 
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index 2f23c8c..d0d024c 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.gitfilediff;
 
@@ -18,6 +18,8 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Patch;
@@ -232,6 +234,15 @@
   public enum Serializer implements CacheSerializer<GitFileDiff> {
     INSTANCE;
 
+    private static final Converter<String, Patch.FileMode> FILE_MODE_CONVERTER =
+        Enums.stringConverter(Patch.FileMode.class);
+
+    private static final Converter<String, Patch.ChangeType> CHANGE_TYPE_CONVERTER =
+        Enums.stringConverter(Patch.ChangeType.class);
+
+    private static final Converter<String, Patch.PatchType> PATCH_TYPE_CONVERTER =
+        Enums.stringConverter(Patch.PatchType.class);
+
     private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
         GitFileDiffProto.getDescriptor().findFieldByNumber(3);
 
@@ -258,7 +269,7 @@
               .setFileHeader(gitFileDiff.fileHeader())
               .setOldId(idConverter.toByteString(gitFileDiff.oldId().toObjectId()))
               .setNewId(idConverter.toByteString(gitFileDiff.newId().toObjectId()))
-              .setChangeType(gitFileDiff.changeType().name());
+              .setChangeType(CHANGE_TYPE_CONVERTER.reverse().convert(gitFileDiff.changeType()));
       gitFileDiff
           .edits()
           .forEach(
@@ -276,13 +287,13 @@
         builder.setNewPath(gitFileDiff.newPath().get());
       }
       if (gitFileDiff.oldMode().isPresent()) {
-        builder.setOldMode(gitFileDiff.oldMode().get().name());
+        builder.setOldMode(FILE_MODE_CONVERTER.reverse().convert(gitFileDiff.oldMode().get()));
       }
       if (gitFileDiff.newMode().isPresent()) {
-        builder.setNewMode(gitFileDiff.newMode().get().name());
+        builder.setNewMode(FILE_MODE_CONVERTER.reverse().convert(gitFileDiff.newMode().get()));
       }
       if (gitFileDiff.patchType().isPresent()) {
-        builder.setPatchType(gitFileDiff.patchType().get().name());
+        builder.setPatchType(PATCH_TYPE_CONVERTER.reverse().convert(gitFileDiff.patchType().get()));
       }
       if (gitFileDiff.negative().isPresent()) {
         builder.setNegative(gitFileDiff.negative().get());
@@ -303,7 +314,7 @@
           .fileHeader(proto.getFileHeader())
           .oldId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getOldId())))
           .newId(AbbreviatedObjectId.fromObjectId(idConverter.fromByteString(proto.getNewId())))
-          .changeType(ChangeType.valueOf(proto.getChangeType()));
+          .changeType(CHANGE_TYPE_CONVERTER.convert(proto.getChangeType()));
 
       if (proto.hasField(OLD_PATH_DESCRIPTOR)) {
         builder.oldPath(Optional.of(proto.getOldPath()));
@@ -312,13 +323,13 @@
         builder.newPath(Optional.of(proto.getNewPath()));
       }
       if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
-        builder.oldMode(Optional.of(Patch.FileMode.valueOf(proto.getOldMode())));
+        builder.oldMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getOldMode())));
       }
       if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
-        builder.newMode(Optional.of(Patch.FileMode.valueOf(proto.getNewMode())));
+        builder.newMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getNewMode())));
       }
       if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
-        builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
+        builder.patchType(Optional.of(PATCH_TYPE_CONVERTER.convert(proto.getPatchType())));
       }
       if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
         builder.negative(Optional.of(proto.getNegative()));
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
index 2516761..adaeb90 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCache.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.gitfilediff;
 
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index f293a64..43a212c 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.gitfilediff;
 
@@ -28,6 +28,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 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;
@@ -43,6 +44,7 @@
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.util.git.CloseablePool;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -59,17 +61,16 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffEntry;
 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.errors.MissingObjectException;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.patch.FileHeader;
 import org.eclipse.jgit.util.io.DisabledOutputStream;
@@ -77,6 +78,7 @@
 /** Implementation of the {@link GitFileDiffCache} */
 @Singleton
 public class GitFileDiffCacheImpl implements GitFileDiffCache {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final String GIT_DIFF = "git_file_diff";
 
   public static Module module() {
@@ -156,16 +158,6 @@
   }
 
   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;
     private final ExecutorService diffExecutor;
     private final long timeoutMillis;
@@ -183,7 +175,7 @@
           ConfigUtil.getTimeUnit(
               cfg,
               "cache",
-              "diff",
+              GIT_DIFF,
               "timeout",
               TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
               TimeUnit.MILLISECONDS);
@@ -218,8 +210,7 @@
                 .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()) {
+          try (Repository repo = repoManager.openRepository(entry.getKey())) {
 
             // Grouping keys by diff options because each group of keys will be processed with a
             // separate call to JGit using the DiffFormatter object.
@@ -228,7 +219,7 @@
 
             for (Map.Entry<DiffOptions, List<GitFileDiffCacheKey>> group :
                 optionsGroups.entrySet()) {
-              result.putAll(loadAllImpl(repo, reader, group.getKey(), group.getValue()));
+              result.putAll(loadAllImpl(repo, group.getKey(), group.getValue()));
             }
           }
         }
@@ -243,42 +234,46 @@
      * @return The git file diffs for all input keys.
      */
     private Map<GitFileDiffCacheKey, GitFileDiff> loadAllImpl(
-        Repository repo, ObjectReader reader, DiffOptions options, List<GitFileDiffCacheKey> keys)
+        Repository repo, DiffOptions options, List<GitFileDiffCacheKey> keys)
         throws IOException, DiffNotAvailableException {
       ImmutableMap.Builder<GitFileDiffCacheKey, GitFileDiff> result =
           ImmutableMap.builderWithExpectedSize(keys.size());
       Map<GitFileDiffCacheKey, String> filePaths =
           keys.stream().collect(Collectors.toMap(identity(), GitFileDiffCacheKey::newFilePath));
-      DiffFormatter formatter = createDiffFormatter(options, repo, reader);
-      ListMultimap<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.empty(
-                  AbbreviatedObjectId.fromObjectId(key.oldTree()),
-                  AbbreviatedObjectId.fromObjectId(key.newTree()),
-                  newFilePath));
-          continue;
+      try (CloseablePool<DiffFormatter> diffPool =
+          new CloseablePool<>(() -> createDiffFormatter(options, repo))) {
+        ListMultimap<String, DiffEntry> diffEntries;
+        try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+          diffEntries = loadDiffEntries(formatter.get(), options, filePaths.values());
         }
-        List<DiffEntry> entries = diffEntries.get(newFilePath);
-        if (entries.size() == 1) {
-          result.put(key, createGitFileDiff(entries.get(0), formatter, key));
-        } else {
-          // Handle when JGit returns two {Added, Deleted} entries for the same file. This happens,
-          // for example, when a file's mode is changed between patchsets (e.g. converting a
-          // symlink to a regular file). We combine both diff entries into a single entry with
-          // {changeType = Rewrite}.
-          List<GitFileDiff> gitDiffs = new ArrayList<>();
-          for (DiffEntry entry : diffEntries.get(newFilePath)) {
-            gitDiffs.add(createGitFileDiff(entry, formatter, key));
+        for (GitFileDiffCacheKey key : filePaths.keySet()) {
+          String newFilePath = filePaths.get(key);
+          if (!diffEntries.containsKey(newFilePath)) {
+            result.put(
+                key,
+                GitFileDiff.empty(
+                    AbbreviatedObjectId.fromObjectId(key.oldTree()),
+                    AbbreviatedObjectId.fromObjectId(key.newTree()),
+                    newFilePath));
+            continue;
           }
-          result.put(key, createRewriteEntry(gitDiffs));
+          List<DiffEntry> entries = diffEntries.get(newFilePath);
+          if (entries.size() == 1) {
+            result.put(key, createGitFileDiff(entries.get(0), key, diffPool));
+          } else {
+            // Handle when JGit returns two {Added, Deleted} entries for the same file. This
+            // happens, for example, when a file's mode is changed between patchsets (e.g.
+            // converting a symlink to a regular file). We combine both diff entries into a single
+            // entry with {changeType = Rewrite}.
+            List<GitFileDiff> gitDiffs = new ArrayList<>();
+            for (DiffEntry entry : diffEntries.get(newFilePath)) {
+              gitDiffs.add(createGitFileDiff(entry, key, diffPool));
+            }
+            result.put(key, createRewriteEntry(gitDiffs));
+          }
         }
+        return result.build();
       }
-      return result.build();
     }
 
     private static ListMultimap<String, DiffEntry> loadDiffEntries(
@@ -291,18 +286,17 @@
               diffOptions.newTree());
 
       return diffEntries.stream()
-          .filter(d -> filePathsSet.contains(pathExtractor.apply(d)))
+          .filter(d -> filePathsSet.contains(extractPath(d)))
           .collect(
               Multimaps.toMultimap(
-                  d -> pathExtractor.apply(d),
+                  Loader::extractPath,
                   identity(),
                   MultimapBuilder.treeKeys().arrayListValues()::build));
     }
 
-    private static DiffFormatter createDiffFormatter(
-        DiffOptions diffOptions, Repository repo, ObjectReader reader) {
+    private static DiffFormatter createDiffFormatter(DiffOptions diffOptions, Repository repo) {
       try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        diffFormatter.setReader(reader, repo.getConfig());
+        diffFormatter.setRepository(repo);
         RawTextComparator cmp = comparatorFor(diffOptions.whitespace());
         diffFormatter.setDiffComparator(cmp);
         if (diffOptions.renameScore() != -1) {
@@ -345,25 +339,30 @@
      *       timeout enforcement.
      */
     private GitFileDiff createGitFileDiff(
-        DiffEntry diffEntry, DiffFormatter formatter, GitFileDiffCacheKey key) throws IOException {
+        DiffEntry diffEntry, GitFileDiffCacheKey key, CloseablePool<DiffFormatter> diffPool)
+        throws IOException {
       if (!key.useTimeout()) {
-        FileHeader fileHeader = formatter.toFileHeader(diffEntry);
-        return GitFileDiff.create(diffEntry, fileHeader);
+        try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+          return GitFileDiff.create(diffEntry, getFileHeader(formatter, diffEntry));
+        }
       }
-      Future<FileHeader> fileHeaderFuture =
+      // This submits the DiffFormatter to a different thread. The CloseablePool and our usage of it
+      // ensures that any DiffFormatter instance and the ObjectReader it references internally is
+      // only used by a single thread concurrently. However, ObjectReaders have a reference to
+      // Repository which might not be thread safe (FileRepository is, DfsRepository might not).
+      // This could lead to a race condition.
+      Future<GitFileDiff> fileDiffFuture =
           diffExecutor.submit(
               () -> {
-                synchronized (diffEntry) {
-                  return formatter.toFileHeader(diffEntry);
+                try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+                  return GitFileDiff.create(diffEntry, getFileHeader(formatter, diffEntry));
                 }
               });
       try {
         // We employ the timeout because of a bug in Myers diff in JGit. See
         // bugs.chromium.org/p/gerrit/issues/detail?id=487 for more details. The bug may happen
         // if the algorithm used in diffs is HISTOGRAM_WITH_FALLBACK_MYERS.
-        fileHeaderFuture.get(timeoutMillis, TimeUnit.MILLISECONDS);
-        FileHeader fileHeader = formatter.toFileHeader(diffEntry);
-        return GitFileDiff.create(diffEntry, fileHeader);
+        return fileDiffFuture.get(timeoutMillis, TimeUnit.MILLISECONDS);
       } catch (InterruptedException | TimeoutException e) {
         // If timeout happens, create a negative result
         metrics.timeouts.increment();
@@ -378,6 +377,56 @@
         throw new IOException(e.getMessage(), e.getCause());
       }
     }
+
+    /**
+     * Extract 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 String extractPath(DiffEntry diffEntry) {
+      return diffEntry.getChangeType().equals(ChangeType.DELETE)
+          ? diffEntry.getOldPath()
+          : diffEntry.getNewPath();
+    }
+
+    private FileHeader getFileHeader(
+        CloseablePool<DiffFormatter>.Handle formatter, DiffEntry diffEntry) throws IOException {
+      logger.atFine().log("getting file header for %s", formatDiffEntryForLogging(diffEntry));
+      try {
+        return formatter.get().toFileHeader(diffEntry);
+      } catch (MissingObjectException e) {
+        throw new IOException(
+            String.format("Failed to get file header for %s", formatDiffEntryForLogging(diffEntry)),
+            e);
+      }
+    }
+
+    private String formatDiffEntryForLogging(DiffEntry diffEntry) {
+      StringBuilder buf = new StringBuilder();
+      buf.append("DiffEntry[");
+      buf.append(diffEntry.getChangeType());
+      buf.append(" ");
+      switch (diffEntry.getChangeType()) {
+        case ADD:
+          buf.append(String.format("%s (%s)", diffEntry.getNewPath(), diffEntry.getNewId().name()));
+          break;
+        case COPY:
+        case RENAME:
+          buf.append(
+              String.format(
+                  "%s (%s) -> %s (%s)",
+                  diffEntry.getOldPath(),
+                  diffEntry.getOldId().name(),
+                  diffEntry.getNewPath(),
+                  diffEntry.getNewId().name()));
+          break;
+        case DELETE:
+        case MODIFY:
+          buf.append(String.format("%s (%s)", diffEntry.getOldPath(), diffEntry.getOldId().name()));
+          break;
+      }
+      buf.append("]");
+      return buf.toString();
+    }
   }
 
   /**
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
index 2d80614..2e18e93 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.server.patch.DiffUtil.stringSize;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -104,6 +106,12 @@
   public enum Serializer implements CacheSerializer<GitFileDiffCacheKey> {
     INSTANCE;
 
+    private static final Converter<String, DiffAlgorithm> DIFF_ALGORITHM_CONVERTER =
+        Enums.stringConverter(DiffAlgorithm.class);
+
+    private static final Converter<String, Whitespace> WHITESPACE_CONVERTER =
+        Enums.stringConverter(Whitespace.class);
+
     @Override
     public byte[] serialize(GitFileDiffCacheKey key) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -114,8 +122,8 @@
               .setBTree(idConverter.toByteString(key.newTree()))
               .setFilePath(key.newFilePath())
               .setRenameScore(key.renameScore())
-              .setDiffAlgorithm(key.diffAlgorithm().name())
-              .setWhitepsace(key.whitespace().name())
+              .setDiffAlgorithm(DIFF_ALGORITHM_CONVERTER.reverse().convert(key.diffAlgorithm()))
+              .setWhitepsace(WHITESPACE_CONVERTER.reverse().convert(key.whitespace()))
               .setUseTimeout(key.useTimeout())
               .build());
     }
@@ -130,8 +138,8 @@
           .newTree(idConverter.fromByteString(proto.getBTree()))
           .newFilePath(proto.getFilePath())
           .renameScore(proto.getRenameScore())
-          .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
-          .whitespace(Whitespace.valueOf(proto.getWhitepsace()))
+          .diffAlgorithm(DIFF_ALGORITHM_CONVERTER.convert(proto.getDiffAlgorithm()))
+          .whitespace(WHITESPACE_CONVERTER.convert(proto.getWhitepsace()))
           .useTimeout(proto.getUseTimeout())
           .build();
     }
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
index 47f7791..7651517 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffWeigher.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.gitfilediff;
 
diff --git a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
new file mode 100644
index 0000000..622f0cf
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.server.util.LabelVote;
+
+/** Abstract permission representing a label. */
+public abstract class AbstractLabelPermission implements ChangePermissionOrLabel {
+  public enum ForUser {
+    SELF,
+    ON_BEHALF_OF
+  }
+
+  protected final ForUser forUser;
+  protected final String name;
+
+  /**
+   * Construct a reference to an abstract label permission.
+   *
+   * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public AbstractLabelPermission(ForUser forUser, String name) {
+    this.forUser = requireNonNull(forUser, "ForUser");
+    this.name = LabelType.checkName(name);
+  }
+
+  /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+  public ForUser forUser() {
+    return forUser;
+  }
+
+  /** Returns name of the label, e.g. {@code "Code-Review"}. */
+  public String label() {
+    return name;
+  }
+
+  protected abstract String permissionPrefix();
+
+  protected String permissionName() {
+    if (forUser == ON_BEHALF_OF) {
+      return permissionPrefix() + "As";
+    }
+    return permissionPrefix();
+  }
+
+  @Override
+  public final String describeForException() {
+    if (forUser == ON_BEHALF_OF) {
+      return permissionPrefix() + " on behalf of " + name;
+    }
+    return permissionPrefix() + " " + name;
+  }
+
+  @Override
+  public final int hashCode() {
+    return (permissionPrefix() + name).hashCode();
+  }
+
+  @Override
+  @SuppressWarnings("EqualsGetClass")
+  public final boolean equals(Object other) {
+    if (this.getClass().isAssignableFrom(other.getClass())) {
+      AbstractLabelPermission b = (AbstractLabelPermission) other;
+      return forUser == b.forUser && name.equals(b.name);
+    }
+    return false;
+  }
+
+  @Override
+  public final String toString() {
+    return permissionName() + "[" + name + ']';
+  }
+
+  /** A {@link AbstractLabelPermission} at a specific value. */
+  public abstract static class WithValue implements ChangePermissionOrLabel {
+    private final ForUser forUser;
+    private final LabelVote label;
+
+    /**
+     * Construct a reference to an abstract label permission at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param label label name and vote.
+     */
+    public WithValue(ForUser forUser, LabelVote label) {
+      this.forUser = requireNonNull(forUser, "ForUser");
+      this.label = requireNonNull(label, "LabelVote");
+    }
+
+    /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+    public ForUser forUser() {
+      return forUser;
+    }
+
+    /** Returns name of the label, e.g. {@code "Code-Review"}. */
+    public String label() {
+      return label.label();
+    }
+
+    /** Returns specific value of the label, e.g. 1 or 2. */
+    public short value() {
+      return label.value();
+    }
+
+    public abstract String permissionName();
+
+    @Override
+    public final String describeForException() {
+      if (forUser == ON_BEHALF_OF) {
+        return permissionName() + " on behalf of " + label.formatWithEquals();
+      }
+      return permissionName() + " " + label.formatWithEquals();
+    }
+
+    @Override
+    public final int hashCode() {
+      return (permissionName() + label).hashCode();
+    }
+
+    @Override
+    @SuppressWarnings("EqualsGetClass")
+    public final boolean equals(Object other) {
+      if (this.getClass().isAssignableFrom(other.getClass())) {
+        AbstractLabelPermission.WithValue b = (AbstractLabelPermission.WithValue) other;
+        return forUser == b.forUser && label.equals(b.label);
+      }
+      return false;
+    }
+
+    @Override
+    public final String toString() {
+      if (forUser == ON_BEHALF_OF) {
+        return permissionName() + "As[" + label.format() + ']';
+      }
+      return permissionName() + "[" + label.format() + ']';
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 37c773a..6f7d761 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.permissions;
 
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
@@ -92,8 +92,7 @@
 
   /** Can this user revert this change? */
   private boolean canRevert() {
-    return (refControl.canRevert())
-        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
+    return refControl.canRevert() && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
   /** The range of permitted values associated with a label permission. */
@@ -241,10 +240,10 @@
     private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
       if (perm instanceof ChangePermission) {
         return can((ChangePermission) perm);
-      } else if (perm instanceof LabelPermission) {
-        return can((LabelPermission) perm);
-      } else if (perm instanceof LabelPermission.WithValue) {
-        return can((LabelPermission.WithValue) perm);
+      } else if (perm instanceof AbstractLabelPermission) {
+        return can((AbstractLabelPermission) perm);
+      } else if (perm instanceof AbstractLabelPermission.WithValue) {
+        return can((AbstractLabelPermission.WithValue) perm);
       }
       throw new PermissionBackendException(perm + " unsupported");
     }
@@ -257,7 +256,7 @@
           case ABANDON:
             return canAbandon();
           case DELETE:
-            return (getProjectControl().isAdmin() || (refControl.canDeleteChanges(isOwner())));
+            return getProjectControl().isAdmin() || refControl.canDeleteChanges(isOwner());
           case ADD_PATCH_SET:
             return canAddPatchSet();
           case EDIT_ASSIGNEE:
@@ -289,11 +288,11 @@
       throw new PermissionBackendException(perm + " unsupported");
     }
 
-    private boolean can(LabelPermission perm) {
+    private boolean can(AbstractLabelPermission perm) {
       return !label(labelPermissionName(perm)).isEmpty();
     }
 
-    private boolean can(LabelPermission.WithValue perm) {
+    private boolean can(AbstractLabelPermission.WithValue perm) {
       PermissionRange r = label(labelPermissionName(perm));
       if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
         return false;
diff --git a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
index 2824efd..f59ba02 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
@@ -16,5 +16,5 @@
 
 import com.google.gerrit.extensions.api.access.GerritPermission;
 
-/** A {@link ChangePermission} or a {@link LabelPermission}. */
+/** A {@link ChangePermission} or a {@link AbstractLabelPermission}. */
 public interface ChangePermissionOrLabel extends GerritPermission {}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 66299a8..aa49852 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -124,11 +123,12 @@
     @Override
     public ForProject project(Project.NameKey project) {
       try {
-        ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
         ProjectControl control =
             PerThreadCache.getOrCompute(
                 PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
-                () -> projectControlFactory.create(user, state));
+                () ->
+                    projectControlFactory.create(
+                        user, projectCache.get(project).orElseThrow(illegalState(project))));
         return control.asForProject();
       } catch (Exception e) {
         Throwable cause = e.getCause() != null ? e.getCause() : e;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index d2e85be..3f84dff 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -31,7 +31,6 @@
       // 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/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 9d69d9b..89f0493 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
 import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.api.access.PluginProjectPermission;
-import com.google.gerrit.server.permissions.LabelPermission.ForUser;
+import com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser;
 import java.util.EnumSet;
 import java.util.Optional;
 import java.util.Set;
@@ -160,19 +160,29 @@
     return Optional.ofNullable(CHANGE_PERMISSIONS.inverse().get(permissionName));
   }
 
-  public static String labelPermissionName(LabelPermission labelPermission) {
-    if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
-      return Permission.forLabelAs(labelPermission.label());
+  public static String labelPermissionName(AbstractLabelPermission labelPermission) {
+    if (labelPermission instanceof LabelPermission) {
+      if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+        return Permission.forLabelAs(labelPermission.label());
+      }
+      return Permission.forLabel(labelPermission.label());
+    } else if (labelPermission instanceof LabelRemovalPermission) {
+      return Permission.forRemoveLabel(labelPermission.label());
     }
-    return Permission.forLabel(labelPermission.label());
+    throw new IllegalStateException("invalid AbstractLabelPermission subtype");
   }
 
   // TODO(dborowitz): Can these share a common superinterface?
-  public static String labelPermissionName(LabelPermission.WithValue labelPermission) {
-    if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
-      return Permission.forLabelAs(labelPermission.label());
+  public static String labelPermissionName(AbstractLabelPermission.WithValue labelPermission) {
+    if (labelPermission instanceof LabelPermission.WithValue) {
+      if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+        return Permission.forLabelAs(labelPermission.label());
+      }
+      return Permission.forLabel(labelPermission.label());
+    } else if (labelPermission instanceof LabelRemovalPermission.WithValue) {
+      return Permission.forRemoveLabel(labelPermission.label());
     }
-    return Permission.forLabel(labelPermission.label());
+    throw new IllegalStateException("invalid AbstractLabelPermission.WithValue subtype");
   }
 
   private DefaultPermissionMappings() {}
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 10aa9cd..eebaa8f 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -15,19 +15,22 @@
 package com.google.gerrit.server.permissions;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static java.util.stream.Collectors.toCollection;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
 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.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.RefNames;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
@@ -41,12 +44,14 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 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.List;
+import java.util.Objects;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -61,38 +66,39 @@
   }
 
   private final TagCache tagCache;
-  private final ChangeNotes.Factory changeNotesFactory;
   private final PermissionBackend permissionBackend;
   private final RefVisibilityControl refVisibilityControl;
   private final ProjectControl projectControl;
   private final CurrentUser user;
   private final ProjectState projectState;
   private final PermissionBackend.ForProject permissionBackendForProject;
+  private final @Nullable SearchingChangeCacheImpl searchingChangeDataProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeNotes.Factory changeNotesFactory;
   private final Counter0 fullFilterCount;
   private final Counter0 skipFilterCount;
   private final boolean skipFullRefEvaluationIfAllRefsAreVisible;
-  private final VisibleChangesCache.Factory visibleChangesCacheFactory;
-
-  private VisibleChangesCache visibleChangesCache;
 
   @Inject
   DefaultRefFilter(
       TagCache tagCache,
-      ChangeNotes.Factory changeNotesFactory,
       PermissionBackend permissionBackend,
       RefVisibilityControl refVisibilityControl,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
-      VisibleChangesCache.Factory visibleChangesCacheFactory,
+      @Nullable SearchingChangeCacheImpl searchingChangeDataProvider,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory changeNotesFactory,
       @Assisted ProjectControl projectControl) {
     this.tagCache = tagCache;
-    this.changeNotesFactory = changeNotesFactory;
     this.permissionBackend = permissionBackend;
     this.refVisibilityControl = refVisibilityControl;
+    this.searchingChangeDataProvider = searchingChangeDataProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.changeNotesFactory = changeNotesFactory;
     this.skipFullRefEvaluationIfAllRefsAreVisible =
         config.getBoolean("auth", "skipFullRefEvaluationIfAllRefsAreVisible", true);
     this.projectControl = projectControl;
-    this.visibleChangesCacheFactory = visibleChangesCacheFactory;
 
     this.user = projectControl.getUser();
     this.projectState = projectControl.getProjectState();
@@ -112,9 +118,8 @@
   }
 
   /** Filters given refs and tags by visibility. */
-  Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
+  ImmutableList<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);
@@ -127,32 +132,26 @@
         "Project state %s permits read = %s",
         projectState.getProject().getState(), projectState.statePermitsRead());
 
-    // See if we can get away with a single, cheap ref evaluation.
-    if (refs.size() == 1) {
-      String refName = Iterables.getOnlyElement(refs).getName();
-      if (opts.filterMeta() && isMetadata(refName)) {
-        logger.atFinest().log("Filter out metadata ref %s", refName);
-        return ImmutableList.of();
-      }
-      if (RefNames.isRefsChanges(refName)) {
-        boolean isChangeRefVisisble = canSeeSingleChangeRef(refName);
-        if (isChangeRefVisisble) {
-          logger.atFinest().log("Change ref %s is visible", refName);
-          return refs;
-        }
-        logger.atFinest().log("Filter out non-visible change ref %s", refName);
-        return ImmutableList.of();
-      }
-    }
-
     // 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), opts);
-    List<Ref> visibleRefs = initialRefFilter.visibleRefs();
+    Supplier<ImmutableMap<Change.Id, ChangeData>> visibleChanges =
+        Suppliers.memoize(
+            () ->
+                GitVisibleChangeFilter.getVisibleChanges(
+                    searchingChangeDataProvider,
+                    changeNotesFactory,
+                    changeDataFactory,
+                    projectState.getNameKey(),
+                    permissionBackendForProject,
+                    repo,
+                    changes(refs)));
+    Result initialRefFilter = filterRefs(new ArrayList<>(refs), opts, visibleChanges);
+    ImmutableList.Builder<Ref> visibleRefs = ImmutableList.builder();
+    visibleRefs.addAll(initialRefFilter.visibleRefs());
     if (!initialRefFilter.deferredTags().isEmpty()) {
       try (TraceTimer traceTimer = TraceContext.newTimer("Check visibility of deferred tags")) {
-        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), opts);
+        Result allVisibleBranches = filterRefs(getTaggableRefs(repo), opts, visibleChanges);
         checkState(
             allVisibleBranches.deferredTags().isEmpty(),
             "unexpected tags found when filtering refs/heads/* "
@@ -177,8 +176,9 @@
       }
     }
 
-    logger.atFinest().log("visible refs = %s", visibleRefs);
-    return visibleRefs;
+    ImmutableList<Ref> visibleRefList = visibleRefs.build();
+    logger.atFinest().log("visible refs = %s", visibleRefList);
+    return visibleRefList;
   }
 
   /**
@@ -186,25 +186,34 @@
    * 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, RefFilterOptions opts) throws PermissionBackendException {
+  Result filterRefs(
+      List<Ref> refs,
+      RefFilterOptions opts,
+      Supplier<ImmutableMap<Change.Id, ChangeData>> visibleChanges)
+      throws PermissionBackendException {
     logger.atFinest().log("Filter refs (refs = %s)", refs);
+    if (!projectState.statePermitsRead()) {
+      return new AutoValue_DefaultRefFilter_Result(ImmutableList.of(), ImmutableList.of());
+    }
 
     // TODO(hiesel): Remove when optimization is done.
     boolean hasReadOnRefsStar =
         checkProjectPermission(permissionBackendForProject, ProjectPermission.READ);
     logger.atFinest().log("User has READ on refs/* = %s", hasReadOnRefsStar);
     if (skipFullRefEvaluationIfAllRefsAreVisible && !projectState.isAllUsers()) {
-      if (projectState.statePermitsRead() && hasReadOnRefsStar) {
+      if (hasReadOnRefsStar) {
         skipFilterCount.increment();
         logger.atFinest().log(
             "Fast path, all refs are visible because user has READ on refs/*: %s", refs);
-        return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
+        return new AutoValue_DefaultRefFilter_Result(
+            ImmutableList.copyOf(refs), ImmutableList.of());
       } else if (projectControl.allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG))) {
         skipFilterCount.increment();
         refs = fastHideRefsMetaConfig(refs);
         logger.atFinest().log(
             "Fast path, all refs except %s are visible: %s", RefNames.REFS_CONFIG, refs);
-        return new AutoValue_DefaultRefFilter_Result(refs, ImmutableList.of());
+        return new AutoValue_DefaultRefFilter_Result(
+            ImmutableList.copyOf(refs), ImmutableList.of());
       }
     }
     logger.atFinest().log("Doing full ref filtering");
@@ -214,8 +223,8 @@
         permissionBackend
             .user(projectControl.getUser())
             .testOrFalse(GlobalPermission.ACCESS_DATABASE);
-    List<Ref> resultRefs = new ArrayList<>(refs.size());
-    List<Ref> deferredTags = new ArrayList<>();
+    ImmutableList.Builder<Ref> resultRefs = ImmutableList.builderWithExpectedSize(refs.size());
+    ImmutableList.Builder<Ref> deferredTags = ImmutableList.builder();
     for (Ref ref : refs) {
       String refName = ref.getName();
       Change.Id changeId;
@@ -251,9 +260,9 @@
         // most recent changes).
         if (hasAccessDatabase) {
           resultRefs.add(ref);
-        } else if (!visibleChangesCache.isVisible(changeId)) {
+        } else if (!visibleChanges.get().containsKey(changeId)) {
           logger.atFinest().log("Filter out invisible change ref %s", refName);
-        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(refName)) {
+        } else if (RefNames.isRefsEdit(refName) && !visibleEdit(refName, visibleChanges.get())) {
           logger.atFinest().log("Filter out invisible change edit ref %s", refName);
         } else {
           // Change is visible
@@ -263,7 +272,7 @@
         resultRefs.add(ref);
       }
     }
-    Result result = new AutoValue_DefaultRefFilter_Result(resultRefs, deferredTags);
+    Result result = new AutoValue_DefaultRefFilter_Result(resultRefs.build(), deferredTags.build());
     logger.atFinest().log("Result of ref filtering = %s", result);
     return result;
   }
@@ -291,6 +300,19 @@
     }
   }
 
+  /**
+   * Returns the number of changes contained in {@code refs}. A change has one meta ref and many
+   * patch set refs. We count over the meta refs to make sure we get the number of unique changes in
+   * the provided refs.
+   */
+  private static ImmutableSet<Change.Id> changes(Collection<Ref> refs) {
+    return refs.stream()
+        .map(Ref::getName)
+        .map(Change.Id::fromRef)
+        .filter(Objects::nonNull)
+        .collect(toImmutableSet());
+  }
+
   private List<Ref> fastHideRefsMetaConfig(List<Ref> refs) throws PermissionBackendException {
     if (!canReadRef(REFS_CONFIG)) {
       return refs.stream()
@@ -300,7 +322,8 @@
     return refs;
   }
 
-  private boolean visibleEdit(String name) throws PermissionBackendException {
+  private boolean visibleEdit(String name, ImmutableMap<Change.Id, ChangeData> visibleChanges)
+      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);
@@ -309,23 +332,19 @@
 
     if (user.isIdentifiedUser()
         && name.startsWith(RefNames.refsEditPrefix(user.asIdentifiedUser().getAccountId()))
-        && visibleChangesCache.isVisible(id)) {
+        && visibleChanges.containsKey(id)) {
       logger.atFinest().log("Own change edit ref is visible: %s", name);
       return true;
     }
 
-    if (visibleChangesCache.isVisible(id)) {
-      try {
-        // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
-        permissionBackendForProject
-            .ref(visibleChangesCache.getBranchNameKey(id).branch())
-            .check(RefPermission.READ_PRIVATE_CHANGES);
-        logger.atFinest().log("Foreign change edit ref is visible: %s", name);
-        return true;
-      } catch (AuthException e) {
-        logger.atFinest().log("Foreign change edit ref is not visible: %s", name);
-        return false;
-      }
+    if (visibleChanges.containsKey(id)) {
+      // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
+      BranchNameKey dest = visibleChanges.get(id).change().getDest();
+      boolean canRead =
+          permissionBackendForProject.ref(dest.branch()).test(RefPermission.READ_PRIVATE_CHANGES);
+      logger.atFinest().log(
+          "Foreign change edit ref is " + (canRead ? "visible" : "invisible") + ": %s", name);
+      return canRead;
     }
 
     logger.atFinest().log("Change %d of change edit ref %s is not visible", id.get(), name);
@@ -343,69 +362,24 @@
   }
 
   private boolean canReadRef(String ref) throws PermissionBackendException {
-    try {
-      permissionBackendForProject.ref(ref).check(RefPermission.READ);
-    } catch (AuthException e) {
-      return false;
-    }
-    return projectState.statePermitsRead();
+    return permissionBackendForProject.ref(ref).test(RefPermission.READ);
   }
 
   private boolean checkProjectPermission(
       PermissionBackend.ForProject forProject, ProjectPermission perm)
       throws PermissionBackendException {
-    try {
-      forProject.check(perm);
-    } catch (AuthException e) {
-      return false;
-    }
-    return true;
-  }
-
-  /**
-   * Returns true if the user can see the provided change ref. Uses NoteDb for evaluation, hence
-   * does not suffer from the limitations documented in {@link SearchingChangeCacheImpl}.
-   *
-   * <p>This code lets users fetch changes that are not among the fraction of most recently modified
-   * changes that {@link SearchingChangeCacheImpl} returns. This works only when Git Protocol v2
-   * with refs-in-wants is used as that enables Gerrit to skip traditional advertisement of all
-   * visible refs.
-   */
-  private boolean canSeeSingleChangeRef(String refName) throws PermissionBackendException {
-    // We are treating just a single change ref. We are therefore not going through regular ref
-    // filtering, but use NoteDb directly. This makes it so that we can always serve this ref
-    // even if the change is not part of the set of most recent changes that
-    // SearchingChangeCacheImpl returns.
-    Change.Id cId = Change.Id.fromRef(refName);
-    if (cId == null) {
-      // The ref is not a valid change ref. Treat it as non-visible since it's not representing a
-      // change.
-      logger.atWarning().log("invalid change ref %s is not visible", refName);
-      return false;
-    }
-    ChangeNotes notes;
-    try {
-      notes = changeNotesFactory.create(projectState.getNameKey(), cId);
-    } catch (StorageException e) {
-      throw new PermissionBackendException("can't construct change notes", e);
-    }
-    try {
-      permissionBackendForProject.change(notes).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return forProject.test(perm);
   }
 
   @AutoValue
   abstract static class Result {
     /** Subset of the refs passed into the computation that is visible to the user. */
-    abstract List<Ref> visibleRefs();
+    abstract ImmutableList<Ref> visibleRefs();
 
     /**
      * List of tags where we couldn't figure out visibility in the first pass and need to do an
      * expensive ref walk.
      */
-    abstract List<Ref> deferredTags();
+    abstract ImmutableList<Ref> deferredTags();
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
new file mode 100644
index 0000000..0e5ff48
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+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.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * This class can tell efficiently if changes are visible to a user. It is intended to be used when
+ * serving Git traffic on the Git wire protocol and in similar use cases when we need to know
+ * efficiently if a (potentially large number) of changes are visible to a user.
+ *
+ * <p>The efficiency of this class comes from heuristic optimization:
+ *
+ * <ul>
+ *   <li>For a low number of expected checks, we check visibility one-by-one.
+ *   <li>For a high number of expected checks and settings where the change index is available, we
+ *       load the N most recent changes from the index and filter them by visibility. This is fast,
+ *       but comes with the caveat that older changes are pretended to be invisible.
+ *   <li>For a high number of expected checks and settings where the change index is unavailable, we
+ *       scan the repo and determine visibility one-by-one. This is *very* expensive.
+ * </ul>
+ *
+ * <p>Changes that fail to load are pretended to be invisible. This is important on the Git paths as
+ * we don't want to advertise change refs where we were unable to check the visibility (e.g. due to
+ * data corruption on that change). At the same time, the overall operation should succeed as
+ * otherwise a single broken change would break Git operations for an entire repo.
+ */
+public class GitVisibleChangeFilter {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final int CHANGE_LIMIT_FOR_DIRECT_FILTERING = 5;
+
+  private GitVisibleChangeFilter() {}
+
+  /** Returns a map of all visible changes. Might pretend old changes are invisible. */
+  static ImmutableMap<Change.Id, ChangeData> getVisibleChanges(
+      @Nullable SearchingChangeCacheImpl searchingChangeCache,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeData.Factory changeDataFactory,
+      Project.NameKey projectName,
+      PermissionBackend.ForProject forProject,
+      Repository repository,
+      ImmutableSet<Change.Id> changes) {
+    Stream<ChangeData> changeDatas;
+    if (changes.size() < CHANGE_LIMIT_FOR_DIRECT_FILTERING) {
+      logger.atFine().log("Loading changes one by one for project %s", projectName);
+      changeDatas = loadChangeDatasOneByOne(changes, changeDataFactory, projectName);
+    } else if (searchingChangeCache != null) {
+      logger.atFine().log("Loading changes from SearchingChangeCache for project %s", projectName);
+      changeDatas = searchingChangeCache.getChangeData(projectName);
+    } else {
+      logger.atFine().log("Loading changes from all refs for project %s", projectName);
+      changeDatas =
+          scanRepoForChangeDatas(changeNotesFactory, changeDataFactory, repository, projectName);
+    }
+    HashMap<Change.Id, ChangeData> result = new HashMap<>();
+    changeDatas
+        .filter(cd -> changes.contains(cd.getId()))
+        .filter(
+            cd -> {
+              try {
+                return forProject.change(cd).test(ChangePermission.READ);
+              } catch (PermissionBackendException e) {
+                throw new StorageException(e);
+              }
+            })
+        .forEach(
+            cd -> {
+              if (result.containsKey(cd.getId())) {
+                logger.atWarning().log(
+                    "Duplicate change datas for the repo %s: [%s, %s]",
+                    projectName, cd, result.get(cd.getId()));
+              }
+              result.put(cd.getId(), cd);
+            });
+    return ImmutableMap.copyOf(result);
+  }
+
+  /** Get a stream of changes by loading them individually. */
+  private static Stream<ChangeData> loadChangeDatasOneByOne(
+      Set<Change.Id> ids, ChangeData.Factory changeDataFactory, Project.NameKey projectName) {
+    return ids.stream()
+        .map(
+            id -> {
+              try {
+                ChangeData cd = changeDataFactory.create(projectName, id);
+                cd.notes(); // Make sure notes are available. This will trigger loading notes and
+                // throw an exception in case the change is corrupt and can't be loaded. It will
+                // then be omitted from the result.
+                return cd;
+              } catch (Exception e) {
+                // We drop changes that we can't load. The repositories contain 'dead' change refs
+                // and we want to overall operation to continue.
+                logger.atFinest().withCause(e).log("Can't load Change notes for %s", id);
+                return null;
+              }
+            })
+        .filter(Objects::nonNull);
+  }
+
+  /** Get a stream of all changes by scanning the repo. This is extremely slow. */
+  private static Stream<ChangeData> scanRepoForChangeDatas(
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeData.Factory changeDataFactory,
+      Repository repository,
+      Project.NameKey projectName) {
+    Stream<ChangeData> cds;
+    try {
+      cds =
+          changeNotesFactory
+              .scan(repository, projectName)
+              .map(
+                  notesResult -> {
+                    if (!notesResult.error().isPresent()) {
+                      return changeDataFactory.create(notesResult.notes());
+                    }
+                    logger.atWarning().withCause(notesResult.error().get()).log(
+                        "Unable to load ChangeNotes for %s", notesResult.id());
+                    return null;
+                  })
+              .filter(Objects::nonNull);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+    return cds;
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index c266caa..4652364 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -14,24 +14,14 @@
 
 package com.google.gerrit.server.permissions;
 
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
-import static java.util.Objects.requireNonNull;
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.SELF;
 
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.server.util.LabelVote;
 
 /** Permission representing a label. */
-public class LabelPermission implements ChangePermissionOrLabel {
-  public enum ForUser {
-    SELF,
-    ON_BEHALF_OF;
-  }
-
-  private final ForUser forUser;
-  private final String name;
-
+public class LabelPermission extends AbstractLabelPermission {
   /**
    * Construct a reference to a label permission.
    *
@@ -67,55 +57,16 @@
    * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
    */
   public LabelPermission(ForUser forUser, String name) {
-    this.forUser = requireNonNull(forUser, "ForUser");
-    this.name = LabelType.checkName(name);
-  }
-
-  /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
-  public ForUser forUser() {
-    return forUser;
-  }
-
-  /** Returns name of the label, e.g. {@code "Code-Review"}. */
-  public String label() {
-    return name;
+    super(forUser, name);
   }
 
   @Override
-  public String describeForException() {
-    if (forUser == ON_BEHALF_OF) {
-      return "label on behalf of " + name;
-    }
-    return "label " + name;
-  }
-
-  @Override
-  public int hashCode() {
-    return name.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other instanceof LabelPermission) {
-      LabelPermission b = (LabelPermission) other;
-      return forUser == b.forUser && name.equals(b.name);
-    }
-    return false;
-  }
-
-  @Override
-  public String toString() {
-    if (forUser == ON_BEHALF_OF) {
-      return "LabelAs[" + name + ']';
-    }
-    return "Label[" + name + ']';
+  public String permissionPrefix() {
+    return "label";
   }
 
   /** A {@link LabelPermission} at a specific value. */
-  public static class WithValue implements ChangePermissionOrLabel {
-    private final ForUser forUser;
-    private final LabelVote label;
-
+  public static class WithValue extends AbstractLabelPermission.WithValue {
     /**
      * Construct a reference to a label at a specific value.
      *
@@ -195,53 +146,12 @@
      * @param label label name and vote.
      */
     public WithValue(ForUser forUser, LabelVote label) {
-      this.forUser = requireNonNull(forUser, "ForUser");
-      this.label = requireNonNull(label, "LabelVote");
-    }
-
-    /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
-    public ForUser forUser() {
-      return forUser;
-    }
-
-    /** Returns name of the label, e.g. {@code "Code-Review"}. */
-    public String label() {
-      return label.label();
-    }
-
-    /** Returns specific value of the label, e.g. 1 or 2. */
-    public short value() {
-      return label.value();
+      super(forUser, label);
     }
 
     @Override
-    public String describeForException() {
-      if (forUser == ON_BEHALF_OF) {
-        return "label on behalf of " + label.formatWithEquals();
-      }
-      return "label " + label.formatWithEquals();
-    }
-
-    @Override
-    public int hashCode() {
-      return label.hashCode();
-    }
-
-    @Override
-    public boolean equals(Object other) {
-      if (other instanceof WithValue) {
-        WithValue b = (WithValue) other;
-        return forUser == b.forUser && label.equals(b.label);
-      }
-      return false;
-    }
-
-    @Override
-    public String toString() {
-      if (forUser == ON_BEHALF_OF) {
-        return "LabelAs[" + label.format() + ']';
-      }
-      return "Label[" + label.format() + ']';
+    public String permissionName() {
+      return "label";
     }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java b/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java
new file mode 100644
index 0000000..2553601
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.SELF;
+
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.util.LabelVote;
+
+/** Permission representing a label removal. */
+public class LabelRemovalPermission extends AbstractLabelPermission {
+  /**
+   * Construct a reference to a label removal permission.
+   *
+   * @param type type description of the label.
+   */
+  public LabelRemovalPermission(LabelType type) {
+    this(type.getName());
+  }
+
+  /**
+   * Construct a reference to a label removal permission.
+   *
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public LabelRemovalPermission(String name) {
+    super(SELF, name);
+  }
+
+  @Override
+  public String permissionPrefix() {
+    return "removeLabel";
+  }
+
+  /** A {@link LabelRemovalPermission} at a specific value. */
+  public static class WithValue extends AbstractLabelPermission.WithValue {
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, LabelValue value) {
+      this(type.getName(), value.getValue());
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, short value) {
+      this(type.getName(), value);
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(String name, short value) {
+      this(LabelVote.create(name, value));
+    }
+
+    /**
+     * Construct a reference to a label removal at a specific value.
+     *
+     * @param label label name and vote.
+     */
+    public WithValue(LabelVote label) {
+      super(SELF, label);
+    }
+
+    @Override
+    public String permissionName() {
+      return "removeLabel";
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 27c6793..eb5e053 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -173,7 +173,13 @@
       return ref(notes.getChange().getDest()).change(notes);
     }
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(GlobalOrPluginPermission)}.
+     */
     public abstract void check(GlobalOrPluginPermission perm)
         throws AuthException, PermissionBackendException;
 
@@ -240,10 +246,9 @@
       Set<Project.NameKey> allowed = Sets.newHashSetWithExpectedSize(projects.size());
       for (Project.NameKey project : projects) {
         try {
-          project(project).check(perm);
-          allowed.add(project);
-        } catch (AuthException e) {
-          // Do not include this project in allowed.
+          if (project(project).test(perm)) {
+            allowed.add(project);
+          }
         } catch (PermissionBackendException e) {
           if (e.getCause() instanceof RepositoryNotFoundException) {
             logger.atWarning().withCause(e).log(
@@ -280,7 +285,13 @@
       return ref(notes.getChange().getDest().branch()).change(notes);
     }
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(CoreOrPluginProjectPermission)}.
+     */
     public abstract void check(CoreOrPluginProjectPermission perm)
         throws AuthException, PermissionBackendException;
 
@@ -368,7 +379,13 @@
     /** Returns an instance scoped to change. */
     public abstract ForChange change(ChangeNotes notes);
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(RefPermission)}.
+     */
     public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
 
     /** Filter {@code permSet} to permissions scoped user might be able to perform. */
@@ -406,7 +423,13 @@
     /** Returns the fully qualified resource path that this instance is scoped to. */
     public abstract String resourcePath();
 
-    /** Verify scoped user can {@code perm}, throwing if denied. */
+    /**
+     * Verify scoped user can {@code perm}, throwing if denied.
+     *
+     * <p>Should be used in REST API handlers where the thrown {@link AuthException} can be
+     * propagated. In business logic, where the exception would have to be caught, prefer using
+     * {@link #test(ChangePermissionOrLabel)}.
+     */
     public abstract void check(ChangePermissionOrLabel perm)
         throws AuthException, PermissionBackendException;
 
@@ -451,6 +474,18 @@
     }
 
     /**
+     * Test which values of a label the user may be able to remove.
+     *
+     * @param label definition of the label to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelRemovalPermission.WithValue> testRemoval(LabelType label)
+        throws PermissionBackendException {
+      return test(removalValuesOf(requireNonNull(label, "LabelType")));
+    }
+
+    /**
      * Test which values of a group of labels the user may be able to set.
      *
      * @param types definition of the labels to test values of.
@@ -463,10 +498,29 @@
       return test(types.stream().flatMap(t -> valuesOf(t).stream()).collect(toSet()));
     }
 
+    /**
+     * Test which values of a group of labels the user may be able to remove.
+     *
+     * @param types definition of the labels to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelRemovalPermission.WithValue> testLabelRemovals(Collection<LabelType> types)
+        throws PermissionBackendException {
+      requireNonNull(types, "LabelType");
+      return test(types.stream().flatMap(t -> removalValuesOf(t).stream()).collect(toSet()));
+    }
+
     private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
       return label.getValues().stream()
           .map(v -> new LabelPermission.WithValue(label, v))
           .collect(toSet());
     }
+
+    private static Set<LabelRemovalPermission.WithValue> removalValuesOf(LabelType label) {
+      return label.getValues().stream()
+          .map(v -> new LabelRemovalPermission.WithValue(label, v))
+          .collect(toSet());
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 1203049..c235012 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.util.MagicBranch.NEW_CHANGE;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.BranchNameKey;
@@ -165,9 +166,8 @@
 
   boolean isAdmin() {
     try {
-      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-      return true;
-    } catch (AuthException | PermissionBackendException e) {
+      return permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (PermissionBackendException e) {
       return false;
     }
   }
@@ -270,6 +270,7 @@
     return false;
   }
 
+  @Nullable
   private Boolean canPerform(String permissionName, AccessSection section, Permission permission) {
     for (PermissionRule rule : permission.getRules()) {
       if (rule.isBlock() || rule.isDeny() || !match(rule)) {
@@ -347,7 +348,6 @@
   }
 
   private class ForProjectImpl extends ForProject {
-    private DefaultRefFilter refFilter;
     private String resourcePath;
 
     @Override
@@ -416,10 +416,7 @@
     @Override
     public Collection<Ref> filter(Collection<Ref> refs, Repository repo, RefFilterOptions opts)
         throws PermissionBackendException {
-      if (refFilter == null) {
-        refFilter = refFilterFactory.create(ProjectControl.this);
-      }
-      return refFilter.filter(refs, repo, opts);
+      return refFilterFactory.create(ProjectControl.this).filter(refs, repo, opts);
     }
 
     private boolean can(CoreOrPluginProjectPermission perm) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 6b51335..ba292e6 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRange;
@@ -177,6 +178,7 @@
   }
 
   /** The range of permitted values associated with a label permission. */
+  @Nullable
   PermissionRange getRange(String permission, boolean isChangeOwner) {
     if (Permission.hasRange(permission)) {
       return toRange(permission, isChangeOwner);
@@ -271,13 +273,7 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return
-        // We allow owner to delete refs even if they have no force-push rights. We forbid
-        // it if force push is blocked, though. See commit 40bd5741026863c99bea13eb5384bd27855c5e1b
-        (isOwner() && !isBlocked(Permission.PUSH, false, true))
-            || canPushWithForce()
-            || canPerform(Permission.DELETE)
-            || projectControl.isAdmin();
+        return canPushWithForce() || canPerform(Permission.DELETE) || projectControl.isAdmin();
     }
   }
 
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index cc6387b..c2d1139 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -170,17 +169,14 @@
       return true;
     }
 
-    try {
-      // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
-      projectControl
-          .asForProject()
-          .ref(cd.change().getDest().branch())
-          .check(RefPermission.READ_PRIVATE_CHANGES);
-      logger.atFinest().log("Foreign change edit ref is visible: %s", refName);
-      return true;
-    } catch (AuthException e) {
-      logger.atFinest().log("Foreign change edit ref is not visible: %s", refName);
-      return false;
-    }
+    // Default to READ_PRIVATE_CHANGES as there is no special permission for reading edits.
+    boolean canRead =
+        projectControl
+            .asForProject()
+            .ref(cd.change().getDest().branch())
+            .test(RefPermission.READ_PRIVATE_CHANGES);
+    logger.atFinest().log(
+        "Foreign change edit ref is " + (canRead ? "visible" : "invisible") + ": %s", refName);
+    return canRead;
   }
 }
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index e64f8b6..552d8ee 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -125,14 +125,15 @@
   abstract static class EntryKey {
     public abstract String ref();
 
-    public abstract List<String> patterns();
+    public abstract ImmutableList<String> patterns();
 
     static EntryKey create(String refName, List<AccessSection> sections) {
-      List<String> patterns = new ArrayList<>(sections.size());
+      ImmutableList.Builder<String> patterns =
+          ImmutableList.builderWithExpectedSize(sections.size());
       for (AccessSection s : sections) {
         patterns.add(s.getName());
       }
-      return new AutoValue_SectionSortCache_EntryKey(refName, ImmutableList.copyOf(patterns));
+      return new AutoValue_SectionSortCache_EntryKey(refName, patterns.build());
     }
 
     @Memoized
diff --git a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java b/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
deleted file mode 100644
index 2e47576..0000000
--- a/java/com/google/gerrit/server/permissions/VisibleChangesCache.java
+++ /dev/null
@@ -1,169 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.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/plugincontext/PluginMapContext.java b/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
index fb50cd5..cd61429 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
 import com.google.inject.Inject;
 import java.util.Iterator;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 
 /**
  * Context to invoke extensions from a {@link DynamicMap}.
@@ -135,7 +135,7 @@
    * @return sorted list of the plugins that have registered implementations for this extension
    *     point
    */
-  public SortedSet<String> plugins() {
+  public NavigableSet<String> plugins() {
     return dynamicMap.plugins();
   }
 
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index 82f97c9..c00a69d 100644
--- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -29,9 +29,10 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.List;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
@@ -104,8 +105,8 @@
   }
 
   private static String tempNameFor(String name) {
-    SimpleDateFormat fmt = new SimpleDateFormat("yyMMdd_HHmm");
-    return PLUGIN_TMP_PREFIX + name + "_" + fmt.format(new Date()) + "_";
+    DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyMMdd_HHmm").withZone(ZoneId.of("UTC"));
+    return PLUGIN_TMP_PREFIX + name + "_" + fmt.format(Instant.now()) + "_";
   }
 
   public static Path storeInTemp(String pluginName, InputStream in, SitePaths sitePaths)
diff --git a/java/com/google/gerrit/server/plugins/JarScanner.java b/java/com/google/gerrit/server/plugins/JarScanner.java
index 0f87135..122e3f4 100644
--- a/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.server.plugins;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.Iterables.transform;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
@@ -31,7 +32,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -42,6 +42,7 @@
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 import java.util.jar.Manifest;
+import java.util.stream.Stream;
 import org.eclipse.jgit.util.IO;
 import org.objectweb.asm.AnnotationVisitor;
 import org.objectweb.asm.Attribute;
@@ -78,9 +79,7 @@
       classObjToClassDescr.put(annotation, descriptor);
     }
 
-    Enumeration<JarEntry> e = jarFile.entries();
-    while (e.hasMoreElements()) {
-      JarEntry entry = e.nextElement();
+    for (JarEntry entry : entriesOf(jarFile)) {
       if (skip(entry)) {
         continue;
       }
@@ -137,9 +136,7 @@
     String name = superClass.replace('.', '/');
 
     List<String> classes = new ArrayList<>();
-    Enumeration<JarEntry> e = jarFile.entries();
-    while (e.hasMoreElements()) {
-      JarEntry entry = e.nextElement();
+    for (JarEntry entry : entriesOf(jarFile)) {
       if (skip(entry)) {
         continue;
       }
@@ -216,6 +213,7 @@
       this.superName = superName;
     }
 
+    @Nullable
     @Override
     public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
       if (!visible) {
@@ -294,10 +292,9 @@
   }
 
   @Override
-  public Enumeration<PluginEntry> entries() {
-    return Collections.enumeration(
-        Lists.transform(
-            Collections.list(jarFile.entries()),
+  public Stream<PluginEntry> entries() {
+    return jarFile.stream()
+        .map(
             jarEntry -> {
               try {
                 return resourceOf(jarEntry);
@@ -305,7 +302,7 @@
                 throw new IllegalArgumentException(
                     "Cannot convert jar entry " + jarEntry + " to a resource", e);
               }
-            }));
+            });
   }
 
   @Override
@@ -333,4 +330,8 @@
     }
     return Maps.transformEntries(attributes, (key, value) -> (String) value);
   }
+
+  private static Iterable<JarEntry> entriesOf(JarFile jarFile) {
+    return jarFile.stream().collect(toImmutableList());
+  }
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginContentScanner.java b/java/com/google/gerrit/server/plugins/PluginContentScanner.java
index b19d6de..12c06f3 100644
--- a/java/com/google/gerrit/server/plugins/PluginContentScanner.java
+++ b/java/com/google/gerrit/server/plugins/PluginContentScanner.java
@@ -19,10 +19,10 @@
 import java.lang.annotation.Annotation;
 import java.nio.file.NoSuchFileException;
 import java.util.Collections;
-import java.util.Enumeration;
 import java.util.Map;
 import java.util.Optional;
 import java.util.jar.Manifest;
+import java.util.stream.Stream;
 
 /**
  * Scans the plugin returning classes and resources.
@@ -58,8 +58,8 @@
         }
 
         @Override
-        public Enumeration<PluginEntry> entries() {
-          return Collections.emptyEnumeration();
+        public Stream<PluginEntry> entries() {
+          return Stream.empty();
         }
       };
 
@@ -122,5 +122,5 @@
    *
    * @return the enumeration of all resources found
    */
-  Enumeration<PluginEntry> entries();
+  Stream<PluginEntry> entries();
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 8d17d85..3263636 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
@@ -713,6 +714,7 @@
     return Iterables.filter(paths, p -> !p.getFileName().toString().endsWith(".disabled"));
   }
 
+  @Nullable
   public String getGerritPluginName(Path srcPath) {
     String fileName = srcPath.getFileName().toString();
     if (isUiPlugin(fileName)) {
diff --git a/java/com/google/gerrit/server/plugins/PluginResource.java b/java/com/google/gerrit/server/plugins/PluginResource.java
index e7ebd56..fb69233 100644
--- a/java/com/google/gerrit/server/plugins/PluginResource.java
+++ b/java/com/google/gerrit/server/plugins/PluginResource.java
@@ -19,8 +19,7 @@
 import com.google.inject.TypeLiteral;
 
 public class PluginResource implements RestResource {
-  public static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND =
-      new TypeLiteral<RestView<PluginResource>>() {};
+  public static final TypeLiteral<RestView<PluginResource>> PLUGIN_KIND = new TypeLiteral<>() {};
 
   private final Plugin plugin;
   private final String name;
diff --git a/java/com/google/gerrit/server/plugins/PluginScannerThread.java b/java/com/google/gerrit/server/plugins/PluginScannerThread.java
index 705e3c0..1c2c836 100644
--- a/java/com/google/gerrit/server/plugins/PluginScannerThread.java
+++ b/java/com/google/gerrit/server/plugins/PluginScannerThread.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.plugins;
 
+import com.google.common.util.concurrent.Uninterruptibles;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
@@ -45,10 +46,6 @@
 
   void end() {
     done.countDown();
-    try {
-      join();
-    } catch (InterruptedException e) {
-      // Ignored
-    }
+    Uninterruptibles.joinUninterruptibly(this);
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index 320b618..af948b0 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -110,6 +110,7 @@
     }
   }
 
+  @Nullable
   @SuppressWarnings("unchecked")
   protected static Class<? extends Module> load(@Nullable String name, ClassLoader pluginLoader)
       throws ClassNotFoundException {
diff --git a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
index 639b278..60dff84 100644
--- a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
+++ b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
@@ -49,6 +49,7 @@
     bind(String.class)
         .annotatedWith(PluginCanonicalWebUrl.class)
         .toInstance(plugin.getPluginCanonicalWebUrl());
+    bind(Plugin.class).toInstance(plugin);
 
     install(
         new LifecycleModule() {
diff --git a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
index ae9828a..9ccbf90 100644
--- a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
+++ b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
@@ -71,6 +71,11 @@
           .put(
               BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT,
               new Mapper(i -> i.workInProgressByDefault, (i, v) -> i.workInProgressByDefault = v))
+          .put(
+              BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS,
+              new Mapper(
+                  i -> i.skipAddingAuthorAndCommitterAsReviewers,
+                  (i, v) -> i.skipAddingAuthorAndCommitterAsReviewers = v))
           .build();
 
   static {
diff --git a/java/com/google/gerrit/server/project/BranchResource.java b/java/com/google/gerrit/server/project/BranchResource.java
index a8936ac..fb1fa57 100644
--- a/java/com/google/gerrit/server/project/BranchResource.java
+++ b/java/com/google/gerrit/server/project/BranchResource.java
@@ -18,19 +18,20 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 
 public class BranchResource extends RefResource {
-  public static final TypeLiteral<RestView<BranchResource>> BRANCH_KIND =
-      new TypeLiteral<RestView<BranchResource>>() {};
+  public static final TypeLiteral<RestView<BranchResource>> BRANCH_KIND = new TypeLiteral<>() {};
 
   private final String refName;
-  private final String revision;
+  private final Optional<String> revision;
 
   public BranchResource(ProjectState projectState, CurrentUser user, Ref ref) {
     super(projectState, user);
     this.refName = ref.getName();
-    this.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
+    this.revision = Optional.ofNullable(ref.getObjectId()).map(ObjectId::name);
   }
 
   public BranchNameKey getBranchKey() {
@@ -43,7 +44,7 @@
   }
 
   @Override
-  public String getRevision() {
+  public Optional<String> getRevision() {
     return revision;
   }
 }
diff --git a/java/com/google/gerrit/server/project/ChildProjectResource.java b/java/com/google/gerrit/server/project/ChildProjectResource.java
index 4b641ca..854f876 100644
--- a/java/com/google/gerrit/server/project/ChildProjectResource.java
+++ b/java/com/google/gerrit/server/project/ChildProjectResource.java
@@ -21,7 +21,7 @@
 
 public class ChildProjectResource implements RestResource {
   public static final TypeLiteral<RestView<ChildProjectResource>> CHILD_PROJECT_KIND =
-      new TypeLiteral<RestView<ChildProjectResource>>() {};
+      new TypeLiteral<>() {};
 
   private final ProjectResource parent;
   private final ProjectState child;
diff --git a/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
index 1b9dc37..88f045e 100644
--- a/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.StoredCommentLinkInfo;
@@ -45,10 +44,11 @@
 
   private List<CommentLinkInfo> parseConfig(Config cfg) {
     Set<String> subsections = cfg.getSubsections(ProjectConfig.COMMENTLINK);
-    List<CommentLinkInfo> cls = Lists.newArrayListWithCapacity(subsections.size());
+    ImmutableList.Builder<CommentLinkInfo> cls =
+        ImmutableList.builderWithExpectedSize(subsections.size());
     for (String name : subsections) {
       try {
-        StoredCommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name, true);
+        StoredCommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name);
         if (cl.getOverrideOnly()) {
           logger.atWarning().log("commentlink %s empty except for \"enabled\"", name);
           continue;
@@ -58,7 +58,7 @@
         logger.atWarning().log("invalid commentlink: %s", e.getMessage());
       }
     }
-    return ImmutableList.copyOf(cls);
+    return cls.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/project/CommitResource.java b/java/com/google/gerrit/server/project/CommitResource.java
index f71c7fe..ffd498d 100644
--- a/java/com/google/gerrit/server/project/CommitResource.java
+++ b/java/com/google/gerrit/server/project/CommitResource.java
@@ -20,8 +20,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 public class CommitResource implements RestResource {
-  public static final TypeLiteral<RestView<CommitResource>> COMMIT_KIND =
-      new TypeLiteral<RestView<CommitResource>>() {};
+  public static final TypeLiteral<RestView<CommitResource>> COMMIT_KIND = new TypeLiteral<>() {};
 
   private final ProjectResource project;
   private final RevCommit commit;
diff --git a/java/com/google/gerrit/server/project/CreateProjectArgs.java b/java/com/google/gerrit/server/project/CreateProjectArgs.java
index c1b7b86..8da0510 100644
--- a/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -48,7 +49,7 @@
     newChangeForAllNotInTarget = InheritableBoolean.INHERIT;
     enableSignedPush = InheritableBoolean.INHERIT;
     requireSignedPush = InheritableBoolean.INHERIT;
-    submitType = SubmitType.MERGE_IF_NECESSARY;
+    submitType = SubmitType.INHERIT;
     rejectEmptyCommit = InheritableBoolean.INHERIT;
   }
 
@@ -56,6 +57,7 @@
     return projectName;
   }
 
+  @Nullable
   public String getProjectName() {
     return projectName != null ? projectName.get() : null;
   }
diff --git a/java/com/google/gerrit/server/project/CreateRefControl.java b/java/com/google/gerrit/server/project/CreateRefControl.java
index 69ac93e..ab134b5 100644
--- a/java/com/google/gerrit/server/project/CreateRefControl.java
+++ b/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -25,10 +25,13 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -41,18 +44,24 @@
 /** Manages access control for creating Git references (aka branches, tags). */
 @Singleton
 public class CreateRefControl {
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final Reachable reachable;
+  private final RetryHelper retryHelper;
 
   @Inject
   CreateRefControl(
-      PermissionBackend permissionBackend, ProjectCache projectCache, Reachable reachable) {
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache,
+      Reachable reachable,
+      RetryHelper retryHelper) {
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.reachable = reachable;
+    this.retryHelper = retryHelper;
   }
 
   /**
@@ -60,30 +69,56 @@
    *
    * @param user the user performing the operation
    * @param repo repository on which user want to create
-   * @param branch the branch the new {@link RevObject} should be created on
+   * @param destBranch the branch the new {@link RevObject} should be created on
    * @param object the object the user will start the reference with
+   * @param sourceBranches the source ref from which the new ref is created from
    * @throws AuthException if creation is denied; the message explains the denial.
    * @throws PermissionBackendException on failure of permission checks.
    * @throws ResourceConflictException if the project state does not permit the operation
    */
   public void checkCreateRef(
-      Provider<? extends CurrentUser> user, Repository repo, BranchNameKey branch, RevObject object)
+      Provider<? extends CurrentUser> user,
+      Repository repo,
+      BranchNameKey destBranch,
+      RevObject object,
+      boolean forPush,
+      BranchNameKey... sourceBranches)
       throws AuthException, PermissionBackendException, NoSuchProjectException, IOException,
           ResourceConflictException {
     ProjectState ps =
-        projectCache.get(branch.project()).orElseThrow(noSuchProject(branch.project()));
+        projectCache.get(destBranch.project()).orElseThrow(noSuchProject(destBranch.project()));
     ps.checkStatePermitsWrite();
 
-    PermissionBackend.ForRef perm = permissionBackend.user(user.get()).ref(branch);
+    PermissionBackend.ForRef perm = permissionBackend.user(user.get()).ref(destBranch);
     if (object instanceof RevCommit) {
       perm.check(RefPermission.CREATE);
-      checkCreateCommit(user, repo, (RevCommit) object, ps.getNameKey(), perm);
+      if (sourceBranches.length == 0) {
+        checkCreateCommit(user, repo, (RevCommit) object, ps.getNameKey(), perm, forPush);
+      } else {
+        for (BranchNameKey src : sourceBranches) {
+          PermissionBackend.ForRef forRef = permissionBackend.user(user.get()).ref(src);
+          if (forRef.testOrFalse(RefPermission.READ)) {
+            return;
+          }
+        }
+        AuthException e =
+            new AuthException(
+                String.format(
+                    "must have %s on existing ref to create new ref from it",
+                    RefPermission.READ.describeForException()));
+        e.setAdvice(
+            String.format(
+                "use an existing ref visible to you, or get %s permission on the ref",
+                RefPermission.READ.describeForException()));
+        throw e;
+      }
     } else if (object instanceof RevTag) {
       RevTag tag = (RevTag) object;
       try (RevWalk rw = new RevWalk(repo)) {
         rw.parseBody(tag);
       } catch (IOException e) {
-        logger.atSevere().withCause(e).log("RevWalk(%s) parsing %s:", branch.project(), tag.name());
+        logger.atSevere().withCause(e).log(
+            "RevWalk(%s) parsing %s:", destBranch.project(), tag.name());
         throw e;
       }
 
@@ -97,15 +132,15 @@
 
       RevObject target = tag.getObject();
       if (target instanceof RevCommit) {
-        checkCreateCommit(user, repo, (RevCommit) target, ps.getNameKey(), perm);
+        checkCreateCommit(user, repo, (RevCommit) target, ps.getNameKey(), perm, forPush);
       } else {
-        checkCreateRef(user, repo, branch, target);
+        checkCreateRef(user, repo, destBranch, target, forPush);
       }
 
       // 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")) {
+      PermissionBackend.ForRef forRef = permissionBackend.user(user.get()).ref(destBranch);
+      if (tag.getRawGpgSignature() != null) {
         forRef.check(RefPermission.CREATE_SIGNED_TAG);
       } else {
         forRef.check(RefPermission.CREATE_TAG);
@@ -122,13 +157,31 @@
       Repository repo,
       RevCommit commit,
       Project.NameKey project,
-      PermissionBackend.ForRef forRef)
+      PermissionBackend.ForRef forRef,
+      boolean forPush)
       throws AuthException, PermissionBackendException, IOException {
     try {
-      // If the user has update (push) permission, they can create the ref regardless
-      // of whether they are pushing any new objects along with the create.
-      forRef.check(RefPermission.UPDATE);
-      return;
+      // If the user has UPDATE (push) permission, they can set the ref to an arbitrary commit:
+      //
+      //  * if they don't have access, we don't advertise the data, and a conforming git client
+      //  would send the object along with the push as outcome of the negotation.
+      //  * a malicious client could try to send the update without sending the object. This
+      //  is prevented by JGit's ConnectivityChecker (see receive.checkReferencedObjectsAreReachable
+      //  to switch off this costly check).
+      //
+      // Thus, when using the git command-line client, we don't need to do extra checks for users
+      // with push access.
+      //
+      // When using the REST API, there is no negotiation, and the target commit must already be on
+      // the server, so we must check that the user can see that commit.
+      if (forPush) {
+        // We can only shortcut for UPDATE permission. Pushing a tag (CREATE_TAG, CREATE_SIGNED_TAG)
+        // can also introduce new objects. While there may not be a confidentiality problem
+        // (the caller supplies the data as documented above), the permission is for creating
+        // tags to existing commits.
+        forRef.check(RefPermission.UPDATE);
+        return;
+      }
     } catch (AuthException denied) {
       // Fall through to check reachability.
     }
@@ -145,6 +198,18 @@
       return;
     }
 
+    // Previous check only catches normal branches. Try PatchSet refs too. If we can create refs,
+    // we're not a replica, so we can always use the change index.
+    List<ChangeData> changes =
+        retryHelper
+            .changeIndexQuery(
+                "queryChangesByProjectCommitWithLimit1",
+                q -> q.enforceVisibility(true).setLimit(1).byProjectCommit(project, commit))
+            .call();
+    if (!changes.isEmpty()) {
+      return;
+    }
+
     AuthException e =
         new AuthException(
             String.format(
diff --git a/java/com/google/gerrit/server/project/DashboardResource.java b/java/com/google/gerrit/server/project/DashboardResource.java
index 54f958a..c918551 100644
--- a/java/com/google/gerrit/server/project/DashboardResource.java
+++ b/java/com/google/gerrit/server/project/DashboardResource.java
@@ -22,7 +22,7 @@
 
 public class DashboardResource implements RestResource {
   public static final TypeLiteral<RestView<DashboardResource>> DASHBOARD_KIND =
-      new TypeLiteral<RestView<DashboardResource>>() {};
+      new TypeLiteral<>() {};
 
   public static DashboardResource projectDefault(ProjectState projectState, CurrentUser user) {
     return new DashboardResource(projectState, user, null, null, null, true);
diff --git a/java/com/google/gerrit/server/project/DeleteVoteControl.java b/java/com/google/gerrit/server/project/DeleteVoteControl.java
new file mode 100644
index 0000000..3f3f88a
--- /dev/null
+++ b/java/com/google/gerrit/server/project/DeleteVoteControl.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.LabelRemovalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import java.util.Set;
+
+public class DeleteVoteControl {
+  private final PermissionBackend permissionBackend;
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  public DeleteVoteControl(
+      PermissionBackend permissionBackend, ChangeData.Factory changeDataFactory) {
+    this.permissionBackend = permissionBackend;
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  public boolean testDeleteVotePermissions(
+      CurrentUser user, ChangeNotes notes, PatchSetApproval approval, LabelType labelType)
+      throws PermissionBackendException {
+    return testDeleteVotePermissions(user, changeDataFactory.create(notes), approval, labelType);
+  }
+
+  public boolean testDeleteVotePermissions(
+      CurrentUser user, ChangeData cd, PatchSetApproval approval, LabelType labelType)
+      throws PermissionBackendException {
+    if (canRemoveReviewerWithoutRemoveLabelPermission(
+        cd.change(), user, approval.accountId(), approval.value())) {
+      return true;
+    }
+    // Test if the user is allowed to remove vote of the given label type and value.
+    Set<LabelRemovalPermission.WithValue> allowed =
+        permissionBackend.user(user).change(cd).testRemoval(labelType);
+    return allowed.contains(new LabelRemovalPermission.WithValue(labelType, approval.value()));
+  }
+
+  private boolean canRemoveReviewerWithoutRemoveLabelPermission(
+      Change change, CurrentUser user, Account.Id reviewer, int value)
+      throws PermissionBackendException {
+    if (user.isIdentifiedUser()) {
+      Account.Id aId = user.getAccountId();
+      if (aId.equals(reviewer)) {
+        return true; // A user can always remove their own votes.
+      } else if (aId.equals(change.getOwner()) && 0 <= value) {
+        return true; // The change owner may remove any zero or positive score.
+      }
+    }
+
+    // Users with the remove reviewer permission, the branch owner, project
+    // owner and site admin can remove anyone
+    PermissionBackend.WithUser withUser = permissionBackend.user(user);
+    PermissionBackend.ForProject forProject = withUser.project(change.getProject());
+    return forProject.ref(change.getDest().branch()).test(RefPermission.WRITE_CONFIG)
+        || withUser.test(GlobalPermission.ADMINISTRATE_SERVER);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/FileResource.java b/java/com/google/gerrit/server/project/FileResource.java
index e8926dc..f02522a 100644
--- a/java/com/google/gerrit/server/project/FileResource.java
+++ b/java/com/google/gerrit/server/project/FileResource.java
@@ -33,8 +33,7 @@
  * <p>This is in the project package because it is accessed through the project/branch/file path.
  */
 public class FileResource implements RestResource {
-  public static final TypeLiteral<RestView<FileResource>> FILE_KIND =
-      new TypeLiteral<RestView<FileResource>>() {};
+  public static final TypeLiteral<RestView<FileResource>> FILE_KIND = new TypeLiteral<>() {};
 
   public static FileResource create(
       GitRepositoryManager repoManager, ProjectState projectState, ObjectId rev, String path)
diff --git a/java/com/google/gerrit/server/project/GroupList.java b/java/com/google/gerrit/server/project/GroupList.java
index 98dc44a..1b0ba97 100644
--- a/java/com/google/gerrit/server/project/GroupList.java
+++ b/java/com/google/gerrit/server/project/GroupList.java
@@ -126,6 +126,7 @@
     byUUID.put(uuid, reference);
   }
 
+  @Nullable
   public String asText() {
     if (byUUID.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/project/LabelConfigValidator.java b/java/com/google/gerrit/server/project/LabelConfigValidator.java
new file mode 100644
index 0000000..3e5ff6b
--- /dev/null
+++ b/java/com/google/gerrit/server/project/LabelConfigValidator.java
@@ -0,0 +1,357 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+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.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Validates modifications to label configurations in the {@code project.config} file that is stored
+ * in {@code refs/meta/config}.
+ *
+ * <p>Rejects setting/changing deprecated fields that are no longer supported (fields {@code
+ * copyAnyScore}, {@code copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange},
+ * {@code copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ * copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ * copyValue}).
+ *
+ * <p>Updates that unset the deprecated fields or that don't touch them are allowed.
+ */
+@Singleton
+public class LabelConfigValidator implements CommitValidationListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @VisibleForTesting public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+
+  @VisibleForTesting public static final String KEY_COPY_VALUE = "copyValue";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
+      "copyAllScoresOnMergeFirstParentUpdate";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
+      "copyAllScoresIfListOfFilesDidNotChange";
+
+  // Map of deprecated boolean flags to the predicates that should be used in the copy condition
+  // instead.
+  private static final ImmutableMap<String, String> DEPRECATED_FLAGS =
+      ImmutableMap.<String, String>builder()
+          .put(KEY_COPY_ANY_SCORE, "is:ANY")
+          .put(KEY_COPY_MIN_SCORE, "is:MIN")
+          .put(KEY_COPY_MAX_SCORE, "is:MAX")
+          .put(KEY_COPY_ALL_SCORES_IF_NO_CHANGE, "changekind:" + ChangeKind.NO_CHANGE.name())
+          .put(
+              KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              "changekind:" + ChangeKind.NO_CODE_CHANGE.name())
+          .put(
+              KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              "changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name())
+          .put(
+              KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              "changekind:" + ChangeKind.TRIVIAL_REBASE.name())
+          .put(KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE, "has:unchanged-files")
+          .build();
+
+  private final DiffOperations diffOperations;
+
+  @Inject
+  public LabelConfigValidator(DiffOperations diffOperations) {
+    this.diffOperations = diffOperations;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    try {
+      if (!receiveEvent.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(receiveEvent, ProjectConfig.PROJECT_CONFIG)) {
+        // The project.config file in refs/meta/config was not modified, hence we do not need to do
+        // any validation and can return early.
+        return ImmutableList.of();
+      }
+
+      ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder =
+          ImmutableList.builder();
+
+      // Load the new config
+      Config newConfig;
+      try {
+        newConfig = loadNewConfig(receiveEvent);
+      } catch (ConfigInvalidException e) {
+        // The current config is invalid, hence we cannot inspect the delta.
+        // Rejecting invalid configs is not the responsibility of this validator, hence ignore this
+        // exception here.
+        logger.atWarning().log(
+            "cannot inspect the project config, because parsing %s from revision %s"
+                + " in project %s failed: %s",
+            ProjectConfig.PROJECT_CONFIG,
+            receiveEvent.commit.name(),
+            receiveEvent.getProjectNameKey(),
+            e.getMessage());
+        return ImmutableList.of();
+      }
+
+      // Load the old config
+      Optional<Config> oldConfig = loadOldConfig(receiveEvent);
+
+      for (String labelName : newConfig.getSubsections(ProjectConfig.LABEL)) {
+        for (String deprecatedFlag : DEPRECATED_FLAGS.keySet()) {
+          if (flagChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName, deprecatedFlag)) {
+            validationMessageBuilder.add(
+                new CommitValidationMessage(
+                    String.format(
+                        "Parameter '%s.%s.%s' is deprecated and cannot be set,"
+                            + " use '%s' in '%s.%s.%s' instead.",
+                        ProjectConfig.LABEL,
+                        labelName,
+                        deprecatedFlag,
+                        DEPRECATED_FLAGS.get(deprecatedFlag),
+                        ProjectConfig.LABEL,
+                        labelName,
+                        ProjectConfig.KEY_COPY_CONDITION),
+                    ValidationMessage.Type.ERROR));
+          }
+        }
+
+        if (copyValuesChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName)) {
+          validationMessageBuilder.add(
+              new CommitValidationMessage(
+                  String.format(
+                      "Parameter '%s.%s.%s' is deprecated and cannot be set,"
+                          + " use 'is:<copy-value>' in '%s.%s.%s' instead.",
+                      ProjectConfig.LABEL,
+                      labelName,
+                      KEY_COPY_VALUE,
+                      ProjectConfig.LABEL,
+                      labelName,
+                      ProjectConfig.KEY_COPY_CONDITION),
+                  ValidationMessage.Type.ERROR));
+        }
+
+        // Ban modifying label functions to any blocking function value
+        if (flagChangedOrNewlySet(
+            newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) {
+          String fnName =
+              newConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
+          Optional<LabelFunction> labelFn = LabelFunction.parse(fnName);
+          if (labelFn.isPresent() && !isLabelFunctionAllowed(labelFn.get())) {
+            validationMessageBuilder.add(
+                new CommitValidationMessage(
+                    String.format(
+                        "Value '%s' of '%s.%s.%s' is not allowed and cannot be set."
+                            + " Label functions can only be set to {%s, %s, %s}."
+                            + " Use submit requirements instead of label functions.",
+                        fnName,
+                        ProjectConfig.LABEL,
+                        labelName,
+                        ProjectConfig.KEY_FUNCTION,
+                        LabelFunction.NO_BLOCK,
+                        LabelFunction.NO_OP,
+                        LabelFunction.PATCH_SET_LOCK),
+                    ValidationMessage.Type.ERROR));
+          }
+        }
+
+        // Ban deletions of label functions as well since the default is MaxWithBlock
+        if (flagDeleted(newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) {
+          validationMessageBuilder.add(
+              new CommitValidationMessage(
+                  String.format(
+                      "Cannot delete '%s.%s.%s'."
+                          + " Label functions can only be set to {%s, %s, %s}."
+                          + " Use submit requirements instead of label functions.",
+                      ProjectConfig.LABEL,
+                      labelName,
+                      ProjectConfig.KEY_FUNCTION,
+                      LabelFunction.NO_BLOCK,
+                      LabelFunction.NO_OP,
+                      LabelFunction.PATCH_SET_LOCK),
+                  ValidationMessage.Type.ERROR));
+        }
+      }
+
+      ImmutableList<CommitValidationMessage> validationMessages = validationMessageBuilder.build();
+      if (!validationMessages.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid %s file in revision %s",
+                ProjectConfig.PROJECT_CONFIG, receiveEvent.commit.getName()),
+            validationMessages);
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException e) {
+      String errorMessage =
+          String.format(
+              "failed to validate file %s for revision %s in ref %s of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              receiveEvent.commit.getName(),
+              RefNames.REFS_CONFIG,
+              receiveEvent.getProjectNameKey());
+      logger.atSevere().withCause(e).log("%s", errorMessage);
+      throw new CommitValidationException(errorMessage, e);
+    }
+  }
+
+  /**
+   * Whether the given file was changed in the given revision.
+   *
+   * @param receiveEvent the receive event
+   * @param fileName the name of the file
+   */
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    Map<String, FileDiffOutput> fileDiffOutputs;
+    if (receiveEvent.commit.getParentCount() > 0) {
+      // normal commit with one parent: use listModifiedFilesAgainstParent with parentNum = 1 to
+      // compare against the only parent (using parentNum = 0 to compare against the default parent
+      // would also work)
+      // merge commit with 2 or more parents: must use listModifiedFilesAgainstParent with parentNum
+      // = 1 to compare against the first parent (using parentNum = 0 would compare against the
+      // auto-merge)
+      fileDiffOutputs =
+          diffOperations.listModifiedFilesAgainstParent(
+              receiveEvent.getProjectNameKey(), receiveEvent.commit, 1, DiffOptions.DEFAULTS);
+    } else {
+      // initial commit: must use listModifiedFilesAgainstParent with parentNum = 0
+      fileDiffOutputs =
+          diffOperations.listModifiedFilesAgainstParent(
+              receiveEvent.getProjectNameKey(),
+              receiveEvent.commit,
+              /* parentNum=*/ 0,
+              DiffOptions.DEFAULTS);
+    }
+    return fileDiffOutputs.keySet().contains(fileName);
+  }
+
+  private Config loadNewConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectLevelConfig.Bare bareConfig = new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    bareConfig.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit);
+    return bareConfig.getConfig();
+  }
+
+  private Optional<Config> loadOldConfig(CommitReceivedEvent receiveEvent) throws IOException {
+    if (receiveEvent.commit.getParentCount() == 0) {
+      // initial commit, an old config doesn't exist
+      return Optional.empty();
+    }
+
+    try {
+      ProjectLevelConfig.Bare bareConfig =
+          new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+      bareConfig.load(
+          receiveEvent.project.getNameKey(),
+          receiveEvent.revWalk,
+          receiveEvent.commit.getParent(0));
+      return Optional.of(bareConfig.getConfig());
+    } catch (ConfigInvalidException e) {
+      // the old config is not parseable, treat this the same way as if an old config didn't exist
+      // so that all parameters in the new config are validated
+      logger.atWarning().log(
+          "cannot inspect the old project config, because parsing %s from parent revision %s"
+              + " in project %s failed: %s",
+          ProjectConfig.PROJECT_CONFIG,
+          receiveEvent.commit.name(),
+          receiveEvent.getProjectNameKey(),
+          e.getMessage());
+      return Optional.empty();
+    }
+  }
+
+  private static boolean flagChangedOrNewlySet(
+      Config newConfig, @Nullable Config oldConfig, String labelName, String key) {
+    if (oldConfig == null) {
+      return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(key);
+    }
+
+    // Use getString rather than getBoolean so that we do not have to deal with values that cannot
+    // be parsed as a boolean.
+    String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key);
+    String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key);
+    return newValue != null && !newValue.equals(oldValue);
+  }
+
+  private static boolean flagDeleted(
+      Config newConfig, @Nullable Config oldConfig, String labelName, String key) {
+    if (oldConfig == null) {
+      return false;
+    }
+    String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key);
+    String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key);
+    return oldValue != null && newValue == null;
+  }
+
+  private static boolean copyValuesChangedOrNewlySet(
+      Config newConfig, @Nullable Config oldConfig, String labelName) {
+    if (oldConfig == null) {
+      return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(KEY_COPY_VALUE);
+    }
+
+    // Ignore the order in which the copy values are defined in the new and old config, since the
+    // order doesn't matter for this parameter.
+    ImmutableSet<String> oldValues =
+        ImmutableSet.copyOf(
+            oldConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE));
+    ImmutableSet<String> newValues =
+        ImmutableSet.copyOf(
+            newConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE));
+    return !newValues.isEmpty() && !Sets.difference(newValues, oldValues).isEmpty();
+  }
+
+  private static boolean isLabelFunctionAllowed(LabelFunction labelFunction) {
+    return labelFunction.equals(LabelFunction.NO_BLOCK)
+        || labelFunction.equals(LabelFunction.NO_OP)
+        || labelFunction.equals(LabelFunction.PATCH_SET_LOCK);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 63c9d22..f46c2b1 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toMap;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.Project;
@@ -25,6 +26,7 @@
   public static LabelDefinitionInfo format(Project.NameKey projectName, LabelType labelType) {
     LabelDefinitionInfo label = new LabelDefinitionInfo();
     label.name = labelType.getName();
+    label.description = labelType.getDescription().orElse(null);
     label.projectName = projectName.get();
     label.function = labelType.getFunction().getFunctionName();
     label.values =
@@ -33,24 +35,14 @@
     label.branches = labelType.getRefPatterns() != null ? labelType.getRefPatterns() : null;
     label.canOverride = toBoolean(labelType.isCanOverride());
     label.copyCondition = labelType.getCopyCondition().orElse(null);
-    label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
-    label.copyMinScore = toBoolean(labelType.isCopyMinScore());
-    label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
-    label.copyAllScoresIfListOfFilesDidNotChange =
-        toBoolean(labelType.isCopyAllScoresIfListOfFilesDidNotChange());
-    label.copyAllScoresIfNoChange = toBoolean(labelType.isCopyAllScoresIfNoChange());
-    label.copyAllScoresIfNoCodeChange = toBoolean(labelType.isCopyAllScoresIfNoCodeChange());
-    label.copyAllScoresOnTrivialRebase = toBoolean(labelType.isCopyAllScoresOnTrivialRebase());
-    label.copyAllScoresOnMergeFirstParentUpdate =
-        toBoolean(labelType.isCopyAllScoresOnMergeFirstParentUpdate());
-    label.copyValues = labelType.getCopyValues().isEmpty() ? null : labelType.getCopyValues();
     label.allowPostSubmit = toBoolean(labelType.isAllowPostSubmit());
     label.ignoreSelfApproval = toBoolean(labelType.isIgnoreSelfApproval());
     return label;
   }
 
+  @Nullable
   private static Boolean toBoolean(boolean v) {
-    return v ? v : null;
+    return v ? Boolean.TRUE : null;
   }
 
   private LabelDefinitionJson() {}
diff --git a/java/com/google/gerrit/server/project/LabelResource.java b/java/com/google/gerrit/server/project/LabelResource.java
index 2df9ff1..fcddc61 100644
--- a/java/com/google/gerrit/server/project/LabelResource.java
+++ b/java/com/google/gerrit/server/project/LabelResource.java
@@ -20,8 +20,7 @@
 import com.google.inject.TypeLiteral;
 
 public class LabelResource implements RestResource {
-  public static final TypeLiteral<RestView<LabelResource>> LABEL_KIND =
-      new TypeLiteral<RestView<LabelResource>>() {};
+  public static final TypeLiteral<RestView<LabelResource>> LABEL_KIND = new TypeLiteral<>() {};
 
   private final ProjectResource project;
   private final LabelType labelType;
diff --git a/java/com/google/gerrit/server/project/NullProjectCache.java b/java/com/google/gerrit/server/project/NullProjectCache.java
index 1d5f5b7..57976d3 100644
--- a/java/com/google/gerrit/server/project/NullProjectCache.java
+++ b/java/com/google/gerrit/server/project/NullProjectCache.java
@@ -42,11 +42,6 @@
   }
 
   @Override
-  public void evict(Project p) {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
   public void evict(NameKey p) {
     throw new UnsupportedOperationException();
   }
@@ -67,6 +62,11 @@
   }
 
   @Override
+  public void refreshProjectList() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
   public Set<UUID> guessRelevantGroupUUIDs() {
     throw new UnsupportedOperationException();
   }
@@ -80,4 +80,14 @@
   public void onCreateProject(NameKey newProjectName) throws IOException {
     throw new UnsupportedOperationException();
   }
+
+  @Override
+  public void evictAndReindex(Project p) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void evictAndReindex(NameKey p) {
+    throw new UnsupportedOperationException();
+  }
 }
diff --git a/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifier.java b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifier.java
new file mode 100644
index 0000000..7e6d93e
--- /dev/null
+++ b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifier.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.ImplementedBy;
+
+/**
+ * Extension point that allows to modify the {@link SubmitRequirementResult} when it is stored
+ * NoteDB.
+ *
+ * <p>The submit requirements are only stored for the closed (merged and abandoned) changes and the
+ * modifier only affects what {@link SubmitRequirementResult} will be stored in NoteDB.
+ *
+ * <p>It has no impact on open changes or evaluations on merge, i.e. does not affect the
+ * submittability of the change (never blocks the ready change from submission or allows bypassing
+ * unsatisfied submit requirement).
+ *
+ * <p>The extension point only applies to non-legacy submit requirements (including non-applicable,
+ * since they are stored too) and does not affect submit rule results.
+ */
+@ExtensionPoint
+@ImplementedBy(OnStoreSubmitRequirementResultModifierImpl.class)
+public interface OnStoreSubmitRequirementResultModifier {
+
+  /**
+   * Evaluate a single {@link SubmitRequirement} using the change data, modifying the original
+   * {@code SubmitRequirementResult}, if needed.
+   *
+   * <p>Only affects how the submit requirement is stored in NoteDb for closed changes.
+   */
+  SubmitRequirementResult modifyResultOnStore(
+      SubmitRequirement submitRequirement,
+      SubmitRequirementResult submitRequirementResult,
+      ChangeData changeData,
+      ChangeContext ctx);
+}
diff --git a/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifierImpl.java b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifierImpl.java
new file mode 100644
index 0000000..39a717f
--- /dev/null
+++ b/java/com/google/gerrit/server/project/OnStoreSubmitRequirementResultModifierImpl.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.ChangeContext;
+
+/**
+ * Default implementation of {@link OnStoreSubmitRequirementResultModifier} that does not
+ * re-evaluate {@link SubmitRequirementResult}.
+ */
+public class OnStoreSubmitRequirementResultModifierImpl
+    implements OnStoreSubmitRequirementResultModifier {
+  @Override
+  public SubmitRequirementResult modifyResultOnStore(
+      SubmitRequirement submitRequirement,
+      SubmitRequirementResult result,
+      ChangeData cd,
+      ChangeContext ctx) {
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java b/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java
new file mode 100644
index 0000000..df2e1cf
--- /dev/null
+++ b/java/com/google/gerrit/server/project/PeriodicProjectListCacheWarmer.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ScheduleConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import java.time.Duration;
+import org.eclipse.jgit.lib.Config;
+
+public class PeriodicProjectListCacheWarmer implements Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static class LifeCycle implements LifecycleListener {
+    protected final Config config;
+    protected final WorkQueue queue;
+    protected final PeriodicProjectListCacheWarmer runner;
+
+    @Inject
+    LifeCycle(
+        @GerritServerConfig Config config, WorkQueue queue, PeriodicProjectListCacheWarmer runner) {
+      this.config = config;
+      this.queue = queue;
+      this.runner = runner;
+    }
+
+    @Override
+    public void start() {
+      long interval = -1L;
+      String intervalString = config.getString("cache", ProjectCacheImpl.CACHE_LIST, "interval");
+      if (!"-1".equals(intervalString)) {
+        long maxAge =
+            config.getTimeUnit("cache", ProjectCacheImpl.CACHE_LIST, "maxAge", -1L, MILLISECONDS);
+        interval =
+            config.getTimeUnit(
+                "cache",
+                ProjectCacheImpl.CACHE_LIST,
+                "interval",
+                getHalfDuration(maxAge),
+                MILLISECONDS);
+      }
+
+      if (interval == -1L) {
+        logger.atWarning().log("project_list cache warmer is disabled");
+        return;
+      }
+
+      String startTime = config.getString("cache", ProjectCacheImpl.CACHE_LIST, "startTime");
+      if (startTime == null) {
+        startTime = "00:00";
+      }
+
+      runner.run();
+      queue.scheduleAtFixedRate(runner, ScheduleConfig.Schedule.createOrFail(interval, startTime));
+    }
+
+    @Override
+    public void stop() {
+      // handled by WorkQueue.stop() already
+    }
+
+    private long getHalfDuration(long duration) {
+      if (duration < 0) {
+        return duration;
+      }
+      return Duration.ofMillis(duration).dividedBy(2L).toMillis();
+    }
+  }
+
+  protected final ProjectCache cache;
+
+  @Inject
+  PeriodicProjectListCacheWarmer(ProjectCache cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public void run() {
+    logger.atFine().log("Loading project_list cache");
+    cache.refreshProjectList();
+    logger.atFine().log("Finished loading project_list cache");
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
index fee7105..e0569b9 100644
--- a/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -59,18 +59,25 @@
   Optional<ProjectState> get(@Nullable Project.NameKey projectName) throws StorageException;
 
   /**
+   * Invalidate the cached information about the given project.
+   *
+   * @param p the NameKey of the project that is being evicted
+   */
+  void evict(Project.NameKey p);
+
+  /**
    * Invalidate the cached information about the given project, and triggers reindexing for it
    *
    * @param p project that is being evicted
    */
-  void evict(Project p);
+  void evictAndReindex(Project p);
 
   /**
    * Invalidate the cached information about the given project, and triggers reindexing for it
    *
    * @param p the NameKey of the project that is being evicted
    */
-  void evict(Project.NameKey p);
+  void evictAndReindex(Project.NameKey p);
 
   /**
    * Remove information about the given project from the cache. It will no longer be returned from
@@ -87,6 +94,9 @@
   /** Returns sorted iteration of projects. */
   ImmutableSortedSet<Project.NameKey> all();
 
+  /** Refreshes project list cache */
+  void refreshProjectList();
+
   /**
    * Returns estimated set of relevant groups extracted from hot project access rules. If the cache
    * is cold or too small for the entire project set of the server, this set may be incomplete.
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index de27afa..6498d1b 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.common.util.concurrent.Futures;
@@ -54,6 +55,7 @@
 import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
@@ -67,6 +69,7 @@
 import com.google.protobuf.ByteString;
 import java.io.IOException;
 import java.time.Duration;
+import java.util.Arrays;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
@@ -75,6 +78,7 @@
 import java.util.concurrent.locks.ReentrantLock;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -95,7 +99,7 @@
 
   public static final String PERSISTED_CACHE_NAME = "persisted_projects";
 
-  private static final String CACHE_LIST = "project_list";
+  public static final String CACHE_LIST = "project_list";
 
   public static Module module() {
     return new CacheModule() {
@@ -130,7 +134,7 @@
             .keySerializer(new ProtobufSerializer<>(Cache.ProjectCacheKeyProto.parser()))
             .valueSerializer(PersistedProjectConfigSerializer.INSTANCE)
             .diskLimit(1 << 30) // 1 GiB
-            .version(2)
+            .version(4)
             .maximumWeight(0);
 
         cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
@@ -147,10 +151,18 @@
                 listener().to(ProjectCacheWarmer.class);
               }
             });
+        install(
+            new LifecycleModule() {
+              @Override
+              protected void configure() {
+                listener().to(PeriodicProjectListCacheWarmer.LifeCycle.class);
+              }
+            });
       }
     };
   }
 
+  private final Config config;
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
   private final LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache;
@@ -162,13 +174,15 @@
 
   @Inject
   ProjectCacheImpl(
-      final AllProjectsName allProjectsName,
-      final AllUsersName allUsersName,
+      @GerritServerConfig Config config,
+      AllProjectsName allProjectsName,
+      AllUsersName allUsersName,
       @Named(CACHE_NAME) LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache,
       @Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
       Provider<ProjectIndexer> indexer,
       MetricMaker metricMaker,
       ProjectState.Factory projectStateFactory) {
+    this.config = config;
     this.allProjectsName = allProjectsName;
     this.allUsersName = allUsersName;
     this.inMemoryProjectCache = inMemoryProjectCache;
@@ -214,16 +228,21 @@
   }
 
   @Override
-  public void evict(Project p) {
-    evict(p.getNameKey());
-  }
-
-  @Override
   public void evict(Project.NameKey p) {
     if (p != null) {
       logger.atFine().log("Evict project '%s'", p.get());
       inMemoryProjectCache.invalidate(p);
     }
+  }
+
+  @Override
+  public void evictAndReindex(Project p) {
+    evictAndReindex(p.getNameKey());
+  }
+
+  @Override
+  public void evictAndReindex(Project.NameKey p) {
+    evict(p);
     indexer.get().index(p);
   }
 
@@ -244,7 +263,7 @@
     } finally {
       listLock.unlock();
     }
-    evict(name);
+    evictAndReindex(name);
   }
 
   @Override
@@ -274,16 +293,28 @@
   }
 
   @Override
+  public void refreshProjectList() {
+    list.refresh(ListKey.ALL);
+  }
+
+  @Override
   public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
     try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
-      return all().stream()
-          .map(n -> inMemoryProjectCache.getIfPresent(n))
-          .filter(Objects::nonNull)
-          .flatMap(p -> p.getAllGroupUUIDs().stream())
-          // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
-          // against them just in case there is a bug or corner case.
-          .filter(id -> id != null && id.get() != null)
-          .collect(toSet());
+      Set<AccountGroup.UUID> relevantGroupUuids =
+          Streams.concat(
+                  Arrays.stream(
+                          config.getStringList("groups", /* subsection= */ null, "relevantGroup"))
+                      .map(AccountGroup::uuid),
+                  all().stream()
+                      .map(n -> inMemoryProjectCache.getIfPresent(n))
+                      .filter(Objects::nonNull)
+                      .flatMap(p -> p.getAllGroupUUIDs().stream())
+                      // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+                      // against them just in case there is a bug or corner case.
+                      .filter(id -> id != null && id.get() != null))
+              .collect(toSet());
+      logger.atFine().log("relevant group UUIDs: %s", relevantGroupUuids);
+      return relevantGroupUuids;
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 332aba7..8794f66 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
@@ -54,15 +55,15 @@
               () -> {
                 for (Project.NameKey name : cache.all()) {
                   pool.execute(
-                      () ->
-                          cache
-                              .get(name)
-                              .orElseThrow(
-                                  () ->
-                                      new IllegalStateException(
-                                          "race while traversing projects. got "
-                                              + name
-                                              + " when loading all projects, but can't load it now")));
+                      () -> {
+                        Optional<ProjectState> project = cache.get(name);
+                        if (!project.isPresent()) {
+                          throw new IllegalStateException(
+                              "race while traversing projects. got "
+                                  + name
+                                  + " when loading all projects, but can't load it now");
+                        }
+                      });
                 }
                 pool.shutdown();
                 try {
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 11ffcad..6d35012 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.project;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.entities.Permission.isPermission;
@@ -23,6 +22,7 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
@@ -105,22 +105,12 @@
 
   public static final String COMMENTLINK = "commentlink";
   public static final String LABEL = "label";
+  public static final String KEY_LABEL_DESCRIPTION = "description";
   public static final String KEY_FUNCTION = "function";
   public static final String KEY_DEFAULT_VALUE = "defaultValue";
-  public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
   public static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
   public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
-  public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
   public static final String KEY_COPY_CONDITION = "copyCondition";
-  public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
-  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
-      "copyAllScoresIfListOfFilesDidNotChange";
-  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";
-  public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
-  public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
-  public static final String KEY_COPY_VALUE = "copyValue";
   public static final String KEY_VALUE = "value";
   public static final String KEY_CAN_OVERRIDE = "canOverride";
   public static final String KEY_BRANCH = "branch";
@@ -140,8 +130,10 @@
           KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
 
   public static final String KEY_MATCH = "match";
-  private static final String KEY_HTML = "html";
   public static final String KEY_LINK = "link";
+  public static final String KEY_PREFIX = "prefix";
+  public static final String KEY_SUFFIX = "suffix";
+  public static final String KEY_TEXT = "text";
   public static final String KEY_ENABLED = "enabled";
 
   public static final String PROJECT_CONFIG = "project.config";
@@ -222,7 +214,8 @@
           projectName,
           projectName.equals(allProjectsName)
               ? allProjectsConfigProvider.get(allProjectsName)
-              : Optional.empty());
+              : Optional.empty(),
+          allProjectsName);
     }
 
     public ProjectConfig read(MetaDataUpdate update) throws IOException, ConfigInvalidException {
@@ -248,6 +241,7 @@
   }
 
   private final Optional<StoredConfig> baseConfig;
+  private final AllProjectsName allProjectsName;
 
   private Project project;
   private AccountsSection accountsSection;
@@ -285,7 +279,6 @@
             .setCheckReceivedObjects(checkReceivedObjects)
             .setExtensionPanelSections(extensionPanelSections);
     groupList.byUUID().values().forEach(g -> builder.addGroup(g));
-    accessSections.values().forEach(a -> builder.addAccessSection(a));
     contributorAgreements.values().forEach(c -> builder.addContributorAgreement(c));
     notifySections.values().forEach(n -> builder.addNotifySection(n));
     subscribeSections.values().forEach(s -> builder.addSubscribeSection(s));
@@ -298,10 +291,32 @@
     projectLevelConfigs
         .entrySet()
         .forEach(c -> builder.addProjectLevelConfig(c.getKey(), c.getValue().toText()));
+
+    if (projectName.equals(allProjectsName)) {
+      // Filter out permissions that aren't allowed to be set on All-Projects
+      accessSections
+          .values()
+          .forEach(
+              a -> {
+                List<Permission.Builder> copy = new ArrayList<>();
+                for (Permission p : a.getPermissions()) {
+                  if (Permission.canBeOnAllProjects(a.getName(), p.getName())) {
+                    copy.add(p.toBuilder());
+                  }
+                }
+                AccessSection section =
+                    AccessSection.builder(a.getName())
+                        .modifyPermissions(permissions -> permissions.addAll(copy))
+                        .build();
+                builder.addAccessSection(section);
+              });
+    } else {
+      accessSections.values().forEach(a -> builder.addAccessSection(a));
+    }
     return builder.build();
   }
 
-  public static StoredCommentLinkInfo buildCommentLink(Config cfg, String name, boolean allowRaw)
+  public static StoredCommentLinkInfo buildCommentLink(Config cfg, String name)
       throws IllegalArgumentException {
     String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
     if (match != null) {
@@ -314,8 +329,9 @@
     }
 
     String link = cfg.getString(COMMENTLINK, name, KEY_LINK);
-    String html = cfg.getString(COMMENTLINK, name, KEY_HTML);
-    boolean hasHtml = !Strings.isNullOrEmpty(html);
+    String linkPrefix = cfg.getString(COMMENTLINK, name, KEY_PREFIX);
+    String linkSuffix = cfg.getString(COMMENTLINK, name, KEY_SUFFIX);
+    String linkText = cfg.getString(COMMENTLINK, name, KEY_TEXT);
 
     String rawEnabled = cfg.getString(COMMENTLINK, name, KEY_ENABLED);
     Boolean enabled;
@@ -324,12 +340,8 @@
     } else {
       enabled = null;
     }
-    checkArgument(allowRaw || !hasHtml, "Raw html replacement not allowed");
 
-    if (Strings.isNullOrEmpty(match)
-        && Strings.isNullOrEmpty(link)
-        && !hasHtml
-        && enabled != null) {
+    if (Strings.isNullOrEmpty(match) && Strings.isNullOrEmpty(link) && enabled != null) {
       if (enabled) {
         return StoredCommentLinkInfo.enabled(name);
       }
@@ -338,7 +350,9 @@
     return StoredCommentLinkInfo.builder(name)
         .setMatch(match)
         .setLink(link)
-        .setHtml(html)
+        .setPrefix(linkPrefix)
+        .setSuffix(linkSuffix)
+        .setText(linkText)
         .setEnabled(enabled)
         .setOverrideOnly(false)
         .build();
@@ -353,9 +367,13 @@
     requireNonNull(commentLinkSections.remove(name));
   }
 
-  private ProjectConfig(Project.NameKey projectName, Optional<StoredConfig> baseConfig) {
+  private ProjectConfig(
+      Project.NameKey projectName,
+      Optional<StoredConfig> baseConfig,
+      AllProjectsName allProjectsName) {
     this.projectName = projectName;
     this.baseConfig = baseConfig;
+    this.allProjectsName = allProjectsName;
   }
 
   public void load(Repository repo) throws IOException, ConfigInvalidException {
@@ -493,12 +511,6 @@
     return sort(contributorAgreements.values());
   }
 
-  public void remove(ContributorAgreement section) {
-    if (section != null) {
-      accessSections.remove(section.getName());
-    }
-  }
-
   public void replace(ContributorAgreement section) {
     ContributorAgreement.Builder ca = section.toBuilder();
     ca.setAutoVerify(resolve(section.getAutoVerify()));
@@ -527,10 +539,16 @@
     return submitRequirementSections;
   }
 
+  /** Adds or replaces the given {@link SubmitRequirement} in this config. */
   public void upsertSubmitRequirement(SubmitRequirement requirement) {
     submitRequirementSections.put(requirement.name(), requirement);
   }
 
+  @VisibleForTesting
+  public void clearSubmitRequirements() {
+    submitRequirementSections = new LinkedHashMap<>();
+  }
+
   /** Adds or replaces the given {@link LabelType} in this config. */
   public void upsertLabelType(LabelType labelType) {
     labelSections.put(labelType.getName(), labelType);
@@ -894,7 +912,7 @@
       Config rc, String section, String subsection, String varName, boolean useRange) {
     Permission.Builder perm = Permission.builder(varName);
     loadPermissionRules(rc, section, subsection, varName, perm, useRange);
-    return ImmutableList.copyOf(perm.build().getRules());
+    return perm.build().getRules();
   }
 
   private void loadPermissionRules(
@@ -949,6 +967,7 @@
     Map<String, String> lowerNames = new HashMap<>();
     submitRequirementSections = new LinkedHashMap<>();
     for (String name : rc.getSubsections(SUBMIT_REQUIREMENT)) {
+      checkDuplicateSrDefinition(rc, name);
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
         error(
@@ -958,8 +977,10 @@
       }
       lowerNames.put(lower, name);
       String description = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_DESCRIPTION);
-      String appExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
-      String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
+      String applicabilityExpr =
+          rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
+      String submittabilityExpr =
+          rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
       String overrideExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_EXPRESSION);
       boolean canInherit;
       try {
@@ -979,7 +1000,7 @@
         continue;
       }
 
-      if (blockExpr == null) {
+      if (submittabilityExpr == null) {
         error(
             String.format(
                 "Setting a submittability expression for submit requirement '%s' is required:"
@@ -988,14 +1009,14 @@
         continue;
       }
 
-      // The expressions are validated in SubmitRequirementExpressionsValidator.
+      // The expressions are validated in SubmitRequirementConfigValidator.
 
       SubmitRequirement submitRequirement =
           SubmitRequirement.builder()
               .setName(name)
               .setDescription(Optional.ofNullable(description))
-              .setApplicabilityExpression(SubmitRequirementExpression.of(appExpr))
-              .setSubmittabilityExpression(SubmitRequirementExpression.create(blockExpr))
+              .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
               .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
               .setAllowOverrideInChildProjects(canInherit)
               .build();
@@ -1004,6 +1025,40 @@
     }
   }
 
+  private void checkDuplicateSrDefinition(Config rc, String srName) {
+    if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_DESCRIPTION).length > 1) {
+      error(
+          String.format(
+              "Multiple definitions of %s for submit requirement '%s'",
+              KEY_SR_DESCRIPTION, srName));
+    }
+    if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_APPLICABILITY_EXPRESSION).length > 1) {
+      error(
+          String.format(
+              "Multiple definitions of %s for submit requirement '%s'",
+              KEY_SR_APPLICABILITY_EXPRESSION, srName));
+    }
+    if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_SUBMITTABILITY_EXPRESSION).length > 1) {
+      error(
+          String.format(
+              "Multiple definitions of %s for submit requirement '%s'",
+              KEY_SR_SUBMITTABILITY_EXPRESSION, srName));
+    }
+    if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_OVERRIDE_EXPRESSION).length > 1) {
+      error(
+          String.format(
+              "Multiple definitions of %s for submit requirement '%s'",
+              KEY_SR_OVERRIDE_EXPRESSION, srName));
+    }
+    if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS).length
+        > 1) {
+      error(
+          String.format(
+              "Multiple definitions of %s for submit requirement '%s'",
+              KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, srName));
+    }
+  }
+
   /**
    * Report unsupported submit requirement parameters as errors.
    *
@@ -1077,6 +1132,8 @@
         continue;
       }
 
+      label.setDescription(Optional.ofNullable(rc.getString(LABEL, name, KEY_LABEL_DESCRIPTION)));
+
       String functionName = rc.getString(LABEL, name, KEY_FUNCTION);
       Optional<LabelFunction> function =
           functionName != null
@@ -1086,9 +1143,11 @@
         error(
             String.format(
                 "Invalid %s for label \"%s\". Valid names are: %s",
-                KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet())));
+                KEY_FUNCTION,
+                name,
+                Joiner.on(", ").join(LabelFunction.ALL_NON_DEPRECATED.keySet())));
       }
-      label.setFunction(function.orElse(null));
+      function.ifPresent(label::setFunction);
       label.setCopyCondition(rc.getString(LABEL, name, KEY_COPY_CONDITION));
 
       if (!values.isEmpty()) {
@@ -1103,62 +1162,26 @@
           rc.getBoolean(LABEL, name, KEY_ALLOW_POST_SUBMIT, LabelType.DEF_ALLOW_POST_SUBMIT));
       label.setIgnoreSelfApproval(
           rc.getBoolean(LABEL, name, KEY_IGNORE_SELF_APPROVAL, LabelType.DEF_IGNORE_SELF_APPROVAL));
-      label.setCopyAnyScore(
-          rc.getBoolean(LABEL, name, KEY_COPY_ANY_SCORE, LabelType.DEF_COPY_ANY_SCORE));
-      label.setCopyMinScore(
-          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,
-              name,
-              KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
-              LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE));
-      label.setCopyAllScoresOnTrivialRebase(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
-              LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE));
-      label.setCopyAllScoresIfNoCodeChange(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
-              LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE));
-      label.setCopyAllScoresIfNoChange(
-          rc.getBoolean(
-              LABEL,
-              name,
-              KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
-              LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE));
-      Set<Short> copyValues = new HashSet<>();
-      for (String value : rc.getStringList(LABEL, name, KEY_COPY_VALUE)) {
-        try {
-          short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
-          if (!copyValues.add(copyValue)) {
-            error(
-                String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name));
-          }
-        } catch (IllegalArgumentException notValue) {
-          error(
-              String.format(
-                  "Invalid %s \"%s\" for label \"%s\": %s",
-                  KEY_COPY_VALUE, value, name, notValue.getMessage()));
-        }
-      }
-      label.setCopyValues(copyValues);
       label.setCanOverride(
           rc.getBoolean(LABEL, name, KEY_CAN_OVERRIDE, LabelType.DEF_CAN_OVERRIDE));
       List<String> refPatterns = getStringListOrNull(rc, LABEL, name, KEY_BRANCH);
-      label.setRefPatterns(refPatterns == null ? null : ImmutableList.copyOf(refPatterns));
+      if (refPatterns == null) {
+        label.setRefPatterns(null);
+      } else {
+        for (String pattern : refPatterns) {
+          if (pattern.startsWith("^")) {
+            try {
+              Pattern.compile(pattern);
+            } catch (PatternSyntaxException e) {
+              error(
+                  String.format(
+                      "Invalid ref pattern \"%s\" in %s.%s.%s: %s",
+                      pattern, LABEL, name, KEY_BRANCH, e.getMessage()));
+            }
+          }
+        }
+        label.setRefPatterns(ImmutableList.copyOf(refPatterns));
+      }
       labelSections.put(name, label.build());
     }
   }
@@ -1172,6 +1195,7 @@
     return false;
   }
 
+  @Nullable
   private List<String> getStringListOrNull(
       Config rc, String section, String subSection, String name) {
     String[] ac = rc.getStringList(section, subSection, name);
@@ -1183,7 +1207,7 @@
     commentLinkSections = new LinkedHashMap<>(subsections.size());
     for (String name : subsections) {
       try {
-        commentLinkSections.put(name, buildCommentLink(rc, name, false));
+        commentLinkSections.put(name, buildCommentLink(rc, name));
       } catch (PatternSyntaxException e) {
         error(
             String.format(
@@ -1273,7 +1297,8 @@
           parsedConfig.fromText(cfg);
           projectLevelConfigs.put(pathInfo.path, parsedConfig);
         } catch (ConfigInvalidException e) {
-          logger.atWarning().withCause(e).log("Unable to parse config");
+          logger.atWarning().withCause(e).log(
+              "Unable to parse config for project %s", projectName.get());
         }
       }
     }
@@ -1341,6 +1366,7 @@
     return true;
   }
 
+  @Nullable
   public static String validMaxObjectSizeLimit(String value) throws ConfigInvalidException {
     if (value == null) {
       return null;
@@ -1384,13 +1410,22 @@
     unsetSection(rc, COMMENTLINK);
     if (commentLinkSections != null) {
       for (StoredCommentLinkInfo cm : commentLinkSections.values()) {
-        rc.setString(COMMENTLINK, cm.getName(), KEY_MATCH, cm.getMatch());
-        if (!Strings.isNullOrEmpty(cm.getHtml())) {
-          rc.setString(COMMENTLINK, cm.getName(), KEY_HTML, cm.getHtml());
+        // Match and Link can be empty if the commentlink is override only.
+        if (!Strings.isNullOrEmpty(cm.getMatch())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_MATCH, cm.getMatch());
         }
         if (!Strings.isNullOrEmpty(cm.getLink())) {
           rc.setString(COMMENTLINK, cm.getName(), KEY_LINK, cm.getLink());
         }
+        if (!Strings.isNullOrEmpty(cm.getPrefix())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_PREFIX, cm.getPrefix());
+        }
+        if (!Strings.isNullOrEmpty(cm.getSuffix())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_SUFFIX, cm.getSuffix());
+        }
+        if (!Strings.isNullOrEmpty(cm.getText())) {
+          rc.setString(COMMENTLINK, cm.getName(), KEY_TEXT, cm.getText());
+        }
         if (cm.getEnabled() != null && !cm.getEnabled()) {
           rc.setBoolean(COMMENTLINK, cm.getName(), KEY_ENABLED, cm.getEnabled());
         }
@@ -1579,6 +1614,11 @@
       String name = e.getKey();
       LabelType label = e.getValue();
       toUnset.remove(name);
+      if (label.getDescription().isPresent() && !label.getDescription().get().isEmpty()) {
+        rc.setString(LABEL, name, KEY_LABEL_DESCRIPTION, label.getDescription().get());
+      } else {
+        rc.unset(LABEL, name, KEY_LABEL_DESCRIPTION);
+      }
       rc.setString(LABEL, name, KEY_FUNCTION, label.getFunction().getFunctionName());
       rc.setInt(LABEL, name, KEY_DEFAULT_VALUE, label.getDefaultValue());
 
@@ -1597,67 +1637,6 @@
           label.isIgnoreSelfApproval(),
           LabelType.DEF_IGNORE_SELF_APPROVAL);
       setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ANY_SCORE,
-          label.isCopyAnyScore(),
-          LabelType.DEF_COPY_ANY_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_MIN_SCORE,
-          label.isCopyMinScore(),
-          LabelType.DEF_COPY_MIN_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_MAX_SCORE,
-          label.isCopyMaxScore(),
-          LabelType.DEF_COPY_MAX_SCORE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
-          label.isCopyAllScoresOnTrivialRebase(),
-          LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
-          label.isCopyAllScoresIfNoCodeChange(),
-          LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-      setBooleanConfigKey(
-          rc,
-          LABEL,
-          name,
-          KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
-          label.isCopyAllScoresIfNoChange(),
-          LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
-      setBooleanConfigKey(
-          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);
-      rc.setStringList(
-          LABEL,
-          name,
-          KEY_COPY_VALUE,
-          label.getCopyValues().stream().map(LabelValue::formatValue).collect(toList()));
-      setBooleanConfigKey(
           rc, LABEL, name, KEY_CAN_OVERRIDE, label.isCanOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = new ArrayList<>(label.getValues().size());
       for (LabelValue value : label.getValues()) {
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index 4e778a4..f1c161d 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -135,10 +135,6 @@
           e);
     } catch (RepositoryNotFoundException badName) {
       throw new BadRequestException("invalid project name: " + nameKey, badName);
-    } catch (ConfigInvalidException e) {
-      String msg = "Cannot create " + nameKey;
-      logger.atSevere().withCause(e).log(msg);
-      throw e;
     }
   }
 
diff --git a/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
index ccb5651..929399a 100644
--- a/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
+++ b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.AllProjectsName;
 import java.util.Iterator;
@@ -63,6 +64,7 @@
     return n;
   }
 
+  @Nullable
   private ProjectState computeNext(ProjectState n) {
     Project.NameKey parentName = n.getProject().getParent();
     if (parentName != null && visit(parentName)) {
diff --git a/java/com/google/gerrit/server/project/ProjectResource.java b/java/com/google/gerrit/server/project/ProjectResource.java
index 8802758..f9fa22e 100644
--- a/java/com/google/gerrit/server/project/ProjectResource.java
+++ b/java/com/google/gerrit/server/project/ProjectResource.java
@@ -21,8 +21,7 @@
 import com.google.inject.TypeLiteral;
 
 public class ProjectResource implements RestResource {
-  public static final TypeLiteral<RestView<ProjectResource>> PROJECT_KIND =
-      new TypeLiteral<RestView<ProjectResource>>() {};
+  public static final TypeLiteral<RestView<ProjectResource>> PROJECT_KIND = new TypeLiteral<>() {};
 
   private final ProjectState projectState;
   private final CurrentUser user;
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 69e6036..6352f66 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -15,9 +15,7 @@
 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;
@@ -268,35 +266,18 @@
 
   /** Get the sections that pertain only to this project. */
   List<SectionMatcher> getLocalAccessSections() {
-    List<SectionMatcher> sm = localAccessSections;
-    if (sm == null) {
-      ImmutableList<AccessSection> fromConfig =
-          cachedConfig.getAccessSections().values().stream()
-              .sorted(comparing(AccessSection::getName))
-              .collect(toImmutableList());
-      sm = new ArrayList<>(fromConfig.size());
-      for (AccessSection section : fromConfig) {
-        if (isAllProjects) {
-          List<Permission.Builder> copy = new ArrayList<>();
-          for (Permission p : section.getPermissions()) {
-            if (Permission.canBeOnAllProjects(section.getName(), p.getName())) {
-              copy.add(p.toBuilder());
-            }
-          }
-          section =
-              AccessSection.builder(section.getName())
-                  .modifyPermissions(permissions -> permissions.addAll(copy))
-                  .build();
-        }
-
-        SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
-        if (matcher != null) {
-          sm.add(matcher);
-        }
-      }
-      localAccessSections = sm;
+    if (localAccessSections != null) {
+      return localAccessSections;
     }
-    return sm;
+    List<SectionMatcher> sm = new ArrayList<>(cachedConfig.getAccessSections().values().size());
+    for (AccessSection section : cachedConfig.getAccessSections().values()) {
+      SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
+      if (matcher != null) {
+        sm.add(matcher);
+      }
+    }
+    localAccessSections = sm;
+    return localAccessSections;
   }
 
   /**
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index fca1b36..9463b39 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -247,7 +247,7 @@
     return r;
   }
 
-  private List<ChangeInfo> executeQueryAndAutoCloseChanges(
+  private ImmutableList<ChangeInfo> executeQueryAndAutoCloseChanges(
       Predicate<ChangeData> basePredicate,
       Set<Change.Id> seenChanges,
       List<Predicate<ChangeData>> predicates,
@@ -264,12 +264,12 @@
               .changeIndexQuery(
                   "projectsConsistencyCheckerQueryChanges",
                   q ->
-                      q.setRequestedFields(ChangeField.CHANGE, ChangeField.PATCH_SET)
+                      q.setRequestedFields(ChangeField.CHANGE_SPEC, ChangeField.PATCH_SET_SPEC)
                           .query(and(basePredicate, or(predicates))))
               .call();
 
       // Result for this query that we want to return to the client.
-      List<ChangeInfo> autoCloseableChangesByBranch = new ArrayList<>();
+      ImmutableList.Builder<ChangeInfo> autoCloseableChangesByBranch = ImmutableList.builder();
 
       for (ChangeData autoCloseableChange : queryResult) {
         // Skip changes that we have already processed, either by this query or by
@@ -306,7 +306,7 @@
         }
       }
 
-      return autoCloseableChangesByBranch;
+      return autoCloseableChangesByBranch.build();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       throw new StorageException(e);
diff --git a/java/com/google/gerrit/server/project/RefPatternMatcher.java b/java/com/google/gerrit/server/project/RefPatternMatcher.java
index b9076b3..be840b5 100644
--- a/java/com/google/gerrit/server/project/RefPatternMatcher.java
+++ b/java/com/google/gerrit/server/project/RefPatternMatcher.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.CurrentUser;
@@ -32,6 +33,13 @@
 import java.util.stream.Stream;
 
 public abstract class RefPatternMatcher {
+  public static RefPatternMatcher getMatcher(AccessSection section) {
+    if (section.getNamePattern().isPresent()) {
+      return new Regexp(section.getNamePattern().get());
+    }
+    return getMatcher(section.getName());
+  }
+
   public static RefPatternMatcher getMatcher(String pattern) {
     if (containsParameters(pattern)) {
       return new ExpandParameters(pattern);
@@ -79,6 +87,10 @@
       pattern = Pattern.compile(re);
     }
 
+    Regexp(Pattern re) {
+      pattern = re;
+    }
+
     @Override
     public boolean match(String ref, CurrentUser user) {
       return pattern.matcher(ref).matches() || (isRE(ref) && pattern.pattern().equals(ref));
diff --git a/java/com/google/gerrit/server/project/RefResource.java b/java/com/google/gerrit/server/project/RefResource.java
index fcf6048..c67e10d 100644
--- a/java/com/google/gerrit/server/project/RefResource.java
+++ b/java/com/google/gerrit/server/project/RefResource.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.gerrit.server.CurrentUser;
+import java.util.Optional;
 
 public abstract class RefResource extends ProjectResource {
 
@@ -25,6 +26,10 @@
   /** Returns the ref's name */
   public abstract String getRef();
 
-  /** Returns the ref's revision */
-  public abstract String getRevision();
+  /**
+   * Returns the ref's revision.
+   *
+   * @return the ref's revision, {@link Optional#empty()} if the ref doesn't exist (yet)
+   */
+  public abstract Optional<String> getRevision();
 }
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index 797756b..07f7ba5 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -18,13 +18,12 @@
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
 import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import java.io.IOException;
 import java.util.Collections;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RevisionSyntaxException;
@@ -37,37 +36,31 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 public class RefUtil {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private RefUtil() {}
 
-  public static ObjectId parseBaseRevision(
-      Repository repo, Project.NameKey projectName, String baseRevision)
-      throws InvalidRevisionException {
+  public static ObjectId parseBaseRevision(Repository repo, String baseRevision)
+      throws UnprocessableEntityException, IOException {
     try {
       ObjectId revid = repo.resolve(baseRevision);
       if (revid == null) {
-        throw new InvalidRevisionException(baseRevision);
+        throw new UnprocessableEntityException(
+            String.format("base revision \"%s\" not found", baseRevision));
       }
       return revid;
-    } catch (IOException err) {
-      logger.atSevere().withCause(err).log(
-          "Cannot resolve \"%s\" in project \"%s\"", baseRevision, projectName.get());
-      throw new InvalidRevisionException(baseRevision);
-    } catch (RevisionSyntaxException err) {
-      throw new InvalidRevisionException(baseRevision, err);
+    } catch (RevisionSyntaxException e) {
+      throw new UnprocessableEntityException(
+          String.format("base revision \"%s\" is invalid", baseRevision), e);
+    } catch (AmbiguousObjectException e) {
+      throw new UnprocessableEntityException(
+          String.format("base revision \"%s\" is ambiguous", baseRevision), e);
     }
   }
 
-  public static RevWalk verifyConnected(Repository repo, ObjectId revid)
-      throws InvalidRevisionException {
+  public static RevWalk verifyConnected(Repository repo, ObjectId baseRevision)
+      throws BadRequestException, UnprocessableEntityException, IOException {
     try {
       ObjectWalk rw = new ObjectWalk(repo);
-      try {
-        rw.markStart(rw.parseCommit(revid));
-      } catch (IncorrectObjectTypeException err) {
-        throw new InvalidRevisionException(revid.name(), err);
-      }
+      rw.markStart(rw.parseCommit(baseRevision));
       RefDatabase refDb = repo.getRefDatabase();
       Iterable<Ref> refs =
           Iterables.concat(
@@ -85,12 +78,12 @@
       }
       rw.checkConnectivity();
       return rw;
-    } catch (IncorrectObjectTypeException | MissingObjectException err) {
-      throw new InvalidRevisionException(revid.name(), err);
-    } catch (IOException err) {
-      logger.atSevere().withCause(err).log(
-          "Repository \"%s\" may be corrupt; suggest running git fsck", repo.getDirectory());
-      throw new InvalidRevisionException(revid.name());
+    } catch (IncorrectObjectTypeException e) {
+      throw new BadRequestException(
+          String.format("base revision \"%s\" is not a commit", baseRevision.name()), e);
+    } catch (MissingObjectException e) {
+      throw new UnprocessableEntityException(
+          String.format("base revision \"%s\" not found", baseRevision.name()), e);
     }
   }
 
@@ -119,19 +112,4 @@
     }
     return result;
   }
-
-  /** Error indicating the revision is invalid as supplied. */
-  public static class InvalidRevisionException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    public static final String MESSAGE = "Invalid Revision";
-
-    InvalidRevisionException(@Nullable String invalidRevision) {
-      super(MESSAGE + ": " + invalidRevision);
-    }
-
-    InvalidRevisionException(@Nullable String invalidRevision, Throwable why) {
-      super(MESSAGE + ": " + invalidRevision, why);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/project/RefValidationHelper.java b/java/com/google/gerrit/server/project/RefValidationHelper.java
index a6020a3..ea92b48 100644
--- a/java/com/google/gerrit/server/project/RefValidationHelper.java
+++ b/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.IdentifiedUser;
@@ -40,13 +41,18 @@
     this.operationType = operationType;
   }
 
-  public void validateRefOperation(String projectName, IdentifiedUser user, RefUpdate update)
+  public void validateRefOperation(
+      String projectName,
+      IdentifiedUser user,
+      RefUpdate update,
+      ImmutableListMultimap<String, String> pushOptions)
       throws ResourceConflictException {
     RefOperationValidators refValidators =
         refValidatorsFactory.create(
             Project.builder(Project.nameKey(projectName)).build(),
             user,
-            RefOperationValidators.getCommand(update, operationType));
+            RefOperationValidators.getCommand(update, operationType),
+            pushOptions);
     try {
       refValidators.validateForRefOperation();
     } catch (ValidationException e) {
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 0336e8e..3fda87a 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -32,10 +32,12 @@
 @Singleton
 public class RemoveReviewerControl {
   private final PermissionBackend permissionBackend;
+  private final ChangeData.Factory changeDataFactory;
 
   @Inject
-  RemoveReviewerControl(PermissionBackend permissionBackend) {
+  RemoveReviewerControl(PermissionBackend permissionBackend, ChangeData.Factory changeDataFactory) {
     this.permissionBackend = permissionBackend;
+    this.changeDataFactory = changeDataFactory;
   }
 
   /**
@@ -64,6 +66,20 @@
 
   /** Returns true if the user is allowed to remove this reviewer. */
   public boolean testRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
+      throws PermissionBackendException {
+    return testRemoveReviewer(notes, currentUser, approval.accountId(), approval.value());
+  }
+
+  /** Returns true if the user is allowed to remove this reviewer. */
+  public boolean testRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int value)
+      throws PermissionBackendException {
+    return testRemoveReviewer(changeDataFactory.create(notes), currentUser, reviewer, value);
+  }
+
+  /** Returns true if the user is allowed to remove this reviewer. */
+  public boolean testRemoveReviewer(
       ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
       throws PermissionBackendException {
     if (canRemoveReviewerWithoutPermissionCheck(
@@ -108,30 +124,10 @@
     // owner and site admin can remove anyone
     PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
     PermissionBackend.ForProject forProject = withUser.project(change.getProject());
-    if (check(forProject.ref(change.getDest().branch()), RefPermission.WRITE_CONFIG)
-        || check(withUser, GlobalPermission.ADMINISTRATE_SERVER)) {
+    if (forProject.ref(change.getDest().branch()).test(RefPermission.WRITE_CONFIG)
+        || withUser.test(GlobalPermission.ADMINISTRATE_SERVER)) {
       return true;
     }
     return false;
   }
-
-  private static boolean check(PermissionBackend.ForRef forRef, RefPermission perm)
-      throws PermissionBackendException {
-    try {
-      forRef.check(perm);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
-
-  private static boolean check(PermissionBackend.WithUser withUser, GlobalPermission perm)
-      throws PermissionBackendException {
-    try {
-      withUser.check(perm);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index 763957e..eaebab2 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -25,10 +26,11 @@
  * of which sections are relevant to any given input reference.
  */
 public class SectionMatcher extends RefPatternMatcher {
+  @Nullable
   static SectionMatcher wrap(Project.NameKey project, AccessSection section) {
     String ref = section.getName();
     if (AccessSection.isValidRefSectionName(ref)) {
-      return new SectionMatcher(project, section, getMatcher(ref));
+      return new SectionMatcher(project, section, getMatcher(section));
     }
     return null;
   }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
new file mode 100644
index 0000000..6366a14
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementConfigValidator.java
@@ -0,0 +1,135 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * Validates the expressions of submit requirements in {@code project.config}.
+ *
+ * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
+ * ProjectConfig#loadSubmitRequirementSections(Config)}.
+ *
+ * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
+ * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
+ * {@link ProjectConfig} is cached in the project cache).
+ */
+public class SubmitRequirementConfigValidator implements CommitValidationListener {
+  private final DiffOperations diffOperations;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  SubmitRequirementConfigValidator(
+      DiffOperations diffOperations,
+      ProjectConfig.Factory projectConfigFactory,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.diffOperations = diffOperations;
+    this.projectConfigFactory = projectConfigFactory;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+      throws CommitValidationException {
+    try {
+      if (!event.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
+        // the project.config file in refs/meta/config was not modified, hence we do not need to
+        // validate the submit requirements in it
+        return ImmutableList.of();
+      }
+
+      ProjectConfig projectConfig = getProjectConfig(event);
+      ImmutableList.Builder<String> validationMsgsBuilder = ImmutableList.builder();
+      for (SubmitRequirement submitRequirement :
+          projectConfig.getSubmitRequirementSections().values()) {
+        validationMsgsBuilder.addAll(
+            submitRequirementExpressionsValidator.validateExpressions(submitRequirement));
+      }
+      ImmutableList<String> validationMsgs = validationMsgsBuilder.build();
+      if (!validationMsgs.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid submit requirement expressions in %s (revision = %s)",
+                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision().name()),
+            new ImmutableList.Builder<CommitValidationMessage>()
+                .add(
+                    new CommitValidationMessage(
+                        "Invalid project configuration", ValidationMessage.Type.ERROR))
+                .addAll(
+                    validationMsgs.stream()
+                        .map(m -> toCommitValidationMessage(m))
+                        .collect(Collectors.toList()))
+                .build());
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
+      throw new CommitValidationException(
+          String.format(
+              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
+                  + " of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              event.commit.getName(),
+              RefNames.REFS_CONFIG,
+              event.project.getNameKey()),
+          e);
+    }
+  }
+
+  private static CommitValidationMessage toCommitValidationMessage(String message) {
+    return new CommitValidationMessage(message, ValidationMessage.Type.ERROR);
+  }
+
+  /**
+   * Whether the given file was changed in the given revision.
+   *
+   * @param receiveEvent the receive event
+   * @param fileName the name of the file
+   */
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    return diffOperations
+        .listModifiedFilesAgainstParent(
+            receiveEvent.project.getNameKey(),
+            receiveEvent.commit,
+            /* parentNum=*/ 0,
+            DiffOptions.DEFAULTS)
+        .keySet().stream()
+        .anyMatch(fileName::equals);
+  }
+
+  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
+    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
+    return projectConfig;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementEvaluationException.java b/java/com/google/gerrit/server/project/SubmitRequirementEvaluationException.java
new file mode 100644
index 0000000..4d15200
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementEvaluationException.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.server.project;
+
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+
+/**
+ * Exception that might occur when evaluating {@link SubmitRequirementPredicate} in {@link
+ * SubmitRequirementExpression}.
+ *
+ * <p>This exception will result in {@link
+ * com.google.gerrit.entities.SubmitRequirementResult.Status#ERROR} overall submit requirement
+ * evaluation status.
+ */
+public class SubmitRequirementEvaluationException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public SubmitRequirementEvaluationException(String errorMessage) {
+    super(errorMessage);
+  }
+
+  public SubmitRequirementEvaluationException(String errorMessage, Throwable why) {
+    super(errorMessage, why);
+  }
+
+  public SubmitRequirementEvaluationException(Throwable why) {
+    super(why);
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
index 738e71b..f2e4ff8 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
@@ -15,146 +15,59 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.git.validators.CommitValidationException;
-import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.git.validators.ValidationMessage;
-import com.google.gerrit.server.patch.DiffNotAvailableException;
-import com.google.gerrit.server.patch.DiffOperations;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
-/**
- * Validates the expressions of submit requirements in {@code project.config}.
- *
- * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
- * ProjectConfig#loadSubmitRequirementSections(Config)}.
- *
- * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
- * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
- * {@link ProjectConfig} is cached in the project cache).
- */
 @Singleton
-public class SubmitRequirementExpressionsValidator implements CommitValidationListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final DiffOperations diffOperations;
-  private final ProjectConfig.Factory projectConfigFactory;
+public class SubmitRequirementExpressionsValidator {
   private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
 
   @Inject
-  SubmitRequirementExpressionsValidator(
-      DiffOperations diffOperations,
-      ProjectConfig.Factory projectConfigFactory,
-      SubmitRequirementsEvaluator submitRequirementsEvaluator) {
-    this.diffOperations = diffOperations;
-    this.projectConfigFactory = projectConfigFactory;
+  SubmitRequirementExpressionsValidator(SubmitRequirementsEvaluator submitRequirementsEvaluator) {
     this.submitRequirementsEvaluator = submitRequirementsEvaluator;
   }
 
-  @Override
-  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
-      throws CommitValidationException {
-    try {
-      if (!event.refName.equals(RefNames.REFS_CONFIG)
-          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
-        // the project.config file in refs/meta/config was not modified, hence we do not need to
-        // validate the submit requirements in it
-        return ImmutableList.of();
-      }
-
-      ProjectConfig projectConfig = getProjectConfig(event);
-      ImmutableList<CommitValidationMessage> validationMessages =
-          validateSubmitRequirementExpressions(
-              projectConfig.getSubmitRequirementSections().values());
-      if (!validationMessages.isEmpty()) {
-        throw new CommitValidationException(
-            String.format(
-                "invalid submit requirement expressions in %s (revision = %s)",
-                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
-            validationMessages);
-      }
-      return ImmutableList.of();
-    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
-      String errorMessage =
-          String.format(
-              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
-                  + " of project %s",
-              ProjectConfig.PROJECT_CONFIG,
-              event.commit.getName(),
-              RefNames.REFS_CONFIG,
-              event.project.getNameKey());
-      logger.atSevere().withCause(e).log(errorMessage);
-      throw new CommitValidationException(errorMessage, e);
-    }
-  }
-
   /**
-   * Whether the given file was changed in the given revision.
+   * Validates the query expressions on the input {@code submitRequirement}.
    *
-   * @param receiveEvent the receive event
-   * @param fileName the name of the file
+   * @return list of string containing the error messages resulting from the validation. The list is
+   *     empty if the "submit requirement" is valid.
    */
-  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
-      throws DiffNotAvailableException {
-    return diffOperations
-        .listModifiedFilesAgainstParent(
-            receiveEvent.project.getNameKey(), receiveEvent.commit, /* parentNum=*/ 0)
-        .keySet().stream()
-        .anyMatch(fileName::equals);
-  }
-
-  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
-      throws IOException, ConfigInvalidException {
-    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
-    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
-    return projectConfig;
-  }
-
-  private ImmutableList<CommitValidationMessage> validateSubmitRequirementExpressions(
-      Collection<SubmitRequirement> submitRequirements) {
-    List<CommitValidationMessage> validationMessages = new ArrayList<>();
-    for (SubmitRequirement submitRequirement : submitRequirements) {
-      validateSubmitRequirementExpression(
-          validationMessages,
-          submitRequirement,
-          submitRequirement.submittabilityExpression(),
-          ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
-      submitRequirement
-          .applicabilityExpression()
-          .ifPresent(
-              expression ->
-                  validateSubmitRequirementExpression(
-                      validationMessages,
-                      submitRequirement,
-                      expression,
-                      ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
-      submitRequirement
-          .overrideExpression()
-          .ifPresent(
-              expression ->
-                  validateSubmitRequirementExpression(
-                      validationMessages,
-                      submitRequirement,
-                      expression,
-                      ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
-    }
+  public ImmutableList<String> validateExpressions(SubmitRequirement submitRequirement) {
+    List<String> validationMessages = new ArrayList<>();
+    validateSubmitRequirementExpression(
+        validationMessages,
+        submitRequirement,
+        submitRequirement.submittabilityExpression(),
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+    submitRequirement
+        .applicabilityExpression()
+        .ifPresent(
+            expression ->
+                validateSubmitRequirementExpression(
+                    validationMessages,
+                    submitRequirement,
+                    expression,
+                    ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
+    submitRequirement
+        .overrideExpression()
+        .ifPresent(
+            expression ->
+                validateSubmitRequirementExpression(
+                    validationMessages,
+                    submitRequirement,
+                    expression,
+                    ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
     return ImmutableList.copyOf(validationMessages);
   }
 
   private void validateSubmitRequirementExpression(
-      List<CommitValidationMessage> validationMessages,
+      List<String> validationMessages,
       SubmitRequirement submitRequirement,
       SubmitRequirementExpression expression,
       String configKey) {
@@ -162,23 +75,19 @@
       submitRequirementsEvaluator.validateExpression(expression);
     } catch (QueryParseException e) {
       if (validationMessages.isEmpty()) {
-        validationMessages.add(
-            new CommitValidationMessage(
-                "Invalid project configuration", ValidationMessage.Type.ERROR));
+        validationMessages.add("Invalid project configuration");
       }
       validationMessages.add(
-          new CommitValidationMessage(
-              String.format(
-                  "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
-                      + " invalid: %s",
-                  ProjectConfig.PROJECT_CONFIG,
-                  expression.expressionString(),
-                  submitRequirement.name(),
-                  ProjectConfig.SUBMIT_REQUIREMENT,
-                  submitRequirement.name(),
-                  configKey,
-                  e.getMessage()),
-              ValidationMessage.Type.ERROR));
+          String.format(
+              "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                  + " invalid: %s",
+              ProjectConfig.PROJECT_CONFIG,
+              expression.expressionString(),
+              submitRequirement.name(),
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              submitRequirement.name(),
+              configKey,
+              e.getMessage()));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementJson.java b/java/com/google/gerrit/server/project/SubmitRequirementJson.java
new file mode 100644
index 0000000..5593ff4
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementJson.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.inject.Singleton;
+
+/** Converts a {@link SubmitRequirement} to a {@link SubmitRequirementInfo}. */
+@Singleton
+public class SubmitRequirementJson {
+  public static SubmitRequirementInfo format(SubmitRequirement sr) {
+    SubmitRequirementInfo info = new SubmitRequirementInfo();
+    info.name = sr.name();
+    info.description = sr.description().orElse(null);
+    if (sr.applicabilityExpression().isPresent()) {
+      info.applicabilityExpression = sr.applicabilityExpression().get().expressionString();
+    }
+    if (sr.overrideExpression().isPresent()) {
+      info.overrideExpression = sr.overrideExpression().get().expressionString();
+    }
+    info.submittabilityExpression = sr.submittabilityExpression().expressionString();
+    info.allowOverrideInChildProjects = sr.allowOverrideInChildProjects();
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementResource.java b/java/com/google/gerrit/server/project/SubmitRequirementResource.java
new file mode 100644
index 0000000..d075cd7
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementResource.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class SubmitRequirementResource implements RestResource {
+  public static final TypeLiteral<RestView<SubmitRequirementResource>> SUBMIT_REQUIREMENT_KIND =
+      new TypeLiteral<>() {};
+
+  private final ProjectResource project;
+  private final SubmitRequirement submitRequirement;
+
+  public SubmitRequirementResource(ProjectResource project, SubmitRequirement submitRequirement) {
+    this.project = project;
+    this.submitRequirement = submitRequirement;
+  }
+
+  public ProjectResource getProject() {
+    return project;
+  }
+
+  public SubmitRequirement getSubmitRequirement() {
+    return submitRequirement;
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
index 539edc1..39ba8b4 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.MoreCollectors;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Label;
@@ -27,10 +30,10 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 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;
 
@@ -48,36 +51,92 @@
    * com.google.gerrit.server.rules.SubmitRule}s) and convert them to submit requirement results.
    */
   public static Map<SubmitRequirement, SubmitRequirementResult> getLegacyRequirements(
-      SubmitRuleEvaluator.Factory evaluator, ChangeData cd) {
+      ChangeData cd) {
     // We use SubmitRuleOptions.defaults() which does not recompute submit rules for closed changes.
     // This doesn't have an effect since we never call this class (i.e. to evaluate submit
     // requirements) for closed changes.
-    List<SubmitRecord> records = evaluator.create(SubmitRuleOptions.defaults()).evaluate(cd);
+    List<SubmitRecord> records = cd.submitRecords(SubmitRuleOptions.defaults());
+    boolean areForced =
+        records.stream().anyMatch(record -> SubmitRecord.Status.FORCED.equals(record.status));
     List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
     ObjectId commitId = cd.currentPatchSet().commitId();
-    return records.stream()
-        .map(r -> createResult(r, labelTypes, commitId))
-        .flatMap(List::stream)
-        .collect(Collectors.toMap(sr -> sr.submitRequirement(), Function.identity()));
+    Map<String, List<SubmitRequirementResult>> srsByName =
+        records.stream()
+            // Filter out the "FORCED" submit record. This is a marker submit record that was just
+            // used to indicate that all other records were forced. "FORCED" means that the change
+            // was pushed with the %submit option bypassing submit rules.
+            .filter(r -> !SubmitRecord.Status.FORCED.equals(r.status))
+            .map(r -> createResult(r, labelTypes, commitId, areForced))
+            .flatMap(List::stream)
+            .collect(Collectors.groupingBy(sr -> sr.submitRequirement().name()));
+
+    // We convert submit records to submit requirements by generating a separate
+    // submit requirement result for each available label in each submit record.
+    // The SR status is derived from the label status of the submit record.
+    // This conversion might result in duplicate entries.
+    // One such example can be a prolog rule emitting the same label name twice.
+    // Another case might happen if two different submit rules emit the same label
+    // name. In such cases, we need to merge these entries and return a single submit
+    // requirement result. If both entries agree in their status, return any of them.
+    // Otherwise, favour the entry that is blocking submission.
+    ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> result =
+        ImmutableMap.builder();
+    for (Map.Entry<String, List<SubmitRequirementResult>> entry : srsByName.entrySet()) {
+      if (entry.getValue().size() == 1) {
+        SubmitRequirementResult srResult = entry.getValue().iterator().next();
+        result.put(srResult.submitRequirement(), srResult);
+        continue;
+      }
+      // If all submit requirements with the same name match in status, return the first one.
+      List<SubmitRequirementResult> resultsSameName = entry.getValue();
+      boolean allNonBlocking = resultsSameName.stream().allMatch(sr -> sr.fulfilled());
+      if (allNonBlocking) {
+        result.put(resultsSameName.get(0).submitRequirement(), resultsSameName.get(0));
+      } else {
+        // Otherwise, return the first submit requirement result that is blocking submission.
+        Optional<SubmitRequirementResult> nonFulfilled =
+            resultsSameName.stream().filter(sr -> !sr.fulfilled()).findFirst();
+        if (nonFulfilled.isPresent()) {
+          result.put(nonFulfilled.get().submitRequirement(), nonFulfilled.get());
+        }
+      }
+    }
+    return result.build();
   }
 
+  @VisibleForTesting
   static List<SubmitRequirementResult> createResult(
-      SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId) {
+      SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId, boolean isForced) {
     List<SubmitRequirementResult> results;
-    if (record.ruleName != null && record.ruleName.equals("gerrit~DefaultSubmitRule")) {
-      results = createFromDefaultSubmitRecord(record.labels, labelTypes, psCommitId);
+    if (record.ruleName != null && record.ruleName.equals(DefaultSubmitRule.RULE_NAME)) {
+      results = createFromDefaultSubmitRecord(record.labels, labelTypes, psCommitId, isForced);
     } else {
-      results = createFromCustomSubmitRecord(record, psCommitId);
+      results = createFromCustomSubmitRecord(record, psCommitId, isForced);
     }
     logger.atFine().log("Converted submit record %s to submit requirements %s", record, results);
     return results;
   }
 
   private static List<SubmitRequirementResult> createFromDefaultSubmitRecord(
-      List<Label> labels, List<LabelType> labelTypes, ObjectId psCommitId) {
+      @Nullable List<Label> labels,
+      List<LabelType> labelTypes,
+      ObjectId psCommitId,
+      boolean isForced) {
     ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
+    if (labels == null) {
+      return result.build();
+    }
     for (Label label : labels) {
-      LabelType labelType = getLabelType(labelTypes, label.label);
+      if (skipSubmitRequirementFor(label)) {
+        continue;
+      }
+      Optional<LabelType> maybeLabelType = getLabelType(labelTypes, label.label);
+      if (!maybeLabelType.isPresent()) {
+        // Label type might have been removed from the project config. We don't have information
+        // if it was blocking or not, hence we skip the label.
+        continue;
+      }
+      LabelType labelType = maybeLabelType.get();
       if (!isBlocking(labelType)) {
         continue;
       }
@@ -94,13 +153,14 @@
               .submittabilityExpressionResult(
                   createExpressionResult(toExpression(atoms), mapStatus(label), atoms))
               .patchSetCommitId(psCommitId)
+              .forced(Optional.of(isForced))
               .build());
     }
     return result.build();
   }
 
   private static List<SubmitRequirementResult> createFromCustomSubmitRecord(
-      SubmitRecord record, ObjectId psCommitId) {
+      SubmitRecord record, ObjectId psCommitId, boolean isForced) {
     String ruleName = record.ruleName != null ? record.ruleName : "Custom-Rule";
     if (record.labels == null || record.labels.isEmpty()) {
       SubmitRequirement sr =
@@ -116,12 +176,19 @@
               .submitRequirement(sr)
               .submittabilityExpressionResult(
                   createExpressionResult(
-                      sr.submittabilityExpression(), mapStatus(record), ImmutableList.of(ruleName)))
+                      sr.submittabilityExpression(),
+                      mapStatus(record),
+                      ImmutableList.of(ruleName),
+                      record.errorMessage))
               .patchSetCommitId(psCommitId)
+              .forced(Optional.of(isForced))
               .build());
     }
     ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
     for (Label label : record.labels) {
+      if (skipSubmitRequirementFor(label)) {
+        continue;
+      }
       String expressionString = String.format("label:%s=%s", label.label, ruleName);
       SubmitRequirement sr =
           SubmitRequirement.builder()
@@ -212,9 +279,40 @@
         status == Status.FAIL ? atoms : ImmutableList.of());
   }
 
-  private static LabelType getLabelType(List<LabelType> labelTypes, String labelName) {
-    return labelTypes.stream()
-        .filter(lt -> lt.getName().equals(labelName))
-        .collect(MoreCollectors.onlyElement());
+  private static SubmitRequirementExpressionResult createExpressionResult(
+      SubmitRequirementExpression expression,
+      Status status,
+      ImmutableList<String> atoms,
+      String errorMessage) {
+    return SubmitRequirementExpressionResult.create(
+        expression,
+        status,
+        status == Status.PASS ? atoms : ImmutableList.of(),
+        status == Status.FAIL ? atoms : ImmutableList.of(),
+        Optional.ofNullable(Strings.emptyToNull(errorMessage)));
+  }
+
+  private static Optional<LabelType> getLabelType(List<LabelType> labelTypes, String labelName) {
+    List<LabelType> label =
+        labelTypes.stream()
+            .filter(lt -> lt.getName().equals(labelName))
+            .collect(Collectors.toList());
+    if (label.isEmpty()) {
+      // Label might have been removed from the project.
+      logger.atFine().log("Label '%s' was not found for the project.", labelName);
+      return Optional.empty();
+    } else if (label.size() > 1) {
+      logger.atWarning().log("Found more than one label definition for label name '%s'", labelName);
+      return Optional.empty();
+    }
+    return Optional.of(label.get(0));
+  }
+
+  /**
+   * Returns true if we should skip creating a "submit requirement" result out of the "submit
+   * record" label.
+   */
+  private static boolean skipSubmitRequirementFor(SubmitRecord.Label label) {
+    return label.status == SubmitRecord.Label.Status.MAY;
   }
 }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
index 402bb51..b3278c9 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
-import java.util.Map;
 
 public interface SubmitRequirementsEvaluator {
   /**
@@ -28,11 +28,8 @@
    * from the project config of the project containing the change as well as parent projects.
    *
    * @param cd change data corresponding to a specific gerrit change
-   * @param includeLegacy if set to true, evaluate legacy {@link
-   *     com.google.gerrit.entities.SubmitRecord}s and convert them to submit requirements.
    */
-  Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
-      ChangeData cd, boolean includeLegacy);
+  ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(ChangeData cd);
 
   /** Evaluate a single {@link SubmitRequirement} using change data. */
   SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd);
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index cc2c805..f60e9e5 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
@@ -24,23 +26,29 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Scopes;
-import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Stream;
 
 /** Evaluates submit requirements for different change data. */
 public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvaluator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
   private final ProjectCache projectCache;
-  private final SubmitRuleEvaluator.Factory legacyEvaluator;
+  private final PluginSetContext<SubmitRequirement> globalSubmitRequirements;
+  private final OneOffRequestContext requestContext;
 
   public static Module module() {
     return new AbstractModule() {
@@ -57,10 +65,12 @@
   private SubmitRequirementsEvaluatorImpl(
       Provider<SubmitRequirementChangeQueryBuilder> queryBuilder,
       ProjectCache projectCache,
-      SubmitRuleEvaluator.Factory legacyEvaluator) {
+      PluginSetContext<SubmitRequirement> globalSubmitRequirements,
+      OneOffRequestContext requestContext) {
     this.queryBuilder = queryBuilder;
     this.projectCache = projectCache;
-    this.legacyEvaluator = legacyEvaluator;
+    this.globalSubmitRequirements = globalSubmitRequirements;
+    this.requestContext = requestContext;
   }
 
   @Override
@@ -70,43 +80,71 @@
   }
 
   @Override
-  public Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
-      ChangeData cd, boolean includeLegacy) {
-    Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements = getRequirements(cd);
-    Map<SubmitRequirement, SubmitRequirementResult> result = projectConfigRequirements;
-    if (includeLegacy) {
-      Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
-          SubmitRequirementsAdapter.getLegacyRequirements(legacyEvaluator, cd);
-      result =
-          SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
-              projectConfigRequirements, legacyReqs);
-    }
-    return ImmutableMap.copyOf(result);
+  public ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
+      ChangeData cd) {
+    return getRequirements(cd);
   }
 
   @Override
   public SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd) {
-    SubmitRequirementExpressionResult blockingResult =
-        evaluateExpression(sr.submittabilityExpression(), cd);
+    try (ManualRequestContext ignored = requestContext.open()) {
+      // Use a request context to execute predicates as an internal user with expanded visibility.
+      // This is so that the evaluation does not depend on who is running the current request (e.g.
+      // a "ownerin" predicate with group that is not visible to the person making this request).
 
-    Optional<SubmitRequirementExpressionResult> applicabilityResult =
-        sr.applicabilityExpression().isPresent()
-            ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
-            : Optional.empty();
+      Optional<SubmitRequirementExpressionResult> applicabilityResult =
+          sr.applicabilityExpression().isPresent()
+              ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
+              : Optional.empty();
+      Optional<SubmitRequirementExpressionResult> submittabilityResult =
+          Optional.of(
+              SubmitRequirementExpressionResult.notEvaluated(sr.submittabilityExpression()));
+      Optional<SubmitRequirementExpressionResult> overrideResult =
+          sr.overrideExpression().isPresent()
+              ? Optional.of(
+                  SubmitRequirementExpressionResult.notEvaluated(sr.overrideExpression().get()))
+              : Optional.empty();
+      if (!sr.applicabilityExpression().isPresent()
+          || SubmitRequirementResult.assertPass(applicabilityResult)) {
+        submittabilityResult = Optional.of(evaluateExpression(sr.submittabilityExpression(), cd));
+        overrideResult =
+            sr.overrideExpression().isPresent()
+                ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
+                : Optional.empty();
+      }
 
-    Optional<SubmitRequirementExpressionResult> overrideResult =
-        sr.overrideExpression().isPresent()
-            ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
-            : Optional.empty();
+      if (applicabilityResult.isPresent()) {
+        logger.atFine().log(
+            "Applicability expression result for SR name '%s':"
+                + " passing atoms: %s, failing atoms: %s",
+            sr.name(),
+            applicabilityResult.get().passingAtoms(),
+            applicabilityResult.get().failingAtoms());
+      }
+      if (submittabilityResult.isPresent()) {
+        logger.atFine().log(
+            "Submittability expression result for SR name '%s':"
+                + " passing atoms: %s, failing atoms: %s",
+            sr.name(),
+            submittabilityResult.get().passingAtoms(),
+            submittabilityResult.get().failingAtoms());
+      }
+      if (overrideResult.isPresent()) {
+        logger.atFine().log(
+            "Override expression result for SR name '%s':"
+                + " passing atoms: %s, failing atoms: %s",
+            sr.name(), overrideResult.get().passingAtoms(), overrideResult.get().failingAtoms());
+      }
 
-    return SubmitRequirementResult.builder()
-        .legacy(Optional.of(false))
-        .submitRequirement(sr)
-        .patchSetCommitId(cd.currentPatchSet().commitId())
-        .submittabilityExpressionResult(blockingResult)
-        .applicabilityExpressionResult(applicabilityResult)
-        .overrideExpressionResult(overrideResult)
-        .build();
+      return SubmitRequirementResult.builder()
+          .legacy(Optional.of(false))
+          .submitRequirement(sr)
+          .patchSetCommitId(cd.currentPatchSet().commitId())
+          .submittabilityExpressionResult(submittabilityResult)
+          .applicabilityExpressionResult(applicabilityResult)
+          .overrideExpressionResult(overrideResult)
+          .build();
+    }
   }
 
   @Override
@@ -116,20 +154,58 @@
       Predicate<ChangeData> predicate = queryBuilder.get().parse(expression.expressionString());
       PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
       return SubmitRequirementExpressionResult.create(expression, predicateResult);
-    } catch (QueryParseException e) {
+    } catch (QueryParseException | SubmitRequirementEvaluationException e) {
       return SubmitRequirementExpressionResult.error(expression, e.getMessage());
     }
   }
 
-  /** Evaluate and return submit requirements stored in this project's config and its parents. */
-  private Map<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) {
+  /**
+   * Evaluate and return all {@link SubmitRequirement}s.
+   *
+   * <p>This includes all globally bound {@link SubmitRequirement}s, as well as requirements stored
+   * in this project's config and its parents.
+   *
+   * <p>The behaviour in case of the name match is controlled by {@link
+   * SubmitRequirement#allowOverrideInChildProjects} of global {@link SubmitRequirement}.
+   */
+  private ImmutableMap<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) {
+    Map<String, SubmitRequirement> globalRequirements = getGlobalRequirements();
+
     ProjectState state = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
-    Map<String, SubmitRequirement> requirements = state.getSubmitRequirements();
-    Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
+    Map<String, SubmitRequirement> projectConfigRequirements = state.getSubmitRequirements();
+
+    ImmutableMap<String, SubmitRequirement> requirements =
+        Stream.concat(
+                globalRequirements.entrySet().stream(),
+                projectConfigRequirements.entrySet().stream())
+            .collect(
+                toImmutableMap(
+                    Map.Entry::getKey,
+                    Map.Entry::getValue,
+                    (globalSubmitRequirement, projectConfigRequirement) ->
+                        // Override with projectConfigRequirement if allowed by
+                        // globalSubmitRequirement configuration
+                        globalSubmitRequirement.allowOverrideInChildProjects()
+                            ? projectConfigRequirement
+                            : globalSubmitRequirement));
+    ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> results =
+        ImmutableMap.builder();
     for (SubmitRequirement requirement : requirements.values()) {
-      result.put(requirement, evaluateRequirement(requirement, cd));
+      results.put(requirement, evaluateRequirement(requirement, cd));
     }
-    return result;
+    return results.build();
+  }
+
+  /**
+   * Returns a map of all global {@link SubmitRequirement}s, keyed by their lower-case name.
+   *
+   * <p>The global {@link SubmitRequirement}s apply to all projects and can be bound by plugins.
+   */
+  private Map<String, SubmitRequirement> getGlobalRequirements() {
+    return globalSubmitRequirements.stream()
+        .collect(
+            toImmutableMap(
+                globalRequirement -> globalRequirement.name().toLowerCase(), Function.identity()));
   }
 
   /** Evaluate the predicate recursively using change data. */
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index 102d3f2..e54e5af 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -14,19 +14,104 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
+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.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 /**
  * A utility class for different operations related to {@link
  * com.google.gerrit.entities.SubmitRequirement}s.
  */
+@Singleton
 public class SubmitRequirementsUtil {
 
-  private SubmitRequirementsUtil() {}
+  /**
+   * Submit requirement name can only contain alphanumeric characters or hyphen. Name cannot start
+   * with a hyphen or number.
+   */
+  private static final Pattern SUBMIT_REQ_NAME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9\\-]*");
+
+  @Singleton
+  static class Metrics {
+    final Counter1<String> submitRequirementsMatchingWithLegacy;
+    final Counter1<String> submitRequirementsMismatchingWithLegacy;
+    final Counter1<String> legacyNotInSrs;
+    final Counter1<String> srsNotInLegacy;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      submitRequirementsMatchingWithLegacy =
+          metricMaker.newCounter(
+              "change/submit_requirements/matching_with_legacy",
+              new Description(
+                      "Total number of times there was a legacy and non-legacy "
+                          + "submit requirements with the same name for a change, "
+                          + "and the evaluation of both requirements had the same result "
+                          + "w.r.t. change submittability.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+      submitRequirementsMismatchingWithLegacy =
+          metricMaker.newCounter(
+              "change/submit_requirements/mismatching_with_legacy",
+              new Description(
+                      "Total number of times there was a legacy and non-legacy "
+                          + "submit requirements with the same name for a change, "
+                          + "and the evaluation of both requirements had a different result "
+                          + "w.r.t. change submittability.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+      legacyNotInSrs =
+          metricMaker.newCounter(
+              "change/submit_requirements/legacy_not_in_srs",
+              new Description(
+                      "Total number of times there was a legacy submit requirement result "
+                          + "but not a project config requirement with the same name for a change.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+      srsNotInLegacy =
+          metricMaker.newCounter(
+              "change/submit_requirements/srs_not_in_legacy",
+              new Description(
+                      "Total number of times there was a project config submit requirement "
+                          + "result but not a legacy requirement with the same name for a change.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("sr_name", Metadata.Builder::submitRequirementName)
+                  .description("Submit requirement name")
+                  .build());
+    }
+  }
+
+  private final Metrics metrics;
+
+  @Inject
+  public SubmitRequirementsUtil(Metrics metrics) {
+    this.metrics = metrics;
+  }
 
   /**
    * Merge legacy and non-legacy submit requirement results. If both input maps have submit
@@ -43,25 +128,83 @@
    * @return a map that is the result of merging both input maps, while eliminating requirements
    *     with the same name and status.
    */
-  public static Map<SubmitRequirement, SubmitRequirementResult> mergeLegacyAndNonLegacyRequirements(
-      Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements,
-      Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements) {
+  public ImmutableMap<SubmitRequirement, SubmitRequirementResult>
+      mergeLegacyAndNonLegacyRequirements(
+          Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements,
+          Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements,
+          ChangeData cd) {
+    // Cannot use ImmutableMap.Builder here since entries in the map may be overridden.
     Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
     result.putAll(projectConfigRequirements);
     Map<String, SubmitRequirementResult> requirementsByName =
         projectConfigRequirements.entrySet().stream()
+            // filter out legacy entries as a safety guard for duplicate entries
+            // (projectConfigRequirements should not contain legacy entries)
+            // TODO(ghareeb): remove the filter statement
+            .filter(entry -> !entry.getValue().isLegacy())
             .collect(Collectors.toMap(sr -> sr.getKey().name().toLowerCase(), sr -> sr.getValue()));
     for (Map.Entry<SubmitRequirement, SubmitRequirementResult> legacy :
         legacyRequirements.entrySet()) {
-      String name = legacy.getKey().name().toLowerCase();
-      SubmitRequirementResult projectConfigResult = requirementsByName.get(name);
+      String srName = legacy.getKey().name().toLowerCase();
+      SubmitRequirementResult projectConfigResult = requirementsByName.get(srName);
       SubmitRequirementResult legacyResult = legacy.getValue();
-      if (projectConfigResult != null && matchByStatus(projectConfigResult, legacyResult)) {
+      // If there's no project config requirement with the same name as the legacy requirement
+      // then add the legacy SR to the result. There is no mismatch in results in this case.
+      if (projectConfigResult == null) {
+        result.put(legacy.getKey(), legacy.getValue());
+        if (shouldReportMetric(cd)) {
+          metrics.legacyNotInSrs.increment(srName);
+        }
         continue;
       }
+      if (matchByStatus(projectConfigResult, legacyResult)) {
+        // There exists a project config SR with the same name as the legacy SR, and they are
+        // matching in result. No need to include the legacy SR in the output since the project
+        // config SR is already there.
+        if (shouldReportMetric(cd)) {
+          metrics.submitRequirementsMatchingWithLegacy.increment(srName);
+        }
+        continue;
+      }
+      // There exists a project config SR with the same name as the legacy SR but they are not
+      // matching in their result. Increment the mismatch count and add the legacy SR to the result.
+      if (shouldReportMetric(cd)) {
+        metrics.submitRequirementsMismatchingWithLegacy.increment(srName);
+      }
       result.put(legacy.getKey(), legacy.getValue());
     }
-    return result;
+    Set<String> legacyNames =
+        legacyRequirements.keySet().stream()
+            .map(SubmitRequirement::name)
+            .map(String::toLowerCase)
+            .collect(Collectors.toSet());
+    for (String projectConfigSrName : requirementsByName.keySet()) {
+      if (!legacyNames.contains(projectConfigSrName) && shouldReportMetric(cd)) {
+        metrics.srsNotInLegacy.increment(projectConfigSrName);
+      }
+    }
+
+    return ImmutableMap.copyOf(result);
+  }
+
+  /** Validates the name of submit requirements. */
+  public static void validateName(@Nullable String name) throws IllegalArgumentException {
+    if (name == null || name.isEmpty()) {
+      throw new IllegalArgumentException("Empty submit requirement name");
+    }
+    if (!SUBMIT_REQ_NAME_PATTERN.matcher(name).matches()) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Illegal submit requirement name \"%s\". Name can only consist of "
+                  + "alphanumeric characters and '-'. Name cannot start with '-' or number.",
+              name));
+    }
+  }
+
+  private static boolean shouldReportMetric(ChangeData cd) {
+    // We only care about recording differences in old and new requirements for open changes
+    // that did not have their data retrieved from the (potentially stale) change index.
+    return cd.change().isNew() && cd.getStorageConstraint() == StorageConstraint.NOTEDB_ONLY;
   }
 
   /** Returns true if both input results are equal in allowing/disallowing change submission. */
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 6c5559c..1d999dd 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -15,11 +15,11 @@
 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.Project;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
@@ -115,7 +115,12 @@
           throw new StorageException("Change not found");
         }
 
-        projectState = projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
+        Project.NameKey name = cd.project();
+        Optional<ProjectState> projectStateOptional = projectCache.get(name);
+        if (!projectStateOptional.isPresent()) {
+          throw new NoSuchProjectException(name);
+        }
+        projectState = projectStateOptional.get();
       } catch (NoSuchProjectException e) {
         throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
@@ -147,12 +152,14 @@
               c ->
                   c.call(
                       s -> {
-                        Optional<SubmitRecord> evaluate = s.evaluate(cd);
-                        if (evaluate.isPresent()) {
-                          evaluate.get().ruleName =
+                        Optional<SubmitRecord> record = s.evaluate(cd);
+                        if (record.isPresent() && record.get().ruleName == null) {
+                          // Only back-fill the ruleName if it was not populated by the "submit
+                          // rule".
+                          record.get().ruleName =
                               c.getPluginName() + "~" + s.getClass().getSimpleName();
                         }
-                        return evaluate;
+                        return record;
                       }))
           .filter(Optional::isPresent)
           .map(Optional::get)
@@ -168,7 +175,11 @@
   public SubmitTypeRecord getSubmitType(ChangeData cd) {
     try (Timer0.Context ignored = submitTypeEvaluationLatency.start()) {
       try {
-        projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
+        Project.NameKey name = cd.project();
+        Optional<ProjectState> project = projectCache.get(name);
+        if (!project.isPresent()) {
+          throw new NoSuchProjectException(name);
+        }
       } catch (NoSuchProjectException e) {
         throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
diff --git a/java/com/google/gerrit/server/project/TagResource.java b/java/com/google/gerrit/server/project/TagResource.java
index 08ef669..d46b9ab 100644
--- a/java/com/google/gerrit/server/project/TagResource.java
+++ b/java/com/google/gerrit/server/project/TagResource.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
+import java.util.Optional;
 
 public class TagResource extends RefResource {
-  public static final TypeLiteral<RestView<TagResource>> TAG_KIND =
-      new TypeLiteral<RestView<TagResource>>() {};
+  public static final TypeLiteral<RestView<TagResource>> TAG_KIND = new TypeLiteral<>() {};
 
   private final TagInfo tagInfo;
 
@@ -40,7 +40,7 @@
   }
 
   @Override
-  public String getRevision() {
-    return tagInfo.revision;
+  public Optional<String> getRevision() {
+    return Optional.ofNullable(tagInfo.revision);
   }
 }
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index 62f8560..32b87ec 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -19,21 +19,30 @@
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
 import java.util.Arrays;
+import java.util.Optional;
 
 public class TestLabels {
+  public static final String CODE_REVIEW_LABEL_DESCRIPTION = "Code review label description";
+  public static final String VERIFIED_LABEL_DESCRIPTION = "Verified label description";
+
   public static LabelType codeReview() {
     return label(
         LabelId.CODE_REVIEW,
+        CODE_REVIEW_LABEL_DESCRIPTION,
         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 this is not merged as is"),
-        value(-2, "This shall not be merged"));
+        value(-1, "I would prefer this is not submitted as is"),
+        value(-2, "This shall not be submitted"));
   }
 
   public static LabelType verified() {
     return label(
-        LabelId.VERIFIED, value(1, LabelId.VERIFIED), value(0, "No score"), value(-1, "Fails"));
+        LabelId.VERIFIED,
+        VERIFIED_LABEL_DESCRIPTION,
+        value(1, LabelId.VERIFIED),
+        value(0, "No score"),
+        value(-1, "Fails"));
   }
 
   public static LabelType patchSetLock() {
@@ -48,6 +57,10 @@
     return LabelValue.create((short) value, text);
   }
 
+  public static LabelType label(String name, String description, LabelValue... values) {
+    return labelBuilder(name, values).setDescription(Optional.of(description)).build();
+  }
+
   public static LabelType label(String name, LabelValue... values) {
     return labelBuilder(name, values).build();
   }
diff --git a/java/com/google/gerrit/server/query/FileEditsPredicate.java b/java/com/google/gerrit/server/query/FileEditsPredicate.java
new file mode 100644
index 0000000..7058765
--- /dev/null
+++ b/java/com/google/gerrit/server/query/FileEditsPredicate.java
@@ -0,0 +1,197 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.FilePathAdapter;
+import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * A submit-requirement predicate that can be used in submit requirements expressions. This
+ * predicate is fulfilled if the diff between the latest patchset of the change and the base commit
+ * includes a specific file path pattern with some specific content modification. The modification
+ * could be an added, deleted or replaced content.
+ */
+public class FileEditsPredicate extends SubmitRequirementPredicate {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private DiffOperations diffOperations;
+  private GitRepositoryManager repoManager;
+  private final FileEditsArgs fileEditsArgs;
+
+  public interface Factory {
+    FileEditsPredicate create(FileEditsArgs fileEditsArgs);
+  }
+
+  @AutoValue
+  public abstract static class FileEditsArgs {
+    abstract String filePattern();
+
+    abstract String editPattern();
+
+    public static FileEditsArgs create(String filePattern, String contentPattern) {
+      return new AutoValue_FileEditsPredicate_FileEditsArgs(filePattern, contentPattern);
+    }
+  }
+
+  @AssistedInject
+  public FileEditsPredicate(
+      DiffOperations diffOperations,
+      GitRepositoryManager repoManager,
+      @Assisted FileEditsPredicate.FileEditsArgs fileEditsArgs) {
+    super("fileEdits", fileEditsArgs.filePattern() + "," + fileEditsArgs.editPattern());
+    this.diffOperations = diffOperations;
+    this.repoManager = repoManager;
+    this.fileEditsArgs = fileEditsArgs;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    try {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFilesAgainstParent(
+              cd.project(),
+              cd.currentPatchSet().commitId(),
+              /* parentNum= */ 0,
+              DiffOptions.DEFAULTS);
+      FileDiffOutput firstDiff =
+          Iterables.getFirst(modifiedFiles.values(), /* defaultValue= */ null);
+      if (firstDiff == null) {
+        // No available diffs. We cannot identify old and new commit IDs.
+        // engine.fail();
+        return false;
+      }
+
+      Pattern filePattern = null;
+      Pattern editPattern = null;
+      if (fileEditsArgs.filePattern().startsWith("^")) {
+        // We validated the pattern before creating this predicate. No need to revalidate.
+        String pattern = fileEditsArgs.filePattern();
+        filePattern = Pattern.compile(pattern);
+      }
+      if (fileEditsArgs.editPattern().startsWith("^")) {
+        // We validated the pattern before creating this predicate. No need to revalidate.
+        String pattern = fileEditsArgs.editPattern();
+        editPattern = Pattern.compile(pattern);
+      }
+      try (Repository repo = repoManager.openRepository(cd.project());
+          ObjectReader reader = repo.newObjectReader();
+          RevWalk rw = new RevWalk(reader)) {
+        RevTree aTree =
+            firstDiff.oldCommitId().equals(ObjectId.zeroId())
+                ? null
+                : rw.parseTree(firstDiff.oldCommitId());
+        RevTree bTree = rw.parseCommit(firstDiff.newCommitId()).getTree();
+
+        for (FileDiffOutput entry : modifiedFiles.values()) {
+          String newName =
+              FilePathAdapter.getNewPath(entry.oldPath(), entry.newPath(), entry.changeType());
+          String oldName = FilePathAdapter.getOldPath(entry.oldPath(), entry.changeType());
+
+          if (Patch.isMagic(newName)) {
+            continue;
+          }
+
+          if (match(newName, fileEditsArgs.filePattern(), filePattern)
+              || (oldName != null && match(oldName, fileEditsArgs.filePattern(), filePattern))) {
+            List<Edit> edits =
+                entry.edits().stream().map(TaggedEdit::jgitEdit).collect(Collectors.toList());
+            if (edits.isEmpty()) {
+              continue;
+            }
+            Text tA;
+            if (oldName != null) {
+              tA = load(aTree, oldName, reader);
+            } else {
+              tA = load(aTree, newName, reader);
+            }
+            Text tB = load(bTree, newName, reader);
+            for (Edit edit : edits) {
+              if (tA != Text.EMPTY) {
+                String aDiff = tA.getString(edit.getBeginA(), edit.getEndA(), true);
+                if (match(aDiff, fileEditsArgs.editPattern(), editPattern)) {
+                  return true;
+                }
+              }
+              if (tB != Text.EMPTY) {
+                String bDiff = tB.getString(edit.getBeginB(), edit.getEndB(), true);
+                if (match(bDiff, fileEditsArgs.editPattern(), editPattern)) {
+                  return true;
+                }
+              }
+            }
+          }
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Error while evaluating commit edits.");
+        return false;
+      }
+    } catch (DiffNotAvailableException e) {
+      logger.atSevere().withCause(e).log("Diff error while evaluating commit edits.");
+      return false;
+    }
+    return false;
+  }
+
+  @Override
+  public int getCost() {
+    return 10;
+  }
+
+  private Text load(@Nullable ObjectId tree, String path, ObjectReader reader) throws IOException {
+    if (tree == null || path == null) {
+      return Text.EMPTY;
+    }
+    final TreeWalk tw = TreeWalk.forPath(reader, path, tree);
+    if (tw == null) {
+      return Text.EMPTY;
+    }
+    if (tw.getFileMode(0).getObjectType() != Constants.OBJ_BLOB) {
+      return Text.EMPTY;
+    }
+    return new Text(reader.open(tw.getObjectId(0), Constants.OBJ_BLOB));
+  }
+
+  private boolean match(String text, String search, @Nullable Pattern searchPattern) {
+    return searchPattern == null ? text.contains(search) : searchPattern.matcher(text).find();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 8f94089..fa75542 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.query.account;
 
+import static com.google.gerrit.server.index.account.AccountField.USERNAME_SPEC;
+
+import com.google.common.base.Ascii;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
@@ -31,7 +34,8 @@
 /** Utility class to create predicates for account index queries. */
 public class AccountPredicates {
   public static boolean hasActive(Predicate<AccountState> p) {
-    return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE.getName()) != null;
+    return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE_FIELD_SPEC.getName())
+        != null;
   }
 
   public static Predicate<AccountState> andActive(Predicate<AccountState> p) {
@@ -49,16 +53,18 @@
     if (canSeeSecondaryEmails) {
       preds.add(equalsNameIncludingSecondaryEmails(query));
     } else {
-      if (schema.hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL)) {
+      if (schema.hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC)) {
         preds.add(equalsName(query));
       } else {
         preds.add(AccountPredicates.fullName(query));
-        if (schema.hasField(AccountField.PREFERRED_EMAIL)) {
+        if (schema.hasField(AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC)) {
           preds.add(AccountPredicates.preferredEmail(query));
         }
       }
     }
-    preds.add(username(query));
+    if (schema.hasField(USERNAME_SPEC)) {
+      preds.add(username(query));
+    }
     // Adapt the capacity of the "predicates" list when adding more default
     // predicates.
     return Predicate.or(preds);
@@ -66,63 +72,67 @@
 
   public static Predicate<AccountState> id(Schema<AccountState> schema, Account.Id accountId) {
     return new AccountPredicate(
-        schema.useLegacyNumericFields() ? AccountField.ID : AccountField.ID_STR,
+        schema.hasField(AccountField.ID_FIELD_SPEC)
+            ? AccountField.ID_FIELD_SPEC
+            : AccountField.ID_STR_FIELD_SPEC,
         AccountQueryBuilder.FIELD_ACCOUNT,
         accountId.toString());
   }
 
   public static Predicate<AccountState> emailIncludingSecondaryEmails(String email) {
     return new AccountPredicate(
-        AccountField.EMAIL, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
+        AccountField.EMAIL_SPEC, AccountQueryBuilder.FIELD_EMAIL, Ascii.toLowerCase(email));
   }
 
   public static Predicate<AccountState> preferredEmail(String email) {
     return new AccountPredicate(
-        AccountField.PREFERRED_EMAIL,
+        AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC,
         AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
-        email.toLowerCase());
+        Ascii.toLowerCase(email));
   }
 
   public static Predicate<AccountState> preferredEmailExact(String email) {
     return new AccountPredicate(
-        AccountField.PREFERRED_EMAIL_EXACT, AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT, email);
+        AccountField.PREFERRED_EMAIL_EXACT_SPEC,
+        AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT,
+        email);
   }
 
   public static Predicate<AccountState> equalsNameIncludingSecondaryEmails(String name) {
     return new AccountPredicate(
-        AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
+        AccountField.NAME_PART_SPEC, AccountQueryBuilder.FIELD_NAME, Ascii.toLowerCase(name));
   }
 
   public static Predicate<AccountState> equalsName(String name) {
     return new AccountPredicate(
-        AccountField.NAME_PART_NO_SECONDARY_EMAIL,
+        AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC,
         AccountQueryBuilder.FIELD_NAME,
-        name.toLowerCase());
+        Ascii.toLowerCase(name));
   }
 
   public static Predicate<AccountState> externalIdIncludingSecondaryEmails(String externalId) {
-    return new AccountPredicate(AccountField.EXTERNAL_ID, externalId);
+    return new AccountPredicate(AccountField.EXTERNAL_ID_FIELD_SPEC, externalId);
   }
 
   public static Predicate<AccountState> fullName(String fullName) {
-    return new AccountPredicate(AccountField.FULL_NAME, fullName);
+    return new AccountPredicate(AccountField.FULL_NAME_SPEC, fullName);
   }
 
   public static Predicate<AccountState> isActive() {
-    return new AccountPredicate(AccountField.ACTIVE, "1");
+    return new AccountPredicate(AccountField.ACTIVE_FIELD_SPEC, "1");
   }
 
   public static Predicate<AccountState> isNotActive() {
-    return new AccountPredicate(AccountField.ACTIVE, "0");
+    return new AccountPredicate(AccountField.ACTIVE_FIELD_SPEC, "0");
   }
 
   public static Predicate<AccountState> username(String username) {
     return new AccountPredicate(
-        AccountField.USERNAME, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
+        USERNAME_SPEC, AccountQueryBuilder.FIELD_USERNAME, Ascii.toLowerCase(username));
   }
 
   public static Predicate<AccountState> watchedProject(Project.NameKey project) {
-    return new AccountPredicate(AccountField.WATCHED_PROJECT, project.get());
+    return new AccountPredicate(AccountField.WATCHED_PROJECT_SPEC, project.get());
   }
 
   public static Predicate<AccountState> cansee(
@@ -132,11 +142,11 @@
 
   /** Predicate that is mapped to a field in the account index. */
   static class AccountPredicate extends IndexPredicate<AccountState> {
-    AccountPredicate(FieldDef<AccountState, ?> def, String value) {
+    AccountPredicate(SchemaField<AccountState, ?> def, String value) {
       super(def, value);
     }
 
-    AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
+    AccountPredicate(SchemaField<AccountState, ?> def, String name, String value) {
       super(def, name, value);
     }
   }
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 4a95b2e..d5c4a97 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -14,13 +14,15 @@
 package com.google.gerrit.server.query.account;
 
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.LimitPredicate;
@@ -38,6 +40,8 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.account.AccountPredicates.AccountPredicate;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
@@ -62,6 +66,7 @@
 
   public static class Arguments {
     final ChangeFinder changeFinder;
+    final ChangeData.Factory changeDataFactory;
     final PermissionBackend permissionBackend;
 
     private final Provider<CurrentUser> self;
@@ -72,9 +77,11 @@
         Provider<CurrentUser> self,
         AccountIndexCollection indexes,
         ChangeFinder changeFinder,
+        ChangeData.Factory changeDataFactory,
         PermissionBackend permissionBackend) {
       this.self = self;
       this.indexes = indexes;
+      this.changeDataFactory = changeDataFactory;
       this.changeFinder = changeFinder;
       this.permissionBackend = permissionBackend;
     }
@@ -99,6 +106,7 @@
       }
     }
 
+    @Nullable
     Schema<AccountState> schema() {
       Index<?, AccountState> index = indexes != null ? indexes.getSearchIndex() : null;
       return index != null ? index.getSchema() : null;
@@ -120,18 +128,23 @@
     if (!changeNotes.isPresent()) {
       throw error(String.format("change %s not found", change));
     }
-
-    try {
-      args.permissionBackend
-          .user(args.getUser())
-          .change(changeNotes.get())
-          .check(ChangePermission.READ);
-    } catch (AuthException e) {
-      String msg = String.format("change %s not found", change);
-      logger.atSevere().withCause(e).log(msg);
-      throw error(msg);
+    if (changeNotes.get().getChange().isPrivate()) {
+      Account.Id caller = self();
+      ChangeData cd = args.changeDataFactory.create(changeNotes.get());
+      Account.Id owner = cd.change().getOwner();
+      ImmutableSet<Account.Id> reviewersAndCC = cd.reviewers().all();
+      if (!(caller.equals(owner) || reviewersAndCC.contains(caller))) {
+        throw error(String.format("change %s not found", change));
+      }
+      return orAccountPredicate(
+          ImmutableList.<Account.Id>builder().add(owner).addAll(reviewersAndCC).build());
     }
-
+    if (!args.permissionBackend
+        .user(args.getUser())
+        .change(changeNotes.get())
+        .test(ChangePermission.READ)) {
+      throw error(String.format("change %s not found", change));
+    }
     return AccountPredicates.cansee(args, changeNotes.get());
   }
 
@@ -142,11 +155,11 @@
       return AccountPredicates.emailIncludingSecondaryEmails(email);
     }
 
-    if (args.schema().hasField(AccountField.PREFERRED_EMAIL)) {
+    if (args.schema().hasField(AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC)) {
       return AccountPredicates.preferredEmail(email);
     }
 
-    throw new QueryParseException("'email' operator is not supported by account index version");
+    throw new QueryParseException("'email' operator is not supported on this gerrit host");
   }
 
   @Operator
@@ -176,7 +189,7 @@
       return AccountPredicates.equalsNameIncludingSecondaryEmails(name);
     }
 
-    if (args.schema().hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL)) {
+    if (args.schema().hasField(AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC)) {
       return AccountPredicates.equalsName(name);
     }
 
@@ -221,12 +234,7 @@
   }
 
   private boolean canSeeSecondaryEmails() throws PermissionBackendException, QueryParseException {
-    try {
-      args.permissionBackend.user(args.getUser()).check(GlobalPermission.MODIFY_ACCOUNT);
-      return true;
-    } catch (AuthException e) {
-      return false;
-    }
+    return args.permissionBackend.user(args.getUser()).test(GlobalPermission.MODIFY_ACCOUNT);
   }
 
   private boolean checkedCanSeeSecondaryEmails() {
@@ -240,4 +248,14 @@
       return false;
     }
   }
+
+  /** Creates an OR predicate of the account IDs of the {@code accounts} parameter. */
+  private Predicate<AccountState> orAccountPredicate(ImmutableList<Account.Id> accounts) {
+    Predicate<AccountState> result =
+        AccountPredicate.or(AccountPredicates.id(args.schema(), accounts.get(0)));
+    for (int i = 1; i < accounts.size(); i += 1) {
+      result = AccountPredicate.or(result, AccountPredicates.id(args.schema(), accounts.get(i)));
+    }
+    return result;
+  }
 }
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index 2e29bbd..9893d1a 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexRewriter;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -41,6 +42,8 @@
  */
 public class AccountQueryProcessor extends QueryProcessor<AccountState> {
   private final AccountControl.Factory accountControlFactory;
+  private final Sequences sequences;
+  private final IndexConfig indexConfig;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -57,7 +60,8 @@
       IndexConfig indexConfig,
       AccountIndexCollection indexes,
       AccountIndexRewriter rewriter,
-      AccountControl.Factory accountControlFactory) {
+      AccountControl.Factory accountControlFactory,
+      Sequences sequences) {
     super(
         metricMaker,
         AccountSchemaDefinitions.INSTANCE,
@@ -67,16 +71,28 @@
         FIELD_LIMIT,
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.accountControlFactory = accountControlFactory;
+    this.sequences = sequences;
+    this.indexConfig = indexConfig;
   }
 
   @Override
   protected Predicate<AccountState> enforceVisibility(Predicate<AccountState> pred) {
     return new AndSource<>(
-        pred, new AccountIsVisibleToPredicate(accountControlFactory.get()), start);
+        pred, new AccountIsVisibleToPredicate(accountControlFactory.get()), start, indexConfig);
   }
 
   @Override
   protected String formatForLogging(AccountState accountState) {
     return accountState.account().id().toString();
   }
+
+  @Override
+  protected int getIndexSize() {
+    return sequences.lastAccountId();
+  }
+
+  @Override
+  protected int getBatchSize() {
+    return sequences.accountBatchSize();
+  }
 }
diff --git a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
index 0252a06..e293285 100644
--- a/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.account;
 
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -36,15 +35,12 @@
   @Override
   public boolean match(AccountState accountState) {
     try {
-      permissionBackend
+      return permissionBackend
           .absentUser(accountState.account().id())
           .change(changeNotes)
-          .check(ChangePermission.READ);
-      return true;
+          .test(ChangePermission.READ);
     } catch (PermissionBackendException e) {
       throw new StorageException("Failed to check if account can see change", e);
-    } catch (AuthException e) {
-      return false;
     }
   }
 
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index 1d67009..fa1758a 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -23,11 +23,12 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -71,6 +72,7 @@
     return query(AccountPredicates.externalIdIncludingSecondaryEmails(externalId.toString()));
   }
 
+  @Nullable
   @UsedAt(UsedAt.Project.COLLABNET)
   public AccountState oneByExternalId(ExternalId.Key externalId) {
     List<AccountState> accountStates = byExternalId(externalId);
@@ -82,7 +84,7 @@
       Joiner.on(", ")
           .appendTo(
               msg, accountStates.stream().map(a -> a.account().id().toString()).collect(toList()));
-      logger.atWarning().log(msg.toString());
+      logger.atWarning().log("%s", msg);
     }
     return null;
   }
@@ -151,16 +153,16 @@
     return query(AccountPredicates.watchedProject(project));
   }
 
-  private boolean hasField(FieldDef<AccountState, ?> field) {
+  private boolean hasField(SchemaField<AccountState, ?> field) {
     Schema<AccountState> s = schema();
     return (s != null && s.hasField(field));
   }
 
   private boolean hasPreferredEmail() {
-    return hasField(AccountField.PREFERRED_EMAIL);
+    return hasField(AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC);
   }
 
   private boolean hasPreferredEmailExact() {
-    return hasField(AccountField.PREFERRED_EMAIL_EXACT);
+    return hasField(AccountField.PREFERRED_EMAIL_EXACT_SPEC);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
index 4dedbb5..901c51f 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalContext.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -17,23 +17,32 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Entity representing all required information to match predicates for copying approvals. */
 @AutoValue
 public abstract class ApprovalContext {
-  /** Approval on the source patch set to be copied. */
-  public abstract PatchSetApproval patchSetApproval();
+  public abstract PatchSet.Id sourcePatchSetId();
+
+  public abstract Account.Id approverId();
+
+  public abstract LabelType labelType();
+
+  /** Value of the approval on the source patch set to be copied. */
+  public abstract short approvalValue();
 
   /**
    * Target change and patch set for the approval. This must be used instead of getting the PatchSet
    * from {@link #changeNotes()} because it is possible we are now creating the patch-set, so it
    * doesn't exist in changeNotes yet.
    */
-  public abstract PatchSet target();
+  public abstract PatchSet targetPatchSet();
 
   /** {@link ChangeNotes} of the change in question. */
   public abstract ChangeNotes changeNotes();
@@ -41,19 +50,47 @@
   /** {@link ChangeKind} of the delta between the origin and target patch set. */
   public abstract ChangeKind changeKind();
 
+  /** Whether the new patch set is a merge commit. */
+  public abstract boolean isMerge();
+
+  /** {@link RevWalk} of the repository for the current commit. */
+  public abstract RevWalk revWalk();
+
+  /** {@link RevWalk} of the repository for the current commit. */
+  public abstract Config repoConfig();
+
   public static ApprovalContext create(
-      ChangeNotes changeNotes, PatchSetApproval psa, PatchSet patchSet, ChangeKind changeKind) {
+      ChangeNotes changeNotes,
+      PatchSet.Id sourcePatchSetId,
+      Account.Id approverId,
+      LabelType labelType,
+      short approvalValue,
+      PatchSet targetPatchSet,
+      ChangeKind changeKind,
+      boolean isMerge,
+      RevWalk revWalk,
+      Config repoConfig) {
     checkState(
-        psa.patchSetId().changeId().equals(patchSet.id().changeId()),
+        sourcePatchSetId.changeId().equals(targetPatchSet.id().changeId()),
         "approval and target must be the same change. got: %s, %s",
-        psa.patchSetId(),
-        patchSet.id());
+        sourcePatchSetId,
+        targetPatchSet.id());
     // TODO(ekempin): Use checkState to verify that psa.patchSetId().get() + 1 == id.get() so that
     // it's ensured that approvals are only copied to the next consecutive patch set. To add back
     // this verification https://gerrit-review.googlesource.com/c/gerrit/+/312633 can be reverted.
     // As explained in the commit message of this change doing this check is only possible if there
     // are no changes with gaps in patch set numbers. Since it's planned to fix-up old changes with
     // gaps in patch set numbers, this todo is a reminder to add back the check once this is done.
-    return new AutoValue_ApprovalContext(psa, patchSet, changeNotes, changeKind);
+    return new AutoValue_ApprovalContext(
+        sourcePatchSetId,
+        approverId,
+        labelType,
+        approvalValue,
+        targetPatchSet,
+        changeNotes,
+        changeKind,
+        isMerge,
+        revWalk,
+        repoConfig);
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
index 819f319..11749cc 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.server.query.approval;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.base.Enums;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.extensions.client.ChangeKind;
@@ -24,6 +28,7 @@
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.inject.Inject;
 import java.util.Arrays;
+import java.util.Optional;
 
 public class ApprovalQueryBuilder extends QueryBuilder<ApprovalContext, ApprovalQueryBuilder> {
   private static final QueryBuilder.Definition<ApprovalContext, ApprovalQueryBuilder> mydef =
@@ -51,13 +56,37 @@
   }
 
   @Operator
-  public Predicate<ApprovalContext> changekind(String term) throws QueryParseException {
-    return new ChangeKindPredicate(toEnumValue(ChangeKind.class, term));
+  public Predicate<ApprovalContext> changekind(String value) throws QueryParseException {
+    return parseEnumValue(ChangeKind.class, value)
+        .map(ChangeKindPredicate::new)
+        .orElseThrow(
+            () ->
+                new QueryParseException(
+                    String.format(
+                        "%s is not a valid value for operator 'changekind'. Valid values: %s",
+                        value, formatEnumValues(ChangeKind.class))));
   }
 
   @Operator
-  public Predicate<ApprovalContext> is(String term) throws QueryParseException {
-    return magicValuePredicate.create(toEnumValue(MagicValuePredicate.MagicValue.class, term));
+  public Predicate<ApprovalContext> is(String value) throws QueryParseException {
+    // try to parse exact value
+    Optional<Integer> exactValue = Optional.ofNullable(Ints.tryParse(value));
+    if (exactValue.isPresent()) {
+      return new ExactValuePredicate(exactValue.get().shortValue());
+    }
+
+    // try to parse magic value
+    Optional<MagicValuePredicate.MagicValue> magicValue =
+        parseEnumValue(MagicValuePredicate.MagicValue.class, value);
+    if (magicValue.isPresent()) {
+      return magicValuePredicate.create(magicValue.get());
+    }
+
+    // it's neither an exact value nor a magic value
+    throw new QueryParseException(
+        String.format(
+            "%s is not a valid value for operator 'is'. Valid values: %s or integer",
+            value, formatEnumValues(MagicValuePredicate.MagicValue.class)));
   }
 
   @Operator
@@ -77,21 +106,21 @@
     }
     throw error(
         String.format(
-            "'%s' is not a supported argument for has. only 'unchanged-files' is supported",
+            "'%s' is not a valid value for operator 'has'."
+                + " The only valid value is 'unchanged-files'.",
             value));
   }
 
-  private static <T extends Enum<T>> T toEnumValue(Class<T> clazz, String term)
-      throws QueryParseException {
-    try {
-      return Enum.valueOf(clazz, term.toUpperCase().replace('-', '_'));
-    } catch (IllegalArgumentException e) {
-      throw new QueryParseException(
-          String.format(
-              "%s is not a valid term. valid options: %s",
-              term, Arrays.asList(clazz.getEnumConstants())),
-          e);
-    }
+  private static <T extends Enum<T>> Optional<T> parseEnumValue(Class<T> clazz, String value) {
+    return Optional.ofNullable(
+        Enums.getIfPresent(clazz, value.toUpperCase().replace('-', '_')).orNull());
+  }
+
+  private <T extends Enum<T>> String formatEnumValues(Class<T> clazz) {
+    return Arrays.stream(clazz.getEnumConstants())
+        .map(Object::toString)
+        .sorted()
+        .collect(joining(", "));
   }
 
   private AccountGroup.UUID parseGroupOrThrow(String maybeUUID) throws QueryParseException {
diff --git a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
index 78711fd..daf437b 100644
--- a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
@@ -32,7 +32,7 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    return ctx.changeKind().equals(changeKind);
+    return ctx.changeKind().matches(changeKind, ctx.isMerge());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java b/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
new file mode 100644
index 0000000..3021534
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ExactValuePredicate.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.index.query.Predicate;
+import java.util.Collection;
+
+/** Predicate that matches patch set approvals that have a given voting value. */
+public class ExactValuePredicate extends ApprovalPredicate {
+  private final short votingValue;
+
+  public ExactValuePredicate(short votingValue) {
+    this.votingValue = votingValue;
+  }
+
+  @Override
+  public boolean match(ApprovalContext approvalContext) {
+    return votingValue == approvalContext.approvalValue();
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new ExactValuePredicate(votingValue);
+  }
+
+  @Override
+  public int hashCode() {
+    return Short.valueOf(votingValue).hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    return (other instanceof ExactValuePredicate)
+        && votingValue == ((ExactValuePredicate) other).votingValue;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
index de7dd0a..958011c 100644
--- a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -23,7 +23,8 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
-import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,30 +52,41 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    PatchSet targetPatchSet = ctx.target();
-    PatchSet sourcePatchSet =
-        ctx.changeNotes().getPatchSets().get(ctx.patchSetApproval().patchSetId());
+    PatchSet targetPatchSet = ctx.targetPatchSet();
+    PatchSet sourcePatchSet = ctx.changeNotes().getPatchSets().get(ctx.sourcePatchSetId());
 
     Integer parentNum =
         isInitialCommit(ctx.changeNotes().getProjectName(), targetPatchSet.commitId()) ? 0 : 1;
     try {
-      Map<String, FileDiffOutput> baseVsCurrent =
-          diffOperations.listModifiedFilesAgainstParent(
-              ctx.changeNotes().getProjectName(), targetPatchSet.commitId(), parentNum);
-      Map<String, FileDiffOutput> baseVsPrior =
-          diffOperations.listModifiedFilesAgainstParent(
-              ctx.changeNotes().getProjectName(), sourcePatchSet.commitId(), parentNum);
-      Map<String, FileDiffOutput> priorVsCurrent =
-          diffOperations.listModifiedFiles(
+      Map<String, ModifiedFile> baseVsCurrent =
+          diffOperations.loadModifiedFilesAgainstParent(
+              ctx.changeNotes().getProjectName(),
+              targetPatchSet.commitId(),
+              parentNum,
+              DiffOptions.DEFAULTS,
+              ctx.revWalk(),
+              ctx.repoConfig());
+      Map<String, ModifiedFile> baseVsPrior =
+          diffOperations.loadModifiedFilesAgainstParent(
               ctx.changeNotes().getProjectName(),
               sourcePatchSet.commitId(),
-              targetPatchSet.commitId());
+              parentNum,
+              DiffOptions.DEFAULTS,
+              ctx.revWalk(),
+              ctx.repoConfig());
+      Map<String, ModifiedFile> priorVsCurrent =
+          diffOperations.loadModifiedFiles(
+              ctx.changeNotes().getProjectName(),
+              sourcePatchSet.commitId(),
+              targetPatchSet.commitId(),
+              DiffOptions.DEFAULTS,
+              ctx.revWalk(),
+              ctx.repoConfig());
       return match(baseVsCurrent, baseVsPrior, priorVsCurrent);
     } catch (DiffNotAvailableException ex) {
       throw new StorageException(
           "failed to compute difference in files, so won't copy"
-              + " votes on labels even if list of files is the same and "
-              + "copyAllIfListOfFilesDidNotChange",
+              + " votes on labels even if list of files is the same",
           ex);
     }
   }
@@ -84,9 +96,9 @@
    * {@link ChangeType} matches for each modified file.
    */
   public boolean match(
-      Map<String, FileDiffOutput> baseVsCurrent,
-      Map<String, FileDiffOutput> baseVsPrior,
-      Map<String, FileDiffOutput> priorVsCurrent) {
+      Map<String, ModifiedFile> baseVsCurrent,
+      Map<String, ModifiedFile> baseVsPrior,
+      Map<String, ModifiedFile> priorVsCurrent) {
     Set<String> allFiles = new HashSet<>();
     allFiles.addAll(baseVsCurrent.keySet());
     allFiles.addAll(baseVsPrior.keySet());
@@ -94,17 +106,17 @@
       if (Patch.isMagic(file)) {
         continue;
       }
-      FileDiffOutput fileDiffOutput1 = baseVsCurrent.get(file);
-      FileDiffOutput fileDiffOutput2 = baseVsPrior.get(file);
+      ModifiedFile modifiedFile1 = baseVsCurrent.get(file);
+      ModifiedFile modifiedFile2 = baseVsPrior.get(file);
       if (!priorVsCurrent.containsKey(file)) {
         // If the file is not modified between prior and current patchsets, then scan safely skip
-        // it. The file might has been modified due to rebase.
+        // it. The file might have been modified due to rebase.
         continue;
       }
-      if (fileDiffOutput1 == null || fileDiffOutput2 == null) {
+      if (modifiedFile1 == null || modifiedFile2 == null) {
         return false;
       }
-      if (!fileDiffOutput2.changeType().equals(fileDiffOutput1.changeType())) {
+      if (!modifiedFile2.changeType().equals(modifiedFile1.changeType())) {
         return false;
       }
     }
diff --git a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
index 326620d..98471da 100644
--- a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
+++ b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
@@ -14,16 +14,12 @@
 
 package com.google.gerrit.server.query.approval;
 
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collection;
 import java.util.Objects;
-import java.util.Optional;
 
 /** Predicate that matches patch set approvals we want to copy based on the value. */
 public class MagicValuePredicate extends ApprovalPredicate {
@@ -48,36 +44,20 @@
 
   @Override
   public boolean match(ApprovalContext ctx) {
-    Optional<LabelType> lt =
-        getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId());
     short pValue;
     switch (value) {
       case ANY:
         return true;
       case MIN:
-        if (!lt.isPresent()) {
-          return false;
-        }
-        pValue = lt.get().getMaxNegative();
+        pValue = ctx.labelType().getMaxNegative();
         break;
       case MAX:
-        if (!lt.isPresent()) {
-          return false;
-        }
-        pValue = lt.get().getMaxPositive();
+        pValue = ctx.labelType().getMaxPositive();
         break;
       default:
         throw new IllegalArgumentException("unrecognized label value: " + value);
     }
-    return pValue == ctx.patchSetApproval().value();
-  }
-
-  private Optional<LabelType> getLabelType(Project.NameKey project, LabelId labelId) {
-    return projectCache
-        .get(project)
-        .orElseThrow(() -> new IllegalStateException(project + " absent"))
-        .getLabelTypes()
-        .byLabel(labelId);
+    return pValue == ctx.approvalValue();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/approval/UserInPredicate.java b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
index ac6720d..fda2014 100644
--- a/java/com/google/gerrit/server/query/approval/UserInPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package com.google.gerrit.server.query.approval;
 
 import com.google.gerrit.entities.Account;
@@ -39,10 +53,10 @@
   public boolean match(ApprovalContext ctx) {
     Account.Id accountId;
     if (field == Field.UPLOADER) {
-      PatchSet patchSet = ctx.target();
+      PatchSet patchSet = ctx.targetPatchSet();
       accountId = patchSet.uploader();
     } else if (field == Field.APPROVER) {
-      accountId = ctx.patchSetApproval().accountId();
+      accountId = ctx.approverId();
     } else {
       throw new IllegalStateException("unknown field in group membership check: " + field);
     }
diff --git a/java/com/google/gerrit/server/query/change/AddedPredicate.java b/java/com/google/gerrit/server/query/change/AddedPredicate.java
index 1f526c5..698884c 100644
--- a/java/com/google/gerrit/server/query/change/AddedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AddedPredicate.java
@@ -19,11 +19,11 @@
 
 public class AddedPredicate extends IntegerRangeChangePredicate {
   public AddedPredicate(String value) throws QueryParseException {
-    super(ChangeField.ADDED, value);
+    super(ChangeField.ADDED_LINES_SPEC, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) {
-    return ChangeField.ADDED.get(changeData);
+    return ChangeField.ADDED_LINES_SPEC.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 8f92d9a..d3e3477 100644
--- a/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -14,32 +14,32 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.QueryParseException;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 
 /**
  * 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;
+  protected final Instant cut;
 
-  public AfterPredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+  public AfterPredicate(SchemaField<ChangeData, Timestamp> def, String name, String value)
       throws QueryParseException {
     super(def, name, value);
     cut = parse(value);
   }
 
   @Override
-  public Date getMinTimestamp() {
+  public Instant getMinTimestamp() {
     return cut;
   }
 
   @Override
-  public Date getMaxTimestamp() {
-    return new Date(Long.MAX_VALUE);
+  public Instant getMaxTimestamp() {
+    return Instant.ofEpochMilli(Long.MAX_VALUE);
   }
 
   @Override
@@ -48,6 +48,6 @@
     if (valueTimestamp == null) {
       return false;
     }
-    return valueTimestamp.getTime() >= cut.getTime();
+    return valueTimestamp.getTime() >= cut.toEpochMilli();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
index d38789f..8a9ba2e 100644
--- a/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -21,26 +21,27 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.time.TimeUtil;
 import java.sql.Timestamp;
+import java.time.Instant;
 
 public class AgePredicate extends TimestampRangeChangePredicate {
-  protected final long cut;
+  protected final Instant cut;
 
   public AgePredicate(String value) {
-    super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_AGE, value);
+    super(ChangeField.UPDATED_SPEC, ChangeQueryBuilder.FIELD_AGE, value);
 
     long s = ConfigUtil.getTimeUnit(getValue(), 0, SECONDS);
     long ms = MILLISECONDS.convert(s, SECONDS);
-    this.cut = TimeUtil.nowMs() - ms;
+    this.cut = Instant.ofEpochMilli(TimeUtil.nowMs() - ms);
   }
 
   @Override
-  public Timestamp getMinTimestamp() {
-    return new Timestamp(0);
+  public Instant getMinTimestamp() {
+    return Instant.EPOCH;
   }
 
   @Override
-  public Timestamp getMaxTimestamp() {
-    return new Timestamp(cut);
+  public Instant getMaxTimestamp() {
+    return cut;
   }
 
   @Override
@@ -49,6 +50,6 @@
     if (valueTimestamp == null) {
       return false;
     }
-    return valueTimestamp.getTime() <= cut;
+    return valueTimestamp.getTime() <= cut.toEpochMilli();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/AndChangeSource.java b/java/com/google/gerrit/server/query/change/AndChangeSource.java
index 749204f..98cada3 100644
--- a/java/com/google/gerrit/server/query/change/AndChangeSource.java
+++ b/java/com/google/gerrit/server/query/change/AndChangeSource.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.AndSource;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.index.query.Predicate;
@@ -22,15 +23,16 @@
 
 public class AndChangeSource extends AndSource<ChangeData> implements ChangeDataSource {
 
-  public AndChangeSource(Collection<Predicate<ChangeData>> that) {
-    super(that);
+  public AndChangeSource(Collection<Predicate<ChangeData>> that, IndexConfig indexConfig) {
+    super(that, indexConfig);
   }
 
   public AndChangeSource(
       Predicate<ChangeData> that,
       IsVisibleToPredicate<ChangeData> isVisibleToPredicate,
-      int start) {
-    super(that, isVisibleToPredicate, start);
+      int start,
+      IndexConfig indexConfig) {
+    super(that, isVisibleToPredicate, start, indexConfig);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 6e28ce6..e9ddbff 100644
--- a/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -14,31 +14,31 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.QueryParseException;
 import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 
 /**
  * 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;
+  protected final Instant cut;
 
-  public BeforePredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+  public BeforePredicate(SchemaField<ChangeData, Timestamp> def, String name, String value)
       throws QueryParseException {
     super(def, name, value);
     cut = parse(value);
   }
 
   @Override
-  public Date getMinTimestamp() {
-    return new Date(0);
+  public Instant getMinTimestamp() {
+    return Instant.EPOCH;
   }
 
   @Override
-  public Date getMaxTimestamp() {
+  public Instant getMaxTimestamp() {
     return cut;
   }
 
@@ -48,6 +48,6 @@
     if (valueTimestamp == null) {
       return false;
     }
-    return valueTimestamp.getTime() <= cut.getTime();
+    return valueTimestamp.getTime() <= cut.toEpochMilli();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/BooleanPredicate.java b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
index 6ca3acc..d6df7e0 100644
--- a/java/com/google/gerrit/server/query/change/BooleanPredicate.java
+++ b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 
 public class BooleanPredicate extends ChangeIndexPredicate {
-  public BooleanPredicate(FieldDef<ChangeData, String> field) {
+  public BooleanPredicate(SchemaField<ChangeData, String> field) {
     super(field, "1");
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 9961519..fc4df49 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -75,10 +75,8 @@
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.RobotCommentNotes;
 import com.google.gerrit.server.patch.DiffSummary;
@@ -98,7 +96,7 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -278,7 +276,7 @@
             .id(PatchSet.id(id, currentPatchSetId))
             .commitId(commitId)
             .uploader(Account.id(1000))
-            .createdOn(TimeUtil.nowTs())
+            .createdOn(TimeUtil.now())
             .build();
     return cd;
   }
@@ -290,9 +288,8 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
-  private final ExperimentFeatures experimentFeatures;
   private final GitRepositoryManager repoManager;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
   private final PatchListCache patchListCache;
   private final PatchSetUtil psUtil;
@@ -300,6 +297,7 @@
   private final TrackingFooters trackingFooters;
   private final PureRevert pureRevert;
   private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
+  private final SubmitRequirementsUtil submitRequirementsUtil;
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   // Required assisted injected fields.
@@ -336,7 +334,7 @@
    * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
    * change and a given user.
    */
-  private Table<Account.Id, PatchSet.Id, ObjectId> editsByUser;
+  private Table<Account.Id, PatchSet.Id, Ref> editRefsByUser;
 
   private Set<Account.Id> reviewedBy;
   /**
@@ -360,7 +358,7 @@
   private Integer unresolvedCommentCount;
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
-  private Optional<Timestamp> mergedOn;
+  private Optional<Instant> mergedOn;
   private ImmutableSetMultimap<NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
 
@@ -372,9 +370,8 @@
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
-      ExperimentFeatures experimentFeatures,
       GitRepositoryManager repoManager,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
       PatchListCache patchListCache,
       PatchSetUtil psUtil,
@@ -382,6 +379,7 @@
       TrackingFooters trackingFooters,
       PureRevert pureRevert,
       SubmitRequirementsEvaluator submitRequirementsEvaluator,
+      SubmitRequirementsUtil submitRequirementsUtil,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
@@ -392,7 +390,6 @@
     this.cmUtil = cmUtil;
     this.notesFactory = notesFactory;
     this.commentsUtil = commentsUtil;
-    this.experimentFeatures = experimentFeatures;
     this.repoManager = repoManager;
     this.mergeUtilFactory = mergeUtilFactory;
     this.mergeabilityCache = mergeabilityCache;
@@ -403,6 +400,7 @@
     this.trackingFooters = trackingFooters;
     this.pureRevert = pureRevert;
     this.submitRequirementsEvaluator = submitRequirementsEvaluator;
+    this.submitRequirementsUtil = submitRequirementsUtil;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
 
     this.project = project;
@@ -424,6 +422,10 @@
     return this;
   }
 
+  public StorageConstraint getStorageConstraint() {
+    return storageConstraint;
+  }
+
   /** Returns {@code true} if we allow reading data from NoteDb. */
   public boolean lazyload() {
     return storageConstraint.ordinal()
@@ -578,6 +580,7 @@
     return notes;
   }
 
+  @Nullable
   public PatchSet currentPatchSet() {
     if (currentPatchSet == null) {
       Change c = change();
@@ -622,6 +625,7 @@
     currentApprovals = approvals;
   }
 
+  @Nullable
   public String commitMessage() {
     if (commitMessage == null) {
       if (!loadCommitData()) {
@@ -645,6 +649,7 @@
     return trackingFooters.extract(commitFooters());
   }
 
+  @Nullable
   public PersonIdent getAuthor() {
     if (author == null) {
       if (!loadCommitData()) {
@@ -654,6 +659,7 @@
     return author;
   }
 
+  @Nullable
   public PersonIdent getCommitter() {
     if (committer == null) {
       if (!loadCommitData()) {
@@ -707,7 +713,7 @@
    * @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 {
+  public Optional<Instant> getMergedOn() throws StorageException {
     if (mergedOn == null) {
       // The value was not loaded yet, try to get from the database.
       mergedOn = notes().getMergedOn();
@@ -716,7 +722,7 @@
   }
 
   /** Sets the value e.g. when loading from index. */
-  public void setMergedOn(@Nullable Timestamp mergedOn) {
+  public void setMergedOn(@Nullable Instant mergedOn) {
     this.mergedOn = Optional.ofNullable(mergedOn);
   }
 
@@ -749,6 +755,7 @@
   }
 
   /** Returns patch with the given ID, or null if it does not exist. */
+  @Nullable
   public PatchSet patchSet(PatchSet.Id psId) {
     if (currentPatchSet != null && currentPatchSet.id().equals(psId)) {
       return currentPatchSet;
@@ -770,7 +777,7 @@
       if (!lazyload()) {
         return ImmutableListMultimap.of();
       }
-      allApprovals = approvalsUtil.byChange(notes());
+      allApprovals = approvalsUtil.byChangeExcludingCopiedApprovals(notes());
     }
     return allApprovals;
   }
@@ -890,6 +897,7 @@
     return robotComments;
   }
 
+  @Nullable
   public Integer unresolvedCommentCount() {
     if (unresolvedCommentCount == null) {
       if (!lazyload()) {
@@ -912,6 +920,7 @@
     this.unresolvedCommentCount = count;
   }
 
+  @Nullable
   public Integer totalCommentCount() {
     if (totalCommentCount == null) {
       if (!lazyload()) {
@@ -940,18 +949,26 @@
   }
 
   /**
+   * Similar to {@link #submitRequirements()}, except that it also converts submit records resulting
+   * from the evaluation of legacy submit rules to submit requirements.
+   */
+  public Map<SubmitRequirement, SubmitRequirementResult> submitRequirementsIncludingLegacy() {
+    Map<SubmitRequirement, SubmitRequirementResult> projectConfigReqs = submitRequirements();
+    Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
+        SubmitRequirementsAdapter.getLegacyRequirements(this);
+    return submitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
+        projectConfigReqs, legacyReqs, this);
+  }
+
+  /**
    * Get all evaluated submit requirements for this change, including those from parent projects.
    * For closed changes, submit requirements are read from the change notes. For active changes,
    * submit requirements are evaluated online.
    *
    * <p>For changes loaded from the index, the value will be set from index field {@link
-   * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
+   * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS_FIELD}.
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
-    if (!experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)) {
-      return Collections.emptyMap();
-    }
     if (submitRequirements == null) {
       if (!lazyload()) {
         return Collections.emptyMap();
@@ -959,35 +976,21 @@
       Change c = change();
       if (c == null || !c.isClosed()) {
         // Open changes: Evaluate submit requirements online.
-        submitRequirements =
-            submitRequirementsEvaluator.evaluateAllRequirements(this, /* includeLegacy= */ true);
+        submitRequirements = submitRequirementsEvaluator.evaluateAllRequirements(this);
         return submitRequirements;
       }
       // Closed changes: Load submit requirement results from NoteDb.
-      Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements =
+      submitRequirements =
           notes().getSubmitRequirementsResult().stream()
               .filter(r -> !r.isLegacy())
               .collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
-      Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements =
-          SubmitRequirementsAdapter.getLegacyRequirements(submitRuleEvaluatorFactory, this);
-      submitRequirements =
-          SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
-              projectConfigRequirements, legacyRequirements);
     }
     return submitRequirements;
   }
 
   public void setSubmitRequirements(
       Map<SubmitRequirement, SubmitRequirementResult> submitRequirements) {
-    if (!experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD)) {
-      // Only set back values from the index if the experiment is not active. While the experiment
-      // is active, we want
-      // to compute SRs from scratch to ensure fresh results.
-      // TODO(ghareeb, hiesel): Remove this.
-      this.submitRequirements = submitRequirements;
-    }
+    this.submitRequirements = submitRequirements;
   }
 
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
@@ -1094,8 +1097,8 @@
     return editRefs().rowKeySet();
   }
 
-  public Table<Account.Id, PatchSet.Id, ObjectId> editRefs() {
-    if (editsByUser == null) {
+  public Table<Account.Id, PatchSet.Id, Ref> editRefs() {
+    if (editRefsByUser == null) {
       if (!lazyload()) {
         return HashBasedTable.create();
       }
@@ -1103,7 +1106,7 @@
       if (c == null) {
         return HashBasedTable.create();
       }
-      editsByUser = HashBasedTable.create();
+      editRefsByUser = HashBasedTable.create();
       Change.Id id = requireNonNull(change.getId());
       try (Repository repo = repoManager.openRepository(project())) {
         for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS)) {
@@ -1114,7 +1117,7 @@
           if (id.equals(ps.changeId())) {
             Account.Id accountId = Account.Id.fromRef(ref.getName());
             if (accountId != null) {
-              editsByUser.put(accountId, ps, ref.getObjectId());
+              editRefsByUser.put(accountId, ps, ref);
             }
           }
         }
@@ -1122,7 +1125,7 @@
         throw new StorageException(e);
       }
     }
-    return editsByUser;
+    return editRefsByUser;
   }
 
   public Set<Account.Id> draftsByUser() {
@@ -1197,7 +1200,7 @@
     this.stars = ImmutableListMultimap.copyOf(stars);
   }
 
-  public ImmutableMap<Account.Id, StarRef> starRefs() {
+  private ImmutableMap<Account.Id, StarRef> starRefs() {
     if (starRefs == null) {
       if (!lazyload()) {
         return ImmutableMap.of();
@@ -1270,15 +1273,14 @@
       }
 
       ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
-      for (Table.Cell<Account.Id, PatchSet.Id, ObjectId> edit : editRefs().cellSet()) {
+      for (Table.Cell<Account.Id, PatchSet.Id, Ref> edit : editRefs().cellSet()) {
         result.put(
             project,
             RefState.create(
                 RefNames.refsEdit(
                     edit.getRowKey(), edit.getColumnKey().changeId(), edit.getColumnKey()),
-                edit.getValue()));
+                edit.getValue().getObjectId()));
       }
-      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.
@@ -1286,14 +1288,6 @@
       notes().getRobotComments(); // Force loading robot comments.
       RobotCommentNotes robotNotes = notes().getRobotCommentNotes();
       result.put(project, RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()));
-      draftRefs()
-          .entrySet()
-          .forEach(
-              r ->
-                  result.put(
-                      allUsersName,
-                      RefState.create(
-                          RefNames.refsDraftComments(getId(), r.getKey()), r.getValue())));
 
       refStates = result.build();
     }
@@ -1315,21 +1309,6 @@
             .forEach(r -> draftsByUser.put(Account.Id.fromRef(r.ref()), r.id()));
       }
     }
-    if (editsByUser == null) {
-      // Recover edit refs as well. Edits are represented as refs in the repository.
-      // ChangeData exposes #editsByUser which just provides a Set of Account.Ids of users who
-      // have edits on this change. Recovering this list from RefStates makes it available even
-      // on ChangeData instances retrieved from the index.
-      editsByUser = HashBasedTable.create();
-      if (refStates.containsKey(project())) {
-        refStates.get(project()).stream()
-            .filter(r -> RefNames.isRefsEdit(r.ref()))
-            .forEach(
-                r ->
-                    editsByUser.put(
-                        Account.Id.fromRef(r.ref()), PatchSet.Id.fromEditRef(r.ref()), r.id()));
-      }
-    }
   }
 
   public ImmutableList<byte[]> getRefStatePatterns() {
@@ -1348,7 +1327,7 @@
 
     public abstract Account.Id author();
 
-    public abstract Timestamp ts();
+    public abstract Instant ts();
   }
 
   @AutoValue
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java
new file mode 100644
index 0000000..e39b3e2
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.query.HasCardinality;
+
+public class ChangeIndexCardinalPredicate extends ChangeIndexPredicate implements HasCardinality {
+  protected final int cardinality;
+
+  protected ChangeIndexCardinalPredicate(
+      SchemaField<ChangeData, ?> def, String value, int cardinality) {
+    super(def, value);
+    this.cardinality = cardinality;
+  }
+
+  protected ChangeIndexCardinalPredicate(
+      SchemaField<ChangeData, ?> def, String name, String value, int cardinality) {
+    super(def, name, value);
+    this.cardinality = cardinality;
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
new file mode 100644
index 0000000..c69f021
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+
+/**
+ * Predicate that is mapped to a field in the change index, with additional filtering done in the
+ * {@code match} method.
+ */
+public abstract class ChangeIndexPostFilterPredicate extends ChangeIndexPredicate {
+  protected ChangeIndexPostFilterPredicate(SchemaField<ChangeData, ?> def, String value) {
+    super(def, value);
+  }
+
+  protected ChangeIndexPostFilterPredicate(
+      SchemaField<ChangeData, ?> def, String name, String value) {
+    super(def, name, value);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index ccd4109..a897a8d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 
@@ -32,11 +32,11 @@
     return ChangeStatusPredicate.NONE;
   }
 
-  protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String value) {
+  protected ChangeIndexPredicate(SchemaField<ChangeData, ?> def, String value) {
     super(def, value);
   }
 
-  protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
+  protected ChangeIndexPredicate(SchemaField<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index e3e0312..24042ad 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -17,7 +17,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
@@ -42,9 +41,8 @@
   }
 
   protected final CurrentUser user;
-  protected final PermissionBackend permissionBackend;
   protected final ProjectCache projectCache;
-  private final Provider<AnonymousUser> anonymousUserProvider;
+  private final PermissionBackend.WithUser withUser;
 
   @Inject
   public ChangeIsVisibleToPredicate(
@@ -54,9 +52,14 @@
       @Assisted CurrentUser user) {
     super(ChangeQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(user));
     this.user = user;
-    this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
-    this.anonymousUserProvider = anonymousUserProvider;
+    withUser =
+        user.isIdentifiedUser()
+            ? permissionBackend.absentUser(user.getAccountId())
+            : permissionBackend.user(
+                Optional.of(user)
+                    .filter(u -> u instanceof GroupBackedUser || u instanceof InternalUser)
+                    .orElseGet(anonymousUserProvider::get));
   }
 
   @Override
@@ -75,19 +78,15 @@
       return false;
     }
     if (!projectState.get().statePermitsRead()) {
-      logger.atFine().log("Filter out change %s of non-reabable project %s", cd, cd.project());
+      logger.atFine().log("Filter out change %s of non-readable project %s", cd, cd.project());
       return false;
     }
 
-    PermissionBackend.WithUser withUser =
-        user.isIdentifiedUser()
-            ? permissionBackend.absentUser(user.getAccountId())
-            : permissionBackend.user(
-                Optional.of(user)
-                    .filter(u -> u instanceof GroupBackedUser || u instanceof InternalUser)
-                    .orElseGet(anonymousUserProvider::get));
     try {
-      withUser.change(cd).check(ChangePermission.READ);
+      if (!withUser.change(cd).test(ChangePermission.READ)) {
+        logger.atFine().log("Filter out non-visisble change: %s", cd);
+        return false;
+      }
     } catch (PermissionBackendException e) {
       Throwable cause = e.getCause();
       if (cause instanceof RepositoryNotFoundException) {
@@ -97,9 +96,6 @@
         return false;
       }
       throw new StorageException("unable to check permissions on change " + cd.getId(), e);
-    } catch (AuthException e) {
-      logger.atFine().log("Filter out non-visisble change: %s", cd);
-      return false;
     }
 
     cd.cacheVisibleTo(user);
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 7e50c6f..c344edd 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -50,7 +50,7 @@
    * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> assignee(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.ASSIGNEE, id.toString());
+    return new ChangeIndexPredicate(ChangeField.ASSIGNEE_SPEC, id.toString());
   }
 
   /**
@@ -58,7 +58,7 @@
    * com.google.gerrit.entities.Change.Id}.
    */
   public static Predicate<ChangeData> revertOf(Change.Id revertOf) {
-    return new ChangeIndexPredicate(ChangeField.REVERT_OF, revertOf.toString());
+    return new ChangeIndexCardinalPredicate(ChangeField.REVERT_OF, revertOf.toString(), 1);
   }
 
   /**
@@ -66,7 +66,7 @@
    * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> commentBy(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.COMMENTBY, id.toString());
+    return new ChangeIndexPredicate(ChangeField.COMMENTBY_SPEC, id.toString());
   }
 
   /**
@@ -74,23 +74,21 @@
    * com.google.gerrit.entities.Account.Id} has a pending change edit.
    */
   public static Predicate<ChangeData> editBy(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.EDITBY, id.toString());
+    return new ChangeIndexPredicate(ChangeField.EDITBY_SPEC, id.toString());
   }
 
   /**
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has a pending draft comment.
    */
-  public static Predicate<ChangeData> draftBy(
-      boolean computeFromAllUsersRepository, CommentsUtil commentsUtil, Account.Id id) {
-    if (!computeFromAllUsersRepository) {
-      return new ChangeIndexPredicate(ChangeField.DRAFTBY, id.toString());
-    }
+  public static Predicate<ChangeData> draftBy(CommentsUtil commentsUtil, Account.Id id) {
     Set<Predicate<ChangeData>> changeIdPredicates =
         commentsUtil.getChangesWithDrafts(id).stream()
             .map(ChangePredicates::idStr)
             .collect(toImmutableSet());
-    return Predicate.or(changeIdPredicates);
+    return changeIdPredicates.isEmpty()
+        ? ChangeIndexPredicate.none()
+        : Predicate.or(changeIdPredicates);
   }
 
   /**
@@ -98,17 +96,12 @@
    * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
    */
   public static Predicate<ChangeData> starBy(
-      boolean computeFromAllUsersRepository,
-      StarredChangesUtil starredChangesUtil,
-      Account.Id id,
-      String label) {
-    if (!computeFromAllUsersRepository) {
-      return new StarPredicate(id, label);
-    }
-    return Predicate.or(
+      StarredChangesUtil starredChangesUtil, Account.Id id, String label) {
+    Set<Predicate<ChangeData>> starredChanges =
         starredChangesUtil.byAccountId(id, label).stream()
             .map(ChangePredicates::idStr)
-            .collect(toImmutableSet()));
+            .collect(toImmutableSet());
+    return starredChanges.isEmpty() ? ChangeIndexPredicate.none() : Predicate.or(starredChanges);
   }
 
   /**
@@ -118,7 +111,7 @@
   public static Predicate<ChangeData> reviewedBy(Collection<Account.Id> ids) {
     List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
     for (Account.Id id : ids) {
-      predicates.add(new ChangeIndexPredicate(ChangeField.REVIEWEDBY, id.toString()));
+      predicates.add(new ChangeIndexPredicate(ChangeField.REVIEWEDBY_SPEC, id.toString()));
     }
     return Predicate.or(predicates);
   }
@@ -126,16 +119,7 @@
   /** Returns a predicate that matches changes that were not yet reviewed. */
   public static Predicate<ChangeData> unreviewed() {
     return Predicate.not(
-        new ChangeIndexPredicate(ChangeField.REVIEWEDBY, ChangeField.NOT_REVIEWED.toString()));
-  }
-
-  /**
-   * Returns a predicate that matches the change with the provided {@link
-   * com.google.gerrit.entities.Change.Id}.
-   */
-  public static Predicate<ChangeData> id(Change.Id id) {
-    return new ChangeIndexPredicate(
-        ChangeField.LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
+        new ChangeIndexPredicate(ChangeField.REVIEWEDBY_SPEC, ChangeField.NOT_REVIEWED.toString()));
   }
 
   /**
@@ -143,8 +127,8 @@
    * com.google.gerrit.entities.Change.Id}.
    */
   public static Predicate<ChangeData> idStr(Change.Id id) {
-    return new ChangeIndexPredicate(
-        ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
+    return new ChangeIndexCardinalPredicate(
+        ChangeField.NUMERIC_ID_STR_SPEC, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
   }
 
   /**
@@ -152,7 +136,7 @@
    * com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> owner(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.OWNER, id.toString());
+    return new ChangeIndexCardinalPredicate(ChangeField.OWNER_SPEC, id.toString(), 5000);
   }
 
   /**
@@ -160,7 +144,7 @@
    * provided {@link com.google.gerrit.entities.Account.Id}.
    */
   public static Predicate<ChangeData> uploader(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.UPLOADER, id.toString());
+    return new ChangeIndexPredicate(ChangeField.UPLOADER_SPEC, id.toString());
   }
 
   /**
@@ -186,12 +170,12 @@
    * com.google.gerrit.entities.Project.NameKey}.
    */
   public static Predicate<ChangeData> project(Project.NameKey id) {
-    return new ChangeIndexPredicate(ChangeField.PROJECT, id.get());
+    return new ChangeIndexCardinalPredicate(ChangeField.PROJECT_SPEC, id.get(), 1_000_000);
   }
 
   /** Returns a predicate that matches changes targeted at the provided {@code refName}. */
   public static Predicate<ChangeData> ref(String refName) {
-    return new ChangeIndexPredicate(ChangeField.REF, refName);
+    return new ChangeIndexCardinalPredicate(ChangeField.REF_SPEC, refName, 10_000);
   }
 
   /** Returns a predicate that matches changes in the provided {@code topic}. */
@@ -204,21 +188,26 @@
     return new ChangeIndexPredicate(ChangeField.FUZZY_TOPIC, topic);
   }
 
+  /** Returns a predicate that matches changes in the provided {@code topic}. Used with prefixes */
+  public static Predicate<ChangeData> prefixTopic(String topic) {
+    return new ChangeIndexPredicate(ChangeField.PREFIX_TOPIC, topic);
+  }
+
   /** Returns a predicate that matches changes submitted in the provided {@code changeSet}. */
   public static Predicate<ChangeData> submissionId(String changeSet) {
-    return new ChangeIndexPredicate(ChangeField.SUBMISSIONID, changeSet);
+    return new ChangeIndexPredicate(ChangeField.SUBMISSIONID_SPEC, changeSet);
   }
 
   /** Returns a predicate that matches changes that modified the provided {@code path}. */
   public static Predicate<ChangeData> path(String path) {
-    return new ChangeIndexPredicate(ChangeField.PATH, path);
+    return new ChangeIndexPredicate(ChangeField.PATH_SPEC, path);
   }
 
   /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
   public static Predicate<ChangeData> hashtag(String hashtag) {
     // Use toLowerCase without locale to match behavior in ChangeField.
     return new ChangeIndexPredicate(
-        ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+        ChangeField.HASHTAG_SPEC, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
   }
 
   /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
@@ -228,13 +217,22 @@
         ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
   }
 
+  /**
+   * Returns a predicate that matches changes in the provided {@code hashtag}. Used with prefixes
+   */
+  public static Predicate<ChangeData> prefixHashtag(String hashtag) {
+    // Use toLowerCase without locale to match behavior in ChangeField.
+    return new ChangeIndexPredicate(
+        ChangeField.PREFIX_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+  }
+
   /** Returns a predicate that matches changes that modified the provided {@code file}. */
   public static Predicate<ChangeData> file(ChangeQueryBuilder.Arguments args, String file) {
     Predicate<ChangeData> eqPath = path(file);
-    if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
+    if (!args.getSchema().hasField(ChangeField.FILE_PART_SPEC)) {
       return eqPath;
     }
-    return Predicate.or(eqPath, new ChangeIndexPredicate(ChangeField.FILE_PART, file));
+    return Predicate.or(eqPath, new ChangeIndexPredicate(ChangeField.FILE_PART_SPEC, file));
   }
 
   /**
@@ -249,7 +247,15 @@
     if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
       footer = footer.substring(0, indexEquals) + ": " + footer.substring(indexEquals + 1);
     }
-    return new ChangeIndexPredicate(ChangeField.FOOTER, footer.toLowerCase(Locale.US));
+    return new ChangeIndexPredicate(ChangeField.FOOTER_SPEC, footer.toLowerCase(Locale.US));
+  }
+
+  /**
+   * Returns a predicate that matches changes with the provided {@code footer} name in their commit
+   * message.
+   */
+  public static Predicate<ChangeData> hasFooter(String footerName) {
+    return new ChangeIndexPredicate(ChangeField.FOOTER_NAME, footerName);
   }
 
   /**
@@ -257,22 +263,23 @@
    */
   public static Predicate<ChangeData> directory(String directory) {
     return new ChangeIndexPredicate(
-        ChangeField.DIRECTORY, CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US));
+        ChangeField.DIRECTORY_SPEC, CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US));
   }
 
   /** Returns a predicate that matches changes with the provided {@code trackingId}. */
   public static Predicate<ChangeData> trackingId(String trackingId) {
-    return new ChangeIndexPredicate(ChangeField.TR, trackingId);
+    return new ChangeIndexCardinalPredicate(ChangeField.TR_SPEC, trackingId, 5);
   }
 
   /** Returns a predicate that matches changes authored by the provided {@code exactAuthor}. */
   public static Predicate<ChangeData> exactAuthor(String exactAuthor) {
-    return new ChangeIndexPredicate(ChangeField.EXACT_AUTHOR, exactAuthor.toLowerCase(Locale.US));
+    return new ChangeIndexPredicate(
+        ChangeField.EXACT_AUTHOR_SPEC, exactAuthor.toLowerCase(Locale.US));
   }
 
   /** Returns a predicate that matches changes authored by the provided {@code author}. */
   public static Predicate<ChangeData> author(String author) {
-    return new ChangeIndexPredicate(ChangeField.AUTHOR, author);
+    return new ChangeIndexPredicate(ChangeField.AUTHOR_PARTS_SPEC, author);
   }
 
   /**
@@ -281,7 +288,7 @@
    */
   public static Predicate<ChangeData> exactCommitter(String exactCommitter) {
     return new ChangeIndexPredicate(
-        ChangeField.EXACT_COMMITTER, exactCommitter.toLowerCase(Locale.US));
+        ChangeField.EXACT_COMMITTER_SPEC, exactCommitter.toLowerCase(Locale.US));
   }
 
   /**
@@ -289,12 +296,13 @@
    * committer}.
    */
   public static Predicate<ChangeData> committer(String comitter) {
-    return new ChangeIndexPredicate(ChangeField.COMMITTER, comitter.toLowerCase(Locale.US));
+    return new ChangeIndexPredicate(
+        ChangeField.COMMITTER_PARTS_SPEC, comitter.toLowerCase(Locale.US));
   }
 
   /** Returns a predicate that matches changes whose ID starts with the provided {@code id}. */
   public static Predicate<ChangeData> idPrefix(String id) {
-    return new ChangeIndexPredicate(ChangeField.ID, id);
+    return new ChangeIndexCardinalPredicate(ChangeField.CHANGE_ID_SPEC, id, 5);
   }
 
   /**
@@ -302,7 +310,7 @@
    * its name.
    */
   public static Predicate<ChangeData> projectPrefix(String prefix) {
-    return new ChangeIndexPredicate(ChangeField.PROJECTS, prefix);
+    return new ChangeIndexPredicate(ChangeField.PROJECTS_SPEC, prefix);
   }
 
   /**
@@ -311,9 +319,9 @@
    */
   public static Predicate<ChangeData> commitPrefix(String commitId) {
     if (commitId.length() == ObjectIds.STR_LEN) {
-      return new ChangeIndexPredicate(ChangeField.EXACT_COMMIT, commitId);
+      return new ChangeIndexCardinalPredicate(ChangeField.EXACT_COMMIT_SPEC, commitId, 5);
     }
-    return new ChangeIndexPredicate(ChangeField.COMMIT, commitId);
+    return new ChangeIndexCardinalPredicate(ChangeField.COMMIT_SPEC, commitId, 5);
   }
 
   /**
@@ -324,12 +332,20 @@
     return new ChangeIndexPredicate(ChangeField.COMMIT_MESSAGE, message);
   }
 
+  public static Predicate<ChangeData> subject(String subject) {
+    return new ChangeIndexPredicate(ChangeField.SUBJECT_SPEC, subject);
+  }
+
+  public static Predicate<ChangeData> prefixSubject(String subject) {
+    return new ChangeIndexPredicate(ChangeField.PREFIX_SUBJECT_SPEC, subject);
+  }
+
   /**
    * Returns a predicate that matches changes where the provided {@code comment} appears in any
    * comment on any patch set of the change. Uses full-text search semantics.
    */
   public static Predicate<ChangeData> comment(String comment) {
-    return new ChangeIndexPredicate(ChangeField.COMMENT, comment);
+    return new ChangeIndexPredicate(ChangeField.COMMENT_SPEC, comment);
   }
 
   /**
@@ -340,7 +356,7 @@
    * in the form of 'gerrit~$rule_name'.
    */
   public static Predicate<ChangeData> submitRuleStatus(String value) {
-    return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT, value);
+    return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT_SPEC, value);
   }
 
   /**
@@ -348,6 +364,17 @@
    * to "1", or non-pure reverts if {@code value} is "0".
    */
   public static Predicate<ChangeData> pureRevert(String value) {
-    return new ChangeIndexPredicate(ChangeField.IS_PURE_REVERT, value);
+    return new ChangeIndexPredicate(ChangeField.IS_PURE_REVERT_SPEC, value);
+  }
+
+  /**
+   * Returns a predicate that matches with changes that are submittable if {@code value} is equal to
+   * "1", or non-submittable if {@code value} is "0".
+   *
+   * <p>The computation of this field is based on the evaluation of {@link
+   * com.google.gerrit.entities.SubmitRequirement}s.
+   */
+  public static Predicate<ChangeData> isSubmittable(String value) {
+    return new ChangeIndexPredicate(ChangeField.IS_SUBMITTABLE_SPEC, value);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index d435df1..ca18ab2 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -17,7 +17,6 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.account.AccountResolver.isSelf;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -27,8 +26,10 @@
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
@@ -43,9 +44,9 @@
 import com.google.gerrit.exceptions.NotSignedInException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
@@ -60,6 +61,7 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.DestinationList;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupMembers;
@@ -96,6 +98,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -108,6 +111,8 @@
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Parses a query string meant to be applied to change objects. */
 public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuilder> {
@@ -141,7 +146,7 @@
   static final int MAX_ACCOUNTS_PER_DEFAULT_FIELD = 10;
 
   // NOTE: As new search operations are added, please keep the suggestions in
-  // gr-search-bar.js up to date.
+  // gr-search-bar.ts up to date.
 
   public static final String FIELD_ADDED = "added";
   public static final String FIELD_AGE = "age";
@@ -163,6 +168,7 @@
   public static final String FIELD_EXTENSION = "extension";
   public static final String FIELD_ONLY_EXTENSIONS = "onlyextensions";
   public static final String FIELD_FOOTER = "footer";
+  public static final String FIELD_FOOTER_NAME = "footernames";
   public static final String FIELD_CONFLICTS = "conflicts";
   public static final String FIELD_DELETED = "deleted";
   public static final String FIELD_DELTA = "delta";
@@ -180,6 +186,9 @@
   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_SUBJECT = "subject";
+  public static final String FIELD_PREFIX_SUBJECT = "prefixsubject";
+  public static final String FIELD_MESSAGE_EXACT = "messageexact";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
   public static final String FIELD_PARENTOF = "parentof";
@@ -209,6 +218,7 @@
   public static final String FIELD_CHERRYPICK = "cherrypick";
   public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
   public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
+  public static final String FIELD_IS_SUBMITTABLE = "issubmittable";
 
   public static final String ARG_ID_NAME = "name";
   public static final String ARG_ID_USER = "user";
@@ -401,7 +411,7 @@
       this.submitRules = submitRules;
     }
 
-    Arguments asUser(CurrentUser otherUser) {
+    public Arguments asUser(CurrentUser otherUser) {
       return new Arguments(
           queryProvider,
           rewriter,
@@ -469,6 +479,7 @@
       }
     }
 
+    @Nullable
     Schema<ChangeData> getSchema() {
       return index != null ? index.getSchema() : null;
     }
@@ -476,9 +487,15 @@
 
   private final Arguments args;
   protected Map<String, String> hasOperandAliases = Collections.emptyMap();
+  private Map<Account.Id, DestinationList> destinationListByAccount = new HashMap<>();
+  private boolean forceAccountVisibilityCheck = false;
+
+  private static final Splitter RULE_SPLITTER = Splitter.on("=");
+  private static final Splitter PLUGIN_SPLITTER = Splitter.on("_");
+  private static final Splitter LABEL_SPLITTER = Splitter.on(",");
 
   @Inject
-  ChangeQueryBuilder(Arguments args) {
+  protected ChangeQueryBuilder(Arguments args) {
     this(mydef, args);
     setupAliases();
   }
@@ -498,6 +515,15 @@
     return new ChangeQueryBuilder(builderDef, args.asUser(user));
   }
 
+  public Arguments getArgs() {
+    return args;
+  }
+
+  /** Whether to force account visibility check when searching for changes by account(s). */
+  public void forceAccountVisibilityCheck() {
+    forceAccountVisibilityCheck = true;
+  }
+
   @Operator
   public Predicate<ChangeData> age(String value) {
     return new AgePredicate(value);
@@ -505,7 +531,7 @@
 
   @Operator
   public Predicate<ChangeData> before(String value) throws QueryParseException {
-    return new BeforePredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_BEFORE, value);
+    return new BeforePredicate(ChangeField.UPDATED_SPEC, ChangeQueryBuilder.OPERATOR_BEFORE, value);
   }
 
   @Operator
@@ -515,7 +541,7 @@
 
   @Operator
   public Predicate<ChangeData> after(String value) throws QueryParseException {
-    return new AfterPredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_AFTER, value);
+    return new AfterPredicate(ChangeField.UPDATED_SPEC, ChangeQueryBuilder.OPERATOR_AFTER, value);
   }
 
   @Operator
@@ -525,16 +551,16 @@
 
   @Operator
   public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_BEFORE);
+    checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_BEFORE);
     return new BeforePredicate(
-        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
+        ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
   }
 
   @Operator
   public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_AFTER);
+    checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_AFTER);
     return new AfterPredicate(
-        ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
+        ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
   }
 
   @Operator
@@ -549,9 +575,7 @@
     if (PAT_LEGACY_ID.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
-        return args.getSchema().useLegacyNumericFields()
-            ? ChangePredicates.id(Change.id(id))
-            : ChangePredicates.idStr(Change.id(id));
+        return ChangePredicates.idStr(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return ChangePredicates.idPrefix(parseChangeId(query));
@@ -566,7 +590,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> status(String statusName) {
+  public Predicate<ChangeData> status(String statusName) throws QueryParseException {
     if ("reviewed".equalsIgnoreCase(statusName)) {
       return ChangePredicates.unreviewed();
     }
@@ -581,21 +605,16 @@
   public Predicate<ChangeData> rule(String value) throws QueryParseException {
     String ruleNameArg = value;
     String statusArg = null;
-    String[] queryArgs = value.split("=");
-    if (queryArgs.length > 2) {
+    List<String> queryArgs = RULE_SPLITTER.splitToList(value);
+    if (queryArgs.size() > 2) {
       throw new QueryParseException(
           "Invalid query arguments. Correct format is 'rule:<rule_name>=<status>' "
               + "with <rule_name> in the form of <plugin>~<rule>. For Gerrit core rules, "
-              + "rule name should be specified either as gerrit~<rule> or <rule>.");
+              + "rule name should be specified as gerrit~<rule>.");
     }
-    if (queryArgs.length == 2) {
-      ruleNameArg = queryArgs[0];
-      statusArg = queryArgs[1];
-    }
-
-    // If ruleName is not prefixed by the plugin name, add the "gerrit~" prefix to it.
-    if (!ruleNameArg.contains("~")) {
-      ruleNameArg = "gerrit~" + ruleNameArg;
+    if (queryArgs.size() == 2) {
+      ruleNameArg = queryArgs.get(0);
+      statusArg = queryArgs.get(1);
     }
 
     return statusArg == null
@@ -622,7 +641,7 @@
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
+      checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
       return new IsAttentionPredicate();
     }
 
@@ -631,7 +650,7 @@
     }
 
     // for plugins the value will be operandName_pluginName
-    List<String> names = Lists.newArrayList(Splitter.on('_').split(value));
+    List<String> names = PLUGIN_SPLITTER.splitToList(value);
     if (names.size() == 2) {
       ChangeHasOperandFactory op = args.hasOperands.get(names.get(1), names.get(0));
       if (op != null) {
@@ -665,13 +684,14 @@
     }
 
     if ("uploader".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.UPLOADER, "is:uploader");
+      checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "is:uploader");
       return ChangePredicates.uploader(self());
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
       return Predicate.and(
-          Predicate.not(new BooleanPredicate(ChangeField.WIP)), ReviewerPredicate.reviewer(self()));
+          Predicate.not(new BooleanPredicate(ChangeField.WIP_SPEC)),
+          ReviewerPredicate.reviewer(self()));
     }
 
     if ("cc".equalsIgnoreCase(value)) {
@@ -680,22 +700,23 @@
 
     if ("mergeable".equalsIgnoreCase(value)) {
       if (!args.indexMergeable) {
-        throw new QueryParseException("'is:mergeable' operator is not supported by server");
+        throw new QueryParseException(
+            "'is:mergeable' operator is not supported on this gerrit host");
       }
-      return new BooleanPredicate(ChangeField.MERGEABLE);
+      return new BooleanPredicate(ChangeField.MERGEABLE_SPEC);
     }
 
     if ("merge".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.MERGE, "is:merge");
-      return new BooleanPredicate(ChangeField.MERGE);
+      checkOperatorAvailable(ChangeField.MERGE_SPEC, "is:merge");
+      return new BooleanPredicate(ChangeField.MERGE_SPEC);
     }
 
     if ("private".equalsIgnoreCase(value)) {
-      return new BooleanPredicate(ChangeField.PRIVATE);
+      return new BooleanPredicate(ChangeField.PRIVATE_SPEC);
     }
 
     if ("attention".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
+      checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
       return new IsAttentionPredicate();
     }
 
@@ -708,42 +729,42 @@
     }
 
     if ("pure-revert".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.IS_PURE_REVERT, "is:pure-revert");
+      checkOperatorAvailable(ChangeField.IS_PURE_REVERT_SPEC, "is:pure-revert");
       return ChangePredicates.pureRevert("1");
     }
 
     if ("submittable".equalsIgnoreCase(value)) {
-      // SubmittablePredicate will match if *any* of the submit records are OK,
-      // but we need to check that they're *all* OK, so check that none of the
-      // submit records match any of the negative cases. To avoid checking yet
-      // more negative cases for CLOSED and FORCED, instead make sure at least
-      // one submit record is OK.
-      return Predicate.and(
-          new SubmittablePredicate(SubmitRecord.Status.OK),
-          Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
-          Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
-    }
-
-    if ("ignored".equalsIgnoreCase(value)) {
-      return ignoredBySelf();
+      if (!args.index.getSchema().hasField(ChangeField.IS_SUBMITTABLE_SPEC)) {
+        // SubmittablePredicate will match if *any* of the submit records are OK,
+        // but we need to check that they're *all* OK, so check that none of the
+        // submit records match any of the negative cases. To avoid checking yet
+        // more negative cases for CLOSED and FORCED, instead make sure at least
+        // one submit record is OK.
+        return Predicate.and(
+            new SubmittablePredicate(SubmitRecord.Status.OK),
+            Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
+            Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
+      }
+      checkOperatorAvailable(ChangeField.IS_SUBMITTABLE_SPEC, "is:submittable");
+      return new IsSubmittablePredicate();
     }
 
     if ("started".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.STARTED, "is:started");
-      return new BooleanPredicate(ChangeField.STARTED);
+      checkOperatorAvailable(ChangeField.STARTED_SPEC, "is:started");
+      return new BooleanPredicate(ChangeField.STARTED_SPEC);
     }
 
     if ("wip".equalsIgnoreCase(value)) {
-      return new BooleanPredicate(ChangeField.WIP);
+      return new BooleanPredicate(ChangeField.WIP_SPEC);
     }
 
     if ("cherrypick".equalsIgnoreCase(value)) {
-      checkFieldAvailable(ChangeField.CHERRY_PICK, "is:cherrypick");
-      return new BooleanPredicate(ChangeField.CHERRY_PICK);
+      checkOperatorAvailable(ChangeField.CHERRY_PICK_SPEC, "is:cherrypick");
+      return new BooleanPredicate(ChangeField.CHERRY_PICK_SPEC);
     }
 
     // for plugins the value will be operandName_pluginName
-    List<String> names = Lists.newArrayList(Splitter.on('_').split(value));
+    List<String> names = PLUGIN_SPLITTER.splitToList(value);
     if (names.size() == 2) {
       ChangeIsOperandFactory op = args.isOperands.get(names.get(1), names.get(0));
       if (op != null) {
@@ -761,7 +782,7 @@
   @Operator
   public Predicate<ChangeData> conflicts(String value) throws QueryParseException {
     if (!args.conflictsPredicateEnabled) {
-      throw new QueryParseException("'conflicts:' operator is not supported by server");
+      throw new QueryParseException("'conflicts:' operator is not supported on this gerrit host");
     }
     List<Change> changes = parseChange(value);
     List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
@@ -794,11 +815,28 @@
     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));
+      for (RevCommit revCommit : getParents(c)) {
+        or.add(ChangePredicates.commitPrefix(revCommit.getId().getName()));
+      }
     }
     return Predicate.or(or);
   }
 
+  private Set<RevCommit> getParents(ChangeData change) {
+    PatchSet ps = change.currentPatchSet();
+    try (Repository repo = args.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);
+    }
+  }
+
   @Operator
   public Predicate<ChangeData> parentproject(String name) {
     return new ParentProjectPredicate(args.projectCache, args.childProjects, name);
@@ -856,11 +894,21 @@
       return ChangePredicates.hashtag(hashtag);
     }
 
-    checkFieldAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
+    checkOperatorAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
     return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
   @Operator
+  public Predicate<ChangeData> prefixhashtag(String hashtag) throws QueryParseException {
+    if (hashtag.isEmpty()) {
+      return ChangePredicates.hashtag(hashtag);
+    }
+
+    checkOperatorAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
+    return ChangePredicates.prefixHashtag(hashtag);
+  }
+
+  @Operator
   public Predicate<ChangeData> topic(String name) {
     return ChangePredicates.exactTopic(name);
   }
@@ -877,6 +925,16 @@
   }
 
   @Operator
+  public Predicate<ChangeData> prefixtopic(String name) throws QueryParseException {
+    if (name.isEmpty()) {
+      return ChangePredicates.exactTopic(name);
+    }
+
+    checkOperatorAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
+    return ChangePredicates.prefixTopic(name);
+  }
+
+  @Operator
   public Predicate<ChangeData> ref(String ref) throws QueryParseException {
     if (ref.startsWith("^")) {
       return new RegexRefPredicate(ref);
@@ -885,12 +943,18 @@
   }
 
   @Operator
-  public Predicate<ChangeData> f(String file) {
+  public Predicate<ChangeData> f(String file) throws QueryParseException {
     return file(file);
   }
 
+  /**
+   * Creates a predicate to match changes by file.
+   *
+   * @param file the value of the {@code file} query operator
+   * @throws QueryParseException thrown if parsing the value fails (may be thrown by subclasses)
+   */
   @Operator
-  public Predicate<ChangeData> file(String file) {
+  public Predicate<ChangeData> file(String file) throws QueryParseException {
     if (file.startsWith("^")) {
       return new RegexPathPredicate(file);
     }
@@ -906,37 +970,43 @@
   }
 
   @Operator
-  public Predicate<ChangeData> ext(String ext) throws QueryParseException {
+  public Predicate<ChangeData> ext(String ext) {
     return extension(ext);
   }
 
   @Operator
-  public Predicate<ChangeData> extension(String ext) throws QueryParseException {
+  public Predicate<ChangeData> extension(String ext) {
     return new FileExtensionPredicate(ext);
   }
 
   @Operator
-  public Predicate<ChangeData> onlyexts(String extList) throws QueryParseException {
+  public Predicate<ChangeData> onlyexts(String extList) {
     return onlyextensions(extList);
   }
 
   @Operator
-  public Predicate<ChangeData> onlyextensions(String extList) throws QueryParseException {
+  public Predicate<ChangeData> onlyextensions(String extList) {
     return new FileExtensionListPredicate(extList);
   }
 
   @Operator
-  public Predicate<ChangeData> footer(String footer) throws QueryParseException {
+  public Predicate<ChangeData> footer(String footer) {
     return ChangePredicates.footer(footer);
   }
 
   @Operator
-  public Predicate<ChangeData> dir(String directory) throws QueryParseException {
+  public Predicate<ChangeData> hasfooter(String footerName) throws QueryParseException {
+    checkOperatorAvailable(ChangeField.FOOTER_NAME, "hasfooter");
+    return ChangePredicates.hasFooter(footerName);
+  }
+
+  @Operator
+  public Predicate<ChangeData> dir(String directory) {
     return directory(directory);
   }
 
   @Operator
-  public Predicate<ChangeData> directory(String directory) throws QueryParseException {
+  public Predicate<ChangeData> directory(String directory) {
     if (directory.startsWith("^")) {
       return new RegexDirectoryPredicate(directory);
     }
@@ -961,7 +1031,7 @@
     // label:Code-Review+2,owner
     // label:Code-Review+2,user=owner
     // label:Code-Review+1,count=2
-    List<String> splitReviewer = Lists.newArrayList(Splitter.on(',').limit(2).split(name));
+    List<String> splitReviewer = LABEL_SPLITTER.limit(2).splitToList(name);
     name = splitReviewer.get(0); // remove all but the vote piece, e.g.'CodeReview=1'
 
     if (splitReviewer.size() == 2) {
@@ -1075,45 +1145,36 @@
   }
 
   @Operator
-  public Predicate<ChangeData> message(String text) {
+  public Predicate<ChangeData> message(String text) throws QueryParseException {
+    if (text.startsWith("^")) {
+      checkFieldAvailable(
+          ChangeField.COMMIT_MESSAGE_EXACT,
+          "'message' operator with regular expression is not supported on this gerrit host");
+      return new RegexMessagePredicate(text);
+    }
     return ChangePredicates.message(text);
   }
 
   @Operator
-  public Predicate<ChangeData> star(String label) throws QueryParseException {
-    if ("ignore".equalsIgnoreCase(label)) {
-      return ignoredBySelf();
-    }
-    if ("star".equalsIgnoreCase(label)) {
-      return starredBySelf();
-    }
-    throw new IllegalArgumentException();
+  public Predicate<ChangeData> subject(String value) throws QueryParseException {
+    checkOperatorAvailable(ChangeField.SUBJECT_SPEC, ChangeQueryBuilder.FIELD_SUBJECT);
+    return ChangePredicates.subject(value);
   }
 
-  private Predicate<ChangeData> ignoredBySelf() throws QueryParseException {
-    return ChangePredicates.starBy(
-        args.experimentFeatures.isFeatureEnabled(
-            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-        args.starredChangesUtil,
-        self(),
-        StarredChangesUtil.IGNORE_LABEL);
+  @Operator
+  public Predicate<ChangeData> prefixsubject(String value) throws QueryParseException {
+    checkOperatorAvailable(
+        ChangeField.PREFIX_SUBJECT_SPEC, ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
+    return ChangePredicates.prefixSubject(value);
   }
 
   private Predicate<ChangeData> starredBySelf() throws QueryParseException {
     return ChangePredicates.starBy(
-        args.experimentFeatures.isFeatureEnabled(
-            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-        args.starredChangesUtil,
-        self(),
-        StarredChangesUtil.DEFAULT_LABEL);
+        args.starredChangesUtil, self(), StarredChangesUtil.DEFAULT_LABEL);
   }
 
   private Predicate<ChangeData> draftBySelf() throws QueryParseException {
-    return ChangePredicates.draftBy(
-        args.experimentFeatures.isFeatureEnabled(
-            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-        args.commentsUtil,
-        self());
+    return ChangePredicates.draftBy(args.commentsUtil, self());
   }
 
   @Operator
@@ -1191,7 +1252,7 @@
   @Operator
   public Predicate<ChangeData> uploader(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    checkFieldAvailable(ChangeField.UPLOADER, "uploader");
+    checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploader");
     return uploader(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1206,7 +1267,7 @@
   @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
+    checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
     return attention(parseAccount(who, (AccountState s) -> true));
   }
 
@@ -1251,7 +1312,7 @@
 
   @Operator
   public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
-    checkFieldAvailable(ChangeField.UPLOADER, "uploaderin");
+    checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploaderin");
 
     GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
     if (g == null) {
@@ -1296,7 +1357,7 @@
     if (Objects.equals(byState, Predicate.<ChangeData>any())) {
       return Predicate.any();
     }
-    return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
+    return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP_SPEC)), byState);
   }
 
   @Operator
@@ -1455,9 +1516,7 @@
         account = self();
       }
 
-      VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
-      d.load(args.allUsersName, git);
-      Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
+      Set<BranchNameKey> destinations = getDestinationList(git, account).getDestinations(name);
       if (destinations != null && !destinations.isEmpty()) {
         return new DestinationPredicate(destinations, value);
       }
@@ -1470,6 +1529,23 @@
     throw new QueryParseException("Unknown named destination: " + name);
   }
 
+  protected DestinationList getDestinationList(Repository git, Account.Id account)
+      throws ConfigInvalidException, RepositoryNotFoundException, IOException {
+    DestinationList dl = destinationListByAccount.get(account);
+    if (dl == null) {
+      dl = loadDestinationList(git, account);
+      destinationListByAccount.put(account, dl);
+    }
+    return dl;
+  }
+
+  protected DestinationList loadDestinationList(Repository git, Account.Id account)
+      throws ConfigInvalidException, RepositoryNotFoundException, IOException {
+    VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
+    d.load(args.allUsersName, git);
+    return d.getDestinationList();
+  }
+
   @Operator
   public Predicate<ChangeData> author(String who) throws QueryParseException {
     return getAuthorOrCommitterPredicate(
@@ -1483,16 +1559,6 @@
   }
 
   @Operator
-  public Predicate<ChangeData> submittable(String str) throws QueryParseException {
-    SubmitRecord.Status status =
-        Enums.getIfPresent(SubmitRecord.Status.class, str.toUpperCase()).orNull();
-    if (status == null) {
-      throw error("invalid value for submittable:" + str);
-    }
-    return new SubmittablePredicate(status);
-  }
-
-  @Operator
   public Predicate<ChangeData> unresolved(String value) throws QueryParseException {
     return new IsUnresolvedPredicate(value);
   }
@@ -1506,14 +1572,14 @@
   }
 
   @Operator
-  public Predicate<ChangeData> submissionId(String value) throws QueryParseException {
+  public Predicate<ChangeData> submissionId(String value) {
     return ChangePredicates.submissionId(value);
   }
 
   @Operator
   public Predicate<ChangeData> cherryPickOf(String value) throws QueryParseException {
-    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
-    checkFieldAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
+    checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
+    checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
     if (Ints.tryParse(value) != null) {
       return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
     }
@@ -1585,11 +1651,16 @@
     return Predicate.or(predicates);
   }
 
-  protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator)
+  private void checkOperatorAvailable(SchemaField<ChangeData, ?> field, String operator)
+      throws QueryParseException {
+    checkFieldAvailable(
+        field, String.format("'%s' operator is not supported on this gerrit host", operator));
+  }
+
+  protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String errorMessage)
       throws QueryParseException {
     if (!args.index.getSchema().hasField(field)) {
-      throw new QueryParseException(
-          String.format("'%s' operator is not supported by change index version", operator));
+      throw new QueryParseException(errorMessage);
     }
   }
 
@@ -1640,7 +1711,9 @@
   private Set<Account.Id> parseAccount(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     try {
-      return args.accountResolver.resolve(who).asNonEmptyIdSet();
+      return args.accountResolver
+          .resolveAsUser(args.getUser(), who, forceAccountVisibilityCheck)
+          .asNonEmptyIdSet();
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
         throw new QueryRequiresAuthException(e.getMessage(), e);
@@ -1653,7 +1726,9 @@
       String who, java.util.function.Predicate<AccountState> activityFilter)
       throws QueryParseException, IOException, ConfigInvalidException {
     try {
-      return args.accountResolver.resolve(who, activityFilter).asNonEmptyIdSet();
+      return args.accountResolver
+          .resolveAsUser(args.getUser(), who, activityFilter, forceAccountVisibilityCheck)
+          .asNonEmptyIdSet();
     } catch (UnresolvableAccountException e) {
       if (e.isSelf()) {
         throw new QueryRequiresAuthException(e.getMessage(), e);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index ed1f2f1..0f0535a 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.ArrayList;
@@ -61,6 +62,8 @@
   private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
   private final List<Extension<ChangePluginDefinedInfoFactory>>
       changePluginDefinedInfoFactoriesByPlugin = new ArrayList<>();
+  private final Sequences sequences;
+  private final IndexConfig indexConfig;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -77,6 +80,7 @@
       IndexConfig indexConfig,
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
+      Sequences sequences,
       ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
       DynamicSet<ChangePluginDefinedInfoFactory> changePluginDefinedInfoFactories) {
     super(
@@ -89,6 +93,8 @@
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.userProvider = userProvider;
     this.changeIsVisibleToPredicateFactory = changeIsVisibleToPredicateFactory;
+    this.sequences = sequences;
+    this.indexConfig = indexConfig;
 
     changePluginDefinedInfoFactories
         .entries()
@@ -103,8 +109,14 @@
 
   @Override
   protected QueryOptions createOptions(
-      IndexConfig indexConfig, int start, int limit, Set<String> requestedFields) {
-    return IndexedChangeQuery.createOptions(indexConfig, start, limit, requestedFields);
+      IndexConfig indexConfig,
+      int start,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      Set<String> requestedFields) {
+    return IndexedChangeQuery.createOptions(
+        indexConfig, start, pageSize, pageSizeMultiplier, limit, requestedFields);
   }
 
   @Override
@@ -131,11 +143,26 @@
   @Override
   protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
     return new AndChangeSource(
-        pred, changeIsVisibleToPredicateFactory.forUser(userProvider.get()), start);
+        pred, changeIsVisibleToPredicateFactory.forUser(userProvider.get()), start, indexConfig);
   }
 
   @Override
   protected String formatForLogging(ChangeData changeData) {
     return changeData.getId().toString();
   }
+
+  @Override
+  protected int getIndexSize() {
+    return sequences.lastChangeId();
+  }
+
+  @Override
+  protected int getBatchSize() {
+    return sequences.changeBatchSize();
+  }
+
+  @Override
+  protected int getInitialPageSize(int limit) {
+    return Math.min(getUserQueryLimit().getAsInt(), limit);
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
index 24b8b7a..d1c487e 100644
--- a/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.RegexPredicate;
 
 public abstract class ChangeRegexPredicate extends RegexPredicate<ChangeData>
     implements Matchable<ChangeData> {
-  protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String value) {
+  protected ChangeRegexPredicate(SchemaField<ChangeData, ?> def, String value) {
     super(def, value);
   }
 
-  protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
+  protected ChangeRegexPredicate(SchemaField<ChangeData, ?> def, String name, String value) {
     super(def, name, value);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 0721433..fa48511 100644
--- a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -20,7 +20,9 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
+import com.google.gerrit.index.query.HasCardinality;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.ArrayList;
 import java.util.List;
@@ -38,7 +40,7 @@
  *
  * <p>Status names are looked up by prefix case-insensitively.
  */
-public final class ChangeStatusPredicate extends ChangeIndexPredicate {
+public final class ChangeStatusPredicate extends ChangeIndexPredicate implements HasCardinality {
   private static final String INVALID_STATUS = "__invalid__";
   static final Predicate<ChangeData> NONE = new ChangeStatusPredicate(null);
 
@@ -75,7 +77,7 @@
     return status.name().toLowerCase();
   }
 
-  public static Predicate<ChangeData> parse(String value) {
+  public static Predicate<ChangeData> parse(String value) throws QueryParseException {
     String lower = value.toLowerCase();
     NavigableMap<String, Predicate<ChangeData>> head = PREDICATES.tailMap(lower, true);
     if (!head.isEmpty()) {
@@ -85,7 +87,7 @@
         return e.getValue();
       }
     }
-    return NONE;
+    throw new QueryParseException("Unrecognized value: " + value);
   }
 
   public static Predicate<ChangeData> open() {
@@ -103,7 +105,7 @@
   @Nullable private final Change.Status status;
 
   private ChangeStatusPredicate(@Nullable Change.Status status) {
-    super(ChangeField.STATUS, status != null ? canonicalize(status) : INVALID_STATUS);
+    super(ChangeField.STATUS_SPEC, status != null ? canonicalize(status) : INVALID_STATUS);
     this.status = status;
   }
 
@@ -138,4 +140,20 @@
   public String toString() {
     return getOperator() + ":" + getValue();
   }
+
+  @Override
+  public int getCardinality() {
+    if (getStatus() == null) {
+      return 0;
+    }
+    switch (getStatus()) {
+      case MERGED:
+        return 50_000;
+      case ABANDONED:
+        return 50_000;
+      case NEW:
+      default:
+        return 2000;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index f95dbb0..fc4c1d0 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -89,11 +89,7 @@
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
     and.add(ChangePredicates.project(c.getProject()));
     and.add(ChangePredicates.ref(c.getDest().branch()));
-    and.add(
-        Predicate.not(
-            args.getSchema().useLegacyNumericFields()
-                ? ChangePredicates.id(c.getId())
-                : ChangePredicates.idStr(c.getId())));
+    and.add(Predicate.not(ChangePredicates.idStr(c.getId())));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
diff --git a/java/com/google/gerrit/server/query/change/ConstantPredicate.java b/java/com/google/gerrit/server/query/change/ConstantPredicate.java
new file mode 100644
index 0000000..f0a85fe
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ConstantPredicate.java
@@ -0,0 +1,38 @@
+// 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.inject.Singleton;
+
+/**
+ * A submit requirement predicate (can only be used in submit requirement expressions) that always
+ * evaluates to {@code true} if the value is equal to "true" or false otherwise.
+ */
+@Singleton
+public class ConstantPredicate extends SubmitRequirementPredicate {
+  public ConstantPredicate(String value) {
+    super("is", value);
+  }
+
+  @Override
+  public boolean match(ChangeData object) {
+    return "true".equals(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/DeletedPredicate.java b/java/com/google/gerrit/server/query/change/DeletedPredicate.java
index d4bdc67..40e4c6e 100644
--- a/java/com/google/gerrit/server/query/change/DeletedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DeletedPredicate.java
@@ -19,11 +19,11 @@
 
 public class DeletedPredicate extends IntegerRangeChangePredicate {
   public DeletedPredicate(String value) throws QueryParseException {
-    super(ChangeField.DELETED, value);
+    super(ChangeField.DELETED_LINES_SPEC, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) {
-    return ChangeField.DELETED.get(changeData);
+    return ChangeField.DELETED_LINES_SPEC.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/DeltaPredicate.java b/java/com/google/gerrit/server/query/change/DeltaPredicate.java
index 821ec94..e9eaa32 100644
--- a/java/com/google/gerrit/server/query/change/DeltaPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DeltaPredicate.java
@@ -19,11 +19,11 @@
 
 public class DeltaPredicate extends IntegerRangeChangePredicate {
   public DeltaPredicate(String value) throws QueryParseException {
-    super(ChangeField.DELTA, value);
+    super(ChangeField.DELTA_LINES_SPEC, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) {
-    return ChangeField.DELTA.get(changeData);
+    return ChangeField.DELTA_LINES_SPEC.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java b/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
new file mode 100644
index 0000000..5a51f5d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
@@ -0,0 +1,124 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.index.query.QueryParseException;
+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 java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A submit requirement predicate that allows checking for distinct voters across labels.
+ *
+ * <p>Examples:
+ *
+ * <ul>
+ *   <li>[Label-Name1,Label-Name2],value=MAX,count=2
+ *   <li>[Label-Name1,Label-Name2,Label-Name3],count=5
+ * </ul>
+ */
+public class DistinctVotersPredicate extends SubmitRequirementPredicate {
+  public interface Factory {
+    DistinctVotersPredicate create(String value) throws QueryParseException;
+  }
+
+  private static final Pattern PATTERN =
+      Pattern.compile(
+          "\\[(?<labels>[^\\]]+)\\](,value=(?<value>MAX|MIN|-?[0-9]+))?,count\\>(?<count>[0-9]+)");
+
+  private final ProjectCache projectCache;
+  private final ImmutableList<String> labelNames;
+  private final boolean enforceMaxVote;
+  private final boolean enforceMinVote;
+  private final Optional<Integer> enforceIntegerVote;
+  private final int numDistinctVotes;
+
+  @Inject
+  public DistinctVotersPredicate(ProjectCache projectCache, @Assisted String value)
+      throws QueryParseException {
+    super("distinctvoters", value);
+    this.projectCache = projectCache;
+    Matcher m = PATTERN.matcher(value);
+    if (!m.matches()) {
+      throw new QueryParseException("input " + value + " invalid");
+    }
+    labelNames = ImmutableList.copyOf(Splitter.on(',').split(m.group("labels")));
+    Integer votes = Ints.tryParse(m.group("count"));
+    if (votes == null) {
+      throw new QueryParseException("unable to parse number of required votes");
+    }
+    numDistinctVotes = votes + 1; // Regex has > sign
+    if (m.group("value") != null) {
+      enforceMaxVote = "MAX".equals(m.group("value"));
+      enforceMinVote = "MIN".equals(m.group("value"));
+      enforceIntegerVote = Optional.ofNullable(Ints.tryParse(m.group("value")));
+    } else {
+      enforceMaxVote = false;
+      enforceMinVote = false;
+      enforceIntegerVote = Optional.empty();
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    ProjectState projectState =
+        projectCache
+            .get(cd.project())
+            .orElseThrow(() -> new IllegalStateException("project absent " + cd.project()));
+    return cd.currentApprovals().stream()
+            .filter(psa -> filterPatchSetApproval(psa, projectState))
+            .map(PatchSetApproval::accountId)
+            .distinct()
+            .count()
+        >= numDistinctVotes;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  private boolean filterPatchSetApproval(PatchSetApproval psa, ProjectState projectState) {
+    if (!labelNames.contains(psa.label())) {
+      return false;
+    }
+
+    Optional<LabelType> labelType = projectState.getLabelTypes().byLabel(psa.labelId());
+    if (labelType.isEmpty()) {
+      // Label is not configured in this project
+      return false;
+    }
+
+    if (enforceMaxVote && psa.value() != labelType.get().getMaxPositive()) {
+      return false;
+    }
+    if (enforceMinVote && psa.value() != labelType.get().getMaxNegative()) {
+      return false;
+    }
+    if (enforceIntegerVote.isPresent() && psa.value() != enforceIntegerVote.get()) {
+      return false;
+    }
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index b2bc6aa..5662e4d 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -29,9 +29,10 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
 import java.util.Optional;
 
-public class EqualsLabelPredicate extends ChangeIndexPredicate {
+public class EqualsLabelPredicate extends ChangeIndexPostFilterPredicate {
   protected final ProjectCache projectCache;
   protected final PermissionBackend permissionBackend;
   protected final IdentifiedUser.GenericFactory userFactory;
@@ -58,7 +59,7 @@
       int expVal,
       Account.Id account,
       @Nullable Integer count) {
-    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account, count));
+    super(ChangeField.LABEL_SPEC, ChangeField.formatLabel(label, expVal, account, count));
     this.permissionBackend = args.permissionBackend;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
@@ -100,6 +101,7 @@
 
     boolean hasVote = false;
     int matchingVotes = 0;
+    StorageConstraint currentStorageConstraint = object.getStorageConstraint();
     object.setStorageConstraint(ChangeData.StorageConstraint.INDEX_PRIMARY_NOTEDB_SECONDARY);
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
@@ -109,7 +111,7 @@
         }
       }
     }
-
+    object.setStorageConstraint(currentStorageConstraint);
     if (!hasVote && expVal == 0) {
       return true;
     }
@@ -117,6 +119,7 @@
     return count == null ? matchingVotes >= 1 : matchingVotes == count;
   }
 
+  @Nullable
   protected static LabelType type(LabelTypes types, String toFind) {
     if (types.byLabel(toFind).isPresent()) {
       return types.byLabel(toFind).get();
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
index c16bc83..830df98 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
@@ -29,7 +29,7 @@
   }
 
   FileExtensionListPredicate(String value) {
-    super(ChangeField.ONLY_EXTENSIONS, clean(value));
+    super(ChangeField.ONLY_EXTENSIONS_SPEC, clean(value));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
index 39715cf..d15c5dc 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
@@ -26,7 +26,7 @@
   }
 
   FileExtensionPredicate(String value) {
-    super(ChangeField.EXTENSION, clean(value));
+    super(ChangeField.EXTENSION_SPEC, clean(value));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/GroupPredicate.java b/java/com/google/gerrit/server/query/change/GroupPredicate.java
index f470cf9..c4aba0d 100644
--- a/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -20,7 +20,7 @@
 
 public class GroupPredicate extends ChangeIndexPredicate {
   public GroupPredicate(String group) {
-    super(ChangeField.GROUP, group);
+    super(ChangeField.GROUP_SPEC, group);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/HasSubmoduleUpdatePredicate.java b/java/com/google/gerrit/server/query/change/HasSubmoduleUpdatePredicate.java
new file mode 100644
index 0000000..4ff40a4
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/HasSubmoduleUpdatePredicate.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder.SUBMODULE_UPDATE_HAS_ARG;
+
+import com.google.gerrit.entities.Patch.FileMode;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Submit requirement predicate that returns true if the diff of the latest patchset against the
+ * parent number identified by {@link #base} has a submodule modified file, that is, a .gitmodules
+ * or a git link file.
+ */
+public class HasSubmoduleUpdatePredicate extends SubmitRequirementPredicate {
+  private static final String GIT_MODULES_FILE = ".gitmodules";
+
+  private final DiffOperations diffOperations;
+  private final GitRepositoryManager repoManager;
+  private final int base;
+
+  public interface Factory {
+    HasSubmoduleUpdatePredicate create(int base);
+  }
+
+  @Inject
+  HasSubmoduleUpdatePredicate(
+      DiffOperations diffOperations, GitRepositoryManager repoManager, @Assisted int base) {
+    super("has", SUBMODULE_UPDATE_HAS_ARG);
+    this.diffOperations = diffOperations;
+    this.repoManager = repoManager;
+    this.base = base;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    try {
+      try (Repository repo = repoManager.openRepository(cd.project());
+          RevWalk rw = new RevWalk(repo)) {
+        RevCommit revCommit = rw.parseCommit(cd.currentPatchSet().commitId());
+        if (base > revCommit.getParentCount()) {
+          return false;
+        }
+      }
+      Map<String, FileDiffOutput> diffList =
+          diffOperations.listModifiedFilesAgainstParent(
+              cd.project(), cd.currentPatchSet().commitId(), base, DiffOptions.DEFAULTS);
+      return diffList.values().stream().anyMatch(HasSubmoduleUpdatePredicate::isGitLink);
+    } catch (DiffNotAvailableException e) {
+      throw new StorageException(
+          String.format(
+              "Failed to evaluate the diff for commit %s against parent number %d",
+              cd.currentPatchSet().commitId(), base),
+          e);
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Failed to open repo for project %s", cd.project()), e);
+    }
+  }
+
+  /**
+   * Return true if the modified file is a {@link #GIT_MODULES_FILE} or a git link regardless of if
+   * the modification type is add, remove or modify.
+   */
+  private static boolean isGitLink(FileDiffOutput fileDiffOutput) {
+    Optional<String> oldPath = fileDiffOutput.oldPath();
+    Optional<String> newPath = fileDiffOutput.newPath();
+    Optional<FileMode> oldMode = fileDiffOutput.oldMode();
+    Optional<FileMode> newMode = fileDiffOutput.newMode();
+
+    return (oldPath.isPresent() && oldPath.get().equals(GIT_MODULES_FILE))
+        || (newPath.isPresent() && newPath.get().equals(GIT_MODULES_FILE))
+        || (oldMode.isPresent() && oldMode.get().equals(FileMode.GITLINK))
+        || (newMode.isPresent() && newMode.get().equals(FileMode.GITLINK));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java b/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
index 312c04e..b6059f7 100644
--- a/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IntegerRangePredicate;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.QueryParseException;
@@ -22,7 +22,7 @@
 public abstract class IntegerRangeChangePredicate extends IntegerRangePredicate<ChangeData>
     implements Matchable<ChangeData> {
 
-  protected IntegerRangeChangePredicate(FieldDef<ChangeData, Integer> type, String value)
+  protected IntegerRangeChangePredicate(SchemaField<ChangeData, Integer> type, String value)
       throws QueryParseException {
     super(type, value);
   }
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 76ebd81..3edad69 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -22,9 +22,11 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -55,11 +57,6 @@
  * holding on to a single instance.
  */
 public class InternalChangeQuery extends InternalQuery<ChangeData, InternalChangeQuery> {
-  @FunctionalInterface
-  static interface ChangeIdPredicateFactory {
-    Predicate<ChangeData> create(Change.Id id);
-  }
-
   private static Predicate<ChangeData> ref(BranchNameKey branch) {
     return ChangePredicates.ref(branch.branch());
   }
@@ -83,9 +80,6 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory notesFactory;
 
-  // TODO(davido): Remove the below fields when support for legacy numeric fields is removed.
-  private final ChangeIdPredicateFactory predicateFactory;
-
   @Inject
   InternalChangeQuery(
       ChangeQueryProcessor queryProcessor,
@@ -96,11 +90,6 @@
     super(queryProcessor, indexes, indexConfig);
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
-    predicateFactory =
-        (id) ->
-            schema().useLegacyNumericFields()
-                ? ChangePredicates.id(id)
-                : ChangePredicates.idStr(id);
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -112,13 +101,14 @@
   }
 
   public List<ChangeData> byLegacyChangeId(Change.Id id) {
-    return query(predicateFactory.create(id));
+    return query(ChangePredicates.idStr(id));
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
-      preds.add(predicateFactory.create(id));
+      preds.add(ChangePredicates.idStr(id));
     }
     return query(or(preds));
   }
@@ -127,15 +117,6 @@
     return query(byBranchKeyPred(branch, key));
   }
 
-  public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key) {
-    return query(and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open()));
-  }
-
-  public static Predicate<ChangeData> byBranchKeyOpenPred(
-      Project.NameKey project, String branch, Change.Key key) {
-    return and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open());
-  }
-
   private static Predicate<ChangeData> byBranchKeyPred(BranchNameKey branch, Change.Key key) {
     return and(ref(branch), project(branch.project()), change(key));
   }
@@ -193,6 +174,7 @@
 
     List<ChangeNotes> notes =
         notesFactory.create(
+            repo,
             branch.project(),
             changeIds,
             cn -> {
@@ -282,21 +264,21 @@
     return query(ChangePredicates.submissionId(cs));
   }
 
-  private static Predicate<ChangeData> byProjectGroupsPredicate(
-      IndexConfig indexConfig, Project.NameKey project, Collection<String> groups) {
-    int n = indexConfig.maxTerms() - 1;
+  private static Predicate<ChangeData> byBranchGroupsPredicate(
+      IndexConfig indexConfig, BranchNameKey branchAndProject, Collection<String> groups) {
+    int n = indexConfig.maxTerms() - 2;
     checkArgument(groups.size() <= n, "cannot exceed %s groups", n);
     List<GroupPredicate> groupPredicates = new ArrayList<>(groups.size());
     for (String g : groups) {
       groupPredicates.add(new GroupPredicate(g));
     }
-    return and(project(project), or(groupPredicates));
+    return and(project(branchAndProject.project()), ref(branchAndProject), or(groupPredicates));
   }
 
-  public static List<ChangeData> byProjectGroups(
+  public static ImmutableList<ChangeData> byBranchGroups(
       Provider<InternalChangeQuery> queryProvider,
       IndexConfig indexConfig,
-      Project.NameKey project,
+      BranchNameKey branchAndProject,
       Collection<String> groups) {
     // These queries may be complex along multiple dimensions:
     //  * Many groups per change, if there are very many patch sets. This requires partitioning the
@@ -307,21 +289,22 @@
     // InternalChangeQuery is single-use.
 
     Supplier<InternalChangeQuery> querySupplier = () -> queryProvider.get().enforceVisibility(true);
-    int batchSize = indexConfig.maxTerms() - 1;
+    int batchSize = indexConfig.maxTerms() - 2;
     if (groups.size() <= batchSize) {
       return queryExhaustively(
-          querySupplier, byProjectGroupsPredicate(indexConfig, project, groups));
+          querySupplier, byBranchGroupsPredicate(indexConfig, branchAndProject, groups));
     }
     Set<Change.Id> seen = new HashSet<>();
-    List<ChangeData> result = new ArrayList<>();
+    ImmutableList.Builder<ChangeData> result = ImmutableList.builder();
     for (List<String> part : Iterables.partition(groups, batchSize)) {
       for (ChangeData cd :
-          queryExhaustively(querySupplier, byProjectGroupsPredicate(indexConfig, project, part))) {
+          queryExhaustively(
+              querySupplier, byBranchGroupsPredicate(indexConfig, branchAndProject, part))) {
         if (!seen.add(cd.getId())) {
           result.add(cd);
         }
       }
     }
-    return result;
+    return result.build();
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
new file mode 100644
index 0000000..aeee744
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.NotPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class IsSubmittablePredicate extends BooleanPredicate {
+  public IsSubmittablePredicate() {
+    super(ChangeField.IS_SUBMITTABLE_SPEC);
+  }
+
+  /**
+   * Rewrite the is:submittable predicate.
+   *
+   * <p>If we run a query with "is:submittable OR -is:submittable" the result should match all
+   * changes. In Lucene, we keep separate sub-indexes for open and closed changes. The Lucene
+   * backend inspects the input predicate and depending on all its child predicates decides if the
+   * query should run against the open sub-index, closed sub-index or both.
+   *
+   * <p>The "is:submittable" operator is implemented as:
+   *
+   * <p>issubmittable:1
+   *
+   * <p>But we want to exclude closed changes from being matched by this query. For the normal case,
+   * we rewrite the query as:
+   *
+   * <p>issubmittable:1 AND status:new
+   *
+   * <p>Hence Lucene will match the query against the open sub-index. For the negated case (i.e.
+   * "-is:submittable"), we cannot just negate the previous query because it would result in:
+   *
+   * <p>-(issubmittable:1 AND status:new)
+   *
+   * <p>Lucene will conclude that it should look for changes that are <b>not</b> new and hence will
+   * run the query against the closed sub-index, not matching with changes that are open but not
+   * submittable. For this case, we need to rewrite the query to match with closed changes <b>or</b>
+   * changes that are not submittable.
+   */
+  public static Predicate<ChangeData> rewrite(Predicate<ChangeData> in) {
+    if (in instanceof IsSubmittablePredicate) {
+      return Predicate.and(
+          new BooleanPredicate(ChangeField.IS_SUBMITTABLE_SPEC), ChangeStatusPredicate.open());
+    }
+    if (in instanceof NotPredicate && in.getChild(0) instanceof IsSubmittablePredicate) {
+      return Predicate.or(
+          Predicate.not(new BooleanPredicate(ChangeField.IS_SUBMITTABLE_SPEC)),
+          ChangeStatusPredicate.closed());
+    }
+    return in;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java b/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
index 27309af..ffa29ba 100644
--- a/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
@@ -23,11 +23,11 @@
   }
 
   public IsUnresolvedPredicate(String value) throws QueryParseException {
-    super(ChangeField.UNRESOLVED_COMMENT_COUNT, value);
+    super(ChangeField.UNRESOLVED_COMMENT_COUNT_SPEC, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) {
-    return ChangeField.UNRESOLVED_COMMENT_COUNT.get(changeData);
+    return ChangeField.UNRESOLVED_COMMENT_COUNT_SPEC.get(changeData);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 2e09075..2a5a47d 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -214,6 +214,8 @@
       case LESS_EQUAL:
         result.add(count);
         break;
+      case GREATER:
+      case LESS:
       default:
         break;
     }
@@ -226,6 +228,7 @@
       case LESS_EQUAL:
         IntStream.range(0, count).forEach(result::add);
         break;
+      case EQUAL:
       default:
         break;
     }
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
index 5a81ca1..32a8fdf 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
@@ -39,7 +39,7 @@
       Account.Id account,
       @Nullable Integer count) {
     super(
-        ChangeField.LABEL,
+        ChangeField.LABEL_SPEC,
         ChangeField.formatLabel(
             magicLabelVote.label(), magicLabelVote.value().name(), account, count));
     this.account = account;
@@ -99,6 +99,7 @@
     return new EqualsLabelPredicate(args, label, value, account, count);
   }
 
+  @Nullable
   protected static LabelType type(LabelTypes types, String toFind) {
     if (types.byLabel(toFind).isPresent()) {
       return types.byLabel(toFind).get();
diff --git a/java/com/google/gerrit/server/query/change/OrSource.java b/java/com/google/gerrit/server/query/change/OrSource.java
index 983d9b4..f2de536 100644
--- a/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/java/com/google/gerrit/server/query/change/OrSource.java
@@ -18,13 +18,11 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.LazyResultSet;
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.ResultSet;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
@@ -36,23 +34,22 @@
 
   public OrSource(Collection<? extends Predicate<ChangeData>> that) {
     super(that);
+    Optional<Predicate<ChangeData>> nonChangeDataSource =
+        getChildren().stream().filter(p -> !(p instanceof ChangeDataSource)).findAny();
+    if (nonChangeDataSource.isPresent()) {
+      throw new IllegalArgumentException("No ChangeDataSource: " + nonChangeDataSource.get());
+    }
   }
 
   @Override
   public ResultSet<ChangeData> read() {
-    Optional<Predicate<ChangeData>> nonChangeDataSource =
-        getChildren().stream().filter(p -> !(p instanceof ChangeDataSource)).findAny();
-    if (nonChangeDataSource.isPresent()) {
-      throw new StorageException("No ChangeDataSource: " + nonChangeDataSource.get());
-    }
-
     // ResultSets are lazy. Calling #read here first and then dealing with ResultSets only when
     // requested allows the index to run asynchronous queries.
     List<ResultSet<ChangeData>> results =
         getChildren().stream().map(p -> ((ChangeDataSource) p).read()).collect(toImmutableList());
     return new LazyResultSet<>(
         () -> {
-          List<ChangeData> r = new ArrayList<>();
+          ImmutableList.Builder<ChangeData> r = ImmutableList.builder();
           Set<Change.Id> have = new HashSet<>();
           for (ResultSet<ChangeData> resultSet : results) {
             for (ChangeData result : resultSet) {
@@ -61,7 +58,7 @@
               }
             }
           }
-          return ImmutableList.copyOf(r);
+          return r.build();
         });
   }
 
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index 1b6dc62..716cf10 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -18,6 +18,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelTypes;
@@ -28,6 +29,8 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.account.AccountAttributeLoader;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.PatchSetAttribute;
@@ -86,6 +89,7 @@
   private final EventFactory eventFactory;
   private final TrackingFooters trackingFooters;
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+  private final AccountAttributeLoader.Factory accountAttributeLoaderFactory;
 
   private OutputFormat outputFormat = OutputFormat.TEXT;
   private boolean includePatchSets;
@@ -110,13 +114,15 @@
       ChangeQueryProcessor queryProcessor,
       EventFactory eventFactory,
       TrackingFooters trackingFooters,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
+      AccountAttributeLoader.Factory accountAttributeLoaderFactory) {
     this.repoManager = repoManager;
     this.queryBuilder = queryBuilder;
     this.queryProcessor = queryProcessor;
     this.eventFactory = eventFactory;
     this.trackingFooters = trackingFooters;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+    this.accountAttributeLoaderFactory = accountAttributeLoaderFactory;
   }
 
   void setLimit(int n) {
@@ -205,7 +211,7 @@
         return;
       }
 
-      try {
+      try (PerThreadCache ignored = PerThreadCache.create()) {
         final QueryStatsAttribute stats = new QueryStatsAttribute();
         stats.runTimeMilliseconds = TimeUtil.nowMs();
 
@@ -214,9 +220,13 @@
         QueryResult<ChangeData> results = queryProcessor.query(queryBuilder.parse(queryString));
         pluginInfosByChange = queryProcessor.createPluginDefinedInfos(results.entities());
         try {
+          AccountAttributeLoader accountLoader = accountAttributeLoaderFactory.create();
+          List<ChangeAttribute> changeAttributes = new ArrayList<>();
           for (ChangeData d : results.entities()) {
-            show(buildChangeAttribute(d, repos, revWalks));
+            changeAttributes.add(buildChangeAttribute(d, repos, revWalks, accountLoader));
           }
+          accountLoader.fill();
+          changeAttributes.forEach(c -> show(c));
         } finally {
           closeAll(revWalks.values(), repos.values());
         }
@@ -247,10 +257,14 @@
   }
 
   private ChangeAttribute buildChangeAttribute(
-      ChangeData d, Map<Project.NameKey, Repository> repos, Map<Project.NameKey, RevWalk> revWalks)
+      ChangeData d,
+      Map<Project.NameKey, Repository> repos,
+      Map<Project.NameKey, RevWalk> revWalks,
+      AccountAttributeLoader accountLoader)
       throws IOException {
     LabelTypes labelTypes = d.getLabelTypes();
-    ChangeAttribute c = eventFactory.asChangeAttribute(d.change(), d.notes());
+    ChangeAttribute c = eventFactory.asChangeAttribute(d.change(), accountLoader);
+    c.hashtags = Lists.newArrayList(d.hashtags());
     eventFactory.extend(c, d.change());
 
     if (!trackingFooters.isEmpty()) {
@@ -258,13 +272,14 @@
     }
 
     if (includeAllReviewers) {
-      eventFactory.addAllReviewers(c, d.notes());
+      eventFactory.addAllReviewers(c, d.notes(), accountLoader);
     }
 
     if (includeSubmitRecords) {
       SubmitRuleOptions options =
           SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
-      eventFactory.addSubmitRecords(c, submitRuleEvaluatorFactory.create(options).evaluate(d));
+      eventFactory.addSubmitRecords(
+          c, submitRuleEvaluatorFactory.create(options).evaluate(d), accountLoader);
     }
 
     if (includeCommitMessage) {
@@ -292,26 +307,28 @@
           includeApprovals ? d.approvals().asMap() : null,
           includeFiles,
           d.change(),
-          labelTypes);
+          labelTypes,
+          accountLoader);
     }
 
     if (includeCurrentPatchSet) {
       PatchSet current = d.currentPatchSet();
       if (current != null) {
         c.currentPatchSet = eventFactory.asPatchSetAttribute(rw, d.change(), current);
-        eventFactory.addApprovals(c.currentPatchSet, d.currentApprovals(), labelTypes);
+        eventFactory.addApprovals(
+            c.currentPatchSet, d.currentApprovals(), labelTypes, accountLoader);
 
         if (includeFiles) {
           eventFactory.addPatchSetFileNames(c.currentPatchSet, d.change(), d.currentPatchSet());
         }
         if (includeComments) {
-          eventFactory.addPatchSetComments(c.currentPatchSet, d.publishedComments());
+          eventFactory.addPatchSetComments(c.currentPatchSet, d.publishedComments(), accountLoader);
         }
       }
     }
 
     if (includeComments) {
-      eventFactory.addComments(c, d.messages());
+      eventFactory.addComments(c, d.messages(), accountLoader);
       if (includePatchSets) {
         eventFactory.addPatchSets(
             rw,
@@ -320,9 +337,10 @@
             includeApprovals ? d.approvals().asMap() : null,
             includeFiles,
             d.change(),
-            labelTypes);
+            labelTypes,
+            accountLoader);
         for (PatchSetAttribute attribute : c.patchSets) {
-          eventFactory.addPatchSetComments(attribute, d.publishedComments());
+          eventFactory.addPatchSetComments(attribute, d.publishedComments(), accountLoader);
         }
       }
     }
diff --git a/java/com/google/gerrit/server/query/change/ParentOfPredicate.java b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
deleted file mode 100644
index e48d586..0000000
--- a/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.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/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 4a54c03..536aae2 100644
--- a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ProjectInfo;
@@ -23,9 +24,6 @@
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
 import java.util.Optional;
 
 public class ParentProjectPredicate extends OrPredicate<ChangeData> {
@@ -39,14 +37,14 @@
     this.value = value;
   }
 
-  protected static List<Predicate<ChangeData>> predicates(
+  protected static ImmutableList<Predicate<ChangeData>> predicates(
       ProjectCache projectCache, ChildProjects childProjects, String value) {
     Optional<ProjectState> projectState = projectCache.get(Project.nameKey(value));
     if (!projectState.isPresent()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
-    List<Predicate<ChangeData>> r = new ArrayList<>();
+    ImmutableList.Builder<Predicate<ChangeData>> r = ImmutableList.builder();
     r.add(ChangePredicates.project(projectState.get().getNameKey()));
     try {
       for (ProjectInfo p : childProjects.list(projectState.get().getNameKey())) {
@@ -55,7 +53,7 @@
     } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log("cannot check permissions to expand child projects");
     }
-    return r;
+    return r.build();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java
index 9f0dffb..ebe4390 100644
--- a/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -50,7 +50,7 @@
     Operator(String op) {
       this.op = op;
     }
-  };
+  }
 
   @AutoValue
   public abstract static class ValOp {
diff --git a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java b/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
new file mode 100644
index 0000000..22891bc
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+/**
+ * A submit requirement predicate that matches with changes having the author email's address
+ * matching a specific regular expression pattern.
+ */
+public class RegexAuthorEmailPredicate extends SubmitRequirementPredicate {
+  protected final RunAutomaton authorEmailPattern;
+
+  public RegexAuthorEmailPredicate(String pattern) throws QueryParseException {
+    super("authoremail", pattern);
+
+    if (pattern.startsWith("^")) {
+      pattern = pattern.substring(1);
+    }
+
+    if (pattern.endsWith("$") && !pattern.endsWith("\\$")) {
+      pattern = pattern.substring(0, pattern.length() - 1);
+    }
+
+    try {
+      this.authorEmailPattern = new RunAutomaton(new RegExp(pattern).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(String.format("invalid regular expression: %s", pattern), e);
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return authorEmailPattern.run(cd.getAuthor().getEmailAddress());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java b/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
index 1787c76..315785c 100644
--- a/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
@@ -22,7 +22,7 @@
   protected final RunAutomaton pattern;
 
   public RegexDirectoryPredicate(String re) {
-    super(ChangeField.DIRECTORY, re);
+    super(ChangeField.DIRECTORY_SPEC, re);
 
     if (re.startsWith("^")) {
       re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
index 24efa6a..f62780a 100644
--- a/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.server.index.change.ChangeField.HASHTAG;
+import static com.google.gerrit.server.index.change.ChangeField.HASHTAG_SPEC;
 
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
@@ -23,7 +23,7 @@
   protected final RunAutomaton pattern;
 
   public RegexHashtagPredicate(String re) {
-    super(HASHTAG, re);
+    super(HASHTAG_SPEC, re);
 
     if (re.startsWith("^")) {
       re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java b/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java
new file mode 100644
index 0000000..a2b9ad8
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexMessagePredicate.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.index.change.ChangeField;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+public class RegexMessagePredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
+
+  public RegexMessagePredicate(String re) throws QueryParseException {
+    super(ChangeField.COMMIT_MESSAGE_EXACT, re);
+
+    if (re.startsWith("^")) {
+      re = re.substring(1);
+    }
+
+    if (re.endsWith("$") && !re.endsWith("\\$")) {
+      re = re.substring(0, re.length() - 1);
+    }
+
+    try {
+      this.pattern = new RunAutomaton(new RegExp(re).toAutomaton());
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(String.format("invalid regular expression: %s", re), e);
+    }
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    return pattern.run(cd.commitMessage());
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 4c3c04c..9368047 100644
--- a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -19,7 +19,7 @@
 
 public class RegexPathPredicate extends ChangeRegexPredicate {
   public RegexPathPredicate(String re) {
-    super(ChangeField.PATH, re);
+    super(ChangeField.PATH_SPEC, re);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index bbdfc66..a51dcc4 100644
--- a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -24,7 +24,7 @@
   protected final RunAutomaton pattern;
 
   public RegexProjectPredicate(String re) {
-    super(ChangeField.PROJECT, re);
+    super(ChangeField.PROJECT_SPEC, re);
 
     if (re.startsWith("^")) {
       re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index b2dba72..cc556ba 100644
--- a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -24,7 +24,7 @@
   protected final RunAutomaton pattern;
 
   public RegexRefPredicate(String re) throws QueryParseException {
-    super(ChangeField.REF, re);
+    super(ChangeField.REF_SPEC, re);
 
     if (re.startsWith("^")) {
       re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 142e956..b355afb 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -17,11 +17,12 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.index.query.HasCardinality;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 
-public class ReviewerPredicate extends ChangeIndexPredicate {
+public class ReviewerPredicate extends ChangeIndexPredicate implements HasCardinality {
   protected static Predicate<ChangeData> forState(Account.Id id, ReviewerStateInternal state) {
     checkArgument(state != ReviewerStateInternal.REMOVED, "can't query by removed reviewer");
     return new ReviewerPredicate(state, id);
@@ -39,7 +40,7 @@
   protected final Account.Id id;
 
   private ReviewerPredicate(ReviewerStateInternal state, Account.Id id) {
-    super(ChangeField.REVIEWER, ChangeField.getReviewerFieldValue(state, id));
+    super(ChangeField.REVIEWER_SPEC, ChangeField.getReviewerFieldValue(state, id));
     this.state = state;
     this.id = id;
   }
@@ -52,4 +53,9 @@
   public boolean match(ChangeData cd) {
     return cd.reviewers().asTable().get(state, id) != null;
   }
+
+  @Override
+  public int getCardinality() {
+    return 5000;
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/StarPredicate.java b/java/com/google/gerrit/server/query/change/StarPredicate.java
deleted file mode 100644
index 4a8fe43..0000000
--- a/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class StarPredicate extends ChangeIndexPredicate {
-  protected final Account.Id accountId;
-  protected final String label;
-
-  public StarPredicate(Account.Id accountId, String label) {
-    super(ChangeField.STAR, StarredChangesUtil.StarField.create(accountId, label).toString());
-    this.accountId = accountId;
-    this.label = label;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.stars().get(accountId).contains(label);
-  }
-
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_STAR + ":" + label;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index ecddbb6..1ea6c41 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -36,7 +36,7 @@
   }
 
   private SubmitRecordPredicate(String value) {
-    super(ChangeField.SUBMIT_RECORD, value);
+    super(ChangeField.SUBMIT_RECORD_SPEC, value);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index cd0fee3..3f4c158 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -14,9 +14,19 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.common.base.Splitter;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.FileEditsPredicate;
+import com.google.gerrit.server.query.FileEditsPredicate.FileEditsArgs;
 import com.google.inject.Inject;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
 
 /**
  * A query builder for submit requirement expressions that includes all {@link ChangeQueryBuilder}
@@ -29,14 +39,133 @@
   private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> def =
       new QueryBuilder.Definition<>(SubmitRequirementChangeQueryBuilder.class);
 
+  private final DistinctVotersPredicate.Factory distinctVotersPredicateFactory;
+  private final HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory;
+
+  /**
+   * Regular expression for the {@link #file(String)} operator. Field value is of the form:
+   *
+   * <p>'$fileRegex',withDiffContaining='$contentRegex'
+   *
+   * <p>Both $fileRegex and $contentRegex may contain escaped single or double quotes.
+   */
+  private static final Pattern FILE_EDITS_PATTERN =
+      Pattern.compile("'((?:(?:\\\\')|(?:[^']))*)',withDiffContaining='((?:(?:\\\\')|(?:[^']))*)'");
+
+  public static final String SUBMODULE_UPDATE_HAS_ARG = "submodule-update";
+  private static final Splitter SUBMODULE_UPDATE_SPLITTER = Splitter.on(",");
+
+  private final FileEditsPredicate.Factory fileEditsPredicateFactory;
+
   @Inject
-  SubmitRequirementChangeQueryBuilder(Arguments args) {
+  SubmitRequirementChangeQueryBuilder(
+      Arguments args,
+      DistinctVotersPredicate.Factory distinctVotersPredicateFactory,
+      FileEditsPredicate.Factory fileEditsPredicateFactory,
+      HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory) {
     super(def, args);
+    this.distinctVotersPredicateFactory = distinctVotersPredicateFactory;
+    this.fileEditsPredicateFactory = fileEditsPredicateFactory;
+    this.hasSubmoduleUpdateFactory = hasSubmoduleUpdateFactory;
   }
 
   @Override
-  protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator) {
+  protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String operator) {
     // Submit requirements don't rely on the index, so they can be used regardless of index schema
     // version.
   }
+
+  @Override
+  public Predicate<ChangeData> is(String value) throws QueryParseException {
+    if ("submittable".equalsIgnoreCase(value)) {
+      throw new QueryParseException(
+          String.format(
+              "Operator 'is:submittable' cannot be used in submit requirement expressions."));
+    }
+    if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) {
+      return new ConstantPredicate(value);
+    }
+    return super.is(value);
+  }
+
+  @Override
+  public Predicate<ChangeData> has(String value) throws QueryParseException {
+    if (value.toLowerCase(Locale.US).startsWith(SUBMODULE_UPDATE_HAS_ARG)) {
+      List<String> args = SUBMODULE_UPDATE_SPLITTER.splitToList(value);
+      if (args.size() > 2) {
+        throw error(
+            String.format(
+                "wrong number of arguments for the has:%s operator", SUBMODULE_UPDATE_HAS_ARG));
+      } else if (args.size() == 2) {
+        List<String> baseValue = Splitter.on("=").splitToList(args.get(1));
+        if (baseValue.size() != 2) {
+          throw error("unexpected base value format");
+        }
+        if (!baseValue.get(0).toLowerCase(Locale.US).equals("base")) {
+          throw error("unexpected base value format");
+        }
+        try {
+          int base = Integer.parseInt(baseValue.get(1));
+          return hasSubmoduleUpdateFactory.create(base);
+        } catch (NumberFormatException e) {
+          throw error(
+              String.format(
+                  "failed to parse the parent number %s: %s", baseValue.get(1), e.getMessage()));
+        }
+      } else {
+        return hasSubmoduleUpdateFactory.create(0);
+      }
+    }
+    return super.has(value);
+  }
+
+  @Operator
+  public Predicate<ChangeData> authoremail(String who) throws QueryParseException {
+    return new RegexAuthorEmailPredicate(who);
+  }
+
+  @Operator
+  public Predicate<ChangeData> distinctvoters(String value) throws QueryParseException {
+    return distinctVotersPredicateFactory.create(value);
+  }
+
+  /**
+   * A SR operator that can match with file path and content pattern. The value should be of the
+   * form:
+   *
+   * <p>file:"'$filePattern',withDiffContaining='$contentPattern'"
+   *
+   * <p>The operator matches with changes that have their latest PS vs. base diff containing a file
+   * path matching the {@code filePattern} with an edit (added, deleted, modified) matching the
+   * {@code contentPattern}. {@code filePattern} and {@code contentPattern} can start with "^" to
+   * use regular expression matching.
+   *
+   * <p>If the specified value does not match this form, we fall back to the operator's
+   * implementation in {@link ChangeQueryBuilder}.
+   */
+  @Override
+  public Predicate<ChangeData> file(String value) throws QueryParseException {
+    Matcher matcher = FILE_EDITS_PATTERN.matcher(value);
+    if (!matcher.find()) {
+      return super.file(value);
+    }
+    String filePattern = matcher.group(1);
+    String contentPattern = matcher.group(2);
+    if (filePattern.startsWith("^")) {
+      validateRegularExpression(filePattern, "Invalid file pattern.");
+    }
+    if (contentPattern.startsWith("^")) {
+      validateRegularExpression(contentPattern, "Invalid content pattern.");
+    }
+    return fileEditsPredicateFactory.create(FileEditsArgs.create(filePattern, contentPattern));
+  }
+
+  private static void validateRegularExpression(String pattern, String errorMessage)
+      throws QueryParseException {
+    try {
+      Pattern.compile(pattern);
+    } catch (PatternSyntaxException e) {
+      throw new QueryParseException(errorMessage, e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index 060a92e..e543ac3 100644
--- a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -21,7 +21,7 @@
   protected final SubmitRecord.Status status;
 
   public SubmittablePredicate(SubmitRecord.Status status) {
-    super(ChangeField.SUBMIT_RECORD, status.name());
+    super(ChangeField.SUBMIT_RECORD_SPEC, status.name());
     this.status = status;
   }
 
diff --git a/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java b/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
index abbd0c9..0b2d32d 100644
--- a/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.TimestampRangePredicate;
 import java.sql.Timestamp;
@@ -22,7 +22,7 @@
 public abstract class TimestampRangeChangePredicate extends TimestampRangePredicate<ChangeData>
     implements Matchable<ChangeData> {
   protected TimestampRangeChangePredicate(
-      FieldDef<ChangeData, Timestamp> def, String name, String value) {
+      SchemaField<ChangeData, Timestamp> def, String name, String value) {
     super(def, name, value);
   }
 
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 8a2dc8d..e742cba 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -17,7 +17,7 @@
 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.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.group.GroupField;
@@ -26,51 +26,51 @@
 /** Utility class to create predicates for group index queries. */
 public class GroupPredicates {
   public static Predicate<InternalGroup> id(AccountGroup.Id groupId) {
-    return new GroupPredicate(GroupField.ID, groupId.toString());
+    return new GroupPredicate(GroupField.ID_FIELD_SPEC, groupId.toString());
   }
 
   public static Predicate<InternalGroup> uuid(AccountGroup.UUID uuid) {
-    return new GroupPredicate(GroupField.UUID, GroupQueryBuilder.FIELD_UUID, uuid.get());
+    return new GroupPredicate(GroupField.UUID_FIELD_SPEC, GroupQueryBuilder.FIELD_UUID, uuid.get());
   }
 
   public static Predicate<InternalGroup> description(String description) {
     return new GroupPredicate(
-        GroupField.DESCRIPTION, GroupQueryBuilder.FIELD_DESCRIPTION, description);
+        GroupField.DESCRIPTION_SPEC, GroupQueryBuilder.FIELD_DESCRIPTION, description);
   }
 
   public static Predicate<InternalGroup> inname(String name) {
     return new GroupPredicate(
-        GroupField.NAME_PART, GroupQueryBuilder.FIELD_INNAME, name.toLowerCase(Locale.US));
+        GroupField.NAME_PART_SPEC, GroupQueryBuilder.FIELD_INNAME, name.toLowerCase(Locale.US));
   }
 
   public static Predicate<InternalGroup> name(String name) {
-    return new GroupPredicate(GroupField.NAME, name);
+    return new GroupPredicate(GroupField.NAME_SPEC, name);
   }
 
   public static Predicate<InternalGroup> owner(AccountGroup.UUID ownerUuid) {
     return new GroupPredicate(
-        GroupField.OWNER_UUID, GroupQueryBuilder.FIELD_OWNER, ownerUuid.get());
+        GroupField.OWNER_UUID_SPEC, GroupQueryBuilder.FIELD_OWNER, ownerUuid.get());
   }
 
   public static Predicate<InternalGroup> isVisibleToAll() {
-    return new GroupPredicate(GroupField.IS_VISIBLE_TO_ALL, "1");
+    return new GroupPredicate(GroupField.IS_VISIBLE_TO_ALL_SPEC, "1");
   }
 
   public static Predicate<InternalGroup> member(Account.Id memberId) {
-    return new GroupPredicate(GroupField.MEMBER, memberId.toString());
+    return new GroupPredicate(GroupField.MEMBER_SPEC, memberId.toString());
   }
 
   public static Predicate<InternalGroup> subgroup(AccountGroup.UUID subgroupUuid) {
-    return new GroupPredicate(GroupField.SUBGROUP, subgroupUuid.get());
+    return new GroupPredicate(GroupField.SUBGROUP_SPEC, subgroupUuid.get());
   }
 
   /** Predicate that is mapped to a field in the group index. */
   static class GroupPredicate extends IndexPredicate<InternalGroup> {
-    GroupPredicate(FieldDef<InternalGroup, ?> def, String value) {
+    GroupPredicate(SchemaField<InternalGroup, ?> def, String value) {
       super(def, value);
     }
 
-    GroupPredicate(FieldDef<InternalGroup, ?> def, String name, String value) {
+    GroupPredicate(SchemaField<InternalGroup, ?> def, String name, String value) {
       super(def, name, value);
     }
   }
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
index 9e56807..c6683fa 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexRewriter;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -42,6 +43,8 @@
 public class GroupQueryProcessor extends QueryProcessor<InternalGroup> {
   private final Provider<CurrentUser> userProvider;
   private final GroupControl.GenericFactory groupControlFactory;
+  private final Sequences sequences;
+  private final IndexConfig indexConfig;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -58,7 +61,8 @@
       IndexConfig indexConfig,
       GroupIndexCollection indexes,
       GroupIndexRewriter rewriter,
-      GroupControl.GenericFactory groupControlFactory) {
+      GroupControl.GenericFactory groupControlFactory,
+      Sequences sequences) {
     super(
         metricMaker,
         GroupSchemaDefinitions.INSTANCE,
@@ -69,16 +73,31 @@
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.userProvider = userProvider;
     this.groupControlFactory = groupControlFactory;
+    this.sequences = sequences;
+    this.indexConfig = indexConfig;
   }
 
   @Override
   protected Predicate<InternalGroup> enforceVisibility(Predicate<InternalGroup> pred) {
     return new AndSource<>(
-        pred, new GroupIsVisibleToPredicate(groupControlFactory, userProvider.get()), start);
+        pred,
+        new GroupIsVisibleToPredicate(groupControlFactory, userProvider.get()),
+        start,
+        indexConfig);
   }
 
   @Override
   protected String formatForLogging(InternalGroup internalGroup) {
     return internalGroup.getGroupUUID().get();
   }
+
+  @Override
+  protected int getIndexSize() {
+    return sequences.lastGroupId();
+  }
+
+  @Override
+  protected int getBatchSize() {
+    return sequences.groupBatchSize();
+  }
 }
diff --git a/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
index 8b4048f..a7b0743 100644
--- a/java/com/google/gerrit/server/query/project/ProjectPredicates.java
+++ b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -25,23 +25,23 @@
 /** Utility class to create predicates for project index queries. */
 public class ProjectPredicates {
   public static Predicate<ProjectData> name(Project.NameKey nameKey) {
-    return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+    return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
   }
 
   public static Predicate<ProjectData> parent(Project.NameKey parentNameKey) {
-    return new ProjectPredicate(ProjectField.PARENT_NAME, parentNameKey.get());
+    return new ProjectPredicate(ProjectField.PARENT_NAME_SPEC, parentNameKey.get());
   }
 
   public static Predicate<ProjectData> inname(String name) {
-    return new ProjectPredicate(ProjectField.NAME_PART, name.toLowerCase(Locale.US));
+    return new ProjectPredicate(ProjectField.NAME_PART_SPEC, name.toLowerCase(Locale.US));
   }
 
   public static Predicate<ProjectData> description(String description) {
-    return new ProjectPredicate(ProjectField.DESCRIPTION, description);
+    return new ProjectPredicate(ProjectField.DESCRIPTION_SPEC, description);
   }
 
   public static Predicate<ProjectData> state(ProjectState state) {
-    return new ProjectPredicate(ProjectField.STATE, state.name());
+    return new ProjectPredicate(ProjectField.STATE_SPEC, state.name());
   }
 
   private ProjectPredicates() {}
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index d234546..edb12ec 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2023 The Android Open 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,93 +14,21 @@
 
 package com.google.gerrit.server.query.project;
 
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.index.project.ProjectData;
-import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.inject.Inject;
 import java.util.List;
 
-/** Parses a query string meant to be applied to project objects. */
-public class ProjectQueryBuilder extends QueryBuilder<ProjectData, ProjectQueryBuilder> {
-  public static final String FIELD_LIMIT = "limit";
+/**
+ * Provides methods required for parsing projects queries.
+ *
+ * <p>Internally (at google), this interface has a different implementation, comparing to upstream.
+ */
+public interface ProjectQueryBuilder {
+  String FIELD_LIMIT = "limit";
 
-  private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilder> mydef =
-      new QueryBuilder.Definition<>(ProjectQueryBuilder.class);
-
-  @Inject
-  ProjectQueryBuilder() {
-    super(mydef, null);
-  }
-
-  @Operator
-  public Predicate<ProjectData> name(String name) {
-    return ProjectPredicates.name(Project.nameKey(name));
-  }
-
-  @Operator
-  public Predicate<ProjectData> parent(String parentName) {
-    return ProjectPredicates.parent(Project.nameKey(parentName));
-  }
-
-  @Operator
-  public Predicate<ProjectData> inname(String namePart) {
-    if (namePart.isEmpty()) {
-      return name(namePart);
-    }
-    return ProjectPredicates.inname(namePart);
-  }
-
-  @Operator
-  public Predicate<ProjectData> description(String description) throws QueryParseException {
-    if (Strings.isNullOrEmpty(description)) {
-      throw error("description operator requires a value");
-    }
-
-    return ProjectPredicates.description(description);
-  }
-
-  @Operator
-  public Predicate<ProjectData> state(String state) throws QueryParseException {
-    if (Strings.isNullOrEmpty(state)) {
-      throw error("state operator requires a value");
-    }
-    ProjectState parsedState;
-    try {
-      parsedState = ProjectState.valueOf(state.replace('-', '_').toUpperCase());
-    } catch (IllegalArgumentException e) {
-      throw error("state operator must be either 'active' or 'read-only'", e);
-    }
-    if (parsedState == ProjectState.HIDDEN) {
-      throw error("state operator must be either 'active' or 'read-only'");
-    }
-    return ProjectPredicates.state(parsedState);
-  }
-
-  @Override
-  protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
-    // Adapt the capacity of this list when adding more default predicates.
-    List<Predicate<ProjectData>> preds = Lists.newArrayListWithCapacity(3);
-    preds.add(name(query));
-    preds.add(inname(query));
-    if (!Strings.isNullOrEmpty(query)) {
-      preds.add(description(query));
-    }
-    return Predicate.or(preds);
-  }
-
-  @Operator
-  public Predicate<ProjectData> limit(String query) throws QueryParseException {
-    Integer limit = Ints.tryParse(query);
-    if (limit == null) {
-      throw error("Invalid limit: " + query);
-    }
-    return new LimitPredicate<>(FIELD_LIMIT, limit);
-  }
+  /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(String)}. */
+  Predicate<ProjectData> parse(String query) throws QueryParseException;
+  /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(List<String>)}. */
+  List<Predicate<ProjectData>> parse(List<String> queries) throws QueryParseException;
 }
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
new file mode 100644
index 0000000..f7135982
--- /dev/null
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.inject.Inject;
+import java.util.List;
+
+/** Parses a query string meant to be applied to project objects. */
+public class ProjectQueryBuilderImpl extends QueryBuilder<ProjectData, ProjectQueryBuilderImpl>
+    implements ProjectQueryBuilder {
+  private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilderImpl> mydef =
+      new QueryBuilder.Definition<>(ProjectQueryBuilderImpl.class);
+
+  @Inject
+  ProjectQueryBuilderImpl() {
+    super(mydef, null);
+  }
+
+  @Operator
+  public Predicate<ProjectData> name(String name) {
+    return ProjectPredicates.name(Project.nameKey(name));
+  }
+
+  @Operator
+  public Predicate<ProjectData> parent(String parentName) {
+    return ProjectPredicates.parent(Project.nameKey(parentName));
+  }
+
+  @Operator
+  public Predicate<ProjectData> inname(String namePart) {
+    if (namePart.isEmpty()) {
+      return name(namePart);
+    }
+    return ProjectPredicates.inname(namePart);
+  }
+
+  @Operator
+  public Predicate<ProjectData> description(String description) throws QueryParseException {
+    if (Strings.isNullOrEmpty(description)) {
+      throw error("description operator requires a value");
+    }
+
+    return ProjectPredicates.description(description);
+  }
+
+  @Operator
+  public Predicate<ProjectData> state(String state) throws QueryParseException {
+    if (Strings.isNullOrEmpty(state)) {
+      throw error("state operator requires a value");
+    }
+    ProjectState parsedState;
+    try {
+      parsedState = ProjectState.valueOf(state.replace('-', '_').toUpperCase());
+    } catch (IllegalArgumentException e) {
+      throw error("state operator must be either 'active' or 'read-only'", e);
+    }
+    if (parsedState == ProjectState.HIDDEN) {
+      throw error("state operator must be either 'active' or 'read-only'");
+    }
+    return ProjectPredicates.state(parsedState);
+  }
+
+  @Override
+  protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
+    // Adapt the capacity of this list when adding more default predicates.
+    List<Predicate<ProjectData>> preds = Lists.newArrayListWithCapacity(3);
+    preds.add(name(query));
+    preds.add(inname(query));
+    if (!Strings.isNullOrEmpty(query)) {
+      preds.add(description(query));
+    }
+    return Predicate.or(preds);
+  }
+
+  @Operator
+  public Predicate<ProjectData> limit(String query) throws QueryParseException {
+    Integer limit = Ints.tryParse(query);
+    if (limit == null) {
+      throw error("Invalid limit: " + query);
+    }
+    return new LimitPredicate<>(FIELD_LIMIT, limit);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
index 8e6d8a1..6dafa92 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
@@ -44,6 +45,8 @@
 public class ProjectQueryProcessor extends QueryProcessor<ProjectData> {
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> userProvider;
+  private final ProjectCache projectCache;
+  private final IndexConfig indexConfig;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -60,7 +63,8 @@
       IndexConfig indexConfig,
       ProjectIndexCollection indexes,
       ProjectIndexRewriter rewriter,
-      PermissionBackend permissionBackend) {
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache) {
     super(
         metricMaker,
         ProjectSchemaDefinitions.INSTANCE,
@@ -71,16 +75,31 @@
         () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.permissionBackend = permissionBackend;
     this.userProvider = userProvider;
+    this.projectCache = projectCache;
+    this.indexConfig = indexConfig;
   }
 
   @Override
   protected Predicate<ProjectData> enforceVisibility(Predicate<ProjectData> pred) {
     return new AndSource<>(
-        pred, new ProjectIsVisibleToPredicate(permissionBackend, userProvider.get()), start);
+        pred,
+        new ProjectIsVisibleToPredicate(permissionBackend, userProvider.get()),
+        start,
+        indexConfig);
   }
 
   @Override
   protected String formatForLogging(ProjectData projectData) {
     return projectData.getProject().getName();
   }
+
+  @Override
+  protected int getIndexSize() {
+    return projectCache.all().size();
+  }
+
+  @Override
+  protected int getBatchSize() {
+    return 1;
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index f70379b..62da2f2 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -8,6 +8,7 @@
     name = "restapi",
     srcs = glob(["**/*.java"]),
     deps = [
+        "//antlr3:query_parser",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/entities",
@@ -29,10 +30,12 @@
         "//lib:guava",
         "//lib:jgit",
         "//lib:servlet-api",
+        "//lib/antlr:java-runtime",
+        "//lib/auto:auto-factory",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/commons:compress",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
         "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
diff --git a/java/com/google/gerrit/server/restapi/access/AccessResource.java b/java/com/google/gerrit/server/restapi/access/AccessResource.java
index 4847da4..36ffdcf 100644
--- a/java/com/google/gerrit/server/restapi/access/AccessResource.java
+++ b/java/com/google/gerrit/server/restapi/access/AccessResource.java
@@ -26,6 +26,5 @@
  * collection.
  */
 public class AccessResource implements RestResource {
-  public static final TypeLiteral<RestView<AccessResource>> ACCESS_KIND =
-      new TypeLiteral<RestView<AccessResource>>() {};
+  public static final TypeLiteral<RestView<AccessResource>> ACCESS_KIND = new TypeLiteral<>() {};
 }
diff --git a/java/com/google/gerrit/server/restapi/account/Capabilities.java b/java/com/google/gerrit/server/restapi/account/Capabilities.java
index 3d719ff9..469f05d 100644
--- a/java/com/google/gerrit/server/restapi/account/Capabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/Capabilities.java
@@ -71,12 +71,10 @@
     }
 
     GlobalOrPluginPermission perm = parse(id);
-    try {
-      permissionBackend.absentUser(target.getAccountId()).check(perm);
+    if (permissionBackend.absentUser(target.getAccountId()).test(perm)) {
       return new AccountResource.Capability(target, globalOrPluginPermissionName(perm));
-    } catch (AuthException e) {
-      throw new ResourceNotFoundException(id, e);
     }
+    throw new ResourceNotFoundException(id);
   }
 
   private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index ad3c56b..e35ffdb 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
@@ -40,7 +39,6 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
@@ -56,7 +54,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedHashMap;
@@ -70,37 +68,34 @@
 
   private final Provider<CurrentUser> userProvider;
   private final BatchUpdate.Factory batchUpdateFactory;
-  private final Provider<ChangeQueryBuilder> queryBuilderProvider;
+  private final ChangeQueryBuilder queryBuilder;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeJson.Factory changeJsonFactory;
   private final Provider<CommentJson> commentJsonProvider;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final ExperimentFeatures experimentFeatures;
 
   @Inject
   DeleteDraftComments(
       Provider<CurrentUser> userProvider,
       BatchUpdate.Factory batchUpdateFactory,
-      Provider<ChangeQueryBuilder> queryBuilderProvider,
+      ChangeQueryBuilder queryBuilder,
       Provider<InternalChangeQuery> queryProvider,
       ChangeData.Factory changeDataFactory,
       ChangeJson.Factory changeJsonFactory,
       Provider<CommentJson> commentJsonProvider,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      ExperimentFeatures experimentFeatures) {
+      PatchSetUtil psUtil) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
-    this.queryBuilderProvider = queryBuilderProvider;
+    this.queryBuilder = queryBuilder;
     this.queryProvider = queryProvider;
     this.changeDataFactory = changeDataFactory;
     this.changeJsonFactory = changeJsonFactory;
     this.commentJsonProvider = commentJsonProvider;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.experimentFeatures = experimentFeatures;
   }
 
   @Override
@@ -123,7 +118,7 @@
     HumanCommentFormatter humanCommentFormatter =
         commentJsonProvider.get().newHumanCommentFormatter();
     Account.Id accountId = rsrc.getUser().getAccountId();
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
     List<Op> ops = new ArrayList<>();
     for (ChangeData cd :
@@ -151,17 +146,12 @@
 
   private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
       throws BadRequestException {
-    Predicate<ChangeData> hasDraft =
-        ChangePredicates.draftBy(
-            experimentFeatures.isFeatureEnabled(
-                GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
-            commentsUtil,
-            accountId);
+    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
     if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
       return hasDraft;
     }
     try {
-      return Predicate.and(hasDraft, queryBuilderProvider.get().parse(input.query));
+      return Predicate.and(hasDraft, queryBuilder.parse(input.query));
     } catch (QueryParseException e) {
       throw new BadRequestException("Invalid query: " + e.getMessage(), e);
     }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index f73f00a..e09e48f 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -74,10 +75,14 @@
       permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
-    IdentifiedUser user = rsrc.getUser();
-    authorizedKeys.deleteKey(user.getAccountId(), rsrc.getSshKey().seq());
+    return apply(rsrc.getUser(), rsrc.getSshKey());
+  }
+
+  public Response<?> apply(IdentifiedUser user, AccountSshKey sshKey)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    authorizedKeys.deleteKey(user.getAccountId(), sshKey.seq());
     try {
-      deleteKeySenderFactory.create(user, rsrc.getSshKey()).send();
+      deleteKeySenderFactory.create(user, sshKey).send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
           "Cannot send SSH key deletion message to %s", user.getAccount().preferredEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index db6ad48..eb2be10 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -87,10 +87,8 @@
 
     IdentifiedUser user = self.get().asIdentifiedUser();
     if (user != resource.getUser()) {
-      try {
-        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
-      } catch (AuthException e) {
-        throw new AuthException("not allowed to get contributor agreements", e);
+      if (!permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+        throw new AuthException("not allowed to get contributor agreements");
       }
     }
 
diff --git a/java/com/google/gerrit/server/restapi/account/GetAvatar.java b/java/com/google/gerrit/server/restapi/account/GetAvatar.java
index 5256d68..14fee12 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAvatar.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAvatar.java
@@ -55,12 +55,12 @@
   public Response.Redirect apply(AccountResource rsrc) throws ResourceNotFoundException {
     AvatarProvider impl = avatarProvider.get();
     if (impl == null) {
-      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
+      throw new ResourceNotFoundException().caching(CacheControl.PUBLIC(1, TimeUnit.DAYS));
     }
 
     String url = impl.getUrl(rsrc.getUser(), size);
     if (Strings.isNullOrEmpty(url)) {
-      throw (new ResourceNotFoundException()).caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
+      throw new ResourceNotFoundException().caching(CacheControl.PUBLIC(1, TimeUnit.HOURS));
     }
     return Response.redirect(url);
   }
diff --git a/java/com/google/gerrit/server/restapi/account/GetDetail.java b/java/com/google/gerrit/server/restapi/account/GetDetail.java
index 1091599..ba7a37f 100644
--- a/java/com/google/gerrit/server/restapi/account/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/account/GetDetail.java
@@ -48,7 +48,7 @@
   public Response<AccountDetailInfo> apply(AccountResource rsrc) throws PermissionBackendException {
     Account a = rsrc.getUser().getAccount();
     AccountDetailInfo info = new AccountDetailInfo(a.id().get());
-    info.registeredOn = a.registeredOn();
+    info.setRegisteredOn(a.registeredOn());
     info.inactive = !a.isActive() ? true : null;
     directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
     return Response.ok(info);
diff --git a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
index a3c48b9..d7a5da11 100644
--- a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -92,7 +93,8 @@
     return Response.ok(result);
   }
 
+  @Nullable
   private static Boolean toBoolean(boolean v) {
-    return v ? v : null;
+    return v ? Boolean.TRUE : null;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
index 8d65aac..d8ad3cf 100644
--- a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
@@ -93,6 +94,7 @@
     return pwi;
   }
 
+  @Nullable
   private static Boolean toBoolean(boolean value) {
     return value ? true : null;
   }
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index 5979b2a..2131070 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 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.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryParser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountResource;
@@ -95,6 +98,17 @@
         throw new BadRequestException("project name must be specified");
       }
 
+      if (!Strings.isNullOrEmpty(info.filter)) {
+        try {
+          QueryParser.parse(info.filter);
+        } catch (QueryParseException e) {
+          throw new BadRequestException(
+              String.format(
+                  "invalid filter expression for project %s: %s", info.project, e.getMessage()),
+              e);
+        }
+      }
+
       ProjectWatchKey key =
           ProjectWatchKey.create(projectsCollection.parse(info.project).getNameKey(), info.filter);
       if (m.containsKey(key)) {
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index aee0b78..9a11891 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
@@ -92,70 +93,75 @@
       throws RestApiException, IOException, ConfigInvalidException {
     AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty());
     AtomicBoolean alreadyPreferred = new AtomicBoolean(false);
-    accountsUpdateProvider
-        .get()
-        .update(
-            "Set Preferred Email via API",
-            user.getAccountId(),
-            (a, u) -> {
-              if (preferredEmail.equals(a.account().preferredEmail())) {
-                alreadyPreferred.set(true);
-              } else {
-                // check if the user has a matching email
-                String matchingEmail = null;
-                for (String email :
-                    a.externalIds().stream()
-                        .map(ExternalId::email)
-                        .filter(Objects::nonNull)
-                        .collect(toSet())) {
-                  if (email.equals(preferredEmail)) {
-                    // we have an email that matches exactly, prefer this one
-                    matchingEmail = email;
-                    break;
-                  } else if (matchingEmail == null && email.equalsIgnoreCase(preferredEmail)) {
-                    // we found an email that matches but has a different case
-                    matchingEmail = email;
-                  }
-                }
-
-                if (matchingEmail == null) {
-                  // user doesn't have an external ID for this email
-                  if (user.hasEmailAddress(preferredEmail)) {
-                    // but Realm says the user is allowed to use this email
-                    Set<ExternalId> existingExtIdsWithThisEmail =
-                        externalIds.byEmail(preferredEmail);
-                    if (!existingExtIdsWithThisEmail.isEmpty()) {
-                      // but the email is already assigned to another account
-                      logger.atWarning().log(
-                          "Cannot set preferred email %s for account %s because it is owned"
-                              + " by the following account(s): %s",
-                          preferredEmail,
-                          user.getAccountId(),
-                          existingExtIdsWithThisEmail.stream()
-                              .map(ExternalId::accountId)
-                              .collect(toList()));
-                      exception.set(
-                          Optional.of(
-                              new ResourceConflictException("email in use by another account")));
-                      return;
+    Optional<AccountState> updatedAccount =
+        accountsUpdateProvider
+            .get()
+            .update(
+                "Set Preferred Email via API",
+                user.getAccountId(),
+                (a, u) -> {
+                  if (preferredEmail.equals(a.account().preferredEmail())) {
+                    alreadyPreferred.set(true);
+                  } else {
+                    // check if the user has a matching email
+                    String matchingEmail = null;
+                    for (String email :
+                        a.externalIds().stream()
+                            .map(ExternalId::email)
+                            .filter(Objects::nonNull)
+                            .collect(toSet())) {
+                      if (email.equals(preferredEmail)) {
+                        // we have an email that matches exactly, prefer this one
+                        matchingEmail = email;
+                        break;
+                      } else if (matchingEmail == null && email.equalsIgnoreCase(preferredEmail)) {
+                        // we found an email that matches but has a different case
+                        matchingEmail = email;
+                      }
                     }
 
-                    // claim the email now
-                    u.addExternalId(
-                        externalIdFactory.createEmail(a.account().id(), preferredEmail));
-                    matchingEmail = preferredEmail;
-                  } else {
-                    // Realm says that the email doesn't belong to the user. This can only happen as
-                    // a race condition because EmailsCollection would have thrown
-                    // ResourceNotFoundException already before invoking this REST endpoint.
-                    exception.set(Optional.of(new ResourceNotFoundException(preferredEmail)));
-                    return;
+                    if (matchingEmail == null) {
+                      // user doesn't have an external ID for this email
+                      if (user.hasEmailAddress(preferredEmail)) {
+                        // but Realm says the user is allowed to use this email
+                        Set<ExternalId> existingExtIdsWithThisEmail =
+                            externalIds.byEmail(preferredEmail);
+                        if (!existingExtIdsWithThisEmail.isEmpty()) {
+                          // but the email is already assigned to another account
+                          logger.atWarning().log(
+                              "Cannot set preferred email %s for account %s because it is owned"
+                                  + " by the following account(s): %s",
+                              preferredEmail,
+                              user.getAccountId(),
+                              existingExtIdsWithThisEmail.stream()
+                                  .map(ExternalId::accountId)
+                                  .collect(toList()));
+                          exception.set(
+                              Optional.of(
+                                  new ResourceConflictException(
+                                      "email in use by another account")));
+                          return;
+                        }
+
+                        // claim the email now
+                        u.addExternalId(
+                            externalIdFactory.createEmail(a.account().id(), preferredEmail));
+                        matchingEmail = preferredEmail;
+                      } else {
+                        // Realm says that the email doesn't belong to the user. This can only
+                        // happen as
+                        // a race condition because EmailsCollection would have thrown
+                        // ResourceNotFoundException already before invoking this REST endpoint.
+                        exception.set(Optional.of(new ResourceNotFoundException(preferredEmail)));
+                        return;
+                      }
+                    }
+                    u.setPreferredEmail(matchingEmail);
                   }
-                }
-                u.setPreferredEmail(matchingEmail);
-              }
-            })
-        .orElseThrow(() -> new ResourceNotFoundException("account not found"));
+                });
+    if (!updatedAccount.isPresent()) {
+      throw new ResourceNotFoundException("account not found");
+    }
     if (exception.get().isPresent()) {
       throw exception.get().get();
     }
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index e6b4eee..79737f3 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -186,11 +185,8 @@
       if (modifyAccountCapabilityChecked) {
         fillOptions.add(FillOptions.SECONDARY_EMAILS);
       } else {
-        try {
-          permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+        if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
           fillOptions.add(FillOptions.SECONDARY_EMAILS);
-        } catch (AuthException e) {
-          // Do nothing.
         }
       }
     }
@@ -206,6 +202,9 @@
     }
 
     if (start != null) {
+      if (start < 0) {
+        throw new BadRequestException("'start' parameter cannot be less than zero");
+      }
       queryProcessor.setStart(start);
     }
 
@@ -241,7 +240,7 @@
       if (suggest) {
         return Response.ok(ImmutableList.of());
       }
-      throw new BadRequestException(e.getMessage());
+      throw new BadRequestException(e.getMessage(), e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index 39c1fef..173f24b 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -131,11 +131,7 @@
 
       try {
         starredChangesUtil.star(
-            self.get().getAccountId(),
-            change.getProject(),
-            change.getId(),
-            StarredChangesUtil.DEFAULT_LABELS,
-            null);
+            self.get().getAccountId(), change.getId(), StarredChangesUtil.Operation.ADD);
       } catch (MutuallyExclusiveLabelsException e) {
         throw new ResourceConflictException(e.getMessage());
       } catch (IllegalLabelException e) {
@@ -183,11 +179,7 @@
         throw new AuthException("not allowed remove starred change");
       }
       starredChangesUtil.star(
-          self.get().getAccountId(),
-          rsrc.getChange().getProject(),
-          rsrc.getChange().getId(),
-          null,
-          StarredChangesUtil.DEFAULT_LABELS);
+          self.get().getAccountId(), rsrc.getChange().getId(), StarredChangesUtil.Operation.REMOVE);
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index affe947..8dd0e78 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -44,6 +45,7 @@
 @Singleton
 public class Abandon
     implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
+  private final ChangeData.Factory changeDataFactory;
   private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
   private final AbandonOp.Factory abandonOpFactory;
@@ -53,12 +55,14 @@
 
   @Inject
   Abandon(
+      ChangeData.Factory changeDataFactory,
       BatchUpdate.Factory updateFactory,
       ChangeJson.Factory json,
       AbandonOp.Factory abandonOpFactory,
       NotifyResolver notifyResolver,
       PatchSetUtil patchSetUtil,
       StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
+    this.changeDataFactory = changeDataFactory;
     this.updateFactory = updateFactory;
     this.json = json;
     this.abandonOpFactory = abandonOpFactory;
@@ -121,10 +125,14 @@
       throws RestApiException, UpdateException {
     AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
     AbandonOp op = abandonOpFactory.create(accountState, msgTxt);
-    try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.nowTs())) {
+    ChangeData changeData = changeDataFactory.create(notes.getProjectName(), notes.getChangeId());
+    try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.now())) {
       u.setNotify(notify);
       u.addOp(notes.getChangeId(), op);
-      u.addOp(notes.getChangeId(), storeSubmitRequirementsOpFactory.create());
+      u.addOp(
+          notes.getChangeId(),
+          storeSubmitRequirementsOpFactory.create(
+              changeData.submitRequirements().values(), changeData));
       u.execute();
     }
     return op.getChange();
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index cb1256c..03d383f 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -81,18 +81,16 @@
           String.format(
               "%s is a robot, and robots can't be added to the attention set.", input.user));
     }
-    try {
-      permissionBackend
-          .absentUser(attentionUserId)
-          .change(changeResource.getNotes())
-          .check(ChangePermission.READ);
-    } catch (AuthException e) {
-      throw new AuthException("read not permitted for " + attentionUserId, e);
+    if (!permissionBackend
+        .absentUser(attentionUserId)
+        .change(changeResource.getNotes())
+        .test(ChangePermission.READ)) {
+      throw new AuthException("read not permitted for " + attentionUserId);
     }
 
     try (BatchUpdate bu =
         updateFactory.create(
-            changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
+            changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.now())) {
       AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
       bu.addOp(changeResource.getId(), op);
       NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
diff --git a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
index ebec3295..e3ab135 100644
--- a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
+++ b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
@@ -22,8 +22,6 @@
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Set;
 
 @Singleton
@@ -33,14 +31,14 @@
 
   @Inject
   AllowedFormats(DownloadConfig cfg) {
-    Map<String, ArchiveFormatInternal> exts = new HashMap<>();
+    ImmutableMap.Builder<String, ArchiveFormatInternal> exts = ImmutableMap.builder();
     for (ArchiveFormatInternal format : cfg.getArchiveFormats()) {
       for (String ext : format.getSuffixes()) {
         exts.put(ext, format);
       }
       exts.put(format.name().toLowerCase(), format);
     }
-    extensions = ImmutableMap.copyOf(exts);
+    extensions = exts.build();
 
     // Zip is not supported because it may be interpreted by a Java plugin as a
     // valid JAR file, whose code would have access to cookies on the domain.
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
deleted file mode 100644
index 9224154..0000000
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.common.EditInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.change.FixResource;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.edit.ChangeEditJson;
-import com.google.gerrit.server.edit.ChangeEditModifier;
-import com.google.gerrit.server.edit.CommitModification;
-import com.google.gerrit.server.fixes.FixReplacementInterpreter;
-import com.google.gerrit.server.git.GitRepositoryManager;
-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 com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class ApplyFix implements RestModifyView<FixResource, Void> {
-
-  private final GitRepositoryManager gitRepositoryManager;
-  private final FixReplacementInterpreter fixReplacementInterpreter;
-  private final ChangeEditModifier changeEditModifier;
-  private final ChangeEditJson changeEditJson;
-  private final ProjectCache projectCache;
-
-  @Inject
-  public ApplyFix(
-      GitRepositoryManager gitRepositoryManager,
-      FixReplacementInterpreter fixReplacementInterpreter,
-      ChangeEditModifier changeEditModifier,
-      ChangeEditJson changeEditJson,
-      ProjectCache projectCache) {
-    this.gitRepositoryManager = gitRepositoryManager;
-    this.fixReplacementInterpreter = fixReplacementInterpreter;
-    this.changeEditModifier = changeEditModifier;
-    this.changeEditJson = changeEditJson;
-    this.projectCache = projectCache;
-  }
-
-  @Override
-  public Response<EditInfo> apply(FixResource fixResource, Void nothing)
-      throws AuthException, BadRequestException, ResourceConflictException, IOException,
-          ResourceNotFoundException, PermissionBackendException {
-    RevisionResource revisionResource = fixResource.getRevisionResource();
-    Project.NameKey project = revisionResource.getProject();
-    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-    PatchSet patchSet = revisionResource.getPatchSet();
-
-    try (Repository repository = gitRepositoryManager.openRepository(project)) {
-      CommitModification commitModification =
-          fixReplacementInterpreter.toCommitModification(
-              repository, projectState, patchSet.commitId(), fixResource.getFixReplacements());
-      ChangeEdit changeEdit =
-          changeEditModifier.combineWithModifiedPatchSetTree(
-              repository, revisionResource.getNotes(), patchSet, commitModification);
-      return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
-    } catch (InvalidChangeOperationException e) {
-      throw new ResourceConflictException(e.getMessage());
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
new file mode 100644
index 0000000..044fa0d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -0,0 +1,194 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+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;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class ApplyPatch implements RestModifyView<ChangeResource, ApplyPatchPatchSetInput> {
+  private final ChangeJson.Factory jsonFactory;
+  private final ContributorAgreementsChecker contributorAgreements;
+  private final Provider<IdentifiedUser> user;
+  private final GitRepositoryManager gitManager;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final PatchSetInserter.Factory patchSetInserterFactory;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ZoneId serverZoneId;
+
+  @Inject
+  ApplyPatch(
+      ChangeJson.Factory jsonFactory,
+      ContributorAgreementsChecker contributorAgreements,
+      Provider<IdentifiedUser> user,
+      GitRepositoryManager gitManager,
+      BatchUpdate.Factory batchUpdateFactory,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      @GerritPersonIdent PersonIdent myIdent) {
+    this.jsonFactory = jsonFactory;
+    this.contributorAgreements = contributorAgreements;
+    this.user = user;
+    this.gitManager = gitManager;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.patchSetInserterFactory = patchSetInserterFactory;
+    this.queryProvider = queryProvider;
+    this.serverZoneId = myIdent.getZoneId();
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ChangeResource rsrc, ApplyPatchPatchSetInput input)
+      throws IOException, UpdateException, RestApiException, PermissionBackendException,
+          ConfigInvalidException, NoSuchProjectException, InvalidChangeOperationException {
+    NameKey project = rsrc.getProject();
+    contributorAgreements.check(project, rsrc.getUser());
+    BranchNameKey destBranch = rsrc.getChange().getDest();
+
+    try (Repository repo = gitManager.openRepository(project);
+        // This inserter and revwalk *must* be passed to any BatchUpdates
+        // created later on, to ensure the applied commit is flushed
+        // before patch sets are updated.
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
+      Ref destRef = repo.getRefDatabase().exactRef(destBranch.branch());
+      if (destRef == null) {
+        throw new ResourceNotFoundException(
+            String.format("Branch %s does not exist.", destBranch.branch()));
+      }
+      ChangeData destChange = rsrc.getChangeData();
+      if (destChange == null) {
+        throw new PreconditionFailedException(
+            "patch:apply cannot be called without a destination change.");
+      }
+
+      if (destChange.change().isClosed()) {
+        throw new PreconditionFailedException(
+            String.format(
+                "patch:apply with Change-Id %s could not update the existing change %d "
+                    + "in destination branch %s of project %s, because the change was closed (%s)",
+                destChange.getId(),
+                destChange.getId().get(),
+                destBranch.branch(),
+                destBranch.project(),
+                destChange.change().getStatus().name()));
+      }
+
+      RevCommit baseCommit =
+          CommitUtil.getBaseCommit(
+              project.get(), queryProvider.get(), revWalk, destRef, input.base);
+      ObjectId treeId = ApplyPatchUtil.applyPatch(repo, oi, input.patch, baseCommit);
+
+      Instant now = TimeUtil.now();
+      PersonIdent committerIdent = user.get().newCommitterIdent(now, serverZoneId);
+      PersonIdent authorIdent =
+          input.author == null
+              ? committerIdent
+              : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
+      String commitMessage =
+          CommitMessageUtil.checkAndSanitizeCommitMessage(
+              input.commitMessage != null
+                  ? input.commitMessage
+                  : "The following patch was applied:\n>\t"
+                      + input.patch.patch.replaceAll("\n", "\n>\t"));
+
+      ObjectId appliedCommit =
+          CommitUtil.createCommitWithTree(
+              oi, authorIdent, committerIdent, baseCommit, commitMessage, treeId);
+      CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
+      oi.flush();
+
+      Change resultChange;
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+        bu.setRepository(repo, revWalk, oi);
+        resultChange =
+            insertPatchSet(bu, repo, patchSetInserterFactory, destChange.notes(), commit);
+      } catch (NoSuchChangeException | RepositoryNotFoundException e) {
+        throw new ResourceConflictException(e.getMessage());
+      }
+      ChangeJson json = jsonFactory.create(ListChangesOption.CURRENT_REVISION);
+      ChangeInfo changeInfo = json.format(resultChange);
+      return Response.ok(changeInfo);
+    }
+  }
+
+  private static Change insertPatchSet(
+      BatchUpdate bu,
+      Repository git,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      ChangeNotes destNotes,
+      CodeReviewCommit commit)
+      throws IOException, UpdateException, RestApiException {
+    Change destChange = destNotes.getChange();
+    PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
+    PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, commit);
+    inserter.setMessage(buildMessageForPatchSet(psId));
+    bu.addOp(destChange.getId(), inserter);
+    bu.execute();
+    return inserter.getChange();
+  }
+
+  private static String buildMessageForPatchSet(PatchSet.Id psId) {
+    return new StringBuilder(String.format("Uploaded patch set %s.", psId.get())).toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
new file mode 100644
index 0000000..d4f549a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import org.eclipse.jgit.api.errors.PatchApplyException;
+import org.eclipse.jgit.api.errors.PatchFormatException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+
+/** Utility for applying a patch. */
+public final class ApplyPatchUtil {
+
+  /**
+   * Applies the given patch on top of the merge tip, using the given object inserter.
+   *
+   * @param repo to apply the patch in
+   * @param oi to operate with
+   * @param input the patch for applying
+   * @param mergeTip the tip to apply the patch on
+   * @return the tree ID with the applied patch
+   * @throws IOException if unable to create the jgit PatchApplier object
+   * @throws RestApiException for any other failure
+   */
+  public static ObjectId applyPatch(
+      Repository repo, ObjectInserter oi, ApplyPatchInput input, RevCommit mergeTip)
+      throws IOException, RestApiException {
+    checkNotNull(mergeTip);
+    RevTree tip = mergeTip.getTree();
+    InputStream patchStream =
+        new ByteArrayInputStream(input.patch.getBytes(StandardCharsets.UTF_8));
+    try {
+      PatchApplier applier = new PatchApplier(repo, tip, oi);
+      PatchApplier.Result applyResult = applier.applyPatch(patchStream);
+      return applyResult.getTreeId();
+    } catch (PatchFormatException e) {
+      throw new BadRequestException("Invalid patch format: " + input.patch, e);
+    } catch (PatchApplyException e) {
+      throw RestApiException.wrap("Cannot apply patch: " + input.patch, e);
+    }
+  }
+
+  private ApplyPatchUtil() {}
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java b/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
new file mode 100644
index 0000000..a55ef84b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ApplyProvidedFix.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditJson;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.CommitModification;
+import com.google.gerrit.server.fixes.FixReplacementInterpreter;
+import com.google.gerrit.server.git.GitRepositoryManager;
+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 com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.Repository;
+
+/** Applies a fix that is provided as part of the request body. */
+@Singleton
+public class ApplyProvidedFix implements RestModifyView<RevisionResource, ApplyProvidedFixInput> {
+  private final GitRepositoryManager gitRepositoryManager;
+  private final FixReplacementInterpreter fixReplacementInterpreter;
+  private final ChangeEditModifier changeEditModifier;
+  private final ChangeEditJson changeEditJson;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public ApplyProvidedFix(
+      GitRepositoryManager gitRepositoryManager,
+      FixReplacementInterpreter fixReplacementInterpreter,
+      ChangeEditModifier changeEditModifier,
+      ChangeEditJson changeEditJson,
+      ProjectCache projectCache) {
+    this.gitRepositoryManager = gitRepositoryManager;
+    this.fixReplacementInterpreter = fixReplacementInterpreter;
+    this.changeEditModifier = changeEditModifier;
+    this.changeEditJson = changeEditJson;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<EditInfo> apply(
+      RevisionResource revisionResource, ApplyProvidedFixInput applyProvidedFixInput)
+      throws AuthException, BadRequestException, ResourceConflictException, IOException,
+          ResourceNotFoundException, PermissionBackendException {
+    if (applyProvidedFixInput == null) {
+      throw new BadRequestException("applyProvidedFixInput is required");
+    }
+    if (applyProvidedFixInput.fixReplacementInfos == null) {
+      throw new BadRequestException("applyProvidedFixInput.fixReplacementInfos is required");
+    }
+    Project.NameKey project = revisionResource.getProject();
+    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
+    PatchSet patchSet = revisionResource.getPatchSet();
+
+    ChangeNotes changeNotes = revisionResource.getNotes();
+
+    List<FixReplacement> fixReplacements =
+        applyProvidedFixInput.fixReplacementInfos.stream()
+            .map(fix -> new FixReplacement(fix.path, new Range(fix.range), fix.replacement))
+            .collect(Collectors.toList());
+
+    try (Repository repository = gitRepositoryManager.openRepository(project)) {
+      CommitModification commitModification =
+          fixReplacementInterpreter.toCommitModification(
+              repository, projectState, patchSet.commitId(), fixReplacements);
+      ChangeEdit changeEdit =
+          changeEditModifier.combineWithModifiedPatchSetTree(
+              repository, changeNotes, patchSet, commitModification);
+
+      return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
+    } catch (InvalidChangeOperationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyStoredFix.java b/java/com/google/gerrit/server/restapi/change/ApplyStoredFix.java
new file mode 100644
index 0000000..2d87dcf
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ApplyStoredFix.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.FixResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditJson;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.CommitModification;
+import com.google.gerrit.server.fixes.FixReplacementInterpreter;
+import com.google.gerrit.server.git.GitRepositoryManager;
+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 com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class ApplyStoredFix implements RestModifyView<FixResource, Input> {
+
+  private final GitRepositoryManager gitRepositoryManager;
+  private final FixReplacementInterpreter fixReplacementInterpreter;
+  private final ChangeEditModifier changeEditModifier;
+  private final ChangeEditJson changeEditJson;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public ApplyStoredFix(
+      GitRepositoryManager gitRepositoryManager,
+      FixReplacementInterpreter fixReplacementInterpreter,
+      ChangeEditModifier changeEditModifier,
+      ChangeEditJson changeEditJson,
+      ProjectCache projectCache) {
+    this.gitRepositoryManager = gitRepositoryManager;
+    this.fixReplacementInterpreter = fixReplacementInterpreter;
+    this.changeEditModifier = changeEditModifier;
+    this.changeEditJson = changeEditJson;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<EditInfo> apply(FixResource fixResource, Input nothing)
+      throws AuthException, BadRequestException, ResourceConflictException, IOException,
+          ResourceNotFoundException, PermissionBackendException {
+    RevisionResource revisionResource = fixResource.getRevisionResource();
+    Project.NameKey project = revisionResource.getProject();
+    ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
+    PatchSet patchSet = revisionResource.getPatchSet();
+
+    try (Repository repository = gitRepositoryManager.openRepository(project)) {
+      CommitModification commitModification =
+          fixReplacementInterpreter.toCommitModification(
+              repository, projectState, patchSet.commitId(), fixResource.getFixReplacements());
+      ChangeEdit changeEdit =
+          changeEditModifier.combineWithModifiedPatchSetTree(
+              repository, revisionResource.getNotes(), patchSet, commitModification);
+      return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
+    } catch (InvalidChangeOperationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 318b0fa..b688e2d 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -127,10 +127,11 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource resource, IdString id, FileContentInput input)
+    public Response<Object> apply(
+        ChangeResource resource, IdString id, FileContentInput fileContentInput)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
-      putEdit.apply(resource, id.get(), input);
+      putEdit.apply(resource, id.get(), fileContentInput);
       return Response.none();
     }
   }
@@ -146,7 +147,7 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource rsrc, IdString id, Input in)
+    public Response<Object> apply(ChangeResource rsrc, IdString id, Input input)
         throws IOException, AuthException, BadRequestException, ResourceConflictException,
             PermissionBackendException {
       return deleteContent.apply(rsrc, id.get());
@@ -249,15 +250,16 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource resource, Post.Input input)
+    public Response<Object> apply(ChangeResource resource, Post.Input postInput)
         throws AuthException, BadRequestException, IOException, ResourceConflictException,
             PermissionBackendException {
       Project.NameKey project = resource.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
-        if (isRestoreFile(input)) {
-          editModifier.restoreFile(repository, resource.getNotes(), input.restorePath);
-        } else if (isRenameFile(input)) {
-          editModifier.renameFile(repository, resource.getNotes(), input.oldPath, input.newPath);
+        if (isRestoreFile(postInput)) {
+          editModifier.restoreFile(repository, resource.getNotes(), postInput.restorePath);
+        } else if (isRenameFile(postInput)) {
+          editModifier.renameFile(
+              repository, resource.getNotes(), postInput.oldPath, postInput.newPath);
         } else {
           editModifier.createEdit(repository, resource.getNotes());
         }
@@ -267,14 +269,14 @@
       return Response.none();
     }
 
-    private static boolean isRestoreFile(Input input) {
-      return input != null && !Strings.isNullOrEmpty(input.restorePath);
+    private static boolean isRestoreFile(Post.Input postInput) {
+      return postInput != null && !Strings.isNullOrEmpty(postInput.restorePath);
     }
 
-    private static boolean isRenameFile(Input input) {
-      return input != null
-          && !Strings.isNullOrEmpty(input.oldPath)
-          && !Strings.isNullOrEmpty(input.newPath);
+    private static boolean isRenameFile(Post.Input postInput) {
+      return postInput != null
+          && !Strings.isNullOrEmpty(postInput.oldPath)
+          && !Strings.isNullOrEmpty(postInput.newPath);
     }
   }
 
@@ -300,45 +302,56 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeEditResource rsrc, FileContentInput input)
+    public Response<Object> apply(ChangeEditResource rsrc, FileContentInput fileContentInput)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
-      return apply(rsrc.getChangeResource(), rsrc.getPath(), input);
+      return apply(rsrc.getChangeResource(), rsrc.getPath(), fileContentInput);
     }
 
-    public Response<Object> apply(ChangeResource rsrc, String path, FileContentInput input)
+    public Response<Object> apply(
+        ChangeResource rsrc, String path, FileContentInput fileContentInput)
         throws AuthException, BadRequestException, ResourceConflictException, IOException,
             PermissionBackendException {
 
-      if (input.content == null && input.binary_content == null) {
+      if (fileContentInput.content == null && fileContentInput.binary_content == null) {
         throw new BadRequestException("either content or binary_content is required");
       }
 
       RawInput newContent;
-      if (input.binary_content != null) {
-        Matcher m = BINARY_DATA_PATTERN.matcher(input.binary_content);
+      if (fileContentInput.binary_content != null) {
+        Matcher m = BINARY_DATA_PATTERN.matcher(fileContentInput.binary_content);
         if (m.matches() && BASE64.equals(m.group(2))) {
           newContent = RawInputUtil.create(Base64.decode(m.group(3)));
         } else {
           throw new BadRequestException("binary_content must be encoded as base64 data uri");
         }
       } else {
-        newContent = input.content;
+        newContent = fileContentInput.content;
       }
 
-      if (Patch.COMMIT_MSG.equals(path) && input.binary_content == null) {
-        EditMessage.Input editCommitMessageInput = new EditMessage.Input();
-        editCommitMessageInput.message =
+      if (Patch.COMMIT_MSG.equals(path) && fileContentInput.binary_content == null) {
+        EditMessage.Input editMessageInput = new EditMessage.Input();
+        editMessageInput.message =
             new String(ByteStreams.toByteArray(newContent.getInputStream()), UTF_8);
-        return editMessage.apply(rsrc, editCommitMessageInput);
+        return editMessage.apply(rsrc, editMessageInput);
       }
 
       if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
         throw new ResourceConflictException("Invalid path: " + path);
       }
 
+      if (fileContentInput.fileMode != null) {
+        if ((fileContentInput.fileMode != 100644) && (fileContentInput.fileMode != 100755)) {
+          throw new BadRequestException(
+              "file_mode ("
+                  + fileContentInput.fileMode
+                  + ") was invalid: supported values are 0, 644, or 755.");
+        }
+      }
+
       try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
-        editModifier.modifyFile(repository, rsrc.getNotes(), path, newContent);
+        editModifier.modifyFile(
+            repository, rsrc.getNotes(), path, newContent, fileContentInput.fileMode);
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
@@ -471,16 +484,16 @@
     }
 
     @Override
-    public Response<Object> apply(ChangeResource rsrc, Input input)
+    public Response<Object> apply(ChangeResource rsrc, EditMessage.Input editMessageInput)
         throws AuthException, IOException, BadRequestException, ResourceConflictException,
             PermissionBackendException {
-      if (input == null || Strings.isNullOrEmpty(input.message)) {
+      if (editMessageInput == null || Strings.isNullOrEmpty(editMessageInput.message)) {
         throw new BadRequestException("commit message must be provided");
       }
 
       Project.NameKey project = rsrc.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.modifyMessage(repository, rsrc.getNotes(), input.message);
+        editModifier.modifyMessage(repository, rsrc.getNotes(), editMessageInput.message);
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 02b4c13..f49ee7f 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -113,17 +113,17 @@
     post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
     get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
     post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
+    post(CHANGE_KIND, "rebase:chain").to(RebaseChain.class);
     post(CHANGE_KIND, "index").to(Index.class);
     post(CHANGE_KIND, "move").to(Move.class);
     post(CHANGE_KIND, "private").to(PostPrivate.class);
     post(CHANGE_KIND, "private.delete").to(DeletePrivateByPost.class);
     delete(CHANGE_KIND, "private").to(DeletePrivate.class);
-    put(CHANGE_KIND, "ignore").to(Ignore.class);
-    put(CHANGE_KIND, "unignore").to(Unignore.class);
     post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
     post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
     put(CHANGE_KIND, "message").to(PutMessage.class);
     post(CHANGE_KIND, "check.submit_requirement").to(CheckSubmitRequirement.class);
+    post(CHANGE_KIND, "patch:apply").to(ApplyPatch.class);
 
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
     child(CHANGE_KIND, "reviewers").to(Reviewers.class);
@@ -143,7 +143,6 @@
     get(REVISION_KIND, "related").to(GetRelated.class);
     get(REVISION_KIND, "review").to(GetReview.class);
     post(REVISION_KIND, "review").to(PostReview.class);
-    get(REVISION_KIND, "preview_submit").to(PreviewSubmit.class);
     post(REVISION_KIND, "submit").to(Submit.class);
     post(REVISION_KIND, "rebase").to(Rebase.class);
     put(REVISION_KIND, "description").to(PutDescription.class);
@@ -171,8 +170,10 @@
     child(REVISION_KIND, "robotcomments").to(RobotComments.class);
     get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
     child(REVISION_KIND, "fixes").to(Fixes.class);
-    post(FIX_KIND, "apply").to(ApplyFix.class);
-    get(FIX_KIND, "preview").to(GetFixPreview.class);
+    post(FIX_KIND, "apply").to(ApplyStoredFix.class);
+    get(FIX_KIND, "preview").to(PreviewFix.Stored.class);
+    post(REVISION_KIND, "fix:apply").to(ApplyProvidedFix.class);
+    post(REVISION_KIND, "fix:preview").to(PreviewFix.Provided.class);
 
     get(REVISION_KIND, "ported_comments").to(ListPortedComments.class);
     get(REVISION_KIND, "ported_drafts").to(ListPortedDrafts.class);
@@ -211,9 +212,12 @@
     factory(DeleteChangeOp.Factory.class);
     factory(DeleteReviewerByEmailOp.Factory.class);
     factory(DeleteReviewerOp.Factory.class);
+    factory(DeleteVoteOp.Factory.class);
     factory(EmailReviewComments.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(AddReviewersOp.Factory.class);
+    factory(PostReviewOp.Factory.class);
+    factory(PreviewFix.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
index 572f704..a0c5b16 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -123,9 +122,7 @@
   }
 
   private boolean canRead(ChangeNotes notes) throws PermissionBackendException {
-    try {
-      permissionBackend.currentUser().change(notes).check(ChangePermission.READ);
-    } catch (AuthException e) {
+    if (!permissionBackend.currentUser().change(notes).test(ChangePermission.READ)) {
       return false;
     }
     Optional<ProjectState> projectState = projectCache.get(notes.getProjectName());
diff --git a/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java b/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
index 55b234c..e5c47a7a 100644
--- a/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
@@ -14,39 +14,102 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.common.SubmitRequirementInput;
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.SubmitRequirementsJson;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map.Entry;
 import java.util.Optional;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.kohsuke.args4j.Option;
 
 /**
  * A rest view to evaluate (test) a {@link com.google.gerrit.entities.SubmitRequirement} on a given
- * change.
+ * change. The submit requirement can be supplied in one of two ways:
  *
- * <p>TODO(ghareeb): Can this class be made singleton?
+ * <p>1) Using the {@link SubmitRequirementInput}.
+ *
+ * <p>2) From a change to the {@link RefNames#REFS_CONFIG} branch and the name of the
+ * submit-requirement.
  */
 public class CheckSubmitRequirement
     implements RestModifyView<ChangeResource, SubmitRequirementInput> {
   private final SubmitRequirementsEvaluator evaluator;
 
+  @Option(name = "--sr-name")
+  private String srName;
+
+  @Option(name = "--refs-config-change-id")
+  private String refsConfigChangeId;
+
+  private final GitRepositoryManager repoManager;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangesCollection changesCollection;
+  private final ChangeNotes.Factory changeNotesFactory;
+
+  public void setSrName(String srName) {
+    this.srName = srName;
+  }
+
+  public void setRefsConfigChangeId(String refsConfigChangeId) {
+    this.refsConfigChangeId = refsConfigChangeId;
+  }
+
   @Inject
-  public CheckSubmitRequirement(SubmitRequirementsEvaluator evaluator) {
+  public CheckSubmitRequirement(
+      SubmitRequirementsEvaluator evaluator,
+      GitRepositoryManager repoManager,
+      ProjectConfig.Factory projectConfigFactory,
+      ChangeData.Factory changeDataFactory,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangesCollection changesCollection) {
     this.evaluator = evaluator;
+    this.repoManager = repoManager;
+    this.projectConfigFactory = projectConfigFactory;
+    this.changeDataFactory = changeDataFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.changesCollection = changesCollection;
   }
 
   @Override
   public Response<SubmitRequirementResultInfo> apply(
-      ChangeResource resource, SubmitRequirementInput input) throws BadRequestException {
-    SubmitRequirement requirement = createSubmitRequirement(input);
+      ChangeResource resource, SubmitRequirementInput input)
+      throws IOException, PermissionBackendException, RestApiException {
+    if ((srName == null || refsConfigChangeId == null)
+        && !(srName == null && refsConfigChangeId == null)) {
+      throw new BadRequestException(
+          "Both 'sr-name' and 'refs-config-change-id' parameters must be set");
+    }
+    SubmitRequirement requirement =
+        srName != null && refsConfigChangeId != null
+            ? createSubmitRequirementFromRequestParams()
+            : createSubmitRequirement(input);
     SubmitRequirementResult res =
         evaluator.evaluateRequirement(requirement, resource.getChangeData());
     return Response.ok(SubmitRequirementsJson.toInfo(requirement, res));
@@ -67,6 +130,57 @@
         .build();
   }
 
+  /**
+   * Loads the submit-requirement identified by the name {@link #srName} from the latest patch-set
+   * of the change with ID {@link #refsConfigChangeId}.
+   *
+   * @return a {@link SubmitRequirement} entity.
+   * @throws BadRequestException If {@link #refsConfigChangeId} is a non-existent change or not in
+   *     the {@link RefNames#REFS_CONFIG} branch, if the submit-requirement with name {@link
+   *     #srName} does not exist or if the server failed to load the project due to other
+   *     exceptions.
+   */
+  private SubmitRequirement createSubmitRequirementFromRequestParams()
+      throws IOException, PermissionBackendException, RestApiException {
+    ChangeResource refsConfigChange;
+    try {
+      refsConfigChange =
+          changesCollection.parse(
+              TopLevelResource.INSTANCE, IdString.fromDecoded(refsConfigChangeId));
+    } catch (ResourceNotFoundException e) {
+      throw new BadRequestException(
+          String.format("Change '%s' does not exist", refsConfigChangeId), e);
+    }
+    ChangeNotes notes = changeNotesFactory.createCheckedUsingIndexLookup(refsConfigChange.getId());
+    ChangeData changeData = changeDataFactory.create(notes);
+    try (Repository git = repoManager.openRepository(changeData.project())) {
+      if (!changeData.change().getDest().branch().equals(RefNames.REFS_CONFIG)) {
+        throw new BadRequestException(
+            String.format("Change '%s' is not in refs/meta/config branch.", refsConfigChangeId));
+      }
+      ObjectId revisionId = changeData.currentPatchSet().commitId();
+      ProjectConfig cfg = projectConfigFactory.create(changeData.project());
+      try {
+        cfg.load(git, revisionId);
+      } catch (ConfigInvalidException e) {
+        throw new ResourceConflictException(
+            String.format(
+                "Failed to load project config for change '%s' from revision '%s'",
+                refsConfigChangeId, revisionId),
+            e);
+      }
+      List<Entry<String, SubmitRequirement>> submitRequirements =
+          cfg.getSubmitRequirementSections().entrySet().stream()
+              .filter(entry -> entry.getKey().equals(srName))
+              .collect(Collectors.toList());
+      if (submitRequirements.isEmpty()) {
+        throw new BadRequestException(
+            String.format("No submit requirement matching name '%s'", srName));
+      }
+      return Iterables.getOnlyElement(submitRequirements).getValue();
+    }
+  }
+
   private void validateSubmitRequirementInput(SubmitRequirementInput input)
       throws BadRequestException {
     if (input.name == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 5375936..c192500 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -30,9 +30,7 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -43,11 +41,14 @@
 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.change.ValidationOptionsUtil;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
@@ -67,14 +68,12 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
-import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -82,7 +81,6 @@
 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.eclipse.jgit.util.ChangeIdUtil;
 
 @Singleton
@@ -101,12 +99,12 @@
   private final Sequences seq;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitRepositoryManager gitManager;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<IdentifiedUser> user;
   private final ChangeInserter.Factory changeInserterFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final SetCherryPickOp.Factory setCherryPickOfFactory;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ProjectCache projectCache;
   private final ApprovalsUtil approvalsUtil;
@@ -123,7 +121,7 @@
       ChangeInserter.Factory changeInserterFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       SetCherryPickOp.Factory setCherryPickOfFactory,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       ChangeNotes.Factory changeNotesFactory,
       ProjectCache projectCache,
       ApprovalsUtil approvalsUtil,
@@ -132,7 +130,7 @@
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.user = user;
     this.changeInserterFactory = changeInserterFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
@@ -170,7 +168,7 @@
         patch.commitId(),
         input,
         dest,
-        TimeUtil.nowTs(),
+        TimeUtil.now(),
         null,
         null,
         null,
@@ -205,7 +203,7 @@
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
     return cherryPick(
-        sourceChange, project, sourceCommit, input, dest, TimeUtil.nowTs(), null, null, null, null);
+        sourceChange, project, sourceCommit, input, dest, TimeUtil.now(), null, null, null, null);
   }
 
   /**
@@ -243,14 +241,13 @@
       ObjectId sourceCommit,
       CherryPickInput input,
       BranchNameKey dest,
-      Timestamp timestamp,
+      Instant timestamp,
       @Nullable Change.Id revertedChange,
       @Nullable ObjectId changeIdForNewChange,
       @Nullable Change.Id idForNewChange,
       @Nullable Boolean workInProgress)
       throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
           ConfigInvalidException, NoSuchProjectException {
-
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
         // This inserter and revwalk *must* be passed to any BatchUpdates
@@ -265,7 +262,9 @@
             String.format("Branch %s does not exist.", dest.branch()));
       }
 
-      RevCommit baseCommit = getBaseCommit(destRef, project.get(), revWalk, input.base);
+      RevCommit baseCommit =
+          CommitUtil.getBaseCommit(
+              project.get(), queryProvider.get(), revWalk, destRef, input.base);
 
       CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceCommit);
 
@@ -305,7 +304,7 @@
       CodeReviewCommit cherryPickCommit;
       ProjectState projectState =
           projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverTimeZone);
+      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverZoneId);
 
       try {
         MergeUtil mergeUtil;
@@ -357,6 +356,7 @@
                   cherryPickCommit,
                   sourceChange,
                   newTopic,
+                  input,
                   workInProgress);
         } else {
           // Change key not found on destination branch. We can create a new
@@ -381,57 +381,6 @@
     }
   }
 
-  private RevCommit getBaseCommit(Ref destRef, String project, RevWalk revWalk, String base)
-      throws RestApiException, IOException {
-    RevCommit destRefTip = revWalk.parseCommit(destRef.getObjectId());
-    // The tip commit of the destination ref is the default base for the newly created change.
-    if (Strings.isNullOrEmpty(base)) {
-      return destRefTip;
-    }
-
-    ObjectId baseObjectId;
-    try {
-      baseObjectId = ObjectId.fromString(base);
-    } catch (InvalidObjectIdException e) {
-      throw new BadRequestException(
-          String.format("Base %s doesn't represent a valid SHA-1", base), e);
-    }
-
-    RevCommit baseCommit;
-    try {
-      baseCommit = revWalk.parseCommit(baseObjectId);
-    } catch (MissingObjectException e) {
-      throw new UnprocessableEntityException(
-          String.format("Base %s doesn't exist", baseObjectId.name()), e);
-    }
-
-    InternalChangeQuery changeQuery = queryProvider.get();
-    changeQuery.enforceVisibility(true);
-    List<ChangeData> changeDatas = changeQuery.byBranchCommit(project, destRef.getName(), base);
-
-    if (changeDatas.isEmpty()) {
-      if (revWalk.isMergedInto(baseCommit, destRefTip)) {
-        // The base commit is a merged commit with no change associated.
-        return baseCommit;
-      }
-      throw new UnprocessableEntityException(
-          String.format("Commit %s does not exist on branch %s", base, destRef.getName()));
-    } else if (changeDatas.size() != 1) {
-      throw new ResourceConflictException("Multiple changes found for commit " + base);
-    }
-
-    Change change = changeDatas.get(0).change();
-    if (!change.isAbandoned()) {
-      // The base commit is a valid change revision.
-      return baseCommit;
-    }
-
-    throw new ResourceConflictException(
-        String.format(
-            "Change %s with commit %s is %s",
-            change.getChangeId(), base, ChangeUtil.status(change)));
-  }
-
   private Change.Id insertPatchSet(
       BatchUpdate bu,
       Repository git,
@@ -439,6 +388,7 @@
       CodeReviewCommit cherryPickCommit,
       @Nullable Change sourceChange,
       String topic,
+      CherryPickInput input,
       @Nullable Boolean workInProgress)
       throws IOException {
     Change destChange = destNotes.getChange();
@@ -452,6 +402,8 @@
     if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
       inserter.setWorkInProgress(false);
     }
+    inserter.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
     bu.addOp(destChange.getId(), inserter);
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -502,6 +454,8 @@
           (sourceChange != null && sourceChange.isWorkInProgress())
               || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
     }
+    ins.setValidationOptions(
+        ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
     BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     ins.setMessage(
@@ -510,8 +464,10 @@
                     ins.getPatchSetId(), sourceBranch, sourceCommit, cherryPickCommit)
                 : "Uploaded patch set 1.") // For revert commits, the message should not include
         // cherry-pick information.
-        .setTopic(topic)
-        .setCherryPickOf(sourcePatchSetId);
+        .setTopic(topic);
+    if (revertOf == null) {
+      ins.setCherryPickOf(sourcePatchSetId);
+    }
     if (input.keepReviewers && sourceChange != null) {
       ReviewerSet reviewerSet =
           approvalsUtil.getReviewers(changeNotesFactory.createChecked(sourceChange));
@@ -521,7 +477,7 @@
       reviewers.remove(user.get().getAccountId());
       Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
       ccs.remove(user.get().getAccountId());
-      ins.setReviewersAndCcs(reviewers, ccs);
+      ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
     }
     // If there is a base, and the base is not merged, the groups will be overridden by the base's
     // groups.
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 81b6fb3..8ebe71f 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -263,6 +263,7 @@
       return rci;
     }
 
+    @Nullable
     private List<FixSuggestionInfo> toFixSuggestionInfos(
         @Nullable List<FixSuggestion> fixSuggestions) {
       if (fixSuggestions == null || fixSuggestions.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 1a4eb18..24a9c83 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.patch.DiffMappings;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.GitPositionTransformer;
 import com.google.gerrit.server.patch.GitPositionTransformer.BestPositionOnConflict;
 import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
@@ -193,10 +194,9 @@
                 patchsetComments));
       } else {
         logger.atWarning().log(
-            String.format(
-                "Some comments which should be ported refer to the non-existent patchset %s of"
-                    + " change %d. Omitting %d affected comments.",
-                originalPatchsetId, notes.getChangeId().get(), patchsetComments.size()));
+            "Some comments which should be ported refer to the non-existent patchset %s of"
+                + " change %d. Omitting %d affected comments.",
+            originalPatchsetId, notes.getChangeId().get(), patchsetComments.size());
       }
     }
     return portedComments.build();
@@ -254,8 +254,8 @@
         mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
       } catch (Exception e) {
         logger.atWarning().withCause(e).log(
-            "Could not determine some necessary diff mappings for porting comments on change %s from"
-                + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
+            "Could not determine some necessary diff mappings for porting comments on change %s"
+                + " from patchset %s to patchset %s. Mapping %d affected comments to the fallback"
                 + " destination.",
             change.getChangeId(),
             originalPatchset.id().getId(),
@@ -315,7 +315,11 @@
         TraceContext.newTimer(
             "Computing diffs", Metadata.builder().commit(originalCommit.name()).build())) {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffOperations.listModifiedFiles(project, originalCommit, targetCommit);
+          diffOperations.listModifiedFiles(
+              project,
+              originalCommit,
+              targetCommit,
+              DiffOptions.builder().skipFilesWithAllEditsDueToRebase(false).build());
       return modifiedFiles.values().stream()
           .map(CommentPorter::getFileEdits)
           .map(DiffMappings::toMapping)
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index fa47bef..2cb427a 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -61,8 +61,10 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -84,16 +86,15 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.NoMergeBaseException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -116,7 +117,7 @@
   private final String anonymousCowardName;
   private final GitRepositoryManager gitManager;
   private final Sequences seq;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final ProjectsCollection projectsCollection;
@@ -126,7 +127,7 @@
   private final ChangeFinder changeFinder;
   private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final SubmitType submitType;
   private final NotifyResolver notifyResolver;
   private final ContributorAgreementsChecker contributorAgreements;
@@ -149,14 +150,14 @@
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
       @GerritServerConfig Config config,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       NotifyResolver notifyResolver,
       ContributorAgreementsChecker contributorAgreements) {
     this.updateFactory = updateFactory;
     this.anonymousCowardName = anonymousCowardName;
     this.gitManager = gitManager;
     this.seq = seq;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.permissionBackend = permissionBackend;
     this.user = user;
     this.projectsCollection = projectsCollection;
@@ -292,6 +293,10 @@
       }
     }
 
+    if (input.merge != null && input.patch != null) {
+      throw new BadRequestException("Only one of `merge` and `patch` arguments can be set.");
+    }
+
     if (input.author != null
         && (Strings.isNullOrEmpty(input.author.email)
             || Strings.isNullOrEmpty(input.author.name))) {
@@ -308,10 +313,8 @@
       Project.NameKey project, String refName, @Nullable AccountInput author)
       throws ResourceNotFoundException, AuthException, PermissionBackendException {
     PermissionBackend.ForRef forRef = permissionBackend.currentUser().project(project).ref(refName);
-    try {
-      forRef.check(RefPermission.READ);
-    } catch (AuthException e) {
-      throw new ResourceNotFoundException(String.format("ref %s not found", refName), e);
+    if (!forRef.test(RefPermission.READ)) {
+      throw new ResourceNotFoundException(String.format("ref %s not found", refName));
     }
     forRef.check(RefPermission.CREATE_CHANGE);
     if (author != null) {
@@ -353,13 +356,13 @@
 
       RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
 
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
 
-      PersonIdent committer = me.newCommitterIdent(now, serverTimeZone);
+      PersonIdent committer = me.newCommitterIdent(now, serverZoneId);
       PersonIdent author =
           input.author == null
               ? committer
-              : new PersonIdent(input.author.name, input.author.email, now, serverTimeZone);
+              : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
 
       String commitMessage = getCommitMessage(input.subject, me);
 
@@ -374,9 +377,19 @@
               "merge commit has conflicts in the following files: %s",
               c.getFilesWithGitConflicts());
         }
+      } else if (input.patch != null) {
+        // create a commit with the given patch.
+        if (mergeTip == null) {
+          throw new BadRequestException("Cannot apply patch on top of an empty tree.");
+        }
+        ObjectId treeId = ApplyPatchUtil.applyPatch(git, oi, input.patch, mergeTip);
+        c =
+            rw.parseCommit(
+                CommitUtil.createCommitWithTree(
+                    oi, author, committer, mergeTip, commitMessage, treeId));
       } else {
-        // create an empty commit
-        c = newCommit(oi, rw, author, committer, mergeTip, commitMessage);
+        // create an empty commit.
+        c = createEmptyCommit(oi, rw, author, committer, mergeTip, commitMessage);
       }
       // Flush inserter so that commit becomes visible to validators
       oi.flush();
@@ -527,7 +540,7 @@
     return commitMessage;
   }
 
-  private static CodeReviewCommit newCommit(
+  private static CodeReviewCommit createEmptyCommit(
       ObjectInserter oi,
       CodeReviewRevWalk rw,
       PersonIdent authorIdent,
@@ -536,17 +549,14 @@
       String commitMessage)
       throws IOException {
     logger.atFine().log("Creating empty commit");
-    CommitBuilder commit = new CommitBuilder();
-    if (mergeTip == null) {
-      commit.setTreeId(emptyTreeId(oi));
-    } else {
-      commit.setTreeId(mergeTip.getTree().getId());
-      commit.setParentId(mergeTip);
-    }
-    commit.setAuthor(authorIdent);
-    commit.setCommitter(committerIdent);
-    commit.setMessage(commitMessage);
-    return rw.parseCommit(insert(oi, commit));
+    ObjectId treeID = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree().getId();
+    return rw.parseCommit(
+        CommitUtil.createCommitWithTree(
+            oi, authorIdent, committerIdent, mergeTip, commitMessage, treeID));
+  }
+
+  private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
+    return inserter.insert(new TreeFormatter());
   }
 
   private CodeReviewCommit newMergeCommit(
@@ -616,14 +626,4 @@
 
     return stringBuilder.toString();
   }
-
-  private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit) throws IOException {
-    ObjectId id = inserter.insert(commit);
-    inserter.flush();
-    return id;
-  }
-
-  private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
-    return inserter.insert(new TreeFormatter());
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 8476767..9e9cf6a 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -82,8 +82,7 @@
           String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op(rsrc.getPatchSet().id(), in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index e943e47..4b66cdc 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -67,9 +68,9 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.List;
-import java.util.TimeZone;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -84,11 +85,11 @@
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager gitManager;
   private final CommitsCollection commits;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<CurrentUser> user;
   private final ChangeJson.Factory jsonFactory;
   private final PatchSetUtil psUtil;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final ProjectCache projectCache;
   private final ChangeFinder changeFinder;
@@ -103,7 +104,7 @@
       Provider<CurrentUser> user,
       ChangeJson.Factory json,
       PatchSetUtil psUtil,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       ProjectCache projectCache,
       ChangeFinder changeFinder,
@@ -111,7 +112,7 @@
     this.updateFactory = updateFactory;
     this.gitManager = gitManager;
     this.commits = commits;
-    this.serverTimeZone = myIdent.getTimeZone();
+    this.serverZoneId = myIdent.getZoneId();
     this.user = user;
     this.jsonFactory = json;
     this.psUtil = psUtil;
@@ -176,12 +177,12 @@
         currentPsCommit = rw.parseCommit(ps.commitId());
       }
 
-      Timestamp now = TimeUtil.nowTs();
+      Instant now = TimeUtil.now();
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author =
           in.author == null
-              ? me.newCommitterIdent(now, serverTimeZone)
-              : new PersonIdent(in.author.name, in.author.email, now, serverTimeZone);
+              ? me.newCommitterIdent(now, serverZoneId)
+              : new PersonIdent(in.author.name, in.author.email, now, serverZoneId);
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index d867e00..66171c4 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -67,8 +68,7 @@
       throws RestApiException, UpdateException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op();
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -98,6 +98,7 @@
       return true;
     }
 
+    @Nullable
     public Account.Id getDeletedAssignee() {
       return deletedAssignee != null ? deletedAssignee.account().id() : null;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChange.java b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
index 3ca5463..8298abb 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChange.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
@@ -54,8 +54,7 @@
     }
     rsrc.permissions().check(ChangePermission.DELETE);
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       Change.Id id = rsrc.getChange().getId();
       bu.addOp(id, opFactory.create(id));
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 0e868e70..588d56e 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -89,7 +89,7 @@
     DeleteChangeMessageOp deleteChangeMessageOp =
         new DeleteChangeMessageOp(resource.getChangeMessageId(), newChangeMessage);
     try (BatchUpdate batchUpdate =
-        updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.nowTs())) {
+        updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.now())) {
       batchUpdate.addOp(resource.getChangeId(), deleteChangeMessageOp).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 044fd77..2056664 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -84,7 +84,7 @@
     String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
     DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
     try (BatchUpdate batchUpdate =
-        updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.now())) {
       batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 51a0b8e..7d28a39 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -54,7 +54,7 @@
   public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
       throws RestApiException, UpdateException {
     try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op(rsrc.getComment().key);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
index 16b7136..08725b5 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
@@ -62,8 +62,7 @@
     }
 
     SetPrivateOp op = setPrivateOpFactory.create(false, input);
-    try (BatchUpdate u =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getId(), op).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index db8e9de..7a409e8 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -58,7 +58,7 @@
         updateFactory.create(
             rsrc.getChangeResource().getProject(),
             rsrc.getChangeResource().getUser(),
-            TimeUtil.nowTs())) {
+            TimeUtil.now())) {
       bu.setNotify(getNotify(rsrc.getChange(), input));
       BatchUpdateOp op;
       if (rsrc.isByEmail()) {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 45d1f5a..9fa3160 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -15,101 +15,51 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static java.util.Objects.requireNonNull;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
+import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
-import com.google.gerrit.server.extensions.events.VoteDeleted;
-import com.google.gerrit.server.mail.send.DeleteVoteSender;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.AccountTemplateUtil;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.HashMap;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteVote implements RestModifyView<VoteResource, DeleteVoteInput> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final BatchUpdate.Factory updateFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final VoteDeleted voteDeleted;
-  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
   private final NotifyResolver notifyResolver;
-  private final RemoveReviewerControl removeReviewerControl;
-  private final ProjectCache projectCache;
-  private final MessageIdGenerator messageIdGenerator;
+
   private final AddToAttentionSetOp.Factory attentionSetOpFactory;
   private final Provider<CurrentUser> currentUserProvider;
+  private final DeleteVoteOp.Factory deleteVoteOpFactory;
 
   @Inject
   DeleteVote(
       BatchUpdate.Factory updateFactory,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
-      VoteDeleted voteDeleted,
-      DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
-      RemoveReviewerControl removeReviewerControl,
-      ProjectCache projectCache,
-      MessageIdGenerator messageIdGenerator,
       AddToAttentionSetOp.Factory attentionSetOpFactory,
-      Provider<CurrentUser> currentUserProvider) {
+      Provider<CurrentUser> currentUserProvider,
+      DeleteVoteOp.Factory deleteVoteOpFactory) {
     this.updateFactory = updateFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
-    this.voteDeleted = voteDeleted;
-    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
     this.notifyResolver = notifyResolver;
-    this.removeReviewerControl = removeReviewerControl;
-    this.projectCache = projectCache;
-    this.messageIdGenerator = messageIdGenerator;
     this.attentionSetOpFactory = attentionSetOpFactory;
     this.currentUserProvider = currentUserProvider;
+    this.deleteVoteOpFactory = deleteVoteOpFactory;
   }
 
   @Override
@@ -133,20 +83,20 @@
 
     try (BatchUpdate bu =
         updateFactory.create(
-            change.getProject(), r.getChangeResource().getUser(), TimeUtil.nowTs())) {
+            change.getProject(), r.getChangeResource().getUser(), TimeUtil.now())) {
       bu.setNotify(
           notifyResolver.resolve(
               firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
       bu.addOp(
           change.getId(),
-          new Op(
-              projectCache
-                  .get(r.getChange().getProject())
-                  .orElseThrow(illegalState(r.getChange().getProject())),
+          deleteVoteOpFactory.create(
+              r.getChange().getProject(),
               r.getReviewerUser().state(),
               rsrc.getLabel(),
-              input));
-      if (!r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
+              input,
+              true));
+      if (!input.ignoreAutomaticAttentionSetRules
+          && !r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
         bu.addOp(
             change.getId(),
             attentionSetOpFactory.create(
@@ -154,116 +104,12 @@
                 /* reason= */ "Their vote was deleted",
                 /* notify= */ false));
       }
+      if (input.ignoreAutomaticAttentionSetRules) {
+        bu.addOp(change.getId(), new AttentionSetUnchangedOp());
+      }
       bu.execute();
     }
 
     return Response.none();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final AccountState accountState;
-    private final String label;
-    private final DeleteVoteInput input;
-
-    private String mailMessage;
-    private Change change;
-    private PatchSet ps;
-    private Map<String, Short> newApprovals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(
-        ProjectState projectState, AccountState accountState, String label, DeleteVoteInput input) {
-      this.projectState = projectState;
-      this.accountState = accountState;
-      this.label = label;
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
-      change = ctx.getChange();
-      PatchSet.Id psId = change.currentPatchSetId();
-      ps = psUtil.current(ctx.getNotes());
-
-      boolean found = false;
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-
-      Account.Id accountId = accountState.account().id();
-
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getNotes(), psId, accountId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
-        if (!labelTypes.byLabel(a.labelId()).isPresent()) {
-          continue; // Ignore undefined labels.
-        } else if (!a.label().equals(label)) {
-          // Populate map for non-matching labels, needed by VoteDeleted.
-          newApprovals.put(a.label(), a.value());
-          continue;
-        } else {
-          try {
-            removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-          } catch (AuthException e) {
-            throw new AuthException("delete vote not permitted", e);
-          }
-        }
-        // Set the approval to 0 if vote is being removed.
-        newApprovals.put(a.label(), (short) 0);
-        found = true;
-
-        // Set old value, as required by VoteDeleted.
-        oldApprovals.put(a.label(), a.value());
-        break;
-      }
-      if (!found) {
-        throw new ResourceNotFoundException();
-      }
-
-      ctx.getUpdate(psId).removeApprovalFor(accountId, label);
-
-      StringBuilder msg = new StringBuilder();
-      msg.append("Removed ");
-      LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
-      msg.append(" by ").append(AccountTemplateUtil.getAccountTemplate(accountId)).append("\n");
-      mailMessage =
-          cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
-      return true;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) {
-      if (mailMessage == null) {
-        return;
-      }
-
-      IdentifiedUser user = ctx.getIdentifiedUser();
-      try {
-        NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        if (notify.shouldNotify()) {
-          ReplyToChangeSender emailSender =
-              deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
-          emailSender.setFrom(user.getAccountId());
-          emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-          emailSender.setNotify(notify);
-          emailSender.setMessageId(
-              messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-          emailSender.send();
-        }
-      } catch (Exception e) {
-        logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
-      }
-
-      voteDeleted.fire(
-          ctx.getChangeData(change),
-          ps,
-          accountState,
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          mailMessage,
-          user.state(),
-          ctx.getWhen());
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
new file mode 100644
index 0000000..0e1a218
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -0,0 +1,230 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.extensions.events.VoteDeleted;
+import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.permissions.LabelRemovalPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DeleteVoteControl;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.RemoveReviewerControl;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Updates the storage to delete vote(s). */
+public class DeleteVoteOp implements BatchUpdateOp {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /** Factory to create {@link DeleteVoteOp} instances. */
+  public interface Factory {
+    DeleteVoteOp create(
+        Project.NameKey projectState,
+        AccountState reviewerToDeleteVoteFor,
+        String label,
+        DeleteVoteInput input,
+        boolean enforcePermissions);
+  }
+
+  private final Project.NameKey projectName;
+  private final AccountState reviewerToDeleteVoteFor;
+
+  private final ProjectCache projectCache;
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final VoteDeleted voteDeleted;
+  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+
+  private final DeleteVoteControl deleteVoteControl;
+  private final RemoveReviewerControl removeReviewerControl;
+  private final MessageIdGenerator messageIdGenerator;
+
+  private final String label;
+  private final DeleteVoteInput input;
+  private final boolean enforcePermissions;
+
+  private String mailMessage;
+  private Change change;
+  private PatchSet ps;
+  private Map<String, Short> newApprovals = new HashMap<>();
+  private Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  public DeleteVoteOp(
+      ProjectCache projectCache,
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      VoteDeleted voteDeleted,
+      DeleteVoteSender.Factory deleteVoteSenderFactory,
+      DeleteVoteControl deleteVoteControl,
+      MessageIdGenerator messageIdGenerator,
+      RemoveReviewerControl removeReviewerControl,
+      @Assisted Project.NameKey projectName,
+      @Assisted AccountState reviewerToDeleteVoteFor,
+      @Assisted String label,
+      @Assisted DeleteVoteInput input,
+      @Assisted boolean enforcePermissions) {
+    this.projectCache = projectCache;
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.voteDeleted = voteDeleted;
+    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.deleteVoteControl = deleteVoteControl;
+    this.removeReviewerControl = removeReviewerControl;
+    this.messageIdGenerator = messageIdGenerator;
+
+    this.projectName = projectName;
+    this.reviewerToDeleteVoteFor = reviewerToDeleteVoteFor;
+    this.label = label;
+    this.input = input;
+    this.enforcePermissions = enforcePermissions;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
+    change = ctx.getChange();
+    PatchSet.Id psId = change.currentPatchSetId();
+    ps = psUtil.current(ctx.getNotes());
+
+    boolean found = false;
+    LabelTypes labelTypes =
+        projectCache
+            .get(projectName)
+            .orElseThrow(illegalState(projectName))
+            .getLabelTypes(ctx.getNotes());
+
+    Account.Id accountId = reviewerToDeleteVoteFor.account().id();
+
+    for (PatchSetApproval a : approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, accountId)) {
+      if (!labelTypes.byLabel(a.labelId()).isPresent()) {
+        continue; // Ignore undefined labels.
+      } else if (!a.label().equals(label)) {
+        // Populate map for non-matching labels, needed by VoteDeleted.
+        newApprovals.put(a.label(), a.value());
+        continue;
+      } else if (enforcePermissions) {
+        checkPermissions(ctx, labelTypes.byLabel(a.labelId()).get(), a);
+      }
+      // Set the approval to 0 if vote is being removed.
+      newApprovals.put(a.label(), (short) 0);
+      found = true;
+
+      // Set old value, as required by VoteDeleted.
+      oldApprovals.put(a.label(), a.value());
+      break;
+    }
+    if (!found) {
+      throw new ResourceNotFoundException();
+    }
+
+    ctx.getUpdate(psId).removeApprovalFor(accountId, label);
+
+    StringBuilder msg = new StringBuilder();
+    msg.append("Removed ");
+    LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
+    msg.append(" by ").append(AccountTemplateUtil.getAccountTemplate(accountId));
+    if (input.reason != null) {
+      msg.append("\n\n" + input.reason);
+    }
+    msg.append("\n");
+    mailMessage = cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (mailMessage == null) {
+      return;
+    }
+
+    CurrentUser user = ctx.getUser();
+    try {
+      NotifyResolver.Result notify = ctx.getNotify(change.getId());
+      ReplyToChangeSender emailSender =
+          deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
+      if (user.isIdentifiedUser()) {
+        emailSender.setFrom(user.getAccountId());
+      }
+      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
+      emailSender.setNotify(notify);
+      emailSender.setMessageId(
+          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+      emailSender.send();
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+    }
+    voteDeleted.fire(
+        ctx.getChangeData(change),
+        ps,
+        reviewerToDeleteVoteFor,
+        newApprovals,
+        oldApprovals,
+        input.notify,
+        mailMessage,
+        user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null,
+        ctx.getWhen());
+  }
+
+  private void checkPermissions(ChangeContext ctx, LabelType labelType, PatchSetApproval approval)
+      throws PermissionBackendException, AuthException {
+    boolean permitted =
+        removeReviewerControl.testRemoveReviewer(ctx.getNotes(), ctx.getUser(), approval)
+            || deleteVoteControl.testDeleteVotePermissions(
+                ctx.getUser(), ctx.getNotes(), approval, labelType);
+    if (!permitted) {
+      throw new AuthException(
+          "Delete vote not permitted.",
+          new AuthException(
+              "Both "
+                  + new LabelRemovalPermission.WithValue(labelType, approval.value())
+                      .describeForException()
+                  + " and remove-reviewer are not permitted"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
index ab5b9f4..a4c9400 100644
--- a/java/com/google/gerrit/server/restapi/change/DraftComments.java
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -75,7 +75,7 @@
   }
 
   private void checkIdentifiedUser() throws AuthException {
-    if (!(user.get().isIdentifiedUser())) {
+    if (!user.get().isIdentifiedUser()) {
       throw new AuthException("drafts only available to authenticated users");
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 320e57d..7699873 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -237,7 +238,7 @@
 
     private Collection<String> reviewed(RevisionResource resource) throws AuthException {
       CurrentUser user = self.get();
-      if (!(user.isIdentifiedUser())) {
+      if (!user.isIdentifiedUser()) {
         throw new AuthException("Authentication required");
       }
 
@@ -280,11 +281,14 @@
 
         Map<String, FileDiffOutput> oldList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, patchSet.commitId(), /* parentNum= */ 0);
+                project, patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
 
         Map<String, FileDiffOutput> curList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, resource.getPatchSet().commitId(), /* parentNum= */ 0);
+                project,
+                resource.getPatchSet().commitId(),
+                /* parentNum= */ 0,
+                DiffOptions.DEFAULTS);
 
         int sz = paths.size();
         List<String> pathList = Lists.newArrayListWithCapacity(sz);
@@ -366,6 +370,7 @@
           : Iterables.getFirst(fileDiffList.values(), null).oldCommitId();
     }
 
+    @Nullable
     private ObjectId getNewId(Map<String, FileDiffOutput> fileDiffList) {
       return fileDiffList.isEmpty()
           ? null
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index 740b8cb..d126d8a 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -18,6 +18,7 @@
 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;
@@ -26,6 +27,7 @@
 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;
@@ -34,15 +36,21 @@
 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
@@ -53,6 +61,7 @@
   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) {
@@ -73,9 +82,13 @@
   }
 
   @Inject
-  GetChange(ChangeJson.Factory json, DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
+  GetChange(
+      ChangeJson.Factory json,
+      DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories,
+      GitRepositoryManager repoMgr) {
     this.json = json;
     this.pdiFactories = pdiFactories;
+    this.repoMgr = repoMgr;
   }
 
   @Override
@@ -89,10 +102,11 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc)
-      throws BadRequestException, PreconditionFailedException {
+  public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException {
     try {
-      return Response.withMustRevalidate(newChangeJson().format(rsrc.getChange(), getMetaRevId()));
+      Change change = rsrc.getChange();
+      ObjectId changeMetaRevId = getMetaRevId(change);
+      return Response.withMustRevalidate(newChangeJson().format(change, changeMetaRevId));
     } catch (MissingMetaObjectException e) {
       throw new PreconditionFailedException(e.getMessage());
     }
@@ -103,7 +117,7 @@
   }
 
   @Nullable
-  private ObjectId getMetaRevId() throws BadRequestException {
+  private ObjectId getMetaRevId(Change change) throws RestApiException {
     if (metaRevId.isEmpty()) {
       return null;
     }
@@ -111,11 +125,13 @@
     // 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 {
-      return ObjectId.fromString(metaRevId);
+      metaRevObjectId = ObjectId.fromString(metaRevId);
     } catch (InvalidObjectIdException e) {
       throw new BadRequestException("invalid meta SHA1: " + metaRevId, e);
     }
+    return verifyMetaId(change, metaRevObjectId);
   }
 
   private ChangeJson newChangeJson() {
@@ -127,4 +143,35 @@
     return PluginDefinedAttributesFactories.createAll(
         cds, this, Streams.stream(pdiFactories.entries()));
   }
+
+  @Nullable
+  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 d76ce04..5193501 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static java.util.concurrent.TimeUnit.DAYS;
+
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -25,7 +27,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -64,10 +65,11 @@
                   commit,
                   addLinks,
                   /* fillCommit= */ true,
-                  rsrc.getChange().getDest().branch());
+                  rsrc.getChange().getDest().branch(),
+                  rsrc.getChange().getKey().get());
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
-        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+        r.caching(CacheControl.PRIVATE(7, DAYS));
       }
       return r;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
index c6bbf53..b365e57 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.PreconditionFailedException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
@@ -60,8 +60,7 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource rsrc)
-      throws BadRequestException, PreconditionFailedException {
+  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 dd951a8..9424198 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -184,6 +184,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;
 
@@ -202,6 +204,7 @@
       this.sideB = sideB;
 
       revA = basePatchSet != null ? basePatchSet.refName() : sideA.fileInfo().commitId;
+      hashA = sideA.fileInfo().commitId;
 
       RevisionResource revision = resource.getRevision();
       revB =
@@ -209,8 +212,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
@@ -234,14 +238,19 @@
     @Override
     public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type type) {
       String rev = getSideRev(type);
+      String hash = getSideHash(type);
       DiffSide side = getDiffSide(type);
-      return webLinks.getFileLinks(projectName.get(), rev, side.fileName());
+      return webLinks.getFileLinks(projectName.get(), rev, hash, side.fileName());
     }
 
     private String getSideRev(DiffSide.Type sideType) {
       return DiffSide.Type.SIDE_A == sideType ? revA : revB;
     }
 
+    private String getSideHash(DiffSide.Type sideType) {
+      return DiffSide.Type.SIDE_A == sideType ? hashA : hashB;
+    }
+
     private DiffSide getDiffSide(DiffSide.Type sideType) {
       return DiffSide.Type.SIDE_A == sideType ? sideA : sideB;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
deleted file mode 100644
index 95e26a23..0000000
--- a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
+++ /dev/null
@@ -1,148 +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.restapi.change;
-
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static java.util.stream.Collectors.groupingBy;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.FixReplacement;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo;
-import com.google.gerrit.extensions.common.DiffInfo;
-import com.google.gerrit.extensions.common.DiffWebLinkInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.change.FixResource;
-import com.google.gerrit.server.diff.DiffInfoCreator;
-import com.google.gerrit.server.diff.DiffSide;
-import com.google.gerrit.server.diff.DiffWebLinksProvider;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LargeObjectException;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-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.Singleton;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.lib.Repository;
-
-@Singleton
-public class GetFixPreview implements RestReadView<FixResource> {
-
-  private final ProjectCache projectCache;
-  private final GitRepositoryManager repoManager;
-  private final PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory;
-
-  @Inject
-  GetFixPreview(
-      ProjectCache projectCache,
-      GitRepositoryManager repoManager,
-      PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory) {
-    this.projectCache = projectCache;
-    this.repoManager = repoManager;
-    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
-  }
-
-  @Override
-  public Response<Map<String, DiffInfo>> apply(FixResource resource)
-      throws PermissionBackendException, ResourceNotFoundException, ResourceConflictException,
-          AuthException, IOException, InvalidChangeOperationException {
-    Map<String, DiffInfo> result = new HashMap<>();
-    PatchSet patchSet = resource.getRevisionResource().getPatchSet();
-    ChangeNotes notes = resource.getRevisionResource().getNotes();
-    Change change = notes.getChange();
-    ProjectState state =
-        projectCache.get(change.getProject()).orElseThrow(illegalState(change.getProject()));
-    Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
-        resource.getFixReplacements().stream()
-            .collect(groupingBy(fixReplacement -> fixReplacement.path));
-    try {
-      try (Repository git = repoManager.openRepository(notes.getProjectName())) {
-        for (Map.Entry<String, List<FixReplacement>> entry :
-            fixReplacementsPerFilePath.entrySet()) {
-          String fileName = entry.getKey();
-          DiffInfo diffInfo =
-              getFixPreviewForSingleFile(
-                  git, patchSet, state, notes, fileName, ImmutableList.copyOf(entry.getValue()));
-          result.put(fileName, diffInfo);
-        }
-      }
-    } catch (NoSuchChangeException e) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    } catch (LargeObjectException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    }
-    return Response.ok(result);
-  }
-
-  private DiffInfo getFixPreviewForSingleFile(
-      Repository git,
-      PatchSet patchSet,
-      ProjectState state,
-      ChangeNotes notes,
-      String fileName,
-      ImmutableList<FixReplacement> fixReplacements)
-      throws PermissionBackendException, AuthException, LargeObjectException,
-          InvalidChangeOperationException, IOException, ResourceNotFoundException {
-    PatchScriptFactoryForAutoFix psf =
-        patchScriptFactoryFactory.create(
-            git, notes, fileName, patchSet, fixReplacements, DiffPreferencesInfo.defaults());
-    PatchScript ps = psf.call();
-
-    DiffSide sideA =
-        DiffSide.create(
-            ps.getFileInfoA(),
-            MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
-            DiffSide.Type.SIDE_A);
-    DiffSide sideB = DiffSide.create(ps.getFileInfoB(), ps.getNewName(), DiffSide.Type.SIDE_B);
-
-    DiffInfoCreator diffInfoCreator =
-        new DiffInfoCreator(state, new DiffWebLinksProviderImpl(), true);
-    return diffInfoCreator.create(ps, sideA, sideB);
-  }
-
-  private static class DiffWebLinksProviderImpl implements DiffWebLinksProvider {
-
-    @Override
-    public ImmutableList<DiffWebLinkInfo> getDiffLinks() {
-      return ImmutableList.of();
-    }
-
-    @Override
-    public ImmutableList<WebLinkInfo> getEditWebLinks() {
-      return ImmutableList.of();
-    }
-
-    @Override
-    public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType) {
-      return ImmutableList.of();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index f0639b5..551b50f 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static java.util.concurrent.TimeUnit.DAYS;
+
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Project;
@@ -30,7 +32,6 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -77,7 +78,7 @@
         return createResponse(rsrc, ImmutableList.of());
       }
 
-      List<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
+      ImmutableList<RevCommit> commits = MergeListBuilder.build(rw, commit, uninterestingParent);
       List<CommitInfo> result = new ArrayList<>(commits.size());
       RevisionJson changeJson = json.create(ImmutableSet.of());
       for (RevCommit c : commits) {
@@ -88,7 +89,8 @@
                 c,
                 addLinks,
                 /* fillCommit= */ true,
-                rsrc.getChange().getDest().branch()));
+                rsrc.getChange().getDest().branch(),
+                rsrc.getChange().getKey().get()));
       }
       return createResponse(rsrc, result);
     }
@@ -98,7 +100,7 @@
       RevisionResource rsrc, List<CommitInfo> result) {
     Response<List<CommitInfo>> r = Response.ok(result);
     if (rsrc.isCacheable()) {
-      r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+      r.caching(CacheControl.PRIVATE(7, DAYS));
     }
     return r;
   }
diff --git a/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
index 08d51e7..81d97e2 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
@@ -25,6 +25,7 @@
 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;
@@ -98,13 +99,13 @@
 
   @Override
   public Response<ChangeInfoDifference> apply(ChangeResource resource)
-      throws BadRequestException, PreconditionFailedException, IOException {
+      throws RestApiException, IOException {
     return Response.ok(
         ChangeInfoDiffer.getDifference(getOldChangeInfo(resource), getNewChangeInfo(resource)));
   }
 
   private ChangeInfo getOldChangeInfo(ChangeResource resource)
-      throws BadRequestException, IOException, PreconditionFailedException {
+      throws RestApiException, IOException {
     GetChange getChange = createGetChange();
     getChange.setMetaRevId(getOldMetaRevId(resource));
     ChangeInfo oldChangeInfo;
@@ -137,7 +138,7 @@
   }
 
   private ChangeInfo getNewChangeInfo(ChangeResource resource)
-      throws BadRequestException, PreconditionFailedException, IOException {
+      throws RestApiException, IOException {
     GetChange getChange = createGetChange();
     getChange.setMetaRevId(getNewMetaRevId(resource));
     return getChange.apply(resource).value();
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index 187ebce..dea4dc4 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -43,8 +43,6 @@
 public class GetPatch implements RestReadView<RevisionResource> {
   private final GitRepositoryManager repoManager;
 
-  private static final String FILE_NOT_FOUND = "File not found: %s.";
-
   @Option(name = "--zip")
   private boolean zip;
 
@@ -118,7 +116,7 @@
             };
 
         if (path != null && bin.asString().isEmpty()) {
-          throw new ResourceNotFoundException(String.format(FILE_NOT_FOUND, path));
+          throw new ResourceNotFoundException(String.format("File not found: %s.", path));
         }
 
         if (zip) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index 0eef468..6471a62 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 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.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.RelatedChangesInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -34,20 +37,19 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.kohsuke.args4j.Option;
 
-@Singleton
 public class GetRelated implements RestReadView<RevisionResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ChangeData.Factory changeDataFactory;
   private final GetRelatedChangesUtil getRelatedChangesUtil;
+  private boolean computeSubmittable = false;
 
   @Inject
   GetRelated(ChangeData.Factory changeDataFactory, GetRelatedChangesUtil getRelatedChangesUtil) {
@@ -55,6 +57,15 @@
     this.getRelatedChangesUtil = getRelatedChangesUtil;
   }
 
+  @Option(name = "-o", usage = "Options")
+  public void addOption(GetRelatedOption option) {
+    if (option == GetRelatedOption.SUBMITTABLE) {
+      computeSubmittable = true;
+    } else {
+      throw new IllegalArgumentException("option not recognized: " + option);
+    }
+  }
+
   @Override
   public Response<RelatedChangesInfo> apply(RevisionResource rsrc)
       throws IOException, NoSuchProjectException, PermissionBackendException {
@@ -63,7 +74,7 @@
     return Response.ok(relatedChangesInfo);
   }
 
-  public List<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
+  public ImmutableList<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
       throws IOException, PermissionBackendException {
     boolean isEdit = rsrc.getEdit().isPresent();
     PatchSet basePs = isEdit ? rsrc.getEdit().get().getBasePatchSet() : rsrc.getPatchSet();
@@ -86,23 +97,24 @@
       } else {
         commit = d.commit();
       }
-      result.add(newChangeAndCommit(rsrc.getProject(), d.data().change(), ps, commit));
+      result.add(newChangeAndCommit(rsrc.getProject(), d.data(), ps, commit));
     }
 
     if (result.size() == 1) {
       RelatedChangeAndCommitInfo r = result.get(0);
       if (r.commit != null && r.commit.commit.equals(rsrc.getPatchSet().commitId().name())) {
-        return Collections.emptyList();
+        return ImmutableList.of();
       }
     }
-    return result;
+    return ImmutableList.copyOf(result);
   }
 
-  static RelatedChangeAndCommitInfo newChangeAndCommit(
-      Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
+  private RelatedChangeAndCommitInfo newChangeAndCommit(
+      Project.NameKey project, ChangeData cd, @Nullable PatchSet ps, RevCommit c) {
     RelatedChangeAndCommitInfo info = new RelatedChangeAndCommitInfo();
     info.project = project.get();
 
+    Change change = cd.change();
     if (change != null) {
       info.changeId = change.getKey().get();
       info._changeNumber = change.getChangeId();
@@ -110,6 +122,7 @@
       PatchSet.Id curr = change.currentPatchSetId();
       info._currentRevisionNumber = curr != null ? curr.get() : null;
       info.status = ChangeUtil.status(change).toUpperCase(Locale.US);
+      info.submittable = computeSubmittable ? submittable(cd) : null;
     }
 
     info.commit = new CommitInfo();
@@ -124,4 +137,9 @@
     info.commit.subject = c.getShortMessage();
     return info;
   }
+
+  private static boolean submittable(ChangeData cd) {
+    return cd.submitRequirementsIncludingLegacy().values().stream()
+        .allMatch(SubmitRequirementResult::fulfilled);
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Ignore.java b/java/com/google/gerrit/server/restapi/change/Ignore.java
deleted file mode 100644
index a049e54..0000000
--- a/java/com/google/gerrit/server/restapi/change/Ignore.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class Ignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final StarredChangesUtil stars;
-
-  @Inject
-  Ignore(StarredChangesUtil stars) {
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Ignore")
-        .setTitle("Ignore the change")
-        .setVisible(canIgnore(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, IllegalLabelException {
-    try {
-      if (rsrc.isUserOwner()) {
-        throw new BadRequestException("cannot ignore own change");
-      }
-
-      if (!isIgnored(rsrc)) {
-        stars.ignore(rsrc);
-      }
-      return Response.ok();
-    } catch (MutuallyExclusiveLabelsException e) {
-      throw new ResourceConflictException(e.getMessage());
-    }
-  }
-
-  private boolean canIgnore(ChangeResource rsrc) {
-    return !rsrc.isUserOwner() && !isIgnored(rsrc);
-  }
-
-  private boolean isIgnored(ChangeResource rsrc) {
-    try {
-      return stars.isIgnored(rsrc);
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("failed to check ignored star");
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 7683ab7..8aa2554 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -61,7 +61,7 @@
 
   private final GitRepositoryManager gitManager;
   private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeIndexer indexer;
   private final MergeabilityCache cache;
@@ -71,7 +71,7 @@
   Mergeable(
       GitRepositoryManager gitManager,
       ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       ChangeData.Factory changeDataFactory,
       ChangeIndexer indexer,
       MergeabilityCache cache,
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index 8c21841..c1b36d7 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -141,12 +141,9 @@
     // 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);
+    if (input.keepAllVotes
+        && !permissionBackend.user(caller).test(GlobalPermission.ADMINISTRATE_SERVER)) {
+      throw new AuthException("move is not permitted with keepAllVotes option");
     }
 
     // Move requires abandoning this change, and creating a new change.
@@ -159,7 +156,7 @@
     projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
 
     Op op = new Op(input);
-    try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.now())) {
       u.addOp(change.getId(), op);
       u.execute();
     }
@@ -200,9 +197,6 @@
           RevWalk revWalk = new RevWalk(repo)) {
         RevCommit currPatchsetRevCommit =
             revWalk.parseCommit(psUtil.current(ctx.getNotes()).commitId());
-        if (currPatchsetRevCommit.getParentCount() > 1) {
-          throw new ResourceConflictException("Merge commit cannot be moved");
-        }
 
         ObjectId refId = repo.resolve(input.destinationBranch);
         // Check if destination ref exists in project repo
@@ -260,11 +254,8 @@
      * proposal: https://gerrit-review.googlesource.com/c/gerrit/+/129171
      */
     private void updateApprovals(
-        ChangeContext ctx, ChangeUpdate update, PatchSet.Id psId, Project.NameKey project)
-        throws IOException {
-      for (PatchSetApproval psa :
-          approvalsUtil.byPatchSet(
-              ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
+        ChangeContext ctx, ChangeUpdate update, PatchSet.Id psId, Project.NameKey project) {
+      for (PatchSetApproval psa : approvalsUtil.byPatchSet(ctx.getNotes(), psId)) {
         ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
         Optional<LabelType> type =
             projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
diff --git a/java/com/google/gerrit/server/restapi/change/PostHashtags.java b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
index c1a6a13..bcaa145 100644
--- a/java/com/google/gerrit/server/restapi/change/PostHashtags.java
+++ b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
@@ -48,7 +48,7 @@
     req.permissions().check(ChangePermission.EDIT_HASHTAGS);
 
     try (BatchUpdate bu =
-        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
       SetHashtagsOp op = hashtagsFactory.create(input);
       bu.addOp(req.getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/PostPrivate.java b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
index f774457..45d7250 100644
--- a/java/com/google/gerrit/server/restapi/change/PostPrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
@@ -74,8 +74,7 @@
     }
 
     SetPrivateOp op = setPrivateOpFactory.create(true, input);
-    try (BatchUpdate u =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate u = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getId(), op).execute();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 5c252f4..22eb32c 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -15,23 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.groupingBy;
-import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
 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;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
@@ -44,16 +38,11 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.FixReplacement;
-import com.google.gerrit.entities.FixSuggestion;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.RobotComment;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -75,29 +64,20 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.extensions.validators.CommentForValidation;
-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.metrics.Counter1;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.change.ModifyReviewersEmail;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerModifier;
@@ -106,12 +86,10 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -121,25 +99,17 @@
 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.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -149,7 +119,6 @@
 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.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -185,21 +154,15 @@
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
 
-  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
-
   private final BatchUpdate.Factory updateFactory;
+  private final PostReviewOp.Factory postReviewOpFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
-  private final ChangeMessagesUtil cmUtil;
   private final CommentsUtil commentsUtil;
-  private final PublishCommentUtil publishCommentUtil;
-  private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
   private final AccountResolver accountResolver;
-  private final EmailReviewComments.Factory email;
-  private final CommentAdded commentAdded;
   private final ReviewerModifier reviewerModifier;
   private final Metrics metrics;
   private final ModifyReviewersEmail modifyReviewersEmail;
@@ -207,28 +170,22 @@
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
   private final PermissionBackend permissionBackend;
-  private final PluginSetContext<CommentValidator> commentValidators;
-  private final PluginSetContext<OnPostReview> onPostReviews;
+
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final ReviewerAdded reviewerAdded;
   private final boolean strictLabels;
-  private final boolean publishPatchSetLevelComment;
 
   @Inject
   PostReview(
       BatchUpdate.Factory updateFactory,
+      PostReviewOp.Factory postReviewOpFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
       CommentsUtil commentsUtil,
-      PublishCommentUtil publishCommentUtil,
-      PatchSetUtil psUtil,
       PatchListCache patchListCache,
       AccountResolver accountResolver,
-      EmailReviewComments.Factory email,
-      CommentAdded commentAdded,
       ReviewerModifier reviewerModifier,
       Metrics metrics,
       ModifyReviewersEmail modifyReviewersEmail,
@@ -237,23 +194,17 @@
       WorkInProgressOp.Factory workInProgressOpFactory,
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
-      PluginSetContext<CommentValidator> commentValidators,
-      PluginSetContext<OnPostReview> onPostReviews,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
       ReviewerAdded reviewerAdded) {
     this.updateFactory = updateFactory;
+    this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
     this.commentsUtil = commentsUtil;
-    this.publishCommentUtil = publishCommentUtil;
-    this.psUtil = psUtil;
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
     this.accountResolver = accountResolver;
-    this.email = email;
-    this.commentAdded = commentAdded;
     this.reviewerModifier = reviewerModifier;
     this.metrics = metrics;
     this.modifyReviewersEmail = modifyReviewersEmail;
@@ -261,23 +212,19 @@
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
-    this.commentValidators = commentValidators;
-    this.onPostReviews = onPostReviews;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.reviewerAdded = reviewerAdded;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
-    this.publishPatchSetLevelComment =
-        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
   }
 
   @Override
   public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input)
       throws RestApiException, UpdateException, IOException, PermissionBackendException,
           ConfigInvalidException, PatchListNotAvailableException {
-    return apply(revision, input, TimeUtil.nowTs());
+    return apply(revision, input, TimeUtil.now());
   }
 
-  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Timestamp ts)
+  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Instant ts)
       throws RestApiException, UpdateException, IOException, PermissionBackendException,
           ConfigInvalidException, PatchListNotAvailableException {
     // Respect timestamp, but truncate at change created-on time.
@@ -353,8 +300,13 @@
     }
     output.labels = input.labels;
 
+    // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
+    NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
+
     try (BatchUpdate bu =
         updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
+      bu.setNotify(notify);
+
       Account account = revision.getUser().asIdentifiedUser().getAccount();
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
@@ -424,32 +376,30 @@
         bu.addOp(revision.getChange().getId(), wipOp);
       }
 
-      // Add the review op.
+      // Add the review ops.
       logger.atFine().log("posting review");
-      bu.addOp(
-          revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
-
-      // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
-      NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
-      bu.setNotify(notify);
+      PostReviewOp postReviewOp =
+          postReviewOpFactory.create(
+              projectState, revision.getPatchSet().id(), input, revision.getAccountId());
+      bu.addOp(revision.getChange().getId(), postReviewOp);
 
       // Adjust the attention set based on the input
       replyAttentionSetUpdates.updateAttentionSet(
           bu, revision.getNotes(), input, revision.getUser());
       bu.execute();
-
-      // Re-read change to take into account results of the update.
-      ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
-      for (ReviewerModification reviewerResult : reviewerResults) {
-        reviewerResult.gatherResults(cd);
-      }
-
-      // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
-      // email/event here.
-      batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
-      batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
     }
 
+    // Re-read change to take into account results of the update.
+    ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
+    for (ReviewerModification reviewerResult : reviewerResults) {
+      reviewerResult.gatherResults(cd);
+    }
+
+    // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
+    // email/event here.
+    batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
+    batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
+
     return Response.ok(output);
   }
 
@@ -486,7 +436,9 @@
       Change change,
       List<ReviewerModification> reviewerModifications,
       NotifyResolver.Result notify) {
-    try (TraceContext.TraceTimer ignored = newTimer("batchEmailReviewers")) {
+    try (TraceContext.TraceTimer ignored =
+        TraceContext.newTimer(
+            getClass().getSimpleName() + "#batchEmailReviewers", Metadata.empty())) {
       List<Account.Id> to = new ArrayList<>();
       List<Account.Id> cc = new ArrayList<>();
       List<Account.Id> removed = new ArrayList<>();
@@ -530,7 +482,7 @@
       ChangeData cd,
       PatchSet patchSet,
       List<ReviewerModification> reviewerModifications,
-      Timestamp when) {
+      Instant when) {
     List<AccountState> newlyAddedReviewers = new ArrayList<>();
 
     // There are no events for CCs and reviewers added/deleted by email.
@@ -556,7 +508,8 @@
 
   private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
       throws BadRequestException, AuthException, UnprocessableEntityException,
-          PermissionBackendException, IOException, ConfigInvalidException {
+          ResourceConflictException, PermissionBackendException, IOException,
+          ConfigInvalidException {
     logger.atFine().log("request is executed on behalf of %s", in.onBehalfOf);
 
     if (in.labels == null || in.labels.isEmpty()) {
@@ -614,7 +567,7 @@
     try {
       permissionBackend.user(reviewer).change(rev.getNotes()).check(ChangePermission.READ);
     } catch (AuthException e) {
-      throw new UnprocessableEntityException(
+      throw new ResourceConflictException(
           String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()), e);
     }
 
@@ -697,10 +650,6 @@
         .collect(toList());
   }
 
-  private TraceContext.TraceTimer newTimer(String method) {
-    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
-  }
-
   private <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
       RevisionResource revision, Map<String, List<T>> commentsPerPath)
       throws BadRequestException, PatchListNotAvailableException {
@@ -1007,646 +956,4 @@
     @Nullable
     abstract Comment.Range range();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final ProjectState projectState;
-    private final PatchSet.Id psId;
-    private final ReviewInput in;
-
-    private IdentifiedUser user;
-    private ChangeNotes notes;
-    private PatchSet ps;
-    private String mailMessage;
-    private List<Comment> comments = new ArrayList<>();
-    private List<LabelVote> labelDelta = new ArrayList<>();
-    private Map<String, Short> approvals = new HashMap<>();
-    private Map<String, Short> oldApprovals = new HashMap<>();
-
-    private Op(ProjectState projectState, PatchSet.Id psId, ReviewInput in) {
-      this.projectState = projectState;
-      this.psId = psId;
-      this.in = in;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws ResourceConflictException, UnprocessableEntityException, IOException,
-            CommentsRejectedException {
-      user = ctx.getIdentifiedUser();
-      notes = ctx.getNotes();
-      ps = psUtil.get(ctx.getNotes(), psId);
-      List<RobotComment> newRobotComments =
-          in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
-      boolean dirty = false;
-      try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
-        dirty |= insertComments(ctx, newRobotComments);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
-        dirty |= insertRobotComments(ctx, newRobotComments);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
-        dirty |= updateLabels(projectState, ctx);
-      }
-      try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
-        dirty |= insertMessage(ctx);
-      }
-      return dirty;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) {
-      if (mailMessage == null) {
-        return;
-      }
-      NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
-      if (notify.shouldNotify()) {
-        try {
-          email
-              .create(
-                  notify,
-                  notes,
-                  ps,
-                  user,
-                  mailMessage,
-                  ctx.getWhen(),
-                  comments,
-                  in.message,
-                  labelDelta,
-                  ctx.getRepoView())
-              .sendAsync();
-        } catch (IOException ex) {
-          throw new StorageException(
-              String.format("Repository %s not found", ctx.getProject().get()), ex);
-        }
-      }
-      String comment = mailMessage;
-      if (publishPatchSetLevelComment) {
-        // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
-        // added event. For backwards compatibility, patchset level comment has a higher priority
-        // than change message and should be used as comment in comment added event.
-        if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
-          List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
-          if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
-            CommentInput firstComment = patchSetLevelComments.get(0);
-            if (!Strings.isNullOrEmpty(firstComment.message)) {
-              comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
-            }
-          }
-        }
-      }
-      commentAdded.fire(
-          ctx.getChangeData(notes),
-          ps,
-          user.state(),
-          comment,
-          approvals,
-          oldApprovals,
-          ctx.getWhen());
-    }
-
-    /**
-     * Publishes draft and input comments. Input comments are those passed as input in the request
-     * body.
-     *
-     * @param ctx context for performing the change update.
-     * @param newRobotComments robot comments. Used only for validation in this method.
-     * @return true if any input comments where published.
-     */
-    private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
-        throws CommentsRejectedException {
-      Map<String, List<CommentInput>> inputComments = in.comments;
-      if (inputComments == null) {
-        inputComments = Collections.emptyMap();
-      }
-
-      // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
-      Map<String, HumanComment> drafts = new HashMap<>();
-
-      if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
-        drafts =
-            in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
-                ? changeDrafts(ctx)
-                : patchSetDrafts(ctx);
-      }
-
-      // Existing published comments
-      Set<CommentSetEntry> existingComments =
-          in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
-
-      // Input comments should be deduplicated from existing drafts
-      List<HumanComment> inputCommentsToPublish =
-          resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
-
-      switch (in.drafts) {
-        case PUBLISH:
-        case PUBLISH_ALL_REVISIONS:
-          Collection<HumanComment> filteredDrafts =
-              in.draftIdsToPublish == null
-                  ? drafts.values()
-                  : drafts.values().stream()
-                      .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
-                      .collect(Collectors.toList());
-
-          validateComments(
-              ctx,
-              Streams.concat(
-                  drafts.values().stream(),
-                  inputCommentsToPublish.stream(),
-                  newRobotComments.stream()));
-          publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
-          comments.addAll(drafts.values());
-          break;
-        case KEEP:
-          validateComments(
-              ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
-          break;
-      }
-      commentsUtil.putHumanComments(
-          ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
-      comments.addAll(inputCommentsToPublish);
-      return !inputCommentsToPublish.isEmpty();
-    }
-
-    /**
-     * Returns the subset of {@code inputComments} that do not have a matching comment (with same
-     * id) neither in {@code existingComments} nor in {@code drafts}.
-     *
-     * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
-     * removed.
-     *
-     * @param inputComments new comments provided as {@link CommentInput} entries in the API.
-     * @param existingComments existing published comments in the database.
-     * @param drafts existing draft comments in the database. This map can be modified.
-     */
-    private List<HumanComment> resolveInputCommentsAndDrafts(
-        Map<String, List<CommentInput>> inputComments,
-        Set<CommentSetEntry> existingComments,
-        Map<String, HumanComment> drafts,
-        ChangeContext ctx) {
-      List<HumanComment> inputCommentsToPublish = new ArrayList<>();
-      for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
-        String path = entry.getKey();
-        for (CommentInput inputComment : entry.getValue()) {
-          HumanComment comment = drafts.remove(Url.decode(inputComment.id));
-          if (comment == null) {
-            String parent = Url.decode(inputComment.inReplyTo);
-            comment =
-                commentsUtil.newHumanComment(
-                    ctx.getNotes(),
-                    ctx.getUser(),
-                    ctx.getWhen(),
-                    path,
-                    psId,
-                    inputComment.side(),
-                    inputComment.message,
-                    inputComment.unresolved,
-                    parent);
-          } else {
-            // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
-            comment.writtenOn = ctx.getWhen();
-            comment.side = inputComment.side();
-            comment.message = inputComment.message;
-          }
-
-          commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
-          comment.setLineNbrAndRange(inputComment.line, inputComment.range);
-          comment.tag = in.tag;
-
-          if (existingComments.contains(CommentSetEntry.create(comment))) {
-            continue;
-          }
-          inputCommentsToPublish.add(comment);
-        }
-      }
-      return inputCommentsToPublish;
-    }
-
-    /**
-     * Validates all comments and the change message in a single call to fulfill the interface
-     * contract of {@link CommentValidator#validateComments(CommentValidationContext,
-     * ImmutableList)}.
-     */
-    private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
-        throws CommentsRejectedException {
-      CommentValidationContext commentValidationCtx =
-          CommentValidationContext.create(
-              ctx.getChange().getChangeId(), ctx.getChange().getProject().get());
-      String changeMessage = Strings.nullToEmpty(in.message).trim();
-      ImmutableList<CommentForValidation> draftsForValidation =
-          Stream.concat(
-                  comments.map(
-                      comment ->
-                          CommentForValidation.create(
-                              comment instanceof RobotComment
-                                  ? CommentForValidation.CommentSource.ROBOT
-                                  : CommentForValidation.CommentSource.HUMAN,
-                              comment.lineNbr > 0
-                                  ? CommentForValidation.CommentType.INLINE_COMMENT
-                                  : CommentForValidation.CommentType.FILE_COMMENT,
-                              comment.message,
-                              comment.getApproximateSize())),
-                  Stream.of(
-                      CommentForValidation.create(
-                          CommentForValidation.CommentSource.HUMAN,
-                          CommentForValidation.CommentType.CHANGE_MESSAGE,
-                          changeMessage,
-                          changeMessage.length())))
-              .collect(toImmutableList());
-      ImmutableList<CommentValidationFailure> draftValidationFailures =
-          PublishCommentUtil.findInvalidComments(
-              commentValidationCtx, commentValidators, draftsForValidation);
-      if (!draftValidationFailures.isEmpty()) {
-        throw new CommentsRejectedException(draftValidationFailures);
-      }
-    }
-
-    private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
-      if (in.robotComments == null) {
-        return false;
-      }
-      commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
-      comments.addAll(newRobotComments);
-      return !newRobotComments.isEmpty();
-    }
-
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
-      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
-
-      Set<CommentSetEntry> existingIds =
-          in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
-
-      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
-        String path = ent.getKey();
-        for (RobotCommentInput c : ent.getValue()) {
-          RobotComment e = createRobotCommentFromInput(ctx, path, c);
-          if (existingIds.contains(CommentSetEntry.create(e))) {
-            continue;
-          }
-          toAdd.add(e);
-        }
-      }
-      return toAdd;
-    }
-
-    private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
-      RobotComment robotComment =
-          commentsUtil.newRobotComment(
-              ctx,
-              path,
-              psId,
-              robotCommentInput.side(),
-              robotCommentInput.message,
-              robotCommentInput.robotId,
-              robotCommentInput.robotRunId);
-      robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
-      robotComment.url = robotCommentInput.url;
-      robotComment.properties = robotCommentInput.properties;
-      robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
-      robotComment.tag = in.tag;
-      commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
-      robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
-      return robotComment;
-    }
-
-    private List<FixSuggestion> createFixSuggestionsFromInput(
-        List<FixSuggestionInfo> fixSuggestionInfos) {
-      if (fixSuggestionInfos == null) {
-        return Collections.emptyList();
-      }
-
-      List<FixSuggestion> fixSuggestions = new ArrayList<>(fixSuggestionInfos.size());
-      for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
-        fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
-      }
-      return fixSuggestions;
-    }
-
-    private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
-      List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
-      String fixId = ChangeUtil.messageUuid();
-      return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
-    }
-
-    private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
-      return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
-    }
-
-    private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
-      Comment.Range range = new Comment.Range(fixReplacementInfo.range);
-      return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
-    }
-
-    private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
-      return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
-      return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
-          .map(CommentSetEntry::create)
-          .collect(toSet());
-    }
-
-    private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
-      return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
-          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-    }
-
-    private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
-      return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
-          .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
-    }
-
-    private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
-      Map<String, Short> labels = new HashMap<>();
-      for (PatchSetApproval psa : patchsetApprovals) {
-        labels.put(psa.label(), psa.value());
-      }
-      return labels;
-    }
-
-    private Map<String, Short> getAllApprovals(
-        LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
-      Map<String, Short> allApprovals = new HashMap<>();
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        allApprovals.put(lt.getName(), (short) 0);
-      }
-      // set approvals to existing votes
-      if (current != null) {
-        allApprovals.putAll(current);
-      }
-      // set approvals to new votes
-      if (input != null) {
-        allApprovals.putAll(input);
-      }
-      return allApprovals;
-    }
-
-    private Map<String, Short> getPreviousApprovals(
-        Map<String, Short> allApprovals, Map<String, Short> current) {
-      Map<String, Short> previous = new HashMap<>();
-      for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
-        // assume vote is 0 if there is no vote
-        if (!current.containsKey(approval.getKey())) {
-          previous.put(approval.getKey(), (short) 0);
-        } else {
-          previous.put(approval.getKey(), current.get(approval.getKey()));
-        }
-      }
-      return previous;
-    }
-
-    private boolean isReviewer(ChangeContext ctx) {
-      return approvalsUtil
-          .getReviewers(ctx.getNotes())
-          .byState(REVIEWER)
-          .contains(ctx.getAccountId());
-    }
-
-    private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
-        throws ResourceConflictException, IOException {
-      Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
-
-      // If no labels were modified and change is closed, abort early.
-      // This avoids trying to record a modified label caused by a user
-      // losing access to a label after the change was submitted.
-      if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
-        return false;
-      }
-
-      List<PatchSetApproval> del = new ArrayList<>();
-      List<PatchSetApproval> ups = new ArrayList<>();
-      Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-      Map<String, Short> allApprovals =
-          getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
-      Map<String, Short> previous =
-          getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
-
-      ChangeUpdate update = ctx.getUpdate(psId);
-      for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
-        String name = ent.getKey();
-        LabelType lt =
-            labelTypes
-                .byLabel(name)
-                .orElseThrow(() -> new IllegalStateException("no label config for " + name));
-
-        PatchSetApproval c = current.remove(lt.getName());
-        String normName = lt.getName();
-        approvals.put(normName, (short) 0);
-        if (ent.getValue() == null || ent.getValue() == 0) {
-          // User requested delete of this label.
-          oldApprovals.put(normName, null);
-          if (c != null) {
-            if (c.value() != 0) {
-              addLabelDelta(normName, (short) 0);
-              oldApprovals.put(normName, previous.get(normName));
-            }
-            del.add(c);
-            update.putApproval(normName, (short) 0);
-          }
-          // 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())
-                  .granted(ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag));
-          ctx.getUser().updateRealAccountId(b::realAccountId);
-          c = b.build();
-          ups.add(c);
-          addLabelDelta(normName, c.value());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.value());
-          update.putApproval(normName, ent.getValue());
-        } else if (c != null && c.value() == ent.getValue()) {
-          current.put(normName, c);
-          oldApprovals.put(normName, null);
-          approvals.put(normName, c.value());
-        } else if (c == null) {
-          c =
-              ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
-                  .tag(Optional.ofNullable(in.tag))
-                  .granted(ctx.getWhen())
-                  .build();
-          ups.add(c);
-          addLabelDelta(normName, c.value());
-          oldApprovals.put(normName, previous.get(normName));
-          approvals.put(normName, c.value());
-          update.putReviewer(user.getAccountId(), REVIEWER);
-          update.putApproval(normName, ent.getValue());
-        }
-      }
-
-      validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
-
-      // Return early if user is not a reviewer and not posting any labels.
-      // This allows us to preserve their CC status.
-      if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
-        return false;
-      }
-
-      return !del.isEmpty() || !ups.isEmpty();
-    }
-
-    /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
-    private boolean isApprovalCopiedOver(
-        PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
-      return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
-          .anyMatch(p -> p.equals(patchSetApproval));
-    }
-
-    private void validatePostSubmitLabels(
-        ChangeContext ctx,
-        LabelTypes labelTypes,
-        Map<String, Short> previous,
-        List<PatchSetApproval> ups,
-        List<PatchSetApproval> del)
-        throws ResourceConflictException {
-      if (ctx.getChange().isNew()) {
-        return; // Not closed, nothing to validate.
-      } else if (del.isEmpty() && ups.isEmpty()) {
-        return; // No new votes.
-      } else if (!ctx.getChange().isMerged()) {
-        throw new ResourceConflictException("change is closed");
-      }
-
-      // Disallow reducing votes on any labels post-submit. This assumes the
-      // high values were broadly necessary to submit, so reducing them would
-      // make it possible to take a merged change and make it no longer
-      // submittable.
-      List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
-      List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
-
-      for (PatchSetApproval psa : del) {
-        LabelType lt =
-            labelTypes
-                .byLabel(psa.label())
-                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
-        String normName = lt.getName();
-        if (!lt.isAllowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev != null && prev != 0) {
-          reduced.add(psa);
-        }
-      }
-
-      for (PatchSetApproval psa : ups) {
-        LabelType lt =
-            labelTypes
-                .byLabel(psa.label())
-                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
-        String normName = lt.getName();
-        if (!lt.isAllowPostSubmit()) {
-          disallowed.add(normName);
-        }
-        Short prev = previous.get(normName);
-        if (prev == null) {
-          continue;
-        }
-        if (prev > psa.value()) {
-          reduced.add(psa);
-        }
-        // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
-      }
-
-      if (!disallowed.isEmpty()) {
-        throw new ResourceConflictException(
-            "Voting on labels disallowed after submit: "
-                + disallowed.stream().distinct().sorted().collect(joining(", ")));
-      }
-      if (!reduced.isEmpty()) {
-        throw new ResourceConflictException(
-            "Cannot reduce vote on labels for closed change: "
-                + reduced.stream()
-                    .map(PatchSetApproval::label)
-                    .distinct()
-                    .sorted()
-                    .collect(joining(", ")));
-      }
-    }
-
-    private Map<String, PatchSetApproval> scanLabels(
-        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
-        throws IOException {
-      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
-      Map<String, PatchSetApproval> current = new HashMap<>();
-
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getNotes(),
-              psId,
-              user.getAccountId(),
-              ctx.getRevWalk(),
-              ctx.getRepoView().getConfig())) {
-        if (a.isLegacySubmit()) {
-          continue;
-        }
-
-        Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
-        if (lt.isPresent()) {
-          current.put(lt.get().getName(), a);
-        } else {
-          del.add(a);
-        }
-      }
-      return current;
-    }
-
-    private boolean insertMessage(ChangeContext ctx) {
-      String msg = Strings.nullToEmpty(in.message).trim();
-
-      StringBuilder buf = new StringBuilder();
-      for (LabelVote d : labelDelta) {
-        buf.append(" ").append(d.format());
-      }
-      if (comments.size() == 1) {
-        buf.append("\n\n(1 comment)");
-      } else if (comments.size() > 1) {
-        buf.append(String.format("\n\n(%d comments)", comments.size()));
-      }
-      if (!msg.isEmpty()) {
-        // Message was already validated when validating comments, since validators need to see
-        // everything in a single call.
-        buf.append("\n\n").append(msg);
-      } 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;
-      }
-
-      mailMessage =
-          cmUtil.setChangeMessage(
-              ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
-      return true;
-    }
-
-    private void addLabelDelta(String name, short value) {
-      labelDelta.add(LabelVote.create(name, value));
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
new file mode 100644
index 0000000..29e453b
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -0,0 +1,1146 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+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;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SortedSetMultimap;
+import com.google.common.collect.Streams;
+import com.google.common.collect.Table.Cell;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.FixSuggestion;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.FixSuggestionInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+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.ChangeUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.approval.ApprovalCopier;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.change.PostReview.CommentSetEntry;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+
+public class PostReviewOp implements BatchUpdateOp {
+  interface Factory {
+    PostReviewOp create(
+        ProjectState projectState, PatchSet.Id psId, ReviewInput in, Account.Id reviewerId);
+  }
+
+  /**
+   * Update of a copied label that has been performed on a follow-up patch set after a vote has been
+   * applied on an outdated patch set (follow-up patch sets = all patch sets that are newer than the
+   * outdated patch set on which the user voted).
+   */
+  @AutoValue
+  abstract static class CopiedLabelUpdate {
+    /**
+     * Type of the update that has been performed for a copied vote on a follow-up patch set.
+     *
+     * <p>Whether the copied vote has been added
+     *
+     * <ul>
+     *   <li>added to
+     *   <li>updated on
+     *   <li>removed from
+     * </ul>
+     *
+     * a follow-up patch set.
+     */
+    enum Type {
+      /** A copied vote was added. No copied vote existed for this label yet. */
+      ADDED,
+
+      /** An existing copied vote has been updated. */
+      UPDATED,
+
+      /** An existing copied vote has been removed. */
+      REMOVED;
+    }
+
+    /** The ID of the (follow-up) patch set on which the copied label update has been performed. */
+    abstract PatchSet.Id patchSetId();
+
+    /**
+     * The old copied label vote that has been updated or that has been removed.
+     *
+     * <p>Not set if {@link #type()} is {@link Type#ADDED}.
+     */
+    abstract Optional<LabelVote> oldLabelVote();
+
+    /**
+     * The type of the update that has been performed for the copied vote on the (follow-up) patch
+     * set.
+     */
+    abstract Type type();
+
+    /** Returns a string with the patch set number and if present the old label vote. */
+    private String formatPatchSetWithOldLabelVote() {
+      StringBuilder b = new StringBuilder();
+      b.append(patchSetId().get());
+      if (oldLabelVote().isPresent()) {
+        b.append(" (was ").append(oldLabelVote().get().format()).append(")");
+      }
+      return b.toString();
+    }
+
+    private static CopiedLabelUpdate added(PatchSet.Id patchSetId) {
+      return create(patchSetId, Optional.empty(), Type.ADDED);
+    }
+
+    private static CopiedLabelUpdate updated(PatchSet.Id patchSetId, LabelVote oldLabelVote) {
+      return create(patchSetId, Optional.of(oldLabelVote), Type.UPDATED);
+    }
+
+    private static CopiedLabelUpdate removed(PatchSet.Id patchSetId, LabelVote oldLabelVote) {
+      return create(patchSetId, Optional.of(oldLabelVote), Type.REMOVED);
+    }
+
+    private static CopiedLabelUpdate create(
+        PatchSet.Id patchSetId, Optional<LabelVote> oldLabelVote, Type type) {
+      return new AutoValue_PostReviewOp_CopiedLabelUpdate(patchSetId, oldLabelVote, type);
+    }
+  }
+
+  @VisibleForTesting
+  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
+
+  private final ApprovalCopier approvalCopier;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final CommentsUtil commentsUtil;
+  private final PublishCommentUtil publishCommentUtil;
+  private final PatchSetUtil psUtil;
+  private final EmailReviewComments.Factory email;
+  private final CommentAdded commentAdded;
+  private final PluginSetContext<CommentValidator> commentValidators;
+  private final PluginSetContext<OnPostReview> onPostReviews;
+
+  private final ProjectState projectState;
+  private final PatchSet.Id psId;
+  private final ReviewInput in;
+  private final Account.Id reviewerId;
+  private final boolean publishPatchSetLevelComment;
+
+  private IdentifiedUser user;
+  private ChangeNotes notes;
+  private PatchSet ps;
+  private String mailMessage;
+  private List<Comment> comments = new ArrayList<>();
+  private List<LabelVote> labelDelta = new ArrayList<>();
+  private SortedSetMultimap<LabelVote, CopiedLabelUpdate> labelUpdatesOnFollowUpPatchSets =
+      MultimapBuilder.hashKeys().treeSetValues(comparing(CopiedLabelUpdate::patchSetId)).build();
+  private Map<String, Short> approvals = new HashMap<>();
+  private Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  PostReviewOp(
+      @GerritServerConfig Config gerritConfig,
+      ApprovalCopier approvalCopier,
+      ApprovalsUtil approvalsUtil,
+      ChangeMessagesUtil cmUtil,
+      CommentsUtil commentsUtil,
+      PublishCommentUtil publishCommentUtil,
+      PatchSetUtil psUtil,
+      EmailReviewComments.Factory email,
+      CommentAdded commentAdded,
+      PluginSetContext<CommentValidator> commentValidators,
+      PluginSetContext<OnPostReview> onPostReviews,
+      @Assisted ProjectState projectState,
+      @Assisted PatchSet.Id psId,
+      @Assisted ReviewInput in,
+      @Assisted Account.Id reviewerId) {
+    this.approvalCopier = approvalCopier;
+    this.approvalsUtil = approvalsUtil;
+    this.publishCommentUtil = publishCommentUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.commentsUtil = commentsUtil;
+    this.email = email;
+    this.commentAdded = commentAdded;
+    this.commentValidators = commentValidators;
+    this.onPostReviews = onPostReviews;
+    this.publishPatchSetLevelComment =
+        gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
+
+    this.projectState = projectState;
+    this.psId = psId;
+    this.in = in;
+    this.reviewerId = reviewerId;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws ResourceConflictException, UnprocessableEntityException, IOException,
+          CommentsRejectedException {
+    user = ctx.getIdentifiedUser();
+    notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getNotes(), psId);
+    List<RobotComment> newRobotComments =
+        in.robotComments == null ? ImmutableList.of() : getNewRobotComments(ctx);
+    boolean dirty = false;
+    try (TraceContext.TraceTimer ignored = newTimer("insertComments")) {
+      dirty |= insertComments(ctx, newRobotComments);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("insertRobotComments")) {
+      dirty |= insertRobotComments(ctx, newRobotComments);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
+      dirty |= updateLabels(projectState, ctx);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("updateCopiedApprovals")) {
+      dirty |= updateCopiedApprovalsOnFollowUpPatchSets(ctx);
+    }
+    try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
+      dirty |= insertMessage(ctx);
+    }
+    return dirty;
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (mailMessage == null) {
+      return;
+    }
+    email
+        .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
+        .sendAsync();
+    String comment = mailMessage;
+    if (publishPatchSetLevelComment) {
+      // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
+      // added event. For backwards compatibility, patchset level comment has a higher priority
+      // than change message and should be used as comment in comment added event.
+      if (in.comments != null && in.comments.containsKey(PATCHSET_LEVEL)) {
+        List<CommentInput> patchSetLevelComments = in.comments.get(PATCHSET_LEVEL);
+        if (patchSetLevelComments != null && !patchSetLevelComments.isEmpty()) {
+          CommentInput firstComment = patchSetLevelComments.get(0);
+          if (!Strings.isNullOrEmpty(firstComment.message)) {
+            comment = String.format("Patch Set %s:\n\n%s", psId.get(), firstComment.message);
+          }
+        }
+      }
+    }
+    commentAdded.fire(
+        ctx.getChangeData(notes),
+        ps,
+        user.state(),
+        comment,
+        approvals,
+        oldApprovals,
+        ctx.getWhen());
+  }
+
+  /**
+   * Publishes draft and input comments. Input comments are those passed as input in the request
+   * body.
+   *
+   * @param ctx context for performing the change update.
+   * @param newRobotComments robot comments. Used only for validation in this method.
+   * @return true if any input comments where published.
+   */
+  private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
+      throws CommentsRejectedException {
+    Map<String, List<CommentInput>> inputComments = in.comments;
+    if (inputComments == null) {
+      inputComments = Collections.emptyMap();
+    }
+
+    // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
+    Map<String, HumanComment> drafts = new HashMap<>();
+
+    if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
+      drafts =
+          in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
+              ? changeDrafts(ctx)
+              : patchSetDrafts(ctx);
+    }
+
+    // Existing published comments
+    Set<CommentSetEntry> existingComments =
+        in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
+
+    // Input comments should be deduplicated from existing drafts
+    List<HumanComment> inputCommentsToPublish =
+        resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
+
+    switch (in.drafts) {
+      case PUBLISH:
+      case PUBLISH_ALL_REVISIONS:
+        Collection<HumanComment> filteredDrafts =
+            in.draftIdsToPublish == null
+                ? drafts.values()
+                : drafts.values().stream()
+                    .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
+                    .collect(Collectors.toList());
+
+        validateComments(
+            ctx,
+            Streams.concat(
+                drafts.values().stream(),
+                inputCommentsToPublish.stream(),
+                newRobotComments.stream()));
+        publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
+        comments.addAll(drafts.values());
+        break;
+      case KEEP:
+        validateComments(
+            ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
+        break;
+    }
+    commentsUtil.putHumanComments(
+        ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
+    comments.addAll(inputCommentsToPublish);
+    return !inputCommentsToPublish.isEmpty();
+  }
+
+  /**
+   * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
+   * neither in {@code existingComments} nor in {@code drafts}.
+   *
+   * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
+   * removed.
+   *
+   * @param inputComments new comments provided as {@link CommentInput} entries in the API.
+   * @param existingComments existing published comments in the database.
+   * @param drafts existing draft comments in the database. This map can be modified.
+   */
+  private List<HumanComment> resolveInputCommentsAndDrafts(
+      Map<String, List<CommentInput>> inputComments,
+      Set<CommentSetEntry> existingComments,
+      Map<String, HumanComment> drafts,
+      ChangeContext ctx) {
+    List<HumanComment> inputCommentsToPublish = new ArrayList<>();
+    for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
+      String path = entry.getKey();
+      for (CommentInput inputComment : entry.getValue()) {
+        HumanComment comment = drafts.remove(Url.decode(inputComment.id));
+        if (comment == null) {
+          String parent = Url.decode(inputComment.inReplyTo);
+          comment =
+              commentsUtil.newHumanComment(
+                  ctx.getNotes(),
+                  ctx.getUser(),
+                  ctx.getWhen(),
+                  path,
+                  psId,
+                  inputComment.side(),
+                  inputComment.message,
+                  inputComment.unresolved,
+                  parent);
+        } else {
+          // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
+          comment.writtenOn = Timestamp.from(ctx.getWhen());
+          comment.side = inputComment.side();
+          comment.message = inputComment.message;
+        }
+
+        commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
+        comment.setLineNbrAndRange(inputComment.line, inputComment.range);
+        comment.tag = in.tag;
+
+        if (existingComments.contains(CommentSetEntry.create(comment))) {
+          continue;
+        }
+        inputCommentsToPublish.add(comment);
+      }
+    }
+    return inputCommentsToPublish;
+  }
+
+  /**
+   * Validates all comments and the change message in a single call to fulfill the interface
+   * contract of {@link CommentValidator#validateComments(CommentValidationContext, ImmutableList)}.
+   */
+  private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
+      throws CommentsRejectedException {
+    CommentValidationContext commentValidationCtx =
+        CommentValidationContext.create(
+            ctx.getChange().getChangeId(),
+            ctx.getChange().getProject().get(),
+            ctx.getChange().getDest().branch());
+    String changeMessage = Strings.nullToEmpty(in.message).trim();
+    ImmutableList<CommentForValidation> draftsForValidation =
+        Stream.concat(
+                comments.map(
+                    comment ->
+                        CommentForValidation.create(
+                            comment instanceof RobotComment
+                                ? CommentForValidation.CommentSource.ROBOT
+                                : CommentForValidation.CommentSource.HUMAN,
+                            comment.lineNbr > 0
+                                ? CommentForValidation.CommentType.INLINE_COMMENT
+                                : CommentForValidation.CommentType.FILE_COMMENT,
+                            comment.message,
+                            comment.getApproximateSize())),
+                Stream.of(
+                    CommentForValidation.create(
+                        CommentForValidation.CommentSource.HUMAN,
+                        CommentForValidation.CommentType.CHANGE_MESSAGE,
+                        changeMessage,
+                        changeMessage.length())))
+            .collect(toImmutableList());
+    ImmutableList<CommentValidationFailure> draftValidationFailures =
+        PublishCommentUtil.findInvalidComments(
+            commentValidationCtx, commentValidators, draftsForValidation);
+    if (!draftValidationFailures.isEmpty()) {
+      throw new CommentsRejectedException(draftValidationFailures);
+    }
+  }
+
+  private boolean insertRobotComments(ChangeContext ctx, List<RobotComment> newRobotComments) {
+    if (in.robotComments == null) {
+      return false;
+    }
+    commentsUtil.putRobotComments(ctx.getUpdate(psId), newRobotComments);
+    comments.addAll(newRobotComments);
+    return !newRobotComments.isEmpty();
+  }
+
+  private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
+    List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
+
+    Set<CommentSetEntry> existingIds =
+        in.omitDuplicateComments ? readExistingRobotComments(ctx) : Collections.emptySet();
+
+    for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
+      String path = ent.getKey();
+      for (RobotCommentInput c : ent.getValue()) {
+        RobotComment e = createRobotCommentFromInput(ctx, path, c);
+        if (existingIds.contains(CommentSetEntry.create(e))) {
+          continue;
+        }
+        toAdd.add(e);
+      }
+    }
+    return toAdd;
+  }
+
+  private RobotComment createRobotCommentFromInput(
+      ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
+    RobotComment robotComment =
+        commentsUtil.newRobotComment(
+            ctx,
+            path,
+            psId,
+            robotCommentInput.side(),
+            robotCommentInput.message,
+            robotCommentInput.robotId,
+            robotCommentInput.robotRunId);
+    robotComment.parentUuid = Url.decode(robotCommentInput.inReplyTo);
+    robotComment.url = robotCommentInput.url;
+    robotComment.properties = robotCommentInput.properties;
+    robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
+    robotComment.tag = in.tag;
+    commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
+    robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
+    return robotComment;
+  }
+
+  private ImmutableList<FixSuggestion> createFixSuggestionsFromInput(
+      List<FixSuggestionInfo> fixSuggestionInfos) {
+    if (fixSuggestionInfos == null) {
+      return ImmutableList.of();
+    }
+
+    ImmutableList.Builder<FixSuggestion> fixSuggestions =
+        ImmutableList.builderWithExpectedSize(fixSuggestionInfos.size());
+    for (FixSuggestionInfo fixSuggestionInfo : fixSuggestionInfos) {
+      fixSuggestions.add(createFixSuggestionFromInput(fixSuggestionInfo));
+    }
+    return fixSuggestions.build();
+  }
+
+  private FixSuggestion createFixSuggestionFromInput(FixSuggestionInfo fixSuggestionInfo) {
+    List<FixReplacement> fixReplacements = toFixReplacements(fixSuggestionInfo.replacements);
+    String fixId = ChangeUtil.messageUuid();
+    return new FixSuggestion(fixId, fixSuggestionInfo.description, fixReplacements);
+  }
+
+  private List<FixReplacement> toFixReplacements(List<FixReplacementInfo> fixReplacementInfos) {
+    return fixReplacementInfos.stream().map(this::toFixReplacement).collect(toList());
+  }
+
+  private FixReplacement toFixReplacement(FixReplacementInfo fixReplacementInfo) {
+    Comment.Range range = new Comment.Range(fixReplacementInfo.range);
+    return new FixReplacement(fixReplacementInfo.path, range, fixReplacementInfo.replacement);
+  }
+
+  private Set<CommentSetEntry> readExistingComments(ChangeContext ctx) {
+    return commentsUtil.publishedHumanCommentsByChange(ctx.getNotes()).stream()
+        .map(CommentSetEntry::create)
+        .collect(toSet());
+  }
+
+  private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx) {
+    return commentsUtil.robotCommentsByChange(ctx.getNotes()).stream()
+        .map(CommentSetEntry::create)
+        .collect(toSet());
+  }
+
+  private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
+    return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
+    return commentsUtil.draftByPatchSetAuthor(psId, user.getAccountId(), ctx.getNotes()).stream()
+        .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+  }
+
+  private Map<String, Short> approvalsByKey(Collection<PatchSetApproval> patchsetApprovals) {
+    Map<String, Short> labels = new HashMap<>();
+    for (PatchSetApproval psa : patchsetApprovals) {
+      labels.put(psa.label(), psa.value());
+    }
+    return labels;
+  }
+
+  private Map<String, Short> getAllApprovals(
+      LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
+    Map<String, Short> allApprovals = new HashMap<>();
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      allApprovals.put(lt.getName(), (short) 0);
+    }
+    // set approvals to existing votes
+    if (current != null) {
+      allApprovals.putAll(current);
+    }
+    // set approvals to new votes
+    if (input != null) {
+      allApprovals.putAll(input);
+    }
+    return allApprovals;
+  }
+
+  private Map<String, Short> getPreviousApprovals(
+      Map<String, Short> allApprovals, Map<String, Short> current) {
+    Map<String, Short> previous = new HashMap<>();
+    for (Map.Entry<String, Short> approval : allApprovals.entrySet()) {
+      // assume vote is 0 if there is no vote
+      if (!current.containsKey(approval.getKey())) {
+        previous.put(approval.getKey(), (short) 0);
+      } else {
+        previous.put(approval.getKey(), current.get(approval.getKey()));
+      }
+    }
+    return previous;
+  }
+
+  private boolean isReviewer(ChangeContext ctx) {
+    return approvalsUtil
+        .getReviewers(ctx.getNotes())
+        .byState(REVIEWER)
+        .contains(ctx.getAccountId());
+  }
+
+  private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
+      throws ResourceConflictException {
+    Map<String, Short> inLabels = firstNonNull(in.labels, Collections.emptyMap());
+
+    // If no labels were modified and change is closed, abort early.
+    // This avoids trying to record a modified label caused by a user
+    // losing access to a label after the change was submitted.
+    if (inLabels.isEmpty() && ctx.getChange().isClosed()) {
+      return false;
+    }
+
+    List<PatchSetApproval> del = new ArrayList<>();
+    List<PatchSetApproval> ups = new ArrayList<>();
+    Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
+    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+    Map<String, Short> allApprovals =
+        getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
+    Map<String, Short> previous =
+        getPreviousApprovals(allApprovals, approvalsByKey(current.values()));
+
+    ChangeUpdate update = ctx.getUpdate(psId);
+    for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
+      String name = ent.getKey();
+      LabelType lt =
+          labelTypes
+              .byLabel(name)
+              .orElseThrow(() -> new IllegalStateException("no label config for " + name));
+
+      PatchSetApproval c = current.remove(lt.getName());
+      String normName = lt.getName();
+      approvals.put(normName, (short) 0);
+      if (ent.getValue() == null || ent.getValue() == 0) {
+        // User requested delete of this label.
+        oldApprovals.put(normName, null);
+        if (c != null) {
+          if (c.value() != 0) {
+            addLabelDelta(normName, (short) 0);
+            oldApprovals.put(normName, previous.get(normName));
+          }
+          del.add(c);
+          update.putApproval(normName, (short) 0);
+        }
+        // Only allow voting again the values are different, if the real account differs or if the
+        // vote is copied over from a past patch-set.
+      } else if (c != null
+          && (c.value() != ent.getValue()
+              || !c.realAccountId().equals(reviewerId)
+              || (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
+        PatchSetApproval.Builder b =
+            c.toBuilder()
+                .value(ent.getValue())
+                .granted(ctx.getWhen())
+                .tag(Optional.ofNullable(in.tag));
+        ctx.getUser().updateRealAccountId(b::realAccountId);
+        c = b.build();
+        ups.add(c);
+        addLabelDelta(normName, c.value());
+        oldApprovals.put(normName, previous.get(normName));
+        approvals.put(normName, c.value());
+        update.putApproval(normName, ent.getValue());
+      } else if (c != null && c.value() == ent.getValue()) {
+        current.put(normName, c);
+        oldApprovals.put(normName, null);
+        approvals.put(normName, c.value());
+      } else if (c == null) {
+        c =
+            ApprovalsUtil.newApproval(psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen())
+                .tag(Optional.ofNullable(in.tag))
+                .granted(ctx.getWhen())
+                .build();
+        ups.add(c);
+        addLabelDelta(normName, c.value());
+        oldApprovals.put(normName, previous.get(normName));
+        approvals.put(normName, c.value());
+        update.putReviewer(user.getAccountId(), REVIEWER);
+        update.putApproval(normName, ent.getValue());
+      }
+    }
+
+    validatePostSubmitLabels(ctx, labelTypes, previous, ups, del);
+
+    // Return early if user is not a reviewer and not posting any labels.
+    // This allows us to preserve their CC status.
+    if (current.isEmpty() && del.isEmpty() && ups.isEmpty() && !isReviewer(ctx)) {
+      return false;
+    }
+
+    return !del.isEmpty() || !ups.isEmpty();
+  }
+
+  /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
+  private boolean isApprovalCopiedOver(PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
+    return !changeNotes.getApprovals().onlyNonCopied()
+        .get(changeNotes.getChange().currentPatchSetId()).stream()
+        .anyMatch(p -> p.equals(patchSetApproval));
+  }
+
+  private void validatePostSubmitLabels(
+      ChangeContext ctx,
+      LabelTypes labelTypes,
+      Map<String, Short> previous,
+      List<PatchSetApproval> ups,
+      List<PatchSetApproval> del)
+      throws ResourceConflictException {
+    if (ctx.getChange().isNew()) {
+      return; // Not closed, nothing to validate.
+    } else if (del.isEmpty() && ups.isEmpty()) {
+      return; // No new votes.
+    } else if (!ctx.getChange().isMerged()) {
+      throw new ResourceConflictException("change is closed");
+    }
+
+    // Disallow reducing votes on any labels post-submit. This assumes the
+    // high values were broadly necessary to submit, so reducing them would
+    // make it possible to take a merged change and make it no longer
+    // submittable.
+    List<PatchSetApproval> reduced = new ArrayList<>(ups.size() + del.size());
+    List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
+
+    for (PatchSetApproval psa : del) {
+      LabelType lt =
+          labelTypes
+              .byLabel(psa.label())
+              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+      String normName = lt.getName();
+      if (!lt.isAllowPostSubmit()) {
+        disallowed.add(normName);
+      }
+      Short prev = previous.get(normName);
+      if (prev != null && prev != 0) {
+        reduced.add(psa);
+      }
+    }
+
+    for (PatchSetApproval psa : ups) {
+      LabelType lt =
+          labelTypes
+              .byLabel(psa.label())
+              .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
+      String normName = lt.getName();
+      if (!lt.isAllowPostSubmit()) {
+        disallowed.add(normName);
+      }
+      Short prev = previous.get(normName);
+      if (prev == null) {
+        continue;
+      }
+      if (prev > psa.value()) {
+        reduced.add(psa);
+      }
+      // No need to set postSubmit bit, which is set automatically when parsing from NoteDb.
+    }
+
+    if (!disallowed.isEmpty()) {
+      throw new ResourceConflictException(
+          "Voting on labels disallowed after submit: "
+              + disallowed.stream().distinct().sorted().collect(joining(", ")));
+    }
+    if (!reduced.isEmpty()) {
+      throw new ResourceConflictException(
+          "Cannot reduce vote on labels for closed change: "
+              + reduced.stream()
+                  .map(PatchSetApproval::label)
+                  .distinct()
+                  .sorted()
+                  .collect(joining(", ")));
+    }
+  }
+
+  private Map<String, PatchSetApproval> scanLabels(
+      ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del) {
+    LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes());
+    Map<String, PatchSetApproval> current = new HashMap<>();
+
+    for (PatchSetApproval a :
+        approvalsUtil.byPatchSetUser(ctx.getNotes(), psId, user.getAccountId())) {
+      if (a.isLegacySubmit()) {
+        continue;
+      }
+
+      Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
+      if (lt.isPresent()) {
+        current.put(lt.get().getName(), a);
+      } else {
+        del.add(a);
+      }
+    }
+    return current;
+  }
+
+  /**
+   * Copies approvals that have been newly applied on outdated patch sets to the follow-up patch
+   * sets if they are copyable and no non-copied approvals prevent the copying.
+   *
+   * <p>Must be invoked after the new approvals on outdated patch sets have been applied (e.g. after
+   * {@link #updateLabels(ProjectState, ChangeContext)}.
+   *
+   * @param ctx the change context
+   * @return {@code true} if an update was done, otherwise {@code false}
+   */
+  private boolean updateCopiedApprovalsOnFollowUpPatchSets(ChangeContext ctx) throws IOException {
+    if (ctx.getNotes().getCurrentPatchSet().id().equals(psId)) {
+      // the updated patch set is the current patch, there a no follow-up patch set to which new
+      // approvals could be copied
+      return false;
+    }
+
+    // compute follow-up patch sets (sorted by patch set ID)
+    ImmutableList<PatchSet.Id> followUpPatchSets =
+        ctx.getNotes().getPatchSets().keySet().stream()
+            .filter(patchSetId -> patchSetId.get() > psId.get())
+            .collect(toImmutableList());
+
+    boolean dirty = false;
+    ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals =
+        ctx.getUpdate(psId).getApprovals();
+    for (Cell<String, Account.Id, Optional<PatchSetApproval>> cell : newApprovals.cellSet()) {
+      PatchSetApproval psaOrig = cell.getValue().get();
+
+      if (isRemoval(cell)) {
+        if (removeCopies(ctx, followUpPatchSets, psaOrig)) {
+          dirty = true;
+        }
+        continue;
+      }
+
+      PatchSet patchSet = psUtil.get(ctx.getNotes(), psId);
+
+      // Target patch sets to which the approval is copyable.
+      ImmutableList<PatchSet.Id> targetPatchSets =
+          approvalCopier.forApproval(
+              ctx.getNotes(), patchSet, psaOrig.accountId(), psaOrig.label(), psaOrig.value());
+
+      // Iterate over all follow-up patch sets, in patch set order.
+      for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
+        if (hasOverrideOf(ctx, followUpPatchSetId, psaOrig.key())) {
+          // a non-copied approval exists that overrides any copied approval
+          // -> do not copy the approval to this patch set nor to any follow-up patch sets
+          break;
+        }
+
+        if (targetPatchSets.contains(followUpPatchSetId)) {
+          // The approval is copyable to the new patch set.
+
+          if (hasCopyOfWithValue(ctx, followUpPatchSetId, psaOrig)) {
+            // a copy approval with the exact value already exists
+            continue;
+          }
+
+          // add/update the copied approval on the target patch set
+          Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSetId, psaOrig.key());
+          PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
+          ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
+          labelUpdatesOnFollowUpPatchSets.put(
+              LabelVote.createFrom(psaOrig),
+              copiedPsa.isPresent()
+                  ? CopiedLabelUpdate.updated(
+                      followUpPatchSetId, LabelVote.createFrom(copiedPsa.get()))
+                  : CopiedLabelUpdate.added(followUpPatchSetId));
+          dirty = true;
+        } else {
+          // The approval is not copyable to the new patch set.
+          Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSetId, psaOrig.key());
+          if (copiedPsa.isPresent()) {
+            // a copy approval exists and should be removed
+            removeCopy(ctx, psaOrig, copiedPsa.get());
+            dirty = true;
+          }
+        }
+      }
+    }
+
+    return dirty;
+  }
+
+  /**
+   * Whether the given cell entry from the approval table represents the removal of an approval.
+   *
+   * @param cell cell entry from the approval table
+   * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
+   *     otherwise {@code false}
+   */
+  private boolean isRemoval(Cell<String, Account.Id, Optional<PatchSetApproval>> cell) {
+    return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
+  }
+
+  /**
+   * Removes copies of the given approval from all follow-up patch sets.
+   *
+   * @param ctx the change context
+   * @param followUpPatchSets the follow-up patch sets of the patch set on which the review is
+   *     posted
+   * @param psaOrig the original patch set approval for which copies should be removed from all
+   *     follow-up patch sets
+   * @return whether any copy approval has been removed
+   */
+  private boolean removeCopies(
+      ChangeContext ctx, ImmutableList<PatchSet.Id> followUpPatchSets, PatchSetApproval psaOrig) {
+    boolean dirty = false;
+    for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
+      Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSet, psaOrig.key());
+      if (copiedPsa.isPresent()) {
+        removeCopy(ctx, psaOrig, copiedPsa.get());
+      } else {
+        // Do not remove copy from this follow-up patch sets and also not from any further follow-up
+        // patch sets (if the further follow-up patch sets have copies they are copies of a
+        // non-copied approval on this follow-up patch set and hence those should not be removed).
+        break;
+      }
+    }
+    return dirty;
+  }
+
+  /**
+   * Removes the copy approval with the given key from the given patch set.
+   *
+   * @param ctx the change context
+   * @param psaOrig the original patch set approval for which copies should be removed from the
+   *     given patch set
+   * @param copiedPsa the copied patch set approval that should be removed
+   */
+  private void removeCopy(ChangeContext ctx, PatchSetApproval psaOrig, PatchSetApproval copiedPsa) {
+    ctx.getUpdate(copiedPsa.patchSetId())
+        .removeCopiedApprovalFor(
+            ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
+                ? ctx.getIdentifiedUser().getRealUser().getAccountId()
+                : null,
+            copiedPsa.accountId(),
+            copiedPsa.labelId().get());
+    labelUpdatesOnFollowUpPatchSets.put(
+        LabelVote.createFrom(psaOrig),
+        CopiedLabelUpdate.removed(copiedPsa.patchSetId(), LabelVote.createFrom(copiedPsa)));
+  }
+
+  /**
+   * Retrieves the copy of the given approval from the given patch set if it exists.
+   *
+   * @param ctx the change context
+   * @param patchSetId the ID of the patch from which it the copied approval should be returned
+   * @param psaKey the key of the patch set approval for which the copied approval should be
+   *     returned
+   * @return the copy of the given approval from the given patch set if it exists
+   */
+  private Optional<PatchSetApproval> getCopyOf(
+      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+        .filter(psa -> areAccountAndLabelTheSame(psa.key(), psaKey))
+        .findAny();
+  }
+
+  /**
+   * Whether the given patch set has a copy approval with the given key and value.
+   *
+   * @param ctx the change context
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
+   *     approval with the given key and value
+   * @param psaOrig the original patch set approval
+   */
+  private boolean hasCopyOfWithValue(
+      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval psaOrig) {
+    return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+        .anyMatch(
+            psa ->
+                areAccountAndLabelTheSame(psa.key(), psaOrig.key())
+                    && psa.value() == psaOrig.value());
+  }
+
+  /**
+   * Whether the given patch set has a normal approval with the given key that overrides copy
+   * approvals with that key.
+   *
+   * @param ctx the change context
+   * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
+   *     approval with the given key that overrides copy approvals with that key
+   * @param psaKey the key of the patch set approval
+   */
+  private boolean hasOverrideOf(
+      ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+    return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
+        .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
+  }
+
+  private boolean areAccountAndLabelTheSame(
+      PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
+    return psaKey1.accountId().equals(psaKey2.accountId())
+        && psaKey1.labelId().equals(psaKey2.labelId());
+  }
+
+  private boolean insertMessage(ChangeContext ctx) {
+    String msg = Strings.nullToEmpty(in.message).trim();
+
+    StringBuilder buf = new StringBuilder();
+    for (String formattedLabelVote :
+        labelDelta.stream().map(LabelVote::format).sorted().collect(toImmutableList())) {
+      buf.append(" ").append(formattedLabelVote);
+    }
+    if (!labelUpdatesOnFollowUpPatchSets.isEmpty()) {
+      buf.append("\n\nCopied votes on follow-up patch sets have been updated:");
+      for (Map.Entry<LabelVote, Collection<CopiedLabelUpdate>> e :
+          labelUpdatesOnFollowUpPatchSets.asMap().entrySet().stream()
+              .sorted(Map.Entry.comparingByKey(comparing(LabelVote::label)))
+              .collect(toImmutableList())) {
+        Optional<String> copyCondition =
+            projectState
+                .getLabelTypes(ctx.getNotes())
+                .byLabel(e.getKey().label())
+                .map(LabelType::getCopyCondition)
+                .map(Optional::get);
+        buf.append(formatVotesCopiedToFollowUpPatchSets(e.getKey(), e.getValue(), copyCondition));
+      }
+    }
+    if (comments.size() == 1) {
+      buf.append("\n\n(1 comment)");
+    } else if (comments.size() > 1) {
+      buf.append(String.format("\n\n(%d comments)", comments.size()));
+    }
+    if (!msg.isEmpty()) {
+      // Message was already validated when validating comments, since validators need to see
+      // everything in a single call.
+      buf.append("\n\n").append(msg);
+    } 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;
+    }
+
+    mailMessage =
+        cmUtil.setChangeMessage(ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
+    return true;
+  }
+
+  /**
+   * Given a label vote that has been applied on an outdated patch set, this method formats the
+   * updates to the copied labels on the follow-up patch sets that have been performed for that
+   * label vote.
+   *
+   * <p>If label votes have been copied to follow-up patch sets the formatted message is
+   * "<label-vote> has been copied to patch sets: 3, 4 (copy condition: "<copy-condition>").".
+   *
+   * <p>If existing copied votes on follow-up patch sets have been updated, the old copied votes are
+   * included into the message: "<label-vote> has been copied to patch sets: 3 (was
+   * <old-label-vote>), 4 (was <old-label-vote>) (copy condition: "<copy-condition>").".
+   *
+   * <p>If existing copied votes on follow-up patch sets have been removed (because the new vote is
+   * not copyable) the message is: "Copied <label> vote has been removed from patch set 3 (was
+   * <old-label-vote>), 4 (was <old-label-vote>) (copy condition: "<copy-condition>").".
+   *
+   * <p>If copied votes have been both added/updated and removed, 2 messages are returned.
+   *
+   * <p>Each returned message is formatted as a list item (prefixed with '* ').
+   *
+   * <p>Passing atoms in copy conditions are not highlighted. This is because the passing atoms can
+   * be different for different follow-up patch sets (e.g. 'changekind:TRIVIAL_REBASE OR
+   * changekind:NO_CODE_CHANGE' can have 'changekind:TRIVIAL_REBASE' passing for one follow-up patch
+   * set and 'changekind:NO_CODE_CHANGE' passing for another follow-up patch set). Including the
+   * copy condition once per follow-up patch set with differently highlighted passing atoms would
+   * make the message unreadable. Hence we don't highlight passing atoms here.
+   *
+   * @param labelVote the label vote that has been applied on an outdated patch set
+   * @param followUpPatchSetUpdates updates to copied votes on follow-up patch sets that have been
+   *     done by copying the label vote on the outdated patch set to follow-up patch sets
+   * @param copyCondition the copy condition of the label for which a vote was applied on an
+   *     outdated patch set
+   * @return formatted string to be included into a change message
+   */
+  private String formatVotesCopiedToFollowUpPatchSets(
+      LabelVote labelVote,
+      Collection<CopiedLabelUpdate> followUpPatchSetUpdates,
+      Optional<String> copyCondition) {
+    StringBuilder b = new StringBuilder();
+
+    // Add line for added/updated copied approvals.
+    ImmutableList<CopiedLabelUpdate> additionsAndUpdates =
+        followUpPatchSetUpdates.stream()
+            .filter(
+                copiedLabelUpdate ->
+                    copiedLabelUpdate.type() == CopiedLabelUpdate.Type.ADDED
+                        || copiedLabelUpdate.type() == CopiedLabelUpdate.Type.UPDATED)
+            .collect(toImmutableList());
+    if (!additionsAndUpdates.isEmpty()) {
+      b.append("\n* ");
+      b.append(labelVote.format());
+      b.append(" has been copied to patch set ");
+      b.append(
+          additionsAndUpdates.stream()
+              .map(CopiedLabelUpdate::formatPatchSetWithOldLabelVote)
+              .collect(joining(", ")));
+      copyCondition.ifPresent(cc -> b.append(" (copy condition: \"" + cc + "\")"));
+      b.append(".");
+    }
+
+    // Add line for removed copied approvals.
+    ImmutableList<CopiedLabelUpdate> removals =
+        followUpPatchSetUpdates.stream()
+            .filter(copiedLabelUpdate -> copiedLabelUpdate.type() == CopiedLabelUpdate.Type.REMOVED)
+            .collect(toImmutableList());
+    if (!removals.isEmpty()) {
+      b.append("\n* Copied ");
+      b.append(labelVote.label());
+      b.append(" vote has been removed from patch set ");
+      b.append(
+          removals.stream()
+              .map(CopiedLabelUpdate::formatPatchSetWithOldLabelVote)
+              .collect(joining(", ")));
+      b.append(" since the new ");
+      b.append(labelVote.value() != 0 ? labelVote.format() : labelVote.formatWithEquals());
+      b.append(" vote is not copyable");
+      copyCondition.ifPresent(cc -> b.append(" (copy condition: \"" + cc + "\")"));
+      b.append(".");
+    }
+    return b.toString();
+  }
+
+  private void addLabelDelta(String name, short value) {
+    labelDelta.add(LabelVote.create(name, value));
+  }
+
+  private TraceContext.TraceTimer newTimer(String method) {
+    return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 4691550..9bc80a4 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -70,8 +70,7 @@
     if (modification.op == null) {
       return Response.ok(modification.result);
     }
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       bu.setNotify(resolveNotify(rsrc, input));
       Change.Id id = rsrc.getChange().getId();
       bu.addOp(id, modification.op);
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewFix.java b/java/com/google/gerrit/server/restapi/change/PreviewFix.java
new file mode 100644
index 0000000..e771898
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PreviewFix.java
@@ -0,0 +1,201 @@
+// 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.restapi.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.PatchScript;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffWebLinkInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.FixResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.diff.DiffInfoCreator;
+import com.google.gerrit.server.diff.DiffSide;
+import com.google.gerrit.server.diff.DiffWebLinksProvider;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+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.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Repository;
+
+public class PreviewFix {
+  public interface Factory {
+    PreviewFix create(RevisionResource revisionResource);
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory;
+  private final PatchSet patchSet;
+  private final ChangeNotes notes;
+  private final ProjectState state;
+
+  @Inject
+  PreviewFix(
+      GitRepositoryManager repoManager,
+      PatchScriptFactoryForAutoFix.Factory patchScriptFactoryFactory,
+      ProjectCache projectCache,
+      @Assisted RevisionResource revisionResource) {
+    this.repoManager = repoManager;
+    this.patchScriptFactoryFactory = patchScriptFactoryFactory;
+    patchSet = revisionResource.getPatchSet();
+    notes = revisionResource.getNotes();
+    Change change = notes.getChange();
+    state = projectCache.get(change.getProject()).orElseThrow(illegalState(change.getProject()));
+  }
+
+  @Singleton
+  public static class Stored implements RestReadView<FixResource> {
+    private final PreviewFix.Factory previewFixFactory;
+
+    @Inject
+    Stored(PreviewFix.Factory previewFixFactory) {
+      this.previewFixFactory = previewFixFactory;
+    }
+
+    @Override
+    public Response<Map<String, DiffInfo>> apply(FixResource fixResource)
+        throws PermissionBackendException, ResourceNotFoundException, ResourceConflictException,
+            AuthException, IOException, InvalidChangeOperationException {
+
+      PreviewFix previewFix = previewFixFactory.create(fixResource.getRevisionResource());
+
+      Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
+          fixResource.getFixReplacements().stream()
+              .collect(groupingBy(fixReplacement -> fixReplacement.path));
+
+      return Response.ok(previewFix.previewAllFiles(fixReplacementsPerFilePath));
+    }
+  }
+
+  @Singleton
+  public static class Provided implements RestModifyView<RevisionResource, ApplyProvidedFixInput> {
+    private final PreviewFix.Factory previewFixFactory;
+
+    @Inject
+    Provided(PreviewFix.Factory previewFixFactory) {
+      this.previewFixFactory = previewFixFactory;
+    }
+
+    @Override
+    public Response<Map<String, DiffInfo>> apply(
+        RevisionResource revisionResource, ApplyProvidedFixInput applyProvidedFixInput)
+        throws BadRequestException, PermissionBackendException, ResourceNotFoundException,
+            ResourceConflictException, AuthException, IOException, InvalidChangeOperationException {
+      if (applyProvidedFixInput == null) {
+        throw new BadRequestException("applyProvidedFixInput is required");
+      }
+      if (applyProvidedFixInput.fixReplacementInfos == null) {
+        throw new BadRequestException("applyProvidedFixInput.fixReplacementInfos is required");
+      }
+
+      PreviewFix previewFix = previewFixFactory.create(revisionResource);
+
+      Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
+          applyProvidedFixInput.fixReplacementInfos.stream()
+              .map(fix -> new FixReplacement(fix.path, new Range(fix.range), fix.replacement))
+              .collect(groupingBy(fixReplacement -> fixReplacement.path));
+
+      return Response.ok(previewFix.previewAllFiles(fixReplacementsPerFilePath));
+    }
+  }
+
+  private Map<String, DiffInfo> previewAllFiles(
+      Map<String, List<FixReplacement>> fixReplacementsPerFilePath)
+      throws PermissionBackendException, ResourceNotFoundException, ResourceConflictException,
+          AuthException, IOException, InvalidChangeOperationException {
+    Map<String, DiffInfo> result = new HashMap<>();
+    try (Repository git = repoManager.openRepository(notes.getProjectName())) {
+      for (Map.Entry<String, List<FixReplacement>> entry : fixReplacementsPerFilePath.entrySet()) {
+        String fileName = entry.getKey();
+        DiffInfo diffInfo =
+            previewSingleFile(git, fileName, ImmutableList.copyOf(entry.getValue()));
+        result.put(fileName, diffInfo);
+      }
+    } catch (NoSuchChangeException e) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    } catch (LargeObjectException e) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    }
+    return result;
+  }
+
+  private DiffInfo previewSingleFile(
+      Repository git, String fileName, ImmutableList<FixReplacement> fixReplacements)
+      throws PermissionBackendException, AuthException, LargeObjectException,
+          InvalidChangeOperationException, IOException, ResourceNotFoundException {
+    PatchScriptFactoryForAutoFix psf =
+        patchScriptFactoryFactory.create(
+            git, notes, fileName, patchSet, fixReplacements, DiffPreferencesInfo.defaults());
+    PatchScript ps = psf.call();
+
+    DiffSide sideA =
+        DiffSide.create(
+            ps.getFileInfoA(),
+            MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
+            DiffSide.Type.SIDE_A);
+    DiffSide sideB = DiffSide.create(ps.getFileInfoB(), ps.getNewName(), DiffSide.Type.SIDE_B);
+
+    DiffInfoCreator diffInfoCreator =
+        new DiffInfoCreator(state, new DiffWebLinksProviderImpl(), true);
+    return diffInfoCreator.create(ps, sideA, sideB);
+  }
+
+  private static class DiffWebLinksProviderImpl implements DiffWebLinksProvider {
+
+    @Override
+    public ImmutableList<DiffWebLinkInfo> getDiffLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType) {
+      return ImmutableList.of();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java b/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
deleted file mode 100644
index 4acf809..0000000
--- a/java/com/google/gerrit/server/restapi/change/PreviewSubmit.java
+++ /dev/null
@@ -1,187 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-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.NotImplementedException;
-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.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.ArchiveFormatInternal;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream;
-import com.google.gerrit.server.ioutil.LimitedByteArrayOutputStream.LimitExceededException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.submit.MergeOp;
-import com.google.gerrit.server.submit.MergeOpRepoManager;
-import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.Collection;
-import org.apache.commons.compress.archivers.ArchiveOutputStream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.storage.pack.PackConfig;
-import org.eclipse.jgit.transport.BundleWriter;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.kohsuke.args4j.Option;
-
-public class PreviewSubmit implements RestReadView<RevisionResource> {
-  private static final int MAX_DEFAULT_BUNDLE_SIZE = 100 * 1024 * 1024;
-
-  private final Provider<MergeOp> mergeOpProvider;
-  private final AllowedFormats allowedFormats;
-  private int maxBundleSize;
-  private String format;
-
-  @Option(name = "--format")
-  public void setFormat(String f) {
-    this.format = f;
-  }
-
-  @Inject
-  PreviewSubmit(
-      Provider<MergeOp> mergeOpProvider,
-      AllowedFormats allowedFormats,
-      @GerritServerConfig Config cfg) {
-    this.mergeOpProvider = mergeOpProvider;
-    this.allowedFormats = allowedFormats;
-    this.maxBundleSize = cfg.getInt("download", "maxBundleSize", MAX_DEFAULT_BUNDLE_SIZE);
-  }
-
-  @Override
-  public Response<BinaryResult> apply(RevisionResource rsrc)
-      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    if (Strings.isNullOrEmpty(format)) {
-      throw new BadRequestException("format is not specified");
-    }
-    ArchiveFormatInternal f = allowedFormats.extensions.get("." + format);
-    if (f == null && format.equals("tgz")) {
-      // Always allow tgz, even when the allowedFormats doesn't contain it.
-      // Then we allow at least one format even if the list of allowed
-      // formats is empty.
-      f = ArchiveFormatInternal.TGZ;
-    }
-    if (f == null) {
-      throw new BadRequestException("unknown archive format");
-    }
-
-    Change change = rsrc.getChange();
-    if (!change.isNew()) {
-      throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
-    }
-    if (!rsrc.getUser().isIdentifiedUser()) {
-      throw new MethodNotAllowedException("Anonymous users cannot submit");
-    }
-
-    return Response.ok(getBundles(rsrc, f));
-  }
-
-  private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormatInternal f)
-      throws RestApiException, UpdateException, IOException, ConfigInvalidException,
-          PermissionBackendException {
-    IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
-    Change change = rsrc.getChange();
-
-    @SuppressWarnings("resource") // Returned BinaryResult takes ownership and handles closing.
-    MergeOp op = mergeOpProvider.get();
-    try {
-      op.merge(change, caller, false, new SubmitInput(), true);
-      BinaryResult bin = new SubmitPreviewResult(op, f, maxBundleSize);
-      bin.disableGzip()
-          .setContentType(f.getMimeType())
-          .setAttachmentName("submit-preview-" + change.getChangeId() + "." + format);
-      return bin;
-    } catch (RestApiException
-        | UpdateException
-        | IOException
-        | ConfigInvalidException
-        | RuntimeException
-        | PermissionBackendException e) {
-      op.close();
-      throw e;
-    }
-  }
-
-  private static class SubmitPreviewResult extends BinaryResult {
-
-    private final MergeOp mergeOp;
-    private final ArchiveFormatInternal archiveFormat;
-    private final int maxBundleSize;
-
-    private SubmitPreviewResult(
-        MergeOp mergeOp, ArchiveFormatInternal archiveFormat, int maxBundleSize) {
-      this.mergeOp = mergeOp;
-      this.archiveFormat = archiveFormat;
-      this.maxBundleSize = maxBundleSize;
-    }
-
-    @Override
-    public void writeTo(OutputStream out) throws IOException {
-      try (ArchiveOutputStream aos = archiveFormat.createArchiveOutputStream(out)) {
-        MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
-        for (Project.NameKey p : mergeOp.getAllProjects()) {
-          OpenRepo or = orm.getRepo(p);
-          BundleWriter bw = new BundleWriter(or.getCodeReviewRevWalk().getObjectReader());
-          bw.setObjectCountCallback(null);
-          bw.setPackConfig(new PackConfig(or.getRepo()));
-          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates().values();
-          for (ReceiveCommand r : refs) {
-            bw.include(r.getRefName(), r.getNewId());
-            ObjectId oldId = r.getOldId();
-            if (!oldId.equals(ObjectId.zeroId())
-                // Probably the client doesn't already have NoteDb data.
-                && !RefNames.isNoteDbMetaRef(r.getRefName())) {
-              bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
-            }
-          }
-          LimitedByteArrayOutputStream bos = new LimitedByteArrayOutputStream(maxBundleSize, 1024);
-          bw.writeBundle(NullProgressMonitor.INSTANCE, bos);
-          // This naming scheme cannot produce directory/file conflicts
-          // as no projects contains ".git/":
-          String path = p.get() + ".git";
-          archiveFormat.putEntry(aos, path, bos.toByteArray());
-        }
-      } catch (LimitExceededException e) {
-        throw new NotImplementedException("The bundle is too big to generate at the server", e);
-      } catch (NoSuchProjectException e) {
-        throw new IOException(e);
-      }
-    }
-
-    @Override
-    public void close() throws IOException {
-      mergeOp.close();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index dcf616c..d41620e 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -98,7 +98,7 @@
     }
 
     try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       SetAssigneeOp op = assigneeFactory.create(assignee);
       bu.addOp(rsrc.getId(), op);
 
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index 7c54074..5b5bc15 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -57,7 +57,7 @@
 
     Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().id());
     try (BatchUpdate u =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getChange().getId(), op);
       u.execute();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 84a3d89..6411087 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -40,7 +40,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.Optional;
 
@@ -87,7 +87,7 @@
           String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
     try (BatchUpdate bu =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       Op op = new Op(rsrc.getComment().key, in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -145,7 +145,7 @@
     }
   }
 
-  private static HumanComment update(HumanComment e, DraftInput in, Timestamp when) {
+  private static HumanComment update(HumanComment e, DraftInput in, Instant when) {
     if (in.side != null) {
       e.side = in.side();
     }
@@ -154,7 +154,7 @@
     }
     e.setLineNbrAndRange(in.line, in.range);
     e.message = in.message.trim();
-    e.writtenOn = when;
+    e.setWrittenOn(when);
     if (in.tag != null) {
       // TODO(dborowitz): Can we support changing tags via PUT?
       e.tag = in.tag;
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 1ed7fd7..f898dca 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -47,8 +47,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.TimeZone;
+import java.time.Instant;
+import java.time.ZoneId;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -64,7 +64,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repositoryManager;
   private final Provider<CurrentUser> userProvider;
-  private final TimeZone tz;
+  private final ZoneId zoneId;
   private final PatchSetInserter.Factory psInserterFactory;
   private final PermissionBackend permissionBackend;
   private final PatchSetUtil psUtil;
@@ -86,7 +86,7 @@
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
     this.psInserterFactory = psInserterFactory;
-    this.tz = gerritIdent.getTimeZone();
+    this.zoneId = gerritIdent.getZoneId();
     this.permissionBackend = permissionBackend;
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
@@ -126,7 +126,7 @@
         throw new ResourceConflictException("new and existing commit message are the same");
       }
 
-      Timestamp ts = TimeUtil.nowTs();
+      Instant ts = TimeUtil.now();
       try (BatchUpdate bu =
           updateFactory.create(resource.getChange().getProject(), userProvider.get(), ts)) {
         // Ensure that BatchUpdate will update the same repo
@@ -161,13 +161,14 @@
       ObjectInserter objectInserter,
       RevCommit basePatchSetCommit,
       String commitMessage,
-      Timestamp timestamp)
+      Instant timestamp)
       throws IOException {
     CommitBuilder builder = new CommitBuilder();
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
     builder.setAuthor(basePatchSetCommit.getAuthorIdent());
-    builder.setCommitter(userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, tz));
+    builder.setCommitter(
+        userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, zoneId));
     builder.setMessage(commitMessage);
     ObjectId newCommitId = objectInserter.insert(builder);
     objectInserter.flush();
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index 3031781..c9b436e 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -63,7 +63,7 @@
 
     SetTopicOp op = topicOpFactory.create(sanitizedInput.topic);
     try (BatchUpdate u =
-        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
       u.addOp(req.getId(), op);
       u.execute();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 91fa2f0..6ce4b39 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.change.ChangeJson;
@@ -95,7 +96,9 @@
     this.start = start;
   }
 
-  @Option(name = "--no-limit", usage = "Return all results, overriding the default limit")
+  @Option(
+      name = "--no-limit",
+      usage = "Return all results, overriding the default limit. Ignored for anonymous users.")
   public void setNoLimit(boolean on) {
     this.noLimit = on;
   }
@@ -156,7 +159,8 @@
     return Response.ok(out.size() == 1 ? out.get(0) : out);
   }
 
-  private List<List<ChangeInfo>> query() throws QueryParseException, PermissionBackendException {
+  private List<List<ChangeInfo>> query()
+      throws BadRequestException, QueryParseException, PermissionBackendException {
     ChangeQueryProcessor queryProcessor = queryProcessorProvider.get();
     if (queryProcessor.isDisabled()) {
       throw new QueryParseException("query disabled");
@@ -166,9 +170,12 @@
       queryProcessor.setUserProvidedLimit(limit);
     }
     if (start != null) {
+      if (start < 0) {
+        throw new BadRequestException("'start' parameter cannot be less than zero");
+      }
       queryProcessor.setStart(start);
     }
-    if (noLimit != null) {
+    if (noLimit != null && !AnonymousUser.class.isAssignableFrom(userProvider.get().getClass())) {
       queryProcessor.setNoLimit(noLimit);
     }
     if (skipVisibility != null) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 2077fb8..8a8d2ca 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -18,34 +18,28 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 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.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.RebaseChangeOp;
 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;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
@@ -53,12 +47,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
@@ -69,7 +60,6 @@
 
   private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
-  private final RebaseChangeOp.Factory rebaseFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeJson.Factory json;
   private final PermissionBackend permissionBackend;
@@ -80,7 +70,6 @@
   public Rebase(
       BatchUpdate.Factory updateFactory,
       GitRepositoryManager repoManager,
-      RebaseChangeOp.Factory rebaseFactory,
       RebaseUtil rebaseUtil,
       ChangeJson.Factory json,
       PermissionBackend permissionBackend,
@@ -88,7 +77,6 @@
       PatchSetUtil patchSetUtil) {
     this.updateFactory = updateFactory;
     this.repoManager = repoManager;
-    this.rebaseFactory = rebaseFactory;
     this.rebaseUtil = rebaseUtil;
     this.json = json;
     this.permissionBackend = permissionBackend;
@@ -99,10 +87,8 @@
   @Override
   public Response<ChangeInfo> apply(RevisionResource rsrc, RebaseInput input)
       throws UpdateException, RestApiException, IOException, PermissionBackendException {
-    // Not allowed to rebase if the current patch set is locked.
-    patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
-
     rsrc.permissions().check(ChangePermission.REBASE);
+
     projectCache
         .get(rsrc.getProject())
         .orElseThrow(illegalState(rsrc.getProject()))
@@ -114,19 +100,15 @@
         ObjectReader reader = oi.newReader();
         RevWalk rw = CodeReviewCommit.newRevWalk(reader);
         BatchUpdate bu =
-            updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      if (!change.isNew()) {
-        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-      } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
-        throw new ResourceConflictException(
-            "cannot rebase merge commits or commit with no ancestor");
-      }
+            updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
+      rebaseUtil.verifyRebasePreconditions(rw, rsrc.getNotes(), rsrc.getPatchSet());
+
       RebaseChangeOp rebaseOp =
-          rebaseFactory
-              .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
-              .setForceContentMerge(true)
-              .setAllowConflicts(input.allowConflicts)
-              .setFireRevisionCreated(true);
+          rebaseUtil.getRebaseOp(
+              rsrc,
+              input,
+              rebaseUtil.parseOrFindBaseRevision(repo, rw, permissionBackend, rsrc, input, true));
+
       // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
       bu.setNotify(NotifyResolver.Result.none());
       bu.setRepository(repo, rw, oi);
@@ -140,82 +122,14 @@
     }
   }
 
-  private ObjectId findBaseRev(
-      Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
-      throws RestApiException, IOException, NoSuchChangeException, AuthException,
-          PermissionBackendException {
-    BranchNameKey destRefKey = rsrc.getChange().getDest();
-    if (input == null || input.base == null) {
-      return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
-    }
-
-    Change change = rsrc.getChange();
-    String str = input.base.trim();
-    if (str.equals("")) {
-      // Remove existing dependency to other patch set.
-      Ref destRef = repo.exactRef(destRefKey.branch());
-      if (destRef == null) {
-        throw new ResourceConflictException(
-            "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
-      }
-      return destRef.getObjectId();
-    }
-
-    Base base;
-    try {
-      base = rebaseUtil.parseBase(rsrc, str);
-      if (base == null) {
-        throw new ResourceConflictException(
-            "base revision is missing from the destination branch: " + str);
-      }
-    } catch (NoSuchChangeException e) {
-      throw new UnprocessableEntityException(
-          String.format("Base change not found: %s", input.base), e);
-    }
-
-    PatchSet.Id baseId = base.patchSet().id();
-    if (change.getId().equals(baseId.changeId())) {
-      throw new ResourceConflictException("cannot rebase change onto itself");
-    }
-
-    permissionBackend.user(rsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
-
-    Change baseChange = base.notes().getChange();
-    if (!baseChange.getProject().equals(change.getProject())) {
-      throw new ResourceConflictException(
-          "base change is in wrong project: " + baseChange.getProject());
-    } else if (!baseChange.getDest().equals(change.getDest())) {
-      throw new ResourceConflictException(
-          "base change is targeting wrong branch: " + baseChange.getDest());
-    } else if (baseChange.isAbandoned()) {
-      throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
-    } else if (isMergedInto(rw, rsrc.getPatchSet(), base.patchSet())) {
-      throw new ResourceConflictException(
-          "base change "
-              + baseChange.getKey()
-              + " is a descendant of the current change - recursion not allowed");
-    }
-    return base.patchSet().commitId();
-  }
-
-  private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
-    ObjectId baseId = base.commitId();
-    ObjectId tipId = tip.commitId();
-    return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
-  }
-
-  private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
-    // Prevent rebase of exotic changes (merge commit, no ancestor).
-    RevCommit c = rw.parseCommit(ps.commitId());
-    return c.getParentCount() == 1;
-  }
-
   @Override
   public UiAction.Description getDescription(RevisionResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Rebase")
-            .setTitle("Rebase onto tip of branch or parent change")
+            .setTitle(
+                "Rebase onto tip of branch or parent change. Makes you the uploader of this "
+                    + "change which can affect validity of approvals.")
             .setVisible(false);
 
     Change change = rsrc.getChange();
@@ -235,7 +149,7 @@
     boolean enabled = false;
     try (Repository repo = repoManager.openRepository(change.getDest().project());
         RevWalk rw = new RevWalk(repo)) {
-      if (hasOneParent(rw, rsrc.getPatchSet())) {
+      if (RebaseUtil.hasOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
       }
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
new file mode 100644
index 0000000..786bba7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -0,0 +1,277 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.GetRelatedChangesUtil;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.RebaseUtil;
+import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
+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.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.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+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.revwalk.RevWalk;
+
+/** Rest API for rebasing an ancestry chain of changes. */
+@Singleton
+public class RebaseChain
+    implements RestModifyView<ChangeResource, RebaseInput>, UiAction<ChangeResource> {
+  private static final ImmutableSet<ListChangesOption> OPTIONS =
+      Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
+
+  private final GitRepositoryManager repoManager;
+  private final RebaseUtil rebaseUtil;
+  private final GetRelatedChangesUtil getRelatedChangesUtil;
+  private final ChangeResource.Factory changeResourceFactory;
+  private final ChangeData.Factory changeDataFactory;
+  private final PermissionBackend permissionBackend;
+  private final BatchUpdate.Factory updateFactory;
+  private final ChangeNotes.Factory notesFactory;
+  private final ProjectCache projectCache;
+  private final PatchSetUtil patchSetUtil;
+  private final ChangeJson.Factory json;
+
+  @Inject
+  RebaseChain(
+      GitRepositoryManager repoManager,
+      RebaseUtil rebaseUtil,
+      GetRelatedChangesUtil getRelatedChangesUtil,
+      ChangeResource.Factory changeResourceFactory,
+      ChangeData.Factory changeDataFactory,
+      PermissionBackend permissionBackend,
+      BatchUpdate.Factory updateFactory,
+      ChangeNotes.Factory notesFactory,
+      ProjectCache projectCache,
+      PatchSetUtil patchSetUtil,
+      ChangeJson.Factory json) {
+    this.repoManager = repoManager;
+    this.getRelatedChangesUtil = getRelatedChangesUtil;
+    this.changeDataFactory = changeDataFactory;
+    this.rebaseUtil = rebaseUtil;
+    this.changeResourceFactory = changeResourceFactory;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.notesFactory = notesFactory;
+    this.projectCache = projectCache;
+    this.patchSetUtil = patchSetUtil;
+    this.json = json;
+  }
+
+  @Override
+  public Response<RebaseChainInfo> apply(ChangeResource tipRsrc, RebaseInput input)
+      throws IOException, PermissionBackendException, RestApiException, UpdateException {
+    tipRsrc.permissions().check(ChangePermission.REBASE);
+
+    Project.NameKey project = tipRsrc.getProject();
+    projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
+
+    CurrentUser user = tipRsrc.getUser();
+
+    List<Change.Id> upToDateAncestors = new ArrayList<>();
+    Map<Change.Id, RebaseChangeOp> rebaseOps = new LinkedHashMap<>();
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = CodeReviewCommit.newRevWalk(reader);
+        BatchUpdate bu = updateFactory.create(project, user, TimeUtil.now())) {
+      List<PatchSetData> chain = getChainForCurrentPatchSet(tipRsrc);
+
+      boolean ancestorsAreUpToDate = true;
+      for (int i = 0; i < chain.size(); i++) {
+        ChangeData changeData = chain.get(i).data();
+        PatchSet ps = patchSetUtil.current(changeData.notes());
+        if (ps == null) {
+          throw new IllegalStateException(
+              "current revision is missing for change " + changeData.getId());
+        }
+
+        RevisionResource revRsrc =
+            new RevisionResource(changeResourceFactory.create(changeData, user), ps);
+        revRsrc.permissions().check(ChangePermission.REBASE);
+        rebaseUtil.verifyRebasePreconditions(rw, changeData.notes(), ps);
+
+        boolean isUpToDate = false;
+        RebaseChangeOp rebaseOp = null;
+        if (i == 0) {
+          ObjectId desiredBase =
+              rebaseUtil.parseOrFindBaseRevision(
+                  repo, rw, permissionBackend, revRsrc, input, false);
+          if (currentBase(rw, ps).equals(desiredBase)) {
+            isUpToDate = true;
+          } else {
+            rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, desiredBase);
+          }
+        } else {
+          if (ancestorsAreUpToDate) {
+            ObjectId latestCommittedBase =
+                PatchSetUtil.getCurrentCommittedRevCommit(
+                    project, rw, notesFactory, chain.get(i - 1).id());
+            isUpToDate = currentBase(rw, ps).equals(latestCommittedBase);
+          }
+          if (!isUpToDate) {
+            rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, chain.get(i - 1).id());
+          }
+        }
+
+        if (isUpToDate) {
+          upToDateAncestors.add(changeData.getId());
+          continue;
+        }
+        ancestorsAreUpToDate = false;
+        bu.addOp(revRsrc.getChange().getId(), rebaseOp);
+        rebaseOps.put(revRsrc.getChange().getId(), rebaseOp);
+      }
+
+      if (ancestorsAreUpToDate) {
+        throw new ResourceConflictException("The whole chain is already up to date.");
+      }
+
+      bu.setNotify(NotifyResolver.Result.none());
+      bu.setRepository(repo, rw, oi);
+      bu.execute();
+    }
+
+    RebaseChainInfo res = new RebaseChainInfo();
+    res.rebasedChanges = new ArrayList<>();
+    ChangeJson changeJson = json.create(OPTIONS);
+    for (Change.Id c : upToDateAncestors) {
+      res.rebasedChanges.add(changeJson.format(project, c));
+    }
+    for (Map.Entry<Change.Id, RebaseChangeOp> e : rebaseOps.entrySet()) {
+      Change.Id id = e.getKey();
+      RebaseChangeOp op = e.getValue();
+      ChangeInfo changeInfo = changeJson.format(project, id);
+      changeInfo.containsGitConflicts =
+          !op.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
+      res.rebasedChanges.add(changeInfo);
+    }
+    if (res.rebasedChanges.stream()
+        .anyMatch(i -> i.containsGitConflicts != null && i.containsGitConflicts)) {
+      res.containsGitConflicts = true;
+    }
+    return Response.ok(res);
+  }
+
+  @Override
+  public Description getDescription(ChangeResource tipRsrc) throws Exception {
+    UiAction.Description description =
+        new UiAction.Description()
+            .setLabel("Rebase Chain")
+            .setTitle(
+                "Rebase the ancestry chain onto the tip of the target branch. Makes you the "
+                    + "uploader of the changes which can affect validity of approvals.")
+            .setVisible(false);
+
+    Change tip = tipRsrc.getChange();
+    if (!tip.isNew()) {
+      return description;
+    }
+    if (!projectCache
+        .get(tipRsrc.getProject())
+        .orElseThrow(illegalState(tipRsrc.getProject()))
+        .statePermitsWrite()) {
+      return description;
+    }
+
+    if (patchSetUtil.isPatchSetLocked(tipRsrc.getNotes())) {
+      return description;
+    }
+
+    boolean visible = true;
+    boolean enabled = true;
+    try (Repository repo = repoManager.openRepository(tipRsrc.getProject());
+        RevWalk rw = new RevWalk(repo)) {
+      List<PatchSetData> chain = getChainForCurrentPatchSet(tipRsrc);
+      PatchSetData oldestAncestor = chain.get(0);
+      if (rebaseUtil.canRebase(
+          oldestAncestor.patchSet(), oldestAncestor.data().change().getDest(), repo, rw)) {
+        enabled = false;
+      }
+
+      for (PatchSetData ps : chain) {
+        RevisionResource psRsrc =
+            new RevisionResource(
+                changeResourceFactory.create(ps.data(), tipRsrc.getUser()), ps.patchSet());
+
+        if (!psRsrc.permissions().testOrFalse(ChangePermission.REBASE)) {
+          visible = false;
+          break;
+        }
+
+        if (patchSetUtil.isPatchSetLocked(psRsrc.getNotes())) {
+          enabled = false;
+        }
+        if (!RebaseUtil.hasOneParent(rw, psRsrc.getPatchSet())) {
+          enabled = false;
+        }
+      }
+    }
+    return description.setVisible(visible).setEnabled(enabled);
+  }
+
+  private ObjectId currentBase(RevWalk rw, PatchSet ps) throws IOException {
+    return rw.parseCommit(ps.commitId()).getParent(0);
+  }
+
+  private List<PatchSetData> getChainForCurrentPatchSet(ChangeResource rsrc)
+      throws PermissionBackendException, IOException {
+    return Lists.reverse(
+        getRelatedChangesUtil.getAncestors(
+            changeDataFactory.create(rsrc.getNotes()),
+            patchSetUtil.current(rsrc.getNotes()),
+            true));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index 7fe463e..bd3e8ec 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -81,7 +81,7 @@
     ChangeResource changeResource = attentionResource.getChangeResource();
     try (BatchUpdate bu =
         updateFactory.create(
-            changeResource.getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
+            changeResource.getProject(), changeResource.getUser(), TimeUtil.now())) {
       RemoveFromAttentionSetOp op =
           opFactory.create(attentionResource.getAccountId(), input.reason, true);
       bu.addOp(changeResource.getId(), op);
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 53d0f18..3d9d588 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -151,7 +151,7 @@
             commentsUtil.newHumanComment(
                 changeNotes,
                 currentUser,
-                TimeUtil.nowTs(),
+                TimeUtil.now(),
                 commentInput.path,
                 commentInput.patchSet == null
                     ? changeNotes.getChange().currentPatchSetId()
@@ -284,9 +284,8 @@
    */
   private void addToAttentionSet(
       BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
-    AddToAttentionSetOp addOwnerToAttentionSet =
-        addToAttentionSetOpFactory.create(user, reason, notify);
-    bu.addOp(changeNotes.getChangeId(), addOwnerToAttentionSet);
+    AddToAttentionSetOp addToAttentionSet = addToAttentionSetOpFactory.create(user, reason, notify);
+    bu.addOp(changeNotes.getChangeId(), addToAttentionSet);
   }
 
   /**
@@ -327,7 +326,7 @@
       // message here, then it would be possible to probe whether an account exists.
     } catch (AuthException ex) {
       // adding users without permission to the attention set should fail silently.
-      logger.atFine().log(ex.getMessage());
+      logger.atFine().log("%s", ex.getMessage());
     }
   }
 
@@ -352,7 +351,7 @@
       // message here, then it would be possible to probe whether an account exists.
     } catch (AuthException ex) {
       // this should never happen since removing users with permissions should work.
-      logger.atSevere().log(ex.getMessage());
+      logger.atSevere().log("%s", ex.getMessage());
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index b2d1d3a..19d0677 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -100,7 +100,7 @@
 
     Op op = new Op(input);
     try (BatchUpdate u =
-        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
       u.addOp(rsrc.getId(), op).execute();
     }
     return Response.ok(json.noOptions().format(op.change));
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index 8d48c88..7dd3e7a 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -47,7 +47,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -99,12 +98,11 @@
     if (patch == null) {
       throw new ResourceNotFoundException(changeIdToRevert.toString());
     }
-    Timestamp timestamp = TimeUtil.nowTs();
     return Response.ok(
         json.noOptions()
             .format(
                 rsrc.getProject(),
-                commitUtil.createRevertChange(notes, rsrc.getUser(), input, timestamp)));
+                commitUtil.createRevertChange(notes, rsrc.getUser(), input, TimeUtil.now())));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 8bde6e7..62fdcbb 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -53,11 +52,8 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WalkSorter;
 import com.google.gerrit.server.change.WalkSorter.PatchSetData;
-import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.RevertedSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -73,15 +69,14 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.text.MessageFormat;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -92,7 +87,7 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -115,17 +110,13 @@
   private final ChangeJson.Factory json;
   private final GitRepositoryManager repoManager;
   private final WalkSorter sorter;
-  private final ChangeMessagesUtil cmUtil;
   private final CommitUtil commitUtil;
   private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeReverted changeReverted;
-  private final RevertedSender.Factory revertedSenderFactory;
   private final Sequences seq;
   private final NotifyResolver notifyResolver;
   private final BatchUpdate.Factory updateFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final GetRelated getRelated;
-  private final MessageIdGenerator messageIdGenerator;
 
   private CherryPickInput cherryPickInput;
   private List<ChangeInfo> results;
@@ -145,17 +136,13 @@
       ChangeJson.Factory json,
       GitRepositoryManager repoManager,
       WalkSorter sorter,
-      ChangeMessagesUtil cmUtil,
       CommitUtil commitUtil,
       ChangeNotes.Factory changeNotesFactory,
-      ChangeReverted changeReverted,
-      RevertedSender.Factory revertedSenderFactory,
       Sequences seq,
       NotifyResolver notifyResolver,
       BatchUpdate.Factory updateFactory,
       ChangeResource.Factory changeResourceFactory,
-      GetRelated getRelated,
-      MessageIdGenerator messageIdGenerator) {
+      GetRelated getRelated) {
     this.queryProvider = queryProvider;
     this.user = user;
     this.permissionBackend = permissionBackend;
@@ -166,17 +153,13 @@
     this.json = json;
     this.repoManager = repoManager;
     this.sorter = sorter;
-    this.cmUtil = cmUtil;
     this.commitUtil = commitUtil;
     this.changeNotesFactory = changeNotesFactory;
-    this.changeReverted = changeReverted;
-    this.revertedSenderFactory = revertedSenderFactory;
     this.seq = seq;
     this.notifyResolver = notifyResolver;
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.getRelated = getRelated;
-    this.messageIdGenerator = messageIdGenerator;
     results = new ArrayList<>();
     cherryPickInput = null;
   }
@@ -251,15 +234,13 @@
     Multimap<BranchNameKey, ChangeData> changesPerProjectAndBranch = ArrayListMultimap.create();
     changeData.stream().forEach(c -> changesPerProjectAndBranch.put(c.change().getDest(), c));
     cherryPickInput = createCherryPickInput(revertInput);
-    Timestamp timestamp = TimeUtil.nowTs();
+    Instant timestamp = TimeUtil.now();
 
+    String initialMessage = revertInput.message;
     for (BranchNameKey projectAndBranch : changesPerProjectAndBranch.keySet()) {
       cherryPickInput.base = null;
       Project.NameKey project = projectAndBranch.project();
       cherryPickInput.destination = projectAndBranch.branch();
-      if (revertInput.workInProgress) {
-        cherryPickInput.notify = firstNonNull(cherryPickInput.notify, NotifyHandling.OWNER);
-      }
       Collection<ChangeData> changesInProjectAndBranch =
           changesPerProjectAndBranch.get(projectAndBranch);
 
@@ -273,6 +254,7 @@
               .collect(Collectors.toSet());
 
       revertAllChangesInProjectAndBranch(
+          initialMessage,
           revertInput,
           project,
           sortedChangesInProjectAndBranch,
@@ -285,16 +267,16 @@
     return revertSubmissionInfo;
   }
 
+  // Warning: reuses and modifies revertInput.message.
   private void revertAllChangesInProjectAndBranch(
+      String initialMessage,
       RevertInput revertInput,
       Project.NameKey project,
       Iterator<PatchSetData> sortedChangesInProjectAndBranch,
       Set<ObjectId> commitIdsInProjectAndBranch,
-      Timestamp timestamp)
+      Instant timestamp)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException,
           PermissionBackendException {
-
-    String initialMessage = revertInput.message;
     while (sortedChangesInProjectAndBranch.hasNext()) {
       ChangeNotes changeNotes = sortedChangesInProjectAndBranch.next().data().notes();
       if (cherryPickInput.base == null) {
@@ -302,11 +284,12 @@
         cherryPickInput.base = getBase(changeNotes, commitIdsInProjectAndBranch).name();
       }
 
+      // Set revert message for the current revert change.
       revertInput.message = getMessage(initialMessage, changeNotes);
       if (cherryPickInput.base.equals(changeNotes.getCurrentPatchSet().commitId().getName())) {
         // This is the code in case this is the first revert of this project + branch, and the
         // revert would be on top of the change being reverted.
-        craeteNormalRevert(revertInput, changeNotes, timestamp);
+        createNormalRevert(revertInput, changeNotes, timestamp);
       } else {
         createCherryPickedRevert(revertInput, project, changeNotes, timestamp);
       }
@@ -314,10 +297,7 @@
   }
 
   private void createCherryPickedRevert(
-      RevertInput revertInput,
-      Project.NameKey project,
-      ChangeNotes changeNotes,
-      Timestamp timestamp)
+      RevertInput revertInput, Project.NameKey project, ChangeNotes changeNotes, Instant timestamp)
       throws IOException, ConfigInvalidException, UpdateException, RestApiException {
     ObjectId revCommitId =
         commitUtil.createRevertCommit(revertInput.message, changeNotes, user.get(), timestamp);
@@ -326,7 +306,7 @@
     cherryPickInput.message = revertInput.message;
     ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
     Change.Id cherryPickRevertChangeId = Change.id(seq.nextChangeId());
-    try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.setNotify(
           notifyResolver.resolve(
               firstNonNull(cherryPickInput.notify, NotifyHandling.ALL),
@@ -339,17 +319,16 @@
               cherryPickRevertChangeId,
               timestamp,
               revertInput.workInProgress));
-      bu.addOp(changeNotes.getChange().getId(), new PostRevertedMessageOp(generatedChangeId));
-      bu.addOp(
-          cherryPickRevertChangeId,
-          new NotifyOp(changeNotes.getChange(), cherryPickRevertChangeId));
-
+      if (!revertInput.workInProgress) {
+        commitUtil.addChangeRevertedNotificationOps(
+            bu, changeNotes.getChangeId(), cherryPickRevertChangeId, generatedChangeId.name());
+      }
       bu.execute();
     }
   }
 
-  private void craeteNormalRevert(
-      RevertInput revertInput, ChangeNotes changeNotes, Timestamp timestamp)
+  private void createNormalRevert(
+      RevertInput revertInput, ChangeNotes changeNotes, Instant timestamp)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException {
 
     Change.Id revertId =
@@ -369,6 +348,9 @@
     // change is created for the cherry-picked commit. Notifications are sent only for this change,
     // but not for the intermediately created revert commit.
     cherryPickInput.notify = revertInput.notify;
+    if (revertInput.workInProgress) {
+      cherryPickInput.notify = firstNonNull(cherryPickInput.notify, NotifyHandling.NONE);
+    }
     cherryPickInput.notifyDetails = revertInput.notifyDetails;
     cherryPickInput.parent = 1;
     cherryPickInput.keepReviewers = true;
@@ -558,14 +540,14 @@
     private final ObjectId revCommitId;
     private final ObjectId computedChangeId;
     private final Change.Id cherryPickRevertChangeId;
-    private final Timestamp timestamp;
+    private final Instant timestamp;
     private final boolean workInProgress;
 
     CreateCherryPickOp(
         ObjectId revCommitId,
         ObjectId computedChangeId,
         Change.Id cherryPickRevertChangeId,
-        Timestamp timestamp,
+        Instant timestamp,
         Boolean workInProgress) {
       this.revCommitId = revCommitId;
       this.computedChangeId = computedChangeId;
@@ -601,55 +583,4 @@
       return true;
     }
   }
-
-  private class NotifyOp implements BatchUpdateOp {
-    private final Change change;
-    private final Change.Id revertChangeId;
-
-    NotifyOp(Change change, Change.Id revertChangeId) {
-      this.change = change;
-      this.revertChangeId = revertChangeId;
-    }
-
-    @Override
-    public void postUpdate(PostUpdateContext ctx) throws Exception {
-      changeReverted.fire(
-          ctx.getChangeData(change),
-          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertChangeId)),
-          ctx.getWhen());
-      try {
-        RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setNotify(ctx.getNotify(change.getId()));
-        emailSender.setMessageId(
-            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-        emailSender.send();
-      } catch (Exception err) {
-        logger.atSevere().withCause(err).log(
-            "Cannot send email for revert change %s", change.getId());
-      }
-    }
-  }
-
-  /**
-   * create a message that describes the revert if the cherry-pick is successful, and point the
-   * revert of the change towards the cherry-pick. The cherry-pick is the updated change that acts
-   * as "revert-of" the original change.
-   */
-  private class PostRevertedMessageOp implements BatchUpdateOp {
-    private final ObjectId computedChangeId;
-
-    PostRevertedMessageOp(ObjectId computedChangeId) {
-      this.computedChangeId = computedChangeId;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx) throws Exception {
-      cmUtil.setChangeMessage(
-          ctx,
-          "Created a revert of this change as I" + computedChangeId.getName(),
-          ChangeMessagesUtil.TAG_REVERT);
-      return true;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 7f7c1ad..07e54ce 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.Strings;
 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.Account;
@@ -44,6 +43,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -56,7 +56,7 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
-import org.apache.commons.lang.mutable.MutableDouble;
+import org.apache.commons.lang3.mutable.MutableDouble;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
@@ -208,7 +208,7 @@
           queryProvider
               .get()
               .setLimit(numberOfRelevantChanges)
-              .setRequestedFields(ChangeField.REVIEWER)
+              .setRequestedFields(ChangeField.REVIEWER_SPEC)
               .query(changeQueryBuilder.owner("self"));
       Map<Account.Id, MutableDouble> suggestions = new LinkedHashMap<>();
       // Put those candidates at the bottom of the list
@@ -227,7 +227,7 @@
     } catch (QueryParseException e) {
       // Unhandled, because owner:self will never provoke a QueryParseException
       logger.atSevere().withCause(e).log("Exception while suggesting reviewers");
-      return ImmutableMap.of();
+      return new HashMap<>();
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index d6c4c51..418eb9c 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -28,13 +28,14 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.common.GroupBaseInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -57,6 +58,7 @@
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexRewriter;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectState;
@@ -120,6 +122,7 @@
     }
   }
 
+  private final AccountVisibility accountVisibility;
   private final AccountLoader.Factory accountLoaderFactory;
   private final AccountQueryBuilder accountQueryBuilder;
   private final AccountIndexRewriter accountIndexRewriter;
@@ -135,6 +138,7 @@
 
   @Inject
   ReviewersUtil(
+      AccountVisibility accountVisibility,
       AccountLoader.Factory accountLoaderFactory,
       AccountQueryBuilder accountQueryBuilder,
       AccountIndexRewriter accountIndexRewriter,
@@ -147,6 +151,7 @@
       AccountControl.Factory accountControlFactory,
       Provider<CurrentUser> self,
       ServiceUserClassifier serviceUserClassifier) {
+    this.accountVisibility = accountVisibility;
     this.accountLoaderFactory = accountLoaderFactory;
     this.accountQueryBuilder = accountQueryBuilder;
     this.accountIndexRewriter = accountIndexRewriter;
@@ -192,13 +197,20 @@
       logger.atFine().log("Reviewer suggestion is disabled.");
       return Collections.emptyList();
     }
+    AccountControl accountControl = accountControlFactory.get();
+
+    if (accountVisibility == AccountVisibility.NONE && !accountControl.canViewAll()) {
+      logger.atFine().log(
+          "Not suggesting reviewers: accountVisibility = %s and the user does not have %s capability",
+          AccountVisibility.NONE, GlobalPermission.VIEW_ALL_ACCOUNTS);
+      return Collections.emptyList();
+    }
 
     List<Account.Id> candidateList = new ArrayList<>();
     if (!Strings.isNullOrEmpty(query)) {
       candidateList = suggestAccounts(suggestReviewers);
       logger.atFine().log("Candidate list: %s", candidateList);
     }
-
     List<Account.Id> sortedRecommendations =
         recommendAccounts(
             reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
@@ -216,8 +228,7 @@
           continue;
         }
         // Check if change is visible to reviewer and if the current user can see reviewer
-        if (visibilityControl.isVisibleTo(reviewer)
-            && accountControlFactory.get().canSee(reviewer)) {
+        if (visibilityControl.isVisibleTo(reviewer) && accountControl.canSee(reviewer)) {
           filteredRecommendations.add(reviewer);
         }
       }
@@ -238,9 +249,9 @@
 
   private static Account.Id fromIdField(FieldBundle f, boolean useLegacyNumericFields) {
     if (useLegacyNumericFields) {
-      return Account.id(f.getValue(AccountField.ID).intValue());
+      return Account.id(f.<Integer>getValue(AccountField.ID_FIELD_SPEC).intValue());
     }
-    return Account.id(Integer.valueOf(f.getValue(AccountField.ID_STR)));
+    return Account.id(Integer.valueOf(f.<String>getValue(AccountField.ID_STR_FIELD_SPEC)));
   }
 
   private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers)
@@ -256,9 +267,9 @@
       logger.atFine().log("accounts index query: %s", pred);
       accountIndexRewriter.validateMaxTermsInQuery(pred);
       boolean useLegacyNumericFields =
-          accountIndexes.getSearchIndex().getSchema().useLegacyNumericFields();
-      FieldDef<AccountState, ?> idField =
-          useLegacyNumericFields ? AccountField.ID : AccountField.ID_STR;
+          accountIndexes.getSearchIndex().getSchema().hasField(AccountField.ID_FIELD_SPEC);
+      SchemaField<AccountState, ?> idField =
+          useLegacyNumericFields ? AccountField.ID_FIELD_SPEC : AccountField.ID_STR_FIELD_SPEC;
       ResultSet<FieldBundle> result =
           accountIndexes
               .getSearchIndex()
@@ -413,7 +424,7 @@
     GroupAsReviewer result = new GroupAsReviewer();
     int maxAllowed = suggestReviewers.getMaxAllowed();
     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
-    logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);
+    logger.atFine().log("maxAllowedWithoutConfirmation: %s", maxAllowedWithoutConfirmation);
 
     if (!ReviewerModifier.isLegalReviewerGroup(group.getUUID())) {
       logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index 69b82ba..bdc6816 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
@@ -38,9 +39,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -107,21 +106,14 @@
   }
 
   private boolean visible(ChangeResource change) throws PermissionBackendException {
-    try {
-      permissionBackend
-          .user(change.getUser())
-          .change(change.getNotes())
-          .check(ChangePermission.READ);
-      return projectCache
-          .get(change.getProject())
-          .map(ProjectState::statePermitsRead)
-          .orElse(false);
-    } catch (AuthException e) {
-      return false;
-    }
+    return permissionBackend
+            .user(change.getUser())
+            .change(change.getNotes())
+            .test(ChangePermission.READ)
+        && projectCache.get(change.getProject()).map(ProjectState::statePermitsRead).orElse(false);
   }
 
-  private List<RevisionResource> find(ChangeResource change, String id)
+  private ImmutableList<RevisionResource> find(ChangeResource change, String id)
       throws IOException, AuthException {
     if (id.equals("0") || id.equals("edit")) {
       return loadEdit(change, null);
@@ -131,7 +123,7 @@
     } else if (id.length() < 4 || id.length() > ObjectIds.STR_LEN) {
       // Require a minimum of 4 digits.
       // Impossibly long identifier will never match.
-      return Collections.emptyList();
+      return ImmutableList.of();
     } else {
       List<RevisionResource> out = new ArrayList<>();
       for (PatchSet ps : psUtil.byChange(change.getNotes())) {
@@ -143,20 +135,20 @@
       if (out.isEmpty() && ObjectId.isId(id)) {
         return loadEdit(change, ObjectId.fromString(id));
       }
-      return out;
+      return ImmutableList.copyOf(out);
     }
   }
 
-  private List<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id) {
+  private ImmutableList<RevisionResource> byLegacyPatchSetId(ChangeResource change, String id) {
     PatchSet ps = psUtil.get(change.getNotes(), PatchSet.id(change.getId(), Integer.parseInt(id)));
     if (ps != null) {
-      return Collections.singletonList(new RevisionResource(change, ps));
+      return ImmutableList.of(new RevisionResource(change, ps));
     }
-    return Collections.emptyList();
+    return ImmutableList.of();
   }
 
-  private List<RevisionResource> loadEdit(ChangeResource change, @Nullable ObjectId commitId)
-      throws AuthException, IOException {
+  private ImmutableList<RevisionResource> loadEdit(
+      ChangeResource change, @Nullable ObjectId commitId) throws AuthException, IOException {
     Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
     if (edit.isPresent()) {
       RevCommit editCommit = edit.get().getEditCommit();
@@ -165,12 +157,12 @@
               .id(PatchSet.id(change.getId(), 0))
               .commitId(editCommit)
               .uploader(change.getUser().getAccountId())
-              .createdOn(new Timestamp(editCommit.getCommitterIdent().getWhen().getTime()))
+              .createdOn(editCommit.getCommitterIdent().getWhenAsInstant())
               .build();
       if (commitId == null || editCommit.equals(commitId)) {
-        return Collections.singletonList(new RevisionResource(change, ps, edit));
+        return ImmutableList.of(new RevisionResource(change, ps, edit));
       }
     }
-    return Collections.emptyList();
+    return ImmutableList.of();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index c118766..a587ecc 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.change.WorkInProgressOp.Input;
+import com.google.gerrit.server.git.CommitUtil;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -42,11 +43,16 @@
     implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private final BatchUpdate.Factory updateFactory;
   private final WorkInProgressOp.Factory opFactory;
+  private final CommitUtil commitUtil;
 
   @Inject
-  SetReadyForReview(BatchUpdate.Factory updateFactory, WorkInProgressOp.Factory opFactory) {
+  SetReadyForReview(
+      BatchUpdate.Factory updateFactory,
+      WorkInProgressOp.Factory opFactory,
+      CommitUtil commitUtil) {
     this.updateFactory = updateFactory;
     this.opFactory = opFactory;
+    this.commitUtil = commitUtil;
   }
 
   @Override
@@ -63,10 +69,13 @@
       throw new ResourceConflictException("change is not work in progress");
     }
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.ALL)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
+      if (change.getRevertOf() != null) {
+        commitUtil.addChangeRevertedNotificationOps(
+            bu, change.getRevertOf(), change.getId(), change.getKey().get());
+      }
       bu.execute();
       return Response.ok();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
index fdaad9d..0ad5180 100644
--- a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -63,8 +63,7 @@
       throw new ResourceConflictException("change is already work in progress");
     }
 
-    try (BatchUpdate bu =
-        updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
       bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.NONE)));
       bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 876c92c..5fc4f41 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.ListMultimap;
 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.common.data.ParameterizedString;
 import com.google.gerrit.entities.BranchNameKey;
@@ -168,9 +169,12 @@
   }
 
   @Override
-  public Response<ChangeInfo> apply(RevisionResource rsrc, SubmitInput input)
+  public Response<ChangeInfo> apply(RevisionResource rsrc, @Nullable SubmitInput input)
       throws RestApiException, RepositoryNotFoundException, IOException, PermissionBackendException,
           UpdateException, ConfigInvalidException {
+    if (input == null) {
+      input = new SubmitInput();
+    }
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
     IdentifiedUser submitter;
     if (input.onBehalfOf != null) {
@@ -230,6 +234,7 @@
    * @param user the user who is checking to submit
    * @return a reason why any of the changes is not submittable or null
    */
+  @Nullable
   private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
     try {
       if (cs.furtherHiddenChanges()) {
@@ -257,9 +262,19 @@
           return "Change " + c.getId() + " is marked work in progress";
         }
         try {
-          MergeOp.checkSubmitRule(c, false);
+          // The data in the change index may be stale (e.g. if submit requirements have been
+          // changed). For that one change for which the submit action is computed, use the
+          // freshly loaded ChangeData instance 'cd' instead of the potentially stale ChangeData
+          // instance 'c' that was loaded from the index. This makes a difference if the ChangeSet
+          // 'cs' only contains this one single change. If the ChangeSet contains further changes
+          // those may still be stale.
+          MergeOp.checkSubmitRequirements(cd.getId().equals(c.getId()) ? cd : c);
         } catch (ResourceConflictException e) {
-          return "Change " + c.getId() + " is not ready: " + e.getMessage();
+          return (c.getId() == cd.getId())
+              ? String.format("Change %s is not ready: %s", cd.getId(), e.getMessage())
+              : String.format(
+                  "Change %s must be submitted with change %s but %s is not ready: %s",
+                  cd.getId(), c.getId(), c.getId(), e.getMessage());
         }
       }
 
@@ -283,6 +298,7 @@
     return null;
   }
 
+  @Nullable
   @Override
   public UiAction.Description getDescription(RevisionResource resource)
       throws IOException, PermissionBackendException {
@@ -299,12 +315,15 @@
 
     ChangeData cd = resource.getChangeResource().getChangeData();
     try {
-      MergeOp.checkSubmitRule(cd, false);
+      MergeOp.checkSubmitRequirements(cd);
     } catch (ResourceConflictException e) {
       return null; // submit not visible
     }
 
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
@@ -314,14 +333,6 @@
 
     String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
 
-    // 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 stored fields from the
-    // index in the first place.
-    // cd.setMergeable(null);
-    // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
-    // now it is safe to read from the cache, as it yields the same result.
-    Boolean enabled = cd.isMergeable();
-
     if (submitProblems != null) {
       return new UiAction.Description()
           .setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
@@ -330,6 +341,14 @@
           .setEnabled(false);
     }
 
+    // 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 stored fields from the
+    // index in the first place.
+    // cd.setMergeable(null);
+    // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
+    // now it is safe to read from the cache, as it yields the same result.
+    Boolean enabled = cd.isMergeable();
+
     if (treatWithTopic) {
       Map<String, String> params =
           ImmutableMap.of(
@@ -355,6 +374,7 @@
         .setEnabled(Boolean.TRUE.equals(enabled));
   }
 
+  @Nullable
   public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
     Set<ObjectId> outDatedPatchsets = new HashSet<>();
diff --git a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
index 214a001..2ce82ab 100644
--- a/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
+++ b/java/com/google/gerrit/server/restapi/change/SubmittedTogether.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.TOPIC_CLOSURE;
 import static java.util.Collections.reverseOrder;
-import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
@@ -41,7 +43,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.EnumSet;
@@ -127,7 +128,10 @@
       int hidden;
 
       if (c.isNew()) {
-        ChangeSet cs = mergeSuperSet.get().completeChangeSet(c, resource.getUser());
+        ChangeSet cs =
+            mergeSuperSet
+                .get()
+                .completeChangeSet(c, resource.getUser(), options.contains(TOPIC_CLOSURE));
         cds = ensureRequiredDataIsLoaded(cs.changes().asList());
         hidden = cs.nonVisibleChanges().size();
       } else if (c.isMerged()) {
@@ -153,11 +157,11 @@
     }
   }
 
-  private List<ChangeData> sort(List<ChangeData> cds, int hidden) throws IOException {
+  private ImmutableList<ChangeData> sort(List<ChangeData> cds, int hidden) throws IOException {
     if (cds.size() <= 1 && hidden == 0) {
       // Skip sorting for singleton lists, to avoid WalkSorter opening the
       // repo just to fill out the commit field in PatchSetData.
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
     long numProjectsDistinct = cds.stream().map(ChangeData::project).distinct().count();
@@ -167,15 +171,15 @@
       // We either have only a single change per project which means that WalkSorter won't make a
       // difference compared to our index-backed sort, or we are looking at more than 5 projects
       // which would make WalkSorter too expensive for this call.
-      return cds.stream().sorted(COMPARATOR).collect(toList());
+      return cds.stream().sorted(COMPARATOR).collect(toImmutableList());
     }
 
     // Perform more expensive walk-sort.
-    List<ChangeData> sorted = new ArrayList<>(cds.size());
+    ImmutableList.Builder<ChangeData> sorted = ImmutableList.builderWithExpectedSize(cds.size());
     for (PatchSetData psd : sorter.get().sort(cds)) {
       sorted.add(psd.data());
     }
-    return sorted;
+    return sorted.build();
   }
 
   private static List<ChangeData> ensureRequiredDataIsLoaded(List<ChangeData> cds) {
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
index 26c7297..26a0415 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestChangeReviewers.java
@@ -93,6 +93,7 @@
       throw new BadRequestException(
           String.format("Unsupported reviewer state: %s", ReviewerState.REMOVED));
     }
+
     return Response.ok(
         reviewersUtil.suggestReviewers(
             reviewerState,
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index 74f5290..0035a03 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -93,11 +93,7 @@
         cfg.getInt("suggest", "maxSuggestedReviewers", DEFAULT_MAX_SUGGESTED);
     this.limit = this.maxSuggestedReviewers;
     String suggest = cfg.getString("suggest", null, "accounts");
-    if ("OFF".equalsIgnoreCase(suggest) || "false".equalsIgnoreCase(suggest)) {
-      this.suggestAccounts = false;
-    } else {
-      this.suggestAccounts = (av != AccountVisibility.NONE);
-    }
+    this.suggestAccounts = !"OFF".equalsIgnoreCase(suggest) && !"false".equalsIgnoreCase(suggest);
 
     this.maxAllowed =
         cfg.getInt("addreviewer", "maxAllowed", ReviewerModifier.DEFAULT_MAX_REVIEWERS);
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index 02c2ff0..97f866b 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
@@ -28,12 +29,14 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.PrologOptions;
 import com.google.gerrit.server.rules.PrologRule;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.inject.Inject;
 import java.util.LinkedHashMap;
+import java.util.Optional;
 import org.kohsuke.args4j.Option;
 
 public class TestSubmitRule implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
@@ -74,9 +77,11 @@
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
 
-    projectCache
-        .get(rsrc.getProject())
-        .orElseThrow(() -> new BadRequestException("project not found " + rsrc.getProject()));
+    Project.NameKey name = rsrc.getProject();
+    Optional<ProjectState> project = projectCache.get(name);
+    if (!project.isPresent()) {
+      throw new BadRequestException("project not found " + name);
+    }
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     SubmitRecord record =
         prologRule.evaluate(
diff --git a/java/com/google/gerrit/server/restapi/change/Unignore.java b/java/com/google/gerrit/server/restapi/change/Unignore.java
deleted file mode 100644
index 999e736..0000000
--- a/java/com/google/gerrit/server/restapi/change/Unignore.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class Unignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final StarredChangesUtil stars;
-
-  @Inject
-  Unignore(StarredChangesUtil stars) {
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Unignore")
-        .setTitle("Unignore the change")
-        .setVisible(isIgnored(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input) throws IllegalLabelException {
-    if (isIgnored(rsrc)) {
-      stars.unignore(rsrc);
-    }
-    return Response.ok();
-  }
-
-  private boolean isIgnored(ChangeResource rsrc) {
-    try {
-      return stars.isIgnored(rsrc);
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("failed to check ignored star");
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/config/GetCache.java b/java/com/google/gerrit/server/restapi/config/GetCache.java
index 93600ea..5dd3d3d 100644
--- a/java/com/google/gerrit/server/restapi/config/GetCache.java
+++ b/java/com/google/gerrit/server/restapi/config/GetCache.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.config.CacheResource;
 import com.google.inject.Singleton;
 
@@ -23,7 +24,7 @@
 public class GetCache implements RestReadView<CacheResource> {
 
   @Override
-  public Response<ListCaches.CacheInfo> apply(CacheResource rsrc) {
-    return Response.ok(new ListCaches.CacheInfo(rsrc.getName(), rsrc.getCache()));
+  public Response<CacheInfo> apply(CacheResource rsrc) {
+    return Response.ok(new CacheInfo(rsrc.getName(), rsrc.getCache()));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index ae11d71..103a5ac 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -19,6 +19,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.ContributorAgreement;
 import com.google.gerrit.extensions.common.AccountDefaultDisplayName;
 import com.google.gerrit.extensions.common.AccountsInfo;
@@ -66,8 +67,10 @@
 import com.google.inject.Inject;
 import java.nio.file.Files;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -153,6 +156,7 @@
 
     info.user = getUserInfo();
     info.receive = getReceiveInfo();
+    info.submitRequirementDashboardColumns = getSubmitRequirementDashboardColumns();
     return Response.ok(info);
   }
 
@@ -217,7 +221,7 @@
     ChangeConfigInfo info = new ChangeConfigInfo();
     info.allowBlame = toBoolean(config.getBoolean("change", "allowBlame", true));
     boolean hasAssigneeInIndex =
-        indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE);
+        indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE_SPEC);
     info.showAssigneeInChangesTable =
         toBoolean(
             config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
@@ -300,6 +304,7 @@
     return info;
   }
 
+  @Nullable
   private String getDocUrl() {
     String docUrl = config.getString("gerrit", null, "docUrl");
     if (Strings.isNullOrEmpty(docUrl)) {
@@ -325,6 +330,7 @@
   private static final String DEFAULT_THEME = "/static/" + SitePaths.THEME_FILENAME;
   private static final String DEFAULT_THEME_JS = "/static/" + SitePaths.THEME_JS_FILENAME;
 
+  @Nullable
   private String getDefaultTheme() {
     if (config.getString("theme", null, "enableDefault") == null) {
       // If not explicitly enabled or disabled, check for the existence of the theme file.
@@ -341,6 +347,7 @@
     return null;
   }
 
+  @Nullable
   private SshdInfo getSshdInfo() {
     String[] addr = config.getStringList("sshd", null, "listenAddress");
     if (addr.length == 1 && isOff(addr[0])) {
@@ -373,7 +380,12 @@
     return info;
   }
 
+  private List<String> getSubmitRequirementDashboardColumns() {
+    return Arrays.asList(config.getStringList("dashboard", null, "submitRequirementColumns"));
+  }
+
+  @Nullable
   private static Boolean toBoolean(boolean v) {
-    return v ? v : null;
+    return v ? Boolean.TRUE : null;
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index d0a1498..34cf550 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.config;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.Response;
@@ -90,14 +91,22 @@
   private TaskSummaryInfo getTaskSummary() {
     Collection<Task<?>> pending = workQueue.getTasks();
     int tasksTotal = pending.size();
+    int tasksStopping = 0;
     int tasksRunning = 0;
+    int tasksStarting = 0;
     int tasksReady = 0;
     int tasksSleeping = 0;
     for (Task<?> task : pending) {
       switch (task.getState()) {
+        case STOPPING:
+          tasksStopping++;
+          break;
         case RUNNING:
           tasksRunning++;
           break;
+        case STARTING:
+          tasksStarting++;
+          break;
         case READY:
           tasksReady++;
           break;
@@ -113,7 +122,9 @@
 
     TaskSummaryInfo taskSummary = new TaskSummaryInfo();
     taskSummary.total = toInteger(tasksTotal);
+    taskSummary.stopping = toInteger(tasksStopping);
     taskSummary.running = toInteger(tasksRunning);
+    taskSummary.starting = toInteger(tasksStarting);
     taskSummary.ready = toInteger(tasksReady);
     taskSummary.sleeping = toInteger(tasksSleeping);
     return taskSummary;
@@ -211,6 +222,7 @@
     return jvmSummary;
   }
 
+  @Nullable
   private static Integer toInteger(int i) {
     return i != 0 ? i : null;
   }
@@ -247,7 +259,9 @@
 
   public static class TaskSummaryInfo {
     public Integer total;
+    public Integer stopping;
     public Integer running;
+    public Integer starting;
     public Integer ready;
     public Integer sleeping;
   }
diff --git a/java/com/google/gerrit/server/restapi/config/IndexChanges.java b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
index 904c44f..caca5bc 100644
--- a/java/com/google/gerrit/server/restapi/config/IndexChanges.java
+++ b/java/com/google/gerrit/server/restapi/config/IndexChanges.java
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -27,6 +28,8 @@
 import com.google.gerrit.server.restapi.config.IndexChanges.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
@@ -36,6 +39,7 @@
 
   public static class Input {
     public Set<String> changes;
+    boolean deleteMissing;
   }
 
   private final ChangeFinder changeFinder;
@@ -57,7 +61,21 @@
     }
 
     for (String id : input.changes) {
-      for (ChangeNotes n : changeFinder.find(id)) {
+      List<ChangeNotes> notes = changeFinder.find(id);
+
+      if (notes.isEmpty()) {
+        logger.atWarning().log("Change %s missing in NoteDb", id);
+        if (input.deleteMissing) {
+          Optional<Change.Id> changeId = Change.Id.tryParse(id);
+          if (changeId.isPresent()) {
+            logger.atWarning().log("Deleting change %s from index", changeId.get());
+            indexer.delete(changeId.get());
+          }
+        }
+        continue;
+      }
+
+      for (ChangeNotes n : notes) {
         indexer.index(changeDataFactory.create(n));
         logger.atFine().log("Indexed change %s", id);
       }
diff --git a/java/com/google/gerrit/server/restapi/config/ListCaches.java b/java/com/google/gerrit/server/restapi/config/ListCaches.java
index ccafbe8..ffc65c9 100644
--- a/java/com/google/gerrit/server/restapi/config/ListCaches.java
+++ b/java/com/google/gerrit/server/restapi/config/ListCaches.java
@@ -22,7 +22,6 @@
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.cache.Cache;
-import com.google.common.cache.CacheStats;
 import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -30,7 +29,7 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.cache.PersistentCache;
+import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.inject.Inject;
 import java.util.Map;
@@ -87,118 +86,4 @@
     }
     return Response.ok(cacheNames.collect(toImmutableList()));
   }
-
-  public enum CacheType {
-    MEM,
-    DISK
-  }
-
-  public static class CacheInfo {
-    public String name;
-    public CacheType type;
-    public EntriesInfo entries;
-    public String averageGet;
-    public HitRatioInfo hitRatio;
-
-    public CacheInfo(Cache<?, ?> cache) {
-      this(null, cache);
-    }
-
-    public CacheInfo(String name, Cache<?, ?> cache) {
-      this.name = name;
-
-      CacheStats stat = cache.stats();
-
-      entries = new EntriesInfo();
-      entries.setMem(cache.size());
-
-      averageGet = duration(stat.averageLoadPenalty());
-
-      hitRatio = new HitRatioInfo();
-      hitRatio.setMem(stat.hitCount(), stat.requestCount());
-
-      if (cache instanceof PersistentCache) {
-        type = CacheType.DISK;
-        PersistentCache.DiskStats diskStats = ((PersistentCache) cache).diskStats();
-        entries.setDisk(diskStats.size());
-        entries.setSpace(diskStats.space());
-        hitRatio.setDisk(diskStats.hitCount(), diskStats.requestCount());
-      } else {
-        type = CacheType.MEM;
-      }
-    }
-
-    private static String duration(double ns) {
-      if (ns < 0.5) {
-        return null;
-      }
-      String suffix = "ns";
-      if (ns >= 1000.0) {
-        ns /= 1000.0;
-        suffix = "us";
-      }
-      if (ns >= 1000.0) {
-        ns /= 1000.0;
-        suffix = "ms";
-      }
-      if (ns >= 1000.0) {
-        ns /= 1000.0;
-        suffix = "s";
-      }
-      return String.format("%4.1f%s", ns, suffix).trim();
-    }
-  }
-
-  public static class EntriesInfo {
-    public Long mem;
-    public Long disk;
-    public String space;
-
-    public void setMem(long mem) {
-      this.mem = mem != 0 ? mem : null;
-    }
-
-    public void setDisk(long disk) {
-      this.disk = disk != 0 ? disk : null;
-    }
-
-    public void setSpace(double value) {
-      space = bytes(value);
-    }
-
-    private static String bytes(double value) {
-      value /= 1024;
-      String suffix = "k";
-
-      if (value > 1024) {
-        value /= 1024;
-        suffix = "m";
-      }
-      if (value > 1024) {
-        value /= 1024;
-        suffix = "g";
-      }
-      return String.format("%1$6.2f%2$s", value, suffix).trim();
-    }
-  }
-
-  public static class HitRatioInfo {
-    public Integer mem;
-    public Integer disk;
-
-    public void setMem(long value, long total) {
-      mem = percent(value, total);
-    }
-
-    public void setDisk(long value, long total) {
-      disk = percent(value, total);
-    }
-
-    private static Integer percent(long value, long total) {
-      if (total <= 0) {
-        return null;
-      }
-      return (int) ((100 * value) / total);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/ListTasks.java b/java/com/google/gerrit/server/restapi/config/ListTasks.java
index eac9653..8ada657 100644
--- a/java/com/google/gerrit/server/restapi/config/ListTasks.java
+++ b/java/com/google/gerrit/server/restapi/config/ListTasks.java
@@ -72,11 +72,8 @@
     }
 
     List<TaskInfo> allTasks = getTasks();
-    try {
-      permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+    if (permissionBackend.user(user).test(GlobalPermission.VIEW_QUEUE)) {
       return Response.ok(allTasks);
-    } catch (AuthException e) {
-      // Fall through to filter tasks.
     }
 
     Map<String, Boolean> visibilityCache = new HashMap<>();
@@ -90,10 +87,9 @@
           if (!state.isPresent() || !state.get().statePermitsRead()) {
             visible = false;
           } else {
-            try {
-              permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+            if (permissionBackend.user(user).project(nameKey).test(ProjectPermission.ACCESS)) {
               visible = true;
-            } catch (AuthException e) {
+            } else {
               visible = false;
             }
           }
@@ -129,7 +125,7 @@
     public TaskInfo(Task<?> task) {
       this.id = HexFormat.fromInt(task.getTaskId());
       this.state = task.getState();
-      this.startTime = new Timestamp(task.getStartTime().getTime());
+      this.startTime = Timestamp.from(task.getStartTime());
       this.delay = task.getDelay(TimeUnit.MILLISECONDS);
       this.command = task.toString();
       this.queueName = task.getQueueName();
diff --git a/java/com/google/gerrit/server/restapi/config/TasksCollection.java b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
index 409aa9c..29a0033 100644
--- a/java/com/google/gerrit/server/restapi/config/TasksCollection.java
+++ b/java/com/google/gerrit/server/restapi/config/TasksCollection.java
@@ -94,21 +94,14 @@
       }
 
       state.get().checkStatePermitsRead();
-
-      try {
-        permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+      if (permissionBackend.user(user).project(nameKey).test(ProjectPermission.ACCESS)) {
         return new TaskResource(task);
-      } catch (AuthException e) {
-        // Fall through and try view queue permission.
       }
     }
 
     if (task != null) {
-      try {
-        permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+      if (permissionBackend.user(user).test(GlobalPermission.VIEW_QUEUE)) {
         return new TaskResource(task);
-      } catch (AuthException e) {
-        // Fall through and return not found.
       }
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index ee86010..9d36aaa 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -17,6 +17,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+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;
@@ -61,13 +62,13 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 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;
@@ -77,7 +78,7 @@
 public class CreateGroup
     implements RestCollectionCreateView<TopLevelResource, GroupResource, GroupInput> {
   private final Provider<IdentifiedUser> self;
-  private final TimeZone serverTimeZone;
+  private final ZoneId serverZoneId;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
   private final GroupResolver groups;
@@ -102,7 +103,7 @@
       @GerritServerConfig Config cfg,
       Sequences sequences) {
     this.self = self;
-    this.serverTimeZone = serverIdent.get().getTimeZone();
+    this.serverZoneId = serverIdent.get().getZoneId();
     this.groupsUpdateProvider = groupsUpdateProvider;
     this.groupCache = groupCache;
     this.groups = groups;
@@ -181,6 +182,7 @@
     return Response.created(json.format(new InternalGroupDescription(createGroup(args))));
   }
 
+  @Nullable
   private AccountGroup.UUID owner(GroupInput input) throws UnprocessableEntityException {
     if (input.ownerId != null) {
       GroupDescription.Internal d = groups.parseInternal(Url.decode(input.ownerId));
@@ -212,7 +214,7 @@
             createGroupArgs.uuid,
             GroupUuid.make(
                 createGroupArgs.getGroupName(),
-                self.get().newCommitterIdent(TimeUtil.nowTs(), serverTimeZone)));
+                self.get().newCommitterIdent(TimeUtil.now(), serverZoneId)));
     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 e3aa0f3..1b0fcd4 100644
--- a/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/restapi/group/GetAuditLog.java
@@ -102,7 +102,7 @@
           auditEvents.add(
               GroupAuditEventInfo.createRemoveUserEvent(
                   accountLoader.get(auditEvent.removedBy().orElse(null)),
-                  auditEvent.removedOn(),
+                  auditEvent.removedOn().orElse(null),
                   member));
         }
       }
@@ -134,7 +134,7 @@
           auditEvents.add(
               GroupAuditEventInfo.createRemoveGroupEvent(
                   accountLoader.get(auditEvent.removedBy().orElse(null)),
-                  auditEvent.removedOn(),
+                  auditEvent.removedOn().orElse(null),
                   member));
         }
       }
diff --git a/java/com/google/gerrit/server/restapi/group/GroupJson.java b/java/com/google/gerrit/server/restapi/group/GroupJson.java
index e1459c3..6d3fa01 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupJson.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupJson.java
@@ -121,7 +121,7 @@
       }
     }
 
-    info.createdOn = internalGroup.getCreatedOn();
+    info.setCreatedOn(internalGroup.getCreatedOn());
 
     if (options.contains(MEMBERS)) {
       info.members = listMembers.get().getDirectMembers(internalGroup, groupControlSupplier.get());
diff --git a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
index 08cc974..ac81f54 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupsCollection.java
@@ -70,7 +70,7 @@
     final CurrentUser user = self.get();
     if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
-    } else if (!(user.isIdentifiedUser())) {
+    } else if (!user.isIdentifiedUser()) {
       throw new ResourceNotFoundException();
     }
 
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 854f091..4d9a1e9 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -21,6 +21,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
@@ -57,8 +58,8 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.NavigableMap;
 import java.util.Set;
-import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.function.Predicate;
 import java.util.regex.Pattern;
@@ -235,8 +236,9 @@
   }
 
   @Override
-  public Response<SortedMap<String, GroupInfo>> apply(TopLevelResource resource) throws Exception {
-    SortedMap<String, GroupInfo> output = new TreeMap<>();
+  public Response<NavigableMap<String, GroupInfo>> apply(TopLevelResource resource)
+      throws Exception {
+    NavigableMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
       output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
       info.name = null;
@@ -407,6 +409,7 @@
     }
   }
 
+  @Nullable
   private Pattern getRegexPattern() {
     return Strings.isNullOrEmpty(matchRegex) ? null : Pattern.compile(matchRegex);
   }
diff --git a/java/com/google/gerrit/server/restapi/group/ListMembers.java b/java/com/google/gerrit/server/restapi/group/ListMembers.java
index 218eb98..1882fc5 100644
--- a/java/com/google/gerrit/server/restapi/group/ListMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/ListMembers.java
@@ -80,13 +80,18 @@
       throws PermissionBackendException {
     Optional<InternalGroup> group = groupCache.get(groupUuid);
     if (group.isPresent()) {
-      InternalGroupDescription internalGroup = new InternalGroupDescription(group.get());
-      GroupControl groupControl = groupControlFactory.controlFor(internalGroup);
-      return getTransitiveMembers(internalGroup, groupControl);
+      return getTransitiveMembers(group.get());
     }
     return ImmutableList.of();
   }
 
+  public List<AccountInfo> getTransitiveMembers(InternalGroup group)
+      throws PermissionBackendException {
+    InternalGroupDescription internalGroup = new InternalGroupDescription(group);
+    GroupControl groupControl = groupControlFactory.controlFor(internalGroup);
+    return getTransitiveMembers(internalGroup, groupControl);
+  }
+
   private List<AccountInfo> getTransitiveMembers(
       GroupDescription.Internal group, GroupControl groupControl)
       throws PermissionBackendException {
@@ -110,6 +115,13 @@
     return toAccountInfos(directMembers);
   }
 
+  protected List<AccountInfo> getMembers(InternalGroup group) throws PermissionBackendException {
+    if (recursive) {
+      return getTransitiveMembers(group);
+    }
+    return getDirectMembers(group);
+  }
+
   private List<AccountInfo> toAccountInfos(Set<Account.Id> members)
       throws PermissionBackendException {
     AccountLoader accountLoader = accountLoaderFactory.create(true);
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index befccfe..fed2302 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -107,6 +107,10 @@
       throw new MethodNotAllowedException("query disabled");
     }
 
+    if (start < 0) {
+      throw new BadRequestException("'start' parameter cannot be less than zero");
+    }
+
     if (start != 0) {
       queryProcessor.setStart(start);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/BanCommit.java b/java/com/google/gerrit/server/restapi/project/BanCommit.java
index eb5473d..2dd7bd8 100644
--- a/java/com/google/gerrit/server/restapi/project/BanCommit.java
+++ b/java/com/google/gerrit/server/restapi/project/BanCommit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.project;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.projects.BanCommitInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -64,6 +65,7 @@
     return Response.ok(r);
   }
 
+  @Nullable
   private static List<String> transformCommits(List<ObjectId> commits) {
     if (commits == null || commits.isEmpty()) {
       return null;
diff --git a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
index 2d78bb0..00d8658 100644
--- a/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/BranchesCollection.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -75,14 +74,16 @@
       // ListBranches checks the target of a symbolic reference to determine access
       // rights on the symbolic reference itself. This check prevents seeing a hidden
       // branch simply because the symbolic reference name was visible.
-      permissionBackend
-          .currentUser()
-          .project(project)
-          .ref(ref.isSymbolic() ? ref.getTarget().getName() : ref.getName())
-          .check(RefPermission.READ);
-      return new BranchResource(parent.getProjectState(), parent.getUser(), ref);
-    } catch (AuthException notAllowed) {
-      throw new ResourceNotFoundException(id, notAllowed);
+      boolean canRead =
+          permissionBackend
+              .currentUser()
+              .project(project)
+              .ref(ref.isSymbolic() ? ref.getTarget().getName() : ref.getName())
+              .test(RefPermission.READ);
+      if (canRead) {
+        return new BranchResource(parent.getProjectState(), parent.getUser(), ref);
+      }
+      throw new ResourceNotFoundException(id);
     } catch (RepositoryNotFoundException noRepo) {
       throw new ResourceNotFoundException(id, noRepo);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 5c2f932..160dbdc 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -98,7 +98,6 @@
                 HttpServletResponse.SC_FORBIDDEN,
                 String.format("user %s cannot see project %s", match, rsrc.getName())));
       }
-
       RefPermission refPerm;
       if (!Strings.isNullOrEmpty(input.permission)) {
         if (Strings.isNullOrEmpty(input.ref)) {
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsIncludedInRefs.java b/java/com/google/gerrit/server/restapi/project/CommitsIncludedInRefs.java
new file mode 100644
index 0000000..05ec28e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CommitsIncludedInRefs.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.IncludedInRefs;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.kohsuke.args4j.Option;
+
+public class CommitsIncludedInRefs implements RestReadView<ProjectResource> {
+  protected final IncludedInRefs includedInRefs;
+
+  protected Set<String> commits = new HashSet<>();
+  protected Set<String> refs = new HashSet<>();
+
+  @Option(
+      name = "--commit",
+      aliases = {"-c"},
+      required = true,
+      metaVar = "COMMIT",
+      usage = "commit sha1")
+  protected void addCommit(String commit) {
+    commits.add(commit);
+  }
+
+  @Option(
+      name = "--ref",
+      aliases = {"-r"},
+      required = true,
+      metaVar = "REF",
+      usage = "full ref name")
+  protected void addRef(String ref) {
+    refs.add(ref);
+  }
+
+  public void addCommits(Collection<String> commits) {
+    this.commits.addAll(commits);
+  }
+
+  public void addRefs(Collection<String> refs) {
+    this.refs.addAll(refs);
+  }
+
+  @Inject
+  public CommitsIncludedInRefs(IncludedInRefs includedInRefs) {
+    this.includedInRefs = includedInRefs;
+  }
+
+  @Override
+  public Response<Map<String, Set<String>>> apply(ProjectResource resource)
+      throws ResourceConflictException, BadRequestException, IOException,
+          PermissionBackendException, ResourceNotFoundException, AuthException {
+    if (commits.isEmpty()) {
+      throw new BadRequestException("commit is required");
+    }
+    if (refs.isEmpty()) {
+      throw new BadRequestException("ref is required");
+    }
+    return Response.ok(includedInRefs.apply(resource.getNameKey(), commits, refs));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
index 904a16f..192e624 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
@@ -17,6 +17,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
@@ -125,6 +126,7 @@
     return info;
   }
 
+  @Nullable
   private static Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
       ProjectState project,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 92038b0..977bfdb 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Change;
@@ -49,7 +50,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -113,8 +113,10 @@
         .checkStatePermitsWrite();
 
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
-    List<AccessSection> removals = setAccess.getAccessSections(input.remove);
-    List<AccessSection> additions = setAccess.getAccessSections(input.add);
+    ImmutableList<AccessSection> removals =
+        setAccess.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
+    ImmutableList<AccessSection> additions =
+        setAccess.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
 
     Project.NameKey newParentProjectName =
         input.parent == null ? null : Project.nameKey(input.parent);
@@ -152,7 +154,7 @@
           ObjectReader objReader = objInserter.newReader();
           RevWalk rw = new RevWalk(objReader);
           BatchUpdate bu =
-              updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.nowTs())) {
+              updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.now())) {
         bu.setRepository(md.getRepository(), rw, objInserter);
         ChangeInserter ins = newInserter(changeId, commit);
         bu.insertChange(ins);
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index 2fd2d65..17fc6db 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -27,8 +27,10 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -47,6 +49,7 @@
 import java.io.IOException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevObject;
@@ -81,8 +84,9 @@
 
   @Override
   public Response<BranchInfo> apply(ProjectResource rsrc, IdString id, BranchInput input)
-      throws BadRequestException, AuthException, ResourceConflictException, IOException,
-          PermissionBackendException, NoSuchProjectException {
+      throws BadRequestException, AuthException, ResourceConflictException,
+          UnprocessableEntityException, IOException, PermissionBackendException,
+          NoSuchProjectException {
     String ref = id.get();
     if (input == null) {
       input = new BranchInput();
@@ -118,7 +122,7 @@
 
     BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
+      ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
       RevObject object = rw.parseAny(revid);
 
@@ -129,14 +133,32 @@
         object = rw.parseCommit(object);
       }
 
-      createRefControl.checkCreateRef(identifiedUser, repo, name, object);
+      Ref sourceRef = repo.exactRef(input.revision);
+      if (sourceRef == null) {
+        createRefControl.checkCreateRef(identifiedUser, repo, name, object, /* forPush= */ false);
+      } else {
+        if (sourceRef.isSymbolic()) {
+          sourceRef = sourceRef.getTarget();
+        }
+        createRefControl.checkCreateRef(
+            identifiedUser,
+            repo,
+            name,
+            object,
+            /* forPush= */ false,
+            BranchNameKey.create(rsrc.getNameKey(), sourceRef.getName()));
+      }
 
       RefUpdate u = repo.updateRef(ref);
       u.setExpectedOldObjectId(ObjectId.zeroId());
       u.setNewObjectId(object.copy());
       u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
       u.setRefLogMessage("created via REST from " + input.revision, false);
-      refCreationValidator.validateRefOperation(rsrc.getName(), identifiedUser.get(), u);
+      refCreationValidator.validateRefOperation(
+          rsrc.getName(),
+          identifiedUser.get(),
+          u,
+          ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
       RefUpdate.Result result = u.update(rw);
       switch (result) {
         case FAST_FORWARD:
@@ -189,8 +211,6 @@
                 : null;
       }
       return Response.created(info);
-    } catch (RefUtil.InvalidRevisionException e) {
-      throw new BadRequestException("invalid revision \"" + input.revision + "\"", e);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateChange.java b/java/com/google/gerrit/server/restapi/project/CreateChange.java
index 59efd06..2f1153e 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateChange.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ProjectUtil;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectResource;
@@ -59,7 +60,8 @@
       throw new AuthException("Authentication required");
     }
 
-    if (!Strings.isNullOrEmpty(input.project) && !rsrc.getName().equals(input.project)) {
+    if (!Strings.isNullOrEmpty(input.project)
+        && !rsrc.getName().equals(ProjectUtil.sanitizeProjectName(input.project))) {
       throw new BadRequestException("project must match URL");
     }
 
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index d2f4161..ad32f4f 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -43,6 +43,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -106,7 +107,7 @@
 
       config.commit(md);
 
-      projectCache.evict(rsrc.getProjectState().getProject());
+      projectCache.evictAndReindex(rsrc.getProjectState().getProject());
 
       return Response.created(LabelDefinitionJson.format(rsrc.getNameKey(), labelType));
     }
@@ -148,6 +149,11 @@
     List<LabelValue> values = LabelDefinitionInputParser.parseValues(input.values);
     LabelType.Builder labelType = LabelType.builder(LabelType.checkName(label), values);
 
+    if (input.description != null) {
+      String description = Strings.emptyToNull(input.description.trim());
+      labelType.setDescription(Optional.ofNullable(description));
+    }
+
     if (input.function != null && !input.function.trim().isEmpty()) {
       labelType.setFunction(LabelDefinitionInputParser.parseFunction(input.function));
     } else {
@@ -167,10 +173,6 @@
       labelType.setCanOverride(input.canOverride);
     }
 
-    if (input.copyAnyScore != null) {
-      labelType.setCopyAnyScore(input.copyAnyScore);
-    }
-
     if (input.copyCondition != null) {
       try {
         approvalQueryBuilder.parse(input.copyCondition);
@@ -188,40 +190,6 @@
       labelType.setCopyCondition(null);
     }
 
-    if (input.copyMinScore != null) {
-      labelType.setCopyMinScore(input.copyMinScore);
-    }
-
-    if (input.copyMaxScore != null) {
-      labelType.setCopyMaxScore(input.copyMaxScore);
-    }
-
-    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
-      labelType.setCopyAllScoresIfListOfFilesDidNotChange(
-          input.copyAllScoresIfListOfFilesDidNotChange);
-    }
-
-    if (input.copyAllScoresIfNoChange != null) {
-      labelType.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
-    }
-
-    if (input.copyAllScoresIfNoCodeChange != null) {
-      labelType.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
-    }
-
-    if (input.copyAllScoresOnTrivialRebase != null) {
-      labelType.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
-    }
-
-    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-      labelType.setCopyAllScoresOnMergeFirstParentUpdate(
-          input.copyAllScoresOnMergeFirstParentUpdate);
-    }
-
-    if (input.copyValues != null) {
-      labelType.setCopyValues(input.copyValues);
-    }
-
     if (input.allowPostSubmit != null) {
       labelType.setAllowPostSubmit(input.allowPostSubmit);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index f3b2bad..8203346 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.RefNames;
@@ -57,7 +58,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.locks.Lock;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -194,7 +194,8 @@
     }
   }
 
-  private List<String> normalizeBranchNames(List<String> branches) throws BadRequestException {
+  private ImmutableList<String> normalizeBranchNames(List<String> branches)
+      throws BadRequestException {
     if (branches == null || branches.isEmpty()) {
       // Use host-level default for HEAD or fall back to 'master' if nothing else was specified in
       // the input.
@@ -203,7 +204,7 @@
           defaultBranch != null
               ? normalizeAndValidateBranch(defaultBranch)
               : Constants.R_HEADS + Constants.MASTER;
-      return Collections.singletonList(defaultBranch);
+      return ImmutableList.of(defaultBranch);
     }
     List<String> normalizedBranches = new ArrayList<>();
     for (String branch : branches) {
@@ -212,7 +213,7 @@
         normalizedBranches.add(branch);
       }
     }
-    return normalizedBranches;
+    return ImmutableList.copyOf(normalizedBranches);
   }
 
   private String normalizeAndValidateBranch(String branch) throws BadRequestException {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
new file mode 100644
index 0000000..2aeba89
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -0,0 +1,168 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+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.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** A rest create view that creates a "submit requirement" for a project. */
+@Singleton
+public class CreateSubmitRequirement
+    implements RestCollectionCreateView<
+        ProjectResource, SubmitRequirementResource, SubmitRequirementInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  public CreateSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public Response<SubmitRequirementInfo> apply(
+      ProjectResource rsrc, IdString id, SubmitRequirementInput input)
+      throws AuthException, BadRequestException, IOException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new SubmitRequirementInput();
+    }
+
+    if (input.name != null && !input.name.equals(id.get())) {
+      throw new BadRequestException("name in input must match name in URL");
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      SubmitRequirement submitRequirement = createSubmitRequirement(config, id.get(), input);
+
+      md.setMessage(String.format("Create Submit Requirement %s", submitRequirement.name()));
+      config.commit(md);
+
+      projectCache.evict(rsrc.getProjectState().getProject().getNameKey());
+
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    } catch (ResourceConflictException e) {
+      throw new BadRequestException("Failed to create submit requirement", e);
+    }
+  }
+
+  public SubmitRequirement createSubmitRequirement(
+      ProjectConfig config, String name, SubmitRequirementInput input)
+      throws BadRequestException, ResourceConflictException {
+    validateSRName(name);
+    ensureSRUnique(name, config);
+    if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
+      throw new BadRequestException("submittability_expression is required");
+    }
+    if (input.allowOverrideInChildProjects == null) {
+      // default is false
+      input.allowOverrideInChildProjects = false;
+    }
+    SubmitRequirement submitRequirement =
+        SubmitRequirement.builder()
+            .setName(name)
+            .setDescription(Optional.ofNullable(input.description))
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of(input.applicabilityExpression))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(input.submittabilityExpression))
+            .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+            .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
+            .build();
+
+    List<String> validationMessages =
+        submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
+    if (!validationMessages.isEmpty()) {
+      throw new BadRequestException(
+          String.format("Invalid submit requirement input: %s", validationMessages));
+    }
+
+    config.upsertSubmitRequirement(submitRequirement);
+    return submitRequirement;
+  }
+
+  private void validateSRName(String name) throws BadRequestException {
+    try {
+      SubmitRequirementsUtil.validateName(name);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage(), e);
+    }
+  }
+
+  private void ensureSRUnique(String name, ProjectConfig config) throws ResourceConflictException {
+    for (String srName : config.getSubmitRequirementSections().keySet()) {
+      if (srName.equalsIgnoreCase(name)) {
+        throw new ResourceConflictException(
+            String.format(
+                "submit requirement \"%s\" conflicts with existing submit requirement \"%s\"",
+                name, srName));
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index b552ff5..63734bb 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -38,13 +38,12 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.RefUtil;
-import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
 import com.google.gerrit.server.project.TagResource;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.TagCommand;
 import org.eclipse.jgit.api.errors.GitAPIException;
@@ -100,7 +99,7 @@
         permissionBackend.currentUser().project(resource.getNameKey()).ref(ref);
 
     try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
-      ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
+      ObjectId revid = RefUtil.parseBaseRevision(repo, 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.
@@ -136,7 +135,7 @@
                   resource
                       .getUser()
                       .asIdentifiedUser()
-                      .newCommitterIdent(TimeUtil.nowTs(), TimeZone.getDefault()));
+                      .newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
         }
 
         Ref result = tag.call();
@@ -153,8 +152,6 @@
               ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
         }
       }
-    } catch (InvalidRevisionException e) {
-      throw new BadRequestException("Invalid base revision", e);
     } catch (GitAPIException e) {
       logger.atSevere().withCause(e).log("Cannot create tag \"%s\"", ref);
       throw new IOException(e);
diff --git a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
index ca48109..455358a 100644
--- a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
@@ -225,6 +225,7 @@
     return info;
   }
 
+  @Nullable
   private static String replace(String project, String input) {
     return input == null ? input : input.replace("${project}", project);
   }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
index 531640c..8a1927a 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -90,7 +90,7 @@
       config.commit(md);
     }
 
-    projectCache.evict(rsrc.getProject().getProjectState().getProject());
+    projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
 
     return Response.none();
   }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 60405a6..5a84f69 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -21,6 +21,7 @@
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
 
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
@@ -118,7 +119,11 @@
       u.setExpectedOldObjectId(repository.exactRef(ref).getObjectId());
       u.setNewObjectId(ObjectId.zeroId());
       u.setForceUpdate(true);
-      refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
+      refDeletionValidator.validateRefOperation(
+          projectState.getName(),
+          identifiedUser.get(),
+          u,
+          /* pushOptions */ ImmutableListMultimap.of());
       result = u.delete();
 
       switch (result) {
@@ -245,7 +250,11 @@
     u.setForceUpdate(true);
     u.setExpectedOldObjectId(r.exactRef(refName).getObjectId());
     u.setNewObjectId(ObjectId.zeroId());
-    refDeletionValidator.validateRefOperation(projectState.getName(), identifiedUser.get(), u);
+    refDeletionValidator.validateRefOperation(
+        projectState.getName(),
+        identifiedUser.get(),
+        u,
+        /* pushOptions */ ImmutableListMultimap.of());
     return command;
   }
 
@@ -269,7 +278,7 @@
         msg = format("Cannot delete %s: %s", cmd.getRefName(), cmd.getResult());
         break;
     }
-    logger.atSevere().log(msg);
+    logger.atSevere().log("%s", msg);
     errorMessages.append(msg);
     errorMessages.append("\n");
   }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
new file mode 100644
index 0000000..1be4a5f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeleteSubmitRequirement implements RestModifyView<SubmitRequirementResource, Input> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public DeleteSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<?> apply(SubmitRequirementResource rsrc, Input input) throws Exception {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      if (!deleteSubmitRequirement(config, rsrc.getSubmitRequirement().name())) {
+        // This code is unreachable because the exception is thrown when rsrc was parsed
+        throw new ResourceNotFoundException(
+            String.format(
+                "Submit requirement '%s' not found",
+                IdString.fromDecoded(rsrc.getSubmitRequirement().name())));
+      }
+
+      md.setMessage("Delete submit requirement");
+      config.commit(md);
+    }
+
+    projectCache.evict(rsrc.getProject().getProjectState().getProject().getNameKey());
+
+    return Response.none();
+  }
+
+  /**
+   * Delete the given submit requirement from the project config.
+   *
+   * @param config the project config from which the submit-requirement should be deleted
+   * @param srName the name of the submit requirement that should be deleted
+   * @return {@code true} if the submit-requirement was deleted, {@code false} if the
+   *     submit-requirement was not found
+   */
+  public boolean deleteSubmitRequirement(ProjectConfig config, String srName) {
+    if (!config.getSubmitRequirementSections().containsKey(srName)) {
+      return false;
+    }
+    config.getSubmitRequirementSections().remove(srName);
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/FilesCollection.java b/java/com/google/gerrit/server/restapi/project/FilesCollection.java
index 888ecf2..ca5284f 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesCollection.java
@@ -46,8 +46,14 @@
   @Override
   public FileResource parse(BranchResource parent, IdString id)
       throws ResourceNotFoundException, IOException {
+    if (parent.getRevision().isEmpty()) {
+      throw new ResourceNotFoundException(id);
+    }
     return FileResource.create(
-        repoManager, parent.getProjectState(), ObjectId.fromString(parent.getRevision()), id.get());
+        repoManager,
+        parent.getProjectState(),
+        ObjectId.fromString(parent.getRevision().get()),
+        id.get());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index b572db3..e1a3c0c 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -26,6 +26,7 @@
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
@@ -153,12 +154,12 @@
       if (config.updateGroupNames(groupBackend)) {
         md.setMessage("Update group names\n");
         config.commit(md);
-        projectCache.evict(config.getProject());
+        projectCache.evictAndReindex(config.getProject());
         projectState = projectCache.get(projectName).orElseThrow(illegalState(projectName));
         perm = permissionBackend.currentUser().project(projectName);
       } else if (config.getRevision() != null
           && !config.getRevision().equals(projectState.getConfig().getRevision().orElse(null))) {
-        projectCache.evict(config.getProject());
+        projectCache.evictAndReindex(config.getProject());
         projectState = projectCache.get(projectName).orElseThrow(illegalState(projectName));
         perm = permissionBackend.currentUser().project(projectName);
       }
@@ -331,7 +332,7 @@
         }
         AccountGroup.UUID group = r.getGroup().getUUID();
         if (group != null) {
-          pInfo.rules.put(group.get(), info);
+          pInfo.rules.putIfAbsent(group.get(), info); // First entry for the group wins
           loadGroup(groups, group);
         }
       }
@@ -340,6 +341,7 @@
     return accessSectionInfo;
   }
 
+  @Nullable
   private static Boolean toBoolean(boolean value) {
     return value ? true : null;
   }
diff --git a/java/com/google/gerrit/server/restapi/project/GetReflog.java b/java/com/google/gerrit/server/restapi/project/GetReflog.java
index f9c6fd9..967b3c5 100644
--- a/java/com/google/gerrit/server/restapi/project/GetReflog.java
+++ b/java/com/google/gerrit/server/restapi/project/GetReflog.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CommonConverters;
-import com.google.gerrit.server.args4j.TimestampHandler;
+import com.google.gerrit.server.args4j.InstantHandler;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.project.BranchResource;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jgit.lib.ReflogEntry;
@@ -60,9 +60,9 @@
       metaVar = "TIMESTAMP",
       usage =
           "timestamp from which the reflog entries should be listed (UTC, format: "
-              + TimestampHandler.TIMESTAMP_FORMAT
+              + InstantHandler.TIMESTAMP_FORMAT
               + ")")
-  public GetReflog setFrom(Timestamp from) {
+  public GetReflog setFrom(Instant from) {
     this.from = from;
     return this;
   }
@@ -72,16 +72,16 @@
       metaVar = "TIMESTAMP",
       usage =
           "timestamp until which the reflog entries should be listed (UTC, format: "
-              + TimestampHandler.TIMESTAMP_FORMAT
+              + InstantHandler.TIMESTAMP_FORMAT
               + ")")
-  public GetReflog setTo(Timestamp to) {
+  public GetReflog setTo(Instant to) {
     this.to = to;
     return this;
   }
 
   private int limit;
-  private Timestamp from;
-  private Timestamp to;
+  private Instant from;
+  private Instant to;
 
   @Inject
   public GetReflog(GitRepositoryManager repoManager, PermissionBackend permissionBackend) {
@@ -103,7 +103,7 @@
         r = repo.getReflogReader(rsrc.getRef());
       } catch (UnsupportedOperationException e) {
         String msg = "reflog not supported on repo " + rsrc.getNameKey().get();
-        logger.atSevere().log(msg);
+        logger.atSevere().log("%s", msg);
         throw new MethodNotAllowedException(msg, e);
       }
       if (r == null) {
@@ -115,8 +115,8 @@
       } else {
         entries = limit > 0 ? new ArrayList<>(limit) : new ArrayList<>();
         for (ReflogEntry e : r.getReverseEntries()) {
-          Timestamp timestamp = new Timestamp(e.getWho().getWhen().getTime());
-          if ((from == null || from.before(timestamp)) && (to == null || to.after(timestamp))) {
+          Instant timestamp = e.getWho().getWhenAsInstant();
+          if ((from == null || from.isBefore(timestamp)) && (to == null || to.isAfter(timestamp))) {
             entries.add(e);
           }
           if (limit > 0 && entries.size() >= limit) {
diff --git a/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java
new file mode 100644
index 0000000..ce482e3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/GetSubmitRequirement.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Singleton;
+
+/** A rest read view that retrieves a "submit requirement" for a project by its name. */
+@Singleton
+public class GetSubmitRequirement implements RestReadView<SubmitRequirementResource> {
+  @Override
+  public Response<SubmitRequirementInfo> apply(SubmitRequirementResource rsrc) {
+    return Response.ok(SubmitRequirementJson.format(rsrc.getSubmitRequirement()));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListDashboards.java b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
index 4406719..8cedd60 100644
--- a/java/com/google/gerrit/server/restapi/project/ListDashboards.java
+++ b/java/com/google/gerrit/server/restapi/project/ListDashboards.java
@@ -100,7 +100,7 @@
     return tree.values();
   }
 
-  private List<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
+  private ImmutableList<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
     if (!state.statePermitsRead()) {
       return ImmutableList.of();
@@ -109,7 +109,7 @@
     PermissionBackend.ForProject perm = permissionBackend.currentUser().project(state.getNameKey());
     try (Repository git = gitManager.openRepository(state.getNameKey());
         RevWalk rw = new RevWalk(git)) {
-      List<DashboardInfo> all = new ArrayList<>();
+      ImmutableList.Builder<DashboardInfo> all = ImmutableList.builder();
       for (Ref ref : git.getRefDatabase().getRefsByPrefix(REFS_DASHBOARDS)) {
         try {
           perm.ref(ref.getName()).check(RefPermission.READ);
@@ -118,13 +118,13 @@
           // Do nothing.
         }
       }
-      return all;
+      return all.build();
     } catch (RepositoryNotFoundException e) {
       throw new ResourceNotFoundException(project, e);
     }
   }
 
-  private List<DashboardInfo> scanDashboards(
+  private ImmutableList<DashboardInfo> scanDashboards(
       Project definingProject,
       Repository git,
       RevWalk rw,
@@ -132,7 +132,7 @@
       String project,
       boolean setDefault)
       throws IOException {
-    List<DashboardInfo> list = new ArrayList<>();
+    ImmutableList.Builder<DashboardInfo> list = ImmutableList.builder();
     try (TreeWalk tw = new TreeWalk(rw.getObjectReader())) {
       tw.addTree(rw.parseTree(ref.getObjectId()));
       tw.setRecursive(true);
@@ -155,6 +155,6 @@
         }
       }
     }
-    return list;
+    return list.build();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 4d8005b..c0185a7 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -76,9 +76,9 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.NavigableSet;
 import java.util.Optional;
 import java.util.SortedMap;
-import java.util.SortedSet;
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Stream;
@@ -421,14 +421,8 @@
     if (all && state != null) {
       throw new BadRequestException("'all' and 'state' may not be used together");
     }
-    if (groupUuid != null) {
-      try {
-        if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
-          return Collections.emptySortedMap();
-        }
-      } catch (NoSuchGroupException ex) {
-        return Collections.emptySortedMap();
-      }
+    if (!isGroupVisible()) {
+      return Collections.emptySortedMap();
     }
 
     int foundIndex = 0;
@@ -554,6 +548,14 @@
     }
   }
 
+  private boolean isGroupVisible() {
+    try {
+      return groupUuid == null || groupControlFactory.controlFor(groupUuid).isVisible();
+    } catch (NoSuchGroupException ex) {
+      return false;
+    }
+  }
+
   private void printProjectBranches(PrintWriter stdout, ProjectInfo info) {
     for (String name : showBranch) {
       String ref = info.branches != null ? info.branches.get(name) : null;
@@ -666,7 +668,7 @@
       } catch (IllegalArgumentException e) {
         throw new BadRequestException(e.getMessage());
       }
-      return searcher.search(ImmutableList.copyOf(projectCache.all()));
+      return searcher.search(projectCache.all().asList());
     } else {
       return projectCache.all().stream();
     }
@@ -680,7 +682,7 @@
 
   private void printProjectTree(
       final PrintWriter stdout, TreeMap<Project.NameKey, ProjectNode> treeMap) {
-    final SortedSet<ProjectNode> sortedNodes = new TreeSet<>();
+    final NavigableSet<ProjectNode> sortedNodes = new TreeSet<>();
 
     // Builds the inheritance tree using a list.
     //
diff --git a/java/com/google/gerrit/server/restapi/project/ListSubmitRequirements.java b/java/com/google/gerrit/server/restapi/project/ListSubmitRequirements.java
new file mode 100644
index 0000000..69e2cd3
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListSubmitRequirements.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+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.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+/** List submit requirements in a project. */
+public class ListSubmitRequirements implements RestReadView<ProjectResource> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  public ListSubmitRequirements(Provider<CurrentUser> user, PermissionBackend permissionBackend) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Option(name = "--inherited", usage = "to include inherited submit requirements")
+  private boolean inherited;
+
+  public ListSubmitRequirements withInherited(boolean inherited) {
+    this.inherited = inherited;
+    return this;
+  }
+
+  @Override
+  public Response<List<SubmitRequirementInfo>> apply(ProjectResource rsrc)
+      throws AuthException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    if (inherited) {
+      List<SubmitRequirementInfo> allSubmitRequirements = new ArrayList<>();
+      for (ProjectState projectState : rsrc.getProjectState().treeInOrder()) {
+        try {
+          permissionBackend
+              .currentUser()
+              .project(projectState.getNameKey())
+              .check(ProjectPermission.READ_CONFIG);
+        } catch (AuthException e) {
+          throw new AuthException(projectState.getNameKey() + ": " + e.getMessage(), e);
+        }
+        allSubmitRequirements.addAll(listSubmitRequirements(projectState));
+      }
+      return Response.ok(allSubmitRequirements);
+    }
+
+    permissionBackend.currentUser().project(rsrc.getNameKey()).check(ProjectPermission.READ_CONFIG);
+    return Response.ok(listSubmitRequirements(rsrc.getProjectState()));
+  }
+
+  private ImmutableList<SubmitRequirementInfo> listSubmitRequirements(ProjectState projectState) {
+    return projectState.getConfig().getSubmitRequirementSections().values().stream()
+        .map(SubmitRequirementJson::format)
+        .collect(ImmutableList.toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 123c78a..ac0dff9 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -39,7 +39,7 @@
 import com.google.gerrit.server.project.RefFilter;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -197,12 +197,12 @@
           tagger != null ? CommonConverters.toGitPerson(tagger) : null,
           canDelete,
           webLinks.isEmpty() ? null : webLinks,
-          tagger != null ? new Timestamp(tagger.getWhen().getTime()) : null);
+          tagger != null ? tagger.getWhenAsInstant() : null);
     }
 
-    Timestamp timestamp =
+    Instant timestamp =
         object instanceof RevCommit
-            ? new Timestamp(((RevCommit) object).getCommitterIdent().getWhen().getTime())
+            ? ((RevCommit) object).getCommitterIdent().getWhenAsInstant()
             : null;
 
     // Lightweight tag
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
index a56cfe6..6cb912e 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -139,7 +139,7 @@
 
       if (dirty) {
         config.commit(md);
-        projectCache.evict(rsrc.getProjectState().getProject());
+        projectCache.evictAndReindex(rsrc.getProjectState().getProject());
       }
     }
 
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectNode.java b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
index 1e6200c..816c69d 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectNode.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.server.util.TreeFormatter.TreeNode;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import java.util.TreeSet;
 
 /** Node of a Project in a tree formatted by {@link ListProjects}. */
@@ -32,7 +32,7 @@
   private final Project project;
   private final boolean isVisible;
 
-  private final SortedSet<ProjectNode> children = new TreeSet<>();
+  private final NavigableSet<ProjectNode> children = new TreeSet<>();
 
   @Inject
   protected ProjectNode(
@@ -72,7 +72,7 @@
   }
 
   @Override
-  public SortedSet<? extends ProjectNode> getChildren() {
+  public NavigableSet<? extends ProjectNode> getChildren() {
     return children;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index 410a88c8..d188bc8 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.project.FileResource.FILE_KIND;
 import static com.google.gerrit.server.project.LabelResource.LABEL_KIND;
 import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
+import static com.google.gerrit.server.project.SubmitRequirementResource.SUBMIT_REQUIREMENT_KIND;
 import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -46,6 +47,7 @@
     DynamicMap.mapOf(binder(), COMMIT_KIND);
     DynamicMap.mapOf(binder(), TAG_KIND);
     DynamicMap.mapOf(binder(), LABEL_KIND);
+    DynamicMap.mapOf(binder(), SUBMIT_REQUIREMENT_KIND);
 
     DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
     DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
@@ -78,6 +80,12 @@
     delete(LABEL_KIND).to(DeleteLabel.class);
     postOnCollection(LABEL_KIND).to(PostLabels.class);
 
+    child(PROJECT_KIND, "submit_requirements").to(SubmitRequirementsCollection.class);
+    create(SUBMIT_REQUIREMENT_KIND).to(CreateSubmitRequirement.class);
+    put(SUBMIT_REQUIREMENT_KIND).to(UpdateSubmitRequirement.class);
+    get(SUBMIT_REQUIREMENT_KIND).to(GetSubmitRequirement.class);
+    delete(SUBMIT_REQUIREMENT_KIND).to(DeleteSubmitRequirement.class);
+
     get(PROJECT_KIND, "HEAD").to(GetHead.class);
     put(PROJECT_KIND, "HEAD").to(SetHead.class);
 
@@ -99,6 +107,7 @@
     get(FILE_KIND, "content").to(GetContent.class);
 
     child(PROJECT_KIND, "commits").to(CommitsCollection.class);
+    get(PROJECT_KIND, "commits:in").to(CommitsIncludedInRefs.class);
     get(COMMIT_KIND).to(GetCommit.class);
     get(COMMIT_KIND, "in").to(CommitIncludedIn.class);
     child(COMMIT_KIND, "files").to(FilesInCommitCollection.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index fa8638e..d4b30c2 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -18,6 +18,9 @@
 import static com.google.gerrit.server.project.ProjectConfig.KEY_ENABLED;
 import static com.google.gerrit.server.project.ProjectConfig.KEY_LINK;
 import static com.google.gerrit.server.project.ProjectConfig.KEY_MATCH;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_PREFIX;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_SUFFIX;
+import static com.google.gerrit.server.project.ProjectConfig.KEY_TEXT;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
@@ -163,7 +166,7 @@
       md.setMessage("Modified project settings\n");
       try {
         projectConfig.commit(md);
-        projectCache.evict(projectConfig.getProject());
+        projectCache.evictAndReindex(projectConfig.getProject());
         md.getRepository().setGitwebDescription(projectConfig.getProject().getDescription());
       } catch (IOException e) {
         if (e.getCause() instanceof ConfigInvalidException) {
@@ -300,8 +303,17 @@
         Config cfg = new Config();
         cfg.setString(COMMENTLINK, name, KEY_MATCH, value.match);
         cfg.setString(COMMENTLINK, name, KEY_LINK, value.link);
+        if (!Strings.isNullOrEmpty(value.prefix)) {
+          cfg.setString(COMMENTLINK, name, KEY_PREFIX, value.prefix);
+        }
+        if (!Strings.isNullOrEmpty(value.suffix)) {
+          cfg.setString(COMMENTLINK, name, KEY_SUFFIX, value.suffix);
+        }
+        if (!Strings.isNullOrEmpty(value.text)) {
+          cfg.setString(COMMENTLINK, name, KEY_TEXT, value.text);
+        }
         cfg.setBoolean(COMMENTLINK, name, KEY_ENABLED, value.enabled == null || value.enabled);
-        projectConfig.addCommentLinkSection(ProjectConfig.buildCommentLink(cfg, name, false));
+        projectConfig.addCommentLinkSection(ProjectConfig.buildCommentLink(cfg, name));
       } else {
         // Delete the commentlink section
         projectConfig.removeCommentLinkSection(name);
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index a65c626..ec42035 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -84,7 +84,7 @@
       md.setAuthor(user);
       md.setMessage(msg);
       config.commit(md);
-      cache.evict(resource.getProjectState().getProject());
+      cache.evictAndReindex(resource.getProjectState().getProject());
       md.getRepository().setGitwebDescription(config.getProject().getDescription());
 
       return Strings.isNullOrEmpty(config.getProject().getDescription())
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index a9d818d..b219085 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -106,6 +106,10 @@
 
     ProjectQueryProcessor queryProcessor = queryProcessorProvider.get();
 
+    if (start < 0) {
+      throw new BadRequestException("'start' parameter cannot be less than zero");
+    }
+
     if (start != 0) {
       queryProcessor.setStart(start);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 794cae8..6957275 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.InvalidNameException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -39,6 +42,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -80,10 +84,14 @@
       throws Exception {
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
 
+    validateInput(input);
+
     ProjectConfig config;
 
-    List<AccessSection> removals = accessUtil.getAccessSections(input.remove);
-    List<AccessSection> additions = accessUtil.getAccessSections(input.add);
+    List<AccessSection> removals =
+        accessUtil.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
+    List<AccessSection> additions =
+        accessUtil.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       config = projectConfigFactory.read(md);
 
@@ -125,7 +133,7 @@
       }
 
       config.commit(md);
-      projectCache.evict(config.getProject());
+      projectCache.evictAndReindex(config.getProject());
       createGroupPermissionSyncer.syncIfNeeded();
     } catch (InvalidNameException e) {
       throw new BadRequestException(e.toString());
@@ -135,4 +143,65 @@
 
     return Response.ok(getAccess.apply(rsrc.getNameKey()));
   }
+
+  private static void validateInput(ProjectAccessInput input) throws BadRequestException {
+    if (input.add != null) {
+      for (Map.Entry<String, AccessSectionInfo> accessSectionEntry : input.add.entrySet()) {
+        validateAccessSection(accessSectionEntry.getKey(), accessSectionEntry.getValue());
+      }
+    }
+  }
+
+  private static void validateAccessSection(String ref, AccessSectionInfo accessSectionInfo)
+      throws BadRequestException {
+    if (accessSectionInfo != null) {
+      for (Map.Entry<String, PermissionInfo> permissionEntry :
+          accessSectionInfo.permissions.entrySet()) {
+        validatePermission(ref, permissionEntry.getKey(), permissionEntry.getValue());
+      }
+    }
+  }
+
+  private static void validatePermission(
+      String ref, String permission, PermissionInfo permissionInfo) throws BadRequestException {
+    if (permissionInfo != null) {
+      for (Map.Entry<String, PermissionRuleInfo> permissionRuleEntry :
+          permissionInfo.rules.entrySet()) {
+        validatePermissionRule(
+            ref, permission, permissionRuleEntry.getKey(), permissionRuleEntry.getValue());
+      }
+    }
+  }
+
+  private static void validatePermissionRule(
+      String ref, String permission, String groupId, PermissionRuleInfo permissionRuleInfo)
+      throws BadRequestException {
+    if (permissionRuleInfo != null) {
+      if (permissionRuleInfo.min != null || permissionRuleInfo.max != null) {
+        if (permissionRuleInfo.min == null) {
+          throw new BadRequestException(
+              String.format(
+                  "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+                      + " ..%d (min is required if max is set)",
+                  permission, groupId, ref, permissionRuleInfo.max));
+        }
+
+        if (permissionRuleInfo.max == null) {
+          throw new BadRequestException(
+              String.format(
+                  "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+                      + " %d.. (max is required if min is set)",
+                  permission, groupId, ref, permissionRuleInfo.min));
+        }
+
+        if (permissionRuleInfo.min > permissionRuleInfo.max) {
+          throw new BadRequestException(
+              String.format(
+                  "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+                      + " %d..%d (min must be <= max)",
+                  permission, groupId, ref, permissionRuleInfo.min, permissionRuleInfo.max));
+        }
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 65cc5a2..547a214 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.restapi.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelType;
@@ -41,8 +43,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -66,13 +66,15 @@
     this.pluginPermissionsUtil = pluginPermissionsUtil;
   }
 
-  List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
+  ImmutableList<AccessSection> getAccessSections(
+      Map<String, AccessSectionInfo> sectionInfos, boolean rejectNonResolvableGroups)
       throws UnprocessableEntityException {
     if (sectionInfos == null) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
 
-    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
+    ImmutableList.Builder<AccessSection> sections =
+        ImmutableList.builderWithExpectedSize(sectionInfos.size());
     for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
       if (entry.getValue().permissions == null) {
         continue;
@@ -93,13 +95,20 @@
         for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
             permissionEntry.getValue().rules.entrySet()) {
           GroupDescription.Basic group = groupResolver.parseId(permissionRuleInfoEntry.getKey());
-          if (group == null) {
-            throw new UnprocessableEntityException(
-                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+          GroupReference groupReference;
+          if (group != null) {
+            groupReference = GroupReference.forGroup(group);
+          } else {
+            if (rejectNonResolvableGroups) {
+              throw new UnprocessableEntityException(
+                  permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+            }
+            AccountGroup.UUID uuid = AccountGroup.UUID.parse(permissionRuleInfoEntry.getKey());
+            groupReference = GroupReference.create(uuid, uuid.get());
           }
 
           PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
-          PermissionRule.Builder r = PermissionRule.builder(GroupReference.forGroup(group));
+          PermissionRule.Builder r = PermissionRule.builder(groupReference);
           if (pri != null) {
             if (pri.max != null) {
               r.setMax(pri.max);
@@ -120,7 +129,7 @@
       }
       sections.add(accessSection.build());
     }
-    return sections;
+    return sections.build();
   }
 
   /**
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index 5aef76a..853d7df 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -115,7 +115,7 @@
       md.setAuthor(rsrc.getUser().asIdentifiedUser());
       md.setMessage(msg);
       config.commit(md);
-      cache.evict(rsrc.getProjectState().getProject());
+      cache.evictAndReindex(rsrc.getProjectState().getProject());
 
       if (target != null) {
         Response<DashboardInfo> response = get.get().apply(target);
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index d69abef..10589cc 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -38,6 +38,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
@@ -98,7 +99,7 @@
             config.getLabelSections().get(newName.isEmpty() ? labelType.getName() : newName);
 
         config.commit(md);
-        projectCache.evict(rsrc.getProject().getProjectState().getProject());
+        projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
       }
     }
     return Response.ok(LabelDefinitionJson.format(rsrc.getProject().getNameKey(), labelType));
@@ -147,6 +148,12 @@
       }
     }
 
+    if (input.description != null) {
+      String description = Strings.emptyToNull(input.description.trim());
+      labelTypeBuilder.setDescription(Optional.ofNullable(description));
+      dirty = true;
+    }
+
     if (input.function != null) {
       if (input.function.trim().isEmpty()) {
         throw new BadRequestException("function cannot be empty");
@@ -199,53 +206,6 @@
       dirty = true;
     }
 
-    if (input.copyAnyScore != null) {
-      labelTypeBuilder.setCopyAnyScore(input.copyAnyScore);
-      dirty = true;
-    }
-
-    if (input.copyMinScore != null) {
-      labelTypeBuilder.setCopyMinScore(input.copyMinScore);
-      dirty = true;
-    }
-
-    if (input.copyMaxScore != null) {
-      labelTypeBuilder.setCopyMaxScore(input.copyMaxScore);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresIfListOfFilesDidNotChange != null) {
-      labelTypeBuilder.setCopyAllScoresIfListOfFilesDidNotChange(
-          input.copyAllScoresIfListOfFilesDidNotChange);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresIfNoChange != null) {
-      labelTypeBuilder.setCopyAllScoresIfNoChange(input.copyAllScoresIfNoChange);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresIfNoCodeChange != null) {
-      labelTypeBuilder.setCopyAllScoresIfNoCodeChange(input.copyAllScoresIfNoCodeChange);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresOnTrivialRebase != null) {
-      labelTypeBuilder.setCopyAllScoresOnTrivialRebase(input.copyAllScoresOnTrivialRebase);
-      dirty = true;
-    }
-
-    if (input.copyAllScoresOnMergeFirstParentUpdate != null) {
-      labelTypeBuilder.setCopyAllScoresOnMergeFirstParentUpdate(
-          input.copyAllScoresOnMergeFirstParentUpdate);
-      dirty = true;
-    }
-
-    if (input.copyValues != null) {
-      labelTypeBuilder.setCopyValues(input.copyValues);
-      dirty = true;
-    }
-
     if (input.allowPostSubmit != null) {
       labelTypeBuilder.setAllowPostSubmit(input.allowPostSubmit);
       dirty = true;
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index 91c29f5..ef31dc5 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -114,7 +114,7 @@
       md.setAuthor(user);
       md.setMessage(msg);
       config.commit(md);
-      cache.evict(rsrc.getProjectState().getProject());
+      cache.evictAndReindex(rsrc.getProjectState().getProject());
 
       Project.NameKey parent = config.getProject().getParent(allProjects);
       requireNonNull(parent);
diff --git a/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java b/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java
new file mode 100644
index 0000000..1388033
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SubmitRequirementsCollection.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+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.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+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.ProjectResource;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SubmitRequirementsCollection
+    implements ChildCollection<ProjectResource, SubmitRequirementResource> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final DynamicMap<RestView<SubmitRequirementResource>> views;
+  private final Provider<ListSubmitRequirements> list;
+
+  @Inject
+  SubmitRequirementsCollection(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      DynamicMap<RestView<SubmitRequirementResource>> views,
+      Provider<ListSubmitRequirements> list) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.list = list;
+    this.views = views;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws RestApiException {
+    return list.get();
+  }
+
+  @Override
+  public SubmitRequirementResource parse(ProjectResource parent, IdString id)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(parent.getNameKey())
+        .check(ProjectPermission.READ_CONFIG);
+
+    SubmitRequirement submitRequirement =
+        parent.getProjectState().getConfig().getSubmitRequirementSections().get(id.get());
+
+    if (submitRequirement == null) {
+      throw new ResourceNotFoundException(
+          String.format("Submit requirement '%s' does not exist", id));
+    }
+    return new SubmitRequirementResource(parent, submitRequirement);
+  }
+
+  @Override
+  public DynamicMap<RestView<SubmitRequirementResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
new file mode 100644
index 0000000..bbd617c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -0,0 +1,152 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+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.ProjectCache;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
+import com.google.gerrit.server.project.SubmitRequirementJson;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * A rest modify view that updates the definition of an existing submit requirement for a project.
+ */
+@Singleton
+public class UpdateSubmitRequirement
+    implements RestModifyView<SubmitRequirementResource, SubmitRequirementInput> {
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final MetaDataUpdate.User updateFactory;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final ProjectCache projectCache;
+  private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+
+  @Inject
+  public UpdateSubmitRequirement(
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.User updateFactory,
+      ProjectConfig.Factory projectConfigFactory,
+      ProjectCache projectCache,
+      SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.updateFactory = updateFactory;
+    this.projectConfigFactory = projectConfigFactory;
+    this.projectCache = projectCache;
+    this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+  }
+
+  @Override
+  public Response<SubmitRequirementInfo> apply(
+      SubmitRequirementResource rsrc, SubmitRequirementInput input)
+      throws AuthException, BadRequestException, PermissionBackendException, IOException {
+    if (!user.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+
+    permissionBackend
+        .currentUser()
+        .project(rsrc.getProject().getNameKey())
+        .check(ProjectPermission.WRITE_CONFIG);
+
+    if (input == null) {
+      input = new SubmitRequirementInput();
+    }
+
+    if (input.name != null && !input.name.equals(rsrc.getSubmitRequirement().name())) {
+      throw new BadRequestException("name in input must match name in URL");
+    }
+
+    try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
+      ProjectConfig config = projectConfigFactory.read(md);
+
+      SubmitRequirement submitRequirement =
+          createSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
+
+      md.setMessage(String.format("Update Submit Requirement %s", submitRequirement.name()));
+      config.commit(md);
+
+      projectCache.evict(rsrc.getProject().getNameKey());
+
+      return Response.created(SubmitRequirementJson.format(submitRequirement));
+    } catch (ConfigInvalidException e) {
+      throw new IOException("Failed to read project config", e);
+    }
+  }
+
+  public SubmitRequirement createSubmitRequirement(
+      ProjectConfig config, String name, SubmitRequirementInput input) throws BadRequestException {
+    validateSRName(name);
+    if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
+      throw new BadRequestException("submittability_expression is required");
+    }
+    if (input.allowOverrideInChildProjects == null) {
+      // default is false
+      input.allowOverrideInChildProjects = false;
+    }
+    SubmitRequirement submitRequirement =
+        SubmitRequirement.builder()
+            .setName(name)
+            .setDescription(Optional.ofNullable(input.description))
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of(input.applicabilityExpression))
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(input.submittabilityExpression))
+            .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+            .setAllowOverrideInChildProjects(input.allowOverrideInChildProjects)
+            .build();
+
+    List<String> validationMessages =
+        submitRequirementExpressionsValidator.validateExpressions(submitRequirement);
+    if (!validationMessages.isEmpty()) {
+      throw new BadRequestException(
+          String.format("Invalid submit requirement input: %s", validationMessages));
+    }
+
+    config.upsertSubmitRequirement(submitRequirement);
+    return submitRequirement;
+  }
+
+  private void validateSRName(String name) throws BadRequestException {
+    try {
+      SubmitRequirementsUtil.validateName(name);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage(), e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 9f64863..8cd0a58 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -39,6 +39,8 @@
  */
 @Singleton
 public final class DefaultSubmitRule implements SubmitRule {
+  public static final String RULE_NAME = "gerrit~DefaultSubmitRule";
+
   public static class DefaultSubmitRuleModule extends FactoryModule {
     @Override
     public void configure() {
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index bc0bb1a..2bf4175 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -35,10 +35,10 @@
 import com.googlecode.prolog_cafe.lang.PredicateEncoder;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.PrologMachineCopy;
+import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.lib.Config;
@@ -73,7 +73,7 @@
     setEnabled(EnumSet.allOf(Prolog.Feature.class), false);
     args = a;
     storedValues = new HashMap<>();
-    cleanup = new LinkedList<>();
+    cleanup = new ArrayList<>();
   }
 
   public Args getArgs() {
diff --git a/java/com/google/gerrit/server/rules/PrologModule.java b/java/com/google/gerrit/server/rules/PrologModule.java
index 5cf4220..ebb5ec0 100644
--- a/java/com/google/gerrit/server/rules/PrologModule.java
+++ b/java/com/google/gerrit/server/rules/PrologModule.java
@@ -18,12 +18,19 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.rules.RulesCache.RulesCacheModule;
+import org.eclipse.jgit.lib.Config;
 
 public class PrologModule extends FactoryModule {
+  protected final Config config;
+
+  public PrologModule(Config config) {
+    this.config = config;
+  }
+
   @Override
   protected void configure() {
     install(new EnvironmentModule());
-    install(new RulesCacheModule());
+    install(new RulesCacheModule(config));
     bind(PrologEnvironment.Args.class);
     factory(PrologRuleEvaluator.Factory.class);
 
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 179a3d0..cab5b45 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -98,6 +98,7 @@
   private final PrologOptions opts;
   private Term submitRule;
 
+  @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private PrologRuleEvaluator(
       AccountCache accountCache,
@@ -321,7 +322,7 @@
 
   private SubmitRecord ruleError(String err, Exception e) {
     if (opts.logErrors()) {
-      logger.atSevere().withCause(e).log(err);
+      logger.atSevere().withCause(e).log("%s", err);
       return createRuleError(DEFAULT_MSG);
     }
     logger.atFine().log("rule error: %s", err);
@@ -400,8 +401,7 @@
 
   private SubmitTypeRecord typeError(String err, Exception e) {
     if (opts.logErrors()) {
-      logger.atSevere().withCause(e).log(err);
-      return typeError(DEFAULT_MSG);
+      logger.atSevere().withCause(e).log("%s", err);
     }
     return SubmitTypeRecord.error(err);
   }
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index 706804a..710c734 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -17,6 +17,7 @@
 import static com.googlecode.prolog_cafe.lang.PrologMachineCopy.save;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Project;
@@ -73,11 +74,25 @@
 @Singleton
 public class RulesCache {
   public static class RulesCacheModule extends CacheModule {
+    protected final Config config;
+
+    public RulesCacheModule(Config config) {
+      this.config = config;
+    }
+
     @Override
     protected void configure() {
-      cache(RulesCache.CACHE_NAME, ObjectId.class, PrologMachineCopy.class)
-          // This cache is auxiliary to the project cache, so size it the same.
-          .configKey(ProjectCacheImpl.CACHE_NAME);
+      if (has(ProjectCacheImpl.CACHE_NAME, "memoryLimit")) {
+        // As this cache is auxiliary to the project cache, so size it the same when available
+        cache(RulesCache.CACHE_NAME, ObjectId.class, PrologMachineCopy.class)
+            .maximumWeight(config.getLong("cache", ProjectCacheImpl.CACHE_NAME, "memoryLimit", 0));
+      } else {
+        cache(RulesCache.CACHE_NAME, ObjectId.class, PrologMachineCopy.class);
+      }
+    }
+
+    private boolean has(String name, String var) {
+      return !Strings.isNullOrEmpty(config.getString("cache", name, var));
     }
   }
 
@@ -176,6 +191,7 @@
     return pmc;
   }
 
+  @Nullable
   private PrologMachineCopy consultRules(String name, Reader rules) throws CompileException {
     BufferingPrologControl ctl = newEmptyMachine(systemLoader);
     PushbackReader in = new PushbackReader(rules, Prolog.PUSHBACK_SIZE);
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index fd66a3a..dbaefb9 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
@@ -70,7 +71,7 @@
   }
 
   public static final StoredValue<RevCommit> COMMIT =
-      new StoredValue<RevCommit>() {
+      new StoredValue<>() {
         @Override
         public RevCommit createValue(Prolog engine) {
           Change change = getChange(engine);
@@ -86,7 +87,7 @@
       };
 
   public static final StoredValue<Map<String, FileDiffOutput>> DIFF_LIST =
-      new StoredValue<Map<String, FileDiffOutput>>() {
+      new StoredValue<>() {
         @Override
         public Map<String, FileDiffOutput> createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -98,7 +99,7 @@
           try {
             diffList =
                 diffOperations.listModifiedFilesAgainstParent(
-                    project, ps.commitId(), /* parentNum= */ 0);
+                    project, ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
           } catch (DiffNotAvailableException e) {
             throw new SystemException(
                 String.format(
@@ -113,7 +114,7 @@
   // It should be minimized or cached to reduce pause time
   // when evaluating Prolog submit rules.
   public static final StoredValue<GitRepositoryManager> REPO_MANAGER =
-      new StoredValue<GitRepositoryManager>() {
+      new StoredValue<>() {
         @Override
         public GitRepositoryManager createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -122,7 +123,7 @@
       };
 
   public static final StoredValue<PluginConfigFactory> PLUGIN_CONFIG_FACTORY =
-      new StoredValue<PluginConfigFactory>() {
+      new StoredValue<>() {
         @Override
         public PluginConfigFactory createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -131,7 +132,7 @@
       };
 
   public static final StoredValue<Repository> REPOSITORY =
-      new StoredValue<Repository>() {
+      new StoredValue<>() {
         @Override
         public Repository createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -150,7 +151,7 @@
       };
 
   public static final StoredValue<PermissionBackend> PERMISSION_BACKEND =
-      new StoredValue<PermissionBackend>() {
+      new StoredValue<>() {
         @Override
         protected PermissionBackend createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -159,7 +160,7 @@
       };
 
   public static final StoredValue<AnonymousUser> ANONYMOUS_USER =
-      new StoredValue<AnonymousUser>() {
+      new StoredValue<>() {
         @Override
         protected AnonymousUser createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
@@ -168,7 +169,7 @@
       };
 
   public static final StoredValue<Map<Account.Id, IdentifiedUser>> USERS =
-      new StoredValue<Map<Account.Id, IdentifiedUser>>() {
+      new StoredValue<>() {
         @Override
         protected Map<Account.Id, IdentifiedUser> createValue(Prolog engine) {
           return new HashMap<>();
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 9907b1c..09f142b 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -149,19 +149,16 @@
 
     config.upsertAccessSection(
         AccessSection.HEADS,
-        heads -> {
-          initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
-        });
+        heads -> initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config));
 
     config.upsertAccessSection(
         AccessSection.GLOBAL_CAPABILITIES,
-        capabilities -> {
-          input
-              .serviceUsersGroup()
-              .ifPresent(
-                  batchUsersGroup ->
-                      initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
-        });
+        capabilities ->
+            input
+                .serviceUsersGroup()
+                .ifPresent(
+                    batchUsersGroup ->
+                        initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup)));
 
     input
         .administratorsGroup()
@@ -171,16 +168,10 @@
   private void initDefaultAclsForRegisteredUsers(
       AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
     config.upsertAccessSection(
-        "refs/for/*",
-        refsFor -> {
-          grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
-        });
+        "refs/for/*", refsFor -> grant(config, refsFor, Permission.ADD_PATCH_SET, registered));
 
     config.upsertAccessSection(
-        "refs/meta/version",
-        version -> {
-          grant(config, version, Permission.READ, anonymous);
-        });
+        "refs/meta/version", version -> grant(config, version, Permission.READ, anonymous));
 
     grant(config, heads, codeReviewLabel, -1, 1, registered);
     grant(config, heads, Permission.FORGE_AUTHOR, registered);
@@ -208,15 +199,11 @@
       ProjectConfig config, LabelType codeReviewLabel, GroupReference adminsGroup) {
     config.upsertAccessSection(
         AccessSection.GLOBAL_CAPABILITIES,
-        capabilities -> {
-          grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup);
-        });
+        capabilities ->
+            grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup));
 
     config.upsertAccessSection(
-        AccessSection.ALL,
-        all -> {
-          grant(config, all, Permission.READ, adminsGroup);
-        });
+        AccessSection.ALL, all -> grant(config, all, Permission.READ, adminsGroup));
 
     config.upsertAccessSection(
         AccessSection.HEADS,
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index daa24d8..a079050 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.notedb.Sequences;
 import java.util.Optional;
@@ -52,10 +53,12 @@
                 LabelValue.create((short) 2, "Looks good to me, approved"),
                 LabelValue.create((short) 1, "Looks good to me, but someone else must approve"),
                 LabelValue.create((short) 0, "No score"),
-                LabelValue.create((short) -1, "I would prefer this is not merged as is"),
-                LabelValue.create((short) -2, "This shall not be merged")))
-        .setCopyMinScore(true)
-        .setCopyAllScoresOnTrivialRebase(true)
+                LabelValue.create((short) -1, "I would prefer this is not submitted as is"),
+                LabelValue.create((short) -2, "This shall not be submitted")))
+        .setCopyCondition(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()))
         .build();
   }
 
diff --git a/java/com/google/gerrit/server/schema/BUILD b/java/com/google/gerrit/server/schema/BUILD
index 0df7907..ce445e1 100644
--- a/java/com/google/gerrit/server/schema/BUILD
+++ b/java/com/google/gerrit/server/schema/BUILD
@@ -26,6 +26,5 @@
         "//lib/commons:dbcp",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/log:log4j",
     ],
 )
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
new file mode 100644
index 0000000..46a6857
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/MigrateLabelConfigToCopyCondition.java
@@ -0,0 +1,330 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Shorts;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.GerritPersonIdent;
+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.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Migrates all label configurations of a project to copy conditions.
+ *
+ * <p>The label configuration in {@code project.config} controls under which conditions approvals
+ * should be copied to new patch sets:
+ *
+ * <ul>
+ *   <li>old way: by setting boolean flags and copy values (fields {@code copyAnyScore}, {@code
+ *       copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code
+ *       copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ *       copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ *       copyValue})
+ *   <li>new way: by setting a query as a copy condition (field {@code copyCondition})
+ * </ul>
+ *
+ * <p>This class updates all label configurations in the {@code project.config} of the given
+ * project:
+ *
+ * <ul>
+ *   <li>it stores the conditions under which approvals should be copied to new patchs as a copy
+ *       condition query (field {@code copyCondition})
+ *   <li>it unsets all deprecated fields to control approval copying (fields {@code copyAnyScore},
+ *       {@code copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, {@code
+ *       copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
+ *       copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
+ *       copyValue})
+ * </ul>
+ *
+ * <p>This migration assumes {@code true} as default value for the {@code copyAllScoresIfNoChange}
+ * flag since this default value was used for all labels that were created before this migration has
+ * been run (for labels that are created after this migration has been run the default value for
+ * this flag has been changed to {@code false}).
+ */
+public class MigrateLabelConfigToCopyCondition {
+  public static final String COMMIT_MESSAGE = "Migrate label configs to copy conditions";
+
+  @VisibleForTesting public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+
+  @VisibleForTesting public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
+
+  @VisibleForTesting public static final String KEY_COPY_VALUE = "copyValue";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
+      "copyAllScoresOnMergeFirstParentUpdate";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+
+  @VisibleForTesting
+  public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
+      "copyAllScoresIfListOfFilesDidNotChange";
+
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+
+  @Inject
+  public MigrateLabelConfigToCopyCondition(
+      GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+  }
+
+  /**
+   * Executes the migration for the given project.
+   *
+   * @param projectName the name of the project for which the migration should be executed
+   * @throws IOException thrown if an IO error occurs
+   * @throws ConfigInvalidException thrown if the existing project.config is invalid and cannot be
+   *     parsed
+   */
+  public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
+    ProjectLevelConfig.Bare projectConfig =
+        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    try (Repository repo = repoManager.openRepository(projectName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo)) {
+      boolean isAlreadyMigrated = hasMigrationAlreadyRun(repo);
+
+      projectConfig.load(projectName, repo);
+
+      Config cfg = projectConfig.getConfig();
+      String orgConfigAsText = cfg.toText();
+      for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
+        String newCopyCondition = computeCopyCondition(isAlreadyMigrated, cfg, labelName);
+        if (!Strings.isNullOrEmpty(newCopyCondition)) {
+          cfg.setString(
+              ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION, newCopyCondition);
+        }
+
+        unsetDeprecatedFields(cfg, labelName);
+      }
+
+      if (cfg.toText().equals(orgConfigAsText)) {
+        // Config was not changed (ie. none of the label definitions had any deprecated field set).
+        return;
+      }
+
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MESSAGE + "\n");
+      projectConfig.commit(md);
+    }
+  }
+
+  private static String computeCopyCondition(
+      boolean isAlreadyMigrated, Config cfg, String labelName) {
+    List<String> copyConditions = new ArrayList<>();
+
+    ifTrue(cfg, labelName, KEY_COPY_ANY_SCORE, () -> copyConditions.add("is:ANY"));
+    ifTrue(cfg, labelName, KEY_COPY_MIN_SCORE, () -> copyConditions.add("is:MIN"));
+    ifTrue(cfg, labelName, KEY_COPY_MAX_SCORE, () -> copyConditions.add("is:MAX"));
+    forEachSkipNullValues(
+        cfg,
+        labelName,
+        KEY_COPY_VALUE,
+        value -> copyConditions.add("is:" + quoteIfNegative(parseCopyValue(value))));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        () -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
+
+    // If the migration has already been run on this project we must no longer assume true as
+    // default value when copyAllScoresIfNoChange is unset. This is to ensure that the migration is
+    // idempotent when copyAllScoresIfNoChange is set to false:
+    //
+    // 1. migration run:
+    // Removes the copyAllScoresIfNoChange flag, but doesn't add anything to the copy condition.
+    //
+    // 2. migration run:
+    // Since the copyAllScoresIfNoChange flag is no longer set, we would wrongly assume true now and
+    // wrongly include "changekind:NO_CHANGE" into the copy condition. To prevent this we assume
+    // false as default for copyAllScoresIfNoChange once the migration has been run, so that the 2.
+    // migration run is a no-op.
+    if (!isAlreadyMigrated) {
+      // The default value for copyAllScoresIfNoChange is true, hence if this parameter is not set
+      // we need to include "changekind:NO_CHANGE" into the copy condition.
+      ifUnset(
+          cfg,
+          labelName,
+          KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+          () -> copyConditions.add("changekind:" + ChangeKind.NO_CHANGE.name()));
+    }
+
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        () -> copyConditions.add("changekind:" + ChangeKind.NO_CODE_CHANGE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        () -> copyConditions.add("changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        () -> copyConditions.add("changekind:" + ChangeKind.TRIVIAL_REBASE.name()));
+    ifTrue(
+        cfg,
+        labelName,
+        KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        () -> copyConditions.add("has:unchanged-files"));
+
+    if (copyConditions.isEmpty()) {
+      // No copy conditions need to be added. Simply return the current copy condition as it is.
+      // Returning here prevents that OR conditions are reordered and that parentheses are added
+      // unnecessarily.
+      return cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
+    }
+
+    ifSet(
+        cfg,
+        labelName,
+        ProjectConfig.KEY_COPY_CONDITION,
+        copyCondition -> copyConditions.addAll(splitOrConditions(copyCondition)));
+
+    return copyConditions.stream()
+        .map(MigrateLabelConfigToCopyCondition::encloseInParenthesesIfNeeded)
+        .sorted()
+        // Remove duplicated OR conditions
+        .distinct()
+        .collect(joining(" OR "));
+  }
+
+  private static void ifSet(Config cfg, String labelName, String key, Consumer<String> consumer) {
+    Optional.ofNullable(cfg.getString(ProjectConfig.LABEL, labelName, key)).ifPresent(consumer);
+  }
+
+  private static void ifUnset(Config cfg, String labelName, String key, Runnable runnable) {
+    Optional<String> value =
+        Optional.ofNullable(cfg.getString(ProjectConfig.LABEL, labelName, key));
+    if (!value.isPresent()) {
+      runnable.run();
+    }
+  }
+
+  private static void ifTrue(Config cfg, String labelName, String key, Runnable runnable) {
+    if (cfg.getBoolean(ProjectConfig.LABEL, labelName, key, /* defaultValue= */ false)) {
+      runnable.run();
+    }
+  }
+
+  private static void forEachSkipNullValues(
+      Config cfg, String labelName, String key, Consumer<String> consumer) {
+    Arrays.stream(cfg.getStringList(ProjectConfig.LABEL, labelName, key))
+        .filter(Objects::nonNull)
+        .forEach(consumer);
+  }
+
+  private static void unsetDeprecatedFields(Config cfg, String labelName) {
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ANY_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_MIN_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_MAX_SCORE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    cfg.unset(ProjectConfig.LABEL, labelName, KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+  }
+
+  private static ImmutableList<String> splitOrConditions(String copyCondition) {
+    if (copyCondition.contains("(") || copyCondition.contains(")")) {
+      // cannot parse complex predicate tree
+      return ImmutableList.of(copyCondition);
+    }
+
+    // split query on OR, this way we can detect and remove duplicate OR conditions later
+    return ImmutableList.copyOf(Splitter.on(" OR ").splitToList(copyCondition));
+  }
+
+  /**
+   * Add parentheses around the given copyCondition if it consists out of 2 or more predicates and
+   * if it isn't enclosed in parentheses yet.
+   */
+  private static String encloseInParenthesesIfNeeded(String copyCondition) {
+    if (copyCondition.contains(" ")
+        && !(copyCondition.startsWith("(") && copyCondition.endsWith(")"))) {
+      return "(" + copyCondition + ")";
+    }
+    return copyCondition;
+  }
+
+  private static short parseCopyValue(String value) {
+    return Shorts.checkedCast(PermissionRule.parseInt(value));
+  }
+
+  private static String quoteIfNegative(short value) {
+    if (value < 0) {
+      return "\"" + value + "\"";
+    }
+    return Integer.toString(value);
+  }
+
+  public static boolean hasMigrationAlreadyRun(Repository repo) throws IOException {
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsMetaConfig == null) {
+        return false;
+      }
+      revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
+      RevCommit commit;
+      while ((commit = revWalk.next()) != null) {
+        if (COMMIT_MESSAGE.equals(commit.getShortMessage())) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
new file mode 100644
index 0000000..f3c741f
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -0,0 +1,467 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.GerritPersonIdent;
+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.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * A class with logic for migrating existing label functions to submit requirements and resetting
+ * the label functions to {@link LabelFunction#NO_BLOCK}.
+ *
+ * <p>Important note: Callers should do this migration only if this gerrit installation has no
+ * Prolog submit rules (i.e. no rules.pl file in refs/meta/config). Otherwise, the newly created
+ * submit requirements might not behave as intended.
+ *
+ * <p>The conversion is done as follows:
+ *
+ * <ul>
+ *   <li>MaxWithBlock is translated to submittableIf = label:$lbl=MAX AND -label:$lbl=MIN
+ *   <li>MaxNoBlock is translated to submittableIf = label:$lbl=MAX
+ *   <li>AnyWithBlock is translated to submittableIf = -label:$lbl=MIN
+ *   <li>NoBlock/NoOp are translated to applicableIf = is:false (not applicable)
+ *   <li>PatchSetLock labels are left as is
+ * </ul>
+ *
+ * If the label has {@link LabelType#isIgnoreSelfApproval()}, the max vote is appended with the
+ * 'user=non_uploader' argument.
+ *
+ * <p>For labels that were skipped, i.e. had only one "zero" predefined value, the migrator creates
+ * a non-applicable submit-requirement for them. This is done so that if a parent project had a
+ * submit-requirement with the same name, then it's not inherited by this project.
+ *
+ * <p>If there is an existing label and there exists a "submit requirement" with the same name, the
+ * migrator checks if the submit-requirement to be created matches the one in project.config. If
+ * they don't match, a warning message is printed, otherwise nothing happens. In either cases, the
+ * existing submit-requirement is not altered.
+ */
+public class MigrateLabelFunctionsToSubmitRequirement {
+  public static final String COMMIT_MSG = "Migrate label functions to submit requirements";
+  private final GitRepositoryManager repoManager;
+  private final PersonIdent serverUser;
+
+  public enum Status {
+    /**
+     * The migrator updated the project config and created new submit requirements and/or did reset
+     * label functions.
+     */
+    MIGRATED,
+
+    /** The project had prolog rules, and the migration was skipped. */
+    HAS_PROLOG,
+
+    /**
+     * The project was migrated with a previous run of this class. The migration for this run was
+     * skipped.
+     */
+    PREVIOUSLY_MIGRATED,
+
+    /**
+     * Migration was run for the project but did not update the project.config because it was
+     * up-to-date.
+     */
+    NO_CHANGE
+  }
+
+  @Inject
+  public MigrateLabelFunctionsToSubmitRequirement(
+      GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
+    this.repoManager = repoManager;
+    this.serverUser = serverUser;
+  }
+
+  /**
+   * For each label function, create a corresponding submit-requirement and set the label function
+   * to NO_BLOCK. Blocking label functions are substituted with blocking submit-requirements.
+   * Non-blocking label functions are substituted with non-applicable submit requirements, allowing
+   * the label vote to be surfaced as a trigger vote (optional label).
+   *
+   * @return {@link Status} reflecting the status of the migration.
+   */
+  public Status executeMigration(Project.NameKey project, UpdateUI ui)
+      throws IOException, ConfigInvalidException {
+    if (hasPrologRules(project)) {
+      ui.message(String.format("Skipping project %s because it has prolog rules", project));
+      return Status.HAS_PROLOG;
+    }
+    ProjectLevelConfig.Bare projectConfig =
+        new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+    boolean migrationPerformed = false;
+    try (Repository repo = repoManager.openRepository(project);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo)) {
+      if (hasMigrationAlreadyRun(repo)) {
+        ui.message(
+            String.format(
+                "Skipping migrating label functions to submit requirements for project '%s'"
+                    + " because it has been previously migrated",
+                project));
+        return Status.PREVIOUSLY_MIGRATED;
+      }
+      projectConfig.load(project, repo);
+      Config cfg = projectConfig.getConfig();
+      Map<String, LabelAttributes> labelTypes = getLabelTypes(cfg);
+      Map<String, SubmitRequirement> existingSubmitRequirements = loadSubmitRequirements(cfg);
+      boolean updated = false;
+      for (Map.Entry<String, LabelAttributes> lt : labelTypes.entrySet()) {
+        String labelName = lt.getKey();
+        LabelAttributes attributes = lt.getValue();
+        if (attributes.function().equals("PatchSetLock")) {
+          // PATCH_SET_LOCK functions should be left as is
+          continue;
+        }
+        // If the function is other than "NoBlock" we want to reset the label function regardless
+        // of whether there exists a "submit requirement".
+        if (!attributes.function().equals("NoBlock")) {
+          updated = true;
+          writeLabelFunction(cfg, labelName, "NoBlock");
+        }
+        Optional<SubmitRequirement> sr = createSrFromLabelDef(labelName, attributes);
+        if (!sr.isPresent()) {
+          continue;
+        }
+        // Make the operation idempotent by skipping creating the submit-requirement if one was
+        // already created or previously existed.
+        if (existingSubmitRequirements.containsKey(labelName.toLowerCase(Locale.ROOT))) {
+          if (!sr.get()
+              .equals(existingSubmitRequirements.get(labelName.toLowerCase(Locale.ROOT)))) {
+            ui.message(
+                String.format(
+                    "Warning: Skipping creating a submit requirement for label '%s'. An existing "
+                        + "submit requirement is already present but its definition is not "
+                        + "identical to the existing label definition.",
+                    labelName));
+          }
+          continue;
+        }
+        updated = true;
+        ui.message(
+            String.format(
+                "Project %s: Creating a submit requirement for label %s", project, labelName));
+        writeSubmitRequirement(cfg, sr.get());
+      }
+      if (updated) {
+        commit(projectConfig, md);
+        migrationPerformed = true;
+      }
+    }
+    return migrationPerformed ? Status.MIGRATED : Status.NO_CHANGE;
+  }
+
+  /**
+   * Returns a Map containing label names as string in keys along with some of its attributes (that
+   * we need in the migration) like canOverride, ignoreSelfApproval and function in the value.
+   */
+  private Map<String, LabelAttributes> getLabelTypes(Config cfg) {
+    Map<String, LabelAttributes> labelTypes = new HashMap<>();
+    for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
+      String function = cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
+      boolean canOverride =
+          cfg.getBoolean(
+              ProjectConfig.LABEL,
+              labelName,
+              ProjectConfig.KEY_CAN_OVERRIDE,
+              /* defaultValue= */ true);
+      boolean ignoreSelfApproval =
+          cfg.getBoolean(
+              ProjectConfig.LABEL,
+              labelName,
+              ProjectConfig.KEY_IGNORE_SELF_APPROVAL,
+              /* defaultValue= */ false);
+      ImmutableList<String> values =
+          ImmutableList.<String>builder()
+              .addAll(
+                  Arrays.asList(
+                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_VALUE)))
+              .build();
+      ImmutableList<String> refPatterns =
+          ImmutableList.<String>builder()
+              .addAll(
+                  Arrays.asList(
+                      cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_BRANCH)))
+              .build();
+      LabelAttributes attributes =
+          LabelAttributes.create(
+              function == null ? "MaxWithBlock" : function,
+              canOverride,
+              ignoreSelfApproval,
+              values,
+              refPatterns);
+      labelTypes.put(labelName, attributes);
+    }
+    return labelTypes;
+  }
+
+  private void writeSubmitRequirement(Config cfg, SubmitRequirement sr) {
+    if (sr.description().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_DESCRIPTION,
+          sr.description().get());
+    }
+    if (sr.applicabilityExpression().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+          sr.applicabilityExpression().get().expressionString());
+    }
+    cfg.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        sr.name(),
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        sr.submittabilityExpression().expressionString());
+    if (sr.overrideExpression().isPresent()) {
+      cfg.setString(
+          ProjectConfig.SUBMIT_REQUIREMENT,
+          sr.name(),
+          ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+          sr.overrideExpression().get().expressionString());
+    }
+    cfg.setBoolean(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        sr.name(),
+        ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+        sr.allowOverrideInChildProjects());
+  }
+
+  private void writeLabelFunction(Config cfg, String labelName, String function) {
+    cfg.setString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION, function);
+  }
+
+  private void commit(ProjectLevelConfig.Bare projectConfig, MetaDataUpdate md) throws IOException {
+    md.getCommitBuilder().setAuthor(serverUser);
+    md.getCommitBuilder().setCommitter(serverUser);
+    md.setMessage(COMMIT_MSG);
+    projectConfig.commit(md);
+  }
+
+  private static Optional<SubmitRequirement> createSrFromLabelDef(
+      String labelName, LabelAttributes attributes) {
+    if (isLabelSkipped(attributes.values())) {
+      return Optional.of(createNonApplicableSr(labelName, attributes.canOverride()));
+    } else if (isBlockingOrRequiredLabel(attributes.function())) {
+      return Optional.of(createBlockingOrRequiredSr(labelName, attributes));
+    }
+    return Optional.empty();
+  }
+
+  private static SubmitRequirement createNonApplicableSr(String labelName, boolean canOverride) {
+    return SubmitRequirement.builder()
+        .setName(labelName)
+        .setApplicabilityExpression(SubmitRequirementExpression.of("is:false"))
+        .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
+        .setAllowOverrideInChildProjects(canOverride)
+        .build();
+  }
+
+  /**
+   * Create a "submit requirement" that is only satisfied if the label is voted with the max votes
+   * and/or not voted by the min vote, according to the label attributes.
+   */
+  private static SubmitRequirement createBlockingOrRequiredSr(
+      String labelName, LabelAttributes attributes) {
+    SubmitRequirement.Builder builder =
+        SubmitRequirement.builder()
+            .setName(labelName)
+            .setAllowOverrideInChildProjects(attributes.canOverride());
+    String maxPart =
+        String.format("label:%s=MAX", labelName)
+            + (attributes.ignoreSelfApproval() ? ",user=non_uploader" : "");
+    switch (attributes.function()) {
+      case "MaxWithBlock":
+        builder.setSubmittabilityExpression(
+            SubmitRequirementExpression.create(
+                String.format("%s AND -label:%s=MIN", maxPart, labelName)));
+        break;
+      case "AnyWithBlock":
+        builder.setSubmittabilityExpression(
+            SubmitRequirementExpression.create(String.format("-label:%s=MIN", labelName)));
+        break;
+      case "MaxNoBlock":
+        builder.setSubmittabilityExpression(SubmitRequirementExpression.create(maxPart));
+        break;
+      default:
+        break;
+    }
+    if (!attributes.refPatterns().isEmpty()) {
+      builder.setApplicabilityExpression(
+          SubmitRequirementExpression.of(
+              String.join(
+                  " OR ",
+                  attributes.refPatterns().stream()
+                      .map(b -> "branch:\\\"" + b + "\\\"")
+                      .collect(Collectors.toList()))));
+    }
+    return builder.build();
+  }
+
+  private static boolean isBlockingOrRequiredLabel(String function) {
+    return function.equals("AnyWithBlock")
+        || function.equals("MaxWithBlock")
+        || function.equals("MaxNoBlock");
+  }
+
+  /**
+   * Returns true if the label definition was skipped in the project, i.e. it had only one defined
+   * value: zero.
+   */
+  private static boolean isLabelSkipped(List<String> values) {
+    return values.isEmpty() || (values.size() == 1 && values.get(0).startsWith("0"));
+  }
+
+  public boolean anyProjectHasProlog(Collection<Project.NameKey> allProjects) throws IOException {
+    for (Project.NameKey p : allProjects) {
+      if (hasPrologRules(p)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private boolean hasPrologRules(Project.NameKey project) throws IOException {
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader reader = rw.getObjectReader()) {
+      Ref refsConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsConfig == null) {
+        // Project does not have a refs/meta/config and no rules.pl consequently.
+        return false;
+      }
+      RevCommit commit = repo.parseCommit(refsConfig.getObjectId());
+      try (TreeWalk tw = TreeWalk.forPath(reader, "rules.pl", commit.getTree())) {
+        if (tw != null) {
+          return true;
+        }
+      }
+
+      return false;
+    }
+  }
+
+  /**
+   * Returns a map containing submit requirement names in lower name as keys, with {@link
+   * com.google.gerrit.entities.SubmitRequirement} as value.
+   */
+  private Map<String, SubmitRequirement> loadSubmitRequirements(Config rc) {
+    Map<String, SubmitRequirement> allRequirements = new LinkedHashMap<>();
+    for (String name : rc.getSubsections(ProjectConfig.SUBMIT_REQUIREMENT)) {
+      String description =
+          rc.getString(ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_DESCRIPTION);
+      String applicabilityExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION);
+      String submittabilityExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+      String overrideExpr =
+          rc.getString(
+              ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION);
+      boolean canInherit =
+          rc.getBoolean(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              name,
+              ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+              false);
+      SubmitRequirement submitRequirement =
+          SubmitRequirement.builder()
+              .setName(name)
+              .setDescription(Optional.ofNullable(description))
+              .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
+              .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+              .setAllowOverrideInChildProjects(canInherit)
+              .build();
+      allRequirements.put(name.toLowerCase(Locale.ROOT), submitRequirement);
+    }
+    return allRequirements;
+  }
+
+  private static boolean hasMigrationAlreadyRun(Repository repo) throws IOException {
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
+      if (refsMetaConfig == null) {
+        return false;
+      }
+      revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
+      RevCommit commit;
+      while ((commit = revWalk.next()) != null) {
+        if (COMMIT_MSG.equals(commit.getShortMessage())) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+
+  @AutoValue
+  abstract static class LabelAttributes {
+    abstract String function();
+
+    abstract boolean canOverride();
+
+    abstract boolean ignoreSelfApproval();
+
+    abstract ImmutableList<String> values();
+
+    abstract ImmutableList<String> refPatterns();
+
+    static LabelAttributes create(
+        String function,
+        boolean canOverride,
+        boolean ignoreSelfApproval,
+        ImmutableList<String> values,
+        ImmutableList<String> refPatterns) {
+      return new AutoValue_MigrateLabelFunctionsToSubmitRequirement_LabelAttributes(
+          function, canOverride, ignoreSelfApproval, values, refPatterns);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
index 209ff89..d84ae60 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
@@ -33,7 +33,8 @@
               Schema_181.class,
               Schema_182.class,
               Schema_183.class,
-              Schema_184.class)
+              Schema_184.class,
+              Schema_185.class)
           .collect(toImmutableSortedMap(naturalOrder(), v -> guessVersion(v).get(), v -> v));
 
   public static final int FIRST = ALL.firstKey();
diff --git a/java/com/google/gerrit/server/schema/SchemaModule.java b/java/com/google/gerrit/server/schema/SchemaModule.java
index ff2073d..e0e64a3 100644
--- a/java/com/google/gerrit/server/schema/SchemaModule.java
+++ b/java/com/google/gerrit/server/schema/SchemaModule.java
@@ -16,6 +16,7 @@
 
 import static com.google.inject.Scopes.SINGLETON;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -25,9 +26,12 @@
 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.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritImportedServerIdsProvider;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.inject.TypeLiteral;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /** Bindings for low-level Gerrit schema data. */
@@ -51,6 +55,11 @@
         .toProvider(GerritServerIdProvider.class)
         .in(SINGLETON);
 
+    bind(new TypeLiteral<ImmutableSet<String>>() {})
+        .annotatedWith(GerritImportedServerIds.class)
+        .toProvider(GerritImportedServerIdsProvider.class)
+        .in(SINGLETON);
+
     // It feels wrong to have this binding in a seemingly unrelated module, but it's a dependency of
     // SchemaCreatorImpl, so it's needed.
     // TODO(dborowitz): Is there any way to untangle this?
diff --git a/java/com/google/gerrit/server/schema/Schema_185.java b/java/com/google/gerrit/server/schema/Schema_185.java
new file mode 100644
index 0000000..264914f
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_185.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.ProjectConfig;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.List;
+import java.util.NavigableSet;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * Migrates the label configurations of all projects to copy conditions.
+ *
+ * @see MigrateLabelConfigToCopyCondition
+ */
+public class Schema_185 implements NoteDbSchemaVersion {
+  private AtomicInteger i = new AtomicInteger();
+  private Stopwatch sw = Stopwatch.createStarted();
+  private int size;
+
+  @Override
+  public void upgrade(Arguments args, UpdateUI ui) throws Exception {
+    ui.message("Migrating label configurations");
+
+    NavigableSet<Project.NameKey> projects = args.repoManager.list();
+    size = projects.size();
+
+    Set<List<Project.NameKey>> batches = Sets.newHashSet(Iterables.partition(projects, 50));
+    ExecutorService pool = createExecutor(ui);
+    try {
+      batches.stream()
+          .forEach(
+              batch -> {
+                @SuppressWarnings("unused")
+                Future<?> possiblyIgnoredError =
+                    pool.submit(() -> processBatch(args.repoManager, args.serverUser, batch, ui));
+              });
+      pool.shutdown();
+      pool.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS);
+    } catch (InterruptedException e) {
+      throw new RuntimeException(e);
+    }
+    ui.message(
+        String.format(
+            "... (%.3f s) Migrated label configurations of all %d projects to schema 185",
+            elapsed(), i.get()));
+  }
+
+  private ExecutorService createExecutor(UpdateUI ui) {
+    int threads;
+    try {
+      threads = Integer.parseInt(System.getProperty("threadcount"));
+    } catch (NumberFormatException e) {
+      threads = Runtime.getRuntime().availableProcessors();
+    }
+    ui.message(String.format("... using %d threads ...", threads));
+    return Executors.newFixedThreadPool(threads);
+  }
+
+  private void processBatch(
+      GitRepositoryManager repoManager,
+      PersonIdent serverUser,
+      List<Project.NameKey> batch,
+      UpdateUI ui) {
+    try {
+      for (Project.NameKey project : batch) {
+        try {
+          new MigrateLabelConfigToCopyCondition(repoManager, serverUser).execute(project);
+          int count = i.incrementAndGet();
+          showProgress(ui, count);
+        } catch (ConfigInvalidException e) {
+          ui.message(
+              String.format(
+                  "WARNING: Skipping migration of label configurations for project %s"
+                      + " since its %s file is invalid: %s",
+                  project, ProjectConfig.PROJECT_CONFIG, e.getMessage()));
+        }
+      }
+    } catch (IOException e) {
+      throw new UncheckedIOException(
+          String.format("Failed to migrate batch of projects to schema 185: %s", batch), e);
+    }
+  }
+
+  private double elapsed() {
+    return sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
+  }
+
+  private void showProgress(UpdateUI ui, int count) {
+    if (count % 100 == 0) {
+      ui.message(
+          String.format(
+              "... (%.3f s) migrated label configurations of %d%% (%d/%d) projects",
+              elapsed(), Math.round(100.0 * count / size), count, size));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 39e3a59..7243bdf 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -102,10 +102,9 @@
           "[label \"Code-Review\"]",
           "  function = MaxWithBlock",
           "  defaultValue = 0",
-          "  copyMinScore = true",
-          "  copyAllScoresOnTrivialRebase = true",
-          "  value = -2 This shall not be merged",
-          "  value = -1 I would prefer this is not merged as is",
+          "  copyCondition = changekind:NO_CHANGE OR changekind:TRIVIAL_REBASE OR is:MIN",
+          "  value = -2 This shall not be submitted",
+          "  value = -1 I would prefer this is not submitted as is",
           "  value = 0 No score",
           "  value = +1 Looks good to me, but someone else must approve",
           "  value = +2 Looks good to me, approved");
diff --git a/java/com/google/gerrit/server/securestore/DefaultSecureStore.java b/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
index 02ff159..37e7278 100644
--- a/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
+++ b/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.securestore;
 
 import com.google.gerrit.common.FileUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
@@ -54,6 +55,7 @@
     return sec.getStringList(section, subsection, name);
   }
 
+  @Nullable
   @Override
   public synchronized String[] getListForPlugin(
       String pluginName, String section, String subsection, String name) {
diff --git a/java/com/google/gerrit/server/securestore/SecureStore.java b/java/com/google/gerrit/server/securestore/SecureStore.java
index b53e38c..855c978 100644
--- a/java/com/google/gerrit/server/securestore/SecureStore.java
+++ b/java/com/google/gerrit/server/securestore/SecureStore.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.securestore;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import java.util.List;
 
 /**
@@ -53,6 +54,7 @@
    *
    * @return decrypted String value or {@code null} if not found
    */
+  @Nullable
   public final String get(String section, String subsection, String name) {
     String[] values = getList(section, subsection, name);
     if (values != null && values.length > 0) {
@@ -67,6 +69,7 @@
    *
    * @return decrypted String value or {@code null} if not found
    */
+  @Nullable
   public final String getForPlugin(
       String pluginName, String section, String subsection, String name) {
     String[] values = getListForPlugin(pluginName, section, subsection, name);
diff --git a/java/com/google/gerrit/server/securestore/SecureStoreProvider.java b/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
index 4e43b2e..547b6dc 100644
--- a/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
+++ b/java/com/google/gerrit/server/securestore/SecureStoreProvider.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.securestore;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.SiteLibraryLoaderUtil;
 import com.google.gerrit.server.config.SitePaths;
@@ -27,8 +26,6 @@
 
 @Singleton
 public class SecureStoreProvider implements Provider<SecureStore> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final Path libdir;
   private final Injector injector;
   private final String className;
@@ -56,9 +53,7 @@
     try {
       return (Class<? extends SecureStore>) Class.forName(className);
     } catch (ClassNotFoundException e) {
-      String msg = String.format("Cannot load secure store class: %s", className);
-      logger.atSevere().withCause(e).log(msg);
-      throw new RuntimeException(msg, e);
+      throw new RuntimeException(String.format("Cannot load secure store class: %s", className), e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index a09ba63..0471b67 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -19,6 +19,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -31,7 +32,6 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -45,9 +45,10 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = CodeReviewCommit.ORDER.sortedCopy(toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     boolean first = true;
     while (!sorted.isEmpty()) {
       CodeReviewCommit n = sorted.remove(0);
@@ -62,7 +63,7 @@
       }
       first = false;
     }
-    return ops;
+    return ops.build();
   }
 
   private class CherryPickRootOp extends SubmitStrategyOp {
@@ -102,8 +103,7 @@
       args.rw.parseBody(mergeTip);
       String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
 
-      PersonIdent committer =
-          args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
+      PersonIdent committer = ctx.newCommitterIdent(args.caller);
       try {
         newCommit =
             args.mergeUtil.createCherryPickFromCommit(
@@ -143,6 +143,7 @@
       patchSetInfo = args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
     }
 
+    @Nullable
     @Override
     public PatchSet updateChangeImpl(ChangeContext ctx) throws NoSuchChangeException, IOException {
       if (newCommit == null && toMerge.getStatusCode() == SKIPPED_IDENTICAL_TREE) {
@@ -196,7 +197,7 @@
           && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
-        PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
+        PersonIdent myIdent = ctx.newPersonIdent(args.serverIdent);
         CodeReviewCommit result =
             args.mergeUtil.mergeOneCommit(
                 myIdent,
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 3d38f6c..7aa3716 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
@@ -93,7 +94,10 @@
     RequestContext old = requestContext.setContext(this);
     try {
       MergedSender emailSender =
-          mergedSenderFactory.create(project, change.getId(), Optional.of(stickyApprovalDiff));
+          mergedSenderFactory.create(
+              project,
+              change.getId(),
+              Optional.ofNullable(Strings.emptyToNull(stickyApprovalDiff)));
       if (submitter != null) {
         emailSender.setFrom(submitter.getAccountId());
       }
diff --git a/java/com/google/gerrit/server/submit/FastForwardOnly.java b/java/com/google/gerrit/server/submit/FastForwardOnly.java
index 8a30898..ee8fec8 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOnly.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOnly.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.update.RepoContext;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
@@ -30,7 +29,7 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
 
     Map<BranchNameKey, CodeReviewCommit> branchToCommit = new HashMap<>();
@@ -47,7 +46,8 @@
       }
     }
 
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     CodeReviewCommit newTipCommit =
         args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
     if (!newTipCommit.equals(args.mergeTip.getInitialTip())) {
@@ -57,7 +57,7 @@
         ops.add(new NotFastForwardOp(c));
       }
     }
-    return ops;
+    return ops.build();
   }
 
   private class NotFastForwardOp extends SubmitStrategyOp {
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 89ba1fa..86d6c674 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
@@ -48,6 +49,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
@@ -59,6 +61,8 @@
 public class LocalMergeSuperSetComputation implements MergeSuperSetComputation {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  public static final int MAX_SUBMITTABLE_CHANGES_AT_ONCE_DEFAULT = 1024;
+
   public static class LocalMergeSuperSetComputationModule extends AbstractModule {
     @Override
     protected void configure() {
@@ -83,15 +87,20 @@
   private final Map<QueryKey, ImmutableList<ChangeData>> queryCache;
   private final Map<BranchNameKey, Optional<RevCommit>> heads;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
+  private final int maxSubmittableChangesAtOnce;
 
   @Inject
   LocalMergeSuperSetComputation(
       Provider<InternalChangeQuery> queryProvider,
-      ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory) {
+      ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
+      @GerritServerConfig Config gerritConfig) {
     this.queryProvider = queryProvider;
     this.queryCache = new HashMap<>();
     this.heads = new HashMap<>();
     this.changeIsVisibleToPredicateFactory = changeIsVisibleToPredicateFactory;
+    this.maxSubmittableChangesAtOnce =
+        gerritConfig.getInt(
+            "change", "maxSubmittableAtOnce", MAX_SUBMITTABLE_CHANGES_AT_ONCE_DEFAULT);
   }
 
   @Override
@@ -130,9 +139,15 @@
       }
 
       Set<String> visibleHashes =
-          walkChangesByHashes(visibleCommits, Collections.emptySet(), or, branchNameKey);
+          walkChangesByHashes(
+              visibleCommits,
+              Collections.emptySet(),
+              or,
+              branchNameKey,
+              maxSubmittableChangesAtOnce);
       Set<String> nonVisibleHashes =
-          walkChangesByHashes(nonVisibleCommits, visibleHashes, or, branchNameKey);
+          walkChangesByHashes(
+              nonVisibleCommits, visibleHashes, or, branchNameKey, maxSubmittableChangesAtOnce);
 
       ChangeSet partialSet =
           byCommitsOnBranchNotMerged(or, branchNameKey, visibleHashes, nonVisibleHashes, user);
@@ -216,7 +231,11 @@
 
   @UsedAt(UsedAt.Project.GOOGLE)
   public Set<String> walkChangesByHashes(
-      Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, BranchNameKey b)
+      Collection<RevCommit> sourceCommits,
+      Set<String> ignoreHashes,
+      OpenRepo or,
+      BranchNameKey b,
+      int limit)
       throws IOException {
     Set<String> destHashes = new HashSet<>();
     or.rw.reset();
@@ -226,7 +245,11 @@
       if (ignoreHashes.contains(name)) {
         continue;
       }
-      destHashes.add(name);
+      if (destHashes.size() < limit) {
+        destHashes.add(name);
+      } else {
+        break;
+      }
       or.rw.markStart(c);
     }
     for (RevCommit c : or.rw) {
@@ -234,7 +257,11 @@
       if (ignoreHashes.contains(name)) {
         continue;
       }
-      destHashes.add(name);
+      if (destHashes.size() < limit) {
+        destHashes.add(name);
+      } else {
+        break;
+      }
     }
 
     return destHashes;
@@ -253,7 +280,7 @@
   }
 
   private void logErrorAndThrow(String msg) {
-    logger.atSevere().log(msg);
+    logger.atSevere().log("%s", msg);
     throw new StorageException(msg);
   }
 }
diff --git a/java/com/google/gerrit/server/submit/MergeAlways.java b/java/com/google/gerrit/server/submit/MergeAlways.java
index c3f186a..1118a29 100644
--- a/java/com/google/gerrit/server/submit/MergeAlways.java
+++ b/java/com/google/gerrit/server/submit/MergeAlways.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
@@ -25,9 +25,10 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     if (args.mergeTip.getInitialTip() == null && !sorted.isEmpty()) {
       // The branch is unborn. Take a fast-forward resolution to
       // create the branch.
@@ -38,7 +39,7 @@
       CodeReviewCommit n = sorted.remove(0);
       ops.add(new MergeOneOp(args, n));
     }
-    return ops;
+    return ops.build();
   }
 
   static boolean dryRun(
diff --git a/java/com/google/gerrit/server/submit/MergeIfNecessary.java b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
index 30f1661..75136f5 100644
--- a/java/com/google/gerrit/server/submit/MergeIfNecessary.java
+++ b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.git.CodeReviewCommit;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
@@ -25,9 +25,10 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
 
     if (args.mergeTip.getInitialTip() == null
         || !args.subscriptionGraph.hasSubscription(args.destBranch)) {
@@ -43,7 +44,7 @@
       CodeReviewCommit n = sorted.remove(0);
       ops.add(new MergeOneOp(args, n));
     }
-    return ops;
+    return ops.build();
   }
 
   static boolean dryRun(
diff --git a/java/com/google/gerrit/server/submit/MergeOneOp.java b/java/com/google/gerrit/server/submit/MergeOneOp.java
index f1b93e1..1840479 100644
--- a/java/com/google/gerrit/server/submit/MergeOneOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOneOp.java
@@ -29,9 +29,7 @@
 
   @Override
   public void updateRepoImpl(RepoContext ctx) throws IntegrationConflictException, IOException {
-    PersonIdent caller =
-        ctx.getIdentifiedUser()
-            .newCommitterIdent(args.serverIdent.getWhen(), args.serverIdent.getTimeZone());
+    PersonIdent caller = ctx.getIdentifiedUser().newCommitterIdent(args.serverIdent);
     if (args.mergeTip.getCurrentTip() == null) {
       throw new IllegalStateException(
           "cannot merge commit "
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 942f024..14a636f 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -36,13 +36,14 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Change.Status;
-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.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
-import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
+import com.google.gerrit.exceptions.MergeUpdateException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -88,7 +89,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -96,6 +97,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Collectors;
@@ -246,7 +248,7 @@
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
 
-  private Timestamp ts;
+  private Instant ts;
   private SubmissionId submissionId;
   private IdentifiedUser caller;
 
@@ -254,7 +256,7 @@
   private CommitStatus commitStatus;
   private SubmitInput submitInput;
   private NotifyResolver.Result notify;
-  private Set<Project.NameKey> allProjects;
+  private Set<Project.NameKey> projects;
   private boolean dryrun;
   private TopicMetrics topicMetrics;
 
@@ -303,43 +305,47 @@
     }
   }
 
-  public static void checkSubmitRule(ChangeData cd, boolean allowClosed)
-      throws ResourceConflictException {
+  public static void checkSubmitRequirements(ChangeData cd) throws ResourceConflictException {
     PatchSet patchSet = cd.currentPatchSet();
     if (patchSet == null) {
       throw new ResourceConflictException("missing current patch set for change " + cd.getId());
     }
-    List<SubmitRecord> results = getSubmitRecords(cd, allowClosed);
-    if (SubmitRecord.allRecordsOK(results)) {
-      // Rules supplied a valid solution.
+    Map<SubmitRequirement, SubmitRequirementResult> srResults =
+        cd.submitRequirementsIncludingLegacy();
+    if (srResults.values().stream().allMatch(SubmitRequirementResult::fulfilled)) {
       return;
-    } else if (results.isEmpty()) {
+    } else if (srResults.isEmpty()) {
       throw new IllegalStateException(
           String.format(
-              "SubmitRuleEvaluator.evaluate for change %s returned empty list for %s in %s",
+              "Submit requirement results for change '%s' and patchset '%s' "
+                  + "are empty in project '%s'",
               cd.getId(), patchSet.id(), cd.change().getProject().get()));
     }
 
-    for (SubmitRecord record : results) {
-      switch (record.status) {
-        case OK:
+    for (SubmitRequirementResult srResult : srResults.values()) {
+      switch (srResult.status()) {
+        case SATISFIED:
+        case NOT_APPLICABLE:
+        case OVERRIDDEN:
+        case FORCED:
           break;
 
-        case CLOSED:
-          throw new ResourceConflictException("change is closed");
+        case ERROR:
+          throw new ResourceConflictException(
+              String.format(
+                  "submit requirement '%s' has an error: %s",
+                  srResult.submitRequirement().name(), srResult.errorMessage().orElse("")));
 
-        case RULE_ERROR:
-          throw new ResourceConflictException("submit rule error: " + record.errorMessage);
+        case UNSATISFIED:
+          throw new ResourceConflictException(
+              String.format(
+                  "submit requirement '%s' is unsatisfied.", srResult.submitRequirement().name()));
 
-        case NOT_READY:
-          throw new ResourceConflictException(describeNotReady(cd, record));
-
-        case FORCED:
         default:
           throw new IllegalStateException(
               String.format(
-                  "Unexpected SubmitRecord status %s for %s in %s",
-                  record.status, patchSet.id().getId(), cd.change().getProject().get()));
+                  "Unexpected submit requirement status %s for %s in %s",
+                  srResult.status().name(), patchSet.id().getId(), cd.change().getProject().get()));
       }
     }
     throw new IllegalStateException();
@@ -349,56 +355,8 @@
     return allowClosed ? SUBMIT_RULE_OPTIONS_ALLOW_CLOSED : SUBMIT_RULE_OPTIONS;
   }
 
-  private static List<SubmitRecord> getSubmitRecords(ChangeData cd, boolean allowClosed) {
-    return cd.submitRecords(submitRuleOptions(allowClosed));
-  }
-
-  private static String describeNotReady(ChangeData cd, SubmitRecord record) {
-    List<String> blockingConditions = new ArrayList<>();
-    if (record.labels != null) {
-      blockingConditions.add(describeLabels(cd, record.labels));
-    }
-    if (record.requirements != null) {
-      record.requirements.stream()
-          .map(MergeOp::describeSubmitRequirement)
-          .forEach(blockingConditions::add);
-    }
-    return Joiner.on("; ").join(blockingConditions);
-  }
-
-  private static String describeLabels(ChangeData cd, List<SubmitRecord.Label> labels) {
-    List<String> labelResults = new ArrayList<>();
-    for (SubmitRecord.Label lbl : labels) {
-      switch (lbl.status) {
-        case OK:
-        case MAY:
-          break;
-
-        case REJECT:
-          labelResults.add("blocked by " + lbl.label);
-          break;
-
-        case NEED:
-          labelResults.add("needs " + lbl.label);
-          break;
-
-        case IMPOSSIBLE:
-          labelResults.add("needs " + lbl.label + " (check project access)");
-          break;
-
-        default:
-          throw new IllegalStateException(
-              String.format(
-                  "Unsupported SubmitRecord.Label %s for %s in %s",
-                  lbl, cd.change().currentPatchSetId(), cd.change().getProject()));
-      }
-    }
-    return Joiner.on("; ").join(labelResults);
-  }
-
-  private static String describeSubmitRequirement(LegacySubmitRequirement legacySubmitRequirement) {
-    return String.format(
-        "Submit requirement not fulfilled: %s", legacySubmitRequirement.fallbackText());
+  private static List<SubmitRecord> getSubmitRecords(ChangeData cd) {
+    return cd.submitRecords(submitRuleOptions(/* allowClosed= */ false));
   }
 
   private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
@@ -415,7 +373,7 @@
         } else if (cd.change().isWorkInProgress()) {
           commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
         } else {
-          checkSubmitRule(cd, allowMerged);
+          checkSubmitRequirements(cd);
         }
       } catch (ResourceConflictException e) {
         commitStatus.problem(cd.getId(), e.getMessage());
@@ -428,15 +386,32 @@
     commitStatus.maybeFailVerbose();
   }
 
-  private void bypassSubmitRules(ChangeSet cs, boolean allowClosed) {
+  private void bypassSubmitRulesAndRequirements(ChangeSet cs) {
     checkArgument(
         !cs.furtherHiddenChanges(), "cannot bypass submit rules for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
-      List<SubmitRecord> records = new ArrayList<>(getSubmitRecords(cd, allowClosed));
+      Change change = cd.change();
+      if (change == null) {
+        throw new StorageException("Change not found");
+      }
+      if (change.isClosed()) {
+        // No need to check submit rules if the change is closed.
+        continue;
+      }
+      List<SubmitRecord> records = new ArrayList<>(getSubmitRecords(cd));
       SubmitRecord forced = new SubmitRecord();
       forced.status = SubmitRecord.Status.FORCED;
       records.add(forced);
-      cd.setSubmitRecords(submitRuleOptions(allowClosed), records);
+      cd.setSubmitRecords(submitRuleOptions(/* allowClosed= */ false), records);
+
+      // Also bypass submit requirements. Mark them as forced.
+      Map<SubmitRequirement, SubmitRequirementResult> forcedSRs =
+          cd.submitRequirementsIncludingLegacy().entrySet().stream()
+              .collect(
+                  Collectors.toMap(
+                      Map.Entry::getKey,
+                      entry -> entry.getValue().toBuilder().forced(Optional.of(true)).build()));
+      cd.setSubmitRequirements(forcedSRs);
     }
   }
 
@@ -470,7 +445,7 @@
             firstNonNull(submitInput.notify, NotifyHandling.ALL), submitInput.notifyDetails);
     this.dryrun = dryrun;
     this.caller = caller;
-    this.ts = TimeUtil.nowTs();
+    this.ts = TimeUtil.now();
     this.submissionId = new SubmissionId(change);
 
     try (TraceContext traceContext =
@@ -481,7 +456,9 @@
       logger.atFine().log("Beginning integration of %s", change);
       try {
         ChangeSet indexBackedChangeSet =
-            mergeSuperSet.setMergeOpRepoManager(orm).completeChangeSet(change, caller);
+            mergeSuperSet
+                .setMergeOpRepoManager(orm)
+                .completeChangeSet(change, caller, /* includingTopicClosure= */ false);
         if (!indexBackedChangeSet.ids().contains(change.getId())) {
           // indexBackedChangeSet contains only open changes, if the change is missing in this set
           // it might be that the change was concurrently submitted in the meantime.
@@ -509,7 +486,7 @@
           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());
+                changeData.getId(), changeData.change().getStatus().name());
             continue;
           }
           filteredChanges.add(changeData);
@@ -538,7 +515,7 @@
                   boolean isRetry = attempt > 1;
                   if (isRetry) {
                     logger.atFine().log("Retrying, attempt #%d; skipping merged changes", attempt);
-                    this.ts = TimeUtil.nowTs();
+                    this.ts = TimeUtil.now();
                     openRepoManager();
                   }
                   this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
@@ -547,9 +524,10 @@
                     checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
                   } else {
                     logger.atFine().log("Bypassing submit rules");
-                    bypassSubmitRules(filteredNoteDbChangeSet, isRetry);
+                    bypassSubmitRulesAndRequirements(filteredNoteDbChangeSet);
                   }
-                  integrateIntoHistory(filteredNoteDbChangeSet, submissionExecutor);
+                  integrateIntoHistory(
+                      filteredNoteDbChangeSet, submissionExecutor, checkSubmitRules);
                   return null;
                 })
             .listener(retryTracker)
@@ -557,9 +535,6 @@
             // Multiply the timeout by the number of projects we're actually attempting to
             // 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);
 
@@ -627,7 +602,8 @@
     }
   }
 
-  private void integrateIntoHistory(ChangeSet cs, SubmissionExecutor submissionExecutor)
+  private void integrateIntoHistory(
+      ChangeSet cs, SubmissionExecutor submissionExecutor, boolean checkSubmitRules)
       throws RestApiException, UpdateException {
     checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
     logger.atFine().log("Beginning merge attempt on %s", cs);
@@ -658,17 +634,23 @@
       List<SubmitStrategy> strategies =
           getSubmitStrategies(
               toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
-      this.allProjects = updateOrderCalculator.getProjectsInOrder();
-      List<BatchUpdate> batchUpdates = orm.batchUpdates(allProjects);
+      this.projects = updateOrderCalculator.getProjectsInOrder();
+      List<BatchUpdate> batchUpdates =
+          orm.batchUpdates(
+              projects, /* refLogMessage= */ checkSubmitRules ? "merged" : "forced-merge");
       // Group batch updates by project
       Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
           batchUpdates.stream().collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
       for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
         Project.NameKey project = entry.getValue().project();
         Change.Id changeId = entry.getKey();
+        ChangeData cd = entry.getValue();
         batchUpdatesByProject
             .get(project)
-            .addOp(changeId, storeSubmitRequirementsOpFactory.create());
+            .addOp(
+                changeId,
+                storeSubmitRequirementsOpFactory.create(
+                    cd.submitRequirementsIncludingLegacy().values(), cd));
       }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
@@ -682,7 +664,7 @@
 
         // Do not leave executed BatchUpdates in the OpenRepos
         if (!dryrun) {
-          orm.resetUpdates(ImmutableSet.copyOf(this.allProjects));
+          orm.resetUpdates(ImmutableSet.copyOf(this.projects));
         }
       }
     } catch (NoSuchProjectException e) {
@@ -711,12 +693,12 @@
       if (e.getCause() instanceof IntegrationConflictException) {
         throw (IntegrationConflictException) e.getCause();
       }
-      throw new InternalServerWithUserMessageException(genericMergeError(cs), e);
+      throw new MergeUpdateException(genericMergeError(cs), e);
     }
   }
 
   public Set<Project.NameKey> getAllProjects() {
-    return allProjects;
+    return projects;
   }
 
   public MergeOpRepoManager getMergeOpRepoManager() {
@@ -739,7 +721,7 @@
       OpenRepo or = orm.getRepo(branch.project());
       if (toSubmit.containsKey(branch)) {
         BranchBatch submitting = toSubmit.get(branch);
-        logger.atFine().log("adding ops for branch batch %s", submitting);
+        logger.atFine().log("adding ops for branch %s, batch = %s", branch, submitting);
         OpenBranch ob = or.getBranch(branch);
         requireNonNull(
             submitting.submitType(),
@@ -944,11 +926,13 @@
     }
   }
 
+  @Nullable
   private SubmitType getSubmitType(ChangeData cd) {
     SubmitTypeRecord str = cd.submitTypeRecord();
     return str.isOk() ? str.type : null;
   }
 
+  @Nullable
   private OpenRepo openRepo(Project.NameKey project) {
     try {
       return orm.getRepo(project);
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index 8981b07..0a74a07 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -37,7 +37,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -167,7 +167,7 @@
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
 
-  private Timestamp ts;
+  private Instant ts;
   private IdentifiedUser caller;
   private NotifyResolver.Result notify;
 
@@ -185,7 +185,7 @@
     openRepos = new HashMap<>();
   }
 
-  public void setContext(Timestamp ts, IdentifiedUser caller, NotifyResolver.Result notify) {
+  public void setContext(Instant ts, IdentifiedUser caller, NotifyResolver.Result notify) {
     this.ts = requireNonNull(ts);
     this.caller = requireNonNull(caller);
     this.notify = requireNonNull(notify);
@@ -206,11 +206,12 @@
     }
   }
 
-  public List<BatchUpdate> batchUpdates(Collection<Project.NameKey> projects)
+  public List<BatchUpdate> batchUpdates(Collection<Project.NameKey> projects, String refLogMessage)
       throws NoSuchProjectException, IOException {
+    requireNonNull(refLogMessage, "refLogMessage");
     List<BatchUpdate> updates = new ArrayList<>(projects.size());
     for (Project.NameKey project : projects) {
-      updates.add(getRepo(project).getUpdate().setNotify(notify).setRefLogMessage("merged"));
+      updates.add(getRepo(project).getUpdate().setNotify(notify).setRefLogMessage(refLogMessage));
     }
     return updates;
   }
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 67f2907..8581e20 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -92,7 +92,19 @@
     return this;
   }
 
-  public ChangeSet completeChangeSet(Change change, CurrentUser user)
+  /**
+   * Gets the ChangeSet of this {@code change} based on visiblity of the {@code user}. if
+   * change.submitWholeTopic is true, we return the topic closure as well as the dependent changes
+   * of the topic closure. Otherwise, we return just the dependent changes.
+   *
+   * @param change the change for which we get the dependent changes / topic closure.
+   * @param user the current user for visibility purposes.
+   * @param includingTopicClosure when true, return as if change.submitWholeTopic = true, so we
+   *     return the topic closure.
+   * @return {@link ChangeSet} object that represents the dependent changes and/or topic closure of
+   *     the requested change.
+   */
+  public ChangeSet completeChangeSet(Change change, CurrentUser user, boolean includingTopicClosure)
       throws IOException, PermissionBackendException {
     try {
       if (orm == null) {
@@ -113,7 +125,7 @@
       }
 
       ChangeSet changeSet = new ChangeSet(cd, visible);
-      if (wholeTopicEnabled(cfg)) {
+      if (wholeTopicEnabled(cfg) || includingTopicClosure) {
         return completeChangeSetIncludingTopics(changeSet, user);
       }
       try (TraceContext traceContext = PluginContext.newTrace(mergeSuperSetComputation)) {
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 355d25f..5f58a74 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -15,11 +15,12 @@
 package com.google.gerrit.server.submit;
 
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
 import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
-import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
@@ -38,7 +39,6 @@
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -56,7 +56,7 @@
   }
 
   @Override
-  public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
+  public ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted;
     try {
       sorted = args.rebaseSorter.sort(toMerge);
@@ -92,12 +92,13 @@
         // found a merge commit that depends on a normal change, this means we are required to merge
         // the whole series at once
         sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
-        return sorted.stream().map(n -> new MergeIfNecessaryOp(n)).collect(toList());
+        return sorted.stream().map(n -> new MergeIfNecessaryOp(n)).collect(toImmutableList());
       }
       foundNonMerge = true;
     }
 
-    List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
+    ImmutableList.Builder<SubmitStrategyOp> ops =
+        ImmutableList.builderWithExpectedSize(sorted.size());
     boolean first = true;
     while (!sorted.isEmpty()) {
       CodeReviewCommit n = sorted.remove(0);
@@ -114,7 +115,7 @@
       }
       first = false;
     }
-    return ops;
+    return ops.build();
   }
 
   private class RebaseRootOp extends SubmitStrategyOp {
@@ -166,8 +167,7 @@
         RevCommit mergeTip = args.mergeTip.getCurrentTip();
         args.rw.parseBody(mergeTip);
         String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-        PersonIdent committer =
-            args.caller.newCommitterIdent(ctx.getWhen(), args.serverIdent.getTimeZone());
+        PersonIdent committer = ctx.newCommitterIdent(args.caller);
         try {
           newCommit =
               args.mergeUtil.createCherryPickFromCommit(
@@ -239,6 +239,7 @@
       acceptMergeTip(args.mergeTip);
     }
 
+    @Nullable
     @Override
     public PatchSet updateChangeImpl(ChangeContext ctx)
         throws NoSuchChangeException, ResourceConflictException, IOException, BadRequestException {
@@ -304,8 +305,7 @@
           && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
         mergeTip.moveTipTo(toMerge, toMerge);
       } else {
-        PersonIdent caller =
-            ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone());
+        PersonIdent caller = ctx.newCommitterIdent();
         CodeReviewCommit newTip =
             args.mergeUtil.mergeOneCommit(
                 caller,
diff --git a/java/com/google/gerrit/server/submit/SubmitDryRun.java b/java/com/google/gerrit/server/submit/SubmitDryRun.java
index bcd7923..a3bb58b 100644
--- a/java/com/google/gerrit/server/submit/SubmitDryRun.java
+++ b/java/com/google/gerrit/server/submit/SubmitDryRun.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -96,13 +97,13 @@
   }
 
   private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
+  private final MergeUtilFactory mergeUtilFactory;
   private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
   SubmitDryRun(
       ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
+      MergeUtilFactory mergeUtilFactory,
       Provider<InternalChangeQuery> queryProvider) {
     this.projectCache = projectCache;
     this.mergeUtilFactory = mergeUtilFactory;
@@ -152,7 +153,7 @@
       case INHERIT:
       default:
         String errorMsg = "No submit strategy for: " + submitType;
-        logger.atSevere().log(errorMsg);
+        logger.atSevere().log("%s", errorMsg);
         throw new StorageException(errorMsg);
     }
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 6291e6c..bdda3fc5 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -18,8 +18,10 @@
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.SubmissionId;
@@ -42,6 +44,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.MergeUtilFactory;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -74,6 +77,8 @@
  * merged.
  */
 public abstract class SubmitStrategy {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   public static Module module() {
     return new FactoryModule() {
       @Override
@@ -150,7 +155,7 @@
         EmailMerge.Factory mergedSenderFactory,
         GitRepositoryManager repoManager,
         LabelNormalizer labelNormalizer,
-        MergeUtil.Factory mergeUtilFactory,
+        MergeUtilFactory mergeUtilFactory,
         PatchSetInfoFactory patchSetInfoFactory,
         PatchSetUtil psUtil,
         @GerritPersonIdent PersonIdent serverIdent,
@@ -273,6 +278,7 @@
       Change.Id id = c.change().getId();
       bu.addOp(id, args.setPrivateOpFactory.create(false, null));
       ImplicitIntegrateOp implicitIntegrateOp = new ImplicitIntegrateOp(args, c);
+      logger.atFine().log("Add implicit integrate op: %s", implicitIntegrateOp);
       bu.addOp(id, implicitIntegrateOp);
       maybeAddTestHelperOp(bu, id);
       this.submitStrategyOps.add(implicitIntegrateOp);
@@ -280,6 +286,7 @@
 
     // Then ops for explicitly merged changes
     for (SubmitStrategyOp op : ops) {
+      logger.atFine().log("Add explicit integrate op: %s", op);
       bu.addOp(op.getId(), args.setPrivateOpFactory.create(false, null));
       bu.addOp(op.getId(), op);
       maybeAddTestHelperOp(bu, op.getId());
@@ -293,5 +300,5 @@
     }
   }
 
-  protected abstract List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge);
+  protected abstract ImmutableList<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge);
 }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 2e66ae2..3bd26dc 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -90,7 +90,7 @@
       case INHERIT:
       default:
         String errorMsg = "No submit strategy for: " + submitType;
-        logger.atSevere().log(errorMsg);
+        logger.atSevere().log("%s", errorMsg);
         throw new StorageException(errorMsg);
     }
   }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 7d428eb..96dc326 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -21,7 +21,9 @@
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.base.MoreObjects;
 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.LabelId;
@@ -126,7 +128,8 @@
       logger.atFine().log("No merge tip, no update to perform");
       return;
     }
-    logger.atFine().log("Moved tip from %s to %s", tipBefore, tipAfter);
+    logger.atFine().log(
+        "Moved tip from %s to %s (branch = %s)", tipBefore, tipAfter, getDest().branch());
 
     checkProjectConfig(ctx, tipAfter);
 
@@ -158,6 +161,7 @@
     }
   }
 
+  @Nullable
   private CodeReviewCommit getAlreadyMergedCommit(RepoContext ctx) throws IOException {
     CodeReviewCommit tip = args.mergeTip.getInitialTip();
     if (tip == null) {
@@ -286,7 +290,7 @@
       setMerged(ctx, commit, message(ctx, commit, s));
     } catch (StorageException err) {
       String msg = "Error updating change status for " + id;
-      logger.atSevere().withCause(err).log(msg);
+      logger.atSevere().withCause(err).log("%s", msg);
       args.commitStatus.logProblem(id, msg);
       // It's possible this happened before updating anything in the db, but
       // it's hard to know for sure, so just return true below to be safe.
@@ -327,7 +331,7 @@
         ctx.getRevWalk(), ctx.getUpdate(psId), psId, alreadyMergedCommit, groups, null, null);
   }
 
-  private void setApproval(ChangeContext ctx, IdentifiedUser user) throws IOException {
+  private void setApproval(ChangeContext ctx, IdentifiedUser user) {
     Change.Id id = ctx.getChange().getId();
     List<SubmitRecord> records = args.commitStatus.getSubmitRecords(id);
     PatchSet.Id oldPsId = toMerge.getPatchsetId();
@@ -348,13 +352,10 @@
     }
   }
 
-  private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update)
-      throws IOException {
+  private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update) {
     PatchSet.Id psId = update.getPatchSetId();
     Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
-    for (PatchSetApproval psa :
-        args.approvalsUtil.byPatchSet(
-            ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
+    for (PatchSetApproval psa : args.approvalsUtil.byPatchSet(ctx.getNotes(), psId)) {
       byKey.put(psa.key(), psa);
     }
 
@@ -482,7 +483,7 @@
       // TODO(dborowitz): Move to BatchUpdate? Would also allow us to run once
       // per project even if multiple changes to refs/meta/config are submitted.
       if (RefNames.REFS_CONFIG.equals(getDest().branch())) {
-        args.projectCache.evict(getProject());
+        args.projectCache.evictAndReindex(getProject());
         ProjectState p =
             args.projectCache.get(getProject()).orElseThrow(illegalState(getProject()));
         try (Repository git = args.repoManager.openRepository(getProject())) {
@@ -521,19 +522,29 @@
     }
   }
 
-  /** See {@link #updateRepo(RepoContext)} */
+  /**
+   * See {@link #updateRepo(RepoContext)}
+   *
+   * @param ctx context for the repository update
+   */
   protected void updateRepoImpl(RepoContext ctx) throws Exception {}
 
   /**
    * Returns a new patch set if one was created by the submit strategy, or null if not
    *
    * <p>See {@link #updateChange(ChangeContext)}
+   *
+   * @param ctx context for the change update
    */
   protected PatchSet updateChangeImpl(ChangeContext ctx) throws Exception {
     return null;
   }
 
-  /** See {@link #postUpdate(PostUpdateContext)} */
+  /**
+   * See {@link #postUpdate(PostUpdateContext)}
+   *
+   * @param ctx context for the post update
+   */
   protected void postUpdateImpl(PostUpdateContext ctx) throws Exception {}
 
   /** Amend the commit with gitlink update */
@@ -558,4 +569,14 @@
           e);
     }
   }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("commit", getCommit().name())
+        .add("changeId", getId())
+        .add("dest", getDest().branch())
+        .add("project", getProject())
+        .toString();
+  }
 }
diff --git a/java/com/google/gerrit/server/submit/SubmoduleCommits.java b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
index 1312a4b..1fd3ad6 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleCommits.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
@@ -18,6 +18,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.SubmoduleSubscription;
 import com.google.gerrit.exceptions.StorageException;
@@ -36,7 +37,7 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -212,6 +213,7 @@
     return newCommit;
   }
 
+  @Nullable
   private RevCommit updateSubmodule(
       DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
       throws SubmoduleConflictException, IOException {
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index 69d76e2..ba736fa 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -103,7 +103,10 @@
           }
         }
       }
-      BatchUpdate.execute(orm.batchUpdates(superProjects), ImmutableList.of(), dryrun);
+      BatchUpdate.execute(
+          orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
+          ImmutableList.of(),
+          dryrun);
     } catch (UpdateException | IOException | NoSuchProjectException e) {
       throw new StorageException("Cannot update gitlinks", e);
     }
diff --git a/java/com/google/gerrit/server/tools/ToolsCatalog.java b/java/com/google/gerrit/server/tools/ToolsCatalog.java
index 9c1483f..015b8f1 100644
--- a/java/com/google/gerrit/server/tools/ToolsCatalog.java
+++ b/java/com/google/gerrit/server/tools/ToolsCatalog.java
@@ -31,7 +31,7 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-import java.util.SortedMap;
+import java.util.NavigableMap;
 import java.util.TreeMap;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.util.RawParseUtils;
@@ -46,7 +46,7 @@
 public class ToolsCatalog {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final SortedMap<String, Entry> toc;
+  private final NavigableMap<String, Entry> toc;
 
   @Inject
   ToolsCatalog() throws IOException {
@@ -73,8 +73,8 @@
     return toc.get(name);
   }
 
-  private static SortedMap<String, Entry> readToc() throws IOException {
-    SortedMap<String, Entry> toc = new TreeMap<>();
+  private static NavigableMap<String, Entry> readToc() throws IOException {
+    NavigableMap<String, Entry> toc = new TreeMap<>();
     final BufferedReader br =
         new BufferedReader(new InputStreamReader(new ByteArrayInputStream(read("TOC")), UTF_8));
     String line;
@@ -108,7 +108,7 @@
     }
     toc.put(top.getPath(), top);
 
-    return Collections.unmodifiableSortedMap(toc);
+    return Collections.unmodifiableNavigableMap(toc);
   }
 
   @Nullable
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 917e967..412a8ee 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -26,6 +26,7 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
@@ -34,10 +35,13 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectChangeKey;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -48,6 +52,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
@@ -68,7 +73,8 @@
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -76,7 +82,6 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.TreeMap;
 import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -122,7 +127,7 @@
   }
 
   public interface Factory {
-    BatchUpdate create(Project.NameKey project, CurrentUser user, Timestamp when);
+    BatchUpdate create(Project.NameKey project, CurrentUser user, Instant when);
   }
 
   public static void execute(
@@ -148,7 +153,9 @@
         }
         for (ChangesHandle h : changesHandles) {
           h.execute();
-          indexFutures.addAll(h.startIndexFutures());
+          if (h.requiresReindex()) {
+            indexFutures.addAll(h.startIndexFutures());
+          }
         }
         notifyAfterUpdateRefs(listeners);
         notifyAfterUpdateChanges(listeners);
@@ -252,13 +259,13 @@
     }
 
     @Override
-    public Timestamp getWhen() {
+    public Instant getWhen() {
       return when;
     }
 
     @Override
-    public TimeZone getTimeZone() {
-      return tz;
+    public ZoneId getZoneId() {
+      return zoneId;
     }
 
     @Override
@@ -354,6 +361,12 @@
     }
 
     @Override
+    public ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId) {
+      return changeDatas.computeIfAbsent(
+          changeId, id -> changeDataFactory.create(projectName, changeId));
+    }
+
+    @Override
     public ChangeData getChangeData(Change change) {
       return changeDatas.computeIfAbsent(change.getId(), id -> changeDataFactory.create(change));
     }
@@ -361,8 +374,13 @@
 
   /** Per-change result status from {@link #executeChangeOps}. */
   private enum ChangeResult {
+    /** Change was not modified by any of the batch update ops. */
     SKIPPED,
+
+    /** Change was inserted or updated. */
     UPSERTED,
+
+    /** Change was deleted. */
     DELETED
   }
 
@@ -376,8 +394,8 @@
 
   private final Project.NameKey project;
   private final CurrentUser user;
-  private final Timestamp when;
-  private final TimeZone tz;
+  private final Instant when;
+  private final ZoneId zoneId;
 
   private final ListMultimap<Change.Id, BatchUpdateOp> ops =
       MultimapBuilder.linkedHashKeys().arrayListValues().build();
@@ -387,11 +405,15 @@
 
   private RepoView repoView;
   private BatchRefUpdate batchRefUpdate;
+  private ImmutableListMultimap<ProjectChangeKey, AttentionSetUpdate> attentionSetUpdates;
+
   private boolean executed;
   private OnSubmitValidators onSubmitValidators;
   private PushCertificate pushCert;
   private String refLogMessage;
   private NotifyResolver.Result notify = NotifyResolver.Result.all();
+  // Batch operations doesn't need observer
+  private AttentionSetObserver attentionSetObserver;
 
   @Inject
   BatchUpdate(
@@ -403,9 +425,10 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeIndexer indexer,
       GitReferenceUpdated gitRefUpdated,
+      AttentionSetObserver attentionSetObserver,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
-      @Assisted Timestamp when) {
+      @Assisted Instant when) {
     this.repoManager = repoManager;
     this.changeDataFactory = changeDataFactory;
     this.changeNotesFactory = changeNotesFactory;
@@ -416,7 +439,8 @@
     this.project = project;
     this.user = user;
     this.when = when;
-    tz = serverIdent.getTimeZone();
+    this.attentionSetObserver = attentionSetObserver;
+    zoneId = serverIdent.getZoneId();
   }
 
   @Override
@@ -589,6 +613,16 @@
     }
   }
 
+  private void fireAttentionSetUpdateEvents(PostUpdateContext ctx) {
+    for (ProjectChangeKey key : attentionSetUpdates.keySet()) {
+      ChangeData change = ctx.getChangeData(key.projectName(), key.changeId());
+      AccountState account = ctx.getAccount();
+      for (AttentionSetUpdate update : attentionSetUpdates.get(key)) {
+        attentionSetObserver.fire(change, account, update, ctx.getWhen());
+      }
+    }
+  }
+
   private class ChangesHandle implements AutoCloseable {
     private final NoteDbUpdateManager manager;
     private final boolean dryrun;
@@ -613,14 +647,26 @@
     void execute() throws IOException {
       BatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
       BatchUpdate.this.executed = manager.isExecuted();
+      BatchUpdate.this.attentionSetUpdates = manager.attentionSetUpdates();
     }
 
-    List<ListenableFuture<ChangeData>> startIndexFutures() {
+    boolean requiresReindex() {
+      // We do not need to reindex changes if there are no ref updates, or if updated refs
+      // are all draft comment refs (since draft fields are not stored in the change index).
+      BatchRefUpdate bru = BatchUpdate.this.batchRefUpdate;
+      return !(bru == null
+          || bru.getCommands().isEmpty()
+          || bru.getCommands().stream()
+              .allMatch(cmd -> RefNames.isRefsDraftsComments(cmd.getRefName())));
+    }
+
+    ImmutableList<ListenableFuture<ChangeData>> startIndexFutures() {
       if (dryrun) {
         return ImmutableList.of();
       }
       logDebug("Reindexing %d changes", results.size());
-      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>(results.size());
+      ImmutableList.Builder<ListenableFuture<ChangeData>> indexFutures =
+          ImmutableList.builderWithExpectedSize(results.size());
       for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
         Change.Id id = e.getKey();
         switch (e.getValue()) {
@@ -628,7 +674,7 @@
             indexFutures.add(indexer.indexAsync(project, id));
             break;
           case DELETED:
-            indexFutures.add(indexer.deleteAsync(id));
+            indexFutures.add(indexer.deleteAsync(project, id));
             break;
           case SKIPPED:
             break;
@@ -636,7 +682,7 @@
             throw new IllegalStateException("unexpected result: " + e.getValue());
         }
       }
-      return indexFutures;
+      return indexFutures.build();
     }
   }
 
@@ -659,7 +705,7 @@
                     repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
             dryrun);
     if (user.isIdentifiedUser()) {
-      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, zoneId));
     }
     handle.manager.setRefLogMessage(refLogMessage);
     handle.manager.setPushCertificate(pushCert);
@@ -729,6 +775,10 @@
         op.postUpdate(ctx);
       }
     }
+    try (TraceContext.TraceTimer ignored =
+        TraceContext.newTimer("fireAttentionSetUpdates#postUpdate", Metadata.empty())) {
+      fireAttentionSetUpdateEvents(ctx);
+    }
   }
 
   private static void logDebug(String msg) {
@@ -736,7 +786,7 @@
     // expensive/complicated requests like MergeOp. Doing it every time would be
     // noisy.
     if (RequestId.isSet()) {
-      logger.atFine().log(msg);
+      logger.atFine().log("%s", msg);
     }
   }
 
diff --git a/java/com/google/gerrit/server/update/ChainedReceiveCommands.java b/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
index 99c72f2..5ff8d33 100644
--- a/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
+++ b/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
@@ -39,13 +39,19 @@
 public class ChainedReceiveCommands implements RefCache {
   private final Map<String, ReceiveCommand> commands = new LinkedHashMap<>();
   private final RepoRefCache refCache;
+  private final boolean closeRefCache;
 
   public ChainedReceiveCommands(Repository repo) {
-    this(new RepoRefCache(repo));
+    this(new RepoRefCache(repo), true);
   }
 
   public ChainedReceiveCommands(RepoRefCache refCache) {
+    this(refCache, false);
+  }
+
+  private ChainedReceiveCommands(RepoRefCache refCache, boolean closeRefCache) {
     this.refCache = requireNonNull(refCache);
+    this.closeRefCache = closeRefCache;
   }
 
   public RepoRefCache getRepoRefCache() {
@@ -122,4 +128,11 @@
   public Map<String, ReceiveCommand> getCommands() {
     return Collections.unmodifiableMap(commands);
   }
+
+  @Override
+  public void close() {
+    if (closeRefCache) {
+      refCache.close();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
index 9947168..aa41d90 100644
--- a/java/com/google/gerrit/server/update/Context.java
+++ b/java/com/google/gerrit/server/update/Context.java
@@ -24,8 +24,9 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
 import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.TimeZone;
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -66,16 +67,16 @@
    *
    * @return timestamp.
    */
-  Timestamp getWhen();
+  Instant getWhen();
 
   /**
-   * Get the time zone in which this update takes place.
+   * Get the time zone ID in which this update takes place.
    *
-   * <p>In the current implementation, this is always the time zone of the server.
+   * <p>In the current implementation, this is always the time zone ID of the server.
    *
-   * @return time zone.
+   * @return zone ID.
    */
-  TimeZone getTimeZone();
+  ZoneId getZoneId();
 
   /**
    * Get the user performing the update.
@@ -134,4 +135,33 @@
   default Account.Id getAccountId() {
     return getIdentifiedUser().getAccountId();
   }
+
+  /**
+   * Creates a new {@link PersonIdent} with {@link #getWhen()} as timestamp.
+   *
+   * @param personIdent {@link PersonIdent} to be copied
+   * @return copied {@link PersonIdent} with {@link #getWhen()} as timestamp
+   */
+  default PersonIdent newPersonIdent(PersonIdent personIdent) {
+    return new PersonIdent(personIdent, getWhen().toEpochMilli(), personIdent.getTimeZoneOffset());
+  }
+
+  /**
+   * Creates a committer {@link PersonIdent} for {@link #getIdentifiedUser()}.
+   *
+   * @return the created committer {@link PersonIdent}
+   */
+  default PersonIdent newCommitterIdent() {
+    return newCommitterIdent(getIdentifiedUser());
+  }
+
+  /**
+   * Creates a committer {@link PersonIdent} for the given user.
+   *
+   * @param user user for which a committer {@link PersonIdent} should be created
+   * @return the created committer {@link PersonIdent}
+   */
+  default PersonIdent newCommitterIdent(IdentifiedUser user) {
+    return user.newCommitterIdent(getWhen(), getZoneId());
+  }
 }
diff --git a/java/com/google/gerrit/server/update/PostUpdateContext.java b/java/com/google/gerrit/server/update/PostUpdateContext.java
index d4d1f62..25af264 100644
--- a/java/com/google/gerrit/server/update/PostUpdateContext.java
+++ b/java/com/google/gerrit/server/update/PostUpdateContext.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.update;
 
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 
@@ -27,9 +28,13 @@
    * an update or because this method has been invoked before, the cached change data instance is
    * returned.
    *
-   * @param change the change for which the change data should be returned
+   * @param changeId the ID of the change for which the change data should be returned
    */
-  ChangeData getChangeData(Change change);
+  ChangeData getChangeData(Project.NameKey projectName, Change.Id changeId);
+
+  default ChangeData getChangeData(Change change) {
+    return getChangeData(change.getProject(), change.getId());
+  }
 
   default ChangeData getChangeData(ChangeNotes changeNotes) {
     return getChangeData(changeNotes.getChange());
diff --git a/java/com/google/gerrit/server/util/AccountTemplateUtil.java b/java/com/google/gerrit/server/util/AccountTemplateUtil.java
index c552ce8..93d7086 100644
--- a/java/com/google/gerrit/server/util/AccountTemplateUtil.java
+++ b/java/com/google/gerrit/server/util/AccountTemplateUtil.java
@@ -24,9 +24,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.HashSet;
 import java.util.Optional;
-import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -62,7 +60,7 @@
       return ImmutableSet.of();
     }
     Matcher matcher = ACCOUNT_TEMPLATE_PATTERN.matcher(textTemplate);
-    Set<Account.Id> accountsInTemplate = new HashSet<>();
+    ImmutableSet.Builder<Account.Id> accountsInTemplate = ImmutableSet.builder();
     while (matcher.find()) {
       String accountId = matcher.group(1);
       Optional<Account.Id> parsedAccountId = Account.Id.tryParse(accountId);
@@ -72,7 +70,7 @@
         logger.atFine().log("Failed to parse accountId from template %s", matcher.group());
       }
     }
-    return ImmutableSet.copyOf(accountsInTemplate);
+    return accountsInTemplate.build();
   }
 
   public static String getAccountTemplate(Account.Id accountId) {
@@ -82,7 +80,7 @@
   /** Builds user-readable text from text, that might contain {@link #ACCOUNT_TEMPLATE}. */
   public String replaceTemplates(String messageTemplate) {
     Matcher matcher = ACCOUNT_TEMPLATE_PATTERN.matcher(messageTemplate);
-    StringBuffer out = new StringBuffer();
+    StringBuilder out = new StringBuilder();
     while (matcher.find()) {
       String accountId = matcher.group(1);
       String unrecognizedAccount = "Unrecognized Gerrit Account " + accountId;
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 48ddd31..1b36139 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -18,19 +18,24 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.AttentionSetSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 
-public class AttentionSetEmail implements Runnable, RequestContext {
+public class AttentionSetEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -43,7 +48,6 @@
      * @param ctx context for sending the email.
      * @param change the change that the user was added/removed in.
      * @param reason reason for adding/removing the user.
-     * @param messageId messageId for tracking the email.
      * @param attentionUserId the user added/removed.
      */
     AttentionSetEmail create(
@@ -51,70 +55,117 @@
         Context ctx,
         Change change,
         String reason,
-        MessageIdGenerator.MessageId messageId,
         Account.Id attentionUserId);
   }
 
-  private ExecutorService sendEmailsExecutor;
-  private AccountTemplateUtil accountTemplateUtil;
-  private AttentionSetSender sender;
-  private Context ctx;
-  private Change change;
-  private String reason;
-
-  private MessageIdGenerator.MessageId messageId;
-  private Account.Id attentionUserId;
+  private final ExecutorService sendEmailsExecutor;
+  private final AsyncSender asyncSender;
 
   @Inject
   AttentionSetEmail(
       @SendEmailExecutor ExecutorService executor,
+      ThreadLocalRequestContext requestContext,
+      MessageIdGenerator messageIdGenerator,
       AccountTemplateUtil accountTemplateUtil,
       @Assisted AttentionSetSender sender,
       @Assisted Context ctx,
       @Assisted Change change,
       @Assisted String reason,
-      @Assisted MessageIdGenerator.MessageId messageId,
       @Assisted Account.Id attentionUserId) {
     this.sendEmailsExecutor = executor;
-    this.accountTemplateUtil = accountTemplateUtil;
-    this.sender = sender;
-    this.ctx = ctx;
-    this.change = change;
-    this.reason = reason;
-    this.messageId = messageId;
-    this.attentionUserId = attentionUserId;
+
+    MessageId messageId;
+    try {
+      messageId =
+          messageIdGenerator.fromChangeUpdateAndReason(
+              ctx.getRepoView(), change.currentPatchSetId(), "AttentionSetEmail");
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    this.asyncSender =
+        new AsyncSender(
+            requestContext,
+            ctx.getIdentifiedUser(),
+            sender,
+            messageId,
+            ctx.getNotify(change.getId()),
+            attentionUserId,
+            accountTemplateUtil.replaceTemplates(reason),
+            change.getId());
   }
 
   public void sendAsync() {
     @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+    Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender);
   }
 
-  @Override
-  public void run() {
-    try {
-      AccountState accountState =
-          ctx.getUser().isIdentifiedUser() ? ctx.getUser().asIdentifiedUser().state() : null;
-      if (accountState != null) {
-        sender.setFrom(accountState.account().id());
-      }
-      sender.setNotify(ctx.getNotify(change.getId()));
-      sender.setAttentionSetUser(attentionUserId);
-      sender.setReason(accountTemplateUtil.replaceTemplates(reason));
-      sender.setMessageId(messageId);
-      sender.send();
-    } catch (Exception e) {
-      logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+  /**
+   * {@link Runnable} that sends the email asynchonously.
+   *
+   * <p>Only pass objects into this class that are thread-safe (e.g. immutable) so that they can be
+   * safely accessed from the background thread.
+   */
+  private static class AsyncSender implements Runnable, RequestContext {
+    private final ThreadLocalRequestContext requestContext;
+    private final IdentifiedUser user;
+    private final AttentionSetSender sender;
+    private final MessageIdGenerator.MessageId messageId;
+    private final NotifyResolver.Result notify;
+    private final Account.Id attentionUserId;
+    private final String reason;
+    private final Change.Id changeId;
+
+    AsyncSender(
+        ThreadLocalRequestContext requestContext,
+        IdentifiedUser user,
+        AttentionSetSender sender,
+        MessageIdGenerator.MessageId messageId,
+        NotifyResolver.Result notify,
+        Account.Id attentionUserId,
+        String reason,
+        Change.Id changeId) {
+      this.requestContext = requestContext;
+      this.user = user;
+      this.sender = sender;
+      this.messageId = messageId;
+      this.notify = notify;
+      this.attentionUserId = attentionUserId;
+      this.reason = reason;
+      this.changeId = changeId;
     }
-  }
 
-  @Override
-  public String toString() {
-    return "send-email comments";
-  }
+    @Override
+    public void run() {
+      RequestContext old = requestContext.setContext(this);
+      try {
+        Optional<Account.Id> accountId =
+            user.isIdentifiedUser()
+                ? Optional.of(user.asIdentifiedUser().getAccountId())
+                : Optional.empty();
+        if (accountId.isPresent()) {
+          sender.setFrom(accountId.get());
+        }
+        sender.setNotify(notify);
+        sender.setAttentionSetUser(attentionUserId);
+        sender.setReason(reason);
+        sender.setMessageId(messageId);
+        sender.send();
+      } catch (Exception e) {
+        logger.atSevere().withCause(e).log("Cannot email update for change %s", changeId);
+      } finally {
+        requestContext.setContext(old);
+      }
+    }
 
-  @Override
-  public CurrentUser getUser() {
-    return ctx.getUser();
+    @Override
+    public String toString() {
+      return "send-email attention-set-update";
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index 9238b44..26c8f47 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -43,6 +42,14 @@
         .collect(ImmutableSet.toImmutableSet());
   }
 
+  /** Returns only updates where the user was removed. */
+  public static ImmutableSet<AttentionSetUpdate> removalsOnly(
+      Collection<AttentionSetUpdate> updates) {
+    return updates.stream()
+        .filter(u -> u.operation() == Operation.REMOVE)
+        .collect(ImmutableSet.toImmutableSet());
+  }
+
   /**
    * Validates the input for AttentionSetInput. This must be called for all inputs that relate to
    * adding or removing attention set entries, except for {@link
@@ -115,7 +122,7 @@
             : null;
     return new AttentionSetInfo(
         accountLoader.get(attentionSetUpdate.account()),
-        Timestamp.from(attentionSetUpdate.timestamp()),
+        attentionSetUpdate.timestamp(),
         attentionSetUpdate.reason(),
         reasonAccount);
   }
diff --git a/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java b/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
deleted file mode 100644
index 99dd8bf..0000000
--- a/java/com/google/gerrit/server/util/GuiceRequestScopePropagator.java
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.server.RemotePeer;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.inject.Inject;
-import com.google.inject.Key;
-import com.google.inject.Provider;
-import com.google.inject.servlet.ServletScopes;
-import com.google.inject.util.Providers;
-import com.google.inject.util.Types;
-import java.lang.reflect.ParameterizedType;
-import java.lang.reflect.Type;
-import java.net.SocketAddress;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.Callable;
-
-/** Propagator for Guice's built-in servlet scope. */
-public class GuiceRequestScopePropagator extends RequestScopePropagator {
-
-  private final String url;
-  private final SocketAddress peer;
-
-  @Inject
-  GuiceRequestScopePropagator(
-      @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      @RemotePeer Provider<SocketAddress> remotePeerProvider,
-      ThreadLocalRequestContext local) {
-    super(ServletScopes.REQUEST, local);
-    this.url = urlProvider != null ? urlProvider.get() : null;
-    this.peer = remotePeerProvider.get();
-  }
-
-  /** @see RequestScopePropagator#wrap(Callable) */
-  // ServletScopes#continueRequest is deprecated, but it's not obvious their
-  // recommended replacement is an appropriate drop-in solution; see
-  // https://gerrit-review.googlesource.com/83971
-  @SuppressWarnings("deprecation")
-  @Override
-  protected <T> Callable<T> wrapImpl(Callable<T> callable) {
-    Map<Key<?>, Object> seedMap = new HashMap<>();
-
-    // Request scopes appear to use specific keys in their map, instead of only
-    // providers. Add bindings for both the key to the instance directly and the
-    // provider to the instance to be safe.
-    seedMap.put(Key.get(typeOfProvider(String.class), CanonicalWebUrl.class), Providers.of(url));
-    seedMap.put(Key.get(String.class, CanonicalWebUrl.class), url);
-
-    seedMap.put(Key.get(typeOfProvider(SocketAddress.class), RemotePeer.class), Providers.of(peer));
-    seedMap.put(Key.get(SocketAddress.class, RemotePeer.class), peer);
-
-    return ServletScopes.continueRequest(callable, seedMap);
-  }
-
-  private ParameterizedType typeOfProvider(Type type) {
-    return Types.newParameterizedType(Provider.class, type);
-  }
-}
diff --git a/java/com/google/gerrit/server/util/IdGenerator.java b/java/com/google/gerrit/server/util/IdGenerator.java
index d4c2dc4..1534ef3 100644
--- a/java/com/google/gerrit/server/util/IdGenerator.java
+++ b/java/com/google/gerrit/server/util/IdGenerator.java
@@ -62,7 +62,7 @@
   private static short hi16(int in) {
     return (short)
         ( //
-        ((in >>> 24 & 0xff))
+        (in >>> 24 & 0xff)
             | //
             ((in >>> 16 & 0xff) << 8) //
         );
@@ -71,7 +71,7 @@
   private static short lo16(int in) {
     return (short)
         ( //
-        ((in >>> 8 & 0xff))
+        (in >>> 8 & 0xff)
             | //
             ((in & 0xff) << 8) //
         );
diff --git a/java/com/google/gerrit/server/util/LabelVote.java b/java/com/google/gerrit/server/util/LabelVote.java
index 038fe2c..fbcf3ce 100644
--- a/java/com/google/gerrit/server/util/LabelVote.java
+++ b/java/com/google/gerrit/server/util/LabelVote.java
@@ -19,6 +19,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
 
 /** A single vote on a label, consisting of a label name and a value. */
 @AutoValue
@@ -68,6 +69,10 @@
     return new AutoValue_LabelVote(LabelType.checkNameInternal(label), value);
   }
 
+  public static LabelVote createFrom(PatchSetApproval psa) {
+    return create(psa.label(), psa.value());
+  }
+
   public abstract String label();
 
   public abstract short value();
diff --git a/java/com/google/gerrit/server/util/MagicBranch.java b/java/com/google/gerrit/server/util/MagicBranch.java
index 924c288..a5ce108 100644
--- a/java/com/google/gerrit/server/util/MagicBranch.java
+++ b/java/com/google/gerrit/server/util/MagicBranch.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.util;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.entities.Project;
 import java.io.IOException;
@@ -38,6 +39,7 @@
   }
 
   /** Returns the ref name prefix for a magic branch, {@code null} if the branch is not magic */
+  @Nullable
   public static String getMagicRefNamePrefix(String refName) {
     if (refName.startsWith(NEW_CHANGE)) {
       return NEW_CHANGE;
diff --git a/java/com/google/gerrit/server/util/MostSpecificComparator.java b/java/com/google/gerrit/server/util/MostSpecificComparator.java
index ac33902..ef5e67f 100644
--- a/java/com/google/gerrit/server/util/MostSpecificComparator.java
+++ b/java/com/google/gerrit/server/util/MostSpecificComparator.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.server.project.RefPattern;
 import java.util.Comparator;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.text.similarity.LevenshteinDistance;
 
 /**
  * Order the Ref Pattern by the most specific. This sort is done by:
@@ -89,7 +89,7 @@
     } else {
       return Math.max(pattern.length(), refName.length());
     }
-    return StringUtils.getLevenshteinDistance(example, refName);
+    return LevenshteinDistance.getDefaultInstance().apply(example, refName);
   }
 
   private boolean finite(String pattern) {
diff --git a/java/com/google/gerrit/server/util/RequestScopePropagator.java b/java/com/google/gerrit/server/util/RequestScopePropagator.java
index 10c46fc..2f03b07 100644
--- a/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -76,7 +76,7 @@
   public final <T> Callable<T> wrap(Callable<T> callable) {
     final RequestContext callerContext = requireNonNull(local.getContext());
     final Callable<T> wrapped = wrapImpl(context(callerContext, cleanup(callable)));
-    return new Callable<T>() {
+    return new Callable<>() {
       @Override
       public T call() throws Exception {
         if (callerContext == local.getContext()) {
@@ -169,7 +169,7 @@
 
   protected <T> Callable<T> context(RequestContext context, Callable<T> callable) {
     return () -> {
-      RequestContext old = local.setContext(context::getUser);
+      RequestContext old = local.setContext(context);
       try {
         return callable.call();
       } finally {
diff --git a/java/com/google/gerrit/server/util/TreeFormatter.java b/java/com/google/gerrit/server/util/TreeFormatter.java
index 49d4a55..5a898d5 100644
--- a/java/com/google/gerrit/server/util/TreeFormatter.java
+++ b/java/com/google/gerrit/server/util/TreeFormatter.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.util;
 
 import java.io.PrintWriter;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 
 public class TreeFormatter {
 
@@ -24,7 +24,7 @@
 
     boolean isVisible();
 
-    SortedSet<? extends TreeNode> getChildren();
+    NavigableSet<? extends TreeNode> getChildren();
   }
 
   public static final String NOT_VISIBLE_NODE = "(x)";
@@ -40,7 +40,7 @@
     this.stdout = stdout;
   }
 
-  public void printTree(SortedSet<? extends TreeNode> rootNodes) {
+  public void printTree(NavigableSet<? extends TreeNode> rootNodes) {
     if (rootNodes.isEmpty()) {
       return;
     }
@@ -66,7 +66,7 @@
 
   private void printTree(TreeNode node, int level, boolean isLast) {
     printNode(node, level, isLast);
-    final SortedSet<? extends TreeNode> childNodes = node.getChildren();
+    final NavigableSet<? extends TreeNode> childNodes = node.getChildren();
     int i = 0;
     final int size = childNodes.size();
     for (TreeNode childNode : childNodes) {
diff --git a/java/com/google/gerrit/server/util/git/BUILD b/java/com/google/gerrit/server/util/git/BUILD
index 4f4ba83..83a230d 100644
--- a/java/com/google/gerrit/server/util/git/BUILD
+++ b/java/com/google/gerrit/server/util/git/BUILD
@@ -5,7 +5,9 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//lib:jgit",
+        "//lib/flogger:api",
     ],
 )
diff --git a/java/com/google/gerrit/server/util/git/CloseablePool.java b/java/com/google/gerrit/server/util/git/CloseablePool.java
new file mode 100644
index 0000000..442bd09
--- /dev/null
+++ b/java/com/google/gerrit/server/util/git/CloseablePool.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.util.git;
+
+import com.google.common.flogger.FluentLogger;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * Pool to manage resources that need to be closed but to whom we might lose the reference to or
+ * where closing resources individually is not always possible.
+ *
+ * <p>This pool can be used when we want to reuse closable resources in a multithreaded context.
+ * Example:
+ *
+ * <pre>{@code
+ * try (CloseablePool<T> pool = new CloseablePool(() -> new T())) {
+ *   for (int i = 0; i < 100; i++) {
+ *     executor.submit(() -> {
+ *       try (CloseablePool<T>.Handle handle = pool.get()) {
+ *         // Do work that might potentially take longer than the timeout.
+ *         handle.get(); // pooled instance to be used
+ *       }
+ *     }).get(1000, MILLISECONDS);
+ *   }
+ * }
+ * }</pre>
+ */
+public class CloseablePool<T extends AutoCloseable> implements AutoCloseable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Supplier<T> tCreator;
+  private List<T> ts;
+
+  /**
+   * Instantiate a new pool. The {@link Supplier} must be capable of creating a new instance on
+   * every call.
+   */
+  public CloseablePool(Supplier<T> tCreator) {
+    this.ts = new ArrayList<>();
+    this.tCreator = tCreator;
+  }
+
+  /**
+   * Get a shared instance or create a new instance. Close the returned handle to return it to the
+   * pool.
+   */
+  public synchronized Handle get() {
+    if (ts.isEmpty()) {
+      return new Handle(tCreator.get());
+    }
+    return new Handle(ts.remove(ts.size() - 1));
+  }
+
+  private synchronized boolean discard(T t) {
+    if (ts != null) {
+      ts.add(t);
+      return true;
+    }
+    return false;
+  }
+
+  @Override
+  public synchronized void close() {
+    for (T t : ts)
+      try {
+        t.close();
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log(
+            "Failed to close resource %s in CloseablePool %s", t, this);
+      }
+    ts = null;
+  }
+
+  /**
+   * Wrapper around an {@link AutoCloseable}. Will try to return the resource to the pool and close
+   * it in case the pool was already closed.
+   */
+  public class Handle implements AutoCloseable {
+    private final T t;
+
+    private Handle(T t) {
+      this.t = t;
+    }
+
+    /** Returns the managed instance. */
+    public T get() {
+      return t;
+    }
+
+    @Override
+    public void close() {
+      if (!discard(t)) {
+        try {
+          t.close();
+        } catch (Exception e) {
+          logger.atWarning().withCause(e).log(
+              "Failed to close resource %s in CloseablePool %s", this, CloseablePool.this);
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
index 97132a3..201a9b7 100644
--- a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
+++ b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.util.git;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmoduleSubscription;
@@ -65,6 +66,7 @@
     return parsedSubscriptions;
   }
 
+  @Nullable
   private SubmoduleSubscription parse(String id) {
     final String url = config.getString("submodule", id, "url");
     final String path = config.getString("submodule", id, "path");
diff --git a/java/com/google/gerrit/server/util/time/BUILD b/java/com/google/gerrit/server/util/time/BUILD
index b1126f0..f3e0091 100644
--- a/java/com/google/gerrit/server/util/time/BUILD
+++ b/java/com/google/gerrit/server/util/time/BUILD
@@ -5,7 +5,6 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
-        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/server/util/git",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/server/util/time/TimeUtil.java b/java/com/google/gerrit/server/util/time/TimeUtil.java
index 54ef305..f89324b 100644
--- a/java/com/google/gerrit/server/util/time/TimeUtil.java
+++ b/java/com/google/gerrit/server/util/time/TimeUtil.java
@@ -15,10 +15,7 @@
 package com.google.gerrit.server.util.time;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.server.util.git.DelegateSystemReader;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
@@ -44,23 +41,8 @@
     return Instant.ofEpochMilli(nowMs());
   }
 
-  public static Timestamp nowTs() {
-    return new Timestamp(nowMs());
-  }
-
-  /**
-   * Returns the magic timestamp representing no specific time.
-   *
-   * <p>This "null object" is helpful in contexts where using {@code null} directly is not possible.
-   */
-  @UsedAt(Project.PLUGIN_CHECKS)
-  public static Timestamp never() {
-    // Always create a new object as timestamps are mutable.
-    return new Timestamp(0);
-  }
-
-  public static Timestamp truncateToSecond(Timestamp t) {
-    return new Timestamp((t.getTime() / 1000) * 1000);
+  public static Instant truncateToSecond(Instant t) {
+    return Instant.ofEpochMilli(t.getEpochSecond() * 1000);
   }
 
   @VisibleForTesting
diff --git a/java/com/google/gerrit/sshd/AliasCommand.java b/java/com/google/gerrit/sshd/AliasCommand.java
index bf0dd91..17ea463 100644
--- a/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/java/com/google/gerrit/sshd/AliasCommand.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.io.IOException;
-import java.util.LinkedList;
+import java.util.ArrayDeque;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
@@ -120,8 +120,8 @@
     }
   }
 
-  private static LinkedList<String> chain(CommandName command) {
-    LinkedList<String> chain = new LinkedList<>();
+  private static Iterable<String> chain(CommandName command) {
+    ArrayDeque<String> chain = new ArrayDeque<>();
     while (command != null) {
       chain.addFirst(command.value());
       command = Commands.parentOf(command);
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index f3bd5e1..af7078d 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -4,7 +4,6 @@
     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",
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index f1be04e..c1c58c8 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -104,7 +104,7 @@
 
   @Inject protected Injector injector;
 
-  @Inject protected DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
+  @Inject protected DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   /** The task, as scheduled on a worker thread. */
   private final AtomicReference<Future<?>> task;
@@ -398,7 +398,7 @@
       isZeroLength = Arrays.stream(stackTrace).anyMatch(s -> s.toString().contains(zeroLength));
     }
     if (!isZeroLength) {
-      logger.atSevere().withCause(e).log(message.toString());
+      logger.atSevere().withCause(e).log("%s", message);
     }
   }
 
diff --git a/java/com/google/gerrit/sshd/ChannelIdTrackingUnknownChannelReferenceHandler.java b/java/com/google/gerrit/sshd/ChannelIdTrackingUnknownChannelReferenceHandler.java
index f8ab90e..b9ca79c 100644
--- a/java/com/google/gerrit/sshd/ChannelIdTrackingUnknownChannelReferenceHandler.java
+++ b/java/com/google/gerrit/sshd/ChannelIdTrackingUnknownChannelReferenceHandler.java
@@ -45,7 +45,7 @@
 public class ChannelIdTrackingUnknownChannelReferenceHandler
     extends DefaultUnknownChannelReferenceHandler implements ChannelListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  public static final AttributeKey<Integer> LAST_CHANNEL_ID_KEY = new AttributeKey<>();
+  public static final AttributeKey<Long> LAST_CHANNEL_ID_KEY = new AttributeKey<>();
 
   public static final ChannelIdTrackingUnknownChannelReferenceHandler TRACKER =
       new ChannelIdTrackingUnknownChannelReferenceHandler();
@@ -56,9 +56,9 @@
 
   @Override
   public void channelInitialized(Channel channel) {
-    int channelId = channel.getId();
+    long channelId = channel.getChannelId();
     Session session = channel.getSession();
-    Integer lastTracked = session.setAttribute(LAST_CHANNEL_ID_KEY, channelId);
+    Long lastTracked = session.setAttribute(LAST_CHANNEL_ID_KEY, channelId);
     logger.atFine().log(
         "channelInitialized(%s) updated last tracked channel ID %s => %s",
         channel, lastTracked, channelId);
@@ -66,9 +66,9 @@
 
   @Override
   public Channel handleUnknownChannelCommand(
-      ConnectionService service, byte cmd, int channelId, Buffer buffer) throws IOException {
+      ConnectionService service, byte cmd, long channelId, Buffer buffer) throws IOException {
     Session session = service.getSession();
-    Integer lastTracked = session.getAttribute(LAST_CHANNEL_ID_KEY);
+    Long lastTracked = session.getAttribute(LAST_CHANNEL_ID_KEY);
     if ((lastTracked != null) && (channelId <= lastTracked.intValue())) {
       // Use TRACE level in order to avoid messages flooding
       logger.atFinest().log(
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 38ac26d..92de012 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,6 +185,12 @@
           cmd.setExitCallback(
               new ExitCallback() {
                 @Override
+                public void onExit(int rc, String exitMessage, boolean closeImmediately) {
+                  exit.onExit(translateExit(rc), exitMessage, closeImmediately);
+                  log(rc, exitMessage);
+                }
+
+                @Override
                 public void onExit(int rc, String exitMessage) {
                   exit.onExit(translateExit(rc), exitMessage);
                   log(rc, exitMessage);
diff --git a/java/com/google/gerrit/sshd/CommandModule.java b/java/com/google/gerrit/sshd/CommandModule.java
index ac07056..4242c71 100644
--- a/java/com/google/gerrit/sshd/CommandModule.java
+++ b/java/com/google/gerrit/sshd/CommandModule.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.account.AccountAttributeLoader;
 import com.google.inject.binder.LinkedBindingBuilder;
 import org.apache.sshd.server.command.Command;
 
@@ -29,6 +30,7 @@
    * @return a binding that must be bound to a non-singleton provider for a {@link Command} object.
    */
   protected LinkedBindingBuilder<Command> command(String name) {
+    factory(AccountAttributeLoader.Factory.class);
     return bind(Commands.key(name));
   }
 
diff --git a/java/com/google/gerrit/sshd/Commands.java b/java/com/google/gerrit/sshd/Commands.java
index b6d3401..5d641a0 100644
--- a/java/com/google/gerrit/sshd/Commands.java
+++ b/java/com/google/gerrit/sshd/Commands.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.auto.value.AutoAnnotation;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Key;
 import java.lang.annotation.Annotation;
 import org.apache.sshd.server.command.Command;
@@ -78,6 +79,7 @@
     return false;
   }
 
+  @Nullable
   static CommandName parentOf(CommandName name) {
     if (name instanceof NestedCommandNameImpl) {
       return ((NestedCommandNameImpl) name).parent;
diff --git a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index 6997d96..401d31e 100644
--- a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -22,6 +22,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.FileUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.account.AccountSshKey;
@@ -169,6 +170,7 @@
     return p.keys;
   }
 
+  @Nullable
   private SshKeyCacheEntry find(Iterable<SshKeyCacheEntry> keyList, PublicKey suppliedKey) {
     for (SshKeyCacheEntry k : keyList) {
       if (k.match(suppliedKey)) {
diff --git a/java/com/google/gerrit/sshd/DispatchCommandProvider.java b/java/com/google/gerrit/sshd/DispatchCommandProvider.java
index 2a65ed0..acf2df9 100644
--- a/java/com/google/gerrit/sshd/DispatchCommandProvider.java
+++ b/java/com/google/gerrit/sshd/DispatchCommandProvider.java
@@ -91,7 +91,7 @@
     return m;
   }
 
-  private static final TypeLiteral<Command> type = new TypeLiteral<Command>() {};
+  private static final TypeLiteral<Command> type = new TypeLiteral<>() {};
 
   private List<Binding<Command>> allCommands() {
     return injector.findBindingsByType(type);
diff --git a/java/com/google/gerrit/sshd/NoShell.java b/java/com/google/gerrit/sshd/NoShell.java
index e3f654b..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;
 
diff --git a/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
index 5b6d8f9..7adcd24 100644
--- a/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
+++ b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.Export;
 import com.google.gerrit.server.plugins.InvalidPluginException;
 import com.google.gerrit.server.plugins.ModuleGenerator;
@@ -84,6 +85,7 @@
     listeners.put(tl, clazz);
   }
 
+  @Nullable
   @Override
   public Module create() throws InvalidPluginException {
     checkState(command != null, "pluginName must be provided");
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index 553287ec..cc35a32 100644
--- a/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/java/com/google/gerrit/sshd/SshDaemon.java
@@ -85,6 +85,7 @@
 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.kex.extension.DefaultServerKexExtensionHandler;
 import org.apache.sshd.common.keyprovider.KeyPairProvider;
 import org.apache.sshd.common.mac.Mac;
 import org.apache.sshd.common.random.Random;
@@ -480,6 +481,7 @@
   }
 
   private void initKeyExchanges(Config cfg) {
+    setKexExtensionHandler(DefaultServerKexExtensionHandler.INSTANCE);
     List<KeyExchangeFactory> a =
         NamedFactory.setUpTransformedFactories(
             true,
@@ -641,7 +643,7 @@
           msg.append(avail[i].getName());
         }
         msg.append(" is supported");
-        logger.atSevere().log(msg.toString());
+        logger.atSevere().log("%s", msg);
       } else if (add) {
         if (!def.contains(n)) {
           def.add(n);
diff --git a/java/com/google/gerrit/sshd/SshLogJsonLayout.java b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
index fca0a5a..321cf56 100644
--- a/java/com/google/gerrit/sshd/SshLogJsonLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogJsonLayout.java
@@ -26,11 +26,14 @@
 import static com.google.gerrit.sshd.SshLog.P_USER_NAME;
 import static com.google.gerrit.sshd.SshLog.P_WAIT;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.util.logging.JsonLayout;
 import com.google.gerrit.util.logging.JsonLogEntry;
+import java.util.List;
 import org.apache.log4j.spi.LoggingEvent;
 
 public class SshLogJsonLayout extends JsonLayout {
+  private static final Splitter SPLITTER = Splitter.on(" ");
 
   @Override
   public JsonLogEntry toJsonLogEntry(LoggingEvent event) {
@@ -81,18 +84,18 @@
 
       String metricString = getMdcString(event, P_MESSAGE);
       if (metricString != null && !metricString.isEmpty()) {
-        String[] ssh_metrics = metricString.split(" ");
-        this.timeNegotiating = ssh_metrics[0];
-        this.timeSearchReuse = ssh_metrics[1];
-        this.timeSearchSizes = ssh_metrics[2];
-        this.timeCounting = ssh_metrics[3];
-        this.timeCompressing = ssh_metrics[4];
-        this.timeWriting = ssh_metrics[5];
-        this.timeTotal = ssh_metrics[6];
-        this.bitmapIndexMisses = ssh_metrics[7];
-        this.deltasTotal = ssh_metrics[8];
-        this.objectsTotal = ssh_metrics[9];
-        this.bytesTotal = ssh_metrics[10];
+        List<String> ssh_metrics = SPLITTER.splitToList(metricString);
+        this.timeNegotiating = ssh_metrics.get(0);
+        this.timeSearchReuse = ssh_metrics.get(1);
+        this.timeSearchSizes = ssh_metrics.get(2);
+        this.timeCounting = ssh_metrics.get(3);
+        this.timeCompressing = ssh_metrics.get(4);
+        this.timeWriting = ssh_metrics.get(5);
+        this.timeTotal = ssh_metrics.get(6);
+        this.bitmapIndexMisses = ssh_metrics.get(7);
+        this.deltasTotal = ssh_metrics.get(8);
+        this.objectsTotal = ssh_metrics.get(9);
+        this.bytesTotal = ssh_metrics.get(10);
       }
     }
   }
diff --git a/java/com/google/gerrit/sshd/SshLogLayout.java b/java/com/google/gerrit/sshd/SshLogLayout.java
index a1f2c40..bb7edfa 100644
--- a/java/com/google/gerrit/sshd/SshLogLayout.java
+++ b/java/com/google/gerrit/sshd/SshLogLayout.java
@@ -32,7 +32,7 @@
 import org.eclipse.jgit.util.QuotedString;
 
 public final class SshLogLayout extends Layout {
-  protected final LogTimestampFormatter timestampFormatter;
+  private final LogTimestampFormatter timestampFormatter;
 
   public SshLogLayout() {
     timestampFormatter = new LogTimestampFormatter();
@@ -40,7 +40,7 @@
 
   @Override
   public String format(LoggingEvent event) {
-    final StringBuffer buf = new StringBuffer(128);
+    final StringBuilder buf = new StringBuilder(128);
 
     buf.append('[');
     buf.append(timestampFormatter.format(event.getTimeStamp()));
@@ -75,7 +75,7 @@
     return buf.toString();
   }
 
-  private void req(String key, StringBuffer buf, LoggingEvent event) {
+  private void req(String key, StringBuilder buf, LoggingEvent event) {
     Object val = event.getMDC(key);
     buf.append(' ');
     if (val != null) {
diff --git a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
index 6e8590c..8711fe6 100644
--- a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
+++ b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.plugins.Plugin;
@@ -57,6 +58,7 @@
     }
   }
 
+  @Nullable
   private Provider<Command> load(Plugin plugin) {
     if (plugin.getSshInjector() != null) {
       Key<Command> key = Commands.key(plugin.getName());
diff --git a/java/com/google/gerrit/sshd/SshScope.java b/java/com/google/gerrit/sshd/SshScope.java
index e9ed750..f19c395 100644
--- a/java/com/google/gerrit/sshd/SshScope.java
+++ b/java/com/google/gerrit/sshd/SshScope.java
@@ -53,6 +53,8 @@
     private volatile long startedMemory;
     private volatile long finishedMemory;
 
+    private IdentifiedUser identifiedUser;
+
     private Context(SshSession s, String c, long at) {
       session = s;
       commandLine = c;
@@ -125,8 +127,10 @@
     public CurrentUser getUser() {
       CurrentUser user = session.getUser();
       if (user != null && user.isIdentifiedUser()) {
-        IdentifiedUser identifiedUser = userFactory.create(user.getAccountId());
-        identifiedUser.setAccessPath(user.getAccessPath());
+        if (identifiedUser == null) {
+          identifiedUser = userFactory.create(user.getAccountId());
+          identifiedUser.setAccessPath(user.getAccessPath());
+        }
         return identifiedUser;
       }
       return user;
@@ -219,7 +223,7 @@
       new Scope() {
         @Override
         public <T> Provider<T> scope(Key<T> key, Provider<T> creator) {
-          return new Provider<T>() {
+          return new Provider<>() {
             @Override
             public T get() {
               return requireContext().get(key, creator);
diff --git a/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java b/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
index 1cdf923..de91b68 100644
--- a/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
+++ b/java/com/google/gerrit/sshd/SshSessionFactoryInitializer.java
@@ -14,9 +14,6 @@
 
 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;
@@ -24,13 +21,11 @@
 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);
-    }
+  public static void init() {
+    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/commands/CheckProjectAccessCommand.java b/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java
new file mode 100644
index 0000000..39f9ef2
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/CheckProjectAccessCommand.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+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.permissions.ProjectPermission;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.kohsuke.args4j.Option;
+
+@CommandMetaData(
+    name = "check-project-access",
+    description = "Check project readability for specified user(s)",
+    runsAt = MASTER_OR_SLAVE)
+public class CheckProjectAccessCommand extends SshCommand {
+  @Inject private AccountResolver accountResolver;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private Provider<CurrentUser> userProvider;
+
+  @Option(
+      name = "--project",
+      aliases = {"-p"},
+      metaVar = "PROJECT",
+      required = true,
+      usage = "project name to check")
+  private String projectName;
+
+  @Option(
+      name = "--user",
+      aliases = {"-u"},
+      metaVar = "USER",
+      required = true,
+      usage = "Account identifier used to find the user(s) for which to check access.")
+  private String userName;
+
+  @Override
+  protected void run() throws Failure, ConfigInvalidException, IOException {
+    PermissionBackend.WithUser userPermission = permissionBackend.user(userProvider.get());
+
+    boolean isAdmin = userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+    boolean canViewAccount = isAdmin || userPermission.testOrFalse(GlobalPermission.VIEW_ACCESS);
+
+    if (!user.hasSameAccountId(userProvider.get()) && !canViewAccount) {
+      throw die("This command requires 'view access' or 'administrate server' capabilities.");
+    }
+
+    try {
+      for (IdentifiedUser user : getUserList(userName)) {
+        stdout.println(
+            String.format(
+                "Username: '%s', Email: '%s', Full Name: '%s', Result: %b\n",
+                user.getLoggableName(),
+                user.getNameEmail()
+                    .substring(
+                        user.getNameEmail().indexOf("<") + 1, user.getNameEmail().indexOf(">")),
+                user.getName(),
+                permissionBackend
+                    .user(user)
+                    .project(Project.nameKey(projectName))
+                    .test(ProjectPermission.READ)));
+      }
+    } catch (ConfigInvalidException
+        | ResourceNotFoundException
+        | IllegalArgumentException
+        | PermissionBackendException e) {
+      throw die(e);
+    }
+  }
+
+  private Set<IdentifiedUser> getUserList(String userName)
+      throws ConfigInvalidException, IOException, ResourceNotFoundException {
+    return getIdList(userName).stream().map(userFactory::create).collect(Collectors.toSet());
+  }
+
+  private Set<Account.Id> getIdList(String userName)
+      throws ConfigInvalidException, IOException, ResourceNotFoundException {
+    Set<Account.Id> idList = accountResolver.resolve(userName).asIdSet();
+    if (idList.isEmpty()) {
+      throw new ResourceNotFoundException(
+          "No accounts found for your query: \""
+              + userName
+              + "\""
+              + " Tip: Try double-escaping spaces, for example: \"--user Last,\\\\ First\"");
+    }
+    return idList;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 4da55e2..2e29203 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -17,6 +17,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -87,6 +88,7 @@
     }
   }
 
+  @Nullable
   private String readSshKey() throws IOException {
     if (sshKey == null) {
       return null;
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index e7fe22f..42e7c0f 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -46,6 +46,7 @@
     command(gerrit).toProvider(new DispatchCommandProvider(gerrit));
     command(gerrit, AproposCommand.class);
     command(gerrit, BanCommitCommand.class);
+    command(gerrit, CheckProjectAccessCommand.class);
     command(gerrit, CloseConnection.class);
     command(gerrit, ConvertRefStorage.class);
     command(gerrit, FlushCaches.class);
diff --git a/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java b/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
new file mode 100644
index 0000000..aa147f0
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigrator;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@CommandMetaData(
+    name = "migrate-externalids-to-insensitive",
+    description = "Migrate external-ids to case insensitive")
+public class ExternalIdCaseSensitivityMigrationCommand extends SshCommand {
+
+  @Inject OnlineExternalIdCaseSensivityMigrator onlineExternalIdCaseSensivityMigrator;
+  @Inject @GerritServerConfig private Config globalConfig;
+
+  @Override
+  public void run() throws UnloggedFailure, Failure, Exception {
+    Boolean isUserNameCaseInsensitiveMigrationMode =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitiveMigrationMode", false);
+    Boolean isUserNameCaseInsensitive =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitive", false);
+
+    if (!isUserNameCaseInsensitive || !isUserNameCaseInsensitiveMigrationMode) {
+      die(
+          "External IDs online migration requires auth.userNameCaseInsensitive and"
+              + " auth.userNameCaseInsensitiveMigrationMode to be set to true. Cannot start"
+              + " migration!");
+    }
+    onlineExternalIdCaseSensivityMigrator.migrate();
+    stdout.println(
+        "External ids case insensitivity migration started. To check if it's completed look for"
+            + " \"External IDs migration completed!\" message in the Gerrit server logs");
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java b/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java
new file mode 100644
index 0000000..57bf9e5
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd.commands;
+
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigratiorExecutor;
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigrator;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.sshd.CommandModule;
+import com.google.gerrit.sshd.Commands;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import java.util.concurrent.ExecutorService;
+
+public class ExternalIdCommandsModule extends CommandModule {
+
+  @Override
+  protected void configure() {
+    bind(OnlineExternalIdCaseSensivityMigrator.class);
+    command(Commands.named("gerrit"), ExternalIdCaseSensitivityMigrationCommand.class);
+  }
+
+  @Provides
+  @Singleton
+  @OnlineExternalIdCaseSensivityMigratiorExecutor
+  public ExecutorService OnlineExternalIdCaseSensivityMigratiorExecutor(WorkQueue queues) {
+    return queues.createQueue(1, "MigrateExternalIdCase", true);
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
index 1a7be32..742536c 100644
--- a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import java.util.Enumeration;
+import java.util.Collections;
 import java.util.Map;
 import java.util.TreeMap;
 import org.apache.log4j.LogManager;
@@ -37,19 +37,22 @@
   @Argument(index = 0, required = false, metaVar = "NAME", usage = "used to match loggers")
   private String name;
 
-  @SuppressWarnings("unchecked")
   @Override
   protected void run() {
     enableGracefulStop();
     Map<String, String> logs = new TreeMap<>();
-    for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
-      Logger log = logger.nextElement();
-      if (name == null || log.getName().contains(name)) {
-        logs.put(log.getName(), log.getEffectiveLevel().toString());
+    for (Logger logger : getCurrentLoggers()) {
+      if (name == null || logger.getName().contains(name)) {
+        logs.put(logger.getName(), logger.getEffectiveLevel().toString());
       }
     }
     for (Map.Entry<String, String> e : logs.entrySet()) {
       stdout.println(e.getKey() + ": " + e.getValue());
     }
   }
+
+  @SuppressWarnings({"unchecked", "JdkObsolete"})
+  private static Iterable<Logger> getCurrentLoggers() {
+    return Collections.list(LogManager.getCurrentLoggers());
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index 45c6a35..fd18656 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -80,7 +80,7 @@
         return;
       }
 
-      List<AccountInfo> members = getDirectMembers(group.get());
+      List<AccountInfo> members = getMembers(group.get());
       ColumnFormatter formatter = new ColumnFormatter(writer, '\t');
       formatter.addColumn("id");
       formatter.addColumn("username");
diff --git a/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
index 4ebf15e..65d48dd 100644
--- a/java/com/google/gerrit/sshd/commands/PatchSetParser.java
+++ b/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeFinder;
@@ -59,6 +60,7 @@
     if (token.matches("^([0-9a-fA-F]{4," + ObjectIds.STR_LEN + "})$")) {
       InternalChangeQuery query = queryProvider.get();
       List<ChangeData> cds;
+      branch = branch != null ? RefNames.fullName(branch) : null;
       if (projectState != null) {
         Project.NameKey p = projectState.getNameKey();
         if (branch != null) {
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java
index 92666f3..3f2e2ad 100644
--- a/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -110,7 +110,7 @@
         msg.append(currentUser.getAccountId());
         msg.append("): ");
         msg.append(badStream.getCause().getMessage());
-        logger.atInfo().log(msg.toString());
+        logger.atInfo().log("%s", msg);
         throw new UnloggedFailure(128, "error: " + badStream.getCause().getMessage());
       }
       StringBuilder msg = new StringBuilder();
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 4c84bd3..e0805c0 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -41,6 +41,8 @@
 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.update.RetryHelper;
+import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -163,6 +165,8 @@
 
   @Inject private PatchSetParser psParser;
 
+  @Inject private RetryHelper retryHelper;
+
   private Map<Option, LabelSetter> optionMap;
   private Map<String, Short> customLabels;
 
@@ -243,11 +247,19 @@
     }
   }
 
-  private void applyReview(PatchSet patchSet, ReviewInput review) throws RestApiException {
-    gApi.changes()
-        .id(patchSet.id().changeId().get())
-        .revision(patchSet.commitId().name())
-        .review(review);
+  private void applyReview(PatchSet patchSet, ReviewInput review) throws Exception {
+    retryHelper
+        .action(
+            ActionType.CHANGE_UPDATE,
+            "applyReview",
+            () -> {
+              gApi.changes()
+                  .id(patchSet.id().changeId().get())
+                  .revision(patchSet.commitId().name())
+                  .review(review);
+              return null;
+            })
+        .call();
   }
 
   private ReviewInput reviewFromJson() throws UnloggedFailure {
diff --git a/java/com/google/gerrit/sshd/commands/ScpCommand.java b/java/com/google/gerrit/sshd/commands/ScpCommand.java
index 6912795..3be98fd 100644
--- a/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -147,7 +147,7 @@
     for (; ; ) {
       int c = in.read();
       if (c == '\n') {
-        return baos.toString();
+        return baos.toString(UTF_8);
       } else if (c == -1) {
         throw new IOException("End of stream");
       } else {
diff --git a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index 3faf598..4d16da6 100644
--- a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.sshd.SshCommand;
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.util.Enumeration;
+import java.util.Collections;
 import org.apache.log4j.Level;
 import org.apache.log4j.LogManager;
 import org.apache.log4j.Logger;
@@ -58,27 +58,23 @@
   @Argument(index = 1, required = false, metaVar = "NAME", usage = "used to match loggers")
   private String name;
 
-  @SuppressWarnings("unchecked")
   @Override
   protected void run() throws MalformedURLException {
     enableGracefulStop();
     if (level == LevelOption.RESET) {
       reset();
     } else {
-      for (Enumeration<Logger> logger = LogManager.getCurrentLoggers();
-          logger.hasMoreElements(); ) {
-        Logger log = logger.nextElement();
-        if (name == null || log.getName().contains(name)) {
-          log.setLevel(Level.toLevel(level.name()));
+      for (Logger logger : getCurrentLoggers()) {
+        if (name == null || logger.getName().contains(name)) {
+          logger.setLevel(Level.toLevel(level.name()));
         }
       }
     }
   }
 
-  @SuppressWarnings("unchecked")
   private static void reset() throws MalformedURLException {
-    for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
-      logger.nextElement().setLevel(null);
+    for (Logger logger : getCurrentLoggers()) {
+      logger.setLevel(null);
     }
 
     String path = System.getProperty(JAVA_OPTIONS_LOG_CONFIG);
@@ -88,4 +84,9 @@
       PropertyConfigurator.configure(new URL(path));
     }
   }
+
+  @SuppressWarnings({"unchecked", "JdkObsolete"})
+  private static Iterable<Logger> getCurrentLoggers() {
+    return Collections.list(LogManager.getCurrentLoggers());
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
index 35cb3ba..244fdbe 100644
--- a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
@@ -84,8 +84,7 @@
 
     for (ChangeResource r : changes.values()) {
       SetTopicOp op = topicOpFactory.create(topic);
-      try (BatchUpdate u =
-          updateFactory.create(r.getChange().getProject(), user, TimeUtil.nowTs())) {
+      try (BatchUpdate u = updateFactory.create(r.getChange().getProject(), user, TimeUtil.now())) {
         u.addOp(r.getId(), op);
         u.execute();
       }
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 979be1b..5b89228 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -24,6 +24,8 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.cache.CacheDisplay;
+import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -35,17 +37,16 @@
 import com.google.gerrit.server.restapi.config.GetSummary.TaskSummaryInfo;
 import com.google.gerrit.server.restapi.config.GetSummary.ThreadSummaryInfo;
 import com.google.gerrit.server.restapi.config.ListCaches;
-import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.restapi.config.ListCaches.CacheType;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.Collection;
-import java.util.Date;
 import java.util.Map;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -114,61 +115,20 @@
   protected void run() throws Failure {
     enableGracefulStop();
     nw = columns - 50;
-    Date now = new Date();
+    Instant now = Instant.now();
+    DateTimeFormatter fmt =
+        DateTimeFormatter.ofPattern("HH:mm:ss   zzz").withZone(ZoneId.of("UTC"));
     stdout.format(
         "%-25s %-20s      now  %16s\n",
         "Gerrit Code Review",
         Version.getVersion() != null ? Version.getVersion() : "",
-        new SimpleDateFormat("HH:mm:ss   zzz").format(now));
-    stdout.format("%-25s %-20s   uptime %16s\n", "", "", uptime(now.getTime() - serverStarted));
+        fmt.format(now));
+    stdout.format(
+        "%-25s %-20s   uptime %16s\n", "", "", uptime(now.toEpochMilli() - serverStarted));
     stdout.print('\n');
 
-    stdout.print(
-        String.format( //
-            "%1s %-" + nw + "s|%-21s|  %-5s |%-9s|\n" //
-            ,
-            "" //
-            ,
-            "Name" //
-            ,
-            "Entries" //
-            ,
-            "AvgGet" //
-            ,
-            "Hit Ratio" //
-            ));
-    stdout.print(
-        String.format( //
-            "%1s %-" + nw + "s|%6s %6s %7s|  %-5s  |%-4s %-4s|\n" //
-            ,
-            "" //
-            ,
-            "" //
-            ,
-            "Mem" //
-            ,
-            "Disk" //
-            ,
-            "Space" //
-            ,
-            "" //
-            ,
-            "Mem" //
-            ,
-            "Disk" //
-            ));
-    stdout.print("--");
-    for (int i = 0; i < nw; i++) {
-      stdout.print('-');
-    }
-    stdout.print("+---------------------+---------+---------+\n");
-
     try {
-      Collection<CacheInfo> caches = getCaches();
-      printMemoryCoreCaches(caches);
-      printMemoryPluginCaches(caches);
-      printDiskCaches(caches);
-      stdout.print('\n');
+      new CacheDisplay(stdout, nw, getCaches()).displayCaches();
 
       boolean showJvm;
       try {
@@ -209,52 +169,6 @@
     return caches.values();
   }
 
-  private void printMemoryCoreCaches(Collection<CacheInfo> caches) {
-    for (CacheInfo cache : caches) {
-      if (!cache.name.contains("-") && CacheType.MEM.equals(cache.type)) {
-        printCache(cache);
-      }
-    }
-  }
-
-  private void printMemoryPluginCaches(Collection<CacheInfo> caches) {
-    for (CacheInfo cache : caches) {
-      if (cache.name.contains("-") && CacheType.MEM.equals(cache.type)) {
-        printCache(cache);
-      }
-    }
-  }
-
-  private void printDiskCaches(Collection<CacheInfo> caches) {
-    for (CacheInfo cache : caches) {
-      if (CacheType.DISK.equals(cache.type)) {
-        printCache(cache);
-      }
-    }
-  }
-
-  private void printCache(CacheInfo cache) {
-    stdout.print(
-        String.format(
-            "%1s %-" + nw + "s|%6s %6s %7s| %7s |%4s %4s|\n",
-            CacheType.DISK.equals(cache.type) ? "D" : "",
-            cache.name,
-            nullToEmpty(cache.entries.mem),
-            nullToEmpty(cache.entries.disk),
-            Strings.nullToEmpty(cache.entries.space),
-            Strings.nullToEmpty(cache.averageGet),
-            formatAsPercent(cache.hitRatio.mem),
-            formatAsPercent(cache.hitRatio.disk)));
-  }
-
-  private static String nullToEmpty(Long l) {
-    return l != null ? String.valueOf(l) : "";
-  }
-
-  private static String formatAsPercent(Integer i) {
-    return i != null ? String.valueOf(i) + "%" : "";
-  }
-
   private void memSummary(MemSummaryInfo memSummary) {
     stdout.format(
         "Mem: %s total = %s used + %s free + %s buffers\n",
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index 7eeb770..5efeb42 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -34,8 +34,9 @@
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
-import java.text.SimpleDateFormat;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.Optional;
 import org.apache.sshd.common.io.IoAcceptor;
 import org.apache.sshd.common.io.IoSession;
@@ -171,10 +172,13 @@
   }
 
   private static String time(long now, long time) {
+    Instant instant = Instant.ofEpochMilli(time);
     if (now - time < 24 * 60 * 60 * 1000L) {
-      return new SimpleDateFormat("HH:mm:ss").format(new Date(time));
+      return DateTimeFormatter.ofPattern("HH:mm:ss")
+          .withZone(ZoneId.systemDefault())
+          .format(instant);
     }
-    return new SimpleDateFormat("MMM-dd").format(new Date(time));
+    return DateTimeFormatter.ofPattern("MMM-dd").withZone(ZoneId.systemDefault()).format(instant);
   }
 
   private static String age(long age) {
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 779f2df..00361ad 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -35,8 +35,9 @@
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.util.Date;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.List;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.apache.sshd.server.Environment;
@@ -133,7 +134,9 @@
       switch (task.state) {
         case DONE:
         case CANCELLED:
+        case STARTING:
         case RUNNING:
+        case STOPPING:
         case READY:
           start = format(task.state);
           break;
@@ -154,7 +157,7 @@
         stdout.print(
             String.format(
                 "%8s %-12s %-12s %-4s %s\n",
-                task.id, start, startTime(task.startTime), "", command));
+                task.id, start, startTime(task.startTime.toInstant()), "", command));
       } else {
         String remoteName =
             task.remoteName != null ? task.remoteName + "/" + task.projectName : task.projectName;
@@ -164,7 +167,7 @@
                 "%8s %-12s %-4s %s\n",
                 task.id,
                 start,
-                startTime(task.startTime),
+                startTime(task.startTime.toInstant()),
                 MoreObjects.firstNonNull(remoteName, "n/a")));
       }
     }
@@ -178,19 +181,23 @@
   }
 
   private static String time(long now, long delay) {
-    Date when = new Date(now + delay);
+    Instant when = Instant.ofEpochMilli(now + delay);
     return format(when, delay);
   }
 
-  private static String startTime(Date when) {
-    return format(when, TimeUtil.nowMs() - when.getTime());
+  private static String startTime(Instant when) {
+    return format(when, TimeUtil.nowMs() - when.toEpochMilli());
   }
 
-  private static String format(Date when, long timeFromNow) {
+  private static String format(Instant when, long timeFromNow) {
     if (timeFromNow < 24 * 60 * 60 * 1000L) {
-      return new SimpleDateFormat("HH:mm:ss.SSS").format(when);
+      return DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
+          .withZone(ZoneId.systemDefault())
+          .format(when);
     }
-    return new SimpleDateFormat("MMM-dd HH:mm").format(when);
+    return DateTimeFormatter.ofPattern("MMM-dd HH:mm")
+        .withZone(ZoneId.systemDefault())
+        .format(when);
   }
 
   private static String format(Task.State state) {
@@ -199,8 +206,12 @@
         return "....... done";
       case CANCELLED:
         return "..... killed";
+      case STOPPING:
+        return "... stopping";
       case RUNNING:
         return "";
+      case STARTING:
+        return "starting ...";
       case READY:
         return "waiting ....";
       case SLEEPING:
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index be32138..861fa00 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -34,6 +34,7 @@
         "//java/com/google/gerrit/server/audit",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
+        "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
@@ -49,7 +50,6 @@
         "//lib/guice",
         "//lib/guice:guice-servlet",
         "//lib/log:impl-log4j",
-        "//lib/log:log4j",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/testing/FakeAccountCache.java b/java/com/google/gerrit/testing/FakeAccountCache.java
index ab3348b..2c01548 100644
--- a/java/com/google/gerrit/testing/FakeAccountCache.java
+++ b/java/com/google/gerrit/testing/FakeAccountCache.java
@@ -24,6 +24,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Fake implementation of {@link AccountCache} for testing. */
 public class FakeAccountCache implements AccountCache {
@@ -40,7 +41,7 @@
       return state;
     }
     return newState(
-        Account.builder(accountId, TimeUtil.nowTs())
+        Account.builder(accountId, TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build());
   }
@@ -60,6 +61,11 @@
     throw new UnsupportedOperationException();
   }
 
+  @Override
+  public AccountState getFromMetaId(Account.Id accountId, ObjectId metaId) {
+    return get(accountId).get();
+  }
+
   public synchronized void put(Account account) {
     AccountState state = newState(account);
     byId.put(account.id(), state);
diff --git a/java/com/google/gerrit/testing/FloggerInitializer.java b/java/com/google/gerrit/testing/FloggerInitializer.java
index 1972107..7793de1 100644
--- a/java/com/google/gerrit/testing/FloggerInitializer.java
+++ b/java/com/google/gerrit/testing/FloggerInitializer.java
@@ -25,7 +25,7 @@
   public static void initBackend() {
     System.setProperty(
         FLOGGER_BACKEND_PROPERTY,
-        "com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance");
+        "com.google.common.flogger.backend.system.SimpleBackendFactory#getInstance");
     System.setProperty(FLOGGER_LOGGING_CONTEXT, LoggingContext.class.getName() + "#getInstance");
   }
 }
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index a45e906..b828037 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -19,12 +19,14 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 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.registration.DynamicSet;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.auth.restapi.OAuthRestModule;
@@ -37,9 +39,12 @@
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.PluginUser;
+import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -58,6 +63,8 @@
 import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
 import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritImportedServerIdsProvider;
 import com.google.gerrit.server.config.GerritInstanceIdModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
 import com.google.gerrit.server.config.GerritOptions;
@@ -76,6 +83,8 @@
 import com.google.gerrit.server.git.PerThreadRequestScope;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl.SearchingChangeCacheImplModule;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.account.AllAccountsIndexer;
 import com.google.gerrit.server.index.change.AllChangesIndexer;
@@ -183,6 +192,7 @@
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     install(new AuthModule(authConfig));
     install(new GerritApiModule());
+    install(new ProjectQueryBuilderModule());
     factory(PluginUser.Factory.class);
     install(new PluginApiModule());
     install(new DefaultPermissionBackendModule());
@@ -191,6 +201,7 @@
     install(new AuditModule());
     install(new SubscriptionGraphModule());
     install(new SuperprojectUpdateSubmissionListenerModule());
+    install(new WorkQueueModule());
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
@@ -243,15 +254,19 @@
     bind(AllChangesIndexer.class).toProvider(Providers.of(null));
     bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
 
-    String indexTypeCfg = cfg.getString("index", null, "type");
-    IndexType indexType = new IndexType(indexTypeCfg != null ? indexTypeCfg : "fake");
-    // For custom index types, callers must provide their own module.
-    if (indexType.isLucene()) {
-      install(luceneIndexModule());
-    } else if (indexType.isElasticsearch()) {
-      install(elasticIndexModule());
-    } else if (indexType.isFake()) {
-      install(fakeIndexModule());
+    // Index lib module has a higher priority than index type configuration.
+    String indexModule =
+        cfg.getString("index", null, "install" + LibModuleType.INDEX_MODULE_TYPE.getConfigKey());
+    if (indexModule != null) {
+      install(indexModule(indexModule));
+    } else {
+      String indexTypeCfg = cfg.getString("index", null, "type");
+      IndexType indexType = new IndexType(indexTypeCfg != null ? indexTypeCfg : "fake");
+      if (indexType.isLucene()) {
+        install(luceneIndexModule());
+      } else if (indexType.isFake()) {
+        install(fakeIndexModule());
+      }
     }
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
@@ -262,6 +277,8 @@
     install(new ConfigExperimentFeaturesModule());
 
     bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
+    bind(TestGroupBackend.class).in(SINGLETON);
+    DynamicSet.bind(binder(), GroupBackend.class).to(TestGroupBackend.class);
   }
 
   /** Copy of SchemaModule with a slightly different server ID provider. */
@@ -301,6 +318,17 @@
 
   @Provides
   @Singleton
+  @GerritImportedServerIds
+  public ImmutableSet<String> createImportedServerIds() {
+    ImmutableSet<String> serverIds =
+        ImmutableSet.copyOf(
+            cfg.getStringList(
+                GerritServerIdProvider.SECTION, null, GerritImportedServerIdsProvider.KEY));
+    return serverIds;
+  }
+
+  @Provides
+  @Singleton
   @SendEmailExecutor
   public ExecutorService createSendEmailExecutor() {
     return newDirectExecutorService();
@@ -324,10 +352,6 @@
     return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
   }
 
-  private Module elasticIndexModule() {
-    return indexModule("com.google.gerrit.elasticsearch.ElasticIndexModule");
-  }
-
   private Module fakeIndexModule() {
     return indexModule("com.google.gerrit.index.testing.FakeIndexModule");
   }
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index 362e23c..2051ae3 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -14,16 +14,16 @@
 
 package com.google.gerrit.testing;
 
-import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.inject.Inject;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -111,12 +111,12 @@
   }
 
   @Override
-  public synchronized SortedSet<Project.NameKey> list() {
-    SortedSet<Project.NameKey> names = Sets.newTreeSet();
+  public synchronized NavigableSet<Project.NameKey> list() {
+    NavigableSet<Project.NameKey> names = Sets.newTreeSet();
     for (DfsRepository repo : repos.values()) {
       names.add(Project.nameKey(repo.getDescription().getRepositoryName()));
     }
-    return ImmutableSortedSet.copyOf(names);
+    return Collections.unmodifiableNavigableSet(names);
   }
 
   public synchronized void deleteRepository(Project.NameKey name) {
diff --git a/java/com/google/gerrit/testing/IndexConfig.java b/java/com/google/gerrit/testing/IndexConfig.java
index d68dcad..45b54ce 100644
--- a/java/com/google/gerrit/testing/IndexConfig.java
+++ b/java/com/google/gerrit/testing/IndexConfig.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.testing;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import org.eclipse.jgit.lib.Config;
 
@@ -31,9 +32,14 @@
     cfg.setString("trackingid", "query-bug", "footer", "Bug:");
     cfg.setString("trackingid", "query-bug", "match", "QUERY\\d{2,8}");
     cfg.setString("trackingid", "query-bug", "system", "querytests");
-    cfg.setString("trackingid", "query-feature", "footer", "Feature");
-    cfg.setString("trackingid", "query-feature", "match", "QUERY\\d{2,8}");
-    cfg.setString("trackingid", "query-feature", "system", "querytests");
+    cfg.setStringList(
+        "trackingid", "query-google", "footer", ImmutableList.of("Issue", "Google-Bug-Id"));
+    cfg.setString(
+        "trackingid",
+        "query-google",
+        "match",
+        "(?:[Bb]ug|[Ii]ssue|b/)[ \\t]*\\r?\\n?[ \\t]*#?(\\d+)");
+    cfg.setString("trackingid", "query-google", "system", "querygo");
     return cfg;
   }
 
@@ -43,16 +49,6 @@
     return cfg;
   }
 
-  public static Config createForElasticsearch() {
-    Config cfg = create();
-
-    // For some reason enabling the staleness checker increases the flakiness of the Elasticsearch
-    // tests. Hence disable the staleness checker.
-    cfg.setBoolean("index", null, "autoReindexIfStale", false);
-
-    return cfg;
-  }
-
   public static Config createForFake() {
     return create();
   }
diff --git a/java/com/google/gerrit/testing/IndexVersions.java b/java/com/google/gerrit/testing/IndexVersions.java
index 3281ffc..3810707 100644
--- a/java/com/google/gerrit/testing/IndexVersions.java
+++ b/java/com/google/gerrit/testing/IndexVersions.java
@@ -27,7 +27,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.SortedMap;
+import java.util.NavigableMap;
 import org.eclipse.jgit.lib.Config;
 
 public class IndexVersions {
@@ -90,13 +90,13 @@
       value = value.trim();
     }
 
-    SortedMap<Integer, Schema<V>> schemas = schemaDef.getSchemas();
+    NavigableMap<Integer, Schema<V>> schemas = schemaDef.getSchemas();
     if (!Strings.isNullOrEmpty(value)) {
       if (ALL.equals(value)) {
         return ImmutableList.copyOf(schemas.keySet());
       }
 
-      List<Integer> versions = new ArrayList<>();
+      ImmutableList.Builder<Integer> versions = ImmutableList.builder();
       for (String s : Splitter.on(',').trimResults().split(value)) {
         if (CURRENT.equals(s)) {
           versions.add(schemaDef.getLatest().getVersion());
@@ -115,15 +115,15 @@
           versions.add(version);
         }
       }
-      return ImmutableList.copyOf(versions);
+      return versions.build();
     }
 
-    List<Integer> schemaVersions = new ArrayList<>(2);
+    ImmutableList.Builder<Integer> schemaVersions = ImmutableList.builderWithExpectedSize(2);
     if (schemaDef.getPrevious() != null) {
       schemaVersions.add(schemaDef.getPrevious().getVersion());
     }
     schemaVersions.add(schemaDef.getLatest().getVersion());
-    return ImmutableList.copyOf(schemaVersions);
+    return schemaVersions.build();
   }
 
   public static <V> Map<String, Config> asConfigMap(
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index b795c5b..c8f89cf 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -31,7 +31,8 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Injector;
-import java.util.TimeZone;
+import java.time.ZoneId;
+import java.util.Optional;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
@@ -58,7 +59,7 @@
             changeId,
             userId,
             BranchNameKey.create(project, "master"),
-            TimeUtil.nowTs());
+            TimeUtil.now());
     incrementPatchSet(c);
     return c;
   }
@@ -72,20 +73,25 @@
         .id(id)
         .commitId(ObjectId.fromString(revision))
         .uploader(userId)
-        .createdOn(TimeUtil.nowTs())
+        .createdOn(TimeUtil.now())
         .build();
   }
 
   public static ChangeUpdate newUpdate(
-      Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
+      Injector injector, Change c, Optional<CurrentUser> user, boolean shouldExist)
+      throws Exception {
     injector =
         injector.createChildInjector(
             new FactoryModule() {
               @Override
               public void configure() {
-                bind(CurrentUser.class).toInstance(user);
+                if (user.isPresent()) {
+                  // user may be already bound in injector
+                  bind(CurrentUser.class).toInstance(user.get());
+                }
               }
             });
+    CurrentUser currentUser = injector.getProvider(CurrentUser.class).get();
     ChangeUpdate update =
         injector
             .getInstance(ChangeUpdate.Factory.class)
@@ -93,8 +99,8 @@
                 new ChangeNotes(
                         injector.getInstance(AbstractChangeNotes.Args.class), c, shouldExist, null)
                     .load(),
-                user,
-                TimeUtil.nowTs(),
+                currentUser,
+                TimeUtil.now(),
                 Ordering.natural());
 
     ChangeNotes notes = update.getNotes();
@@ -109,7 +115,9 @@
     try (Repository repo = repoManager.openRepository(c.getProject());
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
       PersonIdent ident =
-          user.asIdentifiedUser().newCommitterIdent(update.getWhen(), TimeZone.getDefault());
+          currentUser
+              .asIdentifiedUser()
+              .newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
       TestRepository<Repository>.CommitBuilder cb =
           tr.commit()
               .author(ident)
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
index 5865a3c..400b559 100644
--- a/java/com/google/gerrit/testing/TestCommentHelper.java
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -33,6 +33,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 
 /** Test helper for dealing with comments/drafts. */
 public class TestCommentHelper {
@@ -64,7 +65,7 @@
     gApi.changes().id(changeId).current().createDraft(in);
   }
 
-  public Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
+  public List<CommentInfo> getPublishedComments(String changeId) throws Exception {
     return gApi.changes().id(changeId).commentsRequest().get().values().stream()
         .flatMap(Collection::stream)
         .collect(toList());
diff --git a/java/com/google/gerrit/testing/TestLoggingActivator.java b/java/com/google/gerrit/testing/TestLoggingActivator.java
index b3ad862..c7e22c9 100644
--- a/java/com/google/gerrit/testing/TestLoggingActivator.java
+++ b/java/com/google/gerrit/testing/TestLoggingActivator.java
@@ -14,15 +14,12 @@
 
 package com.google.gerrit.testing;
 
-import static org.apache.log4j.Logger.getLogger;
-
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import org.apache.log4j.ConsoleAppender;
-import org.apache.log4j.Level;
-import org.apache.log4j.LogManager;
-import org.apache.log4j.Logger;
-import org.apache.log4j.PatternLayout;
+import java.util.logging.ConsoleHandler;
+import java.util.logging.Level;
+import java.util.logging.LogManager;
+import java.util.logging.Logger;
 
 public class TestLoggingActivator {
   private static final ImmutableMap<String, Level> LOG_LEVELS =
@@ -30,45 +27,38 @@
           .put("com.google.gerrit", getGerritLogLevel())
 
           // 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.mina", Level.WARNING)
+          .put("org.apache.sshd.client", Level.WARNING)
+          .put("org.apache.sshd.common", Level.WARNING)
+          .put("org.apache.sshd.server", Level.WARNING)
           .put("org.apache.sshd.common.keyprovider.FileKeyPairProvider", Level.INFO)
-          .put("com.google.gerrit.sshd.GerritServerSession", Level.WARN)
+          .put("com.google.gerrit.sshd.GerritServerSession", Level.WARNING)
 
           // Silence non-critical messages from mime-util.
-          .put("eu.medsea.mimeutil", Level.WARN)
+          .put("eu.medsea.mimeutil", Level.WARNING)
 
           // Silence non-critical messages from openid4java.
-          .put("org.apache.xml", Level.WARN)
-          .put("org.openid4java", Level.WARN)
-          .put("org.openid4java.consumer.ConsumerManager", Level.FATAL)
-          .put("org.openid4java.discovery.Discovery", Level.ERROR)
-          .put("org.openid4java.server.RealmVerifier", Level.ERROR)
-          .put("org.openid4java.message.AuthSuccess", Level.ERROR)
+          .put("org.apache.xml", Level.WARNING)
+          .put("org.openid4java", Level.WARNING)
+          .put("org.openid4java.consumer.ConsumerManager", Level.SEVERE)
+          .put("org.openid4java.discovery.Discovery", Level.SEVERE)
+          .put("org.openid4java.server.RealmVerifier", Level.SEVERE)
+          .put("org.openid4java.message.AuthSuccess", Level.SEVERE)
 
           // Silence non-critical messages from apache.http.
-          .put("org.apache.http", Level.WARN)
+          .put("org.apache.http", Level.WARNING)
 
           // Silence non-critical messages from Jetty.
-          .put("org.eclipse.jetty", Level.WARN)
+          .put("org.eclipse.jetty", Level.WARNING)
 
           // 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)
-
-          // Silence non-critical messages from Elasticsearch.
-          .put("org.elasticsearch", Level.WARN)
-
-          // Silence non-critical messages from Docker for Elasticsearch query tests.
-          .put("org.testcontainers", Level.WARN)
-          .put("com.github.dockerjava.core", Level.WARN)
+          .put("org.eclipse.jgit.transport.PacketLineIn", Level.WARNING)
+          .put("org.eclipse.jgit.transport.PacketLineOut", Level.WARNING)
+          .put("org.eclipse.jgit.internal.transport.sshd", Level.WARNING)
+          .put("org.eclipse.jgit.util.FileUtils", Level.WARNING)
+          .put("org.eclipse.jgit.internal.storage.file.FileSnapshot", Level.WARNING)
+          .put("org.eclipse.jgit.util.FS", Level.WARNING)
+          .put("org.eclipse.jgit.util.SystemReader", Level.WARNING)
           .build();
 
   private static Level getGerritLogLevel() {
@@ -76,27 +66,44 @@
     if (value.isEmpty()) {
       value = Strings.nullToEmpty(System.getProperty("gerrit.logLevel"));
     }
-    return Level.toLevel(value, Level.INFO);
+
+    try {
+      return Level.parse(value);
+    } catch (IllegalArgumentException e) {
+      // for backwards compatibility handle log4j log levels
+      if (value.equalsIgnoreCase("FATAL") || value.equalsIgnoreCase("ERROR")) {
+        return Level.SEVERE;
+      }
+      if (value.equalsIgnoreCase("WARN")) {
+        return Level.WARNING;
+      }
+      if (value.equalsIgnoreCase("DEBUG")) {
+        return Level.FINE;
+      }
+      if (value.equalsIgnoreCase("TRACE")) {
+        return Level.FINEST;
+      }
+
+      return Level.INFO;
+    }
   }
 
   public static void configureLogging() {
-    LogManager.resetConfiguration();
+    LogManager.getLogManager().reset();
     FloggerInitializer.initBackend();
 
-    PatternLayout layout = new PatternLayout();
-    layout.setConversionPattern("%-5p %c %x: %m%n");
+    ConsoleHandler dst = new ConsoleHandler();
+    dst.setLevel(Level.FINEST);
 
-    ConsoleAppender dst = new ConsoleAppender();
-    dst.setLayout(layout);
-    dst.setTarget("System.err");
-    dst.setThreshold(Level.DEBUG);
-    dst.activateOptions();
+    Logger.getLogger(Logger.GLOBAL_LOGGER_NAME).addHandler(dst);
 
-    Logger root = LogManager.getRootLogger();
-    root.removeAllAppenders();
-    root.addAppender(dst);
-
-    LOG_LEVELS.entrySet().stream().forEach(e -> getLogger(e.getKey()).setLevel(e.getValue()));
+    LOG_LEVELS.entrySet().stream()
+        .forEach(
+            e -> {
+              Logger logger = Logger.getLogger(e.getKey());
+              logger.setLevel(e.getValue());
+              logger.addHandler(dst);
+            });
   }
 
   private TestLoggingActivator() {}
diff --git a/java/com/google/gerrit/testing/TestUpdateUI.java b/java/com/google/gerrit/testing/TestUpdateUI.java
index 76671fb..08c9a14 100644
--- a/java/com/google/gerrit/testing/TestUpdateUI.java
+++ b/java/com/google/gerrit/testing/TestUpdateUI.java
@@ -14,12 +14,29 @@
 
 package com.google.gerrit.testing;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.schema.UpdateUI;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Set;
 
 public class TestUpdateUI implements UpdateUI {
+  private final List<String> messages = new ArrayList<>();
+
   @Override
-  public void message(String message) {}
+  public void message(String message) {
+    messages.add(message);
+  }
+
+  public ImmutableList<String> getMessages() {
+    return ImmutableList.copyOf(messages);
+  }
+
+  public String getOutput() {
+    return messages.stream().collect(joining("\n"));
+  }
 
   @Override
   public boolean yesno(boolean defaultValue, String message) {
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index c374691..a37c027 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -44,6 +44,7 @@
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.StringWriter;
@@ -456,7 +457,7 @@
 
     MyParser(Object bean) {
       super(bean, ParserProperties.defaults().withAtSyntax(false));
-      parseAdditionalOptions(bean, new HashSet<>());
+      parseAdditionalOptions("", bean, new HashSet<>());
       addOptionsWithMetRequirements();
       ensureOptionsInitialized();
     }
@@ -527,7 +528,7 @@
       }
     }
 
-    private void parseAdditionalOptions(Object bean, Set<Object> parsedBeans) {
+    private void parseAdditionalOptions(String prefix, Object bean, Set<Object> parsedBeans) {
       for (Class<?> c = bean.getClass(); c != null; c = c.getSuperclass()) {
         for (Field f : c.getDeclaredFields()) {
           if (f.isAnnotationPresent(Options.class)) {
@@ -537,7 +538,8 @@
             } catch (IllegalAccessException e) {
               throw new IllegalAnnotationError(e);
             }
-            parseWithPrefix(f.getAnnotation(Options.class).prefix(), additionalBean, parsedBeans);
+            parseWithPrefix(
+                prefix + f.getAnnotation(Options.class).prefix(), additionalBean, parsedBeans);
           }
         }
       }
@@ -566,6 +568,7 @@
      *     and it needed to be exposed.
      */
     @SuppressWarnings("rawtypes")
+    @Nullable
     public OptionHandler findOptionByName(String name) {
       for (OptionHandler h : optionsList) {
         if (h.option instanceof NamedOptionDef) {
diff --git a/java/com/google/gerrit/util/http/BUILD b/java/com/google/gerrit/util/http/BUILD
index fbd1379..afb4e25 100644
--- a/java/com/google/gerrit/util/http/BUILD
+++ b/java/com/google/gerrit/util/http/BUILD
@@ -4,5 +4,8 @@
     name = "http",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
-    deps = ["//lib:servlet-api"],
+    deps = [
+        "//lib:guava",
+        "//lib:servlet-api",
+    ],
 )
diff --git a/java/com/google/gerrit/util/http/RequestUtil.java b/java/com/google/gerrit/util/http/RequestUtil.java
index f64ce5a..51ffe0c 100644
--- a/java/com/google/gerrit/util/http/RequestUtil.java
+++ b/java/com/google/gerrit/util/http/RequestUtil.java
@@ -14,10 +14,14 @@
 
 package com.google.gerrit.util.http;
 
+import com.google.common.base.Splitter;
+import java.util.List;
 import javax.servlet.http.HttpServletRequest;
 
 /** Utilities for manipulating HTTP request objects. */
 public class RequestUtil {
+  private static final Splitter SPLITTER = Splitter.on("/");
+
   /** HTTP request attribute for storing the Throwable that caused an error condition. */
   private static final String ATTRIBUTE_ERROR_TRACE =
       RequestUtil.class.getName() + "/ErrorTraceThrowable";
@@ -80,11 +84,11 @@
       encodedPathInfo = encodedPathInfo.substring(2);
     }
 
-    String[] parts = encodedPathInfo.split("/");
-    StringBuilder result = new StringBuilder(parts.length);
-    for (int i = 0; i < parts.length; i = i + 2) {
+    List<String> parts = SPLITTER.splitToList(encodedPathInfo);
+    StringBuilder result = new StringBuilder(parts.size());
+    for (int i = 0; i < parts.size(); i = i + 2) {
       result.append("/");
-      result.append(parts[i]);
+      result.append(parts.get(i));
     }
     return result.toString();
   }
diff --git a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
index 7758be6..2ea2a55 100644
--- a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
@@ -126,7 +126,7 @@
 
   private static String getMessageId(FakeEmailSender sender) {
     return ((StringEmailHeader)
-            (Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID")))
+            Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID"))
         .getString();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index ff9bac9..b6e5b74 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -231,7 +231,7 @@
       updateRef(repo2, metaConfig);
     }
 
-    verify(projectCache, only()).evict(project2);
+    verify(projectCache, only()).evictAndReindex(project2);
   }
 
   @Test
@@ -248,7 +248,7 @@
       createRef(repo2, RefNames.REFS_CONFIG);
     }
 
-    verify(projectCache, only()).evict(project2);
+    verify(projectCache, only()).evictAndReindex(project2);
   }
 
   @Test
@@ -335,7 +335,7 @@
   private ObjectId createCommit(Repository repo) throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent ident =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java b/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
index ecfe3f5..9d689ba 100644
--- a/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
+++ b/javatests/com/google/gerrit/acceptance/annotation/UseClockStepTest.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.server.util.time.TimeUtil;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.concurrent.TimeUnit;
 import org.junit.Test;
@@ -52,6 +51,6 @@
   @Test
   @UseClockStep(startAtEpoch = true)
   public void useClockStepWithStartAtEpoch() {
-    assertThat(TimeUtil.nowTs()).isEqualTo(Timestamp.from(Instant.EPOCH));
+    assertThat(TimeUtil.now()).isEqualTo(Instant.EPOCH);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 1da2176c..111f8c9 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.accounts;
 
 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.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -32,7 +33,11 @@
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
+import static com.google.gerrit.server.account.AccountProperties.ACCOUNT;
+import static com.google.gerrit.server.account.AccountProperties.ACCOUNT_CONFIG;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -73,6 +78,7 @@
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
 import com.google.gerrit.common.Nullable;
@@ -101,6 +107,7 @@
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountsInput;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -124,10 +131,12 @@
 import com.google.gerrit.httpd.CacheBasedWebSession;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -135,8 +144,10 @@
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.index.account.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
@@ -168,6 +179,7 @@
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
 import javax.servlet.http.HttpServletResponse;
 import org.apache.http.HttpResponse;
 import org.apache.http.client.ClientProtocolException;
@@ -233,6 +245,8 @@
   @Inject private PluginSetContext<ExceptionHook> exceptionHooks;
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
   @Inject private ExternalIdFactory externalIdFactory;
+  @Inject private AuthConfig authConfig;
+  @Inject private AccountControl.Factory accountControlFactory;
 
   @Inject protected Emails emails;
 
@@ -497,20 +511,20 @@
       assertThat(ref).isNotNull();
       RevCommit c = rw.parseCommit(ref.getObjectId());
       long timestampDiffMs =
-          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).registeredOn().getTime());
+          Math.abs(c.getCommitTime() * 1000L - getAccount(accountId).registeredOn().toEpochMilli());
       assertThat(timestampDiffMs).isAtMost(SECONDS.toMillis(1));
 
       // Check the 'account.config' file.
-      try (TreeWalk tw = TreeWalk.forPath(or, AccountProperties.ACCOUNT_CONFIG, c.getTree())) {
+      try (TreeWalk tw = TreeWalk.forPath(or, ACCOUNT_CONFIG, c.getTree())) {
         if (name != null || status != null) {
           assertThat(tw).isNotNull();
           Config cfg = new Config();
           cfg.fromText(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8));
           assertThat(cfg)
-              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
+              .stringValue(ACCOUNT, null, AccountProperties.KEY_FULL_NAME)
               .isEqualTo(name);
           assertThat(cfg)
-              .stringValue(AccountProperties.ACCOUNT, null, AccountProperties.KEY_STATUS)
+              .stringValue(ACCOUNT, null, AccountProperties.KEY_STATUS)
               .isEqualTo(status);
         } else {
           // No account properties were set, hence an 'account.config' file was not created.
@@ -794,58 +808,6 @@
   }
 
   @Test
-  public void ignoreChange() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      TestAccount user2 = accountCreator.user2();
-      accountIndexedCounter.clear();
-
-      PushOneCommit.Result r = createChange();
-
-      ReviewerInput in = new ReviewerInput();
-      in.reviewer = user.email();
-      gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-      in = new ReviewerInput();
-      in.reviewer = user2.email();
-      gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-      requestScopeOperations.setApiUser(user.id());
-      gApi.changes().id(r.getChangeId()).ignore(true);
-
-      sender.clear();
-      requestScopeOperations.setApiUser(admin.id());
-      gApi.changes().id(r.getChangeId()).abandon();
-      List<Message> messages = sender.getMessages();
-      assertThat(messages).hasSize(1);
-      assertThat(messages.get(0).rcpt()).containsExactly(user2.getNameEmail());
-      accountIndexedCounter.assertNoReindex();
-    }
-  }
-
-  @Test
-  public void addReviewerToIgnoredChange() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      PushOneCommit.Result r = createChange();
-
-      requestScopeOperations.setApiUser(user.id());
-      gApi.changes().id(r.getChangeId()).ignore(true);
-
-      sender.clear();
-      requestScopeOperations.setApiUser(admin.id());
-
-      ReviewerInput in = new ReviewerInput();
-      in.reviewer = user.email();
-      gApi.changes().id(r.getChangeId()).addReviewer(in);
-      List<Message> messages = sender.getMessages();
-      assertThat(messages).hasSize(0);
-    }
-  }
-
-  @Test
   public void addExistingReviewersUsingPostReview() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -978,7 +940,8 @@
     assertThat(detail.email).isEqualTo(email);
     assertThat(detail.secondaryEmails).containsExactly(secondaryEmail);
     assertThat(detail.status).isEqualTo(status);
-    assertThat(detail.registeredOn).isEqualTo(getAccount(foo.id()).registeredOn());
+    assertThat(detail.registeredOn.getTime())
+        .isEqualTo(getAccount(foo.id()).registeredOn().toEpochMilli());
     assertThat(detail.inactive).isNull();
     assertThat(detail._moreAccounts).isNull();
   }
@@ -2107,6 +2070,7 @@
     return newEmailInput(email, true);
   }
 
+  @Nullable
   private String getMetaId(Account.Id accountId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo);
@@ -2475,7 +2439,7 @@
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.nowTs());
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(commit.getTree());
       cb.setCommitter(ident);
@@ -2495,7 +2459,11 @@
     // stale.
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ExternalIdNotes extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(allUsers, repo, externalIdFactory);
+          ExternalIdNotes.load(
+              allUsers,
+              repo,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode());
 
       ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
       extIdNotes.insert(externalIdFactory.create(key, accountId));
@@ -2504,14 +2472,24 @@
       }
       assertStaleAccountAndReindex(accountId);
 
-      extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo, externalIdFactory);
+      extIdNotes =
+          ExternalIdNotes.load(
+              allUsers,
+              repo,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode());
       extIdNotes.upsert(externalIdFactory.createWithEmail(key, accountId, "foo@example.com"));
       try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
         extIdNotes.commit(update);
       }
       assertStaleAccountAndReindex(accountId);
 
-      extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo, externalIdFactory);
+      extIdNotes =
+          ExternalIdNotes.load(
+              allUsers,
+              repo,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode());
       extIdNotes.delete(accountId, key);
       try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
         extIdNotes.commit(update);
@@ -2915,6 +2893,24 @@
     assertThat(gApi.accounts().id("secondary").get()._accountId).isEqualTo(foo.id().get());
   }
 
+  @Test
+  public void getAccountFromMetaId() throws RestApiException {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.accounts().self().setStatus("New status");
+
+    AccountState postUpdateStatus = accountCache.get(admin.id()).get();
+    assertThat(postUpdateStatus).isNotEqualTo(preUpdateState);
+    assertThat(
+            accountCache.getFromMetaId(
+                admin.id(), ObjectId.fromString(preUpdateState.account().metaId())))
+        .isEqualTo(preUpdateState);
+    assertThat(
+            accountCache.getFromMetaId(
+                admin.id(), ObjectId.fromString(postUpdateStatus.account().metaId())))
+        .isEqualTo(postUpdateStatus);
+  }
+
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
     DraftInput in = new DraftInput();
     in.path = path;
@@ -2937,6 +2933,339 @@
     }
   }
 
+  @Test
+  public void projectWatchesUpdate_refsUsersUpdated() throws RestApiException {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ProjectWatchInfo projectWatchInfo = new ProjectWatchInfo();
+    projectWatchInfo.project = project.get();
+    projectWatchInfo.notifyAllComments = true;
+    gApi.accounts().self().setWatchedProjects(ImmutableList.of(projectWatchInfo));
+
+    AccountState updatedState1 = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
+
+    gApi.accounts().self().deleteWatchedProjects(ImmutableList.of(projectWatchInfo));
+
+    AccountState updatedState2 = accountCache.get(admin.id()).get();
+    assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
+  }
+
+  @Test
+  public void updateExternalId_externalIdApiUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    gApi.accounts().self().addEmail(newEmailInput("secondary@google.com"));
+    assertExternalIds(
+        admin.id(),
+        ImmutableSet.of(
+            "mailto:admin@example.com", "username:admin", "mailto:secondary@google.com"));
+
+    AccountState updatedState1 = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState1.account().metaId());
+
+    gApi.accounts().self().deleteExternalIds(ImmutableList.of("mailto:secondary@google.com"));
+
+    AccountState updatedState2 = accountCache.get(admin.id()).get();
+    assertThat(updatedState1.account().metaId()).isNotEqualTo(updatedState2.account().metaId());
+  }
+
+  @Test
+  public void addExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId = externalIdFactory.create("custom", "value", admin.id());
+    accountsUpdateProvider
+        .get()
+        .update("Add External ID", admin.id(), (a, u) -> u.addExternalId(externalId));
+    assertExternalIds(
+        admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:value"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void deleteExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId = externalIdFactory.create("mailto", "admin@example.com", admin.id());
+    accountsUpdateProvider
+        .get()
+        .update("Remove External ID", admin.id(), (a, u) -> u.deleteExternalId(externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void updateExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId =
+        externalIdFactory.createWithEmail(
+            SCHEME_USERNAME, "admin", admin.id(), "secondary@example.com");
+    accountsUpdateProvider
+        .get()
+        .update("Remove External ID", admin.id(), (a, u) -> u.updateExternalId(externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void replaceExternalId_accountUpdate_refsUsersUpdated() throws Exception {
+    AccountState preUpdateState = accountCache.get(admin.id()).get();
+    requestScopeOperations.setApiUser(admin.id());
+
+    ExternalId externalId =
+        externalIdFactory.createWithEmail(
+            SCHEME_USERNAME, "admin", admin.id(), "secondary@example.com");
+    accountsUpdateProvider
+        .get()
+        .update(
+            "Remove External ID",
+            admin.id(),
+            (a, u) ->
+                u.replaceExternalId(
+                    externalIds.get(externalIdKeyFactory.create(SCHEME_USERNAME, "admin")).get(),
+                    externalId));
+    assertExternalIds(admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin"));
+
+    AccountState updatedState = accountCache.get(admin.id()).get();
+    assertThat(preUpdateState.account().metaId()).isNotEqualTo(updatedState.account().metaId());
+  }
+
+  @Test
+  public void accountUpdate_updateBatch_allUsersExternalIdsUpdated_refsUsersUpdated()
+      throws Exception {
+    AccountState preUpdateAdminState = accountCache.get(admin.id()).get();
+    AccountState preUpdateUserState = accountCache.get(user.id()).get();
+
+    requestScopeOperations.setApiUser(admin.id());
+    ExternalId extId1 =
+        externalIdFactory.createWithEmail("custom", "admin-id", admin.id(), "admin-id@test.com");
+
+    ExternalId extId2 =
+        externalIdFactory.createWithEmail("custom", "user-id", user.id(), "user-id@test.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    }
+    accountIndexedCounter.assertReindexOf(admin.id(), 1);
+    accountIndexedCounter.assertReindexOf(user.id(), 1);
+
+    assertExternalIds(
+        admin.id(),
+        ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:admin-id"));
+    assertExternalIds(
+        user.id(), ImmutableSet.of("username:user1", "mailto:user1@example.com", "custom:user-id"));
+    // Assert reindexing has worked on the updated accounts.
+    assertThat(
+            Iterables.getOnlyElement(gApi.accounts().query("admin-id@test.com").get())._accountId)
+        .isEqualTo(admin.id().get());
+    assertThat(Iterables.getOnlyElement(gApi.accounts().query("user-id@test.com").get())._accountId)
+        .isEqualTo(user.id().get());
+    AccountState updatedAdminState = accountCache.get(admin.id()).get();
+    AccountState updatedUserState = accountCache.get(user.id()).get();
+    assertThat(preUpdateAdminState.account().metaId())
+        .isNotEqualTo(updatedAdminState.account().metaId());
+    assertThat(preUpdateUserState.account().metaId())
+        .isNotEqualTo(updatedUserState.account().metaId());
+  }
+
+  @Test
+  public void accountUpdate_updateBatch_someUsersExternalIdsUpdated_refsUsersUpdated()
+      throws Exception {
+    AccountState preUpdateAdminState = accountCache.get(admin.id()).get();
+    AccountState preUpdateUserState = accountCache.get(user.id()).get();
+
+    requestScopeOperations.setApiUser(admin.id());
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Update Display Name", admin.id(), (a, u) -> u.setDisplayName("DN"));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Remove external Id",
+            user.id(),
+            (a, u) ->
+                u.deleteExternalId(
+                    externalIdFactory.createWithEmail(
+                        SCHEME_MAILTO, user.email(), user.id(), user.email())));
+    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+      accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    }
+    accountIndexedCounter.assertReindexOf(admin.id(), 1);
+    accountIndexedCounter.assertReindexOf(user.id(), 1);
+
+    // Only the version in config of the user with external id update was updated.
+    AccountState updatedAdminState = accountCache.get(admin.id()).get();
+    AccountState updatedUserState = accountCache.get(user.id()).get();
+    assertThat(preUpdateAdminState.account().metaId())
+        .isNotEqualTo(updatedAdminState.account().metaId());
+    assertThat(preUpdateUserState.account().metaId())
+        .isNotEqualTo(updatedUserState.account().metaId());
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void accountsCanSeeEachOtherThroughASharedExternalGroupOnlyWhenTheGroupIsMentionedInAcls()
+      throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // user and user2 cannot see each other because they do not share a Gerrit internal group
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+        .isFalse();
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+        .isFalse();
+
+    // Configure an external group backend that has a single group that contains all users.
+    TestGroupBackend testGroupBackend = createTestGroupBackendWithAllUsersGroup("AllUsers");
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(testGroupBackend)) {
+      // user and user2 cannot see each other although the external AllUsers group contains both
+      // users. That's because this group is not detected as relevant and hence its memberships are
+      // not checked.
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+          .isFalse();
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+          .isFalse();
+
+      // Add ACL for the external group.
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(
+              TestProjectUpdate.allowLabel("Code-Review")
+                  .range(0, 1)
+                  .ref("refs/heads/*")
+                  .group(AccountGroup.uuid(TestGroupBackend.PREFIX + "AllUsers"))
+                  .build())
+          .update();
+
+      // user and user2 can now see each other because the external AllUsers group that contains
+      // both users is guessed as relevant now that permissions are assigned to this group.
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+          .isTrue();
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+          .isTrue();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @GerritConfig(name = "groups.relevantGroup", value = "testbackend:AllUsers")
+  public void accountsCanSeeEachOtherThroughASharedExternalGroupThatIsConfiguredAsRelevant()
+      throws Exception {
+    TestAccount user2 = accountCreator.user2();
+
+    // user and user2 cannot see each other because they do not share a Gerrit internal group
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+        .isFalse();
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+        .isFalse();
+
+    // Check that the configured relevant group is included into the guessed groups.
+    assertThat(projectCache.guessRelevantGroupUUIDs())
+        .contains(AccountGroup.uuid("testbackend:AllUsers"));
+
+    // Configure an external group backend that has a single group that contains all users.
+    TestGroupBackend testGroupBackend = createTestGroupBackendWithAllUsersGroup("AllUsers");
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(testGroupBackend)) {
+      // user and user2 can see each other since the external AllUsers that contains both users has
+      // been configured as a relevant group.
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+          .isTrue();
+      assertThat(
+              accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+          .isTrue();
+    }
+  }
+
+  private TestGroupBackend createTestGroupBackendWithAllUsersGroup(String nameOfAllUsersGroup)
+      throws IOException {
+    TestGroupBackend testGroupBackend = new TestGroupBackend();
+
+    AccountGroup.UUID allUsersGroupUuid =
+        testGroupBackend.create(nameOfAllUsersGroup).getGroupUUID();
+
+    GroupMembership testGroupMembership =
+        new GroupMembership() {
+          @Override
+          public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> groupUuids) {
+            return StreamSupport.stream(groupUuids.spliterator(), /* parallel= */ false)
+                .filter(this::contains)
+                .collect(toSet());
+          }
+
+          @Override
+          public Set<AccountGroup.UUID> getKnownGroups() {
+            // Typically for external group backends it's too expensive to query all groups that the
+            // user is a member of. Instead limit the group membership check to groups that are
+            // guessed to be relevant.
+            return projectCache.guessRelevantGroupUUIDs().stream()
+                // filter out groups of other group backends and groups of this group backend that
+                // don't exist
+                .filter(
+                    uuid -> testGroupBackend.handles(uuid) && testGroupBackend.get(uuid) != null)
+                .collect(toImmutableSet());
+          }
+
+          @Override
+          public boolean containsAnyOf(Iterable<AccountGroup.UUID> groupUuids) {
+            return StreamSupport.stream(groupUuids.spliterator(), /* parallel= */ false)
+                .anyMatch(this::contains);
+          }
+
+          @Override
+          public boolean contains(AccountGroup.UUID groupUuid) {
+            return allUsersGroupUuid.equals(groupUuid);
+          }
+        };
+
+    accounts
+        .allIds()
+        .forEach(accountId -> testGroupBackend.setMembershipsOf(accountId, testGroupMembership));
+
+    return testGroupBackend;
+  }
+
+  private void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
+      throws Exception {
+    assertThat(
+            gApi.accounts().id(accountId.get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toImmutableSet()))
+        .isEqualTo(extIds);
+  }
+
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
     return NullAwareCorrespondence.transforming(groupInfo -> groupInfo.name, "has name");
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index 5279ba1..9b77b01 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -71,7 +71,7 @@
       config.updateProject(
           p -> p.setBooleanConfig(BooleanProjectConfig.USE_CONTRIBUTOR_AGREEMENTS, value));
       config.commit(md);
-      projectCache.evict(config.getProject());
+      projectCache.evictAndReindex(config.getProject());
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index aa8615b..f5b311b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -62,6 +62,7 @@
             new MenuItem("Edits", "#/q/has:edit", null),
             new MenuItem("Watched Changes", "#/q/is:watched+is:open", null),
             new MenuItem("Starred Changes", "#/q/is:starred", null),
+            new MenuItem("All Visible Changes", "#/q/is:visible", null),
             new MenuItem("Groups", "#/settings/#Groups", null));
     assertThat(o.changeTable).isEmpty();
 
@@ -83,6 +84,7 @@
     i.legacycidInChangeTable ^= true;
     i.muteCommonPathPrefixes ^= true;
     i.signedOffBy ^= true;
+    i.allowBrowserNotifications ^= false;
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
     i.my.add(new MenuItem("name", "url"));
@@ -94,6 +96,7 @@
     assertThat(o.my).containsExactlyElementsIn(i.my);
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
     assertThat(o.theme).isEqualTo(i.theme);
+    assertThat(o.allowBrowserNotifications).isEqualTo(i.allowBrowserNotifications);
     assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
new file mode 100644
index 0000000..898e1ff
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -0,0 +1,409 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.testing.GitPersonSubject;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import org.eclipse.jgit.api.errors.PatchApplyException;
+import org.eclipse.jgit.util.Base64;
+import org.junit.Test;
+
+public class ApplyPatchIT extends AbstractDaemonTest {
+
+  private static final String COMMIT_MESSAGE = "Applying patch";
+  private static final String DESTINATION_BRANCH = "destBranch";
+
+  private static final String ADDED_FILE_NAME = "a_new_file.txt";
+  private static final String ADDED_FILE_CONTENT = "First added line\nSecond added line\n";
+  private static final String ADDED_FILE_DIFF =
+      "diff --git a/a_new_file.txt b/a_new_file.txt\n"
+          + "new file mode 100644\n"
+          + "--- /dev/null\n"
+          + "+++ b/a_new_file.txt\n"
+          + "@@ -0,0 +1,2 @@\n"
+          + "+First added line\n"
+          + "+Second added line\n";
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void applyAddedFilePatch_success() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo diff = fetchDiffForFile(result, ADDED_FILE_NAME);
+    assertDiffForNewFile(diff, result.currentRevision, ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+  }
+
+  private static final String MODIFIED_FILE_NAME = "modified_file.txt";
+  private static final String MODIFIED_FILE_ORIGINAL_CONTENT =
+      "First original line\nSecond original line";
+  private static final String MODIFIED_FILE_NEW_CONTENT = "Modified line\n";
+  private static final String MODIFIED_FILE_DIFF =
+      "diff --git a/modified_file.txt b/modified_file.txt\n"
+          + "new file mode 100644\n"
+          + "--- a/modified_file.txt\n"
+          + "+++ b/modified_file.txt\n"
+          + "@@ -1,2 +1 @@\n"
+          + "-First original line\n"
+          + "-Second original line\n"
+          + "+Modified line\n";
+
+  @Test
+  public void applyModifiedFilePatch_success() throws Exception {
+    initBaseWithFile(MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
+    ApplyPatchPatchSetInput in = buildInput(MODIFIED_FILE_DIFF);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo diff = fetchDiffForFile(result, MODIFIED_FILE_NAME);
+    assertDiffForFullyModifiedFile(
+        diff,
+        result.currentRevision,
+        MODIFIED_FILE_NAME,
+        MODIFIED_FILE_ORIGINAL_CONTENT,
+        MODIFIED_FILE_NEW_CONTENT);
+  }
+
+  @Test
+  public void applyDeletedFilePatch_success() throws Exception {
+    final String deletedFileName = "deleted_file.txt";
+    final String deletedFileOriginalContent = "content to be deleted.\n";
+    final String deletedFileDiff =
+        "diff --git a/deleted_file.txt b/deleted_file.txt\n"
+            + "--- a/deleted_file.txt\n"
+            + "+++ /dev/null\n";
+    initBaseWithFile(deletedFileName, deletedFileOriginalContent);
+    ApplyPatchPatchSetInput in = buildInput(deletedFileDiff);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo diff = fetchDiffForFile(result, deletedFileName);
+    assertDiffForDeletedFile(diff, deletedFileName, deletedFileOriginalContent);
+  }
+
+  @Test
+  public void applyRenamedFilePatch_success() throws Exception {
+    final String renamedFileOriginalName = "renamed_file_origin.txt";
+    final String renamedFileNewName = "renamed_file_new.txt";
+    final String renamedFileDiff =
+        "diff --git a/renamed_file_origin.txt b/renamed_file_new.txt\n"
+            + "rename from renamed_file_origin.txt\n"
+            + "rename to renamed_file_new.txt\n"
+            + "--- a/renamed_file_origin.txt\n"
+            + "+++ b/renamed_file_new.txt\n"
+            + "@@ -1,2 +1 @@\n"
+            + "-First original line\n"
+            + "-Second original line\n"
+            + "+Modified line\n";
+    initBaseWithFile(renamedFileOriginalName, MODIFIED_FILE_ORIGINAL_CONTENT);
+    ApplyPatchPatchSetInput in = buildInput(renamedFileDiff);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo originalFileDiff = fetchDiffForFile(result, renamedFileOriginalName);
+    assertDiffForDeletedFile(
+        originalFileDiff, renamedFileOriginalName, MODIFIED_FILE_ORIGINAL_CONTENT);
+    DiffInfo newFileDiff = fetchDiffForFile(result, renamedFileNewName);
+    assertDiffForNewFile(
+        newFileDiff, result.currentRevision, renamedFileNewName, MODIFIED_FILE_NEW_CONTENT);
+  }
+
+  @Test
+  public void applyGerritBasedPatchWithSingleFile_success() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result baseCommit = createChange("Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+    baseCommit.assertOkStatus();
+    BinaryResult originalPatch = gApi.changes().id(baseCommit.getChangeId()).current().patch();
+    ApplyPatchPatchSetInput in = buildInput(originalPatch.asString());
+    createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+
+    ChangeInfo result = applyPatch(in);
+
+    BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
+    assertThat(removeHeader(resultPatch)).isEqualTo(removeHeader(originalPatch));
+  }
+
+  @Test
+  public void applyGerritBasedPatchWithMultipleFiles_success() throws Exception {
+    PushOneCommit.Result commonBaseCommit =
+        createChange("File for modification", MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
+    commonBaseCommit.assertOkStatus();
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result commitToPatch =
+        createChange("Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+    amendChange(
+        commitToPatch.getChangeId(), "Modify file", MODIFIED_FILE_NAME, MODIFIED_FILE_NEW_CONTENT);
+    commitToPatch.assertOkStatus();
+    BinaryResult originalPatch = gApi.changes().id(commitToPatch.getChangeId()).current().patch();
+    ApplyPatchPatchSetInput in = buildInput(originalPatch.asString());
+    createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+
+    ChangeInfo result = applyPatch(in);
+
+    BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
+    assertThat(removeHeader(resultPatch)).isEqualTo(removeHeader(originalPatch));
+  }
+
+  @Test
+  public void applyGerritBasedPatchUsingRest_success() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result baseCommit = createChange("Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+    baseCommit.assertOkStatus();
+    createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + baseCommit.getChangeId() + "/revisions/current/patch");
+    patchResp.assertOK();
+    String originalPatch = new String(Base64.decode(patchResp.getEntityContent()), UTF_8);
+    ApplyPatchPatchSetInput in = buildInput(originalPatch);
+    PushOneCommit.Result destChange = createChange();
+
+    RestResponse resp =
+        adminRestSession.post("/changes/" + destChange.getChangeId() + "/patch:apply", in);
+
+    resp.assertOK();
+    BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
+    assertThat(removeHeader(resultPatch)).isEqualTo(removeHeader(originalPatch));
+  }
+
+  @Test
+  public void applyPatchWithConflict_fails() throws Exception {
+    initBaseWithFile(MODIFIED_FILE_NAME, "Unexpected base content");
+    ApplyPatchPatchSetInput in = buildInput(MODIFIED_FILE_DIFF);
+
+    Throwable error = assertThrows(RestApiException.class, () -> applyPatch(in));
+
+    assertThat(error).hasMessageThat().contains("Cannot apply patch");
+    assertThat(error).hasCauseThat().isInstanceOf(PatchApplyException.class);
+    assertThat(error).hasCauseThat().hasMessageThat().contains("Cannot apply: HunkHeader");
+  }
+
+  @Test
+  public void applyPatchWithoutAddPatchSetPermissions_fails() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(
+            permissionKey(Permission.ADD_PATCH_SET)
+                .ref("refs/for/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+    PushOneCommit.Result destChange = createChange("dest change", "a file", "with content");
+    // Add-patch is always allowed for the change owner, so we need to use another account.
+    requestScopeOperations.setApiUser(accountCreator.user2().id());
+
+    Throwable error =
+        assertThrows(
+            AuthException.class, () -> gApi.changes().id(destChange.getChangeId()).applyPatch(in));
+
+    assertThat(error).hasMessageThat().contains("patch set");
+  }
+
+  @Test
+  public void applyPatchWithCustomMessage_success() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.commitMessage = "custom commit message";
+
+    ChangeInfo result = applyPatch(in);
+
+    assertThat(gApi.changes().id(result.id).current().commit(false).message)
+        .contains(in.commitMessage);
+  }
+
+  @Test
+  public void applyPatchWithBaseCommit_success() throws Exception {
+    PushOneCommit.Result baseCommit =
+        createChange("base commit", MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
+    baseCommit.assertOkStatus();
+    PushOneCommit.Result ignoredCommit =
+        createChange("Ignored file modification", MODIFIED_FILE_NAME, "Ignored file modification");
+    ignoredCommit.assertOkStatus();
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(MODIFIED_FILE_DIFF);
+    in.base = baseCommit.getCommit().getName();
+
+    ChangeInfo result = applyPatch(in);
+
+    assertThat(gApi.changes().id(result.id).current().commit(false).parents.get(0).commit)
+        .isEqualTo(in.base);
+  }
+
+  @Test
+  public void applyPatchWithDefaultAuthor_success() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+
+    ChangeInfo result = applyPatch(in);
+
+    GitPerson person = gApi.changes().id(result.id).current().commit(false).author;
+    GitPersonSubject.assertThat(person).email().isEqualTo(admin.email());
+  }
+
+  @Test
+  public void applyPatchWithAuthorOverrideMissingEmail_throwsIllegalArgument() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.author = new AccountInput();
+    in.author.name = "name";
+
+    Throwable error = assertThrows(IllegalArgumentException.class, () -> applyPatch(in));
+
+    assertThat(error).hasMessageThat().contains("E-mail");
+  }
+
+  @Test
+  public void applyPatchWithAuthorOverrideMissingName_throwsIllegalArgument() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.author = new AccountInput();
+    in.author.name = null;
+    in.author.email = "gerritlessjane@invalid";
+
+    Throwable error = assertThrows(IllegalArgumentException.class, () -> applyPatch(in));
+
+    assertThat(error).hasMessageThat().contains("Name");
+  }
+
+  @Test
+  public void applyPatchWithAuthorOverride_success() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.author = new AccountInput();
+    in.author.email = "gerritlessjane@invalid";
+    // This is an email address that doesn't exist as account on the Gerrit server.
+    in.author.name = "Gerritless Jane";
+
+    ChangeInfo result = applyPatch(in);
+
+    RevisionApi rApi = gApi.changes().id(result.id).current();
+    GitPerson author = rApi.commit(false).author;
+    GitPersonSubject.assertThat(author).email().isEqualTo(in.author.email);
+    GitPersonSubject.assertThat(author).name().isEqualTo(in.author.name);
+    GitPerson committer = rApi.commit(false).committer;
+    GitPersonSubject.assertThat(committer).email().isEqualTo(admin.getNameEmail().email());
+  }
+
+  @Test
+  public void applyPatchWithAuthorWithoutPermissions_fails() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.author = new AccountInput();
+    in.author.name = "Jane";
+    in.author.email = "jane@invalid";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    Throwable error = assertThrows(ResourceConflictException.class, () -> applyPatch(in));
+
+    assertThat(error).hasMessageThat().contains("forge author");
+  }
+
+  @Test
+  public void applyPatchWithSelfAsForgedAuthor_success() throws Exception {
+    initDestBranch();
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.author = new AccountInput();
+    in.author.name = admin.fullName();
+    in.author.email = admin.email();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    ChangeInfo result = applyPatch(in);
+
+    GitPerson person = gApi.changes().id(result.id).current().commit(false).author;
+    GitPersonSubject.assertThat(person).email().isEqualTo(admin.email());
+  }
+
+  private void initDestBranch() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, ApplyPatchIT.DESTINATION_BRANCH), head);
+  }
+
+  private void initBaseWithFile(String fileName, String fileContent) throws Exception {
+    PushOneCommit.Result baseCommit =
+        createChange("Add original file: " + fileName, fileName, fileContent);
+    baseCommit.assertOkStatus();
+    initDestBranch();
+  }
+
+  private ApplyPatchPatchSetInput buildInput(String patch) {
+    ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+    in.patch = new ApplyPatchInput();
+    in.patch.patch = patch;
+    return in;
+  }
+
+  private ChangeInfo applyPatch(ApplyPatchPatchSetInput input) throws RestApiException {
+    return gApi.changes()
+        .create(new ChangeInput(project.get(), DESTINATION_BRANCH, COMMIT_MESSAGE))
+        .applyPatch(input);
+  }
+
+  private DiffInfo fetchDiffForFile(ChangeInfo result, String fileName) throws RestApiException {
+    return gApi.changes().id(result.id).current().file(fileName).diff();
+  }
+
+  private String removeHeader(BinaryResult bin) throws IOException {
+    return removeHeader(bin.asString());
+  }
+
+  private String removeHeader(String s) {
+    return s.substring(s.indexOf("\ndiff --git"), s.length() - 1);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/BUILD b/javatests/com/google/gerrit/acceptance/api/change/BUILD
index 94fb0dc..306852a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/change/BUILD
@@ -6,5 +6,8 @@
     labels = [
         "api",
     ],
-    deps = ["//java/com/google/gerrit/server/util/time"],
+    deps = [
+        "//java/com/google/gerrit/server/util/time",
+        "//javatests/com/google/gerrit/acceptance/server/change:util",
+    ],
 ) for f in glob(["*IT.java"])]
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 58c222a..215d1e8 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.acceptance.api.change;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -28,9 +28,11 @@
 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.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.extensions.client.ChangeStatus.MERGED;
 import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CHECK;
@@ -46,7 +48,6 @@
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -69,7 +70,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.MoreCollectors;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
@@ -83,33 +83,30 @@
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.api.change.ChangeIT.TestAttentionSetListenerModule.TestAttentionSetListener;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.server.change.CommentsUtil;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.BooleanProjectConfig;
 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.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.entities.SubmitRecord;
-import com.google.gerrit.entities.SubmitRequirement;
-import com.google.gerrit.entities.SubmitRequirementExpression;
-import com.google.gerrit.entities.SubmitRequirementExpressionResult;
-import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
@@ -120,9 +117,8 @@
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
-import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
@@ -149,40 +145,31 @@
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.common.SubmitRecordInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementExpressionInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementInput;
-import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
-import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
-import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.events.AttentionSetListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 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;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.IntraLineDiff;
@@ -191,7 +178,6 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeOperatorFactory;
 import com.google.gerrit.server.restapi.change.PostReview;
-import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -201,13 +187,14 @@
 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.text.MessageFormat;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -216,6 +203,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
@@ -241,6 +229,8 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private IndexOperations.Change changeIndexOperations;
+  @Inject private AccountControl.Factory accountControlFactory;
 
   @Inject
   @Named("diff_intraline")
@@ -422,6 +412,31 @@
   }
 
   @Test
+  public void setReadyForReviewSendsNotificationsForRevertChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+    RevertInput in = new RevertInput();
+    in.workInProgress = true;
+    String changeId = gApi.changes().id(r.getChangeId()).revert(in).get().changeId;
+    requestScopeOperations.setApiUser(admin.id());
+
+    gApi.changes().id(changeId).setReadyForReview();
+
+    assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+    // expected messages on source change:
+    // 1. Uploaded patch set 1.
+    // 2. Patch Set 1: Code-Review+2
+    // 3. Change has been successfully merged by Administrator
+    // 4. Patch Set 1: Reverted
+    List<ChangeMessageInfo> sourceMessages =
+        new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(sourceMessages).hasSize(4);
+    String expectedMessage = String.format("Created a revert of this change as I%s", changeId);
+    assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
+  }
+
+  @Test
   public void hasReviewStarted() throws Exception {
     PushOneCommit.Result r = createWorkInProgressChange();
     String changeId = r.getChangeId();
@@ -754,278 +769,6 @@
     assertThat(thrown).hasMessageThat().contains("Multiple changes found for " + changeId);
   }
 
-  @FunctionalInterface
-  private interface Rebase {
-    void call(String id) throws RestApiException;
-  }
-
-  @Test
-  public void rebaseViaRevisionApi() throws Exception {
-    testRebase(id -> gApi.changes().id(id).current().rebase());
-  }
-
-  @Test
-  public void rebaseViaChangeApi() throws Exception {
-    testRebase(id -> gApi.changes().id(id).rebase());
-  }
-
-  private void testRebase(Rebase rebase) throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Add an approval whose score should be copied on trivial rebase
-    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
-
-    String changeId = r2.getChangeId();
-    // Rebase the second change
-    rebase.call(changeId);
-
-    // Second change should have 2 patch sets and an approval
-    ChangeInfo c2 = gApi.changes().id(changeId).get(CURRENT_REVISION, DETAILED_LABELS);
-    assertThat(c2.revisions.get(c2.currentRevision)._number).isEqualTo(2);
-
-    // ...and the committer and description should be correct
-    ChangeInfo info = gApi.changes().id(changeId).get(CURRENT_REVISION, CURRENT_COMMIT);
-    GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
-    assertThat(committer.name).isEqualTo(admin.fullName());
-    assertThat(committer.email).isEqualTo(admin.email());
-    String description = info.revisions.get(info.currentRevision).description;
-    assertThat(description).isEqualTo("Rebase");
-
-    // ...and the approval was copied
-    LabelInfo cr = c2.labels.get(LabelId.CODE_REVIEW);
-    assertThat(cr).isNotNull();
-    assertThat(cr.all).hasSize(1);
-    assertThat(cr.all.get(0).value).isEqualTo(1);
-
-    // Rebasing the second change again should fail
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(changeId).current().rebase());
-    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
-  }
-
-  @Test
-  public void rebaseAsUploaderInAttentionSet() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    TestAccount admin2 = accountCreator.admin2();
-    requestScopeOperations.setApiUser(admin2.id());
-    amendChangeWithUploader(r2, project, admin2);
-    gApi.changes()
-        .id(r2.getChangeId())
-        .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
-
-    gApi.changes().id(r2.getChangeId()).rebase();
-  }
-
-  @Test
-  public void rebaseOnChangeNumber() throws Exception {
-    String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
-
-    Change.Id id1 = r1.getChange().getId();
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r2.getChangeId()).rebase(in);
-
-    Change.Id id2 = r2.getChange().getId();
-    ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    List<RelatedChangeAndCommitInfo> related =
-        gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
-    assertThat(related).hasSize(2);
-    assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
-    assertThat(related.get(0)._revisionNumber).isEqualTo(2);
-    assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
-    assertThat(related.get(1)._revisionNumber).isEqualTo(1);
-  }
-
-  @Test
-  public void rebaseOnClosedChange() throws Exception {
-    String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
-
-    // Submit first change.
-    Change.Id id1 = r1.getChange().getId();
-    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id1.get()).current().submit();
-
-    // Rebase second change on first change.
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r2.getChangeId()).rebase(in);
-
-    Change.Id id2 = r2.getChange().getId();
-    ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    ri2 = ci2.revisions.get(ci2.currentRevision);
-    assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
-  }
-
-  @Test
-  public void rebaseOnNonExistingChange() throws Exception {
-    String changeId = createChange().getChangeId();
-    RebaseInput in = new RebaseInput();
-    in.base = "999999";
-    UnprocessableEntityException exception =
-        assertThrows(
-            UnprocessableEntityException.class, () -> gApi.changes().id(changeId).rebase(in));
-    assertThat(exception).hasMessageThat().isEqualTo("Base change not found: " + in.base);
-  }
-
-  @Test
-  public void rebaseFromRelationChainToClosedChange() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    testRepo.reset("HEAD~1");
-
-    createChange();
-    PushOneCommit.Result r3 = createChange();
-
-    // Submit first change.
-    Change.Id id1 = r1.getChange().getId();
-    gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
-    gApi.changes().id(id1.get()).current().submit();
-
-    // Rebase third change on first change.
-    RebaseInput in = new RebaseInput();
-    in.base = id1.toString();
-    gApi.changes().id(r3.getChangeId()).rebase(in);
-
-    Change.Id id3 = r3.getChange().getId();
-    ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
-    RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
-    assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
-    assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
-  }
-
-  @Test
-  public void rebaseNotAllowedWithoutPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseAllowedWithPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).rebase();
-  }
-
-  @Test
-  public void rebaseNotAllowedWithoutPushPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
-        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
-  @Test
-  public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Approve and submit the first change
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    revision.review(ReviewInput.approve());
-    revision.submit();
-
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
-        .update();
-
-    // Rebase the second
-    String changeId = r2.getChangeId();
-    AuthException thrown =
-        assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
-    assertThat(thrown).hasMessageThat().contains("rebase not permitted");
-  }
-
   @Test
   public void deleteNewChangeAsAdmin() throws Exception {
     deleteChangeAsUser(admin, admin);
@@ -1358,266 +1101,35 @@
   }
 
   @Test
-  public void rebaseUpToDateChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase());
-    assertThat(thrown).hasMessageThat().contains("Change is already up to date");
-  }
-
-  @Test
-  public void rebaseConflict() throws Exception {
+  public void attentionSetListener_firesOnChange() throws Exception {
     PushOneCommit.Result r1 = createChange();
-    gApi.changes()
-        .id(r1.getChangeId())
-        .revision(r1.getCommit().name())
-        .review(ReviewInput.approve());
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+    AttentionSetInput addUser = new AttentionSetInput(user.email(), "some reason");
+    TestAttentionSetListener attentionSetListener = new TestAttentionSetListener();
 
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "other content",
-            "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
-    PushOneCommit.Result r2 = push.to("refs/for/master");
-    r2.assertOkStatus();
-    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();
+        extensionRegistry.newRegistration().add(attentionSetListener)) {
+
+      gApi.changes().id(r1.getChangeId()).addReviewer(user.email());
+      assertThat(attentionSetListener.firedCount).isEqualTo(1);
+      assertThat(attentionSetListener.lastEvent.usersAdded().size()).isEqualTo(1);
+      attentionSetListener
+          .lastEvent
+          .usersAdded()
+          .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
+
+      gApi.changes().id(r1.getChangeId()).addToAttentionSet(addUser);
+      assertThat(attentionSetListener.firedCount).isEqualTo(1);
+
+      gApi.changes().id(r1.getChangeId()).attention(user.username()).remove(addUser);
+
+      assertThat(attentionSetListener.firedCount).isEqualTo(2);
+      assertThat(attentionSetListener.lastEvent.usersAdded()).isEmpty();
+      assertThat(attentionSetListener.lastEvent.usersRemoved().size()).isEqualTo(1);
+      attentionSetListener
+          .lastEvent
+          .usersRemoved()
+          .forEach(u -> assertThat(u).isEqualTo(user.id().get()));
     }
-    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
-  public void rebaseChangeBase() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-    PushOneCommit.Result r3 = createChange();
-    RebaseInput ri = new RebaseInput();
-
-    // rebase r3 directly onto master (break dep. towards r2)
-    ri.base = "";
-    gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
-    PatchSet ps3 = r3.getPatchSet();
-    assertThat(ps3.id().get()).isEqualTo(2);
-
-    // rebase r2 onto r3 (referenced by ref)
-    ri.base = ps3.id().toRefName();
-    gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
-    PatchSet ps2 = r2.getPatchSet();
-    assertThat(ps2.id().get()).isEqualTo(2);
-
-    // rebase r1 onto r2 (referenced by commit)
-    ri.base = ps2.commitId().name();
-    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
-    PatchSet ps1 = r1.getPatchSet();
-    assertThat(ps1.id().get()).isEqualTo(2);
-
-    // rebase r1 onto r3 (referenced by change number)
-    ri.base = String.valueOf(r3.getChange().getId().get());
-    gApi.changes().id(r1.getChangeId()).revision(ps1.commitId().name()).rebase(ri);
-    assertThat(r1.getPatchSetId().get()).isEqualTo(3);
-  }
-
-  @Test
-  public void rebaseChangeBaseRecursion() throws Exception {
-    PushOneCommit.Result r1 = createChange();
-    PushOneCommit.Result r2 = createChange();
-
-    RebaseInput ri = new RebaseInput();
-    ri.base = r2.getCommit().name();
-    String expectedMessage =
-        "base change "
-            + r2.getChangeId()
-            + " is a descendant of the current change - recursion not allowed";
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains(expectedMessage);
-  }
-
-  @Test
-  public void rebaseAbandonedChange() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = info(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).revision(r.getCommit().name()).rebase());
-    assertThat(thrown).hasMessageThat().contains("change is abandoned");
-  }
-
-  @Test
-  public void rebaseOntoAbandonedChange() throws Exception {
-    // Create two changes both with the same parent
-    PushOneCommit.Result r = createChange();
-    testRepo.reset("HEAD~1");
-    PushOneCommit.Result r2 = createChange();
-
-    // Abandon the first change
-    String changeId = r.getChangeId();
-    assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
-    gApi.changes().id(changeId).abandon();
-    ChangeInfo info = info(changeId);
-    assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
-    RebaseInput ri = new RebaseInput();
-    ri.base = r.getCommit().name();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
-  }
-
-  @Test
-  public void rebaseOntoSelf() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    String commit = r.getCommit().name();
-    RebaseInput ri = new RebaseInput();
-    ri.base = commit;
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> gApi.changes().id(changeId).revision(commit).rebase(ri));
-    assertThat(thrown).hasMessageThat().contains("cannot rebase change onto itself");
   }
 
   @Test
@@ -1926,7 +1438,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     addReviewer.call(r.getChangeId(), user.email());
 
@@ -2037,7 +1549,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "ab" with one user: testUser
     String email = "abcd@example.com";
@@ -2086,7 +1598,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     // create a group named "kobe" with one user: lee
     String testUserFullname = "kobebryant";
@@ -2187,7 +1699,7 @@
     PushOneCommit.Result r = createChange();
     ChangeResource rsrc = parseResource(r);
     String oldETag = rsrc.getETag();
-    Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
+    Instant oldTs = rsrc.getChange().getLastUpdatedOn();
 
     ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
@@ -2487,6 +1999,36 @@
   }
 
   @Test
+  public void removeChangeOwnerAsReviewerByDelete() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // vote on the change so that the change owner becomes a reviewer
+    approve(changeId);
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER)))
+        .containsExactly(admin.id());
+
+    gApi.changes().id(changeId).reviewer(admin.id().toString()).remove();
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER))).isEmpty();
+  }
+
+  @Test
+  public void removeChangeOwnerAsReviewerByPostReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // vote on the change so that the change owner becomes a reviewer
+    approve(changeId);
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER)))
+        .containsExactly(admin.id());
+
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewer(admin.id().toString(), ReviewerState.REMOVED, /* confirmed= */ false);
+    gApi.changes().id(changeId).current().review(reviewInput);
+    assertThat(getReviewers(gApi.changes().id(changeId).get().reviewers.get(REVIEWER))).isEmpty();
+  }
+
+  @Test
   public void removeCC() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -2583,6 +2125,78 @@
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void removeNonVisibleReviewer() throws Exception {
+    // allow all users to remove reviewers
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    AccountInfo reviewerInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewerInfo._accountId).isEqualTo(user.id().get());
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+
+    // user2 cannot see user
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+        .isFalse();
+
+    gApi.changes().id(changeId).reviewer(user.id().toString()).remove(new DeleteReviewerInput());
+    assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void removeNonVisibleReviewerThroughPostReview() throws Exception {
+    // allow all users to remove reviewers
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    AccountInfo reviewerInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER));
+    assertThat(reviewerInfo._accountId).isEqualTo(user.id().get());
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+
+    // user2 cannot see user
+    assertThat(
+            accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+        .isFalse();
+
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    reviewerInput.state = ReviewerState.REMOVED;
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewers = ImmutableList.of(reviewerInput);
+    ReviewResult reviewResult = gApi.changes().id(changeId).current().review(reviewInput);
+    assertThat(reviewResult.error).isNull();
+
+    // user is removed as a reviewer, user2 is added as a CC by doing the post review request that
+    // removed user as a reviewer
+    assertThat(gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER)).isNull();
+    reviewerInfo =
+        Iterables.getOnlyElement(gApi.changes().id(changeId).get().reviewers.get(ReviewerState.CC));
+    assertThat(reviewerInfo._accountId).isEqualTo(user2.id().get());
+  }
+
+  @Test
   public void removeReviewerNotPermitted() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
@@ -2713,6 +2327,27 @@
   }
 
   @Test
+  public void deleteVoteWithReason() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    sender.clear();
+    DeleteVoteInput in = new DeleteVoteInput();
+    in.label = LabelId.CODE_REVIEW;
+    in.reason = "Internal conflict resolved";
+    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).deleteVote(in);
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).messages()).message)
+        .isEqualTo(
+            "Removed Code-Review+1 by User1 <user1@example.com>\n"
+                + "\n"
+                + "Internal conflict resolved\n");
+  }
+
+  @Test
   public void deleteVoteNotifyAccount() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
@@ -2773,7 +2408,68 @@
                     .id(r.getChangeId())
                     .reviewer(admin.id().toString())
                     .deleteVote(LabelId.CODE_REVIEW));
-    assertThat(thrown).hasMessageThat().contains("delete vote not permitted");
+    assertThat(thrown).hasMessageThat().contains("Delete vote not permitted");
+  }
+
+  @Test
+  public void deleteVoteAlwaysPermittedForSelfVotes() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
+  }
+
+  @Test
+  public void deleteVoteAlwaysPermittedForAdmin() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
   }
 
   @Test
@@ -2831,6 +2527,53 @@
   }
 
   @Test
+  public void labelPermissionsChange_doesNotAffectCurrentVotes() throws Exception {
+    String heads = "refs/heads/*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS).range(-2, +2))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Approve the change as user
+    requestScopeOperations.setApiUser(user.id());
+    approve(changeId);
+    assertThat(
+            gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get("Code-Review").all.stream()
+                .collect(toImmutableMap(vote -> Account.id(vote._accountId), vote -> vote.value)))
+        .isEqualTo(ImmutableMap.of(user.id(), 2));
+
+    // Remove permissions for CODE_REVIEW. The user still has [-1,+1], inherited from All-Projects.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .remove(labelPermissionKey(LabelId.CODE_REVIEW).ref(heads).group(REGISTERED_USERS))
+        .update();
+
+    // No permissions to vote +2
+    assertThrows(AuthException.class, () -> approve(changeId));
+
+    assertThat(
+            get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
+                .map(vote -> vote.value))
+        .containsExactly(2);
+
+    // The change is still submittable
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(changeId).current().submit();
+    assertThat(info(changeId).status).isEqualTo(MERGED);
+
+    // The +2 vote out of permissions range is still present.
+    assertThat(
+            get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
+                .collect(toImmutableMap(vote -> Account.id(vote._accountId), vote -> vote.value)))
+        .isEqualTo(ImmutableMap.of(user.id(), 2, admin.id(), 0));
+  }
+
+  @Test
   public void createEmptyChange() throws Exception {
     ChangeInput in = new ChangeInput();
     in.branch = Constants.MASTER;
@@ -2885,7 +2628,7 @@
   }
 
   @Test
-  public void queryChangesNoLimit() throws Exception {
+  public void queryChangesNoLimitRegisteredUser() throws Exception {
     projectOperations
         .allProjectsForUpdate()
         .add(
@@ -2903,6 +2646,26 @@
   }
 
   @Test
+  public void queryChangesNoLimitIgnoredForAnonymousUser() throws Exception {
+    int limit = 2;
+    projectOperations
+        .allProjectsForUpdate()
+        .add(
+            allowCapability(GlobalCapability.QUERY_LIMIT)
+                .group(SystemGroupBackend.ANONYMOUS_USERS)
+                .range(0, limit))
+        .update();
+    for (int i = 0; i < 3; i++) {
+      createChange();
+    }
+    requestScopeOperations.setApiUserAnonymous();
+    List<ChangeInfo> resultsWithDefaultLimit = gApi.changes().query().get();
+    List<ChangeInfo> resultsWithNoLimit = gApi.changes().query().withNoLimit().get();
+    assertThat(resultsWithDefaultLimit).hasSize(limit);
+    assertThat(resultsWithNoLimit).hasSize(limit);
+  }
+
+  @Test
   public void queryChangesStart() throws Exception {
     PushOneCommit.Result r1 = createChange();
     createChange();
@@ -3002,6 +2765,40 @@
   }
 
   @Test
+  public void queryChangesDefaultFieldMatchesOwner() throws Exception {
+    // We have to create a new user since changes are not deleted between tests, which means
+    // querying the standard users will lead to dirty results.
+    TestAccount changeOwner = accountCreator.createValid("changeOwner");
+    requestScopeOperations.setApiUser(changeOwner.id());
+    // Creating a change through the API since PushOneCommit changes are always owned by admin().
+    ChangeInput in = new ChangeInput();
+    in.branch = Constants.MASTER;
+    in.subject = "subject";
+    in.project = project.get();
+    ChangeInfo info = gApi.changes().createAsInfo(in);
+    assertThat(info.owner._accountId).isEqualTo(changeOwner.id().get());
+    requestScopeOperations.setApiUser(user.id());
+    List<ChangeInfo> results = query(changeOwner.email());
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(info.changeId);
+  }
+
+  @Test
+  public void queryChangesDefaultFieldMatchesReviewer() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    // We have to create a new user since changes are not deleted between tests, which means
+    // querying the standard users will lead to dirty results.
+    TestAccount changeReviewer = accountCreator.createValid("changeReviewer");
+    gApi.changes().id(r.getChangeId()).addReviewer(changeReviewer.email());
+    requestScopeOperations.setApiUser(user.id());
+    List<ChangeInfo> results = query(changeReviewer.email());
+    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r.getChangeId());
+  }
+
+  @Test
   public void checkReviewedFlagBeforeAndAfterReview() throws Exception {
     PushOneCommit.Result r = createChange();
     ReviewerInput in = new ReviewerInput();
@@ -3072,17 +2869,14 @@
   public void submitStaleChange() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    disableChangeIndexWrites();
-    try {
+    try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
       r = amendChange(r.getChangeId());
-    } finally {
-      enableChangeIndexWrites();
     }
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
 
     gApi.changes().id(r.getChangeId()).current().submit();
-    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(MERGED);
   }
 
   @Test
@@ -3108,7 +2902,7 @@
         .update();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(gApi.changes().id(r.getChangeId()).info().status).isEqualTo(MERGED);
   }
 
   @Test
@@ -3244,6 +3038,18 @@
   }
 
   @Test
+  public void stableRevisionSort() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    r1.assertOkStatus();
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+    r2.assertOkStatus();
+
+    ChangeInfo actual = gApi.changes().id(r2.getChangeId()).get(ALL_REVISIONS, CURRENT_REVISION);
+    assertThat(actual.revisions).hasSize(2);
+    assertThat(actual.revisions.values().stream().map(r -> r._number)).isInOrder();
+  }
+
+  @Test
   public void defaultSearchDoesNotTouchDatabase() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
     PushOneCommit.Result r1 = createChange();
@@ -3253,7 +3059,11 @@
         .review(ReviewInput.approve());
     gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
 
-    createChange();
+    PushOneCommit.Result change = createChange();
+    // Populate change with a reasonable set of fields. We can't exhaustively
+    // test all possible variations, but can try to cover a reasonable set.
+    approve(change.getChangeId());
+    gApi.changes().id(change.getChangeId()).addReviewer(user.email());
 
     requestScopeOperations.setApiUser(user.id());
     try (AutoCloseable ignored = disableNoteDb()) {
@@ -3268,6 +3078,34 @@
   }
 
   @Test
+  public void nonLazyloadQueryOptionsDoNotTouchDatabase() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r1 = createChange();
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+    gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+    PushOneCommit.Result change = createChange();
+    // Populate change with a reasonable set of fields. We can't exhaustively
+    // test all possible variations, but can try to cover a reasonable set.
+    approve(change.getChangeId());
+    gApi.changes().id(change.getChangeId()).addReviewer(user.email());
+
+    requestScopeOperations.setApiUser(user.id());
+    try (AutoCloseable ignored = disableNoteDb()) {
+      assertThat(
+              gApi.changes()
+                  .query()
+                  .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+                  .withOptions(EnumSet.complementOf(EnumSet.copyOf(ChangeJson.REQUIRE_LAZY_LOAD)))
+                  .get())
+          .hasSize(2);
+    }
+  }
+
+  @Test
   public void votable() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
@@ -3354,7 +3192,7 @@
       assertThat(commitPatchSetCreation.getShortMessage()).isEqualTo("Create patch set 2");
       PersonIdent expectedAuthor =
           changeNoteUtil.newAccountIdIdent(
-              getAccount(admin.id()).id(), c.updated, serverIdent.get());
+              getAccount(admin.id()).id(), c.updated.toInstant(), serverIdent.get());
       assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitPatchSetCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.updated));
@@ -3364,7 +3202,7 @@
       assertThat(commitChangeCreation.getShortMessage()).isEqualTo("Create change");
       expectedAuthor =
           changeNoteUtil.newAccountIdIdent(
-              getAccount(admin.id()).id(), c.created, serverIdent.get());
+              getAccount(admin.id()).id(), c.created.toInstant(), serverIdent.get());
       assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor);
       assertThat(commitChangeCreation.getCommitterIdent())
           .isEqualTo(new PersonIdent(serverIdent.get(), c.created));
@@ -3476,6 +3314,55 @@
     r2.assertOkStatus();
   }
 
+  private void assertLabelDescription(ChangeInfo changeInfo, String labelName, String description) {
+    assertThat(changeInfo.labels.get(labelName).description).isEqualTo(description);
+  }
+
+  @Test
+  public void checkLabelVotesForUnsubmittedChange() throws Exception {
+    List<ListChangesOption> options =
+        EnumSet.complementOf(
+                EnumSet.of(
+                    ListChangesOption.CHECK,
+                    ListChangesOption.SKIP_DIFFSTAT,
+                    ListChangesOption.DETAILED_LABELS))
+            .stream()
+            .collect(Collectors.toList());
+    PushOneCommit.Result r = createChange();
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.labels.get(LabelId.CODE_REVIEW).all).isNull();
+
+    voteLabel(r.getChangeId(), LabelId.CODE_REVIEW, +1);
+    change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    List<ApprovalInfo> codeReviewApprovals = change.labels.get(LabelId.CODE_REVIEW).all;
+    assertThat(codeReviewApprovals).hasSize(1);
+    ApprovalInfo codeReviewApproval = codeReviewApprovals.get(0);
+    // permittedVotingRange is not served if DETAILED_LABELS is not requested.
+    assertThat(codeReviewApproval.permittedVotingRange).isNull();
+    assertThat(codeReviewApproval.value).isEqualTo(1);
+    assertThat(codeReviewApproval.username).isEqualTo(admin.username());
+
+    // Add another +1 vote as user
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(r.getChangeId(), LabelId.CODE_REVIEW, +1);
+    change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.labels.get(LabelId.CODE_REVIEW).all).hasSize(2);
+    // All available label votes and their meanings are also served if DETAILED_LABELS is not
+    // requested.
+    assertThat(change.labels.get(LabelId.CODE_REVIEW).values).isNotNull();
+    codeReviewApprovals = change.labels.get(LabelId.CODE_REVIEW).all;
+    assertThat(codeReviewApprovals.stream().map(a -> a.permittedVotingRange).collect(toList()))
+        .containsExactly(null, null);
+    assertThat(codeReviewApprovals.stream().map(a -> a.value).collect(toList()))
+        .containsExactly(1, 1);
+    assertThat(codeReviewApprovals.stream().map(a -> a.username).collect(toList()))
+        .containsExactly(admin.username(), user.username());
+  }
+
   @Test
   public void checkLabelsForUnsubmittedChange() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -3483,6 +3370,7 @@
     assertThat(change.status).isEqualTo(ChangeStatus.NEW);
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.removableLabels).isEmpty();
 
     // add new label and assert that it's returned for existing changes
     AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
@@ -3505,12 +3393,16 @@
         .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
     assertPermitted(change, LabelId.CODE_REVIEW, -2, -1, 0, 1, 2);
     assertPermitted(change, LabelId.VERIFIED, -1, 0, 1);
+    assertLabelDescription(change, LabelId.VERIFIED, TestLabels.VERIFIED_LABEL_DESCRIPTION);
 
     // add an approval on the new label
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
         .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
+    change = gApi.changes().id(r.getChangeId()).get();
+    assertPermitted(change, LabelId.VERIFIED, -1, 0, 1);
+    assertOnlyRemovableLabel(change, LabelId.VERIFIED, "+1", admin);
 
     try (ProjectConfigUpdate u = updateProject(project)) {
       // remove label and assert that it's no longer returned for existing
@@ -3530,6 +3422,7 @@
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+    assertThat(change.removableLabels).isEmpty();
 
     // abandon the change and see that the returned labels stay the same
     // while all permitted labels disappear.
@@ -3538,6 +3431,57 @@
     assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels).isEmpty();
+    assertThat(change.removableLabels).isEmpty();
+  }
+
+  @Test
+  public void checkLabelVotesForMergedChange() throws Exception {
+    List<ListChangesOption> options =
+        EnumSet.complementOf(
+                EnumSet.of(
+                    ListChangesOption.CHECK,
+                    ListChangesOption.SKIP_DIFFSTAT,
+                    ListChangesOption.DETAILED_LABELS))
+            .stream()
+            .collect(Collectors.toList());
+    PushOneCommit.Result r = createChange();
+    voteLabel(r.getChangeId(), LabelId.CODE_REVIEW, +2);
+
+    // Add another label for 'Verified'
+    LabelType verified = TestLabels.verified();
+    AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(verified);
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(verified.getName()).ref(heads).group(registeredUsers).range(-1, 1))
+        .update();
+
+    // Submit the change
+    voteLabel(r.getChangeId(), TestLabels.verified().getName(), 1);
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Make sure label votes are available if DETAILED_LABELS is not requested.
+    ChangeInfo change = gApi.changes().id(r.getChangeId()).get(options);
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, TestLabels.verified().getName());
+    List<ApprovalInfo> codeReviewApprovals = change.labels.get(LabelId.CODE_REVIEW).all;
+    List<ApprovalInfo> verifiedApprovals = change.labels.get(TestLabels.verified().getName()).all;
+
+    assertThat(codeReviewApprovals).hasSize(1);
+    assertThat(codeReviewApprovals.get(0).value).isEqualTo(2);
+    assertThat(codeReviewApprovals.get(0).username).isEqualTo(admin.username());
+    assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
+
+    assertThat(verifiedApprovals).hasSize(1);
+    assertThat(verifiedApprovals.get(0).value).isEqualTo(1);
+    assertThat(verifiedApprovals.get(0).username).isEqualTo(admin.username());
+    assertThat(codeReviewApprovals.get(0).permittedVotingRange).isNull();
   }
 
   @Test
@@ -3547,7 +3491,7 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
@@ -3574,9 +3518,10 @@
         .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
     assertPermitted(change, LabelId.VERIFIED, 0, 1);
+    assertLabelDescription(change, LabelId.VERIFIED, TestLabels.VERIFIED_LABEL_DESCRIPTION);
 
-    // ignore the new label by Prolog submit rule and assert that the label is
-    // no longer returned
+    // Ignore the new label by Prolog submit rule. Permitted ranges are still going to be
+    // returned for the label.
     GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
     testRepo.reset("config");
     PushOneCommit push2 =
@@ -3590,11 +3535,10 @@
 
     change = gApi.changes().id(r.getChangeId()).get();
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
-    assertPermitted(change, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.VERIFIED, 0, 1);
 
-    // 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
-    // included in the submit records
+    // add an approval on the new label. The label can still be voted +1 although it is ignored
+    // in Prolog. 0 is not permitted because votes cannot be decreased.
     gApi.changes()
         .id(r.getChangeId())
         .revision(r.getCommit().name())
@@ -3603,7 +3547,8 @@
     change = gApi.changes().id(r.getChangeId()).get();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
-    assertPermitted(change, LabelId.VERIFIED);
+    assertPermitted(change, LabelId.VERIFIED, 1);
+    assertThat(change.removableLabels).isEmpty();
 
     // remove label and assert that it's no longer returned for existing
     // changes, even if there is an approval for it
@@ -3621,6 +3566,7 @@
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertPermitted(change, LabelId.CODE_REVIEW, 2);
+    assertThat(change.removableLabels).isEmpty();
   }
 
   @Test
@@ -3711,12 +3657,13 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     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);
+    assertThat(change.removableLabels).isEmpty();
   }
 
   @Test
@@ -3728,10 +3675,11 @@
     result.assertOkStatus();
 
     ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
-    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.status).isEqualTo(MERGED);
     assertThat(change.submissionId).isNotNull();
     assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
     assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
+    assertThat(change.removableLabels).isEmpty();
   }
 
   @Test
@@ -3745,11 +3693,11 @@
     result.assertOkStatus();
 
     ChangeInfo firstChange = gApi.changes().id(first.getChangeId()).get();
-    assertThat(firstChange.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(firstChange.status).isEqualTo(MERGED);
     assertThat(firstChange.submissionId).isNotNull();
 
     ChangeInfo secondChange = gApi.changes().id(second.getChangeId()).get();
-    assertThat(secondChange.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(secondChange.status).isEqualTo(MERGED);
     assertThat(secondChange.submissionId).isNotNull();
 
     assertThat(secondChange.submissionId).isEqualTo(firstChange.submissionId);
@@ -3905,7 +3853,9 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown).hasMessageThat().contains("needs All-Comments-Resolved");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement 'All-Comments-Resolved' is unsatisfied");
   }
 
   @Test
@@ -4031,1094 +3981,6 @@
   }
 
   @Test
-  public void submitRecords() throws Exception {
-    PushOneCommit.Result r = createChange();
-    TestSubmitRule testSubmitRule = new TestSubmitRule();
-    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
-      String changeId = r.getChangeId();
-
-      ChangeInfo change = gApi.changes().id(changeId).get();
-      assertThat(change.submitRecords).hasSize(2);
-      // Check the default submit record for the code-review label
-      SubmitRecordInfo codeReviewRecord = Iterables.get(change.submitRecords, 0);
-      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
-      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.NOT_READY);
-      assertThat(codeReviewRecord.labels).hasSize(1);
-      SubmitRecordInfo.Label label = Iterables.getOnlyElement(codeReviewRecord.labels);
-      assertThat(label.label).isEqualTo("Code-Review");
-      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.NEED);
-      assertThat(label.appliedBy).isNull();
-      // Check the custom test record created by the TestSubmitRule
-      SubmitRecordInfo testRecord = Iterables.get(change.submitRecords, 1);
-      assertThat(testRecord.ruleName).isEqualTo("gerrit~TestSubmitRule");
-      assertThat(testRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
-      assertThat(testRecord.requirements)
-          .containsExactly(new LegacySubmitRequirementInfo("OK", "fallback text", "type"));
-      assertThat(testRecord.labels).hasSize(1);
-      SubmitRecordInfo.Label testLabel = Iterables.getOnlyElement(testRecord.labels);
-      assertThat(testLabel.label).isEqualTo("label");
-      assertThat(testLabel.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
-      assertThat(testLabel.appliedBy).isNull();
-
-      voteLabel(changeId, "Code-Review", 2);
-      // Code review record is satisfied after voting +2
-      change = gApi.changes().id(changeId).get();
-      assertThat(change.submitRecords).hasSize(2);
-      codeReviewRecord = Iterables.get(change.submitRecords, 0);
-      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
-      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
-      assertThat(codeReviewRecord.labels).hasSize(1);
-      label = Iterables.getOnlyElement(codeReviewRecord.labels);
-      assertThat(label.label).isEqualTo("Code-Review");
-      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
-      assertThat(label.appliedBy._accountId).isEqualTo(admin.id().get());
-    }
-  }
-
-  @Test
-  public void checkSubmitRequirement_satisfied() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput(
-            "Code-Review", /* submittabilityExpression= */ "label:Code-Review=+2");
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
-
-    voteLabel(changeId, "Code-Review", 2);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
-  }
-
-  @Test
-  public void checkSubmitRequirement_notApplicable() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput(
-            "Code-Review",
-            /* applicableIf= */ "branch:non-existent",
-            /* submittableIf= */ "label:Code-Review=+2",
-            /* overrideIf= */ null);
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
-
-    voteLabel(changeId, "Code-Review", 2);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
-  }
-
-  @Test
-  public void checkSubmitRequirement_overridden() throws Exception {
-    configLabel("Override-Label", LabelFunction.NO_OP); // label function has no effect
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("Override-Label")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput(
-            "Code-Review",
-            /* applicableIf= */ null,
-            /* submittableIf= */ "label:Code-Review=+2",
-            /* overrideIf= */ "label:Override-Label=+1");
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
-
-    voteLabel(changeId, "Code-Review", 2);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
-
-    voteLabel(changeId, "Override-Label", 1);
-    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.OVERRIDDEN);
-  }
-
-  @Test
-  public void checkSubmitRequirement_error() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    SubmitRequirementInput in =
-        createSubmitRequirementInput("Code-Review", /* submittabilityExpression= */ "!!!");
-
-    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
-    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.ERROR);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsMax() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:Code-Review=MAX"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "Code-Review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsMax_fromNonUploader() throws Exception {
-    configLabel("my-label", LabelFunction.NO_OP); // label function has no effect
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
-        .update();
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("my-label")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:my-label=MAX,user=non_uploader"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    // The second requirement is coming from the legacy code-review label function
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // Voting with a max vote as the uploader will not satisfy the submit requirement.
-    voteLabel(changeId, "my-label", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // Voting as a non-uploader will satisfy the submit requirement.
-    requestScopeOperations.setApiUser(user.id());
-    voteLabel(changeId, "my-label", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("-label:Code-Review=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // Requirement is satisfied because there are no votes
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-    // Legacy requirement (coming from the label function definition) is not satisfied. We return
-    // both legacy and non-legacy requirements in this case since their statuses are not identical.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-
-    voteLabel(changeId, "Code-Review", -1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // Requirement is still satisfied because -1 is not the max negative value
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-
-    voteLabel(changeId, "Code-Review", -2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    // Requirement is now unsatisfied because -2 is the max negative value
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withMaxWithBlock_ignoringSelfApproval() throws Exception {
-    configLabel("my-label", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
-        .update();
-
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("my-label")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create(
-                    "label:my-label=MAX,user=non_uploader -label:my-label=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Create the change as admin
-    requestScopeOperations.setApiUser(admin.id());
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    // Admin (a.k.a uploader) adds a -1 min vote. This is going to block submission.
-    voteLabel(changeId, "my-label", -1);
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    // The other requirement is coming from the code-review label function
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // user (i.e. non_uploader) votes 1. Requirement is still blocking because of -1 of uploader.
-    requestScopeOperations.setApiUser(user.id());
-    voteLabel(changeId, "my-label", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    // Admin (a.k.a uploader) removes -1. Now requirement is fulfilled.
-    requestScopeOperations.setApiUser(admin.id());
-    voteLabel(changeId, "my-label", 0);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_withLabelEqualsAny() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:Code-Review=ANY"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "Code-Review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // Legacy and non-legacy requirements have mismatching status. Both are returned from the API.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
-      throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Verified")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "Code-Review", 2);
-
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
-      throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setApplicabilityExpression(SubmitRequirementExpression.of("project:foo"))
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
-    configLabel("build-cop-override", LabelFunction.NO_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "build-cop-override", 1);
-
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_overriddenInChildProject() throws Exception {
-    configSubmitRequirement(
-        allProjects,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(true)
-            .build());
-
-    // Override submit requirement in child project (requires Code-Review=+2 instead of +1)
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "Code-Review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "Code-Review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_inheritedFromParentProject() throws Exception {
-    configSubmitRequirement(
-        allProjects,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "Code-Review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
-      throws Exception {
-    configSubmitRequirement(
-        allProjects,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Override submit requirement in child project (requires Code-Review=+2 instead of +1).
-    // Will have no effect since parent does not allow override.
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "Code-Review", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    // +1 was enough to fulfill the requirement: override in child project was ignored
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_storedForClosedChanges() throws Exception {
-    for (SubmitType submitType : SubmitType.values()) {
-      Project.NameKey project = createProjectForPush(submitType);
-      TestRepository<InMemoryRepository> repo = cloneProject(project);
-      configSubmitRequirement(
-          project,
-          SubmitRequirement.builder()
-              .setName("Code-Review")
-              .setSubmittabilityExpression(
-                  SubmitRequirementExpression.create("label:Code-Review=+2"))
-              .setAllowOverrideInChildProjects(false)
-              .build());
-
-      PushOneCommit.Result r =
-          createChange(repo, "master", "Add a file", "foo", "content", "topic");
-      String changeId = r.getChangeId();
-
-      voteLabel(changeId, "Code-Review", 2);
-
-      ChangeInfo change = gApi.changes().id(changeId).get();
-      assertThat(change.submitRequirements).hasSize(1);
-      assertSubmitRequirementStatus(
-          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-
-      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-      revision.review(ReviewInput.approve());
-      revision.submit();
-
-      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
-
-      SubmitRequirementResult result =
-          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
-      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
-      assertThat(result.submittabilityExpressionResult().status())
-          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
-      assertThat(result.submittabilityExpressionResult().expression().expressionString())
-          .isEqualTo("label:Code-Review=+2");
-    }
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_storedForAbandonedChanges() throws Exception {
-    for (SubmitType submitType : SubmitType.values()) {
-      Project.NameKey project = createProjectForPush(submitType);
-      TestRepository<InMemoryRepository> repo = cloneProject(project);
-      configSubmitRequirement(
-          project,
-          SubmitRequirement.builder()
-              .setName("Code-Review")
-              .setSubmittabilityExpression(
-                  SubmitRequirementExpression.create("label:Code-Review=+2"))
-              .setAllowOverrideInChildProjects(false)
-              .build());
-
-      PushOneCommit.Result r =
-          createChange(repo, "master", "Add a file", "foo", "content", "topic");
-      String changeId = r.getChangeId();
-
-      voteLabel(changeId, "Code-Review", 2);
-      ChangeInfo change = gApi.changes().id(changeId).get();
-      assertThat(change.submitRequirements).hasSize(1);
-      assertSubmitRequirementStatus(
-          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-
-      gApi.changes().id(r.getChangeId()).abandon();
-      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
-      SubmitRequirementResult result =
-          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
-      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
-      assertThat(result.submittabilityExpressionResult().status())
-          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
-      assertThat(result.submittabilityExpressionResult().expression().expressionString())
-          .isEqualTo("label:Code-Review=+2");
-    }
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_retrievedFromNoteDbForAbandonedChanges() throws Exception {
-    for (SubmitType submitType : SubmitType.values()) {
-      Project.NameKey project = createProjectForPush(submitType);
-      TestRepository<InMemoryRepository> repo = cloneProject(project);
-      configSubmitRequirement(
-          project,
-          SubmitRequirement.builder()
-              .setName("Code-Review")
-              .setSubmittabilityExpression(
-                  SubmitRequirementExpression.create("label:Code-Review=+2"))
-              .setAllowOverrideInChildProjects(false)
-              .build());
-
-      PushOneCommit.Result r =
-          createChange(repo, "master", "Add a file", "foo", "content", "topic");
-      String changeId = r.getChangeId();
-      voteLabel(changeId, "Code-Review", 2);
-      gApi.changes().id(changeId).abandon();
-
-      // Add another submit requirement. This will not get returned for the abandoned change, since
-      // we return the state of the SR results when the change was abandoned.
-      configSubmitRequirement(
-          project,
-          SubmitRequirement.builder()
-              .setName("New-Requirement")
-              .setSubmittabilityExpression(SubmitRequirementExpression.create("-has:unresolved"))
-              .setAllowOverrideInChildProjects(false)
-              .build());
-      ChangeInfo changeInfo =
-          gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
-      assertThat(changeInfo.submitRequirements).hasSize(1);
-      assertSubmitRequirementStatus(
-          changeInfo.submitRequirements,
-          "Code-Review",
-          Status.SATISFIED,
-          /* isLegacy= */ false,
-          /* submittabilityCondition= */ "label:Code-Review=+2");
-
-      // Restore the change, the new requirement will show up
-      gApi.changes().id(changeId).restore();
-      changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
-      assertThat(changeInfo.submitRequirements).hasSize(2);
-      assertSubmitRequirementStatus(
-          changeInfo.submitRequirements,
-          "Code-Review",
-          Status.SATISFIED,
-          /* isLegacy= */ false,
-          /* submittabilityCondition= */ "label:Code-Review=+2");
-      assertSubmitRequirementStatus(
-          changeInfo.submitRequirements,
-          "New-Requirement",
-          Status.SATISFIED,
-          /* isLegacy= */ false,
-          /* submittabilityCondition= */ "-has:unresolved");
-
-      // Abandon again, make sure the new requirement was persisted
-      gApi.changes().id(changeId).abandon();
-      changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
-      assertThat(changeInfo.submitRequirements).hasSize(2);
-      assertSubmitRequirementStatus(
-          changeInfo.submitRequirements,
-          "Code-Review",
-          Status.SATISFIED,
-          /* isLegacy= */ false,
-          /* submittabilityCondition= */ "label:Code-Review=+2");
-      assertSubmitRequirementStatus(
-          changeInfo.submitRequirements,
-          "New-Requirement",
-          Status.SATISFIED,
-          /* isLegacy= */ false,
-          /* submittabilityCondition= */ "-has:unresolved");
-    }
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
-
-    voteLabel(changeId, "Code-Review", 2);
-
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-
-    gApi.changes().id(changeId).current().submit();
-
-    // Add new submit requirement
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Verified")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // The new "Verified" submit requirement is not returned, since this change is closed
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void
-      submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
-          throws Exception {
-    // Configure a legacy submit requirement: label with a max with block function
-    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // Configure a submit requirement with the same name.
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("build-cop-override")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create(
-                    "label:build-cop-override=MAX -label:build-cop-override=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Create a change. Vote to fulfill all requirements.
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    voteLabel(changeId, "build-cop-override", 1);
-    voteLabel(changeId, "Code-Review", 2);
-
-    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
-    // Only non-legacy bco is returned.
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.SATISFIED,
-        /* isLegacy= */ false,
-        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
-
-    // Merge the change. Submit requirements are still the same.
-    gApi.changes().id(changeId).current().submit();
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.SATISFIED,
-        /* isLegacy= */ false,
-        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void
-      submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
-          throws Exception {
-    // Configure a legacy submit requirement: label with a max with block function
-    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // Configure a submit requirement with the same name.
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("build-cop-override")
-            .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:build-cop-override=MIN"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    // Create a change
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    voteLabel(changeId, "build-cop-override", 1);
-    voteLabel(changeId, "Code-Review", 2);
-
-    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
-    // Two instances of bco will be returned since their status is not matching.
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(3);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.SATISFIED,
-        /* isLegacy= */ true,
-        // MAX_WITH_BLOCK function was translated to a submittability expression.
-        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
-    assertSubmitRequirementStatus(
-        change.submitRequirements,
-        "build-cop-override",
-        Status.UNSATISFIED,
-        /* isLegacy= */ false,
-        /* submittabilityCondition= */ "label:build-cop-override=MIN");
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirements_returnForLegacySubmitRecords_ifEnabled() throws Exception {
-    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(
-            allowLabel("build-cop-override")
-                .ref("refs/heads/master")
-                .group(REGISTERED_USERS)
-                .range(-1, 1))
-        .update();
-
-    // 1. Project has two legacy requirements: Code-Review and bco. Both unsatisfied.
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.UNSATISFIED, /* isLegacy= */ true);
-
-    // 2. Vote +1 on bco. bco becomes satisfied
-    voteLabel(changeId, "build-cop-override", 1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
-
-    // 3. Vote +1 on Code-Review. Code-Review becomes satisfied
-    voteLabel(changeId, "Code-Review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
-
-    // 4. Merge the change. Submit requirements status is presented from NoteDb.
-    gApi.changes().id(changeId).current().submit();
-    change = gApi.changes().id(changeId).get();
-    // Legacy submit records are returned as submit requirements.
-    assertThat(change.submitRequirements).hasSize(2);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
-    assertSubmitRequirementStatus(
-        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    voteLabel(changeId, "Code-Review", 2);
-
-    // Query the change. ChangeInfo is back-filled from the change index.
-    List<ChangeInfo> changeInfos =
-        gApi.changes()
-            .query()
-            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
-            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
-            .get();
-    assertThat(changeInfos).hasSize(1);
-    assertSubmitRequirementStatus(
-        changeInfos.get(0).submitRequirements,
-        "Code-Review",
-        Status.SATISFIED,
-        /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
-      })
-  public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    voteLabel(changeId, "Code-Review", 2);
-    gApi.changes().id(changeId).current().submit();
-
-    // Query the change. ChangeInfo is back-filled from the change index.
-    List<ChangeInfo> changeInfos =
-        gApi.changes()
-            .query()
-            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
-            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
-            .get();
-    assertThat(changeInfos).hasSize(1);
-    assertSubmitRequirementStatus(
-        changeInfos.get(0).submitRequirements,
-        "Code-Review",
-        Status.SATISFIED,
-        /* isLegacy= */ false);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
-  public void submitRequirement_applicabilityExpressionIsAlwaysHidden() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setApplicabilityExpression(SubmitRequirementExpression.of("branch:refs/heads/master"))
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    voteLabel(changeId, "Code-Review", 2);
-    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
-    SubmitRequirementResultInfo requirement =
-        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
-    assertSubmitRequirementExpression(
-        requirement.applicabilityExpressionResult,
-        /* expression= */ null,
-        /* passingAtoms= */ null,
-        /* failingAtoms= */ null,
-        /* fulfilled= */ true);
-  }
-
-  @Test
-  public void submitRequirements_notServedIfExperimentNotEnabled() throws Exception {
-    configSubmitRequirement(
-        project,
-        SubmitRequirement.builder()
-            .setName("Code-Review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
-            .setAllowOverrideInChildProjects(false)
-            .build());
-
-    PushOneCommit.Result r = createChange();
-    String changeId = r.getChangeId();
-
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    voteLabel(changeId, "Code-Review", -1);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    voteLabel(changeId, "Code-Review", 2);
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-
-    gApi.changes().id(changeId).current().submit();
-    change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).isEmpty();
-  }
-
-  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
@@ -5370,7 +4232,7 @@
 
   private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
     try (BatchUpdate batchUpdate =
-        batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.nowTs())) {
+        batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.now())) {
       batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
       batchUpdate.execute();
     }
@@ -5471,118 +4333,41 @@
       ChangeInfo change = info(triplet);
       assertThat(change.starred).isTrue();
       assertThat(change.stars).contains(DEFAULT_LABEL);
-      changeIndexedCounter.assertReindexOf(change);
+      // change was not re-indexed
+      changeIndexedCounter.assertReindexOf(change, 0);
 
       gApi.accounts().self().unstarChange(triplet);
       change = info(triplet);
       assertThat(change.starred).isNull();
       assertThat(change.stars).isNull();
-      changeIndexedCounter.assertReindexOf(change);
+      // change was not re-indexed
+      changeIndexedCounter.assertReindexOf(change, 0);
     }
   }
 
   @Test
-  public void ignore() throws Exception {
-    String email = "user2@example.com";
-    String fullname = "User2";
-    accountOperations
-        .newAccount()
-        .username("user2")
-        .preferredEmail(email)
-        .fullname(fullname)
-        .create();
+  public void createAndDeleteDraftCommentDoesNotTriggerChangeReindex() throws Exception {
+    ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(changeIndexedCounter)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      String triplet = project.get() + "~master~" + r.getChangeId();
+      changeIndexedCounter.clear();
 
-    PushOneCommit.Result r = createChange();
+      // Create the draft. Change is not re-indexed
+      DraftInput draftInput =
+          CommentsUtil.newDraft("file1", Side.REVISION, /* line= */ 1, "comment 1");
+      CommentInfo draftInfo =
+          gApi.changes().id(changeId).revision(revId).createDraft(draftInput).get();
+      ChangeInfo change = info(triplet);
+      changeIndexedCounter.assertReindexOf(change, 0);
 
-    ReviewerInput in = new ReviewerInput();
-    in.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    in = new ReviewerInput();
-    in.reviewer = email;
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).ignore(true);
-    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isTrue();
-
-    // New patch set notification is not sent to users ignoring the change
-    sender.clear();
-    requestScopeOperations.setApiUser(admin.id());
-    amendChange(r.getChangeId());
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    Address address = Address.create(fullname, email);
-    assertThat(messages.get(0).rcpt()).containsExactly(address);
-
-    // Review notification is not sent to users ignoring the change
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
-    messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(address);
-
-    // Abandoned notification is not sent to users ignoring the change
-    sender.clear();
-    gApi.changes().id(r.getChangeId()).abandon();
-    messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(address);
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).ignore(false);
-    assertThat(gApi.changes().id(r.getChangeId()).ignored()).isFalse();
-  }
-
-  @Test
-  public void cannotIgnoreOwnChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    BadRequestException thrown =
-        assertThrows(BadRequestException.class, () -> gApi.changes().id(changeId).ignore(true));
-    assertThat(thrown).hasMessageThat().contains("cannot ignore own change");
-  }
-
-  @Test
-  public void cannotIgnoreStarredChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().self().starChange(changeId);
-    assertThat(gApi.changes().id(changeId).get().starred).isTrue();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.changes().id(changeId).ignore(true));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "The labels "
-                + StarredChangesUtil.DEFAULT_LABEL
-                + " and "
-                + StarredChangesUtil.IGNORE_LABEL
-                + " are mutually exclusive. Only one of them can be set.");
-  }
-
-  @Test
-  public void cannotStarIgnoredChange() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).ignore(true);
-    assertThat(gApi.changes().id(changeId).ignored()).isTrue();
-
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class, () -> gApi.accounts().self().starChange(changeId));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "The labels "
-                + StarredChangesUtil.DEFAULT_LABEL
-                + " and "
-                + StarredChangesUtil.IGNORE_LABEL
-                + " are mutually exclusive. Only one of them can be set.");
+      // Delete the draft comment. Change is not re-indexed
+      gApi.changes().id(changeId).revision(revId).draft(draftInfo.id).delete();
+      changeIndexedCounter.assertReindexOf(change, 0);
+    }
   }
 
   @Test
@@ -5601,9 +4386,13 @@
             ListChangesOption.SKIP_DIFFSTAT);
 
     PushOneCommit.Result change = createChange();
-    int number = gApi.changes().id(change.getChangeId()).get()._number;
+    // Populate change with a reasonable set of fields. We can't exhaustively
+    // test all possible variations, but can try to cover a reasonable set.
+    approve(change.getChangeId());
+    gApi.changes().id(change.getChangeId()).addReviewer(user.email());
 
-    try (AutoCloseable ignored = disableChangeIndex()) {
+    int number = gApi.changes().id(change.getChangeId()).get()._number;
+    try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
       assertThat(gApi.changes().id(project.get(), number).get(options).changeId)
           .isEqualTo(change.getChangeId());
     }
@@ -5625,6 +4414,137 @@
     assertThat(gApi.changes().query(changeId).get().get(0).mergeable).isNull();
   }
 
+  @Test
+  public void ccUserThatCannotSeeTheChange() throws Exception {
+    // Create a project that is only visible to admin users.
+    Project.NameKey project = projectOperations.newProject().create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/*").group(adminGroupUuid()))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Create a change
+    TestRepository<?> adminTestRepo = cloneProject(project, admin);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), adminTestRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Check that the change is not visible to user.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).get());
+
+    // Add user as a CC.
+    requestScopeOperations.setApiUser(admin.id());
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
+
+    // Check that user was not added as a CC since they cannot see the change. Note,
+    // ChangeInfo#reviewers is a map that also contains CCs (if any are present).
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+
+    // Check that the change is still not visible to user.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> gApi.changes().id(r.getChangeId()).get());
+  }
+
+  @Test
+  public void ccNonExistentAccountByEmailThenRemoveByDelete() throws Exception {
+    // Create a project that allows reviewers by email.
+    Project.NameKey project = projectOperations.newProject().create();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateProject(
+              b ->
+                  b.setBooleanConfig(
+                      BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE));
+      u.save();
+    }
+
+    // Create a change
+    TestRepository<?> testRepo = cloneProject(project, admin);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Add an email as a CC for which no Gerrit account exists.
+    sender.clear();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = "email-without-account@example.com";
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
+
+    // Check that the email was added as a CC and an email was sent.
+    AccountInfo ccedAccountInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(r.getChangeId()).get().reviewers.get(ReviewerState.CC));
+    assertThat(ccedAccountInfo.email).isEqualTo(reviewerInput.reviewer);
+    assertThat(ccedAccountInfo._accountId).isNull();
+    assertThat(ccedAccountInfo.name).isNull();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has uploaded this change for review", admin.fullName()));
+
+    // Remove the CC.
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).reviewer(reviewerInput.reviewer).remove();
+
+    // Check that the email was removed as a CC and an email was sent.
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
+  }
+
+  @Test
+  public void ccNonExistentAccountByEmailThenRemoveByPostReview() throws Exception {
+    // Create a project that allows reviewers by email.
+    Project.NameKey project = projectOperations.newProject().create();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateProject(
+              b ->
+                  b.setBooleanConfig(
+                      BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE));
+      u.save();
+    }
+
+    // Create a change
+    TestRepository<?> testRepo = cloneProject(project, admin);
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Add an email as a CC for which no Gerrit account exists.
+    sender.clear();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = "email-without-account@example.com";
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
+
+    // Check that the email was added as a CC and an email was sent.
+    AccountInfo ccedAccountInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(r.getChangeId()).get().reviewers.get(ReviewerState.CC));
+    assertThat(ccedAccountInfo.email).isEqualTo(reviewerInput.reviewer);
+    assertThat(ccedAccountInfo._accountId).isNull();
+    assertThat(ccedAccountInfo.name).isNull();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has uploaded this change for review", admin.fullName()));
+
+    // Remove the CC.
+    sender.clear();
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.reviewer(reviewerInput.reviewer, ReviewerState.REMOVED, /* confirmed= */ false);
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+
+    // Check that the email was removed as a CC and an email was sent.
+    assertThat(gApi.changes().id(r.getChangeId()).get().reviewers).isEmpty();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
+  }
+
   private PushOneCommit.Result createWorkInProgressChange() throws Exception {
     return pushTo("refs/for/master%wip");
   }
@@ -5643,136 +4563,28 @@
     void call(String changeId, String reviewer) throws RestApiException;
   }
 
-  private static class TestWorkInProgressStateChangedListener
-      implements WorkInProgressStateChangedListener {
-    boolean invoked;
-    Boolean wip;
-
+  public static class TestAttentionSetListenerModule extends AbstractModule {
     @Override
-    public void onWorkInProgressStateChanged(Event event) {
-      this.invoked = true;
-      this.wip =
-          event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+    public void configure() {
+      DynamicSet.bind(binder(), AttentionSetListener.class).to(TestAttentionSetListener.class);
+    }
+
+    public static class TestAttentionSetListener implements AttentionSetListener {
+      AttentionSetListener.Event lastEvent;
+      int firedCount;
+
+      @Inject
+      public TestAttentionSetListener() {}
+
+      @Override
+      public void onAttentionSetChanged(AttentionSetListener.Event event) {
+        firedCount++;
+        lastEvent = event;
+      }
     }
   }
 
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
-
-  private void assertSubmitRequirementStatus(
-      Collection<SubmitRequirementResultInfo> results,
-      String requirementName,
-      SubmitRequirementResultInfo.Status status,
-      boolean isLegacy,
-      String submittabilityCondition) {
-    for (SubmitRequirementResultInfo result : results) {
-      if (result.name.equals(requirementName)
-          && result.status == status
-          && result.isLegacy == isLegacy
-          && result.submittabilityExpressionResult.expression.equals(submittabilityCondition)) {
-        return;
-      }
-    }
-    throw new AssertionError(
-        String.format(
-            "Could not find submit requirement %s with status %s (results = %s)",
-            requirementName,
-            status,
-            results.stream()
-                .map(r -> String.format("%s=%s", r.name, r.status))
-                .collect(toImmutableList())));
-  }
-
-  private void assertSubmitRequirementStatus(
-      Collection<SubmitRequirementResultInfo> results,
-      String requirementName,
-      SubmitRequirementResultInfo.Status status,
-      boolean isLegacy) {
-    for (SubmitRequirementResultInfo result : results) {
-      if (result.name.equals(requirementName)
-          && result.status == status
-          && result.isLegacy == isLegacy) {
-        return;
-      }
-    }
-    throw new AssertionError(
-        String.format(
-            "Could not find submit requirement %s with status %s (results = %s)",
-            requirementName,
-            status,
-            results.stream()
-                .map(r -> String.format("%s=%s", r.name, r.status))
-                .collect(toImmutableList())));
-  }
-
-  private void assertSubmitRequirementExpression(
-      SubmitRequirementExpressionInfo result,
-      @Nullable String expression,
-      @Nullable List<String> passingAtoms,
-      @Nullable List<String> failingAtoms,
-      boolean fulfilled) {
-    assertThat(result.expression).isEqualTo(expression);
-    if (passingAtoms == null) {
-      assertThat(result.passingAtoms).isNull();
-    } else {
-      assertThat(result.passingAtoms).containsExactlyElementsIn(passingAtoms);
-    }
-    if (failingAtoms == null) {
-      assertThat(result.failingAtoms).isNull();
-    } else {
-      assertThat(result.failingAtoms).containsExactlyElementsIn(failingAtoms);
-    }
-    assertThat(result.fulfilled).isEqualTo(fulfilled);
-  }
-
-  private Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
-    Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
-    projectOperations
-        .project(project)
-        .forUpdate()
-        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
-        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
-        .update();
-    return project;
-  }
-
-  /** Returns a hard-coded submit record containing all fields. */
-  private static class TestSubmitRule implements SubmitRule {
-    @Override
-    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
-      SubmitRecord record = new SubmitRecord();
-      record.ruleName = "testSubmitRule";
-      record.status = SubmitRecord.Status.OK;
-      SubmitRecord.Label label = new SubmitRecord.Label();
-      label.label = "label";
-      label.status = SubmitRecord.Label.Status.OK;
-      record.labels = Arrays.asList(label);
-      record.requirements =
-          Arrays.asList(
-              LegacySubmitRequirement.builder()
-                  .setType("type")
-                  .setFallbackText("fallback text")
-                  .build());
-      return Optional.of(record);
-    }
-  }
-
-  private static SubmitRequirementInput createSubmitRequirementInput(
-      String name, String submittabilityExpression) {
-    SubmitRequirementInput input = new SubmitRequirementInput();
-    input.name = name;
-    input.submittabilityExpression = submittabilityExpression;
-    return input;
-  }
-
-  private static SubmitRequirementInput createSubmitRequirementInput(
-      String name, String applicableIf, String submittableIf, String overrideIf) {
-    SubmitRequirementInput input = new SubmitRequirementInput();
-    input.name = name;
-    input.applicabilityExpression = applicableIf;
-    input.submittabilityExpression = submittableIf;
-    input.overrideExpression = overrideIf;
-    return input;
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeReviewIT.java
new file mode 100644
index 0000000..2b04e56
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeReviewIT.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseSsh;
+import org.junit.Test;
+
+@UseSsh
+public class ChangeReviewIT extends AbstractDaemonTest {
+
+  @Test
+  public void testGerritReviewCommandWithShortNameBranch() throws Exception {
+    PushOneCommit.Result r = createChange();
+    adminSshSession.exec(
+        "gerrit review --project "
+            + r.getChange().change().getProject().get()
+            + " --branch "
+            + r.getChange().change().getDest().shortName()
+            + " --code-review 1 "
+            + r.getCommit().getName());
+    adminSshSession.assertSuccess();
+  }
+
+  @Test
+  public void testGerritReviewCommandWithoutProject() throws Exception {
+    PushOneCommit.Result r = createChange();
+    adminSshSession.exec(
+        "gerrit review"
+            + " --branch "
+            + r.getChange().change().getDest().shortName()
+            + " --code-review 1 "
+            + r.getCommit().getName());
+    adminSshSession.assertSuccess();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
deleted file mode 100644
index 96db71a..0000000
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ /dev/null
@@ -1,279 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.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.acceptance.config.GerritConfig;
-import com.google.gerrit.entities.LegacySubmitRequirement;
-import com.google.gerrit.entities.SubmitRecord;
-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.LegacySubmitRequirementInfo;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.rules.SubmitRule;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Singleton;
-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 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() {
-    return new FactoryModule() {
-      @Override
-      public void configure() {
-        bind(SubmitRule.class)
-            .annotatedWith(Exports.named("CustomSubmitRule"))
-            .to(CustomSubmitRule.class);
-      }
-    };
-  }
-
-  @Inject private CustomSubmitRule rule;
-
-  @Test
-  public void submitRequirementIsPropagated() throws Exception {
-    rule.block(false);
-    PushOneCommit.Result r = createChange();
-
-    ChangeInfo result = gApi.changes().id(r.getChangeId()).get();
-    assertThat(result.requirements).isEmpty();
-
-    rule.block(true);
-    result = gApi.changes().id(r.getChangeId()).get();
-    assertThat(result.requirements).containsExactly(reqInfo);
-  }
-
-  @Test
-  public void submitRequirementIsPropagatedInQuery() throws Exception {
-    rule.block(false);
-    PushOneCommit.Result r = createChange();
-
-    String query = "status:open project:" + project.get();
-    List<ChangeInfo> result = gApi.changes().query(query).get();
-    assertThat(result).hasSize(1);
-    assertThat(result.get(0).requirements).isEmpty();
-
-    // Submit rule behavior is changed, but the query still returns
-    // the previous result from the index
-    rule.block(true);
-    result = gApi.changes().query(query).get();
-    assertThat(result).hasSize(1);
-    assertThat(result.get(0).requirements).isEmpty();
-
-    // The submit rule result is updated after the change is reindexed
-    gApi.changes().id(r.getChangeId()).index();
-    result = gApi.changes().query(query).get();
-    assertThat(result).hasSize(1);
-    assertThat(result.get(0).requirements).containsExactly(reqInfo);
-  }
-
-  @Test
-  public void submittableQueryRuleNotReady() throws Exception {
-    ChangeApi change = newChangeApi();
-
-    // Satisfy the default rule.
-    approveChange(change);
-
-    // The custom rule is NOT_READY.
-    rule.block(true);
-    change.index();
-
-    assertThat(queryIsSubmittable()).isEmpty();
-  }
-
-  @Test
-  public void submittableQueryRuleError() throws Exception {
-    ChangeApi change = newChangeApi();
-
-    // Satisfy the default rule.
-    approveChange(change);
-
-    rule.status(Optional.of(SubmitRecord.Status.RULE_ERROR));
-    change.index();
-
-    assertThat(queryIsSubmittable()).isEmpty();
-  }
-
-  @Test
-  public void submittableQueryDefaultRejected() throws Exception {
-    ChangeApi change = newChangeApi();
-
-    // CodeReview:-2 the change, causing the default rule to fail.
-    rejectChange(change);
-
-    rule.status(Optional.of(SubmitRecord.Status.OK));
-    change.index();
-
-    assertThat(queryIsSubmittable()).isEmpty();
-  }
-
-  @Test
-  public void submittableQueryRuleOk() throws Exception {
-    ChangeApi change = newChangeApi();
-
-    // Satisfy the default rule.
-    approveChange(change);
-
-    rule.status(Optional.of(SubmitRecord.Status.OK));
-    change.index();
-
-    List<ChangeInfo> result = queryIsSubmittable();
-    assertThat(result).hasSize(1);
-    assertThat(result.get(0).changeId).isEqualTo(change.info().changeId);
-  }
-
-  @Test
-  public void submittableQueryRuleNoRecord() throws Exception {
-    ChangeApi change = newChangeApi();
-
-    // Satisfy the default rule.
-    approveChange(change);
-
-    // Our custom rule isn't providing any submit records.
-    rule.status(Optional.empty());
-    change.index();
-
-    // is:submittable should return the change, since it was approved and the custom rule is not
-    // blocking it.
-    List<ChangeInfo> result = queryIsSubmittable();
-    assertThat(result).hasSize(1);
-    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);
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      values = {
-        ExperimentFeaturesConstants
-            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD,
-        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS
-      })
-  public void submitRuleIsInvokedWhenQueryingChangeWithExperiment() throws Exception {
-    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
-    String changeId = r.getChangeId();
-
-    rule.numberOfEvaluations.set(0);
-    gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
-
-    // Submit rules are invoked
-    assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
-  }
-
-  @Test
-  public void submitRuleIsNotInvokedWhenQueryingChangeWithoutExperiment() throws Exception {
-    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
-    String changeId = r.getChangeId();
-
-    rule.numberOfEvaluations.set(0);
-    gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
-
-    // Submit rules are not invoked
-    assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
-  }
-
-  private List<ChangeInfo> queryIsSubmittable() throws Exception {
-    return gApi.changes().query("is:submittable project:" + project.get()).get();
-  }
-
-  private ChangeApi newChangeApi() throws Exception {
-    return gApi.changes().id(createChange().getChangeId());
-  }
-
-  private void approveChange(ChangeApi changeApi) throws Exception {
-    changeApi.current().review(ReviewInput.approve());
-  }
-
-  private void rejectChange(ChangeApi changeApi) throws Exception {
-    changeApi.current().review(ReviewInput.reject());
-  }
-
-  @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());
-    }
-
-    public void status(Optional<SubmitRecord.Status> status) {
-      this.recordStatus = status;
-    }
-
-    @Override
-    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
-      numberOfEvaluations.incrementAndGet();
-      if (this.recordStatus.isPresent()) {
-        SubmitRecord record = new SubmitRecord();
-        record.labels = new ArrayList<>();
-        record.status = this.recordStatus.get();
-        record.requirements = ImmutableList.of(req);
-        return Optional.of(record);
-      }
-      return Optional.empty();
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
new file mode 100644
index 0000000..2b1bef0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
@@ -0,0 +1,1365 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.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 static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.server.approval.ApprovalCopier;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+/**
+ * Tests to verify that copied/outdated approvals are included into the change message that is
+ * posted on patch set creation. Includes verifying that the copied/outdated approvals in the change
+ * message are correctly formatted.
+ *
+ * <p>Some of the tests only verify the correct formatting of the copied/outdated approvals in the
+ * change message that is done by {@link
+ * ApprovalsUtil#formatApprovalCopierResult(com.google.gerrit.server.approval.ApprovalCopier.Result,
+ * LabelTypes)}. This method does the formatting based on the inputs that it gets, but it doesn't do
+ * any verification of these inputs. This means it's possible to provide inputs that are
+ * inconsistent with the approval copying logic in {@link ApprovalCopier}. E.g. it's possible to
+ * provide "is:MAX" as a passing atom for a "Code-Review-1" vote and have "is:MAX" highlighted as
+ * passing in the message although the "Code-Review-1" vote doesn't match with "is:MAX". For easier
+ * readability the formatting tests avoid using such inconsistent input data, but it's not
+ * impossible that in some cases we made a mistake and the input data is inconsistent.
+ */
+public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
+  @Inject private ApprovalsUtil approvalsUtil;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void cannotFormatWithNullApprovalCopierResult() throws Exception {
+    LabelTypes labelTypes = projectCache.get(project).get().getLabelTypes();
+    NullPointerException exception =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                approvalsUtil.formatApprovalCopierResult(
+                    /* approvalCopierResult= */ null, labelTypes));
+    assertThat(exception).hasMessageThat().isEqualTo("approvalCopierResult");
+  }
+
+  @Test
+  public void cannotFormatWithNullLabelTypes() throws Exception {
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    NullPointerException exception =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                approvalsUtil.formatApprovalCopierResult(
+                    approvalCopierResult, /* labelTypes= */ null));
+    assertThat(exception).hasMessageThat().isEqualTo("labelTypes");
+  }
+
+  @Test
+  public void format_noCopiedApprovals_noOutdatedApprovals() throws Exception {
+    LabelTypes labelTypes = projectCache.get(project).get().getLabelTypes();
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .isEmpty();
+  }
+
+  @Test
+  public void formatCopiedApproval_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Outdated Votes:\n* Code-Review+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Outdated Votes:\n* Code-Review+1\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_withCopyCondition_noUserInPredicate() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", -2);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of("is:MIN"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MAX"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review-2 (copy condition: \"**is:MIN** OR is:MAX\")\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_withCopyCondition_noUserInPredicate() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ "changekind:TRIVIAL_REBASE is:MAX")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("changekind:TRIVIAL_REBASE"))));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Outdated Votes:\n* Code-Review+2 (copy condition:"
+                + " \"changekind:TRIVIAL_REBASE **is:MAX**\")\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_withNonParseableCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void formatOutdatedApproval_withNonParseableCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Outdated Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void formatCopiedApproval_withCopyCondition_withUserInPredicate() throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void formatOutdatedpproval_withCopyCondition_withUserInPredicate() throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:MAX"))));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Outdated Votes:\n"
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN"
+                    + " OR (is:MAX **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void formatCopiedApproval_withCopyCondition_withUserInPredicateThatContainNonVisibleGroup()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+
+    // Set 'user' as the current user in the request scope.
+    // 'user' cannot see the Administrators group that is used in the copy condition.
+    // Parsing the copy condition should still succeed since ApprovalsUtil should use the internal
+    // user that can see everything when parsing the copy condition.
+    requestScopeOperations.setApiUser(user.id());
+
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void
+      formatOutdatedpproval_withCopyCondition_withUserInPredicateThatContainNonVisibleGroup()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:MAX"))));
+
+    // Set 'user' as the current user in the request scope.
+    // 'user' cannot see the Administrators group that is used in the copy condition.
+    // Parsing the copy condition should still succeed since ApprovalsUtil should use the internal
+    // user that can see everything when parsing the copy condition.
+    requestScopeOperations.setApiUser(user.id());
+
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Outdated Votes:\n"
+                    + "* Code-Review+1 by %s (copy condition: \"is:MIN"
+                    + " OR (is:MAX **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
+  }
+
+  @Test
+  public void formatCopiedApproval_withNonParseableCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void formatOutdatedApproval_withNonParseableCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(),
+            /* outdatedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())));
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Outdated Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabels_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (label type is missing)\n"
+                + "* Verified+1 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentValues_missingLabelType() throws Exception {
+    LabelTypes labelTypes = new LabelTypes(ImmutableList.of());
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2 (label type is missing)\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabel_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null),
+                createLabelType(/* labelName= */ "Verified", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1\n* Verified+1\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentValue_noCopyCondition() throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null)));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 2);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue("Copied Votes:\n* Code-Review+2 (copy condition: \"is:MIN OR **is:MAX**\")\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabel_withCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX"),
+                LabelType.builder(
+                        "Verified",
+                        ImmutableList.of(
+                            LabelValue.create((short) -1, "Fails"),
+                            LabelValue.create((short) 0, "No Vote"),
+                            LabelValue.create((short) 1, "Succeeds")))
+                    .setCopyCondition("is:MIN OR is:MAX")
+                    .build()));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+2 (copy condition: \"is:MIN OR **is:MAX**\")\n"
+                + "* Verified+1 (copy condition: \"is:MIN OR **is:MAX**\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate_samePassingAtoms()
+          throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "changekind:REWORK")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("changekind:REWORK"),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("changekind:REWORK"),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1, Code-Review+2 (copy condition: \"**changekind:REWORK**\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate_differentPassingAtoms()
+          throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "is:1 OR is:2")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:2"),
+                    /* failingAtoms= */ ImmutableSet.of("is:1")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of("is:1"),
+                    /* failingAtoms= */ ImmutableSet.of("is:2"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (copy condition: \"**is:1** OR is:2\")\n"
+                + "* Code-Review+2 (copy condition: \"is:1 OR **is:2**\")\n");
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withNonParseableCopyCondition_noUserInPredicate()
+      throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentLabel_withNonParseableCopyCondition_noUserInPredicate()
+          throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz"),
+                createLabelType(/* labelName= */ "Verified", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n"
+                + "* Verified+1 (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withNonParseableCopyCondition_noUserInPredicate()
+          throws Exception {
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz")));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            "Copied Votes:\n"
+                + "* Code-Review+1, Code-Review+2"
+                + " (non-parseable copy condition: \"foo bar baz\")\n");
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate_samePassingAtoms()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 2);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+2 by %s, %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate_differentPassingAtoms()
+          throws Exception {
+    String administratorsGroupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    String registeredUsersGroupUuid = SystemGroupBackend.REGISTERED_USERS.get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR (is:MAX approverin:%s)",
+                        administratorsGroupUuid, registeredUsersGroupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 2);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", registeredUsersGroupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of(
+                        "is:MIN", String.format("approverin:%s", administratorsGroupUuid))),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX",
+                        String.format("approverin:%s", administratorsGroupUuid),
+                        String.format("approverin:%s", registeredUsersGroupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)"
+                    + " OR (**is:MAX** **approverin:%s**)\")\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:MAX** approverin:%s)"
+                    + " OR (**is:MAX** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                administratorsGroupUuid,
+                registeredUsersGroupUuid,
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                administratorsGroupUuid,
+                registeredUsersGroupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentLabel_withCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid)),
+                createLabelType(
+                    /* labelName= */ "Verified",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", -2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of("is:MIN"),
+                    /* failingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid))),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:MAX", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review-2 by %s (copy condition: \"**is:MIN**"
+                    + " OR (is:MAX approverin:%s)\")\n"
+                    + "* Verified+1 by %s (copy condition: \"is:MIN"
+                    + " OR (**is:MAX** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                groupUuid,
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate_samePassingAtoms()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:ANY approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s, Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:ANY** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate_differentPassingAtoms()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:1 approverin:%s) OR (is:2 approverin:%s)",
+                        groupUuid, groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:2", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:1")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:1", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:2"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:1** **approverin:%s**)"
+                    + " OR (is:2 **approverin:%s**)\")\n"
+                    + "* Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (is:1 **approverin:%s**)"
+                    + " OR (**is:2** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                groupUuid,
+                groupUuid,
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid,
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_differentAndSameValue_withCopyCondition_withUserInPredicate()
+      throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    String groupUuid = SystemGroupBackend.REGISTERED_USERS.get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:ANY approverin:%s)", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user2, "Code-Review", 1);
+    PatchSetApproval patchSetApproval3 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval3,
+                    /* passingAtoms= */ ImmutableSet.of(
+                        "is:ANY", String.format("approverin:%s", groupUuid)),
+                    /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 by %s, %s, Code-Review+2 by %s"
+                    + " (copy condition: \"is:MIN OR (**is:ANY** **approverin:%s**)\")\n",
+                AccountTemplateUtil.getAccountTemplate(user.id()),
+                AccountTemplateUtil.getAccountTemplate(user2.id()),
+                AccountTemplateUtil.getAccountTemplate(admin.id()),
+                groupUuid));
+  }
+
+  @Test
+  public void formatMultipleApprovals_sameVote_withNonParseableCopyCondition_withUserInPredicate()
+      throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentLabel_withNonParseableCopyCondition_withUserInPredicate()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)),
+                createLabelType(
+                    /* labelName= */ "Verified",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n"
+                    + "* Verified+1 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid, groupUuid));
+  }
+
+  @Test
+  public void
+      formatMultipleApprovals_differentValue_withNonParseableCopyCondition_withUserInPredicate()
+          throws Exception {
+    String groupUuid =
+        groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+    LabelTypes labelTypes =
+        new LabelTypes(
+            ImmutableList.of(
+                createLabelType(
+                    /* labelName= */ "Code-Review",
+                    /* copyCondition= */ String.format(
+                        "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))));
+    PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+    PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+    ApprovalCopier.Result approvalCopierResult =
+        ApprovalCopier.Result.create(
+            /* copiedApprovals= */ ImmutableSet.of(
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval1,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of()),
+                ApprovalCopier.Result.PatchSetApprovalData.create(
+                    patchSetApproval2,
+                    /* passingAtoms= */ ImmutableSet.of(),
+                    /* failingAtoms= */ ImmutableSet.of())),
+            /* outdatedApprovals= */ ImmutableSet.of());
+    assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+        .hasValue(
+            String.format(
+                "Copied Votes:\n"
+                    + "* Code-Review+1, Code-Review+2 (non-parseable copy condition: \"is:MIN"
+                    + " OR (is:MAX approverin:%s) OR foo bar baz\")\n",
+                groupUuid));
+  }
+
+  @Test
+  public void copiedAndOutdatedApprovalsAreIncludedInChangeMessageOnPatchSetCreationByPush()
+      throws Exception {
+    // Add Verified label without copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Vote Code-Review-2 (sticky because it's a veto vote and the Code-Review label has "is:MIN" as
+    // part of its copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Vote Verified+1 (not sticky because the Verified label has no copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Verified", 1));
+
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    ChangeInfo change = change(r).get();
+    assertThat(Iterables.getLast(change.messages).message)
+        .isEqualTo(
+            "Uploaded patch set 2.\n"
+                + "\n"
+                + "Copied Votes:\n"
+                + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Verified+1\n");
+  }
+
+  @Test
+  public void
+      copiedAndOutdatedApprovalsAreIncludedInChangeMessageOnPatchSetCreationByPush_withReviewMessage()
+          throws Exception {
+    // Add Verified label without copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Vote Code-Review-2 (sticky because it's a veto vote and the Code-Review label has "is:MIN" as
+    // part of its copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Vote Verified+1 (not sticky because the Verified label has no copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Verified", 1));
+
+    String reviewMessage = "Foo-Bar-Baz";
+
+    amendChange(r.getChangeId(), "refs/for/master%m=" + reviewMessage, admin, testRepo)
+        .assertOkStatus();
+
+    ChangeInfo change = change(r).get();
+    assertThat(Iterables.getLast(change.messages).message)
+        .isEqualTo(
+            "Uploaded patch set 2.\n"
+                + "\n"
+                + "Foo-Bar-Baz\n"
+                + "\n"
+                + "Copied Votes:\n"
+                + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Verified+1\n");
+  }
+
+  @Test
+  public void copiedAndOutdatedApprovalsAreIncludedInChangeMessageOnPatchSetCreationByApi()
+      throws Exception {
+    // Add Verified label without copy condition.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    // Vote Code-Review-2 (sticky because it's a veto vote and the Code-Review label has "is:MIN" as
+    // part of its copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Vote Verified+1 (not sticky because the Verified label has no copy condition)
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Verified", 1));
+
+    gApi.changes().id(r.getChangeId()).edit().modifyFile("a.txt", RawInputUtil.create("content"));
+    gApi.changes().id(r.getChangeId()).edit().publish();
+
+    ChangeInfo change = change(r).get();
+    assertThat(Iterables.getLast(change.messages).message)
+        .isEqualTo(
+            "Patch Set 2: Published edit on patch set 1.\n"
+                + "\n"
+                + "Copied Votes:\n"
+                + "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Verified+1\n");
+  }
+
+  private PatchSetApproval createPatchSetApproval(
+      TestAccount testAccount, String label, int value) {
+    return PatchSetApproval.builder()
+        .key(
+            PatchSetApproval.key(
+                PatchSet.id(Change.id(1), 1), testAccount.id(), LabelId.create(label)))
+        .value(value)
+        .granted(TimeUtil.now())
+        .build();
+  }
+
+  private LabelType createLabelType(String labelName, @Nullable String copyCondition) {
+    LabelType.Builder labelTypeBuilder =
+        LabelType.builder(
+            labelName,
+            ImmutableList.of(
+                LabelValue.create((short) -2, "Vetoed"),
+                LabelValue.create((short) -1, "Disliked"),
+                LabelValue.create((short) 0, "No Vote"),
+                LabelValue.create((short) 1, "Liked"),
+                LabelValue.create((short) 2, "Approved")));
+    if (copyCondition != null) {
+      labelTypeBuilder.setCopyCondition(copyCondition);
+    }
+    return labelTypeBuilder.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
new file mode 100644
index 0000000..a055201
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
@@ -0,0 +1,1275 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+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.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+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.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.inject.Inject;
+import java.util.Optional;
+import java.util.function.Consumer;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Test to verify that {@link com.google.gerrit.server.restapi.change.PostReviewOp} copies approvals
+ * to follow-up patch sets if possible.
+ */
+public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @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.
+      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 this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are not copied to the follow-up patch set if
+   * the new approvals are not copyable because no matching copy rule is configured.
+   */
+  @Test
+  public void newApprovals_notCopied_copyingNotEnabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+2 Verified+1");
+    vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-2 Verified-1");
+
+    // Verify that no votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are copied to the follow-up patch set if the
+   * follow-up patch set has no regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void newApprovals_copied_noCurrentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2 (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
+    vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 2 (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are not copied to the follow-up patch set if
+   * the follow-up patch set has regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void newApprovals_notCopied_currentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set.
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Vote on the first patch set and verify change message.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review+1 Verified-1"));
+    vote(user, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review-1 Verified+1"));
+
+    // Verify that the votes have not been copied to the current patch set (since a current vote
+    // already exists).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are not copied to the follow-up patch set if
+   * the follow-up patch set has deletions of regular votes (non-copied deletion votes that override
+   * copied votes).
+   */
+  @Test
+  public void newApprovals_notCopied_currentDeletedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set.
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the current patch set.
+    deleteCurrentVotes(admin, changeId);
+    deleteCurrentVotes(user, changeId);
+
+    // Vote on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review+1 Verified-1"));
+    vote(user, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(), String.format("Patch Set 1: Code-Review-1 Verified+1"));
+
+    // Verify that the votes have not been copied to the current patch set (since a deletion vote
+    // already exists on the current patch set).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the updated approvals are not copyable because no matching copy rule is configured.
+   */
+  @Test
+  public void updatedApprovals_notCopied_copyingNotEnabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Update the votes on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 2, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+2 Verified-1");
+    vote(user, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
+
+    // Verify that no votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -1, 1, /* expectedToBeCopied= */ false);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+  }
+
+  /**
+   * Tests that if updated approvals on an outdated patch set are not copied to the follow-up patch
+   * set that existing copies of the approvals on the follow-up patch sets are unset.
+   */
+  @Test
+  public void updatedApprovals_notCopied_copyingNotEnabled_unsetsCopiedApprovals()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:MAX"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with votes that are copied.
+    vote(admin, changeId, patchSet1.number(), 1, 1);
+    vote(user, changeId, patchSet1.number(), 2, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 1, 1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Update the votes on the first patch set with votes that are not copied and verify the change
+    // messages.
+    vote(admin, changeId, patchSet1.number(), -1, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-1 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+1)"
+                + " since the new Code-Review-1 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified-1 vote is not copyable (copy condition: \"is:MAX\")."));
+    vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+                + " since the new Code-Review-2 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified-1 vote is not copyable (copy condition: \"is:MAX\")."));
+
+    // Verify that the copied votes on the current patch set have been unset.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, -1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are copied to the follow-up patch set if
+   * the follow-up patch set has no regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void updatedApprovals_copied_noCurrentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:MAX"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with votes that are not copied.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have not been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Update the votes on the first patch set with votes that are copied and verify the change
+    // messages.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:MAX\")."));
+    vote(user, changeId, patchSet1.number(), 1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+1 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+1 has been copied to patch set 2"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:MAX\")."));
+
+    // Verify that the votes have been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, 1, 1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 1, 1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void updatedApprovals_notCopied_currentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied votes).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Update the votes on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
+    vote(user, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+1 Verified-1");
+
+    // Verify that the votes have not been copied to the current patch set (since a current vote
+    // already exists).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has deletions of regular votes (non-copied deletion votes that
+   * override copied votes).
+   */
+  @Test
+  public void updatedApprovals_notCopied_currentDeletedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied approvals).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the current patch set.
+    deleteCurrentVotes(admin, changeId);
+    deleteCurrentVotes(user, changeId);
+
+    // Update the votes on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), -1, 1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
+    vote(user, changeId, patchSet1.number(), 1, -1);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+1 Verified-1");
+
+    // Verify that the votes have not been copied to the current patch set (since a deletion vote
+    // already exists on the current patch set).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, -1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that updated approvals on an outdated patch set are copied to the follow-up patch set if
+   * the follow-up patch set has copied votes (the copied votes on the follow-up patch set are
+   * updated).
+   */
+  @Test
+  public void updatedApprovals_copied_currentCopiedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), 2, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, -2, -1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Update the votes on the first patch set with votes that are copied and verify the change
+    // messages.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2 (was Code-Review-2)"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 2 (was Verified-1)"
+                + " (copy condition: \"is:ANY\")."));
+    vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 2 (was Code-Review+2)"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 2 (was Verified+1)"
+                + " (copy condition: \"is:ANY\")."));
+
+    // Verify that the votes have been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the deleted approvals are not copyable because no matching copy rule is configured.
+   */
+  @Test
+  public void deletedApprovals_notCopied_copyingNotEnabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set.
+    vote(admin, changeId, patchSet2.number(), -2, -1);
+    vote(user, changeId, patchSet2.number(), 2, 1);
+
+    // Delete the votes on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
+    vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
+
+    // Verify that the vote deletions have not been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, -2, -1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 2, 1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has no regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void deletedApprovals_notCopied_noCurrentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:0 OR is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:0 OR is:1"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with votes that are not copied.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have not been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Delete the votes on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
+    vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
+
+    // Verify that there are still no votes on the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb (the deletion votes have not been copied).
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has regular votes (non-copied votes that override copied votes).
+   */
+  @Test
+  public void deletedApprovals_notCopied_currentVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied votes).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
+    vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
+
+    // Verify that the vote deletions have not been copied to the current patch set (since a current
+    // vote already exists).
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are not copied to the follow-up patch set
+   * if the follow-up patch set has deletions of regular votes (non-copied deletion votes that
+   * override copied votes).
+   */
+  @Test
+  public void deletedApprovals_notCopied_currentDeletedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 1, -1);
+    vote(user, changeId, patchSet1.number(), -1, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Vote on the current patch set (overrides the copied approvals).
+    vote(admin, changeId, patchSet2.number(), 2, 1);
+    vote(user, changeId, patchSet2.number(), -2, -1);
+
+    // Delete the votes on the current patch set.
+    deleteCurrentVotes(admin, changeId);
+    deleteCurrentVotes(user, changeId);
+
+    // Delete the votes on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
+    vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
+
+    // Verify that there are still no votes on the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb (the deletion votes have not been copied).
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are copied to the follow-up patch set if
+   * the follow-up patch set has copied votes (the copied votes on the follow-up patch set are
+   * removed).
+   */
+  @Test
+  public void deletedApprovals_copied_currentCopiedVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), -2, -1);
+    vote(user, changeId, patchSet1.number(), 2, 1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    // Verify that the votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, -2, -1);
+    assertCurrentVotes(c, user, 2, 1);
+
+    // Delete the votes on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review-2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified-1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
+    vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
+
+    // Verify that the vote deletions have been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+  }
+
+  /** Tests that new approvals on an outdated patch set are copied to all follow-up patch sets. */
+  @Test
+  public void copyNewApprovalAcrossMultipleFollowUpPatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\")."));
+    vote(user, changeId, patchSet1.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 2, 3, 4"
+                + " (copy condition: \"is:ANY\")."));
+
+    // Verify that votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that new approvals on an outdated patch set are copied to all follow-up patch sets, but
+   * not across patch sets have non-copied votes.
+   */
+  @Test
+  public void
+      copyNewApprovalAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    // Vote on the third patch set with non-copyable Code-Review votes and copyable Verified votes.
+    vote(admin, changeId, patchSet3.number(), -2, -1);
+    vote(user, changeId, patchSet3.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Verify that the Verified votes from patch set 3 have been copied to the current patch
+    // set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Vote on the first patch set with copyable votes and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 2 "
+                + "(copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
+    vote(user, changeId, patchSet1.number(), 1, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: Code-Review+1 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+1 has been copied to patch set 2"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
+
+    // Verify that votes have been not copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 1, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 1, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet3.id(), user, -1, -1, /* expectedToBeCopied= */ false);
+    assertNoApproval(patchSet4.id(), admin, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), admin, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+    assertNoApproval(patchSet4.id(), user, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), user, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are copied to all follow-up patch sets.
+   */
+  @Test
+  public void copyApprovalDeletionAcrossMultipleFollowUpPatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), -2, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Verify that votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Delete the votes on the first patch set and verify the change messages.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set"
+                + " 2 (was Code-Review+2), 3 (was Code-Review+2), 4 (was Code-Review+2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set"
+                + " 2 (was Verified+1), 3 (was Verified+1), 4 (was Verified+1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
+    vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set"
+                + " 2 (was Code-Review-2), 3 (was Code-Review-2), 4 (was Code-Review-2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:ANY\").\n"
+                + "* Copied Verified vote has been removed from patch set"
+                + " 2 (was Verified-1), 3 (was Verified-1), 4 (was Verified-1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
+
+    // Verify that the votes has been copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, 0);
+    assertCurrentVotes(c, user, 0, 0);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+  }
+
+  /**
+   * Tests that deleted approvals on an outdated patch set are copied to all follow-up patch sets,
+   * but not across patch sets have non-copied votes.
+   */
+  @Test
+  public void
+      copyApprovalDeletionAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
+          throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    // Vote on the first patch set with copyable votes.
+    vote(admin, changeId, patchSet1.number(), 2, 1);
+    vote(user, changeId, patchSet1.number(), 1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    // Vote on the third patch set with non-copyable Code-Review votes and copyable Verified votes.
+    vote(admin, changeId, patchSet3.number(), -2, -1);
+    vote(user, changeId, patchSet3.number(), -1, -1);
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Verify that the Verified votes from patch set 3 have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Delete the votes on the first patch set.
+    vote(admin, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
+    vote(user, changeId, patchSet1.number(), 0, 0);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 1: -Code-Review -Verified\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+1)"
+                + " since the new Code-Review=0 vote is not copyable"
+                + " (copy condition: \"is:1 OR is:2\").\n"
+                + "* Copied Verified vote has been removed from patch set 2 (was Verified-1)"
+                + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
+
+    // Verify that the vote deletions have been not copied to the current patch set.
+    c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 0, -1);
+    assertCurrentVotes(c, user, 0, -1);
+
+    // Verify the approvals in NoteDb.
+    assertApprovals(patchSet1.id(), admin, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet1.id(), user, 0, 0, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet2.id(), admin, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet2.id(), user, 0, 0, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet3.id(), admin, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet3.id(), user, -1, -1, /* expectedToBeCopied= */ false);
+    assertNoApproval(patchSet4.id(), admin, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), admin, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+    assertNoApproval(patchSet4.id(), user, LabelId.CODE_REVIEW);
+    assertApproval(patchSet4.id(), user, LabelId.VERIFIED, -1, /* expectedToBeCopied= */ true);
+  }
+
+  /** Tests that new approvals on an outdated patch set are not copied to predecessor patch sets. */
+  @Test
+  public void notCopyToPredecessorPatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+    updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    PatchSet patchSet1 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet2 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet3 = r.getChange().currentPatchSet();
+
+    r = amendChange(changeId);
+    r.assertOkStatus();
+    PatchSet patchSet4 = r.getChange().currentPatchSet();
+
+    // Vote on the third patch set and verify the change messages.
+    vote(admin, changeId, patchSet3.number(), 2, 1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 3: Code-Review+2 Verified+1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review+2 has been copied to patch set 4 (copy condition: \"is:ANY\").\n"
+                + "* Verified+1 has been copied to patch set 4 (copy condition: \"is:ANY\")."));
+    vote(user, changeId, patchSet3.number(), -2, -1);
+    assertLastChangeMessage(
+        r.getChangeId(),
+        String.format(
+            "Patch Set 3: Code-Review-2 Verified-1\n\n"
+                + "Copied votes on follow-up patch sets have been updated:\n"
+                + "* Code-Review-2 has been copied to patch set 4 (copy condition: \"is:ANY\").\n"
+                + "* Verified-1 has been copied to patch set 4 (copy condition: \"is:ANY\")."));
+
+    // Verify that votes have been copied to the current patch set.
+    ChangeInfo c = detailedChange(changeId);
+    assertCurrentVotes(c, admin, 2, 1);
+    assertCurrentVotes(c, user, -2, -1);
+
+    // Verify the approvals in NoteDb.
+    assertNoApprovals(patchSet1.id(), admin);
+    assertNoApprovals(patchSet1.id(), user);
+    assertNoApprovals(patchSet2.id(), admin);
+    assertNoApprovals(patchSet2.id(), user);
+    assertApprovals(patchSet3.id(), admin, 2, 1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet3.id(), user, -2, -1, /* expectedToBeCopied= */ false);
+    assertApprovals(patchSet4.id(), admin, 2, 1, /* expectedToBeCopied= */ true);
+    assertApprovals(patchSet4.id(), user, -2, -1, /* expectedToBeCopied= */ true);
+  }
+
+  private void updateCodeReviewLabel(Consumer<LabelType.Builder> update) throws Exception {
+    updateLabel(LabelId.CODE_REVIEW, update);
+  }
+
+  private void updateVerifiedLabel(Consumer<LabelType.Builder> update) throws Exception {
+    updateLabel(LabelId.VERIFIED, update);
+  }
+
+  private void updateLabel(String labelName, Consumer<LabelType.Builder> update) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(labelName, update);
+      u.save();
+    }
+  }
+
+  private ChangeInfo detailedChange(String changeId) throws Exception {
+    return gApi.changes().id(changeId).get(DETAILED_LABELS, CURRENT_REVISION, CURRENT_COMMIT);
+  }
+
+  private void vote(
+      TestAccount user, String changeId, int psNum, int codeReviewVote, int verifiedVote)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput in =
+        new ReviewInput()
+            .label(LabelId.CODE_REVIEW, codeReviewVote)
+            .label(LabelId.VERIFIED, verifiedVote);
+    gApi.changes().id(changeId).revision(psNum).review(in);
+  }
+
+  private void deleteCurrentVotes(TestAccount user, String changeId) throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    deleteCurrentVote(user, changeId, LabelId.CODE_REVIEW);
+    deleteCurrentVote(user, changeId, LabelId.VERIFIED);
+  }
+
+  private void deleteCurrentVote(TestAccount user, String changeId, String label) throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(changeId).reviewer(user.id().toString()).deleteVote(label);
+  }
+
+  private void assertCurrentVotes(
+      ChangeInfo c, TestAccount user, int codeReviewVote, int verifiedVote) {
+    assertCurrentVote(c, user, LabelId.CODE_REVIEW, codeReviewVote);
+    assertCurrentVote(c, user, LabelId.VERIFIED, verifiedVote);
+  }
+
+  private void assertCurrentVote(ChangeInfo c, TestAccount user, String label, int expectedVote) {
+    Integer vote = 0;
+    if (c.labels.get(label) != null && c.labels.get(label).all != null) {
+      for (ApprovalInfo approval : c.labels.get(label).all) {
+        if (approval._accountId == user.id().get()) {
+          vote = approval.value;
+          break;
+        }
+      }
+    }
+
+    assertWithMessage("label = " + label).that(vote).isEqualTo(expectedVote);
+  }
+
+  private void assertNoApprovals(PatchSet.Id patchSetId, TestAccount user) {
+    assertNoApproval(patchSetId, user, LabelId.CODE_REVIEW);
+    assertNoApproval(patchSetId, user, LabelId.VERIFIED);
+  }
+
+  private void assertNoApproval(PatchSet.Id patchSetId, TestAccount user, String label) {
+    ChangeNotes notes = notesFactory.create(project, patchSetId.changeId());
+    Optional<PatchSetApproval> patchSetApproval =
+        notes.getApprovals().all().get(patchSetId).stream()
+            .filter(psa -> psa.accountId().equals(user.id()) && psa.label().equals(label))
+            .findAny();
+    assertThat(patchSetApproval).isEmpty();
+  }
+
+  private void assertApprovals(
+      PatchSet.Id patchSetId,
+      TestAccount user,
+      int expectedCodeReviewVote,
+      int expectedVerifiedVote,
+      boolean expectedToBeCopied) {
+    assertApproval(
+        patchSetId, user, LabelId.CODE_REVIEW, expectedCodeReviewVote, expectedToBeCopied);
+    assertApproval(patchSetId, user, LabelId.VERIFIED, expectedVerifiedVote, expectedToBeCopied);
+  }
+
+  private void assertApproval(
+      PatchSet.Id patchSetId,
+      TestAccount user,
+      String label,
+      int expectedVote,
+      boolean expectedToBeCopied) {
+    ChangeNotes notes = notesFactory.create(project, patchSetId.changeId());
+    Optional<PatchSetApproval> patchSetApproval =
+        notes.getApprovals().all().get(patchSetId).stream()
+            .filter(psa -> psa.accountId().equals(user.id()) && psa.label().equals(label))
+            .findAny();
+    assertThat(patchSetApproval).isPresent();
+    assertThat(patchSetApproval.get().value()).isEqualTo((short) expectedVote);
+    assertThat(patchSetApproval.get().copied()).isEqualTo(expectedToBeCopied);
+  }
+
+  private void assertLastChangeMessage(String changeId, String expectedMessage)
+      throws RestApiException {
+    assertThat(Iterables.getLast(gApi.changes().id(changeId).get().messages).message)
+        .isEqualTo(expectedMessage);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
index aee7f6f..2bde1652 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
index f4cf96d..3ffbda1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -46,7 +47,11 @@
 
     String oddChangeId = createChange().getChangeId();
     String evenChangeId = createChange().getChangeId();
-    assertThat(getChanges(queryChanges)).hasSize(0);
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> getChanges(queryChanges));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("Unrecognized value: changeNumberEven_myplugin");
 
     try (AutoCloseable ignored = installPlugin("myplugin", IsOperatorModule.class)) {
       List<?> changes = getChanges(queryChanges);
@@ -57,8 +62,6 @@
       assertThat(outputChangeId).isEqualTo(evenChangeId);
       assertThat(outputChangeId).isNotEqualTo(oddChangeId);
     }
-
-    assertThat(getChanges(queryChanges)).hasSize(0);
   }
 
   protected static class IsOperatorModule extends AbstractModule {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 96bc65d..bb8f3f3 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -16,6 +16,10 @@
 
 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.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 static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.mockito.ArgumentMatchers.any;
@@ -36,12 +40,16 @@
 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.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 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.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -66,6 +74,7 @@
 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.change.EmailReviewComments;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.OnPostReview;
@@ -78,11 +87,14 @@
 import com.google.inject.Module;
 import java.sql.Timestamp;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -95,6 +107,7 @@
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private ProjectOperations projectOperations;
 
   private static final String COMMENT_TEXT = "The comment text";
   private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -117,6 +130,7 @@
           COMMENT_TEXT.length());
 
   @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> captor;
+  @Captor private ArgumentCaptor<CommentValidationContext> captorCtx;
 
   private static final Correspondence<CommentForValidation, CommentForValidation>
       COMMENT_CORRESPONDENCE =
@@ -152,7 +166,7 @@
   @Test
   public void validateCommentsInInput_commentOK() throws Exception {
     PushOneCommit.Result r = createChange();
-    when(mockCommentValidator.validateComments(eq(contextFor(r)), captor.capture()))
+    when(mockCommentValidator.validateComments(captorCtx.capture(), captor.capture()))
         .thenReturn(ImmutableList.of());
 
     ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
@@ -165,6 +179,7 @@
 
     assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, FILE_COMMENT_FOR_VALIDATION);
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
+    assertThat(captorCtx.getAllValues()).containsExactly(contextFor(r));
   }
 
   @Test
@@ -631,8 +646,15 @@
     assertThat(r.getChange().approvals().values()).hasSize(1);
 
     // Post without changing the vote.
+    ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+    ObjectId metaId = notes.getMetaId();
+    assertAttentionSet(notes.getAttentionSet(), user.id());
     input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
     gApi.changes().id(r.getChangeId()).current().review(input);
+    notes = notesFactory.create(project, r.getChange().getId());
+    // Change meta ID did not change since the update is No/Op. Attention set is same.
+    assertThat(notes.getMetaId()).isEqualTo(metaId);
+    assertAttentionSet(notes.getAttentionSet(), user.id());
 
     // Second vote replaced the original vote, so still only one vote.
     assertThat(r.getChange().approvals().values()).hasSize(1);
@@ -964,6 +986,36 @@
                 user.fullName()));
   }
 
+  @Test
+  public void votesInChangeMessageAreSorted() throws Exception {
+    // Create Verify label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 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+2 Verified+1"));
+  }
+
   private static class TestListener implements CommentAddedListener {
     public CommentAddedListener.Event lastCommentAddedEvent;
 
@@ -985,7 +1037,9 @@
 
   private static CommentValidationContext contextFor(PushOneCommit.Result result) {
     return CommentValidationContext.create(
-        result.getChange().getId().get(), result.getChange().project().get());
+        result.getChange().getId().get(),
+        result.getChange().project().get(),
+        result.getChange().change().getDest().branch());
   }
 
   private void assertValidatorCalledWith(CommentForValidation... commentsForValidation) {
@@ -1046,9 +1100,17 @@
 
     @Override
     public Optional<SubmitRecord> evaluate(ChangeData changeData) {
-      count++;
+      if (!isAsyncCallForSendingReviewCommentsEmail()) {
+        count++;
+      }
       return Optional.empty();
     }
+
+    private boolean isAsyncCallForSendingReviewCommentsEmail() {
+      return Arrays.stream(Thread.currentThread().getStackTrace())
+          .map(StackTraceElement::getClassName)
+          .anyMatch(className -> EmailReviewComments.class.getName().equals(className));
+    }
   }
 
   private static class TestReviewerAddedListener implements ReviewerAddedListener {
@@ -1082,4 +1144,10 @@
           .collect(toImmutableSet());
     }
   }
+
+  private static void assertAttentionSet(
+      ImmutableSet<AttentionSetUpdate> attentionSet, Account.Id... accounts) {
+    assertThat(attentionSet.stream().map(AttentionSetUpdate::account).collect(Collectors.toList()))
+        .containsExactlyElementsIn(accounts);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index 97b7148..267f5a7 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -251,7 +251,7 @@
   private void markMergedChangePrivate(Change.Id changeId) throws Exception {
     try (BatchUpdate u =
         batchUpdateFactory.create(
-            project, identifiedUserFactory.create(admin.id()), TimeUtil.nowTs())) {
+            project, identifiedUserFactory.create(admin.id()), TimeUtil.now())) {
       u.addOp(
               changeId,
               new BatchUpdateOp() {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 31381dd..3bfb573 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -371,7 +371,7 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    for (AccessSection s : ImmutableList.copyOf(cfg.getAccessSections())) {
+    for (AccessSection s : cfg.getAccessSections()) {
       if (s.getName().startsWith("refs/heads/")
           || s.getName().startsWith("refs/for/")
           || s.getName().equals("refs/*")) {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
new file mode 100644
index 0000000..74bd94e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -0,0 +1,1130 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+  RebaseIT.RebaseViaRevisionApi.class, //
+  RebaseIT.RebaseViaChangeApi.class, //
+  RebaseIT.RebaseChain.class, //
+})
+public class RebaseIT {
+  public abstract static class Base extends AbstractDaemonTest {
+    @Inject protected RequestScopeOperations requestScopeOperations;
+    @Inject protected ProjectOperations projectOperations;
+    @Inject protected ExtensionRegistry extensionRegistry;
+
+    @FunctionalInterface
+    protected interface RebaseCall {
+      void call(String id) throws RestApiException;
+    }
+
+    @FunctionalInterface
+    protected interface RebaseCallWithInput {
+      void call(String id, RebaseInput in) throws RestApiException;
+    }
+
+    protected RebaseCall rebaseCall;
+    protected RebaseCallWithInput rebaseCallWithInput;
+
+    protected void init(RebaseCall call, RebaseCallWithInput callWithInput) {
+      this.rebaseCall = call;
+      this.rebaseCallWithInput = callWithInput;
+    }
+
+    @Test
+    public void rebaseChange() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the second change
+      rebaseCall.call(r2.getChangeId());
+
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2);
+
+      // Rebasing the second change again should fail
+      verifyChangeIsUpToDate(r2);
+    }
+
+    @Test
+    public void rebaseAbandonedChange() throws Exception {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo info = info(changeId);
+      assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("Change " + r.getChange().getId() + " is abandoned");
+    }
+
+    @Test
+    public void rebaseOntoAbandonedChange() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Abandon the first change
+      String changeId = r.getChangeId();
+      assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo info = info(changeId);
+      assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r.getCommit().name();
+
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
+    }
+
+    @Test
+    public void rebaseOntoSelf() throws Exception {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String commit = r.getCommit().name();
+      RebaseInput ri = new RebaseInput();
+      ri.base = commit;
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class, () -> rebaseCallWithInput.call(changeId, ri));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains("cannot rebase change " + r.getChange().getId() + " onto itself");
+    }
+
+    @Test
+    public void rebaseChangeBaseRecursion() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange();
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r2.getCommit().name();
+      String expectedMessage =
+          "base change "
+              + r2.getChangeId()
+              + " is a descendant of the current change - recursion not allowed";
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r1.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains(expectedMessage);
+    }
+
+    @Test
+    public void cannotRebaseOntoBaseThatIsNotPresentInTargetBranch() throws Exception {
+      ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+      BranchInput branchInput = new BranchInput();
+      branchInput.revision = initial.getName();
+      gApi.projects().name(project.get()).branch("foo").create(branchInput);
+
+      PushOneCommit.Result r1 =
+          pushFactory
+              .create(admin.newIdent(), testRepo, "Change on foo branch", "a.txt", "a-content")
+              .to("refs/for/foo");
+      approve(r1.getChangeId());
+      gApi.changes().id(r1.getChangeId()).current().submit();
+
+      // reset HEAD in order to create a sibling of the first change
+      testRepo.reset(initial);
+
+      PushOneCommit.Result r2 =
+          pushFactory
+              .create(admin.newIdent(), testRepo, "Change on master branch", "b.txt", "b-content")
+              .to("refs/for/master");
+
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.base = r1.getCommit().getName();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), rebaseInput));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "base change is targeting wrong branch: %s,refs/heads/foo", project.get()));
+
+      rebaseInput.base = "refs/heads/foo";
+      thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r2.getChangeId(), rebaseInput));
+      assertThat(thrown)
+          .hasMessageThat()
+          .contains(
+              String.format(
+                  "base revision is missing from the destination branch: %s", rebaseInput.base));
+    }
+
+    @Test
+    public void rebaseUpToDateChange() throws Exception {
+      PushOneCommit.Result r = createChange();
+      verifyChangeIsUpToDate(r);
+    }
+
+    @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();
+
+      rebaseCall.call(r.getChangeId());
+
+      // 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();
+
+      rebaseCall.call(r.getChangeId());
+
+      // change is still work in progress after rebase
+      assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
+    }
+
+    @Test
+    public void rebaseAsUploaderInAttentionSet() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      TestAccount admin2 = accountCreator.admin2();
+      requestScopeOperations.setApiUser(admin2.id());
+      amendChangeWithUploader(r2, project, admin2);
+      gApi.changes()
+          .id(r2.getChangeId())
+          .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
+
+      rebaseCall.call(r2.getChangeId());
+    }
+
+    @Test
+    public void rebaseOnChangeNumber() throws Exception {
+      String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+      Change.Id id1 = r1.getChange().getId();
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r2.getChangeId(), in);
+
+      Change.Id id2 = r2.getChange().getId();
+      ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      List<RelatedChangeAndCommitInfo> related =
+          gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
+      assertThat(related).hasSize(2);
+      assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
+      assertThat(related.get(0)._revisionNumber).isEqualTo(2);
+      assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
+      assertThat(related.get(1)._revisionNumber).isEqualTo(1);
+    }
+
+    @Test
+    public void rebaseOnClosedChange() throws Exception {
+      String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+      // Submit first change.
+      Change.Id id1 = r1.getChange().getId();
+      gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(id1.get()).current().submit();
+
+      // Rebase second change on first change.
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r2.getChangeId(), in);
+
+      Change.Id id2 = r2.getChange().getId();
+      ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      ri2 = ci2.revisions.get(ci2.currentRevision);
+      assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
+    }
+
+    @Test
+    public void rebaseOnNonExistingChange() throws Exception {
+      String changeId = createChange().getChangeId();
+      RebaseInput in = new RebaseInput();
+      in.base = "999999";
+      UnprocessableEntityException exception =
+          assertThrows(
+              UnprocessableEntityException.class, () -> rebaseCallWithInput.call(changeId, in));
+      assertThat(exception).hasMessageThat().contains("Base change not found: " + in.base);
+    }
+
+    @Test
+    public void rebaseNotAllowedWithoutPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+    }
+
+    @Test
+    public void rebaseAllowedWithPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      rebaseCall.call(changeId);
+    }
+
+    @Test
+    public void rebaseNotAllowedWithoutPushPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+          .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      requestScopeOperations.setApiUser(user.id());
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+    }
+
+    @Test
+    public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the second
+      String changeId = r2.getChangeId();
+      AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+      assertThat(thrown).hasMessageThat().contains("rebase not permitted");
+    }
+
+    @Test
+    public void rebaseWithValidationOptions() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      RebaseInput rebaseInput = new RebaseInput();
+      rebaseInput.validationOptions = ImmutableMap.of("key", "value");
+
+      TestCommitValidationListener testCommitValidationListener =
+          new TestCommitValidationListener();
+      try (ExtensionRegistry.Registration unusedRegistration =
+          extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+        // Rebase the second change
+        rebaseCallWithInput.call(r2.getChangeId(), rebaseInput);
+        assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+            .containsExactly("key", "value");
+      }
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId, Change.Id baseChangeId, boolean shouldHaveApproval)
+        throws RestApiException {
+      verifyRebaseForChange(changeId, baseChangeId, shouldHaveApproval, 2);
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId,
+        Change.Id baseChangeId,
+        boolean shouldHaveApproval,
+        int expectedNumRevisions)
+        throws RestApiException {
+      ChangeInfo baseInfo = gApi.changes().id(baseChangeId.get()).get(CURRENT_REVISION);
+      verifyRebaseForChange(
+          changeId, baseInfo.currentRevision, shouldHaveApproval, expectedNumRevisions);
+    }
+
+    protected void verifyRebaseForChange(
+        Change.Id changeId, String baseCommit, boolean shouldHaveApproval, int expectedNumRevisions)
+        throws RestApiException {
+      ChangeInfo info =
+          gApi.changes().id(changeId.get()).get(CURRENT_REVISION, CURRENT_COMMIT, DETAILED_LABELS);
+
+      RevisionInfo r = info.revisions.get(info.currentRevision);
+      assertThat(r._number).isEqualTo(expectedNumRevisions);
+
+      // ...and the base should be correct
+      assertThat(r.commit.parents).hasSize(1);
+      assertWithMessage("base commit for change " + changeId)
+          .that(r.commit.parents.get(0).commit)
+          .isEqualTo(baseCommit);
+
+      // ...and the committer and description should be correct
+      GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+      assertThat(committer.name).isEqualTo(admin.fullName());
+      assertThat(committer.email).isEqualTo(admin.email());
+      String description = info.revisions.get(info.currentRevision).description;
+      assertThat(description).isEqualTo("Rebase");
+
+      if (shouldHaveApproval) {
+        // ...and the approval was copied
+        LabelInfo cr = info.labels.get(LabelId.CODE_REVIEW);
+        assertThat(cr).isNotNull();
+        assertThat(cr.all).isNotNull();
+        assertThat(cr.all).hasSize(1);
+        assertThat(cr.all.get(0).value).isEqualTo(1);
+      }
+    }
+
+    protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+      assertThat(thrown).hasMessageThat().contains("Change is already up to date");
+    }
+
+    protected static class TestCommitValidationListener implements CommitValidationListener {
+      public CommitReceivedEvent receiveEvent;
+
+      @Override
+      public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+          throws CommitValidationException {
+        this.receiveEvent = receiveEvent;
+        return ImmutableList.of();
+      }
+    }
+
+    protected static class TestWorkInProgressStateChangedListener
+        implements WorkInProgressStateChangedListener {
+      boolean invoked;
+      Boolean wip;
+
+      @Override
+      public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
+        this.invoked = true;
+        this.wip =
+            event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+      }
+    }
+  }
+
+  public abstract static class Rebase extends Base {
+    @Test
+    public void rebaseChangeBase() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      RebaseInput ri = new RebaseInput();
+
+      // rebase r3 directly onto master (break dep. towards r2)
+      ri.base = "";
+      rebaseCallWithInput.call(r3.getChangeId(), ri);
+      PatchSet ps3 = r3.getPatchSet();
+      assertThat(ps3.id().get()).isEqualTo(2);
+
+      // rebase r2 onto r3 (referenced by ref)
+      ri.base = ps3.id().toRefName();
+      rebaseCallWithInput.call(r2.getChangeId(), ri);
+      PatchSet ps2 = r2.getPatchSet();
+      assertThat(ps2.id().get()).isEqualTo(2);
+
+      // rebase r1 onto r2 (referenced by commit)
+      ri.base = ps2.commitId().name();
+      rebaseCallWithInput.call(r1.getChangeId(), ri);
+      PatchSet ps1 = r1.getPatchSet();
+      assertThat(ps1.id().get()).isEqualTo(2);
+
+      // rebase r1 onto r3 (referenced by change number)
+      ri.base = String.valueOf(r3.getChange().getId().get());
+      rebaseCallWithInput.call(r1.getChangeId(), ri);
+      assertThat(r1.getPatchSetId().get()).isEqualTo(3);
+    }
+
+    @Test
+    public void rebaseWithConflict_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 (ExtensionRegistry.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
+    public void rebaseWithConflict_conflictsForbidden() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              PushOneCommit.SUBJECT,
+              PushOneCommit.FILE_NAME,
+              "other content",
+              "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      ResourceConflictException exception =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r2.getChangeId()));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Change %s could not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n%s",
+                  r2.getChange().getId(), PushOneCommit.FILE_NAME));
+    }
+
+    @Test
+    public void rebaseFromRelationChainToClosedChange() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+
+      createChange();
+      PushOneCommit.Result r3 = createChange();
+
+      // Submit first change.
+      Change.Id id1 = r1.getChange().getId();
+      gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(id1.get()).current().submit();
+
+      // Rebase third change on first change.
+      RebaseInput in = new RebaseInput();
+      in.base = id1.toString();
+      rebaseCallWithInput.call(r3.getChangeId(), in);
+
+      Change.Id id3 = r3.getChange().getId();
+      ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+      RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
+      assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+      assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
+    }
+  }
+
+  public static class RebaseViaRevisionApi extends Rebase {
+    @Before
+    public void setUp() throws Exception {
+      init(
+          id -> gApi.changes().id(id).current().rebase(),
+          (id, in) -> gApi.changes().id(id).current().rebase(in));
+    }
+
+    @Test
+    public void rebaseOutdatedPatchSet() throws Exception {
+      String fileName1 = "a.txt";
+      String fileContent1 = "some content";
+      String fileName2 = "b.txt";
+      String fileContent2Ps1 = "foo";
+      String fileContent2Ps2 = "foo/bar";
+
+      // Create two changes both with the same parent touching disjunct files
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName1, fileContent1)
+              .to("refs/for/master");
+      r.assertOkStatus();
+      String changeId1 = r.getChangeId();
+      testRepo.reset("HEAD~1");
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName2, fileContent2Ps1);
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      String changeId2 = r2.getChangeId();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(changeId1).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Amend the second change so that it has 2 patch sets
+      amendChange(
+              changeId2,
+              "refs/for/master",
+              admin,
+              testRepo,
+              PushOneCommit.SUBJECT,
+              fileName2,
+              fileContent2Ps2)
+          .assertOkStatus();
+      ChangeInfo changeInfo2 = gApi.changes().id(changeId2).get();
+      assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(2);
+
+      // Rebase the first patch set of the second change
+      gApi.changes().id(changeId2).revision(1).rebase();
+
+      // Second change should have 3 patch sets
+      changeInfo2 = gApi.changes().id(changeId2).get();
+      assertThat(changeInfo2.revisions.get(changeInfo2.currentRevision)._number).isEqualTo(3);
+
+      // ... and the committer and description should be correct
+      ChangeInfo info = gApi.changes().id(changeId2).get(CURRENT_REVISION, CURRENT_COMMIT);
+      GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
+      assertThat(committer.name).isEqualTo(admin.fullName());
+      assertThat(committer.email).isEqualTo(admin.email());
+      String description = info.revisions.get(info.currentRevision).description;
+      assertThat(description).isEqualTo("Rebase");
+
+      // ... and the file contents should match with patch set 1 based on change1
+      assertThat(gApi.changes().id(changeId2).current().file(fileName1).content().asString())
+          .isEqualTo(fileContent1);
+      assertThat(gApi.changes().id(changeId2).current().file(fileName2).content().asString())
+          .isEqualTo(fileContent2Ps1);
+    }
+  }
+
+  public static class RebaseViaChangeApi extends Rebase {
+    @Before
+    public void setUp() throws Exception {
+      init(id -> gApi.changes().id(id).rebase(), (id, in) -> gApi.changes().id(id).rebase(in));
+    }
+  }
+
+  public static class RebaseChain extends Base {
+    @Before
+    public void setUp() throws Exception {
+      init(
+          id -> {
+            @SuppressWarnings("unused")
+            Object unused = gApi.changes().id(id).rebaseChain();
+          },
+          (id, in) -> {
+            @SuppressWarnings("unused")
+            Object unused = gApi.changes().id(id).rebaseChain(in);
+          });
+    }
+
+    @Override
+    protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+      ResourceConflictException thrown =
+          assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+      assertThat(thrown).hasMessageThat().contains("The whole chain is already up to date.");
+    }
+
+    @Test
+    public void rebaseChain() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3
+      //       * r4
+      //         *r5
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange();
+      PushOneCommit.Result r4 = createChange();
+      PushOneCommit.Result r5 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Add an approval whose score should be copied on trivial rebase
+      gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+      gApi.changes().id(r3.getChangeId()).current().review(ReviewInput.recommend());
+
+      // Rebase the chain through r4.
+      verifyRebaseChainResponse(
+          gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r2, r3, r4);
+
+      // Only r2, r3 and r4 are rebased.
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2);
+      verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), true);
+      verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false);
+
+      verifyChangeIsUpToDate(r2);
+      verifyChangeIsUpToDate(r3);
+      verifyChangeIsUpToDate(r4);
+
+      // r5 wasn't rebased.
+      ChangeInfo r5info = gApi.changes().id(r5.getChangeId()).get(CURRENT_REVISION);
+      assertThat(r5info.revisions.get(r5info.currentRevision)._number).isEqualTo(1);
+
+      // Rebasing r5
+      verifyRebaseChainResponse(
+          gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r2, r3, r4, r5);
+
+      verifyRebaseForChange(r5.getChange().getId(), r4.getChange().getId(), false);
+    }
+
+    @Test
+    public void rebasePartlyOutdatedChain() throws Exception {
+      final String file = "modified_file.txt";
+      final String oldContent = "old content";
+      final String newContent = "new content";
+      // Create changes with the following revision hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3/1    r3/2
+      //       * r4
+      PushOneCommit.Result r = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      PushOneCommit.Result r3 = createChange("original patch-set", file, oldContent);
+      PushOneCommit.Result r4 = createChange();
+      gApi.changes()
+          .id(r3.getChangeId())
+          .edit()
+          .modifyFile(file, RawInputUtil.create(newContent.getBytes(UTF_8)));
+      gApi.changes().id(r3.getChangeId()).edit().publish();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the chain through r4.
+      rebaseCall.call(r4.getChangeId());
+
+      verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), false, 2);
+      verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), false, 3);
+      verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false);
+
+      assertThat(gApi.changes().id(r3.getChangeId()).current().file(file).content().asString())
+          .isEqualTo(newContent);
+
+      verifyChangeIsUpToDate(r2);
+      verifyChangeIsUpToDate(r3);
+      verifyChangeIsUpToDate(r4);
+    }
+
+    @Test
+    public void rebaseChainWithConflicts_conflictsForbidden() throws Exception {
+      PushOneCommit.Result r1 = createChange();
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              PushOneCommit.SUBJECT,
+              PushOneCommit.FILE_NAME,
+              "other content",
+              "I0020020020020020020020020020020020020002");
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+      PushOneCommit.Result r3 = createChange("refs/for/master");
+      r3.assertOkStatus();
+      ResourceConflictException exception =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.changes().id(r3.getChangeId()).rebaseChain());
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Change %s could not be rebased due to a conflict during merge.\n\n"
+                      + "merge conflict(s):\n%s",
+                  r2.getChange().getId(), PushOneCommit.FILE_NAME));
+    }
+
+    @Test
+    public void rebaseChainWithConflicts_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 changeWithConflictId = r2.getChangeId();
+      RevCommit patchSet = r2.getCommit();
+      RevCommit base = r1.getCommit();
+      PushOneCommit.Result r3 = createChange("refs/for/master");
+      r3.assertOkStatus();
+
+      TestWorkInProgressStateChangedListener wipStateChangedListener =
+          new TestWorkInProgressStateChangedListener();
+      try (ExtensionRegistry.Registration registration =
+          extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+        RebaseInput rebaseInput = new RebaseInput();
+        rebaseInput.allowConflicts = true;
+        Response<RebaseChainInfo> res =
+            gApi.changes().id(r3.getChangeId()).rebaseChain(rebaseInput);
+        verifyRebaseChainResponse(res, true, r2, r3);
+        RebaseChainInfo rebaseChainInfo = res.value();
+        ChangeInfo changeWithConflictInfo = rebaseChainInfo.rebasedChanges.get(0);
+        assertThat(changeWithConflictInfo.changeId).isEqualTo(r2.getChangeId());
+        assertThat(changeWithConflictInfo.containsGitConflicts).isTrue();
+        assertThat(changeWithConflictInfo.workInProgress).isTrue();
+        ChangeInfo childChangeInfo = rebaseChainInfo.rebasedChanges.get(1);
+        assertThat(childChangeInfo.changeId).isEqualTo(r3.getChangeId());
+        assertThat(childChangeInfo.containsGitConflicts).isTrue();
+        assertThat(childChangeInfo.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(changeWithConflictId)
+              .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(changeWithConflictId).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(changeWithConflictId).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
+    public void rebaseOntoMidChain() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+      //     * r3
+      //       * r4
+      PushOneCommit.Result r = createChange();
+      r.assertOkStatus();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      r2.assertOkStatus();
+      PushOneCommit.Result r3 = createChange();
+      r3.assertOkStatus();
+      PushOneCommit.Result r4 = createChange();
+
+      RebaseInput ri = new RebaseInput();
+      ri.base = r3.getCommit().name();
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(r4.getChangeId(), ri));
+      assertThat(thrown).hasMessageThat().contains("recursion not allowed");
+    }
+
+    private void verifyRebaseChainResponse(
+        Response<RebaseChainInfo> res,
+        boolean shouldHaveConflicts,
+        PushOneCommit.Result... changes) {
+      assertThat(res.statusCode()).isEqualTo(200);
+      RebaseChainInfo info = res.value();
+      assertThat(info.rebasedChanges.stream().map(c -> c._number).collect(Collectors.toList()))
+          .containsExactlyElementsIn(
+              Arrays.stream(changes)
+                  .map(c -> c.getChange().getId().get())
+                  .collect(Collectors.toList()))
+          .inOrder();
+      assertThat(info.containsGitConflicts).isEqualTo(shouldHaveConflicts ? true : null);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index ff88f31..e0e980e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -20,8 +20,13 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 
+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.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -46,6 +51,10 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.permissions.PermissionDeniedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -54,6 +63,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -64,6 +74,7 @@
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Test
   public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
@@ -80,7 +91,9 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement 'Is-Pure-Revert' is unsatisfied.");
   }
 
   @Test
@@ -101,7 +114,9 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains("Failed to submit 1 change due to the following problems");
-    assertThat(thrown).hasMessageThat().contains("needs Is-Pure-Revert");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("submit requirement 'Is-Pure-Revert' is unsatisfied.");
   }
 
   @Test
@@ -254,10 +269,19 @@
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
     RevertInput in = createWipRevertInput();
+
     ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert(in).get();
+
     assertThat(revertChange.workInProgress).isTrue();
+    // expected messages on source change:
+    // 1. Uploaded patch set 1.
+    // 2. Patch Set 1: Code-Review+2
+    // 3. Change has been successfully merged by Administrator
+    // No "reverted" message is expected.
+    List<ChangeMessageInfo> sourceMessages =
+        new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(sourceMessages).hasSize(3);
   }
 
   @Test
@@ -352,14 +376,14 @@
   }
 
   @Test
-  public void revertNotificationsSupressedOnWip() throws Exception {
+  public void revertNotificationsSuppressedOnWip() throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).addReviewer(user.email());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     sender.clear();
-    // If notify input not specified, the endpoint overrides it to OWNER
+    // If notify input not specified, the endpoint overrides it to NONE
     RevertInput revertInput = createWipRevertInput();
     revertInput.notify = null;
     gApi.changes().id(r.getChangeId()).revert(revertInput).get();
@@ -411,6 +435,46 @@
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void revertWithNonVisibleUsers() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount reverter = user;
+    TestAccount changeOwner = admin; // must be admin, since admin cloned testRepo
+    TestAccount reviewer = accountCreator.user2();
+    TestAccount cc =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the reverter can neither see the changeOwner, the reviewer nor the cc.
+    requestScopeOperations.setApiUser(reverter.id());
+    assertThatAccountIsNotVisible(changeOwner, reviewer, cc);
+
+    // Create the change.
+    requestScopeOperations.setApiUser(changeOwner.id());
+    PushOneCommit.Result r = createChange();
+
+    // Add reviewer and cc.
+    ReviewInput reviewerInput = ReviewInput.approve();
+    reviewerInput.reviewer(reviewer.email());
+    reviewerInput.cc(cc.email());
+    gApi.changes().id(r.getChangeId()).current().review(reviewerInput);
+
+    // Approve and submit the change.
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Revert the change.
+    requestScopeOperations.setApiUser(reverter.id());
+    String revertChangeId = gApi.changes().id(r.getChangeId()).revert().get().id;
+
+    // Revert doesn't check the reviewer/CC visibility. Since the reverter can see the reverted
+    // change, they can also see its reviewers/CCs. This means preserving them on the revert change
+    // doesn't expose their account existence and it's OK to keep them even if their accounts are
+    // not visible to the reverter.
+    assertReviewers(revertChangeId, changeOwner, reviewer);
+    assertCcs(revertChangeId, cc);
+  }
+
+  @Test
   @TestProjectInput(createEmptyCommit = false)
   public void revertInitialCommit() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -509,6 +573,24 @@
   }
 
   @Test
+  public void revertWithValidationOptions() throws Exception {
+    PushOneCommit.Result result = createChange();
+    approve(result.getChangeId());
+    gApi.changes().id(result.getChangeId()).current().submit();
+
+    RevertInput revertInput = new RevertInput();
+    revertInput.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(result.getChangeId()).revert(revertInput);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   @GerritConfig(name = "change.submitWholeTopic", value = "true")
   public void cantCreateRevertSubmissionWithoutProjectWritePermission() throws Exception {
     String secondProject = "secondProject";
@@ -705,6 +787,42 @@
   }
 
   @Test
+  public void revertSubmissionSuppressNotifications() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    approve(firstResult);
+    gApi.changes().id(firstResult).addReviewer(user.email());
+    String secondResult = createChange("second change", "b.txt", "other").getChangeId();
+    approve(secondResult);
+    gApi.changes().id(secondResult).addReviewer(user.email());
+
+    gApi.changes().id(secondResult).current().submit();
+
+    sender.clear();
+    RevertInput revertInput = new RevertInput();
+    revertInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(secondResult).revertSubmission(revertInput);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void revertSubmissionSuppressNotificationsWithWip() throws Exception {
+    String firstResult = createChange("first change", "a.txt", "message").getChangeId();
+    approve(firstResult);
+    gApi.changes().id(firstResult).addReviewer(user.email());
+    String secondResult = createChange("second change", "b.txt", "other").getChangeId();
+    approve(secondResult);
+    gApi.changes().id(secondResult).addReviewer(user.email());
+
+    gApi.changes().id(secondResult).current().submit();
+
+    sender.clear();
+    RevertInput revertInput = createWipRevertInput();
+    revertInput.notify = NotifyHandling.NONE;
+    gApi.changes().id(secondResult).revertSubmission(revertInput);
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void revertSubmissionWipNotificationsWithNotifyHandlingAll() throws Exception {
     String changeId1 = createChange("first change", "a.txt", "message").getChangeId();
     approve(changeId1);
@@ -720,9 +838,14 @@
     // If notify handling is specified, it will be used by the API
     RevertInput revertInput = createWipRevertInput();
     revertInput.notify = NotifyHandling.ALL;
-    gApi.changes().id(changeId2).revertSubmission(revertInput);
+    RevertSubmissionInfo revertChanges = gApi.changes().id(changeId2).revertSubmission(revertInput);
 
-    assertThat(sender.getMessages()).hasSize(4);
+    assertThat(revertChanges.revertChanges).hasSize(2);
+    assertThat(sender.getMessages()).hasSize(2);
+    assertThat(sender.getMessages(revertChanges.revertChanges.get(0).changeId, "newchange"))
+        .hasSize(1);
+    assertThat(sender.getMessages(revertChanges.revertChanges.get(1).changeId, "newchange"))
+        .hasSize(1);
   }
 
   @Test
@@ -733,17 +856,23 @@
     String changeId2 = createChange("second change", "b.txt", "other").getChangeId();
     approve(changeId2);
     gApi.changes().id(changeId2).addReviewer(user.email());
-
     gApi.changes().id(changeId2).current().submit();
-
     sender.clear();
-
     RevertInput revertInput = createWipRevertInput();
+
     RevertSubmissionInfo revertSubmissionInfo =
         gApi.changes().id(changeId2).revertSubmission(revertInput);
 
     assertThat(revertSubmissionInfo.revertChanges.stream().allMatch(r -> r.workInProgress))
         .isTrue();
+
+    // expected messages on source change:
+    // 1. Uploaded patch set 1.
+    // 2. Patch Set 1: Code-Review+2
+    // 3. Change has been successfully merged by Administrator
+    // No "reverted" message is expected.
+    assertThat(gApi.changes().id(changeId1).get().messages).hasSize(3);
+    assertThat(gApi.changes().id(changeId2).get().messages).hasSize(3);
   }
 
   @Test
@@ -1041,8 +1170,10 @@
         .isEqualTo(sha1FirstRevert);
     assertThat(revertChanges.get(0).get().revertOf)
         .isEqualTo(secondResult.getChange().change().getChangeId());
+    assertThat(revertChanges.get(0).get().cherryPickOfChange).isNull();
     assertThat(revertChanges.get(1).get().revertOf)
         .isEqualTo(firstResult.getChange().change().getChangeId());
+    assertThat(revertChanges.get(0).get().cherryPickOfChange).isNull();
     assertThat(revertChanges.get(0).current().files().get("b.txt").linesDeleted).isEqualTo(1);
     assertThat(revertChanges.get(1).current().files().get("a.txt").linesDeleted).isEqualTo(1);
 
@@ -1148,10 +1279,38 @@
                 .distinct()
                 .count())
         .isEqualTo(1);
+
+    // Size
     List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+    assertThat(revertChanges).hasSize(3);
+    assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(2);
+
+    // Contents
     assertThat(revertChanges.get(0).current().files().get("c.txt").linesDeleted).isEqualTo(1);
     assertThat(revertChanges.get(1).current().files().get("a.txt").linesDeleted).isEqualTo(1);
     assertThat(revertChanges.get(2).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+
+    // Commit message
+    assertThat(revertChanges.get(0).current().commit(false).message)
+        .matches(
+            Pattern.compile(
+                "Revert \"first change\"\n\n"
+                    + "This reverts commit [a-f0-9]+\\.\n\n"
+                    + "Change-Id: I[a-f0-9]+\n"));
+    assertThat(revertChanges.get(1).current().commit(false).message)
+        .matches(
+            Pattern.compile(
+                "Revert \"second change\"\n\n"
+                    + "This reverts commit [a-f0-9]+\\.\n\n"
+                    + "Change-Id: I[a-f0-9]+\n"));
+    assertThat(revertChanges.get(2).current().commit(false).message)
+        .matches(
+            Pattern.compile(
+                "Revert \"third change\"\n\n"
+                    + "This reverts commit [a-f0-9]+\\.\n\n"
+                    + "Change-Id: I[a-f0-9]+\n"));
+
+    // Relationships
     String sha1FirstChange = resultCommits.get(0).getCommit().getName();
     String sha1ThirdChange = resultCommits.get(2).getCommit().getName();
     String sha1SecondRevert = revertChanges.get(2).current().commit(false).commit;
@@ -1161,9 +1320,6 @@
         .isEqualTo(sha1ThirdChange);
     assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
         .isEqualTo(sha1SecondRevert);
-
-    assertThat(revertChanges).hasSize(3);
-    assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(2);
   }
 
   @Test
@@ -1420,4 +1576,15 @@
     input.workInProgress = true;
     return input;
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 3888679..2668d1f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -29,42 +29,59 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.Comparator.comparing;
 
 import com.google.common.cache.Cache;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.MoreCollectors;
 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.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 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.Account;
 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.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
+import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
@@ -72,9 +89,11 @@
 @NoHttpd
 public class StickyApprovalsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
   @Inject private ChangeKindCreator changeKindCreator;
+  @Inject private ExtensionRegistry extensionRegistry;
 
   @Inject
   @Named("change_kind")
@@ -91,15 +110,13 @@
               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.setCopyAllScoresIfNoChange(false);
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
       u.getConfig().upsertLabelType(codeReview.build());
 
       LabelType.Builder verified =
           labelBuilder(
               LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
-      verified.setCopyAllScoresIfNoChange(false);
       u.getConfig().upsertLabelType(verified.build());
 
       u.save();
@@ -128,11 +145,17 @@
 
   @Test
   public void stickyOnAnyScore() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
 
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:REWORK"));
+
+    // changekind:REWORK should match all kind of changes so that approvals are always copied.
+    // This means setting changekind:REWORK is equivalent to setting is:ANY and we can do the same
+    // assertions for both cases.
+    testStickyOnAnyScore();
+  }
+
+  private void testStickyOnAnyScore() throws Exception {
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
@@ -149,54 +172,8 @@
   }
 
   @Test
-  public void stickyWhenCopyConditionIsTrue() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:ANY"));
-      u.save();
-    }
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, 1, -1);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 0, changeKind);
-      assertVotes(c, user, 1, 0, changeKind);
-    }
-  }
-
-  @Test
-  public void stickyEvenWhenUserCantSeeUploaderInGroup() throws Exception {
-    // user can't see admin group
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyCondition("approverin:" + administratorsUUID));
-      u.save();
-    }
-
-    String changeId = createChange().getChangeId();
-    approve(changeId);
-    amendChange(changeId);
-    vote(user, changeId, 1, -1); // Invalidate cache
-    requestScopeOperations.setApiUser(user.id());
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0);
-    assertVotes(c, user, 1, -1);
-  }
-
-  @Test
   public void stickyOnMinScore() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
-      u.save();
-    }
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:min"));
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
@@ -214,35 +191,8 @@
   }
 
   @Test
-  public void stickyWhenEitherBooleanConfigsOrCopyConditionAreTrue() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:MAX").setCopyMinScore(true));
-      u.save();
-    }
-
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, -2, -1);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 0, changeKind);
-      assertVotes(c, user, -2, 0, changeKind);
-    }
-  }
-
-  @Test
   public void stickyOnMaxScore() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.save();
-    }
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:max"));
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
@@ -261,14 +211,9 @@
 
   @Test
   public void stickyOnCopyValues() throws Exception {
-    TestAccount user2 = accountCreator.user2();
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:\"-1\" OR is:1"));
 
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
-      u.save();
-    }
+    TestAccount user2 = accountCreator.user2();
 
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
@@ -289,11 +234,7 @@
 
   @Test
   public void stickyOnTrivialRebase() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
-      u.save();
-    }
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + TRIVIAL_REBASE.name()));
 
     String changeId = changeKindCreator.createChange(TRIVIAL_REBASE, testRepo, admin);
     vote(admin, changeId, 2, 1);
@@ -337,10 +278,7 @@
 
   @Test
   public void stickyOnNoCodeChange() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
 
     String changeId = changeKindCreator.createChange(NO_CODE_CHANGE, testRepo, admin);
     vote(admin, changeId, 2, 1);
@@ -361,12 +299,8 @@
 
   @Test
   public void stickyOnMergeFirstParentUpdate() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnMergeFirstParentUpdate(true));
-      u.save();
-    }
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name()));
 
     String changeId = changeKindCreator.createChange(MERGE_FIRST_PARENT_UPDATE, testRepo, admin);
     vote(admin, changeId, 2, 1);
@@ -386,11 +320,26 @@
   }
 
   @Test
+  public void notStickyOnNoChangeForNonMergeIfCopyingIsConfiguredForMergeFirstParentUpdate()
+      throws Exception {
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("changekind:" + MERGE_FIRST_PARENT_UPDATE.name()));
+
+    // Create a change with a non-merge commit
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+    vote(user, changeId, -2, -1);
+
+    // Make a NO_CHANGE update (expect that votes are not copied since it's not a merge change).
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 0, NO_CHANGE);
+    assertVotes(c, user, -0, 0, NO_CHANGE);
+  }
+
+  @Test
   public void notStickyWithCopyOnNoChangeWhenSecondParentIsUpdated() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfNoChange(true));
-      u.save();
-    }
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + NO_CHANGE.name()));
 
     String changeId = changeKindCreator.createChangeForMergeCommit(testRepo, admin);
     vote(admin, changeId, 2, 1);
@@ -403,31 +352,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
-  }
-
-  @Test
-  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withCopyCondition()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
       throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
-  }
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
 
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
-      throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -447,33 +375,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
-  }
-
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     // create "existing file" and submit it.
     String existingFile = "existing file";
     Change.Id prep =
@@ -505,32 +410,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
-  }
-
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -545,28 +428,29 @@
   }
 
   @Test
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
+    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
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase()
+      throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     // Create two changes both with the same parent
     PushOneCommit.Result r = createChange();
     testRepo.reset("HEAD~1");
@@ -588,7 +472,7 @@
     // The code-review approval is copied for the second change between PS1 and PS2 since the only
     // modified file is due to rebase.
     List<PatchSetApproval> patchSetApprovals =
-        r2.getChange().notes().getApprovalsWithCopied().values().stream()
+        r2.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
     PatchSetApproval nonCopied = patchSetApprovals.get(0);
@@ -598,59 +482,11 @@
   }
 
   @Test
-  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withCopyCondition()
-      throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
-  }
-
-  private void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
-      throws Exception {
-    Change.Id changeId =
-        changeOperations.newChange().project(project).file("file").content("content").create();
-    vote(admin, changeId.toString(), 2, 1);
-    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);
-  }
-
   @TestProjectInput(createEmptyCommit = false)
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
-  }
-
-  @TestProjectInput(createEmptyCommit = false)
-  public void
-      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
-  }
-
-  private void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -667,32 +503,10 @@
 
   @Test
   public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
-  }
-
-  private void
       notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
           throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -713,32 +527,10 @@
   }
 
   @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withoutCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
-  }
-
-  @Test
-  public void
-      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withCopyCondition()
-          throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
-  }
-
-  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
       throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -753,156 +545,9 @@
   }
 
   @Test
-  public void removedVotesNotSticky() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyAllScoresOnTrivialRebase(true));
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
+  public void copyWithListOfFilesUnchanged() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
 
-    for (ChangeKind changeKind :
-        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
-      testRepo.reset(projectOperations.project(project).getHead("master"));
-
-      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
-      vote(admin, changeId, 2, 1);
-      vote(user, changeId, -2, -1);
-
-      // Remove votes by re-voting with 0
-      vote(admin, changeId, 0, 0);
-      vote(user, changeId, 0, 0);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 0, 0, null);
-      assertVotes(c, user, 0, 0, null);
-
-      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
-      c = detailedChange(changeId);
-      assertVotes(c, admin, 0, 0, changeKind);
-      assertVotes(c, user, 0, 0, changeKind);
-    }
-  }
-
-  @Test
-  public void stickyAcrossMultiplePatchSets() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-
-    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-
-    for (int i = 0; i < 5; i++) {
-      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-      ChangeInfo c = detailedChange(changeId);
-      assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
-    }
-
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-  }
-
-  @Test
-  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance() throws Exception {
-    // The purpose of this test is to make sure that we compute change kind only against the parent
-    // patch set. Change kind is a heavy operation. In prior version of Gerrit, we computed the
-    // 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(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType(LabelId.VERIFIED, b -> b.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-
-    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
-
-    Map<Integer, ObjectId> revisions = new HashMap<>();
-    gApi.changes()
-        .id(changeId)
-        .get()
-        .revisions
-        .forEach(
-            (revId, revisionInfo) ->
-                revisions.put(revisionInfo._number, ObjectId.fromString(revId)));
-    assertThat(revisions.size()).isEqualTo(4);
-    assertChangeKindCacheContains(revisions.get(3), revisions.get(4));
-    assertChangeKindCacheContains(revisions.get(2), revisions.get(3));
-    assertChangeKindCacheContains(revisions.get(1), revisions.get(2));
-
-    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(4));
-    assertChangeKindCacheDoesNotContain(revisions.get(2), revisions.get(4));
-    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(3));
-  }
-
-  @Test
-  public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
-      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
-      u.save();
-    }
-
-    // Vote max score on PS1
-    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
-    vote(admin, changeId, 2, 1);
-
-    // Have someone else vote min score on PS2
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    vote(user, changeId, -2, 0);
-    ChangeInfo c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-    assertVotes(c, user, -2, 0, REWORK);
-
-    // No vote changes on PS3
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 2, 0, REWORK);
-    assertVotes(c, user, -2, 0, REWORK);
-
-    // Both users revote on PS4
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    vote(admin, changeId, 1, 1);
-    vote(user, changeId, 1, 1);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 1, 1, REWORK);
-    assertVotes(c, user, 1, 1, REWORK);
-
-    // New approvals shouldn't carry through to PS5
-    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
-    c = detailedChange(changeId);
-    assertVotes(c, admin, 0, 0, REWORK);
-    assertVotes(c, user, 0, 0, REWORK);
-  }
-
-  @Test
-  public void copyWithListOfFilesUnchanged_withoutCopyCondition() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
-      u.save();
-    }
-    copyWithListOfFilesUnchanged();
-  }
-
-  @Test
-  public void copyWithListOfFilesUnchanged_withCopyCondition() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    copyWithListOfFilesUnchanged();
-  }
-
-  private void copyWithListOfFilesUnchanged() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -949,33 +594,36 @@
     assertVotes(c, user, 0, 0);
   }
 
+  // This test doesn't work without copy condition (when
+  // setCopyAllScoresIfListOfFilesDidNotChange=true).
+  // This is because magic files are not ignored for setCopyAllScoresIfListOfFilesDidNotChange.
   @Test
-  public void copyWithListOfFilesUnchangedButAddedMergeList() throws Exception {
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
-      u.save();
-    }
-    Change.Id parent1ChangeId = changeOperations.newChange().create();
-    Change.Id parent2ChangeId = changeOperations.newChange().create();
-    Change.Id dummyParentChangeId = changeOperations.newChange().create();
+  public void stickyIfFilesUnchanged_magicFilesAreIgnored() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("has:unchanged-files"));
+
+    Change.Id parent1ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id parent2ChangeId = changeOperations.newChange().project(project).create();
+    Change.Id dummyParentChangeId = changeOperations.newChange().project(project).create();
     Change.Id changeId =
         changeOperations
             .newChange()
+            .project(project)
             .mergeOf()
             .change(parent1ChangeId)
             .and()
             .change(parent2ChangeId)
             .create();
 
+    // The change is for a merge commit. It doesn't touch any files, but contains /COMMIT_MSG and
+    // /MERGE_LIST as magic files.
     Map<String, FileInfo> changedFilesFirstPatchset =
         gApi.changes().id(changeId.get()).current().files();
-
     assertThat(changedFilesFirstPatchset.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST");
 
-    // Make a Code-Review vote that should be sticky.
-    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    // Add a Code-Review+2 vote.
+    approve(changeId.toString());
 
+    // Create a new patch set with a non-merge commit.
     changeOperations
         .change(changeId)
         .newPatchset()
@@ -983,25 +631,260 @@
         .patchset(PatchSet.id(dummyParentChangeId, 1))
         .create();
 
+    // Since the new patch set is not a merge commit, it no longer contains the magic /MERGE_LIST
+    // file.
     Map<String, FileInfo> changedFilesSecondPatchset =
         gApi.changes().id(changeId.get()).current().files();
+    assertThat(changedFilesSecondPatchset.keySet())
+        .containsExactly("/COMMIT_MSG"); // /MERGE_LIST is no longer present
 
-    // Only "/MERGE_LIST" was removed.
-    assertThat(changedFilesSecondPatchset.keySet()).containsExactly("/COMMIT_MSG");
-    ApprovalInfo approvalInfo =
-        Iterables.getOnlyElement(
-            gApi.changes().id(changeId.get()).current().votes().get(LabelId.CODE_REVIEW));
-    assertThat(approvalInfo._accountId).isEqualTo(admin.id().get());
-    assertThat(approvalInfo.value).isEqualTo(2);
+    // The only file difference between the 2 patch sets is the magic /MERGE_LIST file.
+    // Since magic files are ignored when checking whether the files between the patch sets differ,
+    // has:unchanged-file should evaluate to true and we expect that the vote was copied.
+    ChangeInfo c = detailedChange(changeId.toString());
+    assertVotes(c, admin, 2, 0);
+  }
+
+  @Test
+  public void approvalsAreStickyIfUploaderMatchesUploaderinCondition() throws Exception {
+    TestAccount userForWhomApprovalsAreSticky =
+        accountCreator.create(
+            /* username= */ null,
+            /* email= */ "userForWhomApprovalsAreSticky@example.com",
+            /* fullName= */ "User For Whom Approvals Are Sticky",
+            /* displayName= */ null);
+    String usersForWhomApprovalsAreStickyUuid =
+        groupOperations
+            .newGroup()
+            .name("Users-for-whom-approvals-are-sticky")
+            .addMember(userForWhomApprovalsAreSticky.id())
+            .create()
+            .get();
+
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("uploaderin:" + usersForWhomApprovalsAreStickyUuid));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add Code-Review+2 by the admin user.
+    approve(r.getChangeId());
+
+    // Add Code-Review+1 by user.
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    // Create a new patch set by userForWhomApprovalsAreSticky (approavls are sticky).
+    TestRepository<InMemoryRepository> userTestRepo =
+        cloneProject(project, userForWhomApprovalsAreSticky);
+    GitUtil.fetch(userTestRepo, r.getPatchSet().refName() + ":ps");
+    userTestRepo.reset("ps");
+    amendChange(r.getChangeId(), "refs/for/master", userForWhomApprovalsAreSticky, userTestRepo)
+        .assertOkStatus();
+    ChangeInfo c = detailedChange(r.getChangeId());
+    assertThat(c.revisions.get(c.currentRevision).uploader._accountId)
+        .isEqualTo(userForWhomApprovalsAreSticky.id().get());
+
+    // Approvals are sticky.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, 1, 0);
+
+    // Create a new patch set by admin user (approvals are not sticky).
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    // Approvals are not sticky.
+    c = detailedChange(r.getChangeId());
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void approvalsThatMatchApproverinConditionAreSticky() throws Exception {
+    TestAccount userWhoseApprovalsAreSticky = accountCreator.create();
+    String usersWhoseApprovalsAreStickyUuid =
+        groupOperations
+            .newGroup()
+            .name("Users-whose-approvals-are-sticky")
+            .addMember(userWhoseApprovalsAreSticky.id())
+            .create()
+            .get();
+
+    updateCodeReviewLabel(
+        b -> b.setCopyCondition("approverin:" + usersWhoseApprovalsAreStickyUuid));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add Code-Review+2 by the admin user (not sticky).
+    approve(r.getChangeId());
+
+    // Add Code-Review+1 by userWhoseApprovalsAreSticky (sticky).
+    requestScopeOperations.setApiUser(userWhoseApprovalsAreSticky.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    amendChange(r.getChangeId()).assertOkStatus();
+
+    ChangeInfo c = detailedChange(r.getChangeId());
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, userWhoseApprovalsAreSticky, 1, 0);
+  }
+
+  @Test
+  public void approvalsThatMatchApproverinConditionAreStickyEvenIfUserCantSeeTheGroup()
+      throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+
+    // Verify that user can't see the admin group.
+    requestScopeOperations.setApiUser(user.id());
+    ResourceNotFoundException notFound =
+        assertThrows(
+            ResourceNotFoundException.class, () -> gApi.groups().id(administratorsUUID).get());
+    assertThat(notFound).hasMessageThat().isEqualTo("Not found: " + administratorsUUID);
+
+    requestScopeOperations.setApiUser(admin.id());
+    updateCodeReviewLabel(b -> b.setCopyCondition("approverin:" + administratorsUUID));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add Code-Review+2 by the admin user.
+    approve(r.getChangeId());
+
+    // Create a new patch set by user.
+    // Approvals are copied on creation of the new patch set. The approval of the admin user is
+    // expected to be sticky although the group that is configured for the 'approverin' predicate is
+    // not visible to the user.
+    TestRepository<InMemoryRepository> userTestRepo = cloneProject(project, user);
+    GitUtil.fetch(userTestRepo, r.getPatchSet().refName() + ":ps");
+    userTestRepo.reset("ps");
+    amendChange(r.getChangeId(), "refs/for/master", user, userTestRepo).assertOkStatus();
+
+    ChangeInfo c = detailedChange(r.getChangeId());
+    assertThat(c.revisions.get(c.currentRevision).uploader._accountId).isEqualTo(user.id().get());
+
+    // Assert that the approval of the admin user was copied to the new patch set.
+    assertVotes(c, admin, 2, 0);
+  }
+
+  @Test
+  public void removedVotesNotSticky() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:" + TRIVIAL_REBASE.name()));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, -2, -1);
+
+      // Remove votes by re-voting with 0
+      vote(admin, changeId, 0, 0);
+      vote(user, changeId, 0, 0);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, null);
+      assertVotes(c, user, 0, 0, null);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      c = detailedChange(changeId);
+      assertVotes(c, admin, 0, 0, changeKind);
+      assertVotes(c, user, 0, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+
+    for (int i = 0; i < 5; i++) {
+      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
+    }
+
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+  }
+
+  @Test
+  public void stickyAcrossMultiplePatchSetsDoNotRegressPerformance() throws Exception {
+    // The purpose of this test is to make sure that we compute change kind only against the parent
+    // patch set. Change kind is a heavy operation. In prior version of Gerrit, we computed the
+    // 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.
+
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+    updateVerifiedLabel(b -> b.setCopyCondition("changekind:" + NO_CODE_CHANGE.name()));
+
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+
+    Map<Integer, ObjectId> revisions = new HashMap<>();
+    gApi.changes()
+        .id(changeId)
+        .get()
+        .revisions
+        .forEach(
+            (revId, revisionInfo) ->
+                revisions.put(revisionInfo._number, ObjectId.fromString(revId)));
+    assertThat(revisions.size()).isEqualTo(4);
+    assertChangeKindCacheContains(revisions.get(3), revisions.get(4));
+    assertChangeKindCacheContains(revisions.get(2), revisions.get(3));
+    assertChangeKindCacheContains(revisions.get(1), revisions.get(2));
+
+    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(4));
+    assertChangeKindCacheDoesNotContain(revisions.get(2), revisions.get(4));
+    assertChangeKindCacheDoesNotContain(revisions.get(1), revisions.get(3));
+  }
+
+  @Test
+  public void copyMinMaxAcrossMultiplePatchSets() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX OR is:MIN"));
+
+    // Vote max score on PS1
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
+    vote(admin, changeId, 2, 1);
+
+    // Have someone else vote min score on PS2
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    vote(user, changeId, -2, 0);
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+    assertVotes(c, user, -2, 0, REWORK);
+
+    // No vote changes on PS3
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0, REWORK);
+    assertVotes(c, user, -2, 0, REWORK);
+
+    // Both users revote on PS4
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    vote(admin, changeId, 1, 1);
+    vote(user, changeId, 1, 1);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 1, 1, REWORK);
+    assertVotes(c, user, 1, 1, REWORK);
+
+    // New approvals shouldn't carry through to PS5
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
+    c = detailedChange(changeId);
+    assertVotes(c, admin, 0, 0, REWORK);
+    assertVotes(c, user, 0, 0, REWORK);
   }
 
   @Test
   public void deleteStickyVote() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:MAX"));
+
     String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyMaxScore(true));
-      u.save();
-    }
 
     // Vote max score on PS1
     String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
@@ -1017,12 +900,7 @@
 
   @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();
-    }
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
 
@@ -1031,25 +909,109 @@
     gApi.changes().id(r.getChangeId()).current().review(input);
 
     // Make a new patchset, keeping the Code-Review +2 vote.
-    amendChange(r.getChangeId());
+    r = amendChange(r.getChangeId());
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
 
     // 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.
+    // There is a vote both on patch set 1 and on patch set 2, although both votes are Code-Review
+    // +2. The approval on patch set 2 is no longer copied since it was reapplied.
     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);
+    approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
+  }
+
+  @Test
+  public void copiedVoteIsNotReapplied_onVoteOnOtherLabel() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add vote that will be copied.
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Create a new patchset, the Code-Review +2 vote is copied.
+    r = amendChange(r.getChangeId());
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+
+    // Vote on another label. This shouldn't touch the copied approval.
+    input = new ReviewInput().label(LabelId.VERIFIED, 1);
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Patch set 2 has 2 approvals now, one copied approval for the Code-Review label and one
+    // non-copied
+    // approval for the Verified label.
+    approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(2);
+    assertThat(
+            Iterables.getOnlyElement(
+                    approvalsPs2.stream()
+                        .filter(psa -> LabelId.CODE_REVIEW.equals(psa.label()))
+                        .collect(toImmutableList()))
+                .copied())
+        .isTrue();
+    assertThat(
+            Iterables.getOnlyElement(
+                    approvalsPs2.stream()
+                        .filter(psa -> LabelId.VERIFIED.equals(psa.label()))
+                        .collect(toImmutableList()))
+                .copied())
+        .isFalse();
+  }
+
+  @Test
+  public void copiedVoteIsNotReapplied_onRebase() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+
+    // Create a sibling change
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Add vote that will be copied.
+    approve(r2.getChangeId());
+
+    // Verify that that the approval exists and is not copied.
+    List<PatchSetApproval> approvalsPs2 = r2.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isFalse();
+
+    // Approve, verify and submit the first change.
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Rebase the second change, the approval should be sticky.
+    gApi.changes().id(r2.getChangeId()).rebase();
+
+    // Verify that the approval has been copied to patch set 2 (use the currentApprovals() method
+    // rather than the approvals() method since the latter one filters out copied approvals).
+    approvalsPs2 = changeDataFactory.create(project, r2.getChange().getId()).currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
   }
 
   @Test
   public void stickyVoteStoredOnUpload() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
     // Add a new vote.
@@ -1063,7 +1025,7 @@
     }
 
     List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
+        r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
 
@@ -1085,12 +1047,7 @@
 
   @Test
   public void stickyVoteStoredOnRebase() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
 
     // Create two changes both with the same parent
     PushOneCommit.Result r = createChange();
@@ -1109,7 +1066,7 @@
     gApi.changes().id(r2.getChangeId()).rebase();
 
     List<PatchSetApproval> patchSetApprovals =
-        r2.getChange().notes().getApprovalsWithCopied().values().stream()
+        r2.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
     PatchSetApproval nonCopied = patchSetApprovals.get(0);
@@ -1120,6 +1077,8 @@
 
   @Test
   public void stickyVoteStoredOnUploadWithRealAccount() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
     // Give "user" permission to vote on behalf of other users.
     projectOperations
         .project(project)
@@ -1132,13 +1091,6 @@
                 .range(-1, 1))
         .update();
 
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
     PushOneCommit.Result r = createChange();
 
     // Add a new vote as user
@@ -1151,7 +1103,7 @@
     amendChange(r.getChangeId());
 
     List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
+        r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
 
@@ -1174,6 +1126,8 @@
 
   @Test
   public void stickyVoteStoredOnUploadWithRealAccountAndTag() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
     // Give "user" permission to vote on behalf of other users.
     projectOperations
         .project(project)
@@ -1186,13 +1140,6 @@
                 .range(-1, 1))
         .update();
 
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
-
     PushOneCommit.Result r = createChange();
 
     // Add a new vote as user
@@ -1206,7 +1153,7 @@
     amendChange(r.getChangeId());
 
     List<PatchSetApproval> patchSetApprovals =
-        r.getChange().notes().getApprovalsWithCopied().values().stream()
+        r.getChange().notes().getApprovals().all().values().stream()
             .sorted(comparing(a -> a.patchSetId().get()))
             .collect(toImmutableList());
 
@@ -1231,12 +1178,7 @@
 
   @Test
   public void stickyVoteStoredCanBeRemoved() throws Exception {
-    // Code-Review will be sticky.
-    String label = LabelId.CODE_REVIEW;
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType(label, b -> b.setCopyAnyScore(true));
-      u.save();
-    }
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
 
     PushOneCommit.Result r = createChange();
 
@@ -1246,7 +1188,7 @@
 
     // Make a new patchset, keeping the Code-Review +2 vote.
     amendChange(r.getChangeId());
-    assertVotes(detailedChange(r.getChangeId()), admin, label, 2, null);
+    assertVotes(detailedChange(r.getChangeId()), admin, LabelId.CODE_REVIEW, 2, null);
 
     gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
 
@@ -1254,7 +1196,8 @@
         Iterables.getOnlyElement(
             r.getChange()
                 .notes()
-                .getApprovalsWithCopied()
+                .getApprovals()
+                .all()
                 .get(r.getChange().change().currentPatchSetId()));
 
     assertThat(nonCopiedSecondPatchsetRemovedVote.patchSetId().get()).isEqualTo(2);
@@ -1265,6 +1208,132 @@
     assertThat(nonCopiedSecondPatchsetRemovedVote.copied()).isFalse();
   }
 
+  @Test
+  public void reviewerStickyVotingCanBeRemoved() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+
+    PushOneCommit.Result r = createChange();
+
+    // Add a new vote by user
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Make a new patchset, keeping the Code-Review +1 vote.
+    amendChange(r.getChangeId());
+    assertVotes(detailedChange(r.getChangeId()), user, LabelId.CODE_REVIEW, 1, null);
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+
+    assertThat(r.getChange().notes().getApprovals().all()).isEmpty();
+
+    // Changes message has info about vote removed.
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).messages()).message)
+        .contains("Code-Review+1 by User");
+  }
+
+  @Test
+  public void sticky_copiedToLatestPatchSetFromSubmitRecords() throws Exception {
+    updateVerifiedLabel(b -> b.setFunction(LabelFunction.NO_BLOCK));
+
+    // This test is covering the backfilling logic for changes which have been submitted, based on
+    // copied approvals, before Gerrit persisted copied votes as Copied-Label footers in NoteDb. It
+    // verifies that for such changes copied approvals are returned from the API even if the copied
+    // votes were not persisted as Copied-Label footers.
+    //
+    // In other words, this test verifies that given a change that was approved by a copied vote and
+    // then submitted and for which the copied approval is not persisted as a Copied-Label footer in
+    // NoteDb the copied approval is backfilled from the corresponding Submitted-With footer that
+    // got written to NoteDb on submit.
+    //
+    // Creating such a change would be possible by running the old Gerrit code from before Gerrit
+    // persisted copied labels as Copied-Label footers. However since this old Gerrit code is no
+    // longer available, the test needs to apply a trick to create a change in this state. It
+    // configures a fake submit rule, that pretends that an approval for a non-sticky label from an
+    // old patch set is still present on the current patch set and allows to submit the change.
+    // Since the label is non-sticky no Copied-Label footer is written for it. On submit the fake
+    // submit rule results in a Submitted-With footer that records the label as approved (although
+    // the label is actually not present on the current patch set). This is exactly the change state
+    // that we would have had by running the old code if submit was based on a copied label. As
+    // result of the backfilling logic we expect that this "copied" label (the label that is
+    // mentioned in the Submitted-With footer) is returned from the API.
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(new TestSubmitRule(user.id()))) {
+      // We want to add a vote on PS1, then not copy it to PS2, but include it in submit records
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Vote on patch-set 1
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      // Upload patch-set 2. Change user's "Verified" vote on PS2.
+      changeOperations
+          .change(Change.id(r.getChange().getId().get()))
+          .newPatchset()
+          .file("new_file")
+          .content("content")
+          .commitMessage("Upload PS2")
+          .create();
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, 1);
+
+      // Upload patch-set 3
+      changeOperations
+          .change(Change.id(r.getChange().getId().get()))
+          .newPatchset()
+          .file("another_file")
+          .content("content")
+          .commitMessage("Upload PS3")
+          .create();
+      vote(admin, changeId, 2, 1);
+
+      List<PatchSetApproval> patchSetApprovals =
+          notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
+              .sorted(comparing(a -> a.patchSetId().get()))
+              .collect(toImmutableList());
+
+      // There's no verified approval on PS#3.
+      assertThat(
+              patchSetApprovals.stream()
+                  .filter(
+                      a ->
+                          a.accountId().equals(user.id())
+                              && a.label().equals(TestLabels.verified().getName())
+                              && a.patchSetId().get() == 3)
+                  .collect(Collectors.toList()))
+          .isEmpty();
+
+      // Submit the change. The TestSubmitRule will store a "submit record" containing a label
+      // voted by user, but the latest patch-set does not have an approval for this user, hence
+      // it will be copied if we request approvals after the change is merged.
+      requestScopeOperations.setApiUser(admin.id());
+      gApi.changes().id(changeId).current().submit();
+
+      patchSetApprovals =
+          notesFactory.create(project, r.getChange().getId()).getApprovals().all().values().stream()
+              .sorted(comparing(a -> a.patchSetId().get()))
+              .collect(toImmutableList());
+
+      // Get the copied approval for user on PS3 for the "Verified" label.
+      PatchSetApproval verifiedApproval =
+          patchSetApprovals.stream()
+              .filter(
+                  a ->
+                      a.accountId().equals(user.id())
+                          && a.label().equals(TestLabels.verified().getName())
+                          && a.patchSetId().get() == 3)
+              .collect(MoreCollectors.onlyElement());
+
+      assertCopied(
+          verifiedApproval,
+          /* psId= */ 3,
+          TestLabels.verified().getName(),
+          (short) 1,
+          /* copied= */ true);
+    }
+  }
+
   private void assertChangeKindCacheContains(ObjectId prior, ObjectId next) {
     ChangeKind kind =
         changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
@@ -1352,4 +1421,44 @@
     assertThat(approval.value()).isEqualTo(value);
     assertThat(approval.copied()).isEqualTo(copied);
   }
+
+  private void updateCodeReviewLabel(Consumer<LabelType.Builder> update) throws Exception {
+    updateLabel(LabelId.CODE_REVIEW, update);
+  }
+
+  private void updateVerifiedLabel(Consumer<LabelType.Builder> update) throws Exception {
+    updateLabel(LabelId.VERIFIED, update);
+  }
+
+  private void updateLabel(String labelName, Consumer<LabelType.Builder> update) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(labelName, update);
+      u.save();
+    }
+  }
+
+  /**
+   * Test submit rule that always return a passing record with a "Verified" label applied by {@link
+   * TestSubmitRule#userAccountId}.
+   */
+  private static class TestSubmitRule implements SubmitRule {
+    Account.Id userAccountId;
+
+    TestSubmitRule(Account.Id userAccountId) {
+      this.userAccountId = userAccountId;
+    }
+
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      SubmitRecord record = new SubmitRecord();
+      record.ruleName = "testSubmitRule";
+      record.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = "Verified";
+      label.status = SubmitRecord.Label.Status.OK;
+      label.appliedBy = userAccountId;
+      record.labels = Arrays.asList(label);
+      return Optional.of(record);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
new file mode 100644
index 0000000..9ceb6bf
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
@@ -0,0 +1,246 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.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.LegacySubmitRequirement;
+import com.google.gerrit.entities.SubmitRecord;
+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.LegacySubmitRequirementInfo;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Test;
+
+public class SubmitRequirementCustomRuleIT extends AbstractDaemonTest {
+  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() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        bind(SubmitRule.class)
+            .annotatedWith(Exports.named("CustomSubmitRule"))
+            .to(CustomSubmitRule.class);
+      }
+    };
+  }
+
+  @Inject private CustomSubmitRule rule;
+
+  @Test
+  public void submitRequirementIsPropagated() throws Exception {
+    rule.block(false);
+    PushOneCommit.Result r = createChange();
+
+    ChangeInfo result = gApi.changes().id(r.getChangeId()).get();
+    assertThat(result.requirements).isEmpty();
+
+    rule.block(true);
+    result = gApi.changes().id(r.getChangeId()).get();
+    assertThat(result.requirements).containsExactly(reqInfo);
+  }
+
+  @Test
+  public void submitRequirementIsPropagatedInQuery() throws Exception {
+    rule.block(false);
+    PushOneCommit.Result r = createChange();
+
+    String query = "status:open project:" + project.get();
+    List<ChangeInfo> result = gApi.changes().query(query).get();
+    assertThat(result).hasSize(1);
+    assertThat(result.get(0).requirements).isEmpty();
+
+    // Submit rule behavior is changed, but the query still returns
+    // the previous result from the index
+    rule.block(true);
+    result = gApi.changes().query(query).get();
+    assertThat(result).hasSize(1);
+    assertThat(result.get(0).requirements).isEmpty();
+
+    // The submit rule result is updated after the change is reindexed
+    gApi.changes().id(r.getChangeId()).index();
+    result = gApi.changes().query(query).get();
+    assertThat(result).hasSize(1);
+    assertThat(result.get(0).requirements).containsExactly(reqInfo);
+  }
+
+  @Test
+  public void submittableQueryRuleNotReady() throws Exception {
+    ChangeApi change = newChangeApi();
+
+    // Satisfy the default rule.
+    approveChange(change);
+
+    // The custom rule is NOT_READY.
+    rule.block(true);
+    change.index();
+
+    assertThat(queryIsSubmittable()).isEmpty();
+  }
+
+  @Test
+  public void submittableQueryRuleError() throws Exception {
+    ChangeApi change = newChangeApi();
+
+    // Satisfy the default rule.
+    approveChange(change);
+
+    rule.status(Optional.of(SubmitRecord.Status.RULE_ERROR));
+    change.index();
+
+    assertThat(queryIsSubmittable()).isEmpty();
+  }
+
+  @Test
+  public void submittableQueryDefaultRejected() throws Exception {
+    ChangeApi change = newChangeApi();
+
+    // CodeReview:-2 the change, causing the default rule to fail.
+    rejectChange(change);
+
+    rule.status(Optional.of(SubmitRecord.Status.OK));
+    change.index();
+
+    assertThat(queryIsSubmittable()).isEmpty();
+  }
+
+  @Test
+  public void submittableQueryRuleOk() throws Exception {
+    ChangeApi change = newChangeApi();
+
+    // Satisfy the default rule.
+    approveChange(change);
+
+    rule.status(Optional.of(SubmitRecord.Status.OK));
+    change.index();
+
+    List<ChangeInfo> result = queryIsSubmittable();
+    assertThat(result).hasSize(1);
+    assertThat(result.get(0).changeId).isEqualTo(change.info().changeId);
+  }
+
+  @Test
+  public void submittableQueryRuleNoRecord() throws Exception {
+    ChangeApi change = newChangeApi();
+
+    // Satisfy the default rule.
+    approveChange(change);
+
+    // Our custom rule isn't providing any submit records.
+    rule.status(Optional.empty());
+    change.index();
+
+    // is:submittable should return the change, since it was approved and the custom rule is not
+    // blocking it.
+    List<ChangeInfo> result = queryIsSubmittable();
+    assertThat(result).hasSize(1);
+    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();
+  }
+
+  private ChangeApi newChangeApi() throws Exception {
+    return gApi.changes().id(createChange().getChangeId());
+  }
+
+  private void approveChange(ChangeApi changeApi) throws Exception {
+    changeApi.current().review(ReviewInput.approve());
+  }
+
+  private void rejectChange(ChangeApi changeApi) throws Exception {
+    changeApi.current().review(ReviewInput.reject());
+  }
+
+  @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());
+    }
+
+    public void status(Optional<SubmitRecord.Status> status) {
+      this.recordStatus = status;
+    }
+
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      numberOfEvaluations.incrementAndGet();
+      if (this.recordStatus.isPresent()) {
+        SubmitRecord record = new SubmitRecord();
+        record.labels = new ArrayList<>();
+        record.status = this.recordStatus.get();
+        record.requirements = ImmutableList.of(req);
+        return Optional.of(record);
+      }
+      return Optional.empty();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
new file mode 100644
index 0000000..242c278
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -0,0 +1,3139 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+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.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+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.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.MoreCollectors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LegacySubmitRequirement;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+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.LabelDefinitionInput;
+import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRecordInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementExpressionInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.IntStream;
+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.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Test;
+
+@NoHttpd
+@UseTimezone(timezone = "US/Eastern")
+@VerifyNoPiiInChangeNotes(true)
+public class SubmitRequirementIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private IndexOperations.Change changeIndexOperations;
+
+  @Test
+  public void submitRecords() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      String changeId = r.getChangeId();
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRecords).hasSize(2);
+      // Check the default submit record for the code-review label
+      SubmitRecordInfo codeReviewRecord = Iterables.get(change.submitRecords, 0);
+      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
+      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.NOT_READY);
+      assertThat(codeReviewRecord.labels).hasSize(1);
+      SubmitRecordInfo.Label label = Iterables.getOnlyElement(codeReviewRecord.labels);
+      assertThat(label.label).isEqualTo("Code-Review");
+      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.NEED);
+      assertThat(label.appliedBy).isNull();
+      // Check the custom test record created by the TestSubmitRule
+      SubmitRecordInfo testRecord = Iterables.get(change.submitRecords, 1);
+      assertThat(testRecord.ruleName).isEqualTo("testSubmitRule");
+      assertThat(testRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
+      assertThat(testRecord.requirements)
+          .containsExactly(new LegacySubmitRequirementInfo("OK", "fallback text", "type"));
+      assertThat(testRecord.labels).hasSize(1);
+      SubmitRecordInfo.Label testLabel = Iterables.getOnlyElement(testRecord.labels);
+      assertThat(testLabel.label).isEqualTo("label");
+      assertThat(testLabel.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
+      assertThat(testLabel.appliedBy).isNull();
+
+      voteLabel(changeId, "Code-Review", 2);
+      // Code review record is satisfied after voting +2
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRecords).hasSize(2);
+      codeReviewRecord = Iterables.get(change.submitRecords, 0);
+      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
+      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
+      assertThat(codeReviewRecord.labels).hasSize(1);
+      label = Iterables.getOnlyElement(codeReviewRecord.labels);
+      assertThat(label.label).isEqualTo("Code-Review");
+      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
+      assertThat(label.appliedBy._accountId).isEqualTo(admin.id().get());
+    }
+  }
+
+  @Test
+  public void checkSubmitRequirement_fromRefsConfigChange_satisfied() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change. Check the SR against it.
+    testRepo.reset(oldHead);
+    PushOneCommit.Result r2 = createChange();
+    String changeId = r2.getChangeId();
+    SubmitRequirementResultInfo info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+    voteLabel(changeId, "Code-Review", 2);
+    info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_fromRefsConfigChangeOfAnotherProject_satisfied()
+      throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change in another project. Check the SR against it.
+    Project.NameKey otherProject = projectOperations.newProject().create();
+    TestRepository<InMemoryRepository> otherRepo = cloneProject(otherProject, admin);
+    PushOneCommit.Result r2 = createChange(otherRepo);
+    String changeId = r2.getChangeId();
+    SubmitRequirementResultInfo info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+    voteLabel(changeId, "Code-Review", 2);
+    info =
+        gApi.changes()
+            .id(changeId)
+            .checkSubmitRequirementRequest()
+            .srName("Foo")
+            .refsConfigChangeId(configResult.getChange().getId().toString())
+            .get();
+    assertThat(info.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_fromRefsConfigChange_failsForNonExistingSR() throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change. Check the SR against it.
+    testRepo.reset(oldHead);
+    PushOneCommit.Result r2 = createChange();
+    String changeId = r2.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .srName("Bar")
+                    .refsConfigChangeId(configResult.getChange().getId().toString())
+                    .get());
+    assertThat(thrown).hasMessageThat().isEqualTo("No submit requirement matching name 'Bar'");
+  }
+
+  @Test
+  public void checkSubmitRequirement_notAllowedFromNonRefsConfigChange() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .srName("Foo")
+                    .refsConfigChangeId(r.getChange().getId().toString())
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Change '%s' is not in refs/meta/config branch.", r.getChange().getId().get()));
+  }
+
+  @Test
+  public void checkSubmitRequirement_notAllowedFromNonExistingChange() throws Exception {
+    String invalidChangeNumber = "2134789";
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .srName("Foo")
+                    .refsConfigChangeId(invalidChangeNumber)
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(String.format("Change '%s' does not exist", invalidChangeNumber));
+  }
+
+  @Test
+  public void checkSubmitRequirement_fromRefsConfigChange_failsIfBothParametersAreNotSet()
+      throws Exception {
+    String oldHead = projectOperations.project(project).getHead("master").name();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/for/refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    fetchRefsMetaConfig();
+    PushOneCommit.Result configResult =
+        createConfigChangeWithSubmitRequirement("Foo", "label:Code-Review=+2");
+
+    // Upload a normal change. Check the SR against it.
+    testRepo.reset(oldHead);
+    PushOneCommit.Result r2 = createChange();
+    String changeId = r2.getChangeId();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).checkSubmitRequirementRequest().srName("Bar").get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Both 'sr-name' and 'refs-config-change-id' parameters must be set");
+
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .checkSubmitRequirementRequest()
+                    .refsConfigChangeId(configResult.getChangeId())
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Both 'sr-name' and 'refs-config-change-id' parameters must be set");
+  }
+
+  @Test
+  public void checkSubmitRequirement_satisfied() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review", /* submittabilityExpression= */ "label:Code-Review=+2");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_notApplicable() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review",
+            /* applicableIf= */ "branch:non-existent",
+            /* submittableIf= */ "label:Code-Review=+2",
+            /* overrideIf= */ null);
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void checkSubmitRequirement_overridden() throws Exception {
+    configLabel("Override-Label", LabelFunction.NO_OP); // label function has no effect
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Override-Label")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review",
+            /* applicableIf= */ null,
+            /* submittableIf= */ "label:Code-Review=+2",
+            /* overrideIf= */ "label:Override-Label=+1");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+
+    voteLabel(changeId, "Override-Label", 1);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.OVERRIDDEN);
+  }
+
+  @Test
+  public void checkSubmitRequirement_error() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput("Code-Review", /* submittabilityExpression= */ "!!!");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.ERROR);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMax() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMax_fromNonUploader() throws Exception {
+    configLabel("my-label", LabelFunction.NO_OP); // label function has no effect
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("my-label")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:my-label=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    // The second requirement is coming from the legacy code-review label function
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Voting with a max vote as the uploader will not satisfy the submit requirement.
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Voting as a non-uploader will satisfy the submit requirement.
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("-label:Code-Review=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Requirement is satisfied because there are no votes
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement (coming from the label function definition) is not satisfied. We return
+    // both legacy and non-legacy requirements in this case since their statuses are not identical.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    voteLabel(changeId, "Code-Review", -1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Requirement is still satisfied because -1 is not the max negative value
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    voteLabel(changeId, "Code-Review", -2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Requirement is now unsatisfied because -2 is the max negative value
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withMaxWithBlock_ignoringSelfApproval() throws Exception {
+    configLabel("my-label", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("my-label")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(
+                    "label:my-label=MAX,user=non_uploader -label:my-label=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create the change as admin
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Admin (a.k.a uploader) adds a -1 min vote. This is going to block submission.
+    voteLabel(changeId, "my-label", -1);
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    // The other requirement is coming from the code-review label function
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // user (i.e. non_uploader) votes 1. Requirement is still blocking because of -1 of uploader.
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Admin (a.k.a uploader) removes -1. Now requirement is fulfilled.
+    requestScopeOperations.setApiUser(admin.id());
+    voteLabel(changeId, "my-label", 0);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_withLabelEqualsAny() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=ANY"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Legacy and non-legacy requirements have mismatching status. Both are returned from the API.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("project:foo"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
+    configLabel("build-cop-override", LabelFunction.NO_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "build-cop-override", 1);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overriddenInChildProjectWithStricterRequirement() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+2 instead of +1)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_overriddenInChildProjectWithLessStrictRequirement()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+1 instead of +2)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in child project applies
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement that is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overriddenInChildProjectAsDisabled() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Custom-Requirement")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+1 instead of +2)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Custom-Requirement")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("is:false"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:false"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "Custom-Requirement",
+        Status.NOT_APPLICABLE,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_inheritedFromParentProject() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overriddenSRInParentProjectIsInheritedByChildProject()
+      throws Exception {
+    // Define submit requirement in root project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in parent project (requires Code-Review=+2 instead of +1).
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    Project.NameKey child = createProjectOverAPI("child", project, true, /* submitType= */ null);
+    TestRepository<InMemoryRepository> childRepo = cloneProject(child);
+    PushOneCommit.Result r = createChange(childRepo);
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+2 instead of +1).
+    // Will have no effect since parent does not allow override.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in child project was ignored
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInChildProject_ifParentAddsSRThatDoesNotAllowOverride()
+      throws Exception {
+    // Submit requirement in child project (requires Code-Review=+1)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement that is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    // Add stricter non-overridable submit requirement in parent project (requires Code-Review=+2,
+    // instead of Code-Review=+1)
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInChildProject_ifParentMakesSRNonOverridable()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires Code-Review=+1 instead of +2)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in child project applies
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement that is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    // Disallow overriding the submit requirement in the parent project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_ignoredInGrandChildProject_ifGrandParentDoesNotAllowOverride()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    Project.NameKey grandChild =
+        createProjectOverAPI("grandChild", project, true, /* submitType= */ null);
+
+    // Override submit requirement in grand child project (requires Code-Review=+2 instead of +1).
+    // Will have no effect since grand parent does not allow override.
+    configSubmitRequirement(
+        grandChild,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    TestRepository<InMemoryRepository> grandChildRepo = cloneProject(grandChild);
+    PushOneCommit.Result r = createChange(grandChildRepo);
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in grand child project was ignored
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_overrideOverideExpression() throws Exception {
+    // Define submit requirement in root project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Create Code-Review-Override label
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "NoOp";
+    input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
+    gApi.projects().name(project.get()).label("Code-Review-Override").create(input).get();
+
+    // Allow to vote on the Code-Review-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Code-Review-Override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    // Override submit requirement in project (requires Code-Review-Override+1 as override instead
+    // of build-cop-override+1).
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:Code-Review-Override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review-Override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review-Override+1 was enough to fulfill the override expression of the requirement
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirementThatOverridesParentSubmitRequirementTakesEffectImmediately()
+      throws Exception {
+    // Define submit requirement in root project that ignores self approvals from the uploader.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Apply a self approval from the uploader.
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review+2 is ignored since it's a self approval from the uploader
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Already satisfied since it
+    // doesn't ignore self approvals.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+    // since the change is not submittable we expect the submit action to be not returned
+    assertThat(gApi.changes().id(changeId).current().actions()).doesNotContainKey("submit");
+
+    // Override submit requirement in project (allow uploaders to self approve).
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // the self approval from the uploader is no longer ignored, hence the submit requirement is
+    // satisfied now
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    // since the change is submittable now we expect the submit action to be returned
+    Map<String, ActionInfo> actions = gApi.changes().id(changeId).current().actions();
+    assertThat(actions).containsKey("submit");
+    ActionInfo submitAction = actions.get("submit");
+    assertThat(submitAction.enabled).isTrue();
+  }
+
+  @Test
+  public void
+      submitRequirementThatOverridesParentSubmitRequirementTakesEffectImmediately_staleIndex()
+          throws Exception {
+    // Define submit requirement in root project that ignores self approvals from the uploader.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:Code-Review=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Apply a self approval from the uploader.
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Code-Review+2 is ignored since it's a self approval from the uploader
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Already satisfied since it
+    // doesn't ignore self approvals.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+    // since the change is not submittable we expect the submit action to be not returned
+    assertThat(gApi.changes().id(changeId).current().actions()).doesNotContainKey("submit");
+
+    // disable change index writes so that the change in the index gets stale when the new submit
+    // requirement is added
+    try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
+      // Override submit requirement in project (allow uploaders to self approve).
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(
+                  SubmitRequirementExpression.create("label:Code-Review=MAX"))
+              .setAllowOverrideInChildProjects(true)
+              .build());
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      // the self approval from the uploader is no longer ignored, hence the submit requirement is
+      // satisfied now
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      // since the change is submittable now we expect the submit action to be returned
+      Map<String, ActionInfo> actions = gApi.changes().id(changeId).current().actions();
+      assertThat(actions).containsKey("submit");
+      ActionInfo submitAction = actions.get("submit");
+      assertThat(submitAction.enabled).isTrue();
+    }
+  }
+
+  @Test
+  public void submitRequirement_partiallyOverriddenSRIsIgnored() throws Exception {
+    // Create build-cop-override label
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.function = "NoOp";
+    input.values = ImmutableMap.of("+1", "Override", " 0", "No Override");
+    gApi.projects().name(project.get()).label("build-cop-override").create(input).get();
+
+    // Allow to vote on the build-cop-override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("build-cop-override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    // Define submit requirement in root project.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Create Code-Review-Override label
+    gApi.projects().name(project.get()).label("Code-Review-Override").create(input).get();
+
+    // Allow to vote on the Code-Review-Override label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            TestProjectUpdate.allowLabel("Code-Review-Override")
+                .range(0, 1)
+                .ref("refs/*")
+                .group(REGISTERED_USERS)
+                .build())
+        .update();
+
+    // Override submit requirement in project (requires Code-Review-Override+1 as override instead
+    // of build-cop-override+1), but do not set all required properties (submittability expression
+    // is missing). We update the project.config file directly in the remote repository, since
+    // trying to push such a submit requirement would be rejected by the commit validation.
+    projectOperations
+        .project(project)
+        .forInvalidation()
+        .addProjectConfigUpdater(
+            config ->
+                config.setString(
+                    ProjectConfig.SUBMIT_REQUIREMENT,
+                    "Code-Review",
+                    ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+                    "label:Code-Review-Override=+1"))
+        .invalidate();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review-Override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // The override expression in the project is satisfied, but it's ignored since the SR is
+    // incomplete.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "build-cop-override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // The submit requirement is overridden now (the override expression in the child project is
+    // ignored)
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_storedForClosedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      voteLabel(changeId, "Code-Review", 2);
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+
+      SubmitRequirementResult result =
+          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertSubmitRequirementResult(
+          result,
+          "Code-Review",
+          SubmitRequirementResult.Status.SATISFIED,
+          /* submitExpr= */ "label:Code-Review=MAX",
+          SubmitRequirementExpressionResult.Status.PASS);
+
+      // Adding comments does not affect the stored SRs.
+      addComment(r.getChangeId(), /* file= */ "foo");
+      notes = notesFactory.create(project, r.getChange().getId());
+      result = notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertSubmitRequirementResult(
+          result,
+          "Code-Review",
+          SubmitRequirementResult.Status.SATISFIED,
+          /* submitExpr= */ "label:Code-Review=MAX",
+          SubmitRequirementExpressionResult.Status.PASS);
+      assertThat(notes.getHumanComments()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void submitRequirement_storedForAbandonedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      voteLabel(changeId, "Code-Review", 2);
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      gApi.changes().id(r.getChangeId()).abandon();
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+      SubmitRequirementResult result =
+          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(result.submittabilityExpressionResult().get().status())
+          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+      assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+          .isEqualTo("label:Code-Review=MAX");
+    }
+  }
+
+  @Test
+  public void submitRequirement_loadedFromTheLatestRevisionNoteForClosedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore the change.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Upload a second patch-set, fulfill the CR submit requirement.
+      amendChange(changeId, "refs/for/master", user, repo);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.revisions).hasSize(2);
+      voteLabel(changeId, "Code-Review", 2);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      // Abandon the change.
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirement_abandonRestoreUpdateMerge() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore the change.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Update the change.
+      amendChange(changeId, "refs/for/master", user, repo);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.revisions).hasSize(2);
+      voteLabel(changeId, "Code-Review", 2);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+      // Merge the change.
+      gApi.changes().id(changeId).current().submit();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirement_returnsEmpty_forAbandonedChangeWithPreviouslyStoredSRs()
+      throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Clear SRs for the project and update code-review label to be non-blocking.
+      clearSubmitRequirements(project);
+      LabelType cr =
+          TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_BLOCK).build();
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        u.getConfig().upsertLabelType(cr);
+        u.save();
+      }
+
+      // Restore the change. No SRs apply.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+
+      // Abandon the change. Still, no SRs apply.
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+    }
+  }
+
+  @Test
+  public void submitRequirement_returnsEmpty_forMergedChangeWithPreviouslyStoredSRs()
+      throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Clear SRs for the project and update code-review label to be non-blocking.
+      clearSubmitRequirements(project);
+      LabelType cr =
+          TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_BLOCK).build();
+      try (ProjectConfigUpdate u = updateProject(project)) {
+        u.getConfig().upsertLabelType(cr);
+        u.save();
+      }
+
+      // Restore the change. No SRs apply.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+
+      // Merge the change. Still, no SRs apply.
+      gApi.changes().id(changeId).current().submit();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).isEmpty();
+    }
+  }
+
+  @Test
+  public void submitRequirement_withMultipleAbandonAndRestore() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      // Abandon change. Submit requirements get stored in the revision note of patch-set 1.
+      gApi.changes().id(changeId).abandon();
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore the change.
+      gApi.changes().id(changeId).restore();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Abandon the change again.
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Restore, vote CR=+2, and abandon again. Make sure the requirement is now satisfied.
+      gApi.changes().id(changeId).restore();
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).abandon();
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirement_retrievedFromNoteDbForAbandonedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).abandon();
+
+      // Add another submit requirement. This will not get returned for the abandoned change, since
+      // we return the state of the SR results when the change was abandoned.
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("New-Requirement")
+              .setSubmittabilityExpression(SubmitRequirementExpression.create("-has:unresolved"))
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      ChangeInfo changeInfo =
+          gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=MAX");
+
+      // Restore the change, the new requirement will show up
+      gApi.changes().id(changeId).restore();
+      changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=MAX");
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "New-Requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "-has:unresolved");
+
+      // Abandon again, make sure the new requirement was persisted
+      gApi.changes().id(changeId).abandon();
+      changeInfo = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      assertThat(changeInfo.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "label:Code-Review=MAX");
+      assertSubmitRequirementStatus(
+          changeInfo.submitRequirements,
+          "New-Requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false,
+          /* submittabilityCondition= */ "-has:unresolved");
+    }
+  }
+
+  @Test
+  public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    // Add new submit requirement
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // The new "Verified" submit requirement is not returned, since this change is closed
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void
+      submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
+          throws Exception {
+    // Configure a legacy submit requirement: label with a max with block function
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Configure a submit requirement with the same name.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("build-cop-override")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(
+                    "label:build-cop-override=MAX -label:build-cop-override=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create a change. Vote to fulfill all requirements.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "build-cop-override", 1);
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
+    // Only non-legacy bco is returned.
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+    assertThat(change.submittable).isTrue();
+
+    // Merge the change. Submit requirements are still the same.
+    gApi.changes().id(changeId).current().submit();
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+  }
+
+  @Test
+  public void
+      submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
+          throws Exception {
+    // Configure a legacy submit requirement: label with a max with block function
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Configure a submit requirement with the same name.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("build-cop-override")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:build-cop-override=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create a change
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "build-cop-override", 1);
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
+    // Two instances of bco will be returned since their status is not matching.
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(3);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ true,
+        // MAX_WITH_BLOCK function was translated to a submittability expression.
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MIN");
+    assertThat(change.submittable).isFalse();
+  }
+
+  @Test
+  public void submitRequirements_skippedIfLegacySRIsBasedOnOptionalLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.MAY);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirement_notSkippedIfLegacySRIsBasedOnNonOptionalLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.SATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirements_returnForLegacySubmitRecords_ifEnabled() throws Exception {
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // 1. Project has two legacy requirements: Code-Review and bco. Both unsatisfied.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    // 2. Vote +1 on bco. bco becomes satisfied
+    voteLabel(changeId, "build-cop-override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+
+    // 3. Vote +1 on Code-Review. Code-Review becomes satisfied
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+
+    // 4. Merge the change. Submit requirements status is presented from NoteDb.
+    gApi.changes().id(changeId).current().submit();
+    change = gApi.changes().id(changeId).get();
+    // Legacy submit records are returned as submit requirements.
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Query the change. ChangeInfo is back-filled from the change index.
+    List<ChangeInfo> changeInfos =
+        gApi.changes()
+            .query()
+            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
+            .get();
+    assertThat(changeInfos).hasSize(1);
+    assertSubmitRequirementStatus(
+        changeInfos.get(0).submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+    gApi.changes().id(changeId).current().submit();
+
+    // Query the change. ChangeInfo is back-filled from the change index.
+    List<ChangeInfo> changeInfos =
+        gApi.changes()
+            .query()
+            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
+            .get();
+    assertThat(changeInfos).hasSize(1);
+    assertSubmitRequirementStatus(
+        changeInfos.get(0).submitRequirements,
+        "Code-Review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_applicabilityExpressionIsAlwaysHidden() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("branch:refs/heads/master"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
+    assertSubmitRequirementExpression(
+        requirement.applicabilityExpressionResult,
+        /* expression= */ null,
+        /* passingAtoms= */ null,
+        /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
+        /* fulfilled= */ true);
+    assertThat(requirement.submittabilityExpressionResult).isNotNull();
+  }
+
+  @Test
+  public void submitRequirement_nonApplicable_submittabilityAndOverrideNotEvaluated()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(
+                SubmitRequirementExpression.of("branch:refs/heads/non-existent"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("project:" + project.get()))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
+    assertSubmitRequirementExpression(
+        requirement.applicabilityExpressionResult,
+        /* expression= */ null,
+        /* passingAtoms= */ null,
+        /* failingAtoms= */ null,
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
+        /* fulfilled= */ false);
+    assertThat(requirement.submittabilityExpressionResult.status)
+        .isEqualTo(SubmitRequirementExpressionInfo.Status.NOT_EVALUATED);
+    assertThat(requirement.submittabilityExpressionResult.expression)
+        .isEqualTo(SubmitRequirementExpression.maxCodeReview().expressionString());
+    assertThat(requirement.overrideExpressionResult.status)
+        .isEqualTo(SubmitRequirementExpressionInfo.Status.NOT_EVALUATED);
+    assertThat(requirement.overrideExpressionResult.expression)
+        .isEqualTo("project:" + project.get());
+  }
+
+  @Test
+  public void submitRequirement_emptyApplicable_submittabilityAndOverrideEvaluated()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(Optional.empty())
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("project:non-existent"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 2);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream().collect(MoreCollectors.onlyElement());
+    assertThat(requirement.applicabilityExpressionResult).isNull();
+    assertSubmitRequirementExpression(
+        requirement.submittabilityExpressionResult,
+        /* expression= */ SubmitRequirementExpression.maxCodeReview().expressionString(),
+        /* passingAtoms= */ ImmutableList.of(
+            SubmitRequirementExpression.maxCodeReview().expressionString()),
+        /* failingAtoms= */ ImmutableList.of(),
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
+        /* fulfilled= */ true);
+    assertSubmitRequirementExpression(
+        requirement.overrideExpressionResult,
+        /* expression= */ "project:non-existent",
+        /* passingAtoms= */ ImmutableList.of(),
+        /* failingAtoms= */ ImmutableList.of("project:non-existent"),
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
+        /* fulfilled= */ false);
+  }
+
+  @Test
+  public void submitRequirement_overriden_submittabilityEvaluated() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setApplicabilityExpression(Optional.empty())
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("project:" + project.get()))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "Code-Review", 1);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    SubmitRequirementResultInfo requirement =
+        changeInfo.submitRequirements.stream()
+            .filter(sr -> !sr.isLegacy)
+            .collect(MoreCollectors.onlyElement());
+    assertThat(requirement.applicabilityExpressionResult).isNull();
+    assertSubmitRequirementExpression(
+        requirement.submittabilityExpressionResult,
+        /* expression= */ SubmitRequirementExpression.maxCodeReview().expressionString(),
+        /* passingAtoms= */ ImmutableList.of(),
+        /* failingAtoms= */ ImmutableList.of(
+            SubmitRequirementExpression.maxCodeReview().expressionString()),
+        /* status= */ SubmitRequirementExpressionInfo.Status.FAIL,
+        /* fulfilled= */ false);
+    assertSubmitRequirementExpression(
+        requirement.overrideExpressionResult,
+        /* expression= */ "project:" + project.get(),
+        /* passingAtoms= */ ImmutableList.of("project:" + project.get()),
+        /* failingAtoms= */ ImmutableList.of(),
+        /* status= */ SubmitRequirementExpressionInfo.Status.PASS,
+        /* fulfilled= */ true);
+  }
+
+  @Test
+  public void submitRequirements_eliminatesDuplicatesForLegacyNonMatchingSRs() throws Exception {
+    // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
+    // a single submit requirement result: in this test, we have two different submit rules that
+    // return the same label name, one as "OK" and the other as "NEED". The submit requirements
+    // API favours the blocking entry and returns one SR result with status=UNSATISFIED.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK);
+    SubmitRule r2 =
+        createSubmitRule("r2", SubmitRecord.Status.NOT_READY, "CR", SubmitRecord.Label.Status.NEED);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1).add(r2)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.UNSATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirements_eliminatesDuplicatesForLegacyMatchingSRs() throws Exception {
+    // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
+    // a single submit requirement result: in this test, we have two different submit rules that
+    // return the same label name, but both are fulfilled (i.e. they both allow submission). The
+    // submit requirements API returns one SR result with status=SATISFIED.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    SubmitRule r1 =
+        createSubmitRule("r1", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK);
+    SubmitRule r2 =
+        createSubmitRule("r2", SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.MAY);
+    try (Registration registration = extensionRegistry.newRegistration().add(r1).add(r2)) {
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.SATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirements_eliminatesMultipleDuplicatesForLegacyMatchingSRs()
+      throws Exception {
+    // If a custom/prolog submit rule emits the same label name multiple times, we merge these into
+    // a single submit requirement result: in this test, we have five different submit rules that
+    // return the same label name, all with an "OK" status. The submit requirements API returns
+    // a single SR result with status=SATISFIED.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    try (Registration registration = extensionRegistry.newRegistration()) {
+      IntStream.range(0, 5)
+          .forEach(
+              i ->
+                  registration.add(
+                      createSubmitRule(
+                          "r" + i, SubmitRecord.Status.OK, "CR", SubmitRecord.Label.Status.OK)));
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      Collection<SubmitRequirementResultInfo> submitRequirements = change.submitRequirements;
+      assertThat(submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          submitRequirements, "CR", Status.SATISFIED, /* isLegacy= */ true);
+    }
+  }
+
+  @Test
+  public void submitRequirement_duplicateSubmitRequirement_sameCase() throws Exception {
+    // Define 2 submit requirements with exact same name but different submittability expression.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = repo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = repo.getRevWalk().parseCommit(ref.getObjectId());
+      RevObject blob = repo.get(head.getTree(), ProjectConfig.PROJECT_CONFIG);
+      byte[] data = repo.getRepository().open(blob).getCachedBytes(Integer.MAX_VALUE);
+      String projectConfig = RawParseUtils.decode(data);
+
+      repo.update(
+          RefNames.REFS_CONFIG,
+          repo.commit()
+              .parent(head)
+              .message("Set project config")
+              .add(
+                  ProjectConfig.PROJECT_CONFIG,
+                  projectConfig
+                      // JGit parses this as a list value:
+                      // submit-requirement.Code-Review.submittableIf =
+                      //     [label:Code-Review=+2, label:Code-Review=+1]
+                      // if getString is used to read submittableIf JGit returns the last value
+                      // (label:Code-Review=+1)
+                      + "[submit-requirement \"Code-Review\"]\n"
+                      + "    submittableIf = label:Code-Review=+2\n"
+                      + "[submit-requirement \"Code-Review\"]\n"
+                      + "    submittableIf = label:Code-Review=+1\n"));
+    }
+    projectCache.evict(project);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // The submit requirement is fulfilled now, since label:Code-Review=+1 applies as submittability
+    // expression (see comment above)
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_duplicateSubmitRequirement_differentCase() throws Exception {
+    // Define 2 submit requirements with same name but different case and different submittability
+    // expression.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      Ref ref = repo.getRepository().exactRef(RefNames.REFS_CONFIG);
+      RevCommit head = repo.getRevWalk().parseCommit(ref.getObjectId());
+      RevObject blob = repo.get(head.getTree(), ProjectConfig.PROJECT_CONFIG);
+      byte[] data = repo.getRepository().open(blob).getCachedBytes(Integer.MAX_VALUE);
+      String projectConfig = RawParseUtils.decode(data);
+
+      repo.update(
+          RefNames.REFS_CONFIG,
+          repo.commit()
+              .parent(head)
+              .message("Set project config")
+              .add(
+                  ProjectConfig.PROJECT_CONFIG,
+                  projectConfig
+                      // ProjectConfig processes the submit requirements in the order in which they
+                      // appear (1. Code-Review, 2. code-review) and ignores any further submit
+                      // requirement if its name case-insensitively matches the name of a submit
+                      // requirement that has already been seen. This means the Code-Review submit
+                      // requirement applies and the code-review submit requirement is ignored.
+                      + "[submit-requirement \"Code-Review\"]\n"
+                      + "    submittableIf = label:Code-Review=+2\n"
+                      + "[submit-requirement \"code-review\"]\n"
+                      + "    submittableIf = label:Code-Review=+1\n"));
+    }
+    projectCache.evict(project);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Still not satisfied since the Code-Review submit requirement with label:Code-Review=+2 as
+    // submittability expression applies (see comment above).
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // The submit requirement is fulfilled now, since label:Code-Review=+2 applies as submittability
+    // expression (see comment above)
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirement_overrideInheritedSRWithDifferentNameCasing() throws Exception {
+    // Define submit requirement in root project and allow override.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Define a submit requirement with the same name in the child project that differs by case and
+    // has a different submittability expression (requires Code-Review=+1 instead of +2).
+    // This overrides the inherited submit requirement with the same name, although the case is
+    // different.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement since the override applies
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_cannotOverrideNonOverridableInheritedSRWithDifferentNameCasing()
+      throws Exception {
+    // Define submit requirement in root project and disallow override.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Define a submit requirement with the same name in the child project that differs by case and
+    // has a different submittability expression (requires Code-Review=+1 instead of +2).
+    // This is ignored since the inherited submit requirement with the same name (different case)
+    // disallows overriding.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Still not satisfied since the override is ignored.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void globalSubmitRequirement_storedForClosedChanges() throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("global-submit-requirement")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(false)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements,
+          "global-submit-requirement",
+          Status.UNSATISFIED,
+          /* isLegacy= */ false);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements,
+          "global-submit-requirement",
+          Status.SATISFIED,
+          /* isLegacy= */ false);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+      gApi.changes().id(changeId).current().submit();
+
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+      SubmitRequirementResult result =
+          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(result.submittabilityExpressionResult().get().status())
+          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+      assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+          .isEqualTo("topic:test");
+    }
+  }
+
+  @Test
+  public void projectSubmitRequirementDuplicatesGlobal_overrideNotAllowed_globalEvaluated()
+      throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(false)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Vote does not satisfy submit requirement, because the global definition is evaluated.
+      voteLabel(changeId, "CoDe-reView", 2);
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+      // In addition, the legacy submit requirement is emitted, since the status mismatch
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+
+      // Setting the topic satisfies the global definition.
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void projectSubmitRequirementDuplicatesGlobal_overrideAllowed_projectRequirementEvaluated()
+      throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(true)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("Code-Review")
+              .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+              .setAllowOverrideInChildProjects(false)
+              .build());
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Setting the topic does not satisfy submit requirement, because the project definition is
+      // evaluated.
+      gApi.changes().id(changeId).topic("test");
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      // There is no mismatch with legacy submit requirement, so the single result is emitted.
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Voting satisfies the project definition.
+      voteLabel(changeId, "Code-Review", 2);
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void legacySubmitRequirementDuplicatesGlobal_statusMatches_globalReturned()
+      throws Exception {
+    // The behaviour does not depend on AllowOverrideInChildProject in global submit requirement.
+    testLegacySubmitRequirementDuplicatesGlobalStatusMatches(/*allowOverrideInChildProject=*/ true);
+    testLegacySubmitRequirementDuplicatesGlobalStatusMatches(
+        /*allowOverrideInChildProject=*/ false);
+  }
+
+  private void testLegacySubmitRequirementDuplicatesGlobalStatusMatches(
+      boolean allowOverrideInChildProject) throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(allowOverrideInChildProject)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Both are evaluated, but only the global is returned, since both are unsatisfied
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Both are evaluated, but only the global is returned, since both are satisfied
+      voteLabel(changeId, "Code-Review", 2);
+      gApi.changes().id(changeId).topic("test");
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void legacySubmitRequirementWithIgnoreSelfApproval() throws Exception {
+    LabelType verified =
+        label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+    verified = verified.toBuilder().setIgnoreSelfApproval(true).build();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(verified);
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(verified.getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // The DefaultSubmitRule emits an "OK" submit record for Verified, while the
+    // ignoreSelfApprovalRule emits a "NEED" submit record. The "submit requirements" adapter merges
+    // both results and returns the blocking one only.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).addReviewer(user.id().toString());
+
+    voteLabel(changeId, verified.getName(), +1);
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    Collection<SubmitRequirementResultInfo> submitRequirements = changeInfo.submitRequirements;
+    assertSubmitRequirementStatus(
+        submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void legacySubmitRequirementDuplicatesGlobal_statusDoesNotMatch_bothRecordsReturned()
+      throws Exception {
+    // The behaviour does not depend on AllowOverrideInChildProject in global submit requirement.
+    testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+        /*allowOverrideInChildProject=*/ true);
+    testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+        /*allowOverrideInChildProject=*/ false);
+  }
+
+  private void testLegacySubmitRequirementDuplicatesGlobalStatusDoesNotMatch(
+      boolean allowOverrideInChildProject) throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        SubmitRequirement.builder()
+            .setName("CoDe-reView")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("topic:test"))
+            .setAllowOverrideInChildProjects(allowOverrideInChildProject)
+            .build();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+
+      // Both are evaluated, but only the global is returned, since both are unsatisfied
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      // Both are evaluated and both are returned, since result mismatch
+      voteLabel(changeId, "Code-Review", 2);
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.UNSATISFIED, /* isLegacy= */ false);
+
+      gApi.changes().id(changeId).topic("test");
+      gApi.changes().id(changeId).reviewer(admin.id().toString()).deleteVote(LabelId.CODE_REVIEW);
+
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(2);
+      assertThat(change.submitRequirements).hasSize(2);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "CoDe-reView", Status.SATISFIED, /* isLegacy= */ false);
+    }
+  }
+
+  @Test
+  public void submitRequirements_disallowsTheIsSubmittableOperator() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Wrong-Req")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:submittable"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo srResult =
+        change.submitRequirements.stream()
+            .filter(sr -> sr.name.equals("Wrong-Req"))
+            .collect(MoreCollectors.onlyElement());
+    assertThat(srResult.status).isEqualTo(Status.ERROR);
+    assertThat(srResult.submittabilityExpressionResult.errorMessage)
+        .isEqualTo("Operator 'is:submittable' cannot be used in submit requirement expressions.");
+  }
+
+  @Test
+  public void submitRequirements_forcedByDirectSubmission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("My-Requirement")
+            // Submit requirement is always unsatisfied, but we are going to bypass it.
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:false"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    pushFactory.create(admin.newIdent(), testRepo, changeId).to("refs/for/master%submit");
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "My-Requirement", Status.FORCED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.FORCED, /* isLegacy= */ true);
+  }
+
+  @Test
+  public void submitRequirement_evaluatedWithInternalUserCredentials() throws Exception {
+    GroupInput in = new GroupInput();
+    in.name = "invisible-group";
+    in.visibleToAll = false;
+    in.ownerId = adminGroupUuid().get();
+    gApi.groups().create(in);
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("My-Requirement")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("ownerin:invisible-group"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    requestScopeOperations.setApiUser(user.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    SubmitRequirementResultInfo srResult =
+        change.submitRequirements.stream()
+            .filter(sr -> sr.name.equals("My-Requirement"))
+            .collect(MoreCollectors.onlyElement());
+    assertThat(srResult.status).isEqualTo(Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirements_submittedTogetherWithoutLegacySubmitRequirements()
+      throws Exception {
+    // Add a code review submit requirement and mark the 'Code-Review' label function to be
+    // non-blocking.
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    LabelType cr = TestLabels.codeReview().toBuilder().setFunction(LabelFunction.NO_BLOCK).build();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(cr);
+      u.save();
+    }
+
+    // Create two changes in a chain.
+    createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    // Make sure the CR requirement is unsatisfied.
+    String changeId = r2.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    List<ChangeInfo> changeInfos = gApi.changes().id(changeId).submittedTogether();
+    assertThat(changeInfos).hasSize(2);
+    assertThat(
+            changeInfos.stream()
+                .map(c -> c.submittable)
+                .distinct()
+                .collect(MoreCollectors.onlyElement()))
+        .isFalse();
+  }
+
+  @Test
+  public void queryChangesBySubmitRequirementResultUsingTheLabelPredicate() throws Exception {
+    // Create a non-blocking label and a submit-requirement that necessitates voting on this label.
+    configLabel("LC", LabelFunction.NO_OP);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("LC").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("LC")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:LC=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    List<ChangeInfo> changeInfos = gApi.changes().query("label:LC=NEED").get();
+    assertThat(changeInfos).hasSize(1);
+    assertThat(changeInfos.get(0).changeId).isEqualTo(changeId);
+    assertThat(gApi.changes().query("label:LC=OK").get()).isEmpty();
+    // case does not matter
+    changeInfos = gApi.changes().query("label:lc=NEED").get();
+    assertThat(changeInfos).hasSize(1);
+    assertThat(changeInfos.get(0).changeId).isEqualTo(changeId);
+
+    voteLabel(r.getChangeId(), "LC", +1);
+    changeInfos = gApi.changes().query("label:LC=OK").get();
+    assertThat(changeInfos.get(0).changeId).isEqualTo(changeId);
+    assertThat(gApi.changes().query("label:LC=NEED").get()).isEmpty();
+  }
+
+  @Test
+  public void queryingChangesWithSubmitRequirementOptionDoesNotTouchDatabase() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            // Always not submittable
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("is:false"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r1 = createChange();
+    gApi.changes()
+        .id(r1.getChangeId())
+        .revision(r1.getCommit().name())
+        .review(ReviewInput.approve());
+
+    ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+    assertThat(changeInfo.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy = */ false);
+    assertSubmitRequirementStatus(
+        changeInfo.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy = */ true);
+
+    requestScopeOperations.setApiUser(user.id());
+    try (AutoCloseable ignored = disableNoteDb()) {
+      List<ChangeInfo> changeInfos =
+          gApi.changes()
+              .query()
+              .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+              .withOptions(
+                  new ImmutableSet.Builder<ListChangesOption>()
+                      .addAll(IndexPreloadingUtil.DASHBOARD_OPTIONS)
+                      .add(ListChangesOption.SUBMIT_REQUIREMENTS)
+                      .build())
+              .get();
+      assertThat(changeInfos).hasSize(1);
+      assertSubmitRequirementStatus(
+          changeInfos.get(0).submitRequirements,
+          "Code-Review",
+          Status.UNSATISFIED,
+          /* isLegacy = */ false);
+      assertSubmitRequirementStatus(
+          changeInfos.get(0).submitRequirements,
+          "Code-Review",
+          Status.SATISFIED,
+          /* isLegacy = */ true);
+    }
+  }
+
+  private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
+  }
+
+  private void assertSubmitRequirementResult(
+      SubmitRequirementResult result,
+      String srName,
+      SubmitRequirementResult.Status status,
+      String submitExpr,
+      SubmitRequirementExpressionResult.Status submitStatus) {
+    assertThat(result.submitRequirement().name()).isEqualTo(srName);
+    assertThat(result.status()).isEqualTo(status);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo(submitExpr);
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(submitStatus);
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy,
+      String submittabilityCondition) {
+    for (SubmitRequirementResultInfo result : results) {
+      if (result.name.equals(requirementName)
+          && result.status == status
+          && result.isLegacy == isLegacy
+          && result.submittabilityExpressionResult.expression.equals(submittabilityCondition)) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            requirementName,
+            status,
+            results.stream()
+                .map(r -> String.format("%s=%s", r.name, r.status))
+                .collect(toImmutableList())));
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy) {
+    for (SubmitRequirementResultInfo result : results) {
+      if (result.name.equals(requirementName)
+          && result.status == status
+          && result.isLegacy == isLegacy) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            requirementName,
+            status,
+            results.stream()
+                .map(r -> String.format("%s=%s, legacy=%s", r.name, r.status, r.isLegacy))
+                .collect(toImmutableList())));
+  }
+
+  private void assertSubmitRequirementExpression(
+      SubmitRequirementExpressionInfo result,
+      @Nullable String expression,
+      @Nullable List<String> passingAtoms,
+      @Nullable List<String> failingAtoms,
+      SubmitRequirementExpressionInfo.Status status,
+      boolean fulfilled) {
+    assertThat(result.expression).isEqualTo(expression);
+    if (passingAtoms == null) {
+      assertThat(result.passingAtoms).isNull();
+    } else {
+      assertThat(result.passingAtoms).containsExactlyElementsIn(passingAtoms);
+    }
+    if (failingAtoms == null) {
+      assertThat(result.failingAtoms).isNull();
+    } else {
+      assertThat(result.failingAtoms).containsExactlyElementsIn(failingAtoms);
+    }
+    assertThat(result.status).isEqualTo(status);
+    assertThat(result.fulfilled).isEqualTo(fulfilled);
+  }
+
+  private Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
+    Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+        .update();
+    return project;
+  }
+
+  private static SubmitRule createSubmitRule(
+      String ruleName,
+      SubmitRecord.Status srStatus,
+      String labelName,
+      SubmitRecord.Label.Status labelStatus) {
+    return changeData -> {
+      SubmitRecord r = new SubmitRecord();
+      r.ruleName = ruleName;
+      r.status = srStatus;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = labelName;
+      label.status = labelStatus;
+      r.labels = Arrays.asList(label);
+      return Optional.of(r);
+    };
+  }
+
+  /** Returns a hard-coded submit record containing all fields. */
+  private static class TestSubmitRule implements SubmitRule {
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      SubmitRecord record = new SubmitRecord();
+      record.ruleName = "testSubmitRule";
+      record.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = "label";
+      label.status = SubmitRecord.Label.Status.OK;
+      record.labels = Arrays.asList(label);
+      record.requirements =
+          Arrays.asList(
+              LegacySubmitRequirement.builder()
+                  .setType("type")
+                  .setFallbackText("fallback text")
+                  .build());
+      return Optional.of(record);
+    }
+  }
+
+  private static SubmitRequirementInput createSubmitRequirementInput(
+      String name, String submittabilityExpression) {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.submittabilityExpression = submittabilityExpression;
+    return input;
+  }
+
+  private static SubmitRequirementInput createSubmitRequirementInput(
+      String name, String applicableIf, String submittableIf, String overrideIf) {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.applicabilityExpression = applicableIf;
+    input.submittabilityExpression = submittableIf;
+    input.overrideExpression = overrideIf;
+    return input;
+  }
+
+  private void addComment(String changeId, String file) throws Exception {
+    ReviewInput in = new ReviewInput();
+    CommentInput ci = new CommentInput();
+    ci.path = file;
+    ci.message = "message";
+    ci.line = 1;
+    in.comments = ImmutableMap.of("foo", ImmutableList.of(ci));
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  private void fetchRefsMetaConfig() throws Exception {
+    git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
+    testRepo.reset(RefNames.REFS_CONFIG);
+  }
+
+  private PushOneCommit.Result createConfigChangeWithSubmitRequirement(
+      String srName, String submitExpression) throws Exception {
+    Config cfg = projectOperations.project(project).getConfig();
+    cfg.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        srName,
+        ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        submitExpression);
+    return createConfigChange(cfg);
+  }
+
+  private PushOneCommit.Result createConfigChange(Config cfg) throws Exception {
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                admin.newIdent(), testRepo, "Update project config", "project.config", cfg.toText())
+            .to("refs/for/refs/meta/config");
+    r.assertOkStatus();
+    return r;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
new file mode 100644
index 0000000..56e23a4
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -0,0 +1,355 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.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.ANONYMOUS_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.label;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.ThreeWayMerger;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseTimezone(timezone = "US/Eastern")
+@VerifyNoPiiInChangeNotes(true)
+public class SubmitRequirementPredicateIT extends AbstractDaemonTest {
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private SubmitRequirementsEvaluator submitRequirementsEvaluator;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private AccountOperations accountOperations;
+
+  private final LabelType label =
+      label("Custom-Label", value(1, "Positive"), value(0, "No score"), value(-1, "Negative"));
+
+  private final LabelType pLabel =
+      label("Custom-Label2", value(1, "Positive"), value(0, "No score"));
+
+  @Before
+  public void setUp() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(label.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(-1, 1))
+        .add(allowLabel(pLabel.getName()).ref("refs/heads/*").group(ANONYMOUS_USERS).range(0, 1))
+        .update();
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(label);
+      u.getConfig().upsertLabelType(pLabel);
+      u.save();
+    }
+  }
+
+  @Test
+  public void distinctVoters_sameUserVotesOnDifferentLabels_fails() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+
+    // Same user votes on both labels
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_distinctUsersOnDifferentLabels_passes() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_onlyMaxVotesRespected() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MAX,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_onlyMinVotesRespected() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", -1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MIN,count>1\"", c1);
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(c1.toString()).current().review(ReviewInput.reject());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=MIN,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_onlyExactValueRespected() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    approve(c1.toString());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],value=1,count>1\"", c1);
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],value=1,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_valueIsOptional() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", -1));
+    requestScopeOperations.setApiUser(admin.id());
+    assertNotMatching("distinctvoters:\"[Code-Review,Custom-Label],count>1\"", c1);
+    recommend(c1.toString());
+    assertMatching("distinctvoters:\"[Code-Review,Custom-Label],count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_moreThanTwoLabels() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label2", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertMatching(
+        "distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>1\"", c1);
+  }
+
+  @Test
+  public void distinctVoters_moreThanTwoLabels_moreThanTwoUsers() throws Exception {
+    Change.Id c1 = changeOperations.newChange().project(project).create();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label2", 1));
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(c1.toString());
+    assertNotMatching(
+        "distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>2\"", c1);
+    Account.Id tester = accountOperations.newAccount().create();
+    requestScopeOperations.setApiUser(tester);
+    gApi.changes()
+        .id(c1.toString())
+        .current()
+        .review(ReviewInput.create().label("Custom-Label", 1));
+    assertMatching(
+        "distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>2\"", c1);
+  }
+
+  @Test
+  public void hasSubmoduleUpdate_withSubmoduleChangeInParent1() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    PushOneCommit.Result r1 = createGitSubmoduleCommit("refs/for/master");
+    testRepo.reset(initial);
+    PushOneCommit.Result r2 = createNormalCommit("refs/for/master", "file1");
+    PushOneCommit.Result merge =
+        createMergeCommitChange(
+            "refs/for/master",
+            r1.getCommit(),
+            r2.getCommit(),
+            mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+    assertNotMatching("has:submodule-update,base=1", merge.getChange().getId());
+    assertMatching("has:submodule-update,base=2", merge.getChange().getId());
+    assertNotMatching("has:submodule-update", merge.getChange().getId());
+  }
+
+  @Test
+  public void hasSubmoduleUpdate_withSubmoduleChangeInParent2() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    PushOneCommit.Result r1 = createNormalCommit("refs/for/master", "file1");
+    testRepo.reset(initial);
+    PushOneCommit.Result r2 = createGitSubmoduleCommit("refs/for/master");
+    PushOneCommit.Result merge =
+        createMergeCommitChange(
+            "refs/for/master",
+            r1.getCommit(),
+            r2.getCommit(),
+            mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+    assertMatching("has:submodule-update,base=1", merge.getChange().getId());
+    assertNotMatching("has:submodule-update,base=2", merge.getChange().getId());
+    assertNotMatching("has:submodule-update", merge.getChange().getId());
+  }
+
+  @Test
+  public void hasSubmoduleUpdate_withoutSubmoduleChange_doesNotMatch() throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    PushOneCommit.Result r1 = createNormalCommit("refs/for/master", "file1");
+    testRepo.reset(initial);
+    PushOneCommit.Result r2 = createNormalCommit("refs/for/master", "file2");
+    PushOneCommit.Result merge =
+        createMergeCommitChange(
+            "refs/for/master",
+            r1.getCommit(),
+            r2.getCommit(),
+            mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+    assertNotMatching("has:submodule-update,base=1", merge.getChange().getId());
+    assertNotMatching("has:submodule-update,base=2", merge.getChange().getId());
+    assertNotMatching("has:submodule-update", merge.getChange().getId());
+  }
+
+  @Test
+  public void hasSubmoduleUpdate_withBaseParamGreaterThanParentCount_doesNotMatch()
+      throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    PushOneCommit.Result r1 = createNormalCommit("refs/for/master", "file1");
+    testRepo.reset(initial);
+    PushOneCommit.Result r2 = createGitSubmoduleCommit("refs/for/master");
+    PushOneCommit.Result merge =
+        createMergeCommitChange(
+            "refs/for/master",
+            r1.getCommit(),
+            r2.getCommit(),
+            mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+    assertNotMatching("has:submodule-update,base=3", merge.getChange().getId());
+  }
+
+  @Test
+  public void hasSubmoduleUpdate_withWrongArgs_throws() {
+    assertError(
+        "has:submodule-update,base=xyz",
+        changeOperations.newChange().project(project).create(),
+        "failed to parse the parent number xyz: For input string: \"xyz\"");
+    assertError(
+        "has:submodule-update,base=1,arg=foo",
+        changeOperations.newChange().project(project).create(),
+        "wrong number of arguments for the has:submodule-update operator");
+    assertError(
+        "has:submodule-update,base",
+        changeOperations.newChange().project(project).create(),
+        "unexpected base value format");
+  }
+
+  private PushOneCommit.Result createGitSubmoduleCommit(String ref) throws Exception {
+    return pushFactory
+        .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of())
+        .addGitSubmodule(
+            "modules/module-a", ObjectId.fromString("19f1787342cb15d7e82a762f6b494e91ccb4dd34"))
+        .to(ref);
+  }
+
+  private PushOneCommit.Result createNormalCommit(String ref, String fileName) throws Exception {
+    return pushFactory
+        .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of(fileName, fileName))
+        .to(ref);
+  }
+
+  private PushOneCommit.Result createMergeCommitChange(
+      String ref, RevCommit parent1, RevCommit parent2, @Nullable ObjectId treeId)
+      throws Exception {
+    PushOneCommit m =
+        pushFactory
+            .create(admin.newIdent(), testRepo)
+            .setParents(ImmutableList.of(parent1, parent2));
+    if (treeId != null) {
+      m.setTopLevelTreeId(treeId);
+    }
+    PushOneCommit.Result result = m.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
+  private ObjectId mergeAndGetTreeId(RevCommit c1, RevCommit c2) throws Exception {
+    ThreeWayMerger threeWayMerger = MergeStrategy.RESOLVE.newMerger(repo(), true);
+    threeWayMerger.setBase(c1.getParent(0));
+    boolean mergeResult = threeWayMerger.merge(c1, c2);
+    assertThat(mergeResult).isTrue();
+    return threeWayMerger.getResultTreeId();
+  }
+
+  private void assertMatching(String requirement, Change.Id change) {
+    assertThat(evaluate(requirement, change).status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private void assertNotMatching(String requirement, Change.Id change) {
+    assertThat(evaluate(requirement, change).status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  private void assertError(String requirement, Change.Id change, String errorMessage) {
+    SubmitRequirementExpressionResult result = evaluate(requirement, change);
+    assertThat(result.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+    assertThat(result.errorMessage().get()).isEqualTo(errorMessage);
+  }
+
+  private SubmitRequirementExpressionResult evaluate(String requirement, Change.Id change) {
+    ChangeData cd = changeDataFactory.create(project, change);
+    return submitRequirementsEvaluator.evaluateExpression(
+        SubmitRequirementExpression.create(requirement), cd);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
index 636b71d..af95e7e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 import com.google.inject.Inject;
 import java.util.List;
 import java.util.stream.Collectors;
@@ -44,7 +45,7 @@
             recordsBeforeSubmission.stream()
                 .map(record -> record.ruleName)
                 .collect(Collectors.toList()))
-        .containsExactly("gerrit~DefaultSubmitRule");
+        .containsExactly(DefaultSubmitRule.RULE_NAME);
     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.
@@ -67,7 +68,7 @@
             recordsBeforeSubmission.stream()
                 .map(record -> record.ruleName)
                 .collect(Collectors.toList()))
-        .containsExactly("gerrit~DefaultSubmitRule");
+        .containsExactly(DefaultSubmitRule.RULE_NAME);
     gApi.changes().id(r.getChangeId()).current().submit();
     // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
     // this would show up as blocking submission.
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index 5124d11..651130e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.collect.ImmutableList;
@@ -35,6 +36,7 @@
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
 import java.util.HashSet;
@@ -59,9 +61,9 @@
               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);
+              value(-1, "I would prefer this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:ANY");
       u.getConfig().upsertLabelType(codeReview.build());
       u.save();
     }
@@ -345,8 +347,8 @@
   }
 
   @Test
-  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "1k")
-  public void autoGeneratedPostSubmitDiffIsNotPartOfTheCommentSizeLimit() throws Exception {
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "10k")
+  public void autoGeneratedPostSubmitDiffIsPartOfTheCommentSizeLimit() throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
@@ -356,35 +358,69 @@
     // 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");
+        .doesNotContain("The diff is too large to show. Please review the diff");
 
-    // unrelated comment and change message posting works fine, since the post submit diff is not
+    // unrelated comment and change message posting doesn't work, since the post submit diff is
     // 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");
+    // 800 + 9500 > 10k.
+    String message = new String(new char[9500]).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);
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId.get()).current().review(reviewInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Exceeding maximum cumulative size of comments and change messages");
   }
 
   @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");
+    // max post submit diff size is 300k
+    String content = new String(new char[320000]).replace("\0", "a");
 
     changeOperations.change(changeId).newPatchset().file("file").content(content).create();
 
-    // Post submit diff is over the cumulativeCommentSizeLimit, so we shorten the message.
+    // Post submit diff is over the postSubmitDiffSizeLimit (300k).
+    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 unreviewed changes in the following "
+                + "files:\n\n```\nThe name of the file: file\nInsertions: 1, Deletions: 1.\n\nThe"
+                + " diff is too large to show. Please review the diff.\n```\n");
+  }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "10k")
+  public void postSubmitDiffCannotBeTooBigWithLargeComments() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    // unrelated comment taking up most of the space, making post submit diff shorter.
+    String message = new String(new char[9700]).replace("\0", "a");
+    ReviewInput reviewInput = new ReviewInput().message(message);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.path = "file";
+    reviewInput.comments = ImmutableMap.of("file", ImmutableList.of(commentInput));
+    gApi.changes().id(changeId.get()).current().review(reviewInput);
+
+    String content = new String(new char[500]).replace("\0", "a");
+    changeOperations.change(changeId).newPatchset().file("file").content(content).create();
+
+    // Post submit diff is over the cumulativeCommentSizeLimit, since the comment took most of
+    // the space (even though the post submit diff is not limited).
     gApi.changes().id(changeId.get()).current().submit();
     assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
         .isEqualTo(
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
index 079d43e9..27f6111 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupAssert.java
@@ -42,7 +42,7 @@
     assertThat(toBoolean(info.options.visibleToAll)).isEqualTo(group.isVisibleToAll());
     assertThat(info.description).isEqualTo(group.getDescription());
     assertThat(Url.decode(info.ownerId)).isEqualTo(group.getOwnerGroupUUID().get());
-    assertThat(info.createdOn).isEqualTo(group.getCreatedOn());
+    assertThat(info.createdOn.toInstant()).isEqualTo(group.getCreatedOn());
   }
 
   public static boolean toBoolean(Boolean b) {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 4cbc36b..04bdf15 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -109,6 +109,7 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.Target;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
@@ -557,14 +558,17 @@
     assertThrows(AuthException.class, () -> gApi.groups().create(name("newGroup")));
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   @Test
   public void createdOnFieldIsPopulatedForNewGroup() throws Exception {
     // NoteDb allows only second precision.
-    Timestamp testStartTime = TimeUtil.truncateToSecond(TimeUtil.nowTs());
+    Instant testStartTime = TimeUtil.truncateToSecond(TimeUtil.now());
     String newGroupName = name("newGroup");
     GroupInfo group = gApi.groups().create(newGroupName).get();
 
-    assertThat(group.createdOn).isAtLeast(testStartTime);
+    assertThat(group.createdOn.toInstant()).isAtLeast(testStartTime);
   }
 
   @Test
@@ -597,6 +601,20 @@
   }
 
   @Test
+  public void getGroupFromMetaId() throws Exception {
+    AccountGroup.UUID uuid = groupOperations.newGroup().create();
+    InternalGroup preUpdateState = groupCache.get(uuid).get();
+    gApi.groups().id(uuid.toString()).description("New description");
+
+    InternalGroup postUpdateState = groupCache.get(uuid).get();
+    assertThat(postUpdateState).isNotEqualTo(preUpdateState);
+    assertThat(groupCache.getFromMetaId(uuid, preUpdateState.getRefState()))
+        .isEqualTo(preUpdateState);
+    assertThat(groupCache.getFromMetaId(uuid, postUpdateState.getRefState()))
+        .isEqualTo(postUpdateState);
+  }
+
+  @Test
   @GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
   public void getSystemGroupByConfiguredName() throws Exception {
     GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
@@ -606,7 +624,7 @@
     assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
 
     group = gApi.groups().id(anonymousUsersGroup.getName()).get();
-    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
+    assertThat(group.id).isEqualTo(Url.encode(anonymousUsersGroup.getUUID().get()));
   }
 
   @Test
@@ -614,7 +632,7 @@
     GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
     GroupInfo group = gApi.groups().id("Anonymous Users").get();
     assertThat(group.name).isEqualTo(anonymousUsersGroup.getName());
-    assertThat(group.id).isEqualTo(Url.encode((anonymousUsersGroup.getUUID().get())));
+    assertThat(group.id).isEqualTo(Url.encode(anonymousUsersGroup.getUUID().get()));
   }
 
   @Test
@@ -1610,7 +1628,7 @@
         treeId = oi.insert(Constants.OBJ_TREE, new byte[] {});
       }
 
-      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.nowTs());
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(ident);
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index 18eca27..462d0a5 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 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.common.RawInputUtil;
 import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
 import com.google.gerrit.extensions.api.plugins.PluginApi;
@@ -198,6 +199,7 @@
     return pluginJarContent(plugin);
   }
 
+  @Nullable
   private String pluginVersion(String plugin) {
     String name = pluginName(plugin);
     if (name.endsWith("empty")) {
@@ -210,6 +212,7 @@
     return dash > 0 ? name.substring(dash + 1) : "";
   }
 
+  @Nullable
   private String pluginApiVersion(String plugin) {
     if (plugin.endsWith("normal.jar")) {
       return "2.16.19-SNAPSHOT";
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
index 744cc2a..fe0ab2a 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginLoaderIT.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.acceptance.api.plugin;
 
+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.config.GerritConfig;
@@ -34,9 +36,9 @@
   @Override
   protected void afterTest() throws Exception {}
 
-  @Test(expected = MissingMandatoryPluginsException.class)
+  @Test
   @GerritConfig(name = "plugins.mandatory", value = "my-mandatory-plugin")
   public void shouldFailToStartGerritWhenMandatoryPluginsAreMissing() throws Exception {
-    super.beforeTest(testDescription);
+    assertThrows(MissingMandatoryPluginsException.class, () -> super.beforeTest(testDescription));
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index bf428f9..6bd2b68 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -24,6 +24,8 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.truth.ConfigSubject.assertThat;
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -34,6 +36,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Permission;
@@ -60,15 +63,19 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.schema.GrantRevertPermission;
 import com.google.inject.Inject;
+import java.util.Arrays;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
@@ -258,6 +265,38 @@
   }
 
   @Test
+  public void addDuplicatedAccessSection_doesNotAddDuplicateEntry() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // Update project config. Record the file content and the refs_config object ID
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+    ObjectId refsConfigId =
+        projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG).getId();
+    List<String> projectConfigLines =
+        Arrays.asList(projectOperations.project(newProjectName).getConfig().toText().split("\n"));
+    assertThat(projectConfigLines)
+        .containsExactly(
+            "[submit]",
+            "\taction = inherit",
+            "[access \"refs/heads/*\"]",
+            "\tlabel-Code-Review = deny group Registered Users",
+            "\tlabel-Code-Review = -1..+1 group Project Owners",
+            "\tpush = group Registered Users");
+
+    // Apply the same update once more. Make sure that the file content and the ref did not change
+    pApi().access(accessInput);
+
+    List<String> newProjectConfigLines =
+        Arrays.asList(projectOperations.project(newProjectName).getConfig().toText().split("\n"));
+    ObjectId newRefsConfigId =
+        projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG).getId();
+    assertThat(projectConfigLines).isEqualTo(newProjectConfigLines);
+    assertThat(refsConfigId).isEqualTo(newRefsConfigId);
+  }
+
+  @Test
   public void addAccessSectionForPluginPermission() throws Exception {
     try (Registration registration =
         extensionRegistry
@@ -320,6 +359,79 @@
   }
 
   @Test
+  public void addAccessSectionWithInvalidLabelRange_minGreaterThanMax() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo permissionRuleInfo =
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+    permissionRuleInfo.min = 1;
+    permissionRuleInfo.max = -1;
+    accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid range for permission rule that assigns label-Code-Review to group %s"
+                    + " on ref refs/heads/*: 1..-1 (min must be <= max)",
+                SystemGroupBackend.REGISTERED_USERS.get()));
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidLabelRange_minSetMaxMissing() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo permissionRuleInfo =
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+    permissionRuleInfo.min = -1;
+    accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid range for permission rule that assigns label-Code-Review to group %s"
+                    + " on ref refs/heads/*: -1.. (max is required if min is set)",
+                SystemGroupBackend.REGISTERED_USERS.get()));
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidLabelRange_maxSetMinMissing() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo permissionRuleInfo =
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+    permissionRuleInfo.max = 1;
+    accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Invalid range for permission rule that assigns label-Code-Review to group %s"
+                    + " on ref refs/heads/*: ..1 (min is required if max is set)",
+                SystemGroupBackend.REGISTERED_USERS.get()));
+  }
+
+  @Test
   public void createAccessChangeNop() throws Exception {
     ProjectAccessInput accessInput = newProjectAccessInput();
     assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
@@ -464,6 +576,40 @@
   }
 
   @Test
+  public void removePermissionRuleForNonExistingeExternalGroup() throws Exception {
+    // Register a group backend with an external group
+    TestGroupBackend testGroupBackend = new TestGroupBackend();
+    GroupDescription.Basic externalGroup = testGroupBackend.create("External Group");
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(testGroupBackend)) {
+      // Add a permission for the external group.
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+      PermissionInfo push = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      push.rules.put(externalGroup.getGroupUUID().get(), pri);
+      accessSectionInfo.permissions.put(Permission.PUSH, push);
+      accessInput.add.put(REFS_HEADS, accessSectionInfo);
+      pApi().access(accessInput);
+      assertThat(pApi().access().local).isNotEmpty();
+
+      // Remove the external group.
+      testGroupBackend.remove(externalGroup.getGroupUUID());
+
+      // Remove the permission rule for the external group that no longer exists.
+      AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+      push = newPermissionInfo();
+      pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      push.rules.put(externalGroup.getGroupUUID().get(), pri);
+      accessSectionToRemove.permissions.put(Permission.PUSH, push);
+      ProjectAccessInput removal = newProjectAccessInput();
+      removal.remove.put(REFS_HEADS, accessSectionToRemove);
+      pApi().access(removal);
+      assertThat(pApi().access().local).isEmpty();
+    }
+  }
+
+  @Test
   public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
     // Add initial permission set
     ProjectAccessInput accessInput = newProjectAccessInput();
@@ -946,6 +1092,90 @@
     assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
   }
 
+  @Test
+  public void grantAllowAndDenyForSameGroup() throws Exception {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    String access = "access";
+    List<String> allowThenDeny =
+        asList(registeredUsers.toConfigValue(), "deny " + registeredUsers.toConfigValue());
+    // Clone repository to forcefully add permission
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
+
+    // Fetch permission ref
+    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
+    allProjectsRepo.reset("cfg");
+
+    // Load current permissions
+    String config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+
+    // Append and push allowThenDeny permissions
+    Config cfg = new Config();
+    cfg.fromText(config);
+    cfg.setStringList(access, AccessSection.HEADS, Permission.READ, allowThenDeny);
+    config = cfg.toText();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
+    push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+    ProjectAccessInfo pai = gApi.projects().name(allProjects.get()).access();
+    Map<String, AccessSectionInfo> local = pai.local;
+    AccessSectionInfo heads = local.get(AccessSection.HEADS);
+    Map<String, PermissionInfo> permissions = heads.permissions;
+    PermissionInfo read = permissions.get(Permission.READ);
+    Map<String, PermissionRuleInfo> rules = read.rules;
+    assertEquals(
+        rules.get(registeredUsers.getUUID().get()),
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false));
+  }
+
+  @Test
+  public void grantDenyAndAllowForSameGroup() throws Exception {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    String access = "access";
+    List<String> denyThenAllow =
+        asList("deny " + registeredUsers.toConfigValue(), registeredUsers.toConfigValue());
+    // Clone repository to forcefully add permission
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
+
+    // Fetch permission ref
+    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
+    allProjectsRepo.reset("cfg");
+
+    // Load current permissions
+    String config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+
+    // Append and push denyThenAllow permissions
+    Config cfg = new Config();
+    cfg.fromText(config);
+    cfg.setStringList(access, AccessSection.HEADS, Permission.READ, denyThenAllow);
+    config = cfg.toText();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
+    push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+    ProjectAccessInfo pai = gApi.projects().name(allProjects.get()).access();
+    Map<String, AccessSectionInfo> local = pai.local;
+    AccessSectionInfo heads = local.get(AccessSection.HEADS);
+    Map<String, PermissionInfo> permissions = heads.permissions;
+    PermissionInfo read = permissions.get(Permission.READ);
+    Map<String, PermissionRuleInfo> rules = read.rules;
+    assertEquals(
+        rules.get(registeredUsers.getUUID().get()),
+        new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false));
+  }
+
   private ProjectApi pApi() throws Exception {
     return gApi.projects().name(newProjectName.get());
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index 18e192d..5a024cc 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -23,6 +23,7 @@
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 
+import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -34,6 +35,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
@@ -43,10 +45,13 @@
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
 import java.util.Iterator;
 import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
@@ -433,6 +438,76 @@
     assertThat(changeInfo.topic).isEqualTo(input.topic);
   }
 
+  @Test
+  public void cherryPickOnTopOfOpenChange() throws Exception {
+    BranchNameKey srcBranch = BranchNameKey.create(project, "master");
+
+    // Create a target branch
+    BranchNameKey destBranch = BranchNameKey.create(project, "foo");
+    createBranch(destBranch);
+
+    // Create base change on the target branch
+    PushOneCommit.Result r = createChange("refs/for/" + destBranch.shortName());
+    String base = r.getCommit().name();
+    int baseChangeNumber = r.getChange().getId().get();
+
+    // Create commit to cherry-pick on the source branch (no change exists for this commit)
+    String changeId = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    RevCommit commitToCherryPick;
+    try (Repository repo = repoManager.openRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      commitToCherryPick =
+          tr.commit()
+              .parent(repo.parseCommit(repo.exactRef(srcBranch.branch()).getObjectId()))
+              .message(String.format("Commit to be cherry-picked\n\nChange-Id: %s\n", changeId))
+              .add("file.txt", "content")
+              .create();
+      tr.branch(srcBranch.branch()).update(commitToCherryPick);
+    }
+
+    // Perform the cherry-pick (cherry-pick on top of the base change)
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch.shortName();
+    input.base = base;
+    ChangeInfo cherryPickChange =
+        gApi.projects()
+            .name(project.get())
+            .commit(commitToCherryPick.name())
+            .cherryPick(input)
+            .get();
+
+    // Verify that a new change in destination branch was created.
+    assertThat(cherryPickChange._number).isGreaterThan(baseChangeNumber);
+    assertThat(cherryPickChange.branch).isEqualTo(destBranch.shortName());
+    assertThat(cherryPickChange.revisions).hasSize(1);
+    assertThat(cherryPickChange.messages).hasSize(1);
+
+    // Verify that the Change-Id of the cherry-picked commit is used for the cherry pick change.
+    assertThat(cherryPickChange.changeId).isEqualTo(changeId);
+
+    // Verify that cherry-pick-of is not set, since we cherry-picked a commit and not a change.
+    assertThat(cherryPickChange.cherryPickOfChange).isNull();
+    assertThat(cherryPickChange.cherryPickOfPatchSet).isNull();
+
+    // Verify that the message of the cherry-picked commit was used for the cherry-pick change.
+    RevisionInfo revInfo = cherryPickChange.revisions.get(cherryPickChange.currentRevision);
+    assertThat(revInfo).isNotNull();
+    assertThat(revInfo.commit.message).isEqualTo(commitToCherryPick.getFullMessage());
+
+    // Verify that the provided base commit is the parent commit of the cherry pick revision.
+    assertThat(revInfo.commit.parents).hasSize(1);
+    assertThat(revInfo.commit.parents.get(0).commit).isEqualTo(input.base);
+
+    // Verify that the related changes contain the base change and the cherry-pick change (no matter
+    // for which of these changes the related changes are retrieved).
+    assertThat(gApi.changes().id(cherryPickChange._number).current().related().changes)
+        .comparingElementsUsing(hasId())
+        .containsExactly(baseChangeNumber, cherryPickChange._number);
+    assertThat(gApi.changes().id(baseChangeNumber).current().related().changes)
+        .comparingElementsUsing(hasId())
+        .containsExactly(baseChangeNumber, cherryPickChange._number);
+  }
+
   private IncludedInInfo getIncludedIn(ObjectId id) throws Exception {
     return gApi.projects().name(project.get()).commit(id.name()).includedIn();
   }
@@ -452,4 +527,9 @@
   private void createLightWeightTag(String tagName) throws Exception {
     pushHead(testRepo, RefNames.REFS_TAGS + tagName, false, false);
   }
+
+  private static Correspondence<RelatedChangeAndCommitInfo, Integer> hasId() {
+    return NullAwareCorrespondence.transforming(
+        relatedChangeAndCommitInfo -> relatedChangeAndCommitInfo._changeNumber, "hasId");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
new file mode 100644
index 0000000..28a0196
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
@@ -0,0 +1,1105 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.GroupList;
+import com.google.gerrit.server.project.LabelConfigValidator;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class ProjectConfigIT extends AbstractDaemonTest {
+  private static final String INVALID_PRROJECT_CONFIG =
+      "[label \"Foo\"]\n"
+          // copyAllScoresOnTrivialRebase is deprecated and no longer allowed to be set
+          + "  copyAllScoresOnTrivialRebase = true";
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void noLabelValidationForNonRefsMetaConfigChange() throws Exception {
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            "refs/heads/master",
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            INVALID_PRROJECT_CONFIG,
+            /* topic= */ null);
+    r.assertOkStatus();
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void noLabelValidationForNoneProjectConfigChange() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Test Change",
+            "foo.config",
+            INVALID_PRROJECT_CONFIG,
+            /* topic= */ null);
+    r.assertOkStatus();
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void validateNoIssues_push() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[label \"Foo\"]\n  description = Foo Label");
+    PushOneCommit.Result r = push.to("refs/for/" + RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void validateNoIssues_createChangeApi() throws Exception {
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = RefNames.REFS_CONFIG;
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    ChangeInfo changeInfo = gApi.changes().create(changeInput).get();
+
+    gApi.changes().id(changeInfo.id).edit().create();
+    gApi.changes()
+        .id(changeInfo.id)
+        .edit()
+        .modifyFile(
+            ProjectConfig.PROJECT_CONFIG,
+            RawInputUtil.create("[label \"Foo\"]\n  description = Foo Label"));
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    gApi.changes().id(changeInfo.id).edit().publish(publishInput);
+
+    approve(changeInfo.id);
+    gApi.changes().id(changeInfo.id).current().submit();
+    assertThat(gApi.changes().id(changeInfo.id).get().status).isEqualTo(ChangeStatus.MERGED);
+  }
+
+  @Test
+  public void rejectSettingCopyAnyScore() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ true, "is:ANY");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ false, "is:ANY");
+  }
+
+  @Test
+  public void rejectCreatingLabelWithInvalidFunction() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[label \"Foo\"]\n  function = INVALID");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: Invalid function for label \"foo\"."
+                + " Valid names are: NoBlock, NoOp, PatchSetLock",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectCreatingLabelPermissionWithInvalidRange_minGreaterThanMax() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ImmutableMap.of(
+                ProjectConfig.PROJECT_CONFIG,
+                "[access \"refs/heads/*\"]\n  label-Code-Review = 1..-1 group Registered-Users",
+                GroupList.FILE_NAME,
+                String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: invalid rule in"
+                + " access.refs/heads/*.label-Code-Review:"
+                + " invalid range in rule: 1..-1 group Registered-Users",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectCreatingLabelPermissionWithInvalidRange_minSetMaxMissing() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ImmutableMap.of(
+                ProjectConfig.PROJECT_CONFIG,
+                "[access \"refs/heads/*\"]\n  label-Code-Review = -1.. group Registered-Users",
+                GroupList.FILE_NAME,
+                String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: invalid rule in"
+                + " access.refs/heads/*.label-Code-Review:"
+                + " invalid range in rule: -1.. group Registered-Users",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectCreatingLabelPermissionWithInvalidRange_maxSetMinMissing() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ImmutableMap.of(
+                ProjectConfig.PROJECT_CONFIG,
+                "[access \"refs/heads/*\"]\n  label-Code-Review = ..1 group Registered-Users",
+                GroupList.FILE_NAME,
+                String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: invalid project configuration:\n"
+                + "ERROR: commit %s:   project.config: invalid rule in"
+                + " access.refs/heads/*.label-Code-Review:"
+                + " invalid range in rule: ..1 group Registered-Users",
+            abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectSettingCopyMinScore() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ false, "is:MIN");
+  }
+
+  @Test
+  public void rejectSettingCopyMaxScore() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ true, "is:MAX");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ false, "is:MAX");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfNoChange() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CHANGE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfNoCodeChange() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CODE_CHANGE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CODE_CHANGE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ true,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ false,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresOnTrivialRebase() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ true,
+        "changekind:TRIVIAL_REBASE");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ false,
+        "changekind:TRIVIAL_REBASE");
+  }
+
+  @Test
+  public void rejectSettingCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true,
+        "has:unchanged-files");
+    testRejectSettingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false,
+        "has:unchanged-files");
+  }
+
+  private void testRejectSettingLabelFlag(
+      String key, boolean value, String expectedPredicateSuggestion) throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = %s", key, value));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use '%s' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), key, expectedPredicateSuggestion));
+  }
+
+  @Test
+  public void rejectSettingCopyValues() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:<copy-value>' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_VALUE));
+  }
+
+  @Test
+  public void rejectChangingCopyAnyScore() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ true, "is:ANY");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ false, "is:ANY");
+  }
+
+  @Test
+  public void rejectChangingCopyMinScore() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ false, "is:MIN");
+  }
+
+  @Test
+  public void rejectChangingCopyMaxScore() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ true, "is:MAX");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ false, "is:MAX");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfNoChange() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CHANGE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfNoCodeChange() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ true,
+        "changekind:NO_CODE_CHANGE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        /* value= */ false,
+        "changekind:NO_CODE_CHANGE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ true,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* value= */ false,
+        "changekind:MERGE_FIRST_PARENT_UPDATE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresOnTrivialRebase() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ true,
+        "changekind:TRIVIAL_REBASE");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        /* value= */ false,
+        "changekind:TRIVIAL_REBASE");
+  }
+
+  @Test
+  public void rejectChangingCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true,
+        "has:unchanged-files");
+    testRejectChangingLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false,
+        "has:unchanged-files");
+  }
+
+  private void testRejectChangingLabelFlag(
+      String key, boolean value, String expectedPredicateSuggestion) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format("[label \"Foo\"]\n  %s = %s", key, !value))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    testRejectSettingLabelFlag(key, value, expectedPredicateSuggestion);
+  }
+
+  @Test
+  public void rejectChangingCopyValues() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = -1\n  %s = -2",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:<copy-value>' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_VALUE));
+  }
+
+  @Test
+  public void rejectSubmitRequirement_duplicateDescriptionKeys() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[submit-requirement \"Foo\"]\n"
+                + "    description = description 1\n "
+                + "    submittableIf = label:Code-Review=MAX\n"
+                + "[submit-requirement \"Foo\"]\n"
+                + "    description = description 2\n");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s:   project.config: multiple definitions of description"
+                + " for submit requirement 'foo'",
+            abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectSubmitRequirement_duplicateApplicableIfKeys() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[submit-requirement \"Foo\"]\n "
+                + "   applicableIf = is:true\n  "
+                + "   submittableIf = label:Code-Review=MAX\n"
+                + "[submit-requirement \"Foo\"]\n"
+                + "   applicableIf = is:false\n");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s:   project.config: multiple definitions of applicableif"
+                + " for submit requirement 'foo'",
+            abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectSubmitRequirement_duplicateSubmittableIfKeys() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[submit-requirement \"Foo\"]\n"
+                + "    submittableIf = label:Code-Review=MAX\n"
+                + "[submit-requirement \"Foo\"]\n"
+                + "    submittableIf = label:Code-Review=MIN\n");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s:   project.config: multiple definitions of submittableif"
+                + " for submit requirement 'foo'",
+            abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectSubmitRequirement_duplicateOverrideIfKeys() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[submit-requirement \"Foo\"]\n"
+                + "  overrideIf = is:true\n "
+                + "  submittableIf = label:Code-Review=MAX\n"
+                + "[submit-requirement \"Foo\"]\n"
+                + "  overrideIf = is:false\n");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s:   project.config: multiple definitions of overrideif"
+                + " for submit requirement 'foo'",
+            abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void rejectSubmitRequirement_duplicateCanOverrideInChildProjectsKey() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            "[submit-requirement \"Foo\"]\n"
+                + "    canOverrideInChildProjects = true\n"
+                + "    submittableIf = label:Code-Review=MAX\n"
+                + "[submit-requirement \"Foo\"]\n "
+                + "    canOverrideInChildProjects = false\n");
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s:   project.config: multiple definitions of canoverrideinchildprojects"
+                + " for submit requirement 'foo'",
+            abbreviateName(r.getCommit())));
+  }
+
+  @Test
+  public void submitRequirementsAreParsed_forExistingDuplicateDefinitions() throws Exception {
+    // Duplicate submit requirement definitions are rejected on config change uploads. For setups
+    // already containing duplicate SR definitions, the server is able to parse the "submit
+    // requirements correctly"
+
+    RevCommit revision;
+    // Commit a change to the project config, bypassing server validation.
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      revision =
+          testRepo
+              .branch(RefNames.REFS_CONFIG)
+              .commit()
+              .add(
+                  ProjectConfig.PROJECT_CONFIG,
+                  "[submit-requirement \"Foo\"]\n"
+                      + "    canOverrideInChildProjects = true\n"
+                      + "    submittableIf = label:Code-Review=MAX\n"
+                      + "[submit-requirement \"Foo\"]\n "
+                      + "    canOverrideInChildProjects = false\n")
+              .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+              .create();
+    }
+
+    try (Repository git = repoManager.openRepository(project)) {
+      // Server is able to parse the config.
+      ProjectConfig cfg = projectConfigFactory.create(project);
+      cfg.load(git, revision);
+
+      // One of the two definitions takes precedence and overrides the other.
+      assertThat(cfg.getSubmitRequirementSections())
+          .containsExactly(
+              "Foo",
+              SubmitRequirement.builder()
+                  .setName("Foo")
+                  .setAllowOverrideInChildProjects(false)
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create("label:Code-Review=MAX"))
+                  .build());
+    }
+  }
+
+  @Test
+  public void testRejectChangingLabelFunction_toMaxWithBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* newLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* errorMessage= */ String.format(
+            "Value '%s' of 'label.foo.function' is not allowed and cannot be set."
+                + " Label functions can only be set to {no_block, no_op, patch_set_lock}."
+                + " Use submit requirements instead of label functions.",
+            LabelFunction.MAX_WITH_BLOCK.getFunctionName()));
+  }
+
+  @Test
+  public void testRejectChangingLabelFunction_toMaxNoBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* newLabelFunction= */ LabelFunction.MAX_NO_BLOCK,
+        /* errorMessage= */ String.format(
+            "Value '%s' of 'label.foo.function' is not allowed and cannot be set."
+                + " Label functions can only be set to {no_block, no_op, patch_set_lock}."
+                + " Use submit requirements instead of label functions.",
+            LabelFunction.MAX_NO_BLOCK.getFunctionName()));
+  }
+
+  @Test
+  public void testRejectChangingLabelFunction_toAnyWithBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* newLabelFunction= */ LabelFunction.ANY_WITH_BLOCK,
+        /* errorMessage= */ String.format(
+            "Value '%s' of 'label.foo.function' is not allowed and cannot be set."
+                + " Label functions can only be set to {no_block, no_op, patch_set_lock}."
+                + " Use submit requirements instead of label functions.",
+            LabelFunction.ANY_WITH_BLOCK.getFunctionName()));
+  }
+
+  @Test
+  public void testChangingLabelFunction_toNoBlock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ LabelFunction.NO_BLOCK,
+        /* errorMessage= */ null);
+  }
+
+  @Test
+  public void testChangingLabelFunction_toNoOp() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ LabelFunction.NO_OP,
+        /* errorMessage= */ null);
+  }
+
+  @Test
+  public void testChangingLabelFunction_toPatchSetLock() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ LabelFunction.PATCH_SET_LOCK,
+        /* errorMessage= */ null);
+  }
+
+  @Test
+  public void testRejectRemovingLabelFunction() throws Exception {
+    testChangingLabelFunction(
+        /* initialLabelFunction= */ LabelFunction.MAX_WITH_BLOCK,
+        /* newLabelFunction= */ null,
+        /* errorMessage= */ String.format(
+            "Cannot delete '%s.%s.%s'."
+                + " Label functions can only be set to {%s, %s, %s}."
+                + " Use submit requirements instead of label functions.",
+            ProjectConfig.LABEL,
+            "Foo",
+            ProjectConfig.KEY_FUNCTION,
+            LabelFunction.NO_BLOCK,
+            LabelFunction.NO_OP,
+            LabelFunction.PATCH_SET_LOCK));
+  }
+
+  private void testChangingLabelFunction(
+      LabelFunction initialLabelFunction,
+      @Nullable LabelFunction newLabelFunction,
+      @Nullable String errorMessage)
+      throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = %s\n",
+                  ProjectConfig.KEY_FUNCTION, initialLabelFunction.getFunctionName()))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            newLabelFunction == null
+                ? "[label \"Foo\"]\n"
+                : String.format(
+                    "[label \"Foo\"]\n  %s = %s\n",
+                    ProjectConfig.KEY_FUNCTION, newLabelFunction.getFunctionName()));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    if (errorMessage == null) {
+      r.assertOkStatus();
+      return;
+    }
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(errorMessage);
+  }
+
+  @Test
+  public void unsetCopyAnyScore() throws Exception {
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyMinScore() throws Exception {
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyMaxScore() throws Exception {
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* previousValue= */ true);
+    testUnsetLabelFlag(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoChange() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* previousValue= */ false);
+  }
+
+  @Test
+  public void unsetCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* previousValue= */ true);
+    testUnsetLabelFlag(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* previousValue= */ false);
+  }
+
+  private void testUnsetLabelFlag(String key, boolean previousValue) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format("[label \"Foo\"]\n  %s = %s", key, previousValue))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  otherKey = value"));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void unsetCopyValues() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  otherKey = value"));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyAnyScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_ANY_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyMinScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyMaxScoreUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ true);
+    testKeepLabelFlagUnchanged(LabelConfigValidator.KEY_COPY_MAX_SCORE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfNoChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CHANGE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfNoCodeChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresOnMergeFirstParentUpdateUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresOnTrivialRebaseUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, /* value= */ false);
+  }
+
+  @Test
+  public void keepCopyAllScoresIfListOfFilesDidNotChangeUnchanged() throws Exception {
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ true);
+    testKeepLabelFlagUnchanged(
+        LabelConfigValidator.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        /* value= */ false);
+  }
+
+  private void testKeepLabelFlagUnchanged(String key, boolean value) throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG, String.format("[label \"Foo\"]\n  %s = %s", key, value))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = %s\n  otherKey = value", key, value));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyValuesUnchanged() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 1\n  %s = 2\n  otherKey = value",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void keepCopyValuesUnchanged_differentOrder() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo
+          .branch(RefNames.REFS_CONFIG)
+          .commit()
+          .add(
+              ProjectConfig.PROJECT_CONFIG,
+              String.format(
+                  "[label \"Foo\"]\n  %s = 1\n  %s = 2",
+                  LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE))
+          .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+          .create();
+    }
+
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = 2\n  %s = 1",
+                LabelConfigValidator.KEY_COPY_VALUE, LabelConfigValidator.KEY_COPY_VALUE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void rejectMultipleLabelFlags() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format(
+                "[label \"Foo\"]\n  %s = true\n  %s = true",
+                LabelConfigValidator.KEY_COPY_MIN_SCORE, LabelConfigValidator.KEY_COPY_MAX_SCORE));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:MIN' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_MIN_SCORE));
+    r.assertMessage(
+        String.format(
+            "ERROR: commit %s: Parameter 'label.Foo.%s' is deprecated and cannot be set,"
+                + " use 'is:MAX' in 'label.Foo.copyCondition' instead.",
+            abbreviateName(r.getCommit()), LabelConfigValidator.KEY_COPY_MAX_SCORE));
+  }
+
+  @Test
+  public void setCopyCondition() throws Exception {
+    fetchRefsMetaConfig();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Test Change",
+            ProjectConfig.PROJECT_CONFIG,
+            String.format("[label \"Foo\"]\n  %s = is:ANY", ProjectConfig.KEY_COPY_CONDITION));
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void validateLabelConfigInInitialCommit() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.delete(RefNames.REFS_CONFIG);
+    }
+
+    PushOneCommit push =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "Test Change",
+                ProjectConfig.PROJECT_CONFIG,
+                INVALID_PRROJECT_CONFIG)
+            .setParents(ImmutableList.of());
+    PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus(
+        String.format(
+            "invalid %s file in revision %s", ProjectConfig.PROJECT_CONFIG, r.getCommit().name()));
+  }
+
+  private void fetchRefsMetaConfig() throws Exception {
+    fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    testRepo.reset(RefNames.REFS_CONFIG);
+  }
+
+  private String abbreviateName(AnyObjectId id) throws Exception {
+    return ObjectIds.abbreviateName(id, testRepo.getRevWalk().getObjectReader());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index c42628c..f997c77 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -16,20 +16,25 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_GLOBAL;
 import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_PARENT;
 import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_GLOBAL;
 import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_PARENT;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -41,6 +46,7 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelId;
@@ -74,9 +80,13 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -92,6 +102,7 @@
   private static final String JIRA = "jira";
   private static final String JIRA_LINK = "http://jira.example.com/?id=$2";
   private static final String JIRA_MATCH = "(jira\\\\s+#?)(\\\\d+)";
+  private static final String R_HEADS_MASTER = R_HEADS + "master";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -777,6 +788,39 @@
   }
 
   @Test
+  public void projectConfigUsesLocallySetCommentlinksWithOptionalFields() throws Exception {
+    ConfigInput input = new ConfigInput();
+    CommentLinkInput bugzillaInput = new CommentLinkInput();
+    String match = "(^|\\\\s)(bug\\\\s+#?)(\\\\d+)($|\\\\s)";
+    String link = "http://bugzilla.example.com/?id=$3";
+    String prefix = "$1";
+    String suffix = "$4";
+    String text = "$2$3";
+    bugzillaInput.match = match;
+    bugzillaInput.link = link;
+    bugzillaInput.prefix = prefix;
+    bugzillaInput.suffix = suffix;
+    bugzillaInput.text = text;
+    addCommentLink(input, BUGZILLA, bugzillaInput);
+    addCommentLink(input, JIRA, JIRA_MATCH, JIRA_LINK);
+
+    ConfigInfo info = setConfig(project, input);
+
+    Map<String, CommentLinkInfo> expected = new HashMap<>();
+    CommentLinkInfo bugzillaInfo = new CommentLinkInfo();
+    bugzillaInfo.name = BUGZILLA;
+    bugzillaInfo.match = match;
+    bugzillaInfo.link = link;
+    bugzillaInfo.prefix = prefix;
+    bugzillaInfo.suffix = suffix;
+    bugzillaInfo.text = text;
+    expected.put(BUGZILLA, bugzillaInfo);
+    expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
+    assertCommentLinks(info, expected);
+    assertCommentLinks(getConfig(), expected);
+  }
+
+  @Test
   @GerritConfig(name = "commentlink.bugzilla.match", value = BUGZILLA_MATCH)
   @GerritConfig(name = "commentlink.bugzilla.link", value = BUGZILLA_LINK)
   public void projectConfigUsesCommentLinksFromGlobalAndLocal() throws Exception {
@@ -917,6 +961,21 @@
   }
 
   @Test
+  public void invalidJgitConfigFile_canNotPushToProjectConfig() throws Exception {
+    TestRepository<InMemoryRepository> repo = cloneProject(allProjects);
+    GitUtil.fetch(repo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    repo.reset(RefNames.REFS_CONFIG);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), repo, "Subject", "project.config", "jgit config")
+            .to(RefNames.REFS_CONFIG);
+    r.assertErrorStatus("invalid project configuration");
+    r.assertMessage(
+        "Invalid config file project.config in project All-Projects in branch refs/meta/config");
+    r.assertMessage("Invalid line in config file");
+  }
+
+  @Test
   public void getProjectThatHasLabelDefinitionWithDuplicateValues() throws Exception {
     // Update the definition of the Code-Review label so that it has the value "+1 LGTM" twice.
     // This update bypasses all validation checks so that the duplicate label value doesn't get
@@ -989,7 +1048,7 @@
       ProjectConfig config = projectConfigFactory.read(md);
       config.renameGroup(AccountGroup.uuid(group.id), newName);
       config.commit(md);
-      projectCache.evict(config.getProject());
+      projectCache.evictAndReindex(config.getProject());
     }
 
     Optional<String> afterRename =
@@ -1000,6 +1059,156 @@
     assertThat(afterRename).hasValue(newName);
   }
 
+  @Test
+  public void commitsIncludedInRefsEmptyCommitAndEmptyRefs() throws Exception {
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> getCommitsIncludedInRefs(Collections.emptyList(), Arrays.asList(R_HEADS_MASTER)));
+    assertThat(thrown).hasMessageThat().contains("commit is required");
+    PushOneCommit.Result result = createChange();
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> getCommitsIncludedInRefs(result.getCommit().getName(), Collections.emptyList()));
+    assertThat(thrown).hasMessageThat().contains("ref is required");
+  }
+
+  @Test
+  public void commitsIncludedInRefsNonExistentCommits() throws Exception {
+    assertThat(
+            getCommitsIncludedInRefs(
+                Arrays.asList("foo", "4fa12ab8f257034ec793dacb2ae2752ae2e9f5f3"),
+                Arrays.asList(R_HEADS_MASTER)))
+        .isEmpty();
+  }
+
+  @Test
+  public void commitsIncludedInRefsNonExistentRefs() throws Exception {
+    PushOneCommit.Result change = createChange();
+    assertThat(
+            getCommitsIncludedInRefs(
+                Arrays.asList(change.getCommit().getName()), Arrays.asList(R_HEADS + "foo")))
+        .isEmpty();
+  }
+
+  @Test
+  public void commitsIncludedInRefsOpenChange() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    testRepo.reset(change1.getCommit().getParent(0));
+    PushOneCommit.Result change2 = createChange();
+
+    Map<String, Set<String>> refsByCommit =
+        getCommitsIncludedInRefs(
+            Arrays.asList(change1.getCommit().getName(), change2.getCommit().getName()),
+            Arrays.asList(
+                R_HEADS_MASTER, change1.getPatchSet().refName(), change2.getPatchSet().refName()));
+    assertThat(refsByCommit.get(change2.getCommit().getName()))
+        .containsExactly(change2.getPatchSet().refName());
+    assertThat(refsByCommit.get(change1.getCommit().getName()))
+        .containsExactly(change1.getPatchSet().refName());
+  }
+
+  @Test
+  public void commitsIncludedInRefsMergedChange() throws Exception {
+    String branchWithoutChanges = R_HEADS + "branch-without-changes";
+    String tagWithChange1 = R_TAGS + "tag-with-change1";
+    String branchWithChange1 = R_HEADS + "branch-with-change1";
+
+    createBranch(BranchNameKey.create(project, "branch-without-changes"));
+    PushOneCommit.Result change1 = createAndSubmitChange("refs/for/master");
+
+    assertThat(
+            getCommitsIncludedInRefs(change1.getCommit().getName(), Arrays.asList(R_HEADS_MASTER)))
+        .containsExactly(R_HEADS_MASTER);
+    assertThat(
+            getCommitsIncludedInRefs(
+                change1.getCommit().getName(), Arrays.asList(R_HEADS_MASTER, branchWithoutChanges)))
+        .containsExactly(R_HEADS_MASTER);
+
+    pushHead(testRepo, tagWithChange1, false, false);
+    createBranch(BranchNameKey.create(project, "branch-with-change1"));
+
+    PushOneCommit.Result change2 = createAndSubmitChange("refs/for/master");
+    Map<String, Set<String>> refsByCommit =
+        getCommitsIncludedInRefs(
+            Arrays.asList(change1.getCommit().getName(), change2.getCommit().getName()),
+            Arrays.asList(R_HEADS_MASTER, tagWithChange1, branchWithoutChanges, branchWithChange1));
+    assertThat(refsByCommit.get(change1.getCommit().getName()))
+        .containsExactly(R_HEADS_MASTER, tagWithChange1, branchWithChange1);
+    assertThat(refsByCommit.get(change2.getCommit().getName())).containsExactly(R_HEADS_MASTER);
+  }
+
+  @Test
+  public void commitsIncludedInRefsMergedChangeNonTipCommit() throws Exception {
+    String branchWithChange1 = R_HEADS + "branch-with-change1";
+    String tagWithChange1 = R_TAGS + "tag-with-change1";
+    String branchWithChange1Change2 = R_HEADS + "branch-with-change1-change2";
+    String tagWithChange1Change2 = R_TAGS + "tag-with-change1-change2";
+
+    createBranch(BranchNameKey.create(project, "branch-with-change1"));
+    PushOneCommit.Result change1 = createAndSubmitChange("refs/for/branch-with-change1");
+    pushHead(testRepo, tagWithChange1, false, false);
+    createBranch(BranchNameKey.create(project, "branch-with-change1-change2"));
+    PushOneCommit.Result change2 = createAndSubmitChange("refs/for/branch-with-change1-change2");
+    pushHead(testRepo, tagWithChange1Change2, false, false);
+
+    Map<String, Set<String>> refsByCommit =
+        getCommitsIncludedInRefs(
+            Arrays.asList(change1.getCommit().getName(), change2.getCommit().getName()),
+            Arrays.asList(
+                branchWithChange1,
+                branchWithChange1Change2,
+                tagWithChange1,
+                tagWithChange1Change2));
+    assertThat(refsByCommit.get(change1.getCommit().getName()))
+        .containsExactly(
+            branchWithChange1, branchWithChange1Change2, tagWithChange1, tagWithChange1Change2);
+    assertThat(refsByCommit.get(change2.getCommit().getName()))
+        .containsExactly(branchWithChange1Change2, tagWithChange1Change2);
+  }
+
+  @Test
+  public void commitsIncludedInRefsMergedChangeFilterNonVisibleBranchRef() throws Exception {
+    String nonVisibleBranch = R_HEADS + "non-visible-branch";
+    PushOneCommit.Result change = createAndSubmitChange("refs/for/master");
+    createBranch(BranchNameKey.create(project, "non-visible-branch"));
+    blockReadPermission(nonVisibleBranch);
+
+    assertThat(
+            getCommitsIncludedInRefs(
+                change.getCommit().getName(), Arrays.asList(R_HEADS_MASTER, nonVisibleBranch)))
+        .containsExactly(R_HEADS_MASTER);
+  }
+
+  @Test
+  public void commitsIncludedInRefsMergedChangeFilterNonVisibleTagRef() throws Exception {
+    String nonVisibleTag = R_TAGS + "non-visible-tag";
+    PushOneCommit.Result change = createAndSubmitChange("refs/for/master");
+    pushHead(testRepo, nonVisibleTag, false, false);
+    // Tag permissions are controlled by read permissions on branches. Blocking read permission
+    // on master so that tag-with-change becomes non-visible
+    blockReadPermission(R_HEADS_MASTER);
+
+    assertThat(
+            getCommitsIncludedInRefs(
+                change.getCommit().getName(), Arrays.asList(R_HEADS_MASTER, nonVisibleTag)))
+        .isNull();
+  }
+
+  @Test
+  public void commitsIncludedInRefsFilterNonVisibleChangeRef() throws Exception {
+    PushOneCommit.Result change = createChange("refs/for/master");
+    // change ref permissions are controlled by read permissions on destination branch.
+    // Blocking read permission on master so that refs/changes/01/1/1 becomes non-visible
+    blockReadPermission(R_HEADS_MASTER);
+
+    assertThat(
+            getCommitsIncludedInRefs(
+                change.getCommit().getName(), Arrays.asList(change.getPatchSet().refName())))
+        .isNull();
+  }
+
   private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
     CommentLinkInfo info = new CommentLinkInfo();
     info.name = name;
@@ -1039,6 +1248,30 @@
     return getConfig(project);
   }
 
+  private Set<String> getCommitsIncludedInRefs(String commit, List<String> refs) throws Exception {
+    return getCommitsIncludedInRefs(Lists.newArrayList(commit), refs).get(commit);
+  }
+
+  private Map<String, Set<String>> getCommitsIncludedInRefs(List<String> commits, List<String> refs)
+      throws Exception {
+    return gApi.projects().name(project.get()).commitsIn(commits, refs);
+  }
+
+  private PushOneCommit.Result createAndSubmitChange(String branch) throws Exception {
+    PushOneCommit.Result r = createChange(branch);
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    return r;
+  }
+
+  private void blockReadPermission(String ref) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(ref).group(REGISTERED_USERS))
+        .update();
+  }
+
   private ConfigInput createTestConfigInput() {
     ConfigInput input = new ConfigInput();
     input.description = "some description";
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index e45d95c..93f91dd 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -21,6 +21,7 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
@@ -50,9 +51,10 @@
   @Inject private IndexConfig indexConfig;
   @Inject private StalenessChecker stalenessChecker;
   @Inject private ProjectOperations projectOperations;
+  @Inject private IndexOperations.Project projectIndexOperations;
 
   private static final ImmutableSet<String> FIELDS =
-      ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+      ImmutableSet.of(ProjectField.NAME_SPEC.getName(), ProjectField.REF_STATE.getName());
 
   @Test
   public void indexProject_indexesRefStateOfProjectAndParents() throws Exception {
@@ -124,7 +126,7 @@
         assertThrows(
             StorageException.class,
             () -> {
-              try (AutoCloseable ignored = disableProjectIndex()) {
+              try (AutoCloseable ignored = projectIndexOperations.disableReadsAndWrites()) {
                 try (ProjectConfigUpdate u = updateProject(project)) {
                   update.accept(u.getConfig());
                   u.save();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
new file mode 100644
index 0000000..97a2d2b
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -0,0 +1,651 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitRequirementsAPIIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void cannotGetANonExistingSR() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("code-review").get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Submit requirement 'code-review' does not exist");
+  }
+
+  @Test
+  public void getExistingSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").get();
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.applicabilityExpression).isEqualTo("topic:foo");
+    assertThat(info.submittabilityExpression).isEqualTo("label:code-review=+2");
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(false);
+  }
+
+  @Test
+  public void updateSubmitRequirement() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.submittabilityExpression = "label:code-review=+1";
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.submittabilityExpression).isEqualTo("label:code-review=+1");
+  }
+
+  @Test
+  public void updateSRWithEmptyApplicabilityExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.applicabilityExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.applicabilityExpression = null;
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.applicabilityExpression).isNull();
+  }
+
+  @Test
+  public void updateSRWithEmptyOverrideExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.overrideExpression = "topic:foo";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.overrideExpression = null;
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.overrideExpression).isNull();
+  }
+
+  @Test
+  public void allowOverrideInChildProjectsDefaultsToFalse_updateSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "label:code-review=+2";
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    input.overrideExpression = "topic:foo";
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").update(input);
+    assertThat(info.allowOverrideInChildProjects).isFalse();
+  }
+
+  @Test
+  public void cannotUpdateSRAsAnonymousUser() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = "label:code-review=+1";
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .update(new SubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotUpdateSRtIfSRDoesNotExist() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Submit requirement 'code-review' does not exist");
+  }
+
+  @Test
+  public void cannotUpdateSRWithEmptySubmittableIf() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = null;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+
+    assertThat(thrown).hasMessageThat().isEqualTo("submittability_expression is required");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidSubmittableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.submittabilityExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.submittableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidOverrideIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.overrideExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.overrideIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotUpdateSRWithInvalidApplicableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.submittabilityExpression = "project:foo AND branch:refs/heads/main";
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+    input.applicabilityExpression = "invalid_field:invalid_value";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.applicableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void createSubmitRequirement() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+    input.allowOverrideInChildProjects = true;
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.description).isEqualTo(input.description);
+    assertThat(info.applicabilityExpression).isEqualTo(input.applicabilityExpression);
+    assertThat(info.applicabilityExpression).isEqualTo(input.applicabilityExpression);
+    assertThat(info.submittabilityExpression).isEqualTo(input.submittabilityExpression);
+    assertThat(info.overrideExpression).isEqualTo(input.overrideExpression);
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(true);
+  }
+
+  @Test
+  public void createSRWithEmptyApplicabilityExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.applicabilityExpression).isNull();
+  }
+
+  @Test
+  public void createSRWithEmptyOverrideExpression_isAllowed() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.name).isEqualTo("code-review");
+    assertThat(info.overrideExpression).isNull();
+  }
+
+  @Test
+  public void allowOverrideInChildProjectsDefaultsToFalse_createSR() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    SubmitRequirementInfo info =
+        gApi.projects().name(project.get()).submitRequirement("code-review").create(input).get();
+
+    assertThat(info.allowOverrideInChildProjects).isEqualTo(false);
+  }
+
+  @Test
+  public void cannotCreateSRAsAnonymousUser() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.submittabilityExpression = "label:code-review=+2";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(new SubmitRequirementInput()));
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotCreateSRtIfNameInInputDoesNotMatchResource() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("other-requirement")
+                    .create(input)
+                    .get());
+    assertThat(thrown).hasMessageThat().isEqualTo("name in input must match name in URL");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidName() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "wrong$%";
+    input.submittabilityExpression = "label:code-review=+2";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("wrong$%")
+                    .create(input)
+                    .get());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Illegal submit requirement name \"wrong$%\". "
+                + "Name can only consist of alphanumeric characters and '-'."
+                + " Name cannot start with '-' or number.");
+  }
+
+  @Test
+  public void cannotCreateSRWithEmptySubmittableIf() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "project:foo AND branch:refs/heads/main";
+    input.overrideExpression = "label:build-cop-override=+1";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+
+    assertThat(thrown).hasMessageThat().isEqualTo("submittability_expression is required");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidSubmittableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "invalid_field:invalid_value";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.submittableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidOverrideIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.submittabilityExpression = "label:Code-Review=+2";
+    input.overrideExpression = "invalid_field:invalid_value";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.overrideIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotCreateSRWithInvalidApplicableIfExpression() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "code-review";
+    input.description = "At least one +2 vote to the code-review label";
+    input.applicabilityExpression = "invalid_field:invalid_value";
+    input.submittabilityExpression = "label:Code-Review=+2";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.projects()
+                    .name(project.get())
+                    .submitRequirement("code-review")
+                    .create(input)
+                    .get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "Invalid submit requirement input: "
+                + "[Invalid project configuration,   "
+                + "project.config: Expression 'invalid_field:invalid_value' of "
+                + "submit requirement 'code-review' "
+                + "(parameter submit-requirement.code-review.applicableIf) is invalid: "
+                + "Unsupported operator invalid_field:invalid_value]");
+  }
+
+  @Test
+  public void cannotListSRsAsAnonymous() throws Exception {
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements().get());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotListSRs_withMissingReadPermissionsToRefsConfig() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirements().get());
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void cannotListSRs_withMissingReadPermissionsInParent_withInheritance() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                gApi.projects().name(project.get()).submitRequirements().withInherited(true).get());
+    assertThat(thrown).hasMessageThat().contains("read refs/meta/config not permitted");
+  }
+
+  @Test
+  public void canListSRs_withReadPermissionsInAllParentProjects_withInheritance() throws Exception {
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.projects().name(project.get()).submitRequirements().get();
+  }
+
+  @Test
+  public void canListSRs_withMissingReadPermissionsInParent_withoutInheritance() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.projects().name(project.get()).submitRequirements().withInherited(false).get();
+  }
+
+  @Test
+  public void listSRs() throws Exception {
+    createSubmitRequirement("sr-1");
+    createSubmitRequirement("sr-2");
+
+    List<SubmitRequirementInfo> infos =
+        gApi.projects().name(project.get()).submitRequirements().get();
+
+    assertThat(names(infos)).containsExactly("sr-1", "sr-2");
+  }
+
+  @Test
+  public void listSRsWithInheritance() throws Exception {
+    createSubmitRequirement(allProjects.get(), "base-sr");
+    createSubmitRequirement(project.get(), "sr-1");
+    createSubmitRequirement(project.get(), "sr-2");
+
+    List<SubmitRequirementInfo> infos =
+        gApi.projects().name(project.get()).submitRequirements().withInherited(false).get();
+
+    assertThat(names(infos)).containsExactly("sr-1", "sr-2");
+
+    infos = gApi.projects().name(project.get()).submitRequirements().withInherited(true).get();
+
+    assertThat(names(infos)).containsExactly("base-sr", "sr-1", "sr-2");
+  }
+
+  @Test
+  public void cannotDeleteSRAsAnonymousUser() throws Exception {
+    createSubmitRequirement("code-review");
+
+    requestScopeOperations.setApiUserAnonymous();
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("code-review").delete());
+    assertThat(thrown).hasMessageThat().contains("Authentication required");
+  }
+
+  @Test
+  public void cannotDeleteSRWithMissingWritePermissionsToRefsConfig() throws Exception {
+    createSubmitRequirement("sr-1");
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .add(block("write").ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("sr-1").delete());
+    assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+  }
+
+  @Test
+  public void cannotDeleteNonExistingSR() throws Exception {
+    ResourceNotFoundException thrown =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("non-existing").delete());
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("Submit requirement 'non-existing' does not exist");
+  }
+
+  @Test
+  public void deleteSubmitRequirement() throws Exception {
+    createSubmitRequirement("code-review");
+    createSubmitRequirement("verified");
+
+    List<SubmitRequirementInfo> infos =
+        gApi.projects().name(project.get()).submitRequirements().get();
+    assertThat(names(infos)).containsExactly("code-review", "verified");
+
+    gApi.projects().name(project.get()).submitRequirement("code-review").delete();
+    infos = gApi.projects().name(project.get()).submitRequirements().get();
+    assertThat(names(infos)).containsExactly("verified");
+  }
+
+  private SubmitRequirementInfo createSubmitRequirement(String srName) throws RestApiException {
+    return createSubmitRequirement(project.get(), srName);
+  }
+
+  private SubmitRequirementInfo createSubmitRequirement(String project, String srName)
+      throws RestApiException {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = srName;
+    input.submittabilityExpression = "label:dummy=+2";
+
+    return gApi.projects().name(project).submitRequirement(srName).create(input).get();
+  }
+
+  private List<String> names(List<SubmitRequirementInfo> infos) {
+    return infos.stream().map(sr -> sr.name).collect(Collectors.toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
new file mode 100644
index 0000000..7c0b713
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
@@ -0,0 +1,581 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
+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.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.testing.BinaryResultSubject;
+import com.google.gson.stream.JsonReader;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ApplyProvidedFixIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_NAME3 = "file_without_newline_at_end.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+  private static final String FILE_CONTENT3 = "1st line\n2nd line";
+
+  private String changeId;
+  private String commitId;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(
+                FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2, FILE_NAME3, FILE_CONTENT3));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
+    changeId = changeResult.getChangeId();
+    commitId = changeResult.getCommit().getName();
+  }
+
+  @Test
+  public void applyProvidedFixWithinALineCanBeApplied() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixRestAPItestForASimpleFix() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    RestResponse resp =
+        adminRestSession.post(
+            "/changes/" + changeId + "/revisions/" + commitId + "/fix:apply",
+            applyProvidedFixInput);
+    readContentFromJson(resp, 200, ReviewResult.class);
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nTModified contentrd line\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixSpanningMultipleLinesCanBeApplied() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content\n5", 3, 2, 5, 3);
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nSecond line\nThModified content\n5th line\nSixth line\nSeventh line\n"
+                + "Eighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nSome other modified content\nFourth line\nFifth line\n"
+                + "Sixth line\nSeventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoApplyProvidedFixesOnSameFileCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(8, 0, 9, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nSome other modified content\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void twoConflictingApplyProvidedFixesOnSameFileCannotBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME;
+    fixReplacementInfo2.range = createRange(3, 0, 4, 0);
+    fixReplacementInfo2.replacement = "Some other modified content\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown).hasMessageThat().contains("Cannot calculate fix replacement");
+  }
+
+  @Test
+  public void applyProvidedFixInvolvingTwoFilesCanBeApplied() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "Different file modification\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+    Optional<BinaryResult> file2 = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file2)
+        .value()
+        .asString()
+        .isEqualTo("Different file modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void applyProvidedFixReferringToNonExistentFileCannotBeApplied() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput("a_non_existent_file.txt", "Modified content\n", 1, 0, 2, 0);
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+  }
+
+  @Test
+  public void applyProvidedFixRestAPIcallWithoutAddPatchSetPermissionCannotBeApplied()
+      throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    String allRefs = RefNames.REFS + "*";
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.ADD_PATCH_SET).ref(allRefs).group(ANONYMOUS_USERS))
+        .add(block(Permission.ADD_PATCH_SET).ref(allRefs).group(REGISTERED_USERS))
+        .update();
+
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + changeId + "/revisions/" + commitId + "/fix:apply",
+            applyProvidedFixInput);
+    resp.assertStatus(403);
+  }
+
+  @Test
+  public void applyProvidedFixOnCurrentPatchSetWithExistingChangeEditCanBeApplied()
+      throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 0);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo1);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo(
+            "First line\nFirst modification\nThird line\nFourth line\nFifth line\nSixth line\n"
+                + "Seventh line\nEighth line\nNinth line\nTenth line\n");
+  }
+
+  @Test
+  public void applyProvidedFixOnPreviousPatchSetCannotBeApplied() throws Exception {
+    // Remember patch set and add another one.
+    String previousRevision = gApi.changes().id(changeId).get().currentRevision;
+    amendChange(changeId);
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () ->
+                gApi.changes()
+                    .id(changeId)
+                    .revision(previousRevision)
+                    .applyFix(applyProvidedFixInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("A change edit may only be created for the current patch set");
+  }
+
+  @Test
+  public void applyProvidedFixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+      throws Exception {
+    // Create an empty change edit.
+    gApi.changes().id(changeId).edit().create();
+    // Add another patch set.
+    amendChange(changeId);
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("on which the existing change edit is based may be modified");
+  }
+
+  @Test
+  public void applyProvidedFixOnCommitMessageCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "Modified line\n", 7, 0, 8, 0);
+
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line\nLine 2 of commit message\n" + footer);
+  }
+
+  @Test
+  public void applyProvidedFixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "Change-Id: " + changeId;
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\n" + "\n" + footer + "\n";
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "Modified line\n", 1, 0, 2, 0);
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(exception).hasMessageThat().contains("header");
+  }
+
+  @Test
+  public void applyProvidedFixContainingSeveralModificationsOfCommitMessageCanBeApplied()
+      throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Modified line 3\n";
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage)
+        .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n" + footer);
+  }
+
+  @Test
+  public void applyProvidedFixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+    fixReplacementInfo2.replacement = "File modification\n";
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage).isEqualTo("Modified line 1\nLine 2 of commit message\n" + footer);
+    Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+    BinaryResultSubject.assertThat(file)
+        .value()
+        .asString()
+        .isEqualTo("File modification\n2nd line\n3rd line\n");
+  }
+
+  @Test
+  public void twoApplyProvidedFixesNonOverlappingOnCommitMessageCanBeAppliedSubsequently()
+      throws Exception {
+    // Set a dedicated commit message.
+    String footer = "\nChange-Id: " + changeId + "\n";
+    String originalCommitMessage =
+        "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n" + footer;
+    gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+    gApi.changes().id(changeId).edit().publish();
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = Patch.COMMIT_MSG;
+    fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+    fixReplacementInfo1.replacement = "Modified line 1\n";
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = Patch.COMMIT_MSG;
+    fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+    fixReplacementInfo2.replacement = "Modified line 3\n";
+    List<FixReplacementInfo> fixReplacementInfoList1 = Arrays.asList(fixReplacementInfo1);
+    ApplyProvidedFixInput applyProvidedFixInput1 = new ApplyProvidedFixInput();
+    applyProvidedFixInput1.fixReplacementInfos = fixReplacementInfoList1;
+    List<FixReplacementInfo> fixReplacementInfoList2 = Arrays.asList(fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput2 = new ApplyProvidedFixInput();
+    applyProvidedFixInput2.fixReplacementInfos = fixReplacementInfoList2;
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput1);
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput2);
+
+    String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+    assertThat(commitMessage)
+        .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n" + footer);
+  }
+
+  @Test
+  public void applyingStoredFixTwiceIsIdempotent() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    String expectedEditCommit =
+        gApi.changes().id(changeId).edit().get().map(edit -> edit.commit.commit).orElse("");
+
+    // Apply the fix again.
+    gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    Optional<EditInfo> editInfo = gApi.changes().id(changeId).edit().get();
+    assertThat(editInfo).value().commit().commit().isEqualTo(expectedEditCommit);
+  }
+
+  @Test
+  public void applyProvidedFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    Optional<EditInfo> expectedEditInfo = gApi.changes().id(changeId).edit().get();
+    String expectedEditCommit = expectedEditInfo.map(edit -> edit.commit.commit).orElse("");
+    assertThat(editInfo).commit().commit().isEqualTo(expectedEditCommit);
+    String expectedBaseRevision = expectedEditInfo.map(edit -> edit.baseRevision).orElse("");
+    assertThat(editInfo).baseRevision().isEqualTo(expectedBaseRevision);
+  }
+
+  @Test
+  public void createdApplyProvidedFixChangeEditIsBasedOnCurrentPatchSet() throws Exception {
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    EditInfo editInfo = gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput);
+    assertThat(editInfo).baseRevision().isEqualTo(currentRevision);
+  }
+
+  @Test
+  public void applyProvidedFixRestCallWithDifferentUserTheUserBecomesUploader() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    RevisionInfo rev = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(rev.uploader.username).isEqualTo(admin.username());
+
+    RestResponse resp =
+        userRestSession.post(
+            "/changes/" + changeId + "/revisions/" + commitId + "/fix:apply",
+            applyProvidedFixInput);
+    resp.assertStatus(200);
+
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    RestResponse resp2 =
+        userRestSession.post("/changes/" + changeId + "/edit:publish", publishInput);
+    resp2.assertStatus(204);
+
+    changeInfo = gApi.changes().id(changeId).get();
+    RevisionInfo rev2 = changeInfo.revisions.get(changeInfo.currentRevision);
+    assertThat(rev2.uploader.username).isEqualTo(user.username());
+  }
+
+  @Test
+  public void applyProvidedFixInputNullReturnsBadRequestException() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput = null;
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown).hasMessageThat().contains("applyProvidedFixInput is required");
+  }
+
+  @Test
+  public void applyProvidedFixInputFixReplacementInfosNullReturnsBadRequestException()
+      throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId).current().applyFix(applyProvidedFixInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("applyProvidedFixInput.fixReplacementInfos is required");
+  }
+
+  private ApplyProvidedFixInput createApplyProvidedFixInput(
+      String file_name,
+      String replacement,
+      int startLine,
+      int startCharacter,
+      int endLine,
+      int endCharacter) {
+    FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+    fixReplacementInfo.path = file_name;
+    fixReplacementInfo.replacement = replacement;
+    fixReplacementInfo.range = createRange(startLine, startCharacter, endLine, endCharacter);
+
+    List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    return applyProvidedFixInput;
+  }
+
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+
+  private static <T> T readContentFromJson(RestResponse r, int expectedStatus, Class<T> clazz)
+      throws Exception {
+    r.assertStatus(expectedStatus);
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      jsonReader.setLenient(true);
+      return newGson().fromJson(jsonReader, clazz);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
index 7197425..ba45fb2 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -22,23 +22,31 @@
 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 static java.util.stream.Collectors.joining;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.change.TestCommentCreation;
 import com.google.gerrit.acceptance.testsuite.change.TestPatchset;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
 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.BinaryResult;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -47,11 +55,12 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Ignore;
 import org.junit.Test;
 
 public class PortedCommentsIT extends AbstractDaemonTest {
-
   @Inject private ChangeOperations changeOps;
   @Inject private AccountOperations accountOps;
   @Inject private RequestScopeOperations requestScopeOps;
@@ -143,6 +152,39 @@
   }
 
   @Test
+  public void commentsArePortedWhenAllEditsAreDueToRebase() throws Exception {
+    String fileName = "f.txt";
+    String baseContent =
+        IntStream.rangeClosed(1, 50)
+            .mapToObj(number -> String.format("Line %d\n", number))
+            .collect(joining());
+    ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    ObjectId baseCommit = addCommit(headCommit, fileName, baseContent);
+
+    // Create a change on top of baseCommit, modify line 1, then add comment on line 10.
+    PushOneCommit.Result r = createEmptyChange();
+    Change.Id changeId = r.getChange().getId();
+    addModifiedPatchSet(
+        changeId.toString(), fileName, baseContent.replace("Line 1\n", "Line one\n"));
+    PatchSet.Id ps2Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+    newComment(ps2Id).message("Line comment").onLine(10).ofFile(fileName).create();
+
+    // Add a commit on top of baseCommit. Delete line 4. Rebase the change on top of this commit.
+    ObjectId newBase = addCommit(baseCommit, fileName, baseContent.replace("Line 4\n", ""));
+    rebaseChangeOn(changeId.toString(), newBase);
+    PatchSet.Id ps3Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+
+    List<CommentInfo> portedComments = flatten(getPortedComments(ps3Id));
+    assertThat(portedComments).hasSize(1);
+    int portedLine = portedComments.get(0).line;
+    BinaryResult fileContent = gApi.changes().id(changeId.get()).current().file(fileName).content();
+    List<String> lines = Splitter.on("\n").splitToList(fileContent.asString());
+    // Comment has shifted to L9 instead of L10 because of the deletion of line 4.
+    assertThat(portedLine).isEqualTo(9);
+    assertThat(lines.get(portedLine - 1)).isEqualTo("Line 10");
+  }
+
+  @Test
   public void resolvedAndUnresolvedDraftCommentsArePorted() throws Exception {
     Account.Id accountId = accountOps.newAccount().create();
     // Set up change and patchsets.
@@ -1803,7 +1845,7 @@
   }
 
   @Test
-  public void deletedCommentContentIsNotCachedInPortedComments() throws Exception {
+  public void deletedCommentContentIsNotPorted() throws Exception {
     // Set up change and patchsets.
     Change.Id changeId = changeOps.newChange().create();
     PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
@@ -1817,9 +1859,9 @@
         .revision(patchsetId1.get())
         .comment(commentUuid)
         .delete(new DeleteCommentInput());
-    CommentInfo portedComment = getPortedComment(patchsetId2, commentUuid);
+    List<CommentInfo> portedComments = flatten(getPortedComments(patchsetId2));
 
-    assertThat(portedComment).message().doesNotContain("Confidential content");
+    assertThatList(portedComments).isEmpty();
   }
 
   @Test
@@ -1897,9 +1939,7 @@
    */
   private static ImmutableList<CommentInfo> flatten(
       Map<String, List<CommentInfo>> commentsPerFile) {
-    return commentsPerFile.values().stream()
-        .flatMap(Collection::stream)
-        .collect((toImmutableList()));
+    return commentsPerFile.values().stream().flatMap(Collection::stream).collect(toImmutableList());
   }
 
   // Unfortunately, we don't get an absolutely helpful error message when using this correspondence
@@ -1909,4 +1949,35 @@
   private static Correspondence<CommentInfo, String> hasUuid() {
     return NullAwareCorrespondence.transforming(comment -> comment.id, "hasUuid");
   }
+
+  private Result createEmptyChange() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Test change", ImmutableMap.of());
+    return push.to("refs/for/master");
+  }
+
+  private void rebaseChangeOn(String changeId, ObjectId newParent) throws Exception {
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.base = newParent.getName();
+    gApi.changes().id(changeId).current().rebase(rebaseInput);
+  }
+
+  private void addModifiedPatchSet(String changeId, String filePath, String content)
+      throws Exception {
+    gApi.changes().id(changeId).edit().modifyFile(filePath, RawInputUtil.create(content));
+    gApi.changes().id(changeId).edit().publish();
+  }
+
+  private ObjectId addCommit(ObjectId parentCommit, String fileName, String fileContent)
+      throws Exception {
+    testRepo.reset(parentCommit);
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Adjust files of repo",
+            ImmutableMap.of(fileName, fileContent));
+    PushOneCommit.Result result = push.to("refs/for/master");
+    return result.getCommit();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
new file mode 100644
index 0000000..c257e703
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PreviewProvidedFixIT.java
@@ -0,0 +1,278 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.common.ApplyProvidedFixInput;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.DiffInfo.IntraLineStatus;
+import com.google.gerrit.extensions.common.FixReplacementInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PreviewProvidedFixIT extends AbstractDaemonTest {
+  private static final String PLAIN_TEXT_CONTENT_TYPE = "text/plain";
+  private static final String GERRIT_COMMIT_MESSAGE_TYPE = "text/x-gerrit-commit-message";
+
+  private static final String FILE_NAME = "file_to_fix.txt";
+  private static final String FILE_NAME2 = "another_file_to_fix.txt";
+  private static final String FILE_NAME3 = "file_without_newline_at_end.txt";
+  private static final String FILE_CONTENT =
+      "First line\nSecond line\nThird line\nFourth line\nFifth line\nSixth line"
+          + "\nSeventh line\nEighth line\nNinth line\nTenth line\n";
+  private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
+  private static final String FILE_CONTENT3 = "1st line\n2nd line";
+
+  private String changeId;
+  private String commitId;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Provide files which can be used for fixes",
+            ImmutableMap.of(
+                FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2, FILE_NAME3, FILE_CONTENT3));
+    PushOneCommit.Result changeResult = push.to("refs/for/master");
+    changeId = changeResult.getChangeId();
+    commitId = changeResult.getCommit().getName();
+  }
+
+  @Test
+  public void previewFixDetailedCheck() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.replacement = "some replacement code";
+    fixReplacementInfo1.range = createRange(3, 9, 8, 4);
+
+    FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+    fixReplacementInfo2.path = FILE_NAME2;
+    fixReplacementInfo2.replacement = "New line\n";
+    fixReplacementInfo2.range = createRange(2, 0, 2, 0);
+
+    List<FixReplacementInfo> fixReplacementInfoList =
+        Arrays.asList(fixReplacementInfo1, fixReplacementInfo2);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    Map<String, DiffInfo> fixPreview =
+        gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+
+    DiffInfo diff = fixPreview.get(FILE_NAME);
+    assertThat(diff).intralineStatus().isEqualTo(IntraLineStatus.OK);
+    assertThat(diff).webLinks().isNull();
+    assertThat(diff).binary().isNull();
+    assertThat(diff).diffHeader().isNull();
+    assertThat(diff).changeType().isEqualTo(ChangeType.MODIFIED);
+    assertThat(diff).metaA().totalLineCount().isEqualTo(11);
+    assertThat(diff).metaA().name().isEqualTo(FILE_NAME);
+    assertThat(diff).metaA().commitId().isEqualTo(commitId);
+    assertThat(diff).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff).metaA().webLinks().isNull();
+    assertThat(diff).metaB().totalLineCount().isEqualTo(6);
+    assertThat(diff).metaB().name().isEqualTo(FILE_NAME);
+    assertThat(diff).metaB().commitId().isNull();
+    assertThat(diff).metaB().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff).metaB().webLinks().isNull();
+
+    assertThat(diff).content().hasSize(3);
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .containsExactly("First line", "Second line");
+    assertThat(diff).content().element(0).linesOfA().isNull();
+    assertThat(diff).content().element(0).linesOfB().isNull();
+
+    assertThat(diff).content().element(1).commonLines().isNull();
+    assertThat(diff)
+        .content()
+        .element(1)
+        .linesOfA()
+        .containsExactly(
+            "Third line", "Fourth line", "Fifth line", "Sixth line", "Seventh line", "Eighth line");
+    assertThat(diff)
+        .content()
+        .element(1)
+        .linesOfB()
+        .containsExactly("Third linsome replacement codeth line");
+
+    assertThat(diff)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Ninth line", "Tenth line", "");
+    assertThat(diff).content().element(2).linesOfA().isNull();
+    assertThat(diff).content().element(2).linesOfB().isNull();
+
+    DiffInfo diff2 = fixPreview.get(FILE_NAME2);
+    assertThat(diff2).intralineStatus().isEqualTo(IntraLineStatus.OK);
+    assertThat(diff2).webLinks().isNull();
+    assertThat(diff2).binary().isNull();
+    assertThat(diff2).diffHeader().isNull();
+    assertThat(diff2).changeType().isEqualTo(ChangeType.MODIFIED);
+    assertThat(diff2).metaA().totalLineCount().isEqualTo(4);
+    assertThat(diff2).metaA().name().isEqualTo(FILE_NAME2);
+    assertThat(diff2).metaA().commitId().isEqualTo(commitId);
+    assertThat(diff2).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff2).metaA().webLinks().isNull();
+    assertThat(diff2).metaB().totalLineCount().isEqualTo(5);
+    assertThat(diff2).metaB().name().isEqualTo(FILE_NAME2);
+    assertThat(diff2).metaB().commitId().isNull();
+    assertThat(diff2).metaA().contentType().isEqualTo(PLAIN_TEXT_CONTENT_TYPE);
+    assertThat(diff2).metaB().webLinks().isNull();
+
+    assertThat(diff2).content().hasSize(3);
+    assertThat(diff2).content().element(0).commonLines().containsExactly("1st line");
+    assertThat(diff2).content().element(0).linesOfA().isNull();
+    assertThat(diff2).content().element(0).linesOfB().isNull();
+
+    assertThat(diff2).content().element(1).commonLines().isNull();
+    assertThat(diff2).content().element(1).linesOfA().isNull();
+    assertThat(diff2).content().element(1).linesOfB().containsExactly("New line");
+
+    assertThat(diff2)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("2nd line", "3rd line", "");
+    assertThat(diff2).content().element(2).linesOfA().isNull();
+    assertThat(diff2).content().element(2).linesOfB().isNull();
+  }
+
+  @Test
+  public void previewFixForCommitMsg() throws Exception {
+    String footer = "Change-Id: " + changeId;
+    updateCommitMessage(
+        changeId,
+        "Commit title\n\nCommit message line 1\nLine 2\nLine 3\nLast line\n\n" + footer + "\n");
+    // The test assumes that the first 5 lines is a header.
+    // Line 10 has content "Line 2"
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(Patch.COMMIT_MSG, "New content\n", 10, 0, 11, 0);
+    Map<String, DiffInfo> fixPreview =
+        gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+    assertThat(fixPreview).hasSize(1);
+    assertThat(fixPreview).containsKey(Patch.COMMIT_MSG);
+
+    DiffInfo diff = fixPreview.get(Patch.COMMIT_MSG);
+    assertThat(diff).metaA().name().isEqualTo(Patch.COMMIT_MSG);
+    assertThat(diff).metaA().contentType().isEqualTo(GERRIT_COMMIT_MESSAGE_TYPE);
+    assertThat(diff).metaB().name().isEqualTo(Patch.COMMIT_MSG);
+    assertThat(diff).metaB().contentType().isEqualTo(GERRIT_COMMIT_MESSAGE_TYPE);
+
+    assertThat(diff).content().element(0).commonLines().hasSize(9);
+    // Header has a dynamic content, do not check it
+    assertThat(diff).content().element(0).commonLines().element(6).isEqualTo("Commit title");
+    assertThat(diff).content().element(0).commonLines().element(7).isEqualTo("");
+    assertThat(diff)
+        .content()
+        .element(0)
+        .commonLines()
+        .element(8)
+        .isEqualTo("Commit message line 1");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("Line 2");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("New content");
+    assertThat(diff)
+        .content()
+        .element(2)
+        .commonLines()
+        .containsExactly("Line 3", "Last line", "", footer, "");
+  }
+
+  @Test
+  public void previewFixForNonExistingFile() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput("a_non_existent_file.txt", "Modified content\n", 1, 0, 2, 0);
+
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput));
+  }
+
+  @Test
+  public void previewFixAddNewLineAtEnd() throws Exception {
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME3, "\n", 2, 8, 2, 8);
+    Map<String, DiffInfo> fixPreview =
+        gApi.changes().id(changeId).current().getFixPreview(applyProvidedFixInput);
+    assertThat(fixPreview).hasSize(1);
+    assertThat(fixPreview).containsKey(FILE_NAME3);
+
+    DiffInfo diff = fixPreview.get(FILE_NAME3);
+    assertThat(diff).metaA().totalLineCount().isEqualTo(2);
+    // Original file doesn't have EOL marker at the end of file.
+    // Due to the additional EOL mark diff has one additional line
+    // This behavior is in line with ordinary get diff API.
+    assertThat(diff).metaB().totalLineCount().isEqualTo(3);
+
+    assertThat(diff).content().hasSize(2);
+    assertThat(diff).content().element(0).commonLines().containsExactly("1st line");
+    assertThat(diff).content().element(1).linesOfA().containsExactly("2nd line");
+    assertThat(diff).content().element(1).linesOfB().containsExactly("2nd line", "");
+  }
+
+  private ApplyProvidedFixInput createApplyProvidedFixInput(
+      String file_name,
+      String replacement,
+      int startLine,
+      int startCharacter,
+      int endLine,
+      int endCharacter) {
+    FixReplacementInfo fixReplacementInfo = new FixReplacementInfo();
+    fixReplacementInfo.path = file_name;
+    fixReplacementInfo.replacement = replacement;
+    fixReplacementInfo.range = createRange(startLine, startCharacter, endLine, endCharacter);
+
+    List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo);
+    ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput();
+    applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList;
+
+    return applyProvidedFixInput;
+  }
+
+  private void updateCommitMessage(String changeId, String newCommitMessage) throws Exception {
+    gApi.changes().id(changeId).edit().create();
+    gApi.changes().id(changeId).edit().modifyCommitMessage(newCommitMessage);
+    PublishChangeEditInput publishInput = new PublishChangeEditInput();
+    gApi.changes().id(changeId).edit().publish(publishInput);
+  }
+
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 58dc0b0..fca2253 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -50,13 +50,15 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.webui.EditWebLink;
+import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.text.SimpleDateFormat;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -102,7 +104,7 @@
   public void setUp() throws Exception {
     // Reduce flakiness of tests. (If tests aren't fast enough, we would use a fall-back
     // computation, which might yield different results.)
-    baseConfig.setString("cache", "diff", "timeout", "1 minute");
+    baseConfig.setString("cache", "git_file_diff", "timeout", "1 minute");
     baseConfig.setString("cache", "diff_intraline", "timeout", "1 minute");
 
     intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
@@ -148,7 +150,7 @@
 
     Map<String, FileDiffOutput> modifiedFiles =
         diffOperations.listModifiedFilesAgainstParent(
-            project, result.getCommit(), /* parentNum= */ 0);
+            project, result.getCommit(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
 
     assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
     assertThat(
@@ -196,6 +198,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();
@@ -205,6 +227,66 @@
   }
 
   @Test
+  public void fileModeChangeIsIncludedInListFilesDiff() throws Exception {
+    String fileName = "file.txt";
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit Subject", /* files= */ ImmutableMap.of())
+            .addFile(fileName, "content", /* fileMode= */ 0100644);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    String commitRev1 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+    push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, result.getChangeId())
+            .addFile(fileName, "content", /* fileMode= */ 0100755);
+    result = push.to("refs/for/master");
+    String commitRev2 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(result.getChangeId()).revision(commitRev2).files(commitRev1);
+
+    assertThat(changedFiles.get(fileName)).oldMode().isEqualTo(0100644);
+    assertThat(changedFiles.get(fileName)).newMode().isEqualTo(0100755);
+  }
+
+  @Test
+  public void fileMode_oldMode_isMissingInListFilesDiff_forAddedFile() throws Exception {
+    String fileName = "file.txt";
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit Subject", /* files= */ ImmutableMap.of())
+            .addFile(fileName, "content", /* fileMode= */ 0100644);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    String commitRev = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(result.getChangeId()).revision(commitRev).files();
+
+    assertThat(changedFiles.get(fileName)).oldMode().isNull();
+    assertThat(changedFiles.get(fileName)).newMode().isEqualTo(0100644);
+  }
+
+  @Test
+  public void fileMode_newMode_isMissingInListFilesDiff_forDeletedFile() throws Exception {
+    String fileName = "file.txt";
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit Subject", /* files= */ ImmutableMap.of())
+            .addFile(fileName, "content", /* fileMode= */ 0100644);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    String commitRev1 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+    push = pushFactory.create(admin.newIdent(), testRepo, result.getChangeId()).rmFile(fileName);
+    result = push.to("refs/for/master");
+    String commitRev2 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+    Map<String, FileInfo> changedFiles =
+        gApi.changes().id(result.getChangeId()).revision(commitRev2).files(commitRev1);
+
+    assertThat(changedFiles.get(fileName)).oldMode().isEqualTo(0100644);
+    assertThat(changedFiles.get(fileName)).newMode().isNull();
+  }
+
+  @Test
   public void numberOfLinesInDiffOfDeletedFileWithoutNewlineAtEndIsCorrect() throws Exception {
     String filePath = "a_new_file.txt";
     String fileContent = "Line 1\nLine 2\nLine 3";
@@ -2964,6 +3046,21 @@
     return extensionRegistry.newRegistration().add(webLink);
   }
 
+  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;
   }
@@ -2986,16 +3083,18 @@
           abbreviateName(parentCommit, 8, testRepo.getRevWalk().getObjectReader());
       headers.add("Parent:     " + parentCommitId + " (" + parentCommit.getShortMessage() + ")");
 
-      SimpleDateFormat dtfmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US);
       PersonIdent author = c.getAuthorIdent();
-      dtfmt.setTimeZone(author.getTimeZone());
+      DateTimeFormatter fmt =
+          DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z")
+              .withLocale(Locale.US)
+              .withZone(author.getZoneId());
       headers.add("Author:     " + author.getName() + " <" + author.getEmailAddress() + ">");
-      headers.add("AuthorDate: " + dtfmt.format(author.getWhen().getTime()));
+      headers.add("AuthorDate: " + fmt.format(author.getWhenAsInstant()));
 
       PersonIdent committer = c.getCommitterIdent();
-      dtfmt.setTimeZone(committer.getTimeZone());
+      fmt = fmt.withZone(committer.getZoneId());
       headers.add("Commit:     " + committer.getName() + " <" + committer.getEmailAddress() + ">");
-      headers.add("CommitDate: " + dtfmt.format(committer.getWhen().getTime()));
+      headers.add("CommitDate: " + fmt.format(committer.getWhenAsInstant()));
       headers.add("");
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index d3fe83f..0291f33 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -95,12 +95,15 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
-import java.sql.Timestamp;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
 import java.util.Collection;
@@ -503,7 +506,7 @@
     PushOneCommit.Result r1 = createChange();
 
     // Push another new change (change 2)
-    String subject = "Test change\n\nChange-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    String subject = "Test change";
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(), testRepo, subject, "another_file.txt", "another content");
@@ -517,7 +520,7 @@
     ChangeApi orig = gApi.changes().id(triplet);
     CherryPickInput in = new CherryPickInput();
     in.destination = "master";
-    in.message = subject;
+    in.message = subject + "\n\nChange-Id: " + r2.getChangeId();
     ChangeApi cherry = orig.revision(r2.getCommit().name()).cherryPick(in);
     ChangeInfo cherryInfo = cherry.get();
     assertThat(cherryInfo.messages).hasSize(2);
@@ -685,6 +688,26 @@
   }
 
   @Test
+  public void cherryPickWithValidationOptions() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "foo";
+    in.message = "Cherry Pick";
+    in.validationOptions = ImmutableMap.of("key", "value");
+
+    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(r.getChangeId()).current().cherryPickAsInfo(in);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void cherryPickToExistingChangeUpdatesCherryPickOf() throws Exception {
     PushOneCommit.Result r1 =
         pushFactory
@@ -986,6 +1009,84 @@
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void cherryPickWithNonVisibleUsers() throws Exception {
+    // Create a target branch for the cherry-pick.
+    createBranch(BranchNameKey.create(project, "stable"));
+
+    // Define readable names for the users we use in this test.
+    TestAccount cherryPicker = user;
+    TestAccount changeOwner = admin;
+    TestAccount reviewer = accountCreator.user2();
+    TestAccount cc =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+    TestAccount authorCommitter =
+        accountCreator.create("user4", "user4@example.com", "User4", /* displayName= */ null);
+
+    // Check that the cherry-picker can neither see the changeOwner, the reviewer, the cc nor the
+    // authorCommitter.
+    requestScopeOperations.setApiUser(cherryPicker.id());
+    assertThatAccountIsNotVisible(changeOwner, reviewer, cc, authorCommitter);
+
+    // Create the change with authorCommitter as the author and the committer.
+    requestScopeOperations.setApiUser(changeOwner.id());
+    PushOneCommit push = pushFactory.create(authorCommitter.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Check that authorCommitter was set as the author and committer.
+    ChangeInfo changeInfo = gApi.changes().id(r.getChangeId()).get();
+    CommitInfo commit = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+    assertThat(commit.author.email).isEqualTo(authorCommitter.email());
+    assertThat(commit.committer.email).isEqualTo(authorCommitter.email());
+
+    // Pushing a commit with a forged author/committer adds the author/committer as a CC.
+    assertCcs(r.getChangeId(), authorCommitter);
+
+    // Remove the author/committer as a CC because because otherwise there are two signals for CCing
+    // authorCommitter on the cherry-pick change: once because they are author and committer and
+    // once because they are a CC. For authorCommitter we only want to test the first signal here
+    // (the second signal is covered by adding an explicit CC below).
+    gApi.changes().id(r.getChangeId()).reviewer(authorCommitter.email()).remove();
+    assertNoCcs(r.getChangeId());
+
+    // Add reviewer and cc.
+    ReviewInput reviewerInput = ReviewInput.approve();
+    reviewerInput.reviewer(reviewer.email());
+    reviewerInput.cc(cc.email());
+    gApi.changes().id(r.getChangeId()).current().review(reviewerInput);
+
+    // Approve and submit the change.
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // Cherry-pick the change.
+    requestScopeOperations.setApiUser(cherryPicker.id());
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.message = "Cherry-pick to stable branch";
+    cherryPickInput.destination = "stable";
+    cherryPickInput.keepReviewers = true;
+    String cherryPickChangeId =
+        gApi.changes().id(r.getChangeId()).current().cherryPick(cherryPickInput).get().id;
+
+    // Cherry-pick doesn't check the visibility of explicit reviewers/CCs. Since the cherry-picker
+    // can see the cherry-picked change, they can also see its reviewers/CCs. This means preserving
+    // them on the cherry-pick change doesn't expose their account existence and it's OK to keep
+    // them even if their accounts are not visible to the cherry-picker.
+    // In contrast to this for implicit CCs that are added for the author/committer the account
+    // visibility is checked, but if their accounts are not visible the CC is silently dropped (so
+    // that the cherry-pick request can still succeed). Since in this case authorCommitter is not
+    // visible, we expect that CCing them is being dropped and hence authorCommitter is not returned
+    // as a CC here. The reason that the visibility for author/committer must be checked is that
+    // author/committer may not match a Gerrit account (if they are forged). This means by seeing
+    // the author/committer on the cherry-picked change, it's not possible to deduce that these
+    // Gerrit accounts exists, but if they would be added as a CC on the cherry-pick change even if
+    // they are not visible the account existence would be exposed.
+    assertReviewers(cherryPickChangeId, changeOwner, reviewer);
+    assertCcs(cherryPickChangeId, cc);
+  }
+
+  @Test
   public void cherryPickToMergedChangeRevision() throws Exception {
     createBranch(BranchNameKey.create(project, "foo"));
 
@@ -1683,10 +1784,13 @@
     }
   }
 
+  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
+  // Instant
+  @SuppressWarnings("JdkObsolete")
   private void assertPersonIdent(GitPerson gitPerson, PersonIdent expectedIdent) {
     assertThat(gitPerson.name).isEqualTo(expectedIdent.getName());
     assertThat(gitPerson.email).isEqualTo(expectedIdent.getEmailAddress());
-    assertThat(gitPerson.date).isEqualTo(new Timestamp(expectedIdent.getWhen().getTime()));
+    assertThat(gitPerson.date.getTime()).isEqualTo(expectedIdent.getWhenAsInstant().toEpochMilli());
     assertThat(gitPerson.tz).isEqualTo(expectedIdent.getTimeZoneOffset());
   }
 
@@ -2077,4 +2181,15 @@
   private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
     return Iterables.transform(r, a -> Account.id(a._accountId));
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 875ce97..1363ce7 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -588,6 +588,22 @@
   }
 
   @Test
+  public void commentWithRangeAndLine_lineIsIgnored() throws Exception {
+    FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+    fixReplacementInfo1.path = FILE_NAME;
+    fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+    fixReplacementInfo1.replacement = "First modification\n";
+
+    withFixRobotCommentInput.line = 1;
+    withFixRobotCommentInput.range = createRange(2, 0, 3, 1);
+    withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+    testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+    List<RobotCommentInfo> robotComments = getRobotComments();
+    assertThat(robotComments.get(0).line).isEqualTo(3);
+  }
+
+  @Test
   public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap() {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
@@ -716,7 +732,7 @@
   }
 
   @Test
-  public void fixWithinALineCanBeApplied() throws Exception {
+  public void storedFixWithinALineCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -739,7 +755,7 @@
   }
 
   @Test
-  public void fixSpanningMultipleLinesCanBeApplied() throws Exception {
+  public void storedFixSpanningMultipleLinesCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content\n5";
     fixReplacementInfo.range = createRange(3, 2, 5, 3);
@@ -761,7 +777,7 @@
   }
 
   @Test
-  public void fixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
+  public void storedFixWithTwoCloseReplacementsOnSameFileCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -793,7 +809,7 @@
   }
 
   @Test
-  public void twoFixesOnSameFileCanBeApplied() throws Exception {
+  public void twoStoredFixesOnSameFileCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -828,7 +844,7 @@
   }
 
   @Test
-  public void twoConflictingFixesOnSameFileCannotBeApplied() throws Exception {
+  public void twoConflictingStoredFixesOnSameFileCannotBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 1);
@@ -859,7 +875,7 @@
   }
 
   @Test
-  public void twoFixesOfSameRobotCommentCanBeApplied() throws Exception {
+  public void twoStoredFixesOfSameRobotCommentCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -892,7 +908,7 @@
   }
 
   @Test
-  public void fixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
+  public void storedFixReferringToDifferentFileThanRobotCommentCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME2;
     fixReplacementInfo.range = createRange(2, 0, 3, 0);
     fixReplacementInfo.replacement = "Modified content\n";
@@ -912,7 +928,7 @@
   }
 
   @Test
-  public void fixInvolvingTwoFilesCanBeApplied() throws Exception {
+  public void storedFixInvolvingTwoFilesCanBeApplied() throws Exception {
     FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
     fixReplacementInfo1.path = FILE_NAME;
     fixReplacementInfo1.range = createRange(2, 0, 3, 0);
@@ -949,7 +965,7 @@
   }
 
   @Test
-  public void fixReferringToNonExistentFileCannotBeApplied() throws Exception {
+  public void storedFixReferringToNonExistentFileCannotBeApplied() throws Exception {
     fixReplacementInfo.path = "a_non_existent_file.txt";
     fixReplacementInfo.range = createRange(1, 0, 2, 0);
     fixReplacementInfo.replacement = "Modified content\n";
@@ -965,7 +981,7 @@
   }
 
   @Test
-  public void fixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
+  public void storedFixOnPreviousPatchSetWithoutChangeEditCannotBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -988,7 +1004,7 @@
   }
 
   @Test
-  public void fixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
+  public void storedFixOnPreviousPatchSetWithExistingChangeEditCanBeApplied() throws Exception {
     // Create an empty change edit.
     gApi.changes().id(changeId).edit().create();
 
@@ -1019,7 +1035,7 @@
   }
 
   @Test
-  public void fixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
+  public void storedFixOnCurrentPatchSetWithChangeEditOnPreviousPatchSetCannotBeApplied()
       throws Exception {
     // Create an empty change edit.
     gApi.changes().id(changeId).edit().create();
@@ -1045,7 +1061,7 @@
   }
 
   @Test
-  public void fixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
+  public void storedFixDoesNotModifyCommitMessageOfChangeEdit() throws Exception {
     String changeEditCommitMessage =
         "This is the commit message of the change edit.\n\nChange-Id: " + changeId + "\n";
     gApi.changes().id(changeId).edit().modifyCommitMessage(changeEditCommitMessage);
@@ -1064,7 +1080,7 @@
   }
 
   @Test
-  public void fixOnCommitMessageCanBeApplied() throws Exception {
+  public void storedFixOnCommitMessageCanBeApplied() throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
@@ -1086,7 +1102,7 @@
   }
 
   @Test
-  public void fixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
+  public void storedFixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
     // Set a dedicated commit message.
     String footer = "Change-Id: " + changeId;
     String originalCommitMessage =
@@ -1110,7 +1126,8 @@
   }
 
   @Test
-  public void fixContainingSeveralModificationsOfCommitMessageCanBeApplied() throws Exception {
+  public void storedFixContainingSeveralModificationsOfCommitMessageCanBeApplied()
+      throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage =
@@ -1144,7 +1161,7 @@
   }
 
   @Test
-  public void fixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
+  public void storedFixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n" + footer;
@@ -1180,7 +1197,7 @@
   }
 
   @Test
-  public void twoFixesOnCommitMessageCanBeAppliedOneAfterTheOther() throws Exception {
+  public void twoStoredFixesOnCommitMessageCanBeAppliedOneAfterTheOther() throws Exception {
     // Set a dedicated commit message.
     String footer = "\nChange-Id: " + changeId + "\n";
     String originalCommitMessage =
@@ -1217,7 +1234,8 @@
   }
 
   @Test
-  public void twoConflictingFixesOnCommitMessageCanNotBeAppliedOneAfterTheOther() throws Exception {
+  public void twoConflictingStoredFixesOnCommitMessageCanNotBeAppliedOneAfterTheOther()
+      throws Exception {
     // Set a dedicated commit message.
     String footer = "Change-Id: " + changeId;
     String originalCommitMessage =
@@ -1254,7 +1272,7 @@
   }
 
   @Test
-  public void applyingFixTwiceIsIdempotent() throws Exception {
+  public void applyingStoredFixTwiceIsIdempotent() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -1277,7 +1295,7 @@
   }
 
   @Test
-  public void nonExistentFixCannotBeApplied() throws Exception {
+  public void nonExistentStoredFixCannotBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -1295,7 +1313,7 @@
   }
 
   @Test
-  public void applyingFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
+  public void applyingStoredFixReturnsEditInfoForCreatedChangeEdit() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content";
     fixReplacementInfo.range = createRange(3, 1, 3, 3);
@@ -1316,7 +1334,8 @@
   }
 
   @Test
-  public void applyingFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit() throws Exception {
+  public void applyingStoredFixOnTopOfChangeEditReturnsEditInfoForUpdatedChangeEdit()
+      throws Exception {
     gApi.changes().id(changeId).edit().create();
 
     fixReplacementInfo.path = FILE_NAME;
@@ -1379,7 +1398,7 @@
   }
 
   @Test
-  public void getFixPreviewWithNonexistingFixId() throws Exception {
+  public void previewStoredFixWithNonexistentFixId() throws Exception {
     testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
 
     assertThrows(
@@ -1388,7 +1407,7 @@
   }
 
   @Test
-  public void getFixPreviewForCommitMsg() throws Exception {
+  public void previewStoredFixForCommitMsg() throws Exception {
     String footer = "Change-Id: " + changeId;
     updateCommitMessage(
         changeId,
@@ -1447,7 +1466,7 @@
   }
 
   @Test
-  public void getFixPreviewForNonExistingFile() throws Exception {
+  public void previewStoredFixForNonExistingFile() throws Exception {
     FixReplacementInfo replacement = new FixReplacementInfo();
     replacement.path = "a_non_existent_file.txt";
     replacement.range = createRange(1, 0, 2, 0);
@@ -1468,7 +1487,7 @@
   }
 
   @Test
-  public void getFixPreview() throws Exception {
+  public void previewStoredFix() throws Exception {
     FixReplacementInfo fixReplacementInfoFile1 = new FixReplacementInfo();
     fixReplacementInfoFile1.path = FILE_NAME;
     fixReplacementInfoFile1.replacement = "some replacement code";
@@ -1578,7 +1597,7 @@
   }
 
   @Test
-  public void getFixPreviewAddNewLineAtEnd() throws Exception {
+  public void previewStoredFixAddNewLineAtEnd() throws Exception {
     FixReplacementInfo replacement = new FixReplacementInfo();
     replacement.path = FILE_NAME3;
     replacement.range = createRange(2, 8, 2, 8);
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 0e0168e..cc72924 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -93,6 +94,7 @@
   private static final String FILE_NAME = "foo";
   private static final String FILE_NAME2 = "foo2";
   private static final String FILE_NAME3 = "foo3";
+  private static final int FILE_MODE = 100644;
   private static final byte[] CONTENT_OLD = "bar".getBytes(UTF_8);
   private static final byte[] CONTENT_NEW = "baz".getBytes(UTF_8);
   private static final String CONTENT_NEW2_STR = "quxÄÜÖßµ";
@@ -106,6 +108,7 @@
       "Uploading to an edit worked!".getBytes(UTF_8);
   private static final String CONTENT_BINARY_ENCODED_NEW3 =
       "data:text/plain,VXBsb2FkaW5nIHRvIGFuIGVkaXQgd29ya2VkIQ==";
+  private static final String CONTENT_BINARY_ENCODED_EMPTY = "data:text/plain;base64,";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -685,6 +688,26 @@
   }
 
   @Test
+  public void changeEditModifyFileModeRest() throws Exception {
+    createEmptyEditFor(changeId);
+    FileContentInput in = new FileContentInput();
+    in.binary_content = CONTENT_BINARY_ENCODED_NEW;
+    in.fileMode = FILE_MODE;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW);
+  }
+
+  @Test
+  public void changeEditModifyFileSetEmptyContentModeRest() throws Exception {
+    createEmptyEditFor(changeId);
+    FileContentInput in = new FileContentInput();
+    in.binary_content = CONTENT_BINARY_ENCODED_EMPTY;
+    in.fileMode = FILE_MODE;
+    adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+    ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), "".getBytes(UTF_8));
+  }
+
+  @Test
   public void createAndUploadBinaryInChangeEditOneRequestRest() throws Exception {
     FileContentInput in = new FileContentInput();
     in.binary_content = CONTENT_BINARY_ENCODED_NEW;
@@ -810,7 +833,9 @@
       LabelType codeReview = TestLabels.codeReview();
       u.getConfig().upsertLabelType(codeReview);
       u.getConfig()
-          .updateLabelType(codeReview.getName(), lt -> lt.setCopyAllScoresIfNoCodeChange(true));
+          .updateLabelType(
+              codeReview.getName(),
+              lt -> lt.setCopyCondition("changekind:" + ChangeKind.NO_CODE_CHANGE.name()));
       u.save();
     }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
index a22759f..a7e673a 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.FakeGroupAuditService;
@@ -26,6 +27,7 @@
 import com.google.inject.Inject;
 import java.util.Optional;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
@@ -106,6 +108,38 @@
     uploadPackAuditEventLog(remote, Optional.empty());
   }
 
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void wantNotValidErrorOverHTTPShouldResultIn200OKHttpStatus() throws Exception {
+    String remote = "origin";
+    String uri = admin.getHttpUrl(server) + "/a/" + project.get();
+    cfg.setString("remote", remote, "url", uri);
+    cfg.setString("remote", remote, "fetch", "+refs/heads/*:refs/remotes/origin/*");
+    String wantNotValidCommit = "554013834d49a69a2f3c494de195ee606dd6d035";
+
+    auditService.drainHttpAuditEvents();
+
+    TransportException thrown =
+        assertThrows(
+            TransportException.class,
+            () ->
+                testRepo
+                    .git()
+                    .fetch()
+                    .setRemote(remote)
+                    .setRefSpecs(new RefSpec(wantNotValidCommit))
+                    .call());
+
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(String.format("want %s not valid", wantNotValidCommit));
+
+    assertThat(
+            auditService.drainHttpAuditEvents().stream()
+                .allMatch(e -> e.httpStatus == HttpServletResponse.SC_OK))
+        .isTrue();
+  }
+
   /**
    * 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
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 2253202..b738324 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -46,6 +46,7 @@
 import static java.util.stream.Collectors.toList;
 
 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.Iterables;
@@ -127,6 +128,7 @@
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Stream;
 import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
@@ -1062,6 +1064,9 @@
     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);
+
     PushOneCommit push =
         pushFactory.create(
             admin.newIdent(),
@@ -1071,12 +1076,18 @@
             "anotherContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
 
     ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     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
+        .isEqualTo(
+            "Uploaded patch set 2: Code-Review+2.\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Code-Review+1 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n");
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
 
     assertThat(cr.all).hasSize(1);
@@ -1092,8 +1103,15 @@
             "moreContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
     ci = get(r.getChangeId(), MESSAGES);
-    assertThat(Iterables.getLast(ci.messages).message).isEqualTo("Uploaded patch set 3.");
+    assertThat(Iterables.getLast(ci.messages).message)
+        .isEqualTo(
+            "Uploaded patch set 3.\n"
+                + "\n"
+                + "Outdated Votes:\n"
+                + "* Code-Review+2 (copy condition: \"changekind:NO_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n");
   }
 
   @Test
@@ -1110,6 +1128,7 @@
             "anotherContent",
             r.getChangeId());
     r = push.to("refs/for/master%l=Code-Review+2");
+    r.assertOkStatus();
 
     ChangeInfo ci = get(r.getChangeId(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
     LabelInfo cr = ci.labels.get(LabelId.CODE_REVIEW);
@@ -1117,7 +1136,7 @@
         .isEqualTo("Uploaded patch set 2: Code-Review+2.");
 
     // Check that the user who pushed the new patch set was added as a reviewer since they added
-    // a vote
+    // a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
 
     assertThat(cr.all).hasSize(1);
@@ -1136,7 +1155,7 @@
             .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
             .message(PushOneCommit.SUBJECT)
             .create();
-    // Push commit as "Admnistrator".
+    // Push commit as "Administrator".
     pushHead(testRepo, "refs/for/master");
 
     String changeId = GitUtil.getChangeId(testRepo, c).get();
@@ -1150,6 +1169,92 @@
   }
 
   @Test
+  public void pushForMasterWithForgedAuthorAndCommitter_skipAddingAuthorAndCommitterAsReviewers()
+      throws Exception {
+    setSkipAddingAuthorAndCommitterAsReviewers(InheritableBoolean.TRUE);
+    TestAccount user2 = accountCreator.user2();
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        commitBuilder()
+            .author(user.newIdent())
+            .committer(user2.newIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+    // Push commit as "Administrator".
+    pushHead(testRepo, "refs/for/master");
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
+  public void pushForMasterWithNonExistingForgedAuthorAndCommitter() throws Exception {
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        commitBuilder()
+            .author(new PersonIdent("author", "author@example.com"))
+            .committer(new PersonIdent("committer", "committer@example.com"))
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+    // Push commit as "Administrator".
+    pushHead(testRepo, "refs/for/master");
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void pushForMasterWithNonVisibleForgedAuthorAndCommitter() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount uploader = user; // cannot use admin since admin can see all users
+    TestAccount author = accountCreator.user2();
+    TestAccount committer =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the uploader can neither see the author nor the committer.
+    requestScopeOperations.setApiUser(uploader.id());
+    assertThatAccountIsNotVisible(author, committer);
+
+    // Allow the uploader to forge author and committer.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Clone the repo as uploader so that the push is done by the uplaoder.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, uploader);
+
+    // Create a commit with different forged author and committer.
+    RevCommit c =
+        testRepo
+            .branch("HEAD")
+            .commit()
+            .insertChangeId()
+            .author(author.newIdent())
+            .committer(committer.newIdent())
+            .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+            .message(PushOneCommit.SUBJECT)
+            .create();
+
+    PushResult r = pushHead(testRepo, "refs/for/master");
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate("refs/for/master");
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    assertThat(getOwnerEmail(changeId)).isEqualTo(uploader.email());
+
+    // author and committer have not been CCed because their accounts are not visible
+    assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
   public void pushNewPatchSetForMasterWithForgedAuthorAndCommitter() throws Exception {
     TestAccount user2 = accountCreator.user2();
     // First patch set has author and committer matching change owner.
@@ -1174,6 +1279,74 @@
         .containsExactly(user.getNameEmail(), user2.getNameEmail());
   }
 
+  @Test
+  public void pushNewPatchSetForMasterWithNonExistingForgedAuthorAndCommitter() throws Exception {
+    // First patch set has author and committer matching change owner.
+    PushOneCommit.Result r = pushTo("refs/for/master");
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+    amendBuilder()
+        .author(new PersonIdent("author", "author@example.com"))
+        .committer(new PersonIdent("committer", "committer@example.com"))
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+        .create();
+    pushHead(testRepo, "refs/for/master");
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC)).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void pushNewPatchSetForMasterWithNonVisibleForgedAuthorAndCommitter() throws Exception {
+    // Define readable names for the users we use in this test.
+    TestAccount uploader = user; // cannot use admin since admin can see all users
+    TestAccount author = accountCreator.user2();
+    TestAccount committer =
+        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+    // Check that the uploader can neither see the author nor the committer.
+    requestScopeOperations.setApiUser(uploader.id());
+    assertThatAccountIsNotVisible(author, committer);
+
+    // Allow the uploader to forge author and committer.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.FORGE_AUTHOR).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(allow(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Clone the repo as uploader so that the push is done by the uplaoder.
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, uploader);
+
+    // First patch set has author and committer matching uploader.
+    PushOneCommit push = pushFactory.create(uploader.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(uploader.email());
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+    testRepo
+        .amendRef("HEAD")
+        .author(author.newIdent())
+        .committer(committer.newIdent())
+        .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+        .create();
+
+    PushResult r2 = pushHead(testRepo, "refs/for/master");
+    RemoteRefUpdate refUpdate = r2.getRemoteUpdate("refs/for/master");
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+    assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(uploader.email());
+
+    // author and committer have not been CCed because their accounts are not visible
+    assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC)).isEmpty();
+  }
+
   /**
    * There was a bug that allowed a user with Forge Committer Identity access right to upload a
    * commit and put *votes on behalf of another user* on it. This test checks that this is not
@@ -1244,7 +1417,7 @@
     assertThat(cr.all).hasSize(1);
     cr = ci.labels.get("Custom-Label");
     assertThat(cr.all).hasSize(1);
-    // Check that the user who pushed the change was added as a reviewer since they added a vote
+    // Check that the user who pushed the change was added as a reviewer since they added a vote.
     assertThatUserIsOnlyReviewer(ci, admin);
   }
 
@@ -1427,6 +1600,34 @@
   }
 
   @Test
+  public void pushToNonVisibleBranchIsRejected() throws Exception {
+    String master = "refs/heads/master";
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(master).group(REGISTERED_USERS))
+        .update();
+
+    testRepo.branch("HEAD").commit().message("New Commit 1").insertChangeId().create();
+    // Since the branch is not visible to the caller, the command tries to create the ref resulting
+    // in the command being rejected because the ref already exists.
+    assertPushRejected(
+        pushHead(testRepo, master),
+        master,
+        "Cannot create ref 'refs/heads/master' because it already exists.");
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(master).group(REGISTERED_USERS))
+        .update();
+
+    testRepo.branch("HEAD").commit().message("New Commit 2").insertChangeId().create();
+    assertPushOk(pushHead(testRepo, master), master);
+  }
+
+  @Test
   public void pushSameCommitTwiceUsingMagicBranchBaseOption() throws Exception {
     projectOperations
         .project(project)
@@ -1908,7 +2109,7 @@
   @Test
   public void pushNewPatchsetOverridingStickyLabel() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
-      LabelType codeReview = TestLabels.codeReview().toBuilder().setCopyMaxScore(true).build();
+      LabelType codeReview = TestLabels.codeReview().toBuilder().setCopyCondition("is:MAX").build();
       u.getConfig().upsertLabelType(codeReview);
       u.save();
     }
@@ -2395,8 +2596,8 @@
     }
 
     @Nullable
-    public CommitReceivedEvent getReceivedEvent() {
-      return receivedEvent;
+    public ImmutableListMultimap<String, String> pushOptions() {
+      return receivedEvent != null ? receivedEvent.pushOptions : null;
     }
   }
 
@@ -2492,7 +2693,24 @@
       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)
+      assertThat(validator.pushOptions())
+          .containsExactly("trace", "123", "gerrit~foo", "", "gerrit~bar", "456");
+    }
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugins.transitionalPushOptions",
+      values = {"gerrit~foo", "gerrit~bar"})
+  public void transitionalPushOptionsArePassedToCommitValidationListener() throws Exception {
+    TestValidator validator = new TestValidator();
+    try (Registration registration = extensionRegistry.newRegistration().add(validator)) {
+      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.pushOptions())
           .containsExactly("trace", "123", "gerrit~foo", "", "gerrit~bar", "456");
     }
   }
@@ -2864,6 +3082,12 @@
     assertThat(r.getChange().attentionSet()).isEmpty();
   }
 
+  @Test
+  public void pushWithInvalidBaseIsRejected() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master%base=invalid");
+    r.assertErrorStatus("expected SHA1 for option --base: invalid");
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index 31292d5..0cdac5a 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseLocalDisk;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Address;
@@ -65,6 +66,7 @@
   }
 
   @Test
+  @UseLocalDisk
   public void submitOnPush() throws Exception {
     projectOperations
         .project(project)
@@ -76,6 +78,8 @@
     r.assertChange(Change.Status.MERGED, null, admin);
     assertSubmitApproval(r.getPatchSetId());
     assertCommit(project, "refs/heads/master");
+    assertThat(gApi.projects().name(project.get()).branch("master").reflog().get(0).comment)
+        .isEqualTo("forced-merge");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index 415aa79..c3bcbd3 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -226,7 +226,7 @@
       ObjectId oldId = pc.getRevision();
       ObjectId newId = pc.commit(md);
       assertThat(newId).isNotEqualTo(oldId);
-      projectCache.evict(pc.getProject());
+      projectCache.evictAndReindex(pc.getProject());
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/BUILD b/javatests/com/google/gerrit/acceptance/git/BUILD
index 13311e3..db73f3f 100644
--- a/javatests/com/google/gerrit/acceptance/git/BUILD
+++ b/javatests/com/google/gerrit/acceptance/git/BUILD
@@ -10,7 +10,7 @@
         ":submodule_util",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server/git/receive/testing",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
     ],
 ) for f in glob(["*IT.java"])]
 
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 9d1bdaa..f94aa12 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -47,6 +47,7 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 import org.eclipse.jgit.transport.TrackingRefUpdate;
 import org.junit.Before;
@@ -94,6 +95,32 @@
   }
 
   @Test
+  public void pushNewCommitsRequiresPushPermission() throws Exception {
+    testRepo.branch("HEAD").commit().create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.CREATE).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    PushResult r = push("HEAD:refs/heads/newbranch");
+
+    String msg = "update for creating new commit object not permitted";
+    RemoteRefUpdate rru = r.getRemoteUpdate("refs/heads/newbranch");
+    assertThat(rru.getStatus()).isNotEqualTo(Status.OK);
+    assertThat(rru.getMessage()).contains(msg);
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    RemoteRefUpdate success =
+        push("HEAD:refs/heads/newbranch").getRemoteUpdate("refs/heads/newbranch");
+    assertThat(success.getStatus()).isEqualTo(Status.OK);
+  }
+
+  @Test
   public void fastForwardUpdateDenied() throws Exception {
     testRepo.branch("HEAD").commit().create();
     PushResult r = push("HEAD:refs/heads/master");
@@ -379,7 +406,7 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    for (AccessSection s : ImmutableList.copyOf(cfg.getAccessSections())) {
+    for (AccessSection s : cfg.getAccessSections()) {
       if (s.getName().startsWith("refs/heads/")
           || s.getName().startsWith("refs/for/")
           || s.getName().equals("refs/*")) {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 194f5f9..f58f81c 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -32,6 +32,7 @@
 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.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
@@ -82,6 +83,7 @@
   @Inject private PermissionBackend permissionBackend;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   private AccountGroup.UUID admins;
   private AccountGroup.UUID nonInteractiveUsers;
@@ -125,7 +127,7 @@
     // Remove read permissions for all users besides admin.
     try (ProjectConfigUpdate u = updateProject(allProjects)) {
 
-      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+      for (AccessSection sec : u.getConfig().getAccessSections()) {
         u.getConfig()
             .upsertAccessSection(
                 sec.getName(),
@@ -142,7 +144,7 @@
 
     // Remove all read permissions on All-Users.
     try (ProjectConfigUpdate u = updateProject(allUsers)) {
-      for (AccessSection sec : ImmutableList.copyOf(u.getConfig().getAccessSections())) {
+      for (AccessSection sec : u.getConfig().getAccessSections()) {
         u.getConfig()
             .upsertAccessSection(
                 sec.getName(),
@@ -1395,14 +1397,15 @@
   public void fetchSingleChangeWithoutIndexAccess() throws Exception {
     PushOneCommit.Result change = createChange();
     String patchSetRef = change.getPatchSetId().toRefName();
-    try (AutoCloseable ignored = disableChangeIndex();
+    try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites();
         Repository repo = repoManager.openRepository(project)) {
-      Collection<Ref> singleRef = ImmutableList.of(repo.exactRef(patchSetRef));
-      Collection<Ref> filteredRefs =
-          permissionBackend
-              .user(user(admin))
-              .project(project)
-              .filter(singleRef, repo, RefFilterOptions.defaults());
+      ImmutableList<Ref> singleRef = ImmutableList.of(repo.exactRef(patchSetRef));
+      ImmutableList<Ref> filteredRefs =
+          ImmutableList.copyOf(
+              permissionBackend
+                  .user(user(admin))
+                  .project(project)
+                  .filter(singleRef, repo, RefFilterOptions.defaults()));
       assertThat(filteredRefs).isEqualTo(singleRef);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
index 9c5afd2..3cf54f3 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.git;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -25,6 +27,7 @@
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.UPDATE_NONFASTFORWARD;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
@@ -79,6 +82,35 @@
   }
 
   @Test
+  public void infoMessagesAreReturnedOnPush() throws Exception {
+    String message1 = "for bar baz";
+    String message2 = "abc xyz";
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new RefOperationValidationListener() {
+                  @Override
+                  public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+                      throws ValidationException {
+                    return ImmutableList.of(
+                        new ValidationMessage(message1, ValidationMessage.Type.HINT),
+                        new ValidationMessage(message2, ValidationMessage.Type.HINT));
+                  }
+                })) {
+      PushResult r =
+          pushHead(
+              testRepo,
+              "refs/heads/new",
+              /* pushTags= */ false,
+              /* force= */ false,
+              /* pushOptions= */ ImmutableList.of());
+      assertPushOk(r, "refs/heads/new");
+      assertThat(r.getMessages()).contains(String.format("hint: %s\nhint: %s", message1, message2));
+    }
+  }
+
+  @Test
   public void rejectRefCreation() throws Exception {
     try (Registration registration = testValidator(CREATE)) {
       RestApiException expected =
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 1c8ca93..d2aab5b 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
@@ -53,6 +54,8 @@
   }
 
   @Inject private ProjectOperations projectOperations;
+  @Inject private IndexOperations.Change changeIndexOperations;
+  @Inject private IndexOperations.Account accountIndexOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
 
   @Test
@@ -747,12 +750,10 @@
   }
 
   private String getStatus(ChangeData cd) throws Exception {
-
-    try (AutoCloseable changeIndex = disableChangeIndex()) {
-      try (AutoCloseable accountIndex = disableAccountIndex()) {
-        SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
-        return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
-      }
+    try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites();
+        AutoCloseable accountIndex = accountIndexOperations.disableReadsAndWrites()) {
+      SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
+      return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 8367f60..ef2ca95 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -35,8 +34,7 @@
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.ArrayDeque;
-import java.util.Map;
-import org.apache.commons.lang.RandomStringUtils;
+import org.apache.commons.lang3.RandomStringUtils;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -144,7 +142,6 @@
     gApi.changes().id(id2).current().review(ReviewInput.approve());
     gApi.changes().id(id3).current().review(ReviewInput.approve());
 
-    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(id1);
     gApi.changes().id(id1).current().submit();
     ObjectId subRepoId =
         subRepo
@@ -156,25 +153,6 @@
             .getObjectId();
 
     expectToHaveSubmoduleState(superRepo, "master", subKey, subRepoId);
-
-    // As the submodules have changed commits, the superproject tree will be
-    // different, so we cannot directly compare the trees here, so make
-    // assumptions only about the changed branches:
-    assertThat(preview).containsKey(BranchNameKey.create(superKey, "refs/heads/master"));
-    assertThat(preview).containsKey(BranchNameKey.create(subKey, "refs/heads/master"));
-
-    if ((getSubmitType() == SubmitType.CHERRY_PICK)
-        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
-      // each change is updated and the respective target branch is updated:
-      assertThat(preview).hasSize(5);
-    } else if ((getSubmitType() == SubmitType.REBASE_IF_NECESSARY)) {
-      // Either the first is used first as is, then the second and third need
-      // rebasing, or those two stay as is and the first is rebased.
-      // add in 2 master branches, expect 3 or 4:
-      assertThat(preview.size()).isAnyOf(3, 4);
-    } else {
-      assertThat(preview).hasSize(2);
-    }
   }
 
   @Test
@@ -661,12 +639,6 @@
   }
 
   @Test
-  public void branchCircularSubscriptionPreview() throws Exception {
-    testBranchCircularSubscription(
-        changeId -> gApi.changes().id(changeId).current().submitPreview());
-  }
-
-  @Test
   public void projectCircularSubscriptionWholeTopic() throws Exception {
     allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, "refs/heads/master");
     allowMatchingSubmoduleSubscription(superKey, "refs/heads/dev", subKey, "refs/heads/dev");
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index cac376f..7386a03 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -32,10 +32,12 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Injector;
 import com.google.inject.Key;
@@ -48,6 +50,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
+import org.junit.Assume;
 import org.junit.Test;
 
 @NoHttpd
@@ -175,7 +178,9 @@
 
   @Test
   public void onlineUpgradeChanges() throws Exception {
-    int prevVersion = ChangeSchemaDefinitions.INSTANCE.getPrevious().getVersion();
+    Schema<ChangeData> previous = ChangeSchemaDefinitions.INSTANCE.getPrevious();
+    Assume.assumeNotNull(previous);
+    int prevVersion = previous.getVersion();
     int currVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
 
     // Before storing any changes, switch back to the previous version.
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index 5b18d02..9fab01b 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -4,7 +4,6 @@
 acceptance_tests(
     srcs = glob(
         ["*IT.java"],
-        exclude = ["ElasticReindexIT.java"],
     ),
     group = "pgm",
     labels = [
@@ -21,24 +20,6 @@
     ],
 )
 
-acceptance_tests(
-    srcs = ["ElasticReindexIT.java"],
-    group = "elastic",
-    labels = [
-        "docker",
-        "elastic",
-        "pgm",
-        "no_windows",
-    ],
-    vm_args = ["-Xmx1024m"],
-    deps = [
-        ":util",
-        "//java/com/google/gerrit/elasticsearch",
-        "//java/com/google/gerrit/server/schema",
-        "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
-    ],
-)
-
 java_library(
     name = "util",
     testonly = True,
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
deleted file mode 100644
index 0632241..0000000
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.pgm;
-
-import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
-
-import com.google.gerrit.elasticsearch.ElasticVersion;
-import com.google.gerrit.testing.ConfigSuite;
-import org.eclipse.jgit.lib.Config;
-
-public class ElasticReindexIT extends AbstractReindexTests {
-  @ConfigSuite.Default
-  public static Config elasticsearchV7() {
-    return getConfig(ElasticVersion.V7_8);
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java b/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
index 9cdcb40..7d8794b 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/IndexUpgradeController.java
@@ -22,8 +22,6 @@
 import com.google.gerrit.server.index.OnlineUpgradeListener;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.concurrent.CountDownLatch;
 
 class IndexUpgradeController implements OnlineUpgradeListener {
@@ -45,18 +43,18 @@
   private final CountDownLatch started;
   private final CountDownLatch finished;
 
-  private final List<UpgradeAttempt> startedAttempts;
-  private final List<UpgradeAttempt> succeededAttempts;
-  private final List<UpgradeAttempt> failedAttempts;
+  private final ImmutableList.Builder<UpgradeAttempt> startedAttempts;
+  private final ImmutableList.Builder<UpgradeAttempt> succeededAttempts;
+  private final ImmutableList.Builder<UpgradeAttempt> failedAttempts;
 
   IndexUpgradeController(int numExpected) {
     this.numExpected = numExpected;
     readyToStart = new CountDownLatch(1);
     started = new CountDownLatch(numExpected);
     finished = new CountDownLatch(numExpected);
-    startedAttempts = new ArrayList<>();
-    succeededAttempts = new ArrayList<>();
-    failedAttempts = new ArrayList<>();
+    startedAttempts = ImmutableList.builder();
+    succeededAttempts = ImmutableList.builder();
+    failedAttempts = ImmutableList.builder();
   }
 
   Module module() {
@@ -93,7 +91,7 @@
     finish(UpgradeAttempt.create(name, oldVersion, newVersion), failedAttempts);
   }
 
-  private synchronized void finish(UpgradeAttempt a, List<UpgradeAttempt> out) {
+  private synchronized void finish(UpgradeAttempt a, ImmutableList.Builder<UpgradeAttempt> out) {
     checkState(readyToStart.getCount() == 0, "shouldn't be finishing upgrade before starting");
     checkState(
         finished.getCount() > 0, "already finished %s upgrades, can't finish %s", numExpected, a);
@@ -108,14 +106,14 @@
   }
 
   synchronized ImmutableList<UpgradeAttempt> getStartedAttempts() {
-    return ImmutableList.copyOf(startedAttempts);
+    return startedAttempts.build();
   }
 
   synchronized ImmutableList<UpgradeAttempt> getSucceededAttempts() {
-    return ImmutableList.copyOf(succeededAttempts);
+    return succeededAttempts.build();
   }
 
   synchronized ImmutableList<UpgradeAttempt> getFailedAttempts() {
-    return ImmutableList.copyOf(failedAttempts);
+    return failedAttempts.build();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java
new file mode 100644
index 0000000..eec2811
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelConfigToCopyConditionIT.java
@@ -0,0 +1,840 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+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.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 static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.schema.MigrateLabelConfigToCopyCondition;
+import com.google.inject.Inject;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+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 MigrateLabelConfigToCopyConditionIT extends AbstractDaemonTest {
+  private static final ImmutableSet<String> DEPRECATED_FIELDS =
+      ImmutableSet.<String>builder()
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_VALUE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
+          .add(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
+          .add(
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE)
+          .build();
+
+  @Inject private ProjectOperations projectOperations;
+
+  @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.
+      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 this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      u.save();
+    }
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(
+            allowLabel(TestLabels.verified().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // overwrite the default value for copyAllScoresIfNoChange which is true for the migration
+    updateProjectConfig(
+        cfg -> {
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.VERIFIED,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+        });
+  }
+
+  @Test
+  public void nothingToMigrate_noLabels() throws Exception {
+    Project.NameKey projectWithoutLabelDefinitions = projectOperations.newProject().create();
+    RevCommit refsMetaConfigHead =
+        projectOperations.project(projectWithoutLabelDefinitions).getHead(RefNames.REFS_CONFIG);
+
+    runMigration(projectWithoutLabelDefinitions);
+
+    // verify that refs/meta/config was not touched
+    assertThat(
+            projectOperations.project(projectWithoutLabelDefinitions).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void noFieldsToMigrate() throws Exception {
+    assertThat(projectOperations.project(project).getConfig().getSubsections(ProjectConfig.LABEL))
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+
+    // copyAllScoresIfNoChange=false is set in the test setup to override the default value
+    assertDeprecatedFieldsUnset(
+        LabelId.CODE_REVIEW, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    assertDeprecatedFieldsUnset(
+        LabelId.VERIFIED, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+  }
+
+  @Test
+  public void noFieldsToMigrate_copyConditionExists() throws Exception {
+    String copyCondition = "is:MIN";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void noFieldsToMigrate_complexCopyConditionExists() throws Exception {
+    String copyCondition = "is:MIN has:unchanged-files";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed (e.g. no parentheses have been added around
+    // the
+    // copy condition)
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void noFieldsToMigrate_nonOrderedCopyConditionExists() throws Exception {
+    String copyCondition = "is:MIN OR has:unchanged-files";
+    setCopyConditionOnCodeReviewLabel(copyCondition);
+
+    runMigration();
+
+    // verify that copyAllScoresIfNoChange=false (that was set in the test setup to override to
+    // default value) was removed
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // verify that the copy condition was not changed (e.g. the order of OR conditions has not be
+    // changed and no parentheses have been added around the copy condition)
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo(copyCondition);
+  }
+
+  @Test
+  public void migrateCopyAnyScore() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:ANY"));
+  }
+
+  @Test
+  public void migrateCopyMinScore() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:MIN"));
+  }
+
+  @Test
+  public void migrateCopyMaxScore() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:MAX"));
+  }
+
+  @Test
+  public void migrateCopyValues_singleValue() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(1), copyCondition -> assertThat(copyCondition).isEqualTo("is:1"));
+  }
+
+  @Test
+  public void migrateCopyValues_negativeValue() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-1), copyCondition -> assertThat(copyCondition).isEqualTo("is:\"-1\""));
+  }
+
+  @Test
+  public void migrateCopyValues_multipleValues() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-1, 1),
+        copyCondition -> assertThat(copyCondition).isEqualTo("is:\"-1\" OR is:1"));
+  }
+
+  @Test
+  public void migrateCopyValues_manyValues() throws Exception {
+    testCopyValueMigration(
+        ImmutableList.of(-2, -1, 1, 2),
+        copyCondition ->
+            assertThat(copyCondition).isEqualTo("is:\"-1\" OR is:\"-2\" OR is:1 OR is:2"));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfNoCange() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+        copyCondition ->
+            assertThat(copyCondition).isEqualTo("changekind:" + ChangeKind.NO_CHANGE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfNoCodeCange() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.NO_CODE_CHANGE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresOnTrivialRebase() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+        copyCondition ->
+            assertThat(copyCondition)
+                .isEqualTo("changekind:" + ChangeKind.TRIVIAL_REBASE.toString()));
+  }
+
+  @Test
+  public void migrateCopyAllScoresIfListOfFilesDidNotChange() throws Exception {
+    testFlagMigration(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+        copyCondition -> assertThat(copyCondition).isEqualTo("has:unchanged-files"));
+  }
+
+  @Test
+  public void migrateDefaultValues() throws Exception {
+    // remove copyAllScoresIfNoChange=false that was set in the test setup to override to default
+    // value
+    unset(LabelId.CODE_REVIEW, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // expect that the copy condition was set to "changekind:NO_CHANGE" since
+    // copyAllScoresIfNoChange was not set and has true as default value
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE");
+  }
+
+  @Test
+  public void migrateDefaultValues_copyConditionExists() throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:MIN");
+
+    // remove copyAllScoresIfNoChange=false that was set in the test setup to override to default
+    // value
+    unset(LabelId.CODE_REVIEW, MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+
+    // expect that the copy condition includes "changekind:NO_CHANGE" since
+    // copyAllScoresIfNoChange was not set and has true as default value
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE OR is:MIN");
+  }
+
+  @Test
+  public void migrateAll() throws Exception {
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+    setCopyValuesOnCodeReviewLabel(-2, -1, 1, 2);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo(
+            "changekind:MERGE_FIRST_PARENT_UPDATE"
+                + " OR changekind:NO_CHANGE"
+                + " OR changekind:NO_CODE_CHANGE"
+                + " OR changekind:TRIVIAL_REBASE"
+                + " OR has:unchanged-files"
+                + " OR is:\"-1\""
+                + " OR is:\"-2\""
+                + " OR is:1"
+                + " OR is:2"
+                + " OR is:ANY"
+                + " OR is:MAX"
+                + " OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_mutualllyExclusive() throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noDuplicatePredicate()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noDuplicatePredicates()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY OR is:MIN");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("is:ANY OR is:MAX OR is:MIN");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_complexCopyCondition_v1()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("is:ANY changekind:TRIVIAL_REBASE");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(is:ANY changekind:TRIVIAL_REBASE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_complexCopyCondition_v2()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel(
+        "is:ANY AND (changekind:TRIVIAL_REBASE OR changekind:NO_CODE_CHANGE)");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo(
+            "(is:ANY AND (changekind:TRIVIAL_REBASE OR changekind:NO_CODE_CHANGE)) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_noUnnecessaryParenthesesAdded()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("(is:ANY changekind:TRIVIAL_REBASE)");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(is:ANY changekind:TRIVIAL_REBASE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrationMergesFlagsIntoExistingCopyCondition_existingCopyConditionIsNotParseable()
+      throws Exception {
+    setCopyConditionOnCodeReviewLabel("NOT-PARSEABLE");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("NOT-PARSEABLE OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void
+      migrationMergesFlagsIntoExistingCopyCondition_existingComplexCopyConditionIsNotParseable()
+          throws Exception {
+    setCopyConditionOnCodeReviewLabel("NOT PARSEABLE");
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE);
+    setFlagOnCodeReviewLabel(
+        MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel())
+        .isEqualTo("(NOT PARSEABLE) OR changekind:TRIVIAL_REBASE OR is:ANY");
+  }
+
+  @Test
+  public void migrateMultipleLabels() throws Exception {
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+
+    setFlagOnVerifiedLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
+    setFlagOnVerifiedLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE OR is:MIN");
+
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+    assertThat(getCopyConditionOfVerifiedLabel()).isEqualTo("changekind:TRIVIAL_REBASE OR is:MAX");
+  }
+
+  @Test
+  public void deprecatedFlagsThatAreSetToFalseAreUnset() throws Exception {
+    // set all flags to false
+    updateProjectConfig(
+        cfg -> {
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ANY_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
+              /* value= */ false);
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE,
+              /* value= */ false);
+        });
+  }
+
+  @Test
+  public void emptyCopyValueParameterIsUnset() throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setString(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_VALUE,
+                /* value= */ ""));
+
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+  }
+
+  @Test
+  public void migrationCreatesASingleCommit() throws Exception {
+    // Set flags on 2 labels (the migrations for both labels are expected to be done in a single
+    // commit)
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+    setFlagOnVerifiedLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MAX_SCORE);
+
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    runMigration();
+
+    // verify that the new commit in refs/meta/config is a successor of the old head
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getParent(0))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void commitMessageIsDistinct() throws Exception {
+    // Set a flag so that the migration has to do something.
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    // Verify that the commit message is distinct (e.g. this is important in case there is an issue
+    // with the migration, having a distinct commit message allows to identify the commit that was
+    // done for the migration and would allow to revert it)
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(refsMetaConfigHead.getShortMessage())
+        .isEqualTo(MigrateLabelConfigToCopyCondition.COMMIT_MESSAGE);
+  }
+
+  @Test
+  public void gerritIsAuthorAndCommitterOfTheMigrationCommit() throws Exception {
+    // Set a flag so that the migration has to do something.
+    setFlagOnCodeReviewLabel(MigrateLabelConfigToCopyCondition.KEY_COPY_MIN_SCORE);
+
+    runMigration();
+
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(refsMetaConfigHead.getAuthorIdent().getEmailAddress())
+        .isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(refsMetaConfigHead.getAuthorIdent().getName())
+        .isEqualTo(serverIdent.get().getName());
+    assertThat(refsMetaConfigHead.getCommitterIdent())
+        .isEqualTo(refsMetaConfigHead.getAuthorIdent());
+  }
+
+  @Test
+  public void migrationFailsIfProjectConfigIsNotParseable() throws Exception {
+    projectOperations.project(project).forInvalidation().makeProjectConfigInvalid().invalidate();
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    ConfigInvalidException exception =
+        assertThrows(ConfigInvalidException.class, () -> runMigration());
+    assertThat(exception)
+        .hasMessageThat()
+        .contains(String.format("Invalid config file project.config in project %s", project));
+
+    // verify that refs/meta/config was not touched
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void migrateWhenProjectConfigIsMissing() throws Exception {
+    deleteProjectConfig();
+    RevCommit refsMetaConfigHead = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+
+    runMigration();
+
+    // verify that refs/meta/config was not touched (e.g. project.config was not created)
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+        .isEqualTo(refsMetaConfigHead);
+  }
+
+  @Test
+  public void migrateWhenRefsMetaConfigIsMissing() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.delete(RefNames.REFS_CONFIG);
+    }
+
+    runMigration();
+
+    // verify that refs/meta/config was not created
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      assertThat(testRepo.getRepository().exactRef(RefNames.REFS_CONFIG)).isNull();
+    }
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsUnset() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ null);
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsFalse() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ false);
+  }
+
+  @Test
+  public void migrationIsIdempotent_copyAllScoresIfNoChangeIsTrue() throws Exception {
+    testMigrationIsIdempotent(/* copyAllScoresIfNoChangeValue= */ true);
+  }
+
+  private void testMigrationIsIdempotent(@Nullable Boolean copyAllScoresIfNoChangeValue)
+      throws Exception {
+    updateProjectConfig(
+        cfg -> {
+          if (copyAllScoresIfNoChangeValue != null) {
+            cfg.setBoolean(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+                copyAllScoresIfNoChangeValue);
+            cfg.setBoolean(
+                ProjectConfig.LABEL,
+                LabelId.VERIFIED,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+                copyAllScoresIfNoChangeValue);
+          } else {
+            cfg.unset(
+                ProjectConfig.LABEL,
+                LabelId.CODE_REVIEW,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+            cfg.unset(
+                ProjectConfig.LABEL,
+                LabelId.VERIFIED,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_ALL_SCORES_IF_NO_CHANGE);
+          }
+        });
+
+    // Run the migration to update the label configuration.
+    runMigration();
+
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    assertDeprecatedFieldsUnset(LabelId.VERIFIED);
+
+    // default value for copyAllScoresIfNoChangeValue is true
+    if (copyAllScoresIfNoChangeValue == null || copyAllScoresIfNoChangeValue) {
+      assertThat(getCopyConditionOfCodeReviewLabel()).isEqualTo("changekind:NO_CHANGE");
+    } else {
+      assertThat(getCopyConditionOfCodeReviewLabel()).isNull();
+    }
+
+    // Running the migration again doesn't change anything.
+    RevCommit head = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    runMigration();
+    assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG)).isEqualTo(head);
+  }
+
+  private void testFlagMigration(String key, Consumer<String> copyConditionValidator)
+      throws Exception {
+    setFlagOnCodeReviewLabel(key);
+    runMigration();
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    copyConditionValidator.accept(getCopyConditionOfCodeReviewLabel());
+  }
+
+  private void testCopyValueMigration(List<Integer> values, Consumer<String> copyConditionValidator)
+      throws Exception {
+    setCopyValuesOnCodeReviewLabel(values.toArray(new Integer[0]));
+    runMigration();
+    assertDeprecatedFieldsUnset(LabelId.CODE_REVIEW);
+    copyConditionValidator.accept(getCopyConditionOfCodeReviewLabel());
+  }
+
+  private void runMigration() throws Exception {
+    runMigration(project);
+  }
+
+  private void runMigration(Project.NameKey project) throws Exception {
+    new MigrateLabelConfigToCopyCondition(repoManager, serverIdent.get()).execute(project);
+  }
+
+  private void setFlagOnCodeReviewLabel(String key) throws Exception {
+    setFlag(LabelId.CODE_REVIEW, key);
+  }
+
+  private void setFlagOnVerifiedLabel(String key) throws Exception {
+    setFlag(LabelId.VERIFIED, key);
+  }
+
+  private void setFlag(String labelName, String key) throws Exception {
+    updateProjectConfig(
+        cfg -> cfg.setBoolean(ProjectConfig.LABEL, labelName, key, /* value= */ true));
+  }
+
+  private void unset(String labelName, String key) throws Exception {
+    updateProjectConfig(cfg -> cfg.unset(ProjectConfig.LABEL, labelName, key));
+  }
+
+  private void setCopyValuesOnCodeReviewLabel(Integer... values) throws Exception {
+    setCopyValues(LabelId.CODE_REVIEW, values);
+  }
+
+  private void setCopyValues(String labelName, Integer... values) throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setStringList(
+                ProjectConfig.LABEL,
+                labelName,
+                MigrateLabelConfigToCopyCondition.KEY_COPY_VALUE,
+                Arrays.stream(values).map(Object::toString).collect(toImmutableList())));
+  }
+
+  private void setCopyConditionOnCodeReviewLabel(String copyCondition) throws Exception {
+    setCopyCondition(LabelId.CODE_REVIEW, copyCondition);
+  }
+
+  private void setCopyCondition(String labelName, String copyCondition) throws Exception {
+    updateProjectConfig(
+        cfg ->
+            cfg.setString(
+                ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION, copyCondition));
+  }
+
+  private void updateProjectConfig(Consumer<Config> configUpdater) throws Exception {
+    Config projectConfig = projectOperations.project(project).getConfig();
+    configUpdater.accept(projectConfig);
+    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("Update label config")
+              .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+    }
+    projectCache.evictAndReindex(project);
+  }
+
+  private void deleteProjectConfig() 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("Update label config")
+              .rm(ProjectConfig.PROJECT_CONFIG));
+    }
+    projectCache.evictAndReindex(project);
+  }
+
+  private void assertDeprecatedFieldsUnset(String labelName, String... excludedFields) {
+    for (String field :
+        Sets.difference(DEPRECATED_FIELDS, Sets.newHashSet(Arrays.asList(excludedFields)))) {
+      assertUnset(labelName, field);
+    }
+  }
+
+  private void assertUnset(String labelName, String key) {
+    assertThat(
+            projectOperations.project(project).getConfig().getNames(ProjectConfig.LABEL, labelName))
+        .doesNotContain(key);
+  }
+
+  private String getCopyConditionOfCodeReviewLabel() {
+    return getCopyCondition(LabelId.CODE_REVIEW);
+  }
+
+  private String getCopyConditionOfVerifiedLabel() {
+    return getCopyCondition(LabelId.VERIFIED);
+  }
+
+  private String getCopyCondition(String labelName) {
+    return projectOperations
+        .project(project)
+        .getConfig()
+        .getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
new file mode 100644
index 0000000..9d37497
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -0,0 +1,556 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement;
+import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement.Status;
+import com.google.gerrit.server.schema.UpdateUI;
+import com.google.inject.Inject;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Test for {@link com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement}. */
+@Sandboxed
+public class MigrateLabelFunctionsToSubmitRequirementIT extends AbstractDaemonTest {
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void migrateBlockingLabel_maxWithBlock() throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_maxNoBlock() throws Exception {
+    createLabel("Foo", "MaxNoBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_anyWithBlock() throws Exception {
+    createLabel("Foo", "AnyWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "-label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_maxWithBlock_withIgnoreSelfApproval() throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ true);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX,user=non_uploader AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_maxNoBlock_withIgnoreSelfApproval() throws Exception {
+    createLabel("Foo", "MaxNoBlock", /* ignoreSelfApproval= */ true);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX,user=non_uploader",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateNonBlockingLabel_noBlock() throws Exception {
+    // NoBlock labels are left as is, i.e. we don't create a "submit requirement" for them. Those
+    // labels will then be treated as trigger votes in the change page.
+    createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.NO_CHANGE);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // No SR was created for the label. Label will be treated as a trigger vote.
+    assertNonExistentSr("Foo");
+    // Label function has not changed.
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateNonBlockingLabel_noOp() throws Exception {
+    // NoOp labels are left as is, i.e. we don't create a "submit requirement" for them. Those
+    // labels will then be treated as trigger votes in the change page.
+    createLabel("Foo", "NoOp", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // No SR was created for the label. Label will be treated as a trigger vote.
+    assertNonExistentSr("Foo");
+    // The NoOp function is converted to NoBlock. Both are same.
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateNoBlockLabel_withSingleZeroValue() throws Exception {
+    // Labels that have a single "zero" value are skipped in the project. The migrator creates
+    // non-applicable SR for these labels.
+    createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false, ImmutableMap.of("0", "No vote"));
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // a non-applicable SR was created for the skipped label.
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "is:false",
+        /* submittabilityExpression= */ "is:true",
+        /* canOverride= */ true);
+
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateMaxWithBlockLabel_withSingleZeroValue() throws Exception {
+    // Labels that have a single "zero" value are skipped in the project. The migrator creates
+    // non-applicable SRs for these labels.
+    createLabel(
+        "Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false, ImmutableMap.of("0", "No vote"));
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // a non-applicable SR was created for the skipped label.
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "is:false",
+        /* submittabilityExpression= */ "is:true",
+        /* canOverride= */ true);
+
+    // The MaxWithBlock function is converted to NoBlock. This has no effect anyway because the
+    // label was originally skipped.
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void cannotCreateLabelsWithNoValues() {
+    // This test just asserts the server's behaviour for visibility; admins cannot create a label
+    // without any defined values.
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false, ImmutableMap.of()));
+    assertThat(thrown).hasMessageThat().isEqualTo("values are required");
+  }
+
+  @Test
+  public void migrateNonBlockingLabel_patchSetLock_doesNothing() throws Exception {
+    createLabel("Foo", "PatchSetLock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.NO_CHANGE);
+    // No submit requirement created for the patchset lock label function
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertNonExistentSr(/* srName = */ "Foo");
+    assertLabelFunction("Foo", "PatchSetLock");
+  }
+
+  @Test
+  public void migrationIsCommittedWithServerIdent() throws Exception {
+    RevCommit oldMetaCommit = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+
+    RevCommit newMetaCommit = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+    assertThat(newMetaCommit).isNotEqualTo(oldMetaCommit);
+    assertThat(newMetaCommit.getCommitterIdent().getEmailAddress())
+        .isEqualTo(serverIdent.get().getEmailAddress());
+  }
+
+  @Test
+  public void migrateBlockingLabel_withBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withMultipleBranchAttributes() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master", "refs/heads/develop"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+            + "OR branch:\\\"refs/heads/develop\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withRegexBranchAttribute() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("^refs/heads/main-.*"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"^refs/heads/main-.*\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrateBlockingLabel_withRegexAndNonRegexBranchAttributes() throws Exception {
+    createLabelWithBranch(
+        "Foo",
+        "MaxWithBlock",
+        /* ignoreSelfApproval= */ false,
+        ImmutableList.of("refs/heads/master", "^refs/heads/main-.*"));
+
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+            + "OR branch:\\\"^refs/heads/main-.*\\\"",
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  @Test
+  public void migrationIsIdempotent() throws Exception {
+    String oldRefsConfigId;
+    try (Repository repo = repoManager.openRepository(project)) {
+      oldRefsConfigId = repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString();
+    }
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    assertNonExistentSr(/* srName = */ "Foo");
+
+    // Running the migration causes REFS_CONFIG to change.
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(oldRefsConfigId)
+          .isNotEqualTo(repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString());
+      oldRefsConfigId = repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString();
+    }
+
+    // No new SRs will be created. No conflicting submit requirements either since the migration
+    // detects that a previous run was made and skips the migration.
+    updateUI = runMigration(/* expectedResult= */ Status.PREVIOUSLY_MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+    // Running the migration a second time won't update REFS_CONFIG.
+    try (Repository repo = repoManager.openRepository(project)) {
+      assertThat(oldRefsConfigId)
+          .isEqualTo(repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString());
+    }
+  }
+
+  @Test
+  public void migrationDoesNotCreateANewSubmitRequirement_ifSRAlreadyExists_matchingWithMigration()
+      throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    createSubmitRequirement("Foo", "label:Foo=MAX AND -label:Foo=MIN", /* canOverride= */ true);
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    // No new submit requirements are created.
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+    // No conflicting submit requirements from migration vs. what was previously configured.
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+    // The existing SR was left as is.
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+        /* canOverride= */ true);
+  }
+
+  @Test
+  public void
+      migrationDoesNotCreateANewSubmitRequirement_ifSRAlreadyExists_mismatchingWithMigration()
+          throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    createSubmitRequirement("Foo", "project:" + project, /* canOverride= */ true);
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "project:" + project,
+        /* canOverride= */ true);
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    // One conflicting submit requirement between migration vs. what was previously configured.
+    assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(1);
+
+    // The existing SR was left as is.
+    assertExistentSr(
+        /* srName */ "Foo",
+        /* applicabilityExpression= */ null,
+        /* submittabilityExpression= */ "project:" + project,
+        /* canOverride= */ true);
+  }
+
+  @Test
+  public void migrationResetsBlockingLabel_ifSRAlreadyExists() throws Exception {
+    createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+    createSubmitRequirement("Foo", "owner:" + admin.email(), /* canOverride= */ true);
+
+    TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+    assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+
+    // The label function was reset
+    assertLabelFunction("Foo", "NoBlock");
+  }
+
+  private TestUpdateUI runMigration(Status expectedResult) throws Exception {
+    TestUpdateUI updateUi = new TestUpdateUI();
+    MigrateLabelFunctionsToSubmitRequirement executor =
+        new MigrateLabelFunctionsToSubmitRequirement(repoManager, serverIdent.get());
+    Status status = executor.executeMigration(project, updateUi);
+    assertThat(status).isEqualTo(expectedResult);
+    projectCache.evictAndReindex(project);
+    return updateUi;
+  }
+
+  private void createLabel(String labelName, String function, boolean ignoreSelfApproval)
+      throws Exception {
+    createLabel(
+        labelName,
+        function,
+        ignoreSelfApproval,
+        ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad"));
+  }
+
+  private void createLabel(
+      String labelName, String function, boolean ignoreSelfApproval, Map<String, String> values)
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = labelName;
+    input.function = function;
+    input.ignoreSelfApproval = ignoreSelfApproval;
+    input.values = values;
+    gApi.projects().name(project.get()).label(labelName).create(input);
+  }
+
+  private void createLabelWithBranch(
+      String labelName,
+      String function,
+      boolean ignoreSelfApproval,
+      ImmutableList<String> refPatterns)
+      throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.name = labelName;
+    input.function = function;
+    input.ignoreSelfApproval = ignoreSelfApproval;
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.branches = refPatterns;
+    gApi.projects().name(project.get()).label(labelName).create(input);
+  }
+
+  @CanIgnoreReturnValue
+  private SubmitRequirementApi createSubmitRequirement(
+      String name, String submitExpression, boolean canOverride) throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.submittabilityExpression = submitExpression;
+    input.allowOverrideInChildProjects = canOverride;
+    return gApi.projects().name(project.get()).submitRequirement(name).create(input);
+  }
+
+  private void assertLabelFunction(String labelName, String function) throws Exception {
+    LabelDefinitionInfo info = gApi.projects().name(project.get()).label(labelName).get();
+    assertThat(info.function).isEqualTo(function);
+  }
+
+  private void assertNonExistentSr(String srName) {
+    ResourceNotFoundException foo =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.projects().name(project.get()).submitRequirement("Foo").get());
+    assertThat(foo.getMessage()).isEqualTo("Submit requirement '" + srName + "' does not exist");
+  }
+
+  private void assertExistentSr(
+      String srName,
+      String applicabilityExpression,
+      String submittabilityExpression,
+      boolean canOverride)
+      throws Exception {
+    SubmitRequirementInfo sr = gApi.projects().name(project.get()).submitRequirement(srName).get();
+    assertThat(sr.applicabilityExpression).isEqualTo(applicabilityExpression);
+    assertThat(sr.submittabilityExpression).isEqualTo(submittabilityExpression);
+    assertThat(sr.allowOverrideInChildProjects).isEqualTo(canOverride);
+  }
+
+  private static class TestUpdateUI implements UpdateUI {
+    int existingSrsMismatchingWithMigration = 0;
+    int newlyCreatedSrs = 0;
+
+    @Override
+    public void message(String message) {
+      if (message.startsWith("Warning")) {
+        existingSrsMismatchingWithMigration += 1;
+      } else if (message.startsWith("Project")) {
+        newlyCreatedSrs += 1;
+      }
+    }
+
+    @Override
+    public boolean yesno(boolean defaultValue, String message) {
+      return false;
+    }
+
+    @Override
+    public void waitForUser() {}
+
+    @Override
+    public String readString(String defaultValue, Set<String> allowedValues, String message) {
+      return null;
+    }
+
+    @Override
+    public boolean isBatch() {
+      return false;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
index 093711f..fd9054c 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
@@ -1,4 +1,4 @@
-// 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.
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_185IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_185IT.java
new file mode 100644
index 0000000..afaf530
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_185IT.java
@@ -0,0 +1,261 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.schema.MigrateLabelConfigToCopyCondition;
+import com.google.gerrit.server.schema.NoteDbSchemaVersion;
+import com.google.gerrit.server.schema.Schema_185;
+import com.google.gerrit.testing.TestUpdateUI;
+import com.google.inject.Inject;
+import java.util.function.Consumer;
+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.Test;
+
+@Sandboxed
+public class Schema_185IT extends AbstractDaemonTest {
+  private static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
+  private static final String KEY_COPY_MIN_SCORE = "copyMinScore";
+
+  @Inject private ProjectOperations projectOperations;
+  @Inject private NoteDbSchemaVersion.Arguments args;
+
+  private final TestUpdateUI testUpdateUI = new TestUpdateUI();
+
+  @Test
+  public void nothingToMigrate() throws Exception {
+    RevCommit oldHeadAllProjects = getHead(allProjects);
+    RevCommit oldHeadAllUsers = getHead(allUsers);
+    RevCommit oldHeadProject = getHead(project);
+
+    runMigration();
+
+    // All-Projects and All-Users both contain a label definition for Code-Review but without
+    // boolean flags, hence those don't need to be migrated (the migration assumes true for
+    // copyAllScoresIfNoChange if unset, but the copyCondition already contains
+    // 'changekind:NO_CHANGE' so copyCondition doesn't need to be changed).
+    assertThatMigrationHasNotRun(allProjects, oldHeadAllProjects);
+    assertThatMigrationHasNotRun(allUsers, oldHeadAllUsers);
+
+    // Check that the migration was not executed for the projects that do not contain label
+    // definitions.
+    assertThatMigrationHasNotRun(project, oldHeadProject);
+  }
+
+  @Test
+  public void labelConfigsAreMigrated() throws Exception {
+    addLabelThatNeedsToBeMigrated(project);
+
+    RevCommit projectOldHead = getHead(project);
+    runMigration();
+    assertThatMigrationHasRun(project, projectOldHead);
+  }
+
+  @Test
+  public void upgradeIsIdempotent() throws Exception {
+    addLabelThatNeedsToBeMigrated(project);
+
+    // Run the migration to update the label configuration.
+    runMigration();
+
+    // Running the migration again, doesn't change anything.
+    RevCommit projectOldHead = getHead(project);
+    runMigration();
+    assertThatMigrationHasNotRun(project, projectOldHead);
+  }
+
+  @Test
+  public void upgradeIsIdempotent_onlyDefaultFlagIsMigrated() throws Exception {
+    addLabelThatNeedsToBeMigratedDueToDefaultFlag(project);
+
+    // Run the migration to update the label configuration.
+    runMigration();
+
+    // Running the migration again, doesn't change anything.
+    RevCommit projectOldHead = getHead(project);
+    runMigration();
+    assertThatMigrationHasNotRun(project, projectOldHead);
+  }
+
+  @Test
+  public void migrateMultipleProjects() throws Exception {
+    Project.NameKey project1 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    Project.NameKey project2 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    Project.NameKey project3 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+
+    RevCommit oldHeadProject1 = getHead(project1);
+    RevCommit oldHeadProject2 = getHead(project2);
+    RevCommit oldHeadProject3 = getHead(project3);
+
+    runMigration();
+
+    assertThatMigrationHasRun(project1, oldHeadProject1);
+    assertThatMigrationHasRun(project2, oldHeadProject2);
+    assertThatMigrationHasRun(project3, oldHeadProject3);
+  }
+
+  @Test
+  public void migrationPrintsOutProgress() throws Exception {
+    // Create 197 projects so that in total we have 200 projects (197 + All-Projects + All-Users +
+    // test project).
+    for (int i = 0; i < 197; i++) {
+      createProjectWithLabelConfigThatNeedsToBeMigrated();
+    }
+
+    runMigration();
+    String output = testUpdateUI.getOutput();
+    assertThat(output).contains("Migrating label configurations");
+    assertThat(output).contains("migrated label configurations of 50% (100/200) projects");
+    assertThat(output).contains("migrated label configurations of 100% (200/200) projects");
+    assertThat(output).contains("Migrated label configurations of all 200 projects to schema 185");
+  }
+
+  @Test
+  public void projectsWithInvalidConfigurationAreSkipped() throws Exception {
+    Project.NameKey projectWithInvalidConfig = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    projectOperations
+        .project(projectWithInvalidConfig)
+        .forInvalidation()
+        .makeProjectConfigInvalid()
+        .invalidate();
+
+    Project.NameKey otherProject1 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+    Project.NameKey otherProject2 = createProjectWithLabelConfigThatNeedsToBeMigrated();
+
+    RevCommit oldHeadProjectWithInvalidConfig = getHead(projectWithInvalidConfig);
+    RevCommit oldHeadOtherProject1 = getHead(otherProject1);
+    RevCommit oldHeadOtherProject2 = getHead(otherProject2);
+
+    runMigration();
+
+    assertThatMigrationHasNotRun(projectWithInvalidConfig, oldHeadProjectWithInvalidConfig);
+    assertThatMigrationHasRun(otherProject1, oldHeadOtherProject1);
+    assertThatMigrationHasRun(otherProject2, oldHeadOtherProject2);
+
+    String output = testUpdateUI.getOutput();
+    assertThat(output)
+        .contains(
+            String.format(
+                "WARNING: Skipping migration of label configurations for project %s"
+                    + " since its %s file is invalid:",
+                projectWithInvalidConfig, ProjectConfig.PROJECT_CONFIG));
+  }
+
+  private void runMigration() throws Exception {
+    Schema_185 upgrade = new Schema_185();
+    upgrade.upgrade(args, testUpdateUI);
+  }
+
+  private RevCommit getHead(Project.NameKey project) {
+    return projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+  }
+
+  private void assertThatMigrationHasRun(Project.NameKey project, RevCommit oldHead) {
+    RevCommit newHead = getHead(project);
+    assertThat(getHead(project)).isNotEqualTo(oldHead);
+    assertThat(newHead.getShortMessage())
+        .isEqualTo(MigrateLabelConfigToCopyCondition.COMMIT_MESSAGE);
+  }
+
+  private void assertThatMigrationHasNotRun(Project.NameKey project, RevCommit oldHead) {
+    assertThat(getHead(project)).isEqualTo(oldHead);
+  }
+
+  private Project.NameKey createProjectWithLabelConfigThatNeedsToBeMigrated() throws Exception {
+    Project.NameKey project = projectOperations.newProject().create();
+    addLabelThatNeedsToBeMigrated(project);
+    return project;
+  }
+
+  private void addLabelThatNeedsToBeMigrated(Project.NameKey project) throws Exception {
+    // create a label which needs to be migrated because flags have been set
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      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 this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    updateProjectConfig(
+        cfg -> {
+          // override the default value
+          cfg.setBoolean(
+              ProjectConfig.LABEL,
+              LabelId.CODE_REVIEW,
+              KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
+              /* value= */ false);
+          // set random flag
+          cfg.setBoolean(
+              ProjectConfig.LABEL, LabelId.CODE_REVIEW, KEY_COPY_MIN_SCORE, /* value= */ true);
+        });
+  }
+
+  private void addLabelThatNeedsToBeMigratedDueToDefaultFlag(Project.NameKey project)
+      throws Exception {
+    // create a label which needs to be migrated (copyAllScoresIfNoChange is unset, the migration
+    // assumes true as default and hence sets copyCondition to "changekind:NO_CHANGE").
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      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 this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+  }
+
+  private void updateProjectConfig(Consumer<Config> configUpdater) throws Exception {
+    Config projectConfig = projectOperations.project(project).getConfig();
+    configUpdater.accept(projectConfig);
+    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("Update label config")
+              .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+    }
+    projectCache.evictAndReindex(project);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java b/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java
new file mode 100644
index 0000000..39f1e8d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.restapi.project.ListProjects;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.AbstractModule;
+import java.util.Map;
+import org.junit.Test;
+
+public class DynamicOptionsBeanParseListenerIT extends AbstractDaemonTest {
+  private static final Gson GSON = OutputFormat.JSON.newGson();
+
+  @Test
+  public void testBeanParseListener() throws Exception {
+    createProjectOverAPI("project1", project, true, null);
+    createProjectOverAPI("project2", project, true, null);
+    try (AutoCloseable ignored = installPlugin("my-plugin", PluginModule.class)) {
+      assertThat(getProjects(adminRestSession.get("/projects/"))).hasSize(1);
+    }
+  }
+
+  protected Map<String, Object> getProjects(RestResponse res) throws Exception {
+    res.assertOK();
+    return GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+  }
+
+  protected static class ListProjectsBeanListener implements DynamicOptions.BeanParseListener {
+    @Override
+    public void onBeanParseStart(String plugin, Object bean) {
+      ListProjects listProjects = (ListProjects) bean;
+      listProjects.setLimit(1);
+    }
+
+    @Override
+    public void onBeanParseEnd(String plugin, Object bean) {}
+  }
+
+  protected static class PluginModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(ListProjects.class))
+          .to(ListProjectsBeanListener.class);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index 4efdbba..a0ae91b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -417,6 +417,16 @@
     }
   }
 
+  @Test
+  public void requestsOnRootCollectionDontRequireTrailingSlash() throws Exception {
+    adminRestSession.get("/access").assertOK();
+    adminRestSession.get("/accounts?q=is:active").assertOK();
+    adminRestSession.get("/changes?q=status:open").assertOK();
+    // GET on /config/ is not supported, hence we cannot test GET on /config
+    adminRestSession.get("/groups").assertOK();
+    adminRestSession.get("/projects").assertOK();
+  }
+
   private ObjectId getMetaRefSha1(Result change) {
     return change.getChange().notes().getRevision();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 7e40b2b..9710bf4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -43,6 +43,7 @@
 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;
@@ -374,6 +375,7 @@
   }
 
   @Test
+  @GerritConfig(name = "tracing.performanceLogging", value = "true")
   public void performanceLoggingForRestCall() throws Exception {
     PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
@@ -385,6 +387,7 @@
   }
 
   @Test
+  @GerritConfig(name = "tracing.performanceLogging", value = "true")
   public void performanceLoggingForPush() throws Exception {
     PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
@@ -397,8 +400,7 @@
   }
 
   @Test
-  @GerritConfig(name = "tracing.performanceLogging", value = "false")
-  public void noPerformanceLoggingIfDisabled() throws Exception {
+  public void noPerformanceLoggingByDefault() throws Exception {
     PerformanceLogger testPerformanceLogger = mock(PerformanceLogger.class);
     try (Registration registration =
         extensionRegistry.newRegistration().add(testPerformanceLogger)) {
@@ -800,7 +802,7 @@
 
   @Test
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
-  public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
+  public void autoRetryWithTrace() throws Exception {
     String changeId = createChange().getChangeId();
     approve(changeId);
 
@@ -810,6 +812,49 @@
       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();
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
index be4fde0..e1e5b85 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilitiesIT.java
@@ -57,7 +57,7 @@
       RestResponse r = userRestSession.get("/accounts/self/capabilities");
       r.assertOK();
       CapabilityInfo info =
-          (new Gson()).fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
+          new Gson().fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
       for (String c : GlobalCapability.getAllNames()) {
         if (ADMINISTRATE_SERVER.equals(c)) {
           assertThat(info.administrateServer).isFalse();
@@ -87,7 +87,7 @@
     RestResponse r = adminRestSession.get("/accounts/self/capabilities");
     r.assertOK();
     CapabilityInfo info =
-        (new Gson()).fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
+        new Gson().fromJson(r.getReader(), new TypeToken<CapabilityInfo>() {}.getType());
     for (String c : GlobalCapability.getAllNames()) {
       if (BATCH_CHANGES_LIMIT.equals(c)) {
         // It does not have default value for any user as it can override the
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/DeleteSshKeyIT.java b/javatests/com/google/gerrit/acceptance/rest/account/DeleteSshKeyIT.java
new file mode 100644
index 0000000..4e9b2af
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/account/DeleteSshKeyIT.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.restapi.account.DeleteSshKey;
+import com.google.inject.Inject;
+import java.security.KeyPair;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class DeleteSshKeyIT extends AbstractDaemonTest {
+
+  private static final String KEY1 =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS"
+          + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28"
+          + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
+          + "w== john.doe@example.com";
+
+  @Inject VersionedAuthorizedKeys.Accessor authorizedKeys;
+  @Inject DeleteSshKey deleteSshKey;
+
+  private AccountSshKey userSshKey;
+  private AccountSshKey adminSshKey;
+
+  @Before
+  public void setup() throws Exception {
+    addUserSshKeys();
+    addAdminSshKeys();
+  }
+
+  @Test
+  @UseSsh
+  public void assertUsersHaveSshKeysPreconditions() throws Exception {
+    List<AccountSshKey> userSshKeys = authorizedKeys.getKeys(user.id());
+    assertThat(userSshKeys).containsExactly(userSshKey, AccountSshKey.create(user.id(), 2, KEY1));
+    List<AccountSshKey> adminSshKeys = authorizedKeys.getKeys(admin.id());
+    assertThat(adminSshKeys).containsExactly(adminSshKey);
+  }
+
+  @Test
+  @UseSsh
+  public void deleteSshKeyRestApi() throws Exception {
+    gApi.accounts().id(user.id().get()).deleteSshKey(userSshKey.seq());
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void adminCanDeleteUserSshKey() throws Exception {
+    adminRestSession
+        .delete(String.format("/accounts/%s/sshkeys/%d", user.id(), userSshKey.seq()))
+        .assertNoContent();
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void deleteSshKeyOnBehalf() throws Exception {
+    assertThat(deleteSshKey.apply(identifiedUserFactory.create(user.id()), userSshKey))
+        .isEqualTo(Response.none());
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void userCanDeleteOwnSshKey() throws Exception {
+    userRestSession
+        .delete(String.format("/accounts/self/sshkeys/%d", userSshKey.seq()))
+        .assertNoContent();
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(user.id());
+    assertThat(sshKeysAfterDel).containsExactly(AccountSshKey.create(user.id(), 2, KEY1));
+  }
+
+  @Test
+  @UseSsh
+  public void userCannotDeleteOtherUsersSshKey() throws Exception {
+    userRestSession
+        .delete(String.format("/accounts/%s/sshkeys/%d", admin.id(), adminSshKey.seq()))
+        .assertNotFound();
+    List<AccountSshKey> sshKeysAfterDel = authorizedKeys.getKeys(admin.id());
+    assertThat(sshKeysAfterDel).containsExactly(adminSshKey);
+  }
+
+  private void addUserSshKeys() throws Exception {
+    KeyPair keyPair = sshKeys.getKeyPair(user);
+    userSshKey =
+        authorizedKeys.addKey(
+            user(user).getAccountId(), TestSshKeys.publicKey(keyPair, user.email()));
+    gApi.accounts().id(user.id().get()).addSshKey(KEY1);
+  }
+
+  private void addAdminSshKeys() throws Exception {
+    KeyPair keyPair = sshKeys.getKeyPair(admin);
+    adminSshKey =
+        authorizedKeys.addKey(
+            user(admin).getAccountId(), TestSshKeys.publicKey(keyPair, admin.email()));
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index cd123aa..39a32af 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
-import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
@@ -66,7 +65,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.testing.ConfigSuite;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -83,7 +81,6 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 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.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
@@ -93,6 +90,10 @@
 import org.junit.Test;
 
 public class ExternalIdIT extends AbstractDaemonTest {
+  private static final boolean IS_USER_NAME_CASE_INSENSITIVE_MIGRATION_MODE = false;
+  private static final boolean CASE_SENSITIVE_USERNAME = false;
+  private static final boolean CASE_INSENSITIVE_USERNAME = true;
+
   @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private ExternalIds externalIds;
   @Inject private ExternalIdReader externalIdReader;
@@ -102,20 +103,6 @@
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
   @Inject private ExternalIdFactory externalIdFactory;
 
-  @ConfigSuite.Default
-  public static Config partialCacheReloadingEnabled() {
-    Config cfg = new Config();
-    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", true);
-    return cfg;
-  }
-
-  @ConfigSuite.Config
-  public static Config partialCacheReloadingDisabled() {
-    Config cfg = new Config();
-    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", false);
-    return cfg;
-  }
-
   @Test
   public void getExternalIds() throws Exception {
     Collection<ExternalId> expectedIds = getAccountState(user.id()).externalIds();
@@ -728,38 +715,7 @@
   }
 
   @Test
-  public void checkNoReloadAfterUpdate() throws Exception {
-    Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(admin.id()));
-    try (AutoCloseable ctx = createFailOnLoadContext()) {
-      // insert external ID
-      ExternalId extId = externalIdFactory.create("foo", "bar", admin.id());
-      insertExtId(extId);
-      expectedExtIds.add(extId);
-      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
-
-      // update external ID
-      expectedExtIds.remove(extId);
-      ExternalId extId2 =
-          externalIdFactory.createWithEmail("foo", "bar", admin.id(), "foo.bar@example.com");
-      accountsUpdateProvider
-          .get()
-          .update("Update External ID", admin.id(), u -> u.updateExternalId(extId2));
-      expectedExtIds.add(extId2);
-      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
-
-      // delete external ID
-      accountsUpdateProvider
-          .get()
-          .update("Delete External ID", admin.id(), u -> u.deleteExternalId(extId));
-      expectedExtIds.remove(extId2);
-      assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
-    }
-  }
-
-  @Test
   public void byAccountFailIfReadingExternalIdsFails() throws Exception {
-    assume().that(isPartialCacheReloadingEnabled()).isFalse();
-
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
       insertExtIdBehindGerritsBack(externalIdFactory.create("foo", "bar", admin.id()));
@@ -770,8 +726,6 @@
 
   @Test
   public void byEmailFailIfReadingExternalIdsFails() throws Exception {
-    assume().that(isPartialCacheReloadingEnabled()).isFalse();
-
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
       insertExtIdBehindGerritsBack(externalIdFactory.create("foo", "bar", admin.id()));
@@ -852,6 +806,131 @@
 
   @Test
   @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void createCaseInsensitiveMigrationModeExternalIdBeforeTheMigration() throws Exception {
+    Account.Id accountId = Account.id(66);
+    boolean isUserNameCaseInsensitive = false;
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JaneDoe", accountId, isUserNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JaneDoe", accountId, isUserNameCaseInsensitive);
+
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "JaneDoe")).isEqualTo(accountId.get());
+      assertThat(getExternalId(extIdNotes, SCHEME_GERRIT, "janedoe").isPresent()).isFalse();
+
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "JaneDoe")).isEqualTo(accountId.get());
+      assertThat(getExternalId(extIdNotes, SCHEME_USERNAME, "janedoe").isPresent()).isFalse();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void createCaseInsensitiveMigrationModeExternalIdAccountAfterTheMigration()
+      throws Exception {
+    Account.Id accountId = Account.id(66);
+    boolean isUserNameCaseInsensitive = true;
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JaneDoe", accountId, isUserNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JaneDoe", accountId, isUserNameCaseInsensitive);
+
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "JaneDoe")).isEqualTo(accountId.get());
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "janedoe")).isEqualTo(accountId.get());
+
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "JaneDoe")).isEqualTo(accountId.get());
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "janedoe")).isEqualTo(accountId.get());
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldTolerateDuplicateExternalIdsWhenInMigrationMode() throws Exception {
+    Account.Id firstAccountId = Account.id(1);
+    Account.Id secondAccountId = Account.id(2);
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "janedoe", firstAccountId, CASE_SENSITIVE_USERNAME);
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JaneDoe", secondAccountId, CASE_SENSITIVE_USERNAME);
+
+      ExternalId.Key firstAccountExternalId =
+          externalIdKeyFactory.create(SCHEME_GERRIT, "janedoe", CASE_INSENSITIVE_USERNAME);
+      assertThat(externalIds.get(firstAccountExternalId).get().accountId())
+          .isEqualTo(firstAccountId);
+
+      ExternalId.Key secondAccountExternalId =
+          externalIdKeyFactory.create(SCHEME_GERRIT, "JaneDoe", CASE_INSENSITIVE_USERNAME);
+      assertThat(externalIds.get(secondAccountExternalId).get().accountId())
+          .isEqualTo(secondAccountId);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void createCaseInsensitiveMigrationModeExternalIdAccountDuringTheMigration()
+      throws Exception {
+    Account.Id accountId = Account.id(66);
+    boolean userNameCaseInsensitive = true;
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JonDoe", accountId, !userNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, !userNameCaseInsensitive);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JaneDoe", accountId, userNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JaneDoe", accountId, userNameCaseInsensitive);
+
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "JonDoe")).isEqualTo(accountId.get());
+      assertThat(getExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isFalse();
+
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "JonDoe")).isEqualTo(accountId.get());
+
+      assertThat(getExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "JaneDoe")).isEqualTo(accountId.get());
+
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "janedoe")).isEqualTo(accountId.get());
+
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "JaneDoe")).isEqualTo(accountId.get());
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "janedoe")).isEqualTo(accountId.get());
+    }
+  }
+
+  protected int getAccountId(ExternalIdNotes extIdNotes, String scheme, String id)
+      throws IOException, ConfigInvalidException {
+    return getExternalId(extIdNotes, scheme, id).get().accountId().get();
+  }
+
+  protected Optional<ExternalId> getExternalId(ExternalIdNotes extIdNotes, String scheme, String id)
+      throws IOException, ConfigInvalidException {
+    return extIdNotes.get(externalIdKeyFactory.create(scheme, id));
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
   public void createCaseSensitiveExternalId_SchemeWithoutUsername() throws Exception {
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
@@ -869,10 +948,8 @@
     ExternalId extId = externalIdFactory.create(scheme, id, accountId);
     extIdNotes.insert(extId);
     extIdNotes.commit(md);
-    assertThat(extIdNotes.get(externalIdKeyFactory.create(scheme, id)).get().accountId().get())
-        .isEqualTo(accountId.get());
-    assertThat(extIdNotes.get(externalIdKeyFactory.create(scheme, id.toLowerCase())).isPresent())
-        .isFalse();
+    assertThat(getAccountId(extIdNotes, scheme, id)).isEqualTo(accountId.get());
+    assertThat(getExternalId(extIdNotes, scheme, id.toLowerCase()).isPresent()).isFalse();
   }
 
   private void testCaseInsensitiveExternalIdKey(
@@ -881,19 +958,29 @@
     ExternalId extId = externalIdFactory.create(scheme, id, accountId);
     extIdNotes.insert(extId);
     extIdNotes.commit(md);
-    assertThat(extIdNotes.get(externalIdKeyFactory.create(scheme, id)).get().accountId().get())
-        .isEqualTo(accountId.get());
-    assertThat(
-            extIdNotes
-                .get(externalIdKeyFactory.create(scheme, id.toLowerCase()))
-                .get()
-                .accountId()
-                .get())
-        .isEqualTo(accountId.get());
+    assertThat(getAccountId(extIdNotes, scheme, id)).isEqualTo(accountId.get());
+    assertThat(getAccountId(extIdNotes, scheme, id.toLowerCase())).isEqualTo(accountId.get());
   }
 
-  private boolean isPartialCacheReloadingEnabled() {
-    return cfg.getBoolean("cache", "external_ids_map", "enablePartialReloads", true);
+  /**
+   * Create external id object
+   *
+   * <p>This method skips gerrit.config auth.userNameCaseInsensitiveMigrationMode and allow to
+   * create case sensitive/insensitive external id
+   */
+  protected void createExternalId(
+      MetaDataUpdate md,
+      ExternalIdNotes extIdNotes,
+      String scheme,
+      String id,
+      Account.Id accountId,
+      boolean isUserNameCaseInsensitive)
+      throws IOException {
+    ExternalId extId =
+        externalIdFactory.create(
+            externalIdKeyFactory.create(scheme, id, isUserNameCaseInsensitive), accountId);
+    extIdNotes.insert(extId);
+    extIdNotes.commit(md);
   }
 
   private void insertExtId(ExternalId extId) throws Exception {
@@ -918,7 +1005,8 @@
     try (Repository repo = repoManager.openRepository(allUsers)) {
       // Inserting an external ID "behind Gerrit's back" means that the caches are not updated.
       ExternalIdNotes extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(allUsers, repo, externalIdFactory);
+          ExternalIdNotes.load(
+              allUsers, repo, externalIdFactory, IS_USER_NAME_CASE_INSENSITIVE_MIGRATION_MODE);
       extIdNotes.insert(extId);
       try (MetaDataUpdate metaDataUpdate =
           new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index a1e9bf1..c89e11a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -59,7 +59,7 @@
     AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
     assertAccountInfo(admin, info);
     Account account = getAccount(admin.id());
-    assertThat(info.registeredOn).isEqualTo(account.registeredOn());
+    assertThat(info.registeredOn.getTime()).isEqualTo(account.registeredOn().toEpochMilli());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 4da4da4..3531d1c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -20,6 +20,8 @@
 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.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.entities.RefNames.patchSetRef;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -28,11 +30,13 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.acceptance.TestAccount;
+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;
@@ -43,8 +47,10 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -55,6 +61,7 @@
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -62,6 +69,7 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -70,8 +78,15 @@
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
 import org.apache.http.Header;
 import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -104,28 +119,246 @@
   }
 
   @Test
+  @UseLocalDisk
   public void voteOnBehalfOf() throws Exception {
     allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = user;
     PushOneCommit.Result r = createChange();
     RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
 
+    try (Repository repo = repoManager.openRepository(project)) {
+      String changeMetaRef = changeMetaRef(r.getChange().getId());
+      createRefLogFileIfMissing(repo, changeMetaRef);
+
+      ReviewInput in = ReviewInput.recommend();
+      in.onBehalfOf = impersonatedUser.id().toString();
+      in.message = "Message on behalf of";
+      revision.review(in);
+
+      PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+      assertThat(psa.patchSetId().get()).isEqualTo(1);
+      assertThat(psa.label()).isEqualTo("Code-Review");
+      assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+      assertThat(psa.value()).isEqualTo(1);
+      assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+      ChangeData cd = r.getChange();
+      ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+      assertThat(m.getMessage()).endsWith(in.message);
+      assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+      assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+
+      // The change meta commit is created by the server and has the impersonated user as the
+      // author.
+      // Person idents of users in NoteDb commits are obfuscated due to privacy reasons.
+      RevCommit changeMetaCommit = projectOperations.project(project).getHead(changeMetaRef);
+      assertThat(changeMetaCommit.getCommitterIdent().getEmailAddress())
+          .isEqualTo(serverIdent.get().getEmailAddress());
+      assertThat(changeMetaCommit.getAuthorIdent().getEmailAddress())
+          .isEqualTo(changeNoteUtil.getAccountIdAsEmailAddress(impersonatedUser.id()));
+
+      // The ref log for the change meta ref records the impersonated user.
+      ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+      assertThat(changeMetaRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+    }
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithOtherImpersonatedVote_sameValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount realUser2 = admin2;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
     ReviewInput in = ReviewInput.recommend();
-    in.onBehalfOf = user.id().toString();
+    in.onBehalfOf = impersonatedUser.id().toString();
     in.message = "Message on behalf of";
     revision.review(in);
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
     assertThat(psa.patchSetId().get()).isEqualTo(1);
     assertThat(psa.label()).isEqualTo("Code-Review");
-    assertThat(psa.accountId()).isEqualTo(user.id());
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
     assertThat(psa.value()).isEqualTo(1);
-    assertThat(psa.realAccountId()).isEqualTo(admin.id());
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
 
     ChangeData cd = r.getChange();
     ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
     assertThat(m.getMessage()).endsWith(in.message);
-    assertThat(m.getAuthor()).isEqualTo(user.id());
-    assertThat(m.getRealAuthor()).isEqualTo(admin.id());
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+
+    // realUser2 votes Code-Review+1 on behalf of impersonatedUser, this should override the
+    // impersonated Code-Review+1 of realUser with an impersonated Code-Review+1 of realUser2
+    requestScopeOperations.setApiUser(realUser2.id());
+    in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Another message on behalf of";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser2.id());
+
+    cd = r.getChange();
+    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser2.id());
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithOtherImpersonatedVote_differentValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount realUser2 = admin2;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+
+    // realUser2 votes Code-Review-1 on behalf of impersonatedUser, this should override the
+    // impersonated Code-Review+1 of realUser with an impersonated Code-Review-1 of realUser2
+    requestScopeOperations.setApiUser(realUser2.id());
+    in = ReviewInput.dislike();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Another message on behalf of";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(-1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser2.id());
+
+    cd = r.getChange();
+    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser2.id());
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithNonImpersonatedVote_sameValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+
+    // impersonatedUser votes Code-Review+1 themselves, this should override the impersonated
+    // Code-Review+1 with a non-impersonated Code-Review+1
+    requestScopeOperations.setApiUser(impersonatedUser.id());
+    in = ReviewInput.recommend();
+    in.message = "Message";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(impersonatedUser.id());
+
+    cd = r.getChange();
+    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(impersonatedUser.id());
+  }
+
+  @Test
+  public void overrideImpersonatedVoteWithNonImpersonatedVote_differentValue() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = user;
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+    // realUser votes Code-Review+1 on behalf of impersonatedUser
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = impersonatedUser.id().toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(1);
+    assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(realUser.id());
+
+    // impersonatedUser votes Code-Review-1 themselves, this should override the impersonated
+    // Code-Review+1 with a non-impersonated Code-Review-1
+    requestScopeOperations.setApiUser(impersonatedUser.id());
+    in = ReviewInput.dislike();
+    in.message = "Message";
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+    assertThat(psa.patchSetId().get()).isEqualTo(1);
+    assertThat(psa.label()).isEqualTo("Code-Review");
+    assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+    assertThat(psa.value()).isEqualTo(-1);
+    assertThat(psa.realAccountId()).isEqualTo(impersonatedUser.id());
+
+    cd = r.getChange();
+    m = Iterables.getLast(cmUtil.byChange(cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(impersonatedUser.id());
+    assertThat(m.getRealAuthor()).isEqualTo(impersonatedUser.id());
   }
 
   @Test
@@ -313,8 +546,8 @@
     in.onBehalfOf = user.id().toString();
     in.label("Code-Review", 1);
 
-    UnprocessableEntityException thrown =
-        assertThrows(UnprocessableEntityException.class, () -> revision.review(in));
+    ResourceConflictException thrown =
+        assertThrows(ResourceConflictException.class, () -> revision.review(in));
     assertThat(thrown)
         .hasMessageThat()
         .contains("on_behalf_of account " + user.id() + " cannot see change");
@@ -341,21 +574,120 @@
   }
 
   @Test
-  public void submitOnBehalfOf() throws Exception {
-    allowSubmitOnBehalfOf();
-    PushOneCommit.Result r = createChange();
+  @UseLocalDisk
+  public void submitOnBehalfOf_mergeAlways() throws Exception {
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = admin2;
+
+    // Create a project with MERGE_ALWAYS submit strategy so that a merge commit is created on
+    // submit and we can verify its committer and author and the ref log for the update of the
+    // target branch.
+    Project.NameKey project =
+        projectOperations.newProject().submitType(SubmitType.MERGE_ALWAYS).create();
+
+    testSubmitOnBehalfOf(project, realUser, impersonatedUser);
+
+    // The merge commit is created by the server and has the impersonated user as the author.
+    RevCommit mergeCommit = projectOperations.project(project).getHead("refs/heads/master");
+    assertThat(mergeCommit.getCommitterIdent().getEmailAddress())
+        .isEqualTo(serverIdent.get().getEmailAddress());
+    assertThat(mergeCommit.getAuthorIdent().getEmailAddress()).isEqualTo(impersonatedUser.email());
+
+    // The ref log for the target branch records the impersonated user.
+    try (Repository repo = repoManager.openRepository(project)) {
+      ReflogEntry targetBranchRefLogEntry =
+          repo.getReflogReader("refs/heads/master").getLastEntry();
+      assertThat(targetBranchRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+    }
+  }
+
+  @Test
+  @UseLocalDisk
+  public void submitOnBehalfOf_rebaseAlways() throws Exception {
+    TestAccount realUser = admin;
+    TestAccount impersonatedUser = admin2;
+
+    // Create a project with REBASE_ALWAYS submit strategy so that a new patch set is created on
+    // submit and we can verify its committer and author and the ref log for the update of the
+    // patch set ref and the target branch.
+    Project.NameKey project =
+        projectOperations.newProject().submitType(SubmitType.REBASE_ALWAYS).create();
+
+    ChangeData cd = testSubmitOnBehalfOf(project, realUser, impersonatedUser);
+
+    // Rebase on submit is expected to create a new patch set.
+    assertThat(cd.currentPatchSet().id().get()).isEqualTo(2);
+
+    // The patch set commit is created by the impersonated user and has the real user as the author.
+    // Recording the real user as the author seems to a bug, we would expect the author to be the
+    // impersonated user.
+    RevCommit newPatchSetCommit =
+        projectOperations.project(project).getHead(cd.currentPatchSet().refName());
+    assertThat(newPatchSetCommit.getCommitterIdent().getEmailAddress())
+        .isEqualTo(impersonatedUser.email());
+    assertThat(newPatchSetCommit.getAuthorIdent().getEmailAddress()).isEqualTo(realUser.email());
+
+    try (Repository repo = repoManager.openRepository(project)) {
+      // The ref log for the patch set ref records the impersonated user.
+      ReflogEntry patchSetRefLogEntry =
+          repo.getReflogReader(cd.currentPatchSet().refName()).getLastEntry();
+      assertThat(patchSetRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+
+      // The ref log for the target branch records the impersonated user.
+      ReflogEntry targetBranchRefLogEntry =
+          repo.getReflogReader("refs/heads/master").getLastEntry();
+      assertThat(targetBranchRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+    }
+  }
+
+  @CanIgnoreReturnValue
+  private ChangeData testSubmitOnBehalfOf(
+      Project.NameKey project, TestAccount realUser, TestAccount impersonatedUser)
+      throws Exception {
+    allowSubmitOnBehalfOf(project);
+
+    TestRepository<InMemoryRepository> testRepo = cloneProject(project, realUser);
+
+    PushOneCommit.Result r = createChange(testRepo);
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
     SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email();
-    gApi.changes().id(changeId).current().submit(in);
+    in.onBehalfOf = impersonatedUser.email();
 
-    ChangeData cd = r.getChange();
-    assertThat(cd.change().isMerged()).isTrue();
-    PatchSetApproval submitter =
-        approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
-    assertThat(submitter.accountId()).isEqualTo(admin2.id());
-    assertThat(submitter.realAccountId()).isEqualTo(admin.id());
+    try (Repository repo = repoManager.openRepository(project)) {
+      String changeMetaRef = changeMetaRef(r.getChange().getId());
+      createRefLogFileIfMissing(repo, changeMetaRef);
+      createRefLogFileIfMissing(repo, "refs/heads/master");
+      createRefLogFileIfMissing(repo, patchSetRef(PatchSet.id(r.getChange().getId(), 2)));
+
+      gApi.changes().id(changeId).current().submit(in);
+
+      ChangeData cd = r.getChange();
+      assertThat(cd.change().isMerged()).isTrue();
+      PatchSetApproval submitter =
+          approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
+      assertThat(submitter.accountId()).isEqualTo(impersonatedUser.id());
+      assertThat(submitter.realAccountId()).isEqualTo(realUser.id());
+
+      // The change meta commit is created by the server and has the impersonated user as the
+      // author.
+      // Person idents of users in NoteDb commits are obfuscated due to privacy reasons.
+      RevCommit changeMetaCommit = projectOperations.project(project).getHead(changeMetaRef);
+      assertThat(changeMetaCommit.getCommitterIdent().getEmailAddress())
+          .isEqualTo(serverIdent.get().getEmailAddress());
+      assertThat(changeMetaCommit.getAuthorIdent().getEmailAddress())
+          .isEqualTo(changeNoteUtil.getAccountIdAsEmailAddress(impersonatedUser.id()));
+
+      // The ref log for the change meta ref records the impersonated user.
+      ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+      assertThat(changeMetaRefLogEntry.getWho().getEmailAddress())
+          .isEqualTo(impersonatedUser.email());
+
+      return cd;
+    }
   }
 
   @Test
@@ -590,6 +922,10 @@
   }
 
   private void allowSubmitOnBehalfOf() throws Exception {
+    allowSubmitOnBehalfOf(project);
+  }
+
+  private void allowSubmitOnBehalfOf(Project.NameKey project) throws Exception {
     String heads = "refs/heads/*";
     projectOperations
         .project(project)
@@ -629,4 +965,12 @@
   private static Header runAsHeader(Object user) {
     return new BasicHeader("X-Gerrit-RunAs", user.toString());
   }
+
+  private void createRefLogFileIfMissing(Repository repo, String ref) throws IOException {
+    File log = new File(repo.getDirectory(), "logs/" + ref);
+    if (!log.exists()) {
+      log.getParentFile().mkdirs();
+      assertThat(log.createNewFile()).isTrue();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 79484ca..2d663df 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -74,8 +74,6 @@
           RestCall.delete("/changes/%s/private"),
           RestCall.post("/changes/%s/wip"),
           RestCall.post("/changes/%s/ready"),
-          RestCall.put("/changes/%s/ignore"),
-          RestCall.put("/changes/%s/unignore"),
           RestCall.get("/changes/%s/messages"),
           RestCall.put("/changes/%s/message"),
           RestCall.post("/changes/%s/merge"),
@@ -145,12 +143,13 @@
           RestCall.get("/changes/%s/revisions/%s/related"),
           RestCall.get("/changes/%s/revisions/%s/review"),
           RestCall.post("/changes/%s/revisions/%s/review"),
-          RestCall.get("/changes/%s/revisions/%s/preview_submit"),
           RestCall.post("/changes/%s/revisions/%s/submit"),
           RestCall.get("/changes/%s/revisions/%s/submit_type"),
           RestCall.post("/changes/%s/revisions/%s/test.submit_rule"),
           RestCall.post("/changes/%s/revisions/%s/test.submit_type"),
           RestCall.post("/changes/%s/revisions/%s/rebase"),
+          RestCall.post("/changes/%s/revisions/%s/fix:apply"),
+          RestCall.post("/changes/%s/revisions/%s/fix:preview"),
           RestCall.get("/changes/%s/revisions/%s/description"),
           RestCall.put("/changes/%s/revisions/%s/description"),
           RestCall.get("/changes/%s/revisions/%s/patch"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
index 23a1d23..d2c1430 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
@@ -56,8 +56,7 @@
   @Inject private TestCommentHelper testCommentHelper;
 
   /** Resource to bind a child collection. */
-  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND =
-      new TypeLiteral<RestView<TestPluginResource>>() {};
+  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND = new TypeLiteral<>() {};
 
   private static final String PLUGIN_NAME = "my-plugin";
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
index 16dc294..24ce605 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
@@ -60,8 +60,7 @@
 public class PluginProvidedRootRestApiBindingsIT extends AbstractDaemonTest {
 
   /** Resource to bind a child collection. */
-  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND =
-      new TypeLiteral<RestView<TestPluginResource>>() {};
+  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND = new TypeLiteral<>() {};
 
   private static final String PLUGIN_NAME = "my-plugin";
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index f1c0110..cffcc2f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -32,6 +32,8 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.TagInput;
@@ -85,7 +87,9 @@
               .build(),
           RestCall.get("/projects/%s/dashboards"),
           RestCall.put("/projects/%s/labels/new-label"),
-          RestCall.post("/projects/%s/labels/"));
+          RestCall.post("/projects/%s/labels/"),
+          RestCall.put("/projects/%s/submit_requirements/new-sr"),
+          RestCall.get("/projects/%s/submit_requirements"));
 
   /**
    * Child project REST endpoints to be tested, each URL contains placeholders for the parent
@@ -175,6 +179,18 @@
           // Label deletion must be tested last
           RestCall.delete("/projects/%s/labels/%s"));
 
+  /**
+   * Submit requirement REST endpoints to be tested, each URL contains placeholders for the project
+   * identifier and the submit requirement name.
+   */
+  private static final ImmutableList<RestCall> SUBMIT_REQUIREMENT_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/projects/%s/submit_requirements/%s"),
+          RestCall.put("/projects/%s/submit_requirements/%s"),
+
+          // Submit requirement deletion must be tested last
+          RestCall.delete("/projects/%s/submit_requirements/%s"));
+
   private static final String FILENAME = "test.txt";
   @Inject private ProjectOperations projectOperations;
 
@@ -236,6 +252,20 @@
     RestApiCallHelper.execute(adminRestSession, LABEL_ENDPOINTS, project.get(), label);
   }
 
+  @Test
+  public void submitRequirementsEndpoints() throws Exception {
+    // Create the SR, so that the GET endpoint succeeds
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    RestApiCallHelper.execute(
+        adminRestSession, SUBMIT_REQUIREMENT_ENDPOINTS, project.get(), "code-review");
+  }
+
   private String createAndSubmitChange(String filename) throws Exception {
     RevCommit c =
         testRepo
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index d967f48..0e4f212 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -28,6 +28,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
@@ -81,7 +82,6 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -104,10 +104,8 @@
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffFormatter;
@@ -148,162 +146,25 @@
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   @Test
   public void submitSingleChange() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
     assertThat(headAfterSubmit).isEqualTo(initialHead);
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
 
-    if ((getSubmitType() == SubmitType.CHERRY_PICK)
-        || (getSubmitType() == SubmitType.REBASE_ALWAYS)) {
-      // The change is updated as well:
-      assertThat(actual).hasSize(2);
-    } else {
-      assertThat(actual).hasSize(1);
-    }
-
     submit(change.getChangeId());
-    assertTrees(project, actual);
-  }
-
-  @Test
-  public void submitMultipleChangesOtherMergeConflictPreview() throws Throwable {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-
-    PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
-    submit(change.getChangeId());
-
-    RevCommit headAfterFirstSubmit = projectOperations.project(project).getHead("master");
-    testRepo.reset(initialHead);
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    // change 2 is not approved, but we ignore labels
-    approve(change3.getChangeId());
-
-    try (BinaryResult request =
-        gApi.changes().id(change4.getChangeId()).current().submitPreview()) {
-      assertThat(getSubmitType()).isEqualTo(SubmitType.CHERRY_PICK);
-      submit(change4.getChangeId());
-    } catch (RestApiException e) {
-      switch (getSubmitType()) {
-        case FAST_FORWARD_ONLY:
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Failed to submit 3 changes due to the following problems:\n"
-                      + "Change "
-                      + change2.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.\n"
-                      + "Change "
-                      + change3.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.\n"
-                      + "Change "
-                      + change4.getChange().getId()
-                      + ": Project policy "
-                      + "requires all submissions to be a fast-forward. Please "
-                      + "rebase the change locally and upload again for review.");
-          break;
-        case REBASE_IF_NECESSARY:
-        case REBASE_ALWAYS:
-          String change2hash = change2.getChange().currentPatchSet().commitId().name();
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Cannot rebase "
-                      + change2hash
-                      + ": The change could "
-                      + "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:
-        case INHERIT:
-          assertThat(e.getMessage())
-              .isEqualTo(
-                  "Failed to submit 3 changes due to the following problems:\n"
-                      + "Change "
-                      + change2.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.\n"
-                      + "Change "
-                      + change3.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.\n"
-                      + "Change "
-                      + change4.getChange().getId()
-                      + ": Change could not be "
-                      + "merged due to a path conflict. Please rebase the change "
-                      + "locally and upload the rebased commit for review.");
-          break;
-        case CHERRY_PICK:
-        default:
-          assertWithMessage("Should not reach here.").fail();
-          break;
-      }
-
-      RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
-      assertThat(headAfterSubmit).isEqualTo(headAfterFirstSubmit);
-      assertRefUpdatedEvents(initialHead, headAfterFirstSubmit);
-      assertChangeMergedEvents(change.getChangeId(), headAfterFirstSubmit.name());
-    }
-  }
-
-  @Test
-  public void submitMultipleChangesPreview() throws Throwable {
-    RevCommit initialHead = projectOperations.project(project).getHead("master");
-    PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
-    PushOneCommit.Result change3 = createChange("Change 3", "d", "d");
-    PushOneCommit.Result change4 = createChange("Change 4", "e", "e");
-    // change 2 is not approved, but we ignore labels
-    approve(change3.getChangeId());
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change4.getChangeId());
-    Map<String, Map<String, Integer>> expected = new HashMap<>();
-    expected.put(project.get(), new HashMap<>());
-    expected.get(project.get()).put("refs/heads/master", 3);
-
-    assertThat(actual).containsKey(BranchNameKey.create(project, "refs/heads/master"));
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
-      // CherryPick ignores dependencies, thus only change and destination
-      // branch refs are modified.
-      assertThat(actual).hasSize(2);
-    } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
-      // RebaseAlways takes care of dependencies, therefore Change{2,3,4} and
-      // destination branch will be modified.
-      assertThat(actual).hasSize(4);
-    } else {
-      assertThat(actual).hasSize(1);
-    }
-
-    // check that the submit preview did not actually submit
-    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
-    assertThat(headAfterSubmit).isEqualTo(initialHead);
-    assertRefUpdatedEvents();
-    assertChangeMergedEvents();
-
-    // now check we actually have the same content:
-    approve(change2.getChangeId());
-    submit(change4.getChangeId());
-    assertTrees(project, actual);
+    headAfterSubmit = projectOperations.project(project).getHead("master");
+    assertThat(headAfterSubmit).isNotEqualTo(initialHead);
   }
 
   /**
@@ -1238,14 +1099,11 @@
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
     PushOneCommit.Result change = createChange();
     assertThat(change.getCommit().getParents()).isEmpty();
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   @Test
@@ -1259,20 +1117,17 @@
     change.assertOkStatus();
     assertThat(change.getCommit().getTree()).isEqualTo(EMPTY_TREE_ID);
 
-    Map<BranchNameKey, ObjectId> actual = fetchFromSubmitPreview(change.getChangeId());
     assertThat(projectOperations.project(project).hasHead("master")).isFalse();
-    assertThat(actual).hasSize(1);
 
     submit(change.getChangeId());
     assertThat(projectOperations.project(project).getHead("master").getId())
         .isEqualTo(change.getCommit());
-    assertTrees(project, actual);
   }
 
   private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Throwable {
     for (PushOneCommit.Result change : changes) {
       try (BatchUpdate bu =
-          batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.nowTs())) {
+          batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.now())) {
         bu.addOp(
             change.getChange().getId(),
             new BatchUpdateOp() {
@@ -1395,8 +1250,8 @@
     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);
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.getUpdated());
+    assertThat(r.getChange().getMergedOn().get()).isEqualTo(change.getSubmitted());
   }
 
   @Override
@@ -1507,9 +1362,10 @@
   }
 
   protected void assertAuthorAndCommitDateEquals(RevCommit commit) {
-    assertThat(commit.getAuthorIdent().getWhen()).isEqualTo(commit.getCommitterIdent().getWhen());
-    assertThat(commit.getAuthorIdent().getTimeZone())
-        .isEqualTo(commit.getCommitterIdent().getTimeZone());
+    assertThat(commit.getAuthorIdent().getWhenAsInstant())
+        .isEqualTo(commit.getCommitterIdent().getWhenAsInstant());
+    assertThat(commit.getAuthorIdent().getZoneId())
+        .isEqualTo(commit.getCommitterIdent().getZoneId());
   }
 
   protected void assertSubmitter(String changeId, int psId) throws Throwable {
@@ -1588,7 +1444,7 @@
       fmt.setRepository(repo);
       fmt.format(oldTreeId, newTreeId);
       fmt.flush();
-      return out.toString();
+      return out.toString(UTF_8);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 81c098f..aeebc10 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -234,11 +234,11 @@
     PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
     submitWithConflict(
         change2.getChangeId(),
-        "Cannot rebase "
-            + change2.getCommit().name()
-            + ": The change could not be rebased due to a conflict during merge.\n\n"
-            + "merge conflict(s):\n"
-            + "a.txt");
+        String.format(
+            "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
+                + "merge conflict(s):\n"
+                + "a.txt",
+            change2.getCommit().name(), change2.getChange().getId()));
     RevCommit head = projectOperations.project(project).getHead("master");
     assertThat(head).isEqualTo(headAfterFirstSubmit);
     assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
@@ -362,12 +362,11 @@
 
     submitWithConflict(
         change2.getChangeId(),
-        "Cannot rebase "
-            + change2.getCommit().getName()
-            + ": "
-            + "The change could not be rebased due to a conflict during merge.\n\n"
-            + "merge conflict(s):\n"
-            + "fileName 2");
+        String.format(
+            "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
+                + "merge conflict(s):\n"
+                + "fileName 2",
+            change2.getCommit().name(), change2.getChange().getId()));
     assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
index e35f758..fbcc5fa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -104,7 +104,8 @@
 
   @Test
   public void revisionActionsTwoChangesInTopic() throws Exception {
-    String changeId = createChangeWithTopic().getChangeId();
+    PushOneCommit.Result change1 = createChangeWithTopic();
+    String changeId = change1.getChangeId();
     approve(changeId);
     PushOneCommit.Result change2 = createChangeWithTopic();
     int legacyId2 = change2.getChange().getId().get();
@@ -116,7 +117,15 @@
       assertThat(info.enabled).isNull();
       assertThat(info.label).isEqualTo("Submit whole topic");
       assertThat(info.method).isEqualTo("POST");
-      assertThat(info.title).matches("Change " + legacyId2 + " is not ready: needs Code-Review");
+      assertThat(info.title)
+          .startsWith(
+              "Change "
+                  + change1.getChange().getId()
+                  + " must be submitted with change "
+                  + legacyId2
+                  + " but "
+                  + legacyId2
+                  + " is not ready: submit requirement 'Code-Review' is unsatisfied.");
     } else {
       noSubmitWholeTopicAssertions(actions, 1);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 0a11b15..ea52690 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.restapi.testing.AttentionSetUpdateSubject.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
@@ -42,10 +46,14 @@
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
@@ -59,11 +67,13 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.GetAttentionSet;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -261,6 +271,12 @@
             fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed");
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
+    // The removal also shows up in AttentionSetInfo.
+    AttentionSetInfo attentionSetInfo =
+        Iterables.getOnlyElement(change(r).get().removedFromAttentionSet.values());
+    assertThat(attentionSetInfo.reason).isEqualTo("removed");
+    assertThat(attentionSetInfo.account).isEqualTo(getAccountInfo(user.id()));
+
     // Second removal is ignored.
     fakeClock.advance(Duration.ofSeconds(42));
     change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed again"));
@@ -1658,11 +1674,7 @@
 
     // 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());
+    setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
 
     // Add user to attention set. They receive an email since they are in the attention set.
     change(r).addReviewer(user.id().toString());
@@ -1736,11 +1748,7 @@
   public void attentionSetWithEmailFilterImpactingOnlyChangeEmails() throws Exception {
     // Add preference for the user such that they only receive an email on changes that require
     // their attention.
-    requestScopeOperations.setApiUser(user.id());
-    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
-    prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
-    gApi.accounts().self().setPreferences(prefs);
-    requestScopeOperations.setApiUser(admin.id());
+    setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
 
     // Ensure emails that don't relate to changes are still sent.
     gApi.accounts().id(user.id().get()).generateHttpPassword();
@@ -1935,6 +1943,30 @@
   }
 
   @Test
+  public void deleteVotesDoesNotAffectAttentionSetWhenIgnoreAutomaticRulesIsSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    DeleteVoteInput deleteVoteInput = new DeleteVoteInput();
+    deleteVoteInput.label = LabelId.CODE_REVIEW;
+
+    // set this to true to not change the attention set.
+    deleteVoteInput.ignoreAutomaticAttentionSetRules = true;
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .reviewer(user.id().toString())
+        .deleteVote(deleteVoteInput);
+
+    assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+  }
+
+  @Test
   public void deleteVotesOfOthersAddThemToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -1993,6 +2025,736 @@
         .isEqualTo(Operation.REMOVE);
   }
 
+  @Test
+  public void outsideAttentionSet_watchProjectEmailReceived() throws Exception {
+    setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
+
+    requestScopeOperations.setApiUser(user.id());
+    watch(project.get());
+
+    createChange();
+
+    assertThat(sender.getMessages()).isNotEmpty();
+    sender.clear();
+  }
+
+  @Test
+  public void approverOfOutdatedApprovalAddedToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add an approval that gets outdated when a new patch set is created (i.e. an approval that is
+    // not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approval got outdated and was removed and
+    // user now needs to re-review the change and renew the approval.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Code-Review+1"));
+
+    // Expect that the email notification contains the outdated vote.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "Hello %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s\n",
+                user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s</p>",
+                user.fullName()));
+  }
+
+  @Test
+  public void approverOfMultipleOutdatedApprovalsAddedToAttentionSet() throws Exception {
+    // Create a Verify and a Foo-Var label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      LabelType.Builder fooBar =
+          labelBuilder("Foo-Bar", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(fooBar.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Foo-Bar")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add multiple approvals from one user that gets outdated when a new patch set is created (i.e.
+    // approvals that are not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Foo-Bar", -1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approvals have been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approvals got outdated and were removed and
+    // user now needs to re-review the change and renew the approvals.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Votes got outdated and were removed: Code-Review+1, Foo-Bar-1, Verified+1"));
+
+    // Expect that the email notification contains the outdated votes.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "Hello %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Foo-Bar-1 by %s, Verified+1 by %s\n",
+                user.fullName(), user.fullName(), user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Foo-Bar-1 by %s, Verified+1 by %s</p>",
+                user.fullName(), user.fullName(), user.fullName()));
+  }
+
+  @Test
+  public void multipleApproverOfOutdatedApprovalsAddedToAttentionSet() throws Exception {
+    // Create Verify label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add approvals from multiple users that gets outdated when a new patch set is created (i.e.
+    // approvals that are not copied).
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this removes the vote from user, as it is not copied to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approvals have been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // User got added to the attention set because users approvals got outdated and were removed and
+    // user now needs to re-review the change and renew the approvals.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Code-Review+1"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user2.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Vote got outdated and was removed: Verified+1"));
+
+    // Expect that the email notification contains the outdated votes.
+    assertThat(sender.getMessages()).hasSize(1);
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "Hello %s, %s, \n"
+                    + "\n"
+                    + "I'd like you to reexamine a change."
+                    + " Please visit",
+                user.fullName(), user2.fullName(), user.fullName(), user2.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Verified+1 by %s\n",
+                user.fullName(), user2.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p> Attention is currently required from: %s, %s. </p>\n"
+                    + "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                user.fullName(), user2.fullName(), admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s, Verified+1 by %s</p>",
+                user.fullName(), user2.fullName()));
+  }
+
+  @Test
+  public void robotApproverOfOutdatedApprovalIsNotAddedToAttentionSet() throws Exception {
+    // Create robot account
+    TestAccount robot =
+        accountCreator.create(
+            "robot-X",
+            "robot-x@example.com",
+            "Ro Bot X",
+            "RoX",
+            ServiceUserClassifier.SERVICE_USERS,
+            "Administrators");
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add an approval by a robot that gets outdated when a new patch set is created (i.e. an
+    // approval that is not copied).
+    requestScopeOperations.setApiUser(robot.id());
+    recommend(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // A robot vote doesn't add the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet()).isEmpty();
+
+    // Amend the change, this removes the vote from the robot, as it is not copied to the new patch
+    // set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been removed.
+    assertThat(r.getChange().currentApprovals()).isEmpty();
+
+    // The robot was not added to the attention set because users service users are never added to
+    // the attention set.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Verify the email for the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    String emailBody = message.body();
+    assertThat(emailBody)
+        .doesNotContain(
+            String.format("Attention is currently required from: %s", robot.fullName()));
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "Hello %s, \n\nI'd like you to reexamine a change. Please visit",
+                robot.fullName()));
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "The following approvals got outdated and were removed:\nCode-Review+1 by %s",
+                robot.fullName()));
+    assertThat(message.htmlBody())
+        .doesNotContain(
+            String.format("Attention is currently required from: %s", robot.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                admin.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "View Change</a></p>"
+                    + "<p>The following approvals got outdated and were removed:\n"
+                    + "Code-Review+1 by %s</p>",
+                robot.fullName()));
+  }
+
+  @Test
+  public void approverOfCopiedApprovelNotAddedToAttentionSet() throws Exception {
+    // Allow user to make veto votes.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.CODE_REVIEW)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Add a veto vote that will be copied over to a new patch set.
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, -2));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the admin user to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove admin user from attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Amend the change, this copies the vote from user to the new patch set.
+    sender.clear();
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+
+    // Verify that the approval has been copied.
+    List<PatchSetApproval> approvalsPs2 = r.getChange().currentApprovals();
+    assertThat(approvalsPs2).hasSize(1);
+    assertThat(Iterables.getOnlyElement(approvalsPs2).copied()).isTrue();
+
+    // Attention set wasn't changed.
+    assertThat(r.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Verify the email for the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .doesNotContain(String.format("Attention is currently required from: %s", user.fullName()));
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Hello %s, \n\nI'd like you to reexamine a change. Please visit", user.fullName()));
+    assertThat(message.body())
+        .doesNotContain("The following approvals got outdated and were removed:");
+    assertThat(message.htmlBody())
+        .doesNotContain(String.format("Attention is currently required from: %s", user.fullName()));
+    assertThat(message.htmlBody())
+        .contains(
+            String.format(
+                "<p>%s <strong>uploaded patch set #2</strong> to this change.</p>",
+                admin.fullName()));
+    assertThat(message.htmlBody())
+        .doesNotContain("The following approvals got outdated and were removed:");
+  }
+
+  @Test
+  public void ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_approvalRemoved()
+      throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Revoke the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Removing the approval added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
+  @Test
+  public void
+      ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_approvalDowngraded()
+          throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Downgrade the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Changing the approval added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for downgrading the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("\nPatch Set 2: Code-Review+2\n");
+    assertThat(message.body()).contains("\nPatch Set 2: Code-Review+1\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
+  @Test
+  public void ownerAndUploaderAreAddedToAttentionSetWhenChangeBecomesUnsubmittable_vetoApplied()
+      throws Exception {
+    // Allow all users to approve and veto.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Attention set is empty.
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Voting added the owner (admin) and the uploader (user) to the attention set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Remove the owner (admin) and the uploader (user) from the attention set.
+    change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.REMOVE, "removed"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(), user.id(), AttentionSetUpdate.Operation.REMOVE, "removed"));
+
+    // Apply veto by another user.
+    TestAccount approver2 = accountCreator.user2();
+    sender.clear();
+    requestScopeOperations.setApiUser(approver2.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Adding the veto approval added the owner (admin) and the uploader (user) to the attention
+    // set.
+    assertThat(changeDataFactory.create(project, r.getChange().getId()).attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                user.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Someone else replied on the change"));
+
+    // Verify the email notification that has been sent for adding the veto.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).contains("\nPatch Set 2: Code-Review-2\n");
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+  }
+
+  private void setEmailStrategyForUser(EmailStrategy es) throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+    prefs.emailStrategy = es;
+    gApi.accounts().self().setPreferences(prefs);
+    requestScopeOperations.setApiUser(admin.id());
+  }
+
   private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
       PushOneCommit.Result r, TestAccount account) {
     return getAttentionSetUpdates(r.getChange().getId()).stream()
@@ -2018,7 +2780,7 @@
     comment.side = Side.REVISION;
     comment.path = Patch.COMMIT_MSG;
     comment.message = "comment";
-    comment.updated = TimeUtil.nowTs();
+    comment.setUpdated(TimeUtil.now());
     comment.inReplyTo = id;
     ReviewInput reviewInput = new ReviewInput();
     reviewInput.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
new file mode 100644
index 0000000..1094a42
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
@@ -0,0 +1,910 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.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.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+/**
+ * Integration test to verify that change-no-longer-submittable emails are sent when a change
+ * becomes not submittable, and that they are sent only in this case (and not when the change
+ * becomes submittable or stays submittable/unsubmittable).
+ */
+public class ChangeNoLongerSubmittableIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_approvalRemoved() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Verify the email notifications that have been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_approvalDowngraded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Downgrade the approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification that has been sent for downgrading the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_vetoApplied() throws Exception {
+    // Allow all users to approve and veto.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Apply veto by another user.
+    TestAccount approver2 = accountCreator.user2();
+    sender.clear();
+    requestScopeOperations.setApiUser(approver2.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject());
+
+    // Verify the email notification that has been sent for adding the veto.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeBecomesUnsubmittable_multipleSubmitRequirementsNoLongerSatisfied()
+      throws Exception {
+    // Create a Verify, a Foo-Bar and a Bar-Baz label and allow voting on it.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+              LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(verified.build());
+
+      LabelType.Builder fooBar =
+          labelBuilder("Foo-Bar", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(fooBar.build());
+
+      LabelType.Builder barBaz =
+          labelBuilder("Bar-Baz", value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+      u.getConfig().upsertLabelType(barBaz.build());
+
+      u.save();
+    }
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Foo-Bar")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .add(
+            allowLabel("Bar-Baz")
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve all labels.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Foo-Bar", 1));
+    gApi.changes().id(r.getChangeId()).current().review(new ReviewInput().label("Bar-Baz", 1));
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke several approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label("Code-Review", 0).label("Foo-Bar", 0).label("Verified", 0));
+
+    // Verify the email notification that have been sent for removing the approval.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body())
+        .contains(
+            "The change is no longer submittable:"
+                + " Code-Review, Foo-Bar and Verified are unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains(
+            "<p>The change is no longer submittable:"
+                + " Code-Review, Foo-Bar and Verified are unsatisfied now.</p>");
+  }
+
+  @Test
+  public void postReview_changeStaysSubmittable_approvalRemoved() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change by 2 users.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    TestAccount approver2 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    requestScopeOperations.setApiUser(approver2.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Revoke one approval.
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeStaysSubmittable_approvalDowngraded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change by 2 users.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    TestAccount approver2 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    requestScopeOperations.setApiUser(approver2.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Downgrade one approval
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeStaysUnsubmittable_approvalAdded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Add a vote that doesn't make the change submittable.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    recommend(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void postReview_changeBecomesSubmittable_approvalAdded() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Add a vote that makes the change submittable.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    sender.clear();
+    approve(r.getChangeId());
+
+    // Verify the email notification.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has posted comments on this change.",
+                admin.fullName(), user.fullName(), approver.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalNotCopied() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that removes the approval.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    r.assertMessage(
+        "The following approvals got outdated and were removed:\n* Code-Review+2 by user2\n");
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                approver.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedAndRevoked()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      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 this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but revoke it on push.
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, approver);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=-Code-Review",
+            approver,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedAndDowngraded()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      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 this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but downgrade it on push.
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, approver);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+1",
+            approver,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), approver.fullName(), admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesUnsubmittable_approvalCopiedVetoApplied()
+      throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      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 this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to veto and approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that copies the approval, but apply a new veto on push.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review-2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                uploaderPs3.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body())
+        .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
+    assertThat(message.htmlBody())
+        .contains("<p>The change is no longer submittable: Code-Review is unsatisfied now.</p>");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysSubmittable_approvalCopied() throws Exception {
+    // Make Code-Review approvals sticky.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      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 this is not submitted as is"),
+              value(-2, "This shall not be submitted"));
+      codeReview.setCopyCondition("is:MAX");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set, the approval is copied.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(), user.fullName(), uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysSubmittable_approvalReapplied() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Approve the change.
+    TestAccount approver = accountCreator.user2();
+    requestScopeOperations.setApiUser(approver.id());
+    approve(r.getChangeId());
+    requestScopeOperations.setApiUser(admin.id());
+
+    // Upload a new patch set that removes the approval, but re-apply a new approval on push.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    // uploaderPs3 gets added to the attention set because this user is a new reviewer on the change
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s, %s, %s, %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                admin.fullName(),
+                user.fullName(),
+                approver.fullName(),
+                uploaderPs3.fullName(),
+                uploaderPs3.fullName(),
+                admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeStaysUnsubmittable() throws Exception {
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Upload a new patch set.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    r = amendChangeWithUploader(r, project, uploaderPs3);
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+
+  @Test
+  public void pushNewPatchSet_changeBecomesSubmittable() throws Exception {
+    // Allow all users to approve.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 2))
+        .update();
+
+    // Create change with admin as the owner and upload a new patch set with user as uploader.
+    PushOneCommit.Result r = createChange();
+    r = amendChangeWithUploader(r, project, user);
+    r.assertOkStatus();
+
+    // Upload a new patch set and approve it.
+    TestAccount uploaderPs3 =
+        accountCreator.create("user3", "user3@email.com", "User3", /* displayName= */ null);
+    sender.clear();
+    TestRepository<InMemoryRepository> repo = cloneProject(project, uploaderPs3);
+    GitUtil.fetch(repo, "refs/*:refs/*");
+    repo.reset(r.getCommit());
+    r =
+        amendChange(
+            r.getChangeId(),
+            "refs/for/master%l=Code-Review+2",
+            uploaderPs3,
+            repo,
+            "new subject",
+            "new file",
+            "new content");
+    r.assertOkStatus();
+
+    // Verify the email notification that has been sent for uploading the new patch set.
+    Message message = Iterables.getOnlyElement(sender.getMessages());
+    // uploaderPs3 gets added to the attention set because this user is a new reviewer on the change
+    assertThat(message.body())
+        .contains(
+            String.format(
+                "Attention is currently required from: %s.\n"
+                    + "\n"
+                    + "%s has uploaded a new patch set (#3) to the change originally created by %s.",
+                uploaderPs3.fullName(), uploaderPs3.fullName(), admin.fullName()));
+    assertThat(message.body()).doesNotContain("The change is no longer submittable");
+    assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 88e5f10..70a3cf2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -65,9 +65,9 @@
       gApi.changes().id(r.getChangeId()).addReviewer(input);
 
       ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
-      assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(acc)));
+      assertThat(info.reviewers).containsExactly(state, ImmutableList.of(acc));
       // All reviewers added by email should be removable
-      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(acc));
+      assertThat(info.removableReviewers).containsExactly(acc);
     }
   }
 
@@ -92,7 +92,7 @@
       ChangeInfo info = gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS);
       assertThat(info.reviewers).isEqualTo(ImmutableMap.of(state, ImmutableList.of(byId, byEmail)));
       // All reviewers (both by id and by email) should be removable
-      assertThat(info.removableReviewers).isEqualTo(ImmutableList.of(byId, byEmail));
+      assertThat(info.removableReviewers).containsExactly(byId, byEmail);
     }
   }
 
@@ -368,6 +368,6 @@
   }
 
   private static String toRfcAddressString(AccountInfo info) {
-    return (Address.create(info.name, info.email)).toString();
+    return Address.create(info.name, info.email).toString();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index b0a14cf..079f84e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -19,7 +19,10 @@
 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.HEAD;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.common.testing.GitPersonSubject.assertThat;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -27,6 +30,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
+import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -46,18 +50,22 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -73,6 +81,7 @@
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gson.stream.JsonReader;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
 import java.util.ArrayList;
@@ -84,6 +93,8 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.api.errors.PatchApplyException;
+import org.eclipse.jgit.api.errors.PatchFormatException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -93,6 +104,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.util.Base64;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -484,6 +496,20 @@
   }
 
   @Test
+  public void createAuthorNotAddedAsCcWithAvoidAddingOriginalAuthorAsReviewer() throws Exception {
+    ConfigInput config = new ConfigInput();
+    config.skipAddingAuthorAndCommitterAsReviewers = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(config);
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.author = new AccountInput();
+    input.author.email = user.email();
+    input.author.name = user.fullName();
+
+    ChangeInfo info = assertCreateSucceeds(input);
+    assertThat(info.reviewers).isEmpty();
+  }
+
+  @Test
   public void createNewWorkInProgressChange() throws Exception {
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.workInProgress = true;
@@ -593,7 +619,7 @@
 
       PersonIdent expectedAuthor =
           changeNoteUtil.newAccountIdIdent(
-              getAccount(admin.id()).id(), c.created, serverIdent.get());
+              getAccount(admin.id()).id(), c.created.toInstant(), serverIdent.get());
       assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor);
 
       assertThat(commit.getCommitterIdent())
@@ -922,6 +948,167 @@
   }
 
   @Test
+  public void createChangeWithBothMergeAndPatch_fails() throws Exception {
+    ChangeInput input = newMergeChangeInput("foo", "master", "");
+    input.patch = new ApplyPatchInput();
+    assertCreateFails(
+        input, BadRequestException.class, "Only one of `merge` and `patch` arguments can be set");
+  }
+
+  private static final String PATCH_FILE_NAME = "a_file.txt";
+  private static final String PATCH_NEW_FILE_CONTENT = "First added line\nSecond added line\n";
+  private static final String PATCH_INPUT =
+      "diff --git a/a_file.txt b/a_file.txt\n"
+          + "new file mode 100644\n"
+          + "index 0000000..f0eec86\n"
+          + "--- /dev/null\n"
+          + "+++ b/a_file.txt\n"
+          + "@@ -0,0 +1,2 @@\n"
+          + "+First added line\n"
+          + "+Second added line\n";
+  private static final String MODIFICATION_PATCH_INPUT =
+      "diff --git a/a_file.txt b/a_file.txt\n"
+          + "new file mode 100644\n"
+          + "--- a/a_file.txt\n"
+          + "+++ b/a_file.txt.txt\n"
+          + "@@ -1,2 +1 @@\n"
+          + "-First original line\n"
+          + "-Second original line\n"
+          + "+Modified line\n";
+
+  @Test
+  public void createPatchApplyingChange_success() throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+  }
+
+  @Test
+  public void createPatchApplyingChange_fromGerritPatch_success() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result baseCommit =
+        createChange("Add file", PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+    baseCommit.assertOkStatus();
+    BinaryResult originalPatch = gApi.changes().id(baseCommit.getChangeId()).current().patch();
+    createBranchWithRevision(BranchNameKey.create(project, "other"), head);
+    ChangeInput input = newPatchApplyingChangeInput("other", originalPatch.asString());
+
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+  }
+
+  @Test
+  public void createPatchApplyingChange_fromGerritPatchUsingRest_success() throws Exception {
+    String head = getHead(repo(), HEAD).name();
+    createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+    PushOneCommit.Result baseCommit =
+        createChange("Add file", PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+    baseCommit.assertOkStatus();
+    createBranchWithRevision(BranchNameKey.create(project, "other"), head);
+    RestResponse patchResp =
+        userRestSession.get("/changes/" + baseCommit.getChangeId() + "/revisions/current/patch");
+    patchResp.assertOK();
+    String originalPatch = new String(Base64.decode(patchResp.getEntityContent()), UTF_8);
+    ChangeInput input = newPatchApplyingChangeInput("other", originalPatch);
+
+    ChangeInfo info = assertCreateSucceedsUsingRest(input);
+
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+  }
+
+  @Test
+  public void createPatchApplyingChange_withParentChange_success() throws Exception {
+    Result change = createChange();
+    ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+    input.baseChange = change.getChangeId();
+
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    assertThat(gApi.changes().id(info.id).current().commit(false).parents.get(0).commit)
+        .isEqualTo(change.getCommit().getId().name());
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+  }
+
+  @Test
+  public void createPatchApplyingChange_withParentCommit_success() throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    Result baseChange = createChange("refs/heads/other");
+    PushOneCommit.Result ignoredCommit = createChange();
+    ignoredCommit.assertOkStatus();
+    ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+    input.baseCommit = baseChange.getCommit().getId().name();
+
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    assertThat(gApi.changes().id(info.id).current().commit(false).parents.get(0).commit)
+        .isEqualTo(input.baseCommit);
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+  }
+
+  @Test
+  public void createPatchApplyingChange_withEmptyTip_fails() throws Exception {
+    ChangeInput input = newPatchApplyingChangeInput("foo", "patch");
+    input.newBranch = true;
+    assertCreateFails(
+        input, BadRequestException.class, "Cannot apply patch on top of an empty tree");
+  }
+
+  @Test
+  public void createPatchApplyingChange_fromBadPatch_fails() throws Exception {
+    final String invalidPatch = "@@ -2,2 +2,3 @@ a\n" + " b\n" + "+c\n" + " d";
+    createBranch(BranchNameKey.create(project, "other"));
+    ChangeInput input = newPatchApplyingChangeInput("other", invalidPatch);
+    assertCreateFailsWithCause(
+        input, BadRequestException.class, PatchFormatException.class, "Format error");
+  }
+
+  @Test
+  public void createPatchApplyingChange_withAuthorOverride_success() throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+    input.author = new AccountInput();
+    input.author.email = "gerritlessjane@invalid";
+    // This is an email address that doesn't exist as account on the Gerrit server.
+    input.author.name = "Gerritless Jane";
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    RevisionApi rApi = gApi.changes().id(info.id).current();
+    GitPerson author = rApi.commit(false).author;
+    assertThat(author).email().isEqualTo(input.author.email);
+    assertThat(author).name().isEqualTo(input.author.name);
+    GitPerson committer = rApi.commit(false).committer;
+    assertThat(committer).email().isEqualTo(admin.getNameEmail().email());
+  }
+
+  @Test
+  public void createPatchApplyingChange_withInfeasiblePatch_fails() throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            "Adding unexpected base content, which will cause the patch to fail",
+            PATCH_FILE_NAME,
+            "unexpected base content");
+    Result conflictingChange = push.to("refs/heads/other");
+    conflictingChange.assertOkStatus();
+    ChangeInput input = newPatchApplyingChangeInput("other", MODIFICATION_PATCH_INPUT);
+
+    assertCreateFailsWithCause(
+        input, RestApiException.class, PatchApplyException.class, "Cannot apply: HunkHeader");
+  }
+
+  @Test
   @UseSystemTime
   public void sha1sOfTwoNewChangesDiffer() throws Exception {
     ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
@@ -1083,17 +1270,38 @@
 
   private ChangeInfo assertCreateSucceeds(ChangeInput in) throws Exception {
     ChangeInfo out = gApi.changes().create(in).get();
+    validateCreateSucceeds(in, out);
+    return out;
+  }
+
+  private ChangeInfo assertCreateSucceedsUsingRest(ChangeInput in) throws Exception {
+    RestResponse resp = adminRestSession.post("/changes/", in);
+    resp.assertCreated();
+    ChangeInfo res = readContentFromJson(resp, ChangeInfo.class);
+    // The original result doesn't contain any revision data.
+    ChangeInfo out = gApi.changes().id(res.changeId).get(ALL_REVISIONS, CURRENT_COMMIT);
+    validateCreateSucceeds(in, out);
+    return out;
+  }
+
+  private static <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
+    try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+      return newGson().fromJson(jsonReader, clazz);
+    }
+  }
+
+  private void validateCreateSucceeds(ChangeInput in, ChangeInfo out) throws Exception {
     assertThat(out.project).isEqualTo(in.project);
     assertThat(RefNames.fullName(out.branch)).isEqualTo(RefNames.fullName(in.branch));
-    assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
+    assertThat(out.subject).isEqualTo(Splitter.on("\n").splitToList(in.subject).get(0));
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
-    if (in.isPrivate) {
+    if (Boolean.TRUE.equals(in.isPrivate)) {
       assertThat(out.isPrivate).isTrue();
     } else {
       assertThat(out.isPrivate).isNull();
     }
-    if (in.workInProgress) {
+    if (Boolean.TRUE.equals(in.workInProgress)) {
       assertThat(out.workInProgress).isTrue();
     } else {
       assertThat(out.workInProgress).isNull();
@@ -1102,14 +1310,13 @@
     assertThat(out.submitted).isNull();
     assertThat(out.containsGitConflicts).isNull();
     assertThat(in.status).isEqualTo(ChangeStatus.NEW);
-    return out;
   }
 
   private ChangeInfo assertCreateSucceedsWithConflicts(ChangeInput in) throws Exception {
     ChangeInfo out = gApi.changes().createAsInfo(in);
     assertThat(out.project).isEqualTo(in.project);
     assertThat(RefNames.fullName(out.branch)).isEqualTo(RefNames.fullName(in.branch));
-    assertThat(out.subject).isEqualTo(in.subject.split("\n")[0]);
+    assertThat(out.subject).isEqualTo(Splitter.on("\n").splitToList(in.subject).get(0));
     assertThat(out.topic).isEqualTo(in.topic);
     assertThat(out.status).isEqualTo(in.status);
     if (in.isPrivate) {
@@ -1131,6 +1338,17 @@
     assertThat(thrown).hasMessageThat().contains(errSubstring);
   }
 
+  private void assertCreateFailsWithCause(
+      ChangeInput in,
+      Class<? extends RestApiException> errType,
+      Class<? extends Exception> causeType,
+      String causeSubstring)
+      throws Exception {
+    Throwable thrown = assertThrows(errType, () -> gApi.changes().create(in));
+    assertThat(thrown).hasCauseThat().isInstanceOf(causeType);
+    assertThat(thrown).hasCauseThat().hasMessageThat().contains(causeSubstring);
+  }
+
   // TODO(davido): Expose setting of account preferences in the API
   private void setSignedOffByFooter(boolean value) throws Exception {
     RestResponse r = adminRestSession.get("/accounts/" + admin.email() + "/preferences");
@@ -1173,6 +1391,19 @@
     return in;
   }
 
+  private ChangeInput newPatchApplyingChangeInput(String targetBranch, String patch) {
+    // create a change applying the given patch on the target branch in gerrit
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = targetBranch;
+    in.subject = "apply patch to " + targetBranch;
+    in.status = ChangeStatus.NEW;
+    ApplyPatchInput patchInput = new ApplyPatchInput();
+    patchInput.patch = patch;
+    in.patch = patchInput;
+    return in;
+  }
+
   /**
    * Create an empty commit in master, two new branches with one commit each.
    *
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index c57d285..016b1e6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -15,16 +15,24 @@
 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.allowLabelRemoval;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableSet;
 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.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -39,56 +47,141 @@
 import org.junit.Test;
 
 public class DeleteVoteIT extends AbstractDaemonTest {
+  @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
-  public void deleteVoteOnChange() throws Exception {
-    deleteVote(false);
+  public void deleteVoteOnChange_withRemoveLabelPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(false);
   }
 
   @Test
-  public void deleteVoteOnRevision() throws Exception {
-    deleteVote(true);
+  public void deleteVoteOnChange_withRemoveReviewerPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(false);
   }
 
-  private void deleteVote(boolean onRevisionLevel) throws Exception {
+  @Test
+  public void deleteVoteOnChange_noPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyCannotDeleteVote(false);
+  }
+
+  @Test
+  public void deleteVoteOnRevision_withRemoveLabelPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(true);
+  }
+
+  @Test
+  public void deleteVoteOnRevision_withRemoveReviewerPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyDeleteVote(true);
+  }
+
+  @Test
+  public void deleteVoteOnRevision_noPermission() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            blockLabelRemoval(LabelId.CODE_REVIEW)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    verifyCannotDeleteVote(true);
+  }
+
+  private void verifyDeleteVote(boolean onRevisionLevel) throws Exception {
     PushOneCommit.Result r = createChange();
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
 
     PushOneCommit.Result r2 = amendChange(r.getChangeId());
 
-    requestScopeOperations.setApiUser(user.id());
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(r.getChangeId());
+
+    TestAccount user2 = accountCreator.user2();
+    requestScopeOperations.setApiUser(user2.id());
     recommend(r.getChangeId());
 
     sender.clear();
-    String endPoint =
+    String deleteAdminVoteEndPoint =
         "/changes/"
             + r.getChangeId()
             + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
             + "/reviewers/"
-            + user.id().toString()
+            + admin.id().toString()
             + "/votes/Code-Review";
 
-    RestResponse response = adminRestSession.delete(endPoint);
+    RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
     response.assertNoContent();
 
     List<FakeEmailSender.Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     FakeEmailSender.Message msg = messages.get(0);
-    assertThat(msg.rcpt()).containsExactly(user.getNameEmail());
-    assertThat(msg.body()).contains(admin.fullName() + " has removed a vote from this change.");
+    assertThat(msg.rcpt()).containsExactly(admin.getNameEmail(), user2.getNameEmail());
+    assertThat(msg.body()).contains(user.fullName() + " has removed a vote from this change.");
     assertThat(msg.body())
-        .contains("Removed Code-Review+1 by " + user.fullName() + " <" + user.email() + ">\n");
+        .contains("Removed Code-Review+1 by " + admin.fullName() + " <" + admin.email() + ">\n");
 
-    endPoint =
+    String viewVotesEndPoint =
         "/changes/"
             + r.getChangeId()
             + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
             + "/reviewers/"
-            + user.id().toString()
+            + admin.id().toString()
             + "/votes";
 
-    response = adminRestSession.get(endPoint);
+    response = userRestSession.get(viewVotesEndPoint);
     response.assertOK();
 
     Map<String, Short> m =
@@ -99,14 +192,38 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
-    assertThat(message.author._accountId).isEqualTo(admin.id().get());
+    assertThat(message.author._accountId).isEqualTo(user.id().get());
     assertThat(message.message)
         .isEqualTo(
             String.format(
                 "Removed Code-Review+1 by %s\n",
-                AccountTemplateUtil.getAccountTemplate(user.id())));
+                AccountTemplateUtil.getAccountTemplate(admin.id())));
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
-        .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
+        .containsExactlyElementsIn(ImmutableSet.of(user2.id(), admin.id()));
+  }
+
+  private void verifyCannotDeleteVote(boolean onRevisionLevel) throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+    PushOneCommit.Result r2 = amendChange(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    recommend(r.getChangeId());
+
+    sender.clear();
+    String deleteAdminVoteEndPoint =
+        "/changes/"
+            + r.getChangeId()
+            + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
+            + "/reviewers/"
+            + admin.id().toString()
+            + "/votes/Code-Review";
+
+    RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
+    response.assertForbidden();
+
+    assertThat(sender.getMessages()).isEmpty();
   }
 
   private Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
index 15e6360..e2f4b5b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetArchiveIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -135,7 +136,7 @@
               bufferedOut.write(data, 0, count);
             }
             bufferedOut.flush();
-            archiveEntries.put(entry.getName(), out.toString());
+            archiveEntries.put(entry.getName(), out.toString(UTF_8));
           }
         }
       }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
index 29dd227..26e37f4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
@@ -60,6 +60,21 @@
   }
 
   @Test
+  public void metaDiffSubmitReq() throws Exception {
+    PushOneCommit.Result ch = createChange();
+    ChangeApi chApi = gApi.changes().id(ch.getChangeId());
+    ChangeInfo oldInfo = chApi.get();
+    chApi.setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
+    ChangeInfo newInfo = chApi.get();
+
+    ChangeInfoDifference difference =
+        chApi.metaDiff(oldInfo.metaRevId, newInfo.metaRevId, ListChangesOption.SUBMIT_REQUIREMENTS);
+
+    assertThat(difference.added().submitRequirements).isNull();
+    assertThat(difference.removed().submitRequirements).isNull();
+  }
+
+  @Test
   public void metaDiffReturnsSuccessful() throws Exception {
     PushOneCommit.Result ch = createChange();
     ChangeInfo info = gApi.changes().id(ch.getChangeId()).get();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 6ed0bf8..d5c7610 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -176,11 +176,9 @@
     BranchNameKey newBranch =
         BranchNameKey.create(r1.getChange().change().getProject(), "moveTest");
     createBranch(newBranch);
-    ResourceConflictException thrown =
-        assertThrows(
-            ResourceConflictException.class,
-            () -> move(GitUtil.getChangeId(testRepo, c).get(), newBranch.branch()));
-    assertThat(thrown).hasMessageThat().contains("Merge commit cannot be moved");
+    String changeId = GitUtil.getChangeId(testRepo, c).get();
+    move(changeId, newBranch.branch());
+    assertThat(gApi.changes().id(changeId).get().branch).isEqualTo("moveTest");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index b3592e3..c712b14 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -414,8 +414,7 @@
   public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
     try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
       u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
       u.save();
     }
     stickyVoteStoredOnSubmitOnNewPatchset();
@@ -446,7 +445,7 @@
     // during submit.
     PatchSetApproval patchSetApprovals =
         Iterables.getLast(
-            r.getChange().notes().getApprovalsWithCopied().values().stream()
+            r.getChange().notes().getApprovals().all().values().stream()
                 .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
                 .sorted(comparing(a -> a.patchSetId().get()))
                 .collect(toImmutableList()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 58e48e9..3be49df 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -99,7 +99,7 @@
         "Failed to submit 2 changes due to the following problems:\n"
             + "Change "
             + id1
-            + ": needs Code-Review");
+            + ": submit requirement 'Code-Review' is unsatisfied.");
 
     RevCommit updatedHead = projectOperations.project(project).getHead("master");
     assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index 157c93c..c4f8f2c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -38,22 +38,10 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.inject.Inject;
-import java.io.File;
-import java.io.InputStream;
-import java.nio.file.Files;
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Map;
-import java.util.zip.GZIPInputStream;
-import org.apache.commons.compress.archivers.ArchiveStreamFactory;
-import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
 import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.RefSpec;
 import org.junit.Test;
@@ -184,8 +172,6 @@
     approve(change2b.getChangeId());
     approve(change3.getChangeId());
 
-    // get a preview before submitting:
-    Map<BranchNameKey, ObjectId> preview = fetchFromSubmitPreview(change1b.getChangeId());
     submit(change1b.getChangeId());
 
     RevCommit tip1 = getRemoteLog(p1, "master").get(0);
@@ -197,23 +183,9 @@
     if (isSubmitWholeTopicEnabled()) {
       assertThat(tip2.getShortMessage()).isEqualTo(change2b.getCommit().getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(change3.getCommit().getShortMessage());
-
-      // check that the preview matched what happened:
-      assertThat(preview).hasSize(3);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p1, "refs/heads/master"));
-      assertTrees(p1, preview);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p2, "refs/heads/master"));
-      assertTrees(p2, preview);
-
-      assertThat(preview).containsKey(BranchNameKey.create(p3, "refs/heads/master"));
-      assertTrees(p3, preview);
     } else {
       assertThat(tip2.getShortMessage()).isEqualTo(initialHead2.getShortMessage());
       assertThat(tip3.getShortMessage()).isEqualTo(initialHead3.getShortMessage());
-      assertThat(preview).hasSize(1);
-      assertThat(preview.get(BranchNameKey.create(p1, "refs/heads/master"))).isNotNull();
     }
   }
 
@@ -281,13 +253,6 @@
               + "merged due to a path conflict. Please rebase the change locally "
               + "and upload the rebased commit for review.";
 
-      // Get a preview before submitting:
-      RestApiException thrown =
-          assertThrows(
-              RestApiException.class,
-              () -> gApi.changes().id(change1b.getChangeId()).current().submitPreview().close());
-      assertThat(thrown.getMessage()).isEqualTo(msg);
-
       submitWithConflict(change1b.getChangeId(), msg);
     } else {
       submit(change1b.getChangeId());
@@ -756,34 +721,4 @@
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
-
-  @Test
-  public void testPreviewSubmitTgz() throws Throwable {
-    Project.NameKey p1 = projectOperations.newProject().create();
-
-    TestRepository<?> repo1 = cloneProject(p1);
-    PushOneCommit.Result change1 = createChange(repo1, "master", "test", "a.txt", "1", "topic");
-    approve(change1.getChangeId());
-
-    // get a preview before submitting:
-    File tempfile;
-    try (BinaryResult request =
-        gApi.changes().id(change1.getChangeId()).current().submitPreview("tgz")) {
-      assertThat(request.getContentType()).isEqualTo("application/x-gzip");
-      tempfile = File.createTempFile("test", null);
-      request.writeTo(Files.newOutputStream(tempfile.toPath()));
-    }
-
-    InputStream is = new GZIPInputStream(Files.newInputStream(tempfile.toPath()));
-
-    List<String> untarredFiles = new ArrayList<>();
-    try (TarArchiveInputStream tarInputStream =
-        (TarArchiveInputStream) new ArchiveStreamFactory().createArchiveInputStream("tar", is)) {
-      TarArchiveEntry entry;
-      while ((entry = (TarArchiveEntry) tarInputStream.getNextEntry()) != null) {
-        untarredFiles.add(entry.getName());
-      }
-    }
-    assertThat(untarredFiles).containsExactly(p1.get() + ".git");
-  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index aa93815..d58ad11 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project.NameKey;
-import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
+import com.google.gerrit.exceptions.MergeUpdateException;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -136,8 +136,8 @@
     ChangeMessageModifier modifier2 = (msg, orig, tip, dest) -> msg + "A-footer: value\n";
     try (Registration registration =
         extensionRegistry.newRegistration().add(modifier1).add(modifier2)) {
-      InternalServerWithUserMessageException thrown =
-          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
+      MergeUpdateException thrown =
+          assertThrows(MergeUpdateException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause).hasMessageThat().isEqualTo("boom");
@@ -153,8 +153,8 @@
             .newRegistration()
             .add(modifier1, "modifier-1")
             .add(modifier2, "modifier-2")) {
-      InternalServerWithUserMessageException thrown =
-          assertThrows(InternalServerWithUserMessageException.class, () -> submitWithRebase());
+      MergeUpdateException thrown =
+          assertThrows(MergeUpdateException.class, () -> submitWithRebase());
       Throwable cause = Throwables.getRootCause(thrown);
       assertThat(cause).isInstanceOf(RuntimeException.class);
       assertThat(cause)
@@ -170,8 +170,7 @@
   public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
     try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
       u.getConfig()
-          .updateLabelType(
-              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
       u.save();
     }
     stickyVoteStoredOnSubmitOnNewPatchset();
@@ -202,7 +201,7 @@
     // submit.
     PatchSetApproval patchSetApprovals =
         Iterables.getLast(
-            r.getChange().notes().getApprovalsWithCopied().values().stream()
+            r.getChange().notes().getApprovals().all().values().stream()
                 .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
                 .sorted(comparing(a -> a.patchSetId().get()))
                 .collect(toImmutableList()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index a63d60a..0a9a098 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -306,7 +306,10 @@
   private void assertChangeSetMergeable(ChangeData change, boolean expected)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
           PermissionBackendException {
-    ChangeSet cs = mergeSuperSet.get().completeChangeSet(change.change(), user(admin));
+    ChangeSet cs =
+        mergeSuperSet
+            .get()
+            .completeChangeSet(change.change(), user(admin), /* includingTopicClosure= */ false);
     assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 3850e13..80bedcd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.entities.Permission.READ;
 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 static java.util.stream.Collectors.toList;
 
@@ -81,23 +82,58 @@
   }
 
   @Test
-  @GerritConfig(name = "accounts.visibility", value = "NONE")
-  public void suggestReviewersNoResult1() throws Exception {
+  @GerritConfig(name = "suggest.accounts", value = "false")
+  public void suggestReviewers_withSuggestDisabled() throws Exception {
     String changeId = createChange().getChangeId();
+
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void suggestReviewers_accountVisibilityNone_noAccountsSuggested() throws Exception {
+    // Change is created by admin
+    String changeId = createChange().getChangeId();
+
+    requestScopeOperations.setApiUser(user2.id());
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
     assertThat(reviewers).isEmpty();
   }
 
   @Test
   @GerritConfig(name = "suggest.from", value = "1")
   @GerritConfig(name = "accounts.visibility", value = "NONE")
-  public void suggestReviewersNoResult2() throws Exception {
+  public void suggestReviewers_accountVisibilityNone_withSuggestFrom_noAccountsSuggested()
+      throws Exception {
+    // Change is created by admin
     String changeId = createChange().getChangeId();
+
+    requestScopeOperations.setApiUser(user2.id());
     List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
     assertThat(reviewers).isEmpty();
   }
 
   @Test
+  @GerritConfig(name = "accounts.visibility", value = "NONE")
+  public void suggestReviewers_accountVisibilityNone_withGlobalCapability_allAccountsSuggested()
+      throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_ALL_ACCOUNTS).group(REGISTERED_USERS))
+        .update();
+    String changeId = createChange().getChangeId();
+
+    requestScopeOperations.setApiUser(user2.id());
+    List<SuggestedReviewerInfo> reviewers = suggestReviewers(changeId, name("u"), 6);
+
+    assertReviewers(reviewers, ImmutableList.of(user1, user2, user3), ImmutableList.of(group2));
+  }
+
+  @Test
   public void suggestReviewersChange() throws Exception {
     String changeId = createChange().getChangeId();
     testSuggestReviewersChange(changeId);
@@ -137,7 +173,7 @@
   }
 
   @Test
-  @GerritConfig(name = "index.maxTerms", value = "10")
+  @GerritConfig(name = "index.maxTerms", value = "20")
   public void suggestReviewersTooManyQueryTerms() throws Exception {
     String changeId = createChange().getChangeId();
 
@@ -147,14 +183,19 @@
     for (int i = 1; i <= 9; i++) {
       query.append(name("u")).append(" ");
     }
+    // The query expands to (2 * predicates + 1) terms = 2 * 9 + 1 = 19:
+    // (2 * predicates) since the default predicate expands to two "name" OR "username" predicates.
+    // + 1 since the query processor appends a predicate to search for active accounts only.
     assertThat(suggestReviewers(changeId, query.toString())).isNotEmpty();
 
-    // Do a query which exceed index.maxTerms succeeds (10 terms plus 'inactive:1' term which is
+    // Do a query which exceed index.maxTerms succeeds (10 * 2 terms plus 'inactive:1' term which is
     // implicitly added).
     query.append(name("u"));
     BadRequestException exception =
         assertThrows(BadRequestException.class, () -> suggestReviewers(changeId, query.toString()));
-    assertThat(exception).hasMessageThat().isEqualTo("too many terms in query");
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("too many terms in query: 21 terms (max = 20)");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index ef5e7dc..ab8e4d8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.restapi.config.PostCaches;
 import com.google.inject.Inject;
 import java.util.Arrays;
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
index a161ec4..164f683 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -23,7 +23,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
+import com.google.gerrit.server.cache.CacheInfo;
 import com.google.inject.Inject;
 import org.junit.Test;
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
index 247d63b..8765360 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetCacheIT.java
@@ -18,8 +18,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.restapi.config.ListCaches.CacheType;
+import com.google.gerrit.server.cache.CacheInfo;
 import org.junit.Test;
 
 public class GetCacheIT extends AbstractDaemonTest {
@@ -31,7 +30,7 @@
     CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
 
     assertThat(result.name).isEqualTo("accounts");
-    assertThat(result.type).isEqualTo(CacheType.MEM);
+    assertThat(result.type).isEqualTo(CacheInfo.CacheType.MEM);
     assertThat(result.entries.mem).isAtLeast(1L);
     assertThat(result.averageGet).isNotNull();
     assertThat(result.averageGet).endsWith("s");
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
index 6d2c6dfa..a9e3cf6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
 import com.google.gson.reflect.TypeToken;
 import java.util.List;
@@ -41,6 +42,7 @@
     userRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId()).assertNotFound();
   }
 
+  @Nullable
   private String getLogFileCompressorTaskId() throws Exception {
     RestResponse r = adminRestSession.get("/config/server/tasks/");
     List<TaskInfo> result =
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
index 8baeffc..be21436 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ListCachesIT.java
@@ -21,8 +21,7 @@
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.restapi.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.restapi.config.ListCaches.CacheType;
+import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gson.reflect.TypeToken;
 import java.util.Arrays;
 import java.util.List;
@@ -40,7 +39,7 @@
 
     assertThat(result).containsKey("accounts");
     CacheInfo accountsCacheInfo = result.get("accounts");
-    assertThat(accountsCacheInfo.type).isEqualTo(CacheType.MEM);
+    assertThat(accountsCacheInfo.type).isEqualTo(CacheInfo.CacheType.MEM);
     assertThat(accountsCacheInfo.entries.mem).isAtLeast(1L);
     assertThat(accountsCacheInfo.averageGet).isNotNull();
     assertThat(accountsCacheInfo.averageGet).endsWith("s");
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 97288a8..8131352 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -192,6 +192,9 @@
 
     // user
     assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
+
+    // submit requirement columns in dashboard
+    assertThat(i.submitRequirementDashboardColumns).isEmpty();
   }
 
   @Test
@@ -202,6 +205,15 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "dashboard.submitRequirementColumns",
+      values = {"Code-Review", "Verified"})
+  public void serverConfigWithMultipleSubmitRequirementColumn() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+    assertThat(i.submitRequirementDashboardColumns).containsExactly("Code-Review", "Verified");
+  }
+
+  @Test
   @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
   public void mergeabilityComputationBehavior_neverCompute() throws Exception {
     ServerInfo i = gApi.config().server().getInfo();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index 531357a..793f256 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.ANNOTATED;
 import static com.google.gerrit.acceptance.rest.project.AbstractPushTag.TagType.LIGHTWEIGHT;
 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.permissionKey;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
@@ -29,6 +30,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.testing.ConfigSuite;
@@ -191,33 +193,57 @@
     pushTagDeletion(tagName, Status.OK);
   }
 
+  @Test
+  public void pushToNonVisibleTagIsRejected() throws Exception {
+    allowTagCreation();
+    allowPushOnRefsTags();
+
+    String tagName = pushTagForExistingCommit(Status.OK);
+
+    removeReadFromRefsTags();
+    removeReadFromRefsHeads();
+
+    pushTag(
+        tagName,
+        /* newCommit= */ true,
+        /* force= */ false,
+        Status.REJECTED_OTHER_REASON,
+        /* expectedMessage= */ String.format(
+            "Cannot create ref '%s' because it already exists.", tagRef(tagName)));
+  }
+
   private String pushTagForExistingCommit(Status expectedStatus) throws Exception {
-    return pushTag(null, false, false, expectedStatus);
+    return pushTag(null, false, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private String pushTagForNewCommit(Status expectedStatus) throws Exception {
-    return pushTag(null, true, false, expectedStatus);
+    return pushTag(null, true, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void fastForwardTagToExistingCommit(String tagName, Status expectedStatus)
       throws Exception {
-    pushTag(tagName, false, false, expectedStatus);
+    pushTag(tagName, false, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void fastForwardTagToNewCommit(String tagName, Status expectedStatus) throws Exception {
-    pushTag(tagName, true, false, expectedStatus);
+    pushTag(tagName, true, false, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void forceUpdateTagToExistingCommit(String tagName, Status expectedStatus)
       throws Exception {
-    pushTag(tagName, false, true, expectedStatus);
+    pushTag(tagName, false, true, expectedStatus, /* expectedMessage= */ null);
   }
 
   private void forceUpdateTagToNewCommit(String tagName, Status expectedStatus) throws Exception {
-    pushTag(tagName, true, true, expectedStatus);
+    pushTag(tagName, true, true, expectedStatus, /* expectedMessage= */ null);
   }
 
-  private String pushTag(String tagName, boolean newCommit, boolean force, Status expectedStatus)
+  private String pushTag(
+      String tagName,
+      boolean newCommit,
+      boolean force,
+      Status expectedStatus,
+      @Nullable String expectedMessage)
       throws Exception {
     if (force) {
       testRepo.reset(initialHead);
@@ -256,6 +282,9 @@
             : GitUtil.pushTag(testRepo, tagName, !createTag);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(tagRef);
     assertWithMessage(tagType.name()).that(refUpdate.getStatus()).isEqualTo(expectedStatus);
+    if (expectedMessage != null) {
+      assertWithMessage(tagType.name()).that(refUpdate.getMessage()).isEqualTo(expectedMessage);
+    }
     return tagName;
   }
 
@@ -352,6 +381,22 @@
         .update();
   }
 
+  private void removeReadFromRefsTags() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/tags/*").group(REGISTERED_USERS))
+        .update();
+  }
+
+  private void removeReadFromRefsHeads() throws Exception {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+  }
+
   private void commit(PersonIdent ident, String subject) throws Exception {
     commitBuilder().ident(ident).message(subject + " (" + System.nanoTime() + ")").create();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index edcb1f9..1fcc69a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -10,7 +10,7 @@
         ":project",
         ":push_tag_util",
         ":refassert",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
     ],
 ) for f in glob(["*IT.java"])]
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index 93ce255..d45c90b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -16,6 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
+import static com.google.gerrit.acceptance.GitUtil.assertPushRejected;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
@@ -24,9 +27,14 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 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.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+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.Account;
@@ -42,6 +50,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.events.RefReceivedEvent;
 import com.google.gerrit.server.git.validators.RefOperationValidationListener;
 import com.google.gerrit.server.git.validators.ValidationMessage;
@@ -55,12 +64,14 @@
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
 import org.junit.Before;
 import org.junit.Test;
 
 public class CreateBranchIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private GroupOperations groupOperations;
   @Inject private ExtensionRegistry extensionRegistry;
 
   private BranchNameKey testBranch;
@@ -324,8 +335,8 @@
     assertCreateFails(
         testBranch,
         "refs/heads/non-existing",
-        BadRequestException.class,
-        "invalid revision \"refs/heads/non-existing\"");
+        UnprocessableEntityException.class,
+        "base revision \"refs/heads/non-existing\" not found");
   }
 
   @Test
@@ -333,8 +344,8 @@
     assertCreateFails(
         testBranch,
         "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
-        BadRequestException.class,
-        "invalid revision \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\"");
+        UnprocessableEntityException.class,
+        "base revision \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\" not found");
   }
 
   @Test
@@ -342,8 +353,23 @@
     assertCreateFails(
         testBranch,
         "invalid\trevision",
+        UnprocessableEntityException.class,
+        "base revision \"invalid\trevision\" is invalid");
+  }
+
+  @Test
+  public void cannotCreateWithNonCommitAsRevision() throws Exception {
+    String treeId =
+        projectOperations
+            .project(testBranch.project())
+            .getHead("refs/heads/master")
+            .getTree()
+            .name();
+    assertCreateFails(
+        testBranch,
+        treeId,
         BadRequestException.class,
-        "invalid revision \"invalid\trevision\"");
+        "base revision \"" + treeId + "\" is not a commit");
   }
 
   @Test
@@ -410,6 +436,131 @@
     assertThat(ex).hasMessageThat().isEqualTo("ref must match URL");
   }
 
+  @Test
+  public void createBranchViaRestApiFailsIfCommitIsInvalid() throws Exception {
+    BranchInput input = new BranchInput();
+    input.ref = "new";
+
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    testRefOperationValidationListener.doReject = true;
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      ResourceConflictException ex =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> gApi.projects().name(project.get()).branch(input.ref).create(input));
+      assertThat(ex)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Validation for creation of ref 'refs/heads/new' in project %s failed:\n%s",
+                  project, TestRefOperationValidationListener.FAILURE_MESSAGE));
+    }
+  }
+
+  @Test
+  public void createBranchViaRestApiWithValidationOptions() throws Exception {
+    BranchInput input = new BranchInput();
+    input.ref = "new";
+    input.validationOptions = ImmutableMap.of("key", "value");
+
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      gApi.projects().name(project.get()).branch(input.ref).create(input);
+      assertThat(testRefOperationValidationListener.refReceivedEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
+  public void createBranchViaPushFailsIfCommitIsInvalid() throws Exception {
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    testRefOperationValidationListener.doReject = true;
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      PushResult r =
+          pushHead(
+              testRepo,
+              "refs/heads/new",
+              /* pushTags= */ false,
+              /* force= */ false,
+              /* pushOptions= */ ImmutableList.of());
+      assertPushRejected(
+          r,
+          "refs/heads/new",
+          String.format(
+              "Validation for creation of ref 'refs/heads/new' in project %s failed:\n%s",
+              project, TestRefOperationValidationListener.FAILURE_MESSAGE));
+    }
+  }
+
+  @Test
+  public void createBranchViaPushWithValidationOptions() throws Exception {
+    TestRefOperationValidationListener testRefOperationValidationListener =
+        new TestRefOperationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testRefOperationValidationListener)) {
+      PushResult r =
+          pushHead(
+              testRepo,
+              "refs/heads/new",
+              /* pushTags= */ false,
+              /* force= */ false,
+              /* pushOptions= */ ImmutableList.of("key=value"));
+      assertPushOk(r, "refs/heads/new");
+      assertThat(testRefOperationValidationListener.refReceivedEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
+  public void createBranchRevisionVisibility() throws Exception {
+    AccountGroup.UUID privilegedGroupUuid =
+        groupOperations.newGroup().name(name("privilegedGroup")).create();
+    TestAccount privilegedUser =
+        accountCreator.create(
+            "privilegedUser", "privilegedUser@example.com", "privilegedUser", null);
+    groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id()).update();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/secret/*").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/secret/*").group(privilegedGroupUuid))
+        .add(allow(Permission.READ).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(allow(Permission.CREATE).ref("refs/heads/*").group(REGISTERED_USERS))
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(REGISTERED_USERS))
+        .update();
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "Configure", "file.txt", "contents");
+    PushOneCommit.Result result = push.to("refs/heads/secret/main");
+    result.assertOkStatus();
+    RevCommit secretCommit = result.getCommit();
+    requestScopeOperations.setApiUser(privilegedUser.id());
+    BranchInfo info = gApi.projects().name(project.get()).branch("refs/heads/secret/main").get();
+    assertThat(info.revision).isEqualTo(secretCommit.name());
+    TestAccount unprivileged =
+        accountCreator.create("unprivileged", "unprivileged@example.com", "unprivileged", null);
+    requestScopeOperations.setApiUser(unprivileged.id());
+    assertThrows(
+        ResourceNotFoundException.class,
+        () -> gApi.projects().name(project.get()).branch("refs/heads/secret/main").get());
+    BranchInput branchInput = new BranchInput();
+    branchInput.ref = "public";
+    branchInput.revision = secretCommit.name();
+    assertThrows(
+        AuthException.class,
+        () -> gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput));
+
+    branchInput.revision = "refs/heads/secret/main";
+    assertThrows(
+        AuthException.class,
+        () -> gApi.projects().name(project.get()).branch(branchInput.ref).create(branchInput));
+  }
+
   private void blockCreateReference() throws Exception {
     projectOperations
         .project(project)
@@ -454,4 +605,24 @@
       assertThat(thrown).hasMessageThat().contains(errMsg);
     }
   }
+
+  private static class TestRefOperationValidationListener
+      implements RefOperationValidationListener {
+    static final String FAILURE_MESSAGE = "failure from test";
+
+    public boolean doReject;
+    public RefReceivedEvent refReceivedEvent;
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refReceivedEvent)
+        throws ValidationException {
+      this.refReceivedEvent = refReceivedEvent;
+
+      if (doReject) {
+        throw new ValidationException(FAILURE_MESSAGE);
+      }
+
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
index 0c221aa..7b42d93 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.entities.RefNames.REFS_HEADS;
 
@@ -21,6 +22,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.IdString;
 import org.junit.Test;
 
 public class CreateChangeIT extends AbstractDaemonTest {
@@ -43,7 +45,44 @@
     ChangeInput input = new ChangeInput();
     input.branch = "foo";
     input.subject = "subject";
-    RestResponse cr = adminRestSession.post("/projects/" + project.get() + "/create.change", input);
-    cr.assertCreated();
+    RestResponse response =
+        adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    response.assertCreated();
+  }
+
+  @Test
+  public void nonMatchingProjectIsRejected() throws Exception {
+    ChangeInput input = new ChangeInput();
+    input.project = "non-matching-project";
+    input.branch = "master";
+    input.subject = "subject";
+    RestResponse response =
+        adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    response.assertBadRequest();
+    assertThat(response.getEntityContent()).isEqualTo("project must match URL");
+  }
+
+  @Test
+  public void matchingProjectIsAccepted() throws Exception {
+    ChangeInput input = new ChangeInput();
+    input.project = project.get();
+    input.branch = "master";
+    input.subject = "subject";
+    RestResponse response =
+        adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+    response.assertCreated();
+  }
+
+  @Test
+  public void matchingProjectWithTrailingSlashIsAccepted() throws Exception {
+    ChangeInput input = new ChangeInput();
+    input.project = project.get() + "/";
+    input.branch = "master";
+    input.subject = "subject";
+    RestResponse response =
+        adminRestSession.post(
+            "/projects/" + IdString.fromDecoded(project.get() + "/").encoded() + "/create.change",
+            input);
+    response.assertCreated();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index dfe69f9..462c76f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -224,6 +224,19 @@
   }
 
   @Test
+  public void createWithNameAndDescription() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.description = "Foo label description";
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("Foo").create(input).get();
+
+    assertThat(createdLabel.name).isEqualTo("Foo");
+    assertThat(createdLabel.description).isEqualTo("Foo label description");
+  }
+
+  @Test
   public void createWithNameAndValuesOnly() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
@@ -238,15 +251,6 @@
     assertThat(createdLabel.defaultValue).isEqualTo(0);
     assertThat(createdLabel.branches).isNull();
     assertThat(createdLabel.canOverride).isTrue();
-    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();
-    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(createdLabel.copyValues).isNull();
     assertThat(createdLabel.allowPostSubmit).isTrue();
     assertThat(createdLabel.ignoreSelfApproval).isNull();
   }
@@ -386,50 +390,6 @@
   }
 
   @Test
-  public void createWithCopyAnyScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAnyScore = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAnyScore).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAnyScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAnyScore = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAnyScore).isNull();
-  }
-
-  @Test
-  public void createWithCopyMinScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMinScore = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMinScore).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyMinScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMinScore = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMinScore).isNull();
-  }
-
-  @Test
   public void createWithCopyCondition() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
@@ -481,149 +441,6 @@
   }
 
   @Test
-  public void createWithCopyMaxScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMaxScore = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMaxScore).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyMaxScore() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyMaxScore = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyMaxScore).isNull();
-  }
-
-  @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");
-    input.copyAllScoresIfNoChange = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoChange).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresIfNoChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoChange = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoChange).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresIfNoCodeChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoCodeChange = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresIfNoCodeChange() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresIfNoCodeChange = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresIfNoCodeChange).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresOnTrivialRebase() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnTrivialRebase = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresOnTrivialRebase() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnTrivialRebase = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnTrivialRebase).isNull();
-  }
-
-  @Test
-  public void createWithCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnMergeFirstParentUpdate = true;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-  }
-
-  @Test
-  public void createWithoutCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyAllScoresOnMergeFirstParentUpdate = false;
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-  }
-
-  @Test
-  public void createWithCopyValues() throws Exception {
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
-    input.copyValues = ImmutableList.of((short) -1, (short) 1);
-
-    LabelDefinitionInfo createdLabel =
-        gApi.projects().name(project.get()).label("foo").create(input).get();
-    assertThat(createdLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
-  }
-
-  @Test
   public void createWithAllowPostSubmit() 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/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index ce92536..d8b0cb1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -37,6 +37,8 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.inject.Inject;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -65,10 +67,10 @@
   }
 
   @Test
-  public void deleteBranchByProjectOwner() throws Exception {
+  public void deleteBranchByProjectOwner_Forbidden() throws Exception {
     grantOwner();
     requestScopeOperations.setApiUser(user.id());
-    assertDeleteSucceeds(testBranch);
+    assertDeleteForbidden(testBranch);
   }
 
   @Test
@@ -78,14 +80,6 @@
   }
 
   @Test
-  public void deleteBranchByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
-    grantOwner();
-    blockForcePush();
-    requestScopeOperations.setApiUser(user.id());
-    assertDeleteForbidden(testBranch);
-  }
-
-  @Test
   public void deleteBranchByUserWithForcePushPermission() throws Exception {
     grantForcePush();
     requestScopeOperations.setApiUser(user.id());
@@ -192,6 +186,26 @@
     assertThat(thrown).hasMessageThat().contains("not allowed to delete HEAD");
   }
 
+  @Test
+  public void deleteRefsForBranch() throws Exception {
+    BranchNameKey refsForBranch = BranchNameKey.create(project, "refs/for/master");
+
+    // Creating a branch under refs/for/ is not allowed through the API, hence create it directly in
+    // the remote repo.
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      repo.branch(refsForBranch.branch()).commit().message("Initial empty commit").create();
+    }
+
+    assertThat(branch(refsForBranch).get().canDelete).isTrue();
+    String branchRev = branch(refsForBranch).get().revision;
+
+    branch(refsForBranch).delete();
+
+    eventRecorder.assertRefUpdatedEvents(project.get(), refsForBranch.branch(), branchRev, null);
+    assertThrows(ResourceNotFoundException.class, () -> branch(refsForBranch).get());
+  }
+
   private void blockForcePush() throws Exception {
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 7e60395..8c0836d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -59,10 +59,10 @@
   }
 
   @Test
-  public void deleteTagByProjectOwner() throws Exception {
+  public void deleteTagByProjectOwner_Forbidden() throws Exception {
     grantOwner();
     requestScopeOperations.setApiUser(user.id());
-    assertDeleteSucceeds();
+    assertDeleteForbidden();
   }
 
   @Test
@@ -72,14 +72,6 @@
   }
 
   @Test
-  public void deleteTagByProjectOwnerForcePushBlocked_Forbidden() throws Exception {
-    grantOwner();
-    blockForcePush();
-    requestScopeOperations.setApiUser(user.id());
-    assertDeleteForbidden();
-  }
-
-  @Test
   public void deleteTagByUserWithForcePushPermission() throws Exception {
     grantForcePush();
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
index a7f3174..8dce9c3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -18,16 +18,17 @@
 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.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
-@NoHttpd
 public class FileBranchIT extends AbstractDaemonTest {
 
   private BranchNameKey branch;
@@ -47,6 +48,24 @@
   }
 
   @Test
+  public void getFileFromNonExistingBranch() throws Exception {
+    RestResponse response =
+        adminRestSession.get(
+            String.format("/projects/%s/branches/non-existing/files/path", project.get()));
+    response.assertNotFound();
+  }
+
+  @Test
+  public void getFileFromSymbolicRefPointingToAnUnbornBranch() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing");
+    }
+    RestResponse response =
+        adminRestSession.get(String.format("/projects/%s/branches/HEAD/files/path", project.get()));
+    response.assertNotFound();
+  }
+
+  @Test
   public void getNonExistingFile() throws Exception {
     assertThrows(ResourceNotFoundException.class, () -> branch().file("does-not-exist"));
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
index 74ba48e..fa8b79a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
@@ -36,7 +36,7 @@
 
   @Before
   public void setUp() throws Exception {
-    baseConfig.setString("cache", "diff", "timeout", "1 minute");
+    baseConfig.setString("cache", "git_file_diff", "timeout", "1 minute");
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
     addCommit(
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
index 302d827..c29762e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -103,7 +103,6 @@
               "foo",
               labelType -> {
                 labelType.setCanOverride(false);
-                labelType.setCopyAllScoresIfNoChange(false);
                 labelType.setAllowPostSubmit(false);
               });
       u.save();
@@ -111,15 +110,6 @@
 
     LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
     assertThat(fooLabel.canOverride).isNull();
-    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();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(fooLabel.copyValues).isNull();
     assertThat(fooLabel.allowPostSubmit).isNull();
     assertThat(fooLabel.ignoreSelfApproval).isNull();
   }
@@ -134,14 +124,7 @@
           .updateLabelType(
               "foo",
               labelType -> {
-                labelType.setCopyAnyScore(true);
-                labelType.setCopyMinScore(true);
-                labelType.setCopyMaxScore(true);
-                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
-                labelType.setCopyAllScoresIfNoCodeChange(true);
-                labelType.setCopyAllScoresOnTrivialRebase(true);
-                labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
-                labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+                labelType.setCopyCondition("is:MIN OR is:MAX");
                 labelType.setIgnoreSelfApproval(true);
               });
       u.save();
@@ -149,16 +132,8 @@
 
     LabelDefinitionInfo fooLabel = gApi.projects().name(project.get()).label("foo").get();
     assertThat(fooLabel.canOverride).isTrue();
-    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();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-    assertThat(fooLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
     assertThat(fooLabel.allowPostSubmit).isTrue();
+    assertThat(fooLabel.copyCondition).isEqualTo("is:MIN OR is:MAX");
     assertThat(fooLabel.ignoreSelfApproval).isTrue();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
index e9aa589..71ee90c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
@@ -40,8 +40,8 @@
     ImmutableMap<String, String> want =
         ImmutableMap.of(
             " 0", "No score",
-            "-1", "I would prefer this is not merged as is",
-            "-2", "This shall not be merged",
+            "-1", "I would prefer this is not submitted as is",
+            "-2", "This shall not be submitted",
             "+1", "Looks good to me, but someone else must approve",
             "+2", "Looks good to me, approved");
     assertThat(l.values).isEqualTo(want);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 9e31026..7f2a924 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.LabelDefinitionInfo;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 
@@ -35,21 +36,17 @@
             " 0",
             "No score",
             "-1",
-            "I would prefer this is not merged as is",
+            "I would prefer this is not submitted as is",
             "-2",
-            "This shall not be merged");
+            "This shall not be submitted");
     assertThat(codeReviewLabel.defaultValue).isEqualTo(0);
     assertThat(codeReviewLabel.branches).isNull();
     assertThat(codeReviewLabel.canOverride).isTrue();
-    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();
-    assertThat(codeReviewLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(codeReviewLabel.copyValues).isNull();
+    assertThat(codeReviewLabel.copyCondition)
+        .isEqualTo(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
     assertThat(codeReviewLabel.allowPostSubmit).isTrue();
     assertThat(codeReviewLabel.ignoreSelfApproval).isNull();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
index 3c8357b..b2ececc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListChildProjectsIT.java
@@ -32,7 +32,7 @@
 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.apache.commons.lang3.RandomStringUtils;
 import org.junit.Test;
 
 @NoHttpd
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index a397693..7a717d1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.inject.Inject;
 import java.util.List;
+import java.util.Optional;
 import org.junit.Test;
 
 @NoHttpd
@@ -100,6 +101,24 @@
   }
 
   @Test
+  public void labelWithDescription() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              "foo", labelType -> labelType.setDescription(Optional.of("foo label description")));
+      u.save();
+    }
+
+    List<LabelDefinitionInfo> labels = gApi.projects().name(project.get()).labels().get();
+    assertThat(labelNames(labels)).containsExactly("foo");
+
+    LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
+    assertThat(fooLabel.description).isEqualTo("foo label description");
+  }
+
+  @Test
   public void labelLimitedToBranches() throws Exception {
     configLabel(
         "foo", LabelFunction.NO_OP, ImmutableList.of("refs/heads/master", "^refs/heads/stable-.*"));
@@ -122,7 +141,6 @@
               "foo",
               labelType -> {
                 labelType.setCanOverride(false);
-                labelType.setCopyAllScoresIfNoChange(false);
                 labelType.setAllowPostSubmit(false);
               });
       u.save();
@@ -133,15 +151,6 @@
 
     LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
     assertThat(fooLabel.canOverride).isNull();
-    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();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-    assertThat(fooLabel.copyValues).isNull();
     assertThat(fooLabel.allowPostSubmit).isNull();
     assertThat(fooLabel.ignoreSelfApproval).isNull();
   }
@@ -156,14 +165,7 @@
           .updateLabelType(
               "foo",
               labelType -> {
-                labelType.setCopyAnyScore(true);
-                labelType.setCopyMinScore(true);
-                labelType.setCopyMaxScore(true);
-                labelType.setCopyAllScoresIfListOfFilesDidNotChange(true);
-                labelType.setCopyAllScoresIfNoCodeChange(true);
-                labelType.setCopyAllScoresOnTrivialRebase(true);
-                labelType.setCopyAllScoresOnMergeFirstParentUpdate(true);
-                labelType.setCopyValues(ImmutableList.of((short) -1, (short) 1));
+                labelType.setCopyCondition("is:MIN OR is:MAX");
                 labelType.setIgnoreSelfApproval(true);
               });
       u.save();
@@ -174,15 +176,7 @@
 
     LabelDefinitionInfo fooLabel = Iterables.getOnlyElement(labels);
     assertThat(fooLabel.canOverride).isTrue();
-    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();
-    assertThat(fooLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-    assertThat(fooLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
+    assertThat(fooLabel.copyCondition).isEqualTo("is:MIN OR is:MAX");
     assertThat(fooLabel.allowPostSubmit).isTrue();
     assertThat(fooLabel.ignoreSelfApproval).isTrue();
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index 2e274d9..e69f781 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 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.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableSet;
@@ -143,7 +143,7 @@
   public void listProjectsToOutputStream() throws Exception {
     int numInitialProjects = gApi.projects().list().get().size();
     int numTestProjects = 5;
-    List<String> testProjects = createProjects("zzz_testProject", numTestProjects);
+    ImmutableSet<String> testProjects = createProjects("zzz_testProject", numTestProjects);
     try (ByteArrayOutputStream displayOut = new ByteArrayOutputStream()) {
 
       listProjects.setStart(numInitialProjects);
@@ -153,7 +153,7 @@
           Splitter.on("\n")
               .omitEmptyStrings()
               .splitToList(new String(displayOut.toByteArray(), UTF_8));
-      assertThat(lines).isEqualTo(testProjects);
+      assertThat(lines).isEqualTo(testProjects.asList());
     }
   }
 
@@ -173,8 +173,7 @@
 
     int numInitialProjects = gApi.projects().list().get().size();
     int numTestProjects = 5;
-    Set<String> testProjects =
-        ImmutableSet.copyOf(createProjects("zzz_testProject", numTestProjects));
+    ImmutableSet<String> testProjects = createProjects("zzz_testProject", numTestProjects);
     try (ByteArrayOutputStream displayOut = new ByteArrayOutputStream()) {
 
       listProjects.setStart(numInitialProjects);
@@ -191,11 +190,11 @@
     }
   }
 
-  private List<String> createProjects(String prefix, int numProjects) {
+  private ImmutableSet<String> createProjects(String prefix, int numProjects) {
     return IntStream.range(0, numProjects)
         .mapToObj(i -> projectOperations.newProject().name(prefix + i).create())
         .map(Project.NameKey::get)
-        .collect(toList());
+        .collect(toImmutableSet());
   }
 
   @Test
@@ -227,7 +226,7 @@
 
     assertThatNameList(gApi.projects().list().withRegex(".*some").get())
         .containsExactly(projectAwesome);
-    String r = ("lpwr-some-project$").replace(".", "\\.");
+    String r = "lpwr-some-project$".replace(".", "\\.");
     assertThatNameList(gApi.projects().list().withRegex(r).get()).containsExactly(someProject);
     assertThatNameList(gApi.projects().list().withRegex(".*").get())
         .containsExactly(
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index b4938c1..b4731db 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -94,6 +94,20 @@
   }
 
   @Test
+  public void updateDescription() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.description = "Code review label description";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
+    assertThat(updatedLabel.description).isEqualTo("Code review label description");
+
+    input.description = "";
+    updatedLabel = gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input);
+    assertThat(updatedLabel.description).isNull();
+  }
+
+  @Test
   public void nameIsTrimmed() throws Exception {
     LabelDefinitionInput input = new LabelDefinitionInput();
     input.name = " Foo-Review ";
@@ -482,40 +496,6 @@
   }
 
   @Test
-  public void setCopyAnyScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAnyScore = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAnyScore).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
-  }
-
-  @Test
-  public void unsetCopyAnyScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAnyScore(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAnyScore = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAnyScore).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAnyScore).isNull();
-  }
-
-  @Test
   public void setCopyCondition() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
@@ -593,361 +573,6 @@
   }
 
   @Test
-  public void setCopyMinScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMinScore = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMinScore).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
-  }
-
-  @Test
-  public void unsetCopyMinScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyMinScore(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMinScore = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMinScore).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
-  }
-
-  @Test
-  public void setCopyMaxScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMaxScore = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMaxScore).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
-  }
-
-  @Test
-  public void unsetCopyMaxScore() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyMaxScore(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyMaxScore = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyMaxScore).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyMaxScore).isNull();
-  }
-
-  @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)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresIfNoChange(false));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoChange = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoChange).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresIfNoChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoChange = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoChange).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoChange)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresIfNoCodeChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoCodeChange = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoCodeChange).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresIfNoCodeChange() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresIfNoCodeChange(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresIfNoCodeChange = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresIfNoCodeChange).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresIfNoCodeChange)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresOnTrivialRebase() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnTrivialRebase = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnTrivialRebase).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresOnTrivialRebase() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresOnTrivialRebase(true));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnTrivialRebase = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnTrivialRebase).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyAllScoresOnTrivialRebase)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnMergeFirstParentUpdate = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnMergeFirstParentUpdate).isTrue();
-
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isTrue();
-  }
-
-  @Test
-  public void unsetCopyAllScoresOnMergeFirstParentUpdate() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setCopyAllScoresOnMergeFirstParentUpdate(true));
-      u.save();
-    }
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isTrue();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyAllScoresOnMergeFirstParentUpdate = false;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyAllScoresOnMergeFirstParentUpdate).isNull();
-
-    assertThat(
-            gApi.projects()
-                .name(project.get())
-                .label("foo")
-                .get()
-                .copyAllScoresOnMergeFirstParentUpdate)
-        .isNull();
-  }
-
-  @Test
-  public void setCopyValues() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyValues = ImmutableList.of((short) -1, (short) 1);
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyValues).containsExactly((short) -1, (short) 1).inOrder();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues)
-        .containsExactly((short) -1, (short) 1)
-        .inOrder();
-  }
-
-  @Test
-  public void unsetCopyValues() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig()
-          .updateLabelType("foo", lt -> lt.setCopyValues(ImmutableList.of((short) -1, (short) 1)));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNotEmpty();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.copyValues = ImmutableList.of();
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.copyValues).isNull();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().copyValues).isNull();
-  }
-
-  @Test
-  public void setAllowPostSubmit() throws Exception {
-    configLabel("foo", LabelFunction.NO_OP);
-    try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().updateLabelType("foo", lt -> lt.setAllowPostSubmit(false));
-      u.save();
-    }
-    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isNull();
-
-    LabelDefinitionInput input = new LabelDefinitionInput();
-    input.allowPostSubmit = true;
-
-    LabelDefinitionInfo updatedLabel =
-        gApi.projects().name(project.get()).label("foo").update(input);
-    assertThat(updatedLabel.allowPostSubmit).isTrue();
-
-    assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isTrue();
-  }
-
-  @Test
   public void unsetAllowPostSubmit() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     assertThat(gApi.projects().name(project.get()).label("foo").get().allowPostSubmit).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index b1879f6..795e22c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -39,9 +39,10 @@
 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.server.project.ProjectConfig;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.List;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -222,14 +223,14 @@
     assertThat(result.ref).isEqualTo(R_TAGS + input.ref);
     assertThat(result.revision).isEqualTo(input.revision);
     assertThat(result.canDelete).isTrue();
-    assertThat(result.created).isEqualTo(timestamp(r));
+    assertThat(result.created.toInstant()).isEqualTo(instant(r));
 
     input.ref = "refs/tags/v2.0";
     result = tag(input.ref).create(input).get();
     assertThat(result.ref).isEqualTo(input.ref);
     assertThat(result.revision).isEqualTo(input.revision);
     assertThat(result.canDelete).isTrue();
-    assertThat(result.created).isEqualTo(timestamp(r));
+    assertThat(result.created.toInstant()).isEqualTo(instant(r));
 
     requestScopeOperations.setApiUser(user.id());
     result = tag(input.ref).get();
@@ -366,16 +367,49 @@
   }
 
   @Test
+  public void nonExistingBaseRevision() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.revision = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("base revision \"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\" not found");
+  }
+
+  @Test
   public void invalidBaseRevision() throws Exception {
     grantTagPermissions();
 
     TagInput input = new TagInput();
     input.ref = "test";
-    input.revision = "abcdefg";
+    input.revision = "invalid\trevision";
+
+    UnprocessableEntityException thrown =
+        assertThrows(UnprocessableEntityException.class, () -> tag(input.ref).create(input));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("base revision \"" + input.revision + "\" is invalid");
+  }
+
+  @Test
+  public void nonCommitRevision() throws Exception {
+    grantTagPermissions();
+
+    TagInput input = new TagInput();
+    input.ref = "test";
+    input.revision =
+        projectOperations.project(project).getHead("refs/heads/master").getTree().name();
 
     BadRequestException thrown =
         assertThrows(BadRequestException.class, () -> tag(input.ref).create(input));
-    assertThat(thrown).hasMessageThat().contains("Invalid base revision");
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains("base revision \"" + input.revision + "\" is not a commit");
   }
 
   @Test
@@ -457,8 +491,8 @@
     return gApi.projects().name(project.get()).tag(tagname);
   }
 
-  private Timestamp timestamp(PushOneCommit.Result r) {
-    return new Timestamp(r.getCommit().getCommitterIdent().getWhen().getTime());
+  private Instant instant(PushOneCommit.Result r) {
+    return r.getCommit().getCommitterIdent().getWhenAsInstant();
   }
 
   private void assertBadRequest(ListRefsRequest<TagInfo> req) throws Exception {
@@ -477,7 +511,7 @@
   }
 
   private static void removeAllBranchPermissions(ProjectConfig cfg, String... permissions) {
-    for (AccessSection accessSection : ImmutableList.copyOf(cfg.getAccessSections())) {
+    for (AccessSection accessSection : cfg.getAccessSections()) {
       cfg.upsertAccessSection(
           accessSection.getName(),
           updatedAccessSection -> {
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/BUILD b/javatests/com/google/gerrit/acceptance/rest/util/BUILD
index 1d3fe65..9b16eb1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/util/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/util/BUILD
@@ -7,6 +7,6 @@
     visibility = ["//visibility:public"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
-        "//lib/commons:lang",
+        "//lib/commons:lang3",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java b/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
index 7b0002c..91cf15a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
+++ b/javatests/com/google/gerrit/acceptance/rest/util/RestCall.java
@@ -18,7 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import java.util.Optional;
-import org.apache.commons.lang.StringUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.junit.Ignore;
 
 /** Data container for test REST requests. */
diff --git a/javatests/com/google/gerrit/acceptance/server/account/externalids/BUILD b/javatests/com/google/gerrit/acceptance/server/account/externalids/BUILD
new file mode 100644
index 0000000..32676fe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/externalids/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_externalids",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java b/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java
new file mode 100644
index 0000000..4eac16f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java
@@ -0,0 +1,293 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.account.externalids;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigratiorExecutor;
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigrator;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import java.io.IOException;
+import java.util.Optional;
+import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class OnlineExternalIdCaseSensivityMigratorIT extends AbstractDaemonTest {
+  private Account.Id accountId = Account.id(66);
+  private boolean isUserNameCaseInsensitive = false;
+
+  @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+  @Inject private ExternalIdKeyFactory externalIdKeyFactory;
+  @Inject private ExternalIdFactory externalIdFactory;
+  @Inject private OnlineExternalIdCaseSensivityMigrator objectUnderTest;
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        install(new FactoryModuleBuilder().build(ExternalIdCaseSensitivityMigrator.Factory.class));
+        bind(ExecutorService.class)
+            .annotatedWith(OnlineExternalIdCaseSensivityMigratiorExecutor.class)
+            .toInstance(MoreExecutors.newDirectExecutorService());
+      }
+    };
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldMigrateExternalId() throws IOException, ConfigInvalidException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JonDoe", accountId, isUserNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isFalse();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+
+      objectUnderTest.migrate();
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isTrue();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isTrue();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldNotThrowExceptionDuringTheMigrationForExternalIdsWithCaseInsensitiveSha1()
+      throws IOException, ConfigInvalidException {
+
+    final boolean caseInsensitiveUserName = true;
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(md, extIdNotes, SCHEME_GERRIT, "JonDoe", accountId, caseInsensitiveUserName);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, caseInsensitiveUserName);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isTrue();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isTrue();
+
+      objectUnderTest.migrate();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldNotCreateDuplicateExternaIdNotesWhenUpdatingAccount()
+      throws IOException, ConfigInvalidException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JonDoe", accountId, isUserNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      ExternalId extId =
+          externalIdFactory.create(
+              externalIdKeyFactory.create(SCHEME_USERNAME, "JonDoe", true),
+              accountId,
+              "test@email.com",
+              "w1m9Bg85GQ4hijLNxW+6xAfj4r9wyk9rzVQelIHxuQ");
+      extIdNotes.upsert(extId);
+      extIdNotes.commit(md);
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isFalse();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").get().email())
+          .isEqualTo("test@email.com");
+
+      objectUnderTest.migrate();
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isTrue();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isTrue();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").get().email())
+          .isEqualTo("test@email.com");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void caseInsensitivityShouldWorkAfterMigration()
+      throws IOException, ConfigInvalidException {
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+
+      objectUnderTest.migrate();
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      assertThat(
+              getExternalIdWithCaseInsensitive(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent())
+          .isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isTrue();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldThrowExceptionWhenDuplicateKeys() throws IOException, ConfigInvalidException {
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "jondoe", Account.id(67), isUserNameCaseInsensitive);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isTrue();
+
+      assertThrows(DuplicateExternalIdKeyException.class, () -> objectUnderTest.migrate());
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "false")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldSkipMigrationWhenUserNameCaseInsensitiveIsSetToFalse()
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+
+      objectUnderTest.migrate();
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "false")
+  public void shouldSkipMigrationWhenUserNameCaseInsensitiveMigrationModeIsSetToFalse()
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      objectUnderTest.migrate();
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+    }
+  }
+
+  protected Optional<ExternalId> getExternalIdWithCaseInsensitive(
+      ExternalIdNotes extIdNotes, String scheme, String id)
+      throws IOException, ConfigInvalidException {
+    return extIdNotes.get(externalIdKeyFactory.create(scheme, id, true));
+  }
+
+  protected Optional<ExternalId> getExactExternalId(
+      ExternalIdNotes extIdNotes, String scheme, String id)
+      throws IOException, ConfigInvalidException {
+    return extIdNotes.get(externalIdKeyFactory.create(scheme, id, false));
+  }
+
+  protected void createExternalId(
+      MetaDataUpdate md,
+      ExternalIdNotes extIdNotes,
+      String scheme,
+      String id,
+      Account.Id accountId,
+      boolean isUserNameCaseInsensitive)
+      throws IOException {
+    ExternalId extId =
+        externalIdFactory.create(
+            externalIdKeyFactory.create(scheme, id, isUserNameCaseInsensitive), accountId);
+    extIdNotes.insert(extId);
+    extIdNotes.commit(md);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java b/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java
new file mode 100644
index 0000000..8405b25
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/approval/PatchSetApprovalUuidTest.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.approval;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGeneratorImpl;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.time.Instant;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for {@link PatchSetApprovalUuidGeneratorImpl} - the default implementation of {@link
+ * PatchSetApprovalUuidGenerator}.
+ */
+@RunWith(JUnit4.class)
+public class PatchSetApprovalUuidTest {
+
+  @Test
+  public void sameInput_differentUuid() {
+    PatchSetApprovalUuidGeneratorImpl patchSetApprovalUuidGenerator =
+        new PatchSetApprovalUuidGeneratorImpl();
+    for (short value = -2; value <= 2; value++) {
+      PatchSet.Id patchSetId = PatchSet.id(Change.id(1), 1);
+      Account.Id accountId = Account.id(1);
+      String label = LabelId.CODE_REVIEW;
+      Instant granted = TimeUtil.now();
+      PatchSetApproval.UUID uuid1 =
+          patchSetApprovalUuidGenerator.get(patchSetId, accountId, label, value, granted);
+      PatchSetApproval.UUID uuid2 =
+          patchSetApprovalUuidGenerator.get(patchSetId, accountId, label, value, granted);
+      assertThat(uuid2).isNotEqualTo(uuid1);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
new file mode 100644
index 0000000..379a712
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -0,0 +1,568 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.assertThat;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.assertThatList;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.hasTestId;
+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 static com.google.gerrit.truth.ListSubject.elements;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Correspondence;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StandardSubjectBuilder;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth8;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.approval.ApprovalCopier;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests of the {@link ApprovalCopier} API.
+ *
+ * <p>This class doesn't verify the copy condition predicates, as they are already covered by {@code
+ * StickyApprovalsIT}.
+ */
+@NoHttpd
+public class ApprovalCopierIT extends AbstractDaemonTest {
+  @Inject private ApprovalCopier approvalCopier;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Before
+  public void setup() throws Exception {
+    // Overwrite "Code-Review" label that is inherited from All-Projects.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      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 this is not submitted as is"),
+                  value(-2, "This shall not be submitted"))
+              .setCopyCondition(
+                  String.format(
+                      "changekind:%s OR changekind:%s OR is:MIN",
+                      ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Add Verified label.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+                  LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"))
+              .setCopyCondition("is:MIN");
+      u.getConfig().upsertLabelType(verified.build());
+      u.save();
+    }
+
+    // Grant permissions to vote on the verified label.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(LabelId.VERIFIED)
+                .ref(RefNames.REFS_HEADS + "*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+  }
+
+  @Test
+  public void forInitialPatchSet_noApprovals() throws Exception {
+    ChangeData changeData = createChange().getChange();
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      ApprovalCopier.Result approvalCopierResult =
+          approvalCopier.forPatchSet(
+              changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+      assertThat(approvalCopierResult.copiedApprovals()).isEmpty();
+      assertThat(approvalCopierResult.outdatedApprovals()).isEmpty();
+    }
+  }
+
+  @Test
+  public void forInitialPatchSet_currentApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 1);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_noApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_outdatedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add some approvals that are not copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, 1);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThat(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThat(approvalCopierResult.outdatedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, 2),
+            PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.VERIFIED, 1));
+
+    ApprovalDataSubject codeReviewApprovalSubject =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    codeReviewApprovalSubject.hasPassingAtomsThat().isEmpty();
+    codeReviewApprovalSubject
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE", "is:MIN");
+
+    ApprovalDataSubject verifiedApprovalSubject =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.VERIFIED, user.id());
+    verifiedApprovalSubject.hasPassingAtomsThat().isEmpty();
+    verifiedApprovalSubject.hasFailingAtomsThat().containsExactly("is:MIN");
+  }
+
+  @Test
+  public void forPatchSet_copiedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add some approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
+            PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+
+    ApprovalDataSubject codeReviewApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    codeReviewApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    codeReviewApprovalSubject
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE");
+
+    ApprovalDataSubject verifiedApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.VERIFIED, user.id());
+    verifiedApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    verifiedApprovalSubject.hasFailingAtomsThat().isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_currentApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_allKindOfApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add some approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    // Add some approvals that are not copied.
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, 1);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    // Add some current approvals.
+    vote(r.getChangeId(), user, LabelId.CODE_REVIEW, -1);
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, -1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
+            PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+    assertThatList(approvalCopierResult.outdatedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.CODE_REVIEW, 1),
+            PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.VERIFIED, 1));
+
+    ApprovalDataSubject copiedCodeReviewApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    copiedCodeReviewApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    copiedCodeReviewApprovalSubject
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE");
+
+    ApprovalDataSubject copiedVerifiedApprovalSubject =
+        assertThat(approvalCopierResult.copiedApprovals(), LabelId.VERIFIED, user.id());
+    copiedVerifiedApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+    copiedVerifiedApprovalSubject.hasFailingAtomsThat().isEmpty();
+
+    ApprovalDataSubject outdatedCodeReviewApprovalSubject1 =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, user.id());
+    outdatedCodeReviewApprovalSubject1.hasPassingAtomsThat().isEmpty();
+    outdatedCodeReviewApprovalSubject1
+        .hasFailingAtomsThat()
+        .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE", "is:MIN");
+
+    ApprovalDataSubject outdatedVerifiedApprovalSubject1 =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.VERIFIED, admin.id());
+    outdatedVerifiedApprovalSubject1.hasPassingAtomsThat().isEmpty();
+    outdatedVerifiedApprovalSubject1.hasFailingAtomsThat().containsExactly("is:MIN");
+  }
+
+  @Test
+  public void forPatchSet_copiedApprovalOverriddenByCurrentApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add approval that is copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    // Override the copied approval.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_approvalForNonExistingLabel() throws Exception {
+    PushOneCommit.Result r = createChange();
+    PatchSet.Id patchSet1Id = r.getPatchSetId();
+
+    // Add approval that could be copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+
+    // Delete the Code-Review label (override it with an empty label definition).
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertLabelType(labelBuilder(LabelId.CODE_REVIEW).build());
+      u.save();
+    }
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals())
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, -2));
+
+    ApprovalDataSubject codeReviewApprovalSubject1 =
+        assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, admin.id());
+    codeReviewApprovalSubject1.hasPassingAtomsThat().isEmpty();
+    codeReviewApprovalSubject1.hasFailingAtomsThat().isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_copyableZeroApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Override the inherited Code-Review label to make all votes copyable, including zero votes.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      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 this is not submitted as is"),
+                  value(-2, "This shall not be submitted"))
+              .setCopyCondition("is:ANY");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    // Create a zero approval that is copyable, by adding an approval and removing it again.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 0);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void forPatchSet_nonCopyableZeroApproval() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Create a zero approval that is non-copyable, by adding an approval and removing it again.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 2);
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 0);
+
+    amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus();
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    assertThatList(approvalCopierResult.copiedApprovals()).isEmpty();
+    assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+  }
+
+  @Test
+  public void copiedFlagSetOnCopiedApprovals() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add approvals that are copied.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2);
+    vote(r.getChangeId(), user, LabelId.VERIFIED, -1);
+
+    r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo);
+    r.assertOkStatus();
+    PatchSet.Id patchSet2Id = r.getPatchSetId();
+
+    // Override copied approval.
+    vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, 1);
+
+    // Add new current approval.
+    vote(r.getChangeId(), admin, LabelId.VERIFIED, 1);
+
+    ApprovalCopier.Result approvalCopierResult =
+        invokeApprovalCopierForCurrentPatchSet(
+            r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
+    ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> copiedApprovals =
+        approvalCopierResult.copiedApprovals();
+    assertThatList(filter(copiedApprovals, approval -> approval.patchSetApproval().copied()))
+        .comparingElementsUsing(hasTestId())
+        .containsExactly(
+            PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
+    assertThatList(filter(copiedApprovals, approval -> !approval.patchSetApproval().copied()))
+        .isEmpty();
+  }
+
+  private void vote(String changeId, TestAccount testAccount, String label, int value)
+      throws RestApiException {
+    requestScopeOperations.setApiUser(testAccount.id());
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(label, value));
+    requestScopeOperations.setApiUser(admin.id());
+  }
+
+  private ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> filter(
+      Set<ApprovalCopier.Result.PatchSetApprovalData> approvals,
+      Predicate<ApprovalCopier.Result.PatchSetApprovalData> filter) {
+    return approvals.stream().filter(filter).collect(toImmutableSet());
+  }
+
+  private ApprovalCopier.Result invokeApprovalCopierForCurrentPatchSet(
+      Change.Id changeId, int expectedCurrentPatchSetNum) throws IOException {
+    ChangeData changeData = changeDataFactory.create(project, changeId);
+    assertThat(changeData.currentPatchSet().id().get()).isEqualTo(expectedCurrentPatchSetNum);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      return approvalCopier.forPatchSet(
+          changeData.notes(), changeData.currentPatchSet(), revWalk, repo.getConfig());
+    }
+  }
+
+  public static class ApprovalDataSubject extends Subject {
+    public static Correspondence<ApprovalCopier.Result.PatchSetApprovalData, PatchSetApprovalTestId>
+        hasTestId() {
+      return NullAwareCorrespondence.transforming(
+          approvalData -> PatchSetApprovalTestId.create(approvalData.patchSetApproval()),
+          "has test ID");
+    }
+
+    public static ApprovalDataSubject assertThat(
+        ApprovalCopier.Result.PatchSetApprovalData approvalData) {
+      return assertAbout(approvalDatas()).that(approvalData);
+    }
+
+    public static ApprovalDataSubject assertThat(
+        ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas,
+        String labelId,
+        Account.Id accountId) {
+      Optional<ApprovalCopier.Result.PatchSetApprovalData> approvalDataForLabelAndAccount =
+          approvalDatas.stream()
+              .filter(
+                  approvalData ->
+                      approvalData.patchSetApproval().label().equals(labelId)
+                          && approvalData.patchSetApproval().accountId().equals(accountId))
+              .findAny();
+      Truth8.assertThat(approvalDataForLabelAndAccount).isPresent();
+      return assertAbout(approvalDatas()).that(approvalDataForLabelAndAccount.get());
+    }
+
+    public static ListSubject<ApprovalDataSubject, ApprovalCopier.Result.PatchSetApprovalData>
+        assertThatList(ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas) {
+      return ListSubject.assertThat(approvalDatas.asList(), approvalDatas());
+    }
+
+    private static Factory<ApprovalDataSubject, ApprovalCopier.Result.PatchSetApprovalData>
+        approvalDatas() {
+      return ApprovalDataSubject::new;
+    }
+
+    private final ApprovalCopier.Result.PatchSetApprovalData approvalData;
+
+    private ApprovalDataSubject(
+        FailureMetadata metadata, ApprovalCopier.Result.PatchSetApprovalData approvalData) {
+      super(metadata, approvalData);
+      this.approvalData = approvalData;
+    }
+
+    public ListSubject<StringSubject, String> hasPassingAtomsThat() {
+      return check("passingAtoms()")
+          .about(elements())
+          .that(approvalData().passingAtoms().asList(), StandardSubjectBuilder::that);
+    }
+
+    public ListSubject<StringSubject, String> hasFailingAtomsThat() {
+      return check("failingAtoms()")
+          .about(elements())
+          .that(approvalData().failingAtoms().asList(), StandardSubjectBuilder::that);
+    }
+
+    private ApprovalCopier.Result.PatchSetApprovalData approvalData() {
+      isNotNull();
+      return approvalData;
+    }
+  }
+
+  public static class PatchSetApprovalSubject extends Subject {
+    public static PatchSetApprovalSubject assertThat(PatchSetApproval patchSetApproval) {
+      return assertAbout(patchSetApprovals()).that(patchSetApproval);
+    }
+
+    private static Factory<PatchSetApprovalSubject, PatchSetApproval> patchSetApprovals() {
+      return PatchSetApprovalSubject::new;
+    }
+
+    private PatchSetApprovalSubject(FailureMetadata metadata, PatchSetApproval patchSetApproval) {
+      super(metadata, patchSetApproval);
+    }
+  }
+
+  /**
+   * AutoValue class that contains all properties of a PatchSetApproval that are relevant to do
+   * assertions in tests (patch set ID, account ID, label name, voting value).
+   */
+  @AutoValue
+  public abstract static class PatchSetApprovalTestId {
+    public abstract PatchSet.Id patchSetId();
+
+    public abstract Account.Id accountId();
+
+    public abstract LabelId labelId();
+
+    public abstract short value();
+
+    public static PatchSetApprovalTestId create(PatchSetApproval patchSetApproval) {
+      return new AutoValue_ApprovalCopierIT_PatchSetApprovalTestId(
+          patchSetApproval.patchSetId(),
+          patchSetApproval.accountId(),
+          patchSetApproval.labelId(),
+          patchSetApproval.value());
+    }
+
+    public static PatchSetApprovalTestId create(
+        PatchSet.Id patchSetId, Account.Id accountId, String labelId, int value) {
+      return new AutoValue_ApprovalCopierIT_PatchSetApprovalTestId(
+          patchSetId, accountId, LabelId.create(labelId), (short) value);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD
index 19ca946..4514ea3 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -14,6 +14,7 @@
 java_library(
     name = "util",
     srcs = ["CommentsUtil.java"],
+    visibility = ["//javatests/com/google/gerrit/acceptance/api/change:__subpackages__"],
     deps = [
         "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/entities",
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 80cdad8..15baa78 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.acceptance.testsuite.change.TestHumanComment;
 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.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.HumanComment;
@@ -47,9 +48,11 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -66,7 +69,7 @@
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -182,6 +185,39 @@
   }
 
   @Test
+  public void commentWithRangeAndLine_lineIsIgnored() throws Exception {
+    String file = "file";
+    String contents = "contents";
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "first subject", file, contents);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    ReviewInput input = new ReviewInput();
+    CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 1, "comment 1", false);
+    int rangeEndLine = 3;
+    comment.range = createRange(1, 1, rangeEndLine, 3);
+    input.comments = new HashMap<>();
+    input.comments.put(comment.path, Lists.newArrayList(comment));
+    revision(r).review(input);
+    Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+    assertThat(result).isNotEmpty();
+    CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+    assertThat(actual.line).isEqualTo(rangeEndLine);
+    input = new ReviewInput();
+    comment = CommentsUtil.newComment(file, Side.REVISION, 1, "comment 1 reply", false);
+    comment.range = createRange(1, 1, rangeEndLine, 3);
+    // Post another comment in reply, and the line is still fixed to the range.endLine
+    comment.inReplyTo = actual.id;
+    input.comments = new HashMap<>();
+    input.comments.put(comment.path, Lists.newArrayList(comment));
+    revision(r).review(input);
+    result = getPublishedComments(changeId, revId);
+    assertThat(result.get(comment.path)).hasSize(2);
+    assertThat(result.get(comment.path).stream().allMatch(c -> c.line == rangeEndLine)).isTrue();
+  }
+
+  @Test
   public void patchsetLevelCommentCanBeAddedAndRetrieved() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -218,6 +254,30 @@
   }
 
   @Test
+  public void deletedCommentsAreResolved() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+    String commentMessage = "to be deleted";
+    CommentInput comment =
+        CommentsUtil.newComment(
+            COMMIT_MSG, Side.REVISION, /*line= */ 0, commentMessage, /*unresolved= */ true);
+    CommentsUtil.addComments(gApi, changeId, revId, comment);
+
+    Map<String, List<CommentInfo>> results = getPublishedComments(changeId, revId);
+    CommentInfo oldComment = Iterables.getOnlyElement(results.get(COMMIT_MSG));
+
+    DeleteCommentInput input = new DeleteCommentInput("reason");
+    gApi.changes().id(changeId).revision(revId).comment(oldComment.id).delete(input);
+    CommentInfo updatedComment =
+        Iterables.getOnlyElement(getPublishedComments(changeId, revId).get(COMMIT_MSG));
+
+    assertThat(updatedComment.message).doesNotContain(commentMessage);
+    assertThat(updatedComment.unresolved).isFalse();
+  }
+
+  @Test
   public void patchsetLevelCommentEmailNotification() throws Exception {
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
@@ -640,7 +700,7 @@
   public void putDraft() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      Instant origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       String path = "file1";
@@ -887,7 +947,7 @@
   public void deleteDraft() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createChange();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      Instant origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
       DraftInput draft = CommentsUtil.newDraft("file1", Side.REVISION, line, "comment 1");
@@ -903,7 +963,7 @@
 
   @Test
   public void insertCommentsWithHistoricTimestamp() throws Exception {
-    Timestamp timestamp = new Timestamp(0);
+    Instant timestamp = Instant.EPOCH;
     for (Integer line : lines) {
       String file = "file";
       String contents = "contents " + line;
@@ -912,11 +972,11 @@
       PushOneCommit.Result r = push.to("refs/for/master");
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
+      Instant origLastUpdated = r.getChange().change().getLastUpdatedOn();
 
       ReviewInput input = new ReviewInput();
       CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, line, "comment 1", false);
-      comment.updated = timestamp;
+      comment.setUpdated(timestamp);
       input.comments = new HashMap<>();
       input.comments.put(comment.path, Lists.newArrayList(comment));
       ChangeResource changeRsrc =
@@ -1202,7 +1262,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(0).id
-                + " \n"
+                + " :\n"
                 + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
                 + "\n"
@@ -1214,7 +1274,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(1).id
-                + " \n"
+                + " :\n"
                 + "PS1, Line 1: boring\n"
                 + "Is it that bad?\n"
                 + "\n"
@@ -1228,7 +1288,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(0).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
                 + "\n"
@@ -1240,7 +1300,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(1).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
                 + "\n"
@@ -1252,7 +1312,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(2).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
                 + "\n"
@@ -1264,7 +1324,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(3).id
-                + " \n"
+                + " :\n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
                 + "\n"
@@ -1853,6 +1913,72 @@
     assertThat(exception.getMessage()).contains(String.format("%s not found", comment.inReplyTo));
   }
 
+  @Test
+  public void commentsOnRootCommitsAreIncludedInEmails() throws Exception {
+    // Create a change in a new branch, making the patch-set commit a root commit.
+    ChangeInfo changeInfo = createChangeInNewBranch("newBranch");
+    Change.Id changeId = Change.Id.tryParse(Integer.toString(changeInfo._number)).get();
+
+    // Add a file.
+    gApi.changes().id(changeId.get()).edit().modifyFile("f1.txt", RawInputUtil.create("content"));
+    gApi.changes().id(changeId.get()).edit().publish();
+    email.clear();
+
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = admin.email();
+    gApi.changes().id(changeId.get()).addReviewer(reviewerInput);
+    changeInfo = gApi.changes().id(changeId.get()).get();
+    assertThat(email.getMessages()).hasSize(1);
+    Message message = email.getMessages().get(0);
+    assertThat(message.body()).contains("f1.txt");
+    email.clear();
+
+    // Send a comment. Make sure the email that is sent includes the comment text.
+    CommentInput c1 =
+        CommentsUtil.newComment(
+            "f1.txt",
+            Side.REVISION,
+            /* line= */ 1,
+            /* message= */ "Comment text",
+            /* unresolved= */ false);
+    CommentsUtil.addComments(gApi, changeId.toString(), changeInfo.currentRevision, c1);
+    assertThat(email.getMessages()).hasSize(1);
+    Message commentMessage = email.getMessages().get(0);
+    assertThat(commentMessage.body())
+        .contains("Patch Set 2:\n" + "\n" + "(1 comment)\n" + "\n" + "File f1.txt:");
+    assertThat(commentMessage.body()).contains("PS2, Line 1: content\n" + "Comment text");
+  }
+
+  @Test
+  public void commentsOnDeletedFileIsIncludedInEmails() throws Exception {
+    // Create a change with a file.
+    createChange("subject", "f1.txt", "content");
+
+    // Stack a second change that deletes the file.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    gApi.changes().id(changeId).edit().deleteFile("f1.txt");
+    gApi.changes().id(changeId).edit().publish();
+    String currentRevision = gApi.changes().id(changeId).get().currentRevision;
+
+    // Add a comment on the deleted file on the parent side.
+    email.clear();
+    CommentInput commentInput =
+        CommentsUtil.newComment(
+            "f1.txt",
+            Side.PARENT,
+            /* line= */ 1,
+            /* message= */ "Comment text",
+            /* unresolved= */ false);
+    CommentsUtil.addComments(gApi, changeId, currentRevision, commentInput);
+
+    // Assert email contains the comment text.
+    assertThat(email.getMessages()).hasSize(1);
+    Message commentMessage = email.getMessages().get(0);
+    assertThat(commentMessage.body()).contains("Patch Set 2:\n\n(1 comment)\n\nFile f1.txt:");
+    assertThat(commentMessage.body()).contains("PS2, Line 1: content\nComment text");
+  }
+
   private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
     return getPublishedComments(changeId, revId).values().stream()
         .flatMap(List::stream)
@@ -1980,6 +2106,16 @@
     return range;
   }
 
+  private static Comment.Range createRange(
+      int startLine, int startCharacter, int endLine, int endCharacter) {
+    Comment.Range range = new Comment.Range();
+    range.startLine = startLine;
+    range.startCharacter = startCharacter;
+    range.endLine = endLine;
+    range.endCharacter = endCharacter;
+    return range;
+  }
+
   private static Function<CommentInfo, CommentInput> infoToInput(String path) {
     return info -> {
       CommentInput commentInput = new CommentInput();
@@ -2017,4 +2153,13 @@
     reviewInput.draftIdsToPublish = draftIdsToPublish;
     return reviewInput;
   }
+
+  private ChangeInfo createChangeInNewBranch(String branchName) throws Exception {
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = branchName;
+    in.newBranch = true;
+    in.subject = "New changes";
+    return gApi.changes().create(in).get();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
index c4927f0..f32cf32 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsUtil.java
@@ -34,7 +34,7 @@
  * A utility class for creating {@link CommentInput} objects, publishing comments and creating draft
  * comments. Used by tests that require dealing with comments.
  */
-class CommentsUtil {
+public 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);
@@ -88,7 +88,7 @@
     return populate(c, path, Side.PARENT, parent, line, message);
   }
 
-  static DraftInput newDraft(String path, Side side, int line, String message) {
+  public 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);
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 9d821b7..bcde618 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -732,7 +732,7 @@
   }
 
   private BatchUpdate newUpdate(Account.Id owner) {
-    return batchUpdateFactory.create(project, userFactory.create(owner), TimeUtil.nowTs());
+    return batchUpdateFactory.create(project, userFactory.create(owner), TimeUtil.now());
   }
 
   private ChangeNotes insertChange() throws Exception {
@@ -828,7 +828,8 @@
   private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
     PersonIdent committer = serverIdent.get();
     PersonIdent author =
-        noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
+        noteUtil.newAccountIdIdent(
+            getAccount(admin.id()).id(), committer.getWhenAsInstant(), committer);
     serverSideTestRepo
         .branch(RefNames.changeMetaRef(id))
         .commit()
diff --git a/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
new file mode 100644
index 0000000..1eef944
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
@@ -0,0 +1,246 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.server.notedb.ChangeNoteJson;
+import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gson.JsonParser;
+import com.google.inject.Inject;
+import java.util.List;
+import org.apache.commons.lang3.reflect.TypeLiteral;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test for {@link com.google.gerrit.server.notedb.DeleteZombieCommentsRefs}. */
+public class DeleteZombieDraftIT extends AbstractDaemonTest {
+  private static final String TEST_PARAMETER_MARKER = "test_only_parameter";
+
+  @Inject private DeleteZombieCommentsRefs.Factory deleteZombieDraftsFactory;
+  @Inject private ChangeNoteJson changeNoteJson;
+  private boolean dryRun;
+
+  @ConfigSuite.Default
+  public static Config dryRunMode() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "dryRun", true);
+    return config;
+  }
+
+  @ConfigSuite.Config
+  public static Config deleteMode() {
+    Config config = new Config();
+    config.setBoolean(TEST_PARAMETER_MARKER, null, "dryRun", false);
+    return config;
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    dryRun = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "dryRun", true);
+  }
+
+  @Test
+  public void draftRefWithOneZombie() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String revId = r.getCommit().getName();
+
+    // Create a draft. A draft ref is created for this draft comment.
+    addDraft(changeId, revId, "comment 1");
+    Ref draftRef = getOnlyDraftRef();
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).hasSize(1);
+    // Publish the draft. The draft ref is deleted.
+    publishAllDrafts(r);
+    assertNumDrafts(changeId, 0);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).isEmpty();
+    assertNumPublishedComments(changeId, 1);
+
+    // Restore the draft ref. Now the same comment exists as draft and published -> zombie.
+    restoreRef(draftRef.getName(), draftRef.getObjectId());
+    draftRef = getOnlyDraftRef();
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).hasSize(1);
+
+    // Run the cleanup logic. The zombie draft is cleared. The published comment is untouched.
+    DeleteZombieCommentsRefs worker =
+        deleteZombieDraftsFactory.create(/* cleanupPercentage= */ 100, dryRun);
+    assertThat(worker.deleteDraftCommentsThatAreAlsoPublished()).isEqualTo(1);
+    if (dryRun) {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).hasSize(1);
+    } else {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).isEmpty();
+    }
+    assertNumPublishedComments(changeId, 1);
+  }
+
+  @Test
+  public void draftRefWithOneDraftAndOneZombie() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    String changeId = r1.getChangeId();
+    PushOneCommit.Result r2 = amendChange(changeId);
+
+    // Add two draft comments: one on PS1, the other on PS2
+    addDraft(changeId, r1.getCommit().getName(), "comment 1");
+    CommentInfo c2 = addDraft(changeId, r2.getCommit().getName(), "comment 2");
+    Ref draftRef = getOnlyDraftRef();
+
+    // Publish the draft on PS2. Now PS1 still has one draft, PS2 has no drafts
+    publishDraft(r2, c2.id);
+    assertNumDrafts(changeId, 1);
+    assertNumPublishedComments(changeId, 1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).isEmpty();
+
+    // Restore the draft ref for PS2 draft. Now draft on PS2 is zombie because it is also published.
+    restoreRef(draftRef.getName(), draftRef.getObjectId());
+    draftRef = getOnlyDraftRef();
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
+
+    // Run the zombie cleanup logic. Zombie draft ref for PS2 will be removed.
+    DeleteZombieCommentsRefs worker =
+        deleteZombieDraftsFactory.create(/* cleanupPercentage= */ 100, dryRun);
+    assertThat(worker.deleteDraftCommentsThatAreAlsoPublished()).isEqualTo(1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    if (dryRun) {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
+    } else {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).isEmpty();
+    }
+    assertNumPublishedComments(changeId, 1);
+
+    // Re-run the worker: nothing happens.
+    assertThat(worker.deleteDraftCommentsThatAreAlsoPublished()).isEqualTo(dryRun ? 1 : 0);
+    assertNumDrafts(changeId, 1);
+    assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
+    if (dryRun) {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
+    } else {
+      assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).isEmpty();
+    }
+    assertNumPublishedComments(changeId, 1);
+  }
+
+  private Ref getOnlyDraftRef() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      return Iterables.getOnlyElement(
+          allUsersRepo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS));
+    }
+  }
+
+  private void publishAllDrafts(PushOneCommit.Result r) throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "foo";
+    revision(r).review(reviewInput);
+  }
+
+  private void publishDraft(PushOneCommit.Result r, String draftId) throws Exception {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    reviewInput.message = "foo";
+    reviewInput.draftIdsToPublish = ImmutableList.of(draftId);
+    revision(r).review(reviewInput);
+  }
+
+  private List<CommentInfo> getDraftComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).draftsRequest().getAsList();
+  }
+
+  private List<CommentInfo> getPublishedComments(String changeId) throws Exception {
+    return gApi.changes().id(changeId).commentsRequest().getAsList();
+  }
+
+  private CommentInfo addDraft(String changeId, String revId, String commentText) throws Exception {
+    DraftInput comment = CommentsUtil.newDraft("f1.txt", Side.REVISION, /* line= */ 1, commentText);
+    return gApi.changes().id(changeId).revision(revId).createDraft(comment).get();
+  }
+
+  private void restoreRef(String refName, ObjectId id) throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      RefUpdate u = allUsersRepo.updateRef(refName);
+      u.setNewObjectId(id);
+      u.forceUpdate();
+    }
+  }
+
+  /**
+   * Returns all draft comments that are stored in {@code draftRefStr} for a specific revision
+   * (patchset) identified by its {@code blobFile} SHA-1.
+   *
+   * <p>Background: This ref points to a tree containing one or more blob files, each named after
+   * the patchset revision SHA-1, that is drafts for each patchset are stored in a separate blob
+   * file.
+   */
+  private List<HumanComment> getDraftsByParsingDraftRef(String draftRefStr, String blobFile)
+      throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      Ref draftRef = allUsersRepo.exactRef(draftRefStr);
+      if (draftRef == null) {
+        // draft ref does not exist, i.e. no draft comments stored for this ref.
+        return ImmutableList.of();
+      }
+      RevTree revTree = rw.parseTree(draftRef.getObjectId());
+      TreeWalk tw = TreeWalk.forPath(allUsersRepo, blobFile, revTree);
+      if (tw == null) {
+        // blobFile does not exist, i.e. no draft comments for this revision.
+        return ImmutableList.of();
+      }
+      ObjectLoader open = allUsersRepo.open(tw.getObjectId(0));
+      String content = new String(open.getBytes(), UTF_8);
+      List<HumanComment> drafts =
+          changeNoteJson
+              .getGson()
+              .fromJson(
+                  JsonParser.parseString(content)
+                      .getAsJsonObject()
+                      .getAsJsonArray("comments")
+                      .toString(),
+                  new TypeLiteral<ImmutableList<HumanComment>>() {}.getType());
+      return drafts;
+    }
+  }
+
+  private void assertNumDrafts(String changeId, int num) throws Exception {
+    assertThat(getDraftComments(changeId)).hasSize(num);
+  }
+
+  private void assertNumPublishedComments(String changeId, int num) throws Exception {
+    assertThat(getPublishedComments(changeId)).hasSize(num);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index e778a5c..21db45c 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -38,10 +39,13 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.api.changes.GetRelatedOption;
 import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.index.IndexConfig;
@@ -57,12 +61,16 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Optional;
+import javax.annotation.Nullable;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.junit.Test;
 
 @NoHttpd
@@ -82,6 +90,7 @@
   @Inject private GroupOperations groupOperations;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Inject private IndexConfig indexConfig;
   @Inject private ChangesCollection changes;
@@ -480,6 +489,7 @@
     gApi.changes().id(changeId2).edit().modifyFile("a.txt", RawInputUtil.create(new byte[] {'a'}));
     Optional<EditInfo> edit = getEdit(changeId2);
     assertThat(edit).isPresent();
+    @SuppressWarnings("OptionalGetWithoutIsPresent")
     ObjectId editRev = ObjectId.fromString(edit.get().commit.commit);
 
     PatchSet.Id ps1_1 = getPatchSetId(c1_1);
@@ -545,11 +555,8 @@
     RevCommit c2_2 = testRepo.amend(c2_1).add("b.txt", "2").create();
     testRepo.reset(c2_2);
 
-    disableChangeIndexWrites();
-    try {
+    try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
       pushHead(testRepo, "refs/for/master", false);
-    } finally {
-      enableChangeIndexWrites();
     }
 
     PatchSet.Id psId1_1 = getPatchSetId(c1_1);
@@ -633,6 +640,99 @@
         .containsExactly("NEW", "ABANDONED", "MERGED");
   }
 
+  @Test
+  public void submittable() throws Exception {
+    RevCommit c1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    RevCommit c2 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    RevCommit c3 = commitBuilder().add("c.txt", "3").message("subject: 3").create();
+    pushHead(testRepo, "refs/for/master", false);
+    PatchSet.Id ps1 = getPatchSetId(c1);
+    PatchSet.Id ps2 = getPatchSetId(c2);
+    PatchSet.Id ps3 = getPatchSetId(c3);
+
+    for (RevCommit c : ImmutableList.of(c1, c3)) {
+      gApi.changes()
+          .id(getChange(c).change().getChangeId())
+          .current()
+          .review(ReviewInput.approve());
+    }
+
+    for (PatchSet.Id ps : ImmutableList.of(ps3, ps2, ps1)) {
+      assertRelated(
+          ps,
+          Arrays.asList(
+              changeAndCommit(ps3, c3, 1, true),
+              changeAndCommit(ps2, c2, 1, false),
+              changeAndCommit(ps1, c1, 1, true)),
+          GetRelatedOption.SUBMITTABLE);
+    }
+  }
+
+  @Test
+  public void getRelatedLinearSameCommitPushedTwice() throws Exception {
+    RevCommit base = projectOperations.project(project).getHead("master");
+
+    // 1,1---2,1 on master
+    PushOneCommit.Result r1 =
+        createChange(
+            testRepo,
+            "master",
+            "subject: 1",
+            "a.txt",
+            "1",
+            /** topic= */
+            null);
+    RevCommit c1_1 = r1.getCommit();
+    PatchSet.Id ps1_1 = r1.getPatchSetId();
+
+    PushOneCommit.Result r2 =
+        createChange(
+            testRepo,
+            "master",
+            "subject: 2",
+            "b.txt",
+            "2",
+            /** topic= */
+            null);
+    RevCommit c2_1 = r2.getCommit();
+    PatchSet.Id ps2_1 = r2.getPatchSetId();
+
+    // 3,1---4,1 on stable
+    gApi.projects().name(project.get()).branch("stable").create(new BranchInput());
+    testRepo.reset(c1_1);
+    PushResult r3 = pushHead(testRepo, "refs/for/stable%base=" + base.getName());
+    assertThat(r3.getRemoteUpdate("refs/for/stable%base=" + base.getName()).getStatus())
+        .isEqualTo(RemoteRefUpdate.Status.OK);
+    ChangeData change3 =
+        Iterables.getOnlyElement(
+            queryProvider
+                .get()
+                .byBranchCommit(BranchNameKey.create(project, "stable"), c1_1.getName()));
+    assertThat(change3.currentPatchSet().commitId()).isEqualTo(c1_1);
+    RevCommit c3_1 = c1_1;
+    PatchSet.Id ps3_1 = change3.currentPatchSet().id();
+
+    PushOneCommit.Result r4 =
+        createChange(
+            testRepo,
+            "stable",
+            "subject: 4",
+            "d.txt",
+            "4",
+            /** topic= */
+            null);
+    RevCommit c4_1 = r4.getCommit();
+    PatchSet.Id ps4_1 = r4.getPatchSetId();
+
+    for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
+      assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
+    }
+
+    for (PatchSet.Id ps : ImmutableList.of(ps4_1, ps3_1)) {
+      assertRelated(ps, changeAndCommit(ps4_1, c4_1, 1), changeAndCommit(ps3_1, c3_1, 1));
+    }
+  }
+
   private static Correspondence<RelatedChangeAndCommitInfo, String>
       getRelatedChangeToStatusCorrespondence() {
     return Correspondence.transforming(
@@ -644,16 +744,21 @@
     return c;
   }
 
-  private PatchSet.Id getPatchSetId(ObjectId c) throws Exception {
+  private PatchSet.Id getPatchSetId(ObjectId c) {
     return getChange(c).change().currentPatchSetId();
   }
 
-  private ChangeData getChange(ObjectId c) throws Exception {
+  private ChangeData getChange(ObjectId c) {
     return Iterables.getOnlyElement(queryProvider.get().byCommit(c));
   }
 
   private RelatedChangeAndCommitInfo changeAndCommit(
       PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
+    return changeAndCommit(psId, commitId, currentRevisionNum, null);
+  }
+
+  private RelatedChangeAndCommitInfo changeAndCommit(
+      PatchSet.Id psId, ObjectId commitId, int currentRevisionNum, @Nullable Boolean submittable) {
     RelatedChangeAndCommitInfo result = new RelatedChangeAndCommitInfo();
     result.project = project.get();
     result._changeNumber = psId.changeId().get();
@@ -662,11 +767,12 @@
     result._revisionNumber = psId.get();
     result._currentRevisionNumber = currentRevisionNum;
     result.status = "NEW";
+    result.submittable = submittable;
     return result;
   }
 
   private void clearGroups(PatchSet.Id psId) throws Exception {
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.now())) {
       bu.addOp(
           psId.changeId(),
           new BatchUpdateOp() {
@@ -685,10 +791,18 @@
     assertRelated(psId, Arrays.asList(expected));
   }
 
-  private void assertRelated(PatchSet.Id psId, List<RelatedChangeAndCommitInfo> expected)
+  private void assertRelated(
+      PatchSet.Id psId, List<RelatedChangeAndCommitInfo> expected, GetRelatedOption... options)
       throws Exception {
     List<RelatedChangeAndCommitInfo> actual =
-        gApi.changes().id(psId.changeId().get()).revision(psId.get()).related().changes;
+        gApi.changes()
+            .id(psId.changeId().get())
+            .revision(psId.get())
+            .related(
+                options.length > 0
+                    ? EnumSet.copyOf(Arrays.asList(options))
+                    : EnumSet.noneOf(GetRelatedOption.class))
+            .changes;
     assertWithMessage("related to " + psId).that(actual).hasSize(expected.size());
     for (int i = 0; i < actual.size(); i++) {
       String name = "index " + i + " related to " + psId;
@@ -703,6 +817,7 @@
           .that(a._currentRevisionNumber)
           .isEqualTo(e._currentRevisionNumber);
       assertThat(a.status).isEqualTo(e.status);
+      assertThat(a.submittable).isEqualTo(e.submittable);
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index a97fb49..5a4f073 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -20,7 +20,9 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestProjectInput;
+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.Project;
@@ -130,6 +132,8 @@
     } else {
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
     }
   }
 
@@ -152,6 +156,8 @@
     } else {
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
     }
   }
 
@@ -180,10 +186,33 @@
       assertSubmittedTogether(id1);
       assertSubmittedTogether(id2);
       assertSubmittedTogether(id3, id3, id2);
+      assertSubmittedTogetherWithTopicClosure(id1, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id3, id3, id2, id1);
     }
   }
 
   @Test
+  @Sandboxed
+  @GerritConfig(name = "change.maxSubmittableAtOnce", value = "2")
+  public void submittedTogetherWithMaxChangesLimit() throws Exception {
+    String targetRef = "refs/for/master";
+
+    commitBuilder().add("a.txt", "1").message("subject: 1").create();
+    pushHead(testRepo, targetRef, false);
+
+    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+    String id2 = getChangeId(c2_1);
+    pushHead(testRepo, targetRef, false);
+
+    RevCommit c3_1 = commitBuilder().add("b.txt", "3").message("subject: 3").create();
+    String id3 = getChangeId(c3_1);
+    pushHead(testRepo, targetRef, false);
+
+    assertSubmittedTogether(id3, id3, id2);
+  }
+
+  @Test
   public void respectTopicsOnAncestors() throws Exception {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
 
@@ -227,6 +256,13 @@
       assertSubmittedTogether(id4, id4, id3, id2);
       assertSubmittedTogether(id5);
       assertSubmittedTogether(id6, id6, id5);
+
+      assertSubmittedTogetherWithTopicClosure(id1, id6, id5, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id2, id6, id5, id2);
+      assertSubmittedTogetherWithTopicClosure(id3, id6, id5, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id4, id6, id5, id4, id3, id2, id1);
+      assertSubmittedTogetherWithTopicClosure(id5);
+      assertSubmittedTogetherWithTopicClosure(id6, id6, id5, id2);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index fb3259f..f2184de 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -213,7 +213,7 @@
 
   @Test
   @GerritConfig(name = "event.comment-added.publishPatchSetLevelComment", value = "false")
-  public void publishPatchSetLevelComment() throws Exception {
+  public void publishPatchSetLevelComment_disabled() throws Exception {
     PushOneCommit.Result r = createChange();
     TestListener listener = new TestListener();
     try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
@@ -225,6 +225,20 @@
   }
 
   @Test
+  @GerritConfig(name = "event.comment-added.publishPatchSetLevelComment", value = "true")
+  public void publishPatchSetLevelComment_enabled() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestListener listener = new TestListener();
+    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+      String patchSetLevelComment = "a patch set level comment";
+      ReviewInput reviewInput = new ReviewInput().patchSetLevelComment(patchSetLevelComment);
+      revision(r).review(reviewInput);
+      assertThat(listener.getLastCommentAddedEvent().getComment())
+          .isEqualTo(String.format("Patch Set 1:\n\n%s", patchSetLevelComment));
+    }
+  }
+
+  @Test
   public void reviewChange_MultipleVotes() throws Exception {
     TestListener listener = new TestListener();
     try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index 09e6dfe..b2a0ded 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -65,7 +65,7 @@
       values = {"enabledFeature"})
   @GerritConfig(
       name = "experiments.disabled",
-      values = {"UiFeature__patchset_comments"})
+      values = {"UiFeature__patchset_comments", "UiFeature__submit_requirements_ui"})
   public void configOverride_defaultFeatureDisabled() {
     assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
     assertThat(
diff --git a/javatests/com/google/gerrit/acceptance/server/git/CodeReviewCommitTest.java b/javatests/com/google/gerrit/acceptance/server/git/CodeReviewCommitTest.java
new file mode 100644
index 0000000..e073f6f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/CodeReviewCommitTest.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+/** Tests for {@link CodeReviewCommit}. */
+public class CodeReviewCommitTest {
+
+  @Test
+  public void checkSerializable_withStatusMessage() throws Exception {
+    CodeReviewCommit commit = new CodeReviewCommit(createCommit());
+    commit.setStatusMessage("Status");
+    CodeReviewCommit deserializedCommit = serializeAndReadBack(commit);
+    assertThat(deserializedCommit).isEqualTo(commit);
+    assertThat(deserializedCommit.getStatusMessage().get()).isEqualTo("Status");
+  }
+
+  @Test
+  public void checkSerializable_emptyStatusMessage() throws Exception {
+    CodeReviewCommit commit = new CodeReviewCommit(createCommit());
+    CodeReviewCommit deserializedCommit = serializeAndReadBack(commit);
+    assertThat(deserializedCommit).isEqualTo(commit);
+    assertThat(deserializedCommit.getStatusMessage().isPresent()).isFalse();
+  }
+
+  @SuppressWarnings("BanSerializableRead")
+  private CodeReviewCommit serializeAndReadBack(CodeReviewCommit codeReviewCommit)
+      throws Exception {
+    try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        ObjectOutputStream out = new ObjectOutputStream(bos)) {
+      out.writeObject(codeReviewCommit);
+      out.flush();
+      try (ByteArrayInputStream fileIn = new ByteArrayInputStream(bos.toByteArray());
+          ObjectInputStream in = new ObjectInputStream(fileIn); ) {
+        return (CodeReviewCommit) in.readObject();
+      }
+    }
+  }
+
+  private ObjectId createCommit() throws Exception {
+    InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+    Project.NameKey project = Project.nameKey("test");
+    try (Repository repo = repoManager.createRepository(project);
+        TestRepository<Repository> tr = new TestRepository<>(repo)) {
+      PersonIdent ident = new PersonIdent(new PersonIdent("Test Ident", "test@test.com"));
+      return tr.commit().author(ident).committer(ident).message("Test commit").create();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
new file mode 100644
index 0000000..b2836fd
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsChangeIdValidationIT.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.git.receive;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for checking the validation of Change-Id during receive-commits. */
+public class ReceiveCommitsChangeIdValidationIT extends AbstractDaemonTest {
+
+  @Test
+  public void disallowTruncatingChangeIdAcrossPatchSets() throws Exception {
+    // Create the parent.
+    RevCommit parent =
+        commitBuilder().add("foo.txt", "foo content").message("base commit").create();
+    testRepo.reset(parent);
+
+    String changeId = "I0000000000000000000000000000000000000012";
+    String truncatedChangeId = "I000000000000000000000000000000000000001";
+
+    // The initial Change PS1 is accepted
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah",
+            ImmutableMap.of("foo.txt", "first patch-set"),
+            changeId)
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertOkStatus();
+
+    // The Change PS2 is rejected because the Change-Id is truncated
+    pushFactory
+        .create(
+            admin.newIdent(),
+            testRepo,
+            "blah\n\nChange-Id: " + truncatedChangeId,
+            ImmutableMap.of("foo.txt", "second patch-set"))
+        .setParent(parent)
+        .to("refs/for/master")
+        .assertErrorStatus("invalid Change-Id");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
index 1a01184..13e2f24 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -95,10 +95,7 @@
     PushOneCommit.Result result = createChange();
     String changeId = result.getChangeId();
     String revId = result.getCommit().getName();
-    when(mockCommentValidator.validateComments(
-            CommentValidationContext.create(
-                result.getChange().getId().get(), result.getChange().project().get()),
-            ImmutableList.of(COMMENT_FOR_VALIDATION)))
+    when(mockCommentValidator.validateComments(captureCtx.capture(), capture.capture()))
         .thenReturn(ImmutableList.of());
     DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
     testCommentHelper.addDraft(changeId, revId, comment);
@@ -107,6 +104,12 @@
     amendResult.assertOkStatus();
     amendResult.assertNotMessage("Comment validation failure:");
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+
+    assertThat(captureCtx.getAllValues()).hasSize(1);
+    assertThat(captureCtx.getValue().getProject()).isEqualTo(result.getChange().project().get());
+    assertThat(captureCtx.getValue().getChangeId()).isEqualTo(result.getChange().getId().get());
+    assertThat(captureCtx.getValue().getRefName()).isEqualTo("refs/heads/master");
+    assertThat(capture.getValue()).containsExactly(COMMENT_FOR_VALIDATION);
   }
 
   @Test
@@ -182,7 +185,9 @@
     String revId = result.getCommit().getName();
     when(mockCommentValidator.validateComments(
             CommentValidationContext.create(
-                result.getChange().getId().get(), result.getChange().project().get()),
+                result.getChange().getId().get(),
+                result.getChange().project().get(),
+                result.getChange().change().getDest().branch()),
             ImmutableList.of(COMMENT_FOR_VALIDATION)))
         .thenReturn(ImmutableList.of(COMMENT_FOR_VALIDATION.failValidation("Oh no!")));
     DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
@@ -215,6 +220,7 @@
 
     assertThat(captureCtx.getValue().getProject()).isEqualTo(result.getChange().project().get());
     assertThat(captureCtx.getValue().getChangeId()).isEqualTo(result.getChange().getId().get());
+    assertThat(captureCtx.getValue().getRefName()).isEqualTo("refs/heads/master");
 
     assertThat(capture.getAllValues().get(0))
         .containsExactly(
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 85238f8..b68afc5 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -29,15 +29,19 @@
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.ENABLED;
 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.truth.Truth;
 import com.google.gerrit.acceptance.AbstractNotificationTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
@@ -56,14 +60,16 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.restapi.change.PostReviewOp;
 import com.google.inject.Inject;
+import java.util.UUID;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
 
 public class ChangeNotificationsIT extends AbstractNotificationTest {
+
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
@@ -301,32 +307,6 @@
     addReviewerToReviewableChange(batch());
   }
 
-  private void addReviewerToIgnoredChange(Adder adder) throws Exception {
-    StagedChange sc = stageReviewableChange();
-    requestScopeOperations.setApiUser(sc.reviewer.id());
-    gApi.changes().id(sc.changeId).ignore(true);
-    TestAccount addedReviewer = accountCreator.create("added", "added@example.com", "added", null);
-    addReviewer(adder, sc.changeId, sc.owner, addedReviewer.email(), CC_ON_OWN_COMMENTS, null);
-
-    assertThat(sender)
-        .sent("newchange", sc)
-        .to(addedReviewer)
-        .cc(sc.owner)
-        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
-        .noOneElse();
-    assertThat(sender).didNotSend();
-  }
-
-  @Test
-  public void addReviewerToIgnoredChangeSingly() throws Exception {
-    addReviewerToIgnoredChange(singly());
-  }
-
-  @Test
-  public void addReviewerToIgnoredChangeBatch() throws Exception {
-    addReviewerToIgnoredChange(batch());
-  }
-
   private void addReviewerToReviewableChangeByOwnerCcingSelf(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
@@ -599,6 +579,7 @@
   }
 
   private interface Adder {
+
     void addReviewer(String changeId, String reviewer, @Nullable NotifyHandling notify)
         throws Exception;
   }
@@ -950,13 +931,13 @@
     // TODO(logan): Remove this test check once PolyGerrit workaround is rolled back.
     StagedChange sc = stageWipChange();
     ReviewInput in =
-        ReviewInput.noScore().message(PostReview.START_REVIEW_MESSAGE).setWorkInProgress(false);
+        ReviewInput.noScore().message(PostReviewOp.START_REVIEW_MESSAGE).setWorkInProgress(false);
     gApi.changes().id(sc.changeId).current().review(in);
     Truth.assertThat(sender.getMessages()).isNotEmpty();
     String body = sender.getMessages().get(0).body();
-    int idx = body.indexOf(PostReview.START_REVIEW_MESSAGE);
+    int idx = body.indexOf(PostReviewOp.START_REVIEW_MESSAGE);
     Truth.assertThat(idx).isAtLeast(0);
-    Truth.assertThat(body.indexOf(PostReview.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
+    Truth.assertThat(body.indexOf(PostReviewOp.START_REVIEW_MESSAGE, idx + 1)).isEqualTo(-1);
   }
 
   private void review(TestAccount account, String changeId, EmailStrategy strategy)
@@ -993,13 +974,22 @@
     StagedPreChange spc = stagePreChange("refs/for/master");
     assertThat(sender)
         .sent("newchange", spc)
-        .to(spc.watchingProjectOwner)
+        .bcc(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
     assertThat(sender).didNotSend();
   }
 
   @Test
+  public void verifyTitle() throws Exception {
+    StagedPreChange spc = stagePreChange("refs/for/master");
+    assertThat(sender)
+        .sent("newchange", spc)
+        .title(String.format("[S] Change in %s[master]: test commit", project));
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
   public void createWipChange() throws Exception {
     stagePreChange("refs/for/master%wip");
     assertThat(sender).didNotSend();
@@ -1060,7 +1050,7 @@
     StagedPreChange spc = stagePreChange("refs/for/master%wip,notify=ALL");
     assertThat(sender)
         .sent("newchange", spc)
-        .to(spc.watchingProjectOwner)
+        .bcc(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -1073,10 +1063,13 @@
             "refs/for/master",
             users ->
                 ImmutableList.of("r=" + users.reviewer.username(), "cc=" + users.ccer.username()));
-    FakeEmailSenderSubject subject =
-        assertThat(sender).sent("newchange", spc).to(spc.reviewer, spc.watchingProjectOwner);
-    subject.cc(spc.ccer);
-    subject.bcc(NEW_CHANGES, NEW_PATCHSETS).noOneElse();
+    assertThat(sender)
+        .sent("newchange", spc)
+        .to(spc.reviewer)
+        .cc(spc.ccer)
+        .bcc(spc.watchingProjectOwner)
+        .bcc(NEW_CHANGES, NEW_PATCHSETS)
+        .noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -1090,8 +1083,8 @@
     assertThat(sender)
         .sent("newchange", spc)
         .to("nobody1@example.com")
-        .to(spc.watchingProjectOwner)
         .cc("nobody2@example.com")
+        .bcc(spc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -1288,6 +1281,7 @@
   }
 
   private interface Stager {
+
     StagedChange stage() throws Exception;
   }
 
@@ -1719,6 +1713,42 @@
     assertThat(sender).didNotSend();
   }
 
+  @Test
+  public void mergeByOtherAlwaysNotifiesAllIfThereIsAStickyApprovalDiff() throws Exception {
+    StagedChange sc = stageChangeReadyForMergeWithStickyApprovalDiff();
+    // The user requests to notify NONE, but if there is a sticky approval diff we notify ALL.
+    merge(sc.changeId, other, NONE);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+        .bcc(sc.starrer)
+        .bcc(SUBMITTED_CHANGES)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfAlwaysNotifiesAllIfThereIsAStickyApprovalDiff() throws Exception {
+    StagedChange sc = stageChangeReadyForMergeWithStickyApprovalDiff();
+    // The user requests to notify NONE, but if there is a sticky approval diff we notify ALL.
+    merge(sc.changeId, other, sc.owner, NONE);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer)
+        .cc(sc.ccer)
+        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+        .bcc(sc.starrer)
+        .bcc(SUBMITTED_CHANGES)
+        .bcc(ALL_COMMENTS)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
   private void merge(String changeId, TestAccount by) throws Exception {
     merge(changeId, by, ENABLED);
   }
@@ -1762,6 +1792,29 @@
     return sc;
   }
 
+  private StagedChange stageChangeReadyForMergeWithStickyApprovalDiff() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      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 this is not submitted as is"),
+                  value(-2, "This shall not be submitted"))
+              .setCopyCondition("is:ANY");
+      u.getConfig().upsertLabelType(codeReview.build());
+      u.save();
+    }
+
+    StagedChange sc = stageReviewableChange();
+    requestScopeOperations.setApiUser(sc.reviewer.id());
+    gApi.changes().id(sc.changeId).current().review(ReviewInput.approve());
+    amendChange(sc.changeId, "refs/for/master", sc.owner, sc.repo).assertOkStatus();
+    sender.clear();
+    return sc;
+  }
+
   /*
    * ReplacePatchSetSender tests.
    */
@@ -1989,7 +2042,19 @@
   private void pushTo(StagedChange sc, String ref, TestAccount by, EmailStrategy emailStrategy)
       throws Exception {
     setEmailStrategy(by, emailStrategy);
-    pushFactory.create(by.newIdent(), sc.repo, sc.changeId).to(ref).assertOkStatus();
+
+    // Use random file content to avoid that change kind is NO_CHANGE.
+    String randomContent = UUID.randomUUID().toString();
+    pushFactory
+        .create(
+            by.newIdent(),
+            sc.repo,
+            "New Patch Set",
+            PushOneCommit.FILE_NAME,
+            randomContent,
+            sc.changeId)
+        .to(ref)
+        .assertOkStatus();
   }
 
   @Test
@@ -2271,8 +2336,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.reviewer, admin)
         .cc(sc.ccer)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
@@ -2295,8 +2361,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.reviewer, admin)
         .cc(sc.owner, sc.ccer)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
@@ -2320,8 +2387,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.owner, sc.reviewer, admin)
         .cc(sc.ccer)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
@@ -2345,8 +2413,9 @@
     // email for the newly created revert change
     assertThat(sender)
         .sent("newchange", sc)
-        .to(sc.owner, sc.reviewer, sc.watchingProjectOwner, admin)
+        .to(sc.owner, sc.reviewer, admin)
         .cc(sc.ccer, other)
+        .bcc(sc.watchingProjectOwner)
         .bcc(NEW_CHANGES, NEW_PATCHSETS)
         .noOneElse();
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index d6fcccc..1ad27eb 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -30,11 +30,10 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
 import java.util.Collection;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -55,7 +54,7 @@
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
-    String changeURL = "<" + getChangeUrl(newChange.getChange()) + ">";
+    String changeURL = "<" + getChangeUrl(newChange.getChange()) + "?usp=email>";
 
     Map<String, Object> expectedHeaders = new HashMap<>();
     expectedHeaders.put("Gerrit-PatchSet", "1");
@@ -92,7 +91,7 @@
     assertThat(emails).hasSize(1);
     FakeEmailSender.Message message = emails.get(0);
 
-    String changeURL = "<" + getChangeUrl(newChange.getChange()) + ">";
+    String changeURL = "<" + getChangeUrl(newChange.getChange()) + "?usp=email>";
     Map<String, Object> expectedHeaders = new HashMap<>();
     expectedHeaders.put("Gerrit-PatchSet", "1");
     expectedHeaders.put(
@@ -100,7 +99,7 @@
     expectedHeaders.put("Gerrit-MessageType", "comment");
     expectedHeaders.put("Gerrit-Commit", newChange.getCommit().getId().name());
     expectedHeaders.put("Gerrit-ChangeURL", changeURL);
-    expectedHeaders.put("Gerrit-Comment-Date", Iterables.getLast(result).date);
+    expectedHeaders.put("Gerrit-Comment-Date", Iterables.getLast(result).date.toInstant());
 
     assertHeaders(message.headers(), expectedHeaders);
 
@@ -116,14 +115,14 @@
       if (entry.getValue() instanceof String) {
         assertThat(have)
             .containsEntry("X-" + entry.getKey(), new StringEmailHeader((String) entry.getValue()));
-      } else if (entry.getValue() instanceof Date) {
+      } else if (entry.getValue() instanceof Instant) {
         assertThat(have)
-            .containsEntry("X-" + entry.getKey(), new EmailHeader.Date((Date) entry.getValue()));
+            .containsEntry("X-" + entry.getKey(), new EmailHeader.Date((Instant) entry.getValue()));
       } else {
         throw new Exception(
             "Object has unsupported type: "
                 + entry.getValue().getClass().getName()
-                + " must be java.util.Date or java.lang.String for key "
+                + " must be java.time.Instant or java.lang.String for key "
                 + entry.getKey());
       }
     }
@@ -133,19 +132,18 @@
     for (Map.Entry<String, Object> entry : want.entrySet()) {
       if (entry.getValue() instanceof String) {
         assertThat(body).contains(entry.getKey() + ": " + entry.getValue());
-      } else if (entry.getValue() instanceof Timestamp) {
+      } else if (entry.getValue() instanceof Instant) {
         assertThat(body)
             .contains(
                 entry.getKey()
                     + ": "
                     + MailProcessingUtil.rfcDateformatter.format(
-                        ZonedDateTime.ofInstant(
-                            ((Timestamp) entry.getValue()).toInstant(), ZoneId.of("UTC"))));
+                        ZonedDateTime.ofInstant((Instant) entry.getValue(), ZoneId.of("UTC"))));
       } else {
         throw new Exception(
             "Object has unsupported type: "
                 + entry.getValue().getClass().getName()
-                + " must be java.util.Date or java.lang.String for key "
+                + " must be java.time.Instant or java.lang.String for key "
                 + entry.getKey());
       }
     }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index d4000b3..2bccc87 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -436,7 +436,7 @@
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", COMMENT_TEXT, null, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(b.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -445,7 +445,7 @@
     assertThat(message.body()).contains("rejected one or more comments");
 
     // ensure the message header contains a valid message id.
-    assertThat(((StringEmailHeader) (message.headers().get("Message-ID"))).getString())
+    assertThat(((StringEmailHeader) message.headers().get("Message-ID")).getString())
         .containsMatch("<someid-REJECTION-HTML@" + new URL(canonicalWebUrl.get()).getHost() + ">");
   }
 
@@ -465,7 +465,7 @@
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, COMMENT_TEXT, null);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(b.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -490,7 +490,7 @@
     String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", null, null, COMMENT_TEXT);
     b.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(b.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -576,7 +576,7 @@
             null);
     mailMessage.textContent(txt + textFooterForChange(changeInfo._number, ts));
 
-    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    List<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
     mailProcessor.process(mailMessage.build());
     assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
 
@@ -596,7 +596,7 @@
             CommentForValidation.CommentSource.HUMAN, type, COMMENT_TEXT, COMMENT_TEXT.length());
 
     when(mockCommentValidator.validateComments(
-            CommentValidationContext.create(failChange, failProject),
+            CommentValidationContext.create(failChange, failProject, "refs/heads/master"),
             ImmutableList.of(commentForValidation)))
         .thenReturn(ImmutableList.of(commentForValidation.failValidation("Oh no!")));
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
index 628b90c..65b1d4f 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/NotificationMailFormatIT.java
@@ -18,12 +18,17 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
 import org.junit.Test;
 
 public class NotificationMailFormatIT extends AbstractDaemonTest {
@@ -56,6 +61,33 @@
   }
 
   @Test
+  public void bccUserIsNotAddedToReplyTo() throws Exception {
+    TestAccount bccUser = accountCreator.user2();
+
+    List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+    ProjectWatchInfo pwi = new ProjectWatchInfo();
+    pwi.project = project.get();
+    pwi.notifyAllComments = true;
+    projectsToWatch.add(pwi);
+
+    gApi.accounts().id(bccUser.id().get()).setWatchedProjects(projectsToWatch);
+
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.recommend());
+
+    assertThat(sender.getMessages()).hasSize(1);
+    FakeEmailSender.Message m = sender.getMessages().get(0);
+    assertMailReplyTo(m, admin.email());
+    assertMailReplyTo(m, user.email());
+    assertMailNotReplyTo(m, bccUser.email());
+
+    assertThat(m.rcpt().stream().map(a -> a.email()).collect(Collectors.toSet()))
+        .contains(bccUser.email());
+  }
+
+  @Test
   public void userReceivesHtmlAndPlaintextEmail() throws Exception {
     // Create change as admin and review as user
     PushOneCommit.Result r = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index 3c066a3..ab5e1d8 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -274,7 +274,7 @@
   }
 
   private BatchUpdate newBatchUpdate(BatchUpdate.Factory buf) {
-    return buf.create(project, identifiedUserFactory.create(user.id()), TimeUtil.nowTs());
+    return buf.create(project, identifiedUserFactory.create(user.id()), TimeUtil.now());
   }
 
   private Optional<ObjectId> getRef(String name) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
index 46687e3..3508112 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -53,6 +53,7 @@
 import com.google.inject.Module;
 import java.util.Collection;
 import java.util.Set;
+import java.util.stream.StreamSupport;
 import javax.inject.Inject;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -144,13 +145,14 @@
 
                       @Override
                       public boolean containsAnyOf(Iterable<AccountGroup.UUID> groupIds) {
-                        return ImmutableList.copyOf(groupIds).stream().anyMatch(g -> contains(g));
+                        return StreamSupport.stream(groupIds.spliterator(), /* parallel= */ false)
+                            .anyMatch(g -> contains(g));
                       }
 
                       @Override
                       public Set<AccountGroup.UUID> intersection(
                           Iterable<AccountGroup.UUID> groupIds) {
-                        return ImmutableList.copyOf(groupIds).stream()
+                        return StreamSupport.stream(groupIds.spliterator(), /* parallel= */ false)
                             .filter(g -> contains(g))
                             .collect(toImmutableSet());
                       }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 6b34cca..576c7d0 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -300,8 +300,8 @@
         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 this is not merged as is"),
-        value(-2, "This shall not be merged"));
+        value(-1, "I would prefer this is not submitted as is"),
+        value(-2, "This shall not be submitted"));
 
     projectOperations
         .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
new file mode 100644
index 0000000..50fa3b2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
@@ -0,0 +1,245 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.project;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+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.TestOnStoreSubmitRequirementResultModifier;
+import com.google.gerrit.acceptance.TestOnStoreSubmitRequirementResultModifier.ModificationStrategy;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.change.TestSubmitInput;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.util.ArrayDeque;
+import java.util.Collection;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link OnStoreSubmitRequirementResultModifier} on the closed changes. */
+@NoHttpd
+public class OnStoreSubmitRequirementResultModifierIT extends AbstractDaemonTest {
+
+  private static final TestOnStoreSubmitRequirementResultModifier
+      TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER =
+          new TestOnStoreSubmitRequirementResultModifier();
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        bind(OnStoreSubmitRequirementResultModifier.class)
+            .toInstance(TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER);
+      }
+    };
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(false);
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.maxCodeReview())
+            .setAllowOverrideInChildProjects(false)
+            .build());
+  }
+
+  @Test
+  public void submitRequirementStored_canBeOverriddenForMergedChanges() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.OVERRIDE);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirementStored_canBeOverriddenForAbandonedChanges() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.OVERRIDE);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).abandon();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirementStored_notReturnedWhenHidden() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.OVERRIDE);
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(true);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(0);
+
+    ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+
+    SubmitRequirementResult result =
+        notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+    assertThat(result.submitRequirement().name()).isEqualTo("Code-Review");
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.OVERRIDDEN);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo("label:Code-Review=MAX");
+  }
+
+  @Test
+  public void overrideToUnsatisfied_unsatisfied_doesNotBlockSubmission() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.FAIL);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void overrideToUnsatisfied_doesNotBlockSubmissionWithRetries() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.FAIL);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    approve(changeId);
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+
+    TestSubmitInput input = new TestSubmitInput();
+    input.generateLockFailures = new ArrayDeque<>(ImmutableList.of(true));
+    gApi.changes().id(changeId).current().submit(input);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  public void overrideToSatisfied_doesNotBypassSubmitRequirement() throws Exception {
+    TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.setModificationStrategy(
+        ModificationStrategy.PASS);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
+    assertThrows(
+        ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy) {
+    assertWithMessage(
+            "Could not find submit requirement %s with status %s, legacy=%s, (results = %s)",
+            requirementName,
+            status,
+            isLegacy,
+            results.stream()
+                .map(r -> String.format("%s=%s, legacy=%s", r.name, r.status, r.isLegacy))
+                .collect(toImmutableList()))
+        .that(
+            results.stream()
+                .filter(
+                    result ->
+                        result.name.equals(requirementName)
+                            && result.status == status
+                            && result.isLegacy == isLegacy)
+                .count())
+        .isEqualTo(1);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index ba86976..cf1eee0 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -17,10 +17,12 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 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.AccountGroup;
@@ -46,16 +48,21 @@
 
   @Test
   public void newPatchSetsNotifyConfig() throws Exception {
-    Address addr = Address.create("Watcher", "watcher@example.com");
-    NotifyConfig.Builder nc = NotifyConfig.builder();
-    nc.addAddress(addr);
-    nc.setName("new-patch-set");
-    nc.setHeader(NotifyConfig.Header.CC);
-    nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
-    nc.setFilter("message:sekret");
-
+    ImmutableList<String> messageFilters =
+        ImmutableList.of("message:subject-with-tokens", "message:subject-with-tokens=secret");
+    ImmutableList.Builder<Address> watchers = ImmutableList.builder();
     try (ProjectConfigUpdate u = updateProject(project)) {
-      u.getConfig().putNotifyConfig("watch", nc.build());
+      for (int i = 0; i < messageFilters.size(); i++) {
+        Address addr = Address.create("Watcher#" + i, String.format("watcher-%s@example.com", i));
+        watchers.add(addr);
+        NotifyConfig.Builder nc = NotifyConfig.builder();
+        nc.addAddress(addr);
+        nc.setName("new-patch-set" + i);
+        nc.setHeader(NotifyConfig.Header.CC);
+        nc.setNotify(EnumSet.of(NotifyType.NEW_PATCHSETS));
+        nc.setFilter(messageFilters.get(i));
+        u.getConfig().putNotifyConfig("watch" + i, nc.build());
+      }
       u.save();
     }
 
@@ -67,7 +74,13 @@
 
     r =
         pushFactory
-            .create(admin.newIdent(), testRepo, "super sekret subject", "a", "a2", r.getChangeId())
+            .create(
+                admin.newIdent(),
+                testRepo,
+                "super sekret subject\n\nsubject-with-tokens=secret subject",
+                "a",
+                "a2",
+                r.getChangeId())
             .to("refs/for/master");
     r.assertOkStatus();
 
@@ -80,7 +93,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message m = messages.get(0);
-    assertThat(m.rcpt()).containsExactly(addr);
+    assertThat(m.rcpt()).containsExactlyElementsIn(watchers.build());
     assertThat(m.body()).contains("Change subject: super sekret subject\n");
     assertThat(m.body()).contains("Gerrit-PatchSet: 2\n");
   }
@@ -336,6 +349,163 @@
   }
 
   @Test
+  public void noNotificationForWatchKeywordWhenKeywordMatchesChangeOwner() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, admin.email());
+
+    // push a change with owner=keyword -> should not trigger email notification
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void noNotificationForWatchKeywordWhenKeywordMatchesChangeReviewer() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, user2.email());
+
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+    sender.clear();
+
+    // Add reviewer=keyword -> should trigger email notification only to new reviewer
+    gApi.changes().id(r.getChangeId()).addReviewer(user2.email());
+
+    // assert email notification
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertNotifyTo(user2);
+    assertThat(m.body()).contains("Change subject: subject\n");
+  }
+
+  @Test
+  public void watchOwner() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, "owner:admin");
+
+    // push a change with keyword -> should trigger email notification
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert email notification for user
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void watchNonVisibleOwner() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // watch keyword in project as user
+    watch(watchedProject, "owner:admin");
+
+    // Verify that 'user' can't see 'admin'
+    assertThatAccountIsNotVisible(admin);
+
+    // push a change with keyword -> should trigger email notification
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+
+    // assert no email notifications for user
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void watchChangesCommentedBySelf() throws Exception {
+    String watchedProject = projectOperations.newProject().create().get();
+    requestScopeOperations.setApiUser(user.id());
+
+    // user watches all changes that have a comment by themselves
+    watch(watchedProject, "commentby:self");
+
+    // pushing a change as admin should not trigger an email to user
+    requestScopeOperations.setApiUser(admin.id());
+    TestRepository<InMemoryRepository> watchedRepo =
+        cloneProject(Project.nameKey(watchedProject), admin);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+            .to("refs/for/master");
+    r.assertOkStatus();
+    assertThat(sender.getMessages()).isEmpty();
+
+    // commenting by admin should not trigger an email to user
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.message = "A Comment";
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    assertThat(sender.getMessages()).isEmpty();
+
+    // commenting by user matches the project watch, but doesn't send an email to user because
+    // CC_ON_OWN_COMMENTS is false by default, so the user is removed from the TO list, but an email
+    // is sent to the admin user
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(admin.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+
+    // commenting by admin now triggers an email to user because the change has a comment by user
+    // and hence matches the project watch
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+    assertThat(m.body()).contains("Change subject: subject\n");
+    assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+    sender.clear();
+  }
+
+  @Test
   public void watchAllProjects() throws Exception {
     String anyProject = projectOperations.newProject().create().get();
     requestScopeOperations.setApiUser(user.id());
@@ -449,39 +619,6 @@
   }
 
   @Test
-  public void watchProjectNoNotificationForIgnoredChange() throws Exception {
-    // watch project
-    String watchedProject = projectOperations.newProject().create().get();
-    requestScopeOperations.setApiUser(user.id());
-    watch(watchedProject);
-
-    // push a change to watched project
-    requestScopeOperations.setApiUser(admin.id());
-    TestRepository<InMemoryRepository> watchedRepo =
-        cloneProject(Project.nameKey(watchedProject), admin);
-    PushOneCommit.Result r =
-        pushFactory
-            .create(admin.newIdent(), watchedRepo, "ignored change", "a", "a1")
-            .to("refs/for/master");
-    r.assertOkStatus();
-
-    // ignore the change
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(r.getChangeId()).ignore(true);
-
-    sender.clear();
-
-    // post a comment -> should not trigger email notification since user ignored the change
-    requestScopeOperations.setApiUser(admin.id());
-    ReviewInput in = new ReviewInput();
-    in.message = "comment";
-    gApi.changes().id(r.getChangeId()).current().review(in);
-
-    // assert email notification
-    assertThat(sender.getMessages()).isEmpty();
-  }
-
-  @Test
   public void watchProjectNoNotificationForPrivateChange() throws Exception {
     // watch project
     String watchedProject = projectOperations.newProject().create().get();
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index b76d5cb..d3c4949 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -15,15 +15,21 @@
 package com.google.gerrit.acceptance.server.project;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.ExtensionRegistry.PLUGIN_NAME;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 
 import com.google.common.collect.MoreCollectors;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
@@ -34,12 +40,20 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.project.SubmitRequirementEvaluationException;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeIsOperandFactory;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.Map;
 import java.util.Optional;
 import org.junit.Before;
 import org.junit.Test;
@@ -49,10 +63,16 @@
   @Inject SubmitRequirementsEvaluator evaluator;
   @Inject private ProjectOperations projectOperations;
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ChangeOperations changeOperations;
 
   private ChangeData changeData;
   private String changeId;
 
+  private static final String FILE_NAME = "file,txt";
+  private static final String CONTENT = "line 1\nline 2\n line 3\n";
+
   @Before
   public void setUp() throws Exception {
     PushOneCommit.Result pushResult =
@@ -93,6 +113,25 @@
   }
 
   @Test
+  public void throwingSubmitRequirementPredicate() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new ThrowingSubmitRequirementPredicate(),
+                ThrowingSubmitRequirementPredicate.OPERAND)) {
+      SubmitRequirementExpression expression =
+          SubmitRequirementExpression.create(
+              String.format("is:%s_%s", ThrowingSubmitRequirementPredicate.OPERAND, PLUGIN_NAME));
+      SubmitRequirementExpressionResult result =
+          evaluator.evaluateExpression(expression, changeData);
+      assertThat(result.status()).isEqualTo(Status.ERROR);
+      assertThat(result.errorMessage().get())
+          .isEqualTo(ThrowingSubmitRequirementPredicate.ERROR_MESSAGE);
+    }
+  }
+
+  @Test
   public void compositeExpression() throws Exception {
     SubmitRequirementExpression expression =
         SubmitRequirementExpression.create(
@@ -110,6 +149,93 @@
   }
 
   @Test
+  public void globalSubmitRequirementEvaluated() throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "global-config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            false);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "project-config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData);
+      assertThat(results).hasSize(2);
+      assertThat(results.get(globalSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(results.get(projectSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
+  public void
+      globalSubmitRequirement_duplicateInProjectConfig_overrideAllowed_projectResultReturned()
+          throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            true);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData);
+      assertThat(results).hasSize(1);
+      assertThat(results.get(projectSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
+  public void
+      globalSubmitRequirement_duplicateInProjectConfig_overrideNotAllowedAllowed_globalResultReturned()
+          throws Exception {
+    SubmitRequirement globalSubmitRequirement =
+        createSubmitRequirement(
+            /*name=*/ "config-requirement",
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /*submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+            false);
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(globalSubmitRequirement)) {
+      SubmitRequirement projectSubmitRequirement =
+          createSubmitRequirement(
+              /*name=*/ "config-requirement",
+              /* applicabilityExpr= */ "project:" + project.get(),
+              /*submittabilityExpr= */ "is:true",
+              /* overrideExpr= */ "", /*allowOverrideInChildProjects*/
+              false);
+      configSubmitRequirement(project, projectSubmitRequirement);
+      Map<SubmitRequirement, SubmitRequirementResult> results =
+          evaluator.evaluateAllRequirements(changeData);
+      assertThat(results).hasSize(1);
+      assertThat(results.get(globalSubmitRequirement).status())
+          .isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    }
+  }
+
+  @Test
   public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsFalse()
       throws Exception {
     SubmitRequirement sr =
@@ -123,6 +249,95 @@
   }
 
   @Test
+  public void submitRequirement_alwaysNotApplicable() {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "is:false",
+            /* submittabilityExpr= */ "is:false", // redundant
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirement_alwaysApplicable() {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "is:true",
+            /* submittabilityExpr= */ "is:true",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
+  @Test
+  public void submittabilityAndOverrideNotEvaluated_whenApplicabilityIsFalse() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:non-existent-project",
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+    assertThat(result.applicabilityExpressionResult().get().status()).isEqualTo(Status.FAIL);
+    assertThat(result.submittabilityExpressionResult().get().status())
+        .isEqualTo(Status.NOT_EVALUATED);
+    assertThat(result.submittabilityExpressionResult().get().expression().expressionString())
+        .isEqualTo("message:\"Fix bug\"");
+    assertThat(result.overrideExpressionResult().isPresent()).isFalse();
+  }
+
+  @Test
+  public void submittabilityAndOverrideEvaluated_whenApplicabilityIsEmpty() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ null,
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "label:\"build-cop-override=-1\"");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    assertThat(result.applicabilityExpressionResult().isPresent()).isFalse();
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.overrideExpressionResult().get().status()).isEqualTo(Status.FAIL);
+  }
+
+  @Test
+  public void submittabilityAndOverrideEvaluated_whenApplicabilityIsTrue() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "label:\"build-cop-override=-1\"");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+    assertThat(result.applicabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.overrideExpressionResult().get().status()).isEqualTo(Status.FAIL);
+  }
+
+  @Test
+  public void submittabilityIsEvaluated_whenOverrideApplies() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ null,
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "project:" + project.get());
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.OVERRIDDEN);
+
+    assertThat(result.applicabilityExpressionResult().isPresent()).isFalse();
+    assertThat(result.submittabilityExpressionResult().get().status()).isEqualTo(Status.PASS);
+    assertThat(result.overrideExpressionResult().get().status()).isEqualTo(Status.PASS);
+  }
+
+  @Test
   public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsTrue() throws Exception {
     SubmitRequirement sr =
         createSubmitRequirement(
@@ -145,7 +360,7 @@
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
-    assertThat(result.submittabilityExpressionResult().failingAtoms())
+    assertThat(result.submittabilityExpressionResult().get().failingAtoms())
         .containsExactly("label:\"Code-Review=+2\"");
   }
 
@@ -199,7 +414,7 @@
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
-    assertThat(result.submittabilityExpressionResult().errorMessage().get())
+    assertThat(result.submittabilityExpressionResult().get().errorMessage().get())
         .isEqualTo("Unsupported operator invalid_field:invalid_value");
   }
 
@@ -239,11 +454,412 @@
     ChangeInfo changeInfo = gApi.changes().id(changeId).revert().get();
     String revertId = Integer.toString(changeInfo._number);
     ChangeData revertChangeData =
-        changeQueryProvider.get().byLegacyChangeId(Change.Id.parse(revertId)).get(0);
+        changeQueryProvider.get().byLegacyChangeId(Change.Id.tryParse(revertId).get()).get(0);
     result = evaluator.evaluateRequirement(sr, revertChangeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
   }
 
+  @Test
+  public void byAuthorEmail() throws Exception {
+    TestAccount user2 =
+        accountCreator.create("Foo", "user@example.com", "User", /* displayName = */ null);
+    requestScopeOperations.setApiUser(user2.id());
+    ChangeInfo info =
+        gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get();
+    ChangeData cd =
+        changeQueryProvider
+            .get()
+            .byLegacyChangeId(Change.Id.tryParse(Integer.toString(info._number)).get())
+            .get(0);
+
+    // Match by email works
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^.*@example\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^user@.*\\.com\"",
+        SubmitRequirementResult.Status.SATISFIED);
+
+    // Match by name does not work
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^Foo$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+    checkSubmitRequirementResult(
+        cd,
+        /* submittabilityExpr= */ "authoremail:\"^User$\"",
+        SubmitRequirementResult.Status.UNSATISFIED);
+  }
+
+  private void checkSubmitRequirementResult(
+      ChangeData cd, String submittabilityExpr, SubmitRequirementResult.Status expectedStatus) {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            submittabilityExpr,
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, cd);
+    assertThat(result.status()).isEqualTo(expectedStatus);
+  }
+
+  @Test
+  public void byFileEdits_deletedContent_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 2\n", ""))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 2'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_deletedContent_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 1\n", ""))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 2'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_addedContent_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT + "line 4\n")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 4'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_addedContent_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT + "line 4\n")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='line 5'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_addedFile_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file("new_file.txt")
+            .content("content of the new file")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^new.*\\\\.txt',withDiffContaining='of the new'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_addedFile_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file("new_file.txt")
+            .content("content of the new file")
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^new.*\\.txt',withDiffContaining='not_exist'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_modifiedContent_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='three'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_modifiedContent_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.txt',withDiffContaining='ten'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_modifiedContentPattern_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='^.*th[rR]ee$'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_exactMatchingWithFilePath_matching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            String.format("file:\"'%s',withDiffContaining='three'\"", FILE_NAME));
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_exactMatchingWithFilePath_nonMatching() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'non_existent.txt',withDiffContaining='three'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_notMatchingWithFilePath() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    // commit edit only matches with files ending with ".java". Since our modified file name ends
+    // with ".txt", the applicability expression will not match.
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^.*\\.java',withDiffContaining='three'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_escapeSingleQuotes() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line 'three' is modified\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='line \\'three\\' is'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_doubleEscapeSingleQuote() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            // This will be written to the file as: line \'three\' is modified.
+            .content(CONTENT.replace("line 3\n", "line \\'three\\' is modified\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    // Users can still provide back-slashes in regexes by escaping them.
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='line \\\\'three\\\\' is'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_escapeDoubleQuotes() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line \"three\" is modified\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining='line \\\"three\\\" is'\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void byFileEdits_invalidSyntax() throws Exception {
+    Change.Id parent = changeOperations.newChange().file(FILE_NAME).content(CONTENT).create();
+    Change.Id childId =
+        changeOperations
+            .newChange()
+            .file(FILE_NAME)
+            .content(CONTENT.replace("line 3\n", "line three\n"))
+            .childOf()
+            .change(parent)
+            .create();
+
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create(
+            "file:\"'^.*\\.txt',withDiffContaining=forgot single quotes\"");
+
+    ChangeData childChangeData = changeQueryProvider.get().byLegacyChangeId(childId).get(0);
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, childChangeData);
+    // If the format is invalid, the operator falls back to the default operator of
+    // ChangeQueryBuilder which does not match the change, i.e. returns false.
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void byFileEdits_invalidFilePattern() throws Exception {
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'^**',withDiffContaining='content'\"");
+
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, changeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+    assertThat(srResult.errorMessage().get()).isEqualTo("Invalid file pattern.");
+  }
+
+  @Test
+  public void byFileEdits_invalidContentPattern() throws Exception {
+    SubmitRequirementExpression exp =
+        SubmitRequirementExpression.create("file:\"'fileName\\.txt',withDiffContaining='^**'\"");
+
+    SubmitRequirementExpressionResult srResult = evaluator.evaluateExpression(exp, changeData);
+    assertThat(srResult.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+    assertThat(srResult.errorMessage().get()).isEqualTo("Invalid content pattern.");
+  }
+
   private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
     gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
   }
@@ -268,13 +884,55 @@
       @Nullable String applicabilityExpr,
       String submittabilityExpr,
       @Nullable String overrideExpr) {
+    return createSubmitRequirement(
+        /*name= */ "sr-name",
+        applicabilityExpr,
+        submittabilityExpr,
+        overrideExpr,
+        /*allowOverrideInChildProjects=*/ false);
+  }
+
+  private SubmitRequirement createSubmitRequirement(
+      String name,
+      @Nullable String applicabilityExpr,
+      String submittabilityExpr,
+      @Nullable String overrideExpr,
+      boolean allowOverrideInChildProjects) {
     return SubmitRequirement.builder()
-        .setName("sr-name")
+        .setName(name)
         .setDescription(Optional.of("sr-description"))
         .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
         .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
         .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
-        .setAllowOverrideInChildProjects(false)
+        .setAllowOverrideInChildProjects(allowOverrideInChildProjects)
         .build();
   }
+
+  /** Submit requirement predicate that always throws an error on match. */
+  static class ThrowingSubmitRequirementPredicate extends SubmitRequirementPredicate
+      implements ChangeIsOperandFactory {
+
+    public static final String OPERAND = "throwing-predicate";
+
+    public static final String ERROR_MESSAGE = "Error is storage";
+
+    public ThrowingSubmitRequirementPredicate() {
+      super("is", OPERAND);
+    }
+
+    @Override
+    public boolean match(ChangeData object) {
+      throw new SubmitRequirementEvaluationException(ERROR_MESSAGE);
+    }
+
+    @Override
+    public int getCost() {
+      return 0;
+    }
+
+    @Override
+    public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException {
+      return this;
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
index d8aa789..a643d56 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.project.ProjectConfig;
 import java.util.Locale;
@@ -377,6 +378,115 @@
             invalidValue));
   }
 
+  @Test
+  public void validSubmitRequirementCanBePushedForReview_optionalParametersNotSet()
+      throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        /* value= */ "label:\"Code-Review=+2\"");
+
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Add submit requirement",
+            ProjectConfig.PROJECT_CONFIG,
+            projectConfig.toText(),
+            /* topic= */ null);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void validSubmitRequirementCanBePushedForReview_allParametersSet() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+        /* value= */ "foo bar description");
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+        /* value= */ "branch:refs/heads/master");
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        /* value= */ "label:\"Code-Review=+2\"");
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+        /* value= */ "label:\"override=+1\"");
+    projectConfig.setBoolean(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+        /* value= */ false);
+
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Add submit requirement",
+            ProjectConfig.PROJECT_CONFIG,
+            projectConfig.toText(),
+            /* topic= */ null);
+    r.assertOkStatus();
+  }
+
+  @Test
+  public void invalidSubmitRequirementIsRejectedWhenPushingForReview() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    projectConfig.setString(
+        ProjectConfig.SUBMIT_REQUIREMENT,
+        /* subsection= */ submitRequirementName,
+        /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+        /* value= */ invalidExpression);
+    PushOneCommit.Result r =
+        createChange(
+            testRepo,
+            RefNames.REFS_CONFIG,
+            "Add submit requirement",
+            ProjectConfig.PROJECT_CONFIG,
+            projectConfig.toText(),
+            /* topic= */ null);
+    r.assertErrorStatus(
+        String.format(
+            "invalid submit requirement expressions in project.config (revision = %s)",
+            r.getCommit().name()));
+    assertThat(r.getMessage()).contains("Invalid project configuration");
+    assertThat(r.getMessage())
+        .contains(
+            String.format(
+                "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                    + " invalid: Unsupported operator %s",
+                invalidExpression,
+                submitRequirementName,
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                submitRequirementName,
+                ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                invalidExpression));
+  }
+
   private void fetchRefsMetaConfig() throws Exception {
     fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
     testRepo.reset(RefNames.REFS_CONFIG);
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
index 537c7d8..4ce62d2 100644
--- a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -25,9 +25,7 @@
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.change.ChangeKindCache;
@@ -35,7 +33,8 @@
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
-import java.util.Date;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Test;
 
 public class ApprovalQueryIT extends AbstractDaemonTest {
@@ -65,6 +64,29 @@
   }
 
   @Test
+  public void exactValuePredicate() throws Exception {
+    ApprovalContext approvalContextCodeReviewPlusOne = contextForCodeReviewLabel(1);
+    assertFalse(
+        queryBuilder.parse("is:\"-2\"").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertFalse(
+        queryBuilder.parse("is:\"-1\"").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertFalse(queryBuilder.parse("is:0").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertTrue(queryBuilder.parse("is:1").asMatchable().match(approvalContextCodeReviewPlusOne));
+    assertFalse(queryBuilder.parse("is:2").asMatchable().match(approvalContextCodeReviewPlusOne));
+  }
+
+  @Test
+  public void isPredicate_invalidValue() throws Exception {
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> queryBuilder.parse("is:INVALID"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "INVALID is not a valid value for operator 'is'. Valid values: ANY, MAX, MIN"
+                + " or integer");
+  }
+
+  @Test
   public void changeKindPredicate_noCodeChange() throws Exception {
     String change = changeKindCreator.createChange(ChangeKind.NO_CODE_CHANGE, testRepo, admin);
     changeKindCreator.updateChange(change, ChangeKind.NO_CODE_CHANGE, testRepo, admin, project);
@@ -125,6 +147,17 @@
   }
 
   @Test
+  public void changeKindPredicate_invalidValue() throws Exception {
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> queryBuilder.parse("changekind:INVALID"));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "INVALID is not a valid value for operator 'changekind'. Valid values:"
+                + " MERGE_FIRST_PARENT_UPDATE, NO_CHANGE, NO_CODE_CHANGE, REWORK, TRIVIAL_REBASE");
+  }
+
+  @Test
   public void uploaderInPredicate() throws Exception {
     String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
 
@@ -239,7 +272,15 @@
     assertThat(thrown)
         .hasMessageThat()
         .contains(
-            "'invalid' is not a supported argument for has. only 'unchanged-files' is supported");
+            "'invalid' is not a valid value for operator 'has'."
+                + " The only valid value is 'unchanged-files'.");
+  }
+
+  @Test
+  public void invalidQuery() throws Exception {
+    QueryParseException thrown =
+        assertThrows(QueryParseException.class, () -> queryBuilder.parse("INVALID"));
+    assertThat(thrown).hasMessageThat().contains("Unsupported query: INVALID");
   }
 
   private ApprovalContext contextForCodeReviewLabel(int value) throws Exception {
@@ -250,20 +291,25 @@
   }
 
   private ApprovalContext contextForCodeReviewLabel(
-      int value, PatchSet.Id psId, Account.Id approver) {
+      int value, PatchSet.Id psId, Account.Id approver) throws Exception {
     ChangeNotes changeNotes = changeNotesFactory.create(project, psId.changeId());
     PatchSet.Id newPsId = PatchSet.id(psId.changeId(), psId.get() + 1);
     ChangeKind changeKind =
         changeKindCache.getChangeKind(
             changeNotes.getChange(), changeNotes.getPatchSets().get(newPsId));
-    PatchSetApproval approval =
-        PatchSetApproval.builder()
-            .postSubmit(false)
-            .granted(new Date())
-            .key(PatchSetApproval.key(psId, approver, LabelId.create("Code-Review")))
-            .value(value)
-            .build();
-    return ApprovalContext.create(
-        changeNotes, approval, changeNotes.getPatchSets().get(newPsId), changeKind);
+    try (Repository repo = repoManager.openRepository(project);
+        RevWalk rw = new RevWalk(repo.newObjectReader())) {
+      return ApprovalContext.create(
+          changeNotes,
+          psId,
+          approver,
+          projectCache.get(project).get().getLabelTypes().byLabel("Code-Review").get(),
+          (short) value,
+          changeNotes.getPatchSets().get(newPsId),
+          changeKind,
+          /* isMerge= */ false,
+          rw,
+          repo.getConfig());
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index 0585f74..4f93dd6 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
@@ -41,11 +42,10 @@
  */
 @NoHttpd
 public class RulesIT extends AbstractDaemonTest {
-  private static final String RULE_TEMPLATE =
-      "submit_rule(submit(W)) :- \n%s,\nW = label('OK', ok(user(1000000))).";
-
   @Inject private ProjectOperations projectOperations;
   @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
+  @Inject private IndexOperations.Change changeIndexOperations;
+  @Inject private IndexOperations.Account accountIndexOperations;
 
   @Test
   public void testUnresolvedCommentsCountPredicate() throws Exception {
@@ -164,6 +164,12 @@
     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 result =
@@ -237,8 +243,8 @@
     ChangeData cd = result.getChange();
 
     Collection<SubmitRecord> records;
-    try (AutoCloseable ignored1 = disableChangeIndex();
-        AutoCloseable ignored2 = disableAccountIndex()) {
+    try (AutoCloseable ignored1 = changeIndexOperations.disableReadsAndWrites();
+        AutoCloseable ignored2 = accountIndexOperations.disableReadsAndWrites()) {
       SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
       records = ruleEvaluator.evaluate(cd);
     }
@@ -249,7 +255,9 @@
   }
 
   private void modifySubmitRules(String ruleTested) throws Exception {
-    String newContent = String.format(RULE_TEMPLATE, ruleTested);
+    String newContent =
+        String.format(
+            "submit_rule(submit(W)) :- \n%s,\nW = label('OK', ok(user(1000000))).", ruleTested);
 
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
new file mode 100644
index 0000000..fdfef87
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
@@ -0,0 +1,285 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.git.WorkQueue.TaskListener;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TaskListenerIT extends AbstractDaemonTest {
+  /**
+   * Use a LatchedMethod in a method to allow another thread to await the method's call. Once
+   * called, the Latch.call() method will block until another thread calls its LatchedMethods's
+   * complete() method.
+   */
+  private static class LatchedMethod {
+    private static final int AWAIT_TIMEOUT = 20;
+    private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
+
+    /** API class meant be used by the class whose method is being latched */
+    private class Latch {
+      /** Ensure that the latched method calls this on entry */
+      public void call() {
+        called.countDown();
+        await(complete);
+      }
+    }
+
+    public Latch latch = new Latch();
+
+    private final CountDownLatch called = new CountDownLatch(1);
+    private final CountDownLatch complete = new CountDownLatch(1);
+
+    /** Assert that the Latch's call() method has not yet been called */
+    public void assertUncalled() {
+      assertThat(called.getCount()).isEqualTo(1);
+    }
+
+    /**
+     * Assert that a timeout does not occur while awaiting Latch's call() method to be called. Fails
+     * if the waiting time elapses before Latch's call() method is called, otherwise passes.
+     */
+    public void assertAwait() {
+      assertThat(await(called)).isEqualTo(true);
+    }
+
+    /** Unblock the Latch's call() method so that it can complete */
+    public void complete() {
+      complete.countDown();
+    }
+
+    @CanIgnoreReturnValue
+    private static boolean await(CountDownLatch latch) {
+      try {
+        return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
+      } catch (InterruptedException e) {
+        return false;
+      }
+    }
+  }
+
+  private static class LatchedRunnable implements Runnable {
+    public LatchedMethod run = new LatchedMethod();
+
+    @Override
+    public void run() {
+      run.latch.call();
+    }
+  }
+
+  private static class ForwardingListener implements TaskListener {
+    public volatile TaskListener delegate;
+    public volatile Task<?> task;
+
+    public void resetDelegate(TaskListener listener) {
+      delegate = listener;
+      task = null;
+    }
+
+    @Override
+    public void onStart(Task<?> task) {
+      if (delegate != null) {
+        if (this.task == null || this.task == task) {
+          this.task = task;
+          delegate.onStart(task);
+        }
+      }
+    }
+
+    @Override
+    public void onStop(Task<?> task) {
+      if (delegate != null) {
+        if (this.task == task) {
+          delegate.onStop(task);
+        }
+      }
+    }
+  }
+
+  private static class LatchedListener implements TaskListener {
+    public LatchedMethod onStart = new LatchedMethod();
+    public LatchedMethod onStop = new LatchedMethod();
+
+    @Override
+    public void onStart(Task<?> task) {
+      onStart.latch.call();
+    }
+
+    @Override
+    public void onStop(Task<?> task) {
+      onStop.latch.call();
+    }
+  }
+
+  private static ForwardingListener forwarder;
+
+  @Inject private WorkQueue workQueue;
+  private ScheduledExecutorService executor;
+
+  private final LatchedListener listener = new LatchedListener();
+  private final LatchedRunnable runnable = new LatchedRunnable();
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      public void configure() {
+        // Forwarder.delegate is empty on start to protect test listener from non test tasks
+        // (such as the "Log File Compressor") interference
+        forwarder = new ForwardingListener(); // Only gets bound once for all tests
+        bind(TaskListener.class).annotatedWith(Exports.named("listener")).toInstance(forwarder);
+      }
+    };
+  }
+
+  @Before
+  public void setupExecutorAndForwarder() throws InterruptedException {
+    executor = workQueue.createQueue(1, "TaskListeners");
+
+    // "Log File Compressor"s are likely running and will interfere with tests
+    while (0 != workQueue.getTasks().size()) {
+      for (Task<?> t : workQueue.getTasks()) {
+        @SuppressWarnings("unused")
+        boolean unused = t.cancel(true);
+      }
+      TimeUnit.MILLISECONDS.sleep(1);
+    }
+
+    forwarder.resetDelegate(listener);
+
+    assertQueueSize(0);
+    assertThat(forwarder.task).isEqualTo(null);
+    listener.onStart.assertUncalled();
+    runnable.run.assertUncalled();
+    listener.onStop.assertUncalled();
+  }
+
+  @Test
+  public void onStartThenRunThenOnStopAreCalled() throws Exception {
+    int size = assertQueueBlockedOnExecution(runnable);
+
+    // onStartThenRunThenOnStopAreCalled -> onStart...Called
+    listener.onStart.assertAwait();
+    assertQueueSize(size);
+    runnable.run.assertUncalled();
+    listener.onStop.assertUncalled();
+
+    listener.onStart.complete();
+    // onStartThenRunThenOnStopAreCalled -> ...ThenRun...Called
+    runnable.run.assertAwait();
+    listener.onStop.assertUncalled();
+
+    runnable.run.complete();
+    // onStartThenRunThenOnStopAreCalled -> ...ThenOnStop...Called
+    listener.onStop.assertAwait();
+    assertQueueSize(size);
+
+    listener.onStop.complete();
+    assertAwaitQueueSize(--size);
+  }
+
+  @Test
+  public void firstBlocksSecond() throws Exception {
+    int size = assertQueueBlockedOnExecution(runnable);
+
+    // firstBlocksSecond -> first...
+    listener.onStart.assertAwait();
+    assertQueueSize(size);
+
+    LatchedRunnable runnable2 = new LatchedRunnable();
+    size = assertQueueBlockedOnExecution(runnable2);
+
+    // firstBlocksSecond -> ...BlocksSecond
+    runnable2.run.assertUncalled();
+    assertQueueSize(size); // waiting on first
+
+    listener.onStart.complete();
+    runnable.run.assertAwait();
+    assertQueueSize(size); // waiting on first
+    runnable2.run.assertUncalled();
+
+    runnable.run.complete();
+    listener.onStop.assertAwait();
+    assertQueueSize(size); // waiting on first
+    runnable2.run.assertUncalled();
+
+    listener.onStop.complete();
+    runnable2.run.assertAwait();
+    assertQueueSize(--size);
+
+    runnable2.run.complete();
+    assertAwaitQueueSize(--size);
+  }
+
+  @Test
+  public void states() throws Exception {
+    executor.execute(runnable);
+    listener.onStart.assertAwait();
+    assertStateIs(Task.State.STARTING);
+
+    listener.onStart.complete();
+    runnable.run.assertAwait();
+    assertStateIs(Task.State.RUNNING);
+
+    runnable.run.complete();
+    listener.onStop.assertAwait();
+    assertStateIs(Task.State.STOPPING);
+
+    listener.onStop.complete();
+    assertAwaitQueueIsEmpty();
+    assertStateIs(Task.State.DONE);
+  }
+
+  private void assertStateIs(Task.State state) {
+    assertThat(forwarder.task.getState()).isEqualTo(state);
+  }
+
+  private int assertQueueBlockedOnExecution(Runnable runnable) {
+    int expectedSize = workQueue.getTasks().size() + 1;
+    executor.execute(runnable);
+    assertQueueSize(expectedSize);
+    return expectedSize;
+  }
+
+  private void assertQueueSize(int size) {
+    assertThat(workQueue.getTasks().size()).isEqualTo(size);
+  }
+
+  private void assertAwaitQueueIsEmpty() throws InterruptedException {
+    assertAwaitQueueSize(0);
+  }
+
+  /** Fails if the waiting time elapses before the count is reached, otherwise passes */
+  private void assertAwaitQueueSize(int size) throws InterruptedException {
+    long i = 0;
+    do {
+      TimeUnit.NANOSECONDS.sleep(10);
+      assertThat(i++).isLessThan(100);
+    } while (size != workQueue.getTasks().size());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index 3b38bad..2a06900 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -26,10 +26,10 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import com.google.inject.Injector;
 import java.util.List;
 import org.junit.Test;
 
@@ -37,8 +37,7 @@
 @UseSsh
 public abstract class AbstractIndexTests extends AbstractDaemonTest {
   @Inject private ExtensionRegistry extensionRegistry;
-
-  public void configureIndex(Injector injector) {}
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @Test
   @GerritConfig(name = "index.autoReindexIfStale", value = "false")
@@ -46,18 +45,16 @@
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(changeIndexedCounter)) {
-      configureIndex(server.getTestInjector());
 
       PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
       String changeId = change.getChangeId();
       String changeLegacyId = change.getChange().getId().toString();
       ChangeInfo changeInfo = gApi.changes().id(changeId).get();
 
-      disableChangeIndexWrites();
-      amendChange(changeId, "second test", "test2.txt", "test2");
-
-      assertChangeQuery(change.getChange(), false);
-      enableChangeIndexWrites();
+      try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
+        amendChange(changeId, "second test", "test2.txt", "test2");
+        assertChangeQuery(change.getChange(), false);
+      }
 
       changeIndexedCounter.clear();
       String cmd = Joiner.on(" ").join("gerrit", "index", "changes", changeLegacyId);
@@ -76,17 +73,15 @@
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(changeIndexedCounter)) {
-      configureIndex(server.getTestInjector());
 
       PushOneCommit.Result change = createChange("first change", "test1.txt", "test1");
       String changeId = change.getChangeId();
       ChangeInfo changeInfo = gApi.changes().id(changeId).get();
 
-      disableChangeIndexWrites();
-      amendChange(changeId, "second test", "test2.txt", "test2");
-
-      assertChangeQuery(change.getChange(), false);
-      enableChangeIndexWrites();
+      try (AutoCloseable ignored = changeIndexOperations.disableWrites()) {
+        amendChange(changeId, "second test", "test2.txt", "test2");
+        assertChangeQuery(change.getChange(), false);
+      }
 
       changeIndexedCounter.clear();
       String cmd = Joiner.on(" ").join("gerrit", "index", "changes-in-project", project.get());
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BUILD b/javatests/com/google/gerrit/acceptance/ssh/BUILD
index ee1b221..6dec6af 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BUILD
+++ b/javatests/com/google/gerrit/acceptance/ssh/BUILD
@@ -11,7 +11,6 @@
 acceptance_tests(
     srcs = glob(
         ["*IT.java"],
-        exclude = ["ElasticIndexIT.java"],
     ),
     group = "ssh",
     labels = ["ssh"],
@@ -23,20 +22,3 @@
         "//lib/commons:compress",
     ],
 )
-
-acceptance_tests(
-    srcs = ["ElasticIndexIT.java"],
-    group = "elastic",
-    labels = [
-        "docker",
-        "elastic",
-        "exclusive",
-        "ssh",
-    ],
-    deps = [
-        ":util",
-        "//java/com/google/gerrit/elasticsearch",
-        "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
-        "//lib/commons:compress",
-    ],
-)
diff --git a/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsBeanParseListenerIT.java b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsBeanParseListenerIT.java
new file mode 100644
index 0000000..0afdbc6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/DynamicOptionsBeanParseListenerIT.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.sshd.commands.ListProjectsCommand;
+import com.google.inject.AbstractModule;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class DynamicOptionsBeanParseListenerIT extends AbstractDaemonTest {
+
+  @Test
+  public void testBeanParseListener() throws Exception {
+    createProjectOverAPI("project1", project, true, null);
+    createProjectOverAPI("project2", project, true, null);
+    try (AutoCloseable ignored = installPlugin("my-plugin", PluginModule.class)) {
+      String output = adminSshSession.exec("gerrit ls-projects");
+      adminSshSession.assertSuccess();
+      assertThat(getProjects(output)).hasSize(1);
+    }
+  }
+
+  protected List<String> getProjects(String sshOutput) {
+    return Arrays.asList(sshOutput.split("\n"));
+  }
+
+  protected static class ListProjectsCommandBeanListener
+      implements DynamicOptions.BeanParseListener {
+    @Override
+    public void onBeanParseStart(String plugin, Object bean) {
+      ListProjectsCommand command = (ListProjectsCommand) bean;
+      command.impl.setLimit(1);
+    }
+
+    @Override
+    public void onBeanParseEnd(String plugin, Object bean) {}
+  }
+
+  protected static class PluginModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(DynamicOptions.DynamicBean.class)
+          .annotatedWith(Exports.named(ListProjectsCommand.class))
+          .to(ListProjectsCommandBeanListener.class);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
deleted file mode 100644
index f35bcb7..0000000
--- a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.ssh;
-
-import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
-import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
-
-import com.google.gerrit.elasticsearch.ElasticVersion;
-import com.google.gerrit.index.IndexType;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-
-/** Tests for every supported {@link IndexType#isElasticsearch()} most recent index version. */
-public class ElasticIndexIT extends AbstractIndexTests {
-
-  @ConfigSuite.Default
-  public static Config elasticsearchV7() {
-    return getConfig(ElasticVersion.V7_8);
-  }
-
-  @Override
-  public void configureIndex(Injector injector) {
-    createAllIndexes(injector);
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
index cf316c7..912c464 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -17,18 +17,31 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.SshSession;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.ListResultSet;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.server.data.ChangeAttribute;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gson.Gson;
+import com.google.inject.AbstractModule;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -37,7 +50,6 @@
 @NoHttpd
 @UseSsh
 public class QueryIT extends AbstractDaemonTest {
-
   private static Gson gson = new Gson();
 
   @Test
@@ -301,6 +313,74 @@
     }
   }
 
+  @Test
+  public void chooseCheapestDatasource() throws Exception {
+    try (AutoCloseable ignored = installPlugin("myplugin", SamplePluginModule.class)) {
+      for (int i = 0; i < 5; i++) {
+        createChange();
+      }
+      List<ChangeAttribute> changes =
+          executeSuccessfulQuery("status:open " + CheapSource.FIELD + "_myplugin:foo");
+      assertThat(changes).hasSize(0);
+    }
+  }
+
+  protected static class SamplePluginModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(ChangeQueryBuilder.ChangeOperatorFactory.class)
+          .annotatedWith(Exports.named(CheapSource.FIELD))
+          .to(CheapSourceOperator.class);
+    }
+  }
+
+  protected static class CheapSourceOperator implements ChangeQueryBuilder.ChangeOperatorFactory {
+    @Override
+    public Predicate<ChangeData> create(ChangeQueryBuilder builder, String value)
+        throws QueryParseException {
+      return new CheapSource(value);
+    }
+  }
+
+  protected static class CheapSource extends OperatorPredicate<ChangeData>
+      implements Matchable<ChangeData>, ChangeDataSource {
+    public static final String FIELD = "cheapsource";
+
+    public CheapSource(String value) {
+      super(FIELD, value);
+    }
+
+    @Override
+    public int getCardinality() {
+      return 1;
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() {
+      return new ListResultSet<>(new ArrayList<>());
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() {
+      return null;
+    }
+
+    @Override
+    public boolean match(ChangeData object) {
+      return true;
+    }
+
+    @Override
+    public int getCost() {
+      return 1;
+    }
+
+    @Override
+    public boolean hasChange() {
+      return false;
+    }
+  }
+
   private List<ChangeAttribute> executeSuccessfulQuery(String params, SshSession session)
       throws Exception {
     String rawResponse = session.exec("gerrit query --format=JSON " + params);
@@ -313,10 +393,10 @@
   }
 
   private static List<ChangeAttribute> getChanges(String rawResponse) {
-    String[] lines = rawResponse.split("\\n");
-    List<ChangeAttribute> changes = new ArrayList<>(lines.length - 1);
-    for (int i = 0; i < lines.length - 1; i++) {
-      changes.add(gson.fromJson(lines[i], ChangeAttribute.class));
+    List<String> lines = Splitter.on("\n").omitEmptyStrings().splitToList(rawResponse);
+    List<ChangeAttribute> changes = new ArrayList<>(lines.size() - 1);
+    for (int i = 0; i < lines.size() - 1; i++) {
+      changes.add(gson.fromJson(lines.get(i), ChangeAttribute.class));
     }
     return changes;
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
index 58c2517..2de52b2 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SetReviewersIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -50,7 +51,8 @@
 
   @Test
   public void byCommitHash() throws Exception {
-    String id = change.getCommit().getId().toString().split("\\s+")[1];
+    String id =
+        Splitter.onPattern("\\s+").splitToList(change.getCommit().getId().toString()).get(1);
     addReviewer(id);
     removeReviewer(id);
   }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 2b37cfd..dd300058 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -44,6 +44,7 @@
   private static final ImmutableList<String> COMMON_ROOT_COMMANDS =
       ImmutableList.of(
           "apropos",
+          "check-project-access",
           "close-connection",
           "convert-ref-storage",
           "flush-caches",
@@ -81,7 +82,8 @@
           "set-reviewers",
           "set-topic",
           "stream-events",
-          "test-submit");
+          "test-submit",
+          "migrate-externalids-to-insensitive");
 
   private static final ImmutableList<String> EMPTY = ImmutableList.of();
   private static final ImmutableMap<String, List<String>> MASTER_COMMANDS =
@@ -142,7 +144,7 @@
     // option causes the usage info to be written to stderr. Instead, we assert on the
     // content of the stderr, which will always start with "gerrit command" when the --help
     // option is used.
-    logger.atFine().log(cmd);
+    logger.atFine().log("%s", cmd);
     adminSshSession.exec(String.format("%s --help", cmd));
     String response = adminSshSession.getError();
     assertWithMessage(String.format("command %s failed: %s", cmd, response))
@@ -205,22 +207,22 @@
   private List<String> parseCommandsFromGerritHelpText(String helpText) {
     List<String> commands = new ArrayList<>();
 
-    String[] lines = helpText.split("\\n");
+    List<String> lines = Splitter.on("\n").splitToList(helpText);
 
     // Skip all lines including the line starting with "Available commands"
     int row = 0;
     do {
       row++;
-    } while (row < lines.length && !lines[row - 1].startsWith("Available commands"));
+    } while (row < lines.size() && !lines.get(row - 1).startsWith("Available commands"));
 
     // Skip all empty lines
-    while (lines[row].trim().isEmpty()) {
+    while (lines.get(row).trim().isEmpty()) {
       row++;
     }
 
     // Parse commands from all lines that are indented (start with a space)
-    while (row < lines.length && lines[row].startsWith(" ")) {
-      String line = lines[row].trim();
+    while (row < lines.size() && lines.get(row).startsWith(" ")) {
+      String line = lines.get(row).trim();
       // Abort on empty line
       if (line.isEmpty()) {
         break;
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
index ae45d90..84c3936 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogger;
@@ -31,8 +32,6 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import java.util.ArrayList;
-import java.util.List;
 import org.junit.Test;
 
 @UseSsh
@@ -94,6 +93,7 @@
   }
 
   @Test
+  @GerritConfig(name = "tracing.performanceLogging", value = "true")
   public void performanceLoggingForSshCall() throws Exception {
     TestPerformanceLogger testPerformanceLogger = new TestPerformanceLogger();
     try (Registration registration =
@@ -120,7 +120,7 @@
   }
 
   private static class TestPerformanceLogger implements PerformanceLogger {
-    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+    private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
     public void log(String operation, long durationMs, Metadata metadata) {
@@ -128,7 +128,7 @@
     }
 
     ImmutableList<PerformanceLogEntry> logEntries() {
-      return ImmutableList.copyOf(logEntries);
+      return logEntries.build();
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
index e0e1880..13a9e0c 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -40,7 +40,6 @@
 public class StreamEventsIT extends AbstractDaemonTest {
   private static final Duration MAX_DURATION_FOR_RECEIVING_EVENTS = Duration.ofSeconds(2);
   private static final String TEST_REVIEW_COMMENT = "any comment";
-  private StringBuilder eventsOutput = new StringBuilder();
   private Reader streamEventsReader;
 
   @Before
@@ -56,7 +55,17 @@
   @Test
   public void commentOnChangeShowsUpInStreamEvents() throws Exception {
     reviewChange(new ReviewInput().message(TEST_REVIEW_COMMENT));
-    waitForEvent(() -> pollEventsContaining(TEST_REVIEW_COMMENT).size() == 1);
+    waitForEvent(() -> pollEventsContaining("comment-added", TEST_REVIEW_COMMENT).size() == 1);
+  }
+
+  @Test
+  public void batchRefsUpdatedShowSeparatelyInStreamEvents() throws Exception {
+    String refName = createChange().getChange().currentPatchSet().refName();
+    waitForEvent(
+        () ->
+            pollEventsContaining("ref-updated", refName.substring(0, refName.lastIndexOf('/')))
+                    .size()
+                == 2);
   }
 
   private void waitForEvent(Supplier<Boolean> waitCondition) throws InterruptedException {
@@ -68,16 +77,20 @@
     changeApi.current().review(reviewInput);
   }
 
-  private List<String> pollEventsContaining(String reviewComment) {
+  private List<String> pollEventsContaining(String eventType, String expectedContent) {
     try {
       char[] cbuf = new char[2048];
+      StringBuilder eventsOutput = new StringBuilder();
       while (streamEventsReader.ready()) {
         streamEventsReader.read(cbuf);
         eventsOutput.append(cbuf);
       }
       return StreamSupport.stream(
               Splitter.on('\n').trimResults().split(eventsOutput.toString()).spliterator(), false)
-          .filter(event -> event.contains(reviewComment))
+          .filter(
+              event ->
+                  event.contains(String.format("\"type\":\"%s\"", eventType))
+                      && event.contains(expectedContent))
           .collect(Collectors.toList());
     } catch (IOException e) {
       throw new IllegalStateException(e);
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 0bd6554..6c629c9 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.truth.MapSubject.assertThatMap;
 import static com.google.gerrit.truth.OptionalSubject.assertThat;
 
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -610,6 +611,25 @@
   }
 
   @Test
+  public void createdChangeHasSpecifiedTopic() throws Exception {
+    Change.Id changeId = changeOperations.newChange().topic("test-topic").create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.topic).isEqualTo("test-topic");
+  }
+
+  @Test
+  public void createdChangeHasSpecifiedApprovals() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().approvals(ImmutableMap.of("Code-Review", (short) 1)).create();
+
+    ChangeInfo change = getChangeFromServer(changeId);
+    assertThat(change.labels).hasSize(1);
+    assertThat(change.labels.get("Code-Review").recommended._accountId)
+        .isEqualTo(change.owner._accountId);
+  }
+
+  @Test
   public void createdChangeHasSpecifiedCommitMessage() throws Exception {
     Change.Id changeId =
         changeOperations
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
index a003f9d..473b128 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImplTest.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -325,9 +325,9 @@
     GroupInfo group = gApi.groups().create(createArbitraryGroupInput()).detail();
     AccountGroup.UUID groupUuid = AccountGroup.uuid(group.id);
 
-    Timestamp createdOn = groupOperations.group(groupUuid).get().createdOn();
+    Instant createdOn = groupOperations.group(groupUuid).get().createdOn();
 
-    assertThat(createdOn).isEqualTo(group.createdOn);
+    assertThat(createdOn).isEqualTo(group.createdOn.toInstant());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
index 8e0d4bb..f6e5fb3 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.acceptance.testsuite.index;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assume.assumeTrue;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.index.IndexType;
 import com.google.gerrit.index.testing.AbstractFakeIndex;
@@ -35,6 +37,10 @@
 
   @Test
   public void fakeIsBoundByDefault() throws Exception {
+    String gerritIndexTypeEnv = System.getenv("GERRIT_INDEX_TYPE");
+    assumeTrue(
+        Strings.isNullOrEmpty(gerritIndexTypeEnv) || gerritIndexTypeEnv.equalsIgnoreCase("fake"));
+
     assertThat(System.getProperty(IndexType.SYS_PROP)).isEmpty();
     assertThat(changeIndex.getSearchIndex()).isInstanceOf(AbstractFakeIndex.FakeChangeIndex.class);
   }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 7543ba8..661802e 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -18,11 +18,14 @@
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabelRemoval;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelRemovalPermissionKey;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
 import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
@@ -160,7 +163,8 @@
     Project.NameKey key = projectOperations.newProject().create();
     Config config = projectOperations.project(key).getConfig();
     assertThat(config).isNotInstanceOf(StoredConfig.class);
-    assertThat(config).text().isEmpty();
+    assertThat(config).sections().containsExactly("submit");
+    assertThat(config).sectionValues("submit").containsExactly("action", "inherit");
 
     ConfigInput input = new ConfigInput();
     input.description = "my fancy project";
@@ -168,7 +172,7 @@
 
     config = projectOperations.project(key).getConfig();
     assertThat(config).isNotInstanceOf(StoredConfig.class);
-    assertThat(config).sections().containsExactly("project");
+    assertThat(config).sections().containsExactly("project", "submit");
     assertThat(config).subsections("project").isEmpty();
     assertThat(config).sectionValues("project").containsExactly("description", "my fancy project");
   }
@@ -193,7 +197,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -210,7 +214,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -227,7 +231,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -244,7 +248,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -262,7 +266,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -277,7 +281,7 @@
         .update();
 
     config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -318,7 +322,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -328,31 +332,28 @@
   }
 
   @Test
-  public void addDuplicatePermissions() throws Exception {
+  public void addDuplicatePermissions_isIgnored() throws Exception {
     TestPermission permission =
         TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS).build();
     Project.NameKey key = projectOperations.newProject().create();
     projectOperations.project(key).forUpdate().add(permission).add(permission).update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().contains("access");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
-        .containsExactly(
-            "abandon", "group global:Registered-Users",
-            "abandon", "group global:Registered-Users");
+        // Duplicated permission was recorded only once
+        .containsExactly("abandon", "group global:Registered-Users");
 
     projectOperations.project(key).forUpdate().add(permission).update();
     config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
-        .containsExactly(
-            "abandon", "group global:Registered-Users",
-            "abandon", "group global:Registered-Users",
-            "abandon", "group global:Registered-Users");
+        // Duplicated permission in request was dropped
+        .containsExactly("abandon", "group global:Registered-Users");
   }
 
   @Test
@@ -365,7 +366,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -382,7 +383,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -400,7 +401,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -415,7 +416,7 @@
         .update();
 
     config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -437,7 +438,7 @@
         .update();
 
     Config config = projectOperations.project(key).getConfig();
-    assertThat(config).sections().containsExactly("access");
+    assertThat(config).sections().containsExactly("access", "submit");
     assertThat(config).subsections("access").containsExactly("refs/foo");
     assertThat(config)
         .subsectionValues("access", "refs/foo")
@@ -445,6 +446,73 @@
   }
 
   @Test
+  public void addAllowLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addBlockLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(blockLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "block -1..+2 group global:Registered-Users");
+  }
+
+  @Test
+  public void addAllowExclusiveLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .setExclusiveGroup(labelRemovalPermissionKey("Code-Review").ref("refs/foo"), true)
+        .update();
+
+    Config config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "removeLabel-Code-Review", "-1..+2 group global:Registered-Users",
+            "exclusiveGroupPermissions", "removeLabel-Code-Review");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .setExclusiveGroup(labelRemovalPermissionKey("Code-Review").ref("refs/foo"), false)
+        .update();
+
+    config = projectOperations.project(key).getConfig();
+    assertThat(config).sections().containsExactly("access", "submit");
+    assertThat(config).subsections("access").containsExactly("refs/foo");
+    assertThat(config)
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "-1..+2 group global:Registered-Users");
+  }
+
+  @Test
   public void addAllowCapability() throws Exception {
     Config config = projectOperations.project(allProjects).getConfig();
     assertThat(config)
@@ -542,6 +610,31 @@
   }
 
   @Test
+  public void removeLabelRemovalPermission() throws Exception {
+    Project.NameKey key = projectOperations.newProject().create();
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+        .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(PROJECT_OWNERS).range(-2, 1))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly(
+            "removeLabel-Code-Review", "-1..+2 group global:Registered-Users",
+            "removeLabel-Code-Review", "-2..+1 group global:Project-Owners");
+
+    projectOperations
+        .project(key)
+        .forUpdate()
+        .remove(labelRemovalPermissionKey("Code-Review").ref("refs/foo").group(REGISTERED_USERS))
+        .update();
+    assertThat(projectOperations.project(key).getConfig())
+        .subsectionValues("access", "refs/foo")
+        .containsExactly("removeLabel-Code-Review", "-2..+1 group global:Project-Owners");
+  }
+
+  @Test
   public void removeCapability() throws Exception {
     projectOperations
         .allProjectsForUpdate()
diff --git a/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
index 0883033..ec70aef 100644
--- a/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
+++ b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
@@ -44,7 +44,7 @@
 import org.junit.Test;
 
 public final class LdapRealmTest {
-  @Inject private LdapRealm ldapRealm = null;
+  @Inject private LdapRealm ldapRealm;
   @Inject private ExternalIdFactory externalIdFactory;
 
   @Before
diff --git a/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
index 3ec6f28..6702c7e 100644
--- a/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
@@ -34,7 +34,7 @@
 import org.junit.Test;
 
 public final class OAuthRealmTest {
-  @Inject private OAuthRealm oauthRealm = null;
+  @Inject private OAuthRealm oauthRealm;
   @Inject private ExternalIdFactory externalIdFactory;
 
   @Before
diff --git a/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java b/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
index f83409b..563f05e 100644
--- a/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
+++ b/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
@@ -35,7 +35,7 @@
 import org.junit.Test;
 
 public final class OpenIdRealmTest {
-  @Inject private OpenIdRealm openidRealm = null;
+  @Inject private OpenIdRealm openidRealm;
   @Inject private ExternalIdFactory extIdFactory;
 
   @Before
diff --git a/javatests/com/google/gerrit/common/BUILD b/javatests/com/google/gerrit/common/BUILD
index c7b21a3..492d007 100644
--- a/javatests/com/google/gerrit/common/BUILD
+++ b/javatests/com/google/gerrit/common/BUILD
@@ -11,6 +11,7 @@
         "//lib:guava",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/log:log4j",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
index ec71e05..d959c2a 100644
--- a/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
+++ b/javatests/com/google/gerrit/common/data/FilenameComparatorTest.java
@@ -45,6 +45,7 @@
 
   @Test
   public void cppExtensions() {
+    assertThat(comparator.compare("abc/file.hh", "abc/file.cc")).isLessThan(0);
     assertThat(comparator.compare("abc/file.h", "abc/file.cc")).isLessThan(0);
     assertThat(comparator.compare("abc/file.c", "abc/file.hpp")).isGreaterThan(0);
     assertThat(comparator.compare("abc..xyz.file.h", "abc.xyz.file.cc")).isLessThan(0);
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
index 593b635..477f9d2 100644
--- a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -127,8 +127,16 @@
     AccountGroup.UUID uuid1 = AccountGroup.uuid("uuid1");
     assertThat(GroupReference.create(uuid1, "foo").hashCode())
         .isEqualTo(GroupReference.create(uuid1, "bar").hashCode());
+  }
 
-    // Check that the following calls don't fail with an exception.
-    GroupReference.create("bar").hashCode();
+  @Test
+  public void testEqualsWithoutUuid() {
+    assertThat(GroupReference.create("foo").equals(GroupReference.create("bar"))).isTrue();
+  }
+
+  @Test
+  public void testHashCodeWithoutUuid() {
+    assertThat(GroupReference.create("foo").hashCode())
+        .isEqualTo(GroupReference.create("bar").hashCode());
   }
 }
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
deleted file mode 100644
index 3036811..0000000
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ /dev/null
@@ -1,85 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-java_library(
-    name = "elasticsearch_test_utils",
-    testonly = True,
-    srcs = [
-        "ElasticContainer.java",
-        "ElasticTestUtils.java",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/elasticsearch",
-        "//java/com/google/gerrit/index",
-        "//lib:guava",
-        "//lib:jgit",
-        "//lib:junit",
-        "//lib/guice",
-        "//lib/httpcomponents:httpcore",
-        "//lib/jackson:jackson-annotations",
-        "//lib/log:api",
-        "//lib/testcontainers",
-        "//lib/testcontainers:docker-java-api",
-        "//lib/testcontainers:docker-java-transport",
-        "//lib/testcontainers:testcontainers-elasticsearch",
-    ],
-)
-
-ELASTICSEARCH_DEPS = [
-    ":elasticsearch_test_utils",
-    "//java/com/google/gerrit/elasticsearch",
-    "//java/com/google/gerrit/testing:gerrit-test-util",
-    "//lib/guice",
-    "//lib:jgit",
-]
-
-HTTP_TEST_DEPS = [
-    "//lib/httpcomponents:httpasyncclient",
-    "//lib/httpcomponents:httpclient",
-]
-
-QUERY_TESTS_DEP = "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests"
-
-TYPES = [
-    "account",
-    "change",
-    "group",
-    "project",
-]
-
-SUFFIX = "sTest.java"
-
-ELASTICSEARCH_TESTS_V7 = {i: "ElasticV7Query" + i.capitalize() + SUFFIX for i in TYPES}
-
-ELASTICSEARCH_TAGS = [
-    "docker",
-    "elastic",
-]
-
-[junit_tests(
-    name = "elasticsearch_query_%ss_test_V7" % name,
-    size = "large",
-    srcs = [src],
-    tags = ELASTICSEARCH_TAGS,
-    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name] + HTTP_TEST_DEPS,
-) for name, src in ELASTICSEARCH_TESTS_V7.items()]
-
-junit_tests(
-    name = "elasticsearch_tests",
-    size = "small",
-    srcs = glob(
-        ["*Test.java"],
-        exclude = ["Elastic*Query*" + SUFFIX],
-    ),
-    tags = ["elastic"],
-    deps = [
-        "//java/com/google/gerrit/elasticsearch",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:guava",
-        "//lib:jgit",
-        "//lib/guice",
-        "//lib/httpcomponents:httpcore",
-        "//lib/truth",
-    ],
-)
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
deleted file mode 100644
index 7e044c3..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.DEFAULT_USERNAME;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PASSWORD;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PREFIX;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_SERVER;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_USERNAME;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.SECTION_ELASTICSEARCH;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.inject.ProvisionException;
-import java.util.Arrays;
-import org.apache.http.HttpHost;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Test;
-
-public class ElasticConfigurationTest {
-  @Test
-  public void singleServerNoOtherConfig() throws Exception {
-    Config cfg = newConfig();
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertHosts(esCfg, "http://elastic:1234");
-    assertThat(esCfg.username).isNull();
-    assertThat(esCfg.password).isNull();
-    assertThat(esCfg.prefix).isEmpty();
-  }
-
-  @Test
-  public void serverWithoutPortSpecified() throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertHosts(esCfg, "http://elastic:9200");
-  }
-
-  @Test
-  public void prefix() throws Exception {
-    Config cfg = newConfig();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PREFIX, "myprefix");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertThat(esCfg.prefix).isEqualTo("myprefix");
-  }
-
-  @Test
-  public void withAuthentication() throws Exception {
-    Config cfg = newConfig();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_USERNAME, "myself");
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertThat(esCfg.username).isEqualTo("myself");
-    assertThat(esCfg.password).isEqualTo("s3kr3t");
-  }
-
-  @Test
-  public void withAuthenticationPasswordOnlyUsesDefaultUsername() throws Exception {
-    Config cfg = newConfig();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertThat(esCfg.username).isEqualTo(DEFAULT_USERNAME);
-    assertThat(esCfg.password).isEqualTo("s3kr3t");
-  }
-
-  @Test
-  public void multipleServers() throws Exception {
-    Config cfg = new Config();
-    cfg.setStringList(
-        SECTION_ELASTICSEARCH,
-        null,
-        KEY_SERVER,
-        ImmutableList.of("http://elastic1:1234", "http://elastic2:1234"));
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertHosts(esCfg, "http://elastic1:1234", "http://elastic2:1234");
-  }
-
-  @Test
-  public void noServers() throws Exception {
-    assertProvisionException(new Config());
-  }
-
-  @Test
-  public void singleServerInvalid() throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "foo");
-    assertProvisionException(cfg);
-  }
-
-  @Test
-  public void multipleServersIncludingInvalid() throws Exception {
-    Config cfg = new Config();
-    cfg.setStringList(
-        SECTION_ELASTICSEARCH, null, KEY_SERVER, ImmutableList.of("http://elastic1:1234", "foo"));
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertHosts(esCfg, "http://elastic1:1234");
-  }
-
-  private static Config newConfig() {
-    Config config = new Config();
-    config.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic:1234");
-    return config;
-  }
-
-  private void assertHosts(ElasticConfiguration cfg, Object... hostURIs) throws Exception {
-    assertThat(Arrays.asList(cfg.getHosts()).stream().map(HttpHost::toURI).collect(toList()))
-        .containsExactly(hostURIs);
-  }
-
-  private void assertProvisionException(Config cfg) {
-    ProvisionException thrown =
-        assertThrows(ProvisionException.class, () -> new ElasticConfiguration(cfg));
-    assertThat(thrown).hasMessageThat().contains("No valid Elasticsearch servers configured");
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
deleted file mode 100644
index c330961..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import org.apache.http.HttpHost;
-import org.junit.AssumptionViolatedException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.testcontainers.elasticsearch.ElasticsearchContainer;
-import org.testcontainers.utility.DockerImageName;
-
-/* Helper class for running ES integration tests in docker container */
-public class ElasticContainer extends ElasticsearchContainer {
-  private static final int ELASTICSEARCH_DEFAULT_PORT = 9200;
-
-  public static ElasticContainer createAndStart(ElasticVersion version) {
-    // Assumption violation is not natively supported by Testcontainers.
-    // See https://github.com/testcontainers/testcontainers-java/issues/343
-    try {
-      ElasticContainer container = new ElasticContainer(version);
-      container.start();
-      return container;
-    } catch (Throwable t) {
-      throw new AssumptionViolatedException("Unable to start container", t);
-    }
-  }
-
-  private static String getImageName(ElasticVersion version) {
-    switch (version) {
-      case V7_6:
-        return "blacktop/elasticsearch:7.6.2";
-      case V7_7:
-        return "blacktop/elasticsearch:7.7.1";
-      case V7_8:
-        return "blacktop/elasticsearch:7.8.1";
-    }
-    throw new IllegalStateException("No tests for version: " + version.name());
-  }
-
-  private ElasticContainer(ElasticVersion version) {
-    super(
-        DockerImageName.parse(getImageName(version))
-            .asCompatibleSubstituteFor("docker.elastic.co/elasticsearch/elasticsearch"));
-  }
-
-  @Override
-  protected Logger logger() {
-    return LoggerFactory.getLogger("org.testcontainers");
-  }
-
-  public HttpHost getHttpHost() {
-    return new HttpHost(getContainerIpAddress(), getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
deleted file mode 100644
index dcc6880..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.index.IndexDefinition;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.TypeLiteral;
-import java.util.Collection;
-import java.util.UUID;
-import org.eclipse.jgit.lib.Config;
-
-public final class ElasticTestUtils {
-  public static void configure(Config config, ElasticContainer container, String prefix) {
-    String hostname = container.getHttpHost().getHostName();
-    int port = container.getHttpHost().getPort();
-    config.setString("index", null, "type", "elasticsearch");
-    config.setString("elasticsearch", null, "server", "http://" + hostname + ":" + port);
-    config.setString("elasticsearch", null, "prefix", prefix);
-    config.setInt("index", null, "maxLimit", 10000);
-  }
-
-  public static void createAllIndexes(Injector injector) {
-    Collection<IndexDefinition<?, ?, ?>> indexDefs =
-        injector.getInstance(Key.get(new TypeLiteral<Collection<IndexDefinition<?, ?, ?>>>() {}));
-    for (IndexDefinition<?, ?, ?> indexDef : indexDefs) {
-      indexDef.getIndexCollection().getSearchIndex().deleteAll();
-    }
-  }
-
-  public static Config getConfig(ElasticVersion version) {
-    ElasticContainer container = ElasticContainer.createAndStart(version);
-    String indicesPrefix = UUID.randomUUID().toString();
-    Config cfg = new Config();
-    configure(cfg, container, indicesPrefix);
-    return cfg;
-  }
-
-  private ElasticTestUtils() {
-    // hide default constructor
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
deleted file mode 100644
index 4826490..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticV7QueryAccountsTest extends AbstractQueryAccountsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticContainer container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (container == null) {
-      // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
-    }
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
deleted file mode 100644
index d9a4d2e..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static java.util.concurrent.TimeUnit.MINUTES;
-
-import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.GerritTestName;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.apache.http.client.methods.HttpPost;
-import org.apache.http.client.protocol.HttpClientContext;
-import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
-import org.apache.http.impl.nio.client.HttpAsyncClients;
-import org.eclipse.jgit.lib.Config;
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-import org.junit.Rule;
-
-public class ElasticV7QueryChangesTest extends AbstractQueryChangesTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticContainer container;
-  private static CloseableHttpAsyncClient client;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (container == null) {
-      // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
-      client = HttpAsyncClients.createDefault();
-      client.start();
-    }
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Rule public final GerritTestName testName = new GerritTestName();
-
-  @After
-  public void closeIndex() throws Exception {
-    // Close the index after each test to prevent exceeding Elasticsearch's
-    // shard limit (see Issue 10120).
-    client
-        .execute(
-            new HttpPost(
-                String.format(
-                    "http://%s:%d/%s*/_close",
-                    container.getHttpHost().getHostName(),
-                    container.getHttpHost().getPort(),
-                    testName.getSanitizedMethodName())),
-            HttpClientContext.create(),
-            null)
-        .get(5, MINUTES);
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
deleted file mode 100644
index 0fc96f8..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticV7QueryGroupsTest extends AbstractQueryGroupsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticContainer container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (container == null) {
-      // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
-    }
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
deleted file mode 100644
index 1e56af9..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticV7QueryProjectsTest extends AbstractQueryProjectsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticContainer container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (container == null) {
-      // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_8);
-    }
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
deleted file mode 100644
index 2ce3a2c..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import org.junit.Test;
-
-public class ElasticVersionTest {
-  @Test
-  public void supportedVersion() throws Exception {
-    assertThat(ElasticVersion.forVersion("7.6.0")).isEqualTo(ElasticVersion.V7_6);
-    assertThat(ElasticVersion.forVersion("7.6.1")).isEqualTo(ElasticVersion.V7_6);
-
-    assertThat(ElasticVersion.forVersion("7.7.0")).isEqualTo(ElasticVersion.V7_7);
-    assertThat(ElasticVersion.forVersion("7.7.1")).isEqualTo(ElasticVersion.V7_7);
-
-    assertThat(ElasticVersion.forVersion("7.8.0")).isEqualTo(ElasticVersion.V7_8);
-    assertThat(ElasticVersion.forVersion("7.8.1")).isEqualTo(ElasticVersion.V7_8);
-  }
-
-  @Test
-  public void unsupportedVersion() throws Exception {
-    ElasticVersion.UnsupportedVersion thrown =
-        assertThrows(
-            ElasticVersion.UnsupportedVersion.class, () -> ElasticVersion.forVersion("4.0.0"));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "Unsupported version: [4.0.0]. Supported versions: "
-                + ElasticVersion.supportedVersions());
-  }
-}
diff --git a/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java b/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java
new file mode 100644
index 0000000..a0fff22
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/ChangeSizeBucketTest.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class ChangeSizeBucketTest {
+
+  @Test
+  public void getChangeSizeBucket() {
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(0)).isEqualTo("NoOp");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(1)).isEqualTo("XS");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(10)).isEqualTo("S");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(50)).isEqualTo("M");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(250)).isEqualTo("L");
+    assertThat(ChangeSizeBucket.getChangeSizeBucket(1000)).isEqualTo("XL");
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/LabelFunctionTest.java b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
index 0132697..80d97db 100644
--- a/javatests/com/google/gerrit/entities/LabelFunctionTest.java
+++ b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
@@ -19,7 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import java.time.Instant;
 import java.util.ArrayList;
-import java.util.Date;
 import java.util.List;
 import org.junit.Test;
 
@@ -94,7 +93,7 @@
     return PatchSetApproval.builder()
         .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
         .value(value)
-        .granted(Date.from(Instant.now()))
+        .granted(Instant.now())
         .build();
   }
 
diff --git a/javatests/com/google/gerrit/entities/LabelTypeTest.java b/javatests/com/google/gerrit/entities/LabelTypeTest.java
index f31f2c9..fcbe386 100644
--- a/javatests/com/google/gerrit/entities/LabelTypeTest.java
+++ b/javatests/com/google/gerrit/entities/LabelTypeTest.java
@@ -30,18 +30,6 @@
   }
 
   @Test
-  public void sortCopyValues() {
-    LabelValue v0 = LabelValue.create((short) 0, "Zero");
-    LabelValue v1 = LabelValue.create((short) 1, "One");
-    LabelValue v2 = LabelValue.create((short) 2, "Two");
-    LabelType types =
-        LabelType.builder("Label", ImmutableList.of(v2, v0, v1))
-            .setCopyValues(ImmutableList.of((short) 2, (short) 0, (short) 1))
-            .build();
-    assertThat(types.getCopyValues()).containsExactly((short) 0, (short) 1, (short) 2).inOrder();
-  }
-
-  @Test
   public void insertMissingLabelValues() {
     LabelValue v0 = LabelValue.create((short) 0, "Zero");
     LabelValue v2 = LabelValue.create((short) 2, "Two");
diff --git a/javatests/com/google/gerrit/entities/PermissionTest.java b/javatests/com/google/gerrit/entities/PermissionTest.java
index 3175671..d25d833 100644
--- a/javatests/com/google/gerrit/entities/PermissionTest.java
+++ b/javatests/com/google/gerrit/entities/PermissionTest.java
@@ -36,6 +36,7 @@
 
     assertThat(Permission.isPermission(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
     assertThat(Permission.isPermission(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isPermission(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isTrue();
     assertThat(Permission.isPermission(LabelId.CODE_REVIEW)).isFalse();
   }
 
@@ -56,6 +57,7 @@
 
     assertThat(Permission.isLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
     assertThat(Permission.isLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isLabel(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isFalse();
     assertThat(Permission.isLabel(LabelId.CODE_REVIEW)).isFalse();
   }
 
@@ -66,10 +68,22 @@
 
     assertThat(Permission.isLabelAs(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
     assertThat(Permission.isLabelAs(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isLabelAs(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isFalse();
     assertThat(Permission.isLabelAs(LabelId.CODE_REVIEW)).isFalse();
   }
 
   @Test
+  public void isRemoveLabel() {
+    assertThat(Permission.isRemoveLabel(Permission.ABANDON)).isFalse();
+    assertThat(Permission.isRemoveLabel("no-permission")).isFalse();
+
+    assertThat(Permission.isRemoveLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isRemoveLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+    assertThat(Permission.isRemoveLabel(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isTrue();
+    assertThat(Permission.isRemoveLabel(LabelId.CODE_REVIEW)).isFalse();
+  }
+
+  @Test
   public void forLabel() {
     assertThat(Permission.forLabel(LabelId.CODE_REVIEW))
         .isEqualTo(Permission.LABEL + LabelId.CODE_REVIEW);
@@ -82,11 +96,19 @@
   }
 
   @Test
+  public void forRemoveLabel() {
+    assertThat(Permission.forRemoveLabel(LabelId.CODE_REVIEW))
+        .isEqualTo(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW);
+  }
+
+  @Test
   public void extractLabel() {
     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(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+        .isEqualTo(LabelId.CODE_REVIEW);
     assertThat(Permission.extractLabel(LabelId.CODE_REVIEW)).isNull();
     assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
   }
@@ -103,6 +125,10 @@
             Permission.canBeOnAllProjects(
                 AccessSection.ALL, Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(
+                AccessSection.ALL, Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+        .isTrue();
 
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
     assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
@@ -113,6 +139,10 @@
             Permission.canBeOnAllProjects(
                 "refs/heads/*", Permission.LABEL_AS + LabelId.CODE_REVIEW))
         .isTrue();
+    assertThat(
+            Permission.canBeOnAllProjects(
+                "refs/heads/*", Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+        .isTrue();
   }
 
   @Test
@@ -126,6 +156,8 @@
         .isEqualTo(LabelId.CODE_REVIEW);
     assertThat(Permission.create(Permission.LABEL_AS + LabelId.CODE_REVIEW).getLabel())
         .isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(Permission.create(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW).getLabel())
+        .isEqualTo(LabelId.CODE_REVIEW);
     assertThat(Permission.create(LabelId.CODE_REVIEW).getLabel()).isNull();
     assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
   }
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index ec6c372..22daf5b 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class ChangeMessageProtoConverterTest {
@@ -40,7 +40,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13),
             "This is a change message.",
             Account.id(10003),
@@ -73,7 +73,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13));
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
@@ -140,7 +140,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13),
             "This is a change message.",
             Account.id(10003),
@@ -157,7 +157,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13),
             String.format(
                 "This is a change message by %s and includes %s ",
@@ -178,7 +178,7 @@
         ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
-            new Timestamp(9876543),
+            Instant.ofEpochMilli(9876543),
             PatchSet.id(Change.id(34), 13));
 
     ChangeMessage convertedChangeMessage =
@@ -205,7 +205,7 @@
             ImmutableMap.<String, Type>builder()
                 .put("key", ChangeMessage.Key.class)
                 .put("author", Account.Id.class)
-                .put("writtenOn", Timestamp.class)
+                .put("writtenOn", Instant.class)
                 .put("message", String.class)
                 .put("patchset", PatchSet.Id.class)
                 .put("tag", String.class)
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index 8c5e449..bd4b2b1 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class ChangeProtoConverterTest {
@@ -41,8 +41,8 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch 74"),
-            new Timestamp(987654L));
-    change.setLastUpdatedOn(new Timestamp(1234567L));
+            Instant.ofEpochMilli(987654L));
+    change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
         PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
@@ -90,7 +90,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -125,7 +125,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
     // O as ID actually means that no current patch set is present.
     change.setCurrentPatchSet(PatchSet.id(Change.id(14), 0), null, null);
 
@@ -162,7 +162,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
     change.setCurrentPatchSet(PatchSet.id(Change.id(14), 23), "subject ABC", null);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
@@ -198,8 +198,8 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
-    change.setLastUpdatedOn(new Timestamp(1234567L));
+            Instant.ofEpochMilli(987654L));
+    change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
         PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
@@ -223,7 +223,7 @@
             Change.id(14),
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
-            new Timestamp(987654L));
+            Instant.ofEpochMilli(987654L));
 
     Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
     assertEqualChange(convertedChange, change);
@@ -242,8 +242,8 @@
     assertThat(change.getKey()).isNull();
     assertThat(change.getOwner()).isNull();
     assertThat(change.getDest()).isNull();
-    assertThat(change.getCreatedOn()).isEqualTo(new Timestamp(0));
-    assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(0));
+    assertThat(change.getCreatedOn()).isEqualTo(Instant.EPOCH);
+    assertThat(change.getLastUpdatedOn()).isEqualTo(Instant.EPOCH);
     assertThat(change.getSubject()).isNull();
     assertThat(change.currentPatchSetId()).isNull();
     // Default values for unset protobuf fields which can't be unset in the entity object.
@@ -268,7 +268,7 @@
             .build();
     Change change = changeProtoConverter.fromProto(proto);
 
-    assertThat(change.getLastUpdatedOn()).isEqualTo(new Timestamp(987654L));
+    assertThat(change.getLastUpdatedOn()).isEqualTo(Instant.ofEpochMilli(987654L));
   }
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
@@ -279,8 +279,8 @@
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
                 .put("changeKey", Change.Key.class)
-                .put("createdOn", Timestamp.class)
-                .put("lastUpdatedOn", Timestamp.class)
+                .put("createdOn", Instant.class)
+                .put("lastUpdatedOn", Instant.class)
                 .put("owner", Account.Id.class)
                 .put("dest", BranchNameKey.class)
                 .put("status", char.class)
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
index d332f8a..28f9cdb 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
@@ -28,8 +28,7 @@
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
-import java.util.Date;
+import java.time.Instant;
 import java.util.Optional;
 import org.junit.Test;
 
@@ -44,8 +43,9 @@
             .key(
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .tag("tag-21")
             .realAccountId(Account.id(612))
             .postSubmit(true)
@@ -64,6 +64,7 @@
                             .setId(14))
                     .setAccountId(Entities.Account_Id.newBuilder().setId(100013))
                     .setLabelId(Entities.LabelId.newBuilder().setId("label-8")))
+            .setUuid("577fb248e474018276351785930358ec0450e9f7")
             .setValue(456)
             .setGranted(987654L)
             .setTag("tag-21")
@@ -82,7 +83,7 @@
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .build();
 
     Entities.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
@@ -113,8 +114,9 @@
             .key(
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .tag("tag-21")
             .realAccountId(Account.id(612))
             .postSubmit(true)
@@ -134,7 +136,7 @@
                 PatchSetApproval.key(
                     PatchSet.id(Change.id(42), 14), Account.id(100013), LabelId.create("label-8")))
             .value(456)
-            .granted(new Date(987654L))
+            .granted(Instant.ofEpochMilli(987654L))
             .build();
 
     PatchSetApproval convertedPatchSetApproval =
@@ -164,7 +166,7 @@
     assertThat(patchSetApproval.labelId()).isEqualTo(LabelId.create("label-8"));
     // Default values for unset protobuf fields which can't be unset in the entity object.
     assertThat(patchSetApproval.value()).isEqualTo(0);
-    assertThat(patchSetApproval.granted()).isEqualTo(new Timestamp(0));
+    assertThat(patchSetApproval.granted()).isEqualTo(Instant.EPOCH);
     assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
     assertThat(patchSetApproval.copied()).isEqualTo(false);
   }
@@ -176,8 +178,9 @@
         .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
+                .put("uuid", new TypeLiteral<Optional<PatchSetApproval.UUID>>() {}.getType())
                 .put("value", short.class)
-                .put("granted", Timestamp.class)
+                .put("granted", Instant.class)
                 .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index efeb24f..3a534e9 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
@@ -42,7 +42,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
             .description("This is a patch set description.")
@@ -74,7 +74,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .build();
 
     Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
@@ -100,7 +100,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
             .description("This is a patch set description.")
@@ -118,7 +118,7 @@
             .id(PatchSet.id(Change.id(103), 73))
             .commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
             .uploader(Account.id(452))
-            .createdOn(new Timestamp(930349320L))
+            .createdOn(Instant.ofEpochMilli(930349320L))
             .build();
 
     PatchSet convertedPatchSet =
@@ -143,7 +143,7 @@
                 .id(PatchSet.id(Change.id(103), 73))
                 .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
                 .uploader(Account.id(0))
-                .createdOn(new Timestamp(0))
+                .createdOn(Instant.EPOCH)
                 .build());
   }
 
@@ -156,7 +156,7 @@
                 .put("id", PatchSet.Id.class)
                 .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
-                .put("createdOn", Timestamp.class)
+                .put("createdOn", Instant.class)
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("description", new TypeLiteral<Optional<String>>() {}.getType())
diff --git a/javatests/com/google/gerrit/extensions/BUILD b/javatests/com/google/gerrit/extensions/BUILD
index 2202a11..1bb39c8 100644
--- a/javatests/com/google/gerrit/extensions/BUILD
+++ b/javatests/com/google/gerrit/extensions/BUILD
@@ -5,6 +5,7 @@
     size = "small",
     srcs = glob(["**/*.java"]),
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/extensions/common/testing:common-test-util",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
index 024e35e..f45d33b 100644
--- a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -18,6 +18,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ReviewerState;
 import java.lang.reflect.Field;
 import java.lang.reflect.ParameterizedType;
@@ -47,6 +48,7 @@
     assertThat(diff.added().messages).isNull();
     assertThat(diff.added().reviewers).isNull();
     assertThat(diff.added().hashtags).isNull();
+    assertThat(diff.added().removableLabels).isNull();
     assertThat(diff.removed()._number).isNull();
     assertThat(diff.removed().branch).isNull();
     assertThat(diff.removed().project).isNull();
@@ -55,6 +57,7 @@
     assertThat(diff.removed().messages).isNull();
     assertThat(diff.removed().reviewers).isNull();
     assertThat(diff.removed().hashtags).isNull();
+    assertThat(diff.removed().removableLabels).isNull();
   }
 
   @Test
@@ -314,6 +317,295 @@
   }
 
   @Test
+  public void getDiff_removableLabelsEmpty_returnsNullRemovableLabels() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    oldChangeInfo.removableLabels = ImmutableMap.of();
+    newChangeInfo.removableLabels = ImmutableMap.of();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsNullAndEmpty_returnsEmptyRemovableLabels() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    newChangeInfo.removableLabels = ImmutableMap.of();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isEmpty();
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsEmptyAndNull_returnsEmptyRemovableLabels() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    oldChangeInfo.removableLabels = ImmutableMap.of();
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels).isEmpty();
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelAdded() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "Cow";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "Pig";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "Cat";
+    AccountInfo acc4 = new AccountInfo();
+    acc4.name = "Dog";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)),
+            "Verified",
+            ImmutableMap.of("-1", ImmutableList.of(acc4)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc4))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelRemoved() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "Cow";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "Pig";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "Cat";
+    AccountInfo acc4 = new AccountInfo();
+    acc4.name = "Dog";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)),
+            "Verified",
+            ImmutableMap.of("-1", ImmutableList.of(acc4)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc4))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsVoteAdded() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "acc3";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc2, acc3))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsVoteRemoved() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+    AccountInfo acc3 = new AccountInfo();
+    acc3.name = "acc3";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of(
+            "Code-Review",
+            ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels).isNull();
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc2, acc3))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsAccountAdded() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1, acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsAccountRemoved() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1, acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels).isNull();
+  }
+
+  @Test
+  public void getDiff_removableLabelsAccountChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsScoreChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc1)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc1))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Verified", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
+  public void getDiff_removableLabelsLabelScoreAndAccountChanged() {
+    ChangeInfo oldChangeInfo = new ChangeInfo();
+    ChangeInfo newChangeInfo = new ChangeInfo();
+    AccountInfo acc1 = new AccountInfo();
+    acc1.name = "acc1";
+    AccountInfo acc2 = new AccountInfo();
+    acc2.name = "acc2";
+
+    oldChangeInfo.removableLabels =
+        ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+    newChangeInfo.removableLabels =
+        ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc2)));
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.added().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc2))));
+    assertThat(diff.removed().removableLabels)
+        .containsExactlyEntriesIn(
+            ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+  }
+
+  @Test
   public void getDiff_assertCanConstructAllChangeInfoReferences() throws Exception {
     buildObjectWithFullFields(ChangeInfo.class);
   }
@@ -344,6 +636,7 @@
     assertThat(diff.removed().reviewers).isNull();
   }
 
+  @Nullable
   private static Object buildObjectWithFullFields(Class<?> c) throws Exception {
     if (c == null) {
       return null;
@@ -365,6 +658,7 @@
     return toPopulate;
   }
 
+  @Nullable
   private static Class<?> getParameterizedType(Field field) {
     if (!Collection.class.isAssignableFrom(field.getType())) {
       return null;
diff --git a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
index 1d021f7..a1f6cef 100644
--- a/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
+++ b/javatests/com/google/gerrit/git/RefUpdateUtilTest.java
@@ -33,44 +33,38 @@
 
 @RunWith(JUnit4.class)
 public class RefUpdateUtilTest {
-  private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
-  private static final Consumer<ReceiveCommand> LOCK_FAILURE =
-      c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
-  private static final Consumer<ReceiveCommand> REJECTED =
-      c -> c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
-  private static final Consumer<ReceiveCommand> ABORTED =
-      c -> {
-        c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
-        ReceiveCommand.abort(ImmutableList.of(c));
-        checkState(
-            c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
-                && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
-                && c.getResult() != ReceiveCommand.Result.OK,
-            "unexpected state after abort: %s",
-            c);
-      };
-
   @Test
   public void checkBatchRefUpdateResults() throws Exception {
     checkResults();
-    checkResults(OK);
-    checkResults(OK, OK);
+    checkResults(RefUpdateUtilTest::ok);
+    checkResults(RefUpdateUtilTest::ok, RefUpdateUtilTest::ok);
 
-    assertIoException(REJECTED);
-    assertIoException(OK, REJECTED);
-    assertIoException(LOCK_FAILURE, REJECTED);
-    assertIoException(LOCK_FAILURE, OK);
-    assertIoException(LOCK_FAILURE, REJECTED, OK);
-    assertIoException(LOCK_FAILURE, LOCK_FAILURE, REJECTED);
-    assertIoException(LOCK_FAILURE, ABORTED, REJECTED);
-    assertIoException(LOCK_FAILURE, ABORTED, OK);
+    assertIoException(RefUpdateUtilTest::rejected);
+    assertIoException(RefUpdateUtilTest::ok, RefUpdateUtilTest::rejected);
+    assertIoException(RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::rejected);
+    assertIoException(RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::ok);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::rejected, RefUpdateUtilTest::ok);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::rejected);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::aborted, RefUpdateUtilTest::rejected);
+    assertIoException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::aborted, RefUpdateUtilTest::ok);
 
-    assertLockFailureException(LOCK_FAILURE);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED);
-    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED, ABORTED);
-    assertLockFailureException(ABORTED);
-    assertLockFailureException(ABORTED, ABORTED);
+    assertLockFailureException(RefUpdateUtilTest::lockFailure);
+    assertLockFailureException(RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::lockFailure);
+    assertLockFailureException(
+        RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::lockFailure, RefUpdateUtilTest::aborted);
+    assertLockFailureException(
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::lockFailure,
+        RefUpdateUtilTest::aborted,
+        RefUpdateUtilTest::aborted);
+    assertLockFailureException(RefUpdateUtilTest::aborted);
+    assertLockFailureException(RefUpdateUtilTest::aborted, RefUpdateUtilTest::aborted);
   }
 
   @SafeVarargs
@@ -110,4 +104,27 @@
       return bru;
     }
   }
+
+  private static void ok(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.OK);
+  }
+
+  private static void lockFailure(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
+  }
+
+  private static void rejected(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
+  }
+
+  private static void aborted(ReceiveCommand c) {
+    c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
+    ReceiveCommand.abort(ImmutableList.of(c));
+    checkState(
+        c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
+            && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
+            && c.getResult() != ReceiveCommand.Result.OK,
+        "unexpected state after abort: %s",
+        c);
+  }
 }
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index 7703fb0..8bafafe 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -38,11 +38,12 @@
 import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.gpg.testing.TestKey;
-import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.Date;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -212,9 +213,11 @@
     String problem = "Key is revoked (key material has been compromised): test6 compromised";
     assertProblems(k, problem);
 
-    SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
-    PublicKeyChecker checker =
-        new PublicKeyChecker().setStore(store).setEffectiveTime(df.parse("2010-01-01 12:00:00"));
+    Instant instant =
+        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+            .withZone(ZoneId.systemDefault())
+            .parse("2010-01-01 12:00:00", Instant::from);
+    PublicKeyChecker checker = new PublicKeyChecker().setStore(store).setEffectiveTime(instant);
     assertProblems(checker, k, problem);
   }
 
@@ -360,8 +363,8 @@
         + " is valid, but key is not trusted";
   }
 
-  private static Date parseDate(String str) throws Exception {
-    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").parse(str);
+  private static Instant parseDate(String str) throws Exception {
+    return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss Z").parse(str, Instant::from);
   }
 
   private static List<String> list(String first, String[] rest) {
diff --git a/javatests/com/google/gerrit/httpd/BUILD b/javatests/com/google/gerrit/httpd/BUILD
index 121cbc4..a69d60f 100644
--- a/javatests/com/google/gerrit/httpd/BUILD
+++ b/javatests/com/google/gerrit/httpd/BUILD
@@ -21,6 +21,7 @@
         "//lib/bouncycastle:bcprov",
         "//lib/guice",
         "//lib/guice:guice-servlet",
+        "//lib/jsoup",
         "//lib/mockito",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
diff --git a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
index da6092b..04f9827 100644
--- a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
+++ b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
@@ -43,7 +43,9 @@
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.time.Instant;
 import java.util.Base64;
+import java.util.Collection;
 import java.util.Optional;
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
@@ -71,10 +73,6 @@
 
   @Mock private AccountCache accountCache;
 
-  @Mock private AccountState accountState;
-
-  @Mock private Account account;
-
   @Mock private AccountManager accountManager;
 
   @Mock private AuthConfig authConfig;
@@ -102,16 +100,12 @@
     res = new FakeHttpServletResponse();
 
     extIdKeyFactory = new ExternalIdKeyFactory(new ExternalIdKeyFactory.ConfigImpl(authConfig));
-    extIdFactory = new ExternalIdFactory(extIdKeyFactory);
+    extIdFactory = new ExternalIdFactory(extIdKeyFactory, authConfig);
     authRequestFactory = new AuthRequest.Factory(extIdKeyFactory);
-    pwdVerifier = new PasswordVerifier(extIdKeyFactory);
+    pwdVerifier = new PasswordVerifier(extIdKeyFactory, authConfig);
 
     authSuccessful =
         new AuthResult(AUTH_ACCOUNT_ID, extIdKeyFactory.create("username", AUTH_USER), false);
-    doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
-    doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
-    doReturn(account).when(accountState).account();
-    doReturn(true).when(account).isActive();
     doReturn(authSuccessful).when(accountManager).authenticate(any());
 
     doReturn(new WebSessionManager.Key(AUTH_COOKIE_VALUE)).when(webSessionManager).createKey(any());
@@ -124,6 +118,7 @@
 
   @Test
   public void shouldAllowAnonymousRequest() throws Exception {
+    initAccount();
     initMockedWebSession();
     res.setStatus(HttpServletResponse.SC_OK);
 
@@ -144,6 +139,7 @@
 
   @Test
   public void shouldRequestAuthenticationForBasicAuthRequest() throws Exception {
+    initAccount();
     initMockedWebSession();
     req.addHeader("Authorization", "Basic " + AUTH_USER_B64);
     res.setStatus(HttpServletResponse.SC_OK);
@@ -166,11 +162,11 @@
 
   @Test
   public void shouldAuthenticateSucessfullyAgainstRealmAndReturnCookie() throws Exception {
+    initAccount();
     initWebSessionWithoutCookie();
     requestBasicAuth(req);
     res.setStatus(HttpServletResponse.SC_OK);
 
-    doReturn(true).when(account).isActive();
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
 
     ProjectBasicAuthFilter basicAuthFilter =
@@ -193,9 +189,10 @@
 
   @Test
   public void shouldValidateUserPasswordAndNotReturnCookie() throws Exception {
+    ExternalId extId = createUsernamePasswordExternalId();
+    initAccount(ImmutableSet.of(extId));
     initWebSessionWithoutCookie();
     requestBasicAuth(req);
-    initMockedUsernamePasswordExternalId();
     doReturn(GitBasicAuthPolicy.HTTP).when(authConfig).getGitBasicAuthPolicy();
     res.setStatus(HttpServletResponse.SC_OK);
 
@@ -219,6 +216,7 @@
 
   @Test
   public void shouldNotReauthenticateForGitPostRequest() throws Exception {
+    initAccount();
     req.setPathInfo("/a/project.git/git-upload-pack");
     req.setMethod("POST");
     req.addHeader("Content-Type", "application/x-git-upload-pack-request");
@@ -231,6 +229,7 @@
 
   @Test
   public void shouldReauthenticateForRegularRequestEvenIfAlreadySignedIn() throws Exception {
+    initAccount();
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
     doFilterForRequestWhenAlreadySignedIn();
 
@@ -241,6 +240,7 @@
 
   @Test
   public void shouldReauthenticateEvenIfHasExistingCookie() throws Exception {
+    initAccount();
     initWebSessionWithCookie("GerritAccount=" + AUTH_COOKIE_VALUE);
     res.setStatus(HttpServletResponse.SC_OK);
     requestBasicAuth(req);
@@ -264,10 +264,10 @@
 
   @Test
   public void shouldFailedAuthenticationAgainstRealm() throws Exception {
+    initAccount();
     initMockedWebSession();
     requestBasicAuth(req);
 
-    doReturn(true).when(account).isActive();
     doThrow(new AccountException("Authentication error")).when(accountManager).authenticate(any());
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
 
@@ -288,10 +288,20 @@
     assertThat(res.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
   }
 
+  private void initAccount() throws Exception {
+    initAccount(ImmutableSet.of());
+  }
+
+  private void initAccount(Collection<ExternalId> extIds) throws Exception {
+    Account account = Account.builder(Account.id(1000000), Instant.now()).build();
+    AccountState accountState = AccountState.forAccount(account, extIds);
+    doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
+    doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
+  }
+
   private void doFilterForRequestWhenAlreadySignedIn()
       throws IOException, ServletException, AccountException {
     initMockedWebSession();
-    doReturn(true).when(account).isActive();
     doReturn(true).when(webSession).isSignedIn();
     doReturn(authSuccessful).when(accountManager).authenticate(any());
     requestBasicAuth(req);
@@ -326,14 +336,12 @@
     doReturn(webSession).when(webSessionItem).get();
   }
 
-  private void initMockedUsernamePasswordExternalId() {
-    ExternalId extId =
-        extIdFactory.createWithPassword(
-            extIdKeyFactory.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
-            AUTH_ACCOUNT_ID,
-            null,
-            AUTH_PASSWORD);
-    doReturn(ImmutableSet.builder().add(extId).build()).when(accountState).externalIds();
+  private ExternalId createUsernamePasswordExternalId() {
+    return extIdFactory.createWithPassword(
+        extIdKeyFactory.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
+        AUTH_ACCOUNT_ID,
+        null,
+        AUTH_PASSWORD);
   }
 
   private void requestBasicAuth(FakeHttpServletRequest fakeReq) {
diff --git a/javatests/com/google/gerrit/httpd/raw/DocServletTest.java b/javatests/com/google/gerrit/httpd/raw/DocServletTest.java
new file mode 100644
index 0000000..2f09aa2
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/DocServletTest.java
@@ -0,0 +1,167 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION;
+import static org.mockito.Mockito.when;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(JUnit4.class)
+public class DocServletTest {
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+  @Mock private ExperimentFeatures experimentFeatures;
+  private FileSystem fs = Jimfs.newFileSystem(Configuration.unix());
+  private DocServlet docServlet;
+
+  @Before
+  public void setUp() throws Exception {
+    when(experimentFeatures.isFeatureEnabled(GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION))
+        .thenReturn(true);
+
+    docServlet =
+        new DocServlet(
+            CacheBuilder.newBuilder().maximumSize(1).build(), false, experimentFeatures) {
+          private static final long serialVersionUID = 1L;
+
+          @Override
+          protected Path getResourcePath(String pathInfo) throws IOException {
+            return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
+          }
+        };
+
+    Files.createDirectories(fs.getPath(DOC_PATH).getParent());
+    Files.write(fs.getPath(DOC_PATH), HTML_RESPONSE.getBytes(StandardCharsets.UTF_8));
+    Files.write(
+        fs.getPath(DOC_PATH_NO_SCRIPT), HTML_RESPONSE_NO_SCRIPT.getBytes(StandardCharsets.UTF_8));
+    Files.write(fs.getPath(NON_HTML_FILE_PATH), NON_HTML_FILE);
+  }
+
+  @Test
+  public void noNonce_unchangedResponse() throws Exception {
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    assertThat(response.getActualBody()).isEqualTo(HTML_RESPONSE.getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Test
+  public void experimentDisabled_unchangedResponse() throws Exception {
+    when(experimentFeatures.isFeatureEnabled(GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION))
+        .thenReturn(false);
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+    request.setAttribute("nonce", NONCE);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    assertThat(response.getActualBody()).isEqualTo(HTML_RESPONSE.getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Test
+  public void nonHtmlResponse_unchangedResponse() throws Exception {
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(NON_HTML_FILE_PATH);
+    request.setAttribute("nonce", NONCE);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    assertThat(response.getActualBody()).isEqualTo(NON_HTML_FILE);
+  }
+
+  @Test
+  public void responseWithoutScripts_equivalentResponse() throws Exception {
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH_NO_SCRIPT);
+    request.setAttribute("nonce", NONCE);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    // Normally file is not guaranteed to not get reformatted, but in the simple example like we use
+    // here we can check byte-wise equality.
+    assertThat(response.getActualBody())
+        .isEqualTo(HTML_RESPONSE_NO_SCRIPT.getBytes(StandardCharsets.UTF_8));
+  }
+
+  @Test
+  public void htmlResponse_nonceAttached() throws Exception {
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+    request.setAttribute("nonce", NONCE);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    Document doc = Jsoup.parse(response.getActualBodyString());
+    for (Element el : doc.getElementsByTag("script")) {
+      assertThat(el.attributes().get("nonce")).isEqualTo(NONCE);
+    }
+  }
+
+  @Test
+  public void htmlResponse_noCacheHeaderSet() throws Exception {
+    FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+    request.setAttribute("nonce", NONCE);
+    FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+    docServlet.doGet(request, response);
+
+    assertThat(response.getHeader("Cache-Control"))
+        .isEqualTo("no-cache, no-store, max-age=0, must-revalidate");
+  }
+
+  private static final String NONCE = "1234abcde";
+  private static final String HTML_RESPONSE =
+      "<!DOCTYPE html>"
+          + "<html lang=\"en\">"
+          + "<head>"
+          + "  <title>Gerrit Code Review - Searching Changes</title>"
+          + "  <link rel=\"stylesheet\" href=\"./asciidoctor.css\">"
+          + "  <script src=\"./prettify.min.js\"></script>"
+          + "  <script>document.addEventListener('DOMContentLoaded', prettyPrint)</script>"
+          + "</head><body></body></html>";
+  private static final String DOC_PATH = "/Documentation/page1.html";
+  private static final String HTML_RESPONSE_NO_SCRIPT =
+      "<html><head></head><body><div>Hello</div></body></html>";
+  private static final String DOC_PATH_NO_SCRIPT = "/Documentation/page_no_script.html";
+  private static final byte[] NON_HTML_FILE = "<script></script>".getBytes(StandardCharsets.UTF_8);
+  private static final String NON_HTML_FILE_PATH = "/foo";
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index 6691587..cc1ee00 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -116,7 +116,7 @@
 
     assertThat(dynamicTemplateData(gerritApi, "/c/project/+/123"))
         .containsAtLeast(
-            "defaultChangeDetailHex", "916314",
+            "defaultChangeDetailHex", "1916314",
             "changeRequestsPath", "changes/project~123");
   }
 
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index 634231f..f65e823 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -105,7 +105,7 @@
     assertThat(output)
         .contains(
             "window.ENABLED_EXPERIMENTS = JSON.parse('\\x5b\\x22"
-                + String.join("\\x22,", expectedEnabled)
+                + String.join("\\x22,\\x22", expectedEnabled)
                 + "\\x22\\x5d');</script>");
   }
 }
diff --git a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
index dd594d6..36641fe 100644
--- a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -72,16 +72,6 @@
       this.fs = fs;
     }
 
-    private Servlet(
-        FileSystem fs,
-        Cache<Path, Resource> cache,
-        boolean refresh,
-        boolean cacheOnClient,
-        int cacheFileSizeLimitBytes) {
-      super(cache, refresh, cacheOnClient, cacheFileSizeLimitBytes);
-      this.fs = fs;
-    }
-
     @Override
     protected Path getResourcePath(String pathInfo) {
       return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
diff --git a/javatests/com/google/gerrit/index/BUILD b/javatests/com/google/gerrit/index/BUILD
index 861e768..7cb86e7 100644
--- a/javatests/com/google/gerrit/index/BUILD
+++ b/javatests/com/google/gerrit/index/BUILD
@@ -9,6 +9,7 @@
         "//antlr3:query_parser",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/index/query/testing",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeTest.java b/javatests/com/google/gerrit/index/IndexUpgradeTest.java
new file mode 100644
index 0000000..724964b
--- /dev/null
+++ b/javatests/com/google/gerrit/index/IndexUpgradeTest.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import java.util.Collection;
+import java.util.Map.Entry;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+/** Validates index upgrades; see {@link IndexUpgradeValidator} for details. */
+@RunWith(Parameterized.class)
+public class IndexUpgradeTest {
+  /** This is the first version to which {@link IndexUpgradeValidator} is applied. */
+  private static final ImmutableMap<Class<? extends SchemaDefinitions<?>>, Integer>
+      ENFORCE_UPDATE_RESTRICTIONS_FROM_VERSION =
+          ImmutableMap.of(
+              AccountSchemaDefinitions.class, 12,
+              ChangeSchemaDefinitions.class, 78,
+              GroupSchemaDefinitions.class, 8,
+              ProjectSchemaDefinitions.class, 4);
+
+  @Parameter public SchemaDefinitions<?> schemaDefinitions;
+
+  @Parameters(name = "schema: {0}")
+  public static Collection<SchemaDefinitions<?>> indexes() {
+    return ImmutableList.of(
+        AccountSchemaDefinitions.INSTANCE,
+        ChangeSchemaDefinitions.INSTANCE,
+        GroupSchemaDefinitions.INSTANCE,
+        ProjectSchemaDefinitions.INSTANCE);
+  }
+
+  @Test
+  public void upgradesValid() {
+    Schema<?> previousSchema = null;
+    for (Entry<Integer, ? extends Schema<?>> entry : schemaDefinitions.getSchemas().entrySet()) {
+      Schema<?> schema = entry.getValue();
+      if (previousSchema != null
+          && schema.getVersion()
+              >= ENFORCE_UPDATE_RESTRICTIONS_FROM_VERSION.get(schemaDefinitions.getClass())) {
+        IndexUpgradeValidator.assertValid(previousSchema, schema);
+      }
+      previousSchema = schema;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeValidator.java b/javatests/com/google/gerrit/index/IndexUpgradeValidator.java
new file mode 100644
index 0000000..5bcc6ff
--- /dev/null
+++ b/javatests/com/google/gerrit/index/IndexUpgradeValidator.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Sets.SetView;
+import org.junit.Ignore;
+
+/**
+ * Validates index upgrades to enforce the following constraints: Upgrades may only add or remove
+ * fields. They may not do both, and may not change field types.
+ */
+@Ignore
+public class IndexUpgradeValidator {
+
+  public static void assertValid(Schema<?> previousSchema, Schema<?> schema) {
+    assertValid(previousSchema.getSchemaFields(), schema.getSchemaFields(), schema.getVersion());
+    assertValid(previousSchema.getIndexFields(), schema.getIndexFields(), schema.getVersion());
+  }
+
+  private static void assertValid(
+      ImmutableMap<String, ?> previousSchemaFields,
+      ImmutableMap<String, ?> schemaFields,
+      int schemaVersion) {
+    SetView<String> addedFields =
+        Sets.difference(schemaFields.keySet(), previousSchemaFields.keySet());
+    SetView<String> removedFields =
+        Sets.difference(previousSchemaFields.keySet(), schemaFields.keySet());
+    SetView<String> keptFields =
+        Sets.intersection(previousSchemaFields.keySet(), schemaFields.keySet());
+    assertWithMessage(
+            "Schema upgrade to version "
+                + schemaVersion
+                + " may either add or remove fields, but not both")
+        .that(addedFields.isEmpty() || removedFields.isEmpty())
+        .isTrue();
+    ImmutableList<String> modifiedFields =
+        keptFields.stream()
+            .filter(fieldName -> previousSchemaFields.get(fieldName) != schemaFields.get(fieldName))
+            .collect(toImmutableList());
+    assertWithMessage("Fields may not be modified (create a new field instead)")
+        .that(modifiedFields)
+        .isEmpty();
+  }
+
+  private IndexUpgradeValidator() {}
+}
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
new file mode 100644
index 0000000..affaadf
--- /dev/null
+++ b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
@@ -0,0 +1,138 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.SchemaUtil.schema;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.SchemaFieldDefs.Getter;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.query.change.ChangeData;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests {@link IndexUpgradeValidator}. */
+@RunWith(JUnit4.class)
+public class IndexUpgradeValidatorTest {
+
+  // TODO(mariasavtchouk): adopt this test to verity IndexedFields follow the same constraints as
+  // SchemaFields.
+  @Test
+  public void valid() {
+    IndexUpgradeValidator.assertValid(
+        schema(
+            1,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
+        schema(
+            2,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)));
+    IndexUpgradeValidator.assertValid(
+        schema(
+            1,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
+        schema(
+            2,
+            ImmutableList.<FieldDef<ChangeData, ?>>of(),
+            ImmutableList.<IndexedField<ChangeData, ?>>of(
+                ChangeField.OWNER_FIELD, ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                ChangeField.OWNER_SPEC, ChangeField.CHANGE_ID_SPEC)));
+    IndexUpgradeValidator.assertValid(
+        schema(
+            1,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
+        schema(
+            2,
+            ImmutableList.<IndexedField<ChangeData, ?>>of(
+                ChangeField.CHANGE_ID_FIELD,
+                ChangeField.OWNER_FIELD,
+                ChangeField.COMMITTER_PARTS_FIELD),
+            ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                ChangeField.CHANGE_ID_SPEC,
+                ChangeField.OWNER_SPEC,
+                ChangeField.COMMITTER_PARTS_SPEC)));
+  }
+
+  @Test
+  public void invalid_addAndRemove() {
+    AssertionError e =
+        assertThrows(
+            AssertionError.class,
+            () ->
+                IndexUpgradeValidator.assertValid(
+                    schema(
+                        1,
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                            ChangeField.CHANGE_ID_SPEC)),
+                    schema(
+                        2,
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.OWNER_FIELD),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                            ChangeField.OWNER_SPEC))));
+    assertThat(e)
+        .hasMessageThat()
+        .contains("Schema upgrade to version 2 may either add or remove fields, but not both");
+  }
+
+  @Test
+  public void invalid_modify() {
+    // Change value type from String to Integer.
+    IndexedField<ChangeData, Integer> ID_MODIFIED =
+        IndexedField.<ChangeData>integerBuilder(ChangeField.CHANGE_ID_FIELD.name()).build(cd -> 42);
+    AssertionError e =
+        assertThrows(
+            AssertionError.class,
+            () ->
+                IndexUpgradeValidator.assertValid(
+                    schema(
+                        1,
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+                            ChangeField.CHANGE_ID_SPEC)),
+                    schema(
+                        2,
+                        ImmutableList.<IndexedField<ChangeData, ?>>of(ID_MODIFIED),
+                        ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of())));
+    assertThat(e).hasMessageThat().contains("Fields may not be modified");
+    assertThat(e).hasMessageThat().contains(ChangeField.CHANGE_ID_FIELD.name());
+  }
+
+  @Test
+  public void invalid_modify_referenceEquality() {
+    // Comparison uses Object.equals(), i.e. reference equality.
+    Getter<ChangeData, String> getter = cd -> cd.change().getKey().get();
+    IndexedField<ChangeData, String> ID_1 =
+        IndexedField.<ChangeData>stringBuilder(ChangeField.CHANGE_ID_FIELD.name()).build(getter);
+    IndexedField<ChangeData, String> ID_2 =
+        IndexedField.<ChangeData>stringBuilder(ChangeField.CHANGE_ID_FIELD.name()).build(getter);
+    AssertionError e =
+        assertThrows(
+            AssertionError.class,
+            () ->
+                IndexUpgradeValidator.assertValid(
+                    schema(1, ImmutableList.of(ID_1), ImmutableList.of()),
+                    schema(2, ImmutableList.of(ID_2), ImmutableList.of())));
+    assertThat(e).hasMessageThat().contains("Fields may not be modified");
+    assertThat(e).hasMessageThat().contains(ChangeField.CHANGE_ID_FIELD.name());
+  }
+}
diff --git a/javatests/com/google/gerrit/index/SchemaUtilTest.java b/javatests/com/google/gerrit/index/SchemaUtilTest.java
index 698e00a..a92ee0c 100644
--- a/javatests/com/google/gerrit/index/SchemaUtilTest.java
+++ b/javatests/com/google/gerrit/index/SchemaUtilTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.index;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.SchemaUtil.getNameParts;
 import static com.google.gerrit.index.SchemaUtil.getPersonParts;
 import static com.google.gerrit.index.SchemaUtil.schema;
@@ -23,17 +24,37 @@
 import java.util.Map;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
+@RunWith(JUnit4.class)
 public class SchemaUtilTest {
+
+  private static final FieldDef<String, String> TEST_DEF =
+      exact("test_id").stored().build(id -> id);
+
+  private static final FieldDef<String, String> OTHER_TEST_DEF =
+      exact("other_test_id").stored().build(id -> id);
+
+  private static final IndexedField<String, String> TEST_FIELD =
+      IndexedField.<String>stringBuilder("TestId").build(a -> a);
+
+  private static final IndexedField<String, String> TEST_FIELD_DUPLICATE_NAME =
+      IndexedField.<String>stringBuilder(TEST_DEF.getName()).build(a -> a);
+
+  private static final IndexedField<String, String>.SearchSpec TEST_FIELD_SPEC =
+      TEST_FIELD.exact(TEST_DEF.getName());
+
   static class TestSchemas {
-    static final Schema<String> V1 = schema();
-    static final Schema<String> V2 = schema();
-    static Schema<String> V3 = schema(); // Not final, ignored.
-    private static final Schema<String> V4 = schema();
+
+    static final Schema<String> V1 = schema(/* version= */ 1);
+    static final Schema<String> V2 = schema(/* version= */ 2);
+    static Schema<String> V3 = schema(V2); // Not final, ignored.
+    private static final Schema<String> V4 = schema(V3);
 
     // Ignored.
-    static Schema<String> V10 = schema();
-    final Schema<String> V11 = schema();
+    static Schema<String> V10 = schema(/* version= */ 10);
+    final Schema<String> V11 = schema(V10);
   }
 
   @Test
@@ -49,6 +70,14 @@
   }
 
   @Test
+  public void schemaVersion_incrementedOnVersionUpgrades() {
+    Schema<String> initialSchemaVersion = schema(/* version= */ 1);
+    Schema<String> schemaVersionUpgrade = schema(initialSchemaVersion);
+    assertThat(initialSchemaVersion.getVersion()).isEqualTo(1);
+    assertThat(schemaVersionUpgrade.getVersion()).isEqualTo(2);
+  }
+
+  @Test
   public void getPersonPartsExtractsParts() {
     // PersonIdent allows empty email, which should be extracted as the empty
     // string. However, it converts empty names to null internally.
@@ -77,4 +106,169 @@
     assertThat(getNameParts("foO-bAr_Baz a.b@c/d"))
         .containsExactly("foo", "bar", "baz", "a", "b", "c", "d");
   }
+
+  @Test
+  public void canAddFieldSpecAndFieldDef() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .add(OTHER_TEST_DEF)
+            .build();
+
+    assertThat(schema0.hasField(TEST_FIELD_SPEC)).isTrue();
+    assertThat(schema0.hasField(OTHER_TEST_DEF)).isTrue();
+    assertThat(schema0.getIndexFields().values()).contains(TEST_FIELD);
+  }
+
+  @Test
+  public void canRemoveIndexedField() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .build();
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>()
+            .add(schema0)
+            .remove(TEST_FIELD_SPEC)
+            .remove(TEST_FIELD)
+            .build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isFalse();
+    assertThat(schema1.getIndexFields().values()).doesNotContain(TEST_FIELD);
+  }
+
+  @Test
+  public void canRemoveSearchSpec() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .build();
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>().add(schema0).remove(TEST_FIELD_SPEC).build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isFalse();
+    assertThat(schema1.getIndexFields().values()).contains(TEST_FIELD);
+  }
+
+  @Test
+  public void canRemoveFieldDef() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .add(OTHER_TEST_DEF)
+            .build();
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>().add(schema0).remove(OTHER_TEST_DEF).build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isTrue();
+    assertThat(schema1.hasField(OTHER_TEST_DEF)).isFalse();
+    assertThat(schema1.getIndexFields().values()).contains(TEST_FIELD);
+  }
+
+  @Test
+  public void addSearchWithoutStoredField_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new Schema.Builder<String>().version(0).addSearchSpecs(TEST_FIELD_SPEC).build());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("test_id spec can only be added to the schema that contains TestId field");
+  }
+
+  @Test
+  public void addDuplicateIndexField_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD)
+                    .addIndexedFields(TEST_FIELD)
+                    .build());
+    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: TestId");
+  }
+
+  @Test
+  public void addDuplicateSearchSpec_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD)
+                    .addSearchSpecs(TEST_FIELD_SPEC)
+                    .addSearchSpecs(TEST_FIELD_SPEC)
+                    .build());
+    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: test_id");
+  }
+
+  @Test
+  public void addFieldDefWithDuplicateSearchName_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD)
+                    .addSearchSpecs(TEST_FIELD_SPEC)
+                    .add(TEST_DEF)
+                    .build());
+    assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: test_id");
+  }
+
+  @Test
+  public void addFieldDefWithDuplicateFieldName_disallowed() {
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new Schema.Builder<String>()
+                    .version(0)
+                    .addIndexedFields(TEST_FIELD_DUPLICATE_NAME)
+                    .add(TEST_DEF)
+                    .build());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("DuplicateKeys found [test_id], indexFields:[test_id], schemaFields: [test_id]");
+  }
+
+  @Test
+  public void removeFieldWithExistingSearchSpec_disallowed() {
+    Schema<String> schema0 =
+        new Schema.Builder<String>()
+            .version(0)
+            .addIndexedFields(TEST_FIELD)
+            .addSearchSpecs(TEST_FIELD_SPEC)
+            .build();
+
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new Schema.Builder<String>().add(schema0).remove(TEST_FIELD).build());
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            "Field TestId can be only removed from schema after all of its searches are removed.");
+
+    Schema<String> schema1 =
+        new Schema.Builder<String>()
+            .add(schema0)
+            .remove(TEST_FIELD_SPEC)
+            .remove(TEST_FIELD)
+            .build();
+    assertThat(schema1.hasField(TEST_FIELD_SPEC)).isFalse();
+    assertThat(schema1.getIndexFields().values()).doesNotContain(TEST_FIELD);
+  }
 }
diff --git a/javatests/com/google/gerrit/index/query/AndCardinalPredicateTest.java b/javatests/com/google/gerrit/index/query/AndCardinalPredicateTest.java
new file mode 100644
index 0000000..f2d4e03
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/AndCardinalPredicateTest.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.server.query.change.ChangeData;
+import org.junit.Test;
+
+public class AndCardinalPredicateTest extends PredicateTest {
+  @Test
+  public void ensureAtLeastOneChildHasCardinality() {
+    TestMatchablePredicate<ChangeData> p1 = new TestMatchablePredicate<>("predicate1", "foo", 1);
+    TestMatchablePredicate<ChangeData> p2 = new TestMatchablePredicate<>("predicate2", "foo", 1);
+
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new AndCardinalPredicate<>(Lists.newArrayList(p1, p2)));
+    assertThat(thrown).hasMessageThat().contains("No HasCardinality Found");
+  }
+}
diff --git a/javatests/com/google/gerrit/index/query/AndPredicateTest.java b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
index 01fa99b..860a9fd 100644
--- a/javatests/com/google/gerrit/index/query/AndPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
@@ -29,8 +29,8 @@
 public class AndPredicateTest extends PredicateTest {
   @Test
   public void children() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
     final Predicate<String> n = and(a, b);
     assertEquals(2, n.getChildCount());
     assertSame(a, n.getChild(0));
@@ -39,8 +39,8 @@
 
   @Test
   public void childrenUnmodifiable() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
     final Predicate<String> n = and(a, b);
 
     assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
@@ -60,18 +60,18 @@
 
   @Test
   public void testToString() {
-    final TestPredicate a = f("q", "alice");
-    final TestPredicate b = f("q", "bob");
-    final TestPredicate c = f("q", "charlie");
+    final TestPredicate<String> a = f("q", "alice");
+    final TestPredicate<String> b = f("q", "bob");
+    final TestPredicate<String> c = f("q", "charlie");
     assertEquals("(q:alice q:bob)", and(a, b).toString());
     assertEquals("(q:alice q:bob q:charlie)", and(a, b, c).toString());
   }
 
   @Test
   public void testEquals() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
+    final TestPredicate<String> c = f("author", "charlie");
 
     assertTrue(and(a, b).equals(and(a, b)));
     assertTrue(and(a, b, c).equals(and(a, b, c)));
@@ -84,9 +84,9 @@
 
   @Test
   public void testHashCode() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
+    final TestPredicate<String> c = f("author", "charlie");
 
     assertTrue(and(a, b).hashCode() == and(a, b).hashCode());
     assertTrue(and(a, b, c).hashCode() == and(a, b, c).hashCode());
@@ -95,11 +95,11 @@
 
   @Test
   public void testCopy() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
-    final List<TestPredicate> s2 = ImmutableList.of(a, b);
-    final List<TestPredicate> s3 = ImmutableList.of(a, b, c);
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
+    final TestPredicate<String> c = f("author", "charlie");
+    final List<TestPredicate<String>> s2 = ImmutableList.of(a, b);
+    final List<TestPredicate<String>> s3 = ImmutableList.of(a, b, c);
     final Predicate<String> n2 = and(a, b);
 
     assertNotSame(n2, n2.copy(s2));
diff --git a/javatests/com/google/gerrit/index/query/AndSourceTest.java b/javatests/com/google/gerrit/index/query/AndSourceTest.java
new file mode 100644
index 0000000..068ae8c
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/AndSourceTest.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.server.query.change.ChangeData;
+import org.junit.Test;
+
+public class AndSourceTest extends PredicateTest {
+  @Test
+  public void ensureLowerCostPredicateRunsFirst() {
+    TestDataSourcePredicate p1 = new TestDataSourcePredicate("predicate1", "foo", 10, 10);
+    TestDataSourcePredicate p2 = new TestDataSourcePredicate("predicate2", "foo", 1, 10);
+    AndSource<String> andSource = new AndSource<>(Lists.newArrayList(p1, p2), null);
+    assertFalse(andSource.match("bar"));
+    assertFalse(p1.ranMatch);
+    assertTrue(p2.ranMatch);
+  }
+
+  @Test
+  public void ensureAtLeastOneChildIsADataSource() {
+    TestMatchablePredicate<ChangeData> p1 = new TestMatchablePredicate<>("predicate1", "foo", 1);
+    TestMatchablePredicate<ChangeData> p2 = new TestMatchablePredicate<>("predicate2", "foo", 1);
+
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new AndSource<>(Lists.newArrayList(p1, p2), null));
+    assertThat(thrown).hasMessageThat().contains("No DataSource Found");
+  }
+}
diff --git a/javatests/com/google/gerrit/index/query/LazyDataSourceTest.java b/javatests/com/google/gerrit/index/query/LazyDataSourceTest.java
index 7064f64..4105a1d 100644
--- a/javatests/com/google/gerrit/index/query/LazyDataSourceTest.java
+++ b/javatests/com/google/gerrit/index/query/LazyDataSourceTest.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gerrit.server.query.change.OrSource;
@@ -88,11 +89,17 @@
     public void close() {
       // No-op
     }
+
+    @Override
+    public Object searchAfter() {
+      return null;
+    }
   }
 
   @Test
   public void andSourceIsLazy() {
-    AndSource<ChangeData> and = new AndSource<>(ImmutableList.of(new LazyPredicate()));
+    AndSource<ChangeData> and =
+        new AndSource<>(ImmutableList.of(new LazyPredicate()), IndexConfig.createDefault());
     ResultSet<ChangeData> resultSet = and.read();
     assertThrows(AssertionError.class, () -> resultSet.toList());
   }
diff --git a/javatests/com/google/gerrit/index/query/NotPredicateTest.java b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
index 3d1839d..c476bc9 100644
--- a/javatests/com/google/gerrit/index/query/NotPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/NotPredicateTest.java
@@ -30,7 +30,7 @@
 public class NotPredicateTest extends PredicateTest {
   @Test
   public void notNot() {
-    final TestPredicate p = f("author", "bob");
+    final TestPredicate<String> p = f("author", "bob");
     final Predicate<String> n = not(p);
     assertTrue(n instanceof NotPredicate);
     assertNotSame(p, n);
@@ -39,7 +39,7 @@
 
   @Test
   public void children() {
-    final TestPredicate p = f("author", "bob");
+    final TestPredicate<String> p = f("author", "bob");
     final Predicate<String> n = not(p);
     assertEquals(1, n.getChildCount());
     assertSame(p, n.getChild(0));
@@ -47,7 +47,7 @@
 
   @Test
   public void childrenUnmodifiable() {
-    final TestPredicate p = f("author", "bob");
+    final TestPredicate<String> p = f("author", "bob");
     final Predicate<String> n = not(p);
 
     assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
@@ -88,10 +88,10 @@
   @Test
   @SuppressWarnings({"rawtypes", "unchecked"})
   public void testCopy() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final List<TestPredicate> sa = Collections.singletonList(a);
-    final List<TestPredicate> sb = Collections.singletonList(b);
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
+    final List<TestPredicate<String>> sa = Collections.singletonList(a);
+    final List<TestPredicate<String>> sb = Collections.singletonList(b);
     final Predicate n = not(a);
 
     assertNotSame(n, n.copy(sa));
diff --git a/javatests/com/google/gerrit/index/query/OrCardinalPredicateTest.java b/javatests/com/google/gerrit/index/query/OrCardinalPredicateTest.java
new file mode 100644
index 0000000..5f7d048
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/OrCardinalPredicateTest.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.server.query.change.ChangeData;
+import org.junit.Test;
+
+public class OrCardinalPredicateTest extends PredicateTest {
+  @Test
+  public void ensureAllChildrenAreHasCardinal() {
+    TestMatchablePredicate<ChangeData> p1 = new TestCardinalPredicate<>("predicate1", "foo", 10);
+    TestMatchablePredicate<ChangeData> p2 = new TestMatchablePredicate<>("predicate2", "foo", 1);
+
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> new OrCardinalPredicate<>(Lists.newArrayList(p1, p2)));
+    assertThat(thrown).hasMessageThat().contains("No HasCardinality");
+  }
+}
diff --git a/javatests/com/google/gerrit/index/query/OrPredicateTest.java b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
index 4d6c6e1..e5c9672 100644
--- a/javatests/com/google/gerrit/index/query/OrPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
@@ -29,8 +29,8 @@
 public class OrPredicateTest extends PredicateTest {
   @Test
   public void children() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
     final Predicate<String> n = or(a, b);
     assertEquals(2, n.getChildCount());
     assertSame(a, n.getChild(0));
@@ -39,8 +39,8 @@
 
   @Test
   public void childrenUnmodifiable() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
     final Predicate<String> n = or(a, b);
 
     assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
@@ -60,18 +60,18 @@
 
   @Test
   public void testToString() {
-    final TestPredicate a = f("q", "alice");
-    final TestPredicate b = f("q", "bob");
-    final TestPredicate c = f("q", "charlie");
+    final TestPredicate<String> a = f("q", "alice");
+    final TestPredicate<String> b = f("q", "bob");
+    final TestPredicate<String> c = f("q", "charlie");
     assertEquals("(q:alice OR q:bob)", or(a, b).toString());
     assertEquals("(q:alice OR q:bob OR q:charlie)", or(a, b, c).toString());
   }
 
   @Test
   public void testEquals() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
+    final TestPredicate<String> c = f("author", "charlie");
 
     assertTrue(or(a, b).equals(or(a, b)));
     assertTrue(or(a, b, c).equals(or(a, b, c)));
@@ -84,9 +84,9 @@
 
   @Test
   public void testHashCode() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
+    final TestPredicate<String> c = f("author", "charlie");
 
     assertTrue(or(a, b).hashCode() == or(a, b).hashCode());
     assertTrue(or(a, b, c).hashCode() == or(a, b, c).hashCode());
@@ -95,11 +95,11 @@
 
   @Test
   public void testCopy() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
-    final List<TestPredicate> s2 = ImmutableList.of(a, b);
-    final List<TestPredicate> s3 = ImmutableList.of(a, b, c);
+    final TestPredicate<String> a = f("author", "alice");
+    final TestPredicate<String> b = f("author", "bob");
+    final TestPredicate<String> c = f("author", "charlie");
+    final List<TestPredicate<String>> s2 = ImmutableList.of(a, b);
+    final List<TestPredicate<String>> s3 = ImmutableList.of(a, b, c);
     final Predicate<String> n2 = or(a, b);
 
     assertNotSame(n2, n2.copy(s2));
diff --git a/javatests/com/google/gerrit/index/query/OrSourceTest.java b/javatests/com/google/gerrit/index/query/OrSourceTest.java
new file mode 100644
index 0000000..8c8d09a
--- /dev/null
+++ b/javatests/com/google/gerrit/index/query/OrSourceTest.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.OrSource;
+import org.junit.Test;
+
+public class OrSourceTest extends PredicateTest {
+  @Test
+  public void ensureAllChildrenAreDataSources() {
+    TestMatchablePredicate<ChangeData> p1 = new TestMatchablePredicate<>("predicate1", "foo", 10);
+    TestMatchablePredicate<ChangeData> p2 = new TestMatchablePredicate<>("predicate2", "foo", 1);
+
+    IllegalArgumentException thrown =
+        assertThrows(
+            IllegalArgumentException.class, () -> new OrSource(Lists.newArrayList(p1, p2)));
+    assertThat(thrown).hasMessageThat().contains("No ChangeDataSource");
+  }
+}
diff --git a/javatests/com/google/gerrit/index/query/PredicateTest.java b/javatests/com/google/gerrit/index/query/PredicateTest.java
index 3ec7f13..d789201 100644
--- a/javatests/com/google/gerrit/index/query/PredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/PredicateTest.java
@@ -18,13 +18,73 @@
 
 @Ignore
 public abstract class PredicateTest {
-  protected static final class TestPredicate extends OperatorPredicate<String> {
+  @SuppressWarnings("ProtectedMembersInFinalClass")
+  protected static class TestDataSourcePredicate extends TestMatchablePredicate<String>
+      implements DataSource<String> {
+    protected final int cardinality;
+
+    protected TestDataSourcePredicate(String name, String value, int cost, int cardinality) {
+      super(name, value, cost);
+      this.cardinality = cardinality;
+    }
+
+    @Override
+    public int getCardinality() {
+      return cardinality;
+    }
+
+    @Override
+    public ResultSet<String> read() {
+      return null;
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() {
+      return null;
+    }
+  }
+
+  protected static class TestCardinalPredicate<T> extends TestMatchablePredicate<T>
+      implements HasCardinality {
+    protected TestCardinalPredicate(String name, String value, int cost) {
+      super(name, value, cost);
+    }
+
+    @Override
+    public int getCardinality() {
+      return 1;
+    }
+  }
+
+  protected static class TestMatchablePredicate<T> extends TestPredicate<T>
+      implements Matchable<T> {
+    protected int cost;
+    protected boolean ranMatch = false;
+
+    protected TestMatchablePredicate(String name, String value, int cost) {
+      super(name, value);
+      this.cost = cost;
+    }
+
+    @Override
+    public boolean match(T object) {
+      ranMatch = true;
+      return false;
+    }
+
+    @Override
+    public int getCost() {
+      return cost;
+    }
+  }
+
+  protected static class TestPredicate<T> extends OperatorPredicate<T> {
     protected TestPredicate(String name, String value) {
       super(name, value);
     }
   }
 
-  protected static TestPredicate f(String name, String value) {
-    return new TestPredicate(name, value);
+  protected static TestPredicate<String> f(String name, String value) {
+    return new TestPredicate<>(name, value);
   }
 }
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java
index 776a2c4..268c388 100644
--- a/javatests/com/google/gerrit/index/query/QueryParserTest.java
+++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -17,7 +17,10 @@
 import static com.google.gerrit.index.query.QueryParser.AND;
 import static com.google.gerrit.index.query.QueryParser.COLON;
 import static com.google.gerrit.index.query.QueryParser.DEFAULT_FIELD;
+import static com.google.gerrit.index.query.QueryParser.EXACT_PHRASE;
 import static com.google.gerrit.index.query.QueryParser.FIELD_NAME;
+import static com.google.gerrit.index.query.QueryParser.NOT;
+import static com.google.gerrit.index.query.QueryParser.OR;
 import static com.google.gerrit.index.query.QueryParser.SINGLE_WORD;
 import static com.google.gerrit.index.query.QueryParser.parse;
 import static com.google.gerrit.index.query.testing.TreeSubject.assertThat;
@@ -204,6 +207,186 @@
     assertThat(r).child(2).hasNoChildren();
   }
 
+  @Test
+  public void fieldNameWithEscapedDoubleQuotesInValue() throws Exception {
+    // Actual String: A \"special\" word
+    String search = "message:\"A \\\"special\\\" word\"";
+    Tree r = parse(search);
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).hasText("message");
+    assertThat(r).child(0).hasType(EXACT_PHRASE);
+    // Antlr escaped the double quotes in the phrase.
+    assertThat(r).child(0).hasText("A \"special\" word");
+  }
+
+  @Test
+  public void fieldNameWithEscapedTabCharacterIsPreserved() throws Exception {
+    String[] searches = {"message:\"A \\t word\"", "message:{A \\t word}"};
+    for (String search : searches) {
+      Tree r = parse(search);
+      assertThat(r).hasType(FIELD_NAME);
+      assertThat(r).hasChildCount(1);
+      assertThat(r).hasText("message");
+      assertThat(r).child(0).hasType(EXACT_PHRASE);
+      assertThat(r).child(0).hasText("A \t word");
+    }
+  }
+
+  @Test
+  public void fieldNameWithEscapedBackslashIsIncludedInOutput() throws Exception {
+    String search = "message:\"A backslash \\\\ in phrase\"";
+    Tree r = parse(search);
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).hasText("message");
+    assertThat(r).child(0).hasType(EXACT_PHRASE);
+    assertThat(r).child(0).hasText("A backslash \\ in phrase");
+  }
+
+  @Test
+  public void upperCaseParsedAsOperators() throws Exception {
+    Tree r = parse("project:foo:bar AND file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("project:foo:bar OR file:baz");
+    assertThat(r).hasType(OR);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("NOT project:foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+  }
+
+  @Test
+  public void lowerCaseParsedAsOperators() throws Exception {
+    Tree r = parse("project:foo:bar and file:baz");
+    assertThat(r).hasType(AND);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("project:foo:bar or file:baz");
+    assertThat(r).hasType(OR);
+    assertThat(r).hasChildCount(2);
+
+    r = parse("not project:foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+  }
+
+  @Test
+  public void fieldNameWithNot() throws Exception {
+    Tree r = parse("-foo:bar");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasText("NOT");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("foo");
+    assertThat(r).child(0).hasChildCount(1);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("bar");
+    assertThat(r).child(0).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithDigit() throws Exception {
+    Tree r = parse("foo9:bar");
+    assertThat(r).hasType(DEFAULT_FIELD);
+    assertThat(r).hasChildCount(3);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("foo9");
+    assertThat(r).child(0).hasNoChildren();
+    assertThat(r).child(1).hasType(COLON);
+    assertThat(r).child(1).hasText(":");
+    assertThat(r).child(1).hasNoChildren();
+    assertThat(r).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(2).hasText("bar");
+    assertThat(r).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithUnderscore() throws Exception {
+    Tree r = parse("foo_bar:baz");
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasText("foo_bar");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("baz");
+    assertThat(r).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithHyphen() throws Exception {
+    Tree r = parse("foo-bar:baz");
+    assertThat(r).hasType(FIELD_NAME);
+    assertThat(r).hasText("foo-bar");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("baz");
+    assertThat(r).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameEndingWithHyphen() throws Exception {
+    Tree r = parse("foo-:bar");
+    assertThat(r).hasType(DEFAULT_FIELD);
+    assertThat(r).hasChildCount(3);
+    assertThat(r).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).hasText("foo-");
+    assertThat(r).child(0).hasNoChildren();
+    assertThat(r).child(1).hasType(COLON);
+    assertThat(r).child(1).hasText(":");
+    assertThat(r).child(1).hasNoChildren();
+    assertThat(r).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(2).hasText("bar");
+    assertThat(r).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithNotAndHyphen() throws Exception {
+    Tree r = parse("-foo-bar:baz");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasText("NOT");
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("foo-bar");
+    assertThat(r).child(0).hasChildCount(1);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("baz");
+    assertThat(r).child(0).child(0).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithNotAndEndingWithHyphen() throws Exception {
+    Tree r = parse("-foo-bar-:baz");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(DEFAULT_FIELD);
+    assertThat(r).child(0).hasChildCount(3);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("foo-bar-");
+    assertThat(r).child(0).child(0).hasNoChildren();
+    assertThat(r).child(0).child(1).hasType(COLON);
+    assertThat(r).child(0).child(1).hasText(":");
+    assertThat(r).child(0).child(1).hasNoChildren();
+    assertThat(r).child(0).child(2).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(2).hasText("baz");
+    assertThat(r).child(0).child(2).hasNoChildren();
+  }
+
+  @Test
+  public void fieldNameWithMiscellaneousCharacters() throws Exception {
+    Tree r = parse("-foo-bar_-baz_:qux");
+    assertThat(r).hasType(NOT);
+    assertThat(r).hasChildCount(1);
+    assertThat(r).child(0).hasType(FIELD_NAME);
+    assertThat(r).child(0).hasText("foo-bar_-baz_");
+    assertThat(r).child(0).hasChildCount(1);
+    assertThat(r).child(0).child(0).hasType(SINGLE_WORD);
+    assertThat(r).child(0).child(0).hasText("qux");
+    assertThat(r).child(0).child(0).hasNoChildren();
+  }
+
   private static void assertParseFails(String query) {
     assertThrows(QueryParseException.class, () -> parse(query));
   }
diff --git a/javatests/com/google/gerrit/integration/git/BUILD b/javatests/com/google/gerrit/integration/git/BUILD
index 28755af..86b3f36 100644
--- a/javatests/com/google/gerrit/integration/git/BUILD
+++ b/javatests/com/google/gerrit/integration/git/BUILD
@@ -1,13 +1,32 @@
 load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
 
+# TODO(davido): This was only needed as own rule, to provide a dedicated
+# tag to skip Git version v2 protocol tests. That was particularly
+# needed for RBE, because this test assumes that git client version is
+# at least 2.17.1. Once Bazel docker image for Ubuntu 20.04 is available
+# and we removed our own RBE docker image, we can merge this rule with
+# the other rules in this package.
 acceptance_tests(
     srcs = ["GitProtocolV2IT.java"],
     group = "protocol-v2",
     labels = ["git-protocol-v2"],
 )
 
+# This rule can be also merged with the other tests in this package.
 acceptance_tests(
     srcs = ["UploadArchiveIT.java"],
     group = "upload-archive",
     labels = ["git-upload-archive"],
 )
+
+acceptance_tests(
+    srcs = glob(
+        ["*.java"],
+        exclude = [
+            "GitProtocolV2IT.java",
+            "UploadArchiveIT.java",
+        ],
+    ),
+    group = "git_tests",
+    labels = ["git"],
+)
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index b114acc..d40f2a1 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -16,7 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
@@ -27,7 +29,11 @@
 import com.google.gerrit.acceptance.StandaloneSiteTest;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -40,7 +46,10 @@
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.inject.Inject;
 import java.io.File;
+import java.io.IOException;
 import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.junit.BeforeClass;
 import org.junit.Test;
@@ -62,9 +71,11 @@
   @Inject private GerritApi gApi;
   @Inject private AccountCreator accountCreator;
   @Inject private ProjectOperations projectOperations;
+  @Inject private GroupOperations groupOperations;
   @Inject private @TestSshServerAddress InetSocketAddress sshAddress;
   @Inject private @GerritServerConfig Config config;
   @Inject private AllProjectsName allProjectsName;
+  @Inject private IndexOperations.Change changeIndexOperations;
 
   @BeforeClass
   public static void assertGitClientVersion() throws Exception {
@@ -86,15 +97,20 @@
       Project.NameKey project = Project.nameKey("foo");
       gApi.projects().create(project.get());
 
-      // Set up project permission
+      // Clear all permissions for anonymous users. Allow registered users to fetch/push.
+      AccountGroup.UUID admins = groupOperations.newGroup().addMember(admin.id()).create();
       projectOperations
-          .project(project)
+          .project(allProjectsName)
           .forUpdate()
-          .add(deny(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .removeAllAccessSections()
           .add(
               allow(Permission.READ)
                   .ref("refs/heads/master")
                   .group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.READ).ref("refs/*").group(admins))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allowCapability(GlobalCapability.ADMINISTRATE_SERVER).group(admins))
           .update();
 
       // Retrieve HTTP url
@@ -211,15 +227,17 @@
       Project.NameKey allRefsVisibleProject = Project.nameKey("all-refs-visible");
       gApi.projects().create(allRefsVisibleProject.get());
 
-      // Set up project permission to allow reading all refs
+      // Allow registered users to fetch/push. Allow anonymous users to read refs/heads/* which also
+      // allows reading changes.
       projectOperations
-          .project(allRefsVisibleProject)
+          .project(allProjectsName)
           .forUpdate()
+          .removeAllAccessSections()
           .add(allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.ANONYMOUS_USERS))
           .add(
-              allow(Permission.READ)
-                  .ref("refs/changes/*")
-                  .group(SystemGroupBackend.ANONYMOUS_USERS))
+              allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
           .update();
 
       // Create new change and retrieve refs for the created patch set
@@ -265,6 +283,140 @@
       Project.NameKey privateProject = Project.nameKey("private-project");
       gApi.projects().create(privateProject.get());
 
+      // Clear all permissions for anonymous users. Allow registered users to fetch/push.
+      projectOperations
+          .project(allProjectsName)
+          .forUpdate()
+          .removeAllAccessSections()
+          .add(
+              allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn =
+          new ChangeInput(privateProject.get(), "master", "Test private change");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+
+      // Create new change and retrieve refs for the created patch set. We'll use this to make sure
+      // refs-in-wants only sends us changes we asked for.
+      ChangeInput changeWeDidNotAskForIn =
+          new ChangeInput(privateProject.get(), "stable", "Test private change 2");
+      changeWeDidNotAskForIn.newBranch = true;
+      int changeWeDidNotAskForNumber = gApi.changes().create(changeWeDidNotAskForIn).info()._number;
+      Change.Id changeWeDidNotAskFor = Change.id(changeWeDidNotAskForNumber);
+      String changeWeDidNotAskForRef = RefNames.patchSetRef(PatchSet.id(changeWeDidNotAskFor, 1));
+
+      // Fetch a single ref using git wire protocol v2 over HTTP with authentication
+      execute(GIT_INIT);
+
+      String outFetchRef =
+          execute(
+              ImmutableList.<String>builder()
+                  .add(GIT_FETCH)
+                  .add(urlWithCredentials + "/" + privateProject.get())
+                  .add(visibleChangeNumberRef)
+                  .build(),
+              ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+
+      assertThat(outFetchRef).contains("git< version 2");
+      assertThat(outFetchRef).contains(visibleChangeNumberRef);
+      // refs-in-wants should not advertise changes we did not ask for
+      assertThat(outFetchRef).doesNotContain(changeWeDidNotAskForRef);
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2FetchIndividualRef_doesNotNeedChangeIndex() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      // Get authenticated Git/HTTP URL
+      String urlWithCredentials =
+          config
+              .getString("gerrit", null, "canonicalweburl")
+              .replace("http://", "http://" + admin.username() + ":" + ADMIN_PASSWORD + "@");
+
+      // Create project
+      Project.NameKey privateProject = Project.nameKey("private-project");
+      gApi.projects().create(privateProject.get());
+
+      // Disallow general read permissions for anonymous users
+      projectOperations
+          .project(allProjectsName)
+          .forUpdate()
+          .add(deny(Permission.READ).ref("refs/*").group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(
+              allow(Permission.READ)
+                  .ref("refs/heads/master")
+                  .group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn =
+          new ChangeInput(privateProject.get(), "master", "Test private change");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+
+      // Create new change and retrieve refs for the created patch set. We'll use this to make sure
+      // refs-in-wants only sends us changes we asked for.
+      ChangeInput changeWeDidNotAskForIn =
+          new ChangeInput(privateProject.get(), "stable", "Test private change 2");
+      changeWeDidNotAskForIn.newBranch = true;
+      int changeWeDidNotAskForNumber = gApi.changes().create(changeWeDidNotAskForIn).info()._number;
+      Change.Id changeWeDidNotAskFor = Change.id(changeWeDidNotAskForNumber);
+      String changeWeDidNotAskForRef = RefNames.patchSetRef(PatchSet.id(changeWeDidNotAskFor, 1));
+
+      // Fetch a single ref using git wire protocol v2 over HTTP with authentication
+      execute(GIT_INIT);
+
+      String outFetchRef;
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        outFetchRef =
+            execute(
+                ImmutableList.<String>builder()
+                    .add(GIT_FETCH)
+                    .add(urlWithCredentials + "/" + privateProject.get())
+                    .add(visibleChangeNumberRef)
+                    .build(),
+                ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+      }
+
+      assertThat(outFetchRef).contains("git< version 2");
+      assertThat(outFetchRef).contains(visibleChangeNumberRef);
+      // refs-in-wants should not advertise changes we did not ask for
+      assertThat(outFetchRef).doesNotContain(changeWeDidNotAskForRef);
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2FetchIndividualRef_doesNotNeedNoteDbWhenAskedForManyChanges()
+      throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      // Get authenticated Git/HTTP URL
+      String urlWithCredentials =
+          config
+              .getString("gerrit", null, "canonicalweburl")
+              .replace("http://", "http://" + admin.username() + ":" + ADMIN_PASSWORD + "@");
+
+      // Create project
+      Project.NameKey privateProject = Project.nameKey("private-project");
+      gApi.projects().create(privateProject.get());
+
       // Disallow general read permissions for anonymous users
       projectOperations
           .project(allProjectsName)
@@ -286,28 +438,125 @@
                   .group(SystemGroupBackend.REGISTERED_USERS))
           .update();
 
-      // Create new change and retrieve refs for the created patch set
-      ChangeInput visibleChangeIn =
-          new ChangeInput(privateProject.get(), "master", "Test private change");
-      visibleChangeIn.newBranch = true;
-      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
-      Change.Id changeId = Change.id(visibleChangeNumber);
-      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+      List<String> changeRefs = new ArrayList<>();
+      for (int i = 0; i < 10; i++) {
+        // Create new change and retrieve refs for the created patch set
+        ChangeInput visibleChangeIn =
+            new ChangeInput(privateProject.get(), "master", "Test private change");
+        visibleChangeIn.newBranch = true;
+        int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+        Change.Id changeId = Change.id(visibleChangeNumber);
+        changeRefs.add(RefNames.patchSetRef(PatchSet.id(changeId, 1)));
+      }
 
       // Fetch a single ref using git wire protocol v2 over HTTP with authentication
       execute(GIT_INIT);
 
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        // Since we ask for many changes at once, the server will use the change index to speed up
+        // filtering. Having that disabled fails.
+        assertThrows(
+            IOException.class,
+            () ->
+                execute(
+                    ImmutableList.<String>builder()
+                        .add(GIT_FETCH)
+                        .add(urlWithCredentials + "/" + privateProject.get())
+                        .addAll(changeRefs)
+                        .build(),
+                    ImmutableMap.of("GIT_TRACE_PACKET", "1")));
+      }
+
+      // The same call succeeds if the change index is enabled.
       String outFetchRef =
           execute(
               ImmutableList.<String>builder()
                   .add(GIT_FETCH)
                   .add(urlWithCredentials + "/" + privateProject.get())
-                  .add(visibleChangeNumberRef)
+                  .addAll(changeRefs)
                   .build(),
               ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+      assertThat(outFetchRef).contains("git< version 2");
+      assertThat(outFetchRef).contains(changeRefs.get(0));
+    }
+  }
+
+  @Test
+  public void testGitWireProtocolV2FetchIndividualRef_anonymousCantSeeInvisibleChange()
+      throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      String url = config.getString("gerrit", null, "canonicalweburl");
+
+      // Create project
+      Project.NameKey privateProject = Project.nameKey("private-project");
+      gApi.projects().create(privateProject.get());
+
+      // Disallow general read permissions for anonymous users except on master
+      projectOperations
+          .project(allProjectsName)
+          .forUpdate()
+          .removeAllAccessSections()
+          .add(
+              allow(Permission.READ)
+                  .ref("refs/heads/master")
+                  .group(SystemGroupBackend.ANONYMOUS_USERS))
+          .add(
+              allow(Permission.READ).ref("refs/heads/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.CREATE).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .add(allow(Permission.PUSH).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
+          .update();
+
+      // Create new change and retrieve refs for the created patch set
+      ChangeInput visibleChangeIn = new ChangeInput(privateProject.get(), "master", "Visible");
+      visibleChangeIn.newBranch = true;
+      int visibleChangeNumber = gApi.changes().create(visibleChangeIn).info()._number;
+      Change.Id changeId = Change.id(visibleChangeNumber);
+      String visibleChangeNumberRef = RefNames.patchSetRef(PatchSet.id(changeId, 1));
+
+      // Create new change and retrieve refs for the created patch set. We'll use this to make sure
+      // refs-in-wants only sends us changes we asked for.
+      ChangeInput invisibleChangeIn = new ChangeInput(privateProject.get(), "stable", "Invisible");
+      invisibleChangeIn.newBranch = true;
+      int invisibleChangeNumber = gApi.changes().create(invisibleChangeIn).info()._number;
+      Change.Id invisibleChange = Change.id(invisibleChangeNumber);
+      String invisibleChangeRef = RefNames.patchSetRef(PatchSet.id(invisibleChange, 1));
+
+      // Fetch a single ref using git wire protocol v2 over HTTP with authentication
+      execute(GIT_INIT);
+
+      String outFetchRef;
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        outFetchRef =
+            execute(
+                ImmutableList.<String>builder()
+                    .add(GIT_FETCH)
+                    .add(url + "/" + privateProject.get())
+                    .add(visibleChangeNumberRef)
+                    .build(),
+                ImmutableMap.of("GIT_TRACE_PACKET", "1"));
+      }
 
       assertThat(outFetchRef).contains("git< version 2");
       assertThat(outFetchRef).contains(visibleChangeNumberRef);
+
+      try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
+        // Fetching invisible ref fails
+        assertThrows(
+            IOException.class,
+            () ->
+                execute(
+                    ImmutableList.<String>builder()
+                        .add(GIT_FETCH)
+                        .add(url + "/" + privateProject.get())
+                        .add(invisibleChangeRef)
+                        .build(),
+                    ImmutableMap.of("GIT_TRACE_PACKET", "1")));
+      }
     }
   }
 
@@ -333,7 +582,7 @@
   }
 
   private static void assertGitProtocolV2Refs(String commit, String out) {
-    assertThat(out).contains("git< version 2");
+    assertThat(out).containsMatch("(git|ls-remote)< version 2");
     assertThat(out).contains("refs/changes/01/1/1");
     assertThat(out).contains("refs/changes/01/1/meta");
     assertThat(out).contains(commit);
diff --git a/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java b/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java
new file mode 100644
index 0000000..c42f00d
--- /dev/null
+++ b/javatests/com/google/gerrit/integration/git/PushToRefsUsersIT.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.integration.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+// TODO(davido): In addition to push over HTTP also add a test for push over SSH
+public class PushToRefsUsersIT extends StandaloneSiteTest {
+  private static final String ADMIN_PASSWORD = "secret";
+  private final String[] GIT_CLONE = new String[] {"git", "clone"};
+  private final String[] GIT_FETCH_USERS_SELF =
+      new String[] {"git", "fetch", "origin", "refs/users/self"};
+  private final String[] GIT_CO_FETCH_HEAD = new String[] {"git", "checkout", "FETCH_HEAD"};
+  private final String[] GIT_CONFIG_USER_EMAIL =
+      new String[] {"git", "config", "user.email", "admin@example.com"};
+  private final String[] GIT_CONFIG_USER_NAME =
+      new String[] {"git", "config", "user.name", "Administrator"};
+  private final String[] GIT_COMMIT = new String[] {"git", "commit", "-am", "OOO"};
+  private final String[] GIT_PUSH_USERS_SELF =
+      new String[] {"git", "push", "origin", "HEAD:refs/users/self"};
+
+  @Inject private GerritApi gApi;
+  @Inject private @GerritServerConfig Config config;
+  @Inject private AllUsersName allUsersName;
+
+  @Test
+  public void testPushToRefsUsersOverHttp() throws Exception {
+    try (ServerContext ctx = startServer()) {
+      ctx.getInjector().injectMembers(this);
+
+      // Setup admin password
+      gApi.accounts().id(admin.username()).setHttpPassword(ADMIN_PASSWORD);
+
+      // Get authenticated Git/HTTP URL
+      String urlWithCredentials =
+          config
+              .getString("gerrit", null, "canonicalweburl")
+              .replace("http://", "http://" + admin.username() + ":" + ADMIN_PASSWORD + "@");
+
+      // Clone All-Users repository
+      execute(
+          ImmutableList.<String>builder()
+              .add(GIT_CLONE)
+              .add(urlWithCredentials + "/a/" + allUsersName)
+              .add(sitePaths.data_dir.toFile().getAbsolutePath())
+              .build(),
+          sitePaths.site_path);
+
+      // Fetch refs/users/self for admin user
+      execute(GIT_FETCH_USERS_SELF);
+
+      // Checkout FETCH_HEAD
+      execute(GIT_CO_FETCH_HEAD);
+
+      // Set admin user status to OOO
+      Files.write(
+          sitePaths.data_dir.resolve("account.config"),
+          "  status = OOO".getBytes(UTF_8),
+          StandardOpenOption.APPEND);
+
+      // Set user email
+      execute(GIT_CONFIG_USER_EMAIL);
+
+      // Set user name
+      execute(GIT_CONFIG_USER_NAME);
+
+      // Commit
+      execute(GIT_COMMIT);
+
+      // Push
+      assertThat(execute(GIT_PUSH_USERS_SELF)).contains("Processing changes: refs: 1, done");
+
+      // Verify user status
+      assertThat(gApi.accounts().id(admin.id().get()).detail().status).isEqualTo("OOO");
+    }
+  }
+
+  private String execute(String... cmds) throws Exception {
+    return execute(ImmutableList.<String>builder().add(cmds).build(), sitePaths.data_dir);
+  }
+
+  private String execute(ImmutableList<String> cmd, Path path) throws Exception {
+    return execute(cmd, path.toFile(), ImmutableMap.of());
+  }
+}
diff --git a/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java b/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
index a219cc2..039bc8e 100644
--- a/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
+++ b/javatests/com/google/gerrit/integration/ssh/PeerKeysAuthIT.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.GerritServer.TestSshServerAddress;
@@ -31,6 +32,7 @@
 import java.io.IOException;
 import java.net.InetSocketAddress;
 import java.nio.file.Files;
+import java.util.List;
 import org.junit.Test;
 
 @NoHttpd
@@ -59,17 +61,19 @@
       // Generate private/public key for user
       execute(ImmutableList.<String>builder().add(SSH_KEYGEN_CMD).build());
 
-      String[] parts =
-          new String(Files.readAllBytes(sitePaths.data_dir.resolve("id_rsa.pub")), UTF_8)
-              .split(" ");
+      List<String> parts =
+          Splitter.on(" ")
+              .splitToList(
+                  new String(Files.readAllBytes(sitePaths.data_dir.resolve("id_rsa.pub")), UTF_8));
 
       // Loose algorithm at index 0, verify the format: "key comment"
       Files.write(
-          sitePaths.peer_keys, String.format("%s %s", parts[1], parts[2]).getBytes(ISO_8859_1));
+          sitePaths.peer_keys,
+          String.format("%s %s", parts.get(1), parts.get(2)).getBytes(ISO_8859_1));
       assertContent(execGerritVersionCommand());
 
       // Only preserve the key material: no algorithm and no comment
-      Files.write(sitePaths.peer_keys, parts[1].getBytes(ISO_8859_1));
+      Files.write(sitePaths.peer_keys, parts.get(1).getBytes(ISO_8859_1));
       assertContent(execGerritVersionCommand());
 
       // Wipe out the content of the peer keys file
diff --git a/javatests/com/google/gerrit/json/BUILD b/javatests/com/google/gerrit/json/BUILD
index 575f575..a242b0e 100644
--- a/javatests/com/google/gerrit/json/BUILD
+++ b/javatests/com/google/gerrit/json/BUILD
@@ -5,7 +5,6 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/json",
-        "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:gson",
         "//lib:guava",
diff --git a/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java b/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
index 2699c3b..44ef822 100644
--- a/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
+++ b/javatests/com/google/gerrit/json/SqlTimestampDeserializerTest.java
@@ -16,9 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.JsonPrimitive;
 import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class SqlTimestampDeserializerTest {
@@ -28,6 +28,6 @@
   @Test
   public void emptyStringIsDeserializedToMagicTimestamp() {
     Timestamp timestamp = deserializer.deserialize(new JsonPrimitive(""), Timestamp.class, null);
-    assertThat(timestamp).isEqualTo(TimeUtil.never());
+    assertThat(timestamp).isEqualTo(Timestamp.from(Instant.EPOCH));
   }
 }
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index a2432a2..f99a2af 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
@@ -58,7 +57,7 @@
         new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
-            new Timestamp(0L),
+            Instant.EPOCH,
             (short) 0,
             message,
             "",
@@ -73,7 +72,7 @@
         new HumanComment(
             new Comment.Key(uuid, file, 1),
             Account.id(0),
-            new Timestamp(0L),
+            Instant.EPOCH,
             (short) 0,
             message,
             "",
diff --git a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
index 60368eb..86a0b56 100644
--- a/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
+++ b/javatests/com/google/gerrit/mail/data/NonUTF8Message.java
@@ -45,7 +45,8 @@
   public int[] rawChars() {
     int[] arr = new int[raw.length()];
     int i = 0;
-    for (char c : raw.toCharArray()) {
+    for (int j = 0; j < raw.length(); j++) {
+      char c = raw.charAt(j);
       arr[i++] = c;
     }
     return arr;
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/BUILD b/javatests/com/google/gerrit/metrics/dropwizard/BUILD
index 98d12b2..e236f30 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/BUILD
+++ b/javatests/com/google/gerrit/metrics/dropwizard/BUILD
@@ -6,7 +6,10 @@
     tags = ["metrics"],
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/metrics/dropwizard",
+        "//lib/mockito",
         "//lib/truth",
+        "@dropwizard-core//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
index 9b21bf6..5777779 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
+++ b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
@@ -15,12 +15,33 @@
 package com.google.gerrit.metrics.dropwizard;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
+import com.codahale.metrics.MetricRegistry;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricsReservoirConfig;
+import com.google.gerrit.metrics.ReservoirType;
+import org.junit.Before;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
+@RunWith(MockitoJUnitRunner.class)
 public class DropWizardMetricMakerTest {
-  DropWizardMetricMaker metrics =
-      new DropWizardMetricMaker(null /* MetricRegistry unused in tests */);
+
+  @Mock MetricsReservoirConfig reservoirConfigMock;
+
+  MetricRegistry registry;
+
+  DropWizardMetricMaker metrics;
+
+  @Before
+  public void setupMocks() {
+    registry = new MetricRegistry();
+    metrics = new DropWizardMetricMaker(registry, reservoirConfigMock);
+  }
 
   @Test
   public void shouldSanitizeUnwantedChars() throws Exception {
@@ -41,4 +62,15 @@
     assertThat(metrics.sanitizeMetricName("metric//")).isEqualTo("metric");
     assertThat(metrics.sanitizeMetricName("metric/submetric/")).isEqualTo("metric/submetric");
   }
+
+  @Test
+  public void shouldRequestForReservoirForNewTimer() throws Exception {
+    when(reservoirConfigMock.reservoirType()).thenReturn(ReservoirType.ExponentiallyDecaying);
+
+    metrics.newTimer(
+        "foo",
+        new Description("foo description").setCumulative().setUnit(Description.Units.MILLISECONDS));
+
+    verify(reservoirConfigMock).reservoirType();
+  }
 }
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardReservoirProviderTest.java b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardReservoirProviderTest.java
new file mode 100644
index 0000000..6402b53
--- /dev/null
+++ b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardReservoirProviderTest.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.metrics.dropwizard;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.SlidingTimeWindowArrayReservoir;
+import com.codahale.metrics.SlidingTimeWindowReservoir;
+import com.codahale.metrics.SlidingWindowReservoir;
+import com.codahale.metrics.UniformReservoir;
+import com.google.gerrit.metrics.MetricsReservoirConfig;
+import com.google.gerrit.metrics.ReservoirType;
+import java.time.Duration;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class DropWizardReservoirProviderTest {
+  private static final int SLIDING_WINDOW_INTERVAL = 1;
+  private static final int SLIDING_WINDOW_SIZE = 256;
+
+  @Mock private MetricsReservoirConfig configMock;
+
+  @Test
+  public void shouldInstantiateReservoirProviderBasedOnMetricsConfig() {
+    when(configMock.reservoirType()).thenReturn(ReservoirType.ExponentiallyDecaying);
+    assertThat(DropWizardReservoirProvider.get(configMock))
+        .isInstanceOf(ExponentiallyDecayingReservoir.class);
+
+    when(configMock.reservoirType()).thenReturn(ReservoirType.SlidingTimeWindow);
+    when(configMock.reservoirWindow()).thenReturn(Duration.ofMinutes(1));
+    assertThat(DropWizardReservoirProvider.get(configMock))
+        .isInstanceOf(SlidingTimeWindowReservoir.class);
+
+    when(configMock.reservoirType()).thenReturn(ReservoirType.SlidingTimeWindowArray);
+    when(configMock.reservoirWindow()).thenReturn(Duration.ofMinutes(SLIDING_WINDOW_INTERVAL));
+    assertThat(DropWizardReservoirProvider.get(configMock))
+        .isInstanceOf(SlidingTimeWindowArrayReservoir.class);
+
+    when(configMock.reservoirType()).thenReturn(ReservoirType.SlidingWindow);
+    when(configMock.reservoirSize()).thenReturn(SLIDING_WINDOW_SIZE);
+    assertThat(DropWizardReservoirProvider.get(configMock))
+        .isInstanceOf(SlidingWindowReservoir.class);
+
+    when(configMock.reservoirType()).thenReturn(ReservoirType.Uniform);
+    assertThat(DropWizardReservoirProvider.get(configMock)).isInstanceOf(UniformReservoir.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
index 33919e7..ea89ae9 100644
--- a/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
+++ b/javatests/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.mock;
 
 import com.codahale.metrics.Counter;
 import com.codahale.metrics.Gauge;
@@ -31,7 +32,9 @@
 import com.google.gerrit.metrics.Description.FieldOrdering;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.MetricsReservoirConfig;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
+import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -179,7 +182,14 @@
 
   @Before
   public void setup() {
-    Injector injector = Guice.createInjector(new DropWizardMetricMaker.ApiModule());
+    Injector injector =
+        Guice.createInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                install(new DropWizardMetricMaker.ApiModule(mock(MetricsReservoirConfig.class)));
+              }
+            });
 
     LifecycleManager mgr = new LifecycleManager();
     mgr.add(injector);
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 689698e..c694a87 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -45,6 +45,7 @@
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/testing",
         "//java/com/google/gerrit/jgit",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/lifecycle",
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 463af35..855a0bc 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -99,7 +99,7 @@
     injector.injectMembers(this);
 
     Account account =
-        Account.builder(Account.id(1), TimeUtil.nowTs())
+        Account.builder(Account.id(1), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build();
     Account.Id ownerId = account.id();
diff --git a/javatests/com/google/gerrit/server/account/AccountCacheTest.java b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
index a2aa40b..c1eff15 100644
--- a/javatests/com/google/gerrit/server/account/AccountCacheTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.CachedPreferences;
-import java.sql.Timestamp;
 import java.time.Instant;
 import org.junit.Test;
 
@@ -32,8 +31,7 @@
  * of the {@code AccountCache}.
  */
 public class AccountCacheTest {
-  private static final Account ACCOUNT =
-      Account.builder(Account.id(1), Timestamp.from(Instant.EPOCH)).build();
+  private static final Account ACCOUNT = Account.builder(Account.id(1), Instant.EPOCH).build();
   private static final Cache.AccountProto ACCOUNT_PROTO =
       Cache.AccountProto.newBuilder().setId(1).setRegisteredOn(0).build();
   private static final CachedAccountDetails.Serializer SERIALIZER =
@@ -42,7 +40,7 @@
   @Test
   public void account_roundTrip() throws Exception {
     Account account =
-        Account.builder(Account.id(1), Timestamp.from(Instant.EPOCH))
+        Account.builder(Account.id(1), Instant.EPOCH)
             .setFullName("foo bar")
             .setDisplayName("foo")
             .setActive(false)
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index 5e49aaf..37728f7 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -23,6 +23,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver.Result;
 import com.google.gerrit.server.account.AccountResolver.Searcher;
 import com.google.gerrit.server.account.AccountResolver.StringSearcher;
@@ -35,6 +37,8 @@
 import org.junit.Test;
 
 public class AccountResolverTest {
+  private final CurrentUser user = new AnonymousUser();
+
   private static class TestSearcher extends StringSearcher {
     private final String pattern;
     private final boolean shortCircuit;
@@ -96,15 +100,15 @@
             new TestSearcher("foo", false, newAccount(1)),
             new TestSearcher("bar", false, newAccount(2), newAccount(3)));
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("foo");
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
 
-    result = search("bar", searchers, allVisible());
+    result = search("bar", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("bar");
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(2, 3));
 
-    result = search("baz", searchers, allVisible());
+    result = search("baz", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("baz");
     assertThat(result.asIdSet()).isEmpty();
   }
@@ -115,11 +119,11 @@
         ImmutableList.of(
             new TestSearcher("f.*", true), new TestSearcher("foo|bar", false, newAccount(1)));
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("foo");
     assertThat(result.asIdSet()).isEmpty();
 
-    result = search("bar", searchers, allVisible());
+    result = search("bar", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.input()).isEqualTo("bar");
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
   }
@@ -129,7 +133,7 @@
     ImmutableList<Searcher<?>> searchers =
         ImmutableList.of(new TestSearcher("foo", false, newAccount(1), newAccount(2)));
 
-    assertThat(search("foo", searchers, allVisible()).asIdSet())
+    assertThat(search("foo", searchers, AccountResolverTest::allVisiblePredicate).asIdSet())
         .containsExactlyElementsIn(ids(1, 2));
     assertThat(search("foo", searchers, only(2)).asIdSet()).containsExactlyElementsIn(ids(2));
   }
@@ -152,7 +156,7 @@
             new TestSearcher("foo", false, newInactiveAccount(1)),
             new TestSearcher("f.*", false, newInactiveAccount(2)));
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     // Searchers always short-circuit when finding a non-empty result list, and this one didn't
     // filter out inactive results, so the second searcher never ran.
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1));
@@ -168,7 +172,7 @@
     searcher2.setCallerShouldFilterOutInactiveCandidates();
     ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
 
-    Result result = search("foo", searchers, allVisible(), (a) -> true);
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate, (a) -> true);
     // Searchers always short-circuit when finding a non-empty result list,
     // and this one didn't filter out inactive results,
     // so the second searcher never ran.
@@ -185,8 +189,9 @@
     searcher2.setCallerShouldFilterOutInactiveCandidates();
     ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
 
-    Result result = search("foo", searchers, allVisible());
-    assertThat(search("foo", searchers, allVisible()).asIdSet()).containsExactlyElementsIn(ids(2));
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
+    assertThat(search("foo", searchers, AccountResolverTest::allVisiblePredicate).asIdSet())
+        .containsExactlyElementsIn(ids(2));
     // No info about inactive results exposed if there was at least one active result.
     assertThat(filteredInactiveIds(result)).isEmpty();
   }
@@ -199,7 +204,7 @@
     searcher2.setCallerShouldFilterOutInactiveCandidates();
     ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher1, searcher2);
 
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.asIdSet()).isEmpty();
     assertThat(filteredInactiveIds(result)).containsExactlyElementsIn(ids(1, 2));
   }
@@ -217,7 +222,7 @@
 
     // searcher1 matched, but filtered out all candidates because account2 is inactive. Actual
     // result came from searcher2 instead.
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.asIdSet()).containsExactlyElementsIn(ids(1, 2));
   }
 
@@ -233,7 +238,7 @@
 
     // searcher1 matched and then filtered out all candidates because account2 is inactive, but
     // still short-circuited.
-    Result result = search("foo", searchers, allVisible());
+    Result result = search("foo", searchers, AccountResolverTest::allVisiblePredicate);
     assertThat(result.asIdSet()).isEmpty();
     assertThat(filteredInactiveIds(result)).containsExactlyElementsIn(ids(2));
   }
@@ -242,11 +247,10 @@
   public void asUniqueWithNoResults() throws Exception {
     String input = "foo";
     ImmutableList<Searcher<?>> searchers = ImmutableList.of();
-    Supplier<Predicate<AccountState>> visibilitySupplier = allVisible();
     UnresolvableAccountException thrown =
         assertThrows(
             UnresolvableAccountException.class,
-            () -> search(input, searchers, visibilitySupplier).asUnique());
+            () -> search(input, searchers, AccountResolverTest::allVisiblePredicate).asUnique());
     assertThat(thrown).hasMessageThat().isEqualTo("Account 'foo' not found");
   }
 
@@ -255,7 +259,11 @@
     AccountState account = newAccount(1);
     ImmutableList<Searcher<?>> searchers =
         ImmutableList.of(new TestSearcher("foo", false, account));
-    assertThat(search("foo", searchers, allVisible()).asUnique().account().id())
+    assertThat(
+            search("foo", searchers, AccountResolverTest::allVisiblePredicate)
+                .asUnique()
+                .account()
+                .id())
         .isEqualTo(account.account().id());
   }
 
@@ -266,7 +274,7 @@
     UnresolvableAccountException thrown =
         assertThrows(
             UnresolvableAccountException.class,
-            () -> search("foo", searchers, allVisible()).asUnique());
+            () -> search("foo", searchers, AccountResolverTest::allVisiblePredicate).asUnique());
     assertThat(thrown)
         .hasMessageThat()
         .isEqualTo(
@@ -278,7 +286,7 @@
     AccountResolver resolver = newAccountResolver();
     assertThat(
             new UnresolvableAccountException(
-                resolver.new Result("foo", ImmutableList.of(), ImmutableList.of())))
+                resolver.new Result("foo", ImmutableList.of(), ImmutableList.of(), user)))
         .hasMessageThat()
         .isEqualTo("Account 'foo' not found");
   }
@@ -288,7 +296,7 @@
     AccountResolver resolver = newAccountResolver();
     UnresolvableAccountException e =
         new UnresolvableAccountException(
-            resolver.new Result("self", ImmutableList.of(), ImmutableList.of()));
+            resolver.new Result("self", ImmutableList.of(), ImmutableList.of(), user));
     assertThat(e.isSelf()).isTrue();
     assertThat(e).hasMessageThat().isEqualTo("Resolving account 'self' requires login");
   }
@@ -298,7 +306,7 @@
     AccountResolver resolver = newAccountResolver();
     UnresolvableAccountException e =
         new UnresolvableAccountException(
-            resolver.new Result("me", ImmutableList.of(), ImmutableList.of()));
+            resolver.new Result("me", ImmutableList.of(), ImmutableList.of(), user));
     assertThat(e.isSelf()).isTrue();
     assertThat(e).hasMessageThat().isEqualTo("Resolving account 'me' requires login");
   }
@@ -310,7 +318,10 @@
             new UnresolvableAccountException(
                 resolver
                 .new Result(
-                    "foo", ImmutableList.of(newAccount(3), newAccount(1)), ImmutableList.of())))
+                    "foo",
+                    ImmutableList.of(newAccount(3), newAccount(1)),
+                    ImmutableList.of(),
+                    user)))
         .hasMessageThat()
         .isEqualTo(
             "Account 'foo' is ambiguous (at most 3 shown):\n1: Anonymous Name (1)\n3: Anonymous Name (3)");
@@ -325,7 +336,8 @@
                 .new Result(
                     "foo",
                     ImmutableList.of(),
-                    ImmutableList.of(newInactiveAccount(3), newInactiveAccount(1)))))
+                    ImmutableList.of(newInactiveAccount(3), newInactiveAccount(1)),
+                    user)))
         .hasMessageThat()
         .isEqualTo(
             "Account 'foo' only matches inactive accounts. To use an inactive account, retry"
@@ -339,7 +351,7 @@
       ImmutableList<Searcher<?>> searchers,
       Supplier<Predicate<AccountState>> visibilitySupplier)
       throws Exception {
-    return search(input, searchers, visibilitySupplier, activityPrediate());
+    return search(input, searchers, visibilitySupplier, AccountResolverTest::isActive);
   }
 
   private Result search(
@@ -348,22 +360,23 @@
       Supplier<Predicate<AccountState>> visibilitySupplier,
       Predicate<AccountState> activityPredicate)
       throws Exception {
-    return newAccountResolver().searchImpl(input, searchers, visibilitySupplier, activityPredicate);
+    return newAccountResolver()
+        .searchImpl(input, searchers, user, visibilitySupplier, activityPredicate);
   }
 
-  private static AccountResolver newAccountResolver() {
+  private AccountResolver newAccountResolver() {
     return new AccountResolver(null, null, null, null, null, null, null, null, "Anonymous Name");
   }
 
   private AccountState newAccount(int id) {
     return AccountState.forAccount(
-        Account.builder(Account.id(id), TimeUtil.nowTs())
+        Account.builder(Account.id(id), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build());
   }
 
   private AccountState newInactiveAccount(int id) {
-    Account.Builder a = Account.builder(Account.id(id), TimeUtil.nowTs());
+    Account.Builder a = Account.builder(Account.id(id), TimeUtil.now());
     a.setActive(false);
     return AccountState.forAccount(a.build());
   }
@@ -372,12 +385,17 @@
     return Arrays.stream(ids).mapToObj(Account::id).collect(toImmutableSet());
   }
 
-  private static Supplier<Predicate<AccountState>> allVisible() {
-    return () -> a -> true;
+  private static Predicate<AccountState> allVisiblePredicate() {
+    return AccountResolverTest::allVisible;
   }
 
-  private Predicate<AccountState> activityPrediate() {
-    return (AccountState accountState) -> accountState.account().isActive();
+  /** @param accountState account state for which the visibility should be checked */
+  private static boolean allVisible(AccountState accountState) {
+    return true;
+  }
+
+  private static boolean isActive(AccountState accountState) {
+    return accountState.account().isActive();
   }
 
   private static Supplier<Predicate<AccountState>> only(int... ids) {
diff --git a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
index 1381c75..1a7d1fb 100644
--- a/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
+++ b/javatests/com/google/gerrit/server/account/AuthorizedKeysTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.base.Splitter;
 import com.google.gerrit.entities.Account;
 import java.util.ArrayList;
 import java.util.List;
@@ -125,18 +126,22 @@
   public void getters() throws Exception {
     AccountSshKey key = AccountSshKey.create(accountId, 1, KEY1);
     assertThat(key.sshPublicKey()).isEqualTo(KEY1);
-    assertThat(key.algorithm()).isEqualTo(KEY1.split(" ")[0]);
-    assertThat(key.encodedKey()).isEqualTo(KEY1.split(" ")[1]);
-    assertThat(key.comment()).isEqualTo(KEY1.split(" ")[2]);
+
+    List<String> keyParts = Splitter.on(" ").splitToList(KEY1);
+    assertThat(key.algorithm()).isEqualTo(keyParts.get(0));
+    assertThat(key.encodedKey()).isEqualTo(keyParts.get(1));
+    assertThat(key.comment()).isEqualTo(keyParts.get(2));
   }
 
   @Test
   public void keyWithNewLines() throws Exception {
     AccountSshKey key = AccountSshKey.create(accountId, 1, KEY1_WITH_NEWLINES);
     assertThat(key.sshPublicKey()).isEqualTo(KEY1);
-    assertThat(key.algorithm()).isEqualTo(KEY1.split(" ")[0]);
-    assertThat(key.encodedKey()).isEqualTo(KEY1.split(" ")[1]);
-    assertThat(key.comment()).isEqualTo(KEY1.split(" ")[2]);
+
+    List<String> keyParts = Splitter.on(" ").splitToList(KEY1);
+    assertThat(key.algorithm()).isEqualTo(keyParts.get(0));
+    assertThat(key.encodedKey()).isEqualTo(keyParts.get(1));
+    assertThat(key.comment()).isEqualTo(keyParts.get(2));
   }
 
   private static String toWindowsLineEndings(String s) {
diff --git a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
index 1e3063e..5f062be 100644
--- a/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
+++ b/javatests/com/google/gerrit/server/account/UniversalGroupBackendTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountGroup.UUID;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
@@ -56,7 +57,8 @@
     backends.add("gerrit", new SystemGroupBackend(new Config()));
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
   }
 
   @Test
@@ -124,7 +126,8 @@
     backends.add("gerrit", backend);
     backend =
         new UniversalGroupBackend(
-            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE));
+            new PluginSetContext<>(backends, PluginMetrics.DISABLED_INSTANCE),
+            new DisabledMetricMaker());
 
     GroupMembership checker = backend.membershipsOf(member);
     assertFalse(checker.contains(REGISTERED_USERS));
diff --git a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
index 7d9db0b..eb2133e 100644
--- a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
@@ -25,16 +25,20 @@
 import com.google.gerrit.server.account.externalids.AllExternalIds.Serializer;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
 import java.util.Arrays;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
+import org.mockito.Mock;
 
 public class AllExternalIdsTest {
   private ExternalIdFactory externalIdFactory;
 
+  @Mock AuthConfig authConfig;
+
   @Before
   public void setUp() throws Exception {
     externalIdFactory =
@@ -45,7 +49,8 @@
                   public boolean isUserNameCaseInsensitive() {
                     return false;
                   }
-                }));
+                }),
+            authConfig);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
index 4f8c559..8f2d613 100644
--- a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -28,11 +28,11 @@
 import com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.util.function.Consumer;
 import org.eclipse.jgit.lib.Config;
@@ -44,6 +44,7 @@
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.Mock;
 import org.mockito.Mockito;
 import org.mockito.junit.MockitoJUnitRunner;
 
@@ -58,24 +59,18 @@
   private ExternalIdReader externalIdReaderSpy;
 
   private ExternalIdFactory externalIdFactory;
+  @Mock private AuthConfig authConfig;
 
   @Before
   public void setUp() throws Exception {
-    externalIdFactory =
-        new ExternalIdFactory(
-            new ExternalIdKeyFactory(
-                new ExternalIdKeyFactory.Config() {
-                  @Override
-                  public boolean isUserNameCaseInsensitive() {
-                    return false;
-                  }
-                }));
+    externalIdFactory = new ExternalIdFactory(new ExternalIdKeyFactory(() -> false), authConfig);
     externalIdCache = CacheBuilder.newBuilder().build();
     repoManager.createRepository(ALL_USERS).close();
     externalIdReader =
-        new ExternalIdReader(repoManager, ALL_USERS, new DisabledMetricMaker(), externalIdFactory);
+        new ExternalIdReader(
+            repoManager, ALL_USERS, new DisabledMetricMaker(), externalIdFactory, authConfig);
     externalIdReaderSpy = Mockito.spy(externalIdReader);
-    loader = createLoader(true);
+    loader = createLoader();
   }
 
   @Test
@@ -92,7 +87,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -104,7 +100,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -117,16 +114,6 @@
   }
 
   @Test
-  public void partialReloadingDisabledAlwaysTriggersFullReload() throws Exception {
-    loader = createLoader(false);
-    insertExternalId(1, 1);
-    ObjectId head = insertExternalId(2, 2);
-
-    assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verify(externalIdReaderSpy, times(1)).all(head);
-  }
-
-  @Test
   public void fallsBackToFullReloadOnManyUpdatesOnBranch() throws Exception {
     insertExternalId(1, 1);
     ObjectId head = null;
@@ -154,7 +141,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -169,7 +157,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -186,7 +175,8 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -199,7 +189,8 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -213,19 +204,18 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyNoInteractions(externalIdReaderSpy);
+    verify(externalIdReaderSpy, times(1)).checkReadEnabled();
+    verifyNoMoreInteractions(externalIdReaderSpy);
   }
 
-  private ExternalIdCacheLoader createLoader(boolean allowPartial) {
-    Config cfg = new Config();
-    cfg.setBoolean("cache", "external_ids_map", "enablePartialReloads", allowPartial);
+  private ExternalIdCacheLoader createLoader() {
     return new ExternalIdCacheLoader(
         repoManager,
         ALL_USERS,
         externalIdReaderSpy,
-        Providers.of(externalIdCache),
+        externalIdCache,
         new DisabledMetricMaker(),
-        cfg,
+        new Config(),
         externalIdFactory);
   }
 
@@ -276,8 +266,7 @@
   private ObjectId performExternalIdUpdate(Consumer<ExternalIdNotes> update) throws Exception {
     try (Repository repo = repoManager.openRepository(ALL_USERS)) {
       PersonIdent updater = new PersonIdent("Foo bar", "foo@bar.com");
-      ExternalIdNotes extIdNotes =
-          ExternalIdNotes.loadNoCacheUpdate(ALL_USERS, repo, externalIdFactory);
+      ExternalIdNotes extIdNotes = ExternalIdNotes.load(ALL_USERS, repo, externalIdFactory, false);
       update.accept(extIdNotes);
       try (MetaDataUpdate metaDataUpdate =
           new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index b3b2f5a..275f2ec 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:junit",
         "//lib/truth",
+        "//lib/truth:truth-java8-extension",
     ],
 )
 
diff --git a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
index 2264612..fb9a375 100644
--- a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
+++ b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
@@ -57,15 +57,13 @@
     }
 
     @Override
-    public <K, V> com.google.common.cache.Cache<K, V> build(
-        PersistentCacheDef<K, V> def, CacheBackend backend) {
-      return memoryCacheFactory.build(def, backend);
+    public <K, V> com.google.common.cache.Cache<K, V> build(PersistentCacheDef<K, V> def) {
+      return memoryCacheFactory.build(def);
     }
 
     @Override
-    public <K, V> LoadingCache<K, V> build(
-        PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
-      return memoryCacheFactory.build(def, loader, backend);
+    public <K, V> LoadingCache<K, V> build(PersistentCacheDef<K, V> def, CacheLoader<K, V> loader) {
+      return memoryCacheFactory.build(def, loader);
     }
 
     @Override
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 14af43b..16fd4ca 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -45,7 +45,7 @@
 import org.junit.Test;
 
 public class H2CacheTest {
-  private static final TypeLiteral<String> KEY_TYPE = new TypeLiteral<String>() {};
+  private static final TypeLiteral<String> KEY_TYPE = new TypeLiteral<>() {};
   private static final int DEFAULT_VERSION = 1234;
   private static int dbCnt;
 
diff --git a/javatests/com/google/gerrit/server/cache/mem/BUILD b/javatests/com/google/gerrit/server/cache/mem/BUILD
new file mode 100644
index 0000000..a263c7b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/mem/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "tests",
+    srcs = glob(["*Test.java"]),
+    deps = [
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cache/mem",
+        "//lib:jgit",
+        "//lib:junit",
+        "//lib/guice",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
new file mode 100644
index 0000000..5958465
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
@@ -0,0 +1,203 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache.mem;
+
+import static com.google.common.base.Functions.identity;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.server.cache.CacheDef;
+import com.google.inject.TypeLiteral;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BrokenBarrierException;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class DefaultMemoryCacheFactoryTest {
+
+  private static final String TEST_CACHE = "test-cache";
+  private static final long TEST_TIMEOUT_SEC = 1;
+  private static final int TEST_CACHE_KEY = 1;
+
+  private DefaultMemoryCacheFactory memoryCacheFactory;
+  private Config memoryCacheConfig;
+  private ScheduledExecutorService executor;
+  private CyclicBarrier cacheGetStarted;
+  private CyclicBarrier cacheGetCompleted;
+
+  @Before
+  public void setUp() {
+    memoryCacheConfig = new Config();
+    memoryCacheFactory = new DefaultMemoryCacheFactory(memoryCacheConfig, null);
+    executor = Executors.newScheduledThreadPool(1);
+    cacheGetStarted = new CyclicBarrier(2);
+    cacheGetCompleted = new CyclicBarrier(2);
+  }
+
+  @Test
+  public void shouldNotBlockEvictionsWhenCacheIsDisabledByDefault() throws Exception {
+    LoadingCache<Integer, Integer> disabledCache =
+        memoryCacheFactory.build(newCacheDef(0), newCacheLoader(identity()));
+
+    assertCacheEvictionIsNotBlocking(disabledCache);
+  }
+
+  @Test
+  public void shouldNotBlockEvictionsWhenCacheIsDisabledByConfiguration() throws Exception {
+    memoryCacheConfig.setInt("cache", TEST_CACHE, "memoryLimit", 0);
+    LoadingCache<Integer, Integer> disabledCache =
+        memoryCacheFactory.build(newCacheDef(1), newCacheLoader(identity()));
+
+    assertCacheEvictionIsNotBlocking(disabledCache);
+  }
+
+  @Test
+  public void shouldBlockEvictionsWhenCacheIsEnabled() throws Exception {
+    LoadingCache<Integer, Integer> cache =
+        memoryCacheFactory.build(newCacheDef(1), newCacheLoader(identity()));
+
+    ScheduledFuture<Integer> cacheValue =
+        executor.schedule(() -> cache.getUnchecked(TEST_CACHE_KEY), 0, TimeUnit.SECONDS);
+
+    cacheGetStarted.await(TEST_TIMEOUT_SEC, TimeUnit.SECONDS);
+    cache.invalidate(TEST_CACHE_KEY);
+
+    assertThat(cacheValue.isDone()).isTrue();
+    assertThat(cacheValue.get()).isEqualTo(TEST_CACHE_KEY);
+  }
+
+  @Test
+  public void shouldLoadAllKeysWithDisabledCache() throws Exception {
+    LoadingCache<Integer, Integer> disabledCache =
+        memoryCacheFactory.build(newCacheDef(0), newCacheLoader(identity()));
+
+    List<Integer> keys = Arrays.asList(1, 2);
+    ImmutableMap<Integer, Integer> entries = disabledCache.getAll(keys);
+
+    assertThat(entries).containsExactly(1, 1, 2, 2);
+  }
+
+  private void assertCacheEvictionIsNotBlocking(LoadingCache<Integer, Integer> disabledCache)
+      throws InterruptedException, BrokenBarrierException, TimeoutException, ExecutionException {
+    ScheduledFuture<Integer> cacheValue =
+        executor.schedule(() -> disabledCache.getUnchecked(TEST_CACHE_KEY), 0, TimeUnit.SECONDS);
+    cacheGetStarted.await(TEST_TIMEOUT_SEC, TimeUnit.SECONDS);
+    disabledCache.invalidate(TEST_CACHE_KEY);
+
+    // The invalidate did not wait for the cache loader to finish, therefore the cacheValue isn't
+    // done yet
+    assertThat(cacheValue.isDone()).isFalse();
+
+    // The cache loader completes after the invalidation
+    cacheGetCompleted.await(TEST_TIMEOUT_SEC, TimeUnit.SECONDS);
+    assertThat(cacheValue.get()).isEqualTo(TEST_CACHE_KEY);
+  }
+
+  private CacheLoader<Integer, Integer> newCacheLoader(Function<Integer, Integer> loadFunc) {
+    return new CacheLoader<>() {
+
+      @Override
+      public Integer load(Integer n) throws Exception {
+        Integer v = 0;
+        try {
+          cacheGetStarted.await(TEST_TIMEOUT_SEC, TimeUnit.SECONDS);
+          v = loadFunc.apply(n);
+          cacheGetCompleted.await(TEST_TIMEOUT_SEC, TimeUnit.SECONDS);
+        } catch (TimeoutException | BrokenBarrierException e) {
+          // Just continue
+        }
+        return v;
+      }
+
+      @Override
+      public Map<Integer, Integer> loadAll(Iterable<? extends Integer> keys) throws Exception {
+        return StreamSupport.stream(keys.spliterator(), false)
+            .collect(Collectors.toMap(identity(), identity()));
+      }
+    };
+  }
+
+  private CacheDef<Integer, Integer> newCacheDef(long maximumWeight) {
+    return new CacheDef<>() {
+
+      @Override
+      public String name() {
+        return TEST_CACHE;
+      }
+
+      @Override
+      public String configKey() {
+        return TEST_CACHE;
+      }
+
+      @Override
+      public TypeLiteral<Integer> keyType() {
+        return null;
+      }
+
+      @Override
+      public TypeLiteral<Integer> valueType() {
+        return null;
+      }
+
+      @Override
+      public long maximumWeight() {
+        return maximumWeight;
+      }
+
+      @Override
+      public Duration expireAfterWrite() {
+        return null;
+      }
+
+      @Override
+      public Duration expireFromMemoryAfterAccess() {
+        return null;
+      }
+
+      @Override
+      public Duration refreshAfterWrite() {
+        return null;
+      }
+
+      @Override
+      public Weigher<Integer, Integer> weigher() {
+        return null;
+      }
+
+      @Override
+      public CacheLoader<Integer, Integer> loader() {
+        return null;
+      }
+    };
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
index 8f5b215..3d0e508 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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;
 
 import static com.google.common.truth.Truth.assertThat;
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
index 8df9292..d68a5c1 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -16,5 +16,6 @@
         "//lib/truth:truth-proto-extension",
         "//proto:cache_java_proto",
         "//proto/testing:test_java_proto",
+        "@gson//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
index c7e09dc..bf8a071 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
@@ -52,7 +52,7 @@
           .addNotifySection(NotifyConfigSerializerTest.ALL_VALUES_SET)
           .addLabelSection(LabelTypeSerializerTest.ALL_VALUES_SET)
           .addSubscribeSection(SubscribeSectionSerializerTest.ALL_VALUES_SET)
-          .addCommentLinkSection(StoredCommentLinkInfoSerializerTest.HTML_ONLY)
+          .addCommentLinkSection(StoredCommentLinkInfoSerializerTest.LINK_ONLY)
           .setRevision(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
           .setRulesId(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
           .setExtensionPanelSections(ImmutableMap.of("key1", ImmutableList.of("val1", "val2")))
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
index c5e8574..00272112 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -18,6 +18,7 @@
 
 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.ComparisonType;
 import com.google.gerrit.server.patch.filediff.Edit;
@@ -42,6 +43,8 @@
             .comparisonType(ComparisonType.againstOtherPatchSet())
             .oldPath(Optional.of("old_file_path.txt"))
             .newPath(Optional.empty())
+            .oldMode(Optional.of(FileMode.REGULAR_FILE))
+            .newMode(Optional.of(FileMode.SYMLINK))
             .changeType(ChangeType.DELETED)
             .patchType(Optional.of(PatchType.UNIFIED))
             .size(23)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
index d7fdfe6..2eae1bf 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
index caf1fbb..0eb4103 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
index 8d301e4..78947a2 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/InternalGroupSerializerTest.java
@@ -34,7 +34,7 @@
           .setOwnerGroupUUID(AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
           .setVisibleToAll(false)
           .setGroupUUID(AccountGroup.uuid("deadbeefdeadbeefdeadbeefdeadbeef12345678"))
-          .setCreatedOn(TimeUtil.nowTs())
+          .setCreatedOn(TimeUtil.now())
           .setMembers(ImmutableSet.of(Account.id(123), Account.id(321)))
           .setSubgroups(
               ImmutableSet.of(
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 614dcf0..872ced9 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
+import java.util.Optional;
 import org.junit.Test;
 
 public class LabelTypeSerializerTest {
@@ -30,23 +31,13 @@
               ImmutableList.of(
                   LabelValue.create((short) 0, "no vote"),
                   LabelValue.create((short) 1, "approved")))
+          .setDescription(Optional.of("description"))
           .setCanOverride(!LabelType.DEF_CAN_OVERRIDE)
           .setAllowPostSubmit(!LabelType.DEF_ALLOW_POST_SUBMIT)
           .setIgnoreSelfApproval(!LabelType.DEF_IGNORE_SELF_APPROVAL)
           .setRefPatterns(ImmutableList.of("refs/heads/*", "refs/tags/*"))
           .setDefaultValue((short) 1)
           .setCopyCondition("is:ANY")
-          .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
-          .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
-          .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
-          .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)
-          .setCopyAllScoresIfNoCodeChange(!LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
-          .setCopyAllScoresIfNoChange(!LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
-          .setCopyValues(ImmutableList.of((short) 0, (short) 1))
           .setMaxNegative((short) -1)
           .setMaxPositive((short) 1)
           .build();
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
index b39ba57..97d152b 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
index bff0c5d..38a2050 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
@@ -1,16 +1,16 @@
-//  Copyright (C) 2020 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.cache.serialize.entities;
 
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
index 29fd5ed..1f725f8 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
@@ -40,6 +40,9 @@
           .setBooleanConfig(
               BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
               InheritableBoolean.INHERIT)
+          .setBooleanConfig(
+              BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS,
+              InheritableBoolean.TRUE)
           .build();
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
index e293493..aa6cfef 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
@@ -22,27 +22,16 @@
 import org.junit.Test;
 
 public class StoredCommentLinkInfoSerializerTest {
-  static final StoredCommentLinkInfo HTML_ONLY =
+  static final StoredCommentLinkInfo LINK_ONLY =
       StoredCommentLinkInfo.builder("name")
           .setEnabled(true)
-          .setHtml("<p>html")
+          .setLink("a.com/b.html")
           .setMatch("*")
           .build();
 
   @Test
-  public void htmlOnly_roundTrip() {
-    assertThat(deserialize(serialize(HTML_ONLY))).isEqualTo(HTML_ONLY);
-  }
-
-  @Test
   public void linkOnly_roundTrip() {
-    StoredCommentLinkInfo autoValue =
-        StoredCommentLinkInfo.builder("name")
-            .setEnabled(true)
-            .setLink("<p>html")
-            .setMatch("*")
-            .build();
-    assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+    assertThat(deserialize(serialize(LINK_ONLY))).isEqualTo(LINK_ONLY);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.java
new file mode 100644
index 0000000..93f18d6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializerTest.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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+import java.util.Optional;
+import org.junit.Test;
+
+public class SubmitRequirementExpressionResultSerializerTest {
+  private static final SubmitRequirementExpressionResult r1 =
+      SubmitRequirementExpressionResult.create(
+          SubmitRequirementExpression.create("label:Code-Review=+2"),
+          Status.PASS,
+          ImmutableList.of("Label:Code-Review=+2"),
+          ImmutableList.of());
+
+  private static final SubmitRequirementExpressionResult r2 =
+      SubmitRequirementExpressionResult.create(
+          SubmitRequirementExpression.create("label:Code-Review=+2"),
+          Status.ERROR,
+          ImmutableList.of(),
+          ImmutableList.of(),
+          Optional.of("Failed to parse the code review label"));
+
+  @Test
+  public void roundTrip_withoutError() throws Exception {
+    assertThat(deserialize(serialize(r1))).isEqualTo(r1);
+  }
+
+  @Test
+  public void roundTrip_withErrorMessage() throws Exception {
+    assertThat(deserialize(serialize(r2))).isEqualTo(r2);
+  }
+
+  @Test
+  public void deserializeUnknownStatus() throws Exception {
+    SubmitRequirementExpressionResultProto proto =
+        serialize(r1).toBuilder().setStatus("unknown").build();
+    assertThat(deserialize(proto).status())
+        .isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
new file mode 100644
index 0000000..7e71a3e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementJsonSerializerTest.java
@@ -0,0 +1,315 @@
+// 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 com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.notedb.ChangeNoteJson;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class SubmitRequirementJsonSerializerTest {
+  private static final SubmitRequirementExpression srReqExp =
+      SubmitRequirementExpression.create("label:Code-Review=+2");
+
+  private static final String srReqExpSerial = "{\"expressionString\":\"label:Code-Review=+2\"}";
+
+  private static final SubmitRequirement sr =
+      SubmitRequirement.builder()
+          .setName("CR")
+          .setDescription(Optional.of("CR description"))
+          .setApplicabilityExpression(SubmitRequirementExpression.of("branch:refs/heads/master"))
+          .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
+          .setAllowOverrideInChildProjects(true)
+          .build();
+
+  private static final String srSerial =
+      "{\"name\":\"CR\","
+          + "\"description\":{\"value\":\"CR description\"},"
+          + "\"applicabilityExpression\":{\"value\":"
+          + "{\"expressionString\":\"branch:refs/heads/master\"}},"
+          + "\"submittabilityExpression\":{"
+          + "\"expressionString\":\"label:Code-Review=+2\"},"
+          + "\"overrideExpression\":{\"value\":null},"
+          + "\"allowOverrideInChildProjects\":true}";
+
+  private static final SubmitRequirementExpressionResult srExpResult =
+      SubmitRequirementExpressionResult.create(
+          SubmitRequirementExpression.create("label:Code-Review=MAX AND -label:Code-Review=MIN"),
+          Status.FAIL,
+          /* passingAtoms= */ ImmutableList.of("label:Code-Review=MAX"),
+          /* failingAtoms= */ ImmutableList.of("label:Code-Review=MIN"));
+
+  private static final String srExpResultSerial =
+      "{\"expression\":{\"expressionString\":"
+          + "\"label:Code-Review=MAX AND -label:Code-Review=MIN\"},"
+          + "\"status\":\"FAIL\","
+          + "\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[\"label:Code-Review=MAX\"],"
+          + "\"failingAtoms\":[\"label:Code-Review=MIN\"]}";
+
+  private static final SubmitRequirementResult srReqResult =
+      SubmitRequirementResult.builder()
+          .submitRequirement(
+              SubmitRequirement.builder()
+                  .setName("CR")
+                  .setDescription(Optional.of("CR Description"))
+                  .setApplicabilityExpression(
+                      SubmitRequirementExpression.of("branch:refs/heads/master"))
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create("label:\"Code-Review=+2\""))
+                  .setOverrideExpression(SubmitRequirementExpression.of("label:Override=+1"))
+                  .setAllowOverrideInChildProjects(false)
+                  .build())
+          .patchSetCommitId(ObjectId.fromString("4663ab9e9eb49a214e68e60f0fe5d0b6f44f763e"))
+          .applicabilityExpressionResult(
+              Optional.of(
+                  SubmitRequirementExpressionResult.create(
+                      SubmitRequirementExpression.create("branch:refs/heads/master"),
+                      Status.PASS,
+                      ImmutableList.of("refs/heads/master"),
+                      ImmutableList.of())))
+          .submittabilityExpressionResult(
+              SubmitRequirementExpressionResult.create(
+                  SubmitRequirementExpression.create("label:\"Code-Review=+2\""),
+                  Status.PASS,
+                  /* passingAtoms= */ ImmutableList.of("label:\"Code-Review=+2\""),
+                  /* failingAtoms= */ ImmutableList.of()))
+          .overrideExpressionResult(
+              Optional.of(
+                  SubmitRequirementExpressionResult.create(
+                      SubmitRequirementExpression.create("label:Override=+1"),
+                      Status.PASS,
+                      /* passingAtoms= */ ImmutableList.of(),
+                      /* failingAtoms= */ ImmutableList.of("label:Override=+1"))))
+          .legacy(Optional.of(true))
+          .build();
+
+  private static final String srReqResultSerial =
+      "{\"submitRequirement\":{\"name\":\"CR\",\"description\":{\"value\":\"CR Description\"},"
+          + "\"applicabilityExpression\":{\"value\":{"
+          + "\"expressionString\":\"branch:refs/heads/master\"}},"
+          + "\"submittabilityExpression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+          + "\"overrideExpression\":{\"value\":{\"expressionString\":\"label:Override=+1\"}},"
+          + "\"allowOverrideInChildProjects\":false},"
+          + "\"applicabilityExpressionResult\":{\"value\":{"
+          + "\"expression\":{\"expressionString\":\"branch:refs/heads/master\"},"
+          + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[\"refs/heads/master\"],"
+          + "\"failingAtoms\":[]}},"
+          + "\"submittabilityExpressionResult\":{\"value\":{"
+          + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+          + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+          + "\"failingAtoms\":[]}},"
+          + "\"overrideExpressionResult\":{\"value\":{"
+          + "\"expression\":{\"expressionString\":\"label:Override=+1\"},"
+          + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+          + "\"passingAtoms\":[],"
+          + "\"failingAtoms\":[\"label:Override=+1\"]}},"
+          + "\"patchSetCommitId\":\"4663ab9e9eb49a214e68e60f0fe5d0b6f44f763e\","
+          + "\"legacy\":{\"value\":true},"
+          + "\"forced\":{\"value\":null},"
+          + "\"hidden\":{\"value\":null}}";
+
+  private static final Gson gson = new ChangeNoteJson().getGson();
+
+  @Test
+  public void submitRequirementExpression_serialize() {
+    assertThat(SubmitRequirementExpression.typeAdapter(gson).toJson(srReqExp))
+        .isEqualTo(srReqExpSerial);
+  }
+
+  @Test
+  public void submitRequirementExpression_deserialize() throws Exception {
+    assertThat(SubmitRequirementExpression.typeAdapter(gson).fromJson(srReqExpSerial))
+        .isEqualTo(srReqExp);
+  }
+
+  @Test
+  public void submitRequirementExpression_roundTrip() throws Exception {
+    SubmitRequirementExpression exp = SubmitRequirementExpression.create("label:Code-Review=+2");
+    TypeAdapter<SubmitRequirementExpression> adapter =
+        SubmitRequirementExpression.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(exp))).isEqualTo(exp);
+  }
+
+  @Test
+  public void submitRequirement_serialize() throws Exception {
+    assertThat(SubmitRequirement.typeAdapter(gson).toJson(sr)).isEqualTo(srSerial);
+  }
+
+  @Test
+  public void submitRequirement_deserialize() throws Exception {
+    assertThat(SubmitRequirement.typeAdapter(gson).fromJson(srSerial)).isEqualTo(sr);
+  }
+
+  @Test
+  public void submitRequirement_roundTrip() throws Exception {
+    TypeAdapter<SubmitRequirement> adapter = SubmitRequirement.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(sr))).isEqualTo(sr);
+  }
+
+  @Test
+  public void submitRequirementExpressionResult_serialize() throws Exception {
+    assertThat(SubmitRequirementExpressionResult.typeAdapter(gson).toJson(srExpResult))
+        .isEqualTo(srExpResultSerial);
+  }
+
+  @Test
+  public void submitRequirementExpressionResult_deserialize() throws Exception {
+    assertThat(SubmitRequirementExpressionResult.typeAdapter(gson).fromJson(srExpResultSerial))
+        .isEqualTo(srExpResult);
+  }
+
+  @Test
+  public void submitRequirementExpressionResult_roundtrip() throws Exception {
+    TypeAdapter<SubmitRequirementExpressionResult> adapter =
+        SubmitRequirementExpressionResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srExpResult))).isEqualTo(srExpResult);
+  }
+
+  @Test
+  public void submitRequirementExpressionResult_deserializeUnrecognizedStatus() throws Exception {
+    // If the status field has an unrecognized value while deserialization, we set the status field
+    // to ERROR.
+    String serial = srExpResultSerial.replace("FAIL", "UNKNOWN");
+    SubmitRequirementExpressionResult entity =
+        srExpResult.toBuilder().status(SubmitRequirementExpressionResult.Status.ERROR).build();
+    TypeAdapter<SubmitRequirementExpressionResult> adapter =
+        SubmitRequirementExpressionResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(serial)).isEqualTo(entity);
+  }
+
+  @Test
+  public void submitRequirementResult_serialize() throws Exception {
+    assertThat(SubmitRequirementResult.typeAdapter(gson).toJson(srReqResult))
+        .isEqualTo(srReqResultSerial);
+  }
+
+  @Test
+  public void submitRequirementResult_deserialize_optionalSubmittabilityExpressionResultField()
+      throws Exception {
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(srReqResultSerial))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_deserialize_nonOptionalSubmittabilityExpressionResultField()
+      throws Exception {
+    String oldFormatSrReqResultSerial =
+        srReqResultSerial.replace(
+            "\"submittabilityExpressionResult\":{\"value\":{"
+                + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+                + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+                + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+                + "\"failingAtoms\":[]}},",
+            "\"submittabilityExpressionResult\":{"
+                + "\"expression\":{\"expressionString\":\"label:\\\"Code-Review=+2\\\"\"},"
+                + "\"status\":\"PASS\",\"errorMessage\":{\"value\":null},"
+                + "\"passingAtoms\":[\"label:\\\"Code-Review=+2\\\"\"],"
+                + "\"failingAtoms\":[]},");
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(oldFormatSrReqResultSerial))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_roundTrip() throws Exception {
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srReqResult))).isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_withHidden_roundTrip() throws Exception {
+    SubmitRequirementResult srResultWithHidden =
+        srReqResult.toBuilder().hidden(Optional.of(true)).build();
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srResultWithHidden))).isEqualTo(srResultWithHidden);
+  }
+
+  @Test
+  public void submitRequirementResult_deserializeNoHidden() throws Exception {
+    String srResultSerialMandatoryLegacyFieldFormat =
+        srReqResultSerial.replace(",hidden\":{\"value\":null}", "");
+    assertThat(
+            SubmitRequirementResult.typeAdapter(gson)
+                .fromJson(srResultSerialMandatoryLegacyFieldFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_deserializeNonExistentField() throws Exception {
+    // Tests that unrecognized fields are skipped on deserialization (e.g. when the new fields are
+    // introduced, the old binary can parse the new-format Json)
+    String srResultSerialMandatoryLegacyFieldFormat =
+        srReqResultSerial.replace(
+            "\"hidden\":{\"value\":null}}", "\"non-existent\":{\"value\":null}}");
+    assertThat(
+            SubmitRequirementResult.typeAdapter(gson)
+                .fromJson(srResultSerialMandatoryLegacyFieldFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_emptySubmittabilityExpressionResultField_roundTrip()
+      throws Exception {
+    SubmitRequirementResult srResult =
+        srReqResult
+            .toBuilder()
+            .submittabilityExpressionResult(Optional.empty())
+            .applicabilityExpressionResult(Optional.empty())
+            .overrideExpressionResult(Optional.empty())
+            .build();
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srResult))).isEqualTo(srResult);
+  }
+
+  @Test
+  public void deserializeSubmitRequirementResult_withJGitPatchsetIdFormat() throws Exception {
+    String srResultSerialJgitFormat =
+        srReqResultSerial.replace(
+            "\"4663ab9e9eb49a214e68e60f0fe5d0b6f44f763e\"",
+            "{\"w1\":1180937118,\"w2\":-1632331231,\"w3\":1315497487,"
+                + "\"w4\":266719414,\"w5\":-196118978}");
+    assertThat(SubmitRequirementResult.typeAdapter(gson).fromJson(srResultSerialJgitFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_deserializeNonOptionalLegacyField() throws Exception {
+    String srResultSerialMandatoryLegacyFieldFormat =
+        srReqResultSerial.replace("\"legacy\":{\"value\":true}", "\"legacy\":true");
+    assertThat(
+            SubmitRequirementResult.typeAdapter(gson)
+                .fromJson(srResultSerialMandatoryLegacyFieldFormat))
+        .isEqualTo(srReqResult);
+  }
+
+  @Test
+  public void submitRequirementResult_emptyLegacyField_roundTrip() throws Exception {
+    SubmitRequirementResult srResult = srReqResult.toBuilder().legacy(Optional.empty()).build();
+    TypeAdapter<SubmitRequirementResult> adapter = SubmitRequirementResult.typeAdapter(gson);
+    assertThat(adapter.fromJson(adapter.toJson(srResult))).isEqualTo(srResult);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadTest.java b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
index 08485a4..d0b6c14 100644
--- a/javatests/com/google/gerrit/server/change/CommentThreadTest.java
+++ b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class CommentThreadTest {
@@ -60,7 +60,7 @@
     return new HumanComment(
         new Comment.Key(commentUuid, "myFile", 1),
         Account.id(100),
-        new Timestamp(1234),
+        Instant.ofEpochMilli(1234),
         (short) 1,
         "Comment text",
         "serverId",
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadsTest.java b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
index 0c61906..83e8370 100644
--- a/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
+++ b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
-import java.sql.Timestamp;
+import java.time.Instant;
 import org.junit.Test;
 
 public class CommentThreadsTest {
@@ -126,13 +126,15 @@
 
   @Test
   public void branchedThreadsAreFlattenedAccordingToDate() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
-    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
-    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(3));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(1));
+    HumanComment sibling1 =
+        writtenOn(asReply(createComment("sibling1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment sibling2 =
+        writtenOn(asReply(createComment("sibling2"), "root"), Instant.ofEpochMilli(3));
     HumanComment sibling1Child =
-        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), new Timestamp(4));
+        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), Instant.ofEpochMilli(4));
     HumanComment sibling2Child =
-        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), new Timestamp(5));
+        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), Instant.ofEpochMilli(5));
 
     ImmutableList<HumanComment> comments =
         ImmutableList.of(sibling2, sibling2Child, sibling1, sibling1Child, root);
@@ -146,9 +148,11 @@
 
   @Test
   public void threadsConsiderParentRelationshipStrongerThanDate() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(3));
-    HumanComment child1 = writtenOn(asReply(createComment("child1"), "root"), new Timestamp(2));
-    HumanComment child2 = writtenOn(asReply(createComment("child2"), "child1"), new Timestamp(1));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(3));
+    HumanComment child1 =
+        writtenOn(asReply(createComment("child1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment child2 =
+        writtenOn(asReply(createComment("child2"), "child1"), Instant.ofEpochMilli(1));
 
     ImmutableList<HumanComment> comments = ImmutableList.of(child2, child1, root);
     ImmutableSet<CommentThread<HumanComment>> commentThreads =
@@ -161,9 +165,11 @@
 
   @Test
   public void threadsFallBackToUuidOrderIfParentAndDateAreTheSame() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
-    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
-    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(2));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(1));
+    HumanComment sibling1 =
+        writtenOn(asReply(createComment("sibling1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment sibling2 =
+        writtenOn(asReply(createComment("sibling2"), "root"), Instant.ofEpochMilli(2));
 
     ImmutableList<HumanComment> comments = ImmutableList.of(sibling2, sibling1, root);
     ImmutableSet<CommentThread<HumanComment>> commentThreads =
@@ -224,13 +230,15 @@
 
   @Test
   public void completeThreadWithBranchesCanBeRequestedByReplyToIntermediateComment() {
-    HumanComment root = writtenOn(createComment("root"), new Timestamp(1));
-    HumanComment sibling1 = writtenOn(asReply(createComment("sibling1"), "root"), new Timestamp(2));
-    HumanComment sibling2 = writtenOn(asReply(createComment("sibling2"), "root"), new Timestamp(3));
+    HumanComment root = writtenOn(createComment("root"), Instant.ofEpochMilli(1));
+    HumanComment sibling1 =
+        writtenOn(asReply(createComment("sibling1"), "root"), Instant.ofEpochMilli(2));
+    HumanComment sibling2 =
+        writtenOn(asReply(createComment("sibling2"), "root"), Instant.ofEpochMilli(3));
     HumanComment sibling1Child =
-        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), new Timestamp(4));
+        writtenOn(asReply(createComment("sibling1Child"), "sibling1"), Instant.ofEpochMilli(4));
     HumanComment sibling2Child =
-        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), new Timestamp(5));
+        writtenOn(asReply(createComment("sibling2Child"), "sibling2"), Instant.ofEpochMilli(5));
 
     HumanComment reply = asReply(createComment("sibling1"), "root");
 
@@ -262,7 +270,7 @@
     return new HumanComment(
         new Comment.Key(commentUuid, "myFile", 1),
         Account.id(100),
-        new Timestamp(1234),
+        Instant.ofEpochMilli(1234),
         (short) 1,
         "Comment text",
         "serverId",
@@ -274,8 +282,8 @@
     return comment;
   }
 
-  private static HumanComment writtenOn(HumanComment comment, Timestamp writtenOn) {
-    comment.writtenOn = writtenOn;
+  private static HumanComment writtenOn(HumanComment comment, Instant writtenOn) {
+    comment.setWrittenOn(writtenOn);
     return comment;
   }
 
diff --git a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
deleted file mode 100644
index b69a894..0000000
--- a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
+++ /dev/null
@@ -1,175 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.entities.RefNames.REFS_TAGS;
-
-import com.google.common.truth.Correspondence;
-import com.google.gerrit.truth.NullAwareCorrespondence;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTag;
-import org.junit.Before;
-import org.junit.Test;
-
-public class IncludedInResolverTest {
-  // Branch names
-  private static final String BRANCH_MASTER = "master";
-  private static final String BRANCH_1_0 = "rel-1.0";
-  private static final String BRANCH_1_3 = "rel-1.3";
-  private static final String BRANCH_2_0 = "rel-2.0";
-  private static final String BRANCH_2_5 = "rel-2.5";
-
-  // Tag names
-  private static final String TAG_1_0 = "1.0";
-  private static final String TAG_1_0_1 = "1.0.1";
-  private static final String TAG_1_3 = "1.3";
-  private static final String TAG_2_0_1 = "2.0.1";
-  private static final String TAG_2_0 = "2.0";
-  private static final String TAG_2_5 = "2.5";
-  private static final String TAG_2_5_ANNOTATED = "2.5-annotated";
-  private static final String TAG_2_5_ANNOTATED_TWICE = "2.5-annotated_twice";
-
-  // Commits
-  private RevCommit commit_initial;
-  private RevCommit commit_v1_3;
-  private RevCommit commit_v2_5;
-
-  private TestRepository<?> tr;
-
-  @Before
-  public void setUp() throws Exception {
-    tr = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("repo")));
-
-    /*- The following graph will be created.
-
-     o   tag 2.5, 2.5_annotated, 2.5_annotated_twice
-     |\
-     | o tag 2.0.1
-     | o tag 2.0
-     o | tag 1.3
-     |/
-     o   c3
-
-     | o tag 1.0.1
-     |/
-     o   tag 1.0
-     o   c2
-     o   c1
-
-    */
-
-    // Version 1.0
-    commit_initial = tr.branch(BRANCH_MASTER).commit().message("c1").create();
-    tr.branch(BRANCH_MASTER).commit().message("c2").create();
-    RevCommit commit_v1_0 = tr.branch(BRANCH_MASTER).commit().message("version 1.0").create();
-    tag(TAG_1_0, commit_v1_0);
-    RevCommit c3 = tr.branch(BRANCH_MASTER).commit().message("c3").create();
-
-    // Version 1.01
-    tr.branch(BRANCH_1_0).update(commit_v1_0);
-    RevCommit commit_v1_0_1 = tr.branch(BRANCH_1_0).commit().message("version 1.0.1").create();
-    tag(TAG_1_0_1, commit_v1_0_1);
-
-    // Version 1.3
-    tr.branch(BRANCH_1_3).update(c3);
-    commit_v1_3 = tr.branch(BRANCH_1_3).commit().message("version 1.3").create();
-    tag(TAG_1_3, commit_v1_3);
-
-    // Version 2.0
-    tr.branch(BRANCH_2_0).update(c3);
-    RevCommit commit_v2_0 = tr.branch(BRANCH_2_0).commit().message("version 2.0").create();
-    tag(TAG_2_0, commit_v2_0);
-    RevCommit commit_v2_0_1 = tr.branch(BRANCH_2_0).commit().message("version 2.0.1").create();
-    tag(TAG_2_0_1, commit_v2_0_1);
-
-    // Version 2.5
-    tr.branch(BRANCH_2_5).update(commit_v1_3);
-    tr.branch(BRANCH_2_5).commit().parent(commit_v2_0_1).create(); // Merge v2.0.1
-    commit_v2_5 = tr.branch(BRANCH_2_5).commit().message("version 2.5").create();
-    tr.update(REFS_TAGS + TAG_2_5, commit_v2_5);
-    RevTag tag_2_5_annotated = tag(TAG_2_5_ANNOTATED, commit_v2_5);
-    tag(TAG_2_5_ANNOTATED_TWICE, tag_2_5_annotated);
-  }
-
-  @Test
-  public void resolveLatestCommit() throws Exception {
-    // Check tip commit
-    IncludedInResolver.Result detail = resolve(commit_v2_5);
-
-    // Check that only tags and branches which refer the tip are returned
-    assertThat(detail.tags())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(TAG_2_5, TAG_2_5_ANNOTATED, TAG_2_5_ANNOTATED_TWICE);
-    assertThat(detail.branches())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(BRANCH_2_5);
-  }
-
-  @Test
-  public void resolveFirstCommit() throws Exception {
-    // Check first commit
-    IncludedInResolver.Result detail = resolve(commit_initial);
-
-    // Check whether all tags and branches are returned
-    assertThat(detail.tags())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(
-            TAG_1_0,
-            TAG_1_0_1,
-            TAG_1_3,
-            TAG_2_0,
-            TAG_2_0_1,
-            TAG_2_5,
-            TAG_2_5_ANNOTATED,
-            TAG_2_5_ANNOTATED_TWICE);
-    assertThat(detail.branches())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(BRANCH_MASTER, BRANCH_1_0, BRANCH_1_3, BRANCH_2_0, BRANCH_2_5);
-  }
-
-  @Test
-  public void resolveBetwixtCommit() throws Exception {
-    // Check a commit somewhere in the middle
-    IncludedInResolver.Result detail = resolve(commit_v1_3);
-
-    // Check whether all succeeding tags and branches are returned
-    assertThat(detail.tags())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(TAG_1_3, TAG_2_5, TAG_2_5_ANNOTATED, TAG_2_5_ANNOTATED_TWICE);
-    assertThat(detail.branches())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(BRANCH_1_3, BRANCH_2_5);
-  }
-
-  private IncludedInResolver.Result resolve(RevCommit commit) throws Exception {
-    return IncludedInResolver.resolve(tr.getRepository(), tr.getRevWalk(), commit);
-  }
-
-  private RevTag tag(String name, RevObject dest) throws Exception {
-    return tr.update(REFS_TAGS + name, tr.tag(name, dest));
-  }
-
-  private static Correspondence<Ref, String> hasShortName() {
-    return NullAwareCorrespondence.transforming(
-        ref -> Repository.shortenRefName(ref.getName()), "has short name");
-  }
-}
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 5e3be9a..6cbbd26 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -21,7 +21,7 @@
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static org.junit.Assert.assertEquals;
 
-import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
@@ -50,7 +50,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.List;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
@@ -100,7 +99,7 @@
   private void configureProject() throws Exception {
     ProjectConfig pc = loadAllProjects();
 
-    for (AccessSection sec : ImmutableList.copyOf(pc.getAccessSections())) {
+    for (AccessSection sec : pc.getAccessSections()) {
       pc.upsertAccessSection(
           sec.getName(),
           updatedSection -> {
@@ -149,7 +148,7 @@
 
     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)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   @Test
@@ -167,15 +166,15 @@
     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)));
+        Result.create(set(), set(copy(cr, 2), copy(v, 1)), set()),
+        norm.normalize(notes, set(cr, v)));
   }
 
   @Test
   public void emptyPermissionRangeKeepsResult() throws Exception {
     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)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   @Test
@@ -191,7 +190,7 @@
 
     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)));
+    assertEquals(Result.create(set(cr, v), set(), set()), norm.normalize(notes, set(cr, v)));
   }
 
   private ProjectConfig loadAllProjects() throws Exception {
@@ -205,7 +204,7 @@
   private void save(ProjectConfig pc) throws Exception {
     try (MetaDataUpdate md = metaDataUpdateFactory.create(pc.getProject().getNameKey(), user)) {
       pc.commit(md);
-      projectCache.evict(pc.getProject().getNameKey());
+      projectCache.evictAndReindex(pc.getProject().getNameKey());
     }
   }
 
@@ -213,7 +212,7 @@
     return PatchSetApproval.builder()
         .key(PatchSetApproval.key(change.currentPatchSetId(), accountId, LabelId.create(label)))
         .value(value)
-        .granted(TimeUtil.nowTs())
+        .granted(TimeUtil.now())
         .build();
   }
 
@@ -221,7 +220,7 @@
     return src.toBuilder().value(newValue).build();
   }
 
-  private static List<PatchSetApproval> list(PatchSetApproval... psas) {
-    return ImmutableList.copyOf(psas);
+  private static ImmutableSet<PatchSetApproval> set(PatchSetApproval... psas) {
+    return ImmutableSet.copyOf(psas);
   }
 }
diff --git a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
index cb6de34..7316074 100644
--- a/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
+++ b/javatests/com/google/gerrit/server/config/GitwebConfigTest.java
@@ -24,7 +24,8 @@
 
   @Test
   public void validPathSeparator() {
-    for (char c : VALID_CHARACTERS.toCharArray()) {
+    for (int i = 0; i < VALID_CHARACTERS.length(); i++) {
+      char c = VALID_CHARACTERS.charAt(i);
       assertWithMessage("valid character rejected: " + c)
           .that(GitwebConfig.isValidPathSeparator(c))
           .isTrue();
@@ -33,7 +34,8 @@
 
   @Test
   public void inalidPathSeparator() {
-    for (char c : SOME_INVALID_CHARACTERS.toCharArray()) {
+    for (int i = 0; i < SOME_INVALID_CHARACTERS.length(); i++) {
+      char c = SOME_INVALID_CHARACTERS.charAt(i);
       assertWithMessage("invalid character accepted: " + c)
           .that(GitwebConfig.isValidPathSeparator(c))
           .isFalse();
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index e0223e4..00b92b4 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -22,11 +22,13 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.Gson;
-import java.sql.Timestamp;
 import org.junit.Test;
 
 public class EventDeserializerTest {
@@ -240,6 +242,31 @@
     assertSameChangeEvent(e, orig);
   }
 
+  @Test
+  public void shouldSerializeAllProjectsToString() {
+    String allProjectsString = "foobar";
+    AllProjectsName allProjectsNameKey = new AllProjectsName(allProjectsString);
+
+    assertThat(gson.toJson(allProjectsNameKey))
+        .isEqualTo(String.format("\"%s\"", allProjectsString));
+  }
+
+  @Test
+  public void shouldSerializeAllUsersToString() {
+    String allUsersString = "foobar";
+    AllUsersName allUsersNameKey = new AllUsersName(allUsersString);
+
+    assertThat(gson.toJson(allUsersNameKey)).isEqualTo(String.format("\"%s\"", allUsersString));
+  }
+
+  @Test
+  public void shouldSerializeProjectNameKeyToString() {
+    String projectString = "foobar";
+    Project.NameKey projectNameKey = Project.nameKey(projectString);
+
+    assertThat(gson.toJson(projectNameKey)).isEqualTo(String.format("\"%s\"", projectString));
+  }
+
   private <T> Supplier<T> createSupplier(T value) {
     return Suppliers.memoize(() -> value);
   }
@@ -251,7 +278,7 @@
             Change.id(1000),
             Account.id(1000),
             BranchNameKey.create(Project.nameKey("myproject"), "mybranch"),
-            new Timestamp(System.currentTimeMillis()));
+            TimeUtil.now());
     return change;
   }
 
@@ -308,7 +335,7 @@
     a.commitMessage = "This is a test commit message";
     a.url = "http://somewhere.com";
     a.status = change.getStatus();
-    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.createdOn = change.getCreatedOn().getEpochSecond();
     a.wip = change.isWorkInProgress() ? true : null;
     a.isPrivate = change.isPrivate() ? true : null;
     return Suppliers.ofInstance(a);
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index 8e4f436..c2b67c3 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -593,6 +593,24 @@
                 .build());
   }
 
+  @Test
+  public void projectHeadUpdatedEvent() {
+    ProjectHeadUpdatedEvent event = new ProjectHeadUpdatedEvent();
+    event.projectName = PROJECT;
+    event.oldHead = "refs/heads/master";
+    event.newHead = REF;
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put("projectName", PROJECT)
+                .put("oldHead", "refs/heads/master")
+                .put("newHead", REF)
+                .put("type", "project-head-updated")
+                .put("eventCreatedOn", TS1)
+                .build());
+  }
+
   private Supplier<AccountAttribute> newAccount(String name) {
     AccountAttribute account = new AccountAttribute();
     account.name = name;
@@ -607,7 +625,7 @@
         Change.id(CHANGE_NUM),
         Account.id(9999),
         BranchNameKey.create(Project.nameKey(PROJECT), BRANCH),
-        TimeUtil.nowTs());
+        TimeUtil.now());
   }
 
   private <T> Supplier<T> createSupplier(T value) {
@@ -625,7 +643,7 @@
     a.commitMessage = COMMIT_MESSAGE;
     a.url = URL;
     a.status = change.getStatus();
-    a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.createdOn = change.getCreatedOn().getEpochSecond();
     a.wip = change.isWorkInProgress() ? true : null;
     a.isPrivate = change.isPrivate() ? true : null;
     return Suppliers.ofInstance(a);
diff --git a/javatests/com/google/gerrit/server/events/EventTypesTest.java b/javatests/com/google/gerrit/server/events/EventTypesTest.java
index c822d6c..7e97f184 100644
--- a/javatests/com/google/gerrit/server/events/EventTypesTest.java
+++ b/javatests/com/google/gerrit/server/events/EventTypesTest.java
@@ -48,4 +48,16 @@
     Class<?> clazz = EventTypes.getClass("does-not-exist-event");
     assertThat(clazz).isNull();
   }
+
+  @Test
+  public void getRegisteredEventsGetsANewlyRegisteredEvent() {
+    EventTypes.register(TestEvent.TYPE, TestEvent.class);
+    assertThat(EventTypes.getRegisteredEvents()).containsEntry(TestEvent.TYPE, TestEvent.class);
+  }
+
+  @Test
+  public void getRegisteredEventsGetsTypeGivenAtRegistration() {
+    EventTypes.register("alternate-type", TestEvent.class);
+    assertThat(EventTypes.getRegisteredEvents()).containsEntry("alternate-type", TestEvent.class);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
new file mode 100644
index 0000000..7bdb23c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
@@ -0,0 +1,115 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import java.io.IOException;
+import java.time.Instant;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class GitReferenceUpdatedTest {
+  private DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
+  private DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
+
+  private final AccountState updater =
+      AccountState.forAccount(Account.builder(Account.id(1), Instant.now()).build());
+
+  @Mock GitReferenceUpdatedListener refUpdatedListener;
+  @Mock GitBatchRefUpdateListener batchRefUpdateListener;
+  @Mock EventUtil util;
+
+  @Before
+  public void setup() {
+    refUpdatedListeners = new DynamicSet<>();
+    refUpdatedListeners.add("gerrit", refUpdatedListener);
+    batchRefUpdateListeners = new DynamicSet<>();
+    batchRefUpdateListeners.add("gerrit", batchRefUpdateListener);
+  }
+
+  @Test
+  public void RefUpdateEventsAndRefsUpdateEventAreFired_BatchRefUpdate() {
+    BatchRefUpdate update = newBatchRefUpdate();
+    ReceiveCommand cmd1 =
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            ObjectId.fromString("0000000000000000000000000000000000000001"),
+            "refs/changes/01/1/1");
+    ReceiveCommand cmd2 =
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            ObjectId.fromString("0000000000000000000000000000000000000001"),
+            "refs/changes/01/1/meta");
+    cmd1.setResult(ReceiveCommand.Result.OK);
+    cmd2.setResult(ReceiveCommand.Result.OK);
+    update.addCommand(cmd1);
+    update.addCommand(cmd2);
+
+    GitReferenceUpdated event =
+        new GitReferenceUpdated(
+            new PluginSetContext<>(batchRefUpdateListeners, PluginMetrics.DISABLED_INSTANCE),
+            new PluginSetContext<>(refUpdatedListeners, PluginMetrics.DISABLED_INSTANCE),
+            util);
+    event.fire(Project.NameKey.parse("project"), update, updater);
+    Mockito.verify(batchRefUpdateListener, Mockito.times(1)).onGitBatchRefUpdate(Mockito.any());
+    Mockito.verify(refUpdatedListener, Mockito.times(2)).onGitReferenceUpdated(Mockito.any());
+  }
+
+  @Test
+  public void RefUpdateEventAndRefsUpdateEventAreFired_RefUpdate() throws Exception {
+    String ref = "refs/heads/master";
+    RefUpdate update = newRefUpdate(ref);
+
+    GitReferenceUpdated event =
+        new GitReferenceUpdated(
+            new PluginSetContext<>(batchRefUpdateListeners, PluginMetrics.DISABLED_INSTANCE),
+            new PluginSetContext<>(refUpdatedListeners, PluginMetrics.DISABLED_INSTANCE),
+            util);
+    event.fire(Project.NameKey.parse("project"), update, updater);
+    Mockito.verify(batchRefUpdateListener, Mockito.times(1)).onGitBatchRefUpdate(Mockito.any());
+    Mockito.verify(refUpdatedListener, Mockito.times(1)).onGitReferenceUpdated(Mockito.any());
+  }
+
+  private static BatchRefUpdate newBatchRefUpdate() {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      return repo.getRefDatabase().newBatchUpdate();
+    }
+  }
+
+  private static RefUpdate newRefUpdate(String ref) throws IOException {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      return repo.getRefDatabase().newUpdate(ref, false);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
index fa5c47f..42a80c3 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
@@ -248,4 +248,30 @@
     assertThat(edit).internalEdits().element(0).isReplace(6, 12, 6, 9);
     assertThat(edit).internalEdits().element(1).isReplace(18, 10, 15, 7);
   }
+
+  @Test
+  public void overlappingChangesInMiddleOfLineRaisesException() throws Exception {
+    FixReplacement firstReplace =
+        new FixReplacement("path", new Range(2, 0, 3, 5), "First modification\n");
+    FixReplacement secondReplace =
+        new FixReplacement("path", new Range(3, 4, 4, 0), "Some other modified content\n");
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            FixCalculator.calculateFix(
+                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+  }
+
+  @Test
+  public void overlappingChangesInBeginningOfLineRaisesException() throws Exception {
+    FixReplacement firstReplace =
+        new FixReplacement("path", new Range(2, 0, 3, 1), "First modification\n");
+    FixReplacement secondReplace =
+        new FixReplacement("path", new Range(3, 0, 4, 0), "Some other modified content\n");
+    assertThrows(
+        ResourceConflictException.class,
+        () ->
+            FixCalculator.calculateFix(
+                multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+  }
 }
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 3a8d7e4..6bdf80f 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -59,7 +59,8 @@
       Ref ref2 = createRefWithEmptyTreeCommit(usersRepo, 1, 1000002);
 
       DeleteZombieCommentsRefs clean =
-          new DeleteZombieCommentsRefs(new AllUsersName("All-Users"), repoManager, null);
+          new DeleteZombieCommentsRefs(
+              new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
       clean.execute();
 
       /* Check that ref1 still exists, and ref2 is deleted */
@@ -80,7 +81,7 @@
       int cleanupPercentage = 50;
       DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, cleanupPercentage);
+              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
       clean.execute();
 
       /* ref1 not deleted, ref2 deleted, ref3 not deleted because of the clean percentage */
@@ -100,7 +101,7 @@
       cleanupPercentage = 70;
       clean =
           new DeleteZombieCommentsRefs(
-              new AllUsersName("All-Users"), repoManager, cleanupPercentage);
+              new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
 
       clean.execute();
 
@@ -136,7 +137,8 @@
           .isEqualTo(goodRefs.size() + badRefs.size());
 
       DeleteZombieCommentsRefs clean =
-          new DeleteZombieCommentsRefs(new AllUsersName("All-Users"), repoManager, null);
+          new DeleteZombieCommentsRefs(
+              new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
       clean.execute();
 
       assertThat(
@@ -207,7 +209,7 @@
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
diff --git a/javatests/com/google/gerrit/server/git/GarbageCollectionTest.java b/javatests/com/google/gerrit/server/git/GarbageCollectionTest.java
new file mode 100644
index 0000000..41b5d79
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/GarbageCollectionTest.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.config.GcConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import java.io.IOException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class GarbageCollectionTest {
+  private static final Project.NameKey FOO = Project.nameKey("foo");
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+  @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Mock private GcConfig gcConfig;
+  @Mock private DelegateRepository wrapper;
+
+  private SitePaths site;
+  private Config cfg;
+
+  @Before
+  public void setup() throws Exception {
+    site = new SitePaths(temporaryFolder.newFolder().toPath());
+    site.resolve("git").toFile().mkdir();
+    cfg = new Config();
+    cfg.setString("gerrit", null, "basePath", "git");
+  }
+
+  @Test
+  public void shouldCallGcOnDelegatedRepositoryWhenDelegateRepositoryIsPassed() throws IOException {
+    // given
+    GarbageCollection objectUnderTest = prepareObjectForTesting();
+
+    // when
+    objectUnderTest.run(ImmutableList.of(FOO), false, null);
+
+    // then
+    verify(wrapper).delegate();
+  }
+
+  private GarbageCollection prepareObjectForTesting() throws IOException {
+    LocalDiskRepositoryManager repoManager = new DelegatedRepositoryManager(site, cfg, wrapper);
+    try (Repository repo = repoManager.createRepository(FOO)) {
+      assertThat(repo).isNotNull();
+    }
+    return new GarbageCollection(
+        repoManager,
+        new GarbageCollectionQueue(),
+        gcConfig,
+        new PluginSetContext<>(new DynamicSet<>(), PluginMetrics.DISABLED_INSTANCE));
+  }
+
+  private static final class DelegatedRepositoryManager extends LocalDiskRepositoryManager {
+    private final DelegateRepository wrapper;
+
+    private DelegatedRepositoryManager(SitePaths site, Config cfg, DelegateRepository wrapper) {
+      super(site, cfg);
+      this.wrapper = wrapper;
+    }
+
+    @Override
+    public Repository openRepository(NameKey name) throws RepositoryNotFoundException {
+      Repository opened = super.openRepository(name);
+      when(wrapper.delegate()).thenReturn(opened);
+      when(wrapper.getConfig()).thenReturn(opened.getConfig());
+      return wrapper;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
index 6b8177e..82cc049 100644
--- a/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
@@ -16,7 +16,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.entities.Project.NameKey;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Before;
 import org.junit.Test;
@@ -53,7 +53,7 @@
     }
 
     @Override
-    public SortedSet<NameKey> list() {
+    public NavigableSet<NameKey> list() {
       throw new UnsupportedOperationException("Not implemented");
     }
   }
diff --git a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
index 4902830..6c771d7 100644
--- a/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManagerTest.java
@@ -26,7 +26,7 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.SortedSet;
+import java.util.NavigableSet;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
@@ -77,7 +77,7 @@
     assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
         .isEqualTo(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString());
 
-    SortedSet<Project.NameKey> repoList = repoManager.list();
+    NavigableSet<Project.NameKey> repoList = repoManager.list();
     assertThat(repoList).hasSize(1);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {someProjectKey});
@@ -105,7 +105,7 @@
     assertThat(repoManager.getBasePath(someProjectKey).toAbsolutePath().toString())
         .isEqualTo(alternateBasePath.toString());
 
-    SortedSet<Project.NameKey> repoList = repoManager.list();
+    NavigableSet<Project.NameKey> repoList = repoManager.list();
     assertThat(repoList).hasSize(1);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {someProjectKey});
@@ -131,7 +131,7 @@
     createRepository(repoManager.getBasePath(basePathProject), misplacedProject2);
     createRepository(alternateBasePath, misplacedProject1);
 
-    SortedSet<Project.NameKey> repoList = repoManager.list();
+    NavigableSet<Project.NameKey> repoList = repoManager.list();
     assertThat(repoList).hasSize(2);
     assertThat(repoList.toArray(new Project.NameKey[repoList.size()]))
         .isEqualTo(new Project.NameKey[] {altPathProject, basePathProject});
diff --git a/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java b/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java
new file mode 100644
index 0000000..c09d8d5
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/RepoRefCacheTest.java
@@ -0,0 +1,276 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.attributes.AttributesNodeProvider;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectDatabase;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefRename;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.ReflogReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.Test;
+
+public class RepoRefCacheTest {
+  private static final String TEST_BRANCH = "main";
+
+  @Test
+  @SuppressWarnings("resource")
+  public void repositoryUseShouldBeTrackedByRepoRefCache() throws Exception {
+    RefCache cache;
+    TestRepositoryWithRefCounting repoWithRefCounting;
+
+    try (TestRepositoryWithRefCounting repo =
+        TestRepositoryWithRefCounting.createWithBranch(TEST_BRANCH)) {
+      assertThat(repo.refCounter()).isEqualTo(1);
+      repoWithRefCounting = repo;
+      cache = new RepoRefCache(repo);
+    }
+
+    assertThat(repoWithRefCounting.refCounter()).isEqualTo(1);
+    assertThat(cache.get(Constants.R_HEADS + TEST_BRANCH)).isNotNull();
+  }
+
+  private static class TestRepositoryWithRefCounting extends Repository {
+    private int refCounter;
+
+    @SuppressWarnings("resource")
+    static TestRepositoryWithRefCounting createWithBranch(String branchName) throws Exception {
+      InMemoryRepository.Builder builder =
+          new InMemoryRepository.Builder()
+              .setRepositoryDescription(new DfsRepositoryDescription(""))
+              .setFS(FS.detect().setUserHome(null));
+      TestRepositoryWithRefCounting testRepo = new TestRepositoryWithRefCounting(builder);
+      new TestRepository<>(testRepo).branch(branchName).commit().message("").create();
+      return testRepo;
+    }
+
+    private final Repository repo;
+
+    private TestRepositoryWithRefCounting(InMemoryRepository.Builder builder) throws IOException {
+      super(builder);
+
+      repo = builder.build();
+      refCounter = 1;
+    }
+
+    public int refCounter() {
+      return refCounter;
+    }
+
+    @Override
+    public void incrementOpen() {
+      repo.incrementOpen();
+      refCounter++;
+    }
+
+    @Override
+    public void close() {
+      repo.close();
+      refCounter--;
+    }
+
+    @Override
+    public void create(boolean bare) throws IOException {}
+
+    @Override
+    public ObjectDatabase getObjectDatabase() {
+      checkIsOpen();
+      return repo.getObjectDatabase();
+    }
+
+    @Override
+    public RefDatabase getRefDatabase() {
+      RefDatabase refDatabase = repo.getRefDatabase();
+      return new RefDatabase() {
+
+        @Override
+        public int hashCode() {
+          return refDatabase.hashCode();
+        }
+
+        @Override
+        public void create() throws IOException {
+          refDatabase.create();
+        }
+
+        @Override
+        public void close() {
+          checkIsOpen();
+          refDatabase.close();
+        }
+
+        @Override
+        public boolean isNameConflicting(String name) throws IOException {
+          checkIsOpen();
+          return refDatabase.isNameConflicting(name);
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+          return refDatabase.equals(obj);
+        }
+
+        @Override
+        public Collection<String> getConflictingNames(String name) throws IOException {
+          checkIsOpen();
+          return refDatabase.getConflictingNames(name);
+        }
+
+        @Override
+        public RefUpdate newUpdate(String name, boolean detach) throws IOException {
+          checkIsOpen();
+          return refDatabase.newUpdate(name, detach);
+        }
+
+        @Override
+        public RefRename newRename(String fromName, String toName) throws IOException {
+          checkIsOpen();
+          return refDatabase.newRename(fromName, toName);
+        }
+
+        @Override
+        public BatchRefUpdate newBatchUpdate() {
+          checkIsOpen();
+          return refDatabase.newBatchUpdate();
+        }
+
+        @Override
+        public boolean performsAtomicTransactions() {
+          checkIsOpen();
+          return refDatabase.performsAtomicTransactions();
+        }
+
+        @Override
+        public Ref exactRef(String name) throws IOException {
+          checkIsOpen();
+          return refDatabase.exactRef(name);
+        }
+
+        @Override
+        public String toString() {
+          return refDatabase.toString();
+        }
+
+        @Override
+        public Map<String, Ref> exactRef(String... refs) throws IOException {
+          checkIsOpen();
+          return refDatabase.exactRef(refs);
+        }
+
+        @Override
+        public Ref firstExactRef(String... refs) throws IOException {
+          checkIsOpen();
+          return refDatabase.firstExactRef(refs);
+        }
+
+        @Override
+        public List<Ref> getRefs() throws IOException {
+          checkIsOpen();
+          return refDatabase.getRefs();
+        }
+
+        @Override
+        @Deprecated
+        public Map<String, Ref> getRefs(String prefix) throws IOException {
+          checkIsOpen();
+          return refDatabase.getRefs(prefix);
+        }
+
+        @Override
+        public List<Ref> getRefsByPrefix(String prefix) throws IOException {
+          checkIsOpen();
+          return refDatabase.getRefsByPrefix(prefix);
+        }
+
+        @Override
+        public boolean hasRefs() throws IOException {
+          checkIsOpen();
+          return refDatabase.hasRefs();
+        }
+
+        @Override
+        public List<Ref> getAdditionalRefs() throws IOException {
+          checkIsOpen();
+          return refDatabase.getAdditionalRefs();
+        }
+
+        @Override
+        public Ref peel(Ref ref) throws IOException {
+          checkIsOpen();
+          return refDatabase.peel(ref);
+        }
+
+        @Override
+        public void refresh() {
+          checkIsOpen();
+          refDatabase.refresh();
+        }
+      };
+    }
+
+    @Override
+    public StoredConfig getConfig() {
+      return repo.getConfig();
+    }
+
+    @Override
+    public AttributesNodeProvider createAttributesNodeProvider() {
+      checkIsOpen();
+      return repo.createAttributesNodeProvider();
+    }
+
+    @Override
+    public void scanForRepoChanges() throws IOException {
+      checkIsOpen();
+    }
+
+    @Override
+    public void notifyIndexChanged(boolean internal) {
+      checkIsOpen();
+    }
+
+    @Override
+    public ReflogReader getReflogReader(String refName) throws IOException {
+      checkIsOpen();
+      return repo.getReflogReader(refName);
+    }
+
+    private void checkIsOpen() {
+      if (refCounter <= 0) {
+        throw new IllegalStateException("Repository is not open (refCounter=" + refCounter + ")");
+      }
+    }
+
+    @Override
+    public String getIdentifier() {
+      return "foo";
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/delegate/DelegateRepositoryTest.java b/javatests/com/google/gerrit/server/git/delegate/DelegateRepositoryTest.java
new file mode 100644
index 0000000..74e0fac
--- /dev/null
+++ b/javatests/com/google/gerrit/server/git/delegate/DelegateRepositoryTest.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.delegate;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.DelegateRepository;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class DelegateRepositoryTest {
+
+  @Test
+  public void shouldDelegateRepositoryFromAnyPackage() throws IOException {
+    Repository foo = new InMemoryRepositoryManager().createRepository(Project.nameKey("foo"));
+    try (TestDelegateRepository delegateRepository = new TestDelegateRepository(foo)) {
+      assertThat(delegateRepository.delegate()).isSameInstanceAs(foo);
+    }
+  }
+
+  static class TestDelegateRepository extends DelegateRepository {
+    protected TestDelegateRepository(Repository delegate) {
+      super(delegate);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
index 690a5cc..91d5596 100644
--- a/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/meta/VersionedMetaDataTest.java
@@ -29,9 +29,9 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.Arrays;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
@@ -55,7 +55,7 @@
   // instead coming up with a replacement interface for
   // VersionedMetaData/BatchMetaDataUpdate/MetaDataUpdate that is easier to use correctly.
 
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   private static final String DEFAULT_REF = "refs/meta/config";
 
   private Project.NameKey project;
@@ -222,7 +222,8 @@
 
   private CommitBuilder newCommitBuilder() {
     CommitBuilder cb = new CommitBuilder();
-    PersonIdent author = new PersonIdent("J. Author", "author@example.com", TimeUtil.nowTs(), TZ);
+    PersonIdent author =
+        new PersonIdent("J. Author", "author@example.com", TimeUtil.now(), ZONE_ID);
     cb.setAuthor(author);
     cb.setCommitter(
         new PersonIdent(
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index e24d481..2d90ab4 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -30,9 +30,9 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -44,7 +44,7 @@
 
 @Ignore
 public class AbstractGroupTest {
-  protected static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  protected static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
   protected static final String SERVER_ID = "server-id";
   protected static final String SERVER_NAME = "Gerrit Server";
   protected static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
@@ -65,7 +65,7 @@
     repoManager = new InMemoryRepositoryManager();
     allUsersRepo = repoManager.createRepository(allUsersName);
     serverAccountId = Account.id(SERVER_ACCOUNT_NUMBER);
-    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
     userId = Account.id(USER_ACCOUNT_NUMBER);
     userIdent = newPersonIdent(userId, serverIdent);
   }
@@ -75,12 +75,13 @@
     allUsersRepo.close();
   }
 
-  protected Timestamp getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
+  @Nullable
+  protected Instant getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
     try (RevWalk rw = new RevWalk(allUsersRepo)) {
       Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
       return ref == null
           ? null
-          : new Timestamp(rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhen().getTime());
+          : rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhenAsInstant();
     }
   }
 
@@ -110,7 +111,7 @@
   }
 
   protected static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
   }
 
   protected static PersonIdent newPersonIdent(Account.Id id, PersonIdent ident) {
@@ -123,7 +124,7 @@
   }
 
   private static Optional<Account> getAccount(Account.Id id) {
-    Account.Builder account = Account.builder(id, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(id, TimeUtil.now());
     account.setFullName("Account " + id);
     return Optional.of(account.build());
   }
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 6ad899e..a764654 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.account.GroupUuid;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Set;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Before;
@@ -299,7 +299,7 @@
   }
 
   private static AccountGroupMemberAudit createExpMemberAudit(
-      AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Timestamp addedOn) {
+      AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Instant addedOn) {
     return AccountGroupMemberAudit.builder()
         .groupId(groupId)
         .memberId(id)
@@ -309,7 +309,7 @@
   }
 
   private static AccountGroupByIdAudit createExpGroupAudit(
-      AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Timestamp addedOn) {
+      AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Instant addedOn) {
     return AccountGroupByIdAudit.builder()
         .groupId(groupId)
         .includeUuid(uuid)
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index 5d88a5f..8c19732 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;
@@ -37,13 +35,12 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.Month;
 import java.time.ZoneId;
 import java.util.Optional;
-import java.util.TimeZone;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -65,7 +62,7 @@
   private final AccountGroup.Id groupId = AccountGroup.id(123);
   private final AuditLogFormatter auditLogFormatter =
       AuditLogFormatter.createBackedBy(ImmutableSet.of(), ImmutableSet.of(), "server-id");
-  private final TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
+  private final ZoneId zoneId = ZoneId.of("America/Los_Angeles");
 
   @Before
   public void setUp() throws Exception {
@@ -114,8 +111,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 +134,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);
     }
   }
@@ -212,8 +211,9 @@
     groupConfig.setGroupDelta(groupDelta, 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);
     }
   }
@@ -245,7 +245,7 @@
   @Test
   public void createdOnDefaultsToNow() throws Exception {
     // Git timestamps are only precise to the second.
-    Timestamp testStart = TimeUtil.truncateToSecond(TimeUtil.nowTs());
+    Instant testStart = TimeUtil.truncateToSecond(TimeUtil.now());
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -261,7 +261,7 @@
 
   @Test
   public void specifiedCreatedOnIsRespectedForNewGroup() throws Exception {
-    Timestamp createdOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 11).atTime(13, 44, 10));
+    Instant createdOn = toInstant(LocalDate.of(2017, Month.DECEMBER, 11).atTime(13, 44, 10));
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     GroupDelta groupDelta = GroupDelta.builder().setUpdatedOn(createdOn).build();
@@ -520,8 +520,9 @@
     groupConfig.setGroupDelta(groupDelta, 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);
     }
   }
@@ -584,8 +585,9 @@
     groupConfig.setGroupDelta(groupDelta, 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);
     }
   }
@@ -604,8 +606,8 @@
 
   @Test
   public void createdOnIsNotAffectedByFurtherUpdates() throws Exception {
-    Timestamp createdOn = toTimestamp(LocalDate.of(2017, Month.MAY, 11).atTime(13, 44, 10));
-    Timestamp updatedOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 12).atTime(10, 21, 49));
+    Instant createdOn = toInstant(LocalDate.of(2017, Month.MAY, 11).atTime(13, 44, 10));
+    Instant updatedOn = toInstant(LocalDate.of(2017, Month.DECEMBER, 12).atTime(10, 21, 49));
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
     GroupDelta initialGroupDelta = GroupDelta.builder().setUpdatedOn(createdOn).build();
@@ -737,7 +739,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(92900892))
+            .setUpdatedOn(Instant.ofEpochMilli(92900892))
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
@@ -758,7 +760,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(92900892))
+            .setUpdatedOn(Instant.ofEpochMilli(92900892))
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
@@ -780,7 +782,7 @@
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(92900892))
+            .setUpdatedOn(Instant.ofEpochMilli(92900892))
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
@@ -864,7 +866,7 @@
   public void newCommitIsNotCreatedForPureUpdatedOnUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    Timestamp updatedOn = toTimestamp(LocalDate.of(3017, Month.DECEMBER, 12).atTime(10, 21, 49));
+    Instant updatedOn = toInstant(LocalDate.of(3017, Month.DECEMBER, 12).atTime(10, 21, 49));
     GroupDelta groupDelta = GroupDelta.builder().setUpdatedOn(updatedOn).build();
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
@@ -1005,7 +1007,7 @@
   @Test
   public void commitTimeMatchesDefaultCreatedOnOfNewGroup() throws Exception {
     // Git timestamps are only precise to the second.
-    long testStartAsSecondsSinceEpoch = TimeUtil.nowTs().getTime() / 1000;
+    long testStartAsSecondsSinceEpoch = TimeUtil.now().getEpochSecond();
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -1032,7 +1034,7 @@
             .build();
     GroupDelta groupDelta =
         GroupDelta.builder()
-            .setUpdatedOn(new Timestamp(createdOnAsSecondsSinceEpoch * 1000))
+            .setUpdatedOn(Instant.ofEpochSecond(createdOnAsSecondsSinceEpoch))
             .build();
     createGroup(groupCreation, groupDelta);
 
@@ -1042,9 +1044,9 @@
 
   @Test
   public void timestampOfCommitterMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
-    Timestamp committerTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp createdOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant committerTimestamp =
+        toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant createdOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -1061,23 +1063,22 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen()).isEqualTo(createdOn);
-    assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getCommitterIdent().getWhenAsInstant()).isEqualTo(createdOn);
+    assertThat(revCommit.getCommitterIdent().getZoneId().getRules().getOffset(createdOn))
+        .isEqualTo(zoneId.getRules().getOffset(createdOn));
   }
 
   @Test
   public void timestampOfAuthorMatchesSpecifiedCreatedOnOfNewGroup() throws Exception {
-    Timestamp authorTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp createdOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant createdOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     InternalGroupCreation groupCreation =
         InternalGroupCreation.builder()
@@ -1094,22 +1095,22 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen()).isEqualTo(createdOn);
-    assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo(createdOn);
+    assertThat(revCommit.getAuthorIdent().getZoneId().getRules().getOffset(createdOn))
+        .isEqualTo(zoneId.getRules().getOffset(createdOn));
   }
 
   @Test
   public void commitTimeMatchesDefaultUpdatedOnOfUpdatedGroup() throws Exception {
     // Git timestamps are only precise to the second.
-    long testStartAsSecondsSinceEpoch = TimeUtil.nowTs().getTime() / 1000;
+    long testStartAsSecondsSinceEpoch = TimeUtil.now().getEpochSecond();
 
     createArbitraryGroup(groupUuid);
     GroupDelta groupDelta =
@@ -1129,7 +1130,7 @@
     GroupDelta groupDelta =
         GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
-            .setUpdatedOn(new Timestamp(updatedOnAsSecondsSinceEpoch * 1000))
+            .setUpdatedOn(Instant.ofEpochSecond(updatedOnAsSecondsSinceEpoch))
             .build();
     updateGroup(groupUuid, groupDelta);
 
@@ -1139,9 +1140,9 @@
 
   @Test
   public void timestampOfCommitterMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
-    Timestamp committerTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp updatedOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant committerTimestamp =
+        toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant updatedOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     createArbitraryGroup(groupUuid);
     GroupDelta groupDelta =
@@ -1153,23 +1154,22 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getCommitterIdent().getWhen()).isEqualTo(updatedOn);
-    assertThat(revCommit.getCommitterIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getCommitterIdent().getWhenAsInstant()).isEqualTo(updatedOn);
+    assertThat(revCommit.getCommitterIdent().getZoneId().getRules().getOffset(updatedOn))
+        .isEqualTo(zoneId.getRules().getOffset(updatedOn));
   }
 
   @Test
   public void timestampOfAuthorMatchesSpecifiedUpdatedOnOfUpdatedGroup() throws Exception {
-    Timestamp authorTimestamp =
-        toTimestamp(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
-    Timestamp updatedOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
+    Instant authorTimestamp = toInstant(LocalDate.of(2017, Month.DECEMBER, 13).atTime(15, 5, 27));
+    Instant updatedOn = toInstant(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     createArbitraryGroup(groupUuid);
     GroupDelta groupDelta =
@@ -1181,16 +1181,16 @@
     groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
-        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, timeZone);
+        new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, zoneId);
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
       groupConfig.commit(metaDataUpdate);
     }
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
-    assertThat(revCommit.getAuthorIdent().getWhen()).isEqualTo(updatedOn);
-    assertThat(revCommit.getAuthorIdent().getTimeZone().getRawOffset())
-        .isEqualTo(timeZone.getRawOffset());
+    assertThat(revCommit.getAuthorIdent().getWhenAsInstant()).isEqualTo(updatedOn);
+    assertThat(revCommit.getAuthorIdent().getZoneId().getRules().getOffset(updatedOn))
+        .isEqualTo(zoneId.getRules().getOffset(updatedOn));
   }
 
   @Test
@@ -1455,8 +1455,8 @@
                 + "Rename from Old name to New name");
   }
 
-  private static Timestamp toTimestamp(LocalDateTime localDateTime) {
-    return Timestamp.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
+  private static Instant toInstant(LocalDateTime localDateTime) {
+    return localDateTime.atZone(ZoneId.systemDefault()).toInstant();
   }
 
   private void populateGroupConfig(AccountGroup.UUID uuid, String fileContent) throws Exception {
@@ -1541,8 +1541,7 @@
 
   private MetaDataUpdate createMetaDataUpdate() {
     PersonIdent serverIdent =
-        new PersonIdent(
-            "Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.nowTs(), timeZone);
+        new PersonIdent("Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.now(), zoneId);
 
     MetaDataUpdate metaDataUpdate =
         new MetaDataUpdate(
@@ -1564,7 +1563,7 @@
   }
 
   private static Account createAccount(Account.Id id, String name) {
-    Account.Builder account = Account.builder(id, TimeUtil.nowTs());
+    Account.Builder account = Account.builder(id, TimeUtil.now());
     account.setFullName(name);
     return account.build();
   }
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
index 3b7beb9..9d8f260 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -44,10 +44,10 @@
 import com.google.gerrit.truth.ListSubject;
 import com.google.gerrit.truth.OptionalSubject;
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Optional;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -71,7 +71,7 @@
 public class GroupNameNotesTest {
   private static final String SERVER_NAME = "Gerrit Server";
   private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   private final AccountGroup.UUID groupUuid = AccountGroup.uuid("users-XYZ");
   private final AccountGroup.NameKey groupName = AccountGroup.nameKey("users");
@@ -558,7 +558,7 @@
   }
 
   private static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.now(), ZONE_ID);
   }
 
   private static ObjectId getNoteKey(GroupReference g) {
diff --git a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
index 9025691..6745b1d 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyCheckerTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo.warning;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -95,7 +96,7 @@
   @Test
   public void groupNameNoteFailToParse() throws Exception {
     updateGroupNamesRef("g-1", "[invalid");
-    List<ConsistencyProblemInfo> problems =
+    ImmutableList<ConsistencyProblemInfo> problems =
         GroupsNoteDbConsistencyChecker.checkWithGroupNameNotes(
             allUsersRepo, AccountGroup.nameKey("g-1"), AccountGroup.uuid("uuid-1"));
     assertThat(problems)
diff --git a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
new file mode 100644
index 0000000..dacc37d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
@@ -0,0 +1,180 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.testing.TestIndexedFields.EXACT_STRING_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_INTEGER_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_INTEGER_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_LONG_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_LONG_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_PROTO_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STORED_BYTE_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STORED_BYTE_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STORED_PROTO_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STRING_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STRING_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.LONG_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.LONG_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.PREFIX_STRING_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_PROTO_FIELD;
+import static com.google.gerrit.index.testing.TestIndexedFields.STORED_PROTO_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.STRING_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.TIMESTAMP_FIELD_SPEC;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.StoredValue;
+import com.google.gerrit.index.testing.FakeStoredValue;
+import com.google.gerrit.index.testing.TestIndexedFields;
+import com.google.gerrit.index.testing.TestIndexedFields.TestIndexedData;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Protos;
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import java.sql.Timestamp;
+import java.util.Map.Entry;
+import org.junit.Test;
+import org.junit.experimental.theories.DataPoints;
+import org.junit.experimental.theories.FromDataPoints;
+import org.junit.experimental.theories.Theories;
+import org.junit.experimental.theories.Theory;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link com.google.gerrit.index.IndexedField} */
+@SuppressWarnings("serial")
+@RunWith(Theories.class)
+public class IndexedFieldTest {
+
+  @DataPoints("nonProtoTypes")
+  public static final ImmutableList<
+          Entry<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>>
+      fieldToStoredValue =
+          new ImmutableMap.Builder<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>()
+              .put(INTEGER_FIELD_SPEC, 123456)
+              .put(INTEGER_RANGE_FIELD_SPEC, 123456)
+              .put(ITERABLE_INTEGER_FIELD_SPEC, ImmutableList.of(123456, 654321))
+              .put(ITERABLE_INTEGER_RANGE_FIELD_SPEC, ImmutableList.of(123456, 654321))
+              .put(LONG_FIELD_SPEC, 123456L)
+              .put(LONG_RANGE_FIELD_SPEC, 123456L)
+              .put(ITERABLE_LONG_FIELD_SPEC, ImmutableList.of(123456L, 654321L))
+              .put(ITERABLE_LONG_RANGE_FIELD_SPEC, ImmutableList.of(123456L, 654321L))
+              .put(TIMESTAMP_FIELD_SPEC, new Timestamp(1234567L))
+              .put(STRING_FIELD_SPEC, "123456")
+              .put(PREFIX_STRING_FIELD_SPEC, "123456")
+              .put(EXACT_STRING_FIELD_SPEC, "123456")
+              .put(ITERABLE_STRING_FIELD_SPEC, ImmutableList.of("123456"))
+              .put(
+                  ITERABLE_STORED_BYTE_SPEC,
+                  ImmutableList.of("123456".getBytes(StandardCharsets.UTF_8)))
+              .put(STORED_BYTE_SPEC, "123456".getBytes(StandardCharsets.UTF_8))
+              .build()
+              .entrySet()
+              .asList();
+
+  @DataPoints("protoTypes")
+  public static final ImmutableList<
+          Entry<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>>
+      protoFieldToStoredValue =
+          ImmutableMap.<IndexedField<TestIndexedData, ?>.SearchSpec, Serializable>of(
+                  STORED_PROTO_FIELD_SPEC,
+                  TestIndexedFields.createChangeProto(12345),
+                  ITERABLE_PROTO_FIELD_SPEC,
+                  ImmutableList.of(
+                      TestIndexedFields.createChangeProto(12345),
+                      TestIndexedFields.createChangeProto(54321)))
+              .entrySet()
+              .asList();
+
+  @Theory
+  public void testSetIfPossible(
+      @FromDataPoints("nonProtoTypes")
+          Entry<IndexedField<TestIndexedData, StoredValue>.SearchSpec, StoredValue>
+              fieldToStoredValue) {
+    Object docValue = fieldToStoredValue.getValue();
+    IndexedField<TestIndexedData, StoredValue>.SearchSpec searchSpec = fieldToStoredValue.getKey();
+    StoredValue storedValue = new FakeStoredValue(fieldToStoredValue.getValue());
+    TestIndexedData testIndexedData = new TestIndexedData();
+    searchSpec.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(docValue);
+  }
+
+  @Test
+  public void testSetIfPossible_protoFromBytes() {
+    Entities.Change changeProto = TestIndexedFields.createChangeProto(12345);
+    StoredValue storedValue = new FakeStoredValue(Protos.toByteArray(changeProto));
+    TestIndexedData testIndexedData = new TestIndexedData();
+    STORED_PROTO_FIELD_SPEC.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(changeProto);
+  }
+
+  @Test
+  public void testSetIfPossible_iterableProtoFromIterableBytes() {
+    ImmutableList<Entities.Change> changeProtos =
+        ImmutableList.of(
+            TestIndexedFields.createChangeProto(12345), TestIndexedFields.createChangeProto(54321));
+    StoredValue storedValue =
+        new FakeStoredValue(
+            changeProtos.stream()
+                .map(proto -> Protos.toByteArray(proto))
+                .collect(toImmutableList()));
+    TestIndexedData testIndexedData = new TestIndexedData();
+    ITERABLE_STORED_PROTO_FIELD.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(changeProtos);
+  }
+
+  @Theory
+  public void testSetIfPossible_fromProto(
+      @FromDataPoints("protoTypes")
+          Entry<IndexedField<TestIndexedData, StoredValue>.SearchSpec, StoredValue>
+              fieldToStoredValue) {
+    Object docValue = fieldToStoredValue.getValue();
+    IndexedField<TestIndexedData, StoredValue>.SearchSpec searchSpec = fieldToStoredValue.getKey();
+    StoredValue storedValue = new FakeStoredValue(fieldToStoredValue.getValue(), /*isProto=*/ true);
+    TestIndexedData testIndexedData = new TestIndexedData();
+    searchSpec.setIfPossible(testIndexedData, storedValue);
+    assertThat(testIndexedData.getTestField()).isEqualTo(docValue);
+  }
+
+  @Test
+  public void test_isProtoType() {
+    assertThat(STORED_PROTO_FIELD.isProtoType()).isTrue();
+
+    assertThat(ITERABLE_STORED_PROTO_FIELD.isProtoType()).isFalse();
+    assertThat(INTEGER_FIELD.isProtoType()).isFalse();
+    assertThat(ITERABLE_STRING_FIELD.isProtoType()).isFalse();
+    assertThat(STORED_BYTE_FIELD.isProtoType()).isFalse();
+    assertThat(ITERABLE_STORED_BYTE_FIELD.isProtoType()).isFalse();
+  }
+
+  @Test
+  public void test_isProtoIterableType() {
+
+    assertThat(ITERABLE_STORED_PROTO_FIELD.isProtoIterableType()).isTrue();
+
+    assertThat(STORED_PROTO_FIELD.isProtoIterableType()).isFalse();
+    assertThat(INTEGER_FIELD.isProtoIterableType()).isFalse();
+    assertThat(ITERABLE_STRING_FIELD.isProtoIterableType()).isFalse();
+    assertThat(STORED_BYTE_FIELD.isProtoIterableType()).isFalse();
+    assertThat(ITERABLE_STORED_BYTE_FIELD.isProtoType()).isFalse();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index d16efc3..a40afe8 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -36,12 +36,12 @@
   @Test
   public void refStateFieldValues() throws Exception {
     AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
-    Account.Builder account = Account.builder(Account.id(1), TimeUtil.nowTs());
+    Account.Builder account = Account.builder(Account.id(1), TimeUtil.now());
     String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
     account.setMetaId(metaId);
-    List<String> values =
-        toStrings(AccountField.REF_STATE.get(AccountState.forAccount(account.build())));
-    assertThat(values).hasSize(1);
+    Iterable<byte[]> refStates =
+        AccountField.REF_STATE_SPEC.get(AccountState.forAccount(account.build()));
+    List<String> values = toStrings(refStates);
     String expectedValue =
         allUsersName.get() + ":" + RefNames.refsUsers(account.id()) + ":" + metaId;
     assertThat(Iterables.getOnlyElement(values)).isEqualTo(expectedValue);
@@ -51,7 +51,7 @@
   public void externalIdStateFieldValues() throws Exception {
     Account.Id id = Account.id(1);
     Account account =
-        Account.builder(id, TimeUtil.nowTs())
+        Account.builder(id, TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build();
     ExternalId extId1 =
@@ -77,7 +77,7 @@
             ObjectId.fromString("483ea804e84282e15ddcdd1d15a797eb4796a760"));
     List<String> values =
         toStrings(
-            AccountField.EXTERNAL_ID_STATE.get(
+            AccountField.EXTERNAL_ID_STATE_FIELD.get(
                 AccountState.forAccount(account, ImmutableSet.of(extId1, extId2, extId3))));
     String expectedValue1 = extId1.key().sha1().name() + ":" + extId1.blobId().name();
     String expectedValue2 = extId2.key().sha1().name() + ":" + extId2.blobId().name();
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index a7b25b8..35077db 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -25,15 +25,23 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LegacySubmitRequirement;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.index.testing.FakeStoredValue;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -51,17 +59,22 @@
 
   @Test
   public void reviewerFieldValues() {
-    Table<ReviewerStateInternal, Account.Id, Timestamp> t = HashBasedTable.create();
-    Timestamp t1 = TimeUtil.nowTs();
+    Table<ReviewerStateInternal, Account.Id, Instant> t = HashBasedTable.create();
+
+    // Timestamps are stored as epoch millis in the reviewer field. Epoch millis are less precise
+    // than Instants which have nanosecond precision. Create Instants with millisecond precision
+    // here so that the comparison for the assertions works.
+    Instant t1 = Instant.ofEpochMilli(TimeUtil.nowMs());
+    Instant t2 = Instant.ofEpochMilli(TimeUtil.nowMs());
+
     t.put(ReviewerStateInternal.REVIEWER, Account.id(1), t1);
-    Timestamp t2 = TimeUtil.nowTs();
     t.put(ReviewerStateInternal.CC, Account.id(2), t2);
     ReviewerSet reviewers = ReviewerSet.fromTable(t);
 
     List<String> values = ChangeField.getReviewerFieldValues(reviewers);
     assertThat(values)
         .containsExactly(
-            "REVIEWER,1", "REVIEWER,1," + t1.getTime(), "CC,2", "CC,2," + t2.getTime());
+            "REVIEWER,1", "REVIEWER,1," + t1.toEpochMilli(), "CC,2", "CC,2," + t2.toEpochMilli());
 
     assertThat(ChangeField.parseReviewerFieldValues(Change.id(1), values)).isEqualTo(reviewers);
   }
@@ -80,6 +93,18 @@
   }
 
   @Test
+  public void formatSubmitRequirementValues() {
+    assertThat(
+            ChangeField.formatSubmitRequirementValues(
+                ImmutableList.of(
+                    submitRequirementResult(
+                        "CR", "label:CR=+1", SubmitRequirementExpressionResult.Status.PASS),
+                    submitRequirementResult(
+                        "LC", "label:LC=+1", SubmitRequirementExpressionResult.Status.FAIL))))
+        .containsExactly("MAY,cr", "OK,cr", "NEED,lc", "REJECT,lc");
+  }
+
+  @Test
   public void storedSubmitRecords() {
     assertStoredRecordRoundTrip(record(SubmitRecord.Status.CLOSED));
 
@@ -129,6 +154,70 @@
     assertStoredRecordRoundTrip(r);
   }
 
+  @Test
+  public void tolerateNullValuesForInsertion() {
+    Project.NameKey project = Project.nameKey("project");
+    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    assertThat(ChangeField.ADDED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
+  }
+
+  @Test
+  public void tolerateNullValuesForDeletion() {
+    Project.NameKey project = Project.nameKey("project");
+    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    assertThat(ChangeField.DELETED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null)))
+        .isTrue();
+  }
+
+  @Test
+  public void shortStringIsNotTruncated() {
+    assertThat(ChangeField.truncateStringValue("short string", 20)).isEqualTo("short string");
+    String two_byte_str = String.format("short string %s", new String(Character.toChars(956)));
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 20)).isEqualTo(two_byte_str);
+    String three_byte_str = String.format("short string %s", new String(Character.toChars(43421)));
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 20)).isEqualTo(three_byte_str);
+    String four_byte_str = String.format("short string %s", new String(Character.toChars(132878)));
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 20)).isEqualTo(four_byte_str);
+    assertThat(ChangeField.truncateStringValue("", 6)).isEqualTo("");
+    assertThat(ChangeField.truncateStringValue("", 0)).isEqualTo("");
+  }
+
+  @Test
+  public void longStringIsTruncated() {
+    assertThat(ChangeField.truncateStringValue("longer string", 6)).isEqualTo("longer");
+    assertThat(ChangeField.truncateStringValue("longer string", 0)).isEqualTo("");
+
+    String two_byte_str =
+        String.format(
+            "multibytechars %1$s%1$s%1$s%1$s present", new String(Character.toChars(956)));
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 16)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 17))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(956))));
+    assertThat(ChangeField.truncateStringValue(two_byte_str, 18))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(956))));
+
+    String three_byte_str =
+        String.format(
+            "multibytechars %1$s%1$s%1$s%1$s present", new String(Character.toChars(43421)));
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 16)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 17)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 18))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(43421))));
+    assertThat(ChangeField.truncateStringValue(three_byte_str, 21))
+        .isEqualTo(String.format("multibytechars %1$s%1$s", new String(Character.toChars(43421))));
+
+    String four_byte_str =
+        String.format(
+            "multibytechars %1$s%1$s%1$s%1$s present", new String(Character.toChars(132878)));
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 16)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 17)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 18)).isEqualTo("multibytechars ");
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 19))
+        .isEqualTo(String.format("multibytechars %1$s", new String(Character.toChars(132878))));
+    assertThat(ChangeField.truncateStringValue(four_byte_str, 23))
+        .isEqualTo(String.format("multibytechars %1$s%1$s", new String(Character.toChars(132878))));
+  }
+
   private static SubmitRecord record(SubmitRecord.Status status, SubmitRecord.Label... labels) {
     SubmitRecord r = new SubmitRecord();
     r.status = status;
@@ -138,6 +227,25 @@
     return r;
   }
 
+  private SubmitRequirementResult submitRequirementResult(
+      String srName, String submitExpr, SubmitRequirementExpressionResult.Status submitExprStatus) {
+    return SubmitRequirementResult.builder()
+        .submitRequirement(
+            SubmitRequirement.builder()
+                .setName(srName)
+                .setSubmittabilityExpression(SubmitRequirementExpression.create("NA"))
+                .setAllowOverrideInChildProjects(false)
+                .build())
+        .submittabilityExpressionResult(
+            SubmitRequirementExpressionResult.create(
+                SubmitRequirementExpression.create(submitExpr),
+                submitExprStatus,
+                ImmutableList.of(submitExpr),
+                ImmutableList.of()))
+        .patchSetCommitId(ObjectId.zeroId())
+        .build();
+  }
+
   private static SubmitRecord.Label label(
       SubmitRecord.Label.Status status, String label, Integer appliedBy) {
     SubmitRecord.Label l = new SubmitRecord.Label();
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index e5b2ffb..26e9e54 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -16,10 +16,10 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
+import static com.google.gerrit.entities.Change.Status.ABANDONED;
 import static com.google.gerrit.entities.Change.Status.MERGED;
 import static com.google.gerrit.entities.Change.Status.NEW;
 import static com.google.gerrit.index.query.Predicate.and;
-import static com.google.gerrit.index.query.Predicate.or;
 import static com.google.gerrit.server.index.change.IndexedChangeQuery.convertOptions;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
@@ -28,13 +28,16 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.AndCardinalPredicate;
+import com.google.gerrit.index.query.AndPredicate;
+import com.google.gerrit.index.query.OrCardinalPredicate;
+import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.query.change.AndChangeSource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
-import com.google.gerrit.server.query.change.OrSource;
 import java.util.Arrays;
 import java.util.EnumSet;
 import java.util.Set;
@@ -71,7 +74,12 @@
     assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
     assertThat(out.getChildren())
         .containsExactly(
-            query(Predicate.or(ChangeStatusPredicate.open(), ChangeStatusPredicate.closed())), in)
+            query(
+                orCardinal(
+                    ChangeStatusPredicate.forStatus(NEW),
+                    ChangeStatusPredicate.forStatus(MERGED),
+                    ChangeStatusPredicate.forStatus(ABANDONED))),
+            in)
         .inOrder();
   }
 
@@ -88,7 +96,12 @@
     assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
     assertThat(out.getChildren())
         .containsExactly(
-            query(Predicate.or(ChangeStatusPredicate.open(), ChangeStatusPredicate.closed())), in)
+            query(
+                orCardinal(
+                    ChangeStatusPredicate.forStatus(NEW),
+                    ChangeStatusPredicate.forStatus(MERGED),
+                    ChangeStatusPredicate.forStatus(ABANDONED))),
+            in)
         .inOrder();
   }
 
@@ -97,7 +110,7 @@
     Predicate<ChangeData> in = parse("foo:a file:b");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
-    assertThat(out.getChildren()).containsExactly(query(in.getChild(1)), in.getChild(0)).inOrder();
+    assertThat(out.getChildren()).containsExactly(query(parse("file:b")), parse("foo:a")).inOrder();
   }
 
   @Test
@@ -116,8 +129,8 @@
 
     assertThat(out.getChild(0)).isEqualTo(query(firstIndexedSubQuery));
 
-    assertThat(out.getChild(1).getClass()).isSameInstanceAs(OrSource.class);
-    OrSource indexedSubTree = (OrSource) out.getChild(1);
+    assertThat(out.getChild(1).getClass()).isSameInstanceAs(OrPredicate.class);
+    OrPredicate<ChangeData> indexedSubTree = (OrPredicate<ChangeData>) out.getChild(1);
 
     Predicate<ChangeData> secondIndexedSubQuery = parse("foo:a OR file:b");
     assertThat(indexedSubTree.getChildren())
@@ -126,9 +139,9 @@
         .inOrder();
 
     // Same at the assertions above, that were added for readability
-    assertThat(out.getChild(0)).isEqualTo(query(in.getChild(0)));
+    assertThat(out.getChild(0)).isEqualTo(query(parse("-status:abandoned")));
     assertThat(indexedSubTree.getChildren())
-        .containsExactly(query(in.getChild(1).getChild(1)), in.getChild(1).getChild(0))
+        .containsExactly(query(parse("file:b")), parse("foo:a"))
         .inOrder();
   }
 
@@ -137,17 +150,17 @@
     Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(out.getClass()).isSameInstanceAs(AndChangeSource.class);
-    assertThat(out.getChildren()).containsExactly(query(in.getChild(1)), in.getChild(0)).inOrder();
+    assertThat(out.getChildren())
+        .containsExactly(query(parse("file:b OR file:c")), parse("-foo:a"))
+        .inOrder();
   }
 
   @Test
   public void multipleIndexPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("file:a OR foo:b OR file:c OR foo:d");
+    Predicate<ChangeData> in = parse("file:a OR file:b OR file:c");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isSameInstanceAs(OrSource.class);
-    assertThat(out.getChildren())
-        .containsExactly(query(or(in.getChild(0), in.getChild(2))), in.getChild(1), in.getChild(3))
-        .inOrder();
+    assertThat(out.getClass()).isSameInstanceAs(IndexedChangeQuery.class);
+    assertEquals(query(in), out);
   }
 
   @Test
@@ -156,7 +169,7 @@
     Predicate<ChangeData> out = rewrite(in);
     assertThat(AndChangeSource.class).isSameInstanceAs(out.getClass());
     assertThat(out.getChildren())
-        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
+        .containsExactly(query(andCardinal(parse("status:new"), parse("file:a"))), parse("bar:p"))
         .inOrder();
   }
 
@@ -166,7 +179,7 @@
     Predicate<ChangeData> out = rewrite(in);
     assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
     assertThat(out.getChildren())
-        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
+        .containsExactly(query(andCardinal(parse("status:new"), parse("file:a"))), parse("bar:p"))
         .inOrder();
   }
 
@@ -176,7 +189,7 @@
     Predicate<ChangeData> out = rewrite(in);
     assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
     assertThat(out.getChildren())
-        .containsExactly(query(and(in.getChild(0), in.getChild(2))), in.getChild(1))
+        .containsExactly(query(and(parse("status:new OR file:a"), parse("file:b"))), parse("bar:p"))
         .inOrder();
   }
 
@@ -186,7 +199,7 @@
     Predicate<ChangeData> out = rewrite(in, options(0, 5));
     assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
     assertThat(out.getChildren())
-        .containsExactly(query(in.getChild(1), 5), parse("limit:5"), parse("limit:5"))
+        .containsExactly(query(parse("file:a"), 5), parse("limit:5"), parse("limit:5"))
         .inOrder();
   }
 
@@ -195,7 +208,7 @@
     int n = 3;
     Predicate<ChangeData> f = parse("file:a");
     Predicate<ChangeData> l = parse("limit:" + n);
-    Predicate<ChangeData> in = andSource(f, l);
+    Predicate<ChangeData> in = AndPredicate.and(f, l);
     assertThat(rewrite.rewrite(in, options(0, n))).isEqualTo(andSource(query(f, 3), l));
     assertThat(rewrite.rewrite(in, options(1, n))).isEqualTo(andSource(query(f, 4), l));
     assertThat(rewrite.rewrite(in, options(2, n))).isEqualTo(andSource(query(f, 5), l));
@@ -207,12 +220,10 @@
     assertThat(status("file:a")).isEqualTo(all);
     assertThat(status("is:new")).containsExactly(NEW);
     assertThat(status("is:new OR is:merged")).containsExactly(NEW, MERGED);
-    assertThat(status("is:new OR is:x")).isEqualTo(all);
 
     assertThat(status("is:new is:merged")).isEmpty();
     assertThat(status("(is:new) (is:merged)")).isEmpty();
     assertThat(status("(is:new) (is:merged)")).isEmpty();
-    assertThat(status("is:new is:x")).containsExactly(NEW);
   }
 
   @Test
@@ -259,7 +270,17 @@
 
   @SafeVarargs
   private static AndChangeSource andSource(Predicate<ChangeData>... preds) {
-    return new AndChangeSource(Arrays.asList(preds));
+    return new AndChangeSource(Arrays.asList(preds), IndexConfig.createDefault());
+  }
+
+  @SafeVarargs
+  private static AndCardinalPredicate<ChangeData> andCardinal(Predicate<ChangeData>... preds) {
+    return new AndCardinalPredicate<>(Arrays.asList(preds));
+  }
+
+  @SafeVarargs
+  private static OrCardinalPredicate<ChangeData> orCardinal(Predicate<ChangeData>... preds) {
+    return new OrCardinalPredicate<>(Arrays.asList(preds));
   }
 
   private Predicate<ChangeData> rewrite(Predicate<ChangeData> in) throws QueryParseException {
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index a23ccab..15adcf8 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -14,8 +14,12 @@
 
 package com.google.gerrit.server.index.change;
 
+import static com.google.gerrit.index.SchemaUtil.schema;
+
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
@@ -28,11 +32,21 @@
 
 @Ignore
 public class FakeChangeIndex implements ChangeIndex {
-  static final Schema<ChangeData> V1 = new Schema<>(1, false, ImmutableList.of(ChangeField.STATUS));
+  static final Schema<ChangeData> V1 =
+      schema(
+          1,
+          ImmutableList.<FieldDef<ChangeData, ?>>of(),
+          ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.STATUS_FIELD),
+          ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.STATUS_SPEC));
 
   static final Schema<ChangeData> V2 =
-      new Schema<>(
-          2, false, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
+      schema(
+          2,
+          ImmutableList.of(),
+          ImmutableList.<IndexedField<ChangeData, ?>>of(
+              ChangeField.PATH_FIELD, ChangeField.STATUS_FIELD, ChangeField.UPDATED_FIELD),
+          ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+              ChangeField.PATH_SPEC, ChangeField.STATUS_SPEC, ChangeField.UPDATED_SPEC));
 
   private static class Source implements ChangeDataSource {
     private final Predicate<ChangeData> p;
@@ -74,11 +88,21 @@
   }
 
   @Override
+  public void insert(ChangeData obj) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
   public void replace(ChangeData cd) {
     throw new UnsupportedOperationException();
   }
 
   @Override
+  public void deleteByValue(ChangeData value) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
   public void delete(Change.Id id) {
     throw new UnsupportedOperationException();
   }
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 67b0342..e879170 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.index.query.OperatorPredicate;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import org.eclipse.jgit.lib.Config;
@@ -25,7 +26,7 @@
 public class FakeQueryBuilder extends ChangeQueryBuilder {
   FakeQueryBuilder(ChangeIndexCollection indexes) {
     super(
-        new ChangeQueryBuilder.Definition<>(FakeQueryBuilder.class),
+        new QueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
             null,
             null,
@@ -71,6 +72,6 @@
   }
 
   private Predicate<ChangeData> predicate(String name, String value) {
-    return new OperatorPredicate<ChangeData>(name, value) {};
+    return new OperatorPredicate<>(name, value) {};
   }
 }
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 8d019f3..1f0da16 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -56,6 +56,9 @@
           }
         };
     performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+
+    // Enable performance logging
+    config.setBoolean("tracing", null, "performanceLogging", true);
   }
 
   @After
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
index fefa066..b1cd8fb 100644
--- a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -32,8 +32,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
@@ -58,6 +56,9 @@
 
     testPerformanceLogger = new TestPerformanceLogger();
     performanceLoggerRegistrationHandle = performanceLoggers.add("gerrit", testPerformanceLogger);
+
+    // Enable performance logging
+    config.setBoolean("tracing", null, "performanceLogging", true);
   }
 
   @After
@@ -360,7 +361,7 @@
   }
 
   private static class TestPerformanceLogger implements PerformanceLogger {
-    private List<PerformanceLogEntry> logEntries = new ArrayList<>();
+    private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
     public void log(String operation, long durationMs, Metadata metadata) {
@@ -368,7 +369,7 @@
     }
 
     ImmutableList<PerformanceLogEntry> logEntries() {
-      return ImmutableList.copyOf(logEntries);
+      return logEntries.build();
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/mail/SignedTokenTest.java b/javatests/com/google/gerrit/server/mail/SignedTokenTest.java
index 41d8d69..27c4f56 100644
--- a/javatests/com/google/gerrit/server/mail/SignedTokenTest.java
+++ b/javatests/com/google/gerrit/server/mail/SignedTokenTest.java
@@ -152,7 +152,7 @@
   private static String randomString(int length) {
     String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
     Random random = new Random();
-    StringBuffer sb = new StringBuffer();
+    StringBuilder sb = new StringBuilder();
     for (int i = 0; i < length; i++) {
       int number = random.nextInt(62);
       sb.append(str.charAt(number));
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
index 78cefdf..d7a6282 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
@@ -22,7 +22,7 @@
 public class CommentSenderTest {
   private static class TestSender extends CommentSender {
     TestSender() {
-      super(null, null, null, null, null);
+      super(null, null, null, null, null, null, null);
     }
   }
 
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index 5980071..629b0cc 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -362,7 +362,7 @@
 
   private AccountState makeUser(String name, String email) {
     final Account.Id userId = Account.id(42);
-    final Account.Builder account = Account.builder(userId, TimeUtil.nowTs());
+    final Account.Builder account = Account.builder(userId, TimeUtil.now());
     account.setFullName(name);
     account.setPreferredEmail(email);
     return AccountState.forAccount(account.build());
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
index bb443f8..3ce60b8 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
@@ -1,3 +1,17 @@
+// 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;
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index ba514fd..be8f1f9 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -18,6 +18,7 @@
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
@@ -39,6 +40,10 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
+import com.google.gerrit.server.approval.testing.TestPatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.AnonymousCowardName;
@@ -46,11 +51,13 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.gerrit.server.config.GerritImportedServerIds;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.NullProjectCache;
 import com.google.gerrit.server.project.ProjectCache;
@@ -65,9 +72,13 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import java.sql.Timestamp;
-import java.util.TimeZone;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.Optional;
 import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -82,10 +93,13 @@
 @Ignore
 @RunWith(ConfigSuite.class)
 public abstract class AbstractChangeNotesTest {
-  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  protected static final String LOCAL_SERVER_ID = "gerrit";
+
+  private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
 
   @ConfigSuite.Parameter public Config testConfig;
 
+  protected Account.Id changeOwnerId;
   protected Account.Id otherUserId;
   protected FakeAccountCache accountCache;
   protected IdentifiedUser changeOwner;
@@ -109,81 +123,112 @@
 
   @Inject @GerritServerId protected String serverId;
 
+  @Inject protected ExternalIdCache externalIdCache;
+
   protected Injector injector;
   private String systemTimeZone;
 
+  @Inject protected ChangeNotes.Factory changeNotesFactory;
+
   @Before
   public void setUpTestEnvironment() throws Exception {
+    setupTestPrerequisites();
+
+    injector = createTestInjector(LOCAL_SERVER_ID);
+    createAllUsers(injector);
+    injector.injectMembers(this);
+  }
+
+  protected void setupTestPrerequisites() throws Exception {
     setTimeForTesting();
 
-    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.nowTs(), TZ);
+    serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.now(), ZONE_ID);
     project = Project.nameKey("test-project");
     repoManager = new InMemoryRepositoryManager();
     repo = repoManager.createRepository(project);
     tr = new TestRepository<>(repo);
     rw = tr.getRevWalk();
     accountCache = new FakeAccountCache();
-    Account.Builder co = Account.builder(Account.id(1), TimeUtil.nowTs());
+    Account.Builder co = Account.builder(Account.id(1), TimeUtil.now());
     co.setFullName("Change Owner");
     co.setPreferredEmail("change@owner.com");
     accountCache.put(co.build());
-    Account.Builder ou = Account.builder(Account.id(2), TimeUtil.nowTs());
+    Account.Builder ou = Account.builder(Account.id(2), TimeUtil.now());
     ou.setFullName("Other Account");
     ou.setPreferredEmail("other@account.com");
     accountCache.put(ou.build());
     assertableFanOutExecutor = new AssertableExecutorService();
-
-    injector =
-        Guice.createInjector(
-            new FactoryModule() {
-              @Override
-              public void configure() {
-                install(new GitModule());
-
-                install(new DefaultUrlFormatterModule());
-                install(NoteDbModule.forTest());
-                bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
-                bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
-                bind(GitRepositoryManager.class).toInstance(repoManager);
-                bind(ProjectCache.class).to(NullProjectCache.class);
-                bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
-                bind(String.class)
-                    .annotatedWith(AnonymousCowardName.class)
-                    .toProvider(AnonymousCowardNameProvider.class);
-                bind(String.class)
-                    .annotatedWith(CanonicalWebUrl.class)
-                    .toInstance("http://localhost:8080/");
-                bind(Boolean.class)
-                    .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);
-                bind(PersonIdent.class)
-                    .annotatedWith(GerritPersonIdent.class)
-                    .toInstance(serverIdent);
-                bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
-                bind(MetricMaker.class).to(DisabledMetricMaker.class);
-                bind(ExecutorService.class)
-                    .annotatedWith(FanOutExecutor.class)
-                    .toInstance(assertableFanOutExecutor);
-                bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
-                bind(InternalChangeQuery.class)
-                    .toProvider(
-                        () -> {
-                          throw new UnsupportedOperationException();
-                        });
-              }
-            });
-
-    injector.injectMembers(this);
-    repoManager.createRepository(allUsers);
-    changeOwner = userFactory.create(co.id());
-    otherUser = userFactory.create(ou.id());
-    otherUserId = otherUser.getAccountId();
+    changeOwnerId = co.id();
+    otherUserId = ou.id();
     internalUser = new InternalUser();
   }
 
+  protected Injector createTestInjector(String serverId, String... importedServerIds)
+      throws Exception {
+    return createTestInjector(DisabledExternalIdCache.module(), serverId, importedServerIds);
+  }
+
+  protected Injector createTestInjector(
+      Module extraGuiceModule, String serverId, String... importedServerIds) throws Exception {
+
+    return Guice.createInjector(
+        new FactoryModule() {
+          @Override
+          public void configure() {
+            install(extraGuiceModule);
+            install(new GitModule());
+
+            install(new DefaultUrlFormatterModule());
+            install(NoteDbModule.forTest());
+            bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
+            bind(String.class).annotatedWith(GerritServerId.class).toInstance(serverId);
+            bind(new TypeLiteral<ImmutableSet<String>>() {})
+                .annotatedWith(GerritImportedServerIds.class)
+                .toInstance(new ImmutableSet.Builder<String>().add(importedServerIds).build());
+            bind(GitRepositoryManager.class).toInstance(repoManager);
+            bind(ProjectCache.class).to(NullProjectCache.class);
+            bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
+            bind(String.class)
+                .annotatedWith(AnonymousCowardName.class)
+                .toProvider(AnonymousCowardNameProvider.class);
+            bind(String.class)
+                .annotatedWith(CanonicalWebUrl.class)
+                .toInstance("http://localhost:8080/");
+            bind(Boolean.class)
+                .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);
+            bind(PersonIdent.class).annotatedWith(GerritPersonIdent.class).toInstance(serverIdent);
+            bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+            bind(MetricMaker.class).to(DisabledMetricMaker.class);
+            bind(ExecutorService.class)
+                .annotatedWith(FanOutExecutor.class)
+                .toInstance(assertableFanOutExecutor);
+            bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
+            bind(InternalChangeQuery.class)
+                .toProvider(
+                    () -> {
+                      throw new UnsupportedOperationException();
+                    });
+            bind(PatchSetApprovalUuidGenerator.class).to(TestPatchSetApprovalUuidGenerator.class);
+          }
+        });
+  }
+
+  protected void createAllUsers(Injector injector)
+      throws RepositoryCaseMismatchException, RepositoryNotFoundException {
+    AllUsersName allUsersName = injector.getInstance(AllUsersName.class);
+
+    repoManager.createRepository(allUsersName);
+
+    IdentifiedUser.GenericFactory identifiedUserFactory =
+        injector.getInstance(IdentifiedUser.GenericFactory.class);
+    changeOwner = identifiedUserFactory.create(changeOwnerId);
+    otherUser = identifiedUserFactory.create(otherUserId);
+  }
+
   private void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
     TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -196,8 +241,12 @@
   }
 
   protected Change newChange(boolean workInProgress) throws Exception {
+    return newChange(injector, workInProgress);
+  }
+
+  protected Change newChange(Injector injector, boolean workInProgress) throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
-    ChangeUpdate u = newUpdateForNewChange(c, changeOwner);
+    ChangeUpdate u = newUpdate(injector, c, changeOwner, false);
     u.setChangeId(c.getKey().get());
     u.setBranch(c.getDest().branch());
     u.setWorkInProgress(workInProgress);
@@ -214,16 +263,21 @@
   }
 
   protected ChangeUpdate newUpdateForNewChange(Change c, CurrentUser user) throws Exception {
-    return newUpdate(c, user, false);
+    return newUpdate(injector, c, user, false);
   }
 
   protected ChangeUpdate newUpdate(Change c, CurrentUser user) throws Exception {
-    return newUpdate(c, user, true);
+    return newUpdate(injector, c, user, true);
   }
 
   protected ChangeUpdate newUpdate(Change c, CurrentUser user, boolean shouldExist)
       throws Exception {
-    ChangeUpdate update = TestChanges.newUpdate(injector, c, user, shouldExist);
+    return newUpdate(injector, c, user, shouldExist);
+  }
+
+  protected ChangeUpdate newUpdate(
+      Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
+    ChangeUpdate update = TestChanges.newUpdate(injector, c, Optional.of(user), shouldExist);
     update.setPatchSetId(c.currentPatchSetId());
     update.setAllowWriteToNewRef(true);
     return update;
@@ -233,6 +287,10 @@
     return new ChangeNotes(args, c, true, null).load();
   }
 
+  protected ChangeNotes newNotes(AbstractChangeNotes.Args cArgs, Change c) {
+    return new ChangeNotes(cArgs, c, true, null).load();
+  }
+
   protected static SubmitRecord submitRecord(
       String status, String errorMessage, SubmitRecord.Label... labels) {
     SubmitRecord rec = new SubmitRecord();
@@ -261,7 +319,7 @@
       int line,
       IdentifiedUser commenter,
       String parentUUID,
-      Timestamp t,
+      Instant t,
       String message,
       short side,
       ObjectId commitId,
@@ -282,11 +340,11 @@
     return c;
   }
 
-  protected static Timestamp truncate(Timestamp ts) {
-    return new Timestamp((ts.getTime() / 1000) * 1000);
+  protected static Instant truncate(Instant ts) {
+    return Instant.ofEpochMilli((ts.toEpochMilli() / 1000) * 1000);
   }
 
-  protected static Timestamp after(Change c, long millis) {
-    return new Timestamp(c.getCreatedOn().getTime() + millis);
+  protected static Instant after(Change c, long millis) {
+    return Instant.ofEpochMilli(c.getCreatedOn().toEpochMilli() + millis);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
new file mode 100644
index 0000000..24e28f3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNoteJsonTest.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.gson.Gson;
+import com.google.inject.TypeLiteral;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ChangeNoteJsonTest {
+  private final Gson gson = new ChangeNoteJson().getGson();
+
+  static class Child {
+    Optional<String> optionalValue;
+  }
+
+  static class Parent {
+    Optional<Child> optionalChild;
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeEmptyOptional() {
+    // given
+    Optional<?> empty = Optional.empty();
+
+    // when
+    String json = gson.toJson(empty);
+
+    // then
+    assertThat(json).isEqualTo("{}");
+
+    // and when
+    Optional<?> result = gson.fromJson(json, Optional.class);
+
+    // and then
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNonEmptyOptional() {
+    // given
+    String value = "foo";
+    Optional<String> nonEmpty = Optional.of(value);
+
+    // when
+    String json = gson.toJson(nonEmpty);
+
+    // then
+    assertThat(json).isEqualTo("{\n  \"value\": \"" + value + "\"\n}");
+
+    // and when
+    Optional<String> result = gson.fromJson(json, new TypeLiteral<Optional<String>>() {}.getType());
+
+    // and then
+    assertThat(result).isPresent();
+    assertThat(result.get()).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNestedNonEmptyOptional() {
+    String value = "foo";
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.of(value);
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String json = gson.toJson(parent);
+
+    assertThat(json)
+        .isEqualTo(
+            "{\n"
+                + "  \"optionalChild\": {\n"
+                + "    \"value\": {\n"
+                + "      \"optionalValue\": {\n"
+                + "        \"value\": \"foo\"\n"
+                + "      }\n"
+                + "    }\n"
+                + "  }\n"
+                + "}");
+
+    Parent result = gson.fromJson(json, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isPresent();
+    assertThat(result.optionalChild.get().optionalValue.get()).isEqualTo(value);
+  }
+
+  @Test
+  public void shouldSerializeAndDeserializeNestedEmptyOptional() {
+    Child fooChild = new Child();
+    fooChild.optionalValue = Optional.empty();
+    Parent parent = new Parent();
+    parent.optionalChild = Optional.of(fooChild);
+
+    String json = gson.toJson(parent);
+
+    assertThat(json)
+        .isEqualTo(
+            "{\n"
+                + "  \"optionalChild\": {\n"
+                + "    \"value\": {\n"
+                + "      \"optionalValue\": {}\n"
+                + "    }\n"
+                + "  }\n"
+                + "}");
+
+    Parent result = gson.fromJson(json, new TypeLiteral<Parent>() {}.getType());
+
+    assertThat(result.optionalChild).isPresent();
+    assertThat(result.optionalChild.get().optionalValue).isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
index f105cf1..9445f4a 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesCommitTest.java
@@ -98,7 +98,8 @@
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
     walk.reset();
     ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
-    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+    return new ChangeNotesParser(
+        newChange().getId(), tip, walk, changeNoteJson, args.metrics, serverId, externalIdCache);
   }
 
   private RevCommit writeCommit(String body) throws Exception {
@@ -106,7 +107,7 @@
     ChangeNotes notes = newNotes(change).load();
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     PersonIdent author =
-        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent);
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.now(), serverIdent);
     try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
       CommitBuilder cb = new CommitBuilder();
       cb.setParentId(notes.getRevision());
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index dc9b9cd..4543b50 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -32,6 +32,11 @@
 import org.junit.Before;
 import org.junit.Test;
 
+/**
+ * Tests for {@link ChangeNotesParser}.
+ *
+ * <p>When modifying storage format, please, add tests that both old and new data can be parsed.
+ */
 public class ChangeNotesParserTest extends AbstractChangeNotesTest {
   private TestRepository<InMemoryRepository> testRepo;
   private ChangeNotesRevWalk walk;
@@ -153,6 +158,42 @@
   }
 
   @Test
+  public void parseApprovalWithUUID() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7\n"
+            + "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n"
+            + "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n"
+            + "Subject: This is a test change\n");
+
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Label: Label1=+1, non-SHA1_UUID\n"
+            + "Label: Label1=+1, non-SHA1_UUID Gerrit User 2 <2@gerrit>\n"
+            + "Label: Label1=0, non-SHA1_UUID Gerrit User 2 <2@gerrit>\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=+1, \n");
+    assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=+1,\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: Label1=-1,  577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: Label1=-1,  577fb248e474018276351785930358ec0450e9f7\n");
+    // UUID for removals is not supported.
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: -Label1, 577fb248e474018276351785930358ec0450e9f7\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nLabel: -Label1, 577fb248e474018276351785930358ec0450e9f7 Other Account <2@gerrit>\n");
+  }
+
+  @Test
   public void parseCopiedApproval() throws Exception {
     assertParseSucceeds(
         "Update change\n"
@@ -160,20 +201,14 @@
             + "Branch: refs/heads/master\n"
             + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
             + "Patch-set: 1\n"
-            + "Copied-Label: Label1=+1 Account <1@gerrit>,Other Account <2@Gerrit>\n"
+            + "Copied-Label: Label1=+1 Account <1@gerrit>,Other Account <2@gerrit>\n"
             + "Copied-Label: Label2=+1 Account <1@gerrit>\n"
-            + "Copied-Label: Label3=+1 Account <1@gerrit>,Other Account <2@Gerrit> :\"tag\"\n"
-            + "Copied-Label: Label4=+1 Account <1@Gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label3=+1 Account <1@gerrit>,Other Account <2@gerrit> :\"tag\"\n"
+            + "Copied-Label: Label4=+1 Account <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: -Label1 Account <1@gerrit>,Other Account <2@gerrit>\\n"
+            + "Copied-Label: -Label1 Account <1@gerrit>\n"
             + "Subject: This is a test change\n");
-    assertParseSucceeds(
-        "Update change\n"
-            + "\n"
-            + "Branch: refs/heads/master\n"
-            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
-            + "Patch-set: 1\n"
-            + "Label: -Label1\n"
-            + "Label: -Label4 Account <1@gerrit>\n"
-            + "Subject: This is a test change\n");
+
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=X\n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1 = 1\n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: X+Y\n");
@@ -187,6 +222,59 @@
         "Update change\n\nPatch-set: 1\nCopied-Label: Label1 Other Account <2@gerrit>,Other "
             + "Account <2@gerrit>,Other Account <2@gerrit> \n");
     assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1 non-user\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: -Label1\n");
+  }
+
+  @Test
+  public void parseCopiedApprovalWithUUID() throws Exception {
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1 ,\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Copied-Label: Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>\n"
+            + "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n"
+            + "Copied-Label: Label3=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag\"\n"
+            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit> :\"tag with uuid delimiter , \"\n"
+            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
+            + "Subject: This is a test change\n");
+
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Copied-Label: Label2=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>\n"
+            + "Copied-Label: Label1=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n"
+            + "Copied-Label: Label3=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with uuid delimiter , \"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
+            + "Subject: This is a test change\n");
+
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1,\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1,\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1 ,\n");
+    assertParseFails(
+        "Copied-Label: Label1=+1,  577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 :\"tag\"\n");
+
+    // UUID for removals is not supported.
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: -Label1,"
+            + " 577fb248e474018276351785930358ec0450e9f7\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: -Label1,"
+            + " 577fb248e474018276351785930358ec0450e9f7 Other Account <2@gerrit>\n");
   }
 
   @Test
@@ -204,6 +292,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(
@@ -540,7 +639,9 @@
             "Update patch set 1\n"
                 + "\n"
                 + "Patch-set: 1\n"
-                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000"
+                + " \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added"
+                + " by Administrator using the hovercard menu\"}",
             false);
     ChangeNotesParser changeNotesParser = newParser(commit);
     changeNotesParser.parseAll();
@@ -559,7 +660,9 @@
                 + "\n"
                 + "Patch-set: 1\n"
                 + "Subject: Change subject\n"
-                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000"
+                + " \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added"
+                + " by Administrator using the hovercard menu\"}",
             false);
     ChangeNotesParser changeNotesParser = newParser(commit);
     changeNotesParser.parseAll();
@@ -589,7 +692,9 @@
             "Update patch set 1\n"
                 + "\n"
                 + "Patch-set: 1\n"
-                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000"
+                + " \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added"
+                + " by Administrator using the hovercard menu\"}",
             false);
     ChangeNotesParser changeNotesParser = newParser(commit);
     changeNotesParser.parseAll();
@@ -623,7 +728,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.now(), serverIdent),
         false);
   }
 
@@ -635,7 +740,7 @@
     ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
     return writeCommit(
         body,
-        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.nowTs(), serverIdent),
+        noteUtil.newAccountIdIdent(changeOwner.getAccount().id(), TimeUtil.now(), serverIdent),
         initWorkInProgress);
   }
 
@@ -677,6 +782,7 @@
   private ChangeNotesParser newParser(ObjectId tip) throws Exception {
     walk.reset();
     ChangeNoteJson changeNoteJson = injector.getInstance(ChangeNoteJson.class);
-    return new ChangeNotesParser(newChange().getId(), tip, walk, changeNoteJson, args.metrics);
+    return new ChangeNotesParser(
+        newChange().getId(), tip, walk, changeNoteJson, args.metrics, serverId, externalIdCache);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 0c26f1a..976ffc8 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -92,8 +92,8 @@
     cols =
         ChangeColumns.builder()
             .changeKey(Change.key(CHANGE_KEY))
-            .createdOn(new Timestamp(123456L))
-            .lastUpdatedOn(new Timestamp(234567L))
+            .createdOn(Instant.ofEpochMilli(123456L))
+            .lastUpdatedOn(Instant.ofEpochMilli(234567L))
             .owner(Account.id(1000))
             .branch("refs/heads/master")
             .subject("Test change")
@@ -135,7 +135,9 @@
   @Test
   public void serializeCreatedOn() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().createdOn(new Timestamp(98765L)).build()).build(),
+        newBuilder()
+            .columns(cols.toBuilder().createdOn(Instant.ofEpochMilli(98765L)).build())
+            .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -146,7 +148,9 @@
   @Test
   public void serializeLastUpdatedOn() throws Exception {
     assertRoundTrip(
-        newBuilder().columns(cols.toBuilder().lastUpdatedOn(new Timestamp(98765L)).build()).build(),
+        newBuilder()
+            .columns(cols.toBuilder().lastUpdatedOn(Instant.ofEpochMilli(98765L)).build())
+            .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -376,7 +380,7 @@
                     PatchSet.id(ID, 1), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .tag("tag")
-            .granted(new Timestamp(1212L))
+            .granted(Instant.ofEpochMilli(1212L))
             .build();
     Entities.PatchSetApproval psa1 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a1);
     ByteString a1Bytes = Protos.toByteString(psa1);
@@ -389,7 +393,7 @@
             .value(-1)
             .tag("tag")
             .copied(true)
-            .granted(new Timestamp(3434L))
+            .granted(Instant.ofEpochMilli(3434L))
             .build();
     Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
     ByteString a2Bytes = Protos.toByteString(psa2);
@@ -410,14 +414,62 @@
   }
 
   @Test
+  public void serializeApprovalsWithUUID() throws Exception {
+    PatchSetApproval a1 =
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    PatchSet.id(ID, 1), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
+            .value(1)
+            .tag("tag")
+            .granted(Instant.ofEpochMilli(1212L))
+            .build();
+    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(LabelId.VERIFIED)))
+            .uuid(Optional.of(PatchSetApproval.uuid("577fb248e474018276351785930358ec0450e9f7")))
+            .value(-1)
+            .tag("tag")
+            .copied(true)
+            .granted(Instant.ofEpochMilli(3434L))
+            .build();
+    Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
+    ByteString a2Bytes = Protos.toByteString(psa2);
+    assertThat(a2Bytes.size()).isEqualTo(98);
+    assertThat(a2Bytes).isNotEqualTo(a1Bytes);
+
+    assertRoundTrip(
+        newBuilder()
+            .approvals(ImmutableListMultimap.of(a2.patchSetId(), a2, a1.patchSetId(), a1).entries())
+            .build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setColumns(colsProto)
+            .addApproval(psa2)
+            .addApproval(psa1)
+            .build());
+  }
+
+  @Test
   public void serializeReviewers() throws Exception {
     assertRoundTrip(
         newBuilder()
             .reviewers(
                 ReviewerSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
-                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
+                    ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
+                        .put(
+                            ReviewerStateInternal.CC, Account.id(2001), Instant.ofEpochMilli(1212L))
+                        .put(
+                            ReviewerStateInternal.REVIEWER,
+                            Account.id(2002),
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -443,15 +495,15 @@
         newBuilder()
             .reviewersByEmail(
                 ReviewerByEmailSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Address, Timestamp>builder()
+                    ImmutableTable.<ReviewerStateInternal, Address, Instant>builder()
                         .put(
                             ReviewerStateInternal.CC,
                             Address.create("Name1", "email1@example.com"),
-                            new Timestamp(1212L))
+                            Instant.ofEpochMilli(1212L))
                         .put(
                             ReviewerStateInternal.REVIEWER,
                             Address.create("Name2", "email2@example.com"),
-                            new Timestamp(3434L))
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -481,7 +533,7 @@
                         ImmutableTable.of(
                             ReviewerStateInternal.CC,
                             Address.create("emailonly@example.com"),
-                            new Timestamp(1212L))))
+                            Instant.ofEpochMilli(1212L))))
                 .build(),
             ChangeNotesStateProto.newBuilder()
                 .setMetaId(SHA_BYTES)
@@ -509,9 +561,13 @@
         newBuilder()
             .pendingReviewers(
                 ReviewerSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
-                        .put(ReviewerStateInternal.CC, Account.id(2001), new Timestamp(1212L))
-                        .put(ReviewerStateInternal.REVIEWER, Account.id(2002), new Timestamp(3434L))
+                    ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
+                        .put(
+                            ReviewerStateInternal.CC, Account.id(2001), Instant.ofEpochMilli(1212L))
+                        .put(
+                            ReviewerStateInternal.REVIEWER,
+                            Account.id(2002),
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -537,15 +593,15 @@
         newBuilder()
             .pendingReviewersByEmail(
                 ReviewerByEmailSet.fromTable(
-                    ImmutableTable.<ReviewerStateInternal, Address, Timestamp>builder()
+                    ImmutableTable.<ReviewerStateInternal, Address, Instant>builder()
                         .put(
                             ReviewerStateInternal.CC,
                             Address.create("Name1", "email1@example.com"),
-                            new Timestamp(1212L))
+                            Instant.ofEpochMilli(1212L))
                         .put(
                             ReviewerStateInternal.REVIEWER,
                             Address.create("Name2", "email2@example.com"),
-                            new Timestamp(3434L))
+                            Instant.ofEpochMilli(3434L))
                         .build()))
             .build(),
         ChangeNotesStateProto.newBuilder()
@@ -585,12 +641,12 @@
             .reviewerUpdates(
                 ImmutableList.of(
                     ReviewerStatusUpdate.create(
-                        new Timestamp(1212L),
+                        Instant.ofEpochMilli(1212L),
                         Account.id(1000),
                         Account.id(2002),
                         ReviewerStateInternal.CC),
                     ReviewerStatusUpdate.create(
-                        new Timestamp(3434L),
+                        Instant.ofEpochMilli(3434L),
                         Account.id(1000),
                         Account.id(2001),
                         ReviewerStateInternal.REVIEWER)))
@@ -688,6 +744,7 @@
                 ImmutableList.of(
                     SubmitRequirementResult.builder()
                         .legacy(Optional.of(true))
+                        .hidden(Optional.of(true))
                         .patchSetCommitId(
                             ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd"))
                         .submitRequirement(
@@ -718,6 +775,7 @@
             .addSubmitRequirementResult(
                 SubmitRequirementResultProto.newBuilder()
                     .setLegacy(true)
+                    .setHidden(true)
                     .setCommit(
                         ObjectIdConverter.create()
                             .toByteString(
@@ -752,9 +810,11 @@
             .assigneeUpdates(
                 ImmutableList.of(
                     AssigneeStatusUpdate.create(
-                        new Timestamp(1212L), Account.id(1000), Optional.of(Account.id(2001))),
+                        Instant.ofEpochMilli(1212L),
+                        Account.id(1000),
+                        Optional.of(Account.id(2001))),
                     AssigneeStatusUpdate.create(
-                        new Timestamp(3434L), Account.id(1000), Optional.empty())))
+                        Instant.ofEpochMilli(3434L), Account.id(1000), Optional.empty())))
             .build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
@@ -799,7 +859,7 @@
         ChangeMessage.create(
             ChangeMessage.key(ID, "uuid1"),
             Account.id(1000),
-            new Timestamp(1212L),
+            Instant.ofEpochMilli(1212L),
             PatchSet.id(ID, 1));
     Entities.ChangeMessage m1Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m1);
     ByteString m1Bytes = Protos.toByteString(m1Proto);
@@ -809,7 +869,7 @@
         ChangeMessage.create(
             ChangeMessage.key(ID, "uuid2"),
             Account.id(2000),
-            new Timestamp(3434L),
+            Instant.ofEpochMilli(3434L),
             PatchSet.id(ID, 2));
     Entities.ChangeMessage m2Proto = ChangeMessageProtoConverter.INSTANCE.toProto(m2);
     ByteString m2Bytes = Protos.toByteString(m2Proto);
@@ -833,7 +893,7 @@
         new HumanComment(
             new Comment.Key("uuid1", "file1", 1),
             Account.id(1001),
-            new Timestamp(1212L),
+            Instant.ofEpochMilli(1212L),
             (short) 1,
             "message 1",
             "serverId",
@@ -845,7 +905,7 @@
         new HumanComment(
             new Comment.Key("uuid2", "file2", 2),
             Account.id(1002),
-            new Timestamp(3434L),
+            Instant.ofEpochMilli(3434L),
             (short) 2,
             "message 2",
             "serverId",
@@ -921,7 +981,7 @@
                     "submitRequirementsResult",
                     new TypeLiteral<ImmutableList<SubmitRequirementResult>>() {}.getType())
                 .put("updateCount", int.class)
-                .put("mergedOn", Timestamp.class)
+                .put("mergedOn", Instant.class)
                 .build());
   }
 
@@ -931,8 +991,8 @@
         .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("changeKey", Change.Key.class)
-                .put("createdOn", Timestamp.class)
-                .put("lastUpdatedOn", Timestamp.class)
+                .put("createdOn", Instant.class)
+                .put("lastUpdatedOn", Instant.class)
                 .put("owner", Account.Id.class)
                 .put("branch", String.class)
                 .put("currentPatchSetId", PatchSet.Id.class)
@@ -958,7 +1018,7 @@
                 .put("id", PatchSet.Id.class)
                 .put("commitId", ObjectId.class)
                 .put("uploader", Account.Id.class)
-                .put("createdOn", Timestamp.class)
+                .put("createdOn", Instant.class)
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("description", new TypeLiteral<Optional<String>>() {}.getType())
@@ -978,8 +1038,9 @@
         .hasAutoValueMethods(
             ImmutableMap.<String, Type>builder()
                 .put("key", PatchSetApproval.Key.class)
+                .put("uuid", new TypeLiteral<Optional<PatchSetApproval.UUID>>() {}.getType())
                 .put("value", short.class)
-                .put("granted", Timestamp.class)
+                .put("granted", Instant.class)
                 .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
@@ -995,7 +1056,7 @@
             ImmutableMap.of(
                 "table",
                 new TypeLiteral<
-                    ImmutableTable<ReviewerStateInternal, Account.Id, Timestamp>>() {}.getType(),
+                    ImmutableTable<ReviewerStateInternal, Account.Id, Instant>>() {}.getType(),
                 "accounts",
                 new TypeLiteral<ImmutableSet<Account.Id>>() {}.getType()));
   }
@@ -1007,7 +1068,7 @@
             ImmutableMap.of(
                 "table",
                 new TypeLiteral<
-                    ImmutableTable<ReviewerStateInternal, Address, Timestamp>>() {}.getType(),
+                    ImmutableTable<ReviewerStateInternal, Address, Instant>>() {}.getType(),
                 "users",
                 new TypeLiteral<ImmutableSet<Address>>() {}.getType()));
   }
@@ -1017,7 +1078,7 @@
     assertThatSerializedClass(ReviewerStatusUpdate.class)
         .hasAutoValueMethods(
             ImmutableMap.of(
-                "date", Timestamp.class,
+                "date", Instant.class,
                 "updatedBy", Account.Id.class,
                 "reviewer", Account.Id.class,
                 "state", ReviewerStateInternal.class));
@@ -1029,7 +1090,7 @@
         .hasAutoValueMethods(
             ImmutableMap.of(
                 "date",
-                Timestamp.class,
+                Instant.class,
                 "updatedBy",
                 Account.Id.class,
                 "currentAssignee",
@@ -1067,7 +1128,7 @@
   @Test
   public void serializeMergedOn() throws Exception {
     assertRoundTrip(
-        newBuilder().mergedOn(new Timestamp(234567L)).build(),
+        newBuilder().mergedOn(Instant.ofEpochMilli(234567L)).build(),
         ChangeNotesStateProto.newBuilder()
             .setMetaId(SHA_BYTES)
             .setChangeId(ID.get())
@@ -1086,7 +1147,7 @@
             ImmutableMap.<String, Type>builder()
                 .put("key", ChangeMessage.Key.class)
                 .put("author", Account.Id.class)
-                .put("writtenOn", Timestamp.class)
+                .put("writtenOn", Instant.class)
                 .put("message", String.class)
                 .put("patchset", PatchSet.Id.class)
                 .put("tag", String.class)
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index c524c94..9cd002e 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.notedb;
 
 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.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -36,6 +37,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
@@ -61,7 +63,6 @@
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.TestChanges;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -131,7 +132,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
@@ -163,7 +164,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals().all();
     assertThat(approvals).hasSize(1);
     assertThat(approvals.entries().asList().get(0).getValue().tag()).hasValue(tag2);
   }
@@ -193,7 +194,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
@@ -209,7 +210,7 @@
 
     ChangeNotes notes = newNotes(c);
 
-    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals();
+    ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals = notes.getApprovals().all();
     assertThat(approvals).hasSize(1);
     PatchSetApproval approval = approvals.entries().asList().get(0).getValue();
     assertThat(approval.tag()).hasValue(integrationTag);
@@ -235,8 +236,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
@@ -244,12 +245,14 @@
     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)));
+    assertParsedUuid(psas.get(0));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(1);
     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());
+    assertParsedUuid(psas.get(1));
   }
 
   @Test
@@ -267,7 +270,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     ChangeNotes notes = newNotes(c);
-    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals();
+    ListMultimap<PatchSet.Id, PatchSetApproval> psas = notes.getApprovals().all();
     assertThat(psas).hasSize(2);
 
     PatchSetApproval psa1 = Iterables.getOnlyElement(psas.get(ps1));
@@ -276,6 +279,7 @@
     assertThat(psa1.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa1.value()).isEqualTo((short) -1);
     assertThat(psa1.granted()).isEqualTo(truncate(after(c, 2000)));
+    assertParsedUuid(psa1);
 
     PatchSetApproval psa2 = Iterables.getOnlyElement(psas.get(ps2));
     assertThat(psa2.patchSetId()).isEqualTo(ps2);
@@ -283,6 +287,7 @@
     assertThat(psa2.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa2.value()).isEqualTo((short) +1);
     assertThat(psa2.granted()).isEqualTo(truncate(after(c, 4000)));
+    assertParsedUuid(psa2);
   }
 
   @Test
@@ -294,18 +299,20 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) -1);
+    assertParsedUuid(psa);
 
     update = newUpdate(c, changeOwner);
     update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
 
     notes = newNotes(c);
-    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    psa = Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(psa.value()).isEqualTo((short) 1);
+    assertParsedUuid(psa);
   }
 
   @Test
@@ -320,8 +327,8 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getApprovals().keySet()).containsExactly(c.currentPatchSetId());
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).patchSetId()).isEqualTo(c.currentPatchSetId());
@@ -329,12 +336,14 @@
     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)));
+    assertParsedUuid(psas.get(0));
 
     assertThat(psas.get(1).patchSetId()).isEqualTo(c.currentPatchSetId());
     assertThat(psas.get(1).accountId().get()).isEqualTo(2);
     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)));
+    assertParsedUuid(psas.get(1));
   }
 
   @Test
@@ -346,17 +355,18 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId().get()).isEqualTo(1);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
+    assertParsedUuid(psa);
 
     update = newUpdate(c, changeOwner);
     update.removeApproval("Not-For-Long");
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals())
+    assertThat(notes.getApprovals().all())
         .containsExactlyEntriesIn(
             ImmutableListMultimap.of(
                 psa.patchSetId(),
@@ -368,6 +378,250 @@
   }
 
   @Test
+  public void approval_UUIDGenerated_forAllValues() throws Exception {
+    for (int value = -2; value <= 2; value++) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.putApproval(LabelId.CODE_REVIEW, (short) value);
+      update.commit();
+
+      ChangeNotes notes = newNotes(c);
+      PatchSetApproval psa =
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+      assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
+      assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(psa.value()).isEqualTo((short) value);
+      assertParsedUuid(psa);
+    }
+  }
+
+  @Test
+  public void emptyApproval_uuidGenerated() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+    assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(psa.value()).isEqualTo((short) -1);
+    assertParsedUuid(psa);
+
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 0);
+    update.commit();
+
+    notes = newNotes(c);
+    PatchSetApproval emptyPsa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
+    assertThat(emptyPsa.key()).isEqualTo(psa.key());
+    assertThat(emptyPsa.value()).isEqualTo((short) 0);
+    assertThat(emptyPsa.label()).isEqualTo(psa.label());
+    assertParsedUuid(emptyPsa);
+  }
+
+  @Test
+  public void removedApproval_noUuid() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval psa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+    assertThat(psa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(psa.value()).isEqualTo((short) -1);
+    assertParsedUuid(psa);
+
+    update = newUpdate(c, changeOwner);
+    update.removeApproval(LabelId.CODE_REVIEW);
+    update.commit();
+
+    notes = newNotes(c);
+    PatchSetApproval removedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
+    assertThat(removedPsa.key()).isEqualTo(psa.key());
+    assertThat(removedPsa.value()).isEqualTo((short) 0);
+    assertThat(removedPsa.label()).isEqualTo(psa.label());
+    assertThat(removedPsa.uuid()).isEmpty();
+  }
+
+  @Test
+  public void reissuedApproval_samePatchSet_differentUUID() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    PatchSetApproval originalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+
+    assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(originalPsa.value()).isEqualTo((short) -1);
+    assertParsedUuid(originalPsa);
+
+    // Remove approval from current patch set
+    update = newUpdate(c, changeOwner);
+    update.removeApproval(LabelId.CODE_REVIEW);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(1);
+    PatchSetApproval removedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+    assertThat(removedPsa.key()).isEqualTo(originalPsa.key());
+    assertThat(removedPsa.value()).isEqualTo(0);
+    // Add approval with the same author, label, value to the current patch set
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(1);
+    PatchSetApproval reAddedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+
+    assertThat(reAddedPsa.key()).isEqualTo(originalPsa.key());
+    assertThat(reAddedPsa.value()).isEqualTo(originalPsa.value());
+    // The re-added approval has a different UUID
+    assertParsedUuid(reAddedPsa);
+    assertThat(reAddedPsa.uuid().get()).isNotEqualTo(originalPsa.uuid().get());
+  }
+
+  @Test
+  public void reissuedApproval_otherPatchSet_differentUUID() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    PatchSetApproval originalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+
+    assertThat(originalPsa.patchSetId()).isEqualTo(c.currentPatchSetId());
+    assertThat(originalPsa.accountId().get()).isEqualTo(1);
+    assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(originalPsa.value()).isEqualTo((short) -1);
+    assertParsedUuid(originalPsa);
+
+    // Create new PatchSet and re-issue vote
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(2);
+    PatchSetApproval postUpdateOriginalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(originalPsa.patchSetId()));
+    PatchSetApproval reAddedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+
+    // Same patch set approval for the original patch set is returned after the vote was re-issued
+    // on the next patch set
+    assertThat(postUpdateOriginalPsa).isEqualTo(originalPsa);
+
+    assertThat(reAddedPsa.accountId()).isEqualTo(originalPsa.accountId());
+    assertThat(reAddedPsa.label()).isEqualTo(originalPsa.label());
+    assertThat(reAddedPsa.value()).isEqualTo(originalPsa.value());
+
+    // The re-added approval has a different UUID
+    assertParsedUuid(reAddedPsa);
+    assertThat(reAddedPsa.uuid().get()).isNotEqualTo(originalPsa.uuid().get());
+  }
+
+  @Test
+  public void approvalUUID_samePatchSet_differentUsers() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.putApprovalFor(otherUserId, LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals =
+        notes.getApprovals().all().get(c.currentPatchSetId());
+    assertThat(patchSetApprovals).hasSize(2);
+    assertThat(
+            patchSetApprovals.stream()
+                .filter(psa -> psa.value() == (short) -1 && psa.label().equals(LabelId.CODE_REVIEW))
+                .count())
+        .isEqualTo(2);
+    // Count UUIDs to make sure they are unique
+    assertThat(patchSetApprovals.stream().map(psa -> psa.uuid().get()).distinct().count())
+        .isEqualTo(2);
+    assertThat(patchSetApprovals.stream().map(psa -> psa.accountId()).collect(toImmutableSet()))
+        .containsExactly(changeOwner.getAccountId(), otherUserId);
+  }
+
+  @Test
+  public void approvalUUID_samePatchSet_differentLabels() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.VERIFIED, (short) -1);
+    update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getApprovals().all().keySet()).containsExactly(c.currentPatchSetId());
+    List<PatchSetApproval> patchSetApprovals =
+        notes.getApprovals().all().get(c.currentPatchSetId());
+    assertThat(patchSetApprovals).hasSize(2);
+    assertThat(
+            patchSetApprovals.stream()
+                .filter(
+                    psa ->
+                        psa.value() == (short) -1
+                            && psa.accountId().equals(changeOwner.getAccountId()))
+                .count())
+        .isEqualTo(2);
+
+    // Count UUIDs to make sure they are unique
+    assertThat(patchSetApprovals.stream().map(psa -> psa.uuid().get()).distinct().count())
+        .isEqualTo(2);
+    assertThat(patchSetApprovals.stream().map(psa -> psa.label()).collect(toImmutableSet()))
+        .containsExactly(LabelId.VERIFIED, LabelId.CODE_REVIEW);
+  }
+
+  @Test
+  public void approvalUUID_differentChanges() throws Exception {
+    Change c1 = newChange();
+    ChangeUpdate update1 = newUpdate(c1, changeOwner);
+    update1.putApproval(LabelId.CODE_REVIEW, (short) +2);
+    update1.commit();
+
+    Change c2 = newChange();
+    ChangeUpdate update = newUpdate(c2, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) +2);
+    update.commit();
+
+    ChangeNotes notes1 = newNotes(c1);
+    PatchSetApproval psa1 =
+        Iterables.getOnlyElement(notes1.getApprovals().all().get(c1.currentPatchSetId()));
+    ChangeNotes notes2 = newNotes(c2);
+    PatchSetApproval psa2 =
+        Iterables.getOnlyElement(notes2.getApprovals().all().get(c2.currentPatchSetId()));
+    assertThat(psa1.label()).isEqualTo(psa2.label());
+    assertThat(psa1.accountId()).isEqualTo(psa2.accountId());
+    assertThat(psa1.value()).isEqualTo(psa2.value());
+
+    // UUID is global: different across changes.
+    assertThat(psa1.uuid()).isNotEqualTo(psa2.uuid());
+  }
+
+  @Test
   public void removeOtherUsersApprovals() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, otherUser);
@@ -376,25 +630,23 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval psa =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 1);
+    assertParsedUuid(psa);
 
     update = newUpdate(c, changeOwner);
     update.removeApprovalFor(otherUserId, "Not-For-Long");
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getApprovals())
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                psa.patchSetId(),
-                PatchSetApproval.builder()
-                    .key(psa.key())
-                    .value(0)
-                    .granted(update.getWhen())
-                    .build()));
+    PatchSetApproval removedPsa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(psa.patchSetId()));
+    assertThat(removedPsa.key()).isEqualTo(psa.key());
+    assertThat(removedPsa.value()).isEqualTo((short) 0);
+    assertThat(removedPsa.label()).isEqualTo(psa.label());
+    assertThat(removedPsa.uuid()).isEmpty();
 
     // Add back approval on same label.
     update = newUpdate(c, otherUser);
@@ -402,10 +654,11 @@
     update.commit();
 
     notes = newNotes(c);
-    psa = Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    psa = Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(psa.accountId()).isEqualTo(otherUserId);
     assertThat(psa.label()).isEqualTo("Not-For-Long");
     assertThat(psa.value()).isEqualTo((short) 2);
+    assertParsedUuid(psa);
   }
 
   @Test
@@ -418,7 +671,7 @@
 
     ChangeNotes notes = newNotes(c);
     ImmutableList<PatchSetApproval> approvals =
-        notes.getApprovals().get(c.currentPatchSetId()).stream()
+        notes.getApprovals().all().get(c.currentPatchSetId()).stream()
             .sorted(comparing(a -> a.accountId().get()))
             .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
@@ -426,10 +679,13 @@
     assertThat(approvals.get(0).accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
+    assertParsedUuid(approvals.get(0));
 
     assertThat(approvals.get(1).accountId()).isEqualTo(otherUser.getAccountId());
     assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) -1);
+    assertThat(approvals.get(1).uuid()).isPresent();
+    assertParsedUuid(approvals.get(1));
   }
 
   @Test
@@ -457,14 +713,17 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(2);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertParsedUuid(approvals.get(1));
+
     assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo((short) 2);
     assertThat(approvals.get(1).postSubmit()).isTrue();
+    assertParsedUuid(approvals.get(1));
   }
 
   @Test
@@ -497,20 +756,161 @@
 
     ChangeNotes notes = newNotes(c);
 
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(3);
     assertThat(approvals.get(0).accountId()).isEqualTo(ownerId);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo(1);
     assertThat(approvals.get(0).postSubmit()).isFalse();
+    assertParsedUuid(approvals.get(0));
+
     assertThat(approvals.get(1).accountId()).isEqualTo(ownerId);
     assertThat(approvals.get(1).label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approvals.get(1).value()).isEqualTo(2);
     assertThat(approvals.get(1).postSubmit()).isFalse(); // During submit.
+    assertParsedUuid(approvals.get(1));
+
     assertThat(approvals.get(2).accountId()).isEqualTo(otherId);
     assertThat(approvals.get(2).label()).isEqualTo("Other-Label");
     assertThat(approvals.get(2).value()).isEqualTo(2);
     assertThat(approvals.get(2).postSubmit()).isTrue();
+    assertParsedUuid(approvals.get(2));
+  }
+
+  @Test
+  public void copiedApprovals_keepsUUID() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.CODE_REVIEW, (short) 2);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval originalPsa =
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+    assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(originalPsa.value()).isEqualTo(2);
+    assertThat(originalPsa.tag()).isEmpty();
+    assertThat(originalPsa.realAccountId()).isEqualTo(changeOwner.getAccountId());
+    assertParsedUuid(originalPsa);
+
+    // Copied approvals are persisted at the patch set upload, add new patch set
+    incrementPatchSet(c);
+
+    addCopiedApproval(c, changeOwner, originalPsa);
+
+    notes = newNotes(c);
+    assertThat(notes.getApprovals().all().keySet()).hasSize(2);
+    PatchSetApproval copiedApproval =
+        Iterables.getOnlyElement(
+            notes.getApprovals().all().get(c.currentPatchSetId()).stream()
+                .filter(a -> a.copied())
+                .collect(toImmutableList()));
+    PatchSetApproval nonCopiedApproval =
+        Iterables.getOnlyElement(
+            notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
+                .filter(a -> !a.copied())
+                .collect(toImmutableList()));
+
+    // Still same original PSA is returned
+    assertThat(nonCopiedApproval).isEqualTo(originalPsa);
+
+    // The copied approval matches the original approval, including UUID
+    assertCopiedApproval(originalPsa, copiedApproval);
+  }
+
+  @Test
+  public void copiedApprovals_withRealUserAndTag_keepsUUID() throws Exception {
+    ImmutableList<String> strangeTags =
+        ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
+    for (String strangeTag : strangeTags) {
+      Change c = newChange();
+      CurrentUser otherUserAsOwner =
+          userFactory.runAs(/* remotePeer= */ null, changeOwner.getAccountId(), otherUser);
+      ChangeUpdate update = newUpdate(c, otherUserAsOwner);
+      update.putApproval(LabelId.CODE_REVIEW, (short) 2);
+      update.setTag(strangeTag);
+      update.commit();
+      ChangeNotes notes = newNotes(c);
+      PatchSetApproval originalPsa =
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+      assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+      assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(originalPsa.value()).isEqualTo(2);
+      assertThat(originalPsa.tag()).hasValue(NoteDbUtil.sanitizeFooter(strangeTag));
+      assertThat(originalPsa.realAccountId()).isEqualTo(otherUserId);
+      assertParsedUuid(originalPsa);
+
+      // Copied approvals are persisted at the patch set upload, add new patch set
+      incrementPatchSet(c);
+
+      addCopiedApproval(c, changeOwner, originalPsa);
+
+      notes = newNotes(c);
+      assertThat(notes.getApprovals().all().keySet()).hasSize(2);
+      PatchSetApproval copiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovals().all().get(c.currentPatchSetId()).stream()
+                  .filter(a -> a.copied())
+                  .collect(toImmutableList()));
+      PatchSetApproval nonCopiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
+                  .filter(a -> !a.copied())
+                  .collect(toImmutableList()));
+
+      // Still same original PSA is returned
+      assertThat(nonCopiedApproval).isEqualTo(originalPsa);
+
+      // The copied approval matches the original approval, including UUID
+      assertCopiedApproval(originalPsa, copiedApproval);
+    }
+  }
+
+  @Test
+  public void copiedApprovals_withTag_keepsUUID() throws Exception {
+    ImmutableList<String> strangeTags =
+        ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
+    for (String strangeTag : strangeTags) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.putApproval(LabelId.CODE_REVIEW, (short) 2);
+      update.setTag(strangeTag);
+      update.commit();
+
+      ChangeNotes notes = newNotes(c);
+      PatchSetApproval originalPsa =
+          Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
+      assertThat(originalPsa.accountId()).isEqualTo(changeOwner.getAccountId());
+      assertThat(originalPsa.label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(originalPsa.value()).isEqualTo(2);
+      assertThat(originalPsa.tag()).hasValue(NoteDbUtil.sanitizeFooter(strangeTag));
+      assertThat(originalPsa.realAccountId()).isEqualTo(changeOwner.getAccountId());
+      assertParsedUuid(originalPsa);
+      // Copied approvals are persisted at the patch set upload, add new patch set
+      incrementPatchSet(c);
+
+      addCopiedApproval(c, changeOwner, originalPsa);
+
+      notes = newNotes(c);
+      assertThat(notes.getApprovals().all().keySet()).hasSize(2);
+      PatchSetApproval copiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovals().all().get(c.currentPatchSetId()).stream()
+                  .filter(a -> a.copied())
+                  .collect(toImmutableList()));
+      PatchSetApproval nonCopiedApproval =
+          Iterables.getOnlyElement(
+              notes.getApprovals().all().get(originalPsa.patchSetId()).stream()
+                  .filter(a -> !a.copied())
+                  .collect(toImmutableList()));
+
+      // Still same original PSA is returned
+      assertThat(nonCopiedApproval).isEqualTo(originalPsa);
+
+      // The copied approval matches the original approval, including UUID
+      assertCopiedApproval(originalPsa, copiedApproval);
+    }
   }
 
   @Test
@@ -526,25 +926,23 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .tag("tag")
             .realAccountId(otherUserId)
             .build());
     update.putApprovalFor(otherUserId, LabelId.CODE_REVIEW, (short) -1);
     update.commit();
 
-    // Only the non copied approval is reachable by getApprovals.
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().onlyNonCopied().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(otherUser.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approval.value()).isEqualTo((short) -1);
     assertThat(approval.copied()).isFalse();
 
-    // Get approvals with copied gets all of the approvals (including copied).
     ImmutableList<PatchSetApproval> approvals =
-        notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+        notes.getApprovals().all().get(c.currentPatchSetId()).stream()
             .sorted(comparing(a -> a.accountId().get()))
             .collect(toImmutableList());
     assertThat(approvals).hasSize(2);
@@ -578,7 +976,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.commit();
 
@@ -587,7 +985,7 @@
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovalsWithCopied().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     // The vote got removed since the latest patch-set only has one vote and it's "0". The copied
@@ -611,14 +1009,14 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .tag(strangeTag)
             .build());
     update.commit();
 
     ChangeNotes notes = newNotes(c);
     PatchSetApproval approval =
-        Iterables.getOnlyElement(notes.getApprovalsWithCopied().get(c.currentPatchSetId()));
+        Iterables.getOnlyElement(notes.getApprovals().all().get(c.currentPatchSetId()));
     assertThat(approval.accountId()).isEqualTo(changeOwner.getAccountId());
     assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
     assertThat(approval.value()).isEqualTo((short) 1);
@@ -640,7 +1038,7 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.putCopiedApproval(
         PatchSetApproval.builder()
@@ -651,7 +1049,7 @@
                     LabelId.create(LabelId.VERIFIED)))
             .value(1)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.commit();
 
@@ -676,12 +1074,12 @@
                     LabelId.create(LabelId.CODE_REVIEW)))
             .value(2)
             .copied(true)
-            .granted(TimeUtil.nowTs())
+            .granted(TimeUtil.now())
             .build());
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovalsWithCopied().values());
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovals().all().values());
     assertThat(approvals).hasSize(2);
     assertThat(approvals.get(0).label()).isEqualTo(LabelId.VERIFIED);
     assertThat(approvals.get(0).value()).isEqualTo((short) 1);
@@ -700,11 +1098,11 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    Instant ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(
             ReviewerSet.fromTable(
-                ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+                ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
                     .put(REVIEWER, Account.id(1), ts)
                     .put(REVIEWER, Account.id(2), ts)
                     .build()));
@@ -719,11 +1117,11 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    Instant ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(
             ReviewerSet.fromTable(
-                ImmutableTable.<ReviewerStateInternal, Account.Id, Timestamp>builder()
+                ImmutableTable.<ReviewerStateInternal, Account.Id, Instant>builder()
                     .put(REVIEWER, Account.id(1), ts)
                     .put(CC, Account.id(2), ts)
                     .build()));
@@ -737,7 +1135,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts = new Timestamp(update.getWhen().getTime());
+    Instant ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(REVIEWER, Account.id(2), ts)));
 
@@ -746,7 +1144,7 @@
     update.commit();
 
     notes = newNotes(c);
-    ts = new Timestamp(update.getWhen().getTime());
+    ts = update.getWhen();
     assertThat(notes.getReviewers())
         .isEqualTo(ReviewerSet.fromTable(ImmutableTable.of(CC, Account.id(2), ts)));
   }
@@ -767,7 +1165,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
     assertThat(psas.get(1).accountId()).isEqualTo(otherUser.getAccount().id());
@@ -777,7 +1175,7 @@
     update.commit();
 
     notes = newNotes(c);
-    psas = notes.getApprovals().get(c.currentPatchSetId());
+    psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(1);
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
   }
@@ -881,7 +1279,7 @@
     update.commit();
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getMergedOn()).isPresent();
-    Timestamp mergedOn = notes.getMergedOn().get();
+    Instant mergedOn = notes.getMergedOn().get();
     assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
 
     // Next update does not change mergedOn date.
@@ -907,7 +1305,7 @@
     update.commit();
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getMergedOn()).isPresent();
-    Timestamp mergedOn = notes.getMergedOn().get();
+    Instant mergedOn = notes.getMergedOn().get();
     assertThat(mergedOn).isEqualTo(notes.getChange().getLastUpdatedOn());
 
     incrementPatchSet(c);
@@ -1301,7 +1699,7 @@
   public void createdOnChangeNotes() throws Exception {
     Change c = newChange();
 
-    Timestamp createdOn = newNotes(c).getChange().getCreatedOn();
+    Instant createdOn = newNotes(c).getChange().getCreatedOn();
     assertThat(createdOn).isNotNull();
 
     // An update doesn't affect the createdOn timestamp.
@@ -1316,54 +1714,54 @@
     Change c = newChange();
 
     ChangeNotes notes = newNotes(c);
-    Timestamp ts1 = notes.getChange().getLastUpdatedOn();
+    Instant ts1 = notes.getChange().getLastUpdatedOn();
     assertThat(ts1).isEqualTo(notes.getChange().getCreatedOn());
 
     // Various kinds of updates that update the timestamp.
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setTopic("topic"); // Change something to get a new commit.
     update.commit();
-    Timestamp ts2 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts2 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts2).isGreaterThan(ts1);
 
     update = newUpdate(c, changeOwner);
     update.setChangeMessage("Some message");
     update.commit();
-    Timestamp ts3 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts3 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts3).isGreaterThan(ts2);
 
     update = newUpdate(c, changeOwner);
     update.setHashtags(ImmutableSet.of("foo"));
     update.commit();
-    Timestamp ts4 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts4 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts4).isGreaterThan(ts3);
 
     incrementPatchSet(c);
-    Timestamp ts5 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts5 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts5).isGreaterThan(ts4);
 
     update = newUpdate(c, changeOwner);
     update.putApproval(LabelId.CODE_REVIEW, (short) 1);
     update.commit();
-    Timestamp ts6 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts6 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts6).isGreaterThan(ts5);
 
     update = newUpdate(c, changeOwner);
     update.setStatus(Change.Status.ABANDONED);
     update.commit();
-    Timestamp ts7 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts7 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts7).isGreaterThan(ts6);
 
     update = newUpdate(c, changeOwner);
     update.putReviewer(otherUser.getAccountId(), ReviewerStateInternal.REVIEWER);
     update.commit();
-    Timestamp ts8 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts8 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts8).isGreaterThan(ts7);
 
     update = newUpdate(c, changeOwner);
     update.setGroups(ImmutableList.of("a", "b"));
     update.commit();
-    Timestamp ts9 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts9 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts9).isGreaterThan(ts8);
 
     // Finish off by merging the change.
@@ -1377,7 +1775,7 @@
                 submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
                 submitLabel("Alternative-Code-Review", "NEED", null))));
     update.commit();
-    Timestamp ts10 = newNotes(c).getChange().getLastUpdatedOn();
+    Instant ts10 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts10).isGreaterThan(ts9);
   }
 
@@ -1490,7 +1888,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
@@ -1499,7 +1897,7 @@
 
     ChangeNotes notes = newNotes(c);
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
-    assertThat(notes.getApprovals()).isNotEmpty();
+    assertThat(notes.getApprovals().all()).isNotEmpty();
     assertThat(notes.getChangeMessages()).isNotEmpty();
     assertThat(notes.getHumanComments()).isNotEmpty();
 
@@ -1515,7 +1913,7 @@
 
     notes = newNotes(c);
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1);
-    assertThat(notes.getApprovals()).isEmpty();
+    assertThat(notes.getApprovals().all()).isEmpty();
     assertThat(notes.getChangeMessages()).isEmpty();
     assertThat(notes.getHumanComments()).isEmpty();
   }
@@ -1579,7 +1977,7 @@
     // comment on ps2
     update = newUpdate(c, changeOwner);
     update.setPatchSetId(psId2);
-    Timestamp ts = TimeUtil.nowTs();
+    Instant ts = TimeUtil.now();
     update.putComment(
         HumanComment.Status.PUBLISHED,
         newComment(
@@ -1628,7 +2026,7 @@
     }
 
     ChangeNotes notes = newNotes(c);
-    List<PatchSetApproval> psas = notes.getApprovals().get(c.currentPatchSetId());
+    List<PatchSetApproval> psas = notes.getApprovals().all().get(c.currentPatchSetId());
     assertThat(psas).hasSize(2);
 
     assertThat(psas.get(0).accountId()).isEqualTo(changeOwner.getAccount().id());
@@ -1647,7 +2045,7 @@
     String uuid1 = "uuid1";
     String message1 = "comment 1";
     CommentRange range1 = new CommentRange(1, 1, 2, 1);
-    Timestamp time1 = TimeUtil.nowTs();
+    Instant time1 = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
     RevCommit tipCommit;
     try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
@@ -1688,7 +2086,13 @@
     try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
       ChangeNotesParser notesWithComments =
           new ChangeNotesParser(
-              c.getId(), commitWithComments.copy(), rw, changeNoteJson, args.metrics);
+              c.getId(),
+              commitWithComments.copy(),
+              rw,
+              changeNoteJson,
+              args.metrics,
+              serverId,
+              externalIdCache);
       ChangeNotesState state = notesWithComments.parseAll();
       assertThat(state.approvals()).isEmpty();
       assertThat(state.publishedComments()).hasSize(1);
@@ -1697,7 +2101,13 @@
     try (ChangeNotesRevWalk rw = ChangeNotesCommit.newRevWalk(repo)) {
       ChangeNotesParser notesWithApprovals =
           new ChangeNotesParser(
-              c.getId(), commitWithApprovals.copy(), rw, changeNoteJson, args.metrics);
+              c.getId(),
+              commitWithApprovals.copy(),
+              rw,
+              changeNoteJson,
+              args.metrics,
+              serverId,
+              externalIdCache);
 
       ChangeNotesState state = notesWithApprovals.parseAll();
       assertThat(state.approvals()).hasSize(1);
@@ -1734,11 +2144,11 @@
     assertThat(ref2.getObjectId()).isNotEqualTo(initial2.getObjectId());
 
     PatchSetApproval approval1 =
-        newNotes(c1).getApprovals().get(c1.currentPatchSetId()).iterator().next();
+        newNotes(c1).getApprovals().all().get(c1.currentPatchSetId()).iterator().next();
     assertThat(approval1.label()).isEqualTo(LabelId.VERIFIED);
 
     PatchSetApproval approval2 =
-        newNotes(c2).getApprovals().get(c2.currentPatchSetId()).iterator().next();
+        newNotes(c2).getApprovals().all().get(c2.currentPatchSetId()).iterator().next();
     assertThat(approval2.label()).isEqualTo(LabelId.CODE_REVIEW);
   }
 
@@ -1896,7 +2306,7 @@
             0,
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -1926,7 +2336,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -1956,7 +2366,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -1986,7 +2396,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "message",
             (short) 1,
             commitId,
@@ -2013,7 +2423,7 @@
     String message3 = "comment 3";
     CommentRange range1 = new CommentRange(1, 1, 2, 1);
     CommentRange range2 = new CommentRange(2, 1, 3, 1);
-    Timestamp time = TimeUtil.nowTs();
+    Instant time = TimeUtil.now();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
     HumanComment comment1 =
@@ -2083,7 +2493,7 @@
     String uuid = "uuid";
     String message = "comment";
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp time = TimeUtil.nowTs();
+    Instant time = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
 
@@ -2113,7 +2523,7 @@
 
   @Test
   public void patchLineCommentNotesFormatWeirdUser() throws Exception {
-    Account.Builder account = Account.builder(Account.id(3), TimeUtil.nowTs());
+    Account.Builder account = Account.builder(Account.id(3), TimeUtil.now());
     account.setFullName("Weird\n\u0002<User>\n");
     account.setPreferredEmail(" we\r\nird@ex>ample<.com");
     accountCache.put(account.build());
@@ -2123,7 +2533,7 @@
     ChangeUpdate update = newUpdate(c, user);
     String uuid = "uuid";
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp time = TimeUtil.nowTs();
+    Instant time = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment comment =
@@ -2161,7 +2571,7 @@
     String messageForBase = "comment for base";
     String messageForPS = "comment for ps";
     CommentRange range = new CommentRange(1, 1, 2, 1);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment commentForBase =
@@ -2220,8 +2630,8 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp timeForComment1 = TimeUtil.nowTs();
-    Timestamp timeForComment2 = TimeUtil.nowTs();
+    Instant timeForComment1 = TimeUtil.now();
+    Instant timeForComment2 = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             psId,
@@ -2279,7 +2689,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             psId,
@@ -2337,7 +2747,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2360,7 +2770,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
+    now = TimeUtil.now();
     HumanComment comment2 =
         newComment(
             ps2,
@@ -2397,7 +2807,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2442,7 +2852,7 @@
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
     short side = (short) 1;
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     // Write two drafts on the same side of one patch set.
@@ -2512,7 +2922,7 @@
     CommentRange range1 = new CommentRange(1, 1, 2, 2);
     CommentRange range2 = new CommentRange(2, 2, 3, 3);
     String filename = "filename1";
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     // Write two drafts, one on each side of the patchset.
@@ -2587,7 +2997,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment =
         newComment(
             psId,
@@ -2632,7 +3042,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2655,7 +3065,7 @@
     PatchSet.Id ps2 = c.currentPatchSetId();
 
     update = newUpdate(c, otherUser);
-    now = TimeUtil.nowTs();
+    now = TimeUtil.now();
     HumanComment comment2 =
         newComment(
             ps2,
@@ -2700,7 +3110,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment =
         newComment(
             ps1,
@@ -2733,7 +3143,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment draft =
         newComment(
             ps1,
@@ -2783,7 +3193,7 @@
     String uuid = "uuid";
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment comment =
@@ -2815,7 +3225,7 @@
     String uuid = "uuid";
     ObjectId commitId = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     String messageForBase = "comment for base";
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
 
     HumanComment comment =
@@ -2856,7 +3266,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps2);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2914,7 +3324,7 @@
 
     ChangeUpdate update = newUpdate(c, otherUser);
     update.setPatchSetId(ps1);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -2988,7 +3398,7 @@
     short side = (short) 1;
 
     ChangeUpdate update = newUpdate(c, otherUser);
-    Timestamp now = TimeUtil.nowTs();
+    Instant now = TimeUtil.now();
     HumanComment comment1 =
         newComment(
             ps1,
@@ -3075,7 +3485,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            new Timestamp(update1.getWhen().getTime()),
+            update1.getWhen(),
             "comment 1",
             (short) 1,
             commitId,
@@ -3092,7 +3502,7 @@
             range.getEndLine(),
             otherUser,
             null,
-            new Timestamp(update2.getWhen().getTime()),
+            update2.getWhen(),
             "comment 2",
             (short) 1,
             commitId,
@@ -3132,7 +3542,7 @@
     ChangeNotes notes = newNotes(c);
     int numMessages = notes.getChangeMessages().size();
     int numPatchSets = notes.getPatchSets().size();
-    int numApprovals = notes.getApprovals().size();
+    int numApprovals = notes.getApprovals().all().size();
     int numComments = notes.getHumanComments().size();
 
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -3149,7 +3559,7 @@
             range.getEndLine(),
             changeOwner,
             null,
-            new Timestamp(update.getWhen().getTime()),
+            update.getWhen(),
             "comment",
             (short) 1,
             ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
@@ -3160,7 +3570,7 @@
     notes = newNotes(c);
     assertThat(notes.getChangeMessages()).hasSize(numMessages);
     assertThat(notes.getPatchSets()).hasSize(numPatchSets);
-    assertThat(notes.getApprovals()).hasSize(numApprovals);
+    assertThat(notes.getApprovals().all()).hasSize(numApprovals);
     assertThat(notes.getHumanComments()).hasSize(numComments);
   }
 
@@ -3542,6 +3952,7 @@
     return new String(rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
   }
 
+  @Nullable
   private ObjectId exactRefAllUsers(String refName) throws Exception {
     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
       Ref ref = allUsersRepo.exactRef(refName);
@@ -3585,11 +3996,45 @@
   }
 
   private AttentionSetUpdate addTimestamp(AttentionSetUpdate attentionSetUpdate, Change c) {
-    Timestamp timestamp = newNotes(c).getChange().getLastUpdatedOn();
+    Instant timestamp = newNotes(c).getChange().getLastUpdatedOn();
     return AttentionSetUpdate.createFromRead(
-        timestamp.toInstant(),
+        timestamp,
         attentionSetUpdate.account(),
         attentionSetUpdate.operation(),
         attentionSetUpdate.reason());
   }
+
+  /**
+   * Assert UUID was parsed as generated by {@link
+   * com.google.gerrit.server.approval.testing.TestPatchSetApprovalUuidGenerator}.
+   */
+  private void assertParsedUuid(PatchSetApproval patchSetApproval) {
+    assertThat(patchSetApproval.uuid().get().get()).matches("^[0-9a-z_]+$");
+  }
+
+  private void assertCopiedApproval(PatchSetApproval originalPsa, PatchSetApproval copiedPsa) {
+    assertThat(copiedPsa.label()).isEqualTo(originalPsa.label());
+    assertThat(copiedPsa.value()).isEqualTo(originalPsa.value());
+    assertThat(copiedPsa.tag()).isEqualTo(originalPsa.tag());
+    assertThat(copiedPsa.accountId()).isEqualTo(originalPsa.accountId());
+    assertThat(copiedPsa.realAccountId()).isEqualTo(originalPsa.realAccountId());
+    assertThat(copiedPsa.uuid()).isEqualTo(originalPsa.uuid());
+    assertThat(copiedPsa.copied()).isTrue();
+  }
+
+  private void addCopiedApproval(Change c, CurrentUser user, PatchSetApproval originalPsa)
+      throws Exception {
+    ChangeUpdate update = newUpdate(c, user);
+    update.putCopiedApproval(
+        PatchSetApproval.builder()
+            .key(originalPsa.key())
+            .value(originalPsa.value())
+            .copied(true)
+            .granted(TimeUtil.now())
+            .tag(originalPsa.tag())
+            .uuid(originalPsa.uuid())
+            .realAccountId(originalPsa.realAccountId())
+            .build());
+    update.commit();
+  }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
index fa05adc..0bb0578 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeUpdateTest.java
@@ -17,11 +17,13 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.CommentRange;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.util.time.TimeUtil;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
@@ -81,7 +83,7 @@
             1,
             changeOwner,
             null,
-            TimeUtil.nowTs(),
+            TimeUtil.now(),
             "Comment",
             (short) 1,
             commit,
@@ -183,10 +185,89 @@
     assertThat(updateWithVote.bypassMaxUpdates()).isFalse();
   }
 
-  private void addToAttentionSet(ChangeUpdate update) {
+  @Test
+  public void commitChangeUpdateWithoutTouchingAttentionSet() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval("Code-Review", (short) 1);
+    update.commit();
+
+    assertThat(update.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
+  public void nonCommittedChangeUpdateReturnsEmptyAttentionSetUpdates() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    addToAttentionSet(update, otherUser);
+
+    assertThat(update.getAttentionSetUpdates()).isEmpty();
+  }
+
+  @Test
+  public void committedChangeUpdateReturnsAttentionSetUpdates() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate = addToAttentionSet(update, otherUser);
+    update.commit();
+
+    assertThat(update.getAttentionSetUpdates()).containsExactly(attentionSetUpdate);
+  }
+
+  @Test
+  public void committedChangeUpdateReturnsMultipleAttentionSetUpdates() throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    AttentionSetUpdate attentionSetUpdate1 = addToAttentionSet(update, otherUser);
+    AttentionSetUpdate attentionSetUpdate2 = addToAttentionSet(update, changeOwner);
+    update.commit();
+
+    assertThat(update.getAttentionSetUpdates())
+        .containsExactly(attentionSetUpdate1, attentionSetUpdate2);
+  }
+
+  @Test
+  public void changeUpdateDoesntReturnAttentionSetUpdateForUserAlreadyAddedInAttentionSet()
+      throws Exception {
+    Change c = newChangeWithEmptyAttentionSet();
+    ChangeUpdate update1 = newUpdate(c, changeOwner);
+    addToAttentionSet(update1, otherUser);
+    update1.commit();
+
+    ChangeUpdate update2 = newUpdate(c, changeOwner);
+    addToAttentionSet(update2, otherUser);
+    update2.commit();
+
+    assertThat(update2.getAttentionSetUpdates()).isEmpty();
+  }
+
+  /**
+   * Creates a change with an empty attention set
+   *
+   * <p>Method ensures that changeOwner and otherUser can be added to the attention set later. (only
+   * users active on the change can be added to the attention set - see {@link
+   * ChangeUpdate#isActiveOnChange})
+   */
+  private Change newChangeWithEmptyAttentionSet() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(otherUser.getAccountId(), ReviewerStateInternal.CC);
+    update.commit();
+    return c;
+  }
+
+  @CanIgnoreReturnValue
+  private AttentionSetUpdate addToAttentionSet(ChangeUpdate update) {
+    return addToAttentionSet(update, otherUser);
+  }
+
+  @CanIgnoreReturnValue
+  private AttentionSetUpdate addToAttentionSet(ChangeUpdate update, IdentifiedUser user) {
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(
-            otherUser.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
+            user.getAccountId(), AttentionSetUpdate.Operation.ADD, "test");
     update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+    return attentionSetUpdate;
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index 2c1348c..5a89584 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -34,7 +34,7 @@
   /** Arbitrary time outside of a DST transition, as an ISO instant. */
   private static final String NON_DST_STR = "2017-02-07T10:20:30.123Z";
 
-  /** Arbitrary time outside of a DST transition, as a reasonable Java 8 representation. */
+  /** Arbitrary time outside of a DST transition, as a reasonable Java 11 representation. */
   private static final ZonedDateTime NON_DST = ZonedDateTime.parse(NON_DST_STR);
 
   /** {@link #NON_DST_STR} truncated to seconds. */
@@ -123,6 +123,18 @@
   }
 
   @Test
+  public void fixedFallbackFormatCanParseOutputOfLegacyAdapter() {
+    assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 7, 2017 2:20:30 AM"))
+        .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-07T10:20:30Z").toInstant()));
+    assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 17, 2017 10:20:30 AM"))
+        .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-17T18:20:30Z").toInstant()));
+    assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 17, 2017 02:20:30 PM"))
+        .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-17T22:20:30Z").toInstant()));
+    assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 07, 2017 10:20:30 PM"))
+        .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-08T06:20:30Z").toInstant()));
+  }
+
+  @Test
   public void newAdapterDisagreesWithLegacyAdapterDuringDstTransition() {
     String duringJson = legacyGson.toJson(new Timestamp(MID_DST_MS));
     Timestamp duringTs = legacyGson.fromJson(duringJson, Timestamp.class);
@@ -155,7 +167,7 @@
         new HumanComment(
             new Comment.Key("uuid", "filename", 1),
             Account.id(100),
-            NON_DST_TS,
+            NON_DST_TS.toInstant(),
             (short) 0,
             "message",
             "serverId",
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 68a1d9d..25f2f98 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -27,8 +27,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestChanges;
-import java.util.Date;
-import java.util.TimeZone;
+import java.time.ZoneId;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -40,8 +39,8 @@
   public void approvalsCommitFormatSimple() throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId(), 1);
     ChangeUpdate update = newUpdateForNewChange(c, changeOwner);
-    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.putApproval(LabelId.CODE_REVIEW, (short) -1);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
     update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
     update.putReviewer(otherUser.getAccount().id(), CC);
     update.commit();
@@ -61,21 +60,22 @@
             + "\n"
             + "Reviewer: Gerrit User 1 <1@gerrit>\n"
             + "CC: Gerrit User 2 <2@gerrit>\n"
-            + "Label: Code-Review=-1\n"
-            + "Label: Verified=+1\n",
+            + "Label: Code-Review=-1, 1_1_1_code_review__1_1\n"
+            + "Label: Verified=+1, 1_1_1_verified_1_2\n",
         commit);
 
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 1000));
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhenAsInstant().toEpochMilli())
+        .isEqualTo(c.getCreatedOn().toEpochMilli() + 1000);
+    assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen()).isEqualTo(author.getWhen());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+    assertThat(committer.getWhenAsInstant()).isEqualTo(author.getWhenAsInstant());
+    assertThat(committer.getZoneId()).isEqualTo(author.getZoneId());
   }
 
   @Test
@@ -184,20 +184,21 @@
     PersonIdent author = commit.getAuthorIdent();
     assertThat(author.getName()).isEqualTo("Gerrit User 1");
     assertThat(author.getEmailAddress()).isEqualTo("1@gerrit");
-    assertThat(author.getWhen()).isEqualTo(new Date(c.getCreatedOn().getTime() + 2000));
-    assertThat(author.getTimeZone()).isEqualTo(TimeZone.getTimeZone("GMT-7:00"));
+    assertThat(author.getWhenAsInstant().toEpochMilli())
+        .isEqualTo(c.getCreatedOn().toEpochMilli() + 2000);
+    assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7"));
 
     PersonIdent committer = commit.getCommitterIdent();
     assertThat(committer.getName()).isEqualTo("Gerrit Server");
     assertThat(committer.getEmailAddress()).isEqualTo("noreply@gerrit.com");
-    assertThat(committer.getWhen()).isEqualTo(author.getWhen());
-    assertThat(committer.getTimeZone()).isEqualTo(author.getTimeZone());
+    assertThat(committer.getWhenAsInstant()).isEqualTo(author.getWhenAsInstant());
+    assertThat(committer.getZoneId()).isEqualTo(author.getZoneId());
   }
 
   @Test
   public void anonymousUser() throws Exception {
     Account anon =
-        Account.builder(Account.id(3), TimeUtil.nowTs())
+        Account.builder(Account.id(3), TimeUtil.now())
             .setMetaId("1234567812345678123456781234567812345678")
             .build();
     accountCache.put(anon);
@@ -353,7 +354,8 @@
   @Test
   public void realUser() throws Exception {
     Change c = newChange();
-    CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(/* remotePeer= */ null, otherUserId, changeOwner);
     ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
     update.setChangeMessage("Message on behalf of other user");
     update.commit();
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 98721fd..527e78e 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -47,10 +47,12 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gson.Gson;
 import com.google.inject.Inject;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.IntStream;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
@@ -261,6 +263,7 @@
       Ref metaRefBeforeRewrite = repo.exactRef(refName);
       expectedSkippedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
     }
+    Set<String> invalidRefs = new HashSet<>();
     for (int i = 0; i < numberOfInvalidChanges; i++) {
       Change c = newChange();
       ChangeUpdate update = newUpdate(c, changeOwner);
@@ -270,12 +273,20 @@
       updateWithSubject.setSubjectForCommit("Update with subject");
       updateWithSubject.commit();
       String refName = RefNames.changeMetaRef(c.getId());
-      Ref metaRefBeforeRewrite = repo.exactRef(refName);
-      if (i < maxRefsToUpdate) {
-        expectedFixedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
-      } else {
-        expectedSkippedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
+      invalidRefs.add(refName);
+    }
+    int i = 0;
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+      Ref metaRefBeforeRewrite = repo.exactRef(ref.getName());
+      if (!invalidRefs.contains(ref.getName())) {
+        continue;
       }
+      if (i < maxRefsToUpdate) {
+        expectedFixedRefsToOldMetaBuilder.put(ref.getName(), metaRefBeforeRewrite.getObjectId());
+      } else {
+        expectedSkippedRefsToOldMetaBuilder.put(ref.getName(), metaRefBeforeRewrite.getObjectId());
+      }
+      i++;
     }
     ImmutableMap<String, ObjectId> expectedFixedRefsToOldMeta =
         expectedFixedRefsToOldMetaBuilder.build();
@@ -312,13 +323,13 @@
   @Test
   public void fixAuthorIdent() throws Exception {
     Change c = newChange();
-    Timestamp when = TimeUtil.nowTs();
+    Instant when = TimeUtil.now();
     PersonIdent invalidAuthorIdent =
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
             when,
-            serverIdent.getTimeZone());
+            serverIdent.getZoneId());
     RevCommit invalidUpdateCommit =
         writeUpdate(
             RefNames.changeMetaRef(c.getId()),
@@ -359,7 +370,8 @@
     assertThat(fixedUpdateCommit.getAuthorIdent().getName())
         .isEqualTo("Gerrit User " + changeOwner.getAccountId());
     assertThat(originalAuthorIdent.getEmailAddress()).isEqualTo(fixedAuthorIdent.getEmailAddress());
-    assertThat(originalAuthorIdent.getWhen()).isEqualTo(fixedAuthorIdent.getWhen());
+    assertThat(originalAuthorIdent.getWhenAsInstant())
+        .isEqualTo(fixedAuthorIdent.getWhenAsInstant());
     assertThat(originalAuthorIdent.getTimeZone()).isEqualTo(fixedAuthorIdent.getTimeZone());
     assertThat(invalidUpdateCommit.getFullMessage()).isEqualTo(fixedUpdateCommit.getFullMessage());
     assertThat(invalidUpdateCommit.getCommitterIdent())
@@ -387,7 +399,9 @@
 
     IdentifiedUser impersonatedChangeOwner =
         this.userFactory.runAs(
-            null, changeOwner.getAccountId(), requireNonNull(otherUser).getRealUser());
+            /* remotePeer= */ null,
+            changeOwner.getAccountId(),
+            requireNonNull(otherUser).getRealUser());
     ChangeUpdate impersonatedChangeMessageUpdate = newUpdate(c, impersonatedChangeOwner);
     impersonatedChangeMessageUpdate.setChangeMessage("Other comment on behalf of");
     impersonatedChangeMessageUpdate.commit();
@@ -483,7 +497,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
@@ -567,21 +581,15 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
         ImmutableList.of(
             ReviewerStatusUpdate.create(
-                new Timestamp(addReviewerUpdate.when.getTime()),
-                changeOwner.getAccountId(),
-                otherUserId,
-                REVIEWER),
+                addReviewerUpdate.when, changeOwner.getAccountId(), otherUserId, REVIEWER),
             ReviewerStatusUpdate.create(
                 updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED),
             ReviewerStatusUpdate.create(
-                new Timestamp(addCcUpdate.when.getTime()),
-                changeOwner.getAccountId(),
-                otherUserId,
-                CC),
+                addCcUpdate.when, changeOwner.getAccountId(), otherUserId, CC),
             ReviewerStatusUpdate.create(
                 updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED));
     ChangeNotes notesAfterRewrite = newNotes(c);
@@ -703,7 +711,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -754,9 +762,9 @@
                 .build());
     ChangeNotes notesAfterRewrite = newNotes(c);
 
-    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesBeforeRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
-    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesAfterRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
 
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -840,7 +848,7 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
 
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<PatchSetApproval> expectedApprovals =
         ImmutableList.of(
             PatchSetApproval.builder()
@@ -873,14 +881,14 @@
             "Removed Custom-Label-1 by Other Account <other@account.com>",
             "Removed Verified+2 by Change Owner <change@owner.com>");
 
-    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesBeforeRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
     assertThat(changeMessages(notesAfterRewrite))
         .containsExactly(
             "Removed Code-Review+2 by <GERRIT_ACCOUNT_2>",
             "Removed Custom-Label-1 by <GERRIT_ACCOUNT_2>",
             "Removed Verified+2 by <GERRIT_ACCOUNT_1>");
-    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+    assertThat(notesAfterRewrite.getApprovals().all().get(c.currentPatchSetId()))
         .containsExactlyElementsIn(expectedApprovals);
 
     Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -915,10 +923,7 @@
     Change c = newChange();
     PersonIdent invalidAuthorIdent =
         new PersonIdent(
-            changeOwner.getName(),
-            "server@" + serverId,
-            TimeUtil.nowTs(),
-            serverIdent.getTimeZone());
+            changeOwner.getName(), "server@" + serverId, TimeUtil.now(), serverIdent.getZoneId());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
         getChangeUpdateBody(
@@ -1053,7 +1058,7 @@
   public void fixRemoveVoteChangeMessageWithNoFooterLabel_matchDuplicateAccounts()
       throws Exception {
     Account duplicateCodeOwner =
-        Account.builder(Account.id(4), TimeUtil.nowTs())
+        Account.builder(Account.id(4), TimeUtil.now())
             .setFullName(changeOwner.getName())
             .setPreferredEmail("other@test.com")
             .build();
@@ -1243,46 +1248,46 @@
     BackfillResult result = rewriter.backfillProject(project, repo, options);
     assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
     notesBeforeRewrite.getAttentionSetUpdates();
-    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    Instant updateTimestamp = serverIdent.getWhenAsInstant();
     ImmutableList<AttentionSetUpdate> attentionSetUpdatesBeforeRewrite =
         ImmutableList.of(
             AttentionSetUpdate.createFromRead(
-                invalidRemovedByClickUpdate.getWhen().toInstant(),
+                invalidRemovedByClickUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 String.format("Removed by %s by clicking the attention icon", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 "Added by someone"),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 "Removed by someone"),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 String.format("%s replied on the change", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 otherUserId,
                 Operation.ADD,
                 "Added by someone using the hovercard menu"),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 String.format("Removed by %s using the hovercard menu", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 String.format("%s replied on the change", otherUser.getName())),
             AttentionSetUpdate.createFromRead(
-                invalidAttentionSetUpdate.getWhen().toInstant(),
+                invalidAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.ADD,
                 String.format("Added by %s using the hovercard menu", otherUser.getName())));
@@ -1290,42 +1295,42 @@
     ImmutableList<AttentionSetUpdate> attentionSetUpdatesAfterRewrite =
         ImmutableList.of(
             AttentionSetUpdate.createFromRead(
-                invalidRemovedByClickUpdate.getWhen().toInstant(),
+                invalidRemovedByClickUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 "Removed by someone by clicking the attention icon"),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 "Added by someone"),
             AttentionSetUpdate.createFromRead(
-                validAttentionSetUpdate.getWhen().toInstant(),
+                validAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 "Removed by someone"),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 changeOwner.getAccountId(),
                 Operation.REMOVE,
                 "Someone replied on the change"),
             AttentionSetUpdate.createFromRead(
-                updateTimestamp.toInstant(),
+                updateTimestamp,
                 otherUserId,
                 Operation.ADD,
                 "Added by someone using the hovercard menu"),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.REMOVE,
                 "Removed by someone using the hovercard menu"),
             AttentionSetUpdate.createFromRead(
-                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                invalidMultipleAttentionSetUpdate.getWhen(),
                 changeOwner.getAccountId(),
                 Operation.ADD,
                 "Someone replied on the change"),
             AttentionSetUpdate.createFromRead(
-                invalidAttentionSetUpdate.getWhen().toInstant(),
+                invalidAttentionSetUpdate.getWhen(),
                 otherUserId,
                 Operation.ADD,
                 "Added by someone using the hovercard menu"));
@@ -1420,22 +1425,22 @@
       thirdAttentionSetUpdate.commit();
       attentionSetUpdatesBeforeRewrite.add(
           AttentionSetUpdate.createFromRead(
-              thirdAttentionSetUpdate.getWhen().toInstant(),
+              thirdAttentionSetUpdate.getWhen(),
               changeOwner.getAccountId(),
               Operation.REMOVE,
               String.format("Removed by %s by clicking the attention icon", okAccountName)),
           AttentionSetUpdate.createFromRead(
-              secondAttentionSetUpdate.getWhen().toInstant(),
+              secondAttentionSetUpdate.getWhen(),
               otherUserId,
               Operation.REMOVE,
               String.format("Removed by %s using the hovercard menu", okAccountName)),
           AttentionSetUpdate.createFromRead(
-              secondAttentionSetUpdate.getWhen().toInstant(),
+              secondAttentionSetUpdate.getWhen(),
               changeOwner.getAccountId(),
               Operation.ADD,
               String.format("%s replied on the change", okAccountName)),
           AttentionSetUpdate.createFromRead(
-              firstAttentionSetUpdate.getWhen().toInstant(),
+              firstAttentionSetUpdate.getWhen(),
               otherUserId,
               Operation.ADD,
               String.format("Added by %s using the hovercard menu", okAccountName)));
@@ -1548,8 +1553,8 @@
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
-            TimeUtil.nowTs(),
-            serverIdent.getTimeZone());
+            TimeUtil.now(),
+            serverIdent.getZoneId());
     String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
     writeUpdate(
         RefNames.changeMetaRef(c.getId()),
@@ -1744,16 +1749,16 @@
   public void fixCodeOwnersOnAddReviewerChangeMessage() throws Exception {
 
     Account reviewer =
-        Account.builder(Account.id(3), TimeUtil.nowTs())
+        Account.builder(Account.id(3), TimeUtil.now())
             .setFullName("Reviewer User")
             .setPreferredEmail("reviewer@account.com")
             .build();
     accountCache.put(reviewer);
     Account duplicateCodeOwner =
-        Account.builder(Account.id(4), TimeUtil.nowTs()).setFullName(changeOwner.getName()).build();
+        Account.builder(Account.id(4), TimeUtil.now()).setFullName(changeOwner.getName()).build();
     accountCache.put(duplicateCodeOwner);
     Account duplicateReviewer =
-        Account.builder(Account.id(5), TimeUtil.nowTs()).setFullName(reviewer.getName()).build();
+        Account.builder(Account.id(5), TimeUtil.now()).setFullName(reviewer.getName()).build();
     accountCache.put(duplicateReviewer);
     Change c = newChange();
     ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
@@ -2211,7 +2216,7 @@
         getChangeUpdateBody(c, "Assignee deleted: " + otherUser.getName()),
         getAuthorIdent(changeOwner.getAccount()));
     Account reviewer =
-        Account.builder(Account.id(3), TimeUtil.nowTs())
+        Account.builder(Account.id(3), TimeUtil.now())
             .setFullName("Reviewer User")
             .setPreferredEmail("reviewer@account.com")
             .build();
@@ -2253,14 +2258,14 @@
   @Test
   public void singleRunFixesAll() throws Exception {
     Change c = newChange();
-    Timestamp when = TimeUtil.nowTs();
+    Instant when = TimeUtil.now();
     String assigneeIdentToFix = getAccountIdentToFix(otherUser.getAccount());
     PersonIdent authorIdentToFix =
         new PersonIdent(
             changeOwner.getName(),
             changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
             when,
-            serverIdent.getTimeZone());
+            serverIdent.getZoneId());
 
     RevCommit invalidUpdateCommit =
         writeUpdate(
@@ -2416,7 +2421,6 @@
   }
 
   private PersonIdent getAuthorIdent(Account account) {
-    Timestamp when = TimeUtil.nowTs();
-    return changeNoteUtil.newAccountIdIdent(account.id(), when, serverIdent);
+    return changeNoteUtil.newAccountIdIdent(account.id(), TimeUtil.now(), serverIdent);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
index 041366c..31b1db0 100644
--- a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
@@ -89,7 +89,7 @@
         0,
         otherUser,
         null,
-        TimeUtil.nowTs(),
+        TimeUtil.now(),
         "comment",
         (short) 0,
         ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234"),
diff --git a/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
new file mode 100644
index 0000000..bb49a6d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.inject.AbstractModule;
+import java.util.Optional;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ImportedChangeNotesTest extends AbstractChangeNotesTest {
+
+  private static final String FOREIGN_SERVER_ID = "foreign-server-id";
+  private static final String IMPORTED_SERVER_ID = "gerrit-imported-1";
+
+  private ExternalIdCache externalIdCacheMock;
+
+  @Before
+  @Override
+  public void setUpTestEnvironment() throws Exception {
+    setupTestPrerequisites();
+  }
+
+  private void initServerIds(String serverId, String... importedServerIds)
+      throws Exception, RepositoryCaseMismatchException, RepositoryNotFoundException {
+    externalIdCacheMock = mock(ExternalIdCache.class);
+    injector =
+        createTestInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                bind(ExternalIdCache.class).toInstance(externalIdCacheMock);
+              }
+            },
+            serverId,
+            importedServerIds);
+    injector.injectMembers(this);
+    createAllUsers(injector);
+  }
+
+  @Test
+  public void allowChangeFromImportedServerId() throws Exception {
+    initServerIds(LOCAL_SERVER_ID, IMPORTED_SERVER_ID);
+
+    ExternalId.Key importedAccountIdKey =
+        ExternalId.Key.create(
+            ExternalId.SCHEME_IMPORTED,
+            changeOwner.getAccountId() + "@" + IMPORTED_SERVER_ID,
+            false);
+    ExternalId importedAccountId =
+        ExternalId.create(importedAccountIdKey, changeOwner.getAccountId(), null, null, null);
+
+    when(externalIdCacheMock.byKey(eq(importedAccountIdKey)))
+        .thenReturn(Optional.of(importedAccountId));
+
+    Change importedChange = newChange(createTestInjector(IMPORTED_SERVER_ID), false);
+    Change localChange = newChange();
+
+    assertThat(newNotes(importedChange).getServerId()).isEqualTo(IMPORTED_SERVER_ID);
+    assertThat(newNotes(localChange).getServerId()).isEqualTo(LOCAL_SERVER_ID);
+  }
+
+  @Test
+  public void rejectChangeWithForeignServerId() throws Exception {
+    initServerIds(LOCAL_SERVER_ID);
+    when(externalIdCacheMock.byKey(any())).thenReturn(Optional.empty());
+
+    Change foreignChange = newChange(createTestInjector(FOREIGN_SERVER_ID), false);
+
+    InvalidServerIdException invalidServerIdEx =
+        assertThrows(InvalidServerIdException.class, () -> newNotes(foreignChange));
+
+    String invalidServerIdMessage = invalidServerIdEx.getMessage();
+    assertThat(invalidServerIdMessage).contains("expected " + LOCAL_SERVER_ID);
+    assertThat(invalidServerIdMessage).contains("actual: " + FOREIGN_SERVER_ID);
+  }
+
+  @Test
+  public void changeFromImportedServerIdWithUnknownAccountId() throws Exception {
+    initServerIds(LOCAL_SERVER_ID, IMPORTED_SERVER_ID);
+
+    when(externalIdCacheMock.byKey(any())).thenReturn(Optional.empty());
+
+    Change importedChange = newChange(createTestInjector(IMPORTED_SERVER_ID), false);
+    assertThat(newNotes(importedChange).getServerId()).isEqualTo(IMPORTED_SERVER_ID);
+
+    assertThat(newNotes(importedChange).getChange().getOwner())
+        .isEqualTo(Account.UNKNOWN_ACCOUNT_ID);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
index 507b71f..323aee9 100644
--- a/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
+++ b/javatests/com/google/gerrit/server/notedb/OpenRepoTest.java
@@ -244,6 +244,19 @@
     }
   }
 
+  @Test
+  public void canCreateChangeNotesFromOpenRepoAndChangeid() throws Exception {
+    try (OpenRepo openRepo = openRepo()) {
+      Change change = newChange();
+
+      ChangeNotes changeNotes =
+          changeNotesFactory.createChecked(openRepo.repo, project, change.getId(), null);
+
+      assertThat(changeNotes).isNotNull();
+      assertThat(changeNotes.getChangeId()).isEqualTo(change.getId());
+    }
+  }
+
   private void addToAttentionSet(ChangeUpdate update) {
     AttentionSetUpdate attentionSetUpdate =
         AttentionSetUpdate.createForWrite(
diff --git a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
index a768eaf..0c9f731 100644
--- a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -91,27 +91,37 @@
     assertThat(s.acquireCount).isEqualTo(0);
 
     assertThat(s.next()).isEqualTo(1);
+    assertThat(s.last()).isEqualTo(1);
     assertThat(s.acquireCount).isEqualTo(1);
     assertThat(s.next()).isEqualTo(2);
+    assertThat(s.last()).isEqualTo(2);
     assertThat(s.acquireCount).isEqualTo(1);
     assertThat(s.next()).isEqualTo(3);
+    assertThat(s.last()).isEqualTo(3);
     assertThat(s.acquireCount).isEqualTo(1);
 
     assertThat(s.next()).isEqualTo(4);
+    assertThat(s.last()).isEqualTo(4);
     assertThat(s.acquireCount).isEqualTo(2);
     assertThat(s.next()).isEqualTo(5);
+    assertThat(s.last()).isEqualTo(5);
     assertThat(s.acquireCount).isEqualTo(2);
     assertThat(s.next()).isEqualTo(6);
+    assertThat(s.last()).isEqualTo(6);
     assertThat(s.acquireCount).isEqualTo(2);
 
     assertThat(s.next()).isEqualTo(7);
+    assertThat(s.last()).isEqualTo(7);
     assertThat(s.acquireCount).isEqualTo(3);
     assertThat(s.next()).isEqualTo(8);
+    assertThat(s.last()).isEqualTo(8);
     assertThat(s.acquireCount).isEqualTo(3);
     assertThat(s.next()).isEqualTo(9);
+    assertThat(s.last()).isEqualTo(9);
     assertThat(s.acquireCount).isEqualTo(3);
 
     assertThat(s.next()).isEqualTo(10);
+    assertThat(s.last()).isEqualTo(10);
     assertThat(s.acquireCount).isEqualTo(4);
   }
 
@@ -127,6 +137,8 @@
     assertThat(s2.next()).isEqualTo(5);
     assertThat(s1.next()).isEqualTo(3);
     assertThat(s2.next()).isEqualTo(6);
+    assertThat(s1.last()).isEqualTo(3);
+    assertThat(s2.last()).isEqualTo(6);
 
     // s2 acquires 7-9; s1 acquires 10-12.
     assertThat(s2.next()).isEqualTo(7);
@@ -135,6 +147,8 @@
     assertThat(s1.next()).isEqualTo(11);
     assertThat(s2.next()).isEqualTo(9);
     assertThat(s1.next()).isEqualTo(12);
+    assertThat(s1.last()).isEqualTo(12);
+    assertThat(s2.last()).isEqualTo(9);
   }
 
   @Test
@@ -284,48 +298,61 @@
   }
 
   @Test
-  public void nextWithCountOneCaller() throws Exception {
+  public void nextWithCountAndLastByOneCaller() throws Exception {
     RepoSequence s = newSequence("id", 1, 3);
     assertThat(s.next(2)).containsExactly(1, 2).inOrder();
     assertThat(s.acquireCount).isEqualTo(1);
+    assertThat(s.last()).isEqualTo(2);
     assertThat(s.next(2)).containsExactly(3, 4).inOrder();
     assertThat(s.acquireCount).isEqualTo(2);
+    assertThat(s.last()).isEqualTo(4);
     assertThat(s.next(2)).containsExactly(5, 6).inOrder();
     assertThat(s.acquireCount).isEqualTo(2);
+    assertThat(s.last()).isEqualTo(6);
 
     assertThat(s.next(3)).containsExactly(7, 8, 9).inOrder();
     assertThat(s.acquireCount).isEqualTo(3);
+    assertThat(s.last()).isEqualTo(9);
     assertThat(s.next(3)).containsExactly(10, 11, 12).inOrder();
     assertThat(s.acquireCount).isEqualTo(4);
+    assertThat(s.last()).isEqualTo(12);
     assertThat(s.next(3)).containsExactly(13, 14, 15).inOrder();
     assertThat(s.acquireCount).isEqualTo(5);
+    assertThat(s.last()).isEqualTo(15);
 
     assertThat(s.next(7)).containsExactly(16, 17, 18, 19, 20, 21, 22).inOrder();
     assertThat(s.acquireCount).isEqualTo(6);
+    assertThat(s.last()).isEqualTo(22);
     assertThat(s.next(7)).containsExactly(23, 24, 25, 26, 27, 28, 29).inOrder();
     assertThat(s.acquireCount).isEqualTo(7);
+    assertThat(s.last()).isEqualTo(29);
     assertThat(s.next(7)).containsExactly(30, 31, 32, 33, 34, 35, 36).inOrder();
     assertThat(s.acquireCount).isEqualTo(8);
+    assertThat(s.last()).isEqualTo(36);
   }
 
   @Test
-  public void nextWithCountMultipleCallers() throws Exception {
+  public void nextWithCountAndLastByMultipleCallers() throws Exception {
     RepoSequence s1 = newSequence("id", 1, 3);
     RepoSequence s2 = newSequence("id", 1, 4);
 
     assertThat(s1.next(2)).containsExactly(1, 2).inOrder();
+    assertThat(s1.last()).isEqualTo(2);
     assertThat(s1.acquireCount).isEqualTo(1);
 
     // s1 hasn't exhausted its last batch.
     assertThat(s2.next(2)).containsExactly(4, 5).inOrder();
+    assertThat(s2.last()).isEqualTo(5);
     assertThat(s2.acquireCount).isEqualTo(1);
 
     // s1 acquires again to cover this request, plus a whole new batch.
     assertThat(s1.next(3)).containsExactly(3, 8, 9);
+    assertThat(s1.last()).isEqualTo(9);
     assertThat(s1.acquireCount).isEqualTo(2);
 
     // s2 hasn't exhausted its last batch, do so now.
     assertThat(s2.next(2)).containsExactly(6, 7);
+    assertThat(s2.last()).isEqualTo(7);
     assertThat(s2.acquireCount).isEqualTo(1);
   }
 
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index d47afb0..1c28690 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -17,12 +17,15 @@
 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.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffOperationsTest.FileEntity.FileType;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
@@ -30,13 +33,16 @@
 import com.google.inject.Injector;
 import java.io.IOException;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
 import org.eclipse.jgit.lib.TreeFormatter;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.Before;
@@ -64,12 +70,15 @@
 
   @Test
   public void diffModifiedFileAgainstParent() throws Exception {
-    ImmutableMap<String, String> oldFiles =
-        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2);
+    ImmutableList<FileEntity> oldFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
     ObjectId oldCommitId = createCommit(repo, null, oldFiles);
 
-    ImmutableMap<String, String> newFiles =
-        ImmutableMap.of(fileName1, fileContent1, fileName2, fileContent2 + "\nnew line here");
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1),
+            new FileEntity(fileName2, fileContent2 + "\nnew line here"));
     ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
 
     FileDiffOutput diffOutput =
@@ -83,20 +92,19 @@
   }
 
   @Test
-  public void diffAgainstAutoMergePersistsAutoMergeInRepo() throws Exception {
-    ObjectId parent1 = createCommit(repo, null, ImmutableMap.of("file_1.txt", "file 1 content"));
-    ObjectId parent2 = createCommit(repo, null, ImmutableMap.of("file_2.txt", "file 2 content"));
+  public void diffAgainstAutoMergeDoesNotPersistAutoMergeInRepo() throws Exception {
+    ObjectId parent1 =
+        createCommit(repo, null, ImmutableList.of(new FileEntity("file_1.txt", "file 1 content")));
+    ObjectId parent2 =
+        createCommit(repo, null, ImmutableList.of(new FileEntity("file_2.txt", "file 2 content")));
 
     ObjectId merge =
         createMergeCommit(
             repo,
-            ImmutableMap.of(
-                "file_1.txt",
-                "file 1 content",
-                "file_2.txt",
-                "file 2 content",
-                "file_3.txt",
-                "file 3 content"),
+            ImmutableList.of(
+                new FileEntity("file_1.txt", "file 1 content"),
+                new FileEntity("file_2.txt", "file 2 content"),
+                new FileEntity("file_3.txt", "file 3 content")),
             parent1,
             parent2);
 
@@ -104,29 +112,144 @@
     assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNull();
 
     Map<String, FileDiffOutput> changedFiles =
-        diffOperations.listModifiedFilesAgainstParent(testProjectName, merge, /* parentNum=*/ 0);
+        diffOperations.listModifiedFilesAgainstParent(
+            testProjectName, merge, /* parentNum=*/ 0, DiffOptions.DEFAULTS);
     assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST", "file_3.txt");
 
-    // Requesting diff against auto-merge had the side effect of updating the auto-merge ref
-    assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNotNull();
+    assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNull();
+  }
+
+  @Test
+  public void loadModifiedFiles() throws Exception {
+    ImmutableList<FileEntity> oldFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1),
+            new FileEntity(fileName2, fileContent2 + "\nnew line here"));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    Repository repository = repoManager.openRepository(testProjectName);
+    ObjectReader objectReader = repository.newObjectReader();
+    RevWalk rw = new RevWalk(objectReader);
+    StoredConfig repoConfig = repository.getConfig();
+
+    // This call loads modified files directly without going through the diff cache.
+    Map<String, ModifiedFile> modifiedFiles =
+        diffOperations.loadModifiedFiles(
+            testProjectName, newCommitId, oldCommitId, DiffOptions.DEFAULTS, rw, repoConfig);
+
+    assertThat(modifiedFiles)
+        .containsExactly(
+            fileName2,
+            ModifiedFile.builder()
+                .changeType(ChangeType.MODIFIED)
+                .oldPath(Optional.of(fileName2))
+                .newPath(Optional.of(fileName2))
+                .build());
+  }
+
+  @Test
+  public void loadModifiedFiles_withSymlinkConvertedToRegularFile() throws Exception {
+    // Commit 1: Create a regular fileName1 with fileContent1
+    ImmutableList<FileEntity> oldFiles = ImmutableList.of(new FileEntity(fileName1, fileContent1));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    // Commit 2: Create a symlink with name FileName1 pointing to target file "target"
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(new FileEntity(fileName1, "target", FileType.SYMLINK));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    Repository repository = repoManager.openRepository(testProjectName);
+    ObjectReader objectReader = repository.newObjectReader();
+
+    Map<String, ModifiedFile> modifiedFiles =
+        diffOperations.loadModifiedFiles(
+            testProjectName,
+            newCommitId,
+            oldCommitId,
+            DiffOptions.DEFAULTS,
+            new RevWalk(objectReader),
+            repository.getConfig());
+
+    assertThat(modifiedFiles)
+        .containsExactly(
+            fileName1,
+            ModifiedFile.builder()
+                .changeType(ChangeType.REWRITE)
+                .oldPath(Optional.empty())
+                .newPath(Optional.of(fileName1))
+                .build());
+  }
+
+  @Test
+  public void loadModifiedFilesAgainstParent() throws Exception {
+    ImmutableList<FileEntity> oldFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1), new FileEntity(fileName2, fileContent2));
+    ObjectId oldCommitId = createCommit(repo, null, oldFiles);
+
+    ImmutableList<FileEntity> newFiles =
+        ImmutableList.of(
+            new FileEntity(fileName1, fileContent1),
+            new FileEntity(fileName2, fileContent2 + "\nnew line here"));
+    ObjectId newCommitId = createCommit(repo, oldCommitId, newFiles);
+
+    Repository repository = repoManager.openRepository(testProjectName);
+    ObjectReader objectReader = repository.newObjectReader();
+    RevWalk rw = new RevWalk(objectReader);
+    StoredConfig repoConfig = repository.getConfig();
+
+    // This call loads modified files directly without going through the diff cache.
+    Map<String, ModifiedFile> modifiedFiles =
+        diffOperations.loadModifiedFilesAgainstParent(
+            testProjectName, newCommitId, /* parentNum=*/ 0, DiffOptions.DEFAULTS, rw, repoConfig);
+
+    assertThat(modifiedFiles)
+        .containsExactly(
+            fileName2,
+            ModifiedFile.builder()
+                .changeType(ChangeType.MODIFIED)
+                .oldPath(Optional.of(fileName2))
+                .newPath(Optional.of(fileName2))
+                .build());
+  }
+
+  static class FileEntity {
+    String name;
+    String content;
+    FileType type;
+
+    enum FileType {
+      REGULAR,
+      SYMLINK
+    }
+
+    FileEntity(String name, String content) {
+      this(name, content, FileType.REGULAR);
+    }
+
+    FileEntity(String name, String content, FileType type) {
+      this.name = name;
+      this.content = content;
+      this.type = type;
+    }
   }
 
   private ObjectId createMergeCommit(
-      Repository repo,
-      ImmutableMap<String, String> fileNameToContent,
-      ObjectId parent1,
-      ObjectId parent2)
+      Repository repo, ImmutableList<FileEntity> fileEntities, ObjectId parent1, ObjectId parent2)
       throws IOException {
-    ObjectId treeId = createTree(repo, fileNameToContent);
+    ObjectId treeId = createTree(repo, fileEntities);
     return createCommitInRepo(repo, treeId, parent1, parent2);
   }
 
   private ObjectId createCommit(
-      Repository repo,
-      @Nullable ObjectId parentCommit,
-      ImmutableMap<String, String> fileNameToContent)
+      Repository repo, @Nullable ObjectId parentCommit, ImmutableList<FileEntity> fileEntities)
       throws IOException {
-    ObjectId treeId = createTree(repo, fileNameToContent);
+    ObjectId treeId = createTree(repo, fileEntities);
     return parentCommit == null
         ? createCommitInRepo(repo, treeId)
         : createCommitInRepo(repo, treeId, parentCommit);
@@ -136,7 +259,7 @@
       throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
-          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
+          new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.now());
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(treeId);
       cb.setCommitter(committer);
@@ -152,17 +275,21 @@
     }
   }
 
-  private static ObjectId createTree(
-      Repository repo, ImmutableMap<String, String> fileNameToContent) throws IOException {
+  private static ObjectId createTree(Repository repo, ImmutableList<FileEntity> fileEntities)
+      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();
+      for (FileEntity fileEntity : fileEntities) {
+        String fileName = fileEntity.name;
+        String fileContent = fileEntity.content;
         ObjectId fileObjId = createBlob(repo, fileContent);
-        formatter.append(fileName, rw.lookupBlob(fileObjId));
+        if (fileEntity.type.equals(FileType.REGULAR)) {
+          formatter.append(fileName, rw.lookupBlob(fileObjId));
+        } else {
+          formatter.append(fileName, FileMode.SYMLINK, fileObjId);
+        }
       }
       ObjectId treeId = oi.insert(formatter);
       oi.flush();
diff --git a/javatests/com/google/gerrit/server/patch/MagicFileTest.java b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
index 93928f0..b0050b0 100644
--- a/javatests/com/google/gerrit/server/patch/MagicFileTest.java
+++ b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
@@ -22,9 +22,8 @@
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.Month;
+import java.time.ZoneId;
 import java.time.ZoneOffset;
-import java.util.Date;
-import java.util.TimeZone;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -104,20 +103,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       ObjectId commit =
           testRepo
@@ -155,20 +146,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       RevCommit parent =
           testRepo.commit().message("Parent subject\n\nParent further details.").create();
@@ -211,20 +194,12 @@
       Instant authorTime =
           LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent author =
-          new PersonIdent(
-              "Alfred",
-              "alfred@example.com",
-              Date.from(authorTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Alfred", "alfred@example.com", authorTime, ZoneId.of("UTC"));
 
       Instant committerTime =
           LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
       PersonIdent committer =
-          new PersonIdent(
-              "Luise",
-              "luise@example.com",
-              Date.from(committerTime),
-              TimeZone.getTimeZone(ZoneOffset.UTC));
+          new PersonIdent("Luise", "luise@example.com", committerTime, ZoneId.of("UTC"));
 
       RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
       RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
diff --git a/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java b/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
index 55c9bc3..ca4872d 100644
--- a/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
+++ b/javatests/com/google/gerrit/server/plugins/AutoRegisterModulesTest.java
@@ -27,11 +27,11 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.annotation.Annotation;
-import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 import java.util.jar.Manifest;
+import java.util.stream.Stream;
 import org.junit.Test;
 
 public class AutoRegisterModulesTest {
@@ -94,7 +94,7 @@
     }
 
     @Override
-    public Enumeration<PluginEntry> entries() {
+    public Stream<PluginEntry> entries() {
       return null;
     }
   }
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index aed1648..3b7ad1e 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -16,10 +16,12 @@
 
 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.entities.BooleanProjectConfig.REQUIRE_CHANGE_ID;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.common.RuntimeVersion;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.AccountsSection;
@@ -74,31 +76,8 @@
 
 @RunWith(JUnit4.class)
 public class ProjectConfigTest {
-  private static final String LABEL_SCORES_CONFIG =
-      "  copyAnyScore = "
-          + !LabelType.DEF_COPY_ANY_SCORE
-          + "\n"
-          + "  copyMinScore = "
-          + !LabelType.DEF_COPY_MIN_SCORE
-          + "\n"
-          + "  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"
-          + "  copyAllScoresOnTrivialRebase = "
-          + !LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE
-          + "\n"
-          + "  copyAllScoresIfNoCodeChange = "
-          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE
-          + "\n"
-          + "  copyAllScoresIfNoChange = "
-          + !LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE
-          + "\n";
+  private static final String COPY_CONDITION = "is:MIN OR is:MAX";
+  private static final String LABEL_SCORES_CONFIG = "  copyCondition = " + COPY_CONDITION + "\n";
 
   private static final AllProjectsName ALL_PROJECTS = new AllProjectsName("All-The-Projects");
 
@@ -396,7 +375,32 @@
   }
 
   @Test
-  public void readConfigLabelScores() throws Exception {
+  public void readConfigLabelInvalidBranchPattern() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[label \"CustomLabel\"]\n"
+                    + "  value = -1 Negative\n"
+                    + "  value = 0 No Score\n"
+                    + "  value =  1 Positive\n"
+                    + "  branch = ^***\n"
+                    + "  defaultValue = 0\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo(
+            "project.config: Invalid ref pattern \"^***\""
+                + " in label.CustomLabel.branch: Dangling meta character '*' near index 2\n"
+                + "^***\n"
+                + "  ^");
+  }
+
+  @Test
+  public void readConfigCondition() throws Exception {
     RevCommit rev =
         tr.commit()
             .add("groups", group(developers))
@@ -406,19 +410,7 @@
     ProjectConfig cfg = read(rev);
     Map<String, LabelType> labels = cfg.getLabelSections();
     LabelType type = labels.entrySet().iterator().next().getValue();
-    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())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
-    assertThat(type.isCopyAllScoresIfNoCodeChange())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
-    assertThat(type.isCopyAllScoresIfNoChange())
-        .isNotEqualTo(LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
+    assertThat(type.getCopyCondition()).hasValue(COPY_CONDITION);
   }
 
   @Test
@@ -545,14 +537,14 @@
     StoredCommentLinkInfo cm =
         StoredCommentLinkInfo.builder("Test")
             .setMatch("abc.*")
-            .setHtml("<a>link</a>")
+            .setLink("link")
             .setEnabled(true)
             .setOverrideOnly(false)
             .build();
     cfg.addCommentLinkSection(cm);
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
-        .isEqualTo("[commentlink \"Test\"]\n\tmatch = abc.*\n\thtml = <a>link</a>\n");
+        .isEqualTo("[commentlink \"Test\"]\n\tmatch = abc.*\n\tlink = link\n");
   }
 
   @Test
@@ -605,7 +597,7 @@
     PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
     assertThat(pluginCfg.getNames()).hasSize(2);
     assertThat(pluginCfg.getString("key1")).isEqualTo("value1");
-    assertThat(pluginCfg.getStringList(("key2"))).isEqualTo(new String[] {"value2a", "value2b"});
+    assertThat(pluginCfg.getStringList("key2")).isEqualTo(new String[] {"value2a", "value2b"});
   }
 
   @Test
@@ -710,30 +702,37 @@
             .add(
                 "project.config",
                 "[commentlink \"bugzilla\"]\n"
-                    + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
-                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2")
+                    + "\tmatch = \"(^|\\\\s)(bug\\\\s+#?)(\\\\d+)($|\\\\s)\"\n"
+                    + "\tlink = http://bugs.example.com/show_bug.cgi?id=$3\n"
+                    + "\tprefix = $1\n"
+                    + "\ttext = $2$3\n"
+                    + "\tsuffix = $4\n")
             .create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(
             StoredCommentLinkInfo.builder("bugzilla")
-                .setMatch("(bug\\s+#?)(\\d+)")
-                .setLink("http://bugs.example.com/show_bug.cgi?id=$2")
+                .setMatch("(^|\\s)(bug\\s+#?)(\\d+)($|\\s)")
+                .setLink("http://bugs.example.com/show_bug.cgi?id=$3")
+                .setPrefix("$1")
+                .setSuffix("$4")
+                .setText("$2$3")
                 .setOverrideOnly(false)
                 .build());
   }
 
   @Test
-  public void readCommentLinksNoHtmlOrLinkButEnabled() throws Exception {
+  public void readCommentLinksNoLinkButEnabled() throws Exception {
     RevCommit rev =
         tr.commit().add("project.config", "[commentlink \"bugzilla\"]\n \tenabled = true").create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(StoredCommentLinkInfo.enabled("bugzilla"));
+    assertThat(Iterables.getOnlyElement(cfg.getCommentLinkSections()).getEnabled()).isNull();
   }
 
   @Test
-  public void readCommentLinksNoHtmlOrLinkAndDisabled() throws Exception {
+  public void readCommentLinksNoLinkAndDisabled() throws Exception {
     RevCommit rev =
         tr.commit()
             .add("project.config", "[commentlink \"bugzilla\"]\n \tenabled = false")
@@ -741,10 +740,11 @@
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections())
         .containsExactly(StoredCommentLinkInfo.disabled("bugzilla"));
+    assertThat(Iterables.getOnlyElement(cfg.getCommentLinkSections()).getEnabled()).isFalse();
   }
 
   @Test
-  public void readCommentLinksNoHtmlOrLinkAndMissingEnabled() throws Exception {
+  public void readCommentLinksMissingEnabled() throws Exception {
     RevCommit rev =
         tr.commit()
             .add(
@@ -776,36 +776,23 @@
             .create();
     ProjectConfig cfg = read(rev);
     assertThat(cfg.getCommentLinkSections()).isEmpty();
+
+    boolean atLeastJava17 = RuntimeVersion.isAtLeast17();
     assertThat(cfg.getValidationErrors())
         .containsExactly(
             ValidationError.create(
                 "project.config: Invalid pattern \"(bugs{+#?)(d+)\" in commentlink.bugzilla.match: "
-                    + "Illegal repetition near index 4\n"
+                    + "Illegal repetition near index "
+                    + (atLeastJava17 ? "6" : "4")
+                    + "\n"
                     + "(bugs{+#?)(d+)\n"
-                    + "    ^"));
+                    + "    "
+                    + (atLeastJava17 ? "  " : "")
+                    + "^"));
   }
 
   @Test
-  public void readCommentLinkRawHtml() throws Exception {
-    RevCommit rev =
-        tr.commit()
-            .add(
-                "project.config",
-                "[commentlink \"bugzilla\"]\n"
-                    + "\tmatch = \"(bugs#?)(d+)\"\n"
-                    + "\thtml = http://bugs.example.com/show_bug.cgi?id=$2")
-            .create();
-    ProjectConfig cfg = read(rev);
-    assertThat(cfg.getCommentLinkSections()).isEmpty();
-    assertThat(cfg.getValidationErrors())
-        .containsExactly(
-            ValidationError.create(
-                "project.config: Error in pattern \"(bugs#?)(d+)\" in commentlink.bugzilla.match: "
-                    + "Raw html replacement not allowed"));
-  }
-
-  @Test
-  public void readCommentLinkMatchButNoHtmlOrLink() throws Exception {
+  public void readCommentLinkMatchButNoLink() throws Exception {
     RevCommit rev =
         tr.commit()
             .add("project.config", "[commentlink \"bugzilla\"]\n" + "\tmatch = \"(bugs#?)(d+)\"\n")
@@ -816,7 +803,7 @@
         .containsExactly(
             ValidationError.create(
                 "project.config: Error in pattern \"(bugs#?)(d+)\" in commentlink.bugzilla.match: "
-                    + "commentlink.bugzilla must have either link or html"));
+                    + "commentlink.bugzilla must have link specified"));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java
new file mode 100644
index 0000000..98ee71d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementNameValidatorTest.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Test for {@link SubmitRequirementsUtil#validateName(String)}. */
+@RunWith(JUnit4.class)
+public class SubmitRequirementNameValidatorTest {
+  @Test
+  public void canStartWithSmallLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("abc");
+  }
+
+  @Test
+  public void canStartWithCapitalLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("Abc");
+  }
+
+  @Test
+  public void canBeEqualToOneLetter() throws Exception {
+    SubmitRequirementsUtil.validateName("a");
+  }
+
+  @Test
+  public void cannotStartWithNumber() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("98abc"));
+  }
+
+  @Test
+  public void cannotStartWithHyphen() throws Exception {
+    assertThrows(IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("-abc"));
+  }
+
+  @Test
+  public void cannotContainNonAlphanumericOrHyphen() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class, () -> SubmitRequirementsUtil.validateName("a&^bc"));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
index 05eb6e0..b05f3c7 100644
--- a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 import java.util.Arrays;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -45,7 +46,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.MAX_WITH_BLOCK)
             .build();
 
@@ -55,7 +56,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.MAX_NO_BLOCK)
             .build();
 
@@ -65,7 +66,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.ANY_WITH_BLOCK)
             .build();
 
@@ -75,7 +76,7 @@
                 ImmutableList.of(
                     LabelValue.create((short) 1, "Looks good to me"),
                     LabelValue.create((short) 0, "No score"),
-                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+                    LabelValue.create((short) -1, "I would prefer this is not submitted as is")))
             .setFunction(LabelFunction.MAX_WITH_BLOCK)
             .setIgnoreSelfApproval(true)
             .build();
@@ -87,14 +88,15 @@
   public void defaultSubmitRule_withLabelsAllPass() {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.OK,
             Arrays.asList(
                 createLabel("Code-Review", Label.Status.OK),
                 createLabel("Verified", Label.Status.OK)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -115,14 +117,15 @@
   public void defaultSubmitRule_withLabelsAllNeed() {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.OK,
             Arrays.asList(
                 createLabel("Code-Review", Label.Status.NEED),
                 createLabel("Verified", Label.Status.NEED)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -140,15 +143,42 @@
   }
 
   @Test
+  public void defaultSubmitRule_withOneLabelForced() {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            DefaultSubmitRule.RULE_NAME,
+            Status.OK,
+            Arrays.asList(createLabel("Code-Review", Label.Status.NEED)));
+
+    // Submit records that are forced are written with their initial status in NoteDb, e.g. NEED.
+    // If we do a force submit, the gerrit server appends an extra marker record with status=FORCED
+    // to indicate that all other records were forced, that's why we explicitly pass isForced=true
+    // to the "submit requirements adapter". The resulting submit requirement result has a
+    // status=FORCED.
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ true);
+
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "Code-Review",
+        /* submitExpression= */ "label:Code-Review=MAX -label:Code-Review=MIN",
+        SubmitRequirementResult.Status.FORCED,
+        SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
   public void defaultSubmitRule_withLabelStatusNeed_labelHasIgnoreSelfApproval() throws Exception {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.NOT_READY,
             Arrays.asList(createLabel("ISA-Label", Label.Status.NEED)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -163,12 +193,13 @@
   public void defaultSubmitRule_withLabelStatusOk_labelHasIgnoreSelfApproval() throws Exception {
     SubmitRecord submitRecord =
         createSubmitRecord(
-            "gerrit~DefaultSubmitRule",
+            DefaultSubmitRule.RULE_NAME,
             Status.OK,
             Arrays.asList(createLabel("ISA-Label", Label.Status.OK)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -180,12 +211,52 @@
   }
 
   @Test
+  public void defaultSubmitRule_withNonExistingLabel() throws Exception {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            DefaultSubmitRule.RULE_NAME,
+            Status.OK,
+            Arrays.asList(createLabel("Non-Existing", Label.Status.OK)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
+
+    assertThat(requirements).isEmpty();
+  }
+
+  @Test
+  public void defaultSubmitRule_withExistingAndNonExistingLabels() throws Exception {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            DefaultSubmitRule.RULE_NAME,
+            Status.OK,
+            Arrays.asList(
+                createLabel("Non-Existing", Label.Status.OK),
+                createLabel("Code-Review", Label.Status.OK)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
+
+    // The "Non-Existing" label was skipped since it does not exist in the project config.
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "Code-Review",
+        /* submitExpression= */ "label:Code-Review=MAX -label:Code-Review=MIN",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
   public void customSubmitRule_noLabels_withStatusOk() {
     SubmitRecord submitRecord =
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, Arrays.asList());
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -202,7 +273,8 @@
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, /* labels= */ null);
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -219,7 +291,8 @@
         createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.NOT_READY, Arrays.asList());
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(1);
     assertResult(
@@ -241,7 +314,8 @@
                 createLabel("custom-label-2", Label.Status.REJECT)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -269,7 +343,8 @@
                 createLabel("custom-label-2", Label.Status.REJECT)));
 
     List<SubmitRequirementResult> requirements =
-        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+        SubmitRequirementsAdapter.createResult(
+            submitRecord, labelTypes, psCommitId, /* isForced= */ false);
 
     assertThat(requirements).hasSize(2);
     assertResult(
@@ -296,7 +371,7 @@
     assertThat(r.submitRequirement().submittabilityExpression().expressionString())
         .isEqualTo(submitExpression);
     assertThat(r.status()).isEqualTo(status);
-    assertThat(r.submittabilityExpressionResult().status()).isEqualTo(expressionStatus);
+    assertThat(r.submittabilityExpressionResult().get().status()).isEqualTo(expressionStatus);
   }
 
   private SubmitRecord createSubmitRecord(
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 16f7199..1fede32 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -17,6 +17,7 @@
 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.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.fail;
@@ -24,6 +25,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -32,10 +34,12 @@
 import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.api.accounts.Accounts.QueryRequest;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -45,7 +49,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -269,19 +272,7 @@
     AccountInfo user2 = newAccount("user");
     requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
 
-    if (getSchemaVersion() < 5) {
-      assertMissingField(AccountField.PREFERRED_EMAIL);
-      assertFailingQuery("email:foo", "'email' operator is not supported by account index version");
-      return;
-    }
-
-    // This at least needs the PREFERRED_EMAIL field which is available from schema version 5.
-    if (getSchemaVersion() >= 5) {
-      assertQuery(preferredEmail, user1);
-    } else {
-      assertQuery(preferredEmail);
-    }
-
+    assertQuery(preferredEmail, user1);
     assertQuery(secondaryEmail);
 
     assertQuery("email:" + preferredEmail, user1);
@@ -290,6 +281,7 @@
 
   @Test
   public void byUsername() throws Exception {
+    assume().that(hasIndexByUsername()).isTrue();
     AccountInfo user1 = newAccount("myuser");
 
     assertQuery("notexisting");
@@ -369,14 +361,6 @@
     assertQuery("self", user3);
     assertQuery("me", user3);
 
-    if (getSchemaVersion() < 8) {
-      assertMissingField(AccountField.NAME_PART_NO_SECONDARY_EMAIL);
-
-      // prefix queries only work if the NAME_PART_NO_SECONDARY_EMAIL field is available
-      assertQuery("john");
-      return;
-    }
-
     assertQuery("John", user1);
     assertQuery("john", user1);
     assertQuery("Doe", user1);
@@ -408,6 +392,46 @@
   }
 
   @Test
+  public void byCanSee_privateChange() throws Exception {
+    String domain = name("test.com");
+    AccountInfo user1 = newAccountWithEmail("account1", "account1@" + domain);
+    AccountInfo user2 = newAccountWithEmail("account2", "account2@" + domain);
+    AccountInfo user3 = newAccountWithEmail("account3", "account3@" + domain);
+    AccountInfo user4 = newAccountWithEmail("account4", "account4@" + domain);
+
+    Project.NameKey p = createProject(name("p"));
+
+    // Create the change as User1
+    requestContext.setContext(newRequestContext(Account.id(user1._accountId)));
+    ChangeInfo c = createPrivateChange(p);
+    assertThat(c.owner).isEqualTo(user1);
+
+    // Add user2 as a reviewer, user3 as a CC, and leave user4 dangling.
+    addReviewer(c.changeId, user2.email, ReviewerState.REVIEWER);
+    addReviewer(c.changeId, user3.email, ReviewerState.CC);
+
+    // Request as the owner
+    requestContext.setContext(newRequestContext(Account.id(user1._accountId)));
+    assertQuery("cansee:" + c.changeId, user1, user2, user3);
+
+    // Request as the reviewer
+    requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
+    assertQuery("cansee:" + c.changeId, user1, user2, user3);
+
+    // Request as the CC
+    requestContext.setContext(newRequestContext(Account.id(user3._accountId)));
+    assertQuery("cansee:" + c.changeId, user1, user2, user3);
+
+    // Request as an account not in {owner, reviewer, CC}
+    requestContext.setContext(newRequestContext(Account.id(user4._accountId)));
+    BadRequestException exception =
+        assertThrows(BadRequestException.class, () -> newQuery("cansee:" + c.changeId).get());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(String.format("change %s not found", c.changeId));
+  }
+
+  @Test
   public void byWatchedProject() throws Exception {
     Project.NameKey p = createProject(name("p"));
     Project.NameKey p2 = createProject(name("p2"));
@@ -468,6 +492,12 @@
   }
 
   @Test
+  public void withStartCannotBeLessThanZero() throws Exception {
+    assertFailingQuery(
+        newQuery("self").withStart(-1), "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void sortedByFullname() throws Exception {
     String appendix = name("name");
 
@@ -532,7 +562,7 @@
   public void withDetails() throws Exception {
     AccountInfo user1 = newAccount("myuser", "My User", "my.user@example.com", true);
 
-    List<AccountInfo> result = assertQuery(user1.username, user1);
+    List<AccountInfo> result = assertQuery(getDefaultSearch(user1), user1);
     AccountInfo ai = result.get(0);
     assertThat(ai._accountId).isEqualTo(user1._accountId);
     assertThat(ai.name).isNull();
@@ -540,7 +570,9 @@
     assertThat(ai.email).isNull();
     assertThat(ai.avatars).isNull();
 
-    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
+    result =
+        assertQuery(
+            newQuery(getDefaultSearch(user1)).withOption(ListAccountsOption.DETAILS), user1);
     ai = result.get(0);
     assertThat(ai._accountId).isEqualTo(user1._accountId);
     assertThat(ai.name).isEqualTo(user1.name);
@@ -555,25 +587,29 @@
     String[] secondaryEmails = new String[] {"bar@example.com", "foo@example.com"};
     addEmails(user1, secondaryEmails);
 
-    List<AccountInfo> result = assertQuery(user1.username, user1);
+    List<AccountInfo> result = assertQuery(getDefaultSearch(user1), user1);
     assertThat(result.get(0).secondaryEmails).isNull();
 
-    result = assertQuery(newQuery(user1.username).withSuggest(true), user1);
-    assertThat(result.get(0).secondaryEmails)
-        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
-        .inOrder();
-
-    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
-    assertThat(result.get(0).secondaryEmails).isNull();
-
-    result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.ALL_EMAILS), user1);
+    result = assertQuery(newQuery(getDefaultSearch(user1)).withSuggest(true), user1);
     assertThat(result.get(0).secondaryEmails)
         .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
         .inOrder();
 
     result =
         assertQuery(
-            newQuery(user1.username)
+            newQuery(getDefaultSearch(user1)).withOption(ListAccountsOption.DETAILS), user1);
+    assertThat(result.get(0).secondaryEmails).isNull();
+
+    result =
+        assertQuery(
+            newQuery(getDefaultSearch(user1)).withOption(ListAccountsOption.ALL_EMAILS), user1);
+    assertThat(result.get(0).secondaryEmails)
+        .containsExactlyElementsIn(Arrays.asList(secondaryEmails))
+        .inOrder();
+
+    result =
+        assertQuery(
+            newQuery(getDefaultSearch(user1))
                 .withOptions(ListAccountsOption.DETAILS, ListAccountsOption.ALL_EMAILS),
             user1);
     assertThat(result.get(0).secondaryEmails)
@@ -591,21 +627,22 @@
 
     requestContext.setContext(newRequestContext(Account.id(user._accountId)));
 
-    List<AccountInfo> result = newQuery(otherUser.username).withSuggest(true).get();
+    List<AccountInfo> result = newQuery(getDefaultSearch(otherUser)).withSuggest(true).get();
     assertThat(result.get(0).secondaryEmails).isNull();
     assertThrows(
         AuthException.class,
-        () -> newQuery(otherUser.username).withOption(ListAccountsOption.ALL_EMAILS).get());
+        () ->
+            newQuery(getDefaultSearch(otherUser)).withOption(ListAccountsOption.ALL_EMAILS).get());
   }
 
   @Test
   public void asAnonymous() throws Exception {
-    AccountInfo user1 = newAccount("user1");
+    AccountInfo user1 = newAccount("user1", "user1@gerrit.com", /*active=*/ true);
 
     setAnonymous();
     assertQuery("9999999");
     assertQuery("self");
-    assertQuery("username:" + user1.username, user1);
+    assertQuery("email:" + user1.email, user1);
   }
 
   // reindex permissions are tested by {@link AccountIT#reindexPermissions}
@@ -646,19 +683,20 @@
             .getRaw(
                 Account.id(userInfo._accountId),
                 QueryOptions.create(
-                    IndexConfig.createDefault(), 0, 1, schema.getStoredFields().keySet()));
+                    config != null
+                        ? IndexConfig.fromConfig(config).build()
+                        : IndexConfig.createDefault(),
+                    0,
+                    1,
+                    schema.getStoredFields()));
 
     assertThat(rawFields).isPresent();
-    if (schema.useLegacyNumericFields()) {
-      assertThat(rawFields.get().getValue(AccountField.ID)).isEqualTo(userInfo._accountId);
-    } else {
-      assertThat(Integer.valueOf(rawFields.get().getValue(AccountField.ID_STR)))
+    if (schema.hasField(AccountField.ID_FIELD_SPEC)) {
+      assertThat(rawFields.get().getValue(AccountField.ID_FIELD_SPEC))
           .isEqualTo(userInfo._accountId);
-    }
-
-    // The field EXTERNAL_ID_STATE is only supported from schema version 6.
-    if (getSchemaVersion() < 6) {
-      return;
+    } else {
+      assertThat(Integer.valueOf(rawFields.get().<String>getValue(AccountField.ID_STR_FIELD_SPEC)))
+          .isEqualTo(userInfo._accountId);
     }
 
     List<AccountExternalIdInfo> externalIdInfos = gApi.accounts().self().getExternalIds();
@@ -668,14 +706,33 @@
       assertThat(extId).isPresent();
       blobs.add(new ByteArrayWrapper(extId.get().toByteArray()));
     }
-    assertThat(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE)).hasSize(blobs.size());
-    assertThat(
-            Streams.stream(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE))
-                .map(ByteArrayWrapper::new)
-                .collect(toList()))
+
+    // Some installations do not store EXTERNAL_ID_STATE_SPEC
+    if (!schema.hasField(AccountField.EXTERNAL_ID_STATE_SPEC)) {
+      return;
+    }
+    Iterable<byte[]> externalIdStates =
+        rawFields.get().<Iterable<byte[]>>getValue(AccountField.EXTERNAL_ID_STATE_SPEC);
+    assertThat(externalIdStates).hasSize(blobs.size());
+    assertThat(Streams.stream(externalIdStates).map(b -> new ByteArrayWrapper(b)).collect(toList()))
         .containsExactlyElementsIn(blobs);
   }
 
+  private String getDefaultSearch(AccountInfo user) {
+    return hasIndexByUsername() ? user.username : user.name;
+  }
+
+  /**
+   * Returns 'true' is {@link AccountField#USERNAME_FIELD} is indexed.
+   *
+   * <p>Some installations do not index {@link AccountField#USERNAME_FIELD}, since they do not use
+   * {@link ExternalId#SCHEME_USERNAME}
+   */
+  private boolean hasIndexByUsername() {
+    Schema<AccountState> schema = indexes.getSearchIndex().getSchema();
+    return schema.hasField(AccountField.USERNAME_SPEC);
+  }
+
   protected AccountInfo newAccount(String username) throws Exception {
     return newAccountWithEmail(username, null);
   }
@@ -729,6 +786,15 @@
     gApi.projects().name(project.get()).access(in);
   }
 
+  protected ChangeInfo createPrivateChange(Project.NameKey project) throws RestApiException {
+    ChangeInput in = new ChangeInput();
+    in.subject = "A change";
+    in.project = project.get();
+    in.branch = "master";
+    in.isPrivate = true;
+    return gApi.changes().create(in).get();
+  }
+
   protected ChangeInfo createChange(Project.NameKey project) throws RestApiException {
     ChangeInput in = new ChangeInput();
     in.subject = "A change";
@@ -737,6 +803,14 @@
     return gApi.changes().create(in).get();
   }
 
+  protected void addReviewer(String changeId, String email, ReviewerState state)
+      throws RestApiException {
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = email;
+    reviewerInput.state = state;
+    gApi.changes().id(changeId).addReviewer(reviewerInput);
+  }
+
   protected GroupInfo createGroup(String name, AccountInfo... members) throws RestApiException {
     GroupInput in = new GroupInput();
     in.name = name;
@@ -762,6 +836,7 @@
     return "\"" + s + "\"";
   }
 
+  @Nullable
   protected String name(String name) {
     if (name == null) {
       return null;
@@ -876,13 +951,7 @@
     return accounts.stream().map(a -> a._accountId).collect(toList());
   }
 
-  protected void assertMissingField(FieldDef<AccountState, ?> field) {
-    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
-        .that(getSchema().hasField(field))
-        .isFalse();
-  }
-
-  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
     try {
       assertQuery(query);
       fail("expected BadRequestException for query '" + query + "'");
@@ -891,14 +960,6 @@
     }
   }
 
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
-  protected Schema<AccountState> getSchema() {
-    return indexes.getSearchIndex().getSchema();
-  }
-
   /** Boiler plate code to check two byte arrays for equality */
   private static class ByteArrayWrapper {
     private byte[] arr;
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index 4ae2039..c781d8b 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -13,6 +13,7 @@
         "//prolog:gerrit-prolog-common",
     ],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
@@ -53,11 +54,9 @@
     visibility = ["//visibility:public"],
     deps = [
         ":abstract_query_tests",
-        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jgit",
         "//lib/guice",
-        "@truth//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 5253a5b..fbf9c87 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -46,6 +46,7 @@
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.FakeSubmitRule;
@@ -91,7 +92,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
-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;
@@ -103,26 +103,29 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.account.VersionedAccountQueries;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
 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.ChangeUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -134,20 +137,21 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GerritServerTests;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.TestChanges;
 import com.google.gerrit.testing.TestTimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -157,7 +161,7 @@
 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.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
@@ -175,14 +179,14 @@
   @Inject protected AllUsersName allUsersName;
   @Inject protected BatchUpdate.Factory updateFactory;
   @Inject protected ChangeInserter.Factory changeFactory;
-  @Inject protected Provider<ChangeQueryBuilder> queryBuilderProvider;
+  @Inject protected ChangeQueryBuilder queryBuilder;
   @Inject protected GerritApi gApi;
   @Inject protected IdentifiedUser.GenericFactory userFactory;
   @Inject protected ChangeIndexCollection indexes;
   @Inject protected ChangeIndexer indexer;
   @Inject protected ExtensionRegistry extensionRegistry;
   @Inject protected IndexConfig indexConfig;
-  @Inject protected InMemoryRepositoryManager repoManager;
+  @Inject protected GitRepositoryManager repoManager;
   @Inject protected Provider<AnonymousUser> anonymousUserProvider;
   @Inject protected Provider<InternalChangeQuery> queryProvider;
   @Inject protected ChangeNotes.Factory notesFactory;
@@ -194,22 +198,31 @@
   @Inject protected SchemaCreator schemaCreator;
   @Inject protected Sequences seq;
   @Inject protected ThreadLocalRequestContext requestContext;
+  @Inject protected TestGroupBackend testGroupBackend;
   @Inject protected ProjectCache projectCache;
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-  @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
   @Inject protected AuthRequest.Factory authRequestFactory;
   @Inject protected ExternalIdFactory externalIdFactory;
+  @Inject protected ProjectOperations projectOperations;
 
   @Inject private ProjectConfig.Factory projectConfigFactory;
-  @Inject private ProjectOperations projectOperations;
 
   protected Injector injector;
   protected LifecycleManager lifecycle;
+
+  /**
+   * Index tests should not use username in query assert, since some backends do not use {@link
+   * ExternalId#SCHEME_USERNAME}
+   */
   protected Account.Id userId;
+
   protected CurrentUser user;
+  protected Account userAccount;
 
   private String systemTimeZone;
 
+  protected TestRepository<Repository> repo;
+
   protected abstract Injector createInjector();
 
   @Before
@@ -225,6 +238,10 @@
 
   @After
   public void cleanUp() {
+    if (repo != null) {
+      repo.close();
+      repo = null;
+    }
     lifecycle.stop();
   }
 
@@ -251,8 +268,9 @@
     return () -> requestUser;
   }
 
-  protected void resetUser() {
+  protected void resetUser() throws ConfigInvalidException, IOException {
     user = userFactory.create(userId);
+    userAccount = accounts.get(userId).get().account();
     requestContext.setContext(newRequestContext(userId));
   }
 
@@ -280,14 +298,17 @@
   @After
   public void resetTime() {
     TestTimeUtil.useSystemTime();
-    System.setProperty("user.timezone", systemTimeZone);
+    if (systemTimeZone != null) {
+      System.setProperty("user.timezone", systemTimeZone);
+      systemTimeZone = null;
+    }
   }
 
   @Test
   public void byId() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     assertQuery("12345");
     assertQuery(change1.getId().get(), change1);
@@ -296,8 +317,8 @@
 
   @Test
   public void byKey() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
     String key = change.getKey().get();
 
     assertQuery("I0000000000000000000000000000000000000000");
@@ -309,8 +330,8 @@
 
   @Test
   public void byTriplet() throws Exception {
-    TestRepository<Repo> repo = createProject("iabcde");
-    Change change = insert(repo, newChangeForBranch(repo, "branch"));
+    repo = createAndOpenProject("iabcde");
+    Change change = insert("iabcde", newChangeForBranch(repo, "branch"));
     String k = change.getKey().get();
 
     assertQuery("iabcde~branch~" + k, change);
@@ -332,27 +353,41 @@
 
   @Test
   public void byStatus() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     assertQuery("status:new", change1);
     assertQuery("status:NEW", change1);
     assertQuery("is:new", change1);
     assertQuery("status:merged", change2);
     assertQuery("is:merged", change2);
-    assertQuery("status:draft");
-    assertQuery("is:draft");
+    Exception thrown = assertThrows(BadRequestException.class, () -> assertQuery("is:draft"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: draft");
+    thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:draft"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: draft");
+  }
+
+  @Test
+  public void byStatusOr() throws Exception {
+    repo = createAndOpenProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
+    Change change1 = insert("repo", ins1);
+    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
+    Change change2 = insert("repo", ins2);
+
+    assertQuery("status:new OR status:merged", change2, change1);
+    assertQuery("status:new or status:merged", change2, change1);
   }
 
   @Test
   public void byStatusOpen() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert("repo", ins1);
+    insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     Change[] expected = new Change[] {change1};
     assertQuery("status:open", expected);
@@ -371,12 +406,12 @@
 
   @Test
   public void byStatusClosed() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
-    Change change2 = insert(repo, ins2);
-    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    Change change2 = insert("repo", ins2);
+    insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
 
     Change[] expected = new Change[] {change2, change1};
     assertQuery("status:closed", expected);
@@ -392,12 +427,12 @@
 
   @Test
   public void byStatusAbandoned() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
-    insert(repo, ins1);
+    insert("repo", ins1);
     ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
-    Change change1 = insert(repo, ins2);
-    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    Change change1 = insert("repo", ins2);
+    insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
 
     assertQuery("status:abandoned", change1);
     assertQuery("status:ABANDONED", change1);
@@ -406,10 +441,10 @@
 
   @Test
   public void byStatusPrefix() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change1 = insert("repo", ins1);
+    Change change2 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     assertQuery("status:n", change1);
     assertQuery("status:ne", change1);
@@ -417,17 +452,20 @@
     assertQuery("status:N", change1);
     assertQuery("status:nE", change1);
     assertQuery("status:neW", change1);
-    assertQuery("status:nx");
-    assertQuery("status:newx");
+    assertQuery("status:m", change2);
+    Exception thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:newx"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: newx");
+    thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:nx"));
+    assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: nx");
   }
 
   @Test
   public void byPrivate() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
 
     // No private changes.
     assertQuery("is:open", change2, change1);
@@ -447,8 +485,8 @@
 
   @Test
   public void byWip() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
 
     assertQuery("is:open", change1);
     assertQuery("is:wip");
@@ -465,8 +503,8 @@
   @Test
   public void excludeWipChangeFromReviewersDashboards() throws Exception {
     Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWorkInProgress(repo), userId);
 
     assertQuery("is:wip", change1);
     assertQuery("reviewer:" + user1);
@@ -482,8 +520,8 @@
 
   @Test
   public void byStarted() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWorkInProgress(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWorkInProgress(repo));
 
     assertQuery("is:started");
 
@@ -518,11 +556,11 @@
   @Test
   public void restorePendingReviewers() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
+    repo = createAndOpenProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
-    Change change1 = insert(repo, newChangeWorkInProgress(repo));
+    Change change1 = insert("repo", newChangeWorkInProgress(repo));
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
     String email1 = "email1@example.com";
@@ -575,9 +613,9 @@
 
   @Test
   public void byCommit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
-    Change change = insert(repo, ins);
+    Change change = insert("repo", ins);
     String sha = ins.getCommitId().name();
 
     assertQuery("0000000000000000000000000000000000000000");
@@ -591,11 +629,11 @@
 
   @Test
   public void byOwner() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
 
     assertQuery("is:owner", change1);
     assertQuery("owner:" + userId.get(), change1);
@@ -607,16 +645,17 @@
 
   @Test
   public void byUploader() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
-    Account.Id user2 =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    CurrentUser user2CurrentUser = userFactory.create(user2);
+    assume().that(getSchema().hasField(ChangeField.UPLOADER_SPEC)).isTrue();
 
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     assertQuery("is:uploader", change1);
     assertQuery("uploader:" + userId.get(), change1);
-    change1 = newPatchSet(repo, change1, user2CurrentUser);
+
+    Account.Id user2 = createAccount("anotheruser");
+    CurrentUser user2CurrentUser = userFactory.create(user2);
+
+    change1 = newPatchSet("repo", change1, user2CurrentUser, /* message= */ Optional.empty());
     // Uploader has changed
     assertQuery("uploader:" + userId.get());
     assertQuery("uploader:" + user2.get(), change1);
@@ -630,7 +669,6 @@
 
   @Test
   public void byAuthorExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
     byAuthorOrCommitterExact("author:");
   }
 
@@ -641,7 +679,6 @@
 
   @Test
   public void byCommitterExact() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.EXACT_COMMITTER)).isTrue();
     byAuthorOrCommitterExact("committer:");
   }
 
@@ -651,7 +688,7 @@
   }
 
   private void byAuthorOrCommitterExact(String searchOperator) throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    createProject("repo");
     PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
     PersonIdent john = new PersonIdent("John", "john@example.com");
     PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
@@ -659,10 +696,10 @@
     PersonIdent myself = new PersonIdent("I Am", ua.preferredEmail());
     PersonIdent selfName = new PersonIdent("My Self", "my.self@example.com");
 
-    Change change1 = createChange(repo, johnDoe);
-    Change change2 = createChange(repo, john);
-    Change change3 = createChange(repo, doeSmith);
-    createChange(repo, selfName);
+    Change change1 = createChange("repo", johnDoe);
+    Change change2 = createChange("repo", john);
+    Change change3 = createChange("repo", doeSmith);
+    createChange("repo", selfName);
 
     // Only email address.
     assertQuery(searchOperator + "john.doe@example.com", change1);
@@ -687,19 +724,19 @@
     assertQuery(searchOperator + "self");
 
     // ':self' matches a change created with the current user's email address
-    Change change5 = createChange(repo, myself);
+    Change change5 = createChange("repo", myself);
     assertQuery(searchOperator + "me", change5);
     assertQuery(searchOperator + "self", change5);
   }
 
   private void byAuthorOrCommitterFullText(String searchOperator) throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    createProject("repo");
     PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
     PersonIdent john = new PersonIdent("John", "john@example.com");
     PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
-    Change change1 = createChange(repo, johnDoe);
-    Change change2 = createChange(repo, john);
-    Change change3 = createChange(repo, doeSmith);
+    Change change1 = createChange("repo", johnDoe);
+    Change change2 = createChange("repo", john);
+    Change change3 = createChange("repo", doeSmith);
 
     // By exact name.
     assertQuery(searchOperator + "\"John Doe\"", change1);
@@ -720,20 +757,25 @@
     assertThat(thrown).hasMessageThat().contains("invalid value");
   }
 
-  private Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
-    RevCommit commit =
-        repo.parseBody(repo.commit().message("message").author(person).committer(person).create());
-    return insert(repo, newChangeForCommit(repo, commit), null);
+  @CanIgnoreReturnValue
+  protected Change createChange(String repoName, PersonIdent person) throws Exception {
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+      RevCommit commit =
+          repo.parseBody(
+              repo.commit().message("message").author(person).committer(person).create());
+      return insert("repo", newChangeForCommit(repo, commit), null);
+    }
   }
 
   @Test
   public void byOwnerIn() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
-    Change change3 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
+    Change change3 = insert("repo", newChange(repo), user2);
     gApi.changes().id(change3.getId().get()).current().review(ReviewInput.approve());
     gApi.changes().id(change3.getId().get()).current().submit();
 
@@ -745,24 +787,26 @@
 
   @Test
   public void byUploaderIn() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    assume().that(getSchema().hasField(ChangeField.UPLOADER_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
+
     assertQuery("uploaderin:Administrators", change1);
 
-    Account.Id user2 =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    Account.Id user2 = createAccount("anotheruser");
     CurrentUser user2CurrentUser = userFactory.create(user2);
-    newPatchSet(repo, change1, user2CurrentUser);
+    change1 = newPatchSet("repo", change1, user2CurrentUser, /* message= */ Optional.empty());
+
     assertQuery("uploaderin:Administrators");
+    assertQuery("uploaderin:\"Registered Users\"", change1);
   }
 
   @Test
   public void byProject() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("project:foo");
     assertQuery("project:repo");
@@ -772,16 +816,16 @@
 
   @Test
   public void byProjectWithHidden() throws Exception {
-    TestRepository<Repo> hiddenProject = createProject("hiddenProject");
-    insert(hiddenProject, newChange(hiddenProject));
+    createProject("hiddenProject");
+    insert("hiddenProject", newChange("hiddenProject"));
     projectOperations
         .project(Project.nameKey("hiddenProject"))
         .forUpdate()
         .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
         .update();
 
-    TestRepository<Repo> visibleProject = createProject("visibleProject");
-    Change visibleChange = insert(visibleProject, newChange(visibleProject));
+    createProject("visibleProject");
+    Change visibleChange = insert("visibleProject", newChange("visibleProject"));
     assertQuery("project:visibleProject", visibleChange);
     assertQuery("project:hiddenProject");
     assertQuery("project:visibleProject OR project:hiddenProject", visibleChange);
@@ -789,13 +833,13 @@
 
   @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));
+    repo = createAndOpenProject("repo1");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("message").create());
+    Change change1 = insert("repo1", newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit(commit1));
+    Change change2 = insert("repo1", newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit(commit1, commit2));
+    Change change3 = insert("repo1", newChangeForCommit(repo, commit3));
 
     assertQuery("parentof:" + change1.getId().get());
     assertQuery("parentof:" + change1.getKey().get());
@@ -807,10 +851,10 @@
 
   @Test
   public void byParentProject() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2", "repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("parentproject:repo1", change2, change1);
     assertQuery("parentproject:repo2", change2);
@@ -818,10 +862,10 @@
 
   @Test
   public void byProjectPrefix() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("projects:foo");
     assertQuery("projects:repo1", change1);
@@ -831,10 +875,10 @@
 
   @Test
   public void byRepository() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repository:foo");
     assertQuery("repository:repo");
@@ -844,10 +888,10 @@
 
   @Test
   public void byParentRepository() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2", "repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("parentrepository:repo1", change2, change1);
     assertQuery("parentrepository:repo2", change2);
@@ -855,10 +899,10 @@
 
   @Test
   public void byRepositoryPrefix() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repositories:foo");
     assertQuery("repositories:repo1", change1);
@@ -868,10 +912,10 @@
 
   @Test
   public void byRepo() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repo:foo");
     assertQuery("repo:repo");
@@ -881,10 +925,10 @@
 
   @Test
   public void byParentRepo() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2", "repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2", "repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("parentrepo:repo1", change2, change1);
     assertQuery("parentrepo:repo2", change2);
@@ -892,10 +936,10 @@
 
   @Test
   public void byRepoPrefix() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change1 = insert(repo1, newChange(repo1));
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    createProject("repo2");
+    Change change1 = insert("repo1", newChange("repo1"));
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertQuery("repos:foo");
     assertQuery("repos:repo1", change1);
@@ -905,9 +949,9 @@
 
   @Test
   public void byBranchAndRef() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeForBranch(repo, "master"));
-    Change change2 = insert(repo, newChangeForBranch(repo, "branch"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeForBranch(repo, "master"));
+    Change change2 = insert("repo", newChangeForBranch(repo, "branch"));
 
     assertQuery("branch:foo");
     assertQuery("branch:master", change1);
@@ -923,26 +967,27 @@
 
   @Test
   public void byTopic() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
 
     ChangeInserter ins2 = newChangeWithTopic(repo, "feature2");
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     ChangeInserter ins3 = newChangeWithTopic(repo, "Cherrypick-feature2");
-    Change change3 = insert(repo, ins3);
+    Change change3 = insert("repo", ins3);
 
     ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
-    Change change4 = insert(repo, ins4);
+    Change change4 = insert("repo", ins4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "https://gerrit.local");
-    Change change5 = insert(repo, ins5);
+    Change change5 = insert("repo", ins5);
 
     ChangeInserter ins6 = newChangeWithTopic(repo, "git_gerrit_training");
-    Change change6 = insert(repo, ins6);
+    Change change6 = insert("repo", ins6);
 
-    Change change_no_topic = insert(repo, newChange(repo));
+    Change changeNoTopic = insert("repo", newChange(repo));
 
     assertQuery("intopic:foo");
     assertQuery("intopic:feature1", change1);
@@ -951,22 +996,27 @@
     assertQuery("intopic:feature2", change4, change3, change2);
     assertQuery("intopic:fixup", change4);
     assertQuery("intopic:gerrit", change6, change5);
-    assertQuery("topic:\"\"", change_no_topic);
-    assertQuery("intopic:\"\"", change_no_topic);
+    assertQuery("topic:\"\"", changeNoTopic);
+    assertQuery("intopic:\"\"", changeNoTopic);
+
+    assume().that(getSchema().hasField(ChangeField.PREFIX_TOPIC)).isTrue();
+    assertQuery("prefixtopic:feature", change4, change2, change1);
+    assertQuery("prefixtopic:Cher", change3);
+    assertQuery("prefixtopic:feature22");
   }
 
   @Test
   public void byTopicRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
     ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
-    Change change1 = insert(repo, ins1);
+    Change change1 = insert("repo", ins1);
 
     ChangeInserter ins2 = newChangeWithTopic(repo, "Cherrypick-feature1");
-    Change change2 = insert(repo, ins2);
+    Change change2 = insert("repo", ins2);
 
     ChangeInserter ins3 = newChangeWithTopic(repo, "feature1-fixup");
-    Change change3 = insert(repo, ins3);
+    Change change3 = insert("repo", ins3);
 
     assertQuery("intopic:^feature1.*", change3, change1);
     assertQuery("intopic:{^.*feature1$}", change2, change1);
@@ -974,24 +1024,145 @@
 
   @Test
   public void byMessageExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("A great \"fix\" to my bug").create());
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     assertQuery("message:foo");
     assertQuery("message:one", change1);
     assertQuery("message:two", change2);
+    assertQuery("message:\"great \\\"fix\\\" to\"", change3);
+  }
+
+  @Test
+  public void byMessageRegEx() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.COMMIT_MESSAGE_EXACT)).isTrue();
+    repo = createAndOpenProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("aaaabcc").create());
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("aaaacc").create());
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("Title\n\nHELLO WORLD").create());
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    RevCommit commit4 =
+        repo.parseBody(repo.commit().message("Title\n\nfoobar hello WORLD").create());
+    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
+
+    assertQuery("message:\"^aaaa(b|c)*\"", change2, change1);
+    assertQuery("message:\"^aaaa(c)*c.*\"", change2);
+    assertQuery("message:\"^.*HELLO WORLD.*\"", change3);
+    assertQuery(
+        "message:\"^.*(H|h)(E|e)(L|l)(L|l)(O|o) (W|w)(O|o)(R|r)(L|l)(D|d).*\"", change4, change3);
+  }
+
+  @Test
+  public void bySubject() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.SUBJECT_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
+    RevCommit commit1 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "First commit with test subject\n\n"
+                        + "Message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
+                .create());
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    RevCommit commit2 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "Second commit with test subject\n\n"
+                        + "Message body for another commit\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    RevCommit commit3 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "Third commit with test subject\n\n"
+                        + "Last message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+
+    assertQuery("subject:First", change1);
+    assertQuery("subject:Second", change2);
+    assertQuery("subject:Third", change3);
+    assertQuery("subject:\"commit with test subject\"", change3, change2, change1);
+    assertQuery("subject:\"Message body\"");
+    assertQuery("subject:body");
+    change1 =
+        newPatchSet(
+            "repo",
+            change1,
+            user,
+            Optional.of("Rework of commit with test subject\n\n" + "Message body\n\n"));
+    assertQuery("subject:Rework", change1);
+    assertQuery("subject:First");
+    assertQuery("subject:\"commit with test subject\"", change1, change3, change2);
+  }
+
+  @Test
+  public void bySubjectPrefix() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.PREFIX_SUBJECT_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
+    RevCommit commit1 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "[FOO123] First commit with test subject\n\n"
+                        + "Message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
+                .create());
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    RevCommit commit2 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "[BAR45] Second commit with test subject\n\n"
+                        + "Message body for another commit\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    RevCommit commit3 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "[FOO99] Third commit with test subject\n\n"
+                        + "Last message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+
+    assertQuery("prefixsubject:\"[FOO\"", change3, change1);
+    assertQuery("prefixsubject:\"[BAR\"", change2);
+    assertQuery("prefixsubject:\"[FOO1\"", change1);
+    assertQuery("prefixsubject:\"[FOO123]\"", change1);
+    assertQuery("prefixsubject:\"[\"", change3, change2, change1);
+    assertQuery("prefixsubject:FOO");
+    change1 =
+        newPatchSet(
+            "repo",
+            change1,
+            user,
+            Optional.of("[BAR123] Rework of commit with test subject\n\n" + "Message body\n\n"));
+    assertQuery("prefixsubject:\"[FOO\"", change3);
+    assertQuery("prefixsubject:\"[BAR\"", change1, change2);
   }
 
   @Test
   public void fullTextWithNumbers() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("12345 67890").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("12346 67891").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("message:1234");
     assertQuery("message:12345", change1);
@@ -1000,13 +1171,13 @@
 
   @Test
   public void fullTextMultipleTerms() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Signed-off: owner").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Signed by owner").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("This change is off").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     assertQuery("message:\"Signed-off: owner\"", change1);
     assertQuery("message:\"Signed\"", change2, change1);
@@ -1015,11 +1186,11 @@
 
   @Test
   public void byMessageMixedCase() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Hello gerrit").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Hello Gerrit").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("message:gerrit", change2, change1);
     assertQuery("message:Gerrit", change2, change1);
@@ -1027,16 +1198,16 @@
 
   @Test
   public void byMessageSubstring() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("https://gerrit.local").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     assertQuery("message:gerrit", change1);
   }
 
   @Test
   public void byLabel() throws Exception {
-    accountManager.authenticate(authRequestFactory.createForUser("anotheruser"));
-    TestRepository<Repo> repo = createProject("repo");
+    Account.Id anotherUser = createAccount("anotheruser");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
     ChangeInserter ins2 = newChange(repo);
     ChangeInserter ins3 = newChange(repo);
@@ -1044,24 +1215,24 @@
     ChangeInserter ins5 = newChange(repo);
     ChangeInserter ins6 = newChange(repo);
 
-    Change reviewMinus2Change = insert(repo, ins);
+    Change reviewMinus2Change = insert("repo", ins);
     gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
 
-    Change reviewMinus1Change = insert(repo, ins2);
+    Change reviewMinus1Change = insert("repo", ins2);
     gApi.changes().id(reviewMinus1Change.getId().get()).current().review(ReviewInput.dislike());
 
-    Change noLabelChange = insert(repo, ins3);
+    Change noLabelChange = insert("repo", ins3);
 
-    Change reviewPlus1Change = insert(repo, ins4);
+    Change reviewPlus1Change = insert("repo", ins4);
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
-    Change reviewTwoPlus1Change = insert(repo, ins5);
+    Change reviewTwoPlus1Change = insert("repo", ins5);
     gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
     requestContext.setContext(newRequestContext(createAccount("user1")));
     gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
     requestContext.setContext(newRequestContext(userId));
 
-    Change reviewPlus2Change = insert(repo, ins6);
+    Change reviewPlus2Change = insert("repo", ins6);
     gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
 
     Map<String, Short> m =
@@ -1125,9 +1296,15 @@
     assertQuery("label:Code-Review<=-2", reviewMinus2Change);
     assertQuery("label:Code-Review<-2");
 
-    assertQuery("label:Code-Review=+1,anotheruser");
-    assertQuery("label:Code-Review=+1,user", reviewTwoPlus1Change, reviewPlus1Change);
-    assertQuery("label:Code-Review=+1,user=user", reviewTwoPlus1Change, reviewPlus1Change);
+    assertQuery("label:Code-Review=+1," + anotherUser);
+    assertQuery(
+        String.format("label:Code-Review=+1,%s", userAccount.preferredEmail()),
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
+    assertQuery(
+        String.format("label:Code-Review=+1,user=%s", userAccount.preferredEmail()),
+        reviewTwoPlus1Change,
+        reviewPlus1Change);
     assertQuery("label:Code-Review=+1,Administrators", reviewTwoPlus1Change, reviewPlus1Change);
     assertQuery(
         "label:Code-Review=+1,group=Administrators", reviewTwoPlus1Change, reviewPlus1Change);
@@ -1194,9 +1371,8 @@
 
   @Test
   public void byLabelMulti() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Project.NameKey project =
-        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project.get());
 
     LabelType verified =
         label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
@@ -1206,7 +1382,7 @@
       cfg.upsertLabelType(verified);
       cfg.commit(md);
     }
-    projectCache.evict(project);
+    projectCache.evictAndReindex(project);
 
     String heads = RefNames.REFS_HEADS + "*";
     projectOperations
@@ -1223,25 +1399,25 @@
     ChangeInserter ins5 = newChange(repo);
 
     // CR+1
-    Change reviewCRplus1 = insert(repo, ins);
+    Change reviewCRplus1 = insert(project.get(), ins);
     gApi.changes().id(reviewCRplus1.getId().get()).current().review(ReviewInput.recommend());
 
     // CR+2
-    Change reviewCRplus2 = insert(repo, ins2);
+    Change reviewCRplus2 = insert(project.get(), ins2);
     gApi.changes().id(reviewCRplus2.getId().get()).current().review(ReviewInput.approve());
 
     // CR+1 VR+1
-    Change reviewCRplus1VRplus1 = insert(repo, ins3);
+    Change reviewCRplus1VRplus1 = insert(project.get(), ins3);
     gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(ReviewInput.recommend());
     gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(reviewVerified);
 
     // CR+2 VR+1
-    Change reviewCRplus2VRplus1 = insert(repo, ins4);
+    Change reviewCRplus2VRplus1 = insert(project.get(), ins4);
     gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(ReviewInput.approve());
     gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(reviewVerified);
 
     // VR+1
-    Change reviewVRplus1 = insert(repo, ins5);
+    Change reviewVRplus1 = insert(project.get(), ins5);
     gApi.changes().id(reviewVRplus1.getId().get()).current().review(reviewVerified);
 
     assertQuery("label:Code-Review=+1", reviewCRplus1VRplus1, reviewCRplus1);
@@ -1260,28 +1436,28 @@
 
   @Test
   public void byLabelNotOwner() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
     Account.Id user1 = createAccount("user1");
 
-    Change reviewPlus1Change = insert(repo, ins);
+    Change reviewPlus1Change = insert("repo", ins);
 
     // post a review with user1
     requestContext.setContext(newRequestContext(user1));
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
 
-    assertQuery("label:Code-Review=+1,user=user1", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,user=" + user1, reviewPlus1Change);
     assertQuery("label:Code-Review=+1,owner");
   }
 
   @Test
   public void byLabelNonUploader() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
     Account.Id user1 = createAccount("user1");
 
     // create a change with "user"
-    Change reviewPlus1Change = insert(repo, ins);
+    Change reviewPlus1Change = insert("repo", ins);
 
     // add a +1 vote with "user". Query doesn't match since voter is the uploader.
     gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
@@ -1320,8 +1496,8 @@
   @Test
   public void byLabelGroup() throws Exception {
     Account.Id user1 = createAccount("user1");
-    createAccount("user2");
-    TestRepository<Repo> repo = createProject("repo");
+    Account.Id user2 = createAccount("user2");
+    repo = createAndOpenProject("repo");
 
     // create group and add users
     String g1 = createGroup("group1", "Administrators");
@@ -1330,7 +1506,7 @@
     gApi.groups().id(g2).addMembers("user2");
 
     // create a change
-    Change change1 = insert(repo, newChange(repo), user1);
+    Change change1 = insert("repo", newChange(repo), user1);
 
     // post a review with user1
     requestContext.setContext(newRequestContext(user1));
@@ -1343,18 +1519,56 @@
     requestContext.setContext(newRequestContext(userId));
     assertQuery("label:Code-Review=+1,group1", change1);
     assertQuery("label:Code-Review=+1,group=group1", change1);
-    assertQuery("label:Code-Review=+1,user=user1", change1);
-    assertQuery("label:Code-Review=+1,user=user2");
+    assertQuery("label:Code-Review=+1,user=" + user1, change1);
+    assertQuery("label:Code-Review=+1,user=" + user2);
     assertQuery("label:Code-Review=+1,group=group2");
   }
 
   @Test
+  public void byLabelExternalGroup() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    Account.Id user2 = createAccount("user2");
+    repo = createAndOpenProject("repo");
+
+    // create group and add users
+    AccountGroup.UUID external_group1 = AccountGroup.uuid("testbackend:group1");
+    AccountGroup.UUID external_group2 = AccountGroup.uuid("testbackend:group2");
+    testGroupBackend.create(external_group1);
+    testGroupBackend.create(external_group2);
+    testGroupBackend.setMembershipsOf(
+        user1, new ListGroupMembership(ImmutableList.of(external_group1)));
+    testGroupBackend.setMembershipsOf(
+        user2, new ListGroupMembership(ImmutableList.of(external_group2)));
+
+    Change change1 = insert("repo", newChange(repo), user1);
+    Change change2 = insert("repo", newChange(repo), user1);
+
+    // post a review with user1 and other_user
+    requestContext.setContext(newRequestContext(user1));
+    gApi.changes()
+        .id(change1.getId().get())
+        .current()
+        .review(new ReviewInput().label("Code-Review", 1));
+    requestContext.setContext(newRequestContext(userId));
+    gApi.changes()
+        .id(change2.getId().get())
+        .current()
+        .review(new ReviewInput().label("Code-Review", 1));
+
+    assertQuery("label:Code-Review=+1," + external_group1.get(), change1);
+    assertQuery("label:Code-Review=+1,group=" + external_group1.get(), change1);
+    assertQuery("label:Code-Review=+1,user=" + user1, change1);
+    assertQuery("label:Code-Review=+1,user=" + user2);
+    assertQuery("label:Code-Review=+1,group=" + external_group2.get());
+  }
+
+  @Test
   public void limit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Change last = null;
     int n = 5;
     for (int i = 0; i < n; i++) {
-      last = insert(repo, newChange(repo));
+      last = insert("repo", newChange(repo));
     }
 
     for (int i = 1; i <= n + 2; i++) {
@@ -1379,10 +1593,10 @@
 
   @Test
   public void start() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 2; i++) {
-      changes.add(insert(repo, newChange(repo)));
+      changes.add(insert("repo", newChange(repo)));
     }
 
     assertQuery("status:new", changes.get(1), changes.get(0));
@@ -1392,11 +1606,17 @@
   }
 
   @Test
+  public void startCannotBeLessThanZero() throws Exception {
+    assertFailingQuery(
+        newQuery("owner:self").withStart(-1), "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void startWithLimit() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 3; i++) {
-      changes.add(insert(repo, newChange(repo)));
+      changes.add(insert("repo", newChange(repo)));
     }
 
     assertQuery("status:new limit:2", changes.get(2), changes.get(1));
@@ -1407,8 +1627,8 @@
 
   @Test
   public void maxPages() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     QueryRequest query = newQuery("status:new").withLimit(10);
     assertQuery(query, change);
@@ -1423,12 +1643,12 @@
   @Test
   public void updateOrder() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     List<ChangeInserter> inserters = new ArrayList<>();
     List<Change> changes = new ArrayList<>();
     for (int i = 0; i < 5; i++) {
       inserters.add(newChange(repo));
-      changes.add(insert(repo, inserters.get(i)));
+      changes.add(insert("repo", inserters.get(i)));
     }
 
     for (int i : ImmutableList.of(2, 0, 1, 4, 3)) {
@@ -1450,10 +1670,10 @@
   @Test
   public void updatedOrder() throws Exception {
     resetTimeWithClockStep(1, SECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins1 = newChange(repo);
-    Change change1 = insert(repo, ins1);
-    Change change2 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", ins1);
+    Change change2 = insert("repo", newChange(repo));
 
     assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
     assertQuery("status:new", change2, change1);
@@ -1471,12 +1691,12 @@
 
   @Test
   public void filterOutMoreThanOnePageOfResults() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo), userId);
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
-      insert(repo, newChange(repo), user2);
+      insert("repo", newChange(repo), user2);
     }
 
     assertQuery("status:new ownerin:Administrators", change);
@@ -1485,11 +1705,11 @@
 
   @Test
   public void filterOutAllResults() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
-      insert(repo, newChange(repo), user2);
+      insert("repo", newChange(repo), user2);
     }
 
     assertQuery("status:new ownerin:Administrators");
@@ -1498,8 +1718,8 @@
 
   @Test
   public void byFileExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("file:file");
     assertQuery("file:dir", change);
@@ -1511,8 +1731,8 @@
 
   @Test
   public void byFileRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("file:.*file.*");
     assertQuery("file:^file.*"); // Whole path only.
@@ -1521,8 +1741,8 @@
 
   @Test
   public void byPathExact() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("path:file");
     assertQuery("path:dir");
@@ -1534,8 +1754,8 @@
 
   @Test
   public void byPathRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
 
     assertQuery("path:.*file.*");
     assertQuery("path:^dir.file.*", change);
@@ -1543,12 +1763,12 @@
 
   @Test
   public void byExtension() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc"));
-    Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC"));
-    Change change3 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
-    Change change4 = insert(repo, newChangeWithFiles(repo, "Quux.java", "foo"));
-    Change change5 = insert(repo, newChangeWithFiles(repo, "foo"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "foo.h", "foo.cc"));
+    Change change2 = insert("repo", newChangeWithFiles(repo, "bar.H", "bar.CC"));
+    Change change3 = insert("repo", newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change4 = insert("repo", newChangeWithFiles(repo, "Quux.java", "foo"));
+    Change change5 = insert("repo", newChangeWithFiles(repo, "foo"));
 
     assertQuery("extension:java", change4);
     assertQuery("ext:java", change4);
@@ -1557,23 +1777,21 @@
     assertQuery("ext:.jAvA", change4);
     assertQuery("ext:cc", change3, change2, change1);
 
-    if (getSchemaVersion() >= 56) {
-      // matching changes with files that have no extension is possible
-      assertQuery("ext:\"\"", change5, change4);
-      assertFailingQuery("ext:");
-    }
+    // matching changes with files that have no extension is possible
+    assertQuery("ext:\"\"", change5, change4);
+    assertFailingQuery("ext:");
   }
 
   @Test
   public void byOnlyExtensions() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
-    Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
-    Change change3 = insert(repo, newChangeWithFiles(repo, "foo.CC", "bar.cc"));
-    Change change4 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
-    Change change5 = insert(repo, newChangeWithFiles(repo, "Quux.java"));
-    Change change6 = insert(repo, newChangeWithFiles(repo, "foo.txt", "foo"));
-    Change change7 = insert(repo, newChangeWithFiles(repo, "foo"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
+    Change change2 = insert("repo", newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
+    Change change3 = insert("repo", newChangeWithFiles(repo, "foo.CC", "bar.cc"));
+    Change change4 = insert("repo", newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+    Change change5 = insert("repo", newChangeWithFiles(repo, "Quux.java"));
+    Change change6 = insert("repo", newChangeWithFiles(repo, "foo.txt", "foo"));
+    Change change7 = insert("repo", newChangeWithFiles(repo, "foo"));
 
     // case doesn't matter
     assertQuery("onlyextensions:cc,h", change4, change2, change1);
@@ -1613,23 +1831,23 @@
 
   @Test
   public void byFooter() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nfoo: baz").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar\nfoo:baz").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
     RevCommit commit4 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar=baz").create());
-    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
 
     // create a changes with lines that look like footers, but which are not
     RevCommit commit5 =
         repo.parseBody(
             repo.commit().message("Test\n\nfoo: bar\n\nfoo=bar").insertChangeId().create());
-    Change change5 = insert(repo, newChangeForCommit(repo, commit5));
+    Change change5 = insert("repo", newChangeForCommit(repo, commit5));
     RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
-    insert(repo, newChangeForCommit(repo, commit6));
+    insert("repo", newChangeForCommit(repo, commit6));
 
     // matching by 'key=value' works
     assertQuery("footer:foo=bar", change3, change1);
@@ -1661,15 +1879,36 @@
   }
 
   @Test
+  public void byFooterName() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.FOOTER_NAME)).isTrue();
+    repo = createAndOpenProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nBaR: baz").create());
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+
+    // create a changes with lines that look like footers, but which are not
+    RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
+    insert("repo", newChangeForCommit(repo, commit6));
+
+    // matching by 'key=value' works
+    assertQuery("hasfooter:foo", change1);
+
+    // case matters
+    assertQuery("hasfooter:BaR", change2);
+    assertQuery("hasfooter:Bar");
+  }
+
+  @Test
   public void byDirectory() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
-    Change change2 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
+    Change change2 = insert("repo", newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
     Change change3 =
-        insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
-    Change change4 = insert(repo, newChangeWithFiles(repo, "a.txt"));
-    Change change5 = insert(repo, newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
-    Change change6 = insert(repo, newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
+        insert("repo", newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+    Change change4 = insert("repo", newChangeWithFiles(repo, "a.txt"));
+    Change change5 = insert("repo", newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
+    Change change6 = insert("repo", newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
 
     // matching by directory prefix works
     assertQuery("directory:src", change2, change1);
@@ -1730,10 +1969,10 @@
 
   @Test
   public void byDirectoryRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
     Change change2 =
-        insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+        insert("repo", newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
 
     // match by regexp
     assertQuery("directory:^.*va.*", change1);
@@ -1743,9 +1982,9 @@
 
   @Test
   public void byComment() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ChangeInserter ins = newChange(repo);
-    Change change = insert(repo, ins);
+    Change change = insert("repo", ins);
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
@@ -1773,10 +2012,11 @@
   public void byAge() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
-    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
+    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change2 =
+        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
 
     // Stop time so age queries use the same endpoint.
     TestTimeUtil.setClockStep(0, MILLISECONDS);
@@ -1813,10 +2053,11 @@
   public void byBeforeUntil() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
-    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
+    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change2 =
+        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -1864,10 +2105,11 @@
   public void byAfterSince() throws Exception {
     long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
     resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     long startMs = TestTimeUtil.START.toEpochMilli();
-    Change change1 = insert(repo, newChange(repo), null, new Timestamp(startMs));
-    Change change2 = insert(repo, newChange(repo), null, new Timestamp(startMs + thirtyHoursInMs));
+    Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
+    Change change2 =
+        insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
     TestTimeUtil.setClockStep(0, MILLISECONDS);
 
     // Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -1901,45 +2143,31 @@
   }
 
   @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();
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).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");
+    repo = createAndOpenProject("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));
+    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.
+    // Put another approval on the change, just to update it. This does not record an update in
+    // NoteDb since this is a no/op.
     approve(change1);
     approve(change3);
 
     assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
-    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
 
@@ -1958,7 +2186,7 @@
     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);
+    assertQuery("mergedbefore:2009-10-04", change2, change3);
 
     // Same test as above, but using filter code path.
     assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-01"));
@@ -1971,22 +2199,22 @@
         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);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedbefore:2009-10-04"), change2, change3);
   }
 
   @Test
   public void byMergedAfter() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).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");
+    repo = createAndOpenProject("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));
+    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));
@@ -1996,13 +2224,14 @@
     submit(change2);
 
     TestTimeUtil.setClock(new Timestamp(startMs + 3 * thirtyHoursInMs));
-    // Put another approval on the change, just to update it.
+    // Put another approval on the change, just to update it. This does not record an update
+    // in NoteDb since this is a no/op.
     approve(change1);
     approve(change3);
 
     assertThat(TimeUtil.nowMs()).isEqualTo(startMs + 3 * thirtyHoursInMs);
 
-    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + 3 * thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change3)).isEqualTo(startMs + thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + 2 * thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 3 * thirtyHoursInMs);
 
@@ -2010,66 +2239,67 @@
     // 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);
+    assertQuery("mergedafter:2009-10-01", change2, change3);
     // 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);
+    // Change 2 (which was updated last) is returned before change 3.
+    assertQuery("mergedafter:\"2009-10-01 22:59:00 -0400\"", change2, change3);
+    assertQuery("mergedafter:\"2009-10-02 02:59:00 -0000\"", change2, change3);
     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-02", change2, change3);
     assertQuery("mergedafter:2009-10-03", change2);
 
     // Same test as above, but using filter code path.
 
-    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-01"), change3, change2);
+    assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-01"), change2, change3);
     // 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);
+        change2,
+        change3);
     assertQuery(
         makeIndexedPredicateFilterQuery("mergedafter:\"2009-10-02 02:59:00 -0000\""),
-        change3,
-        change2);
+        change2,
+        change3);
     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-02"), change2, change3);
     assertQuery(makeIndexedPredicateFilterQuery("mergedafter:2009-10-03"), change2);
   }
 
   @Test
   public void updatedThenMergedOrder() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+    assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).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");
+    repo = createAndOpenProject("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));
+    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 post submit just to update lastUpdatedOn. This does not record an update in NoteDb
+    // since this is a No/op.
     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(change3)).isEqualTo(startMs + thirtyHoursInMs);
+    assertThat(lastUpdatedMsApi(change2)).isEqualTo(startMs + thirtyHoursInMs);
     assertThat(lastUpdatedMsApi(change1)).isEqualTo(startMs + 2 * thirtyHoursInMs);
 
     // Changes are sorted by lastUpdatedOn first, then by mergedOn, then by Id in reverse order.
@@ -2082,15 +2312,15 @@
 
   @Test
   public void bySize() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
     // added = 3, deleted = 0, delta = 3
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "foo\n\foo\nfoo").create());
     // added = 0, deleted = 2, delta = 2
     RevCommit commit2 = repo.parseBody(repo.commit().parent(commit1).add("file1", "foo").create());
 
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("added:>4");
     assertQuery("-added:<=4");
@@ -2138,9 +2368,9 @@
   }
 
   private List<Change> setUpHashtagChanges() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     addHashtags(change1.getId(), "foo", "aaa-bbb-ccc");
     addHashtags(change2.getId(), "foo", "bar", "a tag", "ACamelCaseTag");
@@ -2177,11 +2407,20 @@
   }
 
   @Test
+  public void byHashtagPrefix() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.PREFIX_HASHTAG)).isTrue();
+    List<Change> changes = setUpHashtagChanges();
+    assertQuery("prefixhashtag:a", changes.get(1), changes.get(0));
+    assertQuery("prefixhashtag:aa", changes.get(0));
+    assertQuery("prefixhashtag:bar", changes.get(1));
+  }
+
+  @Test
   public void byHashtagRegex() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
     addHashtags(change1.getId(), "feature1");
     addHashtags(change1.getId(), "trending");
     addHashtags(change2.getId(), "Cherrypick-feature1");
@@ -2194,27 +2433,27 @@
 
   @Test
   public void byDefault() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
-    Change change1 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
 
     RevCommit commit2 = repo.parseBody(repo.commit().message("foosubject").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     RevCommit commit3 = repo.parseBody(repo.commit().add("Foo.java", "foo contents").create());
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     ChangeInserter ins4 = newChange(repo);
-    Change change4 = insert(repo, ins4);
+    Change change4 = insert("repo", ins4);
     ReviewInput ri4 = new ReviewInput();
     ri4.message = "toplevel";
     ri4.labels = ImmutableMap.of("Code-Review", (short) 1);
     gApi.changes().id(change4.getId().get()).current().review(ri4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
-    Change change5 = insert(repo, ins5);
+    Change change5 = insert("repo", ins5);
 
-    Change change6 = insert(repo, newChangeForBranch(repo, "branch6"));
+    Change change6 = insert("repo", newChangeForBranch(repo, "branch6"));
 
     assertQuery(change1.getId().get(), change1);
     assertQuery(ChangeTriplet.format(change1), change1);
@@ -2235,18 +2474,18 @@
 
   @Test
   public void byDefaultWithCommitPrefix() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit = repo.parseBody(repo.commit().message("message").create());
-    Change change = insert(repo, newChangeForCommit(repo, commit));
+    Change change = insert("repo", newChangeForCommit(repo, commit));
 
     assertQuery(commit.getId().getName().substring(0, 6), change);
   }
 
   @Test
   public void visible() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChangePrivate(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChangePrivate(repo));
 
     String q = "project:repo";
 
@@ -2300,8 +2539,8 @@
 
     // Switch to user3
     requestContext.setContext(newRequestContext(user3));
-    Change change3 = insert(repo, newChange(repo), user3);
-    Change change4 = insert(repo, newChangePrivate(repo), user3);
+    Change change3 = insert("repo", newChange(repo), user3);
+    Change change4 = insert("repo", newChangePrivate(repo), user3);
 
     // User3 can see both their changes and the first user's change
     assertQuery(q + " visibleto:" + user3.get(), change4, change3, change1);
@@ -2335,15 +2574,15 @@
           });
 
       config.commit(md);
-      projectCache.evict(config.getProject());
+      projectCache.evictAndReindex(config.getProject());
     }
   }
 
   @Test
   public void visibleToSelf() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     gApi.changes().id(change2.getChangeId()).setPrivate(true, "private");
 
@@ -2359,16 +2598,12 @@
 
   @Test
   public void byCommentBy() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
 
-    int user2 =
-        accountManager
-            .authenticate(authRequestFactory.createForUser("anotheruser"))
-            .getAccountId()
-            .get();
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
+    Account.Id user2 = createAccount("anotheruser");
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
@@ -2387,65 +2622,39 @@
 
   @Test
   public void bySubmitRuleResult() throws Exception {
-    if (getSchemaVersion() < 68) {
-      assertMissingField(ChangeField.SUBMIT_RULE_RESULT);
-      return;
-    }
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
-      TestRepository<Repo> repo = createProject("repo");
-      Change change = insert(repo, newChange(repo));
-      assertQuery("rule:gerrit~FakeSubmitRule");
+      repo = createAndOpenProject("repo");
+      Change change = insert("repo", newChange(repo));
+      // The fake submit rule exports its ruleName as "FakeSubmitRule"
+      assertQuery("rule:FakeSubmitRule");
 
       // FakeSubmitRule returns true if change has one or more hashtags.
       HashtagsInput hashtag = new HashtagsInput();
       hashtag.add = ImmutableSet.of("Tag1");
       gApi.changes().id(change.getId().get()).setHashtags(hashtag);
-      assertQuery("rule:gerrit~FakeSubmitRule", change);
-      assertQuery("rule:gerrit~FakeSubmitRule=OK", change);
-      assertQuery("rule:gerrit~FakeSubmitRule=NOT_READY");
 
-      // The 'gerrit~' prefix can be omitted for core submit rules
       assertQuery("rule:FakeSubmitRule", change);
+      assertQuery("rule:FakeSubmitRule=OK", change);
+      assertQuery("rule:FakeSubmitRule=NOT_READY");
     }
   }
 
   @Test
   public void byNonExistingSubmitRule_returnsEmpty() throws Exception {
-    // Some submit rules could be removed from the gerrit.config but there can be records for
-    // merged changes in NoteDb for these rules. We allow querying for non-existent rules to handle
-    // this case.
-    if (getSchemaVersion() < 68) {
-      assertMissingField(ChangeField.SUBMIT_RULE_RESULT);
-      return;
-    }
     try (Registration registration =
         extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
-      TestRepository<Repo> repo = createProject("repo");
-      insert(repo, newChange(repo));
+      repo = createAndOpenProject("repo");
+      insert("repo", newChange(repo));
       assertQuery("rule:non-existent-rule");
     }
   }
 
   @Test
-  public void byHasDraft_draftsComputedFromIndex() throws Exception {
-    byHasDraft();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  public void byHasDraft_draftsComputedFromAllUsersRepository() throws Exception {
-    byHasDraft();
-  }
-
-  private void byHasDraft() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+  public void byHasDraft() throws Exception {
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     assertQuery("has:draft");
 
@@ -2477,8 +2686,8 @@
    */
   public void byHasDraftExcludesZombieDrafts() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject(project.get());
+    Change change = insert("repo", newChange(repo));
     Change.Id id = change.getId();
 
     DraftInput in = new DraftInput();
@@ -2490,7 +2699,7 @@
     assertQuery("has:draft", change);
     assertQuery("commentby:" + userId);
 
-    try (TestRepository<Repo> allUsers =
+    try (TestRepository<Repository> allUsers =
         new TestRepository<>(repoManager.openRepository(allUsersName))) {
       Ref draftsRef = allUsers.getRepository().exactRef(RefNames.refsDraftComments(id, userId));
       assertThat(draftsRef).isNotNull();
@@ -2512,29 +2721,17 @@
     assertQuery("has:draft");
   }
 
-  public void byHasDraftWithManyDrafts_draftsComputedFromIndex() throws Exception {
-    byHasDraftWithManyDrafts();
-  }
-
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  public void byHasDraftWithManyDrafts_draftsComputedFromAllUsersRepository() throws Exception {
-    byHasDraftWithManyDrafts();
-  }
-
-  private void byHasDraftWithManyDrafts() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+  @Test
+  public void byHasDraftWithManyDrafts() throws Exception {
+    repo = createAndOpenProject("repo");
     Change[] changesWithDrafts = new Change[30];
 
     // unrelated change not shown in the result.
-    insert(repo, newChange(repo));
+    insert("repo", newChange(repo));
 
     for (int i = 0; i < changesWithDrafts.length; i++) {
       // put the changes in reverse order since this is the order we receive them from the index.
-      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert("repo", newChange(repo));
       DraftInput in = new DraftInput();
       in.line = 1;
       in.message = "nit: trailing whitespace";
@@ -2553,25 +2750,11 @@
   }
 
   @Test
-  public void byStarredBy_starsComputedFromIndex() throws Exception {
-    byStarredBy();
-  }
-
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  @Test
-  public void byStarredBy_starsComputedFromAllUsersRepository() throws Exception {
-    byStarredBy();
-  }
-
-  private void byStarredBy() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+  public void byStarredBy() throws Exception {
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     gApi.accounts().self().starChange(change1.getId().toString());
     gApi.accounts().self().starChange(change2.getId().toString());
@@ -2580,132 +2763,53 @@
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
     assertQuery("has:star", change2, change1);
-    assertQuery("star:star", change2, change1);
 
     requestContext.setContext(newRequestContext(user2));
     assertQuery("has:star");
-    assertQuery("star:star");
   }
 
   @Test
-  public void byStar_starsComputedFromIndex() throws Exception {
-    byStar();
-  }
-
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  @Test
-  public void byStar_starsComputedFromAllUsersRepository() throws Exception {
-    byStar();
-  }
-
-  private void byStar() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change3 = insert(repo, newChange(repo));
+  public void byStar() throws Exception {
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     requestContext.setContext(newRequestContext(user2));
 
     gApi.accounts().self().starChange(change1.getId().toString());
-    gApi.changes().id(change3.getChangeId()).ignore(true);
 
     // check default star
     assertQuery("has:star", change1);
     assertQuery("is:starred", change1);
-    assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change1);
-
-    // check ignored
-    assertQuery("is:ignored", change3);
-    assertQuery("-is:ignored", change2, change1);
-    assertQuery("star:ignore", change3);
-    assertQuery("-star:ignore", change2, change1);
   }
 
   @Test
-  public void byIgnore_starsComputedFromIndex() throws Exception {
-    byIgnore();
-  }
-
-  @Test
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  public void byIgnore_starsComputedFromAllUsersRepository() throws Exception {
-    byIgnore();
-  }
-
-  private void byIgnore() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Account.Id user2 =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change1 = insert(repo, newChange(repo), user2);
-    Change change2 = insert(repo, newChange(repo), user2);
-
-    gApi.changes().id(change1.getId().toString()).ignore(true);
-    assertQuery("is:ignored", change1);
-    assertQuery("-is:ignored", change2);
-    assertQuery("star:ignore", change1);
-    assertQuery("-star:ignore", change2);
-
-    gApi.changes().id(change1.getId().toString()).ignore(false);
-    assertQuery("is:ignored");
-    assertQuery("-is:ignored", change2, change1);
-    assertQuery("star:ignore");
-    assertQuery("-star:ignore", change2, change1);
-  }
-
-  public void byStarWithManyStars_starsComputedFromIndex() throws Exception {
-    byStarWithManyStars();
-  }
-
-  @GerritConfig(
-      name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
-  public void byStarWithManyStars_starsComputedFromAllUsersRepository() throws Exception {
-    byStarWithManyStars();
-  }
-
-  private void byStarWithManyStars() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+  public void byStarWithManyStars() throws Exception {
+    repo = createAndOpenProject("repo");
     Change[] changesWithDrafts = new Change[30];
     for (int i = 0; i < changesWithDrafts.length; i++) {
       // put the changes in reverse order since this is the order we receive them from the index.
-      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert("repo", newChange(repo));
 
       // star the change
       gApi.accounts()
           .self()
           .starChange(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().toString());
-
-      // ignore the change
-      gApi.changes()
-          .id(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().toString())
-          .ignore(true);
     }
 
     // all changes are both starred and ignored.
-    assertQuery("is:ignored", changesWithDrafts);
     assertQuery("is:starred", changesWithDrafts);
   }
 
   @Test
   public void byFrom() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change2 = insert(repo, newChange(repo), user2);
+    Change change2 = insert("repo", newChange(repo), user2);
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
@@ -2721,7 +2825,7 @@
 
   @Test
   public void conflicts() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 =
         repo.parseBody(
             repo.commit()
@@ -2733,10 +2837,10 @@
     RevCommit commit3 =
         repo.parseBody(repo.commit().add("dir/file2", "contents2 different").create());
     RevCommit commit4 = repo.parseBody(repo.commit().add("file4", "contents4").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
-    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
-    Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
 
     assertQuery("conflicts:" + change1.getId().get(), change3);
     assertQuery("conflicts:" + change2.getId().get());
@@ -2749,11 +2853,12 @@
       name = "change.mergeabilityComputationBehavior",
       value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
   public void mergeable() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    assume().that(getSchema().hasField(ChangeField.MERGEABLE_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
     RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("conflicts:" + change1.getId().get(), change2);
     assertQuery("conflicts:" + change2.getId().get(), change1);
@@ -2776,10 +2881,10 @@
 
   @Test
   public void cherrypick() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.CHERRY_PICK)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
+    assume().that(getSchema().hasField(ChangeField.CHERRY_PICK_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
 
     assertQuery("is:cherrypick", change2);
     assertQuery("-is:cherrypick", change1);
@@ -2787,15 +2892,15 @@
 
   @Test
   public void merge() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.MERGE)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    assume().that(getSchema().hasField(ChangeField.MERGE_SPEC)).isTrue();
+    repo = createAndOpenProject("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));
+    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()
@@ -2804,7 +2909,7 @@
             .parent(commit3)
             .insertChangeId()
             .create();
-    Change mergeChange = insert(repo, newChangeForCommit(repo, mergeCommit));
+    Change mergeChange = insert("repo", newChangeForCommit(repo, mergeCommit));
 
     assertQuery("status:open is:merge", mergeChange);
     assertQuery("status:open -is:merge", change3, change2, change1);
@@ -2814,10 +2919,10 @@
   @Test
   public void reviewedBy() throws Exception {
     resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     gApi.changes().id(change1.getId().get()).current().review(new ReviewInput().message("comment"));
 
@@ -2828,7 +2933,7 @@
     gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
 
     PatchSet.Id ps3_1 = change3.currentPatchSetId();
-    change3 = newPatchSet(repo, change3, user);
+    change3 = newPatchSet("repo", change3, user, /* message= */ Optional.empty());
     assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
     // Response to previous patch set still counts as reviewing.
     gApi.changes()
@@ -2855,11 +2960,11 @@
   @Test
   public void reviewerAndCc() throws Exception {
     Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
@@ -2886,11 +2991,11 @@
 
   @Test
   public void byReviewed() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherUser =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     assertQuery("is:reviewed");
     assertQuery("status:reviewed");
@@ -2914,11 +3019,11 @@
         accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId();
     Account.Id user3 =
         accountManager.authenticate(authRequestFactory.createForUser("user3")).getAccountId();
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
 
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
@@ -2958,7 +3063,7 @@
   @Test
   public void reviewerAndCcByEmail() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
+    repo = createAndOpenProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
@@ -2966,9 +3071,9 @@
     String userByEmail = "un.registered@reviewer.com";
     String userByEmailWithName = "John Doe <" + userByEmail + ">";
 
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmailWithName;
@@ -2991,16 +3096,16 @@
   @Test
   public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
+    repo = createAndOpenProject(project.get());
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
 
     String userByEmail = "John Doe <un.registered@reviewer.com>";
 
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    insert(repo, newChange(repo));
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    insert("repo", newChange(repo));
 
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmail;
@@ -3019,9 +3124,9 @@
   @Test
   public void submitRecords() throws Exception {
     Account.Id user1 = createAccount("user1");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     gApi.changes().id(change1.getId().get()).current().review(ReviewInput.approve());
     requestContext.setContext(newRequestContext(user1));
@@ -3030,11 +3135,9 @@
 
     assertQuery("is:submittable", change1);
     assertQuery("-is:submittable", change2);
-    assertQuery("submittable:ok", change1);
-    assertQuery("submittable:not_ready", change2);
 
     assertQuery("label:CodE-RevieW=ok", change1);
-    assertQuery("label:CodE-RevieW=ok,user=user", change1);
+    assertQuery("label:CodE-RevieW=ok,user=" + userAccount.preferredEmail(), change1);
     assertQuery("label:CodE-RevieW=ok,Administrators", change1);
     assertQuery("label:CodE-RevieW=ok,group=Administrators", change1);
     assertQuery("label:CodE-RevieW=ok,owner", change1);
@@ -3045,18 +3148,18 @@
     assertQuery("label:CodE-RevieW=need,user");
 
     gApi.changes().id(change1.getId().get()).current().submit();
-    assertQuery("submittable:ok");
-    assertQuery("submittable:closed", change1);
+    assertQuery("is:submittable");
+    assertQuery("-is:submittable", change1, change2);
   }
 
   @Test
   public void hasEdit() throws Exception {
     Account.Id user1 = createAccount("user1");
     Account.Id user2 = createAccount("user2");
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
     String changeId1 = change1.getKey().get();
-    Change change2 = insert(repo, newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
     String changeId2 = change2.getKey().get();
 
     requestContext.setContext(newRequestContext(user1));
@@ -3077,10 +3180,10 @@
 
   @Test
   public void byUnresolved() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
-    Change change3 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
+    Change change3 = insert("repo", newChange(repo));
 
     // Change1 has one resolved comment (unresolvedcount = 0)
     // Change2 has one unresolved comment (unresolvedcount = 1)
@@ -3108,13 +3211,13 @@
 
   @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
-    TestRepository<Repo> tr = createProject("repo");
-    testByCommitsOnBranchNotMerged(tr, ImmutableSet.of());
+    createProject("repo");
+    testByCommitsOnBranchNotMerged("repo", ImmutableSet.of());
   }
 
   @Test
   public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     ObjectId missing =
         repo.branch(PatchSet.id(Change.id(987654), 1).toRefName())
             .commit()
@@ -3122,72 +3225,75 @@
             .insertChangeId()
             .create()
             .copy();
-    testByCommitsOnBranchNotMerged(repo, ImmutableSet.of(missing));
+    testByCommitsOnBranchNotMerged("repo", ImmutableSet.of(missing));
   }
 
-  private void testByCommitsOnBranchNotMerged(TestRepository<Repo> repo, Collection<ObjectId> extra)
+  private void testByCommitsOnBranchNotMerged(String repo, Collection<ObjectId> extra)
       throws Exception {
     int n = 10;
     List<String> shas = new ArrayList<>(n + extra.size());
     extra.forEach(i -> shas.add(i.name()));
     List<Integer> expectedIds = new ArrayList<>(n);
     BranchNameKey dest = null;
-    for (int i = 0; i < n; i++) {
-      ChangeInserter ins = newChange(repo);
-      insert(repo, ins);
-      if (dest == null) {
-        dest = ins.getChange().getDest();
+    try (TestRepository<Repository> repository =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repo)))) {
+      for (int i = 0; i < n; i++) {
+        ChangeInserter ins = newChange(repository);
+        insert("repo", ins);
+        if (dest == null) {
+          dest = ins.getChange().getDest();
+        }
+        shas.add(ins.getCommitId().name());
+        expectedIds.add(ins.getChange().getId().get());
       }
-      shas.add(ins.getCommitId().name());
-      expectedIds.add(ins.getChange().getId().get());
     }
-
-    for (int i = 1; i <= 11; i++) {
-      Iterable<ChangeData> cds =
-          queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), dest, shas, i);
-      Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
-      String name = "limit " + i;
-      assertWithMessage(name).that(ids).hasSize(n);
-      assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
+    try (Repository repository = repoManager.openRepository(Project.nameKey(repo))) {
+      for (int i = 1; i <= 11; i++) {
+        Iterable<ChangeData> cds =
+            queryProvider.get().byCommitsOnBranchNotMerged(repository, dest, shas, i);
+        Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
+        String name = "limit " + i;
+        assertWithMessage(name).that(ids).hasSize(n);
+        assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
+      }
     }
   }
 
   @Test
   public void reindexIfStale() throws Exception {
-    Account.Id user = createAccount("user");
     Project.NameKey project = Project.nameKey("repo");
-    TestRepository<Repo> repo = createProject(project.get());
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject(project.get());
+    Change change = insert("repo", newChange(repo));
     String changeId = change.getKey().get();
-    ChangeNotes notes = notesFactory.create(change.getProject(), change.getId());
-    PatchSet ps = psUtil.get(notes, change.currentPatchSetId());
 
-    requestContext.setContext(newRequestContext(user));
-    gApi.changes().id(changeId).edit().create();
-    assertQuery("has:edit", change);
+    Account.Id anotherUser = createAccount("another-user");
+    requestContext.setContext(newRequestContext(anotherUser));
+    gApi.changes().id(changeId).addReviewer(anotherUser.toString());
+
+    assertQuery("reviewer:self", change);
     assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
 
-    // Delete edit ref behind index's back.
-    RefUpdate ru = repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.id()));
-    ru.setForceUpdate(true);
-    assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    // Remove reviewer behind index's back.
+    ChangeUpdate update = newUpdate(change);
+    update.removeReviewer(anotherUser);
+    update.commit();
 
     // Index is stale.
-    assertQuery("has:edit", change);
+    assertQuery("reviewer:self", change);
     assertThat(indexer.reindexIfStale(project, change.getId()).get()).isTrue();
-    assertQuery("has:edit");
+    assertQuery("reviewer:self");
   }
 
   @Test
   public void watched() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
-    Change change1 = insert(repo, ins1);
+    createProject("repo");
+    ChangeInserter ins1 = newChangeWithStatus("repo", Change.Status.NEW);
+    Change change1 = insert("repo", ins1);
 
-    TestRepository<Repo> repo2 = createProject("repo2");
+    createProject("repo2");
 
-    ChangeInserter ins2 = newChangeWithStatus(repo2, Change.Status.NEW);
-    insert(repo2, ins2);
+    ChangeInserter ins2 = newChangeWithStatus("repo2", Change.Status.NEW);
+    insert("repo2", ins2);
 
     assertQuery("is:watched");
 
@@ -3207,18 +3313,24 @@
 
   @Test
   public void trackingid() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 =
         repo.parseBody(repo.commit().message("Change one\n\nBug:QUERY123").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 =
-        repo.parseBody(repo.commit().message("Change two\n\nFeature:QUERY456").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+        repo.parseBody(repo.commit().message("Change two\n\nIssue: Issue 16038\n").create());
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+
+    RevCommit commit3 =
+        repo.parseBody(repo.commit().message("Change two\n\nGoogle-Bug-Id: b/16039\n").create());
+    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
 
     assertQuery("tr:QUERY123", change1);
     assertQuery("bug:QUERY123", change1);
-    assertQuery("tr:QUERY456", change2);
-    assertQuery("bug:QUERY456", change2);
+    assertQuery("tr:16038", change2);
+    assertQuery("bug:16038", change2);
+    assertQuery("tr:16039", change3);
+    assertQuery("bug:16039", change3);
     assertQuery("tr:QUERY-123");
     assertQuery("bug:QUERY-123");
     assertQuery("tr:QUERY12");
@@ -3237,9 +3349,9 @@
 
   @Test
   public void revertOf() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     // Create two commits and revert second commit (initial commit can't be reverted)
-    Change initial = insert(repo, newChange(repo));
+    Change initial = insert("repo", newChange(repo));
     gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(initial.getChangeId()).current().submit();
 
@@ -3254,10 +3366,10 @@
 
   @Test
   public void submissionId() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
     // create irrelevant change
-    insert(repo, newChange(repo));
+    insert("repo", newChange(repo));
     gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(change.getChangeId()).current().submit();
     String submissionId = gApi.changes().id(change.getChangeId()).get().submissionId;
@@ -3270,7 +3382,6 @@
     private final Account.Id ownerId;
     private final List<Account.Id> reviewedBy;
     private final List<Account.Id> cced;
-    private final List<Account.Id> ignoredBy;
     private final List<Account.Id> draftCommentBy;
     private final List<Account.Id> deleteDraftCommentBy;
     private boolean wip;
@@ -3284,7 +3395,6 @@
       this.ownerId = ownerId;
       reviewedBy = new ArrayList<>();
       cced = new ArrayList<>();
-      ignoredBy = new ArrayList<>();
       draftCommentBy = new ArrayList<>();
       deleteDraftCommentBy = new ArrayList<>();
     }
@@ -3309,11 +3419,6 @@
       return this;
     }
 
-    DashboardChangeState ignoreBy(Account.Id ignorerId) {
-      ignoredBy.add(ignorerId);
-      return this;
-    }
-
     DashboardChangeState addReviewer(Account.Id reviewerId) {
       reviewedBy.add(reviewerId);
       return this;
@@ -3334,9 +3439,9 @@
       return this;
     }
 
-    DashboardChangeState create(TestRepository<Repo> repo) throws Exception {
+    DashboardChangeState create(TestRepository<Repository> repo) throws Exception {
       requestContext.setContext(newRequestContext(ownerId));
-      Change change = insert(repo, newChange(repo), ownerId);
+      Change change = insert("repo", newChange(repo), ownerId);
       id = change.getId();
       ChangeApi cApi = gApi.changes().id(change.getChangeId());
       if (assigneeId != null) {
@@ -3359,10 +3464,6 @@
         in.state = ReviewerState.CC;
         cApi.addReviewer(in);
       }
-      for (Account.Id ignorerId : ignoredBy) {
-        requestContext.setContext(newRequestContext(ignorerId));
-        gApi.changes().id(change.getChangeId()).ignore(true);
-      }
       DraftInput in = new DraftInput();
       in.path = Patch.COMMIT_MSG;
       in.message = "message";
@@ -3408,7 +3509,7 @@
 
   @Test
   public void dashboardHasUnpublishedDrafts() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState hasUnpublishedDraft =
         new DashboardChangeState(otherAccountId).draftCommentBy(user.getAccountId()).create(repo);
@@ -3426,7 +3527,7 @@
 
   @Test
   public void dashboardAssignedReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState otherOpenWip =
         new DashboardChangeState(otherAccountId).wip().assignTo(user.getAccountId()).create(repo);
@@ -3440,9 +3541,6 @@
     new DashboardChangeState(user.getAccountId()).assignTo(user.getAccountId()).abandon();
     new DashboardChangeState(user.getAccountId())
         .assignTo(user.getAccountId())
-        .ignoreBy(user.getAccountId());
-    new DashboardChangeState(user.getAccountId())
-        .assignTo(user.getAccountId())
         .mergeBy(user.getAccountId());
 
     assertDashboardQuery(
@@ -3451,12 +3549,12 @@
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(), IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
+        userId.toString(), IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
   }
 
   @Test
   public void dashboardWorkInProgressReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     DashboardChangeState ownedOpenWip =
         new DashboardChangeState(user.getAccountId()).wip().create(repo);
 
@@ -3471,50 +3569,32 @@
 
   @Test
   public void dashboardOutgoingReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState ownedOpenReviewable =
         new DashboardChangeState(user.getAccountId()).create(repo);
-    DashboardChangeState ownedOpenReviewableIgnoredByOther =
-        new DashboardChangeState(user.getAccountId()).ignoreBy(otherAccountId).create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(user.getAccountId()).wip().create(repo);
     new DashboardChangeState(otherAccountId).create(repo);
 
     // Viewing one's own dashboard.
-    assertDashboardQuery(
-        "self",
-        IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
-        ownedOpenReviewableIgnoredByOther,
-        ownedOpenReviewable);
+    assertDashboardQuery("self", IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(),
-        IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
-        ownedOpenReviewable);
+        userId.toString(), IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
   }
 
   @Test
   public void dashboardIncomingReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState reviewingReviewable =
         new DashboardChangeState(otherAccountId).addReviewer(user.getAccountId()).create(repo);
-    DashboardChangeState reviewingReviewableIgnoredByReviewer =
-        new DashboardChangeState(otherAccountId)
-            .addReviewer(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState assignedReviewable =
         new DashboardChangeState(otherAccountId).assignTo(user.getAccountId()).create(repo);
-    DashboardChangeState assignedReviewableIgnoredByAssignee =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(otherAccountId).wip().addReviewer(user.getAccountId()).create(repo);
@@ -3535,36 +3615,23 @@
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(),
+        userId.toString(),
         IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
-        assignedReviewableIgnoredByAssignee,
         assignedReviewable,
-        reviewingReviewableIgnoredByReviewer,
         reviewingReviewable);
   }
 
   @Test
   public void dashboardRecentlyClosedReviews() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     Account.Id otherAccountId = createAccount("other");
     DashboardChangeState mergedOwned =
         new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
-    DashboardChangeState mergedOwnedIgnoredByOther =
-        new DashboardChangeState(user.getAccountId())
-            .ignoreBy(otherAccountId)
-            .mergeBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState mergedReviewing =
         new DashboardChangeState(otherAccountId)
             .addReviewer(user.getAccountId())
             .mergeBy(user.getAccountId())
             .create(repo);
-    DashboardChangeState mergedReviewingIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .addReviewer(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .mergeBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState mergedCced =
         new DashboardChangeState(otherAccountId)
             .addCc(user.getAccountId())
@@ -3575,62 +3642,26 @@
             .assignTo(user.getAccountId())
             .mergeBy(user.getAccountId())
             .create(repo);
-    DashboardChangeState mergedAssignedIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .mergeBy(user.getAccountId())
-            .create(repo);
     DashboardChangeState abandonedOwned =
         new DashboardChangeState(user.getAccountId()).abandon().create(repo);
-    DashboardChangeState abandonedOwnedIgnoredByOther =
-        new DashboardChangeState(user.getAccountId())
-            .ignoreBy(otherAccountId)
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedOwnedWip =
         new DashboardChangeState(user.getAccountId()).wip().abandon().create(repo);
-    DashboardChangeState abandonedOwnedWipIgnoredByOther =
-        new DashboardChangeState(user.getAccountId())
-            .ignoreBy(otherAccountId)
-            .wip()
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedReviewing =
         new DashboardChangeState(otherAccountId)
             .addReviewer(user.getAccountId())
             .abandon()
             .create(repo);
-    DashboardChangeState abandonedReviewingIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .addReviewer(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedAssigned =
         new DashboardChangeState(otherAccountId)
             .assignTo(user.getAccountId())
             .abandon()
             .create(repo);
-    DashboardChangeState abandonedAssignedIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .abandon()
-            .create(repo);
     DashboardChangeState abandonedAssignedWip =
         new DashboardChangeState(otherAccountId)
             .assignTo(user.getAccountId())
             .wip()
             .abandon()
             .create(repo);
-    DashboardChangeState abandonedAssignedWipIgnoredByUser =
-        new DashboardChangeState(otherAccountId)
-            .assignTo(user.getAccountId())
-            .ignoreBy(user.getAccountId())
-            .wip()
-            .abandon()
-            .create(repo);
 
     // Create changes that should not be returned by any queries in this test.
     new DashboardChangeState(otherAccountId)
@@ -3638,12 +3669,6 @@
         .wip()
         .abandon()
         .create(repo);
-    new DashboardChangeState(otherAccountId)
-        .addReviewer(user.getAccountId())
-        .ignoreBy(user.getAccountId())
-        .wip()
-        .abandon()
-        .create(repo);
 
     // Viewing one's own dashboard.
     assertDashboardQuery(
@@ -3651,39 +3676,24 @@
         IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
         abandonedAssigned,
         abandonedReviewing,
-        abandonedOwnedWipIgnoredByOther,
         abandonedOwnedWip,
-        abandonedOwnedIgnoredByOther,
         abandonedOwned,
         mergedAssigned,
         mergedCced,
         mergedReviewing,
-        mergedOwnedIgnoredByOther);
-
-    assertDashboardQueryWithStart(
-        "self", IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY, 10, mergedOwned);
+        mergedOwned);
 
     // Viewing another user's dashboard.
     requestContext.setContext(newRequestContext(otherAccountId));
     assertDashboardQuery(
-        user.getUserName().get(),
+        userId.toString(),
         IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
-        abandonedAssignedWipIgnoredByUser,
         abandonedAssignedWip,
-        abandonedAssignedIgnoredByUser,
         abandonedAssigned,
-        abandonedReviewingIgnoredByUser,
         abandonedReviewing,
         abandonedOwned,
-        mergedAssignedIgnoredByUser,
         mergedAssigned,
-        mergedCced);
-
-    assertDashboardQueryWithStart(
-        user.getUserName().get(),
-        IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
-        10,
-        mergedReviewingIgnoredByUser,
+        mergedCced,
         mergedReviewing,
         mergedOwned);
   }
@@ -3692,9 +3702,9 @@
   public void attentionSetIndexed() throws Exception {
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS_COUNT)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
     gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
@@ -3703,7 +3713,7 @@
     assertQuery("-is:attention", change2);
     assertQuery("has:attention", change1);
     assertQuery("-has:attention", change2);
-    assertQuery("attention:" + user.getUserName().get(), change1);
+    assertQuery("attention:" + userAccount.preferredEmail(), change1);
     assertQuery("-attention:" + userId.toString(), change2);
 
     gApi.changes()
@@ -3716,8 +3726,8 @@
   @Test
   public void attentionSetStored() throws Exception {
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "reason 1");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
@@ -3745,29 +3755,29 @@
 
   @Test
   public void assignee() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChange(repo));
 
     AssigneeInput input = new AssigneeInput();
-    input.assignee = user.getUserName().get();
+    input.assignee = user.getAccountId().toString();
     gApi.changes().id(change1.getChangeId()).setAssignee(input);
 
     assertQuery("is:assigned", change1);
     assertQuery("-is:assigned", change2);
     assertQuery("is:unassigned", change2);
     assertQuery("-is:unassigned", change1);
-    assertQuery("assignee:" + user.getUserName().get(), change1);
-    assertQuery("-assignee:" + user.getUserName().get(), change2);
+    assertQuery("assignee:" + user.getAccountId(), change1);
+    assertQuery("-assignee:" + user.getAccountId(), change2);
   }
 
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
   public void userDestination() throws Exception {
-    TestRepository<Repo> repo1 = createProject("repo1");
-    Change change1 = insert(repo1, newChange(repo1));
-    TestRepository<Repo> repo2 = createProject("repo2");
-    Change change2 = insert(repo2, newChange(repo2));
+    createProject("repo1");
+    Change change1 = insert("repo1", newChange("repo1"));
+    createProject("repo2");
+    Change change2 = insert("repo2", newChange("repo2"));
 
     assertThatQueryException("destination:foo")
         .hasMessageThat()
@@ -3781,7 +3791,7 @@
     String destination4 = "refs/heads/master\trepo3";
     String destination5 = "refs/heads/other\trepo1";
 
-    try (TestRepository<Repo> allUsers =
+    try (TestRepository<Repository> allUsers =
         new TestRepository<>(repoManager.openRepository(allUsersName))) {
       String refsUsers = RefNames.refsUsers(userId);
       allUsers.branch(refsUsers).commit().add("destinations/destination1", destination1).create();
@@ -3832,26 +3842,25 @@
     assertThatQueryException("destination:destination3,user=" + anotherUserId)
         .hasMessageThat()
         .isEqualTo("Unknown named destination: destination3");
-    assertThatQueryException("destination:destination3,user=test")
+    assertThatQueryException("destination:destination3,user=non-existent")
         .hasMessageThat()
-        .isEqualTo("Account 'test' not found");
+        .isEqualTo("Account 'non-existent' not found");
 
     requestContext.setContext(newRequestContext(anotherUserId));
-    // account 1000000 is not visible to 'anotheruser' as they are not an admin
+    // account userId is not visible to 'anotheruser' as they are not an admin
     assertThatQueryException("destination:destination3,user=" + userId)
         .hasMessageThat()
-        .isEqualTo("Account '1000000' not found");
+        .isEqualTo(String.format("Account '%s' not found", userId));
   }
 
   @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"));
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    Change change2 = insert("repo", newChangeForBranch(repo, "stable"));
 
-    Account.Id anotherUserId =
-        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    Account.Id anotherUserId = createAccount("anotheruser");
     String queryListText =
         "query1\tproject:repo\n"
             + "query2\tproject:repo status:open\n"
@@ -3863,7 +3872,7 @@
             + "query7\tproject:repo branch:stable\n"
             + "query8\tproject:repo branch:other";
 
-    try (TestRepository<Repo> allUsers =
+    try (TestRepository<Repository> allUsers =
             new TestRepository<>(repoManager.openRepository(allUsersName));
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName);
         MetaDataUpdate anotherMd = metaDataUpdateFactory.create(allUsersName)) {
@@ -3877,19 +3886,20 @@
       anotherQueries.commit(anotherMd);
     }
 
+    assertThat(gApi.accounts().self().get()._accountId).isEqualTo(userId.get());
     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")
+    assertThatQueryException("query:query1,user=non-existent")
         .hasMessageThat()
-        .isEqualTo("Account 'test' not found");
+        .isEqualTo("Account 'non-existent' 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");
+        .isEqualTo(String.format("Account '%s' not found", userId));
     requestContext.setContext(newRequestContext(userId));
 
     assertQuery("query:query1", change2, change1);
@@ -3908,16 +3918,16 @@
 
   @Test
   public void byOwnerInvalidQuery() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    insert("repo", newChange(repo), userId);
     String nameEmail = user.asIdentifiedUser().getNameEmail();
     assertQuery("owner: \"" + nameEmail + "\"\\");
   }
 
   @Test
   public void byDeletedChange() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     String query = "change:" + change.getId();
     assertQuery(query, change);
@@ -3928,8 +3938,8 @@
 
   @Test
   public void byUrlEncodedProject() throws Exception {
-    TestRepository<Repo> repo = createProject("repo+foo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo+foo");
+    Change change = insert("repo+foo", newChange(repo));
     assertQuery("project:repo+foo", change);
   }
 
@@ -3961,10 +3971,10 @@
 
   @Test
   public void isPureRevert() throws Exception {
-    assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT)).isTrue();
-    TestRepository<Repo> repo = createProject("repo");
+    assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
     // Create two commits and revert second commit (initial commit can't be reverted)
-    Change initial = insert(repo, newChange(repo));
+    Change initial = insert("repo", newChange(repo));
     gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
     gApi.changes().id(initial.getChangeId()).current().submit();
 
@@ -3988,7 +3998,7 @@
 
   @Test
   public void selfFailsForAnonymousUser() throws Exception {
-    for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred", "star:star")) {
+    for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred")) {
       assertQuery(query);
       RequestContext oldContext = requestContext.setContext(anonymousUserProvider::get);
 
@@ -4008,8 +4018,8 @@
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
     gApi.changes().id(change.getId().get()).addReviewer(user2.toString());
 
     RequestContext adminContext = requestContext.setContext(newRequestContext(user2));
@@ -4024,12 +4034,11 @@
 
   @Test
   public void none() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change = insert(repo, newChange(repo));
+    repo = createAndOpenProject("repo");
+    Change change = insert("repo", newChange(repo));
 
     assertQuery(ChangeIndexPredicate.none());
 
-    ChangeQueryBuilder queryBuilder = queryBuilderProvider.get();
     for (Predicate<ChangeData> matchingOneChange :
         ImmutableList.of(
             // One index query, one post-filtering query.
@@ -4046,27 +4055,24 @@
   @Test
   @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
   public void mergeableFailsWhenNotIndexed() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    assume().that(getSchema().hasField(ChangeField.MERGE_SPEC)).isTrue();
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
-    insert(repo, newChangeForCommit(repo, commit1));
+    insert("repo", newChangeForCommit(repo, commit1));
 
     Throwable thrown = assertThrows(Throwable.class, () -> assertQuery("status:open is:mergeable"));
     assertThat(thrown.getCause()).isInstanceOf(QueryParseException.class);
     assertThat(thrown)
         .hasMessageThat()
-        .contains("'is:mergeable' operator is not supported by server");
+        .contains("'is:mergeable' operator is not supported on this gerrit host");
   }
 
-  protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
-    return newChange(repo, null, null, null, null, null, false, false);
-  }
-
-  protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo, RevCommit commit)
+  protected ChangeInserter newChangeForCommit(TestRepository<Repository> repo, RevCommit commit)
       throws Exception {
     return newChange(repo, commit, null, null, null, null, false, false);
   }
 
-  protected ChangeInserter newChangeWithFiles(TestRepository<Repo> repo, String... paths)
+  protected ChangeInserter newChangeWithFiles(TestRepository<Repository> repo, String... paths)
       throws Exception {
     TestRepository<?>.CommitBuilder b = repo.commit().message("Change with files");
     for (String path : paths) {
@@ -4075,36 +4081,67 @@
     return newChangeForCommit(repo, repo.parseBody(b.create()));
   }
 
-  protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo, String branch)
+  protected ChangeInserter newChangeForBranch(TestRepository<Repository> repo, String branch)
       throws Exception {
     return newChange(repo, null, branch, null, null, null, false, false);
   }
 
-  protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo, Change.Status status)
-      throws Exception {
+  protected ChangeInserter newChangeWithStatus(
+      TestRepository<Repository> repo, Change.Status status) throws Exception {
     return newChange(repo, null, null, status, null, null, false, false);
   }
 
-  protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo, String topic)
+  protected ChangeInserter newChangeWithStatus(String repoName, Change.Status status)
+      throws Exception {
+    return newChange(repoName, null, null, status, null, null, false, false);
+  }
+
+  protected ChangeInserter newChangeWithTopic(TestRepository<Repository> repo, String topic)
       throws Exception {
     return newChange(repo, null, null, null, topic, null, false, false);
   }
 
-  protected ChangeInserter newChangeWorkInProgress(TestRepository<Repo> repo) throws Exception {
+  protected ChangeInserter newChangeWorkInProgress(TestRepository<Repository> repo)
+      throws Exception {
     return newChange(repo, null, null, null, null, null, true, false);
   }
 
-  protected ChangeInserter newChangePrivate(TestRepository<Repo> repo) throws Exception {
+  protected ChangeInserter newChangePrivate(TestRepository<Repository> repo) throws Exception {
     return newChange(repo, null, null, null, null, null, false, true);
   }
 
   protected ChangeInserter newCherryPickChange(
-      TestRepository<Repo> repo, String branch, PatchSet.Id cherryPickOf) throws Exception {
+      TestRepository<Repository> repo, String branch, PatchSet.Id cherryPickOf) throws Exception {
     return newChange(repo, null, branch, null, null, cherryPickOf, false, true);
   }
 
+  protected ChangeInserter newChange(String repoName) throws Exception {
+    return newChange(repoName, null, null, null, null, null, false, false);
+  }
+
   protected ChangeInserter newChange(
-      TestRepository<Repo> repo,
+      String repoName,
+      @Nullable RevCommit commit,
+      @Nullable String branch,
+      @Nullable Change.Status status,
+      @Nullable String topic,
+      @Nullable PatchSet.Id cherryPickOf,
+      boolean workInProgress,
+      boolean isPrivate)
+      throws Exception {
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+      return newChange(
+          repo, commit, branch, status, topic, cherryPickOf, workInProgress, isPrivate);
+    }
+  }
+
+  protected ChangeInserter newChange(TestRepository<Repository> repo) throws Exception {
+    return newChange(repo, null, null, null, null, null, false, false);
+  }
+
+  protected ChangeInserter newChange(
+      TestRepository<Repository> repo,
       @Nullable RevCommit commit,
       @Nullable String branch,
       @Nullable Change.Status status,
@@ -4114,7 +4151,7 @@
       boolean isPrivate)
       throws Exception {
     if (commit == null) {
-      commit = repo.parseBody(repo.commit().message("message").create());
+      commit = repo.parseBody(repo.commit().message("initial message").create());
     }
 
     branch = MoreObjects.firstNonNull(branch, "refs/heads/master");
@@ -4123,35 +4160,32 @@
     }
 
     Change.Id id = Change.id(seq.nextChangeId());
-    ChangeInserter ins =
-        changeFactory
-            .create(id, commit, branch)
-            .setValidate(false)
-            .setStatus(status)
-            .setTopic(topic)
-            .setWorkInProgress(workInProgress)
-            .setPrivate(isPrivate)
-            .setCherryPickOf(cherryPickOf);
-    return ins;
+    return changeFactory
+        .create(id, commit, branch)
+        .setValidate(false)
+        .setStatus(status)
+        .setTopic(topic)
+        .setWorkInProgress(workInProgress)
+        .setPrivate(isPrivate)
+        .setCherryPickOf(cherryPickOf);
   }
 
-  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins) throws Exception {
-    return insert(repo, ins, null, TimeUtil.nowTs());
-  }
-
-  protected Change insert(TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner)
+  @CanIgnoreReturnValue
+  protected Change insert(String repoName, ChangeInserter ins, @Nullable Account.Id owner)
       throws Exception {
-    return insert(repo, ins, owner, TimeUtil.nowTs());
+    return insert(repoName, ins, owner, TimeUtil.now());
   }
 
+  @CanIgnoreReturnValue
+  protected Change insert(String repoName, ChangeInserter ins) throws Exception {
+    return insert(repoName, ins, null, TimeUtil.now());
+  }
+
+  @CanIgnoreReturnValue
   protected Change insert(
-      TestRepository<Repo> repo,
-      ChangeInserter ins,
-      @Nullable Account.Id owner,
-      Timestamp createdOn)
+      String repoName, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
       throws Exception {
-    Project.NameKey project =
-        Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
+    Project.NameKey project = Project.nameKey(repoName);
     Account.Id ownerId = owner != null ? owner : userId;
     IdentifiedUser user = userFactory.create(ownerId);
     try (BatchUpdate bu = updateFactory.create(project, user, createdOn)) {
@@ -4161,30 +4195,37 @@
     }
   }
 
-  protected Change newPatchSet(TestRepository<Repo> repo, Change c, CurrentUser user)
-      throws Exception {
-    // Add a new file so the patch set is not a trivial rebase, to avoid default
-    // Code-Review label copying.
-    int n = c.currentPatchSetId().get() + 1;
-    RevCommit commit =
-        repo.parseBody(repo.commit().message("message").add("file" + n, "contents " + n).create());
+  protected Change newPatchSet(
+      String repoName, Change c, CurrentUser user, Optional<String> message) throws Exception {
+    try (TestRepository<Repository> repo =
+        new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+      // Add a new file so the patch set is not a trivial rebase, to avoid default
+      // Code-Review label copying.
+      int n = c.currentPatchSetId().get() + 1;
+      RevCommit commit =
+          repo.parseBody(
+              repo.commit()
+                  .message(message.orElse("updated message"))
+                  .add("file" + n, "contents " + n)
+                  .create());
 
-    PatchSetInserter inserter =
-        patchSetFactory
-            .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
-            .setFireRevisionCreated(false)
-            .setValidate(false);
-    try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.nowTs());
-        ObjectInserter oi = repo.getRepository().newObjectInserter();
-        ObjectReader reader = oi.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      bu.setRepository(repo.getRepository(), rw, oi);
-      bu.setNotify(NotifyResolver.Result.none());
-      bu.addOp(c.getId(), inserter);
-      bu.execute();
+      PatchSetInserter inserter =
+          patchSetFactory
+              .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
+              .setFireRevisionCreated(false)
+              .setValidate(false);
+      try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.now());
+          ObjectInserter oi = repo.getRepository().newObjectInserter();
+          ObjectReader reader = oi.newReader();
+          RevWalk rw = new RevWalk(reader)) {
+        bu.setRepository(repo.getRepository(), rw, oi);
+        bu.setNotify(NotifyResolver.Result.none());
+        bu.addOp(c.getId(), inserter);
+        bu.execute();
+      }
+
+      return inserter.getChange();
     }
-
-    return inserter.getChange();
   }
 
   protected ThrowableSubject assertThatQueryException(Object query) throws Exception {
@@ -4209,17 +4250,27 @@
     }
   }
 
-  protected TestRepository<Repo> createProject(String name) throws Exception {
-    gApi.projects().create(name).get();
+  @CanIgnoreReturnValue
+  protected TestRepository<Repository> createAndOpenProject(String name) throws Exception {
+    createProject(name);
     return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
-  protected TestRepository<Repo> createProject(String name, String parent) throws Exception {
+  protected TestRepository<Repository> createAndOpenProject(String name, String parent)
+      throws Exception {
+    createProject(name, parent);
+    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
+  }
+
+  protected void createProject(String name) throws Exception {
+    gApi.projects().create(name).get();
+  }
+
+  protected void createProject(String name, String parent) throws Exception {
     ProjectInput input = new ProjectInput();
     input.name = name;
     input.parent = parent;
     gApi.projects().create(input).get();
-    return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
   }
 
   protected QueryRequest newQuery(Object query) {
@@ -4313,7 +4364,7 @@
   }
 
   protected static long lastUpdatedMs(Change c) {
-    return c.getLastUpdatedOn().getTime();
+    return c.getLastUpdatedOn().toEpochMilli();
   }
 
   // Get the last  updated time from ChangeApi
@@ -4402,16 +4453,19 @@
     }
   }
 
-  protected void assertMissingField(FieldDef<ChangeData, ?> field) {
-    assertWithMessage("schema %s has field %s", getSchemaVersion(), field.getName())
-        .that(getSchema().hasField(field))
-        .isFalse();
-  }
-
   protected void assertFailingQuery(String query) throws Exception {
     assertFailingQuery(query, null);
   }
 
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
   protected void assertFailingQuery(String query, @Nullable String expectedMessage)
       throws Exception {
     try {
@@ -4424,11 +4478,15 @@
     }
   }
 
-  protected int getSchemaVersion() {
-    return getSchema().getVersion();
-  }
-
   protected Schema<ChangeData> getSchema() {
     return indexes.getSearchIndex().getSchema();
   }
+
+  protected ChangeUpdate newUpdate(Change c) throws Exception {
+    ChangeUpdate update =
+        TestChanges.newUpdate(injector, c, Optional.empty(), /* shouldExist= */ true);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.setAllowWriteToNewRef(true);
+    return update;
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 08456d1..32a646e 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -21,14 +21,15 @@
         "//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",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/index/testing",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/group/testing",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index e42230f..e48d4af 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -46,7 +46,7 @@
         .id(PatchSet.id(changeId, num))
         .commitId(ObjectId.zeroId())
         .uploader(Account.id(1234))
-        .createdOn(TimeUtil.nowTs())
+        .createdOn(TimeUtil.now())
         .build();
   }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesLatestIndexVersionTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesLatestIndexVersionTest.java
index 5496f56..4dde452 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesLatestIndexVersionTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesLatestIndexVersionTest.java
@@ -26,4 +26,11 @@
   public static Config defaultConfig() {
     return IndexConfig.createForFake();
   }
+
+  @ConfigSuite.Config
+  public static Config searchAfterPaginationType() {
+    Config config = defaultConfig();
+    config.setString("index", null, "paginationType", "SEARCH_AFTER");
+    return config;
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesPreviousIndexVersionTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesPreviousIndexVersionTest.java
index 1610eca..95896dc 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesPreviousIndexVersionTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesPreviousIndexVersionTest.java
@@ -36,4 +36,11 @@
                 IndexConfig.createForFake())
             .values());
   }
+
+  @ConfigSuite.Config
+  public static Config searchAfterPaginationType() {
+    Config config = againstPreviousIndexVersion();
+    config.setString("index", null, "paginationType", "SEARCH_AFTER");
+    return config;
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index 1e23420..6b17bb6 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -14,10 +14,27 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
+import com.google.inject.Inject;
 import com.google.inject.Injector;
+import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
 
 /**
  * Test against {@link com.google.gerrit.index.testing.AbstractFakeIndex}. This test might seem
@@ -25,6 +42,9 @@
  * results as production indices.
  */
 public abstract class FakeQueryChangesTest extends AbstractQueryChangesTest {
+  @Inject private ChangeIndexCollection changeIndexCollection;
+  @Inject protected AllProjectsName allProjects;
+
   @Override
   protected Injector createInjector() {
     Config fakeConfig = new Config(config);
@@ -32,4 +52,89 @@
     fakeConfig.setString("index", null, "type", "fake");
     return Guice.createInjector(new InMemoryModule(fakeConfig));
   }
+
+  @Test
+  @UseClockStep
+  public void stopQueryIfNoMoreResults() throws Exception {
+    // create 2 visible changes
+    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+    }
+
+    // create 2 invisible changes
+    try (TestRepository<Repository> hiddenProject = createAndOpenProject("hiddenProject")) {
+      insert("hiddenProject", newChange(hiddenProject));
+      insert("hiddenProject", newChange(hiddenProject));
+      projectOperations
+          .project(Project.nameKey("hiddenProject"))
+          .forUpdate()
+          .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+          .update();
+    }
+
+    AbstractFakeIndex<?, ?, ?> idx =
+        (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
+    newQuery("status:new").withLimit(5).get();
+    // Since the limit of the query (i.e. 5) is more than the total number of changes (i.e. 4),
+    // only 1 index search is expected.
+    assertThat(idx.getQueryCount()).isEqualTo(1);
+  }
+
+  @Test
+  @UseClockStep
+  public void noLimitQueryPaginates() throws Exception {
+    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+      // create 4 changes
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+    }
+
+    // Set queryLimit to 2
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 2))
+        .update();
+
+    AbstractFakeIndex<?, ?, ?> idx =
+        (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
+
+    // 2 index searches are expected. The first index search will run with size 3 (i.e.
+    // the configured query-limit+1), and then we will paginate to get the remaining
+    // changes with the second index search.
+    newQuery("status:new").withNoLimit().get();
+    assertThat(idx.getQueryCount()).isEqualTo(2);
+  }
+
+  @Test
+  @UseClockStep
+  public void internalQueriesPaginate() throws Exception {
+    // create 4 changes
+    try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+      insert("repo", newChange(testRepo));
+    }
+
+    // Set queryLimit to 2
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 2))
+        .update();
+
+    AbstractFakeIndex<?, ?, ?> idx =
+        (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
+
+    // 2 index searches are expected. The first index search will run with size 3 (i.e.
+    // the configured query-limit+1), and then we will paginate to get the remaining
+    // changes with the second index search.
+    List<ChangeData> matches = queryProvider.get().query(queryBuilder.parse("status:new"));
+    assertThat(matches).hasSize(4);
+    assertThat(idx.getQueryCount()).isEqualTo(2);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesLatestIndexVersionTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesLatestIndexVersionTest.java
index 52a9170..4587943 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesLatestIndexVersionTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesLatestIndexVersionTest.java
@@ -23,4 +23,11 @@
   public static Config defaultConfig() {
     return IndexConfig.createForLucene();
   }
+
+  @ConfigSuite.Config
+  public static Config searchAfterPaginationType() {
+    Config config = defaultConfig();
+    config.setString("index", null, "paginationType", "SEARCH_AFTER");
+    return config;
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesPreviousIndexVersionTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesPreviousIndexVersionTest.java
index 62483fa..1782697 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesPreviousIndexVersionTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesPreviousIndexVersionTest.java
@@ -33,4 +33,11 @@
                 IndexConfig.createForLucene())
             .values());
   }
+
+  @ConfigSuite.Config
+  public static Config searchAfterPaginationType() {
+    Config config = againstPreviousIndexVersion();
+    config.setString("index", null, "paginationType", "SEARCH_AFTER");
+    return config;
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 6a83fb9..5cae012 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -15,20 +15,25 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
 import com.google.inject.Guice;
+import com.google.inject.Inject;
 import com.google.inject.Injector;
-import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public abstract class LuceneQueryChangesTest extends AbstractQueryChangesTest {
+  @Inject protected AllProjectsName allProjects;
+
   @Override
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
@@ -38,11 +43,11 @@
 
   @Test
   public void fullTextWithSpecialChars() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
+    repo = createAndOpenProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("foo_bar_foo").create());
-    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("one.two.three").create());
-    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
 
     assertQuery("message:foo_ba");
     assertQuery("message:bar", change1);
@@ -56,8 +61,8 @@
   @Test
   @Override
   public void byOwnerInvalidQuery() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo), userId);
     String nameEmail = user.asIdentifiedUser().getNameEmail();
 
     BadRequestException thrown =
@@ -66,4 +71,29 @@
             () -> assertQuery("owner: \"" + nameEmail + "\"\\", change1));
     assertThat(thrown).hasMessageThat().contains("Cannot create full-text query with value: \\");
   }
+
+  @Test
+  public void openAndClosedChanges() throws Exception {
+    repo = createAndOpenProject("repo");
+
+    // create 3 closed changes
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change2 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change3 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+
+    // create 3 new changes
+    Change change4 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Change change5 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+    Change change6 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+
+    // Set queryLimit to 1
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 1))
+        .update();
+
+    Change[] expected = new Change[] {change6, change5, change4, change3, change2, change1};
+    assertQuery(newQuery("project:repo").withNoLimit(), expected);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 568b5a0..12bafd5 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -20,8 +20,10 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.CharMatcher;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
@@ -326,6 +328,13 @@
   }
 
   @Test
+  public void withStartCannotBeLessThanZero() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    assertFailingQuery(
+        newQuery("uuid:" + group1.id).withStart(-1), "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void sortedByUuid() throws Exception {
     GroupInfo group1 = createGroup(name("group1"));
     GroupInfo group2 = createGroup(name("group2"));
@@ -374,13 +383,15 @@
             .getRaw(
                 uuid,
                 QueryOptions.create(
-                    IndexConfig.createDefault(),
+                    config != null
+                        ? IndexConfig.fromConfig(config).build()
+                        : IndexConfig.createDefault(),
                     0,
                     10,
-                    indexes.getSearchIndex().getSchema().getStoredFields().keySet()));
+                    indexes.getSearchIndex().getSchema().getStoredFields()));
 
     assertThat(rawFields).isPresent();
-    assertThat(rawFields.get().getValue(GroupField.UUID)).isEqualTo(uuid.get());
+    assertThat(rawFields.get().getValue(GroupField.UUID_FIELD_SPEC)).isEqualTo(uuid.get());
   }
 
   @Test
@@ -390,9 +401,8 @@
     String query = "uuid:" + uuid;
     assertQuery(query, group);
 
-    for (GroupIndex index : groupIndexes.getWriteIndexes()) {
-      index.delete(uuid);
-    }
+    deleteGroup(uuid);
+
     assertQuery(query);
   }
 
@@ -430,6 +440,10 @@
     return createGroupWithDescription(name, null, members);
   }
 
+  protected GroupInfo createGroup(GroupInput in) throws Exception {
+    return gApi.groups().create(in).get();
+  }
+
   protected GroupInfo createGroupWithDescription(
       String name, String description, AccountInfo... members) throws Exception {
     GroupInput in = new GroupInput();
@@ -437,21 +451,27 @@
     in.description = description;
     in.members =
         Arrays.asList(members).stream().map(a -> String.valueOf(a._accountId)).collect(toList());
-    return gApi.groups().create(in).get();
+    return createGroup(in);
   }
 
   protected GroupInfo createGroupWithOwner(String name, GroupInfo ownerGroup) throws Exception {
     GroupInput in = new GroupInput();
     in.name = name;
     in.ownerId = ownerGroup.id;
-    return gApi.groups().create(in).get();
+    return createGroup(in);
   }
 
   protected GroupInfo createGroupThatIsVisibleToAll(String name) throws Exception {
     GroupInput in = new GroupInput();
     in.name = name;
     in.visibleToAll = true;
-    return gApi.groups().create(in).get();
+    return createGroup(in);
+  }
+
+  protected void deleteGroup(AccountGroup.UUID uuid) throws Exception {
+    for (GroupIndex index : groupIndexes.getWriteIndexes()) {
+      index.delete(uuid);
+    }
   }
 
   protected GroupInfo getGroup(AccountGroup.UUID uuid) throws Exception {
@@ -477,6 +497,15 @@
     return result;
   }
 
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
   protected QueryRequest newQuery(Object query) {
     return gApi.groups().query(query.toString());
   }
@@ -541,6 +570,7 @@
     return groups.stream().map(g -> g.id).sorted().collect(toList());
   }
 
+  @Nullable
   protected String name(String name) {
     if (name == null) {
       return null;
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 4b74325..e877c81 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -10,6 +10,7 @@
     visibility = ["//visibility:public"],
     runtime_deps = ["//java/com/google/gerrit/lucene"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
@@ -54,6 +55,5 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jgit",
         "//lib/guice",
-        "@truth//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index 60d1655..b119104 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -19,10 +19,12 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -290,6 +292,13 @@
   }
 
   @Test
+  public void withStartCannotBeLessThanZero() throws Exception {
+    assertFailingQuery(
+        newQuery("name:" + allProjects.get()).withStart(-1),
+        "'start' parameter cannot be less than zero");
+  }
+
+  @Test
   public void sortedByName() throws Exception {
     ProjectInfo projectFoo = createProject("foo-" + name("project1"));
     ProjectInfo projectBar = createProject("bar-" + name("project2"));
@@ -396,6 +405,15 @@
     return result;
   }
 
+  protected void assertFailingQuery(QueryRequest query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
   protected QueryRequest newQuery(Object query) {
     return gApi.projects().query(query.toString());
   }
@@ -452,6 +470,7 @@
     return projects.stream().map(p -> p.name).collect(toList());
   }
 
+  @Nullable
   protected String name(String name) {
     if (name == null) {
       return null;
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index a65306c..53f9d9d 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -10,6 +10,7 @@
     visibility = ["//visibility:public"],
     runtime_deps = ["//java/com/google/gerrit/lucene"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/index",
@@ -51,10 +52,8 @@
     deps = [
         ":abstract_query_tests",
         "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:jgit",
         "//lib/guice",
-        "@truth//jar",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index 46f9c5a..4c8750a 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -38,9 +38,10 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
 import com.google.gerrit.server.restapi.change.CommentPorter.Metrics;
 import com.google.gerrit.truth.NullAwareCorrespondence;
-import java.sql.Timestamp;
+import java.time.Instant;
 import java.util.Arrays;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -78,7 +79,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(DiffNotAvailableException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -101,7 +105,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -144,7 +151,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -173,7 +183,10 @@
         .thenReturn(Optional.of(dummyObjectId));
     // Throw an exception on the first diff request but return an actual value on the second.
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenThrow(IllegalStateException.class)
         .thenReturn(ImmutableMap.of());
     ImmutableList<HumanComment> portedComments =
@@ -200,7 +213,10 @@
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
     when(diffOperations.listModifiedFiles(
-            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+            any(Project.NameKey.class),
+            any(ObjectId.class),
+            any(ObjectId.class),
+            any(DiffOptions.class)))
         .thenReturn(ImmutableMap.of());
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -215,7 +231,7 @@
         changeId,
         Account.id(123),
         BranchNameKey.create(project, "myBranch"),
-        new Timestamp(12345));
+        Instant.ofEpochMilli(12345));
   }
 
   private PatchSet createPatchset(PatchSet.Id id) {
@@ -223,7 +239,7 @@
         .id(id)
         .commitId(dummyObjectId)
         .uploader(Account.id(123))
-        .createdOn(new Timestamp(12345))
+        .createdOn(Instant.ofEpochMilli(12345))
         .build();
   }
 
@@ -246,7 +262,7 @@
     return new HumanComment(
         new Comment.Key(getUniqueUuid(), filePath, patchsetId.get()),
         Account.id(100),
-        new Timestamp(1234),
+        Instant.ofEpochMilli(1234),
         (short) 1,
         "Comment text",
         "serverId",
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 44c3cef..2685a8b 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
@@ -124,9 +127,11 @@
   /** Create a new change message with an id, message, timestamp and tag */
   private static ChangeMessage newChangeMessage(String id, String message, String ts, String tag) {
     ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
-    ChangeMessage cm =
-        ChangeMessage.create(
-            key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null, message, null, tag);
+    Instant timestamp =
+        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+            .withZone(ZoneId.systemDefault())
+            .parse("2000-01-01 00:00:" + ts, Instant::from);
+    ChangeMessage cm = ChangeMessage.create(key, null, timestamp, null, message, null, tag);
     return cm;
   }
 
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index e5dd817..509447a 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -27,7 +27,6 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Date;
 import java.util.List;
 import org.junit.Test;
 
@@ -84,7 +83,7 @@
     return PatchSetApproval.builder()
         .key(PatchSetApproval.key(PS_ID, accountId, labelId))
         .value(value)
-        .granted(Date.from(Instant.now()))
+        .granted(Instant.now())
         .build();
   }
 
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index 646f0cd..767ac28 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -34,8 +34,6 @@
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestUpdateUI;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
 import java.util.Optional;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.junit.TestRepository;
@@ -84,7 +82,7 @@
     protected final NoteDbSchemaUpdater updater;
     protected final GitRepositoryManager repoManager;
     protected final NoteDbSchemaVersion.Arguments args;
-    private final List<String> messages;
+    private final ImmutableList.Builder<String> messages;
 
     TestUpdate(Optional<Integer> initialVersion) {
       cfg = new Config();
@@ -106,7 +104,7 @@
               versionManager,
               args,
               ImmutableSortedMap.of(10, TestSchema_10.class, 11, TestSchema_11.class));
-      messages = new ArrayList<>();
+      messages = ImmutableList.builder();
     }
 
     private class TestSchemaCreator implements SchemaCreator {
@@ -173,7 +171,7 @@
     }
 
     ImmutableList<String> getMessages() {
-      return ImmutableList.copyOf(messages);
+      return messages.build();
     }
 
     Optional<Integer> readVersion() throws Exception {
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index 9cba362..1304c53 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.extensions.client.ChangeKind;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -77,7 +78,11 @@
     assertThat(codeReview.getName()).isEqualTo("Code-Review");
     assertThat(codeReview.getDefaultValue()).isEqualTo(0);
     assertThat(codeReview.getFunction()).isEqualTo(LabelFunction.MAX_WITH_BLOCK);
-    assertThat(codeReview.isCopyMinScore()).isTrue();
+    assertThat(codeReview.getCopyCondition())
+        .hasValue(
+            String.format(
+                "changekind:%s OR changekind:%s OR is:MIN",
+                ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
     assertValueRange(codeReview, -2, -1, 0, 1, 2);
   }
 
diff --git a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
index fb995fd..8702755 100644
--- a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
+++ b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
@@ -38,8 +38,11 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
+import org.junit.runner.RunWith;
 import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
+@RunWith(MockitoJUnitRunner.class)
 public class SubscriptionGraphTest {
   private static final String TEST_PATH = "test/path";
   private static final Project.NameKey SUPER_PROJECT = Project.nameKey("Superproject");
@@ -51,9 +54,9 @@
   private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
   private MergeOpRepoManager mergeOpRepoManager;
 
-  @Mock GitModules.Factory mockGitModulesFactory = mock(GitModules.Factory.class);
-  @Mock ProjectCache mockProjectCache = mock(ProjectCache.class);
-  @Mock ProjectState mockProjectState = mock(ProjectState.class);
+  @Mock GitModules.Factory mockGitModulesFactory;
+  @Mock ProjectCache mockProjectCache;
+  @Mock ProjectState mockProjectState;
 
   @Before
   public void setUp() throws Exception {
@@ -61,7 +64,6 @@
     mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
 
     GitModules emptyMockGitModules = mock(GitModules.class);
-    when(emptyMockGitModules.subscribedTo(any())).thenReturn(ImmutableSet.of());
     when(mockGitModulesFactory.create(any(), any())).thenReturn(emptyMockGitModules);
 
     TestRepository<Repository> superProject = createRepo(SUPER_PROJECT);
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 4fe4ab04..6d96c10 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -1,8 +1,8 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 junit_tests(
-    name = "small_tests",
-    size = "small",
+    name = "update_tests",
+    size = "medium",
     srcs = glob(["*.java"]),
     runtime_deps = [
         "//java/com/google/gerrit/lucene",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 10599c6..91c8371 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -18,18 +18,28 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.events.AttentionSetListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -51,6 +61,11 @@
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
 
 public class BatchUpdateTest {
   private static final int MAX_UPDATES = 4;
@@ -75,6 +90,15 @@
   @Inject private PatchSetInserter.Factory patchSetInserterFactory;
   @Inject private Provider<CurrentUser> user;
   @Inject private Sequences sequences;
+  @Inject private AddReviewersOp.Factory addReviewersOpFactory;
+  @Inject private DynamicSet<AttentionSetListener> attentionSetListeners;
+  @Inject private AccountManager accountManager;
+  @Inject private AuthRequest.Factory authRequestFactory;
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+  @Captor ArgumentCaptor<AttentionSetListener.Event> attentionSetEventCaptor;
+  @Mock private AttentionSetListener attentionSetListener;
 
   @Inject
   private @Named("diff_summary") Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
@@ -95,7 +119,7 @@
     RevCommit masterCommit = repo.branch("master").commit().create();
     RevCommit branchCommit = repo.branch("branch").commit().parent(masterCommit).create();
 
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addRepoOnlyOp(
           new RepoOnlyOp() {
             @Override
@@ -114,7 +138,7 @@
   public void cannotExceedMaxUpdates() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new AddMessageOp("Excessive update"));
       ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute);
       assertThat(thrown)
@@ -130,7 +154,7 @@
     Change.Id id = createChangeWithPatchSets(2);
 
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new AddMessageOp("Update on PS1", PatchSet.id(id, 1)));
       bu.addOp(id, new AddMessageOp("Update on PS2", PatchSet.id(id, 2)));
       ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute);
@@ -143,10 +167,43 @@
   }
 
   @Test
+  public void attentionSetUpdateEventsFiredForSeveralChangesInSingleBatch() throws Exception {
+    Change.Id id1 = createChangeWithUpdates(1);
+    Change.Id id2 = createChangeWithUpdates(1);
+    attentionSetListeners.add("test", attentionSetListener);
+
+    Account.Id reviewer1 =
+        accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId();
+    Account.Id reviewer2 =
+        accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId();
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      bu.addOp(
+          id1,
+          addReviewersOpFactory.create(
+              ImmutableSet.of(reviewer1), ImmutableList.of(), ReviewerState.REVIEWER, false));
+      bu.addOp(
+          id2,
+          addReviewersOpFactory.create(
+              ImmutableSet.of(reviewer2), ImmutableList.of(), ReviewerState.REVIEWER, false));
+      bu.execute();
+    }
+    verify(attentionSetListener, times(2)).onAttentionSetChanged(attentionSetEventCaptor.capture());
+    AttentionSetListener.Event event1 = attentionSetEventCaptor.getAllValues().get(0);
+    assertThat(event1.getChange()._number).isEqualTo(id1.get());
+    assertThat(event1.usersAdded()).containsExactly(reviewer1.get());
+    assertThat(event1.usersRemoved()).isEmpty();
+
+    AttentionSetListener.Event event2 = attentionSetEventCaptor.getAllValues().get(1);
+    assertThat(event2.getChange()._number).isEqualTo(id2.get());
+    assertThat(event2.usersRemoved()).isEmpty();
+  }
+
+  @Test
   public void exceedingMaxUpdatesAllowedWithCompleteNoOp() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(
           id,
           new BatchUpdateOp() {
@@ -165,7 +222,7 @@
   public void exceedingMaxUpdatesAllowedWithNoOpAfterPopulatingUpdate() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(
           id,
           new BatchUpdateOp() {
@@ -185,7 +242,7 @@
   public void exceedingMaxUpdatesAllowedWithSubmit() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new SubmitOp());
       bu.execute();
     }
@@ -197,7 +254,7 @@
   public void exceedingMaxUpdatesAllowedWithSubmitAfterOtherOp() throws Exception {
     Change.Id id = createChangeWithPatchSets(2);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(id, new AddMessageOp("Message on PS1", PatchSet.id(id, 1)));
       bu.addOp(id, new SubmitOp());
       bu.execute();
@@ -212,7 +269,7 @@
   public void exceedingMaxUpdatesAllowedWithAbandon() throws Exception {
     Change.Id id = createChangeWithUpdates(MAX_UPDATES);
     ObjectId oldMetaId = getMetaId(id);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.addOp(
           id,
           new BatchUpdateOp() {
@@ -235,7 +292,7 @@
     Change.Id changeId = createChangeWithPatchSets(MAX_PATCH_SETS);
     ObjectId oldMetaId = getMetaId(changeId);
     ChangeNotes notes = changeNotesFactory.create(project, changeId);
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       ObjectId commitId =
           repo.amend(notes.getCurrentPatchSet().commitId()).message("kaboom").create();
       bu.addOp(
@@ -257,7 +314,7 @@
     Change.Id changeId = createChangeWithUpdates(1);
     ChangeNotes notes = changeNotesFactory.create(project, changeId);
 
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       ObjectId commitId =
           repo.amend(notes.getCurrentPatchSet().commitId())
               .add("bar.txt", "bar")
@@ -285,7 +342,7 @@
     int cacheSizeBefore = diffSummaryCache.asMap().size();
 
     // We don't want to depend on the test helper used above so we perform an explicit commit here.
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       ObjectId commitId =
           repo.amend(notes.getCurrentPatchSet().commitId())
               .add("bar.txt", "bar")
@@ -309,7 +366,7 @@
     checkArgument(totalUpdates > 0);
     checkArgument(totalUpdates <= MAX_UPDATES);
     Change.Id id = Change.id(sequences.nextChangeId());
-    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
       bu.insertChange(
           changeInserterFactory.create(
               id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master"));
@@ -317,7 +374,7 @@
     }
     assertThat(getUpdateCount(id)).isEqualTo(1);
     for (int i = 2; i <= totalUpdates; i++) {
-      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
         bu.addOp(id, new AddMessageOp("Update " + i));
         bu.execute();
       }
@@ -331,7 +388,7 @@
     Change.Id id = createChangeWithUpdates(MAX_UPDATES - 2);
     ChangeNotes notes = changeNotesFactory.create(project, id);
     for (int i = 2; i <= patchSets; ++i) {
-      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.nowTs())) {
+      try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
         ObjectId commitId =
             repo.amend(notes.getCurrentPatchSet().commitId()).message("PS" + i).create();
         bu.addOp(
diff --git a/javatests/com/google/gerrit/util/http/testutil/BUILD b/javatests/com/google/gerrit/util/http/testutil/BUILD
index 3a67d45..3b4817b 100644
--- a/javatests/com/google/gerrit/util/http/testutil/BUILD
+++ b/javatests/com/google/gerrit/util/http/testutil/BUILD
@@ -6,6 +6,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib:jgit",
         "//lib:servlet-api",
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index 0bb4de4..0347177 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -25,6 +25,7 @@
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
 import java.io.BufferedReader;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
@@ -105,6 +106,7 @@
     return -1;
   }
 
+  @Nullable
   @Override
   public String getContentType() {
     List<String> contentType = headers.get("Content-Type");
@@ -269,8 +271,8 @@
         .filter(s -> !s.isEmpty())
         .map(
             (String cookieValue) -> {
-              String[] kv = cookieValue.split("=");
-              return new Cookie(kv[0], kv[1]);
+              List<String> kv = Splitter.on("=").splitToList(cookieValue);
+              return new Cookie(kv.get(0), kv.get(1));
             })
         .collect(toList())
         .toArray(new Cookie[0]);
diff --git a/lib/BUILD b/lib/BUILD
index b2810cf..7aa9a45 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -47,13 +47,6 @@
 )
 
 java_library(
-    name = "jgit-ssh-jsch",
-    data = ["//lib:LICENSE-jgit"],
-    visibility = ["//visibility:public"],
-    exports = ["@jgit//org.eclipse.jgit.ssh.jsch:ssh-jsch"],
-)
-
-java_library(
     name = "jgit-ssh-apache",
     data = ["//lib:LICENSE-jgit"],
     visibility = ["//visibility:public"],
@@ -99,7 +92,10 @@
     name = "protobuf",
     data = ["//lib:LICENSE-protobuf"],
     visibility = ["//visibility:public"],
-    exports = ["@com_google_protobuf//:protobuf_java"],
+    exports = [
+        "@com_google_protobuf//:protobuf_java",
+        "@com_google_protobuf//:protobuf_javalite",
+    ],
 )
 
 java_library(
@@ -162,13 +158,6 @@
 )
 
 java_library(
-    name = "jsch",
-    data = ["//lib:LICENSE-jsch"],
-    visibility = ["//visibility:public"],
-    exports = ["@jsch//jar"],
-)
-
-java_library(
     name = "juniversalchardet",
     data = ["//lib:LICENSE-MPL1.1"],
     visibility = ["//visibility:public"],
@@ -499,17 +488,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(
@@ -549,18 +538,6 @@
     exports = ["@icu4j//jar"],
 )
 
-java_library(
-    name = "javax-annotation",
-    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
-    visibility = [
-        "//java/com/google/gerrit/acceptance:__pkg__",
-        "//java/com/google/gerrit/extensions:__pkg__",
-        "//java/com/google/gerrit/server:__pkg__",
-        "//plugins:__subpackages__",
-    ],
-    exports = ["@javax-annotation//jar"],
-)
-
 sh_test(
     name = "nongoogle_test",
     srcs = ["nongoogle_test.sh"],
diff --git a/lib/LICENSE-elasticsearch b/lib/LICENSE-elasticsearch
deleted file mode 100644
index 23cae9e..0000000
--- a/lib/LICENSE-elasticsearch
+++ /dev/null
@@ -1,5 +0,0 @@
-Elasticsearch
-Copyright 2009-2015 Elasticsearch
-
-This product includes software developed by The Apache Software
-Foundation (http://www.apache.org/).
diff --git a/lib/LICENSE-testcontainers b/lib/LICENSE-testcontainers
deleted file mode 100644
index 5d60e93..0000000
--- a/lib/LICENSE-testcontainers
+++ /dev/null
@@ -1,22 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2015 Richard North
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index 18b9b91..6e5418e 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -10,6 +10,23 @@
 )
 
 java_plugin(
+    name = "auto-factory-plugin",
+    generates_api = 1,
+    processor_class = "com.google.auto.factory.processor.AutoFactoryProcessor",
+    visibility = ["//visibility:private"],
+    deps = [
+        "@auto-common//jar",
+        "@auto-factory//jar",
+        "@auto-service-annotations//jar",
+        "@auto-value-annotations//jar",
+        "@auto-value//jar",
+        "@guava//jar",
+        "@javapoet//jar",
+        "@javax_inject//jar",
+    ],
+)
+
+java_plugin(
     name = "auto-value-plugin",
     processor_class = "com.google.auto.value.processor.AutoValueProcessor",
     deps = [
@@ -43,6 +60,16 @@
 )
 
 java_library(
+    name = "auto-factory",
+    data = ["//lib:LICENSE-Apache2.0"],
+    exported_plugins = [
+        ":auto-factory-plugin",
+    ],
+    visibility = ["//visibility:public"],
+    exports = ["@auto-factory//jar"],
+)
+
+java_library(
     name = "auto-value",
     data = ["//lib:LICENSE-Apache2.0"],
     exported_plugins = [
@@ -74,8 +101,6 @@
     ],
     visibility = ["//visibility:public"],
     exports = [
-        "@auto-value-gson-extension//jar",
-        "@auto-value-gson-factory//jar",
         "@auto-value-gson-runtime//jar",
     ],
 )
diff --git a/lib/commons/BUILD b/lib/commons/BUILD
index 38b1b6d..091ea07 100644
--- a/lib/commons/BUILD
+++ b/lib/commons/BUILD
@@ -15,12 +15,6 @@
 )
 
 java_library(
-    name = "lang",
-    data = ["//lib:LICENSE-Apache2.0"],
-    exports = ["@commons-lang//jar"],
-)
-
-java_library(
     name = "lang3",
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@commons-lang3//jar"],
diff --git a/lib/elasticsearch-rest-client/BUILD b/lib/elasticsearch-rest-client/BUILD
deleted file mode 100644
index e323263..0000000
--- a/lib/elasticsearch-rest-client/BUILD
+++ /dev/null
@@ -1,9 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-package(default_visibility = ["//visibility:public"])
-
-java_library(
-    name = "elasticsearch-rest-client",
-    data = ["//lib:LICENSE-elasticsearch"],
-    exports = ["@elasticsearch-rest-client//jar"],
-)
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD
index 41d0273..f13a064 100644
--- a/lib/fonts/BUILD
+++ b/lib/fonts/BUILD
@@ -26,3 +26,12 @@
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
 )
+
+filegroup(
+    name = "material-icons",
+    srcs = [
+        "material-icons.woff2",
+    ],
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+)
diff --git a/lib/fonts/material-icons.woff2 b/lib/fonts/material-icons.woff2
new file mode 100644
index 0000000..11074da
--- /dev/null
+++ b/lib/fonts/material-icons.woff2
Binary files differ
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD
index b10bc55..4105d85 100644
--- a/lib/highlightjs/BUILD
+++ b/lib/highlightjs/BUILD
@@ -1,3 +1,28 @@
-exports_files([
-    "highlight.min.js",
-])
+# build highlight.min.js from node modules
+
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+
+package(
+    default_visibility = ["//visibility:public"],
+    licenses = ["notice"],
+)
+
+rollup_bundle(
+    name = "highlight.min",
+    srcs = [
+        "@ui_npm//highlight.js",
+        "@ui_npm//highlightjs-closure-templates",
+        "@ui_npm//highlightjs-structured-text",
+    ],
+    config_file = "rollup.config.js",
+    entry_point = "index.js",
+    format = "iife",
+    rollup_bin = "//tools/node_tools:rollup-bin",
+    silent = True,
+    sourcemap = "hidden",
+    deps = [
+        "@tools_npm//rollup",
+        "@tools_npm//rollup-plugin-commonjs",
+        "@tools_npm//rollup-plugin-node-resolve",
+    ],
+)
diff --git a/lib/highlightjs/building.md b/lib/highlightjs/building.md
deleted file mode 100644
index 18c5746..0000000
--- a/lib/highlightjs/building.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Building Highlight.js for Gerrit
-
-Highlight JS needs to be built with specific language support. Here are the
-steps to build the minified file that appears here.
-
-NOTE: If you are adding support for a language to Highlight.js make sure to add
-it to the list of languages in the build command below.
-
-## Prerequisites
-
-You will need:
-
-* nodejs
-* closure-compiler
-* git
-
-## Steps to Create the Pack File
-
-The packed version of Highlight.js is an un-minified JS file with all of the
-languages included. Build it with the following:
-
-    $>  # start in some temp directory
-    $>  git clone https://github.com/highlightjs/highlight.js
-    $>  cd highlight.js
-    $>  git clone https://github.com/highlightjs/highlightjs-closure-templates
-    $>  ln -s ../../highlightjs-closure-templates/soy.js src/languages/soy.js
-    $>  mkdir test/detect/soy && ln -s ../../../highlightjs-closure-templates/test/detect/soy/default.txt test/detect/soy/default.txt
-    $>  npm install
-    $>  node tools/build.js
-
-The resulting minified JS file will appear in the "build" directory of the Highlight.js
-repo under the name "highlight.min.js".
-
-## Finish
-
-Copy the resulting build/highlight.min.js file to lib/highlightjs
diff --git a/lib/highlightjs/highlight.min.js b/lib/highlightjs/highlight.min.js
deleted file mode 100644
index ad4d1fe..0000000
--- a/lib/highlightjs/highlight.min.js
+++ /dev/null
@@ -1,3712 +0,0 @@
-/*
-  Highlight.js 10.7.2 (00233d63)
-  License: BSD-3-Clause
-  Copyright (c) 2006-2021, Ivan Sagalaev
-*/
-var hljs=function(){"use strict";function e(t){
-return t instanceof Map?t.clear=t.delete=t.set=()=>{
-throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{
-throw Error("set is read-only")
-}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{var i=t[n]
-;"object"!=typeof i||Object.isFrozen(i)||e(i)})),t}var t=e,n=e;t.default=n
-;class i{constructor(e){
-void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1}
-ignoreMatch(){this.isMatchIgnored=!0}}function s(e){
-return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#x27;")
-}function a(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t]
-;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const r=e=>!!e.kind
-;class l{constructor(e,t){
-this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){
-this.buffer+=s(e)}openNode(e){if(!r(e))return;let t=e.kind
-;e.sublanguage||(t=`${this.classPrefix}${t}`),this.span(t)}closeNode(e){
-r(e)&&(this.buffer+="</span>")}value(){return this.buffer}span(e){
-this.buffer+=`<span class="${e}">`}}class o{constructor(){this.rootNode={
-children:[]},this.stack=[this.rootNode]}get top(){
-return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){
-this.top.children.push(e)}openNode(e){const t={kind:e,children:[]}
-;this.add(t),this.stack.push(t)}closeNode(){
-if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){
-for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}
-walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){
-return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t),
-t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){
-"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{
-o._collapse(e)})))}}class c extends o{constructor(e){super(),this.options=e}
-addKeyword(e,t){""!==e&&(this.openNode(t),this.addText(e),this.closeNode())}
-addText(e){""!==e&&this.add(e)}addSublanguage(e,t){const n=e.root
-;n.kind=t,n.sublanguage=!0,this.add(n)}toHTML(){
-return new l(this,this.options).value()}finalize(){return!0}}function g(e){
-return e?"string"==typeof e?e:e.source:null}
-const u=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./,h="[a-zA-Z]\\w*",d="[a-zA-Z_]\\w*",f="\\b\\d+(\\.\\d+)?",p="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",m="\\b(0b[01]+)",b={
-begin:"\\\\[\\s\\S]",relevance:0},E={className:"string",begin:"'",end:"'",
-illegal:"\\n",contains:[b]},x={className:"string",begin:'"',end:'"',
-illegal:"\\n",contains:[b]},v={
-begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/
-},w=(e,t,n={})=>{const i=a({className:"comment",begin:e,end:t,contains:[]},n)
-;return i.contains.push(v),i.contains.push({className:"doctag",
-begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),i
-},y=w("//","$"),N=w("/\\*","\\*/"),R=w("#","$");var _=Object.freeze({
-__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:h,UNDERSCORE_IDENT_RE:d,
-NUMBER_RE:f,C_NUMBER_RE:p,BINARY_NUMBER_RE:m,
-RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",
-SHEBANG:(e={})=>{const t=/^#![ ]*\//
-;return e.binary&&(e.begin=((...e)=>e.map((e=>g(e))).join(""))(t,/.*\b/,e.binary,/\b.*/)),
-a({className:"meta",begin:t,end:/$/,relevance:0,"on:begin":(e,t)=>{
-0!==e.index&&t.ignoreMatch()}},e)},BACKSLASH_ESCAPE:b,APOS_STRING_MODE:E,
-QUOTE_STRING_MODE:x,PHRASAL_WORDS_MODE:v,COMMENT:w,C_LINE_COMMENT_MODE:y,
-C_BLOCK_COMMENT_MODE:N,HASH_COMMENT_MODE:R,NUMBER_MODE:{className:"number",
-begin:f,relevance:0},C_NUMBER_MODE:{className:"number",begin:p,relevance:0},
-BINARY_NUMBER_MODE:{className:"number",begin:m,relevance:0},CSS_NUMBER_MODE:{
-className:"number",
-begin:f+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",
-relevance:0},REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",
-begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b,{begin:/\[/,end:/\]/,
-relevance:0,contains:[b]}]}]},TITLE_MODE:{className:"title",begin:h,relevance:0
-},UNDERSCORE_TITLE_MODE:{className:"title",begin:d,relevance:0},METHOD_GUARD:{
-begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:e=>Object.assign(e,{
-"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{
-t.data._beginMatch!==e[1]&&t.ignoreMatch()}})});function k(e,t){
-"."===e.input[e.index-1]&&t.ignoreMatch()}function M(e,t){
-t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",
-e.__beforeBegin=k,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,
-void 0===e.relevance&&(e.relevance=0))}function O(e,t){
-Array.isArray(e.illegal)&&(e.illegal=((...e)=>"("+e.map((e=>g(e))).join("|")+")")(...e.illegal))
-}function A(e,t){if(e.match){
-if(e.begin||e.end)throw Error("begin & end are not supported with match")
-;e.begin=e.match,delete e.match}}function L(e,t){
-void 0===e.relevance&&(e.relevance=1)}
-const I=["of","and","for","in","not","or","if","then","parent","list","value"]
-;function j(e,t,n="keyword"){const i={}
-;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{
-Object.assign(i,j(e[n],t,n))})),i;function s(e,n){
-t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|")
-;i[n[0]]=[e,B(n[0],n[1])]}))}}function B(e,t){
-return t?Number(t):(e=>I.includes(e.toLowerCase()))(e)?0:1}
-function T(e,{plugins:t}){function n(t,n){
-return RegExp(g(t),"m"+(e.case_insensitive?"i":"")+(n?"g":""))}class i{
-constructor(){
-this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}
-addRule(e,t){
-t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]),
-this.matchAt+=(e=>RegExp(e.toString()+"|").exec("").length-1)(e)+1}compile(){
-0===this.regexes.length&&(this.exec=()=>null)
-;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(((e,t="|")=>{let n=0
-;return e.map((e=>{n+=1;const t=n;let i=g(e),s="";for(;i.length>0;){
-const e=u.exec(i);if(!e){s+=i;break}
-s+=i.substring(0,e.index),i=i.substring(e.index+e[0].length),
-"\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0],"("===e[0]&&n++)}return s
-})).map((e=>`(${e})`)).join(t)})(e),!0),this.lastIndex=0}exec(e){
-this.matcherRe.lastIndex=this.lastIndex;const t=this.matcherRe.exec(e)
-;if(!t)return null
-;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n]
-;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){
-this.rules=[],this.multiRegexes=[],
-this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){
-if(this.multiRegexes[e])return this.multiRegexes[e];const t=new i
-;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))),
-t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){
-return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){
-this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){
-const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex
-;let n=t.exec(e)
-;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{
-const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)}
-return n&&(this.regexIndex+=n.position+1,
-this.regexIndex===this.count&&this.considerAll()),n}}
-if(e.compilerExtensions||(e.compilerExtensions=[]),
-e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language.  See documentation.")
-;return e.classNameAliases=a(e.classNameAliases||{}),function t(i,r){const l=i
-;if(i.isCompiled)return l
-;[A].forEach((e=>e(i,r))),e.compilerExtensions.forEach((e=>e(i,r))),
-i.__beforeBegin=null,[M,O,L].forEach((e=>e(i,r))),i.isCompiled=!0;let o=null
-;if("object"==typeof i.keywords&&(o=i.keywords.$pattern,
-delete i.keywords.$pattern),
-i.keywords&&(i.keywords=j(i.keywords,e.case_insensitive)),
-i.lexemes&&o)throw Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ")
-;return o=o||i.lexemes||/\w+/,
-l.keywordPatternRe=n(o,!0),r&&(i.begin||(i.begin=/\B|\b/),
-l.beginRe=n(i.begin),i.endSameAsBegin&&(i.end=i.begin),
-i.end||i.endsWithParent||(i.end=/\B|\b/),
-i.end&&(l.endRe=n(i.end)),l.terminatorEnd=g(i.end)||"",
-i.endsWithParent&&r.terminatorEnd&&(l.terminatorEnd+=(i.end?"|":"")+r.terminatorEnd)),
-i.illegal&&(l.illegalRe=n(i.illegal)),
-i.contains||(i.contains=[]),i.contains=[].concat(...i.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>a(e,{
-variants:null},t)))),e.cachedVariants?e.cachedVariants:S(e)?a(e,{
-starts:e.starts?a(e.starts):null
-}):Object.isFrozen(e)?a(e):e))("self"===e?i:e)))),i.contains.forEach((e=>{t(e,l)
-})),i.starts&&t(i.starts,r),l.matcher=(e=>{const t=new s
-;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin"
-}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end"
-}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(l),l}(e)}function S(e){
-return!!e&&(e.endsWithParent||S(e.starts))}function P(e){const t={
-props:["language","code","autodetect"],data:()=>({detectedLanguage:"",
-unknownLanguage:!1}),computed:{className(){
-return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){
-if(!this.autoDetect&&!e.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`),
-this.unknownLanguage=!0,s(this.code);let t={}
-;return this.autoDetect?(t=e.highlightAuto(this.code),
-this.detectedLanguage=t.language):(t=e.highlight(this.language,this.code,this.ignoreIllegals),
-this.detectedLanguage=this.language),t.value},autoDetect(){
-return!(this.language&&(e=this.autodetect,!e&&""!==e));var e},
-ignoreIllegals:()=>!0},render(e){return e("pre",{},[e("code",{
-class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{
-Component:t,VuePlugin:{install(e){e.component("highlightjs",t)}}}}const D={
-"after:highlightElement":({el:e,result:t,text:n})=>{const i=H(e)
-;if(!i.length)return;const a=document.createElement("div")
-;a.innerHTML=t.value,t.value=((e,t,n)=>{let i=0,a="";const r=[];function l(){
-return e.length&&t.length?e[0].offset!==t[0].offset?e[0].offset<t[0].offset?e:t:"start"===t[0].event?e:t:e.length?e:t
-}function o(e){a+="<"+C(e)+[].map.call(e.attributes,(function(e){
-return" "+e.nodeName+'="'+s(e.value)+'"'})).join("")+">"}function c(e){
-a+="</"+C(e)+">"}function g(e){("start"===e.event?o:c)(e.node)}
-for(;e.length||t.length;){let t=l()
-;if(a+=s(n.substring(i,t[0].offset)),i=t[0].offset,t===e){r.reverse().forEach(c)
-;do{g(t.splice(0,1)[0]),t=l()}while(t===e&&t.length&&t[0].offset===i)
-;r.reverse().forEach(o)
-}else"start"===t[0].event?r.push(t[0].node):r.pop(),g(t.splice(0,1)[0])}
-return a+s(n.substr(i))})(i,H(a),n)}};function C(e){
-return e.nodeName.toLowerCase()}function H(e){const t=[];return function e(n,i){
-for(let s=n.firstChild;s;s=s.nextSibling)3===s.nodeType?i+=s.nodeValue.length:1===s.nodeType&&(t.push({
-event:"start",offset:i,node:s}),i=e(s,i),C(s).match(/br|hr|img|input/)||t.push({
-event:"stop",offset:i,node:s}));return i}(e,0),t}const $={},U=e=>{
-console.error(e)},z=(e,...t)=>{console.log("WARN: "+e,...t)},K=(e,t)=>{
-$[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),$[`${e}/${t}`]=!0)
-},G=s,V=a,W=Symbol("nomatch");return(e=>{
-const n=Object.create(null),s=Object.create(null),a=[];let r=!0
-;const l=/(^(<[^>]+>|\t|)+|\n)/gm,o="Could not find the language '{}', did you forget to load/include a language module?",g={
-disableAutodetect:!0,name:"Plain text",contains:[]};let u={
-noHighlightRe:/^(no-?highlight)$/i,
-languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",
-tabReplace:null,useBR:!1,languages:null,__emitter:c};function h(e){
-return u.noHighlightRe.test(e)}function d(e,t,n,i){let s="",a=""
-;"object"==typeof t?(s=e,
-n=t.ignoreIllegals,a=t.language,i=void 0):(K("10.7.0","highlight(lang, code, ...args) has been deprecated."),
-K("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),
-a=e,s=t);const r={code:s,language:a};M("before:highlight",r)
-;const l=r.result?r.result:f(r.language,r.code,n,i)
-;return l.code=r.code,M("after:highlight",l),l}function f(e,t,s,l){
-function c(e,t){const n=v.case_insensitive?t[0].toLowerCase():t[0]
-;return Object.prototype.hasOwnProperty.call(e.keywords,n)&&e.keywords[n]}
-function g(){null!=R.subLanguage?(()=>{if(""===M)return;let e=null
-;if("string"==typeof R.subLanguage){
-if(!n[R.subLanguage])return void k.addText(M)
-;e=f(R.subLanguage,M,!0,_[R.subLanguage]),_[R.subLanguage]=e.top
-}else e=p(M,R.subLanguage.length?R.subLanguage:null)
-;R.relevance>0&&(O+=e.relevance),k.addSublanguage(e.emitter,e.language)
-})():(()=>{if(!R.keywords)return void k.addText(M);let e=0
-;R.keywordPatternRe.lastIndex=0;let t=R.keywordPatternRe.exec(M),n="";for(;t;){
-n+=M.substring(e,t.index);const i=c(R,t);if(i){const[e,s]=i
-;if(k.addText(n),n="",O+=s,e.startsWith("_"))n+=t[0];else{
-const n=v.classNameAliases[e]||e;k.addKeyword(t[0],n)}}else n+=t[0]
-;e=R.keywordPatternRe.lastIndex,t=R.keywordPatternRe.exec(M)}
-n+=M.substr(e),k.addText(n)})(),M=""}function h(e){
-return e.className&&k.openNode(v.classNameAliases[e.className]||e.className),
-R=Object.create(e,{parent:{value:R}}),R}function d(e,t,n){let s=((e,t)=>{
-const n=e&&e.exec(t);return n&&0===n.index})(e.endRe,n);if(s){if(e["on:end"]){
-const n=new i(e);e["on:end"](t,n),n.isMatchIgnored&&(s=!1)}if(s){
-for(;e.endsParent&&e.parent;)e=e.parent;return e}}
-if(e.endsWithParent)return d(e.parent,t,n)}function m(e){
-return 0===R.matcher.regexIndex?(M+=e[0],1):(I=!0,0)}function b(e){
-const n=e[0],i=t.substr(e.index),s=d(R,e,i);if(!s)return W;const a=R
-;a.skip?M+=n:(a.returnEnd||a.excludeEnd||(M+=n),g(),a.excludeEnd&&(M=n));do{
-R.className&&k.closeNode(),R.skip||R.subLanguage||(O+=R.relevance),R=R.parent
-}while(R!==s.parent)
-;return s.starts&&(s.endSameAsBegin&&(s.starts.endRe=s.endRe),
-h(s.starts)),a.returnEnd?0:n.length}let E={};function x(n,a){const l=a&&a[0]
-;if(M+=n,null==l)return g(),0
-;if("begin"===E.type&&"end"===a.type&&E.index===a.index&&""===l){
-if(M+=t.slice(a.index,a.index+1),!r){const t=Error("0 width match regex")
-;throw t.languageName=e,t.badRule=E.rule,t}return 1}
-if(E=a,"begin"===a.type)return function(e){
-const t=e[0],n=e.rule,s=new i(n),a=[n.__beforeBegin,n["on:begin"]]
-;for(const n of a)if(n&&(n(e,s),s.isMatchIgnored))return m(t)
-;return n&&n.endSameAsBegin&&(n.endRe=RegExp(t.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")),
-n.skip?M+=t:(n.excludeBegin&&(M+=t),
-g(),n.returnBegin||n.excludeBegin||(M=t)),h(n),n.returnBegin?0:t.length}(a)
-;if("illegal"===a.type&&!s){
-const e=Error('Illegal lexeme "'+l+'" for mode "'+(R.className||"<unnamed>")+'"')
-;throw e.mode=R,e}if("end"===a.type){const e=b(a);if(e!==W)return e}
-if("illegal"===a.type&&""===l)return 1
-;if(L>1e5&&L>3*a.index)throw Error("potential infinite loop, way more iterations than matches")
-;return M+=l,l.length}const v=N(e)
-;if(!v)throw U(o.replace("{}",e)),Error('Unknown language: "'+e+'"')
-;const w=T(v,{plugins:a});let y="",R=l||w;const _={},k=new u.__emitter(u);(()=>{
-const e=[];for(let t=R;t!==v;t=t.parent)t.className&&e.unshift(t.className)
-;e.forEach((e=>k.openNode(e)))})();let M="",O=0,A=0,L=0,I=!1;try{
-for(R.matcher.considerAll();;){
-L++,I?I=!1:R.matcher.considerAll(),R.matcher.lastIndex=A
-;const e=R.matcher.exec(t);if(!e)break;const n=x(t.substring(A,e.index),e)
-;A=e.index+n}return x(t.substr(A)),k.closeAllNodes(),k.finalize(),y=k.toHTML(),{
-relevance:Math.floor(O),value:y,language:e,illegal:!1,emitter:k,top:R}}catch(n){
-if(n.message&&n.message.includes("Illegal"))return{illegal:!0,illegalBy:{
-msg:n.message,context:t.slice(A-100,A+100),mode:n.mode},sofar:y,relevance:0,
-value:G(t),emitter:k};if(r)return{illegal:!1,relevance:0,value:G(t),emitter:k,
-language:e,top:R,errorRaised:n};throw n}}function p(e,t){
-t=t||u.languages||Object.keys(n);const i=(e=>{const t={relevance:0,
-emitter:new u.__emitter(u),value:G(e),illegal:!1,top:g}
-;return t.emitter.addText(e),t})(e),s=t.filter(N).filter(k).map((t=>f(t,e,!1)))
-;s.unshift(i);const a=s.sort(((e,t)=>{
-if(e.relevance!==t.relevance)return t.relevance-e.relevance
-;if(e.language&&t.language){if(N(e.language).supersetOf===t.language)return 1
-;if(N(t.language).supersetOf===e.language)return-1}return 0})),[r,l]=a,o=r
-;return o.second_best=l,o}const m={"before:highlightElement":({el:e})=>{
-u.useBR&&(e.innerHTML=e.innerHTML.replace(/\n/g,"").replace(/<br[ /]*>/g,"\n"))
-},"after:highlightElement":({result:e})=>{
-u.useBR&&(e.value=e.value.replace(/\n/g,"<br>"))}},b=/^(<[^>]+>|\t)+/gm,E={
-"after:highlightElement":({result:e})=>{
-u.tabReplace&&(e.value=e.value.replace(b,(e=>e.replace(/\t/g,u.tabReplace))))}}
-;function x(e){let t=null;const n=(e=>{let t=e.className+" "
-;t+=e.parentNode?e.parentNode.className:"";const n=u.languageDetectRe.exec(t)
-;if(n){const t=N(n[1])
-;return t||(z(o.replace("{}",n[1])),z("Falling back to no-highlight mode for this block.",e)),
-t?n[1]:"no-highlight"}return t.split(/\s+/).find((e=>h(e)||N(e)))})(e)
-;if(h(n))return;M("before:highlightElement",{el:e,language:n}),t=e
-;const i=t.textContent,a=n?d(i,{language:n,ignoreIllegals:!0}):p(i)
-;M("after:highlightElement",{el:e,result:a,text:i
-}),e.innerHTML=a.value,((e,t,n)=>{const i=t?s[t]:n
-;e.classList.add("hljs"),i&&e.classList.add(i)})(e,n,a.language),e.result={
-language:a.language,re:a.relevance,relavance:a.relevance
-},a.second_best&&(e.second_best={language:a.second_best.language,
-re:a.second_best.relevance,relavance:a.second_best.relevance})}const v=()=>{
-v.called||(v.called=!0,
-K("10.6.0","initHighlighting() is deprecated.  Use highlightAll() instead."),
-document.querySelectorAll("pre code").forEach(x))};let w=!1;function y(){
-"loading"!==document.readyState?document.querySelectorAll("pre code").forEach(x):w=!0
-}function N(e){return e=(e||"").toLowerCase(),n[e]||n[s[e]]}
-function R(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{
-s[e.toLowerCase()]=t}))}function k(e){const t=N(e)
-;return t&&!t.disableAutodetect}function M(e,t){const n=e;a.forEach((e=>{
-e[n]&&e[n](t)}))}
-"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{
-w&&y()}),!1),Object.assign(e,{highlight:d,highlightAuto:p,highlightAll:y,
-fixMarkup:e=>{
-return K("10.2.0","fixMarkup will be removed entirely in v11.0"),K("10.2.0","Please see https://github.com/highlightjs/highlight.js/issues/2534"),
-t=e,
-u.tabReplace||u.useBR?t.replace(l,(e=>"\n"===e?u.useBR?"<br>":e:u.tabReplace?e.replace(/\t/g,u.tabReplace):e)):t
-;var t},highlightElement:x,
-highlightBlock:e=>(K("10.7.0","highlightBlock will be removed entirely in v12.0"),
-K("10.7.0","Please use highlightElement now."),x(e)),configure:e=>{
-e.useBR&&(K("10.3.0","'useBR' will be removed entirely in v11.0"),
-K("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")),
-u=V(u,e)},initHighlighting:v,initHighlightingOnLoad:()=>{
-K("10.6.0","initHighlightingOnLoad() is deprecated.  Use highlightAll() instead."),
-w=!0},registerLanguage:(t,i)=>{let s=null;try{s=i(e)}catch(e){
-if(U("Language definition for '{}' could not be registered.".replace("{}",t)),
-!r)throw e;U(e),s=g}
-s.name||(s.name=t),n[t]=s,s.rawDefinition=i.bind(null,e),s.aliases&&R(s.aliases,{
-languageName:t})},unregisterLanguage:e=>{delete n[e]
-;for(const t of Object.keys(s))s[t]===e&&delete s[t]},
-listLanguages:()=>Object.keys(n),getLanguage:N,registerAliases:R,
-requireLanguage:e=>{
-K("10.4.0","requireLanguage will be removed entirely in v11."),
-K("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844")
-;const t=N(e);if(t)return t
-;throw Error("The '{}' language is required, but not loaded.".replace("{}",e))},
-autoDetection:k,inherit:V,addPlugin:e=>{(e=>{
-e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{
-e["before:highlightBlock"](Object.assign({block:t.el},t))
-}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{
-e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),a.push(e)},
-vuePlugin:P(e).VuePlugin}),e.debugMode=()=>{r=!1},e.safeMode=()=>{r=!0
-},e.versionString="10.7.2";for(const e in _)"object"==typeof _[e]&&t(_[e])
-;return Object.assign(e,_),e.addPlugin(m),e.addPlugin(D),e.addPlugin(E),e})({})
-}();"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);
-hljs.registerLanguage("1c",(()=>{"use strict";return s=>{
-var x="[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]+",n="\u0434\u0430\u043b\u0435\u0435 \u0432\u043e\u0437\u0432\u0440\u0430\u0442 \u0432\u044b\u0437\u0432\u0430\u0442\u044c\u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0434\u043b\u044f \u0435\u0441\u043b\u0438 \u0438 \u0438\u0437 \u0438\u043b\u0438 \u0438\u043d\u0430\u0447\u0435 \u0438\u043d\u0430\u0447\u0435\u0435\u0441\u043b\u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043a\u043e\u043d\u0435\u0446\u0435\u0441\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u043f\u043e\u043f\u044b\u0442\u043a\u0438 \u043a\u043e\u043d\u0435\u0446\u0446\u0438\u043a\u043b\u0430 \u043d\u0435 \u043d\u043e\u0432\u044b\u0439 \u043f\u0435\u0440\u0435\u0439\u0442\u0438 \u043f\u0435\u0440\u0435\u043c \u043f\u043e \u043f\u043e\u043a\u0430 \u043f\u043e\u043f\u044b\u0442\u043a\u0430 \u043f\u0440\u0435\u0440\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c \u0442\u043e\u0433\u0434\u0430 \u0446\u0438\u043a\u043b \u044d\u043a\u0441\u043f\u043e\u0440\u0442 ",e="null \u0438\u0441\u0442\u0438\u043d\u0430 \u043b\u043e\u0436\u044c \u043d\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043e",o=s.inherit(s.NUMBER_MODE),t={
-className:"string",begin:'"|\\|',end:'"|$',contains:[{begin:'""'}]},a={
-begin:"'",end:"'",excludeBegin:!0,excludeEnd:!0,contains:[{className:"number",
-begin:"\\d{4}([\\.\\\\/:-]?\\d{2}){0,5}"}]},m=s.inherit(s.C_LINE_COMMENT_MODE)
-;return{name:"1C:Enterprise",case_insensitive:!0,keywords:{$pattern:x,keyword:n,
-built_in:"\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u0441\u0442\u0440\u043e\u043a \u0441\u0438\u043c\u0432\u043e\u043b\u0442\u0430\u0431\u0443\u043b\u044f\u0446\u0438\u0438 ansitooem oemtoansi \u0432\u0432\u0435\u0441\u0442\u0438\u0432\u0438\u0434\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u0435 \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u0435\u0440\u0438\u043e\u0434 \u0432\u0432\u0435\u0441\u0442\u0438\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0439\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u0434\u0430\u0442\u0430\u0433\u043e\u0434 \u0434\u0430\u0442\u0430\u043c\u0435\u0441\u044f\u0446 \u0434\u0430\u0442\u0430\u0447\u0438\u0441\u043b\u043e \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0441\u0442\u0440\u043e\u043a\u0443 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0441\u0442\u0440\u043e\u043a\u0438 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0438\u0431 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a\u043e\u0434\u0441\u0438\u043c\u0432 \u043a\u043e\u043d\u0433\u043e\u0434\u0430 \u043a\u043e\u043d\u0435\u0446\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043a\u043e\u043d\u0435\u0446\u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u043d\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043a\u043e\u043d\u0435\u0446\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043a\u043e\u043d\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043a\u043e\u043d\u043c\u0435\u0441\u044f\u0446\u0430 \u043a\u043e\u043d\u043d\u0435\u0434\u0435\u043b\u0438 \u043b\u043e\u0433 \u043b\u043e\u043310 \u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e\u0441\u0443\u0431\u043a\u043e\u043d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043d\u0430\u0431\u043e\u0440\u0430\u043f\u0440\u0430\u0432 \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c\u0432\u0438\u0434 \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c\u0441\u0447\u0435\u0442 \u043d\u0430\u0439\u0442\u0438\u0441\u0441\u044b\u043b\u043a\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0431\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430 \u043d\u0430\u0447\u0433\u043e\u0434\u0430 \u043d\u0430\u0447\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043d\u0430\u0447\u043c\u0435\u0441\u044f\u0446\u0430 \u043d\u0430\u0447\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u043e\u043c\u0435\u0440\u0434\u043d\u044f\u0433\u043e\u0434\u0430 \u043d\u043e\u043c\u0435\u0440\u0434\u043d\u044f\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u043e\u043c\u0435\u0440\u043d\u0435\u0434\u0435\u043b\u0438\u0433\u043e\u0434\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439\u044f\u0437\u044b\u043a \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u043e\u043a\u043d\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 \u043f\u0435\u0440\u0438\u043e\u0434\u0441\u0442\u0440 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u0430\u0442\u0443\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0443\u0441\u0442\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0442\u0430 \u043f\u0440\u0435\u0444\u0438\u043a\u0441\u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u0438 \u043f\u0440\u043e\u043f\u0438\u0441\u044c \u043f\u0443\u0441\u0442\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0440\u0430\u0437\u043c \u0440\u0430\u0437\u043e\u0431\u0440\u0430\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043d\u0430 \u0440\u0430\u0441\u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043f\u043e \u0441\u0438\u043c\u0432 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442 \u0441\u0442\u0430\u0442\u0443\u0441\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430 \u0441\u0442\u0440\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e\u0441\u0442\u0440\u043e\u043a \u0441\u0444\u043e\u0440\u043c\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u043f\u043e\u0437\u0438\u0446\u0438\u044e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0441\u0447\u0435\u0442\u043f\u043e\u043a\u043e\u0434\u0443 \u0442\u0435\u043a\u0443\u0449\u0435\u0435\u0432\u0440\u0435\u043c\u044f \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0441\u0442\u0440 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0442\u0430\u043d\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0442\u0430\u043f\u043e \u0444\u0438\u043a\u0441\u0448\u0430\u0431\u043b\u043e\u043d \u0448\u0430\u0431\u043b\u043e\u043d acos asin atan base64\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 base64\u0441\u0442\u0440\u043e\u043a\u0430 cos exp log log10 pow sin sqrt tan xml\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 xml\u0441\u0442\u0440\u043e\u043a\u0430 xml\u0442\u0438\u043f xml\u0442\u0438\u043f\u0437\u043d\u0447 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0435\u043e\u043a\u043d\u043e \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u043b\u0435\u0432\u043e \u0432\u0432\u0435\u0441\u0442\u0438\u0434\u0430\u0442\u0443 \u0432\u0432\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0432\u0432\u0435\u0441\u0442\u0438\u0441\u0442\u0440\u043e\u043a\u0443 \u0432\u0432\u0435\u0441\u0442\u0438\u0447\u0438\u0441\u043b\u043e \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u044c\u0447\u0442\u0435\u043d\u0438\u044fxml \u0432\u043e\u043f\u0440\u043e\u0441 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0432\u0440\u0435\u0433 \u0432\u044b\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0443\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u043f\u0440\u0430\u0432\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0432\u044b\u0447\u0438\u0441\u043b\u0438\u0442\u044c \u0433\u043e\u0434 \u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b\u0432\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0434\u0430\u0442\u0430 \u0434\u0435\u043d\u044c \u0434\u0435\u043d\u044c\u0433\u043e\u0434\u0430 \u0434\u0435\u043d\u044c\u043d\u0435\u0434\u0435\u043b\u0438 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c\u043c\u0435\u0441\u044f\u0446 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0434\u043b\u044f\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u0437\u0430\u043a\u0440\u044b\u0442\u044c\u0441\u043f\u0440\u0430\u0432\u043a\u0443 \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044cjson \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044cxml \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0434\u0430\u0442\u0443json \u0437\u0430\u043f\u0438\u0441\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0437\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0441\u0432\u043e\u0439\u0441\u0442\u0432 \u0437\u0430\u043f\u0440\u043e\u0441\u0438\u0442\u044c\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c\u0441\u0438\u0441\u0442\u0435\u043c\u0443 \u0437\u0430\u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0441\u0442\u0440\u043e\u043a\u0443\u0432\u043d\u0443\u0442\u0440 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0432\u0444\u0430\u0439\u043b \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u043e \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0441\u0442\u0440\u043e\u043a\u0438\u0432\u043d\u0443\u0442\u0440 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u0438\u0437xml\u0442\u0438\u043f\u0430 \u0438\u043c\u043f\u043e\u0440\u0442\u043c\u043e\u0434\u0435\u043b\u0438xdto \u0438\u043c\u044f\u043a\u043e\u043c\u043f\u044c\u044e\u0442\u0435\u0440\u0430 \u0438\u043c\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0435\u0434\u0430\u043d\u043d\u044b\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u043e\u0431\u043e\u0448\u0438\u0431\u043a\u0435 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0438\u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0433\u043e\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445\u0444\u0430\u0439\u043b\u043e\u0432 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u043a\u043e\u0434\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043a\u043e\u0434\u0441\u0438\u043c\u0432\u043e\u043b\u0430 \u043a\u043e\u043c\u0430\u043d\u0434\u0430\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043a\u043e\u043d\u0435\u0446\u0433\u043e\u0434\u0430 \u043a\u043e\u043d\u0435\u0446\u0434\u043d\u044f \u043a\u043e\u043d\u0435\u0446\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043a\u043e\u043d\u0435\u0446\u043c\u0435\u0441\u044f\u0446\u0430 \u043a\u043e\u043d\u0435\u0446\u043c\u0438\u043d\u0443\u0442\u044b \u043a\u043e\u043d\u0435\u0446\u043d\u0435\u0434\u0435\u043b\u0438 \u043a\u043e\u043d\u0435\u0446\u0447\u0430\u0441\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0430\u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0430 \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0444\u043e\u0440\u043c\u044b \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0444\u0430\u0439\u043b \u043a\u0440\u0430\u0442\u043a\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043b\u0435\u0432 \u043c\u0430\u043a\u0441 \u043c\u0435\u0441\u0442\u043d\u043e\u0435\u0432\u0440\u0435\u043c\u044f \u043c\u0435\u0441\u044f\u0446 \u043c\u0438\u043d \u043c\u0438\u043d\u0443\u0442\u0430 \u043c\u043e\u043d\u043e\u043f\u043e\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u043d\u0430\u0439\u0442\u0438 \u043d\u0430\u0439\u0442\u0438\u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u044bxml \u043d\u0430\u0439\u0442\u0438\u043e\u043a\u043d\u043e\u043f\u043e\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0441\u0441\u044b\u043b\u043a\u0435 \u043d\u0430\u0439\u0442\u0438\u043f\u043e\u043c\u0435\u0447\u0435\u043d\u043d\u044b\u0435\u043d\u0430\u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435 \u043d\u0430\u0439\u0442\u0438\u043f\u043e\u0441\u0441\u044b\u043b\u043a\u0430\u043c \u043d\u0430\u0439\u0442\u0438\u0444\u0430\u0439\u043b\u044b \u043d\u0430\u0447\u0430\u043b\u043e\u0433\u043e\u0434\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u0434\u043d\u044f \u043d\u0430\u0447\u0430\u043b\u043e\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u043c\u0435\u0441\u044f\u0446\u0430 \u043d\u0430\u0447\u0430\u043b\u043e\u043c\u0438\u043d\u0443\u0442\u044b \u043d\u0430\u0447\u0430\u043b\u043e\u043d\u0435\u0434\u0435\u043b\u0438 \u043d\u0430\u0447\u0430\u043b\u043e\u0447\u0430\u0441\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0437\u0430\u043f\u0440\u043e\u0441\u0440\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u0437\u0430\u043f\u0443\u0441\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u0438\u0441\u043a\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0447\u0435\u0433\u043e\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0430 \u043d\u0430\u0447\u0430\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u0444\u0430\u0439\u043b\u043e\u0432 \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043d\u0430\u0447\u0430\u0442\u044c\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043d\u0435\u0434\u0435\u043b\u044f\u0433\u043e\u0434\u0430 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u0441\u0442\u044c\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043d\u043e\u043c\u0435\u0440\u0441\u0435\u0430\u043d\u0441\u0430\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043d\u043e\u043c\u0435\u0440\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043d\u0440\u0435\u0433 \u043d\u0441\u0442\u0440 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u044e\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u044b\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043f\u0440\u0435\u0440\u044b\u0432\u0430\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043e\u043a\u0440 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043e\u043f\u043e\u0432\u0435\u0441\u0442\u0438\u0442\u044c \u043e\u043f\u043e\u0432\u0435\u0441\u0442\u0438\u0442\u044c\u043e\u0431\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438 \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0438\u043d\u0434\u0435\u043a\u0441\u0441\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0441\u043e\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435\u0441\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0441\u043f\u0440\u0430\u0432\u043a\u0443 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0444\u043e\u0440\u043c\u0443 \u043e\u0442\u043a\u0440\u044b\u0442\u044c\u0444\u043e\u0440\u043c\u0443\u043c\u043e\u0434\u0430\u043b\u044c\u043d\u043e \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044e \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043e\u0447\u0438\u0441\u0442\u0438\u0442\u044c\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0435\u0440\u0435\u0439\u0442\u0438\u043f\u043e\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0441\u0441\u044b\u043b\u043a\u0435 \u043f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043e\u0448\u0438\u0431\u043a\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0434\u0430\u0442\u044b \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0441\u0442\u0440\u043e\u043a\u0438 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u0432\u043e\u0434\u0447\u0438\u0441\u043b\u0430 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0432\u043e\u043f\u0440\u043e\u0441 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e\u043e\u0431\u043e\u0448\u0438\u0431\u043a\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043d\u0430\u043a\u0430\u0440\u0442\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c\u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435 \u043f\u043e\u043b\u043d\u043e\u0435\u0438\u043c\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044ccom\u043e\u0431\u044a\u0435\u043a\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044cxml\u0442\u0438\u043f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0430\u0434\u0440\u0435\u0441\u043f\u043e\u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043f\u044f\u0449\u0435\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0441\u044b\u043f\u0430\u043d\u0438\u044f\u043f\u0430\u0441\u0441\u0438\u0432\u043d\u043e\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0432\u044b\u0431\u043e\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u043a\u043e\u0434\u044b\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0447\u0430\u0441\u043e\u0432\u044b\u0435\u043f\u043e\u044f\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0437\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043c\u044f\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0444\u0430\u0439\u043b\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043c\u044f\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e\u044d\u043a\u0440\u0430\u043d\u043e\u0432\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043a\u0440\u0430\u0442\u043a\u0438\u0439\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u043a\u0435\u0442\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0430\u0441\u043a\u0443\u0432\u0441\u0435\u0444\u0430\u0439\u043b\u044b\u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0430\u0434\u0440\u0435\u0441\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0443\u044e\u0434\u043b\u0438\u043d\u0443\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u0443\u044e\u0441\u0441\u044b\u043b\u043a\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u0443\u044e\u0441\u0441\u044b\u043b\u043a\u0443\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u0449\u0438\u0439\u043c\u0430\u043a\u0435\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0431\u0449\u0443\u044e\u0444\u043e\u0440\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u043a\u043d\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u043f\u0435\u0440\u0430\u0442\u0438\u0432\u043d\u0443\u044e\u043e\u0442\u043c\u0435\u0442\u043a\u0443\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0433\u043e\u0440\u0435\u0436\u0438\u043c\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445\u043e\u043f\u0446\u0438\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u043e\u043b\u043d\u043e\u0435\u0438\u043c\u044f\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0445\u0441\u0441\u044b\u043b\u043e\u043a \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438\u043a\u043b\u0438\u0435\u043d\u0442\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u0435\u043b\u044c\u043f\u0443\u0442\u0438\u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0435\u0430\u043d\u0441\u044b\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430odata \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0443\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0441\u0435\u0430\u043d\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u043e\u0440\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e\u043e\u043f\u0446\u0438\u044e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u0443\u044e\u043e\u043f\u0446\u0438\u044e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438\u043e\u0441 \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0432\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0435\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435 \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u043f\u043e\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u043f\u0440\u0430\u0432 \u043f\u0440\u0430\u0432\u043e\u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043a\u043e\u0434\u0430\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0430\u0432\u0430 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0447\u0430\u0441\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u044f\u0441\u0430 \u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u043a\u0440\u0430\u0442\u0438\u0442\u044c\u0440\u0430\u0431\u043e\u0442\u0443\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043f\u0440\u0438\u0432\u0438\u043b\u0435\u0433\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u043f\u0440\u043e\u0434\u043e\u043b\u0436\u0438\u0442\u044c\u0432\u044b\u0437\u043e\u0432 \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044cjson \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044cxml \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044c\u0434\u0430\u0442\u0443json \u043f\u0443\u0441\u0442\u0430\u044f\u0441\u0442\u0440\u043e\u043a\u0430 \u0440\u0430\u0431\u043e\u0447\u0438\u0439\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0440\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0434\u043b\u044f\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c\u0444\u0430\u0439\u043b \u0440\u0430\u0437\u043e\u0440\u0432\u0430\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0441\u0432\u043d\u0435\u0448\u043d\u0438\u043c\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u043c\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u0440\u043e\u043b\u044c\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0441\u0435\u043a\u0443\u043d\u0434\u0430 \u0441\u0438\u0433\u043d\u0430\u043b \u0441\u0438\u043c\u0432\u043e\u043b \u0441\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0441\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u043b\u0435\u0442\u043d\u0435\u0433\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0441\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u044c\u0431\u0443\u0444\u0435\u0440\u044b\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u043a\u0430\u0442\u0430\u043b\u043e\u0433 \u0441\u043e\u0437\u0434\u0430\u0442\u044c\u0444\u0430\u0431\u0440\u0438\u043a\u0443xdto \u0441\u043e\u043a\u0440\u043b \u0441\u043e\u043a\u0440\u043b\u043f \u0441\u043e\u043a\u0440\u043f \u0441\u043e\u043e\u0431\u0449\u0438\u0442\u044c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441\u0440\u0435\u0434 \u0441\u0442\u0440\u0434\u043b\u0438\u043d\u0430 \u0441\u0442\u0440\u0437\u0430\u043a\u0430\u043d\u0447\u0438\u0432\u0430\u0435\u0442\u0441\u044f\u043d\u0430 \u0441\u0442\u0440\u0437\u0430\u043c\u0435\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u043d\u0430\u0439\u0442\u0438 \u0441\u0442\u0440\u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442\u0441\u044f\u0441 \u0441\u0442\u0440\u043e\u043a\u0430 \u0441\u0442\u0440\u043e\u043a\u0430\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0441\u0442\u0440\u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0441\u0442\u0440\u043e\u043a\u0443 \u0441\u0442\u0440\u0440\u0430\u0437\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0442\u0440\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u0441\u0440\u0430\u0432\u043d\u0438\u0442\u044c \u0441\u0442\u0440\u0447\u0438\u0441\u043b\u043e\u0432\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0439 \u0441\u0442\u0440\u0447\u0438\u0441\u043b\u043e\u0441\u0442\u0440\u043e\u043a \u0441\u0442\u0440\u0448\u0430\u0431\u043b\u043e\u043d \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0434\u0430\u0442\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0434\u0430\u0442\u0430\u0441\u0435\u0430\u043d\u0441\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u0430\u044f\u0434\u0430\u0442\u0430 \u0442\u0435\u043a\u0443\u0449\u0430\u044f\u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u0430\u044f\u0434\u0430\u0442\u0430\u0432\u043c\u0438\u043b\u043b\u0438\u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u0448\u0440\u0438\u0444\u0442\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u043a\u043e\u0434\u043b\u043e\u043a\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u044f\u0437\u044b\u043a \u0442\u0435\u043a\u0443\u0449\u0438\u0439\u044f\u0437\u044b\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0442\u0438\u043f \u0442\u0438\u043f\u0437\u043d\u0447 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044f\u0430\u043a\u0442\u0438\u0432\u043d\u0430 \u0442\u0440\u0435\u0433 \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0434\u0430\u043d\u043d\u044b\u0435\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0438\u0437\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c\u0444\u0430\u0439\u043b\u044b \u0443\u043d\u0438\u0432\u0435\u0440\u0441\u0430\u043b\u044c\u043d\u043e\u0435\u0432\u0440\u0435\u043c\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u043d\u0435\u0448\u043d\u044e\u044e\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0443 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u044f\u0441\u043f\u044f\u0449\u0435\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u0437\u0430\u0441\u044b\u043f\u0430\u043d\u0438\u044f\u043f\u0430\u0441\u0441\u0438\u0432\u043d\u043e\u0433\u043e\u0441\u0435\u0430\u043d\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0432\u0440\u0435\u043c\u044f\u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u0441\u0438\u0441\u0442\u0435\u043c\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043a\u0440\u0430\u0442\u043a\u0438\u0439\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0443\u044e\u0434\u043b\u0438\u043d\u0443\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043c\u043e\u043d\u043e\u043f\u043e\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u043b\u0438\u0446\u0435\u043d\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0433\u043e\u0440\u0435\u0436\u0438\u043c\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0444\u0443\u043d\u043a\u0446\u0438\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0445\u043e\u043f\u0446\u0438\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0440\u0438\u0432\u0438\u043b\u0435\u0433\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443\u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438\u043f\u0430\u0440\u043e\u043b\u0435\u0439\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0435\u0439 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0442\u044b\u0441\u0444\u0430\u0439\u043b\u0430\u043c\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0441\u0432\u043d\u0435\u0448\u043d\u0438\u043c\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u043c\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0438\u0444\u043e\u0440\u043c\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430odata \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c\u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0441\u0435\u0430\u043d\u0441\u0430 \u0444\u043e\u0440\u043c\u0430\u0442 \u0446\u0435\u043b \u0447\u0430\u0441 \u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441 \u0447\u0430\u0441\u043e\u0432\u043e\u0439\u043f\u043e\u044f\u0441\u0441\u0435\u0430\u043d\u0441\u0430 \u0447\u0438\u0441\u043b\u043e \u0447\u0438\u0441\u043b\u043e\u043f\u0440\u043e\u043f\u0438\u0441\u044c\u044e \u044d\u0442\u043e\u0430\u0434\u0440\u0435\u0441\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0433\u043e\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 ws\u0441\u0441\u044b\u043b\u043a\u0438 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043c\u0430\u043a\u0435\u0442\u043e\u0432\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u0441\u0442\u0438\u043b\u0435\u0439 \u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u044b \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u0432\u043d\u0435\u0448\u043d\u0438\u0435\u043e\u0442\u0447\u0435\u0442\u044b \u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0435\u043f\u043e\u043a\u0443\u043f\u043a\u0438 \u0433\u043b\u0430\u0432\u043d\u044b\u0439\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 \u0433\u043b\u0430\u0432\u043d\u044b\u0439\u0441\u0442\u0438\u043b\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0435\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f \u0436\u0443\u0440\u043d\u0430\u043b\u044b\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u0437\u0430\u0434\u0430\u0447\u0438 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u043e\u0431\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0431\u043e\u0447\u0435\u0439\u0434\u0430\u0442\u044b \u0438\u0441\u0442\u043e\u0440\u0438\u044f\u0440\u0430\u0431\u043e\u0442\u044b\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a\u043e\u043d\u0441\u0442\u0430\u043d\u0442\u044b \u043a\u0440\u0438\u0442\u0435\u0440\u0438\u0438\u043e\u0442\u0431\u043e\u0440\u0430 \u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0435 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u043a\u043b\u0430\u043c\u044b \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u043e\u0442\u0447\u0435\u0442\u044b \u043f\u0430\u043d\u0435\u043b\u044c\u0437\u0430\u0434\u0430\u0447\u043e\u0441 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0437\u0430\u043f\u0443\u0441\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0441\u0435\u0430\u043d\u0441\u0430 \u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044f \u043f\u043b\u0430\u043d\u044b\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043f\u043b\u0430\u043d\u044b\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a \u043f\u043b\u0430\u043d\u044b\u043e\u0431\u043c\u0435\u043d\u0430 \u043f\u043b\u0430\u043d\u044b\u0441\u0447\u0435\u0442\u043e\u0432 \u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u043f\u043e\u0438\u0441\u043a \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0431\u0430\u0437\u044b \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445\u043f\u043e\u043a\u0443\u043f\u043e\u043a \u0440\u0430\u0431\u043e\u0447\u0430\u044f\u0434\u0430\u0442\u0430 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 \u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u044b\u0435\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0442\u043e\u0440xdto \u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0438 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u0433\u0435\u043e\u043f\u043e\u0437\u0438\u0446\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043c\u0443\u043b\u044c\u0442\u0438\u043c\u0435\u0434\u0438\u0430 \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0440\u0435\u043a\u043b\u0430\u043c\u044b \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u043f\u043e\u0447\u0442\u044b \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430\u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0438\u0438 \u0444\u0430\u0431\u0440\u0438\u043a\u0430xdto \u0444\u0430\u0439\u043b\u043e\u0432\u044b\u0435\u043f\u043e\u0442\u043e\u043a\u0438 \u0444\u043e\u043d\u043e\u0432\u044b\u0435\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0432\u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043e\u0431\u0449\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u0434\u0438\u043d\u0430\u043c\u0438\u0447\u0435\u0441\u043a\u0438\u0445\u0441\u043f\u0438\u0441\u043a\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0441\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a ",
-class:"web\u0446\u0432\u0435\u0442\u0430 windows\u0446\u0432\u0435\u0442\u0430 windows\u0448\u0440\u0438\u0444\u0442\u044b \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u0440\u0430\u043c\u043a\u0438\u0441\u0442\u0438\u043b\u044f \u0441\u0438\u043c\u0432\u043e\u043b\u044b \u0446\u0432\u0435\u0442\u0430\u0441\u0442\u0438\u043b\u044f \u0448\u0440\u0438\u0444\u0442\u044b\u0441\u0442\u0438\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0435\u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c\u044b\u0432\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u044f\u0432\u0444\u043e\u0440\u043c\u0435 \u0430\u0432\u0442\u043e\u0440\u0430\u0437\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435\u0441\u0435\u0440\u0438\u0439 \u0430\u043d\u0438\u043c\u0430\u0446\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0438\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u043e\u0432 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0432\u044b\u0441\u043e\u0442\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u0430\u044f\u043f\u0440\u043e\u043a\u0440\u0443\u0442\u043a\u0430\u0444\u043e\u0440\u043c\u044b \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430 \u0432\u0438\u0434\u0433\u0440\u0443\u043f\u043f\u044b\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u0435\u043a\u043e\u0440\u0430\u0446\u0438\u0438\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0438\u0434\u043a\u043d\u043e\u043f\u043a\u0438\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044f \u0432\u0438\u0434\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u0432\u0438\u0434\u043f\u043e\u043b\u044f\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0444\u043b\u0430\u0436\u043a\u0430 \u0432\u043b\u0438\u044f\u043d\u0438\u0435\u0440\u0430\u0437\u043c\u0435\u0440\u0430\u043d\u0430\u043f\u0443\u0437\u044b\u0440\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430 \u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0430\u043a\u043e\u043b\u043e\u043d\u043e\u043a \u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0430\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0444\u043e\u0440\u043c\u044b \u0433\u0440\u0443\u043f\u043f\u044b\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439\u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f\u043f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u043c\u0435\u0436\u0434\u0443\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043c\u0438\u0444\u043e\u0440\u043c\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0432\u044b\u0432\u043e\u0434\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u043b\u043e\u0441\u044b\u043f\u0440\u043e\u043a\u0440\u0443\u0442\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u043c\u043e\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0442\u043e\u0447\u043a\u0438\u0431\u0438\u0440\u0436\u0435\u0432\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0438\u0441\u0442\u043e\u0440\u0438\u044f\u0432\u044b\u0431\u043e\u0440\u0430\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043e\u0441\u0438\u0442\u043e\u0447\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0440\u0430\u0437\u043c\u0435\u0440\u0430\u043f\u0443\u0437\u044b\u0440\u044c\u043a\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u044b\u043a\u043e\u043c\u0430\u043d\u0434 \u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c\u0441\u0435\u0440\u0438\u0439 \u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e\u0435\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0434\u0435\u0440\u0435\u0432\u0430 \u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e\u0435\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0441\u043f\u0438\u0441\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u043c\u0435\u0442\u043e\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u043c\u0435\u0442\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u043b\u0435\u0433\u0435\u043d\u0434\u0435\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u043a\u043d\u043e\u043f\u043e\u043a \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043d\u043e\u043f\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043d\u043e\u043f\u043a\u0438\u0432\u044b\u0431\u043e\u0440\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0431\u0441\u0443\u0436\u0434\u0435\u043d\u0438\u0439\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043e\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043f\u0443\u0437\u044b\u0440\u044c\u043a\u043e\u0432\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0430\u043d\u0435\u043b\u0438\u043f\u043e\u0438\u0441\u043a\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u0443\u043f\u0440\u0435\u0436\u0434\u0435\u043d\u0438\u044f\u043f\u0440\u0438\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0438 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0440\u0430\u0437\u043c\u0435\u0442\u043a\u0438\u043f\u043e\u043b\u043e\u0441\u044b\u0440\u0435\u0433\u0443\u043b\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0444\u043e\u0440\u043c\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0444\u0438\u0433\u0443\u0440\u044b\u043a\u043d\u043e\u043f\u043a\u0438 \u043f\u0430\u043b\u0438\u0442\u0440\u0430\u0446\u0432\u0435\u0442\u043e\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435\u043e\u0431\u044b\u0447\u043d\u043e\u0439\u0433\u0440\u0443\u043f\u043f\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0438\u0441\u043a\u0432\u0442\u0430\u0431\u043b\u0438\u0446\u0435\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438\u043a\u043d\u043e\u043f\u043a\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u0430\u043d\u0434\u043d\u043e\u0439\u043f\u0430\u043d\u0435\u043b\u0438\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u0430\u043d\u0434\u043d\u043e\u0439\u043f\u0430\u043d\u0435\u043b\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0444\u043e\u0440\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043e\u043f\u043e\u0440\u043d\u043e\u0439\u0442\u043e\u0447\u043a\u0438\u043e\u0442\u0440\u0438\u0441\u043e\u0432\u043a\u0438 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u0434\u043f\u0438\u0441\u0435\u0439\u0448\u043a\u0430\u043b\u044b\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u0442\u0440\u043e\u043a\u0438\u043f\u043e\u0438\u0441\u043a\u0430 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u043b\u0438\u043d\u0438\u0438 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u043e\u0438\u0441\u043a\u043e\u043c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u043a\u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0439\u0433\u0438\u0441\u0442\u043e\u0433\u0440\u0430\u043c\u043c\u044b \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u0441\u0435\u0440\u0438\u0439\u0432\u043b\u0435\u0433\u0435\u043d\u0434\u0435\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0430\u0437\u043c\u0435\u0440\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0430\u0441\u0442\u044f\u0433\u0438\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0432\u0432\u043e\u0434\u0430\u0441\u0442\u0440\u043e\u043a\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0431\u043e\u0440\u0430\u043d\u0435\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u043e\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0434\u0430\u0442\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0441\u0442\u0440\u043e\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0440\u0435\u0436\u0438\u043c\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0437\u043c\u0435\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0441\u0432\u044f\u0437\u0430\u043d\u043d\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u043f\u0435\u0447\u0430\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043a\u043e\u043c\u0430\u043d\u0434\u044b \u0440\u0435\u0436\u0438\u043c\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u043e\u043a\u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u043e\u043a\u043d\u0430\u0444\u043e\u0440\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0441\u0435\u0440\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u0440\u0438\u0441\u043e\u0432\u043a\u0438\u0441\u0435\u0442\u043a\u0438\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u0443\u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u043e\u0441\u0442\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0431\u0435\u043b\u043e\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0435 \u0440\u0435\u0436\u0438\u043c\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043a\u043e\u043b\u043e\u043d\u043a\u0438 \u0440\u0435\u0436\u0438\u043c\u0441\u0433\u043b\u0430\u0436\u0438\u0432\u0430\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0440\u0435\u0436\u0438\u043c\u0441\u0433\u043b\u0430\u0436\u0438\u0432\u0430\u043d\u0438\u044f\u0438\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0441\u043f\u0438\u0441\u043a\u0430\u0437\u0430\u0434\u0430\u0447 \u0441\u043a\u0432\u043e\u0437\u043d\u043e\u0435\u0432\u044b\u0440\u0430\u0432\u043d\u0438\u0432\u0430\u043d\u0438\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u0444\u043e\u0440\u043c\u044b\u0432\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0448\u043a\u0430\u043b\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0441\u043f\u043e\u0441\u043e\u0431\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0438\u0432\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u0430\u044f\u0433\u0440\u0443\u043f\u043f\u0430\u043a\u043e\u043c\u0430\u043d\u0434 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0435\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u0435 \u0441\u0442\u0430\u0442\u0443\u0441\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441\u0442\u0438\u043b\u044c\u0441\u0442\u0440\u0435\u043b\u043a\u0438 \u0442\u0438\u043f\u0430\u043f\u043f\u0440\u043e\u043a\u0441\u0438\u043c\u0430\u0446\u0438\u0438\u043b\u0438\u043d\u0438\u0438\u0442\u0440\u0435\u043d\u0434\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0435\u0434\u0438\u043d\u0438\u0446\u044b\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0442\u0438\u043f\u0438\u043c\u043f\u043e\u0440\u0442\u0430\u0441\u0435\u0440\u0438\u0439\u0441\u043b\u043e\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u043c\u0430\u0440\u043a\u0435\u0440\u0430\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043c\u0430\u0440\u043a\u0435\u0440\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0441\u0435\u0440\u0438\u0438\u0441\u043b\u043e\u044f\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u0447\u043d\u043e\u0433\u043e\u043e\u0431\u044a\u0435\u043a\u0442\u0430\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0448\u043a\u0430\u043b\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043b\u0435\u0433\u0435\u043d\u0434\u044b\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043f\u043e\u0438\u0441\u043a\u0430\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u043f\u0440\u043e\u0435\u043a\u0446\u0438\u0438\u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0440\u0430\u043c\u043a\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u0432\u044f\u0437\u0438\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u0433\u0430\u043d\u0442\u0430 \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u043f\u043e\u0441\u0435\u0440\u0438\u044f\u043c\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0442\u043e\u0447\u0435\u043a\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439\u043b\u0438\u043d\u0438\u0438 \u0442\u0438\u043f\u0441\u0442\u043e\u0440\u043e\u043d\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0442\u0438\u043f\u0444\u043e\u0440\u043c\u044b\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0448\u043a\u0430\u043b\u044b\u0440\u0430\u0434\u0430\u0440\u043d\u043e\u0439\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0444\u0430\u043a\u0442\u043e\u0440\u043b\u0438\u043d\u0438\u0438\u0442\u0440\u0435\u043d\u0434\u0430\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b \u0444\u0438\u0433\u0443\u0440\u0430\u043a\u043d\u043e\u043f\u043a\u0438 \u0444\u0438\u0433\u0443\u0440\u044b\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u043e\u0439\u0441\u0445\u0435\u043c\u044b \u0444\u0438\u043a\u0441\u0430\u0446\u0438\u044f\u0432\u0442\u0430\u0431\u043b\u0438\u0446\u0435 \u0444\u043e\u0440\u043c\u0430\u0442\u0434\u043d\u044f\u0448\u043a\u0430\u043b\u044b\u0432\u0440\u0435\u043c\u0435\u043d\u0438 \u0444\u043e\u0440\u043c\u0430\u0442\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0448\u0438\u0440\u0438\u043d\u0430\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0444\u043e\u0440\u043c\u044b \u0432\u0438\u0434\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438 \u0432\u0438\u0434\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0432\u0438\u0434\u0441\u0447\u0435\u0442\u0430 \u0432\u0438\u0434\u0442\u043e\u0447\u043a\u0438\u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0435\u0436\u0438\u043c\u0430\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u0440\u0435\u0437\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0430\u0433\u0440\u0435\u0433\u0430\u0442\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u0432\u0440\u0435\u043c\u044f \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0438\u0441\u0438\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0430\u0432\u0442\u043e\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0439 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439\u043d\u043e\u043c\u0435\u0440\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043e\u0440\u0438\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u043a\u043e\u043b\u043e\u043d\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u0441\u0442\u0440\u043e\u043a\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430\u043e\u0442\u043d\u043e\u0441\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0441\u043f\u043e\u0441\u043e\u0431\u0447\u0442\u0435\u043d\u0438\u044f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0434\u0432\u0443\u0441\u0442\u043e\u0440\u043e\u043d\u043d\u0435\u0439\u043f\u0435\u0447\u0430\u0442\u0438 \u0442\u0438\u043f\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043a\u0443\u0440\u0441\u043e\u0440\u043e\u0432\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u0440\u0438\u0441\u0443\u043d\u043a\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043b\u0438\u043d\u0438\u0438\u044f\u0447\u0435\u0439\u043a\u0438\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u0432\u044b\u0434\u0435\u043b\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043b\u0438\u043d\u0438\u0439\u0441\u0432\u043e\u0434\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0440\u0438\u0441\u0443\u043d\u043a\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0441\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0443\u0437\u043e\u0440\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u0444\u0430\u0439\u043b\u0430\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u043e\u0447\u043d\u043e\u0441\u0442\u044c\u043f\u0435\u0447\u0430\u0442\u0438 \u0447\u0435\u0440\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f\u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u0438\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0449\u0438\u043a\u0430 \u0442\u0438\u043f\u0444\u0430\u0439\u043b\u0430\u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0433\u043e\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043e\u0431\u0445\u043e\u0434\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0437\u0430\u043f\u0438\u0441\u0438\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432\u0438\u0434\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u043e\u0442\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0438\u0442\u043e\u0433\u043e\u0432 \u0434\u043e\u0441\u0442\u0443\u043f\u043a\u0444\u0430\u0439\u043b\u0443 \u0440\u0435\u0436\u0438\u043c\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u0432\u044b\u0431\u043e\u0440\u0430\u0444\u0430\u0439\u043b\u0430 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0444\u0430\u0439\u043b\u0430 \u0442\u0438\u043f\u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044f\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0432\u0438\u0434\u0434\u0430\u043d\u043d\u044b\u0445\u0430\u043d\u0430\u043b\u0438\u0437\u0430 \u043c\u0435\u0442\u043e\u0434\u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0438\u043f\u0435\u0434\u0438\u043d\u0438\u0446\u044b\u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b\u0430\u0432\u0440\u0435\u043c\u0435\u043d\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0447\u0438\u0441\u043b\u043e\u0432\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u0430\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u0434\u0435\u0440\u0435\u0432\u043e\u0440\u0435\u0448\u0435\u043d\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043a\u043b\u0430\u0441\u0442\u0435\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0430\u044f\u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0430 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0438\u0441\u043a\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439 \u0442\u0438\u043f\u043a\u043e\u043b\u043e\u043d\u043a\u0438\u043c\u043e\u0434\u0435\u043b\u0438\u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430 \u0442\u0438\u043f\u043c\u0435\u0440\u044b\u0440\u0430\u0441\u0441\u0442\u043e\u044f\u043d\u0438\u044f\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043e\u0442\u0441\u0435\u0447\u0435\u043d\u0438\u044f\u043f\u0440\u0430\u0432\u0438\u043b\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0438 \u0442\u0438\u043f\u043f\u043e\u043b\u044f\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u0438\u0437\u0430\u0446\u0438\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u043e\u0440\u044f\u0434\u043e\u0447\u0438\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u0430\u0432\u0438\u043b\u0430\u0441\u0441\u043e\u0446\u0438\u0430\u0446\u0438\u0438\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u043e\u0440\u044f\u0434\u043e\u0447\u0438\u0432\u0430\u043d\u0438\u044f\u0448\u0430\u0431\u043b\u043e\u043d\u043e\u0432\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0443\u043f\u0440\u043e\u0449\u0435\u043d\u0438\u044f\u0434\u0435\u0440\u0435\u0432\u0430\u0440\u0435\u0448\u0435\u043d\u0438\u0439 ws\u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u0432\u0430\u0440\u0438\u0430\u043d\u0442xpathxs \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0437\u0430\u043f\u0438\u0441\u0438\u0434\u0430\u0442\u044bjson \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0432\u0438\u0434\u0433\u0440\u0443\u043f\u043f\u044b\u043c\u043e\u0434\u0435\u043b\u0438xs \u0432\u0438\u0434\u0444\u0430\u0441\u0435\u0442\u0430xdto \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044fdom \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u0441\u043e\u0441\u0442\u0430\u0432\u043d\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u0441\u0445\u0435\u043c\u044bxs \u0437\u0430\u043f\u0440\u0435\u0449\u0435\u043d\u043d\u044b\u0435\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u043e\u0441\u0442\u0438xs \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0438\u043c\u0435\u043dxs \u043c\u0435\u0442\u043e\u0434\u043d\u0430\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u044fxs \u043c\u043e\u0434\u0435\u043b\u044c\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0433\u043exs \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0442\u0438\u043f\u0430xml \u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0435\u043f\u043e\u0434\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438xs \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0445\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432xs \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u043c\u043e\u0433\u043exs \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u043e\u0442\u0431\u043e\u0440\u0430\u0443\u0437\u043b\u043e\u0432dom \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0441\u0442\u0440\u043e\u043ajson \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0432\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0435dom \u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u044bxml \u0442\u0438\u043f\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xml \u0442\u0438\u043f\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fjson \u0442\u0438\u043f\u043a\u0430\u043d\u043e\u043d\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043exml \u0442\u0438\u043f\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044bxs \u0442\u0438\u043f\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438xml \u0442\u0438\u043f\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430domxpath \u0442\u0438\u043f\u0443\u0437\u043b\u0430dom \u0442\u0438\u043f\u0443\u0437\u043b\u0430xml \u0444\u043e\u0440\u043c\u0430xml \u0444\u043e\u0440\u043c\u0430\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044fxs \u0444\u043e\u0440\u043c\u0430\u0442\u0434\u0430\u0442\u044bjson \u044d\u043a\u0440\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432json \u0432\u0438\u0434\u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0432\u043b\u043e\u0436\u0435\u043d\u043d\u044b\u0445\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0438\u0442\u043e\u0433\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u0435\u0439\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u043e\u0433\u043e\u043e\u0441\u0442\u0430\u0442\u043a\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0432\u044b\u0432\u043e\u0434\u0430\u0442\u0435\u043a\u0441\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0433\u0440\u0443\u043f\u043f\u044b\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u043e\u0442\u0431\u043e\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430\u043f\u043e\u043b\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043e\u0441\u0442\u0430\u0442\u043a\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f\u0442\u0435\u043a\u0441\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u0441\u0432\u044f\u0437\u0438\u043d\u0430\u0431\u043e\u0440\u043e\u0432\u0434\u0430\u043d\u043d\u044b\u0445\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043b\u0435\u0433\u0435\u043d\u0434\u044b\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u044b\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043f\u0440\u0438\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u043e\u0442\u0431\u043e\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0430\u0432\u0442\u043e\u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0433\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0440\u0435\u0441\u0443\u0440\u0441\u043e\u0432\u0432\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0444\u0438\u043a\u0441\u0430\u0446\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0443\u0441\u043b\u043e\u0432\u043d\u043e\u0433\u043e\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u0430\u0436\u043d\u043e\u0441\u0442\u044c\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0442\u0435\u043a\u0441\u0442\u0430\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0432\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043d\u0435ascii\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0442\u0435\u043a\u0441\u0442\u0430\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u044b \u0441\u0442\u0430\u0442\u0443\u0441\u0440\u0430\u0437\u0431\u043e\u0440\u0430\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0433\u043e\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438\u0437\u0430\u043f\u0438\u0441\u0438\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0441\u0442\u0430\u0442\u0443\u0441\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438\u0437\u0430\u043f\u0438\u0441\u0438\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0440\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0442\u0438\u043f\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0432\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430\u0438\u043c\u0435\u043d\u0444\u0430\u0439\u043b\u043e\u0432\u0432zip\u0444\u0430\u0439\u043b\u0435 \u043c\u0435\u0442\u043e\u0434\u0441\u0436\u0430\u0442\u0438\u044fzip \u043c\u0435\u0442\u043e\u0434\u0448\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u044fzip \u0440\u0435\u0436\u0438\u043c\u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043f\u0443\u0442\u0435\u0439\u0444\u0430\u0439\u043b\u043e\u0432zip \u0440\u0435\u0436\u0438\u043c\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u043f\u043e\u0434\u043a\u0430\u0442\u0430\u043b\u043e\u0433\u043e\u0432zip \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u043f\u0443\u0442\u0435\u0439zip \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0441\u0436\u0430\u0442\u0438\u044fzip \u0437\u0432\u0443\u043a\u043e\u0432\u043e\u0435\u043e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430\u043a\u0441\u0442\u0440\u043e\u043a\u0435 \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0432\u043f\u043e\u0442\u043e\u043a\u0435 \u043f\u043e\u0440\u044f\u0434\u043e\u043a\u0431\u0430\u0439\u0442\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0435\u0436\u0438\u043c\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u043e\u0439\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0435\u0440\u0432\u0438\u0441\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u0445\u043f\u043e\u043a\u0443\u043f\u043e\u043a \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u0444\u043e\u043d\u043e\u0432\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0442\u0438\u043f\u043f\u043e\u0434\u043f\u0438\u0441\u0447\u0438\u043a\u0430\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044fftp \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043e\u0440\u044f\u0434\u043a\u0430\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f\u043f\u0435\u0440\u0438\u043e\u0434\u0430\u043c\u0438\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044c\u043d\u043e\u0439\u0442\u043e\u0447\u043a\u0438\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043e\u0431\u044a\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e\u0439\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u0438\u043f\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f\u0441\u0445\u0435\u043c\u044b\u0437\u0430\u043f\u0440\u043e\u0441\u0430 http\u043c\u0435\u0442\u043e\u0434 \u0430\u0432\u0442\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0430\u0432\u0442\u043e\u043f\u0440\u0435\u0444\u0438\u043a\u0441\u043d\u043e\u043c\u0435\u0440\u0430\u0437\u0430\u0434\u0430\u0447\u0438 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0432\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u043e\u0433\u043e\u044f\u0437\u044b\u043a\u0430 \u0432\u0438\u0434\u0438\u0435\u0440\u0430\u0440\u0445\u0438\u0438 \u0432\u0438\u0434\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u044c\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439\u043f\u0440\u0438\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435\u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0435\u0439 \u0438\u043d\u0434\u0435\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0431\u0430\u0437\u044b\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0431\u044b\u0441\u0442\u0440\u043e\u0433\u043e\u0432\u044b\u0431\u043e\u0440\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u0434\u0447\u0438\u043d\u0435\u043d\u0438\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0430\u0437\u0434\u0435\u043b\u044f\u0435\u043c\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u043f\u0435\u0440\u0430\u0442\u0438\u0432\u043d\u043e\u0435\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0432\u0438\u0434\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0432\u0438\u0434\u0430\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0437\u0430\u0434\u0430\u0447\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043b\u0430\u043d\u0430\u043e\u0431\u043c\u0435\u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0435\u043f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u0447\u0435\u0442\u0430 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435\u0433\u0440\u0430\u043d\u0438\u0446\u044b\u043f\u0440\u0438\u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0438 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u043d\u043e\u043c\u0435\u0440\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u043d\u043e\u043c\u0435\u0440\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u043d\u043e\u0441\u0442\u044c\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0435\u043c\u044b\u0445\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u043f\u043e\u0438\u0441\u043a\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u043d\u043e\u0441\u0442\u044c\u043e\u0431\u044a\u0435\u043a\u0442\u0430 \u043f\u0440\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0430\u0437\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u043e\u0431\u0449\u0435\u0433\u043e\u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 \u0440\u0435\u0436\u0438\u043c\u0430\u0432\u0442\u043e\u043d\u0443\u043c\u0435\u0440\u0430\u0446\u0438\u0438\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0438\u0441\u0438\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u043e\u0434\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u044b\u0445\u0432\u044b\u0437\u043e\u0432\u043e\u0432\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b\u0438\u0432\u043d\u0435\u0448\u043d\u0438\u0445\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0433\u043e\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0435\u0430\u043d\u0441\u043e\u0432 \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u044b\u0431\u043e\u0440\u0430\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u043e\u0441\u0442\u0438 \u0440\u0435\u0436\u0438\u043c\u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u043e\u0441\u0442\u0438\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u0440\u0435\u0436\u0438\u043c\u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u043e\u0439\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u043f\u043b\u0430\u043d\u0430\u0441\u0447\u0435\u0442\u043e\u0432 \u0441\u0435\u0440\u0438\u0438\u043a\u043e\u0434\u043e\u0432\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0435\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u044b\u0431\u043e\u0440\u0430 \u0441\u043f\u043e\u0441\u043e\u0431\u043f\u043e\u0438\u0441\u043a\u0430\u0441\u0442\u0440\u043e\u043a\u0438\u043f\u0440\u0438\u0432\u0432\u043e\u0434\u0435\u043f\u043e\u0441\u0442\u0440\u043e\u043a\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0442\u0438\u043f\u0434\u0430\u043d\u043d\u044b\u0445\u0442\u0430\u0431\u043b\u0438\u0446\u044b\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0438\u043f\u043a\u043e\u0434\u0430\u043f\u043b\u0430\u043d\u0430\u0432\u0438\u0434\u043e\u0432\u0440\u0430\u0441\u0447\u0435\u0442\u0430 \u0442\u0438\u043f\u043a\u043e\u0434\u0430\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0442\u0438\u043f\u043c\u0430\u043a\u0435\u0442\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0431\u0438\u0437\u043d\u0435\u0441\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430 \u0442\u0438\u043f\u043d\u043e\u043c\u0435\u0440\u0430\u0437\u0430\u0434\u0430\u0447\u0438 \u0442\u0438\u043f\u0444\u043e\u0440\u043c\u044b \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0439 \u0432\u0430\u0436\u043d\u043e\u0441\u0442\u044c\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b\u043f\u0440\u0438\u043c\u0435\u043d\u0435\u043d\u0438\u044f\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0430\u0444\u043e\u0440\u043c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e\u0448\u0440\u0438\u0444\u0442\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0439\u0434\u0430\u0442\u044b\u043d\u0430\u0447\u0430\u043b\u0430 \u0432\u0438\u0434\u0433\u0440\u0430\u043d\u0438\u0446\u044b \u0432\u0438\u0434\u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0438 \u0432\u0438\u0434\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0432\u0438\u0434\u0440\u0430\u043c\u043a\u0438 \u0432\u0438\u0434\u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0446\u0432\u0435\u0442\u0430 \u0432\u0438\u0434\u0447\u0438\u0441\u043b\u043e\u0432\u043e\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0432\u0438\u0434\u0448\u0440\u0438\u0444\u0442\u0430 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u0430\u044f\u0434\u043b\u0438\u043d\u0430 \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439\u0437\u043d\u0430\u043a \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435byteordermark \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0439\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043a\u043b\u0430\u0432\u0438\u0448\u0430 \u043a\u043e\u0434\u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430\u0434\u0438\u0430\u043b\u043e\u0433\u0430 \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430xbase \u043a\u043e\u0434\u0438\u0440\u043e\u0432\u043a\u0430\u0442\u0435\u043a\u0441\u0442\u0430 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u043e\u0438\u0441\u043a\u0430 \u043d\u0430\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u043a\u0438 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0435\u0434\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u0438\u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043f\u0430\u043d\u0435\u043b\u0438\u0440\u0430\u0437\u0434\u0435\u043b\u043e\u0432 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u0434\u0438\u0430\u043b\u043e\u0433\u0430\u0432\u043e\u043f\u0440\u043e\u0441 \u0440\u0435\u0436\u0438\u043c\u0437\u0430\u043f\u0443\u0441\u043a\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u043a\u0440\u0443\u0433\u043b\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0444\u043e\u0440\u043c\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0440\u0435\u0436\u0438\u043c\u043f\u043e\u043b\u043d\u043e\u0442\u0435\u043a\u0441\u0442\u043e\u0432\u043e\u0433\u043e\u043f\u043e\u0438\u0441\u043a\u0430 \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u044c\u043a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u043e\u0433\u043e\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u0432\u043d\u0435\u0448\u043d\u0435\u0433\u043e\u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u043f\u043e\u0441\u043e\u0431\u0432\u044b\u0431\u043e\u0440\u0430\u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430windows \u0441\u043f\u043e\u0441\u043e\u0431\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0442\u0440\u043e\u043a\u0438 \u0441\u0442\u0430\u0442\u0443\u0441\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u0442\u0438\u043f\u0432\u043d\u0435\u0448\u043d\u0435\u0439\u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u0442\u0438\u043f\u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u044b \u0442\u0438\u043f\u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u044f\u043a\u043b\u0430\u0432\u0438\u0448\u0438enter \u0442\u0438\u043f\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438\u043e\u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0438\u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438\u0431\u0430\u0437\u044b\u0434\u0430\u043d\u043d\u044b\u0445 \u0443\u0440\u043e\u0432\u0435\u043d\u044c\u0438\u0437\u043e\u043b\u044f\u0446\u0438\u0438\u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0439 \u0445\u0435\u0448\u0444\u0443\u043d\u043a\u0446\u0438\u044f \u0447\u0430\u0441\u0442\u0438\u0434\u0430\u0442\u044b",
-type:"com\u043e\u0431\u044a\u0435\u043a\u0442 ftp\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 http\u0437\u0430\u043f\u0440\u043e\u0441 http\u0441\u0435\u0440\u0432\u0438\u0441\u043e\u0442\u0432\u0435\u0442 http\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 ws\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044f ws\u043f\u0440\u043e\u043a\u0441\u0438 xbase \u0430\u043d\u0430\u043b\u0438\u0437\u0434\u0430\u043d\u043d\u044b\u0445 \u0430\u043d\u043d\u043e\u0442\u0430\u0446\u0438\u044fxs \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u0444\u0435\u0440\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435xs \u0432\u044b\u0440\u0430\u0436\u0435\u043d\u0438\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0433\u0435\u043d\u0435\u0440\u0430\u0442\u043e\u0440\u0441\u043b\u0443\u0447\u0430\u0439\u043d\u044b\u0445\u0447\u0438\u0441\u0435\u043b \u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0430\u044f\u0441\u0445\u0435\u043c\u0430 \u0433\u0435\u043e\u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0438\u0435\u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u044b \u0433\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0430\u044f\u0441\u0445\u0435\u043c\u0430 \u0433\u0440\u0443\u043f\u043f\u0430\u043c\u043e\u0434\u0435\u043b\u0438xs \u0434\u0430\u043d\u043d\u044b\u0435\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0435\u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u0435\u043d\u0434\u0440\u043e\u0433\u0440\u0430\u043c\u043c\u0430 \u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430 \u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430\u0433\u0430\u043d\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0444\u0430\u0439\u043b\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0446\u0432\u0435\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0432\u044b\u0431\u043e\u0440\u0430\u0448\u0440\u0438\u0444\u0442\u0430 \u0434\u0438\u0430\u043b\u043e\u0433\u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u044f\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0434\u0438\u0430\u043b\u043e\u0433\u0440\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u043e\u0433\u043e\u043f\u0435\u0440\u0438\u043e\u0434\u0430 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442dom \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442html \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044fxs \u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u043c\u043e\u0435\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u0437\u0430\u043f\u0438\u0441\u044cdom \u0437\u0430\u043f\u0438\u0441\u044cfastinfoset \u0437\u0430\u043f\u0438\u0441\u044chtml \u0437\u0430\u043f\u0438\u0441\u044cjson \u0437\u0430\u043f\u0438\u0441\u044cxml \u0437\u0430\u043f\u0438\u0441\u044czip\u0444\u0430\u0439\u043b\u0430 \u0437\u0430\u043f\u0438\u0441\u044c\u0434\u0430\u043d\u043d\u044b\u0445 \u0437\u0430\u043f\u0438\u0441\u044c\u0442\u0435\u043a\u0441\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c\u0443\u0437\u043b\u043e\u0432dom \u0437\u0430\u043f\u0440\u043e\u0441 \u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u043e\u0435\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435openssl \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043f\u043e\u043b\u0435\u0439\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0437\u0432\u043b\u0435\u0447\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430 \u0438\u043c\u043f\u043e\u0440\u0442xs \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u0430 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0435\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439\u043f\u0440\u043e\u0444\u0438\u043b\u044c \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u043f\u0440\u043e\u043a\u0441\u0438 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u0434\u043b\u044f\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044fxs \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0438\u0442\u0435\u0440\u0430\u0442\u043e\u0440\u0443\u0437\u043b\u043e\u0432dom \u043a\u0430\u0440\u0442\u0438\u043d\u043a\u0430 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0434\u0430\u0442\u044b \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0434\u0432\u043e\u0438\u0447\u043d\u044b\u0445\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0441\u0442\u0440\u043e\u043a\u0438 \u043a\u0432\u0430\u043b\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u044b\u0447\u0438\u0441\u043b\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u0449\u0438\u043a\u043c\u0430\u043a\u0435\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u0449\u0438\u043a\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u043c\u0430\u043a\u0435\u0442\u0430\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0441\u0442\u0440\u0443\u043a\u0442\u043e\u0440\u0444\u043e\u0440\u043c\u0430\u0442\u043d\u043e\u0439\u0441\u0442\u0440\u043e\u043a\u0438 \u043b\u0438\u043d\u0438\u044f \u043c\u0430\u043a\u0435\u0442\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u043a\u0435\u0442\u043e\u0431\u043b\u0430\u0441\u0442\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u043a\u0435\u0442\u043e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043c\u0430\u0441\u043a\u0430xs \u043c\u0435\u043d\u0435\u0434\u0436\u0435\u0440\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u043d\u0430\u0431\u043e\u0440\u0441\u0445\u0435\u043cxml \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438json \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u043a\u0430\u0440\u0442\u0438\u043d\u043e\u043a \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u0431\u0445\u043e\u0434\u0434\u0435\u0440\u0435\u0432\u0430dom \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430xs \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u043d\u043e\u0442\u0430\u0446\u0438\u0438xs \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d\u0438\u0435\u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0430xs \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0434\u043e\u0441\u0442\u0443\u043f\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u043e\u0442\u043a\u0430\u0437\u0432\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u0436\u0443\u0440\u043d\u0430\u043b\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438\u0440\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0438\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u043f\u0435\u0440\u0435\u0434\u0430\u0432\u0430\u0435\u043c\u043e\u0433\u043e\u0444\u0430\u0439\u043b\u0430 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0442\u0438\u043f\u043e\u0432 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u0430\u0442\u0440\u0438\u0431\u0443\u0442\u043e\u0432xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0433\u0440\u0443\u043f\u043f\u044b\u043c\u043e\u0434\u0435\u043b\u0438xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u0438\u0434\u0435\u043d\u0442\u0438\u0447\u043d\u043e\u0441\u0442\u0438xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u043f\u0440\u043e\u0441\u0442\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0441\u043e\u0441\u0442\u0430\u0432\u043d\u043e\u0433\u043e\u0442\u0438\u043f\u0430xs \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435\u0442\u0438\u043f\u0430\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430dom \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u044fxpathxs \u043e\u0442\u0431\u043e\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0430\u043a\u0435\u0442\u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u043c\u044b\u0445\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0432\u044b\u0431\u043e\u0440\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0437\u0430\u043f\u0438\u0441\u0438json \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0437\u0430\u043f\u0438\u0441\u0438xml \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0447\u0442\u0435\u043d\u0438\u044fxml \u043f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0438\u0435xs \u043f\u043b\u0430\u043d\u0438\u0440\u043e\u0432\u0449\u0438\u043a \u043f\u043e\u043b\u0435\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u043b\u0435\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044cdom \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u043e\u0442\u0447\u0435\u0442\u0430 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u043e\u0442\u0447\u0435\u0442\u0430\u0430\u043d\u0430\u043b\u0438\u0437\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043e\u0441\u0442\u0440\u043e\u0438\u0442\u0435\u043b\u044c\u0441\u0445\u0435\u043cxml \u043f\u043e\u0442\u043e\u043a \u043f\u043e\u0442\u043e\u043a\u0432\u043f\u0430\u043c\u044f\u0442\u0438 \u043f\u043e\u0447\u0442\u0430 \u043f\u043e\u0447\u0442\u043e\u0432\u043e\u0435\u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435xsl \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043a\u043a\u0430\u043d\u043e\u043d\u0438\u0447\u0435\u0441\u043a\u043e\u043c\u0443xml \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0432\u044b\u0432\u043e\u0434\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u043a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u044e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u0432\u044b\u0432\u043e\u0434\u0430\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445\u0432\u0442\u0430\u0431\u043b\u0438\u0447\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0440\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0440\u0430\u0437\u044b\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0438\u043c\u0435\u043ddom \u0440\u0430\u043c\u043a\u0430 \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u0440\u0435\u0433\u043b\u0430\u043c\u0435\u043d\u0442\u043d\u043e\u0433\u043e\u0437\u0430\u0434\u0430\u043d\u0438\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0435\u0438\u043c\u044fxml \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0447\u0442\u0435\u043d\u0438\u044f\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0432\u043e\u0434\u043d\u0430\u044f\u0434\u0438\u0430\u0433\u0440\u0430\u043c\u043c\u0430 \u0441\u0432\u044f\u0437\u044c\u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u0432\u044b\u0431\u043e\u0440\u0430 \u0441\u0432\u044f\u0437\u044c\u043f\u043e\u0442\u0438\u043f\u0443 \u0441\u0432\u044f\u0437\u044c\u043f\u043e\u0442\u0438\u043f\u0443\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0435\u0440\u0438\u0430\u043b\u0438\u0437\u0430\u0442\u043e\u0440xdto \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u043b\u0438\u0435\u043d\u0442\u0430windows \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u043b\u0438\u0435\u043d\u0442\u0430\u0444\u0430\u0439\u043b \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043a\u0440\u0438\u043f\u0442\u043e\u0433\u0440\u0430\u0444\u0438\u0438 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b\u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u044e\u0449\u0438\u0445\u0446\u0435\u043d\u0442\u0440\u043e\u0432windows \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u044b\u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u044e\u0449\u0438\u0445\u0446\u0435\u043d\u0442\u0440\u043e\u0432\u0444\u0430\u0439\u043b \u0441\u0436\u0430\u0442\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0441\u0438\u0441\u0442\u0435\u043c\u043d\u0430\u044f\u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044e \u0441\u043e\u0447\u0435\u0442\u0430\u043d\u0438\u0435\u043a\u043b\u0430\u0432\u0438\u0448 \u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u0430\u044f\u0434\u0430\u0442\u0430\u043d\u0430\u0447\u0430\u043b\u0430 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439\u043f\u0435\u0440\u0438\u043e\u0434 \u0441\u0445\u0435\u043c\u0430xml \u0441\u0445\u0435\u043c\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 \u0442\u0430\u0431\u043b\u0438\u0447\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0442\u0435\u043a\u0441\u0442\u043e\u0432\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0442\u0435\u0441\u0442\u0438\u0440\u0443\u0435\u043c\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u0438\u043f\u0434\u0430\u043d\u043d\u044b\u0445xml \u0443\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u0439\u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0444\u0430\u0431\u0440\u0438\u043a\u0430xdto \u0444\u0430\u0439\u043b \u0444\u0430\u0439\u043b\u043e\u0432\u044b\u0439\u043f\u043e\u0442\u043e\u043a \u0444\u0430\u0441\u0435\u0442\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0430\u0440\u0430\u0437\u0440\u044f\u0434\u043e\u0432\u0434\u0440\u043e\u0431\u043d\u043e\u0439\u0447\u0430\u0441\u0442\u0438xs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0438\u0441\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0439\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0432\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0433\u043e\u0438\u0441\u043a\u043b\u044e\u0447\u0430\u044e\u0449\u0435\u0433\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0439\u0434\u043b\u0438\u043d\u044bxs \u0444\u0430\u0441\u0435\u0442\u043e\u0431\u0440\u0430\u0437\u0446\u0430xs \u0444\u0430\u0441\u0435\u0442\u043e\u0431\u0449\u0435\u0433\u043e\u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u0430\u0440\u0430\u0437\u0440\u044f\u0434\u043e\u0432xs \u0444\u0430\u0441\u0435\u0442\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044fxs \u0444\u0430\u0441\u0435\u0442\u043f\u0440\u043e\u0431\u0435\u043b\u044c\u043d\u044b\u0445\u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432xs \u0444\u0438\u043b\u044c\u0442\u0440\u0443\u0437\u043b\u043e\u0432dom \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f\u0441\u0442\u0440\u043e\u043a\u0430 \u0444\u043e\u0440\u043c\u0430\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0444\u0440\u0430\u0433\u043c\u0435\u043d\u0442xs \u0445\u0435\u0448\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u0446\u0432\u0435\u0442 \u0447\u0442\u0435\u043d\u0438\u0435fastinfoset \u0447\u0442\u0435\u043d\u0438\u0435html \u0447\u0442\u0435\u043d\u0438\u0435json \u0447\u0442\u0435\u043d\u0438\u0435xml \u0447\u0442\u0435\u043d\u0438\u0435zip\u0444\u0430\u0439\u043b\u0430 \u0447\u0442\u0435\u043d\u0438\u0435\u0434\u0430\u043d\u043d\u044b\u0445 \u0447\u0442\u0435\u043d\u0438\u0435\u0442\u0435\u043a\u0441\u0442\u0430 \u0447\u0442\u0435\u043d\u0438\u0435\u0443\u0437\u043b\u043e\u0432dom \u0448\u0440\u0438\u0444\u0442 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u0430\u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438\u0434\u0430\u043d\u043d\u044b\u0445 comsafearray \u0434\u0435\u0440\u0435\u0432\u043e\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u043c\u0430\u0441\u0441\u0438\u0432 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435 \u0441\u043f\u0438\u0441\u043e\u043a\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0442\u0430\u0431\u043b\u0438\u0446\u0430\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0435\u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0438\u0435 \u0444\u0438\u043a\u0441\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u043c\u0430\u0441\u0441\u0438\u0432 ",
-literal:e},contains:[{className:"meta",begin:"#|&",end:"$",keywords:{$pattern:x,
-"meta-keyword":n+"\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c\u0438\u0437\u0444\u0430\u0439\u043b\u0430 \u0432\u0435\u0431\u043a\u043b\u0438\u0435\u043d\u0442 \u0432\u043c\u0435\u0441\u0442\u043e \u0432\u043d\u0435\u0448\u043d\u0435\u0435\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442 \u043a\u043e\u043d\u0435\u0446\u043e\u0431\u043b\u0430\u0441\u0442\u0438 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043a\u043b\u0438\u0435\u043d\u0442 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0441\u0435\u0440\u0432\u0435\u0440 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435\u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043d\u0430\u043a\u043b\u0438\u0435\u043d\u0442\u0435\u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435\u0431\u0435\u0437\u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435 \u043d\u0430\u0441\u0435\u0440\u0432\u0435\u0440\u0435\u0431\u0435\u0437\u043a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u0430 \u043e\u0431\u043b\u0430\u0441\u0442\u044c \u043f\u0435\u0440\u0435\u0434 \u043f\u043e\u0441\u043b\u0435 \u0441\u0435\u0440\u0432\u0435\u0440 \u0442\u043e\u043b\u0441\u0442\u044b\u0439\u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0431\u044b\u0447\u043d\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u043e\u043b\u0441\u0442\u044b\u0439\u043a\u043b\u0438\u0435\u043d\u0442\u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u043c\u043e\u0435\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u043e\u043d\u043a\u0438\u0439\u043a\u043b\u0438\u0435\u043d\u0442 "
-},contains:[m]},{className:"function",variants:[{
-begin:"\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u0430|\u0444\u0443\u043d\u043a\u0446\u0438\u044f",
-end:"\\)",
-keywords:"\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f"
-},{
-begin:"\u043a\u043e\u043d\u0435\u0446\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b|\u043a\u043e\u043d\u0435\u0446\u0444\u0443\u043d\u043a\u0446\u0438\u0438",
-keywords:"\u043a\u043e\u043d\u0435\u0446\u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b \u043a\u043e\u043d\u0435\u0446\u0444\u0443\u043d\u043a\u0446\u0438\u0438"
-}],contains:[{begin:"\\(",end:"\\)",endsParent:!0,contains:[{className:"params",
-begin:x,end:",",excludeEnd:!0,endsWithParent:!0,keywords:{$pattern:x,
-keyword:"\u0437\u043d\u0430\u0447",literal:e},contains:[o,t,a]},m]
-},s.inherit(s.TITLE_MODE,{begin:x})]},m,{className:"symbol",begin:"~",end:";|:",
-excludeEnd:!0},o,t,a]}}})());
-hljs.registerLanguage("abnf",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(a=e)?"string"==typeof a?a:a.source:null;var a
-})).join("")}return a=>{const s={ruleDeclaration:/^[a-zA-Z][a-zA-Z0-9-]*/,
-unexpectedChars:/[!@#$^&',?+~`|:]/},n=a.COMMENT(/;/,/$/),r={
-className:"attribute",begin:e(s.ruleDeclaration,/(?=\s*=)/)};return{
-name:"Augmented Backus-Naur Form",illegal:s.unexpectedChars,
-keywords:["ALPHA","BIT","CHAR","CR","CRLF","CTL","DIGIT","DQUOTE","HEXDIG","HTAB","LF","LWSP","OCTET","SP","VCHAR","WSP"],
-contains:[r,n,{className:"symbol",begin:/%b[0-1]+(-[0-1]+|(\.[0-1]+)+){0,1}/},{
-className:"symbol",begin:/%d[0-9]+(-[0-9]+|(\.[0-9]+)+){0,1}/},{
-className:"symbol",begin:/%x[0-9A-F]+(-[0-9A-F]+|(\.[0-9A-F]+)+){0,1}/},{
-className:"symbol",begin:/%[si]/},a.QUOTE_STRING_MODE,a.NUMBER_MODE]}}})());
-hljs.registerLanguage("accesslog",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}function a(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
-const l=["GET","POST","HEAD","PUT","DELETE","CONNECT","OPTIONS","PATCH","TRACE"]
-;return{name:"Apache Access Log",contains:[{className:"number",
-begin:/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?\b/,relevance:5},{
-className:"number",begin:/\b\d+\b/,relevance:0},{className:"string",
-begin:n(/"/,a(...l)),end:/"/,keywords:l,illegal:/\n/,relevance:5,contains:[{
-begin:/HTTP\/[12]\.\d'/,relevance:5}]},{className:"string",
-begin:/\[\d[^\]\n]{8,}\]/,illegal:/\n/,relevance:1},{className:"string",
-begin:/\[/,end:/\]/,illegal:/\n/,relevance:0},{className:"string",
-begin:/"Mozilla\/\d\.\d \(/,end:/"/,illegal:/\n/,relevance:3},{
-className:"string",begin:/"/,end:/"/,illegal:/\n/,relevance:0}]}}})());
-hljs.registerLanguage("actionscript",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>({name:"ActionScript",aliases:["as"],keywords:{
-keyword:"as break case catch class const continue default delete do dynamic each else extends final finally for function get if implements import in include instanceof interface internal is namespace native new override package private protected public return set static super switch this throw try typeof use var void while with",
-literal:"true false null undefined"},
-contains:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.C_NUMBER_MODE,{
-className:"class",beginKeywords:"package",end:/\{/,contains:[n.TITLE_MODE]},{
-className:"class",beginKeywords:"class interface",end:/\{/,excludeEnd:!0,
-contains:[{beginKeywords:"extends implements"},n.TITLE_MODE]},{className:"meta",
-beginKeywords:"import include",end:/;/,keywords:{"meta-keyword":"import include"
-}},{className:"function",beginKeywords:"function",end:/[{;]/,excludeEnd:!0,
-illegal:/\S/,contains:[n.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,
-contains:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,{
-className:"rest_arg",begin:/[.]{3}/,end:/[a-zA-Z_$][a-zA-Z0-9_$]*/,relevance:10
-}]},{begin:e(/:\s*/,/([*]|[a-zA-Z_$][a-zA-Z0-9_$]*)/)}]},n.METHOD_GUARD],
-illegal:/#/})})());
-hljs.registerLanguage("ada",(()=>{"use strict";return e=>{
-const n="[A-Za-z](_?[A-Za-z0-9.])*",s="[]\\{\\}%#'\"",a=e.COMMENT("--","$"),r={
-begin:"\\s+:\\s+",end:"\\s*(:=|;|\\)|=>|$)",illegal:s,contains:[{
-beginKeywords:"loop for declare others",endsParent:!0},{className:"keyword",
-beginKeywords:"not null constant access function procedure in out aliased exception"
-},{className:"type",begin:n,endsParent:!0,relevance:0}]};return{name:"Ada",
-case_insensitive:!0,keywords:{
-keyword:"abort else new return abs elsif not reverse abstract end accept entry select access exception of separate aliased exit or some all others subtype and for out synchronized array function overriding at tagged generic package task begin goto pragma terminate body private then if procedure type case in protected constant interface is raise use declare range delay limited record when delta loop rem while digits renames with do mod requeue xor",
-literal:"True False"},contains:[a,{className:"string",begin:/"/,end:/"/,
-contains:[{begin:/""/,relevance:0}]},{className:"string",begin:/'.'/},{
-className:"number",
-begin:"\\b(\\d(_|\\d)*#\\w+(\\.\\w+)?#([eE][-+]?\\d(_|\\d)*)?|\\d(_|\\d)*(\\.\\d(_|\\d)*)?([eE][-+]?\\d(_|\\d)*)?)",
-relevance:0},{className:"symbol",begin:"'"+n},{className:"title",
-begin:"(\\bwith\\s+)?(\\bprivate\\s+)?\\bpackage\\s+(\\bbody\\s+)?",
-end:"(is|$)",keywords:"package body",excludeBegin:!0,excludeEnd:!0,illegal:s},{
-begin:"(\\b(with|overriding)\\s+)?\\b(function|procedure)\\s+",
-end:"(\\bis|\\bwith|\\brenames|\\)\\s*;)",
-keywords:"overriding function procedure with is renames return",returnBegin:!0,
-contains:[a,{className:"title",
-begin:"(\\bwith\\s+)?\\b(function|procedure)\\s+",end:"(\\(|\\s+|$)",
-excludeBegin:!0,excludeEnd:!0,illegal:s},r,{className:"type",
-begin:"\\breturn\\s+",end:"(\\s+|;|$)",keywords:"return",excludeBegin:!0,
-excludeEnd:!0,endsParent:!0,illegal:s}]},{className:"type",
-begin:"\\b(sub)?type\\s+",end:"\\s+",keywords:"type",excludeBegin:!0,illegal:s
-},r]}}})());
-hljs.registerLanguage("angelscript",(()=>{"use strict";return e=>{var n={
-className:"built_in",
-begin:"\\b(void|bool|int|int8|int16|int32|int64|uint|uint8|uint16|uint32|uint64|string|ref|array|double|float|auto|dictionary)"
-},a={className:"symbol",begin:"[a-zA-Z0-9_]+@"},i={className:"keyword",
-begin:"<",end:">",contains:[n,a]};return n.contains=[i],a.contains=[i],{
-name:"AngelScript",aliases:["asc"],
-keywords:"for in|0 break continue while do|0 return if else case switch namespace is cast or and xor not get|0 in inout|10 out override set|0 private public const default|0 final shared external mixin|10 enum typedef funcdef this super import from interface abstract|0 try catch protected explicit property",
-illegal:"(^using\\s+[A-Za-z0-9_\\.]+;$|\\bfunction\\s*[^\\(])",contains:[{
-className:"string",begin:"'",end:"'",illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE],relevance:0},{className:"string",begin:'"""',
-end:'"""'},{className:"string",begin:'"',end:'"',illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE],relevance:0
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
-begin:"^\\s*\\[",end:"\\]"},{beginKeywords:"interface namespace",end:/\{/,
-illegal:"[;.\\-]",contains:[{className:"symbol",begin:"[a-zA-Z0-9_]+"}]},{
-beginKeywords:"class",end:/\{/,illegal:"[;.\\-]",contains:[{className:"symbol",
-begin:"[a-zA-Z0-9_]+",contains:[{begin:"[:,]\\s*",contains:[{className:"symbol",
-begin:"[a-zA-Z0-9_]+"}]}]}]},n,a,{className:"literal",
-begin:"\\b(null|true|false)"},{className:"number",relevance:0,
-begin:"(-?)(\\b0[xXbBoOdD][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?f?|\\.\\d+f?)([eE][-+]?\\d+f?)?)"
-}]}}})());
-hljs.registerLanguage("apache",(()=>{"use strict";return e=>{const n={
-className:"number",begin:/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?/}
-;return{name:"Apache config",aliases:["apacheconf"],case_insensitive:!0,
-contains:[e.HASH_COMMENT_MODE,{className:"section",begin:/<\/?/,end:/>/,
-contains:[n,{className:"number",begin:/:\d{1,5}/
-},e.inherit(e.QUOTE_STRING_MODE,{relevance:0})]},{className:"attribute",
-begin:/\w+/,relevance:0,keywords:{
-nomarkup:"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername"
-},starts:{end:/$/,relevance:0,keywords:{literal:"on off all deny allow"},
-contains:[{className:"meta",begin:/\s\[/,end:/\]$/},{className:"variable",
-begin:/[\$%]\{/,end:/\}/,contains:["self",{className:"number",begin:/[$%]\d+/}]
-},n,{className:"number",begin:/\d+/},e.QUOTE_STRING_MODE]}}],illegal:/\S/}}
-})());
-hljs.registerLanguage("applescript",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function t(...t){
-return t.map((t=>e(t))).join("")}function n(...t){
-return"("+t.map((t=>e(t))).join("|")+")"}return e=>{
-const r=e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),i={className:"params",
-begin:/\(/,end:/\)/,contains:["self",e.C_NUMBER_MODE,r]
-},o=e.COMMENT(/--/,/$/),a=[o,e.COMMENT(/\(\*/,/\*\)/,{contains:["self",o]
-}),e.HASH_COMMENT_MODE];return{name:"AppleScript",aliases:["osascript"],
-keywords:{
-keyword:"about above after against and around as at back before beginning behind below beneath beside between but by considering contain contains continue copy div does eighth else end equal equals error every exit fifth first for fourth from front get given global if ignoring in into is it its last local me middle mod my ninth not of on onto or over prop property put ref reference repeat returning script second set seventh since sixth some tell tenth that the|0 then third through thru timeout times to transaction try until where while whose with without",
-literal:"AppleScript false linefeed return pi quote result space tab true",
-built_in:"alias application boolean class constant date file integer list number real record string text activate beep count delay launch log offset read round run say summarize write character characters contents day frontmost id item length month name paragraph paragraphs rest reverse running time version weekday word words year"
-},contains:[r,e.C_NUMBER_MODE,{className:"built_in",
-begin:t(/\b/,n(/clipboard info/,/the clipboard/,/info for/,/list (disks|folder)/,/mount volume/,/path to/,/(close|open for) access/,/(get|set) eof/,/current date/,/do shell script/,/get volume settings/,/random number/,/set volume/,/system attribute/,/system info/,/time to GMT/,/(load|run|store) script/,/scripting components/,/ASCII (character|number)/,/localized string/,/choose (application|color|file|file name|folder|from list|remote application|URL)/,/display (alert|dialog)/),/\b/)
-},{className:"built_in",begin:/^\s*return\b/},{className:"literal",
-begin:/\b(text item delimiters|current application|missing value)\b/},{
-className:"keyword",
-begin:t(/\b/,n(/apart from/,/aside from/,/instead of/,/out of/,/greater than/,/isn't|(doesn't|does not) (equal|come before|come after|contain)/,/(greater|less) than( or equal)?/,/(starts?|ends|begins?) with/,/contained by/,/comes (before|after)/,/a (ref|reference)/,/POSIX (file|path)/,/(date|time) string/,/quoted form/),/\b/)
-},{beginKeywords:"on",illegal:/[${=;\n]/,contains:[e.UNDERSCORE_TITLE_MODE,i]
-},...a],illegal:/\/\/|->|=>|\[\[/}}})());
-hljs.registerLanguage("arcade",(()=>{"use strict";return e=>{
-const n="[A-Za-z_][0-9A-Za-z_]*",a={
-keyword:"if for while var new function do return void else break",
-literal:"BackSlash DoubleQuote false ForwardSlash Infinity NaN NewLine null PI SingleQuote Tab TextFormatting true undefined",
-built_in:"Abs Acos Angle Attachments Area AreaGeodetic Asin Atan Atan2 Average Bearing Boolean Buffer BufferGeodetic Ceil Centroid Clip Console Constrain Contains Cos Count Crosses Cut Date DateAdd DateDiff Day Decode DefaultValue Dictionary Difference Disjoint Distance DistanceGeodetic Distinct DomainCode DomainName Equals Exp Extent Feature FeatureSet FeatureSetByAssociation FeatureSetById FeatureSetByPortalItem FeatureSetByRelationshipName FeatureSetByTitle FeatureSetByUrl Filter First Floor Geometry GroupBy Guid HasKey Hour IIf IndexOf Intersection Intersects IsEmpty IsNan IsSelfIntersecting Length LengthGeodetic Log Max Mean Millisecond Min Minute Month MultiPartToSinglePart Multipoint NextSequenceValue Now Number OrderBy Overlaps Point Polygon Polyline Portal Pow Random Relate Reverse RingIsClockWise Round Second SetGeometry Sin Sort Sqrt Stdev Sum SymmetricDifference Tan Text Timestamp Today ToLocal Top Touches ToUTC TrackCurrentTime TrackGeometryWindow TrackIndex TrackStartTime TrackWindow TypeOf Union UrlEncode Variance Weekday When Within Year "
-},t={className:"number",variants:[{begin:"\\b(0[bB][01]+)"},{
-begin:"\\b(0[oO][0-7]+)"},{begin:e.C_NUMBER_RE}],relevance:0},i={
-className:"subst",begin:"\\$\\{",end:"\\}",keywords:a,contains:[]},r={
-className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE,i]}
-;i.contains=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,t,e.REGEXP_MODE]
-;const o=i.contains.concat([e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE])
-;return{name:"ArcGIS Arcade",keywords:a,
-contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"symbol",
-begin:"\\$[datastore|feature|layer|map|measure|sourcefeature|sourcelayer|targetfeature|targetlayer|value|view]+"
-},t,{begin:/[{,]\s*/,relevance:0,contains:[{begin:n+"\\s*:",returnBegin:!0,
-relevance:0,contains:[{className:"attr",begin:n,relevance:0}]}]},{
-begin:"("+e.RE_STARTERS_RE+"|\\b(return)\\b)\\s*",keywords:"return",
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.REGEXP_MODE,{
-className:"function",begin:"(\\(.*?\\)|"+n+")\\s*=>",returnBegin:!0,
-end:"\\s*=>",contains:[{className:"params",variants:[{begin:n},{begin:/\(\s*\)/
-},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:a,contains:o}]}]
-}],relevance:0},{className:"function",beginKeywords:"function",end:/\{/,
-excludeEnd:!0,contains:[e.inherit(e.TITLE_MODE,{begin:n}),{className:"params",
-begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,contains:o}],illegal:/\[|%/},{
-begin:/\$[(.]/}],illegal:/#(?!!)/}}})());
-hljs.registerLanguage("arduino",(()=>{"use strict";function e(e){
-return t("(",e,")?")}function t(...e){return e.map((e=>{
-return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return r=>{
-const n=(r=>{const n=r.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),i="[a-zA-Z_]\\w*::",a="(decltype\\(auto\\)|"+e(i)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",s={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[r.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},r.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},r.inherit(o,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},n,r.C_BLOCK_COMMENT_MODE]},d={
-className:"title",begin:e(i)+r.IDENT_RE,relevance:0
-},u=e(i)+r.IDENT_RE+"\\s*\\(",m={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"_Bool _Complex _Imaginary",
-_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
-literal:"true false nullptr NULL"},p={className:"function.dispatch",relevance:0,
-keywords:m,
-begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,r.IDENT_RE,(g=/\s*\(/,
-t("(?=",g,")")))};var g;const b=[p,c,s,n,r.C_BLOCK_COMMENT_MODE,l,o],_={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:m,contains:b.concat([{
-begin:/\(/,end:/\)/,keywords:m,contains:b.concat(["self"]),relevance:0}]),
-relevance:0},y={className:"function",begin:"("+a+"[\\*&\\s]+)+"+u,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:m,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:m,relevance:0},{begin:u,
-returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[o,l]},{className:"params",begin:/\(/,end:/\)/,
-keywords:m,relevance:0,contains:[n,r.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,
-end:/\)/,keywords:m,relevance:0,contains:["self",n,r.C_BLOCK_COMMENT_MODE,o,l,s]
-}]},s,n,r.C_BLOCK_COMMENT_MODE,c]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:m,illegal:"</",
-classNameAliases:{"function.dispatch":"built_in"},
-contains:[].concat(_,y,p,b,[c,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:m,contains:["self",s]},{begin:r.IDENT_RE+"::",keywords:m},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},r.TITLE_MODE]}]),exports:{
-preprocessor:c,strings:o,keywords:m}}})(r),i=n.keywords
-;return i.keyword+=" boolean byte word String",
-i.literal+=" DIGITAL_MESSAGE FIRMATA_STRING ANALOG_MESSAGE REPORT_DIGITAL REPORT_ANALOG INPUT_PULLUP SET_PIN_MODE INTERNAL2V56 SYSTEM_RESET LED_BUILTIN INTERNAL1V1 SYSEX_START INTERNAL EXTERNAL DEFAULT OUTPUT INPUT HIGH LOW",
-i.built_in+=" KeyboardController MouseController SoftwareSerial EthernetServer EthernetClient LiquidCrystal RobotControl GSMVoiceCall EthernetUDP EsploraTFT HttpClient RobotMotor WiFiClient GSMScanner FileSystem Scheduler GSMServer YunClient YunServer IPAddress GSMClient GSMModem Keyboard Ethernet Console GSMBand Esplora Stepper Process WiFiUDP GSM_SMS Mailbox USBHost Firmata PImage Client Server GSMPIN FileIO Bridge Serial EEPROM Stream Mouse Audio Servo File Task GPRS WiFi Wire TFT GSM SPI SD ",
-i._+=" setup loop runShellCommandAsynchronously analogWriteResolution retrieveCallingNumber printFirmwareVersion analogReadResolution sendDigitalPortPair noListenOnLocalhost readJoystickButton setFirmwareVersion readJoystickSwitch scrollDisplayRight getVoiceCallStatus scrollDisplayLeft writeMicroseconds delayMicroseconds beginTransmission getSignalStrength runAsynchronously getAsynchronously listenOnLocalhost getCurrentCarrier readAccelerometer messageAvailable sendDigitalPorts lineFollowConfig countryNameWrite runShellCommand readStringUntil rewindDirectory readTemperature setClockDivider readLightSensor endTransmission analogReference detachInterrupt countryNameRead attachInterrupt encryptionType readBytesUntil robotNameWrite readMicrophone robotNameRead cityNameWrite userNameWrite readJoystickY readJoystickX mouseReleased openNextFile scanNetworks noInterrupts digitalWrite beginSpeaker mousePressed isActionDone mouseDragged displayLogos noAutoscroll addParameter remoteNumber getModifiers keyboardRead userNameRead waitContinue processInput parseCommand printVersion readNetworks writeMessage blinkVersion cityNameRead readMessage setDataMode parsePacket isListening setBitOrder beginPacket isDirectory motorsWrite drawCompass digitalRead clearScreen serialEvent rightToLeft setTextSize leftToRight requestFrom keyReleased compassRead analogWrite interrupts WiFiServer disconnect playMelody parseFloat autoscroll getPINUsed setPINUsed setTimeout sendAnalog readSlider analogRead beginWrite createChar motorsStop keyPressed tempoWrite readButton subnetMask debugPrint macAddress writeGreen randomSeed attachGPRS readString sendString remotePort releaseAll mouseMoved background getXChange getYChange answerCall getResult voiceCall endPacket constrain getSocket writeJSON getButton available connected findUntil readBytes exitValue readGreen writeBlue startLoop IPAddress isPressed sendSysex pauseMode gatewayIP setCursor getOemKey tuneWrite noDisplay loadImage switchPIN onRequest onReceive changePIN playFile noBuffer parseInt overflow checkPIN knobRead beginTFT bitClear updateIR bitWrite position writeRGB highByte writeRed setSpeed readBlue noStroke remoteIP transfer shutdown hangCall beginSMS endWrite attached maintain noCursor checkReg checkPUK shiftOut isValid shiftIn pulseIn connect println localIP pinMode getIMEI display noBlink process getBand running beginSD drawBMP lowByte setBand release bitRead prepare pointTo readRed setMode noFill remove listen stroke detach attach noTone exists buffer height bitSet circle config cursor random IRread setDNS endSMS getKey micros millis begin print write ready flush width isPIN blink clear press mkdir rmdir close point yield image BSSID click delay read text move peek beep rect line open seek fill size turn stop home find step tone sqrt RSSI SSID end bit tan cos sin pow map abs max min get run put",
-n.name="Arduino",n.aliases=["ino"],n.supersetOf="cpp",n}})());
-hljs.registerLanguage("armasm",(()=>{"use strict";return s=>{const e={
-variants:[s.COMMENT("^[ \\t]*(?=#)","$",{relevance:0,excludeBegin:!0
-}),s.COMMENT("[;@]","$",{relevance:0
-}),s.C_LINE_COMMENT_MODE,s.C_BLOCK_COMMENT_MODE]};return{name:"ARM Assembly",
-case_insensitive:!0,aliases:["arm"],keywords:{$pattern:"\\.?"+s.IDENT_RE,
-meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .arm .thumb .code16 .code32 .force_thumb .thumb_func .ltorg ALIAS ALIGN ARM AREA ASSERT ATTR CN CODE CODE16 CODE32 COMMON CP DATA DCB DCD DCDU DCDO DCFD DCFDU DCI DCQ DCQU DCW DCWU DN ELIF ELSE END ENDFUNC ENDIF ENDP ENTRY EQU EXPORT EXPORTAS EXTERN FIELD FILL FUNCTION GBLA GBLL GBLS GET GLOBAL IF IMPORT INCBIN INCLUDE INFO KEEP LCLA LCLL LCLS LTORG MACRO MAP MEND MEXIT NOFP OPT PRESERVE8 PROC QN READONLY RELOC REQUIRE REQUIRE8 RLIST FN ROUT SETA SETL SETS SN SPACE SUBT THUMB THUMBX TTL WHILE WEND ",
-built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 pc lr sp ip sl sb fp a1 a2 a3 a4 v1 v2 v3 v4 v5 v6 v7 v8 f0 f1 f2 f3 f4 f5 f6 f7 p0 p1 p2 p3 p4 p5 p6 p7 p8 p9 p10 p11 p12 p13 p14 p15 c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14 c15 q0 q1 q2 q3 q4 q5 q6 q7 q8 q9 q10 q11 q12 q13 q14 q15 cpsr_c cpsr_x cpsr_s cpsr_f cpsr_cx cpsr_cxs cpsr_xs cpsr_xsf cpsr_sf cpsr_cxsf spsr_c spsr_x spsr_s spsr_f spsr_cx spsr_cxs spsr_xs spsr_xsf spsr_sf spsr_cxsf s0 s1 s2 s3 s4 s5 s6 s7 s8 s9 s10 s11 s12 s13 s14 s15 s16 s17 s18 s19 s20 s21 s22 s23 s24 s25 s26 s27 s28 s29 s30 s31 d0 d1 d2 d3 d4 d5 d6 d7 d8 d9 d10 d11 d12 d13 d14 d15 d16 d17 d18 d19 d20 d21 d22 d23 d24 d25 d26 d27 d28 d29 d30 d31 {PC} {VAR} {TRUE} {FALSE} {OPT} {CONFIG} {ENDIAN} {CODESIZE} {CPU} {FPU} {ARCHITECTURE} {PCSTOREOFFSET} {ARMASM_VERSION} {INTER} {ROPI} {RWPI} {SWST} {NOSWST} . @"
-},contains:[{className:"keyword",
-begin:"\\b(adc|(qd?|sh?|u[qh]?)?add(8|16)?|usada?8|(q|sh?|u[qh]?)?(as|sa)x|and|adrl?|sbc|rs[bc]|asr|b[lx]?|blx|bxj|cbn?z|tb[bh]|bic|bfc|bfi|[su]bfx|bkpt|cdp2?|clz|clrex|cmp|cmn|cpsi[ed]|cps|setend|dbg|dmb|dsb|eor|isb|it[te]{0,3}|lsl|lsr|ror|rrx|ldm(([id][ab])|f[ds])?|ldr((s|ex)?[bhd])?|movt?|mvn|mra|mar|mul|[us]mull|smul[bwt][bt]|smu[as]d|smmul|smmla|mla|umlaal|smlal?([wbt][bt]|d)|mls|smlsl?[ds]|smc|svc|sev|mia([bt]{2}|ph)?|mrr?c2?|mcrr2?|mrs|msr|orr|orn|pkh(tb|bt)|rbit|rev(16|sh)?|sel|[su]sat(16)?|nop|pop|push|rfe([id][ab])?|stm([id][ab])?|str(ex)?[bhd]?|(qd?)?sub|(sh?|q|u[qh]?)?sub(8|16)|[su]xt(a?h|a?b(16)?)|srs([id][ab])?|swpb?|swi|smi|tst|teq|wfe|wfi|yield)(eq|ne|cs|cc|mi|pl|vs|vc|hi|ls|ge|lt|gt|le|al|hs|lo)?[sptrx]?(?=\\s)"
-},e,s.QUOTE_STRING_MODE,{className:"string",begin:"'",end:"[^\\\\]'",relevance:0
-},{className:"title",begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{
-className:"number",variants:[{begin:"[#$=]?0x[0-9a-f]+"},{begin:"[#$=]?0b[01]+"
-},{begin:"[#$=]\\d+"},{begin:"\\b\\d+"}],relevance:0},{className:"symbol",
-variants:[{begin:"^[ \\t]*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{
-begin:"^[a-z_\\.\\$][a-z0-9_\\.\\$]+"},{begin:"[=#]\\w+"}],relevance:0}]}}})());
-hljs.registerLanguage("xml",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(e){return a("(?=",e,")")}
-function a(...n){return n.map((n=>e(n))).join("")}function s(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
-const t=a(/[A-Z_]/,a("(",/[A-Z0-9_.-]*:/,")?"),/[A-Z0-9_.-]*/),i={
-className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},r={begin:/\s/,
-contains:[{className:"meta-keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]
-},c=e.inherit(r,{begin:/\(/,end:/\)/}),l=e.inherit(e.APOS_STRING_MODE,{
-className:"meta-string"}),g=e.inherit(e.QUOTE_STRING_MODE,{
-className:"meta-string"}),m={endsWithParent:!0,illegal:/</,relevance:0,
-contains:[{className:"attr",begin:/[A-Za-z0-9._:-]+/,relevance:0},{begin:/=\s*/,
-relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/,
-end:/"/,contains:[i]},{begin:/'/,end:/'/,contains:[i]},{begin:/[^\s"'=<>`]+/}]}]
-}]};return{name:"HTML, XML",
-aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],
-case_insensitive:!0,contains:[{className:"meta",begin:/<![a-z]/,end:/>/,
-relevance:10,contains:[r,g,l,c,{begin:/\[/,end:/\]/,contains:[{className:"meta",
-begin:/<![a-z]/,end:/>/,contains:[r,c,g,l]}]}]},e.COMMENT(/<!--/,/-->/,{
-relevance:10}),{begin:/<!\[CDATA\[/,end:/\]\]>/,relevance:10},i,{
-className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",
-begin:/<style(?=\s|>)/,end:/>/,keywords:{name:"style"},contains:[m],starts:{
-end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",
-begin:/<script(?=\s|>)/,end:/>/,keywords:{name:"script"},contains:[m],starts:{
-end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{
-className:"tag",begin:/<>|<\/>/},{className:"tag",
-begin:a(/</,n(a(t,s(/\/>/,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name",
-begin:t,relevance:0,starts:m}]},{className:"tag",begin:a(/<\//,n(a(t,/>/))),
-contains:[{className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,
-endsParent:!0}]}]}}})());
-hljs.registerLanguage("asciidoc",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a=[{className:"strong",begin:/\*{2}([^\n]+?)\*{2}/
-},{className:"strong",
-begin:e(/\*\*/,/((\*(?!\*)|\\[^\n]|[^*\n\\])+\n)+/,/(\*(?!\*)|\\[^\n]|[^*\n\\])*/,/\*\*/),
-relevance:0},{className:"strong",begin:/\B\*(\S|\S[^\n]*?\S)\*(?!\w)/},{
-className:"strong",begin:/\*[^\s]([^\n]+\n)+([^\n]+)\*/}],s=[{
-className:"emphasis",begin:/_{2}([^\n]+?)_{2}/},{className:"emphasis",
-begin:e(/__/,/((_(?!_)|\\[^\n]|[^_\n\\])+\n)+/,/(_(?!_)|\\[^\n]|[^_\n\\])*/,/__/),
-relevance:0},{className:"emphasis",begin:/\b_(\S|\S[^\n]*?\S)_(?!\w)/},{
-className:"emphasis",begin:/_[^\s]([^\n]+\n)+([^\n]+)_/},{className:"emphasis",
-begin:"\\B'(?!['\\s])",end:"(\\n{2}|')",contains:[{begin:"\\\\'\\w",relevance:0
-}],relevance:0}];return{name:"AsciiDoc",aliases:["adoc"],
-contains:[n.COMMENT("^/{4,}\\n","\\n/{4,}$",{relevance:10
-}),n.COMMENT("^//","$",{relevance:0}),{className:"title",begin:"^\\.\\w.*$"},{
-begin:"^[=\\*]{4,}\\n",end:"\\n^[=\\*]{4,}$",relevance:10},{className:"section",
-relevance:10,variants:[{begin:"^(={1,6})[ \t].+?([ \t]\\1)?$"},{
-begin:"^[^\\[\\]\\n]+?\\n[=\\-~\\^\\+]{2,}$"}]},{className:"meta",
-begin:"^:.+?:",end:"\\s",excludeEnd:!0,relevance:10},{className:"meta",
-begin:"^\\[.+?\\]$",relevance:0},{className:"quote",begin:"^_{4,}\\n",
-end:"\\n_{4,}$",relevance:10},{className:"code",begin:"^[\\-\\.]{4,}\\n",
-end:"\\n[\\-\\.]{4,}$",relevance:10},{begin:"^\\+{4,}\\n",end:"\\n\\+{4,}$",
-contains:[{begin:"<",end:">",subLanguage:"xml",relevance:0}],relevance:10},{
-className:"bullet",begin:"^(\\*+|-+|\\.+|[^\\n]+?::)\\s+"},{className:"symbol",
-begin:"^(NOTE|TIP|IMPORTANT|WARNING|CAUTION):\\s+",relevance:10},{
-begin:/\\[*_`]/},{begin:/\\\\\*{2}[^\n]*?\*{2}/},{begin:/\\\\_{2}[^\n]*_{2}/},{
-begin:/\\\\`{2}[^\n]*`{2}/},{begin:/[:;}][*_`](?![*_`])/},...a,...s,{
-className:"string",variants:[{begin:"``.+?''"},{begin:"`.+?'"}]},{
-className:"code",begin:/`{2}/,end:/(\n{2}|`{2})/},{className:"code",
-begin:"(`.+?`|\\+.+?\\+)",relevance:0},{className:"code",begin:"^[ \\t]",
-end:"$",relevance:0},{begin:"^'{3,}[ \\t]*$",relevance:10},{
-begin:"(link:)?(http|https|ftp|file|irc|image:?):\\S+?\\[[^[]*?\\]",
-returnBegin:!0,contains:[{begin:"(link|image:?):",relevance:0},{
-className:"link",begin:"\\w",end:"[^\\[]+",relevance:0},{className:"string",
-begin:"\\[",end:"\\]",excludeBegin:!0,excludeEnd:!0,relevance:0}],relevance:10}]
-}}})());
-hljs.registerLanguage("aspectj",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{
-const t="false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else extends implements break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws privileged aspectOf adviceexecution proceed cflowbelow cflow initialization preinitialization staticinitialization withincode target within execution getWithinTypeName handler thisJoinPoint thisJoinPointStaticPart thisEnclosingJoinPointStaticPart declare parents warning error soft precedence thisAspectInstance",i="get set args call"
-;return{name:"AspectJ",keywords:t,illegal:/<\/|#/,
-contains:[n.COMMENT(/\/\*\*/,/\*\//,{relevance:0,contains:[{begin:/\w+@/,
-relevance:0},{className:"doctag",begin:/@[A-Za-z]+/}]
-}),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,{
-className:"class",beginKeywords:"aspect",end:/[{;=]/,excludeEnd:!0,
-illegal:/[:;"\[\]]/,contains:[{
-beginKeywords:"extends implements pertypewithin perthis pertarget percflowbelow percflow issingleton"
-},n.UNDERSCORE_TITLE_MODE,{begin:/\([^\)]*/,end:/[)]+/,keywords:t+" "+i,
-excludeEnd:!1}]},{className:"class",beginKeywords:"class interface",end:/[{;=]/,
-excludeEnd:!0,relevance:0,keywords:"class interface",illegal:/[:"\[\]]/,
-contains:[{beginKeywords:"extends implements"},n.UNDERSCORE_TITLE_MODE]},{
-beginKeywords:"pointcut after before around throwing returning",end:/[)]/,
-excludeEnd:!1,illegal:/["\[\]]/,contains:[{
-begin:e(n.UNDERSCORE_IDENT_RE,/\s*\(/),returnBegin:!0,
-contains:[n.UNDERSCORE_TITLE_MODE]}]},{begin:/[:]/,returnBegin:!0,end:/[{;]/,
-relevance:0,excludeEnd:!1,keywords:t,illegal:/["\[\]]/,contains:[{
-begin:e(n.UNDERSCORE_IDENT_RE,/\s*\(/),keywords:t+" "+i,relevance:0
-},n.QUOTE_STRING_MODE]},{beginKeywords:"new throw",relevance:0},{
-className:"function",
-begin:/\w+ +\w+(\.\w+)?\s*\([^\)]*\)\s*((throws)[\w\s,]+)?[\{;]/,returnBegin:!0,
-end:/[{;=]/,keywords:t,excludeEnd:!0,contains:[{
-begin:e(n.UNDERSCORE_IDENT_RE,/\s*\(/),returnBegin:!0,relevance:0,
-contains:[n.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,
-relevance:0,keywords:t,
-contains:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.C_NUMBER_MODE,n.C_BLOCK_COMMENT_MODE]
-},n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE]},n.C_NUMBER_MODE,{
-className:"meta",begin:/@[A-Za-z]+/}]}}})());
-hljs.registerLanguage("autohotkey",(()=>{"use strict";return e=>{const a={
-begin:"`[\\s\\S]"};return{name:"AutoHotkey",case_insensitive:!0,aliases:["ahk"],
-keywords:{
-keyword:"Break Continue Critical Exit ExitApp Gosub Goto New OnExit Pause return SetBatchLines SetTimer Suspend Thread Throw Until ahk_id ahk_class ahk_pid ahk_exe ahk_group",
-literal:"true false NOT AND OR",
-built_in:"ComSpec Clipboard ClipboardAll ErrorLevel"},
-contains:[a,e.inherit(e.QUOTE_STRING_MODE,{contains:[a]}),e.COMMENT(";","$",{
-relevance:0}),e.C_BLOCK_COMMENT_MODE,{className:"number",begin:e.NUMBER_RE,
-relevance:0},{className:"variable",begin:"%[a-zA-Z0-9#_$@]+%"},{
-className:"built_in",begin:"^\\s*\\w+\\s*(,|%)"},{className:"title",variants:[{
-begin:'^[^\\n";]+::(?!=)'},{begin:'^[^\\n";]+:(?!=)',relevance:0}]},{
-className:"meta",begin:"^\\s*#\\w+",end:"$",relevance:0},{className:"built_in",
-begin:"A_[a-zA-Z0-9]+"},{begin:",\\s*,"}]}}})());
-hljs.registerLanguage("autoit",(()=>{"use strict";return e=>{const t={
-variants:[e.COMMENT(";","$",{relevance:0
-}),e.COMMENT("#cs","#ce"),e.COMMENT("#comments-start","#comments-end")]},r={
-begin:"\\$[A-z0-9_]+"},i={className:"string",variants:[{begin:/"/,end:/"/,
-contains:[{begin:/""/,relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,
-relevance:0}]}]},n={variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]};return{
-name:"AutoIt",case_insensitive:!0,illegal:/\/\*/,keywords:{
-keyword:"ByRef Case Const ContinueCase ContinueLoop Dim Do Else ElseIf EndFunc EndIf EndSelect EndSwitch EndWith Enum Exit ExitLoop For Func Global If In Local Next ReDim Return Select Static Step Switch Then To Until Volatile WEnd While With",
-built_in:"Abs ACos AdlibRegister AdlibUnRegister Asc AscW ASin Assign ATan AutoItSetOption AutoItWinGetTitle AutoItWinSetTitle Beep Binary BinaryLen BinaryMid BinaryToString BitAND BitNOT BitOR BitRotate BitShift BitXOR BlockInput Break Call CDTray Ceiling Chr ChrW ClipGet ClipPut ConsoleRead ConsoleWrite ConsoleWriteError ControlClick ControlCommand ControlDisable ControlEnable ControlFocus ControlGetFocus ControlGetHandle ControlGetPos ControlGetText ControlHide ControlListView ControlMove ControlSend ControlSetText ControlShow ControlTreeView Cos Dec DirCopy DirCreate DirGetSize DirMove DirRemove DllCall DllCallAddress DllCallbackFree DllCallbackGetPtr DllCallbackRegister DllClose DllOpen DllStructCreate DllStructGetData DllStructGetPtr DllStructGetSize DllStructSetData DriveGetDrive DriveGetFileSystem DriveGetLabel DriveGetSerial DriveGetType DriveMapAdd DriveMapDel DriveMapGet DriveSetLabel DriveSpaceFree DriveSpaceTotal DriveStatus EnvGet EnvSet EnvUpdate Eval Execute Exp FileChangeDir FileClose FileCopy FileCreateNTFSLink FileCreateShortcut FileDelete FileExists FileFindFirstFile FileFindNextFile FileFlush FileGetAttrib FileGetEncoding FileGetLongName FileGetPos FileGetShortcut FileGetShortName FileGetSize FileGetTime FileGetVersion FileInstall FileMove FileOpen FileOpenDialog FileRead FileReadLine FileReadToArray FileRecycle FileRecycleEmpty FileSaveDialog FileSelectFolder FileSetAttrib FileSetEnd FileSetPos FileSetTime FileWrite FileWriteLine Floor FtpSetProxy FuncName GUICreate GUICtrlCreateAvi GUICtrlCreateButton GUICtrlCreateCheckbox GUICtrlCreateCombo GUICtrlCreateContextMenu GUICtrlCreateDate GUICtrlCreateDummy GUICtrlCreateEdit GUICtrlCreateGraphic GUICtrlCreateGroup GUICtrlCreateIcon GUICtrlCreateInput GUICtrlCreateLabel GUICtrlCreateList GUICtrlCreateListView GUICtrlCreateListViewItem GUICtrlCreateMenu GUICtrlCreateMenuItem GUICtrlCreateMonthCal GUICtrlCreateObj GUICtrlCreatePic GUICtrlCreateProgress GUICtrlCreateRadio GUICtrlCreateSlider GUICtrlCreateTab GUICtrlCreateTabItem GUICtrlCreateTreeView GUICtrlCreateTreeViewItem GUICtrlCreateUpdown GUICtrlDelete GUICtrlGetHandle GUICtrlGetState GUICtrlRead GUICtrlRecvMsg GUICtrlRegisterListViewSort GUICtrlSendMsg GUICtrlSendToDummy GUICtrlSetBkColor GUICtrlSetColor GUICtrlSetCursor GUICtrlSetData GUICtrlSetDefBkColor GUICtrlSetDefColor GUICtrlSetFont GUICtrlSetGraphic GUICtrlSetImage GUICtrlSetLimit GUICtrlSetOnEvent GUICtrlSetPos GUICtrlSetResizing GUICtrlSetState GUICtrlSetStyle GUICtrlSetTip GUIDelete GUIGetCursorInfo GUIGetMsg GUIGetStyle GUIRegisterMsg GUISetAccelerators GUISetBkColor GUISetCoord GUISetCursor GUISetFont GUISetHelp GUISetIcon GUISetOnEvent GUISetState GUISetStyle GUIStartGroup GUISwitch Hex HotKeySet HttpSetProxy HttpSetUserAgent HWnd InetClose InetGet InetGetInfo InetGetSize InetRead IniDelete IniRead IniReadSection IniReadSectionNames IniRenameSection IniWrite IniWriteSection InputBox Int IsAdmin IsArray IsBinary IsBool IsDeclared IsDllStruct IsFloat IsFunc IsHWnd IsInt IsKeyword IsNumber IsObj IsPtr IsString Log MemGetStats Mod MouseClick MouseClickDrag MouseDown MouseGetCursor MouseGetPos MouseMove MouseUp MouseWheel MsgBox Number ObjCreate ObjCreateInterface ObjEvent ObjGet ObjName OnAutoItExitRegister OnAutoItExitUnRegister Ping PixelChecksum PixelGetColor PixelSearch ProcessClose ProcessExists ProcessGetStats ProcessList ProcessSetPriority ProcessWait ProcessWaitClose ProgressOff ProgressOn ProgressSet Ptr Random RegDelete RegEnumKey RegEnumVal RegRead RegWrite Round Run RunAs RunAsWait RunWait Send SendKeepActive SetError SetExtended ShellExecute ShellExecuteWait Shutdown Sin Sleep SoundPlay SoundSetWaveVolume SplashImageOn SplashOff SplashTextOn Sqrt SRandom StatusbarGetText StderrRead StdinWrite StdioClose StdoutRead String StringAddCR StringCompare StringFormat StringFromASCIIArray StringInStr StringIsAlNum StringIsAlpha StringIsASCII StringIsDigit StringIsFloat StringIsInt StringIsLower StringIsSpace StringIsUpper StringIsXDigit StringLeft StringLen StringLower StringMid StringRegExp StringRegExpReplace StringReplace StringReverse StringRight StringSplit StringStripCR StringStripWS StringToASCIIArray StringToBinary StringTrimLeft StringTrimRight StringUpper Tan TCPAccept TCPCloseSocket TCPConnect TCPListen TCPNameToIP TCPRecv TCPSend TCPShutdown, UDPShutdown TCPStartup, UDPStartup TimerDiff TimerInit ToolTip TrayCreateItem TrayCreateMenu TrayGetMsg TrayItemDelete TrayItemGetHandle TrayItemGetState TrayItemGetText TrayItemSetOnEvent TrayItemSetState TrayItemSetText TraySetClick TraySetIcon TraySetOnEvent TraySetPauseIcon TraySetState TraySetToolTip TrayTip UBound UDPBind UDPCloseSocket UDPOpen UDPRecv UDPSend VarGetType WinActivate WinActive WinClose WinExists WinFlash WinGetCaretPos WinGetClassList WinGetClientSize WinGetHandle WinGetPos WinGetProcess WinGetState WinGetText WinGetTitle WinKill WinList WinMenuSelectItem WinMinimizeAll WinMinimizeAllUndo WinMove WinSetOnTop WinSetState WinSetTitle WinSetTrans WinWait WinWaitActive WinWaitClose WinWaitNotActive",
-literal:"True False And Null Not Or Default"},contains:[t,r,i,n,{
-className:"meta",begin:"#",end:"$",keywords:{
-"meta-keyword":["EndRegion","forcedef","forceref","ignorefunc","include","include-once","NoTrayIcon","OnAutoItStartRegister","pragma","Region","RequireAdmin","Tidy_Off","Tidy_On","Tidy_Parameters"]
-},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",keywords:{
-"meta-keyword":"include"},end:"$",contains:[i,{className:"meta-string",
-variants:[{begin:"<",end:">"},{begin:/"/,end:/"/,contains:[{begin:/""/,
-relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]}]},i,t]
-},{className:"symbol",begin:"@[A-z0-9_]+"},{className:"function",
-beginKeywords:"Func",end:"$",illegal:"\\$|\\[|%",
-contains:[e.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",
-contains:[r,i,n]}]}]}}})());
-hljs.registerLanguage("avrasm",(()=>{"use strict";return r=>({
-name:"AVR Assembly",case_insensitive:!0,keywords:{$pattern:"\\.?"+r.IDENT_RE,
-keyword:"adc add adiw and andi asr bclr bld brbc brbs brcc brcs break breq brge brhc brhs brid brie brlo brlt brmi brne brpl brsh brtc brts brvc brvs bset bst call cbi cbr clc clh cli cln clr cls clt clv clz com cp cpc cpi cpse dec eicall eijmp elpm eor fmul fmuls fmulsu icall ijmp in inc jmp ld ldd ldi lds lpm lsl lsr mov movw mul muls mulsu neg nop or ori out pop push rcall ret reti rjmp rol ror sbc sbr sbrc sbrs sec seh sbi sbci sbic sbis sbiw sei sen ser ses set sev sez sleep spm st std sts sub subi swap tst wdr",
-built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 r16 r17 r18 r19 r20 r21 r22 r23 r24 r25 r26 r27 r28 r29 r30 r31 x|0 xh xl y|0 yh yl z|0 zh zl ucsr1c udr1 ucsr1a ucsr1b ubrr1l ubrr1h ucsr0c ubrr0h tccr3c tccr3a tccr3b tcnt3h tcnt3l ocr3ah ocr3al ocr3bh ocr3bl ocr3ch ocr3cl icr3h icr3l etimsk etifr tccr1c ocr1ch ocr1cl twcr twdr twar twsr twbr osccal xmcra xmcrb eicra spmcsr spmcr portg ddrg ping portf ddrf sreg sph spl xdiv rampz eicrb eimsk gimsk gicr eifr gifr timsk tifr mcucr mcucsr tccr0 tcnt0 ocr0 assr tccr1a tccr1b tcnt1h tcnt1l ocr1ah ocr1al ocr1bh ocr1bl icr1h icr1l tccr2 tcnt2 ocr2 ocdr wdtcr sfior eearh eearl eedr eecr porta ddra pina portb ddrb pinb portc ddrc pinc portd ddrd pind spdr spsr spcr udr0 ucsr0a ucsr0b ubrr0l acsr admux adcsr adch adcl porte ddre pine pinf",
-meta:".byte .cseg .db .def .device .dseg .dw .endmacro .equ .eseg .exit .include .list .listmac .macro .nolist .org .set"
-},contains:[r.C_BLOCK_COMMENT_MODE,r.COMMENT(";","$",{relevance:0
-}),r.C_NUMBER_MODE,r.BINARY_NUMBER_MODE,{className:"number",
-begin:"\\b(\\$[a-zA-Z0-9]+|0o[0-7]+)"},r.QUOTE_STRING_MODE,{className:"string",
-begin:"'",end:"[^\\\\]'",illegal:"[^\\\\][^']"},{className:"symbol",
-begin:"^[A-Za-z0-9_.$]+:"},{className:"meta",begin:"#",end:"$"},{
-className:"subst",begin:"@[0-9]+"}]})})());
-hljs.registerLanguage("awk",(()=>{"use strict";return e=>({name:"Awk",keywords:{
-keyword:"BEGIN END if else while do for in break continue delete next nextfile function func exit|10"
-},contains:[{className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{
-begin:/\$\{(.*?)\}/}]},{className:"string",contains:[e.BACKSLASH_ESCAPE],
-variants:[{begin:/(u|b)?r?'''/,end:/'''/,relevance:10},{begin:/(u|b)?r?"""/,
-end:/"""/,relevance:10},{begin:/(u|r|ur)'/,end:/'/,relevance:10},{
-begin:/(u|r|ur)"/,end:/"/,relevance:10},{begin:/(b|br)'/,end:/'/},{
-begin:/(b|br)"/,end:/"/},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},e.REGEXP_MODE,e.HASH_COMMENT_MODE,e.NUMBER_MODE]})})());
-hljs.registerLanguage("axapta",(()=>{"use strict";return e=>({name:"X++",
-aliases:["x++"],keywords:{
-keyword:["abstract","as","asc","avg","break","breakpoint","by","byref","case","catch","changecompany","class","client","client","common","const","continue","count","crosscompany","delegate","delete_from","desc","display","div","do","edit","else","eventhandler","exists","extends","final","finally","firstfast","firstonly","firstonly1","firstonly10","firstonly100","firstonly1000","flush","for","forceliterals","forcenestedloop","forceplaceholders","forceselectorder","forupdate","from","generateonly","group","hint","if","implements","in","index","insert_recordset","interface","internal","is","join","like","maxof","minof","mod","namespace","new","next","nofetch","notexists","optimisticlock","order","outer","pessimisticlock","print","private","protected","public","readonly","repeatableread","retry","return","reverse","select","server","setting","static","sum","super","switch","this","throw","try","ttsabort","ttsbegin","ttscommit","unchecked","update_recordset","using","validtimestate","void","where","while"],
-built_in:["anytype","boolean","byte","char","container","date","double","enum","guid","int","int64","long","real","short","str","utcdatetime","var"],
-literal:["default","false","null","true"]},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"#",end:"$"},{className:"class",
-beginKeywords:"class interface",end:/\{/,excludeEnd:!0,illegal:":",contains:[{
-beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]}]})})());
-hljs.registerLanguage("bash",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(s=e)?"string"==typeof s?s:s.source:null;var s
-})).join("")}return s=>{const n={},t={begin:/\$\{/,end:/\}/,contains:["self",{
-begin:/:-/,contains:[n]}]};Object.assign(n,{className:"variable",variants:[{
-begin:e(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},t]});const a={
-className:"subst",begin:/\$\(/,end:/\)/,contains:[s.BACKSLASH_ESCAPE]},i={
-begin:/<<-?\s*(?=\w+)/,starts:{contains:[s.END_SAME_AS_BEGIN({begin:/(\w+)/,
-end:/(\w+)/,className:"string"})]}},c={className:"string",begin:/"/,end:/"/,
-contains:[s.BACKSLASH_ESCAPE,n,a]};a.contains.push(c);const o={begin:/\$\(\(/,
-end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},s.NUMBER_MODE,n]
-},r=s.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10
-}),l={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,
-contains:[s.inherit(s.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{
-name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z._-]+\b/,
-keyword:"if then else elif fi for while in do done case esac function",
-literal:"true false",
-built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp"
-},contains:[r,s.SHEBANG(),l,o,s.HASH_COMMENT_MODE,i,c,{className:"",begin:/\\"/
-},{className:"string",begin:/'/,end:/'/},n]}}})());
-hljs.registerLanguage("basic",(()=>{"use strict";return E=>({name:"BASIC",
-case_insensitive:!0,illegal:"^.",keywords:{$pattern:"[a-zA-Z][a-zA-Z0-9_$%!#]*",
-keyword:"ABS ASC AND ATN AUTO|0 BEEP BLOAD|10 BSAVE|10 CALL CALLS CDBL CHAIN CHDIR CHR$|10 CINT CIRCLE CLEAR CLOSE CLS COLOR COM COMMON CONT COS CSNG CSRLIN CVD CVI CVS DATA DATE$ DEFDBL DEFINT DEFSNG DEFSTR DEF|0 SEG USR DELETE DIM DRAW EDIT END ENVIRON ENVIRON$ EOF EQV ERASE ERDEV ERDEV$ ERL ERR ERROR EXP FIELD FILES FIX FOR|0 FRE GET GOSUB|10 GOTO HEX$ IF THEN ELSE|0 INKEY$ INP INPUT INPUT# INPUT$ INSTR IMP INT IOCTL IOCTL$ KEY ON OFF LIST KILL LEFT$ LEN LET LINE LLIST LOAD LOC LOCATE LOF LOG LPRINT USING LSET MERGE MID$ MKDIR MKD$ MKI$ MKS$ MOD NAME NEW NEXT NOISE NOT OCT$ ON OR PEN PLAY STRIG OPEN OPTION BASE OUT PAINT PALETTE PCOPY PEEK PMAP POINT POKE POS PRINT PRINT] PSET PRESET PUT RANDOMIZE READ REM RENUM RESET|0 RESTORE RESUME RETURN|0 RIGHT$ RMDIR RND RSET RUN SAVE SCREEN SGN SHELL SIN SOUND SPACE$ SPC SQR STEP STICK STOP STR$ STRING$ SWAP SYSTEM TAB TAN TIME$ TIMER TROFF TRON TO USR VAL VARPTR VARPTR$ VIEW WAIT WHILE WEND WIDTH WINDOW WRITE XOR"
-},contains:[E.QUOTE_STRING_MODE,E.COMMENT("REM","$",{relevance:10
-}),E.COMMENT("'","$",{relevance:0}),{className:"symbol",begin:"^[0-9]+ ",
-relevance:10},{className:"number",begin:"\\b\\d+(\\.\\d+)?([edED]\\d+)?[#!]?",
-relevance:0},{className:"number",begin:"(&[hH][0-9a-fA-F]{1,4})"},{
-className:"number",begin:"(&[oO][0-7]{1,6})"}]})})());
-hljs.registerLanguage("bnf",(()=>{"use strict";return e=>({
-name:"Backus\u2013Naur Form",contains:[{className:"attribute",begin:/</,end:/>/
-},{begin:/::=/,end:/$/,contains:[{begin:/</,end:/>/
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-}]})})());
-hljs.registerLanguage("brainfuck",(()=>{"use strict";return e=>{const n={
-className:"literal",begin:/[+-]/,relevance:0};return{name:"Brainfuck",
-aliases:["bf"],
-contains:[e.COMMENT("[^\\[\\]\\.,\\+\\-<> \r\n]","[\\[\\]\\.,\\+\\-<> \r\n]",{
-returnEnd:!0,relevance:0}),{className:"title",begin:"[\\[\\]]",relevance:0},{
-className:"string",begin:"[\\.,]",relevance:0},{begin:/(?:\+\+|--)/,contains:[n]
-},n]}}})());
-hljs.registerLanguage("c-like",(()=>{"use strict";function e(e){
-return t("(",e,")?")}function t(...e){return e.map((e=>{
-return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return n=>{
-const a=(n=>{const a=n.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),r="[a-zA-Z_]\\w*::",s="(decltype\\(auto\\)|"+e(r)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",i={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[n.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},n.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},n.inherit(c,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},a,n.C_BLOCK_COMMENT_MODE]},d={
-className:"title",begin:e(r)+n.IDENT_RE,relevance:0
-},u=e(r)+n.IDENT_RE+"\\s*\\(",p={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"_Bool _Complex _Imaginary",
-_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
-literal:"true false nullptr NULL"},m={className:"function.dispatch",relevance:0,
-keywords:p,
-begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,n.IDENT_RE,(_=/\s*\(/,
-t("(?=",_,")")))};var _;const g=[m,l,i,a,n.C_BLOCK_COMMENT_MODE,o,c],b={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:p,contains:g.concat([{
-begin:/\(/,end:/\)/,keywords:p,contains:g.concat(["self"]),relevance:0}]),
-relevance:0},f={className:"function",begin:"("+s+"[\\*&\\s]+)+"+u,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:p,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:p,relevance:0},{begin:u,
-returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[c,o]},{className:"params",begin:/\(/,end:/\)/,
-keywords:p,relevance:0,contains:[a,n.C_BLOCK_COMMENT_MODE,c,o,i,{begin:/\(/,
-end:/\)/,keywords:p,relevance:0,contains:["self",a,n.C_BLOCK_COMMENT_MODE,c,o,i]
-}]},i,a,n.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:p,illegal:"</",
-classNameAliases:{"function.dispatch":"built_in"},
-contains:[].concat(b,f,m,g,[l,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:p,contains:["self",i]},{begin:n.IDENT_RE+"::",keywords:p},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},n.TITLE_MODE]}]),exports:{
-preprocessor:l,strings:c,keywords:p}}})(n)
-;return a.disableAutodetect=!0,a.aliases=[],
-n.getLanguage("c")||a.aliases.push("c","h"),
-n.getLanguage("cpp")||a.aliases.push("cc","c++","h++","hpp","hh","hxx","cxx"),a}
-})());
-hljs.registerLanguage("c",(()=>{"use strict";function e(e){
-return((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(",e,")?")
-}return t=>{const n=t.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),r="[a-zA-Z_]\\w*::",a="(decltype\\(auto\\)|"+e(r)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",i={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},s={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[t.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},t.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},t.inherit(s,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},n,t.C_BLOCK_COMMENT_MODE]},l={
-className:"title",begin:e(r)+t.IDENT_RE,relevance:0
-},d=e(r)+t.IDENT_RE+"\\s*\\(",u={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary",
-literal:"true false nullptr NULL"},m=[c,i,n,t.C_BLOCK_COMMENT_MODE,o,s],p={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:u,contains:m.concat([{
-begin:/\(/,end:/\)/,keywords:u,contains:m.concat(["self"]),relevance:0}]),
-relevance:0},_={className:"function",begin:"("+a+"[\\*&\\s]+)+"+d,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:u,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:u,relevance:0},{begin:d,
-returnBegin:!0,contains:[l],relevance:0},{className:"params",begin:/\(/,
-end:/\)/,keywords:u,relevance:0,contains:[n,t.C_BLOCK_COMMENT_MODE,s,o,i,{
-begin:/\(/,end:/\)/,keywords:u,relevance:0,
-contains:["self",n,t.C_BLOCK_COMMENT_MODE,s,o,i]}]
-},i,n,t.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u,
-disableAutodetect:!0,illegal:"</",contains:[].concat(p,_,m,[c,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:u,contains:["self",i]},{begin:t.IDENT_RE+"::",keywords:u},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},t.TITLE_MODE]}]),exports:{
-preprocessor:c,strings:s,keywords:u}}}})());
-hljs.registerLanguage("cal",(()=>{"use strict";return e=>{
-const n="div mod in and or not xor asserterror begin case do downto else end exit for if of repeat then to until while with var",a=[e.C_LINE_COMMENT_MODE,e.COMMENT(/\{/,/\}/,{
-relevance:0}),e.COMMENT(/\(\*/,/\*\)/,{relevance:10})],r={className:"string",
-begin:/'/,end:/'/,contains:[{begin:/''/}]},s={className:"string",begin:/(#\d+)+/
-},i={className:"function",beginKeywords:"procedure",end:/[:;]/,
-keywords:"procedure|10",contains:[e.TITLE_MODE,{className:"params",begin:/\(/,
-end:/\)/,keywords:n,contains:[r,s]}].concat(a)},t={className:"class",
-begin:"OBJECT (Table|Form|Report|Dataport|Codeunit|XMLport|MenuSuite|Page|Query) (\\d+) ([^\\r\\n]+)",
-returnBegin:!0,contains:[e.TITLE_MODE,i]};return{name:"C/AL",
-case_insensitive:!0,keywords:{keyword:n,literal:"false true"},illegal:/\/\*/,
-contains:[r,s,{className:"number",begin:"\\b\\d+(\\.\\d+)?(DT|D|T)",relevance:0
-},{className:"string",begin:'"',end:'"'},e.NUMBER_MODE,t,i]}}})());
-hljs.registerLanguage("capnproto",(()=>{"use strict";return n=>({
-name:"Cap\u2019n Proto",aliases:["capnp"],keywords:{
-keyword:"struct enum interface union group import using const annotation extends in of on as with from fixed",
-built_in:"Void Bool Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64 Float32 Float64 Text Data AnyPointer AnyStruct Capability List",
-literal:"true false"},
-contains:[n.QUOTE_STRING_MODE,n.NUMBER_MODE,n.HASH_COMMENT_MODE,{
-className:"meta",begin:/@0x[\w\d]{16};/,illegal:/\n/},{className:"symbol",
-begin:/@\d+\b/},{className:"class",beginKeywords:"struct enum",end:/\{/,
-illegal:/\n/,contains:[n.inherit(n.TITLE_MODE,{starts:{endsWithParent:!0,
-excludeEnd:!0}})]},{className:"class",beginKeywords:"interface",end:/\{/,
-illegal:/\n/,contains:[n.inherit(n.TITLE_MODE,{starts:{endsWithParent:!0,
-excludeEnd:!0}})]}]})})());
-hljs.registerLanguage("ceylon",(()=>{"use strict";return e=>{
-const a="assembly module package import alias class interface object given value assign void function new of extends satisfies abstracts in out return break continue throw assert dynamic if else switch case for while try catch finally then let this outer super is exists nonempty",s={
-className:"subst",excludeBegin:!0,excludeEnd:!0,begin:/``/,end:/``/,keywords:a,
-relevance:10},n=[{className:"string",begin:'"""',end:'"""',relevance:10},{
-className:"string",begin:'"',end:'"',contains:[s]},{className:"string",
-begin:"'",end:"'"},{className:"number",
-begin:"#[0-9a-fA-F_]+|\\$[01_]+|[0-9_]+(?:\\.[0-9_](?:[eE][+-]?\\d+)?)?[kMGTPmunpf]?",
-relevance:0}];return s.contains=n,{name:"Ceylon",keywords:{
-keyword:a+" shared abstract formal default actual variable late native deprecated final sealed annotation suppressWarnings small",
-meta:"doc by license see throws tagged"},illegal:"\\$[^01]|#[^0-9a-fA-F]",
-contains:[e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*","\\*/",{contains:["self"]}),{
-className:"meta",begin:'@[a-z]\\w*(?::"[^"]*")?'}].concat(n)}}})());
-hljs.registerLanguage("clean",(()=>{"use strict";return e=>({name:"Clean",
-aliases:["icl","dcl"],keywords:{
-keyword:"if let in with where case of class instance otherwise implementation definition system module from import qualified as special code inline foreign export ccall stdcall generic derive infix infixl infixr",
-built_in:"Int Real Char Bool",literal:"True False"},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-begin:"->|<-[|:]?|#!?|>>=|\\{\\||\\|\\}|:==|=:|<>"}]})})());
-hljs.registerLanguage("clojure",(()=>{"use strict";return e=>{
-const t="a-zA-Z_\\-!.?+*=<>&#'",n="["+t+"]["+t+"0-9/;:]*",r="def defonce defprotocol defstruct defmulti defmethod defn- defn defmacro deftype defrecord",a={
-$pattern:n,
-"builtin-name":r+" cond apply if-not if-let if not not= =|0 <|0 >|0 <=|0 >=|0 ==|0 +|0 /|0 *|0 -|0 rem quot neg? pos? delay? symbol? keyword? true? false? integer? empty? coll? list? set? ifn? fn? associative? sequential? sorted? counted? reversible? number? decimal? class? distinct? isa? float? rational? reduced? ratio? odd? even? char? seq? vector? string? map? nil? contains? zero? instance? not-every? not-any? libspec? -> ->> .. . inc compare do dotimes mapcat take remove take-while drop letfn drop-last take-last drop-while while intern condp case reduced cycle split-at split-with repeat replicate iterate range merge zipmap declare line-seq sort comparator sort-by dorun doall nthnext nthrest partition eval doseq await await-for let agent atom send send-off release-pending-sends add-watch mapv filterv remove-watch agent-error restart-agent set-error-handler error-handler set-error-mode! error-mode shutdown-agents quote var fn loop recur throw try monitor-enter monitor-exit macroexpand macroexpand-1 for dosync and or when when-not when-let comp juxt partial sequence memoize constantly complement identity assert peek pop doto proxy first rest cons cast coll last butlast sigs reify second ffirst fnext nfirst nnext meta with-meta ns in-ns create-ns import refer keys select-keys vals key val rseq name namespace promise into transient persistent! conj! assoc! dissoc! pop! disj! use class type num float double short byte boolean bigint biginteger bigdec print-method print-dup throw-if printf format load compile get-in update-in pr pr-on newline flush read slurp read-line subvec with-open memfn time re-find re-groups rand-int rand mod locking assert-valid-fdecl alias resolve ref deref refset swap! reset! set-validator! compare-and-set! alter-meta! reset-meta! commute get-validator alter ref-set ref-history-count ref-min-history ref-max-history ensure sync io! new next conj set! to-array future future-call into-array aset gen-class reduce map filter find empty hash-map hash-set sorted-map sorted-map-by sorted-set sorted-set-by vec vector seq flatten reverse assoc dissoc list disj get union difference intersection extend extend-type extend-protocol int nth delay count concat chunk chunk-buffer chunk-append chunk-first chunk-rest max min dec unchecked-inc-int unchecked-inc unchecked-dec-inc unchecked-dec unchecked-negate unchecked-add-int unchecked-add unchecked-subtract-int unchecked-subtract chunk-next chunk-cons chunked-seq? prn vary-meta lazy-seq spread list* str find-keyword keyword symbol gensym force rationalize"
-},s={begin:n,relevance:0},o={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",
-relevance:0},i=e.inherit(e.QUOTE_STRING_MODE,{illegal:null
-}),c=e.COMMENT(";","$",{relevance:0}),d={className:"literal",
-begin:/\b(true|false|nil)\b/},l={begin:"[\\[\\{]",end:"[\\]\\}]"},m={
-className:"comment",begin:"\\^"+n},p=e.COMMENT("\\^\\{","\\}"),u={
-className:"symbol",begin:"[:]{1,2}"+n},f={begin:"\\(",end:"\\)"},h={
-endsWithParent:!0,relevance:0},y={keywords:a,className:"name",begin:n,
-relevance:0,starts:h},g=[f,i,m,p,c,u,l,o,d,s],b={beginKeywords:r,lexemes:n,
-end:'(\\[|#|\\d|"|:|\\{|\\)|\\(|$)',contains:[{className:"title",begin:n,
-relevance:0,excludeEnd:!0,endsParent:!0}].concat(g)}
-;return f.contains=[e.COMMENT("comment",""),b,y,h],
-h.contains=g,l.contains=g,p.contains=[l],{name:"Clojure",aliases:["clj"],
-illegal:/\S/,contains:[f,i,m,p,c,u,l,o,d]}}})());
-hljs.registerLanguage("clojure-repl",(()=>{"use strict";return e=>({
-name:"Clojure REPL",contains:[{className:"meta",begin:/^([\w.-]+|\s*#_)?=>/,
-starts:{end:/$/,subLanguage:"clojure"}}]})})());
-hljs.registerLanguage("cmake",(()=>{"use strict";return e=>({name:"CMake",
-aliases:["cmake.in"],case_insensitive:!0,keywords:{
-keyword:"break cmake_host_system_information cmake_minimum_required cmake_parse_arguments cmake_policy configure_file continue elseif else endforeach endfunction endif endmacro endwhile execute_process file find_file find_library find_package find_path find_program foreach function get_cmake_property get_directory_property get_filename_component get_property if include include_guard list macro mark_as_advanced math message option return separate_arguments set_directory_properties set_property set site_name string unset variable_watch while add_compile_definitions add_compile_options add_custom_command add_custom_target add_definitions add_dependencies add_executable add_library add_link_options add_subdirectory add_test aux_source_directory build_command create_test_sourcelist define_property enable_language enable_testing export fltk_wrap_ui get_source_file_property get_target_property get_test_property include_directories include_external_msproject include_regular_expression install link_directories link_libraries load_cache project qt_wrap_cpp qt_wrap_ui remove_definitions set_source_files_properties set_target_properties set_tests_properties source_group target_compile_definitions target_compile_features target_compile_options target_include_directories target_link_directories target_link_libraries target_link_options target_sources try_compile try_run ctest_build ctest_configure ctest_coverage ctest_empty_binary_directory ctest_memcheck ctest_read_custom_files ctest_run_script ctest_sleep ctest_start ctest_submit ctest_test ctest_update ctest_upload build_name exec_program export_library_dependencies install_files install_programs install_targets load_command make_directory output_required_files remove subdir_depends subdirs use_mangled_mesa utility_source variable_requires write_file qt5_use_modules qt5_use_package qt5_wrap_cpp on off true false and or not command policy target test exists is_newer_than is_directory is_symlink is_absolute matches less greater equal less_equal greater_equal strless strgreater strequal strless_equal strgreater_equal version_less version_greater version_equal version_less_equal version_greater_equal in_list defined"
-},contains:[{className:"variable",begin:/\$\{/,end:/\}/
-},e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE]})})());
-hljs.registerLanguage("coffeescript",(()=>{"use strict"
-;const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"])
-;return r=>{const t={
-keyword:e.concat(["then","unless","until","loop","by","when","and","or","is","isnt","not"]).filter((i=["var","const","let","function","static"],
-e=>!i.includes(e))),literal:n.concat(["yes","no","on","off"]),
-built_in:a.concat(["npm","print"])};var i;const s="[A-Za-z$_][0-9A-Za-z$_]*",o={
-className:"subst",begin:/#\{/,end:/\}/,keywords:t
-},c=[r.BINARY_NUMBER_MODE,r.inherit(r.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",
-relevance:0}}),{className:"string",variants:[{begin:/'''/,end:/'''/,
-contains:[r.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[r.BACKSLASH_ESCAPE]
-},{begin:/"""/,end:/"""/,contains:[r.BACKSLASH_ESCAPE,o]},{begin:/"/,end:/"/,
-contains:[r.BACKSLASH_ESCAPE,o]}]},{className:"regexp",variants:[{begin:"///",
-end:"///",contains:[o,r.HASH_COMMENT_MODE]},{begin:"//[gim]{0,3}(?=\\W)",
-relevance:0},{begin:/\/(?![ *]).*?(?![\\]).\/[gim]{0,3}(?=\W)/}]},{begin:"@"+s
-},{subLanguage:"javascript",excludeBegin:!0,excludeEnd:!0,variants:[{
-begin:"```",end:"```"},{begin:"`",end:"`"}]}];o.contains=c
-;const l=r.inherit(r.TITLE_MODE,{begin:s}),d="(\\(.*\\)\\s*)?\\B[-=]>",g={
-className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,
-end:/\)/,keywords:t,contains:["self"].concat(c)}]};return{name:"CoffeeScript",
-aliases:["coffee","cson","iced"],keywords:t,illegal:/\/\*/,
-contains:c.concat([r.COMMENT("###","###"),r.HASH_COMMENT_MODE,{
-className:"function",begin:"^\\s*"+s+"\\s*=\\s*"+d,end:"[-=]>",returnBegin:!0,
-contains:[l,g]},{begin:/[:\(,=]\s*/,relevance:0,contains:[{className:"function",
-begin:d,end:"[-=]>",returnBegin:!0,contains:[g]}]},{className:"class",
-beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{
-beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[l]},l]
-},{begin:s+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0}])}}})());
-hljs.registerLanguage("coq",(()=>{"use strict";return e=>({name:"Coq",keywords:{
-keyword:"_|0 as at cofix else end exists exists2 fix for forall fun if IF in let match mod Prop return Set then Type using where with Abort About Add Admit Admitted All Arguments Assumptions Axiom Back BackTo Backtrack Bind Blacklist Canonical Cd Check Class Classes Close Coercion Coercions CoFixpoint CoInductive Collection Combined Compute Conjecture Conjectures Constant constr Constraint Constructors Context Corollary CreateHintDb Cut Declare Defined Definition Delimit Dependencies Dependent Derive Drop eauto End Equality Eval Example Existential Existentials Existing Export exporting Extern Extract Extraction Fact Field Fields File Fixpoint Focus for From Function Functional Generalizable Global Goal Grab Grammar Graph Guarded Heap Hint HintDb Hints Hypotheses Hypothesis ident Identity If Immediate Implicit Import Include Inductive Infix Info Initial Inline Inspect Instance Instances Intro Intros Inversion Inversion_clear Language Left Lemma Let Libraries Library Load LoadPath Local Locate Ltac ML Mode Module Modules Monomorphic Morphism Next NoInline Notation Obligation Obligations Opaque Open Optimize Options Parameter Parameters Parametric Path Paths pattern Polymorphic Preterm Print Printing Program Projections Proof Proposition Pwd Qed Quit Rec Record Recursive Redirect Relation Remark Remove Require Reserved Reset Resolve Restart Rewrite Right Ring Rings Save Scheme Scope Scopes Script Search SearchAbout SearchHead SearchPattern SearchRewrite Section Separate Set Setoid Show Solve Sorted Step Strategies Strategy Structure SubClass Table Tables Tactic Term Test Theorem Time Timeout Transparent Type Typeclasses Types Undelimit Undo Unfocus Unfocused Unfold Universe Universes Unset Unshelve using Variable Variables Variant Verbose Visibility where with",
-built_in:"abstract absurd admit after apply as assert assumption at auto autorewrite autounfold before bottom btauto by case case_eq cbn cbv change classical_left classical_right clear clearbody cofix compare compute congruence constr_eq constructor contradict contradiction cut cutrewrite cycle decide decompose dependent destruct destruction dintuition discriminate discrR do double dtauto eapply eassumption eauto ecase econstructor edestruct ediscriminate eelim eexact eexists einduction einjection eleft elim elimtype enough equality erewrite eright esimplify_eq esplit evar exact exactly_once exfalso exists f_equal fail field field_simplify field_simplify_eq first firstorder fix fold fourier functional generalize generalizing gfail give_up has_evar hnf idtac in induction injection instantiate intro intro_pattern intros intuition inversion inversion_clear is_evar is_var lapply lazy left lia lra move native_compute nia nsatz omega once pattern pose progress proof psatz quote record red refine reflexivity remember rename repeat replace revert revgoals rewrite rewrite_strat right ring ring_simplify rtauto set setoid_reflexivity setoid_replace setoid_rewrite setoid_symmetry setoid_transitivity shelve shelve_unifiable simpl simple simplify_eq solve specialize split split_Rabs split_Rmult stepl stepr subst sum swap symmetry tactic tauto time timeout top transitivity trivial try tryif unfold unify until using vm_compute with"
-},contains:[e.QUOTE_STRING_MODE,e.COMMENT("\\(\\*","\\*\\)"),e.C_NUMBER_MODE,{
-className:"type",excludeBegin:!0,begin:"\\|\\s*",end:"\\w+"},{begin:/[-=]>/}]})
-})());
-hljs.registerLanguage("cos",(()=>{"use strict";return e=>({
-name:"Cach\xe9 Object Script",case_insensitive:!0,aliases:["cls"],
-keywords:"property parameter class classmethod clientmethod extends as break catch close continue do d|0 else elseif for goto halt hang h|0 if job j|0 kill k|0 lock l|0 merge new open quit q|0 read r|0 return set s|0 tcommit throw trollback try tstart use view while write w|0 xecute x|0 zkill znspace zn ztrap zwrite zw zzdump zzwrite print zbreak zinsert zload zprint zremove zsave zzprint mv mvcall mvcrt mvdim mvprint zquit zsync ascii",
-contains:[{className:"number",begin:"\\b(\\d+(\\.\\d*)?|\\.\\d+)",relevance:0},{
-className:"string",variants:[{begin:'"',end:'"',contains:[{begin:'""',
-relevance:0}]}]},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"comment",begin:/;/,end:"$",relevance:0},{className:"built_in",
-begin:/(?:\$\$?|\.\.)\^?[a-zA-Z]+/},{className:"built_in",
-begin:/\$\$\$[a-zA-Z]+/},{className:"built_in",begin:/%[a-z]+(?:\.[a-z]+)*/},{
-className:"symbol",begin:/\^%?[a-zA-Z][\w]*/},{className:"keyword",
-begin:/##class|##super|#define|#dim/},{begin:/&sql\(/,end:/\)/,excludeBegin:!0,
-excludeEnd:!0,subLanguage:"sql"},{begin:/&(js|jscript|javascript)</,end:/>/,
-excludeBegin:!0,excludeEnd:!0,subLanguage:"javascript"},{begin:/&html<\s*</,
-end:/>\s*>/,subLanguage:"xml"}]})})());
-hljs.registerLanguage("cpp",(()=>{"use strict";function e(e){
-return t("(",e,")?")}function t(...e){return e.map((e=>{
-return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return n=>{
-const r=n.COMMENT("//","$",{contains:[{begin:/\\\n/}]
-}),a="[a-zA-Z_]\\w*::",i="(decltype\\(auto\\)|"+e(a)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",s={
-className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string",
-variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",
-contains:[n.BACKSLASH_ESCAPE]},{
-begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)",
-end:"'",illegal:"."},n.END_SAME_AS_BEGIN({
-begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={
-className:"number",variants:[{begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)"
-},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},n.inherit(c,{className:"meta-string"}),{
-className:"meta-string",begin:/<.*?>/},r,n.C_BLOCK_COMMENT_MODE]},d={
-className:"title",begin:e(a)+n.IDENT_RE,relevance:0
-},u=e(a)+n.IDENT_RE+"\\s*\\(",m={
-keyword:"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq",
-built_in:"_Bool _Complex _Imaginary",
-_relevance_hints:["asin","atan2","atan","calloc","ceil","cosh","cos","exit","exp","fabs","floor","fmod","fprintf","fputs","free","frexp","auto_ptr","deque","list","queue","stack","vector","map","set","pair","bitset","multiset","multimap","unordered_set","fscanf","future","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","tolower","toupper","labs","ldexp","log10","log","malloc","realloc","memchr","memcmp","memcpy","memset","modf","pow","printf","putchar","puts","scanf","sinh","sin","snprintf","sprintf","sqrt","sscanf","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","tanh","tan","unordered_map","unordered_multiset","unordered_multimap","priority_queue","make_pair","array","shared_ptr","abort","terminate","abs","acos","vfprintf","vprintf","vsprintf","endl","initializer_list","unique_ptr","complex","imaginary","std","string","wstring","cin","cout","cerr","clog","stdin","stdout","stderr","stringstream","istringstream","ostringstream"],
-literal:"true false nullptr NULL"},p={className:"function.dispatch",relevance:0,
-keywords:m,
-begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,n.IDENT_RE,(_=/\s*\(/,
-t("(?=",_,")")))};var _;const g=[p,l,s,r,n.C_BLOCK_COMMENT_MODE,o,c],b={
-variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{
-beginKeywords:"new throw return else",end:/;/}],keywords:m,contains:g.concat([{
-begin:/\(/,end:/\)/,keywords:m,contains:g.concat(["self"]),relevance:0}]),
-relevance:0},f={className:"function",begin:"("+i+"[\\*&\\s]+)+"+u,
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:m,illegal:/[^\w\s\*&:<>.]/,
-contains:[{begin:"decltype\\(auto\\)",keywords:m,relevance:0},{begin:u,
-returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,
-endsWithParent:!0,contains:[c,o]},{className:"params",begin:/\(/,end:/\)/,
-keywords:m,relevance:0,contains:[r,n.C_BLOCK_COMMENT_MODE,c,o,s,{begin:/\(/,
-end:/\)/,keywords:m,relevance:0,contains:["self",r,n.C_BLOCK_COMMENT_MODE,c,o,s]
-}]},s,r,n.C_BLOCK_COMMENT_MODE,l]};return{name:"C++",
-aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:m,illegal:"</",
-classNameAliases:{"function.dispatch":"built_in"},
-contains:[].concat(b,f,p,g,[l,{
-begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",
-end:">",keywords:m,contains:["self",s]},{begin:n.IDENT_RE+"::",keywords:m},{
-className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/,
-contains:[{beginKeywords:"final class struct"},n.TITLE_MODE]}]),exports:{
-preprocessor:l,strings:c,keywords:m}}}})());
-hljs.registerLanguage("crmsh",(()=>{"use strict";return e=>{
-const t="group clone ms master location colocation order fencing_topology rsc_ticket acl_target acl_group user role tag xml"
-;return{name:"crmsh",aliases:["crm","pcmk"],case_insensitive:!0,keywords:{
-keyword:"params meta operations op rule attributes utilization read write deny defined not_defined in_range date spec in ref reference attribute type xpath version and or lt gt tag lte gte eq ne \\ number string",
-literal:"Master Started Slave Stopped start promote demote stop monitor true false"
-},contains:[e.HASH_COMMENT_MODE,{beginKeywords:"node",starts:{
-end:"\\s*([\\w_-]+:)?",starts:{className:"title",end:"\\s*[\\$\\w_][\\w_-]*"}}
-},{beginKeywords:"primitive rsc_template",starts:{className:"title",
-end:"\\s*[\\$\\w_][\\w_-]*",starts:{end:"\\s*@?[\\w_][\\w_\\.:-]*"}}},{
-begin:"\\b("+t.split(" ").join("|")+")\\s+",keywords:t,starts:{
-className:"title",end:"[\\$\\w_][\\w_-]*"}},{
-beginKeywords:"property rsc_defaults op_defaults",starts:{className:"title",
-end:"\\s*([\\w_-]+:)?"}},e.QUOTE_STRING_MODE,{className:"meta",
-begin:"(ocf|systemd|service|lsb):[\\w_:-]+",relevance:0},{className:"number",
-begin:"\\b\\d+(\\.\\d+)?(ms|s|h|m)?",relevance:0},{className:"literal",
-begin:"[-]?(infinity|inf)",relevance:0},{className:"attr",
-begin:/([A-Za-z$_#][\w_-]+)=/,relevance:0},{className:"tag",begin:"</?",
-end:"/?>",relevance:0}]}}})());
-hljs.registerLanguage("crystal",(()=>{"use strict";return e=>{
-const n="(_?[ui](8|16|32|64|128))?",i="[a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|[=!]~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~|]|//|//=|&[-+*]=?|&\\*\\*|\\[\\][=?]?",s="[A-Za-z_]\\w*(::\\w+)*(\\?|!)?",a={
-$pattern:"[a-zA-Z_]\\w*[!?=]?",
-keyword:"abstract alias annotation as as? asm begin break case class def do else elsif end ensure enum extend for fun if include instance_sizeof is_a? lib macro module next nil? of out pointerof private protected rescue responds_to? return require select self sizeof struct super then type typeof union uninitialized unless until verbatim when while with yield __DIR__ __END_LINE__ __FILE__ __LINE__",
-literal:"false nil true"},t={className:"subst",begin:/#\{/,end:/\}/,keywords:a
-},c={className:"template-variable",variants:[{begin:"\\{\\{",end:"\\}\\}"},{
-begin:"\\{%",end:"%\\}"}],keywords:a};function r(e,n){const i=[{begin:e,end:n}]
-;return i[0].contains=i,i}const l={className:"string",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/
-},{begin:/`/,end:/`/},{begin:"%[Qwi]?\\(",end:"\\)",contains:r("\\(","\\)")},{
-begin:"%[Qwi]?\\[",end:"\\]",contains:r("\\[","\\]")},{begin:"%[Qwi]?\\{",
-end:/\}/,contains:r(/\{/,/\}/)},{begin:"%[Qwi]?<",end:">",contains:r("<",">")},{
-begin:"%[Qwi]?\\|",end:"\\|"},{begin:/<<-\w+$/,end:/^\s*\w+$/}],relevance:0},b={
-className:"string",variants:[{begin:"%q\\(",end:"\\)",contains:r("\\(","\\)")},{
-begin:"%q\\[",end:"\\]",contains:r("\\[","\\]")},{begin:"%q\\{",end:/\}/,
-contains:r(/\{/,/\}/)},{begin:"%q<",end:">",contains:r("<",">")},{begin:"%q\\|",
-end:"\\|"},{begin:/<<-'\w+'$/,end:/^\s*\w+$/}],relevance:0},o={
-begin:"(?!%\\})("+e.RE_STARTERS_RE+"|\\n|\\b(case|if|select|unless|until|when|while)\\b)\\s*",
-keywords:"case if select unless until when while",contains:[{className:"regexp",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:"//[a-z]*",relevance:0},{
-begin:"/(?!\\/)",end:"/[a-z]*"}]}],relevance:0},g=[c,l,b,{className:"regexp",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[{begin:"%r\\(",end:"\\)",
-contains:r("\\(","\\)")},{begin:"%r\\[",end:"\\]",contains:r("\\[","\\]")},{
-begin:"%r\\{",end:/\}/,contains:r(/\{/,/\}/)},{begin:"%r<",end:">",
-contains:r("<",">")},{begin:"%r\\|",end:"\\|"}],relevance:0},o,{
-className:"meta",begin:"@\\[",end:"\\]",
-contains:[e.inherit(e.QUOTE_STRING_MODE,{className:"meta-string"})]
-},e.HASH_COMMENT_MODE,{className:"class",beginKeywords:"class module struct",
-end:"$|;",illegal:/=/,contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{
-begin:s}),{begin:"<"}]},{className:"class",beginKeywords:"lib enum union",
-end:"$|;",illegal:/=/,contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{
-begin:s})]},{beginKeywords:"annotation",end:"$|;",illegal:/=/,
-contains:[e.HASH_COMMENT_MODE,e.inherit(e.TITLE_MODE,{begin:s})],relevance:2},{
-className:"function",beginKeywords:"def",end:/\B\b/,
-contains:[e.inherit(e.TITLE_MODE,{begin:i,endsParent:!0})]},{
-className:"function",beginKeywords:"fun macro",end:/\B\b/,
-contains:[e.inherit(e.TITLE_MODE,{begin:i,endsParent:!0})],relevance:2},{
-className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{
-className:"symbol",begin:":",contains:[l,{begin:i}],relevance:0},{
-className:"number",variants:[{begin:"\\b0b([01_]+)"+n},{begin:"\\b0o([0-7_]+)"+n
-},{begin:"\\b0x([A-Fa-f0-9_]+)"+n},{
-begin:"\\b([1-9][0-9_]*[0-9]|[0-9])(\\.[0-9][0-9_]*)?([eE]_?[-+]?[0-9_]*)?(_?f(32|64))?(?!_)"
-},{begin:"\\b([1-9][0-9_]*|0)"+n}],relevance:0}]
-;return t.contains=g,c.contains=g.slice(1),{name:"Crystal",aliases:["cr"],
-keywords:a,contains:g}}})());
-hljs.registerLanguage("csharp",(()=>{"use strict";return e=>{const n={
-keyword:["abstract","as","base","break","case","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]),
-built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"],
-literal:["default","false","null","true"]},a=e.inherit(e.TITLE_MODE,{
-begin:"[a-zA-Z](\\.?\\w)*"}),i={className:"number",variants:[{
-begin:"\\b(0b[01']+)"},{
-begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{
-begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)"
-}],relevance:0},s={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]
-},t=e.inherit(s,{illegal:/\n/}),r={className:"subst",begin:/\{/,end:/\}/,
-keywords:n},l=e.inherit(r,{illegal:/\n/}),c={className:"string",begin:/\$"/,
-end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/
-},e.BACKSLASH_ESCAPE,l]},o={className:"string",begin:/\$@"/,end:'"',contains:[{
-begin:/\{\{/},{begin:/\}\}/},{begin:'""'},r]},d=e.inherit(o,{illegal:/\n/,
-contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},l]})
-;r.contains=[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.C_BLOCK_COMMENT_MODE],
-l.contains=[d,c,t,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.inherit(e.C_BLOCK_COMMENT_MODE,{
-illegal:/\n/})];const g={variants:[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},E={begin:"<",end:">",contains:[{beginKeywords:"in out"},a]
-},_=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",b={
-begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"],
-keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0,
-contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{
-begin:"\x3c!--|--\x3e"},{begin:"</?",end:">"}]}]
-}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#",
-end:"$",keywords:{
-"meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum"
-}},g,i,{beginKeywords:"class interface",relevance:0,end:/[{;=]/,
-illegal:/[^\s:,]/,contains:[{beginKeywords:"where class"
-},a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace",
-relevance:0,end:/[{;=]/,illegal:/[^\s:]/,
-contains:[a,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{
-beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/,
-contains:[a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta",
-begin:"^\\s*\\[",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{
-className:"meta-string",begin:/"/,end:/"/}]},{
-beginKeywords:"new return throw await else",relevance:0},{className:"function",
-begin:"("+_+"\\s+)+"+e.IDENT_RE+"\\s*(<.+>\\s*)?\\(",returnBegin:!0,
-end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{
-beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial",
-relevance:0},{begin:e.IDENT_RE+"\\s*(<.+>\\s*)?\\(",returnBegin:!0,
-contains:[e.TITLE_MODE,E],relevance:0},{className:"params",begin:/\(/,end:/\)/,
-excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0,
-contains:[g,i,e.C_BLOCK_COMMENT_MODE]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},b]}}})());
-hljs.registerLanguage("csp",(()=>{"use strict";return e=>({name:"CSP",
-case_insensitive:!1,keywords:{$pattern:"[a-zA-Z][a-zA-Z0-9_-]*",
-keyword:"base-uri child-src connect-src default-src font-src form-action frame-ancestors frame-src img-src media-src object-src plugin-types report-uri sandbox script-src style-src"
-},contains:[{className:"string",begin:"'",end:"'"},{className:"attribute",
-begin:"^Content",end:":",excludeEnd:!0}]})})());
-hljs.registerLanguage("css",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
-;return n=>{const a=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
-HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
-ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
-illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}
-}))(n),l=[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE];return{name:"CSS",
-case_insensitive:!0,illegal:/[=|'\$]/,keywords:{keyframePosition:"from to"},
-classNameAliases:{keyframePosition:"selector-tag"},
-contains:[n.C_BLOCK_COMMENT_MODE,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/
-},n.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0
-},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0
-},a.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{
-begin:":("+i.join("|")+")"},{begin:"::("+o.join("|")+")"}]},{
-className:"attribute",begin:"\\b("+r.join("|")+")\\b"},{begin:":",end:"[;}]",
-contains:[a.HEXCOLOR,a.IMPORTANT,n.CSS_NUMBER_MODE,...l,{
-begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri"
-},contains:[{className:"string",begin:/[^)]/,endsWithParent:!0,excludeEnd:!0}]
-},{className:"built_in",begin:/[\w-]+(?=\()/}]},{
-begin:(s=/@/,((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",s,")")),
-end:"[{;]",relevance:0,illegal:/:/,contains:[{className:"keyword",
-begin:/@-?\w[\w]*(-\w+)*/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,
-relevance:0,keywords:{$pattern:/[a-z-]+/,keyword:"and or not only",
-attribute:t.join(" ")},contains:[{begin:/[a-z-]+(?=:)/,className:"attribute"
-},...l,n.CSS_NUMBER_MODE]}]},{className:"selector-tag",
-begin:"\\b("+e.join("|")+")\\b"}]};var s}})());
-hljs.registerLanguage("d",(()=>{"use strict";return e=>{const a={
-$pattern:e.UNDERSCORE_IDENT_RE,
-keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",
-built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",
-literal:"false null true"
-},d="((0|[1-9][\\d_]*)|0[bB][01_]+|0[xX]([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))",n="\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};",t={
-className:"number",begin:"\\b"+d+"(L|u|U|Lu|LU|uL|UL)?",relevance:0},_={
-className:"number",
-begin:"\\b(((0[xX](([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)\\.([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)|\\.?([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))|((0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)(\\.\\d*|([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)))|\\d+\\.(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)|\\.(0|[1-9][\\d_]*)([eE][+-]?(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d))?))([fF]|L|i|[fF]i|Li)?|"+d+"(i|[fF]i|Li))",
-relevance:0},r={className:"string",begin:"'("+n+"|.)",end:"'",illegal:"."},i={
-className:"string",begin:'"',contains:[{begin:n,relevance:0}],end:'"[cwd]?'
-},s=e.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{
-name:"D",keywords:a,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,{
-className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},i,{
-className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string",
-begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},_,t,r,{
-className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta",
-begin:"#(line)",end:"$",relevance:5},{className:"keyword",
-begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}}})());
-hljs.registerLanguage("markdown",(()=>{"use strict";function n(...n){
-return n.map((n=>{return(e=n)?"string"==typeof e?e:e.source:null;var e
-})).join("")}return e=>{const a={begin:/<\/?[A-Za-z_]/,end:">",
-subLanguage:"xml",relevance:0},i={variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0
-},{begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/,
-relevance:2},{begin:n(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/),
-relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{
-begin:/\[.+?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{
-className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0,
-returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)",
-excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[",
-end:"\\]",excludeBegin:!0,excludeEnd:!0}]},s={className:"strong",contains:[],
-variants:[{begin:/_{2}/,end:/_{2}/},{begin:/\*{2}/,end:/\*{2}/}]},c={
-className:"emphasis",contains:[],variants:[{begin:/\*(?!\*)/,end:/\*/},{
-begin:/_(?!_)/,end:/_/,relevance:0}]};s.contains.push(c),c.contains.push(s)
-;let t=[a,i]
-;return s.contains=s.contains.concat(t),c.contains=c.contains.concat(t),
-t=t.concat(s,c),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{
-className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:t},{
-begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n",
-contains:t}]}]},a,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)",
-end:"\\s+",excludeEnd:!0},s,c,{className:"quote",begin:"^>\\s+",contains:t,
-end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{
-begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{
-begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))",
-contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{
-begin:"^[-\\*]{3,}",end:"$"},i,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{
-className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{
-className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}}})());
-hljs.registerLanguage("dart",(()=>{"use strict";return e=>{const n={
-className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"}]},a={className:"subst",
-variants:[{begin:/\$\{/,end:/\}/}],keywords:"true false null this is new super"
-},t={className:"string",variants:[{begin:"r'''",end:"'''"},{begin:'r"""',
-end:'"""'},{begin:"r'",end:"'",illegal:"\\n"},{begin:'r"',end:'"',illegal:"\\n"
-},{begin:"'''",end:"'''",contains:[e.BACKSLASH_ESCAPE,n,a]},{begin:'"""',
-end:'"""',contains:[e.BACKSLASH_ESCAPE,n,a]},{begin:"'",end:"'",illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE,n,a]},{begin:'"',end:'"',illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE,n,a]}]};a.contains=[e.C_NUMBER_MODE,t]
-;const i=["Comparable","DateTime","Duration","Function","Iterable","Iterator","List","Map","Match","Object","Pattern","RegExp","Set","Stopwatch","String","StringBuffer","StringSink","Symbol","Type","Uri","bool","double","int","num","Element","ElementList"],r=i.map((e=>e+"?"))
-;return{name:"Dart",keywords:{
-keyword:"abstract as assert async await break case catch class const continue covariant default deferred do dynamic else enum export extends extension external factory false final finally for Function get hide if implements import in inferface is late library mixin new null on operator part required rethrow return set show static super switch sync this throw true try typedef var void while with yield",
-built_in:i.concat(r).concat(["Never","Null","dynamic","print","document","querySelector","querySelectorAll","window"]),
-$pattern:/[A-Za-z][A-Za-z0-9_]*\??/},
-contains:[t,e.COMMENT(/\/\*\*(?!\/)/,/\*\//,{subLanguage:"markdown",relevance:0
-}),e.COMMENT(/\/{3,} ?/,/$/,{contains:[{subLanguage:"markdown",begin:".",
-end:"$",relevance:0}]}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"class",beginKeywords:"class interface",end:/\{/,excludeEnd:!0,
-contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]
-},e.C_NUMBER_MODE,{className:"meta",begin:"@[A-Za-z]+"},{begin:"=>"}]}}})());
-hljs.registerLanguage("delphi",(()=>{"use strict";return e=>{
-const r="exports register file shl array record property for mod while set ally label uses raise not stored class safecall var interface or private static exit index inherited to else stdcall override shr asm far resourcestring finalization packed virtual out and protected library do xorwrite goto near function end div overload object unit begin string on inline repeat until destructor write message program with read initialization except default nil if case cdecl in downto threadvar of try pascal const external constructor type public then implementation finally published procedure absolute reintroduce operator as is abstract alias assembler bitpacked break continue cppdecl cvar enumerator experimental platform deprecated unimplemented dynamic export far16 forward generic helper implements interrupt iochecks local name nodefault noreturn nostackframe oldfpccall otherwise saveregisters softfloat specialize strict unaligned varargs ",a=[e.C_LINE_COMMENT_MODE,e.COMMENT(/\{/,/\}/,{
-relevance:0}),e.COMMENT(/\(\*/,/\*\)/,{relevance:10})],t={className:"meta",
-variants:[{begin:/\{\$/,end:/\}/},{begin:/\(\*\$/,end:/\*\)/}]},n={
-className:"string",begin:/'/,end:/'/,contains:[{begin:/''/}]},s={
-className:"string",begin:/(#\d+)+/},i={begin:e.IDENT_RE+"\\s*=\\s*class\\s*\\(",
-returnBegin:!0,contains:[e.TITLE_MODE]},c={className:"function",
-beginKeywords:"function constructor destructor procedure",end:/[:;]/,
-keywords:"function constructor|10 destructor|10 procedure|10",
-contains:[e.TITLE_MODE,{className:"params",begin:/\(/,end:/\)/,keywords:r,
-contains:[n,s,t].concat(a)},t].concat(a)};return{name:"Delphi",
-aliases:["dpr","dfm","pas","pascal","freepascal","lazarus","lpr","lfm"],
-case_insensitive:!0,keywords:r,illegal:/"|\$[G-Zg-z]|\/\*|<\/|\|/,
-contains:[n,s,e.NUMBER_MODE,{className:"number",relevance:0,variants:[{
-begin:"\\$[0-9A-Fa-f]+"},{begin:"&[0-7]+"},{begin:"%[01]+"}]},i,c,t].concat(a)}}
-})());
-hljs.registerLanguage("diff",(()=>{"use strict";return e=>({name:"Diff",
-aliases:["patch"],contains:[{className:"meta",relevance:10,variants:[{
-begin:/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/},{begin:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{
-begin:/^--- +\d+,\d+ +----$/}]},{className:"comment",variants:[{begin:/Index: /,
-end:/$/},{begin:/^index/,end:/$/},{begin:/={3,}/,end:/$/},{begin:/^-{3}/,end:/$/
-},{begin:/^\*{3} /,end:/$/},{begin:/^\+{3}/,end:/$/},{begin:/^\*{15}$/},{
-begin:/^diff --git/,end:/$/}]},{className:"addition",begin:/^\+/,end:/$/},{
-className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/,
-end:/$/}]})})());
-hljs.registerLanguage("django",(()=>{"use strict";return e=>{const t={
-begin:/\|[A-Za-z]+:?/,keywords:{
-name:"truncatewords removetags linebreaksbr yesno get_digit timesince random striptags filesizeformat escape linebreaks length_is ljust rjust cut urlize fix_ampersands title floatformat capfirst pprint divisibleby add make_list unordered_list urlencode timeuntil urlizetrunc wordcount stringformat linenumbers slice date dictsort dictsortreversed default_if_none pluralize lower join center default truncatewords_html upper length phone2numeric wordwrap time addslashes slugify first escapejs force_escape iriencode last safe safeseq truncatechars localize unlocalize localtime utc timezone"
-},contains:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE]};return{name:"Django",
-aliases:["jinja"],case_insensitive:!0,subLanguage:"xml",
-contains:[e.COMMENT(/\{%\s*comment\s*%\}/,/\{%\s*endcomment\s*%\}/),e.COMMENT(/\{#/,/#\}/),{
-className:"template-tag",begin:/\{%/,end:/%\}/,contains:[{className:"name",
-begin:/\w+/,keywords:{
-name:"comment endcomment load templatetag ifchanged endifchanged if endif firstof for endfor ifnotequal endifnotequal widthratio extends include spaceless endspaceless regroup ifequal endifequal ssi now with cycle url filter endfilter debug block endblock else autoescape endautoescape csrf_token empty elif endwith static trans blocktrans endblocktrans get_static_prefix get_media_prefix plural get_current_language language get_available_languages get_current_language_bidi get_language_info get_language_info_list localize endlocalize localtime endlocaltime timezone endtimezone get_current_timezone verbatim"
-},starts:{endsWithParent:!0,keywords:"in by as",contains:[t],relevance:0}}]},{
-className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[t]}]}}})());
-hljs.registerLanguage("dns",(()=>{"use strict";return d=>({name:"DNS Zone",
-aliases:["bind","zone"],keywords:{
-keyword:"IN A AAAA AFSDB APL CAA CDNSKEY CDS CERT CNAME DHCID DLV DNAME DNSKEY DS HIP IPSECKEY KEY KX LOC MX NAPTR NS NSEC NSEC3 NSEC3PARAM PTR RRSIG RP SIG SOA SRV SSHFP TA TKEY TLSA TSIG TXT"
-},contains:[d.COMMENT(";","$",{relevance:0}),{className:"meta",
-begin:/^\$(TTL|GENERATE|INCLUDE|ORIGIN)\b/},{className:"number",
-begin:"((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}))|:)))\\b"
-},{className:"number",
-begin:"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\b"
-},d.inherit(d.NUMBER_MODE,{begin:/\b\d+[dhwm]?/})]})})());
-hljs.registerLanguage("dockerfile",(()=>{"use strict";return e=>({
-name:"Dockerfile",aliases:["docker"],case_insensitive:!0,
-keywords:"from maintainer expose env arg user onbuild stopsignal",
-contains:[e.HASH_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{
-beginKeywords:"run cmd entrypoint volume add copy workdir label healthcheck shell",
-starts:{end:/[^\\]$/,subLanguage:"bash"}}],illegal:"</"})})());
-hljs.registerLanguage("dos",(()=>{"use strict";return e=>{
-const t=e.COMMENT(/^\s*@?rem\b/,/$/,{relevance:10});return{
-name:"Batch file (DOS)",aliases:["bat","cmd"],case_insensitive:!0,
-illegal:/\/\*/,keywords:{
-keyword:"if else goto for in do call exit not exist errorlevel defined equ neq lss leq gtr geq",
-built_in:"prn nul lpt3 lpt2 lpt1 con com4 com3 com2 com1 aux shift cd dir echo setlocal endlocal set pause copy append assoc at attrib break cacls cd chcp chdir chkdsk chkntfs cls cmd color comp compact convert date dir diskcomp diskcopy doskey erase fs find findstr format ftype graftabl help keyb label md mkdir mode more move path pause print popd pushd promt rd recover rem rename replace restore rmdir shift sort start subst time title tree type ver verify vol ping net ipconfig taskkill xcopy ren del"
-},contains:[{className:"variable",begin:/%%[^ ]|%[^ ]+?%|![^ ]+?!/},{
-className:"function",begin:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)",
-end:"goto:eof",contains:[e.inherit(e.TITLE_MODE,{
-begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),t]},{
-className:"number",begin:"\\b\\d+",relevance:0},t]}}})());
-hljs.registerLanguage("dsconfig",(()=>{"use strict";return e=>({
-keywords:"dsconfig",contains:[{className:"keyword",begin:"^dsconfig",end:/\s/,
-excludeEnd:!0,relevance:10},{className:"built_in",
-begin:/(list|create|get|set|delete)-(\w+)/,end:/\s/,excludeEnd:!0,
-illegal:"!@#$%^&*()",relevance:10},{className:"built_in",begin:/--(\w+)/,
-end:/\s/,excludeEnd:!0},{className:"string",begin:/"/,end:/"/},{
-className:"string",begin:/'/,end:/'/},{className:"string",begin:/[\w\-?]+:\w+/,
-end:/\W/,relevance:0},{className:"string",begin:/\w+(\-\w+)*/,end:/(?=\W)/,
-relevance:0},e.HASH_COMMENT_MODE]})})());
-hljs.registerLanguage("dts",(()=>{"use strict";return e=>{const n={
-className:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{
-begin:'((u8?|U)|L)?"'}),{begin:'(u8?|U)?R"',end:'"',
-contains:[e.BACKSLASH_ESCAPE]},{begin:"'\\\\?.",end:"'",illegal:"."}]},a={
-className:"number",variants:[{
-begin:"\\b(\\d+(\\.\\d*)?|\\.\\d+)(u|U|l|L|ul|UL|f|F)"},{begin:e.C_NUMBER_RE}],
-relevance:0},s={className:"meta",begin:"#",end:"$",keywords:{
-"meta-keyword":"if else elif endif define undef ifdef ifndef"},contains:[{
-begin:/\\\n/,relevance:0},{beginKeywords:"include",end:"$",keywords:{
-"meta-keyword":"include"},contains:[e.inherit(n,{className:"meta-string"}),{
-className:"meta-string",begin:"<",end:">",illegal:"\\n"}]
-},n,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},i={className:"variable",
-begin:/&[a-z\d_]*\b/},d={className:"meta-keyword",begin:"/[a-z][a-z\\d-]*/"},l={
-className:"symbol",begin:"^\\s*[a-zA-Z_][a-zA-Z\\d_]*:"},r={className:"params",
-begin:"<",end:">",contains:[a,i]},_={className:"class",
-begin:/[a-zA-Z_][a-zA-Z\d_@]*\s\{/,end:/[{;=]/,returnBegin:!0,excludeEnd:!0}
-;return{name:"Device Tree",keywords:"",contains:[{className:"class",
-begin:"/\\s*\\{",end:/\};/,relevance:10,
-contains:[i,d,l,_,r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,n]
-},i,d,l,_,r,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,n,s,{
-begin:e.IDENT_RE+"::",keywords:""}]}}})());
-hljs.registerLanguage("dust",(()=>{"use strict";return e=>({name:"Dust",
-aliases:["dst"],case_insensitive:!0,subLanguage:"xml",contains:[{
-className:"template-tag",begin:/\{[#\/]/,end:/\}/,illegal:/;/,contains:[{
-className:"name",begin:/[a-zA-Z\.-]+/,starts:{endsWithParent:!0,relevance:0,
-contains:[e.QUOTE_STRING_MODE]}}]},{className:"template-variable",begin:/\{/,
-end:/\}/,illegal:/;/,keywords:"if eq ne lt lte gt gte select default math sep"}]
-})})());
-hljs.registerLanguage("ebnf",(()=>{"use strict";return e=>{
-const a=e.COMMENT(/\(\*/,/\*\)/);return{name:"Extended Backus-Naur Form",
-illegal:/\S/,contains:[a,{className:"attribute",
-begin:/^[ ]*[a-zA-Z]+([\s_-]+[a-zA-Z]+)*/},{begin:/=/,end:/[.;]/,contains:[a,{
-className:"meta",begin:/\?.*\?/},{className:"string",
-variants:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"`",end:"`"}]}]}]}}
-})());
-hljs.registerLanguage("elixir",(()=>{"use strict";return e=>{
-const n="[a-zA-Z_][a-zA-Z0-9_.]*(!|\\?)?",i={$pattern:n,
-keyword:"and false then defined module in return redo retry end for true self when next until do begin unless nil break not case cond alias while ensure or include use alias fn quote require import with|0"
-},a={className:"subst",begin:/#\{/,end:/\}/,keywords:i},s={className:"number",
-begin:"(\\b0o[0-7_]+)|(\\b0b[01_]+)|(\\b0x[0-9a-fA-F_]+)|(-?\\b[1-9][0-9_]*(\\.[0-9_]+([eE][-+]?[0-9]+)?)?)",
-relevance:0},b={className:"string",begin:"~[a-z](?=[/|([{<\"'])",contains:[{
-endsParent:!0,contains:[{contains:[e.BACKSLASH_ESCAPE,a],variants:[{begin:/"/,
-end:/"/},{begin:/'/,end:/'/},{begin:/\//,end:/\//},{begin:/\|/,end:/\|/},{
-begin:/\(/,end:/\)/},{begin:/\[/,end:/\]/},{begin:/\{/,end:/\}/},{begin:/</,
-end:/>/}]}]}]},d={className:"string",contains:[e.BACKSLASH_ESCAPE,a],variants:[{
-begin:/"""/,end:/"""/},{begin:/'''/,end:/'''/},{begin:/~S"""/,end:/"""/,
-contains:[]},{begin:/~S"/,end:/"/,contains:[]},{begin:/~S'''/,end:/'''/,
-contains:[]},{begin:/~S'/,end:/'/,contains:[]},{begin:/'/,end:/'/},{begin:/"/,
-end:/"/}]},r={className:"function",beginKeywords:"def defp defmacro",end:/\B\b/,
-contains:[e.inherit(e.TITLE_MODE,{begin:n,endsParent:!0})]},g=e.inherit(r,{
-className:"class",beginKeywords:"defimpl defmodule defprotocol defrecord",
-end:/\bdo\b|$|;/}),t=[d,{className:"string",begin:"~[A-Z](?=[/|([{<\"'])",
-contains:[{begin:/"/,end:/"/},{begin:/'/,end:/'/},{begin:/\//,end:/\//},{
-begin:/\|/,end:/\|/},{begin:/\(/,end:/\)/},{begin:/\[/,end:/\]/},{begin:/\{/,
-end:/\}/},{begin:/</,end:/>/}]},b,e.HASH_COMMENT_MODE,g,r,{begin:"::"},{
-className:"symbol",begin:":(?![\\s:])",contains:[d,{
-begin:"[a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?"
-}],relevance:0},{className:"symbol",begin:n+":(?!:)",relevance:0},s,{
-className:"variable",begin:"(\\$\\W)|((\\$|@@?)(\\w+))"},{begin:"->"},{
-begin:"("+e.RE_STARTERS_RE+")\\s*",contains:[e.HASH_COMMENT_MODE,{
-begin:/\/: (?=\d+\s*[,\]])/,relevance:0,contains:[s]},{className:"regexp",
-illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,a],variants:[{begin:"/",end:"/[a-z]*"
-},{begin:"%r\\[",end:"\\][a-z]*"}]}],relevance:0}];return a.contains=t,{
-name:"Elixir",keywords:i,contains:t}}})());
-hljs.registerLanguage("elm",(()=>{"use strict";return e=>{const n={
-variants:[e.COMMENT("--","$"),e.COMMENT(/\{-/,/-\}/,{contains:["self"]})]},i={
-className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},s={begin:"\\(",end:"\\)",
-illegal:'"',contains:[{className:"type",
-begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},n]};return{name:"Elm",
-keywords:"let in if then else case of where module import exposing type alias as infix infixl infixr port effect command subscription",
-contains:[{beginKeywords:"port effect module",end:"exposing",
-keywords:"port effect module where command subscription exposing",
-contains:[s,n],illegal:"\\W\\.|;"},{begin:"import",end:"$",
-keywords:"import as exposing",contains:[s,n],illegal:"\\W\\.|;"},{begin:"type",
-end:"$",keywords:"type alias",contains:[i,s,{begin:/\{/,end:/\}/,
-contains:s.contains},n]},{beginKeywords:"infix infixl infixr",end:"$",
-contains:[e.C_NUMBER_MODE,n]},{begin:"port",end:"$",keywords:"port",contains:[n]
-},{className:"string",begin:"'\\\\?.",end:"'",illegal:"."
-},e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,i,e.inherit(e.TITLE_MODE,{
-begin:"^[_a-z][\\w']*"}),n,{begin:"->|<-"}],illegal:/;/}}})());
-hljs.registerLanguage("ruby",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{
-const a="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",i={
-keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor __FILE__",
-built_in:"proc lambda",literal:"true false nil"},s={className:"doctag",
-begin:"@[A-Za-z]+"},r={begin:"#<",end:">"},b=[n.COMMENT("#","$",{contains:[s]
-}),n.COMMENT("^=begin","^=end",{contains:[s],relevance:10
-}),n.COMMENT("^__END__","\\n$")],c={className:"subst",begin:/#\{/,end:/\}/,
-keywords:i},t={className:"string",contains:[n.BACKSLASH_ESCAPE,c],variants:[{
-begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\(/,
-end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{begin:/%[qQwWx]?\{/,end:/\}/},{
-begin:/%[qQwWx]?</,end:/>/},{begin:/%[qQwWx]?\//,end:/\//},{begin:/%[qQwWx]?%/,
-end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\|/,end:/\|/},{
-begin:/\B\?(\\\d{1,3})/},{begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{
-begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{
-begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{
-begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{
-begin:/<<[-~]?'?(\w+)\n(?:[^\n]*\n)*?\s*\1\b/,returnBegin:!0,contains:[{
-begin:/<<[-~]?'?/},n.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,
-contains:[n.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",d={className:"number",
-relevance:0,variants:[{
-begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{
-begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b"
-},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{
-begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{
-begin:"\\b0(_?[0-7])+r?i?\\b"}]},l={className:"params",begin:"\\(",end:"\\)",
-endsParent:!0,keywords:i},o=[t,{className:"class",beginKeywords:"class module",
-end:"$|;",illegal:/=/,contains:[n.inherit(n.TITLE_MODE,{
-begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|!)?"}),{begin:"<\\s*",contains:[{
-begin:"("+n.IDENT_RE+"::)?"+n.IDENT_RE,relevance:0}]}].concat(b)},{
-className:"function",begin:e(/def\s+/,(_=a+"\\s*(\\(|;|$)",e("(?=",_,")"))),
-relevance:0,keywords:"def",end:"$|;",contains:[n.inherit(n.TITLE_MODE,{begin:a
-}),l].concat(b)},{begin:n.IDENT_RE+"::"},{className:"symbol",
-begin:n.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol",
-begin:":(?!\\s)",contains:[t,{begin:a}],relevance:0},d,{className:"variable",
-begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{
-className:"params",begin:/\|/,end:/\|/,relevance:0,keywords:i},{
-begin:"("+n.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[{
-className:"regexp",contains:[n.BACKSLASH_ESCAPE,c],illegal:/\n/,variants:[{
-begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{begin:"%r\\(",
-end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]
-}].concat(r,b),relevance:0}].concat(r,b);var _;c.contains=o,l.contains=o
-;const E=[{begin:/^\s*=>/,starts:{end:"$",contains:o}},{className:"meta",
-begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])",
-starts:{end:"$",contains:o}}];return b.unshift(r),{name:"Ruby",
-aliases:["rb","gemspec","podspec","thor","irb"],keywords:i,illegal:/\/\*/,
-contains:[n.SHEBANG({binary:"ruby"})].concat(E).concat(b).concat(o)}}})());
-hljs.registerLanguage("erb",(()=>{"use strict";return e=>({name:"ERB",
-subLanguage:"xml",contains:[e.COMMENT("<%#","%>"),{begin:"<%[%=-]?",
-end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0}]})})());
-hljs.registerLanguage("erlang-repl",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>({name:"Erlang REPL",keywords:{
-built_in:"spawn spawn_link self",
-keyword:"after and andalso|10 band begin bnot bor bsl bsr bxor case catch cond div end fun if let not of or orelse|10 query receive rem try when xor"
-},contains:[{className:"meta",begin:"^[0-9]+> ",relevance:10
-},n.COMMENT("%","$"),{className:"number",
-begin:"\\b(\\d+(_\\d+)*#[a-fA-F0-9]+(_[a-fA-F0-9]+)*|\\d+(_\\d+)*(\\.\\d+(_\\d+)*)?([eE][-+]?\\d+)?)",
-relevance:0},n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,{
-begin:e(/\?(::)?/,/([A-Z]\w*)/,/((::)[A-Z]\w*)*/)},{begin:"->"},{begin:"ok"},{
-begin:"!"},{
-begin:"(\\b[a-z'][a-zA-Z0-9_']*:[a-z'][a-zA-Z0-9_']*)|(\\b[a-z'][a-zA-Z0-9_']*)",
-relevance:0},{begin:"[A-Z][a-zA-Z0-9_']*",relevance:0}]})})());
-hljs.registerLanguage("erlang",(()=>{"use strict";return e=>{
-const n="[a-z'][a-zA-Z0-9_']*",r="("+n+":"+n+"|"+n+")",a={
-keyword:"after and andalso|10 band begin bnot bor bsl bzr bxor case catch cond div end fun if let not of orelse|10 query receive rem try when xor",
-literal:"false true"},i=e.COMMENT("%","$"),s={className:"number",
-begin:"\\b(\\d+(_\\d+)*#[a-fA-F0-9]+(_[a-fA-F0-9]+)*|\\d+(_\\d+)*(\\.\\d+(_\\d+)*)?([eE][-+]?\\d+)?)",
-relevance:0},c={begin:"fun\\s+"+n+"/\\d+"},t={begin:r+"\\(",end:"\\)",
-returnBegin:!0,relevance:0,contains:[{begin:r,relevance:0},{begin:"\\(",
-end:"\\)",endsWithParent:!0,returnEnd:!0,relevance:0}]},d={begin:/\{/,end:/\}/,
-relevance:0},o={begin:"\\b_([A-Z][A-Za-z0-9_]*)?",relevance:0},l={
-begin:"[A-Z][a-zA-Z0-9_]*",relevance:0},b={begin:"#"+e.UNDERSCORE_IDENT_RE,
-relevance:0,returnBegin:!0,contains:[{begin:"#"+e.UNDERSCORE_IDENT_RE,
-relevance:0},{begin:/\{/,end:/\}/,relevance:0}]},g={
-beginKeywords:"fun receive if try case",end:"end",keywords:a}
-;g.contains=[i,c,e.inherit(e.APOS_STRING_MODE,{className:""
-}),g,t,e.QUOTE_STRING_MODE,s,d,o,l,b]
-;const E=[i,c,g,t,e.QUOTE_STRING_MODE,s,d,o,l,b]
-;t.contains[1].contains=E,d.contains=E,b.contains[1].contains=E;const u={
-className:"params",begin:"\\(",end:"\\)",contains:E};return{name:"Erlang",
-aliases:["erl"],keywords:a,illegal:"(</|\\*=|\\+=|-=|/\\*|\\*/|\\(\\*|\\*\\))",
-contains:[{className:"function",begin:"^"+n+"\\s*\\(",end:"->",returnBegin:!0,
-illegal:"\\(|#|//|/\\*|\\\\|:|;",contains:[u,e.inherit(e.TITLE_MODE,{begin:n})],
-starts:{end:";|\\.",keywords:a,contains:E}},i,{begin:"^-",end:"\\.",relevance:0,
-excludeEnd:!0,returnBegin:!0,keywords:{$pattern:"-"+e.IDENT_RE,
-keyword:["-module","-record","-undef","-export","-ifdef","-ifndef","-author","-copyright","-doc","-vsn","-import","-include","-include_lib","-compile","-define","-else","-endif","-file","-behaviour","-behavior","-spec"].map((e=>e+"|1.5")).join(" ")
-},contains:[u]},s,e.QUOTE_STRING_MODE,b,o,l,d,{begin:/\.$/}]}}})());
-hljs.registerLanguage("excel",(()=>{"use strict";return E=>({
-name:"Excel formulae",aliases:["xlsx","xls"],case_insensitive:!0,keywords:{
-$pattern:/[a-zA-Z][\w\.]*/,
-built_in:"ABS ACCRINT ACCRINTM ACOS ACOSH ACOT ACOTH AGGREGATE ADDRESS AMORDEGRC AMORLINC AND ARABIC AREAS ASC ASIN ASINH ATAN ATAN2 ATANH AVEDEV AVERAGE AVERAGEA AVERAGEIF AVERAGEIFS BAHTTEXT BASE BESSELI BESSELJ BESSELK BESSELY BETADIST BETA.DIST BETAINV BETA.INV BIN2DEC BIN2HEX BIN2OCT BINOMDIST BINOM.DIST BINOM.DIST.RANGE BINOM.INV BITAND BITLSHIFT BITOR BITRSHIFT BITXOR CALL CEILING CEILING.MATH CEILING.PRECISE CELL CHAR CHIDIST CHIINV CHITEST CHISQ.DIST CHISQ.DIST.RT CHISQ.INV CHISQ.INV.RT CHISQ.TEST CHOOSE CLEAN CODE COLUMN COLUMNS COMBIN COMBINA COMPLEX CONCAT CONCATENATE CONFIDENCE CONFIDENCE.NORM CONFIDENCE.T CONVERT CORREL COS COSH COT COTH COUNT COUNTA COUNTBLANK COUNTIF COUNTIFS COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD COVAR COVARIANCE.P COVARIANCE.S CRITBINOM CSC CSCH CUBEKPIMEMBER CUBEMEMBER CUBEMEMBERPROPERTY CUBERANKEDMEMBER CUBESET CUBESETCOUNT CUBEVALUE CUMIPMT CUMPRINC DATE DATEDIF DATEVALUE DAVERAGE DAY DAYS DAYS360 DB DBCS DCOUNT DCOUNTA DDB DEC2BIN DEC2HEX DEC2OCT DECIMAL DEGREES DELTA DEVSQ DGET DISC DMAX DMIN DOLLAR DOLLARDE DOLLARFR DPRODUCT DSTDEV DSTDEVP DSUM DURATION DVAR DVARP EDATE EFFECT ENCODEURL EOMONTH ERF ERF.PRECISE ERFC ERFC.PRECISE ERROR.TYPE EUROCONVERT EVEN EXACT EXP EXPON.DIST EXPONDIST FACT FACTDOUBLE FALSE|0 F.DIST FDIST F.DIST.RT FILTERXML FIND FINDB F.INV F.INV.RT FINV FISHER FISHERINV FIXED FLOOR FLOOR.MATH FLOOR.PRECISE FORECAST FORECAST.ETS FORECAST.ETS.CONFINT FORECAST.ETS.SEASONALITY FORECAST.ETS.STAT FORECAST.LINEAR FORMULATEXT FREQUENCY F.TEST FTEST FV FVSCHEDULE GAMMA GAMMA.DIST GAMMADIST GAMMA.INV GAMMAINV GAMMALN GAMMALN.PRECISE GAUSS GCD GEOMEAN GESTEP GETPIVOTDATA GROWTH HARMEAN HEX2BIN HEX2DEC HEX2OCT HLOOKUP HOUR HYPERLINK HYPGEOM.DIST HYPGEOMDIST IF IFERROR IFNA IFS IMABS IMAGINARY IMARGUMENT IMCONJUGATE IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMPRODUCT IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB IMSUM IMTAN INDEX INDIRECT INFO INT INTERCEPT INTRATE IPMT IRR ISBLANK ISERR ISERROR ISEVEN ISFORMULA ISLOGICAL ISNA ISNONTEXT ISNUMBER ISODD ISREF ISTEXT ISO.CEILING ISOWEEKNUM ISPMT JIS KURT LARGE LCM LEFT LEFTB LEN LENB LINEST LN LOG LOG10 LOGEST LOGINV LOGNORM.DIST LOGNORMDIST LOGNORM.INV LOOKUP LOWER MATCH MAX MAXA MAXIFS MDETERM MDURATION MEDIAN MID MIDBs MIN MINIFS MINA MINUTE MINVERSE MIRR MMULT MOD MODE MODE.MULT MODE.SNGL MONTH MROUND MULTINOMIAL MUNIT N NA NEGBINOM.DIST NEGBINOMDIST NETWORKDAYS NETWORKDAYS.INTL NOMINAL NORM.DIST NORMDIST NORMINV NORM.INV NORM.S.DIST NORMSDIST NORM.S.INV NORMSINV NOT NOW NPER NPV NUMBERVALUE OCT2BIN OCT2DEC OCT2HEX ODD ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD OFFSET OR PDURATION PEARSON PERCENTILE.EXC PERCENTILE.INC PERCENTILE PERCENTRANK.EXC PERCENTRANK.INC PERCENTRANK PERMUT PERMUTATIONA PHI PHONETIC PI PMT POISSON.DIST POISSON POWER PPMT PRICE PRICEDISC PRICEMAT PROB PRODUCT PROPER PV QUARTILE QUARTILE.EXC QUARTILE.INC QUOTIENT RADIANS RAND RANDBETWEEN RANK.AVG RANK.EQ RANK RATE RECEIVED REGISTER.ID REPLACE REPLACEB REPT RIGHT RIGHTB ROMAN ROUND ROUNDDOWN ROUNDUP ROW ROWS RRI RSQ RTD SEARCH SEARCHB SEC SECH SECOND SERIESSUM SHEET SHEETS SIGN SIN SINH SKEW SKEW.P SLN SLOPE SMALL SQL.REQUEST SQRT SQRTPI STANDARDIZE STDEV STDEV.P STDEV.S STDEVA STDEVP STDEVPA STEYX SUBSTITUTE SUBTOTAL SUM SUMIF SUMIFS SUMPRODUCT SUMSQ SUMX2MY2 SUMX2PY2 SUMXMY2 SWITCH SYD T TAN TANH TBILLEQ TBILLPRICE TBILLYIELD T.DIST T.DIST.2T T.DIST.RT TDIST TEXT TEXTJOIN TIME TIMEVALUE T.INV T.INV.2T TINV TODAY TRANSPOSE TREND TRIM TRIMMEAN TRUE|0 TRUNC T.TEST TTEST TYPE UNICHAR UNICODE UPPER VALUE VAR VAR.P VAR.S VARA VARP VARPA VDB VLOOKUP WEBSERVICE WEEKDAY WEEKNUM WEIBULL WEIBULL.DIST WORKDAY WORKDAY.INTL XIRR XNPV XOR YEAR YEARFRAC YIELD YIELDDISC YIELDMAT Z.TEST ZTEST"
-},contains:[{begin:/^=/,end:/[^=]/,returnEnd:!0,illegal:/=/,relevance:10},{
-className:"symbol",begin:/\b[A-Z]{1,2}\d+\b/,end:/[^\d]/,excludeEnd:!0,
-relevance:0},{className:"symbol",begin:/[A-Z]{0,2}\d*:[A-Z]{0,2}\d*/,relevance:0
-},E.BACKSLASH_ESCAPE,E.QUOTE_STRING_MODE,{className:"number",
-begin:E.NUMBER_RE+"(%)?",relevance:0},E.COMMENT(/\bN\(/,/\)/,{excludeBegin:!0,
-excludeEnd:!0,illegal:/\n/})]})})());
-hljs.registerLanguage("fix",(()=>{"use strict";return e=>({name:"FIX",
-contains:[{begin:/[^\u2401\u0001]+/,end:/[\u2401\u0001]/,excludeEnd:!0,
-returnBegin:!0,returnEnd:!1,contains:[{begin:/([^\u2401\u0001=]+)/,
-end:/=([^\u2401\u0001=]+)/,returnEnd:!0,returnBegin:!1,className:"attr"},{
-begin:/=/,end:/([\u2401\u0001])/,excludeEnd:!0,excludeBegin:!0,
-className:"string"}]}],case_insensitive:!0})})());
-hljs.registerLanguage("flix",(()=>{"use strict";return e=>({name:"Flix",
-keywords:{literal:"true false",
-keyword:"case class def else enum if impl import in lat rel index let match namespace switch type yield with"
-},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
-begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},{className:"string",variants:[{begin:'"',
-end:'"'}]},{className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,
-excludeEnd:!0,contains:[{className:"title",relevance:0,
-begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/
-}]},e.C_NUMBER_MODE]})})());
-hljs.registerLanguage("fortran",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a={variants:[n.COMMENT("!","$",{relevance:0
-}),n.COMMENT("^C[ ]","$",{relevance:0}),n.COMMENT("^C$","$",{relevance:0})]
-},t=/(_[a-z_\d]+)?/,i=/([de][+-]?\d+)?/,c={className:"number",variants:[{
-begin:e(/\b\d+/,/\.(\d*)/,i,t)},{begin:e(/\b\d+/,i,t)},{begin:e(/\.\d+/,i,t)}],
-relevance:0},o={className:"function",
-beginKeywords:"subroutine function program",illegal:"[${=\\n]",
-contains:[n.UNDERSCORE_TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]}
-;return{name:"Fortran",case_insensitive:!0,aliases:["f90","f95"],keywords:{
-literal:".False. .True.",
-keyword:"kind do concurrent local shared while private call intrinsic where elsewhere type endtype endmodule endselect endinterface end enddo endif if forall endforall only contains default return stop then block endblock endassociate public subroutine|10 function program .and. .or. .not. .le. .eq. .ge. .gt. .lt. goto save else use module select case access blank direct exist file fmt form formatted iostat name named nextrec number opened rec recl sequential status unformatted unit continue format pause cycle exit c_null_char c_alert c_backspace c_form_feed flush wait decimal round iomsg synchronous nopass non_overridable pass protected volatile abstract extends import non_intrinsic value deferred generic final enumerator class associate bind enum c_int c_short c_long c_long_long c_signed_char c_size_t c_int8_t c_int16_t c_int32_t c_int64_t c_int_least8_t c_int_least16_t c_int_least32_t c_int_least64_t c_int_fast8_t c_int_fast16_t c_int_fast32_t c_int_fast64_t c_intmax_t C_intptr_t c_float c_double c_long_double c_float_complex c_double_complex c_long_double_complex c_bool c_char c_null_ptr c_null_funptr c_new_line c_carriage_return c_horizontal_tab c_vertical_tab iso_c_binding c_loc c_funloc c_associated  c_f_pointer c_ptr c_funptr iso_fortran_env character_storage_size error_unit file_storage_size input_unit iostat_end iostat_eor numeric_storage_size output_unit c_f_procpointer ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode newunit contiguous recursive pad position action delim readwrite eor advance nml interface procedure namelist include sequence elemental pure impure integer real character complex logical codimension dimension allocatable|10 parameter external implicit|10 none double precision assign intent optional pointer target in out common equivalence data",
-built_in:"alog alog10 amax0 amax1 amin0 amin1 amod cabs ccos cexp clog csin csqrt dabs dacos dasin datan datan2 dcos dcosh ddim dexp dint dlog dlog10 dmax1 dmin1 dmod dnint dsign dsin dsinh dsqrt dtan dtanh float iabs idim idint idnint ifix isign max0 max1 min0 min1 sngl algama cdabs cdcos cdexp cdlog cdsin cdsqrt cqabs cqcos cqexp cqlog cqsin cqsqrt dcmplx dconjg derf derfc dfloat dgamma dimag dlgama iqint qabs qacos qasin qatan qatan2 qcmplx qconjg qcos qcosh qdim qerf qerfc qexp qgamma qimag qlgama qlog qlog10 qmax1 qmin1 qmod qnint qsign qsin qsinh qsqrt qtan qtanh abs acos aimag aint anint asin atan atan2 char cmplx conjg cos cosh exp ichar index int log log10 max min nint sign sin sinh sqrt tan tanh print write dim lge lgt lle llt mod nullify allocate deallocate adjustl adjustr all allocated any associated bit_size btest ceiling count cshift date_and_time digits dot_product eoshift epsilon exponent floor fraction huge iand ibclr ibits ibset ieor ior ishft ishftc lbound len_trim matmul maxexponent maxloc maxval merge minexponent minloc minval modulo mvbits nearest pack present product radix random_number random_seed range repeat reshape rrspacing scale scan selected_int_kind selected_real_kind set_exponent shape size spacing spread sum system_clock tiny transpose trim ubound unpack verify achar iachar transfer dble entry dprod cpu_time command_argument_count get_command get_command_argument get_environment_variable is_iostat_end ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode is_iostat_eor move_alloc new_line selected_char_kind same_type_as extends_type_of acosh asinh atanh bessel_j0 bessel_j1 bessel_jn bessel_y0 bessel_y1 bessel_yn erf erfc erfc_scaled gamma log_gamma hypot norm2 atomic_define atomic_ref execute_command_line leadz trailz storage_size merge_bits bge bgt ble blt dshiftl dshiftr findloc iall iany iparity image_index lcobound ucobound maskl maskr num_images parity popcnt poppar shifta shiftl shiftr this_image sync change team co_broadcast co_max co_min co_sum co_reduce"
-},illegal:/\/\*/,contains:[{className:"string",relevance:0,
-variants:[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE]},o,{begin:/^C\s*=(?!=)/,
-relevance:0},a,c]}}})());
-hljs.registerLanguage("fsharp",(()=>{"use strict";return e=>{const n={begin:"<",
-end:">",contains:[e.inherit(e.TITLE_MODE,{begin:/'[a-zA-Z0-9_]+/})]};return{
-name:"F#",aliases:["fs"],
-keywords:"abstract and as assert base begin class default delegate do done downcast downto elif else end exception extern false finally for fun function global if in inherit inline interface internal lazy let match member module mutable namespace new null of open or override private public rec return sig static struct then to true try type upcast use val void when while with yield",
-illegal:/\/\*/,contains:[{className:"keyword",begin:/\b(yield|return|let|do)!/
-},{className:"string",begin:'@"',end:'"',contains:[{begin:'""'}]},{
-className:"string",begin:'"""',end:'"""'},e.COMMENT("\\(\\*(\\s)","\\*\\)",{
-contains:["self"]}),{className:"class",beginKeywords:"type",end:"\\(|=|$",
-excludeEnd:!0,contains:[e.UNDERSCORE_TITLE_MODE,n]},{className:"meta",
-begin:"\\[<",end:">\\]",relevance:10},{className:"symbol",
-begin:"\\B('[A-Za-z])\\b",contains:[e.BACKSLASH_ESCAPE]
-},e.C_LINE_COMMENT_MODE,e.inherit(e.QUOTE_STRING_MODE,{illegal:null
-}),e.C_NUMBER_MODE]}}})());
-hljs.registerLanguage("gams",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a={
-keyword:"abort acronym acronyms alias all and assign binary card diag display else eq file files for free ge gt if integer le loop lt maximizing minimizing model models ne negative no not option options or ord positive prod put putpage puttl repeat sameas semicont semiint smax smin solve sos1 sos2 sum system table then until using while xor yes",
-literal:"eps inf na",
-built_in:"abs arccos arcsin arctan arctan2 Beta betaReg binomial ceil centropy cos cosh cvPower div div0 eDist entropy errorf execSeed exp fact floor frac gamma gammaReg log logBeta logGamma log10 log2 mapVal max min mod ncpCM ncpF ncpVUpow ncpVUsin normal pi poly power randBinomial randLinear randTriangle round rPower sigmoid sign signPower sin sinh slexp sllog10 slrec sqexp sqlog10 sqr sqrec sqrt tan tanh trunc uniform uniformInt vcPower bool_and bool_eqv bool_imp bool_not bool_or bool_xor ifThen rel_eq rel_ge rel_gt rel_le rel_lt rel_ne gday gdow ghour gleap gmillisec gminute gmonth gsecond gyear jdate jnow jstart jtime errorLevel execError gamsRelease gamsVersion handleCollect handleDelete handleStatus handleSubmit heapFree heapLimit heapSize jobHandle jobKill jobStatus jobTerminate licenseLevel licenseStatus maxExecError sleep timeClose timeComp timeElapsed timeExec timeStart"
-},i={className:"symbol",variants:[{begin:/=[lgenxc]=/},{begin:/\$/}]},s={
-className:"comment",variants:[{begin:"'",end:"'"},{begin:'"',end:'"'}],
-illegal:"\\n",contains:[n.BACKSLASH_ESCAPE]},o={begin:"/",end:"/",keywords:a,
-contains:[s,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,n.C_NUMBER_MODE]
-},t=/[a-z0-9&#*=?@\\><:,()$[\]_.{}!+%^-]+/,r={
-begin:/[a-z][a-z0-9_]*(\([a-z0-9_, ]*\))?[ \t]+/,excludeBegin:!0,end:"$",
-endsWithParent:!0,contains:[s,o,{className:"comment",
-begin:e(t,(l=e(/[ ]+/,t),e("(",l,")*"))),relevance:0}]};var l;return{
-name:"GAMS",aliases:["gms"],case_insensitive:!0,keywords:a,
-contains:[n.COMMENT(/^\$ontext/,/^\$offtext/),{className:"meta",
-begin:"^\\$[a-z0-9]+",end:"$",returnBegin:!0,contains:[{
-className:"meta-keyword",begin:"^\\$[a-z0-9]+"}]
-},n.COMMENT("^\\*","$"),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,{
-beginKeywords:"set sets parameter parameters variable variables scalar scalars equation equations",
-end:";",
-contains:[n.COMMENT("^\\*","$"),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,o,r]
-},{beginKeywords:"table",end:";",returnBegin:!0,contains:[{
-beginKeywords:"table",end:"$",contains:[r]
-},n.COMMENT("^\\*","$"),n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,n.C_NUMBER_MODE]
-},{className:"function",begin:/^[a-z][a-z0-9_,\-+' ()$]+\.{2}/,returnBegin:!0,
-contains:[{className:"title",begin:/^[a-z0-9_]+/},{className:"params",
-begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0},i]},n.C_NUMBER_MODE,i]}}
-})());
-hljs.registerLanguage("gauss",(()=>{"use strict";return e=>{const t={
-keyword:"bool break call callexe checkinterrupt clear clearg closeall cls comlog compile continue create debug declare delete disable dlibrary dllcall do dos ed edit else elseif enable end endfor endif endp endo errorlog errorlogat expr external fn for format goto gosub graph if keyword let lib library line load loadarray loadexe loadf loadk loadm loadp loads loadx local locate loopnextindex lprint lpwidth lshow matrix msym ndpclex new open output outwidth plot plotsym pop prcsn print printdos proc push retp return rndcon rndmod rndmult rndseed run save saveall screen scroll setarray show sparse stop string struct system trace trap threadfor threadendfor threadbegin threadjoin threadstat threadend until use while winprint ne ge le gt lt and xor or not eq eqv",
-built_in:"abs acf aconcat aeye amax amean AmericanBinomCall AmericanBinomCall_Greeks AmericanBinomCall_ImpVol AmericanBinomPut AmericanBinomPut_Greeks AmericanBinomPut_ImpVol AmericanBSCall AmericanBSCall_Greeks AmericanBSCall_ImpVol AmericanBSPut AmericanBSPut_Greeks AmericanBSPut_ImpVol amin amult annotationGetDefaults annotationSetBkd annotationSetFont annotationSetLineColor annotationSetLineStyle annotationSetLineThickness annualTradingDays arccos arcsin areshape arrayalloc arrayindex arrayinit arraytomat asciiload asclabel astd astds asum atan atan2 atranspose axmargin balance band bandchol bandcholsol bandltsol bandrv bandsolpd bar base10 begwind besselj bessely beta box boxcox cdfBeta cdfBetaInv cdfBinomial cdfBinomialInv cdfBvn cdfBvn2 cdfBvn2e cdfCauchy cdfCauchyInv cdfChic cdfChii cdfChinc cdfChincInv cdfExp cdfExpInv cdfFc cdfFnc cdfFncInv cdfGam cdfGenPareto cdfHyperGeo cdfLaplace cdfLaplaceInv cdfLogistic cdfLogisticInv cdfmControlCreate cdfMvn cdfMvn2e cdfMvnce cdfMvne cdfMvt2e cdfMvtce cdfMvte cdfN cdfN2 cdfNc cdfNegBinomial cdfNegBinomialInv cdfNi cdfPoisson cdfPoissonInv cdfRayleigh cdfRayleighInv cdfTc cdfTci cdfTnc cdfTvn cdfWeibull cdfWeibullInv cdir ceil ChangeDir chdir chiBarSquare chol choldn cholsol cholup chrs close code cols colsf combinate combinated complex con cond conj cons ConScore contour conv convertsatostr convertstrtosa corrm corrms corrvc corrx corrxs cos cosh counts countwts crossprd crout croutp csrcol csrlin csvReadM csvReadSA cumprodc cumsumc curve cvtos datacreate datacreatecomplex datalist dataload dataloop dataopen datasave date datestr datestring datestrymd dayinyr dayofweek dbAddDatabase dbClose dbCommit dbCreateQuery dbExecQuery dbGetConnectOptions dbGetDatabaseName dbGetDriverName dbGetDrivers dbGetHostName dbGetLastErrorNum dbGetLastErrorText dbGetNumericalPrecPolicy dbGetPassword dbGetPort dbGetTableHeaders dbGetTables dbGetUserName dbHasFeature dbIsDriverAvailable dbIsOpen dbIsOpenError dbOpen dbQueryBindValue dbQueryClear dbQueryCols dbQueryExecPrepared dbQueryFetchAllM dbQueryFetchAllSA dbQueryFetchOneM dbQueryFetchOneSA dbQueryFinish dbQueryGetBoundValue dbQueryGetBoundValues dbQueryGetField dbQueryGetLastErrorNum dbQueryGetLastErrorText dbQueryGetLastInsertID dbQueryGetLastQuery dbQueryGetPosition dbQueryIsActive dbQueryIsForwardOnly dbQueryIsNull dbQueryIsSelect dbQueryIsValid dbQueryPrepare dbQueryRows dbQuerySeek dbQuerySeekFirst dbQuerySeekLast dbQuerySeekNext dbQuerySeekPrevious dbQuerySetForwardOnly dbRemoveDatabase dbRollback dbSetConnectOptions dbSetDatabaseName dbSetHostName dbSetNumericalPrecPolicy dbSetPort dbSetUserName dbTransaction DeleteFile delif delrows denseToSp denseToSpRE denToZero design det detl dfft dffti diag diagrv digamma doswin DOSWinCloseall DOSWinOpen dotfeq dotfeqmt dotfge dotfgemt dotfgt dotfgtmt dotfle dotflemt dotflt dotfltmt dotfne dotfnemt draw drop dsCreate dstat dstatmt dstatmtControlCreate dtdate dtday dttime dttodtv dttostr dttoutc dtvnormal dtvtodt dtvtoutc dummy dummybr dummydn eig eigh eighv eigv elapsedTradingDays endwind envget eof eqSolve eqSolvemt eqSolvemtControlCreate eqSolvemtOutCreate eqSolveset erf erfc erfccplx erfcplx error etdays ethsec etstr EuropeanBinomCall EuropeanBinomCall_Greeks EuropeanBinomCall_ImpVol EuropeanBinomPut EuropeanBinomPut_Greeks EuropeanBinomPut_ImpVol EuropeanBSCall EuropeanBSCall_Greeks EuropeanBSCall_ImpVol EuropeanBSPut EuropeanBSPut_Greeks EuropeanBSPut_ImpVol exctsmpl exec execbg exp extern eye fcheckerr fclearerr feq feqmt fflush fft ffti fftm fftmi fftn fge fgemt fgets fgetsa fgetsat fgetst fgt fgtmt fileinfo filesa fle flemt floor flt fltmt fmod fne fnemt fonts fopen formatcv formatnv fputs fputst fseek fstrerror ftell ftocv ftos ftostrC gamma gammacplx gammaii gausset gdaAppend gdaCreate gdaDStat gdaDStatMat gdaGetIndex gdaGetName gdaGetNames gdaGetOrders gdaGetType gdaGetTypes gdaGetVarInfo gdaIsCplx gdaLoad gdaPack gdaRead gdaReadByIndex gdaReadSome gdaReadSparse gdaReadStruct gdaReportVarInfo gdaSave gdaUpdate gdaUpdateAndPack gdaVars gdaWrite gdaWrite32 gdaWriteSome getarray getdims getf getGAUSShome getmatrix getmatrix4D getname getnamef getNextTradingDay getNextWeekDay getnr getorders getpath getPreviousTradingDay getPreviousWeekDay getRow getscalar3D getscalar4D getTrRow getwind glm gradcplx gradMT gradMTm gradMTT gradMTTm gradp graphprt graphset hasimag header headermt hess hessMT hessMTg hessMTgw hessMTm hessMTmw hessMTT hessMTTg hessMTTgw hessMTTm hessMTw hessp hist histf histp hsec imag indcv indexcat indices indices2 indicesf indicesfn indnv indsav integrate1d integrateControlCreate intgrat2 intgrat3 inthp1 inthp2 inthp3 inthp4 inthpControlCreate intquad1 intquad2 intquad3 intrleav intrleavsa intrsect intsimp inv invpd invswp iscplx iscplxf isden isinfnanmiss ismiss key keyav keyw lag lag1 lagn lapEighb lapEighi lapEighvb lapEighvi lapgEig lapgEigh lapgEighv lapgEigv lapgSchur lapgSvdcst lapgSvds lapgSvdst lapSvdcusv lapSvds lapSvdusv ldlp ldlsol linSolve listwise ln lncdfbvn lncdfbvn2 lncdfmvn lncdfn lncdfn2 lncdfnc lnfact lngammacplx lnpdfmvn lnpdfmvt lnpdfn lnpdft loadd loadstruct loadwind loess loessmt loessmtControlCreate log loglog logx logy lower lowmat lowmat1 ltrisol lu lusol machEpsilon make makevars makewind margin matalloc matinit mattoarray maxbytes maxc maxindc maxv maxvec mbesselei mbesselei0 mbesselei1 mbesseli mbesseli0 mbesseli1 meanc median mergeby mergevar minc minindc minv miss missex missrv moment momentd movingave movingaveExpwgt movingaveWgt nextindex nextn nextnevn nextwind ntos null null1 numCombinations ols olsmt olsmtControlCreate olsqr olsqr2 olsqrmt ones optn optnevn orth outtyp pacf packedToSp packr parse pause pdfCauchy pdfChi pdfExp pdfGenPareto pdfHyperGeo pdfLaplace pdfLogistic pdfn pdfPoisson pdfRayleigh pdfWeibull pi pinv pinvmt plotAddArrow plotAddBar plotAddBox plotAddHist plotAddHistF plotAddHistP plotAddPolar plotAddScatter plotAddShape plotAddTextbox plotAddTS plotAddXY plotArea plotBar plotBox plotClearLayout plotContour plotCustomLayout plotGetDefaults plotHist plotHistF plotHistP plotLayout plotLogLog plotLogX plotLogY plotOpenWindow plotPolar plotSave plotScatter plotSetAxesPen plotSetBar plotSetBarFill plotSetBarStacked plotSetBkdColor plotSetFill plotSetGrid plotSetLegend plotSetLineColor plotSetLineStyle plotSetLineSymbol plotSetLineThickness plotSetNewWindow plotSetTitle plotSetWhichYAxis plotSetXAxisShow plotSetXLabel plotSetXRange plotSetXTicInterval plotSetXTicLabel plotSetYAxisShow plotSetYLabel plotSetYRange plotSetZAxisShow plotSetZLabel plotSurface plotTS plotXY polar polychar polyeval polygamma polyint polymake polymat polymroot polymult polyroot pqgwin previousindex princomp printfm printfmt prodc psi putarray putf putvals pvCreate pvGetIndex pvGetParNames pvGetParVector pvLength pvList pvPack pvPacki pvPackm pvPackmi pvPacks pvPacksi pvPacksm pvPacksmi pvPutParVector pvTest pvUnpack QNewton QNewtonmt QNewtonmtControlCreate QNewtonmtOutCreate QNewtonSet QProg QProgmt QProgmtInCreate qqr qqre qqrep qr qre qrep qrsol qrtsol qtyr qtyre qtyrep quantile quantiled qyr qyre qyrep qz rank rankindx readr real reclassify reclassifyCuts recode recserar recsercp recserrc rerun rescale reshape rets rev rfft rffti rfftip rfftn rfftnp rfftp rndBernoulli rndBeta rndBinomial rndCauchy rndChiSquare rndCon rndCreateState rndExp rndGamma rndGeo rndGumbel rndHyperGeo rndi rndKMbeta rndKMgam rndKMi rndKMn rndKMnb rndKMp rndKMu rndKMvm rndLaplace rndLCbeta rndLCgam rndLCi rndLCn rndLCnb rndLCp rndLCu rndLCvm rndLogNorm rndMTu rndMVn rndMVt rndn rndnb rndNegBinomial rndp rndPoisson rndRayleigh rndStateSkip rndu rndvm rndWeibull rndWishart rotater round rows rowsf rref sampleData satostrC saved saveStruct savewind scale scale3d scalerr scalinfnanmiss scalmiss schtoc schur searchsourcepath seekr select selif seqa seqm setdif setdifsa setvars setvwrmode setwind shell shiftr sin singleindex sinh sleep solpd sortc sortcc sortd sorthc sorthcc sortind sortindc sortmc sortr sortrc spBiconjGradSol spChol spConjGradSol spCreate spDenseSubmat spDiagRvMat spEigv spEye spLDL spline spLU spNumNZE spOnes spreadSheetReadM spreadSheetReadSA spreadSheetWrite spScale spSubmat spToDense spTrTDense spTScalar spZeros sqpSolve sqpSolveMT sqpSolveMTControlCreate sqpSolveMTlagrangeCreate sqpSolveMToutCreate sqpSolveSet sqrt statements stdc stdsc stocv stof strcombine strindx strlen strput strrindx strsect strsplit strsplitPad strtodt strtof strtofcplx strtriml strtrimr strtrunc strtruncl strtruncpad strtruncr submat subscat substute subvec sumc sumr surface svd svd1 svd2 svdcusv svds svdusv sysstate tab tan tanh tempname time timedt timestr timeutc title tkf2eps tkf2ps tocart todaydt toeplitz token topolar trapchk trigamma trimr trunc type typecv typef union unionsa uniqindx uniqindxsa unique uniquesa upmat upmat1 upper utctodt utctodtv utrisol vals varCovMS varCovXS varget vargetl varmall varmares varput varputl vartypef vcm vcms vcx vcxs vec vech vecr vector vget view viewxyz vlist vnamecv volume vput vread vtypecv wait waitc walkindex where window writer xlabel xlsGetSheetCount xlsGetSheetSize xlsGetSheetTypes xlsMakeRange xlsReadM xlsReadSA xlsWrite xlsWriteM xlsWriteSA xpnd xtics xy xyz ylabel ytics zeros zeta zlabel ztics cdfEmpirical dot h5create h5open h5read h5readAttribute h5write h5writeAttribute ldl plotAddErrorBar plotAddSurface plotCDFEmpirical plotSetColormap plotSetContourLabels plotSetLegendFont plotSetTextInterpreter plotSetXTicCount plotSetYTicCount plotSetZLevels powerm strjoin sylvester strtrim",
-literal:"DB_AFTER_LAST_ROW DB_ALL_TABLES DB_BATCH_OPERATIONS DB_BEFORE_FIRST_ROW DB_BLOB DB_EVENT_NOTIFICATIONS DB_FINISH_QUERY DB_HIGH_PRECISION DB_LAST_INSERT_ID DB_LOW_PRECISION_DOUBLE DB_LOW_PRECISION_INT32 DB_LOW_PRECISION_INT64 DB_LOW_PRECISION_NUMBERS DB_MULTIPLE_RESULT_SETS DB_NAMED_PLACEHOLDERS DB_POSITIONAL_PLACEHOLDERS DB_PREPARED_QUERIES DB_QUERY_SIZE DB_SIMPLE_LOCKING DB_SYSTEM_TABLES DB_TABLES DB_TRANSACTIONS DB_UNICODE DB_VIEWS __STDIN __STDOUT __STDERR __FILE_DIR"
-},a=e.COMMENT("@","@"),r={className:"meta",begin:"#",end:"$",keywords:{
-"meta-keyword":"define definecs|10 undef ifdef ifndef iflight ifdllcall ifmac ifos2win ifunix else endif lineson linesoff srcfile srcline"
-},contains:[{begin:/\\\n/,relevance:0},{beginKeywords:"include",end:"$",
-keywords:{"meta-keyword":"include"},contains:[{className:"meta-string",
-begin:'"',end:'"',illegal:"\\n"}]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a]},n={begin:/\bstruct\s+/,
-end:/\s/,keywords:"struct",contains:[{className:"type",
-begin:e.UNDERSCORE_IDENT_RE,relevance:0}]},s=[{className:"params",begin:/\(/,
-end:/\)/,excludeBegin:!0,excludeEnd:!0,endsWithParent:!0,relevance:0,contains:[{
-className:"literal",begin:/\.\.\./},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,a,n]
-}],o={className:"title",begin:e.UNDERSCORE_IDENT_RE,relevance:0},d=(t,r,n)=>{
-const d=e.inherit({className:"function",beginKeywords:t,end:r,excludeEnd:!0,
-contains:[].concat(s)},n||{})
-;return d.contains.push(o),d.contains.push(e.C_NUMBER_MODE),
-d.contains.push(e.C_BLOCK_COMMENT_MODE),d.contains.push(a),d},l={
-className:"built_in",begin:"\\b("+t.built_in.split(" ").join("|")+")\\b"},i={
-className:"string",begin:'"',end:'"',contains:[e.BACKSLASH_ESCAPE],relevance:0
-},c={begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,keywords:t,
-relevance:0,contains:[{beginKeywords:t.keyword},l,{className:"built_in",
-begin:e.UNDERSCORE_IDENT_RE,relevance:0}]},p={begin:/\(/,end:/\)/,relevance:0,
-keywords:{built_in:t.built_in,literal:t.literal},
-contains:[e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,a,l,c,i,"self"]}
-;return c.contains.push(p),{name:"GAUSS",aliases:["gss"],case_insensitive:!0,
-keywords:t,illegal:/(\{[%#]|[%#]\}| <- )/,
-contains:[e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,i,r,{
-className:"keyword",
-begin:/\bexternal (matrix|string|array|sparse matrix|struct|proc|keyword|fn)/
-},d("proc keyword",";"),d("fn","="),{beginKeywords:"for threadfor",end:/;/,
-relevance:0,contains:[e.C_BLOCK_COMMENT_MODE,a,p]},{variants:[{
-begin:e.UNDERSCORE_IDENT_RE+"\\."+e.UNDERSCORE_IDENT_RE},{
-begin:e.UNDERSCORE_IDENT_RE+"\\s*="}],relevance:0},c,n]}}})());
-hljs.registerLanguage("gcode",(()=>{"use strict";return e=>{
-const a=e.inherit(e.C_NUMBER_MODE,{
-begin:"([-+]?((\\.\\d+)|(\\d+)(\\.\\d*)?))|"+e.C_NUMBER_RE
-}),n=[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.COMMENT(/\(/,/\)/),a,e.inherit(e.APOS_STRING_MODE,{
-illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"name",
-begin:"([G])([0-9]+\\.?[0-9]?)"},{className:"name",
-begin:"([M])([0-9]+\\.?[0-9]?)"},{className:"attr",begin:"(VC|VS|#)",
-end:"(\\d+)"},{className:"attr",begin:"(VZOFX|VZOFY|VZOFZ)"},{
-className:"built_in",
-begin:"(ATAN|ABS|ACOS|ASIN|SIN|COS|EXP|FIX|FUP|ROUND|LN|TAN)(\\[)",contains:[a],
-end:"\\]"},{className:"symbol",variants:[{begin:"N",end:"\\d+",illegal:"\\W"}]}]
-;return{name:"G-code (ISO 6983)",aliases:["nc"],case_insensitive:!0,keywords:{
-$pattern:"[A-Z_][A-Z0-9_.]*",
-keyword:"IF DO WHILE ENDWHILE CALL ENDIF SUB ENDSUB GOTO REPEAT ENDREPEAT EQ LT GT NE GE LE OR XOR"
-},contains:[{className:"meta",begin:"%"},{className:"meta",begin:"([O])([0-9]+)"
-}].concat(n)}}})());
-hljs.registerLanguage("gherkin",(()=>{"use strict";return e=>({name:"Gherkin",
-aliases:["feature"],
-keywords:"Feature Background Ability Business Need Scenario Scenarios Scenario Outline Scenario Template Examples Given And Then But When",
-contains:[{className:"symbol",begin:"\\*",relevance:0},{className:"meta",
-begin:"@[^@\\s]+"},{begin:"\\|",end:"\\|\\w*$",contains:[{className:"string",
-begin:"[^|]+"}]},{className:"variable",begin:"<",end:">"},e.HASH_COMMENT_MODE,{
-className:"string",begin:'"""',end:'"""'},e.QUOTE_STRING_MODE]})})());
-hljs.registerLanguage("glsl",(()=>{"use strict";return e=>({name:"GLSL",
-keywords:{
-keyword:"break continue discard do else for if return while switch case default attribute binding buffer ccw centroid centroid varying coherent column_major const cw depth_any depth_greater depth_less depth_unchanged early_fragment_tests equal_spacing flat fractional_even_spacing fractional_odd_spacing highp in index inout invariant invocations isolines layout line_strip lines lines_adjacency local_size_x local_size_y local_size_z location lowp max_vertices mediump noperspective offset origin_upper_left out packed patch pixel_center_integer point_mode points precise precision quads r11f_g11f_b10f r16 r16_snorm r16f r16i r16ui r32f r32i r32ui r8 r8_snorm r8i r8ui readonly restrict rg16 rg16_snorm rg16f rg16i rg16ui rg32f rg32i rg32ui rg8 rg8_snorm rg8i rg8ui rgb10_a2 rgb10_a2ui rgba16 rgba16_snorm rgba16f rgba16i rgba16ui rgba32f rgba32i rgba32ui rgba8 rgba8_snorm rgba8i rgba8ui row_major sample shared smooth std140 std430 stream triangle_strip triangles triangles_adjacency uniform varying vertices volatile writeonly",
-type:"atomic_uint bool bvec2 bvec3 bvec4 dmat2 dmat2x2 dmat2x3 dmat2x4 dmat3 dmat3x2 dmat3x3 dmat3x4 dmat4 dmat4x2 dmat4x3 dmat4x4 double dvec2 dvec3 dvec4 float iimage1D iimage1DArray iimage2D iimage2DArray iimage2DMS iimage2DMSArray iimage2DRect iimage3D iimageBuffer iimageCube iimageCubeArray image1D image1DArray image2D image2DArray image2DMS image2DMSArray image2DRect image3D imageBuffer imageCube imageCubeArray int isampler1D isampler1DArray isampler2D isampler2DArray isampler2DMS isampler2DMSArray isampler2DRect isampler3D isamplerBuffer isamplerCube isamplerCubeArray ivec2 ivec3 ivec4 mat2 mat2x2 mat2x3 mat2x4 mat3 mat3x2 mat3x3 mat3x4 mat4 mat4x2 mat4x3 mat4x4 sampler1D sampler1DArray sampler1DArrayShadow sampler1DShadow sampler2D sampler2DArray sampler2DArrayShadow sampler2DMS sampler2DMSArray sampler2DRect sampler2DRectShadow sampler2DShadow sampler3D samplerBuffer samplerCube samplerCubeArray samplerCubeArrayShadow samplerCubeShadow image1D uimage1DArray uimage2D uimage2DArray uimage2DMS uimage2DMSArray uimage2DRect uimage3D uimageBuffer uimageCube uimageCubeArray uint usampler1D usampler1DArray usampler2D usampler2DArray usampler2DMS usampler2DMSArray usampler2DRect usampler3D samplerBuffer usamplerCube usamplerCubeArray uvec2 uvec3 uvec4 vec2 vec3 vec4 void",
-built_in:"gl_MaxAtomicCounterBindings gl_MaxAtomicCounterBufferSize gl_MaxClipDistances gl_MaxClipPlanes gl_MaxCombinedAtomicCounterBuffers gl_MaxCombinedAtomicCounters gl_MaxCombinedImageUniforms gl_MaxCombinedImageUnitsAndFragmentOutputs gl_MaxCombinedTextureImageUnits gl_MaxComputeAtomicCounterBuffers gl_MaxComputeAtomicCounters gl_MaxComputeImageUniforms gl_MaxComputeTextureImageUnits gl_MaxComputeUniformComponents gl_MaxComputeWorkGroupCount gl_MaxComputeWorkGroupSize gl_MaxDrawBuffers gl_MaxFragmentAtomicCounterBuffers gl_MaxFragmentAtomicCounters gl_MaxFragmentImageUniforms gl_MaxFragmentInputComponents gl_MaxFragmentInputVectors gl_MaxFragmentUniformComponents gl_MaxFragmentUniformVectors gl_MaxGeometryAtomicCounterBuffers gl_MaxGeometryAtomicCounters gl_MaxGeometryImageUniforms gl_MaxGeometryInputComponents gl_MaxGeometryOutputComponents gl_MaxGeometryOutputVertices gl_MaxGeometryTextureImageUnits gl_MaxGeometryTotalOutputComponents gl_MaxGeometryUniformComponents gl_MaxGeometryVaryingComponents gl_MaxImageSamples gl_MaxImageUnits gl_MaxLights gl_MaxPatchVertices gl_MaxProgramTexelOffset gl_MaxTessControlAtomicCounterBuffers gl_MaxTessControlAtomicCounters gl_MaxTessControlImageUniforms gl_MaxTessControlInputComponents gl_MaxTessControlOutputComponents gl_MaxTessControlTextureImageUnits gl_MaxTessControlTotalOutputComponents gl_MaxTessControlUniformComponents gl_MaxTessEvaluationAtomicCounterBuffers gl_MaxTessEvaluationAtomicCounters gl_MaxTessEvaluationImageUniforms gl_MaxTessEvaluationInputComponents gl_MaxTessEvaluationOutputComponents gl_MaxTessEvaluationTextureImageUnits gl_MaxTessEvaluationUniformComponents gl_MaxTessGenLevel gl_MaxTessPatchComponents gl_MaxTextureCoords gl_MaxTextureImageUnits gl_MaxTextureUnits gl_MaxVaryingComponents gl_MaxVaryingFloats gl_MaxVaryingVectors gl_MaxVertexAtomicCounterBuffers gl_MaxVertexAtomicCounters gl_MaxVertexAttribs gl_MaxVertexImageUniforms gl_MaxVertexOutputComponents gl_MaxVertexOutputVectors gl_MaxVertexTextureImageUnits gl_MaxVertexUniformComponents gl_MaxVertexUniformVectors gl_MaxViewports gl_MinProgramTexelOffset gl_BackColor gl_BackLightModelProduct gl_BackLightProduct gl_BackMaterial gl_BackSecondaryColor gl_ClipDistance gl_ClipPlane gl_ClipVertex gl_Color gl_DepthRange gl_EyePlaneQ gl_EyePlaneR gl_EyePlaneS gl_EyePlaneT gl_Fog gl_FogCoord gl_FogFragCoord gl_FragColor gl_FragCoord gl_FragData gl_FragDepth gl_FrontColor gl_FrontFacing gl_FrontLightModelProduct gl_FrontLightProduct gl_FrontMaterial gl_FrontSecondaryColor gl_GlobalInvocationID gl_InstanceID gl_InvocationID gl_Layer gl_LightModel gl_LightSource gl_LocalInvocationID gl_LocalInvocationIndex gl_ModelViewMatrix gl_ModelViewMatrixInverse gl_ModelViewMatrixInverseTranspose gl_ModelViewMatrixTranspose gl_ModelViewProjectionMatrix gl_ModelViewProjectionMatrixInverse gl_ModelViewProjectionMatrixInverseTranspose gl_ModelViewProjectionMatrixTranspose gl_MultiTexCoord0 gl_MultiTexCoord1 gl_MultiTexCoord2 gl_MultiTexCoord3 gl_MultiTexCoord4 gl_MultiTexCoord5 gl_MultiTexCoord6 gl_MultiTexCoord7 gl_Normal gl_NormalMatrix gl_NormalScale gl_NumSamples gl_NumWorkGroups gl_ObjectPlaneQ gl_ObjectPlaneR gl_ObjectPlaneS gl_ObjectPlaneT gl_PatchVerticesIn gl_Point gl_PointCoord gl_PointSize gl_Position gl_PrimitiveID gl_PrimitiveIDIn gl_ProjectionMatrix gl_ProjectionMatrixInverse gl_ProjectionMatrixInverseTranspose gl_ProjectionMatrixTranspose gl_SampleID gl_SampleMask gl_SampleMaskIn gl_SamplePosition gl_SecondaryColor gl_TessCoord gl_TessLevelInner gl_TessLevelOuter gl_TexCoord gl_TextureEnvColor gl_TextureMatrix gl_TextureMatrixInverse gl_TextureMatrixInverseTranspose gl_TextureMatrixTranspose gl_Vertex gl_VertexID gl_ViewportIndex gl_WorkGroupID gl_WorkGroupSize gl_in gl_out EmitStreamVertex EmitVertex EndPrimitive EndStreamPrimitive abs acos acosh all any asin asinh atan atanh atomicAdd atomicAnd atomicCompSwap atomicCounter atomicCounterDecrement atomicCounterIncrement atomicExchange atomicMax atomicMin atomicOr atomicXor barrier bitCount bitfieldExtract bitfieldInsert bitfieldReverse ceil clamp cos cosh cross dFdx dFdy degrees determinant distance dot equal exp exp2 faceforward findLSB findMSB floatBitsToInt floatBitsToUint floor fma fract frexp ftransform fwidth greaterThan greaterThanEqual groupMemoryBarrier imageAtomicAdd imageAtomicAnd imageAtomicCompSwap imageAtomicExchange imageAtomicMax imageAtomicMin imageAtomicOr imageAtomicXor imageLoad imageSize imageStore imulExtended intBitsToFloat interpolateAtCentroid interpolateAtOffset interpolateAtSample inverse inversesqrt isinf isnan ldexp length lessThan lessThanEqual log log2 matrixCompMult max memoryBarrier memoryBarrierAtomicCounter memoryBarrierBuffer memoryBarrierImage memoryBarrierShared min mix mod modf noise1 noise2 noise3 noise4 normalize not notEqual outerProduct packDouble2x32 packHalf2x16 packSnorm2x16 packSnorm4x8 packUnorm2x16 packUnorm4x8 pow radians reflect refract round roundEven shadow1D shadow1DLod shadow1DProj shadow1DProjLod shadow2D shadow2DLod shadow2DProj shadow2DProjLod sign sin sinh smoothstep sqrt step tan tanh texelFetch texelFetchOffset texture texture1D texture1DLod texture1DProj texture1DProjLod texture2D texture2DLod texture2DProj texture2DProjLod texture3D texture3DLod texture3DProj texture3DProjLod textureCube textureCubeLod textureGather textureGatherOffset textureGatherOffsets textureGrad textureGradOffset textureLod textureLodOffset textureOffset textureProj textureProjGrad textureProjGradOffset textureProjLod textureProjLodOffset textureProjOffset textureQueryLevels textureQueryLod textureSize transpose trunc uaddCarry uintBitsToFloat umulExtended unpackDouble2x32 unpackHalf2x16 unpackSnorm2x16 unpackSnorm4x8 unpackUnorm2x16 unpackUnorm4x8 usubBorrow",
-literal:"true false"},illegal:'"',
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"#",end:"$"}]})})());
-hljs.registerLanguage("gml",(()=>{"use strict";return e=>({name:"GML",
-case_insensitive:!1,keywords:{
-keyword:"begin end if then else while do for break continue with until repeat exit and or xor not return mod div switch case default var globalvar enum function constructor delete #macro #region #endregion",
-built_in:"is_real is_string is_array is_undefined is_int32 is_int64 is_ptr is_vec3 is_vec4 is_matrix is_bool is_method is_struct is_infinity is_nan is_numeric typeof variable_global_exists variable_global_get variable_global_set variable_instance_exists variable_instance_get variable_instance_set variable_instance_get_names variable_struct_exists variable_struct_get variable_struct_get_names variable_struct_names_count variable_struct_remove variable_struct_set array_delete array_insert array_length array_length_1d array_length_2d array_height_2d array_equals array_create array_copy array_pop array_push array_resize array_sort random random_range irandom irandom_range random_set_seed random_get_seed randomize randomise choose abs round floor ceil sign frac sqrt sqr exp ln log2 log10 sin cos tan arcsin arccos arctan arctan2 dsin dcos dtan darcsin darccos darctan darctan2 degtorad radtodeg power logn min max mean median clamp lerp dot_product dot_product_3d dot_product_normalised dot_product_3d_normalised dot_product_normalized dot_product_3d_normalized math_set_epsilon math_get_epsilon angle_difference point_distance_3d point_distance point_direction lengthdir_x lengthdir_y real string int64 ptr string_format chr ansi_char ord string_length string_byte_length string_pos string_copy string_char_at string_ord_at string_byte_at string_set_byte_at string_delete string_insert string_lower string_upper string_repeat string_letters string_digits string_lettersdigits string_replace string_replace_all string_count string_hash_to_newline clipboard_has_text clipboard_set_text clipboard_get_text date_current_datetime date_create_datetime date_valid_datetime date_inc_year date_inc_month date_inc_week date_inc_day date_inc_hour date_inc_minute date_inc_second date_get_year date_get_month date_get_week date_get_day date_get_hour date_get_minute date_get_second date_get_weekday date_get_day_of_year date_get_hour_of_year date_get_minute_of_year date_get_second_of_year date_year_span date_month_span date_week_span date_day_span date_hour_span date_minute_span date_second_span date_compare_datetime date_compare_date date_compare_time date_date_of date_time_of date_datetime_string date_date_string date_time_string date_days_in_month date_days_in_year date_leap_year date_is_today date_set_timezone date_get_timezone game_set_speed game_get_speed motion_set motion_add place_free place_empty place_meeting place_snapped move_random move_snap move_towards_point move_contact_solid move_contact_all move_outside_solid move_outside_all move_bounce_solid move_bounce_all move_wrap distance_to_point distance_to_object position_empty position_meeting path_start path_end mp_linear_step mp_potential_step mp_linear_step_object mp_potential_step_object mp_potential_settings mp_linear_path mp_potential_path mp_linear_path_object mp_potential_path_object mp_grid_create mp_grid_destroy mp_grid_clear_all mp_grid_clear_cell mp_grid_clear_rectangle mp_grid_add_cell mp_grid_get_cell mp_grid_add_rectangle mp_grid_add_instances mp_grid_path mp_grid_draw mp_grid_to_ds_grid collision_point collision_rectangle collision_circle collision_ellipse collision_line collision_point_list collision_rectangle_list collision_circle_list collision_ellipse_list collision_line_list instance_position_list instance_place_list point_in_rectangle point_in_triangle point_in_circle rectangle_in_rectangle rectangle_in_triangle rectangle_in_circle instance_find instance_exists instance_number instance_position instance_nearest instance_furthest instance_place instance_create_depth instance_create_layer instance_copy instance_change instance_destroy position_destroy position_change instance_id_get instance_deactivate_all instance_deactivate_object instance_deactivate_region instance_activate_all instance_activate_object instance_activate_region room_goto room_goto_previous room_goto_next room_previous room_next room_restart game_end game_restart game_load game_save game_save_buffer game_load_buffer event_perform event_user event_perform_object event_inherited show_debug_message show_debug_overlay debug_event debug_get_callstack alarm_get alarm_set font_texture_page_size keyboard_set_map keyboard_get_map keyboard_unset_map keyboard_check keyboard_check_pressed keyboard_check_released keyboard_check_direct keyboard_get_numlock keyboard_set_numlock keyboard_key_press keyboard_key_release keyboard_clear io_clear mouse_check_button mouse_check_button_pressed mouse_check_button_released mouse_wheel_up mouse_wheel_down mouse_clear draw_self draw_sprite draw_sprite_pos draw_sprite_ext draw_sprite_stretched draw_sprite_stretched_ext draw_sprite_tiled draw_sprite_tiled_ext draw_sprite_part draw_sprite_part_ext draw_sprite_general draw_clear draw_clear_alpha draw_point draw_line draw_line_width draw_rectangle draw_roundrect draw_roundrect_ext draw_triangle draw_circle draw_ellipse draw_set_circle_precision draw_arrow draw_button draw_path draw_healthbar draw_getpixel draw_getpixel_ext draw_set_colour draw_set_color draw_set_alpha draw_get_colour draw_get_color draw_get_alpha merge_colour make_colour_rgb make_colour_hsv colour_get_red colour_get_green colour_get_blue colour_get_hue colour_get_saturation colour_get_value merge_color make_color_rgb make_color_hsv color_get_red color_get_green color_get_blue color_get_hue color_get_saturation color_get_value merge_color screen_save screen_save_part draw_set_font draw_set_halign draw_set_valign draw_text draw_text_ext string_width string_height string_width_ext string_height_ext draw_text_transformed draw_text_ext_transformed draw_text_colour draw_text_ext_colour draw_text_transformed_colour draw_text_ext_transformed_colour draw_text_color draw_text_ext_color draw_text_transformed_color draw_text_ext_transformed_color draw_point_colour draw_line_colour draw_line_width_colour draw_rectangle_colour draw_roundrect_colour draw_roundrect_colour_ext draw_triangle_colour draw_circle_colour draw_ellipse_colour draw_point_color draw_line_color draw_line_width_color draw_rectangle_color draw_roundrect_color draw_roundrect_color_ext draw_triangle_color draw_circle_color draw_ellipse_color draw_primitive_begin draw_vertex draw_vertex_colour draw_vertex_color draw_primitive_end sprite_get_uvs font_get_uvs sprite_get_texture font_get_texture texture_get_width texture_get_height texture_get_uvs draw_primitive_begin_texture draw_vertex_texture draw_vertex_texture_colour draw_vertex_texture_color texture_global_scale surface_create surface_create_ext surface_resize surface_free surface_exists surface_get_width surface_get_height surface_get_texture surface_set_target surface_set_target_ext surface_reset_target surface_depth_disable surface_get_depth_disable draw_surface draw_surface_stretched draw_surface_tiled draw_surface_part draw_surface_ext draw_surface_stretched_ext draw_surface_tiled_ext draw_surface_part_ext draw_surface_general surface_getpixel surface_getpixel_ext surface_save surface_save_part surface_copy surface_copy_part application_surface_draw_enable application_get_position application_surface_enable application_surface_is_enabled display_get_width display_get_height display_get_orientation display_get_gui_width display_get_gui_height display_reset display_mouse_get_x display_mouse_get_y display_mouse_set display_set_ui_visibility window_set_fullscreen window_get_fullscreen window_set_caption window_set_min_width window_set_max_width window_set_min_height window_set_max_height window_get_visible_rects window_get_caption window_set_cursor window_get_cursor window_set_colour window_get_colour window_set_color window_get_color window_set_position window_set_size window_set_rectangle window_center window_get_x window_get_y window_get_width window_get_height window_mouse_get_x window_mouse_get_y window_mouse_set window_view_mouse_get_x window_view_mouse_get_y window_views_mouse_get_x window_views_mouse_get_y audio_listener_position audio_listener_velocity audio_listener_orientation audio_emitter_position audio_emitter_create audio_emitter_free audio_emitter_exists audio_emitter_pitch audio_emitter_velocity audio_emitter_falloff audio_emitter_gain audio_play_sound audio_play_sound_on audio_play_sound_at audio_stop_sound audio_resume_music audio_music_is_playing audio_resume_sound audio_pause_sound audio_pause_music audio_channel_num audio_sound_length audio_get_type audio_falloff_set_model audio_play_music audio_stop_music audio_master_gain audio_music_gain audio_sound_gain audio_sound_pitch audio_stop_all audio_resume_all audio_pause_all audio_is_playing audio_is_paused audio_exists audio_sound_set_track_position audio_sound_get_track_position audio_emitter_get_gain audio_emitter_get_pitch audio_emitter_get_x audio_emitter_get_y audio_emitter_get_z audio_emitter_get_vx audio_emitter_get_vy audio_emitter_get_vz audio_listener_set_position audio_listener_set_velocity audio_listener_set_orientation audio_listener_get_data audio_set_master_gain audio_get_master_gain audio_sound_get_gain audio_sound_get_pitch audio_get_name audio_sound_set_track_position audio_sound_get_track_position audio_create_stream audio_destroy_stream audio_create_sync_group audio_destroy_sync_group audio_play_in_sync_group audio_start_sync_group audio_stop_sync_group audio_pause_sync_group audio_resume_sync_group audio_sync_group_get_track_pos audio_sync_group_debug audio_sync_group_is_playing audio_debug audio_group_load audio_group_unload audio_group_is_loaded audio_group_load_progress audio_group_name audio_group_stop_all audio_group_set_gain audio_create_buffer_sound audio_free_buffer_sound audio_create_play_queue audio_free_play_queue audio_queue_sound audio_get_recorder_count audio_get_recorder_info audio_start_recording audio_stop_recording audio_sound_get_listener_mask audio_emitter_get_listener_mask audio_get_listener_mask audio_sound_set_listener_mask audio_emitter_set_listener_mask audio_set_listener_mask audio_get_listener_count audio_get_listener_info audio_system show_message show_message_async clickable_add clickable_add_ext clickable_change clickable_change_ext clickable_delete clickable_exists clickable_set_style show_question show_question_async get_integer get_string get_integer_async get_string_async get_login_async get_open_filename get_save_filename get_open_filename_ext get_save_filename_ext show_error highscore_clear highscore_add highscore_value highscore_name draw_highscore sprite_exists sprite_get_name sprite_get_number sprite_get_width sprite_get_height sprite_get_xoffset sprite_get_yoffset sprite_get_bbox_left sprite_get_bbox_right sprite_get_bbox_top sprite_get_bbox_bottom sprite_save sprite_save_strip sprite_set_cache_size sprite_set_cache_size_ext sprite_get_tpe sprite_prefetch sprite_prefetch_multi sprite_flush sprite_flush_multi sprite_set_speed sprite_get_speed_type sprite_get_speed font_exists font_get_name font_get_fontname font_get_bold font_get_italic font_get_first font_get_last font_get_size font_set_cache_size path_exists path_get_name path_get_length path_get_time path_get_kind path_get_closed path_get_precision path_get_number path_get_point_x path_get_point_y path_get_point_speed path_get_x path_get_y path_get_speed script_exists script_get_name timeline_add timeline_delete timeline_clear timeline_exists timeline_get_name timeline_moment_clear timeline_moment_add_script timeline_size timeline_max_moment object_exists object_get_name object_get_sprite object_get_solid object_get_visible object_get_persistent object_get_mask object_get_parent object_get_physics object_is_ancestor room_exists room_get_name sprite_set_offset sprite_duplicate sprite_assign sprite_merge sprite_add sprite_replace sprite_create_from_surface sprite_add_from_surface sprite_delete sprite_set_alpha_from_sprite sprite_collision_mask font_add_enable_aa font_add_get_enable_aa font_add font_add_sprite font_add_sprite_ext font_replace font_replace_sprite font_replace_sprite_ext font_delete path_set_kind path_set_closed path_set_precision path_add path_assign path_duplicate path_append path_delete path_add_point path_insert_point path_change_point path_delete_point path_clear_points path_reverse path_mirror path_flip path_rotate path_rescale path_shift script_execute object_set_sprite object_set_solid object_set_visible object_set_persistent object_set_mask room_set_width room_set_height room_set_persistent room_set_background_colour room_set_background_color room_set_view room_set_viewport room_get_viewport room_set_view_enabled room_add room_duplicate room_assign room_instance_add room_instance_clear room_get_camera room_set_camera asset_get_index asset_get_type file_text_open_from_string file_text_open_read file_text_open_write file_text_open_append file_text_close file_text_write_string file_text_write_real file_text_writeln file_text_read_string file_text_read_real file_text_readln file_text_eof file_text_eoln file_exists file_delete file_rename file_copy directory_exists directory_create directory_destroy file_find_first file_find_next file_find_close file_attributes filename_name filename_path filename_dir filename_drive filename_ext filename_change_ext file_bin_open file_bin_rewrite file_bin_close file_bin_position file_bin_size file_bin_seek file_bin_write_byte file_bin_read_byte parameter_count parameter_string environment_get_variable ini_open_from_string ini_open ini_close ini_read_string ini_read_real ini_write_string ini_write_real ini_key_exists ini_section_exists ini_key_delete ini_section_delete ds_set_precision ds_exists ds_stack_create ds_stack_destroy ds_stack_clear ds_stack_copy ds_stack_size ds_stack_empty ds_stack_push ds_stack_pop ds_stack_top ds_stack_write ds_stack_read ds_queue_create ds_queue_destroy ds_queue_clear ds_queue_copy ds_queue_size ds_queue_empty ds_queue_enqueue ds_queue_dequeue ds_queue_head ds_queue_tail ds_queue_write ds_queue_read ds_list_create ds_list_destroy ds_list_clear ds_list_copy ds_list_size ds_list_empty ds_list_add ds_list_insert ds_list_replace ds_list_delete ds_list_find_index ds_list_find_value ds_list_mark_as_list ds_list_mark_as_map ds_list_sort ds_list_shuffle ds_list_write ds_list_read ds_list_set ds_map_create ds_map_destroy ds_map_clear ds_map_copy ds_map_size ds_map_empty ds_map_add ds_map_add_list ds_map_add_map ds_map_replace ds_map_replace_map ds_map_replace_list ds_map_delete ds_map_exists ds_map_find_value ds_map_find_previous ds_map_find_next ds_map_find_first ds_map_find_last ds_map_write ds_map_read ds_map_secure_save ds_map_secure_load ds_map_secure_load_buffer ds_map_secure_save_buffer ds_map_set ds_priority_create ds_priority_destroy ds_priority_clear ds_priority_copy ds_priority_size ds_priority_empty ds_priority_add ds_priority_change_priority ds_priority_find_priority ds_priority_delete_value ds_priority_delete_min ds_priority_find_min ds_priority_delete_max ds_priority_find_max ds_priority_write ds_priority_read ds_grid_create ds_grid_destroy ds_grid_copy ds_grid_resize ds_grid_width ds_grid_height ds_grid_clear ds_grid_set ds_grid_add ds_grid_multiply ds_grid_set_region ds_grid_add_region ds_grid_multiply_region ds_grid_set_disk ds_grid_add_disk ds_grid_multiply_disk ds_grid_set_grid_region ds_grid_add_grid_region ds_grid_multiply_grid_region ds_grid_get ds_grid_get_sum ds_grid_get_max ds_grid_get_min ds_grid_get_mean ds_grid_get_disk_sum ds_grid_get_disk_min ds_grid_get_disk_max ds_grid_get_disk_mean ds_grid_value_exists ds_grid_value_x ds_grid_value_y ds_grid_value_disk_exists ds_grid_value_disk_x ds_grid_value_disk_y ds_grid_shuffle ds_grid_write ds_grid_read ds_grid_sort ds_grid_set ds_grid_get effect_create_below effect_create_above effect_clear part_type_create part_type_destroy part_type_exists part_type_clear part_type_shape part_type_sprite part_type_size part_type_scale part_type_orientation part_type_life part_type_step part_type_death part_type_speed part_type_direction part_type_gravity part_type_colour1 part_type_colour2 part_type_colour3 part_type_colour_mix part_type_colour_rgb part_type_colour_hsv part_type_color1 part_type_color2 part_type_color3 part_type_color_mix part_type_color_rgb part_type_color_hsv part_type_alpha1 part_type_alpha2 part_type_alpha3 part_type_blend part_system_create part_system_create_layer part_system_destroy part_system_exists part_system_clear part_system_draw_order part_system_depth part_system_position part_system_automatic_update part_system_automatic_draw part_system_update part_system_drawit part_system_get_layer part_system_layer part_particles_create part_particles_create_colour part_particles_create_color part_particles_clear part_particles_count part_emitter_create part_emitter_destroy part_emitter_destroy_all part_emitter_exists part_emitter_clear part_emitter_region part_emitter_burst part_emitter_stream external_call external_define external_free window_handle window_device matrix_get matrix_set matrix_build_identity matrix_build matrix_build_lookat matrix_build_projection_ortho matrix_build_projection_perspective matrix_build_projection_perspective_fov matrix_multiply matrix_transform_vertex matrix_stack_push matrix_stack_pop matrix_stack_multiply matrix_stack_set matrix_stack_clear matrix_stack_top matrix_stack_is_empty browser_input_capture os_get_config os_get_info os_get_language os_get_region os_lock_orientation display_get_dpi_x display_get_dpi_y display_set_gui_size display_set_gui_maximise display_set_gui_maximize device_mouse_dbclick_enable display_set_timing_method display_get_timing_method display_set_sleep_margin display_get_sleep_margin virtual_key_add virtual_key_hide virtual_key_delete virtual_key_show draw_enable_drawevent draw_enable_swf_aa draw_set_swf_aa_level draw_get_swf_aa_level draw_texture_flush draw_flush gpu_set_blendenable gpu_set_ztestenable gpu_set_zfunc gpu_set_zwriteenable gpu_set_lightingenable gpu_set_fog gpu_set_cullmode gpu_set_blendmode gpu_set_blendmode_ext gpu_set_blendmode_ext_sepalpha gpu_set_colorwriteenable gpu_set_colourwriteenable gpu_set_alphatestenable gpu_set_alphatestref gpu_set_alphatestfunc gpu_set_texfilter gpu_set_texfilter_ext gpu_set_texrepeat gpu_set_texrepeat_ext gpu_set_tex_filter gpu_set_tex_filter_ext gpu_set_tex_repeat gpu_set_tex_repeat_ext gpu_set_tex_mip_filter gpu_set_tex_mip_filter_ext gpu_set_tex_mip_bias gpu_set_tex_mip_bias_ext gpu_set_tex_min_mip gpu_set_tex_min_mip_ext gpu_set_tex_max_mip gpu_set_tex_max_mip_ext gpu_set_tex_max_aniso gpu_set_tex_max_aniso_ext gpu_set_tex_mip_enable gpu_set_tex_mip_enable_ext gpu_get_blendenable gpu_get_ztestenable gpu_get_zfunc gpu_get_zwriteenable gpu_get_lightingenable gpu_get_fog gpu_get_cullmode gpu_get_blendmode gpu_get_blendmode_ext gpu_get_blendmode_ext_sepalpha gpu_get_blendmode_src gpu_get_blendmode_dest gpu_get_blendmode_srcalpha gpu_get_blendmode_destalpha gpu_get_colorwriteenable gpu_get_colourwriteenable gpu_get_alphatestenable gpu_get_alphatestref gpu_get_alphatestfunc gpu_get_texfilter gpu_get_texfilter_ext gpu_get_texrepeat gpu_get_texrepeat_ext gpu_get_tex_filter gpu_get_tex_filter_ext gpu_get_tex_repeat gpu_get_tex_repeat_ext gpu_get_tex_mip_filter gpu_get_tex_mip_filter_ext gpu_get_tex_mip_bias gpu_get_tex_mip_bias_ext gpu_get_tex_min_mip gpu_get_tex_min_mip_ext gpu_get_tex_max_mip gpu_get_tex_max_mip_ext gpu_get_tex_max_aniso gpu_get_tex_max_aniso_ext gpu_get_tex_mip_enable gpu_get_tex_mip_enable_ext gpu_push_state gpu_pop_state gpu_get_state gpu_set_state draw_light_define_ambient draw_light_define_direction draw_light_define_point draw_light_enable draw_set_lighting draw_light_get_ambient draw_light_get draw_get_lighting shop_leave_rating url_get_domain url_open url_open_ext url_open_full get_timer achievement_login achievement_logout achievement_post achievement_increment achievement_post_score achievement_available achievement_show_achievements achievement_show_leaderboards achievement_load_friends achievement_load_leaderboard achievement_send_challenge achievement_load_progress achievement_reset achievement_login_status achievement_get_pic achievement_show_challenge_notifications achievement_get_challenges achievement_event achievement_show achievement_get_info cloud_file_save cloud_string_save cloud_synchronise ads_enable ads_disable ads_setup ads_engagement_launch ads_engagement_available ads_engagement_active ads_event ads_event_preload ads_set_reward_callback ads_get_display_height ads_get_display_width ads_move ads_interstitial_available ads_interstitial_display device_get_tilt_x device_get_tilt_y device_get_tilt_z device_is_keypad_open device_mouse_check_button device_mouse_check_button_pressed device_mouse_check_button_released device_mouse_x device_mouse_y device_mouse_raw_x device_mouse_raw_y device_mouse_x_to_gui device_mouse_y_to_gui iap_activate iap_status iap_enumerate_products iap_restore_all iap_acquire iap_consume iap_product_details iap_purchase_details facebook_init facebook_login facebook_status facebook_graph_request facebook_dialog facebook_logout facebook_launch_offerwall facebook_post_message facebook_send_invite facebook_user_id facebook_accesstoken facebook_check_permission facebook_request_read_permissions facebook_request_publish_permissions gamepad_is_supported gamepad_get_device_count gamepad_is_connected gamepad_get_description gamepad_get_button_threshold gamepad_set_button_threshold gamepad_get_axis_deadzone gamepad_set_axis_deadzone gamepad_button_count gamepad_button_check gamepad_button_check_pressed gamepad_button_check_released gamepad_button_value gamepad_axis_count gamepad_axis_value gamepad_set_vibration gamepad_set_colour gamepad_set_color os_is_paused window_has_focus code_is_compiled http_get http_get_file http_post_string http_request json_encode json_decode zip_unzip load_csv base64_encode base64_decode md5_string_unicode md5_string_utf8 md5_file os_is_network_connected sha1_string_unicode sha1_string_utf8 sha1_file os_powersave_enable analytics_event analytics_event_ext win8_livetile_tile_notification win8_livetile_tile_clear win8_livetile_badge_notification win8_livetile_badge_clear win8_livetile_queue_enable win8_secondarytile_pin win8_secondarytile_badge_notification win8_secondarytile_delete win8_livetile_notification_begin win8_livetile_notification_secondary_begin win8_livetile_notification_expiry win8_livetile_notification_tag win8_livetile_notification_text_add win8_livetile_notification_image_add win8_livetile_notification_end win8_appbar_enable win8_appbar_add_element win8_appbar_remove_element win8_settingscharm_add_entry win8_settingscharm_add_html_entry win8_settingscharm_add_xaml_entry win8_settingscharm_set_xaml_property win8_settingscharm_get_xaml_property win8_settingscharm_remove_entry win8_share_image win8_share_screenshot win8_share_file win8_share_url win8_share_text win8_search_enable win8_search_disable win8_search_add_suggestions win8_device_touchscreen_available win8_license_initialize_sandbox win8_license_trial_version winphone_license_trial_version winphone_tile_title winphone_tile_count winphone_tile_back_title winphone_tile_back_content winphone_tile_back_content_wide winphone_tile_front_image winphone_tile_front_image_small winphone_tile_front_image_wide winphone_tile_back_image winphone_tile_back_image_wide winphone_tile_background_colour winphone_tile_background_color winphone_tile_icon_image winphone_tile_small_icon_image winphone_tile_wide_content winphone_tile_cycle_images winphone_tile_small_background_image physics_world_create physics_world_gravity physics_world_update_speed physics_world_update_iterations physics_world_draw_debug physics_pause_enable physics_fixture_create physics_fixture_set_kinematic physics_fixture_set_density physics_fixture_set_awake physics_fixture_set_restitution physics_fixture_set_friction physics_fixture_set_collision_group physics_fixture_set_sensor physics_fixture_set_linear_damping physics_fixture_set_angular_damping physics_fixture_set_circle_shape physics_fixture_set_box_shape physics_fixture_set_edge_shape physics_fixture_set_polygon_shape physics_fixture_set_chain_shape physics_fixture_add_point physics_fixture_bind physics_fixture_bind_ext physics_fixture_delete physics_apply_force physics_apply_impulse physics_apply_angular_impulse physics_apply_local_force physics_apply_local_impulse physics_apply_torque physics_mass_properties physics_draw_debug physics_test_overlap physics_remove_fixture physics_set_friction physics_set_density physics_set_restitution physics_get_friction physics_get_density physics_get_restitution physics_joint_distance_create physics_joint_rope_create physics_joint_revolute_create physics_joint_prismatic_create physics_joint_pulley_create physics_joint_wheel_create physics_joint_weld_create physics_joint_friction_create physics_joint_gear_create physics_joint_enable_motor physics_joint_get_value physics_joint_set_value physics_joint_delete physics_particle_create physics_particle_delete physics_particle_delete_region_circle physics_particle_delete_region_box physics_particle_delete_region_poly physics_particle_set_flags physics_particle_set_category_flags physics_particle_draw physics_particle_draw_ext physics_particle_count physics_particle_get_data physics_particle_get_data_particle physics_particle_group_begin physics_particle_group_circle physics_particle_group_box physics_particle_group_polygon physics_particle_group_add_point physics_particle_group_end physics_particle_group_join physics_particle_group_delete physics_particle_group_count physics_particle_group_get_data physics_particle_group_get_mass physics_particle_group_get_inertia physics_particle_group_get_centre_x physics_particle_group_get_centre_y physics_particle_group_get_vel_x physics_particle_group_get_vel_y physics_particle_group_get_ang_vel physics_particle_group_get_x physics_particle_group_get_y physics_particle_group_get_angle physics_particle_set_group_flags physics_particle_get_group_flags physics_particle_get_max_count physics_particle_get_radius physics_particle_get_density physics_particle_get_damping physics_particle_get_gravity_scale physics_particle_set_max_count physics_particle_set_radius physics_particle_set_density physics_particle_set_damping physics_particle_set_gravity_scale network_create_socket network_create_socket_ext network_create_server network_create_server_raw network_connect network_connect_raw network_send_packet network_send_raw network_send_broadcast network_send_udp network_send_udp_raw network_set_timeout network_set_config network_resolve network_destroy buffer_create buffer_write buffer_read buffer_seek buffer_get_surface buffer_set_surface buffer_delete buffer_exists buffer_get_type buffer_get_alignment buffer_poke buffer_peek buffer_save buffer_save_ext buffer_load buffer_load_ext buffer_load_partial buffer_copy buffer_fill buffer_get_size buffer_tell buffer_resize buffer_md5 buffer_sha1 buffer_base64_encode buffer_base64_decode buffer_base64_decode_ext buffer_sizeof buffer_get_address buffer_create_from_vertex_buffer buffer_create_from_vertex_buffer_ext buffer_copy_from_vertex_buffer buffer_async_group_begin buffer_async_group_option buffer_async_group_end buffer_load_async buffer_save_async gml_release_mode gml_pragma steam_activate_overlay steam_is_overlay_enabled steam_is_overlay_activated steam_get_persona_name steam_initialised steam_is_cloud_enabled_for_app steam_is_cloud_enabled_for_account steam_file_persisted steam_get_quota_total steam_get_quota_free steam_file_write steam_file_write_file steam_file_read steam_file_delete steam_file_exists steam_file_size steam_file_share steam_is_screenshot_requested steam_send_screenshot steam_is_user_logged_on steam_get_user_steam_id steam_user_owns_dlc steam_user_installed_dlc steam_set_achievement steam_get_achievement steam_clear_achievement steam_set_stat_int steam_set_stat_float steam_set_stat_avg_rate steam_get_stat_int steam_get_stat_float steam_get_stat_avg_rate steam_reset_all_stats steam_reset_all_stats_achievements steam_stats_ready steam_create_leaderboard steam_upload_score steam_upload_score_ext steam_download_scores_around_user steam_download_scores steam_download_friends_scores steam_upload_score_buffer steam_upload_score_buffer_ext steam_current_game_language steam_available_languages steam_activate_overlay_browser steam_activate_overlay_user steam_activate_overlay_store steam_get_user_persona_name steam_get_app_id steam_get_user_account_id steam_ugc_download steam_ugc_create_item steam_ugc_start_item_update steam_ugc_set_item_title steam_ugc_set_item_description steam_ugc_set_item_visibility steam_ugc_set_item_tags steam_ugc_set_item_content steam_ugc_set_item_preview steam_ugc_submit_item_update steam_ugc_get_item_update_progress steam_ugc_subscribe_item steam_ugc_unsubscribe_item steam_ugc_num_subscribed_items steam_ugc_get_subscribed_items steam_ugc_get_item_install_info steam_ugc_get_item_update_info steam_ugc_request_item_details steam_ugc_create_query_user steam_ugc_create_query_user_ex steam_ugc_create_query_all steam_ugc_create_query_all_ex steam_ugc_query_set_cloud_filename_filter steam_ugc_query_set_match_any_tag steam_ugc_query_set_search_text steam_ugc_query_set_ranked_by_trend_days steam_ugc_query_add_required_tag steam_ugc_query_add_excluded_tag steam_ugc_query_set_return_long_description steam_ugc_query_set_return_total_only steam_ugc_query_set_allow_cached_response steam_ugc_send_query shader_set shader_get_name shader_reset shader_current shader_is_compiled shader_get_sampler_index shader_get_uniform shader_set_uniform_i shader_set_uniform_i_array shader_set_uniform_f shader_set_uniform_f_array shader_set_uniform_matrix shader_set_uniform_matrix_array shader_enable_corner_id texture_set_stage texture_get_texel_width texture_get_texel_height shaders_are_supported vertex_format_begin vertex_format_end vertex_format_delete vertex_format_add_position vertex_format_add_position_3d vertex_format_add_colour vertex_format_add_color vertex_format_add_normal vertex_format_add_texcoord vertex_format_add_textcoord vertex_format_add_custom vertex_create_buffer vertex_create_buffer_ext vertex_delete_buffer vertex_begin vertex_end vertex_position vertex_position_3d vertex_colour vertex_color vertex_argb vertex_texcoord vertex_normal vertex_float1 vertex_float2 vertex_float3 vertex_float4 vertex_ubyte4 vertex_submit vertex_freeze vertex_get_number vertex_get_buffer_size vertex_create_buffer_from_buffer vertex_create_buffer_from_buffer_ext push_local_notification push_get_first_local_notification push_get_next_local_notification push_cancel_local_notification skeleton_animation_set skeleton_animation_get skeleton_animation_mix skeleton_animation_set_ext skeleton_animation_get_ext skeleton_animation_get_duration skeleton_animation_get_frames skeleton_animation_clear skeleton_skin_set skeleton_skin_get skeleton_attachment_set skeleton_attachment_get skeleton_attachment_create skeleton_collision_draw_set skeleton_bone_data_get skeleton_bone_data_set skeleton_bone_state_get skeleton_bone_state_set skeleton_get_minmax skeleton_get_num_bounds skeleton_get_bounds skeleton_animation_get_frame skeleton_animation_set_frame draw_skeleton draw_skeleton_time draw_skeleton_instance draw_skeleton_collision skeleton_animation_list skeleton_skin_list skeleton_slot_data layer_get_id layer_get_id_at_depth layer_get_depth layer_create layer_destroy layer_destroy_instances layer_add_instance layer_has_instance layer_set_visible layer_get_visible layer_exists layer_x layer_y layer_get_x layer_get_y layer_hspeed layer_vspeed layer_get_hspeed layer_get_vspeed layer_script_begin layer_script_end layer_shader layer_get_script_begin layer_get_script_end layer_get_shader layer_set_target_room layer_get_target_room layer_reset_target_room layer_get_all layer_get_all_elements layer_get_name layer_depth layer_get_element_layer layer_get_element_type layer_element_move layer_force_draw_depth layer_is_draw_depth_forced layer_get_forced_depth layer_background_get_id layer_background_exists layer_background_create layer_background_destroy layer_background_visible layer_background_change layer_background_sprite layer_background_htiled layer_background_vtiled layer_background_stretch layer_background_yscale layer_background_xscale layer_background_blend layer_background_alpha layer_background_index layer_background_speed layer_background_get_visible layer_background_get_sprite layer_background_get_htiled layer_background_get_vtiled layer_background_get_stretch layer_background_get_yscale layer_background_get_xscale layer_background_get_blend layer_background_get_alpha layer_background_get_index layer_background_get_speed layer_sprite_get_id layer_sprite_exists layer_sprite_create layer_sprite_destroy layer_sprite_change layer_sprite_index layer_sprite_speed layer_sprite_xscale layer_sprite_yscale layer_sprite_angle layer_sprite_blend layer_sprite_alpha layer_sprite_x layer_sprite_y layer_sprite_get_sprite layer_sprite_get_index layer_sprite_get_speed layer_sprite_get_xscale layer_sprite_get_yscale layer_sprite_get_angle layer_sprite_get_blend layer_sprite_get_alpha layer_sprite_get_x layer_sprite_get_y layer_tilemap_get_id layer_tilemap_exists layer_tilemap_create layer_tilemap_destroy tilemap_tileset tilemap_x tilemap_y tilemap_set tilemap_set_at_pixel tilemap_get_tileset tilemap_get_tile_width tilemap_get_tile_height tilemap_get_width tilemap_get_height tilemap_get_x tilemap_get_y tilemap_get tilemap_get_at_pixel tilemap_get_cell_x_at_pixel tilemap_get_cell_y_at_pixel tilemap_clear draw_tilemap draw_tile tilemap_set_global_mask tilemap_get_global_mask tilemap_set_mask tilemap_get_mask tilemap_get_frame tile_set_empty tile_set_index tile_set_flip tile_set_mirror tile_set_rotate tile_get_empty tile_get_index tile_get_flip tile_get_mirror tile_get_rotate layer_tile_exists layer_tile_create layer_tile_destroy layer_tile_change layer_tile_xscale layer_tile_yscale layer_tile_blend layer_tile_alpha layer_tile_x layer_tile_y layer_tile_region layer_tile_visible layer_tile_get_sprite layer_tile_get_xscale layer_tile_get_yscale layer_tile_get_blend layer_tile_get_alpha layer_tile_get_x layer_tile_get_y layer_tile_get_region layer_tile_get_visible layer_instance_get_instance instance_activate_layer instance_deactivate_layer camera_create camera_create_view camera_destroy camera_apply camera_get_active camera_get_default camera_set_default camera_set_view_mat camera_set_proj_mat camera_set_update_script camera_set_begin_script camera_set_end_script camera_set_view_pos camera_set_view_size camera_set_view_speed camera_set_view_border camera_set_view_angle camera_set_view_target camera_get_view_mat camera_get_proj_mat camera_get_update_script camera_get_begin_script camera_get_end_script camera_get_view_x camera_get_view_y camera_get_view_width camera_get_view_height camera_get_view_speed_x camera_get_view_speed_y camera_get_view_border_x camera_get_view_border_y camera_get_view_angle camera_get_view_target view_get_camera view_get_visible view_get_xport view_get_yport view_get_wport view_get_hport view_get_surface_id view_set_camera view_set_visible view_set_xport view_set_yport view_set_wport view_set_hport view_set_surface_id gesture_drag_time gesture_drag_distance gesture_flick_speed gesture_double_tap_time gesture_double_tap_distance gesture_pinch_distance gesture_pinch_angle_towards gesture_pinch_angle_away gesture_rotate_time gesture_rotate_angle gesture_tap_count gesture_get_drag_time gesture_get_drag_distance gesture_get_flick_speed gesture_get_double_tap_time gesture_get_double_tap_distance gesture_get_pinch_distance gesture_get_pinch_angle_towards gesture_get_pinch_angle_away gesture_get_rotate_time gesture_get_rotate_angle gesture_get_tap_count keyboard_virtual_show keyboard_virtual_hide keyboard_virtual_status keyboard_virtual_height",
-literal:"self other all noone global local undefined pointer_invalid pointer_null path_action_stop path_action_restart path_action_continue path_action_reverse true false pi GM_build_date GM_version GM_runtime_version  timezone_local timezone_utc gamespeed_fps gamespeed_microseconds  ev_create ev_destroy ev_step ev_alarm ev_keyboard ev_mouse ev_collision ev_other ev_draw ev_draw_begin ev_draw_end ev_draw_pre ev_draw_post ev_keypress ev_keyrelease ev_trigger ev_left_button ev_right_button ev_middle_button ev_no_button ev_left_press ev_right_press ev_middle_press ev_left_release ev_right_release ev_middle_release ev_mouse_enter ev_mouse_leave ev_mouse_wheel_up ev_mouse_wheel_down ev_global_left_button ev_global_right_button ev_global_middle_button ev_global_left_press ev_global_right_press ev_global_middle_press ev_global_left_release ev_global_right_release ev_global_middle_release ev_joystick1_left ev_joystick1_right ev_joystick1_up ev_joystick1_down ev_joystick1_button1 ev_joystick1_button2 ev_joystick1_button3 ev_joystick1_button4 ev_joystick1_button5 ev_joystick1_button6 ev_joystick1_button7 ev_joystick1_button8 ev_joystick2_left ev_joystick2_right ev_joystick2_up ev_joystick2_down ev_joystick2_button1 ev_joystick2_button2 ev_joystick2_button3 ev_joystick2_button4 ev_joystick2_button5 ev_joystick2_button6 ev_joystick2_button7 ev_joystick2_button8 ev_outside ev_boundary ev_game_start ev_game_end ev_room_start ev_room_end ev_no_more_lives ev_animation_end ev_end_of_path ev_no_more_health ev_close_button ev_user0 ev_user1 ev_user2 ev_user3 ev_user4 ev_user5 ev_user6 ev_user7 ev_user8 ev_user9 ev_user10 ev_user11 ev_user12 ev_user13 ev_user14 ev_user15 ev_step_normal ev_step_begin ev_step_end ev_gui ev_gui_begin ev_gui_end ev_cleanup ev_gesture ev_gesture_tap ev_gesture_double_tap ev_gesture_drag_start ev_gesture_dragging ev_gesture_drag_end ev_gesture_flick ev_gesture_pinch_start ev_gesture_pinch_in ev_gesture_pinch_out ev_gesture_pinch_end ev_gesture_rotate_start ev_gesture_rotating ev_gesture_rotate_end ev_global_gesture_tap ev_global_gesture_double_tap ev_global_gesture_drag_start ev_global_gesture_dragging ev_global_gesture_drag_end ev_global_gesture_flick ev_global_gesture_pinch_start ev_global_gesture_pinch_in ev_global_gesture_pinch_out ev_global_gesture_pinch_end ev_global_gesture_rotate_start ev_global_gesture_rotating ev_global_gesture_rotate_end vk_nokey vk_anykey vk_enter vk_return vk_shift vk_control vk_alt vk_escape vk_space vk_backspace vk_tab vk_pause vk_printscreen vk_left vk_right vk_up vk_down vk_home vk_end vk_delete vk_insert vk_pageup vk_pagedown vk_f1 vk_f2 vk_f3 vk_f4 vk_f5 vk_f6 vk_f7 vk_f8 vk_f9 vk_f10 vk_f11 vk_f12 vk_numpad0 vk_numpad1 vk_numpad2 vk_numpad3 vk_numpad4 vk_numpad5 vk_numpad6 vk_numpad7 vk_numpad8 vk_numpad9 vk_divide vk_multiply vk_subtract vk_add vk_decimal vk_lshift vk_lcontrol vk_lalt vk_rshift vk_rcontrol vk_ralt  mb_any mb_none mb_left mb_right mb_middle c_aqua c_black c_blue c_dkgray c_fuchsia c_gray c_green c_lime c_ltgray c_maroon c_navy c_olive c_purple c_red c_silver c_teal c_white c_yellow c_orange fa_left fa_center fa_right fa_top fa_middle fa_bottom pr_pointlist pr_linelist pr_linestrip pr_trianglelist pr_trianglestrip pr_trianglefan bm_complex bm_normal bm_add bm_max bm_subtract bm_zero bm_one bm_src_colour bm_inv_src_colour bm_src_color bm_inv_src_color bm_src_alpha bm_inv_src_alpha bm_dest_alpha bm_inv_dest_alpha bm_dest_colour bm_inv_dest_colour bm_dest_color bm_inv_dest_color bm_src_alpha_sat tf_point tf_linear tf_anisotropic mip_off mip_on mip_markedonly audio_falloff_none audio_falloff_inverse_distance audio_falloff_inverse_distance_clamped audio_falloff_linear_distance audio_falloff_linear_distance_clamped audio_falloff_exponent_distance audio_falloff_exponent_distance_clamped audio_old_system audio_new_system audio_mono audio_stereo audio_3d cr_default cr_none cr_arrow cr_cross cr_beam cr_size_nesw cr_size_ns cr_size_nwse cr_size_we cr_uparrow cr_hourglass cr_drag cr_appstart cr_handpoint cr_size_all spritespeed_framespersecond spritespeed_framespergameframe asset_object asset_unknown asset_sprite asset_sound asset_room asset_path asset_script asset_font asset_timeline asset_tiles asset_shader fa_readonly fa_hidden fa_sysfile fa_volumeid fa_directory fa_archive  ds_type_map ds_type_list ds_type_stack ds_type_queue ds_type_grid ds_type_priority ef_explosion ef_ring ef_ellipse ef_firework ef_smoke ef_smokeup ef_star ef_spark ef_flare ef_cloud ef_rain ef_snow pt_shape_pixel pt_shape_disk pt_shape_square pt_shape_line pt_shape_star pt_shape_circle pt_shape_ring pt_shape_sphere pt_shape_flare pt_shape_spark pt_shape_explosion pt_shape_cloud pt_shape_smoke pt_shape_snow ps_distr_linear ps_distr_gaussian ps_distr_invgaussian ps_shape_rectangle ps_shape_ellipse ps_shape_diamond ps_shape_line ty_real ty_string dll_cdecl dll_stdcall matrix_view matrix_projection matrix_world os_win32 os_windows os_macosx os_ios os_android os_symbian os_linux os_unknown os_winphone os_tizen os_win8native os_wiiu os_3ds  os_psvita os_bb10 os_ps4 os_xboxone os_ps3 os_xbox360 os_uwp os_tvos os_switch browser_not_a_browser browser_unknown browser_ie browser_firefox browser_chrome browser_safari browser_safari_mobile browser_opera browser_tizen browser_edge browser_windows_store browser_ie_mobile  device_ios_unknown device_ios_iphone device_ios_iphone_retina device_ios_ipad device_ios_ipad_retina device_ios_iphone5 device_ios_iphone6 device_ios_iphone6plus device_emulator device_tablet display_landscape display_landscape_flipped display_portrait display_portrait_flipped tm_sleep tm_countvsyncs of_challenge_win of_challen ge_lose of_challenge_tie leaderboard_type_number leaderboard_type_time_mins_secs cmpfunc_never cmpfunc_less cmpfunc_equal cmpfunc_lessequal cmpfunc_greater cmpfunc_notequal cmpfunc_greaterequal cmpfunc_always cull_noculling cull_clockwise cull_counterclockwise lighttype_dir lighttype_point iap_ev_storeload iap_ev_product iap_ev_purchase iap_ev_consume iap_ev_restore iap_storeload_ok iap_storeload_failed iap_status_uninitialised iap_status_unavailable iap_status_loading iap_status_available iap_status_processing iap_status_restoring iap_failed iap_unavailable iap_available iap_purchased iap_canceled iap_refunded fb_login_default fb_login_fallback_to_webview fb_login_no_fallback_to_webview fb_login_forcing_webview fb_login_use_system_account fb_login_forcing_safari  phy_joint_anchor_1_x phy_joint_anchor_1_y phy_joint_anchor_2_x phy_joint_anchor_2_y phy_joint_reaction_force_x phy_joint_reaction_force_y phy_joint_reaction_torque phy_joint_motor_speed phy_joint_angle phy_joint_motor_torque phy_joint_max_motor_torque phy_joint_translation phy_joint_speed phy_joint_motor_force phy_joint_max_motor_force phy_joint_length_1 phy_joint_length_2 phy_joint_damping_ratio phy_joint_frequency phy_joint_lower_angle_limit phy_joint_upper_angle_limit phy_joint_angle_limits phy_joint_max_length phy_joint_max_torque phy_joint_max_force phy_debug_render_aabb phy_debug_render_collision_pairs phy_debug_render_coms phy_debug_render_core_shapes phy_debug_render_joints phy_debug_render_obb phy_debug_render_shapes  phy_particle_flag_water phy_particle_flag_zombie phy_particle_flag_wall phy_particle_flag_spring phy_particle_flag_elastic phy_particle_flag_viscous phy_particle_flag_powder phy_particle_flag_tensile phy_particle_flag_colourmixing phy_particle_flag_colormixing phy_particle_group_flag_solid phy_particle_group_flag_rigid phy_particle_data_flag_typeflags phy_particle_data_flag_position phy_particle_data_flag_velocity phy_particle_data_flag_colour phy_particle_data_flag_color phy_particle_data_flag_category  achievement_our_info achievement_friends_info achievement_leaderboard_info achievement_achievement_info achievement_filter_all_players achievement_filter_friends_only achievement_filter_favorites_only achievement_type_achievement_challenge achievement_type_score_challenge achievement_pic_loaded  achievement_show_ui achievement_show_profile achievement_show_leaderboard achievement_show_achievement achievement_show_bank achievement_show_friend_picker achievement_show_purchase_prompt network_socket_tcp network_socket_udp network_socket_bluetooth network_type_connect network_type_disconnect network_type_data network_type_non_blocking_connect network_config_connect_timeout network_config_use_non_blocking_socket network_config_enable_reliable_udp network_config_disable_reliable_udp buffer_fixed buffer_grow buffer_wrap buffer_fast buffer_vbuffer buffer_network buffer_u8 buffer_s8 buffer_u16 buffer_s16 buffer_u32 buffer_s32 buffer_u64 buffer_f16 buffer_f32 buffer_f64 buffer_bool buffer_text buffer_string buffer_surface_copy buffer_seek_start buffer_seek_relative buffer_seek_end buffer_generalerror buffer_outofspace buffer_outofbounds buffer_invalidtype  text_type button_type input_type ANSI_CHARSET DEFAULT_CHARSET EASTEUROPE_CHARSET RUSSIAN_CHARSET SYMBOL_CHARSET SHIFTJIS_CHARSET HANGEUL_CHARSET GB2312_CHARSET CHINESEBIG5_CHARSET JOHAB_CHARSET HEBREW_CHARSET ARABIC_CHARSET GREEK_CHARSET TURKISH_CHARSET VIETNAMESE_CHARSET THAI_CHARSET MAC_CHARSET BALTIC_CHARSET OEM_CHARSET  gp_face1 gp_face2 gp_face3 gp_face4 gp_shoulderl gp_shoulderr gp_shoulderlb gp_shoulderrb gp_select gp_start gp_stickl gp_stickr gp_padu gp_padd gp_padl gp_padr gp_axislh gp_axislv gp_axisrh gp_axisrv ov_friends ov_community ov_players ov_settings ov_gamegroup ov_achievements lb_sort_none lb_sort_ascending lb_sort_descending lb_disp_none lb_disp_numeric lb_disp_time_sec lb_disp_time_ms ugc_result_success ugc_filetype_community ugc_filetype_microtrans ugc_visibility_public ugc_visibility_friends_only ugc_visibility_private ugc_query_RankedByVote ugc_query_RankedByPublicationDate ugc_query_AcceptedForGameRankedByAcceptanceDate ugc_query_RankedByTrend ugc_query_FavoritedByFriendsRankedByPublicationDate ugc_query_CreatedByFriendsRankedByPublicationDate ugc_query_RankedByNumTimesReported ugc_query_CreatedByFollowedUsersRankedByPublicationDate ugc_query_NotYetRated ugc_query_RankedByTotalVotesAsc ugc_query_RankedByVotesUp ugc_query_RankedByTextSearch ugc_sortorder_CreationOrderDesc ugc_sortorder_CreationOrderAsc ugc_sortorder_TitleAsc ugc_sortorder_LastUpdatedDesc ugc_sortorder_SubscriptionDateDesc ugc_sortorder_VoteScoreDesc ugc_sortorder_ForModeration ugc_list_Published ugc_list_VotedOn ugc_list_VotedUp ugc_list_VotedDown ugc_list_WillVoteLater ugc_list_Favorited ugc_list_Subscribed ugc_list_UsedOrPlayed ugc_list_Followed ugc_match_Items ugc_match_Items_Mtx ugc_match_Items_ReadyToUse ugc_match_Collections ugc_match_Artwork ugc_match_Videos ugc_match_Screenshots ugc_match_AllGuides ugc_match_WebGuides ugc_match_IntegratedGuides ugc_match_UsableInGame ugc_match_ControllerBindings  vertex_usage_position vertex_usage_colour vertex_usage_color vertex_usage_normal vertex_usage_texcoord vertex_usage_textcoord vertex_usage_blendweight vertex_usage_blendindices vertex_usage_psize vertex_usage_tangent vertex_usage_binormal vertex_usage_fog vertex_usage_depth vertex_usage_sample vertex_type_float1 vertex_type_float2 vertex_type_float3 vertex_type_float4 vertex_type_colour vertex_type_color vertex_type_ubyte4 layerelementtype_undefined layerelementtype_background layerelementtype_instance layerelementtype_oldtilemap layerelementtype_sprite layerelementtype_tilemap layerelementtype_particlesystem layerelementtype_tile tile_rotate tile_flip tile_mirror tile_index_mask kbv_type_default kbv_type_ascii kbv_type_url kbv_type_email kbv_type_numbers kbv_type_phone kbv_type_phone_name kbv_returnkey_default kbv_returnkey_go kbv_returnkey_google kbv_returnkey_join kbv_returnkey_next kbv_returnkey_route kbv_returnkey_search kbv_returnkey_send kbv_returnkey_yahoo kbv_returnkey_done kbv_returnkey_continue kbv_returnkey_emergency kbv_autocapitalize_none kbv_autocapitalize_words kbv_autocapitalize_sentences kbv_autocapitalize_characters",
-symbol:"argument_relative argument argument0 argument1 argument2 argument3 argument4 argument5 argument6 argument7 argument8 argument9 argument10 argument11 argument12 argument13 argument14 argument15 argument_count x|0 y|0 xprevious yprevious xstart ystart hspeed vspeed direction speed friction gravity gravity_direction path_index path_position path_positionprevious path_speed path_scale path_orientation path_endaction object_index id solid persistent mask_index instance_count instance_id room_speed fps fps_real current_time current_year current_month current_day current_weekday current_hour current_minute current_second alarm timeline_index timeline_position timeline_speed timeline_running timeline_loop room room_first room_last room_width room_height room_caption room_persistent score lives health show_score show_lives show_health caption_score caption_lives caption_health event_type event_number event_object event_action application_surface gamemaker_pro gamemaker_registered gamemaker_version error_occurred error_last debug_mode keyboard_key keyboard_lastkey keyboard_lastchar keyboard_string mouse_x mouse_y mouse_button mouse_lastbutton cursor_sprite visible sprite_index sprite_width sprite_height sprite_xoffset sprite_yoffset image_number image_index image_speed depth image_xscale image_yscale image_angle image_alpha image_blend bbox_left bbox_right bbox_top bbox_bottom layer background_colour  background_showcolour background_color background_showcolor view_enabled view_current view_visible view_xview view_yview view_wview view_hview view_xport view_yport view_wport view_hport view_angle view_hborder view_vborder view_hspeed view_vspeed view_object view_surface_id view_camera game_id game_display_name game_project_name game_save_id working_directory temp_directory program_directory browser_width browser_height os_type os_device os_browser os_version display_aa async_load delta_time webgl_enabled event_data iap_data phy_rotation phy_position_x phy_position_y phy_angular_velocity phy_linear_velocity_x phy_linear_velocity_y phy_speed_x phy_speed_y phy_speed phy_angular_damping phy_linear_damping phy_bullet phy_fixed_rotation phy_active phy_mass phy_inertia phy_com_x phy_com_y phy_dynamic phy_kinematic phy_sleeping phy_collision_points phy_collision_x phy_collision_y phy_col_normal_x phy_col_normal_y phy_position_xprevious phy_position_yprevious"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE]
-})})());
-hljs.registerLanguage("go",(()=>{"use strict";return e=>{const n={
-keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",
-literal:"true false iota nil",
-built_in:"append cap close complex copy imag len make new panic print println real recover delete"
-};return{name:"Go",aliases:["golang"],keywords:n,illegal:"</",
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",
-variants:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{begin:"`",end:"`"}]},{
-className:"number",variants:[{begin:e.C_NUMBER_RE+"[i]",relevance:1
-},e.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func",
-end:"\\s*(\\{|$)",excludeEnd:!0,contains:[e.TITLE_MODE,{className:"params",
-begin:/\(/,end:/\)/,keywords:n,illegal:/["']/}]}]}}})());
-hljs.registerLanguage("golo",(()=>{"use strict";return e=>({name:"Golo",
-keywords:{
-keyword:"println readln print import module function local return let var while for foreach times in case when match with break continue augment augmentation each find filter reduce if then else otherwise try catch finally raise throw orIfNull DynamicObject|10 DynamicVariable struct Observable map set vector list array",
-literal:"true false null"},
-contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"@[A-Za-z]+"}]})})());
-hljs.registerLanguage("gradle",(()=>{"use strict";return e=>({name:"Gradle",
-case_insensitive:!0,keywords:{
-keyword:"task project allprojects subprojects artifacts buildscript configurations dependencies repositories sourceSets description delete from into include exclude source classpath destinationDir includes options sourceCompatibility targetCompatibility group flatDir doLast doFirst flatten todir fromdir ant def abstract break case catch continue default do else extends final finally for if implements instanceof native new private protected public return static switch synchronized throw throws transient try volatile while strictfp package import false null super this true antlrtask checkstyle codenarc copy boolean byte char class double float int interface long short void compile runTime file fileTree abs any append asList asWritable call collect compareTo count div dump each eachByte eachFile eachLine every find findAll flatten getAt getErr getIn getOut getText grep immutable inject inspect intersect invokeMethods isCase join leftShift minus multiply newInputStream newOutputStream newPrintWriter newReader newWriter next plus pop power previous print println push putAt read readBytes readLines reverse reverseEach round size sort splitEachLine step subMap times toInteger toList tokenize upto waitForOrKill withPrintWriter withReader withStream withWriter withWriterAppend write writeLine"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.REGEXP_MODE]
-})})());
-hljs.registerLanguage("groovy",(()=>{"use strict";function e(e,n={}){
-return n.variants=e,n}return n=>{
-const a="[A-Za-z0-9_$]+",t=e([n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.COMMENT("/\\*\\*","\\*/",{
-relevance:0,contains:[{begin:/\w+@/,relevance:0},{className:"doctag",
-begin:"@[A-Za-z]+"}]})]),s={className:"regexp",begin:/~?\/[^\/\n]+\//,
-contains:[n.BACKSLASH_ESCAPE]
-},i=e([n.BINARY_NUMBER_MODE,n.C_NUMBER_MODE]),r=e([{begin:/"""/,end:/"""/},{
-begin:/'''/,end:/'''/},{begin:"\\$/",end:"/\\$",relevance:10
-},n.APOS_STRING_MODE,n.QUOTE_STRING_MODE],{className:"string"});return{
-name:"Groovy",keywords:{built_in:"this super",literal:"true false null",
-keyword:"byte short char int long boolean float double void def as in assert trait abstract static volatile transient public private protected synchronized final class interface enum if else for while switch case break default continue throw throws try catch finally implements extends new import package return instanceof"
-},contains:[n.SHEBANG({binary:"groovy",relevance:10}),t,r,s,i,{
-className:"class",beginKeywords:"class interface trait enum",end:/\{/,
-illegal:":",contains:[{beginKeywords:"extends implements"
-},n.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"@[A-Za-z]+",relevance:0},{
-className:"attr",begin:a+"[ \t]*:",relevance:0},{begin:/\?/,end:/:/,relevance:0,
-contains:[t,r,s,i,"self"]},{className:"symbol",
-begin:"^[ \t]*"+(l=a+":",((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",l,")")),
-excludeBegin:!0,end:a+":",relevance:0}],illegal:/#|<\//};var l}})());
-hljs.registerLanguage("haml",(()=>{"use strict";return e=>({name:"HAML",
-case_insensitive:!0,contains:[{className:"meta",
-begin:"^!!!( (5|1\\.1|Strict|Frameset|Basic|Mobile|RDFa|XML\\b.*))?$",
-relevance:10},e.COMMENT("^\\s*(!=#|=#|-#|/).*$",!1,{relevance:0}),{
-begin:"^\\s*(-|=|!=)(?!#)",starts:{end:"\\n",subLanguage:"ruby"}},{
-className:"tag",begin:"^\\s*%",contains:[{className:"selector-tag",begin:"\\w+"
-},{className:"selector-id",begin:"#[\\w-]+"},{className:"selector-class",
-begin:"\\.[\\w-]+"},{begin:/\{\s*/,end:/\s*\}/,contains:[{begin:":\\w+\\s*=>",
-end:",\\s+",returnBegin:!0,endsWithParent:!0,contains:[{className:"attr",
-begin:":\\w+"},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"\\w+",relevance:0
-}]}]},{begin:"\\(\\s*",end:"\\s*\\)",excludeEnd:!0,contains:[{begin:"\\w+\\s*=",
-end:"\\s+",returnBegin:!0,endsWithParent:!0,contains:[{className:"attr",
-begin:"\\w+",relevance:0},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{begin:"\\w+",
-relevance:0}]}]}]},{begin:"^\\s*[=~]\\s*"},{begin:/#\{/,starts:{end:/\}/,
-subLanguage:"ruby"}}]})})());
-hljs.registerLanguage("handlebars",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}return a=>{const t={
-"builtin-name":["action","bindattr","collection","component","concat","debugger","each","each-in","get","hash","if","in","input","link-to","loc","log","lookup","mut","outlet","partial","query-params","render","template","textarea","unbound","unless","view","with","yield"]
-},s=/\[\]|\[[^\]]+\]/,i=/[^\s!"#%&'()*+,.\/;<=>@\[\\\]^`{|}~]+/,r=((...n)=>"("+n.map((n=>e(n))).join("|")+")")(/""|"[^"]+"/,/''|'[^']+'/,s,i),l=n(n("(",/\.|\.\/|\//,")?"),r,(h=n(/(\.|\/)/,r),
-n("(",h,")*"))),c=n("(",s,"|",i,")(?==)"),o={begin:l,lexemes:/[\w.\/]+/
-},m=a.inherit(o,{keywords:{literal:["true","false","undefined","null"]}}),d={
-begin:/\(/,end:/\)/},g={className:"attr",begin:c,relevance:0,starts:{begin:/=/,
-end:/=/,starts:{
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,m,d]}}},b={
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{begin:/as\s+\|/,
-keywords:{keyword:"as"},end:/\|/,contains:[{begin:/\w+/}]},g,m,d],returnEnd:!0
-},u=a.inherit(o,{className:"name",keywords:t,starts:a.inherit(b,{end:/\)/})})
-;var h;d.contains=[u];const N=a.inherit(o,{keywords:t,className:"name",
-starts:a.inherit(b,{end:/\}\}/})}),p=a.inherit(o,{keywords:t,className:"name"
-}),E=a.inherit(o,{className:"name",keywords:t,starts:a.inherit(b,{end:/\}\}/})})
-;return{name:"Handlebars",
-aliases:["hbs","html.hbs","html.handlebars","htmlbars"],case_insensitive:!0,
-subLanguage:"xml",contains:[{begin:/\\\{\{/,skip:!0},{begin:/\\\\(?=\{\{)/,
-skip:!0},a.COMMENT(/\{\{!--/,/--\}\}/),a.COMMENT(/\{\{!/,/\}\}/),{
-className:"template-tag",begin:/\{\{\{\{(?!\/)/,end:/\}\}\}\}/,contains:[N],
-starts:{end:/\{\{\{\{\//,returnEnd:!0,subLanguage:"xml"}},{
-className:"template-tag",begin:/\{\{\{\{\//,end:/\}\}\}\}/,contains:[p]},{
-className:"template-tag",begin:/\{\{#/,end:/\}\}/,contains:[N]},{
-className:"template-tag",begin:/\{\{(?=else\}\})/,end:/\}\}/,keywords:"else"},{
-className:"template-tag",begin:/\{\{(?=else if)/,end:/\}\}/,keywords:"else if"
-},{className:"template-tag",begin:/\{\{\//,end:/\}\}/,contains:[p]},{
-className:"template-variable",begin:/\{\{\{/,end:/\}\}\}/,contains:[E]},{
-className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[E]}]}}})());
-hljs.registerLanguage("haskell",(()=>{"use strict";return e=>{const n={
-variants:[e.COMMENT("--","$"),e.COMMENT(/\{-/,/-\}/,{contains:["self"]})]},i={
-className:"meta",begin:/\{-#/,end:/#-\}/},a={className:"meta",begin:"^#",end:"$"
-},s={className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},l={begin:"\\(",
-end:"\\)",illegal:'"',contains:[i,a,{className:"type",
-begin:"\\b[A-Z][\\w]*(\\((\\.\\.|,|\\w+)\\))?"},e.inherit(e.TITLE_MODE,{
-begin:"[_a-z][\\w']*"}),n]};return{name:"Haskell",aliases:["hs"],
-keywords:"let in if then else case of where do module import hiding qualified type data newtype deriving class instance as default infix infixl infixr foreign export ccall stdcall cplusplus jvm dotnet safe unsafe family forall mdo proc rec",
-contains:[{beginKeywords:"module",end:"where",keywords:"module where",
-contains:[l,n],illegal:"\\W\\.|;"},{begin:"\\bimport\\b",end:"$",
-keywords:"import qualified as hiding",contains:[l,n],illegal:"\\W\\.|;"},{
-className:"class",begin:"^(\\s*)?(class|instance)\\b",end:"where",
-keywords:"class family instance where",contains:[s,l,n]},{className:"class",
-begin:"\\b(data|(new)?type)\\b",end:"$",
-keywords:"data family type newtype deriving",contains:[i,s,l,{begin:/\{/,
-end:/\}/,contains:l.contains},n]},{beginKeywords:"default",end:"$",
-contains:[s,l,n]},{beginKeywords:"infix infixl infixr",end:"$",
-contains:[e.C_NUMBER_MODE,n]},{begin:"\\bforeign\\b",end:"$",
-keywords:"foreign import export ccall stdcall cplusplus jvm dotnet safe unsafe",
-contains:[s,e.QUOTE_STRING_MODE,n]},{className:"meta",
-begin:"#!\\/usr\\/bin\\/env runhaskell",end:"$"
-},i,a,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,s,e.inherit(e.TITLE_MODE,{
-begin:"^[_a-z][\\w']*"}),n,{begin:"->|<-"}]}}})());
-hljs.registerLanguage("haxe",(()=>{"use strict";return e=>({name:"Haxe",
-aliases:["hx"],keywords:{
-keyword:"break case cast catch continue default do dynamic else enum extern for function here if import in inline never new override package private get set public return static super switch this throw trace try typedef untyped using var while Int Float String Bool Dynamic Void Array ",
-built_in:"trace this",literal:"true false null _"},contains:[{
-className:"string",begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE,{
-className:"subst",begin:"\\$\\{",end:"\\}"},{className:"subst",begin:"\\$",
-end:/\W\}/}]
-},e.QUOTE_STRING_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"@:",end:"$"},{className:"meta",begin:"#",end:"$",
-keywords:{"meta-keyword":"if else elseif end error"}},{className:"type",
-begin:":[ \t]*",end:"[^A-Za-z0-9_ \t\\->]",excludeBegin:!0,excludeEnd:!0,
-relevance:0},{className:"type",begin:":[ \t]*",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},{className:"type",begin:"new *",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},{className:"class",beginKeywords:"enum",end:"\\{",
-contains:[e.TITLE_MODE]},{className:"class",beginKeywords:"abstract",
-end:"[\\{$]",contains:[{className:"type",begin:"\\(",end:"\\)",excludeBegin:!0,
-excludeEnd:!0},{className:"type",begin:"from +",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},{className:"type",begin:"to +",end:"\\W",excludeBegin:!0,
-excludeEnd:!0},e.TITLE_MODE],keywords:{keyword:"abstract from to"}},{
-className:"class",begin:"\\b(class|interface) +",end:"[\\{$]",excludeEnd:!0,
-keywords:"class interface",contains:[{className:"keyword",
-begin:"\\b(extends|implements) +",keywords:"extends implements",contains:[{
-className:"type",begin:e.IDENT_RE,relevance:0}]},e.TITLE_MODE]},{
-className:"function",beginKeywords:"function",end:"\\(",excludeEnd:!0,
-illegal:"\\S",contains:[e.TITLE_MODE]}],illegal:/<\//})})());
-hljs.registerLanguage("hsp",(()=>{"use strict";return e=>({name:"HSP",
-case_insensitive:!0,keywords:{$pattern:/[\w._]+/,
-keyword:"goto gosub return break repeat loop continue wait await dim sdim foreach dimtype dup dupptr end stop newmod delmod mref run exgoto on mcall assert logmes newlab resume yield onexit onerror onkey onclick oncmd exist delete mkdir chdir dirlist bload bsave bcopy memfile if else poke wpoke lpoke getstr chdpm memexpand memcpy memset notesel noteadd notedel noteload notesave randomize noteunsel noteget split strrep setease button chgdisp exec dialog mmload mmplay mmstop mci pset pget syscolor mes print title pos circle cls font sysfont objsize picload color palcolor palette redraw width gsel gcopy gzoom gmode bmpsave hsvcolor getkey listbox chkbox combox input mesbox buffer screen bgscr mouse objsel groll line clrobj boxf objprm objmode stick grect grotate gsquare gradf objimage objskip objenable celload celdiv celput newcom querycom delcom cnvstow comres axobj winobj sendmsg comevent comevarg sarrayconv callfunc cnvwtos comevdisp libptr system hspstat hspver stat cnt err strsize looplev sublev iparam wparam lparam refstr refdval int rnd strlen length length2 length3 length4 vartype gettime peek wpeek lpeek varptr varuse noteinfo instr abs limit getease str strmid strf getpath strtrim sin cos tan atan sqrt double absf expf logf limitf powf geteasef mousex mousey mousew hwnd hinstance hdc ginfo objinfo dirinfo sysinfo thismod __hspver__ __hsp30__ __date__ __time__ __line__ __file__ _debug __hspdef__ and or xor not screen_normal screen_palette screen_hide screen_fixedsize screen_tool screen_frame gmode_gdi gmode_mem gmode_rgb0 gmode_alpha gmode_rgb0alpha gmode_add gmode_sub gmode_pixela ginfo_mx ginfo_my ginfo_act ginfo_sel ginfo_wx1 ginfo_wy1 ginfo_wx2 ginfo_wy2 ginfo_vx ginfo_vy ginfo_sizex ginfo_sizey ginfo_winx ginfo_winy ginfo_mesx ginfo_mesy ginfo_r ginfo_g ginfo_b ginfo_paluse ginfo_dispx ginfo_dispy ginfo_cx ginfo_cy ginfo_intid ginfo_newid ginfo_sx ginfo_sy objinfo_mode objinfo_bmscr objinfo_hwnd notemax notesize dir_cur dir_exe dir_win dir_sys dir_cmdline dir_desktop dir_mydoc dir_tv font_normal font_bold font_italic font_underline font_strikeout font_antialias objmode_normal objmode_guifont objmode_usefont gsquare_grad msgothic msmincho do until while wend for next _break _continue switch case default swbreak swend ddim ldim alloc m_pi rad2deg deg2rad ease_linear ease_quad_in ease_quad_out ease_quad_inout ease_cubic_in ease_cubic_out ease_cubic_inout ease_quartic_in ease_quartic_out ease_quartic_inout ease_bounce_in ease_bounce_out ease_bounce_inout ease_shake_in ease_shake_out ease_shake_inout ease_loop"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{
-className:"string",begin:/\{"/,end:/"\}/,contains:[e.BACKSLASH_ESCAPE]
-},e.COMMENT(";","$",{relevance:0}),{className:"meta",begin:"#",end:"$",
-keywords:{
-"meta-keyword":"addion cfunc cmd cmpopt comfunc const defcfunc deffunc define else endif enum epack func global if ifdef ifndef include modcfunc modfunc modinit modterm module pack packopt regcmd runtime undef usecom uselib"
-},contains:[e.inherit(e.QUOTE_STRING_MODE,{className:"meta-string"
-}),e.NUMBER_MODE,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]
-},{className:"symbol",begin:"^\\*(\\w+|@)"},e.NUMBER_MODE,e.C_NUMBER_MODE]})
-})());
-hljs.registerLanguage("htmlbars",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}return a=>{const t=function(a){const t={
-"builtin-name":["action","bindattr","collection","component","concat","debugger","each","each-in","get","hash","if","in","input","link-to","loc","log","lookup","mut","outlet","partial","query-params","render","template","textarea","unbound","unless","view","with","yield"]
-},s=/\[\]|\[[^\]]+\]/,i=/[^\s!"#%&'()*+,.\/;<=>@\[\\\]^`{|}~]+/,r=((...n)=>"("+n.map((n=>e(n))).join("|")+")")(/""|"[^"]+"/,/''|'[^']+'/,s,i),l=n(n("(",/\.|\.\/|\//,")?"),r,(c=n(/(\.|\/)/,r),
-n("(",c,")*")));var c;const o=n("(",s,"|",i,")(?==)"),m={begin:l,
-lexemes:/[\w.\/]+/},d=a.inherit(m,{keywords:{
-literal:["true","false","undefined","null"]}}),g={begin:/\(/,end:/\)/},b={
-className:"attr",begin:o,relevance:0,starts:{begin:/=/,end:/=/,starts:{
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,d,g]}}},u={
-contains:[a.NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,{begin:/as\s+\|/,
-keywords:{keyword:"as"},end:/\|/,contains:[{begin:/\w+/}]},b,d,g],returnEnd:!0
-},h=a.inherit(m,{className:"name",keywords:t,starts:a.inherit(u,{end:/\)/})})
-;g.contains=[h];const N=a.inherit(m,{keywords:t,className:"name",
-starts:a.inherit(u,{end:/\}\}/})}),p=a.inherit(m,{keywords:t,className:"name"
-}),E=a.inherit(m,{className:"name",keywords:t,starts:a.inherit(u,{end:/\}\}/})})
-;return{name:"Handlebars",
-aliases:["hbs","html.hbs","html.handlebars","htmlbars"],case_insensitive:!0,
-subLanguage:"xml",contains:[{begin:/\\\{\{/,skip:!0},{begin:/\\\\(?=\{\{)/,
-skip:!0},a.COMMENT(/\{\{!--/,/--\}\}/),a.COMMENT(/\{\{!/,/\}\}/),{
-className:"template-tag",begin:/\{\{\{\{(?!\/)/,end:/\}\}\}\}/,contains:[N],
-starts:{end:/\{\{\{\{\//,returnEnd:!0,subLanguage:"xml"}},{
-className:"template-tag",begin:/\{\{\{\{\//,end:/\}\}\}\}/,contains:[p]},{
-className:"template-tag",begin:/\{\{#/,end:/\}\}/,contains:[N]},{
-className:"template-tag",begin:/\{\{(?=else\}\})/,end:/\}\}/,keywords:"else"},{
-className:"template-tag",begin:/\{\{(?=else if)/,end:/\}\}/,keywords:"else if"
-},{className:"template-tag",begin:/\{\{\//,end:/\}\}/,contains:[p]},{
-className:"template-variable",begin:/\{\{\{/,end:/\}\}\}/,contains:[E]},{
-className:"template-variable",begin:/\{\{/,end:/\}\}/,contains:[E]}]}}(a)
-;return t.name="HTMLbars",a.getLanguage("handlebars")&&(t.disableAutodetect=!0),
-t}})());
-hljs.registerLanguage("http",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a="HTTP/(2|1\\.[01])",s={className:"attribute",
-begin:e("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{contains:[{
-className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",relevance:0}}]}
-},t=[s,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{
-name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{begin:"^(?="+a+" \\d{3})",
-end:/$/,contains:[{className:"meta",begin:a},{className:"number",
-begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:t}},{
-begin:"(?=^[A-Z]+ (.*?) "+a+"$)",end:/$/,contains:[{className:"string",
-begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{className:"meta",begin:a},{
-className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:t}
-},n.inherit(s,{relevance:0})]}}})());
-hljs.registerLanguage("hy",(()=>{"use strict";return e=>{
-var a="a-zA-Z_\\-!.?+*=<>&#'",t="["+a+"]["+a+"0-9/;:]*",i={$pattern:t,
-"builtin-name":"!= % %= & &= * ** **= *= *map + += , --build-class-- --import-- -= . / // //= /= < << <<= <= = > >= >> >>= @ @= ^ ^= abs accumulate all and any ap-compose ap-dotimes ap-each ap-each-while ap-filter ap-first ap-if ap-last ap-map ap-map-when ap-pipe ap-reduce ap-reject apply as-> ascii assert assoc bin break butlast callable calling-module-name car case cdr chain chr coll? combinations compile compress cond cons cons? continue count curry cut cycle dec def default-method defclass defmacro defmacro-alias defmacro/g! defmain defmethod defmulti defn defn-alias defnc defnr defreader defseq del delattr delete-route dict-comp dir disassemble dispatch-reader-macro distinct divmod do doto drop drop-last drop-while empty? end-sequence eval eval-and-compile eval-when-compile even? every? except exec filter first flatten float? fn fnc fnr for for* format fraction genexpr gensym get getattr global globals group-by hasattr hash hex id identity if if* if-not if-python2 import in inc input instance? integer integer-char? integer? interleave interpose is is-coll is-cons is-empty is-even is-every is-float is-instance is-integer is-integer-char is-iterable is-iterator is-keyword is-neg is-none is-not is-numeric is-odd is-pos is-string is-symbol is-zero isinstance islice issubclass iter iterable? iterate iterator? keyword keyword? lambda last len let lif lif-not list* list-comp locals loop macro-error macroexpand macroexpand-1 macroexpand-all map max merge-with method-decorator min multi-decorator multicombinations name neg? next none? nonlocal not not-in not? nth numeric? oct odd? open or ord partition permutations pos? post-route postwalk pow prewalk print product profile/calls profile/cpu put-route quasiquote quote raise range read read-str recursive-replace reduce remove repeat repeatedly repr require rest round route route-with-methods rwm second seq set-comp setattr setv some sorted string string? sum switch symbol? take take-nth take-while tee try unless unquote unquote-splicing vars walk when while with with* with-decorator with-gensyms xi xor yield yield-from zero? zip zip-longest | |= ~"
-},r={begin:t,relevance:0},n={className:"number",begin:"[-+]?\\d+(\\.\\d+)?",
-relevance:0},s=e.inherit(e.QUOTE_STRING_MODE,{illegal:null
-}),o=e.COMMENT(";","$",{relevance:0}),l={className:"literal",
-begin:/\b([Tt]rue|[Ff]alse|nil|None)\b/},c={begin:"[\\[\\{]",end:"[\\]\\}]"},d={
-className:"comment",begin:"\\^"+t},m=e.COMMENT("\\^\\{","\\}"),p={
-className:"symbol",begin:"[:]{1,2}"+t},u={begin:"\\(",end:"\\)"},f={
-endsWithParent:!0,relevance:0},g={className:"name",relevance:0,keywords:i,
-begin:t,starts:f},h=[u,s,d,m,o,p,c,n,l,r]
-;return u.contains=[e.COMMENT("comment",""),g,f],f.contains=h,c.contains=h,{
-name:"Hy",aliases:["hylang"],illegal:/\S/,
-contains:[e.SHEBANG(),u,s,d,m,o,p,c,n,l]}}})());
-hljs.registerLanguage("inform7",(()=>{"use strict";return e=>({name:"Inform 7",
-aliases:["i7"],case_insensitive:!0,keywords:{
-keyword:"thing room person man woman animal container supporter backdrop door scenery open closed locked inside gender is are say understand kind of rule"
-},contains:[{className:"string",begin:'"',end:'"',relevance:0,contains:[{
-className:"subst",begin:"\\[",end:"\\]"}]},{className:"section",
-begin:/^(Volume|Book|Part|Chapter|Section|Table)\b/,end:"$"},{
-begin:/^(Check|Carry out|Report|Instead of|To|Rule|When|Before|After)\b/,
-end:":",contains:[{begin:"\\(This",end:"\\)"}]},{className:"comment",
-begin:"\\[",end:"\\]",contains:["self"]}]})})());
-hljs.registerLanguage("ini",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}return s=>{const a={className:"number",
-relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{begin:s.NUMBER_RE}]
-},i=s.COMMENT();i.variants=[{begin:/;/,end:/$/},{begin:/#/,end:/$/}];const t={
-className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{begin:/\$\{(.*?)\}/
-}]},r={className:"literal",begin:/\bon|off|true|false|yes|no\b/},l={
-className:"string",contains:[s.BACKSLASH_ESCAPE],variants:[{begin:"'''",
-end:"'''",relevance:10},{begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'
-},{begin:"'",end:"'"}]},c={begin:/\[/,end:/\]/,contains:[i,r,t,l,a,"self"],
-relevance:0
-},g="("+[/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/].map((n=>e(n))).join("|")+")"
-;return{name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/,
-contains:[i,{className:"section",begin:/\[+/,end:/\]+/},{
-begin:n(g,"(\\s*\\.\\s*",g,")*",n("(?=",/\s*=\s*[^#\s]/,")")),className:"attr",
-starts:{end:/$/,contains:[i,c,r,t,l,a]}}]}}})());
-hljs.registerLanguage("irpf90",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const t=/(_[a-z_\d]+)?/,i=/([de][+-]?\d+)?/,a={
-className:"number",variants:[{begin:e(/\b\d+/,/\.(\d*)/,i,t)},{
-begin:e(/\b\d+/,i,t)},{begin:e(/\.\d+/,i,t)}],relevance:0};return{name:"IRPF90",
-case_insensitive:!0,keywords:{literal:".False. .True.",
-keyword:"kind do while private call intrinsic where elsewhere type endtype endmodule endselect endinterface end enddo endif if forall endforall only contains default return stop then public subroutine|10 function program .and. .or. .not. .le. .eq. .ge. .gt. .lt. goto save else use module select case access blank direct exist file fmt form formatted iostat name named nextrec number opened rec recl sequential status unformatted unit continue format pause cycle exit c_null_char c_alert c_backspace c_form_feed flush wait decimal round iomsg synchronous nopass non_overridable pass protected volatile abstract extends import non_intrinsic value deferred generic final enumerator class associate bind enum c_int c_short c_long c_long_long c_signed_char c_size_t c_int8_t c_int16_t c_int32_t c_int64_t c_int_least8_t c_int_least16_t c_int_least32_t c_int_least64_t c_int_fast8_t c_int_fast16_t c_int_fast32_t c_int_fast64_t c_intmax_t C_intptr_t c_float c_double c_long_double c_float_complex c_double_complex c_long_double_complex c_bool c_char c_null_ptr c_null_funptr c_new_line c_carriage_return c_horizontal_tab c_vertical_tab iso_c_binding c_loc c_funloc c_associated  c_f_pointer c_ptr c_funptr iso_fortran_env character_storage_size error_unit file_storage_size input_unit iostat_end iostat_eor numeric_storage_size output_unit c_f_procpointer ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode newunit contiguous recursive pad position action delim readwrite eor advance nml interface procedure namelist include sequence elemental pure integer real character complex logical dimension allocatable|10 parameter external implicit|10 none double precision assign intent optional pointer target in out common equivalence data begin_provider &begin_provider end_provider begin_shell end_shell begin_template end_template subst assert touch soft_touch provide no_dep free irp_if irp_else irp_endif irp_write irp_read",
-built_in:"alog alog10 amax0 amax1 amin0 amin1 amod cabs ccos cexp clog csin csqrt dabs dacos dasin datan datan2 dcos dcosh ddim dexp dint dlog dlog10 dmax1 dmin1 dmod dnint dsign dsin dsinh dsqrt dtan dtanh float iabs idim idint idnint ifix isign max0 max1 min0 min1 sngl algama cdabs cdcos cdexp cdlog cdsin cdsqrt cqabs cqcos cqexp cqlog cqsin cqsqrt dcmplx dconjg derf derfc dfloat dgamma dimag dlgama iqint qabs qacos qasin qatan qatan2 qcmplx qconjg qcos qcosh qdim qerf qerfc qexp qgamma qimag qlgama qlog qlog10 qmax1 qmin1 qmod qnint qsign qsin qsinh qsqrt qtan qtanh abs acos aimag aint anint asin atan atan2 char cmplx conjg cos cosh exp ichar index int log log10 max min nint sign sin sinh sqrt tan tanh print write dim lge lgt lle llt mod nullify allocate deallocate adjustl adjustr all allocated any associated bit_size btest ceiling count cshift date_and_time digits dot_product eoshift epsilon exponent floor fraction huge iand ibclr ibits ibset ieor ior ishft ishftc lbound len_trim matmul maxexponent maxloc maxval merge minexponent minloc minval modulo mvbits nearest pack present product radix random_number random_seed range repeat reshape rrspacing scale scan selected_int_kind selected_real_kind set_exponent shape size spacing spread sum system_clock tiny transpose trim ubound unpack verify achar iachar transfer dble entry dprod cpu_time command_argument_count get_command get_command_argument get_environment_variable is_iostat_end ieee_arithmetic ieee_support_underflow_control ieee_get_underflow_mode ieee_set_underflow_mode is_iostat_eor move_alloc new_line selected_char_kind same_type_as extends_type_of acosh asinh atanh bessel_j0 bessel_j1 bessel_jn bessel_y0 bessel_y1 bessel_yn erf erfc erfc_scaled gamma log_gamma hypot norm2 atomic_define atomic_ref execute_command_line leadz trailz storage_size merge_bits bge bgt ble blt dshiftl dshiftr findloc iall iany iparity image_index lcobound ucobound maskl maskr num_images parity popcnt poppar shifta shiftl shiftr this_image IRP_ALIGN irp_here"
-},illegal:/\/\*/,contains:[n.inherit(n.APOS_STRING_MODE,{className:"string",
-relevance:0}),n.inherit(n.QUOTE_STRING_MODE,{className:"string",relevance:0}),{
-className:"function",beginKeywords:"subroutine function program",
-illegal:"[${=\\n]",contains:[n.UNDERSCORE_TITLE_MODE,{className:"params",
-begin:"\\(",end:"\\)"}]},n.COMMENT("!","$",{relevance:0
-}),n.COMMENT("begin_doc","end_doc",{relevance:10}),a]}}})());
-hljs.registerLanguage("isbl",(()=>{"use strict";return S=>{
-const E="[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_!][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*",_={
-className:"number",begin:S.NUMBER_RE,relevance:0},T={className:"string",
-variants:[{begin:'"',end:'"'},{begin:"'",end:"'"}]},R={className:"doctag",
-begin:"\\b(?:TODO|DONE|BEGIN|END|STUB|CHG|FIXME|NOTE|BUG|XXX)\\b",relevance:0
-},O={variants:[{className:"comment",begin:"//",end:"$",relevance:0,
-contains:[S.PHRASAL_WORDS_MODE,R]},{className:"comment",begin:"/\\*",end:"\\*/",
-relevance:0,contains:[S.PHRASAL_WORDS_MODE,R]}]},C={$pattern:E,
-keyword:"and \u0438 else \u0438\u043d\u0430\u0447\u0435 endexcept endfinally endforeach \u043a\u043e\u043d\u0435\u0446\u0432\u0441\u0435 endif \u043a\u043e\u043d\u0435\u0446\u0435\u0441\u043b\u0438 endwhile \u043a\u043e\u043d\u0435\u0446\u043f\u043e\u043a\u0430 except exitfor finally foreach \u0432\u0441\u0435 if \u0435\u0441\u043b\u0438 in \u0432 not \u043d\u0435 or \u0438\u043b\u0438 try while \u043f\u043e\u043a\u0430 ",
-built_in:"SYSRES_CONST_ACCES_RIGHT_TYPE_EDIT SYSRES_CONST_ACCES_RIGHT_TYPE_FULL SYSRES_CONST_ACCES_RIGHT_TYPE_VIEW SYSRES_CONST_ACCESS_MODE_REQUISITE_CODE SYSRES_CONST_ACCESS_NO_ACCESS_VIEW SYSRES_CONST_ACCESS_NO_ACCESS_VIEW_CODE SYSRES_CONST_ACCESS_RIGHTS_ADD_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_ADD_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_CHANGE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_CHANGE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_DELETE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_DELETE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_EXECUTE_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_EXECUTE_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_NO_ACCESS_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_NO_ACCESS_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_RATIFY_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_RATIFY_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_RIGHTS_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW SYSRES_CONST_ACCESS_RIGHTS_VIEW_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW_REQUISITE_CODE SYSRES_CONST_ACCESS_RIGHTS_VIEW_REQUISITE_YES_CODE SYSRES_CONST_ACCESS_TYPE_CHANGE SYSRES_CONST_ACCESS_TYPE_CHANGE_CODE SYSRES_CONST_ACCESS_TYPE_EXISTS SYSRES_CONST_ACCESS_TYPE_EXISTS_CODE SYSRES_CONST_ACCESS_TYPE_FULL SYSRES_CONST_ACCESS_TYPE_FULL_CODE SYSRES_CONST_ACCESS_TYPE_VIEW SYSRES_CONST_ACCESS_TYPE_VIEW_CODE SYSRES_CONST_ACTION_TYPE_ABORT SYSRES_CONST_ACTION_TYPE_ACCEPT SYSRES_CONST_ACTION_TYPE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ADD_ATTACHMENT SYSRES_CONST_ACTION_TYPE_CHANGE_CARD SYSRES_CONST_ACTION_TYPE_CHANGE_KIND SYSRES_CONST_ACTION_TYPE_CHANGE_STORAGE SYSRES_CONST_ACTION_TYPE_CONTINUE SYSRES_CONST_ACTION_TYPE_COPY SYSRES_CONST_ACTION_TYPE_CREATE SYSRES_CONST_ACTION_TYPE_CREATE_VERSION SYSRES_CONST_ACTION_TYPE_DELETE SYSRES_CONST_ACTION_TYPE_DELETE_ATTACHMENT SYSRES_CONST_ACTION_TYPE_DELETE_VERSION SYSRES_CONST_ACTION_TYPE_DISABLE_DELEGATE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ENABLE_DELEGATE_ACCESS_RIGHTS SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_CERTIFICATE SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_CERTIFICATE_AND_PASSWORD SYSRES_CONST_ACTION_TYPE_ENCRYPTION_BY_PASSWORD SYSRES_CONST_ACTION_TYPE_EXPORT_WITH_LOCK SYSRES_CONST_ACTION_TYPE_EXPORT_WITHOUT_LOCK SYSRES_CONST_ACTION_TYPE_IMPORT_WITH_UNLOCK SYSRES_CONST_ACTION_TYPE_IMPORT_WITHOUT_UNLOCK SYSRES_CONST_ACTION_TYPE_LIFE_CYCLE_STAGE SYSRES_CONST_ACTION_TYPE_LOCK SYSRES_CONST_ACTION_TYPE_LOCK_FOR_SERVER SYSRES_CONST_ACTION_TYPE_LOCK_MODIFY SYSRES_CONST_ACTION_TYPE_MARK_AS_READED SYSRES_CONST_ACTION_TYPE_MARK_AS_UNREADED SYSRES_CONST_ACTION_TYPE_MODIFY SYSRES_CONST_ACTION_TYPE_MODIFY_CARD SYSRES_CONST_ACTION_TYPE_MOVE_TO_ARCHIVE SYSRES_CONST_ACTION_TYPE_OFF_ENCRYPTION SYSRES_CONST_ACTION_TYPE_PASSWORD_CHANGE SYSRES_CONST_ACTION_TYPE_PERFORM SYSRES_CONST_ACTION_TYPE_RECOVER_FROM_LOCAL_COPY SYSRES_CONST_ACTION_TYPE_RESTART SYSRES_CONST_ACTION_TYPE_RESTORE_FROM_ARCHIVE SYSRES_CONST_ACTION_TYPE_REVISION SYSRES_CONST_ACTION_TYPE_SEND_BY_MAIL SYSRES_CONST_ACTION_TYPE_SIGN SYSRES_CONST_ACTION_TYPE_START SYSRES_CONST_ACTION_TYPE_UNLOCK SYSRES_CONST_ACTION_TYPE_UNLOCK_FROM_SERVER SYSRES_CONST_ACTION_TYPE_VERSION_STATE SYSRES_CONST_ACTION_TYPE_VERSION_VISIBILITY SYSRES_CONST_ACTION_TYPE_VIEW SYSRES_CONST_ACTION_TYPE_VIEW_SHADOW_COPY SYSRES_CONST_ACTION_TYPE_WORKFLOW_DESCRIPTION_MODIFY SYSRES_CONST_ACTION_TYPE_WRITE_HISTORY SYSRES_CONST_ACTIVE_VERSION_STATE_PICK_VALUE SYSRES_CONST_ADD_REFERENCE_MODE_NAME SYSRES_CONST_ADDITION_REQUISITE_CODE SYSRES_CONST_ADDITIONAL_PARAMS_REQUISITE_CODE SYSRES_CONST_ADITIONAL_JOB_END_DATE_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_READ_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_START_DATE_REQUISITE_NAME SYSRES_CONST_ADITIONAL_JOB_STATE_REQUISITE_NAME SYSRES_CONST_ADMINISTRATION_HISTORY_ADDING_USER_TO_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_ADDING_USER_TO_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_COMP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_COMP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_USER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_CREATION_USER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_CREATION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_CREATION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_DELETION SYSRES_CONST_ADMINISTRATION_HISTORY_DATABASE_USER_DELETION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_COMP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_COMP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_FROM_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_DELETION_USER_FROM_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_RESTRICTION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_FILTERER_RESTRICTION_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_PRIVILEGE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_PRIVILEGE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_RIGHTS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_GRANTING_RIGHTS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_IS_MAIN_SERVER_CHANGED_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_IS_MAIN_SERVER_CHANGED_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_IS_PUBLIC_CHANGED_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_IS_PUBLIC_CHANGED_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_RESTRICTION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_FILTERER_RESTRICTION_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_PRIVILEGE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_PRIVILEGE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_RIGHTS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_REMOVING_RIGHTS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_CREATION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_CREATION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_DELETION SYSRES_CONST_ADMINISTRATION_HISTORY_SERVER_LOGIN_DELETION_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_CATEGORY_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_CATEGORY_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_COMP_TITLE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_COMP_TITLE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_FULL_NAME_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_FULL_NAME_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_PARENT_GROUP_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_PARENT_GROUP_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_AUTH_TYPE_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_AUTH_TYPE_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_LOGIN_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_LOGIN_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_STATUS_ACTION SYSRES_CONST_ADMINISTRATION_HISTORY_UPDATING_USER_STATUS_ACTION_CODE SYSRES_CONST_ADMINISTRATION_HISTORY_USER_PASSWORD_CHANGE SYSRES_CONST_ADMINISTRATION_HISTORY_USER_PASSWORD_CHANGE_ACTION SYSRES_CONST_ALL_ACCEPT_CONDITION_RUS SYSRES_CONST_ALL_USERS_GROUP SYSRES_CONST_ALL_USERS_GROUP_NAME SYSRES_CONST_ALL_USERS_SERVER_GROUP_NAME SYSRES_CONST_ALLOWED_ACCESS_TYPE_CODE SYSRES_CONST_ALLOWED_ACCESS_TYPE_NAME SYSRES_CONST_APP_VIEWER_TYPE_REQUISITE_CODE SYSRES_CONST_APPROVING_SIGNATURE_NAME SYSRES_CONST_APPROVING_SIGNATURE_REQUISITE_CODE SYSRES_CONST_ASSISTANT_SUBSTITUE_TYPE SYSRES_CONST_ASSISTANT_SUBSTITUE_TYPE_CODE SYSRES_CONST_ATTACH_TYPE_COMPONENT_TOKEN SYSRES_CONST_ATTACH_TYPE_DOC SYSRES_CONST_ATTACH_TYPE_EDOC SYSRES_CONST_ATTACH_TYPE_FOLDER SYSRES_CONST_ATTACH_TYPE_JOB SYSRES_CONST_ATTACH_TYPE_REFERENCE SYSRES_CONST_ATTACH_TYPE_TASK SYSRES_CONST_AUTH_ENCODED_PASSWORD SYSRES_CONST_AUTH_ENCODED_PASSWORD_CODE SYSRES_CONST_AUTH_NOVELL SYSRES_CONST_AUTH_PASSWORD SYSRES_CONST_AUTH_PASSWORD_CODE SYSRES_CONST_AUTH_WINDOWS SYSRES_CONST_AUTHENTICATING_SIGNATURE_NAME SYSRES_CONST_AUTHENTICATING_SIGNATURE_REQUISITE_CODE SYSRES_CONST_AUTO_ENUM_METHOD_FLAG SYSRES_CONST_AUTO_NUMERATION_CODE SYSRES_CONST_AUTO_STRONG_ENUM_METHOD_FLAG SYSRES_CONST_AUTOTEXT_NAME_REQUISITE_CODE SYSRES_CONST_AUTOTEXT_TEXT_REQUISITE_CODE SYSRES_CONST_AUTOTEXT_USAGE_ALL SYSRES_CONST_AUTOTEXT_USAGE_ALL_CODE SYSRES_CONST_AUTOTEXT_USAGE_SIGN SYSRES_CONST_AUTOTEXT_USAGE_SIGN_CODE SYSRES_CONST_AUTOTEXT_USAGE_WORK SYSRES_CONST_AUTOTEXT_USAGE_WORK_CODE SYSRES_CONST_AUTOTEXT_USE_ANYWHERE_CODE SYSRES_CONST_AUTOTEXT_USE_ON_SIGNING_CODE SYSRES_CONST_AUTOTEXT_USE_ON_WORK_CODE SYSRES_CONST_BEGIN_DATE_REQUISITE_CODE SYSRES_CONST_BLACK_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_BLUE_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_BTN_PART SYSRES_CONST_CALCULATED_ROLE_TYPE_CODE SYSRES_CONST_CALL_TYPE_VARIABLE_BUTTON_VALUE SYSRES_CONST_CALL_TYPE_VARIABLE_PROGRAM_VALUE SYSRES_CONST_CANCEL_MESSAGE_FUNCTION_RESULT SYSRES_CONST_CARD_PART SYSRES_CONST_CARD_REFERENCE_MODE_NAME SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_ENCRYPT_VALUE SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_SIGN_AND_ENCRYPT_VALUE SYSRES_CONST_CERTIFICATE_TYPE_REQUISITE_SIGN_VALUE SYSRES_CONST_CHECK_PARAM_VALUE_DATE_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_FLOAT_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_INTEGER_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_PICK_PARAM_TYPE SYSRES_CONST_CHECK_PARAM_VALUE_REEFRENCE_PARAM_TYPE SYSRES_CONST_CLOSED_RECORD_FLAG_VALUE_FEMININE SYSRES_CONST_CLOSED_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_CODE_COMPONENT_TYPE_ADMIN SYSRES_CONST_CODE_COMPONENT_TYPE_DEVELOPER SYSRES_CONST_CODE_COMPONENT_TYPE_DOCS SYSRES_CONST_CODE_COMPONENT_TYPE_EDOC_CARDS SYSRES_CONST_CODE_COMPONENT_TYPE_EXTERNAL_EXECUTABLE SYSRES_CONST_CODE_COMPONENT_TYPE_OTHER SYSRES_CONST_CODE_COMPONENT_TYPE_REFERENCE SYSRES_CONST_CODE_COMPONENT_TYPE_REPORT SYSRES_CONST_CODE_COMPONENT_TYPE_SCRIPT SYSRES_CONST_CODE_COMPONENT_TYPE_URL SYSRES_CONST_CODE_REQUISITE_ACCESS SYSRES_CONST_CODE_REQUISITE_CODE SYSRES_CONST_CODE_REQUISITE_COMPONENT SYSRES_CONST_CODE_REQUISITE_DESCRIPTION SYSRES_CONST_CODE_REQUISITE_EXCLUDE_COMPONENT SYSRES_CONST_CODE_REQUISITE_RECORD SYSRES_CONST_COMMENT_REQ_CODE SYSRES_CONST_COMMON_SETTINGS_REQUISITE_CODE SYSRES_CONST_COMP_CODE_GRD SYSRES_CONST_COMPONENT_GROUP_TYPE_REQUISITE_CODE SYSRES_CONST_COMPONENT_TYPE_ADMIN_COMPONENTS SYSRES_CONST_COMPONENT_TYPE_DEVELOPER_COMPONENTS SYSRES_CONST_COMPONENT_TYPE_DOCS SYSRES_CONST_COMPONENT_TYPE_EDOC_CARDS SYSRES_CONST_COMPONENT_TYPE_EDOCS SYSRES_CONST_COMPONENT_TYPE_EXTERNAL_EXECUTABLE SYSRES_CONST_COMPONENT_TYPE_OTHER SYSRES_CONST_COMPONENT_TYPE_REFERENCE_TYPES SYSRES_CONST_COMPONENT_TYPE_REFERENCES SYSRES_CONST_COMPONENT_TYPE_REPORTS SYSRES_CONST_COMPONENT_TYPE_SCRIPTS SYSRES_CONST_COMPONENT_TYPE_URL SYSRES_CONST_COMPONENTS_REMOTE_SERVERS_VIEW_CODE SYSRES_CONST_CONDITION_BLOCK_DESCRIPTION SYSRES_CONST_CONST_FIRM_STATUS_COMMON SYSRES_CONST_CONST_FIRM_STATUS_INDIVIDUAL SYSRES_CONST_CONST_NEGATIVE_VALUE SYSRES_CONST_CONST_POSITIVE_VALUE SYSRES_CONST_CONST_SERVER_STATUS_DONT_REPLICATE SYSRES_CONST_CONST_SERVER_STATUS_REPLICATE SYSRES_CONST_CONTENTS_REQUISITE_CODE SYSRES_CONST_DATA_TYPE_BOOLEAN SYSRES_CONST_DATA_TYPE_DATE SYSRES_CONST_DATA_TYPE_FLOAT SYSRES_CONST_DATA_TYPE_INTEGER SYSRES_CONST_DATA_TYPE_PICK SYSRES_CONST_DATA_TYPE_REFERENCE SYSRES_CONST_DATA_TYPE_STRING SYSRES_CONST_DATA_TYPE_TEXT SYSRES_CONST_DATA_TYPE_VARIANT SYSRES_CONST_DATE_CLOSE_REQ_CODE SYSRES_CONST_DATE_FORMAT_DATE_ONLY_CHAR SYSRES_CONST_DATE_OPEN_REQ_CODE SYSRES_CONST_DATE_REQUISITE SYSRES_CONST_DATE_REQUISITE_CODE SYSRES_CONST_DATE_REQUISITE_NAME SYSRES_CONST_DATE_REQUISITE_TYPE SYSRES_CONST_DATE_TYPE_CHAR SYSRES_CONST_DATETIME_FORMAT_VALUE SYSRES_CONST_DEA_ACCESS_RIGHTS_ACTION_CODE SYSRES_CONST_DESCRIPTION_LOCALIZE_ID_REQUISITE_CODE SYSRES_CONST_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_DET1_PART SYSRES_CONST_DET2_PART SYSRES_CONST_DET3_PART SYSRES_CONST_DET4_PART SYSRES_CONST_DET5_PART SYSRES_CONST_DET6_PART SYSRES_CONST_DETAIL_DATASET_KEY_REQUISITE_CODE SYSRES_CONST_DETAIL_PICK_REQUISITE_CODE SYSRES_CONST_DETAIL_REQ_CODE SYSRES_CONST_DO_NOT_USE_ACCESS_TYPE_CODE SYSRES_CONST_DO_NOT_USE_ACCESS_TYPE_NAME SYSRES_CONST_DO_NOT_USE_ON_VIEW_ACCESS_TYPE_CODE SYSRES_CONST_DO_NOT_USE_ON_VIEW_ACCESS_TYPE_NAME SYSRES_CONST_DOCUMENT_STORAGES_CODE SYSRES_CONST_DOCUMENT_TEMPLATES_TYPE_NAME SYSRES_CONST_DOUBLE_REQUISITE_CODE SYSRES_CONST_EDITOR_CLOSE_FILE_OBSERV_TYPE_CODE SYSRES_CONST_EDITOR_CLOSE_PROCESS_OBSERV_TYPE_CODE SYSRES_CONST_EDITOR_TYPE_REQUISITE_CODE SYSRES_CONST_EDITORS_APPLICATION_NAME_REQUISITE_CODE SYSRES_CONST_EDITORS_CREATE_SEVERAL_PROCESSES_REQUISITE_CODE SYSRES_CONST_EDITORS_EXTENSION_REQUISITE_CODE SYSRES_CONST_EDITORS_OBSERVER_BY_PROCESS_TYPE SYSRES_CONST_EDITORS_REFERENCE_CODE SYSRES_CONST_EDITORS_REPLACE_SPEC_CHARS_REQUISITE_CODE SYSRES_CONST_EDITORS_USE_PLUGINS_REQUISITE_CODE SYSRES_CONST_EDITORS_VIEW_DOCUMENT_OPENED_TO_EDIT_CODE SYSRES_CONST_EDOC_CARD_TYPE_REQUISITE_CODE SYSRES_CONST_EDOC_CARD_TYPES_LINK_REQUISITE_CODE SYSRES_CONST_EDOC_CERTIFICATE_AND_PASSWORD_ENCODE_CODE SYSRES_CONST_EDOC_CERTIFICATE_ENCODE_CODE SYSRES_CONST_EDOC_DATE_REQUISITE_CODE SYSRES_CONST_EDOC_KIND_REFERENCE_CODE SYSRES_CONST_EDOC_KINDS_BY_TEMPLATE_ACTION_CODE SYSRES_CONST_EDOC_MANAGE_ACCESS_CODE SYSRES_CONST_EDOC_NONE_ENCODE_CODE SYSRES_CONST_EDOC_NUMBER_REQUISITE_CODE SYSRES_CONST_EDOC_PASSWORD_ENCODE_CODE SYSRES_CONST_EDOC_READONLY_ACCESS_CODE SYSRES_CONST_EDOC_SHELL_LIFE_TYPE_VIEW_VALUE SYSRES_CONST_EDOC_SIZE_RESTRICTION_PRIORITY_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_CHECK_ACCESS_RIGHTS_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_COMPUTER_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_DATABASE_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_EDIT_IN_STORAGE_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_LOCAL_PATH_REQUISITE_CODE SYSRES_CONST_EDOC_STORAGE_SHARED_SOURCE_NAME_REQUISITE_CODE SYSRES_CONST_EDOC_TEMPLATE_REQUISITE_CODE SYSRES_CONST_EDOC_TYPES_REFERENCE_CODE SYSRES_CONST_EDOC_VERSION_ACTIVE_STAGE_CODE SYSRES_CONST_EDOC_VERSION_DESIGN_STAGE_CODE SYSRES_CONST_EDOC_VERSION_OBSOLETE_STAGE_CODE SYSRES_CONST_EDOC_WRITE_ACCES_CODE SYSRES_CONST_EDOCUMENT_CARD_REQUISITES_REFERENCE_CODE_SELECTED_REQUISITE SYSRES_CONST_ENCODE_CERTIFICATE_TYPE_CODE SYSRES_CONST_END_DATE_REQUISITE_CODE SYSRES_CONST_ENUMERATION_TYPE_REQUISITE_CODE SYSRES_CONST_EXECUTE_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_EXECUTIVE_FILE_STORAGE_TYPE SYSRES_CONST_EXIST_CONST SYSRES_CONST_EXIST_VALUE SYSRES_CONST_EXPORT_LOCK_TYPE_ASK SYSRES_CONST_EXPORT_LOCK_TYPE_WITH_LOCK SYSRES_CONST_EXPORT_LOCK_TYPE_WITHOUT_LOCK SYSRES_CONST_EXPORT_VERSION_TYPE_ASK SYSRES_CONST_EXPORT_VERSION_TYPE_LAST SYSRES_CONST_EXPORT_VERSION_TYPE_LAST_ACTIVE SYSRES_CONST_EXTENSION_REQUISITE_CODE SYSRES_CONST_FILTER_NAME_REQUISITE_CODE SYSRES_CONST_FILTER_REQUISITE_CODE SYSRES_CONST_FILTER_TYPE_COMMON_CODE SYSRES_CONST_FILTER_TYPE_COMMON_NAME SYSRES_CONST_FILTER_TYPE_USER_CODE SYSRES_CONST_FILTER_TYPE_USER_NAME SYSRES_CONST_FILTER_VALUE_REQUISITE_NAME SYSRES_CONST_FLOAT_NUMBER_FORMAT_CHAR SYSRES_CONST_FLOAT_REQUISITE_TYPE SYSRES_CONST_FOLDER_AUTHOR_VALUE SYSRES_CONST_FOLDER_KIND_ANY_OBJECTS SYSRES_CONST_FOLDER_KIND_COMPONENTS SYSRES_CONST_FOLDER_KIND_EDOCS SYSRES_CONST_FOLDER_KIND_JOBS SYSRES_CONST_FOLDER_KIND_TASKS SYSRES_CONST_FOLDER_TYPE_COMMON SYSRES_CONST_FOLDER_TYPE_COMPONENT SYSRES_CONST_FOLDER_TYPE_FAVORITES SYSRES_CONST_FOLDER_TYPE_INBOX SYSRES_CONST_FOLDER_TYPE_OUTBOX SYSRES_CONST_FOLDER_TYPE_QUICK_LAUNCH SYSRES_CONST_FOLDER_TYPE_SEARCH SYSRES_CONST_FOLDER_TYPE_SHORTCUTS SYSRES_CONST_FOLDER_TYPE_USER SYSRES_CONST_FROM_DICTIONARY_ENUM_METHOD_FLAG SYSRES_CONST_FULL_SUBSTITUTE_TYPE SYSRES_CONST_FULL_SUBSTITUTE_TYPE_CODE SYSRES_CONST_FUNCTION_CANCEL_RESULT SYSRES_CONST_FUNCTION_CATEGORY_SYSTEM SYSRES_CONST_FUNCTION_CATEGORY_USER SYSRES_CONST_FUNCTION_FAILURE_RESULT SYSRES_CONST_FUNCTION_SAVE_RESULT SYSRES_CONST_GENERATED_REQUISITE SYSRES_CONST_GREEN_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_GROUP_ACCOUNT_TYPE_VALUE_CODE SYSRES_CONST_GROUP_CATEGORY_NORMAL_CODE SYSRES_CONST_GROUP_CATEGORY_NORMAL_NAME SYSRES_CONST_GROUP_CATEGORY_SERVICE_CODE SYSRES_CONST_GROUP_CATEGORY_SERVICE_NAME SYSRES_CONST_GROUP_COMMON_CATEGORY_FIELD_VALUE SYSRES_CONST_GROUP_FULL_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_RIGHTS_T_REQUISITE_CODE SYSRES_CONST_GROUP_SERVER_CODES_REQUISITE_CODE SYSRES_CONST_GROUP_SERVER_NAME_REQUISITE_CODE SYSRES_CONST_GROUP_SERVICE_CATEGORY_FIELD_VALUE SYSRES_CONST_GROUP_USER_REQUISITE_CODE SYSRES_CONST_GROUPS_REFERENCE_CODE SYSRES_CONST_GROUPS_REQUISITE_CODE SYSRES_CONST_HIDDEN_MODE_NAME SYSRES_CONST_HIGH_LVL_REQUISITE_CODE SYSRES_CONST_HISTORY_ACTION_CREATE_CODE SYSRES_CONST_HISTORY_ACTION_DELETE_CODE SYSRES_CONST_HISTORY_ACTION_EDIT_CODE SYSRES_CONST_HOUR_CHAR SYSRES_CONST_ID_REQUISITE_CODE SYSRES_CONST_IDSPS_REQUISITE_CODE SYSRES_CONST_IMAGE_MODE_COLOR SYSRES_CONST_IMAGE_MODE_GREYSCALE SYSRES_CONST_IMAGE_MODE_MONOCHROME SYSRES_CONST_IMPORTANCE_HIGH SYSRES_CONST_IMPORTANCE_LOW SYSRES_CONST_IMPORTANCE_NORMAL SYSRES_CONST_IN_DESIGN_VERSION_STATE_PICK_VALUE SYSRES_CONST_INCOMING_WORK_RULE_TYPE_CODE SYSRES_CONST_INT_REQUISITE SYSRES_CONST_INT_REQUISITE_TYPE SYSRES_CONST_INTEGER_NUMBER_FORMAT_CHAR SYSRES_CONST_INTEGER_TYPE_CHAR SYSRES_CONST_IS_GENERATED_REQUISITE_NEGATIVE_VALUE SYSRES_CONST_IS_PUBLIC_ROLE_REQUISITE_CODE SYSRES_CONST_IS_REMOTE_USER_NEGATIVE_VALUE SYSRES_CONST_IS_REMOTE_USER_POSITIVE_VALUE SYSRES_CONST_IS_STORED_REQUISITE_NEGATIVE_VALUE SYSRES_CONST_IS_STORED_REQUISITE_STORED_VALUE SYSRES_CONST_ITALIC_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_JOB_BLOCK_DESCRIPTION SYSRES_CONST_JOB_KIND_CONTROL_JOB SYSRES_CONST_JOB_KIND_JOB SYSRES_CONST_JOB_KIND_NOTICE SYSRES_CONST_JOB_STATE_ABORTED SYSRES_CONST_JOB_STATE_COMPLETE SYSRES_CONST_JOB_STATE_WORKING SYSRES_CONST_KIND_REQUISITE_CODE SYSRES_CONST_KIND_REQUISITE_NAME SYSRES_CONST_KINDS_CREATE_SHADOW_COPIES_REQUISITE_CODE SYSRES_CONST_KINDS_DEFAULT_EDOC_LIFE_STAGE_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALL_TEPLATES_ALLOWED_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALLOW_LIFE_CYCLE_STAGE_CHANGING_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_ALLOW_MULTIPLE_ACTIVE_VERSIONS_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_SHARE_ACCES_RIGHTS_BY_DEFAULT_CODE SYSRES_CONST_KINDS_EDOC_TEMPLATE_REQUISITE_CODE SYSRES_CONST_KINDS_EDOC_TYPE_REQUISITE_CODE SYSRES_CONST_KINDS_SIGNERS_REQUISITES_CODE SYSRES_CONST_KOD_INPUT_TYPE SYSRES_CONST_LAST_UPDATE_DATE_REQUISITE_CODE SYSRES_CONST_LIFE_CYCLE_START_STAGE_REQUISITE_CODE SYSRES_CONST_LILAC_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_LINK_OBJECT_KIND_COMPONENT SYSRES_CONST_LINK_OBJECT_KIND_DOCUMENT SYSRES_CONST_LINK_OBJECT_KIND_EDOC SYSRES_CONST_LINK_OBJECT_KIND_FOLDER SYSRES_CONST_LINK_OBJECT_KIND_JOB SYSRES_CONST_LINK_OBJECT_KIND_REFERENCE SYSRES_CONST_LINK_OBJECT_KIND_TASK SYSRES_CONST_LINK_REF_TYPE_REQUISITE_CODE SYSRES_CONST_LIST_REFERENCE_MODE_NAME SYSRES_CONST_LOCALIZATION_DICTIONARY_MAIN_VIEW_CODE SYSRES_CONST_MAIN_VIEW_CODE SYSRES_CONST_MANUAL_ENUM_METHOD_FLAG SYSRES_CONST_MASTER_COMP_TYPE_REQUISITE_CODE SYSRES_CONST_MASTER_TABLE_REC_ID_REQUISITE_CODE SYSRES_CONST_MAXIMIZED_MODE_NAME SYSRES_CONST_ME_VALUE SYSRES_CONST_MESSAGE_ATTENTION_CAPTION SYSRES_CONST_MESSAGE_CONFIRMATION_CAPTION SYSRES_CONST_MESSAGE_ERROR_CAPTION SYSRES_CONST_MESSAGE_INFORMATION_CAPTION SYSRES_CONST_MINIMIZED_MODE_NAME SYSRES_CONST_MINUTE_CHAR SYSRES_CONST_MODULE_REQUISITE_CODE SYSRES_CONST_MONITORING_BLOCK_DESCRIPTION SYSRES_CONST_MONTH_FORMAT_VALUE SYSRES_CONST_NAME_LOCALIZE_ID_REQUISITE_CODE SYSRES_CONST_NAME_REQUISITE_CODE SYSRES_CONST_NAME_SINGULAR_REQUISITE_CODE SYSRES_CONST_NAMEAN_INPUT_TYPE SYSRES_CONST_NEGATIVE_PICK_VALUE SYSRES_CONST_NEGATIVE_VALUE SYSRES_CONST_NO SYSRES_CONST_NO_PICK_VALUE SYSRES_CONST_NO_SIGNATURE_REQUISITE_CODE SYSRES_CONST_NO_VALUE SYSRES_CONST_NONE_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_NONOPERATING_RECORD_FLAG_VALUE SYSRES_CONST_NONOPERATING_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_NORMAL_ACCESS_RIGHTS_TYPE_CODE SYSRES_CONST_NORMAL_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_NORMAL_MODE_NAME SYSRES_CONST_NOT_ALLOWED_ACCESS_TYPE_CODE SYSRES_CONST_NOT_ALLOWED_ACCESS_TYPE_NAME SYSRES_CONST_NOTE_REQUISITE_CODE SYSRES_CONST_NOTICE_BLOCK_DESCRIPTION SYSRES_CONST_NUM_REQUISITE SYSRES_CONST_NUM_STR_REQUISITE_CODE SYSRES_CONST_NUMERATION_AUTO_NOT_STRONG SYSRES_CONST_NUMERATION_AUTO_STRONG SYSRES_CONST_NUMERATION_FROM_DICTONARY SYSRES_CONST_NUMERATION_MANUAL SYSRES_CONST_NUMERIC_TYPE_CHAR SYSRES_CONST_NUMREQ_REQUISITE_CODE SYSRES_CONST_OBSOLETE_VERSION_STATE_PICK_VALUE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_CODE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_FEMININE SYSRES_CONST_OPERATING_RECORD_FLAG_VALUE_MASCULINE SYSRES_CONST_OPTIONAL_FORM_COMP_REQCODE_PREFIX SYSRES_CONST_ORANGE_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_ORIGINALREF_REQUISITE_CODE SYSRES_CONST_OURFIRM_REF_CODE SYSRES_CONST_OURFIRM_REQUISITE_CODE SYSRES_CONST_OURFIRM_VAR SYSRES_CONST_OUTGOING_WORK_RULE_TYPE_CODE SYSRES_CONST_PICK_NEGATIVE_RESULT SYSRES_CONST_PICK_POSITIVE_RESULT SYSRES_CONST_PICK_REQUISITE SYSRES_CONST_PICK_REQUISITE_TYPE SYSRES_CONST_PICK_TYPE_CHAR SYSRES_CONST_PLAN_STATUS_REQUISITE_CODE SYSRES_CONST_PLATFORM_VERSION_COMMENT SYSRES_CONST_PLUGINS_SETTINGS_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_POSITIVE_PICK_VALUE SYSRES_CONST_POWER_TO_CREATE_ACTION_CODE SYSRES_CONST_POWER_TO_SIGN_ACTION_CODE SYSRES_CONST_PRIORITY_REQUISITE_CODE SYSRES_CONST_QUALIFIED_TASK_TYPE SYSRES_CONST_QUALIFIED_TASK_TYPE_CODE SYSRES_CONST_RECSTAT_REQUISITE_CODE SYSRES_CONST_RED_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_REF_ID_T_REF_TYPE_REQUISITE_CODE SYSRES_CONST_REF_REQUISITE SYSRES_CONST_REF_REQUISITE_TYPE SYSRES_CONST_REF_REQUISITES_REFERENCE_CODE_SELECTED_REQUISITE SYSRES_CONST_REFERENCE_RECORD_HISTORY_CREATE_ACTION_CODE SYSRES_CONST_REFERENCE_RECORD_HISTORY_DELETE_ACTION_CODE SYSRES_CONST_REFERENCE_RECORD_HISTORY_MODIFY_ACTION_CODE SYSRES_CONST_REFERENCE_TYPE_CHAR SYSRES_CONST_REFERENCE_TYPE_REQUISITE_NAME SYSRES_CONST_REFERENCES_ADD_PARAMS_REQUISITE_CODE SYSRES_CONST_REFERENCES_DISPLAY_REQUISITE_REQUISITE_CODE SYSRES_CONST_REMOTE_SERVER_STATUS_WORKING SYSRES_CONST_REMOTE_SERVER_TYPE_MAIN SYSRES_CONST_REMOTE_SERVER_TYPE_SECONDARY SYSRES_CONST_REMOTE_USER_FLAG_VALUE_CODE SYSRES_CONST_REPORT_APP_EDITOR_INTERNAL SYSRES_CONST_REPORT_BASE_REPORT_ID_REQUISITE_CODE SYSRES_CONST_REPORT_BASE_REPORT_REQUISITE_CODE SYSRES_CONST_REPORT_SCRIPT_REQUISITE_CODE SYSRES_CONST_REPORT_TEMPLATE_REQUISITE_CODE SYSRES_CONST_REPORT_VIEWER_CODE_REQUISITE_CODE SYSRES_CONST_REQ_ALLOW_COMPONENT_DEFAULT_VALUE SYSRES_CONST_REQ_ALLOW_RECORD_DEFAULT_VALUE SYSRES_CONST_REQ_ALLOW_SERVER_COMPONENT_DEFAULT_VALUE SYSRES_CONST_REQ_MODE_AVAILABLE_CODE SYSRES_CONST_REQ_MODE_EDIT_CODE SYSRES_CONST_REQ_MODE_HIDDEN_CODE SYSRES_CONST_REQ_MODE_NOT_AVAILABLE_CODE SYSRES_CONST_REQ_MODE_VIEW_CODE SYSRES_CONST_REQ_NUMBER_REQUISITE_CODE SYSRES_CONST_REQ_SECTION_VALUE SYSRES_CONST_REQ_TYPE_VALUE SYSRES_CONST_REQUISITE_FORMAT_BY_UNIT SYSRES_CONST_REQUISITE_FORMAT_DATE_FULL SYSRES_CONST_REQUISITE_FORMAT_DATE_TIME SYSRES_CONST_REQUISITE_FORMAT_LEFT SYSRES_CONST_REQUISITE_FORMAT_RIGHT SYSRES_CONST_REQUISITE_FORMAT_WITHOUT_UNIT SYSRES_CONST_REQUISITE_NUMBER_REQUISITE_CODE SYSRES_CONST_REQUISITE_SECTION_ACTIONS SYSRES_CONST_REQUISITE_SECTION_BUTTON SYSRES_CONST_REQUISITE_SECTION_BUTTONS SYSRES_CONST_REQUISITE_SECTION_CARD SYSRES_CONST_REQUISITE_SECTION_TABLE SYSRES_CONST_REQUISITE_SECTION_TABLE10 SYSRES_CONST_REQUISITE_SECTION_TABLE11 SYSRES_CONST_REQUISITE_SECTION_TABLE12 SYSRES_CONST_REQUISITE_SECTION_TABLE13 SYSRES_CONST_REQUISITE_SECTION_TABLE14 SYSRES_CONST_REQUISITE_SECTION_TABLE15 SYSRES_CONST_REQUISITE_SECTION_TABLE16 SYSRES_CONST_REQUISITE_SECTION_TABLE17 SYSRES_CONST_REQUISITE_SECTION_TABLE18 SYSRES_CONST_REQUISITE_SECTION_TABLE19 SYSRES_CONST_REQUISITE_SECTION_TABLE2 SYSRES_CONST_REQUISITE_SECTION_TABLE20 SYSRES_CONST_REQUISITE_SECTION_TABLE21 SYSRES_CONST_REQUISITE_SECTION_TABLE22 SYSRES_CONST_REQUISITE_SECTION_TABLE23 SYSRES_CONST_REQUISITE_SECTION_TABLE24 SYSRES_CONST_REQUISITE_SECTION_TABLE3 SYSRES_CONST_REQUISITE_SECTION_TABLE4 SYSRES_CONST_REQUISITE_SECTION_TABLE5 SYSRES_CONST_REQUISITE_SECTION_TABLE6 SYSRES_CONST_REQUISITE_SECTION_TABLE7 SYSRES_CONST_REQUISITE_SECTION_TABLE8 SYSRES_CONST_REQUISITE_SECTION_TABLE9 SYSRES_CONST_REQUISITES_PSEUDOREFERENCE_REQUISITE_NUMBER_REQUISITE_CODE SYSRES_CONST_RIGHT_ALIGNMENT_CODE SYSRES_CONST_ROLES_REFERENCE_CODE SYSRES_CONST_ROUTE_STEP_AFTER_RUS SYSRES_CONST_ROUTE_STEP_AND_CONDITION_RUS SYSRES_CONST_ROUTE_STEP_OR_CONDITION_RUS SYSRES_CONST_ROUTE_TYPE_COMPLEX SYSRES_CONST_ROUTE_TYPE_PARALLEL SYSRES_CONST_ROUTE_TYPE_SERIAL SYSRES_CONST_SBDATASETDESC_NEGATIVE_VALUE SYSRES_CONST_SBDATASETDESC_POSITIVE_VALUE SYSRES_CONST_SBVIEWSDESC_POSITIVE_VALUE SYSRES_CONST_SCRIPT_BLOCK_DESCRIPTION SYSRES_CONST_SEARCH_BY_TEXT_REQUISITE_CODE SYSRES_CONST_SEARCHES_COMPONENT_CONTENT SYSRES_CONST_SEARCHES_CRITERIA_ACTION_NAME SYSRES_CONST_SEARCHES_EDOC_CONTENT SYSRES_CONST_SEARCHES_FOLDER_CONTENT SYSRES_CONST_SEARCHES_JOB_CONTENT SYSRES_CONST_SEARCHES_REFERENCE_CODE SYSRES_CONST_SEARCHES_TASK_CONTENT SYSRES_CONST_SECOND_CHAR SYSRES_CONST_SECTION_REQUISITE_ACTIONS_VALUE SYSRES_CONST_SECTION_REQUISITE_CARD_VALUE SYSRES_CONST_SECTION_REQUISITE_CODE SYSRES_CONST_SECTION_REQUISITE_DETAIL_1_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_2_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_3_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_4_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_5_VALUE SYSRES_CONST_SECTION_REQUISITE_DETAIL_6_VALUE SYSRES_CONST_SELECT_REFERENCE_MODE_NAME SYSRES_CONST_SELECT_TYPE_SELECTABLE SYSRES_CONST_SELECT_TYPE_SELECTABLE_ONLY_CHILD SYSRES_CONST_SELECT_TYPE_SELECTABLE_WITH_CHILD SYSRES_CONST_SELECT_TYPE_UNSLECTABLE SYSRES_CONST_SERVER_TYPE_MAIN SYSRES_CONST_SERVICE_USER_CATEGORY_FIELD_VALUE SYSRES_CONST_SETTINGS_USER_REQUISITE_CODE SYSRES_CONST_SIGNATURE_AND_ENCODE_CERTIFICATE_TYPE_CODE SYSRES_CONST_SIGNATURE_CERTIFICATE_TYPE_CODE SYSRES_CONST_SINGULAR_TITLE_REQUISITE_CODE SYSRES_CONST_SQL_SERVER_AUTHENTIFICATION_FLAG_VALUE_CODE SYSRES_CONST_SQL_SERVER_ENCODE_AUTHENTIFICATION_FLAG_VALUE_CODE SYSRES_CONST_STANDART_ROUTE_REFERENCE_CODE SYSRES_CONST_STANDART_ROUTE_REFERENCE_COMMENT_REQUISITE_CODE SYSRES_CONST_STANDART_ROUTES_GROUPS_REFERENCE_CODE SYSRES_CONST_STATE_REQ_NAME SYSRES_CONST_STATE_REQUISITE_ACTIVE_VALUE SYSRES_CONST_STATE_REQUISITE_CLOSED_VALUE SYSRES_CONST_STATE_REQUISITE_CODE SYSRES_CONST_STATIC_ROLE_TYPE_CODE SYSRES_CONST_STATUS_PLAN_DEFAULT_VALUE SYSRES_CONST_STATUS_VALUE_AUTOCLEANING SYSRES_CONST_STATUS_VALUE_BLUE_SQUARE SYSRES_CONST_STATUS_VALUE_COMPLETE SYSRES_CONST_STATUS_VALUE_GREEN_SQUARE SYSRES_CONST_STATUS_VALUE_ORANGE_SQUARE SYSRES_CONST_STATUS_VALUE_PURPLE_SQUARE SYSRES_CONST_STATUS_VALUE_RED_SQUARE SYSRES_CONST_STATUS_VALUE_SUSPEND SYSRES_CONST_STATUS_VALUE_YELLOW_SQUARE SYSRES_CONST_STDROUTE_SHOW_TO_USERS_REQUISITE_CODE SYSRES_CONST_STORAGE_TYPE_FILE SYSRES_CONST_STORAGE_TYPE_SQL_SERVER SYSRES_CONST_STR_REQUISITE SYSRES_CONST_STRIKEOUT_LIFE_CYCLE_STAGE_DRAW_STYLE SYSRES_CONST_STRING_FORMAT_LEFT_ALIGN_CHAR SYSRES_CONST_STRING_FORMAT_RIGHT_ALIGN_CHAR SYSRES_CONST_STRING_REQUISITE_CODE SYSRES_CONST_STRING_REQUISITE_TYPE SYSRES_CONST_STRING_TYPE_CHAR SYSRES_CONST_SUBSTITUTES_PSEUDOREFERENCE_CODE SYSRES_CONST_SUBTASK_BLOCK_DESCRIPTION SYSRES_CONST_SYSTEM_SETTING_CURRENT_USER_PARAM_VALUE SYSRES_CONST_SYSTEM_SETTING_EMPTY_VALUE_PARAM_VALUE SYSRES_CONST_SYSTEM_VERSION_COMMENT SYSRES_CONST_TASK_ACCESS_TYPE_ALL SYSRES_CONST_TASK_ACCESS_TYPE_ALL_MEMBERS SYSRES_CONST_TASK_ACCESS_TYPE_MANUAL SYSRES_CONST_TASK_ENCODE_TYPE_CERTIFICATION SYSRES_CONST_TASK_ENCODE_TYPE_CERTIFICATION_AND_PASSWORD SYSRES_CONST_TASK_ENCODE_TYPE_NONE SYSRES_CONST_TASK_ENCODE_TYPE_PASSWORD SYSRES_CONST_TASK_ROUTE_ALL_CONDITION SYSRES_CONST_TASK_ROUTE_AND_CONDITION SYSRES_CONST_TASK_ROUTE_OR_CONDITION SYSRES_CONST_TASK_STATE_ABORTED SYSRES_CONST_TASK_STATE_COMPLETE SYSRES_CONST_TASK_STATE_CONTINUED SYSRES_CONST_TASK_STATE_CONTROL SYSRES_CONST_TASK_STATE_INIT SYSRES_CONST_TASK_STATE_WORKING SYSRES_CONST_TASK_TITLE SYSRES_CONST_TASK_TYPES_GROUPS_REFERENCE_CODE SYSRES_CONST_TASK_TYPES_REFERENCE_CODE SYSRES_CONST_TEMPLATES_REFERENCE_CODE SYSRES_CONST_TEST_DATE_REQUISITE_NAME SYSRES_CONST_TEST_DEV_DATABASE_NAME SYSRES_CONST_TEST_DEV_SYSTEM_CODE SYSRES_CONST_TEST_EDMS_DATABASE_NAME SYSRES_CONST_TEST_EDMS_MAIN_CODE SYSRES_CONST_TEST_EDMS_MAIN_DB_NAME SYSRES_CONST_TEST_EDMS_SECOND_CODE SYSRES_CONST_TEST_EDMS_SECOND_DB_NAME SYSRES_CONST_TEST_EDMS_SYSTEM_CODE SYSRES_CONST_TEST_NUMERIC_REQUISITE_NAME SYSRES_CONST_TEXT_REQUISITE SYSRES_CONST_TEXT_REQUISITE_CODE SYSRES_CONST_TEXT_REQUISITE_TYPE SYSRES_CONST_TEXT_TYPE_CHAR SYSRES_CONST_TYPE_CODE_REQUISITE_CODE SYSRES_CONST_TYPE_REQUISITE_CODE SYSRES_CONST_UNDEFINED_LIFE_CYCLE_STAGE_FONT_COLOR SYSRES_CONST_UNITS_SECTION_ID_REQUISITE_CODE SYSRES_CONST_UNITS_SECTION_REQUISITE_CODE SYSRES_CONST_UNOPERATING_RECORD_FLAG_VALUE_CODE SYSRES_CONST_UNSTORED_DATA_REQUISITE_CODE SYSRES_CONST_UNSTORED_DATA_REQUISITE_NAME SYSRES_CONST_USE_ACCESS_TYPE_CODE SYSRES_CONST_USE_ACCESS_TYPE_NAME SYSRES_CONST_USER_ACCOUNT_TYPE_VALUE_CODE SYSRES_CONST_USER_ADDITIONAL_INFORMATION_REQUISITE_CODE SYSRES_CONST_USER_AND_GROUP_ID_FROM_PSEUDOREFERENCE_REQUISITE_CODE SYSRES_CONST_USER_CATEGORY_NORMAL SYSRES_CONST_USER_CERTIFICATE_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_STATE_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_SUBJECT_NAME_REQUISITE_CODE SYSRES_CONST_USER_CERTIFICATE_THUMBPRINT_REQUISITE_CODE SYSRES_CONST_USER_COMMON_CATEGORY SYSRES_CONST_USER_COMMON_CATEGORY_CODE SYSRES_CONST_USER_FULL_NAME_REQUISITE_CODE SYSRES_CONST_USER_GROUP_TYPE_REQUISITE_CODE SYSRES_CONST_USER_LOGIN_REQUISITE_CODE SYSRES_CONST_USER_REMOTE_CONTROLLER_REQUISITE_CODE SYSRES_CONST_USER_REMOTE_SYSTEM_REQUISITE_CODE SYSRES_CONST_USER_RIGHTS_T_REQUISITE_CODE SYSRES_CONST_USER_SERVER_NAME_REQUISITE_CODE SYSRES_CONST_USER_SERVICE_CATEGORY SYSRES_CONST_USER_SERVICE_CATEGORY_CODE SYSRES_CONST_USER_STATUS_ADMINISTRATOR_CODE SYSRES_CONST_USER_STATUS_ADMINISTRATOR_NAME SYSRES_CONST_USER_STATUS_DEVELOPER_CODE SYSRES_CONST_USER_STATUS_DEVELOPER_NAME SYSRES_CONST_USER_STATUS_DISABLED_CODE SYSRES_CONST_USER_STATUS_DISABLED_NAME SYSRES_CONST_USER_STATUS_SYSTEM_DEVELOPER_CODE SYSRES_CONST_USER_STATUS_USER_CODE SYSRES_CONST_USER_STATUS_USER_NAME SYSRES_CONST_USER_STATUS_USER_NAME_DEPRECATED SYSRES_CONST_USER_TYPE_FIELD_VALUE_USER SYSRES_CONST_USER_TYPE_REQUISITE_CODE SYSRES_CONST_USERS_CONTROLLER_REQUISITE_CODE SYSRES_CONST_USERS_IS_MAIN_SERVER_REQUISITE_CODE SYSRES_CONST_USERS_REFERENCE_CODE SYSRES_CONST_USERS_REGISTRATION_CERTIFICATES_ACTION_NAME SYSRES_CONST_USERS_REQUISITE_CODE SYSRES_CONST_USERS_SYSTEM_REQUISITE_CODE SYSRES_CONST_USERS_USER_ACCESS_RIGHTS_TYPR_REQUISITE_CODE SYSRES_CONST_USERS_USER_AUTHENTICATION_REQUISITE_CODE SYSRES_CONST_USERS_USER_COMPONENT_REQUISITE_CODE SYSRES_CONST_USERS_USER_GROUP_REQUISITE_CODE SYSRES_CONST_USERS_VIEW_CERTIFICATES_ACTION_NAME SYSRES_CONST_VIEW_DEFAULT_CODE SYSRES_CONST_VIEW_DEFAULT_NAME SYSRES_CONST_VIEWER_REQUISITE_CODE SYSRES_CONST_WAITING_BLOCK_DESCRIPTION SYSRES_CONST_WIZARD_FORM_LABEL_TEST_STRING  SYSRES_CONST_WIZARD_QUERY_PARAM_HEIGHT_ETALON_STRING SYSRES_CONST_WIZARD_REFERENCE_COMMENT_REQUISITE_CODE SYSRES_CONST_WORK_RULES_DESCRIPTION_REQUISITE_CODE SYSRES_CONST_WORK_TIME_CALENDAR_REFERENCE_CODE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE_CODE SYSRES_CONST_WORK_WORKFLOW_HARD_ROUTE_TYPE_VALUE_CODE_RUS SYSRES_CONST_WORK_WORKFLOW_SOFT_ROUTE_TYPE_VALUE_CODE_RUS SYSRES_CONST_WORKFLOW_ROUTE_TYPR_HARD SYSRES_CONST_WORKFLOW_ROUTE_TYPR_SOFT SYSRES_CONST_XML_ENCODING SYSRES_CONST_XREC_STAT_REQUISITE_CODE SYSRES_CONST_XRECID_FIELD_NAME SYSRES_CONST_YES SYSRES_CONST_YES_NO_2_REQUISITE_CODE SYSRES_CONST_YES_NO_REQUISITE_CODE SYSRES_CONST_YES_NO_T_REF_TYPE_REQUISITE_CODE SYSRES_CONST_YES_PICK_VALUE SYSRES_CONST_YES_VALUE CR FALSE nil NO_VALUE NULL TAB TRUE YES_VALUE ADMINISTRATORS_GROUP_NAME CUSTOMIZERS_GROUP_NAME DEVELOPERS_GROUP_NAME SERVICE_USERS_GROUP_NAME DECISION_BLOCK_FIRST_OPERAND_PROPERTY DECISION_BLOCK_NAME_PROPERTY DECISION_BLOCK_OPERATION_PROPERTY DECISION_BLOCK_RESULT_TYPE_PROPERTY DECISION_BLOCK_SECOND_OPERAND_PROPERTY ANY_FILE_EXTENTION COMPRESSED_DOCUMENT_EXTENSION EXTENDED_DOCUMENT_EXTENSION SHORT_COMPRESSED_DOCUMENT_EXTENSION SHORT_EXTENDED_DOCUMENT_EXTENSION JOB_BLOCK_ABORT_DEADLINE_PROPERTY JOB_BLOCK_AFTER_FINISH_EVENT JOB_BLOCK_AFTER_QUERY_PARAMETERS_EVENT JOB_BLOCK_ATTACHMENT_PROPERTY JOB_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY JOB_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY JOB_BLOCK_BEFORE_QUERY_PARAMETERS_EVENT JOB_BLOCK_BEFORE_START_EVENT JOB_BLOCK_CREATED_JOBS_PROPERTY JOB_BLOCK_DEADLINE_PROPERTY JOB_BLOCK_EXECUTION_RESULTS_PROPERTY JOB_BLOCK_IS_PARALLEL_PROPERTY JOB_BLOCK_IS_RELATIVE_ABORT_DEADLINE_PROPERTY JOB_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY JOB_BLOCK_JOB_TEXT_PROPERTY JOB_BLOCK_NAME_PROPERTY JOB_BLOCK_NEED_SIGN_ON_PERFORM_PROPERTY JOB_BLOCK_PERFORMER_PROPERTY JOB_BLOCK_RELATIVE_ABORT_DEADLINE_TYPE_PROPERTY JOB_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY JOB_BLOCK_SUBJECT_PROPERTY ENGLISH_LANGUAGE_CODE RUSSIAN_LANGUAGE_CODE smHidden smMaximized smMinimized smNormal wmNo wmYes COMPONENT_TOKEN_LINK_KIND DOCUMENT_LINK_KIND EDOCUMENT_LINK_KIND FOLDER_LINK_KIND JOB_LINK_KIND REFERENCE_LINK_KIND TASK_LINK_KIND COMPONENT_TOKEN_LOCK_TYPE EDOCUMENT_VERSION_LOCK_TYPE MONITOR_BLOCK_AFTER_FINISH_EVENT MONITOR_BLOCK_BEFORE_START_EVENT MONITOR_BLOCK_DEADLINE_PROPERTY MONITOR_BLOCK_INTERVAL_PROPERTY MONITOR_BLOCK_INTERVAL_TYPE_PROPERTY MONITOR_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY MONITOR_BLOCK_NAME_PROPERTY MONITOR_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY MONITOR_BLOCK_SEARCH_SCRIPT_PROPERTY NOTICE_BLOCK_AFTER_FINISH_EVENT NOTICE_BLOCK_ATTACHMENT_PROPERTY NOTICE_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY NOTICE_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY NOTICE_BLOCK_BEFORE_START_EVENT NOTICE_BLOCK_CREATED_NOTICES_PROPERTY NOTICE_BLOCK_DEADLINE_PROPERTY NOTICE_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY NOTICE_BLOCK_NAME_PROPERTY NOTICE_BLOCK_NOTICE_TEXT_PROPERTY NOTICE_BLOCK_PERFORMER_PROPERTY NOTICE_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY NOTICE_BLOCK_SUBJECT_PROPERTY dseAfterCancel dseAfterClose dseAfterDelete dseAfterDeleteOutOfTransaction dseAfterInsert dseAfterOpen dseAfterScroll dseAfterUpdate dseAfterUpdateOutOfTransaction dseBeforeCancel dseBeforeClose dseBeforeDelete dseBeforeDetailUpdate dseBeforeInsert dseBeforeOpen dseBeforeUpdate dseOnAnyRequisiteChange dseOnCloseRecord dseOnDeleteError dseOnOpenRecord dseOnPrepareUpdate dseOnUpdateError dseOnUpdateRatifiedRecord dseOnValidDelete dseOnValidUpdate reOnChange reOnChangeValues SELECTION_BEGIN_ROUTE_EVENT SELECTION_END_ROUTE_EVENT CURRENT_PERIOD_IS_REQUIRED PREVIOUS_CARD_TYPE_NAME SHOW_RECORD_PROPERTIES_FORM ACCESS_RIGHTS_SETTING_DIALOG_CODE ADMINISTRATOR_USER_CODE ANALYTIC_REPORT_TYPE asrtHideLocal asrtHideRemote CALCULATED_ROLE_TYPE_CODE COMPONENTS_REFERENCE_DEVELOPER_VIEW_CODE DCTS_TEST_PROTOCOLS_FOLDER_PATH E_EDOC_VERSION_ALREADY_APPROVINGLY_SIGNED E_EDOC_VERSION_ALREADY_APPROVINGLY_SIGNED_BY_USER E_EDOC_VERSION_ALREDY_SIGNED E_EDOC_VERSION_ALREDY_SIGNED_BY_USER EDOC_TYPES_CODE_REQUISITE_FIELD_NAME EDOCUMENTS_ALIAS_NAME FILES_FOLDER_PATH FILTER_OPERANDS_DELIMITER FILTER_OPERATIONS_DELIMITER FORMCARD_NAME FORMLIST_NAME GET_EXTENDED_DOCUMENT_EXTENSION_CREATION_MODE GET_EXTENDED_DOCUMENT_EXTENSION_IMPORT_MODE INTEGRATED_REPORT_TYPE IS_BUILDER_APPLICATION_ROLE IS_BUILDER_APPLICATION_ROLE2 IS_BUILDER_USERS ISBSYSDEV LOG_FOLDER_PATH mbCancel mbNo mbNoToAll mbOK mbYes mbYesToAll MEMORY_DATASET_DESRIPTIONS_FILENAME mrNo mrNoToAll mrYes mrYesToAll MULTIPLE_SELECT_DIALOG_CODE NONOPERATING_RECORD_FLAG_FEMININE NONOPERATING_RECORD_FLAG_MASCULINE OPERATING_RECORD_FLAG_FEMININE OPERATING_RECORD_FLAG_MASCULINE PROFILING_SETTINGS_COMMON_SETTINGS_CODE_VALUE PROGRAM_INITIATED_LOOKUP_ACTION ratDelete ratEdit ratInsert REPORT_TYPE REQUIRED_PICK_VALUES_VARIABLE rmCard rmList SBRTE_PROGID_DEV SBRTE_PROGID_RELEASE STATIC_ROLE_TYPE_CODE SUPPRESS_EMPTY_TEMPLATE_CREATION SYSTEM_USER_CODE UPDATE_DIALOG_DATASET USED_IN_OBJECT_HINT_PARAM USER_INITIATED_LOOKUP_ACTION USER_NAME_FORMAT USER_SELECTION_RESTRICTIONS WORKFLOW_TEST_PROTOCOLS_FOLDER_PATH ELS_SUBTYPE_CONTROL_NAME ELS_FOLDER_KIND_CONTROL_NAME REPEAT_PROCESS_CURRENT_OBJECT_EXCEPTION_NAME PRIVILEGE_COMPONENT_FULL_ACCESS PRIVILEGE_DEVELOPMENT_EXPORT PRIVILEGE_DEVELOPMENT_IMPORT PRIVILEGE_DOCUMENT_DELETE PRIVILEGE_ESD PRIVILEGE_FOLDER_DELETE PRIVILEGE_MANAGE_ACCESS_RIGHTS PRIVILEGE_MANAGE_REPLICATION PRIVILEGE_MANAGE_SESSION_SERVER PRIVILEGE_OBJECT_FULL_ACCESS PRIVILEGE_OBJECT_VIEW PRIVILEGE_RESERVE_LICENSE PRIVILEGE_SYSTEM_CUSTOMIZE PRIVILEGE_SYSTEM_DEVELOP PRIVILEGE_SYSTEM_INSTALL PRIVILEGE_TASK_DELETE PRIVILEGE_USER_PLUGIN_SETTINGS_CUSTOMIZE PRIVILEGES_PSEUDOREFERENCE_CODE ACCESS_TYPES_PSEUDOREFERENCE_CODE ALL_AVAILABLE_COMPONENTS_PSEUDOREFERENCE_CODE ALL_AVAILABLE_PRIVILEGES_PSEUDOREFERENCE_CODE ALL_REPLICATE_COMPONENTS_PSEUDOREFERENCE_CODE AVAILABLE_DEVELOPERS_COMPONENTS_PSEUDOREFERENCE_CODE COMPONENTS_PSEUDOREFERENCE_CODE FILTRATER_SETTINGS_CONFLICTS_PSEUDOREFERENCE_CODE GROUPS_PSEUDOREFERENCE_CODE RECEIVE_PROTOCOL_PSEUDOREFERENCE_CODE REFERENCE_REQUISITE_PSEUDOREFERENCE_CODE REFERENCE_REQUISITES_PSEUDOREFERENCE_CODE REFTYPES_PSEUDOREFERENCE_CODE REPLICATION_SEANCES_DIARY_PSEUDOREFERENCE_CODE SEND_PROTOCOL_PSEUDOREFERENCE_CODE SUBSTITUTES_PSEUDOREFERENCE_CODE SYSTEM_SETTINGS_PSEUDOREFERENCE_CODE UNITS_PSEUDOREFERENCE_CODE USERS_PSEUDOREFERENCE_CODE VIEWERS_PSEUDOREFERENCE_CODE CERTIFICATE_TYPE_ENCRYPT CERTIFICATE_TYPE_SIGN CERTIFICATE_TYPE_SIGN_AND_ENCRYPT STORAGE_TYPE_FILE STORAGE_TYPE_NAS_CIFS STORAGE_TYPE_SAPERION STORAGE_TYPE_SQL_SERVER COMPTYPE2_REQUISITE_DOCUMENTS_VALUE COMPTYPE2_REQUISITE_TASKS_VALUE COMPTYPE2_REQUISITE_FOLDERS_VALUE COMPTYPE2_REQUISITE_REFERENCES_VALUE SYSREQ_CODE SYSREQ_COMPTYPE2 SYSREQ_CONST_AVAILABLE_FOR_WEB SYSREQ_CONST_COMMON_CODE SYSREQ_CONST_COMMON_VALUE SYSREQ_CONST_FIRM_CODE SYSREQ_CONST_FIRM_STATUS SYSREQ_CONST_FIRM_VALUE SYSREQ_CONST_SERVER_STATUS SYSREQ_CONTENTS SYSREQ_DATE_OPEN SYSREQ_DATE_CLOSE SYSREQ_DESCRIPTION SYSREQ_DESCRIPTION_LOCALIZE_ID SYSREQ_DOUBLE SYSREQ_EDOC_ACCESS_TYPE SYSREQ_EDOC_AUTHOR SYSREQ_EDOC_CREATED SYSREQ_EDOC_DELEGATE_RIGHTS_REQUISITE_CODE SYSREQ_EDOC_EDITOR SYSREQ_EDOC_ENCODE_TYPE SYSREQ_EDOC_ENCRYPTION_PLUGIN_NAME SYSREQ_EDOC_ENCRYPTION_PLUGIN_VERSION SYSREQ_EDOC_EXPORT_DATE SYSREQ_EDOC_EXPORTER SYSREQ_EDOC_KIND SYSREQ_EDOC_LIFE_STAGE_NAME SYSREQ_EDOC_LOCKED_FOR_SERVER_CODE SYSREQ_EDOC_MODIFIED SYSREQ_EDOC_NAME SYSREQ_EDOC_NOTE SYSREQ_EDOC_QUALIFIED_ID SYSREQ_EDOC_SESSION_KEY SYSREQ_EDOC_SESSION_KEY_ENCRYPTION_PLUGIN_NAME SYSREQ_EDOC_SESSION_KEY_ENCRYPTION_PLUGIN_VERSION SYSREQ_EDOC_SIGNATURE_TYPE SYSREQ_EDOC_SIGNED SYSREQ_EDOC_STORAGE SYSREQ_EDOC_STORAGES_ARCHIVE_STORAGE SYSREQ_EDOC_STORAGES_CHECK_RIGHTS SYSREQ_EDOC_STORAGES_COMPUTER_NAME SYSREQ_EDOC_STORAGES_EDIT_IN_STORAGE SYSREQ_EDOC_STORAGES_EXECUTIVE_STORAGE SYSREQ_EDOC_STORAGES_FUNCTION SYSREQ_EDOC_STORAGES_INITIALIZED SYSREQ_EDOC_STORAGES_LOCAL_PATH SYSREQ_EDOC_STORAGES_SAPERION_DATABASE_NAME SYSREQ_EDOC_STORAGES_SEARCH_BY_TEXT SYSREQ_EDOC_STORAGES_SERVER_NAME SYSREQ_EDOC_STORAGES_SHARED_SOURCE_NAME SYSREQ_EDOC_STORAGES_TYPE SYSREQ_EDOC_TEXT_MODIFIED SYSREQ_EDOC_TYPE_ACT_CODE SYSREQ_EDOC_TYPE_ACT_DESCRIPTION SYSREQ_EDOC_TYPE_ACT_DESCRIPTION_LOCALIZE_ID SYSREQ_EDOC_TYPE_ACT_ON_EXECUTE SYSREQ_EDOC_TYPE_ACT_ON_EXECUTE_EXISTS SYSREQ_EDOC_TYPE_ACT_SECTION SYSREQ_EDOC_TYPE_ADD_PARAMS SYSREQ_EDOC_TYPE_COMMENT SYSREQ_EDOC_TYPE_EVENT_TEXT SYSREQ_EDOC_TYPE_NAME_IN_SINGULAR SYSREQ_EDOC_TYPE_NAME_IN_SINGULAR_LOCALIZE_ID SYSREQ_EDOC_TYPE_NAME_LOCALIZE_ID SYSREQ_EDOC_TYPE_NUMERATION_METHOD SYSREQ_EDOC_TYPE_PSEUDO_REQUISITE_CODE SYSREQ_EDOC_TYPE_REQ_CODE SYSREQ_EDOC_TYPE_REQ_DESCRIPTION SYSREQ_EDOC_TYPE_REQ_DESCRIPTION_LOCALIZE_ID SYSREQ_EDOC_TYPE_REQ_IS_LEADING SYSREQ_EDOC_TYPE_REQ_IS_REQUIRED SYSREQ_EDOC_TYPE_REQ_NUMBER SYSREQ_EDOC_TYPE_REQ_ON_CHANGE SYSREQ_EDOC_TYPE_REQ_ON_CHANGE_EXISTS SYSREQ_EDOC_TYPE_REQ_ON_SELECT SYSREQ_EDOC_TYPE_REQ_ON_SELECT_KIND SYSREQ_EDOC_TYPE_REQ_SECTION SYSREQ_EDOC_TYPE_VIEW_CARD SYSREQ_EDOC_TYPE_VIEW_CODE SYSREQ_EDOC_TYPE_VIEW_COMMENT SYSREQ_EDOC_TYPE_VIEW_IS_MAIN SYSREQ_EDOC_TYPE_VIEW_NAME SYSREQ_EDOC_TYPE_VIEW_NAME_LOCALIZE_ID SYSREQ_EDOC_VERSION_AUTHOR SYSREQ_EDOC_VERSION_CRC SYSREQ_EDOC_VERSION_DATA SYSREQ_EDOC_VERSION_EDITOR SYSREQ_EDOC_VERSION_EXPORT_DATE SYSREQ_EDOC_VERSION_EXPORTER SYSREQ_EDOC_VERSION_HIDDEN SYSREQ_EDOC_VERSION_LIFE_STAGE SYSREQ_EDOC_VERSION_MODIFIED SYSREQ_EDOC_VERSION_NOTE SYSREQ_EDOC_VERSION_SIGNATURE_TYPE SYSREQ_EDOC_VERSION_SIGNED SYSREQ_EDOC_VERSION_SIZE SYSREQ_EDOC_VERSION_SOURCE SYSREQ_EDOC_VERSION_TEXT_MODIFIED SYSREQ_EDOCKIND_DEFAULT_VERSION_STATE_CODE SYSREQ_FOLDER_KIND SYSREQ_FUNC_CATEGORY SYSREQ_FUNC_COMMENT SYSREQ_FUNC_GROUP SYSREQ_FUNC_GROUP_COMMENT SYSREQ_FUNC_GROUP_NUMBER SYSREQ_FUNC_HELP SYSREQ_FUNC_PARAM_DEF_VALUE SYSREQ_FUNC_PARAM_IDENT SYSREQ_FUNC_PARAM_NUMBER SYSREQ_FUNC_PARAM_TYPE SYSREQ_FUNC_TEXT SYSREQ_GROUP_CATEGORY SYSREQ_ID SYSREQ_LAST_UPDATE SYSREQ_LEADER_REFERENCE SYSREQ_LINE_NUMBER SYSREQ_MAIN_RECORD_ID SYSREQ_NAME SYSREQ_NAME_LOCALIZE_ID SYSREQ_NOTE SYSREQ_ORIGINAL_RECORD SYSREQ_OUR_FIRM SYSREQ_PROFILING_SETTINGS_BATCH_LOGING SYSREQ_PROFILING_SETTINGS_BATCH_SIZE SYSREQ_PROFILING_SETTINGS_PROFILING_ENABLED SYSREQ_PROFILING_SETTINGS_SQL_PROFILING_ENABLED SYSREQ_PROFILING_SETTINGS_START_LOGGED SYSREQ_RECORD_STATUS SYSREQ_REF_REQ_FIELD_NAME SYSREQ_REF_REQ_FORMAT SYSREQ_REF_REQ_GENERATED SYSREQ_REF_REQ_LENGTH SYSREQ_REF_REQ_PRECISION SYSREQ_REF_REQ_REFERENCE SYSREQ_REF_REQ_SECTION SYSREQ_REF_REQ_STORED SYSREQ_REF_REQ_TOKENS SYSREQ_REF_REQ_TYPE SYSREQ_REF_REQ_VIEW SYSREQ_REF_TYPE_ACT_CODE SYSREQ_REF_TYPE_ACT_DESCRIPTION SYSREQ_REF_TYPE_ACT_DESCRIPTION_LOCALIZE_ID SYSREQ_REF_TYPE_ACT_ON_EXECUTE SYSREQ_REF_TYPE_ACT_ON_EXECUTE_EXISTS SYSREQ_REF_TYPE_ACT_SECTION SYSREQ_REF_TYPE_ADD_PARAMS SYSREQ_REF_TYPE_COMMENT SYSREQ_REF_TYPE_COMMON_SETTINGS SYSREQ_REF_TYPE_DISPLAY_REQUISITE_NAME SYSREQ_REF_TYPE_EVENT_TEXT SYSREQ_REF_TYPE_MAIN_LEADING_REF SYSREQ_REF_TYPE_NAME_IN_SINGULAR SYSREQ_REF_TYPE_NAME_IN_SINGULAR_LOCALIZE_ID SYSREQ_REF_TYPE_NAME_LOCALIZE_ID SYSREQ_REF_TYPE_NUMERATION_METHOD SYSREQ_REF_TYPE_REQ_CODE SYSREQ_REF_TYPE_REQ_DESCRIPTION SYSREQ_REF_TYPE_REQ_DESCRIPTION_LOCALIZE_ID SYSREQ_REF_TYPE_REQ_IS_CONTROL SYSREQ_REF_TYPE_REQ_IS_FILTER SYSREQ_REF_TYPE_REQ_IS_LEADING SYSREQ_REF_TYPE_REQ_IS_REQUIRED SYSREQ_REF_TYPE_REQ_NUMBER SYSREQ_REF_TYPE_REQ_ON_CHANGE SYSREQ_REF_TYPE_REQ_ON_CHANGE_EXISTS SYSREQ_REF_TYPE_REQ_ON_SELECT SYSREQ_REF_TYPE_REQ_ON_SELECT_KIND SYSREQ_REF_TYPE_REQ_SECTION SYSREQ_REF_TYPE_VIEW_CARD SYSREQ_REF_TYPE_VIEW_CODE SYSREQ_REF_TYPE_VIEW_COMMENT SYSREQ_REF_TYPE_VIEW_IS_MAIN SYSREQ_REF_TYPE_VIEW_NAME SYSREQ_REF_TYPE_VIEW_NAME_LOCALIZE_ID SYSREQ_REFERENCE_TYPE_ID SYSREQ_STATE SYSREQ_STAT\u0415 SYSREQ_SYSTEM_SETTINGS_VALUE SYSREQ_TYPE SYSREQ_UNIT SYSREQ_UNIT_ID SYSREQ_USER_GROUPS_GROUP_FULL_NAME SYSREQ_USER_GROUPS_GROUP_NAME SYSREQ_USER_GROUPS_GROUP_SERVER_NAME SYSREQ_USERS_ACCESS_RIGHTS SYSREQ_USERS_AUTHENTICATION SYSREQ_USERS_CATEGORY SYSREQ_USERS_COMPONENT SYSREQ_USERS_COMPONENT_USER_IS_PUBLIC SYSREQ_USERS_DOMAIN SYSREQ_USERS_FULL_USER_NAME SYSREQ_USERS_GROUP SYSREQ_USERS_IS_MAIN_SERVER SYSREQ_USERS_LOGIN SYSREQ_USERS_REFERENCE_USER_IS_PUBLIC SYSREQ_USERS_STATUS SYSREQ_USERS_USER_CERTIFICATE SYSREQ_USERS_USER_CERTIFICATE_INFO SYSREQ_USERS_USER_CERTIFICATE_PLUGIN_NAME SYSREQ_USERS_USER_CERTIFICATE_PLUGIN_VERSION SYSREQ_USERS_USER_CERTIFICATE_STATE SYSREQ_USERS_USER_CERTIFICATE_SUBJECT_NAME SYSREQ_USERS_USER_CERTIFICATE_THUMBPRINT SYSREQ_USERS_USER_DEFAULT_CERTIFICATE SYSREQ_USERS_USER_DESCRIPTION SYSREQ_USERS_USER_GLOBAL_NAME SYSREQ_USERS_USER_LOGIN SYSREQ_USERS_USER_MAIN_SERVER SYSREQ_USERS_USER_TYPE SYSREQ_WORK_RULES_FOLDER_ID RESULT_VAR_NAME RESULT_VAR_NAME_ENG AUTO_NUMERATION_RULE_ID CANT_CHANGE_ID_REQUISITE_RULE_ID CANT_CHANGE_OURFIRM_REQUISITE_RULE_ID CHECK_CHANGING_REFERENCE_RECORD_USE_RULE_ID CHECK_CODE_REQUISITE_RULE_ID CHECK_DELETING_REFERENCE_RECORD_USE_RULE_ID CHECK_FILTRATER_CHANGES_RULE_ID CHECK_RECORD_INTERVAL_RULE_ID CHECK_REFERENCE_INTERVAL_RULE_ID CHECK_REQUIRED_DATA_FULLNESS_RULE_ID CHECK_REQUIRED_REQUISITES_FULLNESS_RULE_ID MAKE_RECORD_UNRATIFIED_RULE_ID RESTORE_AUTO_NUMERATION_RULE_ID SET_FIRM_CONTEXT_FROM_RECORD_RULE_ID SET_FIRST_RECORD_IN_LIST_FORM_RULE_ID SET_IDSPS_VALUE_RULE_ID SET_NEXT_CODE_VALUE_RULE_ID SET_OURFIRM_BOUNDS_RULE_ID SET_OURFIRM_REQUISITE_RULE_ID SCRIPT_BLOCK_AFTER_FINISH_EVENT SCRIPT_BLOCK_BEFORE_START_EVENT SCRIPT_BLOCK_EXECUTION_RESULTS_PROPERTY SCRIPT_BLOCK_NAME_PROPERTY SCRIPT_BLOCK_SCRIPT_PROPERTY SUBTASK_BLOCK_ABORT_DEADLINE_PROPERTY SUBTASK_BLOCK_AFTER_FINISH_EVENT SUBTASK_BLOCK_ASSIGN_PARAMS_EVENT SUBTASK_BLOCK_ATTACHMENTS_PROPERTY SUBTASK_BLOCK_ATTACHMENTS_RIGHTS_GROUP_PROPERTY SUBTASK_BLOCK_ATTACHMENTS_RIGHTS_TYPE_PROPERTY SUBTASK_BLOCK_BEFORE_START_EVENT SUBTASK_BLOCK_CREATED_TASK_PROPERTY SUBTASK_BLOCK_CREATION_EVENT SUBTASK_BLOCK_DEADLINE_PROPERTY SUBTASK_BLOCK_IMPORTANCE_PROPERTY SUBTASK_BLOCK_INITIATOR_PROPERTY SUBTASK_BLOCK_IS_RELATIVE_ABORT_DEADLINE_PROPERTY SUBTASK_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY SUBTASK_BLOCK_JOBS_TYPE_PROPERTY SUBTASK_BLOCK_NAME_PROPERTY SUBTASK_BLOCK_PARALLEL_ROUTE_PROPERTY SUBTASK_BLOCK_PERFORMERS_PROPERTY SUBTASK_BLOCK_RELATIVE_ABORT_DEADLINE_TYPE_PROPERTY SUBTASK_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY SUBTASK_BLOCK_REQUIRE_SIGN_PROPERTY SUBTASK_BLOCK_STANDARD_ROUTE_PROPERTY SUBTASK_BLOCK_START_EVENT SUBTASK_BLOCK_STEP_CONTROL_PROPERTY SUBTASK_BLOCK_SUBJECT_PROPERTY SUBTASK_BLOCK_TASK_CONTROL_PROPERTY SUBTASK_BLOCK_TEXT_PROPERTY SUBTASK_BLOCK_UNLOCK_ATTACHMENTS_ON_STOP_PROPERTY SUBTASK_BLOCK_USE_STANDARD_ROUTE_PROPERTY SUBTASK_BLOCK_WAIT_FOR_TASK_COMPLETE_PROPERTY SYSCOMP_CONTROL_JOBS SYSCOMP_FOLDERS SYSCOMP_JOBS SYSCOMP_NOTICES SYSCOMP_TASKS SYSDLG_CREATE_EDOCUMENT SYSDLG_CREATE_EDOCUMENT_VERSION SYSDLG_CURRENT_PERIOD SYSDLG_EDIT_FUNCTION_HELP SYSDLG_EDOCUMENT_KINDS_FOR_TEMPLATE SYSDLG_EXPORT_MULTIPLE_EDOCUMENTS SYSDLG_EXPORT_SINGLE_EDOCUMENT SYSDLG_IMPORT_EDOCUMENT SYSDLG_MULTIPLE_SELECT SYSDLG_SETUP_ACCESS_RIGHTS SYSDLG_SETUP_DEFAULT_RIGHTS SYSDLG_SETUP_FILTER_CONDITION SYSDLG_SETUP_SIGN_RIGHTS SYSDLG_SETUP_TASK_OBSERVERS SYSDLG_SETUP_TASK_ROUTE SYSDLG_SETUP_USERS_LIST SYSDLG_SIGN_EDOCUMENT SYSDLG_SIGN_MULTIPLE_EDOCUMENTS SYSREF_ACCESS_RIGHTS_TYPES SYSREF_ADMINISTRATION_HISTORY SYSREF_ALL_AVAILABLE_COMPONENTS SYSREF_ALL_AVAILABLE_PRIVILEGES SYSREF_ALL_REPLICATING_COMPONENTS SYSREF_AVAILABLE_DEVELOPERS_COMPONENTS SYSREF_CALENDAR_EVENTS SYSREF_COMPONENT_TOKEN_HISTORY SYSREF_COMPONENT_TOKENS SYSREF_COMPONENTS SYSREF_CONSTANTS SYSREF_DATA_RECEIVE_PROTOCOL SYSREF_DATA_SEND_PROTOCOL SYSREF_DIALOGS SYSREF_DIALOGS_REQUISITES SYSREF_EDITORS SYSREF_EDOC_CARDS SYSREF_EDOC_TYPES SYSREF_EDOCUMENT_CARD_REQUISITES SYSREF_EDOCUMENT_CARD_TYPES SYSREF_EDOCUMENT_CARD_TYPES_REFERENCE SYSREF_EDOCUMENT_CARDS SYSREF_EDOCUMENT_HISTORY SYSREF_EDOCUMENT_KINDS SYSREF_EDOCUMENT_REQUISITES SYSREF_EDOCUMENT_SIGNATURES SYSREF_EDOCUMENT_TEMPLATES SYSREF_EDOCUMENT_TEXT_STORAGES SYSREF_EDOCUMENT_VIEWS SYSREF_FILTERER_SETUP_CONFLICTS SYSREF_FILTRATER_SETTING_CONFLICTS SYSREF_FOLDER_HISTORY SYSREF_FOLDERS SYSREF_FUNCTION_GROUPS SYSREF_FUNCTION_PARAMS SYSREF_FUNCTIONS SYSREF_JOB_HISTORY SYSREF_LINKS SYSREF_LOCALIZATION_DICTIONARY SYSREF_LOCALIZATION_LANGUAGES SYSREF_MODULES SYSREF_PRIVILEGES SYSREF_RECORD_HISTORY SYSREF_REFERENCE_REQUISITES SYSREF_REFERENCE_TYPE_VIEWS SYSREF_REFERENCE_TYPES SYSREF_REFERENCES SYSREF_REFERENCES_REQUISITES SYSREF_REMOTE_SERVERS SYSREF_REPLICATION_SESSIONS_LOG SYSREF_REPLICATION_SESSIONS_PROTOCOL SYSREF_REPORTS SYSREF_ROLES SYSREF_ROUTE_BLOCK_GROUPS SYSREF_ROUTE_BLOCKS SYSREF_SCRIPTS SYSREF_SEARCHES SYSREF_SERVER_EVENTS SYSREF_SERVER_EVENTS_HISTORY SYSREF_STANDARD_ROUTE_GROUPS SYSREF_STANDARD_ROUTES SYSREF_STATUSES SYSREF_SYSTEM_SETTINGS SYSREF_TASK_HISTORY SYSREF_TASK_KIND_GROUPS SYSREF_TASK_KINDS SYSREF_TASK_RIGHTS SYSREF_TASK_SIGNATURES SYSREF_TASKS SYSREF_UNITS SYSREF_USER_GROUPS SYSREF_USER_GROUPS_REFERENCE SYSREF_USER_SUBSTITUTION SYSREF_USERS SYSREF_USERS_REFERENCE SYSREF_VIEWERS SYSREF_WORKING_TIME_CALENDARS ACCESS_RIGHTS_TABLE_NAME EDMS_ACCESS_TABLE_NAME EDOC_TYPES_TABLE_NAME TEST_DEV_DB_NAME TEST_DEV_SYSTEM_CODE TEST_EDMS_DB_NAME TEST_EDMS_MAIN_CODE TEST_EDMS_MAIN_DB_NAME TEST_EDMS_SECOND_CODE TEST_EDMS_SECOND_DB_NAME TEST_EDMS_SYSTEM_CODE TEST_ISB5_MAIN_CODE TEST_ISB5_SECOND_CODE TEST_SQL_SERVER_2005_NAME TEST_SQL_SERVER_NAME ATTENTION_CAPTION cbsCommandLinks cbsDefault CONFIRMATION_CAPTION ERROR_CAPTION INFORMATION_CAPTION mrCancel mrOk EDOC_VERSION_ACTIVE_STAGE_CODE EDOC_VERSION_DESIGN_STAGE_CODE EDOC_VERSION_OBSOLETE_STAGE_CODE cpDataEnciphermentEnabled cpDigitalSignatureEnabled cpID cpIssuer cpPluginVersion cpSerial cpSubjectName cpSubjSimpleName cpValidFromDate cpValidToDate ISBL_SYNTAX NO_SYNTAX XML_SYNTAX WAIT_BLOCK_AFTER_FINISH_EVENT WAIT_BLOCK_BEFORE_START_EVENT WAIT_BLOCK_DEADLINE_PROPERTY WAIT_BLOCK_IS_RELATIVE_DEADLINE_PROPERTY WAIT_BLOCK_NAME_PROPERTY WAIT_BLOCK_RELATIVE_DEADLINE_TYPE_PROPERTY SYSRES_COMMON SYSRES_CONST SYSRES_MBFUNC SYSRES_SBDATA SYSRES_SBGUI SYSRES_SBINTF SYSRES_SBREFDSC SYSRES_SQLERRORS SYSRES_SYSCOMP atUser atGroup atRole aemEnabledAlways aemDisabledAlways aemEnabledOnBrowse aemEnabledOnEdit aemDisabledOnBrowseEmpty apBegin apEnd alLeft alRight asmNever asmNoButCustomize asmAsLastTime asmYesButCustomize asmAlways cirCommon cirRevoked ctSignature ctEncode ctSignatureEncode clbUnchecked clbChecked clbGrayed ceISB ceAlways ceNever ctDocument ctReference ctScript ctUnknown ctReport ctDialog ctFunction ctFolder ctEDocument ctTask ctJob ctNotice ctControlJob cfInternal cfDisplay ciUnspecified ciWrite ciRead ckFolder ckEDocument ckTask ckJob ckComponentToken ckAny ckReference ckScript ckReport ckDialog ctISBLEditor ctBevel ctButton ctCheckListBox ctComboBox ctComboEdit ctGrid ctDBCheckBox ctDBComboBox ctDBEdit ctDBEllipsis ctDBMemo ctDBNavigator ctDBRadioGroup ctDBStatusLabel ctEdit ctGroupBox ctInplaceHint ctMemo ctPanel ctListBox ctRadioButton ctRichEdit ctTabSheet ctWebBrowser ctImage ctHyperLink ctLabel ctDBMultiEllipsis ctRibbon ctRichView ctInnerPanel ctPanelGroup ctBitButton cctDate cctInteger cctNumeric cctPick cctReference cctString cctText cltInternal cltPrimary cltGUI dseBeforeOpen dseAfterOpen dseBeforeClose dseAfterClose dseOnValidDelete dseBeforeDelete dseAfterDelete dseAfterDeleteOutOfTransaction dseOnDeleteError dseBeforeInsert dseAfterInsert dseOnValidUpdate dseBeforeUpdate dseOnUpdateRatifiedRecord dseAfterUpdate dseAfterUpdateOutOfTransaction dseOnUpdateError dseAfterScroll dseOnOpenRecord dseOnCloseRecord dseBeforeCancel dseAfterCancel dseOnUpdateDeadlockError dseBeforeDetailUpdate dseOnPrepareUpdate dseOnAnyRequisiteChange dssEdit dssInsert dssBrowse dssInActive dftDate dftShortDate dftDateTime dftTimeStamp dotDays dotHours dotMinutes dotSeconds dtkndLocal dtkndUTC arNone arView arEdit arFull ddaView ddaEdit emLock emEdit emSign emExportWithLock emImportWithUnlock emChangeVersionNote emOpenForModify emChangeLifeStage emDelete emCreateVersion emImport emUnlockExportedWithLock emStart emAbort emReInit emMarkAsReaded emMarkAsUnreaded emPerform emAccept emResume emChangeRights emEditRoute emEditObserver emRecoveryFromLocalCopy emChangeWorkAccessType emChangeEncodeTypeToCertificate emChangeEncodeTypeToPassword emChangeEncodeTypeToNone emChangeEncodeTypeToCertificatePassword emChangeStandardRoute emGetText emOpenForView emMoveToStorage emCreateObject emChangeVersionHidden emDeleteVersion emChangeLifeCycleStage emApprovingSign emExport emContinue emLockFromEdit emUnLockForEdit emLockForServer emUnlockFromServer emDelegateAccessRights emReEncode ecotFile ecotProcess eaGet eaCopy eaCreate eaCreateStandardRoute edltAll edltNothing edltQuery essmText essmCard esvtLast esvtLastActive esvtSpecified edsfExecutive edsfArchive edstSQLServer edstFile edvstNone edvstEDocumentVersionCopy edvstFile edvstTemplate edvstScannedFile vsDefault vsDesign vsActive vsObsolete etNone etCertificate etPassword etCertificatePassword ecException ecWarning ecInformation estAll estApprovingOnly evtLast evtLastActive evtQuery fdtString fdtNumeric fdtInteger fdtDate fdtText fdtUnknown fdtWideString fdtLargeInteger ftInbox ftOutbox ftFavorites ftCommonFolder ftUserFolder ftComponents ftQuickLaunch ftShortcuts ftSearch grhAuto grhX1 grhX2 grhX3 hltText hltRTF hltHTML iffBMP iffJPEG iffMultiPageTIFF iffSinglePageTIFF iffTIFF iffPNG im8bGrayscale im24bRGB im1bMonochrome itBMP itJPEG itWMF itPNG ikhInformation ikhWarning ikhError ikhNoIcon icUnknown icScript icFunction icIntegratedReport icAnalyticReport icDataSetEventHandler icActionHandler icFormEventHandler icLookUpEventHandler icRequisiteChangeEventHandler icBeforeSearchEventHandler icRoleCalculation icSelectRouteEventHandler icBlockPropertyCalculation icBlockQueryParamsEventHandler icChangeSearchResultEventHandler icBlockEventHandler icSubTaskInitEventHandler icEDocDataSetEventHandler icEDocLookUpEventHandler icEDocActionHandler icEDocFormEventHandler icEDocRequisiteChangeEventHandler icStructuredConversionRule icStructuredConversionEventBefore icStructuredConversionEventAfter icWizardEventHandler icWizardFinishEventHandler icWizardStepEventHandler icWizardStepFinishEventHandler icWizardActionEnableEventHandler icWizardActionExecuteEventHandler icCreateJobsHandler icCreateNoticesHandler icBeforeLookUpEventHandler icAfterLookUpEventHandler icTaskAbortEventHandler icWorkflowBlockActionHandler icDialogDataSetEventHandler icDialogActionHandler icDialogLookUpEventHandler icDialogRequisiteChangeEventHandler icDialogFormEventHandler icDialogValidCloseEventHandler icBlockFormEventHandler icTaskFormEventHandler icReferenceMethod icEDocMethod icDialogMethod icProcessMessageHandler isShow isHide isByUserSettings jkJob jkNotice jkControlJob jtInner jtLeft jtRight jtFull jtCross lbpAbove lbpBelow lbpLeft lbpRight eltPerConnection eltPerUser sfcUndefined sfcBlack sfcGreen sfcRed sfcBlue sfcOrange sfcLilac sfsItalic sfsStrikeout sfsNormal ldctStandardRoute ldctWizard ldctScript ldctFunction ldctRouteBlock ldctIntegratedReport ldctAnalyticReport ldctReferenceType ldctEDocumentType ldctDialog ldctServerEvents mrcrtNone mrcrtUser mrcrtMaximal mrcrtCustom vtEqual vtGreaterOrEqual vtLessOrEqual vtRange rdYesterday rdToday rdTomorrow rdThisWeek rdThisMonth rdThisYear rdNextMonth rdNextWeek rdLastWeek rdLastMonth rdWindow rdFile rdPrinter rdtString rdtNumeric rdtInteger rdtDate rdtReference rdtAccount rdtText rdtPick rdtUnknown rdtLargeInteger rdtDocument reOnChange reOnChangeValues ttGlobal ttLocal ttUser ttSystem ssmBrowse ssmSelect ssmMultiSelect ssmBrowseModal smSelect smLike smCard stNone stAuthenticating stApproving sctString sctStream sstAnsiSort sstNaturalSort svtEqual svtContain soatString soatNumeric soatInteger soatDatetime soatReferenceRecord soatText soatPick soatBoolean soatEDocument soatAccount soatIntegerCollection soatNumericCollection soatStringCollection soatPickCollection soatDatetimeCollection soatBooleanCollection soatReferenceRecordCollection soatEDocumentCollection soatAccountCollection soatContents soatUnknown tarAbortByUser tarAbortByWorkflowException tvtAllWords tvtExactPhrase tvtAnyWord usNone usCompleted usRedSquare usBlueSquare usYellowSquare usGreenSquare usOrangeSquare usPurpleSquare usFollowUp utUnknown utUser utDeveloper utAdministrator utSystemDeveloper utDisconnected btAnd btDetailAnd btOr btNotOr btOnly vmView vmSelect vmNavigation vsmSingle vsmMultiple vsmMultipleCheck vsmNoSelection wfatPrevious wfatNext wfatCancel wfatFinish wfepUndefined wfepText3 wfepText6 wfepText9 wfepSpinEdit wfepDropDown wfepRadioGroup wfepFlag wfepText12 wfepText15 wfepText18 wfepText21 wfepText24 wfepText27 wfepText30 wfepRadioGroupColumn1 wfepRadioGroupColumn2 wfepRadioGroupColumn3 wfetQueryParameter wfetText wfetDelimiter wfetLabel wptString wptInteger wptNumeric wptBoolean wptDateTime wptPick wptText wptUser wptUserList wptEDocumentInfo wptEDocumentInfoList wptReferenceRecordInfo wptReferenceRecordInfoList wptFolderInfo wptTaskInfo wptContents wptFileName wptDate wsrComplete wsrGoNext wsrGoPrevious wsrCustom wsrCancel wsrGoFinal wstForm wstEDocument wstTaskCard wstReferenceRecordCard wstFinal waAll waPerformers waManual wsbStart wsbFinish wsbNotice wsbStep wsbDecision wsbWait wsbMonitor wsbScript wsbConnector wsbSubTask wsbLifeCycleStage wsbPause wdtInteger wdtFloat wdtString wdtPick wdtDateTime wdtBoolean wdtTask wdtJob wdtFolder wdtEDocument wdtReferenceRecord wdtUser wdtGroup wdtRole wdtIntegerCollection wdtFloatCollection wdtStringCollection wdtPickCollection wdtDateTimeCollection wdtBooleanCollection wdtTaskCollection wdtJobCollection wdtFolderCollection wdtEDocumentCollection wdtReferenceRecordCollection wdtUserCollection wdtGroupCollection wdtRoleCollection wdtContents wdtUserList wdtSearchDescription wdtDeadLine wdtPickSet wdtAccountCollection wiLow wiNormal wiHigh wrtSoft wrtHard wsInit wsRunning wsDone wsControlled wsAborted wsContinued wtmFull wtmFromCurrent wtmOnlyCurrent ",
-class:"AltState Application CallType ComponentTokens CreatedJobs CreatedNotices ControlState DialogResult Dialogs EDocuments EDocumentVersionSource Folders GlobalIDs Job Jobs InputValue LookUpReference LookUpRequisiteNames LookUpSearch Object ParentComponent Processes References Requisite ReportName Reports Result Scripts Searches SelectedAttachments SelectedItems SelectMode Sender ServerEvents ServiceFactory ShiftState SubTask SystemDialogs Tasks Wizard Wizards Work \u0412\u044b\u0437\u043e\u0432\u0421\u043f\u043e\u0441\u043e\u0431 \u0418\u043c\u044f\u041e\u0442\u0447\u0435\u0442\u0430 \u0420\u0435\u043a\u0432\u0417\u043d\u0430\u0447 ",
-literal:"null true false nil "},I={begin:"\\.\\s*"+S.UNDERSCORE_IDENT_RE,
-keywords:C,relevance:0},N={className:"type",
-begin:":[ \\t]*(IApplication|IAccessRights|IAccountRepository|IAccountSelectionRestrictions|IAction|IActionList|IAdministrationHistoryDescription|IAnchors|IApplication|IArchiveInfo|IAttachment|IAttachmentList|ICheckListBox|ICheckPointedList|IColumn|IComponent|IComponentDescription|IComponentToken|IComponentTokenFactory|IComponentTokenInfo|ICompRecordInfo|IConnection|IContents|IControl|IControlJob|IControlJobInfo|IControlList|ICrypto|ICrypto2|ICustomJob|ICustomJobInfo|ICustomListBox|ICustomObjectWizardStep|ICustomWork|ICustomWorkInfo|IDataSet|IDataSetAccessInfo|IDataSigner|IDateCriterion|IDateRequisite|IDateRequisiteDescription|IDateValue|IDeaAccessRights|IDeaObjectInfo|IDevelopmentComponentLock|IDialog|IDialogFactory|IDialogPickRequisiteItems|IDialogsFactory|IDICSFactory|IDocRequisite|IDocumentInfo|IDualListDialog|IECertificate|IECertificateInfo|IECertificates|IEditControl|IEditorForm|IEdmsExplorer|IEdmsObject|IEdmsObjectDescription|IEdmsObjectFactory|IEdmsObjectInfo|IEDocument|IEDocumentAccessRights|IEDocumentDescription|IEDocumentEditor|IEDocumentFactory|IEDocumentInfo|IEDocumentStorage|IEDocumentVersion|IEDocumentVersionListDialog|IEDocumentVersionSource|IEDocumentWizardStep|IEDocVerSignature|IEDocVersionState|IEnabledMode|IEncodeProvider|IEncrypter|IEvent|IEventList|IException|IExternalEvents|IExternalHandler|IFactory|IField|IFileDialog|IFolder|IFolderDescription|IFolderDialog|IFolderFactory|IFolderInfo|IForEach|IForm|IFormTitle|IFormWizardStep|IGlobalIDFactory|IGlobalIDInfo|IGrid|IHasher|IHistoryDescription|IHyperLinkControl|IImageButton|IImageControl|IInnerPanel|IInplaceHint|IIntegerCriterion|IIntegerList|IIntegerRequisite|IIntegerValue|IISBLEditorForm|IJob|IJobDescription|IJobFactory|IJobForm|IJobInfo|ILabelControl|ILargeIntegerCriterion|ILargeIntegerRequisite|ILargeIntegerValue|ILicenseInfo|ILifeCycleStage|IList|IListBox|ILocalIDInfo|ILocalization|ILock|IMemoryDataSet|IMessagingFactory|IMetadataRepository|INotice|INoticeInfo|INumericCriterion|INumericRequisite|INumericValue|IObject|IObjectDescription|IObjectImporter|IObjectInfo|IObserver|IPanelGroup|IPickCriterion|IPickProperty|IPickRequisite|IPickRequisiteDescription|IPickRequisiteItem|IPickRequisiteItems|IPickValue|IPrivilege|IPrivilegeList|IProcess|IProcessFactory|IProcessMessage|IProgress|IProperty|IPropertyChangeEvent|IQuery|IReference|IReferenceCriterion|IReferenceEnabledMode|IReferenceFactory|IReferenceHistoryDescription|IReferenceInfo|IReferenceRecordCardWizardStep|IReferenceRequisiteDescription|IReferencesFactory|IReferenceValue|IRefRequisite|IReport|IReportFactory|IRequisite|IRequisiteDescription|IRequisiteDescriptionList|IRequisiteFactory|IRichEdit|IRouteStep|IRule|IRuleList|ISchemeBlock|IScript|IScriptFactory|ISearchCriteria|ISearchCriterion|ISearchDescription|ISearchFactory|ISearchFolderInfo|ISearchForObjectDescription|ISearchResultRestrictions|ISecuredContext|ISelectDialog|IServerEvent|IServerEventFactory|IServiceDialog|IServiceFactory|ISignature|ISignProvider|ISignProvider2|ISignProvider3|ISimpleCriterion|IStringCriterion|IStringList|IStringRequisite|IStringRequisiteDescription|IStringValue|ISystemDialogsFactory|ISystemInfo|ITabSheet|ITask|ITaskAbortReasonInfo|ITaskCardWizardStep|ITaskDescription|ITaskFactory|ITaskInfo|ITaskRoute|ITextCriterion|ITextRequisite|ITextValue|ITreeListSelectDialog|IUser|IUserList|IValue|IView|IWebBrowserControl|IWizard|IWizardAction|IWizardFactory|IWizardFormElement|IWizardParam|IWizardPickParam|IWizardReferenceParam|IWizardStep|IWorkAccessRights|IWorkDescription|IWorkflowAskableParam|IWorkflowAskableParams|IWorkflowBlock|IWorkflowBlockResult|IWorkflowEnabledMode|IWorkflowParam|IWorkflowPickParam|IWorkflowReferenceParam|IWorkState|IWorkTreeCustomNode|IWorkTreeJobNode|IWorkTreeTaskNode|IXMLEditorForm|SBCrypto)",
-end:"[ \\t]*=",excludeEnd:!0},A={className:"variable",keywords:C,begin:E,
-relevance:0,contains:[N,I]
-},e="[A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_][A-Za-z\u0410-\u042f\u0430-\u044f\u0451\u0401_0-9]*\\("
-;return{name:"ISBL",case_insensitive:!0,keywords:C,
-illegal:"\\$|\\?|%|,|;$|~|#|@|</",contains:[{className:"function",begin:e,
-end:"\\)$",returnBegin:!0,keywords:C,illegal:"[\\[\\]\\|\\$\\?%,~#@]",
-contains:[{className:"title",keywords:{$pattern:E,
-built_in:"AddSubString AdjustLineBreaks AmountInWords Analysis ArrayDimCount ArrayHighBound ArrayLowBound ArrayOf ArrayReDim Assert Assigned BeginOfMonth BeginOfPeriod BuildProfilingOperationAnalysis CallProcedure CanReadFile CArrayElement CDataSetRequisite ChangeDate ChangeReferenceDataset Char CharPos CheckParam CheckParamValue CompareStrings ConstantExists ControlState ConvertDateStr Copy CopyFile CreateArray CreateCachedReference CreateConnection CreateDialog CreateDualListDialog CreateEditor CreateException CreateFile CreateFolderDialog CreateInputDialog CreateLinkFile CreateList CreateLock CreateMemoryDataSet CreateObject CreateOpenDialog CreateProgress CreateQuery CreateReference CreateReport CreateSaveDialog CreateScript CreateSQLPivotFunction CreateStringList CreateTreeListSelectDialog CSelectSQL CSQL CSubString CurrentUserID CurrentUserName CurrentVersion DataSetLocateEx DateDiff DateTimeDiff DateToStr DayOfWeek DeleteFile DirectoryExists DisableCheckAccessRights DisableCheckFullShowingRestriction DisableMassTaskSendingRestrictions DropTable DupeString EditText EnableCheckAccessRights EnableCheckFullShowingRestriction EnableMassTaskSendingRestrictions EndOfMonth EndOfPeriod ExceptionExists ExceptionsOff ExceptionsOn Execute ExecuteProcess Exit ExpandEnvironmentVariables ExtractFileDrive ExtractFileExt ExtractFileName ExtractFilePath ExtractParams FileExists FileSize FindFile FindSubString FirmContext ForceDirectories Format FormatDate FormatNumeric FormatSQLDate FormatString FreeException GetComponent GetComponentLaunchParam GetConstant GetLastException GetReferenceRecord GetRefTypeByRefID GetTableID GetTempFolder IfThen In IndexOf InputDialog InputDialogEx InteractiveMode IsFileLocked IsGraphicFile IsNumeric Length LoadString LoadStringFmt LocalTimeToUTC LowerCase Max MessageBox MessageBoxEx MimeDecodeBinary MimeDecodeString MimeEncodeBinary MimeEncodeString Min MoneyInWords MoveFile NewID Now OpenFile Ord Precision Raise ReadCertificateFromFile ReadFile ReferenceCodeByID ReferenceNumber ReferenceRequisiteMode ReferenceRequisiteValue RegionDateSettings RegionNumberSettings RegionTimeSettings RegRead RegWrite RenameFile Replace Round SelectServerCode SelectSQL ServerDateTime SetConstant SetManagedFolderFieldsState ShowConstantsInputDialog ShowMessage Sleep Split SQL SQL2XLSTAB SQLProfilingSendReport StrToDate SubString SubStringCount SystemSetting Time TimeDiff Today Transliterate Trim UpperCase UserStatus UTCToLocalTime ValidateXML VarIsClear VarIsEmpty VarIsNull WorkTimeDiff WriteFile WriteFileEx WriteObjectHistory \u0410\u043d\u0430\u043b\u0438\u0437 \u0411\u0430\u0437\u0430\u0414\u0430\u043d\u043d\u044b\u0445 \u0411\u043b\u043e\u043a\u0415\u0441\u0442\u044c \u0411\u043b\u043e\u043a\u0415\u0441\u0442\u044c\u0420\u0430\u0441\u0448 \u0411\u043b\u043e\u043a\u0418\u043d\u0444\u043e \u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c \u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c\u0420\u0430\u0441\u0448 \u0411\u043b\u043e\u043a\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0412\u0432\u043e\u0434 \u0412\u0432\u043e\u0434\u041c\u0435\u043d\u044e \u0412\u0435\u0434\u0421 \u0412\u0435\u0434\u0421\u043f\u0440 \u0412\u0435\u0440\u0445\u043d\u044f\u044f\u0413\u0440\u0430\u043d\u0438\u0446\u0430\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u0412\u043d\u0435\u0448\u041f\u0440\u043e\u0433\u0440 \u0412\u043e\u0441\u0441\u0442 \u0412\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f\u041f\u0430\u043f\u043a\u0430 \u0412\u0440\u0435\u043c\u044f \u0412\u044b\u0431\u043e\u0440SQL \u0412\u044b\u0431\u0440\u0430\u0442\u044c\u0417\u0430\u043f\u0438\u0441\u044c \u0412\u044b\u0434\u0435\u043b\u0438\u0442\u044c\u0421\u0442\u0440 \u0412\u044b\u0437\u0432\u0430\u0442\u044c \u0412\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0412\u044b\u043f\u041f\u0440\u043e\u0433\u0440 \u0413\u0440\u0430\u0444\u0438\u0447\u0435\u0441\u043a\u0438\u0439\u0424\u0430\u0439\u043b \u0413\u0440\u0443\u043f\u043f\u0430\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0414\u0430\u0442\u0430\u0412\u0440\u0435\u043c\u044f\u0421\u0435\u0440\u0432 \u0414\u0435\u043d\u044c\u041d\u0435\u0434\u0435\u043b\u0438 \u0414\u0438\u0430\u043b\u043e\u0433\u0414\u0430\u041d\u0435\u0442 \u0414\u043b\u0438\u043d\u0430\u0421\u0442\u0440 \u0414\u043e\u0431\u041f\u043e\u0434\u0441\u0442\u0440 \u0415\u041f\u0443\u0441\u0442\u043e \u0415\u0441\u043b\u0438\u0422\u043e \u0415\u0427\u0438\u0441\u043b\u043e \u0417\u0430\u043c\u041f\u043e\u0434\u0441\u0442\u0440 \u0417\u0430\u043f\u0438\u0441\u044c\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0430 \u0417\u043d\u0430\u0447\u041f\u043e\u043b\u044f\u0421\u043f\u0440 \u0418\u0414\u0422\u0438\u043f\u0421\u043f\u0440 \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u0414\u0438\u0441\u043a \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u0418\u043c\u044f\u0424\u0430\u0439\u043b\u0430 \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u041f\u0443\u0442\u044c \u0418\u0437\u0432\u043b\u0435\u0447\u044c\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0418\u0437\u043c\u0414\u0430\u0442 \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u044c\u0420\u0430\u0437\u043c\u0435\u0440\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u0418\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u0439\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u0418\u043c\u044f\u041e\u0440\u0433 \u0418\u043c\u044f\u041f\u043e\u043b\u044f\u0421\u043f\u0440 \u0418\u043d\u0434\u0435\u043a\u0441 \u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440\u0428\u0430\u0433 \u0418\u043d\u0442\u0435\u0440\u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0439\u0420\u0435\u0436\u0438\u043c \u0418\u0442\u043e\u0433\u0422\u0431\u043b\u0421\u043f\u0440 \u041a\u043e\u0434\u0412\u0438\u0434\u0412\u0435\u0434\u0421\u043f\u0440 \u041a\u043e\u0434\u0412\u0438\u0434\u0421\u043f\u0440\u041f\u043e\u0418\u0414 \u041a\u043e\u0434\u041f\u043eAnalit \u041a\u043e\u0434\u0421\u0438\u043c\u0432\u043e\u043b\u0430 \u041a\u043e\u0434\u0421\u043f\u0440 \u041a\u043e\u043b\u041f\u043e\u0434\u0441\u0442\u0440 \u041a\u043e\u043b\u041f\u0440\u043e\u043f \u041a\u043e\u043d\u041c\u0435\u0441 \u041a\u043e\u043d\u0441\u0442 \u041a\u043e\u043d\u0441\u0442\u0415\u0441\u0442\u044c \u041a\u043e\u043d\u0441\u0442\u0417\u043d\u0430\u0447 \u041a\u043e\u043d\u0422\u0440\u0430\u043d \u041a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0424\u0430\u0439\u043b \u041a\u043e\u043f\u0438\u044f\u0421\u0442\u0440 \u041a\u041f\u0435\u0440\u0438\u043e\u0434 \u041a\u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u041c\u0430\u043a\u0441 \u041c\u0430\u043a\u0441\u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u041c\u0430\u0441\u0441\u0438\u0432 \u041c\u0435\u043d\u044e \u041c\u0435\u043d\u044e\u0420\u0430\u0441\u0448 \u041c\u0438\u043d \u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445\u041d\u0430\u0439\u0442\u0438\u0420\u0430\u0441\u0448 \u041d\u0430\u0438\u043c\u0412\u0438\u0434\u0421\u043f\u0440 \u041d\u0430\u0438\u043c\u041f\u043eAnalit \u041d\u0430\u0438\u043c\u0421\u043f\u0440 \u041d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c\u041f\u0435\u0440\u0435\u0432\u043e\u0434\u044b\u0421\u0442\u0440\u043e\u043a \u041d\u0430\u0447\u041c\u0435\u0441 \u041d\u0430\u0447\u0422\u0440\u0430\u043d \u041d\u0438\u0436\u043d\u044f\u044f\u0413\u0440\u0430\u043d\u0438\u0446\u0430\u041c\u0430\u0441\u0441\u0438\u0432\u0430 \u041d\u043e\u043c\u0435\u0440\u0421\u043f\u0440 \u041d\u041f\u0435\u0440\u0438\u043e\u0434 \u041e\u043a\u043d\u043e \u041e\u043a\u0440 \u041e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u0435 \u041e\u0442\u043b\u0418\u043d\u0444\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u041e\u0442\u043b\u0418\u043d\u0444\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u041e\u0442\u0447\u0435\u0442 \u041e\u0442\u0447\u0435\u0442\u0410\u043d\u0430\u043b \u041e\u0442\u0447\u0435\u0442\u0418\u043d\u0442 \u041f\u0430\u043f\u043a\u0430\u0421\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442 \u041f\u0430\u0443\u0437\u0430 \u041f\u0412\u044b\u0431\u043e\u0440SQL \u041f\u0435\u0440\u0435\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u044c\u0424\u0430\u0439\u043b \u041f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0435 \u041f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c\u0424\u0430\u0439\u043b \u041f\u043e\u0434\u0441\u0442\u0440 \u041f\u043e\u0438\u0441\u043a\u041f\u043e\u0434\u0441\u0442\u0440 \u041f\u043e\u0438\u0441\u043a\u0421\u0442\u0440 \u041f\u043e\u043b\u0443\u0447\u0438\u0442\u044c\u0418\u0414\u0422\u0430\u0431\u043b\u0438\u0446\u044b \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0418\u0414 \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0418\u043c\u044f \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0421\u0442\u0430\u0442\u0443\u0441 \u041f\u0440\u0435\u0440\u0432\u0430\u0442\u044c \u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440 \u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0417\u043d\u0430\u0447 \u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c\u0423\u0441\u043b\u043e\u0432\u0438\u0435 \u0420\u0430\u0437\u0431\u0421\u0442\u0440 \u0420\u0430\u0437\u043d\u0412\u0440\u0435\u043c\u044f \u0420\u0430\u0437\u043d\u0414\u0430\u0442 \u0420\u0430\u0437\u043d\u0414\u0430\u0442\u0430\u0412\u0440\u0435\u043c\u044f \u0420\u0430\u0437\u043d\u0420\u0430\u0431\u0412\u0440\u0435\u043c\u044f \u0420\u0435\u0433\u0423\u0441\u0442\u0412\u0440\u0435\u043c \u0420\u0435\u0433\u0423\u0441\u0442\u0414\u0430\u0442 \u0420\u0435\u0433\u0423\u0441\u0442\u0427\u0441\u043b \u0420\u0435\u0434\u0422\u0435\u043a\u0441\u0442 \u0420\u0435\u0435\u0441\u0442\u0440\u0417\u0430\u043f\u0438\u0441\u044c \u0420\u0435\u0435\u0441\u0442\u0440\u0421\u043f\u0438\u0441\u043e\u043a\u0418\u043c\u0435\u043d\u041f\u0430\u0440\u0430\u043c \u0420\u0435\u0435\u0441\u0442\u0440\u0427\u0442\u0435\u043d\u0438\u0435 \u0420\u0435\u043a\u0432\u0421\u043f\u0440 \u0420\u0435\u043a\u0432\u0421\u043f\u0440\u041f\u0440 \u0421\u0435\u0433\u043e\u0434\u043d\u044f \u0421\u0435\u0439\u0447\u0430\u0441 \u0421\u0435\u0440\u0432\u0435\u0440 \u0421\u0435\u0440\u0432\u0435\u0440\u041f\u0440\u043e\u0446\u0435\u0441\u0441\u0418\u0414 \u0421\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0424\u0430\u0439\u043b\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0421\u0436\u041f\u0440\u043e\u0431 \u0421\u0438\u043c\u0432\u043e\u043b \u0421\u0438\u0441\u0442\u0435\u043c\u0430\u0414\u0438\u0440\u0435\u043a\u0442\u0443\u043c\u041a\u043e\u0434 \u0421\u0438\u0441\u0442\u0435\u043c\u0430\u0418\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0421\u0438\u0441\u0442\u0435\u043c\u0430\u041a\u043e\u0434 \u0421\u043e\u0434\u0435\u0440\u0436\u0438\u0442 \u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u0417\u0430\u043a\u0440\u044b\u0442\u044c \u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u0412\u044b\u0431\u043e\u0440\u0430\u0418\u0437\u0414\u0432\u0443\u0445\u0421\u043f\u0438\u0441\u043a\u043e\u0432 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u0412\u044b\u0431\u043e\u0440\u0430\u041f\u0430\u043f\u043a\u0438 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u041e\u0442\u043a\u0440\u044b\u0442\u0438\u044f\u0424\u0430\u0439\u043b\u0430 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0414\u0438\u0430\u043b\u043e\u0433\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f\u0424\u0430\u0439\u043b\u0430 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0417\u0430\u043f\u0440\u043e\u0441 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0418\u043d\u0434\u0438\u043a\u0430\u0442\u043e\u0440 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0418\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041a\u044d\u0448\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041c\u0430\u0441\u0441\u0438\u0432 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041e\u0431\u044a\u0435\u043a\u0442 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041e\u0442\u0447\u0435\u0442 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u041f\u0430\u043f\u043a\u0443 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0420\u0435\u0434\u0430\u043a\u0442\u043e\u0440 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043f\u0438\u0441\u043e\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043f\u0438\u0441\u043e\u043a\u0421\u0442\u0440\u043e\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a \u0421\u043e\u0437\u0434\u0430\u0442\u044c\u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0439 \u0421\u043e\u0437\u0434\u0421\u043f\u0440 \u0421\u043e\u0441\u0442\u0421\u043f\u0440 \u0421\u043e\u0445\u0440 \u0421\u043e\u0445\u0440\u0421\u043f\u0440 \u0421\u043f\u0438\u0441\u043e\u043a\u0421\u0438\u0441\u0442\u0435\u043c \u0421\u043f\u0440 \u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0415\u0441\u0442\u044c \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0421\u043d\u044f\u0442\u044c\u0420\u0430\u0441\u0448 \u0421\u043f\u0440\u0411\u043b\u043e\u043a\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0421\u043f\u0440\u0418\u0437\u043c\u041d\u0430\u0431\u0414\u0430\u043d \u0421\u043f\u0440\u041a\u043e\u0434 \u0421\u043f\u0440\u041d\u043e\u043c\u0435\u0440 \u0421\u043f\u0440\u041e\u0431\u043d\u043e\u0432\u0438\u0442\u044c \u0421\u043f\u0440\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0421\u043f\u0440\u041e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0421\u043f\u0440\u041f\u0430\u0440\u0430\u043c \u0421\u043f\u0440\u041f\u043e\u043b\u0435\u0417\u043d\u0430\u0447 \u0421\u043f\u0440\u041f\u043e\u043b\u0435\u0418\u043c\u044f \u0421\u043f\u0440\u0420\u0435\u043a\u0432 \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u0412\u0432\u0435\u0434\u0417\u043d \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u041d\u043e\u0432\u044b\u0435 \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u041f\u0440 \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u041f\u0440\u0435\u0434\u0417\u043d \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u0420\u0435\u0436\u0438\u043c \u0421\u043f\u0440\u0420\u0435\u043a\u0432\u0422\u0438\u043f\u0422\u0435\u043a\u0441\u0442 \u0421\u043f\u0440\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0421\u043f\u0440\u0421\u043e\u0441\u0442 \u0421\u043f\u0440\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c \u0421\u043f\u0440\u0422\u0431\u043b\u0418\u0442\u043e\u0433 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041a\u043e\u043b \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041c\u0430\u043a\u0441 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041c\u0438\u043d \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u041f\u0440\u0435\u0434 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u0421\u043b\u0435\u0434 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u0421\u043e\u0437\u0434 \u0421\u043f\u0440\u0422\u0431\u043b\u0421\u0442\u0440\u0423\u0434 \u0421\u043f\u0440\u0422\u0435\u043a\u041f\u0440\u0435\u0434\u0441\u0442 \u0421\u043f\u0440\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0421\u0440\u0430\u0432\u043d\u0438\u0442\u044c\u0421\u0442\u0440 \u0421\u0442\u0440\u0412\u0435\u0440\u0445\u0420\u0435\u0433\u0438\u0441\u0442\u0440 \u0421\u0442\u0440\u041d\u0438\u0436\u043d\u0420\u0435\u0433\u0438\u0441\u0442\u0440 \u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u0421\u0443\u043c\u041f\u0440\u043e\u043f \u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0439 \u0421\u0446\u0435\u043d\u0430\u0440\u0438\u0439\u041f\u0430\u0440\u0430\u043c \u0422\u0435\u043a\u0412\u0435\u0440\u0441\u0438\u044f \u0422\u0435\u043a\u041e\u0440\u0433 \u0422\u043e\u0447\u043d \u0422\u0440\u0430\u043d \u0422\u0440\u0430\u043d\u0441\u043b\u0438\u0442\u0435\u0440\u0430\u0446\u0438\u044f \u0423\u0434\u0430\u043b\u0438\u0442\u044c\u0422\u0430\u0431\u043b\u0438\u0446\u0443 \u0423\u0434\u0430\u043b\u0438\u0442\u044c\u0424\u0430\u0439\u043b \u0423\u0434\u0421\u043f\u0440 \u0423\u0434\u0421\u0442\u0440\u0422\u0431\u043b\u0421\u043f\u0440 \u0423\u0441\u0442 \u0423\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438\u041a\u043e\u043d\u0441\u0442\u0430\u043d\u0442 \u0424\u0430\u0439\u043b\u0410\u0442\u0440\u0438\u0431\u0443\u0442\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0410\u0442\u0440\u0438\u0431\u0443\u0442\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0424\u0430\u0439\u043b\u0412\u0440\u0435\u043c\u044f \u0424\u0430\u0439\u043b\u0412\u0440\u0435\u043c\u044f\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u0424\u0430\u0439\u043b\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0417\u0430\u043d\u044f\u0442 \u0424\u0430\u0439\u043b\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0418\u0441\u043a\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041c\u043e\u0436\u043d\u043e\u0427\u0438\u0442\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041e\u0442\u043a\u0440\u044b\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0435\u0440\u0435\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0435\u0440\u0435\u043a\u043e\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u0442\u044c \u0424\u0430\u0439\u043b\u041f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0424\u0430\u0439\u043b\u0420\u0430\u0437\u043c\u0435\u0440 \u0424\u0430\u0439\u043b\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0421\u0441\u044b\u043b\u043a\u0430\u0421\u043e\u0437\u0434\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0421\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442 \u0424\u0430\u0439\u043b\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0424\u0430\u0439\u043b\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u0424\u043c\u0442SQL\u0414\u0430\u0442 \u0424\u043c\u0442\u0414\u0430\u0442 \u0424\u043c\u0442\u0421\u0442\u0440 \u0424\u043c\u0442\u0427\u0441\u043b \u0424\u043e\u0440\u043c\u0430\u0442 \u0426\u041c\u0430\u0441\u0441\u0438\u0432\u042d\u043b\u0435\u043c\u0435\u043d\u0442 \u0426\u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442 \u0426\u041f\u043e\u0434\u0441\u0442\u0440 "
-},begin:e,end:"\\(",returnBegin:!0,excludeEnd:!0},I,A,T,_,O]},N,I,A,T,_,O]}}
-})());
-hljs.registerLanguage("java",(()=>{"use strict"
-;var e="\\.([0-9](_*[0-9])*)",n="[0-9a-fA-F](_*[0-9a-fA-F])*",a={
-className:"number",variants:[{
-begin:`(\\b([0-9](_*[0-9])*)((${e})|\\.)?|(${e}))[eE][+-]?([0-9](_*[0-9])*)[fFdD]?\\b`
-},{begin:`\\b([0-9](_*[0-9])*)((${e})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{
-begin:`(${e})[fFdD]?\\b`},{begin:"\\b([0-9](_*[0-9])*)[fFdD]\\b"},{
-begin:`\\b0[xX]((${n})\\.?|(${n})?\\.(${n}))[pP][+-]?([0-9](_*[0-9])*)[fFdD]?\\b`
-},{begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${n})[lL]?\\b`},{
-begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}],
-relevance:0};return e=>{
-var n="false synchronized int abstract float private char boolean var static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do",s={
-className:"meta",begin:"@[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",
-contains:[{begin:/\(/,end:/\)/,contains:["self"]}]};const r=a;return{
-name:"Java",aliases:["jsp"],keywords:n,illegal:/<\/|#/,
-contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/,
-relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{
-begin:/import java\.[a-z]+\./,keywords:"import",relevance:2
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{
-className:"class",beginKeywords:"class interface enum",end:/[{;=]/,
-excludeEnd:!0,relevance:1,keywords:"class interface enum",illegal:/[:"\[\]]/,
-contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{
-beginKeywords:"new throw return else",relevance:0},{className:"class",
-begin:"record\\s+"+e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,excludeEnd:!0,
-end:/[{;=]/,keywords:n,contains:[{beginKeywords:"record"},{
-begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,
-contains:[e.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,
-keywords:n,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"function",
-begin:"([\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*(<[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*(\\s*,\\s*[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*)*>)?\\s+)+"+e.UNDERSCORE_IDENT_RE+"\\s*\\(",
-returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:n,contains:[{
-begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,
-contains:[e.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/,
-keywords:n,relevance:0,
-contains:[s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,e.C_BLOCK_COMMENT_MODE]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},r,s]}}})());
-hljs.registerLanguage("javascript",(()=>{"use strict"
-;const e="[A-Za-z$_][0-9A-Za-z$_]*",n=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],a=["true","false","null","undefined","NaN","Infinity"],s=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"])
-;function r(e){return t("(?=",e,")")}function t(...e){return e.map((e=>{
-return(n=e)?"string"==typeof n?n:n.source:null;var n})).join("")}return i=>{
-const c=e,o={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,
-isTrulyOpeningTag:(e,n)=>{const a=e[0].length+e.index,s=e.input[a]
-;"<"!==s?">"===s&&(((e,{after:n})=>{const a="</"+e[0].slice(1)
-;return-1!==e.input.indexOf(a,n)})(e,{after:a
-})||n.ignoreMatch()):n.ignoreMatch()}},l={$pattern:e,keyword:n,literal:a,
-built_in:s},g="\\.([0-9](_?[0-9])*)",b="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",d={
-className:"number",variants:[{
-begin:`(\\b(${b})((${g})|\\.)?|(${g}))[eE][+-]?([0-9](_?[0-9])*)\\b`},{
-begin:`\\b(${b})\\b((${g})\\b|\\.)?|(${g})\\b`},{
-begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{
-begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{
-begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{
-begin:"\\b0[0-7]+n?\\b"}],relevance:0},E={className:"subst",begin:"\\$\\{",
-end:"\\}",keywords:l,contains:[]},u={begin:"html`",end:"",starts:{end:"`",
-returnEnd:!1,contains:[i.BACKSLASH_ESCAPE,E],subLanguage:"xml"}},_={
-begin:"css`",end:"",starts:{end:"`",returnEnd:!1,
-contains:[i.BACKSLASH_ESCAPE,E],subLanguage:"css"}},m={className:"string",
-begin:"`",end:"`",contains:[i.BACKSLASH_ESCAPE,E]},y={className:"comment",
-variants:[i.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{
-className:"doctag",begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{",
-end:"\\}",relevance:0},{className:"variable",begin:c+"(?=\\s*(-)|$)",
-endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]
-}),i.C_BLOCK_COMMENT_MODE,i.C_LINE_COMMENT_MODE]
-},N=[i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,u,_,m,d,i.REGEXP_MODE]
-;E.contains=N.concat({begin:/\{/,end:/\}/,keywords:l,contains:["self"].concat(N)
-});const A=[].concat(y,E.contains),f=A.concat([{begin:/\(/,end:/\)/,keywords:l,
-contains:["self"].concat(A)}]),p={className:"params",begin:/\(/,end:/\)/,
-excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f};return{name:"Javascript",
-aliases:["js","jsx","mjs","cjs"],keywords:l,exports:{PARAMS_CONTAINS:f},
-illegal:/#(?![$_A-z])/,contains:[i.SHEBANG({label:"shebang",binary:"node",
-relevance:5}),{label:"use_strict",className:"meta",relevance:10,
-begin:/^\s*['"]use (strict|asm)['"]/
-},i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,u,_,m,y,d,{
-begin:t(/[{,\n]\s*/,r(t(/(((\/\/.*$)|(\/\*(\*[^/]|[^*])*\*\/))\s*)*/,c+"\\s*:"))),
-relevance:0,contains:[{className:"attr",begin:c+r("\\s*:"),relevance:0}]},{
-begin:"("+i.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",
-keywords:"return throw case",contains:[y,i.REGEXP_MODE,{className:"function",
-begin:"(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+i.UNDERSCORE_IDENT_RE+")\\s*=>",
-returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{
-begin:i.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0
-},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f}]}]
-},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{
-variants:[{begin:"<>",end:"</>"},{begin:o.begin,"on:begin":o.isTrulyOpeningTag,
-end:o.end}],subLanguage:"xml",contains:[{begin:o.begin,end:o.end,skip:!0,
-contains:["self"]}]}],relevance:0},{className:"function",
-beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:l,
-contains:["self",i.inherit(i.TITLE_MODE,{begin:c}),p],illegal:/%/},{
-beginKeywords:"while if switch catch for"},{className:"function",
-begin:i.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",
-returnBegin:!0,contains:[p,i.inherit(i.TITLE_MODE,{begin:c})]},{variants:[{
-begin:"\\."+c},{begin:"\\$"+c}],relevance:0},{className:"class",
-beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{
-beginKeywords:"extends"},i.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/,
-end:/[{;]/,excludeEnd:!0,contains:[i.inherit(i.TITLE_MODE,{begin:c}),"self",p]
-},{begin:"(get|set)\\s+(?="+c+"\\()",end:/\{/,keywords:"get set",
-contains:[i.inherit(i.TITLE_MODE,{begin:c}),{begin:/\(\)/},p]},{begin:/\$[(.]/}]
-}}})());
-hljs.registerLanguage("jboss-cli",(()=>{"use strict";return e=>({
-name:"JBoss CLI",aliases:["wildfly-cli"],keywords:{$pattern:"[a-z-]+",
-keyword:"alias batch cd clear command connect connection-factory connection-info data-source deploy deployment-info deployment-overlay echo echo-dmr help history if jdbc-driver-info jms-queue|20 jms-topic|20 ls patch pwd quit read-attribute read-operation reload rollout-plan run-batch set shutdown try unalias undeploy unset version xa-data-source",
-literal:"true false"},contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,{
-className:"params",begin:/--[\w\-=\/]+/},{className:"function",
-begin:/:[\w\-.]+/,relevance:0},{className:"string",begin:/\B([\/.])[\w\-.\/=]+/
-},{className:"params",begin:/\(/,end:/\)/,contains:[{begin:/[\w-]+ *=/,
-returnBegin:!0,relevance:0,contains:[{className:"attr",begin:/[\w-]+/}]}],
-relevance:0}]})})());
-hljs.registerLanguage("json",(()=>{"use strict";return n=>{const e={
-literal:"true false null"
-},i=[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE],a=[n.QUOTE_STRING_MODE,n.C_NUMBER_MODE],l={
-end:",",endsWithParent:!0,excludeEnd:!0,contains:a,keywords:e},t={begin:/\{/,
-end:/\}/,contains:[{className:"attr",begin:/"/,end:/"/,
-contains:[n.BACKSLASH_ESCAPE],illegal:"\\n"},n.inherit(l,{begin:/:/
-})].concat(i),illegal:"\\S"},s={begin:"\\[",end:"\\]",contains:[n.inherit(l)],
-illegal:"\\S"};return a.push(t,s),i.forEach((n=>{a.push(n)})),{name:"JSON",
-contains:a,keywords:e,illegal:"\\S"}}})());
-hljs.registerLanguage("julia",(()=>{"use strict";return e=>{
-var r="[A-Za-z_\\u00A1-\\uFFFF][A-Za-z_0-9\\u00A1-\\uFFFF]*",t={$pattern:r,
-keyword:["baremodule","begin","break","catch","ccall","const","continue","do","else","elseif","end","export","false","finally","for","function","global","if","import","in","isa","let","local","macro","module","quote","return","true","try","using","where","while"],
-literal:["ARGS","C_NULL","DEPOT_PATH","ENDIAN_BOM","ENV","Inf","Inf16","Inf32","Inf64","InsertionSort","LOAD_PATH","MergeSort","NaN","NaN16","NaN32","NaN64","PROGRAM_FILE","QuickSort","RoundDown","RoundFromZero","RoundNearest","RoundNearestTiesAway","RoundNearestTiesUp","RoundToZero","RoundUp","VERSION|0","devnull","false","im","missing","nothing","pi","stderr","stdin","stdout","true","undef","\u03c0","\u212f"],
-built_in:["AbstractArray","AbstractChannel","AbstractChar","AbstractDict","AbstractDisplay","AbstractFloat","AbstractIrrational","AbstractMatrix","AbstractRange","AbstractSet","AbstractString","AbstractUnitRange","AbstractVecOrMat","AbstractVector","Any","ArgumentError","Array","AssertionError","BigFloat","BigInt","BitArray","BitMatrix","BitSet","BitVector","Bool","BoundsError","CapturedException","CartesianIndex","CartesianIndices","Cchar","Cdouble","Cfloat","Channel","Char","Cint","Cintmax_t","Clong","Clonglong","Cmd","Colon","Complex","ComplexF16","ComplexF32","ComplexF64","CompositeException","Condition","Cptrdiff_t","Cshort","Csize_t","Cssize_t","Cstring","Cuchar","Cuint","Cuintmax_t","Culong","Culonglong","Cushort","Cvoid","Cwchar_t","Cwstring","DataType","DenseArray","DenseMatrix","DenseVecOrMat","DenseVector","Dict","DimensionMismatch","Dims","DivideError","DomainError","EOFError","Enum","ErrorException","Exception","ExponentialBackOff","Expr","Float16","Float32","Float64","Function","GlobalRef","HTML","IO","IOBuffer","IOContext","IOStream","IdDict","IndexCartesian","IndexLinear","IndexStyle","InexactError","InitError","Int","Int128","Int16","Int32","Int64","Int8","Integer","InterruptException","InvalidStateException","Irrational","KeyError","LinRange","LineNumberNode","LinearIndices","LoadError","MIME","Matrix","Method","MethodError","Missing","MissingException","Module","NTuple","NamedTuple","Nothing","Number","OrdinalRange","OutOfMemoryError","OverflowError","Pair","PartialQuickSort","PermutedDimsArray","Pipe","ProcessFailedException","Ptr","QuoteNode","Rational","RawFD","ReadOnlyMemoryError","Real","ReentrantLock","Ref","Regex","RegexMatch","RoundingMode","SegmentationFault","Set","Signed","Some","StackOverflowError","StepRange","StepRangeLen","StridedArray","StridedMatrix","StridedVecOrMat","StridedVector","String","StringIndexError","SubArray","SubString","SubstitutionString","Symbol","SystemError","Task","TaskFailedException","Text","TextDisplay","Timer","Tuple","Type","TypeError","TypeVar","UInt","UInt128","UInt16","UInt32","UInt64","UInt8","UndefInitializer","UndefKeywordError","UndefRefError","UndefVarError","Union","UnionAll","UnitRange","Unsigned","Val","Vararg","VecElement","VecOrMat","Vector","VersionNumber","WeakKeyDict","WeakRef"]
-},n={keywords:t,illegal:/<\//},a={className:"subst",begin:/\$\(/,end:/\)/,
-keywords:t},i={className:"variable",begin:"\\$"+r},o={className:"string",
-contains:[e.BACKSLASH_ESCAPE,a,i],variants:[{begin:/\w*"""/,end:/"""\w*/,
-relevance:10},{begin:/\w*"/,end:/"\w*/}]},s={className:"string",
-contains:[e.BACKSLASH_ESCAPE,a,i],begin:"`",end:"`"},l={className:"meta",
-begin:"@"+r};return n.name="Julia",n.contains=[{className:"number",
-begin:/(\b0x[\d_]*(\.[\d_]*)?|0x\.\d[\d_]*)p[-+]?\d+|\b0[box][a-fA-F0-9][a-fA-F0-9_]*|(\b\d[\d_]*(\.[\d_]*)?|\.\d[\d_]*)([eEfF][-+]?\d+)?/,
-relevance:0},{className:"string",begin:/'(.|\\[xXuU][a-zA-Z0-9]+)'/},o,s,l,{
-className:"comment",variants:[{begin:"#=",end:"=#",relevance:10},{begin:"#",
-end:"$"}]},e.HASH_COMMENT_MODE,{className:"keyword",
-begin:"\\b(((abstract|primitive)\\s+)type|(mutable\\s+)?struct)\\b"},{begin:/<:/
-}],a.contains=n.contains,n}})());
-hljs.registerLanguage("julia-repl",(()=>{"use strict";return a=>({
-name:"Julia REPL",contains:[{className:"meta",begin:/^julia>/,relevance:10,
-starts:{end:/^(?![ ]{6})/,subLanguage:"julia"},aliases:["jldoctest"]}]})})());
-hljs.registerLanguage("kotlin",(()=>{"use strict"
-;var e="\\.([0-9](_*[0-9])*)",n="[0-9a-fA-F](_*[0-9a-fA-F])*",a={
-className:"number",variants:[{
-begin:`(\\b([0-9](_*[0-9])*)((${e})|\\.)?|(${e}))[eE][+-]?([0-9](_*[0-9])*)[fFdD]?\\b`
-},{begin:`\\b([0-9](_*[0-9])*)((${e})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{
-begin:`(${e})[fFdD]?\\b`},{begin:"\\b([0-9](_*[0-9])*)[fFdD]\\b"},{
-begin:`\\b0[xX]((${n})\\.?|(${n})?\\.(${n}))[pP][+-]?([0-9](_*[0-9])*)[fFdD]?\\b`
-},{begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${n})[lL]?\\b`},{
-begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}],
-relevance:0};return e=>{const n={
-keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual",
-built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing",
-literal:"true false null"},i={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@"
-},s={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},t={
-className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string",
-variants:[{begin:'"""',end:'"""(?=[^"])',contains:[t,s]},{begin:"'",end:"'",
-illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/,
-contains:[e.BACKSLASH_ESCAPE,t,s]}]};s.contains.push(r);const l={
-className:"meta",
-begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?"
-},c={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/,
-end:/\)/,contains:[e.inherit(r,{className:"meta-string"})]}]
-},o=a,b=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),E={
-variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/,
-contains:[]}]},d=E;return d.variants[1].contains=[E],E.variants[1].contains=[d],
-{name:"Kotlin",aliases:["kt","kts"],keywords:n,
-contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag",
-begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,b,{className:"keyword",
-begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol",
-begin:/@\w+/}]}},i,l,c,{className:"function",beginKeywords:"fun",end:"[(]|$",
-returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{
-begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0,
-contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin:/</,end:/>/,
-keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/,
-endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/,
-endsWithParent:!0,contains:[E,e.C_LINE_COMMENT_MODE,b],relevance:0
-},e.C_LINE_COMMENT_MODE,b,l,c,r,e.C_NUMBER_MODE]},b]},{className:"class",
-beginKeywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0,
-illegal:"extends implements",contains:[{
-beginKeywords:"public protected internal private constructor"
-},e.UNDERSCORE_TITLE_MODE,{className:"type",begin:/</,end:/>/,excludeBegin:!0,
-excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,]|$/,
-excludeBegin:!0,returnEnd:!0},l,c]},r,{className:"meta",begin:"^#!/usr/bin/env",
-end:"$",illegal:"\n"},o]}}})());
-hljs.registerLanguage("lasso",(()=>{"use strict";return e=>{
-const a="<\\?(lasso(script)?|=)",n="\\]|\\?>",r={
-$pattern:"[a-zA-Z_][\\w.]*|&[lg]t;",
-literal:"true false none minimal full all void and or not bw nbw ew new cn ncn lt lte gt gte eq neq rx nrx ft",
-built_in:"array date decimal duration integer map pair string tag xml null boolean bytes keyword list locale queue set stack staticarray local var variable global data self inherited currentcapture givenblock",
-keyword:"cache database_names database_schemanames database_tablenames define_tag define_type email_batch encode_set html_comment handle handle_error header if inline iterate ljax_target link link_currentaction link_currentgroup link_currentrecord link_detail link_firstgroup link_firstrecord link_lastgroup link_lastrecord link_nextgroup link_nextrecord link_prevgroup link_prevrecord log loop namespace_using output_none portal private protect records referer referrer repeating resultset rows search_args search_arguments select sort_args sort_arguments thread_atomic value_list while abort case else fail_if fail_ifnot fail if_empty if_false if_null if_true loop_abort loop_continue loop_count params params_up return return_value run_children soap_definetag soap_lastrequest soap_lastresponse tag_name ascending average by define descending do equals frozen group handle_failure import in into join let match max min on order parent protected provide public require returnhome skip split_thread sum take thread to trait type where with yield yieldhome"
-},t=e.COMMENT("\x3c!--","--\x3e",{relevance:0}),s={className:"meta",
-begin:"\\[noprocess\\]",starts:{end:"\\[/noprocess\\]",returnEnd:!0,contains:[t]
-}},i={className:"meta",begin:"\\[/noprocess|"+a
-},l=[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.inherit(e.C_NUMBER_MODE,{
-begin:e.C_NUMBER_RE+"|(-?infinity|NaN)\\b"}),e.inherit(e.APOS_STRING_MODE,{
-illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{
-className:"string",begin:"`",end:"`"},{variants:[{begin:"[#$][a-zA-Z_][\\w.]*"
-},{begin:"#",end:"\\d+",illegal:"\\W"}]},{className:"type",begin:"::\\s*",
-end:"[a-zA-Z_][\\w.]*",illegal:"\\W"},{className:"params",variants:[{
-begin:"-(?!infinity)[a-zA-Z_][\\w.]*",relevance:0},{begin:"(\\.\\.\\.)"}]},{
-begin:/(->|\.)\s*/,relevance:0,contains:[{className:"symbol",
-begin:"'[a-zA-Z_][\\w.]*'"}]},{className:"class",beginKeywords:"define",
-returnEnd:!0,end:"\\(|=>",contains:[e.inherit(e.TITLE_MODE,{
-begin:"[a-zA-Z_][\\w.]*(=(?!>))?|[-+*/%](?!>)"})]}];return{name:"Lasso",
-aliases:["ls","lassoscript"],case_insensitive:!0,keywords:r,contains:[{
-className:"meta",begin:n,relevance:0,starts:{end:"\\[|"+a,returnEnd:!0,
-relevance:0,contains:[t]}},s,i,{className:"meta",begin:"\\[no_square_brackets",
-starts:{end:"\\[/no_square_brackets\\]",keywords:r,contains:[{className:"meta",
-begin:n,relevance:0,starts:{end:"\\[noprocess\\]|"+a,returnEnd:!0,contains:[t]}
-},s,i].concat(l)}},{className:"meta",begin:"\\[",relevance:0},{className:"meta",
-begin:"^#!",end:"lasso9$",relevance:10}].concat(l)}}})());
-hljs.registerLanguage("latex",(()=>{"use strict";return e=>{const n=[{
-begin:/\^{6}[0-9a-f]{6}/},{begin:/\^{5}[0-9a-f]{5}/},{begin:/\^{4}[0-9a-f]{4}/
-},{begin:/\^{3}[0-9a-f]{3}/},{begin:/\^{2}[0-9a-f]{2}/},{
-begin:/\^{2}[\u0000-\u007f]/}],a=[{className:"keyword",begin:/\\/,relevance:0,
-contains:[{endsParent:!0,begin:((...e)=>"("+e.map((e=>{
-return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("|")+")")(...["(?:NeedsTeXFormat|RequirePackage|GetIdInfo)","Provides(?:Expl)?(?:Package|Class|File)","(?:DeclareOption|ProcessOptions)","(?:documentclass|usepackage|input|include)","makeat(?:letter|other)","ExplSyntax(?:On|Off)","(?:new|renew|provide)?command","(?:re)newenvironment","(?:New|Renew|Provide|Declare)(?:Expandable)?DocumentCommand","(?:New|Renew|Provide|Declare)DocumentEnvironment","(?:(?:e|g|x)?def|let)","(?:begin|end)","(?:part|chapter|(?:sub){0,2}section|(?:sub)?paragraph)","caption","(?:label|(?:eq|page|name)?ref|(?:paren|foot|super)?cite)","(?:alpha|beta|[Gg]amma|[Dd]elta|(?:var)?epsilon|zeta|eta|[Tt]heta|vartheta)","(?:iota|(?:var)?kappa|[Ll]ambda|mu|nu|[Xx]i|[Pp]i|varpi|(?:var)rho)","(?:[Ss]igma|varsigma|tau|[Uu]psilon|[Pp]hi|varphi|chi|[Pp]si|[Oo]mega)","(?:frac|sum|prod|lim|infty|times|sqrt|leq|geq|left|right|middle|[bB]igg?)","(?:[lr]angle|q?quad|[lcvdi]?dots|d?dot|hat|tilde|bar)"].map((e=>e+"(?![a-zA-Z@:_])")))
-},{endsParent:!0,
-begin:RegExp(["(?:__)?[a-zA-Z]{2,}_[a-zA-Z](?:_?[a-zA-Z])+:[a-zA-Z]*","[lgc]__?[a-zA-Z](?:_?[a-zA-Z])*_[a-zA-Z]{2,}","[qs]__?[a-zA-Z](?:_?[a-zA-Z])+","use(?:_i)?:[a-zA-Z]*","(?:else|fi|or):","(?:if|cs|exp):w","(?:hbox|vbox):n","::[a-zA-Z]_unbraced","::[a-zA-Z:]"].map((e=>e+"(?![a-zA-Z:_])")).join("|"))
-},{endsParent:!0,variants:n},{endsParent:!0,relevance:0,variants:[{
-begin:/[a-zA-Z@]+/},{begin:/[^a-zA-Z@]?/}]}]},{className:"params",relevance:0,
-begin:/#+\d?/},{variants:n},{className:"built_in",relevance:0,begin:/[$&^_]/},{
-className:"meta",begin:"% !TeX",end:"$",relevance:10},e.COMMENT("%","$",{
-relevance:0})],i={begin:/\{/,end:/\}/,relevance:0,contains:["self",...a]
-},t=e.inherit(i,{relevance:0,endsParent:!0,contains:[i,...a]}),r={begin:/\[/,
-end:/\]/,endsParent:!0,relevance:0,contains:[i,...a]},s={begin:/\s+/,relevance:0
-},c=[t],l=[r],o=(e,n)=>({contains:[s],starts:{relevance:0,contains:e,starts:n}
-}),d=(e,n)=>({begin:"\\\\"+e+"(?![a-zA-Z@:_])",keywords:{$pattern:/\\[a-zA-Z]+/,
-keyword:"\\"+e},relevance:0,contains:[s],starts:n}),g=(n,a)=>e.inherit({
-begin:"\\\\begin(?=[ \t]*(\\r?\\n[ \t]*)?\\{"+n+"\\})",keywords:{
-$pattern:/\\[a-zA-Z]+/,keyword:"\\begin"},relevance:0
-},o(c,a)),m=(n="string")=>e.END_SAME_AS_BEGIN({className:n,begin:/(.|\r?\n)/,
-end:/(.|\r?\n)/,excludeBegin:!0,excludeEnd:!0,endsParent:!0}),b=e=>({
-className:"string",end:"(?=\\\\end\\{"+e+"\\})"}),p=(e="string")=>({relevance:0,
-begin:/\{/,starts:{endsParent:!0,contains:[{className:e,end:/(?=\})/,
-endsParent:!0,contains:[{begin:/\{/,end:/\}/,relevance:0,contains:["self"]}]}]}
-});return{name:"LaTeX",aliases:["tex"],
-contains:[...["verb","lstinline"].map((e=>d(e,{contains:[m()]}))),d("mint",o(c,{
-contains:[m()]})),d("mintinline",o(c,{contains:[p(),m()]})),d("url",{
-contains:[p("link"),p("link")]}),d("hyperref",{contains:[p("link")]
-}),d("href",o(l,{contains:[p("link")]
-})),...[].concat(...["","\\*"].map((e=>[g("verbatim"+e,b("verbatim"+e)),g("filecontents"+e,o(c,b("filecontents"+e))),...["","B","L"].map((n=>g(n+"Verbatim"+e,o(l,b(n+"Verbatim"+e)))))]))),g("minted",o(l,o(c,b("minted")))),...a]
-}}})());
-hljs.registerLanguage("ldif",(()=>{"use strict";return e=>({name:"LDIF",
-contains:[{className:"attribute",begin:"^dn",end:": ",excludeEnd:!0,starts:{
-end:"$",relevance:0},relevance:10},{className:"attribute",begin:"^\\w",end:": ",
-excludeEnd:!0,starts:{end:"$",relevance:0}},{className:"literal",begin:"^-",
-end:"$"},e.HASH_COMMENT_MODE]})})());
-hljs.registerLanguage("leaf",(()=>{"use strict";return e=>({name:"Leaf",
-contains:[{className:"function",begin:"#+[A-Za-z_0-9]*\\(",end:/ \{/,
-returnBegin:!0,excludeEnd:!0,contains:[{className:"keyword",begin:"#+"},{
-className:"title",begin:"[A-Za-z_][A-Za-z_0-9]*"},{className:"params",
-begin:"\\(",end:"\\)",endsParent:!0,contains:[{className:"string",begin:'"',
-end:'"'},{className:"variable",begin:"[A-Za-z_][A-Za-z_0-9]*"}]}]}]})})());
-hljs.registerLanguage("less",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],n=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse(),r=i.concat(o)
-;return a=>{const s=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
-HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
-ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
-illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}
-}))(a),l=r,d="([\\w-]+|@\\{[\\w-]+\\})",c=[],g=[],b=e=>({className:"string",
-begin:"~?"+e+".*?"+e}),m=(e,t,i)=>({className:e,begin:t,relevance:i}),u={
-$pattern:/[a-z-]+/,keyword:"and or not only",attribute:t.join(" ")},p={
-begin:"\\(",end:"\\)",contains:g,keywords:u,relevance:0}
-;g.push(a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b("'"),b('"'),a.CSS_NUMBER_MODE,{
-begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]",
-excludeEnd:!0}
-},s.HEXCOLOR,p,m("variable","@@?[\\w-]+",10),m("variable","@\\{[\\w-]+\\}"),m("built_in","~?`[^`]*?`"),{
-className:"attribute",begin:"[\\w-]+\\s*:",end:":",returnBegin:!0,excludeEnd:!0
-},s.IMPORTANT);const f=g.concat({begin:/\{/,end:/\}/,contains:c}),h={
-beginKeywords:"when",endsWithParent:!0,contains:[{beginKeywords:"and not"
-}].concat(g)},w={begin:d+"\\s*:",returnBegin:!0,end:/[;}]/,relevance:0,
-contains:[{begin:/-(webkit|moz|ms|o)-/},{className:"attribute",
-begin:"\\b("+n.join("|")+")\\b",end:/(?=:)/,starts:{endsWithParent:!0,
-illegal:"[<=$]",relevance:0,contains:g}}]},v={className:"keyword",
-begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b",
-starts:{end:"[;{}]",keywords:u,returnEnd:!0,contains:g,relevance:0}},y={
-className:"variable",variants:[{begin:"@[\\w-]+\\s*:",relevance:15},{
-begin:"@[\\w-]+"}],starts:{end:"[;}]",returnEnd:!0,contains:f}},k={variants:[{
-begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:d,end:/\{/}],returnBegin:!0,
-returnEnd:!0,illegal:"[<='$\"]",relevance:0,
-contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,h,m("keyword","all\\b"),m("variable","@\\{[\\w-]+\\}"),{
-begin:"\\b("+e.join("|")+")\\b",className:"selector-tag"
-},m("selector-tag",d+"%?",0),m("selector-id","#"+d),m("selector-class","\\."+d,0),m("selector-tag","&",0),s.ATTRIBUTE_SELECTOR_MODE,{
-className:"selector-pseudo",begin:":("+i.join("|")+")"},{
-className:"selector-pseudo",begin:"::("+o.join("|")+")"},{begin:"\\(",end:"\\)",
-contains:f},{begin:"!important"}]},E={begin:`[\\w-]+:(:)?(${l.join("|")})`,
-returnBegin:!0,contains:[k]}
-;return c.push(a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,v,y,E,w,k),{
-name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:c}}})());
-hljs.registerLanguage("lisp",(()=>{"use strict";return e=>{
-var n="[a-zA-Z_\\-+\\*\\/<=>&#][a-zA-Z0-9_\\-+*\\/<=>&#!]*",a="\\|[^]*?\\|",i="(-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s|D|E|F|L|S)(\\+|-)?\\d+)?",s={
-className:"literal",begin:"\\b(t{1}|nil)\\b"},l={className:"number",variants:[{
-begin:i,relevance:0},{begin:"#(b|B)[0-1]+(/[0-1]+)?"},{
-begin:"#(o|O)[0-7]+(/[0-7]+)?"},{begin:"#(x|X)[0-9a-fA-F]+(/[0-9a-fA-F]+)?"},{
-begin:"#(c|C)\\("+i+" +"+i,end:"\\)"}]},b=e.inherit(e.QUOTE_STRING_MODE,{
-illegal:null}),g=e.COMMENT(";","$",{relevance:0}),r={begin:"\\*",end:"\\*"},t={
-className:"symbol",begin:"[:&]"+n},c={begin:n,relevance:0},d={begin:a},o={
-contains:[l,b,r,t,{begin:"\\(",end:"\\)",contains:["self",s,b,l,c]},c],
-variants:[{begin:"['`]\\(",end:"\\)"},{begin:"\\(quote ",end:"\\)",keywords:{
-name:"quote"}},{begin:"'"+a}]},v={variants:[{begin:"'"+n},{
-begin:"#'"+n+"(::"+n+")*"}]},m={begin:"\\(\\s*",end:"\\)"},u={endsWithParent:!0,
-relevance:0};return m.contains=[{className:"name",variants:[{begin:n,relevance:0
-},{begin:a}]},u],u.contains=[o,v,m,s,l,b,g,r,t,d,c],{name:"Lisp",illegal:/\S/,
-contains:[l,e.SHEBANG(),s,b,g,o,v,m,c]}}})());
-hljs.registerLanguage("livecodeserver",(()=>{"use strict";return e=>{const r={
-className:"variable",variants:[{
-begin:"\\b([gtps][A-Z]{1}[a-zA-Z0-9]*)(\\[.+\\])?(?:\\s*?)"},{begin:"\\$_[A-Z]+"
-}],relevance:0
-},t=[e.C_BLOCK_COMMENT_MODE,e.HASH_COMMENT_MODE,e.COMMENT("--","$"),e.COMMENT("[^:]//","$")],a=e.inherit(e.TITLE_MODE,{
-variants:[{begin:"\\b_*rig[A-Z][A-Za-z0-9_\\-]*"},{begin:"\\b_[a-z0-9\\-]+"}]
-}),o=e.inherit(e.TITLE_MODE,{begin:"\\b([A-Za-z0-9_\\-]+)\\b"});return{
-name:"LiveCode",case_insensitive:!1,keywords:{
-keyword:"$_COOKIE $_FILES $_GET $_GET_BINARY $_GET_RAW $_POST $_POST_BINARY $_POST_RAW $_SESSION $_SERVER codepoint codepoints segment segments codeunit codeunits sentence sentences trueWord trueWords paragraph after byte bytes english the until http forever descending using line real8 with seventh for stdout finally element word words fourth before black ninth sixth characters chars stderr uInt1 uInt1s uInt2 uInt2s stdin string lines relative rel any fifth items from middle mid at else of catch then third it file milliseconds seconds second secs sec int1 int1s int4 int4s internet int2 int2s normal text item last long detailed effective uInt4 uInt4s repeat end repeat URL in try into switch to words https token binfile each tenth as ticks tick system real4 by dateItems without char character ascending eighth whole dateTime numeric short first ftp integer abbreviated abbr abbrev private case while if div mod wrap and or bitAnd bitNot bitOr bitXor among not in a an within contains ends with begins the keys of keys",
-literal:"SIX TEN FORMFEED NINE ZERO NONE SPACE FOUR FALSE COLON CRLF PI COMMA ENDOFFILE EOF EIGHT FIVE QUOTE EMPTY ONE TRUE RETURN CR LINEFEED RIGHT BACKSLASH NULL SEVEN TAB THREE TWO six ten formfeed nine zero none space four false colon crlf pi comma endoffile eof eight five quote empty one true return cr linefeed right backslash null seven tab three two RIVERSION RISTATE FILE_READ_MODE FILE_WRITE_MODE FILE_WRITE_MODE DIR_WRITE_MODE FILE_READ_UMASK FILE_WRITE_UMASK DIR_READ_UMASK DIR_WRITE_UMASK",
-built_in:"put abs acos aliasReference annuity arrayDecode arrayEncode asin atan atan2 average avg avgDev base64Decode base64Encode baseConvert binaryDecode binaryEncode byteOffset byteToNum cachedURL cachedURLs charToNum cipherNames codepointOffset codepointProperty codepointToNum codeunitOffset commandNames compound compress constantNames cos date dateFormat decompress difference directories diskSpace DNSServers exp exp1 exp2 exp10 extents files flushEvents folders format functionNames geometricMean global globals hasMemory harmonicMean hostAddress hostAddressToName hostName hostNameToAddress isNumber ISOToMac itemOffset keys len length libURLErrorData libUrlFormData libURLftpCommand libURLLastHTTPHeaders libURLLastRHHeaders libUrlMultipartFormAddPart libUrlMultipartFormData libURLVersion lineOffset ln ln1 localNames log log2 log10 longFilePath lower macToISO matchChunk matchText matrixMultiply max md5Digest median merge messageAuthenticationCode messageDigest millisec millisecs millisecond milliseconds min monthNames nativeCharToNum normalizeText num number numToByte numToChar numToCodepoint numToNativeChar offset open openfiles openProcesses openProcessIDs openSockets paragraphOffset paramCount param params peerAddress pendingMessages platform popStdDev populationStandardDeviation populationVariance popVariance processID random randomBytes replaceText result revCreateXMLTree revCreateXMLTreeFromFile revCurrentRecord revCurrentRecordIsFirst revCurrentRecordIsLast revDatabaseColumnCount revDatabaseColumnIsNull revDatabaseColumnLengths revDatabaseColumnNames revDatabaseColumnNamed revDatabaseColumnNumbered revDatabaseColumnTypes revDatabaseConnectResult revDatabaseCursors revDatabaseID revDatabaseTableNames revDatabaseType revDataFromQuery revdb_closeCursor revdb_columnbynumber revdb_columncount revdb_columnisnull revdb_columnlengths revdb_columnnames revdb_columntypes revdb_commit revdb_connect revdb_connections revdb_connectionerr revdb_currentrecord revdb_cursorconnection revdb_cursorerr revdb_cursors revdb_dbtype revdb_disconnect revdb_execute revdb_iseof revdb_isbof revdb_movefirst revdb_movelast revdb_movenext revdb_moveprev revdb_query revdb_querylist revdb_recordcount revdb_rollback revdb_tablenames revGetDatabaseDriverPath revNumberOfRecords revOpenDatabase revOpenDatabases revQueryDatabase revQueryDatabaseBlob revQueryResult revQueryIsAtStart revQueryIsAtEnd revUnixFromMacPath revXMLAttribute revXMLAttributes revXMLAttributeValues revXMLChildContents revXMLChildNames revXMLCreateTreeFromFileWithNamespaces revXMLCreateTreeWithNamespaces revXMLDataFromXPathQuery revXMLEvaluateXPath revXMLFirstChild revXMLMatchingNode revXMLNextSibling revXMLNodeContents revXMLNumberOfChildren revXMLParent revXMLPreviousSibling revXMLRootNode revXMLRPC_CreateRequest revXMLRPC_Documents revXMLRPC_Error revXMLRPC_GetHost revXMLRPC_GetMethod revXMLRPC_GetParam revXMLText revXMLRPC_Execute revXMLRPC_GetParamCount revXMLRPC_GetParamNode revXMLRPC_GetParamType revXMLRPC_GetPath revXMLRPC_GetPort revXMLRPC_GetProtocol revXMLRPC_GetRequest revXMLRPC_GetResponse revXMLRPC_GetSocket revXMLTree revXMLTrees revXMLValidateDTD revZipDescribeItem revZipEnumerateItems revZipOpenArchives round sampVariance sec secs seconds sentenceOffset sha1Digest shell shortFilePath sin specialFolderPath sqrt standardDeviation statRound stdDev sum sysError systemVersion tan tempName textDecode textEncode tick ticks time to tokenOffset toLower toUpper transpose truewordOffset trunc uniDecode uniEncode upper URLDecode URLEncode URLStatus uuid value variableNames variance version waitDepth weekdayNames wordOffset xsltApplyStylesheet xsltApplyStylesheetFromFile xsltLoadStylesheet xsltLoadStylesheetFromFile add breakpoint cancel clear local variable file word line folder directory URL close socket process combine constant convert create new alias folder directory decrypt delete variable word line folder directory URL dispatch divide do encrypt filter get include intersect kill libURLDownloadToFile libURLFollowHttpRedirects libURLftpUpload libURLftpUploadFile libURLresetAll libUrlSetAuthCallback libURLSetDriver libURLSetCustomHTTPHeaders libUrlSetExpect100 libURLSetFTPListCommand libURLSetFTPMode libURLSetFTPStopTime libURLSetStatusCallback load extension loadedExtensions multiply socket prepare process post seek rel relative read from process rename replace require resetAll resolve revAddXMLNode revAppendXML revCloseCursor revCloseDatabase revCommitDatabase revCopyFile revCopyFolder revCopyXMLNode revDeleteFolder revDeleteXMLNode revDeleteAllXMLTrees revDeleteXMLTree revExecuteSQL revGoURL revInsertXMLNode revMoveFolder revMoveToFirstRecord revMoveToLastRecord revMoveToNextRecord revMoveToPreviousRecord revMoveToRecord revMoveXMLNode revPutIntoXMLNode revRollBackDatabase revSetDatabaseDriverPath revSetXMLAttribute revXMLRPC_AddParam revXMLRPC_DeleteAllDocuments revXMLAddDTD revXMLRPC_Free revXMLRPC_FreeAll revXMLRPC_DeleteDocument revXMLRPC_DeleteParam revXMLRPC_SetHost revXMLRPC_SetMethod revXMLRPC_SetPort revXMLRPC_SetProtocol revXMLRPC_SetSocket revZipAddItemWithData revZipAddItemWithFile revZipAddUncompressedItemWithData revZipAddUncompressedItemWithFile revZipCancel revZipCloseArchive revZipDeleteItem revZipExtractItemToFile revZipExtractItemToVariable revZipSetProgressCallback revZipRenameItem revZipReplaceItemWithData revZipReplaceItemWithFile revZipOpenArchive send set sort split start stop subtract symmetric union unload vectorDotProduct wait write"
-},contains:[r,{className:"keyword",begin:"\\bend\\sif\\b"},{
-className:"function",beginKeywords:"function",end:"$",
-contains:[r,o,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE,a]
-},{className:"function",begin:"\\bend\\s+",end:"$",keywords:"end",
-contains:[o,a],relevance:0},{beginKeywords:"command on",end:"$",
-contains:[r,o,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE,a]
-},{className:"meta",variants:[{begin:"<\\?(rev|lc|livecode)",relevance:10},{
-begin:"<\\?"},{begin:"\\?>"}]
-},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE,a].concat(t),
-illegal:";$|^\\[|^=|&|\\{"}}})());
-hljs.registerLanguage("livescript",(()=>{"use strict"
-;const e=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],n=["true","false","null","undefined","NaN","Infinity"],a=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"])
-;return t=>{const r={
-keyword:e.concat(["then","unless","until","loop","of","by","when","and","or","is","isnt","not","it","that","otherwise","from","to","til","fallthrough","case","enum","native","list","map","__hasProp","__extends","__slice","__bind","__indexOf"]),
-literal:n.concat(["yes","no","on","off","it","that","void"]),
-built_in:a.concat(["npm","print"])
-},i="[A-Za-z$_](?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*",s=t.inherit(t.TITLE_MODE,{
-begin:i}),o={className:"subst",begin:/#\{/,end:/\}/,keywords:r},c={
-className:"subst",begin:/#[A-Za-z$_]/,end:/(?:-[0-9A-Za-z$_]|[0-9A-Za-z$_])*/,
-keywords:r},l=[t.BINARY_NUMBER_MODE,{className:"number",
-begin:"(\\b0[xX][a-fA-F0-9_]+)|(\\b\\d(\\d|_\\d)*(\\.(\\d(\\d|_\\d)*)?)?(_*[eE]([-+]\\d(_\\d|\\d)*)?)?[_a-z]*)",
-relevance:0,starts:{end:"(\\s*/)?",relevance:0}},{className:"string",variants:[{
-begin:/'''/,end:/'''/,contains:[t.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,
-contains:[t.BACKSLASH_ESCAPE]},{begin:/"""/,end:/"""/,
-contains:[t.BACKSLASH_ESCAPE,o,c]},{begin:/"/,end:/"/,
-contains:[t.BACKSLASH_ESCAPE,o,c]},{begin:/\\/,end:/(\s|$)/,excludeEnd:!0}]},{
-className:"regexp",variants:[{begin:"//",end:"//[gim]*",
-contains:[o,t.HASH_COMMENT_MODE]},{
-begin:/\/(?![ *])(\\.|[^\\\n])*?\/[gim]*(?=\W)/}]},{begin:"@"+i},{begin:"``",
-end:"``",excludeBegin:!0,excludeEnd:!0,subLanguage:"javascript"}];o.contains=l
-;const d={className:"params",begin:"\\(",returnBegin:!0,contains:[{begin:/\(/,
-end:/\)/,keywords:r,contains:["self"].concat(l)}]};return{name:"LiveScript",
-aliases:["ls"],keywords:r,illegal:/\/\*/,
-contains:l.concat([t.COMMENT("\\/\\*","\\*\\/"),t.HASH_COMMENT_MODE,{
-begin:"(#=>|=>|\\|>>|-?->|!->)"},{className:"function",contains:[s,d],
-returnBegin:!0,variants:[{
-begin:"("+i+"\\s*(?:=|:=)\\s*)?(\\(.*\\)\\s*)?\\B->\\*?",end:"->\\*?"},{
-begin:"("+i+"\\s*(?:=|:=)\\s*)?!?(\\(.*\\)\\s*)?\\B[-~]{1,2}>\\*?",
-end:"[-~]{1,2}>\\*?"},{
-begin:"("+i+"\\s*(?:=|:=)\\s*)?(\\(.*\\)\\s*)?\\B!?[-~]{1,2}>\\*?",
-end:"!?[-~]{1,2}>\\*?"}]},{className:"class",beginKeywords:"class",end:"$",
-illegal:/[:="\[\]]/,contains:[{beginKeywords:"extends",endsWithParent:!0,
-illegal:/[:="\[\]]/,contains:[s]},s]},{begin:i+":",end:":",returnBegin:!0,
-returnEnd:!0,relevance:0}])}}})());
-hljs.registerLanguage("llvm",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const a=/([-a-zA-Z$._][\w$.-]*)/,t={
-className:"variable",variants:[{begin:e(/%/,a)},{begin:/%\d+/},{begin:/#\d+/}]
-},i={className:"title",variants:[{begin:e(/@/,a)},{begin:/@\d+/},{begin:e(/!/,a)
-},{begin:e(/!\d+/,a)},{begin:/!\d+/}]};return{name:"LLVM IR",
-keywords:"begin end true false declare define global constant private linker_private internal available_externally linkonce linkonce_odr weak weak_odr appending dllimport dllexport common default hidden protected extern_weak external thread_local zeroinitializer undef null to tail target triple datalayout volatile nuw nsw nnan ninf nsz arcp fast exact inbounds align addrspace section alias module asm sideeffect gc dbg linker_private_weak attributes blockaddress initialexec localdynamic localexec prefix unnamed_addr ccc fastcc coldcc x86_stdcallcc x86_fastcallcc arm_apcscc arm_aapcscc arm_aapcs_vfpcc ptx_device ptx_kernel intel_ocl_bicc msp430_intrcc spir_func spir_kernel x86_64_sysvcc x86_64_win64cc x86_thiscallcc cc c signext zeroext inreg sret nounwind noreturn noalias nocapture byval nest readnone readonly inlinehint noinline alwaysinline optsize ssp sspreq noredzone noimplicitfloat naked builtin cold nobuiltin noduplicate nonlazybind optnone returns_twice sanitize_address sanitize_memory sanitize_thread sspstrong uwtable returned type opaque eq ne slt sgt sle sge ult ugt ule uge oeq one olt ogt ole oge ord uno ueq une x acq_rel acquire alignstack atomic catch cleanup filter inteldialect max min monotonic nand personality release seq_cst singlethread umax umin unordered xchg add fadd sub fsub mul fmul udiv sdiv fdiv urem srem frem shl lshr ashr and or xor icmp fcmp phi call trunc zext sext fptrunc fpext uitofp sitofp fptoui fptosi inttoptr ptrtoint bitcast addrspacecast select va_arg ret br switch invoke unwind unreachable indirectbr landingpad resume malloc alloca free load store getelementptr extractelement insertelement shufflevector getresult extractvalue insertvalue atomicrmw cmpxchg fence argmemonly double",
-contains:[{className:"type",begin:/\bi\d+(?=\s|\b)/},n.COMMENT(/;\s*$/,null,{
-relevance:0}),n.COMMENT(/;/,/$/),n.QUOTE_STRING_MODE,{className:"string",
-variants:[{begin:/"/,end:/[^\\]"/}]},i,{className:"punctuation",relevance:0,
-begin:/,/},{className:"operator",relevance:0,begin:/=/},t,{className:"symbol",
-variants:[{begin:/^\s*[a-z]+:/}],relevance:0},{className:"number",variants:[{
-begin:/0[xX][a-fA-F0-9]+/},{begin:/-?\d+(?:[.]\d+)?(?:[eE][-+]?\d+(?:[.]\d+)?)?/
-}],relevance:0}]}}})());
-hljs.registerLanguage("lsl",(()=>{"use strict";return E=>{var T={
-className:"number",relevance:0,begin:E.C_NUMBER_RE};return{
-name:"LSL (Linden Scripting Language)",illegal:":",contains:[{
-className:"string",begin:'"',end:'"',contains:[{className:"subst",
-begin:/\\[tn"\\]/}]},{className:"comment",
-variants:[E.COMMENT("//","$"),E.COMMENT("/\\*","\\*/")],relevance:0},T,{
-className:"section",variants:[{begin:"\\b(state|default)\\b"},{
-begin:"\\b(state_(entry|exit)|touch(_(start|end))?|(land_)?collision(_(start|end))?|timer|listen|(no_)?sensor|control|(not_)?at_(rot_)?target|money|email|experience_permissions(_denied)?|run_time_permissions|changed|attach|dataserver|moving_(start|end)|link_message|(on|object)_rez|remote_data|http_re(sponse|quest)|path_update|transaction_result)\\b"
-}]},{className:"built_in",
-begin:"\\b(ll(AgentInExperience|(Create|DataSize|Delete|KeyCount|Keys|Read|Update)KeyValue|GetExperience(Details|ErrorMessage)|ReturnObjectsBy(ID|Owner)|Json(2List|[GS]etValue|ValueType)|Sin|Cos|Tan|Atan2|Sqrt|Pow|Abs|Fabs|Frand|Floor|Ceil|Round|Vec(Mag|Norm|Dist)|Rot(Between|2(Euler|Fwd|Left|Up))|(Euler|Axes)2Rot|Whisper|(Region|Owner)?Say|Shout|Listen(Control|Remove)?|Sensor(Repeat|Remove)?|Detected(Name|Key|Owner|Type|Pos|Vel|Grab|Rot|Group|LinkNumber)|Die|Ground|Wind|([GS]et)(AnimationOverride|MemoryLimit|PrimMediaParams|ParcelMusicURL|Object(Desc|Name)|PhysicsMaterial|Status|Scale|Color|Alpha|Texture|Pos|Rot|Force|Torque)|ResetAnimationOverride|(Scale|Offset|Rotate)Texture|(Rot)?Target(Remove)?|(Stop)?MoveToTarget|Apply(Rotational)?Impulse|Set(KeyframedMotion|ContentType|RegionPos|(Angular)?Velocity|Buoyancy|HoverHeight|ForceAndTorque|TimerEvent|ScriptState|Damage|TextureAnim|Sound(Queueing|Radius)|Vehicle(Type|(Float|Vector|Rotation)Param)|(Touch|Sit)?Text|Camera(Eye|At)Offset|PrimitiveParams|ClickAction|Link(Alpha|Color|PrimitiveParams(Fast)?|Texture(Anim)?|Camera|Media)|RemoteScriptAccessPin|PayPrice|LocalRot)|ScaleByFactor|Get((Max|Min)ScaleFactor|ClosestNavPoint|StaticPath|SimStats|Env|PrimitiveParams|Link(PrimitiveParams|Number(OfSides)?|Key|Name|Media)|HTTPHeader|FreeURLs|Object(Details|PermMask|PrimCount)|Parcel(MaxPrims|Details|Prim(Count|Owners))|Attached(List)?|(SPMax|Free|Used)Memory|Region(Name|TimeDilation|FPS|Corner|AgentCount)|Root(Position|Rotation)|UnixTime|(Parcel|Region)Flags|(Wall|GMT)clock|SimulatorHostname|BoundingBox|GeometricCenter|Creator|NumberOf(Prims|NotecardLines|Sides)|Animation(List)?|(Camera|Local)(Pos|Rot)|Vel|Accel|Omega|Time(stamp|OfDay)|(Object|CenterOf)?Mass|MassMKS|Energy|Owner|(Owner)?Key|SunDirection|Texture(Offset|Scale|Rot)|Inventory(Number|Name|Key|Type|Creator|PermMask)|Permissions(Key)?|StartParameter|List(Length|EntryType)|Date|Agent(Size|Info|Language|List)|LandOwnerAt|NotecardLine|Script(Name|State))|(Get|Reset|GetAndReset)Time|PlaySound(Slave)?|LoopSound(Master|Slave)?|(Trigger|Stop|Preload)Sound|((Get|Delete)Sub|Insert)String|To(Upper|Lower)|Give(InventoryList|Money)|RezObject|(Stop)?LookAt|Sleep|CollisionFilter|(Take|Release)Controls|DetachFromAvatar|AttachToAvatar(Temp)?|InstantMessage|(GetNext)?Email|StopHover|MinEventDelay|RotLookAt|String(Length|Trim)|(Start|Stop)Animation|TargetOmega|Request(Experience)?Permissions|(Create|Break)Link|BreakAllLinks|(Give|Remove)Inventory|Water|PassTouches|Request(Agent|Inventory)Data|TeleportAgent(Home|GlobalCoords)?|ModifyLand|CollisionSound|ResetScript|MessageLinked|PushObject|PassCollisions|AxisAngle2Rot|Rot2(Axis|Angle)|A(cos|sin)|AngleBetween|AllowInventoryDrop|SubStringIndex|List2(CSV|Integer|Json|Float|String|Key|Vector|Rot|List(Strided)?)|DeleteSubList|List(Statistics|Sort|Randomize|(Insert|Find|Replace)List)|EdgeOfWorld|AdjustSoundVolume|Key2Name|TriggerSoundLimited|EjectFromLand|(CSV|ParseString)2List|OverMyLand|SameGroup|UnSit|Ground(Slope|Normal|Contour)|GroundRepel|(Set|Remove)VehicleFlags|SitOnLink|(AvatarOn)?(Link)?SitTarget|Script(Danger|Profiler)|Dialog|VolumeDetect|ResetOtherScript|RemoteLoadScriptPin|(Open|Close)RemoteDataChannel|SendRemoteData|RemoteDataReply|(Integer|String)ToBase64|XorBase64|Log(10)?|Base64To(String|Integer)|ParseStringKeepNulls|RezAtRoot|RequestSimulatorData|ForceMouselook|(Load|Release|(E|Une)scape)URL|ParcelMedia(CommandList|Query)|ModPow|MapDestination|(RemoveFrom|AddTo|Reset)Land(Pass|Ban)List|(Set|Clear)CameraParams|HTTP(Request|Response)|TextBox|DetectedTouch(UV|Face|Pos|(N|Bin)ormal|ST)|(MD5|SHA1|DumpList2)String|Request(Secure)?URL|Clear(Prim|Link)Media|(Link)?ParticleSystem|(Get|Request)(Username|DisplayName)|RegionSayTo|CastRay|GenerateKey|TransferLindenDollars|ManageEstateAccess|(Create|Delete)Character|ExecCharacterCmd|Evade|FleeFrom|NavigateTo|PatrolPoints|Pursue|UpdateCharacter|WanderWithin))\\b"
-},{className:"literal",variants:[{
-begin:"\\b(PI|TWO_PI|PI_BY_TWO|DEG_TO_RAD|RAD_TO_DEG|SQRT2)\\b"},{
-begin:"\\b(XP_ERROR_(EXPERIENCES_DISABLED|EXPERIENCE_(DISABLED|SUSPENDED)|INVALID_(EXPERIENCE|PARAMETERS)|KEY_NOT_FOUND|MATURITY_EXCEEDED|NONE|NOT_(FOUND|PERMITTED(_LAND)?)|NO_EXPERIENCE|QUOTA_EXCEEDED|RETRY_UPDATE|STORAGE_EXCEPTION|STORE_DISABLED|THROTTLED|UNKNOWN_ERROR)|JSON_APPEND|STATUS_(PHYSICS|ROTATE_[XYZ]|PHANTOM|SANDBOX|BLOCK_GRAB(_OBJECT)?|(DIE|RETURN)_AT_EDGE|CAST_SHADOWS|OK|MALFORMED_PARAMS|TYPE_MISMATCH|BOUNDS_ERROR|NOT_(FOUND|SUPPORTED)|INTERNAL_ERROR|WHITELIST_FAILED)|AGENT(_(BY_(LEGACY_|USER)NAME|FLYING|ATTACHMENTS|SCRIPTED|MOUSELOOK|SITTING|ON_OBJECT|AWAY|WALKING|IN_AIR|TYPING|CROUCHING|BUSY|ALWAYS_RUN|AUTOPILOT|LIST_(PARCEL(_OWNER)?|REGION)))?|CAMERA_(PITCH|DISTANCE|BEHINDNESS_(ANGLE|LAG)|(FOCUS|POSITION)(_(THRESHOLD|LOCKED|LAG))?|FOCUS_OFFSET|ACTIVE)|ANIM_ON|LOOP|REVERSE|PING_PONG|SMOOTH|ROTATE|SCALE|ALL_SIDES|LINK_(ROOT|SET|ALL_(OTHERS|CHILDREN)|THIS)|ACTIVE|PASS(IVE|_(ALWAYS|IF_NOT_HANDLED|NEVER))|SCRIPTED|CONTROL_(FWD|BACK|(ROT_)?(LEFT|RIGHT)|UP|DOWN|(ML_)?LBUTTON)|PERMISSION_(RETURN_OBJECTS|DEBIT|OVERRIDE_ANIMATIONS|SILENT_ESTATE_MANAGEMENT|TAKE_CONTROLS|TRIGGER_ANIMATION|ATTACH|CHANGE_LINKS|(CONTROL|TRACK)_CAMERA|TELEPORT)|INVENTORY_(TEXTURE|SOUND|OBJECT|SCRIPT|LANDMARK|CLOTHING|NOTECARD|BODYPART|ANIMATION|GESTURE|ALL|NONE)|CHANGED_(INVENTORY|COLOR|SHAPE|SCALE|TEXTURE|LINK|ALLOWED_DROP|OWNER|REGION(_START)?|TELEPORT|MEDIA)|OBJECT_(CLICK_ACTION|HOVER_HEIGHT|LAST_OWNER_ID|(PHYSICS|SERVER|STREAMING)_COST|UNKNOWN_DETAIL|CHARACTER_TIME|PHANTOM|PHYSICS|TEMP_(ATTACHED|ON_REZ)|NAME|DESC|POS|PRIM_(COUNT|EQUIVALENCE)|RETURN_(PARCEL(_OWNER)?|REGION)|REZZER_KEY|ROO?T|VELOCITY|OMEGA|OWNER|GROUP(_TAG)?|CREATOR|ATTACHED_(POINT|SLOTS_AVAILABLE)|RENDER_WEIGHT|(BODY_SHAPE|PATHFINDING)_TYPE|(RUNNING|TOTAL)_SCRIPT_COUNT|TOTAL_INVENTORY_COUNT|SCRIPT_(MEMORY|TIME))|TYPE_(INTEGER|FLOAT|STRING|KEY|VECTOR|ROTATION|INVALID)|(DEBUG|PUBLIC)_CHANNEL|ATTACH_(AVATAR_CENTER|CHEST|HEAD|BACK|PELVIS|MOUTH|CHIN|NECK|NOSE|BELLY|[LR](SHOULDER|HAND|FOOT|EAR|EYE|[UL](ARM|LEG)|HIP)|(LEFT|RIGHT)_PEC|HUD_(CENTER_[12]|TOP_(RIGHT|CENTER|LEFT)|BOTTOM(_(RIGHT|LEFT))?)|[LR]HAND_RING1|TAIL_(BASE|TIP)|[LR]WING|FACE_(JAW|[LR]EAR|[LR]EYE|TOUNGE)|GROIN|HIND_[LR]FOOT)|LAND_(LEVEL|RAISE|LOWER|SMOOTH|NOISE|REVERT)|DATA_(ONLINE|NAME|BORN|SIM_(POS|STATUS|RATING)|PAYINFO)|PAYMENT_INFO_(ON_FILE|USED)|REMOTE_DATA_(CHANNEL|REQUEST|REPLY)|PSYS_(PART_(BF_(ZERO|ONE(_MINUS_(DEST_COLOR|SOURCE_(ALPHA|COLOR)))?|DEST_COLOR|SOURCE_(ALPHA|COLOR))|BLEND_FUNC_(DEST|SOURCE)|FLAGS|(START|END)_(COLOR|ALPHA|SCALE|GLOW)|MAX_AGE|(RIBBON|WIND|INTERP_(COLOR|SCALE)|BOUNCE|FOLLOW_(SRC|VELOCITY)|TARGET_(POS|LINEAR)|EMISSIVE)_MASK)|SRC_(MAX_AGE|PATTERN|ANGLE_(BEGIN|END)|BURST_(RATE|PART_COUNT|RADIUS|SPEED_(MIN|MAX))|ACCEL|TEXTURE|TARGET_KEY|OMEGA|PATTERN_(DROP|EXPLODE|ANGLE(_CONE(_EMPTY)?)?)))|VEHICLE_(REFERENCE_FRAME|TYPE_(NONE|SLED|CAR|BOAT|AIRPLANE|BALLOON)|(LINEAR|ANGULAR)_(FRICTION_TIMESCALE|MOTOR_DIRECTION)|LINEAR_MOTOR_OFFSET|HOVER_(HEIGHT|EFFICIENCY|TIMESCALE)|BUOYANCY|(LINEAR|ANGULAR)_(DEFLECTION_(EFFICIENCY|TIMESCALE)|MOTOR_(DECAY_)?TIMESCALE)|VERTICAL_ATTRACTION_(EFFICIENCY|TIMESCALE)|BANKING_(EFFICIENCY|MIX|TIMESCALE)|FLAG_(NO_DEFLECTION_UP|LIMIT_(ROLL_ONLY|MOTOR_UP)|HOVER_((WATER|TERRAIN|UP)_ONLY|GLOBAL_HEIGHT)|MOUSELOOK_(STEER|BANK)|CAMERA_DECOUPLED))|PRIM_(ALLOW_UNSIT|ALPHA_MODE(_(BLEND|EMISSIVE|MASK|NONE))?|NORMAL|SPECULAR|TYPE(_(BOX|CYLINDER|PRISM|SPHERE|TORUS|TUBE|RING|SCULPT))?|HOLE_(DEFAULT|CIRCLE|SQUARE|TRIANGLE)|MATERIAL(_(STONE|METAL|GLASS|WOOD|FLESH|PLASTIC|RUBBER))?|SHINY_(NONE|LOW|MEDIUM|HIGH)|BUMP_(NONE|BRIGHT|DARK|WOOD|BARK|BRICKS|CHECKER|CONCRETE|TILE|STONE|DISKS|GRAVEL|BLOBS|SIDING|LARGETILE|STUCCO|SUCTION|WEAVE)|TEXGEN_(DEFAULT|PLANAR)|SCRIPTED_SIT_ONLY|SCULPT_(TYPE_(SPHERE|TORUS|PLANE|CYLINDER|MASK)|FLAG_(MIRROR|INVERT))|PHYSICS(_(SHAPE_(CONVEX|NONE|PRIM|TYPE)))?|(POS|ROT)_LOCAL|SLICE|TEXT|FLEXIBLE|POINT_LIGHT|TEMP_ON_REZ|PHANTOM|POSITION|SIT_TARGET|SIZE|ROTATION|TEXTURE|NAME|OMEGA|DESC|LINK_TARGET|COLOR|BUMP_SHINY|FULLBRIGHT|TEXGEN|GLOW|MEDIA_(ALT_IMAGE_ENABLE|CONTROLS|(CURRENT|HOME)_URL|AUTO_(LOOP|PLAY|SCALE|ZOOM)|FIRST_CLICK_INTERACT|(WIDTH|HEIGHT)_PIXELS|WHITELIST(_ENABLE)?|PERMS_(INTERACT|CONTROL)|PARAM_MAX|CONTROLS_(STANDARD|MINI)|PERM_(NONE|OWNER|GROUP|ANYONE)|MAX_(URL_LENGTH|WHITELIST_(SIZE|COUNT)|(WIDTH|HEIGHT)_PIXELS)))|MASK_(BASE|OWNER|GROUP|EVERYONE|NEXT)|PERM_(TRANSFER|MODIFY|COPY|MOVE|ALL)|PARCEL_(MEDIA_COMMAND_(STOP|PAUSE|PLAY|LOOP|TEXTURE|URL|TIME|AGENT|UNLOAD|AUTO_ALIGN|TYPE|SIZE|DESC|LOOP_SET)|FLAG_(ALLOW_(FLY|(GROUP_)?SCRIPTS|LANDMARK|TERRAFORM|DAMAGE|CREATE_(GROUP_)?OBJECTS)|USE_(ACCESS_(GROUP|LIST)|BAN_LIST|LAND_PASS_LIST)|LOCAL_SOUND_ONLY|RESTRICT_PUSHOBJECT|ALLOW_(GROUP|ALL)_OBJECT_ENTRY)|COUNT_(TOTAL|OWNER|GROUP|OTHER|SELECTED|TEMP)|DETAILS_(NAME|DESC|OWNER|GROUP|AREA|ID|SEE_AVATARS))|LIST_STAT_(MAX|MIN|MEAN|MEDIAN|STD_DEV|SUM(_SQUARES)?|NUM_COUNT|GEOMETRIC_MEAN|RANGE)|PAY_(HIDE|DEFAULT)|REGION_FLAG_(ALLOW_DAMAGE|FIXED_SUN|BLOCK_TERRAFORM|SANDBOX|DISABLE_(COLLISIONS|PHYSICS)|BLOCK_FLY|ALLOW_DIRECT_TELEPORT|RESTRICT_PUSHOBJECT)|HTTP_(METHOD|MIMETYPE|BODY_(MAXLENGTH|TRUNCATED)|CUSTOM_HEADER|PRAGMA_NO_CACHE|VERBOSE_THROTTLE|VERIFY_CERT)|SIT_(INVALID_(AGENT|LINK_OBJECT)|NO(T_EXPERIENCE|_(ACCESS|EXPERIENCE_PERMISSION|SIT_TARGET)))|STRING_(TRIM(_(HEAD|TAIL))?)|CLICK_ACTION_(NONE|TOUCH|SIT|BUY|PAY|OPEN(_MEDIA)?|PLAY|ZOOM)|TOUCH_INVALID_FACE|PROFILE_(NONE|SCRIPT_MEMORY)|RC_(DATA_FLAGS|DETECT_PHANTOM|GET_(LINK_NUM|NORMAL|ROOT_KEY)|MAX_HITS|REJECT_(TYPES|AGENTS|(NON)?PHYSICAL|LAND))|RCERR_(CAST_TIME_EXCEEDED|SIM_PERF_LOW|UNKNOWN)|ESTATE_ACCESS_(ALLOWED_(AGENT|GROUP)_(ADD|REMOVE)|BANNED_AGENT_(ADD|REMOVE))|DENSITY|FRICTION|RESTITUTION|GRAVITY_MULTIPLIER|KFM_(COMMAND|CMD_(PLAY|STOP|PAUSE)|MODE|FORWARD|LOOP|PING_PONG|REVERSE|DATA|ROTATION|TRANSLATION)|ERR_(GENERIC|PARCEL_PERMISSIONS|MALFORMED_PARAMS|RUNTIME_PERMISSIONS|THROTTLED)|CHARACTER_(CMD_((SMOOTH_)?STOP|JUMP)|DESIRED_(TURN_)?SPEED|RADIUS|STAY_WITHIN_PARCEL|LENGTH|ORIENTATION|ACCOUNT_FOR_SKIPPED_FRAMES|AVOIDANCE_MODE|TYPE(_([ABCD]|NONE))?|MAX_(DECEL|TURN_RADIUS|(ACCEL|SPEED)))|PURSUIT_(OFFSET|FUZZ_FACTOR|GOAL_TOLERANCE|INTERCEPT)|REQUIRE_LINE_OF_SIGHT|FORCE_DIRECT_PATH|VERTICAL|HORIZONTAL|AVOID_(CHARACTERS|DYNAMIC_OBSTACLES|NONE)|PU_(EVADE_(HIDDEN|SPOTTED)|FAILURE_(DYNAMIC_PATHFINDING_DISABLED|INVALID_(GOAL|START)|NO_(NAVMESH|VALID_DESTINATION)|OTHER|TARGET_GONE|(PARCEL_)?UNREACHABLE)|(GOAL|SLOWDOWN_DISTANCE)_REACHED)|TRAVERSAL_TYPE(_(FAST|NONE|SLOW))?|CONTENT_TYPE_(ATOM|FORM|HTML|JSON|LLSD|RSS|TEXT|XHTML|XML)|GCNP_(RADIUS|STATIC)|(PATROL|WANDER)_PAUSE_AT_WAYPOINTS|OPT_(AVATAR|CHARACTER|EXCLUSION_VOLUME|LEGACY_LINKSET|MATERIAL_VOLUME|OTHER|STATIC_OBSTACLE|WALKABLE)|SIM_STAT_PCT_CHARS_STEPPED)\\b"
-},{begin:"\\b(FALSE|TRUE)\\b"},{begin:"\\b(ZERO_ROTATION)\\b"},{
-begin:"\\b(EOF|JSON_(ARRAY|DELETE|FALSE|INVALID|NULL|NUMBER|OBJECT|STRING|TRUE)|NULL_KEY|TEXTURE_(BLANK|DEFAULT|MEDIA|PLYWOOD|TRANSPARENT)|URL_REQUEST_(GRANTED|DENIED))\\b"
-},{begin:"\\b(ZERO_VECTOR|TOUCH_INVALID_(TEXCOORD|VECTOR))\\b"}]},{
-className:"type",
-begin:"\\b(integer|float|string|key|vector|quaternion|rotation|list)\\b"}]}}
-})());
-hljs.registerLanguage("lua",(()=>{"use strict";return e=>{
-const t="\\[=*\\[",a="\\]=*\\]",n={begin:t,end:a,contains:["self"]
-},o=[e.COMMENT("--(?!\\[=*\\[)","$"),e.COMMENT("--\\[=*\\[",a,{contains:[n],
-relevance:10})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE,
-literal:"true false nil",
-keyword:"and break do else elseif end for goto if in local not or repeat return then until while",
-built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove"
-},contains:o.concat([{className:"function",beginKeywords:"function",end:"\\)",
-contains:[e.inherit(e.TITLE_MODE,{
-begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params",
-begin:"\\(",endsWithParent:!0,contains:o}].concat(o)
-},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string",
-begin:t,end:a,contains:[n],relevance:5}])}}})());
-hljs.registerLanguage("makefile",(()=>{"use strict";return e=>{const i={
-className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)",
-contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%<?\^\+\*]/}]},a={className:"string",
-begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,i]},n={className:"variable",
-begin:/\$\([\w-]+\s/,end:/\)/,keywords:{
-built_in:"subst patsubst strip findstring filter filter-out sort word wordlist firstword lastword dir notdir suffix basename addsuffix addprefix join wildcard realpath abspath error warning shell origin flavor foreach if or and call eval file value"
-},contains:[i]},s={begin:"^"+e.UNDERSCORE_IDENT_RE+"\\s*(?=[:+?]?=)"},r={
-className:"section",begin:/^[^\s]+:/,end:/$/,contains:[i]};return{
-name:"Makefile",aliases:["mk","mak","make"],keywords:{$pattern:/[\w-]+/,
-keyword:"define endef undefine ifdef ifndef ifeq ifneq else endif include -include sinclude override export unexport private vpath"
-},contains:[e.HASH_COMMENT_MODE,i,a,n,s,{className:"meta",begin:/^\.PHONY:/,
-end:/$/,keywords:{$pattern:/[\.\w]+/,"meta-keyword":".PHONY"}},r]}}})());
-hljs.registerLanguage("mathematica",(()=>{"use strict"
-;const e=["AASTriangle","AbelianGroup","Abort","AbortKernels","AbortProtect","AbortScheduledTask","Above","Abs","AbsArg","AbsArgPlot","Absolute","AbsoluteCorrelation","AbsoluteCorrelationFunction","AbsoluteCurrentValue","AbsoluteDashing","AbsoluteFileName","AbsoluteOptions","AbsolutePointSize","AbsoluteThickness","AbsoluteTime","AbsoluteTiming","AcceptanceThreshold","AccountingForm","Accumulate","Accuracy","AccuracyGoal","ActionDelay","ActionMenu","ActionMenuBox","ActionMenuBoxOptions","Activate","Active","ActiveClassification","ActiveClassificationObject","ActiveItem","ActivePrediction","ActivePredictionObject","ActiveStyle","AcyclicGraphQ","AddOnHelpPath","AddSides","AddTo","AddToSearchIndex","AddUsers","AdjacencyGraph","AdjacencyList","AdjacencyMatrix","AdjacentMeshCells","AdjustmentBox","AdjustmentBoxOptions","AdjustTimeSeriesForecast","AdministrativeDivisionData","AffineHalfSpace","AffineSpace","AffineStateSpaceModel","AffineTransform","After","AggregatedEntityClass","AggregationLayer","AircraftData","AirportData","AirPressureData","AirTemperatureData","AiryAi","AiryAiPrime","AiryAiZero","AiryBi","AiryBiPrime","AiryBiZero","AlgebraicIntegerQ","AlgebraicNumber","AlgebraicNumberDenominator","AlgebraicNumberNorm","AlgebraicNumberPolynomial","AlgebraicNumberTrace","AlgebraicRules","AlgebraicRulesData","Algebraics","AlgebraicUnitQ","Alignment","AlignmentMarker","AlignmentPoint","All","AllowAdultContent","AllowedCloudExtraParameters","AllowedCloudParameterExtensions","AllowedDimensions","AllowedFrequencyRange","AllowedHeads","AllowGroupClose","AllowIncomplete","AllowInlineCells","AllowKernelInitialization","AllowLooseGrammar","AllowReverseGroupClose","AllowScriptLevelChange","AllowVersionUpdate","AllTrue","Alphabet","AlphabeticOrder","AlphabeticSort","AlphaChannel","AlternateImage","AlternatingFactorial","AlternatingGroup","AlternativeHypothesis","Alternatives","AltitudeMethod","AmbientLight","AmbiguityFunction","AmbiguityList","Analytic","AnatomyData","AnatomyForm","AnatomyPlot3D","AnatomySkinStyle","AnatomyStyling","AnchoredSearch","And","AndersonDarlingTest","AngerJ","AngleBisector","AngleBracket","AnglePath","AnglePath3D","AngleVector","AngularGauge","Animate","AnimationCycleOffset","AnimationCycleRepetitions","AnimationDirection","AnimationDisplayTime","AnimationRate","AnimationRepetitions","AnimationRunning","AnimationRunTime","AnimationTimeIndex","Animator","AnimatorBox","AnimatorBoxOptions","AnimatorElements","Annotate","Annotation","AnnotationDelete","AnnotationKeys","AnnotationRules","AnnotationValue","Annuity","AnnuityDue","Annulus","AnomalyDetection","AnomalyDetector","AnomalyDetectorFunction","Anonymous","Antialiasing","AntihermitianMatrixQ","Antisymmetric","AntisymmetricMatrixQ","Antonyms","AnyOrder","AnySubset","AnyTrue","Apart","ApartSquareFree","APIFunction","Appearance","AppearanceElements","AppearanceRules","AppellF1","Append","AppendCheck","AppendLayer","AppendTo","Apply","ApplySides","ArcCos","ArcCosh","ArcCot","ArcCoth","ArcCsc","ArcCsch","ArcCurvature","ARCHProcess","ArcLength","ArcSec","ArcSech","ArcSin","ArcSinDistribution","ArcSinh","ArcTan","ArcTanh","Area","Arg","ArgMax","ArgMin","ArgumentCountQ","ARIMAProcess","ArithmeticGeometricMean","ARMAProcess","Around","AroundReplace","ARProcess","Array","ArrayComponents","ArrayDepth","ArrayFilter","ArrayFlatten","ArrayMesh","ArrayPad","ArrayPlot","ArrayQ","ArrayResample","ArrayReshape","ArrayRules","Arrays","Arrow","Arrow3DBox","ArrowBox","Arrowheads","ASATriangle","Ask","AskAppend","AskConfirm","AskDisplay","AskedQ","AskedValue","AskFunction","AskState","AskTemplateDisplay","AspectRatio","AspectRatioFixed","Assert","AssociateTo","Association","AssociationFormat","AssociationMap","AssociationQ","AssociationThread","AssumeDeterministic","Assuming","Assumptions","AstronomicalData","Asymptotic","AsymptoticDSolveValue","AsymptoticEqual","AsymptoticEquivalent","AsymptoticGreater","AsymptoticGreaterEqual","AsymptoticIntegrate","AsymptoticLess","AsymptoticLessEqual","AsymptoticOutputTracker","AsymptoticProduct","AsymptoticRSolveValue","AsymptoticSolve","AsymptoticSum","Asynchronous","AsynchronousTaskObject","AsynchronousTasks","Atom","AtomCoordinates","AtomCount","AtomDiagramCoordinates","AtomList","AtomQ","AttentionLayer","Attributes","Audio","AudioAmplify","AudioAnnotate","AudioAnnotationLookup","AudioBlockMap","AudioCapture","AudioChannelAssignment","AudioChannelCombine","AudioChannelMix","AudioChannels","AudioChannelSeparate","AudioData","AudioDelay","AudioDelete","AudioDevice","AudioDistance","AudioEncoding","AudioFade","AudioFrequencyShift","AudioGenerator","AudioIdentify","AudioInputDevice","AudioInsert","AudioInstanceQ","AudioIntervals","AudioJoin","AudioLabel","AudioLength","AudioLocalMeasurements","AudioLooping","AudioLoudness","AudioMeasurements","AudioNormalize","AudioOutputDevice","AudioOverlay","AudioPad","AudioPan","AudioPartition","AudioPause","AudioPitchShift","AudioPlay","AudioPlot","AudioQ","AudioRecord","AudioReplace","AudioResample","AudioReverb","AudioReverse","AudioSampleRate","AudioSpectralMap","AudioSpectralTransformation","AudioSplit","AudioStop","AudioStream","AudioStreams","AudioTimeStretch","AudioTracks","AudioTrim","AudioType","AugmentedPolyhedron","AugmentedSymmetricPolynomial","Authenticate","Authentication","AuthenticationDialog","AutoAction","Autocomplete","AutocompletionFunction","AutoCopy","AutocorrelationTest","AutoDelete","AutoEvaluateEvents","AutoGeneratedPackage","AutoIndent","AutoIndentSpacings","AutoItalicWords","AutoloadPath","AutoMatch","Automatic","AutomaticImageSize","AutoMultiplicationSymbol","AutoNumberFormatting","AutoOpenNotebooks","AutoOpenPalettes","AutoQuoteCharacters","AutoRefreshed","AutoRemove","AutorunSequencing","AutoScaling","AutoScroll","AutoSpacing","AutoStyleOptions","AutoStyleWords","AutoSubmitting","Axes","AxesEdge","AxesLabel","AxesOrigin","AxesStyle","AxiomaticTheory","Axis","BabyMonsterGroupB","Back","Background","BackgroundAppearance","BackgroundTasksSettings","Backslash","Backsubstitution","Backward","Ball","Band","BandpassFilter","BandstopFilter","BarabasiAlbertGraphDistribution","BarChart","BarChart3D","BarcodeImage","BarcodeRecognize","BaringhausHenzeTest","BarLegend","BarlowProschanImportance","BarnesG","BarOrigin","BarSpacing","BartlettHannWindow","BartlettWindow","BaseDecode","BaseEncode","BaseForm","Baseline","BaselinePosition","BaseStyle","BasicRecurrentLayer","BatchNormalizationLayer","BatchSize","BatesDistribution","BattleLemarieWavelet","BayesianMaximization","BayesianMaximizationObject","BayesianMinimization","BayesianMinimizationObject","Because","BeckmannDistribution","Beep","Before","Begin","BeginDialogPacket","BeginFrontEndInteractionPacket","BeginPackage","BellB","BellY","Below","BenfordDistribution","BeniniDistribution","BenktanderGibratDistribution","BenktanderWeibullDistribution","BernoulliB","BernoulliDistribution","BernoulliGraphDistribution","BernoulliProcess","BernsteinBasis","BesselFilterModel","BesselI","BesselJ","BesselJZero","BesselK","BesselY","BesselYZero","Beta","BetaBinomialDistribution","BetaDistribution","BetaNegativeBinomialDistribution","BetaPrimeDistribution","BetaRegularized","Between","BetweennessCentrality","BeveledPolyhedron","BezierCurve","BezierCurve3DBox","BezierCurve3DBoxOptions","BezierCurveBox","BezierCurveBoxOptions","BezierFunction","BilateralFilter","Binarize","BinaryDeserialize","BinaryDistance","BinaryFormat","BinaryImageQ","BinaryRead","BinaryReadList","BinarySerialize","BinaryWrite","BinCounts","BinLists","Binomial","BinomialDistribution","BinomialProcess","BinormalDistribution","BiorthogonalSplineWavelet","BipartiteGraphQ","BiquadraticFilterModel","BirnbaumImportance","BirnbaumSaundersDistribution","BitAnd","BitClear","BitGet","BitLength","BitNot","BitOr","BitSet","BitShiftLeft","BitShiftRight","BitXor","BiweightLocation","BiweightMidvariance","Black","BlackmanHarrisWindow","BlackmanNuttallWindow","BlackmanWindow","Blank","BlankForm","BlankNullSequence","BlankSequence","Blend","Block","BlockchainAddressData","BlockchainBase","BlockchainBlockData","BlockchainContractValue","BlockchainData","BlockchainGet","BlockchainKeyEncode","BlockchainPut","BlockchainTokenData","BlockchainTransaction","BlockchainTransactionData","BlockchainTransactionSign","BlockchainTransactionSubmit","BlockMap","BlockRandom","BlomqvistBeta","BlomqvistBetaTest","Blue","Blur","BodePlot","BohmanWindow","Bold","Bond","BondCount","BondList","BondQ","Bookmarks","Boole","BooleanConsecutiveFunction","BooleanConvert","BooleanCountingFunction","BooleanFunction","BooleanGraph","BooleanMaxterms","BooleanMinimize","BooleanMinterms","BooleanQ","BooleanRegion","Booleans","BooleanStrings","BooleanTable","BooleanVariables","BorderDimensions","BorelTannerDistribution","Bottom","BottomHatTransform","BoundaryDiscretizeGraphics","BoundaryDiscretizeRegion","BoundaryMesh","BoundaryMeshRegion","BoundaryMeshRegionQ","BoundaryStyle","BoundedRegionQ","BoundingRegion","Bounds","Box","BoxBaselineShift","BoxData","BoxDimensions","Boxed","Boxes","BoxForm","BoxFormFormatTypes","BoxFrame","BoxID","BoxMargins","BoxMatrix","BoxObject","BoxRatios","BoxRotation","BoxRotationPoint","BoxStyle","BoxWhiskerChart","Bra","BracketingBar","BraKet","BrayCurtisDistance","BreadthFirstScan","Break","BridgeData","BrightnessEqualize","BroadcastStationData","Brown","BrownForsytheTest","BrownianBridgeProcess","BrowserCategory","BSplineBasis","BSplineCurve","BSplineCurve3DBox","BSplineCurve3DBoxOptions","BSplineCurveBox","BSplineCurveBoxOptions","BSplineFunction","BSplineSurface","BSplineSurface3DBox","BSplineSurface3DBoxOptions","BubbleChart","BubbleChart3D","BubbleScale","BubbleSizes","BuildingData","BulletGauge","BusinessDayQ","ButterflyGraph","ButterworthFilterModel","Button","ButtonBar","ButtonBox","ButtonBoxOptions","ButtonCell","ButtonContents","ButtonData","ButtonEvaluator","ButtonExpandable","ButtonFrame","ButtonFunction","ButtonMargins","ButtonMinHeight","ButtonNote","ButtonNotebook","ButtonSource","ButtonStyle","ButtonStyleMenuListing","Byte","ByteArray","ByteArrayFormat","ByteArrayQ","ByteArrayToString","ByteCount","ByteOrdering","C","CachedValue","CacheGraphics","CachePersistence","CalendarConvert","CalendarData","CalendarType","Callout","CalloutMarker","CalloutStyle","CallPacket","CanberraDistance","Cancel","CancelButton","CandlestickChart","CanonicalGraph","CanonicalizePolygon","CanonicalizePolyhedron","CanonicalName","CanonicalWarpingCorrespondence","CanonicalWarpingDistance","CantorMesh","CantorStaircase","Cap","CapForm","CapitalDifferentialD","Capitalize","CapsuleShape","CaptureRunning","CardinalBSplineBasis","CarlemanLinearize","CarmichaelLambda","CaseOrdering","Cases","CaseSensitive","Cashflow","Casoratian","Catalan","CatalanNumber","Catch","CategoricalDistribution","Catenate","CatenateLayer","CauchyDistribution","CauchyWindow","CayleyGraph","CDF","CDFDeploy","CDFInformation","CDFWavelet","Ceiling","CelestialSystem","Cell","CellAutoOverwrite","CellBaseline","CellBoundingBox","CellBracketOptions","CellChangeTimes","CellContents","CellContext","CellDingbat","CellDynamicExpression","CellEditDuplicate","CellElementsBoundingBox","CellElementSpacings","CellEpilog","CellEvaluationDuplicate","CellEvaluationFunction","CellEvaluationLanguage","CellEventActions","CellFrame","CellFrameColor","CellFrameLabelMargins","CellFrameLabels","CellFrameMargins","CellGroup","CellGroupData","CellGrouping","CellGroupingRules","CellHorizontalScrolling","CellID","CellLabel","CellLabelAutoDelete","CellLabelMargins","CellLabelPositioning","CellLabelStyle","CellLabelTemplate","CellMargins","CellObject","CellOpen","CellPrint","CellProlog","Cells","CellSize","CellStyle","CellTags","CellularAutomaton","CensoredDistribution","Censoring","Center","CenterArray","CenterDot","CentralFeature","CentralMoment","CentralMomentGeneratingFunction","Cepstrogram","CepstrogramArray","CepstrumArray","CForm","ChampernowneNumber","ChangeOptions","ChannelBase","ChannelBrokerAction","ChannelDatabin","ChannelHistoryLength","ChannelListen","ChannelListener","ChannelListeners","ChannelListenerWait","ChannelObject","ChannelPreSendFunction","ChannelReceiverFunction","ChannelSend","ChannelSubscribers","ChanVeseBinarize","Character","CharacterCounts","CharacterEncoding","CharacterEncodingsPath","CharacteristicFunction","CharacteristicPolynomial","CharacterName","CharacterNormalize","CharacterRange","Characters","ChartBaseStyle","ChartElementData","ChartElementDataFunction","ChartElementFunction","ChartElements","ChartLabels","ChartLayout","ChartLegends","ChartStyle","Chebyshev1FilterModel","Chebyshev2FilterModel","ChebyshevDistance","ChebyshevT","ChebyshevU","Check","CheckAbort","CheckAll","Checkbox","CheckboxBar","CheckboxBox","CheckboxBoxOptions","ChemicalData","ChessboardDistance","ChiDistribution","ChineseRemainder","ChiSquareDistribution","ChoiceButtons","ChoiceDialog","CholeskyDecomposition","Chop","ChromaticityPlot","ChromaticityPlot3D","ChromaticPolynomial","Circle","CircleBox","CircleDot","CircleMinus","CirclePlus","CirclePoints","CircleThrough","CircleTimes","CirculantGraph","CircularOrthogonalMatrixDistribution","CircularQuaternionMatrixDistribution","CircularRealMatrixDistribution","CircularSymplecticMatrixDistribution","CircularUnitaryMatrixDistribution","Circumsphere","CityData","ClassifierFunction","ClassifierInformation","ClassifierMeasurements","ClassifierMeasurementsObject","Classify","ClassPriors","Clear","ClearAll","ClearAttributes","ClearCookies","ClearPermissions","ClearSystemCache","ClebschGordan","ClickPane","Clip","ClipboardNotebook","ClipFill","ClippingStyle","ClipPlanes","ClipPlanesStyle","ClipRange","Clock","ClockGauge","ClockwiseContourIntegral","Close","Closed","CloseKernels","ClosenessCentrality","Closing","ClosingAutoSave","ClosingEvent","ClosingSaveDialog","CloudAccountData","CloudBase","CloudConnect","CloudConnections","CloudDeploy","CloudDirectory","CloudDisconnect","CloudEvaluate","CloudExport","CloudExpression","CloudExpressions","CloudFunction","CloudGet","CloudImport","CloudLoggingData","CloudObject","CloudObjectInformation","CloudObjectInformationData","CloudObjectNameFormat","CloudObjects","CloudObjectURLType","CloudPublish","CloudPut","CloudRenderingMethod","CloudSave","CloudShare","CloudSubmit","CloudSymbol","CloudUnshare","CloudUserID","ClusterClassify","ClusterDissimilarityFunction","ClusteringComponents","ClusteringTree","CMYKColor","Coarse","CodeAssistOptions","Coefficient","CoefficientArrays","CoefficientDomain","CoefficientList","CoefficientRules","CoifletWavelet","Collect","Colon","ColonForm","ColorBalance","ColorCombine","ColorConvert","ColorCoverage","ColorData","ColorDataFunction","ColorDetect","ColorDistance","ColorFunction","ColorFunctionScaling","Colorize","ColorNegate","ColorOutput","ColorProfileData","ColorQ","ColorQuantize","ColorReplace","ColorRules","ColorSelectorSettings","ColorSeparate","ColorSetter","ColorSetterBox","ColorSetterBoxOptions","ColorSlider","ColorsNear","ColorSpace","ColorToneMapping","Column","ColumnAlignments","ColumnBackgrounds","ColumnForm","ColumnLines","ColumnsEqual","ColumnSpacings","ColumnWidths","CombinedEntityClass","CombinerFunction","CometData","CommonDefaultFormatTypes","Commonest","CommonestFilter","CommonName","CommonUnits","CommunityBoundaryStyle","CommunityGraphPlot","CommunityLabels","CommunityRegionStyle","CompanyData","CompatibleUnitQ","CompilationOptions","CompilationTarget","Compile","Compiled","CompiledCodeFunction","CompiledFunction","CompilerOptions","Complement","ComplementedEntityClass","CompleteGraph","CompleteGraphQ","CompleteKaryTree","CompletionsListPacket","Complex","ComplexContourPlot","Complexes","ComplexExpand","ComplexInfinity","ComplexityFunction","ComplexListPlot","ComplexPlot","ComplexPlot3D","ComplexRegionPlot","ComplexStreamPlot","ComplexVectorPlot","ComponentMeasurements","ComponentwiseContextMenu","Compose","ComposeList","ComposeSeries","CompositeQ","Composition","CompoundElement","CompoundExpression","CompoundPoissonDistribution","CompoundPoissonProcess","CompoundRenewalProcess","Compress","CompressedData","CompressionLevel","ComputeUncertainty","Condition","ConditionalExpression","Conditioned","Cone","ConeBox","ConfidenceLevel","ConfidenceRange","ConfidenceTransform","ConfigurationPath","ConformAudio","ConformImages","Congruent","ConicHullRegion","ConicHullRegion3DBox","ConicHullRegionBox","ConicOptimization","Conjugate","ConjugateTranspose","Conjunction","Connect","ConnectedComponents","ConnectedGraphComponents","ConnectedGraphQ","ConnectedMeshComponents","ConnectedMoleculeComponents","ConnectedMoleculeQ","ConnectionSettings","ConnectLibraryCallbackFunction","ConnectSystemModelComponents","ConnesWindow","ConoverTest","ConsoleMessage","ConsoleMessagePacket","Constant","ConstantArray","ConstantArrayLayer","ConstantImage","ConstantPlusLayer","ConstantRegionQ","Constants","ConstantTimesLayer","ConstellationData","ConstrainedMax","ConstrainedMin","Construct","Containing","ContainsAll","ContainsAny","ContainsExactly","ContainsNone","ContainsOnly","ContentFieldOptions","ContentLocationFunction","ContentObject","ContentPadding","ContentsBoundingBox","ContentSelectable","ContentSize","Context","ContextMenu","Contexts","ContextToFileName","Continuation","Continue","ContinuedFraction","ContinuedFractionK","ContinuousAction","ContinuousMarkovProcess","ContinuousTask","ContinuousTimeModelQ","ContinuousWaveletData","ContinuousWaveletTransform","ContourDetect","ContourGraphics","ContourIntegral","ContourLabels","ContourLines","ContourPlot","ContourPlot3D","Contours","ContourShading","ContourSmoothing","ContourStyle","ContraharmonicMean","ContrastiveLossLayer","Control","ControlActive","ControlAlignment","ControlGroupContentsBox","ControllabilityGramian","ControllabilityMatrix","ControllableDecomposition","ControllableModelQ","ControllerDuration","ControllerInformation","ControllerInformationData","ControllerLinking","ControllerManipulate","ControllerMethod","ControllerPath","ControllerState","ControlPlacement","ControlsRendering","ControlType","Convergents","ConversionOptions","ConversionRules","ConvertToBitmapPacket","ConvertToPostScript","ConvertToPostScriptPacket","ConvexHullMesh","ConvexPolygonQ","ConvexPolyhedronQ","ConvolutionLayer","Convolve","ConwayGroupCo1","ConwayGroupCo2","ConwayGroupCo3","CookieFunction","Cookies","CoordinateBoundingBox","CoordinateBoundingBoxArray","CoordinateBounds","CoordinateBoundsArray","CoordinateChartData","CoordinatesToolOptions","CoordinateTransform","CoordinateTransformData","CoprimeQ","Coproduct","CopulaDistribution","Copyable","CopyDatabin","CopyDirectory","CopyFile","CopyTag","CopyToClipboard","CornerFilter","CornerNeighbors","Correlation","CorrelationDistance","CorrelationFunction","CorrelationTest","Cos","Cosh","CoshIntegral","CosineDistance","CosineWindow","CosIntegral","Cot","Coth","Count","CountDistinct","CountDistinctBy","CounterAssignments","CounterBox","CounterBoxOptions","CounterClockwiseContourIntegral","CounterEvaluator","CounterFunction","CounterIncrements","CounterStyle","CounterStyleMenuListing","CountRoots","CountryData","Counts","CountsBy","Covariance","CovarianceEstimatorFunction","CovarianceFunction","CoxianDistribution","CoxIngersollRossProcess","CoxModel","CoxModelFit","CramerVonMisesTest","CreateArchive","CreateCellID","CreateChannel","CreateCloudExpression","CreateDatabin","CreateDataStructure","CreateDataSystemModel","CreateDialog","CreateDirectory","CreateDocument","CreateFile","CreateIntermediateDirectories","CreateManagedLibraryExpression","CreateNotebook","CreatePacletArchive","CreatePalette","CreatePalettePacket","CreatePermissionsGroup","CreateScheduledTask","CreateSearchIndex","CreateSystemModel","CreateTemporary","CreateUUID","CreateWindow","CriterionFunction","CriticalityFailureImportance","CriticalitySuccessImportance","CriticalSection","Cross","CrossEntropyLossLayer","CrossingCount","CrossingDetect","CrossingPolygon","CrossMatrix","Csc","Csch","CTCLossLayer","Cube","CubeRoot","Cubics","Cuboid","CuboidBox","Cumulant","CumulantGeneratingFunction","Cup","CupCap","Curl","CurlyDoubleQuote","CurlyQuote","CurrencyConvert","CurrentDate","CurrentImage","CurrentlySpeakingPacket","CurrentNotebookImage","CurrentScreenImage","CurrentValue","Curry","CurryApplied","CurvatureFlowFilter","CurveClosed","Cyan","CycleGraph","CycleIndexPolynomial","Cycles","CyclicGroup","Cyclotomic","Cylinder","CylinderBox","CylindricalDecomposition","D","DagumDistribution","DamData","DamerauLevenshteinDistance","DampingFactor","Darker","Dashed","Dashing","DatabaseConnect","DatabaseDisconnect","DatabaseReference","Databin","DatabinAdd","DatabinRemove","Databins","DatabinUpload","DataCompression","DataDistribution","DataRange","DataReversed","Dataset","DatasetDisplayPanel","DataStructure","DataStructureQ","Date","DateBounds","Dated","DateDelimiters","DateDifference","DatedUnit","DateFormat","DateFunction","DateHistogram","DateInterval","DateList","DateListLogPlot","DateListPlot","DateListStepPlot","DateObject","DateObjectQ","DateOverlapsQ","DatePattern","DatePlus","DateRange","DateReduction","DateString","DateTicksFormat","DateValue","DateWithinQ","DaubechiesWavelet","DavisDistribution","DawsonF","DayCount","DayCountConvention","DayHemisphere","DaylightQ","DayMatchQ","DayName","DayNightTerminator","DayPlus","DayRange","DayRound","DeBruijnGraph","DeBruijnSequence","Debug","DebugTag","Decapitalize","Decimal","DecimalForm","DeclareKnownSymbols","DeclarePackage","Decompose","DeconvolutionLayer","Decrement","Decrypt","DecryptFile","DedekindEta","DeepSpaceProbeData","Default","DefaultAxesStyle","DefaultBaseStyle","DefaultBoxStyle","DefaultButton","DefaultColor","DefaultControlPlacement","DefaultDuplicateCellStyle","DefaultDuration","DefaultElement","DefaultFaceGridsStyle","DefaultFieldHintStyle","DefaultFont","DefaultFontProperties","DefaultFormatType","DefaultFormatTypeForStyle","DefaultFrameStyle","DefaultFrameTicksStyle","DefaultGridLinesStyle","DefaultInlineFormatType","DefaultInputFormatType","DefaultLabelStyle","DefaultMenuStyle","DefaultNaturalLanguage","DefaultNewCellStyle","DefaultNewInlineCellStyle","DefaultNotebook","DefaultOptions","DefaultOutputFormatType","DefaultPrintPrecision","DefaultStyle","DefaultStyleDefinitions","DefaultTextFormatType","DefaultTextInlineFormatType","DefaultTicksStyle","DefaultTooltipStyle","DefaultValue","DefaultValues","Defer","DefineExternal","DefineInputStreamMethod","DefineOutputStreamMethod","DefineResourceFunction","Definition","Degree","DegreeCentrality","DegreeGraphDistribution","DegreeLexicographic","DegreeReverseLexicographic","DEigensystem","DEigenvalues","Deinitialization","Del","DelaunayMesh","Delayed","Deletable","Delete","DeleteAnomalies","DeleteBorderComponents","DeleteCases","DeleteChannel","DeleteCloudExpression","DeleteContents","DeleteDirectory","DeleteDuplicates","DeleteDuplicatesBy","DeleteFile","DeleteMissing","DeleteObject","DeletePermissionsKey","DeleteSearchIndex","DeleteSmallComponents","DeleteStopwords","DeleteWithContents","DeletionWarning","DelimitedArray","DelimitedSequence","Delimiter","DelimiterFlashTime","DelimiterMatching","Delimiters","DeliveryFunction","Dendrogram","Denominator","DensityGraphics","DensityHistogram","DensityPlot","DensityPlot3D","DependentVariables","Deploy","Deployed","Depth","DepthFirstScan","Derivative","DerivativeFilter","DerivedKey","DescriptorStateSpace","DesignMatrix","DestroyAfterEvaluation","Det","DeviceClose","DeviceConfigure","DeviceExecute","DeviceExecuteAsynchronous","DeviceObject","DeviceOpen","DeviceOpenQ","DeviceRead","DeviceReadBuffer","DeviceReadLatest","DeviceReadList","DeviceReadTimeSeries","Devices","DeviceStreams","DeviceWrite","DeviceWriteBuffer","DGaussianWavelet","DiacriticalPositioning","Diagonal","DiagonalizableMatrixQ","DiagonalMatrix","DiagonalMatrixQ","Dialog","DialogIndent","DialogInput","DialogLevel","DialogNotebook","DialogProlog","DialogReturn","DialogSymbols","Diamond","DiamondMatrix","DiceDissimilarity","DictionaryLookup","DictionaryWordQ","DifferenceDelta","DifferenceOrder","DifferenceQuotient","DifferenceRoot","DifferenceRootReduce","Differences","DifferentialD","DifferentialRoot","DifferentialRootReduce","DifferentiatorFilter","DigitalSignature","DigitBlock","DigitBlockMinimum","DigitCharacter","DigitCount","DigitQ","DihedralAngle","DihedralGroup","Dilation","DimensionalCombinations","DimensionalMeshComponents","DimensionReduce","DimensionReducerFunction","DimensionReduction","Dimensions","DiracComb","DiracDelta","DirectedEdge","DirectedEdges","DirectedGraph","DirectedGraphQ","DirectedInfinity","Direction","Directive","Directory","DirectoryName","DirectoryQ","DirectoryStack","DirichletBeta","DirichletCharacter","DirichletCondition","DirichletConvolve","DirichletDistribution","DirichletEta","DirichletL","DirichletLambda","DirichletTransform","DirichletWindow","DisableConsolePrintPacket","DisableFormatting","DiscreteAsymptotic","DiscreteChirpZTransform","DiscreteConvolve","DiscreteDelta","DiscreteHadamardTransform","DiscreteIndicator","DiscreteLimit","DiscreteLQEstimatorGains","DiscreteLQRegulatorGains","DiscreteLyapunovSolve","DiscreteMarkovProcess","DiscreteMaxLimit","DiscreteMinLimit","DiscretePlot","DiscretePlot3D","DiscreteRatio","DiscreteRiccatiSolve","DiscreteShift","DiscreteTimeModelQ","DiscreteUniformDistribution","DiscreteVariables","DiscreteWaveletData","DiscreteWaveletPacketTransform","DiscreteWaveletTransform","DiscretizeGraphics","DiscretizeRegion","Discriminant","DisjointQ","Disjunction","Disk","DiskBox","DiskMatrix","DiskSegment","Dispatch","DispatchQ","DispersionEstimatorFunction","Display","DisplayAllSteps","DisplayEndPacket","DisplayFlushImagePacket","DisplayForm","DisplayFunction","DisplayPacket","DisplayRules","DisplaySetSizePacket","DisplayString","DisplayTemporary","DisplayWith","DisplayWithRef","DisplayWithVariable","DistanceFunction","DistanceMatrix","DistanceTransform","Distribute","Distributed","DistributedContexts","DistributeDefinitions","DistributionChart","DistributionDomain","DistributionFitTest","DistributionParameterAssumptions","DistributionParameterQ","Dithering","Div","Divergence","Divide","DivideBy","Dividers","DivideSides","Divisible","Divisors","DivisorSigma","DivisorSum","DMSList","DMSString","Do","DockedCells","DocumentGenerator","DocumentGeneratorInformation","DocumentGeneratorInformationData","DocumentGenerators","DocumentNotebook","DocumentWeightingRules","Dodecahedron","DomainRegistrationInformation","DominantColors","DOSTextFormat","Dot","DotDashed","DotEqual","DotLayer","DotPlusLayer","Dotted","DoubleBracketingBar","DoubleContourIntegral","DoubleDownArrow","DoubleLeftArrow","DoubleLeftRightArrow","DoubleLeftTee","DoubleLongLeftArrow","DoubleLongLeftRightArrow","DoubleLongRightArrow","DoubleRightArrow","DoubleRightTee","DoubleUpArrow","DoubleUpDownArrow","DoubleVerticalBar","DoublyInfinite","Down","DownArrow","DownArrowBar","DownArrowUpArrow","DownLeftRightVector","DownLeftTeeVector","DownLeftVector","DownLeftVectorBar","DownRightTeeVector","DownRightVector","DownRightVectorBar","Downsample","DownTee","DownTeeArrow","DownValues","DragAndDrop","DrawEdges","DrawFrontFaces","DrawHighlighted","Drop","DropoutLayer","DSolve","DSolveValue","Dt","DualLinearProgramming","DualPolyhedron","DualSystemsModel","DumpGet","DumpSave","DuplicateFreeQ","Duration","Dynamic","DynamicBox","DynamicBoxOptions","DynamicEvaluationTimeout","DynamicGeoGraphics","DynamicImage","DynamicLocation","DynamicModule","DynamicModuleBox","DynamicModuleBoxOptions","DynamicModuleParent","DynamicModuleValues","DynamicName","DynamicNamespace","DynamicReference","DynamicSetting","DynamicUpdating","DynamicWrapper","DynamicWrapperBox","DynamicWrapperBoxOptions","E","EarthImpactData","EarthquakeData","EccentricityCentrality","Echo","EchoFunction","EclipseType","EdgeAdd","EdgeBetweennessCentrality","EdgeCapacity","EdgeCapForm","EdgeColor","EdgeConnectivity","EdgeContract","EdgeCost","EdgeCount","EdgeCoverQ","EdgeCycleMatrix","EdgeDashing","EdgeDelete","EdgeDetect","EdgeForm","EdgeIndex","EdgeJoinForm","EdgeLabeling","EdgeLabels","EdgeLabelStyle","EdgeList","EdgeOpacity","EdgeQ","EdgeRenderingFunction","EdgeRules","EdgeShapeFunction","EdgeStyle","EdgeTaggedGraph","EdgeTaggedGraphQ","EdgeTags","EdgeThickness","EdgeWeight","EdgeWeightedGraphQ","Editable","EditButtonSettings","EditCellTagsSettings","EditDistance","EffectiveInterest","Eigensystem","Eigenvalues","EigenvectorCentrality","Eigenvectors","Element","ElementData","ElementwiseLayer","ElidedForms","Eliminate","EliminationOrder","Ellipsoid","EllipticE","EllipticExp","EllipticExpPrime","EllipticF","EllipticFilterModel","EllipticK","EllipticLog","EllipticNomeQ","EllipticPi","EllipticReducedHalfPeriods","EllipticTheta","EllipticThetaPrime","EmbedCode","EmbeddedHTML","EmbeddedService","EmbeddingLayer","EmbeddingObject","EmitSound","EmphasizeSyntaxErrors","EmpiricalDistribution","Empty","EmptyGraphQ","EmptyRegion","EnableConsolePrintPacket","Enabled","Encode","Encrypt","EncryptedObject","EncryptFile","End","EndAdd","EndDialogPacket","EndFrontEndInteractionPacket","EndOfBuffer","EndOfFile","EndOfLine","EndOfString","EndPackage","EngineEnvironment","EngineeringForm","Enter","EnterExpressionPacket","EnterTextPacket","Entity","EntityClass","EntityClassList","EntityCopies","EntityFunction","EntityGroup","EntityInstance","EntityList","EntityPrefetch","EntityProperties","EntityProperty","EntityPropertyClass","EntityRegister","EntityStore","EntityStores","EntityTypeName","EntityUnregister","EntityValue","Entropy","EntropyFilter","Environment","Epilog","EpilogFunction","Equal","EqualColumns","EqualRows","EqualTilde","EqualTo","EquatedTo","Equilibrium","EquirippleFilterKernel","Equivalent","Erf","Erfc","Erfi","ErlangB","ErlangC","ErlangDistribution","Erosion","ErrorBox","ErrorBoxOptions","ErrorNorm","ErrorPacket","ErrorsDialogSettings","EscapeRadius","EstimatedBackground","EstimatedDistribution","EstimatedProcess","EstimatorGains","EstimatorRegulator","EuclideanDistance","EulerAngles","EulerCharacteristic","EulerE","EulerGamma","EulerianGraphQ","EulerMatrix","EulerPhi","Evaluatable","Evaluate","Evaluated","EvaluatePacket","EvaluateScheduledTask","EvaluationBox","EvaluationCell","EvaluationCompletionAction","EvaluationData","EvaluationElements","EvaluationEnvironment","EvaluationMode","EvaluationMonitor","EvaluationNotebook","EvaluationObject","EvaluationOrder","Evaluator","EvaluatorNames","EvenQ","EventData","EventEvaluator","EventHandler","EventHandlerTag","EventLabels","EventSeries","ExactBlackmanWindow","ExactNumberQ","ExactRootIsolation","ExampleData","Except","ExcludedForms","ExcludedLines","ExcludedPhysicalQuantities","ExcludePods","Exclusions","ExclusionsStyle","Exists","Exit","ExitDialog","ExoplanetData","Exp","Expand","ExpandAll","ExpandDenominator","ExpandFileName","ExpandNumerator","Expectation","ExpectationE","ExpectedValue","ExpGammaDistribution","ExpIntegralE","ExpIntegralEi","ExpirationDate","Exponent","ExponentFunction","ExponentialDistribution","ExponentialFamily","ExponentialGeneratingFunction","ExponentialMovingAverage","ExponentialPowerDistribution","ExponentPosition","ExponentStep","Export","ExportAutoReplacements","ExportByteArray","ExportForm","ExportPacket","ExportString","Expression","ExpressionCell","ExpressionGraph","ExpressionPacket","ExpressionUUID","ExpToTrig","ExtendedEntityClass","ExtendedGCD","Extension","ExtentElementFunction","ExtentMarkers","ExtentSize","ExternalBundle","ExternalCall","ExternalDataCharacterEncoding","ExternalEvaluate","ExternalFunction","ExternalFunctionName","ExternalIdentifier","ExternalObject","ExternalOptions","ExternalSessionObject","ExternalSessions","ExternalStorageBase","ExternalStorageDownload","ExternalStorageGet","ExternalStorageObject","ExternalStoragePut","ExternalStorageUpload","ExternalTypeSignature","ExternalValue","Extract","ExtractArchive","ExtractLayer","ExtractPacletArchive","ExtremeValueDistribution","FaceAlign","FaceForm","FaceGrids","FaceGridsStyle","FacialFeatures","Factor","FactorComplete","Factorial","Factorial2","FactorialMoment","FactorialMomentGeneratingFunction","FactorialPower","FactorInteger","FactorList","FactorSquareFree","FactorSquareFreeList","FactorTerms","FactorTermsList","Fail","Failure","FailureAction","FailureDistribution","FailureQ","False","FareySequence","FARIMAProcess","FeatureDistance","FeatureExtract","FeatureExtraction","FeatureExtractor","FeatureExtractorFunction","FeatureNames","FeatureNearest","FeatureSpacePlot","FeatureSpacePlot3D","FeatureTypes","FEDisableConsolePrintPacket","FeedbackLinearize","FeedbackSector","FeedbackSectorStyle","FeedbackType","FEEnableConsolePrintPacket","FetalGrowthData","Fibonacci","Fibonorial","FieldCompletionFunction","FieldHint","FieldHintStyle","FieldMasked","FieldSize","File","FileBaseName","FileByteCount","FileConvert","FileDate","FileExistsQ","FileExtension","FileFormat","FileHandler","FileHash","FileInformation","FileName","FileNameDepth","FileNameDialogSettings","FileNameDrop","FileNameForms","FileNameJoin","FileNames","FileNameSetter","FileNameSplit","FileNameTake","FilePrint","FileSize","FileSystemMap","FileSystemScan","FileTemplate","FileTemplateApply","FileType","FilledCurve","FilledCurveBox","FilledCurveBoxOptions","Filling","FillingStyle","FillingTransform","FilteredEntityClass","FilterRules","FinancialBond","FinancialData","FinancialDerivative","FinancialIndicator","Find","FindAnomalies","FindArgMax","FindArgMin","FindChannels","FindClique","FindClusters","FindCookies","FindCurvePath","FindCycle","FindDevices","FindDistribution","FindDistributionParameters","FindDivisions","FindEdgeCover","FindEdgeCut","FindEdgeIndependentPaths","FindEquationalProof","FindEulerianCycle","FindExternalEvaluators","FindFaces","FindFile","FindFit","FindFormula","FindFundamentalCycles","FindGeneratingFunction","FindGeoLocation","FindGeometricConjectures","FindGeometricTransform","FindGraphCommunities","FindGraphIsomorphism","FindGraphPartition","FindHamiltonianCycle","FindHamiltonianPath","FindHiddenMarkovStates","FindImageText","FindIndependentEdgeSet","FindIndependentVertexSet","FindInstance","FindIntegerNullVector","FindKClan","FindKClique","FindKClub","FindKPlex","FindLibrary","FindLinearRecurrence","FindList","FindMatchingColor","FindMaximum","FindMaximumCut","FindMaximumFlow","FindMaxValue","FindMeshDefects","FindMinimum","FindMinimumCostFlow","FindMinimumCut","FindMinValue","FindMoleculeSubstructure","FindPath","FindPeaks","FindPermutation","FindPostmanTour","FindProcessParameters","FindRepeat","FindRoot","FindSequenceFunction","FindSettings","FindShortestPath","FindShortestTour","FindSpanningTree","FindSystemModelEquilibrium","FindTextualAnswer","FindThreshold","FindTransientRepeat","FindVertexCover","FindVertexCut","FindVertexIndependentPaths","Fine","FinishDynamic","FiniteAbelianGroupCount","FiniteGroupCount","FiniteGroupData","First","FirstCase","FirstPassageTimeDistribution","FirstPosition","FischerGroupFi22","FischerGroupFi23","FischerGroupFi24Prime","FisherHypergeometricDistribution","FisherRatioTest","FisherZDistribution","Fit","FitAll","FitRegularization","FittedModel","FixedOrder","FixedPoint","FixedPointList","FlashSelection","Flat","Flatten","FlattenAt","FlattenLayer","FlatTopWindow","FlipView","Floor","FlowPolynomial","FlushPrintOutputPacket","Fold","FoldList","FoldPair","FoldPairList","FollowRedirects","Font","FontColor","FontFamily","FontForm","FontName","FontOpacity","FontPostScriptName","FontProperties","FontReencoding","FontSize","FontSlant","FontSubstitutions","FontTracking","FontVariations","FontWeight","For","ForAll","ForceVersionInstall","Format","FormatRules","FormatType","FormatTypeAutoConvert","FormatValues","FormBox","FormBoxOptions","FormControl","FormFunction","FormLayoutFunction","FormObject","FormPage","FormTheme","FormulaData","FormulaLookup","FortranForm","Forward","ForwardBackward","Fourier","FourierCoefficient","FourierCosCoefficient","FourierCosSeries","FourierCosTransform","FourierDCT","FourierDCTFilter","FourierDCTMatrix","FourierDST","FourierDSTMatrix","FourierMatrix","FourierParameters","FourierSequenceTransform","FourierSeries","FourierSinCoefficient","FourierSinSeries","FourierSinTransform","FourierTransform","FourierTrigSeries","FractionalBrownianMotionProcess","FractionalGaussianNoiseProcess","FractionalPart","FractionBox","FractionBoxOptions","FractionLine","Frame","FrameBox","FrameBoxOptions","Framed","FrameInset","FrameLabel","Frameless","FrameMargins","FrameRate","FrameStyle","FrameTicks","FrameTicksStyle","FRatioDistribution","FrechetDistribution","FreeQ","FrenetSerretSystem","FrequencySamplingFilterKernel","FresnelC","FresnelF","FresnelG","FresnelS","Friday","FrobeniusNumber","FrobeniusSolve","FromAbsoluteTime","FromCharacterCode","FromCoefficientRules","FromContinuedFraction","FromDate","FromDigits","FromDMS","FromEntity","FromJulianDate","FromLetterNumber","FromPolarCoordinates","FromRomanNumeral","FromSphericalCoordinates","FromUnixTime","Front","FrontEndDynamicExpression","FrontEndEventActions","FrontEndExecute","FrontEndObject","FrontEndResource","FrontEndResourceString","FrontEndStackSize","FrontEndToken","FrontEndTokenExecute","FrontEndValueCache","FrontEndVersion","FrontFaceColor","FrontFaceOpacity","Full","FullAxes","FullDefinition","FullForm","FullGraphics","FullInformationOutputRegulator","FullOptions","FullRegion","FullSimplify","Function","FunctionCompile","FunctionCompileExport","FunctionCompileExportByteArray","FunctionCompileExportLibrary","FunctionCompileExportString","FunctionDomain","FunctionExpand","FunctionInterpolation","FunctionPeriod","FunctionRange","FunctionSpace","FussellVeselyImportance","GaborFilter","GaborMatrix","GaborWavelet","GainMargins","GainPhaseMargins","GalaxyData","GalleryView","Gamma","GammaDistribution","GammaRegularized","GapPenalty","GARCHProcess","GatedRecurrentLayer","Gather","GatherBy","GaugeFaceElementFunction","GaugeFaceStyle","GaugeFrameElementFunction","GaugeFrameSize","GaugeFrameStyle","GaugeLabels","GaugeMarkers","GaugeStyle","GaussianFilter","GaussianIntegers","GaussianMatrix","GaussianOrthogonalMatrixDistribution","GaussianSymplecticMatrixDistribution","GaussianUnitaryMatrixDistribution","GaussianWindow","GCD","GegenbauerC","General","GeneralizedLinearModelFit","GenerateAsymmetricKeyPair","GenerateConditions","GeneratedCell","GeneratedDocumentBinding","GenerateDerivedKey","GenerateDigitalSignature","GenerateDocument","GeneratedParameters","GeneratedQuantityMagnitudes","GenerateFileSignature","GenerateHTTPResponse","GenerateSecuredAuthenticationKey","GenerateSymmetricKey","GeneratingFunction","GeneratorDescription","GeneratorHistoryLength","GeneratorOutputType","Generic","GenericCylindricalDecomposition","GenomeData","GenomeLookup","GeoAntipode","GeoArea","GeoArraySize","GeoBackground","GeoBoundingBox","GeoBounds","GeoBoundsRegion","GeoBubbleChart","GeoCenter","GeoCircle","GeoContourPlot","GeoDensityPlot","GeodesicClosing","GeodesicDilation","GeodesicErosion","GeodesicOpening","GeoDestination","GeodesyData","GeoDirection","GeoDisk","GeoDisplacement","GeoDistance","GeoDistanceList","GeoElevationData","GeoEntities","GeoGraphics","GeogravityModelData","GeoGridDirectionDifference","GeoGridLines","GeoGridLinesStyle","GeoGridPosition","GeoGridRange","GeoGridRangePadding","GeoGridUnitArea","GeoGridUnitDistance","GeoGridVector","GeoGroup","GeoHemisphere","GeoHemisphereBoundary","GeoHistogram","GeoIdentify","GeoImage","GeoLabels","GeoLength","GeoListPlot","GeoLocation","GeologicalPeriodData","GeomagneticModelData","GeoMarker","GeometricAssertion","GeometricBrownianMotionProcess","GeometricDistribution","GeometricMean","GeometricMeanFilter","GeometricOptimization","GeometricScene","GeometricTransformation","GeometricTransformation3DBox","GeometricTransformation3DBoxOptions","GeometricTransformationBox","GeometricTransformationBoxOptions","GeoModel","GeoNearest","GeoPath","GeoPosition","GeoPositionENU","GeoPositionXYZ","GeoProjection","GeoProjectionData","GeoRange","GeoRangePadding","GeoRegionValuePlot","GeoResolution","GeoScaleBar","GeoServer","GeoSmoothHistogram","GeoStreamPlot","GeoStyling","GeoStylingImageFunction","GeoVariant","GeoVector","GeoVectorENU","GeoVectorPlot","GeoVectorXYZ","GeoVisibleRegion","GeoVisibleRegionBoundary","GeoWithinQ","GeoZoomLevel","GestureHandler","GestureHandlerTag","Get","GetBoundingBoxSizePacket","GetContext","GetEnvironment","GetFileName","GetFrontEndOptionsDataPacket","GetLinebreakInformationPacket","GetMenusPacket","GetPageBreakInformationPacket","Glaisher","GlobalClusteringCoefficient","GlobalPreferences","GlobalSession","Glow","GoldenAngle","GoldenRatio","GompertzMakehamDistribution","GoochShading","GoodmanKruskalGamma","GoodmanKruskalGammaTest","Goto","Grad","Gradient","GradientFilter","GradientOrientationFilter","GrammarApply","GrammarRules","GrammarToken","Graph","Graph3D","GraphAssortativity","GraphAutomorphismGroup","GraphCenter","GraphComplement","GraphData","GraphDensity","GraphDiameter","GraphDifference","GraphDisjointUnion","GraphDistance","GraphDistanceMatrix","GraphElementData","GraphEmbedding","GraphHighlight","GraphHighlightStyle","GraphHub","Graphics","Graphics3D","Graphics3DBox","Graphics3DBoxOptions","GraphicsArray","GraphicsBaseline","GraphicsBox","GraphicsBoxOptions","GraphicsColor","GraphicsColumn","GraphicsComplex","GraphicsComplex3DBox","GraphicsComplex3DBoxOptions","GraphicsComplexBox","GraphicsComplexBoxOptions","GraphicsContents","GraphicsData","GraphicsGrid","GraphicsGridBox","GraphicsGroup","GraphicsGroup3DBox","GraphicsGroup3DBoxOptions","GraphicsGroupBox","GraphicsGroupBoxOptions","GraphicsGrouping","GraphicsHighlightColor","GraphicsRow","GraphicsSpacing","GraphicsStyle","GraphIntersection","GraphLayout","GraphLinkEfficiency","GraphPeriphery","GraphPlot","GraphPlot3D","GraphPower","GraphPropertyDistribution","GraphQ","GraphRadius","GraphReciprocity","GraphRoot","GraphStyle","GraphUnion","Gray","GrayLevel","Greater","GreaterEqual","GreaterEqualLess","GreaterEqualThan","GreaterFullEqual","GreaterGreater","GreaterLess","GreaterSlantEqual","GreaterThan","GreaterTilde","Green","GreenFunction","Grid","GridBaseline","GridBox","GridBoxAlignment","GridBoxBackground","GridBoxDividers","GridBoxFrame","GridBoxItemSize","GridBoxItemStyle","GridBoxOptions","GridBoxSpacings","GridCreationSettings","GridDefaultElement","GridElementStyleOptions","GridFrame","GridFrameMargins","GridGraph","GridLines","GridLinesStyle","GroebnerBasis","GroupActionBase","GroupBy","GroupCentralizer","GroupElementFromWord","GroupElementPosition","GroupElementQ","GroupElements","GroupElementToWord","GroupGenerators","Groupings","GroupMultiplicationTable","GroupOrbits","GroupOrder","GroupPageBreakWithin","GroupSetwiseStabilizer","GroupStabilizer","GroupStabilizerChain","GroupTogetherGrouping","GroupTogetherNestedGrouping","GrowCutComponents","Gudermannian","GuidedFilter","GumbelDistribution","HaarWavelet","HadamardMatrix","HalfLine","HalfNormalDistribution","HalfPlane","HalfSpace","HalftoneShading","HamiltonianGraphQ","HammingDistance","HammingWindow","HandlerFunctions","HandlerFunctionsKeys","HankelH1","HankelH2","HankelMatrix","HankelTransform","HannPoissonWindow","HannWindow","HaradaNortonGroupHN","HararyGraph","HarmonicMean","HarmonicMeanFilter","HarmonicNumber","Hash","HatchFilling","HatchShading","Haversine","HazardFunction","Head","HeadCompose","HeaderAlignment","HeaderBackground","HeaderDisplayFunction","HeaderLines","HeaderSize","HeaderStyle","Heads","HeavisideLambda","HeavisidePi","HeavisideTheta","HeldGroupHe","HeldPart","HelpBrowserLookup","HelpBrowserNotebook","HelpBrowserSettings","Here","HermiteDecomposition","HermiteH","HermitianMatrixQ","HessenbergDecomposition","Hessian","HeunB","HeunBPrime","HeunC","HeunCPrime","HeunD","HeunDPrime","HeunG","HeunGPrime","HeunT","HeunTPrime","HexadecimalCharacter","Hexahedron","HexahedronBox","HexahedronBoxOptions","HiddenItems","HiddenMarkovProcess","HiddenSurface","Highlighted","HighlightGraph","HighlightImage","HighlightMesh","HighpassFilter","HigmanSimsGroupHS","HilbertCurve","HilbertFilter","HilbertMatrix","Histogram","Histogram3D","HistogramDistribution","HistogramList","HistogramTransform","HistogramTransformInterpolation","HistoricalPeriodData","HitMissTransform","HITSCentrality","HjorthDistribution","HodgeDual","HoeffdingD","HoeffdingDTest","Hold","HoldAll","HoldAllComplete","HoldComplete","HoldFirst","HoldForm","HoldPattern","HoldRest","HolidayCalendar","HomeDirectory","HomePage","Horizontal","HorizontalForm","HorizontalGauge","HorizontalScrollPosition","HornerForm","HostLookup","HotellingTSquareDistribution","HoytDistribution","HTMLSave","HTTPErrorResponse","HTTPRedirect","HTTPRequest","HTTPRequestData","HTTPResponse","Hue","HumanGrowthData","HumpDownHump","HumpEqual","HurwitzLerchPhi","HurwitzZeta","HyperbolicDistribution","HypercubeGraph","HyperexponentialDistribution","Hyperfactorial","Hypergeometric0F1","Hypergeometric0F1Regularized","Hypergeometric1F1","Hypergeometric1F1Regularized","Hypergeometric2F1","Hypergeometric2F1Regularized","HypergeometricDistribution","HypergeometricPFQ","HypergeometricPFQRegularized","HypergeometricU","Hyperlink","HyperlinkAction","HyperlinkCreationSettings","Hyperplane","Hyphenation","HyphenationOptions","HypoexponentialDistribution","HypothesisTestData","I","IconData","Iconize","IconizedObject","IconRules","Icosahedron","Identity","IdentityMatrix","If","IgnoreCase","IgnoreDiacritics","IgnorePunctuation","IgnoreSpellCheck","IgnoringInactive","Im","Image","Image3D","Image3DProjection","Image3DSlices","ImageAccumulate","ImageAdd","ImageAdjust","ImageAlign","ImageApply","ImageApplyIndexed","ImageAspectRatio","ImageAssemble","ImageAugmentationLayer","ImageBoundingBoxes","ImageCache","ImageCacheValid","ImageCapture","ImageCaptureFunction","ImageCases","ImageChannels","ImageClip","ImageCollage","ImageColorSpace","ImageCompose","ImageContainsQ","ImageContents","ImageConvolve","ImageCooccurrence","ImageCorners","ImageCorrelate","ImageCorrespondingPoints","ImageCrop","ImageData","ImageDeconvolve","ImageDemosaic","ImageDifference","ImageDimensions","ImageDisplacements","ImageDistance","ImageEffect","ImageExposureCombine","ImageFeatureTrack","ImageFileApply","ImageFileFilter","ImageFileScan","ImageFilter","ImageFocusCombine","ImageForestingComponents","ImageFormattingWidth","ImageForwardTransformation","ImageGraphics","ImageHistogram","ImageIdentify","ImageInstanceQ","ImageKeypoints","ImageLabels","ImageLegends","ImageLevels","ImageLines","ImageMargins","ImageMarker","ImageMarkers","ImageMeasurements","ImageMesh","ImageMultiply","ImageOffset","ImagePad","ImagePadding","ImagePartition","ImagePeriodogram","ImagePerspectiveTransformation","ImagePosition","ImagePreviewFunction","ImagePyramid","ImagePyramidApply","ImageQ","ImageRangeCache","ImageRecolor","ImageReflect","ImageRegion","ImageResize","ImageResolution","ImageRestyle","ImageRotate","ImageRotated","ImageSaliencyFilter","ImageScaled","ImageScan","ImageSize","ImageSizeAction","ImageSizeCache","ImageSizeMultipliers","ImageSizeRaw","ImageSubtract","ImageTake","ImageTransformation","ImageTrim","ImageType","ImageValue","ImageValuePositions","ImagingDevice","ImplicitRegion","Implies","Import","ImportAutoReplacements","ImportByteArray","ImportOptions","ImportString","ImprovementImportance","In","Inactivate","Inactive","IncidenceGraph","IncidenceList","IncidenceMatrix","IncludeAromaticBonds","IncludeConstantBasis","IncludeDefinitions","IncludeDirectories","IncludeFileExtension","IncludeGeneratorTasks","IncludeHydrogens","IncludeInflections","IncludeMetaInformation","IncludePods","IncludeQuantities","IncludeRelatedTables","IncludeSingularTerm","IncludeWindowTimes","Increment","IndefiniteMatrixQ","Indent","IndentingNewlineSpacings","IndentMaxFraction","IndependenceTest","IndependentEdgeSetQ","IndependentPhysicalQuantity","IndependentUnit","IndependentUnitDimension","IndependentVertexSetQ","Indeterminate","IndeterminateThreshold","IndexCreationOptions","Indexed","IndexEdgeTaggedGraph","IndexGraph","IndexTag","Inequality","InexactNumberQ","InexactNumbers","InfiniteFuture","InfiniteLine","InfinitePast","InfinitePlane","Infinity","Infix","InflationAdjust","InflationMethod","Information","InformationData","InformationDataGrid","Inherited","InheritScope","InhomogeneousPoissonProcess","InitialEvaluationHistory","Initialization","InitializationCell","InitializationCellEvaluation","InitializationCellWarning","InitializationObjects","InitializationValue","Initialize","InitialSeeding","InlineCounterAssignments","InlineCounterIncrements","InlineRules","Inner","InnerPolygon","InnerPolyhedron","Inpaint","Input","InputAliases","InputAssumptions","InputAutoReplacements","InputField","InputFieldBox","InputFieldBoxOptions","InputForm","InputGrouping","InputNamePacket","InputNotebook","InputPacket","InputSettings","InputStream","InputString","InputStringPacket","InputToBoxFormPacket","Insert","InsertionFunction","InsertionPointObject","InsertLinebreaks","InsertResults","Inset","Inset3DBox","Inset3DBoxOptions","InsetBox","InsetBoxOptions","Insphere","Install","InstallService","InstanceNormalizationLayer","InString","Integer","IntegerDigits","IntegerExponent","IntegerLength","IntegerName","IntegerPart","IntegerPartitions","IntegerQ","IntegerReverse","Integers","IntegerString","Integral","Integrate","Interactive","InteractiveTradingChart","Interlaced","Interleaving","InternallyBalancedDecomposition","InterpolatingFunction","InterpolatingPolynomial","Interpolation","InterpolationOrder","InterpolationPoints","InterpolationPrecision","Interpretation","InterpretationBox","InterpretationBoxOptions","InterpretationFunction","Interpreter","InterpretTemplate","InterquartileRange","Interrupt","InterruptSettings","IntersectedEntityClass","IntersectingQ","Intersection","Interval","IntervalIntersection","IntervalMarkers","IntervalMarkersStyle","IntervalMemberQ","IntervalSlider","IntervalUnion","Into","Inverse","InverseBetaRegularized","InverseCDF","InverseChiSquareDistribution","InverseContinuousWaveletTransform","InverseDistanceTransform","InverseEllipticNomeQ","InverseErf","InverseErfc","InverseFourier","InverseFourierCosTransform","InverseFourierSequenceTransform","InverseFourierSinTransform","InverseFourierTransform","InverseFunction","InverseFunctions","InverseGammaDistribution","InverseGammaRegularized","InverseGaussianDistribution","InverseGudermannian","InverseHankelTransform","InverseHaversine","InverseImagePyramid","InverseJacobiCD","InverseJacobiCN","InverseJacobiCS","InverseJacobiDC","InverseJacobiDN","InverseJacobiDS","InverseJacobiNC","InverseJacobiND","InverseJacobiNS","InverseJacobiSC","InverseJacobiSD","InverseJacobiSN","InverseLaplaceTransform","InverseMellinTransform","InversePermutation","InverseRadon","InverseRadonTransform","InverseSeries","InverseShortTimeFourier","InverseSpectrogram","InverseSurvivalFunction","InverseTransformedRegion","InverseWaveletTransform","InverseWeierstrassP","InverseWishartMatrixDistribution","InverseZTransform","Invisible","InvisibleApplication","InvisibleTimes","IPAddress","IrreduciblePolynomialQ","IslandData","IsolatingInterval","IsomorphicGraphQ","IsotopeData","Italic","Item","ItemAspectRatio","ItemBox","ItemBoxOptions","ItemDisplayFunction","ItemSize","ItemStyle","ItoProcess","JaccardDissimilarity","JacobiAmplitude","Jacobian","JacobiCD","JacobiCN","JacobiCS","JacobiDC","JacobiDN","JacobiDS","JacobiNC","JacobiND","JacobiNS","JacobiP","JacobiSC","JacobiSD","JacobiSN","JacobiSymbol","JacobiZeta","JankoGroupJ1","JankoGroupJ2","JankoGroupJ3","JankoGroupJ4","JarqueBeraALMTest","JohnsonDistribution","Join","JoinAcross","Joined","JoinedCurve","JoinedCurveBox","JoinedCurveBoxOptions","JoinForm","JordanDecomposition","JordanModelDecomposition","JulianDate","JuliaSetBoettcher","JuliaSetIterationCount","JuliaSetPlot","JuliaSetPoints","K","KagiChart","KaiserBesselWindow","KaiserWindow","KalmanEstimator","KalmanFilter","KarhunenLoeveDecomposition","KaryTree","KatzCentrality","KCoreComponents","KDistribution","KEdgeConnectedComponents","KEdgeConnectedGraphQ","KeepExistingVersion","KelvinBei","KelvinBer","KelvinKei","KelvinKer","KendallTau","KendallTauTest","KernelExecute","KernelFunction","KernelMixtureDistribution","KernelObject","Kernels","Ket","Key","KeyCollisionFunction","KeyComplement","KeyDrop","KeyDropFrom","KeyExistsQ","KeyFreeQ","KeyIntersection","KeyMap","KeyMemberQ","KeypointStrength","Keys","KeySelect","KeySort","KeySortBy","KeyTake","KeyUnion","KeyValueMap","KeyValuePattern","Khinchin","KillProcess","KirchhoffGraph","KirchhoffMatrix","KleinInvariantJ","KnapsackSolve","KnightTourGraph","KnotData","KnownUnitQ","KochCurve","KolmogorovSmirnovTest","KroneckerDelta","KroneckerModelDecomposition","KroneckerProduct","KroneckerSymbol","KuiperTest","KumaraswamyDistribution","Kurtosis","KuwaharaFilter","KVertexConnectedComponents","KVertexConnectedGraphQ","LABColor","Label","Labeled","LabeledSlider","LabelingFunction","LabelingSize","LabelStyle","LabelVisibility","LaguerreL","LakeData","LambdaComponents","LambertW","LaminaData","LanczosWindow","LandauDistribution","Language","LanguageCategory","LanguageData","LanguageIdentify","LanguageOptions","LaplaceDistribution","LaplaceTransform","Laplacian","LaplacianFilter","LaplacianGaussianFilter","Large","Larger","Last","Latitude","LatitudeLongitude","LatticeData","LatticeReduce","Launch","LaunchKernels","LayeredGraphPlot","LayerSizeFunction","LayoutInformation","LCHColor","LCM","LeaderSize","LeafCount","LeapYearQ","LearnDistribution","LearnedDistribution","LearningRate","LearningRateMultipliers","LeastSquares","LeastSquaresFilterKernel","Left","LeftArrow","LeftArrowBar","LeftArrowRightArrow","LeftDownTeeVector","LeftDownVector","LeftDownVectorBar","LeftRightArrow","LeftRightVector","LeftTee","LeftTeeArrow","LeftTeeVector","LeftTriangle","LeftTriangleBar","LeftTriangleEqual","LeftUpDownVector","LeftUpTeeVector","LeftUpVector","LeftUpVectorBar","LeftVector","LeftVectorBar","LegendAppearance","Legended","LegendFunction","LegendLabel","LegendLayout","LegendMargins","LegendMarkers","LegendMarkerSize","LegendreP","LegendreQ","LegendreType","Length","LengthWhile","LerchPhi","Less","LessEqual","LessEqualGreater","LessEqualThan","LessFullEqual","LessGreater","LessLess","LessSlantEqual","LessThan","LessTilde","LetterCharacter","LetterCounts","LetterNumber","LetterQ","Level","LeveneTest","LeviCivitaTensor","LevyDistribution","Lexicographic","LibraryDataType","LibraryFunction","LibraryFunctionError","LibraryFunctionInformation","LibraryFunctionLoad","LibraryFunctionUnload","LibraryLoad","LibraryUnload","LicenseID","LiftingFilterData","LiftingWaveletTransform","LightBlue","LightBrown","LightCyan","Lighter","LightGray","LightGreen","Lighting","LightingAngle","LightMagenta","LightOrange","LightPink","LightPurple","LightRed","LightSources","LightYellow","Likelihood","Limit","LimitsPositioning","LimitsPositioningTokens","LindleyDistribution","Line","Line3DBox","Line3DBoxOptions","LinearFilter","LinearFractionalOptimization","LinearFractionalTransform","LinearGradientImage","LinearizingTransformationData","LinearLayer","LinearModelFit","LinearOffsetFunction","LinearOptimization","LinearProgramming","LinearRecurrence","LinearSolve","LinearSolveFunction","LineBox","LineBoxOptions","LineBreak","LinebreakAdjustments","LineBreakChart","LinebreakSemicolonWeighting","LineBreakWithin","LineColor","LineGraph","LineIndent","LineIndentMaxFraction","LineIntegralConvolutionPlot","LineIntegralConvolutionScale","LineLegend","LineOpacity","LineSpacing","LineWrapParts","LinkActivate","LinkClose","LinkConnect","LinkConnectedQ","LinkCreate","LinkError","LinkFlush","LinkFunction","LinkHost","LinkInterrupt","LinkLaunch","LinkMode","LinkObject","LinkOpen","LinkOptions","LinkPatterns","LinkProtocol","LinkRankCentrality","LinkRead","LinkReadHeld","LinkReadyQ","Links","LinkService","LinkWrite","LinkWriteHeld","LiouvilleLambda","List","Listable","ListAnimate","ListContourPlot","ListContourPlot3D","ListConvolve","ListCorrelate","ListCurvePathPlot","ListDeconvolve","ListDensityPlot","ListDensityPlot3D","Listen","ListFormat","ListFourierSequenceTransform","ListInterpolation","ListLineIntegralConvolutionPlot","ListLinePlot","ListLogLinearPlot","ListLogLogPlot","ListLogPlot","ListPicker","ListPickerBox","ListPickerBoxBackground","ListPickerBoxOptions","ListPlay","ListPlot","ListPlot3D","ListPointPlot3D","ListPolarPlot","ListQ","ListSliceContourPlot3D","ListSliceDensityPlot3D","ListSliceVectorPlot3D","ListStepPlot","ListStreamDensityPlot","ListStreamPlot","ListSurfacePlot3D","ListVectorDensityPlot","ListVectorPlot","ListVectorPlot3D","ListZTransform","Literal","LiteralSearch","LocalAdaptiveBinarize","LocalCache","LocalClusteringCoefficient","LocalizeDefinitions","LocalizeVariables","LocalObject","LocalObjects","LocalResponseNormalizationLayer","LocalSubmit","LocalSymbol","LocalTime","LocalTimeZone","LocationEquivalenceTest","LocationTest","Locator","LocatorAutoCreate","LocatorBox","LocatorBoxOptions","LocatorCentering","LocatorPane","LocatorPaneBox","LocatorPaneBoxOptions","LocatorRegion","Locked","Log","Log10","Log2","LogBarnesG","LogGamma","LogGammaDistribution","LogicalExpand","LogIntegral","LogisticDistribution","LogisticSigmoid","LogitModelFit","LogLikelihood","LogLinearPlot","LogLogisticDistribution","LogLogPlot","LogMultinormalDistribution","LogNormalDistribution","LogPlot","LogRankTest","LogSeriesDistribution","LongEqual","Longest","LongestCommonSequence","LongestCommonSequencePositions","LongestCommonSubsequence","LongestCommonSubsequencePositions","LongestMatch","LongestOrderedSequence","LongForm","Longitude","LongLeftArrow","LongLeftRightArrow","LongRightArrow","LongShortTermMemoryLayer","Lookup","Loopback","LoopFreeGraphQ","Looping","LossFunction","LowerCaseQ","LowerLeftArrow","LowerRightArrow","LowerTriangularize","LowerTriangularMatrixQ","LowpassFilter","LQEstimatorGains","LQGRegulator","LQOutputRegulatorGains","LQRegulatorGains","LUBackSubstitution","LucasL","LuccioSamiComponents","LUDecomposition","LunarEclipse","LUVColor","LyapunovSolve","LyonsGroupLy","MachineID","MachineName","MachineNumberQ","MachinePrecision","MacintoshSystemPageSetup","Magenta","Magnification","Magnify","MailAddressValidation","MailExecute","MailFolder","MailItem","MailReceiverFunction","MailResponseFunction","MailSearch","MailServerConnect","MailServerConnection","MailSettings","MainSolve","MaintainDynamicCaches","Majority","MakeBoxes","MakeExpression","MakeRules","ManagedLibraryExpressionID","ManagedLibraryExpressionQ","MandelbrotSetBoettcher","MandelbrotSetDistance","MandelbrotSetIterationCount","MandelbrotSetMemberQ","MandelbrotSetPlot","MangoldtLambda","ManhattanDistance","Manipulate","Manipulator","MannedSpaceMissionData","MannWhitneyTest","MantissaExponent","Manual","Map","MapAll","MapAt","MapIndexed","MAProcess","MapThread","MarchenkoPasturDistribution","MarcumQ","MardiaCombinedTest","MardiaKurtosisTest","MardiaSkewnessTest","MarginalDistribution","MarkovProcessProperties","Masking","MatchingDissimilarity","MatchLocalNameQ","MatchLocalNames","MatchQ","Material","MathematicalFunctionData","MathematicaNotation","MathieuC","MathieuCharacteristicA","MathieuCharacteristicB","MathieuCharacteristicExponent","MathieuCPrime","MathieuGroupM11","MathieuGroupM12","MathieuGroupM22","MathieuGroupM23","MathieuGroupM24","MathieuS","MathieuSPrime","MathMLForm","MathMLText","Matrices","MatrixExp","MatrixForm","MatrixFunction","MatrixLog","MatrixNormalDistribution","MatrixPlot","MatrixPower","MatrixPropertyDistribution","MatrixQ","MatrixRank","MatrixTDistribution","Max","MaxBend","MaxCellMeasure","MaxColorDistance","MaxDate","MaxDetect","MaxDuration","MaxExtraBandwidths","MaxExtraConditions","MaxFeatureDisplacement","MaxFeatures","MaxFilter","MaximalBy","Maximize","MaxItems","MaxIterations","MaxLimit","MaxMemoryUsed","MaxMixtureKernels","MaxOverlapFraction","MaxPlotPoints","MaxPoints","MaxRecursion","MaxStableDistribution","MaxStepFraction","MaxSteps","MaxStepSize","MaxTrainingRounds","MaxValue","MaxwellDistribution","MaxWordGap","McLaughlinGroupMcL","Mean","MeanAbsoluteLossLayer","MeanAround","MeanClusteringCoefficient","MeanDegreeConnectivity","MeanDeviation","MeanFilter","MeanGraphDistance","MeanNeighborDegree","MeanShift","MeanShiftFilter","MeanSquaredLossLayer","Median","MedianDeviation","MedianFilter","MedicalTestData","Medium","MeijerG","MeijerGReduce","MeixnerDistribution","MellinConvolve","MellinTransform","MemberQ","MemoryAvailable","MemoryConstrained","MemoryConstraint","MemoryInUse","MengerMesh","Menu","MenuAppearance","MenuCommandKey","MenuEvaluator","MenuItem","MenuList","MenuPacket","MenuSortingValue","MenuStyle","MenuView","Merge","MergeDifferences","MergingFunction","MersennePrimeExponent","MersennePrimeExponentQ","Mesh","MeshCellCentroid","MeshCellCount","MeshCellHighlight","MeshCellIndex","MeshCellLabel","MeshCellMarker","MeshCellMeasure","MeshCellQuality","MeshCells","MeshCellShapeFunction","MeshCellStyle","MeshConnectivityGraph","MeshCoordinates","MeshFunctions","MeshPrimitives","MeshQualityGoal","MeshRange","MeshRefinementFunction","MeshRegion","MeshRegionQ","MeshShading","MeshStyle","Message","MessageDialog","MessageList","MessageName","MessageObject","MessageOptions","MessagePacket","Messages","MessagesNotebook","MetaCharacters","MetaInformation","MeteorShowerData","Method","MethodOptions","MexicanHatWavelet","MeyerWavelet","Midpoint","Min","MinColorDistance","MinDate","MinDetect","MineralData","MinFilter","MinimalBy","MinimalPolynomial","MinimalStateSpaceModel","Minimize","MinimumTimeIncrement","MinIntervalSize","MinkowskiQuestionMark","MinLimit","MinMax","MinorPlanetData","Minors","MinRecursion","MinSize","MinStableDistribution","Minus","MinusPlus","MinValue","Missing","MissingBehavior","MissingDataMethod","MissingDataRules","MissingQ","MissingString","MissingStyle","MissingValuePattern","MittagLefflerE","MixedFractionParts","MixedGraphQ","MixedMagnitude","MixedRadix","MixedRadixQuantity","MixedUnit","MixtureDistribution","Mod","Modal","Mode","Modular","ModularInverse","ModularLambda","Module","Modulus","MoebiusMu","Molecule","MoleculeContainsQ","MoleculeEquivalentQ","MoleculeGraph","MoleculeModify","MoleculePattern","MoleculePlot","MoleculePlot3D","MoleculeProperty","MoleculeQ","MoleculeRecognize","MoleculeValue","Moment","Momentary","MomentConvert","MomentEvaluate","MomentGeneratingFunction","MomentOfInertia","Monday","Monitor","MonomialList","MonomialOrder","MonsterGroupM","MoonPhase","MoonPosition","MorletWavelet","MorphologicalBinarize","MorphologicalBranchPoints","MorphologicalComponents","MorphologicalEulerNumber","MorphologicalGraph","MorphologicalPerimeter","MorphologicalTransform","MortalityData","Most","MountainData","MouseAnnotation","MouseAppearance","MouseAppearanceTag","MouseButtons","Mouseover","MousePointerNote","MousePosition","MovieData","MovingAverage","MovingMap","MovingMedian","MoyalDistribution","Multicolumn","MultiedgeStyle","MultigraphQ","MultilaunchWarning","MultiLetterItalics","MultiLetterStyle","MultilineFunction","Multinomial","MultinomialDistribution","MultinormalDistribution","MultiplicativeOrder","Multiplicity","MultiplySides","Multiselection","MultivariateHypergeometricDistribution","MultivariatePoissonDistribution","MultivariateTDistribution","N","NakagamiDistribution","NameQ","Names","NamespaceBox","NamespaceBoxOptions","Nand","NArgMax","NArgMin","NBernoulliB","NBodySimulation","NBodySimulationData","NCache","NDEigensystem","NDEigenvalues","NDSolve","NDSolveValue","Nearest","NearestFunction","NearestMeshCells","NearestNeighborGraph","NearestTo","NebulaData","NeedCurrentFrontEndPackagePacket","NeedCurrentFrontEndSymbolsPacket","NeedlemanWunschSimilarity","Needs","Negative","NegativeBinomialDistribution","NegativeDefiniteMatrixQ","NegativeIntegers","NegativeMultinomialDistribution","NegativeRationals","NegativeReals","NegativeSemidefiniteMatrixQ","NeighborhoodData","NeighborhoodGraph","Nest","NestedGreaterGreater","NestedLessLess","NestedScriptRules","NestGraph","NestList","NestWhile","NestWhileList","NetAppend","NetBidirectionalOperator","NetChain","NetDecoder","NetDelete","NetDrop","NetEncoder","NetEvaluationMode","NetExtract","NetFlatten","NetFoldOperator","NetGANOperator","NetGraph","NetInformation","NetInitialize","NetInsert","NetInsertSharedArrays","NetJoin","NetMapOperator","NetMapThreadOperator","NetMeasurements","NetModel","NetNestOperator","NetPairEmbeddingOperator","NetPort","NetPortGradient","NetPrepend","NetRename","NetReplace","NetReplacePart","NetSharedArray","NetStateObject","NetTake","NetTrain","NetTrainResultsObject","NetworkPacketCapture","NetworkPacketRecording","NetworkPacketRecordingDuring","NetworkPacketTrace","NeumannValue","NevilleThetaC","NevilleThetaD","NevilleThetaN","NevilleThetaS","NewPrimitiveStyle","NExpectation","Next","NextCell","NextDate","NextPrime","NextScheduledTaskTime","NHoldAll","NHoldFirst","NHoldRest","NicholsGridLines","NicholsPlot","NightHemisphere","NIntegrate","NMaximize","NMaxValue","NMinimize","NMinValue","NominalVariables","NonAssociative","NoncentralBetaDistribution","NoncentralChiSquareDistribution","NoncentralFRatioDistribution","NoncentralStudentTDistribution","NonCommutativeMultiply","NonConstants","NondimensionalizationTransform","None","NoneTrue","NonlinearModelFit","NonlinearStateSpaceModel","NonlocalMeansFilter","NonNegative","NonNegativeIntegers","NonNegativeRationals","NonNegativeReals","NonPositive","NonPositiveIntegers","NonPositiveRationals","NonPositiveReals","Nor","NorlundB","Norm","Normal","NormalDistribution","NormalGrouping","NormalizationLayer","Normalize","Normalized","NormalizedSquaredEuclideanDistance","NormalMatrixQ","NormalsFunction","NormFunction","Not","NotCongruent","NotCupCap","NotDoubleVerticalBar","Notebook","NotebookApply","NotebookAutoSave","NotebookClose","NotebookConvertSettings","NotebookCreate","NotebookCreateReturnObject","NotebookDefault","NotebookDelete","NotebookDirectory","NotebookDynamicExpression","NotebookEvaluate","NotebookEventActions","NotebookFileName","NotebookFind","NotebookFindReturnObject","NotebookGet","NotebookGetLayoutInformationPacket","NotebookGetMisspellingsPacket","NotebookImport","NotebookInformation","NotebookInterfaceObject","NotebookLocate","NotebookObject","NotebookOpen","NotebookOpenReturnObject","NotebookPath","NotebookPrint","NotebookPut","NotebookPutReturnObject","NotebookRead","NotebookResetGeneratedCells","Notebooks","NotebookSave","NotebookSaveAs","NotebookSelection","NotebookSetupLayoutInformationPacket","NotebooksMenu","NotebookTemplate","NotebookWrite","NotElement","NotEqualTilde","NotExists","NotGreater","NotGreaterEqual","NotGreaterFullEqual","NotGreaterGreater","NotGreaterLess","NotGreaterSlantEqual","NotGreaterTilde","Nothing","NotHumpDownHump","NotHumpEqual","NotificationFunction","NotLeftTriangle","NotLeftTriangleBar","NotLeftTriangleEqual","NotLess","NotLessEqual","NotLessFullEqual","NotLessGreater","NotLessLess","NotLessSlantEqual","NotLessTilde","NotNestedGreaterGreater","NotNestedLessLess","NotPrecedes","NotPrecedesEqual","NotPrecedesSlantEqual","NotPrecedesTilde","NotReverseElement","NotRightTriangle","NotRightTriangleBar","NotRightTriangleEqual","NotSquareSubset","NotSquareSubsetEqual","NotSquareSuperset","NotSquareSupersetEqual","NotSubset","NotSubsetEqual","NotSucceeds","NotSucceedsEqual","NotSucceedsSlantEqual","NotSucceedsTilde","NotSuperset","NotSupersetEqual","NotTilde","NotTildeEqual","NotTildeFullEqual","NotTildeTilde","NotVerticalBar","Now","NoWhitespace","NProbability","NProduct","NProductFactors","NRoots","NSolve","NSum","NSumTerms","NuclearExplosionData","NuclearReactorData","Null","NullRecords","NullSpace","NullWords","Number","NumberCompose","NumberDecompose","NumberExpand","NumberFieldClassNumber","NumberFieldDiscriminant","NumberFieldFundamentalUnits","NumberFieldIntegralBasis","NumberFieldNormRepresentatives","NumberFieldRegulator","NumberFieldRootsOfUnity","NumberFieldSignature","NumberForm","NumberFormat","NumberLinePlot","NumberMarks","NumberMultiplier","NumberPadding","NumberPoint","NumberQ","NumberSeparator","NumberSigns","NumberString","Numerator","NumeratorDenominator","NumericalOrder","NumericalSort","NumericArray","NumericArrayQ","NumericArrayType","NumericFunction","NumericQ","NuttallWindow","NValues","NyquistGridLines","NyquistPlot","O","ObservabilityGramian","ObservabilityMatrix","ObservableDecomposition","ObservableModelQ","OceanData","Octahedron","OddQ","Off","Offset","OLEData","On","ONanGroupON","Once","OneIdentity","Opacity","OpacityFunction","OpacityFunctionScaling","Open","OpenAppend","Opener","OpenerBox","OpenerBoxOptions","OpenerView","OpenFunctionInspectorPacket","Opening","OpenRead","OpenSpecialOptions","OpenTemporary","OpenWrite","Operate","OperatingSystem","OperatorApplied","OptimumFlowData","Optional","OptionalElement","OptionInspectorSettings","OptionQ","Options","OptionsPacket","OptionsPattern","OptionValue","OptionValueBox","OptionValueBoxOptions","Or","Orange","Order","OrderDistribution","OrderedQ","Ordering","OrderingBy","OrderingLayer","Orderless","OrderlessPatternSequence","OrnsteinUhlenbeckProcess","Orthogonalize","OrthogonalMatrixQ","Out","Outer","OuterPolygon","OuterPolyhedron","OutputAutoOverwrite","OutputControllabilityMatrix","OutputControllableModelQ","OutputForm","OutputFormData","OutputGrouping","OutputMathEditExpression","OutputNamePacket","OutputResponse","OutputSizeLimit","OutputStream","Over","OverBar","OverDot","Overflow","OverHat","Overlaps","Overlay","OverlayBox","OverlayBoxOptions","Overscript","OverscriptBox","OverscriptBoxOptions","OverTilde","OverVector","OverwriteTarget","OwenT","OwnValues","Package","PackingMethod","PackPaclet","PacletDataRebuild","PacletDirectoryAdd","PacletDirectoryLoad","PacletDirectoryRemove","PacletDirectoryUnload","PacletDisable","PacletEnable","PacletFind","PacletFindRemote","PacletInformation","PacletInstall","PacletInstallSubmit","PacletNewerQ","PacletObject","PacletObjectQ","PacletSite","PacletSiteObject","PacletSiteRegister","PacletSites","PacletSiteUnregister","PacletSiteUpdate","PacletUninstall","PacletUpdate","PaddedForm","Padding","PaddingLayer","PaddingSize","PadeApproximant","PadLeft","PadRight","PageBreakAbove","PageBreakBelow","PageBreakWithin","PageFooterLines","PageFooters","PageHeaderLines","PageHeaders","PageHeight","PageRankCentrality","PageTheme","PageWidth","Pagination","PairedBarChart","PairedHistogram","PairedSmoothHistogram","PairedTTest","PairedZTest","PaletteNotebook","PalettePath","PalindromeQ","Pane","PaneBox","PaneBoxOptions","Panel","PanelBox","PanelBoxOptions","Paneled","PaneSelector","PaneSelectorBox","PaneSelectorBoxOptions","PaperWidth","ParabolicCylinderD","ParagraphIndent","ParagraphSpacing","ParallelArray","ParallelCombine","ParallelDo","Parallelepiped","ParallelEvaluate","Parallelization","Parallelize","ParallelMap","ParallelNeeds","Parallelogram","ParallelProduct","ParallelSubmit","ParallelSum","ParallelTable","ParallelTry","Parameter","ParameterEstimator","ParameterMixtureDistribution","ParameterVariables","ParametricFunction","ParametricNDSolve","ParametricNDSolveValue","ParametricPlot","ParametricPlot3D","ParametricRampLayer","ParametricRegion","ParentBox","ParentCell","ParentConnect","ParentDirectory","ParentForm","Parenthesize","ParentList","ParentNotebook","ParetoDistribution","ParetoPickandsDistribution","ParkData","Part","PartBehavior","PartialCorrelationFunction","PartialD","ParticleAcceleratorData","ParticleData","Partition","PartitionGranularity","PartitionsP","PartitionsQ","PartLayer","PartOfSpeech","PartProtection","ParzenWindow","PascalDistribution","PassEventsDown","PassEventsUp","Paste","PasteAutoQuoteCharacters","PasteBoxFormInlineCells","PasteButton","Path","PathGraph","PathGraphQ","Pattern","PatternFilling","PatternSequence","PatternTest","PauliMatrix","PaulWavelet","Pause","PausedTime","PDF","PeakDetect","PeanoCurve","PearsonChiSquareTest","PearsonCorrelationTest","PearsonDistribution","PercentForm","PerfectNumber","PerfectNumberQ","PerformanceGoal","Perimeter","PeriodicBoundaryCondition","PeriodicInterpolation","Periodogram","PeriodogramArray","Permanent","Permissions","PermissionsGroup","PermissionsGroupMemberQ","PermissionsGroups","PermissionsKey","PermissionsKeys","PermutationCycles","PermutationCyclesQ","PermutationGroup","PermutationLength","PermutationList","PermutationListQ","PermutationMax","PermutationMin","PermutationOrder","PermutationPower","PermutationProduct","PermutationReplace","Permutations","PermutationSupport","Permute","PeronaMalikFilter","Perpendicular","PerpendicularBisector","PersistenceLocation","PersistenceTime","PersistentObject","PersistentObjects","PersistentValue","PersonData","PERTDistribution","PetersenGraph","PhaseMargins","PhaseRange","PhysicalSystemData","Pi","Pick","PIDData","PIDDerivativeFilter","PIDFeedforward","PIDTune","Piecewise","PiecewiseExpand","PieChart","PieChart3D","PillaiTrace","PillaiTraceTest","PingTime","Pink","PitchRecognize","Pivoting","PixelConstrained","PixelValue","PixelValuePositions","Placed","Placeholder","PlaceholderReplace","Plain","PlanarAngle","PlanarGraph","PlanarGraphQ","PlanckRadiationLaw","PlaneCurveData","PlanetaryMoonData","PlanetData","PlantData","Play","PlayRange","Plot","Plot3D","Plot3Matrix","PlotDivision","PlotJoined","PlotLabel","PlotLabels","PlotLayout","PlotLegends","PlotMarkers","PlotPoints","PlotRange","PlotRangeClipping","PlotRangeClipPlanesStyle","PlotRangePadding","PlotRegion","PlotStyle","PlotTheme","Pluralize","Plus","PlusMinus","Pochhammer","PodStates","PodWidth","Point","Point3DBox","Point3DBoxOptions","PointBox","PointBoxOptions","PointFigureChart","PointLegend","PointSize","PoissonConsulDistribution","PoissonDistribution","PoissonProcess","PoissonWindow","PolarAxes","PolarAxesOrigin","PolarGridLines","PolarPlot","PolarTicks","PoleZeroMarkers","PolyaAeppliDistribution","PolyGamma","Polygon","Polygon3DBox","Polygon3DBoxOptions","PolygonalNumber","PolygonAngle","PolygonBox","PolygonBoxOptions","PolygonCoordinates","PolygonDecomposition","PolygonHoleScale","PolygonIntersections","PolygonScale","Polyhedron","PolyhedronAngle","PolyhedronCoordinates","PolyhedronData","PolyhedronDecomposition","PolyhedronGenus","PolyLog","PolynomialExtendedGCD","PolynomialForm","PolynomialGCD","PolynomialLCM","PolynomialMod","PolynomialQ","PolynomialQuotient","PolynomialQuotientRemainder","PolynomialReduce","PolynomialRemainder","Polynomials","PoolingLayer","PopupMenu","PopupMenuBox","PopupMenuBoxOptions","PopupView","PopupWindow","Position","PositionIndex","Positive","PositiveDefiniteMatrixQ","PositiveIntegers","PositiveRationals","PositiveReals","PositiveSemidefiniteMatrixQ","PossibleZeroQ","Postfix","PostScript","Power","PowerDistribution","PowerExpand","PowerMod","PowerModList","PowerRange","PowerSpectralDensity","PowersRepresentations","PowerSymmetricPolynomial","Precedence","PrecedenceForm","Precedes","PrecedesEqual","PrecedesSlantEqual","PrecedesTilde","Precision","PrecisionGoal","PreDecrement","Predict","PredictionRoot","PredictorFunction","PredictorInformation","PredictorMeasurements","PredictorMeasurementsObject","PreemptProtect","PreferencesPath","Prefix","PreIncrement","Prepend","PrependLayer","PrependTo","PreprocessingRules","PreserveColor","PreserveImageOptions","Previous","PreviousCell","PreviousDate","PriceGraphDistribution","PrimaryPlaceholder","Prime","PrimeNu","PrimeOmega","PrimePi","PrimePowerQ","PrimeQ","Primes","PrimeZetaP","PrimitivePolynomialQ","PrimitiveRoot","PrimitiveRootList","PrincipalComponents","PrincipalValue","Print","PrintableASCIIQ","PrintAction","PrintForm","PrintingCopies","PrintingOptions","PrintingPageRange","PrintingStartingPageNumber","PrintingStyleEnvironment","Printout3D","Printout3DPreviewer","PrintPrecision","PrintTemporary","Prism","PrismBox","PrismBoxOptions","PrivateCellOptions","PrivateEvaluationOptions","PrivateFontOptions","PrivateFrontEndOptions","PrivateKey","PrivateNotebookOptions","PrivatePaths","Probability","ProbabilityDistribution","ProbabilityPlot","ProbabilityPr","ProbabilityScalePlot","ProbitModelFit","ProcessConnection","ProcessDirectory","ProcessEnvironment","Processes","ProcessEstimator","ProcessInformation","ProcessObject","ProcessParameterAssumptions","ProcessParameterQ","ProcessStateDomain","ProcessStatus","ProcessTimeDomain","Product","ProductDistribution","ProductLog","ProgressIndicator","ProgressIndicatorBox","ProgressIndicatorBoxOptions","Projection","Prolog","PromptForm","ProofObject","Properties","Property","PropertyList","PropertyValue","Proportion","Proportional","Protect","Protected","ProteinData","Pruning","PseudoInverse","PsychrometricPropertyData","PublicKey","PublisherID","PulsarData","PunctuationCharacter","Purple","Put","PutAppend","Pyramid","PyramidBox","PyramidBoxOptions","QBinomial","QFactorial","QGamma","QHypergeometricPFQ","QnDispersion","QPochhammer","QPolyGamma","QRDecomposition","QuadraticIrrationalQ","QuadraticOptimization","Quantile","QuantilePlot","Quantity","QuantityArray","QuantityDistribution","QuantityForm","QuantityMagnitude","QuantityQ","QuantityUnit","QuantityVariable","QuantityVariableCanonicalUnit","QuantityVariableDimensions","QuantityVariableIdentifier","QuantityVariablePhysicalQuantity","Quartics","QuartileDeviation","Quartiles","QuartileSkewness","Query","QueueingNetworkProcess","QueueingProcess","QueueProperties","Quiet","Quit","Quotient","QuotientRemainder","RadialGradientImage","RadialityCentrality","RadicalBox","RadicalBoxOptions","RadioButton","RadioButtonBar","RadioButtonBox","RadioButtonBoxOptions","Radon","RadonTransform","RamanujanTau","RamanujanTauL","RamanujanTauTheta","RamanujanTauZ","Ramp","Random","RandomChoice","RandomColor","RandomComplex","RandomEntity","RandomFunction","RandomGeoPosition","RandomGraph","RandomImage","RandomInstance","RandomInteger","RandomPermutation","RandomPoint","RandomPolygon","RandomPolyhedron","RandomPrime","RandomReal","RandomSample","RandomSeed","RandomSeeding","RandomVariate","RandomWalkProcess","RandomWord","Range","RangeFilter","RangeSpecification","RankedMax","RankedMin","RarerProbability","Raster","Raster3D","Raster3DBox","Raster3DBoxOptions","RasterArray","RasterBox","RasterBoxOptions","Rasterize","RasterSize","Rational","RationalFunctions","Rationalize","Rationals","Ratios","RawArray","RawBoxes","RawData","RawMedium","RayleighDistribution","Re","Read","ReadByteArray","ReadLine","ReadList","ReadProtected","ReadString","Real","RealAbs","RealBlockDiagonalForm","RealDigits","RealExponent","Reals","RealSign","Reap","RebuildPacletData","RecognitionPrior","RecognitionThreshold","Record","RecordLists","RecordSeparators","Rectangle","RectangleBox","RectangleBoxOptions","RectangleChart","RectangleChart3D","RectangularRepeatingElement","RecurrenceFilter","RecurrenceTable","RecurringDigitsForm","Red","Reduce","RefBox","ReferenceLineStyle","ReferenceMarkers","ReferenceMarkerStyle","Refine","ReflectionMatrix","ReflectionTransform","Refresh","RefreshRate","Region","RegionBinarize","RegionBoundary","RegionBoundaryStyle","RegionBounds","RegionCentroid","RegionDifference","RegionDimension","RegionDisjoint","RegionDistance","RegionDistanceFunction","RegionEmbeddingDimension","RegionEqual","RegionFillingStyle","RegionFunction","RegionImage","RegionIntersection","RegionMeasure","RegionMember","RegionMemberFunction","RegionMoment","RegionNearest","RegionNearestFunction","RegionPlot","RegionPlot3D","RegionProduct","RegionQ","RegionResize","RegionSize","RegionSymmetricDifference","RegionUnion","RegionWithin","RegisterExternalEvaluator","RegularExpression","Regularization","RegularlySampledQ","RegularPolygon","ReIm","ReImLabels","ReImPlot","ReImStyle","Reinstall","RelationalDatabase","RelationGraph","Release","ReleaseHold","ReliabilityDistribution","ReliefImage","ReliefPlot","RemoteAuthorizationCaching","RemoteConnect","RemoteConnectionObject","RemoteFile","RemoteRun","RemoteRunProcess","Remove","RemoveAlphaChannel","RemoveAsynchronousTask","RemoveAudioStream","RemoveBackground","RemoveChannelListener","RemoveChannelSubscribers","Removed","RemoveDiacritics","RemoveInputStreamMethod","RemoveOutputStreamMethod","RemoveProperty","RemoveScheduledTask","RemoveUsers","RemoveVideoStream","RenameDirectory","RenameFile","RenderAll","RenderingOptions","RenewalProcess","RenkoChart","RepairMesh","Repeated","RepeatedNull","RepeatedString","RepeatedTiming","RepeatingElement","Replace","ReplaceAll","ReplaceHeldPart","ReplaceImageValue","ReplaceList","ReplacePart","ReplacePixelValue","ReplaceRepeated","ReplicateLayer","RequiredPhysicalQuantities","Resampling","ResamplingAlgorithmData","ResamplingMethod","Rescale","RescalingTransform","ResetDirectory","ResetMenusPacket","ResetScheduledTask","ReshapeLayer","Residue","ResizeLayer","Resolve","ResourceAcquire","ResourceData","ResourceFunction","ResourceObject","ResourceRegister","ResourceRemove","ResourceSearch","ResourceSubmissionObject","ResourceSubmit","ResourceSystemBase","ResourceSystemPath","ResourceUpdate","ResourceVersion","ResponseForm","Rest","RestartInterval","Restricted","Resultant","ResumePacket","Return","ReturnEntersInput","ReturnExpressionPacket","ReturnInputFormPacket","ReturnPacket","ReturnReceiptFunction","ReturnTextPacket","Reverse","ReverseApplied","ReverseBiorthogonalSplineWavelet","ReverseElement","ReverseEquilibrium","ReverseGraph","ReverseSort","ReverseSortBy","ReverseUpEquilibrium","RevolutionAxis","RevolutionPlot3D","RGBColor","RiccatiSolve","RiceDistribution","RidgeFilter","RiemannR","RiemannSiegelTheta","RiemannSiegelZ","RiemannXi","Riffle","Right","RightArrow","RightArrowBar","RightArrowLeftArrow","RightComposition","RightCosetRepresentative","RightDownTeeVector","RightDownVector","RightDownVectorBar","RightTee","RightTeeArrow","RightTeeVector","RightTriangle","RightTriangleBar","RightTriangleEqual","RightUpDownVector","RightUpTeeVector","RightUpVector","RightUpVectorBar","RightVector","RightVectorBar","RiskAchievementImportance","RiskReductionImportance","RogersTanimotoDissimilarity","RollPitchYawAngles","RollPitchYawMatrix","RomanNumeral","Root","RootApproximant","RootIntervals","RootLocusPlot","RootMeanSquare","RootOfUnityQ","RootReduce","Roots","RootSum","Rotate","RotateLabel","RotateLeft","RotateRight","RotationAction","RotationBox","RotationBoxOptions","RotationMatrix","RotationTransform","Round","RoundImplies","RoundingRadius","Row","RowAlignments","RowBackgrounds","RowBox","RowHeights","RowLines","RowMinHeight","RowReduce","RowsEqual","RowSpacings","RSolve","RSolveValue","RudinShapiro","RudvalisGroupRu","Rule","RuleCondition","RuleDelayed","RuleForm","RulePlot","RulerUnits","Run","RunProcess","RunScheduledTask","RunThrough","RuntimeAttributes","RuntimeOptions","RussellRaoDissimilarity","SameQ","SameTest","SameTestProperties","SampledEntityClass","SampleDepth","SampledSoundFunction","SampledSoundList","SampleRate","SamplingPeriod","SARIMAProcess","SARMAProcess","SASTriangle","SatelliteData","SatisfiabilityCount","SatisfiabilityInstances","SatisfiableQ","Saturday","Save","Saveable","SaveAutoDelete","SaveConnection","SaveDefinitions","SavitzkyGolayMatrix","SawtoothWave","Scale","Scaled","ScaleDivisions","ScaledMousePosition","ScaleOrigin","ScalePadding","ScaleRanges","ScaleRangeStyle","ScalingFunctions","ScalingMatrix","ScalingTransform","Scan","ScheduledTask","ScheduledTaskActiveQ","ScheduledTaskInformation","ScheduledTaskInformationData","ScheduledTaskObject","ScheduledTasks","SchurDecomposition","ScientificForm","ScientificNotationThreshold","ScorerGi","ScorerGiPrime","ScorerHi","ScorerHiPrime","ScreenRectangle","ScreenStyleEnvironment","ScriptBaselineShifts","ScriptForm","ScriptLevel","ScriptMinSize","ScriptRules","ScriptSizeMultipliers","Scrollbars","ScrollingOptions","ScrollPosition","SearchAdjustment","SearchIndexObject","SearchIndices","SearchQueryString","SearchResultObject","Sec","Sech","SechDistribution","SecondOrderConeOptimization","SectionGrouping","SectorChart","SectorChart3D","SectorOrigin","SectorSpacing","SecuredAuthenticationKey","SecuredAuthenticationKeys","SeedRandom","Select","Selectable","SelectComponents","SelectedCells","SelectedNotebook","SelectFirst","Selection","SelectionAnimate","SelectionCell","SelectionCellCreateCell","SelectionCellDefaultStyle","SelectionCellParentStyle","SelectionCreateCell","SelectionDebuggerTag","SelectionDuplicateCell","SelectionEvaluate","SelectionEvaluateCreateCell","SelectionMove","SelectionPlaceholder","SelectionSetStyle","SelectWithContents","SelfLoops","SelfLoopStyle","SemanticImport","SemanticImportString","SemanticInterpretation","SemialgebraicComponentInstances","SemidefiniteOptimization","SendMail","SendMessage","Sequence","SequenceAlignment","SequenceAttentionLayer","SequenceCases","SequenceCount","SequenceFold","SequenceFoldList","SequenceForm","SequenceHold","SequenceLastLayer","SequenceMostLayer","SequencePosition","SequencePredict","SequencePredictorFunction","SequenceReplace","SequenceRestLayer","SequenceReverseLayer","SequenceSplit","Series","SeriesCoefficient","SeriesData","SeriesTermGoal","ServiceConnect","ServiceDisconnect","ServiceExecute","ServiceObject","ServiceRequest","ServiceResponse","ServiceSubmit","SessionSubmit","SessionTime","Set","SetAccuracy","SetAlphaChannel","SetAttributes","Setbacks","SetBoxFormNamesPacket","SetCloudDirectory","SetCookies","SetDelayed","SetDirectory","SetEnvironment","SetEvaluationNotebook","SetFileDate","SetFileLoadingContext","SetNotebookStatusLine","SetOptions","SetOptionsPacket","SetPermissions","SetPrecision","SetProperty","SetSecuredAuthenticationKey","SetSelectedNotebook","SetSharedFunction","SetSharedVariable","SetSpeechParametersPacket","SetStreamPosition","SetSystemModel","SetSystemOptions","Setter","SetterBar","SetterBox","SetterBoxOptions","Setting","SetUsers","SetValue","Shading","Shallow","ShannonWavelet","ShapiroWilkTest","Share","SharingList","Sharpen","ShearingMatrix","ShearingTransform","ShellRegion","ShenCastanMatrix","ShiftedGompertzDistribution","ShiftRegisterSequence","Short","ShortDownArrow","Shortest","ShortestMatch","ShortestPathFunction","ShortLeftArrow","ShortRightArrow","ShortTimeFourier","ShortTimeFourierData","ShortUpArrow","Show","ShowAutoConvert","ShowAutoSpellCheck","ShowAutoStyles","ShowCellBracket","ShowCellLabel","ShowCellTags","ShowClosedCellArea","ShowCodeAssist","ShowContents","ShowControls","ShowCursorTracker","ShowGroupOpenCloseIcon","ShowGroupOpener","ShowInvisibleCharacters","ShowPageBreaks","ShowPredictiveInterface","ShowSelection","ShowShortBoxForm","ShowSpecialCharacters","ShowStringCharacters","ShowSyntaxStyles","ShrinkingDelay","ShrinkWrapBoundingBox","SiderealTime","SiegelTheta","SiegelTukeyTest","SierpinskiCurve","SierpinskiMesh","Sign","Signature","SignedRankTest","SignedRegionDistance","SignificanceLevel","SignPadding","SignTest","SimilarityRules","SimpleGraph","SimpleGraphQ","SimplePolygonQ","SimplePolyhedronQ","Simplex","Simplify","Sin","Sinc","SinghMaddalaDistribution","SingleEvaluation","SingleLetterItalics","SingleLetterStyle","SingularValueDecomposition","SingularValueList","SingularValuePlot","SingularValues","Sinh","SinhIntegral","SinIntegral","SixJSymbol","Skeleton","SkeletonTransform","SkellamDistribution","Skewness","SkewNormalDistribution","SkinStyle","Skip","SliceContourPlot3D","SliceDensityPlot3D","SliceDistribution","SliceVectorPlot3D","Slider","Slider2D","Slider2DBox","Slider2DBoxOptions","SliderBox","SliderBoxOptions","SlideView","Slot","SlotSequence","Small","SmallCircle","Smaller","SmithDecomposition","SmithDelayCompensator","SmithWatermanSimilarity","SmoothDensityHistogram","SmoothHistogram","SmoothHistogram3D","SmoothKernelDistribution","SnDispersion","Snippet","SnubPolyhedron","SocialMediaData","Socket","SocketConnect","SocketListen","SocketListener","SocketObject","SocketOpen","SocketReadMessage","SocketReadyQ","Sockets","SocketWaitAll","SocketWaitNext","SoftmaxLayer","SokalSneathDissimilarity","SolarEclipse","SolarSystemFeatureData","SolidAngle","SolidData","SolidRegionQ","Solve","SolveAlways","SolveDelayed","Sort","SortBy","SortedBy","SortedEntityClass","Sound","SoundAndGraphics","SoundNote","SoundVolume","SourceLink","Sow","Space","SpaceCurveData","SpaceForm","Spacer","Spacings","Span","SpanAdjustments","SpanCharacterRounding","SpanFromAbove","SpanFromBoth","SpanFromLeft","SpanLineThickness","SpanMaxSize","SpanMinSize","SpanningCharacters","SpanSymmetric","SparseArray","SpatialGraphDistribution","SpatialMedian","SpatialTransformationLayer","Speak","SpeakerMatchQ","SpeakTextPacket","SpearmanRankTest","SpearmanRho","SpeciesData","SpecificityGoal","SpectralLineData","Spectrogram","SpectrogramArray","Specularity","SpeechCases","SpeechInterpreter","SpeechRecognize","SpeechSynthesize","SpellingCorrection","SpellingCorrectionList","SpellingDictionaries","SpellingDictionariesPath","SpellingOptions","SpellingSuggestionsPacket","Sphere","SphereBox","SpherePoints","SphericalBesselJ","SphericalBesselY","SphericalHankelH1","SphericalHankelH2","SphericalHarmonicY","SphericalPlot3D","SphericalRegion","SphericalShell","SpheroidalEigenvalue","SpheroidalJoiningFactor","SpheroidalPS","SpheroidalPSPrime","SpheroidalQS","SpheroidalQSPrime","SpheroidalRadialFactor","SpheroidalS1","SpheroidalS1Prime","SpheroidalS2","SpheroidalS2Prime","Splice","SplicedDistribution","SplineClosed","SplineDegree","SplineKnots","SplineWeights","Split","SplitBy","SpokenString","Sqrt","SqrtBox","SqrtBoxOptions","Square","SquaredEuclideanDistance","SquareFreeQ","SquareIntersection","SquareMatrixQ","SquareRepeatingElement","SquaresR","SquareSubset","SquareSubsetEqual","SquareSuperset","SquareSupersetEqual","SquareUnion","SquareWave","SSSTriangle","StabilityMargins","StabilityMarginsStyle","StableDistribution","Stack","StackBegin","StackComplete","StackedDateListPlot","StackedListPlot","StackInhibit","StadiumShape","StandardAtmosphereData","StandardDeviation","StandardDeviationFilter","StandardForm","Standardize","Standardized","StandardOceanData","StandbyDistribution","Star","StarClusterData","StarData","StarGraph","StartAsynchronousTask","StartExternalSession","StartingStepSize","StartOfLine","StartOfString","StartProcess","StartScheduledTask","StartupSound","StartWebSession","StateDimensions","StateFeedbackGains","StateOutputEstimator","StateResponse","StateSpaceModel","StateSpaceRealization","StateSpaceTransform","StateTransformationLinearize","StationaryDistribution","StationaryWaveletPacketTransform","StationaryWaveletTransform","StatusArea","StatusCentrality","StepMonitor","StereochemistryElements","StieltjesGamma","StippleShading","StirlingS1","StirlingS2","StopAsynchronousTask","StoppingPowerData","StopScheduledTask","StrataVariables","StratonovichProcess","StreamColorFunction","StreamColorFunctionScaling","StreamDensityPlot","StreamMarkers","StreamPlot","StreamPoints","StreamPosition","Streams","StreamScale","StreamStyle","String","StringBreak","StringByteCount","StringCases","StringContainsQ","StringCount","StringDelete","StringDrop","StringEndsQ","StringExpression","StringExtract","StringForm","StringFormat","StringFreeQ","StringInsert","StringJoin","StringLength","StringMatchQ","StringPadLeft","StringPadRight","StringPart","StringPartition","StringPosition","StringQ","StringRepeat","StringReplace","StringReplaceList","StringReplacePart","StringReverse","StringRiffle","StringRotateLeft","StringRotateRight","StringSkeleton","StringSplit","StringStartsQ","StringTake","StringTemplate","StringToByteArray","StringToStream","StringTrim","StripBoxes","StripOnInput","StripWrapperBoxes","StrokeForm","StructuralImportance","StructuredArray","StructuredArrayHeadQ","StructuredSelection","StruveH","StruveL","Stub","StudentTDistribution","Style","StyleBox","StyleBoxAutoDelete","StyleData","StyleDefinitions","StyleForm","StyleHints","StyleKeyMapping","StyleMenuListing","StyleNameDialogSettings","StyleNames","StylePrint","StyleSheetPath","Subdivide","Subfactorial","Subgraph","SubMinus","SubPlus","SubresultantPolynomialRemainders","SubresultantPolynomials","Subresultants","Subscript","SubscriptBox","SubscriptBoxOptions","Subscripted","Subsequences","Subset","SubsetCases","SubsetCount","SubsetEqual","SubsetMap","SubsetPosition","SubsetQ","SubsetReplace","Subsets","SubStar","SubstitutionSystem","Subsuperscript","SubsuperscriptBox","SubsuperscriptBoxOptions","SubtitleEncoding","SubtitleTracks","Subtract","SubtractFrom","SubtractSides","SubValues","Succeeds","SucceedsEqual","SucceedsSlantEqual","SucceedsTilde","Success","SuchThat","Sum","SumConvergence","SummationLayer","Sunday","SunPosition","Sunrise","Sunset","SuperDagger","SuperMinus","SupernovaData","SuperPlus","Superscript","SuperscriptBox","SuperscriptBoxOptions","Superset","SupersetEqual","SuperStar","Surd","SurdForm","SurfaceAppearance","SurfaceArea","SurfaceColor","SurfaceData","SurfaceGraphics","SurvivalDistribution","SurvivalFunction","SurvivalModel","SurvivalModelFit","SuspendPacket","SuzukiDistribution","SuzukiGroupSuz","SwatchLegend","Switch","Symbol","SymbolName","SymletWavelet","Symmetric","SymmetricGroup","SymmetricKey","SymmetricMatrixQ","SymmetricPolynomial","SymmetricReduction","Symmetrize","SymmetrizedArray","SymmetrizedArrayRules","SymmetrizedDependentComponents","SymmetrizedIndependentComponents","SymmetrizedReplacePart","SynchronousInitialization","SynchronousUpdating","Synonyms","Syntax","SyntaxForm","SyntaxInformation","SyntaxLength","SyntaxPacket","SyntaxQ","SynthesizeMissingValues","SystemCredential","SystemCredentialData","SystemCredentialKey","SystemCredentialKeys","SystemCredentialStoreObject","SystemDialogInput","SystemException","SystemGet","SystemHelpPath","SystemInformation","SystemInformationData","SystemInstall","SystemModel","SystemModeler","SystemModelExamples","SystemModelLinearize","SystemModelParametricSimulate","SystemModelPlot","SystemModelProgressReporting","SystemModelReliability","SystemModels","SystemModelSimulate","SystemModelSimulateSensitivity","SystemModelSimulationData","SystemOpen","SystemOptions","SystemProcessData","SystemProcesses","SystemsConnectionsModel","SystemsModelDelay","SystemsModelDelayApproximate","SystemsModelDelete","SystemsModelDimensions","SystemsModelExtract","SystemsModelFeedbackConnect","SystemsModelLabels","SystemsModelLinearity","SystemsModelMerge","SystemsModelOrder","SystemsModelParallelConnect","SystemsModelSeriesConnect","SystemsModelStateFeedbackConnect","SystemsModelVectorRelativeOrders","SystemStub","SystemTest","Tab","TabFilling","Table","TableAlignments","TableDepth","TableDirections","TableForm","TableHeadings","TableSpacing","TableView","TableViewBox","TableViewBoxBackground","TableViewBoxItemSize","TableViewBoxOptions","TabSpacings","TabView","TabViewBox","TabViewBoxOptions","TagBox","TagBoxNote","TagBoxOptions","TaggingRules","TagSet","TagSetDelayed","TagStyle","TagUnset","Take","TakeDrop","TakeLargest","TakeLargestBy","TakeList","TakeSmallest","TakeSmallestBy","TakeWhile","Tally","Tan","Tanh","TargetDevice","TargetFunctions","TargetSystem","TargetUnits","TaskAbort","TaskExecute","TaskObject","TaskRemove","TaskResume","Tasks","TaskSuspend","TaskWait","TautologyQ","TelegraphProcess","TemplateApply","TemplateArgBox","TemplateBox","TemplateBoxOptions","TemplateEvaluate","TemplateExpression","TemplateIf","TemplateObject","TemplateSequence","TemplateSlot","TemplateSlotSequence","TemplateUnevaluated","TemplateVerbatim","TemplateWith","TemporalData","TemporalRegularity","Temporary","TemporaryVariable","TensorContract","TensorDimensions","TensorExpand","TensorProduct","TensorQ","TensorRank","TensorReduce","TensorSymmetry","TensorTranspose","TensorWedge","TestID","TestReport","TestReportObject","TestResultObject","Tetrahedron","TetrahedronBox","TetrahedronBoxOptions","TeXForm","TeXSave","Text","Text3DBox","Text3DBoxOptions","TextAlignment","TextBand","TextBoundingBox","TextBox","TextCases","TextCell","TextClipboardType","TextContents","TextData","TextElement","TextForm","TextGrid","TextJustification","TextLine","TextPacket","TextParagraph","TextPosition","TextRecognize","TextSearch","TextSearchReport","TextSentences","TextString","TextStructure","TextStyle","TextTranslation","Texture","TextureCoordinateFunction","TextureCoordinateScaling","TextWords","Therefore","ThermodynamicData","ThermometerGauge","Thick","Thickness","Thin","Thinning","ThisLink","ThompsonGroupTh","Thread","ThreadingLayer","ThreeJSymbol","Threshold","Through","Throw","ThueMorse","Thumbnail","Thursday","Ticks","TicksStyle","TideData","Tilde","TildeEqual","TildeFullEqual","TildeTilde","TimeConstrained","TimeConstraint","TimeDirection","TimeFormat","TimeGoal","TimelinePlot","TimeObject","TimeObjectQ","TimeRemaining","Times","TimesBy","TimeSeries","TimeSeriesAggregate","TimeSeriesForecast","TimeSeriesInsert","TimeSeriesInvertibility","TimeSeriesMap","TimeSeriesMapThread","TimeSeriesModel","TimeSeriesModelFit","TimeSeriesResample","TimeSeriesRescale","TimeSeriesShift","TimeSeriesThread","TimeSeriesWindow","TimeUsed","TimeValue","TimeWarpingCorrespondence","TimeWarpingDistance","TimeZone","TimeZoneConvert","TimeZoneOffset","Timing","Tiny","TitleGrouping","TitsGroupT","ToBoxes","ToCharacterCode","ToColor","ToContinuousTimeModel","ToDate","Today","ToDiscreteTimeModel","ToEntity","ToeplitzMatrix","ToExpression","ToFileName","Together","Toggle","ToggleFalse","Toggler","TogglerBar","TogglerBox","TogglerBoxOptions","ToHeldExpression","ToInvertibleTimeSeries","TokenWords","Tolerance","ToLowerCase","Tomorrow","ToNumberField","TooBig","Tooltip","TooltipBox","TooltipBoxOptions","TooltipDelay","TooltipStyle","ToonShading","Top","TopHatTransform","ToPolarCoordinates","TopologicalSort","ToRadicals","ToRules","ToSphericalCoordinates","ToString","Total","TotalHeight","TotalLayer","TotalVariationFilter","TotalWidth","TouchPosition","TouchscreenAutoZoom","TouchscreenControlPlacement","ToUpperCase","Tr","Trace","TraceAbove","TraceAction","TraceBackward","TraceDepth","TraceDialog","TraceForward","TraceInternal","TraceLevel","TraceOff","TraceOn","TraceOriginal","TracePrint","TraceScan","TrackedSymbols","TrackingFunction","TracyWidomDistribution","TradingChart","TraditionalForm","TraditionalFunctionNotation","TraditionalNotation","TraditionalOrder","TrainingProgressCheckpointing","TrainingProgressFunction","TrainingProgressMeasurements","TrainingProgressReporting","TrainingStoppingCriterion","TrainingUpdateSchedule","TransferFunctionCancel","TransferFunctionExpand","TransferFunctionFactor","TransferFunctionModel","TransferFunctionPoles","TransferFunctionTransform","TransferFunctionZeros","TransformationClass","TransformationFunction","TransformationFunctions","TransformationMatrix","TransformedDistribution","TransformedField","TransformedProcess","TransformedRegion","TransitionDirection","TransitionDuration","TransitionEffect","TransitiveClosureGraph","TransitiveReductionGraph","Translate","TranslationOptions","TranslationTransform","Transliterate","Transparent","TransparentColor","Transpose","TransposeLayer","TrapSelection","TravelDirections","TravelDirectionsData","TravelDistance","TravelDistanceList","TravelMethod","TravelTime","TreeForm","TreeGraph","TreeGraphQ","TreePlot","TrendStyle","Triangle","TriangleCenter","TriangleConstruct","TriangleMeasurement","TriangleWave","TriangularDistribution","TriangulateMesh","Trig","TrigExpand","TrigFactor","TrigFactorList","Trigger","TrigReduce","TrigToExp","TrimmedMean","TrimmedVariance","TropicalStormData","True","TrueQ","TruncatedDistribution","TruncatedPolyhedron","TsallisQExponentialDistribution","TsallisQGaussianDistribution","TTest","Tube","TubeBezierCurveBox","TubeBezierCurveBoxOptions","TubeBox","TubeBoxOptions","TubeBSplineCurveBox","TubeBSplineCurveBoxOptions","Tuesday","TukeyLambdaDistribution","TukeyWindow","TunnelData","Tuples","TuranGraph","TuringMachine","TuttePolynomial","TwoWayRule","Typed","TypeSpecifier","UnateQ","Uncompress","UnconstrainedParameters","Undefined","UnderBar","Underflow","Underlined","Underoverscript","UnderoverscriptBox","UnderoverscriptBoxOptions","Underscript","UnderscriptBox","UnderscriptBoxOptions","UnderseaFeatureData","UndirectedEdge","UndirectedGraph","UndirectedGraphQ","UndoOptions","UndoTrackedVariables","Unequal","UnequalTo","Unevaluated","UniformDistribution","UniformGraphDistribution","UniformPolyhedron","UniformSumDistribution","Uninstall","Union","UnionedEntityClass","UnionPlus","Unique","UnitaryMatrixQ","UnitBox","UnitConvert","UnitDimensions","Unitize","UnitRootTest","UnitSimplify","UnitStep","UnitSystem","UnitTriangle","UnitVector","UnitVectorLayer","UnityDimensions","UniverseModelData","UniversityData","UnixTime","Unprotect","UnregisterExternalEvaluator","UnsameQ","UnsavedVariables","Unset","UnsetShared","UntrackedVariables","Up","UpArrow","UpArrowBar","UpArrowDownArrow","Update","UpdateDynamicObjects","UpdateDynamicObjectsSynchronous","UpdateInterval","UpdatePacletSites","UpdateSearchIndex","UpDownArrow","UpEquilibrium","UpperCaseQ","UpperLeftArrow","UpperRightArrow","UpperTriangularize","UpperTriangularMatrixQ","Upsample","UpSet","UpSetDelayed","UpTee","UpTeeArrow","UpTo","UpValues","URL","URLBuild","URLDecode","URLDispatcher","URLDownload","URLDownloadSubmit","URLEncode","URLExecute","URLExpand","URLFetch","URLFetchAsynchronous","URLParse","URLQueryDecode","URLQueryEncode","URLRead","URLResponseTime","URLSave","URLSaveAsynchronous","URLShorten","URLSubmit","UseGraphicsRange","UserDefinedWavelet","Using","UsingFrontEnd","UtilityFunction","V2Get","ValenceErrorHandling","ValidationLength","ValidationSet","Value","ValueBox","ValueBoxOptions","ValueDimensions","ValueForm","ValuePreprocessingFunction","ValueQ","Values","ValuesData","Variables","Variance","VarianceEquivalenceTest","VarianceEstimatorFunction","VarianceGammaDistribution","VarianceTest","VectorAngle","VectorAround","VectorAspectRatio","VectorColorFunction","VectorColorFunctionScaling","VectorDensityPlot","VectorGlyphData","VectorGreater","VectorGreaterEqual","VectorLess","VectorLessEqual","VectorMarkers","VectorPlot","VectorPlot3D","VectorPoints","VectorQ","VectorRange","Vectors","VectorScale","VectorScaling","VectorSizes","VectorStyle","Vee","Verbatim","Verbose","VerboseConvertToPostScriptPacket","VerificationTest","VerifyConvergence","VerifyDerivedKey","VerifyDigitalSignature","VerifyFileSignature","VerifyInterpretation","VerifySecurityCertificates","VerifySolutions","VerifyTestAssumptions","Version","VersionedPreferences","VersionNumber","VertexAdd","VertexCapacity","VertexColors","VertexComponent","VertexConnectivity","VertexContract","VertexCoordinateRules","VertexCoordinates","VertexCorrelationSimilarity","VertexCosineSimilarity","VertexCount","VertexCoverQ","VertexDataCoordinates","VertexDegree","VertexDelete","VertexDiceSimilarity","VertexEccentricity","VertexInComponent","VertexInDegree","VertexIndex","VertexJaccardSimilarity","VertexLabeling","VertexLabels","VertexLabelStyle","VertexList","VertexNormals","VertexOutComponent","VertexOutDegree","VertexQ","VertexRenderingFunction","VertexReplace","VertexShape","VertexShapeFunction","VertexSize","VertexStyle","VertexTextureCoordinates","VertexWeight","VertexWeightedGraphQ","Vertical","VerticalBar","VerticalForm","VerticalGauge","VerticalSeparator","VerticalSlider","VerticalTilde","Video","VideoEncoding","VideoExtractFrames","VideoFrameList","VideoFrameMap","VideoPause","VideoPlay","VideoQ","VideoStop","VideoStream","VideoStreams","VideoTimeSeries","VideoTracks","VideoTrim","ViewAngle","ViewCenter","ViewMatrix","ViewPoint","ViewPointSelectorSettings","ViewPort","ViewProjection","ViewRange","ViewVector","ViewVertical","VirtualGroupData","Visible","VisibleCell","VoiceStyleData","VoigtDistribution","VolcanoData","Volume","VonMisesDistribution","VoronoiMesh","WaitAll","WaitAsynchronousTask","WaitNext","WaitUntil","WakebyDistribution","WalleniusHypergeometricDistribution","WaringYuleDistribution","WarpingCorrespondence","WarpingDistance","WatershedComponents","WatsonUSquareTest","WattsStrogatzGraphDistribution","WaveletBestBasis","WaveletFilterCoefficients","WaveletImagePlot","WaveletListPlot","WaveletMapIndexed","WaveletMatrixPlot","WaveletPhi","WaveletPsi","WaveletScale","WaveletScalogram","WaveletThreshold","WeaklyConnectedComponents","WeaklyConnectedGraphComponents","WeaklyConnectedGraphQ","WeakStationarity","WeatherData","WeatherForecastData","WebAudioSearch","WebElementObject","WeberE","WebExecute","WebImage","WebImageSearch","WebSearch","WebSessionObject","WebSessions","WebWindowObject","Wedge","Wednesday","WeibullDistribution","WeierstrassE1","WeierstrassE2","WeierstrassE3","WeierstrassEta1","WeierstrassEta2","WeierstrassEta3","WeierstrassHalfPeriods","WeierstrassHalfPeriodW1","WeierstrassHalfPeriodW2","WeierstrassHalfPeriodW3","WeierstrassInvariantG2","WeierstrassInvariantG3","WeierstrassInvariants","WeierstrassP","WeierstrassPPrime","WeierstrassSigma","WeierstrassZeta","WeightedAdjacencyGraph","WeightedAdjacencyMatrix","WeightedData","WeightedGraphQ","Weights","WelchWindow","WheelGraph","WhenEvent","Which","While","White","WhiteNoiseProcess","WhitePoint","Whitespace","WhitespaceCharacter","WhittakerM","WhittakerW","WienerFilter","WienerProcess","WignerD","WignerSemicircleDistribution","WikidataData","WikidataSearch","WikipediaData","WikipediaSearch","WilksW","WilksWTest","WindDirectionData","WindingCount","WindingPolygon","WindowClickSelect","WindowElements","WindowFloating","WindowFrame","WindowFrameElements","WindowMargins","WindowMovable","WindowOpacity","WindowPersistentStyles","WindowSelected","WindowSize","WindowStatusArea","WindowTitle","WindowToolbars","WindowWidth","WindSpeedData","WindVectorData","WinsorizedMean","WinsorizedVariance","WishartMatrixDistribution","With","WolframAlpha","WolframAlphaDate","WolframAlphaQuantity","WolframAlphaResult","WolframLanguageData","Word","WordBoundary","WordCharacter","WordCloud","WordCount","WordCounts","WordData","WordDefinition","WordFrequency","WordFrequencyData","WordList","WordOrientation","WordSearch","WordSelectionFunction","WordSeparators","WordSpacings","WordStem","WordTranslation","WorkingPrecision","WrapAround","Write","WriteLine","WriteString","Wronskian","XMLElement","XMLObject","XMLTemplate","Xnor","Xor","XYZColor","Yellow","Yesterday","YuleDissimilarity","ZernikeR","ZeroSymmetric","ZeroTest","ZeroWidthTimes","Zeta","ZetaZero","ZIPCodeData","ZipfDistribution","ZoomCenter","ZoomFactor","ZTest","ZTransform","$Aborted","$ActivationGroupID","$ActivationKey","$ActivationUserRegistered","$AddOnsDirectory","$AllowDataUpdates","$AllowExternalChannelFunctions","$AllowInternet","$AssertFunction","$Assumptions","$AsynchronousTask","$AudioDecoders","$AudioEncoders","$AudioInputDevices","$AudioOutputDevices","$BaseDirectory","$BasePacletsDirectory","$BatchInput","$BatchOutput","$BlockchainBase","$BoxForms","$ByteOrdering","$CacheBaseDirectory","$Canceled","$ChannelBase","$CharacterEncoding","$CharacterEncodings","$CloudAccountName","$CloudBase","$CloudConnected","$CloudConnection","$CloudCreditsAvailable","$CloudEvaluation","$CloudExpressionBase","$CloudObjectNameFormat","$CloudObjectURLType","$CloudRootDirectory","$CloudSymbolBase","$CloudUserID","$CloudUserUUID","$CloudVersion","$CloudVersionNumber","$CloudWolframEngineVersionNumber","$CommandLine","$CompilationTarget","$ConditionHold","$ConfiguredKernels","$Context","$ContextPath","$ControlActiveSetting","$Cookies","$CookieStore","$CreationDate","$CurrentLink","$CurrentTask","$CurrentWebSession","$DataStructures","$DateStringFormat","$DefaultAudioInputDevice","$DefaultAudioOutputDevice","$DefaultFont","$DefaultFrontEnd","$DefaultImagingDevice","$DefaultLocalBase","$DefaultMailbox","$DefaultNetworkInterface","$DefaultPath","$DefaultProxyRules","$DefaultSystemCredentialStore","$Display","$DisplayFunction","$DistributedContexts","$DynamicEvaluation","$Echo","$EmbedCodeEnvironments","$EmbeddableServices","$EntityStores","$Epilog","$EvaluationCloudBase","$EvaluationCloudObject","$EvaluationEnvironment","$ExportFormats","$ExternalIdentifierTypes","$ExternalStorageBase","$Failed","$FinancialDataSource","$FontFamilies","$FormatType","$FrontEnd","$FrontEndSession","$GeoEntityTypes","$GeoLocation","$GeoLocationCity","$GeoLocationCountry","$GeoLocationPrecision","$GeoLocationSource","$HistoryLength","$HomeDirectory","$HTMLExportRules","$HTTPCookies","$HTTPRequest","$IgnoreEOF","$ImageFormattingWidth","$ImageResolution","$ImagingDevice","$ImagingDevices","$ImportFormats","$IncomingMailSettings","$InitialDirectory","$Initialization","$InitializationContexts","$Input","$InputFileName","$InputStreamMethods","$Inspector","$InstallationDate","$InstallationDirectory","$InterfaceEnvironment","$InterpreterTypes","$IterationLimit","$KernelCount","$KernelID","$Language","$LaunchDirectory","$LibraryPath","$LicenseExpirationDate","$LicenseID","$LicenseProcesses","$LicenseServer","$LicenseSubprocesses","$LicenseType","$Line","$Linked","$LinkSupported","$LoadedFiles","$LocalBase","$LocalSymbolBase","$MachineAddresses","$MachineDomain","$MachineDomains","$MachineEpsilon","$MachineID","$MachineName","$MachinePrecision","$MachineType","$MaxExtraPrecision","$MaxLicenseProcesses","$MaxLicenseSubprocesses","$MaxMachineNumber","$MaxNumber","$MaxPiecewiseCases","$MaxPrecision","$MaxRootDegree","$MessageGroups","$MessageList","$MessagePrePrint","$Messages","$MinMachineNumber","$MinNumber","$MinorReleaseNumber","$MinPrecision","$MobilePhone","$ModuleNumber","$NetworkConnected","$NetworkInterfaces","$NetworkLicense","$NewMessage","$NewSymbol","$NotebookInlineStorageLimit","$Notebooks","$NoValue","$NumberMarks","$Off","$OperatingSystem","$Output","$OutputForms","$OutputSizeLimit","$OutputStreamMethods","$Packages","$ParentLink","$ParentProcessID","$PasswordFile","$PatchLevelID","$Path","$PathnameSeparator","$PerformanceGoal","$Permissions","$PermissionsGroupBase","$PersistenceBase","$PersistencePath","$PipeSupported","$PlotTheme","$Post","$Pre","$PreferencesDirectory","$PreInitialization","$PrePrint","$PreRead","$PrintForms","$PrintLiteral","$Printout3DPreviewer","$ProcessID","$ProcessorCount","$ProcessorType","$ProductInformation","$ProgramName","$PublisherID","$RandomState","$RecursionLimit","$RegisteredDeviceClasses","$RegisteredUserName","$ReleaseNumber","$RequesterAddress","$RequesterWolframID","$RequesterWolframUUID","$RootDirectory","$ScheduledTask","$ScriptCommandLine","$ScriptInputString","$SecuredAuthenticationKeyTokens","$ServiceCreditsAvailable","$Services","$SessionID","$SetParentLink","$SharedFunctions","$SharedVariables","$SoundDisplay","$SoundDisplayFunction","$SourceLink","$SSHAuthentication","$SubtitleDecoders","$SubtitleEncoders","$SummaryBoxDataSizeLimit","$SuppressInputFormHeads","$SynchronousEvaluation","$SyntaxHandler","$System","$SystemCharacterEncoding","$SystemCredentialStore","$SystemID","$SystemMemory","$SystemShell","$SystemTimeZone","$SystemWordLength","$TemplatePath","$TemporaryDirectory","$TemporaryPrefix","$TestFileName","$TextStyle","$TimedOut","$TimeUnit","$TimeZone","$TimeZoneEntity","$TopDirectory","$TraceOff","$TraceOn","$TracePattern","$TracePostAction","$TracePreAction","$UnitSystem","$Urgent","$UserAddOnsDirectory","$UserAgentLanguages","$UserAgentMachine","$UserAgentName","$UserAgentOperatingSystem","$UserAgentString","$UserAgentVersion","$UserBaseDirectory","$UserBasePacletsDirectory","$UserDocumentsDirectory","$Username","$UserName","$UserURLBase","$Version","$VersionNumber","$VideoDecoders","$VideoEncoders","$VoiceStyles","$WolframDocumentsDirectory","$WolframID","$WolframUUID"]
-;function t(e){return e?"string"==typeof e?e:e.source:null}function i(e){
-return o("(",e,")?")}function o(...e){return e.map((e=>t(e))).join("")}
-function a(...e){return"("+e.map((e=>t(e))).join("|")+")"}return t=>{
-const n=a(o(/([2-9]|[1-2]\d|[3][0-5])\^\^/,/(\w*\.\w+|\w+\.\w*|\w+)/),/(\d*\.\d+|\d+\.\d*|\d+)/),r={
-className:"number",relevance:0,
-begin:o(n,i(a(/``[+-]?(\d*\.\d+|\d+\.\d*|\d+)/,/`([+-]?(\d*\.\d+|\d+\.\d*|\d+))?/)),i(/\*\^[+-]?\d+/))
-},l=/[a-zA-Z$][a-zA-Z0-9$]*/,s=new Set(e),c={variants:[{
-className:"builtin-symbol",begin:l,"on:begin":(e,t)=>{
-s.has(e[0])||t.ignoreMatch()}},{className:"symbol",relevance:0,begin:l}]},u={
-className:"message-name",relevance:0,begin:o("::",l)};return{name:"Mathematica",
-aliases:["mma","wl"],classNameAliases:{brace:"punctuation",pattern:"type",
-slot:"type",symbol:"variable","named-character":"variable",
-"builtin-symbol":"built_in","message-name":"string"},
-contains:[t.COMMENT(/\(\*/,/\*\)/,{contains:["self"]}),{className:"pattern",
-relevance:0,begin:/([a-zA-Z$][a-zA-Z0-9$]*)?_+([a-zA-Z$][a-zA-Z0-9$]*)?/},{
-className:"slot",relevance:0,begin:/#[a-zA-Z$][a-zA-Z0-9$]*|#+[0-9]?/},u,c,{
-className:"named-character",begin:/\\\[[$a-zA-Z][$a-zA-Z0-9]+\]/
-},t.QUOTE_STRING_MODE,r,{className:"operator",relevance:0,
-begin:/[+\-*/,;.:@~=><&|_`'^?!%]+/},{className:"brace",relevance:0,
-begin:/[[\](){}]/}]}}})());
-hljs.registerLanguage("matlab",(()=>{"use strict";return e=>{var a={relevance:0,
-contains:[{begin:"('|\\.')+"}]};return{name:"Matlab",keywords:{
-keyword:"arguments break case catch classdef continue else elseif end enumeration events for function global if methods otherwise parfor persistent properties return spmd switch try while",
-built_in:"sin sind sinh asin asind asinh cos cosd cosh acos acosd acosh tan tand tanh atan atand atan2 atanh sec secd sech asec asecd asech csc cscd csch acsc acscd acsch cot cotd coth acot acotd acoth hypot exp expm1 log log1p log10 log2 pow2 realpow reallog realsqrt sqrt nthroot nextpow2 abs angle complex conj imag real unwrap isreal cplxpair fix floor ceil round mod rem sign airy besselj bessely besselh besseli besselk beta betainc betaln ellipj ellipke erf erfc erfcx erfinv expint gamma gammainc gammaln psi legendre cross dot factor isprime primes gcd lcm rat rats perms nchoosek factorial cart2sph cart2pol pol2cart sph2cart hsv2rgb rgb2hsv zeros ones eye repmat rand randn linspace logspace freqspace meshgrid accumarray size length ndims numel disp isempty isequal isequalwithequalnans cat reshape diag blkdiag tril triu fliplr flipud flipdim rot90 find sub2ind ind2sub bsxfun ndgrid permute ipermute shiftdim circshift squeeze isscalar isvector ans eps realmax realmin pi i|0 inf nan isnan isinf isfinite j|0 why compan gallery hadamard hankel hilb invhilb magic pascal rosser toeplitz vander wilkinson max min nanmax nanmin mean nanmean type table readtable writetable sortrows sort figure plot plot3 scatter scatter3 cellfun legend intersect ismember procrustes hold num2cell "
-},illegal:'(//|"|#|/\\*|\\s+/\\w+)',contains:[{className:"function",
-beginKeywords:"function",end:"$",contains:[e.UNDERSCORE_TITLE_MODE,{
-className:"params",variants:[{begin:"\\(",end:"\\)"},{begin:"\\[",end:"\\]"}]}]
-},{className:"built_in",begin:/true|false/,relevance:0,starts:a},{
-begin:"[a-zA-Z][a-zA-Z_0-9]*('|\\.')+",relevance:0},{className:"number",
-begin:e.C_NUMBER_RE,relevance:0,starts:a},{className:"string",begin:"'",end:"'",
-contains:[e.BACKSLASH_ESCAPE,{begin:"''"}]},{begin:/\]|\}|\)/,relevance:0,
-starts:a},{className:"string",begin:'"',end:'"',contains:[e.BACKSLASH_ESCAPE,{
-begin:'""'}],starts:a
-},e.COMMENT("^\\s*%\\{\\s*$","^\\s*%\\}\\s*$"),e.COMMENT("%","$")]}}})());
-hljs.registerLanguage("maxima",(()=>{"use strict";return e=>({name:"Maxima",
-keywords:{$pattern:"[A-Za-z_%][0-9A-Za-z_%]*",
-keyword:"if then else elseif for thru do while unless step in and or not",
-literal:"true false unknown inf minf ind und %e %i %pi %phi %gamma",
-built_in:" abasep abs absint absolute_real_time acos acosh acot acoth acsc acsch activate addcol add_edge add_edges addmatrices addrow add_vertex add_vertices adjacency_matrix adjoin adjoint af agd airy airy_ai airy_bi airy_dai airy_dbi algsys alg_type alias allroots alphacharp alphanumericp amortization %and annuity_fv annuity_pv antid antidiff AntiDifference append appendfile apply apply1 apply2 applyb1 apropos args arit_amortization arithmetic arithsum array arrayapply arrayinfo arraymake arraysetapply ascii asec asech asin asinh askinteger asksign assoc assoc_legendre_p assoc_legendre_q assume assume_external_byte_order asympa at atan atan2 atanh atensimp atom atvalue augcoefmatrix augmented_lagrangian_method av average_degree backtrace bars barsplot barsplot_description base64 base64_decode bashindices batch batchload bc2 bdvac belln benefit_cost bern bernpoly bernstein_approx bernstein_expand bernstein_poly bessel bessel_i bessel_j bessel_k bessel_simplify bessel_y beta beta_incomplete beta_incomplete_generalized beta_incomplete_regularized bezout bfallroots bffac bf_find_root bf_fmin_cobyla bfhzeta bfloat bfloatp bfpsi bfpsi0 bfzeta biconnected_components bimetric binomial bipartition block blockmatrixp bode_gain bode_phase bothcoef box boxplot boxplot_description break bug_report build_info|10 buildq build_sample burn cabs canform canten cardinality carg cartan cartesian_product catch cauchy_matrix cbffac cdf_bernoulli cdf_beta cdf_binomial cdf_cauchy cdf_chi2 cdf_continuous_uniform cdf_discrete_uniform cdf_exp cdf_f cdf_gamma cdf_general_finite_discrete cdf_geometric cdf_gumbel cdf_hypergeometric cdf_laplace cdf_logistic cdf_lognormal cdf_negative_binomial cdf_noncentral_chi2 cdf_noncentral_student_t cdf_normal cdf_pareto cdf_poisson cdf_rank_sum cdf_rayleigh cdf_signed_rank cdf_student_t cdf_weibull cdisplay ceiling central_moment cequal cequalignore cf cfdisrep cfexpand cgeodesic cgreaterp cgreaterpignore changename changevar chaosgame charat charfun charfun2 charlist charp charpoly chdir chebyshev_t chebyshev_u checkdiv check_overlaps chinese cholesky christof chromatic_index chromatic_number cint circulant_graph clear_edge_weight clear_rules clear_vertex_label clebsch_gordan clebsch_graph clessp clesspignore close closefile cmetric coeff coefmatrix cograd col collapse collectterms columnop columnspace columnswap columnvector combination combine comp2pui compare compfile compile compile_file complement_graph complete_bipartite_graph complete_graph complex_number_p components compose_functions concan concat conjugate conmetderiv connected_components connect_vertices cons constant constantp constituent constvalue cont2part content continuous_freq contortion contour_plot contract contract_edge contragrad contrib_ode convert coord copy copy_file copy_graph copylist copymatrix cor cos cosh cot coth cov cov1 covdiff covect covers crc24sum create_graph create_list csc csch csetup cspline ctaylor ct_coordsys ctransform ctranspose cube_graph cuboctahedron_graph cunlisp cv cycle_digraph cycle_graph cylindrical days360 dblint deactivate declare declare_constvalue declare_dimensions declare_fundamental_dimensions declare_fundamental_units declare_qty declare_translated declare_unit_conversion declare_units declare_weights decsym defcon define define_alt_display define_variable defint defmatch defrule defstruct deftaylor degree_sequence del delete deleten delta demo demoivre denom depends derivdegree derivlist describe desolve determinant dfloat dgauss_a dgauss_b dgeev dgemm dgeqrf dgesv dgesvd diag diagmatrix diag_matrix diagmatrixp diameter diff digitcharp dimacs_export dimacs_import dimension dimensionless dimensions dimensions_as_list direct directory discrete_freq disjoin disjointp disolate disp dispcon dispform dispfun dispJordan display disprule dispterms distrib divide divisors divsum dkummer_m dkummer_u dlange dodecahedron_graph dotproduct dotsimp dpart draw draw2d draw3d drawdf draw_file draw_graph dscalar echelon edge_coloring edge_connectivity edges eigens_by_jacobi eigenvalues eigenvectors eighth einstein eivals eivects elapsed_real_time elapsed_run_time ele2comp ele2polynome ele2pui elem elementp elevation_grid elim elim_allbut eliminate eliminate_using ellipse elliptic_e elliptic_ec elliptic_eu elliptic_f elliptic_kc elliptic_pi ematrix empty_graph emptyp endcons entermatrix entertensor entier equal equalp equiv_classes erf erfc erf_generalized erfi errcatch error errormsg errors euler ev eval_string evenp every evolution evolution2d evundiff example exp expand expandwrt expandwrt_factored expint expintegral_chi expintegral_ci expintegral_e expintegral_e1 expintegral_ei expintegral_e_simplify expintegral_li expintegral_shi expintegral_si explicit explose exponentialize express expt exsec extdiff extract_linear_equations extremal_subset ezgcd %f f90 facsum factcomb factor factorfacsum factorial factorout factorsum facts fast_central_elements fast_linsolve fasttimes featurep fernfale fft fib fibtophi fifth filename_merge file_search file_type fillarray findde find_root find_root_abs find_root_error find_root_rel first fix flatten flength float floatnump floor flower_snark flush flush1deriv flushd flushnd flush_output fmin_cobyla forget fortran fourcos fourexpand fourier fourier_elim fourint fourintcos fourintsin foursimp foursin fourth fposition frame_bracket freeof freshline fresnel_c fresnel_s from_adjacency_matrix frucht_graph full_listify fullmap fullmapl fullratsimp fullratsubst fullsetify funcsolve fundamental_dimensions fundamental_units fundef funmake funp fv g0 g1 gamma gamma_greek gamma_incomplete gamma_incomplete_generalized gamma_incomplete_regularized gauss gauss_a gauss_b gaussprob gcd gcdex gcdivide gcfac gcfactor gd generalized_lambert_w genfact gen_laguerre genmatrix gensym geo_amortization geo_annuity_fv geo_annuity_pv geomap geometric geometric_mean geosum get getcurrentdirectory get_edge_weight getenv get_lu_factors get_output_stream_string get_pixel get_plot_option get_tex_environment get_tex_environment_default get_vertex_label gfactor gfactorsum ggf girth global_variances gn gnuplot_close gnuplot_replot gnuplot_reset gnuplot_restart gnuplot_start go Gosper GosperSum gr2d gr3d gradef gramschmidt graph6_decode graph6_encode graph6_export graph6_import graph_center graph_charpoly graph_eigenvalues graph_flow graph_order graph_periphery graph_product graph_size graph_union great_rhombicosidodecahedron_graph great_rhombicuboctahedron_graph grid_graph grind grobner_basis grotzch_graph hamilton_cycle hamilton_path hankel hankel_1 hankel_2 harmonic harmonic_mean hav heawood_graph hermite hessian hgfred hilbertmap hilbert_matrix hipow histogram histogram_description hodge horner hypergeometric i0 i1 %ibes ic1 ic2 ic_convert ichr1 ichr2 icosahedron_graph icosidodecahedron_graph icurvature ident identfor identity idiff idim idummy ieqn %if ifactors iframes ifs igcdex igeodesic_coords ilt image imagpart imetric implicit implicit_derivative implicit_plot indexed_tensor indices induced_subgraph inferencep inference_result infix info_display init_atensor init_ctensor in_neighbors innerproduct inpart inprod inrt integerp integer_partitions integrate intersect intersection intervalp intopois intosum invariant1 invariant2 inverse_fft inverse_jacobi_cd inverse_jacobi_cn inverse_jacobi_cs inverse_jacobi_dc inverse_jacobi_dn inverse_jacobi_ds inverse_jacobi_nc inverse_jacobi_nd inverse_jacobi_ns inverse_jacobi_sc inverse_jacobi_sd inverse_jacobi_sn invert invert_by_adjoint invert_by_lu inv_mod irr is is_biconnected is_bipartite is_connected is_digraph is_edge_in_graph is_graph is_graph_or_digraph ishow is_isomorphic isolate isomorphism is_planar isqrt isreal_p is_sconnected is_tree is_vertex_in_graph items_inference %j j0 j1 jacobi jacobian jacobi_cd jacobi_cn jacobi_cs jacobi_dc jacobi_dn jacobi_ds jacobi_nc jacobi_nd jacobi_ns jacobi_p jacobi_sc jacobi_sd jacobi_sn JF jn join jordan julia julia_set julia_sin %k kdels kdelta kill killcontext kostka kron_delta kronecker_product kummer_m kummer_u kurtosis kurtosis_bernoulli kurtosis_beta kurtosis_binomial kurtosis_chi2 kurtosis_continuous_uniform kurtosis_discrete_uniform kurtosis_exp kurtosis_f kurtosis_gamma kurtosis_general_finite_discrete kurtosis_geometric kurtosis_gumbel kurtosis_hypergeometric kurtosis_laplace kurtosis_logistic kurtosis_lognormal kurtosis_negative_binomial kurtosis_noncentral_chi2 kurtosis_noncentral_student_t kurtosis_normal kurtosis_pareto kurtosis_poisson kurtosis_rayleigh kurtosis_student_t kurtosis_weibull label labels lagrange laguerre lambda lambert_w laplace laplacian_matrix last lbfgs lc2kdt lcharp lc_l lcm lc_u ldefint ldisp ldisplay legendre_p legendre_q leinstein length let letrules letsimp levi_civita lfreeof lgtreillis lhs li liediff limit Lindstedt linear linearinterpol linear_program linear_regression line_graph linsolve listarray list_correlations listify list_matrix_entries list_nc_monomials listoftens listofvars listp lmax lmin load loadfile local locate_matrix_entry log logcontract log_gamma lopow lorentz_gauge lowercasep lpart lratsubst lreduce lriemann lsquares_estimates lsquares_estimates_approximate lsquares_estimates_exact lsquares_mse lsquares_residual_mse lsquares_residuals lsum ltreillis lu_backsub lucas lu_factor %m macroexpand macroexpand1 make_array makebox makefact makegamma make_graph make_level_picture makelist makeOrders make_poly_continent make_poly_country make_polygon make_random_state make_rgb_picture makeset make_string_input_stream make_string_output_stream make_transform mandelbrot mandelbrot_set map mapatom maplist matchdeclare matchfix mat_cond mat_fullunblocker mat_function mathml_display mat_norm matrix matrixmap matrixp matrix_size mattrace mat_trace mat_unblocker max max_clique max_degree max_flow maximize_lp max_independent_set max_matching maybe md5sum mean mean_bernoulli mean_beta mean_binomial mean_chi2 mean_continuous_uniform mean_deviation mean_discrete_uniform mean_exp mean_f mean_gamma mean_general_finite_discrete mean_geometric mean_gumbel mean_hypergeometric mean_laplace mean_logistic mean_lognormal mean_negative_binomial mean_noncentral_chi2 mean_noncentral_student_t mean_normal mean_pareto mean_poisson mean_rayleigh mean_student_t mean_weibull median median_deviation member mesh metricexpandall mgf1_sha1 min min_degree min_edge_cut minfactorial minimalPoly minimize_lp minimum_spanning_tree minor minpack_lsquares minpack_solve min_vertex_cover min_vertex_cut mkdir mnewton mod mode_declare mode_identity ModeMatrix moebius mon2schur mono monomial_dimensions multibernstein_poly multi_display_for_texinfo multi_elem multinomial multinomial_coeff multi_orbit multiplot_mode multi_pui multsym multthru mycielski_graph nary natural_unit nc_degree ncexpt ncharpoly negative_picture neighbors new newcontext newdet new_graph newline newton new_variable next_prime nicedummies niceindices ninth nofix nonarray noncentral_moment nonmetricity nonnegintegerp nonscalarp nonzeroandfreeof notequal nounify nptetrad npv nroots nterms ntermst nthroot nullity nullspace num numbered_boundaries numberp number_to_octets num_distinct_partitions numerval numfactor num_partitions nusum nzeta nzetai nzetar octets_to_number octets_to_oid odd_girth oddp ode2 ode_check odelin oid_to_octets op opena opena_binary openr openr_binary openw openw_binary operatorp opsubst optimize %or orbit orbits ordergreat ordergreatp orderless orderlessp orthogonal_complement orthopoly_recur orthopoly_weight outermap out_neighbors outofpois pade parabolic_cylinder_d parametric parametric_surface parg parGosper parse_string parse_timedate part part2cont partfrac partition partition_set partpol path_digraph path_graph pathname_directory pathname_name pathname_type pdf_bernoulli pdf_beta pdf_binomial pdf_cauchy pdf_chi2 pdf_continuous_uniform pdf_discrete_uniform pdf_exp pdf_f pdf_gamma pdf_general_finite_discrete pdf_geometric pdf_gumbel pdf_hypergeometric pdf_laplace pdf_logistic pdf_lognormal pdf_negative_binomial pdf_noncentral_chi2 pdf_noncentral_student_t pdf_normal pdf_pareto pdf_poisson pdf_rank_sum pdf_rayleigh pdf_signed_rank pdf_student_t pdf_weibull pearson_skewness permanent permut permutation permutations petersen_graph petrov pickapart picture_equalp picturep piechart piechart_description planar_embedding playback plog plot2d plot3d plotdf ploteq plsquares pochhammer points poisdiff poisexpt poisint poismap poisplus poissimp poissubst poistimes poistrim polar polarform polartorect polar_to_xy poly_add poly_buchberger poly_buchberger_criterion poly_colon_ideal poly_content polydecomp poly_depends_p poly_elimination_ideal poly_exact_divide poly_expand poly_expt poly_gcd polygon poly_grobner poly_grobner_equal poly_grobner_member poly_grobner_subsetp poly_ideal_intersection poly_ideal_polysaturation poly_ideal_polysaturation1 poly_ideal_saturation poly_ideal_saturation1 poly_lcm poly_minimization polymod poly_multiply polynome2ele polynomialp poly_normal_form poly_normalize poly_normalize_list poly_polysaturation_extension poly_primitive_part poly_pseudo_divide poly_reduced_grobner poly_reduction poly_saturation_extension poly_s_polynomial poly_subtract polytocompanion pop postfix potential power_mod powerseries powerset prefix prev_prime primep primes principal_components print printf printfile print_graph printpois printprops prodrac product properties propvars psi psubst ptriangularize pui pui2comp pui2ele pui2polynome pui_direct puireduc push put pv qput qrange qty quad_control quad_qag quad_qagi quad_qagp quad_qags quad_qawc quad_qawf quad_qawo quad_qaws quadrilateral quantile quantile_bernoulli quantile_beta quantile_binomial quantile_cauchy quantile_chi2 quantile_continuous_uniform quantile_discrete_uniform quantile_exp quantile_f quantile_gamma quantile_general_finite_discrete quantile_geometric quantile_gumbel quantile_hypergeometric quantile_laplace quantile_logistic quantile_lognormal quantile_negative_binomial quantile_noncentral_chi2 quantile_noncentral_student_t quantile_normal quantile_pareto quantile_poisson quantile_rayleigh quantile_student_t quantile_weibull quartile_skewness quit qunit quotient racah_v racah_w radcan radius random random_bernoulli random_beta random_binomial random_bipartite_graph random_cauchy random_chi2 random_continuous_uniform random_digraph random_discrete_uniform random_exp random_f random_gamma random_general_finite_discrete random_geometric random_graph random_graph1 random_gumbel random_hypergeometric random_laplace random_logistic random_lognormal random_negative_binomial random_network random_noncentral_chi2 random_noncentral_student_t random_normal random_pareto random_permutation random_poisson random_rayleigh random_regular_graph random_student_t random_tournament random_tree random_weibull range rank rat ratcoef ratdenom ratdiff ratdisrep ratexpand ratinterpol rational rationalize ratnumer ratnump ratp ratsimp ratsubst ratvars ratweight read read_array read_binary_array read_binary_list read_binary_matrix readbyte readchar read_hashed_array readline read_list read_matrix read_nested_list readonly read_xpm real_imagpart_to_conjugate realpart realroots rearray rectangle rectform rectform_log_if_constant recttopolar rediff reduce_consts reduce_order region region_boundaries region_boundaries_plus rem remainder remarray rembox remcomps remcon remcoord remfun remfunction remlet remove remove_constvalue remove_dimensions remove_edge remove_fundamental_dimensions remove_fundamental_units remove_plot_option remove_vertex rempart remrule remsym remvalue rename rename_file reset reset_displays residue resolvante resolvante_alternee1 resolvante_bipartite resolvante_diedrale resolvante_klein resolvante_klein3 resolvante_produit_sym resolvante_unitaire resolvante_vierer rest resultant return reveal reverse revert revert2 rgb2level rhs ricci riemann rinvariant risch rk rmdir rncombine romberg room rootscontract round row rowop rowswap rreduce run_testsuite %s save saving scalarp scaled_bessel_i scaled_bessel_i0 scaled_bessel_i1 scalefactors scanmap scatterplot scatterplot_description scene schur2comp sconcat scopy scsimp scurvature sdowncase sec sech second sequal sequalignore set_alt_display setdifference set_draw_defaults set_edge_weight setelmx setequalp setify setp set_partitions set_plot_option set_prompt set_random_state set_tex_environment set_tex_environment_default setunits setup_autoload set_up_dot_simplifications set_vertex_label seventh sexplode sf sha1sum sha256sum shortest_path shortest_weighted_path show showcomps showratvars sierpinskiale sierpinskimap sign signum similaritytransform simp_inequality simplify_sum simplode simpmetderiv simtran sin sinh sinsert sinvertcase sixth skewness skewness_bernoulli skewness_beta skewness_binomial skewness_chi2 skewness_continuous_uniform skewness_discrete_uniform skewness_exp skewness_f skewness_gamma skewness_general_finite_discrete skewness_geometric skewness_gumbel skewness_hypergeometric skewness_laplace skewness_logistic skewness_lognormal skewness_negative_binomial skewness_noncentral_chi2 skewness_noncentral_student_t skewness_normal skewness_pareto skewness_poisson skewness_rayleigh skewness_student_t skewness_weibull slength smake small_rhombicosidodecahedron_graph small_rhombicuboctahedron_graph smax smin smismatch snowmap snub_cube_graph snub_dodecahedron_graph solve solve_rec solve_rec_rat some somrac sort sparse6_decode sparse6_encode sparse6_export sparse6_import specint spherical spherical_bessel_j spherical_bessel_y spherical_hankel1 spherical_hankel2 spherical_harmonic spherical_to_xyz splice split sposition sprint sqfr sqrt sqrtdenest sremove sremovefirst sreverse ssearch ssort sstatus ssubst ssubstfirst staircase standardize standardize_inverse_trig starplot starplot_description status std std1 std_bernoulli std_beta std_binomial std_chi2 std_continuous_uniform std_discrete_uniform std_exp std_f std_gamma std_general_finite_discrete std_geometric std_gumbel std_hypergeometric std_laplace std_logistic std_lognormal std_negative_binomial std_noncentral_chi2 std_noncentral_student_t std_normal std_pareto std_poisson std_rayleigh std_student_t std_weibull stemplot stirling stirling1 stirling2 strim striml strimr string stringout stringp strong_components struve_h struve_l sublis sublist sublist_indices submatrix subsample subset subsetp subst substinpart subst_parallel substpart substring subvar subvarp sum sumcontract summand_to_rec supcase supcontext symbolp symmdifference symmetricp system take_channel take_inference tan tanh taylor taylorinfo taylorp taylor_simplifier taytorat tcl_output tcontract tellrat tellsimp tellsimpafter tentex tenth test_mean test_means_difference test_normality test_proportion test_proportions_difference test_rank_sum test_sign test_signed_rank test_variance test_variance_ratio tex tex1 tex_display texput %th third throw time timedate timer timer_info tldefint tlimit todd_coxeter toeplitz tokens to_lisp topological_sort to_poly to_poly_solve totaldisrep totalfourier totient tpartpol trace tracematrix trace_options transform_sample translate translate_file transpose treefale tree_reduce treillis treinat triangle triangularize trigexpand trigrat trigreduce trigsimp trunc truncate truncated_cube_graph truncated_dodecahedron_graph truncated_icosahedron_graph truncated_tetrahedron_graph tr_warnings_get tube tutte_graph ueivects uforget ultraspherical underlying_graph undiff union unique uniteigenvectors unitp units unit_step unitvector unorder unsum untellrat untimer untrace uppercasep uricci uriemann uvect vandermonde_matrix var var1 var_bernoulli var_beta var_binomial var_chi2 var_continuous_uniform var_discrete_uniform var_exp var_f var_gamma var_general_finite_discrete var_geometric var_gumbel var_hypergeometric var_laplace var_logistic var_lognormal var_negative_binomial var_noncentral_chi2 var_noncentral_student_t var_normal var_pareto var_poisson var_rayleigh var_student_t var_weibull vector vectorpotential vectorsimp verbify vers vertex_coloring vertex_connectivity vertex_degree vertex_distance vertex_eccentricity vertex_in_degree vertex_out_degree vertices vertices_to_cycle vertices_to_path %w weyl wheel_graph wiener_index wigner_3j wigner_6j wigner_9j with_stdout write_binary_data writebyte write_data writefile wronskian xreduce xthru %y Zeilberger zeroequiv zerofor zeromatrix zeromatrixp zeta zgeev zheev zlange zn_add_table zn_carmichael_lambda zn_characteristic_factors zn_determinant zn_factor_generators zn_invert_by_lu zn_log zn_mult_table absboxchar activecontexts adapt_depth additive adim aform algebraic algepsilon algexact aliases allbut all_dotsimp_denoms allocation allsym alphabetic animation antisymmetric arrays askexp assume_pos assume_pos_pred assumescalar asymbol atomgrad atrig1 axes axis_3d axis_bottom axis_left axis_right axis_top azimuth background background_color backsubst berlefact bernstein_explicit besselexpand beta_args_sum_to_integer beta_expand bftorat bftrunc bindtest border boundaries_array box boxchar breakup %c capping cauchysum cbrange cbtics center cflength cframe_flag cnonmet_flag color color_bar color_bar_tics colorbox columns commutative complex cone context contexts contour contour_levels cosnpiflag ctaypov ctaypt ctayswitch ctayvar ct_coords ctorsion_flag ctrgsimp cube current_let_rule_package cylinder data_file_name debugmode decreasing default_let_rule_package delay dependencies derivabbrev derivsubst detout diagmetric diff dim dimensions dispflag display2d|10 display_format_internal distribute_over doallmxops domain domxexpt domxmxops domxnctimes dontfactor doscmxops doscmxplus dot0nscsimp dot0simp dot1simp dotassoc dotconstrules dotdistrib dotexptsimp dotident dotscrules draw_graph_program draw_realpart edge_color edge_coloring edge_partition edge_type edge_width %edispflag elevation %emode endphi endtheta engineering_format_floats enhanced3d %enumer epsilon_lp erfflag erf_representation errormsg error_size error_syms error_type %e_to_numlog eval even evenfun evflag evfun ev_point expandwrt_denom expintexpand expintrep expon expop exptdispflag exptisolate exptsubst facexpand facsum_combine factlim factorflag factorial_expand factors_only fb feature features file_name file_output_append file_search_demo file_search_lisp file_search_maxima|10 file_search_tests file_search_usage file_type_lisp file_type_maxima|10 fill_color fill_density filled_func fixed_vertices flipflag float2bf font font_size fortindent fortspaces fpprec fpprintprec functions gamma_expand gammalim gdet genindex gensumnum GGFCFMAX GGFINFINITY globalsolve gnuplot_command gnuplot_curve_styles gnuplot_curve_titles gnuplot_default_term_command gnuplot_dumb_term_command gnuplot_file_args gnuplot_file_name gnuplot_out_file gnuplot_pdf_term_command gnuplot_pm3d gnuplot_png_term_command gnuplot_postamble gnuplot_preamble gnuplot_ps_term_command gnuplot_svg_term_command gnuplot_term gnuplot_view_args Gosper_in_Zeilberger gradefs grid grid2d grind halfangles head_angle head_both head_length head_type height hypergeometric_representation %iargs ibase icc1 icc2 icounter idummyx ieqnprint ifb ifc1 ifc2 ifg ifgi ifr iframe_bracket_form ifri igeowedge_flag ikt1 ikt2 imaginary inchar increasing infeval infinity inflag infolists inm inmc1 inmc2 intanalysis integer integervalued integrate_use_rootsof integration_constant integration_constant_counter interpolate_color intfaclim ip_grid ip_grid_in irrational isolate_wrt_times iterations itr julia_parameter %k1 %k2 keepfloat key key_pos kinvariant kt label label_alignment label_orientation labels lassociative lbfgs_ncorrections lbfgs_nfeval_max leftjust legend letrat let_rule_packages lfg lg lhospitallim limsubst linear linear_solver linechar linel|10 linenum line_type linewidth line_width linsolve_params linsolvewarn lispdisp listarith listconstvars listdummyvars lmxchar load_pathname loadprint logabs logarc logcb logconcoeffp logexpand lognegint logsimp logx logx_secondary logy logy_secondary logz lriem m1pbranch macroexpansion macros mainvar manual_demo maperror mapprint matrix_element_add matrix_element_mult matrix_element_transpose maxapplydepth maxapplyheight maxima_tempdir|10 maxima_userdir|10 maxnegex MAX_ORD maxposex maxpsifracdenom maxpsifracnum maxpsinegint maxpsiposint maxtayorder mesh_lines_color method mod_big_prime mode_check_errorp mode_checkp mode_check_warnp mod_test mod_threshold modular_linear_solver modulus multiplicative multiplicities myoptions nary negdistrib negsumdispflag newline newtonepsilon newtonmaxiter nextlayerfactor niceindicespref nm nmc noeval nolabels nonegative_lp noninteger nonscalar noun noundisp nouns np npi nticks ntrig numer numer_pbranch obase odd oddfun opacity opproperties opsubst optimprefix optionset orientation origin orthopoly_returns_intervals outative outchar packagefile palette partswitch pdf_file pfeformat phiresolution %piargs piece pivot_count_sx pivot_max_sx plot_format plot_options plot_realpart png_file pochhammer_max_index points pointsize point_size points_joined point_type poislim poisson poly_coefficient_ring poly_elimination_order polyfactor poly_grobner_algorithm poly_grobner_debug poly_monomial_order poly_primary_elimination_order poly_return_term_list poly_secondary_elimination_order poly_top_reduction_only posfun position powerdisp pred prederror primep_number_of_tests product_use_gamma program programmode promote_float_to_bigfloat prompt proportional_axes props psexpand ps_file radexpand radius radsubstflag rassociative ratalgdenom ratchristof ratdenomdivide rateinstein ratepsilon ratfac rational ratmx ratprint ratriemann ratsimpexpons ratvarswitch ratweights ratweyl ratwtlvl real realonly redraw refcheck resolution restart resultant ric riem rmxchar %rnum_list rombergabs rombergit rombergmin rombergtol rootsconmode rootsepsilon run_viewer same_xy same_xyz savedef savefactors scalar scalarmatrixp scale scale_lp setcheck setcheckbreak setval show_edge_color show_edges show_edge_type show_edge_width show_id show_label showtime show_vertex_color show_vertex_size show_vertex_type show_vertices show_weight simp simplified_output simplify_products simpproduct simpsum sinnpiflag solvedecomposes solveexplicit solvefactors solvenullwarn solveradcan solvetrigwarn space sparse sphere spring_embedding_depth sqrtdispflag stardisp startphi starttheta stats_numer stringdisp structures style sublis_apply_lambda subnumsimp sumexpand sumsplitfact surface surface_hide svg_file symmetric tab taylordepth taylor_logexpand taylor_order_coefficients taylor_truncate_polynomials tensorkill terminal testsuite_files thetaresolution timer_devalue title tlimswitch tr track transcompile transform transform_xy translate_fast_arrays transparent transrun tr_array_as_ref tr_bound_function_applyp tr_file_tty_messagesp tr_float_can_branch_complex tr_function_call_default trigexpandplus trigexpandtimes triginverses trigsign trivial_solutions tr_numer tr_optimize_max_loop tr_semicompile tr_state_vars tr_warn_bad_function_calls tr_warn_fexpr tr_warn_meval tr_warn_mode tr_warn_undeclared tr_warn_undefined_variable tstep ttyoff tube_extremes ufg ug %unitexpand unit_vectors uric uriem use_fast_arrays user_preamble usersetunits values vect_cross verbose vertex_color vertex_coloring vertex_partition vertex_size vertex_type view warnings weyl width windowname windowtitle wired_surface wireframe xaxis xaxis_color xaxis_secondary xaxis_type xaxis_width xlabel xlabel_secondary xlength xrange xrange_secondary xtics xtics_axis xtics_rotate xtics_rotate_secondary xtics_secondary xtics_secondary_axis xu_grid x_voxel xy_file xyplane xy_scale yaxis yaxis_color yaxis_secondary yaxis_type yaxis_width ylabel ylabel_secondary ylength yrange yrange_secondary ytics ytics_axis ytics_rotate ytics_rotate_secondary ytics_secondary ytics_secondary_axis yv_grid y_voxel yx_ratio zaxis zaxis_color zaxis_type zaxis_width zeroa zerob zerobern zeta%pi zlabel zlabel_rotate zlength zmin zn_primroot_limit zn_primroot_pretest",
-symbol:"_ __ %|0 %%|0"},contains:[{className:"comment",begin:"/\\*",end:"\\*/",
-contains:["self"]},e.QUOTE_STRING_MODE,{className:"number",relevance:0,
-variants:[{begin:"\\b(\\d+|\\d+\\.|\\.\\d+|\\d+\\.\\d+)[Ee][-+]?\\d+\\b"},{
-begin:"\\b(\\d+|\\d+\\.|\\.\\d+|\\d+\\.\\d+)[Bb][-+]?\\d+\\b",relevance:10},{
-begin:"\\b(\\.\\d+|\\d+\\.\\d+)\\b"},{begin:"\\b(\\d+|0[0-9A-Za-z]+)\\.?\\b"}]
-}],illegal:/@/})})());
-hljs.registerLanguage("mel",(()=>{"use strict";return e=>({name:"MEL",
-keywords:"int float string vector matrix if else switch case default while do for in break continue global proc return about abs addAttr addAttributeEditorNodeHelp addDynamic addNewShelfTab addPP addPanelCategory addPrefixToName advanceToNextDrivenKey affectedNet affects aimConstraint air alias aliasAttr align alignCtx alignCurve alignSurface allViewFit ambientLight angle angleBetween animCone animCurveEditor animDisplay animView annotate appendStringArray applicationName applyAttrPreset applyTake arcLenDimContext arcLengthDimension arclen arrayMapper art3dPaintCtx artAttrCtx artAttrPaintVertexCtx artAttrSkinPaintCtx artAttrTool artBuildPaintMenu artFluidAttrCtx artPuttyCtx artSelectCtx artSetPaintCtx artUserPaintCtx assignCommand assignInputDevice assignViewportFactories attachCurve attachDeviceAttr attachSurface attrColorSliderGrp attrCompatibility attrControlGrp attrEnumOptionMenu attrEnumOptionMenuGrp attrFieldGrp attrFieldSliderGrp attrNavigationControlGrp attrPresetEditWin attributeExists attributeInfo attributeMenu attributeQuery autoKeyframe autoPlace bakeClip bakeFluidShading bakePartialHistory bakeResults bakeSimulation basename basenameEx batchRender bessel bevel bevelPlus binMembership bindSkin blend2 blendShape blendShapeEditor blendShapePanel blendTwoAttr blindDataType boneLattice boundary boxDollyCtx boxZoomCtx bufferCurve buildBookmarkMenu buildKeyframeMenu button buttonManip CBG cacheFile cacheFileCombine cacheFileMerge cacheFileTrack camera cameraView canCreateManip canvas capitalizeString catch catchQuiet ceil changeSubdivComponentDisplayLevel changeSubdivRegion channelBox character characterMap characterOutlineEditor characterize chdir checkBox checkBoxGrp checkDefaultRenderGlobals choice circle circularFillet clamp clear clearCache clip clipEditor clipEditorCurrentTimeCtx clipSchedule clipSchedulerOutliner clipTrimBefore closeCurve closeSurface cluster cmdFileOutput cmdScrollFieldExecuter cmdScrollFieldReporter cmdShell coarsenSubdivSelectionList collision color colorAtPoint colorEditor colorIndex colorIndexSliderGrp colorSliderButtonGrp colorSliderGrp columnLayout commandEcho commandLine commandPort compactHairSystem componentEditor compositingInterop computePolysetVolume condition cone confirmDialog connectAttr connectControl connectDynamic connectJoint connectionInfo constrain constrainValue constructionHistory container containsMultibyte contextInfo control convertFromOldLayers convertIffToPsd convertLightmap convertSolidTx convertTessellation convertUnit copyArray copyFlexor copyKey copySkinWeights cos cpButton cpCache cpClothSet cpCollision cpConstraint cpConvClothToMesh cpForces cpGetSolverAttr cpPanel cpProperty cpRigidCollisionFilter cpSeam cpSetEdit cpSetSolverAttr cpSolver cpSolverTypes cpTool cpUpdateClothUVs createDisplayLayer createDrawCtx createEditor createLayeredPsdFile createMotionField createNewShelf createNode createRenderLayer createSubdivRegion cross crossProduct ctxAbort ctxCompletion ctxEditMode ctxTraverse currentCtx currentTime currentTimeCtx currentUnit curve curveAddPtCtx curveCVCtx curveEPCtx curveEditorCtx curveIntersect curveMoveEPCtx curveOnSurface curveSketchCtx cutKey cycleCheck cylinder dagPose date defaultLightListCheckBox defaultNavigation defineDataServer defineVirtualDevice deformer deg_to_rad delete deleteAttr deleteShadingGroupsAndMaterials deleteShelfTab deleteUI deleteUnusedBrushes delrandstr detachCurve detachDeviceAttr detachSurface deviceEditor devicePanel dgInfo dgdirty dgeval dgtimer dimWhen directKeyCtx directionalLight dirmap dirname disable disconnectAttr disconnectJoint diskCache displacementToPoly displayAffected displayColor displayCull displayLevelOfDetail displayPref displayRGBColor displaySmoothness displayStats displayString displaySurface distanceDimContext distanceDimension doBlur dolly dollyCtx dopeSheetEditor dot dotProduct doubleProfileBirailSurface drag dragAttrContext draggerContext dropoffLocator duplicate duplicateCurve duplicateSurface dynCache dynControl dynExport dynExpression dynGlobals dynPaintEditor dynParticleCtx dynPref dynRelEdPanel dynRelEditor dynamicLoad editAttrLimits editDisplayLayerGlobals editDisplayLayerMembers editRenderLayerAdjustment editRenderLayerGlobals editRenderLayerMembers editor editorTemplate effector emit emitter enableDevice encodeString endString endsWith env equivalent equivalentTol erf error eval evalDeferred evalEcho event exactWorldBoundingBox exclusiveLightCheckBox exec executeForEachObject exists exp expression expressionEditorListen extendCurve extendSurface extrude fcheck fclose feof fflush fgetline fgetword file fileBrowserDialog fileDialog fileExtension fileInfo filetest filletCurve filter filterCurve filterExpand filterStudioImport findAllIntersections findAnimCurves findKeyframe findMenuItem findRelatedSkinCluster finder firstParentOf fitBspline flexor floatEq floatField floatFieldGrp floatScrollBar floatSlider floatSlider2 floatSliderButtonGrp floatSliderGrp floor flow fluidCacheInfo fluidEmitter fluidVoxelInfo flushUndo fmod fontDialog fopen formLayout format fprint frameLayout fread freeFormFillet frewind fromNativePath fwrite gamma gauss geometryConstraint getApplicationVersionAsFloat getAttr getClassification getDefaultBrush getFileList getFluidAttr getInputDeviceRange getMayaPanelTypes getModifiers getPanel getParticleAttr getPluginResource getenv getpid glRender glRenderEditor globalStitch gmatch goal gotoBindPose grabColor gradientControl gradientControlNoAttr graphDollyCtx graphSelectContext graphTrackCtx gravity grid gridLayout group groupObjectsByName HfAddAttractorToAS HfAssignAS HfBuildEqualMap HfBuildFurFiles HfBuildFurImages HfCancelAFR HfConnectASToHF HfCreateAttractor HfDeleteAS HfEditAS HfPerformCreateAS HfRemoveAttractorFromAS HfSelectAttached HfSelectAttractors HfUnAssignAS hardenPointCurve hardware hardwareRenderPanel headsUpDisplay headsUpMessage help helpLine hermite hide hilite hitTest hotBox hotkey hotkeyCheck hsv_to_rgb hudButton hudSlider hudSliderButton hwReflectionMap hwRender hwRenderLoad hyperGraph hyperPanel hyperShade hypot iconTextButton iconTextCheckBox iconTextRadioButton iconTextRadioCollection iconTextScrollList iconTextStaticLabel ikHandle ikHandleCtx ikHandleDisplayScale ikSolver ikSplineHandleCtx ikSystem ikSystemInfo ikfkDisplayMethod illustratorCurves image imfPlugins inheritTransform insertJoint insertJointCtx insertKeyCtx insertKnotCurve insertKnotSurface instance instanceable instancer intField intFieldGrp intScrollBar intSlider intSliderGrp interToUI internalVar intersect iprEngine isAnimCurve isConnected isDirty isParentOf isSameObject isTrue isValidObjectName isValidString isValidUiName isolateSelect itemFilter itemFilterAttr itemFilterRender itemFilterType joint jointCluster jointCtx jointDisplayScale jointLattice keyTangent keyframe keyframeOutliner keyframeRegionCurrentTimeCtx keyframeRegionDirectKeyCtx keyframeRegionDollyCtx keyframeRegionInsertKeyCtx keyframeRegionMoveKeyCtx keyframeRegionScaleKeyCtx keyframeRegionSelectKeyCtx keyframeRegionSetKeyCtx keyframeRegionTrackCtx keyframeStats lassoContext lattice latticeDeformKeyCtx launch launchImageEditor layerButton layeredShaderPort layeredTexturePort layout layoutDialog lightList lightListEditor lightListPanel lightlink lineIntersection linearPrecision linstep listAnimatable listAttr listCameras listConnections listDeviceAttachments listHistory listInputDeviceAxes listInputDeviceButtons listInputDevices listMenuAnnotation listNodeTypes listPanelCategories listRelatives listSets listTransforms listUnselected listerEditor loadFluid loadNewShelf loadPlugin loadPluginLanguageResources loadPrefObjects localizedPanelLabel lockNode loft log longNameOf lookThru ls lsThroughFilter lsType lsUI Mayatomr mag makeIdentity makeLive makePaintable makeRoll makeSingleSurface makeTubeOn makebot manipMoveContext manipMoveLimitsCtx manipOptions manipRotateContext manipRotateLimitsCtx manipScaleContext manipScaleLimitsCtx marker match max memory menu menuBarLayout menuEditor menuItem menuItemToShelf menuSet menuSetPref messageLine min minimizeApp mirrorJoint modelCurrentTimeCtx modelEditor modelPanel mouse movIn movOut move moveIKtoFK moveKeyCtx moveVertexAlongDirection multiProfileBirailSurface mute nParticle nameCommand nameField namespace namespaceInfo newPanelItems newton nodeCast nodeIconButton nodeOutliner nodePreset nodeType noise nonLinear normalConstraint normalize nurbsBoolean nurbsCopyUVSet nurbsCube nurbsEditUV nurbsPlane nurbsSelect nurbsSquare nurbsToPoly nurbsToPolygonsPref nurbsToSubdiv nurbsToSubdivPref nurbsUVSet nurbsViewDirectionVector objExists objectCenter objectLayer objectType objectTypeUI obsoleteProc oceanNurbsPreviewPlane offsetCurve offsetCurveOnSurface offsetSurface openGLExtension openMayaPref optionMenu optionMenuGrp optionVar orbit orbitCtx orientConstraint outlinerEditor outlinerPanel overrideModifier paintEffectsDisplay pairBlend palettePort paneLayout panel panelConfiguration panelHistory paramDimContext paramDimension paramLocator parent parentConstraint particle particleExists particleInstancer particleRenderInfo partition pasteKey pathAnimation pause pclose percent performanceOptions pfxstrokes pickWalk picture pixelMove planarSrf plane play playbackOptions playblast plugAttr plugNode pluginInfo pluginResourceUtil pointConstraint pointCurveConstraint pointLight pointMatrixMult pointOnCurve pointOnSurface pointPosition poleVectorConstraint polyAppend polyAppendFacetCtx polyAppendVertex polyAutoProjection polyAverageNormal polyAverageVertex polyBevel polyBlendColor polyBlindData polyBoolOp polyBridgeEdge polyCacheMonitor polyCheck polyChipOff polyClipboard polyCloseBorder polyCollapseEdge polyCollapseFacet polyColorBlindData polyColorDel polyColorPerVertex polyColorSet polyCompare polyCone polyCopyUV polyCrease polyCreaseCtx polyCreateFacet polyCreateFacetCtx polyCube polyCut polyCutCtx polyCylinder polyCylindricalProjection polyDelEdge polyDelFacet polyDelVertex polyDuplicateAndConnect polyDuplicateEdge polyEditUV polyEditUVShell polyEvaluate polyExtrudeEdge polyExtrudeFacet polyExtrudeVertex polyFlipEdge polyFlipUV polyForceUV polyGeoSampler polyHelix polyInfo polyInstallAction polyLayoutUV polyListComponentConversion polyMapCut polyMapDel polyMapSew polyMapSewMove polyMergeEdge polyMergeEdgeCtx polyMergeFacet polyMergeFacetCtx polyMergeUV polyMergeVertex polyMirrorFace polyMoveEdge polyMoveFacet polyMoveFacetUV polyMoveUV polyMoveVertex polyNormal polyNormalPerVertex polyNormalizeUV polyOptUvs polyOptions polyOutput polyPipe polyPlanarProjection polyPlane polyPlatonicSolid polyPoke polyPrimitive polyPrism polyProjection polyPyramid polyQuad polyQueryBlindData polyReduce polySelect polySelectConstraint polySelectConstraintMonitor polySelectCtx polySelectEditCtx polySeparate polySetToFaceNormal polySewEdge polyShortestPathCtx polySmooth polySoftEdge polySphere polySphericalProjection polySplit polySplitCtx polySplitEdge polySplitRing polySplitVertex polyStraightenUVBorder polySubdivideEdge polySubdivideFacet polyToSubdiv polyTorus polyTransfer polyTriangulate polyUVSet polyUnite polyWedgeFace popen popupMenu pose pow preloadRefEd print progressBar progressWindow projFileViewer projectCurve projectTangent projectionContext projectionManip promptDialog propModCtx propMove psdChannelOutliner psdEditTextureFile psdExport psdTextureFile putenv pwd python querySubdiv quit rad_to_deg radial radioButton radioButtonGrp radioCollection radioMenuItemCollection rampColorPort rand randomizeFollicles randstate rangeControl readTake rebuildCurve rebuildSurface recordAttr recordDevice redo reference referenceEdit referenceQuery refineSubdivSelectionList refresh refreshAE registerPluginResource rehash reloadImage removeJoint removeMultiInstance removePanelCategory rename renameAttr renameSelectionList renameUI render renderGlobalsNode renderInfo renderLayerButton renderLayerParent renderLayerPostProcess renderLayerUnparent renderManip renderPartition renderQualityNode renderSettings renderThumbnailUpdate renderWindowEditor renderWindowSelectContext renderer reorder reorderDeformers requires reroot resampleFluid resetAE resetPfxToPolyCamera resetTool resolutionNode retarget reverseCurve reverseSurface revolve rgb_to_hsv rigidBody rigidSolver roll rollCtx rootOf rot rotate rotationInterpolation roundConstantRadius rowColumnLayout rowLayout runTimeCommand runup sampleImage saveAllShelves saveAttrPreset saveFluid saveImage saveInitialState saveMenu savePrefObjects savePrefs saveShelf saveToolSettings scale scaleBrushBrightness scaleComponents scaleConstraint scaleKey scaleKeyCtx sceneEditor sceneUIReplacement scmh scriptCtx scriptEditorInfo scriptJob scriptNode scriptTable scriptToShelf scriptedPanel scriptedPanelType scrollField scrollLayout sculpt searchPathArray seed selLoadSettings select selectContext selectCurveCV selectKey selectKeyCtx selectKeyframeRegionCtx selectMode selectPref selectPriority selectType selectedNodes selectionConnection separator setAttr setAttrEnumResource setAttrMapping setAttrNiceNameResource setConstraintRestPosition setDefaultShadingGroup setDrivenKeyframe setDynamic setEditCtx setEditor setFluidAttr setFocus setInfinity setInputDeviceMapping setKeyCtx setKeyPath setKeyframe setKeyframeBlendshapeTargetWts setMenuMode setNodeNiceNameResource setNodeTypeFlag setParent setParticleAttr setPfxToPolyCamera setPluginResource setProject setStampDensity setStartupMessage setState setToolTo setUITemplate setXformManip sets shadingConnection shadingGeometryRelCtx shadingLightRelCtx shadingNetworkCompare shadingNode shapeCompare shelfButton shelfLayout shelfTabLayout shellField shortNameOf showHelp showHidden showManipCtx showSelectionInTitle showShadingGroupAttrEditor showWindow sign simplify sin singleProfileBirailSurface size sizeBytes skinCluster skinPercent smoothCurve smoothTangentSurface smoothstep snap2to2 snapKey snapMode snapTogetherCtx snapshot soft softMod softModCtx sort sound soundControl source spaceLocator sphere sphrand spotLight spotLightPreviewPort spreadSheetEditor spring sqrt squareSurface srtContext stackTrace startString startsWith stitchAndExplodeShell stitchSurface stitchSurfacePoints strcmp stringArrayCatenate stringArrayContains stringArrayCount stringArrayInsertAtIndex stringArrayIntersector stringArrayRemove stringArrayRemoveAtIndex stringArrayRemoveDuplicates stringArrayRemoveExact stringArrayToString stringToStringArray strip stripPrefixFromName stroke subdAutoProjection subdCleanTopology subdCollapse subdDuplicateAndConnect subdEditUV subdListComponentConversion subdMapCut subdMapSewMove subdMatchTopology subdMirror subdToBlind subdToPoly subdTransferUVsToCache subdiv subdivCrease subdivDisplaySmoothness substitute substituteAllString substituteGeometry substring surface surfaceSampler surfaceShaderList swatchDisplayPort switchTable symbolButton symbolCheckBox sysFile system tabLayout tan tangentConstraint texLatticeDeformContext texManipContext texMoveContext texMoveUVShellContext texRotateContext texScaleContext texSelectContext texSelectShortestPathCtx texSmudgeUVContext texWinToolCtx text textCurves textField textFieldButtonGrp textFieldGrp textManip textScrollList textToShelf textureDisplacePlane textureHairColor texturePlacementContext textureWindow threadCount threePointArcCtx timeControl timePort timerX toNativePath toggle toggleAxis toggleWindowVisibility tokenize tokenizeList tolerance tolower toolButton toolCollection toolDropped toolHasOptions toolPropertyWindow torus toupper trace track trackCtx transferAttributes transformCompare transformLimits translator trim trunc truncateFluidCache truncateHairCache tumble tumbleCtx turbulence twoPointArcCtx uiRes uiTemplate unassignInputDevice undo undoInfo ungroup uniform unit unloadPlugin untangleUV untitledFileName untrim upAxis updateAE userCtx uvLink uvSnapshot validateShelfName vectorize view2dToolCtx viewCamera viewClipPlane viewFit viewHeadOn viewLookAt viewManip viewPlace viewSet visor volumeAxis vortex waitCursor warning webBrowser webBrowserPrefs whatIs window windowPref wire wireContext workspace wrinkle wrinkleContext writeTake xbmLangPathList xform",
-illegal:"</",contains:[e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{
-className:"string",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE]},{
-begin:/[$%@](\^\w\b|#\w+|[^\s\w{]|\{\w+\}|\w+)/
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]})})());
-hljs.registerLanguage("mercury",(()=>{"use strict";return e=>{
-const i=e.COMMENT("%","$"),n=e.inherit(e.APOS_STRING_MODE,{relevance:0
-}),r=e.inherit(e.QUOTE_STRING_MODE,{relevance:0})
-;return r.contains=r.contains.slice(),r.contains.push({className:"subst",
-begin:"\\\\[abfnrtv]\\|\\\\x[0-9a-fA-F]*\\\\\\|%[-+# *.0-9]*[dioxXucsfeEgGp]",
-relevance:0}),{name:"Mercury",aliases:["m","moo"],keywords:{
-keyword:"module use_module import_module include_module end_module initialise mutable initialize finalize finalise interface implementation pred mode func type inst solver any_pred any_func is semidet det nondet multi erroneous failure cc_nondet cc_multi typeclass instance where pragma promise external trace atomic or_else require_complete_switch require_det require_semidet require_multi require_nondet require_cc_multi require_cc_nondet require_erroneous require_failure",
-meta:"inline no_inline type_spec source_file fact_table obsolete memo loop_check minimal_model terminates does_not_terminate check_termination promise_equivalent_clauses foreign_proc foreign_decl foreign_code foreign_type foreign_import_module foreign_export_enum foreign_export foreign_enum may_call_mercury will_not_call_mercury thread_safe not_thread_safe maybe_thread_safe promise_pure promise_semipure tabled_for_io local untrailed trailed attach_to_io_state can_pass_as_mercury_type stable will_not_throw_exception may_modify_trail will_not_modify_trail may_duplicate may_not_duplicate affects_liveness does_not_affect_liveness doesnt_affect_liveness no_sharing unknown_sharing sharing",
-built_in:"some all not if then else true fail false try catch catch_any semidet_true semidet_false semidet_fail impure_true impure semipure"
-},contains:[{className:"built_in",variants:[{begin:"<=>"},{begin:"<=",
-relevance:0},{begin:"=>",relevance:0},{begin:"/\\\\"},{begin:"\\\\/"}]},{
-className:"built_in",variants:[{begin:":-\\|--\x3e"},{begin:"=",relevance:0}]
-},i,e.C_BLOCK_COMMENT_MODE,{className:"number",begin:"0'.\\|0[box][0-9a-fA-F]*"
-},e.NUMBER_MODE,n,r,{begin:/:-/},{begin:/\.$/}]}}})());
-hljs.registerLanguage("mipsasm",(()=>{"use strict";return e=>({
-name:"MIPS Assembly",case_insensitive:!0,aliases:["mips"],keywords:{
-$pattern:"\\.?"+e.IDENT_RE,
-meta:".2byte .4byte .align .ascii .asciz .balign .byte .code .data .else .end .endif .endm .endr .equ .err .exitm .extern .global .hword .if .ifdef .ifndef .include .irp .long .macro .rept .req .section .set .skip .space .text .word .ltorg ",
-built_in:"$0 $1 $2 $3 $4 $5 $6 $7 $8 $9 $10 $11 $12 $13 $14 $15 $16 $17 $18 $19 $20 $21 $22 $23 $24 $25 $26 $27 $28 $29 $30 $31 zero at v0 v1 a0 a1 a2 a3 a4 a5 a6 a7 t0 t1 t2 t3 t4 t5 t6 t7 t8 t9 s0 s1 s2 s3 s4 s5 s6 s7 s8 k0 k1 gp sp fp ra $f0 $f1 $f2 $f2 $f4 $f5 $f6 $f7 $f8 $f9 $f10 $f11 $f12 $f13 $f14 $f15 $f16 $f17 $f18 $f19 $f20 $f21 $f22 $f23 $f24 $f25 $f26 $f27 $f28 $f29 $f30 $f31 Context Random EntryLo0 EntryLo1 Context PageMask Wired EntryHi HWREna BadVAddr Count Compare SR IntCtl SRSCtl SRSMap Cause EPC PRId EBase Config Config1 Config2 Config3 LLAddr Debug DEPC DESAVE CacheErr ECC ErrorEPC TagLo DataLo TagHi DataHi WatchLo WatchHi PerfCtl PerfCnt "
-},contains:[{className:"keyword",
-begin:"\\b(addi?u?|andi?|b(al)?|beql?|bgez(al)?l?|bgtzl?|blezl?|bltz(al)?l?|bnel?|cl[oz]|divu?|ext|ins|j(al)?|jalr(\\.hb)?|jr(\\.hb)?|lbu?|lhu?|ll|lui|lw[lr]?|maddu?|mfhi|mflo|movn|movz|move|msubu?|mthi|mtlo|mul|multu?|nop|nor|ori?|rotrv?|sb|sc|se[bh]|sh|sllv?|slti?u?|srav?|srlv?|subu?|sw[lr]?|xori?|wsbh|abs\\.[sd]|add\\.[sd]|alnv.ps|bc1[ft]l?|c\\.(s?f|un|u?eq|[ou]lt|[ou]le|ngle?|seq|l[et]|ng[et])\\.[sd]|(ceil|floor|round|trunc)\\.[lw]\\.[sd]|cfc1|cvt\\.d\\.[lsw]|cvt\\.l\\.[dsw]|cvt\\.ps\\.s|cvt\\.s\\.[dlw]|cvt\\.s\\.p[lu]|cvt\\.w\\.[dls]|div\\.[ds]|ldx?c1|luxc1|lwx?c1|madd\\.[sd]|mfc1|mov[fntz]?\\.[ds]|msub\\.[sd]|mth?c1|mul\\.[ds]|neg\\.[ds]|nmadd\\.[ds]|nmsub\\.[ds]|p[lu][lu]\\.ps|recip\\.fmt|r?sqrt\\.[ds]|sdx?c1|sub\\.[ds]|suxc1|swx?c1|break|cache|d?eret|[de]i|ehb|mfc0|mtc0|pause|prefx?|rdhwr|rdpgpr|sdbbp|ssnop|synci?|syscall|teqi?|tgei?u?|tlb(p|r|w[ir])|tlti?u?|tnei?|wait|wrpgpr)",
-end:"\\s"
-},e.COMMENT("[;#](?!\\s*$)","$"),e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,{
-className:"string",begin:"'",end:"[^\\\\]'",relevance:0},{className:"title",
-begin:"\\|",end:"\\|",illegal:"\\n",relevance:0},{className:"number",variants:[{
-begin:"0x[0-9a-f]+"},{begin:"\\b-?\\d+"}],relevance:0},{className:"symbol",
-variants:[{begin:"^\\s*[a-z_\\.\\$][a-z0-9_\\.\\$]+:"},{begin:"^\\s*[0-9]+:"},{
-begin:"[0-9]+[bf]"}],relevance:0}],illegal:/\//})})());
-hljs.registerLanguage("mizar",(()=>{"use strict";return e=>({name:"Mizar",
-keywords:"environ vocabularies notations constructors definitions registrations theorems schemes requirements begin end definition registration cluster existence pred func defpred deffunc theorem proof let take assume then thus hence ex for st holds consider reconsider such that and in provided of as from be being by means equals implies iff redefine define now not or attr is mode suppose per cases set thesis contradiction scheme reserve struct correctness compatibility coherence symmetry assymetry reflexivity irreflexivity connectedness uniqueness commutativity idempotence involutiveness projectivity",
-contains:[e.COMMENT("::","$")]})})());
-hljs.registerLanguage("perl",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}function t(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
-const r=/[dualxmsipngr]{0,12}/,s={$pattern:/[\w.]+/,
-keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0"
-},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:s},a={begin:/->\{/,
-end:/\}/},o={variants:[{begin:/\$\d/},{
-begin:n(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])")
-},{begin:/[$%@][^\s\w{]/,relevance:0}]
-},c=[e.BACKSLASH_ESCAPE,i,o],g=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],l=(e,t,s="\\1")=>{
-const i="\\1"===s?s:n(s,t)
-;return n(n("(?:",e,")"),t,/(?:\\.|[^\\\/])*?/,i,/(?:\\.|[^\\\/])*?/,s,r)
-},d=(e,t,s)=>n(n("(?:",e,")"),t,/(?:\\.|[^\\\/])*?/,s,r),p=[o,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{
-endsWithParent:!0}),a,{className:"string",contains:c,variants:[{
-begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[",
-end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{
-begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">",
-relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'",
-contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`",
-contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{
-begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number",
-begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",
-relevance:0},{
-begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*",
-keywords:"split return print reverse grep",relevance:0,
-contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{
-begin:l("s|tr|y",t(...g))},{begin:l("s|tr|y","\\(","\\)")},{
-begin:l("s|tr|y","\\[","\\]")},{begin:l("s|tr|y","\\{","\\}")}],relevance:2},{
-className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{
-begin:d("(?:m|qr)?",/\//,/\//)},{begin:d("m|qr",t(...g),/\1/)},{
-begin:d("m|qr",/\(/,/\)/)},{begin:d("m|qr",/\[/,/\]/)},{
-begin:d("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub",
-end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{
-begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$",
-subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}]
-}];return i.contains=p,a.contains=p,{name:"Perl",aliases:["pl","pm"],keywords:s,
-contains:p}}})());
-hljs.registerLanguage("mojolicious",(()=>{"use strict";return e=>({
-name:"Mojolicious",subLanguage:"xml",contains:[{className:"meta",
-begin:"^__(END|DATA)__$"},{begin:"^\\s*%{1,2}={0,2}",end:"$",subLanguage:"perl"
-},{begin:"<%{1,2}={0,2}",end:"={0,1}%>",subLanguage:"perl",excludeBegin:!0,
-excludeEnd:!0}]})})());
-hljs.registerLanguage("monkey",(()=>{"use strict";return e=>{const n={
-className:"number",relevance:0,variants:[{begin:"[$][a-fA-F0-9]+"
-},e.NUMBER_MODE]};return{name:"Monkey",case_insensitive:!0,keywords:{
-keyword:"public private property continue exit extern new try catch eachin not abstract final select case default const local global field end if then else elseif endif while wend repeat until forever for to step next return module inline throw import",
-built_in:"DebugLog DebugStop Error Print ACos ACosr ASin ASinr ATan ATan2 ATan2r ATanr Abs Abs Ceil Clamp Clamp Cos Cosr Exp Floor Log Max Max Min Min Pow Sgn Sgn Sin Sinr Sqrt Tan Tanr Seed PI HALFPI TWOPI",
-literal:"true false null and or shl shr mod"},illegal:/\/\*/,
-contains:[e.COMMENT("#rem","#end"),e.COMMENT("'","$",{relevance:0}),{
-className:"function",beginKeywords:"function method",end:"[(=:]|$",illegal:/\n/,
-contains:[e.UNDERSCORE_TITLE_MODE]},{className:"class",
-beginKeywords:"class interface",end:"$",contains:[{
-beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{
-className:"built_in",begin:"\\b(self|super)\\b"},{className:"meta",
-begin:"\\s*#",end:"$",keywords:{"meta-keyword":"if else elseif endif end then"}
-},{className:"meta",begin:"^\\s*strict\\b"},{beginKeywords:"alias",end:"=",
-contains:[e.UNDERSCORE_TITLE_MODE]},e.QUOTE_STRING_MODE,n]}}})());
-hljs.registerLanguage("moonscript",(()=>{"use strict";return e=>{const n={
-keyword:"if then not for in while do return else elseif break continue switch and or unless when class extends super local import export from using",
-literal:"true false nil",
-built_in:"_G _VERSION assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall coroutine debug io math os package string table"
-},s="[A-Za-z$_][0-9A-Za-z$_]*",a={className:"subst",begin:/#\{/,end:/\}/,
-keywords:n},t=[e.inherit(e.C_NUMBER_MODE,{starts:{end:"(\\s*/)?",relevance:0}
-}),{className:"string",variants:[{begin:/'/,end:/'/,
-contains:[e.BACKSLASH_ESCAPE]},{begin:/"/,end:/"/,
-contains:[e.BACKSLASH_ESCAPE,a]}]},{className:"built_in",begin:"@__"+e.IDENT_RE
-},{begin:"@"+e.IDENT_RE},{begin:e.IDENT_RE+"\\\\"+e.IDENT_RE}];a.contains=t
-;const i=e.inherit(e.TITLE_MODE,{begin:s}),r="(\\(.*\\)\\s*)?\\B[-=]>",l={
-className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/,
-end:/\)/,keywords:n,contains:["self"].concat(t)}]};return{name:"MoonScript",
-aliases:["moon"],keywords:n,illegal:/\/\*/,
-contains:t.concat([e.COMMENT("--","$"),{className:"function",
-begin:"^\\s*"+s+"\\s*=\\s*"+r,end:"[-=]>",returnBegin:!0,contains:[i,l]},{
-begin:/[\(,:=]\s*/,relevance:0,contains:[{className:"function",begin:r,
-end:"[-=]>",returnBegin:!0,contains:[l]}]},{className:"class",
-beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{
-beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[i]},i]
-},{className:"name",begin:s+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0
-}])}}})());
-hljs.registerLanguage("n1ql",(()=>{"use strict";return e=>({name:"N1QL",
-case_insensitive:!0,contains:[{
-beginKeywords:"build create index delete drop explain infer|10 insert merge prepare select update upsert|10",
-end:/;/,endsWithParent:!0,keywords:{
-keyword:"all alter analyze and any array as asc begin between binary boolean break bucket build by call case cast cluster collate collection commit connect continue correlate cover create database dataset datastore declare decrement delete derived desc describe distinct do drop each element else end every except exclude execute exists explain fetch first flatten for force from function grant group gsi having if ignore ilike in include increment index infer inline inner insert intersect into is join key keys keyspace known last left let letting like limit lsm map mapping matched materialized merge minus namespace nest not number object offset on option or order outer over parse partition password path pool prepare primary private privilege procedure public raw realm reduce rename return returning revoke right role rollback satisfies schema select self semi set show some start statistics string system then to transaction trigger truncate under union unique unknown unnest unset update upsert use user using validate value valued values via view when where while with within work xor",
-literal:"true false null missing|5",
-built_in:"array_agg array_append array_concat array_contains array_count array_distinct array_ifnull array_length array_max array_min array_position array_prepend array_put array_range array_remove array_repeat array_replace array_reverse array_sort array_sum avg count max min sum greatest least ifmissing ifmissingornull ifnull missingif nullif ifinf ifnan ifnanorinf naninf neginfif posinfif clock_millis clock_str date_add_millis date_add_str date_diff_millis date_diff_str date_part_millis date_part_str date_trunc_millis date_trunc_str duration_to_str millis str_to_millis millis_to_str millis_to_utc millis_to_zone_name now_millis now_str str_to_duration str_to_utc str_to_zone_name decode_json encode_json encoded_size poly_length base64 base64_encode base64_decode meta uuid abs acos asin atan atan2 ceil cos degrees e exp ln log floor pi power radians random round sign sin sqrt tan trunc object_length object_names object_pairs object_inner_pairs object_values object_inner_values object_add object_put object_remove object_unwrap regexp_contains regexp_like regexp_position regexp_replace contains initcap length lower ltrim position repeat replace rtrim split substr title trim upper isarray isatom isboolean isnumber isobject isstring type toarray toatom toboolean tonumber toobject tostring"
-},contains:[{className:"string",begin:"'",end:"'",contains:[e.BACKSLASH_ESCAPE]
-},{className:"string",begin:'"',end:'"',contains:[e.BACKSLASH_ESCAPE]},{
-className:"symbol",begin:"`",end:"`",contains:[e.BACKSLASH_ESCAPE],relevance:2
-},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE]},e.C_BLOCK_COMMENT_MODE]})})());
-hljs.registerLanguage("nginx",(()=>{"use strict";return e=>{const n={
-className:"variable",variants:[{begin:/\$\d+/},{begin:/\$\{/,end:/\}/},{
-begin:/[$@]/+e.UNDERSCORE_IDENT_RE}]},a={endsWithParent:!0,keywords:{
-$pattern:"[a-z/_]+",
-literal:"on off yes no true false none blocked debug info notice warn error crit select break last permanent redirect kqueue rtsig epoll poll /dev/poll"
-},relevance:0,illegal:"=>",contains:[e.HASH_COMMENT_MODE,{className:"string",
-contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/
-}]},{begin:"([a-z]+):/",end:"\\s",endsWithParent:!0,excludeEnd:!0,contains:[n]
-},{className:"regexp",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:"\\s\\^",
-end:"\\s|\\{|;",returnEnd:!0},{begin:"~\\*?\\s+",end:"\\s|\\{|;",returnEnd:!0},{
-begin:"\\*(\\.[a-z\\-]+)+"},{begin:"([a-z\\-]+\\.)+\\*"}]},{className:"number",
-begin:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{
-className:"number",begin:"\\b\\d+[kKmMgGdshdwy]*\\b",relevance:0},n]};return{
-name:"Nginx config",aliases:["nginxconf"],contains:[e.HASH_COMMENT_MODE,{
-begin:e.UNDERSCORE_IDENT_RE+"\\s+\\{",returnBegin:!0,end:/\{/,contains:[{
-className:"section",begin:e.UNDERSCORE_IDENT_RE}],relevance:0},{
-begin:e.UNDERSCORE_IDENT_RE+"\\s",end:";|\\{",returnBegin:!0,contains:[{
-className:"attribute",begin:e.UNDERSCORE_IDENT_RE,starts:a}],relevance:0}],
-illegal:"[^\\s\\}]"}}})());
-hljs.registerLanguage("nim",(()=>{"use strict";return e=>({name:"Nim",keywords:{
-keyword:"addr and as asm bind block break case cast const continue converter discard distinct div do elif else end enum except export finally for from func generic if import in include interface is isnot iterator let macro method mixin mod nil not notin object of or out proc ptr raise ref return shl shr static template try tuple type using var when while with without xor yield",
-literal:"shared guarded stdin stdout stderr result true false",
-built_in:"int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float float32 float64 bool char string cstring pointer expr stmt void auto any range array openarray varargs seq set clong culong cchar cschar cshort cint csize clonglong cfloat cdouble clongdouble cuchar cushort cuint culonglong cstringarray semistatic"
-},contains:[{className:"meta",begin:/\{\./,end:/\.\}/,relevance:10},{
-className:"string",begin:/[a-zA-Z]\w*"/,end:/"/,contains:[{begin:/""/}]},{
-className:"string",begin:/([a-zA-Z]\w*)?"""/,end:/"""/},e.QUOTE_STRING_MODE,{
-className:"type",begin:/\b[A-Z]\w+\b/,relevance:0},{className:"number",
-relevance:0,variants:[{
-begin:/\b(0[xX][0-9a-fA-F][_0-9a-fA-F]*)('?[iIuU](8|16|32|64))?/},{
-begin:/\b(0o[0-7][_0-7]*)('?[iIuUfF](8|16|32|64))?/},{
-begin:/\b(0(b|B)[01][_01]*)('?[iIuUfF](8|16|32|64))?/},{
-begin:/\b(\d[_\d]*)('?[iIuUfF](8|16|32|64))?/}]},e.HASH_COMMENT_MODE]})})());
-hljs.registerLanguage("nix",(()=>{"use strict";return e=>{const n={
-keyword:"rec with let in inherit assert if else then",
-literal:"true false or and null",
-built_in:"import abort baseNameOf dirOf isNull builtins map removeAttrs throw toString derivation"
-},i={className:"subst",begin:/\$\{/,end:/\}/,keywords:n},s={className:"string",
-contains:[i],variants:[{begin:"''",end:"''"},{begin:'"',end:'"'}]
-},t=[e.NUMBER_MODE,e.HASH_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,{
-begin:/[a-zA-Z0-9-_]+(\s*=)/,returnBegin:!0,relevance:0,contains:[{
-className:"attr",begin:/\S+/}]}];return i.contains=t,{name:"Nix",
-aliases:["nixos"],keywords:n,contains:t}}})());
-hljs.registerLanguage("node-repl",(()=>{"use strict";return e=>({
-name:"Node REPL",contains:[{className:"meta",starts:{end:/ |$/,starts:{end:"$",
-subLanguage:"javascript"}},variants:[{begin:/^>(?=[ ]|$)/},{
-begin:/^\.\.\.(?=[ ]|$)/}]}]})})());
-hljs.registerLanguage("nsis",(()=>{"use strict";return e=>{const t={
-className:"variable",begin:/\$+\{[\w.:-]+\}/},n={className:"variable",
-begin:/\$+\w+/,illegal:/\(\)\{\}/},i={className:"variable",
-begin:/\$+\([\w^.:-]+\)/},r={className:"string",variants:[{begin:'"',end:'"'},{
-begin:"'",end:"'"},{begin:"`",end:"`"}],illegal:/\n/,contains:[{
-className:"meta",begin:/\$(\\[nrt]|\$)/},{className:"variable",
-begin:/\$(ADMINTOOLS|APPDATA|CDBURN_AREA|CMDLINE|COMMONFILES32|COMMONFILES64|COMMONFILES|COOKIES|DESKTOP|DOCUMENTS|EXEDIR|EXEFILE|EXEPATH|FAVORITES|FONTS|HISTORY|HWNDPARENT|INSTDIR|INTERNET_CACHE|LANGUAGE|LOCALAPPDATA|MUSIC|NETHOOD|OUTDIR|PICTURES|PLUGINSDIR|PRINTHOOD|PROFILE|PROGRAMFILES32|PROGRAMFILES64|PROGRAMFILES|QUICKLAUNCH|RECENT|RESOURCES_LOCALIZED|RESOURCES|SENDTO|SMPROGRAMS|SMSTARTUP|STARTMENU|SYSDIR|TEMP|TEMPLATES|VIDEOS|WINDIR)/
-},t,n,i]};return{name:"NSIS",case_insensitive:!1,keywords:{
-keyword:"Abort AddBrandingImage AddSize AllowRootDirInstall AllowSkipFiles AutoCloseWindow BGFont BGGradient BrandingText BringToFront Call CallInstDLL Caption ChangeUI CheckBitmap ClearErrors CompletedText ComponentText CopyFiles CRCCheck CreateDirectory CreateFont CreateShortCut Delete DeleteINISec DeleteINIStr DeleteRegKey DeleteRegValue DetailPrint DetailsButtonText DirText DirVar DirVerify EnableWindow EnumRegKey EnumRegValue Exch Exec ExecShell ExecShellWait ExecWait ExpandEnvStrings File FileBufSize FileClose FileErrorText FileOpen FileRead FileReadByte FileReadUTF16LE FileReadWord FileWriteUTF16LE FileSeek FileWrite FileWriteByte FileWriteWord FindClose FindFirst FindNext FindWindow FlushINI GetCurInstType GetCurrentAddress GetDlgItem GetDLLVersion GetDLLVersionLocal GetErrorLevel GetFileTime GetFileTimeLocal GetFullPathName GetFunctionAddress GetInstDirError GetKnownFolderPath GetLabelAddress GetTempFileName Goto HideWindow Icon IfAbort IfErrors IfFileExists IfRebootFlag IfRtlLanguage IfShellVarContextAll IfSilent InitPluginsDir InstallButtonText InstallColors InstallDir InstallDirRegKey InstProgressFlags InstType InstTypeGetText InstTypeSetText Int64Cmp Int64CmpU Int64Fmt IntCmp IntCmpU IntFmt IntOp IntPtrCmp IntPtrCmpU IntPtrOp IsWindow LangString LicenseBkColor LicenseData LicenseForceSelection LicenseLangString LicenseText LoadAndSetImage LoadLanguageFile LockWindow LogSet LogText ManifestDPIAware ManifestLongPathAware ManifestMaxVersionTested ManifestSupportedOS MessageBox MiscButtonText Name Nop OutFile Page PageCallbacks PEAddResource PEDllCharacteristics PERemoveResource PESubsysVer Pop Push Quit ReadEnvStr ReadINIStr ReadRegDWORD ReadRegStr Reboot RegDLL Rename RequestExecutionLevel ReserveFile Return RMDir SearchPath SectionGetFlags SectionGetInstTypes SectionGetSize SectionGetText SectionIn SectionSetFlags SectionSetInstTypes SectionSetSize SectionSetText SendMessage SetAutoClose SetBrandingImage SetCompress SetCompressor SetCompressorDictSize SetCtlColors SetCurInstType SetDatablockOptimize SetDateSave SetDetailsPrint SetDetailsView SetErrorLevel SetErrors SetFileAttributes SetFont SetOutPath SetOverwrite SetRebootFlag SetRegView SetShellVarContext SetSilent ShowInstDetails ShowUninstDetails ShowWindow SilentInstall SilentUnInstall Sleep SpaceTexts StrCmp StrCmpS StrCpy StrLen SubCaption Unicode UninstallButtonText UninstallCaption UninstallIcon UninstallSubCaption UninstallText UninstPage UnRegDLL Var VIAddVersionKey VIFileVersion VIProductVersion WindowIcon WriteINIStr WriteRegBin WriteRegDWORD WriteRegExpandStr WriteRegMultiStr WriteRegNone WriteRegStr WriteUninstaller XPStyle",
-literal:"admin all auto both bottom bzip2 colored components current custom directory false force hide highest ifdiff ifnewer instfiles lastused leave left license listonly lzma nevershow none normal notset off on open print right show silent silentlog smooth textonly top true try un.components un.custom un.directory un.instfiles un.license uninstConfirm user Win10 Win7 Win8 WinVista zlib"
-},contains:[e.HASH_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.COMMENT(";","$",{
-relevance:0}),{className:"function",
-beginKeywords:"Function PageEx Section SectionGroup",end:"$"},r,{
-className:"keyword",
-begin:/!(addincludedir|addplugindir|appendfile|cd|define|delfile|echo|else|endif|error|execute|finalize|getdllversion|gettlbversion|if|ifdef|ifmacrodef|ifmacrondef|ifndef|include|insertmacro|macro|macroend|makensis|packhdr|searchparse|searchreplace|system|tempfile|undef|verbose|warning)/
-},t,n,i,{className:"params",
-begin:"(ARCHIVE|FILE_ATTRIBUTE_ARCHIVE|FILE_ATTRIBUTE_NORMAL|FILE_ATTRIBUTE_OFFLINE|FILE_ATTRIBUTE_READONLY|FILE_ATTRIBUTE_SYSTEM|FILE_ATTRIBUTE_TEMPORARY|HKCR|HKCU|HKDD|HKEY_CLASSES_ROOT|HKEY_CURRENT_CONFIG|HKEY_CURRENT_USER|HKEY_DYN_DATA|HKEY_LOCAL_MACHINE|HKEY_PERFORMANCE_DATA|HKEY_USERS|HKLM|HKPD|HKU|IDABORT|IDCANCEL|IDIGNORE|IDNO|IDOK|IDRETRY|IDYES|MB_ABORTRETRYIGNORE|MB_DEFBUTTON1|MB_DEFBUTTON2|MB_DEFBUTTON3|MB_DEFBUTTON4|MB_ICONEXCLAMATION|MB_ICONINFORMATION|MB_ICONQUESTION|MB_ICONSTOP|MB_OK|MB_OKCANCEL|MB_RETRYCANCEL|MB_RIGHT|MB_RTLREADING|MB_SETFOREGROUND|MB_TOPMOST|MB_USERICON|MB_YESNO|NORMAL|OFFLINE|READONLY|SHCTX|SHELL_CONTEXT|SYSTEM|TEMPORARY)"
-},{className:"class",begin:/\w+::\w+/},e.NUMBER_MODE]}}})());
-hljs.registerLanguage("objectivec",(()=>{"use strict";return e=>{
-const n=/[a-zA-Z@][a-zA-Z0-9_]*/,_={$pattern:n,
-keyword:"@interface @class @protocol @implementation"};return{
-name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"],
-keywords:{$pattern:n,
-keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN",
-literal:"false true FALSE TRUE nil YES NO NULL",
-built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"
-},illegal:"</",contains:[{className:"built_in",
-begin:"\\b(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)\\w+"
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{
-className:"string",variants:[{begin:'@"',end:'"',illegal:"\\n",
-contains:[e.BACKSLASH_ESCAPE]}]},{className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,
-keywords:{
-"meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include"
-},contains:[{begin:/\\\n/,relevance:0},e.inherit(e.QUOTE_STRING_MODE,{
-className:"meta-string"}),{className:"meta-string",begin:/<.*?>/,end:/$/,
-illegal:"\\n"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{
-className:"class",begin:"("+_.keyword.split(" ").join("|")+")\\b",end:/(\{|$)/,
-excludeEnd:!0,keywords:_,contains:[e.UNDERSCORE_TITLE_MODE]},{
-begin:"\\."+e.UNDERSCORE_IDENT_RE,relevance:0}]}}})());
-hljs.registerLanguage("ocaml",(()=>{"use strict";return e=>({name:"OCaml",
-aliases:["ml"],keywords:{$pattern:"[a-z_]\\w*!?",
-keyword:"and as assert asr begin class constraint do done downto else end exception external for fun function functor if in include inherit! inherit initializer land lazy let lor lsl lsr lxor match method!|10 method mod module mutable new object of open! open or private rec sig struct then to try type val! val virtual when while with parser value",
-built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 string unit in_channel out_channel ref",
-literal:"true false"},illegal:/\/\/|>>/,contains:[{className:"literal",
-begin:"\\[(\\|\\|)?\\]|\\(\\)",relevance:0},e.COMMENT("\\(\\*","\\*\\)",{
-contains:["self"]}),{className:"symbol",begin:"'[A-Za-z_](?!')[\\w']*"},{
-className:"type",begin:"`[A-Z][\\w']*"},{className:"type",
-begin:"\\b[A-Z][\\w']*",relevance:0},{begin:"[a-z_]\\w*'[\\w']*",relevance:0
-},e.inherit(e.APOS_STRING_MODE,{className:"string",relevance:0
-}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"number",
-begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",
-relevance:0},{begin:/->/}]})})());
-hljs.registerLanguage("openscad",(()=>{"use strict";return e=>{const n={
-className:"keyword",begin:"\\$(f[asn]|t|vp[rtd]|children)"},r={
-className:"number",begin:"\\b\\d+(\\.\\d+)?(e-?\\d+)?",relevance:0
-},s=e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),a={className:"function",
-beginKeywords:"module function",end:/=|\{/,contains:[{className:"params",
-begin:"\\(",end:"\\)",contains:["self",r,s,n,{className:"literal",
-begin:"false|true|PI|undef"}]},e.UNDERSCORE_TITLE_MODE]};return{name:"OpenSCAD",
-aliases:["scad"],keywords:{
-keyword:"function module include use for intersection_for if else \\%",
-literal:"false true PI undef",
-built_in:"circle square polygon text sphere cube cylinder polyhedron translate rotate scale resize mirror multmatrix color offset hull minkowski union difference intersection abs sign sin cos tan acos asin atan atan2 floor round ceil ln log pow sqrt exp rands min max concat lookup str chr search version version_num norm cross parent_module echo import import_dxf dxf_linear_extrude linear_extrude rotate_extrude surface projection render children dxf_cross dxf_dim let assign"
-},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,r,{className:"meta",
-keywords:{"meta-keyword":"include use"},begin:"include|use <",end:">"},s,n,{
-begin:"[*!#%]",relevance:0},a]}}})());
-hljs.registerLanguage("oxygene",(()=>{"use strict";return e=>{const n={
-$pattern:/\.?\w+/,
-keyword:"abstract add and array as asc aspect assembly async begin break block by case class concat const copy constructor continue create default delegate desc distinct div do downto dynamic each else empty end ensure enum equals event except exit extension external false final finalize finalizer finally flags for forward from function future global group has if implementation implements implies in index inherited inline interface into invariants is iterator join locked locking loop matching method mod module namespace nested new nil not notify nullable of old on operator or order out override parallel params partial pinned private procedure property protected public queryable raise read readonly record reintroduce remove repeat require result reverse sealed select self sequence set shl shr skip static step soft take then to true try tuple type union unit unsafe until uses using var virtual raises volatile where while with write xor yield await mapped deprecated stdcall cdecl pascal register safecall overload library platform reference packed strict published autoreleasepool selector strong weak unretained"
-},r=e.COMMENT(/\{/,/\}/,{relevance:0}),a=e.COMMENT("\\(\\*","\\*\\)",{
-relevance:10}),t={className:"string",begin:"'",end:"'",contains:[{begin:"''"}]
-},s={className:"string",begin:"(#\\d+)+"},i={className:"function",
-beginKeywords:"function constructor destructor procedure method",end:"[:;]",
-keywords:"function constructor|10 destructor|10 procedure|10 method|10",
-contains:[e.TITLE_MODE,{className:"params",begin:"\\(",end:"\\)",keywords:n,
-contains:[t,s]},r,a]};return{name:"Oxygene",case_insensitive:!0,keywords:n,
-illegal:'("|\\$[G-Zg-z]|\\/\\*|</|=>|->)',
-contains:[r,a,e.C_LINE_COMMENT_MODE,t,s,e.NUMBER_MODE,i,{className:"class",
-begin:"=\\bclass\\b",end:"end;",keywords:n,
-contains:[t,s,r,a,e.C_LINE_COMMENT_MODE,i]}]}}})());
-hljs.registerLanguage("parser3",(()=>{"use strict";return e=>{
-const a=e.COMMENT(/\{/,/\}/,{contains:["self"]});return{name:"Parser3",
-subLanguage:"xml",relevance:0,
-contains:[e.COMMENT("^#","$"),e.COMMENT(/\^rem\{/,/\}/,{relevance:10,
-contains:[a]}),{className:"meta",begin:"^@(?:BASE|USE|CLASS|OPTIONS)$",
-relevance:10},{className:"title",
-begin:"@[\\w\\-]+\\[[\\w^;\\-]*\\](?:\\[[\\w^;\\-]*\\])?(?:.*)$"},{
-className:"variable",begin:/\$\{?[\w\-.:]+\}?/},{className:"keyword",
-begin:/\^[\w\-.:]+/},{className:"number",begin:"\\^#[0-9a-fA-F]+"
-},e.C_NUMBER_MODE]}}})());
-hljs.registerLanguage("pf",(()=>{"use strict";return t=>({
-name:"Packet Filter config",aliases:["pf.conf"],keywords:{
-$pattern:/[a-z0-9_<>-]+/,
-built_in:"block match pass load anchor|5 antispoof|10 set table",
-keyword:"in out log quick on rdomain inet inet6 proto from port os to route allow-opts divert-packet divert-reply divert-to flags group icmp-type icmp6-type label once probability recieved-on rtable prio queue tos tag tagged user keep fragment for os drop af-to|10 binat-to|10 nat-to|10 rdr-to|10 bitmask least-stats random round-robin source-hash static-port dup-to reply-to route-to parent bandwidth default min max qlimit block-policy debug fingerprints hostid limit loginterface optimization reassemble ruleset-optimization basic none profile skip state-defaults state-policy timeout const counters persist no modulate synproxy state|5 floating if-bound no-sync pflow|10 sloppy source-track global rule max-src-nodes max-src-states max-src-conn max-src-conn-rate overload flush scrub|5 max-mss min-ttl no-df|10 random-id",
-literal:"all any no-route self urpf-failed egress|5 unknown"},
-contains:[t.HASH_COMMENT_MODE,t.NUMBER_MODE,t.QUOTE_STRING_MODE,{
-className:"variable",begin:/\$[\w\d#@][\w\d_]*/},{className:"variable",
-begin:/<(?!\/)/,end:/>/}]})})());
-hljs.registerLanguage("pgsql",(()=>{"use strict";return E=>{
-const T=E.COMMENT("--","$"),N="\\$([a-zA-Z_]?|[a-zA-Z_][a-zA-Z_0-9]*)\\$",A="BIGINT INT8 BIGSERIAL SERIAL8 BIT VARYING VARBIT BOOLEAN BOOL BOX BYTEA CHARACTER CHAR VARCHAR CIDR CIRCLE DATE DOUBLE PRECISION FLOAT8 FLOAT INET INTEGER INT INT4 INTERVAL JSON JSONB LINE LSEG|10 MACADDR MACADDR8 MONEY NUMERIC DEC DECIMAL PATH POINT POLYGON REAL FLOAT4 SMALLINT INT2 SMALLSERIAL|10 SERIAL2|10 SERIAL|10 SERIAL4|10 TEXT TIME ZONE TIMETZ|10 TIMESTAMP TIMESTAMPTZ|10 TSQUERY|10 TSVECTOR|10 TXID_SNAPSHOT|10 UUID XML NATIONAL NCHAR INT4RANGE|10 INT8RANGE|10 NUMRANGE|10 TSRANGE|10 TSTZRANGE|10 DATERANGE|10 ANYELEMENT ANYARRAY ANYNONARRAY ANYENUM ANYRANGE CSTRING INTERNAL RECORD PG_DDL_COMMAND VOID UNKNOWN OPAQUE REFCURSOR NAME OID REGPROC|10 REGPROCEDURE|10 REGOPER|10 REGOPERATOR|10 REGCLASS|10 REGTYPE|10 REGROLE|10 REGNAMESPACE|10 REGCONFIG|10 REGDICTIONARY|10 ",R=A.trim().split(" ").map((E=>E.split("|")[0])).join("|"),I="ARRAY_AGG AVG BIT_AND BIT_OR BOOL_AND BOOL_OR COUNT EVERY JSON_AGG JSONB_AGG JSON_OBJECT_AGG JSONB_OBJECT_AGG MAX MIN MODE STRING_AGG SUM XMLAGG CORR COVAR_POP COVAR_SAMP REGR_AVGX REGR_AVGY REGR_COUNT REGR_INTERCEPT REGR_R2 REGR_SLOPE REGR_SXX REGR_SXY REGR_SYY STDDEV STDDEV_POP STDDEV_SAMP VARIANCE VAR_POP VAR_SAMP PERCENTILE_CONT PERCENTILE_DISC ROW_NUMBER RANK DENSE_RANK PERCENT_RANK CUME_DIST NTILE LAG LEAD FIRST_VALUE LAST_VALUE NTH_VALUE NUM_NONNULLS NUM_NULLS ABS CBRT CEIL CEILING DEGREES DIV EXP FLOOR LN LOG MOD PI POWER RADIANS ROUND SCALE SIGN SQRT TRUNC WIDTH_BUCKET RANDOM SETSEED ACOS ACOSD ASIN ASIND ATAN ATAND ATAN2 ATAN2D COS COSD COT COTD SIN SIND TAN TAND BIT_LENGTH CHAR_LENGTH CHARACTER_LENGTH LOWER OCTET_LENGTH OVERLAY POSITION SUBSTRING TREAT TRIM UPPER ASCII BTRIM CHR CONCAT CONCAT_WS CONVERT CONVERT_FROM CONVERT_TO DECODE ENCODE INITCAP LEFT LENGTH LPAD LTRIM MD5 PARSE_IDENT PG_CLIENT_ENCODING QUOTE_IDENT|10 QUOTE_LITERAL|10 QUOTE_NULLABLE|10 REGEXP_MATCH REGEXP_MATCHES REGEXP_REPLACE REGEXP_SPLIT_TO_ARRAY REGEXP_SPLIT_TO_TABLE REPEAT REPLACE REVERSE RIGHT RPAD RTRIM SPLIT_PART STRPOS SUBSTR TO_ASCII TO_HEX TRANSLATE OCTET_LENGTH GET_BIT GET_BYTE SET_BIT SET_BYTE TO_CHAR TO_DATE TO_NUMBER TO_TIMESTAMP AGE CLOCK_TIMESTAMP|10 DATE_PART DATE_TRUNC ISFINITE JUSTIFY_DAYS JUSTIFY_HOURS JUSTIFY_INTERVAL MAKE_DATE MAKE_INTERVAL|10 MAKE_TIME MAKE_TIMESTAMP|10 MAKE_TIMESTAMPTZ|10 NOW STATEMENT_TIMESTAMP|10 TIMEOFDAY TRANSACTION_TIMESTAMP|10 ENUM_FIRST ENUM_LAST ENUM_RANGE AREA CENTER DIAMETER HEIGHT ISCLOSED ISOPEN NPOINTS PCLOSE POPEN RADIUS WIDTH BOX BOUND_BOX CIRCLE LINE LSEG PATH POLYGON ABBREV BROADCAST HOST HOSTMASK MASKLEN NETMASK NETWORK SET_MASKLEN TEXT INET_SAME_FAMILY INET_MERGE MACADDR8_SET7BIT ARRAY_TO_TSVECTOR GET_CURRENT_TS_CONFIG NUMNODE PLAINTO_TSQUERY PHRASETO_TSQUERY WEBSEARCH_TO_TSQUERY QUERYTREE SETWEIGHT STRIP TO_TSQUERY TO_TSVECTOR JSON_TO_TSVECTOR JSONB_TO_TSVECTOR TS_DELETE TS_FILTER TS_HEADLINE TS_RANK TS_RANK_CD TS_REWRITE TSQUERY_PHRASE TSVECTOR_TO_ARRAY TSVECTOR_UPDATE_TRIGGER TSVECTOR_UPDATE_TRIGGER_COLUMN XMLCOMMENT XMLCONCAT XMLELEMENT XMLFOREST XMLPI XMLROOT XMLEXISTS XML_IS_WELL_FORMED XML_IS_WELL_FORMED_DOCUMENT XML_IS_WELL_FORMED_CONTENT XPATH XPATH_EXISTS XMLTABLE XMLNAMESPACES TABLE_TO_XML TABLE_TO_XMLSCHEMA TABLE_TO_XML_AND_XMLSCHEMA QUERY_TO_XML QUERY_TO_XMLSCHEMA QUERY_TO_XML_AND_XMLSCHEMA CURSOR_TO_XML CURSOR_TO_XMLSCHEMA SCHEMA_TO_XML SCHEMA_TO_XMLSCHEMA SCHEMA_TO_XML_AND_XMLSCHEMA DATABASE_TO_XML DATABASE_TO_XMLSCHEMA DATABASE_TO_XML_AND_XMLSCHEMA XMLATTRIBUTES TO_JSON TO_JSONB ARRAY_TO_JSON ROW_TO_JSON JSON_BUILD_ARRAY JSONB_BUILD_ARRAY JSON_BUILD_OBJECT JSONB_BUILD_OBJECT JSON_OBJECT JSONB_OBJECT JSON_ARRAY_LENGTH JSONB_ARRAY_LENGTH JSON_EACH JSONB_EACH JSON_EACH_TEXT JSONB_EACH_TEXT JSON_EXTRACT_PATH JSONB_EXTRACT_PATH JSON_OBJECT_KEYS JSONB_OBJECT_KEYS JSON_POPULATE_RECORD JSONB_POPULATE_RECORD JSON_POPULATE_RECORDSET JSONB_POPULATE_RECORDSET JSON_ARRAY_ELEMENTS JSONB_ARRAY_ELEMENTS JSON_ARRAY_ELEMENTS_TEXT JSONB_ARRAY_ELEMENTS_TEXT JSON_TYPEOF JSONB_TYPEOF JSON_TO_RECORD JSONB_TO_RECORD JSON_TO_RECORDSET JSONB_TO_RECORDSET JSON_STRIP_NULLS JSONB_STRIP_NULLS JSONB_SET JSONB_INSERT JSONB_PRETTY CURRVAL LASTVAL NEXTVAL SETVAL COALESCE NULLIF GREATEST LEAST ARRAY_APPEND ARRAY_CAT ARRAY_NDIMS ARRAY_DIMS ARRAY_FILL ARRAY_LENGTH ARRAY_LOWER ARRAY_POSITION ARRAY_POSITIONS ARRAY_PREPEND ARRAY_REMOVE ARRAY_REPLACE ARRAY_TO_STRING ARRAY_UPPER CARDINALITY STRING_TO_ARRAY UNNEST ISEMPTY LOWER_INC UPPER_INC LOWER_INF UPPER_INF RANGE_MERGE GENERATE_SERIES GENERATE_SUBSCRIPTS CURRENT_DATABASE CURRENT_QUERY CURRENT_SCHEMA|10 CURRENT_SCHEMAS|10 INET_CLIENT_ADDR INET_CLIENT_PORT INET_SERVER_ADDR INET_SERVER_PORT ROW_SECURITY_ACTIVE FORMAT_TYPE TO_REGCLASS TO_REGPROC TO_REGPROCEDURE TO_REGOPER TO_REGOPERATOR TO_REGTYPE TO_REGNAMESPACE TO_REGROLE COL_DESCRIPTION OBJ_DESCRIPTION SHOBJ_DESCRIPTION TXID_CURRENT TXID_CURRENT_IF_ASSIGNED TXID_CURRENT_SNAPSHOT TXID_SNAPSHOT_XIP TXID_SNAPSHOT_XMAX TXID_SNAPSHOT_XMIN TXID_VISIBLE_IN_SNAPSHOT TXID_STATUS CURRENT_SETTING SET_CONFIG BRIN_SUMMARIZE_NEW_VALUES BRIN_SUMMARIZE_RANGE BRIN_DESUMMARIZE_RANGE GIN_CLEAN_PENDING_LIST SUPPRESS_REDUNDANT_UPDATES_TRIGGER LO_FROM_BYTEA LO_PUT LO_GET LO_CREAT LO_CREATE LO_UNLINK LO_IMPORT LO_EXPORT LOREAD LOWRITE GROUPING CAST".split(" ").map((E=>E.split("|")[0])).join("|")
-;return{name:"PostgreSQL",aliases:["postgres","postgresql"],case_insensitive:!0,
-keywords:{
-keyword:"ABORT ALTER ANALYZE BEGIN CALL CHECKPOINT|10 CLOSE CLUSTER COMMENT COMMIT COPY CREATE DEALLOCATE DECLARE DELETE DISCARD DO DROP END EXECUTE EXPLAIN FETCH GRANT IMPORT INSERT LISTEN LOAD LOCK MOVE NOTIFY PREPARE REASSIGN|10 REFRESH REINDEX RELEASE RESET REVOKE ROLLBACK SAVEPOINT SECURITY SELECT SET SHOW START TRUNCATE UNLISTEN|10 UPDATE VACUUM|10 VALUES AGGREGATE COLLATION CONVERSION|10 DATABASE DEFAULT PRIVILEGES DOMAIN TRIGGER EXTENSION FOREIGN WRAPPER|10 TABLE FUNCTION GROUP LANGUAGE LARGE OBJECT MATERIALIZED VIEW OPERATOR CLASS FAMILY POLICY PUBLICATION|10 ROLE RULE SCHEMA SEQUENCE SERVER STATISTICS SUBSCRIPTION SYSTEM TABLESPACE CONFIGURATION DICTIONARY PARSER TEMPLATE TYPE USER MAPPING PREPARED ACCESS METHOD CAST AS TRANSFORM TRANSACTION OWNED TO INTO SESSION AUTHORIZATION INDEX PROCEDURE ASSERTION ALL ANALYSE AND ANY ARRAY ASC ASYMMETRIC|10 BOTH CASE CHECK COLLATE COLUMN CONCURRENTLY|10 CONSTRAINT CROSS DEFERRABLE RANGE DESC DISTINCT ELSE EXCEPT FOR FREEZE|10 FROM FULL HAVING ILIKE IN INITIALLY INNER INTERSECT IS ISNULL JOIN LATERAL LEADING LIKE LIMIT NATURAL NOT NOTNULL NULL OFFSET ON ONLY OR ORDER OUTER OVERLAPS PLACING PRIMARY REFERENCES RETURNING SIMILAR SOME SYMMETRIC TABLESAMPLE THEN TRAILING UNION UNIQUE USING VARIADIC|10 VERBOSE WHEN WHERE WINDOW WITH BY RETURNS INOUT OUT SETOF|10 IF STRICT CURRENT CONTINUE OWNER LOCATION OVER PARTITION WITHIN BETWEEN ESCAPE EXTERNAL INVOKER DEFINER WORK RENAME VERSION CONNECTION CONNECT TABLES TEMP TEMPORARY FUNCTIONS SEQUENCES TYPES SCHEMAS OPTION CASCADE RESTRICT ADD ADMIN EXISTS VALID VALIDATE ENABLE DISABLE REPLICA|10 ALWAYS PASSING COLUMNS PATH REF VALUE OVERRIDING IMMUTABLE STABLE VOLATILE BEFORE AFTER EACH ROW PROCEDURAL ROUTINE NO HANDLER VALIDATOR OPTIONS STORAGE OIDS|10 WITHOUT INHERIT DEPENDS CALLED INPUT LEAKPROOF|10 COST ROWS NOWAIT SEARCH UNTIL ENCRYPTED|10 PASSWORD CONFLICT|10 INSTEAD INHERITS CHARACTERISTICS WRITE CURSOR ALSO STATEMENT SHARE EXCLUSIVE INLINE ISOLATION REPEATABLE READ COMMITTED SERIALIZABLE UNCOMMITTED LOCAL GLOBAL SQL PROCEDURES RECURSIVE SNAPSHOT ROLLUP CUBE TRUSTED|10 INCLUDE FOLLOWING PRECEDING UNBOUNDED RANGE GROUPS UNENCRYPTED|10 SYSID FORMAT DELIMITER HEADER QUOTE ENCODING FILTER OFF FORCE_QUOTE FORCE_NOT_NULL FORCE_NULL COSTS BUFFERS TIMING SUMMARY DISABLE_PAGE_SKIPPING RESTART CYCLE GENERATED IDENTITY DEFERRED IMMEDIATE LEVEL LOGGED UNLOGGED OF NOTHING NONE EXCLUDE ATTRIBUTE USAGE ROUTINES TRUE FALSE NAN INFINITY ALIAS BEGIN CONSTANT DECLARE END EXCEPTION RETURN PERFORM|10 RAISE GET DIAGNOSTICS STACKED|10 FOREACH LOOP ELSIF EXIT WHILE REVERSE SLICE DEBUG LOG INFO NOTICE WARNING ASSERT OPEN SUPERUSER NOSUPERUSER CREATEDB NOCREATEDB CREATEROLE NOCREATEROLE INHERIT NOINHERIT LOGIN NOLOGIN REPLICATION NOREPLICATION BYPASSRLS NOBYPASSRLS ",
-built_in:"CURRENT_TIME CURRENT_TIMESTAMP CURRENT_USER CURRENT_CATALOG|10 CURRENT_DATE LOCALTIME LOCALTIMESTAMP CURRENT_ROLE|10 CURRENT_SCHEMA|10 SESSION_USER PUBLIC FOUND NEW OLD TG_NAME|10 TG_WHEN|10 TG_LEVEL|10 TG_OP|10 TG_RELID|10 TG_RELNAME|10 TG_TABLE_NAME|10 TG_TABLE_SCHEMA|10 TG_NARGS|10 TG_ARGV|10 TG_EVENT|10 TG_TAG|10 ROW_COUNT RESULT_OID|10 PG_CONTEXT|10 RETURNED_SQLSTATE COLUMN_NAME CONSTRAINT_NAME PG_DATATYPE_NAME|10 MESSAGE_TEXT TABLE_NAME SCHEMA_NAME PG_EXCEPTION_DETAIL|10 PG_EXCEPTION_HINT|10 PG_EXCEPTION_CONTEXT|10 SQLSTATE SQLERRM|10 SUCCESSFUL_COMPLETION WARNING DYNAMIC_RESULT_SETS_RETURNED IMPLICIT_ZERO_BIT_PADDING NULL_VALUE_ELIMINATED_IN_SET_FUNCTION PRIVILEGE_NOT_GRANTED PRIVILEGE_NOT_REVOKED STRING_DATA_RIGHT_TRUNCATION DEPRECATED_FEATURE NO_DATA NO_ADDITIONAL_DYNAMIC_RESULT_SETS_RETURNED SQL_STATEMENT_NOT_YET_COMPLETE CONNECTION_EXCEPTION CONNECTION_DOES_NOT_EXIST CONNECTION_FAILURE SQLCLIENT_UNABLE_TO_ESTABLISH_SQLCONNECTION SQLSERVER_REJECTED_ESTABLISHMENT_OF_SQLCONNECTION TRANSACTION_RESOLUTION_UNKNOWN PROTOCOL_VIOLATION TRIGGERED_ACTION_EXCEPTION FEATURE_NOT_SUPPORTED INVALID_TRANSACTION_INITIATION LOCATOR_EXCEPTION INVALID_LOCATOR_SPECIFICATION INVALID_GRANTOR INVALID_GRANT_OPERATION INVALID_ROLE_SPECIFICATION DIAGNOSTICS_EXCEPTION STACKED_DIAGNOSTICS_ACCESSED_WITHOUT_ACTIVE_HANDLER CASE_NOT_FOUND CARDINALITY_VIOLATION DATA_EXCEPTION ARRAY_SUBSCRIPT_ERROR CHARACTER_NOT_IN_REPERTOIRE DATETIME_FIELD_OVERFLOW DIVISION_BY_ZERO ERROR_IN_ASSIGNMENT ESCAPE_CHARACTER_CONFLICT INDICATOR_OVERFLOW INTERVAL_FIELD_OVERFLOW INVALID_ARGUMENT_FOR_LOGARITHM INVALID_ARGUMENT_FOR_NTILE_FUNCTION INVALID_ARGUMENT_FOR_NTH_VALUE_FUNCTION INVALID_ARGUMENT_FOR_POWER_FUNCTION INVALID_ARGUMENT_FOR_WIDTH_BUCKET_FUNCTION INVALID_CHARACTER_VALUE_FOR_CAST INVALID_DATETIME_FORMAT INVALID_ESCAPE_CHARACTER INVALID_ESCAPE_OCTET INVALID_ESCAPE_SEQUENCE NONSTANDARD_USE_OF_ESCAPE_CHARACTER INVALID_INDICATOR_PARAMETER_VALUE INVALID_PARAMETER_VALUE INVALID_REGULAR_EXPRESSION INVALID_ROW_COUNT_IN_LIMIT_CLAUSE INVALID_ROW_COUNT_IN_RESULT_OFFSET_CLAUSE INVALID_TABLESAMPLE_ARGUMENT INVALID_TABLESAMPLE_REPEAT INVALID_TIME_ZONE_DISPLACEMENT_VALUE INVALID_USE_OF_ESCAPE_CHARACTER MOST_SPECIFIC_TYPE_MISMATCH NULL_VALUE_NOT_ALLOWED NULL_VALUE_NO_INDICATOR_PARAMETER NUMERIC_VALUE_OUT_OF_RANGE SEQUENCE_GENERATOR_LIMIT_EXCEEDED STRING_DATA_LENGTH_MISMATCH STRING_DATA_RIGHT_TRUNCATION SUBSTRING_ERROR TRIM_ERROR UNTERMINATED_C_STRING ZERO_LENGTH_CHARACTER_STRING FLOATING_POINT_EXCEPTION INVALID_TEXT_REPRESENTATION INVALID_BINARY_REPRESENTATION BAD_COPY_FILE_FORMAT UNTRANSLATABLE_CHARACTER NOT_AN_XML_DOCUMENT INVALID_XML_DOCUMENT INVALID_XML_CONTENT INVALID_XML_COMMENT INVALID_XML_PROCESSING_INSTRUCTION INTEGRITY_CONSTRAINT_VIOLATION RESTRICT_VIOLATION NOT_NULL_VIOLATION FOREIGN_KEY_VIOLATION UNIQUE_VIOLATION CHECK_VIOLATION EXCLUSION_VIOLATION INVALID_CURSOR_STATE INVALID_TRANSACTION_STATE ACTIVE_SQL_TRANSACTION BRANCH_TRANSACTION_ALREADY_ACTIVE HELD_CURSOR_REQUIRES_SAME_ISOLATION_LEVEL INAPPROPRIATE_ACCESS_MODE_FOR_BRANCH_TRANSACTION INAPPROPRIATE_ISOLATION_LEVEL_FOR_BRANCH_TRANSACTION NO_ACTIVE_SQL_TRANSACTION_FOR_BRANCH_TRANSACTION READ_ONLY_SQL_TRANSACTION SCHEMA_AND_DATA_STATEMENT_MIXING_NOT_SUPPORTED NO_ACTIVE_SQL_TRANSACTION IN_FAILED_SQL_TRANSACTION IDLE_IN_TRANSACTION_SESSION_TIMEOUT INVALID_SQL_STATEMENT_NAME TRIGGERED_DATA_CHANGE_VIOLATION INVALID_AUTHORIZATION_SPECIFICATION INVALID_PASSWORD DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST DEPENDENT_OBJECTS_STILL_EXIST INVALID_TRANSACTION_TERMINATION SQL_ROUTINE_EXCEPTION FUNCTION_EXECUTED_NO_RETURN_STATEMENT MODIFYING_SQL_DATA_NOT_PERMITTED PROHIBITED_SQL_STATEMENT_ATTEMPTED READING_SQL_DATA_NOT_PERMITTED INVALID_CURSOR_NAME EXTERNAL_ROUTINE_EXCEPTION CONTAINING_SQL_NOT_PERMITTED MODIFYING_SQL_DATA_NOT_PERMITTED PROHIBITED_SQL_STATEMENT_ATTEMPTED READING_SQL_DATA_NOT_PERMITTED EXTERNAL_ROUTINE_INVOCATION_EXCEPTION INVALID_SQLSTATE_RETURNED NULL_VALUE_NOT_ALLOWED TRIGGER_PROTOCOL_VIOLATED SRF_PROTOCOL_VIOLATED EVENT_TRIGGER_PROTOCOL_VIOLATED SAVEPOINT_EXCEPTION INVALID_SAVEPOINT_SPECIFICATION INVALID_CATALOG_NAME INVALID_SCHEMA_NAME TRANSACTION_ROLLBACK TRANSACTION_INTEGRITY_CONSTRAINT_VIOLATION SERIALIZATION_FAILURE STATEMENT_COMPLETION_UNKNOWN DEADLOCK_DETECTED SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION SYNTAX_ERROR INSUFFICIENT_PRIVILEGE CANNOT_COERCE GROUPING_ERROR WINDOWING_ERROR INVALID_RECURSION INVALID_FOREIGN_KEY INVALID_NAME NAME_TOO_LONG RESERVED_NAME DATATYPE_MISMATCH INDETERMINATE_DATATYPE COLLATION_MISMATCH INDETERMINATE_COLLATION WRONG_OBJECT_TYPE GENERATED_ALWAYS UNDEFINED_COLUMN UNDEFINED_FUNCTION UNDEFINED_TABLE UNDEFINED_PARAMETER UNDEFINED_OBJECT DUPLICATE_COLUMN DUPLICATE_CURSOR DUPLICATE_DATABASE DUPLICATE_FUNCTION DUPLICATE_PREPARED_STATEMENT DUPLICATE_SCHEMA DUPLICATE_TABLE DUPLICATE_ALIAS DUPLICATE_OBJECT AMBIGUOUS_COLUMN AMBIGUOUS_FUNCTION AMBIGUOUS_PARAMETER AMBIGUOUS_ALIAS INVALID_COLUMN_REFERENCE INVALID_COLUMN_DEFINITION INVALID_CURSOR_DEFINITION INVALID_DATABASE_DEFINITION INVALID_FUNCTION_DEFINITION INVALID_PREPARED_STATEMENT_DEFINITION INVALID_SCHEMA_DEFINITION INVALID_TABLE_DEFINITION INVALID_OBJECT_DEFINITION WITH_CHECK_OPTION_VIOLATION INSUFFICIENT_RESOURCES DISK_FULL OUT_OF_MEMORY TOO_MANY_CONNECTIONS CONFIGURATION_LIMIT_EXCEEDED PROGRAM_LIMIT_EXCEEDED STATEMENT_TOO_COMPLEX TOO_MANY_COLUMNS TOO_MANY_ARGUMENTS OBJECT_NOT_IN_PREREQUISITE_STATE OBJECT_IN_USE CANT_CHANGE_RUNTIME_PARAM LOCK_NOT_AVAILABLE OPERATOR_INTERVENTION QUERY_CANCELED ADMIN_SHUTDOWN CRASH_SHUTDOWN CANNOT_CONNECT_NOW DATABASE_DROPPED SYSTEM_ERROR IO_ERROR UNDEFINED_FILE DUPLICATE_FILE SNAPSHOT_TOO_OLD CONFIG_FILE_ERROR LOCK_FILE_EXISTS FDW_ERROR FDW_COLUMN_NAME_NOT_FOUND FDW_DYNAMIC_PARAMETER_VALUE_NEEDED FDW_FUNCTION_SEQUENCE_ERROR FDW_INCONSISTENT_DESCRIPTOR_INFORMATION FDW_INVALID_ATTRIBUTE_VALUE FDW_INVALID_COLUMN_NAME FDW_INVALID_COLUMN_NUMBER FDW_INVALID_DATA_TYPE FDW_INVALID_DATA_TYPE_DESCRIPTORS FDW_INVALID_DESCRIPTOR_FIELD_IDENTIFIER FDW_INVALID_HANDLE FDW_INVALID_OPTION_INDEX FDW_INVALID_OPTION_NAME FDW_INVALID_STRING_LENGTH_OR_BUFFER_LENGTH FDW_INVALID_STRING_FORMAT FDW_INVALID_USE_OF_NULL_POINTER FDW_TOO_MANY_HANDLES FDW_OUT_OF_MEMORY FDW_NO_SCHEMAS FDW_OPTION_NAME_NOT_FOUND FDW_REPLY_HANDLE FDW_SCHEMA_NOT_FOUND FDW_TABLE_NOT_FOUND FDW_UNABLE_TO_CREATE_EXECUTION FDW_UNABLE_TO_CREATE_REPLY FDW_UNABLE_TO_ESTABLISH_CONNECTION PLPGSQL_ERROR RAISE_EXCEPTION NO_DATA_FOUND TOO_MANY_ROWS ASSERT_FAILURE INTERNAL_ERROR DATA_CORRUPTED INDEX_CORRUPTED "
-},illegal:/:==|\W\s*\(\*|(^|\s)\$[a-z]|\{\{|[a-z]:\s*$|\.\.\.|TO:|DO:/,
-contains:[{className:"keyword",variants:[{begin:/\bTEXT\s*SEARCH\b/},{
-begin:/\b(PRIMARY|FOREIGN|FOR(\s+NO)?)\s+KEY\b/},{
-begin:/\bPARALLEL\s+(UNSAFE|RESTRICTED|SAFE)\b/},{
-begin:/\bSTORAGE\s+(PLAIN|EXTERNAL|EXTENDED|MAIN)\b/},{
-begin:/\bMATCH\s+(FULL|PARTIAL|SIMPLE)\b/},{begin:/\bNULLS\s+(FIRST|LAST)\b/},{
-begin:/\bEVENT\s+TRIGGER\b/},{begin:/\b(MAPPING|OR)\s+REPLACE\b/},{
-begin:/\b(FROM|TO)\s+(PROGRAM|STDIN|STDOUT)\b/},{
-begin:/\b(SHARE|EXCLUSIVE)\s+MODE\b/},{
-begin:/\b(LEFT|RIGHT)\s+(OUTER\s+)?JOIN\b/},{
-begin:/\b(FETCH|MOVE)\s+(NEXT|PRIOR|FIRST|LAST|ABSOLUTE|RELATIVE|FORWARD|BACKWARD)\b/
-},{begin:/\bPRESERVE\s+ROWS\b/},{begin:/\bDISCARD\s+PLANS\b/},{
-begin:/\bREFERENCING\s+(OLD|NEW)\b/},{begin:/\bSKIP\s+LOCKED\b/},{
-begin:/\bGROUPING\s+SETS\b/},{
-begin:/\b(BINARY|INSENSITIVE|SCROLL|NO\s+SCROLL)\s+(CURSOR|FOR)\b/},{
-begin:/\b(WITH|WITHOUT)\s+HOLD\b/},{
-begin:/\bWITH\s+(CASCADED|LOCAL)\s+CHECK\s+OPTION\b/},{
-begin:/\bEXCLUDE\s+(TIES|NO\s+OTHERS)\b/},{
-begin:/\bFORMAT\s+(TEXT|XML|JSON|YAML)\b/},{
-begin:/\bSET\s+((SESSION|LOCAL)\s+)?NAMES\b/},{begin:/\bIS\s+(NOT\s+)?UNKNOWN\b/
-},{begin:/\bSECURITY\s+LABEL\b/},{begin:/\bSTANDALONE\s+(YES|NO|NO\s+VALUE)\b/
-},{begin:/\bWITH\s+(NO\s+)?DATA\b/},{begin:/\b(FOREIGN|SET)\s+DATA\b/},{
-begin:/\bSET\s+(CATALOG|CONSTRAINTS)\b/},{begin:/\b(WITH|FOR)\s+ORDINALITY\b/},{
-begin:/\bIS\s+(NOT\s+)?DOCUMENT\b/},{
-begin:/\bXML\s+OPTION\s+(DOCUMENT|CONTENT)\b/},{
-begin:/\b(STRIP|PRESERVE)\s+WHITESPACE\b/},{
-begin:/\bNO\s+(ACTION|MAXVALUE|MINVALUE)\b/},{
-begin:/\bPARTITION\s+BY\s+(RANGE|LIST|HASH)\b/},{begin:/\bAT\s+TIME\s+ZONE\b/},{
-begin:/\bGRANTED\s+BY\b/},{begin:/\bRETURN\s+(QUERY|NEXT)\b/},{
-begin:/\b(ATTACH|DETACH)\s+PARTITION\b/},{
-begin:/\bFORCE\s+ROW\s+LEVEL\s+SECURITY\b/},{
-begin:/\b(INCLUDING|EXCLUDING)\s+(COMMENTS|CONSTRAINTS|DEFAULTS|IDENTITY|INDEXES|STATISTICS|STORAGE|ALL)\b/
-},{begin:/\bAS\s+(ASSIGNMENT|IMPLICIT|PERMISSIVE|RESTRICTIVE|ENUM|RANGE)\b/}]},{
-begin:/\b(FORMAT|FAMILY|VERSION)\s*\(/},{begin:/\bINCLUDE\s*\(/,
-keywords:"INCLUDE"},{begin:/\bRANGE(?!\s*(BETWEEN|UNBOUNDED|CURRENT|[-0-9]+))/
-},{
-begin:/\b(VERSION|OWNER|TEMPLATE|TABLESPACE|CONNECTION\s+LIMIT|PROCEDURE|RESTRICT|JOIN|PARSER|COPY|START|END|COLLATION|INPUT|ANALYZE|STORAGE|LIKE|DEFAULT|DELIMITER|ENCODING|COLUMN|CONSTRAINT|TABLE|SCHEMA)\s*=/
-},{begin:/\b(PG_\w+?|HAS_[A-Z_]+_PRIVILEGE)\b/,relevance:10},{
-begin:/\bEXTRACT\s*\(/,end:/\bFROM\b/,returnEnd:!0,keywords:{
-type:"CENTURY DAY DECADE DOW DOY EPOCH HOUR ISODOW ISOYEAR MICROSECONDS MILLENNIUM MILLISECONDS MINUTE MONTH QUARTER SECOND TIMEZONE TIMEZONE_HOUR TIMEZONE_MINUTE WEEK YEAR"
-}},{begin:/\b(XMLELEMENT|XMLPI)\s*\(\s*NAME/,keywords:{keyword:"NAME"}},{
-begin:/\b(XMLPARSE|XMLSERIALIZE)\s*\(\s*(DOCUMENT|CONTENT)/,keywords:{
-keyword:"DOCUMENT CONTENT"}},{beginKeywords:"CACHE INCREMENT MAXVALUE MINVALUE",
-end:E.C_NUMBER_RE,returnEnd:!0,keywords:"BY CACHE INCREMENT MAXVALUE MINVALUE"
-},{className:"type",begin:/\b(WITH|WITHOUT)\s+TIME\s+ZONE\b/},{className:"type",
-begin:/\bINTERVAL\s+(YEAR|MONTH|DAY|HOUR|MINUTE|SECOND)(\s+TO\s+(MONTH|HOUR|MINUTE|SECOND))?\b/
-},{
-begin:/\bRETURNS\s+(LANGUAGE_HANDLER|TRIGGER|EVENT_TRIGGER|FDW_HANDLER|INDEX_AM_HANDLER|TSM_HANDLER)\b/,
-keywords:{keyword:"RETURNS",
-type:"LANGUAGE_HANDLER TRIGGER EVENT_TRIGGER FDW_HANDLER INDEX_AM_HANDLER TSM_HANDLER"
-}},{begin:"\\b("+I+")\\s*\\("},{begin:"\\.("+R+")\\b"},{
-begin:"\\b("+R+")\\s+PATH\\b",keywords:{keyword:"PATH",
-type:A.replace("PATH ","")}},{className:"type",begin:"\\b("+R+")\\b"},{
-className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},{
-className:"string",begin:"(e|E|u&|U&)'",end:"'",contains:[{begin:"\\\\."}],
-relevance:10},E.END_SAME_AS_BEGIN({begin:N,end:N,contains:[{
-subLanguage:["pgsql","perl","python","tcl","r","lua","java","php","ruby","bash","scheme","xml","json"],
-endsWithParent:!0}]}),{begin:'"',end:'"',contains:[{begin:'""'}]
-},E.C_NUMBER_MODE,E.C_BLOCK_COMMENT_MODE,T,{className:"meta",variants:[{
-begin:"%(ROW)?TYPE",relevance:10},{begin:"\\$\\d+"},{begin:"^#\\w",end:"$"}]},{
-className:"symbol",begin:"<<\\s*[a-zA-Z_][a-zA-Z_0-9$]*\\s*>>",relevance:10}]}}
-})());
-hljs.registerLanguage("php",(()=>{"use strict";return e=>{const r={
-className:"variable",
-begin:"\\$+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*(?![A-Za-z0-9])(?![$])"},t={
-className:"meta",variants:[{begin:/<\?php/,relevance:10},{begin:/<\?[=]?/},{
-begin:/\?>/}]},a={className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/,
-end:/\}/}]},n=e.inherit(e.APOS_STRING_MODE,{illegal:null
-}),i=e.inherit(e.QUOTE_STRING_MODE,{illegal:null,
-contains:e.QUOTE_STRING_MODE.contains.concat(a)}),o=e.END_SAME_AS_BEGIN({
-begin:/<<<[ \t]*(\w+)\n/,end:/[ \t]*(\w+)\b/,
-contains:e.QUOTE_STRING_MODE.contains.concat(a)}),l={className:"string",
-contains:[e.BACKSLASH_ESCAPE,t],variants:[e.inherit(n,{begin:"b'",end:"'"
-}),e.inherit(i,{begin:'b"',end:'"'}),i,n,o]},s={className:"number",variants:[{
-begin:"\\b0b[01]+(?:_[01]+)*\\b"},{begin:"\\b0o[0-7]+(?:_[0-7]+)*\\b"},{
-begin:"\\b0x[\\da-f]+(?:_[\\da-f]+)*\\b"},{
-begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:e[+-]?\\d+)?"
-}],relevance:0},c={
-keyword:"__CLASS__ __DIR__ __FILE__ __FUNCTION__ __LINE__ __METHOD__ __NAMESPACE__ __TRAIT__ die echo exit include include_once print require require_once array abstract and as binary bool boolean break callable case catch class clone const continue declare default do double else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum eval extends final finally float for foreach from global goto if implements instanceof insteadof int integer interface isset iterable list match|0 mixed new object or private protected public real return string switch throw trait try unset use var void while xor yield",
-literal:"false null true",
-built_in:"Error|0 AppendIterator ArgumentCountError ArithmeticError ArrayIterator ArrayObject AssertionError BadFunctionCallException BadMethodCallException CachingIterator CallbackFilterIterator CompileError Countable DirectoryIterator DivisionByZeroError DomainException EmptyIterator ErrorException Exception FilesystemIterator FilterIterator GlobIterator InfiniteIterator InvalidArgumentException IteratorIterator LengthException LimitIterator LogicException MultipleIterator NoRewindIterator OutOfBoundsException OutOfRangeException OuterIterator OverflowException ParentIterator ParseError RangeException RecursiveArrayIterator RecursiveCachingIterator RecursiveCallbackFilterIterator RecursiveDirectoryIterator RecursiveFilterIterator RecursiveIterator RecursiveIteratorIterator RecursiveRegexIterator RecursiveTreeIterator RegexIterator RuntimeException SeekableIterator SplDoublyLinkedList SplFileInfo SplFileObject SplFixedArray SplHeap SplMaxHeap SplMinHeap SplObjectStorage SplObserver SplObserver SplPriorityQueue SplQueue SplStack SplSubject SplSubject SplTempFileObject TypeError UnderflowException UnexpectedValueException UnhandledMatchError ArrayAccess Closure Generator Iterator IteratorAggregate Serializable Stringable Throwable Traversable WeakReference WeakMap Directory __PHP_Incomplete_Class parent php_user_filter self static stdClass"
-};return{aliases:["php3","php4","php5","php6","php7","php8"],
-case_insensitive:!0,keywords:c,
-contains:[e.HASH_COMMENT_MODE,e.COMMENT("//","$",{contains:[t]
-}),e.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}]
-}),e.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0,
-keywords:"__halt_compiler"}),t,{className:"keyword",begin:/\$this\b/},r,{
-begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function",
-relevance:0,beginKeywords:"fn function",end:/[;{]/,excludeEnd:!0,
-illegal:"[$%\\[]",contains:[{beginKeywords:"use"},e.UNDERSCORE_TITLE_MODE,{
-begin:"=>",endsParent:!0},{className:"params",begin:"\\(",end:"\\)",
-excludeBegin:!0,excludeEnd:!0,keywords:c,
-contains:["self",r,e.C_BLOCK_COMMENT_MODE,l,s]}]},{className:"class",variants:[{
-beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait",
-illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{
-beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{
-beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/,
-contains:[e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",relevance:0,end:";",
-contains:[e.UNDERSCORE_TITLE_MODE]},l,s]}}})());
-hljs.registerLanguage("php-template",(()=>{"use strict";return n=>({
-name:"PHP template",subLanguage:"xml",contains:[{begin:/<\?(php|=)?/,end:/\?>/,
-subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0},{begin:'b"',
-end:'"',skip:!0},{begin:"b'",end:"'",skip:!0},n.inherit(n.APOS_STRING_MODE,{
-illegal:null,className:null,contains:null,skip:!0
-}),n.inherit(n.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null,
-skip:!0})]}]})})());
-hljs.registerLanguage("plaintext",(()=>{"use strict";return t=>({
-name:"Plain text",aliases:["text","txt"],disableAutodetect:!0})})());
-hljs.registerLanguage("pony",(()=>{"use strict";return e=>({name:"Pony",
-keywords:{
-keyword:"actor addressof and as be break class compile_error compile_intrinsic consume continue delegate digestof do else elseif embed end error for fun if ifdef in interface is isnt lambda let match new not object or primitive recover repeat return struct then trait try type until use var where while with xor",
-meta:"iso val tag trn box ref",literal:"this false true"},contains:[{
-className:"type",begin:"\\b_?[A-Z][\\w]*",relevance:0},{className:"string",
-begin:'"""',end:'"""',relevance:10},{className:"string",begin:'"',end:'"',
-contains:[e.BACKSLASH_ESCAPE]},{className:"string",begin:"'",end:"'",
-contains:[e.BACKSLASH_ESCAPE],relevance:0},{begin:e.IDENT_RE+"'",relevance:0},{
-className:"number",
-begin:"(-?)(\\b0[xX][a-fA-F0-9]+|\\b0[bB][01]+|(\\b\\d+(_\\d+)?(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",
-relevance:0},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]})})());
-hljs.registerLanguage("powershell",(()=>{"use strict";return e=>{const n={
-$pattern:/-?[A-z\.\-]+\b/,
-keyword:"if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter",
-built_in:"ac asnp cat cd CFS chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo|0 epal epcsv epsn erase etsn exsn fc fhx fl ft fw gal gbp gc gcb gci gcm gcs gdr gerr ghy gi gin gjb gl gm gmo gp gps gpv group gsn gsnp gsv gtz gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc scb select set shcm si sl sleep sls sort sp spjb spps spsv start stz sujb sv swmi tee trcm type wget where wjb write"
-},s={begin:"`[\\s\\S]",relevance:0},i={className:"variable",variants:[{
-begin:/\$\B/},{className:"keyword",begin:/\$this/},{begin:/\$[\w\d][\w\d_:]*/}]
-},a={className:"string",variants:[{begin:/"/,end:/"/},{begin:/@"/,end:/^"@/}],
-contains:[s,i,{className:"variable",begin:/\$[A-z]/,end:/[^A-z]/}]},t={
-className:"string",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]
-},r=e.inherit(e.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,
-end:/#>/}],contains:[{className:"doctag",variants:[{
-begin:/\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/
-},{
-begin:/\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\s+\S+/
-}]}]}),c={className:"class",beginKeywords:"class enum",end:/\s*[{]/,
-excludeEnd:!0,relevance:0,contains:[e.TITLE_MODE]},l={className:"function",
-begin:/function\s+/,end:/\s*\{|$/,excludeEnd:!0,returnBegin:!0,relevance:0,
-contains:[{begin:"function",relevance:0,className:"keyword"},{className:"title",
-begin:/\w[\w\d]*((-)[\w\d]+)*/,relevance:0},{begin:/\(/,end:/\)/,
-className:"params",relevance:0,contains:[i]}]},o={begin:/using\s/,end:/$/,
-returnBegin:!0,contains:[a,t,{className:"keyword",
-begin:/(using|assembly|command|module|namespace|type)/}]},p={
-className:"function",begin:/\[.*\]\s*[\w]+[ ]??\(/,end:/$/,returnBegin:!0,
-relevance:0,contains:[{className:"keyword",
-begin:"(".concat(n.keyword.toString().replace(/\s/g,"|"),")\\b"),endsParent:!0,
-relevance:0},e.inherit(e.TITLE_MODE,{endsParent:!0})]
-},g=[p,r,s,e.NUMBER_MODE,a,t,{className:"built_in",variants:[{
-begin:"(Add|Clear|Close|Copy|Enter|Exit|Find|Format|Get|Hide|Join|Lock|Move|New|Open|Optimize|Pop|Push|Redo|Remove|Rename|Reset|Resize|Search|Select|Set|Show|Skip|Split|Step|Switch|Undo|Unlock|Watch|Backup|Checkpoint|Compare|Compress|Convert|ConvertFrom|ConvertTo|Dismount|Edit|Expand|Export|Group|Import|Initialize|Limit|Merge|Mount|Out|Publish|Restore|Save|Sync|Unpublish|Update|Approve|Assert|Build|Complete|Confirm|Deny|Deploy|Disable|Enable|Install|Invoke|Register|Request|Restart|Resume|Start|Stop|Submit|Suspend|Uninstall|Unregister|Wait|Debug|Measure|Ping|Repair|Resolve|Test|Trace|Connect|Disconnect|Read|Receive|Send|Write|Block|Grant|Protect|Revoke|Unblock|Unprotect|Use|ForEach|Sort|Tee|Where)+(-)[\\w\\d]+"
-}]},i,{className:"literal",begin:/\$(null|true|false)\b/},{
-className:"selector-tag",begin:/@\B/,relevance:0}],m={begin:/\[/,end:/\]/,
-excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[].concat("self",g,{
-begin:"(string|char|byte|int|long|bool|decimal|single|double|DateTime|xml|array|hashtable|void)",
-className:"built_in",relevance:0},{className:"type",begin:/[\.\w\d]+/,
-relevance:0})};return p.contains.unshift(m),{name:"PowerShell",
-aliases:["ps","ps1"],case_insensitive:!0,keywords:n,contains:g.concat(c,l,o,{
-variants:[{className:"operator",
-begin:"(-and|-as|-band|-bnot|-bor|-bxor|-casesensitive|-ccontains|-ceq|-cge|-cgt|-cle|-clike|-clt|-cmatch|-cne|-cnotcontains|-cnotlike|-cnotmatch|-contains|-creplace|-csplit|-eq|-exact|-f|-file|-ge|-gt|-icontains|-ieq|-ige|-igt|-ile|-ilike|-ilt|-imatch|-in|-ine|-inotcontains|-inotlike|-inotmatch|-ireplace|-is|-isnot|-isplit|-join|-le|-like|-lt|-match|-ne|-not|-notcontains|-notin|-notlike|-notmatch|-or|-regex|-replace|-shl|-shr|-split|-wildcard|-xor)\\b"
-},{className:"literal",begin:/(-)[\w\d]+/,relevance:0}]},m)}}})());
-hljs.registerLanguage("processing",(()=>{"use strict";return e=>({
-name:"Processing",keywords:{
-keyword:"BufferedReader PVector PFont PImage PGraphics HashMap boolean byte char color double float int long String Array FloatDict FloatList IntDict IntList JSONArray JSONObject Object StringDict StringList Table TableRow XML false synchronized int abstract float private char boolean static null if const for true while long throw strictfp finally protected import native final return void enum else break transient new catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private",
-literal:"P2D P3D HALF_PI PI QUARTER_PI TAU TWO_PI",title:"setup draw",
-built_in:"displayHeight displayWidth mouseY mouseX mousePressed pmouseX pmouseY key keyCode pixels focused frameCount frameRate height width size createGraphics beginDraw createShape loadShape PShape arc ellipse line point quad rect triangle bezier bezierDetail bezierPoint bezierTangent curve curveDetail curvePoint curveTangent curveTightness shape shapeMode beginContour beginShape bezierVertex curveVertex endContour endShape quadraticVertex vertex ellipseMode noSmooth rectMode smooth strokeCap strokeJoin strokeWeight mouseClicked mouseDragged mouseMoved mousePressed mouseReleased mouseWheel keyPressed keyPressedkeyReleased keyTyped print println save saveFrame day hour millis minute month second year background clear colorMode fill noFill noStroke stroke alpha blue brightness color green hue lerpColor red saturation modelX modelY modelZ screenX screenY screenZ ambient emissive shininess specular add createImage beginCamera camera endCamera frustum ortho perspective printCamera printProjection cursor frameRate noCursor exit loop noLoop popStyle pushStyle redraw binary boolean byte char float hex int str unbinary unhex join match matchAll nf nfc nfp nfs split splitTokens trim append arrayCopy concat expand reverse shorten sort splice subset box sphere sphereDetail createInput createReader loadBytes loadJSONArray loadJSONObject loadStrings loadTable loadXML open parseXML saveTable selectFolder selectInput beginRaw beginRecord createOutput createWriter endRaw endRecord PrintWritersaveBytes saveJSONArray saveJSONObject saveStream saveStrings saveXML selectOutput popMatrix printMatrix pushMatrix resetMatrix rotate rotateX rotateY rotateZ scale shearX shearY translate ambientLight directionalLight lightFalloff lights lightSpecular noLights normal pointLight spotLight image imageMode loadImage noTint requestImage tint texture textureMode textureWrap blend copy filter get loadPixels set updatePixels blendMode loadShader PShaderresetShader shader createFont loadFont text textFont textAlign textLeading textMode textSize textWidth textAscent textDescent abs ceil constrain dist exp floor lerp log mag map max min norm pow round sq sqrt acos asin atan atan2 cos degrees radians sin tan noise noiseDetail noiseSeed random randomGaussian randomSeed"
-},
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE]
-})})());
-hljs.registerLanguage("profile",(()=>{"use strict";return e=>({
-name:"Python profiler",contains:[e.C_NUMBER_MODE,{
-begin:"[a-zA-Z_][\\da-zA-Z_]+\\.[\\da-zA-Z_]{1,3}",end:":",excludeEnd:!0},{
-begin:"(ncalls|tottime|cumtime)",end:"$",
-keywords:"ncalls tottime|10 cumtime|10 filename",relevance:10},{
-begin:"function calls",end:"$",contains:[e.C_NUMBER_MODE],relevance:10
-},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string",begin:"\\(",
-end:"\\)$",excludeBegin:!0,excludeEnd:!0,relevance:0}]})})());
-hljs.registerLanguage("prolog",(()=>{"use strict";return n=>{const e={
-begin:/\(/,end:/\)/,relevance:0},a={begin:/\[/,end:/\]/},s={className:"comment",
-begin:/%/,end:/$/,contains:[n.PHRASAL_WORDS_MODE]},i={className:"string",
-begin:/`/,end:/`/,contains:[n.BACKSLASH_ESCAPE]},g=[{begin:/[a-z][A-Za-z0-9_]*/,
-relevance:0},{className:"symbol",variants:[{begin:/[A-Z][a-zA-Z0-9_]*/},{
-begin:/_[A-Za-z0-9_]*/}],relevance:0},e,{begin:/:-/
-},a,s,n.C_BLOCK_COMMENT_MODE,n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,i,{
-className:"string",begin:/0'(\\'|.)/},{className:"string",begin:/0'\\s/
-},n.C_NUMBER_MODE];return e.contains=g,a.contains=g,{name:"Prolog",
-contains:g.concat([{begin:/\.$/}])}}})());
-hljs.registerLanguage("properties",(()=>{"use strict";return e=>{
-var n="[ \\t\\f]*",a=n+"[:=]"+n,t="("+a+"|[ \\t\\f]+)",r="([^\\\\\\W:= \\t\\f\\n]|\\\\.)+",s="([^\\\\:= \\t\\f\\n]|\\\\.)+",i={
-end:t,relevance:0,starts:{className:"string",end:/$/,relevance:0,contains:[{
-begin:"\\\\\\\\"},{begin:"\\\\\\n"}]}};return{name:".properties",
-case_insensitive:!0,illegal:/\S/,contains:[e.COMMENT("^\\s*[!#]","$"),{
-returnBegin:!0,variants:[{begin:r+a,relevance:1},{begin:r+"[ \\t\\f]+",
-relevance:0}],contains:[{className:"attr",begin:r,endsParent:!0,relevance:0}],
-starts:i},{begin:s+t,returnBegin:!0,relevance:0,contains:[{className:"meta",
-begin:s,endsParent:!0,relevance:0}],starts:i},{className:"attr",relevance:0,
-begin:s+n+"$"}]}}})());
-hljs.registerLanguage("protobuf",(()=>{"use strict";return e=>({
-name:"Protocol Buffers",keywords:{
-keyword:"package import option optional required repeated group oneof",
-built_in:"double float int32 int64 uint32 uint64 sint32 sint64 fixed32 fixed64 sfixed32 sfixed64 bool string bytes",
-literal:"true false"},
-contains:[e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"class",beginKeywords:"message enum service",end:/\{/,illegal:/\n/,
-contains:[e.inherit(e.TITLE_MODE,{starts:{endsWithParent:!0,excludeEnd:!0}})]},{
-className:"function",beginKeywords:"rpc",end:/[{;]/,excludeEnd:!0,
-keywords:"rpc returns"},{begin:/^\s*[A-Z_]+(?=\s*=[^\n]+;$)/}]})})());
-hljs.registerLanguage("puppet",(()=>{"use strict";return e=>{
-const s=e.COMMENT("#","$"),r="([A-Za-z_]|::)(\\w|::)*",a=e.inherit(e.TITLE_MODE,{
-begin:r}),n={className:"variable",begin:"\\$"+r},i={className:"string",
-contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/
-}]};return{name:"Puppet",aliases:["pp"],contains:[s,n,i,{beginKeywords:"class",
-end:"\\{|;",illegal:/=/,contains:[a,s]},{beginKeywords:"define",end:/\{/,
-contains:[{className:"section",begin:e.IDENT_RE,endsParent:!0}]},{
-begin:e.IDENT_RE+"\\s+\\{",returnBegin:!0,end:/\S/,contains:[{
-className:"keyword",begin:e.IDENT_RE},{begin:/\{/,end:/\}/,keywords:{
-keyword:"and case default else elsif false if in import enherits node or true undef unless main settings $string ",
-literal:"alias audit before loglevel noop require subscribe tag owner ensure group mode name|0 changes context force incl lens load_path onlyif provider returns root show_diff type_check en_address ip_address realname command environment hour monute month monthday special target weekday creates cwd ogoutput refresh refreshonly tries try_sleep umask backup checksum content ctime force ignore links mtime purge recurse recurselimit replace selinux_ignore_defaults selrange selrole seltype seluser source souirce_permissions sourceselect validate_cmd validate_replacement allowdupe attribute_membership auth_membership forcelocal gid ia_load_module members system host_aliases ip allowed_trunk_vlans description device_url duplex encapsulation etherchannel native_vlan speed principals allow_root auth_class auth_type authenticate_user k_of_n mechanisms rule session_owner shared options device fstype enable hasrestart directory present absent link atboot blockdevice device dump pass remounts poller_tag use message withpath adminfile allow_virtual allowcdrom category configfiles flavor install_options instance package_settings platform responsefile status uninstall_options vendor unless_system_user unless_uid binary control flags hasstatus manifest pattern restart running start stop allowdupe auths expiry gid groups home iterations key_membership keys managehome membership password password_max_age password_min_age profile_membership profiles project purge_ssh_keys role_membership roles salt shell uid baseurl cost descr enabled enablegroups exclude failovermethod gpgcheck gpgkey http_caching include includepkgs keepalive metadata_expire metalink mirrorlist priority protect proxy proxy_password proxy_username repo_gpgcheck s3_enabled skip_if_unavailable sslcacert sslclientcert sslclientkey sslverify mounted",
-built_in:"architecture augeasversion blockdevices boardmanufacturer boardproductname boardserialnumber cfkey dhcp_servers domain ec2_ ec2_userdata facterversion filesystems ldom fqdn gid hardwareisa hardwaremodel hostname id|0 interfaces ipaddress ipaddress_ ipaddress6 ipaddress6_ iphostnumber is_virtual kernel kernelmajversion kernelrelease kernelversion kernelrelease kernelversion lsbdistcodename lsbdistdescription lsbdistid lsbdistrelease lsbmajdistrelease lsbminordistrelease lsbrelease macaddress macaddress_ macosx_buildversion macosx_productname macosx_productversion macosx_productverson_major macosx_productversion_minor manufacturer memoryfree memorysize netmask metmask_ network_ operatingsystem operatingsystemmajrelease operatingsystemrelease osfamily partitions path physicalprocessorcount processor processorcount productname ps puppetversion rubysitedir rubyversion selinux selinux_config_mode selinux_config_policy selinux_current_mode selinux_current_mode selinux_enforced selinux_policyversion serialnumber sp_ sshdsakey sshecdsakey sshrsakey swapencrypted swapfree swapsize timezone type uniqueid uptime uptime_days uptime_hours uptime_seconds uuid virtual vlans xendomains zfs_version zonenae zones zpool_version"
-},relevance:0,contains:[i,s,{begin:"[a-zA-Z_]+\\s*=>",returnBegin:!0,end:"=>",
-contains:[{className:"attr",begin:e.IDENT_RE}]},{className:"number",
-begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",
-relevance:0},n]}],relevance:0}]}}})());
-hljs.registerLanguage("purebasic",(()=>{"use strict";return e=>({
-name:"PureBASIC",aliases:["pb","pbi"],
-keywords:"Align And Array As Break CallDebugger Case CompilerCase CompilerDefault CompilerElse CompilerElseIf CompilerEndIf CompilerEndSelect CompilerError CompilerIf CompilerSelect CompilerWarning Continue Data DataSection Debug DebugLevel Declare DeclareC DeclareCDLL DeclareDLL DeclareModule Default Define Dim DisableASM DisableDebugger DisableExplicit Else ElseIf EnableASM EnableDebugger EnableExplicit End EndDataSection EndDeclareModule EndEnumeration EndIf EndImport EndInterface EndMacro EndModule EndProcedure EndSelect EndStructure EndStructureUnion EndWith Enumeration EnumerationBinary Extends FakeReturn For ForEach ForEver Global Gosub Goto If Import ImportC IncludeBinary IncludeFile IncludePath Interface List Macro MacroExpandedCount Map Module NewList NewMap Next Not Or Procedure ProcedureC ProcedureCDLL ProcedureDLL ProcedureReturn Protected Prototype PrototypeC ReDim Read Repeat Restore Return Runtime Select Shared Static Step Structure StructureUnion Swap Threaded To UndefineMacro Until Until  UnuseModule UseModule Wend While With XIncludeFile XOr",
-contains:[e.COMMENT(";","$",{relevance:0}),{className:"function",
-begin:"\\b(Procedure|Declare)(C|CDLL|DLL)?\\b",end:"\\(",excludeEnd:!0,
-returnBegin:!0,contains:[{className:"keyword",
-begin:"(Procedure|Declare)(C|CDLL|DLL)?",excludeEnd:!0},{className:"type",
-begin:"\\.\\w*"},e.UNDERSCORE_TITLE_MODE]},{className:"string",begin:'(~)?"',
-end:'"',illegal:"\\n"},{className:"symbol",begin:"#[a-zA-Z_]\\w*\\$?"}]})})());
-hljs.registerLanguage("python",(()=>{"use strict";return e=>{const n={
-$pattern:/[A-Za-z]\w+|__\w+__/,
-keyword:["and","as","assert","async","await","break","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],
-built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"],
-literal:["__debug__","Ellipsis","False","None","NotImplemented","True"],
-type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"]
-},a={className:"meta",begin:/^(>>>|\.\.\.) /},i={className:"subst",begin:/\{/,
-end:/\}/,keywords:n,illegal:/#/},s={begin:/\{\{/,relevance:0},t={
-className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{
-begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/,
-contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{
-begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/,
-contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{
-begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/,
-contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/,
-end:/"""/,contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/([uU]|[rR])'/,end:/'/,
-relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{
-begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/,
-end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/,
-contains:[e.BACKSLASH_ESCAPE,s,i]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/,
-contains:[e.BACKSLASH_ESCAPE,s,i]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},r="[0-9](_?[0-9])*",l=`(\\b(${r}))?\\.(${r})|\\b(${r})\\.`,b={
-className:"number",relevance:0,variants:[{
-begin:`(\\b(${r})|(${l}))[eE][+-]?(${r})[jJ]?\\b`},{begin:`(${l})[jJ]?`},{
-begin:"\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?\\b"},{
-begin:"\\b0[bB](_?[01])+[lL]?\\b"},{begin:"\\b0[oO](_?[0-7])+[lL]?\\b"},{
-begin:"\\b0[xX](_?[0-9a-fA-F])+[lL]?\\b"},{begin:`\\b(${r})[jJ]\\b`}]},o={
-className:"comment",
-begin:(d=/# type:/,((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",d,")")),
-end:/$/,keywords:n,contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,
-endsWithParent:!0}]},c={className:"params",variants:[{className:"",
-begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,
-keywords:n,contains:["self",a,b,t,e.HASH_COMMENT_MODE]}]};var d
-;return i.contains=[t,b,a],{name:"Python",aliases:["py","gyp","ipython"],
-keywords:n,illegal:/(<\/|->|\?)|=>/,contains:[a,b,{begin:/\bself\b/},{
-beginKeywords:"if",relevance:0},t,o,e.HASH_COMMENT_MODE,{variants:[{
-className:"function",beginKeywords:"def"},{className:"class",
-beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/,
-contains:[e.UNDERSCORE_TITLE_MODE,c,{begin:/->/,endsWithParent:!0,keywords:n}]
-},{className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[b,c,t]}]}}})());
-hljs.registerLanguage("python-repl",(()=>{"use strict";return s=>({
-aliases:["pycon"],contains:[{className:"meta",starts:{end:/ |$/,starts:{end:"$",
-subLanguage:"python"}},variants:[{begin:/^>>>(?=[ ]|$)/},{
-begin:/^\.\.\.(?=[ ]|$)/}]}]})})());
-hljs.registerLanguage("q",(()=>{"use strict";return e=>({name:"Q",
-aliases:["k","kdb"],keywords:{$pattern:/(`?)[A-Za-z0-9_]+\b/,
-keyword:"do while select delete by update from",literal:"0b 1b",
-built_in:"neg not null string reciprocal floor ceiling signum mod xbar xlog and or each scan over prior mmu lsq inv md5 ltime gtime count first var dev med cov cor all any rand sums prds mins maxs fills deltas ratios avgs differ prev next rank reverse iasc idesc asc desc msum mcount mavg mdev xrank mmin mmax xprev rotate distinct group where flip type key til get value attr cut set upsert raze union inter except cross sv vs sublist enlist read0 read1 hopen hclose hdel hsym hcount peach system ltrim rtrim trim lower upper ssr view tables views cols xcols keys xkey xcol xasc xdesc fkeys meta lj aj aj0 ij pj asof uj ww wj wj1 fby xgroup ungroup ej save load rsave rload show csv parse eval min max avg wavg wsum sin cos tan sum",
-type:"`float `double int `timestamp `timespan `datetime `time `boolean `symbol `char `byte `short `long `real `month `date `minute `second `guid"
-},contains:[e.C_LINE_COMMENT_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE]})})());
-hljs.registerLanguage("qml",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n
-})).join("")}return n=>{const r="[a-zA-Z_][a-zA-Z0-9\\._]*",a={
-className:"attribute",begin:"\\bid\\s*:",starts:{className:"string",end:r,
-returnEnd:!1}},t={begin:r+"\\s*:",returnBegin:!0,contains:[{
-className:"attribute",begin:r,end:"\\s*:",excludeEnd:!0,relevance:0}],
-relevance:0},i={begin:e(r,/\s*\{/),end:/\{/,returnBegin:!0,relevance:0,
-contains:[n.inherit(n.TITLE_MODE,{begin:r})]};return{name:"QML",aliases:["qt"],
-case_insensitive:!1,keywords:{
-keyword:"in of on if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await import",
-literal:"true false null undefined NaN Infinity",
-built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Behavior bool color coordinate date double enumeration font geocircle georectangle geoshape int list matrix4x4 parent point quaternion real rect size string url variant vector2d vector3d vector4d Promise"
-},contains:[{className:"meta",begin:/^\s*['"]use (strict|asm)['"]/
-},n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,{className:"string",begin:"`",end:"`",
-contains:[n.BACKSLASH_ESCAPE,{className:"subst",begin:"\\$\\{",end:"\\}"}]
-},n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,{className:"number",variants:[{
-begin:"\\b(0[bB][01]+)"},{begin:"\\b(0[oO][0-7]+)"},{begin:n.C_NUMBER_RE}],
-relevance:0},{begin:"("+n.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",
-keywords:"return throw case",
-contains:[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,n.REGEXP_MODE,{begin:/</,
-end:/>\s*[);\]]/,relevance:0,subLanguage:"xml"}],relevance:0},{
-className:"keyword",begin:"\\bsignal\\b",starts:{className:"string",
-end:"(\\(|:|=|;|,|//|/\\*|$)",returnEnd:!0}},{className:"keyword",
-begin:"\\bproperty\\b",starts:{className:"string",end:"(:|=|;|,|//|/\\*|$)",
-returnEnd:!0}},{className:"function",beginKeywords:"function",end:/\{/,
-excludeEnd:!0,contains:[n.inherit(n.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/
-}),{className:"params",begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,
-contains:[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE]}],illegal:/\[|%/},{
-begin:"\\."+n.IDENT_RE,relevance:0},a,t,i],illegal:/#/}}})());
-hljs.registerLanguage("r",(()=>{"use strict";function e(...e){return e.map((e=>{
-return(a=e)?"string"==typeof a?a:a.source:null;var a})).join("")}return a=>{
-const n=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/;return{name:"R",
-illegal:/->/,keywords:{$pattern:n,
-keyword:"function if in break next repeat else for while",
-literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10",
-built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm"
-},compilerExtensions:[(a,n)=>{if(!a.beforeMatch)return
-;if(a.starts)throw Error("beforeMatch cannot be used with starts")
-;const i=Object.assign({},a);Object.keys(a).forEach((e=>{delete a[e]
-})),a.begin=e(i.beforeMatch,e("(?=",i.begin,")")),a.starts={relevance:0,
-contains:[Object.assign(i,{endsParent:!0})]},a.relevance=0,delete i.beforeMatch
-}],contains:[a.COMMENT(/#'/,/$/,{contains:[{className:"doctag",
-begin:"@examples",starts:{contains:[{begin:/\n/},{begin:/#'\s*(?=@[a-zA-Z]+)/,
-endsParent:!0},{begin:/#'/,end:/$/,excludeBegin:!0}]}},{className:"doctag",
-begin:"@param",end:/$/,contains:[{className:"variable",variants:[{begin:n},{
-begin:/`(?:\\.|[^`\\])+`/}],endsParent:!0}]},{className:"doctag",
-begin:/@[a-zA-Z]+/},{className:"meta-keyword",begin:/\\[a-zA-Z]+/}]
-}),a.HASH_COMMENT_MODE,{className:"string",contains:[a.BACKSLASH_ESCAPE],
-variants:[a.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/
-}),a.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/
-}),a.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/
-}),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/
-}),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/
-}),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"',
-relevance:0},{begin:"'",end:"'",relevance:0}]},{className:"number",relevance:0,
-beforeMatch:/([^a-zA-Z0-9._])/,variants:[{
-match:/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/},{
-match:/0[xX][0-9a-fA-F]+([pP][+-]?\d+)?[Li]?/},{
-match:/(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?[Li]?/}]},{begin:"%",end:"%"},{
-begin:e(/[a-zA-Z][a-zA-Z_0-9]*/,"\\s+<-\\s+")},{begin:"`",end:"`",contains:[{
-begin:/\\./}]}]}}})());
-hljs.registerLanguage("reasonml",(()=>{"use strict";return e=>{
-const n="~?[a-z$_][0-9a-zA-Z$_]*",a="`?[A-Z$_][0-9a-zA-Z$_]*",s="("+["||","++","**","+.","*","/","*.","/.","..."].map((e=>e.split("").map((e=>"\\"+e)).join(""))).join("|")+"|\\|>|&&|==|===)",i="\\s+"+s+"\\s+",r={
-keyword:"and as asr assert begin class constraint do done downto else end exception external for fun function functor if in include inherit initializer land lazy let lor lsl lsr lxor match method mod module mutable new nonrec object of open or private rec sig struct then to try type val virtual when while with",
-built_in:"array bool bytes char exn|5 float int int32 int64 list lazy_t|5 nativeint|5 ref string unit ",
-literal:"true false"
-},l="\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",t={
-className:"number",relevance:0,variants:[{begin:l},{begin:"\\(-"+l+"\\)"}]},c={
-className:"operator",relevance:0,begin:s},o=[{className:"identifier",
-relevance:0,begin:n},c,t],g=[e.QUOTE_STRING_MODE,c,{className:"module",
-begin:"\\b"+a,returnBegin:!0,end:".",contains:[{className:"identifier",begin:a,
-relevance:0}]}],b=[{className:"module",begin:"\\b"+a,returnBegin:!0,end:".",
-relevance:0,contains:[{className:"identifier",begin:a,relevance:0}]}],m={
-className:"function",relevance:0,keywords:r,variants:[{
-begin:"\\s(\\(\\.?.*?\\)|"+n+")\\s*=>",end:"\\s*=>",returnBegin:!0,relevance:0,
-contains:[{className:"params",variants:[{begin:n},{
-begin:"~?[a-z$_][0-9a-zA-Z$_]*(\\s*:\\s*[a-z$_][0-9a-z$_]*(\\(\\s*('?[a-z$_][0-9a-z$_]*\\s*(,'?[a-z$_][0-9a-z$_]*\\s*)*)?\\))?){0,2}"
-},{begin:/\(\s*\)/}]}]},{begin:"\\s\\(\\.?[^;\\|]*\\)\\s*=>",end:"\\s=>",
-returnBegin:!0,relevance:0,contains:[{className:"params",relevance:0,variants:[{
-begin:n,end:"(,|\\n|\\))",relevance:0,contains:[c,{className:"typing",begin:":",
-end:"(,|\\n)",returnBegin:!0,relevance:0,contains:b}]}]}]},{
-begin:"\\(\\.\\s"+n+"\\)\\s*=>"}]};g.push(m);const d={className:"constructor",
-begin:a+"\\(",end:"\\)",illegal:"\\n",keywords:r,
-contains:[e.QUOTE_STRING_MODE,c,{className:"params",begin:"\\b"+n}]},u={
-className:"pattern-match",begin:"\\|",returnBegin:!0,keywords:r,end:"=>",
-relevance:0,contains:[d,c,{relevance:0,className:"constructor",begin:a}]},v={
-className:"module-access",keywords:r,returnBegin:!0,variants:[{
-begin:"\\b("+a+"\\.)+"+n},{begin:"\\b("+a+"\\.)+\\(",end:"\\)",returnBegin:!0,
-contains:[m,{begin:"\\(",end:"\\)",skip:!0}].concat(g)},{
-begin:"\\b("+a+"\\.)+\\{",end:/\}/}],contains:g};return b.push(v),{
-name:"ReasonML",aliases:["re"],keywords:r,illegal:"(:-|:=|\\$\\{|\\+=)",
-contains:[e.COMMENT("/\\*","\\*/",{illegal:"^(#,\\/\\/)"}),{
-className:"character",begin:"'(\\\\[^']+|[^'])'",illegal:"\\n",relevance:0
-},e.QUOTE_STRING_MODE,{className:"literal",begin:"\\(\\)",relevance:0},{
-className:"literal",begin:"\\[\\|",end:"\\|\\]",relevance:0,contains:o},{
-className:"literal",begin:"\\[",end:"\\]",relevance:0,contains:o},d,{
-className:"operator",begin:i,illegal:"--\x3e",relevance:0
-},t,e.C_LINE_COMMENT_MODE,u,m,{className:"module-def",
-begin:"\\bmodule\\s+"+n+"\\s+"+a+"\\s+=\\s+\\{",end:/\}/,returnBegin:!0,
-keywords:r,relevance:0,contains:[{className:"module",relevance:0,begin:a},{
-begin:/\{/,end:/\}/,skip:!0}].concat(g)},v]}}})());
-hljs.registerLanguage("rib",(()=>{"use strict";return e=>({name:"RenderMan RIB",
-keywords:"ArchiveRecord AreaLightSource Atmosphere Attribute AttributeBegin AttributeEnd Basis Begin Blobby Bound Clipping ClippingPlane Color ColorSamples ConcatTransform Cone CoordinateSystem CoordSysTransform CropWindow Curves Cylinder DepthOfField Detail DetailRange Disk Displacement Display End ErrorHandler Exposure Exterior Format FrameAspectRatio FrameBegin FrameEnd GeneralPolygon GeometricApproximation Geometry Hider Hyperboloid Identity Illuminate Imager Interior LightSource MakeCubeFaceEnvironment MakeLatLongEnvironment MakeShadow MakeTexture Matte MotionBegin MotionEnd NuPatch ObjectBegin ObjectEnd ObjectInstance Opacity Option Orientation Paraboloid Patch PatchMesh Perspective PixelFilter PixelSamples PixelVariance Points PointsGeneralPolygons PointsPolygons Polygon Procedural Projection Quantize ReadArchive RelativeDetail ReverseOrientation Rotate Scale ScreenWindow ShadingInterpolation ShadingRate Shutter Sides Skew SolidBegin SolidEnd Sphere SubdivisionMesh Surface TextureCoordinates Torus Transform TransformBegin TransformEnd TransformPoints Translate TrimCurve WorldBegin WorldEnd",
-illegal:"</",
-contains:[e.HASH_COMMENT_MODE,e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-})})());
-hljs.registerLanguage("roboconf",(()=>{"use strict";return e=>{
-const n="[a-zA-Z-_][^\\n{]+\\{",a={className:"attribute",begin:/[a-zA-Z-_]+/,
-end:/\s*:/,excludeEnd:!0,starts:{end:";",relevance:0,contains:[{
-className:"variable",begin:/\.[a-zA-Z-_]+/},{className:"keyword",
-begin:/\(optional\)/}]}};return{name:"Roboconf",aliases:["graph","instances"],
-case_insensitive:!0,keywords:"import",contains:[{begin:"^facet "+n,end:/\}/,
-keywords:"facet",contains:[a,e.HASH_COMMENT_MODE]},{begin:"^\\s*instance of "+n,
-end:/\}/,
-keywords:"name count channels instance-data instance-state instance of",
-illegal:/\S/,contains:["self",a,e.HASH_COMMENT_MODE]},{begin:"^"+n,end:/\}/,
-contains:[a,e.HASH_COMMENT_MODE]},e.HASH_COMMENT_MODE]}}})());
-hljs.registerLanguage("routeros",(()=>{"use strict";return e=>{
-const r="foreach do while for if from to step else on-error and or not in",n="true false yes no nothing nil null",i={
-className:"variable",variants:[{begin:/\$[\w\d#@][\w\d_]*/},{begin:/\$\{(.*?)\}/
-}]},s={className:"string",begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,i,{
-className:"variable",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]}]},t={
-className:"string",begin:/'/,end:/'/};return{name:"Microtik RouterOS script",
-aliases:["mikrotik"],case_insensitive:!0,keywords:{$pattern:/:?[\w-]+/,
-literal:n,
-keyword:r+" :"+r.split(" ").join(" :")+" :"+"global local beep delay put len typeof pick log time set find environment terminal error execute parse resolve toarray tobool toid toip toip6 tonum tostr totime".split(" ").join(" :")
-},contains:[{variants:[{begin:/\/\*/,end:/\*\//},{begin:/\/\//,end:/$/},{
-begin:/<\//,end:/>/}],illegal:/./},e.COMMENT("^#","$"),s,t,i,{
-begin:/[\w-]+=([^\s{}[\]()>]+)/,relevance:0,returnBegin:!0,contains:[{
-className:"attribute",begin:/[^=]+/},{begin:/=/,endsWithParent:!0,relevance:0,
-contains:[s,t,i,{className:"literal",begin:"\\b("+n.split(" ").join("|")+")\\b"
-},{begin:/("[^"]*"|[^\s{}[\]]+)/}]}]},{className:"number",begin:/\*[0-9a-fA-F]+/
-},{
-begin:"\\b(add|remove|enable|disable|set|get|print|export|edit|find|run|debug|error|info|warning)([\\s[(\\]|])",
-returnBegin:!0,contains:[{className:"builtin-name",begin:/\w+/}]},{
-className:"built_in",variants:[{
-begin:"(\\.\\./|/|\\s)((traffic-flow|traffic-generator|firewall|scheduler|aaa|accounting|address-list|address|align|area|bandwidth-server|bfd|bgp|bridge|client|clock|community|config|connection|console|customer|default|dhcp-client|dhcp-server|discovery|dns|e-mail|ethernet|filter|firmware|gps|graphing|group|hardware|health|hotspot|identity|igmp-proxy|incoming|instance|interface|ip|ipsec|ipv6|irq|l2tp-server|lcd|ldp|logging|mac-server|mac-winbox|mangle|manual|mirror|mme|mpls|nat|nd|neighbor|network|note|ntp|ospf|ospf-v3|ovpn-server|page|peer|pim|ping|policy|pool|port|ppp|pppoe-client|pptp-server|prefix|profile|proposal|proxy|queue|radius|resource|rip|ripng|route|routing|screen|script|security-profiles|server|service|service-port|settings|shares|smb|sms|sniffer|snmp|snooper|socks|sstp-server|system|tool|tracking|type|upgrade|upnp|user-manager|users|user|vlan|secret|vrrp|watchdog|web-access|wireless|pptp|pppoe|lan|wan|layer7-protocol|lease|simple|raw);?\\s)+"
-},{begin:/\.\./,relevance:0}]}]}}})());
-hljs.registerLanguage("rsl",(()=>{"use strict";return e=>({name:"RenderMan RSL",
-keywords:{
-keyword:"float color point normal vector matrix while for if do return else break extern continue",
-built_in:"abs acos ambient area asin atan atmosphere attribute calculatenormal ceil cellnoise clamp comp concat cos degrees depth Deriv diffuse distance Du Dv environment exp faceforward filterstep floor format fresnel incident length lightsource log match max min mod noise normalize ntransform opposite option phong pnoise pow printf ptlined radians random reflect refract renderinfo round setcomp setxcomp setycomp setzcomp shadow sign sin smoothstep specular specularbrdf spline sqrt step tan texture textureinfo trace transform vtransform xcomp ycomp zcomp"
-},illegal:"</",
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"#",end:"$"},{className:"class",
-beginKeywords:"surface displacement light volume imager",end:"\\("},{
-beginKeywords:"illuminate illuminance gather",end:"\\("}]})})());
-hljs.registerLanguage("ruleslanguage",(()=>{"use strict";return T=>({
-name:"Oracle Rules Language",keywords:{
-keyword:"BILL_PERIOD BILL_START BILL_STOP RS_EFFECTIVE_START RS_EFFECTIVE_STOP RS_JURIS_CODE RS_OPCO_CODE INTDADDATTRIBUTE|5 INTDADDVMSG|5 INTDBLOCKOP|5 INTDBLOCKOPNA|5 INTDCLOSE|5 INTDCOUNT|5 INTDCOUNTSTATUSCODE|5 INTDCREATEMASK|5 INTDCREATEDAYMASK|5 INTDCREATEFACTORMASK|5 INTDCREATEHANDLE|5 INTDCREATEOVERRIDEDAYMASK|5 INTDCREATEOVERRIDEMASK|5 INTDCREATESTATUSCODEMASK|5 INTDCREATETOUPERIOD|5 INTDDELETE|5 INTDDIPTEST|5 INTDEXPORT|5 INTDGETERRORCODE|5 INTDGETERRORMESSAGE|5 INTDISEQUAL|5 INTDJOIN|5 INTDLOAD|5 INTDLOADACTUALCUT|5 INTDLOADDATES|5 INTDLOADHIST|5 INTDLOADLIST|5 INTDLOADLISTDATES|5 INTDLOADLISTENERGY|5 INTDLOADLISTHIST|5 INTDLOADRELATEDCHANNEL|5 INTDLOADSP|5 INTDLOADSTAGING|5 INTDLOADUOM|5 INTDLOADUOMDATES|5 INTDLOADUOMHIST|5 INTDLOADVERSION|5 INTDOPEN|5 INTDREADFIRST|5 INTDREADNEXT|5 INTDRECCOUNT|5 INTDRELEASE|5 INTDREPLACE|5 INTDROLLAVG|5 INTDROLLPEAK|5 INTDSCALAROP|5 INTDSCALE|5 INTDSETATTRIBUTE|5 INTDSETDSTPARTICIPANT|5 INTDSETSTRING|5 INTDSETVALUE|5 INTDSETVALUESTATUS|5 INTDSHIFTSTARTTIME|5 INTDSMOOTH|5 INTDSORT|5 INTDSPIKETEST|5 INTDSUBSET|5 INTDTOU|5 INTDTOURELEASE|5 INTDTOUVALUE|5 INTDUPDATESTATS|5 INTDVALUE|5 STDEV INTDDELETEEX|5 INTDLOADEXACTUAL|5 INTDLOADEXCUT|5 INTDLOADEXDATES|5 INTDLOADEX|5 INTDLOADEXRELATEDCHANNEL|5 INTDSAVEEX|5 MVLOAD|5 MVLOADACCT|5 MVLOADACCTDATES|5 MVLOADACCTHIST|5 MVLOADDATES|5 MVLOADHIST|5 MVLOADLIST|5 MVLOADLISTDATES|5 MVLOADLISTHIST|5 IF FOR NEXT DONE SELECT END CALL ABORT CLEAR CHANNEL FACTOR LIST NUMBER OVERRIDE SET WEEK DISTRIBUTIONNODE ELSE WHEN THEN OTHERWISE IENUM CSV INCLUDE LEAVE RIDER SAVE DELETE NOVALUE SECTION WARN SAVE_UPDATE DETERMINANT LABEL REPORT REVENUE EACH IN FROM TOTAL CHARGE BLOCK AND OR CSV_FILE RATE_CODE AUXILIARY_DEMAND UIDACCOUNT RS BILL_PERIOD_SELECT HOURS_PER_MONTH INTD_ERROR_STOP SEASON_SCHEDULE_NAME ACCOUNTFACTOR ARRAYUPPERBOUND CALLSTOREDPROC GETADOCONNECTION GETCONNECT GETDATASOURCE GETQUALIFIER GETUSERID HASVALUE LISTCOUNT LISTOP LISTUPDATE LISTVALUE PRORATEFACTOR RSPRORATE SETBINPATH SETDBMONITOR WQ_OPEN BILLINGHOURS DATE DATEFROMFLOAT DATETIMEFROMSTRING DATETIMETOSTRING DATETOFLOAT DAY DAYDIFF DAYNAME DBDATETIME HOUR MINUTE MONTH MONTHDIFF MONTHHOURS MONTHNAME ROUNDDATE SAMEWEEKDAYLASTYEAR SECOND WEEKDAY WEEKDIFF YEAR YEARDAY YEARSTR COMPSUM HISTCOUNT HISTMAX HISTMIN HISTMINNZ HISTVALUE MAXNRANGE MAXRANGE MINRANGE COMPIKVA COMPKVA COMPKVARFROMKQKW COMPLF IDATTR FLAG LF2KW LF2KWH MAXKW POWERFACTOR READING2USAGE AVGSEASON MAXSEASON MONTHLYMERGE SEASONVALUE SUMSEASON ACCTREADDATES ACCTTABLELOAD CONFIGADD CONFIGGET CREATEOBJECT CREATEREPORT EMAILCLIENT EXPBLKMDMUSAGE EXPMDMUSAGE EXPORT_USAGE FACTORINEFFECT GETUSERSPECIFIEDSTOP INEFFECT ISHOLIDAY RUNRATE SAVE_PROFILE SETREPORTTITLE USEREXIT WATFORRUNRATE TO TABLE ACOS ASIN ATAN ATAN2 BITAND CEIL COS COSECANT COSH COTANGENT DIVQUOT DIVREM EXP FABS FLOOR FMOD FREPM FREXPN LOG LOG10 MAX MAXN MIN MINNZ MODF POW ROUND ROUND2VALUE ROUNDINT SECANT SIN SINH SQROOT TAN TANH FLOAT2STRING FLOAT2STRINGNC INSTR LEFT LEN LTRIM MID RIGHT RTRIM STRING STRINGNC TOLOWER TOUPPER TRIM NUMDAYS READ_DATE STAGING",
-built_in:"IDENTIFIER OPTIONS XML_ELEMENT XML_OP XML_ELEMENT_OF DOMDOCCREATE DOMDOCLOADFILE DOMDOCLOADXML DOMDOCSAVEFILE DOMDOCGETROOT DOMDOCADDPI DOMNODEGETNAME DOMNODEGETTYPE DOMNODEGETVALUE DOMNODEGETCHILDCT DOMNODEGETFIRSTCHILD DOMNODEGETSIBLING DOMNODECREATECHILDELEMENT DOMNODESETATTRIBUTE DOMNODEGETCHILDELEMENTCT DOMNODEGETFIRSTCHILDELEMENT DOMNODEGETSIBLINGELEMENT DOMNODEGETATTRIBUTECT DOMNODEGETATTRIBUTEI DOMNODEGETATTRIBUTEBYNAME DOMNODEGETBYNAME"
-},
-contains:[T.C_LINE_COMMENT_MODE,T.C_BLOCK_COMMENT_MODE,T.APOS_STRING_MODE,T.QUOTE_STRING_MODE,T.C_NUMBER_MODE,{
-className:"literal",variants:[{begin:"#\\s+",relevance:0},{begin:"#[a-zA-Z .]+"
-}]}]})})());
-hljs.registerLanguage("rust",(()=>{"use strict";return e=>{
-const n="([ui](8|16|32|64|128|size)|f(32|64))?",t="drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!"
-;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",
-keyword:"abstract as async await become box break const continue crate do dyn else enum extern false final fn for if impl in let loop macro match mod move mut override priv pub ref return self Self static struct super trait true try type typeof unsafe unsized use virtual where while yield",
-literal:"true false Some None Ok Err",built_in:t},illegal:"</",
-contains:[e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*","\\*/",{contains:["self"]
-}),e.inherit(e.QUOTE_STRING_MODE,{begin:/b?"/,illegal:null}),{
-className:"string",variants:[{begin:/r(#*)"(.|\n)*?"\1(?!#)/},{
-begin:/b?'\\?(x\w{2}|u\w{4}|U\w{8}|.)'/}]},{className:"symbol",
-begin:/'[a-zA-Z_][a-zA-Z0-9_]*/},{className:"number",variants:[{
-begin:"\\b0b([01_]+)"+n},{begin:"\\b0o([0-7_]+)"+n},{
-begin:"\\b0x([A-Fa-f0-9_]+)"+n},{
-begin:"\\b(\\d[\\d_]*(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)"+n}],relevance:0},{
-className:"function",beginKeywords:"fn",end:"(\\(|<)",excludeEnd:!0,
-contains:[e.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"#!?\\[",end:"\\]",
-contains:[{className:"meta-string",begin:/"/,end:/"/}]},{className:"class",
-beginKeywords:"type",end:";",contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{
-endsParent:!0})],illegal:"\\S"},{className:"class",
-beginKeywords:"trait enum struct union",end:/\{/,
-contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"[\\w\\d]"
-},{begin:e.IDENT_RE+"::",keywords:{built_in:t}},{begin:"->"}]}}})());
-hljs.registerLanguage("sas",(()=>{"use strict";return e=>({name:"SAS",
-case_insensitive:!0,keywords:{
-literal:"null missing _all_ _automatic_ _character_ _infile_ _n_ _name_ _null_ _numeric_ _user_ _webout_",
-meta:"do if then else end until while abort array attrib by call cards cards4 catname continue datalines datalines4 delete delim delimiter display dm drop endsas error file filename footnote format goto in infile informat input keep label leave length libname link list lostcard merge missing modify options output out page put redirect remove rename replace retain return select set skip startsas stop title update waitsas where window x systask add and alter as cascade check create delete describe distinct drop foreign from group having index insert into in key like message modify msgtype not null on or order primary references reset restrict select set table unique update validate view where"
-},contains:[{className:"keyword",begin:/^\s*(proc [\w\d_]+|data|run|quit)[\s;]/
-},{className:"variable",begin:/&[a-zA-Z_&][a-zA-Z0-9_]*\.?/},{
-className:"emphasis",begin:/^\s*datalines|cards.*;/,end:/^\s*;\s*$/},{
-className:"built_in",
-begin:"%(bquote|nrbquote|cmpres|qcmpres|compstor|datatyp|display|do|else|end|eval|global|goto|if|index|input|keydef|label|left|length|let|local|lowcase|macro|mend|nrbquote|nrquote|nrstr|put|qcmpres|qleft|qlowcase|qscan|qsubstr|qsysfunc|qtrim|quote|qupcase|scan|str|substr|superq|syscall|sysevalf|sysexec|sysfunc|sysget|syslput|sysprod|sysrc|sysrput|then|to|trim|unquote|until|upcase|verify|while|window)"
-},{className:"name",begin:/%[a-zA-Z_][a-zA-Z_0-9]*/},{className:"meta",
-begin:"[^%](abs|addr|airy|arcos|arsin|atan|attrc|attrn|band|betainv|blshift|bnot|bor|brshift|bxor|byte|cdf|ceil|cexist|cinv|close|cnonct|collate|compbl|compound|compress|cos|cosh|css|curobs|cv|daccdb|daccdbsl|daccsl|daccsyd|dacctab|dairy|date|datejul|datepart|datetime|day|dclose|depdb|depdbsl|depdbsl|depsl|depsl|depsyd|depsyd|deptab|deptab|dequote|dhms|dif|digamma|dim|dinfo|dnum|dopen|doptname|doptnum|dread|dropnote|dsname|erf|erfc|exist|exp|fappend|fclose|fcol|fdelete|fetch|fetchobs|fexist|fget|fileexist|filename|fileref|finfo|finv|fipname|fipnamel|fipstate|floor|fnonct|fnote|fopen|foptname|foptnum|fpoint|fpos|fput|fread|frewind|frlen|fsep|fuzz|fwrite|gaminv|gamma|getoption|getvarc|getvarn|hbound|hms|hosthelp|hour|ibessel|index|indexc|indexw|input|inputc|inputn|int|intck|intnx|intrr|irr|jbessel|juldate|kurtosis|lag|lbound|left|length|lgamma|libname|libref|log|log10|log2|logpdf|logpmf|logsdf|lowcase|max|mdy|mean|min|minute|mod|month|mopen|mort|n|netpv|nmiss|normal|note|npv|open|ordinal|pathname|pdf|peek|peekc|pmf|point|poisson|poke|probbeta|probbnml|probchi|probf|probgam|probhypr|probit|probnegb|probnorm|probt|put|putc|putn|qtr|quote|ranbin|rancau|ranexp|rangam|range|rank|rannor|ranpoi|rantbl|rantri|ranuni|repeat|resolve|reverse|rewind|right|round|saving|scan|sdf|second|sign|sin|sinh|skewness|soundex|spedis|sqrt|std|stderr|stfips|stname|stnamel|substr|sum|symget|sysget|sysmsg|sysprod|sysrc|system|tan|tanh|time|timepart|tinv|tnonct|today|translate|tranwrd|trigamma|trim|trimn|trunc|uniform|upcase|uss|var|varfmt|varinfmt|varlabel|varlen|varname|varnum|varray|varrayx|vartype|verify|vformat|vformatd|vformatdx|vformatn|vformatnx|vformatw|vformatwx|vformatx|vinarray|vinarrayx|vinformat|vinformatd|vinformatdx|vinformatn|vinformatnx|vinformatw|vinformatwx|vinformatx|vlabel|vlabelx|vlength|vlengthx|vname|vnamex|vtype|vtypex|weekday|year|yyq|zipfips|zipname|zipnamel|zipstate)[(]"
-},{className:"string",variants:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]
-},e.COMMENT("\\*",";"),e.C_BLOCK_COMMENT_MODE]})})());
-hljs.registerLanguage("scala",(()=>{"use strict";return e=>{const n={
-className:"subst",variants:[{begin:"\\$[A-Za-z0-9_]+"},{begin:/\$\{/,end:/\}/}]
-},a={className:"string",variants:[{begin:'"""',end:'"""'},{begin:'"',end:'"',
-illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{begin:'[a-z]+"',end:'"',
-illegal:"\\n",contains:[e.BACKSLASH_ESCAPE,n]},{className:"string",
-begin:'[a-z]+"""',end:'"""',contains:[n],relevance:10}]},s={className:"type",
-begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},t={className:"title",
-begin:/[^0-9\n\t "'(),.`{}\[\]:;][^\n\t "'(),.`{}\[\]:;]+|[^0-9\n\t "'(),.`{}\[\]:;=]/,
-relevance:0},i={className:"class",beginKeywords:"class object trait type",
-end:/[:={\[\n;]/,excludeEnd:!0,
-contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-beginKeywords:"extends with",relevance:10},{begin:/\[/,end:/\]/,excludeBegin:!0,
-excludeEnd:!0,relevance:0,contains:[s]},{className:"params",begin:/\(/,end:/\)/,
-excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[s]},t]},l={
-className:"function",beginKeywords:"def",end:/[:={\[(\n;]/,excludeEnd:!0,
-contains:[t]};return{name:"Scala",keywords:{literal:"true false null",
-keyword:"type yield lazy override def with val var sealed abstract private trait object if forSome for while throw finally protected extends import final return else break new catch super class case package default try this match continue throws implicit"
-},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,{className:"symbol",
-begin:"'\\w[\\w\\d_]*(?!')"},s,l,i,e.C_NUMBER_MODE,{className:"meta",
-begin:"@[A-Za-z]+"}]}}})());
-hljs.registerLanguage("scheme",(()=>{"use strict";return e=>{
-const t="[^\\(\\)\\[\\]\\{\\}\",'`;#|\\\\\\s]+",n={$pattern:t,
-"builtin-name":"case-lambda call/cc class define-class exit-handler field import inherit init-field interface let*-values let-values let/ec mixin opt-lambda override protect provide public rename require require-for-syntax syntax syntax-case syntax-error unit/sig unless when with-syntax and begin call-with-current-continuation call-with-input-file call-with-output-file case cond define define-syntax delay do dynamic-wind else for-each if lambda let let* let-syntax letrec letrec-syntax map or syntax-rules ' * + , ,@ - ... / ; < <= = => > >= ` abs acos angle append apply asin assoc assq assv atan boolean? caar cadr call-with-input-file call-with-output-file call-with-values car cdddar cddddr cdr ceiling char->integer char-alphabetic? char-ci<=? char-ci<? char-ci=? char-ci>=? char-ci>? char-downcase char-lower-case? char-numeric? char-ready? char-upcase char-upper-case? char-whitespace? char<=? char<? char=? char>=? char>? char? close-input-port close-output-port complex? cons cos current-input-port current-output-port denominator display eof-object? eq? equal? eqv? eval even? exact->inexact exact? exp expt floor force gcd imag-part inexact->exact inexact? input-port? integer->char integer? interaction-environment lcm length list list->string list->vector list-ref list-tail list? load log magnitude make-polar make-rectangular make-string make-vector max member memq memv min modulo negative? newline not null-environment null? number->string number? numerator odd? open-input-file open-output-file output-port? pair? peek-char port? positive? procedure? quasiquote quote quotient rational? rationalize read read-char real-part real? remainder reverse round scheme-report-environment set! set-car! set-cdr! sin sqrt string string->list string->number string->symbol string-append string-ci<=? string-ci<? string-ci=? string-ci>=? string-ci>? string-copy string-fill! string-length string-ref string-set! string<=? string<? string=? string>=? string>? string? substring symbol->string symbol? tan transcript-off transcript-on truncate values vector vector->list vector-fill! vector-length vector-ref vector-set! with-input-from-file with-output-to-file write write-char zero?"
-},r={className:"literal",begin:"(#t|#f|#\\\\"+t+"|#\\\\.)"},a={
-className:"number",variants:[{begin:"(-|\\+)?\\d+([./]\\d+)?",relevance:0},{
-begin:"(-|\\+)?\\d+([./]\\d+)?[+\\-](-|\\+)?\\d+([./]\\d+)?i",relevance:0},{
-begin:"#b[0-1]+(/[0-1]+)?"},{begin:"#o[0-7]+(/[0-7]+)?"},{
-begin:"#x[0-9a-f]+(/[0-9a-f]+)?"}]},i=e.QUOTE_STRING_MODE,c=[e.COMMENT(";","$",{
-relevance:0}),e.COMMENT("#\\|","\\|#")],s={begin:t,relevance:0},l={
-className:"symbol",begin:"'"+t},o={endsWithParent:!0,relevance:0},g={variants:[{
-begin:/'/},{begin:"`"}],contains:[{begin:"\\(",end:"\\)",
-contains:["self",r,i,a,s,l]}]},u={className:"name",relevance:0,begin:t,
-keywords:n},d={variants:[{begin:"\\(",end:"\\)"},{begin:"\\[",end:"\\]"}],
-contains:[{begin:/lambda/,endsWithParent:!0,returnBegin:!0,contains:[u,{
-endsParent:!0,variants:[{begin:/\(/,end:/\)/},{begin:/\[/,end:/\]/}],
-contains:[s]}]},u,o]};return o.contains=[r,a,i,s,l,g,d].concat(c),{
-name:"Scheme",illegal:/\S/,contains:[e.SHEBANG(),a,i,l,g,d].concat(c)}}})());
-hljs.registerLanguage("scilab",(()=>{"use strict";return e=>{
-const n=[e.C_NUMBER_MODE,{className:"string",begin:"'|\"",end:"'|\"",
-contains:[e.BACKSLASH_ESCAPE,{begin:"''"}]}];return{name:"Scilab",
-aliases:["sci"],keywords:{$pattern:/%?\w+/,
-keyword:"abort break case clear catch continue do elseif else endfunction end for function global if pause return resume select try then while",
-literal:"%f %F %t %T %pi %eps %inf %nan %e %i %z %s",
-built_in:"abs and acos asin atan ceil cd chdir clearglobal cosh cos cumprod deff disp error exec execstr exists exp eye gettext floor fprintf fread fsolve imag isdef isempty isinfisnan isvector lasterror length load linspace list listfiles log10 log2 log max min msprintf mclose mopen ones or pathconvert poly printf prod pwd rand real round sinh sin size gsort sprintf sqrt strcat strcmps tring sum system tanh tan type typename warning zeros matrix"
-},illegal:'("|#|/\\*|\\s+/\\w+)',contains:[{className:"function",
-beginKeywords:"function",end:"$",contains:[e.UNDERSCORE_TITLE_MODE,{
-className:"params",begin:"\\(",end:"\\)"}]},{
-begin:"[a-zA-Z_][a-zA-Z_0-9]*[\\.']+",relevance:0},{begin:"\\[",
-end:"\\][\\.']*",relevance:0,contains:n},e.COMMENT("//","$")].concat(n)}}})());
-hljs.registerLanguage("scss",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],i=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],o=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
-;return a=>{const n=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
-HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
-ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
-illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}
-}))(a),l=o,s=i,d="@[a-z-]+",c={className:"variable",
-begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b"};return{name:"SCSS",case_insensitive:!0,
-illegal:"[=/|']",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{
-className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{
-className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0
-},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag",
-begin:"\\b("+e.join("|")+")\\b",relevance:0},{className:"selector-pseudo",
-begin:":("+s.join("|")+")"},{className:"selector-pseudo",
-begin:"::("+l.join("|")+")"},c,{begin:/\(/,end:/\)/,contains:[a.CSS_NUMBER_MODE]
-},{className:"attribute",begin:"\\b("+r.join("|")+")\\b"},{
-begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b"
-},{begin:":",end:";",
-contains:[c,n.HEXCOLOR,a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,n.IMPORTANT]
-},{begin:"@(page|font-face)",lexemes:d,keywords:"@page @font-face"},{begin:"@",
-end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/,
-keyword:"and or not only",attribute:t.join(" ")},contains:[{begin:d,
-className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute"
-},c,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,n.HEXCOLOR,a.CSS_NUMBER_MODE]}]}}
-})());
-hljs.registerLanguage("shell",(()=>{"use strict";return s=>({
-name:"Shell Session",aliases:["console"],contains:[{className:"meta",
-begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#]/,starts:{end:/[^\\](?=\s*$)/,
-subLanguage:"bash"}}]})})());
-hljs.registerLanguage("smali",(()=>{"use strict";return e=>{
-const n=["add","and","cmp","cmpg","cmpl","const","div","double","float","goto","if","int","long","move","mul","neg","new","nop","not","or","rem","return","shl","shr","sput","sub","throw","ushr","xor"]
-;return{name:"Smali",contains:[{className:"string",begin:'"',end:'"',relevance:0
-},e.COMMENT("#","$",{relevance:0}),{className:"keyword",variants:[{
-begin:"\\s*\\.end\\s[a-zA-Z0-9]*"},{begin:"^[ ]*\\.[a-zA-Z]*",relevance:0},{
-begin:"\\s:[a-zA-Z_0-9]*",relevance:0},{
-begin:"\\s(transient|constructor|abstract|final|synthetic|public|private|protected|static|bridge|system)"
-}]},{className:"built_in",variants:[{begin:"\\s("+n.join("|")+")\\s"},{
-begin:"\\s("+n.join("|")+")((-|/)[a-zA-Z0-9]+)+\\s",relevance:10},{
-begin:"\\s(aget|aput|array|check|execute|fill|filled|goto/16|goto/32|iget|instance|invoke|iput|monitor|packed|sget|sparse)((-|/)[a-zA-Z0-9]+)*\\s",
-relevance:10}]},{className:"class",begin:"L[^(;:\n]*;",relevance:0},{
-begin:"[vp][0-9]+"}]}}})());
-hljs.registerLanguage("smalltalk",(()=>{"use strict";return e=>{
-const n="[a-z][a-zA-Z0-9_]*",a={className:"string",begin:"\\$.{1}"},s={
-className:"symbol",begin:"#"+e.UNDERSCORE_IDENT_RE};return{name:"Smalltalk",
-aliases:["st"],keywords:"self super nil true false thisContext",
-contains:[e.COMMENT('"','"'),e.APOS_STRING_MODE,{className:"type",
-begin:"\\b[A-Z][A-Za-z0-9_]*",relevance:0},{begin:n+":",relevance:0
-},e.C_NUMBER_MODE,s,a,{begin:"\\|[ ]*"+n+"([ ]+"+n+")*[ ]*\\|",returnBegin:!0,
-end:/\|/,illegal:/\S/,contains:[{begin:"(\\|[ ]*)?"+n}]},{begin:"#\\(",
-end:"\\)",contains:[e.APOS_STRING_MODE,a,e.C_NUMBER_MODE,s]}]}}})());
-hljs.registerLanguage("sml",(()=>{"use strict";return e=>({
-name:"SML (Standard ML)",aliases:["ml"],keywords:{$pattern:"[a-z_]\\w*!?",
-keyword:"abstype and andalso as case datatype do else end eqtype exception fn fun functor handle if in include infix infixr let local nonfix of op open orelse raise rec sharing sig signature struct structure then type val with withtype where while",
-built_in:"array bool char exn int list option order real ref string substring vector unit word",
-literal:"true false NONE SOME LESS EQUAL GREATER nil"},illegal:/\/\/|>>/,
-contains:[{className:"literal",begin:/\[(\|\|)?\]|\(\)/,relevance:0
-},e.COMMENT("\\(\\*","\\*\\)",{contains:["self"]}),{className:"symbol",
-begin:"'[A-Za-z_](?!')[\\w']*"},{className:"type",begin:"`[A-Z][\\w']*"},{
-className:"type",begin:"\\b[A-Z][\\w']*",relevance:0},{
-begin:"[a-z_]\\w*'[\\w']*"},e.inherit(e.APOS_STRING_MODE,{className:"string",
-relevance:0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{className:"number",
-begin:"\\b(0[xX][a-fA-F0-9_]+[Lln]?|0[oO][0-7_]+[Lln]?|0[bB][01_]+[Lln]?|[0-9][0-9_]*([Lln]|(\\.[0-9_]*)?([eE][-+]?[0-9_]+)?)?)",
-relevance:0},{begin:/[-=]>/}]})})());
-hljs.registerLanguage("soy",(()=>{"use strict";return e=>({
-contains:[e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE,{begin:/\{delpackage/,
-ends:/\}/,relevance:10,keywords:"delpackage"},{begin:/\{namespace/,end:/\}/,
-relevance:10,keywords:"namespace autoescape",contains:[e.QUOTE_STRING_MODE]},{
-className:"template-tag",begin:/\{template/,end:/\{\/template\}/,relevance:10,
-keywords:"alias as autoescape call case default delcall else elseif fallbackmsg foreach if ifempty let msg namespace param print switch template",
-contains:[e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE,e.QUOTE_STRING_MODE,{
-className:"template-variable",relevance:0,begin:/\$[^}\s]+/},{className:"tag",
-begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0
-},{endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",
-begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{
-className:"string",endsParent:!0,variants:[{begin:/"/,end:/"/},{begin:/'/,
-end:/'/},{begin:/[^\s"'=<>`]+/}]}]}]}]}]}]})})());
-hljs.registerLanguage("sqf",(()=>{"use strict";return e=>{const t={
-className:"string",variants:[{begin:'"',end:'"',contains:[{begin:'""',
-relevance:0}]},{begin:"'",end:"'",contains:[{begin:"''",relevance:0}]}]},a={
-className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{
-"meta-keyword":"define undef ifdef ifndef else endif include"},contains:[{
-begin:/\\\n/,relevance:0},e.inherit(t,{className:"meta-string"}),{
-className:"meta-string",begin:/<[^\n>]*>/,end:/$/,illegal:"\\n"
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]};return{name:"SQF",
-case_insensitive:!0,keywords:{
-keyword:"case catch default do else exit exitWith for forEach from if private switch then throw to try waitUntil while with",
-built_in:"abs accTime acos action actionIDs actionKeys actionKeysImages actionKeysNames actionKeysNamesArray actionName actionParams activateAddons activatedAddons activateKey add3DENConnection add3DENEventHandler add3DENLayer addAction addBackpack addBackpackCargo addBackpackCargoGlobal addBackpackGlobal addCamShake addCuratorAddons addCuratorCameraArea addCuratorEditableObjects addCuratorEditingArea addCuratorPoints addEditorObject addEventHandler addForce addGoggles addGroupIcon addHandgunItem addHeadgear addItem addItemCargo addItemCargoGlobal addItemPool addItemToBackpack addItemToUniform addItemToVest addLiveStats addMagazine addMagazineAmmoCargo addMagazineCargo addMagazineCargoGlobal addMagazineGlobal addMagazinePool addMagazines addMagazineTurret addMenu addMenuItem addMissionEventHandler addMPEventHandler addMusicEventHandler addOwnedMine addPlayerScores addPrimaryWeaponItem addPublicVariableEventHandler addRating addResources addScore addScoreSide addSecondaryWeaponItem addSwitchableUnit addTeamMember addToRemainsCollector addTorque addUniform addVehicle addVest addWaypoint addWeapon addWeaponCargo addWeaponCargoGlobal addWeaponGlobal addWeaponItem addWeaponPool addWeaponTurret admin agent agents AGLToASL aimedAtTarget aimPos airDensityRTD airplaneThrottle airportSide AISFinishHeal alive all3DENEntities allAirports allControls allCurators allCutLayers allDead allDeadMen allDisplays allGroups allMapMarkers allMines allMissionObjects allow3DMode allowCrewInImmobile allowCuratorLogicIgnoreAreas allowDamage allowDammage allowFileOperations allowFleeing allowGetIn allowSprint allPlayers allSimpleObjects allSites allTurrets allUnits allUnitsUAV allVariables ammo ammoOnPylon and animate animateBay animateDoor animatePylon animateSource animationNames animationPhase animationSourcePhase animationState append apply armoryPoints arrayIntersect asin ASLToAGL ASLToATL assert assignAsCargo assignAsCargoIndex assignAsCommander assignAsDriver assignAsGunner assignAsTurret assignCurator assignedCargo assignedCommander assignedDriver assignedGunner assignedItems assignedTarget assignedTeam assignedVehicle assignedVehicleRole assignItem assignTeam assignToAirport atan atan2 atg ATLToASL attachedObject attachedObjects attachedTo attachObject attachTo attackEnabled backpack backpackCargo backpackContainer backpackItems backpackMagazines backpackSpaceFor behaviour benchmark binocular boundingBox boundingBoxReal boundingCenter breakOut breakTo briefingName buildingExit buildingPos buttonAction buttonSetAction cadetMode call callExtension camCommand camCommit camCommitPrepared camCommitted camConstuctionSetParams camCreate camDestroy cameraEffect cameraEffectEnableHUD cameraInterest cameraOn cameraView campaignConfigFile camPreload camPreloaded camPrepareBank camPrepareDir camPrepareDive camPrepareFocus camPrepareFov camPrepareFovRange camPreparePos camPrepareRelPos camPrepareTarget camSetBank camSetDir camSetDive camSetFocus camSetFov camSetFovRange camSetPos camSetRelPos camSetTarget camTarget camUseNVG canAdd canAddItemToBackpack canAddItemToUniform canAddItemToVest cancelSimpleTaskDestination canFire canMove canSlingLoad canStand canSuspend canTriggerDynamicSimulation canUnloadInCombat canVehicleCargo captive captiveNum cbChecked cbSetChecked ceil channelEnabled cheatsEnabled checkAIFeature checkVisibility className clearAllItemsFromBackpack clearBackpackCargo clearBackpackCargoGlobal clearGroupIcons clearItemCargo clearItemCargoGlobal clearItemPool clearMagazineCargo clearMagazineCargoGlobal clearMagazinePool clearOverlay clearRadio clearWeaponCargo clearWeaponCargoGlobal clearWeaponPool clientOwner closeDialog closeDisplay closeOverlay collapseObjectTree collect3DENHistory collectiveRTD combatMode commandArtilleryFire commandChat commander commandFire commandFollow commandFSM commandGetOut commandingMenu commandMove commandRadio commandStop commandSuppressiveFire commandTarget commandWatch comment commitOverlay compile compileFinal completedFSM composeText configClasses configFile configHierarchy configName configProperties configSourceAddonList configSourceMod configSourceModList confirmSensorTarget connectTerminalToUAV controlsGroupCtrl copyFromClipboard copyToClipboard copyWaypoints cos count countEnemy countFriendly countSide countType countUnknown create3DENComposition create3DENEntity createAgent createCenter createDialog createDiaryLink createDiaryRecord createDiarySubject createDisplay createGearDialog createGroup createGuardedPoint createLocation createMarker createMarkerLocal createMenu createMine createMissionDisplay createMPCampaignDisplay createSimpleObject createSimpleTask createSite createSoundSource createTask createTeam createTrigger createUnit createVehicle createVehicleCrew createVehicleLocal crew ctAddHeader ctAddRow ctClear ctCurSel ctData ctFindHeaderRows ctFindRowHeader ctHeaderControls ctHeaderCount ctRemoveHeaders ctRemoveRows ctrlActivate ctrlAddEventHandler ctrlAngle ctrlAutoScrollDelay ctrlAutoScrollRewind ctrlAutoScrollSpeed ctrlChecked ctrlClassName ctrlCommit ctrlCommitted ctrlCreate ctrlDelete ctrlEnable ctrlEnabled ctrlFade ctrlHTMLLoaded ctrlIDC ctrlIDD ctrlMapAnimAdd ctrlMapAnimClear ctrlMapAnimCommit ctrlMapAnimDone ctrlMapCursor ctrlMapMouseOver ctrlMapScale ctrlMapScreenToWorld ctrlMapWorldToScreen ctrlModel ctrlModelDirAndUp ctrlModelScale ctrlParent ctrlParentControlsGroup ctrlPosition ctrlRemoveAllEventHandlers ctrlRemoveEventHandler ctrlScale ctrlSetActiveColor ctrlSetAngle ctrlSetAutoScrollDelay ctrlSetAutoScrollRewind ctrlSetAutoScrollSpeed ctrlSetBackgroundColor ctrlSetChecked ctrlSetEventHandler ctrlSetFade ctrlSetFocus ctrlSetFont ctrlSetFontH1 ctrlSetFontH1B ctrlSetFontH2 ctrlSetFontH2B ctrlSetFontH3 ctrlSetFontH3B ctrlSetFontH4 ctrlSetFontH4B ctrlSetFontH5 ctrlSetFontH5B ctrlSetFontH6 ctrlSetFontH6B ctrlSetFontHeight ctrlSetFontHeightH1 ctrlSetFontHeightH2 ctrlSetFontHeightH3 ctrlSetFontHeightH4 ctrlSetFontHeightH5 ctrlSetFontHeightH6 ctrlSetFontHeightSecondary ctrlSetFontP ctrlSetFontPB ctrlSetFontSecondary ctrlSetForegroundColor ctrlSetModel ctrlSetModelDirAndUp ctrlSetModelScale ctrlSetPixelPrecision ctrlSetPosition ctrlSetScale ctrlSetStructuredText ctrlSetText ctrlSetTextColor ctrlSetTooltip ctrlSetTooltipColorBox ctrlSetTooltipColorShade ctrlSetTooltipColorText ctrlShow ctrlShown ctrlText ctrlTextHeight ctrlTextWidth ctrlType ctrlVisible ctRowControls ctRowCount ctSetCurSel ctSetData ctSetHeaderTemplate ctSetRowTemplate ctSetValue ctValue curatorAddons curatorCamera curatorCameraArea curatorCameraAreaCeiling curatorCoef curatorEditableObjects curatorEditingArea curatorEditingAreaType curatorMouseOver curatorPoints curatorRegisteredObjects curatorSelected curatorWaypointCost current3DENOperation currentChannel currentCommand currentMagazine currentMagazineDetail currentMagazineDetailTurret currentMagazineTurret currentMuzzle currentNamespace currentTask currentTasks currentThrowable currentVisionMode currentWaypoint currentWeapon currentWeaponMode currentWeaponTurret currentZeroing cursorObject cursorTarget customChat customRadio cutFadeOut cutObj cutRsc cutText damage date dateToNumber daytime deActivateKey debriefingText debugFSM debugLog deg delete3DENEntities deleteAt deleteCenter deleteCollection deleteEditorObject deleteGroup deleteGroupWhenEmpty deleteIdentity deleteLocation deleteMarker deleteMarkerLocal deleteRange deleteResources deleteSite deleteStatus deleteTeam deleteVehicle deleteVehicleCrew deleteWaypoint detach detectedMines diag_activeMissionFSMs diag_activeScripts diag_activeSQFScripts diag_activeSQSScripts diag_captureFrame diag_captureFrameToFile diag_captureSlowFrame diag_codePerformance diag_drawMode diag_enable diag_enabled diag_fps diag_fpsMin diag_frameNo diag_lightNewLoad diag_list diag_log diag_logSlowFrame diag_mergeConfigFile diag_recordTurretLimits diag_setLightNew diag_tickTime diag_toggle dialog diarySubjectExists didJIP didJIPOwner difficulty difficultyEnabled difficultyEnabledRTD difficultyOption direction directSay disableAI disableCollisionWith disableConversation disableDebriefingStats disableMapIndicators disableNVGEquipment disableRemoteSensors disableSerialization disableTIEquipment disableUAVConnectability disableUserInput displayAddEventHandler displayCtrl displayParent displayRemoveAllEventHandlers displayRemoveEventHandler displaySetEventHandler dissolveTeam distance distance2D distanceSqr distributionRegion do3DENAction doArtilleryFire doFire doFollow doFSM doGetOut doMove doorPhase doStop doSuppressiveFire doTarget doWatch drawArrow drawEllipse drawIcon drawIcon3D drawLine drawLine3D drawLink drawLocation drawPolygon drawRectangle drawTriangle driver drop dynamicSimulationDistance dynamicSimulationDistanceCoef dynamicSimulationEnabled dynamicSimulationSystemEnabled echo edit3DENMissionAttributes editObject editorSetEventHandler effectiveCommander emptyPositions enableAI enableAIFeature enableAimPrecision enableAttack enableAudioFeature enableAutoStartUpRTD enableAutoTrimRTD enableCamShake enableCaustics enableChannel enableCollisionWith enableCopilot enableDebriefingStats enableDiagLegend enableDynamicSimulation enableDynamicSimulationSystem enableEndDialog enableEngineArtillery enableEnvironment enableFatigue enableGunLights enableInfoPanelComponent enableIRLasers enableMimics enablePersonTurret enableRadio enableReload enableRopeAttach enableSatNormalOnDetail enableSaving enableSentences enableSimulation enableSimulationGlobal enableStamina enableTeamSwitch enableTraffic enableUAVConnectability enableUAVWaypoints enableVehicleCargo enableVehicleSensor enableWeaponDisassembly endLoadingScreen endMission engineOn enginesIsOnRTD enginesRpmRTD enginesTorqueRTD entities environmentEnabled estimatedEndServerTime estimatedTimeLeft evalObjectArgument everyBackpack everyContainer exec execEditorScript execFSM execVM exp expectedDestination exportJIPMessages eyeDirection eyePos face faction fadeMusic fadeRadio fadeSound fadeSpeech failMission fillWeaponsFromPool find findCover findDisplay findEditorObject findEmptyPosition findEmptyPositionReady findIf findNearestEnemy finishMissionInit finite fire fireAtTarget firstBackpack flag flagAnimationPhase flagOwner flagSide flagTexture fleeing floor flyInHeight flyInHeightASL fog fogForecast fogParams forceAddUniform forcedMap forceEnd forceFlagTexture forceFollowRoad forceMap forceRespawn forceSpeed forceWalk forceWeaponFire forceWeatherChange forEachMember forEachMemberAgent forEachMemberTeam forgetTarget format formation formationDirection formationLeader formationMembers formationPosition formationTask formatText formLeader freeLook fromEditor fuel fullCrew gearIDCAmmoCount gearSlotAmmoCount gearSlotData get3DENActionState get3DENAttribute get3DENCamera get3DENConnections get3DENEntity get3DENEntityID get3DENGrid get3DENIconsVisible get3DENLayerEntities get3DENLinesVisible get3DENMissionAttribute get3DENMouseOver get3DENSelected getAimingCoef getAllEnvSoundControllers getAllHitPointsDamage getAllOwnedMines getAllSoundControllers getAmmoCargo getAnimAimPrecision getAnimSpeedCoef getArray getArtilleryAmmo getArtilleryComputerSettings getArtilleryETA getAssignedCuratorLogic getAssignedCuratorUnit getBackpackCargo getBleedingRemaining getBurningValue getCameraViewDirection getCargoIndex getCenterOfMass getClientState getClientStateNumber getCompatiblePylonMagazines getConnectedUAV getContainerMaxLoad getCursorObjectParams getCustomAimCoef getDammage getDescription getDir getDirVisual getDLCAssetsUsage getDLCAssetsUsageByName getDLCs getEditorCamera getEditorMode getEditorObjectScope getElevationOffset getEnvSoundController getFatigue getForcedFlagTexture getFriend getFSMVariable getFuelCargo getGroupIcon getGroupIconParams getGroupIcons getHideFrom getHit getHitIndex getHitPointDamage getItemCargo getMagazineCargo getMarkerColor getMarkerPos getMarkerSize getMarkerType getMass getMissionConfig getMissionConfigValue getMissionDLCs getMissionLayerEntities getModelInfo getMousePosition getMusicPlayedTime getNumber getObjectArgument getObjectChildren getObjectDLC getObjectMaterials getObjectProxy getObjectTextures getObjectType getObjectViewDistance getOxygenRemaining getPersonUsedDLCs getPilotCameraDirection getPilotCameraPosition getPilotCameraRotation getPilotCameraTarget getPlateNumber getPlayerChannel getPlayerScores getPlayerUID getPos getPosASL getPosASLVisual getPosASLW getPosATL getPosATLVisual getPosVisual getPosWorld getPylonMagazines getRelDir getRelPos getRemoteSensorsDisabled getRepairCargo getResolution getShadowDistance getShotParents getSlingLoad getSoundController getSoundControllerResult getSpeed getStamina getStatValue getSuppression getTerrainGrid getTerrainHeightASL getText getTotalDLCUsageTime getUnitLoadout getUnitTrait getUserMFDText getUserMFDvalue getVariable getVehicleCargo getWeaponCargo getWeaponSway getWingsOrientationRTD getWingsPositionRTD getWPPos glanceAt globalChat globalRadio goggles goto group groupChat groupFromNetId groupIconSelectable groupIconsVisible groupId groupOwner groupRadio groupSelectedUnits groupSelectUnit gunner gusts halt handgunItems handgunMagazine handgunWeapon handsHit hasInterface hasPilotCamera hasWeapon hcAllGroups hcGroupParams hcLeader hcRemoveAllGroups hcRemoveGroup hcSelected hcSelectGroup hcSetGroup hcShowBar hcShownBar headgear hideBody hideObject hideObjectGlobal hideSelection hint hintC hintCadet hintSilent hmd hostMission htmlLoad HUDMovementLevels humidity image importAllGroups importance in inArea inAreaArray incapacitatedState inflame inflamed infoPanel infoPanelComponentEnabled infoPanelComponents infoPanels inGameUISetEventHandler inheritsFrom initAmbientLife inPolygon inputAction inRangeOfArtillery insertEditorObject intersect is3DEN is3DENMultiplayer isAbleToBreathe isAgent isArray isAutoHoverOn isAutonomous isAutotest isBleeding isBurning isClass isCollisionLightOn isCopilotEnabled isDamageAllowed isDedicated isDLCAvailable isEngineOn isEqualTo isEqualType isEqualTypeAll isEqualTypeAny isEqualTypeArray isEqualTypeParams isFilePatchingEnabled isFlashlightOn isFlatEmpty isForcedWalk isFormationLeader isGroupDeletedWhenEmpty isHidden isInRemainsCollector isInstructorFigureEnabled isIRLaserOn isKeyActive isKindOf isLaserOn isLightOn isLocalized isManualFire isMarkedForCollection isMultiplayer isMultiplayerSolo isNil isNull isNumber isObjectHidden isObjectRTD isOnRoad isPipEnabled isPlayer isRealTime isRemoteExecuted isRemoteExecutedJIP isServer isShowing3DIcons isSimpleObject isSprintAllowed isStaminaEnabled isSteamMission isStreamFriendlyUIEnabled isText isTouchingGround isTurnedOut isTutHintsEnabled isUAVConnectable isUAVConnected isUIContext isUniformAllowed isVehicleCargo isVehicleRadarOn isVehicleSensorEnabled isWalking isWeaponDeployed isWeaponRested itemCargo items itemsWithMagazines join joinAs joinAsSilent joinSilent joinString kbAddDatabase kbAddDatabaseTargets kbAddTopic kbHasTopic kbReact kbRemoveTopic kbTell kbWasSaid keyImage keyName knowsAbout land landAt landResult language laserTarget lbAdd lbClear lbColor lbColorRight lbCurSel lbData lbDelete lbIsSelected lbPicture lbPictureRight lbSelection lbSetColor lbSetColorRight lbSetCurSel lbSetData lbSetPicture lbSetPictureColor lbSetPictureColorDisabled lbSetPictureColorSelected lbSetPictureRight lbSetPictureRightColor lbSetPictureRightColorDisabled lbSetPictureRightColorSelected lbSetSelectColor lbSetSelectColorRight lbSetSelected lbSetText lbSetTextRight lbSetTooltip lbSetValue lbSize lbSort lbSortByValue lbText lbTextRight lbValue leader leaderboardDeInit leaderboardGetRows leaderboardInit leaderboardRequestRowsFriends leaderboardsRequestUploadScore leaderboardsRequestUploadScoreKeepBest leaderboardState leaveVehicle libraryCredits libraryDisclaimers lifeState lightAttachObject lightDetachObject lightIsOn lightnings limitSpeed linearConversion lineIntersects lineIntersectsObjs lineIntersectsSurfaces lineIntersectsWith linkItem list listObjects listRemoteTargets listVehicleSensors ln lnbAddArray lnbAddColumn lnbAddRow lnbClear lnbColor lnbCurSelRow lnbData lnbDeleteColumn lnbDeleteRow lnbGetColumnsPosition lnbPicture lnbSetColor lnbSetColumnsPos lnbSetCurSelRow lnbSetData lnbSetPicture lnbSetText lnbSetValue lnbSize lnbSort lnbSortByValue lnbText lnbValue load loadAbs loadBackpack loadFile loadGame loadIdentity loadMagazine loadOverlay loadStatus loadUniform loadVest local localize locationPosition lock lockCameraTo lockCargo lockDriver locked lockedCargo lockedDriver lockedTurret lockIdentity lockTurret lockWP log logEntities logNetwork logNetworkTerminate lookAt lookAtPos magazineCargo magazines magazinesAllTurrets magazinesAmmo magazinesAmmoCargo magazinesAmmoFull magazinesDetail magazinesDetailBackpack magazinesDetailUniform magazinesDetailVest magazinesTurret magazineTurretAmmo mapAnimAdd mapAnimClear mapAnimCommit mapAnimDone mapCenterOnCamera mapGridPosition markAsFinishedOnSteam markerAlpha markerBrush markerColor markerDir markerPos markerShape markerSize markerText markerType max members menuAction menuAdd menuChecked menuClear menuCollapse menuData menuDelete menuEnable menuEnabled menuExpand menuHover menuPicture menuSetAction menuSetCheck menuSetData menuSetPicture menuSetValue menuShortcut menuShortcutText menuSize menuSort menuText menuURL menuValue min mineActive mineDetectedBy missionConfigFile missionDifficulty missionName missionNamespace missionStart missionVersion mod modelToWorld modelToWorldVisual modelToWorldVisualWorld modelToWorldWorld modParams moonIntensity moonPhase morale move move3DENCamera moveInAny moveInCargo moveInCommander moveInDriver moveInGunner moveInTurret moveObjectToEnd moveOut moveTime moveTo moveToCompleted moveToFailed musicVolume name nameSound nearEntities nearestBuilding nearestLocation nearestLocations nearestLocationWithDubbing nearestObject nearestObjects nearestTerrainObjects nearObjects nearObjectsReady nearRoads nearSupplies nearTargets needReload netId netObjNull newOverlay nextMenuItemIndex nextWeatherChange nMenuItems not numberOfEnginesRTD numberToDate objectCurators objectFromNetId objectParent objStatus onBriefingGroup onBriefingNotes onBriefingPlan onBriefingTeamSwitch onCommandModeChanged onDoubleClick onEachFrame onGroupIconClick onGroupIconOverEnter onGroupIconOverLeave onHCGroupSelectionChanged onMapSingleClick onPlayerConnected onPlayerDisconnected onPreloadFinished onPreloadStarted onShowNewObject onTeamSwitch openCuratorInterface openDLCPage openMap openSteamApp openYoutubeVideo or orderGetIn overcast overcastForecast owner param params parseNumber parseSimpleArray parseText parsingNamespace particlesQuality pickWeaponPool pitch pixelGrid pixelGridBase pixelGridNoUIScale pixelH pixelW playableSlotsNumber playableUnits playAction playActionNow player playerRespawnTime playerSide playersNumber playGesture playMission playMove playMoveNow playMusic playScriptedMission playSound playSound3D position positionCameraToWorld posScreenToWorld posWorldToScreen ppEffectAdjust ppEffectCommit ppEffectCommitted ppEffectCreate ppEffectDestroy ppEffectEnable ppEffectEnabled ppEffectForceInNVG precision preloadCamera preloadObject preloadSound preloadTitleObj preloadTitleRsc preprocessFile preprocessFileLineNumbers primaryWeapon primaryWeaponItems primaryWeaponMagazine priority processDiaryLink productVersion profileName profileNamespace profileNameSteam progressLoadingScreen progressPosition progressSetPosition publicVariable publicVariableClient publicVariableServer pushBack pushBackUnique putWeaponPool queryItemsPool queryMagazinePool queryWeaponPool rad radioChannelAdd radioChannelCreate radioChannelRemove radioChannelSetCallSign radioChannelSetLabel radioVolume rain rainbow random rank rankId rating rectangular registeredTasks registerTask reload reloadEnabled remoteControl remoteExec remoteExecCall remoteExecutedOwner remove3DENConnection remove3DENEventHandler remove3DENLayer removeAction removeAll3DENEventHandlers removeAllActions removeAllAssignedItems removeAllContainers removeAllCuratorAddons removeAllCuratorCameraAreas removeAllCuratorEditingAreas removeAllEventHandlers removeAllHandgunItems removeAllItems removeAllItemsWithMagazines removeAllMissionEventHandlers removeAllMPEventHandlers removeAllMusicEventHandlers removeAllOwnedMines removeAllPrimaryWeaponItems removeAllWeapons removeBackpack removeBackpackGlobal removeCuratorAddons removeCuratorCameraArea removeCuratorEditableObjects removeCuratorEditingArea removeDrawIcon removeDrawLinks removeEventHandler removeFromRemainsCollector removeGoggles removeGroupIcon removeHandgunItem removeHeadgear removeItem removeItemFromBackpack removeItemFromUniform removeItemFromVest removeItems removeMagazine removeMagazineGlobal removeMagazines removeMagazinesTurret removeMagazineTurret removeMenuItem removeMissionEventHandler removeMPEventHandler removeMusicEventHandler removeOwnedMine removePrimaryWeaponItem removeSecondaryWeaponItem removeSimpleTask removeSwitchableUnit removeTeamMember removeUniform removeVest removeWeapon removeWeaponAttachmentCargo removeWeaponCargo removeWeaponGlobal removeWeaponTurret reportRemoteTarget requiredVersion resetCamShake resetSubgroupDirection resize resources respawnVehicle restartEditorCamera reveal revealMine reverse reversedMouseY roadAt roadsConnectedTo roleDescription ropeAttachedObjects ropeAttachedTo ropeAttachEnabled ropeAttachTo ropeCreate ropeCut ropeDestroy ropeDetach ropeEndPosition ropeLength ropes ropeUnwind ropeUnwound rotorsForcesRTD rotorsRpmRTD round runInitScript safeZoneH safeZoneW safeZoneWAbs safeZoneX safeZoneXAbs safeZoneY save3DENInventory saveGame saveIdentity saveJoysticks saveOverlay saveProfileNamespace saveStatus saveVar savingEnabled say say2D say3D scopeName score scoreSide screenshot screenToWorld scriptDone scriptName scudState secondaryWeapon secondaryWeaponItems secondaryWeaponMagazine select selectBestPlaces selectDiarySubject selectedEditorObjects selectEditorObject selectionNames selectionPosition selectLeader selectMax selectMin selectNoPlayer selectPlayer selectRandom selectRandomWeighted selectWeapon selectWeaponTurret sendAUMessage sendSimpleCommand sendTask sendTaskResult sendUDPMessage serverCommand serverCommandAvailable serverCommandExecutable serverName serverTime set set3DENAttribute set3DENAttributes set3DENGrid set3DENIconsVisible set3DENLayer set3DENLinesVisible set3DENLogicType set3DENMissionAttribute set3DENMissionAttributes set3DENModelsVisible set3DENObjectType set3DENSelected setAccTime setActualCollectiveRTD setAirplaneThrottle setAirportSide setAmmo setAmmoCargo setAmmoOnPylon setAnimSpeedCoef setAperture setApertureNew setArmoryPoints setAttributes setAutonomous setBehaviour setBleedingRemaining setBrakesRTD setCameraInterest setCamShakeDefParams setCamShakeParams setCamUseTI setCaptive setCenterOfMass setCollisionLight setCombatMode setCompassOscillation setConvoySeparation setCuratorCameraAreaCeiling setCuratorCoef setCuratorEditingAreaType setCuratorWaypointCost setCurrentChannel setCurrentTask setCurrentWaypoint setCustomAimCoef setCustomWeightRTD setDamage setDammage setDate setDebriefingText setDefaultCamera setDestination setDetailMapBlendPars setDir setDirection setDrawIcon setDriveOnPath setDropInterval setDynamicSimulationDistance setDynamicSimulationDistanceCoef setEditorMode setEditorObjectScope setEffectCondition setEngineRPMRTD setFace setFaceAnimation setFatigue setFeatureType setFlagAnimationPhase setFlagOwner setFlagSide setFlagTexture setFog setFormation setFormationTask setFormDir setFriend setFromEditor setFSMVariable setFuel setFuelCargo setGroupIcon setGroupIconParams setGroupIconsSelectable setGroupIconsVisible setGroupId setGroupIdGlobal setGroupOwner setGusts setHideBehind setHit setHitIndex setHitPointDamage setHorizonParallaxCoef setHUDMovementLevels setIdentity setImportance setInfoPanel setLeader setLightAmbient setLightAttenuation setLightBrightness setLightColor setLightDayLight setLightFlareMaxDistance setLightFlareSize setLightIntensity setLightnings setLightUseFlare setLocalWindParams setMagazineTurretAmmo setMarkerAlpha setMarkerAlphaLocal setMarkerBrush setMarkerBrushLocal setMarkerColor setMarkerColorLocal setMarkerDir setMarkerDirLocal setMarkerPos setMarkerPosLocal setMarkerShape setMarkerShapeLocal setMarkerSize setMarkerSizeLocal setMarkerText setMarkerTextLocal setMarkerType setMarkerTypeLocal setMass setMimic setMousePosition setMusicEffect setMusicEventHandler setName setNameSound setObjectArguments setObjectMaterial setObjectMaterialGlobal setObjectProxy setObjectTexture setObjectTextureGlobal setObjectViewDistance setOvercast setOwner setOxygenRemaining setParticleCircle setParticleClass setParticleFire setParticleParams setParticleRandom setPilotCameraDirection setPilotCameraRotation setPilotCameraTarget setPilotLight setPiPEffect setPitch setPlateNumber setPlayable setPlayerRespawnTime setPos setPosASL setPosASL2 setPosASLW setPosATL setPosition setPosWorld setPylonLoadOut setPylonsPriority setRadioMsg setRain setRainbow setRandomLip setRank setRectangular setRepairCargo setRotorBrakeRTD setShadowDistance setShotParents setSide setSimpleTaskAlwaysVisible setSimpleTaskCustomData setSimpleTaskDescription setSimpleTaskDestination setSimpleTaskTarget setSimpleTaskType setSimulWeatherLayers setSize setSkill setSlingLoad setSoundEffect setSpeaker setSpeech setSpeedMode setStamina setStaminaScheme setStatValue setSuppression setSystemOfUnits setTargetAge setTaskMarkerOffset setTaskResult setTaskState setTerrainGrid setText setTimeMultiplier setTitleEffect setTrafficDensity setTrafficDistance setTrafficGap setTrafficSpeed setTriggerActivation setTriggerArea setTriggerStatements setTriggerText setTriggerTimeout setTriggerType setType setUnconscious setUnitAbility setUnitLoadout setUnitPos setUnitPosWeak setUnitRank setUnitRecoilCoefficient setUnitTrait setUnloadInCombat setUserActionText setUserMFDText setUserMFDvalue setVariable setVectorDir setVectorDirAndUp setVectorUp setVehicleAmmo setVehicleAmmoDef setVehicleArmor setVehicleCargo setVehicleId setVehicleLock setVehiclePosition setVehicleRadar setVehicleReceiveRemoteTargets setVehicleReportOwnPosition setVehicleReportRemoteTargets setVehicleTIPars setVehicleVarName setVelocity setVelocityModelSpace setVelocityTransformation setViewDistance setVisibleIfTreeCollapsed setWantedRPMRTD setWaves setWaypointBehaviour setWaypointCombatMode setWaypointCompletionRadius setWaypointDescription setWaypointForceBehaviour setWaypointFormation setWaypointHousePosition setWaypointLoiterRadius setWaypointLoiterType setWaypointName setWaypointPosition setWaypointScript setWaypointSpeed setWaypointStatements setWaypointTimeout setWaypointType setWaypointVisible setWeaponReloadingTime setWind setWindDir setWindForce setWindStr setWingForceScaleRTD setWPPos show3DIcons showChat showCinemaBorder showCommandingMenu showCompass showCuratorCompass showGPS showHUD showLegend showMap shownArtilleryComputer shownChat shownCompass shownCuratorCompass showNewEditorObject shownGPS shownHUD shownMap shownPad shownRadio shownScoretable shownUAVFeed shownWarrant shownWatch showPad showRadio showScoretable showSubtitles showUAVFeed showWarrant showWatch showWaypoint showWaypoints side sideChat sideEnemy sideFriendly sideRadio simpleTasks simulationEnabled simulCloudDensity simulCloudOcclusion simulInClouds simulWeatherSync sin size sizeOf skill skillFinal skipTime sleep sliderPosition sliderRange sliderSetPosition sliderSetRange sliderSetSpeed sliderSpeed slingLoadAssistantShown soldierMagazines someAmmo sort soundVolume spawn speaker speed speedMode splitString sqrt squadParams stance startLoadingScreen step stop stopEngineRTD stopped str sunOrMoon supportInfo suppressFor surfaceIsWater surfaceNormal surfaceType swimInDepth switchableUnits switchAction switchCamera switchGesture switchLight switchMove synchronizedObjects synchronizedTriggers synchronizedWaypoints synchronizeObjectsAdd synchronizeObjectsRemove synchronizeTrigger synchronizeWaypoint systemChat systemOfUnits tan targetKnowledge targets targetsAggregate targetsQuery taskAlwaysVisible taskChildren taskCompleted taskCustomData taskDescription taskDestination taskHint taskMarkerOffset taskParent taskResult taskState taskType teamMember teamName teams teamSwitch teamSwitchEnabled teamType terminate terrainIntersect terrainIntersectASL terrainIntersectAtASL text textLog textLogFormat tg time timeMultiplier titleCut titleFadeOut titleObj titleRsc titleText toArray toFixed toLower toString toUpper triggerActivated triggerActivation triggerArea triggerAttachedVehicle triggerAttachObject triggerAttachVehicle triggerDynamicSimulation triggerStatements triggerText triggerTimeout triggerTimeoutCurrent triggerType turretLocal turretOwner turretUnit tvAdd tvClear tvCollapse tvCollapseAll tvCount tvCurSel tvData tvDelete tvExpand tvExpandAll tvPicture tvSetColor tvSetCurSel tvSetData tvSetPicture tvSetPictureColor tvSetPictureColorDisabled tvSetPictureColorSelected tvSetPictureRight tvSetPictureRightColor tvSetPictureRightColorDisabled tvSetPictureRightColorSelected tvSetText tvSetTooltip tvSetValue tvSort tvSortByValue tvText tvTooltip tvValue type typeName typeOf UAVControl uiNamespace uiSleep unassignCurator unassignItem unassignTeam unassignVehicle underwater uniform uniformContainer uniformItems uniformMagazines unitAddons unitAimPosition unitAimPositionVisual unitBackpack unitIsUAV unitPos unitReady unitRecoilCoefficient units unitsBelowHeight unlinkItem unlockAchievement unregisterTask updateDrawIcon updateMenuItem updateObjectTree useAISteeringComponent useAudioTimeForMoves userInputDisabled vectorAdd vectorCos vectorCrossProduct vectorDiff vectorDir vectorDirVisual vectorDistance vectorDistanceSqr vectorDotProduct vectorFromTo vectorMagnitude vectorMagnitudeSqr vectorModelToWorld vectorModelToWorldVisual vectorMultiply vectorNormalized vectorUp vectorUpVisual vectorWorldToModel vectorWorldToModelVisual vehicle vehicleCargoEnabled vehicleChat vehicleRadio vehicleReceiveRemoteTargets vehicleReportOwnPosition vehicleReportRemoteTargets vehicles vehicleVarName velocity velocityModelSpace verifySignature vest vestContainer vestItems vestMagazines viewDistance visibleCompass visibleGPS visibleMap visiblePosition visiblePositionASL visibleScoretable visibleWatch waves waypointAttachedObject waypointAttachedVehicle waypointAttachObject waypointAttachVehicle waypointBehaviour waypointCombatMode waypointCompletionRadius waypointDescription waypointForceBehaviour waypointFormation waypointHousePosition waypointLoiterRadius waypointLoiterType waypointName waypointPosition waypoints waypointScript waypointsEnabledUAV waypointShow waypointSpeed waypointStatements waypointTimeout waypointTimeoutCurrent waypointType waypointVisible weaponAccessories weaponAccessoriesCargo weaponCargo weaponDirection weaponInertia weaponLowered weapons weaponsItems weaponsItemsCargo weaponState weaponsTurret weightRTD WFSideText wind ",
-literal:"blufor civilian configNull controlNull displayNull east endl false grpNull independent lineBreak locationNull nil objNull opfor pi resistance scriptNull sideAmbientLife sideEmpty sideLogic sideUnknown taskNull teamMemberNull true west"
-},contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.NUMBER_MODE,{
-className:"variable",begin:/\b_+[a-zA-Z]\w*/},{className:"title",
-begin:/[a-zA-Z][a-zA-Z0-9]+_fnc_\w*/},t,a],illegal:/#|^\$ /}}})());
-hljs.registerLanguage("sql_more",(()=>{"use strict";return e=>{
-var t=e.COMMENT("--","$");return{name:"SQL (more)",aliases:["mysql","oracle"],
-disableAutodetect:!0,case_insensitive:!0,illegal:/[<>{}*]/,contains:[{
-beginKeywords:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup revoke comment values with",
-end:/;/,endsWithParent:!0,keywords:{$pattern:/[\w\.]+/,
-keyword:"as abort abs absolute acc acce accep accept access accessed accessible account acos action activate add addtime admin administer advanced advise aes_decrypt aes_encrypt after agent aggregate ali alia alias all allocate allow alter always analyze ancillary and anti any anydata anydataset anyschema anytype apply archive archived archivelog are as asc ascii asin assembly assertion associate asynchronous at atan atn2 attr attri attrib attribu attribut attribute attributes audit authenticated authentication authid authors auto autoallocate autodblink autoextend automatic availability avg backup badfile basicfile before begin beginning benchmark between bfile bfile_base big bigfile bin binary_double binary_float binlog bit_and bit_count bit_length bit_or bit_xor bitmap blob_base block blocksize body both bound bucket buffer_cache buffer_pool build bulk by byte byteordermark bytes cache caching call calling cancel capacity cascade cascaded case cast catalog category ceil ceiling chain change changed char_base char_length character_length characters characterset charindex charset charsetform charsetid check checksum checksum_agg child choose chr chunk class cleanup clear client clob clob_base clone close cluster_id cluster_probability cluster_set clustering coalesce coercibility col collate collation collect colu colum column column_value columns columns_updated comment commit compact compatibility compiled complete composite_limit compound compress compute concat concat_ws concurrent confirm conn connec connect connect_by_iscycle connect_by_isleaf connect_by_root connect_time connection consider consistent constant constraint constraints constructor container content contents context contributors controlfile conv convert convert_tz corr corr_k corr_s corresponding corruption cos cost count count_big counted covar_pop covar_samp cpu_per_call cpu_per_session crc32 create creation critical cross cube cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime customdatum cycle data database databases datafile datafiles datalength date_add date_cache date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts day day_to_second dayname dayofmonth dayofweek dayofyear days db_role_change dbtimezone ddl deallocate declare decode decompose decrement decrypt deduplicate def defa defau defaul default defaults deferred defi defin define degrees delayed delegate delete delete_all delimited demand dense_rank depth dequeue des_decrypt des_encrypt des_key_file desc descr descri describ describe descriptor deterministic diagnostics difference dimension direct_load directory disable disable_all disallow disassociate discardfile disconnect diskgroup distinct distinctrow distribute distributed div do document domain dotnet double downgrade drop dumpfile duplicate duration each edition editionable editions element ellipsis else elsif elt empty enable enable_all enclosed encode encoding encrypt end end-exec endian enforced engine engines enqueue enterprise entityescaping eomonth error errors escaped evalname evaluate event eventdata events except exception exceptions exchange exclude excluding execu execut execute exempt exists exit exp expire explain explode export export_set extended extent external external_1 external_2 externally extract failed failed_login_attempts failover failure far fast feature_set feature_value fetch field fields file file_name_convert filesystem_like_logging final finish first first_value fixed flash_cache flashback floor flush following follows for forall force foreign form forma format found found_rows freelist freelists freepools fresh from from_base64 from_days ftp full function general generated get get_format get_lock getdate getutcdate global global_name globally go goto grant grants greatest group group_concat group_id grouping grouping_id groups gtid_subtract guarantee guard handler hash hashkeys having hea head headi headin heading heap help hex hierarchy high high_priority hosts hour hours http id ident_current ident_incr ident_seed identified identity idle_time if ifnull ignore iif ilike ilm immediate import in include including increment index indexes indexing indextype indicator indices inet6_aton inet6_ntoa inet_aton inet_ntoa infile initial initialized initially initrans inmemory inner innodb input insert install instance instantiable instr interface interleaved intersect into invalidate invisible is is_free_lock is_ipv4 is_ipv4_compat is_not is_not_null is_used_lock isdate isnull isolation iterate java join json json_exists keep keep_duplicates key keys kill language large last last_day last_insert_id last_value lateral lax lcase lead leading least leaves left len lenght length less level levels library like like2 like4 likec limit lines link list listagg little ln load load_file lob lobs local localtime localtimestamp locate locator lock locked log log10 log2 logfile logfiles logging logical logical_reads_per_call logoff logon logs long loop low low_priority lower lpad lrtrim ltrim main make_set makedate maketime managed management manual map mapping mask master master_pos_wait match matched materialized max maxextents maximize maxinstances maxlen maxlogfiles maxloghistory maxlogmembers maxsize maxtrans md5 measures median medium member memcompress memory merge microsecond mid migration min minextents minimum mining minus minute minutes minvalue missing mod mode model modification modify module monitoring month months mount move movement multiset mutex name name_const names nan national native natural nav nchar nclob nested never new newline next nextval no no_write_to_binlog noarchivelog noaudit nobadfile nocheck nocompress nocopy nocycle nodelay nodiscardfile noentityescaping noguarantee nokeep nologfile nomapping nomaxvalue nominimize nominvalue nomonitoring none noneditionable nonschema noorder nopr nopro noprom nopromp noprompt norely noresetlogs noreverse normal norowdependencies noschemacheck noswitch not nothing notice notnull notrim novalidate now nowait nth_value nullif nulls num numb numbe nvarchar nvarchar2 object ocicoll ocidate ocidatetime ociduration ociinterval ociloblocator ocinumber ociref ocirefcursor ocirowid ocistring ocitype oct octet_length of off offline offset oid oidindex old on online only opaque open operations operator optimal optimize option optionally or oracle oracle_date oradata ord ordaudio orddicom orddoc order ordimage ordinality ordvideo organization orlany orlvary out outer outfile outline output over overflow overriding package pad parallel parallel_enable parameters parent parse partial partition partitions pascal passing password password_grace_time password_lock_time password_reuse_max password_reuse_time password_verify_function patch path patindex pctincrease pctthreshold pctused pctversion percent percent_rank percentile_cont percentile_disc performance period period_add period_diff permanent physical pi pipe pipelined pivot pluggable plugin policy position post_transaction pow power pragma prebuilt precedes preceding precision prediction prediction_cost prediction_details prediction_probability prediction_set prepare present preserve prior priority private private_sga privileges procedural procedure procedure_analyze processlist profiles project prompt protection public publishingservername purge quarter query quick quiesce quota quotename radians raise rand range rank raw read reads readsize rebuild record records recover recovery recursive recycle redo reduced ref reference referenced references referencing refresh regexp_like register regr_avgx regr_avgy regr_count regr_intercept regr_r2 regr_slope regr_sxx regr_sxy reject rekey relational relative relaylog release release_lock relies_on relocate rely rem remainder rename repair repeat replace replicate replication required reset resetlogs resize resource respect restore restricted result result_cache resumable resume retention return returning returns reuse reverse revoke right rlike role roles rollback rolling rollup round row row_count rowdependencies rowid rownum rows rtrim rules safe salt sample save savepoint sb1 sb2 sb4 scan schema schemacheck scn scope scroll sdo_georaster sdo_topo_geometry search sec_to_time second seconds section securefile security seed segment select self semi sequence sequential serializable server servererror session session_user sessions_per_user set sets settings sha sha1 sha2 share shared shared_pool short show shrink shutdown si_averagecolor si_colorhistogram si_featurelist si_positionalcolor si_stillimage si_texture siblings sid sign sin size size_t sizes skip slave sleep smalldatetimefromparts smallfile snapshot some soname sort soundex source space sparse spfile split sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_small_result sql_variant_property sqlcode sqldata sqlerror sqlname sqlstate sqrt square standalone standby start starting startup statement static statistics stats_binomial_test stats_crosstab stats_ks_test stats_mode stats_mw_test stats_one_way_anova stats_t_test_ stats_t_test_indep stats_t_test_one stats_t_test_paired stats_wsr_test status std stddev stddev_pop stddev_samp stdev stop storage store stored str str_to_date straight_join strcmp strict string struct stuff style subdate subpartition subpartitions substitutable substr substring subtime subtring_index subtype success sum suspend switch switchoffset switchover sync synchronous synonym sys sys_xmlagg sysasm sysaux sysdate sysdatetimeoffset sysdba sysoper system system_user sysutcdatetime table tables tablespace tablesample tan tdo template temporary terminated tertiary_weights test than then thread through tier ties time time_format time_zone timediff timefromparts timeout timestamp timestampadd timestampdiff timezone_abbr timezone_minute timezone_region to to_base64 to_date to_days to_seconds todatetimeoffset trace tracking transaction transactional translate translation treat trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse type ub1 ub2 ub4 ucase unarchived unbounded uncompress under undo unhex unicode uniform uninstall union unique unix_timestamp unknown unlimited unlock unnest unpivot unrecoverable unsafe unsigned until untrusted unusable unused update updated upgrade upped upper upsert url urowid usable usage use use_stored_outlines user user_data user_resources users using utc_date utc_timestamp uuid uuid_short validate validate_password_strength validation valist value values var var_samp varcharc vari varia variab variabl variable variables variance varp varraw varrawc varray verify version versions view virtual visible void wait wallet warning warnings week weekday weekofyear wellformed when whene whenev wheneve whenever where while whitespace window with within without work wrapped xdb xml xmlagg xmlattributes xmlcast xmlcolattval xmlelement xmlexists xmlforest xmlindex xmlnamespaces xmlpi xmlquery xmlroot xmlschema xmlserialize xmltable xmltype xor year year_to_month years yearweek",
-literal:"true false null unknown",
-built_in:"array bigint binary bit blob bool boolean char character date dec decimal float int int8 integer interval number numeric real record serial serial8 smallint text time timestamp tinyint varchar varchar2 varying void"
-},contains:[{className:"string",begin:"'",end:"'",contains:[{begin:"''"}]},{
-className:"string",begin:'"',end:'"',contains:[{begin:'""'}]},{
-className:"string",begin:"`",end:"`"
-},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,e.HASH_COMMENT_MODE]
-},e.C_BLOCK_COMMENT_MODE,t,e.HASH_COMMENT_MODE]}}})());
-hljs.registerLanguage("sql",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function r(...r){
-return r.map((r=>e(r))).join("")}function t(...r){
-return"("+r.map((r=>e(r))).join("|")+")"}return e=>{
-const n=e.COMMENT("--","$"),a=["true","false","unknown"],i=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],s=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],o=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],c=s,l=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update   ","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!s.includes(e))),u={
-begin:r(/\b/,t(...c),/\s*\(/),keywords:{built_in:c}};return{name:"SQL",
-case_insensitive:!0,illegal:/[{}]|<\//,keywords:{$pattern:/\b[\w\.]+/,
-keyword:((e,{exceptions:r,when:t}={})=>{const n=t
-;return r=r||[],e.map((e=>e.match(/\|\d+$/)||r.includes(e)?e:n(e)?e+"|0":e))
-})(l,{when:e=>e.length<3}),literal:a,type:i,
-built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"]
-},contains:[{begin:t(...o),keywords:{$pattern:/[\w\.]+/,keyword:l.concat(o),
-literal:a,type:i}},{className:"type",
-begin:t("double precision","large object","with timezone","without timezone")
-},u,{className:"variable",begin:/@[a-z0-9]+/},{className:"string",variants:[{
-begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/,contains:[{
-begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,n,{className:"operator",
-begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}})());
-hljs.registerLanguage("stan",(()=>{"use strict";return _=>({name:"Stan",
-aliases:["stanfuncs"],keywords:{$pattern:_.IDENT_RE,
-title:["functions","model","data","parameters","quantities","transformed","generated"],
-keyword:["for","in","if","else","while","break","continue","return"].concat(["int","real","vector","ordered","positive_ordered","simplex","unit_vector","row_vector","matrix","cholesky_factor_corr|10","cholesky_factor_cov|10","corr_matrix|10","cov_matrix|10","void"]).concat(["print","reject","increment_log_prob|10","integrate_ode|10","integrate_ode_rk45|10","integrate_ode_bdf|10","algebra_solver"]),
-built_in:["Phi","Phi_approx","abs","acos","acosh","algebra_solver","append_array","append_col","append_row","asin","asinh","atan","atan2","atanh","bernoulli_cdf","bernoulli_lccdf","bernoulli_lcdf","bernoulli_logit_lpmf","bernoulli_logit_rng","bernoulli_lpmf","bernoulli_rng","bessel_first_kind","bessel_second_kind","beta_binomial_cdf","beta_binomial_lccdf","beta_binomial_lcdf","beta_binomial_lpmf","beta_binomial_rng","beta_cdf","beta_lccdf","beta_lcdf","beta_lpdf","beta_rng","binary_log_loss","binomial_cdf","binomial_coefficient_log","binomial_lccdf","binomial_lcdf","binomial_logit_lpmf","binomial_lpmf","binomial_rng","block","categorical_logit_lpmf","categorical_logit_rng","categorical_lpmf","categorical_rng","cauchy_cdf","cauchy_lccdf","cauchy_lcdf","cauchy_lpdf","cauchy_rng","cbrt","ceil","chi_square_cdf","chi_square_lccdf","chi_square_lcdf","chi_square_lpdf","chi_square_rng","cholesky_decompose","choose","col","cols","columns_dot_product","columns_dot_self","cos","cosh","cov_exp_quad","crossprod","csr_extract_u","csr_extract_v","csr_extract_w","csr_matrix_times_vector","csr_to_dense_matrix","cumulative_sum","determinant","diag_matrix","diag_post_multiply","diag_pre_multiply","diagonal","digamma","dims","dirichlet_lpdf","dirichlet_rng","distance","dot_product","dot_self","double_exponential_cdf","double_exponential_lccdf","double_exponential_lcdf","double_exponential_lpdf","double_exponential_rng","e","eigenvalues_sym","eigenvectors_sym","erf","erfc","exp","exp2","exp_mod_normal_cdf","exp_mod_normal_lccdf","exp_mod_normal_lcdf","exp_mod_normal_lpdf","exp_mod_normal_rng","expm1","exponential_cdf","exponential_lccdf","exponential_lcdf","exponential_lpdf","exponential_rng","fabs","falling_factorial","fdim","floor","fma","fmax","fmin","fmod","frechet_cdf","frechet_lccdf","frechet_lcdf","frechet_lpdf","frechet_rng","gamma_cdf","gamma_lccdf","gamma_lcdf","gamma_lpdf","gamma_p","gamma_q","gamma_rng","gaussian_dlm_obs_lpdf","get_lp","gumbel_cdf","gumbel_lccdf","gumbel_lcdf","gumbel_lpdf","gumbel_rng","head","hypergeometric_lpmf","hypergeometric_rng","hypot","inc_beta","int_step","integrate_ode","integrate_ode_bdf","integrate_ode_rk45","inv","inv_Phi","inv_chi_square_cdf","inv_chi_square_lccdf","inv_chi_square_lcdf","inv_chi_square_lpdf","inv_chi_square_rng","inv_cloglog","inv_gamma_cdf","inv_gamma_lccdf","inv_gamma_lcdf","inv_gamma_lpdf","inv_gamma_rng","inv_logit","inv_sqrt","inv_square","inv_wishart_lpdf","inv_wishart_rng","inverse","inverse_spd","is_inf","is_nan","lbeta","lchoose","lgamma","lkj_corr_cholesky_lpdf","lkj_corr_cholesky_rng","lkj_corr_lpdf","lkj_corr_rng","lmgamma","lmultiply","log","log10","log1m","log1m_exp","log1m_inv_logit","log1p","log1p_exp","log2","log_determinant","log_diff_exp","log_falling_factorial","log_inv_logit","log_mix","log_rising_factorial","log_softmax","log_sum_exp","logistic_cdf","logistic_lccdf","logistic_lcdf","logistic_lpdf","logistic_rng","logit","lognormal_cdf","lognormal_lccdf","lognormal_lcdf","lognormal_lpdf","lognormal_rng","machine_precision","matrix_exp","max","mdivide_left_spd","mdivide_left_tri_low","mdivide_right_spd","mdivide_right_tri_low","mean","min","modified_bessel_first_kind","modified_bessel_second_kind","multi_gp_cholesky_lpdf","multi_gp_lpdf","multi_normal_cholesky_lpdf","multi_normal_cholesky_rng","multi_normal_lpdf","multi_normal_prec_lpdf","multi_normal_rng","multi_student_t_lpdf","multi_student_t_rng","multinomial_lpmf","multinomial_rng","multiply_log","multiply_lower_tri_self_transpose","neg_binomial_2_cdf","neg_binomial_2_lccdf","neg_binomial_2_lcdf","neg_binomial_2_log_lpmf","neg_binomial_2_log_rng","neg_binomial_2_lpmf","neg_binomial_2_rng","neg_binomial_cdf","neg_binomial_lccdf","neg_binomial_lcdf","neg_binomial_lpmf","neg_binomial_rng","negative_infinity","normal_cdf","normal_lccdf","normal_lcdf","normal_lpdf","normal_rng","not_a_number","num_elements","ordered_logistic_lpmf","ordered_logistic_rng","owens_t","pareto_cdf","pareto_lccdf","pareto_lcdf","pareto_lpdf","pareto_rng","pareto_type_2_cdf","pareto_type_2_lccdf","pareto_type_2_lcdf","pareto_type_2_lpdf","pareto_type_2_rng","pi","poisson_cdf","poisson_lccdf","poisson_lcdf","poisson_log_lpmf","poisson_log_rng","poisson_lpmf","poisson_rng","positive_infinity","pow","print","prod","qr_Q","qr_R","quad_form","quad_form_diag","quad_form_sym","rank","rayleigh_cdf","rayleigh_lccdf","rayleigh_lcdf","rayleigh_lpdf","rayleigh_rng","reject","rep_array","rep_matrix","rep_row_vector","rep_vector","rising_factorial","round","row","rows","rows_dot_product","rows_dot_self","scaled_inv_chi_square_cdf","scaled_inv_chi_square_lccdf","scaled_inv_chi_square_lcdf","scaled_inv_chi_square_lpdf","scaled_inv_chi_square_rng","sd","segment","sin","singular_values","sinh","size","skew_normal_cdf","skew_normal_lccdf","skew_normal_lcdf","skew_normal_lpdf","skew_normal_rng","softmax","sort_asc","sort_desc","sort_indices_asc","sort_indices_desc","sqrt","sqrt2","square","squared_distance","step","student_t_cdf","student_t_lccdf","student_t_lcdf","student_t_lpdf","student_t_rng","sub_col","sub_row","sum","tail","tan","tanh","target","tcrossprod","tgamma","to_array_1d","to_array_2d","to_matrix","to_row_vector","to_vector","trace","trace_gen_quad_form","trace_quad_form","trigamma","trunc","uniform_cdf","uniform_lccdf","uniform_lcdf","uniform_lpdf","uniform_rng","variance","von_mises_lpdf","von_mises_rng","weibull_cdf","weibull_lccdf","weibull_lcdf","weibull_lpdf","weibull_rng","wiener_lpdf","wishart_lpdf","wishart_rng"]
-},contains:[_.C_LINE_COMMENT_MODE,_.COMMENT(/#/,/$/,{relevance:0,keywords:{
-"meta-keyword":"include"}}),_.COMMENT(/\/\*/,/\*\//,{relevance:0,contains:[{
-className:"doctag",begin:/@(return|param)/}]}),{begin:/<\s*lower\s*=/,
-keywords:"lower"},{begin:/[<,]\s*upper\s*=/,keywords:"upper"},{
-className:"keyword",begin:/\btarget\s*\+=/,relevance:10},{
-begin:"~\\s*("+_.IDENT_RE+")\\s*\\(",
-keywords:["bernoulli","bernoulli_logit","beta","beta_binomial","binomial","binomial_logit","categorical","categorical_logit","cauchy","chi_square","dirichlet","double_exponential","exp_mod_normal","exponential","frechet","gamma","gaussian_dlm_obs","gumbel","hypergeometric","inv_chi_square","inv_gamma","inv_wishart","lkj_corr","lkj_corr_cholesky","logistic","lognormal","multi_gp","multi_gp_cholesky","multi_normal","multi_normal_cholesky","multi_normal_prec","multi_student_t","multinomial","neg_binomial","neg_binomial_2","neg_binomial_2_log","normal","ordered_logistic","pareto","pareto_type_2","poisson","poisson_log","rayleigh","scaled_inv_chi_square","skew_normal","student_t","uniform","von_mises","weibull","wiener","wishart"]
-},{className:"number",variants:[{begin:/\b\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/},{
-begin:/\.\d+(?:[eE][+-]?\d+)?\b/}],relevance:0},{className:"string",begin:'"',
-end:'"',relevance:0}]})})());
-hljs.registerLanguage("stata",(()=>{"use strict";return e=>({name:"Stata",
-aliases:["do","ado"],case_insensitive:!0,
-keywords:"if else in foreach for forv forva forval forvalu forvalue forvalues by bys bysort xi quietly qui capture about ac ac_7 acprplot acprplot_7 adjust ado adopath adoupdate alpha ameans an ano anov anova anova_estat anova_terms anovadef aorder ap app appe appen append arch arch_dr arch_estat arch_p archlm areg areg_p args arima arima_dr arima_estat arima_p as asmprobit asmprobit_estat asmprobit_lf asmprobit_mfx__dlg asmprobit_p ass asse asser assert avplot avplot_7 avplots avplots_7 bcskew0 bgodfrey bias binreg bip0_lf biplot bipp_lf bipr_lf bipr_p biprobit bitest bitesti bitowt blogit bmemsize boot bootsamp bootstrap bootstrap_8 boxco_l boxco_p boxcox boxcox_6 boxcox_p bprobit br break brier bro brow brows browse brr brrstat bs bs_7 bsampl_w bsample bsample_7 bsqreg bstat bstat_7 bstat_8 bstrap bstrap_7 bubble bubbleplot ca ca_estat ca_p cabiplot camat canon canon_8 canon_8_p canon_estat canon_p cap caprojection capt captu captur capture cat cc cchart cchart_7 cci cd censobs_table centile cf char chdir checkdlgfiles checkestimationsample checkhlpfiles checksum chelp ci cii cl class classutil clear cli clis clist clo clog clog_lf clog_p clogi clogi_sw clogit clogit_lf clogit_p clogitp clogl_sw cloglog clonevar clslistarray cluster cluster_measures cluster_stop cluster_tree cluster_tree_8 clustermat cmdlog cnr cnre cnreg cnreg_p cnreg_sw cnsreg codebook collaps4 collapse colormult_nb colormult_nw compare compress conf confi confir confirm conren cons const constr constra constrai constrain constraint continue contract copy copyright copysource cor corc corr corr2data corr_anti corr_kmo corr_smc corre correl correla correlat correlate corrgram cou coun count cox cox_p cox_sw coxbase coxhaz coxvar cprplot cprplot_7 crc cret cretu cretur creturn cross cs cscript cscript_log csi ct ct_is ctset ctst_5 ctst_st cttost cumsp cumsp_7 cumul cusum cusum_7 cutil d|0 datasig datasign datasigna datasignat datasignatu datasignatur datasignature datetof db dbeta de dec deco decod decode deff des desc descr descri describ describe destring dfbeta dfgls dfuller di di_g dir dirstats dis discard disp disp_res disp_s displ displa display distinct do doe doed doedi doedit dotplot dotplot_7 dprobit drawnorm drop ds ds_util dstdize duplicates durbina dwstat dydx e|0 ed edi edit egen eivreg emdef en enc enco encod encode eq erase ereg ereg_lf ereg_p ereg_sw ereghet ereghet_glf ereghet_glf_sh ereghet_gp ereghet_ilf ereghet_ilf_sh ereghet_ip eret eretu eretur ereturn err erro error esize est est_cfexist est_cfname est_clickable est_expand est_hold est_table est_unhold est_unholdok estat estat_default estat_summ estat_vce_only esti estimates etodow etof etomdy ex exi exit expand expandcl fac fact facto factor factor_estat factor_p factor_pca_rotated factor_rotate factormat fcast fcast_compute fcast_graph fdades fdadesc fdadescr fdadescri fdadescrib fdadescribe fdasav fdasave fdause fh_st file open file read file close file filefilter fillin find_hlp_file findfile findit findit_7 fit fl fli flis flist for5_0 forest forestplot form forma format fpredict frac_154 frac_adj frac_chk frac_cox frac_ddp frac_dis frac_dv frac_in frac_mun frac_pp frac_pq frac_pv frac_wgt frac_xo fracgen fracplot fracplot_7 fracpoly fracpred fron_ex fron_hn fron_p fron_tn fron_tn2 frontier ftodate ftoe ftomdy ftowdate funnel funnelplot g|0 gamhet_glf gamhet_gp gamhet_ilf gamhet_ip gamma gamma_d2 gamma_p gamma_sw gammahet gdi_hexagon gdi_spokes ge gen gene gener genera generat generate genrank genstd genvmean gettoken gl gladder gladder_7 glim_l01 glim_l02 glim_l03 glim_l04 glim_l05 glim_l06 glim_l07 glim_l08 glim_l09 glim_l10 glim_l11 glim_l12 glim_lf glim_mu glim_nw1 glim_nw2 glim_nw3 glim_p glim_v1 glim_v2 glim_v3 glim_v4 glim_v5 glim_v6 glim_v7 glm glm_6 glm_p glm_sw glmpred glo glob globa global glogit glogit_8 glogit_p gmeans gnbre_lf gnbreg gnbreg_5 gnbreg_p gomp_lf gompe_sw gomper_p gompertz gompertzhet gomphet_glf gomphet_glf_sh gomphet_gp gomphet_ilf gomphet_ilf_sh gomphet_ip gphdot gphpen gphprint gprefs gprobi_p gprobit gprobit_8 gr gr7 gr_copy gr_current gr_db gr_describe gr_dir gr_draw gr_draw_replay gr_drop gr_edit gr_editviewopts gr_example gr_example2 gr_export gr_print gr_qscheme gr_query gr_read gr_rename gr_replay gr_save gr_set gr_setscheme gr_table gr_undo gr_use graph graph7 grebar greigen greigen_7 greigen_8 grmeanby grmeanby_7 gs_fileinfo gs_filetype gs_graphinfo gs_stat gsort gwood h|0 hadimvo hareg hausman haver he heck_d2 heckma_p heckman heckp_lf heckpr_p heckprob hel help hereg hetpr_lf hetpr_p hetprob hettest hexdump hilite hist hist_7 histogram hlogit hlu hmeans hotel hotelling hprobit hreg hsearch icd9 icd9_ff icd9p iis impute imtest inbase include inf infi infil infile infix inp inpu input ins insheet insp inspe inspec inspect integ inten intreg intreg_7 intreg_p intrg2_ll intrg_ll intrg_ll2 ipolate iqreg ir irf irf_create irfm iri is_svy is_svysum isid istdize ivprob_1_lf ivprob_lf ivprobit ivprobit_p ivreg ivreg_footnote ivtob_1_lf ivtob_lf ivtobit ivtobit_p jackknife jacknife jknife jknife_6 jknife_8 jkstat joinby kalarma1 kap kap_3 kapmeier kappa kapwgt kdensity kdensity_7 keep ksm ksmirnov ktau kwallis l|0 la lab labbe labbeplot labe label labelbook ladder levels levelsof leverage lfit lfit_p li lincom line linktest lis list lloghet_glf lloghet_glf_sh lloghet_gp lloghet_ilf lloghet_ilf_sh lloghet_ip llogi_sw llogis_p llogist llogistic llogistichet lnorm_lf lnorm_sw lnorma_p lnormal lnormalhet lnormhet_glf lnormhet_glf_sh lnormhet_gp lnormhet_ilf lnormhet_ilf_sh lnormhet_ip lnskew0 loadingplot loc loca local log logi logis_lf logistic logistic_p logit logit_estat logit_p loglogs logrank loneway lookfor lookup lowess lowess_7 lpredict lrecomp lroc lroc_7 lrtest ls lsens lsens_7 lsens_x lstat ltable ltable_7 ltriang lv lvr2plot lvr2plot_7 m|0 ma mac macr macro makecns man manova manova_estat manova_p manovatest mantel mark markin markout marksample mat mat_capp mat_order mat_put_rr mat_rapp mata mata_clear mata_describe mata_drop mata_matdescribe mata_matsave mata_matuse mata_memory mata_mlib mata_mosave mata_rename mata_which matalabel matcproc matlist matname matr matri matrix matrix_input__dlg matstrik mcc mcci md0_ md1_ md1debug_ md2_ md2debug_ mds mds_estat mds_p mdsconfig mdslong mdsmat mdsshepard mdytoe mdytof me_derd mean means median memory memsize menl meqparse mer merg merge meta mfp mfx mhelp mhodds minbound mixed_ll mixed_ll_reparm mkassert mkdir mkmat mkspline ml ml_5 ml_adjs ml_bhhhs ml_c_d ml_check ml_clear ml_cnt ml_debug ml_defd ml_e0 ml_e0_bfgs ml_e0_cycle ml_e0_dfp ml_e0i ml_e1 ml_e1_bfgs ml_e1_bhhh ml_e1_cycle ml_e1_dfp ml_e2 ml_e2_cycle ml_ebfg0 ml_ebfr0 ml_ebfr1 ml_ebh0q ml_ebhh0 ml_ebhr0 ml_ebr0i ml_ecr0i ml_edfp0 ml_edfr0 ml_edfr1 ml_edr0i ml_eds ml_eer0i ml_egr0i ml_elf ml_elf_bfgs ml_elf_bhhh ml_elf_cycle ml_elf_dfp ml_elfi ml_elfs ml_enr0i ml_enrr0 ml_erdu0 ml_erdu0_bfgs ml_erdu0_bhhh ml_erdu0_bhhhq ml_erdu0_cycle ml_erdu0_dfp ml_erdu0_nrbfgs ml_exde ml_footnote ml_geqnr ml_grad0 ml_graph ml_hbhhh ml_hd0 ml_hold ml_init ml_inv ml_log ml_max ml_mlout ml_mlout_8 ml_model ml_nb0 ml_opt ml_p ml_plot ml_query ml_rdgrd ml_repor ml_s_e ml_score ml_searc ml_technique ml_unhold mleval mlf_ mlmatbysum mlmatsum mlog mlogi mlogit mlogit_footnote mlogit_p mlopts mlsum mlvecsum mnl0_ mor more mov move mprobit mprobit_lf mprobit_p mrdu0_ mrdu1_ mvdecode mvencode mvreg mvreg_estat n|0 nbreg nbreg_al nbreg_lf nbreg_p nbreg_sw nestreg net newey newey_7 newey_p news nl nl_7 nl_9 nl_9_p nl_p nl_p_7 nlcom nlcom_p nlexp2 nlexp2_7 nlexp2a nlexp2a_7 nlexp3 nlexp3_7 nlgom3 nlgom3_7 nlgom4 nlgom4_7 nlinit nllog3 nllog3_7 nllog4 nllog4_7 nlog_rd nlogit nlogit_p nlogitgen nlogittree nlpred no nobreak noi nois noisi noisil noisily note notes notes_dlg nptrend numlabel numlist odbc old_ver olo olog ologi ologi_sw ologit ologit_p ologitp on one onew onewa oneway op_colnm op_comp op_diff op_inv op_str opr opro oprob oprob_sw oprobi oprobi_p oprobit oprobitp opts_exclusive order orthog orthpoly ou out outf outfi outfil outfile outs outsh outshe outshee outsheet ovtest pac pac_7 palette parse parse_dissim pause pca pca_8 pca_display pca_estat pca_p pca_rotate pcamat pchart pchart_7 pchi pchi_7 pcorr pctile pentium pergram pergram_7 permute permute_8 personal peto_st pkcollapse pkcross pkequiv pkexamine pkexamine_7 pkshape pksumm pksumm_7 pl plo plot plugin pnorm pnorm_7 poisgof poiss_lf poiss_sw poisso_p poisson poisson_estat post postclose postfile postutil pperron pr prais prais_e prais_e2 prais_p predict predictnl preserve print pro prob probi probit probit_estat probit_p proc_time procoverlay procrustes procrustes_estat procrustes_p profiler prog progr progra program prop proportion prtest prtesti pwcorr pwd q\\s qby qbys qchi qchi_7 qladder qladder_7 qnorm qnorm_7 qqplot qqplot_7 qreg qreg_c qreg_p qreg_sw qu quadchk quantile quantile_7 que quer query range ranksum ratio rchart rchart_7 rcof recast reclink recode reg reg3 reg3_p regdw regr regre regre_p2 regres regres_p regress regress_estat regriv_p remap ren rena renam rename renpfix repeat replace report reshape restore ret retu retur return rm rmdir robvar roccomp roccomp_7 roccomp_8 rocf_lf rocfit rocfit_8 rocgold rocplot rocplot_7 roctab roctab_7 rolling rologit rologit_p rot rota rotat rotate rotatemat rreg rreg_p ru run runtest rvfplot rvfplot_7 rvpplot rvpplot_7 sa safesum sample sampsi sav save savedresults saveold sc sca scal scala scalar scatter scm_mine sco scob_lf scob_p scobi_sw scobit scor score scoreplot scoreplot_help scree screeplot screeplot_help sdtest sdtesti se search separate seperate serrbar serrbar_7 serset set set_defaults sfrancia sh she shel shell shewhart shewhart_7 signestimationsample signrank signtest simul simul_7 simulate simulate_8 sktest sleep slogit slogit_d2 slogit_p smooth snapspan so sor sort spearman spikeplot spikeplot_7 spikeplt spline_x split sqreg sqreg_p sret sretu sretur sreturn ssc st st_ct st_hc st_hcd st_hcd_sh st_is st_issys st_note st_promo st_set st_show st_smpl st_subid stack statsby statsby_8 stbase stci stci_7 stcox stcox_estat stcox_fr stcox_fr_ll stcox_p stcox_sw stcoxkm stcoxkm_7 stcstat stcurv stcurve stcurve_7 stdes stem stepwise stereg stfill stgen stir stjoin stmc stmh stphplot stphplot_7 stphtest stphtest_7 stptime strate strate_7 streg streg_sw streset sts sts_7 stset stsplit stsum sttocc sttoct stvary stweib su suest suest_8 sum summ summa summar summari summariz summarize sunflower sureg survcurv survsum svar svar_p svmat svy svy_disp svy_dreg svy_est svy_est_7 svy_estat svy_get svy_gnbreg_p svy_head svy_header svy_heckman_p svy_heckprob_p svy_intreg_p svy_ivreg_p svy_logistic_p svy_logit_p svy_mlogit_p svy_nbreg_p svy_ologit_p svy_oprobit_p svy_poisson_p svy_probit_p svy_regress_p svy_sub svy_sub_7 svy_x svy_x_7 svy_x_p svydes svydes_8 svygen svygnbreg svyheckman svyheckprob svyintreg svyintreg_7 svyintrg svyivreg svylc svylog_p svylogit svymarkout svymarkout_8 svymean svymlog svymlogit svynbreg svyolog svyologit svyoprob svyoprobit svyopts svypois svypois_7 svypoisson svyprobit svyprobt svyprop svyprop_7 svyratio svyreg svyreg_p svyregress svyset svyset_7 svyset_8 svytab svytab_7 svytest svytotal sw sw_8 swcnreg swcox swereg swilk swlogis swlogit swologit swoprbt swpois swprobit swqreg swtobit swweib symmetry symmi symplot symplot_7 syntax sysdescribe sysdir sysuse szroeter ta tab tab1 tab2 tab_or tabd tabdi tabdis tabdisp tabi table tabodds tabodds_7 tabstat tabu tabul tabula tabulat tabulate te tempfile tempname tempvar tes test testnl testparm teststd tetrachoric time_it timer tis tob tobi tobit tobit_p tobit_sw token tokeni tokeniz tokenize tostring total translate translator transmap treat_ll treatr_p treatreg trim trimfill trnb_cons trnb_mean trpoiss_d2 trunc_ll truncr_p truncreg tsappend tset tsfill tsline tsline_ex tsreport tsrevar tsrline tsset tssmooth tsunab ttest ttesti tut_chk tut_wait tutorial tw tware_st two twoway twoway__fpfit_serset twoway__function_gen twoway__histogram_gen twoway__ipoint_serset twoway__ipoints_serset twoway__kdensity_gen twoway__lfit_serset twoway__normgen_gen twoway__pci_serset twoway__qfit_serset twoway__scatteri_serset twoway__sunflower_gen twoway_ksm_serset ty typ type typeof u|0 unab unabbrev unabcmd update us use uselabel var var_mkcompanion var_p varbasic varfcast vargranger varirf varirf_add varirf_cgraph varirf_create varirf_ctable varirf_describe varirf_dir varirf_drop varirf_erase varirf_graph varirf_ograph varirf_rename varirf_set varirf_table varlist varlmar varnorm varsoc varstable varstable_w varstable_w2 varwle vce vec vec_fevd vec_mkphi vec_p vec_p_w vecirf_create veclmar veclmar_w vecnorm vecnorm_w vecrank vecstable verinst vers versi versio version view viewsource vif vwls wdatetof webdescribe webseek webuse weib1_lf weib2_lf weib_lf weib_lf0 weibhet_glf weibhet_glf_sh weibhet_glfa weibhet_glfa_sh weibhet_gp weibhet_ilf weibhet_ilf_sh weibhet_ilfa weibhet_ilfa_sh weibhet_ip weibu_sw weibul_p weibull weibull_c weibull_s weibullhet wh whelp whi which whil while wilc_st wilcoxon win wind windo window winexec wntestb wntestb_7 wntestq xchart xchart_7 xcorr xcorr_7 xi xi_6 xmlsav xmlsave xmluse xpose xsh xshe xshel xshell xt_iis xt_tis xtab_p xtabond xtbin_p xtclog xtcloglog xtcloglog_8 xtcloglog_d2 xtcloglog_pa_p xtcloglog_re_p xtcnt_p xtcorr xtdata xtdes xtfront_p xtfrontier xtgee xtgee_elink xtgee_estat xtgee_makeivar xtgee_p xtgee_plink xtgls xtgls_p xthaus xthausman xtht_p xthtaylor xtile xtint_p xtintreg xtintreg_8 xtintreg_d2 xtintreg_p xtivp_1 xtivp_2 xtivreg xtline xtline_ex xtlogit xtlogit_8 xtlogit_d2 xtlogit_fe_p xtlogit_pa_p xtlogit_re_p xtmixed xtmixed_estat xtmixed_p xtnb_fe xtnb_lf xtnbreg xtnbreg_pa_p xtnbreg_refe_p xtpcse xtpcse_p xtpois xtpoisson xtpoisson_d2 xtpoisson_pa_p xtpoisson_refe_p xtpred xtprobit xtprobit_8 xtprobit_d2 xtprobit_re_p xtps_fe xtps_lf xtps_ren xtps_ren_8 xtrar_p xtrc xtrc_p xtrchh xtrefe_p xtreg xtreg_be xtreg_fe xtreg_ml xtreg_pa_p xtreg_re xtregar xtrere_p xtset xtsf_ll xtsf_llti xtsum xttab xttest0 xttobit xttobit_8 xttobit_p xttrans yx yxview__barlike_draw yxview_area_draw yxview_bar_draw yxview_dot_draw yxview_dropline_draw yxview_function_draw yxview_iarrow_draw yxview_ilabels_draw yxview_normal_draw yxview_pcarrow_draw yxview_pcbarrow_draw yxview_pccapsym_draw yxview_pcscatter_draw yxview_pcspike_draw yxview_rarea_draw yxview_rbar_draw yxview_rbarm_draw yxview_rcap_draw yxview_rcapsym_draw yxview_rconnected_draw yxview_rline_draw yxview_rscatter_draw yxview_rspike_draw yxview_spike_draw yxview_sunflower_draw zap_s zinb zinb_llf zinb_plf zip zip_llf zip_p zip_plf zt_ct_5 zt_hc_5 zt_hcd_5 zt_is_5 zt_iss_5 zt_sho_5 zt_smp_5 ztbase_5 ztcox_5 ztdes_5 ztereg_5 ztfill_5 ztgen_5 ztir_5 ztjoin_5 ztnb ztnb_p ztp ztp_p zts_5 ztset_5 ztspli_5 ztsum_5 zttoct_5 ztvary_5 ztweib_5",
-contains:[{className:"symbol",begin:/`[a-zA-Z0-9_]+'/},{className:"variable",
-begin:/\$\{?[a-zA-Z0-9_]+\}?/},{className:"string",variants:[{
-begin:'`"[^\r\n]*?"\''},{begin:'"[^\r\n"]*"'}]},{className:"built_in",
-variants:[{
-begin:"\\b(abs|acos|asin|atan|atan2|atanh|ceil|cloglog|comb|cos|digamma|exp|floor|invcloglog|invlogit|ln|lnfact|lnfactorial|lngamma|log|log10|max|min|mod|reldif|round|sign|sin|sqrt|sum|tan|tanh|trigamma|trunc|betaden|Binomial|binorm|binormal|chi2|chi2tail|dgammapda|dgammapdada|dgammapdadx|dgammapdx|dgammapdxdx|F|Fden|Ftail|gammaden|gammap|ibeta|invbinomial|invchi2|invchi2tail|invF|invFtail|invgammap|invibeta|invnchi2|invnFtail|invnibeta|invnorm|invnormal|invttail|nbetaden|nchi2|nFden|nFtail|nibeta|norm|normal|normalden|normd|npnchi2|tden|ttail|uniform|abbrev|char|index|indexnot|length|lower|ltrim|match|plural|proper|real|regexm|regexr|regexs|reverse|rtrim|string|strlen|strlower|strltrim|strmatch|strofreal|strpos|strproper|strreverse|strrtrim|strtrim|strupper|subinstr|subinword|substr|trim|upper|word|wordcount|_caller|autocode|byteorder|chop|clip|cond|e|epsdouble|epsfloat|group|inlist|inrange|irecode|matrix|maxbyte|maxdouble|maxfloat|maxint|maxlong|mi|minbyte|mindouble|minfloat|minint|minlong|missing|r|recode|replay|return|s|scalar|d|date|day|dow|doy|halfyear|mdy|month|quarter|week|year|d|daily|dofd|dofh|dofm|dofq|dofw|dofy|h|halfyearly|hofd|m|mofd|monthly|q|qofd|quarterly|tin|twithin|w|weekly|wofd|y|yearly|yh|ym|yofd|yq|yw|cholesky|colnumb|colsof|corr|det|diag|diag0cnt|el|get|hadamard|I|inv|invsym|issym|issymmetric|J|matmissing|matuniform|mreldif|nullmat|rownumb|rowsof|sweep|syminv|trace|vec|vecdiag)(?=\\()"
-}]},e.COMMENT("^[ \t]*\\*.*$",!1),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]
-})})());
-hljs.registerLanguage("step21",(()=>{"use strict";return e=>({
-name:"STEP Part 21",aliases:["p21","step","stp"],case_insensitive:!0,keywords:{
-$pattern:"[A-Z_][A-Z0-9_.]*",keyword:"HEADER ENDSEC DATA"},contains:[{
-className:"meta",begin:"ISO-10303-21;",relevance:10},{className:"meta",
-begin:"END-ISO-10303-21;",relevance:10
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.COMMENT("/\\*\\*!","\\*/"),e.C_NUMBER_MODE,e.inherit(e.APOS_STRING_MODE,{
-illegal:null}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null}),{
-className:"string",begin:"'",end:"'"},{className:"symbol",variants:[{begin:"#",
-end:"\\d+",illegal:"\\W"}]}]})})());
-hljs.registerLanguage("stylus",(()=>{"use strict"
-;const e=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],t=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],o=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],i=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],r=["align-content","align-items","align-self","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","auto","backface-visibility","background","background-attachment","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","border","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","clear","clip","clip-path","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","content","counter-increment","counter-reset","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-variant","font-variant-ligatures","font-variation-settings","font-weight","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inherit","initial","justify-content","left","letter-spacing","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-bottom","margin-left","margin-right","margin-top","marks","mask","max-height","max-width","min-height","min-width","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-bottom","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","perspective","perspective-origin","pointer-events","position","quotes","resize","right","src","tab-size","table-layout","text-align","text-align-last","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-indent","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","white-space","widows","width","word-break","word-spacing","word-wrap","z-index"].reverse()
-;return n=>{const a=(e=>({IMPORTANT:{className:"meta",begin:"!important"},
-HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"},
-ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/,
-illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}}))(n),s={
-className:"variable",begin:"\\$"+n.IDENT_RE},l="(?=[.\\s\\n[:,(])";return{
-name:"Stylus",aliases:["styl"],case_insensitive:!1,keywords:"if else for in",
-illegal:"(\\?|(\\bReturn\\b)|(\\bEnd\\b)|(\\bend\\b)|(\\bdef\\b)|;|#\\s|\\*\\s|===\\s|\\||%)",
-contains:[n.QUOTE_STRING_MODE,n.APOS_STRING_MODE,n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE,a.HEXCOLOR,{
-begin:"\\.[a-zA-Z][a-zA-Z0-9_-]*(?=[.\\s\\n[:,(])",className:"selector-class"},{
-begin:"#[a-zA-Z][a-zA-Z0-9_-]*(?=[.\\s\\n[:,(])",className:"selector-id"},{
-begin:"\\b("+e.join("|")+")"+l,className:"selector-tag"},{
-className:"selector-pseudo",begin:"&?:("+o.join("|")+")"+l},{
-className:"selector-pseudo",begin:"&?::("+i.join("|")+")"+l
-},a.ATTRIBUTE_SELECTOR_MODE,{className:"keyword",begin:/@media/,starts:{
-end:/[{;}]/,keywords:{$pattern:/[a-z-]+/,keyword:"and or not only",
-attribute:t.join(" ")},contains:[n.CSS_NUMBER_MODE]}},{className:"keyword",
-begin:"@((-(o|moz|ms|webkit)-)?(charset|css|debug|extend|font-face|for|import|include|keyframes|media|mixin|page|warn|while))\\b"
-},s,n.CSS_NUMBER_MODE,{className:"function",
-begin:"^[a-zA-Z][a-zA-Z0-9_-]*\\(.*\\)",illegal:"[\\n]",returnBegin:!0,
-contains:[{className:"title",begin:"\\b[a-zA-Z][a-zA-Z0-9_-]*"},{
-className:"params",begin:/\(/,end:/\)/,
-contains:[a.HEXCOLOR,s,n.APOS_STRING_MODE,n.CSS_NUMBER_MODE,n.QUOTE_STRING_MODE]
-}]},{className:"attribute",begin:"\\b("+r.join("|")+")\\b",starts:{end:/;|$/,
-contains:[a.HEXCOLOR,s,n.APOS_STRING_MODE,n.QUOTE_STRING_MODE,n.CSS_NUMBER_MODE,n.C_BLOCK_COMMENT_MODE,a.IMPORTANT],
-illegal:/\./,relevance:0}}]}}})());
-hljs.registerLanguage("subunit",(()=>{"use strict";return s=>({name:"SubUnit",
-case_insensitive:!0,contains:[{className:"string",begin:"\\[\n(multipart)?",
-end:"\\]\n"},{className:"string",
-begin:"\\d{4}-\\d{2}-\\d{2}(\\s+)\\d{2}:\\d{2}:\\d{2}.\\d+Z"},{
-className:"string",begin:"(\\+|-)\\d+"},{className:"keyword",relevance:10,
-variants:[{
-begin:"^(test|testing|success|successful|failure|error|skip|xfail|uxsuccess)(:?)\\s+(test)?"
-},{begin:"^progress(:?)(\\s+)?(pop|push)?"},{begin:"^tags:"},{begin:"^time:"}]}]
-})})());
-hljs.registerLanguage("swift",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(e){return a("(?=",e,")")}
-function a(...n){return n.map((n=>e(n))).join("")}function t(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}
-const i=e=>a(/\b/,e,/\w$/.test(e)?/\b/:/\B/),s=["Protocol","Type"].map(i),u=["init","self"].map(i),c=["Any","Self"],r=["associatedtype","async","await",/as\?/,/as!/,"as","break","case","catch","class","continue","convenience","default","defer","deinit","didSet","do","dynamic","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","lazy","let","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],o=["false","nil","true"],l=["assignment","associativity","higherThan","left","lowerThan","none","right"],m=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warn_unqualified_access","#warning"],d=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],p=t(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),F=t(p,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),b=a(p,F,"*"),h=t(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),f=t(h,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),w=a(h,f,"*"),y=a(/[A-Z]/,f,"*"),g=["autoclosure",a(/convention\(/,t("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",a(/objc\(/,w,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","testable","UIApplicationMain","unknown","usableFromInline"],E=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"]
-;return e=>{const p={match:/\s+/,relevance:0},h=e.COMMENT("/\\*","\\*/",{
-contains:["self"]}),v=[e.C_LINE_COMMENT_MODE,h],N={className:"keyword",
-begin:a(/\./,n(t(...s,...u))),end:t(...s,...u),excludeBegin:!0},A={
-match:a(/\./,t(...r)),relevance:0
-},C=r.filter((e=>"string"==typeof e)).concat(["_|0"]),_={variants:[{
-className:"keyword",
-match:t(...r.filter((e=>"string"!=typeof e)).concat(c).map(i),...u)}]},D={
-$pattern:t(/\b\w+/,/#\w+/),keyword:C.concat(m),literal:o},B=[N,A,_],k=[{
-match:a(/\./,t(...d)),relevance:0},{className:"built_in",
-match:a(/\b/,t(...d),/(?=\()/)}],M={match:/->/,relevance:0},S=[M,{
-className:"operator",relevance:0,variants:[{match:b},{match:`\\.(\\.|${F})+`}]
-}],x="([0-9a-fA-F]_*)+",I={className:"number",relevance:0,variants:[{
-match:"\\b(([0-9]_*)+)(\\.(([0-9]_*)+))?([eE][+-]?(([0-9]_*)+))?\\b"},{
-match:`\\b0x(${x})(\\.(${x}))?([pP][+-]?(([0-9]_*)+))?\\b`},{
-match:/\b0o([0-7]_*)+\b/},{match:/\b0b([01]_*)+\b/}]},O=(e="")=>({
-className:"subst",variants:[{match:a(/\\/,e,/[0\\tnr"']/)},{
-match:a(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}]}),T=(e="")=>({className:"subst",
-match:a(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/)}),L=(e="")=>({className:"subst",
-label:"interpol",begin:a(/\\/,e,/\(/),end:/\)/}),P=(e="")=>({begin:a(e,/"""/),
-end:a(/"""/,e),contains:[O(e),T(e),L(e)]}),$=(e="")=>({begin:a(e,/"/),
-end:a(/"/,e),contains:[O(e),L(e)]}),K={className:"string",
-variants:[P(),P("#"),P("##"),P("###"),$(),$("#"),$("##"),$("###")]},j={
-match:a(/`/,w,/`/)},z=[j,{className:"variable",match:/\$\d+/},{
-className:"variable",match:`\\$${f}+`}],q=[{match:/(@|#)available/,
-className:"keyword",starts:{contains:[{begin:/\(/,end:/\)/,keywords:E,
-contains:[...S,I,K]}]}},{className:"keyword",match:a(/@/,t(...g))},{
-className:"meta",match:a(/@/,w)}],U={match:n(/\b[A-Z]/),relevance:0,contains:[{
-className:"type",
-match:a(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,f,"+")
-},{className:"type",match:y,relevance:0},{match:/[?!]+/,relevance:0},{
-match:/\.\.\./,relevance:0},{match:a(/\s+&\s+/,n(y)),relevance:0}]},Z={
-begin:/</,end:/>/,keywords:D,contains:[...v,...B,...q,M,U]};U.contains.push(Z)
-;const G={begin:/\(/,end:/\)/,relevance:0,keywords:D,contains:["self",{
-match:a(w,/\s*:/),keywords:"_|0",relevance:0
-},...v,...B,...k,...S,I,K,...z,...q,U]},H={beginKeywords:"func",contains:[{
-className:"title",match:t(j.match,w,b),endsParent:!0,relevance:0},p]},R={
-begin:/</,end:/>/,contains:[...v,U]},V={begin:/\(/,end:/\)/,keywords:D,
-contains:[{begin:t(n(a(w,/\s*:/)),n(a(w,/\s+/,w,/\s*:/))),end:/:/,relevance:0,
-contains:[{className:"keyword",match:/\b_\b/},{className:"params",match:w}]
-},...v,...B,...S,I,K,...q,U,G],endsParent:!0,illegal:/["']/},W={
-className:"function",match:n(/\bfunc\b/),contains:[H,R,V,p],illegal:[/\[/,/%/]
-},X={className:"function",match:/\b(subscript|init[?!]?)\s*(?=[<(])/,keywords:{
-keyword:"subscript init init? init!",$pattern:/\w+[?!]?/},contains:[R,V,p],
-illegal:/\[|%/},J={beginKeywords:"operator",end:e.MATCH_NOTHING_RE,contains:[{
-className:"title",match:b,endsParent:!0,relevance:0}]},Q={
-beginKeywords:"precedencegroup",end:e.MATCH_NOTHING_RE,contains:[{
-className:"title",match:y,relevance:0},{begin:/{/,end:/}/,relevance:0,
-endsParent:!0,keywords:[...l,...o],contains:[U]}]};for(const e of K.variants){
-const n=e.contains.find((e=>"interpol"===e.label));n.keywords=D
-;const a=[...B,...k,...S,I,K,...z];n.contains=[...a,{begin:/\(/,end:/\)/,
-contains:["self",...a]}]}return{name:"Swift",keywords:D,contains:[...v,W,X,{
-className:"class",beginKeywords:"struct protocol class extension enum",
-end:"\\{",excludeEnd:!0,keywords:D,contains:[e.inherit(e.TITLE_MODE,{
-begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...B]},J,Q,{
-beginKeywords:"import",end:/$/,contains:[...v],relevance:0
-},...B,...k,...S,I,K,...z,...q,U,G]}}})());
-hljs.registerLanguage("taggerscript",(()=>{"use strict";return e=>({
-name:"Tagger Script",contains:[{className:"comment",begin:/\$noop\(/,end:/\)/,
-contains:[{begin:/\(/,end:/\)/,contains:["self",{begin:/\\./}]}],relevance:10},{
-className:"keyword",begin:/\$(?!noop)[a-zA-Z][_a-zA-Z0-9]*/,end:/\(/,
-excludeEnd:!0},{className:"variable",begin:/%[_a-zA-Z0-9:]*/,end:"%"},{
-className:"symbol",begin:/\\./}]})})());
-hljs.registerLanguage("yaml",(()=>{"use strict";return e=>{
-var n="true false yes no null",a="[\\w#;/?:@&=+$,.~*'()[\\]]+",s={
-className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/
-},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",
-variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(s,{
-variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),l={
-end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},t={begin:/\{/,
-end:/\}/,contains:[l],illegal:"\\n",relevance:0},g={begin:"\\[",end:"\\]",
-contains:[l],illegal:"\\n",relevance:0},b=[{className:"attr",variants:[{
-begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{
-begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$",
-relevance:10},{className:"string",
-begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{
-begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,
-relevance:0},{className:"type",begin:"!\\w+!"+a},{className:"type",
-begin:"!<"+a+">"},{className:"type",begin:"!"+a},{className:"type",begin:"!!"+a
-},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta",
-begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",
-relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{
-className:"number",
-begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"
-},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},t,g,s],r=[...b]
-;return r.pop(),r.push(i),l.contains=r,{name:"YAML",case_insensitive:!0,
-aliases:["yml"],contains:b}}})());
-hljs.registerLanguage("tap",(()=>{"use strict";return e=>({
-name:"Test Anything Protocol",case_insensitive:!0,
-contains:[e.HASH_COMMENT_MODE,{className:"meta",variants:[{
-begin:"^TAP version (\\d+)$"},{begin:"^1\\.\\.(\\d+)$"}]},{begin:/---$/,
-end:"\\.\\.\\.$",subLanguage:"yaml",relevance:0},{className:"number",
-begin:" (\\d+) "},{className:"symbol",variants:[{begin:"^ok"},{begin:"^not ok"}]
-}]})})());
-hljs.registerLanguage("tcl",(()=>{"use strict";function e(...e){
-return e.map((e=>{return(a=e)?"string"==typeof a?a:a.source:null;var a
-})).join("")}return a=>{const t=/[a-zA-Z_][a-zA-Z0-9_]*/,r={className:"number",
-variants:[a.BINARY_NUMBER_MODE,a.C_NUMBER_MODE]};return{name:"Tcl",
-aliases:["tk"],
-keywords:"after append apply array auto_execok auto_import auto_load auto_mkindex auto_mkindex_old auto_qualify auto_reset bgerror binary break catch cd chan clock close concat continue dde dict encoding eof error eval exec exit expr fblocked fconfigure fcopy file fileevent filename flush for foreach format gets glob global history http if incr info interp join lappend|10 lassign|10 lindex|10 linsert|10 list llength|10 load lrange|10 lrepeat|10 lreplace|10 lreverse|10 lsearch|10 lset|10 lsort|10 mathfunc mathop memory msgcat namespace open package parray pid pkg::create pkg_mkIndex platform platform::shell proc puts pwd read refchan regexp registry regsub|10 rename return safe scan seek set socket source split string subst switch tcl_endOfWord tcl_findLibrary tcl_startOfNextWord tcl_startOfPreviousWord tcl_wordBreakAfter tcl_wordBreakBefore tcltest tclvars tell time tm trace unknown unload unset update uplevel upvar variable vwait while",
-contains:[a.COMMENT(";[ \\t]*#","$"),a.COMMENT("^[ \\t]*#","$"),{
-beginKeywords:"proc",end:"[\\{]",excludeEnd:!0,contains:[{className:"title",
-begin:"[ \\t\\n\\r]+(::)?[a-zA-Z_]((::)?[a-zA-Z0-9_])*",end:"[ \\t\\n\\r]",
-endsWithParent:!0,excludeEnd:!0}]},{className:"variable",variants:[{
-begin:e(/\$/,(n=/::/,e("(",n,")?")),t,"(::",t,")*")},{
-begin:"\\$\\{(::)?[a-zA-Z_]((::)?[a-zA-Z0-9_])*",end:"\\}",contains:[r]}]},{
-className:"string",contains:[a.BACKSLASH_ESCAPE],
-variants:[a.inherit(a.QUOTE_STRING_MODE,{illegal:null})]},r]};var n}})());
-hljs.registerLanguage("thrift",(()=>{"use strict";return e=>{
-const t="bool byte i16 i32 i64 double string binary";return{name:"Thrift",
-keywords:{
-keyword:"namespace const typedef struct enum service exception void oneway set list map required optional",
-built_in:t,literal:"true false"},
-contains:[e.QUOTE_STRING_MODE,e.NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{
-className:"class",beginKeywords:"struct enum service exception",end:/\{/,
-illegal:/\n/,contains:[e.inherit(e.TITLE_MODE,{starts:{endsWithParent:!0,
-excludeEnd:!0}})]},{begin:"\\b(set|list|map)\\s*<",end:">",keywords:t,
-contains:["self"]}]}}})());
-hljs.registerLanguage("tp",(()=>{"use strict";return O=>{const e={
-className:"number",begin:"[1-9][0-9]*",relevance:0},R={className:"symbol",
-begin:":[^\\]]+"};return{name:"TP",keywords:{
-keyword:"ABORT ACC ADJUST AND AP_LD BREAK CALL CNT COL CONDITION CONFIG DA DB DIV DETECT ELSE END ENDFOR ERR_NUM ERROR_PROG FINE FOR GP GUARD INC IF JMP LINEAR_MAX_SPEED LOCK MOD MONITOR OFFSET Offset OR OVERRIDE PAUSE PREG PTH RT_LD RUN SELECT SKIP Skip TA TB TO TOOL_OFFSET Tool_Offset UF UT UFRAME_NUM UTOOL_NUM UNLOCK WAIT X Y Z W P R STRLEN SUBSTR FINDSTR VOFFSET PROG ATTR MN POS",
-literal:"ON OFF max_speed LPOS JPOS ENABLE DISABLE START STOP RESET"},
-contains:[{className:"built_in",
-begin:"(AR|P|PAYLOAD|PR|R|SR|RSR|LBL|VR|UALM|MESSAGE|UTOOL|UFRAME|TIMER|TIMER_OVERFLOW|JOINT_MAX_SPEED|RESUME_PROG|DIAG_REC)\\[",
-end:"\\]",contains:["self",e,R]},{className:"built_in",
-begin:"(AI|AO|DI|DO|F|RI|RO|UI|UO|GI|GO|SI|SO)\\[",end:"\\]",
-contains:["self",e,O.QUOTE_STRING_MODE,R]},{className:"keyword",
-begin:"/(PROG|ATTR|MN|POS|END)\\b"},{className:"keyword",
-begin:"(CALL|RUN|POINT_LOGIC|LBL)\\b"},{className:"keyword",
-begin:"\\b(ACC|CNT|Skip|Offset|PSPD|RT_LD|AP_LD|Tool_Offset)"},{
-className:"number",
-begin:"\\d+(sec|msec|mm/sec|cm/min|inch/min|deg/sec|mm|in|cm)?\\b",relevance:0
-},O.COMMENT("//","[;$]"),O.COMMENT("!","[;$]"),O.COMMENT("--eg:","$"),O.QUOTE_STRING_MODE,{
-className:"string",begin:"'",end:"'"},O.C_NUMBER_MODE,{className:"variable",
-begin:"\\$[A-Za-z0-9_]+"}]}}})());
-hljs.registerLanguage("twig",(()=>{"use strict";return e=>{
-var a="attribute block constant cycle date dump include max min parent random range source template_from_string",n={
-beginKeywords:a,keywords:{name:a},relevance:0,contains:[{className:"params",
-begin:"\\(",end:"\\)"}]},t={begin:/\|[A-Za-z_]+:?/,
-keywords:"abs batch capitalize column convert_encoding date date_modify default escape filter first format inky_to_html inline_css join json_encode keys last length lower map markdown merge nl2br number_format raw reduce replace reverse round slice sort spaceless split striptags title trim upper url_encode",
-contains:[n]
-},s="apply autoescape block deprecated do embed extends filter flush for from if import include macro sandbox set use verbatim with"
-;return s=s+" "+s.split(" ").map((e=>"end"+e)).join(" "),{name:"Twig",
-aliases:["craftcms"],case_insensitive:!0,subLanguage:"xml",
-contains:[e.COMMENT(/\{#/,/#\}/),{className:"template-tag",begin:/\{%/,
-end:/%\}/,contains:[{className:"name",begin:/\w+/,keywords:s,starts:{
-endsWithParent:!0,contains:[t,n],relevance:0}}]},{className:"template-variable",
-begin:/\{\{/,end:/\}\}/,contains:["self",t,n]}]}}})());
-hljs.registerLanguage("typescript",(()=>{"use strict"
-;const e="[A-Za-z$_][0-9A-Za-z$_]*",n=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],a=["true","false","null","undefined","NaN","Infinity"],s=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"])
-;function t(e){return r("(?=",e,")")}function r(...e){return e.map((e=>{
-return(n=e)?"string"==typeof n?n:n.source:null;var n})).join("")}return i=>{
-const c={$pattern:e,
-keyword:n.concat(["type","namespace","typedef","interface","public","private","protected","implements","declare","abstract","readonly"]),
-literal:a,
-built_in:s.concat(["any","void","number","boolean","string","object","never","enum"])
-},o={className:"meta",begin:"@[A-Za-z$_][0-9A-Za-z$_]*"},l=(e,n,a)=>{
-const s=e.contains.findIndex((e=>e.label===n))
-;if(-1===s)throw Error("can not find mode to replace");e.contains.splice(s,1,a)
-},b=(i=>{const c=e,o={begin:/<[A-Za-z0-9\\._:-]+/,
-end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{
-const a=e[0].length+e.index,s=e.input[a];"<"!==s?">"===s&&(((e,{after:n})=>{
-const a="</"+e[0].slice(1);return-1!==e.input.indexOf(a,n)})(e,{after:a
-})||n.ignoreMatch()):n.ignoreMatch()}},l={$pattern:e,keyword:n,literal:a,
-built_in:s},b="\\.([0-9](_?[0-9])*)",d="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",g={
-className:"number",variants:[{
-begin:`(\\b(${d})((${b})|\\.)?|(${b}))[eE][+-]?([0-9](_?[0-9])*)\\b`},{
-begin:`\\b(${d})\\b((${b})\\b|\\.)?|(${b})\\b`},{
-begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{
-begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{
-begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{
-begin:"\\b0[0-7]+n?\\b"}],relevance:0},u={className:"subst",begin:"\\$\\{",
-end:"\\}",keywords:l,contains:[]},E={begin:"html`",end:"",starts:{end:"`",
-returnEnd:!1,contains:[i.BACKSLASH_ESCAPE,u],subLanguage:"xml"}},m={
-begin:"css`",end:"",starts:{end:"`",returnEnd:!1,
-contains:[i.BACKSLASH_ESCAPE,u],subLanguage:"css"}},y={className:"string",
-begin:"`",end:"`",contains:[i.BACKSLASH_ESCAPE,u]},_={className:"comment",
-variants:[i.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{
-className:"doctag",begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{",
-end:"\\}",relevance:0},{className:"variable",begin:c+"(?=\\s*(-)|$)",
-endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}]
-}),i.C_BLOCK_COMMENT_MODE,i.C_LINE_COMMENT_MODE]
-},p=[i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,E,m,y,g,i.REGEXP_MODE]
-;u.contains=p.concat({begin:/\{/,end:/\}/,keywords:l,contains:["self"].concat(p)
-});const N=[].concat(_,u.contains),f=N.concat([{begin:/\(/,end:/\)/,keywords:l,
-contains:["self"].concat(N)}]),A={className:"params",begin:/\(/,end:/\)/,
-excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f};return{name:"Javascript",
-aliases:["js","jsx","mjs","cjs"],keywords:l,exports:{PARAMS_CONTAINS:f},
-illegal:/#(?![$_A-z])/,contains:[i.SHEBANG({label:"shebang",binary:"node",
-relevance:5}),{label:"use_strict",className:"meta",relevance:10,
-begin:/^\s*['"]use (strict|asm)['"]/
-},i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,E,m,y,_,g,{
-begin:r(/[{,\n]\s*/,t(r(/(((\/\/.*$)|(\/\*(\*[^/]|[^*])*\*\/))\s*)*/,c+"\\s*:"))),
-relevance:0,contains:[{className:"attr",begin:c+t("\\s*:"),relevance:0}]},{
-begin:"("+i.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",
-keywords:"return throw case",contains:[_,i.REGEXP_MODE,{className:"function",
-begin:"(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+i.UNDERSCORE_IDENT_RE+")\\s*=>",
-returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{
-begin:i.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0
-},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f}]}]
-},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{
-variants:[{begin:"<>",end:"</>"},{begin:o.begin,"on:begin":o.isTrulyOpeningTag,
-end:o.end}],subLanguage:"xml",contains:[{begin:o.begin,end:o.end,skip:!0,
-contains:["self"]}]}],relevance:0},{className:"function",
-beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:l,
-contains:["self",i.inherit(i.TITLE_MODE,{begin:c}),A],illegal:/%/},{
-beginKeywords:"while if switch catch for"},{className:"function",
-begin:i.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",
-returnBegin:!0,contains:[A,i.inherit(i.TITLE_MODE,{begin:c})]},{variants:[{
-begin:"\\."+c},{begin:"\\$"+c}],relevance:0},{className:"class",
-beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{
-beginKeywords:"extends"},i.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/,
-end:/[{;]/,excludeEnd:!0,contains:[i.inherit(i.TITLE_MODE,{begin:c}),"self",A]
-},{begin:"(get|set)\\s+(?="+c+"\\()",end:/\{/,keywords:"get set",
-contains:[i.inherit(i.TITLE_MODE,{begin:c}),{begin:/\(\)/},A]},{begin:/\$[(.]/}]
-}})(i)
-;return Object.assign(b.keywords,c),b.exports.PARAMS_CONTAINS.push(o),b.contains=b.contains.concat([o,{
-beginKeywords:"namespace",end:/\{/,excludeEnd:!0},{beginKeywords:"interface",
-end:/\{/,excludeEnd:!0,keywords:"interface extends"
-}]),l(b,"shebang",i.SHEBANG()),l(b,"use_strict",{className:"meta",relevance:10,
-begin:/^\s*['"]use strict['"]/
-}),b.contains.find((e=>"function"===e.className)).relevance=0,Object.assign(b,{
-name:"TypeScript",aliases:["ts","tsx"]}),b}})());
-hljs.registerLanguage("vala",(()=>{"use strict";return e=>({name:"Vala",
-keywords:{
-keyword:"char uchar unichar int uint long ulong short ushort int8 int16 int32 int64 uint8 uint16 uint32 uint64 float double bool struct enum string void weak unowned owned async signal static abstract interface override virtual delegate if while do for foreach else switch case break default return try catch public private protected internal using new this get set const stdout stdin stderr var",
-built_in:"DBus GLib CCode Gee Object Gtk Posix",literal:"false true null"},
-contains:[{className:"class",beginKeywords:"class interface namespace",end:/\{/,
-excludeEnd:!0,illegal:"[^,:\\n\\s\\.]",contains:[e.UNDERSCORE_TITLE_MODE]
-},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string",begin:'"""',
-end:'"""',relevance:5},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,e.C_NUMBER_MODE,{
-className:"meta",begin:"^#",end:"$",relevance:2}]})})());
-hljs.registerLanguage("vbnet",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function n(...n){
-return n.map((n=>e(n))).join("")}function t(...n){
-return"("+n.map((n=>e(n))).join("|")+")"}return e=>{
-const a=/\d{1,2}\/\d{1,2}\/\d{4}/,i=/\d{4}-\d{1,2}-\d{1,2}/,s=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,o={
-className:"literal",variants:[{begin:n(/# */,t(i,a),/ *#/)},{
-begin:n(/# */,r,/ *#/)},{begin:n(/# */,s,/ *#/)},{
-begin:n(/# */,t(i,a),/ +/,t(s,r),/ *#/)}]},l=e.COMMENT(/'''/,/$/,{contains:[{
-className:"doctag",begin:/<\/?/,end:/>/}]}),c=e.COMMENT(null,/$/,{variants:[{
-begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]});return{name:"Visual Basic .NET",
-aliases:["vb"],case_insensitive:!0,classNameAliases:{label:"symbol"},keywords:{
-keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield",
-built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort",
-type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort",
-literal:"true false nothing"},
-illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{
-className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/,
-end:/"/,illegal:/\n/,contains:[{begin:/""/}]},o,{className:"number",relevance:0,
-variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/
-},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{
-begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{
-className:"label",begin:/^\w+:/},l,c,{className:"meta",
-begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/,
-end:/$/,keywords:{
-"meta-keyword":"const disable else elseif enable end externalsource if region then"
-},contains:[c]}]}}})());
-hljs.registerLanguage("vbscript",(()=>{"use strict";function e(e){
-return e?"string"==typeof e?e:e.source:null}function t(...t){
-return t.map((t=>e(t))).join("")}function r(...t){
-return"("+t.map((t=>e(t))).join("|")+")"}return e=>{
-const i="lcase month vartype instrrev ubound setlocale getobject rgb getref string weekdayname rnd dateadd monthname now day minute isarray cbool round formatcurrency conversions csng timevalue second year space abs clng timeserial fixs len asc isempty maths dateserial atn timer isobject filter weekday datevalue ccur isdate instr datediff formatdatetime replace isnull right sgn array snumeric log cdbl hex chr lbound msgbox ucase getlocale cos cdate cbyte rtrim join hour oct typename trim strcomp int createobject loadpicture tan formatnumber mid split  cint sin datepart ltrim sqr time derived eval date formatpercent exp inputbox left ascw chrw regexp cstr err".split(" ")
-;return{name:"VBScript",aliases:["vbs"],case_insensitive:!0,keywords:{
-keyword:"call class const dim do loop erase execute executeglobal exit for each next function if then else on error option explicit new private property let get public randomize redim rem select case set stop sub while wend with end to elseif is or xor and not class_initialize class_terminate default preserve in me byval byref step resume goto",
-built_in:["server","response","request","scriptengine","scriptenginebuildversion","scriptengineminorversion","scriptenginemajorversion"],
-literal:"true false null nothing empty"},illegal:"//",contains:[{
-begin:t(r(...i),"\\s*\\("),relevance:0,keywords:{built_in:i}
-},e.inherit(e.QUOTE_STRING_MODE,{contains:[{begin:'""'}]}),e.COMMENT(/'/,/$/,{
-relevance:0}),e.C_NUMBER_MODE]}}})());
-hljs.registerLanguage("vbscript-html",(()=>{"use strict";return e=>({
-name:"VBScript in HTML",subLanguage:"xml",contains:[{begin:"<%",end:"%>",
-subLanguage:"vbscript"}]})})());
-hljs.registerLanguage("verilog",(()=>{"use strict";return e=>({name:"Verilog",
-aliases:["v","sv","svh"],case_insensitive:!1,keywords:{$pattern:/[\w\$]+/,
-keyword:"accept_on alias always always_comb always_ff always_latch and assert assign assume automatic before begin bind bins binsof bit break buf|0 bufif0 bufif1 byte case casex casez cell chandle checker class clocking cmos config const constraint context continue cover covergroup coverpoint cross deassign default defparam design disable dist do edge else end endcase endchecker endclass endclocking endconfig endfunction endgenerate endgroup endinterface endmodule endpackage endprimitive endprogram endproperty endspecify endsequence endtable endtask enum event eventually expect export extends extern final first_match for force foreach forever fork forkjoin function generate|5 genvar global highz0 highz1 if iff ifnone ignore_bins illegal_bins implements implies import incdir include initial inout input inside instance int integer interconnect interface intersect join join_any join_none large let liblist library local localparam logic longint macromodule matches medium modport module nand negedge nettype new nexttime nmos nor noshowcancelled not notif0 notif1 or output package packed parameter pmos posedge primitive priority program property protected pull0 pull1 pulldown pullup pulsestyle_ondetect pulsestyle_onevent pure rand randc randcase randsequence rcmos real realtime ref reg reject_on release repeat restrict return rnmos rpmos rtran rtranif0 rtranif1 s_always s_eventually s_nexttime s_until s_until_with scalared sequence shortint shortreal showcancelled signed small soft solve specify specparam static string strong strong0 strong1 struct super supply0 supply1 sync_accept_on sync_reject_on table tagged task this throughout time timeprecision timeunit tran tranif0 tranif1 tri tri0 tri1 triand trior trireg type typedef union unique unique0 unsigned until until_with untyped use uwire var vectored virtual void wait wait_order wand weak weak0 weak1 while wildcard wire with within wor xnor xor",
-literal:"null",
-built_in:"$finish $stop $exit $fatal $error $warning $info $realtime $time $printtimescale $bitstoreal $bitstoshortreal $itor $signed $cast $bits $stime $timeformat $realtobits $shortrealtobits $rtoi $unsigned $asserton $assertkill $assertpasson $assertfailon $assertnonvacuouson $assertoff $assertcontrol $assertpassoff $assertfailoff $assertvacuousoff $isunbounded $sampled $fell $changed $past_gclk $fell_gclk $changed_gclk $rising_gclk $steady_gclk $coverage_control $coverage_get $coverage_save $set_coverage_db_name $rose $stable $past $rose_gclk $stable_gclk $future_gclk $falling_gclk $changing_gclk $display $coverage_get_max $coverage_merge $get_coverage $load_coverage_db $typename $unpacked_dimensions $left $low $increment $clog2 $ln $log10 $exp $sqrt $pow $floor $ceil $sin $cos $tan $countbits $onehot $isunknown $fatal $warning $dimensions $right $high $size $asin $acos $atan $atan2 $hypot $sinh $cosh $tanh $asinh $acosh $atanh $countones $onehot0 $error $info $random $dist_chi_square $dist_erlang $dist_exponential $dist_normal $dist_poisson $dist_t $dist_uniform $q_initialize $q_remove $q_exam $async$and$array $async$nand$array $async$or$array $async$nor$array $sync$and$array $sync$nand$array $sync$or$array $sync$nor$array $q_add $q_full $psprintf $async$and$plane $async$nand$plane $async$or$plane $async$nor$plane $sync$and$plane $sync$nand$plane $sync$or$plane $sync$nor$plane $system $display $displayb $displayh $displayo $strobe $strobeb $strobeh $strobeo $write $readmemb $readmemh $writememh $value$plusargs $dumpvars $dumpon $dumplimit $dumpports $dumpportson $dumpportslimit $writeb $writeh $writeo $monitor $monitorb $monitorh $monitoro $writememb $dumpfile $dumpoff $dumpall $dumpflush $dumpportsoff $dumpportsall $dumpportsflush $fclose $fdisplay $fdisplayb $fdisplayh $fdisplayo $fstrobe $fstrobeb $fstrobeh $fstrobeo $swrite $swriteb $swriteh $swriteo $fscanf $fread $fseek $fflush $feof $fopen $fwrite $fwriteb $fwriteh $fwriteo $fmonitor $fmonitorb $fmonitorh $fmonitoro $sformat $sformatf $fgetc $ungetc $fgets $sscanf $rewind $ftell $ferror"
-},contains:[e.C_BLOCK_COMMENT_MODE,e.C_LINE_COMMENT_MODE,e.QUOTE_STRING_MODE,{
-className:"number",contains:[e.BACKSLASH_ESCAPE],variants:[{
-begin:"\\b((\\d+'(b|h|o|d|B|H|O|D))[0-9xzXZa-fA-F_]+)"},{
-begin:"\\B(('(b|h|o|d|B|H|O|D))[0-9xzXZa-fA-F_]+)"},{begin:"\\b([0-9_])+",
-relevance:0}]},{className:"variable",variants:[{begin:"#\\((?!parameter).+\\)"
-},{begin:"\\.\\w+",relevance:0}]},{className:"meta",begin:"`",end:"$",keywords:{
-"meta-keyword":"define __FILE__ __LINE__ begin_keywords celldefine default_nettype define else elsif end_keywords endcelldefine endif ifdef ifndef include line nounconnected_drive pragma resetall timescale unconnected_drive undef undefineall"
-},relevance:0}]})})());
-hljs.registerLanguage("vhdl",(()=>{"use strict";return e=>({name:"VHDL",
-case_insensitive:!0,keywords:{
-keyword:"abs access after alias all and architecture array assert assume assume_guarantee attribute begin block body buffer bus case component configuration constant context cover disconnect downto default else elsif end entity exit fairness file for force function generate generic group guarded if impure in inertial inout is label library linkage literal loop map mod nand new next nor not null of on open or others out package parameter port postponed procedure process property protected pure range record register reject release rem report restrict restrict_guarantee return rol ror select sequence severity shared signal sla sll sra srl strong subtype then to transport type unaffected units until use variable view vmode vprop vunit wait when while with xnor xor",
-built_in:"boolean bit character integer time delay_length natural positive string bit_vector file_open_kind file_open_status std_logic std_logic_vector unsigned signed boolean_vector integer_vector std_ulogic std_ulogic_vector unresolved_unsigned u_unsigned unresolved_signed u_signed real_vector time_vector",
-literal:"false true note warning error failure line text side width"},
-illegal:/\{/,
-contains:[e.C_BLOCK_COMMENT_MODE,e.COMMENT("--","$"),e.QUOTE_STRING_MODE,{
-className:"number",
-begin:"\\b(\\d(_|\\d)*#\\w+(\\.\\w+)?#([eE][-+]?\\d(_|\\d)*)?|\\d(_|\\d)*(\\.\\d(_|\\d)*)?([eE][-+]?\\d(_|\\d)*)?)",
-relevance:0},{className:"string",begin:"'(U|X|0|1|Z|W|L|H|-)'",
-contains:[e.BACKSLASH_ESCAPE]},{className:"symbol",
-begin:"'[A-Za-z](_?[A-Za-z0-9])*",contains:[e.BACKSLASH_ESCAPE]}]})})());
-hljs.registerLanguage("vim",(()=>{"use strict";return e=>({name:"Vim Script",
-keywords:{$pattern:/[!#@\w]+/,
-keyword:"N|0 P|0 X|0 a|0 ab abc abo al am an|0 ar arga argd arge argdo argg argl argu as au aug aun b|0 bN ba bad bd be bel bf bl bm bn bo bp br brea breaka breakd breakl bro bufdo buffers bun bw c|0 cN cNf ca cabc caddb cad caddf cal cat cb cc ccl cd ce cex cf cfir cgetb cgete cg changes chd che checkt cl cla clo cm cmapc cme cn cnew cnf cno cnorea cnoreme co col colo com comc comp con conf cope cp cpf cq cr cs cst cu cuna cunme cw delm deb debugg delc delf dif diffg diffo diffp diffpu diffs diffthis dig di dl dell dj dli do doautoa dp dr ds dsp e|0 ea ec echoe echoh echom echon el elsei em en endfo endf endt endw ene ex exe exi exu f|0 files filet fin fina fini fir fix fo foldc foldd folddoc foldo for fu go gr grepa gu gv ha helpf helpg helpt hi hid his ia iabc if ij il im imapc ime ino inorea inoreme int is isp iu iuna iunme j|0 ju k|0 keepa kee keepj lN lNf l|0 lad laddb laddf la lan lat lb lc lch lcl lcs le lefta let lex lf lfir lgetb lgete lg lgr lgrepa lh ll lla lli lmak lm lmapc lne lnew lnf ln loadk lo loc lockv lol lope lp lpf lr ls lt lu lua luad luaf lv lvimgrepa lw m|0 ma mak map mapc marks mat me menut mes mk mks mksp mkv mkvie mod mz mzf nbc nb nbs new nm nmapc nme nn nnoreme noa no noh norea noreme norm nu nun nunme ol o|0 om omapc ome on ono onoreme opt ou ounme ow p|0 profd prof pro promptr pc ped pe perld po popu pp pre prev ps pt ptN ptf ptj ptl ptn ptp ptr pts pu pw py3 python3 py3d py3f py pyd pyf quita qa rec red redi redr redraws reg res ret retu rew ri rightb rub rubyd rubyf rund ru rv sN san sa sal sav sb sbN sba sbf sbl sbm sbn sbp sbr scrip scripte scs se setf setg setl sf sfir sh sim sig sil sl sla sm smap smapc sme sn sni sno snor snoreme sor so spelld spe spelli spellr spellu spellw sp spr sre st sta startg startr star stopi stj sts sun sunm sunme sus sv sw sy synti sync tN tabN tabc tabdo tabe tabf tabfir tabl tabm tabnew tabn tabo tabp tabr tabs tab ta tags tc tcld tclf te tf th tj tl tm tn to tp tr try ts tu u|0 undoj undol una unh unl unlo unm unme uns up ve verb vert vim vimgrepa vi viu vie vm vmapc vme vne vn vnoreme vs vu vunme windo w|0 wN wa wh wi winc winp wn wp wq wqa ws wu wv x|0 xa xmapc xm xme xn xnoreme xu xunme y|0 z|0 ~ Next Print append abbreviate abclear aboveleft all amenu anoremenu args argadd argdelete argedit argglobal arglocal argument ascii autocmd augroup aunmenu buffer bNext ball badd bdelete behave belowright bfirst blast bmodified bnext botright bprevious brewind break breakadd breakdel breaklist browse bunload bwipeout change cNext cNfile cabbrev cabclear caddbuffer caddexpr caddfile call catch cbuffer cclose center cexpr cfile cfirst cgetbuffer cgetexpr cgetfile chdir checkpath checktime clist clast close cmap cmapclear cmenu cnext cnewer cnfile cnoremap cnoreabbrev cnoremenu copy colder colorscheme command comclear compiler continue confirm copen cprevious cpfile cquit crewind cscope cstag cunmap cunabbrev cunmenu cwindow delete delmarks debug debuggreedy delcommand delfunction diffupdate diffget diffoff diffpatch diffput diffsplit digraphs display deletel djump dlist doautocmd doautoall deletep drop dsearch dsplit edit earlier echo echoerr echohl echomsg else elseif emenu endif endfor endfunction endtry endwhile enew execute exit exusage file filetype find finally finish first fixdel fold foldclose folddoopen folddoclosed foldopen function global goto grep grepadd gui gvim hardcopy help helpfind helpgrep helptags highlight hide history insert iabbrev iabclear ijump ilist imap imapclear imenu inoremap inoreabbrev inoremenu intro isearch isplit iunmap iunabbrev iunmenu join jumps keepalt keepmarks keepjumps lNext lNfile list laddexpr laddbuffer laddfile last language later lbuffer lcd lchdir lclose lcscope left leftabove lexpr lfile lfirst lgetbuffer lgetexpr lgetfile lgrep lgrepadd lhelpgrep llast llist lmake lmap lmapclear lnext lnewer lnfile lnoremap loadkeymap loadview lockmarks lockvar lolder lopen lprevious lpfile lrewind ltag lunmap luado luafile lvimgrep lvimgrepadd lwindow move mark make mapclear match menu menutranslate messages mkexrc mksession mkspell mkvimrc mkview mode mzscheme mzfile nbclose nbkey nbsart next nmap nmapclear nmenu nnoremap nnoremenu noautocmd noremap nohlsearch noreabbrev noremenu normal number nunmap nunmenu oldfiles open omap omapclear omenu only onoremap onoremenu options ounmap ounmenu ownsyntax print profdel profile promptfind promptrepl pclose pedit perl perldo pop popup ppop preserve previous psearch ptag ptNext ptfirst ptjump ptlast ptnext ptprevious ptrewind ptselect put pwd py3do py3file python pydo pyfile quit quitall qall read recover redo redir redraw redrawstatus registers resize retab return rewind right rightbelow ruby rubydo rubyfile rundo runtime rviminfo substitute sNext sandbox sargument sall saveas sbuffer sbNext sball sbfirst sblast sbmodified sbnext sbprevious sbrewind scriptnames scriptencoding scscope set setfiletype setglobal setlocal sfind sfirst shell simalt sign silent sleep slast smagic smapclear smenu snext sniff snomagic snoremap snoremenu sort source spelldump spellgood spellinfo spellrepall spellundo spellwrong split sprevious srewind stop stag startgreplace startreplace startinsert stopinsert stjump stselect sunhide sunmap sunmenu suspend sview swapname syntax syntime syncbind tNext tabNext tabclose tabedit tabfind tabfirst tablast tabmove tabnext tabonly tabprevious tabrewind tag tcl tcldo tclfile tearoff tfirst throw tjump tlast tmenu tnext topleft tprevious trewind tselect tunmenu undo undojoin undolist unabbreviate unhide unlet unlockvar unmap unmenu unsilent update vglobal version verbose vertical vimgrep vimgrepadd visual viusage view vmap vmapclear vmenu vnew vnoremap vnoremenu vsplit vunmap vunmenu write wNext wall while winsize wincmd winpos wnext wprevious wqall wsverb wundo wviminfo xit xall xmapclear xmap xmenu xnoremap xnoremenu xunmap xunmenu yank",
-built_in:"synIDtrans atan2 range matcharg did_filetype asin feedkeys xor argv complete_check add getwinposx getqflist getwinposy screencol clearmatches empty extend getcmdpos mzeval garbagecollect setreg ceil sqrt diff_hlID inputsecret get getfperm getpid filewritable shiftwidth max sinh isdirectory synID system inputrestore winline atan visualmode inputlist tabpagewinnr round getregtype mapcheck hasmapto histdel argidx findfile sha256 exists toupper getcmdline taglist string getmatches bufnr strftime winwidth bufexists strtrans tabpagebuflist setcmdpos remote_read printf setloclist getpos getline bufwinnr float2nr len getcmdtype diff_filler luaeval resolve libcallnr foldclosedend reverse filter has_key bufname str2float strlen setline getcharmod setbufvar index searchpos shellescape undofile foldclosed setqflist buflisted strchars str2nr virtcol floor remove undotree remote_expr winheight gettabwinvar reltime cursor tabpagenr finddir localtime acos getloclist search tanh matchend rename gettabvar strdisplaywidth type abs py3eval setwinvar tolower wildmenumode log10 spellsuggest bufloaded synconcealed nextnonblank server2client complete settabwinvar executable input wincol setmatches getftype hlID inputsave searchpair or screenrow line settabvar histadd deepcopy strpart remote_peek and eval getftime submatch screenchar winsaveview matchadd mkdir screenattr getfontname libcall reltimestr getfsize winnr invert pow getbufline byte2line soundfold repeat fnameescape tagfiles sin strwidth spellbadword trunc maparg log lispindent hostname setpos globpath remote_foreground getchar synIDattr fnamemodify cscope_connection stridx winbufnr indent min complete_add nr2char searchpairpos inputdialog values matchlist items hlexists strridx browsedir expand fmod pathshorten line2byte argc count getwinvar glob foldtextresult getreg foreground cosh matchdelete has char2nr simplify histget searchdecl iconv winrestcmd pumvisible writefile foldlevel haslocaldir keys cos matchstr foldtext histnr tan tempname getcwd byteidx getbufvar islocked escape eventhandler remote_send serverlist winrestview synstack pyeval prevnonblank readfile cindent filereadable changenr exp"
-},illegal:/;/,contains:[e.NUMBER_MODE,{className:"string",begin:"'",end:"'",
-illegal:"\\n"},{className:"string",begin:/"(\\"|\n\\|[^"\n])*"/
-},e.COMMENT('"',"$"),{className:"variable",begin:/[bwtglsav]:[\w\d_]*/},{
-className:"function",beginKeywords:"function function!",end:"$",relevance:0,
-contains:[e.TITLE_MODE,{className:"params",begin:"\\(",end:"\\)"}]},{
-className:"symbol",begin:/<[\w-]+>/}]})})());
-hljs.registerLanguage("x86asm",(()=>{"use strict";return s=>({
-name:"Intel x86 Assembly",case_insensitive:!0,keywords:{
-$pattern:"[.%]?"+s.IDENT_RE,
-keyword:"lock rep repe repz repne repnz xaquire xrelease bnd nobnd aaa aad aam aas adc add and arpl bb0_reset bb1_reset bound bsf bsr bswap bt btc btr bts call cbw cdq cdqe clc cld cli clts cmc cmp cmpsb cmpsd cmpsq cmpsw cmpxchg cmpxchg486 cmpxchg8b cmpxchg16b cpuid cpu_read cpu_write cqo cwd cwde daa das dec div dmint emms enter equ f2xm1 fabs fadd faddp fbld fbstp fchs fclex fcmovb fcmovbe fcmove fcmovnb fcmovnbe fcmovne fcmovnu fcmovu fcom fcomi fcomip fcomp fcompp fcos fdecstp fdisi fdiv fdivp fdivr fdivrp femms feni ffree ffreep fiadd ficom ficomp fidiv fidivr fild fimul fincstp finit fist fistp fisttp fisub fisubr fld fld1 fldcw fldenv fldl2e fldl2t fldlg2 fldln2 fldpi fldz fmul fmulp fnclex fndisi fneni fninit fnop fnsave fnstcw fnstenv fnstsw fpatan fprem fprem1 fptan frndint frstor fsave fscale fsetpm fsin fsincos fsqrt fst fstcw fstenv fstp fstsw fsub fsubp fsubr fsubrp ftst fucom fucomi fucomip fucomp fucompp fxam fxch fxtract fyl2x fyl2xp1 hlt ibts icebp idiv imul in inc incbin insb insd insw int int01 int1 int03 int3 into invd invpcid invlpg invlpga iret iretd iretq iretw jcxz jecxz jrcxz jmp jmpe lahf lar lds lea leave les lfence lfs lgdt lgs lidt lldt lmsw loadall loadall286 lodsb lodsd lodsq lodsw loop loope loopne loopnz loopz lsl lss ltr mfence monitor mov movd movq movsb movsd movsq movsw movsx movsxd movzx mul mwait neg nop not or out outsb outsd outsw packssdw packsswb packuswb paddb paddd paddsb paddsiw paddsw paddusb paddusw paddw pand pandn pause paveb pavgusb pcmpeqb pcmpeqd pcmpeqw pcmpgtb pcmpgtd pcmpgtw pdistib pf2id pfacc pfadd pfcmpeq pfcmpge pfcmpgt pfmax pfmin pfmul pfrcp pfrcpit1 pfrcpit2 pfrsqit1 pfrsqrt pfsub pfsubr pi2fd pmachriw pmaddwd pmagw pmulhriw pmulhrwa pmulhrwc pmulhw pmullw pmvgezb pmvlzb pmvnzb pmvzb pop popa popad popaw popf popfd popfq popfw por prefetch prefetchw pslld psllq psllw psrad psraw psrld psrlq psrlw psubb psubd psubsb psubsiw psubsw psubusb psubusw psubw punpckhbw punpckhdq punpckhwd punpcklbw punpckldq punpcklwd push pusha pushad pushaw pushf pushfd pushfq pushfw pxor rcl rcr rdshr rdmsr rdpmc rdtsc rdtscp ret retf retn rol ror rdm rsdc rsldt rsm rsts sahf sal salc sar sbb scasb scasd scasq scasw sfence sgdt shl shld shr shrd sidt sldt skinit smi smint smintold smsw stc std sti stosb stosd stosq stosw str sub svdc svldt svts swapgs syscall sysenter sysexit sysret test ud0 ud1 ud2b ud2 ud2a umov verr verw fwait wbinvd wrshr wrmsr xadd xbts xchg xlatb xlat xor cmove cmovz cmovne cmovnz cmova cmovnbe cmovae cmovnb cmovb cmovnae cmovbe cmovna cmovg cmovnle cmovge cmovnl cmovl cmovnge cmovle cmovng cmovc cmovnc cmovo cmovno cmovs cmovns cmovp cmovpe cmovnp cmovpo je jz jne jnz ja jnbe jae jnb jb jnae jbe jna jg jnle jge jnl jl jnge jle jng jc jnc jo jno js jns jpo jnp jpe jp sete setz setne setnz seta setnbe setae setnb setnc setb setnae setcset setbe setna setg setnle setge setnl setl setnge setle setng sets setns seto setno setpe setp setpo setnp addps addss andnps andps cmpeqps cmpeqss cmpleps cmpless cmpltps cmpltss cmpneqps cmpneqss cmpnleps cmpnless cmpnltps cmpnltss cmpordps cmpordss cmpunordps cmpunordss cmpps cmpss comiss cvtpi2ps cvtps2pi cvtsi2ss cvtss2si cvttps2pi cvttss2si divps divss ldmxcsr maxps maxss minps minss movaps movhps movlhps movlps movhlps movmskps movntps movss movups mulps mulss orps rcpps rcpss rsqrtps rsqrtss shufps sqrtps sqrtss stmxcsr subps subss ucomiss unpckhps unpcklps xorps fxrstor fxrstor64 fxsave fxsave64 xgetbv xsetbv xsave xsave64 xsaveopt xsaveopt64 xrstor xrstor64 prefetchnta prefetcht0 prefetcht1 prefetcht2 maskmovq movntq pavgb pavgw pextrw pinsrw pmaxsw pmaxub pminsw pminub pmovmskb pmulhuw psadbw pshufw pf2iw pfnacc pfpnacc pi2fw pswapd maskmovdqu clflush movntdq movnti movntpd movdqa movdqu movdq2q movq2dq paddq pmuludq pshufd pshufhw pshuflw pslldq psrldq psubq punpckhqdq punpcklqdq addpd addsd andnpd andpd cmpeqpd cmpeqsd cmplepd cmplesd cmpltpd cmpltsd cmpneqpd cmpneqsd cmpnlepd cmpnlesd cmpnltpd cmpnltsd cmpordpd cmpordsd cmpunordpd cmpunordsd cmppd comisd cvtdq2pd cvtdq2ps cvtpd2dq cvtpd2pi cvtpd2ps cvtpi2pd cvtps2dq cvtps2pd cvtsd2si cvtsd2ss cvtsi2sd cvtss2sd cvttpd2pi cvttpd2dq cvttps2dq cvttsd2si divpd divsd maxpd maxsd minpd minsd movapd movhpd movlpd movmskpd movupd mulpd mulsd orpd shufpd sqrtpd sqrtsd subpd subsd ucomisd unpckhpd unpcklpd xorpd addsubpd addsubps haddpd haddps hsubpd hsubps lddqu movddup movshdup movsldup clgi stgi vmcall vmclear vmfunc vmlaunch vmload vmmcall vmptrld vmptrst vmread vmresume vmrun vmsave vmwrite vmxoff vmxon invept invvpid pabsb pabsw pabsd palignr phaddw phaddd phaddsw phsubw phsubd phsubsw pmaddubsw pmulhrsw pshufb psignb psignw psignd extrq insertq movntsd movntss lzcnt blendpd blendps blendvpd blendvps dppd dpps extractps insertps movntdqa mpsadbw packusdw pblendvb pblendw pcmpeqq pextrb pextrd pextrq phminposuw pinsrb pinsrd pinsrq pmaxsb pmaxsd pmaxud pmaxuw pminsb pminsd pminud pminuw pmovsxbw pmovsxbd pmovsxbq pmovsxwd pmovsxwq pmovsxdq pmovzxbw pmovzxbd pmovzxbq pmovzxwd pmovzxwq pmovzxdq pmuldq pmulld ptest roundpd roundps roundsd roundss crc32 pcmpestri pcmpestrm pcmpistri pcmpistrm pcmpgtq popcnt getsec pfrcpv pfrsqrtv movbe aesenc aesenclast aesdec aesdeclast aesimc aeskeygenassist vaesenc vaesenclast vaesdec vaesdeclast vaesimc vaeskeygenassist vaddpd vaddps vaddsd vaddss vaddsubpd vaddsubps vandpd vandps vandnpd vandnps vblendpd vblendps vblendvpd vblendvps vbroadcastss vbroadcastsd vbroadcastf128 vcmpeq_ospd vcmpeqpd vcmplt_ospd vcmpltpd vcmple_ospd vcmplepd vcmpunord_qpd vcmpunordpd vcmpneq_uqpd vcmpneqpd vcmpnlt_uspd vcmpnltpd vcmpnle_uspd vcmpnlepd vcmpord_qpd vcmpordpd vcmpeq_uqpd vcmpnge_uspd vcmpngepd vcmpngt_uspd vcmpngtpd vcmpfalse_oqpd vcmpfalsepd vcmpneq_oqpd vcmpge_ospd vcmpgepd vcmpgt_ospd vcmpgtpd vcmptrue_uqpd vcmptruepd vcmplt_oqpd vcmple_oqpd vcmpunord_spd vcmpneq_uspd vcmpnlt_uqpd vcmpnle_uqpd vcmpord_spd vcmpeq_uspd vcmpnge_uqpd vcmpngt_uqpd vcmpfalse_ospd vcmpneq_ospd vcmpge_oqpd vcmpgt_oqpd vcmptrue_uspd vcmppd vcmpeq_osps vcmpeqps vcmplt_osps vcmpltps vcmple_osps vcmpleps vcmpunord_qps vcmpunordps vcmpneq_uqps vcmpneqps vcmpnlt_usps vcmpnltps vcmpnle_usps vcmpnleps vcmpord_qps vcmpordps vcmpeq_uqps vcmpnge_usps vcmpngeps vcmpngt_usps vcmpngtps vcmpfalse_oqps vcmpfalseps vcmpneq_oqps vcmpge_osps vcmpgeps vcmpgt_osps vcmpgtps vcmptrue_uqps vcmptrueps vcmplt_oqps vcmple_oqps vcmpunord_sps vcmpneq_usps vcmpnlt_uqps vcmpnle_uqps vcmpord_sps vcmpeq_usps vcmpnge_uqps vcmpngt_uqps vcmpfalse_osps vcmpneq_osps vcmpge_oqps vcmpgt_oqps vcmptrue_usps vcmpps vcmpeq_ossd vcmpeqsd vcmplt_ossd vcmpltsd vcmple_ossd vcmplesd vcmpunord_qsd vcmpunordsd vcmpneq_uqsd vcmpneqsd vcmpnlt_ussd vcmpnltsd vcmpnle_ussd vcmpnlesd vcmpord_qsd vcmpordsd vcmpeq_uqsd vcmpnge_ussd vcmpngesd vcmpngt_ussd vcmpngtsd vcmpfalse_oqsd vcmpfalsesd vcmpneq_oqsd vcmpge_ossd vcmpgesd vcmpgt_ossd vcmpgtsd vcmptrue_uqsd vcmptruesd vcmplt_oqsd vcmple_oqsd vcmpunord_ssd vcmpneq_ussd vcmpnlt_uqsd vcmpnle_uqsd vcmpord_ssd vcmpeq_ussd vcmpnge_uqsd vcmpngt_uqsd vcmpfalse_ossd vcmpneq_ossd vcmpge_oqsd vcmpgt_oqsd vcmptrue_ussd vcmpsd vcmpeq_osss vcmpeqss vcmplt_osss vcmpltss vcmple_osss vcmpless vcmpunord_qss vcmpunordss vcmpneq_uqss vcmpneqss vcmpnlt_usss vcmpnltss vcmpnle_usss vcmpnless vcmpord_qss vcmpordss vcmpeq_uqss vcmpnge_usss vcmpngess vcmpngt_usss vcmpngtss vcmpfalse_oqss vcmpfalsess vcmpneq_oqss vcmpge_osss vcmpgess vcmpgt_osss vcmpgtss vcmptrue_uqss vcmptruess vcmplt_oqss vcmple_oqss vcmpunord_sss vcmpneq_usss vcmpnlt_uqss vcmpnle_uqss vcmpord_sss vcmpeq_usss vcmpnge_uqss vcmpngt_uqss vcmpfalse_osss vcmpneq_osss vcmpge_oqss vcmpgt_oqss vcmptrue_usss vcmpss vcomisd vcomiss vcvtdq2pd vcvtdq2ps vcvtpd2dq vcvtpd2ps vcvtps2dq vcvtps2pd vcvtsd2si vcvtsd2ss vcvtsi2sd vcvtsi2ss vcvtss2sd vcvtss2si vcvttpd2dq vcvttps2dq vcvttsd2si vcvttss2si vdivpd vdivps vdivsd vdivss vdppd vdpps vextractf128 vextractps vhaddpd vhaddps vhsubpd vhsubps vinsertf128 vinsertps vlddqu vldqqu vldmxcsr vmaskmovdqu vmaskmovps vmaskmovpd vmaxpd vmaxps vmaxsd vmaxss vminpd vminps vminsd vminss vmovapd vmovaps vmovd vmovq vmovddup vmovdqa vmovqqa vmovdqu vmovqqu vmovhlps vmovhpd vmovhps vmovlhps vmovlpd vmovlps vmovmskpd vmovmskps vmovntdq vmovntqq vmovntdqa vmovntpd vmovntps vmovsd vmovshdup vmovsldup vmovss vmovupd vmovups vmpsadbw vmulpd vmulps vmulsd vmulss vorpd vorps vpabsb vpabsw vpabsd vpacksswb vpackssdw vpackuswb vpackusdw vpaddb vpaddw vpaddd vpaddq vpaddsb vpaddsw vpaddusb vpaddusw vpalignr vpand vpandn vpavgb vpavgw vpblendvb vpblendw vpcmpestri vpcmpestrm vpcmpistri vpcmpistrm vpcmpeqb vpcmpeqw vpcmpeqd vpcmpeqq vpcmpgtb vpcmpgtw vpcmpgtd vpcmpgtq vpermilpd vpermilps vperm2f128 vpextrb vpextrw vpextrd vpextrq vphaddw vphaddd vphaddsw vphminposuw vphsubw vphsubd vphsubsw vpinsrb vpinsrw vpinsrd vpinsrq vpmaddwd vpmaddubsw vpmaxsb vpmaxsw vpmaxsd vpmaxub vpmaxuw vpmaxud vpminsb vpminsw vpminsd vpminub vpminuw vpminud vpmovmskb vpmovsxbw vpmovsxbd vpmovsxbq vpmovsxwd vpmovsxwq vpmovsxdq vpmovzxbw vpmovzxbd vpmovzxbq vpmovzxwd vpmovzxwq vpmovzxdq vpmulhuw vpmulhrsw vpmulhw vpmullw vpmulld vpmuludq vpmuldq vpor vpsadbw vpshufb vpshufd vpshufhw vpshuflw vpsignb vpsignw vpsignd vpslldq vpsrldq vpsllw vpslld vpsllq vpsraw vpsrad vpsrlw vpsrld vpsrlq vptest vpsubb vpsubw vpsubd vpsubq vpsubsb vpsubsw vpsubusb vpsubusw vpunpckhbw vpunpckhwd vpunpckhdq vpunpckhqdq vpunpcklbw vpunpcklwd vpunpckldq vpunpcklqdq vpxor vrcpps vrcpss vrsqrtps vrsqrtss vroundpd vroundps vroundsd vroundss vshufpd vshufps vsqrtpd vsqrtps vsqrtsd vsqrtss vstmxcsr vsubpd vsubps vsubsd vsubss vtestps vtestpd vucomisd vucomiss vunpckhpd vunpckhps vunpcklpd vunpcklps vxorpd vxorps vzeroall vzeroupper pclmullqlqdq pclmulhqlqdq pclmullqhqdq pclmulhqhqdq pclmulqdq vpclmullqlqdq vpclmulhqlqdq vpclmullqhqdq vpclmulhqhqdq vpclmulqdq vfmadd132ps vfmadd132pd vfmadd312ps vfmadd312pd vfmadd213ps vfmadd213pd vfmadd123ps vfmadd123pd vfmadd231ps vfmadd231pd vfmadd321ps vfmadd321pd vfmaddsub132ps vfmaddsub132pd vfmaddsub312ps vfmaddsub312pd vfmaddsub213ps vfmaddsub213pd vfmaddsub123ps vfmaddsub123pd vfmaddsub231ps vfmaddsub231pd vfmaddsub321ps vfmaddsub321pd vfmsub132ps vfmsub132pd vfmsub312ps vfmsub312pd vfmsub213ps vfmsub213pd vfmsub123ps vfmsub123pd vfmsub231ps vfmsub231pd vfmsub321ps vfmsub321pd vfmsubadd132ps vfmsubadd132pd vfmsubadd312ps vfmsubadd312pd vfmsubadd213ps vfmsubadd213pd vfmsubadd123ps vfmsubadd123pd vfmsubadd231ps vfmsubadd231pd vfmsubadd321ps vfmsubadd321pd vfnmadd132ps vfnmadd132pd vfnmadd312ps vfnmadd312pd vfnmadd213ps vfnmadd213pd vfnmadd123ps vfnmadd123pd vfnmadd231ps vfnmadd231pd vfnmadd321ps vfnmadd321pd vfnmsub132ps vfnmsub132pd vfnmsub312ps vfnmsub312pd vfnmsub213ps vfnmsub213pd vfnmsub123ps vfnmsub123pd vfnmsub231ps vfnmsub231pd vfnmsub321ps vfnmsub321pd vfmadd132ss vfmadd132sd vfmadd312ss vfmadd312sd vfmadd213ss vfmadd213sd vfmadd123ss vfmadd123sd vfmadd231ss vfmadd231sd vfmadd321ss vfmadd321sd vfmsub132ss vfmsub132sd vfmsub312ss vfmsub312sd vfmsub213ss vfmsub213sd vfmsub123ss vfmsub123sd vfmsub231ss vfmsub231sd vfmsub321ss vfmsub321sd vfnmadd132ss vfnmadd132sd vfnmadd312ss vfnmadd312sd vfnmadd213ss vfnmadd213sd vfnmadd123ss vfnmadd123sd vfnmadd231ss vfnmadd231sd vfnmadd321ss vfnmadd321sd vfnmsub132ss vfnmsub132sd vfnmsub312ss vfnmsub312sd vfnmsub213ss vfnmsub213sd vfnmsub123ss vfnmsub123sd vfnmsub231ss vfnmsub231sd vfnmsub321ss vfnmsub321sd rdfsbase rdgsbase rdrand wrfsbase wrgsbase vcvtph2ps vcvtps2ph adcx adox rdseed clac stac xstore xcryptecb xcryptcbc xcryptctr xcryptcfb xcryptofb montmul xsha1 xsha256 llwpcb slwpcb lwpval lwpins vfmaddpd vfmaddps vfmaddsd vfmaddss vfmaddsubpd vfmaddsubps vfmsubaddpd vfmsubaddps vfmsubpd vfmsubps vfmsubsd vfmsubss vfnmaddpd vfnmaddps vfnmaddsd vfnmaddss vfnmsubpd vfnmsubps vfnmsubsd vfnmsubss vfrczpd vfrczps vfrczsd vfrczss vpcmov vpcomb vpcomd vpcomq vpcomub vpcomud vpcomuq vpcomuw vpcomw vphaddbd vphaddbq vphaddbw vphadddq vphaddubd vphaddubq vphaddubw vphaddudq vphadduwd vphadduwq vphaddwd vphaddwq vphsubbw vphsubdq vphsubwd vpmacsdd vpmacsdqh vpmacsdql vpmacssdd vpmacssdqh vpmacssdql vpmacsswd vpmacssww vpmacswd vpmacsww vpmadcsswd vpmadcswd vpperm vprotb vprotd vprotq vprotw vpshab vpshad vpshaq vpshaw vpshlb vpshld vpshlq vpshlw vbroadcasti128 vpblendd vpbroadcastb vpbroadcastw vpbroadcastd vpbroadcastq vpermd vpermpd vpermps vpermq vperm2i128 vextracti128 vinserti128 vpmaskmovd vpmaskmovq vpsllvd vpsllvq vpsravd vpsrlvd vpsrlvq vgatherdpd vgatherqpd vgatherdps vgatherqps vpgatherdd vpgatherqd vpgatherdq vpgatherqq xabort xbegin xend xtest andn bextr blci blcic blsi blsic blcfill blsfill blcmsk blsmsk blsr blcs bzhi mulx pdep pext rorx sarx shlx shrx tzcnt tzmsk t1mskc valignd valignq vblendmpd vblendmps vbroadcastf32x4 vbroadcastf64x4 vbroadcasti32x4 vbroadcasti64x4 vcompresspd vcompressps vcvtpd2udq vcvtps2udq vcvtsd2usi vcvtss2usi vcvttpd2udq vcvttps2udq vcvttsd2usi vcvttss2usi vcvtudq2pd vcvtudq2ps vcvtusi2sd vcvtusi2ss vexpandpd vexpandps vextractf32x4 vextractf64x4 vextracti32x4 vextracti64x4 vfixupimmpd vfixupimmps vfixupimmsd vfixupimmss vgetexppd vgetexpps vgetexpsd vgetexpss vgetmantpd vgetmantps vgetmantsd vgetmantss vinsertf32x4 vinsertf64x4 vinserti32x4 vinserti64x4 vmovdqa32 vmovdqa64 vmovdqu32 vmovdqu64 vpabsq vpandd vpandnd vpandnq vpandq vpblendmd vpblendmq vpcmpltd vpcmpled vpcmpneqd vpcmpnltd vpcmpnled vpcmpd vpcmpltq vpcmpleq vpcmpneqq vpcmpnltq vpcmpnleq vpcmpq vpcmpequd vpcmpltud vpcmpleud vpcmpnequd vpcmpnltud vpcmpnleud vpcmpud vpcmpequq vpcmpltuq vpcmpleuq vpcmpnequq vpcmpnltuq vpcmpnleuq vpcmpuq vpcompressd vpcompressq vpermi2d vpermi2pd vpermi2ps vpermi2q vpermt2d vpermt2pd vpermt2ps vpermt2q vpexpandd vpexpandq vpmaxsq vpmaxuq vpminsq vpminuq vpmovdb vpmovdw vpmovqb vpmovqd vpmovqw vpmovsdb vpmovsdw vpmovsqb vpmovsqd vpmovsqw vpmovusdb vpmovusdw vpmovusqb vpmovusqd vpmovusqw vpord vporq vprold vprolq vprolvd vprolvq vprord vprorq vprorvd vprorvq vpscatterdd vpscatterdq vpscatterqd vpscatterqq vpsraq vpsravq vpternlogd vpternlogq vptestmd vptestmq vptestnmd vptestnmq vpxord vpxorq vrcp14pd vrcp14ps vrcp14sd vrcp14ss vrndscalepd vrndscaleps vrndscalesd vrndscaless vrsqrt14pd vrsqrt14ps vrsqrt14sd vrsqrt14ss vscalefpd vscalefps vscalefsd vscalefss vscatterdpd vscatterdps vscatterqpd vscatterqps vshuff32x4 vshuff64x2 vshufi32x4 vshufi64x2 kandnw kandw kmovw knotw kortestw korw kshiftlw kshiftrw kunpckbw kxnorw kxorw vpbroadcastmb2q vpbroadcastmw2d vpconflictd vpconflictq vplzcntd vplzcntq vexp2pd vexp2ps vrcp28pd vrcp28ps vrcp28sd vrcp28ss vrsqrt28pd vrsqrt28ps vrsqrt28sd vrsqrt28ss vgatherpf0dpd vgatherpf0dps vgatherpf0qpd vgatherpf0qps vgatherpf1dpd vgatherpf1dps vgatherpf1qpd vgatherpf1qps vscatterpf0dpd vscatterpf0dps vscatterpf0qpd vscatterpf0qps vscatterpf1dpd vscatterpf1dps vscatterpf1qpd vscatterpf1qps prefetchwt1 bndmk bndcl bndcu bndcn bndmov bndldx bndstx sha1rnds4 sha1nexte sha1msg1 sha1msg2 sha256rnds2 sha256msg1 sha256msg2 hint_nop0 hint_nop1 hint_nop2 hint_nop3 hint_nop4 hint_nop5 hint_nop6 hint_nop7 hint_nop8 hint_nop9 hint_nop10 hint_nop11 hint_nop12 hint_nop13 hint_nop14 hint_nop15 hint_nop16 hint_nop17 hint_nop18 hint_nop19 hint_nop20 hint_nop21 hint_nop22 hint_nop23 hint_nop24 hint_nop25 hint_nop26 hint_nop27 hint_nop28 hint_nop29 hint_nop30 hint_nop31 hint_nop32 hint_nop33 hint_nop34 hint_nop35 hint_nop36 hint_nop37 hint_nop38 hint_nop39 hint_nop40 hint_nop41 hint_nop42 hint_nop43 hint_nop44 hint_nop45 hint_nop46 hint_nop47 hint_nop48 hint_nop49 hint_nop50 hint_nop51 hint_nop52 hint_nop53 hint_nop54 hint_nop55 hint_nop56 hint_nop57 hint_nop58 hint_nop59 hint_nop60 hint_nop61 hint_nop62 hint_nop63",
-built_in:"ip eip rip al ah bl bh cl ch dl dh sil dil bpl spl r8b r9b r10b r11b r12b r13b r14b r15b ax bx cx dx si di bp sp r8w r9w r10w r11w r12w r13w r14w r15w eax ebx ecx edx esi edi ebp esp eip r8d r9d r10d r11d r12d r13d r14d r15d rax rbx rcx rdx rsi rdi rbp rsp r8 r9 r10 r11 r12 r13 r14 r15 cs ds es fs gs ss st st0 st1 st2 st3 st4 st5 st6 st7 mm0 mm1 mm2 mm3 mm4 mm5 mm6 mm7 xmm0  xmm1  xmm2  xmm3  xmm4  xmm5  xmm6  xmm7  xmm8  xmm9 xmm10  xmm11 xmm12 xmm13 xmm14 xmm15 xmm16 xmm17 xmm18 xmm19 xmm20 xmm21 xmm22 xmm23 xmm24 xmm25 xmm26 xmm27 xmm28 xmm29 xmm30 xmm31 ymm0  ymm1  ymm2  ymm3  ymm4  ymm5  ymm6  ymm7  ymm8  ymm9 ymm10  ymm11 ymm12 ymm13 ymm14 ymm15 ymm16 ymm17 ymm18 ymm19 ymm20 ymm21 ymm22 ymm23 ymm24 ymm25 ymm26 ymm27 ymm28 ymm29 ymm30 ymm31 zmm0  zmm1  zmm2  zmm3  zmm4  zmm5  zmm6  zmm7  zmm8  zmm9 zmm10  zmm11 zmm12 zmm13 zmm14 zmm15 zmm16 zmm17 zmm18 zmm19 zmm20 zmm21 zmm22 zmm23 zmm24 zmm25 zmm26 zmm27 zmm28 zmm29 zmm30 zmm31 k0 k1 k2 k3 k4 k5 k6 k7 bnd0 bnd1 bnd2 bnd3 cr0 cr1 cr2 cr3 cr4 cr8 dr0 dr1 dr2 dr3 dr8 tr3 tr4 tr5 tr6 tr7 r0 r1 r2 r3 r4 r5 r6 r7 r0b r1b r2b r3b r4b r5b r6b r7b r0w r1w r2w r3w r4w r5w r6w r7w r0d r1d r2d r3d r4d r5d r6d r7d r0h r1h r2h r3h r0l r1l r2l r3l r4l r5l r6l r7l r8l r9l r10l r11l r12l r13l r14l r15l db dw dd dq dt ddq do dy dz resb resw resd resq rest resdq reso resy resz incbin equ times byte word dword qword nosplit rel abs seg wrt strict near far a32 ptr",
-meta:"%define %xdefine %+ %undef %defstr %deftok %assign %strcat %strlen %substr %rotate %elif %else %endif %if %ifmacro %ifctx %ifidn %ifidni %ifid %ifnum %ifstr %iftoken %ifempty %ifenv %error %warning %fatal %rep %endrep %include %push %pop %repl %pathsearch %depend %use %arg %stacksize %local %line %comment %endcomment .nolist __FILE__ __LINE__ __SECT__  __BITS__ __OUTPUT_FORMAT__ __DATE__ __TIME__ __DATE_NUM__ __TIME_NUM__ __UTC_DATE__ __UTC_TIME__ __UTC_DATE_NUM__ __UTC_TIME_NUM__  __PASS__ struc endstruc istruc at iend align alignb sectalign daz nodaz up down zero default option assume public bits use16 use32 use64 default section segment absolute extern global common cpu float __utf16__ __utf16le__ __utf16be__ __utf32__ __utf32le__ __utf32be__ __float8__ __float16__ __float32__ __float64__ __float80m__ __float80e__ __float128l__ __float128h__ __Infinity__ __QNaN__ __SNaN__ Inf NaN QNaN SNaN float8 float16 float32 float64 float80m float80e float128l float128h __FLOAT_DAZ__ __FLOAT_ROUND__ __FLOAT__"
-},contains:[s.COMMENT(";","$",{relevance:0}),{className:"number",variants:[{
-begin:"\\b(?:([0-9][0-9_]*)?\\.[0-9_]*(?:[eE][+-]?[0-9_]+)?|(0[Xx])?[0-9][0-9_]*(\\.[0-9_]*)?(?:[pP](?:[+-]?[0-9_]+)?)?)\\b",
-relevance:0},{begin:"\\$[0-9][0-9A-Fa-f]*",relevance:0},{
-begin:"\\b(?:[0-9A-Fa-f][0-9A-Fa-f_]*[Hh]|[0-9][0-9_]*[DdTt]?|[0-7][0-7_]*[QqOo]|[0-1][0-1_]*[BbYy])\\b"
-},{
-begin:"\\b(?:0[Xx][0-9A-Fa-f_]+|0[DdTt][0-9_]+|0[QqOo][0-7_]+|0[BbYy][0-1_]+)\\b"
-}]},s.QUOTE_STRING_MODE,{className:"string",variants:[{begin:"'",end:"[^\\\\]'"
-},{begin:"`",end:"[^\\\\]`"}],relevance:0},{className:"symbol",variants:[{
-begin:"^\\s*[A-Za-z._?][A-Za-z0-9_$#@~.?]*(:|\\s+label)"},{
-begin:"^\\s*%%[A-Za-z0-9_$#@~.?]*:"}],relevance:0},{className:"subst",
-begin:"%[0-9]+",relevance:0},{className:"subst",begin:"%!S+",relevance:0},{
-className:"meta",begin:/^\s*\.[\w_-]+/}]})})());
-hljs.registerLanguage("xl",(()=>{"use strict";return e=>{const t={
-$pattern:/[a-zA-Z][a-zA-Z0-9_?]*/,
-keyword:"if then else do while until for loop import with is as where when by data constant integer real text name boolean symbol infix prefix postfix block tree",
-literal:"true false nil",
-built_in:"in mod rem and or xor not abs sign floor ceil sqrt sin cos tan asin acos atan exp expm1 log log2 log10 log1p pi at text_length text_range text_find text_replace contains page slide basic_slide title_slide title subtitle fade_in fade_out fade_at clear_color color line_color line_width texture_wrap texture_transform texture scale_?x scale_?y scale_?z? translate_?x translate_?y translate_?z? rotate_?x rotate_?y rotate_?z? rectangle circle ellipse sphere path line_to move_to quad_to curve_to theme background contents locally time mouse_?x mouse_?y mouse_buttons ObjectLoader Animate MovieCredits Slides Filters Shading Materials LensFlare Mapping VLCAudioVideo StereoDecoder PointCloud NetworkAccess RemoteControl RegExp ChromaKey Snowfall NodeJS Speech Charts"
-},a={className:"string",begin:'"',end:'"',illegal:"\\n"},n={
-beginKeywords:"import",end:"$",keywords:t,contains:[a]},o={className:"function",
-begin:/[a-z][^\n]*->/,returnBegin:!0,end:/->/,contains:[e.inherit(e.TITLE_MODE,{
-starts:{endsWithParent:!0,keywords:t}})]};return{name:"XL",aliases:["tao"],
-keywords:t,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,{
-className:"string",begin:"'",end:"'",illegal:"\\n"},{className:"string",
-begin:"<<",end:">>"},o,n,{className:"number",
-begin:"[0-9]+#[0-9A-Z_]+(\\.[0-9-A-Z_]+)?#?([Ee][+-]?[0-9]+)?"},e.NUMBER_MODE]}}
-})());
-hljs.registerLanguage("xquery",(()=>{"use strict";return e=>({name:"XQuery",
-aliases:["xpath","xq"],case_insensitive:!1,
-illegal:/(proc)|(abstract)|(extends)|(until)|(#)/,keywords:{
-$pattern:/[a-zA-Z$][a-zA-Z0-9_:-]*/,
-keyword:"module schema namespace boundary-space preserve no-preserve strip default collation base-uri ordering context decimal-format decimal-separator copy-namespaces empty-sequence except exponent-separator external grouping-separator inherit no-inherit lax minus-sign per-mille percent schema-attribute schema-element strict unordered zero-digit declare import option function validate variable for at in let where order group by return if then else tumbling sliding window start when only end previous next stable ascending descending allowing empty greatest least some every satisfies switch case typeswitch try catch and or to union intersect instance of treat as castable cast map array delete insert into replace value rename copy modify update",
-type:"item document-node node attribute document element comment namespace namespace-node processing-instruction text construction xs:anyAtomicType xs:untypedAtomic xs:duration xs:time xs:decimal xs:float xs:double xs:gYearMonth xs:gYear xs:gMonthDay xs:gMonth xs:gDay xs:boolean xs:base64Binary xs:hexBinary xs:anyURI xs:QName xs:NOTATION xs:dateTime xs:dateTimeStamp xs:date xs:string xs:normalizedString xs:token xs:language xs:NMTOKEN xs:Name xs:NCName xs:ID xs:IDREF xs:ENTITY xs:integer xs:nonPositiveInteger xs:negativeInteger xs:long xs:int xs:short xs:byte xs:nonNegativeInteger xs:unisignedLong xs:unsignedInt xs:unsignedShort xs:unsignedByte xs:positiveInteger xs:yearMonthDuration xs:dayTimeDuration",
-literal:"eq ne lt le gt ge is self:: child:: descendant:: descendant-or-self:: attribute:: following:: following-sibling:: parent:: ancestor:: ancestor-or-self:: preceding:: preceding-sibling:: NaN"
-},contains:[{className:"variable",begin:/[$][\w\-:]+/},{className:"built_in",
-variants:[{begin:/\barray:/,
-end:/(?:append|filter|flatten|fold-(?:left|right)|for-each(?:-pair)?|get|head|insert-before|join|put|remove|reverse|size|sort|subarray|tail)\b/
-},{begin:/\bmap:/,
-end:/(?:contains|entry|find|for-each|get|keys|merge|put|remove|size)\b/},{
-begin:/\bmath:/,
-end:/(?:a(?:cos|sin|tan[2]?)|cos|exp(?:10)?|log(?:10)?|pi|pow|sin|sqrt|tan)\b/
-},{begin:/\bop:/,end:/\(/,excludeEnd:!0},{begin:/\bfn:/,end:/\(/,excludeEnd:!0
-},{
-begin:/[^</$:'"-]\b(?:abs|accumulator-(?:after|before)|adjust-(?:date(?:Time)?|time)-to-timezone|analyze-string|apply|available-(?:environment-variables|system-properties)|avg|base-uri|boolean|ceiling|codepoints?-(?:equal|to-string)|collation-key|collection|compare|concat|contains(?:-token)?|copy-of|count|current(?:-)?(?:date(?:Time)?|time|group(?:ing-key)?|output-uri|merge-(?:group|key))?data|dateTime|days?-from-(?:date(?:Time)?|duration)|deep-equal|default-(?:collation|language)|distinct-values|document(?:-uri)?|doc(?:-available)?|element-(?:available|with-id)|empty|encode-for-uri|ends-with|environment-variable|error|escape-html-uri|exactly-one|exists|false|filter|floor|fold-(?:left|right)|for-each(?:-pair)?|format-(?:date(?:Time)?|time|integer|number)|function-(?:arity|available|lookup|name)|generate-id|has-children|head|hours-from-(?:dateTime|duration|time)|id(?:ref)?|implicit-timezone|in-scope-prefixes|index-of|innermost|insert-before|iri-to-uri|json-(?:doc|to-xml)|key|lang|last|load-xquery-module|local-name(?:-from-QName)?|(?:lower|upper)-case|matches|max|minutes-from-(?:dateTime|duration|time)|min|months?-from-(?:date(?:Time)?|duration)|name(?:space-uri-?(?:for-prefix|from-QName)?)?|nilled|node-name|normalize-(?:space|unicode)|not|number|one-or-more|outermost|parse-(?:ietf-date|json)|path|position|(?:prefix-from-)?QName|random-number-generator|regex-group|remove|replace|resolve-(?:QName|uri)|reverse|root|round(?:-half-to-even)?|seconds-from-(?:dateTime|duration|time)|snapshot|sort|starts-with|static-base-uri|stream-available|string-?(?:join|length|to-codepoints)?|subsequence|substring-?(?:after|before)?|sum|system-property|tail|timezone-from-(?:date(?:Time)?|time)|tokenize|trace|trans(?:form|late)|true|type-available|unordered|unparsed-(?:entity|text)?-?(?:public-id|uri|available|lines)?|uri-collection|xml-to-json|years?-from-(?:date(?:Time)?|duration)|zero-or-one)\b/
-},{begin:/\blocal:/,end:/\(/,excludeEnd:!0},{begin:/\bzip:/,
-end:/(?:zip-file|(?:xml|html|text|binary)-entry| (?:update-)?entries)\b/},{
-begin:/\b(?:util|db|functx|app|xdmp|xmldb):/,end:/\(/,excludeEnd:!0}]},{
-className:"string",variants:[{begin:/"/,end:/"/,contains:[{begin:/""/,
-relevance:0}]},{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]}]},{
-className:"number",
-begin:/(\b0[0-7_]+)|(\b0x[0-9a-fA-F_]+)|(\b[1-9][0-9_]*(\.[0-9_]+)?)|[0_]\b/,
-relevance:0},{className:"comment",begin:/\(:/,end:/:\)/,relevance:10,contains:[{
-className:"doctag",begin:/@\w+/}]},{className:"meta",begin:/%[\w\-:]+/},{
-className:"title",begin:/\bxquery version "[13]\.[01]"\s?(?:encoding ".+")?/,
-end:/;/},{
-beginKeywords:"element attribute comment document processing-instruction",
-end:/\{/,excludeEnd:!0},{begin:/<([\w._:-]+)(\s+\S*=('|").*('|"))?>/,
-end:/(\/[\w._:-]+>)/,subLanguage:"xml",contains:[{begin:/\{/,end:/\}/,
-subLanguage:"xquery"},"self"]}]})})());
-hljs.registerLanguage("zephir",(()=>{"use strict";return e=>{const n={
-className:"string",contains:[e.BACKSLASH_ESCAPE],
-variants:[e.inherit(e.APOS_STRING_MODE,{illegal:null
-}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null})]},s=e.UNDERSCORE_TITLE_MODE,a={
-variants:[e.BINARY_NUMBER_MODE,e.C_NUMBER_MODE]
-},i="namespace class interface use extends function return abstract final public protected private static deprecated throw try catch Exception echo empty isset instanceof unset let var new const self require if else elseif switch case default do while loop for continue break likely unlikely __LINE__ __FILE__ __DIR__ __FUNCTION__ __CLASS__ __TRAIT__ __METHOD__ __NAMESPACE__ array boolean float double integer object resource string char long unsigned bool int uint ulong uchar true false null undefined"
-;return{name:"Zephir",aliases:["zep"],keywords:i,
-contains:[e.C_LINE_COMMENT_MODE,e.COMMENT(/\/\*/,/\*\//,{contains:[{
-className:"doctag",begin:/@[A-Za-z]+/}]}),{className:"string",
-begin:/<<<['"]?\w+['"]?$/,end:/^\w+;/,contains:[e.BACKSLASH_ESCAPE]},{
-begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function",
-beginKeywords:"function fn",end:/[;{]/,excludeEnd:!0,illegal:/\$|\[|%/,
-contains:[s,{className:"params",begin:/\(/,end:/\)/,keywords:i,
-contains:["self",e.C_BLOCK_COMMENT_MODE,n,a]}]},{className:"class",
-beginKeywords:"class interface",end:/\{/,excludeEnd:!0,illegal:/[:($"]/,
-contains:[{beginKeywords:"extends implements"},s]},{beginKeywords:"namespace",
-end:/;/,illegal:/[.']/,contains:[s]},{beginKeywords:"use",end:/;/,contains:[s]
-},{begin:/=>/},n,a]}}})());
\ No newline at end of file
diff --git a/lib/highlightjs/index.js b/lib/highlightjs/index.js
new file mode 100644
index 0000000..c2d048d
--- /dev/null
+++ b/lib/highlightjs/index.js
@@ -0,0 +1,25 @@
+/**
+ * @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 hljs from 'highlight.js';
+import soy from 'highlightjs-closure-templates';
+import iecst from 'highlightjs-structured-text';
+
+hljs.registerLanguage('soy', soy);
+hljs.registerLanguage('iecst', iecst);
+
+export default hljs;
diff --git a/lib/highlightjs/rollup.config.js b/lib/highlightjs/rollup.config.js
new file mode 100644
index 0000000..c3a1340
--- /dev/null
+++ b/lib/highlightjs/rollup.config.js
@@ -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.
+ */
+
+const path = require('path');
+
+// In this file word "plugin" refers to rollup plugin, not Gerrit plugin.
+// By default, require(plugin_name) tries to find module plugin_name starting
+// from the folder where this file (rollup.config.js) is located
+// (see https://www.typescriptlang.org/docs/handbook/module-resolution.html#node
+// and https://nodejs.org/api/modules.html#modules_all_together).
+// So, rollup.config.js can't be in polygerrit-ui/app dir and it should be in
+// tools/node_tools directory (where all plugins are installed).
+// But rollup_bundle rule copy this .config.js file to another directory,
+// so require(plugin_name) can't find a plugin.
+// To fix it, requirePlugin tries:
+// 1. resolve module id using default behavior, i.e. it starts from __dirname
+// 2. if module not found - it tries to resolve module starting from rollupBin
+//    location.
+// This workaround also gives us additional power - we can place .config.js
+// file anywhere in a source tree and add all plugins in the same package.json
+// file as rollup node module.
+function requirePlugin(id) {
+  const rollupBinDir = path.dirname(process.argv[1]);
+  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir] });
+  return require(pluginPath);
+}
+
+const cjs = requirePlugin('rollup-plugin-commonjs');
+const nodeResolve = requirePlugin('rollup-plugin-node-resolve');
+const {terser} = requirePlugin('rollup-plugin-terser');
+
+export default {
+  onwarn: warning => {
+    // No warnings from rollupjs are allowed.
+    // Most of the warnings are real error in our code (for example,
+    // if some import couldn't be resolved we can't continue, but rollup
+    // reports it as a warning)
+    throw new Error(warning.message);
+  },
+  output: {
+    format: 'iife',
+    compact: true,
+    strict: true,
+    exports: 'auto',
+    name: 'hljs',
+    footer: '',
+    plugins: [
+      terser({
+        output: {
+          comments: false
+        },
+        compress: {
+          ecma: 2015,
+          unsafe_arrows: true,
+          passes: 2,
+          unsafe: true,
+          warnings: true,
+          dead_code: true,
+          toplevel: "funcs"
+        }
+      })
+    ]
+  },
+  plugins: [
+    nodeResolve({
+      customResolveOptions: {
+        moduleDirectory: 'external/ui_npm/node_modules'
+      }
+    }),
+    cjs(),
+  ]
+};
diff --git a/lib/httpcomponents/BUILD b/lib/httpcomponents/BUILD
index 07d4bb9..25e09c9 100644
--- a/lib/httpcomponents/BUILD
+++ b/lib/httpcomponents/BUILD
@@ -25,20 +25,3 @@
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@httpcore//jar"],
 )
-
-java_library(
-    name = "httpasyncclient",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = [
-        "//java/com/google/gerrit/elasticsearch:__pkg__",
-        "//javatests/com/google/gerrit/elasticsearch:__pkg__",
-    ],
-    exports = ["@httpasyncclient//jar"],
-)
-
-java_library(
-    name = "httpcore-nio",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//java/com/google/gerrit/elasticsearch:__pkg__"],
-    exports = ["@httpcore-nio//jar"],
-)
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
deleted file mode 100644
index f11b96d..0000000
--- a/lib/jackson/BUILD
+++ /dev/null
@@ -1,20 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-java_library(
-    name = "jackson-annotations",
-    testonly = True,
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@jackson-annotations//jar"],
-)
-
-java_library(
-    name = "jackson-core",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = [
-        "//java/com/google/gerrit/acceptance:__pkg__",
-        "//java/com/google/gerrit/elasticsearch:__pkg__",
-        "//plugins:__pkg__",
-    ],
-    exports = ["@jackson-core//jar"],
-)
diff --git a/lib/js/BUILD b/lib/js/BUILD
index be82540..746a28e 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -4,7 +4,7 @@
 
 js_component(
     name = "highlightjs",
-    srcs = ["//lib/highlightjs:highlight.min.js"],
+    srcs = ["//lib/highlightjs:highlight.min"],
     license = "//lib:LICENSE-highlightjs",
 )
 
@@ -13,6 +13,6 @@
 # double underscore are removed.
 filegroup(
     name = "highlightjs__files",
-    srcs = ["//lib/highlightjs:highlight.min.js"],
+    srcs = ["//lib/highlightjs:highlight.min"],
     data = ["//lib:LICENSE-highlightjs"],
 )
diff --git a/lib/log/BUILD b/lib/log/BUILD
index 4966723..21c4d47 100644
--- a/lib/log/BUILD
+++ b/lib/log/BUILD
@@ -4,7 +4,6 @@
     name = "api",
     data = ["//lib:LICENSE-slf4j"],
     visibility = [
-        "//javatests/com/google/gerrit/elasticsearch:__pkg__",
         "//lib:__pkg__",
         "//plugins:__pkg__",
     ],
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 90d38b0..51c50bf 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -14,11 +14,9 @@
 backward-codecs
 cglib-3_2
 commons-io
-docker-java-api
-docker-java-transport
 dropwizard-core
-duct-tape
 eddsa
+error-prone-annotations
 flogger
 flogger-log4j-backend
 flogger-system-backend
@@ -27,14 +25,15 @@
 guice-assistedinject
 guice-library
 guice-servlet
-httpasyncclient
-httpcore-nio
+hamcrest
+impl-log4j
 j2objc
-jackson-annotations
-jackson-core
+jcl-over-slf4j
 jimfs
-jna
 jruby
+log-api
+log-ext
+log4j
 lucene-analyzers-common
 lucene-core
 lucene-misc
@@ -47,13 +46,11 @@
 sshd-mina
 sshd-osgi
 sshd-sftp
-testcontainers
 truth
 truth-java8-extension
 truth-liteproto-extension
 truth-proto-extension
 tukaani-xz
-visible-assertions
 xerces
 EOF
 
diff --git a/lib/testcontainers/BUILD b/lib/testcontainers/BUILD
deleted file mode 100644
index 693a386..0000000
--- a/lib/testcontainers/BUILD
+++ /dev/null
@@ -1,64 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-java_library(
-    name = "docker-java-api",
-    testonly = True,
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@docker-java-api//jar"],
-)
-
-java_library(
-    name = "docker-java-transport",
-    testonly = True,
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@docker-java-transport//jar"],
-)
-
-java_library(
-    name = "duct-tape",
-    testonly = True,
-    data = ["//lib:LICENSE-testcontainers"],
-    visibility = ["//visibility:public"],
-    exports = ["@duct-tape//jar"],
-)
-
-java_library(
-    name = "visible-assertions",
-    testonly = True,
-    data = ["//lib:LICENSE-testcontainers"],
-    visibility = ["//visibility:public"],
-    exports = ["@visible-assertions//jar"],
-)
-
-java_library(
-    name = "jna",
-    testonly = True,
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@jna//jar"],
-)
-
-java_library(
-    name = "testcontainers",
-    testonly = True,
-    data = ["//lib:LICENSE-testcontainers"],
-    visibility = ["//visibility:public"],
-    exports = ["@testcontainers//jar"],
-    runtime_deps = [
-        ":duct-tape",
-        ":jna",
-        ":visible-assertions",
-        "//lib/log:ext",
-    ],
-)
-
-java_library(
-    name = "testcontainers-elasticsearch",
-    testonly = True,
-    data = ["//lib:LICENSE-testcontainers"],
-    visibility = ["//visibility:public"],
-    exports = ["@testcontainers-elasticsearch//jar"],
-    runtime_deps = [":testcontainers"],
-)
diff --git a/modules/jgit b/modules/jgit
index 1cbfea9..801a56b 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 1cbfea9ece03b40669377a7f858218f6994562ea
+Subproject commit 801a56b48a7fe3c6e171073211cc62194184fe79
diff --git a/package.json b/package.json
index a492055..ae1bb2f 100644
--- a/package.json
+++ b/package.json
@@ -3,52 +3,63 @@
   "version": "3.1.0-SNAPSHOT",
   "description": "Gerrit Code Review",
   "dependencies": {
-    "@bazel/rollup": "^3.5.0",
-    "@bazel/terser": "^3.5.0",
-    "@bazel/typescript": "^3.5.0",
-    "twinkie": "^1.1.3"
+    "@bazel/concatjs": "^5.5.0",
+    "@bazel/rollup": "^5.5.0",
+    "@bazel/terser": "^5.5.0",
+    "@bazel/typescript": "^5.5.0"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^4.29.0",
-    "eslint": "^7.24.0",
+    "@koa/cors": "^3.3.0",
+    "@types/page": "^1.11.5",
+    "@typescript-eslint/eslint-plugin": "^5.27.0",
+    "@web/dev-server": "^0.1.33",
+    "@web/dev-server-esbuild": "^0.3.2",
+    "eslint": "^8.16.0",
     "eslint-config-google": "^0.14.0",
-    "eslint-plugin-html": "^6.1.2",
-    "eslint-plugin-import": "^2.22.1",
-    "eslint-plugin-jsdoc": "^32.3.0",
-    "eslint-plugin-lit": "^1.5.1",
+    "eslint-plugin-html": "^6.2.0",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-jsdoc": "^39.6.4",
+    "eslint-plugin-lit": "^1.6.1",
     "eslint-plugin-node": "^11.1.0",
-    "eslint-plugin-prettier": "^3.4.0",
-    "eslint-plugin-regex": "^1.8.0",
+    "eslint-plugin-prettier": "^4.0.0",
+    "eslint-plugin-regex": "^1.9.0",
     "gts": "^3.1.0",
     "lit-analyzer": "^1.2.1",
-    "prettier": "2.3.1",
+    "npm-run-all": "^4.1.5",
+    "prettier": "2.6.2",
     "rollup": "^2.45.2",
     "terser": "^5.6.1",
     "ts-lit-plugin": "^1.2.1",
-    "typescript": "4.3.2"
+    "typescript": "^4.7.2"
   },
   "scripts": {
+    "setup": "yarn && yarn --cwd=polygerrit-ui && yarn --cwd=polygerrit-ui/app",
     "clean": "git clean -fdx && bazel clean --expunge",
     "compile:local": "tsc --project ./polygerrit-ui/app/tsconfig.json",
     "compile:watch": "npm run compile:local -- --preserveWatchOutput --watch",
-    "start": "polygerrit-ui/run-server.sh",
-    "test": "npm run safe_bazelisk test //polygerrit-ui:karma_test -- --test_verbose_timeout_warnings --test_output=all",
+    "start": "run-p -rl compile:watch start:server",
+    "start:server": "web-dev-server",
+    "test": "yarn --cwd=polygerrit-ui test",
+    "test:screenshot": "yarn --cwd=polygerrit-ui test:screenshot",
+    "test:screenshot-update": "yarn --cwd=polygerrit-ui test:screenshot-update",
+    "test:browsers": "yarn --cwd=polygerrit-ui test:browsers",
+    "test:coverage": "yarn --cwd=polygerrit-ui test:coverage",
+    "test:watch": "yarn --cwd=polygerrit-ui test:watch",
+    "test:single": "yarn --cwd=polygerrit-ui test:single",
+    "test:single:coverage": "yarn --cwd=polygerrit-ui test:single:coverage",
     "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
     "eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
-    "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
-    "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --browsers ChromeDev --no-single-run --test-files",
-    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --test-files",
-    "polylint": "npm run safe_bazelisk test //polygerrit-ui/app:polylint_test",
-    "polylint:dev": "rm -rf ./polygerrit-ui/app/tmpl_out && npm run safe_bazelisk build //polygerrit-ui/app:template_test_tar && mkdir ./polygerrit-ui/app/tmpl_out && tar -xf bazel-bin/polygerrit-ui/app/template_test_tar.tar -C ./polygerrit-ui/app/tmpl_out"
+    "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis"
   },
   "repository": {
     "type": "git",
     "url": "https://gerrit.googlesource.com/gerrit"
   },
   "resolutions": {
-    "lodash": "4.17.21",
-    "twinkie/typescript": "4.3.2"
+    "eslint": "^8.16.0",
+    "@typescript-eslint/eslint-plugin": "^5.27.0",
+    "@typescript-eslint/parser": "^5.27.0"
   },
   "author": "",
   "license": "Apache-2.0"
diff --git a/plugins/BUILD b/plugins/BUILD
index 0e5df2c..39560c5 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -6,7 +6,7 @@
     "CORE_PLUGINS",
     "CUSTOM_PLUGINS",
 )
-load("@npm//@bazel/typescript:index.bzl", "ts_config")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -64,6 +64,7 @@
     "//java/com/google/gerrit/server/logging",
     "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/server/util/time",
+    "//java/com/google/gerrit/proto",
     "//java/com/google/gerrit/util/cli",
     "//java/com/google/gerrit/util/http",
     "//java/com/google/gerrit/util/logging",
@@ -72,7 +73,6 @@
     "//lib/auto:auto-value-gson",
     "//lib/commons:compress",
     "//lib/commons:dbcp",
-    "//lib/commons:lang",
     "//lib/commons:lang3",
     "//lib/dropwizard:dropwizard-core",
     "//lib/flogger:api",
@@ -82,10 +82,8 @@
     "//lib/guice:javax_inject",
     "//lib/httpcomponents:httpclient",
     "//lib/httpcomponents:httpcore",
-    "//lib/jackson:jackson-core",
     "//lib:jgit-servlet",
     "//lib:jgit",
-    "//lib:jgit-ssh-jsch",
     "//lib:jsr305",
     "//lib/log:api",
     "//lib/log:log4j",
@@ -100,7 +98,6 @@
     "//lib:guava-retrying",
     "//lib:gson",
     "//lib:icu4j",
-    "//lib:jsch",
     "//lib:mime-util",
     "//lib:protobuf",
     "//lib:servlet-api-without-neverlink",
@@ -172,3 +169,32 @@
     pkgs = ["com.google.gerrit"],
     title = "Gerrit Review Plugin API Documentation",
 )
+
+# This is a generic test target for TypeScript plugins.
+#
+# `nodejs_test` needs to run in the directory where the `package.json` and
+# `node_modules` are, so unfortunately we cannot move this target into the
+# BUILD files of individual plugins. On the other hand one common target
+# for all plugins also has the advantage of being re-usable.
+#
+# For making this work for a specific plugin you have make the source files
+# of the plugin available as a `filegroup` and add it to the `data` attribute.
+# And you have to specify the `PLUGIN_DIR` in the `env` attribute.
+nodejs_test(
+    name = "web-test-runner",
+    size = "large",
+    chdir = package_name(),
+    data = [
+        ":package.json",
+        ":web-test-runner.config.mjs",
+        # This is an example of how you could reference your plugin sources:
+        # "//plugins/codemirror-editor/web:codemirror-test-sources",
+        "@plugins_npm//:node_modules",
+    ],
+    entry_point = "@plugins_npm//:node_modules/@web/test-runner/dist/bin.js",
+    env = {"PLUGIN_DIR": "codemirror-editor"},
+    tags = [
+        "local",
+        "manual",
+    ],
+)
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index c5bda5b..c964b31 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit c5bda5b6b5fe91a2f7cd40c5a917dd2280b04814
+Subproject commit c964b31675de90cfafe43fff9ec357f4b97d1e08
diff --git a/plugins/delete-project b/plugins/delete-project
index 5dcb1a6..b183ee5 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 5dcb1a64e779def0309354dd0e9886189845b020
+Subproject commit b183ee5230273670f3235cc5b3cf32562ccfb7ee
diff --git a/plugins/download-commands b/plugins/download-commands
index d2f0cae..a16ebc6 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit d2f0cae51a269ca660f221172cf010e2d528b661
+Subproject commit a16ebc6cdaaa4db5e5a2b6d062bb0ebbb3d3d0f4
diff --git a/plugins/gitiles b/plugins/gitiles
index 8e01636..12e26b3 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 8e016364bfbaa30f957e14250f73d1c25bb006b4
+Subproject commit 12e26b33ac55109bbb1d5eb56f198235552fb919
diff --git a/plugins/hooks b/plugins/hooks
index 4e07d16..3007362 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 4e07d16a644ea823f6538a176621acee466d865b
+Subproject commit 30073628612bce23826f4be71bfdd159da521cbc
diff --git a/plugins/package.json b/plugins/package.json
index e5d245c..331a417 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -1,13 +1,20 @@
 {
-    "name": "gerrit-plugin-dependencies",
-    "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
-    "browser": true,
-    "dependencies": {
-      "@polymer/decorators": "^3.0.0",
-      "@polymer/polymer": "^3.4.1",
-      "@gerritcodereview/typescript-api": "3.4.4",
-      "lit": "^2.0.2"
-    },
-    "license": "Apache-2.0",
-    "private": true
-}
+  "name": "gerrit-plugin-dependencies",
+  "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
+  "browser": true,
+  "dependencies": {
+    "@gerritcodereview/typescript-api": "3.7.0",
+    "@polymer/decorators": "^3.0.0",
+    "@polymer/polymer": "^3.4.1",
+    "@open-wc/testing": "^3.1.6",
+    "@types/codemirror": "^5.60.5",
+    "@web/dev-server-esbuild": "^0.3.2",
+    "@web/test-runner": "^0.14.0",
+    "codemirror": "^5.65.6",
+    "lit": "^2.2.3",
+    "rxjs": "^6.6.7",
+    "sinon": "^13.0.0"
+  },
+  "license": "Apache-2.0",
+  "private": true
+}
\ No newline at end of file
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index e0664f6..dbd6820 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit e0664f668ab5bac96a1e105b80d886de66743b1b
+Subproject commit dbd68200d867513e2c0449798476e275aaf08cfd
diff --git a/plugins/replication b/plugins/replication
index 639ea4b..8fd3c27 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 639ea4b3a3b3a67c80d29a6f83130499686543d3
+Subproject commit 8fd3c271ce0a21480e3d04da5ad2112efea3bedf
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index a28ae59..10db2cf 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit a28ae590486934690e4e0a95d7eb75f8b60644a6
+Subproject commit 10db2cf772989d031c6f3558010c51fe07cf9722
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 3239ce3..084a372 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 3239ce3a471f5aa9edd8f6f702bee655ea81f77d
+Subproject commit 084a37253dc94ac52cfaa1c9d516fcb8b0318b31
diff --git a/plugins/webhooks b/plugins/webhooks
index 9e4cd70..16110f3 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit 9e4cd708b96e4fab79b4101eaf3f3e6a8b872dca
+Subproject commit 16110f320dd5b6a40af87eaba4bf3af60cb0efd1
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 4cbe489..368b3e0 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -2,15 +2,131 @@
 # yarn lockfile v1
 
 
-"@gerritcodereview/typescript-api@3.4.4":
-  version "3.4.4"
-  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.4.4.tgz#9f09687038088dd7edd3b4e30d249502eb21bfbc"
-  integrity sha512-MAiQwntcQ59b92yYDsVIXj3oBbAB4C7HELkLFFbYs4ZjzC43XqqtR9VF0dh5OUC8wzFZttgUiOmGehk9edpPuw==
+"@babel/code-frame@^7.12.11":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
+  integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
+  dependencies:
+    "@babel/highlight" "^7.18.6"
 
-"@lit/reactive-element@^1.0.0":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.1.tgz#853cacd4d78d79059f33f66f8e7b0e5c34bee294"
-  integrity sha512-nSD5AA2AZkKuXuvGs8IK7K5ZczLAogfDd26zT9l6S7WzvqALdVWcW5vMUiTnZyj5SPcNwNNANj0koeV1ieqTFQ==
+"@babel/helper-validator-identifier@^7.18.6":
+  version "7.19.1"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2"
+  integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==
+
+"@babel/highlight@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf"
+  integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.18.6"
+    chalk "^2.0.0"
+    js-tokens "^4.0.0"
+
+"@esbuild/linux-loong64@0.14.54":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
+  integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
+
+"@esm-bundle/chai@^4.3.4-fix.0":
+  version "4.3.4-fix.0"
+  resolved "https://registry.yarnpkg.com/@esm-bundle/chai/-/chai-4.3.4-fix.0.tgz#3084cff7eb46d741749f47f3a48dbbdcbaf30a92"
+  integrity sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==
+  dependencies:
+    "@types/chai" "^4.2.12"
+
+"@gerritcodereview/typescript-api@3.7.0":
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.7.0.tgz#ae3886b5c4ddc6a02659a11d577e1df0b6158727"
+  integrity sha512-8zeZClN1gur+Isrn02bRXJ0wUjYvK99jQxg36ZbDelrGDglXKddf8QQkZmaH9sYIRcCFDLlh5+ZlRUTcXTuDVA==
+
+"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.4.0":
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.4.1.tgz#3f587eec5708692135bc9e94cf396130604979f3"
+  integrity sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==
+
+"@mdn/browser-compat-data@^4.0.0":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8"
+  integrity sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==
+
+"@nodelib/fs.scandir@2.1.5":
+  version "2.1.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
+  integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
+  dependencies:
+    "@nodelib/fs.stat" "2.0.5"
+    run-parallel "^1.1.9"
+
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
+  integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
+
+"@nodelib/fs.walk@^1.2.3":
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
+  integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
+  dependencies:
+    "@nodelib/fs.scandir" "2.1.5"
+    fastq "^1.6.0"
+
+"@open-wc/chai-dom-equals@^0.12.36":
+  version "0.12.36"
+  resolved "https://registry.yarnpkg.com/@open-wc/chai-dom-equals/-/chai-dom-equals-0.12.36.tgz#ed0eb56b9e98c4d7f7280facce6215654aae9f4c"
+  integrity sha512-Gt1fa37h4rtWPQGETSU4n1L678NmMi9KwHM1sH+JCGcz45rs8DBPx7MUVeGZ+HxRlbEI5t9LU2RGGv6xT2OlyA==
+  dependencies:
+    "@open-wc/semantic-dom-diff" "^0.13.16"
+    "@types/chai" "^4.1.7"
+
+"@open-wc/dedupe-mixin@^1.3.0":
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/@open-wc/dedupe-mixin/-/dedupe-mixin-1.3.1.tgz#5c1a1eeb0386b344290ebe3f1fca0c4869933dbf"
+  integrity sha512-ukowSvzpZQDUH0Y3znJTsY88HkiGk3Khc0WGpIPhap1xlerieYi27QBg6wx/nTurpWfU6XXXsx9ocxDYCdtw0Q==
+
+"@open-wc/scoped-elements@^2.1.3":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.1.3.tgz#c4f06fa16091c6ebf2a69b3f40afc03821f42535"
+  integrity sha512-WoQD5T8Me9obek+iyjgrAMw9wxZZg4ytIteIN1i9LXW2KohezUp0LTOlWgBajWJo0/bpjUKiODX73cMYL2i3hw==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    "@open-wc/dedupe-mixin" "^1.3.0"
+
+"@open-wc/semantic-dom-diff@^0.13.16":
+  version "0.13.21"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.13.21.tgz#718b9ec5f9a98935fc775e577ad094ae8d8b7dea"
+  integrity sha512-BONpjHcGX2zFa9mfnwBCLEmlDsOHzT+j6Qt1yfK3MzFXFtAykfzFjAgaxPetu0YbBlCfXuMlfxI4vlRGCGMvFg==
+
+"@open-wc/semantic-dom-diff@^0.19.7":
+  version "0.19.7"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.19.7.tgz#92361f0d2dcb54a8d5cf11d5ea40b8e7ffa58eb4"
+  integrity sha512-ahwHb7arQXXnkIGCrOsM895FJQrU47VWZryCsSSzl5nB3tJKcJ8yjzQ3D/yqZn6v8atqOz61vaY05aNsqoz3oA==
+  dependencies:
+    "@types/chai" "^4.3.1"
+    "@web/test-runner-commands" "^0.6.1"
+
+"@open-wc/testing-helpers@^2.1.2":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.3.tgz#85a133ac8637ed1d880d523b07650788eab4a128"
+  integrity sha512-hQujGaWncmWLx/974jq5yf2jydBNNTwnkISw2wLGiYgX34+3R6/ns301Oi9S3Il96Kzd8B7avdExp/gDgqcF5w==
+  dependencies:
+    "@open-wc/scoped-elements" "^2.1.3"
+    lit "^2.0.0"
+    lit-html "^2.0.0"
+
+"@open-wc/testing@^3.1.6":
+  version "3.1.6"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.6.tgz#89f71710e5530d74f0c478b0a9239d68dcdb9f5e"
+  integrity sha512-MIf9cBtac4/UBE5a+R5cXiRhOGfzetsV+ZPFc188AfkPDPbmffHqjrRoCyk4B/qS6fLEulSBMLSaQ+6ze971gQ==
+  dependencies:
+    "@esm-bundle/chai" "^4.3.4-fix.0"
+    "@open-wc/chai-dom-equals" "^0.12.36"
+    "@open-wc/semantic-dom-diff" "^0.19.7"
+    "@open-wc/testing-helpers" "^2.1.2"
+    "@types/chai" "^4.2.11"
+    "@types/chai-dom" "^0.0.12"
+    "@types/sinon-chai" "^3.2.3"
+    chai-a11y-axe "^1.3.2"
 
 "@polymer/decorators@^3.0.0":
   version "3.0.0"
@@ -20,42 +136,2374 @@
     "@polymer/polymer" "^3.0.5"
 
 "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.4.1":
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
-  integrity sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==
+  version "3.5.1"
+  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.5.1.tgz#4b5234e43b8876441022bcb91313ab3c4a29f0c8"
+  integrity sha512-JlAHuy+1qIC6hL1ojEUfIVD58fzTpJAoCxFwV5yr0mYTXV1H8bz5zy0+rC963Cgr9iNXQ4T9ncSjC2fkF9BQfw==
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@rollup/plugin-node-resolve@^13.0.4":
+  version "13.3.0"
+  resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c"
+  integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw==
+  dependencies:
+    "@rollup/pluginutils" "^3.1.0"
+    "@types/resolve" "1.17.1"
+    deepmerge "^4.2.2"
+    is-builtin-module "^3.1.0"
+    is-module "^1.0.0"
+    resolve "^1.19.0"
+
+"@rollup/pluginutils@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
+  integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
+  dependencies:
+    "@types/estree" "0.0.39"
+    estree-walker "^1.0.1"
+    picomatch "^2.2.2"
+
+"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
+  version "1.8.6"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.6.tgz#80c516a4dc264c2a69115e7578d62581ff455ed9"
+  integrity sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==
+  dependencies:
+    type-detect "4.0.8"
+
+"@sinonjs/commons@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3"
+  integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==
+  dependencies:
+    type-detect "4.0.8"
+
+"@sinonjs/fake-timers@^10.0.2":
+  version "10.0.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.0.2.tgz#d10549ed1f423d80639c528b6c7f5a1017747d0c"
+  integrity sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==
+  dependencies:
+    "@sinonjs/commons" "^2.0.0"
+
+"@sinonjs/fake-timers@^9.1.2":
+  version "9.1.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c"
+  integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==
+  dependencies:
+    "@sinonjs/commons" "^1.7.0"
+
+"@sinonjs/samsam@^6.1.1":
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.3.tgz#4e30bcd4700336363302a7d72cbec9b9ab87b104"
+  integrity sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==
+  dependencies:
+    "@sinonjs/commons" "^1.6.0"
+    lodash.get "^4.4.2"
+    type-detect "^4.0.8"
+
+"@sinonjs/text-encoding@^0.7.1":
+  version "0.7.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918"
+  integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==
+
+"@types/accepts@*":
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
+  integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/babel__code-frame@^7.0.2":
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.3.tgz#eda94e1b7c9326700a4b69c485ebbc9498a0b63f"
+  integrity sha512-2TN6oiwtNjOezilFVl77zwdNPwQWaDBBCCWWxyo1ctiO3vAtd7H/aB/CBJdw9+kqq3+latD0SXoedIuHySSZWw==
+
+"@types/body-parser@*":
+  version "1.19.2"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
+  integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==
+  dependencies:
+    "@types/connect" "*"
+    "@types/node" "*"
+
+"@types/chai-dom@^0.0.12":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-0.0.12.tgz#fdd7a52bed4dd235ed1c94d3d2d31d4e7db1d03a"
+  integrity sha512-4rE7sDw713cV61TYzQbMrPjC4DjNk3x4vk9nAVRNXcSD4p0/5lEEfm0OgoCz5eNuWUXNKA0YiKiH/JDTuKivkA==
+  dependencies:
+    "@types/chai" "*"
+
+"@types/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.11", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
+  integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
+
+"@types/co-body@^6.1.0":
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.0.tgz#b52625390eb0d113c9b697ea92c3ffae7740cdb9"
+  integrity sha512-3e0q2jyDAnx/DSZi0z2H0yoZ2wt5yRDZ+P7ymcMObvq0ufWRT4tsajyO+Q1VwVWiv9PRR4W3YEjEzBjeZlhF+w==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
+
+"@types/codemirror@^5.60.5":
+  version "5.60.5"
+  resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.5.tgz#5b989a3b4bbe657458cf372c92b6bfda6061a2b7"
+  integrity sha512-TiECZmm8St5YxjFUp64LK0c8WU5bxMDt9YaAek1UqUb9swrSCoJhh92fWu1p3mTEqlHjhB5sY7OFBhWroJXZVg==
+  dependencies:
+    "@types/tern" "*"
+
+"@types/command-line-args@^5.0.0":
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.0.tgz#adbb77980a1cc376bb208e3f4142e907410430f6"
+  integrity sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==
+
+"@types/connect@*":
+  version "3.4.35"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
+  integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/content-disposition@*":
+  version "0.5.5"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.5.tgz#650820e95de346e1f84e30667d168c8fd25aa6e3"
+  integrity sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==
+
+"@types/convert-source-map@^1.5.1":
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-1.5.2.tgz#318dc22d476632a4855594c16970c6dc3ed086e7"
+  integrity sha512-tHs++ZeXer40kCF2JpE51Hg7t4HPa18B1b1Dzy96S0eCw8QKECNMYMfwa1edK/x8yCN0r4e6ewvLcc5CsVGkdg==
+
+"@types/cookies@*":
+  version "0.7.7"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81"
+  integrity sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==
+  dependencies:
+    "@types/connect" "*"
+    "@types/express" "*"
+    "@types/keygrip" "*"
+    "@types/node" "*"
+
+"@types/debounce@^1.2.0":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.1.tgz#79b65710bc8b6d44094d286aecf38e44f9627852"
+  integrity sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==
+
+"@types/estree@*":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2"
+  integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==
+
+"@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/express-serve-static-core@^4.17.18":
+  version "4.17.31"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz#a1139efeab4e7323834bb0226e62ac019f474b2f"
+  integrity sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
+    "@types/range-parser" "*"
+
+"@types/express@*":
+  version "4.17.14"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.14.tgz#143ea0557249bc1b3b54f15db4c81c3d4eb3569c"
+  integrity sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==
+  dependencies:
+    "@types/body-parser" "*"
+    "@types/express-serve-static-core" "^4.17.18"
+    "@types/qs" "*"
+    "@types/serve-static" "*"
+
+"@types/http-assert@*":
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661"
+  integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
+
+"@types/http-errors@*":
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1"
+  integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==
+
+"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.3":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
+  integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
+
+"@types/istanbul-lib-report@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686"
+  integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==
+  dependencies:
+    "@types/istanbul-lib-coverage" "*"
+
+"@types/istanbul-reports@^3.0.0":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff"
+  integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==
+  dependencies:
+    "@types/istanbul-lib-report" "*"
+
+"@types/keygrip@*":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
+  integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==
+
+"@types/koa-compose@*":
+  version "3.2.5"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d"
+  integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==
+  dependencies:
+    "@types/koa" "*"
+
+"@types/koa@*", "@types/koa@^2.11.6":
+  version "2.13.5"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.5.tgz#64b3ca4d54e08c0062e89ec666c9f45443b21a61"
+  integrity sha512-HSUOdzKz3by4fnqagwthW/1w/yJspTgppyyalPVbgZf8jQWvdIXcVW5h2DGtw4zYntOaeRGx49r1hxoPWrD4aA==
+  dependencies:
+    "@types/accepts" "*"
+    "@types/content-disposition" "*"
+    "@types/cookies" "*"
+    "@types/http-assert" "*"
+    "@types/http-errors" "*"
+    "@types/keygrip" "*"
+    "@types/koa-compose" "*"
+    "@types/node" "*"
+
+"@types/mime@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
+  integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
+
+"@types/mocha@^8.2.0":
+  version "8.2.3"
+  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323"
+  integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
+
+"@types/node@*":
+  version "18.8.3"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.8.3.tgz#ce750ab4017effa51aed6a7230651778d54e327c"
+  integrity sha512-0os9vz6BpGwxGe9LOhgP/ncvYN5Tx1fNcd2TM3rD/aCGBkysb+ZWpXEocG24h6ZzOi13+VB8HndAQFezsSOw1w==
+
+"@types/parse5@^6.0.1":
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb"
+  integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
+
+"@types/qs@*":
+  version "6.9.7"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
+  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
+
+"@types/range-parser@*":
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
+  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
+
+"@types/resolve@1.17.1":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
+  integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
+  dependencies:
+    "@types/node" "*"
+
+"@types/serve-static@*":
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155"
+  integrity sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==
+  dependencies:
+    "@types/mime" "*"
+    "@types/node" "*"
+
+"@types/sinon-chai@^3.2.3":
+  version "3.2.8"
+  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc"
+  integrity sha512-d4ImIQbT/rKMG8+AXpmcan5T2/PNeSjrYhvkwet6z0p8kzYtfgA32xzOBlbU0yqJfq+/0Ml805iFoODO0LP5/g==
+  dependencies:
+    "@types/chai" "*"
+    "@types/sinon" "*"
+
+"@types/sinon@*":
+  version "10.0.13"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.13.tgz#60a7a87a70d9372d0b7b38cc03e825f46981fb83"
+  integrity sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==
+  dependencies:
+    "@types/sinonjs__fake-timers" "*"
+
+"@types/sinonjs__fake-timers@*":
+  version "8.1.2"
+  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e"
+  integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
+
+"@types/tern@*":
+  version "0.23.4"
+  resolved "https://registry.yarnpkg.com/@types/tern/-/tern-0.23.4.tgz#03926eb13dbeaf3ae0d390caf706b2643a0127fb"
+  integrity sha512-JAUw1iXGO1qaWwEOzxTKJZ/5JxVeON9kvGZ/osgZaJImBnyjyn0cjovPsf6FNLmyGY8Vw9DoXZCMlfMkMwHRWg==
+  dependencies:
+    "@types/estree" "*"
+
 "@types/trusted-types@^2.0.2":
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
   integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
 
+"@types/ws@^7.4.0":
+  version "7.4.7"
+  resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702"
+  integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==
+  dependencies:
+    "@types/node" "*"
+
+"@types/yauzl@^2.9.1":
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
+  integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==
+  dependencies:
+    "@types/node" "*"
+
+"@web/browser-logs@^0.2.1", "@web/browser-logs@^0.2.2":
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.2.5.tgz#0895efb641eacb0fbc1138c6092bd18c01df2734"
+  integrity sha512-Qxo1wY/L7yILQqg0jjAaueh+tzdORXnZtxQgWH23SsTCunz9iq9FvsZa8Q5XlpjnZ3vLIsFEuEsCMqFeohJnEg==
+  dependencies:
+    errorstacks "^2.2.0"
+
+"@web/config-loader@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@web/config-loader/-/config-loader-0.1.3.tgz#8325ea54f75ef2ee7166783e64e66936db25bff7"
+  integrity sha512-XVKH79pk4d3EHRhofete8eAnqto1e8mCRAqPV00KLNFzCWSe8sWmLnqKCqkPNARC6nksMaGrATnA5sPDRllMpQ==
+  dependencies:
+    semver "^7.3.4"
+
+"@web/dev-server-core@^0.3.18", "@web/dev-server-core@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.19.tgz#b61f9a0b92351371347a758b30ba19e683c72e94"
+  integrity sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==
+  dependencies:
+    "@types/koa" "^2.11.6"
+    "@types/ws" "^7.4.0"
+    "@web/parse5-utils" "^1.2.0"
+    chokidar "^3.4.3"
+    clone "^2.1.2"
+    es-module-lexer "^1.0.0"
+    get-stream "^6.0.0"
+    is-stream "^2.0.0"
+    isbinaryfile "^4.0.6"
+    koa "^2.13.0"
+    koa-etag "^4.0.0"
+    koa-send "^5.0.1"
+    koa-static "^5.0.0"
+    lru-cache "^6.0.0"
+    mime-types "^2.1.27"
+    parse5 "^6.0.1"
+    picomatch "^2.2.2"
+    ws "^7.4.2"
+
+"@web/dev-server-esbuild@^0.3.2":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.3.tgz#e82af2e5acec0e645b920400be9601601b3921c5"
+  integrity sha512-hB9C8X9NsFWUG2XKT3W+Xcw3IZ/VObf4LNbK14BTjApnNyZfV6hVhSlJfvhgOoJ4DxsImfhIB5+gMRKOG9NmBw==
+  dependencies:
+    "@mdn/browser-compat-data" "^4.0.0"
+    "@web/dev-server-core" "^0.3.19"
+    esbuild "^0.12 || ^0.13 || ^0.14"
+    parse5 "^6.0.1"
+    ua-parser-js "^1.0.2"
+
+"@web/dev-server-rollup@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.3.19.tgz#188f3a37bcc38f4dc1b208663b14ab2d17321a57"
+  integrity sha512-IwiwI+fyX0YuvAOldStlYJ+Zm/JfSCk9OSGIs7+fWbOYysEHwkEVvBwoPowaclSZA44Tobvqt+6ej9udbbZ/WQ==
+  dependencies:
+    "@rollup/plugin-node-resolve" "^13.0.4"
+    "@web/dev-server-core" "^0.3.19"
+    nanocolors "^0.2.1"
+    parse5 "^6.0.1"
+    rollup "^2.67.0"
+    whatwg-url "^11.0.0"
+
+"@web/dev-server@^0.1.35":
+  version "0.1.35"
+  resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.35.tgz#d845822d7c3c7749adf03f7abac4a69e2a4490cc"
+  integrity sha512-E7TSTSFdGPzhkiE3kIVt8i49gsiAYpJIZHzs1vJmVfdt8U4rsmhE+5roezxZo0hkEw4mNsqj9zCc4Dzqy/IFHg==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/command-line-args" "^5.0.0"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server-core" "^0.3.19"
+    "@web/dev-server-rollup" "^0.3.19"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    debounce "^1.2.0"
+    deepmerge "^4.2.2"
+    ip "^1.1.5"
+    nanocolors "^0.2.1"
+    open "^8.0.2"
+    portfinder "^1.0.32"
+
+"@web/parse5-utils@^1.2.0":
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.0.tgz#e2e9e98b31a4ca948309f74891bda8d77399f6bd"
+  integrity sha512-Pgkx3ECc8EgXSlS5EyrgzSOoUbM6P8OKS471HLAyvOBcP1NCBn0to4RN/OaKASGq8qa3j+lPX9H14uA5AHEnQg==
+  dependencies:
+    "@types/parse5" "^6.0.1"
+    parse5 "^6.0.1"
+
+"@web/test-runner-chrome@^0.10.7":
+  version "0.10.7"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-chrome/-/test-runner-chrome-0.10.7.tgz#2dc35da47aa8b98c59f9e229a70ea3f443303e0c"
+  integrity sha512-DKJVHhHh3e/b6/erfKOy0a4kGfZ47qMoQRgROxi9T4F9lavEY3E5/MQ7hapHFM2lBF4vDrm+EWjtBdOL8o42tw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    "@web/test-runner-coverage-v8" "^0.4.8"
+    chrome-launcher "^0.15.0"
+    puppeteer-core "^13.1.3"
+
+"@web/test-runner-commands@^0.6.1", "@web/test-runner-commands@^0.6.3":
+  version "0.6.5"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.5.tgz#69a2a06b52fd9d329f9cf1e172cd8fb1d5ffc521"
+  integrity sha512-W+wLg10jEAJY9N6tNWqG1daKmAzxGmTbO/H9fFfcgOgdxdn+hHiR4r2/x1iylKbFLujHUQlnjNQeu2d6eDPFqg==
+  dependencies:
+    "@web/test-runner-core" "^0.10.27"
+    mkdirp "^1.0.4"
+
+"@web/test-runner-core@^0.10.20", "@web/test-runner-core@^0.10.27":
+  version "0.10.27"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.27.tgz#8d1430f2364fb36b3ac15b9b43034fae9d94e177"
+  integrity sha512-ClV/hSxs4wDm/ANFfQOdRRFb/c0sYywC1QfUXG/nS4vTp3nnt7x7mjydtMGGLmvK9f6Zkubkc1aa+7ryfmVwNA==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/babel__code-frame" "^7.0.2"
+    "@types/co-body" "^6.1.0"
+    "@types/convert-source-map" "^1.5.1"
+    "@types/debounce" "^1.2.0"
+    "@types/istanbul-lib-coverage" "^2.0.3"
+    "@types/istanbul-reports" "^3.0.0"
+    "@web/browser-logs" "^0.2.1"
+    "@web/dev-server-core" "^0.3.18"
+    chokidar "^3.4.3"
+    cli-cursor "^3.1.0"
+    co-body "^6.1.0"
+    convert-source-map "^1.7.0"
+    debounce "^1.2.0"
+    dependency-graph "^0.11.0"
+    globby "^11.0.1"
+    ip "^1.1.5"
+    istanbul-lib-coverage "^3.0.0"
+    istanbul-lib-report "^3.0.0"
+    istanbul-reports "^3.0.2"
+    log-update "^4.0.0"
+    nanocolors "^0.2.1"
+    nanoid "^3.1.25"
+    open "^8.0.2"
+    picomatch "^2.2.2"
+    source-map "^0.7.3"
+
+"@web/test-runner-coverage-v8@^0.4.8":
+  version "0.4.9"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.4.9.tgz#334d80cd19fc68c08ec3339b1b1d2725078b51a2"
+  integrity sha512-y9LWL4uY25+fKQTljwr0XTYjeWIwU4h8eYidVuLoW3n1CdFkaddv+smrGzzF5j8XY+Mp6TmV9NdxjvMWqVkDdw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    istanbul-lib-coverage "^3.0.0"
+    picomatch "^2.2.2"
+    v8-to-istanbul "^8.0.0"
+
+"@web/test-runner-mocha@^0.7.5":
+  version "0.7.5"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-mocha/-/test-runner-mocha-0.7.5.tgz#696f8cb7f5118a72bd7ac5778367ae3bd3fb92cd"
+  integrity sha512-12/OBq6efPCAvJpcz3XJs2OO5nHe7GtBibZ8Il1a0QtsGpRmuJ4/m1EF0Fj9f6KHg7JdpGo18A37oE+5hXjHwg==
+  dependencies:
+    "@types/mocha" "^8.2.0"
+    "@web/test-runner-core" "^0.10.20"
+
+"@web/test-runner@^0.14.0":
+  version "0.14.1"
+  resolved "https://registry.yarnpkg.com/@web/test-runner/-/test-runner-0.14.1.tgz#a637e45c9b6ce7860ab780b5ac82dbfa1ed824f9"
+  integrity sha512-S2/Xp/bZBJdbWeTQxhs45cO9Khwqx99X+rrx8l0uDR0Ju/+kX+yC3RpjnOY1ooKD3rjkoEAE82soZTZNz+aKIg==
+  dependencies:
+    "@web/browser-logs" "^0.2.2"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server" "^0.1.35"
+    "@web/test-runner-chrome" "^0.10.7"
+    "@web/test-runner-commands" "^0.6.3"
+    "@web/test-runner-core" "^0.10.27"
+    "@web/test-runner-mocha" "^0.7.5"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    convert-source-map "^1.7.0"
+    diff "^5.0.0"
+    globby "^11.0.1"
+    nanocolors "^0.2.1"
+    portfinder "^1.0.32"
+    source-map "^0.7.3"
+
 "@webcomponents/shadycss@^1.9.1":
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
   integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
 
-lit-element@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.1.tgz#3c545af17d8a46268bc1dd5623a47486e6ff76f4"
-  integrity sha512-vs9uybH9ORyK49CFjoNGN85HM9h5bmisU4TQ63phe/+GYlwvY/3SIFYKdjV6xNvzz8v2MnVC+9+QOkPqh+Q3Ew==
+accepts@^1.3.5:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
   dependencies:
-    "@lit/reactive-element" "^1.0.0"
-    lit-html "^2.0.0"
+    mime-types "~2.1.34"
+    negotiator "0.6.3"
 
-lit-html@^2.0.0:
+agent-base@6:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
+  integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
+  dependencies:
+    debug "4"
+
+ansi-escapes@^4.3.0:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
+  integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
+  dependencies:
+    type-fest "^0.21.3"
+
+ansi-regex@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
+ansi-styles@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+  dependencies:
+    color-convert "^1.9.0"
+
+ansi-styles@^4.0.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+  dependencies:
+    color-convert "^2.0.1"
+
+anymatch@~3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
+  integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
+  dependencies:
+    normalize-path "^3.0.0"
+    picomatch "^2.0.4"
+
+array-back@^3.0.1, array-back@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
+  integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+
+array-back@^4.0.1, array-back@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
+  integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
+
+array-union@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
+  integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
+
+astral-regex@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
+  integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
+
+async@^2.6.4:
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+  integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
+  dependencies:
+    lodash "^4.17.14"
+
+axe-core@^4.3.3:
+  version "4.4.3"
+  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.3.tgz#11c74d23d5013c0fa5d183796729bc3482bd2f6f"
+  integrity sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w==
+
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+base64-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==
+
+binary-extensions@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
+  integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+
+bl@^4.0.3:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
+  integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
+  dependencies:
+    buffer "^5.5.0"
+    inherits "^2.0.4"
+    readable-stream "^3.4.0"
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
+braces@^3.0.2, braces@~3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
+  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
+  dependencies:
+    fill-range "^7.0.1"
+
+buffer-crc32@~0.2.3:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+  integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
+
+buffer@^5.2.1, buffer@^5.5.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
+  integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
+  dependencies:
+    base64-js "^1.3.1"
+    ieee754 "^1.1.13"
+
+builtin-modules@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
+  integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
+
+bytes@3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
+  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
+
+cache-content-type@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
+  integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==
+  dependencies:
+    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"
+
+camelcase@^6.2.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+  integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
+
+chai-a11y-axe@^1.3.2:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.4.0.tgz#e584af967727a8656e27c32e845f5db21f2bf2e0"
+  integrity sha512-m7J6DVAl1ePL2ifPKHmwQyHXdCZ+Qfv+qduh6ScqcDfBnJEzpV1K49TblujM45j1XciZOFeFNqMb2sShXMg/mw==
+  dependencies:
+    axe-core "^4.3.3"
+
+chalk@^2.0.0, 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==
+  dependencies:
+    ansi-styles "^3.2.1"
+    escape-string-regexp "^1.0.5"
+    supports-color "^5.3.0"
+
+chokidar@^3.4.3:
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
+  integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
+  dependencies:
+    anymatch "~3.1.2"
+    braces "~3.0.2"
+    glob-parent "~5.1.2"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.6.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
+
+chownr@^1.1.1:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
+  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+
+chrome-launcher@^0.15.0:
+  version "0.15.1"
+  resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.1.tgz#0a0208037063641e2b3613b7e42b0fcb3fa2d399"
+  integrity sha512-UugC8u59/w2AyX5sHLZUHoxBAiSiunUhZa3zZwMH6zPVis0C3dDKiRWyUGIo14tTbZHGVviWxv3PQWZ7taZ4fg==
+  dependencies:
+    "@types/node" "*"
+    escape-string-regexp "^4.0.0"
+    is-wsl "^2.2.0"
+    lighthouse-logger "^1.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"
+  integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
+  dependencies:
+    restore-cursor "^3.1.0"
+
+clone@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
+  integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
+
+co-body@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.1.0.tgz#d87a8efc3564f9bfe3aced8ef5cd04c7a8766547"
+  integrity sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==
+  dependencies:
+    inflation "^2.0.0"
+    qs "^6.5.2"
+    raw-body "^2.3.3"
+    type-is "^1.6.16"
+
+co@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+  integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==
+
+codemirror@^5.65.6:
+  version "5.65.10"
+  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.10.tgz#4276a93b8534ce91f14b733ba9a1ac949666eac9"
+  integrity sha512-IXAG5wlhbgcTJ6rZZcmi4+sjWIbJqIGfeg3tNa3yX84Jb3T4huS5qzQAo/cUisc1l3bI47WZodpyf7cYcocDKg==
+
+color-convert@^1.9.0:
+  version "1.9.3"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+  integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
+  dependencies:
+    color-name "1.1.3"
+
+color-convert@^2.0.1:
   version "2.0.1"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.1.tgz#63241015efa07bc9259b6f96f04abd052d2a1f95"
-  integrity sha512-KF5znvFdXbxTYM/GjpdOOnMsjgRcFGusTnB54ixnCTya5zUR0XqrDRj29ybuLS+jLXv1jji6Y8+g4W7WP8uL4w==
+  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 sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
+
+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==
+
+command-line-args@^5.1.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+  integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
+  dependencies:
+    array-back "^3.1.0"
+    find-replace "^3.0.0"
+    lodash.camelcase "^4.3.0"
+    typical "^4.0.0"
+
+command-line-usage@^6.1.1:
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.3.tgz#428fa5acde6a838779dfa30e44686f4b6761d957"
+  integrity sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==
+  dependencies:
+    array-back "^4.0.2"
+    chalk "^2.4.2"
+    table-layout "^1.0.2"
+    typical "^5.2.0"
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
+
+content-disposition@~0.5.2:
+  version "0.5.4"
+  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
+  integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
+  dependencies:
+    safe-buffer "5.2.1"
+
+content-type@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+
+convert-source-map@^1.6.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
+  integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
+
+convert-source-map@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
+  integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
+  dependencies:
+    safe-buffer "~5.1.1"
+
+cookies@~0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
+  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+  dependencies:
+    depd "~2.0.0"
+    keygrip "~1.1.0"
+
+cross-fetch@3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
+  integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
+  dependencies:
+    node-fetch "2.6.7"
+
+debounce@^1.2.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
+  integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
+
+debug@4, debug@4.3.4, debug@^4.1.1, debug@^4.3.2:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
+  dependencies:
+    ms "2.1.2"
+
+debug@^2.6.9:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+  dependencies:
+    ms "2.0.0"
+
+debug@^3.1.0, debug@^3.2.7:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
+  integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
+  dependencies:
+    ms "^2.1.1"
+
+deep-equal@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
+  integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==
+
+deep-extend@~0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
+deepmerge@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
+  integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
+
+define-lazy-prop@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
+  integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
+
+delegates@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
+
+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 sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
+
+dependency-graph@^0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27"
+  integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==
+
+destroy@^1.0.4:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
+devtools-protocol@0.0.981744:
+  version "0.0.981744"
+  resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf"
+  integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==
+
+diff@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
+  integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
+
+dir-glob@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
+  integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==
+  dependencies:
+    path-type "^4.0.0"
+
+ee-first@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+  integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
+
+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:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
+
+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"
+  integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
+  dependencies:
+    once "^1.4.0"
+
+errorstacks@^2.2.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.0.tgz#2155674dd9e741aacda3f3b8b967d9c40a4a0baf"
+  integrity sha512-5ecWhU5gt0a5G05nmQcgCxP5HperSMxLDzvWlT5U+ZSKkuDK0rJ3dbCQny6/vSCIXjwrhwSecXBbw1alr295hQ==
+
+es-module-lexer@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.0.3.tgz#f0d8d35b36d13024110000d5e6fadc8eeaeb66b8"
+  integrity sha512-iC67eXHToclrlVhQfpRawDiF8D8sQxNxmbqw5oebegOaJkyx/w9C/k57/5e6yJR2zIByRt9OXdqX50DV2t6ZKw==
+
+esbuild-android-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
+  integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
+
+esbuild-android-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
+  integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
+
+esbuild-darwin-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
+  integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
+
+esbuild-darwin-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
+  integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
+
+esbuild-freebsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
+  integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
+
+esbuild-freebsd-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
+  integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
+
+esbuild-linux-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
+  integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
+
+esbuild-linux-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652"
+  integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
+
+esbuild-linux-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
+  integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
+
+esbuild-linux-arm@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
+  integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
+
+esbuild-linux-mips64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
+  integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
+
+esbuild-linux-ppc64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
+  integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
+
+esbuild-linux-riscv64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
+  integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
+
+esbuild-linux-s390x@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
+  integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
+
+esbuild-netbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
+  integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
+
+esbuild-openbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
+  integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
+
+esbuild-sunos-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
+  integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
+
+esbuild-windows-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
+  integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
+
+esbuild-windows-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
+  integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
+
+esbuild-windows-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
+  integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
+
+"esbuild@^0.12 || ^0.13 || ^0.14":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
+  integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
+  optionalDependencies:
+    "@esbuild/linux-loong64" "0.14.54"
+    esbuild-android-64 "0.14.54"
+    esbuild-android-arm64 "0.14.54"
+    esbuild-darwin-64 "0.14.54"
+    esbuild-darwin-arm64 "0.14.54"
+    esbuild-freebsd-64 "0.14.54"
+    esbuild-freebsd-arm64 "0.14.54"
+    esbuild-linux-32 "0.14.54"
+    esbuild-linux-64 "0.14.54"
+    esbuild-linux-arm "0.14.54"
+    esbuild-linux-arm64 "0.14.54"
+    esbuild-linux-mips64le "0.14.54"
+    esbuild-linux-ppc64le "0.14.54"
+    esbuild-linux-riscv64 "0.14.54"
+    esbuild-linux-s390x "0.14.54"
+    esbuild-netbsd-64 "0.14.54"
+    esbuild-openbsd-64 "0.14.54"
+    esbuild-sunos-64 "0.14.54"
+    esbuild-windows-32 "0.14.54"
+    esbuild-windows-64 "0.14.54"
+    esbuild-windows-arm64 "0.14.54"
+
+escape-html@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+  integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
+
+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 sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
+
+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==
+
+estree-walker@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
+  integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
+
+etag@^1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+  integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
+
+extract-zip@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
+  integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
+  dependencies:
+    debug "^4.1.1"
+    get-stream "^5.1.0"
+    yauzl "^2.10.0"
+  optionalDependencies:
+    "@types/yauzl" "^2.9.1"
+
+fast-glob@^3.2.9:
+  version "3.2.12"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
+  integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.2"
+    merge2 "^1.3.0"
+    micromatch "^4.0.4"
+
+fastq@^1.6.0:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
+  integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==
+  dependencies:
+    reusify "^1.0.4"
+
+fd-slicer@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+  integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==
+  dependencies:
+    pend "~1.2.0"
+
+fill-range@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
+  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+  dependencies:
+    to-regex-range "^5.0.1"
+
+find-replace@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
+  integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
+  dependencies:
+    array-back "^3.0.1"
+
+find-up@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
+
+fresh@~0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+  integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
+
+fs-constants@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
+  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
+
+fsevents@~2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+  integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
+function-bind@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
+  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+
+get-intrinsic@^1.0.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385"
+  integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==
+  dependencies:
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.3"
+
+get-stream@^5.1.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
+  integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==
+  dependencies:
+    pump "^3.0.0"
+
+get-stream@^6.0.0:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
+  integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
+
+glob-parent@^5.1.2, glob-parent@~5.1.2:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
+  integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
+  dependencies:
+    is-glob "^4.0.1"
+
+glob@^7.1.3:
+  version "7.2.3"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+  integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.1.1"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+globby@^11.0.1:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
+  integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==
+  dependencies:
+    array-union "^2.1.0"
+    dir-glob "^3.0.1"
+    fast-glob "^3.2.9"
+    ignore "^5.2.0"
+    merge2 "^1.4.1"
+    slash "^3.0.0"
+
+has-flag@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+  integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
+
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+has-symbols@^1.0.2, has-symbols@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
+  integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+
+has-tostringtag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
+  integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==
+  dependencies:
+    has-symbols "^1.0.2"
+
+has@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+  dependencies:
+    function-bind "^1.1.1"
+
+html-escaper@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
+  integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
+
+http-assert@^1.3.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f"
+  integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==
+  dependencies:
+    deep-equal "~1.0.1"
+    http-errors "~1.8.0"
+
+http-errors@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+  integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
+  dependencies:
+    depd "2.0.0"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses "2.0.1"
+    toidentifier "1.0.1"
+
+http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
+  integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.1"
+
+http-errors@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+  integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.0"
+    statuses ">= 1.4.0 < 2"
+
+https-proxy-agent@5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
+  integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
+  dependencies:
+    agent-base "6"
+    debug "4"
+
+iconv-lite@0.4.24:
+  version "0.4.24"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3"
+
+ieee754@^1.1.13:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
+  integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
+
+ignore@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
+  integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
+
+inflation@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
+  integrity sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==
+
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+inherits@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+  integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
+
+ip@^1.1.5:
+  version "1.1.8"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
+  integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==
+
+is-binary-path@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+  dependencies:
+    binary-extensions "^2.0.0"
+
+is-builtin-module@^3.1.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.0.tgz#bb0310dfe881f144ca83f30100ceb10cf58835e0"
+  integrity sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==
+  dependencies:
+    builtin-modules "^3.3.0"
+
+is-core-module@^2.9.0:
+  version "2.11.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
+  integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==
+  dependencies:
+    has "^1.0.3"
+
+is-docker@^2.0.0, is-docker@^2.1.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
+  integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
+
+is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+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.10"
+  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72"
+  integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==
+  dependencies:
+    has-tostringtag "^1.0.0"
+
+is-glob@^4.0.1, is-glob@~4.0.1:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
+is-module@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
+  integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
+
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+is-stream@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
+  integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
+
+is-wsl@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
+  integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
+  dependencies:
+    is-docker "^2.0.0"
+
+isarray@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+  integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==
+
+isbinaryfile@^4.0.6:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
+  integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==
+
+istanbul-lib-coverage@^3.0.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3"
+  integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==
+
+istanbul-lib-report@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6"
+  integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==
+  dependencies:
+    istanbul-lib-coverage "^3.0.0"
+    make-dir "^3.0.0"
+    supports-color "^7.1.0"
+
+istanbul-reports@^3.0.2:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.5.tgz#cc9a6ab25cb25659810e4785ed9d9fb742578bae"
+  integrity sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==
+  dependencies:
+    html-escaper "^2.0.0"
+    istanbul-lib-report "^3.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==
+
+just-extend@^4.0.2:
+  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==
+
+keygrip@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
+  integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
+  dependencies:
+    tsscmp "1.0.6"
+
+koa-compose@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
+  integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==
+
+koa-convert@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5"
+  integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==
+  dependencies:
+    co "^4.6.0"
+    koa-compose "^4.1.0"
+
+koa-etag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/koa-etag/-/koa-etag-4.0.0.tgz#2c2bb7ae69ca1ac6ced09ba28dcb78523c810414"
+  integrity sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==
+  dependencies:
+    etag "^1.8.1"
+
+koa-send@^5.0.0, koa-send@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79"
+  integrity sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==
+  dependencies:
+    debug "^4.1.1"
+    http-errors "^1.7.3"
+    resolve-path "^1.4.0"
+
+koa-static@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/koa-static/-/koa-static-5.0.0.tgz#5e92fc96b537ad5219f425319c95b64772776943"
+  integrity sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==
+  dependencies:
+    debug "^3.1.0"
+    koa-send "^5.0.0"
+
+koa@^2.13.0:
+  version "2.13.4"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e"
+  integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==
+  dependencies:
+    accepts "^1.3.5"
+    cache-content-type "^1.0.0"
+    content-disposition "~0.5.2"
+    content-type "^1.0.4"
+    cookies "~0.8.0"
+    debug "^4.3.2"
+    delegates "^1.0.0"
+    depd "^2.0.0"
+    destroy "^1.0.4"
+    encodeurl "^1.0.2"
+    escape-html "^1.0.3"
+    fresh "~0.5.2"
+    http-assert "^1.3.0"
+    http-errors "^1.6.3"
+    is-generator-function "^1.0.7"
+    koa-compose "^4.1.0"
+    koa-convert "^2.0.0"
+    on-finished "^2.3.0"
+    only "~0.0.2"
+    parseurl "^1.3.2"
+    statuses "^1.5.0"
+    type-is "^1.6.16"
+    vary "^1.1.2"
+
+lighthouse-logger@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.3.0.tgz#ba6303e739307c4eee18f08249524e7dafd510db"
+  integrity sha512-BbqAKApLb9ywUli+0a+PcV04SyJ/N1q/8qgCNe6U97KbPCS1BTksEuHFLYdvc8DltuhfxIUBqDZsC0bBGtl3lA==
+  dependencies:
+    debug "^2.6.9"
+    marky "^1.2.2"
+
+lit-element@^3.2.0:
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.2.tgz#d148ab6bf4c53a33f707a5168e087725499e5f2b"
+  integrity sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ==
+  dependencies:
+    "@lit/reactive-element" "^1.3.0"
+    lit-html "^2.2.0"
+
+lit-html@^2.0.0, lit-html@^2.2.0, lit-html@^2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.4.0.tgz#b510430f39a56ec959167ed1187241a4e3ab1574"
+  integrity sha512-G6qXu4JNUpY6aaF2VMfaszhO9hlWw0hOTRFDmuMheg/nDYGB+2RztUSOyrzALAbr8Nh0Y7qjhYkReh3rPnplVg==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-lit@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
-  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
+lit@^2.0.0, lit@^2.2.3:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.4.0.tgz#e33a0f463e17408f6e7f71464e6a266e60a5bb77"
+  integrity sha512-fdgzxEtLrZFQU/BqTtxFQCLwlZd9bdat+ltzSFjvWkZrs7eBmeX0L5MHUMb3kYIkuS8Xlfnii/iI5klirF8/Xg==
   dependencies:
-    "@lit/reactive-element" "^1.0.0"
-    lit-element "^3.0.0"
-    lit-html "^2.0.0"
+    "@lit/reactive-element" "^1.4.0"
+    lit-element "^3.2.0"
+    lit-html "^2.4.0"
+
+locate-path@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
+  dependencies:
+    p-locate "^4.1.0"
+
+lodash.camelcase@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+  integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
+
+lodash.get@^4.4.2:
+  version "4.4.2"
+  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
+  integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
+
+lodash@^4.17.14:
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+
+log-update@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1"
+  integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==
+  dependencies:
+    ansi-escapes "^4.3.0"
+    cli-cursor "^3.1.0"
+    slice-ansi "^4.0.0"
+    wrap-ansi "^6.2.0"
+
+lru-cache@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
+  integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
+  dependencies:
+    yallist "^4.0.0"
+
+make-dir@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.0.0"
+
+marky@^1.2.2:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
+  integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==
+
+media-typer@0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+  integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
+
+merge2@^1.3.0, merge2@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
+micromatch@^4.0.4:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
+  integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
+  dependencies:
+    braces "^3.0.2"
+    picomatch "^2.3.1"
+
+mime-db@1.52.0:
+  version "1.52.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34:
+  version "2.1.35"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
+mimic-fn@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
+  integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
+
+minimatch@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minimist@^1.2.6:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
+  integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
+
+mkdirp-classic@^0.5.2:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
+  integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
+
+mkdirp@^0.5.6:
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+  dependencies:
+    minimist "^1.2.6"
+
+mkdirp@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+
+ms@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
+
+ms@2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+ms@^2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+nanocolors@^0.2.1:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/nanocolors/-/nanocolors-0.2.13.tgz#dfd1ed0bfab05e9fe540eb6874525f0a1684099b"
+  integrity sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==
+
+nanoid@^3.1.25:
+  version "3.3.4"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
+  integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
+
+negotiator@0.6.3:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
+  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+
+nise@^5.1.1:
+  version "5.1.4"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.4.tgz#491ce7e7307d4ec546f5a659b2efe94a18b4bbc0"
+  integrity sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==
+  dependencies:
+    "@sinonjs/commons" "^2.0.0"
+    "@sinonjs/fake-timers" "^10.0.2"
+    "@sinonjs/text-encoding" "^0.7.1"
+    just-extend "^4.0.2"
+    path-to-regexp "^1.7.0"
+
+node-fetch@2.6.7:
+  version "2.6.7"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
+  integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
+  dependencies:
+    whatwg-url "^5.0.0"
+
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+object-inspect@^1.9.0:
+  version "1.12.2"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
+  integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
+
+on-finished@^2.3.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+  dependencies:
+    ee-first "1.1.1"
+
+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"
+  integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
+  dependencies:
+    wrappy "1"
+
+onetime@^5.1.0:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
+  integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
+  dependencies:
+    mimic-fn "^2.1.0"
+
+only@~0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
+  integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==
+
+open@^8.0.2:
+  version "8.4.0"
+  resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8"
+  integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==
+  dependencies:
+    define-lazy-prop "^2.0.0"
+    is-docker "^2.1.1"
+    is-wsl "^2.2.0"
+
+p-limit@^2.2.0:
+  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-locate@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+  dependencies:
+    p-limit "^2.2.0"
+
+p-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==
+
+parse5@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
+  integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
+
+parseurl@^1.3.2:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
+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"
+  integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
+
+path-parse@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+  integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
+
+path-to-regexp@^1.7.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
+  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
+  dependencies:
+    isarray "0.0.1"
+
+path-type@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
+  integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
+
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+  integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
+
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+  integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
+pkg-dir@4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
+  integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
+  dependencies:
+    find-up "^4.0.0"
+
+portfinder@^1.0.32:
+  version "1.0.32"
+  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.32.tgz#2fe1b9e58389712429dc2bea5beb2146146c7f81"
+  integrity sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==
+  dependencies:
+    async "^2.6.4"
+    debug "^3.2.7"
+    mkdirp "^0.5.6"
+
+progress@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
+proxy-from-env@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+  integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+
+pump@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+  integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+punycode@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
+  integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
+
+puppeteer-core@^13.1.3:
+  version "13.7.0"
+  resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-13.7.0.tgz#3344bee3994163f49120a55ddcd144a40575ba5b"
+  integrity sha512-rXja4vcnAzFAP1OVLq/5dWNfwBGuzcOARJ6qGV7oAZhnLmVRU8G5MsdeQEAOy332ZhkIOnn9jp15R89LKHyp2Q==
+  dependencies:
+    cross-fetch "3.1.5"
+    debug "4.3.4"
+    devtools-protocol "0.0.981744"
+    extract-zip "2.0.1"
+    https-proxy-agent "5.0.1"
+    pkg-dir "4.2.0"
+    progress "2.0.3"
+    proxy-from-env "1.1.0"
+    rimraf "3.0.2"
+    tar-fs "2.1.1"
+    unbzip2-stream "1.4.3"
+    ws "8.5.0"
+
+qs@^6.5.2:
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
+  integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
+  dependencies:
+    side-channel "^1.0.4"
+
+queue-microtask@^1.2.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
+  integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+
+raw-body@^2.3.3:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
+  integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
+  dependencies:
+    bytes "3.1.2"
+    http-errors "2.0.0"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
+readable-stream@^3.1.1, readable-stream@^3.4.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
+  integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
+  dependencies:
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
+
+readdirp@~3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+  integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+  dependencies:
+    picomatch "^2.2.1"
+
+reduce-flatten@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
+  integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
+
+resolve-path@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
+  integrity sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==
+  dependencies:
+    http-errors "~1.6.2"
+    path-is-absolute "1.0.1"
+
+resolve@^1.19.0:
+  version "1.22.1"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
+  integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
+  dependencies:
+    is-core-module "^2.9.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
+restore-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
+  integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
+  dependencies:
+    onetime "^5.1.0"
+    signal-exit "^3.0.2"
+
+reusify@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
+  integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
+
+rimraf@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"
+
+rollup@^2.67.0:
+  version "2.79.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
+  integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==
+  optionalDependencies:
+    fsevents "~2.3.2"
+
+run-parallel@^1.1.9:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
+  integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
+  dependencies:
+    queue-microtask "^1.2.2"
+
+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"
+
+safe-buffer@5.2.1, 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-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==
+
+"safer-buffer@>= 2.1.2 < 3":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+semver@^6.0.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+
+semver@^7.3.4:
+  version "7.3.8"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
+  integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
+  dependencies:
+    lru-cache "^6.0.0"
+
+setprototypeof@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
+
+setprototypeof@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
+side-channel@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
+  integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
+  dependencies:
+    call-bind "^1.0.0"
+    get-intrinsic "^1.0.2"
+    object-inspect "^1.9.0"
+
+signal-exit@^3.0.2:
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
+  integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
+
+sinon@^13.0.0:
+  version "13.0.2"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.2.tgz#c6a8ddd655dc1415bbdc5ebf0e5b287806850c3a"
+  integrity sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA==
+  dependencies:
+    "@sinonjs/commons" "^1.8.3"
+    "@sinonjs/fake-timers" "^9.1.2"
+    "@sinonjs/samsam" "^6.1.1"
+    diff "^5.0.0"
+    nise "^5.1.1"
+    supports-color "^7.2.0"
+
+slash@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
+  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
+
+slice-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b"
+  integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==
+  dependencies:
+    ansi-styles "^4.0.0"
+    astral-regex "^2.0.0"
+    is-fullwidth-code-point "^3.0.0"
+
+source-map@^0.7.3:
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656"
+  integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==
+
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
+"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 sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
+
+string-width@^4.1.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+string_decoder@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+  dependencies:
+    safe-buffer "~5.2.0"
+
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
+supports-color@^5.3.0:
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+  integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
+  dependencies:
+    has-flag "^3.0.0"
+
+supports-color@^7.1.0, supports-color@^7.2.0:
+  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"
+
+supports-preserve-symlinks-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
+table-layout@^1.0.2:
+  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"
+
+tar-fs@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
+  integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==
+  dependencies:
+    chownr "^1.1.1"
+    mkdirp-classic "^0.5.2"
+    pump "^3.0.0"
+    tar-stream "^2.1.4"
+
+tar-stream@^2.1.4:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
+  integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
+  dependencies:
+    bl "^4.0.3"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
+through@^2.3.8:
+  version "2.3.8"
+  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+  integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
+
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+  dependencies:
+    is-number "^7.0.0"
+
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
+tr46@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9"
+  integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==
+  dependencies:
+    punycode "^2.1.1"
+
+tr46@~0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+  integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+
+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==
+
+tsscmp@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
+  integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
+
+type-detect@4.0.8, type-detect@^4.0.8:
+  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.21.3:
+  version "0.21.3"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
+  integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
+
+type-is@^1.6.16:
+  version "1.6.18"
+  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+  dependencies:
+    media-typer "0.3.0"
+    mime-types "~2.1.24"
+
+typical@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
+  integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+
+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==
+
+ua-parser-js@^1.0.2:
+  version "1.0.32"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.32.tgz#786bf17df97de159d5b1c9d5e8e9e89806f8a030"
+  integrity sha512-dXVsz3M4j+5tTiovFVyVqssXBu5HM47//YSOeZ9fQkdDKkfzv2v3PP1jmH6FUyPW+yCSn7aBVK1fGGKNhowdDA==
+
+unbzip2-stream@1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7"
+  integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==
+  dependencies:
+    buffer "^5.2.1"
+    through "^2.3.8"
+
+unpipe@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+  integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
+
+util-deprecate@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
+v8-to-istanbul@^8.0.0:
+  version "8.1.1"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed"
+  integrity sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==
+  dependencies:
+    "@types/istanbul-lib-coverage" "^2.0.1"
+    convert-source-map "^1.6.0"
+    source-map "^0.7.3"
+
+vary@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+  integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+
+webidl-conversions@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+  integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
+
+webidl-conversions@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
+  integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
+
+whatwg-url@^11.0.0:
+  version "11.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018"
+  integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==
+  dependencies:
+    tr46 "^3.0.0"
+    webidl-conversions "^7.0.0"
+
+whatwg-url@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+  integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
+  dependencies:
+    tr46 "~0.0.3"
+    webidl-conversions "^3.0.0"
+
+wordwrapjs@^4.0.0:
+  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.2.0"
+
+wrap-ansi@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
+  integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
+  dependencies:
+    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"
+  integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
+
+ws@8.5.0:
+  version "8.5.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f"
+  integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==
+
+ws@^7.4.2:
+  version "7.5.9"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
+  integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
+
+yallist@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
+  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
+
+yauzl@^2.10.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+  integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    fd-slicer "~1.1.0"
+
+ylru@^1.2.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
+  integrity sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 62d1d92..049f1d3 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,12 +1,12 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_binary")
 load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:js.bzl", "karma_test")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
 
 package(default_visibility = ["//visibility:public"])
 
 genrule2(
     name = "fonts",
     srcs = [
+        "//lib/fonts:material-icons",
         "//lib/fonts:robotofonts",
     ],
     outs = ["fonts.zip"],
@@ -20,26 +20,38 @@
     output_to_bindir = 1,
 )
 
-go_binary(
-    name = "devserver",
-    srcs = ["server.go"],
+filegroup(
+    name = "web-test-runner_config-sources",
+    srcs = glob([
+        "package.json",
+        "web-test-runner.config.mjs",
+    ]),
+)
+
+nodejs_test(
+    name = "web-test-runner",
+    size = "large",
+    chdir = package_name(),
     data = [
-        ":fonts.zip",
+        ":web-test-runner_config-sources",
+        "//polygerrit-ui/app:web-test-runner_app-sources",
         "@ui_dev_npm//:node_modules",
         "@ui_npm//:node_modules",
     ],
-    deps = [
-        "@org_golang_x_tools//godoc/vfs/httpfs:go_default_library",
-        "@org_golang_x_tools//godoc/vfs/zipfs:go_default_library",
+    entry_point = "@ui_dev_npm//:node_modules/@web/test-runner/dist/bin.js",
+    tags = [
+        "local",
+        "manual",
     ],
 )
 
+# This is a dependency for karma_test rule in js.bzl that is only used by
+# plugins.
 sh_binary(
     name = "karma_bin",
     srcs = ["@ui_dev_npm//:node_modules/karma/bin/karma"],
     data = [
         "@ui_dev_npm//@open-wc/karma-esm",
-        "@ui_dev_npm//chai",
         "@ui_dev_npm//karma-chrome-launcher",
         "@ui_dev_npm//karma-mocha",
         "@ui_dev_npm//karma-mocha-reporter",
@@ -48,8 +60,5 @@
     ],
 )
 
-karma_test(
-    name = "karma_test",
-    srcs = ["karma_test.sh"],
-    data = ["//polygerrit-ui/app:test-srcs-fg"],
-)
+# This is used by plugins.
+exports_files(["karma.conf.js"])
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index a636119..d2b865b 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -103,29 +103,6 @@
         this._count++;
     }
 }
-
-// app-context.js
-export const appContext = {
-    //...
-    mouseClickCounterService: null,
-    keypressCounterService: null,
-};
-
-// services/app-context-init.js
-export function initAppContext() {
-    //...
-    // Add the following line before the Object.defineProperties(appContext, registeredServices);
-    addService('mouseClickCounterService', () => new CounterService());
-    addService('keypressCounterService', () => new CounterService());
-    // If a service depends on other services, pass dependencies as shown below
-    // If circular dependencies exist, app-init-context tests fail with timeout or stack overflow
-    // (we are  going to improve it in the future)
-    addService('analyticService', () =>
-        new CounterService(appContext.mouseClickCounterService, appContext.keypressCounterService));
-    //...
-    // This following line must remains the last one in the initAppContext
-    Object.defineProperties(appContext, registeredServices);
-}
 ```
 
 **Bad:**
@@ -146,11 +123,7 @@
 If a class/service depends on some other service (or multiple services), the class must accept all dependencies
 as parameters in the constructor.
 
-Do not use appContext anywhere else in a class.
-
-**Note:** This rule doesn't apply for HTML/Polymer elements classes. A browser creates instances of such classes
-implicitly and calls the constructor without parameters. See
-[Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
+Do not use getAppContext() anywhere else in a class.
 
 **Good:**
 ```Javascript
@@ -166,107 +139,20 @@
 
 **Bad:**
 ```Javascript
-import {appContext} from "./app-context";
+import {getAppContext} from "./app-context";
 
 export class UserService {
     constructor() {
         // Incorrect: you must pass all dependencies to a constructor
-        this._restApiService = appContext.restApiService;
+        this._restApiService = getAppContext().restApiService;
     }
 }
 
 export class AdminService {
     isAdmin() {
         // Incorrect: you must pass all dependencies to a constructor
-        return appContext.restApiService.sendRequest(...);
+        return getAppContext().restApiService.sendRequest(...);
     }
 }
 
 ```
-
-## <a name="assign-dependencies-in-html-element-constructor"></a>Assign required services in a HTML/Polymer element constructor
-If a class is a custom HTML/Polymer element, the class must assign all required services in the constructor.
-A browser creates instances of such classes implicitly, so it is impossible to pass anything as a parameter to
-the element's class constructor.
-
-Do not use appContext anywhere except the constructor of the class.
-
-**Note for legacy elements:** If a polymer element extends a LegacyElementMixin and overrides the `created()` method,
-move all code from this method to a constructor right after the call to a `super()`
-([example](#assign-dependencies-legacy-element-example)). The `created()`
-method is [deprecated](https://polymer-library.polymer-project.org/2.0/docs/about_20#lifecycle-changes) and is called
-when a super (i.e. base) class constructor is called. If you are unsure about moving the code from the `created` method
-to the class constructor, consult with the source code:
-[`LegacyElementMixin._initializeProperties`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/legacy/legacy-element-mixin.js#L318)
-and
-[`PropertiesChanged.constructor`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/mixins/properties-changed.js#L177)
-
-
-
-**Good:**
-```Javascript
-import {appContext} from `.../services/app-context.js`;
-
-export class MyCustomElement extends ...{
-    constructor() {
-        super(); //This is mandatory to call parent constructor
-        this._userService = appContext.userService;
-    }
-    //...
-    _getUserName() {
-        return this._userService.activeUserName();
-    }
-}
-```
-
-**Bad:**
-```Javascript
-import {appContext} from `.../services/app-context.js`;
-
-export class MyCustomElement extends ...{
-    created() {
-        // Incorrect: assign all dependencies in the constructor
-        this._userService = appContext.userService;
-    }
-    //...
-    _getUserName() {
-        // Incorrect: use appContext outside of a constructor
-        return appContext.userService.activeUserName();
-    }
-}
-```
-
-<a name="assign-dependencies-legacy-element-example"></a>
-**Legacy element:**
-
-Before:
-```Javascript
-export class MyCustomElement extends ...LegacyElementMixin(...) {
-    constructor() {
-        super();
-        someAction();
-    }
-    created() {
-        super();
-        createdAction1();
-        createdAction2();
-    }
-}
-```
-
-After:
-```Javascript
-export class MyCustomElement extends ...LegacyElementMixin(...) {
-    constructor() {
-        super();
-        // Assign services here
-        this._userService = appContext.userService;
-        // Code from the created method - put it before existing actions in constructor
-        createdAction1();
-        createdAction2();
-        // Original constructor code
-        someAction();
-    }
-    // created method is removed
-}
-```
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index cd88f52..510ce54 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -24,28 +24,13 @@
 
 Follow the instructions
 [here](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_installation)
-to get and install Bazel.
+to get and install Bazel. The `npm install -g @bazel/bazelisk` method is
+probably easiest since you will have npm as part of Nodejs.
 
 ## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
 
-**Note**: Switch between an old branch with bower_components and a new branch with ui-npm
-packages (or vice versa) can lead to some build errors. To avoid such errors clean up the build
-repository:
-```sh
-rm -rf node_modules/ \
-    polygerrit-ui/node_modules/ \
-    polygerrit-ui/app/node_modules \
-    tools/node_tools/node_modules
-
-bazel clean
-```
-
-If it doesn't help also try to run
-```sh
-bazel clean --expunge
-```
-
-The minimum nodejs version supported is 8.x+
+The minimum nodejs version supported is 10.x+. We recommend at least the latest
+LTS (v16 as of October 2022).
 
 ```sh
 # Debian experimental
@@ -53,7 +38,7 @@
 sudo apt-get install npm
 
 # OS X with Homebrew
-brew install node
+brew install node@16
 brew install npm
 ```
 
@@ -66,9 +51,12 @@
 
 We have several bazel commands to install packages we may need for FE development.
 
-For first time users to get the local server up, `npm start` should be enough and will take care of all of them for you.
+For first time users to get the local server up, `bazel build gerrit` should be enough and will take care of all of them for you.
 
 ```sh
+# Install yarn package manager
+npm install -g yarn
+
 # Install packages from root-level packages.json
 bazel fetch @npm//:node_modules
 
@@ -94,11 +82,12 @@
 
 ## Setup typescript support in the IDE
 
-Modern IDE should automatically handle typescript settings from the 
-`pollygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
-`.ts-out/pg` directory at the root of gerrit workspace and you can configure IDE
-to exclude the whole .ts-out directory. To do it in the IntelliJ IDEA click on
-this directory and select "Mark Directory As > Excluded" in the context menu.
+Modern IDEs should automatically handle typescript settings from the
+`polygerrit-ui/app/tsconfig.json` files. The `tsc` compiler places compiled
+files in the `.ts-out/pg` directory at the root of gerrit workspace and you can
+configure the IDE to exclude the whole .ts-out directory. To do it in the
+IntelliJ IDEA click on this directory and select "Mark Directory As > Excluded"
+in the context menu.
 
 However, if you receive some errors from IDE, you can try to configure IDE
 manually. For example, if IntelliJ IDEA shows
@@ -106,41 +95,29 @@
 options `--project polygerrit-ui/app/tsconfig.json` in the IDE settings.
 
 
-## Serving files locally
+## Developing locally
 
-#### Go server
+The preferred method for development is to serve the web files locally using the
+Web Dev Server and then view a running gerrit instance (local or otherwise) to
+replace its web client with the local one using the Gerrit FE Dev Helper
+extension.
 
-To test the local Polymer frontend against production data or a local test site execute:
+### Web Dev Server
+
+The [Web Dev Server](https://modern-web.dev/docs/dev-server/overview/) serves
+the compiled web files and dependencies unbundled over localhost. Start it using
+this command:
 
 ```sh
-./polygerrit-ui/run-server.sh
-
-// or
-npm run start
+yarn start
 ```
 
-These commands start the [simple hand-written Go webserver](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/server.go).
-Mostly it just switches between serving files locally and proxying the real
-server based on the file name. It also does some basic response rewriting, e.g.
-it patches the `config/server/info` response with plugin information provided on
-the command line:
-
-```sh
-./polygerrit-ui/run-server.sh --plugins=plugins/my_plugin/static/my_plugin.js
-```
+To inject plugins or other files, we use the [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd) Chrome extension.
 
 If any issues occured, please refer to the Troubleshooting section at the bottom or contact the team!
 
-## Running locally against production data
 
-### Local website
-
-Start [Go server](#go-server) and then visit http://localhost:8081
-
-The biggest draw back of this method is that you cannot log in, so cannot test
-scenarios that require it.
-
-#### Chrome extension: Gerrit FE Dev Helper
+### Chrome extension: Gerrit FE Dev Helper
 
 To be able to bypass the auth and also help improve the productivity of Gerrit FE developers,
 we created this chrome extension: [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd).
@@ -151,7 +128,7 @@
 
 To use this extension, just follow its [readme here](https://gerrit.googlesource.com/gerrit-fe-dev-helper/+/master/README.md).
 
-## Running locally against a Gerrit test site
+### Running locally against a Gerrit test site
 
 Set up a local test site once:
 
@@ -163,7 +140,7 @@
 [this command](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#run_daemon).
 
 If you want to serve the Polymer frontend directly from the sources in `polygerrit_ui/app/` instead of from the war:
-1. Start [Go server](#go-server)
+1. Start [Web Dev Server](#web-dev-server)
 2. Add the `--dev-cdn` option:
 
 ```sh
@@ -175,119 +152,49 @@
     --dev-cdn http://localhost:8081
 ```
 
+The Web Dev Server is currently not serving fonts or other static assets. Follow
+[Issue 16341](https://bugs.chromium.org/p/gerrit/issues/detail?id=16341) for
+fixing this issue.
+
 *NOTE* You can use any other cdn here, for example: https://cdn.googlesource.com/polygerrit_ui/678.0
 
 ## Running Tests
 
 For daily development you typically only want to run and debug individual tests.
-There are several ways to run tests.
+Our tests run using the
+[Web Test Runner](https://modern-web.dev/docs/test-runner/overview/). There are
+several ways to trigger tests:
 
-* Run all tests in headless mode (exactly like CI does):
+* Run all tests once:
 ```sh
-npm run test
+yarn test
 ```
-This command uses bazel rules for running frontend tests. Bazel fetches
-all nessecary dependencies and runs all required rules.
 
-* Run all tests in debug mode (the command opens Chrome browser with
-the default Karma page; you should click the "Debug" button to start testing):
+* Run all tests and then watches for changes. Change a file will trigger all
+tests affected by the changes.
 ```sh
-# The following command doesn't compile code before tests
-npm run test:debug
+yarn test:watch
 ```
 
-* Run a single test file:
-```
-# Headless mode (doesn't compile code before run)
-npm run test:single async-foreach-behavior_test.js
-
-# Debug mode (doesn't compile code before run)
-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:
-* You can configure IDE for recompiling source code on changes
-* You can use `compile:local` command for running compiler once and
-`compile:watch` for running compiler in watch mode (`compile:...` places
-compile code exactly in the `./ts-out/polygerrit-ui/app` directory)
-
+* Run all tests once under bazel:
 ```sh
-# Compile frontend once and run tests from a file:
-npm run compile:local && npm run test:single async-foreach-behavior_test.js
+./polygerrit-ui/app/run_test.sh
+```
+
+* Run a single test file and rerun on any changes affecting it:
+```
+yarn test:single "**/gr-comment_test.ts"
+```
+
+Compiling code:
+```sh
+# Compile frontend once to check for type errors:
+yarn compile:local
 
 # Watch mode:
-## Terminal 1:
-npm run compile:watch
-## Terminal 2:
-npm run test:debug async-foreach-behavior_test.js
+yarn compile:watch
 ```
 
-* You can run tests in IDE. :
-  - [IntelliJ: running unit tests on Karma](https://www.jetbrains.com/help/idea/running-unit-tests-on-karma.html#ws_karma_running)
-  - You should configure IDE to compile typescript before running tests.
-
-**NOTE**: Bazel plugin for IntelliJ has a bug - it recompiles typescript
-project only if .ts and/or .d.ts files have been changed. If only .js files
-were changed, the plugin doesn't run compiler. As a workaround, setup
-"Run npm script 'compile:local" action instead of the "Compile Typescript" in
-the "Before launch" section for IntelliJ. This is a temporary problem until
-typescript migration is complete.
-
-## Running Templates Test
-The templates test validates polymer templates. The test convert polymer
-templates into a plain typescript code and then run TS compiler. The test fails
-if TS compiler reports errors; in this case you will see TS errors in
-the log/output. Gerrit-CI automatically runs templates test.
-
-**Note**: Files defined in `ignore_templates_list` (`polygerrit-ui/app/BUILD`)
-are excluded from code generation and checking. If you don't know how to fix
-a problem, you can add a problematic template in the list.
-
-* To run test locally, use npm command:
-``` sh
-npm run polytest
-```
-
-* Often, the output from the previous command is not clear (cryptic TS errors).
-In this case, run the command
-```sh
-npm run polytest:dev
-```
-This command (re)creates the `polygerrit-ui/app/tmpl_out` directory and put
-generated files into it. For each polygerrit .ts file there is a generated file
-in the `tmpl_out` directory. If an original file doesn't contain a polymer
-template, the generated file is empty.
-
-You can open a problematic file in IDE and fix the problem. Ensure, that IDE
-uses `polygerrit-ui/app/tsconfig.json` as a project (usually, this is default).
-
-### Generated file overview
-
-A generated file starts with imports followed by a static content with
-different type definitions. You can skip this part - it doesn't contains
-anything usefule.
-
-After the static content there is a class definition. Example:
-```typescript
-export class GrCreateGroupDialogCheck extends GrCreateGroupDialog {
-  templateCheck() {
-    // Converted template
-    // Each HTML element from the template is wrapped into own block.
-  }
-}
-```
-
-The converted template usually quite straightforward, but in some cases
-additional functions are added. For example, `<element x=[[y.a]]>` converts into
-`el.x = y!.a` if y is a simple type. However, if y has a union type, like - `y:A|B`,
-then the generated code looks like `el.x=__f(y)!.a` (`y!.a` may result in a TS error
-if `a` is defined only in one type of a union). 
-
 ## Style guide
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
@@ -313,7 +220,7 @@
 * To run ESLint on the whole app, less some dependency code:
 
 ```sh
-npm run eslint
+yarn eslint
 ```
 
 * To run ESLint on just the subdirectory you modified:
@@ -328,21 +235,6 @@
 git diff --name-only HEAD | xargs node_modules/eslint/bin/eslint.js --ext .html,.js
 ```
 
-We also use the `polylint` tool to lint use of Polymer. To install polylint,
-execute the following command.
-
-To run polylint, execute the following command.
-
-```sh
-bazel test //polygerrit-ui/app:polylint_test
-```
-
-or
-
-```sh
-npm run polylint
-```
-
 ## Migrating tests to Typescript
 
 You can use the following steps for migrating tests to Typescript:
@@ -352,7 +244,7 @@
    ```
    // Before:
    import ... from 'x/y/z.js`
- 
+
    // After
    import .. from 'x/y/z'
    ```
@@ -421,16 +313,16 @@
 ...
 // The _robotCommentThreads declared as _robotCommentThreads?: CommentThread
 assert.equal(element._robotCommentThreads.length, 2);
-  
+
 // Fix with non-null assertion operator:
 const rows = element
   .shadowRoot!.querySelector('table')! // '!' after shadowRoot and querySelector
   .querySelectorAll('tbody tr');
 
-assert.equal(element._robotCommentThreads!.length, 2); 
+assert.equal(element._robotCommentThreads!.length, 2);
 
 // Fix with nullish coalescing operator:
- assert.equal(element._robotCommentThreads?.length, 2); 
+ assert.equal(element._robotCommentThreads?.length, 2);
 ```
 Usually the fix with `!` is preferable, because it gives more clear error
 when an intermediate property is `null/undefined`. If the _robotComments is
@@ -527,7 +419,7 @@
 
 * If a test imports a library from `polygerrit_ui/node_modules` - update
 `paths` in `polygerrit-ui/app/tsconfig_bazel_test.json`.
- 
+
 ## Contributing
 
 Our users report bugs / feature requests related to the UI through [Monorail Issues - PolyGerrit](https://bugs.chromium.org/p/gerrit/issues/list?q=component%3APolyGerrit).
@@ -556,7 +448,7 @@
 git submodule update --init --recursive
 
 // reset the workspace (please save your local changes before running this command)
-npm run clean
+yarn clean
 
 // install all dependencies and start the server
 npm start
diff --git a/polygerrit-ui/app/.eslint-ts-resolver.js b/polygerrit-ui/app/.eslint-ts-resolver.js
index dc578f9..e4ba115 100644
--- a/polygerrit-ui/app/.eslint-ts-resolver.js
+++ b/polygerrit-ui/app/.eslint-ts-resolver.js
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
index bb30f23..087a049 100644
--- a/polygerrit-ui/app/.eslintignore
+++ b/polygerrit-ui/app/.eslintignore
@@ -2,4 +2,3 @@
 **/rollup.config.js
 node_modules_licenses
 !.eslintrc-bazel.js
-tmpl_out
diff --git a/polygerrit-ui/app/.eslintrc-bazel.js b/polygerrit-ui/app/.eslintrc-bazel.js
index 9a51242..fa6c274 100644
--- a/polygerrit-ui/app/.eslintrc-bazel.js
+++ b/polygerrit-ui/app/.eslintrc-bazel.js
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // This file has a special settings for bazel.
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 14f9e8c..c519465 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // Do not add any bazel-specific properties in this file to keep it clean.
@@ -88,7 +77,11 @@
     // https://eslint.org/docs/rules/no-console
     'no-console': [
       'error',
-      {allow: ['warn', 'error', 'info', 'assert', 'group', 'groupEnd']},
+      {
+        allow: [
+          'warn', 'error', 'info', 'debug', 'assert', 'group', 'groupEnd',
+        ],
+      },
     ],
     // https://eslint.org/docs/rules/no-multiple-empty-lines
     'no-multiple-empty-lines': ['error', {max: 1}],
@@ -174,7 +167,9 @@
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-syntax
     'jsdoc/check-syntax': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-tag-names
-    'jsdoc/check-tag-names': 0,
+    'jsdoc/check-tag-names': ['error', {
+      definedTags: ['attr', 'lit', 'mixinFunction', 'mixinClass', 'polymer'],
+    }],
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-types
     'jsdoc/check-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-implements-on-classes
@@ -232,9 +227,13 @@
     'import/no-unused-modules': 2,
     // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
     'import/no-default-export': 2,
-    // Prevents certain identifiers being used.
-    // Prefer flush() over flushAsynchronousOperations().
-    'id-blacklist': ['error', 'flushAsynchronousOperations'],
+    'regex/invalid': [
+      'error', [{
+        // eslint-disable-next-line regex/invalid
+        regex: 'Licensed under',
+        message: 'Please use SPDX license headers.',
+      }],
+    ],
   },
 
   // List of allowed globals in all files
@@ -267,9 +266,6 @@
         '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: {
@@ -292,7 +288,26 @@
       files: ['**/*.ts'],
       extends: [require.resolve('gts/.eslintrc.json')],
       rules: {
+        'regex/invalid': [
+          'error', [{
+            regex: '\'lit/decorators\'',
+            message: 'use \'lit/decorators.js\' instead',
+            replacement: '\'lit/decorators.js\'',
+          }, {
+            regex: '\'lit/directives/([^.\']*)\'',
+            message: 'use \'lit/directives/foo.js\' instead',
+            replacement: {
+              function: 'return "\'lit/directives/" + $[1] + ".js\'"',
+            },
+          }],
+        ],
         'no-restricted-imports': ['error', {
+          name: 'lit-html/static',
+          message: 'Use lit instead',
+        }, {
+          name: '@lit/reactive-element',
+          message: 'Use lit instead',
+        }, {
           name: '@polymer/decorators/lib/decorators',
           message: 'Use @polymer/decorators instead',
         }],
@@ -301,6 +316,13 @@
         '@typescript-eslint/ban-ts-comment': 'off',
         // The following rules is required to match internal google rules
         '@typescript-eslint/restrict-plus-operands': 'error',
+        '@typescript-eslint/no-unnecessary-type-assertion': 'error',
+        'require-await': 'off',
+        '@typescript-eslint/require-await': 'error',
+        '@typescript-eslint/no-confusing-void-expression': [
+          'error',
+          {ignoreArrowShorthand: true},
+        ],
         '@typescript-eslint/no-unused-vars': [
           'error',
           {argsIgnorePattern: '^_'},
@@ -309,7 +331,7 @@
         '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
+        // At the same tigit llme, we are using typescript in a strict mode and
         // it catches almost all errors related to invalid usage of this.
         'no-invalid-this': 'off',
 
@@ -331,6 +353,7 @@
       ],
       rules: {
         '@typescript-eslint/no-explicit-any': 'off',
+        '@typescript-eslint/require-await': 'off',
       },
     },
     {
@@ -350,13 +373,6 @@
         // 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',
         setup: 'readonly',
         sinon: 'readonly',
@@ -366,8 +382,6 @@
         suiteTeardown: 'readonly',
         teardown: 'readonly',
         test: 'readonly',
-        fixtureFromElement: 'readonly',
-        fixtureFromTemplate: 'readonly',
       },
     },
     {
@@ -419,15 +433,17 @@
         'lit/attribute-value-entities': 'error',
         'lit/binding-positions': 'error',
         'lit/no-duplicate-template-bindings': 'error',
+        'lit/no-invalid-escape-sequences': 'error',
         'lit/no-invalid-html': 'error',
         'lit/no-legacy-template-syntax': 'error',
-        'lit/no-property-change-update': 'error',
-        'lit/no-invalid-escape-sequences': 'error',
         'lit/no-legacy-imports': 'error',
         'lit/no-private-properties': 'error',
+        'lit/no-property-change-update': 'error',
+        'lit/no-template-bind': 'error',
         'lit/no-useless-template-literals': 'error',
         'lit/no-value-attribute': 'error',
         'lit/prefer-static-styles': 'error',
+        'lit/quoted-expressions': ['error', 'never'],
       },
     },
   ],
@@ -445,5 +461,11 @@
       node: {},
       [path.resolve(__dirname, './.eslint-ts-resolver.js')]: {},
     },
+    'jsdoc': {
+      tagNamePreference: {
+        returns: 'return',
+        file: 'fileoverview',
+      },
+    },
   },
 };
diff --git a/polygerrit-ui/app/.gitignore b/polygerrit-ui/app/.gitignore
index c45bac3..38a8371 100644
--- a/polygerrit-ui/app/.gitignore
+++ b/polygerrit-ui/app/.gitignore
@@ -1,4 +1,3 @@
 /node_modules/
 /package-lock.json
 /plugins/
-/tmpl_out/
diff --git a/polygerrit-ui/app/.prettierrc.js b/polygerrit-ui/app/.prettierrc.js
index fbb87c6..8f353bf 100644
--- a/polygerrit-ui/app/.prettierrc.js
+++ b/polygerrit-ui/app/.prettierrc.js
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 module.exports = {
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 47c820a..6df4456 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,7 +1,6 @@
 load(":rules.bzl", "polygerrit_bundle")
 load("//tools/js:eslint.bzl", "eslint")
-load("//tools/js:template_checker.bzl", "transform_polymer_templates")
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test", "npm_package_bin")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
 load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
 
 package(default_visibility = ["//visibility:public"])
@@ -15,12 +14,13 @@
     "embed",
     "gr-diff",
     "mixins",
-    "samples",
+    "models",
     "scripts",
     "services",
     "styles",
     "types",
     "utils",
+    "workers",
 ]
 
 ts_config(
@@ -72,15 +72,11 @@
         exclude = [
             "node_modules/**",
             "node_modules_licenses/**",
-            "tmpl_out/**",  # This directory is created by template checker in dev-mode
             "rollup.config.js",
         ],
     ),
     allow_js = True,
     incremental = True,
-    # The same outdir also appears in the following files:
-    # wct_test.sh
-    # karma.conf.js
     out_dir = "_pg_with_tests_out",
     tsc = "//tools/node_tools:tsc-bin",
     tsconfig = ":ts_config_bazel_test",
@@ -90,95 +86,6 @@
     ],
 )
 
-# Template checker reports problems in the following files. Ignore the files,
-# so template tests pass.
-# TODO: fix problems reported by template checker in these files.
-ignore_templates_list = [
-    "elements/admin/gr-admin-view/gr-admin-view_html.ts",
-    "elements/admin/gr-group-members/gr-group-members_html.ts",
-    "elements/admin/gr-permission/gr-permission_html.ts",
-    "elements/admin/gr-plugin-list/gr-plugin-list_html.ts",
-    "elements/admin/gr-repo-access/gr-repo-access_html.ts",
-    "elements/admin/gr-repo-commands/gr-repo-commands_html.ts",
-    "elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts",
-    "elements/admin/gr-repo/gr-repo_html.ts",
-    "elements/admin/gr-rule-editor/gr-rule-editor_html.ts",
-    "elements/change-list/gr-change-list-view/gr-change-list-view_html.ts",
-    "elements/change-list/gr-change-list/gr-change-list_html.ts",
-    "elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts",
-    "elements/change/gr-change-actions/gr-change-actions_html.ts",
-    "elements/change/gr-change-metadata/gr-change-metadata_html.ts",
-    "elements/change/gr-change-requirements/gr-change-requirements_html.ts",
-    "elements/change/gr-change-view/gr-change-view_html.ts",
-    "elements/change/gr-file-list-header/gr-file-list-header_html.ts",
-    "elements/change/gr-file-list/gr-file-list_html.ts",
-    "elements/change/gr-label-score-row/gr-label-score-row_html.ts",
-    "elements/change/gr-message/gr-message_html.ts",
-    "elements/change/gr-messages-list/gr-messages-list_html.ts",
-    "elements/change/gr-reply-dialog/gr-reply-dialog_html.ts",
-    "elements/change/gr-reviewer-list/gr-reviewer-list_html.ts",
-    "elements/change/gr-thread-list/gr-thread-list_html.ts",
-    "elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts",
-    "elements/diff/gr-diff-host/gr-diff-host_html.ts",
-    "elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts",
-    "elements/diff/gr-diff-view/gr-diff-view_html.ts",
-    "elements/diff/gr-diff/gr-diff_html.ts",
-    "elements/gr-app-element_html.ts",
-    "elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts",
-    "elements/shared/gr-account-list/gr-account-list_html.ts",
-]
-
-sources_for_template_checking = glob(
-    [src_dir + "/**/*" + ext for src_dir in src_dirs for ext in [
-        ".ts",
-    ]],
-    exclude = [
-        "**/*_test.ts",
-    ] + ignore_templates_list,
-)
-
-# Transform templates into a .ts files.
-templates_srcs = transform_polymer_templates(
-    name = "template_test",
-    srcs = sources_for_template_checking,
-    out_tsconfig = "tsconfig_template_test.json",
-    tsconfig = "tsconfig_bazel.json",
-    deps = [
-        "tsconfig.json",
-        "tsconfig_bazel.json",
-        "@ui_npm//:node_modules",
-    ],
-)
-
-# After templates are converted into a typescript code, the TS compiler should check that the
-# converted code doesn't have the error (i.e. templates don't have problems).
-# The input to the compiler is: the converted (i.e. autogenerated) code + original polygerrit code;
-# the output (i.e. js code) is not needed (we only care wheather the code has error or not).
-# The existing ts_project rule can't compile a mix of a generated and a non-generated code, so it
-# can't be used for the purpose of template checking.
-# Because the output of TS compiler is not needed, the simplest workaround is to run typescript
-# compiler from command line using the sh_test rule. The compiler exits with non-zero return code if
-# errors found and sh_test fails.
-sh_test(
-    name = "polylint_test",
-    srcs = [":compile_generated_templates.sh"],
-    args = [
-        "$(location //tools/node_tools:tsc-bin)",
-        "$(location tsconfig_template_test.json)",
-    ],
-    data = [
-        "tsconfig_template_test.json",
-        "tsconfig_bazel.json",
-        "tsconfig.json",
-        "//tools/node_tools:tsc-bin",
-        "@ui_npm//:node_modules",
-    ] + templates_srcs + sources_for_template_checking,
-    tags = [
-        "local",
-        "manual",
-    ],
-)
-
 polygerrit_bundle(
     name = "polygerrit_ui",
     srcs = [":compile_pg"],
@@ -243,6 +150,7 @@
         "tsconfig_eslint.json",
         # tsconfig_eslint.json extends tsconfig.json, pass it as a dependency
         "tsconfig.json",
+        "@npm//typescript",
     ],
     extensions = [
         ".html",
@@ -251,11 +159,14 @@
     ],
     ignore = ".eslintignore",
     plugins = [
+        "@npm//@typescript-eslint/eslint-plugin",
         "@npm//eslint-config-google",
         "@npm//eslint-plugin-html",
         "@npm//eslint-plugin-import",
         "@npm//eslint-plugin-jsdoc",
+        "@npm//eslint-plugin-lit",
         "@npm//eslint-plugin-prettier",
+        "@npm//eslint-plugin-regex",
         "@npm//gts",
     ],
 )
@@ -271,20 +182,45 @@
     ) + [
         "@ui_dev_npm//:node_modules",
         "@ui_npm//:node_modules",
+        "@npm//typescript",
     ],
 )
 
-nodejs_binary(
+nodejs_test(
     name = "lit_analysis",
     data = [
         ":lit_analysis_src_code",
         "@npm//lit-analyzer",
     ],
     entry_point = "@npm//:node_modules/lit-analyzer/cli.js",
+    tags = [
+        "local",
+        "manual",
+    ],
     templated_args = [
         "**/elements/**/*.ts",
         "--strict",
         "--rules.no-property-visibility-mismatch off",
         "--rules.no-incompatible-property-type off",
+        "--rules.no-incompatible-type-binding off",
+        # TODO: We would actually like to change this to `error`, but we also
+        # want to allow certain attributes, for example `aria-description`. This
+        # would be possible, if we would run the lit-analyzer as a ts plugin.
+        # In tsconfig.json there is an option `globalAttributes` that we could
+        # use. But that is not available when running lit-analyzer as cli.
+        "--rules.no-unknown-attribute warn",
     ],
 )
+
+# app code including tests and tsconfig.json
+filegroup(
+    name = "web-test-runner_app-sources",
+    srcs = glob(
+        [
+            "**/*.ts",
+            "**/*.js",
+            "**/tsconfig.json",
+        ],
+        exclude = ["node_modules/**/*"],
+    ),
+)
diff --git a/polygerrit-ui/app/api/BUILD_for_publishing_api_only b/polygerrit-ui/app/api/BUILD_for_publishing_api_only
index 9d3029b..67a26cd 100644
--- a/polygerrit-ui/app/api/BUILD_for_publishing_api_only
+++ b/polygerrit-ui/app/api/BUILD_for_publishing_api_only
@@ -38,6 +38,7 @@
 # Use this rule for publishing the js plugin api as a package to the npm repo.
 pkg_npm(
     name = "js_plugin_api_npm_package",
+    package_name = "@gerritcodereview/typescript-api",
     srcs = glob(
         ["**/*"],
         exclude = [
diff --git a/polygerrit-ui/app/api/admin.ts b/polygerrit-ui/app/api/admin.ts
index 0606153..823f3dd 100644
--- a/polygerrit-ui/app/api/admin.ts
+++ b/polygerrit-ui/app/api/admin.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /** Interface for menu link */
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index 5922e5e..c394ef7 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -1,20 +1,9 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {CoverageRange, Side} from './diff';
+import {CoverageRange} from './diff';
 import {ChangeInfo} from './rest-api';
 
 /**
@@ -39,18 +28,5 @@
    * 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;
+  setCoverageProvider(coverageProvider: CoverageProvider): void;
 }
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts
index d813beb..56d25d4 100644
--- a/polygerrit-ui/app/api/attribute-helper.ts
+++ b/polygerrit-ui/app/api/attribute-helper.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 export declare interface AttributeHelperPluginApi {
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
index 4380195..721df03 100644
--- a/polygerrit-ui/app/api/change-actions.ts
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {HttpMethod} from './rest';
 
@@ -42,7 +31,6 @@
   DELETE_EDIT = 'deleteEdit',
   EDIT = 'edit',
   FOLLOW_UP = 'followup',
-  IGNORE = 'ignore',
   MOVE = 'move',
   PRIVATE = 'private',
   PRIVATE_DELETE = 'private.delete',
@@ -56,7 +44,6 @@
   REVIEWED = 'reviewed',
   STOP_EDIT = 'stopEdit',
   SUBMIT = 'submit',
-  UNIGNORE = 'unignore',
   UNREVIEWED = 'unreviewed',
   WIP = 'wip',
   INCLUDED_IN = 'includedIn',
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
index 0b00b10..31a9179 100644
--- a/polygerrit-ui/app/api/change-reply.ts
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {ChangeInfo} from './rest-api';
 
@@ -30,7 +19,7 @@
 ) => void;
 
 export declare interface ChangeReplyPluginApi {
-  getLabelValue(label: string): string;
+  getLabelValue(label: string): string | number | undefined;
 
   setLabelValue(label: string, value: string): void;
 
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index d52a555..b05e70a 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {CommentRange} from './core';
 import {ChangeInfo} from './rest-api';
@@ -97,6 +86,11 @@
   actions?: Action[];
 
   /**
+   * Shown prominently in the change summary below the run chips.
+   */
+  summaryMessage?: string;
+
+  /**
    * 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.
    */
@@ -186,7 +180,11 @@
    * RUNNABLE:  Not run (yet). Mostly useful for runs that the user can trigger
    *            (see actions) and for indicating that a check was not run at a
    *            later attempt. Cannot contain results.
-   * RUNNING:   Subsumes "scheduled".
+   * RUNNING:   The run is in progress.
+   * SCHEDULED: Refinement of RUNNING: The run was triggered, but is not yet
+   *            running. It may have to wait for resources or for some other run
+   *            to finish. The UI treats this mostly identical to RUNNING, but
+   *            uses a differnt icon.
    * 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.
@@ -221,12 +219,16 @@
    * each plugin. The most important actions (which get special UI treatment)
    * are:
    * "Run" for RUNNABLE and COMPLETED runs.
-   * "Cancel" for RUNNING runs.
+   * "Cancel" for RUNNING and SCHEDULED runs.
    */
   actions?: Action[];
 
   scheduledTimestamp?: Date;
   startedTimestamp?: Date;
+  /**
+   * For RUNNING runs this is considered to be an estimate of when the run will
+   * be finished.
+   */
   finishedTimestamp?: Date;
 
   /**
@@ -316,9 +318,11 @@
   errorMessage?: string;
 }
 
+/** See CheckRun.status for documentation. */
 export enum RunStatus {
   RUNNABLE = 'RUNNABLE',
   RUNNING = 'RUNNING',
+  SCHEDULED = 'SCHEDULED',
   COMPLETED = 'COMPLETED',
 }
 
@@ -407,7 +411,11 @@
   links?: Link[];
 
   /**
-   * Links to lines of code. The referenced path must be part of this patchset.
+   * Link to lines of code. The referenced path must be part of this patchset.
+   *
+   * Only one code pointer is supported. If the array contains, more than one
+   * pointer, then all the other pointers will be ignored. Support for multiple
+   * code pointers will only added on demand.
    */
   codePointers?: CodePointer[];
 
@@ -422,6 +430,35 @@
    * Make blocking, Downgrade severity.
    */
   actions?: Action[];
+
+  /**
+   * Optionally you can provide fixes that would solve the issue reported. The
+   * user will then see a "SHOW FIX" button for previewing the fix in a dialog,
+   * whichs allows the user to apply the fix. That will create a new EDIT
+   * patchset or use the exiting EDIT patchset, so the user can also apply fixes
+   * from multiple check results.
+   *
+   * Normally, you would only provide one fix, but you can also provide multiple
+   * different options to the user to choose from. Each fix may contain one or
+   * more replacements, each being a modification of one file. These files do
+   * not have to be part of the change yet.
+   */
+  fixes?: Fix[];
+}
+
+export declare interface Fix {
+  description?: string;
+  replacements: Replacement[];
+}
+
+export declare interface Replacement {
+  /**
+   * For example `polygerrit-ui/app/package.json`.
+   * `/COMMIT_MSG` is not supported yet.
+   */
+  path: string;
+  range: CommentRange;
+  replacement: string;
 }
 
 export enum Category {
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
index af7fc40..c44edfb 100644
--- a/polygerrit-ui/app/api/core.ts
+++ b/polygerrit-ui/app/api/core.ts
@@ -4,19 +4,8 @@
  * 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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 905d6be..e3b3ad4 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -5,19 +5,8 @@
  * 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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 import {CommentRange, CursorMoveResult} from './core';
@@ -38,10 +27,16 @@
  * 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;
+  /**
+   * Meta information about the file on side A as a DiffFileMetaInfo entity.
+   * Not set when change_type is ADDED.
+   */
+  meta_a?: DiffFileMetaInfo;
+  /**
+   * Meta information about the file on side B as a DiffFileMetaInfo entity.
+   * Not set when change_type is DELETED.
+   */
+  meta_b?: DiffFileMetaInfo;
   /** The type of change (ADDED, MODIFIED, DELETED, RENAMED COPIED, REWRITE). */
   change_type: ChangeType;
   /** Intraline status (OK, ERROR, TIMEOUT). */
@@ -50,6 +45,8 @@
   content: DiffContent[];
   /** Whether the file is binary. */
   binary?: boolean;
+  /** A list of strings representing the patch set diff header. */
+  diff_header?: string[];
 }
 
 /**
@@ -165,7 +162,7 @@
    * 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: {
+  range?: {
     start: number;
     end: number;
   };
@@ -204,7 +201,7 @@
   syntax_highlighting?: boolean;
   tab_size: number;
   font_size: number;
-  // TODO: Missing documentation
+  // Hides the FILE and LOST diff rows. Default is TRUE.
   show_file_comment_button?: boolean;
   line_wrapping?: boolean;
 }
@@ -245,6 +242,20 @@
   image_diff_prefs?: ImageDiffPreferences;
   responsive_mode?: DiffResponsiveMode;
   num_lines_rendered_at_once?: number;
+  /**
+   * If enabled, then a new (experimental) diff rendering is used that is
+   * based on Lit components and multiple rendering passes. This is planned to
+   * be a temporary setting until the experiment is concluded.
+   */
+  use_lit_components?: boolean;
+  show_sign_col?: boolean;
+  /**
+   * The default view mode is SIDE_BY_SIDE.
+   *
+   * Note that gr-diff also still supports setting viewMode as a dedicated
+   * property on <gr-diff>. TODO: Migrate usages to RenderPreferences.
+   */
+  view_mode?: DiffViewMode;
 }
 
 /**
@@ -284,7 +295,9 @@
 }
 
 export declare interface LineRange {
+  /** 1-based, inclusive. */
   start_line: number;
+  /** 1-based, inclusive. */
   end_line: number;
 }
 
@@ -323,6 +336,16 @@
   lineNum: LineNumber;
 }
 
+// TODO: Currently unused and not fired.
+export declare interface RenderProgressEventDetail {
+  linesRendered: number;
+}
+
+export declare interface DisplayLine {
+  side: Side;
+  lineNum: LineNumber;
+}
+
 /** All types of button for expanding diff sections */
 export enum ContextButtonType {
   ABOVE = 'above',
@@ -446,6 +469,14 @@
 /** An instance of the GrDiff Webcomponent */
 export declare interface GrDiff extends HTMLElement {
   /**
+   * A line that should not be collapsed, e.g. because it contains a
+   * search result, or is pointed to from the URL.
+   * This is considered during rendering, but changing this does not
+   * automatically trigger a re-render.
+   */
+  lineOfInterest?: DisplayLine;
+
+  /**
    * Return line number element for reading only,
    *
    * This is useful e.g. to determine where on screen certain lines are,
@@ -484,5 +515,20 @@
 
   createCommentInPlace(): void;
   resetScrollMode(): void;
-  moveToLineNumber(lineNum: number, side: Side, path?: string): void;
+
+  /**
+   * Moves to a specific line number in the diff
+   *
+   * @param lineNum which line number should be selected
+   * @param side which side should be selected
+   * @param path file path for the file that should be selected
+   * @param intentionalMove Defines if move-related controls should be applied
+   * (e.g. GrCursorManager.focusOnMove)
+   **/
+  moveToLineNumber(
+    lineNum: number,
+    side: Side,
+    path?: string,
+    intentionalMove?: boolean
+  ): void;
 }
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index 520aeec..af481fd 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -5,19 +5,8 @@
  * bundles, which cannot directly import the classes from their modules.
  *
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 import {
@@ -34,8 +23,9 @@
       GrDiffCursor: {new (): GrDiffCursor};
       TokenHighlightLayer: {
         new (
-          container?: HTMLElement,
-          listener?: TokenHighlightListener
+          container: HTMLElement,
+          listener?: TokenHighlightListener,
+          getTokenQueryContainer?: () => HTMLElement
         ): DiffLayer;
       };
     };
diff --git a/polygerrit-ui/app/api/event-helper.ts b/polygerrit-ui/app/api/event-helper.ts
index 5dc15dc..5aee59e 100644
--- a/polygerrit-ui/app/api/event-helper.ts
+++ b/polygerrit-ui/app/api/event-helper.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 export type UnsubscribeCallback = () => void;
 
diff --git a/polygerrit-ui/app/api/gerrit.ts b/polygerrit-ui/app/api/gerrit.ts
index 2091eea..a5f7731 100644
--- a/polygerrit-ui/app/api/gerrit.ts
+++ b/polygerrit-ui/app/api/gerrit.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {PluginApi} from './plugin';
 import {Styles} from './styles';
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
index e75d83a..c511eb2 100644
--- a/polygerrit-ui/app/api/hook.ts
+++ b/polygerrit-ui/app/api/hook.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {ChangeInfo, ConfigInfo, RevisionInfo} from './rest-api';
 import {PluginApi} from './plugin';
diff --git a/polygerrit-ui/app/api/package.json b/polygerrit-ui/app/api/package.json
index 8af6832..79c8bb6 100644
--- a/polygerrit-ui/app/api/package.json
+++ b/polygerrit-ui/app/api/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@gerritcodereview/typescript-api",
-  "version": "3.4.4",
+  "version": "3.7.0",
   "description": "Gerrit Code Review - TypeScript API",
   "homepage": "https://www.gerritcodereview.com/",
   "browser": true,
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 7a56ff7..d3d012d 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {AdminPluginApi} from './admin';
 import {AnnotationPluginApi} from './annotation';
@@ -25,6 +14,7 @@
 import {ChangeActionsPluginApi} from './change-actions';
 import {RestPluginApi} from './rest';
 import {HookApi, RegisterOptions} from './hook';
+import {StylePluginApi} from './styles';
 
 export enum TargetElement {
   CHANGE_ACTIONS = 'changeactions',
@@ -33,23 +23,34 @@
 
 // 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 declare interface PluginApi {
+  /**
+   * The raw URL of the plugin's js bundle, e.g.:
+   * https://cdn.googlesource.com/polygerrit_assets/533.0/plugins/codemirror_editor/static/codemirror_editor.js'
+   */
   _url?: URL;
+  /**
+   * The base path of plugin related resources. Depends on whether the plugin
+   * was loaded from the same origin as the Gerrit web app itself.
+   *
+   * Same origin: The base path of all Gerrit URLs, e.g.:
+   * https://gerrit-review.googlesource.com/
+   *
+   * Different origin: The root path of plugin files, e.g.:
+   * https://cdn.googlesource.com/polygerrit_assets/533.0/plugins/codemirror_editor/'
+   */
+  url(): string;
   admin(): AdminPluginApi;
   annotationApi(): AnnotationPluginApi;
   attributeHelper(element: Element): AttributeHelperPluginApi;
@@ -77,9 +78,9 @@
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi<T>;
-  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;
+  styleApi(): StylePluginApi;
 }
diff --git a/polygerrit-ui/app/api/popup.ts b/polygerrit-ui/app/api/popup.ts
index d265ee6..d9a9e3c 100644
--- a/polygerrit-ui/app/api/popup.ts
+++ b/polygerrit-ui/app/api/popup.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 export declare interface PopupPluginApi {
diff --git a/polygerrit-ui/app/api/reporting.ts b/polygerrit-ui/app/api/reporting.ts
index c3655bb..59c5cb8 100644
--- a/polygerrit-ui/app/api/reporting.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -1,23 +1,33 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventDetails = any;
 
+export enum Deduping {
+  /**
+   * Only report the event once per session, even if the event details are
+   * different.
+   */
+  EVENT_ONCE_PER_SESSION = 'EVENT_ONCE_PER_SESSION',
+  /**
+   * Only report the event once per change, even if the event details are
+   * different.
+   */
+  EVENT_ONCE_PER_CHANGE = 'EVENT_ONCE_PER_CHANGE',
+  /** Only report these exact event details once per session. */
+  DETAILS_ONCE_PER_SESSION = 'DETAILS_ONCE_PER_SESSION',
+  /** Only report these exact event details once per change. */
+  DETAILS_ONCE_PER_CHANGE = 'DETAILS_ONCE_PER_CHANGE',
+}
+export declare interface ReportingOptions {
+  /** Set this, if you don't want to report *every* time. */
+  deduping?: Deduping;
+}
+
 export declare interface ReportingPluginApi {
   reportInteraction(eventName: string, details?: EventDetails): void;
 
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index e2b1502..1e8f78f 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
@@ -48,7 +37,7 @@
 }
 
 /**
- * @desc Specifies status for a change
+ * Specifies status for a change
  */
 export enum ChangeStatus {
   ABANDONED = 'ABANDONED',
@@ -72,7 +61,7 @@
 }
 
 /**
- * @desc Used for server config of accounts
+ * Used for server config of accounts
  */
 export enum DefaultDisplayNameConfig {
   USERNAME = 'USERNAME',
@@ -91,7 +80,7 @@
 }
 
 /**
- * @desc The status of the file
+ * The status of the file
  */
 export enum FileInfoStatus {
   ADDED = 'A',
@@ -99,12 +88,13 @@
   RENAMED = 'R',
   COPIED = 'C',
   REWRITTEN = 'W',
-  // Modifed = 'M', // but API not set it if the file was modified
+  MODIFIED = 'M', // Not returned by BE, M is the default
   UNMODIFIED = 'U', // Not returned by BE, but added by UI for certain files
+  REVERTED = 'X', // Not returned by BE, but added by UI for certain files
 }
 
 /**
- * @desc The status of the file
+ * The status of the file
  */
 export enum GpgKeyInfoStatus {
   BAD = 'BAD',
@@ -130,7 +120,7 @@
 export enum InheritedBooleanInfoConfiguredValue {
   TRUE = 'TRUE',
   FALSE = 'FALSE',
-  INHERITED = 'INHERITED',
+  INHERIT = 'INHERIT',
 }
 
 /**
@@ -144,7 +134,7 @@
 }
 
 /**
- * @desc The status of fixing the problem
+ * The status of fixing the problem
  */
 export enum ProblemInfoStatus {
   FIXED = 'FIXED',
@@ -152,16 +142,16 @@
 }
 
 /**
- * @desc The state of the projects
+ * The state of the repository
  */
-export enum ProjectState {
+export enum RepoState {
   ACTIVE = 'ACTIVE',
   READ_ONLY = 'READ_ONLY',
   HIDDEN = 'HIDDEN',
 }
 
 /**
- * @desc The reviewer state
+ * The reviewer state
  */
 export enum RequirementStatus {
   OK = 'OK',
@@ -170,7 +160,7 @@
 }
 
 /**
- * @desc The reviewer state
+ * The reviewer state
  */
 export enum ReviewerState {
   REVIEWER = 'REVIEWER',
@@ -179,7 +169,7 @@
 }
 
 /**
- * @desc The patchset kind
+ * The patchset kind
  */
 export enum RevisionKind {
   REWORK = 'REWORK',
@@ -209,8 +199,9 @@
 
 // This is a "meta type", so it comes first and is not sored alphabetically with
 // the other types.
-export type BrandType<T, BrandName extends string> = T &
-  {[__brand in BrandName]: never};
+export type BrandType<T, BrandName extends string> = T & {
+  [__brand in BrandName]: never;
+};
 
 export type AccountId = BrandType<number, '_accountId'>;
 
@@ -286,7 +277,6 @@
   submit?: ActionInfo;
   topic?: ActionInfo;
   hashtags?: ActionInfo;
-  assignee?: ActionInfo;
   ready?: ActionInfo;
   includedIn?: ActionInfo;
 }
@@ -346,9 +336,7 @@
   width: number;
 }
 
-export type BasePatchSetNum = BrandType<'PARENT' | number, '_patchSet'>;
 // The refs/heads/ prefix is omitted in Branch name
-
 export type BranchName = BrandType<string, '_branchName'>;
 
 /**
@@ -363,7 +351,7 @@
   submit_whole_topic?: boolean;
   disable_private_changes?: boolean;
   mergeability_computation_behavior: MergeabilityComputationBehavior;
-  enable_assignee: boolean;
+  conflicts_predicate_enabled?: boolean;
 }
 
 export type ChangeId = BrandType<string, '_changeId'>;
@@ -378,7 +366,6 @@
   branch: BranchName;
   topic?: TopicName;
   attention_set?: IdToAttentionSetMap;
-  assignee?: AccountInfo;
   hashtags?: Hashtag[];
   change_id: ChangeId;
   subject: string;
@@ -389,7 +376,6 @@
   submitter?: AccountInfo;
   starred?: boolean; // not set if false
   stars?: StarLabel[];
-  reviewed?: boolean; // not set if false
   submit_type?: SubmitType;
   mergeable?: boolean;
   submittable?: boolean;
@@ -402,7 +388,7 @@
   actions?: ActionNameToActionInfoMap;
   requirements?: Requirement[];
   labels?: LabelNameToInfoMap;
-  permitted_labels?: LabelNameToValueMap;
+  permitted_labels?: LabelNameToValuesMap;
   removable_reviewers?: AccountInfo[];
   // This is documented as optional, but actually always set.
   reviewers: Reviewers;
@@ -420,10 +406,10 @@
   revert_of?: NumericChangeId;
   submission_id?: ChangeSubmissionId;
   cherry_pick_of_change?: NumericChangeId;
-  cherry_pick_of_patch_set?: PatchSetNum;
+  cherry_pick_of_patch_set?: RevisionPatchSetNum;
   contains_git_conflicts?: boolean;
-  internalHost?: string; // TODO(TS): provide an explanation what is its
   submit_requirements?: SubmitRequirementResultInfo[];
+  submit_records?: SubmitRecordInfo[];
 }
 
 // The ID of the change in the format "'<project>~<branch>~<Change-Id>'"
@@ -466,9 +452,11 @@
  */
 export declare interface CommentLinkInfo {
   match: string;
-  link?: string;
+  link: string;
+  prefix?: string;
+  suffix?: string;
+  text?: string;
   enabled?: boolean;
-  html?: string;
 }
 
 export declare interface CommentLinks {
@@ -500,7 +488,7 @@
 
 /**
  * The ConfigInfo entity contains information about the effective
- * project configuration.
+ * repository configuration.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
  */
 export declare interface ConfigInfo {
@@ -519,11 +507,12 @@
   default_submit_type: SubmitTypeInfo;
   submit_type: SubmitType;
   match_author_to_committer_date?: InheritedBooleanInfo;
-  state?: ProjectState;
+  state?: RepoState;
   commentlinks: CommentLinks;
   plugin_config?: PluginNameToPluginParametersMap;
   actions?: {[viewName: string]: ActionInfo};
   reject_empty_commit?: InheritedBooleanInfo;
+  enable_reviewer_by_email: InheritedBooleanInfo;
 }
 
 export declare interface ConfigListParameterInfo
@@ -559,7 +548,7 @@
 export declare interface ContributorAgreementInfo {
   name: string;
   description: string;
-  url: string;
+  url?: string;
   auto_verify_group?: GroupInfo;
 }
 
@@ -627,8 +616,10 @@
   old_path?: string;
   lines_inserted?: number;
   lines_deleted?: number;
-  size_delta: number; // in bytes
-  size: number; // in bytes
+  size_delta?: number; // in bytes
+  size?: number; // in bytes
+  old_mode?: number;
+  new_mode?: number;
 }
 
 /**
@@ -645,6 +636,7 @@
   report_bug_url?: string;
   // The following property is missed in doc
   primary_weblink_name?: string;
+  instance_id?: string;
 }
 
 export type GitRef = BrandType<string, '_gitRef'>;
@@ -728,12 +720,13 @@
 
 export declare interface LabelCommonInfo {
   optional?: boolean; // not set if false
+  description?: string;
 }
 
 export type LabelNameToInfoMap = {[labelName: string]: LabelInfo};
 
 // {Verified: ["-1", " 0", "+1"]}
-export type LabelNameToValueMap = {[labelName: string]: string[]};
+export type LabelNameToValuesMap = {[labelName: string]: string[]};
 
 /**
  * The LabelInfo entity contains information about a label on a change, always
@@ -748,7 +741,7 @@
 export type LabelNameToLabelTypeInfoMap = {[labelName: string]: LabelTypeInfo};
 
 /**
- * The LabelTypeInfo entity contains metadata about the labels that a project
+ * The LabelTypeInfo entity contains metadata about the labels that a repository
  * has.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#label-type-info
  */
@@ -764,7 +757,7 @@
 
 /**
  * The MaxObjectSizeLimitInfo entity contains information about the max object
- * size limit of a project.
+ * size limit of a repository.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#max-object-size-limit-info
  */
 export declare interface MaxObjectSizeLimitInfo {
@@ -788,7 +781,23 @@
   subject: string;
 }
 
-export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
+export type PatchSetNumber = BrandType<number, '_patchSet'>;
+
+export type EditPatchSet = BrandType<'edit', '_patchSet'>;
+
+export const EDIT = 'edit' as EditPatchSet;
+
+export type ParentPatchSet = BrandType<'PARENT', '_patchSet'>;
+
+export const PARENT = 'PARENT' as ParentPatchSet;
+
+export type PatchSetNum = PatchSetNumber | ParentPatchSet | EditPatchSet;
+
+// for the "left" side of a diff or the base of a patch range
+export type BasePatchSetNum = PatchSetNumber | ParentPatchSet;
+
+// for the "right" side of a diff or the revision of a patch range
+export type RevisionPatchSetNum = PatchSetNumber | EditPatchSet;
 
 /**
  * The PluginConfigInfo entity contains information about Gerrit extensions by
@@ -827,23 +836,23 @@
 }
 
 /**
- * The ProjectInfo entity contains information about a project
+ * The ProjectInfo entity contains information about a repository
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-info
  */
 export declare interface ProjectInfo {
   id: UrlEncodedRepoName;
-  // name is not set if returned in a map where the project name is used as
+  // name is not set if returned in a map where the repo name is used as
   // map key
   name?: RepoName;
-  // ?-<n> if the parent project is not visible (<n> is a number which
-  // is increased for each non-visible project).
+  // ?-<n> if the parent repository is not visible (<n> is a number which
+  // is increased for each non-visible repository).
   parent?: RepoName;
   description?: string;
-  state?: ProjectState;
+  state?: RepoState;
   branches?: {[branchName: string]: CommitId};
-  // labels is filled for Create Project and Get Project calls.
+  // labels is filled for Create Repo and Get Repo calls.
   labels?: LabelNameToLabelTypeInfoMap;
-  // Links to the project in external sites
+  // Links to the repository in external sites
   web_links?: WebLinkInfo[];
 }
 
@@ -944,14 +953,13 @@
  */
 export declare interface RevisionInfo {
   kind: RevisionKind;
-  _number: PatchSetNum;
+  _number: RevisionPatchSetNum;
   created: Timestamp;
   uploader: AccountInfo;
   ref: GitRef;
   fetch?: {[protocol: string]: FetchInfo};
   commit?: CommitInfo;
   files?: {[filename: string]: FileInfo};
-  actions?: ActionNameToActionInfoMap;
   reviewed?: boolean;
   commit_with_footers?: boolean;
   push_certificate?: PushCertificateInfo;
@@ -981,6 +989,7 @@
   suggest: SuggestInfo;
   user: UserConfigInfo;
   default_theme?: string;
+  submit_requirement_dashboard_columns?: string[];
 }
 
 /**
@@ -997,8 +1006,8 @@
 // where "'ffffffffff'" represents nanoseconds.
 
 /**
- * Information about the default submittype of a project, taking into account
- * project inheritance.
+ * Information about the default submittype of a repository, taking into account
+ * repository inheritance.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
  */
 export declare interface SubmitTypeInfo {
@@ -1091,9 +1100,18 @@
  */
 export declare interface SubmitRequirementExpressionInfo {
   expression: string;
-  fulfilled: boolean;
-  passing_atoms: string[];
-  failing_atoms: string[];
+  fulfilled?: boolean;
+  status?: SubmitRequirementExpressionInfoStatus;
+  passing_atoms?: string[];
+  failing_atoms?: string[];
+  error_message?: string;
+}
+
+export enum SubmitRequirementExpressionInfoStatus {
+  PASS = 'PASS',
+  FAIL = 'FAIL',
+  ERROR = 'ERROR',
+  NOT_EVALUATED = 'NOT_EVALUATED',
 }
 
 /**
@@ -1105,6 +1123,80 @@
   UNSATISFIED = 'UNSATISFIED',
   OVERRIDDEN = 'OVERRIDDEN',
   NOT_APPLICABLE = 'NOT_APPLICABLE',
+  ERROR = 'ERROR',
+  FORCED = 'FORCED',
 }
 
 export type UrlEncodedRepoName = BrandType<string, '_urlEncodedRepoName'>;
+
+/**
+ * The SubmitRecordInfo entity describes results from a submit_rule.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-record-info
+ */
+export declare interface SubmitRecordInfo {
+  rule_name: string;
+  status?: SubmitRecordInfoStatus;
+  labels?: SubmitRecordInfoLabel[];
+  requirements?: Requirement[];
+  error_message?: string;
+}
+
+export enum SubmitRecordInfoStatus {
+  OK = 'OK',
+  NOT_READY = 'NOT_READY',
+  CLOSED = 'CLOSED',
+  FORCED = 'FORCED',
+  RULE_ERROR = 'RULE_ERROR',
+}
+
+export enum LabelStatus {
+  /**
+   * This label provides what is necessary for submission.
+   */
+  OK = 'OK',
+  /**
+   * This label prevents the change from being submitted.
+   */
+  REJECT = 'REJECT',
+  /**
+   * The label may be set, but it's neither necessary for submission
+   * nor does it block submission if set.
+   */
+  MAY = 'MAY',
+  /**
+   * The label is required for submission, but has not been satisfied.
+   */
+  NEED = 'NEED',
+  /**
+   * The label is required for submission, but is impossible to complete.
+   * The likely cause is access has not been granted correctly by the
+   * repository owner or site administrator.
+   */
+  IMPOSSIBLE = 'IMPOSSIBLE',
+  OPTIONAL = 'OPTIONAL',
+}
+
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-record-info
+ */
+export declare interface SubmitRecordInfoLabel {
+  label: string;
+  status: LabelStatus;
+  appliedBy: AccountInfo;
+}
+
+/**
+ * Represent a file in a base64 encoding; GrRestApiInterface returns
+ * it from some methods
+ */
+export declare interface Base64FileContent {
+  content: string | null;
+  type: string | null;
+  ok: true;
+}
+
+export function isBase64FileContent(
+  res: Response | Base64FileContent
+): res is Base64FileContent {
+  return (res as Base64FileContent).ok;
+}
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index 86f33a9..a09f711 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {AccountDetailInfo, ProjectInfoWithName, ServerInfo} from './rest-api';
 
@@ -26,7 +15,10 @@
   PUT = 'PUT',
 }
 
-export type ErrorCallback = (response?: Response | null, err?: Error) => void;
+export type ErrorCallback = (
+  response?: Response | null,
+  err?: Error
+) => Promise<void> | void;
 
 export declare interface RestPluginApi {
   getLoggedIn(): Promise<boolean>;
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
index 55ac2cc..6ca8496 100644
--- a/polygerrit-ui/app/api/styles.ts
+++ b/polygerrit-ui/app/api/styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
@@ -33,11 +22,33 @@
   toString(): string;
 }
 
+/** Accessible via `window.Gerrit.styles`. */
 export declare interface Styles {
   font: Style;
   form: Style;
+  icon: Style;
   menuPage: Style;
   spinner: Style;
   subPage: Style;
   table: Style;
+  modal: Style;
+}
+
+/** Accessible via `window.Gerrit.install(plugin => {plugin.styleApi()})`. */
+export declare interface StylePluginApi {
+  /**
+   * Convenience method for adding a CSS rule to a <style> element in <head>.
+   *
+   * Note that you can only insert one rule per call. See `insertRule()`
+   * documentation of `CSSStyleSheet`:
+   * https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/insertRule
+   *
+   * @param rule the css rule, e.g.:
+   *        ```
+   *          html.darkTheme {
+   *            --header-background-color: blue;
+   *          }
+   *        ```
+   */
+  insertCSSRule(rule: string): void;
 }
diff --git a/polygerrit-ui/app/compile_generated_templates.sh b/polygerrit-ui/app/compile_generated_templates.sh
deleted file mode 100755
index 68bf485..0000000
--- a/polygerrit-ui/app/compile_generated_templates.sh
+++ /dev/null
@@ -1 +0,0 @@
-$1 --project $2 --baseUrl ./external/ui_npm/node_modules/ --rootDir null
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 6ff2894..89c7622 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
- * @desc Tab names for primary tabs on change view page.
+ * Tab names for primary tabs on change view page.
  */
 import {DiffViewMode} from '../api/diff';
 import {DiffPreferencesInfo} from '../types/diff';
@@ -33,7 +22,7 @@
   InheritedBooleanInfoConfiguredValue,
   MergeabilityComputationBehavior,
   ProblemInfoStatus,
-  ProjectState,
+  RepoState,
   RequirementStatus,
   ReviewerState,
   RevisionKind,
@@ -52,7 +41,7 @@
   InheritedBooleanInfoConfiguredValue,
   MergeabilityComputationBehavior,
   ProblemInfoStatus,
-  ProjectState,
+  RepoState,
   RequirementStatus,
   ReviewerState,
   RevisionKind,
@@ -63,7 +52,7 @@
   SERVICE_USER = 'SERVICE_USER',
 }
 
-export enum PrimaryTab {
+export enum Tab {
   FILES = 'files',
   /**
    * When renaming 'comments' or 'findings', UrlFormatter.java must be updated.
@@ -74,32 +63,49 @@
 }
 
 /**
- * @desc Tab names for secondary tabs on change view page.
- */
-export enum SecondaryTab {
-  CHANGE_LOG = '_changeLog',
-}
-
-/**
- * @desc Tag names of change log messages.
+ * Tag names of change log messages.
  */
 export enum MessageTag {
   TAG_DELETE_REVIEWER = 'autogenerated:gerrit:deleteReviewer',
   TAG_NEW_PATCHSET = 'autogenerated:gerrit:newPatchSet',
+  TAG_NEW_PATCHSET_OUTDATED_VOTES = 'autogenerated:gerrit:newPatchSetOutdatedVotes',
   TAG_NEW_WIP_PATCHSET = 'autogenerated:gerrit:newWipPatchSet',
   TAG_REVIEWER_UPDATE = 'autogenerated:gerrit:reviewerUpdate',
   TAG_SET_PRIVATE = 'autogenerated:gerrit:setPrivate',
   TAG_UNSET_PRIVATE = 'autogenerated:gerrit:unsetPrivate',
   TAG_SET_READY = 'autogenerated:gerrit:setReadyForReview',
   TAG_SET_WIP = 'autogenerated:gerrit:setWorkInProgress',
-  TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
-  TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
   TAG_MERGED = 'autogenerated:gerrit:merged',
   TAG_REVERT = 'autogenerated:gerrit:revert',
 }
 
 /**
- * @desc Modes for gr-diff-cursor
+ * @description These values are directly displayed in the dialog to show progress of
+ * change.
+ */
+export enum ProgressStatus {
+  RUNNING = 'RUNNING',
+  FAILED = 'FAILED',
+  NOT_STARTED = 'NOT STARTED',
+  SUCCESSFUL = 'SUCCESSFUL',
+}
+
+export enum ColumnNames {
+  SUBJECT = 'Subject',
+  // TODO(milutin) - remove once Submit Requirements are rolled out.
+  STATUS = 'Status',
+  OWNER = 'Owner',
+  REVIEWERS = 'Reviewers',
+  COMMENTS = 'Comments',
+  REPO = 'Repo',
+  BRANCH = 'Branch',
+  UPDATED = 'Updated',
+  SIZE = 'Size',
+  STATUS2 = ' Status ', // spaces to differentiate from old 'Status'
+}
+
+/**
+ * @description Modes for gr-diff-cursor
  * The scroll behavior for the cursor. Values are 'never' and
  * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
  * the viewport.
@@ -110,7 +116,7 @@
 }
 
 /**
- * @desc Special file paths
+ * Special file paths
  */
 export enum SpecialFilePath {
   PATCHSET_LEVEL_COMMENTS = '/PATCHSET_LEVEL',
@@ -167,6 +173,7 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
  */
 export enum AppTheme {
+  AUTO = 'AUTO',
   DARK = 'DARK',
   LIGHT = 'LIGHT',
 }
@@ -251,14 +258,20 @@
   NONE = 'NONE',
 }
 
-// TODO(TS): Many properties are omitted here, but they are required.
-// Add default values for missing properties.
-export function createDefaultPreferences() {
+export function createDefaultPreferences(): PreferencesInfo {
   return {
     changes_per_page: 25,
     diff_view: DiffViewMode.SIDE_BY_SIDE,
     size_bar_in_change_table: true,
-  } as PreferencesInfo;
+    my: [],
+    theme: AppTheme.AUTO,
+    date_format: DateFormat.EURO,
+    time_format: TimeFormat.HHMM_24,
+    change_table: [],
+    email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
+    default_base_for_merges: DefaultBase.AUTO_MERGE,
+    allow_browser_notifications: false,
+  };
 }
 
 // These defaults should match the defaults in
@@ -307,3 +320,5 @@
 export const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
 export const SHOWN_ITEMS_COUNT = 25;
+
+export const WAITING = 'Waiting';
diff --git a/polygerrit-ui/app/constants/messages.ts b/polygerrit-ui/app/constants/messages.ts
index 5b4a534..dca1e61 100644
--- a/polygerrit-ui/app/constants/messages.ts
+++ b/polygerrit-ui/app/constants/messages.ts
@@ -1,20 +1,9 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
-/** @desc Message shown when no threads in gr-thread-list for robot comments */
+/** Message shown when no threads in gr-thread-list for robot comments */
 export const NO_ROBOT_COMMENTS_THREADS_MSG =
   'There are no findings for this patchset.';
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0029f5c..ae4aad9 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 export enum LifeCycle {
@@ -25,6 +14,8 @@
   PLUGINS_INSTALLED = 'Plugins installed',
   PLUGINS_FAILED = 'Some plugins failed to load',
   USER_REFERRED_FROM = 'User referred from',
+  NOTIFICATION_PERMISSION = 'Notification Permission',
+  SERVICE_WORKER_UPDATE = 'Service worker update',
 }
 
 export enum Execution {
@@ -33,6 +24,8 @@
   METHOD_USED = 'method used',
   CHECKS_API_NOT_LOGGED_IN = 'checks-api not-logged-in',
   CHECKS_API_ERROR = 'checks-api error',
+  USER_PREFERENCES_COLUMNS = 'user-preferences-columns',
+  PREFER_MERGE_FIRST_PARENT = 'prefer-merge-first-parent',
 }
 
 export enum Timing {
@@ -46,10 +39,8 @@
   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',
+  DIFF_VIEW_DISPLAYED = 'DiffViewDisplayed',
   // Time from navigation to showing initial content of the file list.
   FILE_LIST_DISPLAYED = 'FileListDisplayed',
   // Time from startup to having loaded all plugins.
@@ -64,10 +55,8 @@
   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',
+  STARTUP_DIFF_VIEW_DISPLAYED = 'StartupDiffViewDisplayed',
   // 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.
@@ -82,22 +71,83 @@
   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.
+  // Time to compute syntax highlighting of a diff  minus diff rendering time (DIFF_CONTENT).
   DIFF_SYNTAX = 'Diff Syntax Render',
+  // Time to load diff and prepare before gr-diff rendering begins.
+  DIFF_LOAD = 'Diff Load 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',
+  // Time for making the REST API call of creating a draft comment.
+  DRAFT_CREATE = 'CreateDraftComment',
+  // Time for making the REST API call of update a draft comment.
+  DRAFT_UPDATE = 'UpdateDraftComment',
+  // Time for making the REST API call of deleting a draft comment.
+  DRAFT_DISCARD = 'DiscardDraftComment',
+  // Time to load checks from all providers for the first time.
+  CHECKS_LOAD = 'ChecksLoad',
+  // Webvitals - Cumulative Layout Shift (CLS): measures visual stability
+  CLS = 'CLS',
+  // WebVitals - First Input Delay (FID): measures interactivity
+  FID = 'FID',
+  // WebVitals - Largest Contentful Paint (LCP): measures loading performance.
+  LCP = 'LCP',
 }
 
 export enum Interaction {
   TOGGLE_SHOW_ALL_BUTTON = 'toggle show all button',
   SHOW_TAB = 'show-tab',
   ATTENTION_SET_CHIP = 'attention-set-chip',
+  BULK_ACTION = 'bulk-action',
   SAVE_COMMENT = 'save-comment',
   COMMENT_SAVED = 'comment-saved',
+  DISCARD_COMMENT = 'discard-comment',
+  COMMENT_DISCARDED = 'comment-discarded',
+  ROBOT_COMMENTS_STATS = 'robot-comments-stats',
+  CHECKS_TAB_RENDERED = 'checks-tab-rendered',
+  CHECKS_CHIP_CLICKED = 'checks-chip-clicked',
+  CHECKS_CHIP_LINK_CLICKED = 'checks-chip-link-clicked',
+  CHECKS_RESULT_ROW_TOGGLE = 'checks-result-row-toggle',
+  CHECKS_ACTION_TRIGGERED = 'checks-action-triggered',
+  CHECKS_TAG_CLICKED = 'checks-tag-clicked',
+  CHECKS_RESULT_FILTER_CHANGED = 'checks-result-filter-changed',
+  CHECKS_RESULT_SECTION_TOGGLE = 'checks-result-section-toggle',
+  CHECKS_RESULT_SECTION_SHOW_ALL = 'checks-result-section-show-all',
+  CHECKS_RUN_SELECTED = 'checks-run-selected',
+  CHECKS_RUN_LINK_CLICKED = 'checks-run-link-clicked',
+  CHECKS_RUN_FILTER_CHANGED = 'checks-run-filter-changed',
+  CHECKS_RUN_SECTION_TOGGLE = 'checks-run-section-toggle',
+  CHECKS_ATTEMPT_SELECTED = 'checks-attempt-selected',
+  CHECKS_RUNS_PANEL_TOGGLE = 'checks-runs-panel-toggle',
+  CHECKS_RUNS_SELECTED_TRIGGERED = 'checks-runs-selected-triggered',
+  CHECKS_STATS = 'checks-stats',
+  // The following interactions are logged for investigating a spurious bug of
+  // auto-closing draft comments.
+  COMMENTS_AUTOCLOSE_FIRST_UPDATE = 'comments-autoclose-first-update',
+  COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE = 'comments-autoclose-editing-false-save',
+  COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED = 'comments-autoclose-editing-disconnected',
+  COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED = 'comments-autoclose-editing-thread-disconnected',
+  COMMENTS_AUTOCLOSE_CHECKS_UPDATED = 'comments-autoclose-checks-updated',
+  COMMENTS_AUTOCLOSE_THREADS_UPDATED = 'comments-autoclose-threads-updated',
+  COMMENTS_AUTOCLOSE_COMMENT_REMOVED = 'comments-autoclose-comment-removed',
+  COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED = 'comments-autoclose-messages-list-created',
+  COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED = 'comments-autoclose-messages-list-updated',
+  COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED = 'comments-autoclose-thread-list-created',
+  COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED = 'comments-autoclose-thread-list-updated',
+  // The following interactions are logged for investigating a spurious bug of
+  // auto-closing diffs.
+  DIFF_AUTOCLOSE_DIFF_UNDEFINED = 'diff-autoclose-diff-undefined',
+  DIFF_AUTOCLOSE_DIFF_ONGOING = 'diff-autoclose-diff-ongoing',
+  DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE = 'diff-autoclose-reload-on-whitespace',
+  DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX = 'diff-autoclose-reload-on-syntax',
+  DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS = 'diff-autoclose-reload-filelist-prefs',
+  DIFF_AUTOCLOSE_DIFF_HOST_CREATED = 'diff-autoclose-diff-host-created',
+  DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING = 'diff-autoclose-diff-not-rendering',
+  DIFF_AUTOCLOSE_FILE_LIST_UPDATED = 'diff-autoclose-file-list-updated',
+  DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED = 'diff-autoclose-shown-files-changed',
+  // The following interaction is logged for reporting and counting a suspected
+  // Chrome bug that leads to html`` misbehavior.
+  AUTOCLOSE_HTML_PATCHED = 'autoclose-html-patched',
+  CHANGE_ACTION_FIRED = 'change-action-fired',
 }
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 2328a05..ce0ea02 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
@@ -1,47 +1,38 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-permission/gr-permission';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-access-section_html';
 import {
   AccessPermissions,
   PermissionArray,
   PermissionArrayItem,
   toSortedPermissionsArray,
 } from '../../../utils/access-util';
-import {customElement, property} from '@polymer/decorators';
 import {
   EditablePermissionInfo,
   PermissionAccessSection,
-  EditableProjectAccessGroups,
+  EditableRepoAccessGroups,
 } from '../gr-repo-access/gr-repo-access-interfaces';
 import {
   CapabilityInfoMap,
   GitRef,
   LabelNameToLabelTypeInfoMap,
+  RepoName,
 } from '../../../types/common';
-import {PolymerDomRepeatEvent} from '../../../types/types';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireEvent} from '../../../utils/event-util';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
 
 /**
  * Fired when the section has been modified or removed.
@@ -63,31 +54,26 @@
 const ON_BEHALF_OF = '(On Behalf Of)';
 const LABEL = 'Label';
 
-export interface GrAccessSection {
-  $: {
-    permissionSelect: HTMLSelectElement;
-  };
-}
-
 @customElement('gr-access-section')
-export class GrAccessSection extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrAccessSection extends LitElement {
+  @query('#permissionSelect') private permissionSelect?: HTMLSelectElement;
+
+  @property({type: String})
+  repo?: RepoName;
 
   @property({type: Object})
   capabilities?: CapabilityInfoMap;
 
-  @property({type: Object, notify: true, observer: '_updateSection'})
+  @property({type: Object})
   section?: PermissionAccessSection;
 
   @property({type: Object})
-  groups?: EditableProjectAccessGroups;
+  groups?: EditableRepoAccessGroups;
 
   @property({type: Object})
   labels?: LabelNameToLabelTypeInfoMap;
 
-  @property({type: Boolean, observer: '_handleEditingChanged'})
+  @property({type: Boolean})
   editing = false;
 
   @property({type: Boolean})
@@ -96,43 +82,220 @@
   @property({type: Array})
   ownerOf?: GitRef[];
 
-  @property({type: String})
-  _originalId?: GitRef;
+  // private but used in test
+  @state() originalId?: GitRef;
 
-  @property({type: Boolean})
-  _editingRef = false;
+  // private but used in test
+  @state() editingRef = false;
 
-  @property({type: Boolean})
-  _deleted = false;
+  // private but used in test
+  @state() deleted = false;
 
-  @property({type: Array})
-  _permissions?: PermissionArray<EditablePermissionInfo>;
+  // private but used in test
+  @state() permissions?: PermissionArray<EditablePermissionInfo>;
 
   constructor() {
     super();
-    this.addEventListener('access-saved', () => this._handleAccessSaved());
+    this.addEventListener('access-saved', () => this.handleAccessSaved());
   }
 
-  _updateSection(section: PermissionAccessSection) {
-    this._permissions = toSortedPermissionsArray(section.value.permissions);
-    this._originalId = section.id;
+  static override get styles() {
+    return [
+      formStyles,
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          margin-bottom: var(--spacing-l);
+        }
+        fieldset {
+          border: 1px solid var(--border-color);
+        }
+        .name {
+          align-items: center;
+          display: flex;
+        }
+        .header,
+        #deletedContainer {
+          align-items: center;
+          background: var(--table-header-background-color);
+          border-bottom: 1px dotted var(--border-color);
+          display: flex;
+          justify-content: space-between;
+          min-height: 3em;
+          padding: 0 var(--spacing-m);
+        }
+        #deletedContainer {
+          border-bottom: 0;
+        }
+        .sectionContent {
+          padding: var(--spacing-m);
+        }
+        #editBtn,
+        .editing #editBtn.global,
+        #deletedContainer,
+        .deleted #mainContainer,
+        #addPermission,
+        #deleteBtn,
+        .editingRef .name,
+        .editRefInput {
+          display: none;
+        }
+        .editing #editBtn,
+        .editingRef .editRefInput {
+          display: flex;
+        }
+        .deleted #deletedContainer {
+          display: flex;
+        }
+        .editing #addPermission,
+        #mainContainer,
+        .editing #deleteBtn {
+          display: block;
+        }
+        .editing #deleteBtn,
+        #undoRemoveBtn {
+          padding-right: var(--spacing-m);
+        }
+      `,
+    ];
   }
 
-  _handleAccessSaved() {
-    if (!this.section) {
-      return;
+  override render() {
+    if (!this.section) return;
+    return html`
+      <fieldset
+        id="section"
+        class="gr-form-styles ${this.computeSectionClass()}"
+      >
+        <div id="mainContainer">
+          <div class="header">
+            <div class="name">
+              <h3 class="heading-3">${this.computeSectionName()}</h3>
+              <gr-button
+                id="editBtn"
+                link
+                class=${this.section?.id === GLOBAL_NAME ? 'global' : ''}
+                @click=${this.editReference}
+              >
+                <gr-icon id="icon" icon="edit" filled small></gr-icon>
+              </gr-button>
+            </div>
+            <iron-input
+              class="editRefInput"
+              .bindValue=${this.section?.id}
+              @input=${this.handleValueChange}
+              @bind-value-changed=${this.handleIdBindValueChanged}
+            >
+              <input
+                class="editRefInput"
+                type="text"
+                @input=${this.handleValueChange}
+              />
+            </iron-input>
+            <gr-button link id="deleteBtn" @click=${this.handleRemoveReference}
+              >Remove</gr-button
+            >
+          </div>
+          <!-- end header -->
+          <div class="sectionContent">
+            ${this.permissions?.map((permission, index) =>
+              this.renderPermission(permission, index)
+            )}
+            <div id="addPermission">
+              Add permission:
+              <select id="permissionSelect">
+                ${this.computePermissions().map(item =>
+                  this.renderPermissionOptions(item)
+                )}
+              </select>
+              <gr-button link id="addBtn" @click=${this.handleAddPermission}
+                >Add</gr-button
+              >
+            </div>
+            <!-- end addPermission -->
+          </div>
+          <!-- end sectionContent -->
+        </div>
+        <!-- end mainContainer -->
+        <div id="deletedContainer">
+          <span>${this.computeSectionName()} was deleted</span>
+          <gr-button link="" id="undoRemoveBtn" @click=${this._handleUndoRemove}
+            >Undo</gr-button
+          >
+        </div>
+        <!-- end deletedContainer -->
+      </fieldset>
+    `;
+  }
+
+  private renderPermission(
+    permission: PermissionArrayItem<EditablePermissionInfo>,
+    index: number
+  ) {
+    return html`
+      <gr-permission
+        .name=${this.computePermissionName(permission)}
+        .permission=${permission}
+        .labels=${this.labels}
+        .section=${this.section?.id}
+        .editing=${this.editing}
+        .groups=${this.groups}
+        .repo=${this.repo}
+        @added-permission-removed=${() => {
+          this.handleAddedPermissionRemoved(index);
+        }}
+        @permission-changed=${(
+          e: ValueChangedEvent<PermissionArrayItem<EditablePermissionInfo>>
+        ) => {
+          this.handlePermissionChanged(e, index);
+        }}
+      >
+      </gr-permission>
+    `;
+  }
+
+  private renderPermissionOptions(item: {
+    id: string;
+    value: {name: string; id: string};
+  }) {
+    return html`<option value=${item.value.id}>${item.value.name}</option>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('section')) {
+      this.updateSection();
     }
+    if (changedProperties.has('editing')) {
+      this.handleEditingChanged(changedProperties.get('editing') as boolean);
+    }
+  }
+
+  // private but used in test
+  updateSection() {
+    this.permissions = toSortedPermissionsArray(
+      this.section!.value.permissions
+    );
+    this.originalId = this.section!.id;
+  }
+
+  // private but used in test
+  handleAccessSaved() {
+    if (!this.section) return;
     // Set a new 'original' value to keep track of after the value has been
     // saved.
-    this._updateSection(this.section);
+    this.updateSection();
   }
 
-  _handleValueChange() {
+  // private but used in test
+  handleValueChange() {
     if (!this.section) {
       return;
     }
     if (!this.section.value.added) {
-      this.section.value.modified = this.section.id !== this._originalId;
+      this.section.value.modified = this.section.id !== this.originalId;
+      this.requestUpdate();
       // Allows overall access page to know a change has been made.
       // 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
@@ -140,25 +303,28 @@
       fireEvent(this, 'access-modified');
     }
     this.section.value.updatedId = this.section.id;
+    this.requestUpdate();
   }
 
-  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+  private handleEditingChanged(editingOld: boolean) {
     // Ignore when editing gets set initially.
     if (!editingOld) {
       return;
     }
-    if (!this.section || !this._permissions) {
+    if (!this.section || !this.permissions) {
       return;
     }
     // Restore original values if no longer editing.
-    if (!editing) {
-      this._editingRef = false;
-      this._deleted = false;
+    if (!this.editing) {
+      this.editingRef = false;
+      this.deleted = false;
       delete this.section.value.deleted;
       // Restore section ref.
-      this.set(['section', 'id'], this._originalId);
+      this.section.id = this.originalId as GitRef;
+      this.requestUpdate();
+      fire(this, 'section-changed', {value: this.section});
       // Remove any unsaved but added permissions.
-      this._permissions = this._permissions.filter(p => !p.value.added);
+      this.permissions = this.permissions.filter(p => !p.value.added);
       for (const key of Object.keys(this.section.value.permissions)) {
         if (this.section.value.permissions[key].added) {
           delete this.section.value.permissions[key];
@@ -167,22 +333,17 @@
     }
   }
 
-  _computePermissions(
-    name: string,
-    capabilities?: CapabilityInfoMap,
-    labels?: LabelNameToLabelTypeInfoMap,
-    // This is just for triggering re-computation. We don't use the value.
-    _?: unknown
-  ) {
+  // private but used in test
+  computePermissions() {
     let allPermissions;
     const section = this.section;
     if (!section || !section.value) {
       return [];
     }
-    if (name === GLOBAL_NAME) {
-      allPermissions = toSortedPermissionsArray(capabilities);
+    if (section.id === GLOBAL_NAME) {
+      allPermissions = toSortedPermissionsArray(this.capabilities);
     } else {
-      const labelOptions = this._computeLabelOptions(labels);
+      const labelOptions = this.computeLabelOptions();
       allPermissions = labelOptions.concat(
         toSortedPermissionsArray(AccessPermissions)
       );
@@ -192,22 +353,22 @@
     );
   }
 
-  _handleAddedPermissionRemoved(e: PolymerDomRepeatEvent) {
-    if (!this._permissions) {
+  private handleAddedPermissionRemoved(index: number) {
+    if (!this.permissions) {
       return;
     }
-    const index = e.model.index;
-    this._permissions = this._permissions
+    delete this.section?.value.permissions[this.permissions[index].id];
+    this.permissions = this.permissions
       .slice(0, index)
-      .concat(this._permissions.slice(index + 1, this._permissions.length));
+      .concat(this.permissions.slice(index + 1, this.permissions.length));
   }
 
-  _computeLabelOptions(labels?: LabelNameToLabelTypeInfoMap) {
+  computeLabelOptions() {
     const labelOptions = [];
-    if (!labels) {
+    if (!this.labels) {
       return [];
     }
-    for (const labelName of Object.keys(labels)) {
+    for (const labelName of Object.keys(this.labels)) {
       labelOptions.push({
         id: 'label-' + labelName,
         value: {
@@ -226,15 +387,14 @@
     return labelOptions;
   }
 
-  _computePermissionName(
-    name: string,
-    permission: PermissionArrayItem<EditablePermissionInfo>,
-    capabilities?: CapabilityInfoMap
+  // private but used in test
+  computePermissionName(
+    permission: PermissionArrayItem<EditablePermissionInfo>
   ): string | undefined {
-    if (name === GLOBAL_NAME) {
-      return capabilities?.[permission.id].name;
+    if (this.section?.id === GLOBAL_NAME) {
+      return this.capabilities?.[permission.id]?.name;
     } else if (AccessPermissions[permission.id]) {
-      return AccessPermissions[permission.id].name;
+      return AccessPermissions[permission.id]?.name;
     } else if (permission.value.label) {
       let behalfOf = '';
       if (permission.id.startsWith('labelAs-')) {
@@ -245,15 +405,19 @@
     return undefined;
   }
 
-  _computeSectionName(name: string) {
+  // private but used in test
+  computeSectionName() {
+    let name = this.section?.id;
     // When a new section is created, it doesn't yet have a ref. Set into
     // edit mode so that the user can input one.
     if (!name) {
-      this._editingRef = true;
+      this.editingRef = true;
       // Needed for the title value. This is the same default as GWT.
-      name = NEW_NAME;
+      name = NEW_NAME as GitRef;
       // Needed for the input field value.
-      this.set('section.id', name);
+      this.section!.id = name;
+      fire(this, 'section-changed', {value: this.section!});
+      this.requestUpdate();
     }
     if (name === GLOBAL_NAME) {
       return 'Global Capabilities';
@@ -263,14 +427,14 @@
     return name;
   }
 
-  _handleRemoveReference() {
+  private handleRemoveReference() {
     if (!this.section) {
       return;
     }
     if (this.section.value.added) {
       fireEvent(this, 'added-section-removed');
     }
-    this._deleted = true;
+    this.deleted = true;
     this.section.value.deleted = true;
     fireEvent(this, 'access-modified');
   }
@@ -279,61 +443,46 @@
     if (!this.section) {
       return;
     }
-    this._deleted = false;
+    this.deleted = false;
     delete this.section.value.deleted;
+    this.requestUpdate();
   }
 
   editRefInput() {
-    return this.root!.querySelector(
-      PolymerElement
-        ? 'iron-input.editRefInput'
-        : 'input[is=iron-input].editRefInput'
-    ) as HTMLInputElement;
+    return queryAndAssert<IronInputElement>(this, 'iron-input.editRefInput');
   }
 
   editReference() {
-    this._editingRef = true;
+    this.editingRef = true;
     this.editRefInput().focus();
   }
 
-  _isEditEnabled(
-    canUpload: boolean | undefined,
-    ownerOf: GitRef[] | undefined,
-    sectionId: GitRef
-  ) {
-    return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
+  private isEditEnabled() {
+    return (
+      this.canUpload ||
+      (this.ownerOf && this.ownerOf.indexOf(this.section!.id) >= 0)
+    );
   }
 
-  _computeSectionClass(
-    editing: boolean,
-    canUpload: boolean | undefined,
-    ownerOf: GitRef[] | undefined,
-    editingRef: boolean,
-    deleted: boolean
-  ) {
+  // private but used in test
+  computeSectionClass() {
     const classList = [];
-    if (
-      editing &&
-      this.section &&
-      this._isEditEnabled(canUpload, ownerOf, this.section.id)
-    ) {
+    if (this.editing && this.section && this.isEditEnabled()) {
       classList.push('editing');
     }
-    if (editingRef) {
+    if (this.editingRef) {
       classList.push('editingRef');
     }
-    if (deleted) {
+    if (this.deleted) {
       classList.push('deleted');
     }
     return classList.join(' ');
   }
 
-  _computeEditBtnClass(name: string) {
-    return name === GLOBAL_NAME ? 'global' : '';
-  }
-
-  _handleAddPermission() {
-    const value = this.$.permissionSelect.value as GitRef;
+  // private but used in test
+  handleAddPermission() {
+    assertIsDefined(this.permissionSelect, 'permissionSelect');
+    const value = this.permissionSelect.value as GitRef;
     const permission: PermissionArrayItem<EditablePermissionInfo> = {
       id: value,
       value: {rules: {}, added: true},
@@ -361,12 +510,31 @@
     }
     // Add to the end of the array (used in dom-repeat) and also to the
     // section object that is two way bound with its parent element.
-    this.push('_permissions', permission);
-    this.set(['section.value.permissions', permission.id], permission.value);
+    this.permissions!.push(permission);
+    this.section!.value.permissions[permission.id] = permission.value;
+    this.requestUpdate();
+    fire(this, 'section-changed', {value: this.section!});
   }
+
+  private handleIdBindValueChanged = (e: BindValueChangeEvent) => {
+    this.section!.id = e.detail.value as GitRef;
+    this.requestUpdate();
+    fire(this, 'section-changed', {value: this.section!});
+  };
+
+  private handlePermissionChanged = (
+    e: ValueChangedEvent<PermissionArrayItem<EditablePermissionInfo>>,
+    index: number
+  ) => {
+    this.permissions![index] = e.detail.value;
+    this.requestUpdate();
+  };
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'section-changed': ValueChangedEvent<PermissionAccessSection>;
+  }
   interface HTMLElementTagNameMap {
     'gr-access-section': GrAccessSection;
   }
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
deleted file mode 100644
index 1438825..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_html.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-l);
-    }
-    fieldset {
-      border: 1px solid var(--border-color);
-    }
-    .name {
-      align-items: center;
-      display: flex;
-    }
-    .header,
-    #deletedContainer {
-      align-items: center;
-      background: var(--table-header-background-color);
-      border-bottom: 1px dotted var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      min-height: 3em;
-      padding: 0 var(--spacing-m);
-    }
-    #deletedContainer {
-      border-bottom: 0;
-    }
-    .sectionContent {
-      padding: var(--spacing-m);
-    }
-    #editBtn,
-    .editing #editBtn.global,
-    #deletedContainer,
-    .deleted #mainContainer,
-    #addPermission,
-    #deleteBtn,
-    .editingRef .name,
-    .editRefInput {
-      display: none;
-    }
-    .editing #editBtn,
-    .editingRef .editRefInput {
-      display: flex;
-    }
-    .deleted #deletedContainer {
-      display: flex;
-    }
-    .editing #addPermission,
-    #mainContainer,
-    .editing #deleteBtn {
-      display: block;
-    }
-    .editing #deleteBtn,
-    #undoRemoveBtn {
-      padding-right: var(--spacing-m);
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <fieldset
-    id="section"
-    class$="gr-form-styles [[_computeSectionClass(editing, canUpload, ownerOf, _editingRef, _deleted)]]"
-  >
-    <div id="mainContainer">
-      <div class="header">
-        <div class="name">
-          <h3 class="heading-3">[[_computeSectionName(section.id)]]</h3>
-          <gr-button
-            id="editBtn"
-            link=""
-            class$="[[_computeEditBtnClass(section.id)]]"
-            on-click="editReference"
-          >
-            <iron-icon id="icon" icon="gr-icons:create"></iron-icon>
-          </gr-button>
-        </div>
-        <iron-input
-          class="editRefInput"
-          bind-value="{{section.id}}"
-          type="text"
-          on-input="_handleValueChange"
-        >
-          <input
-            class="editRefInput"
-            bind-value="{{section.id}}"
-            is="iron-input"
-            type="text"
-            on-input="_handleValueChange"
-          />
-        </iron-input>
-        <gr-button link="" id="deleteBtn" on-click="_handleRemoveReference"
-          >Remove</gr-button
-        >
-      </div>
-      <!-- end header -->
-      <div class="sectionContent">
-        <template is="dom-repeat" items="{{_permissions}}" as="permission">
-          <gr-permission
-            name="[[_computePermissionName(section.id, permission, capabilities)]]"
-            permission="{{permission}}"
-            labels="[[labels]]"
-            section="[[section.id]]"
-            editing="[[editing]]"
-            groups="[[groups]]"
-            on-added-permission-removed="_handleAddedPermissionRemoved"
-          >
-          </gr-permission>
-        </template>
-        <div id="addPermission">
-          Add permission:
-          <select id="permissionSelect">
-            <!-- called with a third parameter so that permissions update
-                  after a new section is added. -->
-            <template
-              is="dom-repeat"
-              items="[[_computePermissions(section.id, capabilities, labels, section.value.permissions.*)]]"
-            >
-              <option value="[[item.value.id]]">[[item.value.name]]</option>
-            </template>
-          </select>
-          <gr-button link="" id="addBtn" on-click="_handleAddPermission"
-            >Add</gr-button
-          >
-        </div>
-        <!-- end addPermission -->
-      </div>
-      <!-- end sectionContent -->
-    </div>
-    <!-- end mainContainer -->
-    <div id="deletedContainer">
-      <span>[[_computeSectionName(section.id)]] was deleted</span>
-      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
-        >Undo</gr-button
-      >
-    </div>
-    <!-- end deletedContainer -->
-  </fieldset>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
deleted file mode 100644
index 7182ede..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
+++ /dev/null
@@ -1,521 +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-access-section.js';
-import {AccessPermissions, toSortedPermissionsArray} from '../../../utils/access-util.js';
-
-const fixture = fixtureFromElement('gr-access-section');
-
-suite('gr-access-section tests', () => {
-  let element;
-
-  setup(() => {
-    element = fixture.instantiate();
-  });
-
-  suite('unit tests', () => {
-    setup(() => {
-      element.section = {
-        id: 'refs/*',
-        value: {
-          permissions: {
-            read: {
-              rules: {},
-            },
-          },
-        },
-      };
-      element.capabilities = {
-        accessDatabase: {
-          id: 'accessDatabase',
-          name: 'Access Database',
-        },
-        administrateServer: {
-          id: 'administrateServer',
-          name: 'Administrate Server',
-        },
-        batchChangesLimit: {
-          id: 'batchChangesLimit',
-          name: 'Batch Changes Limit',
-        },
-        createAccount: {
-          id: 'createAccount',
-          name: 'Create Account',
-        },
-      };
-      element.labels = {
-        'Code-Review': {
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      };
-      element._updateSection(element.section);
-      flush();
-    });
-
-    test('_updateSection', () => {
-      // _updateSection was called in setup, so just make assertions.
-      const expectedPermissions = [
-        {
-          id: 'read',
-          value: {
-            rules: {},
-          },
-        },
-      ];
-      assert.deepEqual(element._permissions, expectedPermissions);
-      assert.equal(element._originalId, element.section.id);
-    });
-
-    test('_computeLabelOptions', () => {
-      const expectedLabelOptions = [
-        {
-          id: 'label-Code-Review',
-          value: {
-            name: 'Label Code-Review',
-            id: 'label-Code-Review',
-          },
-        },
-        {
-          id: 'labelAs-Code-Review',
-          value: {
-            name: 'Label Code-Review (On Behalf Of)',
-            id: 'labelAs-Code-Review',
-          },
-        },
-      ];
-
-      assert.deepEqual(element._computeLabelOptions(element.labels),
-          expectedLabelOptions);
-    });
-
-    test('_handleAccessSaved', () => {
-      assert.equal(element._originalId, 'refs/*');
-      element.section.id = 'refs/for/bar';
-      element._handleAccessSaved();
-      assert.equal(element._originalId, 'refs/for/bar');
-    });
-
-    test('_computePermissions', () => {
-      const capabilities = {
-        push: {
-          rules: {},
-        },
-        read: {
-          rules: {},
-        },
-      };
-
-      const expectedPermissions = [{
-        id: 'push',
-        value: {
-          rules: {},
-        },
-      },
-      ];
-      const labelOptions = [
-        {
-          id: 'label-Code-Review',
-          value: {
-            name: 'Label Code-Review',
-            id: 'label-Code-Review',
-          },
-        },
-        {
-          id: 'labelAs-Code-Review',
-          value: {
-            name: 'Label Code-Review (On Behalf Of)',
-            id: 'labelAs-Code-Review',
-          },
-        },
-      ];
-
-      // For global capabilities, just return the sorted array filtered by
-      // existing permissions.
-      let name = 'GLOBAL_CAPABILITIES';
-      assert.deepEqual(element._computePermissions(name, capabilities,
-          element.labels), expectedPermissions);
-
-      // For everything else, include possible label values before filtering.
-      name = 'refs/for/*';
-      assert.deepEqual(
-          element._computePermissions(name, capabilities, element.labels),
-          labelOptions
-              .concat(toSortedPermissionsArray(AccessPermissions))
-              .filter(permission => permission.id !== 'read'));
-    });
-
-    test('_computePermissionName', () => {
-      let name = 'GLOBAL_CAPABILITIES';
-      let permission = {
-        id: 'administrateServer',
-        value: {},
-      };
-      assert.equal(element._computePermissionName(name, permission,
-          element.capabilities),
-      element.capabilities[permission.id].name);
-
-      name = 'refs/for/*';
-      permission = {
-        id: 'abandon',
-        value: {},
-      };
-
-      assert.equal(element._computePermissionName(
-          name, permission, element.capabilities),
-      AccessPermissions[permission.id].name);
-
-      name = 'refs/for/*';
-      permission = {
-        id: 'label-Code-Review',
-        value: {
-          label: 'Code-Review',
-        },
-      };
-
-      assert.equal(element._computePermissionName(name, permission,
-          element.capabilities),
-      'Label Code-Review');
-
-      permission = {
-        id: 'labelAs-Code-Review',
-        value: {
-          label: 'Code-Review',
-        },
-      };
-
-      assert.equal(element._computePermissionName(name, permission,
-          element.capabilities),
-      'Label Code-Review(On Behalf Of)');
-    });
-
-    test('_computeSectionName', () => {
-      let name;
-      // When computing the section name for an undefined name, it means a
-      // new section is being added. In this case, it should default to
-      // 'refs/heads/*'.
-      element._editingRef = false;
-      assert.equal(element._computeSectionName(name),
-          'Reference: refs/heads/*');
-      assert.isTrue(element._editingRef);
-      assert.equal(element.section.id, 'refs/heads/*');
-
-      // Reset editing to false.
-      element._editingRef = false;
-      name = 'GLOBAL_CAPABILITIES';
-      assert.equal(element._computeSectionName(name), 'Global Capabilities');
-      assert.isFalse(element._editingRef);
-
-      name = 'refs/for/*';
-      assert.equal(element._computeSectionName(name),
-          'Reference: refs/for/*');
-      assert.isFalse(element._editingRef);
-    });
-
-    test('editReference', () => {
-      element.editReference();
-      assert.isTrue(element._editingRef);
-    });
-
-    test('_computeSectionClass', () => {
-      let editingRef = false;
-      let canUpload = false;
-      let ownerOf = [];
-      let editing = false;
-      let deleted = false;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), '');
-
-      ownerOf = ['refs/*'];
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing');
-
-      ownerOf = [];
-      canUpload = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing');
-
-      editingRef = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing editingRef');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing editingRef deleted');
-
-      editingRef = false;
-      assert.equal(element._computeSectionClass(editing, canUpload, ownerOf,
-          editingRef, deleted), 'editing deleted');
-    });
-
-    test('_computeEditBtnClass', () => {
-      let name = 'GLOBAL_CAPABILITIES';
-      assert.equal(element._computeEditBtnClass(name), 'global');
-      name = 'refs/for/*';
-      assert.equal(element._computeEditBtnClass(name), '');
-    });
-  });
-
-  suite('interactive tests', () => {
-    setup(() => {
-      element.labels = {
-        'Code-Review': {
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      };
-    });
-    suite('Global section', () => {
-      setup(() => {
-        element.section = {
-          id: 'GLOBAL_CAPABILITIES',
-          value: {
-            permissions: {
-              accessDatabase: {
-                rules: {},
-              },
-            },
-          },
-        };
-        element.capabilities = {
-          accessDatabase: {
-            id: 'accessDatabase',
-            name: 'Access Database',
-          },
-          administrateServer: {
-            id: 'administrateServer',
-            name: 'Administrate Server',
-          },
-          batchChangesLimit: {
-            id: 'batchChangesLimit',
-            name: 'Batch Changes Limit',
-          },
-          createAccount: {
-            id: 'createAccount',
-            name: 'Create Account',
-          },
-        };
-        element._updateSection(element.section);
-        flush();
-      });
-
-      test('classes are assigned correctly', () => {
-        assert.isFalse(element.$.section.classList.contains('editing'));
-        assert.isFalse(element.$.section.classList.contains('deleted'));
-        assert.isTrue(element.$.editBtn.classList.contains('global'));
-        element.editing = true;
-        element.canUpload = true;
-        element.ownerOf = [];
-        assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
-      });
-    });
-
-    suite('Non-global section', () => {
-      setup(() => {
-        element.section = {
-          id: 'refs/*',
-          value: {
-            permissions: {
-              read: {
-                rules: {},
-              },
-            },
-          },
-        };
-        element.capabilities = {};
-        element._updateSection(element.section);
-        flush();
-      });
-
-      test('classes are assigned correctly', () => {
-        assert.isFalse(element.$.section.classList.contains('editing'));
-        assert.isFalse(element.$.section.classList.contains('deleted'));
-        assert.isFalse(element.$.editBtn.classList.contains('global'));
-        element.editing = true;
-        element.canUpload = true;
-        element.ownerOf = [];
-        flush();
-        assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-      });
-
-      test('add permission', () => {
-        element.editing = true;
-        element.$.permissionSelect.value = 'label-Code-Review';
-        assert.equal(element._permissions.length, 1);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            1);
-        MockInteractions.tap(element.$.addBtn);
-        flush();
-
-        // The permission is added to both the permissions array and also
-        // the section's permission object.
-        assert.equal(element._permissions.length, 2);
-        let permission = {
-          id: 'label-Code-Review',
-          value: {
-            added: true,
-            label: 'Code-Review',
-            rules: {},
-          },
-        };
-        assert.equal(element._permissions.length, 2);
-        assert.deepEqual(element._permissions[1], permission);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            2);
-        assert.deepEqual(
-            element.section.value.permissions['label-Code-Review'],
-            permission.value);
-
-        element.$.permissionSelect.value = 'abandon';
-        MockInteractions.tap(element.$.addBtn);
-        flush();
-
-        permission = {
-          id: 'abandon',
-          value: {
-            added: true,
-            rules: {},
-          },
-        };
-
-        assert.equal(element._permissions.length, 3);
-        assert.deepEqual(element._permissions[2], permission);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            3);
-        assert.deepEqual(element.section.value.permissions['abandon'],
-            permission.value);
-
-        // Unsaved changes are discarded when editing is cancelled.
-        element.editing = false;
-        assert.equal(element._permissions.length, 1);
-        assert.equal(Object.keys(element.section.value.permissions).length,
-            1);
-      });
-
-      test('edit section reference', async () => {
-        element.canUpload = true;
-        element.ownerOf = [];
-        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
-        assert.isFalse(element.$.section.classList.contains('editing'));
-        element.editing = true;
-        assert.isTrue(element.$.section.classList.contains('editing'));
-        assert.isFalse(element._editingRef);
-        MockInteractions.tap(element.$.editBtn);
-        element.editRefInput().bindValue='new/ref';
-        await flush();
-        assert.equal(element.section.id, 'new/ref');
-        assert.isTrue(element._editingRef);
-        assert.isTrue(element.$.section.classList.contains('editingRef'));
-        element.editing = false;
-        assert.isFalse(element._editingRef);
-        assert.equal(element.section.id, 'refs/for/bar');
-      });
-
-      test('_handleValueChange', () => {
-        // For an existing section.
-        const modifiedHandler = sinon.stub();
-        element.section = {id: 'refs/for/bar', value: {permissions: {}}};
-        assert.notOk(element.section.value.updatedId);
-        element.section.id = 'refs/for/baz';
-        element.addEventListener('access-modified', modifiedHandler);
-        assert.isNotOk(element.section.value.modified);
-        element._handleValueChange();
-        assert.equal(element.section.value.updatedId, 'refs/for/baz');
-        assert.isTrue(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 1);
-        element.section.id = 'refs/for/bar';
-        element._handleValueChange();
-        assert.isFalse(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 2);
-
-        // For a new section.
-        element.section.value.added = true;
-        element._handleValueChange();
-        assert.isFalse(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 2);
-        element.section.id = 'refs/for/bar';
-        element._handleValueChange();
-        assert.isFalse(element.section.value.modified);
-        assert.equal(modifiedHandler.callCount, 2);
-      });
-
-      test('remove section', () => {
-        element.editing = true;
-        element.canUpload = true;
-        element.ownerOf = [];
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.section.value.deleted);
-        MockInteractions.tap(element.$.deleteBtn);
-        flush();
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.section.value.deleted);
-        assert.isTrue(element.$.section.classList.contains('deleted'));
-        assert.isTrue(element.section.value.deleted);
-
-        MockInteractions.tap(element.$.undoRemoveBtn);
-        flush();
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.section.value.deleted);
-
-        MockInteractions.tap(element.$.deleteBtn);
-        assert.isTrue(element._deleted);
-        assert.isTrue(element.section.value.deleted);
-        element.editing = false;
-        assert.isFalse(element._deleted);
-        assert.isNotOk(element.section.value.deleted);
-      });
-
-      test('removing an added permission', () => {
-        element.editing = true;
-        assert.equal(element._permissions.length, 1);
-        element.shadowRoot
-            .querySelector('gr-permission').dispatchEvent(
-                new CustomEvent('added-permission-removed', {
-                  composed: true, bubbles: true,
-                }));
-        flush();
-        assert.equal(element._permissions.length, 0);
-      });
-
-      test('remove an added section', () => {
-        const removeStub = sinon.stub();
-        element.addEventListener('added-section-removed', removeStub);
-        element.editing = true;
-        element.section.value.added = true;
-        MockInteractions.tap(element.$.deleteBtn);
-        assert.isTrue(removeStub.called);
-      });
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
new file mode 100644
index 0000000..593a1ed
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -0,0 +1,735 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-access-section';
+import {
+  AccessPermissions,
+  toSortedPermissionsArray,
+} from '../../../utils/access-util';
+import {GrAccessSection} from './gr-access-section';
+import {GitRef} from '../../../types/common';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-access-section tests', () => {
+  let element: GrAccessSection;
+
+  setup(async () => {
+    element = await fixture<GrAccessSection>(html`
+      <gr-access-section></gr-access-section>
+    `);
+  });
+
+  suite('unit tests', () => {
+    setup(async () => {
+      element.section = {
+        id: 'refs/*' as GitRef,
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+      element.capabilities = {
+        accessDatabase: {
+          id: 'accessDatabase',
+          name: 'Access Database',
+        },
+        administrateServer: {
+          id: 'administrateServer',
+          name: 'Administrate Server',
+        },
+        batchChangesLimit: {
+          id: 'batchChangesLimit',
+          name: 'Batch Changes Limit',
+        },
+        createAccount: {
+          id: 'createAccount',
+          name: 'Create Account',
+        },
+      };
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not submitted as is',
+            '-2': 'This shall not be submitted',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+      element.updateSection();
+      await element.updateComplete;
+    });
+
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <fieldset class="gr-form-styles" id="section">
+            <div id="mainContainer">
+              <div class="header">
+                <div class="name">
+                  <h3 class="heading-3">Reference: refs/*</h3>
+                  <gr-button
+                    aria-disabled="false"
+                    id="editBtn"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    <gr-icon icon="edit" id="icon" small filled></gr-icon>
+                  </gr-button>
+                </div>
+                <iron-input class="editRefInput">
+                  <input class="editRefInput" type="text" />
+                </iron-input>
+                <gr-button
+                  aria-disabled="false"
+                  id="deleteBtn"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Remove
+                </gr-button>
+              </div>
+              <div class="sectionContent">
+                <gr-permission> </gr-permission>
+                <div id="addPermission">
+                  Add permission:
+                  <select id="permissionSelect">
+                    <option value="label-Code-Review">Label Code-Review</option>
+                    <option value="labelAs-Code-Review">
+                      Label Code-Review (On Behalf Of)
+                    </option>
+                    <option value="abandon">Abandon</option>
+                    <option value="addPatchSet">Add Patch Set</option>
+                    <option value="create">Create Reference</option>
+                    <option value="createSignedTag">Create Signed Tag</option>
+                    <option value="createTag">Create Annotated Tag</option>
+                    <option value="delete">Delete Reference</option>
+                    <option value="deleteChanges">Delete Changes</option>
+                    <option value="deleteOwnChanges">Delete Own Changes</option>
+                    <option value="editHashtags">Edit Hashtags</option>
+                    <option value="editTopicName">Edit Topic Name</option>
+                    <option value="forgeAuthor">Forge Author Identity</option>
+                    <option value="forgeCommitter">
+                      Forge Committer Identity
+                    </option>
+                    <option value="forgeServerAsCommitter">
+                      Forge Server Identity
+                    </option>
+                    <option value="owner">Owner</option>
+                    <option value="push">Push</option>
+                    <option value="pushMerge">Push Merge Commit</option>
+                    <option value="rebase">Rebase</option>
+                    <option value="removeReviewer">Remove Reviewer</option>
+                    <option value="revert">Revert</option>
+                    <option value="submit">Submit</option>
+                    <option value="submitAs">Submit (On Behalf Of)</option>
+                    <option value="toggleWipState">
+                      Toggle Work In Progress State
+                    </option>
+                    <option value="viewPrivateChanges">
+                      View Private Changes
+                    </option>
+                  </select>
+                  <gr-button
+                    aria-disabled="false"
+                    id="addBtn"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Add
+                  </gr-button>
+                </div>
+              </div>
+            </div>
+            <div id="deletedContainer">
+              <span> Reference: refs/* was deleted </span>
+              <gr-button
+                aria-disabled="false"
+                id="undoRemoveBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Undo
+              </gr-button>
+            </div>
+          </fieldset>
+        `
+      );
+    });
+
+    test('updateSection', () => {
+      // updateSection was called in setup, so just make assertions.
+      const expectedPermissions = [
+        {
+          id: 'read' as GitRef,
+          value: {
+            rules: {},
+          },
+        },
+      ];
+      assert.deepEqual(element.permissions, expectedPermissions);
+      assert.equal(element.originalId, element.section!.id);
+    });
+
+    test('computeLabelOptions', () => {
+      const expectedLabelOptions = [
+        {
+          id: 'label-Code-Review',
+          value: {
+            name: 'Label Code-Review',
+            id: 'label-Code-Review',
+          },
+        },
+        {
+          id: 'labelAs-Code-Review',
+          value: {
+            name: 'Label Code-Review (On Behalf Of)',
+            id: 'labelAs-Code-Review',
+          },
+        },
+      ];
+
+      assert.deepEqual(element.computeLabelOptions(), expectedLabelOptions);
+    });
+
+    test('handleAccessSaved', () => {
+      assert.equal(element.originalId, 'refs/*' as GitRef);
+      element.section!.id = 'refs/for/bar' as GitRef;
+      element.handleAccessSaved();
+      assert.equal(element.originalId, 'refs/for/bar' as GitRef);
+    });
+
+    test('computePermissions', () => {
+      const capabilities = {
+        push: {
+          id: '',
+          name: '',
+          rules: {},
+        },
+        read: {
+          id: '',
+          name: '',
+          rules: {},
+        },
+      };
+
+      const expectedPermissions = [
+        {
+          id: 'push',
+          value: {
+            id: '',
+            name: '',
+            rules: {},
+          },
+        },
+      ];
+      const labelOptions = [
+        {
+          id: 'label-Code-Review',
+          value: {
+            name: 'Label Code-Review',
+            id: 'label-Code-Review',
+          },
+        },
+        {
+          id: 'labelAs-Code-Review',
+          value: {
+            name: 'Label Code-Review (On Behalf Of)',
+            id: 'labelAs-Code-Review',
+          },
+        },
+      ];
+
+      element.section = {
+        id: 'refs/*' as GitRef,
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+
+      // For global capabilities, just return the sorted array filtered by
+      // existing permissions.
+      element.section = {
+        id: 'GLOBAL_CAPABILITIES' as GitRef,
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+      element.capabilities = capabilities;
+      assert.deepEqual(element.computePermissions(), expectedPermissions);
+
+      // For everything else, include possible label values before filtering.
+      element.section.id = 'refs/for/*' as GitRef;
+      assert.deepEqual(
+        element.computePermissions(),
+        labelOptions
+          .concat(toSortedPermissionsArray(AccessPermissions))
+          .filter(permission => permission.id !== 'read')
+      );
+    });
+
+    test('computePermissionName', () => {
+      element.section = {
+        id: 'GLOBAL_CAPABILITIES' as GitRef,
+        value: {
+          permissions: {
+            read: {
+              rules: {},
+            },
+          },
+        },
+      };
+
+      let permission;
+
+      permission = {
+        id: 'administrateServer' as GitRef,
+        value: {rules: {}},
+      };
+      assert.equal(
+        element.computePermissionName(permission),
+        element.capabilities![permission.id].name
+      );
+
+      permission = {
+        id: 'non-existent' as GitRef,
+        value: {rules: {}},
+      };
+      assert.isUndefined(element.computePermissionName(permission));
+
+      element.section.id = 'refs/for/*' as GitRef;
+      permission = {
+        id: 'abandon' as GitRef,
+        value: {rules: {}},
+      };
+
+      assert.equal(
+        element.computePermissionName(permission),
+        AccessPermissions[permission.id].name
+      );
+
+      element.section.id = 'refs/for/*' as GitRef;
+      permission = {
+        id: 'label-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {},
+        },
+      };
+
+      assert.equal(
+        element.computePermissionName(permission),
+        'Label Code-Review'
+      );
+
+      permission = {
+        id: 'labelAs-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {},
+        },
+      };
+
+      assert.equal(
+        element.computePermissionName(permission),
+        'Label Code-Review(On Behalf Of)'
+      );
+    });
+
+    test('computeSectionName', () => {
+      // When computing the section name for an undefined name, it means a
+      // new section is being added. In this case, it should default to
+      // 'refs/heads/*'.
+      element.editingRef = false;
+      element.section!.id = '' as GitRef;
+      assert.equal(element.computeSectionName(), 'Reference: refs/heads/*');
+      assert.isTrue(element.editingRef);
+      assert.equal(element.section!.id, 'refs/heads/*');
+
+      // Reset editing to false.
+      element.editingRef = false;
+      element.section!.id = 'GLOBAL_CAPABILITIES' as GitRef;
+      assert.equal(element.computeSectionName(), 'Global Capabilities');
+      assert.isFalse(element.editingRef);
+
+      element.section!.id = 'refs/for/*' as GitRef;
+      assert.equal(element.computeSectionName(), 'Reference: refs/for/*');
+      assert.isFalse(element.editingRef);
+    });
+
+    test('editReference', () => {
+      element.editReference();
+      assert.isTrue(element.editingRef);
+    });
+
+    test('computeSectionClass', () => {
+      element.editingRef = false;
+      element.canUpload = false;
+      element.ownerOf = [];
+      element.editing = false;
+      element.deleted = false;
+      assert.equal(element.computeSectionClass(), '');
+
+      element.editing = true;
+      assert.equal(element.computeSectionClass(), '');
+
+      element.ownerOf = ['refs/*' as GitRef];
+      assert.equal(element.computeSectionClass(), 'editing');
+
+      element.ownerOf = [];
+      element.canUpload = true;
+      assert.equal(element.computeSectionClass(), 'editing');
+
+      element.editingRef = true;
+      assert.equal(element.computeSectionClass(), 'editing editingRef');
+
+      element.deleted = true;
+      assert.equal(element.computeSectionClass(), 'editing editingRef deleted');
+
+      element.editingRef = false;
+      assert.equal(element.computeSectionClass(), 'editing deleted');
+    });
+  });
+
+  suite('interactive tests', () => {
+    setup(() => {
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not submitted as is',
+            '-2': 'This shall not be submitted',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+    });
+    suite('Global section', () => {
+      setup(async () => {
+        element.section = {
+          id: 'GLOBAL_CAPABILITIES' as GitRef,
+          value: {
+            permissions: {
+              accessDatabase: {
+                rules: {},
+              },
+            },
+          },
+        };
+        element.capabilities = {
+          accessDatabase: {
+            id: 'accessDatabase',
+            name: 'Access Database',
+          },
+          administrateServer: {
+            id: 'administrateServer',
+            name: 'Administrate Server',
+          },
+          batchChangesLimit: {
+            id: 'batchChangesLimit',
+            name: 'Batch Changes Limit',
+          },
+          createAccount: {
+            id: 'createAccount',
+            name: 'Create Account',
+          },
+        };
+        element.updateSection();
+        await element.updateComplete;
+      });
+
+      test('classes are assigned correctly', () => {
+        assert.isFalse(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('editing')
+        );
+        assert.isFalse(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('deleted')
+        );
+        assert.isTrue(
+          queryAndAssert<GrButton>(element, '#editBtn').classList.contains(
+            'global'
+          )
+        );
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        assert.equal(
+          getComputedStyle(queryAndAssert<GrButton>(element, '#editBtn'))
+            .display,
+          'none'
+        );
+      });
+    });
+
+    suite('Non-global section', () => {
+      setup(async () => {
+        element.section = {
+          id: 'refs/*' as GitRef,
+          value: {
+            permissions: {
+              read: {
+                rules: {},
+              },
+            },
+          },
+        };
+        element.capabilities = {};
+        element.updateSection();
+        await element.updateComplete;
+      });
+
+      test('classes are assigned correctly', async () => {
+        assert.isFalse(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('editing')
+        );
+        assert.isFalse(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('deleted')
+        );
+        assert.isFalse(
+          queryAndAssert<GrButton>(element, '#editBtn').classList.contains(
+            'global'
+          )
+        );
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        await element.updateComplete;
+        assert.notEqual(
+          getComputedStyle(queryAndAssert<GrButton>(element, '#editBtn'))
+            .display,
+          'none'
+        );
+      });
+
+      test('add permission', async () => {
+        element.editing = true;
+        queryAndAssert<HTMLSelectElement>(element, '#permissionSelect').value =
+          'label-Code-Review';
+        assert.equal(element.permissions!.length, 1);
+        assert.equal(Object.keys(element.section!.value.permissions).length, 1);
+        queryAndAssert<GrButton>(element, '#addBtn').click();
+        await element.updateComplete;
+
+        // The permission is added to both the permissions array and also
+        // the section's permission object.
+        assert.equal(element.permissions!.length, 2);
+        let permission;
+
+        permission = {
+          id: 'label-Code-Review' as GitRef,
+          value: {
+            added: true,
+            label: 'Code-Review',
+            rules: {},
+          },
+        };
+        assert.equal(element.permissions!.length, 2);
+        assert.deepEqual(element.permissions![1], permission);
+        assert.equal(Object.keys(element.section!.value.permissions).length, 2);
+        assert.deepEqual(
+          element.section!.value.permissions['label-Code-Review'],
+          permission.value
+        );
+
+        queryAndAssert<HTMLSelectElement>(element, '#permissionSelect').value =
+          'abandon';
+        queryAndAssert<GrButton>(element, '#addBtn').click();
+        await element.updateComplete;
+
+        permission = {
+          id: 'abandon' as GitRef,
+          value: {
+            added: true,
+            rules: {},
+          },
+        };
+
+        assert.equal(element.permissions!.length, 3);
+        assert.deepEqual(element.permissions![2], permission);
+        assert.equal(Object.keys(element.section!.value.permissions).length, 3);
+        assert.deepEqual(
+          element.section!.value.permissions['abandon'],
+          permission.value
+        );
+
+        // Unsaved changes are discarded when editing is cancelled.
+        element.editing = false;
+        await element.updateComplete;
+        assert.equal(element.permissions!.length, 1);
+        assert.equal(Object.keys(element.section!.value.permissions).length, 1);
+      });
+
+      test('edit section reference', async () => {
+        element.canUpload = true;
+        element.ownerOf = [];
+        element.section = {
+          id: 'refs/for/bar' as GitRef,
+          value: {permissions: {}},
+        };
+        await element.updateComplete;
+        assert.isFalse(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('editing')
+        );
+        element.editing = true;
+        await element.updateComplete;
+        assert.isTrue(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('editing')
+        );
+        assert.isFalse(element.editingRef);
+        queryAndAssert<GrButton>(element, '#editBtn').click();
+        element.editRefInput().bindValue = 'new/ref';
+        await element.updateComplete;
+        assert.equal(element.section.id, 'new/ref');
+        assert.isTrue(element.editingRef);
+        assert.isTrue(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('editingRef')
+        );
+        element.editing = false;
+        await element.updateComplete;
+        assert.isFalse(element.editingRef);
+        assert.equal(element.section.id, 'refs/for/bar');
+      });
+
+      test('handleValueChange', async () => {
+        // For an existing section.
+        const modifiedHandler = sinon.stub();
+        element.section = {
+          id: 'refs/for/bar' as GitRef,
+          value: {permissions: {}},
+        };
+        await element.updateComplete;
+        assert.notOk(element.section.value.updatedId);
+        element.section.id = 'refs/for/baz' as GitRef;
+        await element.updateComplete;
+        element.addEventListener('access-modified', modifiedHandler);
+        assert.isNotOk(element.section.value.modified);
+        element.handleValueChange();
+        assert.equal(element.section.value.updatedId, 'refs/for/baz');
+        assert.isTrue(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 1);
+        element.section.id = 'refs/for/bar' as GitRef;
+        await element.updateComplete;
+        element.handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+
+        // For a new section.
+        element.section.value.added = true;
+        await element.updateComplete;
+        element.handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+        element.section.id = 'refs/for/bar' as GitRef;
+        await element.updateComplete;
+        element.handleValueChange();
+        assert.isFalse(element.section.value.modified);
+        assert.equal(modifiedHandler.callCount, 2);
+      });
+
+      test('remove section', async () => {
+        element.editing = true;
+        element.canUpload = true;
+        element.ownerOf = [];
+        await element.updateComplete;
+        assert.isFalse(element.deleted);
+        assert.isNotOk(element.section!.value.deleted);
+        queryAndAssert<GrButton>(element, '#deleteBtn').click();
+        await element.updateComplete;
+        assert.isTrue(element.deleted);
+        assert.isTrue(element.section!.value.deleted);
+        assert.isTrue(
+          queryAndAssert<HTMLFieldSetElement>(
+            element,
+            '#section'
+          ).classList.contains('deleted')
+        );
+        assert.isTrue(element.section!.value.deleted);
+
+        queryAndAssert<GrButton>(element, '#undoRemoveBtn').click();
+        await element.updateComplete;
+        assert.isFalse(element.deleted);
+        assert.isNotOk(element.section!.value.deleted);
+
+        queryAndAssert<GrButton>(element, '#deleteBtn').click();
+        await element.updateComplete;
+        assert.isTrue(element.deleted);
+        assert.isTrue(element.section!.value.deleted);
+        element.editing = false;
+        await element.updateComplete;
+        assert.isFalse(element.deleted);
+        assert.isNotOk(element.section!.value.deleted);
+      });
+
+      test('removing an added permission', async () => {
+        element.editing = true;
+        await element.updateComplete;
+        assert.equal(element.permissions!.length, 1);
+        element.shadowRoot!.querySelector('gr-permission')!.dispatchEvent(
+          new CustomEvent('added-permission-removed', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        await element.updateComplete;
+        assert.equal(element.permissions!.length, 0);
+      });
+
+      test('remove an added section', async () => {
+        const removeStub = sinon.stub();
+        element.addEventListener('added-section-removed', removeStub);
+        element.editing = true;
+        element.section!.value.added = true;
+        await element.updateComplete;
+        queryAndAssert<GrButton>(element, '#deleteBtn').click();
+        await element.updateComplete;
+        assert.isTrue(removeStub.called);
+      });
+    });
+  });
+});
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 ea70d7e..d3562f2 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
@@ -1,37 +1,29 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-group-dialog/gr-create-group-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-admin-group-list_html';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe, computed} from '@polymer/decorators';
-import {AppElementAdminParams} from '../../gr-app-types';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
 import {fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, query, property, state} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+} from '../../../models/views/admin';
+import {createGroupUrl} from '../../../models/views/group';
+import {whenVisible} from '../../../utils/dom-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,150 +31,219 @@
   }
 }
 
-export interface GrAdminGroupList {
-  $: {
-    createOverlay: GrOverlay;
-    createNewModal: GrCreateGroupDialog;
-  };
-}
-
 @customElement('gr-admin-group-list')
-export class GrAdminGroupList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrAdminGroupList extends LitElement {
+  @query('#createModal') private createModal?: HTMLDialogElement;
+
+  @query('#createNewModal') private createNewModal?: GrCreateGroupDialog;
 
   @property({type: Object})
-  params?: AppElementAdminParams;
+  params?: AdminViewState;
 
   /**
    * Offset of currently visible query results.
    */
-  @property({type: Number})
-  _offset = 0;
+  @state() private offset = 0;
 
-  @property({type: String})
-  readonly _path = '/admin/groups';
+  @state() private hasNewGroupName = false;
 
-  @property({type: Boolean})
-  _hasNewGroupName = false;
+  @state() private createNewCapability = false;
 
-  @property({type: Boolean})
-  _createNewCapability = false;
+  // private but used in test
+  @state() groups: GroupInfo[] = [];
 
-  @property({type: Array})
-  _groups: GroupInfo[] = [];
+  @state() private groupsPerPage = 25;
 
-  /**
-   * Because  we request one more than the groupsPerPage, _shownGroups
-   * may be one less than _groups.
-   * */
-  @computed('_groups')
-  get _shownGroups() {
-    return this._groups.slice(0, SHOWN_ITEMS_COUNT);
-  }
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Number})
-  _groupsPerPage = 25;
+  @state() private filter = '';
 
-  @property({type: Boolean})
-  _loading = true;
-
-  @property({type: String})
-  _filter = '';
-
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
-    this._getCreateGroupCapability();
+    this.getCreateGroupCapability();
     fireTitleChange(this, 'Groups');
-    this._maybeOpenCreateOverlay(this.params);
   }
 
-  @observe('params')
-  _paramsChanged(params: AppElementAdminParams) {
-    this._loading = true;
-    this._filter = params?.filter ?? '';
-    this._offset = Number(params?.offset ?? 0);
+  static override get styles() {
+    return [
+      tableStyles,
+      sharedStyles,
+      modalStyles,
+      css`
+        gr-list-view {
+          --generic-list-description-width: 70%;
+        }
+      `,
+    ];
+  }
 
-    return this._getGroups(this._filter, this._groupsPerPage, this._offset);
+  override render() {
+    return html`
+      <gr-list-view
+        .createNew=${this.createNewCapability}
+        .filter=${this.filter}
+        .items=${this.groups}
+        .itemsPerPage=${this.groupsPerPage}
+        .loading=${this.loading}
+        .offset=${this.offset}
+        .path=${createAdminUrl({adminView: AdminChildView.GROUPS})}
+        @create-clicked=${() => this.handleCreateClicked()}
+      >
+        <table id="list" class="genericList">
+          <tbody>
+            <tr class="headerRow">
+              <th class="name topHeader">Group Name</th>
+              <th class="description topHeader">Group Description</th>
+              <th class="visibleToAll topHeader">Visible To All</th>
+            </tr>
+            <tr
+              id="loading"
+              class="loadingMsg ${this.loading ? 'loading' : ''}"
+            >
+              <td>Loading...</td>
+            </tr>
+          </tbody>
+          <tbody class=${this.loading ? 'loading' : ''}>
+            ${this.groups
+              .slice(0, SHOWN_ITEMS_COUNT)
+              .map(group => this.renderGroupList(group))}
+          </tbody>
+        </table>
+      </gr-list-view>
+      <dialog id="createModal" tabindex="-1">
+        <gr-dialog
+          id="createDialog"
+          class="confirmDialog"
+          ?disabled=${!this.hasNewGroupName}
+          confirm-label="Create"
+          confirm-on-enter
+          @confirm=${() => this.handleCreateGroup()}
+          @cancel=${() => this.handleCloseCreate()}
+        >
+          <div class="header" slot="header">Create Group</div>
+          <div class="main" slot="main">
+            <gr-create-group-dialog
+              id="createNewModal"
+              @has-new-group-name=${this.handleHasNewGroupName}
+            ></gr-create-group-dialog>
+          </div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
+
+  private renderGroupList(group: GroupInfo) {
+    return html`
+      <tr class="table">
+        <td class="name">
+          <a href=${this.computeGroupUrl(group.id)}>${group.name}</a>
+        </td>
+        <td class="description">${group.description}</td>
+        <td class="visibleToAll">
+          ${group.options?.visible_to_all === true ? 'Y' : 'N'}
+        </td>
+      </tr>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+  }
+
+  // private but used in test
+  paramsChanged() {
+    this.filter = this.params?.filter ?? '';
+    this.offset = Number(this.params?.offset ?? 0);
+    this.maybeOpenCreateModal(this.params);
+
+    return this.getGroups(this.filter, this.groupsPerPage, this.offset);
   }
 
   /**
    * Opens the create overlay if the route has a hash 'create'
+   *
+   * private but used in test
    */
-  _maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+  async maybeOpenCreateModal(params?: AdminViewState) {
     if (params?.openCreateModal) {
-      this.$.createOverlay.open();
+      await this.updateComplete;
+      if (!this.createModal?.open) this.createModal?.showModal();
     }
   }
 
-  /**
-   * Generates groups link (/admin/groups/<uuid>)
-   */
-  _computeGroupUrl(id: string) {
-    return GerritNav.getUrlForGroup(decodeURIComponent(id) as GroupId);
+  // private but used in test
+  computeGroupUrl(encodedId: string) {
+    const groupId = decodeURIComponent(encodedId) as GroupId;
+    return createGroupUrl({groupId});
   }
 
-  _getCreateGroupCapability() {
+  private getCreateGroupCapability() {
     return this.restApiService.getAccount().then(account => {
-      if (!account) {
-        return;
-      }
+      if (!account) return;
       return this.restApiService
         .getAccountCapabilities(['createGroup'])
         .then(capabilities => {
           if (capabilities?.createGroup) {
-            this._createNewCapability = true;
+            this.createNewCapability = true;
           }
         });
     });
   }
 
-  _getGroups(filter: string, groupsPerPage: number, offset?: number) {
-    this._groups = [];
+  private getGroups(filter: string, groupsPerPage: number, offset?: number) {
+    this.groups = [];
+    this.loading = true;
     return this.restApiService
       .getGroups(filter, groupsPerPage, offset)
       .then(groups => {
-        if (!groups) {
-          return;
-        }
-        this._groups = Object.keys(groups).map(key => {
+        if (!groups) return;
+        this.groups = Object.keys(groups).map(key => {
           const group = groups[key];
           group.name = key as GroupName;
           return group;
         });
-        this._loading = false;
+      })
+      .finally(() => {
+        this.loading = false;
       });
   }
 
-  _refreshGroupsList() {
+  private refreshGroupsList() {
     this.restApiService.invalidateGroupsCache();
-    return this._getGroups(this._filter, this._groupsPerPage, this._offset);
+    return this.getGroups(this.filter, this.groupsPerPage, this.offset);
   }
 
-  _handleCreateGroup() {
-    this.$.createNewModal.handleCreateGroup().then(() => {
-      this._refreshGroupsList();
+  // private but used in test
+  handleCreateGroup() {
+    assertIsDefined(this.createNewModal, 'createNewModal');
+    this.createNewModal.handleCreateGroup().then(() => {
+      this.refreshGroupsList();
     });
   }
 
-  _handleCloseCreate() {
-    this.$.createOverlay.close();
+  // private but used in test
+  handleCloseCreate() {
+    assertIsDefined(this.createModal, 'createModal');
+    this.createModal.close();
   }
 
-  _handleCreateClicked() {
-    this.$.createOverlay.open().then(() => {
-      this.$.createNewModal.focus();
+  // private but used in test
+  handleCreateClicked() {
+    assertIsDefined(this.createModal, 'createModal');
+    this.createModal.showModal();
+    whenVisible(this.createModal, () => {
+      assertIsDefined(this.createNewModal, 'createNewModal');
+      this.createNewModal.focus();
     });
   }
 
-  _visibleToAll(item: GroupInfo) {
-    return item.options?.visible_to_all === true ? 'Y' : 'N';
-  }
-
-  computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
+  private handleHasNewGroupName() {
+    assertIsDefined(this.createNewModal, 'createNewModal');
+    this.hasNewGroupName = !!this.createNewModal.name;
   }
 }
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
deleted file mode 100644
index 91863a9..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-list-view
-    create-new="[[_createNewCapability]]"
-    filter="[[_filter]]"
-    items="[[_groups]]"
-    items-per-page="[[_groupsPerPage]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    on-create-clicked="_handleCreateClicked"
-    path="[[_path]]"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Group Name</th>
-          <th class="description topHeader">Group Description</th>
-          <th class="visibleToAll topHeader">Visible To All</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownGroups]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_computeGroupUrl(item.id)]]">[[item.name]]</a>
-            </td>
-            <td class="description">[[item.description]]</td>
-            <td class="visibleToAll">[[_visibleToAll(item)]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      id="createDialog"
-      class="confirmDialog"
-      disabled="[[!_hasNewGroupName]]"
-      confirm-label="Create"
-      confirm-on-enter=""
-      on-confirm="_handleCreateGroup"
-      on-cancel="_handleCloseCreate"
-    >
-      <div class="header" slot="header">Create Group</div>
-      <div class="main" slot="main">
-        <gr-create-group-dialog
-          has-new-group-name="{{_hasNewGroupName}}"
-          id="createNewModal"
-        ></gr-create-group-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
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
deleted file mode 100644
index 7b7b959..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
+++ /dev/null
@@ -1,184 +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-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');
-
-let counter = 0;
-const groupGenerator = () => {
-  return {
-    name: `test${++counter}`,
-    id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b',
-    url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
-    options: {
-      visible_to_all: false,
-    },
-    description: 'Gerrit Site Administrators',
-    group_id: 1,
-    owner: 'Administrators',
-    owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
-  };
-};
-
-suite('gr-admin-group-list tests', () => {
-  let element;
-  let groups;
-
-  let value;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_computeGroupUrl', () => {
-    let urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
-        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    let group = {
-      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
-    };
-    assert.equal(element._computeGroupUrl(group),
-        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    urlStub.restore();
-
-    urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
-        () => '/admin/groups/user/test');
-
-    group = {
-      id: 'user%2Ftest',
-    };
-    assert.equal(element._computeGroupUrl(group),
-        '/admin/groups/user/test');
-
-    urlStub.restore();
-  });
-
-  suite('list with groups', () => {
-    setup(async () => {
-      groups = _.times(26, groupGenerator);
-      stubRestApi('getGroups').returns(Promise.resolve(groups));
-      element._paramsChanged(value);
-      await flush();
-    });
-
-    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', () => {
-      assert.equal(element._shownGroups.length, 25);
-    });
-
-    test('_maybeOpenCreateOverlay', () => {
-      const overlayOpen = sinon.stub(element.$.createOverlay, 'open');
-      element._maybeOpenCreateOverlay();
-      assert.isFalse(overlayOpen.called);
-      const params = {};
-      element._maybeOpenCreateOverlay(params);
-      assert.isFalse(overlayOpen.called);
-      params.openCreateModal = true;
-      element._maybeOpenCreateOverlay(params);
-      assert.isTrue(overlayOpen.called);
-    });
-  });
-
-  suite('test with less then 25 groups', () => {
-    setup(async () => {
-      groups = _.times(25, groupGenerator);
-      stubRestApi('getGroups').returns(Promise.resolve(groups));
-      await element._paramsChanged(value);
-      await flush();
-    });
-
-    test('_shownGroups', () => {
-      assert.equal(element._shownGroups.length, 25);
-    });
-  });
-
-  suite('filter', () => {
-    test('_paramsChanged', async () => {
-      const getGroupsStub = stubRestApi('getGroups');
-      getGroupsStub.returns(Promise.resolve(groups));
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      await element._paramsChanged(value);
-      assert.isTrue(getGroupsStub.lastCall.calledWithExactly('test', 25, 25));
-    });
-  });
-
-  suite('loading', () => {
-    test('correct contents are displayed', () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-      element._loading = false;
-      element._groups = _.times(25, groupGenerator);
-
-      flush();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
-    });
-  });
-
-  suite('create new', () => {
-    test('_handleCreateClicked called when create-click fired', () => {
-      sinon.stub(element, '_handleCreateClicked');
-      element.shadowRoot
-          .querySelector('gr-list-view').dispatchEvent(
-              new CustomEvent('create-clicked', {
-                composed: true, bubbles: true,
-              }));
-      assert.isTrue(element._handleCreateClicked.called);
-    });
-
-    test('_handleCreateClicked opens modal', () => {
-      const openStub = sinon.stub(element.$.createOverlay, 'open').returns(
-          Promise.resolve());
-      element._handleCreateClicked();
-      assert.isTrue(openStub.called);
-    });
-
-    test('_handleCreateGroup called when confirm fired', () => {
-      sinon.stub(element, '_handleCreateGroup');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('confirm', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCreateGroup.called);
-    });
-
-    test('_handleCloseCreate called when cancel fired', () => {
-      sinon.stub(element, '_handleCloseCreate');
-      element.$.createDialog.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCloseCreate.called);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
new file mode 100644
index 0000000..e9b7ea0
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
@@ -0,0 +1,231 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-admin-group-list';
+import {GrAdminGroupList} from './gr-admin-group-list';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  GroupId,
+  GroupName,
+  GroupNameToGroupInfoMap,
+} from '../../../types/common';
+import {GerritView} from '../../../services/router/router-model';
+import {GrListView} from '../../shared/gr-list-view/gr-list-view';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
+
+function createGroup(name: string, counter: number) {
+  return {
+    name: `${name}${counter}` as GroupName,
+    id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b' as GroupId,
+    url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+    options: {
+      visible_to_all: false,
+    },
+    description: 'Gerrit Site Administrators',
+    group_id: 1,
+    owner: 'Administrators',
+    owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
+  };
+}
+
+function createGroupList(name: string, n: number) {
+  const groups = [];
+  for (let i = 0; i < n; ++i) {
+    groups.push(createGroup(name, i));
+  }
+  return groups;
+}
+
+function createGroupObjectList(name: string, n: number) {
+  const groups: GroupNameToGroupInfoMap = {};
+  for (let i = 0; i < n; ++i) {
+    groups[`${name}${i}`] = createGroup(name, i);
+  }
+  return groups;
+}
+
+suite('gr-admin-group-list tests', () => {
+  let element: GrAdminGroupList;
+  let groups: GroupNameToGroupInfoMap;
+
+  const value: AdminViewState = {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.GROUPS,
+  };
+
+  setup(async () => {
+    element = await fixture(html`<gr-admin-group-list></gr-admin-group-list>`);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-list-view>
+          <table class="genericList" id="list">
+            <tbody>
+              <tr class="headerRow">
+                <th class="name topHeader">Group Name</th>
+                <th class="description topHeader">Group Description</th>
+                <th class="topHeader visibleToAll">Visible To All</th>
+              </tr>
+              <tr class="loading loadingMsg" id="loading">
+                <td>Loading...</td>
+              </tr>
+            </tbody>
+            <tbody class="loading"></tbody>
+          </table>
+        </gr-list-view>
+        <dialog id="createModal" tabindex="-1">
+          <gr-dialog
+            class="confirmDialog"
+            confirm-label="Create"
+            confirm-on-enter=""
+            disabled=""
+            id="createDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Create Group</div>
+            <div class="main" slot="main">
+              <gr-create-group-dialog id="createNewModal">
+              </gr-create-group-dialog>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `
+    );
+  });
+
+  suite('list with groups', () => {
+    setup(async () => {
+      groups = createGroupObjectList('test', 26);
+      stubRestApi('getGroups').returns(Promise.resolve(groups));
+      element.params = value;
+      element.paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('test for test group in the list', () => {
+      assert.equal(element.groups[1].name, 'test1' as GroupName);
+      assert.equal(element.groups[1].options!.visible_to_all, false);
+    });
+
+    test('groups', () => {
+      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+
+    test('maybeOpenCreateModal', async () => {
+      const modalOpen = sinon.stub(
+        queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+        'showModal'
+      );
+      await element.maybeOpenCreateModal();
+      assert.isFalse(modalOpen.called);
+      await element.maybeOpenCreateModal(undefined);
+      assert.isFalse(modalOpen.called);
+      value.openCreateModal = true;
+      await element.maybeOpenCreateModal(value);
+      assert.isTrue(modalOpen.called);
+    });
+  });
+
+  suite('test with 25 groups', () => {
+    setup(async () => {
+      groups = createGroupObjectList('test', 25);
+      stubRestApi('getGroups').returns(Promise.resolve(groups));
+      element.params = value;
+      await element.paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('groups', () => {
+      assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    test('paramsChanged', async () => {
+      const getGroupsStub = stubRestApi('getGroups');
+      getGroupsStub.returns(Promise.resolve(groups));
+      value.filter = 'test';
+      value.offset = 25;
+      element.params = value;
+      await element.paramsChanged();
+      assert.isTrue(getGroupsStub.lastCall.calledWithExactly('test', 25, 25));
+    });
+  });
+
+  suite('loading', async () => {
+    test('correct contents are displayed', async () => {
+      assert.isTrue(element.loading);
+      assert.equal(
+        getComputedStyle(queryAndAssert<HTMLTableElement>(element, '#loading'))
+          .display,
+        'block'
+      );
+
+      element.loading = false;
+      element.groups = createGroupList('test', 25);
+
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyle(queryAndAssert<HTMLTableElement>(element, '#loading'))
+          .display,
+        'none'
+      );
+    });
+  });
+
+  suite('create new', () => {
+    test('handleCreateClicked called when create-click fired', () => {
+      const handleCreateClickedStub = sinon.stub(
+        element,
+        'handleCreateClicked'
+      );
+      queryAndAssert<GrListView>(element, 'gr-list-view').dispatchEvent(
+        new CustomEvent('create-clicked', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCreateClickedStub.called);
+    });
+
+    test('handleCreateClicked opens modal', () => {
+      const openStub = sinon.stub(
+        queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+        'showModal'
+      );
+      element.handleCreateClicked();
+      assert.isTrue(openStub.called);
+    });
+
+    test('handleCreateGroup called when confirm fired', () => {
+      const handleCreateGroupStub = sinon.stub(element, 'handleCreateGroup');
+      queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+        new CustomEvent('confirm', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCreateGroupStub.called);
+    });
+
+    test('handleCloseCreate called when cancel fired', () => {
+      const handleCloseCreateStub = sinon.stub(element, 'handleCloseCreate');
+      queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCloseCreateStub.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 1c9c6f3..1be5fa3 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
@@ -1,24 +1,10 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-page-nav-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../gr-admin-group-list/gr-admin-group-list';
 import '../gr-group/gr-group';
@@ -31,27 +17,14 @@
 import '../gr-repo-dashboards/gr-repo-dashboards';
 import '../gr-repo-detail-list/gr-repo-detail-list';
 import '../gr-repo-list/gr-repo-list';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-admin-view_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {
-  GerritNav,
-  GroupDetailView,
-  RepoDetailView,
-} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   AdminNavLinksOption,
   getAdminLinks,
   NavLink,
   SubsectionInterface,
 } from '../../../utils/admin-nav-util';
-import {customElement, observe, property} from '@polymer/decorators';
-import {
-  AppElementAdminParams,
-  AppElementGroupParams,
-  AppElementRepoParams,
-} from '../../gr-app-types';
 import {
   AccountDetailInfo,
   GroupId,
@@ -59,13 +32,40 @@
   RepoName,
 } from '../../../types/common';
 import {GroupNameChangedDetail} from '../gr-group/gr-group';
-import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {appContext} from '../../../services/app-context';
-import {GerritView} from '../../../services/router/router-model';
+import {getAppContext} from '../../../services/app-context';
+import {
+  GerritView,
+  routerModelToken,
+} from '../../../services/router/router-model';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {
+  AdminChildView,
+  adminViewModelToken,
+  AdminViewState,
+} from '../../../models/views/admin';
+import {
+  GroupDetailView,
+  groupViewModelToken,
+  GroupViewState,
+} from '../../../models/views/group';
+import {
+  RepoDetailView,
+  repoViewModelToken,
+  RepoViewState,
+} from '../../../models/views/repo';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
-interface AdminSubsectionLink {
+export interface AdminSubsectionLink {
   text: string;
   value: string;
   view: GerritView;
@@ -74,131 +74,418 @@
   parent?: GroupId | RepoName;
 }
 
-// The type is matched to the _showAdminView function from the gr-app-element
-type AdminViewParams =
-  | AppElementAdminParams
-  | AppElementGroupParams
-  | AppElementRepoParams;
-
-function getAdminViewParamsDetail(
-  params: AdminViewParams
-): GroupDetailView | RepoDetailView | undefined {
-  if (params.view !== GerritView.ADMIN) {
-    return params.detail;
-  }
-  return undefined;
-}
-
 @customElement('gr-admin-view')
-export class GrAdminView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAdminView extends LitElement {
   private account?: AccountDetailInfo;
 
-  @property({type: Object})
-  params?: AdminViewParams;
+  @state()
+  view?: GerritView;
 
-  @property({type: String})
-  path?: string;
+  @state()
+  adminViewState?: AdminViewState;
 
-  @property({type: String})
-  adminView?: string;
+  @state()
+  groupViewState?: GroupViewState;
 
-  @property({type: String})
-  _breadcrumbParentName?: string;
+  @state()
+  repoViewState?: RepoViewState;
 
-  @property({type: String})
-  _repoName?: RepoName;
+  @state() private breadcrumbParentName?: string;
 
-  @property({type: String, observer: '_computeGroupName'})
-  _groupId?: GroupId;
+  // private but used in test
+  @state() repoName?: RepoName;
 
-  @property({type: Boolean})
-  _groupIsInternal?: boolean;
+  // private but used in test
+  @state() groupId?: GroupId;
 
-  @property({type: String})
-  _groupName?: GroupName;
+  // private but used in test
+  @state() groupIsInternal?: boolean;
 
-  @property({type: Boolean})
-  _groupOwner = false;
+  // private but used in test
+  @state() groupName?: GroupName;
 
-  @property({type: Array})
-  _subsectionLinks?: AdminSubsectionLink[];
+  // private but used in test
+  @state() subsectionLinks?: AdminSubsectionLink[];
 
-  @property({type: Array})
-  _filteredLinks?: NavLink[];
+  // private but used in test
+  @state() filteredLinks?: NavLink[];
 
-  @property({type: Boolean})
-  _showDownload = false;
+  private reloading = false;
 
-  @property({type: Boolean})
-  _isAdmin = false;
+  // private but used in the tests
+  private readonly restApiService = getAppContext().restApiService;
 
-  @property({type: Boolean})
-  _showGroup?: boolean;
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
-  @property({type: Boolean})
-  _showGroupAuditLog?: boolean;
+  private readonly getAdminViewModel = resolve(this, adminViewModelToken);
 
-  @property({type: Boolean})
-  _showGroupList?: boolean;
+  private readonly getGroupViewModel = resolve(this, groupViewModelToken);
 
-  @property({type: Boolean})
-  _showGroupMembers?: boolean;
+  private readonly getRepoViewModel = resolve(this, repoViewModelToken);
 
-  @property({type: Boolean})
-  _showRepoAccess?: boolean;
+  private readonly getRouterModel = resolve(this, routerModelToken);
 
-  @property({type: Boolean})
-  _showRepoCommands?: boolean;
+  private readonly getNavigation = resolve(this, navigationToken);
 
-  @property({type: Boolean})
-  _showRepoDashboards?: boolean;
-
-  @property({type: Boolean})
-  _showRepoDetailList?: boolean;
-
-  @property({type: Boolean})
-  _showRepoMain?: boolean;
-
-  @property({type: Boolean})
-  _showRepoList?: boolean;
-
-  @property({type: Boolean})
-  _showPluginList?: boolean;
-
-  private readonly restApiService = appContext.restApiService;
-
-  private readonly jsAPI = appContext.jsApiService;
+  constructor() {
+    super();
+    this.addEventListener('reload', () => window.location.reload());
+    subscribe(
+      this,
+      () => this.getAdminViewModel().state$,
+      state => {
+        this.adminViewState = state;
+        if (this.needsReload()) this.reload();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getGroupViewModel().state$,
+      state => {
+        this.groupViewState = state;
+        if (this.needsReload()) this.reload();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getRepoViewModel().state$,
+      state => {
+        this.repoViewState = state;
+        if (this.needsReload()) this.reload();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getRouterModel().routerView$,
+      view => {
+        this.view = view;
+        if (this.needsReload()) this.reload();
+      }
+    );
+  }
 
   override connectedCallback() {
     super.connectedCallback();
     this.reload();
   }
 
-  reload() {
-    const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = [
-      this.restApiService.getAccount(),
-      getPluginLoader().awaitPluginsLoaded(),
+  static override get styles() {
+    return [
+      sharedStyles,
+      menuPageStyles,
+      pageNavStyles,
+      css`
+        .breadcrumbText {
+          /* Same as dropdown trigger so chevron spacing is consistent. */
+          padding: 5px 4px;
+        }
+        gr-icon {
+          margin: 0 var(--spacing-xs);
+        }
+        .breadcrumb {
+          align-items: center;
+          display: flex;
+        }
+        .mainHeader {
+          align-items: baseline;
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+        }
+        .selectText {
+          display: none;
+        }
+        .selectText.show {
+          display: inline-block;
+        }
+        .main.breadcrumbs:not(.table) {
+          margin-top: var(--spacing-l);
+        }
+      `,
     ];
-    return Promise.all(promises).then(result => {
+  }
+
+  override render() {
+    if (!this.isAdminView()) return nothing;
+    return html`
+      <gr-page-nav class="navStyles">
+        <ul class="sectionContent">
+          ${this.filteredLinks?.map(item => this.renderAdminNav(item))}
+        </ul>
+      </gr-page-nav>
+      ${this.renderSubsectionLinks()} ${this.renderRepoList()}
+      ${this.renderGroupList()} ${this.renderPluginList()}
+      ${this.renderRepoMain()} ${this.renderGroup()}
+      ${this.renderGroupMembers()} ${this.renderGroupAuditLog()}
+      ${this.renderRepoDetailList()} ${this.renderRepoCommands()}
+      ${this.renderRepoAccess()} ${this.renderRepoDashboards()}
+    `;
+  }
+
+  private renderAdminNav(item: NavLink) {
+    return html`
+      <li class="sectionTitle ${this.computeSelectedClass(item.view)}">
+        <a class="title" href=${this.computeLinkURL(item)} rel="noopener"
+          >${item.name}</a
+        >
+      </li>
+      ${item.children?.map(child => this.renderAdminNavChild(child))}
+      ${this.renderAdminNavSubsection(item)}
+    `;
+  }
+
+  private renderAdminNavChild(child: SubsectionInterface) {
+    return html`
+      <li class=${this.computeSelectedClass(child.view)}>
+        <a href=${this.computeLinkURL(child)} rel="noopener">${child.name}</a>
+      </li>
+    `;
+  }
+
+  private renderAdminNavSubsection(item: NavLink) {
+    if (!item.subsection) return nothing;
+
+    return html`
+      <!--If a section has a subsection, render that.-->
+      <li class=${this.computeSelectedClass(item.subsection.view)}>
+        ${this.renderAdminNavSubsectionUrl(item.subsection)}
+      </li>
+      <!--Loop through the links in the sub-section.-->
+      ${item.subsection?.children?.map(child =>
+        this.renderAdminNavSubsectionChild(child)
+      )}
+    `;
+  }
+
+  private renderAdminNavSubsectionUrl(subsection?: SubsectionInterface) {
+    if (!subsection!.url) return html`${subsection!.name}`;
+
+    return html`
+      <a class="title" href=${this.computeLinkURL(subsection)} rel="noopener">
+        ${subsection!.name}</a
+      >
+    `;
+  }
+
+  private renderAdminNavSubsectionChild(child: SubsectionInterface) {
+    return html`
+      <li
+        class="subsectionItem ${this.computeSelectedClass(
+          child.view,
+          child.detailType
+        )}"
+      >
+        <a href=${this.computeLinkURL(child)}>${child.name}</a>
+      </li>
+    `;
+  }
+
+  private renderSubsectionLinks() {
+    if (!this.subsectionLinks?.length) return nothing;
+
+    return html`
+      <section class="mainHeader">
+        <span class="breadcrumb">
+          <span class="breadcrumbText">${this.breadcrumbParentName}</span>
+          <gr-icon icon="chevron_right"></gr-icon>
+        </span>
+        <gr-dropdown-list
+          id="pageSelect"
+          value=${ifDefined(this.computeSelectValue())}
+          .items=${this.subsectionLinks}
+          @value-change=${this.handleSubsectionChange}
+        >
+        </gr-dropdown-list>
+      </section>
+    `;
+  }
+
+  private renderRepoList() {
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.REPOS) return nothing;
+
+    return html`
+      <div class="main table">
+        <gr-repo-list
+          class="table"
+          .params=${this.adminViewState}
+        ></gr-repo-list>
+      </div>
+    `;
+  }
+
+  private renderGroupList() {
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.GROUPS)
+      return nothing;
+
+    return html`
+      <div class="main table">
+        <gr-admin-group-list class="table" .params=${this.adminViewState}>
+        </gr-admin-group-list>
+      </div>
+    `;
+  }
+
+  private renderPluginList() {
+    if (this.view !== GerritView.ADMIN) return nothing;
+    if (this.adminViewState?.adminView !== AdminChildView.PLUGINS)
+      return nothing;
+
+    return html`
+      <div class="main table">
+        <gr-plugin-list
+          class="table"
+          .params=${this.adminViewState}
+        ></gr-plugin-list>
+      </div>
+    `;
+  }
+
+  private renderRepoMain() {
+    if (this.view !== GerritView.REPO) return nothing;
+    const detail = this.repoViewState?.detail ?? RepoDetailView.GENERAL;
+    if (detail !== RepoDetailView.GENERAL) return nothing;
+
+    return html`
+      <div class="main breadcrumbs">
+        <gr-repo .repo=${this.repoViewState?.repo}></gr-repo>
+      </div>
+    `;
+  }
+
+  private renderGroup() {
+    if (this.view !== GerritView.GROUP) return nothing;
+    if (this.groupViewState?.detail !== undefined) return nothing;
+
+    return html`
+      <div class="main breadcrumbs">
+        <gr-group
+          .groupId=${this.groupViewState?.groupId}
+          @name-changed=${(e: CustomEvent<GroupNameChangedDetail>) => {
+            this.updateGroupName(e);
+          }}
+        ></gr-group>
+      </div>
+    `;
+  }
+
+  private renderGroupMembers() {
+    if (this.view !== GerritView.GROUP) return nothing;
+    if (this.groupViewState?.detail !== GroupDetailView.MEMBERS) return nothing;
+
+    return html`
+      <div class="main breadcrumbs">
+        <gr-group-members
+          .groupId=${this.groupViewState?.groupId}
+        ></gr-group-members>
+      </div>
+    `;
+  }
+
+  private renderGroupAuditLog() {
+    if (this.view !== GerritView.GROUP) return nothing;
+    if (this.groupViewState?.detail !== GroupDetailView.LOG) return nothing;
+
+    return html`
+      <div class="main table breadcrumbs">
+        <gr-group-audit-log
+          class="table"
+          .groupId=${this.groupViewState?.groupId}
+        ></gr-group-audit-log>
+      </div>
+    `;
+  }
+
+  private renderRepoDetailList() {
+    if (this.view !== GerritView.REPO) return nothing;
+    const detail = this.repoViewState?.detail;
+    if (detail !== RepoDetailView.BRANCHES && detail !== RepoDetailView.TAGS) {
+      return nothing;
+    }
+
+    return html`
+      <div class="main table breadcrumbs">
+        <gr-repo-detail-list
+          class="table"
+          .params=${this.repoViewState}
+        ></gr-repo-detail-list>
+      </div>
+    `;
+  }
+
+  private renderRepoCommands() {
+    if (this.view !== GerritView.REPO) return nothing;
+    if (this.repoViewState?.detail !== RepoDetailView.COMMANDS) return nothing;
+
+    return html`
+      <div class="main breadcrumbs">
+        <gr-repo-commands
+          .repo=${this.repoViewState.repo}
+          .createEdit=${this.repoViewState.createEdit}
+        ></gr-repo-commands>
+      </div>
+    `;
+  }
+
+  private renderRepoAccess() {
+    if (this.view !== GerritView.REPO) return nothing;
+    if (this.repoViewState?.detail !== RepoDetailView.ACCESS) return nothing;
+
+    return html`
+      <div class="main breadcrumbs">
+        <gr-repo-access .repo=${this.repoViewState.repo}></gr-repo-access>
+      </div>
+    `;
+  }
+
+  private renderRepoDashboards() {
+    if (this.view !== GerritView.REPO) return nothing;
+    if (this.repoViewState?.detail !== RepoDetailView.DASHBOARDS)
+      return nothing;
+
+    return html`
+      <div class="main table breadcrumbs">
+        <gr-repo-dashboards
+          .repo=${this.repoViewState.repo}
+        ></gr-repo-dashboards>
+      </div>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('groupId')) {
+      this.computeGroupName();
+    }
+  }
+
+  async reload() {
+    try {
+      this.reloading = true;
+      const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] =
+        [
+          this.restApiService.getAccount(),
+          this.getPluginLoader().awaitPluginsLoaded(),
+        ];
+      const result = await Promise.all(promises);
       this.account = result[0];
       let options: AdminNavLinksOption | undefined = undefined;
-      if (this._repoName) {
-        options = {repoName: this._repoName};
-      } else if (this._groupId) {
+      if (this.repoName) {
+        options = {repoName: this.repoName};
+      } else if (this.groupId) {
+        const isAdmin = await this.restApiService.getIsAdmin();
+        const isOwner = await this.restApiService.getIsGroupOwner(
+          this.groupName
+        );
         options = {
-          groupId: this._groupId,
-          groupName: this._groupName,
-          groupIsInternal: this._groupIsInternal,
-          isAdmin: this._isAdmin,
-          groupOwner: this._groupOwner,
+          groupId: this.groupId,
+          groupName: this.groupName,
+          groupIsInternal: this.groupIsInternal,
+          isAdmin,
+          groupOwner: isOwner,
         };
       }
 
-      return getAdminLinks(
+      const res = await getAdminLinks(
         this.account,
         () =>
           this.restApiService.getAccountCapabilities().then(capabilities => {
@@ -207,242 +494,170 @@
             }
             return capabilities;
           }),
-        () => this.jsAPI.getAdminMenuLinks(),
+        () => this.getPluginLoader().jsApiService.getAdminMenuLinks(),
         options
-      ).then(res => {
-        this._filteredLinks = res.links;
-        this._breadcrumbParentName = res.expandedSection
-          ? res.expandedSection.name
-          : '';
+      );
+      this.filteredLinks = res.links;
+      this.breadcrumbParentName = res.expandedSection
+        ? res.expandedSection.name
+        : '';
 
-        if (!res.expandedSection) {
-          this._subsectionLinks = [];
-          return;
-        }
-        this._subsectionLinks = [res.expandedSection]
-          .concat(res.expandedSection.children ?? [])
-          .map(section => {
-            return {
-              text: !section.detailType ? 'Home' : section.name,
-              value: section.view + (section.detailType ?? ''),
-              view: section.view,
-              url: section.url,
-              detailType: section.detailType,
-              parent: this._groupId ?? this._repoName,
-            };
-          });
-      });
-    });
+      if (!res.expandedSection) {
+        this.subsectionLinks = [];
+        return;
+      }
+      this.subsectionLinks = [res.expandedSection]
+        .concat(res.expandedSection.children ?? [])
+        .map(section => {
+          return {
+            text: !section.detailType ? 'Home' : section.name,
+            value: section.view + (section.detailType ?? ''),
+            view: section.view,
+            url: section.url,
+            detailType: section.detailType,
+            parent: this.groupId ?? this.repoName,
+          };
+        });
+    } finally {
+      this.reloading = false;
+    }
   }
 
-  _computeSelectValue(params: AdminViewParams) {
-    if (!params || !params.view) return;
-    return `${params.view}${getAdminViewParamsDetail(params) ?? ''}`;
+  private getDetailView() {
+    if (this.view === GerritView.REPO) return this.repoViewState?.detail;
+    if (this.view === GerritView.GROUP) return this.groupViewState?.detail;
+    return undefined;
   }
 
-  _selectedIsCurrentPage(selected: AdminSubsectionLink) {
-    if (!this.params) return false;
+  private computeSelectValue() {
+    return `${this.view}${this.getDetailView() ?? ''}`;
+  }
+
+  // private but used in test
+  selectedIsCurrentPage(selected: AdminSubsectionLink) {
+    if (!this.view) return false;
 
     return (
-      selected.parent === (this._repoName ?? this._groupId) &&
-      selected.view === this.params.view &&
-      selected.detailType === getAdminViewParamsDetail(this.params)
+      selected.parent === (this.repoName ?? this.groupId) &&
+      selected.view === this.view &&
+      selected.detailType === this.getDetailView()
     );
   }
 
-  _handleSubsectionChange(e: CustomEvent<ValueChangeDetail>) {
-    if (!this._subsectionLinks) return;
+  // private but used in test
+  handleSubsectionChange(e: ValueChangedEvent<string>) {
+    if (!this.subsectionLinks) return;
 
-    // The GrDropdownList items are _subsectionLinks, so find(...) always return
-    // an item _subsectionLinks and never returns undefined
-    const selected = this._subsectionLinks.find(
+    // The GrDropdownList items are subsectionLinks, so find(...) always return
+    // an item subsectionLinks and never returns undefined
+    const selected = this.subsectionLinks.find(
       section => section.value === e.detail.value
     )!;
 
     // This is when it gets set initially.
-    if (this._selectedIsCurrentPage(selected)) return;
+    if (this.selectedIsCurrentPage(selected)) return;
     if (selected.url === undefined) return;
-    GerritNav.navigateToRelativeUrl(selected.url);
+    if (this.reloading) return;
+    this.getNavigation().setUrl(selected.url);
   }
 
-  @observe('params')
-  _paramsChanged(params: AdminViewParams) {
-    this.set('_showGroup', params.view === GerritView.GROUP && !params.detail);
-    this.set(
-      '_showGroupAuditLog',
-      params.view === GerritView.GROUP && params.detail === GroupDetailView.LOG
+  isAdminView(): boolean {
+    return (
+      this.view === GerritView.ADMIN ||
+      this.view === GerritView.GROUP ||
+      this.view === GerritView.REPO
     );
-    this.set(
-      '_showGroupMembers',
-      params.view === GerritView.GROUP &&
-        params.detail === GroupDetailView.MEMBERS
-    );
+  }
 
-    this.set(
-      '_showGroupList',
-      params.view === GerritView.ADMIN &&
-        params.adminView === 'gr-admin-group-list'
-    );
-
-    this.set(
-      '_showRepoAccess',
-      params.view === GerritView.REPO && params.detail === RepoDetailView.ACCESS
-    );
-    this.set(
-      '_showRepoCommands',
-      params.view === GerritView.REPO &&
-        params.detail === RepoDetailView.COMMANDS
-    );
-    this.set(
-      '_showRepoDetailList',
-      params.view === GerritView.REPO &&
-        (params.detail === RepoDetailView.BRANCHES ||
-          params.detail === RepoDetailView.TAGS)
-    );
-    this.set(
-      '_showRepoDashboards',
-      params.view === GerritView.REPO &&
-        params.detail === RepoDetailView.DASHBOARDS
-    );
-    this.set(
-      '_showRepoMain',
-      params.view === GerritView.REPO &&
-        (!params.detail || params.detail === RepoDetailView.GENERAL)
-    );
-    this.set(
-      '_showRepoList',
-      params.view === GerritView.ADMIN && params.adminView === 'gr-repo-list'
-    );
-
-    this.set(
-      '_showPluginList',
-      params.view === GerritView.ADMIN && params.adminView === 'gr-plugin-list'
-    );
+  needsReload(): boolean {
+    if (!this.isAdminView()) return false;
 
     let needsReload = false;
     const newRepoName =
-      params.view === GerritView.REPO ? params.repo : undefined;
-    if (newRepoName !== this._repoName) {
-      this._repoName = newRepoName;
+      this.view === GerritView.REPO ? this.repoViewState?.repo : undefined;
+    if (newRepoName !== this.repoName) {
+      this.repoName = newRepoName;
       // Reloads the admin menu.
       needsReload = true;
     }
     const newGroupId =
-      params.view === GerritView.GROUP ? params.groupId : undefined;
-    if (newGroupId !== this._groupId) {
-      this._groupId = newGroupId;
+      this.view === GerritView.GROUP ? this.groupViewState?.groupId : undefined;
+    if (newGroupId !== this.groupId) {
+      this.groupId = newGroupId;
       // Reloads the admin menu.
       needsReload = true;
     }
     if (
-      this._breadcrumbParentName &&
-      (params.view !== GerritView.GROUP || !params.groupId) &&
-      (params.view !== GerritView.REPO || !params.repo)
+      this.breadcrumbParentName &&
+      (this.view !== GerritView.GROUP || !this.groupViewState?.groupId) &&
+      (this.view !== GerritView.REPO || !this.repoViewState?.repo)
     ) {
       needsReload = true;
     }
-    if (!needsReload) {
-      return;
-    }
-    this.reload();
+
+    return needsReload;
   }
 
-  // TODO (beckysiegel): Update these functions after router abstraction is
-  // updated. They are currently copied from gr-dropdown (and should be
-  // updated there as well once complete).
-  _computeURLHelper(host: string, path: string) {
-    return '//' + host + getBaseUrl() + path;
-  }
-
-  _computeRelativeURL(path: string) {
-    const host = window.location.host;
-    return this._computeURLHelper(host, path);
-  }
-
-  _computeLinkURL(link: NavLink | SubsectionInterface) {
+  // private but used in test
+  computeLinkURL(link?: NavLink | SubsectionInterface) {
     if (!link || typeof link.url === 'undefined') return '';
 
     if ((link as NavLink).target || !(link as NavLink).noBaseUrl) {
       return link.url;
     }
-    return this._computeRelativeURL(link.url);
+    return `//${window.location.host}${getBaseUrl()}${link.url}`;
   }
 
-  _computeSelectedClass(
-    itemView?: GerritView,
-    params?: AdminViewParams,
+  private computeSelectedClass(
+    itemView?: GerritView | AdminChildView,
     detailType?: GroupDetailView | RepoDetailView
   ) {
-    if (!params) return '';
-    // Group params are structured differently from admin params. Compute
+    if (!this.view) return '';
+    // Group view state is structured differently than admin view state. Compute
     // selected differently for groups.
-    // TODO(wyatta): Simplify this when all routes work like group params.
-    if (params.view === GerritView.GROUP && itemView === GerritView.GROUP) {
-      if (!params.detail && !detailType) {
+    // TODO(wyatta): Simplify this when all routes work like group view state.
+    if (this.view === GerritView.GROUP && itemView === GerritView.GROUP) {
+      if (!this.groupViewState?.detail && !detailType) {
         return 'selected';
       }
-      if (params.detail === detailType) {
+      if (this.groupViewState?.detail === detailType) {
         return 'selected';
       }
       return '';
     }
 
-    if (params.view === GerritView.REPO && itemView === GerritView.REPO) {
-      if (!params.detail && !detailType) {
+    if (this.view === GerritView.REPO && itemView === GerritView.REPO) {
+      if (!this.repoViewState?.detail && !detailType) {
         return 'selected';
       }
-      if (params.detail === detailType) {
+      if (this.repoViewState?.detail === detailType) {
         return 'selected';
       }
       return '';
     }
-    // TODO(TS): The following condition seems always false, because params
-    // never has detailType property. Remove it.
-    if (
-      (params as unknown as AdminSubsectionLink).detailType &&
-      (params as unknown as AdminSubsectionLink).detailType !== detailType
-    ) {
-      return '';
-    }
-    return params.view === GerritView.ADMIN && itemView === params.adminView
+    return this.view === GerritView.ADMIN &&
+      itemView === this.adminViewState?.adminView
       ? 'selected'
       : '';
   }
 
-  _computeGroupName(groupId?: GroupId) {
-    if (!groupId) return;
+  // private but used in test
+  async computeGroupName() {
+    if (!this.groupId) return;
 
-    const promises: Array<Promise<void>> = [];
-    this.restApiService.getGroupConfig(groupId).then(group => {
-      if (!group || !group.name) {
-        return;
-      }
+    const group = await this.restApiService.getGroupConfig(this.groupId);
+    if (!group || !group.name) {
+      return;
+    }
 
-      this._groupName = group.name;
-      this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
-      this.reload();
-
-      promises.push(
-        this.restApiService.getIsAdmin().then(isAdmin => {
-          this._isAdmin = !!isAdmin;
-        })
-      );
-
-      promises.push(
-        this.restApiService.getIsGroupOwner(group.name).then(isOwner => {
-          this._groupOwner = isOwner;
-        })
-      );
-
-      return Promise.all(promises).then(() => {
-        this.reload();
-      });
-    });
+    this.groupName = group.name;
+    this.groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
+    await this.reload();
   }
 
-  _updateGroupName(e: CustomEvent<GroupNameChangedDetail>) {
-    this._groupName = e.detail.name;
-    this.reload();
+  private async updateGroupName(e: CustomEvent<GroupNameChangedDetail>) {
+    this.groupName = e.detail.name;
+    await this.reload();
   }
 }
 
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
deleted file mode 100644
index a3afc5c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_html.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-page-nav-styles">
-    .breadcrumbText {
-      /* Same as dropdown trigger so chevron spacing is consistent. */
-      padding: 5px 4px;
-    }
-    iron-icon {
-      margin: 0 var(--spacing-xs);
-    }
-    .breadcrumb {
-      align-items: center;
-      display: flex;
-    }
-    .mainHeader {
-      align-items: baseline;
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-    }
-    .selectText {
-      display: none;
-    }
-    .selectText.show {
-      display: inline-block;
-    }
-    .main.breadcrumbs:not(.table) {
-      margin-top: var(--spacing-l);
-    }
-  </style>
-  <gr-page-nav class="navStyles">
-    <ul class="sectionContent">
-      <template id="adminNav" is="dom-repeat" items="[[_filteredLinks]]">
-        <li class$="sectionTitle [[_computeSelectedClass(item.view, params)]]">
-          <a class="title" href="[[_computeLinkURL(item)]]" rel="noopener"
-            >[[item.name]]</a
-          >
-        </li>
-        <template is="dom-repeat" items="[[item.children]]" as="child">
-          <li class$="[[_computeSelectedClass(child.view, params)]]">
-            <a href$="[[_computeLinkURL(child)]]" rel="noopener"
-              >[[child.name]]</a
-            >
-          </li>
-        </template>
-        <template is="dom-if" if="[[item.subsection]]">
-          <!--If a section has a subsection, render that.-->
-          <li class$="[[_computeSelectedClass(item.subsection.view, params)]]">
-            <template is="dom-if" if="[[item.subsection.url]]" as="child">
-              <a
-                class="title"
-                href$="[[_computeLinkURL(item.subsection)]]"
-                rel="noopener"
-              >
-                [[item.subsection.name]]</a
-              >
-            </template>
-            <template is="dom-if" if="[[!item.subsection.url]]" as="child">
-              [[item.subsection.name]]
-            </template>
-          </li>
-          <!--Loop through the links in the sub-section.-->
-          <template
-            is="dom-repeat"
-            items="[[item.subsection.children]]"
-            as="child"
-          >
-            <li
-              class$="subsectionItem [[_computeSelectedClass(child.view, params, child.detailType)]]"
-            >
-              <a href$="[[_computeLinkURL(child)]]">[[child.name]]</a>
-            </li>
-          </template>
-        </template>
-      </template>
-    </ul>
-  </gr-page-nav>
-  <template is="dom-if" if="[[_subsectionLinks.length]]">
-    <section class="mainHeader">
-      <span class="breadcrumb">
-        <span class="breadcrumbText">[[_breadcrumbParentName]]</span>
-        <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-      </span>
-      <gr-dropdown-list
-        lowercase=""
-        id="pageSelect"
-        value="[[_computeSelectValue(params)]]"
-        items="[[_subsectionLinks]]"
-        on-value-change="_handleSubsectionChange"
-      >
-      </gr-dropdown-list>
-    </section>
-  </template>
-  <template is="dom-if" if="[[_showRepoList]]" restamp="true">
-    <div class="main table">
-      <gr-repo-list class="table" params="[[params]]"></gr-repo-list>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showGroupList]]" restamp="true">
-    <div class="main table">
-      <gr-admin-group-list class="table" params="[[params]]">
-      </gr-admin-group-list>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showPluginList]]" restamp="true">
-    <div class="main table">
-      <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showRepoMain]]" restamp="true">
-    <div class="main breadcrumbs">
-      <gr-repo repo="[[params.repo]]"></gr-repo>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showGroup]]" restamp="true">
-    <div class="main breadcrumbs">
-      <gr-group
-        group-id="[[params.groupId]]"
-        on-name-changed="_updateGroupName"
-      ></gr-group>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
-    <div class="main breadcrumbs">
-      <gr-group-members group-id="[[params.groupId]]"></gr-group-members>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showRepoDetailList]]" restamp="true">
-    <div class="main table breadcrumbs">
-      <gr-repo-detail-list
-        params="[[params]]"
-        class="table"
-      ></gr-repo-detail-list>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
-    <div class="main table breadcrumbs">
-      <gr-group-audit-log
-        group-id="[[params.groupId]]"
-        class="table"
-      ></gr-group-audit-log>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showRepoCommands]]" restamp="true">
-    <div class="main breadcrumbs">
-      <gr-repo-commands repo="[[params.repo]]"></gr-repo-commands>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showRepoAccess]]" restamp="true">
-    <div class="main breadcrumbs">
-      <gr-repo-access path="[[path]]" repo="[[params.repo]]"></gr-repo-access>
-    </div>
-  </template>
-  <template is="dom-if" if="[[_showRepoDashboards]]" restamp="true">
-    <div class="main table breadcrumbs">
-      <gr-repo-dashboards repo="[[params.repo]]"></gr-repo-dashboards>
-    </div>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
deleted file mode 100644
index 6bd1ac5..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ /dev/null
@@ -1,594 +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-admin-view.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {mockPromise, 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(async () => {
-    element = basicFixture.instantiate();
-    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-    const pluginsLoaded = Promise.resolve();
-    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
-    await pluginsLoaded;
-    await flush();
-  });
-
-  test('_computeURLHelper', () => {
-    const path = '/test';
-    const host = 'http://www.testsite.com';
-    const computedPath = element._computeURLHelper(host, path);
-    assert.equal(computedPath, '//http://www.testsite.com/test');
-  });
-
-  test('link URLs', () => {
-    assert.equal(
-        element._computeLinkURL({url: '/test', noBaseUrl: true}),
-        '//' + window.location.host + '/test');
-
-    stubBaseUrl('/foo');
-    assert.equal(
-        element._computeLinkURL({url: '/test', noBaseUrl: true}),
-        '//' + window.location.host + '/foo/test');
-    assert.equal(element._computeLinkURL({url: '/test'}), '/test');
-    assert.equal(
-        element._computeLinkURL({url: '/test', target: '_blank'}),
-        '/test');
-  });
-
-  test('current page gets selected and is displayed', () => {
-    element._filteredLinks = [{
-      name: 'Repositories',
-      url: '/admin/repos',
-      view: 'gr-repo-list',
-    }];
-
-    element.params = {
-      view: 'admin',
-      adminView: 'gr-repo-list',
-    };
-
-    flush();
-    assert.equal(element.root.querySelectorAll(
-        '.selected').length, 1);
-    assert.ok(element.shadowRoot
-        .querySelector('gr-repo-list'));
-    assert.isNotOk(element.shadowRoot
-        .querySelector('gr-admin-create-repo'));
-  });
-
-  test('_filteredLinks admin', async () => {
-    stubRestApi('getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    stubRestApi('getAccountCapabilities').returns(
-        Promise.resolve(createAdminCapabilities()));
-    await element.reload();
-    assert.equal(element._filteredLinks.length, 3);
-
-    // Repos
-    assert.isNotOk(element._filteredLinks[0].subsection);
-
-    // Groups
-    assert.isNotOk(element._filteredLinks[0].subsection);
-
-    // Plugins
-    assert.isNotOk(element._filteredLinks[0].subsection);
-  });
-
-  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', 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', () => {
-    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'},
-    ]);
-    return element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 3);
-      assert.deepEqual(element._filteredLinks[1], {
-        capability: undefined,
-        url: '/internal/link/url',
-        name: 'internal link text',
-        noBaseUrl: true,
-        view: null,
-        viewableToAll: true,
-        target: null,
-      });
-      assert.deepEqual(element._filteredLinks[2], {
-        capability: undefined,
-        url: 'http://external/link/url',
-        name: 'external link text',
-        noBaseUrl: false,
-        view: null,
-        viewableToAll: true,
-        target: '_blank',
-      });
-    });
-  });
-
-  test('Repo shows up in nav', async () => {
-    element._repoName = 'Test Repo';
-    stubRestApi('getAccount').returns(Promise.resolve({
-      name: 'test-user',
-    }));
-    stubRestApi('getAccountCapabilities').returns(
-        Promise.resolve(createAdminCapabilities()));
-    await element.reload();
-    await flush();
-    assert.equal(dom(element.root)
-        .querySelectorAll('.sectionTitle').length, 3);
-    assert.equal(element.shadowRoot
-        .querySelector('.breadcrumbText').innerText, 'Test Repo');
-    assert.equal(
-        element.shadowRoot.querySelector('#pageSelect').items.length,
-        7
-    );
-  });
-
-  test('Group shows up in nav', async () => {
-    element._groupId = 'a15262';
-    element._groupName = 'my-group';
-    element._groupIsInternal = true;
-    element._isAdmin = true;
-    element._groupOwner = false;
-    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', () => {
-    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);
-    element.params = {repo: 'Test Repo 2',
-      view: GerritView.REPO};
-    assert.equal(element.reload.callCount, 2);
-  });
-
-  test('Nav is reloaded when group changes', () => {
-    sinon.stub(element, '_computeGroupName');
-    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);
-  });
-
-  test('Nav is reloaded when group name changes', async () => {
-    const newName = 'newName';
-    const reloadCalled = mockPromise();
-    sinon.stub(element, '_computeGroupName');
-    sinon.stub(element, 'reload').callsFake(() => {
-      assert.equal(element._groupName, newName);
-      reloadCalled.resolve();
-    });
-    element.params = {group: 1, view: GerritNav.View.GROUP};
-    element._groupName = 'oldName';
-    await flush();
-    element.shadowRoot
-        .querySelector('gr-group').dispatchEvent(
-            new CustomEvent('name-changed', {
-              detail: {name: newName},
-              composed: true, bubbles: true,
-            }));
-    await reloadCalled;
-  });
-
-  test('dropdown displays if there is a subsection', () => {
-    assert.isNotOk(element.shadowRoot
-        .querySelector('.mainHeader'));
-    element._subsectionLinks = [
-      {
-        text: 'Home',
-        value: 'repo',
-        view: 'repo',
-        parent: 'my-repo',
-        detailType: undefined,
-      },
-    ];
-    flush();
-    assert.isOk(element.shadowRoot
-        .querySelector('.mainHeader'));
-    element._subsectionLinks = undefined;
-    flush();
-    assert.equal(
-        getComputedStyle(element.shadowRoot
-            .querySelector('.mainHeader')).display,
-        'none');
-  });
-
-  test('Dropdown only triggers navigation on explicit select', async () => {
-    element._repoName = 'my-repo';
-    element.params = {
-      repo: 'my-repo',
-      view: GerritNav.View.REPO,
-      detail: GerritNav.RepoDetailView.ACCESS,
-    };
-    stubRestApi('getAccountCapabilities').returns(
-        Promise.resolve(createAdminCapabilities()));
-    stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
-    await flush();
-    const expectedFilteredLinks = [
-      {
-        name: 'Repositories',
-        noBaseUrl: true,
-        url: '/admin/repos',
-        view: 'gr-repo-list',
-        viewableToAll: true,
-        subsection: {
-          name: 'my-repo',
-          view: 'repo',
-          children: [
-            {
-              name: 'General',
-              view: 'repo',
-              url: '',
-              detailType: 'general',
-            },
-            {
-              name: 'Access',
-              view: 'repo',
-              detailType: 'access',
-              url: '',
-            },
-            {
-              name: 'Commands',
-              view: 'repo',
-              detailType: 'commands',
-              url: '',
-            },
-            {
-              name: 'Branches',
-              view: 'repo',
-              detailType: 'branches',
-              url: '',
-            },
-            {
-              name: 'Tags',
-              view: 'repo',
-              detailType: 'tags',
-              url: '',
-            },
-            {
-              name: 'Dashboards',
-              view: 'repo',
-              detailType: 'dashboards',
-              url: '',
-            },
-          ],
-        },
-      },
-      {
-        name: 'Groups',
-        section: 'Groups',
-        noBaseUrl: true,
-        url: '/admin/groups',
-        view: 'gr-admin-group-list',
-      },
-      {
-        name: 'Plugins',
-        capability: 'viewPlugins',
-        section: 'Plugins',
-        noBaseUrl: true,
-        url: '/admin/plugins',
-        view: 'gr-plugin-list',
-      },
-    ];
-    const expectedSubsectionLinks = [
-      {
-        text: 'Home',
-        value: 'repo',
-        view: 'repo',
-        url: undefined,
-        parent: 'my-repo',
-        detailType: undefined,
-      },
-      {
-        text: 'General',
-        value: 'repogeneral',
-        view: 'repo',
-        url: '',
-        detailType: 'general',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Access',
-        value: 'repoaccess',
-        view: 'repo',
-        url: '',
-        detailType: 'access',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Commands',
-        value: 'repocommands',
-        view: 'repo',
-        url: '',
-        detailType: 'commands',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Branches',
-        value: 'repobranches',
-        view: 'repo',
-        url: '',
-        detailType: 'branches',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Tags',
-        value: 'repotags',
-        view: 'repo',
-        url: '',
-        detailType: 'tags',
-        parent: 'my-repo',
-      },
-      {
-        text: 'Dashboards',
-        value: 'repodashboards',
-        view: 'repo',
-        url: '',
-        detailType: 'dashboards',
-        parent: 'my-repo',
-      },
-    ];
-    sinon.stub(GerritNav, 'navigateToRelativeUrl');
-    sinon.spy(element, '_selectedIsCurrentPage');
-    sinon.spy(element, '_handleSubsectionChange');
-    await element.reload();
-    assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
-    assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
-    assert.equal(
-        element.shadowRoot.querySelector('#pageSelect').value,
-        'repoaccess'
-    );
-    assert.isTrue(element._selectedIsCurrentPage.calledOnce);
-    // Doesn't trigger navigation from the page select menu.
-    assert.isFalse(GerritNav.navigateToRelativeUrl.called);
-
-    // When explicitly changed, navigation is called
-    element.shadowRoot.querySelector('#pageSelect').value = 'repogeneral';
-    assert.isTrue(element._selectedIsCurrentPage.calledTwice);
-    assert.isTrue(GerritNav.navigateToRelativeUrl.calledOnce);
-  });
-
-  test('_selectedIsCurrentPage', () => {
-    element._repoName = 'my-repo';
-    element.params = {view: 'repo', repo: 'my-repo'};
-    const selected = {
-      view: 'repo',
-      detailType: undefined,
-      parent: 'my-repo',
-    };
-    assert.isTrue(element._selectedIsCurrentPage(selected));
-    selected.parent = 'my-second-repo';
-    assert.isFalse(element._selectedIsCurrentPage(selected));
-    selected.detailType = 'detailType';
-    assert.isFalse(element._selectedIsCurrentPage(selected));
-  });
-
-  suite('_computeSelectedClass', () => {
-    setup(() => {
-      stubRestApi('getAccountCapabilities').returns(
-          Promise.resolve(createAdminCapabilities()));
-      stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
-      return element.reload();
-    });
-
-    suite('repos', () => {
-      setup(() => {
-        stub('gr-repo-access', '_repoChanged').callsFake(() => {});
-      });
-
-      test('repo list', () => {
-        element.params = {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-repo-list',
-          openCreateModal: false,
-        };
-        flush();
-        const selected = element.shadowRoot
-            .querySelector('gr-page-nav .selected');
-        assert.isOk(selected);
-        assert.equal(selected.textContent.trim(), 'Repositories');
-      });
-
-      test('repo', () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          repoName: 'foo',
-        };
-        element._repoName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'foo');
-        });
-      });
-
-      test('repo access', () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.ACCESS,
-          repoName: 'foo',
-        };
-        element._repoName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Access');
-        });
-      });
-
-      test('repo dashboards', () => {
-        element.params = {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.DASHBOARDS,
-          repoName: 'foo',
-        };
-        element._repoName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Dashboards');
-        });
-      });
-    });
-
-    suite('groups', () => {
-      let getGroupConfigStub;
-      setup(() => {
-        stub('gr-group', 'loadGroup').callsFake(() => Promise.resolve({}));
-        stub('gr-group-members', '_loadGroupDetails').callsFake(() => {});
-
-        getGroupConfigStub = stubRestApi('getGroupConfig');
-        getGroupConfigStub.returns(Promise.resolve({
-          name: 'foo',
-          id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
-        }));
-        stubRestApi('getIsGroupOwner')
-            .returns(Promise.resolve(true));
-        return element.reload();
-      });
-
-      test('group list', () => {
-        element.params = {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          openCreateModal: false,
-        };
-        flush();
-        const selected = element.shadowRoot
-            .querySelector('gr-page-nav .selected');
-        assert.isOk(selected);
-        assert.equal(selected.textContent.trim(), 'Groups');
-      });
-
-      test('internal group', () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-        };
-        element._groupName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const subsectionItems = dom(element.root)
-              .querySelectorAll('.subsectionItem');
-          assert.equal(subsectionItems.length, 2);
-          assert.isTrue(element._groupIsInternal);
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'foo');
-        });
-      });
-
-      test('external group', () => {
-        getGroupConfigStub.returns(Promise.resolve({
-          name: 'foo',
-          id: 'external-id',
-        }));
-        element.params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-        };
-        element._groupName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const subsectionItems = dom(element.root)
-              .querySelectorAll('.subsectionItem');
-          assert.equal(subsectionItems.length, 0);
-          assert.isFalse(element._groupIsInternal);
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'foo');
-        });
-      });
-
-      test('group members', () => {
-        element.params = {
-          view: GerritNav.View.GROUP,
-          detail: GerritNav.GroupDetailView.MEMBERS,
-          groupId: 1234,
-        };
-        element._groupName = 'foo';
-        return element.reload().then(() => {
-          flush();
-          const selected = element.shadowRoot
-              .querySelector('gr-page-nav .selected');
-          assert.isOk(selected);
-          assert.equal(selected.textContent.trim(), 'Members');
-        });
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
new file mode 100644
index 0000000..ee4a179
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -0,0 +1,738 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-admin-view';
+import {AdminSubsectionLink, GrAdminView} from './gr-admin-view';
+import {stubBaseUrl, stubElement, stubRestApi} from '../../../test/test-utils';
+import {GerritView} from '../../../services/router/router-model';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrRepoList} from '../gr-repo-list/gr-repo-list';
+import {GroupId, GroupName, RepoName, Timestamp} from '../../../types/common';
+import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrGroup} from '../gr-group/gr-group';
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView} from '../../../models/views/admin';
+import {GroupDetailView} from '../../../models/views/group';
+import {RepoDetailView} from '../../../models/views/repo';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  PluginLoader,
+  pluginLoaderToken,
+} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
+function createAdminCapabilities() {
+  return {
+    createGroup: true,
+    createProject: true,
+    viewPlugins: true,
+  };
+}
+
+suite('gr-admin-view tests', () => {
+  let element: GrAdminView;
+  let pluginLoader: PluginLoader;
+
+  setup(async () => {
+    element = await fixture(html`<gr-admin-view></gr-admin-view>`);
+    stubRestApi('getProjectConfig').returns(Promise.resolve(undefined));
+    const pluginsLoaded = Promise.resolve();
+    pluginLoader = testResolver(pluginLoaderToken);
+    sinon.stub(pluginLoader, 'awaitPluginsLoaded').returns(pluginsLoaded);
+    await pluginsLoaded;
+    await element.updateComplete;
+  });
+
+  test('link URLs', () => {
+    assert.equal(
+      element.computeLinkURL({name: '', url: '/test', noBaseUrl: true}),
+      '//' + window.location.host + '/test'
+    );
+
+    stubBaseUrl('/foo');
+    assert.equal(
+      element.computeLinkURL({name: '', url: '/test', noBaseUrl: true}),
+      '//' + window.location.host + '/foo/test'
+    );
+    assert.equal(
+      element.computeLinkURL({name: '', url: '/test', noBaseUrl: false}),
+      '/test'
+    );
+    assert.equal(
+      element.computeLinkURL({
+        name: '',
+        url: '/test',
+        target: '_blank',
+        noBaseUrl: false,
+      }),
+      '/test'
+    );
+  });
+
+  test('current page gets selected and is displayed', async () => {
+    element.filteredLinks = [
+      {
+        name: 'Repositories',
+        url: '/admin/repos',
+        view: 'gr-repo-list' as GerritView,
+        noBaseUrl: false,
+      },
+    ];
+
+    element.view = GerritView.ADMIN;
+    element.adminViewState = {
+      view: GerritView.ADMIN,
+      adminView: AdminChildView.REPOS,
+    };
+
+    await element.updateComplete;
+    assert.equal(queryAll<HTMLLIElement>(element, '.selected').length, 1);
+    assert.ok(queryAndAssert<GrRepoList>(element, 'gr-repo-list'));
+    assert.isNotOk(query(element, 'gr-admin-create-repo'));
+  });
+
+  test('filteredLinks admin', async () => {
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        name: 'test-user',
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    await element.reload();
+    assert.equal(element.filteredLinks!.length, 3);
+
+    // Repos
+    assert.isNotOk(element.filteredLinks![0].subsection);
+
+    // Groups
+    assert.isNotOk(element.filteredLinks![0].subsection);
+
+    // Plugins
+    assert.isNotOk(element.filteredLinks![0].subsection);
+  });
+
+  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', 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', () => {
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    sinon.stub(pluginLoader.jsApiService, 'getAdminMenuLinks').returns([
+      {
+        capability: null,
+        text: 'internal link text',
+        url: '/internal/link/url',
+      },
+      {
+        capability: null,
+        text: 'external link text',
+        url: 'http://external/link/url',
+      },
+    ]);
+    return element.reload().then(() => {
+      assert.equal(element.filteredLinks!.length, 3);
+      assert.deepEqual(element.filteredLinks![1], {
+        capability: undefined,
+        url: '/internal/link/url',
+        name: 'internal link text',
+        noBaseUrl: true,
+        view: undefined,
+        viewableToAll: true,
+        target: null,
+      });
+      assert.deepEqual(element.filteredLinks![2], {
+        capability: undefined,
+        url: 'http://external/link/url',
+        name: 'external link text',
+        noBaseUrl: false,
+        view: undefined,
+        viewableToAll: true,
+        target: '_blank',
+      });
+    });
+  });
+
+  test('Repo shows up in nav', async () => {
+    element.view = GerritView.REPO;
+    element.repoName = 'Test Repo' as RepoName;
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        name: 'test-user',
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    await element.reload();
+    await element.updateComplete;
+    assert.equal(queryAll<HTMLLIElement>(element, '.sectionTitle').length, 3);
+    assert.equal(
+      queryAndAssert<HTMLSpanElement>(element, '.breadcrumbText').innerText,
+      'Test Repo'
+    );
+    assert.equal(
+      queryAndAssert<GrDropdownList>(element, '#pageSelect').items!.length,
+      7
+    );
+  });
+
+  test('Group shows up in nav', async () => {
+    element.groupId = 'a15262' as GroupId;
+    element.groupName = 'my-group' as GroupName;
+    element.groupIsInternal = true;
+    stubRestApi('getIsAdmin').returns(Promise.resolve(true));
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        name: 'test-user',
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    await element.reload();
+    await element.updateComplete;
+    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('Needs reload when repo changes', async () => {
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        _id: 1,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+
+    element.view = GerritView.REPO;
+    element.repoViewState = {repo: 'Repo 1' as RepoName, view: GerritView.REPO};
+    assert.isTrue(element.needsReload());
+    await element.reload();
+    await element.updateComplete;
+
+    element.repoViewState = {repo: 'Repo 2' as RepoName, view: GerritView.REPO};
+    assert.isTrue(element.needsReload());
+    await element.updateComplete;
+  });
+
+  test('Needs reload when group changes', async () => {
+    sinon.stub(element, 'computeGroupName');
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        _id: 1,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    element.view = GerritView.GROUP;
+    element.groupViewState = {groupId: '1' as GroupId, view: GerritView.GROUP};
+    assert.isTrue(element.needsReload());
+  });
+
+  test('Needs reload when changing from repo to group', async () => {
+    element.repoName = 'Test Repo' as RepoName;
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        name: 'test-user',
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    await element.reload();
+    await element.updateComplete;
+
+    sinon.stub(element, 'computeGroupName');
+    const groupId = '1' as GroupId;
+    element.view = GerritView.GROUP;
+    element.groupViewState = {groupId, view: GerritView.GROUP};
+    await element.updateComplete;
+
+    assert.isTrue(element.needsReload());
+    assert.equal(element.groupId, groupId);
+  });
+
+  test('Needs reload when group name changes', async () => {
+    const newName = 'newName' as GroupName;
+    sinon.stub(element, 'computeGroupName');
+    element.view = GerritView.GROUP;
+    element.groupViewState = {groupId: '1' as GroupId, view: GerritView.GROUP};
+    element.groupName = 'oldName' as GroupName;
+    assert.isTrue(element.needsReload());
+    await element.reload();
+    await element.updateComplete;
+
+    queryAndAssert<GrGroup>(element, 'gr-group').dispatchEvent(
+      new CustomEvent('name-changed', {
+        detail: {name: newName},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.equal(element.groupName, newName);
+  });
+
+  test('dropdown displays if there is a subsection', async () => {
+    element.view = GerritView.REPO;
+    assert.isNotOk(query(element, '.mainHeader'));
+    element.subsectionLinks = [
+      {
+        text: 'Home',
+        value: 'repo',
+        view: GerritView.REPO,
+        parent: 'my-repo' as RepoName,
+        detailType: undefined,
+      },
+    ];
+    await element.updateComplete;
+    assert.isOk(query(element, '.mainHeader'));
+    element.subsectionLinks = undefined;
+    await element.updateComplete;
+    assert.isNotOk(query(element, '.mainHeader'));
+  });
+
+  test('Dropdown only triggers navigation on explicit select', async () => {
+    element.repoName = 'my-repo' as RepoName;
+    element.view = GerritView.REPO;
+    element.repoViewState = {
+      repo: 'my-repo' as RepoName,
+      view: GerritView.REPO,
+      detail: RepoDetailView.ACCESS,
+    };
+    stubRestApi('getAccountCapabilities').returns(
+      Promise.resolve(createAdminCapabilities())
+    );
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        _id: 1,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+    await element.updateComplete;
+
+    const expectedFilteredLinks = [
+      {
+        name: 'Repositories',
+        noBaseUrl: true,
+        url: '/admin/repos',
+        view: 'gr-repo-list' as GerritView,
+        viewableToAll: true,
+        subsection: {
+          name: 'my-repo',
+          view: GerritView.REPO,
+          children: [
+            {
+              name: 'General',
+              view: GerritView.REPO,
+              url: '/admin/repos/my-repo,general',
+              detailType: RepoDetailView.GENERAL,
+            },
+            {
+              name: 'Access',
+              view: GerritView.REPO,
+              detailType: RepoDetailView.ACCESS,
+              url: '/admin/repos/my-repo,access',
+            },
+            {
+              name: 'Commands',
+              view: GerritView.REPO,
+              detailType: RepoDetailView.COMMANDS,
+              url: '/admin/repos/my-repo,commands',
+            },
+            {
+              name: 'Branches',
+              view: GerritView.REPO,
+              detailType: RepoDetailView.BRANCHES,
+              url: '/admin/repos/my-repo,branches',
+            },
+            {
+              name: 'Tags',
+              view: GerritView.REPO,
+              detailType: RepoDetailView.TAGS,
+              url: '/admin/repos/my-repo,tags',
+            },
+            {
+              name: 'Dashboards',
+              view: GerritView.REPO,
+              detailType: RepoDetailView.DASHBOARDS,
+              url: '/admin/repos/my-repo,dashboards',
+            },
+          ],
+        },
+      },
+      {
+        name: 'Groups',
+        section: 'Groups',
+        noBaseUrl: true,
+        url: '/admin/groups',
+        view: 'gr-admin-group-list' as GerritView,
+      },
+      {
+        name: 'Plugins',
+        capability: 'viewPlugins',
+        section: 'Plugins',
+        noBaseUrl: true,
+        url: '/admin/plugins',
+        view: 'gr-plugin-list' as GerritView,
+      },
+    ];
+    const expectedSubsectionLinks = [
+      {
+        text: 'Home',
+        value: 'repo',
+        view: GerritView.REPO,
+        url: undefined,
+        parent: 'my-repo' as RepoName,
+        detailType: undefined,
+      },
+      {
+        text: 'General',
+        value: 'repogeneral',
+        view: GerritView.REPO,
+        url: '/admin/repos/my-repo,general',
+        detailType: RepoDetailView.GENERAL,
+        parent: 'my-repo' as RepoName,
+      },
+      {
+        text: 'Access',
+        value: 'repoaccess',
+        view: GerritView.REPO,
+        url: '/admin/repos/my-repo,access',
+        detailType: RepoDetailView.ACCESS,
+        parent: 'my-repo' as RepoName,
+      },
+      {
+        text: 'Commands',
+        value: 'repocommands',
+        view: GerritView.REPO,
+        url: '/admin/repos/my-repo,commands',
+        detailType: RepoDetailView.COMMANDS,
+        parent: 'my-repo' as RepoName,
+      },
+      {
+        text: 'Branches',
+        value: 'repobranches',
+        view: GerritView.REPO,
+        url: '/admin/repos/my-repo,branches',
+        detailType: RepoDetailView.BRANCHES,
+        parent: 'my-repo' as RepoName,
+      },
+      {
+        text: 'Tags',
+        value: 'repotags',
+        view: GerritView.REPO,
+        url: '/admin/repos/my-repo,tags',
+        detailType: RepoDetailView.TAGS,
+        parent: 'my-repo' as RepoName,
+      },
+      {
+        text: 'Dashboards',
+        value: 'repodashboards',
+        view: GerritView.REPO,
+        url: '/admin/repos/my-repo,dashboards',
+        detailType: RepoDetailView.DASHBOARDS,
+        parent: 'my-repo' as RepoName,
+      },
+    ];
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+    const selectedIsCurrentPageSpy = sinon.spy(
+      element,
+      'selectedIsCurrentPage'
+    );
+    sinon.spy(element, 'handleSubsectionChange');
+    await element.reload();
+    await element.updateComplete;
+    assert.deepEqual(element.filteredLinks, expectedFilteredLinks);
+    assert.deepEqual(element.subsectionLinks, expectedSubsectionLinks);
+    assert.equal(
+      queryAndAssert<GrDropdownList>(element, '#pageSelect').value,
+      'repoaccess'
+    );
+    assert.equal(selectedIsCurrentPageSpy.callCount, 1);
+    // Doesn't trigger navigation from the page select menu.
+    assert.isFalse(setUrlStub.called);
+
+    // When explicitly changed, navigation is called
+    queryAndAssert<GrDropdownList>(element, '#pageSelect').value =
+      'repogeneral';
+    await queryAndAssert<GrDropdownList>(element, '#pageSelect').updateComplete;
+    assert.equal(selectedIsCurrentPageSpy.callCount, 2);
+    assert.isTrue(setUrlStub.calledOnce);
+  });
+
+  test('selectedIsCurrentPage', () => {
+    element.repoName = 'my-repo' as RepoName;
+    element.view = GerritView.REPO;
+    element.repoViewState = {
+      view: GerritView.REPO,
+      repo: 'my-repo' as RepoName,
+    };
+    const selected = {
+      view: GerritView.REPO,
+      parent: 'my-repo' as RepoName,
+      value: '',
+      text: '',
+    } as AdminSubsectionLink;
+    assert.isTrue(element.selectedIsCurrentPage(selected));
+    selected.parent = 'my-second-repo' as RepoName;
+    assert.isFalse(element.selectedIsCurrentPage(selected));
+    selected.detailType = RepoDetailView.GENERAL;
+    assert.isFalse(element.selectedIsCurrentPage(selected));
+  });
+
+  suite('computeSelectedClass', () => {
+    setup(async () => {
+      stubRestApi('getAccountCapabilities').returns(
+        Promise.resolve(createAdminCapabilities())
+      );
+      stubRestApi('getAccount').returns(
+        Promise.resolve({
+          _id: 1,
+          registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+        })
+      );
+      await element.reload();
+    });
+
+    test('render', async () => {
+      element.view = GerritView.ADMIN;
+      element.adminViewState = {
+        view: GerritView.ADMIN,
+        adminView: AdminChildView.REPOS,
+        openCreateModal: false,
+      };
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-page-nav class="navStyles">
+            <ul class="sectionContent">
+              <li class="sectionTitle selected">
+                <a
+                  class="title"
+                  href="//localhost:9876/admin/repos"
+                  rel="noopener"
+                >
+                  Repositories
+                </a>
+              </li>
+              <li class="sectionTitle">
+                <a
+                  class="title"
+                  href="//localhost:9876/admin/groups"
+                  rel="noopener"
+                >
+                  Groups
+                </a>
+              </li>
+              <li class="sectionTitle">
+                <a
+                  class="title"
+                  href="//localhost:9876/admin/plugins"
+                  rel="noopener"
+                >
+                  Plugins
+                </a>
+              </li>
+            </ul>
+          </gr-page-nav>
+          <div class="main table">
+            <gr-repo-list class="table"></gr-repo-list>
+          </div>
+        `
+      );
+    });
+
+    suite('repos', () => {
+      setup(() => {
+        stubElement('gr-repo-access', '_repoChanged').callsFake(() =>
+          Promise.resolve()
+        );
+      });
+
+      test('repo list', async () => {
+        element.view = GerritView.ADMIN;
+        element.adminViewState = {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.REPOS,
+          openCreateModal: false,
+        };
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'Repositories');
+      });
+
+      test('repo', async () => {
+        element.view = GerritView.REPO;
+        element.repoViewState = {
+          view: GerritView.REPO,
+          repo: 'foo' as RepoName,
+        };
+        element.repoName = 'foo' as RepoName;
+        await element.reload();
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'foo');
+      });
+
+      test('repo access', async () => {
+        element.view = GerritView.REPO;
+        element.repoViewState = {
+          view: GerritView.REPO,
+          detail: RepoDetailView.ACCESS,
+          repo: 'foo' as RepoName,
+        };
+        element.repoName = 'foo' as RepoName;
+        await element.reload();
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'Access');
+      });
+
+      test('repo dashboards', async () => {
+        element.view = GerritView.REPO;
+        element.repoViewState = {
+          view: GerritView.REPO,
+          detail: RepoDetailView.DASHBOARDS,
+          repo: 'foo' as RepoName,
+        };
+        element.repoName = 'foo' as RepoName;
+        await element.reload();
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'Dashboards');
+      });
+    });
+
+    suite('groups', () => {
+      let getGroupConfigStub: sinon.SinonStub;
+
+      setup(async () => {
+        stubElement('gr-group', 'loadGroup').callsFake(() => Promise.resolve());
+        stubElement('gr-group-members', 'loadGroupDetails').callsFake(() =>
+          Promise.resolve()
+        );
+
+        getGroupConfigStub = stubRestApi('getGroupConfig');
+        getGroupConfigStub.returns(
+          Promise.resolve({
+            name: 'foo',
+            id: 'c0f83e941ce90caea30e6ad88f0d4ea0e841a7a9',
+          })
+        );
+        stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
+        await element.reload();
+      });
+
+      test('group list', async () => {
+        element.view = GerritView.ADMIN;
+        element.adminViewState = {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.GROUPS,
+          openCreateModal: false,
+        };
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'Groups');
+      });
+
+      test('internal group', async () => {
+        element.view = GerritView.GROUP;
+        element.groupViewState = {
+          view: GerritView.GROUP,
+          groupId: '1234' as GroupId,
+        };
+        element.groupName = 'foo' as GroupName;
+        if (element.needsReload()) await element.reload();
+        await element.updateComplete;
+        const subsectionItems = queryAll<HTMLLIElement>(
+          element,
+          '.subsectionItem'
+        );
+        assert.equal(subsectionItems.length, 2);
+        assert.isTrue(element.groupIsInternal);
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'foo');
+      });
+
+      test('external group', async () => {
+        getGroupConfigStub.returns(
+          Promise.resolve({
+            name: 'foo',
+            id: 'external-id',
+          })
+        );
+        element.view = GerritView.GROUP;
+        element.groupViewState = {
+          view: GerritView.GROUP,
+          groupId: '1234' as GroupId,
+        };
+        element.groupName = 'foo' as GroupName;
+        if (element.needsReload()) await element.reload();
+        await element.updateComplete;
+        const subsectionItems = queryAll<HTMLLIElement>(
+          element,
+          '.subsectionItem'
+        );
+        assert.equal(subsectionItems.length, 0);
+        assert.isFalse(element.groupIsInternal);
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'foo');
+      });
+
+      test('group members', async () => {
+        element.view = GerritView.GROUP;
+        element.groupViewState = {
+          view: GerritView.GROUP,
+          detail: GroupDetailView.MEMBERS,
+          groupId: '1234' as GroupId,
+        };
+        element.groupName = 'foo' as GroupName;
+        if (element.needsReload()) await element.reload();
+        await element.updateComplete;
+        const selected = queryAndAssert(element, 'gr-page-nav .selected');
+        assert.isOk(selected);
+        assert.equal(selected.textContent!.trim(), 'Members');
+      });
+    });
+  });
+});
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 04d3198..42ec988 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
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dialog/gr-dialog';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
index e286883..d4b5f03 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
@@ -1,34 +1,43 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-confirm-delete-item-dialog';
 import {GrConfirmDeleteItemDialog} from './gr-confirm-delete-item-dialog';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-
-const basicFixture = fixtureFromElement('gr-confirm-delete-item-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-confirm-delete-item-dialog tests', () => {
   let element: GrConfirmDeleteItemDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(
+      html`<gr-confirm-delete-item-dialog></gr-confirm-delete-item-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog
+          confirm-label="Delete UNKNOWN ITEM TYPE"
+          confirm-on-enter=""
+          role="dialog"
+        >
+          <div class="header" slot="header">UNKNOWN ITEM TYPE Deletion</div>
+          <div class="main" slot="main">
+            <label for="branchInput">
+              Do you really want to delete the following UNKNOWN ITEM TYPE?
+            </label>
+            <div>UNKNOWN ITEM</div>
+          </div>
+        </gr-dialog>
+      `
+    );
   });
 
   test('_handleConfirmTap', () => {
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 15f6f4b..3254b5c 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
@@ -1,140 +1,220 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-create-change-dialog_html';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe} from '@polymer/decorators';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   RepoName,
   BranchName,
   ChangeId,
-  ConfigInfo,
   InheritedBooleanInfo,
 } from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
-import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {appContext} from '../../../services/app-context';
-import {Subject} from 'rxjs';
-import {
-  repoConfig$,
-  serverConfig$,
-} from '../../../services/config/config-model';
-import {takeUntil} from 'rxjs/operators';
-import {IronInputElement} from '@polymer/iron-input/iron-input';
+import {getAppContext} from '../../../services/app-context';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
 
-export interface GrCreateChangeDialog {
-  $: {
-    privateChangeCheckBox: HTMLInputElement;
-    branchInput: GrTypedAutocomplete<BranchName>;
-    tagNameInput: IronInputElement;
-    messageInput: IronAutogrowTextareaElement;
-  };
-}
-@customElement('gr-create-change-dialog')
-export class GrCreateChangeDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-change-dialog': GrCreateChangeDialog;
   }
+}
+
+@customElement('gr-create-change-dialog')
+export class GrCreateChangeDialog extends LitElement {
+  // private but used in test
+  @query('#privateChangeCheckBox') privateChangeCheckBox!: HTMLInputElement;
 
   @property({type: String})
   repoName?: RepoName;
 
-  @property({type: String})
-  branch = '' as BranchName;
+  // private but used in test
+  @state() branch = '' as BranchName;
 
-  @property({type: Object})
-  _repoConfig?: ConfigInfo;
+  // private but used in test
+  @state() subject = '';
 
-  @property({type: String})
-  subject = '';
-
-  @property({type: String})
-  topic?: string;
-
-  @property({type: Object})
-  _query?: (input: string) => Promise<{name: BranchName}[]>;
+  // private but used in test
+  @state() topic?: string;
 
   @property({type: String})
   baseChange?: ChangeId;
 
-  @property({type: String})
-  baseCommit?: string;
+  @state() private baseCommit?: string;
 
   @property({type: Object})
   privateByDefault?: InheritedBooleanInfo;
 
-  @property({type: Boolean, notify: true})
-  canCreate = false;
+  @state() private privateChangesEnabled = false;
 
-  @property({type: Boolean})
-  _privateChangesEnabled = false;
+  private readonly query: (input: string) => Promise<{name: BranchName}[]>;
 
-  restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  disconnected$ = new Subject();
+  private readonly configModel = resolve(this, configModelToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
 
   constructor() {
     super();
-    this._query = (input: string) => this._getRepoBranchesSuggestions(input);
+    this.query = (input: string) => this.getRepoBranchesSuggestions(input);
+
+    subscribe(
+      this,
+      () => this.configModel().serverConfig$,
+      config => {
+        this.privateChangesEnabled =
+          config?.change?.disable_private_changes ?? false;
+      }
+    );
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    if (!this.repoName) return;
-
-    repoConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
-      this.privateByDefault = config?.private_by_default;
-    });
-
-    serverConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
-      this._privateChangesEnabled =
-        config?.change?.disable_private_changes ?? false;
-    });
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        input:not([type='checkbox']),
+        gr-autocomplete,
+        iron-autogrow-textarea {
+          width: 100%;
+        }
+        .value {
+          width: 32em;
+        }
+        .hide {
+          display: none;
+        }
+        @media only screen and (max-width: 40em) {
+          .value {
+            width: 29em;
+          }
+        }
+      `,
+    ];
   }
 
-  override disconnectedCallback() {
-    this.disconnected$.next();
-    super.disconnectedCallback();
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <section class=${this.baseChange ? 'hide' : ''}>
+          <span class="title">Select branch for new change</span>
+          <span class="value">
+            <gr-autocomplete
+              id="branchInput"
+              .text=${this.branch}
+              .query=${this.query}
+              placeholder="Destination branch"
+              @text-changed=${(e: CustomEvent) => {
+                this.branch = e.detail.value;
+              }}
+            >
+            </gr-autocomplete>
+          </span>
+        </section>
+        <section class=${this.baseChange ? 'hide' : ''}>
+          <span class="title">Provide base commit sha1 for change</span>
+          <span class="value">
+            <iron-input
+              .bindValue=${this.baseCommit}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                this.baseCommit = e.detail.value;
+              }}
+            >
+              <input
+                id="baseCommitInput"
+                maxlength="40"
+                placeholder="(optional)"
+              />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <span class="title">Enter topic for new change</span>
+          <span class="value">
+            <iron-input
+              .bindValue=${this.topic}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                this.topic = e.detail.value;
+              }}
+            >
+              <input
+                id="tagNameInput"
+                maxlength="1024"
+                placeholder="(optional)"
+              />
+            </iron-input>
+          </span>
+        </section>
+        <section id="description">
+          <span class="title">Description</span>
+          <span class="value">
+            <iron-autogrow-textarea
+              id="messageInput"
+              class="message"
+              autocomplete="on"
+              rows="4"
+              maxRows="15"
+              .bindValue=${this.subject}
+              placeholder="Insert the description of the change."
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                this.subject = e.detail.value ?? '';
+              }}
+            >
+            </iron-autogrow-textarea>
+          </span>
+        </section>
+        <section class=${this.privateChangesEnabled ? 'hide' : ''}>
+          <label class="title" for="privateChangeCheckBox"
+            >Private change</label
+          >
+          <span class="value">
+            <input
+              type="checkbox"
+              id="privateChangeCheckBox"
+              ?checked=${this.formatPrivateByDefaultBoolean()}
+            />
+          </span>
+        </section>
+      </div>
+    `;
   }
 
-  _computeBranchClass(baseChange?: ChangeId) {
-    return baseChange ? 'hide' : '';
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('branch') || changedProperties.has('subject')) {
+      this.allowCreate();
+    }
   }
 
-  @observe('branch', 'subject')
-  _allowCreate(branch: BranchName, subject: string) {
-    this.canCreate = !!branch && !!subject;
+  private allowCreate() {
+    fireEvent(this, 'can-create-change');
   }
 
   handleCreateChange(): Promise<void> {
     if (!this.repoName || !this.branch || !this.subject) {
       return Promise.resolve();
     }
-    const isPrivate = this.$.privateChangeCheckBox.checked;
+    const isPrivate = this.privateChangeCheckBox.checked;
     const isWip = true;
     return this.restApiService
       .createChange(
@@ -147,15 +227,14 @@
         this.baseChange,
         this.baseCommit || undefined
       )
-      .then(changeCreated => {
-        if (!changeCreated) {
-          return;
-        }
-        GerritNav.navigateToChange(changeCreated);
+      .then(change => {
+        if (!change) return;
+        this.getNavigation().setUrl(createChangeUrl({change}));
       });
   }
 
-  _getRepoBranchesSuggestions(input: string) {
+  // private but used in test
+  getRepoBranchesSuggestions(input: string) {
     if (!this.repoName) {
       return Promise.reject(new Error('missing repo name'));
     }
@@ -163,7 +242,13 @@
       input = input.substring(REF_PREFIX.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.repoName, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.repoName,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
@@ -178,34 +263,19 @@
       });
   }
 
-  _formatBooleanString(config?: InheritedBooleanInfo) {
-    if (
-      config &&
-      config.configured_value === InheritedBooleanInfoConfiguredValue.TRUE
-    ) {
-      return true;
-    } else if (
-      config &&
-      config.configured_value === InheritedBooleanInfoConfiguredValue.FALSE
-    ) {
-      return false;
-    } else if (
-      config &&
-      config.configured_value === InheritedBooleanInfoConfiguredValue.INHERITED
-    ) {
-      return !!(config && config.inherited_value);
-    } else {
-      return false;
+  // private but used in test
+  formatPrivateByDefaultBoolean() {
+    const config = this.privateByDefault;
+    if (config === undefined) return false;
+    switch (config.configured_value) {
+      case InheritedBooleanInfoConfiguredValue.TRUE:
+        return true;
+      case InheritedBooleanInfoConfiguredValue.FALSE:
+        return false;
+      case InheritedBooleanInfoConfiguredValue.INHERIT:
+        return !!config.inherited_value;
+      default:
+        return false;
     }
   }
-
-  _computePrivateSectionClass(config: boolean) {
-    return config ? 'hide' : '';
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-create-change-dialog': GrCreateChangeDialog;
-  }
 }
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
deleted file mode 100644
index 47f3818..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_html.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    input:not([type='checkbox']),
-    gr-autocomplete,
-    iron-autogrow-textarea {
-      width: 100%;
-    }
-    .value {
-      width: 32em;
-    }
-    .hide {
-      display: none;
-    }
-    @media only screen and (max-width: 40em) {
-      .value {
-        width: 29em;
-      }
-    }
-  </style>
-  <div class="gr-form-styles">
-    <section class$="[[_computeBranchClass(baseChange)]]">
-      <span class="title">Select branch for new change</span>
-      <span class="value">
-        <gr-autocomplete
-          id="branchInput"
-          text="{{branch}}"
-          query="[[_query]]"
-          placeholder="Destination branch"
-        >
-        </gr-autocomplete>
-      </span>
-    </section>
-    <section class$="[[_computeBranchClass(baseChange)]]">
-      <span class="title">Provide base commit sha1 for change</span>
-      <span class="value">
-        <iron-input
-          maxlength="40"
-          placeholder="(optional)"
-          bind-value="{{baseCommit}}"
-        >
-          <input
-            is="iron-input"
-            id="baseCommitInput"
-            maxlength="40"
-            placeholder="(optional)"
-            bind-value="{{baseCommit}}"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <span class="title">Enter topic for new change</span>
-      <span class="value">
-        <iron-input
-          maxlength="1024"
-          placeholder="(optional)"
-          bind-value="{{topic}}"
-        >
-          <input
-            is="iron-input"
-            id="tagNameInput"
-            maxlength="1024"
-            placeholder="(optional)"
-            bind-value="{{topic}}"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section id="description">
-      <span class="title">Description</span>
-      <span class="value">
-        <iron-autogrow-textarea
-          id="messageInput"
-          class="message"
-          autocomplete="on"
-          rows="4"
-          max-rows="15"
-          bind-value="{{subject}}"
-          placeholder="Insert the description of the change."
-        >
-        </iron-autogrow-textarea>
-      </span>
-    </section>
-    <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
-      <label class="title" for="privateChangeCheckBox">Private change</label>
-      <span class="value">
-        <input
-          type="checkbox"
-          id="privateChangeCheckBox"
-          checked$="[[_formatBooleanString(privateByDefault)]]"
-        />
-      </span>
-    </section>
-  </div>
-`;
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 9ed5d81..87916b6 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
@@ -1,38 +1,22 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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,
-  TEST_CHANGE_ID,
-} from '../../../test/test-data-generators';
-import {stubRestApi} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-create-change-dialog');
+import {createChange} from '../../../test/test-data-generators';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-change-dialog tests', () => {
   let element: GrCreateChangeDialog;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getRepoBranches').callsFake((input: string) => {
       if (input.startsWith('test')) {
         return Promise.resolve([
@@ -46,16 +30,77 @@
         return Promise.resolve([]);
       }
     });
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-create-change-dialog></gr-create-change-dialog>`
+    );
     element.repoName = 'test-repo' as RepoName;
-    element._repoConfig = {
-      ...createConfig(),
-      private_by_default: {
-        value: false,
-        configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
-        inherited_value: false,
-      },
-    };
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <section>
+            <span class="title"> Select branch for new change </span>
+            <span class="value">
+              <gr-autocomplete
+                id="branchInput"
+                placeholder="Destination branch"
+              >
+              </gr-autocomplete>
+            </span>
+          </section>
+          <section>
+            <span class="title"> Provide base commit sha1 for change </span>
+            <span class="value">
+              <iron-input>
+                <input
+                  id="baseCommitInput"
+                  maxlength="40"
+                  placeholder="(optional)"
+                />
+              </iron-input>
+            </span>
+          </section>
+          <section>
+            <span class="title"> Enter topic for new change </span>
+            <span class="value">
+              <iron-input>
+                <input
+                  id="tagNameInput"
+                  maxlength="1024"
+                  placeholder="(optional)"
+                />
+              </iron-input>
+            </span>
+          </section>
+          <section id="description">
+            <span class="title"> Description </span>
+            <span class="value">
+              <iron-autogrow-textarea
+                aria-disabled="false"
+                autocomplete="on"
+                class="message"
+                id="messageInput"
+                maxrows="15"
+                placeholder="Insert the description of the change."
+                rows="4"
+              >
+              </iron-autogrow-textarea>
+            </span>
+          </section>
+          <section>
+            <label class="title" for="privateChangeCheckBox">
+              Private change
+            </label>
+            <span class="value">
+              <input id="privateChangeCheckBox" type="checkbox" />
+            </span>
+          </section>
+        </div>
+      `
+    );
   });
 
   test('new change created with default', async () => {
@@ -74,9 +119,13 @@
     element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
     element.subject = 'first change created with polygerrit ui';
-    assert.isFalse(element.$.privateChangeCheckBox.checked);
+    assert.isFalse(element.privateChangeCheckBox.checked);
 
-    element.$.messageInput.bindValue = configInputObj.subject;
+    const messageInput = queryAndAssert<IronAutogrowTextareaElement>(
+      element,
+      '#messageInput'
+    );
+    messageInput.bindValue = configInputObj.subject;
 
     await element.handleCreateChange();
     // Private change
@@ -92,8 +141,8 @@
       inherited_value: false,
       value: true,
     };
-    sinon.stub(element, '_formatBooleanString').callsFake(() => true);
-    flush();
+    sinon.stub(element, 'formatPrivateByDefaultBoolean').callsFake(() => true);
+    await element.updateComplete;
 
     const configInputObj = {
       branch: 'test-branch',
@@ -110,9 +159,13 @@
     element.branch = 'test-branch' as BranchName;
     element.topic = 'test-topic';
     element.subject = 'first change created with polygerrit ui';
-    assert.isTrue(element.$.privateChangeCheckBox.checked);
+    assert.isTrue(element.privateChangeCheckBox.checked);
 
-    element.$.messageInput.bindValue = configInputObj.subject;
+    const messageInput = queryAndAssert<IronAutogrowTextareaElement>(
+      element,
+      '#messageInput'
+    );
+    messageInput.bindValue = configInputObj.subject;
 
     await element.handleCreateChange();
     // Private change
@@ -122,24 +175,14 @@
     assert.isTrue(saveStub.called);
   });
 
-  test('_getRepoBranchesSuggestions empty', async () => {
-    const branches = await element._getRepoBranchesSuggestions('nonexistent');
+  test('getRepoBranchesSuggestions empty', async () => {
+    const branches = await element.getRepoBranchesSuggestions('nonexistent');
     assert.equal(branches.length, 0);
   });
 
-  test('_getRepoBranchesSuggestions non-empty', async () => {
-    const branches = await element._getRepoBranchesSuggestions('test-branch');
+  test('getRepoBranchesSuggestions non-empty', async () => {
+    const branches = await element.getRepoBranchesSuggestions('test-branch');
     assert.equal(branches.length, 1);
     assert.equal(branches[0].name, 'test-branch');
   });
-
-  test('_computeBranchClass', () => {
-    assert.equal(element._computeBranchClass(TEST_CHANGE_ID), 'hide');
-    assert.equal(element._computeBranchClass(undefined), '');
-  });
-
-  test('_computePrivateSectionClass', () => {
-    assert.equal(element._computePrivateSectionClass(true), 'hide');
-    assert.equal(element._computePrivateSectionClass(false), '');
-  });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
new file mode 100644
index 0000000..0cfbaa4
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
@@ -0,0 +1,170 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  RepoName,
+  BranchName,
+  ChangeInfo,
+  PatchSetNumber,
+} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
+import {LitElement, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {createEditUrl} from '../../../models/views/change';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {when} from 'lit/directives/when.js';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-file-edit-dialog': GrCreateFileEditDialog;
+  }
+}
+
+@customElement('gr-create-file-edit-dialog')
+export class GrCreateFileEditDialog extends LitElement {
+  @query('dialog')
+  modal?: HTMLDialogElement;
+
+  @query('gr-dialog')
+  grDialog?: GrDialog;
+
+  @property({type: String})
+  repo?: RepoName;
+
+  @property({type: String})
+  branch?: BranchName;
+
+  @property({type: String})
+  path?: string;
+
+  /**
+   * If this is set, then we show this message replacing all other content.
+   */
+  @state()
+  errorMessage?: string;
+
+  /**
+   * Triggers showing the dialog and kicks off creating a change.
+   */
+  @state()
+  active = false;
+
+  /**
+   * Indicates whether the REST API call for creating a change is in progress.
+   */
+  @state()
+  loading = false;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  static override get styles() {
+    return [modalStyles];
+  }
+
+  override render() {
+    if (!this.active) return nothing;
+    return html`
+      <dialog tabindex="-1">
+        <gr-dialog
+          disabled
+          ?loading=${this.loading}
+          .loadingLabel=${'Creating change ...'}
+          @cancel=${() => this.deactivate()}
+          .confirmLabel=${this.loading ? 'Please wait ...' : 'Failed'}
+          .cancelLabel=${'Cancel'}
+        >
+          <div slot="header">
+            <span class="main-heading">Create Change from URL</span>
+          </div>
+          <div slot="main">
+            ${when(
+              this.errorMessage,
+              () => this.renderError(),
+              () => this.renderCreating()
+            )}
+          </div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
+
+  async activate() {
+    this.active = true;
+    this.createChange();
+    await this.updateComplete;
+    if (this.active && this.modal?.open === false) this.modal.showModal();
+  }
+
+  deactivate() {
+    this.active = false;
+    this.modal?.close();
+  }
+
+  private renderCreating() {
+    return html`
+      <div>
+        <span>
+          Creating a change in repository <b>${this.repo}</b> on branch
+          <b>${this.branch}</b>.
+        </span>
+      </div>
+      <div>
+        <span>
+          The page will then redirect to the file editor for
+          <b>${this.path}</b>
+          in the newly created change.
+        </span>
+      </div>
+    `;
+  }
+
+  private renderError() {
+    return html`<div>Error: ${this.errorMessage}</div>`;
+  }
+
+  private createChange() {
+    if (!this.repo || !this.branch || !this.path) {
+      this.errorMessage = 'repo, branch and path must be set';
+      return;
+    }
+    if (this.loading || this.errorMessage) return;
+    this.loading = true;
+    this.restApiService
+      .createChange(this.repo, this.branch, `Edit ${this.path}`)
+      .then(change => {
+        if (!this.active) return;
+        if (change) {
+          this.loading = false;
+          this.redirectToFileEdit(change);
+          this.deactivate();
+        } else {
+          this.errorMessage = 'Creating the change failed.';
+        }
+      })
+      .catch(() => {
+        this.errorMessage = 'Creating the change failed.';
+      })
+      .finally(() => {
+        this.loading = false;
+      });
+  }
+
+  private redirectToFileEdit(change: ChangeInfo) {
+    assertIsDefined(this.path, 'path');
+    const url = createEditUrl({
+      changeNum: change._number,
+      repo: change.project,
+      patchNum: 1 as PatchSetNumber,
+      editView: {path: this.path},
+    });
+    this.getNavigation().setUrl(url);
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
new file mode 100644
index 0000000..7621fac
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
@@ -0,0 +1,99 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-create-file-edit-dialog';
+import {createChange} from '../../../test/test-data-generators';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrCreateFileEditDialog} from './gr-create-file-edit-dialog';
+import {stubRestApi, waitUntilCalled} from '../../../test/test-utils';
+import {BranchName, RepoName} from '../../../api/rest-api';
+import {SinonStub} from 'sinon';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+
+suite('gr-create-file-edit-dialog', () => {
+  let element: GrCreateFileEditDialog;
+  let createChangeStub: SinonStub;
+  let setUrlStub: SinonStub;
+
+  setup(async () => {
+    createChangeStub = stubRestApi('createChange').resolves(createChange());
+    setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+    element = await fixture(
+      html`<gr-create-file-edit-dialog></gr-create-file-edit-dialog>`
+    );
+    element.repo = 'test-repo' as RepoName;
+    element.branch = 'test-branch' as BranchName;
+    element.path = 'test-path';
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    element.activate();
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <dialog tabindex="-1">
+          <gr-dialog disabled loading>
+            <div slot="header">
+              <span class="main-heading"> Create Change from URL </span>
+            </div>
+            <div slot="main">
+              <div>
+                <span>
+                  Creating a change in repository
+                  <b> test-repo </b>
+                  on branch
+                  <b> test-branch </b>
+                  .
+                </span>
+              </div>
+              <div>
+                <span>
+                  The page will then redirect to the file editor for
+                  <b> test-path </b> in the newly created change.
+                </span>
+              </div>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `
+    );
+  });
+
+  test('render error', async () => {
+    element.activate();
+    element.errorMessage = 'Failed.';
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <dialog tabindex="-1">
+          <gr-dialog disabled loading>
+            <div slot="header">
+              <span class="main-heading"> Create Change from URL </span>
+            </div>
+            <div slot="main">
+              <div>Error: Failed.</div>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `
+    );
+  });
+
+  test('creates change', async () => {
+    element.activate();
+    await element.updateComplete;
+
+    assert.isTrue(createChangeStub.calledOnce);
+    await waitUntilCalled(setUrlStub, 'setUrl');
+    await element.updateComplete;
+    assert.shadowDom.equal(element, '');
+  });
+});
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 180e60a..8d5689c 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
@@ -1,77 +1,98 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-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 {GroupName} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
-
-@customElement('gr-create-group-dialog')
-export class GrCreateGroupDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Boolean, notify: true})
-  hasNewGroupName = false;
-
-  @property({type: String})
-  _name: GroupName | '' = '';
-
-  @property({type: Boolean})
-  _groupCreated = false;
-
-  private readonly restApiService = appContext.restApiService;
-
-  _computeGroupUrl(groupId: string) {
-    return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
-  }
-
-  @observe('_name')
-  _updateGroupName(name: string) {
-    this.hasNewGroupName = !!name;
-  }
-
-  override focus() {
-    this.shadowRoot?.querySelector('input')?.focus();
-  }
-
-  handleCreateGroup() {
-    const name = this._name as GroupName;
-    return this.restApiService.createGroup({name}).then(groupRegistered => {
-      if (groupRegistered.status !== 201) {
-        return;
-      }
-      this._groupCreated = true;
-      return this.restApiService.getGroupConfig(name).then(group => {
-        // TODO(TS): should group always defined ?
-        page.show(this._computeGroupUrl(String(group!.group_id!)));
-      });
-    });
-  }
-}
+import {GroupId, GroupName} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, query, property} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fireEvent} from '../../../utils/event-util';
+import {createGroupUrl} from '../../../models/views/group';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-create-group-dialog': GrCreateGroupDialog;
   }
 }
+
+@customElement('gr-create-group-dialog')
+export class GrCreateGroupDialog extends LitElement {
+  @query('input') private input!: HTMLInputElement;
+
+  @property({type: String})
+  name: GroupName | '' = '';
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        input {
+          width: 20em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <div id="form">
+          <section>
+            <span class="title">Group name</span>
+            <iron-input
+              .bindValue=${this.name}
+              @bind-value-changed=${this.handleGroupNameBindValueChanged}
+            >
+              <input />
+            </iron-input>
+          </section>
+        </div>
+      </div>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('name')) {
+      this.updateGroupName();
+    }
+  }
+
+  private updateGroupName() {
+    fireEvent(this, 'has-new-group-name');
+  }
+
+  override focus() {
+    this.input.focus();
+  }
+
+  handleCreateGroup() {
+    const name = this.name as GroupName;
+    return this.restApiService.createGroup({name}).then(groupRegistered => {
+      if (groupRegistered.status !== 201) return;
+      return this.restApiService.getGroupConfig(name).then(group => {
+        if (!group) return;
+        const groupId = String(group.group_id!) as GroupId;
+        // TODO: Use navigation service instead of `page.show()` directly.
+        page.show(createGroupUrl({groupId}));
+      });
+    });
+  }
+
+  private handleGroupNameBindValueChanged(e: BindValueChangeEvent) {
+    this.name = e.detail.value as GroupName;
+  }
+}
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
deleted file mode 100644
index daf8780..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_html.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    :host {
-      display: inline-block;
-    }
-    input {
-      width: 20em;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <div id="form">
-      <section>
-        <span class="title">Group name</span>
-        <iron-input bind-value="{{_name}}">
-          <input is="iron-input" bind-value="{{_name}}" />
-        </iron-input>
-      </section>
-    </div>
-  </div>
-`;
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
deleted file mode 100644
index 321f069..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-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');
-
-suite('gr-create-group-dialog tests', () => {
-  let element;
-
-  const GROUP_NAME = 'test-group';
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('name is updated correctly', async () => {
-    assert.isFalse(element.hasNewGroupName);
-
-    const inputEl = element.root.querySelector('iron-input');
-    inputEl.bindValue = GROUP_NAME;
-
-    await new Promise(resolve => setTimeout(resolve));
-    assert.isTrue(element.hasNewGroupName);
-    assert.deepEqual(element._name, GROUP_NAME);
-  });
-
-  test('test for redirecting to group on successful creation', async () => {
-    stubRestApi('createGroup').returns(Promise.resolve({status: 201}));
-    stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
-
-    const showStub = sinon.stub(page, 'show');
-    await element.handleCreateGroup();
-    assert.isTrue(showStub.calledWith('/admin/groups/551'));
-  });
-
-  test('test for unsuccessful group creation', async () => {
-    stubRestApi('createGroup').returns(Promise.resolve({status: 409}));
-    stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
-
-    const showStub = sinon.stub(page, 'show');
-    await element.handleCreateGroup();
-    assert.isFalse(showStub.called);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
new file mode 100644
index 0000000..2a0b539
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-create-group-dialog';
+import {GrCreateGroupDialog} from './gr-create-group-dialog';
+import {page} from '../../../utils/page-wrapper-utils';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {IronInputElement} from '@polymer/iron-input';
+import {GroupId} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-create-group-dialog tests', () => {
+  let element: GrCreateGroupDialog;
+
+  const GROUP_NAME = 'test-group';
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-create-group-dialog></gr-create-group-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div id="form">
+            <section>
+              <span class="title"> Group name </span>
+              <iron-input>
+                <input />
+              </iron-input>
+            </section>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('name is updated correctly', async () => {
+    const promise = mockPromise();
+    element.addEventListener('has-new-group-name', () => {
+      promise.resolve();
+    });
+
+    const inputEl = queryAndAssert<IronInputElement>(element, 'iron-input');
+    inputEl.bindValue = GROUP_NAME;
+    inputEl.dispatchEvent(new Event('input', {bubbles: true, composed: true}));
+
+    await promise;
+
+    assert.deepEqual(element.name, GROUP_NAME);
+  });
+
+  test('test for redirecting to group on successful creation', async () => {
+    stubRestApi('createGroup').returns(
+      Promise.resolve({status: 201} as Response)
+    );
+    stubRestApi('getGroupConfig').returns(
+      Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
+    );
+
+    const showStub = sinon.stub(page, 'show');
+    await element.handleCreateGroup();
+    assert.isTrue(showStub.calledWith('/admin/groups/551'));
+  });
+
+  test('test for unsuccessful group creation', async () => {
+    stubRestApi('createGroup').returns(
+      Promise.resolve({status: 409} as Response)
+    );
+    stubRestApi('getGroupConfig').returns(
+      Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
+    );
+
+    const showStub = sinon.stub(page, 'show');
+    await element.handleCreateGroup();
+    assert.isFalse(showStub.called);
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index 7fce8e5..558d571 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
@@ -1,106 +1,163 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-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 {appContext} from '../../../services/app-context';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+import {getAppContext} from '../../../services/app-context';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
+import {RepoDetailView} from '../../../models/views/repo';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-create-pointer-dialog': GrCreatePointerDialog;
+  }
+}
 
 @customElement('gr-create-pointer-dialog')
-export class GrCreatePointerDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrCreatePointerDialog extends LitElement {
   @property({type: String})
   detailType?: string;
 
   @property({type: String})
   repoName?: RepoName;
 
-  @property({type: Boolean, notify: true})
-  hasNewItemName = false;
-
   @property({type: String})
   itemDetail?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
-  @property({type: String})
-  _itemName?: BranchName;
+  /* private but used in test */
+  @state() itemName?: BranchName;
 
-  @property({type: String})
-  _itemRevision?: string;
+  /* private but used in test */
+  @state() itemRevision?: string;
 
-  @property({type: String})
-  _itemAnnotation?: string;
+  /* private but used in test */
+  @state() itemAnnotation?: string;
 
-  @observe('_itemName')
-  _updateItemName(name?: string) {
-    this.hasNewItemName = !!name;
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        input {
+          width: 20em;
+        }
+        /* Add css selector with #id to increase priority
+          (otherwise ".gr-form-styles section" rule wins) */
+        .hideItem,
+        #itemAnnotationSection.hideItem {
+          display: none;
+        }
+      `,
+    ];
   }
 
-  private readonly restApiService = appContext.restApiService;
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <div id="form">
+          <section id="itemNameSection">
+            <span class="title">${this.detailType} name</span>
+            <iron-input
+              .bindValue=${this.itemName}
+              @bind-value-changed=${this.handleItemNameBindValueChanged}
+            >
+              <input placeholder="${this.detailType} Name" />
+            </iron-input>
+          </section>
+          <section id="itemRevisionSection">
+            <span class="title">Initial Revision</span>
+            <iron-input
+              .bindValue=${this.itemRevision}
+              @bind-value-changed=${this.handleItemRevisionBindValueChanged}
+            >
+              <input placeholder="Revision (Branch or SHA-1)" />
+            </iron-input>
+          </section>
+          <section
+            id="itemAnnotationSection"
+            class=${this.itemDetail === RepoDetailView.BRANCHES
+              ? 'hideItem'
+              : ''}
+          >
+            <span class="title">Annotation</span>
+            <iron-input
+              .bindValue=${this.itemAnnotation}
+              @bind-value-changed=${this.handleItemAnnotationBindValueChanged}
+            >
+              <input placeholder="Annotation (Optional)" />
+            </iron-input>
+          </section>
+        </div>
+      </div>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('itemName')) {
+      this.updateItemName();
+    }
+  }
+
+  private updateItemName() {
+    fireEvent(this, 'update-item-name');
+  }
 
   handleCreateItem() {
     if (!this.repoName) {
       throw new Error('repoName name is not set');
     }
-    if (!this._itemName) {
+    if (!this.itemName) {
       throw new Error('itemName name is not set');
     }
-    const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
-    const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
+    const USE_HEAD = this.itemRevision ? this.itemRevision : 'HEAD';
     if (this.itemDetail === RepoDetailView.BRANCHES) {
       return this.restApiService
-        .createRepoBranch(this.repoName, this._itemName, {revision: USE_HEAD})
+        .createRepoBranch(this.repoName, this.itemName, {revision: USE_HEAD})
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
-            page.show(`${url},branches`);
+            fireAlert(this, 'Branch created successfully. Reloading...');
+            fireReload(this);
           }
         });
     } else if (this.itemDetail === RepoDetailView.TAGS) {
       return this.restApiService
-        .createRepoTag(this.repoName, this._itemName, {
+        .createRepoTag(this.repoName, this.itemName, {
           revision: USE_HEAD,
-          message: this._itemAnnotation || undefined,
+          message: this.itemAnnotation || undefined,
         })
         .then(itemRegistered => {
           if (itemRegistered.status === 201) {
-            page.show(`${url},tags`);
+            fireAlert(this, 'Tag created successfully. Reloading...');
+            fireReload(this);
           }
         });
     }
     throw new Error(`Invalid itemDetail: ${this.itemDetail}`);
   }
 
-  _computeHideItemClass(type?: RepoDetailView.BRANCHES | RepoDetailView.TAGS) {
-    return type === RepoDetailView.BRANCHES ? 'hideItem' : '';
+  private handleItemNameBindValueChanged(e: BindValueChangeEvent) {
+    this.itemName = e.detail.value as BranchName;
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-create-pointer-dialog': GrCreatePointerDialog;
+  private handleItemRevisionBindValueChanged(e: BindValueChangeEvent) {
+    this.itemRevision = e.detail.value;
+  }
+
+  private handleItemAnnotationBindValueChanged(e: BindValueChangeEvent) {
+    this.itemAnnotation = e.detail.value;
   }
 }
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
deleted file mode 100644
index 0e2b157..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_html.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    :host {
-      display: inline-block;
-    }
-    input {
-      width: 20em;
-    }
-    /* Add css selector with #id to increase priority
-      (otherwise ".gr-form-styles section" rule wins) */
-    .hideItem,
-    #itemAnnotationSection.hideItem {
-      display: none;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <div id="form">
-      <section id="itemNameSection">
-        <span class="title">[[detailType]] name</span>
-        <iron-input bind-value="{{_itemName}}">
-          <input placeholder="[[detailType]] Name" />
-        </iron-input>
-      </section>
-      <section id="itemRevisionSection">
-        <span class="title">Initial Revision</span>
-        <iron-input bind-value="{{_itemRevision}}">
-          <input placeholder="Revision (Branch or SHA-1)" />
-        </iron-input>
-      </section>
-      <section
-        id="itemAnnotationSection"
-        class$="[[_computeHideItemClass(itemDetail)]]"
-      >
-        <span class="title">Annotation</span>
-        <iron-input bind-value="{{_itemAnnotation}}">
-          <input placeholder="Annotation (Optional)" />
-        </iron-input>
-      </section>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
index ea0919c..9e455d1 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
@@ -1,29 +1,20 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-pointer-dialog';
 import {GrCreatePointerDialog} from './gr-create-pointer-dialog';
-import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {BranchName} from '../../../types/common';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 import {IronInputElement} from '@polymer/iron-input';
-
-const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
+import {RepoDetailView} from '../../../models/views/repo';
 
 suite('gr-create-pointer-dialog tests', () => {
   let element: GrCreatePointerDialog;
@@ -31,73 +22,109 @@
   const ironInput = (element: Element) =>
     queryAndAssert<IronInputElement>(element, 'iron-input');
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-create-pointer-dialog></gr-create-pointer-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div id="form">
+            <section id="itemNameSection">
+              <span class="title"> name </span>
+              <iron-input>
+                <input placeholder=" Name" />
+              </iron-input>
+            </section>
+            <section id="itemRevisionSection">
+              <span class="title"> Initial Revision </span>
+              <iron-input>
+                <input placeholder="Revision (Branch or SHA-1)" />
+              </iron-input>
+            </section>
+            <section id="itemAnnotationSection">
+              <span class="title"> Annotation </span>
+              <iron-input>
+                <input placeholder="Annotation (Optional)" />
+              </iron-input>
+            </section>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('branch created', async () => {
     stubRestApi('createRepoBranch').returns(Promise.resolve(new Response()));
 
-    assert.isFalse(element.hasNewItemName);
+    const promise = mockPromise();
+    element.addEventListener('update-item-name', () => {
+      promise.resolve();
+    });
 
-    element._itemName = 'test-branch' as BranchName;
+    element.itemName = 'test-branch' as BranchName;
     element.itemDetail = 'branches' as RepoDetailView.BRANCHES;
 
-    ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+    ironInput(queryAndAssert(element, '#itemNameSection')).bindValue =
+      'test-branch2';
+    ironInput(queryAndAssert(element, '#itemRevisionSection')).bindValue =
+      'HEAD';
 
-    await flush();
+    await promise;
 
-    assert.isTrue(element.hasNewItemName);
-    assert.equal(element._itemName, 'test-branch2' as BranchName);
-    assert.equal(element._itemRevision, 'HEAD');
+    assert.equal(element.itemName, 'test-branch2' as BranchName);
+    assert.equal(element.itemRevision, 'HEAD');
   });
 
   test('tag created', async () => {
     stubRestApi('createRepoTag').returns(Promise.resolve(new Response()));
 
-    assert.isFalse(element.hasNewItemName);
+    const promise = mockPromise();
+    element.addEventListener('update-item-name', () => {
+      promise.resolve();
+    });
 
-    element._itemName = 'test-tag' as BranchName;
+    element.itemName = 'test-tag' as BranchName;
     element.itemDetail = 'tags' as RepoDetailView.TAGS;
 
-    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+    ironInput(queryAndAssert(element, '#itemNameSection')).bindValue =
+      'test-tag2';
+    ironInput(queryAndAssert(element, '#itemRevisionSection')).bindValue =
+      'HEAD';
 
-    await flush();
-    assert.isTrue(element.hasNewItemName);
-    assert.equal(element._itemName, 'test-tag2' as BranchName);
-    assert.equal(element._itemRevision, 'HEAD');
+    await promise;
+
+    assert.equal(element.itemName, 'test-tag2' as BranchName);
+    assert.equal(element.itemRevision, 'HEAD');
   });
 
   test('tag created with annotations', async () => {
     stubRestApi('createRepoTag').returns(Promise.resolve(new Response()));
 
-    assert.isFalse(element.hasNewItemName);
+    const promise = mockPromise();
+    element.addEventListener('update-item-name', () => {
+      promise.resolve();
+    });
 
-    element._itemName = 'test-tag' as BranchName;
-    element._itemAnnotation = 'test-message';
+    element.itemName = 'test-tag' as BranchName;
+    element.itemAnnotation = 'test-message';
     element.itemDetail = 'tags' as RepoDetailView.TAGS;
 
-    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-    ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+    ironInput(queryAndAssert(element, '#itemNameSection')).bindValue =
+      'test-tag2';
+    ironInput(queryAndAssert(element, '#itemAnnotationSection')).bindValue =
+      'test-message2';
+    ironInput(queryAndAssert(element, '#itemRevisionSection')).bindValue =
+      'HEAD';
 
-    await flush();
-    assert.isTrue(element.hasNewItemName);
-    assert.equal(element._itemName, 'test-tag2' as BranchName);
-    assert.equal(element._itemAnnotation, 'test-message2');
-    assert.equal(element._itemRevision, 'HEAD');
-  });
+    await promise;
 
-  test('_computeHideItemClass returns hideItem if type is branches', () => {
-    assert.equal(
-      element._computeHideItemClass(RepoDetailView.BRANCHES),
-      'hideItem'
-    );
-  });
-
-  test('_computeHideItemClass returns strings if not branches', () => {
-    assert.equal(element._computeHideItemClass(RepoDetailView.TAGS), '');
+    assert.equal(element.itemName, 'test-tag2' as BranchName);
+    assert.equal(element.itemAnnotation, 'test-message2');
+    assert.equal(element.itemRevision, 'HEAD');
   });
 });
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 934e3fb..bb59ccc 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
@@ -1,24 +1,12 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
 import {
   BranchName,
@@ -27,19 +15,17 @@
   RepoName,
 } from '../../../types/common';
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {convertToString} from '../../../utils/string-util';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, query, property, state} from 'lit/decorators';
+import {customElement, query, property, state} from 'lit/decorators.js';
 import {fireEvent} from '../../../utils/event-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {createRepoUrl} from '../../../models/views/repo';
 
 declare global {
-  interface HTMLElementEventMap {
-    'text-changed': CustomEvent;
-    'value-changed': CustomEvent;
-  }
   interface HTMLElementTagNameMap {
     'gr-create-repo-dialog': GrCreateRepoDialog;
   }
@@ -83,7 +69,7 @@
 
   private readonly queryGroups: AutocompleteQuery;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
@@ -138,7 +124,7 @@
                 id="rightsInheritFromInput"
                 .text=${convertToString(this.repoConfig.parent)}
                 .query=${this.query}
-                .placeholder="Optional, defaults to 'All-Projects'"
+                .placeholder=${"Optional, defaults to 'All-Projects'"}
                 @text-changed=${this.handleRightsTextChanged}
               >
               </gr-autocomplete>
@@ -197,10 +183,6 @@
     `;
   }
 
-  _computeRepoUrl(repoName: string) {
-    return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
-  }
-
   override focus() {
     this.input?.focus();
   }
@@ -213,23 +195,33 @@
     );
     if (repoRegistered.status === 201) {
       this.repoCreated = true;
-      page.show(this._computeRepoUrl(this.repoConfig.name));
+      // TODO: Use navigation service instead of `page.show()` directly.
+      page.show(createRepoUrl({repo: this.repoConfig.name}));
     }
     return repoRegistered;
   }
 
   private async getRepoSuggestions(input: string) {
-    const response = await this.restApiService.getSuggestedProjects(input);
+    const response = await this.restApiService.getSuggestedRepos(
+      input,
+      /* n=*/ undefined,
+      throwingErrorCallback
+    );
 
     const repos = [];
-    for (const [name, project] of Object.entries(response ?? {})) {
-      repos.push({name, value: project.id});
+    for (const [name, repo] of Object.entries(response ?? {})) {
+      repos.push({name, value: repo.id});
     }
     return repos;
   }
 
   private async getGroupSuggestions(input: string) {
-    const response = await this.restApiService.getSuggestedGroups(input);
+    const response = await this.restApiService.getSuggestedGroups(
+      input,
+      /* project=*/ undefined,
+      /* n=*/ undefined,
+      throwingErrorCallback
+    );
 
     const groups = [];
     for (const [name, group] of Object.entries(response ?? {})) {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
index d3e2171..63b8c06 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.ts
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-repo-dialog';
 import {GrCreateRepoDialog} from './gr-create-repo-dialog';
 import {
@@ -26,15 +14,75 @@
 import {BranchName, GroupId, RepoName} from '../../../types/common';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GrSelect} from '../../shared/gr-select/gr-select';
-
-const basicFixture = fixtureFromElement('gr-create-repo-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-repo-dialog tests', () => {
   let element: GrCreateRepoDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(
+      html`<gr-create-repo-dialog></gr-create-repo-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div id="form">
+            <section>
+              <span class="title"> Repository name </span>
+              <iron-input>
+                <input autocomplete="on" id="repoNameInput" />
+              </iron-input>
+            </section>
+            <section>
+              <span class="title"> Default Branch </span>
+              <iron-input>
+                <input autocomplete="off" id="defaultBranchNameInput" />
+              </iron-input>
+            </section>
+            <section>
+              <span class="title"> Rights inherit from </span>
+              <span class="value">
+                <gr-autocomplete id="rightsInheritFromInput"> </gr-autocomplete>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Owner </span>
+              <span class="value">
+                <gr-autocomplete id="ownerInput"> </gr-autocomplete>
+              </span>
+            </section>
+            <section>
+              <span class="title"> Create initial empty commit </span>
+              <span class="value">
+                <gr-select id="initialCommit">
+                  <select>
+                    <option value="false">False</option>
+                    <option value="true">True</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <span class="title">
+                Only serve as parent for other repositories
+              </span>
+              <span class="value">
+                <gr-select id="parentRepo">
+                  <select>
+                    <option value="false">False</option>
+                    <option value="true">True</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('default values are populated', () => {
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 6605350..e0c0d30 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
@@ -1,27 +1,9 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
-import '../../shared/gr-account-link/gr-account-link';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-group-audit-log_html';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
+import '../../shared/gr-account-label/gr-account-label';
 import {
   GroupInfo,
   AccountInfo,
@@ -31,58 +13,140 @@
   isGroupAuditGroupEventInfo,
 } from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {createGroupUrl} from '../../../models/views/group';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-group-audit-log': GrGroupAuditLog;
+  }
+}
 
 @customElement('gr-group-audit-log')
-export class GrGroupAuditLog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrGroupAuditLog extends LitElement {
   @property({type: String})
   groupId?: EncodedGroupId;
 
-  @property({type: Array})
-  _auditLog?: GroupAuditEventInfo[];
+  @state() private auditLog?: GroupAuditEventInfo[];
 
-  @property({type: Boolean})
-  _loading = true;
+  @state() private loading = true;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Audit Log');
   }
 
-  override ready() {
-    super.ready();
-    this._getAuditLogs();
+  static override get styles() {
+    return [
+      sharedStyles,
+      tableStyles,
+      css`
+        /* GenericList style centers the last column, but we don't want that here. */
+        .genericList tr th:last-of-type,
+        .genericList tr td:last-of-type {
+          text-align: left;
+        }
+      `,
+    ];
   }
 
-  _getAuditLogs() {
-    if (!this.groupId) {
-      return '';
+  override render() {
+    return html`
+      <table id="list" class="genericList">
+        <tbody>
+          <tr class="headerRow">
+            <th class="date topHeader">Date</th>
+            <th class="type topHeader">Type</th>
+            <th class="member topHeader">Member</th>
+            <th class="by-user topHeader">By User</th>
+          </tr>
+          ${this.renderLoading()}
+        </tbody>
+        ${this.renderAuditLogTable()}
+      </table>
+    `;
+  }
+
+  private renderLoading() {
+    if (!this.loading) return;
+
+    return html`
+      <tr id="loading" class="loadingMsg loading">
+        <td>Loading...</td>
+      </tr>
+    `;
+  }
+
+  private renderAuditLogTable() {
+    if (this.loading) return;
+
+    return html`
+      <tbody>
+        ${this.auditLog?.map(audit => this.renderAuditLog(audit))}
+      </tbody>
+    `;
+  }
+
+  private renderAuditLog(audit: GroupAuditEventInfo) {
+    return html`
+      <tr class="table">
+        <td class="date">
+          <gr-date-formatter withTooltip .dateStr=${audit.date}>
+          </gr-date-formatter>
+        </td>
+        <td class="type">${this.itemType(audit.type)}</td>
+        <td class="member">
+          ${this.isGroupEvent(audit)
+            ? html`<a href=${this.computeGroupUrl(audit.member)}
+                >${this.getNameForGroup(audit.member)}</a
+              >`
+            : html`<gr-account-label
+                  .account=${audit.member}
+                  clickable
+                ></gr-account-label
+                >${this.getIdForUser(audit.member)}`}
+        </td>
+        <td class="by-user">
+          <gr-account-label clickable .account=${audit.user}></gr-account-label>
+          ${this.getIdForUser(audit.user)}
+        </td>
+      </tr>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('groupId')) {
+      this.getAuditLogs();
     }
+  }
+
+  // private but used in test
+  getAuditLogs() {
+    if (!this.groupId) return;
 
     const errFn: ErrorCallback = response => {
       firePageError(response);
     };
 
+    this.loading = true;
     return this.restApiService
       .getGroupAuditLog(this.groupId, errFn)
       .then(auditLog => {
-        if (!auditLog) {
-          this._auditLog = [];
-          return;
-        }
-        this._auditLog = auditLog;
-        this._loading = false;
+        this.auditLog = auditLog ?? [];
+      })
+      .finally(() => {
+        this.loading = false;
       });
   }
 
-  itemType(type: string) {
+  private itemType(type: string) {
     let item;
     switch (type) {
       case 'ADD_GROUP':
@@ -99,23 +163,23 @@
     return item;
   }
 
-  _isGroupEvent(event: GroupAuditEventInfo): event is GroupAuditGroupEventInfo {
+  // private but used in test
+  isGroupEvent(event: GroupAuditEventInfo): event is GroupAuditGroupEventInfo {
     return isGroupAuditGroupEventInfo(event);
   }
 
-  _computeGroupUrl(group: GroupInfo) {
-    if (group && group.url && group.id) {
-      return GerritNav.getUrlForGroup(group.id);
-    }
-
-    return '';
+  private computeGroupUrl(group?: GroupInfo) {
+    if (!group?.id) return '';
+    return createGroupUrl({groupId: group.id});
   }
 
-  _getIdForUser(account: AccountInfo) {
+  // private but used in test
+  getIdForUser(account: AccountInfo) {
     return account._account_id ? ` (${account._account_id})` : '';
   }
 
-  _getNameForGroup(group: GroupInfo) {
+  // private but used in test
+  getNameForGroup(group: GroupInfo) {
     if (group && group.name) {
       return group.name;
     } else if (group && group.id) {
@@ -125,14 +189,4 @@
 
     return '';
   }
-
-  computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-group-audit-log': GrGroupAuditLog;
-  }
 }
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
deleted file mode 100644
index 828aa55..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* GenericList style centers the last column, but we don't want that here. */
-    .genericList tr th:last-of-type,
-    .genericList tr td:last-of-type {
-      text-align: left;
-    }
-  </style>
-  <table id="list" class="genericList">
-    <tbody>
-      <tr class="headerRow">
-        <th class="date topHeader">Date</th>
-        <th class="type topHeader">Type</th>
-        <th class="member topHeader">Member</th>
-        <th class="by-user topHeader">By User</th>
-      </tr>
-      <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-        <td>Loading...</td>
-      </tr>
-    </tbody>
-    <tbody class$="[[computeLoadingClass(_loading)]]">
-      <template is="dom-repeat" items="[[_auditLog]]">
-        <tr class="table">
-          <td class="date">
-            <gr-date-formatter withTooltip date-str="[[item.date]]">
-            </gr-date-formatter>
-          </td>
-          <td class="type">[[itemType(item.type)]]</td>
-          <td class="member">
-            <template is="dom-if" if="[[_isGroupEvent(item)]]">
-              <a href$="[[_computeGroupUrl(item.member)]]">
-                [[_getNameForGroup(item.member)]]
-              </a>
-            </template>
-            <template is="dom-if" if="[[!_isGroupEvent(item)]]">
-              <gr-account-link account="[[item.member]]"></gr-account-link>
-              [[_getIdForUser(item.member)]]
-            </template>
-          </td>
-          <td class="by-user">
-            <gr-account-link account="[[item.user]]"></gr-account-link>
-            [[_getIdForUser(item.user)]]
-          </td>
-        </tr>
-      </template>
-    </tbody>
-  </table>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
index dc09390..828a3c6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
@@ -1,81 +1,89 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-group-audit-log.js';
+import '../../../test/common-test-setup';
+import './gr-group-audit-log';
 import {
   addListenerForTest,
   mockPromise,
   stubRestApi,
-} from '../../../test/test-utils.js';
-import {GrGroupAuditLog} from './gr-group-audit-log.js';
+} from '../../../test/test-utils';
+import {GrGroupAuditLog} from './gr-group-audit-log';
 import {
   EncodedGroupId,
   GroupAuditEventType,
   GroupInfo,
   GroupName,
-} from '../../../types/common.js';
+} from '../../../types/common';
 import {
   createAccountWithId,
   createGroupAuditEventInfo,
   createGroupInfo,
-} from '../../../test/test-data-generators.js';
-import {PageErrorEvent} from '../../../types/events.js';
-
-const basicFixture = fixtureFromElement('gr-group-audit-log');
+} from '../../../test/test-data-generators';
+import {PageErrorEvent} from '../../../types/events';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-group-audit-log tests', () => {
   let element: GrGroupAuditLog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-group-audit-log></gr-group-audit-log>`);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <table class="genericList" id="list">
+          <tbody>
+            <tr class="headerRow">
+              <th class="date topHeader">Date</th>
+              <th class="topHeader type">Type</th>
+              <th class="member topHeader">Member</th>
+              <th class="by-user topHeader">By User</th>
+            </tr>
+            <tr class="loading loadingMsg" id="loading">
+              <td>Loading...</td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
   });
 
   suite('members', () => {
-    test('test _getNameForGroup', () => {
+    test('test getNameForGroup', () => {
       let member: GroupInfo = {
         ...createGroupInfo(),
         name: 'test-name' as GroupName,
       };
-      assert.equal(element._getNameForGroup(member), 'test-name');
+      assert.equal(element.getNameForGroup(member), 'test-name');
 
       member = createGroupInfo('test-id');
-      assert.equal(element._getNameForGroup(member), 'test-id');
+      assert.equal(element.getNameForGroup(member), 'test-id');
     });
 
-    test('test _isGroupEvent', () => {
+    test('test isGroupEvent', () => {
       assert.isTrue(
-        element._isGroupEvent(
+        element.isGroupEvent(
           createGroupAuditEventInfo(GroupAuditEventType.ADD_GROUP)
         )
       );
       assert.isTrue(
-        element._isGroupEvent(
+        element.isGroupEvent(
           createGroupAuditEventInfo(GroupAuditEventType.REMOVE_GROUP)
         )
       );
 
       assert.isFalse(
-        element._isGroupEvent(
+        element.isGroupEvent(
           createGroupAuditEventInfo(GroupAuditEventType.ADD_USER)
         )
       );
       assert.isFalse(
-        element._isGroupEvent(
+        element.isGroupEvent(
           createGroupAuditEventInfo(GroupAuditEventType.REMOVE_USER)
         )
       );
@@ -83,12 +91,12 @@
   });
 
   suite('users', () => {
-    test('test _getIdForUser', () => {
+    test('test getIdForUser', () => {
       const user = {
         ...createAccountWithId(12),
         username: 'test-user',
       };
-      assert.equal(element._getIdForUser(user), ' (12)');
+      assert.equal(element.getIdForUser(user), ' (12)');
     });
 
     test('test _account_id not present', () => {
@@ -97,14 +105,14 @@
           username: 'test-user',
         },
       };
-      assert.equal(element._getIdForUser(account.user), '');
+      assert.equal(element.getIdForUser(account.user), '');
     });
   });
 
   suite('404', () => {
     test('fires page-error', async () => {
       element.groupId = '1' as EncodedGroupId;
-      await flush();
+      await element.updateComplete;
 
       const response = {...new Response(), status: 404};
       stubRestApi('getGroupAuditLog').callsFake((_group, errFn) => {
@@ -118,7 +126,7 @@
         pageErrorCalled.resolve();
       });
 
-      element._getAuditLogs();
+      element.getAuditLogs();
       await pageErrorCalled;
     });
   });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index c38f8be..af9977b 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
@@ -1,57 +1,49 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
-import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
-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 {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
   GroupId,
   AccountId,
   AccountInfo,
   GroupInfo,
   GroupName,
+  ServerInfo,
 } from '../../../types/common';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {PolymerDomRepeatEvent} from '../../../types/types';
 import {
   fireAlert,
   firePageError,
   fireTitleChange,
 } from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {assertNever} from '../../../utils/common-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {getAccountSuggestions} from '../../../utils/account-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
-const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
   'Group may not exist, or you may not have ' + 'permission to add it';
 
@@ -62,84 +54,276 @@
   INCLUDED_GROUP = 'includedGroup',
 }
 
-export interface GrGroupMembers {
-  $: {
-    overlay: GrOverlay;
-  };
-}
-@customElement('gr-group-members')
-export class GrGroupMembers extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-group-members': GrGroupMembers;
   }
+}
 
-  @property({type: Number})
+@customElement('gr-group-members')
+export class GrGroupMembers extends LitElement {
+  @query('#modal') protected modal!: HTMLDialogElement;
+
+  @property({type: String})
   groupId?: GroupId;
 
-  @property({type: Number})
-  _groupMemberSearchId?: number;
+  @state() protected groupMemberSearchId?: number;
 
-  @property({type: String})
-  _groupMemberSearchName?: string;
+  @state() protected groupMemberSearchName?: string;
 
-  @property({type: String})
-  _includedGroupSearchId?: string;
+  @state() protected includedGroupSearchId?: string;
 
-  @property({type: String})
-  _includedGroupSearchName?: string;
+  @state() protected includedGroupSearchName?: string;
 
-  @property({type: Boolean})
-  _loading = true;
+  @state() protected loading = true;
 
-  @property({type: String})
-  _groupName?: GroupName;
+  /* private but used in test */
+  @state() groupName?: GroupName;
 
-  @property({type: Object})
-  _groupMembers?: AccountInfo[];
+  @state() protected groupMembers?: AccountInfo[];
 
-  @property({type: Object})
-  _includedGroups?: GroupInfo[];
+  /* private but used in test */
+  @state() includedGroups?: GroupInfo[];
 
-  @property({type: String})
-  _itemName?: string;
+  /* private but used in test */
+  @state() itemName?: string;
 
-  @property({type: String})
-  _itemType?: ItemType;
+  @state() protected itemType?: ItemType;
 
-  @property({type: Object})
-  _queryMembers: AutocompleteQuery;
+  @state() protected queryMembers?: AutocompleteQuery;
 
-  @property({type: Object})
-  _queryIncludedGroup: AutocompleteQuery;
+  @state() protected queryIncludedGroup?: AutocompleteQuery;
 
-  @property({type: Boolean})
-  _groupOwner = false;
+  /* private but used in test */
+  @state() groupOwner = false;
 
-  @property({type: Boolean})
-  _isAdmin = false;
+  @state() protected isAdmin = false;
 
-  _itemId?: AccountId | GroupId;
+  /* private but used in test */
+  @state() itemId?: AccountId | GroupId;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private serverConfig?: ServerInfo;
 
   constructor() {
     super();
-    this._queryMembers = input => this._getAccountSuggestions(input);
-    this._queryIncludedGroup = input => this._getGroupSuggestions(input);
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+    this.queryMembers = input =>
+      getAccountSuggestions(input, this.restApiService, this.serverConfig);
+    this.queryIncludedGroup = input => this.getGroupSuggestions(input);
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadGroupDetails();
+    this.loadGroupDetails();
 
     fireTitleChange(this, 'Members');
   }
 
-  _loadGroupDetails() {
-    if (!this.groupId) {
-      return;
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      sharedStyles,
+      subpageStyles,
+      tableStyles,
+      modalStyles,
+      css`
+        .input {
+          width: 15em;
+        }
+        gr-autocomplete {
+          width: 20em;
+        }
+        a {
+          color: var(--primary-text-color);
+          text-decoration: none;
+        }
+        a:hover {
+          text-decoration: underline;
+        }
+        th {
+          border-bottom: 1px solid var(--border-color);
+          font-weight: var(--font-weight-bold);
+          text-align: left;
+        }
+        .canModify #groupMemberSearchInput,
+        .canModify #saveGroupMember,
+        .canModify .deleteHeader,
+        .canModify .deleteColumn,
+        .canModify #includedGroupSearchInput,
+        .canModify #saveIncludedGroups,
+        .canModify .deleteIncludedHeader,
+        .canModify #saveIncludedGroups {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div
+        class="main gr-form-styles ${this.isAdmin || this.groupOwner
+          ? ''
+          : 'canModify'}"
+      >
+        <div id="loading" class=${this.loading ? 'loading' : ''}>
+          Loading...
+        </div>
+        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
+          <h1 id="Title" class="heading-1">${this.groupName}</h1>
+          <div id="form">
+            <h3 id="members" class="heading-3">Members</h3>
+            <fieldset>
+              <span class="value">
+                <gr-autocomplete
+                  id="groupMemberSearchInput"
+                  .text=${this.groupMemberSearchName}
+                  .value=${this.groupMemberSearchId}
+                  .query=${this.queryMembers}
+                  placeholder="Name Or Email"
+                  @text-changed=${this.handleGroupMemberTextChanged}
+                  @value-changed=${this.handleGroupMemberValueChanged}
+                >
+                </gr-autocomplete>
+              </span>
+              <gr-button
+                id="saveGroupMember"
+                ?disabled=${!this.groupMemberSearchId}
+                @click=${this.handleSavingGroupMember}
+              >
+                Add
+              </gr-button>
+              <table id="groupMembers">
+                <tbody>
+                  <tr class="headerRow">
+                    <th class="nameHeader">Name</th>
+                    <th class="emailAddressHeader">Email Address</th>
+                    <th class="deleteHeader">Delete Member</th>
+                  </tr>
+                </tbody>
+                <tbody>
+                  ${this.groupMembers?.map((member, index) =>
+                    this.renderGroupMember(member, index)
+                  )}
+                </tbody>
+              </table>
+            </fieldset>
+            <h3 id="includedGroups" class="heading-3">Included Groups</h3>
+            <fieldset>
+              <span class="value">
+                <gr-autocomplete
+                  id="includedGroupSearchInput"
+                  .text=${this.includedGroupSearchName}
+                  .value=${this.includedGroupSearchId}
+                  .query=${this.queryIncludedGroup}
+                  placeholder="Group Name"
+                  @text-changed=${this.handleIncludedGroupTextChanged}
+                  @value-changed=${this.handleIncludedGroupValueChanged}
+                >
+                </gr-autocomplete>
+              </span>
+              <gr-button
+                id="saveIncludedGroups"
+                ?disabled=${!this.includedGroupSearchId}
+                @click=${this.handleSavingIncludedGroups}
+              >
+                Add
+              </gr-button>
+              <table id="includedGroups">
+                <tbody>
+                  <tr class="headerRow">
+                    <th class="groupNameHeader">Group Name</th>
+                    <th class="descriptionHeader">Description</th>
+                    <th class="deleteIncludedHeader">Delete Group</th>
+                  </tr>
+                </tbody>
+                <tbody>
+                  ${this.includedGroups?.map((group, index) =>
+                    this.renderIncludedGroup(group, index)
+                  )}
+                </tbody>
+              </table>
+            </fieldset>
+          </div>
+        </div>
+      </div>
+      <dialog id="modal" tabindex="-1">
+        <gr-confirm-delete-item-dialog
+          class="confirmDialog"
+          .item=${this.itemName}
+          .itemTypeName=${this.computeItemTypeName(this.itemType)}
+          @confirm=${this.handleDeleteConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-delete-item-dialog>
+      </dialog>
+    `;
+  }
+
+  private renderGroupMember(member: AccountInfo, index: number) {
+    return html`
+      <tr>
+        <td class="nameColumn">
+          <gr-account-label .account=${member} clickable></gr-account-label>
+        </td>
+        <td>${member.email}</td>
+        <td class="deleteColumn">
+          <gr-button
+            class="deleteMembersButton"
+            data-index=${index}
+            @click=${this.handleDeleteMember}
+          >
+            Delete
+          </gr-button>
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderIncludedGroup(group: GroupInfo, index: number) {
+    return html`
+      <tr>
+        <td class="nameColumn">${this.renderIncludedGroupHref(group)}</td>
+        <td>${group.description}</td>
+        <td class="deleteColumn">
+          <gr-button
+            class="deleteIncludedGroupButton"
+            data-index=${index}
+            @click=${this.handleDeleteIncludedGroup}
+          >
+            Delete
+          </gr-button>
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderIncludedGroupHref(group: GroupInfo) {
+    if (group.url) {
+      return html`
+        <a href=${ifDefined(this.computeGroupUrl(group.url))} rel="noopener">
+          ${group.name}
+        </a>
+      `;
     }
 
+    return group.name;
+  }
+
+  /* private but used in test */
+  loadGroupDetails() {
+    if (!this.groupId) return;
+
     const promises: Promise<void>[] = [];
 
     const errFn: ErrorCallback = response => {
@@ -153,52 +337,43 @@
           return Promise.resolve();
         }
 
-        this._groupName = config.name;
+        this.groupName = config.name;
 
         promises.push(
           this.restApiService.getIsAdmin().then(isAdmin => {
-            this._isAdmin = !!isAdmin;
+            this.isAdmin = !!isAdmin;
           })
         );
 
         promises.push(
-          this.restApiService.getIsGroupOwner(this._groupName).then(isOwner => {
-            this._groupOwner = !!isOwner;
+          this.restApiService.getIsGroupOwner(this.groupName).then(isOwner => {
+            this.groupOwner = !!isOwner;
           })
         );
 
         promises.push(
-          this.restApiService.getGroupMembers(this._groupName).then(members => {
-            this._groupMembers = members;
+          this.restApiService.getGroupMembers(this.groupName).then(members => {
+            this.groupMembers = members;
           })
         );
 
         promises.push(
           this.restApiService
-            .getIncludedGroup(this._groupName)
+            .getIncludedGroup(this.groupName)
             .then(includedGroup => {
-              this._includedGroups = includedGroup;
+              this.includedGroups = includedGroup;
             })
         );
 
         return Promise.all(promises).then(() => {
-          this._loading = false;
+          this.loading = false;
         });
       });
   }
 
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _computeGroupUrl(url: string) {
-    if (!url) {
-      return;
-    }
+  /* private but used in test */
+  computeGroupUrl(url?: string) {
+    if (!url) return;
 
     const r = new RegExp(URL_REGEX, 'i');
     if (r.test(url)) {
@@ -212,53 +387,55 @@
     return getBaseUrl() + url;
   }
 
-  _handleSavingGroupMember() {
-    if (!this._groupName) {
+  /* private but used in test */
+  handleSavingGroupMember() {
+    if (!this.groupName) {
       return Promise.reject(new Error('group name undefined'));
     }
     return this.restApiService
-      .saveGroupMember(this._groupName, this._groupMemberSearchId as AccountId)
+      .saveGroupMember(this.groupName, this.groupMemberSearchId as AccountId)
       .then(config => {
-        if (!config || !this._groupName) {
+        if (!config || !this.groupName) {
           return;
         }
-        this.restApiService.getGroupMembers(this._groupName).then(members => {
-          this._groupMembers = members;
+        this.restApiService.getGroupMembers(this.groupName).then(members => {
+          this.groupMembers = members;
         });
-        this._groupMemberSearchName = '';
-        this._groupMemberSearchId = undefined;
+        this.groupMemberSearchName = '';
+        this.groupMemberSearchId = undefined;
       });
   }
 
-  _handleDeleteConfirm() {
-    if (!this._groupName) {
+  /* private but used in test */
+  handleDeleteConfirm() {
+    if (!this.groupName) {
       return Promise.reject(new Error('group name undefined'));
     }
-    this.$.overlay.close();
-    if (this._itemType === ItemType.MEMBER) {
+    this.modal.close();
+    if (this.itemType === ItemType.MEMBER) {
       return this.restApiService
-        .deleteGroupMember(this._groupName, this._itemId! as AccountId)
+        .deleteGroupMember(this.groupName, this.itemId! as AccountId)
         .then(itemDeleted => {
-          if (itemDeleted.status === 204 && this._groupName) {
+          if (itemDeleted.status === 204 && this.groupName) {
             this.restApiService
-              .getGroupMembers(this._groupName)
+              .getGroupMembers(this.groupName)
               .then(members => {
-                this._groupMembers = members;
+                this.groupMembers = members;
               });
           }
         });
-    } else if (this._itemType === ItemType.INCLUDED_GROUP) {
+    } else if (this.itemType === ItemType.INCLUDED_GROUP) {
       return this.restApiService
-        .deleteIncludedGroup(this._groupName, this._itemId! as GroupId)
+        .deleteIncludedGroup(this.groupName, this.itemId! as GroupId)
         .then(itemDeleted => {
           if (
             (itemDeleted.status === 204 || itemDeleted.status === 205) &&
-            this._groupName
+            this.groupName
           ) {
             this.restApiService
-              .getIncludedGroup(this._groupName)
+              .getIncludedGroup(this.groupName)
               .then(includedGroup => {
-                this._includedGroups = includedGroup;
+                this.includedGroups = includedGroup;
               });
           }
         });
@@ -266,7 +443,8 @@
     return Promise.reject(new Error('Unrecognized item type'));
   }
 
-  _computeItemTypeName(itemType?: ItemType): string {
+  /* private but used in test */
+  computeItemTypeName(itemType?: ItemType): string {
     if (itemType === undefined) return '';
     switch (itemType) {
       case ItemType.INCLUDED_GROUP:
@@ -278,40 +456,41 @@
     }
   }
 
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
+  private handleConfirmDialogCancel() {
+    this.modal.close();
   }
 
-  _handleDeleteMember(e: PolymerDomRepeatEvent<AccountInfo>) {
-    const id = e.model.get('item._account_id');
-    const name = e.model.get('item.name');
-    const username = e.model.get('item.username');
-    const email = e.model.get('item.email');
-    const item = username || name || email || id?.toString();
-    if (!item) {
-      return;
-    }
-    this._itemName = item;
-    this._itemId = id;
-    this._itemType = ItemType.MEMBER;
-    this.$.overlay.open();
+  private handleDeleteMember(e: Event) {
+    if (!this.groupMembers) return;
+
+    const el = e.target as GrButton;
+    const index = Number(el.getAttribute('data-index')!);
+    const keys = this.groupMembers[index];
+    const item =
+      keys.username || keys.name || keys.email || keys._account_id?.toString();
+    if (!item) return;
+    this.itemName = item;
+    this.itemId = keys._account_id;
+    this.itemType = ItemType.MEMBER;
+    this.modal.showModal();
   }
 
-  _handleSavingIncludedGroups() {
-    if (!this._groupName || !this._includedGroupSearchId) {
+  /* private but used in test */
+  handleSavingIncludedGroups() {
+    if (!this.groupName || !this.includedGroupSearchId) {
       return Promise.reject(
         new Error('group name or includedGroupSearchId undefined')
       );
     }
     return this.restApiService
       .saveIncludedGroup(
-        this._groupName,
-        this._includedGroupSearchId.replace(/\+/g, ' ') as GroupId,
+        this.groupName,
+        this.includedGroupSearchId.replace(/\+/g, ' ') as GroupId,
         (errResponse, err) => {
           if (errResponse) {
             if (errResponse.status === 404) {
               fireAlert(this, SAVING_ERROR_TEXT);
-              return errResponse;
+              return;
             }
             throw Error(errResponse.statusText);
           }
@@ -319,77 +498,71 @@
         }
       )
       .then(config => {
-        if (!config || !this._groupName) {
+        if (!config || !this.groupName) {
           return;
         }
         this.restApiService
-          .getIncludedGroup(this._groupName)
+          .getIncludedGroup(this.groupName)
           .then(includedGroup => {
-            this._includedGroups = includedGroup;
+            this.includedGroups = includedGroup;
           });
-        this._includedGroupSearchName = '';
-        this._includedGroupSearchId = '';
+        this.includedGroupSearchName = '';
+        this.includedGroupSearchId = '';
       });
   }
 
-  _handleDeleteIncludedGroup(e: PolymerDomRepeatEvent<GroupInfo>) {
-    const id = decodeURIComponent(`${e.model.get('item.id')}`).replace(
-      /\+/g,
-      ' '
-    ) as GroupId;
-    const name = e.model.get('item.name');
+  private handleDeleteIncludedGroup(e: Event) {
+    if (!this.includedGroups) return;
+
+    const el = e.target as GrButton;
+    const index = Number(el.getAttribute('data-index')!);
+    const keys = this.includedGroups[index];
+
+    const id = decodeURIComponent(keys.id).replace(/\+/g, ' ') as GroupId;
+    const name = keys.name;
     const item = name || id;
-    if (!item) {
-      return;
-    }
-    this._itemName = item;
-    this._itemId = id;
-    this._itemType = ItemType.INCLUDED_GROUP;
-    this.$.overlay.open();
+    if (!item) return;
+    this.itemName = item;
+    this.itemId = id;
+    this.itemType = ItemType.INCLUDED_GROUP;
+    this.modal.showModal();
   }
 
-  _getAccountSuggestions(input: string) {
-    if (input.length === 0) {
-      return Promise.resolve([]);
-    }
+  /* private but used in test */
+  getGroupSuggestions(input: string) {
     return this.restApiService
-      .getSuggestedAccounts(input, SUGGESTIONS_LIMIT)
-      .then(accounts => {
-        if (!accounts) return [];
-        const accountSuggestions = [];
-        for (const account of accounts) {
-          let nameAndEmail;
-          if (account.email !== undefined) {
-            nameAndEmail = `${account.name} <${account.email}>`;
-          } else {
-            nameAndEmail = account.name;
-          }
-          accountSuggestions.push({
-            name: nameAndEmail,
-            value: account._account_id?.toString(),
-          });
+      .getSuggestedGroups(
+        input,
+        /* project=*/ undefined,
+        /* n=*/ undefined,
+        throwingErrorCallback
+      )
+      .then(response => {
+        const groups: AutocompleteSuggestion[] = [];
+        for (const [name, group] of Object.entries(response ?? {})) {
+          groups.push({name, value: decodeURIComponent(group.id)});
         }
-        return accountSuggestions;
+        return groups;
       });
   }
 
-  _getGroupSuggestions(input: string) {
-    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;
-    });
+  private handleGroupMemberTextChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.groupMemberSearchName = e.detail.value;
   }
 
-  _computeHideItemClass(owner: boolean, admin: boolean) {
-    return admin || owner ? '' : 'canModify';
+  private handleGroupMemberValueChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.groupMemberSearchId = e.detail.value;
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-group-members': GrGroupMembers;
+  private handleIncludedGroupTextChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.includedGroupSearchName = e.detail.value;
+  }
+
+  private handleIncludedGroupValueChanged(e: CustomEvent) {
+    if (this.loading) return;
+    this.includedGroupSearchId = e.detail.value;
   }
 }
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
deleted file mode 100644
index 518abac..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_html.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .input {
-      width: 15em;
-    }
-    gr-autocomplete {
-      width: 20em;
-    }
-    a {
-      color: var(--primary-text-color);
-      text-decoration: none;
-    }
-    a:hover {
-      text-decoration: underline;
-    }
-    th {
-      border-bottom: 1px solid var(--border-color);
-      font-weight: var(--font-weight-bold);
-      text-align: left;
-    }
-    .canModify #groupMemberSearchInput,
-    .canModify #saveGroupMember,
-    .canModify .deleteHeader,
-    .canModify .deleteColumn,
-    .canModify #includedGroupSearchInput,
-    .canModify #saveIncludedGroups,
-    .canModify .deleteIncludedHeader,
-    .canModify #saveIncludedGroups {
-      display: none;
-    }
-  </style>
-  <div
-    class$="main gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]"
-  >
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h1 id="Title" class="heading-1">[[_groupName]]</h1>
-      <div id="form">
-        <h3 id="members" class="heading-3">Members</h3>
-        <fieldset>
-          <span class="value">
-            <gr-autocomplete
-              id="groupMemberSearchInput"
-              text="{{_groupMemberSearchName}}"
-              value="{{_groupMemberSearchId}}"
-              query="[[_queryMembers]]"
-              placeholder="Name Or Email"
-            >
-            </gr-autocomplete>
-          </span>
-          <gr-button
-            id="saveGroupMember"
-            on-click="_handleSavingGroupMember"
-            disabled="[[!_groupMemberSearchId]]"
-          >
-            Add
-          </gr-button>
-          <table id="groupMembers">
-            <tbody>
-              <tr class="headerRow">
-                <th class="nameHeader">Name</th>
-                <th class="emailAddressHeader">Email Address</th>
-                <th class="deleteHeader">Delete Member</th>
-              </tr>
-            </tbody>
-            <tbody>
-              <template is="dom-repeat" items="[[_groupMembers]]">
-                <tr>
-                  <td class="nameColumn">
-                    <gr-account-link account="[[item]]"></gr-account-link>
-                  </td>
-                  <td>[[item.email]]</td>
-                  <td class="deleteColumn">
-                    <gr-button
-                      class="deleteMembersButton"
-                      on-click="_handleDeleteMember"
-                    >
-                      Delete
-                    </gr-button>
-                  </td>
-                </tr>
-              </template>
-            </tbody>
-          </table>
-        </fieldset>
-        <h3 id="includedGroups" class="heading-3">Included Groups</h3>
-        <fieldset>
-          <span class="value">
-            <gr-autocomplete
-              id="includedGroupSearchInput"
-              text="{{_includedGroupSearchName}}"
-              value="{{_includedGroupSearchId}}"
-              query="[[_queryIncludedGroup]]"
-              placeholder="Group Name"
-            >
-            </gr-autocomplete>
-          </span>
-          <gr-button
-            id="saveIncludedGroups"
-            on-click="_handleSavingIncludedGroups"
-            disabled="[[!_includedGroupSearchId]]"
-          >
-            Add
-          </gr-button>
-          <table id="includedGroups">
-            <tbody>
-              <tr class="headerRow">
-                <th class="groupNameHeader">Group Name</th>
-                <th class="descriptionHeader">Description</th>
-                <th class="deleteIncludedHeader">Delete Group</th>
-              </tr>
-            </tbody>
-            <tbody>
-              <template is="dom-repeat" items="[[_includedGroups]]">
-                <tr>
-                  <td class="nameColumn">
-                    <template is="dom-if" if="[[item.url]]">
-                      <a href$="[[_computeGroupUrl(item.url)]]" rel="noopener">
-                        [[item.name]]
-                      </a>
-                    </template>
-                    <template is="dom-if" if="[[!item.url]]">
-                      [[item.name]]
-                    </template>
-                  </td>
-                  <td>[[item.description]]</td>
-                  <td class="deleteColumn">
-                    <gr-button
-                      class="deleteIncludedGroupButton"
-                      on-click="_handleDeleteIncludedGroup"
-                    >
-                      Delete
-                    </gr-button>
-                  </td>
-                </tr>
-              </template>
-            </tbody>
-          </table>
-        </fieldset>
-      </div>
-    </div>
-  </div>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-delete-item-dialog
-      class="confirmDialog"
-      on-confirm="_handleDeleteConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      item="[[_itemName]]"
-      item-type-name="[[_computeItemTypeName(_itemType)]]"
-    ></gr-confirm-delete-item-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
deleted file mode 100644
index b91b04b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ /dev/null
@@ -1,363 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-group-members.js';
-import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {addListenerForTest, mockPromise, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
-import {ItemType} from './gr-group-members.js';
-
-const basicFixture = fixtureFromElement('gr-group-members');
-
-suite('gr-group-members tests', () => {
-  let element;
-
-  let groups;
-  let groupMembers;
-  let includedGroups;
-  let groupStub;
-
-  setup(() => {
-    groups = {
-      name: 'Administrators',
-      owner: 'Administrators',
-      group_id: 1,
-    };
-
-    groupMembers = [
-      {
-        _account_id: 1000097,
-        name: 'Jane Roe',
-        email: 'jane.roe@example.com',
-        username: 'jane',
-      },
-      {
-        _account_id: 1000096,
-        name: 'Test User',
-        email: 'john.doe@example.com',
-      },
-      {
-        _account_id: 1000095,
-        name: 'Gerrit',
-      },
-      {
-        _account_id: 1000098,
-      },
-    ];
-
-    includedGroups = [{
-      url: 'https://group/url',
-      options: {},
-      id: 'testId',
-      name: 'testName',
-    },
-    {
-      url: '/group/url',
-      options: {},
-      id: 'testId2',
-      name: 'testName2',
-    },
-    {
-      url: '#/group/url',
-      options: {},
-      id: 'testId3',
-      name: 'testName3',
-    },
-    ];
-
-    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 = stubRestApi('getGroupConfig').returns(Promise.resolve(groups));
-    return element._loadGroupDetails();
-  });
-
-  test('_includedGroups', () => {
-    assert.equal(element._includedGroups.length, 3);
-    assert.equal(dom(element.root)
-        .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
-    assert.equal(dom(element.root)
-        .querySelectorAll('.nameColumn a')[1].href,
-    'https://test/site/group/url');
-    assert.equal(dom(element.root)
-        .querySelectorAll('.nameColumn a')[2].href,
-    'https://test/site/group/url');
-  });
-
-  test('save members correctly', async () => {
-    element._groupOwner = true;
-
-    const memberName = 'test-admin';
-
-    const saveStub = stubRestApi('saveGroupMember')
-        .callsFake(() => Promise.resolve({}));
-
-    const button = element.$.saveGroupMember;
-
-    assert.isTrue(button.hasAttribute('disabled'));
-
-    element.$.groupMemberSearchInput.text = memberName;
-    element.$.groupMemberSearchInput.value = 1234;
-
-    await flush();
-    assert.isFalse(button.hasAttribute('disabled'));
-
-    return element._handleSavingGroupMember().then(() => {
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-      assert.isTrue(saveStub.lastCall.calledWithExactly('Administrators',
-          1234));
-    });
-  });
-
-  test('save included groups correctly', async () => {
-    element._groupOwner = true;
-
-    const includedGroupName = 'testName';
-
-    const saveIncludedGroupStub = stubRestApi('saveIncludedGroup')
-        .callsFake(() => Promise.resolve({}));
-
-    const button = element.$.saveIncludedGroups;
-
-    assert.isTrue(button.hasAttribute('disabled'));
-
-    element.$.includedGroupSearchInput.text = includedGroupName;
-    element.$.includedGroupSearchInput.value = 'testId';
-    await flush();
-    assert.isFalse(button.hasAttribute('disabled'));
-
-    return element._handleSavingIncludedGroups().then(() => {
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
-      assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
-      assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
-    });
-  });
-
-  test('add included group 404 shows helpful error text', () => {
-    element._groupOwner = true;
-    element._groupName = 'test';
-
-    const memberName = 'bad-name';
-    const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
-    const errorResponse = {
-      status: 404,
-      ok: false,
-    };
-    stubRestApi('saveIncludedGroup').callsFake((
-        groupName,
-        includedGroup,
-        errFn
-    ) => {
-      errFn(errorResponse);
-      return Promise.resolve(undefined);
-    });
-
-    element.$.groupMemberSearchInput.text = memberName;
-    element.$.groupMemberSearchInput.value = 1234;
-
-    return flush(element._handleSavingIncludedGroups().then(() => {
-      assert.isTrue(alertStub.called);
-    }));
-  });
-
-  test('add included group network-error throws an exception', async () => {
-    element._groupOwner = true;
-    const memberName = 'bad-name';
-    stubRestApi('saveIncludedGroup').throws(new Error());
-
-    element.$.groupMemberSearchInput.text = memberName;
-    element.$.groupMemberSearchInput.value = 1234;
-
-    let exceptionThrown = false;
-    try {
-      await element._handleSavingIncludedGroups();
-    } catch (e) {
-      exceptionThrown = true;
-    }
-    assert.isTrue(exceptionThrown);
-  });
-
-  test('_getAccountSuggestions empty', async () => {
-    const accounts = await element._getAccountSuggestions('nonexistent');
-    assert.equal(accounts.length, 0);
-  });
-
-  test('_getAccountSuggestions non-empty', async () => {
-    const accounts = await element._getAccountSuggestions('test-');
-    assert.equal(accounts.length, 3);
-    assert.equal(accounts[0].name,
-        'test-account <test.account@example.com>');
-    assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
-    assert.equal(accounts[2].name, 'test-git');
-  });
-
-  test('_getGroupSuggestions empty', async () => {
-    const groups = await element._getGroupSuggestions('nonexistent');
-
-    assert.equal(groups.length, 0);
-  });
-
-  test('_getGroupSuggestions non-empty', async () => {
-    const groups = await element._getGroupSuggestions('test');
-
-    assert.equal(groups.length, 2);
-    assert.equal(groups[0].name, 'test-admin');
-    assert.equal(groups[1].name, 'test/Administrator (admin)');
-  });
-
-  test('_computeHideItemClass returns string for admin', () => {
-    const admin = true;
-    const owner = false;
-    assert.equal(element._computeHideItemClass(owner, admin), '');
-  });
-
-  test('_computeHideItemClass returns hideItem for admin and owner', () => {
-    const admin = false;
-    const owner = false;
-    assert.equal(element._computeHideItemClass(owner, admin), 'canModify');
-  });
-
-  test('_computeHideItemClass returns string for owner', () => {
-    const admin = false;
-    const owner = true;
-    assert.equal(element._computeHideItemClass(owner, admin), '');
-  });
-
-  test('delete member', () => {
-    const deleteBtns = dom(element.root)
-        .querySelectorAll('.deleteMembersButton');
-    MockInteractions.tap(deleteBtns[0]);
-    assert.equal(element._itemId, '1000097');
-    assert.equal(element._itemName, 'jane');
-    MockInteractions.tap(deleteBtns[1]);
-    assert.equal(element._itemId, '1000096');
-    assert.equal(element._itemName, 'Test User');
-    MockInteractions.tap(deleteBtns[2]);
-    assert.equal(element._itemId, '1000095');
-    assert.equal(element._itemName, 'Gerrit');
-    MockInteractions.tap(deleteBtns[3]);
-    assert.equal(element._itemId, '1000098');
-    assert.equal(element._itemName, '1000098');
-  });
-
-  test('delete included groups', () => {
-    const deleteBtns = dom(element.root)
-        .querySelectorAll('.deleteIncludedGroupButton');
-    MockInteractions.tap(deleteBtns[0]);
-    assert.equal(element._itemId, 'testId');
-    assert.equal(element._itemName, 'testName');
-    MockInteractions.tap(deleteBtns[1]);
-    assert.equal(element._itemId, 'testId2');
-    assert.equal(element._itemName, 'testName2');
-    MockInteractions.tap(deleteBtns[2]);
-    assert.equal(element._itemId, 'testId3');
-    assert.equal(element._itemName, 'testName3');
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-
-    assert.equal(element._computeLoadingClass(false), '');
-  });
-
-  test('_computeGroupUrl', () => {
-    assert.isUndefined(element._computeGroupUrl(undefined));
-
-    assert.isUndefined(element._computeGroupUrl(false));
-
-    let url = '#/admin/groups/uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
-    assert.equal(element._computeGroupUrl(url),
-        'https://test/site/admin/groups/' +
-        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498');
-
-    url = 'https://gerrit.local/admin/groups/' +
-        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
-    assert.equal(element._computeGroupUrl(url), url);
-  });
-
-  test('fires page-error', async () => {
-    groupStub.restore();
-
-    element.groupId = 1;
-
-    const response = {status: 404};
-    stubRestApi('getGroupConfig').callsFake((group, errFn) => {
-      errFn(response);
-      return Promise.resolve();
-    });
-    const promise = mockPromise();
-    addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      promise.resolve();
-    });
-
-    element._loadGroupDetails();
-    await promise;
-  });
-
-  test('_computeItemName', () => {
-    assert.equal(element._computeItemTypeName(ItemType.MEMBER), 'Member');
-    assert.equal(element._computeItemTypeName(ItemType.INCLUDED_GROUP),
-        'Included Group');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
new file mode 100644
index 0000000..0841595
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -0,0 +1,619 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-group-members';
+import {GrGroupMembers, ItemType} from './gr-group-members';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubBaseUrl,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
+import {
+  AccountId,
+  AccountInfo,
+  EmailAddress,
+  GroupId,
+  GroupInfo,
+  GroupName,
+} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {EventType, PageErrorEvent} from '../../../types/events';
+import {getAccountSuggestions} from '../../../utils/account-util';
+import {getAppContext} from '../../../services/app-context';
+import {fixture, html, assert} from '@open-wc/testing';
+import {createServerInfo} from '../../../test/test-data-generators';
+
+suite('gr-group-members tests', () => {
+  let element: GrGroupMembers;
+
+  let groups: GroupInfo;
+  let groupMembers: AccountInfo[];
+  let includedGroups: GroupInfo[];
+  let groupStub: sinon.SinonStub;
+
+  setup(async () => {
+    groups = {
+      id: 'testId1' as GroupId,
+      name: 'Administrators' as GroupName,
+      owner: 'Administrators',
+      group_id: 1,
+    };
+
+    groupMembers = [
+      {
+        _account_id: 1000097 as AccountId,
+        name: 'Jane Roe',
+        email: 'jane.roe@example.com' as EmailAddress,
+        username: 'jane',
+      },
+      {
+        _account_id: 1000096 as AccountId,
+        name: 'Test User',
+        email: 'john.doe@example.com' as EmailAddress,
+      },
+      {
+        _account_id: 1000095 as AccountId,
+        name: 'Gerrit',
+      },
+      {
+        _account_id: 1000098 as AccountId,
+      },
+    ];
+
+    includedGroups = [
+      {
+        url: 'https://group/url',
+        options: {
+          visible_to_all: false,
+        },
+        id: 'testId' as GroupId,
+        name: 'testName' as GroupName,
+      },
+      {
+        url: '/group/url',
+        options: {
+          visible_to_all: false,
+        },
+        id: 'testId2' as GroupId,
+        name: 'testName2' as GroupName,
+      },
+      {
+        url: '#/group/url',
+        options: {
+          visible_to_all: false,
+        },
+        id: 'testId3' as GroupId,
+        name: 'testName3' as GroupName,
+      },
+    ];
+
+    stubRestApi('getSuggestedAccounts').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            _account_id: 1000096 as AccountId,
+            name: 'test-account',
+            email: 'test.account@example.com' as EmailAddress,
+            username: 'test123',
+            display_name: 'display-test-account',
+          },
+          {
+            _account_id: 1001439 as AccountId,
+            name: 'test-admin',
+            email: 'test.admin@example.com' as EmailAddress,
+            username: 'test_admin',
+          },
+          {
+            _account_id: 1001439 as AccountId,
+            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 = await fixture(html`<gr-group-members></gr-group-members>`);
+    stubBaseUrl('https://test/site');
+    element.groupId = 'testId1' as GroupId;
+    groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(groups));
+    return element.loadGroupDetails();
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles main">
+          <div id="loading">Loading...</div>
+          <div id="loadedContent">
+            <h1 class="heading-1" id="Title">Administrators</h1>
+            <div id="form">
+              <h3 class="heading-3" id="members">Members</h3>
+              <fieldset>
+                <span class="value">
+                  <gr-autocomplete
+                    id="groupMemberSearchInput"
+                    placeholder="Name Or Email"
+                  >
+                  </gr-autocomplete>
+                </span>
+                <gr-button
+                  aria-disabled="true"
+                  disabled=""
+                  id="saveGroupMember"
+                  role="button"
+                  tabindex="-1"
+                >
+                  Add
+                </gr-button>
+                <table id="groupMembers">
+                  <tbody>
+                    <tr class="headerRow">
+                      <th class="nameHeader">Name</th>
+                      <th class="emailAddressHeader">Email Address</th>
+                      <th class="deleteHeader">Delete Member</th>
+                    </tr>
+                  </tbody>
+                  <tbody>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td>jane.roe@example.com</td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="0"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td>john.doe@example.com</td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="1"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="2"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <gr-account-label clickable="" deselected="">
+                        </gr-account-label>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteMembersButton"
+                          data-index="3"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                  </tbody>
+                </table>
+              </fieldset>
+              <h3 class="heading-3" id="includedGroups">Included Groups</h3>
+              <fieldset>
+                <span class="value">
+                  <gr-autocomplete
+                    id="includedGroupSearchInput"
+                    placeholder="Group Name"
+                  >
+                  </gr-autocomplete>
+                </span>
+                <gr-button
+                  aria-disabled="true"
+                  disabled=""
+                  id="saveIncludedGroups"
+                  role="button"
+                  tabindex="-1"
+                >
+                  Add
+                </gr-button>
+                <table id="includedGroups">
+                  <tbody>
+                    <tr class="headerRow">
+                      <th class="groupNameHeader">Group Name</th>
+                      <th class="descriptionHeader">Description</th>
+                      <th class="deleteIncludedHeader">Delete Group</th>
+                    </tr>
+                  </tbody>
+                  <tbody>
+                    <tr>
+                      <td class="nameColumn">
+                        <a href="https://group/url" rel="noopener">
+                          testName
+                        </a>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteIncludedGroupButton"
+                          data-index="0"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <a href="https://test/site/group/url" rel="noopener">
+                          testName2
+                        </a>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteIncludedGroupButton"
+                          data-index="1"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td class="nameColumn">
+                        <a href="https://test/site/group/url" rel="noopener">
+                          testName3
+                        </a>
+                      </td>
+                      <td></td>
+                      <td class="deleteColumn">
+                        <gr-button
+                          aria-disabled="false"
+                          class="deleteIncludedGroupButton"
+                          data-index="2"
+                          role="button"
+                          tabindex="0"
+                        >
+                          Delete
+                        </gr-button>
+                      </td>
+                    </tr>
+                  </tbody>
+                </table>
+              </fieldset>
+            </div>
+          </div>
+        </div>
+        <dialog id="modal" tabindex="-1">
+          <gr-confirm-delete-item-dialog class="confirmDialog">
+          </gr-confirm-delete-item-dialog>
+        </dialog>
+      `
+    );
+  });
+
+  test('includedGroups', () => {
+    assert.equal(element.includedGroups!.length, 3);
+    assert.equal(
+      queryAll<HTMLAnchorElement>(element, '.nameColumn a')[0].href,
+      includedGroups[0].url
+    );
+    assert.equal(
+      queryAll<HTMLAnchorElement>(element, '.nameColumn a')[1].href,
+      'https://test/site/group/url'
+    );
+    assert.equal(
+      queryAll<HTMLAnchorElement>(element, '.nameColumn a')[2].href,
+      'https://test/site/group/url'
+    );
+  });
+
+  test('save members correctly', async () => {
+    element.groupOwner = true;
+
+    const memberName = 'test-admin';
+
+    const saveStub = stubRestApi('saveGroupMember').callsFake(() =>
+      Promise.resolve({})
+    );
+
+    const button = queryAndAssert<GrButton>(element, '#saveGroupMember');
+
+    assert.isTrue(button.hasAttribute('disabled'));
+
+    const groupMemberSearchInput = queryAndAssert<GrAutocomplete>(
+      element,
+      '#groupMemberSearchInput'
+    );
+    groupMemberSearchInput.text = memberName;
+    groupMemberSearchInput.value = '1234';
+
+    await waitUntil(() => !button.hasAttribute('disabled'));
+
+    return element.handleSavingGroupMember().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#Title'
+        ).classList.contains('edited')
+      );
+      assert.equal(saveStub.lastCall.args[0], 'Administrators');
+      assert.equal(saveStub.lastCall.args[1], 1234);
+    });
+  });
+
+  test('save included groups correctly', async () => {
+    element.groupOwner = true;
+
+    const includedGroupName = 'testName';
+
+    const saveIncludedGroupStub = stubRestApi('saveIncludedGroup').callsFake(
+      () => Promise.resolve({id: '0' as GroupId})
+    );
+
+    const button = queryAndAssert<GrButton>(element, '#saveIncludedGroups');
+
+    await waitUntil(() => button.hasAttribute('disabled'));
+
+    const includedGroupSearchInput = queryAndAssert<GrAutocomplete>(
+      element,
+      '#includedGroupSearchInput'
+    );
+    includedGroupSearchInput.text = includedGroupName;
+    includedGroupSearchInput.value = 'testId';
+
+    await waitUntil(() => !button.hasAttribute('disabled'));
+
+    return element.handleSavingIncludedGroups().then(() => {
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#Title'
+        ).classList.contains('edited')
+      );
+      assert.equal(saveIncludedGroupStub.lastCall.args[0], 'Administrators');
+      assert.equal(saveIncludedGroupStub.lastCall.args[1], 'testId');
+    });
+  });
+
+  test('add included group 404 shows helpful error text', async () => {
+    element.groupOwner = true;
+    element.groupName = 'test' as GroupName;
+
+    const memberName = 'bad-name';
+    const alertStub = sinon.stub();
+    element.addEventListener(EventType.SHOW_ALERT, alertStub);
+    const errorResponse = {...new Response(), status: 404, ok: false};
+    stubRestApi('saveIncludedGroup').callsFake((_, _non, errFn) => {
+      if (errFn !== undefined) {
+        errFn(errorResponse);
+      } else {
+        assert.fail('errFn is undefined');
+      }
+      return Promise.resolve(undefined);
+    });
+
+    const groupMemberSearchInput = queryAndAssert<GrAutocomplete>(
+      element,
+      '#groupMemberSearchInput'
+    );
+    groupMemberSearchInput.text = memberName;
+    groupMemberSearchInput.value = '1234';
+
+    await element.updateComplete;
+    element.handleSavingIncludedGroups().then(() => {
+      assert.isTrue(alertStub.called);
+    });
+  });
+
+  test('add included group network-error throws an exception', async () => {
+    element.groupOwner = true;
+    const memberName = 'bad-name';
+    stubRestApi('saveIncludedGroup').throws(new Error());
+
+    const groupMemberSearchInput = queryAndAssert<GrAutocomplete>(
+      element,
+      '#groupMemberSearchInput'
+    );
+    groupMemberSearchInput.text = memberName;
+    groupMemberSearchInput.value = '1234';
+
+    let exceptionThrown = false;
+    try {
+      await element.handleSavingIncludedGroups();
+    } catch (e) {
+      exceptionThrown = true;
+    }
+    assert.isTrue(exceptionThrown);
+  });
+
+  test('getAccountSuggestions empty', async () => {
+    const accounts = await getAccountSuggestions(
+      'nonexistent',
+      getAppContext().restApiService,
+      createServerInfo()
+    );
+    assert.equal(accounts.length, 0);
+  });
+
+  test('getAccountSuggestions non-empty', async () => {
+    const accounts = await getAccountSuggestions(
+      'test-',
+      getAppContext().restApiService,
+      createServerInfo()
+    );
+    assert.equal(accounts.length, 3);
+    assert.equal(
+      accounts[0].name,
+      'display-test-account <test.account@example.com>'
+    );
+    assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
+    assert.equal(accounts[2].name, 'test-git');
+  });
+
+  test('getGroupSuggestions empty', async () => {
+    const groups = await element.getGroupSuggestions('nonexistent');
+
+    assert.equal(groups.length, 0);
+  });
+
+  test('getGroupSuggestions non-empty', async () => {
+    const groups = await element.getGroupSuggestions('test');
+
+    assert.equal(groups.length, 2);
+    assert.equal(groups[0].name, 'test-admin');
+    assert.equal(groups[1].name, 'test/Administrator (admin)');
+  });
+
+  test('delete member', () => {
+    const deleteBtns = queryAll<GrButton>(element, '.deleteMembersButton');
+    deleteBtns[0].click();
+    assert.equal(element.itemId, 1000097 as AccountId);
+    assert.equal(element.itemName, 'jane');
+    queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
+    deleteBtns[1].click();
+    assert.equal(element.itemId, 1000096 as AccountId);
+    assert.equal(element.itemName, 'Test User');
+    queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
+    deleteBtns[2].click();
+    assert.equal(element.itemId, 1000095 as AccountId);
+    assert.equal(element.itemName, 'Gerrit');
+    queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
+    deleteBtns[3].click();
+    assert.equal(element.itemId, 1000098 as AccountId);
+    assert.equal(element.itemName, '1000098');
+  });
+
+  test('delete included groups', () => {
+    const deleteBtns = queryAll<GrButton>(
+      element,
+      '.deleteIncludedGroupButton'
+    );
+    deleteBtns[0].click();
+    assert.equal(element.itemId, 'testId' as GroupId);
+    assert.equal(element.itemName, 'testName');
+    queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
+    deleteBtns[1].click();
+    assert.equal(element.itemId, 'testId2' as GroupId);
+    assert.equal(element.itemName, 'testName2');
+    queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
+    deleteBtns[2].click();
+    assert.equal(element.itemId, 'testId3' as GroupId);
+    assert.equal(element.itemName, 'testName3');
+  });
+
+  test('computeGroupUrl', () => {
+    assert.isUndefined(element.computeGroupUrl(undefined));
+
+    let url = '#/admin/groups/uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
+    assert.equal(
+      element.computeGroupUrl(url),
+      'https://test/site/admin/groups/' +
+        'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498'
+    );
+
+    url =
+      'https://gerrit.local/admin/groups/' +
+      'uuid-529b3c2605bb1029c8146f9de4a91c776fe64498';
+    assert.equal(element.computeGroupUrl(url), url);
+  });
+
+  test('fires page-error', async () => {
+    groupStub.restore();
+
+    element.groupId = 'testId1' as GroupId;
+
+    const response = {...new Response(), status: 404};
+    stubRestApi('getGroupConfig').callsFake((_, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      }
+      return Promise.resolve(undefined);
+    });
+    const promise = mockPromise();
+    addListenerForTest(document, 'page-error', e => {
+      assert.deepEqual((e as PageErrorEvent).detail.response, response);
+      promise.resolve();
+    });
+
+    element.loadGroupDetails();
+    await promise;
+  });
+
+  test('_computeItemName', () => {
+    assert.equal(element.computeItemTypeName(ItemType.MEMBER), 'Member');
+    assert.equal(
+      element.computeItemTypeName(ItemType.INCLUDED_GROUP),
+      'Included Group'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index d7ffbaf..ba2b3fd 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
@@ -26,7 +14,7 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {convertToString} from '../../../utils/string-util';
 import {BindValueChangeEvent} from '../../../types/events';
@@ -34,8 +22,9 @@
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
-import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -56,10 +45,6 @@
 }
 
 declare global {
-  interface HTMLElementEventMap {
-    'text-changed': CustomEvent;
-    'value-changed': CustomEvent;
-  }
   interface HTMLElementTagNameMap {
     'gr-group': GrGroup;
   }
@@ -86,28 +71,28 @@
 
   @state() private submitTypes = Object.values(OPTIONS);
 
-  /* private but used in test */
+  // private but used in test
   @state() isAdmin = false;
 
-  /* private but used in test */
+  // private but used in test
   @state() groupOwner = false;
 
-  /* private but used in test */
+  // private but used in test
   @state() groupIsInternal = false;
 
-  /* private but used in test */
+  // private but used in test
   @state() loading = true;
 
-  /* private but used in test */
+  // private but used in test
   @state() groupConfig?: GroupInfo;
 
-  /* private but used in test */
+  // private but used in test
   @state() groupConfigOwner?: string;
 
-  /* private but used in test */
+  // private but used in test
   @state() originalName?: GroupName;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
@@ -116,7 +101,6 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.loadGroup();
   }
 
   static override get styles() {
@@ -137,11 +121,9 @@
   override render() {
     return html`
       <div class="main gr-form-styles read-only">
-        <div id="loading" class="${this.computeLoadingClass()}">Loading...</div>
-        <div id="loadedContent" class="${this.computeLoadingClass()}">
-          <h1 id="Title" class="heading-1">
-            ${convertToString(this.originalName)}
-          </h1>
+        <div id="loading" class=${this.computeLoadingClass()}>Loading...</div>
+        <div id="loadedContent" class=${this.computeLoadingClass()}>
+          <h1 id="Title" class="heading-1">${this.originalName}</h1>
           <h2 id="configurations" class="heading-2">General</h2>
           <div id="form">
             <fieldset>
@@ -180,12 +162,12 @@
         <span class="value">
           <gr-autocomplete
             id="groupNameInput"
-            .text=${convertToString(this.groupConfig?.name)}
+            .text=${this.groupConfig?.name}
             ?disabled=${this.computeGroupDisabled()}
             @text-changed=${this.handleNameTextChanged}
           ></gr-autocomplete>
         </span>
-        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+        <span class="value">
           <gr-button
             id="inputUpdateNameBtn"
             ?disabled=${!groupNameEdited}
@@ -212,8 +194,8 @@
         <span class="value">
           <gr-autocomplete
             id="groupOwnerInput"
-            .text=${convertToString(this.groupConfig?.owner)}
-            .value=${convertToString(this.groupConfigOwner)}
+            .text=${this.groupConfig?.owner}
+            .value=${this.groupConfigOwner}
             .query=${this.query}
             ?disabled=${this.computeGroupDisabled()}
             @text-changed=${this.handleOwnerTextChanged}
@@ -221,7 +203,7 @@
           >
           </gr-autocomplete>
         </span>
-        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+        <span class="value">
           <gr-button
             id="inputUpdateOwnerBtn"
             ?disabled=${!groupOwnerNameEdited}
@@ -249,11 +231,11 @@
             rows="4"
             monospace
             ?disabled=${this.computeGroupDisabled()}
-            .text=${convertToString(this.groupConfig?.description)}
+            .text=${this.groupConfig?.description ?? ''}
             @text-changed=${this.handleDescriptionTextChanged}
-          >
+          ></gr-textarea>
         </div>
-        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+        <span class="value">
           <gr-button
             ?disabled=${!groupDescriptionEdited}
             @click=${this.handleSaveDescription}
@@ -266,9 +248,15 @@
   }
 
   private renderGroupOptions() {
+    // We make sure the value is a boolean
+    // this is done so undefined is converted to false.
     const groupOptionsEdited =
-      this.originalOptionsVisibleToAll !==
-      this.groupConfig?.options?.visible_to_all;
+      Boolean(this.originalOptionsVisibleToAll) !==
+      Boolean(this.groupConfig?.options?.visible_to_all);
+
+    // We have to convert boolean to string in order
+    // for the selection to work correctly.
+    // We also convert undefined to false using boolean.
     return html`
       <h3
         id="options"
@@ -284,7 +272,9 @@
           <span class="value">
             <gr-select
               id="visibleToAll"
-              .bindValue="${this.groupConfig?.options?.visible_to_all}"
+              .bindValue=${convertToString(
+                Boolean(this.groupConfig?.options?.visible_to_all)
+              )}
               @bind-value-changed=${this.handleOptionsBindValueChanged}
             >
               <select ?disabled=${this.computeGroupDisabled()}>
@@ -297,7 +287,7 @@
             </gr-select>
           </span>
         </section>
-        <span class="value" ?disabled=${this.computeGroupDisabled()}>
+        <span class="value">
           <gr-button
             ?disabled=${!groupOptionsEdited}
             @click=${this.handleSaveOptions}
@@ -309,11 +299,15 @@
     `;
   }
 
-  /* private but used in test */
-  async loadGroup() {
-    if (!this.groupId) {
-      return;
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('groupId')) {
+      this.loadGroup();
     }
+  }
+
+  // private but used in test
+  async loadGroup() {
+    if (!this.groupId) return;
 
     const promises: Promise<unknown>[] = [];
 
@@ -348,13 +342,6 @@
       })
     );
 
-    // 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.originalOptionsVisibleToAll = config?.options?.visible_to_all;
 
@@ -364,12 +351,12 @@
     this.loading = false;
   }
 
-  /* private but used in test */
+  // private but used in test
   computeLoadingClass() {
     return this.loading ? 'loading' : '';
   }
 
-  /* private but used in test */
+  // private but used in test
   async handleSaveName() {
     const groupConfig = this.groupConfig;
     if (!this.groupId || !groupConfig || !groupConfig.name) {
@@ -399,7 +386,7 @@
     return;
   }
 
-  /* private but used in test */
+  // private but used in test
   async handleSaveOwner() {
     if (!this.groupId || !this.groupConfig) return;
     let owner = this.groupConfig.owner;
@@ -412,7 +399,7 @@
     this.groupConfigOwner = undefined;
   }
 
-  /* private but used in test */
+  // private but used in test
   async handleSaveDescription() {
     if (
       !this.groupId ||
@@ -427,14 +414,13 @@
     this.originalDescriptionName = this.groupConfig.description;
   }
 
-  /* private but used in test */
+  // private but used in test
   async handleSaveOptions() {
     if (!this.groupId || !this.groupConfig || !this.groupConfig.options) return;
     const visible = this.groupConfig.options.visible_to_all;
     const options = {visible_to_all: visible};
     await this.restApiService.saveGroupOptions(this.groupId, options);
-    this.originalOptionsVisibleToAll =
-      this.groupConfig?.options?.visible_to_all;
+    this.originalOptionsVisibleToAll = visible;
   }
 
   private computeHeaderClass(configChanged: boolean) {
@@ -442,16 +428,23 @@
   }
 
   private getGroupSuggestions(input: string) {
-    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;
-    });
+    return this.restApiService
+      .getSuggestedGroups(
+        input,
+        /* project=*/ undefined,
+        /* n=*/ undefined,
+        throwingErrorCallback
+      )
+      .then(response => {
+        const groups: AutocompleteSuggestion[] = [];
+        for (const [name, group] of Object.entries(response ?? {})) {
+          groups.push({name, value: decodeURIComponent(group.id)});
+        }
+        return groups;
+      });
   }
 
-  /* private but used in test */
+  // private but used in test
   computeGroupDisabled() {
     return !(this.groupIsInternal && (this.isAdmin || this.groupOwner));
   }
@@ -487,9 +480,12 @@
   }
 
   private handleOptionsBindValueChanged(e: BindValueChangeEvent) {
-    if (!this.groupConfig || !this.groupConfig.options || this.loading) return;
-    this.groupConfig.options.visible_to_all = e.detail
-      .value as unknown as boolean;
+    if (!this.groupConfig || this.loading) return;
+
+    // Because the value for e.detail.value is a string
+    // we convert the value to a boolean.
+    const value = e.detail.value === 'true' ? true : false;
+    this.groupConfig.options!.visible_to_all = value;
     this.requestUpdate();
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index 5e96e33..256c6a9 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-group';
 import {GrGroup} from './gr-group';
 import {
@@ -23,15 +11,15 @@
   mockPromise,
   queryAndAssert,
   stubRestApi,
+  waitUntil,
 } from '../../../test/test-utils';
-import {createGroupInfo} from '../../../test/test-data-generators.js';
+import {createGroupInfo} from '../../../test/test-data-generators';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrCopyClipboard} from '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import {GrSelect} from '../../shared/gr-select/gr-select';
-
-const basicFixture = fixtureFromElement('gr-group');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-group tests', () => {
   let element: GrGroup;
@@ -51,11 +39,118 @@
   };
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-group></gr-group>`);
     groupStub = stubRestApi('getGroupConfig').returns(Promise.resolve(group));
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles main read-only">
+          <div class="loading" id="loading">Loading...</div>
+          <div class="loading" id="loadedContent">
+            <h1 class="heading-1" id="Title"></h1>
+            <h2 class="heading-2" id="configurations">General</h2>
+            <div id="form">
+              <fieldset>
+                <h3 class="heading-3" id="groupUUID">Group UUID</h3>
+                <fieldset>
+                  <gr-copy-clipboard id="uuid"> </gr-copy-clipboard>
+                </fieldset>
+                <h3 class="heading-3" id="groupName">Group Name</h3>
+                <fieldset>
+                  <span class="value">
+                    <gr-autocomplete disabled="" id="groupNameInput">
+                    </gr-autocomplete>
+                  </span>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      id="inputUpdateNameBtn"
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Rename Group
+                    </gr-button>
+                  </span>
+                </fieldset>
+                <h3 class="heading-3" id="groupOwner">Owners</h3>
+                <fieldset>
+                  <span class="value">
+                    <gr-autocomplete disabled="" id="groupOwnerInput">
+                    </gr-autocomplete>
+                  </span>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      id="inputUpdateOwnerBtn"
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Change Owners
+                    </gr-button>
+                  </span>
+                </fieldset>
+                <h3 class="heading-3">Description</h3>
+                <fieldset>
+                  <div>
+                    <gr-textarea
+                      autocomplete="on"
+                      class="description monospace"
+                      disabled=""
+                      monospace=""
+                      rows="4"
+                    >
+                    </gr-textarea>
+                  </div>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Save Description
+                    </gr-button>
+                  </span>
+                </fieldset>
+                <h3 class="heading-3" id="options">Group Options</h3>
+                <fieldset>
+                  <section>
+                    <span class="title">
+                      Make group visible to all registered users
+                    </span>
+                    <span class="value">
+                      <gr-select id="visibleToAll">
+                        <select disabled="">
+                          <option value="false">False</option>
+                          <option value="true">True</option>
+                        </select>
+                      </gr-select>
+                    </span>
+                  </section>
+                  <span class="value">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Save Group Options
+                    </gr-button>
+                  </span>
+                </fieldset>
+              </fieldset>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
   test('loading displays before group config is loaded', () => {
     assert.isTrue(
       queryAndAssert<HTMLDivElement>(element, '#loading').classList.contains(
@@ -84,8 +179,11 @@
     element.groupId = '1' as GroupId;
     await element.loadGroup();
     assert.isTrue(element.groupIsInternal);
-    assert.isFalse(
-      queryAndAssert<GrSelect>(element, '#visibleToAll').bindValue
+    // The value returned is a boolean in a string
+    // thus we have to check with the string.
+    assert.equal(
+      queryAndAssert<GrSelect>(element, '#visibleToAll').bindValue,
+      'false'
     );
   });
 
@@ -100,8 +198,11 @@
     element.groupId = '1' as GroupId;
     await element.loadGroup();
     assert.isFalse(element.groupIsInternal);
-    assert.isFalse(
-      queryAndAssert<GrSelect>(element, '#visibleToAll').bindValue
+    // The value returned is a boolean in a string
+    // thus we have to check with the string.
+    assert.equal(
+      queryAndAssert<GrSelect>(element, '#visibleToAll').bindValue,
+      'false'
     );
   });
 
@@ -133,9 +234,8 @@
     queryAndAssert<GrAutocomplete>(element, '#groupNameInput').text =
       groupName2;
 
-    await element.updateComplete;
+    await waitUntil(() => button.hasAttribute('disabled') === false);
 
-    assert.isFalse(button.hasAttribute('disabled'));
     assert.isTrue(
       queryAndAssert<HTMLHeadingElement>(
         element,
@@ -178,8 +278,7 @@
     queryAndAssert<GrAutocomplete>(element, '#groupOwnerInput').text =
       'testId2';
 
-    await element.updateComplete;
-    assert.isFalse(button.disabled);
+    await waitUntil(() => button.disabled === false);
     assert.isTrue(
       queryAndAssert<HTMLHeadingElement>(
         element,
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 b7ea237..07b7c87 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -1,44 +1,26 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../gr-rule-editor/gr-rule-editor';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-permission_html';
+import {css, html, LitElement, PropertyValues} from 'lit';
 import {
   toSortedPermissionsArray,
   PermissionArrayItem,
   PermissionArray,
+  AccessPermissionId,
 } from '../../../utils/access-util';
-import {customElement, property, observe} from '@polymer/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   LabelNameToLabelTypeInfoMap,
   LabelTypeInfoValues,
   GroupInfo,
-  ProjectAccessGroups,
-  GroupId,
   GitRef,
+  RepoName,
 } from '../../../types/common';
 import {
   AutocompleteQuery,
@@ -49,11 +31,17 @@
 import {
   EditablePermissionInfo,
   EditablePermissionRuleInfo,
-  EditableProjectAccessGroups,
+  EditableRepoAccessGroups,
 } 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';
+import {getAppContext} from '../../../services/app-context';
+import {fire, fireEvent} from '../../../utils/event-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {when} from 'lit/directives/when.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const MAX_AUTOCOMPLETE_RESULTS = 20;
 
@@ -61,12 +49,6 @@
 
 type GroupsWithRulesMap = {[ruleId: string]: boolean};
 
-export interface GrPermission {
-  $: {
-    groupAutocomplete: GrAutocomplete;
-  };
-}
-
 interface ComputedLabelValue {
   value: number;
   text: string;
@@ -93,10 +75,9 @@
  * @event added-permission-removed
  */
 @customElement('gr-permission')
-export class GrPermission extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrPermission extends LitElement {
+  @property({type: String})
+  repo?: RepoName;
 
   @property({type: Object})
   labels?: LabelNameToLabelTypeInfoMap;
@@ -104,85 +85,252 @@
   @property({type: String})
   name?: string;
 
-  @property({type: Object, observer: '_sortPermission', notify: true})
+  @property({type: Object})
   permission?: PermissionArrayItem<EditablePermissionInfo>;
 
   @property({type: Object})
-  groups?: EditableProjectAccessGroups;
+  groups?: EditableRepoAccessGroups;
 
   @property({type: String})
   section?: GitRef;
 
-  @property({type: Boolean, observer: '_handleEditingChanged'})
+  @property({type: Boolean})
   editing = false;
 
-  @property({type: Object, computed: '_computeLabel(permission, labels)'})
-  _label?: ComputedLabel;
+  @state()
+  private label?: ComputedLabel;
 
-  @property({type: String})
-  _groupFilter?: string;
+  @state()
+  private groupFilter?: string;
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  @state()
+  private query: AutocompleteQuery;
 
-  @property({type: Array})
-  _rules?: PermissionArray<EditablePermissionRuleInfo>;
+  @state()
+  rules?: PermissionArray<EditablePermissionRuleInfo | undefined>;
 
-  @property({type: Object})
-  _groupsWithRules?: GroupsWithRulesMap;
+  @state()
+  groupsWithRules?: GroupsWithRulesMap;
 
-  @property({type: Boolean})
-  _deleted = false;
+  @state()
+  deleted = false;
 
-  @property({type: Boolean})
-  _originalExclusiveValue?: boolean;
+  @state()
+  originalExclusiveValue?: boolean;
 
-  private readonly restApiService = appContext.restApiService;
+  @query('#groupAutocomplete')
+  private groupAutocomplete!: GrAutocomplete;
+
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._query = () => this._getGroupSuggestions();
-    this.addEventListener('access-saved', () => this._handleAccessSaved());
+    this.query = () => this.getGroupSuggestions();
+    this.addEventListener('access-saved', () => this.handleAccessSaved());
   }
 
-  override ready() {
-    super.ready();
-    this._setupValues();
+  override connectedCallback() {
+    super.connectedCallback();
+    this.setupValues();
   }
 
-  _setupValues() {
+  override willUpdate(changedProperties: PropertyValues<GrPermission>): void {
+    if (changedProperties.has('editing')) {
+      this.handleEditingChanged(changedProperties.get('editing'));
+    }
+    if (
+      changedProperties.has('permission') ||
+      changedProperties.has('labels')
+    ) {
+      this.label = this.computeLabel();
+    }
+    if (changedProperties.has('permission')) {
+      this.sortPermission(this.permission);
+    }
+  }
+
+  static override styles = [
+    sharedStyles,
+    paperStyles,
+    formStyles,
+    menuPageStyles,
+    css`
+      :host {
+        display: block;
+        margin-bottom: var(--spacing-m);
+      }
+      .header {
+        align-items: baseline;
+        display: flex;
+        justify-content: space-between;
+        margin: var(--spacing-s) var(--spacing-m);
+      }
+      .rules {
+        background: var(--table-header-background-color);
+        border: 1px solid var(--border-color);
+        border-bottom: 0;
+      }
+      .editing .rules {
+        border-bottom: 1px solid var(--border-color);
+      }
+      .title {
+        margin-bottom: var(--spacing-s);
+      }
+      #addRule,
+      #removeBtn {
+        display: none;
+      }
+      .right {
+        display: flex;
+        align-items: center;
+      }
+      .editing #removeBtn {
+        display: block;
+        margin-left: var(--spacing-xl);
+      }
+      .editing #addRule {
+        display: block;
+        padding: var(--spacing-m);
+      }
+      #deletedContainer,
+      .deleted #mainContainer {
+        display: none;
+      }
+      .deleted #deletedContainer {
+        align-items: baseline;
+        border: 1px solid var(--border-color);
+        display: flex;
+        justify-content: space-between;
+        padding: var(--spacing-m);
+      }
+      #mainContainer {
+        display: block;
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.section || !this.permission) {
+      return;
+    }
+    return html`
+      <section
+        id="permission"
+        class="gr-form-styles ${this.computeSectionClass(
+          this.editing,
+          this.deleted
+        )}"
+      >
+        <div id="mainContainer">
+          <div class="header">
+            <span class="title">${this.name}</span>
+            <div class="right">
+              ${when(
+                !this.permissionIsOwnerOrGlobal(
+                  this.permission.id ?? '',
+                  this.section
+                ),
+                () => html`
+                  <paper-toggle-button
+                    id="exclusiveToggle"
+                    ?checked=${this.permission?.value.exclusive}
+                    ?disabled=${!this.editing}
+                    @change=${this.handleValueChange}
+                    @click=${this.onTapExclusiveToggle}
+                  ></paper-toggle-button
+                  >${this.computeExclusiveLabel(this.permission?.value)}
+                `
+              )}
+              <gr-button
+                link=""
+                id="removeBtn"
+                @click=${this.handleRemovePermission}
+                >Remove</gr-button
+              >
+            </div>
+          </div>
+          <!-- end header -->
+          <div class="rules">
+            ${this.rules?.map(
+              (rule, index) => html`
+                <gr-rule-editor
+                  .hasRange=${this.computeHasRange(this.name)}
+                  .label=${this.label}
+                  .editing=${this.editing}
+                  .groupId=${rule.id}
+                  .groupName=${this.computeGroupName(this.groups, rule.id)}
+                  .permission=${this.permission!.id as AccessPermissionId}
+                  .rule=${rule}
+                  .section=${this.section}
+                  @rule-changed=${(e: CustomEvent) =>
+                    this.handleRuleChanged(e, index)}
+                  @added-rule-removed=${(_: Event) =>
+                    this.handleAddedRuleRemoved(index)}
+                ></gr-rule-editor>
+              `
+            )}
+            <div id="addRule">
+              <gr-autocomplete
+                id="groupAutocomplete"
+                .text=${this.groupFilter ?? ''}
+                @text-changed=${(e: ValueChangedEvent) =>
+                  (this.groupFilter = e.detail.value)}
+                .query=${this.query}
+                placeholder="Add group"
+                @commit=${this.handleAddRuleItem}
+              >
+              </gr-autocomplete>
+            </div>
+            <!-- end addRule -->
+          </div>
+          <!-- end rules -->
+        </div>
+        <!-- end mainContainer -->
+        <div id="deletedContainer">
+          <span>${this.name} was deleted</span>
+          <gr-button link="" id="undoRemoveBtn" @click=${this.handleUndoRemove}
+            >Undo</gr-button
+          >
+        </div>
+        <!-- end deletedContainer -->
+      </section>
+    `;
+  }
+
+  setupValues() {
     if (!this.permission) {
       return;
     }
-    this._originalExclusiveValue = !!this.permission.value.exclusive;
-    flush();
+    this.originalExclusiveValue = !!this.permission.value.exclusive;
+    this.requestUpdate();
   }
 
-  _handleAccessSaved() {
+  private handleAccessSaved() {
     // Set a new 'original' value to keep track of after the value has been
     // saved.
-    this._setupValues();
+    this.setupValues();
   }
 
-  _permissionIsOwnerOrGlobal(permissionId: string, section: string) {
+  private permissionIsOwnerOrGlobal(permissionId: string, section: string) {
     return permissionId === 'owner' || section === 'GLOBAL_CAPABILITIES';
   }
 
-  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+  private handleEditingChanged(editingOld: boolean) {
     // Ignore when editing gets set initially.
     if (!editingOld) {
       return;
     }
-    if (!this.permission || !this._rules) {
+    if (!this.permission || !this.rules) {
       return;
     }
 
     // Restore original values if no longer editing.
-    if (!editing) {
-      this._deleted = false;
+    if (!this.editing) {
+      this.deleted = false;
       delete this.permission.value.deleted;
-      this._groupFilter = '';
-      this._rules = this._rules.filter(rule => !rule.value.added);
+      this.groupFilter = '';
+      this.rules = this.rules.filter(rule => !rule.value!.added);
+      this.handleRulesChanged();
       for (const key of Object.keys(this.permission.value.rules)) {
         if (this.permission.value.rules[key].added) {
           delete this.permission.value.rules[key];
@@ -190,58 +338,58 @@
       }
 
       // Restore exclusive bit to original.
-      this.set(
-        ['permission', 'value', 'exclusive'],
-        this._originalExclusiveValue
-      );
+      this.permission.value.exclusive = this.originalExclusiveValue;
+      fire(this, 'permission-changed', {value: this.permission});
+      this.requestUpdate();
     }
   }
 
-  _handleAddedRuleRemoved(e: PolymerDomRepeatEvent) {
-    if (!this._rules) {
+  private handleAddedRuleRemoved(index: number) {
+    if (!this.rules) {
       return;
     }
-    const index = e.model.index;
-    this._rules = this._rules
+    this.rules = this.rules
       .slice(0, index)
-      .concat(this._rules.slice(index + 1, this._rules.length));
+      .concat(this.rules.slice(index + 1, this.rules.length));
+    this.handleRulesChanged();
   }
 
-  _handleValueChange() {
+  handleValueChange(e: Event) {
     if (!this.permission) {
       return;
     }
     this.permission.value.modified = true;
+    this.permission.value.exclusive = (e.target as HTMLInputElement).checked;
     // Allows overall access page to know a change has been made.
     fireEvent(this, 'access-modified');
   }
 
-  _handleRemovePermission() {
+  handleRemovePermission() {
     if (!this.permission) {
       return;
     }
     if (this.permission.value.added) {
       fireEvent(this, 'added-permission-removed');
     }
-    this._deleted = true;
+    this.deleted = true;
     this.permission.value.deleted = true;
     fireEvent(this, 'access-modified');
   }
 
-  @observe('_rules.splices')
-  _handleRulesChanged() {
-    if (!this._rules) {
+  private handleRulesChanged() {
+    if (!this.rules) {
       return;
     }
     // Update the groups to exclude in the autocomplete.
-    this._groupsWithRules = this._computeGroupsWithRules(this._rules);
+    this.groupsWithRules = this.computeGroupsWithRules(this.rules);
   }
 
-  _sortPermission(permission: PermissionArrayItem<EditablePermissionInfo>) {
-    this._rules = toSortedPermissionsArray(permission.value.rules);
+  sortPermission(permission?: PermissionArrayItem<EditablePermissionInfo>) {
+    this.rules = toSortedPermissionsArray(permission?.value.rules);
+    this.handleRulesChanged();
   }
 
-  _computeSectionClass(editing: boolean, deleted: boolean) {
+  computeSectionClass(editing: boolean, deleted: boolean) {
     const classList = [];
     if (editing) {
       classList.push('editing');
@@ -252,18 +400,16 @@
     return classList.join(' ');
   }
 
-  _handleUndoRemove() {
+  handleUndoRemove() {
     if (!this.permission) {
       return;
     }
-    this._deleted = false;
+    this.deleted = false;
     delete this.permission.value.deleted;
   }
 
-  _computeLabel(
-    permission?: PermissionArrayItem<EditablePermissionInfo>,
-    labels?: LabelNameToLabelTypeInfoMap
-  ): ComputedLabel | undefined {
+  computeLabel(): ComputedLabel | undefined {
+    const {permission, labels} = this;
     if (
       !labels ||
       !permission ||
@@ -282,11 +428,11 @@
     }
     return {
       name: labelName,
-      values: this._computeLabelValues(labels[labelName].values),
+      values: this.computeLabelValues(labels[labelName].values),
     };
   }
 
-  _computeLabelValues(values: LabelTypeInfoValues): ComputedLabelValue[] {
+  computeLabelValues(values: LabelTypeInfoValues): ComputedLabelValue[] {
     const valuesArr: ComputedLabelValue[] = [];
     const keys = Object.keys(values).sort((a, b) => Number(a) - Number(b));
 
@@ -302,8 +448,8 @@
     return valuesArr;
   }
 
-  _computeGroupsWithRules(
-    rules: PermissionArray<EditablePermissionRuleInfo>
+  computeGroupsWithRules(
+    rules: PermissionArray<EditablePermissionRuleInfo | undefined>
   ): GroupsWithRulesMap {
     const groups: GroupsWithRulesMap = {};
     for (const rule of rules) {
@@ -312,15 +458,23 @@
     return groups;
   }
 
-  _computeGroupName(groups: ProjectAccessGroups, groupId: GroupId) {
+  computeGroupName(
+    groups: EditableRepoAccessGroups | undefined,
+    groupId: GitRef
+  ) {
     return groups && groups[groupId] && groups[groupId].name
       ? groups[groupId].name
       : groupId;
   }
 
-  _getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
+  getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getSuggestedGroups(this._groupFilter || '', MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedGroups(
+        this.groupFilter || '',
+        this.repo,
+        MAX_AUTOCOMPLETE_RESULTS,
+        throwingErrorCallback
+      )
       .then(response => {
         const groups: GroupSuggestion[] = [];
         for (const [name, value] of Object.entries(response ?? {})) {
@@ -330,7 +484,7 @@
         return groups
           .filter(
             group =>
-              this._groupsWithRules && !this._groupsWithRules[group.value.id]
+              this.groupsWithRules && !this.groupsWithRules[group.value.id]
           )
           .map((group: GroupSuggestion) => {
             const autocompleteSuggestion: AutocompleteSuggestion = {
@@ -346,8 +500,8 @@
    * Handles adding a skeleton item to the dom-repeat.
    * gr-rule-editor handles setting the default values.
    */
-  _handleAddRuleItem(e: AutocompleteCommitEvent) {
-    if (!this.permission || !this._rules) {
+  async handleAddRuleItem(e: AutocompleteCommitEvent) {
+    if (!this.permission || !this.rules) {
       return;
     }
 
@@ -364,33 +518,35 @@
 
     // Purposely don't recompute sorted array so that the newly added rule
     // is the last item of the array.
-    this.push('_rules', {
-      id: groupId,
+    this.rules.push({
+      id: groupId as GitRef,
+      value: undefined,
     });
-
-    // Add the new group name to the groups object so the name renders
-    // correctly.
-    if (this.groups && !this.groups[groupId]) {
-      this.groups[groupId] = {name: this.$.groupAutocomplete.text};
-    }
-
-    // Clear the text of the auto-complete box, so that the user can add the
-    // next group.
-    this.$.groupAutocomplete.text = '';
-
     // Wait for new rule to get value populated via gr-rule-editor, and then
     // add to permission values as well, so that the change gets propagated
     // back to the section. Since the rule is inside a dom-repeat, a flush
     // is needed.
-    flush();
-    const value = this._rules[this._rules.length - 1].value;
-    value.added = true;
-    // See comment above for why we cannot use "this.set(...)" here.
-    this.permission.value.rules[groupId] = value;
+    this.requestUpdate();
+    await this.updateComplete;
+
+    // Add the new group name to the groups object so the name renders
+    // correctly.
+    if (this.groups && !this.groups[groupId]) {
+      this.groups[groupId] = {name: this.groupAutocomplete.text};
+    }
+
+    // Clear the text of the auto-complete box, so that the user can add the
+    // next group.
+    this.groupAutocomplete.text = '';
+
+    const value = this.rules[this.rules.length - 1].value;
+    value!.added = true;
+    this.permission.value.rules[groupId] = value!;
     fireEvent(this, 'access-modified');
+    this.requestUpdate();
   }
 
-  _computeHasRange(name: string) {
+  computeHasRange(name?: string) {
     if (!name) {
       return false;
     }
@@ -398,15 +554,30 @@
     return RANGE_NAMES.includes(name.toUpperCase());
   }
 
+  private computeExclusiveLabel(permission?: EditablePermissionInfo) {
+    return permission?.exclusive ? 'Exclusive' : 'Not Exclusive';
+  }
+
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
-  _onTapExclusiveToggle(e: Event) {
+  private onTapExclusiveToggle(e: Event) {
     e.preventDefault();
   }
+
+  private handleRuleChanged(e: CustomEvent, index: number) {
+    this.rules!.splice(index, e.detail.value);
+    this.handleRulesChanged();
+    this.requestUpdate();
+  }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'permission-changed': ValueChangedEvent<
+      PermissionArrayItem<EditablePermissionInfo>
+    >;
+  }
   interface HTMLElementTagNameMap {
     'gr-permission': GrPermission;
   }
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
deleted file mode 100644
index a8405df..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_html.ts
+++ /dev/null
@@ -1,146 +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-m);
-    }
-    .header {
-      align-items: baseline;
-      display: flex;
-      justify-content: space-between;
-      margin: var(--spacing-s) var(--spacing-m);
-    }
-    .rules {
-      background: var(--table-header-background-color);
-      border: 1px solid var(--border-color);
-      border-bottom: 0;
-    }
-    .editing .rules {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .title {
-      margin-bottom: var(--spacing-s);
-    }
-    #addRule,
-    #removeBtn {
-      display: none;
-    }
-    .right {
-      display: flex;
-      align-items: center;
-    }
-    .editing #removeBtn {
-      display: block;
-      margin-left: var(--spacing-xl);
-    }
-    .editing #addRule {
-      display: block;
-      padding: var(--spacing-m);
-    }
-    #deletedContainer,
-    .deleted #mainContainer {
-      display: none;
-    }
-    .deleted #deletedContainer {
-      align-items: baseline;
-      border: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m);
-    }
-    #mainContainer {
-      display: block;
-    }
-  </style>
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <section
-    id="permission"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    <div id="mainContainer">
-      <div class="header">
-        <span class="title">[[name]]</span>
-        <div class="right">
-          <template
-            is="dom-if"
-            if="[[!_permissionIsOwnerOrGlobal(permission.id, section)]]"
-          >
-            <paper-toggle-button
-              id="exclusiveToggle"
-              checked="{{permission.value.exclusive}}"
-              on-change="_handleValueChange"
-              disabled$="[[!editing]]"
-              on-click="_onTapExclusiveToggle"
-            ></paper-toggle-button
-            >Exclusive
-          </template>
-          <gr-button link="" id="removeBtn" on-click="_handleRemovePermission"
-            >Remove</gr-button
-          >
-        </div>
-      </div>
-      <!-- end header -->
-      <div class="rules">
-        <template is="dom-repeat" items="{{_rules}}" as="rule">
-          <gr-rule-editor
-            has-range="[[_computeHasRange(name)]]"
-            label="[[_label]]"
-            editing="[[editing]]"
-            group-id="[[rule.id]]"
-            group-name="[[_computeGroupName(groups, rule.id)]]"
-            permission="[[permission.id]]"
-            rule="{{rule}}"
-            section="[[section]]"
-            on-added-rule-removed="_handleAddedRuleRemoved"
-          ></gr-rule-editor>
-        </template>
-        <div id="addRule">
-          <gr-autocomplete
-            id="groupAutocomplete"
-            text="{{_groupFilter}}"
-            query="[[_query]]"
-            placeholder="Add group"
-            on-commit="_handleAddRuleItem"
-          >
-          </gr-autocomplete>
-        </div>
-        <!-- end addRule -->
-      </div>
-      <!-- end rules -->
-    </div>
-    <!-- end mainContainer -->
-    <div id="deletedContainer">
-      <span>[[name]] was deleted</span>
-      <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
-        >Undo</gr-button
-      >
-    </div>
-    <!-- end deletedContainer -->
-  </section>
-`;
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
deleted file mode 100644
index 8f25db5..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
+++ /dev/null
@@ -1,409 +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-permission.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-permission');
-
-suite('gr-permission tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    stubRestApi('getSuggestedGroups').returns(
-        Promise.resolve({
-          'Administrators': {
-            id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
-          },
-          'Anonymous Users': {
-            id: 'global%3AAnonymous-Users',
-          },
-        }));
-  });
-
-  suite('unit tests', () => {
-    test('_sortPermission', () => {
-      const permission = {
-        id: 'submit',
-        value: {
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-            },
-          },
-        },
-      };
-
-      const expectedRules = [
-        {
-          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
-          value: {action: 'ALLOW', force: false},
-        },
-        {
-          id: 'global:Project-Owners',
-          value: {action: 'ALLOW', force: false},
-        },
-      ];
-
-      element._sortPermission(permission);
-      assert.deepEqual(element._rules, expectedRules);
-    });
-
-    test('_computeLabel and _computeLabelValues', () => {
-      const labels = {
-        'Code-Review': {
-          default_value: 0,
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-        },
-      };
-      let permission = {
-        id: 'label-Code-Review',
-        value: {
-          label: 'Code-Review',
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-          },
-        },
-      };
-
-      const expectedLabelValues = [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: 0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ];
-
-      const expectedLabel = {
-        name: 'Code-Review',
-        values: expectedLabelValues,
-      };
-
-      assert.deepEqual(element._computeLabelValues(
-          labels['Code-Review'].values), expectedLabelValues);
-
-      assert.deepEqual(element._computeLabel(permission, labels),
-          expectedLabel);
-
-      permission = {
-        id: 'label-reviewDB',
-        value: {
-          label: 'reviewDB',
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-            },
-          },
-        },
-      };
-
-      assert.isNotOk(element._computeLabel(permission, labels));
-    });
-
-    test('_computeSectionClass', () => {
-      let deleted = true;
-      let editing = false;
-      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-      deleted = false;
-      assert.equal(element._computeSectionClass(editing, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, deleted),
-          'editing deleted');
-    });
-
-    test('_computeGroupName', () => {
-      const groups = {
-        abc123: {name: 'test group'},
-        bcd234: {},
-      };
-      assert.equal(element._computeGroupName(groups, 'abc123'), 'test group');
-      assert.equal(element._computeGroupName(groups, 'bcd234'), 'bcd234');
-    });
-
-    test('_computeGroupsWithRules', () => {
-      const rules = [
-        {
-          id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
-          value: {action: 'ALLOW', force: false},
-        },
-        {
-          id: 'global:Project-Owners',
-          value: {action: 'ALLOW', force: false},
-        },
-      ];
-      const groupsWithRules = {
-        '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
-        'global:Project-Owners': true,
-      };
-      assert.deepEqual(element._computeGroupsWithRules(rules),
-          groupsWithRules);
-    });
-
-    test('_getGroupSuggestions without existing rules', async () => {
-      element._groupsWithRules = {};
-
-      const groups = await element._getGroupSuggestions();
-      assert.deepEqual(groups, [
-        {
-          name: 'Administrators',
-          value: '4c97682e6ce61b7247f3381b6f1789356666de7f',
-        }, {
-          name: 'Anonymous Users',
-          value: 'global%3AAnonymous-Users',
-        },
-      ]);
-    });
-
-    test('_getGroupSuggestions with existing rules filters them', async () => {
-      element._groupsWithRules = {
-        '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
-      };
-
-      const groups = await element._getGroupSuggestions();
-      assert.deepEqual(groups, [{
-        name: 'Anonymous Users',
-        value: 'global%3AAnonymous-Users',
-      }]);
-    });
-
-    test('_handleRemovePermission', () => {
-      element.editing = true;
-      element.permission = {value: {rules: {}}};
-      element._handleRemovePermission();
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.permission.value.deleted);
-
-      element.editing = false;
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.permission.value.deleted);
-    });
-
-    test('_handleUndoRemove', () => {
-      element.permission = {value: {deleted: true, rules: {}}};
-      element._handleUndoRemove();
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.permission.value.deleted);
-    });
-
-    test('_computeHasRange', () => {
-      assert.isTrue(element._computeHasRange('Query Limit'));
-
-      assert.isTrue(element._computeHasRange('Batch Changes Limit'));
-
-      assert.isFalse(element._computeHasRange('test'));
-    });
-  });
-
-  suite('interactions', () => {
-    setup(() => {
-      sinon.spy(element, '_computeLabel');
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.labels = {
-        'Code-Review': {
-          values: {
-            ' 0': 'No score',
-            '-1': 'I would prefer this is not merged as is',
-            '-2': 'This shall not be merged',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      };
-      element.permission = {
-        id: 'label-Code-Review',
-        value: {
-          label: 'Code-Review',
-          rules: {
-            'global:Project-Owners': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-              action: 'ALLOW',
-              force: false,
-              min: -2,
-              max: 2,
-            },
-          },
-        },
-      };
-      element._setupValues();
-      flush();
-    });
-
-    test('adding a rule', () => {
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.groups = {};
-      element.$.groupAutocomplete.text = 'ldap/tests te.st';
-      const e = {
-        detail: {
-          value: 'ldap:CN=test+te.st',
-        },
-      };
-      element.editing = true;
-      assert.equal(element._rules.length, 2);
-      assert.equal(Object.keys(element._groupsWithRules).length, 2);
-      element._handleAddRuleItem(e);
-      flush();
-      assert.deepEqual(element.groups, {'ldap:CN=test te.st': {
-        name: 'ldap/tests te.st'}});
-      assert.equal(element._rules.length, 3);
-      assert.equal(Object.keys(element._groupsWithRules).length, 3);
-      assert.deepEqual(element.permission.value.rules['ldap:CN=test te.st'],
-          {action: 'ALLOW', min: -2, max: 2, added: true});
-      assert.equal(element.$.groupAutocomplete.text, '');
-      // New rule should be removed if cancel from editing.
-      element.editing = false;
-      assert.equal(element._rules.length, 2);
-      assert.equal(Object.keys(element.permission.value.rules).length, 2);
-    });
-
-    test('removing an added rule', () => {
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.groups = {};
-      element.$.groupAutocomplete.text = 'new group name';
-      assert.equal(element._rules.length, 2);
-      element.shadowRoot
-          .querySelector('gr-rule-editor').dispatchEvent(
-              new CustomEvent('added-rule-removed', {
-                composed: true, bubbles: true,
-              }));
-      flush();
-      assert.equal(element._rules.length, 1);
-    });
-
-    test('removing an added permission', () => {
-      const removeStub = sinon.stub();
-      element.addEventListener('added-permission-removed', removeStub);
-      element.editing = true;
-      element.name = 'Priority';
-      element.section = 'refs/*';
-      element.permission.value.added = true;
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(removeStub.called);
-    });
-
-    test('removing the permission', () => {
-      element.editing = true;
-      element.name = 'Priority';
-      element.section = 'refs/*';
-
-      const removeStub = sinon.stub();
-      element.addEventListener('added-permission-removed', removeStub);
-
-      assert.isFalse(element.$.permission.classList.contains('deleted'));
-      assert.isFalse(element._deleted);
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(element.$.permission.classList.contains('deleted'));
-      assert.isTrue(element._deleted);
-      MockInteractions.tap(element.$.undoRemoveBtn);
-      assert.isFalse(element.$.permission.classList.contains('deleted'));
-      assert.isFalse(element._deleted);
-      assert.isFalse(removeStub.called);
-    });
-
-    test('modify a permission', () => {
-      element.editing = true;
-      element.name = 'Priority';
-      element.section = 'refs/*';
-
-      assert.isFalse(element._originalExclusiveValue);
-      assert.isNotOk(element.permission.value.modified);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#exclusiveToggle'));
-      flush();
-      assert.isTrue(element.permission.value.exclusive);
-      assert.isTrue(element.permission.value.modified);
-      assert.isFalse(element._originalExclusiveValue);
-      element.editing = false;
-      assert.isFalse(element.permission.value.exclusive);
-    });
-
-    test('_handleValueChange', () => {
-      const modifiedHandler = sinon.stub();
-      element.permission = {value: {rules: {}}};
-      element.addEventListener('access-modified', modifiedHandler);
-      assert.isNotOk(element.permission.value.modified);
-      element._handleValueChange();
-      assert.isTrue(element.permission.value.modified);
-      assert.isTrue(modifiedHandler.called);
-    });
-
-    test('Exclusive hidden for owner permission', () => {
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'flex');
-      element.set(['permission', 'id'], 'owner');
-      flush();
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'none');
-    });
-
-    test('Exclusive hidden for any global permissions', () => {
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'flex');
-      element.section = 'GLOBAL_CAPABILITIES';
-      flush();
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#exclusiveToggle')).display,
-      'none');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
new file mode 100644
index 0000000..b6bc3ed
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
@@ -0,0 +1,545 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-permission';
+import {GrPermission} from './gr-permission';
+import {query, stubRestApi, waitEventLoop} from '../../../test/test-utils';
+import {GitRef, GroupId, GroupName} from '../../../types/common';
+import {PermissionAction} from '../../../constants/constants';
+import {
+  AutocompleteCommitEventDetail,
+  GrAutocomplete,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrRuleEditor} from '../gr-rule-editor/gr-rule-editor';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
+
+suite('gr-permission tests', () => {
+  let element: GrPermission;
+
+  setup(async () => {
+    element = await fixture(html`<gr-permission></gr-permission>`);
+    stubRestApi('getSuggestedGroups').returns(
+      Promise.resolve({
+        Administrators: {
+          id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId,
+        },
+        'Anonymous Users': {
+          id: 'global%3AAnonymous-Users' as GroupId,
+        },
+      })
+    );
+  });
+
+  suite('unit tests', () => {
+    test('sortPermission', async () => {
+      const permission = {
+        id: 'submit' as GitRef,
+        value: {
+          rules: {
+            'global:Project-Owners': {
+              action: PermissionAction.ALLOW,
+              force: false,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: PermissionAction.ALLOW,
+              force: false,
+            },
+          },
+        },
+      };
+
+      const expectedRules = [
+        {
+          id: '4c97682e6ce6b7247f3381b6f1789356666de7f' as GitRef,
+          value: {action: PermissionAction.ALLOW, force: false},
+        },
+        {
+          id: 'global:Project-Owners' as GitRef,
+          value: {action: PermissionAction.ALLOW, force: false},
+        },
+      ];
+
+      element.sortPermission(permission);
+      await element.updateComplete;
+      assert.deepEqual(element.rules, expectedRules);
+    });
+
+    test('computeLabel and computeLabelValues', async () => {
+      const labels = {
+        'Code-Review': {
+          default_value: 0,
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not submitted as is',
+            '-2': 'This shall not be submitted',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+        },
+      };
+      let permission = {
+        id: 'label-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {
+            'global:Project-Owners': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: -2,
+              max: 2,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: -2,
+              max: 2,
+            },
+          },
+        },
+      };
+
+      const expectedLabelValues = [
+        {value: -2, text: 'This shall not be submitted'},
+        {value: -1, text: 'I would prefer this is not submitted as is'},
+        {value: 0, text: 'No score'},
+        {value: 1, text: 'Looks good to me, but someone else must approve'},
+        {value: 2, text: 'Looks good to me, approved'},
+      ];
+
+      const expectedLabel = {
+        name: 'Code-Review',
+        values: expectedLabelValues,
+      };
+
+      element.permission = permission;
+      element.labels = labels;
+      await element.updateComplete;
+
+      assert.deepEqual(
+        element.computeLabelValues(labels['Code-Review'].values),
+        expectedLabelValues
+      );
+
+      assert.deepEqual(element.computeLabel(), expectedLabel);
+
+      permission = {
+        id: 'label-reviewDB' as GitRef,
+        value: {
+          label: 'reviewDB',
+          rules: {
+            'global:Project-Owners': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: 0,
+              max: 0,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: 0,
+              max: 0,
+            },
+          },
+        },
+      };
+
+      element.permission = permission;
+      await element.updateComplete;
+
+      assert.isNotOk(element.computeLabel());
+    });
+
+    test('computeSectionClass', async () => {
+      let deleted = true;
+      let editing = false;
+      assert.equal(element.computeSectionClass(editing, deleted), 'deleted');
+
+      deleted = false;
+      assert.equal(element.computeSectionClass(editing, deleted), '');
+
+      editing = true;
+      assert.equal(element.computeSectionClass(editing, deleted), 'editing');
+
+      deleted = true;
+      assert.equal(
+        element.computeSectionClass(editing, deleted),
+        'editing deleted'
+      );
+    });
+
+    test('computeGroupName', async () => {
+      const groups = {
+        abc123: {id: '1' as GroupId, name: 'test group' as GroupName},
+        bcd234: {id: '1' as GroupId},
+      };
+      assert.equal(
+        element.computeGroupName(groups, 'abc123' as GitRef),
+        'test group' as GroupName
+      );
+      assert.equal(
+        element.computeGroupName(groups, 'bcd234' as GitRef),
+        'bcd234' as GroupName
+      );
+    });
+
+    test('computeGroupsWithRules', async () => {
+      const rules = [
+        {
+          id: '4c97682e6ce6b7247f3381b6f1789356666de7f' as GitRef,
+          value: {action: PermissionAction.ALLOW, force: false},
+        },
+        {
+          id: 'global:Project-Owners' as GitRef,
+          value: {action: PermissionAction.ALLOW, force: false},
+        },
+      ];
+      const groupsWithRules = {
+        '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
+        'global:Project-Owners': true,
+      };
+      assert.deepEqual(element.computeGroupsWithRules(rules), groupsWithRules);
+    });
+
+    test('getGroupSuggestions without existing rules', async () => {
+      element.groupsWithRules = {};
+      await element.updateComplete;
+
+      const groups = await element.getGroupSuggestions();
+      assert.deepEqual(groups, [
+        {
+          name: 'Administrators',
+          value: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+        },
+        {
+          name: 'Anonymous Users',
+          value: 'global%3AAnonymous-Users',
+        },
+      ]);
+    });
+
+    test('getGroupSuggestions with existing rules filters them', async () => {
+      element.groupsWithRules = {
+        '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
+      };
+      await element.updateComplete;
+
+      const groups = await element.getGroupSuggestions();
+      assert.deepEqual(groups, [
+        {
+          name: 'Anonymous Users',
+          value: 'global%3AAnonymous-Users',
+        },
+      ]);
+    });
+
+    test('handleRemovePermission', async () => {
+      element.editing = true;
+      element.permission = {id: 'test' as GitRef, value: {rules: {}}};
+      element.handleRemovePermission();
+      await element.updateComplete;
+
+      assert.isTrue(element.deleted);
+      assert.isTrue(element.permission.value.deleted);
+
+      element.editing = false;
+      await element.updateComplete;
+      assert.isFalse(element.deleted);
+      assert.isNotOk(element.permission.value.deleted);
+    });
+
+    test('handleUndoRemove', async () => {
+      element.permission = {
+        id: 'test' as GitRef,
+        value: {deleted: true, rules: {}},
+      };
+      element.handleUndoRemove();
+      await element.updateComplete;
+
+      assert.isFalse(element.deleted);
+      assert.isNotOk(element.permission.value.deleted);
+    });
+
+    test('computeHasRange', async () => {
+      assert.isTrue(element.computeHasRange('Query Limit'));
+
+      assert.isTrue(element.computeHasRange('Batch Changes Limit'));
+
+      assert.isFalse(element.computeHasRange('test'));
+    });
+  });
+
+  suite('interactions', () => {
+    setup(async () => {
+      sinon.spy(element, 'computeLabel');
+      element.name = 'Priority';
+      element.section = 'refs/*' as GitRef;
+      element.labels = {
+        'Code-Review': {
+          values: {
+            ' 0': 'No score',
+            '-1': 'I would prefer this is not submitted as is',
+            '-2': 'This shall not be submitted',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      };
+      element.permission = {
+        id: 'label-Code-Review' as GitRef,
+        value: {
+          label: 'Code-Review',
+          rules: {
+            'global:Project-Owners': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: -2,
+              max: 2,
+            },
+            '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+              action: PermissionAction.ALLOW,
+              force: false,
+              min: -2,
+              max: 2,
+            },
+          },
+        },
+      };
+      element.setupValues();
+      await element.updateComplete;
+      await waitEventLoop();
+    });
+
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <section class="gr-form-styles" id="permission">
+            <div id="mainContainer">
+              <div class="header">
+                <span class="title"> Priority </span>
+                <div class="right">
+                  <paper-toggle-button
+                    aria-disabled="true"
+                    aria-pressed="false"
+                    disabled=""
+                    id="exclusiveToggle"
+                    role="button"
+                    style="pointer-events: none; touch-action: none;"
+                    tabindex="-1"
+                    toggles=""
+                  >
+                  </paper-toggle-button>
+                  Not Exclusive
+                  <gr-button
+                    aria-disabled="false"
+                    id="removeBtn"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Remove
+                  </gr-button>
+                </div>
+              </div>
+              <div class="rules">
+                <gr-rule-editor> </gr-rule-editor>
+                <gr-rule-editor> </gr-rule-editor>
+                <div id="addRule">
+                  <gr-autocomplete
+                    id="groupAutocomplete"
+                    placeholder="Add group"
+                  >
+                  </gr-autocomplete>
+                </div>
+              </div>
+            </div>
+            <div id="deletedContainer">
+              <span> Priority was deleted </span>
+              <gr-button
+                aria-disabled="false"
+                id="undoRemoveBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Undo
+              </gr-button>
+            </div>
+          </section>
+        `,
+        // touch-action varies on paper-toggle-button between local and CI
+        {
+          ignoreAttributes: [
+            {tags: ['paper-toggle-button'], attributes: ['style']},
+          ],
+        }
+      );
+    });
+
+    test('adding a rule', async () => {
+      element.name = 'Priority';
+      element.section = 'refs/*' as GitRef;
+      element.groups = {};
+      await element.updateComplete;
+
+      queryAndAssert<GrAutocomplete>(element, '#groupAutocomplete').text =
+        'ldap/tests te.st';
+      const e = {
+        detail: {
+          value: 'ldap:CN=test+te.st',
+        },
+      } as CustomEvent<AutocompleteCommitEventDetail>;
+      element.editing = true;
+      assert.equal(element.rules!.length, 2);
+      assert.equal(Object.keys(element.groupsWithRules!).length, 2);
+      await element.handleAddRuleItem(e);
+      assert.deepEqual(element.groups, {
+        'ldap:CN=test te.st': {
+          name: 'ldap/tests te.st',
+        },
+      });
+      assert.equal(element.rules!.length, 3);
+      assert.equal(Object.keys(element.groupsWithRules!).length, 3);
+      assert.deepEqual(element.permission!.value.rules['ldap:CN=test te.st'], {
+        action: PermissionAction.ALLOW,
+        min: -2,
+        max: 2,
+        added: true,
+      });
+      assert.equal(
+        queryAndAssert<GrAutocomplete>(element, '#groupAutocomplete').text,
+        ''
+      );
+      // New rule should be removed if cancel from editing.
+      element.editing = false;
+      await element.updateComplete;
+      assert.equal(element.rules!.length, 2);
+      assert.equal(Object.keys(element.permission!.value.rules).length, 2);
+    });
+
+    test('removing an added rule', async () => {
+      element.name = 'Priority';
+      element.section = 'refs/*' as GitRef;
+      element.groups = {};
+      await element.updateComplete;
+      queryAndAssert<GrAutocomplete>(element, '#groupAutocomplete').text =
+        'new group name';
+      assert.equal(element.rules!.length, 2);
+      queryAndAssert<GrRuleEditor>(element, 'gr-rule-editor').dispatchEvent(
+        new CustomEvent('added-rule-removed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await waitEventLoop();
+      assert.equal(element.rules!.length, 1);
+    });
+
+    test('removing an added permission', async () => {
+      const removeStub = sinon.stub();
+      element.addEventListener('added-permission-removed', removeStub);
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*' as GitRef;
+      element.permission!.value.added = true;
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
+      await element.updateComplete;
+      assert.isTrue(removeStub.called);
+    });
+
+    test('removing the permission', async () => {
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*' as GitRef;
+      await element.updateComplete;
+
+      const removeStub = sinon.stub();
+      element.addEventListener('added-permission-removed', removeStub);
+
+      assert.isFalse(
+        queryAndAssert(element, '#permission').classList.contains('deleted')
+      );
+      assert.isFalse(element.deleted);
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
+      await element.updateComplete;
+      assert.isTrue(
+        queryAndAssert(element, '#permission').classList.contains('deleted')
+      );
+      assert.isTrue(element.deleted);
+      queryAndAssert<GrButton>(element, '#undoRemoveBtn').click();
+
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert(element, '#permission').classList.contains('deleted')
+      );
+      assert.isFalse(element.deleted);
+      assert.isFalse(removeStub.called);
+    });
+
+    test('modify a permission', async () => {
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*' as GitRef;
+      await element.updateComplete;
+
+      assert.isFalse(element.originalExclusiveValue);
+      assert.isNotOk(element.permission!.value.modified);
+      queryAndAssert<PaperToggleButtonElement>(
+        element,
+        '#exclusiveToggle'
+      ).click();
+      await element.updateComplete;
+      assert.isTrue(element.permission!.value.exclusive);
+      assert.isTrue(element.permission!.value.modified);
+      assert.isFalse(element.originalExclusiveValue);
+      element.editing = false;
+      await element.updateComplete;
+      assert.isFalse(element.permission!.value.exclusive);
+    });
+
+    test('modifying emits access-modified event', async () => {
+      const modifiedHandler = sinon.stub();
+      element.editing = true;
+      element.name = 'Priority';
+      element.section = 'refs/*' as GitRef;
+      element.permission = {id: '0' as GitRef, value: {rules: {}}};
+      element.addEventListener('access-modified', modifiedHandler);
+      await element.updateComplete;
+      assert.isNotOk(element.permission.value.modified);
+      queryAndAssert<PaperToggleButtonElement>(
+        element,
+        '#exclusiveToggle'
+      ).click();
+      await element.updateComplete;
+      assert.isTrue(element.permission.value.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
+
+    test('Exclusive hidden for owner permission', async () => {
+      queryAndAssert(element, '#exclusiveToggle');
+
+      element.permission!.id = 'owner' as GitRef;
+      element.requestUpdate();
+      await element.updateComplete;
+
+      assert.notOk(query(element, '#exclusiveToggle'));
+    });
+
+    test('Exclusive hidden for any global permissions', async () => {
+      queryAndAssert(element, '#exclusiveToggle');
+
+      element.section = 'GLOBAL_CAPABILITIES' as GitRef;
+      await element.updateComplete;
+
+      assert.notOk(query(element, '#exclusiveToggle'));
+    });
+  });
+});
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 2d3b5c3..54a83ee 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
@@ -1,33 +1,20 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-plugin-config-array-editor_html';
-import {property, customElement} from '@polymer/decorators';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {
   PluginConfigOptionsChangedEventDetail,
   ArrayPluginOption,
 } from '../gr-repo-plugin-config/gr-repo-plugin-config-types';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -36,72 +23,152 @@
 }
 
 @customElement('gr-plugin-config-array-editor')
-export class GrPluginConfigArrayEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrPluginConfigArrayEditor extends LitElement {
   /**
    * Fired when the plugin config option changes.
    *
    * @event plugin-config-option-changed
    */
 
-  @property({type: String})
-  _newValue = '';
+  // private but used in test
+  @state() newValue = '';
 
   // This property is never null, since this component in only about operations
   // on pluginOption.
   @property({type: Object})
   pluginOption!: ArrayPluginOption;
 
-  @property({type: Boolean, computed: '_computeDisabled(pluginOption.*)'})
-  disabled!: boolean; // _computeDisabled never returns null
+  @property({type: Boolean, reflect: true})
+  disabled = false;
 
-  _computeDisabled(
-    record: PolymerDeepPropertyChange<ArrayPluginOption, ArrayPluginOption>
-  ) {
-    return !(
-      record &&
-      record.base &&
-      record.base.info &&
-      record.base.info.editable
-    );
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        .wrapper {
+          width: 30em;
+        }
+        .existingItems {
+          background: var(--table-header-background-color);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+        }
+        gr-button {
+          float: right;
+          margin-left: var(--spacing-m);
+          width: 4.5em;
+        }
+        .row {
+          align-items: center;
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-m) 0;
+          width: 100%;
+        }
+        .existingItems .row {
+          padding: var(--spacing-m);
+        }
+        .existingItems .row:not(:first-of-type) {
+          border-top: 1px solid var(--border-color);
+        }
+        input {
+          flex-grow: 1;
+        }
+        .hide {
+          display: none;
+        }
+        .placeholder {
+          color: var(--deemphasized-text-color);
+          padding-top: var(--spacing-m);
+        }
+      `,
+    ];
   }
 
-  _handleAddTap(e: MouseEvent) {
+  override render() {
+    return html`
+      <div class="wrapper gr-form-styles">
+        ${this.renderPluginOptions()}
+        <div class="row ${this.disabled ? 'hide' : ''}">
+          <iron-input
+            .bindValue=${this.newValue}
+            @bind-value-changed=${this.handleBindValueChangedNewValue}
+          >
+            <input
+              id="input"
+              @keydown=${this.handleInputKeydown}
+              ?disabled=${this.disabled}
+            />
+          </iron-input>
+          <gr-button
+            id="addButton"
+            ?disabled=${!this.newValue.length}
+            link
+            @click=${this.handleAddTap}
+            >Add</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private renderPluginOptions() {
+    if (!this.pluginOption?.info?.values?.length) {
+      return html`<div class="row placeholder">None configured.</div>`;
+    }
+
+    return html`
+      <div class="existingItems">
+        ${this.pluginOption.info.values.map(item =>
+          this.renderPluginOptionValue(item)
+        )}
+      </div>
+    `;
+  }
+
+  private renderPluginOptionValue(item: string) {
+    return html`
+      <div class="row">
+        <span>${item}</span>
+        <gr-button
+          link
+          ?disabled=${this.disabled}
+          @click=${() => this.handleDelete(item)}
+          >Delete</gr-button
+        >
+      </div>
+    `;
+  }
+
+  private handleAddTap(e: MouseEvent) {
     e.preventDefault();
-    this._handleAdd();
+    this.handleAdd();
   }
 
-  _handleInputKeydown(e: KeyboardEvent) {
-    // Enter.
-    if (e.keyCode === 13) {
+  private handleInputKeydown(e: KeyboardEvent) {
+    if (e.key === 'Enter') {
       e.preventDefault();
-      this._handleAdd();
+      this.handleAdd();
     }
   }
 
-  _handleAdd() {
-    if (!this._newValue.length) {
+  private handleAdd() {
+    if (!this.newValue.length) {
       return;
     }
-    this._dispatchChanged(
-      this.pluginOption.info.values.concat([this._newValue])
-    );
-    this._newValue = '';
+    this.dispatchChanged(this.pluginOption.info.values.concat([this.newValue]));
+    this.newValue = '';
   }
 
-  _handleDelete(e: MouseEvent) {
-    const value = ((dom(e) as EventApi).localTarget as HTMLElement).dataset[
-      'item'
-    ];
-    this._dispatchChanged(
+  private handleDelete(value: string) {
+    this.dispatchChanged(
       this.pluginOption.info.values.filter(str => str !== value)
     );
   }
 
-  _dispatchChanged(values: string[]) {
+  // private but used in test
+  dispatchChanged(values: string[]) {
     const {_key, info} = this.pluginOption;
     const detail: PluginConfigOptionsChangedEventDetail = {
       _key,
@@ -113,7 +180,7 @@
     );
   }
 
-  _computeShowInputRow(disabled: boolean) {
-    return disabled ? 'hide' : '';
+  private handleBindValueChangedNewValue(e: BindValueChangeEvent) {
+    this.newValue = e.detail.value ?? '';
   }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts
deleted file mode 100644
index c96b86c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_html.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .wrapper {
-      width: 30em;
-    }
-    .existingItems {
-      background: var(--table-header-background-color);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-    }
-    gr-button {
-      float: right;
-      margin-left: var(--spacing-m);
-      width: 4.5em;
-    }
-    .row {
-      align-items: center;
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m) 0;
-      width: 100%;
-    }
-    .existingItems .row {
-      padding: var(--spacing-m);
-    }
-    .existingItems .row:not(:first-of-type) {
-      border-top: 1px solid var(--border-color);
-    }
-    input {
-      flex-grow: 1;
-    }
-    .hide {
-      display: none;
-    }
-    .placeholder {
-      color: var(--deemphasized-text-color);
-      padding-top: var(--spacing-m);
-    }
-  </style>
-  <div class="wrapper gr-form-styles">
-    <template is="dom-if" if="[[pluginOption.info.values.length]]">
-      <div class="existingItems">
-        <template is="dom-repeat" items="[[pluginOption.info.values]]">
-          <div class="row">
-            <span>[[item]]</span>
-            <gr-button
-              link=""
-              disabled$="[[disabled]]"
-              data-item$="[[item]]"
-              on-click="_handleDelete"
-              >Delete</gr-button
-            >
-          </div>
-        </template>
-      </div>
-    </template>
-    <template is="dom-if" if="[[!pluginOption.info.values.length]]">
-      <div class="row placeholder">None configured.</div>
-    </template>
-    <div class$="row [[_computeShowInputRow(disabled)]]">
-      <iron-input on-keydown="_handleInputKeydown" bind-value="{{_newValue}}">
-        <input
-          is="iron-input"
-          id="input"
-          on-keydown="_handleInputKeydown"
-          bind-value="{{_newValue}}"
-        />
-      </iron-input>
-      <gr-button
-        id="addButton"
-        disabled$="[[!_newValue.length]]"
-        link=""
-        on-click="_handleAddTap"
-        >Add</gr-button
-      >
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
deleted file mode 100644
index dfc191f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
+++ /dev/null
@@ -1,126 +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-plugin-config-array-editor.js';
-
-const basicFixture = fixtureFromElement('gr-plugin-config-array-editor');
-
-suite('gr-plugin-config-array-editor tests', () => {
-  let element;
-
-  let dispatchStub;
-
-  const getAll = str => element.root.querySelectorAll(str);
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element.pluginOption = {
-      _key: 'test-key',
-      info: {
-        values: [],
-      },
-    };
-  });
-
-  test('_computeShowInputRow', () => {
-    assert.equal(element._computeShowInputRow(true), 'hide');
-    assert.equal(element._computeShowInputRow(false), '');
-  });
-
-  test('_computeDisabled', () => {
-    assert.isTrue(element._computeDisabled({}));
-    assert.isTrue(element._computeDisabled({base: {}}));
-    assert.isTrue(element._computeDisabled({base: {info: {}}}));
-    assert.isTrue(
-        element._computeDisabled({base: {info: {editable: false}}}));
-    assert.isFalse(
-        element._computeDisabled({base: {info: {editable: true}}}));
-  });
-
-  suite('adding', () => {
-    setup(() => {
-      dispatchStub = sinon.stub(element, '_dispatchChanged');
-    });
-
-    test('with enter', () => {
-      element._newValue = '';
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-      flush();
-
-      assert.isFalse(dispatchStub.called);
-      element._newValue = 'test';
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
-      flush();
-
-      assert.isTrue(dispatchStub.called);
-      assert.equal(dispatchStub.lastCall.args[0], 'test');
-      assert.equal(element._newValue, '');
-    });
-
-    test('with add btn', () => {
-      element._newValue = '';
-      MockInteractions.tap(element.$.addButton);
-      flush();
-
-      assert.isFalse(dispatchStub.called);
-      element._newValue = 'test';
-      MockInteractions.tap(element.$.addButton);
-      flush();
-
-      assert.isTrue(dispatchStub.called);
-      assert.equal(dispatchStub.lastCall.args[0], 'test');
-      assert.equal(element._newValue, '');
-    });
-  });
-
-  test('deleting', () => {
-    dispatchStub = sinon.stub(element, '_dispatchChanged');
-    element.pluginOption = {info: {values: ['test', 'test2']}};
-    flush();
-
-    const rows = getAll('.existingItems .row');
-    assert.equal(rows.length, 2);
-    const button = rows[0].querySelector('gr-button');
-
-    MockInteractions.tap(button);
-    flush();
-
-    assert.isFalse(dispatchStub.called);
-    element.pluginOption.info.editable = true;
-    element.notifyPath('pluginOption.info.editable');
-    flush();
-
-    MockInteractions.tap(button);
-    flush();
-
-    assert.isTrue(dispatchStub.called);
-    assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
-  });
-
-  test('_dispatchChanged', () => {
-    const eventStub = sinon.stub(element, 'dispatchEvent');
-    element._dispatchChanged(['new-test-value']);
-
-    assert.isTrue(eventStub.called);
-    const {detail} = eventStub.lastCall.args[0];
-    assert.equal(detail._key, 'test-key');
-    assert.deepEqual(detail.info, {values: ['new-test-value']});
-    assert.equal(detail.notifyPath, 'test-key.values');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts
new file mode 100644
index 0000000..672d58e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.ts
@@ -0,0 +1,150 @@
+/**
+ * @license
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ConfigParameterInfoType} from '../../../constants/constants';
+import '../../../test/common-test-setup';
+import './gr-plugin-config-array-editor';
+import {GrPluginConfigArrayEditor} from './gr-plugin-config-array-editor';
+import {queryAll, queryAndAssert, pressKey} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key} from '../../../utils/dom-util';
+
+suite('gr-plugin-config-array-editor tests', () => {
+  let element: GrPluginConfigArrayEditor;
+
+  let dispatchStub: sinon.SinonStub;
+
+  setup(async () => {
+    element = await fixture<GrPluginConfigArrayEditor>(html`
+      <gr-plugin-config-array-editor></gr-plugin-config-array-editor>
+    `);
+    element.pluginOption = {
+      _key: 'test-key',
+      info: {
+        type: ConfigParameterInfoType.ARRAY,
+        values: [],
+      },
+    };
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles wrapper">
+          <div class="placeholder row">None configured.</div>
+          <div class="row">
+            <iron-input>
+              <input id="input" />
+            </iron-input>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="addButton"
+              link=""
+              role="button"
+              tabindex="-1"
+            >
+              Add
+            </gr-button>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  suite('adding', () => {
+    setup(() => {
+      dispatchStub = sinon.stub(element, 'dispatchChanged');
+    });
+
+    test('with enter', async () => {
+      element.newValue = '';
+      await element.updateComplete;
+      pressKey(queryAndAssert<HTMLInputElement>(element, '#input'), Key.ENTER);
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(element, '#input').hasAttribute(
+          'disabled'
+        )
+      );
+      await element.updateComplete;
+
+      assert.isFalse(dispatchStub.called);
+      element.newValue = 'test';
+      await element.updateComplete;
+
+      pressKey(queryAndAssert<HTMLInputElement>(element, '#input'), Key.ENTER);
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(element, '#input').hasAttribute(
+          'disabled'
+        )
+      );
+      await element.updateComplete;
+
+      assert.isTrue(dispatchStub.called);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element.newValue, '');
+    });
+
+    test('with add btn', async () => {
+      element.newValue = '';
+      queryAndAssert<GrButton>(element, '#addButton').click();
+      await element.updateComplete;
+
+      assert.isFalse(dispatchStub.called);
+
+      element.newValue = 'test';
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#addButton').click();
+      await element.updateComplete;
+
+      assert.isTrue(dispatchStub.called);
+      assert.equal(dispatchStub.lastCall.args[0], 'test');
+      assert.equal(element.newValue, '');
+    });
+  });
+
+  test('deleting', async () => {
+    dispatchStub = sinon.stub(element, 'dispatchChanged');
+    element.pluginOption = {
+      _key: '',
+      info: {type: ConfigParameterInfoType.ARRAY, values: ['test', 'test2']},
+    };
+    element.disabled = true;
+    await element.updateComplete;
+
+    const rows = queryAll(element, '.existingItems .row');
+    assert.equal(rows.length, 2);
+    const button = queryAndAssert<GrButton>(rows[0], 'gr-button');
+
+    button.click();
+    await element.updateComplete;
+
+    assert.isFalse(dispatchStub.called);
+    element.disabled = false;
+    await element.updateComplete;
+
+    button.click();
+    await element.updateComplete;
+
+    assert.isTrue(dispatchStub.called);
+    assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
+  });
+
+  test('dispatchChanged', () => {
+    const eventStub = sinon.stub(element, 'dispatchEvent');
+    element.dispatchChanged(['new-test-value']);
+
+    assert.isTrue(eventStub.called);
+    const {detail} = eventStub.lastCall.args[0] as CustomEvent;
+    assert.equal(detail._key, 'test-key');
+    assert.deepEqual(detail.info, {type: 'ARRAY', values: ['new-test-value']});
+    assert.equal(detail.notifyPath, 'test-key.values');
+  });
+});
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 9f51688..383b4a7 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
@@ -1,93 +1,161 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-list-view/gr-list-view';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-plugin-list_html';
-import {customElement, property} from '@polymer/decorators';
 import {PluginInfo} from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
-import {ListViewParams} from '../../gr-app-types';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {AdminViewState} from '../../../models/views/admin';
 
-interface PluginInfoWithName extends PluginInfo {
+// Exported for tests
+export interface PluginInfoWithName extends PluginInfo {
   name: string;
 }
 
 @customElement('gr-plugin-list')
-export class GrPluginList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrPluginList extends LitElement {
+  readonly path = '/admin/plugins';
 
   /**
    * URL params passed from the router.
    */
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: ListViewParams;
+  @property({type: Object})
+  params?: AdminViewState;
 
   /**
    * Offset of currently visible query results.
    */
-  @property({type: Number})
-  _offset = 0;
+  @state() private offset = 0;
 
-  @property({type: String})
-  readonly _path = '/admin/plugins';
+  // private but used in test
+  @state() plugins?: PluginInfoWithName[];
 
-  @property({type: Array})
-  _plugins?: PluginInfoWithName[];
+  @state() private pluginsPerPage = 25;
 
-  /**
-   * Because  we request one more than the pluginsPerPage, _shownPlugins
-   * maybe one less than _plugins.
-   **/
-  @property({type: Array, computed: 'computeShownItems(_plugins)'})
-  _shownPlugins?: PluginInfoWithName[];
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Number})
-  _pluginsPerPage = 25;
+  @state() private filter = '';
 
-  @property({type: Boolean})
-  _loading = true;
-
-  @property({type: String})
-  _filter = '';
-
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Plugins');
   }
 
-  _paramsChanged(params: ListViewParams) {
-    this._loading = true;
-    this._filter = params?.filter ?? '';
-    this._offset = Number(params?.offset ?? 0);
-
-    return this._getPlugins(this._filter, this._pluginsPerPage, this._offset);
+  static override get styles() {
+    return [
+      tableStyles,
+      sharedStyles,
+      css`
+        .placeholder {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
   }
 
-  _getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
+  override render() {
+    return html`
+      <gr-list-view
+        .filter=${this.filter}
+        .itemsPerPage=${this.pluginsPerPage}
+        .items=${this.plugins}
+        .loading=${this.loading}
+        .offset=${this.offset}
+        .path=${this.path}
+      >
+        <table id="list" class="genericList">
+          <tbody>
+            <tr class="headerRow">
+              <th class="name topHeader">Plugin Name</th>
+              <th class="version topHeader">Version</th>
+              <th class="apiVersion topHeader">API Version</th>
+              <th class="status topHeader">Status</th>
+            </tr>
+            ${this.renderLoading()}
+          </tbody>
+          ${this.renderPluginListsTable()}
+        </table>
+      </gr-list-view>
+    `;
+  }
+
+  private renderLoading() {
+    if (!this.loading) return;
+
+    return html`
+      <tr id="loading" class="loadingMsg loading">
+        <td>Loading...</td>
+      </tr>
+    `;
+  }
+
+  private renderPluginListsTable() {
+    if (this.loading) return;
+
+    return html`
+      <tbody>
+        ${this.plugins
+          ?.slice(0, SHOWN_ITEMS_COUNT)
+          .map(plugin => this.renderPluginList(plugin))}
+      </tbody>
+    `;
+  }
+
+  private renderPluginList(plugin: PluginInfoWithName) {
+    return html`
+      <tr class="table">
+        <td class="name">
+          ${plugin.index_url
+            ? html`<a href=${this.computePluginUrl(plugin.index_url)}
+                >${plugin.id}</a
+              >`
+            : plugin.id}
+        </td>
+        <td class="version">
+          ${plugin.version
+            ? plugin.version
+            : html`<span class="placeholder">--</span>`}
+        </td>
+        <td class="apiVersion">
+          ${plugin.api_version
+            ? plugin.api_version
+            : html`<span class="placeholder">--</span>`}
+        </td>
+        <td class="status">
+          ${plugin.disabled === true ? 'Disabled' : 'Enabled'}
+        </td>
+      </tr>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+  }
+
+  // private but used in test
+  paramsChanged() {
+    this.loading = true;
+    this.filter = this.params?.filter ?? '';
+    this.offset = Number(this.params?.offset ?? 0);
+
+    return this.getPlugins(this.filter, this.pluginsPerPage, this.offset);
+  }
+
+  private getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
     const errFn: ErrorCallback = response => {
       firePageError(response);
     };
@@ -95,31 +163,21 @@
       .getPlugins(filter, pluginsPerPage, offset, errFn)
       .then(plugins => {
         if (!plugins) {
-          this._plugins = [];
+          this.plugins = [];
           return;
         }
-        this._plugins = Object.keys(plugins).map(key => {
+        this.plugins = Object.keys(plugins).map(key => {
           return {...plugins[key], name: key};
         });
-        this._loading = false;
+      })
+      .finally(() => {
+        this.loading = false;
       });
   }
 
-  _status(item: PluginInfo) {
-    return item.disabled === true ? 'Disabled' : 'Enabled';
-  }
-
-  _computePluginUrl(id: string) {
+  private computePluginUrl(id: string) {
     return getBaseUrl() + '/' + encodeURL(id, true);
   }
-
-  computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  computeShownItems(plugins: PluginInfoWithName[]) {
-    return plugins.slice(0, SHOWN_ITEMS_COUNT);
-  }
 }
 
 declare global {
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
deleted file mode 100644
index eeca478..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_html.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    .placeholder {
-      color: var(--deemphasized-text-color);
-    }
-  </style>
-  <gr-list-view
-    filter="[[_filter]]"
-    items-per-page="[[_pluginsPerPage]]"
-    items="[[_plugins]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    path="[[_path]]"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Plugin Name</th>
-          <th class="version topHeader">Version</th>
-          <th class="apiVersion topHeader">API Version</th>
-          <th class="status topHeader">Status</th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownPlugins]]">
-          <tr class="table">
-            <td class="name">
-              <template is="dom-if" if="[[item.index_url]]">
-                <a href$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
-              </template>
-              <template is="dom-if" if="[[!item.index_url]]">
-                [[item.id]]
-              </template>
-            </td>
-            <td class="version">
-              <template is="dom-if" if="[[item.version]]">
-                [[item.version]]
-              </template>
-              <template is="dom-if" if="[[!item.version]]">
-                <span class="placeholder">--</span>
-              </template>
-            </td>
-            <td class="apiVersion">
-              <template is="dom-if" if="[[item.api_version]]">
-                [[item.api_version]]
-              </template>
-              <template is="dom-if" if="[[!item.api_version]]">
-                <span class="placeholder">--</span>
-              </template>
-            </td>
-            <td class="status">[[_status(item)]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-`;
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
deleted file mode 100644
index 62c89b2..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
+++ /dev/null
@@ -1,171 +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-plugin-list.js';
-import 'lodash/lodash.js';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-plugin-list');
-
-let counter;
-const pluginGenerator = () => {
-  const plugin = {
-    id: `test${++counter}`,
-    disabled: false,
-  };
-
-  if (counter !== 2) {
-    plugin.index_url = `plugins/test${counter}/`;
-  }
-  if (counter !== 3) {
-    plugin.version = `version-${counter}`;
-  }
-  if (counter !== 4) {
-    plugin.api_version = `api-version-${counter}`;
-  }
-  return plugin;
-};
-
-suite('gr-plugin-list tests', () => {
-  let element;
-  let plugins;
-
-  let value;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    counter = 0;
-  });
-
-  suite('list with plugins', async () => {
-    setup(async () => {
-      plugins = _.times(26, pluginGenerator);
-      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
-      await element._paramsChanged(value);
-      await flush();
-    });
-
-    test('plugin in the list is formatted correctly', async () => {
-      await flush();
-      assert.equal(element._plugins[4].id, 'test5');
-      assert.equal(element._plugins[4].index_url, 'plugins/test5/');
-      assert.equal(element._plugins[4].version, 'version-5');
-      assert.equal(element._plugins[4].api_version, 'api-version-5');
-      assert.equal(element._plugins[4].disabled, false);
-    });
-
-    test('with and without urls', async () => {
-      await flush();
-      const names = element.root.querySelectorAll('.name');
-      assert.isOk(names[1].querySelector('a'));
-      assert.equal(names[1].querySelector('a').innerText, 'test1');
-      assert.isNotOk(names[2].querySelector('a'));
-      assert.equal(names[2].innerText, 'test2');
-    });
-
-    test('versions', async () => {
-      await flush();
-      const versions = element.root.querySelectorAll('.version');
-      assert.equal(versions[2].innerText, 'version-2');
-      assert.equal(versions[3].innerText, '--');
-    });
-
-    test('api versions', async () => {
-      await flush();
-      const apiVersions = element.root.querySelectorAll(
-          '.apiVersion');
-      assert.equal(apiVersions[3].innerText, 'api-version-3');
-      assert.equal(apiVersions[4].innerText, '--');
-    });
-
-    test('_shownPlugins', () => {
-      assert.equal(element._shownPlugins.length, 25);
-    });
-  });
-
-  suite('list with less then 26 plugins', () => {
-    setup(async () => {
-      plugins = _.times(25, pluginGenerator);
-      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
-      await element._paramsChanged(value);
-      await flush();
-    });
-
-    test('_shownPlugins', () => {
-      assert.equal(element._shownPlugins.length, 25);
-    });
-  });
-
-  suite('filter', () => {
-    test('_paramsChanged', async () => {
-      const getPluginsStub = stubRestApi('getPlugins');
-      getPluginsStub.returns(Promise.resolve(plugins));
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      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);
-    });
-  });
-
-  suite('loading', () => {
-    test('correct contents are displayed', async () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
-
-      element._loading = false;
-      element._plugins = _.times(25, pluginGenerator);
-
-      await flush();
-      assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', async () => {
-      const response = {status: 404};
-      stubRestApi('getPlugins').callsFake(
-          (filter, pluginsPerPage, opt_offset, errFn) => {
-            errFn(response);
-            return Promise.resolve(undefined);
-          });
-
-      const promise = mockPromise();
-      addListenerForTest(document, 'page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        promise.resolve();
-      });
-
-      const value = {
-        filter: 'test',
-        offset: 25,
-      };
-      await element._paramsChanged(value);
-      await promise;
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
new file mode 100644
index 0000000..4057e52
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
@@ -0,0 +1,411 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-plugin-list';
+import {GrPluginList, PluginInfoWithName} from './gr-plugin-list';
+import {
+  addListenerForTest,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {PluginInfo} from '../../../types/common';
+import {GerritView} from '../../../services/router/router-model';
+import {PageErrorEvent} from '../../../types/events';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
+
+function pluginGenerator(counter: number) {
+  const plugin: PluginInfo = {
+    id: `test${counter}`,
+    version: `version-${counter}`,
+    disabled: false,
+  };
+
+  if (counter !== 2) {
+    plugin.index_url = `plugins/test${counter}/`;
+  }
+  if (counter !== 4) {
+    plugin.api_version = `api-version-${counter}`;
+  }
+  return plugin;
+}
+
+function createPluginList(n: number) {
+  const plugins = [];
+  for (let i = 0; i < n; ++i) {
+    const plugin = pluginGenerator(i) as PluginInfoWithName;
+    plugin.name = `test${i}`;
+    plugins.push(plugin);
+  }
+  return plugins;
+}
+
+function createPluginObjectList(n: number) {
+  const plugins: {[pluginName: string]: PluginInfo} | undefined = {};
+  for (let i = 0; i < n; ++i) {
+    plugins[`test${i}`] = pluginGenerator(i);
+  }
+  return plugins;
+}
+
+suite('gr-plugin-list tests', () => {
+  let element: GrPluginList;
+  let plugins: {[pluginName: string]: PluginInfo} | undefined;
+
+  const value: AdminViewState = {
+    view: GerritView.ADMIN,
+    adminView: AdminChildView.PLUGINS,
+  };
+
+  setup(async () => {
+    element = await fixture(html`<gr-plugin-list></gr-plugin-list>`);
+  });
+
+  suite('list with plugins', async () => {
+    setup(async () => {
+      plugins = createPluginObjectList(26);
+      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
+      element.params = value;
+      await element.paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-list-view>
+            <table class="genericList" id="list">
+              <tbody>
+                <tr class="headerRow">
+                  <th class="name topHeader">Plugin Name</th>
+                  <th class="topHeader version">Version</th>
+                  <th class="apiVersion topHeader">API Version</th>
+                  <th class="status topHeader">Status</th>
+                </tr>
+              </tbody>
+              <tbody>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test0/"> test0 </a>
+                  </td>
+                  <td class="version">version-0</td>
+                  <td class="apiVersion">api-version-0</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test1/"> test1 </a>
+                  </td>
+                  <td class="version">version-1</td>
+                  <td class="apiVersion">api-version-1</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">test2</td>
+                  <td class="version">version-2</td>
+                  <td class="apiVersion">api-version-2</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test3/"> test3 </a>
+                  </td>
+                  <td class="version">version-3</td>
+                  <td class="apiVersion">api-version-3</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test4/"> test4 </a>
+                  </td>
+                  <td class="version">version-4</td>
+                  <td class="apiVersion">
+                    <span class="placeholder"> -- </span>
+                  </td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test5/"> test5 </a>
+                  </td>
+                  <td class="version">version-5</td>
+                  <td class="apiVersion">api-version-5</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test6/"> test6 </a>
+                  </td>
+                  <td class="version">version-6</td>
+                  <td class="apiVersion">api-version-6</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test7/"> test7 </a>
+                  </td>
+                  <td class="version">version-7</td>
+                  <td class="apiVersion">api-version-7</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test8/"> test8 </a>
+                  </td>
+                  <td class="version">version-8</td>
+                  <td class="apiVersion">api-version-8</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test9/"> test9 </a>
+                  </td>
+                  <td class="version">version-9</td>
+                  <td class="apiVersion">api-version-9</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test10/"> test10 </a>
+                  </td>
+                  <td class="version">version-10</td>
+                  <td class="apiVersion">api-version-10</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test11/"> test11 </a>
+                  </td>
+                  <td class="version">version-11</td>
+                  <td class="apiVersion">api-version-11</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test12/"> test12 </a>
+                  </td>
+                  <td class="version">version-12</td>
+                  <td class="apiVersion">api-version-12</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test13/"> test13 </a>
+                  </td>
+                  <td class="version">version-13</td>
+                  <td class="apiVersion">api-version-13</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test14/"> test14 </a>
+                  </td>
+                  <td class="version">version-14</td>
+                  <td class="apiVersion">api-version-14</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test15/"> test15 </a>
+                  </td>
+                  <td class="version">version-15</td>
+                  <td class="apiVersion">api-version-15</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test16/"> test16 </a>
+                  </td>
+                  <td class="version">version-16</td>
+                  <td class="apiVersion">api-version-16</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test17/"> test17 </a>
+                  </td>
+                  <td class="version">version-17</td>
+                  <td class="apiVersion">api-version-17</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test18/"> test18 </a>
+                  </td>
+                  <td class="version">version-18</td>
+                  <td class="apiVersion">api-version-18</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test19/"> test19 </a>
+                  </td>
+                  <td class="version">version-19</td>
+                  <td class="apiVersion">api-version-19</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test20/"> test20 </a>
+                  </td>
+                  <td class="version">version-20</td>
+                  <td class="apiVersion">api-version-20</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test21/"> test21 </a>
+                  </td>
+                  <td class="version">version-21</td>
+                  <td class="apiVersion">api-version-21</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test22/"> test22 </a>
+                  </td>
+                  <td class="version">version-22</td>
+                  <td class="apiVersion">api-version-22</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test23/"> test23 </a>
+                  </td>
+                  <td class="version">version-23</td>
+                  <td class="apiVersion">api-version-23</td>
+                  <td class="status">Enabled</td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/plugins/test24/"> test24 </a>
+                  </td>
+                  <td class="version">version-24</td>
+                  <td class="apiVersion">api-version-24</td>
+                  <td class="status">Enabled</td>
+                </tr>
+              </tbody>
+            </table>
+          </gr-list-view>
+        `
+      );
+    });
+
+    test('plugin in the list is formatted correctly', async () => {
+      await element.updateComplete;
+      assert.equal(element.plugins![5].id, 'test5');
+      assert.equal(element.plugins![5].index_url, 'plugins/test5/');
+      assert.equal(element.plugins![5].version, 'version-5');
+      assert.equal(element.plugins![5].api_version, 'api-version-5');
+      assert.equal(element.plugins![5].disabled, false);
+    });
+
+    test('with and without urls', async () => {
+      await element.updateComplete;
+      const names = queryAll<HTMLTableElement>(element, '.name');
+      assert.isOk(queryAndAssert<HTMLAnchorElement>(names[2], 'a'));
+      assert.equal(
+        queryAndAssert<HTMLAnchorElement>(names[2], 'a').innerText,
+        'test1'
+      );
+      assert.isNotOk(query(names[3], 'a'));
+      assert.equal(names[3].innerText, 'test2');
+    });
+
+    test('versions', async () => {
+      await element.updateComplete;
+      const versions = queryAll<HTMLTableElement>(element, '.version');
+      assert.equal(versions[3].innerText, 'version-2');
+    });
+
+    test('api versions', async () => {
+      await element.updateComplete;
+      const apiVersions = queryAll<HTMLTableElement>(element, '.apiVersion');
+      assert.equal(apiVersions[4].innerText, 'api-version-3');
+      assert.equal(apiVersions[5].innerText, '--');
+    });
+
+    test('plugins', () => {
+      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+  });
+
+  suite('list with less then 26 plugins', () => {
+    setup(async () => {
+      plugins = createPluginObjectList(25);
+      stubRestApi('getPlugins').returns(Promise.resolve(plugins));
+      element.params = value;
+      await element.paramsChanged();
+      await element.updateComplete;
+    });
+
+    test('plugins', () => {
+      assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+    });
+  });
+
+  suite('filter', () => {
+    test('paramsChanged', async () => {
+      const getPluginsStub = stubRestApi('getPlugins');
+      getPluginsStub.returns(Promise.resolve(plugins));
+      value.filter = 'test';
+      value.offset = 25;
+      element.params = value;
+      await element.paramsChanged();
+      assert.equal(getPluginsStub.lastCall.args[0], 'test');
+      assert.equal(getPluginsStub.lastCall.args[1], 25);
+      assert.equal(getPluginsStub.lastCall.args[2], 25);
+    });
+  });
+
+  suite('loading', () => {
+    test('correct contents are displayed', async () => {
+      assert.isTrue(element.loading);
+      assert.equal(
+        getComputedStyle(queryAndAssert<HTMLTableElement>(element, '#loading'))
+          .display,
+        'block'
+      );
+
+      element.loading = false;
+      element.plugins = createPluginList(25);
+
+      await element.updateComplete;
+      assert.isNotOk(query<HTMLTableElement>(element, '#loading'));
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', async () => {
+      const response = {status: 404} as Response;
+      stubRestApi('getPlugins').callsFake(
+        (_filter, _pluginsPerPage, _opt_offset, errFn) => {
+          if (errFn !== undefined) {
+            errFn(response);
+          }
+          return Promise.resolve(undefined);
+        }
+      );
+
+      const promise = mockPromise();
+      addListenerForTest(document, 'page-error', e => {
+        assert.deepEqual((e as PageErrorEvent).detail.response, response);
+        promise.resolve();
+      });
+
+      value.filter = 'test';
+      value.offset = 25;
+      element.params = value;
+      await element.paramsChanged();
+      await promise;
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
index 6136f1e1..e9396b9 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 /**
  * @fileoverview This file contains interfaces shared between gr-repo-access
@@ -77,6 +66,6 @@
 export interface NewlyAddedGroupInfo {
   name: string;
 }
-export type EditableProjectAccessGroups = {
+export type EditableRepoAccessGroups = {
   [uuid: string]: GroupInfo | NewlyAddedGroupInfo;
 };
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 56e981a..52e0b3f 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
@@ -1,31 +1,12 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
 import '../gr-access-section/gr-access-section';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-access_html';
-import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {singleDecodeURL} from '../../../utils/url-util';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {toSortedPermissionsArray} from '../../../utils/access-util';
-import {customElement, property} from '@polymer/decorators';
 import {
   RepoName,
   ProjectInfo,
@@ -34,7 +15,7 @@
   ProjectAccessInput,
   GitRef,
   UrlEncodedRepoName,
-  ProjectAccessGroups,
+  RepoAccessGroups,
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrAccessSection} from '../gr-access-section/gr-access-section';
@@ -49,107 +30,298 @@
   PrimitiveValue,
 } from './gr-repo-access-interfaces';
 import {firePageError, fireAlert} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {WebLinkInfo} from '../../../types/diff';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {createRepoUrl, RepoDetailView} from '../../../models/views/repo';
 
 const NOTHING_TO_SAVE = 'No changes to save.';
 
 const MAX_AUTOCOMPLETE_RESULTS = 50;
 
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-access': GrRepoAccess;
+  }
+}
+
 /**
  * Fired when save is a no-op
  *
  * @event show-alert
  */
 @customElement('gr-repo-access')
-export class GrRepoAccess extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRepoAccess extends LitElement {
+  @query('gr-access-section:last-of-type') accessSection?: GrAccessSection;
 
-  @property({type: String, observer: '_repoChanged'})
+  @property({type: String})
   repo?: RepoName;
 
-  @property({type: String})
-  path?: string;
+  // private but used in test
+  @state() canUpload?: boolean = false; // restAPI can return undefined
 
-  @property({type: Boolean})
-  _canUpload?: boolean = false; // restAPI can return undefined
+  // private but used in test
+  @state() inheritFromFilter?: RepoName;
 
-  @property({type: String})
-  _inheritFromFilter?: RepoName;
+  // private but used in test
+  @state() ownerOf?: GitRef[];
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  // private but used in test
+  @state() capabilities?: CapabilityInfoMap;
 
-  @property({type: Array})
-  _ownerOf?: GitRef[];
+  // private but used in test
+  @state() groups?: RepoAccessGroups;
 
-  @property({type: Object})
-  _capabilities?: CapabilityInfoMap;
+  // private but used in test
+  @state() inheritsFrom?: ProjectInfo;
 
-  @property({type: Object})
-  _groups?: ProjectAccessGroups;
+  // private but used in test
+  @state() labels?: LabelNameToLabelTypeInfoMap;
 
-  @property({type: Object})
-  _inheritsFrom?: ProjectInfo;
+  // private but used in test
+  @state() local?: EditableLocalAccessSectionInfo;
 
-  @property({type: Object})
-  _labels?: LabelNameToLabelTypeInfoMap;
+  // private but used in test
+  @state() editing = false;
 
-  @property({type: Object})
-  _local?: EditableLocalAccessSectionInfo;
+  // private but used in test
+  @state() modified = false;
 
-  @property({type: Boolean, observer: '_handleEditingChanged'})
-  _editing = false;
+  // private but used in test
+  @state() sections?: PermissionAccessSection[];
 
-  @property({type: Boolean})
-  _modified = false;
+  @state() private weblinks?: WebLinkInfo[];
 
-  @property({type: Array})
-  _sections?: PermissionAccessSection[];
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Array})
-  _weblinks?: WebLinkInfo[];
+  // private but used in the tests
+  originalInheritsFrom?: ProjectInfo;
 
-  @property({type: Boolean})
-  _loading = true;
+  private readonly query: AutocompleteQuery;
 
-  private originalInheritsFrom?: ProjectInfo;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly getNavigation = resolve(this, navigationToken);
 
   constructor() {
     super();
-    this._query = () => this._getInheritFromSuggestions();
+    this.query = () => this.getInheritFromSuggestions();
     this.addEventListener('access-modified', () =>
       this._handleAccessModified()
     );
   }
 
-  _handleAccessModified() {
-    this._modified = true;
+  static override get styles() {
+    return [
+      fontStyles,
+      menuPageStyles,
+      subpageStyles,
+      sharedStyles,
+      css`
+        gr-button,
+        #inheritsFrom,
+        #editInheritFromInput,
+        .editing #inheritFromName,
+        .weblinks,
+        .editing .invisible {
+          display: none;
+        }
+        #inheritsFrom.show {
+          display: flex;
+          min-height: 2em;
+          align-items: center;
+        }
+        .weblink {
+          margin-right: var(--spacing-xs);
+        }
+        gr-access-section {
+          margin-top: var(--spacing-l);
+        }
+        .weblinks.show,
+        .referenceContainer {
+          display: block;
+        }
+        .rightsText {
+          margin-right: var(--spacing-s);
+        }
+
+        .editing gr-button,
+        .admin #editBtn {
+          display: inline-block;
+          margin: var(--spacing-l) 0;
+        }
+        .editing #editInheritFromInput {
+          display: inline-block;
+        }
+      `,
+    ];
   }
 
-  _repoChanged(repo: RepoName) {
-    this._loading = true;
+  override render() {
+    return html`
+      <div class="main ${this.computeMainClass()}">
+        <div id="loading" class=${this.loading ? 'loading' : ''}>
+          Loading...
+        </div>
+        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
+          <h3
+            id="inheritsFrom"
+            class="heading-3 ${this.editing || this.inheritsFrom?.id?.length
+              ? 'show'
+              : ''}"
+          >
+            <span class="rightsText">Rights Inherit From</span>
+            <a
+              id="inheritFromName"
+              href=${this.computeParentHref()}
+              rel="noopener"
+            >
+              ${this.inheritsFrom?.name}</a
+            >
+            <gr-autocomplete
+              id="editInheritFromInput"
+              .text=${this.inheritFromFilter}
+              .query=${this.query}
+              @commit=${(e: ValueChangedEvent) => {
+                this.handleUpdateInheritFrom(e);
+              }}
+              @bind-value-changed=${(e: ValueChangedEvent) => {
+                this.handleUpdateInheritFrom(e);
+              }}
+              @text-changed=${(e: ValueChangedEvent) => {
+                this.handleEditInheritFromTextChanged(e);
+              }}
+            ></gr-autocomplete>
+          </h3>
+          <div class="weblinks ${this.weblinks?.length ? 'show' : ''}">
+            History:
+            ${this.weblinks?.map(webLink => this.renderWebLinks(webLink))}
+          </div>
+          ${this.sections?.map((section, index) =>
+            this.renderPermissionSections(section, index)
+          )}
+          <div class="referenceContainer">
+            <gr-button
+              id="addReferenceBtn"
+              @click=${() => this.handleCreateSection()}
+              >Add Reference</gr-button
+            >
+          </div>
+          <div>
+            <gr-button
+              id="editBtn"
+              @click=${() => {
+                this.handleEdit();
+              }}
+              >${this.editing ? 'Cancel' : 'Edit'}</gr-button
+            >
+            <gr-button
+              id="saveBtn"
+              class=${this.ownerOf && this.ownerOf.length === 0
+                ? 'invisible'
+                : ''}
+              primary
+              ?disabled=${!this.modified}
+              @click=${this.handleSave}
+              >Save</gr-button
+            >
+            <gr-button
+              id="saveReviewBtn"
+              class=${!this.canUpload ? 'invisible' : ''}
+              primary
+              ?disabled=${!this.modified}
+              @click=${this.handleSaveForReview}
+              >Save for review</gr-button
+            >
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderWebLinks(webLink: WebLinkInfo) {
+    return html`
+      <a
+        class="weblink"
+        href=${webLink.url}
+        rel="noopener"
+        target=${ifDefined(webLink.target)}
+      >
+        ${webLink.name}
+      </a>
+    `;
+  }
+
+  private renderPermissionSections(
+    section: PermissionAccessSection,
+    index: number
+  ) {
+    return html`
+      <gr-access-section
+        .capabilities=${this.capabilities}
+        .section=${section}
+        .labels=${this.labels}
+        .canUpload=${this.canUpload}
+        .editing=${this.editing}
+        .ownerOf=${this.ownerOf}
+        .groups=${this.groups}
+        .repo=${this.repo}
+        @added-section-removed=${() => {
+          this.handleAddedSectionRemoved(index);
+        }}
+        @section-changed=${(e: ValueChangedEvent<PermissionAccessSection>) => {
+          this.handleAccessSectionChanged(e, index);
+        }}
+      ></gr-access-section>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this._repoChanged(this.repo);
+    }
+
+    if (changedProperties.has('editing')) {
+      this.handleEditingChanged(changedProperties.get('editing') as boolean);
+      this.requestUpdate();
+    }
+  }
+
+  _handleAccessModified() {
+    this.modified = true;
+  }
+
+  _repoChanged(repo?: RepoName) {
+    this.loading = true;
 
     if (!repo) {
       return Promise.resolve();
     }
 
-    return this._reload(repo);
+    return this.reload(repo);
   }
 
-  _reload(repo: RepoName) {
+  private reload(repo: RepoName) {
     const errFn = (response?: Response | null) => {
       firePageError(response);
     };
 
-    this._editing = false;
+    this.editing = false;
 
-    // Always reset sections when a project changes.
-    this._sections = [];
+    // Always reset sections when a repo changes.
+    this.sections = [];
     const sectionsPromises = this.restApiService
       .getRepoAccessRights(repo, errFn)
       .then(res => {
@@ -161,26 +333,26 @@
         // the ones data bound to gr-autocomplete, so the original value
         // can be restored if the user cancels.
         if (res.inherits_from) {
-          this._inheritsFrom = {...res.inherits_from};
+          this.inheritsFrom = {...res.inherits_from};
           this.originalInheritsFrom = {...res.inherits_from};
         } else {
-          this._inheritsFrom = undefined;
+          this.inheritsFrom = undefined;
           this.originalInheritsFrom = undefined;
         }
         // Initialize the filter value so when the user clicks edit, the
         // current value appears. If there is no parent repo, it is
         // initialized as an empty string.
-        this._inheritFromFilter = res.inherits_from
+        this.inheritFromFilter = res.inherits_from
           ? res.inherits_from.name
           : ('' as RepoName);
         // 'as EditableLocalAccessSectionInfo' is required because res.local
         // type doesn't have index signature
-        this._local = res.local as EditableLocalAccessSectionInfo;
-        this._groups = res.groups;
-        this._weblinks = res.config_web_links || [];
-        this._canUpload = res.can_upload;
-        this._ownerOf = res.owner_of || [];
-        return toSortedPermissionsArray(this._local);
+        this.local = res.local as EditableLocalAccessSectionInfo;
+        this.groups = res.groups;
+        this.weblinks = res.config_web_links || [];
+        this.canUpload = res.can_upload;
+        this.ownerOf = res.owner_of || [];
+        return toSortedPermissionsArray(this.local);
       });
 
     const capabilitiesPromises = this.restApiService
@@ -208,101 +380,90 @@
       capabilitiesPromises,
       labelsPromises,
     ]).then(([sections, capabilities, labels]) => {
-      this._capabilities = capabilities;
-      this._labels = labels;
-      this._sections = sections;
-      this._loading = false;
+      this.capabilities = capabilities;
+      this.labels = labels;
+      this.sections = sections;
+      this.loading = false;
     });
   }
 
-  _handleUpdateInheritFrom(e: CustomEvent<{value: string}>) {
-    this._inheritsFrom = {
-      ...(this._inheritsFrom ?? {}),
+  // private but used in test
+  handleUpdateInheritFrom(e: ValueChangedEvent) {
+    this.inheritsFrom = {
+      ...(this.inheritsFrom ?? {}),
       id: e.detail.value as UrlEncodedRepoName,
-      name: this._inheritFromFilter,
+      name: this.inheritFromFilter,
     };
     this._handleAccessModified();
   }
 
-  _getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
+  private getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getRepos(this._inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
+      .getRepos(
+        this.inheritFromFilter,
+        MAX_AUTOCOMPLETE_RESULTS,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
-        const projects: AutocompleteSuggestion[] = [];
+        const repos: AutocompleteSuggestion[] = [];
         if (!response) {
-          return projects;
+          return repos;
         }
         for (const item of response) {
-          projects.push({
+          repos.push({
             name: item.name,
             value: item.id,
           });
         }
-        return projects;
+        return repos;
       });
   }
 
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
+  private handleEdit() {
+    this.editing = !this.editing;
   }
 
-  _handleEdit() {
-    this._editing = !this._editing;
-  }
-
-  _editOrCancel(editing: boolean) {
-    return editing ? 'Cancel' : 'Edit';
-  }
-
-  _computeWebLinkClass(weblinks?: string[]) {
-    return weblinks && weblinks.length ? 'show' : '';
-  }
-
-  _computeShowInherit(inheritsFrom?: ProjectInfo) {
-    return inheritsFrom?.id?.length ? 'show' : '';
-  }
-
-  // TODO(TS): Unclear what is model here, provide a better explanation
-  _handleAddedSectionRemoved(e: CustomEvent & {model: {index: string}}) {
-    if (!this._sections) {
-      return;
-    }
-    const index = Number(e.model.index);
-    if (isNaN(index)) {
-      return;
-    }
-    this._sections = this._sections
+  // private but used in tests
+  handleAddedSectionRemoved(index: number) {
+    if (!this.sections) return;
+    assertIsDefined(this.local, 'local');
+    delete this.local[this.sections[index].id];
+    this.sections = this.sections
       .slice(0, index)
-      .concat(this._sections.slice(index + 1, this._sections.length));
+      .concat(this.sections.slice(index + 1, this.sections.length));
   }
 
-  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+  private handleEditingChanged(editingOld: boolean) {
     // Ignore when editing gets set initially.
-    if (!editingOld || editing) {
+    if (!editingOld || this.editing) {
       return;
     }
     // Remove any unsaved but added refs.
-    if (this._sections) {
-      this._sections = this._sections.filter(p => !p.value.added);
+    if (this.sections) {
+      this.sections = this.sections.filter(p => !p.value.added);
     }
     // Restore inheritFrom.
-    if (this._inheritsFrom) {
-      this._inheritsFrom = this.originalInheritsFrom
+    if (this.inheritsFrom) {
+      this.inheritsFrom = this.originalInheritsFrom
         ? {...this.originalInheritsFrom}
         : undefined;
-      this._inheritFromFilter = this.originalInheritsFrom?.name;
+      this.inheritFromFilter = this.originalInheritsFrom?.name;
     }
-    if (!this._local) {
+    if (!this.local) {
       return;
     }
-    for (const key of Object.keys(this._local)) {
-      if (this._local[key].added) {
-        delete this._local[key];
+    for (const key of Object.keys(this.local)) {
+      if (this.local[key].added) {
+        delete this.local[key];
       }
     }
   }
 
-  _updateRemoveObj(addRemoveObj: {remove: PropertyTreeNode}, path: string[]) {
+  private updateRemoveObj(
+    addRemoveObj: {remove: PropertyTreeNode},
+    path: string[]
+  ) {
     let curPos: PropertyTreeNode = addRemoveObj.remove;
     for (const item of path) {
       if (!curPos[item]) {
@@ -326,7 +487,7 @@
     return addRemoveObj;
   }
 
-  _updateAddObj(
+  private updateAddObj(
     addRemoveObj: {add: PropertyTreeNode},
     path: string[],
     value: PropertyTreeNode | PrimitiveValue
@@ -350,8 +511,10 @@
 
   /**
    * Used to recursively remove any objects with a 'deleted' bit.
+   *
+   * private but used in test
    */
-  _recursivelyRemoveDeleted(obj?: PropertyTreeNode) {
+  recursivelyRemoveDeleted(obj?: PropertyTreeNode) {
     if (!obj) return;
     for (const k of Object.keys(obj)) {
       const node = obj[k];
@@ -360,12 +523,13 @@
           delete obj[k];
           return;
         }
-        this._recursivelyRemoveDeleted(node);
+        this.recursivelyRemoveDeleted(node);
       }
     }
   }
 
-  _recursivelyUpdateAddRemoveObj(
+  // private but used in test
+  recursivelyUpdateAddRemoveObj(
     obj: PropertyTreeNode | undefined,
     addRemoveObj: {
       add: PropertyTreeNode;
@@ -380,36 +544,36 @@
         const updatedId = node.updatedId;
         const ref = updatedId ? updatedId : k;
         if (node.deleted) {
-          this._updateRemoveObj(addRemoveObj, path.concat(k));
+          this.updateRemoveObj(addRemoveObj, path.concat(k));
           continue;
         } else if (node.modified) {
-          this._updateRemoveObj(addRemoveObj, path.concat(k));
-          this._updateAddObj(addRemoveObj, path.concat(ref), node);
+          this.updateRemoveObj(addRemoveObj, path.concat(k));
+          this.updateAddObj(addRemoveObj, path.concat(ref), node);
           /* Special case for ref changes because they need to be added and
-           removed in a different way. The new ref needs to include all
-           changes but also the initial state. To do this, instead of
-           continuing with the same recursion, just remove anything that is
-           deleted in the current state. */
+          removed in a different way. The new ref needs to include all
+          changes but also the initial state. To do this, instead of
+          continuing with the same recursion, just remove anything that is
+          deleted in the current state. */
           if (updatedId && updatedId !== k) {
-            this._recursivelyRemoveDeleted(
+            this.recursivelyRemoveDeleted(
               addRemoveObj.add[updatedId] as PropertyTreeNode
             );
           }
           continue;
         } else if (node.added) {
-          this._updateAddObj(addRemoveObj, path.concat(ref), node);
+          this.updateAddObj(addRemoveObj, path.concat(ref), node);
           /**
            * As add / delete both can happen in the new section,
            * so here to make sure it will remove the deleted ones.
            *
            * @see Issue 11339
            */
-          this._recursivelyRemoveDeleted(
+          this.recursivelyRemoveDeleted(
             addRemoveObj.add[k] as PropertyTreeNode
           );
           continue;
         }
-        this._recursivelyUpdateAddRemoveObj(node, addRemoveObj, path.concat(k));
+        this.recursivelyUpdateAddRemoveObj(node, addRemoveObj, path.concat(k));
       }
     }
   }
@@ -417,8 +581,10 @@
   /**
    * Returns an object formatted for saving or submitting access changes for
    * review
+   *
+   * private but used in test
    */
-  _computeAddAndRemove() {
+  computeAddAndRemove() {
     const addRemoveObj: {
       add: PropertyTreeNode;
       remove: PropertyTreeNode;
@@ -431,8 +597,8 @@
     const originalInheritsFromId = this.originalInheritsFrom
       ? singleDecodeURL(this.originalInheritsFrom.id)
       : undefined;
-    const inheritsFromId = this._inheritsFrom
-      ? singleDecodeURL(this._inheritsFrom.id)
+    const inheritsFromId = this.inheritsFrom
+      ? singleDecodeURL(this.inheritsFrom.id)
       : undefined;
 
     const inheritFromChanged =
@@ -441,12 +607,12 @@
       // Inherit from added (did not have one initially);
       (!originalInheritsFromId && inheritsFromId);
 
-    if (!this._local) {
+    if (!this.local) {
       return addRemoveObj;
     }
 
-    this._recursivelyUpdateAddRemoveObj(
-      this._local as unknown as PropertyTreeNode,
+    this.recursivelyUpdateAddRemoveObj(
+      this.local as unknown as PropertyTreeNode,
       addRemoveObj
     );
 
@@ -456,30 +622,25 @@
     return addRemoveObj;
   }
 
-  _handleCreateSection() {
-    if (!this._local) {
-      return;
-    }
+  private handleCreateSection() {
+    if (!this.local) return;
     let newRef = 'refs/for/*';
     // Avoid using an already used key for the placeholder, since it
     // immediately gets added to an object.
-    while (this._local[newRef]) {
+    while (this.local[newRef]) {
       newRef = `${newRef}*`;
     }
     const section = {permissions: {}, added: true};
-    this.push('_sections', {id: newRef, value: section});
-    this.set(['_local', newRef], section);
-    flush();
+    this.sections!.push({id: newRef as GitRef, value: section});
+    this.local[newRef] = section;
+    this.requestUpdate();
+    assertIsDefined(this.accessSection, 'accessSection');
     // Template already instantiated at this point
-    (
-      this.root!.querySelector(
-        'gr-access-section:last-of-type'
-      ) as GrAccessSection
-    ).editReference();
+    this.accessSection.editReference();
   }
 
-  _getObjforSave(): ProjectAccessInput | undefined {
-    const addRemoveObj = this._computeAddAndRemove();
+  private getObjforSave(): ProjectAccessInput | undefined {
+    const addRemoveObj = this.computeAddAndRemove();
     // If there are no changes, don't actually save.
     if (
       !Object.keys(addRemoveObj.add).length &&
@@ -499,8 +660,9 @@
     return obj;
   }
 
-  _handleSave(e: Event) {
-    const obj = this._getObjforSave();
+  // private but used in test
+  handleSave(e: Event) {
+    const obj = this.getObjforSave();
     if (!obj) {
       return;
     }
@@ -515,18 +677,19 @@
     return this.restApiService
       .setRepoAccessRights(repo, obj)
       .then(() => {
-        this._reload(repo);
+        this.reload(repo);
       })
       .finally(() => {
-        this._modified = false;
+        this.modified = false;
         if (button) {
           button.loading = false;
         }
       });
   }
 
-  _handleSaveForReview(e: Event) {
-    const obj = this._getObjforSave();
+  // private but used in test
+  handleSaveForReview(e: Event) {
+    const obj = this.getObjforSave();
     if (!obj) {
       return;
     }
@@ -540,46 +703,45 @@
     return this.restApiService
       .setRepoAccessRightsForReview(this.repo, obj)
       .then(change => {
-        GerritNav.navigateToChange(change);
+        this.getNavigation().setUrl(createChangeUrl({change}));
       })
       .finally(() => {
-        this._modified = false;
+        this.modified = false;
         if (button) {
           button.loading = false;
         }
       });
   }
 
-  _computeSaveReviewBtnClass(canUpload?: boolean) {
-    return !canUpload ? 'invisible' : '';
-  }
-
-  _computeSaveBtnClass(ownerOf?: GitRef[]) {
-    return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
-  }
-
-  _computeMainClass(
-    ownerOf: GitRef[] | undefined,
-    canUpload: boolean,
-    editing: boolean
-  ) {
+  // private but used in test
+  computeMainClass() {
     const classList = [];
-    if ((ownerOf && ownerOf.length > 0) || canUpload) {
+    if ((this.ownerOf && this.ownerOf.length > 0) || this.canUpload) {
       classList.push('admin');
     }
-    if (editing) {
+    if (this.editing) {
       classList.push('editing');
     }
     return classList.join(' ');
   }
 
-  _computeParentHref(repoName: RepoName) {
-    return getBaseUrl() + `/admin/repos/${encodeURL(repoName, true)},access`;
+  computeParentHref() {
+    if (!this.inheritsFrom?.name) return '';
+    return createRepoUrl({
+      repo: this.inheritsFrom.name,
+      detail: RepoDetailView.ACCESS,
+    });
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-repo-access': GrRepoAccess;
+  private handleEditInheritFromTextChanged(e: ValueChangedEvent) {
+    this.inheritFromFilter = e.detail.value as RepoName;
+  }
+
+  private handleAccessSectionChanged(
+    e: ValueChangedEvent<PermissionAccessSection>,
+    index: number
+  ) {
+    this.sections![index] = e.detail.value;
+    this.requestUpdate();
   }
 }
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
deleted file mode 100644
index 65f0564..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_html.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    gr-button,
-    #inheritsFrom,
-    #editInheritFromInput,
-    .editing #inheritFromName,
-    .weblinks,
-    .editing .invisible {
-      display: none;
-    }
-    #inheritsFrom.show {
-      display: flex;
-      min-height: 2em;
-      align-items: center;
-    }
-    .weblink {
-      margin-right: var(--spacing-xs);
-    }
-    gr-access-section {
-      margin-top: var(--spacing-l);
-    }
-    .weblinks.show,
-    .referenceContainer {
-      display: block;
-    }
-    .rightsText {
-      margin-right: var(--spacing-s);
-    }
-
-    .editing gr-button,
-    .admin #editBtn {
-      display: inline-block;
-      margin: var(--spacing-l) 0;
-    }
-    .editing #editInheritFromInput {
-      display: inline-block;
-    }
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class$="main [[_computeMainClass(_ownerOf, _canUpload, _editing)]]">
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h3
-        id="inheritsFrom"
-        class$="heading-3 [[_computeShowInherit(_inheritsFrom)]]"
-      >
-        <span class="rightsText">Rights Inherit From</span>
-        <a
-          href$="[[_computeParentHref(_inheritsFrom.name)]]"
-          rel="noopener"
-          id="inheritFromName"
-        >
-          [[_inheritsFrom.name]]</a
-        >
-        <gr-autocomplete
-          id="editInheritFromInput"
-          text="{{_inheritFromFilter}}"
-          query="[[_query]]"
-          on-commit="_handleUpdateInheritFrom"
-          on-bind-value-changed="_handleUpdateInheritFrom"
-        ></gr-autocomplete>
-      </h3>
-      <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
-        History:
-        <template is="dom-repeat" items="[[_weblinks]]" as="link">
-          <a
-            href="[[link.url]]"
-            class="weblink"
-            rel="noopener"
-            target="[[link.target]]"
-          >
-            [[link.name]]
-          </a>
-        </template>
-      </div>
-      <template
-        is="dom-repeat"
-        items="{{_sections}}"
-        initial-count="5"
-        target-framerate="60"
-        as="section"
-      >
-        <gr-access-section
-          capabilities="[[_capabilities]]"
-          section="{{section}}"
-          labels="[[_labels]]"
-          can-upload="[[_canUpload]]"
-          editing="[[_editing]]"
-          owner-of="[[_ownerOf]]"
-          groups="[[_groups]]"
-          on-added-section-removed="_handleAddedSectionRemoved"
-        ></gr-access-section>
-      </template>
-      <div class="referenceContainer">
-        <gr-button id="addReferenceBtn" on-click="_handleCreateSection"
-          >Add Reference</gr-button
-        >
-      </div>
-      <div>
-        <gr-button id="editBtn" on-click="_handleEdit"
-          >[[_editOrCancel(_editing)]]</gr-button
-        >
-        <gr-button
-          id="saveBtn"
-          primary=""
-          class$="[[_computeSaveBtnClass(_ownerOf)]]"
-          on-click="_handleSave"
-          disabled="[[!_modified]]"
-          >Save</gr-button
-        >
-        <gr-button
-          id="saveReviewBtn"
-          primary=""
-          class$="[[_computeSaveReviewBtnClass(_canUpload)]]"
-          on-click="_handleSaveForReview"
-          disabled="[[!_modified]]"
-          >Save for review</gr-button
-        >
-      </div>
-    </div>
-  </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
deleted file mode 100644
index 1ccfd5e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
+++ /dev/null
@@ -1,1287 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-repo-access.js';
-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,
-  mockPromise,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-repo-access');
-
-suite('gr-repo-access tests', () => {
-  let element;
-
-  let repoStub;
-
-  const accessRes = {
-    local: {
-      'refs/*': {
-        permissions: {
-          owner: {
-            rules: {
-              234: {action: 'ALLOW'},
-              123: {action: 'DENY'},
-            },
-          },
-          read: {
-            rules: {
-              234: {action: 'ALLOW'},
-            },
-          },
-        },
-      },
-    },
-    groups: {
-      Administrators: {
-        name: 'Administrators',
-      },
-      Maintainers: {
-        name: 'Maintainers',
-      },
-    },
-    config_web_links: [{
-      name: 'gitiles',
-      target: '_blank',
-      url: 'https://my/site/+log/123/project.config',
-    }],
-    can_upload: true,
-  };
-  const accessRes2 = {
-    local: {
-      GLOBAL_CAPABILITIES: {
-        permissions: {
-          accessDatabase: {
-            rules: {
-              group1: {
-                action: 'ALLOW',
-              },
-            },
-          },
-        },
-      },
-    },
-  };
-  const repoRes = {
-    labels: {
-      'Code-Review': {
-        values: {
-          ' 0': 'No score',
-          '-1': 'I would prefer this is not merged as is',
-          '-2': 'This shall not be merged',
-          '+1': 'Looks good to me, but someone else must approve',
-          '+2': 'Looks good to me, approved',
-        },
-      },
-    },
-  };
-  const capabilitiesRes = {
-    accessDatabase: {
-      id: 'accessDatabase',
-      name: 'Access Database',
-    },
-    createAccount: {
-      id: 'createAccount',
-      name: 'Create Account',
-    },
-  };
-  setup(async () => {
-    element = basicFixture.instantiate();
-    stubRestApi('getAccount').returns(Promise.resolve(null));
-    repoStub = stubRestApi('getRepo').returns(Promise.resolve(repoRes));
-    element._loading = false;
-    element._ownerOf = [];
-    element._canUpload = false;
-    await flush();
-  });
-
-  test('_repoChanged called when repo name changes', async () => {
-    sinon.stub(element, '_repoChanged');
-    element.repo = 'New Repo';
-    await flush();
-    assert.isTrue(element._repoChanged.called);
-  });
-
-  test('_repoChanged', async () => {
-    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 = stubRestApi(
-        'getCapabilities');
-    capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
-
-    await element._repoChanged('New Repo');
-    assert.isTrue(accessStub.called);
-    assert.isTrue(capabilitiesStub.called);
-    assert.isTrue(repoStub.called);
-    assert.isNotOk(element._inheritsFrom);
-    assert.deepEqual(element._local, accessRes.local);
-    assert.deepEqual(element._sections,
-        toSortedPermissionsArray(accessRes.local));
-    assert.deepEqual(element._labels, repoRes.labels);
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.weblinks')).display,
-    'block');
-
-    await element._repoChanged('Another New Repo');
-    assert.deepEqual(element._sections,
-        toSortedPermissionsArray(accessRes2.local));
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.weblinks')).display,
-    'none');
-  });
-
-  test('_repoChanged when repo changes to undefined returns', async () => {
-    const capabilitiesRes = {
-      accessDatabase: {
-        id: 'accessDatabase',
-        name: 'Access Database',
-      },
-    };
-    const accessStub = stubRestApi('getRepoAccessRights')
-        .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
-    const capabilitiesStub = stubRestApi(
-        'getCapabilities').returns(Promise.resolve(capabilitiesRes));
-
-    await element._repoChanged();
-    assert.isFalse(accessStub.called);
-    assert.isFalse(capabilitiesStub.called);
-    assert.isFalse(repoStub.called);
-  });
-
-  test('_computeParentHref', () => {
-    const repoName = 'test-repo';
-    assert.equal(element._computeParentHref(repoName),
-        '/admin/repos/test-repo,access');
-  });
-
-  test('_computeMainClass', () => {
-    let ownerOf = ['refs/*'];
-    const editing = true;
-    const canUpload = false;
-    assert.equal(element._computeMainClass(ownerOf, canUpload), 'admin');
-    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-        'admin editing');
-    ownerOf = [];
-    assert.equal(element._computeMainClass(ownerOf, canUpload), '');
-    assert.equal(element._computeMainClass(ownerOf, canUpload, editing),
-        'editing');
-  });
-
-  test('inherit section', async () => {
-    element._local = {};
-    element._ownerOf = [];
-    sinon.stub(element, '_computeParentHref');
-    await flush();
-
-    // Nothing should appear when no inherit from and not in edit mode.
-    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    // The autocomplete should be hidden, and the link should be  displayed.
-    assert.isFalse(element._computeParentHref.called);
-    // When in edit mode, the autocomplete should appear.
-    element._editing = true;
-    // When editing, the autocomplete should still not be shown.
-    assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
-
-    element._editing = false;
-    element._inheritsFrom = {
-      id: '1234',
-      name: 'another-repo',
-    };
-    await flush();
-
-    // When there is a parent project, the link should be displayed.
-    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
-        'none');
-    assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
-        'none');
-    assert.isTrue(element._computeParentHref.called);
-    element._editing = true;
-    // When editing, the autocomplete should be shown.
-    assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
-    assert.equal(getComputedStyle(element.$.inheritFromName).display, 'none');
-    assert.notEqual(getComputedStyle(element.$.editInheritFromInput).display,
-        'none');
-  });
-
-  test('_handleUpdateInheritFrom', async () => {
-    element._inheritFromFilter = 'foo bar baz';
-    element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
-    await flush();
-    assert.isOk(element._inheritsFrom);
-    assert.equal(element._inheritsFrom.id, 'abc+123');
-    assert.equal(element._inheritsFrom.name, 'foo bar baz');
-  });
-
-  test('_computeLoadingClass', () => {
-    assert.equal(element._computeLoadingClass(true), 'loading');
-    assert.equal(element._computeLoadingClass(false), '');
-  });
-
-  test('fires page-error', async () => {
-    const response = {status: 404};
-
-    stubRestApi('getRepoAccessRights').callsFake((repoName, errFn) => {
-      errFn(response);
-      return Promise.resolve(undefined);
-    });
-
-    const promise = mockPromise();
-    addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      promise.resolve();
-    });
-
-    element.repo = 'test';
-    await promise;
-  });
-
-  suite('with defined sections', () => {
-    const testEditSaveCancelBtns = async (
-        shouldShowSave,
-        shouldShowSaveReview
-    ) => {
-      // Edit button is visible and Save button is hidden.
-      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
-      assert.equal(element.$.editBtn.innerText, 'EDIT');
-      assert.equal(
-          getComputedStyle(element.$.editInheritFromInput).display,
-          'none'
-      );
-      element._inheritsFrom = {
-        id: 'test-project',
-      };
-      await flush();
-      assert.equal(
-          getComputedStyle(
-              element.shadowRoot.querySelector('#editInheritFromInput')
-          ).display,
-          'none'
-      );
-
-      MockInteractions.tap(element.$.editBtn);
-      await flush();
-
-      // Edit button changes to Cancel button, and Save button is visible but
-      // disabled.
-      assert.equal(element.$.editBtn.innerText, 'CANCEL');
-      if (shouldShowSaveReview) {
-        assert.notEqual(
-            getComputedStyle(element.$.saveReviewBtn).display,
-            'none'
-        );
-        assert.isTrue(element.$.saveReviewBtn.disabled);
-      }
-      if (shouldShowSave) {
-        assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
-        assert.isTrue(element.$.saveBtn.disabled);
-      }
-      assert.notEqual(
-          getComputedStyle(
-              element.shadowRoot.querySelector('#editInheritFromInput')
-          ).display,
-          'none'
-      );
-
-      // Save button should be enabled after access is modified
-      element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            composed: true,
-            bubbles: true,
-          })
-      );
-      if (shouldShowSaveReview) {
-        assert.isFalse(element.$.saveReviewBtn.disabled);
-      }
-      if (shouldShowSave) {
-        assert.isFalse(element.$.saveBtn.disabled);
-      }
-    };
-
-    setup(async () => {
-      // Create deep copies of these objects so the originals are not modified
-      // by any tests.
-      element._local = JSON.parse(JSON.stringify(accessRes.local));
-      element._ownerOf = [];
-      element._sections = toSortedPermissionsArray(element._local);
-      element._groups = JSON.parse(JSON.stringify(accessRes.groups));
-      element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
-      element._labels = JSON.parse(JSON.stringify(repoRes.labels));
-      await flush();
-    });
-
-    test('removing an added section', async () => {
-      element.editing = true;
-      await flush();
-      assert.equal(element._sections.length, 1);
-      element.shadowRoot
-          .querySelector('gr-access-section').dispatchEvent(
-              new CustomEvent('added-section-removed', {
-                composed: true, bubbles: true,
-              }));
-      await flush();
-      assert.equal(element._sections.length, 0);
-    });
-
-    test('button visibility for non ref owner', () => {
-      assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
-    });
-
-    test('button visibility for non ref owner with upload privilege',
-        async () => {
-          element._canUpload = true;
-          await flush();
-          testEditSaveCancelBtns(false, true);
-        });
-
-    test('button visibility for ref owner', async () => {
-      element._ownerOf = ['refs/for/*'];
-      await flush();
-      testEditSaveCancelBtns(true, false);
-    });
-
-    test('button visibility for ref owner and upload', async () => {
-      element._ownerOf = ['refs/for/*'];
-      element._canUpload = true;
-      await flush();
-      testEditSaveCancelBtns(true, false);
-    });
-
-    test('_handleAccessModified called with event fired', async () => {
-      sinon.spy(element, '_handleAccessModified');
-      element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            composed: true, bubbles: true,
-          }));
-      await flush();
-      assert.isTrue(element._handleAccessModified.called);
-    });
-
-    test('_handleAccessModified called when parent changes', async () => {
-      element._inheritsFrom = {
-        id: 'test-project',
-      };
-      await flush();
-      element.shadowRoot.querySelector('#editInheritFromInput').dispatchEvent(
-          new CustomEvent('commit', {
-            detail: {},
-            composed: true, bubbles: true,
-          }));
-      sinon.spy(element, '_handleAccessModified');
-      element.dispatchEvent(
-          new CustomEvent('access-modified', {
-            detail: {},
-            composed: true, bubbles: true,
-          }));
-      await flush();
-      assert.isTrue(element._handleAccessModified.called);
-    });
-
-    test('_handleSaveForReview', async () => {
-      const saveStub =
-          stubRestApi('setRepoAccessRightsForReview');
-      sinon.stub(element, '_computeAddAndRemove').returns({
-        add: {},
-        remove: {},
-      });
-      element._handleSaveForReview();
-      await flush();
-      assert.isFalse(saveStub.called);
-    });
-
-    test('_recursivelyRemoveDeleted', () => {
-      const obj = {
-        'refs/*': {
-          permissions: {
-            owner: {
-              rules: {
-                234: {action: 'ALLOW'},
-                123: {action: 'DENY', deleted: true},
-              },
-            },
-            read: {
-              deleted: true,
-              rules: {
-                234: {action: 'ALLOW'},
-              },
-            },
-          },
-        },
-      };
-      const expectedResult = {
-        'refs/*': {
-          permissions: {
-            owner: {
-              rules: {
-                234: {action: 'ALLOW'},
-              },
-            },
-          },
-        },
-      };
-      element._recursivelyRemoveDeleted(obj);
-      assert.deepEqual(obj, expectedResult);
-    });
-
-    test('_recursivelyUpdateAddRemoveObj on new added section', () => {
-      const obj = {
-        'refs/for/*': {
-          permissions: {
-            'label-Code-Review': {
-              rules: {
-                e798fed07afbc9173a587f876ef8760c78d240c1: {
-                  min: -2,
-                  max: 2,
-                  action: 'ALLOW',
-                  added: true,
-                },
-              },
-              added: true,
-              label: 'Code-Review',
-            },
-            'labelAs-Code-Review': {
-              rules: {
-                'ldap:gerritcodereview-eng': {
-                  min: -2,
-                  max: 2,
-                  action: 'ALLOW',
-                  added: true,
-                  deleted: true,
-                },
-              },
-              added: true,
-              label: 'Code-Review',
-            },
-          },
-          added: true,
-        },
-      };
-
-      const expectedResult = {
-        add: {
-          'refs/for/*': {
-            permissions: {
-              'label-Code-Review': {
-                rules: {
-                  e798fed07afbc9173a587f876ef8760c78d240c1: {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-                added: true,
-                label: 'Code-Review',
-              },
-              'labelAs-Code-Review': {
-                rules: {},
-                added: true,
-                label: 'Code-Review',
-              },
-            },
-            added: true,
-          },
-        },
-        remove: {},
-      };
-      const updateObj = {add: {}, remove: {}};
-      element._recursivelyUpdateAddRemoveObj(obj, updateObj);
-      assert.deepEqual(updateObj, expectedResult);
-    });
-
-    test('_handleSaveForReview with no changes', () => {
-      assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
-    });
-
-    test('_handleSaveForReview parent change', async () => {
-      element._inheritsFrom = {
-        id: 'test-project',
-      };
-      element._originalInheritsFrom = {
-        id: 'test-project-original',
-      };
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), {
-        parent: 'test-project', add: {}, remove: {},
-      });
-    });
-
-    test('_handleSaveForReview new parent with spaces', async () => {
-      element._inheritsFrom = {id: 'spaces+in+project+name'};
-      element._originalInheritsFrom = {id: 'old-project'};
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), {
-        parent: 'spaces in project name', add: {}, remove: {},
-      });
-    });
-
-    test('_handleSaveForReview rules', async () => {
-      // Delete a rule.
-      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
-      await flush();
-      let expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Undo deleting a rule.
-      delete element._local['refs/*'].permissions.owner.rules[123].deleted;
-
-      // Modify a rule.
-      element._local['refs/*'].permissions.owner.rules[123].modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {action: 'DENY', modified: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove permissions', async () => {
-      // Add a new rule to a permission.
-      let expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-
-      element.shadowRoot
-          .querySelector('gr-access-section').shadowRoot
-          .querySelector('gr-permission')
-          ._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
-
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Remove the added rule.
-      delete element._local['refs/*'].permissions.owner.rules.Maintainers;
-
-      // Delete a permission.
-      element._local['refs/*'].permissions.owner.deleted = true;
-      await flush();
-      expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Undo delete permission.
-      delete element._local['refs/*'].permissions.owner.deleted;
-
-      // Modify a permission.
-      element._local['refs/*'].permissions.owner.modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                  123: {action: 'DENY'},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove sections', async () => {
-      // Add a new permission to a section
-      let expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {},
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      element.shadowRoot
-          .querySelector('gr-access-section')._handleAddPermission();
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a new rule to the new permission.
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      const newPermission =
-          dom(element.shadowRoot
-              .querySelector('gr-access-section').root).querySelectorAll(
-              'gr-permission')[2];
-      newPermission._handleAddRuleItem(
-          {detail: {value: 'Maintainers'}});
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Modify a section reference.
-      element._local['refs/*'].updatedId = 'refs/for/bar';
-      element._local['refs/*'].modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/for/bar': {
-            modified: true,
-            updatedId: 'refs/for/bar',
-            permissions: {
-              'owner': {
-                rules: {
-                  234: {action: 'ALLOW'},
-                  123: {action: 'DENY'},
-                },
-              },
-              'read': {
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    min: -2,
-                    max: 2,
-                    action: 'ALLOW',
-                    added: true,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Delete a section.
-      element._local['refs/*'].deleted = true;
-      await flush();
-      expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove new section', async () => {
-      // Add a new permission to a section
-      let expectedInput = {
-        add: {
-          'refs/for/*': {
-            added: true,
-            permissions: {},
-          },
-        },
-        remove: {},
-      };
-      MockInteractions.tap(element.$.addReferenceBtn);
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      expectedInput = {
-        add: {
-          'refs/for/*': {
-            added: true,
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {},
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      const newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[1];
-      newSection._handleAddPermission();
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add rule to the new permission.
-      expectedInput = {
-        add: {
-          'refs/for/*': {
-            added: true,
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Modify a the reference from the default value.
-      element._local['refs/for/*'].updatedId = 'refs/for/new';
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {},
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('_computeAddAndRemove combinations', async () => {
-      // Modify rule and delete permission that it is inside of.
-      element._local['refs/*'].permissions.owner.rules[123].modified = true;
-      element._local['refs/*'].permissions.owner.deleted = true;
-      await flush();
-      let expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      // Delete rule and delete permission that it is inside of.
-      element._local['refs/*'].permissions.owner.rules[123].modified = false;
-      element._local['refs/*'].permissions.owner.rules[123].deleted = true;
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Also modify a different rule inside of another permission.
-      element._local['refs/*'].permissions.read.modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              read: {
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-              read: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-      // Modify both permissions with an exclusive bit. Owner is still
-      // deleted.
-      element._local['refs/*'].permissions.owner.exclusive = true;
-      element._local['refs/*'].permissions.owner.modified = true;
-      element._local['refs/*'].permissions.read.exclusive = true;
-      element._local['refs/*'].permissions.read.modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              read: {
-                exclusive: true,
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-              read: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a rule to the existing permission;
-      const readPermission =
-          dom(element.shadowRoot
-              .querySelector('gr-access-section').root).querySelectorAll(
-              'gr-permission')[1];
-      readPermission._handleAddRuleItem(
-          {detail: {value: 'Maintainers'}});
-      await flush();
-
-      expectedInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              read: {
-                exclusive: true,
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                  Maintainers: {action: 'ALLOW', added: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {rules: {}},
-              read: {rules: {}},
-            },
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Change one of the refs
-      element._local['refs/*'].updatedId = 'refs/for/bar';
-      element._local['refs/*'].modified = true;
-      await flush();
-
-      expectedInput = {
-        add: {
-          'refs/for/bar': {
-            modified: true,
-            updatedId: 'refs/for/bar',
-            permissions: {
-              read: {
-                exclusive: true,
-                modified: true,
-                rules: {
-                  234: {action: 'ALLOW'},
-                  Maintainers: {action: 'ALLOW', added: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      expectedInput = {
-        add: {},
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      element._local['refs/*'].deleted = true;
-      await flush();
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a new section.
-      MockInteractions.tap(element.$.addReferenceBtn);
-      let newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[1];
-      newSection._handleAddPermission();
-      await flush();
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
-      // Modify a the reference from the default value.
-      element._local['refs/for/*'].updatedId = 'refs/for/new';
-      await flush();
-
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Modify newly added rule inside new ref.
-      element._local['refs/for/*'].permissions['label-Code-Review'].
-          rules['Maintainers'].modified = true;
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    modified: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-
-      // Add a second new section.
-      MockInteractions.tap(element.$.addReferenceBtn);
-      await flush();
-      newSection = dom(element.root)
-          .querySelectorAll('gr-access-section')[2];
-      newSection._handleAddPermission();
-      await flush();
-      newSection.shadowRoot
-          .querySelector('gr-permission')._handleAddRuleItem(
-              {detail: {value: 'Maintainers'}});
-      // Modify a the reference from the default value.
-      element._local['refs/for/**'].updatedId = 'refs/for/new2';
-      await flush();
-      expectedInput = {
-        add: {
-          'refs/for/new': {
-            added: true,
-            updatedId: 'refs/for/new',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    modified: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-          'refs/for/new2': {
-            added: true,
-            updatedId: 'refs/for/new2',
-            permissions: {
-              'label-Code-Review': {
-                added: true,
-                rules: {
-                  Maintainers: {
-                    action: 'ALLOW',
-                    added: true,
-                    max: 2,
-                    min: -2,
-                  },
-                },
-                label: 'Code-Review',
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {},
-          },
-        },
-      };
-      assert.deepEqual(element._computeAddAndRemove(), expectedInput);
-    });
-
-    test('Unsaved added refs are discarded when edit cancelled', async () => {
-      // Unsaved changes are discarded when editing is cancelled.
-      MockInteractions.tap(element.$.editBtn);
-      await flush();
-      assert.equal(element._sections.length, 1);
-      assert.equal(Object.keys(element._local).length, 1);
-      MockInteractions.tap(element.$.addReferenceBtn);
-      await flush();
-      assert.equal(element._sections.length, 2);
-      assert.equal(Object.keys(element._local).length, 2);
-      MockInteractions.tap(element.$.editBtn);
-      await flush();
-      assert.equal(element._sections.length, 1);
-      assert.equal(Object.keys(element._local).length, 1);
-    });
-
-    test('_handleSave', async () => {
-      const repoAccessInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {action: 'DENY', modified: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      stubRestApi('getRepoAccessRights').returns(
-          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-      sinon.stub(GerritNav, 'navigateToChange');
-      let resolver;
-      const saveStub = stubRestApi(
-          'setRepoAccessRights')
-          .returns(new Promise(r => resolver = r));
-
-      element.repo = 'test-repo';
-      sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
-
-      element._modified = true;
-      MockInteractions.tap(element.$.saveBtn);
-      await flush();
-      assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
-      resolver({_number: 1});
-      await flush();
-      assert.isTrue(saveStub.called);
-      assert.isTrue(GerritNav.navigateToChange.notCalled);
-    });
-
-    test('_handleSaveForReview', async () => {
-      const repoAccessInput = {
-        add: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {action: 'DENY', modified: true},
-                },
-              },
-            },
-          },
-        },
-        remove: {
-          'refs/*': {
-            permissions: {
-              owner: {
-                rules: {
-                  123: {},
-                },
-              },
-            },
-          },
-        },
-      };
-      stubRestApi('getRepoAccessRights').returns(
-          Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
-      sinon.stub(GerritNav, 'navigateToChange');
-      let resolver;
-      const saveForReviewStub = stubRestApi(
-          'setRepoAccessRightsForReview')
-          .returns(new Promise(r => resolver = r));
-
-      element.repo = 'test-repo';
-      sinon.stub(element, '_computeAddAndRemove').returns(repoAccessInput);
-
-      element._modified = true;
-      MockInteractions.tap(element.$.saveReviewBtn);
-      await flush();
-      assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
-      resolver({_number: 1});
-      await flush();
-      assert.isTrue(saveForReviewStub.called);
-      assert.isTrue(GerritNav.navigateToChange
-          .lastCall.calledWithExactly({_number: 1}));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
new file mode 100644
index 0000000..2204400
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
@@ -0,0 +1,1521 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-repo-access';
+import {GrRepoAccess} from './gr-repo-access';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {toSortedPermissionsArray} from '../../../utils/access-util';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  ChangeInfo,
+  GitRef,
+  RepoName,
+  UrlEncodedRepoName,
+} from '../../../types/common';
+import {PermissionAction} from '../../../constants/constants';
+import {PageErrorEvent} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {
+  AutocompleteCommitEvent,
+  GrAutocomplete,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrAccessSection} from '../gr-access-section/gr-access-section';
+import {GrPermission} from '../gr-permission/gr-permission';
+import {createChange} from '../../../test/test-data-generators';
+import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+
+suite('gr-repo-access tests', () => {
+  let element: GrRepoAccess;
+
+  let repoStub: sinon.SinonStub;
+
+  const accessRes = {
+    local: {
+      'refs/*': {
+        permissions: {
+          owner: {
+            rules: {
+              234: {action: PermissionAction.ALLOW},
+              123: {action: PermissionAction.DENY},
+            },
+          },
+          read: {
+            rules: {
+              234: {action: PermissionAction.ALLOW},
+            },
+          },
+        },
+      },
+    },
+    groups: {
+      Administrators: {
+        name: 'Administrators',
+      },
+      Maintainers: {
+        name: 'Maintainers',
+      },
+    },
+    config_web_links: [
+      {
+        name: 'gitiles',
+        target: '_blank',
+        url: 'https://my/site/+log/123/project.config',
+      },
+    ],
+    can_upload: true,
+  };
+  const accessRes2 = {
+    local: {
+      GLOBAL_CAPABILITIES: {
+        permissions: {
+          accessDatabase: {
+            rules: {
+              group1: {
+                action: PermissionAction.ALLOW,
+              },
+            },
+          },
+        },
+      },
+    },
+  };
+  const repoRes = {
+    id: '' as UrlEncodedRepoName,
+    labels: {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '-1': 'I would prefer this is not submitted as is',
+          '-2': 'This shall not be submitted',
+          '+1': 'Looks good to me, but someone else must approve',
+          '+2': 'Looks good to me, approved',
+        },
+        default_value: 0,
+      },
+    },
+  };
+  const capabilitiesRes = {
+    accessDatabase: {
+      id: 'accessDatabase',
+      name: 'Access Database',
+    },
+    createAccount: {
+      id: 'createAccount',
+      name: 'Create Account',
+    },
+  };
+
+  setup(async () => {
+    element = await fixture<GrRepoAccess>(html`
+      <gr-repo-access></gr-repo-access>
+    `);
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    repoStub = stubRestApi('getRepo').returns(Promise.resolve(repoRes));
+    element.loading = false;
+    element.ownerOf = [];
+    element.canUpload = false;
+    await element.updateComplete;
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="main">
+          <div id="loading">Loading...</div>
+          <div id="loadedContent">
+            <h3 class="heading-3" id="inheritsFrom">
+              <span class="rightsText"> Rights Inherit From </span>
+              <a href="" id="inheritFromName" rel="noopener"> </a>
+              <gr-autocomplete id="editInheritFromInput"> </gr-autocomplete>
+            </h3>
+            <div class="weblinks">History:</div>
+            <div class="referenceContainer">
+              <gr-button
+                aria-disabled="false"
+                id="addReferenceBtn"
+                role="button"
+                tabindex="0"
+              >
+                Add Reference
+              </gr-button>
+            </div>
+            <div>
+              <gr-button
+                aria-disabled="false"
+                id="editBtn"
+                role="button"
+                tabindex="0"
+              >
+                Edit
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="invisible"
+                id="saveBtn"
+                primary=""
+                role="button"
+                tabindex="0"
+              >
+                Save
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                class="invisible"
+                id="saveReviewBtn"
+                primary=""
+                role="button"
+                tabindex="0"
+              >
+                Save for review
+              </gr-button>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('_repoChanged called when repo name changes', async () => {
+    const repoChangedStub = sinon.stub(element, '_repoChanged');
+    element.repo = 'New Repo' as RepoName;
+    await element.updateComplete;
+    assert.isTrue(repoChangedStub.called);
+  });
+
+  test('_repoChanged', async () => {
+    const accessStub = stubRestApi('getRepoAccessRights');
+
+    accessStub
+      .withArgs('New Repo' as RepoName)
+      .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes))));
+    accessStub
+      .withArgs('Another New Repo' as RepoName)
+      .returns(Promise.resolve(JSON.parse(JSON.stringify(accessRes2))));
+    const capabilitiesStub = stubRestApi('getCapabilities');
+    capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
+
+    await element._repoChanged('New Repo' as RepoName);
+    assert.isTrue(accessStub.called);
+    assert.isTrue(capabilitiesStub.called);
+    assert.isTrue(repoStub.called);
+    assert.isNotOk(element.inheritsFrom);
+    assert.deepEqual(element.local, accessRes.local);
+    assert.deepEqual(
+      element.sections,
+      toSortedPermissionsArray(accessRes.local)
+    );
+    assert.deepEqual(element.labels, repoRes.labels);
+    assert.equal(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '.weblinks'))
+        .display,
+      'block'
+    );
+
+    await element._repoChanged('Another New Repo' as RepoName);
+    assert.deepEqual(
+      element.sections,
+      toSortedPermissionsArray(accessRes2.local)
+    );
+    assert.equal(
+      getComputedStyle(queryAndAssert<HTMLDivElement>(element, '.weblinks'))
+        .display,
+      'none'
+    );
+  });
+
+  test('_repoChanged when repo changes to undefined returns', async () => {
+    const capabilitiesRes = {
+      accessDatabase: {
+        id: 'accessDatabase',
+        name: 'Access Database',
+      },
+    };
+    const accessStub = stubRestApi('getRepoAccessRights').returns(
+      Promise.resolve(JSON.parse(JSON.stringify(accessRes2)))
+    );
+    const capabilitiesStub = stubRestApi('getCapabilities').returns(
+      Promise.resolve(capabilitiesRes)
+    );
+
+    await element._repoChanged();
+    assert.isFalse(accessStub.called);
+    assert.isFalse(capabilitiesStub.called);
+    assert.isFalse(repoStub.called);
+  });
+
+  test('computeParentHref', () => {
+    element.inheritsFrom!.name = 'test-repo' as RepoName;
+    assert.equal(element.computeParentHref(), '/admin/repos/test-repo,access');
+  });
+
+  test('computeMainClass', () => {
+    element.ownerOf = ['refs/*'] as GitRef[];
+    element.editing = false;
+    element.canUpload = false;
+    assert.equal(element.computeMainClass(), 'admin');
+    element.editing = true;
+    assert.equal(element.computeMainClass(), 'admin editing');
+    element.ownerOf = [];
+    element.editing = false;
+    assert.equal(element.computeMainClass(), '');
+    element.editing = true;
+    assert.equal(element.computeMainClass(), 'editing');
+  });
+
+  test('inherit section', async () => {
+    element.local = {};
+    element.ownerOf = [];
+    const computeParentHrefStub = sinon.stub(element, 'computeParentHref');
+    await element.updateComplete;
+
+    // Nothing should appear when no inherit from and not in edit mode.
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
+    // When in edit mode, the autocomplete should appear.
+    element.editing = true;
+    // When editing, the autocomplete should still not be shown.
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
+
+    element.editing = false;
+    element.inheritsFrom = {
+      id: '1234' as UrlEncodedRepoName,
+      name: 'another-repo' as RepoName,
+    };
+    await element.updateComplete;
+
+    // When there is a parent repo, the link should be displayed.
+    assert.notEqual(
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
+    assert.notEqual(
+      getComputedStyle(
+        queryAndAssert<HTMLAnchorElement>(element, '#inheritFromName')
+      ).display,
+      'none'
+    );
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+      ).display,
+      'none'
+    );
+    assert.isTrue(computeParentHrefStub.called);
+    element.editing = true;
+    await element.updateComplete;
+    // When editing, the autocomplete should be shown.
+    assert.notEqual(
+      getComputedStyle(
+        queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
+      ).display,
+      'none'
+    );
+    assert.equal(
+      getComputedStyle(
+        queryAndAssert<HTMLAnchorElement>(element, '#inheritFromName')
+      ).display,
+      'none'
+    );
+    assert.notEqual(
+      getComputedStyle(
+        queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+      ).display,
+      'none'
+    );
+  });
+
+  test('handleUpdateInheritFrom', async () => {
+    element.inheritFromFilter = 'foo bar baz' as RepoName;
+    await element.updateComplete;
+    element.handleUpdateInheritFrom({
+      detail: {value: 'abc+123'},
+    } as CustomEvent);
+    await element.updateComplete;
+    assert.isOk(element.inheritsFrom);
+    assert.equal(element.inheritsFrom!.id, 'abc+123');
+    assert.equal(element.inheritsFrom!.name, 'foo bar baz' as RepoName);
+  });
+
+  test('fires page-error', async () => {
+    const response = {status: 404} as Response;
+
+    stubRestApi('getRepoAccessRights').callsFake((_repoName, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      }
+      return Promise.resolve(undefined);
+    });
+
+    const promise = mockPromise();
+    addListenerForTest(document, 'page-error', e => {
+      assert.deepEqual((e as PageErrorEvent).detail.response, response);
+      promise.resolve();
+    });
+
+    element.repo = 'test' as RepoName;
+    await promise;
+  });
+
+  suite('with defined sections', () => {
+    const testEditSaveCancelBtns = async (
+      shouldShowSave: boolean,
+      shouldShowSaveReview: boolean
+    ) => {
+      // Edit button is visible and Save button is hidden.
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#saveReviewBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#saveBtn')).display,
+        'none'
+      );
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#editBtn')).display,
+        'none'
+      );
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#editBtn').innerText,
+        'EDIT'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+        ).display,
+        'none'
+      );
+      element.inheritsFrom = {
+        id: 'test-project' as UrlEncodedRepoName,
+      };
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+        ).display,
+        'none'
+      );
+
+      queryAndAssert<GrButton>(element, '#editBtn').click();
+      await element.updateComplete;
+
+      // Edit button changes to Cancel button, and Save button is visible but
+      // disabled.
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#editBtn').innerText,
+        'CANCEL'
+      );
+      if (shouldShowSaveReview) {
+        assert.notEqual(
+          getComputedStyle(queryAndAssert<GrButton>(element, '#saveReviewBtn'))
+            .display,
+          'none'
+        );
+        assert.isTrue(
+          queryAndAssert<GrButton>(element, '#saveReviewBtn').disabled
+        );
+      }
+      if (shouldShowSave) {
+        assert.notEqual(
+          getComputedStyle(queryAndAssert<GrButton>(element, '#saveBtn'))
+            .display,
+          'none'
+        );
+        assert.isTrue(queryAndAssert<GrButton>(element, '#saveBtn').disabled);
+      }
+      assert.notEqual(
+        getComputedStyle(
+          queryAndAssert<GrAutocomplete>(element, '#editInheritFromInput')
+        ).display,
+        'none'
+      );
+
+      // Save button should be enabled after access is modified
+      element.dispatchEvent(
+        new CustomEvent('access-modified', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      if (shouldShowSaveReview) {
+        assert.isFalse(
+          queryAndAssert<GrButton>(element, '#saveReviewBtn').disabled
+        );
+      }
+      if (shouldShowSave) {
+        assert.isFalse(queryAndAssert<GrButton>(element, '#saveBtn').disabled);
+      }
+    };
+
+    setup(async () => {
+      // Create deep copies of these objects so the originals are not modified
+      // by any tests.
+      element.local = JSON.parse(JSON.stringify(accessRes.local));
+      element.ownerOf = [];
+      element.sections = toSortedPermissionsArray(element.local);
+      element.groups = JSON.parse(JSON.stringify(accessRes.groups));
+      element.capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
+      element.labels = JSON.parse(JSON.stringify(repoRes.labels));
+      await element.updateComplete;
+    });
+
+    test('removing an added section', async () => {
+      element.editing = true;
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 1);
+      queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      ).dispatchEvent(
+        new CustomEvent('added-section-removed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 0);
+    });
+
+    test('button visibility for non ref owner', async () => {
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#saveReviewBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#editBtn')).display,
+        'none'
+      );
+    });
+
+    test('button visibility for non ref owner with upload privilege', async () => {
+      element.canUpload = true;
+      await element.updateComplete;
+      testEditSaveCancelBtns(false, true);
+    });
+
+    test('button visibility for ref owner', async () => {
+      element.ownerOf = ['refs/for/*'] as GitRef[];
+      await element.updateComplete;
+      testEditSaveCancelBtns(true, false);
+    });
+
+    test('button visibility for ref owner and upload', async () => {
+      element.ownerOf = ['refs/for/*'] as GitRef[];
+      element.canUpload = true;
+      await element.updateComplete;
+      testEditSaveCancelBtns(true, false);
+    });
+
+    test('_handleAccessModified called with event fired', async () => {
+      const handleAccessModifiedSpy = sinon.spy(
+        element,
+        '_handleAccessModified'
+      );
+      element.dispatchEvent(
+        new CustomEvent('access-modified', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await element.updateComplete;
+      assert.isTrue(handleAccessModifiedSpy.called);
+    });
+
+    test('_handleAccessModified called when parent changes', async () => {
+      element.inheritsFrom = {
+        id: 'test-project' as UrlEncodedRepoName,
+      };
+      await element.updateComplete;
+      queryAndAssert<GrAutocomplete>(
+        element,
+        '#editInheritFromInput'
+      ).dispatchEvent(
+        new CustomEvent('commit', {
+          detail: {},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      const handleAccessModifiedSpy = sinon.spy(
+        element,
+        '_handleAccessModified'
+      );
+      element.dispatchEvent(
+        new CustomEvent('access-modified', {
+          detail: {},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await element.updateComplete;
+      assert.isTrue(handleAccessModifiedSpy.called);
+    });
+
+    test('handleSaveForReview', async () => {
+      const saveStub = stubRestApi('setRepoAccessRightsForReview');
+      sinon.stub(element, 'computeAddAndRemove').returns({
+        add: {},
+        remove: {},
+      });
+      element.handleSaveForReview(new Event('test'));
+      await element.updateComplete;
+      assert.isFalse(saveStub.called);
+    });
+
+    test('recursivelyRemoveDeleted', () => {
+      const obj = {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+                123: {action: 'DENY', deleted: true},
+              },
+            },
+            read: {
+              deleted: true,
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      };
+      const expectedResult = {
+        'refs/*': {
+          permissions: {
+            owner: {
+              rules: {
+                234: {action: 'ALLOW'},
+              },
+            },
+          },
+        },
+      };
+      element.recursivelyRemoveDeleted(obj);
+      assert.deepEqual(obj, expectedResult);
+    });
+
+    test('recursivelyUpdateAddRemoveObj on new added section', () => {
+      const obj = {
+        'refs/for/*': {
+          permissions: {
+            'label-Code-Review': {
+              rules: {
+                e798fed07afbc9173a587f876ef8760c78d240c1: {
+                  min: -2,
+                  max: 2,
+                  action: 'ALLOW',
+                  added: true,
+                },
+              },
+              added: true,
+              label: 'Code-Review',
+            },
+            'labelAs-Code-Review': {
+              rules: {
+                'ldap:gerritcodereview-eng': {
+                  min: -2,
+                  max: 2,
+                  action: 'ALLOW',
+                  added: true,
+                  deleted: true,
+                },
+              },
+              added: true,
+              label: 'Code-Review',
+            },
+          },
+          added: true,
+        },
+      };
+
+      const expectedResult = {
+        add: {
+          'refs/for/*': {
+            permissions: {
+              'label-Code-Review': {
+                rules: {
+                  e798fed07afbc9173a587f876ef8760c78d240c1: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                added: true,
+                label: 'Code-Review',
+              },
+              'labelAs-Code-Review': {
+                rules: {},
+                added: true,
+                label: 'Code-Review',
+              },
+            },
+            added: true,
+          },
+        },
+        remove: {},
+      };
+      const updateObj = {add: {}, remove: {}};
+      element.recursivelyUpdateAddRemoveObj(obj, updateObj);
+      assert.deepEqual(updateObj, expectedResult);
+    });
+
+    test('handleSaveForReview with no changes', () => {
+      assert.deepEqual(element.computeAddAndRemove(), {add: {}, remove: {}});
+    });
+
+    test('handleSaveForReview parent change', async () => {
+      element.inheritsFrom = {
+        id: 'test-project' as UrlEncodedRepoName,
+      };
+      element.originalInheritsFrom = {
+        id: 'test-project-original' as UrlEncodedRepoName,
+      };
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), {
+        parent: 'test-project',
+        add: {},
+        remove: {},
+      });
+    });
+
+    test('handleSaveForReview new parent with spaces', async () => {
+      element.inheritsFrom = {
+        id: 'spaces+in+project+name' as UrlEncodedRepoName,
+      };
+      element.originalInheritsFrom = {id: 'old-project' as UrlEncodedRepoName};
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), {
+        parent: 'spaces in project name',
+        add: {},
+        remove: {},
+      });
+    });
+
+    test('handleSaveForReview rules', async () => {
+      // Delete a rule.
+      element.local!['refs/*'].permissions.owner.rules[123].deleted = true;
+      await element.updateComplete;
+      let expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Undo deleting a rule.
+      delete element.local!['refs/*'].permissions.owner.rules[123].deleted;
+
+      // Modify a rule.
+      element.local!['refs/*'].permissions.owner.rules[123].modified = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+    });
+
+    test('computeAddAndRemove permissions', async () => {
+      // Add a new rule to a permission.
+      let expectedInput = {};
+
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const grAccessSection = queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      );
+      await queryAndAssert<GrPermission>(
+        grAccessSection,
+        'gr-permission'
+      ).handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Remove the added rule.
+      delete element.local!['refs/*'].permissions.owner.rules.Maintainers;
+
+      // Delete a permission.
+      element.local!['refs/*'].permissions.owner.deleted = true;
+      await element.updateComplete;
+
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Undo delete permission.
+      delete element.local!['refs/*'].permissions.owner.deleted;
+
+      // Modify a permission.
+      element.local!['refs/*'].permissions.owner.modified = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  123: {action: 'DENY'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+    });
+
+    test('computeAddAndRemove sections', async () => {
+      // Add a new permission to a section
+      let expectedInput = {};
+
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {},
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      ).handleAddPermission();
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Add a new rule to the new permission.
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const grAccessSection = queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      );
+      await queryAll<GrPermission>(
+        grAccessSection,
+        'gr-permission'
+      )[2].handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Modify a section reference.
+      element.local!['refs/*'].updatedId = 'refs/for/bar';
+      element.local!['refs/*'].modified = true;
+      await element.updateComplete;
+
+      expectedInput = {
+        add: {
+          'refs/for/bar': {
+            modified: true,
+            updatedId: 'refs/for/bar',
+            permissions: {
+              owner: {
+                rules: {
+                  234: {action: 'ALLOW'},
+                  123: {action: 'DENY'},
+                },
+              },
+              read: {
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    min: -2,
+                    max: 2,
+                    action: 'ALLOW',
+                    added: true,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Delete a section.
+      element.local!['refs/*'].deleted = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+    });
+
+    test('add and remove and re-add ref', async () => {
+      // refs/for/* is added
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+
+      // refs/for/* is removed
+      element.handleAddedSectionRemoved(1);
+      await element.updateComplete;
+
+      // refs/for/* is re-added without extra starts
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+
+      assert.equal(element.sections![1].id, 'refs/for/*');
+    });
+
+    test('computeAddAndRemove new section', async () => {
+      // Add a new permission to a section
+      let expectedInput = {};
+
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {},
+          },
+        },
+        remove: {},
+      };
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {},
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      const newSection = queryAll<GrAccessSection>(
+        element,
+        'gr-access-section'
+      )[1];
+      newSection.handleAddPermission();
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Add rule to the new permission.
+      expectedInput = {
+        add: {
+          'refs/for/*': {
+            added: true,
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+
+      await queryAndAssert<GrPermission>(
+        newSection,
+        'gr-permission'
+      ).handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Modify a the reference from the default value.
+      element.local!['refs/for/*'].updatedId = 'refs/for/new';
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {},
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+    });
+
+    test('computeAddAndRemove combinations', async () => {
+      // Modify rule and delete permission that it is inside of.
+      element.local!['refs/*'].permissions.owner.rules[123].modified = true;
+      element.local!['refs/*'].permissions.owner.deleted = true;
+      await element.updateComplete;
+      let expectedInput = {};
+
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+      // Delete rule and delete permission that it is inside of.
+      element.local!['refs/*'].permissions.owner.rules[123].modified = false;
+      element.local!['refs/*'].permissions.owner.rules[123].deleted = true;
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Also modify a different rule inside of another permission.
+      element.local!['refs/*'].permissions.read.modified = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+      // Modify both permissions with an exclusive bit. Owner is still
+      // deleted.
+      element.local!['refs/*'].permissions.owner.exclusive = true;
+      element.local!['refs/*'].permissions.owner.modified = true;
+      element.local!['refs/*'].permissions.read.exclusive = true;
+      element.local!['refs/*'].permissions.read.modified = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Add a rule to the existing permission;
+      const grAccessSection = queryAndAssert<GrAccessSection>(
+        element,
+        'gr-access-section'
+      );
+      await queryAll<GrPermission>(
+        grAccessSection,
+        'gr-permission'
+      )[1].handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+
+      expectedInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  Maintainers: {action: 'ALLOW', added: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {rules: {}},
+              read: {rules: {}},
+            },
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Change one of the refs
+      element.local!['refs/*'].updatedId = 'refs/for/bar';
+      element.local!['refs/*'].modified = true;
+      await element.updateComplete;
+
+      expectedInput = {
+        add: {
+          'refs/for/bar': {
+            modified: true,
+            updatedId: 'refs/for/bar',
+            permissions: {
+              read: {
+                exclusive: true,
+                modified: true,
+                rules: {
+                  234: {action: 'ALLOW'},
+                  Maintainers: {action: 'ALLOW', added: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      expectedInput = {
+        add: {},
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      element.local!['refs/*'].deleted = true;
+      await element.updateComplete;
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Add a new section.
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+      let newSection = queryAll<GrAccessSection>(
+        element,
+        'gr-access-section'
+      )[1];
+      newSection.handleAddPermission();
+      await element.updateComplete;
+      await queryAndAssert<GrPermission>(
+        newSection,
+        'gr-permission'
+      ).handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+      // Modify a the reference from the default value.
+      element.local!['refs/for/*'].updatedId = 'refs/for/new';
+      await element.updateComplete;
+
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Modify newly added rule inside new ref.
+      element.local!['refs/for/*'].permissions['label-Code-Review'].rules[
+        'Maintainers'
+      ].modified = true;
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    modified: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+
+      // Add a second new section.
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+      newSection = queryAll<GrAccessSection>(element, 'gr-access-section')[2];
+      newSection.handleAddPermission();
+      await element.updateComplete;
+      await queryAndAssert<GrPermission>(
+        newSection,
+        'gr-permission'
+      ).handleAddRuleItem({
+        detail: {value: 'Maintainers'},
+      } as AutocompleteCommitEvent);
+      // Modify a the reference from the default value.
+      element.local!['refs/for/**'].updatedId = 'refs/for/new2';
+      await element.updateComplete;
+      expectedInput = {
+        add: {
+          'refs/for/new': {
+            added: true,
+            updatedId: 'refs/for/new',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    modified: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+          'refs/for/new2': {
+            added: true,
+            updatedId: 'refs/for/new2',
+            permissions: {
+              'label-Code-Review': {
+                added: true,
+                rules: {
+                  Maintainers: {
+                    action: 'ALLOW',
+                    added: true,
+                    max: 2,
+                    min: -2,
+                  },
+                },
+                label: 'Code-Review',
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {},
+          },
+        },
+      };
+      assert.deepEqual(element.computeAddAndRemove(), expectedInput);
+    });
+
+    test('Unsaved added refs are discarded when edit cancelled', async () => {
+      // Unsaved changes are discarded when editing is cancelled.
+      queryAndAssert<GrButton>(element, '#editBtn').click();
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 1);
+      assert.equal(Object.keys(element.local!).length, 1);
+      queryAndAssert<GrButton>(element, '#addReferenceBtn').click();
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 2);
+      assert.equal(Object.keys(element.local!).length, 2);
+      queryAndAssert<GrButton>(element, '#editBtn').click();
+      await element.updateComplete;
+      assert.equal(element.sections!.length, 1);
+      assert.equal(Object.keys(element.local!).length, 1);
+    });
+
+    test('handleSave', async () => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      stubRestApi('getRepoAccessRights').returns(
+        Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
+      );
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+      let resolver: (value: Response | PromiseLike<Response>) => void;
+      const saveStub = stubRestApi('setRepoAccessRights').returns(
+        new Promise(r => (resolver = r))
+      );
+
+      element.repo = 'test-repo' as RepoName;
+      sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
+
+      element.modified = true;
+      queryAndAssert<GrButton>(element, '#saveBtn').click();
+      await element.updateComplete;
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('loading'),
+        true
+      );
+      resolver!({status: 200} as Response);
+      await element.updateComplete;
+      assert.isTrue(saveStub.called);
+      assert.isTrue(setUrlStub.notCalled);
+    });
+
+    test('handleSaveForReview', async () => {
+      const repoAccessInput = {
+        add: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {action: 'DENY', modified: true},
+                },
+              },
+            },
+          },
+        },
+        remove: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      stubRestApi('getRepoAccessRights').returns(
+        Promise.resolve(JSON.parse(JSON.stringify(accessRes)))
+      );
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+      let resolver: (value: ChangeInfo | PromiseLike<ChangeInfo>) => void;
+      const saveForReviewStub = stubRestApi(
+        'setRepoAccessRightsForReview'
+      ).returns(new Promise(r => (resolver = r)));
+
+      element.repo = 'test-repo' as RepoName;
+      sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
+
+      element.modified = true;
+      queryAndAssert<GrButton>(element, '#saveReviewBtn').click();
+      await element.updateComplete;
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#saveReviewBtn').hasAttribute(
+          'loading'
+        ),
+        true
+      );
+      resolver!(createChange());
+      await element.updateComplete;
+      assert.isTrue(saveForReviewStub.called);
+      assert.isTrue(setUrlStub.called);
+      assert.isTrue(
+        setUrlStub.lastCall.args?.[0]?.includes(`${createChange()._number}`)
+      );
+    });
+  });
+});
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 de43dc6..11cfaab 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
@@ -1,131 +1,261 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-change-dialog/gr-create-change-dialog';
-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 '../gr-create-change-dialog/gr-create-file-edit-dialog';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   BranchName,
   ConfigInfo,
-  PatchSetNum,
+  RevisionPatchSetNum,
   RepoName,
 } 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 {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, query, property, state} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
+import {createEditUrl} from '../../../models/views/change';
+import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {GrCreateFileEditDialog} from '../gr-create-change-dialog/gr-create-file-edit-dialog';
 
 const GC_MESSAGE = 'Garbage collection completed successfully.';
 const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
 const CONFIG_PATH = 'project.config';
 const EDIT_CONFIG_SUBJECT = 'Edit Repo Config';
-const INITIAL_PATCHSET = 1 as PatchSetNum;
+const INITIAL_PATCHSET = 1 as RevisionPatchSetNum;
 const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
 const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
 
-export interface GrRepoCommands {
-  $: {
-    createChangeOverlay: GrOverlay;
-    createNewChangeModal: GrCreateChangeDialog;
-  };
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-commands': GrRepoCommands;
+  }
 }
 
 @customElement('gr-repo-commands')
-export class GrRepoCommands extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRepoCommands extends LitElement {
+  @query('#createChangeModal')
+  private readonly createChangeModal?: HTMLDialogElement;
 
-  // This is a required property. Without `repo` being set the component is not
-  // useful. Thus using !.
+  @query('#createNewChangeModal')
+  private readonly createNewChangeModal?: GrCreateChangeDialog;
+
+  @query('#createFileEditDialog')
+  private readonly createFileEditDialog?: GrCreateFileEditDialog;
+
   @property({type: String})
-  repo!: RepoName;
-
-  @property({type: Boolean})
-  _loading = true;
+  repo?: RepoName;
 
   @property({type: Object})
-  _repoConfig?: ConfigInfo;
+  createEdit?: {
+    branch: BranchName;
+    path: string;
+  };
 
-  @property({type: Boolean})
-  _canCreate = false;
+  @state() private loading = true;
 
-  @property({type: Boolean})
-  _creatingChange = false;
+  @state() private repoConfig?: ConfigInfo;
 
-  @property({type: Boolean})
-  _editingConfig = false;
+  @state() private canCreateChange = false;
 
-  @property({type: Boolean})
-  _runningGC = false;
+  @state() private creatingChange = false;
 
-  private readonly restApiService = appContext.restApiService;
+  @state() private editingConfig = false;
+
+  @state() private runningGC = false;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  /** Make sure that this dialog is only activated once. */
+  private createFileEditDialogWasActivated = false;
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadRepo();
-
     fireTitleChange(this, 'Repo Commands');
   }
 
-  _loadRepo() {
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      subpageStyles,
+      sharedStyles,
+      modalStyles,
+      css`
+        #form h2,
+        h3 {
+          margin-top: var(--spacing-xxl);
+        }
+        p {
+          padding: var(--spacing-m) 0;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="main gr-form-styles read-only">
+        <h1 id="Title" class="heading-1">Repository Commands</h1>
+        <div id="loading" class=${this.loading ? 'loading' : ''}>
+          Loading...
+        </div>
+        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
+          <div id="form">
+            <h2 class="heading-2">Create change</h2>
+            <div>
+              <p>
+                Creates an empty work-in-progress change that can be used to
+                edit files online and send the modifications for review.
+              </p>
+            </div>
+            <div>
+              <gr-button
+                ?loading=${this.creatingChange}
+                @click=${() => {
+                  this.createNewChange();
+                }}
+              >
+                Create change
+              </gr-button>
+            </div>
+            <h2 class="heading-2">Edit repo config</h2>
+            <div>
+              <p>
+                Creates a work-in-progress change that allows to edit the
+                <code>project.config</code> file in the
+                <code>refs/meta/config</code> branch and send the modifications
+                for review.
+              </p>
+            </div>
+            <div>
+              <gr-button
+                id="editRepoConfig"
+                ?loading=${this.editingConfig}
+                @click=${() => {
+                  this.handleEditRepoConfig();
+                }}
+              >
+                Edit repo config
+              </gr-button>
+            </div>
+            ${this.renderRepoGarbageCollector()}
+            <gr-endpoint-decorator name="repo-command">
+              <gr-endpoint-param name="config" .value=${this.repoConfig}>
+              </gr-endpoint-param>
+              <gr-endpoint-param name="repoName" .value=${this.repo}>
+              </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        </div>
+      </div>
+      <dialog id="createChangeModal" tabindex="-1">
+        <gr-dialog
+          id="createChangeDialog"
+          confirm-label="Create"
+          ?disabled=${!this.canCreateChange}
+          @confirm=${() => {
+            this.handleCreateChange();
+          }}
+          @cancel=${() => {
+            this.handleCloseCreateChange();
+          }}
+        >
+          <div class="header" slot="header">Create Change</div>
+          <div class="main" slot="main">
+            <gr-create-change-dialog
+              id="createNewChangeModal"
+              .repoName=${this.repo}
+              .privateByDefault=${this.repoConfig?.private_by_default}
+              @can-create-change=${() => {
+                this.handleCanCreateChange();
+              }}
+            ></gr-create-change-dialog>
+          </div>
+        </gr-dialog>
+      </dialog>
+      <gr-create-file-edit-dialog
+        id="createFileEditDialog"
+        .repo=${this.repo}
+        .branch=${this.createEdit?.branch}
+        .path=${this.createEdit?.path}
+      ></gr-create-file-edit-dialog>
+    `;
+  }
+
+  private renderRepoGarbageCollector() {
+    if (!this.repoConfig?.actions || !this.repoConfig?.actions['gc']?.enabled)
+      return;
+
+    return html`
+      <h3 class="heading-3">${this.repoConfig?.actions['gc']?.label}</h3>
+      <gr-button
+        title=${this.repoConfig?.actions['gc']?.title || ''}
+        ?loading=${this.runningGC}
+        @click=${() => this.handleRunningGC()}
+      >
+        ${this.repoConfig?.actions['gc']?.label}
+      </gr-button>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('createEdit')) {
+      if (!this.createFileEditDialogWasActivated) {
+        this.createFileEditDialog?.activate();
+        this.createFileEditDialogWasActivated = true;
+      }
+    }
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this.loadRepo();
+    }
+  }
+
+  // private but used in test
+  loadRepo() {
+    if (!this.repo) return;
+
     const errFn: ErrorCallback = response => {
-      // 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;
       firePageError(response);
     };
 
-    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.
-      if (!this.isConnected) return;
-      this._repoConfig = config;
-      this._loading = false;
-    });
+    this.restApiService
+      .getProjectConfig(this.repo, errFn)
+      .then(config => {
+        if (!config) return;
+        this.repoConfig = config;
+      })
+      .finally(() => {
+        this.loading = false;
+      });
   }
 
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  _isLoading() {
-    return this._loading;
-  }
-
-  _handleRunningGC() {
+  private handleRunningGC() {
     if (!this.repo) return;
-    this._runningGC = true;
+    this.runningGC = true;
     return this.restApiService
       .runRepoGC(this.repo)
       .then(response => {
@@ -134,31 +264,40 @@
         }
       })
       .finally(() => {
-        this._runningGC = false;
+        this.runningGC = false;
       });
   }
 
-  _createNewChange() {
-    this.$.createChangeOverlay.open();
+  // private but used in test
+  createNewChange() {
+    assertIsDefined(this.createChangeModal, 'createChangeModal');
+    this.createChangeModal.showModal();
   }
 
-  _handleCreateChange() {
-    this._creatingChange = true;
-    this.$.createNewChangeModal.handleCreateChange().finally(() => {
-      this._creatingChange = false;
+  // private but used in test
+  handleCreateChange() {
+    assertIsDefined(this.createNewChangeModal, 'createNewChangeModal');
+    this.creatingChange = true;
+    this.createNewChangeModal.handleCreateChange().finally(() => {
+      this.creatingChange = false;
     });
-    this._handleCloseCreateChange();
+    this.handleCloseCreateChange();
   }
 
-  _handleCloseCreateChange() {
-    this.$.createChangeOverlay.close();
+  // private but used in test
+  handleCloseCreateChange() {
+    assertIsDefined(this.createChangeModal, 'createChangeModal');
+    this.createChangeModal.close();
   }
 
   /**
    * Returns a Promise for testing.
+   *
+   * private but used in test
    */
-  _handleEditRepoConfig() {
-    this._editingConfig = true;
+  handleEditRepoConfig() {
+    if (!this.repo) return;
+    this.editingConfig = true;
     return this.restApiService
       .createChange(
         this.repo,
@@ -177,18 +316,23 @@
           return;
         }
 
-        GerritNav.navigateToRelativeUrl(
-          GerritNav.getEditUrlForDiff(change, CONFIG_PATH, INITIAL_PATCHSET)
+        this.getNavigation().setUrl(
+          createEditUrl({
+            changeNum: change._number,
+            repo: change.project,
+            patchNum: INITIAL_PATCHSET,
+            editView: {path: CONFIG_PATH},
+          })
         );
       })
       .finally(() => {
-        this._editingConfig = false;
+        this.editingConfig = false;
       });
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-repo-commands': GrRepoCommands;
+  private handleCanCreateChange() {
+    assertIsDefined(this.createNewChangeModal, 'createNewChangeModal');
+    this.canCreateChange =
+      !!this.createNewChangeModal.branch && !!this.createNewChangeModal.subject;
   }
 }
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
deleted file mode 100644
index 9948f8f..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_html.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #form gr-button {
-      margin-bottom: var(--spacing-xxl);
-    }
-  </style>
-  <div class="main gr-form-styles read-only">
-    <h1 id="Title" class="heading-1">Repository Commands</h1>
-    <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <h2 id="options" class="heading-2">Command</h2>
-      <div id="form">
-        <h3 class="heading-3">Create change</h3>
-        <gr-button loading="[[_creatingChange]]" on-click="_createNewChange">
-          Create change
-        </gr-button>
-        <h3 class="heading-3">Edit repo config</h3>
-        <gr-button
-          id="editRepoConfig"
-          loading="[[_editingConfig]]"
-          on-click="_handleEditRepoConfig"
-        >
-          Edit repo config
-        </gr-button>
-        <h3 class="heading-3" hidden="[[!_repoConfig.actions.gc.enabled]]">
-          [[_repoConfig.actions.gc.label]]
-        </h3>
-        <gr-button
-          hidden="[[!_repoConfig.actions.gc.enabled]]"
-          title="[[_repoConfig.actions.gc.title]]"
-          loading="[[_runningGC]]"
-          on-click="_handleRunningGC"
-        >
-          [[_repoConfig.actions.gc.label]]
-        </gr-button>
-        <gr-endpoint-decorator name="repo-command">
-          <gr-endpoint-param name="config" value="[[_repoConfig]]">
-          </gr-endpoint-param>
-          <gr-endpoint-param name="repoName" value="[[repo]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-  </div>
-  <gr-overlay id="createChangeOverlay" with-backdrop="">
-    <gr-dialog
-      id="createChangeDialog"
-      confirm-label="Create"
-      disabled="[[!_canCreate]]"
-      on-confirm="_handleCreateChange"
-      on-cancel="_handleCloseCreateChange"
-    >
-      <div class="header" slot="header">Create Change</div>
-      <div class="main" slot="main">
-        <gr-create-change-dialog
-          id="createNewChangeModal"
-          can-create="{{_canCreate}}"
-          repo-name="[[repo]]"
-        ></gr-create-change-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
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
deleted file mode 100644
index ac48484..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-repo-commands.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-repo-commands');
-
-suite('gr-repo-commands tests', () => {
-  let element;
-
-  let repoStub;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    // 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 = stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-  });
-
-  suite('create new change dialog', () => {
-    test('_createNewChange opens modal', () => {
-      const openStub = sinon.stub(element.$.createChangeOverlay, 'open');
-      element._createNewChange();
-      assert.isTrue(openStub.called);
-    });
-
-    test('_handleCreateChange called when confirm fired', () => {
-      sinon.stub(element, '_handleCreateChange');
-      element.$.createChangeDialog.dispatchEvent(
-          new CustomEvent('confirm', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCreateChange.called);
-    });
-
-    test('_handleCloseCreateChange called when cancel fired', () => {
-      sinon.stub(element, '_handleCloseCreateChange');
-      element.$.createChangeDialog.dispatchEvent(
-          new CustomEvent('cancel', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleCloseCreateChange.called);
-    });
-  });
-
-  suite('edit repo config', () => {
-    let createChangeStub;
-    let urlStub;
-    let handleSpy;
-    let alertStub;
-
-    setup(() => {
-      createChangeStub = stubRestApi('createChange');
-      urlStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
-      sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      handleSpy = sinon.spy(element, '_handleEditRepoConfig');
-      alertStub = sinon.stub();
-      element.repo = 'test';
-      element.addEventListener('show-alert', alertStub);
-    });
-
-    test('successful creation of change', () => {
-      const change = {_number: '1'};
-      createChangeStub.returns(Promise.resolve(change));
-      MockInteractions.tap(element.$.editRepoConfig);
-      assert.isTrue(element.$.editRepoConfig.loading);
-      return handleSpy.lastCall.returnValue.then(() => {
-        flush();
-
-        assert.isTrue(alertStub.called);
-        assert.equal(alertStub.lastCall.args[0].detail.message,
-            'Navigating to change');
-        assert.isTrue(urlStub.called);
-        assert.deepEqual(urlStub.lastCall.args,
-            [change, 'project.config', 1]);
-        assert.isFalse(element.$.editRepoConfig.loading);
-      });
-    });
-
-    test('unsuccessful creation of change', () => {
-      createChangeStub.returns(Promise.resolve(null));
-      MockInteractions.tap(element.$.editRepoConfig);
-      assert.isTrue(element.$.editRepoConfig.loading);
-      return handleSpy.lastCall.returnValue.then(() => {
-        flush();
-
-        assert.isTrue(alertStub.called);
-        assert.equal(alertStub.lastCall.args[0].detail.message,
-            'Failed to create change.');
-        assert.isFalse(urlStub.called);
-        assert.isFalse(element.$.editRepoConfig.loading);
-      });
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', async () => {
-      repoStub.restore();
-
-      element.repo = 'test';
-
-      const response = {status: 404};
-      stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
-        errFn(response);
-        return Promise.resolve(undefined);
-      });
-
-      await flush();
-      const promise = mockPromise();
-      addListenerForTest(document, 'page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        promise.resolve();
-      });
-
-      element._loadRepo();
-      await promise;
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
new file mode 100644
index 0000000..dab2706
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
@@ -0,0 +1,222 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-repo-commands';
+import {GrRepoCommands} from './gr-repo-commands';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {EventType, PageErrorEvent} from '../../../types/events';
+import {RepoName} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-repo-commands tests', () => {
+  let element: GrRepoCommands;
+  let repoStub: sinon.SinonStub;
+
+  setup(async () => {
+    element = await fixture(html`<gr-repo-commands></gr-repo-commands>`);
+    // 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 = stubRestApi('getProjectConfig').returns(
+      Promise.resolve(undefined)
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles main read-only">
+          <h1 class="heading-1" id="Title">Repository Commands</h1>
+          <div class="loading" id="loading">Loading...</div>
+          <div class="loading" id="loadedContent">
+            <div id="form">
+              <h2 class="heading-2">Create change</h2>
+              <div>
+                <p>
+                  Creates an empty work-in-progress change that can be used to
+                  edit files online and send the modifications for review.
+                </p>
+              </div>
+              <div>
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  Create change
+                </gr-button>
+              </div>
+              <h2 class="heading-2">Edit repo config</h2>
+              <div>
+                <p>
+                  Creates a work-in-progress change that allows to edit the
+                  <code> project.config </code>
+                  file in the
+                  <code> refs/meta/config </code>
+                  branch and send the modifications for review.
+                </p>
+              </div>
+              <div>
+                <gr-button
+                  aria-disabled="false"
+                  id="editRepoConfig"
+                  role="button"
+                  tabindex="0"
+                >
+                  Edit repo config
+                </gr-button>
+              </div>
+              <gr-endpoint-decorator name="repo-command">
+                <gr-endpoint-param name="config"> </gr-endpoint-param>
+                <gr-endpoint-param name="repoName"> </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          </div>
+        </div>
+        <dialog id="createChangeModal" tabindex="-1">
+          <gr-dialog
+            confirm-label="Create"
+            disabled=""
+            id="createChangeDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Create Change</div>
+            <div class="main" slot="main">
+              <gr-create-change-dialog id="createNewChangeModal">
+              </gr-create-change-dialog>
+            </div>
+          </gr-dialog>
+        </dialog>
+        <gr-create-file-edit-dialog id="createFileEditDialog">
+        </gr-create-file-edit-dialog>
+      `,
+      {ignoreTags: ['p']}
+    );
+  });
+
+  suite('create new change dialog', () => {
+    test('createNewChange opens modal', () => {
+      const openStub = sinon.stub(
+        queryAndAssert<HTMLDialogElement>(element, '#createChangeModal'),
+        'showModal'
+      );
+      element.createNewChange();
+      assert.isTrue(openStub.called);
+    });
+
+    test('handleCreateChange called when confirm fired', () => {
+      const handleCreateChangeStub = sinon.stub(element, 'handleCreateChange');
+      queryAndAssert<GrDialog>(element, '#createChangeDialog').dispatchEvent(
+        new CustomEvent('confirm', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCreateChangeStub.called);
+    });
+
+    test('handleCloseCreateChange called when cancel fired', () => {
+      const handleCloseCreateChangeStub = sinon.stub(
+        element,
+        'handleCloseCreateChange'
+      );
+      queryAndAssert<GrDialog>(element, '#createChangeDialog').dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(handleCloseCreateChangeStub.called);
+    });
+  });
+
+  suite('edit repo config', () => {
+    let createChangeStub: sinon.SinonStub;
+    let handleSpy: sinon.SinonSpy;
+    let alertStub: sinon.SinonStub;
+
+    setup(() => {
+      createChangeStub = stubRestApi('createChange');
+      handleSpy = sinon.spy(element, 'handleEditRepoConfig');
+      alertStub = sinon.stub();
+      element.repo = 'test' as RepoName;
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+    });
+
+    test('successful creation of change', async () => {
+      const change = {_number: '1'};
+      createChangeStub.returns(Promise.resolve(change));
+      queryAndAssert<GrButton>(element, '#editRepoConfig').click();
+      await element.updateComplete;
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#editRepoConfig').loading
+      );
+
+      await handleSpy.lastCall.returnValue;
+      await element.updateComplete;
+
+      assert.isTrue(alertStub.called);
+      assert.equal(
+        alertStub.lastCall.args[0].detail.message,
+        'Navigating to change'
+      );
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#editRepoConfig').loading
+      );
+    });
+
+    test('unsuccessful creation of change', async () => {
+      createChangeStub.returns(Promise.resolve(null));
+      queryAndAssert<GrButton>(element, '#editRepoConfig').click();
+      await element.updateComplete;
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#editRepoConfig').loading
+      );
+
+      await handleSpy.lastCall.returnValue;
+      await element.updateComplete;
+
+      assert.isTrue(alertStub.called);
+      assert.equal(
+        alertStub.lastCall.args[0].detail.message,
+        'Failed to create change.'
+      );
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#editRepoConfig').loading
+      );
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', async () => {
+      repoStub.restore();
+
+      element.repo = 'test' as RepoName;
+
+      const response = {status: 404} as Response;
+      stubRestApi('getProjectConfig').callsFake((_repo, errFn) => {
+        if (errFn !== undefined) {
+          errFn(response);
+        }
+        return Promise.resolve(undefined);
+      });
+
+      await element.updateComplete;
+      const promise = mockPromise();
+      addListenerForTest(document, 'page-error', e => {
+        assert.deepEqual((e as PageErrorEvent).detail.response, response);
+        promise.resolve();
+      });
+
+      element.loadRepo();
+      await promise;
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index 7800653..bff56bdd 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
@@ -1,29 +1,17 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
 import {firePageError} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
+import {createDashboardUrl} from '../../../models/views/dashboard';
 
 interface DashboardRef {
   section: string;
@@ -41,7 +29,7 @@
   @property({type: Array})
   _dashboards?: DashboardRef[];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -90,7 +78,7 @@
               info => html`
                 <tr class="table">
                   <td class="name">
-                    <a href="${this._getUrl(info.project, info.id)}"
+                    <a href=${this._getUrl(info.project, info.id)}
                       >${info.path}</a
                     >
                   </td>
@@ -165,12 +153,10 @@
       });
   }
 
-  _getUrl(project?: RepoName, id?: DashboardId) {
-    if (!project || !id) {
-      return '';
-    }
+  _getUrl(project?: RepoName, dashboard?: DashboardId) {
+    if (!project || !dashboard) return '';
 
-    return GerritNav.getUrlForRepoDashboard(project, id);
+    return createDashboardUrl({project, dashboard});
   }
 
   _computeLoadingClass(loading: boolean) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
index a54eafb..2bbc28b 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
@@ -1,41 +1,27 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-dashboards';
 import {GrRepoDashboards} from './gr-repo-dashboards';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   addListenerForTest,
   mockPromise,
   queryAndAssert,
   stubRestApi,
+  waitEventLoop,
 } from '../../../test/test-utils';
-import {DashboardId, DashboardInfo, RepoName} from '../../../types/common';
-import {PageErrorEvent} from '../../../types/events.js';
-
-const basicFixture = fixtureFromElement('gr-repo-dashboards');
+import {DashboardInfo, RepoName} from '../../../types/common';
+import {PageErrorEvent} from '../../../types/events';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo-dashboards tests', () => {
   let element: GrRepoDashboards;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(html`<gr-repo-dashboards></gr-repo-dashboards>`);
   });
 
   suite('dashboard table', () => {
@@ -93,6 +79,29 @@
       );
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <table class="genericList loading" id="list">
+            <tbody>
+              <tr class="headerRow">
+                <th class="topHeader">Dashboard name</th>
+                <th class="topHeader">Dashboard title</th>
+                <th class="topHeader">Dashboard description</th>
+                <th class="topHeader">Inherited from</th>
+                <th class="topHeader">Default</th>
+              </tr>
+              <tr id="loadingContainer">
+                <td>Loading...</td>
+              </tr>
+            </tbody>
+            <tbody id="dashboards"></tbody>
+          </table>
+        `
+      );
+    });
+
     test('loading, sections, and ordering', async () => {
       assert.isTrue(element._loading);
       assert.notEqual(
@@ -104,7 +113,7 @@
         'none'
       );
       element.repo = 'test' as RepoName;
-      await flush();
+      await waitEventLoop();
       assert.equal(
         getComputedStyle(queryAndAssert(element, '#loadingContainer')).display,
         'none'
@@ -115,9 +124,9 @@
       );
 
       const dashboard = element._dashboards!;
-      assert.equal(dashboard.length!, 2);
-      assert.equal(dashboard[0].section!, 'custom');
-      assert.equal(dashboard[1].section!, 'default');
+      assert.equal(dashboard.length, 2);
+      assert.equal(dashboard[0].section, 'custom');
+      assert.equal(dashboard[1].section, 'default');
 
       const dashboards = dashboard[0].dashboards;
       assert.equal(dashboards.length, 2);
@@ -126,28 +135,11 @@
     });
   });
 
-  suite('test url', () => {
-    test('_getUrl', () => {
-      sinon
-        .stub(GerritNav, 'getUrlForRepoDashboard')
-        .callsFake(() => '/r/p/test/+/dashboard/default:contributor');
-
-      assert.equal(
-        element._getUrl(
-          'test' as RepoName,
-          'default:contributor' as DashboardId
-        ),
-        '/r/p/test/+/dashboard/default:contributor'
-      );
-
-      assert.equal(element._getUrl(undefined, undefined), '');
-    });
-  });
-
   suite('404', () => {
     test('fires page-error', async () => {
       const response = {status: 404} as Response;
       stubRestApi('getRepoDashboards').callsFake((_repo, errFn) => {
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
         errFn!(response);
         return Promise.resolve([]);
       });
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 052e07a..7eef7a4 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
@@ -1,132 +1,366 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
-import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-overlay/gr-overlay';
 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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-detail-list_html';
 import {encodeURL} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
 import {
-  RepoName,
-  ProjectInfo,
   BranchInfo,
-  GitRef,
-  TagInfo,
   GitPersonInfo,
+  GitRef,
+  ProjectInfo,
+  RepoName,
+  TagInfo,
+  WebLinkInfo,
 } from '../../../types/common';
-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 {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, query, property, state} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {assertIsDefined} from '../../../utils/common-util';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {RepoDetailView, RepoViewState} from '../../../models/views/repo';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
-export interface GrRepoDetailList {
-  $: {
-    overlay: GrOverlay;
-    createOverlay: GrOverlay;
-    createNewModal: GrCreatePointerDialog;
-  };
-}
-
 @customElement('gr-repo-detail-list')
-export class GrRepoDetailList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrRepoDetailList extends LitElement {
+  @query('#modal') private readonly modal?: HTMLDialogElement;
+
+  @query('#createModal') private readonly createModal?: HTMLDialogElement;
+
+  @query('#createNewModal')
+  private readonly createNewModal?: GrCreatePointerDialog;
+
+  @property({type: Object})
+  params?: RepoViewState;
+
+  // private but used in test
+  @state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
+
+  // private but used in test
+  @state() isOwner = false;
+
+  @state() private loggedIn = false;
+
+  @state() private offset = 0;
+
+  // private but used in test
+  @state() repo?: RepoName;
+
+  // private but used in test
+  @state() items?: BranchInfo[] | TagInfo[];
+
+  @state() private readonly itemsPerPage = 25;
+
+  @state() private loading = true;
+
+  @state() private filter?: string;
+
+  @state() private refName?: GitRef;
+
+  @state() private newItemName = false;
+
+  // private but used in test
+  @state() isEditing = false;
+
+  // private but used in test
+  @state() revisedRef?: GitRef;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      formStyles,
+      tableStyles,
+      sharedStyles,
+      modalStyles,
+      css`
+        .tags td.name {
+          min-width: 25em;
+        }
+        td.name,
+        td.revision,
+        td.message {
+          word-break: break-word;
+        }
+        td.revision.tags {
+          width: 27em;
+        }
+        td.message,
+        td.tagger {
+          max-width: 15em;
+        }
+        .editing .editItem {
+          display: inherit;
+        }
+        .editItem,
+        .editing .editBtn,
+        .canEdit .revisionNoEditing,
+        .editing .revisionWithEditing,
+        .revisionEdit,
+        .hideItem {
+          display: none;
+        }
+        .revisionEdit gr-button {
+          margin-left: var(--spacing-m);
+        }
+        .editBtn {
+          margin-left: var(--spacing-l);
+        }
+        .canEdit .revisionEdit {
+          align-items: center;
+          display: flex;
+        }
+        .deleteButton:not(.show) {
+          display: none;
+        }
+        .tagger.hide {
+          display: none;
+        }
+      `,
+    ];
   }
 
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: AppElementRepoParams;
+  override render() {
+    return html`
+      <gr-list-view
+        .createNew=${this.loggedIn}
+        .filter=${this.filter}
+        .itemsPerPage=${this.itemsPerPage}
+        .items=${this.items}
+        .loading=${this.loading}
+        .offset=${this.offset}
+        .path=${this.getPath(this.repo, this.detailType)}
+        @create-clicked=${() => {
+          this.handleCreateClicked();
+        }}
+      >
+        <table id="list" class="genericList gr-form-styles">
+          <tbody>
+            <tr class="headerRow">
+              <th class="name topHeader">Name</th>
+              <th class="revision topHeader">Revision</th>
+              <th
+                class="message topHeader ${this.detailType ===
+                RepoDetailView.BRANCHES
+                  ? 'hideItem'
+                  : ''}"
+              >
+                Message
+              </th>
+              <th
+                class="tagger topHeader ${this.detailType ===
+                RepoDetailView.BRANCHES
+                  ? 'hideItem'
+                  : ''}"
+              >
+                Tagger
+              </th>
+              <th class="repositoryBrowser topHeader">Repository Browser</th>
+              <th class="delete topHeader"></th>
+            </tr>
+            <tr
+              id="loading"
+              class="loadingMsg ${this.loading ? 'loading' : ''}"
+            >
+              <td>Loading...</td>
+            </tr>
+          </tbody>
+          <tbody class=${this.loading ? 'loading' : ''}>
+            ${this.items
+              ?.slice(0, SHOWN_ITEMS_COUNT)
+              .map((item, index) => this.renderItemList(item, index))}
+          </tbody>
+        </table>
+        <dialog id="modal" tabindex="-1">
+          <gr-confirm-delete-item-dialog
+            class="confirmDialog"
+            .item=${this.refName}
+            .itemTypeName=${this.computeItemName(this.detailType)}
+            @confirm=${() => this.handleDeleteItemConfirm()}
+            @cancel=${() => {
+              this.handleConfirmDialogCancel();
+            }}
+          ></gr-confirm-delete-item-dialog>
+        </dialog>
+      </gr-list-view>
+      <dialog id="createModal" tabindex="-1">
+        <gr-dialog
+          id="createDialog"
+          ?disabled=${!this.newItemName}
+          confirm-label="Create"
+          @confirm=${() => {
+            this.handleCreateItem();
+          }}
+          @cancel=${() => {
+            this.handleCloseCreate();
+          }}
+        >
+          <div class="header" slot="header">
+            Create ${this.computeItemName(this.detailType)}
+          </div>
+          <div class="main" slot="main">
+            <gr-create-pointer-dialog
+              id="createNewModal"
+              .detailType=${this.computeItemName(this.detailType)}
+              .itemDetail=${this.detailType}
+              .repoName=${this.repo}
+              @update-item-name=${() => {
+                this.handleUpdateItemName();
+              }}
+            ></gr-create-pointer-dialog>
+          </div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
 
-  @property({type: String})
-  detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
+  private renderItemList(item: BranchInfo | TagInfo, index: number) {
+    return html`
+      <tr class="table">
+        <td class="${this.detailType} name">
+          <a href=${ifDefined(this.computeFirstWebLink(item))}>
+            ${this.stripRefs(item.ref, this.detailType)}
+          </a>
+        </td>
+        <td
+          class="${this.detailType} revision ${this.computeCanEditClass(
+            item.ref,
+            this.detailType,
+            this.isOwner
+          )}"
+        >
+          <span class="revisionNoEditing"> ${item.revision} </span>
+          <span class="revisionEdit ${this.isEditing ? 'editing' : ''}">
+            <span class="revisionWithEditing"> ${item.revision} </span>
+            <gr-button
+              class="editBtn"
+              link
+              data-index=${index}
+              @click=${() => {
+                this.handleEditRevision(index);
+              }}
+            >
+              edit
+            </gr-button>
+            <iron-input
+              class="editItem"
+              .bindValue=${this.revisedRef}
+              @bind-value-changed=${this.handleRevisedRefBindValueChanged}
+            >
+              <input />
+            </iron-input>
+            <gr-button
+              class="cancelBtn editItem"
+              link
+              @click=${() => {
+                this.handleCancelRevision();
+              }}
+            >
+              Cancel
+            </gr-button>
+            <gr-button
+              class="saveBtn editItem"
+              link
+              data-index=${index}
+              ?disabled=${!this.revisedRef}
+              @click=${() => {
+                this.handleSaveRevision(index);
+              }}
+            >
+              Save
+            </gr-button>
+          </span>
+        </td>
+        <td
+          class="message ${this.detailType === RepoDetailView.BRANCHES
+            ? 'hideItem'
+            : ''}"
+        >
+          ${(item as TagInfo)?.message
+            ? (item as TagInfo).message?.split(PGP_START)[0]
+            : ''}
+        </td>
+        <td
+          class="tagger ${this.detailType === RepoDetailView.BRANCHES
+            ? 'hideItem'
+            : ''}"
+        >
+          ${this.renderTagger((item as TagInfo).tagger)}
+        </td>
+        <td class="repositoryBrowser">
+          ${this.computeWeblink(item).map(link => this.renderWeblink(link))}
+        </td>
+        <td class="delete">
+          <gr-button
+            class="deleteButton ${item.can_delete ? 'show' : ''}"
+            link
+            data-index=${index}
+            @click=${() => {
+              this.handleDeleteItem(index);
+            }}
+          >
+            Delete
+          </gr-button>
+        </td>
+      </tr>
+    `;
+  }
 
-  @property({type: Boolean})
-  _editing = false;
+  private renderTagger(tagger?: GitPersonInfo) {
+    if (!tagger) return;
 
-  @property({type: Boolean})
-  _isOwner = false;
+    return html`
+      <div class="tagger">
+        <gr-account-label .account=${tagger} clickable> </gr-account-label>
+        (<gr-date-formatter withTooltip .dateStr=${tagger.date}>
+        </gr-date-formatter
+        >)
+      </div>
+    `;
+  }
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  private renderWeblink(link: WebLinkInfo) {
+    return html`
+      <a href=${link.url} class="webLink" rel="noopener" target="_blank">
+        (${link.name})
+      </a>
+    `;
+  }
 
-  @property({type: Number})
-  _offset = 0;
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this.paramsChanged();
+    }
+  }
 
-  @property({type: String})
-  _repo?: RepoName;
-
-  @property({type: Array})
-  _items?: BranchInfo[] | TagInfo[];
-
-  // _shownItems should be BranchInfo[] | TagInfo[],
-  // but TS incorrectly assumes that in the loop for(const item of _shownItems)
-  // item has type BranchInfo, not BranchInfo | TagInfo.
-  @property({type: Array, computed: 'computeShownItems(_items)'})
-  _shownItems?: Array<BranchInfo | TagInfo>;
-
-  @property({type: Number})
-  _itemsPerPage = 25;
-
-  @property({type: Boolean})
-  _loading = true;
-
-  @property({type: String})
-  _filter?: string;
-
-  @property({type: String})
-  _refName?: GitRef;
-
-  @property({type: Boolean})
-  _hasNewItemName = false;
-
-  @property({type: Boolean})
-  _isEditing = false;
-
-  @property({type: String})
-  _revisedRef?: GitRef;
-
-  private readonly restApiService = appContext.restApiService;
-
-  _determineIfOwner(repo: RepoName) {
+  // private but used in test
+  determineIfOwner(repo: RepoName) {
     return this.restApiService
       .getRepoAccess(repo)
-      .then(access => (this._isOwner = !!access?.[repo]?.is_owner));
+      .then(access => (this.isOwner = !!access?.[repo]?.is_owner));
   }
 
-  _paramsChanged(params?: AppElementRepoParams) {
-    if (!params?.repo) {
+  // private but used in test
+  paramsChanged() {
+    if (!this.params?.repo) {
       return Promise.reject(new Error('undefined repo'));
     }
 
@@ -134,52 +368,52 @@
     // to false and polymer removes this component, hence check for params
     if (
       !(
-        params?.detail === RepoDetailView.BRANCHES ||
-        params?.detail === RepoDetailView.TAGS
+        this.params?.detail === RepoDetailView.BRANCHES ||
+        this.params?.detail === RepoDetailView.TAGS
       )
     ) {
       return;
     }
 
-    this._repo = params.repo;
+    this.repo = this.params.repo;
 
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn && this._repo) {
-        this._determineIfOwner(this._repo);
+    this.getLoggedIn().then(loggedIn => {
+      this.loggedIn = loggedIn;
+      if (loggedIn && this.repo) {
+        this.determineIfOwner(this.repo);
       }
     });
 
-    this.detailType = params.detail;
+    this.detailType = this.params.detail;
 
-    this._filter = params?.filter ?? '';
-    this._offset = Number(params?.offset ?? 0);
+    this.filter = this.params?.filter ?? '';
+    this.offset = Number(this.params?.offset ?? 0);
     if (!this.detailType)
       return Promise.reject(new Error('undefined detailType'));
 
-    return this._getItems(
-      this._filter,
-      this._repo,
-      this._itemsPerPage,
-      this._offset,
+    return this.getItems(
+      this.filter,
+      this.repo,
+      this.itemsPerPage,
+      this.offset,
       this.detailType
     );
   }
 
   // TODO(TS) Move this to object for easier read, understand.
-  _getItems(
+  private getItems(
     filter: string | undefined,
     repo: RepoName | undefined,
     itemsPerPage: number,
     offset: number | undefined,
-    detailType: string
+    detailType?: string
   ) {
     if (filter === undefined || !repo || offset === undefined) {
       return Promise.reject(new Error('filter or repo or offset undefined'));
     }
-    this._loading = true;
-    this._items = [];
-    flush();
+    this.loading = true;
+    this.items = [];
+
     const errFn: ErrorCallback = response => {
       firePageError(response);
     };
@@ -188,52 +422,45 @@
       return this.restApiService
         .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
         .then(items => {
-          if (!items) {
-            return;
-          }
-          this._items = items;
-          this._loading = false;
+          this.items = items ?? [];
+          this.loading = false;
+        })
+        .finally(() => {
+          this.loading = false;
         });
     } else if (detailType === RepoDetailView.TAGS) {
       return this.restApiService
         .getRepoTags(filter, repo, itemsPerPage, offset, errFn)
         .then(items => {
-          if (!items) {
-            return;
-          }
-          this._items = items;
-          this._loading = false;
+          this.items = items ?? [];
+        })
+        .finally(() => {
+          this.loading = false;
         });
     }
     return Promise.reject(new Error('unknown detail type'));
   }
 
-  _getPath(repo?: RepoName, detailType?: RepoDetailView) {
+  private getPath(repo?: RepoName, detailType?: RepoDetailView) {
+    // TODO: Replace with `createRepoUrl()`, but be aware that `encodeURL()`
+    // gets `false` as a second parameter here. The router pattern in gr-router
+    // does not handle the filter URLs, if the repo is not encoded!
     return `/admin/repos/${encodeURL(repo ?? '', false)},${detailType}`;
   }
 
-  _computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
-    if (!repo.web_links) {
-      return '';
-    }
+  private computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
+    if (!repo.web_links) return [];
     const webLinks = repo.web_links;
-    return webLinks.length ? webLinks : null;
+    return webLinks.length ? webLinks : [];
   }
 
-  _computeFirstWebLink(repo: ProjectInfo | BranchInfo | TagInfo) {
-    const webLinks = this._computeWeblink(repo);
-    return webLinks ? webLinks[0].url : null;
+  private computeFirstWebLink(repo: ProjectInfo | BranchInfo | TagInfo) {
+    const webLinks = this.computeWeblink(repo);
+    return webLinks.length > 0 ? webLinks[0].url : undefined;
   }
 
-  _computeMessage(message?: string) {
-    if (!message) {
-      return;
-    }
-    // Strip PGP info.
-    return message.split(PGP_START)[0];
-  }
-
-  _stripRefs(item: GitRef, detailType?: RepoDetailView) {
+  // private but used in test
+  stripRefs(item: GitRef, detailType?: RepoDetailView) {
     if (detailType === RepoDetailView.BRANCHES) {
       return item.replace('refs/heads/', '');
     } else if (detailType === RepoDetailView.TAGS) {
@@ -242,62 +469,61 @@
     throw new Error('unknown detailType');
   }
 
-  _getLoggedIn() {
+  // private but used in test
+  getLoggedIn() {
     return this.restApiService.getLoggedIn();
   }
 
-  _computeEditingClass(isEditing: boolean) {
-    return isEditing ? 'editing' : '';
-  }
-
-  _computeCanEditClass(
+  private computeCanEditClass(
     ref?: GitRef,
     detailType?: RepoDetailView,
     isOwner?: boolean
   ) {
     if (ref === undefined || detailType === undefined) return '';
-    return isOwner && this._stripRefs(ref, detailType) === 'HEAD'
+    return isOwner && this.stripRefs(ref, detailType) === 'HEAD'
       ? 'canEdit'
       : '';
   }
 
-  _handleEditRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
-    this._revisedRef = e.model.get('item.revision') as unknown as GitRef;
-    this._isEditing = true;
+  private handleEditRevision(index: number) {
+    if (!this.items) return;
+
+    this.revisedRef = this.items[index].revision as GitRef;
+    this.isEditing = true;
   }
 
-  _handleCancelRevision() {
-    this._isEditing = false;
+  private handleCancelRevision() {
+    this.isEditing = false;
   }
 
-  _handleSaveRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
-    if (this._revisedRef && this._repo)
-      this._setRepoHead(this._repo, this._revisedRef, e);
+  // private but used in test
+  handleSaveRevision(index: number) {
+    if (this.revisedRef && this.repo)
+      this.setRepoHead(this.repo, this.revisedRef, index);
   }
 
-  _setRepoHead(
-    repo: RepoName,
-    ref: GitRef,
-    e: PolymerDomRepeatEvent<BranchInfo | TagInfo>
-  ) {
+  // private but used in test
+  setRepoHead(repo: RepoName, ref: GitRef, index: number) {
+    if (!this.items) return;
     return this.restApiService.setRepoHead(repo, ref).then(res => {
       if (res.status < 400) {
-        this._isEditing = false;
-        e.model.set('item.revision', ref);
-        // This is needed to refresh _items property with fresh data,
+        this.isEditing = false;
+        this.items![index].revision = ref;
+        // This is needed to refresh 'items' property with fresh data,
         // specifically can_delete from the json response.
-        this._getItems(
-          this._filter,
-          this._repo,
-          this._itemsPerPage,
-          this._offset,
-          this.detailType!
+        this.getItems(
+          this.filter,
+          this.repo,
+          this.itemsPerPage,
+          this.offset,
+          this.detailType
         );
       }
     });
   }
 
-  _computeItemName(detailType?: RepoDetailView) {
+  // private but used in test
+  computeItemName(detailType?: RepoDetailView) {
     if (detailType === undefined) return '';
     if (detailType === RepoDetailView.BRANCHES) {
       return 'Branch';
@@ -307,36 +533,37 @@
     throw new Error('unknown detailType');
   }
 
-  _handleDeleteItemConfirm() {
-    this.$.overlay.close();
-    if (!this._repo || !this._refName) {
+  private handleDeleteItemConfirm() {
+    assertIsDefined(this.modal, 'modal');
+    this.modal.close();
+    if (!this.repo || !this.refName) {
       return Promise.reject(new Error('undefined repo or refName'));
     }
     if (this.detailType === RepoDetailView.BRANCHES) {
       return this.restApiService
-        .deleteRepoBranches(this._repo, this._refName)
+        .deleteRepoBranches(this.repo, this.refName)
         .then(itemDeleted => {
           if (itemDeleted.status === 204) {
-            this._getItems(
-              this._filter,
-              this._repo,
-              this._itemsPerPage,
-              this._offset,
-              this.detailType!
+            this.getItems(
+              this.filter,
+              this.repo,
+              this.itemsPerPage,
+              this.offset,
+              this.detailType
             );
           }
         });
     } else if (this.detailType === RepoDetailView.TAGS) {
       return this.restApiService
-        .deleteRepoTags(this._repo, this._refName)
+        .deleteRepoTags(this.repo, this.refName)
         .then(itemDeleted => {
           if (itemDeleted.status === 204) {
-            this._getItems(
-              this._filter,
-              this._repo,
-              this._itemsPerPage,
-              this._offset,
-              this.detailType!
+            this.getItems(
+              this.filter,
+              this.repo,
+              this.itemsPerPage,
+              this.offset,
+              this.detailType
             );
           }
         });
@@ -344,61 +571,49 @@
     return Promise.reject(new Error('unknown detail type'));
   }
 
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
+  private handleConfirmDialogCancel() {
+    assertIsDefined(this.modal, 'modal');
+    this.modal.close();
   }
 
-  _handleDeleteItem(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
-    const name = this._stripRefs(
-      e.model.get('item.ref'),
+  private handleDeleteItem(index: number) {
+    if (!this.items) return;
+    assertIsDefined(this.modal, 'modal');
+    const name = this.stripRefs(
+      this.items[index].ref,
       this.detailType
     ) as GitRef;
-    if (!name) {
-      return;
-    }
-    this._refName = name;
-    this.$.overlay.open();
+    if (!name) return;
+    this.refName = name;
+    this.modal.showModal();
   }
 
-  _computeHideDeleteClass(owner?: boolean, canDelete?: boolean) {
-    if (canDelete || owner) {
-      return 'show';
-    }
-
-    return '';
+  // private but used in test
+  handleCreateItem() {
+    assertIsDefined(this.createNewModal, 'createNewModal');
+    this.createNewModal.handleCreateItem();
+    this.handleCloseCreate();
   }
 
-  _handleCreateItem() {
-    this.$.createNewModal.handleCreateItem();
-    this._handleCloseCreate();
+  // private but used in test
+  handleCloseCreate() {
+    assertIsDefined(this.createModal, 'createModal');
+    this.createModal.close();
   }
 
-  _handleCloseCreate() {
-    this.$.createOverlay.close();
+  // private but used in test
+  handleCreateClicked() {
+    assertIsDefined(this.createModal, 'createModal');
+    this.createModal.showModal();
   }
 
-  _handleCreateClicked() {
-    this.$.createOverlay.open();
+  private handleUpdateItemName() {
+    assertIsDefined(this.createNewModal, 'createNewModal');
+    this.newItemName = !!this.createNewModal.itemName;
   }
 
-  _hideIfBranch(type?: RepoDetailView) {
-    if (type === RepoDetailView.BRANCHES) {
-      return 'hideItem';
-    }
-
-    return '';
-  }
-
-  _computeHideTagger(tagger?: GitPersonInfo) {
-    return tagger ? '' : 'hide';
-  }
-
-  computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  computeShownItems(items: BranchInfo[] | TagInfo[]) {
-    return items.slice(0, SHOWN_ITEMS_COUNT);
+  private handleRevisedRefBindValueChanged(e: BindValueChangeEvent) {
+    this.revisedRef = e.detail.value as GitRef;
   }
 }
 
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
deleted file mode 100644
index 429a6d6..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .tags td.name {
-      min-width: 25em;
-    }
-    td.name,
-    td.revision,
-    td.message {
-      word-break: break-word;
-    }
-    td.revision.tags {
-      width: 27em;
-    }
-    td.message,
-    td.tagger {
-      max-width: 15em;
-    }
-    .editing .editItem {
-      display: inherit;
-    }
-    .editItem,
-    .editing .editBtn,
-    .canEdit .revisionNoEditing,
-    .editing .revisionWithEditing,
-    .revisionEdit,
-    .hideItem {
-      display: none;
-    }
-    .revisionEdit gr-button {
-      margin-left: var(--spacing-m);
-    }
-    .editBtn {
-      margin-left: var(--spacing-l);
-    }
-    .canEdit .revisionEdit {
-      align-items: center;
-      display: flex;
-    }
-    .deleteButton:not(.show) {
-      display: none;
-    }
-    .tagger.hide {
-      display: none;
-    }
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-list-view
-    create-new="[[_loggedIn]]"
-    filter="[[_filter]]"
-    items-per-page="[[_itemsPerPage]]"
-    items="[[_items]]"
-    loading="[[_loading]]"
-    offset="[[_offset]]"
-    on-create-clicked="_handleCreateClicked"
-    path="[[_getPath(_repo, detailType)]]"
-  >
-    <table id="list" class="genericList gr-form-styles">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Name</th>
-          <th class="revision topHeader">Revision</th>
-          <th class$="message topHeader [[_hideIfBranch(detailType)]]">
-            Message
-          </th>
-          <th class$="tagger topHeader [[_hideIfBranch(detailType)]]">
-            Tagger
-          </th>
-          <th class="repositoryBrowser topHeader">Repository Browser</th>
-          <th class="delete topHeader"></th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_shownItems]]">
-          <tr class="table">
-            <td class$="[[detailType]] name">
-              <a href$="[[_computeFirstWebLink(item)]]">
-                [[_stripRefs(item.ref, detailType)]]
-              </a>
-            </td>
-            <td
-              class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]"
-            >
-              <span class="revisionNoEditing"> [[item.revision]] </span>
-              <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
-                <span class="revisionWithEditing"> [[item.revision]] </span>
-                <gr-button
-                  link=""
-                  on-click="_handleEditRevision"
-                  class="editBtn"
-                >
-                  edit
-                </gr-button>
-                <iron-input bind-value="{{_revisedRef}}" class="editItem">
-                  <input is="iron-input" bind-value="{{_revisedRef}}" />
-                </iron-input>
-                <gr-button
-                  link=""
-                  on-click="_handleCancelRevision"
-                  class="cancelBtn editItem"
-                >
-                  Cancel
-                </gr-button>
-                <gr-button
-                  link=""
-                  on-click="_handleSaveRevision"
-                  class="saveBtn editItem"
-                  disabled="[[!_revisedRef]]"
-                >
-                  Save
-                </gr-button>
-              </span>
-            </td>
-            <td class$="message [[_hideIfBranch(detailType)]]">
-              [[_computeMessage(item.message)]]
-            </td>
-            <td class$="tagger [[_hideIfBranch(detailType)]]">
-              <div class$="tagger [[_computeHideTagger(item.tagger)]]">
-                <gr-account-link account="[[item.tagger]]"> </gr-account-link>
-                (<gr-date-formatter withTooltip date-str="[[item.tagger.date]]">
-                </gr-date-formatter
-                >)
-              </div>
-            </td>
-            <td class="repositoryBrowser">
-              <template
-                is="dom-repeat"
-                items="[[_computeWeblink(item)]]"
-                as="link"
-              >
-                <a
-                  href$="[[link.url]]"
-                  class="webLink"
-                  rel="noopener"
-                  target="_blank"
-                >
-                  ([[link.name]])
-                </a>
-              </template>
-            </td>
-            <td class="delete">
-              <gr-button
-                link=""
-                class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
-                on-click="_handleDeleteItem"
-              >
-                Delete
-              </gr-button>
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-    <gr-overlay id="overlay" with-backdrop="">
-      <gr-confirm-delete-item-dialog
-        class="confirmDialog"
-        on-confirm="_handleDeleteItemConfirm"
-        on-cancel="_handleConfirmDialogCancel"
-        item="[[_refName]]"
-        item-type-name="[[_computeItemName(detailType)]]"
-      ></gr-confirm-delete-item-dialog>
-    </gr-overlay>
-  </gr-list-view>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      id="createDialog"
-      disabled="[[!_hasNewItemName]]"
-      confirm-label="Create"
-      on-confirm="_handleCreateItem"
-      on-cancel="_handleCloseCreate"
-    >
-      <div class="header" slot="header">
-        Create [[_computeItemName(detailType)]]
-      </div>
-      <div class="main" slot="main">
-        <gr-create-pointer-dialog
-          id="createNewModal"
-          detail-type="[[_computeItemName(detailType)]]"
-          has-new-item-name="{{_hasNewItemName}}"
-          item-detail="[[detailType]]"
-          repo-name="[[_repo]]"
-        ></gr-create-pointer-dialog>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
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
deleted file mode 100644
index d5eb5d5..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
+++ /dev/null
@@ -1,503 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-repo-detail-list.js';
-import 'lodash/lodash.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {
-  addListenerForTest,
-  mockPromise,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-import {RepoDetailView} from '../../core/gr-navigation/gr-navigation.js';
-
-const basicFixture = fixtureFromElement('gr-repo-detail-list');
-
-let counter;
-const branchGenerator = () => {
-  return {
-    ref: `refs/heads/test${++counter}`,
-    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-    web_links: [
-      {
-        name: 'diffusion',
-        url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
-      },
-    ],
-  };
-};
-const tagGenerator = () => {
-  return {
-    ref: `refs/tags/test${++counter}`,
-    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
-    web_links: [
-      {
-        name: 'diffusion',
-        url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
-      },
-    ],
-    message: 'Annotated tag',
-    tagger: {
-      name: 'Test User',
-      email: 'test.user@gmail.com',
-      date: '2017-09-19 14:54:00.000000000',
-      tz: 540,
-    },
-  };
-};
-
-suite('gr-repo-detail-list', () => {
-  suite('Branches', () => {
-    let element;
-    let branches;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.detailType = 'branches';
-      counter = 0;
-      sinon.stub(page, 'show');
-    });
-
-    suite('list of repo branches', () => {
-      setup(async () => {
-        branches = [{
-          ref: 'HEAD',
-          revision: 'master',
-        }].concat(_.times(25, branchGenerator));
-        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
-
-        const params = {
-          repo: 'test',
-          detail: 'branches',
-        };
-        await element._paramsChanged(params);
-        await flush();
-      });
-
-      test('test for branch in the list', () => {
-        assert.equal(element._items[2].ref, 'refs/heads/test2');
-      });
-
-      test('test for web links in the branches list', () => {
-        assert.equal(element._items[2].web_links[0].url,
-            'https://git.example.org/branch/test;refs/heads/test2');
-      });
-
-      test('test for refs/heads/ being striped from ref', () => {
-        assert.equal(element._stripRefs(element._items[2].ref,
-            element.detailType), 'test2');
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-
-      test('Edit HEAD button not admin', async () => {
-        sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        stubRestApi('getRepoAccess').returns(
-            Promise.resolve({
-              test: {is_owner: false},
-            }));
-        await element._determineIfOwner('test');
-        assert.equal(element._isOwner, false);
-        assert.equal(getComputedStyle(dom(element.root)
-            .querySelector('.revisionNoEditing')).display, 'inline');
-        assert.equal(getComputedStyle(dom(element.root)
-            .querySelector('.revisionEdit')).display, 'none');
-      });
-
-      test('Edit HEAD button admin', async () => {
-        const saveBtn = element.root.querySelector('.saveBtn');
-        const cancelBtn = element.root.querySelector('.cancelBtn');
-        const editBtn = element.root.querySelector('.editBtn');
-        const revisionNoEditing = dom(element.root)
-            .querySelector('.revisionNoEditing');
-        const revisionWithEditing = dom(element.root)
-            .querySelector('.revisionWithEditing');
-
-        sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        stubRestApi('getRepoAccess').returns(
-            Promise.resolve({
-              test: {is_owner: true},
-            }));
-        sinon.stub(element, '_handleSaveRevision');
-        await element._determineIfOwner('test');
-        assert.equal(element._isOwner, true);
-        // The revision container for non-editing enabled row is not visible.
-        assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
-
-        // The revision container for editing enabled row is visible.
-        assert.notEqual(getComputedStyle(dom(element.root)
-            .querySelector('.revisionEdit')).display, 'none');
-
-        // The revision and edit button are visible.
-        assert.notEqual(getComputedStyle(revisionWithEditing).display,
-            'none');
-        assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-        // The input, cancel, and save buttons are not visible.
-        const hiddenElements = dom(element.root)
-            .querySelectorAll('.canEdit .editItem');
-
-        for (const item of hiddenElements) {
-          assert.equal(getComputedStyle(item).display, 'none');
-        }
-
-        MockInteractions.tap(editBtn);
-        await flush();
-        // The revision and edit button are not visible.
-        assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
-        assert.equal(getComputedStyle(editBtn).display, 'none');
-
-        // The input, cancel, and save buttons are not visible.
-        for (const item of hiddenElements) {
-          assert.notEqual(getComputedStyle(item).display, 'none');
-        }
-
-        // The revised ref was set correctly
-        assert.equal(element._revisedRef, 'master');
-
-        assert.isFalse(saveBtn.disabled);
-
-        // Delete the ref.
-        element._revisedRef = '';
-        assert.isTrue(saveBtn.disabled);
-
-        // Change the ref to something else
-        element._revisedRef = 'newRef';
-        element._repo = 'test';
-        assert.isFalse(saveBtn.disabled);
-
-        // Save button calls handleSave. since this is stubbed, the edit
-        // section remains open.
-        MockInteractions.tap(saveBtn);
-        assert.isTrue(element._handleSaveRevision.called);
-
-        // When cancel is tapped, the edit secion closes.
-        MockInteractions.tap(cancelBtn);
-        await flush();
-
-        // The revision and edit button are visible.
-        assert.notEqual(getComputedStyle(revisionWithEditing).display,
-            'none');
-        assert.notEqual(getComputedStyle(editBtn).display, 'none');
-
-        // The input, cancel, and save buttons are not visible.
-        for (const item of hiddenElements) {
-          assert.equal(getComputedStyle(item).display, 'none');
-        }
-      });
-
-      test('_handleSaveRevision with invalid rev', async () => {
-        const event = {model: {set: sinon.stub()}};
-        element._isEditing = true;
-        stubRestApi('setRepoHead').returns(
-            Promise.resolve({
-              status: 400,
-            })
-        );
-
-        await element._setRepoHead('test', 'newRef', event);
-        assert.isTrue(element._isEditing);
-        assert.isFalse(event.model.set.called);
-      });
-
-      test('_handleSaveRevision with valid rev', async () => {
-        const event = {model: {set: sinon.stub()}};
-        element._isEditing = true;
-        stubRestApi('setRepoHead').returns(
-            Promise.resolve({
-              status: 200,
-            })
-        );
-
-        await element._setRepoHead('test', 'newRef', event);
-        assert.isFalse(element._isEditing);
-        assert.isTrue(event.model.set.called);
-      });
-
-      test('test _computeItemName', () => {
-        assert.deepEqual(element._computeItemName('branches'), 'Branch');
-        assert.deepEqual(element._computeItemName('tags'), 'Tag');
-      });
-    });
-
-    suite('list with less then 25 branches', () => {
-      setup(async () => {
-        branches = _.times(25, branchGenerator);
-        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
-
-        const params = {
-          repo: 'test',
-          detail: 'branches',
-        };
-
-        await element._paramsChanged(params);
-        await flush();
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', async () => {
-        const stub = stubRestApi('getRepoBranches').returns(
-            Promise.resolve(branches));
-        const params = {
-          detail: 'branches',
-          repo: 'test',
-          filter: 'test',
-          offset: 25,
-        };
-        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', async () => {
-        const response = {status: 404};
-        stubRestApi('getRepoBranches').callsFake(
-            (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
-              errFn(response);
-              return Promise.resolve();
-            });
-
-        const promise = mockPromise();
-        addListenerForTest(document, 'page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          promise.resolve();
-        });
-
-        const params = {
-          detail: 'branches',
-          repo: 'test',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params);
-        await promise;
-      });
-    });
-  });
-
-  suite('Tags', () => {
-    let element;
-    let tags;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.detailType = 'tags';
-      counter = 0;
-      sinon.stub(page, 'show');
-    });
-
-    test('_computeMessage', () => {
-      let message = 'v2.15-rc1↵-----BEGIN PGP SIGNATURE-----↵Version: GnuPG v' +
-      '1↵↵iQIcBAABAgAGBQJZ27O7AAoJEF/XxZqaEoiMy6kQAMoQCpGr3J6JITI4BVWsr7QM↵xy' +
-      'EcWH5YPUko5EPTbkABHmaVyFmKGkuIQdn6c+NIbqJOk+5XT4oUyRSo1T569HPJ↵3kyxEJi' +
-      'T1ryvp5BIHwdvHx58fjw1+YkiWLZuZq1FFkUYqnWTYCrkv7Fok98pdOmV↵CL1Hgugi5uK8' +
-      '/kxf1M7+Nv6piaZ140pwSb1h6QdAjaZVfaBCnoxlG4LRUqHvEYay↵f4QYgFT67auHIGkZ4' +
-      'moUcsp2Du/1jSsCWL/CPwjPFGbbckVAjLCMT9yD3NKwpEZF↵pfsiZyHI9dL0M+QjVrM+RD' +
-      'HwIIJwra8R0IMkDlQ6MDrFlKNqNBbo588S6UPrm71L↵YuiwWlcrK9ZIybxT6LzbR65Rvez' +
-      'DSitQ+xeIfpZE19/X6BCnvlARLE8k/tC2JksI↵lEZi7Lf3FQdIcwwyt98tJkS9HX9v9jbC' +
-      '5QXifnoj3Li8tHSLuQ1dJCxHQiis6ojI↵OWUFkm0IHBXVNHA2dqYBdM+pL12mlI3wp6Ica' +
-      '4cdEVDwzu+j1xnVSFUa+d+Y2xJF↵7mytuyhHiKG4hm+zbhMv6WD8Q3FoDsJZeLY99l0hYQ' +
-      'SnnkMduFVroIs45pAs8gUA↵RvYla8mm9w/543IJAPzzFarPVLSsSyQ7tJl3UBzjKRNH/rX' +
-      'W+F22qyWD1zyHPUIR↵C00ItmwlAvveImYKpQAH↵=L+K9↵-----END PGP SIGNATURE---' +
-      '--';
-      assert.equal(element._computeMessage(message), 'v2.15-rc1↵');
-      message = 'v2.15-rc1';
-      assert.equal(element._computeMessage(message), 'v2.15-rc1');
-    });
-
-    suite('list of repo tags', () => {
-      setup(async () => {
-        tags = _.times(26, tagGenerator);
-        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-        };
-
-        await element._paramsChanged(params);
-        await flush();
-      });
-
-      test('test for tag in the list', async () => {
-        assert.equal(element._items[1].ref, 'refs/tags/test2');
-      });
-
-      test('test for tag message in the list', async () => {
-        assert.equal(element._items[1].message, 'Annotated tag');
-      });
-
-      test('test for tagger in the tag list', async () => {
-        const tagger = {
-          name: 'Test User',
-          email: 'test.user@gmail.com',
-          date: '2017-09-19 14:54:00.000000000',
-          tz: 540,
-        };
-
-        assert.deepEqual(element._items[1].tagger, tagger);
-      });
-
-      test('test for web links in the tags list', async () => {
-        assert.equal(element._items[1].web_links[0].url,
-            'https://git.example.org/tag/test;refs/tags/test2');
-      });
-
-      test('test for refs/tags/ being striped from ref', async () => {
-        assert.equal(element._stripRefs(element._items[1].ref,
-            element.detailType), 'test2');
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-
-      test('_computeHideTagger', () => {
-        const testObject1 = {
-          tagger: 'test',
-        };
-        assert.equal(element._computeHideTagger(testObject1), '');
-
-        assert.equal(element._computeHideTagger(undefined), 'hide');
-      });
-    });
-
-    suite('list with less then 25 tags', () => {
-      setup(async () => {
-        tags = _.times(25, tagGenerator);
-        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-        };
-
-        await element._paramsChanged(params);
-        await flush();
-      });
-
-      test('_shownItems', () => {
-        assert.equal(element._shownItems.length, 25);
-      });
-    });
-
-    suite('filter', () => {
-      test('_paramsChanged', async () => {
-        const stub = stubRestApi('getRepoTags').returns(Promise.resolve(tags));
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-          filter: 'test',
-          offset: 25,
-        };
-        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('create new', () => {
-      test('_handleCreateClicked called when create-click fired', () => {
-        sinon.stub(element, '_handleCreateClicked');
-        element.shadowRoot
-            .querySelector('gr-list-view').dispatchEvent(
-                new CustomEvent('create-clicked', {
-                  composed: true, bubbles: true,
-                }));
-        assert.isTrue(element._handleCreateClicked.called);
-      });
-
-      test('_handleCreateClicked opens modal', () => {
-        const openStub = sinon.stub(element.$.createOverlay, 'open');
-        element._handleCreateClicked();
-        assert.isTrue(openStub.called);
-      });
-
-      test('_handleCreateItem called when confirm fired', () => {
-        sinon.stub(element, '_handleCreateItem');
-        element.$.createDialog.dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-        assert.isTrue(element._handleCreateItem.called);
-      });
-
-      test('_handleCloseCreate called when cancel fired', () => {
-        sinon.stub(element, '_handleCloseCreate');
-        element.$.createDialog.dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-        assert.isTrue(element._handleCloseCreate.called);
-      });
-    });
-
-    suite('404', () => {
-      test('fires page-error', async () => {
-        const response = {status: 404};
-        stubRestApi('getRepoTags').callsFake(
-            (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
-              errFn(response);
-              return Promise.resolve();
-            });
-
-        const promise = mockPromise();
-        addListenerForTest(document, 'page-error', e => {
-          assert.deepEqual(e.detail.response, response);
-          promise.resolve();
-        });
-
-        const params = {
-          repo: 'test',
-          detail: 'tags',
-          filter: 'test',
-          offset: 25,
-        };
-        element._paramsChanged(params);
-        await promise;
-      });
-    });
-
-    test('test _computeHideDeleteClass', () => {
-      assert.deepEqual(element._computeHideDeleteClass(true, false), 'show');
-      assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
-      assert.deepEqual(element._computeHideDeleteClass(false, false), '');
-    });
-
-    test('_computeItemName', () => {
-      assert.equal(element._computeItemName(RepoDetailView.BRANCHES), 'Branch');
-      assert.equal(element._computeItemName(RepoDetailView.TAGS),
-          'Tag');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
new file mode 100644
index 0000000..ec1bb82
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
@@ -0,0 +1,2531 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-repo-detail-list';
+import {GrRepoDetailList} from './gr-repo-detail-list';
+import {page} from '../../../utils/page-wrapper-utils';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  BranchInfo,
+  EmailAddress,
+  GitRef,
+  GroupId,
+  GroupName,
+  RepoAccessGroups,
+  RepoAccessInfoMap,
+  RepoName,
+  TagInfo,
+  Timestamp,
+  TimezoneOffset,
+} from '../../../types/common';
+import {GerritView} from '../../../services/router/router-model';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {PageErrorEvent} from '../../../types/events';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrListView} from '../../shared/gr-list-view/gr-list-view';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {fixture, html, assert} from '@open-wc/testing';
+import {RepoDetailView} from '../../../models/views/repo';
+
+function branchGenerator(counter: number) {
+  return {
+    ref: `refs/heads/test${counter}` as GitRef,
+    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
+      },
+    ],
+  };
+}
+
+function createBranchesList(n: number) {
+  const branches = [];
+  for (let i = 0; i < n; ++i) {
+    branches.push(branchGenerator(i));
+  }
+  return branches;
+}
+
+function tagGenerator(counter: number) {
+  return {
+    ref: `refs/tags/test${counter}` as GitRef,
+    revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+    can_delete: false,
+    web_links: [
+      {
+        name: 'diffusion',
+        url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
+      },
+    ],
+    message: 'Annotated tag',
+    tagger: {
+      name: 'Test User',
+      email: 'test.user@gmail.com' as EmailAddress,
+      date: '2017-09-19 14:54:00.000000000' as Timestamp,
+      tz: 540 as TimezoneOffset,
+    },
+  };
+}
+
+function createTagsList(n: number) {
+  const tags = [];
+  for (let i = 0; i < n; ++i) {
+    tags.push(tagGenerator(i));
+  }
+  return tags;
+}
+
+suite('gr-repo-detail-list', () => {
+  suite('Branches', () => {
+    let element: GrRepoDetailList;
+    let branches: BranchInfo[];
+
+    setup(async () => {
+      element = await fixture(
+        html`<gr-repo-detail-list></gr-repo-detail-list>`
+      );
+      element.detailType = RepoDetailView.BRANCHES;
+      sinon.stub(page, 'show');
+    });
+
+    suite('list of repo branches', () => {
+      setup(async () => {
+        branches = [
+          {
+            ref: 'HEAD' as GitRef,
+            revision: 'master',
+          },
+        ].concat(createBranchesList(25));
+        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+        };
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
+      test('render', () => {
+        assert.shadowDom.equal(
+          element,
+          /* HTML */ `
+            <gr-list-view>
+              <table class="genericList gr-form-styles" id="list">
+                <tbody>
+                  <tr class="headerRow">
+                    <th class="name topHeader">Name</th>
+                    <th class="revision topHeader">Revision</th>
+                    <th class="hideItem message topHeader">Message</th>
+                    <th class="hideItem tagger topHeader">Tagger</th>
+                    <th class="repositoryBrowser topHeader">
+                      Repository Browser
+                    </th>
+                    <th class="delete topHeader"></th>
+                  </tr>
+                  <tr class="loadingMsg" id="loading">
+                    <td>Loading...</td>
+                  </tr>
+                </tbody>
+                <tbody>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a> HEAD </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing"> master </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing"> master </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="0"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="0"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser"></td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="0"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test0"
+                      >
+                        test0
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="1"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="1"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test0"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="1"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test1"
+                      >
+                        test1
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="2"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="2"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test1"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="2"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test2"
+                      >
+                        test2
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="3"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="3"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test2"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="3"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test3"
+                      >
+                        test3
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="4"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="4"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test3"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="4"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test4"
+                      >
+                        test4
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="5"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="5"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test4"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="5"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test5"
+                      >
+                        test5
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="6"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="6"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test5"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="6"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test6"
+                      >
+                        test6
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="7"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="7"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test6"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="7"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test7"
+                      >
+                        test7
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="8"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="8"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test7"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="8"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test8"
+                      >
+                        test8
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="9"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="9"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test8"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="9"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test9"
+                      >
+                        test9
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="10"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="10"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test9"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="10"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test10"
+                      >
+                        test10
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="11"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="11"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test10"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="11"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test11"
+                      >
+                        test11
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="12"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="12"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test11"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="12"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test12"
+                      >
+                        test12
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="13"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="13"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test12"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="13"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test13"
+                      >
+                        test13
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="14"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="14"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test13"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="14"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test14"
+                      >
+                        test14
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="15"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="15"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test14"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="15"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test15"
+                      >
+                        test15
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="16"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="16"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test15"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="16"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test16"
+                      >
+                        test16
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="17"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="17"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test16"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="17"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test17"
+                      >
+                        test17
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="18"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="18"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test17"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="18"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test18"
+                      >
+                        test18
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="19"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="19"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test18"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="19"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test19"
+                      >
+                        test19
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="20"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="20"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test19"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="20"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test20"
+                      >
+                        test20
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="21"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="21"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test20"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="21"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test21"
+                      >
+                        test21
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="22"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="22"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test21"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="22"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test22"
+                      >
+                        test22
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="23"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="23"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test22"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="23"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                  <tr class="table">
+                    <td class="branches name">
+                      <a
+                        href="https://git.example.org/branch/test;refs/heads/test23"
+                      >
+                        test23
+                      </a>
+                    </td>
+                    <td class="branches revision">
+                      <span class="revisionNoEditing">
+                        9c9d08a438e55e52f33b608415e6dddd9b18550d
+                      </span>
+                      <span class="revisionEdit">
+                        <span class="revisionWithEditing">
+                          9c9d08a438e55e52f33b608415e6dddd9b18550d
+                        </span>
+                        <gr-button
+                          aria-disabled="false"
+                          class="editBtn"
+                          data-index="24"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          edit
+                        </gr-button>
+                        <iron-input class="editItem">
+                          <input />
+                        </iron-input>
+                        <gr-button
+                          aria-disabled="false"
+                          class="cancelBtn editItem"
+                          link=""
+                          role="button"
+                          tabindex="0"
+                        >
+                          Cancel
+                        </gr-button>
+                        <gr-button
+                          aria-disabled="true"
+                          class="editItem saveBtn"
+                          data-index="24"
+                          disabled=""
+                          link=""
+                          role="button"
+                          tabindex="-1"
+                        >
+                          Save
+                        </gr-button>
+                      </span>
+                    </td>
+                    <td class="hideItem message"></td>
+                    <td class="hideItem tagger"></td>
+                    <td class="repositoryBrowser">
+                      <a
+                        class="webLink"
+                        href="https://git.example.org/branch/test;refs/heads/test23"
+                        rel="noopener"
+                        target="_blank"
+                      >
+                        (diffusion)
+                      </a>
+                    </td>
+                    <td class="delete">
+                      <gr-button
+                        aria-disabled="false"
+                        class="deleteButton"
+                        data-index="24"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+              <dialog id="modal" tabindex="-1">
+                <gr-confirm-delete-item-dialog class="confirmDialog">
+                </gr-confirm-delete-item-dialog>
+              </dialog>
+            </gr-list-view>
+            <dialog id="createModal" tabindex="-1">
+              <gr-dialog
+                confirm-label="Create"
+                disabled=""
+                id="createDialog"
+                role="dialog"
+              >
+                <div class="header" slot="header">Create Branch</div>
+                <div class="main" slot="main">
+                  <gr-create-pointer-dialog id="createNewModal">
+                  </gr-create-pointer-dialog>
+                </div>
+              </gr-dialog>
+            </dialog>
+          `
+        );
+      });
+
+      test('test for branch in the list', () => {
+        assert.equal(element.items![3].ref, 'refs/heads/test2');
+      });
+
+      test('test for web links in the branches list', () => {
+        assert.equal(
+          element.items![3].web_links![0].url,
+          'https://git.example.org/branch/test;refs/heads/test2'
+        );
+      });
+
+      test('test for refs/heads/ being striped from ref', () => {
+        assert.equal(
+          element.stripRefs(element.items![3].ref, element.detailType),
+          'test2'
+        );
+      });
+
+      test('items', () => {
+        assert.equal(queryAll<HTMLTableElement>(element, '.table').length, 25);
+      });
+
+      test('Edit HEAD button not admin', async () => {
+        sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(true));
+        stubRestApi('getRepoAccess').returns(
+          Promise.resolve({
+            test: {
+              revision: 'xxxx',
+              local: {
+                'refs/*': {
+                  permissions: {
+                    owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
+                  },
+                },
+              },
+              owner_of: ['refs/*'] as GitRef[],
+              groups: {
+                xxxx: {
+                  id: 'xxxx' as GroupId,
+                  url: 'test',
+                  name: 'test' as GroupName,
+                },
+              } as RepoAccessGroups,
+              config_web_links: [{name: 'gitiles', url: 'test'}],
+            },
+          } as RepoAccessInfoMap)
+        );
+        await element.determineIfOwner('test' as RepoName);
+        assert.equal(element.isOwner, false);
+        assert.equal(
+          getComputedStyle(
+            queryAndAssert<HTMLSpanElement>(element, '.revisionNoEditing')
+          ).display,
+          'inline'
+        );
+        assert.equal(
+          getComputedStyle(
+            queryAndAssert<HTMLSpanElement>(element, '.revisionEdit')
+          ).display,
+          'none'
+        );
+      });
+
+      test('Edit HEAD button admin', async () => {
+        const saveBtn = queryAndAssert<GrButton>(element, '.saveBtn');
+        const cancelBtn = queryAndAssert<GrButton>(element, '.cancelBtn');
+        const editBtn = queryAndAssert<GrButton>(element, '.editBtn');
+        const revisionNoEditing = queryAndAssert<HTMLSpanElement>(
+          element,
+          '.revisionNoEditing'
+        );
+        const revisionWithEditing = queryAndAssert<HTMLSpanElement>(
+          element,
+          '.revisionWithEditing'
+        );
+
+        sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(true));
+        stubRestApi('getRepoAccess').returns(
+          Promise.resolve({
+            test: {
+              revision: 'xxxx',
+              local: {
+                'refs/*': {
+                  permissions: {
+                    owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
+                  },
+                },
+              },
+              is_owner: true,
+              owner_of: ['refs/*'] as GitRef[],
+              groups: {
+                xxxx: {
+                  id: 'xxxx' as GroupId,
+                  url: 'test',
+                  name: 'test' as GroupName,
+                },
+              } as RepoAccessGroups,
+              config_web_links: [{name: 'gitiles', url: 'test'}],
+            },
+          } as RepoAccessInfoMap)
+        );
+        const handleSaveRevisionStub = sinon.stub(
+          element,
+          'handleSaveRevision'
+        );
+        await element.determineIfOwner('test' as RepoName);
+        assert.equal(element.isOwner, true);
+        // The revision container for non-editing enabled row is not visible.
+        assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
+
+        // The revision container for editing enabled row is visible.
+        assert.notEqual(
+          getComputedStyle(
+            queryAndAssert<HTMLSpanElement>(element, '.revisionEdit')
+          ).display,
+          'none'
+        );
+
+        // The revision and edit button are visible.
+        assert.notEqual(getComputedStyle(revisionWithEditing).display, 'none');
+        assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+        // The input, cancel, and save buttons are not visible.
+        const hiddenElements = queryAll<HTMLTableElement>(
+          element,
+          '.canEdit .editItem'
+        );
+
+        for (const item of hiddenElements) {
+          assert.equal(getComputedStyle(item).display, 'none');
+        }
+
+        editBtn.click();
+        await element.updateComplete;
+        // The revision and edit button are not visible.
+        assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
+        assert.equal(getComputedStyle(editBtn).display, 'none');
+
+        // The input, cancel, and save buttons are not visible.
+        for (const item of hiddenElements) {
+          assert.notEqual(getComputedStyle(item).display, 'none');
+        }
+
+        // The revised ref was set correctly
+        assert.equal(element.revisedRef, 'master' as GitRef);
+
+        assert.isFalse(saveBtn.disabled);
+
+        // Delete the ref.
+        element.revisedRef = '' as GitRef;
+        await element.updateComplete;
+        assert.isTrue(saveBtn.disabled);
+
+        // Change the ref to something else
+        element.revisedRef = 'newRef' as GitRef;
+        element.repo = 'test' as RepoName;
+        await element.updateComplete;
+        assert.isFalse(saveBtn.disabled);
+
+        // Save button calls handleSave. since this is stubbed, the edit
+        // section remains open.
+        saveBtn.click();
+        assert.isTrue(handleSaveRevisionStub.called);
+
+        // When cancel is tapped, the edit section closes.
+        cancelBtn.click();
+        await element.updateComplete;
+
+        // The revision and edit button are visible.
+        assert.notEqual(getComputedStyle(revisionWithEditing).display, 'none');
+        assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+        // The input, cancel, and save buttons are not visible.
+        for (const item of hiddenElements) {
+          assert.equal(getComputedStyle(item).display, 'none');
+        }
+      });
+
+      test('handleSaveRevision with invalid rev', async () => {
+        element.isEditing = true;
+        stubRestApi('setRepoHead').returns(
+          Promise.resolve({
+            status: 400,
+          } as Response)
+        );
+
+        await element.setRepoHead('test' as RepoName, 'newRef' as GitRef, 1);
+        assert.isTrue(element.isEditing);
+      });
+
+      test('handleSaveRevision with valid rev', async () => {
+        element.isEditing = true;
+        stubRestApi('setRepoHead').returns(
+          Promise.resolve({
+            status: 200,
+          } as Response)
+        );
+
+        await element.setRepoHead('test' as RepoName, 'newRef' as GitRef, 1);
+        assert.isFalse(element.isEditing);
+      });
+
+      test('test computeItemName', () => {
+        assert.deepEqual(
+          element.computeItemName(RepoDetailView.BRANCHES),
+          'Branch'
+        );
+        assert.deepEqual(element.computeItemName(RepoDetailView.TAGS), 'Tag');
+      });
+    });
+
+    suite('list with less then 25 branches', () => {
+      setup(async () => {
+        branches = createBranchesList(25);
+        stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+        };
+
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
+      test('items', () => {
+        assert.equal(queryAll<HTMLTableElement>(element, '.table').length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('paramsChanged', async () => {
+        const stub = stubRestApi('getRepoBranches').returns(
+          Promise.resolve(branches)
+        );
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+          filter: 'test',
+          offset: 25,
+        };
+        await element.paramsChanged();
+        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', async () => {
+        const response = {status: 404} as Response;
+        stubRestApi('getRepoBranches').callsFake(
+          (_filter, _repo, _reposBranchesPerPage, _opt_offset, errFn) => {
+            if (errFn !== undefined) {
+              errFn(response);
+            }
+            return Promise.resolve([]);
+          }
+        );
+
+        const promise = mockPromise();
+        addListenerForTest(document, 'page-error', e => {
+          assert.deepEqual((e as PageErrorEvent).detail.response, response);
+          promise.resolve();
+        });
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+          filter: 'test',
+          offset: 25,
+        };
+        element.paramsChanged();
+        await promise;
+      });
+    });
+  });
+
+  suite('Tags', () => {
+    let element: GrRepoDetailList;
+    let tags: TagInfo[];
+
+    setup(async () => {
+      element = await fixture(
+        html`<gr-repo-detail-list></gr-repo-detail-list>`
+      );
+      element.detailType = RepoDetailView.TAGS;
+      sinon.stub(page, 'show');
+    });
+
+    suite('list of repo tags', () => {
+      setup(async () => {
+        tags = createTagsList(26);
+        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.TAGS,
+        };
+
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
+      test('test for tag in the list', async () => {
+        assert.equal(element.items![2].ref, 'refs/tags/test2');
+      });
+
+      test('test for tag message in the list', async () => {
+        assert.equal((element.items as TagInfo[])![2].message, 'Annotated tag');
+      });
+
+      test('test for tagger in the tag list', async () => {
+        const tagger = {
+          name: 'Test User',
+          email: 'test.user@gmail.com' as EmailAddress,
+          date: '2017-09-19 14:54:00.000000000' as Timestamp,
+          tz: 540 as TimezoneOffset,
+        };
+
+        assert.deepEqual((element.items as TagInfo[])![2].tagger, tagger);
+      });
+
+      test('test for web links in the tags list', async () => {
+        assert.equal(
+          element.items![2].web_links![0].url,
+          'https://git.example.org/tag/test;refs/tags/test2'
+        );
+      });
+
+      test('test for refs/tags/ being striped from ref', async () => {
+        assert.equal(
+          element.stripRefs(element.items![2].ref, element.detailType),
+          'test2'
+        );
+      });
+
+      test('items', () => {
+        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+      });
+    });
+
+    suite('list with less then 25 tags', () => {
+      setup(async () => {
+        tags = createTagsList(25);
+        stubRestApi('getRepoTags').returns(Promise.resolve(tags));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.TAGS,
+        };
+
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
+      test('items', () => {
+        assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('paramsChanged', async () => {
+        const stub = stubRestApi('getRepoTags').returns(Promise.resolve(tags));
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.TAGS,
+          filter: 'test',
+          offset: 25,
+        };
+        await element.paramsChanged();
+        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('create new', () => {
+      setup(async () => {
+        stubRestApi('getRepoBranches').resolves(createBranchesList(3));
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.BRANCHES,
+        };
+        await element.paramsChanged();
+        await element.updateComplete;
+      });
+
+      test('handleCreateClicked called when create-click fired', () => {
+        const handleCreateClickedStub = sinon.stub(
+          element,
+          'handleCreateClicked'
+        );
+        queryAndAssert<GrListView>(element, 'gr-list-view').dispatchEvent(
+          new CustomEvent('create-clicked', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        assert.isTrue(handleCreateClickedStub.called);
+      });
+
+      test('handleCreateClicked opens modal', () => {
+        queryAndAssert<HTMLDialogElement>(element, '#createModal');
+        const openStub = sinon.stub(
+          queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+          'showModal'
+        );
+        element.handleCreateClicked();
+        assert.isTrue(openStub.called);
+      });
+
+      test('handleCreateItem called when confirm fired', () => {
+        const handleCreateItemStub = sinon.stub(element, 'handleCreateItem');
+        queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+          new CustomEvent('confirm', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        assert.isTrue(handleCreateItemStub.called);
+      });
+
+      test('handleCloseCreate called when cancel fired', () => {
+        const handleCloseCreateStub = sinon.stub(element, 'handleCloseCreate');
+        queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
+          new CustomEvent('cancel', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        assert.isTrue(handleCloseCreateStub.called);
+      });
+    });
+
+    suite('404', () => {
+      test('fires page-error', async () => {
+        const response = {status: 404} as Response;
+        stubRestApi('getRepoTags').callsFake(
+          (_filter, _repo, _reposTagsPerPage, _opt_offset, errFn) => {
+            if (errFn !== undefined) {
+              errFn(response);
+            }
+            return Promise.resolve([]);
+          }
+        );
+
+        const promise = mockPromise();
+        addListenerForTest(document, 'page-error', e => {
+          assert.deepEqual((e as PageErrorEvent).detail.response, response);
+          promise.resolve();
+        });
+
+        element.params = {
+          view: GerritView.REPO,
+          repo: 'test' as RepoName,
+          detail: RepoDetailView.TAGS,
+          filter: 'test',
+          offset: 25,
+        };
+        element.paramsChanged();
+        await promise;
+      });
+    });
+
+    test('computeItemName', () => {
+      assert.equal(element.computeItemName(RepoDetailView.BRANCHES), 'Branch');
+      assert.equal(element.computeItemName(RepoDetailView.TAGS), 'Tag');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 8e50b5f..2fd7e79 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
@@ -1,40 +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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-overlay/gr-overlay';
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {AppElementAdminParams} from '../../gr-app-types';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {
-  RepoName,
-  ProjectInfoWithName,
-  WebLinkInfo,
-} from '../../../types/common';
+import {ProjectInfoWithName, WebLinkInfo} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {getAppContext} from '../../../services/app-context';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {
+  AdminChildView,
+  AdminViewState,
+  createAdminUrl,
+} from '../../../models/views/admin';
+import {createSearchUrl} from '../../../models/views/search';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {createRepoUrl} from '../../../models/views/repo';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -44,44 +32,46 @@
 
 @customElement('gr-repo-list')
 export class GrRepoList extends LitElement {
-  @query('#createOverlay')
-  createOverlay?: GrOverlay;
+  @query('#createModal') private createModal?: HTMLDialogElement;
 
-  @query('#createNewModal')
-  createNewModal?: GrCreateRepoDialog;
+  @query('#createNewModal') private createNewModal?: GrCreateRepoDialog;
 
   @property({type: Object})
-  params?: AppElementAdminParams;
+  params?: AdminViewState;
 
+  // private but used in test
   @state() offset = 0;
 
-  @state() newRepoName = false;
+  @state() private newRepoName = false;
 
-  @state() createNewCapability = false;
+  @state() private createNewCapability = false;
 
+  // private but used in test
   @state() repos: ProjectInfoWithName[] = [];
 
+  // private but used in test
   @state() reposPerPage = 25;
 
+  // private but used in test
   @state() loading = true;
 
+  // private but used in test
   @state() filter = '';
 
-  @state() readonly path = '/admin/repos';
-
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override async connectedCallback() {
     super.connectedCallback();
     await this.getCreateRepoCapability();
     fireTitleChange(this, 'Repos');
-    this.maybeOpenCreateOverlay(this.params);
+    this.maybeOpenCreateModal(this.params);
   }
 
   static override get styles() {
     return [
       tableStyles,
       sharedStyles,
+      modalStyles,
       css`
         .genericList tr td:last-of-type {
           text-align: left;
@@ -111,8 +101,8 @@
         .items=${this.repos}
         .loading=${this.loading}
         .offset=${this.offset}
-        .path=${this.path}
-        @create-clicked=${this.handleCreateClicked}
+        .path=${createAdminUrl({adminView: AdminChildView.REPOS})}
+        @create-clicked=${() => this.handleCreateClicked()}
       >
         <table id="list" class="genericList">
           <tbody>
@@ -130,29 +120,29 @@
               <td>Loading...</td>
             </tr>
           </tbody>
-          <tbody class="${this.computeLoadingClass(this.loading)}">
+          <tbody class=${this.computeLoadingClass(this.loading)}>
             ${this.renderRepoList()}
           </tbody>
         </table>
       </gr-list-view>
-      <gr-overlay id="createOverlay" with-backdrop>
+      <dialog id="createModal" tabindex="-1">
         <gr-dialog
           id="createDialog"
           class="confirmDialog"
           ?disabled=${!this.newRepoName}
           confirm-label="Create"
-          @confirm=${this.handleCreateRepo}
-          @cancel=${this.handleCloseCreate}
+          @confirm=${() => this.handleCreateRepo()}
+          @cancel=${() => this.handleCloseCreate()}
         >
           <div class="header" slot="header">Create Repository</div>
           <div class="main" slot="main">
             <gr-create-repo-dialog
               id="createNewModal"
-              @new-repo-name=${this.handleNewRepoName}
+              @new-repo-name=${() => this.handleNewRepoName()}
             ></gr-create-repo-dialog>
           </div>
         </gr-dialog>
-      </gr-overlay>
+      </dialog>
     `;
   }
 
@@ -165,14 +155,14 @@
     return html`
       <tr class="table">
         <td class="name">
-          <a href="${this.computeRepoUrl(item.name)}">${item.name}</a>
+          <a href=${createRepoUrl({repo: item.name})}>${item.name}</a>
         </td>
         <td class="repositoryBrowser">${this.renderWebLinks(item)}</td>
         <td class="changesLink">
-          <a href="${this.computeChangesLink(item.name)}">view all</a>
+          <a href=${createSearchUrl({repo: item.name})}>view all</a>
         </td>
         <td class="readOnly">
-          ${item.state === ProjectState.READ_ONLY ? 'Y' : ''}
+          ${item.state === RepoState.READ_ONLY ? 'Y' : ''}
         </td>
         <td class="description">${item.description}</td>
       </tr>
@@ -186,7 +176,7 @@
 
   private renderWebLink(link: WebLinkInfo) {
     return html`
-      <a href="${link.url}" class="webLink" rel="noopener" target="_blank">
+      <a href=${link.url} class="webLink" rel="noopener" target="_blank">
         ${link.name}
       </a>
     `;
@@ -212,20 +202,12 @@
    *
    * private but used in test
    */
-  maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+  maybeOpenCreateModal(params?: AdminViewState) {
     if (params?.openCreateModal) {
-      this.createOverlay?.open();
+      this.createModal?.showModal();
     }
   }
 
-  private computeRepoUrl(name: string) {
-    return `${getBaseUrl()}${this.path}/${encodeURL(name, true)}`;
-  }
-
-  private computeChangesLink(name: string) {
-    return GerritNav.getUrlForProjectChanges(name as RepoName);
-  }
-
   private async getCreateRepoCapability() {
     const account = await this.restApiService.getAccount();
 
@@ -240,7 +222,7 @@
     return account;
   }
 
-  /* private but used in test */
+  // private but used in test
   async getRepos() {
     this.repos = [];
 
@@ -270,25 +252,24 @@
     return await this.getRepos();
   }
 
-  /* private but used in test */
+  // private but used in test
   async handleCreateRepo() {
     await this.createNewModal?.handleCreateRepo();
     await this.refreshReposList();
   }
 
-  /* private but used in test */
+  // private but used in test
   handleCloseCreate() {
-    this.createOverlay?.close();
+    this.createModal?.close();
   }
 
-  /* private but used in test */
+  // private but used in test
   handleCreateClicked() {
-    this.createOverlay?.open().then(() => {
-      this.createNewModal?.focus();
-    });
+    this.createModal?.showModal();
+    this.createNewModal?.focus();
   }
 
-  /* private but used in test */
+  // private but used in test
   computeLoadingClass(loading: boolean) {
     return loading ? 'loading' : '';
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
index 142a838..5b65942 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-list';
 import {GrRepoList} from './gr-repo-list';
 import {page} from '../../../utils/page-wrapper-utils';
@@ -29,20 +17,18 @@
   ProjectInfoWithName,
   RepoName,
 } from '../../../types/common';
-import {AppElementAdminParams} from '../../gr-app-types';
-import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {GerritView} from '../../../services/router/router-model';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrListView} from '../../shared/gr-list-view/gr-list-view';
-
-const basicFixture = fixtureFromElement('gr-repo-list');
+import {fixture, html, assert} from '@open-wc/testing';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
 
 function createRepo(name: string, counter: number) {
   return {
     id: `${name}${counter}` as UrlEncodedRepoName,
     name: `${name}` as RepoName,
-    state: 'ACTIVE' as ProjectState,
+    state: 'ACTIVE' as RepoState,
     web_links: [
       {
         name: 'diffusion',
@@ -66,8 +52,7 @@
 
   setup(async () => {
     sinon.stub(page, 'show');
-    element = basicFixture.instantiate();
-    await element.updateComplete;
+    element = await fixture(html`<gr-repo-list></gr-repo-list>`);
   });
 
   suite('list with repos', () => {
@@ -78,6 +63,549 @@
       await element.updateComplete;
     });
 
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-list-view>
+            <table class="genericList" id="list">
+              <tbody>
+                <tr class="headerRow">
+                  <th class="name topHeader">Repository Name</th>
+                  <th class="repositoryBrowser topHeader">
+                    Repository Browser
+                  </th>
+                  <th class="changesLink topHeader">Changes</th>
+                  <th class="readOnly topHeader">Read only</th>
+                  <th class="description topHeader">Repository Description</th>
+                </tr>
+                <tr class="loadingMsg" id="loading">
+                  <td>Loading...</td>
+                </tr>
+              </tbody>
+              <tbody>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test0"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test1"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test2"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test3"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test4"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test5"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test6"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test7"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test8"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test9"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test10"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test11"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test12"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test13"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test14"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test15"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test16"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test17"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test18"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test19"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test20"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test21"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test22"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test23"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/admin/repos/test"> test </a>
+                  </td>
+                  <td class="repositoryBrowser">
+                    <a
+                      class="webLink"
+                      href="https://phabricator.example.org/r/project/test24"
+                      rel="noopener"
+                      target="_blank"
+                    >
+                      diffusion
+                    </a>
+                  </td>
+                  <td class="changesLink">
+                    <a href="/q/project:test"> view all </a>
+                  </td>
+                  <td class="readOnly"></td>
+                  <td class="description"></td>
+                </tr>
+              </tbody>
+            </table>
+          </gr-list-view>
+          <dialog id="createModal" tabindex="-1">
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Create"
+              disabled=""
+              id="createDialog"
+              role="dialog"
+            >
+              <div class="header" slot="header">Create Repository</div>
+              <div class="main" slot="main">
+                <gr-create-repo-dialog id="createNewModal">
+                </gr-create-repo-dialog>
+              </div>
+            </gr-dialog>
+          </dialog>
+        `
+      );
+    });
+
     test('test for test repo in the list', async () => {
       await element.updateComplete;
       assert.equal(element.repos[0].id, 'test0');
@@ -89,22 +617,22 @@
       assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
     });
 
-    test('maybeOpenCreateOverlay', () => {
-      const overlayOpen = sinon.stub(
-        queryAndAssert<GrOverlay>(element, '#createOverlay'),
-        'open'
+    test('maybeOpenCreateModal', () => {
+      const modalOpen = sinon.stub(
+        queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+        'showModal'
       );
-      element.maybeOpenCreateOverlay();
-      assert.isFalse(overlayOpen.called);
-      element.maybeOpenCreateOverlay(undefined);
-      assert.isFalse(overlayOpen.called);
-      const params: AppElementAdminParams = {
+      element.maybeOpenCreateModal();
+      assert.isFalse(modalOpen.called);
+      element.maybeOpenCreateModal(undefined);
+      assert.isFalse(modalOpen.called);
+      const params: AdminViewState = {
         view: GerritView.ADMIN,
-        adminView: '',
+        adminView: AdminChildView.REPOS,
         openCreateModal: true,
       };
-      element.maybeOpenCreateOverlay(params);
-      assert.isTrue(overlayOpen.called);
+      element.maybeOpenCreateModal(params);
+      assert.isTrue(modalOpen.called);
     });
   });
 
@@ -134,10 +662,10 @@
       repoStub.returns(Promise.resolve(repos));
       element.params = {
         view: GerritView.ADMIN,
-        adminView: '',
+        adminView: AdminChildView.REPOS,
         filter: 'test',
         offset: 25,
-      } as AppElementAdminParams;
+      } as AdminViewState;
       await element._paramsChanged();
       assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
     });
@@ -200,8 +728,10 @@
 
   suite('create new', () => {
     test('handleCreateClicked called when create-clicked fired', () => {
-      const handleCreateClickedStub = sinon.stub();
-      element.addEventListener('create-clicked', handleCreateClickedStub);
+      const handleCreateClickedStub = sinon.stub(
+        element,
+        'handleCreateClicked'
+      );
       queryAndAssert<GrListView>(element, 'gr-list-view').dispatchEvent(
         new CustomEvent('create-clicked', {
           composed: true,
@@ -212,16 +742,16 @@
     });
 
     test('handleCreateClicked opens modal', () => {
-      const openStub = sinon
-        .stub(queryAndAssert<GrOverlay>(element, '#createOverlay'), 'open')
-        .returns(Promise.resolve());
+      const openStub = sinon.stub(
+        queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+        'showModal'
+      );
       element.handleCreateClicked();
       assert.isTrue(openStub.called);
     });
 
     test('handleCreateRepo called when confirm fired', () => {
-      const handleCreateRepoStub = sinon.stub();
-      element.addEventListener('confirm', handleCreateRepoStub);
+      const handleCreateRepoStub = sinon.stub(element, 'handleCreateRepo');
       queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
         new CustomEvent('confirm', {
           composed: true,
@@ -232,8 +762,7 @@
     });
 
     test('handleCloseCreate called when cancel fired', () => {
-      const handleCloseCreateStub = sinon.stub();
-      element.addEventListener('cancel', handleCloseCreateStub);
+      const handleCloseCreateStub = sinon.stub(element, 'handleCloseCreate');
       queryAndAssert<GrDialog>(element, '#createDialog').dispatchEvent(
         new CustomEvent('cancel', {
           composed: true,
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
index d5515f9..319c030 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 /**
  * @fileoverview This file contains interfaces shared between
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 7092c9b..f8dcd32 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
@@ -1,30 +1,18 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
-import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {ConfigParameterInfoType} from '../../../constants/constants';
 import {
   ConfigParameterInfo,
@@ -43,7 +31,6 @@
 export interface ConfigChangeInfo {
   _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
   info: ConfigParameterInfo;
-  notifyPath: string;
 }
 
 export interface PluginData {
@@ -54,7 +41,6 @@
 export interface PluginConfigChangeDetail {
   name: string; // parameterName of PluginParameterToConfigParameterInfoMap
   config: PluginParameterToConfigParameterInfoMap;
-  notifyPath: string;
 }
 
 @customElement('gr-repo-plugin-config')
@@ -68,6 +54,9 @@
   @property({type: Object})
   pluginData?: PluginData;
 
+  @property({type: Boolean, reflect: true})
+  disabled = false;
+
   static override get styles() {
     return [
       sharedStyles,
@@ -135,7 +124,7 @@
     return html` <gr-tooltip-content
       has-tooltip
       show-icon
-      title="${option.info.description}"
+      title=${option.info.description}
     >
       ${titleName}
     </gr-tooltip-content>`;
@@ -146,7 +135,8 @@
       return html`
         <gr-plugin-config-array-editor
           @plugin-config-option-changed=${this._handleArrayChange}
-          .pluginOption="${option}"
+          .pluginOption=${option}
+          ?disabled=${this.disabled || !option.info.editable}
         ></gr-plugin-config-array-editor>
       `;
     } else if (option.info.type === ConfigParameterInfoType.BOOLEAN) {
@@ -155,7 +145,7 @@
           ?checked=${this._computeChecked(option.info.value)}
           @change=${this._handleBooleanChange}
           data-option-key=${option._key}
-          ?disabled=${!option.info.editable}
+          ?disabled=${this.disabled || !option.info.editable}
           @click=${this._onTapPluginBoolean}
         ></paper-toggle-button>
       `;
@@ -167,10 +157,10 @@
         >
           <select
             data-option-key=${option._key}
-            ?disabled=${!option.info.editable}
+            ?disabled=${this.disabled || !option.info.editable}
           >
             ${(option.info.permitted_values || []).map(
-              value => html`<option value="${value}">${value}</option>`
+              value => html`<option value=${value}>${value}</option>`
             )}
           </select>
         </gr-select>
@@ -183,14 +173,14 @@
       return html`
         <iron-input
           @input=${this._handleStringChange}
-          data-option-key="${option._key}"
+          data-option-key=${option._key}
         >
           <input
             is="iron-input"
-            .value="${option.info.value ?? ''}"
+            .value=${option.info.value ?? ''}
             @input=${this._handleStringChange}
-            data-option-key="${option._key}"
-            ?disabled=${!option.info.editable}
+            data-option-key=${option._key}
+            ?disabled=${this.disabled || !option.info.editable}
           />
         </iron-input>
       `;
@@ -248,7 +238,6 @@
     return {
       _key,
       info,
-      notifyPath: `${_key}.value`,
     };
   }
 
@@ -256,7 +245,7 @@
     this._handleChange(e.detail);
   }
 
-  _handleChange({_key, info, notifyPath}: ConfigChangeInfo) {
+  _handleChange({_key, info}: ConfigChangeInfo) {
     // If pluginData is not set, editors are not created and this method
     // can't be called
     const {name, config} = this.pluginData!;
@@ -265,7 +254,6 @@
     const detail: PluginConfigChangeDetail = {
       name,
       config: {...config, [_key]: info},
-      notifyPath: `${name}.${notifyPath}`,
     };
 
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
deleted file mode 100644
index 8c2e6b3..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
+++ /dev/null
@@ -1,157 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-repo-plugin-config.js';
-
-const basicFixture = fixtureFromElement('gr-repo-plugin-config');
-
-suite('gr-repo-plugin-config tests', () => {
-  let element;
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
-  });
-
-  test('_computePluginConfigOptions', () => {
-    assert.deepEqual(element._computePluginConfigOptions({config: {}}), []);
-    assert.deepEqual(element._computePluginConfigOptions(
-        {config: {testKey: 'testInfo'}}),
-    [{_key: 'testKey', info: 'testInfo'}]);
-  });
-
-  test('_handleChange', () => {
-    const eventStub = sinon.stub(element, 'dispatchEvent');
-    element.pluginData = {
-      name: 'testName',
-      config: {plugin: {value: 'test'}},
-    };
-    element._handleChange({
-      _key: 'plugin',
-      info: {value: 'newTest'},
-      notifyPath: 'plugin.value',
-    });
-
-    assert.isTrue(eventStub.called);
-
-    const {detail} = eventStub.lastCall.args[0];
-    assert.equal(detail.name, 'testName');
-    assert.deepEqual(detail.config, {plugin: {value: 'newTest'}});
-    assert.equal(detail.notifyPath, 'testName.plugin.value');
-  });
-
-  suite('option types', () => {
-    let changeStub;
-    let buildStub;
-
-    setup(() => {
-      changeStub = sinon.stub(element, '_handleChange');
-      buildStub = sinon.stub(element, '_buildConfigChangeInfo');
-    });
-
-    test('ARRAY type option', async () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'test', type: 'ARRAY', editable: true}},
-      };
-      await flush();
-
-      const editor = element.shadowRoot
-          .querySelector('gr-plugin-config-array-editor');
-      assert.ok(editor);
-      element._handleArrayChange({detail: 'test'});
-      assert.isTrue(changeStub.called);
-      assert.equal(changeStub.lastCall.args[0], 'test');
-    });
-
-    test('BOOLEAN type option', async () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'true', type: 'BOOLEAN', editable: true}},
-      };
-      await flush();
-
-      const toggle = element.shadowRoot
-          .querySelector('paper-toggle-button');
-      assert.ok(toggle);
-      toggle.click();
-      await flush();
-
-      assert.isTrue(buildStub.called);
-      assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
-
-      assert.isTrue(changeStub.called);
-    });
-
-    test('INT/LONG/STRING type option', async () => {
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin: {value: 'test', type: 'STRING', editable: true}},
-      };
-      await flush();
-
-      const input = element.shadowRoot
-          .querySelector('input');
-      assert.ok(input);
-      input.value = 'newTest';
-      input.dispatchEvent(new Event('input'));
-      await flush();
-
-      assert.isTrue(buildStub.called);
-      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
-
-      assert.isTrue(changeStub.called);
-    });
-
-    test('LIST type option', async () => {
-      const permitted_values = ['test', 'newTest'];
-      element.pluginData = {
-        name: 'testName',
-        config: {plugin:
-          {value: 'test', type: 'LIST', editable: true, permitted_values},
-        },
-      };
-      await flush();
-
-      const select = element.shadowRoot
-          .querySelector('select');
-      assert.ok(select);
-      select.value = 'newTest';
-      select.dispatchEvent(new Event(
-          'change', {bubbles: true, composed: true}));
-      await flush();
-
-      assert.isTrue(buildStub.called);
-      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
-
-      assert.isTrue(changeStub.called);
-    });
-  });
-
-  test('_buildConfigChangeInfo', () => {
-    element.pluginData = {
-      name: 'testName',
-      config: {plugin: {value: 'test'}},
-    };
-    const detail = element._buildConfigChangeInfo('newTest', 'plugin');
-    assert.equal(detail._key, 'plugin');
-    assert.deepEqual(detail.info, {value: 'newTest'});
-    assert.equal(detail.notifyPath, 'plugin.value');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts
new file mode 100644
index 0000000..3dc6f1e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.ts
@@ -0,0 +1,226 @@
+/**
+ * @license
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-repo-plugin-config';
+import {GrRepoPluginConfig} from './gr-repo-plugin-config';
+import {PluginParameterToConfigParameterInfoMap} from '../../../types/common';
+import {ConfigParameterInfoType} from '../../../constants/constants';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrPluginConfigArrayEditor} from '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
+import {PaperToggleButtonElement} from '@polymer/paper-toggle-button/paper-toggle-button';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-repo-plugin-config tests', () => {
+  let element: GrRepoPluginConfig;
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-repo-plugin-config></gr-repo-plugin-config>`
+    );
+  });
+
+  test('render', async () => {
+    element.pluginData = {
+      name: 'testName',
+      config: {
+        plugin: {type: 'STRING' as ConfigParameterInfoType, value: 'test'},
+      },
+    };
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <fieldset>
+            <h4>testName</h4>
+            <section class="STRING section">
+              <span class="title">
+                <span> </span>
+              </span>
+              <span class="value">
+                <iron-input data-option-key="plugin">
+                  <input data-option-key="plugin" disabled="" is="iron-input" />
+                </iron-input>
+              </span>
+            </section>
+          </fieldset>
+        </div>
+      `
+    );
+  });
+
+  test('_computePluginConfigOptions', () => {
+    assert.deepEqual(
+      element._computePluginConfigOptions({
+        name: 'testInfo',
+        config: {
+          testKey: {display_name: 'testInfo plugin', type: 'STRING'},
+        } as PluginParameterToConfigParameterInfoMap,
+      }),
+      [
+        {
+          _key: 'testKey',
+          info: {
+            display_name: 'testInfo plugin',
+            type: 'STRING' as ConfigParameterInfoType,
+          },
+        },
+      ]
+    );
+  });
+
+  test('_handleChange', () => {
+    const eventStub = sinon.stub(element, 'dispatchEvent');
+    element.pluginData = {
+      name: 'testName',
+      config: {
+        plugin: {type: 'STRING' as ConfigParameterInfoType, value: 'test'},
+      },
+    };
+    element._handleChange({
+      _key: 'plugin',
+      info: {type: 'STRING' as ConfigParameterInfoType, value: 'newTest'},
+    });
+
+    assert.isTrue(eventStub.called);
+
+    const {detail} = eventStub.lastCall.args[0] as CustomEvent;
+    assert.equal(detail.name, 'testName');
+    assert.deepEqual(detail.config, {
+      plugin: {type: 'STRING' as ConfigParameterInfoType, value: 'newTest'},
+    });
+  });
+
+  suite('option types', () => {
+    let changeStub: sinon.SinonStub;
+    let buildStub: sinon.SinonStub;
+
+    setup(() => {
+      changeStub = sinon.stub(element, '_handleChange');
+      buildStub = sinon.stub(element, '_buildConfigChangeInfo');
+    });
+
+    test('ARRAY type option', async () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {
+          plugin: {
+            value: 'test',
+            type: 'ARRAY' as ConfigParameterInfoType,
+            editable: true,
+          },
+        },
+      };
+      await element.updateComplete;
+
+      const editor = queryAndAssert<GrPluginConfigArrayEditor>(
+        element,
+        'gr-plugin-config-array-editor'
+      );
+      assert.ok(editor);
+      element._handleArrayChange({detail: 'test'} as CustomEvent);
+      assert.isTrue(changeStub.called);
+      assert.equal(changeStub.lastCall.args[0], 'test');
+    });
+
+    test('BOOLEAN type option', async () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {
+          plugin: {
+            value: 'true',
+            type: 'BOOLEAN' as ConfigParameterInfoType,
+            editable: true,
+          },
+        },
+      };
+      await element.updateComplete;
+
+      const toggle = queryAndAssert<PaperToggleButtonElement>(
+        element,
+        'paper-toggle-button'
+      );
+      assert.ok(toggle);
+      toggle.click();
+      await element.updateComplete;
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+
+    test('INT/LONG/STRING type option', async () => {
+      element.pluginData = {
+        name: 'testName',
+        config: {
+          plugin: {
+            value: 'test',
+            type: 'STRING' as ConfigParameterInfoType,
+            editable: true,
+          },
+        },
+      };
+      await element.updateComplete;
+
+      const input = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.ok(input);
+      input.value = 'newTest';
+      input.dispatchEvent(new Event('input'));
+      await element.updateComplete;
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+
+    test('LIST type option', async () => {
+      const permitted_values = ['test', 'newTest'];
+      element.pluginData = {
+        name: 'testName',
+        config: {
+          plugin: {
+            value: 'test',
+            type: 'LIST' as ConfigParameterInfoType,
+            editable: true,
+            permitted_values,
+          },
+        },
+      };
+      await element.updateComplete;
+
+      const select = queryAndAssert<HTMLSelectElement>(element, 'select');
+      assert.ok(select);
+      select.value = 'newTest';
+      select.dispatchEvent(
+        new Event('change', {bubbles: true, composed: true})
+      );
+      await element.updateComplete;
+
+      assert.isTrue(buildStub.called);
+      assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
+
+      assert.isTrue(changeStub.called);
+    });
+  });
+
+  test('_buildConfigChangeInfo', () => {
+    element.pluginData = {
+      name: 'testName',
+      config: {
+        plugin: {type: 'STRING' as ConfigParameterInfoType, value: 'test'},
+      },
+    };
+    const detail = element._buildConfigChangeInfo('newTest', 'plugin');
+    assert.equal(detail._key, 'plugin');
+    assert.deepEqual(detail.info, {
+      type: 'STRING' as ConfigParameterInfoType,
+      value: 'newTest',
+    });
+  });
+});
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 cd5b095..3599224 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -1,56 +1,53 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '@polymer/iron-input/iron-input';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-button/gr-button';
 import '../../shared/gr-download-commands/gr-download-commands';
 import '../../shared/gr-select/gr-select';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
+import '../../shared/gr-textarea/gr-textarea';
 import '../gr-repo-plugin-config/gr-repo-plugin-config';
-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 {
   ConfigInfo,
   RepoName,
   InheritedBooleanInfo,
   SchemesInfoMap,
   ConfigInput,
+  MaxObjectSizeLimitInfo,
   PluginParameterToConfigParameterInfoMap,
-  PluginNameToPluginParametersMap,
 } from '../../../types/common';
-import {PluginData} from '../gr-repo-plugin-config/gr-repo-plugin-config';
-import {ProjectState} from '../../../constants/constants';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {
+  InheritedBooleanInfoConfiguredValue,
+  RepoState,
+  SubmitType,
+} from '../../../constants/constants';
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {WebLinkInfo} from '../../../types/diff';
 import {ErrorCallback} from '../../../api/rest';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {BindValueChangeEvent} from '../../../types/events';
+import {deepClone} from '../../../utils/deep-util';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
+import {subscribe} from '../../lit/subscription-controller';
+import {createSearchUrl} from '../../../models/views/search';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
 
 const STATES = {
-  active: {value: ProjectState.ACTIVE, label: 'Active'},
-  readOnly: {value: ProjectState.READ_ONLY, label: 'Read Only'},
-  hidden: {value: ProjectState.HIDDEN, label: 'Hidden'},
+  active: {value: RepoState.ACTIVE, label: 'Active'},
+  readOnly: {value: RepoState.READ_ONLY, label: 'Read Only'},
+  hidden: {value: RepoState.HIDDEN, label: 'Hidden'},
 };
 
 const SUBMIT_TYPES = {
@@ -81,92 +78,688 @@
   },
 };
 
-@customElement('gr-repo')
-export class GrRepo extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo': GrRepo;
   }
+}
+
+@customElement('gr-repo')
+export class GrRepo extends LitElement {
+  private schemes: string[] = [];
 
   @property({type: String})
   repo?: RepoName;
 
-  @property({type: Boolean})
-  _configChanged = false;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() repoConfig?: ConfigInfo;
 
-  @property({type: Boolean, observer: '_loggedInChanged'})
-  _loggedIn = false;
+  // private but used in test
+  @state() readOnly = true;
 
-  @property({type: Object})
-  _repoConfig?: ConfigInfo;
+  @state() private states = Object.values(STATES);
 
-  @property({
-    type: Array,
-    computed: '_computePluginData(_repoConfig.plugin_config.*)',
-  })
-  _pluginData?: PluginData[];
+  @state() private originalConfig?: ConfigInfo;
 
-  @property({type: Boolean})
-  _readOnly = true;
+  @state() private selectedScheme?: string;
 
-  @property({type: Array})
-  _states = Object.values(STATES);
+  // private but used in test
+  @state() schemesObj?: SchemesInfoMap;
 
-  @property({
-    type: Array,
-    computed: '_computeSchemes(_schemesDefault, _schemesObj)',
-    observer: '_schemesChanged',
-  })
-  _schemes: string[] = [];
+  @state() private weblinks: WebLinkInfo[] = [];
 
-  // This is workaround to have _schemes with default value [],
-  // because assignment doesn't work when property has a computed attribute.
-  @property({type: Array})
-  _schemesDefault: string[] = [];
+  @state() private pluginConfigChanged = false;
 
-  @property({type: String})
-  _selectedCommand = 'Clone';
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  @property({type: String})
-  _selectedScheme?: string;
+  private readonly restApiService = getAppContext().restApiService;
 
-  @property({type: Object})
-  _schemesObj?: SchemesInfoMap;
-
-  @property({type: Array})
-  weblinks: WebLinkInfo[] = [];
-
-  private readonly restApiService = appContext.restApiService;
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => {
+        if (prefs?.download_scheme) {
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this.selectedScheme = prefs.download_scheme.toLowerCase();
+        }
+      }
+    );
+  }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadRepo();
 
     fireTitleChange(this, `${this.repo}`);
   }
 
-  _computePluginData(
-    configRecord: PolymerDeepPropertyChange<
-      PluginNameToPluginParametersMap,
-      PluginNameToPluginParametersMap
-    >
-  ) {
-    if (!configRecord || !configRecord.base) {
-      return [];
-    }
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      subpageStyles,
+      sharedStyles,
+      css`
+        .info {
+          margin-bottom: var(--spacing-xl);
+        }
+        h2.edited:after {
+          color: var(--deemphasized-text-color);
+          content: ' *';
+        }
+        #options .repositorySettings {
+          display: none;
+        }
+        #options .repositorySettings.showConfig {
+          display: block;
+        }
+      `,
+    ];
+  }
 
-    const pluginConfig = configRecord.base;
+  override render() {
+    const configChanged = this.hasConfigChanged();
+    return html`
+      <div class="main gr-form-styles read-only">
+        <div class="info">
+          <h1 id="Title" class="heading-1">${this.repo}</h1>
+          <hr />
+          <div>
+            <a href=${this.weblinks?.[0]?.url}
+              ><gr-button link ?disabled=${!this.weblinks?.[0]?.url}
+                >Browse</gr-button
+              ></a
+            ><a href=${this.computeChangesUrl(this.repo)}
+              ><gr-button link>View Changes</gr-button></a
+            >
+          </div>
+        </div>
+        ${when(
+          this.loading || !this.repoConfig,
+          () => html`<div id="loading">Loading...</div>`,
+          () => html`<div id="loadedContent">
+            ${this.renderDownloadCommands()}
+            <h2
+              id="configurations"
+              class="heading-2 ${configChanged ? 'edited' : ''}"
+            >
+              Configurations
+            </h2>
+            <div id="form">
+              <fieldset>
+                ${this.renderDescription()} ${this.renderRepoOptions()}
+                ${this.renderPluginConfig()}
+                <gr-button
+                  ?disabled=${this.readOnly || !configChanged}
+                  @click=${this.handleSaveRepoConfig}
+                  >Save changes</gr-button
+                >
+              </fieldset>
+              <gr-endpoint-decorator name="repo-config">
+                <gr-endpoint-param
+                  name="repoName"
+                  .value=${this.repo}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="readOnly"
+                  .value=${this.readOnly}
+                ></gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          </div>`
+        )}
+      </div>
+    `;
+  }
+
+  private renderDownloadCommands() {
+    if (!this.schemes.length) return nothing;
+    return html`
+      <div id="downloadContent">
+        <h2 id="download" class="heading-2">Download</h2>
+        <fieldset>
+          <gr-download-commands
+            id="downloadCommands"
+            .commands=${this.computeCommands(
+              this.repo,
+              this.schemesObj,
+              this.selectedScheme
+            )}
+            .schemes=${this.schemes}
+            .selectedScheme=${this.selectedScheme}
+            @selected-scheme-changed=${(e: BindValueChangeEvent) => {
+              if (this.loading) return;
+              this.selectedScheme = e.detail.value;
+            }}
+          ></gr-download-commands>
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderDescription() {
+    assertIsDefined(this.repoConfig, 'repoConfig');
+    return html`
+      <h3 id="Description" class="heading-3">Description</h3>
+      <fieldset>
+        <gr-textarea
+          id="descriptionInput"
+          class="description"
+          autocomplete="on"
+          placeholder="&lt;Insert repo description here&gt;"
+          rows="4"
+          monospace
+          ?disabled=${this.readOnly}
+          .text=${this.repoConfig.description ?? ''}
+          @text-changed=${this.handleDescriptionTextChanged}
+        ></gr-textarea>
+      </fieldset>
+    `;
+  }
+
+  private renderRepoOptions() {
+    return html`
+      <h3 id="Options" class="heading-3">Repository Options</h3>
+      <fieldset id="options">
+        ${this.renderState()} ${this.renderSubmitType()}
+        ${this.renderContentMerges()} ${this.renderNewChange()}
+        ${this.renderChangeId()} ${this.renderEnableSignedPush()}
+        ${this.renderRequireSignedPush()} ${this.renderRejectImplicitMerges()}
+        ${this.renderUnRegisteredCc()} ${this.renderPrivateByDefault()}
+        ${this.renderWorkInProgressByDefault()} ${this.renderMaxGitObjectSize()}
+        ${this.renderMatchAuthoredDateWithCommitterDate()}
+        ${this.renderRejectEmptyCommit()}
+      </fieldset>
+      <h3 id="Options" class="heading-3">Contributor Agreements</h3>
+      <fieldset id="agreements">
+        ${this.renderContributorAgreement()} ${this.renderUseSignedOffBy()}
+      </fieldset>
+    `;
+  }
+
+  private renderState() {
+    return html`
+      <section>
+        <span class="title">State</span>
+        <span class="value">
+          <gr-select
+            id="stateSelect"
+            .bindValue=${this.repoConfig?.state}
+            @bind-value-changed=${this.handleStateSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.states.map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderSubmitType() {
+    return html`
+      <section>
+        <span class="title">Submit type</span>
+        <span class="value">
+          <gr-select
+            id="submitTypeSelect"
+            .bindValue=${this.repoConfig?.submit_type}
+            @bind-value-changed=${this.handleSubmitTypeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatSubmitTypeSelect(this.repoConfig).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderContentMerges() {
+    return html`
+      <section>
+        <span class="title">Allow content merges</span>
+        <span class="value">
+          <gr-select
+            id="contentMergeSelect"
+            .bindValue=${this.repoConfig?.use_content_merge?.configured_value}
+            @bind-value-changed=${this.handleContentMergeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_content_merge
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderNewChange() {
+    return html`
+      <section>
+        <span class="title">
+          Create a new change for every commit not in the target branch
+        </span>
+        <span class="value">
+          <gr-select
+            id="newChangeSelect"
+            .bindValue=${this.repoConfig
+              ?.create_new_change_for_all_not_in_target?.configured_value}
+            @bind-value-changed=${this.handleNewChangeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.create_new_change_for_all_not_in_target
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderChangeId() {
+    return html`
+      <section>
+        <span class="title">Require Change-Id in commit message</span>
+        <span class="value">
+          <gr-select
+            id="requireChangeIdSelect"
+            .bindValue=${this.repoConfig?.require_change_id?.configured_value}
+            @bind-value-changed=${this
+              .handleRequireChangeIdSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.require_change_id
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEnableSignedPush() {
+    return html`
+      <section
+        id="enableSignedPushSettings"
+        class="repositorySettings ${this.repoConfig?.enable_signed_push
+          ? 'showConfig'
+          : ''}"
+      >
+        <span class="title">Enable signed push</span>
+        <span class="value">
+          <gr-select
+            id="enableSignedPush"
+            .bindValue=${this.repoConfig?.enable_signed_push?.configured_value}
+            @bind-value-changed=${this.handleEnableSignedPushBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.enable_signed_push
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRequireSignedPush() {
+    return html`
+      <section
+        id="requireSignedPushSettings"
+        class="repositorySettings ${this.repoConfig?.require_signed_push
+          ? 'showConfig'
+          : ''}"
+      >
+        <span class="title">Require signed push</span>
+        <span class="value">
+          <gr-select
+            id="requireSignedPush"
+            .bindValue=${this.repoConfig?.require_signed_push?.configured_value}
+            @bind-value-changed=${this.handleRequireSignedPushBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.require_signed_push
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRejectImplicitMerges() {
+    return html`
+      <section>
+        <span class="title">
+          Reject implicit merges when changes are pushed for review</span
+        >
+        <span class="value">
+          <gr-select
+            id="rejectImplicitMergesSelect"
+            .bindValue=${this.repoConfig?.reject_implicit_merges
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleRejectImplicitMergeSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.reject_implicit_merges
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderUnRegisteredCc() {
+    return html`
+      <section>
+        <span class="title">
+          Enable adding unregistered users as reviewers and CCs on changes</span
+        >
+        <span class="value">
+          <gr-select
+            id="unRegisteredCcSelect"
+            .bindValue=${this.repoConfig?.enable_reviewer_by_email
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleUnRegisteredCcSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.enable_reviewer_by_email
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPrivateByDefault() {
+    return html`
+      <section>
+        <span class="title"> Set all new changes private by default</span>
+        <span class="value">
+          <gr-select
+            id="setAllnewChangesPrivateByDefaultSelect"
+            .bindValue=${this.repoConfig?.private_by_default?.configured_value}
+            @bind-value-changed=${this
+              .handleSetAllNewChangesPrivateByDefaultSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.private_by_default
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderWorkInProgressByDefault() {
+    return html`
+      <section>
+        <span class="title">
+          Set new changes to "work in progress" by default</span
+        >
+        <span class="value">
+          <gr-select
+            id="setAllNewChangesWorkInProgressByDefaultSelect"
+            .bindValue=${this.repoConfig?.work_in_progress_by_default
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleSetAllNewChangesWorkInProgressByDefaultSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.work_in_progress_by_default
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderMaxGitObjectSize() {
+    return html`
+      <section>
+        <span class="title">Maximum Git object size limit</span>
+        <span class="value">
+          <iron-input
+            id="maxGitObjSizeIronInput"
+            .bindValue=${this.repoConfig?.max_object_size_limit
+              ?.configured_value}
+            @bind-value-changed=${this.handleMaxGitObjSizeBindValueChanged}
+          >
+            <input
+              id="maxGitObjSizeInput"
+              type="text"
+              ?disabled=${this.readOnly}
+            />
+          </iron-input>
+          ${this.repoConfig?.max_object_size_limit?.value
+            ? `effective: ${this.repoConfig.max_object_size_limit.value} bytes`
+            : ''}
+        </span>
+      </section>
+    `;
+  }
+
+  private renderMatchAuthoredDateWithCommitterDate() {
+    return html`
+      <section>
+        <span class="title"
+          >Match authored date with committer date upon submit</span
+        >
+        <span class="value">
+          <gr-select
+            id="matchAuthoredDateWithCommitterDateSelect"
+            .bindValue=${this.repoConfig?.match_author_to_committer_date
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleMatchAuthoredDateWithCommitterDateSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.match_author_to_committer_date
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderRejectEmptyCommit() {
+    return html`
+      <section>
+        <span class="title">Reject empty commit upon submit</span>
+        <span class="value">
+          <gr-select
+            id="rejectEmptyCommitSelect"
+            .bindValue=${this.repoConfig?.reject_empty_commit?.configured_value}
+            @bind-value-changed=${this
+              .handleRejectEmptyCommitSelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.reject_empty_commit
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderContributorAgreement() {
+    return html`
+      <section>
+        <span class="title">
+          Require a valid contributor agreement to upload</span
+        >
+        <span class="value">
+          <gr-select
+            id="contributorAgreementSelect"
+            .bindValue=${this.repoConfig?.use_contributor_agreements
+              ?.configured_value}
+            @bind-value-changed=${this
+              .handleUseContributorAgreementsBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_contributor_agreements
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderUseSignedOffBy() {
+    return html`
+      <section>
+        <span class="title">Require Signed-off-by in commit message</span>
+        <span class="value">
+          <gr-select
+            id="useSignedOffBySelect"
+            .bindValue=${this.repoConfig?.use_signed_off_by?.configured_value}
+            @bind-value-changed=${this
+              .handleUseSignedOffBySelectBindValueChanged}
+          >
+            <select ?disabled=${this.readOnly}>
+              ${this.formatBooleanSelect(
+                this.repoConfig?.use_signed_off_by
+              ).map(
+                item => html`
+                  <option value=${item.value}>${item.label}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPluginConfig() {
+    const pluginData = this.computePluginData();
+    if (!pluginData.length) return nothing;
+    return html` <div
+      class="pluginConfig"
+      @plugin-config-changed=${this.handlePluginConfigChanged}
+    >
+      <h3 class="heading-3">Plugins</h3>
+      ${pluginData.map(
+        item => html`
+          <gr-repo-plugin-config
+            .pluginData=${item}
+            ?disabled=${this.readOnly}
+          ></gr-repo-plugin-config>
+        `
+      )}
+    </div>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this.loadRepo();
+    }
+    if (changedProperties.has('schemesObj')) {
+      this.computeSchemesAndDefault();
+    }
+  }
+
+  // private but used in test
+  computePluginData() {
+    if (!this.repoConfig || !this.repoConfig.plugin_config) return [];
+    const pluginConfig = this.repoConfig.plugin_config;
     return Object.keys(pluginConfig).map(name => {
       return {name, config: pluginConfig[name]};
     });
   }
 
-  _loadRepo() {
-    if (!this.repo) {
-      return Promise.resolve();
-    }
+  // private but used in test
+  async loadRepo() {
+    if (!this.repo) return Promise.resolve();
+    this.repoConfig = undefined;
+    this.originalConfig = undefined;
+    this.loading = true;
+    this.weblinks = [];
+    this.schemesObj = undefined;
+    this.readOnly = true;
 
     const promises = [];
 
@@ -175,8 +768,7 @@
     };
 
     promises.push(
-      this._getLoggedIn().then(loggedIn => {
-        this._loggedIn = loggedIn;
+      this.restApiService.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           const repo = this.repo;
           if (!repo) throw new Error('undefined repo');
@@ -190,71 +782,60 @@
             }
 
             // If the user is not an owner, is_owner is not a property.
-            this._readOnly = !access[repo]?.is_owner;
+            this.readOnly = !access[repo]?.is_owner;
           });
         }
       })
     );
 
-    promises.push(
-      this.restApiService.getProjectConfig(this.repo, errFn).then(config => {
-        if (!config) {
-          return;
-        }
+    const repoConfigHelper = async () => {
+      const config = await this.restApiService.getProjectConfig(
+        this.repo as RepoName,
+        errFn
+      );
+      if (!config) return;
 
-        if (config.default_submit_type) {
-          // The gr-select is bound to submit_type, which needs to be the
-          // *configured* submit type. When default_submit_type is
-          // present, the server reports the *effective* submit type in
-          // submit_type, so we need to overwrite it before storing the
-          // config in this.
-          config.submit_type = config.default_submit_type.configured_value;
-        }
-        if (!config.state) {
-          config.state = STATES.active.value;
-        }
-        this._repoConfig = config;
-        this._loading = false;
-      })
-    );
-
-    promises.push(
-      this.restApiService.getConfig().then(config => {
-        if (!config) {
-          return;
-        }
-
-        this._schemesObj = config.download.schemes;
-      })
-    );
-
-    return Promise.all(promises);
-  }
-
-  _computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
-
-  _computeHideClass(arr?: PluginData[] | string[]) {
-    return !arr || !arr.length ? 'hide' : '';
-  }
-
-  _loggedInChanged(_loggedIn?: boolean) {
-    if (!_loggedIn) {
-      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();
+      if (config.default_submit_type) {
+        // The gr-select is bound to submit_type, which needs to be the
+        // *configured* submit type. When default_submit_type is
+        // present, the server reports the *effective* submit type in
+        // submit_type, so we need to overwrite it before storing the
+        // config in this.
+        config.submit_type = config.default_submit_type.configured_value;
       }
-    });
+      if (!config.state) {
+        config.state = STATES.active.value;
+      }
+      // To properly check if the config has changed we need it to be a string
+      // as it's converted to a string in the input.
+      if (config.description === undefined) {
+        config.description = '';
+      }
+      // To properly check if the config has changed we need it to be a string
+      // as it's converted to a string in the input.
+      if (config.max_object_size_limit.configured_value === undefined) {
+        config.max_object_size_limit.configured_value = '';
+      }
+      this.repoConfig = config;
+      this.originalConfig = deepClone(config) as ConfigInfo;
+      this.loading = false;
+    };
+    promises.push(repoConfigHelper());
+
+    const configHelper = async () => {
+      const config = await this.restApiService.getConfig();
+      if (!config) return;
+
+      this.schemesObj = config.download.schemes;
+    };
+    promises.push(configHelper());
+
+    await Promise.all(promises);
   }
 
-  _formatBooleanSelect(item: InheritedBooleanInfo) {
-    if (!item) {
-      return;
-    }
+  // private but used in test
+  formatBooleanSelect(item?: InheritedBooleanInfo) {
+    if (!item) return [];
     let inheritLabel = 'Inherit';
     if (!(item.inherited_value === undefined)) {
       inheritLabel = `Inherit (${item.inherited_value})`;
@@ -275,12 +856,10 @@
     ];
   }
 
-  _formatSubmitTypeSelect(projectConfig: ConfigInfo) {
-    if (!projectConfig) {
-      return;
-    }
+  private formatSubmitTypeSelect(repoConfig?: ConfigInfo) {
+    if (!repoConfig) return [];
     const allValues = Object.values(SUBMIT_TYPES);
-    const type = projectConfig.default_submit_type;
+    const type = repoConfig.default_submit_type;
     if (!type) {
       // Server is too old to report default_submit_type, so assume INHERIT
       // is not a valid value.
@@ -306,15 +885,9 @@
     ];
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
+  // private but used in test
+  formatRepoConfigForSave(repoConfig?: ConfigInfo): ConfigInput {
+    if (!repoConfig) return {};
     const configInputObj: ConfigInput = {};
     for (const configKey of Object.keys(repoConfig)) {
       const key = configKey as keyof ConfigInfo;
@@ -329,7 +902,7 @@
       } else if (typeof repoConfig[key] === 'object') {
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         const repoConfigObj: any = repoConfig[key];
-        if (repoConfigObj.configured_value) {
+        if (repoConfigObj.configured_value !== undefined) {
           configInputObj[key as keyof ConfigInput] =
             repoConfigObj.configured_value;
         }
@@ -341,56 +914,173 @@
     return configInputObj;
   }
 
-  _handleSaveRepoConfig() {
-    if (!this._repoConfig || !this.repo)
+  // private but used in test
+  async handleSaveRepoConfig() {
+    if (!this.repoConfig || !this.repo)
       return Promise.reject(new Error('undefined repoConfig or repo'));
-    return this.restApiService
-      .saveRepoConfig(
-        this.repo,
-        this._formatRepoConfigForSave(this._repoConfig)
+    await this.restApiService.saveRepoConfig(
+      this.repo,
+      this.formatRepoConfigForSave(this.repoConfig)
+    );
+    this.originalConfig = deepClone(this.repoConfig) as ConfigInfo;
+    this.pluginConfigChanged = false;
+    return;
+  }
+
+  private isEdited(
+    original?: InheritedBooleanInfo | MaxObjectSizeLimitInfo,
+    repo?: InheritedBooleanInfo | MaxObjectSizeLimitInfo
+  ) {
+    return original?.configured_value !== repo?.configured_value;
+  }
+
+  private hasConfigChanged() {
+    const {repoConfig, originalConfig} = this;
+
+    if (!repoConfig || !originalConfig) return false;
+
+    if (originalConfig.description !== repoConfig.description) {
+      return true;
+    }
+    if (originalConfig.state !== repoConfig.state) {
+      return true;
+    }
+    if (originalConfig.submit_type !== repoConfig.submit_type) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_content_merge,
+        repoConfig.use_content_merge
       )
-      .then(() => {
-        this._configChanged = false;
-      });
-  }
-
-  @observe('_repoConfig.*')
-  _handleConfigChanged() {
-    if (this._isLoading()) {
-      return;
+    ) {
+      return true;
     }
-    this._configChanged = true;
-  }
-
-  _computeButtonDisabled(readOnly: boolean, configChanged: boolean) {
-    return readOnly || !configChanged;
-  }
-
-  _computeHeaderClass(configChanged: boolean) {
-    return configChanged ? 'edited' : '';
-  }
-
-  _computeSchemes(schemesDefault: string[], schemesObj?: SchemesInfoMap) {
-    return !schemesObj ? schemesDefault : Object.keys(schemesObj);
-  }
-
-  _schemesChanged(schemes: string[]) {
-    if (schemes.length === 0) {
-      return;
+    if (
+      this.isEdited(
+        originalConfig.create_new_change_for_all_not_in_target,
+        repoConfig.create_new_change_for_all_not_in_target
+      )
+    ) {
+      return true;
     }
-    if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
-      this._selectedScheme = schemes.sort()[0];
+    if (
+      this.isEdited(
+        originalConfig.require_change_id,
+        repoConfig.require_change_id
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.enable_signed_push,
+        repoConfig.enable_signed_push
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.require_signed_push,
+        repoConfig.require_signed_push
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.reject_implicit_merges,
+        repoConfig.reject_implicit_merges
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.enable_reviewer_by_email,
+        repoConfig.enable_reviewer_by_email
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.private_by_default,
+        repoConfig.private_by_default
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.work_in_progress_by_default,
+        repoConfig.work_in_progress_by_default
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.max_object_size_limit,
+        repoConfig.max_object_size_limit
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.match_author_to_committer_date,
+        repoConfig.match_author_to_committer_date
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.reject_empty_commit,
+        repoConfig.reject_empty_commit
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_contributor_agreements,
+        repoConfig.use_contributor_agreements
+      )
+    ) {
+      return true;
+    }
+    if (
+      this.isEdited(
+        originalConfig.use_signed_off_by,
+        repoConfig.use_signed_off_by
+      )
+    ) {
+      return true;
+    }
+
+    return this.pluginConfigChanged;
+  }
+
+  private computeSchemesAndDefault() {
+    this.schemes = !this.schemesObj ? [] : Object.keys(this.schemesObj).sort();
+    if (this.schemes.length > 0) {
+      if (!this.selectedScheme || !this.schemes.includes(this.selectedScheme)) {
+        this.selectedScheme = this.schemes.sort()[0];
+      }
     }
   }
 
-  _computeCommands(
+  private computeCommands(
     repo?: RepoName,
     schemesObj?: SchemesInfoMap,
-    _selectedScheme?: string
+    selectedScheme?: string
   ) {
-    if (!schemesObj || !repo || !_selectedScheme) return [];
-    if (!hasOwnProperty(schemesObj, _selectedScheme)) return [];
-    const commandObj = schemesObj[_selectedScheme].clone_commands;
+    if (!schemesObj || !repo || !selectedScheme) return [];
+    if (!hasOwnProperty(schemesObj, selectedScheme)) return [];
+    const commandObj = schemesObj[selectedScheme].clone_commands;
     const commands = [];
     for (const [title, command] of Object.entries(commandObj)) {
       commands.push({
@@ -406,36 +1096,169 @@
     return commands;
   }
 
-  _computeRepositoriesClass(config: InheritedBooleanInfo) {
-    return config ? 'showConfig' : '';
+  private computeChangesUrl(name?: RepoName) {
+    if (!name) return '';
+    return createSearchUrl({repo: name});
   }
 
-  _computeChangesUrl(name: RepoName) {
-    return GerritNav.getUrlForProjectChanges(name);
-  }
-
-  _computeBrowseUrl(weblinks: WebLinkInfo[]) {
-    return weblinks?.[0]?.url;
-  }
-
-  _handlePluginConfigChanged({
-    detail: {name, config, notifyPath},
+  // private but used in test
+  handlePluginConfigChanged({
+    detail: {name, config},
   }: {
     detail: {
       name: string;
       config: PluginParameterToConfigParameterInfoMap;
-      notifyPath: string;
     };
   }) {
-    if (this._repoConfig?.plugin_config) {
-      this._repoConfig.plugin_config[name] = config;
-      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+    if (this.repoConfig?.plugin_config) {
+      this.repoConfig.plugin_config[name] = config;
+      this.pluginConfigChanged = true;
+      this.requestUpdate();
     }
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-repo': GrRepo;
+  private handleDescriptionTextChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.description === e.detail.value) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      description: e.detail.value,
+    };
+    this.requestUpdate();
+  }
+
+  private handleStateSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.state === e.detail.value) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      state: e.detail.value as RepoState,
+    };
+    this.requestUpdate();
+  }
+
+  private handleSubmitTypeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig || this.loading) return;
+    if (this.repoConfig.submit_type === e.detail.value) return;
+    this.repoConfig = {
+      ...this.repoConfig,
+      submit_type: e.detail.value as SubmitType,
+    };
+    this.requestUpdate();
+  }
+
+  private handleContentMergeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.use_content_merge || this.loading) return;
+    this.repoConfig.use_content_merge.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleNewChangeSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.repoConfig?.create_new_change_for_all_not_in_target ||
+      this.loading
+    )
+      return;
+    this.repoConfig.create_new_change_for_all_not_in_target.configured_value = e
+      .detail.value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRequireChangeIdSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.require_change_id || this.loading) return;
+    this.repoConfig.require_change_id.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleEnableSignedPushBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.enable_signed_push || this.loading) return;
+    this.repoConfig.enable_signed_push.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRequireSignedPushBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.require_signed_push || this.loading) return;
+    this.repoConfig.require_signed_push.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRejectImplicitMergeSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.reject_implicit_merges || this.loading) return;
+    this.repoConfig.reject_implicit_merges.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUnRegisteredCcSelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.enable_reviewer_by_email || this.loading) return;
+    this.repoConfig.enable_reviewer_by_email.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleSetAllNewChangesPrivateByDefaultSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.private_by_default || this.loading) return;
+    this.repoConfig.private_by_default.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleSetAllNewChangesWorkInProgressByDefaultSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.work_in_progress_by_default || this.loading) return;
+    this.repoConfig.work_in_progress_by_default.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleMaxGitObjSizeBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.max_object_size_limit || this.loading) return;
+    this.repoConfig.max_object_size_limit.value = e.detail.value;
+    this.repoConfig.max_object_size_limit.configured_value = e.detail.value;
+    this.requestUpdate();
+  }
+
+  private handleMatchAuthoredDateWithCommitterDateSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.match_author_to_committer_date || this.loading)
+      return;
+    this.repoConfig.match_author_to_committer_date.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleRejectEmptyCommitSelectBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.reject_empty_commit || this.loading) return;
+    this.repoConfig.reject_empty_commit.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUseContributorAgreementsBindValueChanged(
+    e: BindValueChangeEvent
+  ) {
+    if (!this.repoConfig?.use_contributor_agreements || this.loading) return;
+    this.repoConfig.use_contributor_agreements.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
+  }
+
+  private handleUseSignedOffBySelectBindValueChanged(e: BindValueChangeEvent) {
+    if (!this.repoConfig?.use_signed_off_by || this.loading) return;
+    this.repoConfig.use_signed_off_by.configured_value = e.detail
+      .value as InheritedBooleanInfoConfiguredValue;
+    this.requestUpdate();
   }
 }
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
deleted file mode 100644
index 71abec0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
+++ /dev/null
@@ -1,449 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    .info {
-      margin-bottom: var(--spacing-xl);
-    }
-    h2.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-    .loading,
-    .hide {
-      display: none;
-    }
-    #loading.loading {
-      display: block;
-    }
-    #loading:not(.loading) {
-      display: none;
-    }
-    #options .repositorySettings {
-      display: none;
-    }
-    #options .repositorySettings.showConfig {
-      display: block;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <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>
-      <hr />
-      <div>
-        <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)]]">
-      Loading...
-    </div>
-    <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
-      <div id="downloadContent" class$="[[_computeHideClass(_schemes)]]">
-        <h2 id="download" class="heading-2">Download</h2>
-        <fieldset>
-          <gr-download-commands
-            id="downloadCommands"
-            commands="[[_computeCommands(repo, _schemesObj, _selectedScheme)]]"
-            schemes="[[_schemes]]"
-            selected-scheme="{{_selectedScheme}}"
-          ></gr-download-commands>
-        </fieldset>
-      </div>
-      <h2
-        id="configurations"
-        class$="heading-2 [[_computeHeaderClass(_configChanged)]]"
-      >
-        Configurations
-      </h2>
-      <div id="form">
-        <fieldset>
-          <h3 id="Description" class="heading-3">Description</h3>
-          <fieldset>
-            <iron-autogrow-textarea
-              id="descriptionInput"
-              class="description"
-              autocomplete="on"
-              placeholder="<Insert repo description here>"
-              bind-value="{{_repoConfig.description}}"
-              disabled$="[[_readOnly]]"
-            ></iron-autogrow-textarea>
-          </fieldset>
-          <h3 id="Options" class="heading-3">Repository Options</h3>
-          <fieldset id="options">
-            <section>
-              <span class="title">State</span>
-              <span class="value">
-                <gr-select id="stateSelect" bind-value="{{_repoConfig.state}}">
-                  <select disabled$="[[_readOnly]]">
-                    <template is="dom-repeat" items="[[_states]]">
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Submit type</span>
-              <span class="value">
-                <gr-select
-                  id="submitTypeSelect"
-                  bind-value="{{_repoConfig.submit_type}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatSubmitTypeSelect(_repoConfig)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Allow content merges</span>
-              <span class="value">
-                <gr-select
-                  id="contentMergeSelect"
-                  bind-value="{{_repoConfig.use_content_merge.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_content_merge)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Create a new change for every commit not in the target branch
-              </span>
-              <span class="value">
-                <gr-select
-                  id="newChangeSelect"
-                  bind-value="{{_repoConfig.create_new_change_for_all_not_in_target.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.create_new_change_for_all_not_in_target)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Require Change-Id in commit message</span>
-              <span class="value">
-                <gr-select
-                  id="requireChangeIdSelect"
-                  bind-value="{{_repoConfig.require_change_id.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.require_change_id)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              id="enableSignedPushSettings"
-              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.enable_signed_push)]]"
-            >
-              <span class="title">Enable signed push</span>
-              <span class="value">
-                <gr-select
-                  id="enableSignedPush"
-                  bind-value="{{_repoConfig.enable_signed_push.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.enable_signed_push)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section
-              id="requireSignedPushSettings"
-              class$="repositorySettings [[_computeRepositoriesClass(_repoConfig.require_signed_push)]]"
-            >
-              <span class="title">Require signed push</span>
-              <span class="value">
-                <gr-select
-                  id="requireSignedPush"
-                  bind-value="{{_repoConfig.require_signed_push.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.require_signed_push)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Reject implicit merges when changes are pushed for review</span
-              >
-              <span class="value">
-                <gr-select
-                  id="rejectImplicitMergesSelect"
-                  bind-value="{{_repoConfig.reject_implicit_merges.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.reject_implicit_merges)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Enable adding unregistered users as reviewers and CCs on
-                changes</span
-              >
-              <span class="value">
-                <gr-select
-                  id="unRegisteredCcSelect"
-                  bind-value="{{_repoConfig.enable_reviewer_by_email.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.enable_reviewer_by_email)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title"> Set all new changes private by default</span>
-              <span class="value">
-                <gr-select
-                  id="setAllnewChangesPrivateByDefaultSelect"
-                  bind-value="{{_repoConfig.private_by_default.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.private_by_default)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">
-                Set new changes to "work in progress" by default</span
-              >
-              <span class="value">
-                <gr-select
-                  id="setAllNewChangesWorkInProgressByDefaultSelect"
-                  bind-value="{{_repoConfig.work_in_progress_by_default.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.work_in_progress_by_default)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Maximum Git object size limit</span>
-              <span class="value">
-                <iron-input
-                  id="maxGitObjSizeIronInput"
-                  bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                  type="text"
-                  disabled$="[[_readOnly]]"
-                >
-                  <input
-                    id="maxGitObjSizeInput"
-                    bind-value="{{_repoConfig.max_object_size_limit.configured_value}}"
-                    is="iron-input"
-                    type="text"
-                    disabled$="[[_readOnly]]"
-                  />
-                </iron-input>
-                <template
-                  is="dom-if"
-                  if="[[_repoConfig.max_object_size_limit.value]]"
-                >
-                  effective: [[_repoConfig.max_object_size_limit.value]] bytes
-                </template>
-              </span>
-            </section>
-            <section>
-              <span class="title"
-                >Match authored date with committer date upon submit</span
-              >
-              <span class="value">
-                <gr-select
-                  id="matchAuthoredDateWithCommitterDateSelect"
-                  bind-value="{{_repoConfig.match_author_to_committer_date.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.match_author_to_committer_date)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Reject empty commit upon submit</span>
-              <span class="value">
-                <gr-select
-                  id="rejectEmptyCommitSelect"
-                  bind-value="{{_repoConfig.reject_empty_commit.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.reject_empty_commit)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-          </fieldset>
-          <h3 id="Options" class="heading-3">Contributor Agreements</h3>
-          <fieldset id="agreements">
-            <section>
-              <span class="title">
-                Require a valid contributor agreement to upload</span
-              >
-              <span class="value">
-                <gr-select
-                  id="contributorAgreementSelect"
-                  bind-value="{{_repoConfig.use_contributor_agreements.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_contributor_agreements)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-            <section>
-              <span class="title">Require Signed-off-by in commit message</span>
-              <span class="value">
-                <gr-select
-                  id="useSignedOffBySelect"
-                  bind-value="{{_repoConfig.use_signed_off_by.configured_value}}"
-                >
-                  <select disabled$="[[_readOnly]]">
-                    <template
-                      is="dom-repeat"
-                      items="[[_formatBooleanSelect(_repoConfig.use_signed_off_by)]]"
-                    >
-                      <option value="[[item.value]]">[[item.label]]</option>
-                    </template>
-                  </select>
-                </gr-select>
-              </span>
-            </section>
-          </fieldset>
-          <div
-            class$="pluginConfig [[_computeHideClass(_pluginData)]]"
-            on-plugin-config-changed="_handlePluginConfigChanged"
-          >
-            <h3 class="heading-3">Plugins</h3>
-            <template is="dom-repeat" items="[[_pluginData]]" as="data">
-              <gr-repo-plugin-config
-                plugin-data="[[data]]"
-              ></gr-repo-plugin-config>
-            </template>
-          </div>
-          <gr-button
-            on-click="_handleSaveRepoConfig"
-            disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]"
-            >Save changes</gr-button
-          >
-        </fieldset>
-        <gr-endpoint-decorator name="repo-config">
-          <gr-endpoint-param
-            name="repoName"
-            value="[[repo]]"
-          ></gr-endpoint-param>
-          <gr-endpoint-param
-            name="readOnly"
-            value="[[_readOnly]]"
-          ></gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-  </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
deleted file mode 100644
index 89ad86e..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
+++ /dev/null
@@ -1,358 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-repo.js';
-import {mockPromise} from '../../../test/test-utils.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.',
-    use_contributor_agreements: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    use_content_merge: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    use_signed_off_by: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    create_new_change_for_all_not_in_target: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    require_change_id: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    enable_signed_push: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    require_signed_push: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    reject_implicit_merges: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    private_by_default: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    match_author_to_committer_date: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    reject_empty_commit: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    enable_reviewer_by_email: {
-      value: false,
-      configured_value: 'FALSE',
-    },
-    max_object_size_limit: {},
-    submit_type: 'MERGE_IF_NECESSARY',
-    default_submit_type: {
-      value: 'MERGE_IF_NECESSARY',
-      configured_value: 'INHERIT',
-      inherited_value: 'MERGE_IF_NECESSARY',
-    },
-  };
-
-  const REPO = 'test-repo';
-  const SCHEMES = {http: {}, repo: {}, ssh: {}};
-
-  function getFormFields() {
-    const selects = Array.from(
-        element.root.querySelectorAll('select'));
-    const textareas = Array.from(
-        element.root.querySelectorAll('iron-autogrow-textarea'));
-    const inputs = Array.from(
-        element.root.querySelectorAll('input'));
-    return inputs.concat(textareas).concat(selects);
-  }
-
-  setup(() => {
-    loggedInStub = stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getConfig').returns(Promise.resolve({download: {}}));
-    repoStub =
-        stubRestApi('getProjectConfig').returns(Promise.resolve(repoConf));
-    element = basicFixture.instantiate();
-  });
-
-  test('_computePluginData', () => {
-    assert.deepEqual(element._computePluginData(), []);
-    assert.deepEqual(element._computePluginData({}), []);
-    assert.deepEqual(element._computePluginData({base: {}}), []);
-    assert.deepEqual(element._computePluginData({base: {plugin: 'data'}}),
-        [{name: 'plugin', config: 'data'}]);
-  });
-
-  test('_handlePluginConfigChanged', () => {
-    const notifyStub = sinon.stub(element, 'notifyPath');
-    element._repoConfig = {plugin_config: {}};
-    element._handlePluginConfigChanged({detail: {
-      name: 'test',
-      config: 'data',
-      notifyPath: 'path',
-    }});
-    flush();
-
-    assert.equal(element._repoConfig.plugin_config.test, 'data');
-    assert.equal(notifyStub.lastCall.args[0],
-        '_repoConfig.plugin_config.path');
-  });
-
-  test('loading displays before repo config is loaded', () => {
-    assert.isTrue(element.$.loading.classList.contains('loading'));
-    assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
-    assert.isTrue(element.$.loadedContent.classList.contains('loading'));
-    assert.isTrue(getComputedStyle(element.$.loadedContent)
-        .display === 'none');
-  });
-
-  test('download commands visibility', () => {
-    element._loading = false;
-    flush();
-    assert.isTrue(element.$.downloadContent.classList.contains('hide'));
-    assert.isTrue(getComputedStyle(element.$.downloadContent)
-        .display == 'none');
-    element._schemesObj = SCHEMES;
-    flush();
-    assert.isFalse(element.$.downloadContent.classList.contains('hide'));
-    assert.isFalse(getComputedStyle(element.$.downloadContent)
-        .display == 'none');
-  });
-
-  test('form defaults to read only', () => {
-    assert.isTrue(element._readOnly);
-  });
-
-  test('form defaults to read only when not logged in', async () => {
-    element.repo = REPO;
-    await element._loadRepo();
-    assert.isTrue(element._readOnly);
-  });
-
-  test('form defaults to read only when logged in and not admin', async () => {
-    element.repo = REPO;
-    stubRestApi('getRepoAccess')
-        .callsFake(() => Promise.resolve({'test-repo': {}}));
-    await element._loadRepo();
-    assert.isTrue(element._readOnly);
-  });
-
-  test('all form elements are disabled when not admin', async () => {
-    element.repo = REPO;
-    await element._loadRepo();
-    flush();
-    const formFields = getFormFields();
-    for (const field of formFields) {
-      assert.isTrue(field.hasAttribute('disabled'));
-    }
-  });
-
-  test('_formatBooleanSelect', () => {
-    let item = {inherited_value: true};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit (true)',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-
-    item = {inherited_value: false};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit (false)',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-
-    // For items without inherited values
-    item = {};
-    assert.deepEqual(element._formatBooleanSelect(item), [
-      {
-        label: 'Inherit',
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ]);
-  });
-
-  test('fires page-error', async () => {
-    repoStub.restore();
-
-    element.repo = 'test';
-
-    const pageErrorFired = mockPromise();
-    const response = {status: 404};
-    stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
-      errFn(response);
-      return Promise.resolve(undefined);
-    });
-    addListenerForTest(document, 'page-error', e => {
-      assert.deepEqual(e.detail.response, response);
-      pageErrorFired.resolve();
-    });
-
-    element._loadRepo();
-    await pageErrorFired;
-  });
-
-  suite('admin', () => {
-    setup(() => {
-      element.repo = REPO;
-      loggedInStub.returns(Promise.resolve(true));
-      stubRestApi('getRepoAccess')
-          .returns(Promise.resolve({'test-repo': {is_owner: true}}));
-    });
-
-    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', async () => {
-      await element._loadRepo();
-      assert.equal(element._repoConfig.state, 'ACTIVE');
-      assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
-    });
-
-    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', async () => {
-      const configInputObj = {
-        description: 'new description',
-        use_contributor_agreements: 'TRUE',
-        use_content_merge: 'TRUE',
-        use_signed_off_by: 'TRUE',
-        create_new_change_for_all_not_in_target: 'TRUE',
-        require_change_id: 'TRUE',
-        enable_signed_push: 'TRUE',
-        require_signed_push: 'TRUE',
-        reject_implicit_merges: 'TRUE',
-        private_by_default: 'TRUE',
-        match_author_to_committer_date: 'TRUE',
-        reject_empty_commit: 'TRUE',
-        max_object_size_limit: 10,
-        submit_type: 'FAST_FORWARD_ONLY',
-        state: 'READ_ONLY',
-        enable_reviewer_by_email: 'TRUE',
-      };
-
-      const saveStub = stubRestApi('saveRepoConfig')
-          .callsFake(() => Promise.resolve({}));
-
-      const button = element.root.querySelectorAll('gr-button')[2];
-
-      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'));
-
-      const formattedObj =
-          element._formatRepoConfigForSave(element._repoConfig);
-      assert.deepEqual(formattedObj, 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-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
new file mode 100644
index 0000000..4deb99a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -0,0 +1,804 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-repo';
+import {GrRepo} from './gr-repo';
+import {mockPromise} from '../../../test/test-utils';
+import {
+  addListenerForTest,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  createInheritedBoolean,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {
+  ConfigInfo,
+  GitRef,
+  GroupId,
+  GroupName,
+  InheritedBooleanInfo,
+  MaxObjectSizeLimitInfo,
+  PluginParameterToConfigParameterInfoMap,
+  RepoAccessGroups,
+  RepoAccessInfoMap,
+  RepoName,
+} from '../../../types/common';
+import {
+  ConfigParameterInfoType,
+  InheritedBooleanInfoConfiguredValue,
+  RepoState,
+  SubmitType,
+} from '../../../constants/constants';
+import {
+  createConfig,
+  createDownloadSchemes,
+} from '../../../test/test-data-generators';
+import {PageErrorEvent} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-repo tests', () => {
+  let element: GrRepo;
+  let loggedInStub: sinon.SinonStub;
+  let repoStub: sinon.SinonStub;
+
+  const repoConf: ConfigInfo = {
+    description: 'Access inherited by all other repositories.',
+    use_contributor_agreements: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    use_content_merge: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    use_signed_off_by: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    create_new_change_for_all_not_in_target: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    require_change_id: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    enable_signed_push: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    require_signed_push: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    reject_implicit_merges: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    match_author_to_committer_date: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    reject_empty_commit: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    enable_reviewer_by_email: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    private_by_default: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    work_in_progress_by_default: {
+      value: false,
+      configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+    },
+    max_object_size_limit: {},
+    commentlinks: {},
+    submit_type: SubmitType.MERGE_IF_NECESSARY,
+    default_submit_type: {
+      value: SubmitType.MERGE_IF_NECESSARY,
+      configured_value: SubmitType.INHERIT,
+      inherited_value: SubmitType.MERGE_IF_NECESSARY,
+    },
+  };
+
+  const REPO = 'test-repo';
+  const SCHEMES = {
+    ...createDownloadSchemes(),
+    http: {
+      url: 'test',
+      is_auth_required: false,
+      is_auth_supported: false,
+      commands: 'test',
+      clone_commands: {clone: 'test'},
+    },
+    repo: {
+      url: 'test',
+      is_auth_required: false,
+      is_auth_supported: false,
+      commands: 'test',
+      clone_commands: {clone: 'test'},
+    },
+    ssh: {
+      url: 'test',
+      is_auth_required: false,
+      is_auth_supported: false,
+      commands: 'test',
+      clone_commands: {clone: 'test'},
+    },
+  };
+
+  function getFormFields() {
+    const selects = Array.from(queryAll(element, 'select'));
+    const textareas = Array.from(queryAll(element, 'iron-autogrow-textarea'));
+    const inputs = Array.from(queryAll(element, 'input'));
+    return inputs.concat(textareas).concat(selects);
+  }
+
+  setup(async () => {
+    loggedInStub = stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
+    repoStub = stubRestApi('getProjectConfig').returns(
+      Promise.resolve(repoConf)
+    );
+    element = await fixture(html`<gr-repo></gr-repo>`);
+  });
+
+  test('render', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    await element.updateComplete;
+    // prettier and shadowDom assert do not agree about span.title wrapping
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <div class="gr-form-styles main read-only">
+        <div class="info">
+          <h1 class="heading-1" id="Title">test-repo</h1>
+          <hr />
+          <div>
+            <a href="">
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                link=""
+                role="button"
+                tabindex="-1"
+              >
+                Browse
+              </gr-button>
+            </a>
+            <a href="/q/project:test-repo">
+              <gr-button
+                aria-disabled="false"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                View Changes
+              </gr-button>
+            </a>
+          </div>
+        </div>
+        <div id="loadedContent">
+          <h2 class="heading-2" id="configurations">Configurations</h2>
+          <div id="form">
+            <fieldset>
+              <h3 class="heading-3" id="Description">Description</h3>
+              <fieldset>
+                <gr-textarea
+                  autocomplete="on"
+                  class="description monospace"
+                  disabled=""
+                  id="descriptionInput"
+                  monospace=""
+                  placeholder="<Insert repo description here>"
+                  rows="4"
+                >
+                </gr-textarea>
+              </fieldset>
+              <h3 class="heading-3" id="Options">Repository Options</h3>
+              <fieldset id="options">
+                <section>
+                  <span class="title"> State </span>
+                  <span class="value">
+                    <gr-select id="stateSelect">
+                      <select disabled="">
+                        <option value="ACTIVE">Active</option>
+                        <option value="READ_ONLY">Read Only</option>
+                        <option value="HIDDEN">Hidden</option>
+                      </select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Submit type </span>
+                  <span class="value">
+                    <gr-select id="submitTypeSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Allow content merges </span>
+                  <span class="value">
+                    <gr-select id="contentMergeSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Create a new change for every commit not in the target branch
+                  </span>
+                  <span class="value">
+                    <gr-select id="newChangeSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Require Change-Id in commit message
+                  </span>
+                  <span class="value">
+                    <gr-select id="requireChangeIdSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section
+                  class="repositorySettings showConfig"
+                  id="enableSignedPushSettings"
+                >
+                  <span class="title"> Enable signed push </span>
+                  <span class="value">
+                    <gr-select id="enableSignedPush">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section
+                  class="repositorySettings showConfig"
+                  id="requireSignedPushSettings"
+                >
+                  <span class="title"> Require signed push </span>
+                  <span class="value">
+                    <gr-select id="requireSignedPush">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Reject implicit merges when changes are pushed for review
+                  </span>
+                  <span class="value">
+                    <gr-select id="rejectImplicitMergesSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Enable adding unregistered users as reviewers and CCs on changes
+                  </span>
+                  <span class="value">
+                    <gr-select id="unRegisteredCcSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Set all new changes private by default
+                  </span>
+                  <span class="value">
+                    <gr-select id="setAllnewChangesPrivateByDefaultSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Set new changes to "work in progress" by default
+                  </span>
+                  <span class="value">
+                    <gr-select
+                      id="setAllNewChangesWorkInProgressByDefaultSelect"
+                    >
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Maximum Git object size limit </span>
+                  <span class="value">
+                    <iron-input id="maxGitObjSizeIronInput">
+                      <input disabled="" id="maxGitObjSizeInput" type="text" />
+                    </iron-input>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Match authored date with committer date upon submit
+                  </span>
+                  <span class="value">
+                    <gr-select id="matchAuthoredDateWithCommitterDateSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title"> Reject empty commit upon submit </span>
+                  <span class="value">
+                    <gr-select id="rejectEmptyCommitSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+              </fieldset>
+              <h3 class="heading-3" id="Options">Contributor Agreements</h3>
+              <fieldset id="agreements">
+                <section>
+                  <span class="title">
+                    Require a valid contributor agreement to upload
+                  </span>
+                  <span class="value">
+                    <gr-select id="contributorAgreementSelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+                <section>
+                  <span class="title">
+                    Require Signed-off-by in commit message
+                  </span>
+                  <span class="value">
+                    <gr-select id="useSignedOffBySelect">
+                      <select disabled=""></select>
+                    </gr-select>
+                  </span>
+                </section>
+              </fieldset>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                role="button"
+                tabindex="-1"
+              >
+                Save changes
+              </gr-button>
+            </fieldset>
+            <gr-endpoint-decorator name="repo-config">
+              <gr-endpoint-param name="repoName"> </gr-endpoint-param>
+              <gr-endpoint-param name="readOnly"> </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        </div>
+      </div>
+    `,
+      {ignoreTags: ['option']}
+    );
+  });
+
+  test('render loading', async () => {
+    element.repo = REPO as RepoName;
+    element.loading = true;
+    await element.updateComplete;
+    // prettier and shadowDom assert do not agree about span.title wrapping
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <div class="gr-form-styles main read-only">
+        <div class="info">
+          <h1 class="heading-1" id="Title">test-repo</h1>
+          <hr />
+          <div>
+            <a href="">
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                link=""
+                role="button"
+                tabindex="-1"
+              >
+                Browse
+              </gr-button>
+            </a>
+            <a href="/q/project:test-repo">
+              <gr-button
+                aria-disabled="false"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                View Changes
+              </gr-button>
+            </a>
+          </div>
+        </div>
+        <div id="loading">Loading...</div>
+      </div>
+    `,
+      {ignoreTags: ['option']}
+    );
+  });
+
+  test('_computePluginData', async () => {
+    element.repoConfig = {
+      ...createConfig(),
+      plugin_config: {},
+    };
+    await element.updateComplete;
+    assert.deepEqual(element.computePluginData(), []);
+
+    element.repoConfig.plugin_config = {
+      'test-plugin': {
+        test: {display_name: 'test plugin', type: 'STRING'},
+      } as PluginParameterToConfigParameterInfoMap,
+    };
+    await element.updateComplete;
+    assert.deepEqual(element.computePluginData(), [
+      {
+        name: 'test-plugin',
+        config: {
+          test: {
+            display_name: 'test plugin',
+            type: 'STRING' as ConfigParameterInfoType,
+          },
+        },
+      },
+    ]);
+  });
+
+  test('handlePluginConfigChanged', async () => {
+    const requestUpdateStub = sinon.stub(element, 'requestUpdate');
+    element.repoConfig = {
+      ...createConfig(),
+      plugin_config: {},
+    };
+    element.handlePluginConfigChanged({
+      detail: {
+        name: 'test',
+        config: {
+          test: {display_name: 'test plugin', type: 'STRING'},
+        } as PluginParameterToConfigParameterInfoMap,
+      },
+    });
+    await element.updateComplete;
+
+    assert.deepEqual(element.repoConfig.plugin_config!.test, {
+      test: {display_name: 'test plugin', type: 'STRING'},
+    } as PluginParameterToConfigParameterInfoMap);
+    assert.isTrue(requestUpdateStub.called);
+  });
+
+  test('render download commands', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    element.schemesObj = SCHEMES;
+    await element.updateComplete;
+    const content = queryAndAssert<HTMLDivElement>(element, '#downloadContent');
+    assert.dom.equal(
+      content,
+      /* HTML */ `
+        <div id="downloadContent">
+          <h2 class="heading-2" id="download">Download</h2>
+          <fieldset>
+            <gr-download-commands id="downloadCommands"></gr-download-commands>
+          </fieldset>
+        </div>
+      `
+    );
+  });
+
+  test('form defaults to read only', () => {
+    assert.isTrue(element.readOnly);
+  });
+
+  test('form defaults to read only when not logged in', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    assert.isTrue(element.readOnly);
+  });
+
+  test('form defaults to read only when logged in and not admin', async () => {
+    element.repo = REPO as RepoName;
+
+    stubRestApi('getRepoAccess').callsFake(() =>
+      Promise.resolve({
+        'test-repo': {
+          revision: 'xxxx',
+          local: {
+            'refs/*': {
+              permissions: {
+                owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
+              },
+            },
+          },
+          owner_of: ['refs/*'] as GitRef[],
+          groups: {
+            xxxx: {
+              id: 'xxxx' as GroupId,
+              url: 'test',
+              name: 'test' as GroupName,
+            },
+          } as RepoAccessGroups,
+          config_web_links: [{name: 'gitiles', url: 'test'}],
+        },
+      } as RepoAccessInfoMap)
+    );
+    await element.loadRepo();
+    assert.isTrue(element.readOnly);
+  });
+
+  test('all form elements are disabled when not admin', async () => {
+    element.repo = REPO as RepoName;
+    await element.loadRepo();
+    await element.updateComplete;
+    const formFields = getFormFields();
+    for (const field of formFields) {
+      assert.isTrue(field.hasAttribute('disabled'));
+    }
+  });
+
+  test('formatBooleanSelect', () => {
+    let item: InheritedBooleanInfo = {
+      ...createInheritedBoolean(true),
+      inherited_value: true,
+    };
+    assert.deepEqual(element.formatBooleanSelect(item), [
+      {
+        label: 'Inherit (true)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    item = {...createInheritedBoolean(false), inherited_value: false};
+    assert.deepEqual(element.formatBooleanSelect(item), [
+      {
+        label: 'Inherit (false)',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+
+    // For items without inherited values
+    item = createInheritedBoolean(false);
+    assert.deepEqual(element.formatBooleanSelect(item), [
+      {
+        label: 'Inherit',
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ]);
+  });
+
+  test('fires page-error', async () => {
+    repoStub.restore();
+
+    element.repo = 'test' as RepoName;
+
+    const pageErrorFired = mockPromise();
+    const response = {...new Response(), status: 404};
+    stubRestApi('getProjectConfig').callsFake((_, errFn) => {
+      if (errFn !== undefined) {
+        errFn(response);
+      }
+      return Promise.resolve(undefined);
+    });
+    addListenerForTest(document, 'page-error', e => {
+      assert.deepEqual((e as PageErrorEvent).detail.response, response);
+      pageErrorFired.resolve();
+    });
+
+    element.loadRepo();
+    await pageErrorFired;
+  });
+
+  suite('admin', () => {
+    setup(() => {
+      element.repo = REPO as RepoName;
+      loggedInStub.returns(Promise.resolve(true));
+      stubRestApi('getRepoAccess').callsFake(() =>
+        Promise.resolve({
+          'test-repo': {
+            revision: 'xxxx',
+            local: {
+              'refs/*': {
+                permissions: {
+                  owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
+                },
+              },
+            },
+            is_owner: true,
+            owner_of: ['refs/*'] as GitRef[],
+            groups: {
+              xxxx: {
+                id: 'xxxx' as GroupId,
+                url: 'test',
+                name: 'test' as GroupName,
+              },
+            } as RepoAccessGroups,
+            config_web_links: [{name: 'gitiles', url: 'test'}],
+          },
+        } as RepoAccessInfoMap)
+      );
+    });
+
+    test('all form elements are enabled', async () => {
+      await element.loadRepo();
+      await element.updateComplete;
+      const formFields = getFormFields();
+      for (const field of formFields) {
+        assert.isFalse(field.hasAttribute('disabled'));
+      }
+      assert.isFalse(element.loading);
+    });
+
+    test('state gets set correctly', async () => {
+      await element.loadRepo();
+      assert.equal(element.repoConfig!.state, RepoState.ACTIVE);
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#stateSelect').bindValue,
+        RepoState.ACTIVE
+      );
+    });
+
+    test('inherited submit type value is calculated correctly', async () => {
+      await element.loadRepo();
+      const sel = queryAndAssert<GrSelect>(element, '#submitTypeSelect');
+      assert.equal(sel.bindValue, 'INHERIT');
+      assert.equal(
+        sel.nativeSelect.options[0].text,
+        'Inherit (Merge if necessary)'
+      );
+    });
+
+    test('fields update and save correctly', async () => {
+      const configInputObj = {
+        description: 'new description',
+        use_contributor_agreements: InheritedBooleanInfoConfiguredValue.TRUE,
+        use_content_merge: InheritedBooleanInfoConfiguredValue.TRUE,
+        use_signed_off_by: InheritedBooleanInfoConfiguredValue.TRUE,
+        create_new_change_for_all_not_in_target:
+          InheritedBooleanInfoConfiguredValue.TRUE,
+        require_change_id: InheritedBooleanInfoConfiguredValue.TRUE,
+        enable_signed_push: InheritedBooleanInfoConfiguredValue.TRUE,
+        require_signed_push: InheritedBooleanInfoConfiguredValue.TRUE,
+        reject_implicit_merges: InheritedBooleanInfoConfiguredValue.TRUE,
+        private_by_default: InheritedBooleanInfoConfiguredValue.TRUE,
+        work_in_progress_by_default: InheritedBooleanInfoConfiguredValue.TRUE,
+        match_author_to_committer_date:
+          InheritedBooleanInfoConfiguredValue.TRUE,
+        reject_empty_commit: InheritedBooleanInfoConfiguredValue.TRUE,
+        max_object_size_limit: '10' as MaxObjectSizeLimitInfo,
+        submit_type: SubmitType.FAST_FORWARD_ONLY,
+        state: RepoState.READ_ONLY,
+        enable_reviewer_by_email: InheritedBooleanInfoConfiguredValue.TRUE,
+      };
+
+      const saveStub = stubRestApi('saveRepoConfig').callsFake(() =>
+        Promise.resolve(new Response())
+      );
+
+      await element.loadRepo();
+
+      const button = queryAll<GrButton>(element, 'gr-button')[2];
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#Title'
+        ).classList.contains('edited')
+      );
+      queryAndAssert<GrTextarea>(element, '#descriptionInput').text =
+        configInputObj.description;
+      queryAndAssert<GrSelect>(element, '#stateSelect').bindValue =
+        configInputObj.state;
+      queryAndAssert<GrSelect>(element, '#submitTypeSelect').bindValue =
+        configInputObj.submit_type;
+      queryAndAssert<GrSelect>(element, '#contentMergeSelect').bindValue =
+        configInputObj.use_content_merge;
+      queryAndAssert<GrSelect>(element, '#newChangeSelect').bindValue =
+        configInputObj.create_new_change_for_all_not_in_target;
+      queryAndAssert<GrSelect>(element, '#requireChangeIdSelect').bindValue =
+        configInputObj.require_change_id;
+      queryAndAssert<GrSelect>(element, '#enableSignedPush').bindValue =
+        configInputObj.enable_signed_push;
+      queryAndAssert<GrSelect>(element, '#requireSignedPush').bindValue =
+        configInputObj.require_signed_push;
+      queryAndAssert<GrSelect>(
+        element,
+        '#rejectImplicitMergesSelect'
+      ).bindValue = configInputObj.reject_implicit_merges;
+      queryAndAssert<GrSelect>(
+        element,
+        '#setAllnewChangesPrivateByDefaultSelect'
+      ).bindValue = configInputObj.private_by_default;
+      queryAndAssert<GrSelect>(
+        element,
+        '#setAllNewChangesWorkInProgressByDefaultSelect'
+      ).bindValue = configInputObj.work_in_progress_by_default;
+      queryAndAssert<GrSelect>(
+        element,
+        '#matchAuthoredDateWithCommitterDateSelect'
+      ).bindValue = configInputObj.match_author_to_committer_date;
+      queryAndAssert<IronInputElement>(
+        element,
+        '#maxGitObjSizeIronInput'
+      ).bindValue = String(configInputObj.max_object_size_limit);
+      queryAndAssert<GrSelect>(
+        element,
+        '#contributorAgreementSelect'
+      ).bindValue = configInputObj.use_contributor_agreements;
+      queryAndAssert<GrSelect>(element, '#useSignedOffBySelect').bindValue =
+        configInputObj.use_signed_off_by;
+      queryAndAssert<GrSelect>(element, '#rejectEmptyCommitSelect').bindValue =
+        configInputObj.reject_empty_commit;
+      queryAndAssert<GrSelect>(element, '#unRegisteredCcSelect').bindValue =
+        configInputObj.enable_reviewer_by_email;
+
+      await element.updateComplete;
+
+      assert.isFalse(button.hasAttribute('disabled'));
+      assert.isTrue(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#configurations'
+        ).classList.contains('edited')
+      );
+
+      const formattedObj = element.formatRepoConfigForSave(element.repoConfig);
+      assert.deepEqual(formattedObj, configInputObj);
+
+      await element.handleSaveRepoConfig();
+      assert.isTrue(button.hasAttribute('disabled'));
+      assert.isFalse(
+        queryAndAssert<HTMLHeadingElement>(
+          element,
+          '#Title'
+        ).classList.contains('edited')
+      );
+      assert.isTrue(
+        saveStub.lastCall.calledWithExactly(REPO as RepoName, 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 172f807..975dd3b 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
@@ -1,30 +1,22 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-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';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
+import {PermissionAction} from '../../../constants/constants';
 
 /**
  * Fired when the rule has been modified or removed.
@@ -38,15 +30,19 @@
  * @event added-rule-removed
  */
 
-const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
+const PRIORITY_OPTIONS = [PermissionAction.BATCH, PermissionAction.INTERACTIVE];
 
 const Action = {
-  ALLOW: 'ALLOW',
-  DENY: 'DENY',
-  BLOCK: 'BLOCK',
+  ALLOW: PermissionAction.ALLOW,
+  DENY: PermissionAction.DENY,
+  BLOCK: PermissionAction.BLOCK,
 };
 
-const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.BLOCK];
+const DROPDOWN_OPTIONS = [
+  PermissionAction.ALLOW,
+  PermissionAction.DENY,
+  PermissionAction.BLOCK,
+];
 
 const ForcePushOptions = {
   ALLOW: [
@@ -70,19 +66,7 @@
   },
 ];
 
-interface Rule {
-  value: RuleValue;
-}
-
-interface RuleValue {
-  min?: number;
-  max?: number;
-  force?: boolean;
-  action?: string;
-  added?: boolean;
-  modified?: boolean;
-  deleted?: boolean;
-}
+type Rule = {value?: EditablePermissionRuleInfo};
 
 interface RuleLabel {
   values: RuleLabelValue[];
@@ -100,18 +84,14 @@
 }
 
 @customElement('gr-rule-editor')
-export class GrRuleEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrRuleEditor extends LitElement {
   @property({type: Boolean})
   hasRange?: boolean;
 
   @property({type: Object})
   label?: RuleLabel;
 
-  @property({type: Boolean, observer: '_handleEditingChanged'})
+  @property({type: Boolean})
   editing = false;
 
   @property({type: String})
@@ -124,97 +104,281 @@
   @property({type: String})
   permission!: AccessPermissionId;
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   rule?: Rule;
 
   @property({type: String})
   section?: string;
 
-  @property({type: Boolean})
-  _deleted = false;
+  // private but used in test
+  @state() deleted = false;
 
-  @property({type: Object})
-  _originalRuleValues?: RuleValue;
+  // private but used in test
+  @state() originalRuleValues?: EditablePermissionRuleInfo;
 
   constructor() {
     super();
-    this.addEventListener('access-saved', () => this._handleAccessSaved());
-  }
-
-  override ready() {
-    super.ready();
-    // Called on ready rather than the observer because when new rules are
-    // added, the observer is triggered prior to being ready.
-    if (!this.rule) {
-      return;
-    } // Check needed for test purposes.
-    this._setupValues(this.rule);
+    this.addEventListener('access-saved', () => this.handleAccessSaved());
   }
 
   override connectedCallback() {
     super.connectedCallback();
+    if (this.rule) {
+      this.setupValues();
+    }
     // Check needed for test purposes.
-    if (!this._originalRuleValues && this.rule) {
-      // Observer _handleValueChange is called after the ready()
-      // method finishes. Original values must be set later to
-      // avoid set .modified flag to true
-      this._setOriginalRuleValues(this.rule.value);
+    if (!this.originalRuleValues && this.rule) {
+      this.setOriginalRuleValues();
     }
   }
 
-  _setupValues(rule: Rule) {
-    if (!rule.value) {
-      this._setDefaultRuleValues();
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      css`
+        :host {
+          border-bottom: 1px solid var(--border-color);
+          padding: var(--spacing-m);
+          display: block;
+        }
+        #removeBtn {
+          display: none;
+        }
+        .editing #removeBtn {
+          display: flex;
+        }
+        #options {
+          align-items: baseline;
+          display: flex;
+        }
+        #options > * {
+          margin-right: var(--spacing-m);
+        }
+        #mainContainer {
+          align-items: baseline;
+          display: flex;
+          flex-wrap: nowrap;
+          justify-content: space-between;
+        }
+        #deletedContainer.deleted {
+          align-items: baseline;
+          display: flex;
+          justify-content: space-between;
+        }
+        #undoBtn,
+        #force,
+        #deletedContainer,
+        #mainContainer.deleted {
+          display: none;
+        }
+        #undoBtn.modified,
+        #force.force {
+          display: block;
+        }
+        .groupPath {
+          color: var(--deemphasized-text-color);
+        }
+        iron-autogrow-textarea {
+          width: 14em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div
+        id="mainContainer"
+        class="gr-form-styles ${this.computeSectionClass()}"
+      >
+        <div id="options">
+          <gr-select
+            id="action"
+            .bindValue=${this.rule?.value?.action}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.handleActionBindValueChanged(e);
+            }}
+          >
+            <select ?disabled=${!this.editing}>
+              ${this.computeOptions().map(
+                item => html` <option value=${item}>${item}</option> `
+              )}
+            </select>
+          </gr-select>
+          ${this.renderMinAndMaxLabel()} ${this.renderMinAndMaxInput()}
+          <a
+            class="groupPath"
+            href=${ifDefined(this.computeGroupPath(this.groupId))}
+          >
+            ${this.groupName}
+          </a>
+          <gr-select
+            id="force"
+            class=${this.computeForce(this.rule?.value?.action) ? 'force' : ''}
+            .bindValue=${this.rule?.value?.force}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.handleForceBindValueChanged(e);
+            }}
+          >
+            <select ?disabled=${!this.editing}>
+              ${this.computeForceOptions(this.rule?.value?.action).map(
+                item => html`
+                  <option value=${item.value}>${item.name}</option>
+                `
+              )}
+            </select>
+          </gr-select>
+        </div>
+        <gr-button
+          link
+          id="removeBtn"
+          @click=${() => {
+            this.handleRemoveRule();
+          }}
+          >Remove</gr-button
+        >
+      </div>
+      <div
+        id="deletedContainer"
+        class="gr-form-styles ${this.computeSectionClass()}"
+      >
+        ${this.groupName} was deleted
+        <gr-button
+          link
+          id="undoRemoveBtn"
+          @click=${() => {
+            this.handleUndoRemove();
+          }}
+          >Undo</gr-button
+        >
+      </div>
+    `;
+  }
+
+  private renderMinAndMaxLabel() {
+    if (!this.label) return;
+
+    return html`
+      <gr-select
+        id="labelMin"
+        .bindValue=${this.rule?.value?.min}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMinBindValueChanged(e);
+        }}
+      >
+        <select ?disabled=${!this.editing}>
+          ${this.label.values.map(
+            item => html` <option value=${item.value}>${item.value}</option> `
+          )}
+        </select>
+      </gr-select>
+      <gr-select
+        id="labelMax"
+        .bindValue=${this.rule?.value?.max}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMaxBindValueChanged(e);
+        }}
+      >
+        <select ?disabled=${!this.editing}>
+          ${this.label.values.map(
+            item => html` <option value=${item.value}>${item.value}</option> `
+          )}
+        </select>
+      </gr-select>
+    `;
+  }
+
+  private renderMinAndMaxInput() {
+    if (!this.hasRange) return;
+
+    return html`
+      <iron-autogrow-textarea
+        id="minInput"
+        class="min"
+        autocomplete="on"
+        placeholder="Min value"
+        .bindValue=${this.rule?.value?.min}
+        ?disabled=${!this.editing}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMinBindValueChanged(e);
+        }}
+      ></iron-autogrow-textarea>
+      <iron-autogrow-textarea
+        id="maxInput"
+        class="max"
+        autocomplete="on"
+        placeholder="Max value"
+        .bindValue=${this.rule?.value?.max}
+        ?disabled=${!this.editing}
+        @bind-value-changed=${(e: BindValueChangeEvent) => {
+          this.handleMaxBindValueChanged(e);
+        }}
+      ></iron-autogrow-textarea>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('editing')) {
+      this.handleEditingChanged(changedProperties.get('editing') as boolean);
     }
   }
 
-  _computeForce(permission: AccessPermissionId, action: string) {
-    if (AccessPermissionId.PUSH === permission && action !== Action.DENY) {
+  // private but used in test
+  setupValues() {
+    if (!this.rule?.value) {
+      this.setDefaultRuleValues();
+    }
+  }
+
+  // private but used in test
+  computeForce(action?: string) {
+    if (AccessPermissionId.PUSH === this.permission && action !== Action.DENY) {
       return true;
     }
 
-    return AccessPermissionId.EDIT_TOPIC_NAME === permission;
+    return AccessPermissionId.EDIT_TOPIC_NAME === this.permission;
   }
 
-  _computeForceClass(permission: AccessPermissionId, action: string) {
-    return this._computeForce(permission, action) ? 'force' : '';
+  // private but used in test
+  computeGroupPath(groupId?: string) {
+    if (!groupId) return;
+    return `${getBaseUrl()}/admin/groups/${encodeURL(groupId, true)}`;
   }
 
-  _computeGroupPath(group: string) {
-    return `${getBaseUrl()}/admin/groups/${encodeURL(group, true)}`;
-  }
-
-  _handleAccessSaved() {
-    if (!this.rule) return;
+  // private but used in test
+  handleAccessSaved() {
     // Set a new 'original' value to keep track of after the value has been
     // saved.
-    this._setOriginalRuleValues(this.rule.value);
+    this.setOriginalRuleValues();
   }
 
-  _handleEditingChanged(editing: boolean, editingOld: boolean) {
+  private handleEditingChanged(editingOld: boolean) {
     // Ignore when editing gets set initially.
     if (!editingOld) {
       return;
     }
     // Restore original values if no longer editing.
-    if (!editing) {
-      this._handleUndoChange();
+    if (!this.editing) {
+      this.handleUndoChange();
     }
   }
 
-  _computeSectionClass(editing: boolean, deleted: boolean) {
+  // private but used in test
+  computeSectionClass() {
     const classList = [];
-    if (editing) {
+    if (this.editing) {
       classList.push('editing');
     }
-    if (deleted) {
+    if (this.deleted) {
       classList.push('deleted');
     }
     return classList.join(' ');
   }
 
-  _computeForceOptions(permission: string, action: string) {
-    if (permission === AccessPermissionId.PUSH) {
+  // private but used in test
+  computeForceOptions(action?: string) {
+    if (this.permission === AccessPermissionId.PUSH) {
       if (action === Action.ALLOW) {
         return ForcePushOptions.ALLOW;
       } else if (action === Action.BLOCK) {
@@ -222,82 +386,164 @@
       } else {
         return [];
       }
-    } else if (permission === AccessPermissionId.EDIT_TOPIC_NAME) {
+    } else if (this.permission === AccessPermissionId.EDIT_TOPIC_NAME) {
       return FORCE_EDIT_OPTIONS;
     }
     return [];
   }
 
-  _getDefaultRuleValues(permission: AccessPermissionId, label?: RuleLabel) {
-    const ruleAction = Action.ALLOW;
-    const value: RuleValue = {};
-    if (permission === AccessPermissionId.PRIORITY) {
-      value.action = PRIORITY_OPTIONS[0];
-      return value;
-    } else if (label) {
-      value.min = label.values[0].value;
-      value.max = label.values[label.values.length - 1].value;
-    } else if (this._computeForce(permission, ruleAction)) {
-      value.force = this._computeForceOptions(permission, ruleAction)[0].value;
+  // private but used in test
+  getDefaultRuleValues(): EditablePermissionRuleInfo {
+    if (this.permission === AccessPermissionId.PRIORITY) {
+      return {action: PRIORITY_OPTIONS[0]};
     }
-    value.action = DROPDOWN_OPTIONS[0];
-    return value;
+    if (this.label) {
+      return {
+        action: DROPDOWN_OPTIONS[0],
+        min: this.label.values[0].value,
+        max: this.label.values[this.label.values.length - 1].value,
+      };
+    }
+    if (this.computeForce(Action.ALLOW)) {
+      return {
+        action: DROPDOWN_OPTIONS[0],
+        force: this.computeForceOptions(Action.ALLOW)[0].value,
+      };
+    }
+    return {action: DROPDOWN_OPTIONS[0]};
   }
 
-  _setDefaultRuleValues() {
-    this.set(
-      'rule.value',
-      this._getDefaultRuleValues(this.permission, this.label)
-    );
+  // private but used in test
+  setDefaultRuleValues() {
+    this.rule!.value = this.getDefaultRuleValues();
+
+    this.handleRuleChange();
   }
 
-  _computeOptions(permission: string) {
-    if (permission === 'priority') {
+  // private but used in test
+  computeOptions() {
+    if (this.permission === 'priority') {
       return PRIORITY_OPTIONS;
     }
     return DROPDOWN_OPTIONS;
   }
 
-  _handleRemoveRule() {
-    if (!this.rule) return;
+  private handleRemoveRule() {
+    if (!this.rule?.value) return;
     if (this.rule.value.added) {
       fireEvent(this, 'added-rule-removed');
     }
-    this._deleted = true;
+    this.deleted = true;
     this.rule.value.deleted = true;
+
+    this.handleRuleChange();
+
     fireEvent(this, 'access-modified');
   }
 
-  _handleUndoRemove() {
-    if (!this.rule) return;
-    this._deleted = false;
+  private handleUndoRemove() {
+    if (!this.rule?.value) return;
+    this.deleted = false;
     delete this.rule.value.deleted;
+
+    this.handleRuleChange();
   }
 
-  _handleUndoChange() {
-    if (!this.rule) return;
+  private handleUndoChange() {
+    if (!this.originalRuleValues || !this.rule?.value) {
+      return;
+    }
     // gr-permission will take care of removing rules that were added but
     // unsaved. We need to keep the added bit for the filter.
     if (this.rule.value.added) {
       return;
     }
-    this.set('rule.value', {...this._originalRuleValues});
-    this._deleted = false;
+    this.rule.value = {...this.originalRuleValues};
+    this.deleted = false;
     delete this.rule.value.deleted;
     delete this.rule.value.modified;
+
+    this.handleRuleChange();
   }
 
-  @observe('rule.value.*')
-  _handleValueChange() {
-    if (!this._originalRuleValues || !this.rule) {
+  // private but used in test
+  handleValueChange() {
+    if (!this.originalRuleValues || !this.rule?.value) {
       return;
     }
     this.rule.value.modified = true;
+
+    this.handleRuleChange();
+
     // Allows overall access page to know a change has been made.
     fireEvent(this, 'access-modified');
   }
 
-  _setOriginalRuleValues(value: RuleValue) {
-    this._originalRuleValues = {...value};
+  // private but used in test
+  setOriginalRuleValues() {
+    if (!this.rule?.value) return;
+    this.originalRuleValues = {...this.rule.value};
+  }
+
+  private handleActionBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.action === String(e.detail.value)
+    )
+      return;
+
+    this.rule.value.action = String(e.detail.value) as PermissionAction;
+
+    this.handleValueChange();
+  }
+
+  private handleMinBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.min === Number(e.detail.value)
+    )
+      return;
+    this.rule.value.min = Number(e.detail.value);
+
+    this.handleValueChange();
+  }
+
+  private handleMaxBindValueChanged(e: BindValueChangeEvent) {
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.max === Number(e.detail.value)
+    )
+      return;
+    this.rule.value.max = Number(e.detail.value);
+
+    this.handleValueChange();
+  }
+
+  private handleForceBindValueChanged(e: BindValueChangeEvent) {
+    const forceValue = String(e.detail.value) === 'true' ? true : false;
+    if (
+      !this.rule?.value ||
+      e.detail.value === undefined ||
+      this.rule.value.force === forceValue
+    )
+      return;
+    this.rule.value.force = forceValue;
+
+    this.handleValueChange();
+  }
+
+  private handleRuleChange() {
+    this.requestUpdate('rule');
+
+    this.dispatchEvent(
+      new CustomEvent('rule-changed', {
+        detail: {value: this.rule},
+        composed: true,
+        bubbles: true,
+      })
+    );
   }
 }
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
deleted file mode 100644
index c4d7688..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_html.ts
+++ /dev/null
@@ -1,159 +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 {
-      border-bottom: 1px solid var(--border-color);
-      padding: var(--spacing-m);
-      display: block;
-    }
-    #removeBtn {
-      display: none;
-    }
-    .editing #removeBtn {
-      display: flex;
-    }
-    #options {
-      align-items: baseline;
-      display: flex;
-    }
-    #options > * {
-      margin-right: var(--spacing-m);
-    }
-    #mainContainer {
-      align-items: baseline;
-      display: flex;
-      flex-wrap: nowrap;
-      justify-content: space-between;
-    }
-    #deletedContainer.deleted {
-      align-items: baseline;
-      display: flex;
-      justify-content: space-between;
-    }
-    #undoBtn,
-    #force,
-    #deletedContainer,
-    #mainContainer.deleted {
-      display: none;
-    }
-    #undoBtn.modified,
-    #force.force {
-      display: block;
-    }
-    .groupPath {
-      color: var(--deemphasized-text-color);
-    }
-  </style>
-  <style include="gr-form-styles">
-    iron-autogrow-textarea {
-      width: 14em;
-    }
-  </style>
-  <div
-    id="mainContainer"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    <div id="options">
-      <gr-select
-        id="action"
-        bind-value="{{rule.value.action}}"
-        on-change="_handleValueChange"
-      >
-        <select disabled$="[[!editing]]">
-          <template is="dom-repeat" items="[[_computeOptions(permission)]]">
-            <option value="[[item]]">[[item]]</option>
-          </template>
-        </select>
-      </gr-select>
-      <template is="dom-if" if="[[label]]">
-        <gr-select
-          id="labelMin"
-          bind-value="{{rule.value.min}}"
-          on-change="_handleValueChange"
-        >
-          <select disabled$="[[!editing]]">
-            <template is="dom-repeat" items="[[label.values]]">
-              <option value="[[item.value]]">[[item.value]]</option>
-            </template>
-          </select>
-        </gr-select>
-        <gr-select
-          id="labelMax"
-          bind-value="{{rule.value.max}}"
-          on-change="_handleValueChange"
-        >
-          <select disabled$="[[!editing]]">
-            <template is="dom-repeat" items="[[label.values]]">
-              <option value="[[item.value]]">[[item.value]]</option>
-            </template>
-          </select>
-        </gr-select>
-      </template>
-      <template is="dom-if" if="[[hasRange]]">
-        <iron-autogrow-textarea
-          id="minInput"
-          class="min"
-          autocomplete="on"
-          placeholder="Min value"
-          bind-value="{{rule.value.min}}"
-          disabled$="[[!editing]]"
-        ></iron-autogrow-textarea>
-        <iron-autogrow-textarea
-          id="maxInput"
-          class="max"
-          autocomplete="on"
-          placeholder="Max value"
-          bind-value="{{rule.value.max}}"
-          disabled$="[[!editing]]"
-        ></iron-autogrow-textarea>
-      </template>
-      <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
-        [[groupName]]
-      </a>
-      <gr-select
-        id="force"
-        class$="[[_computeForceClass(permission, rule.value.action)]]"
-        bind-value="{{rule.value.force}}"
-        on-change="_handleValueChange"
-      >
-        <select disabled$="[[!editing]]">
-          <template
-            is="dom-repeat"
-            items="[[_computeForceOptions(permission, rule.value.action)]]"
-          >
-            <option value="[[item.value]]">[[item.name]]</option>
-          </template>
-        </select>
-      </gr-select>
-    </div>
-    <gr-button link="" id="removeBtn" on-click="_handleRemoveRule"
-      >Remove</gr-button
-    >
-  </div>
-  <div
-    id="deletedContainer"
-    class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]"
-  >
-    [[groupName]] was deleted
-    <gr-button link="" id="undoRemoveBtn" on-click="_handleUndoRemove"
-      >Undo</gr-button
-    >
-  </div>
-`;
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
deleted file mode 100644
index f3df132..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ /dev/null
@@ -1,586 +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-rule-editor.js';
-
-const basicFixture = fixtureFromElement('gr-rule-editor');
-
-suite('gr-rule-editor tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('unit tests', () => {
-    test('_computeForce, _computeForceClass, and _computeForceOptions',
-        () => {
-          const ForcePushOptions = {
-            ALLOW: [
-              {name: 'Allow pushing (but not force pushing)', value: false},
-              {name: 'Allow pushing with or without force', value: true},
-            ],
-            BLOCK: [
-              {name: 'Block pushing with or without force', value: false},
-              {name: 'Block force pushing', value: true},
-            ],
-          };
-
-          const FORCE_EDIT_OPTIONS = [
-            {
-              name: 'No Force Edit',
-              value: false,
-            },
-            {
-              name: 'Force Edit',
-              value: true,
-            },
-          ];
-          let permission = 'push';
-          let action = 'ALLOW';
-          assert.isTrue(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action),
-              'force');
-          assert.deepEqual(element._computeForceOptions(permission, action),
-              ForcePushOptions.ALLOW);
-
-          action = 'BLOCK';
-          assert.isTrue(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action),
-              'force');
-          assert.deepEqual(element._computeForceOptions(permission, action),
-              ForcePushOptions.BLOCK);
-
-          action = 'DENY';
-          assert.isFalse(element._computeForce(permission, action));
-          assert.equal(element._computeForceClass(permission, action), '');
-          assert.equal(
-              element._computeForceOptions(permission, action).length, 0);
-
-          permission = 'editTopicName';
-          assert.isTrue(element._computeForce(permission));
-          assert.equal(element._computeForceClass(permission), 'force');
-          assert.deepEqual(element._computeForceOptions(permission),
-              FORCE_EDIT_OPTIONS);
-          permission = 'submit';
-          assert.isFalse(element._computeForce(permission));
-          assert.equal(element._computeForceClass(permission), '');
-          assert.deepEqual(element._computeForceOptions(permission), []);
-        });
-
-    test('_computeSectionClass', () => {
-      let deleted = true;
-      let editing = false;
-      assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
-
-      deleted = false;
-      assert.equal(element._computeSectionClass(editing, deleted), '');
-
-      editing = true;
-      assert.equal(element._computeSectionClass(editing, deleted), 'editing');
-
-      deleted = true;
-      assert.equal(element._computeSectionClass(editing, deleted),
-          'editing deleted');
-    });
-
-    test('_getDefaultRuleValues', () => {
-      let permission = 'priority';
-      let label;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'BATCH'});
-      permission = 'label-Code-Review';
-      label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', max: 2, min: -2});
-      permission = 'push';
-      label = undefined;
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW', force: false});
-      permission = 'submit';
-      assert.deepEqual(element._getDefaultRuleValues(permission, label),
-          {action: 'ALLOW'});
-    });
-
-    test('_setDefaultRuleValues', () => {
-      element.rule = {id: 123};
-      const defaultValue = {action: 'ALLOW'};
-      sinon.stub(element, '_getDefaultRuleValues').returns(defaultValue);
-      element._setDefaultRuleValues();
-      assert.isTrue(element._getDefaultRuleValues.called);
-      assert.equal(element.rule.value, defaultValue);
-    });
-
-    test('_computeOptions', () => {
-      const PRIORITY_OPTIONS = [
-        'BATCH',
-        'INTERACTIVE',
-      ];
-      const DROPDOWN_OPTIONS = [
-        'ALLOW',
-        'DENY',
-        'BLOCK',
-      ];
-      let permission = 'priority';
-      assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
-      permission = 'submit';
-      assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
-    });
-
-    test('_handleValueChange', () => {
-      const modifiedHandler = sinon.stub();
-      element.rule = {value: {}};
-      element.addEventListener('access-modified', modifiedHandler);
-      element._handleValueChange();
-      assert.isNotOk(element.rule.value.modified);
-      element._originalRuleValues = {};
-      element._handleValueChange();
-      assert.isTrue(element.rule.value.modified);
-      assert.isTrue(modifiedHandler.called);
-    });
-
-    test('_handleAccessSaved', () => {
-      const originalValue = {action: 'DENY'};
-      const newValue = {action: 'ALLOW'};
-      element._originalRuleValues = originalValue;
-      element.rule = {value: newValue};
-      element._handleAccessSaved();
-      assert.deepEqual(element._originalRuleValues, newValue);
-    });
-
-    test('_setOriginalRuleValues', () => {
-      const value = {
-        action: 'ALLOW',
-        force: false,
-      };
-      element._setOriginalRuleValues(value);
-      assert.deepEqual(element._originalRuleValues, value);
-    });
-  });
-
-  suite('already existing generic rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'submit';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: false,
-        },
-      };
-      element.section = 'refs/*';
-
-      // Typically called on ready since elements will have properties defined
-      // by the parent element.
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-      assert.isFalse(element.$.force.classList.contains('force'));
-    });
-
-    test('modify and cancel restores original values', () => {
-      element.editing = true;
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = 'DENY';
-      assert.isTrue(element.rule.value.modified);
-      element.editing = false;
-      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-      assert.equal(element.$.action.bindValue, 'ALLOW');
-      assert.isNotOk(element.rule.value.modified);
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = 'DENY';
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('all selects are disabled when not in edit mode', () => {
-      const selects = element.root.querySelectorAll('select');
-      for (const select of selects) {
-        assert.isTrue(select.disabled);
-      }
-      element.editing = true;
-      for (const select of selects) {
-        assert.isFalse(select.disabled);
-      }
-    });
-
-    test('remove rule and undo remove', () => {
-      element.editing = true;
-      element.rule = {id: 123, value: {action: 'ALLOW'}};
-      assert.isFalse(
-          element.$.deletedContainer.classList.contains('deleted'));
-      MockInteractions.tap(element.$.removeBtn);
-      assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.rule.value.deleted);
-
-      MockInteractions.tap(element.$.undoRemoveBtn);
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.rule.value.deleted);
-    });
-
-    test('remove rule and cancel', () => {
-      element.editing = true;
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-
-      element.rule = {id: 123, value: {action: 'ALLOW'}};
-      MockInteractions.tap(element.$.removeBtn);
-      assert.notEqual(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.notEqual(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-      assert.isTrue(element._deleted);
-      assert.isTrue(element.rule.value.deleted);
-
-      element.editing = false;
-      assert.isFalse(element._deleted);
-      assert.isNotOk(element.rule.value.deleted);
-      assert.isNotOk(element.rule.value.modified);
-
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-      assert.equal(getComputedStyle(element.$.removeBtn).display, 'none');
-      assert.equal(getComputedStyle(element.$.deletedContainer).display,
-          'none');
-    });
-
-    test('_computeGroupPath', () => {
-      const group = '123';
-      assert.equal(element._computeGroupPath(group),
-          `/admin/groups/123`);
-    });
-  });
-
-  suite('new edit rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'editTopicName';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.force.bindValue = true;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('remove value', () => {
-      element.editing = true;
-      const removeStub = sinon.stub();
-      element.addEventListener('added-rule-removed', removeStub);
-      MockInteractions.tap(element.$.removeBtn);
-      flush();
-      assert.isTrue(removeStub.called);
-    });
-  });
-
-  suite('already existing rule with labels', () => {
-    setup(async () => {
-      element.label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      element.group = 'Group Name';
-      element.permission = 'label-Code-Review';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: false,
-          max: 2,
-          min: -2,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#labelMin').bindValue,
-          element.rule.value.min);
-      assert.equal(
-          element.root.querySelector('#labelMax').bindValue,
-          element.rule.value.max);
-      assert.isFalse(element.$.force.classList.contains('force'));
-    });
-
-    test('modify value', () => {
-      const removeStub = sinon.stub();
-      element.addEventListener('added-rule-removed', removeStub);
-      assert.isNotOk(element.rule.value.modified);
-      element.root.querySelector('#labelMin').bindValue = 1;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-      assert.isFalse(removeStub.called);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('new rule with labels', () => {
-    setup(async () => {
-      sinon.spy(element, '_setDefaultRuleValues');
-      element.label = {values: [
-        {value: -2, text: 'This shall not be merged'},
-        {value: -1, text: 'I would prefer this is not merged as is'},
-        {value: -0, text: 'No score'},
-        {value: 1, text: 'Looks good to me, but someone else must approve'},
-        {value: 2, text: 'Looks good to me, approved'},
-      ]};
-      element.group = 'Group Name';
-      element.permission = 'label-Code-Review';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      assert.isTrue(element._setDefaultRuleValues.called);
-
-      const expectedRuleValue = {
-        max: element.label.values[element.label.values.length - 1].value,
-        min: element.label.values[0].value,
-        action: 'ALLOW',
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(
-            element.$.action.bindValue,
-            expectedRuleValue.action);
-        assert.equal(
-            element.root.querySelector('#labelMin').bindValue,
-            expectedRuleValue.min);
-        assert.equal(
-            element.root.querySelector('#labelMax').bindValue,
-            expectedRuleValue.max);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.root.querySelector('#labelMin').bindValue = 1;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('already existing push rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'push';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(element.$.force.classList.contains('force'));
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#force').bindValue,
-          element.rule.value.force);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = false;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('new push rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'push';
-      element.rule = {
-        id: '123',
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.rule.value.added = true;
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      // Since the element does not already have default values, they should
-      // be set. The original values should be set to those too.
-      assert.isNotOk(element.rule.value.modified);
-      const expectedRuleValue = {
-        action: 'ALLOW',
-        force: false,
-        added: true,
-      };
-      assert.deepEqual(element.rule.value, expectedRuleValue);
-      test('values are set correctly', () => {
-        assert.equal(element.$.action.bindValue, expectedRuleValue.action);
-        assert.equal(element.$.force.bindValue, expectedRuleValue.action);
-      });
-    });
-
-    test('modify value', () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.force.bindValue = true;
-      flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-
-  suite('already existing edit rule', () => {
-    setup(async () => {
-      element.group = 'Group Name';
-      element.permission = 'editTopicName';
-      element.rule = {
-        id: '123',
-        value: {
-          action: 'ALLOW',
-          force: true,
-        },
-      };
-      element.section = 'refs/*';
-      element._setupValues(element.rule);
-      await flush();
-      element.connectedCallback();
-    });
-
-    test('_ruleValues and _originalRuleValues are set correctly', () => {
-      assert.deepEqual(element._originalRuleValues, element.rule.value);
-    });
-
-    test('values are set correctly', () => {
-      assert.isTrue(element.$.force.classList.contains('force'));
-      assert.equal(element.$.action.bindValue, element.rule.value.action);
-      assert.equal(
-          element.root.querySelector('#force').bindValue,
-          element.rule.value.force);
-      assert.isNotOk(element.root.querySelector('#labelMin'));
-      assert.isNotOk(element.root.querySelector('#labelMax'));
-    });
-
-    test('modify value', async () => {
-      assert.isNotOk(element.rule.value.modified);
-      element.$.action.bindValue = false;
-      await flush();
-      assert.isTrue(element.rule.value.modified);
-
-      // The original value should now differ from the rule values.
-      assert.notDeepEqual(element._originalRuleValues, element.rule.value);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
new file mode 100644
index 0000000..8066289
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.ts
@@ -0,0 +1,768 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-rule-editor';
+import {GrRuleEditor} from './gr-rule-editor';
+import {AccessPermissionId} from '../../../utils/access-util';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
+import {PermissionAction} from '../../../constants/constants';
+
+suite('gr-rule-editor tests', () => {
+  let element: GrRuleEditor;
+
+  setup(async () => {
+    element = await fixture<GrRuleEditor>(html`
+      <gr-rule-editor></gr-rule-editor>
+    `);
+    await element.updateComplete;
+  });
+
+  suite('dom tests', () => {
+    test('default', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="gr-form-styles" id="mainContainer">
+            <div id="options">
+              <gr-select id="action">
+                <select disabled="">
+                  <option value="ALLOW">ALLOW</option>
+                  <option value="DENY">DENY</option>
+                  <option value="BLOCK">BLOCK</option>
+                </select>
+              </gr-select>
+              <a class="groupPath"> </a>
+              <gr-select id="force">
+                <select disabled=""></select>
+              </gr-select>
+            </div>
+            <gr-button
+              aria-disabled="false"
+              id="removeBtn"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Remove
+            </gr-button>
+          </div>
+          <div class="gr-form-styles" id="deletedContainer">
+            was deleted
+            <gr-button
+              aria-disabled="false"
+              id="undoRemoveBtn"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Undo
+            </gr-button>
+          </div>
+        `
+      );
+    });
+
+    test('push options', async () => {
+      const rule: {value: EditablePermissionRuleInfo} = {
+        value: {
+          action: PermissionAction.ALLOW,
+        },
+      };
+      element = await fixture<GrRuleEditor>(html`
+        <gr-rule-editor
+          .editing=${true}
+          .rule=${rule}
+          .permission=${AccessPermissionId.PUSH}
+        ></gr-rule-editor>
+      `);
+      assert.dom.equal(
+        queryAndAssert(element, '#options'),
+        /* HTML */ `
+          <div id="options">
+            <gr-select id="action">
+              <select>
+                <option value="ALLOW">ALLOW</option>
+                <option value="DENY">DENY</option>
+                <option value="BLOCK">BLOCK</option>
+              </select>
+            </gr-select>
+            <a class="groupPath"> </a>
+            <gr-select class="force" id="force">
+              <select>
+                <option value="false">
+                  Allow pushing (but not force pushing)
+                </option>
+                <option value="true">
+                  Allow pushing with or without force
+                </option>
+              </select>
+            </gr-select>
+          </div>
+        `
+      );
+    });
+  });
+
+  suite('unit tests', () => {
+    test('computeForce and computeForceOptions', () => {
+      const ForcePushOptions = {
+        ALLOW: [
+          {name: 'Allow pushing (but not force pushing)', value: false},
+          {name: 'Allow pushing with or without force', value: true},
+        ],
+        BLOCK: [
+          {name: 'Block pushing with or without force', value: false},
+          {name: 'Block force pushing', value: true},
+        ],
+      };
+
+      const FORCE_EDIT_OPTIONS = [
+        {
+          name: 'No Force Edit',
+          value: false,
+        },
+        {
+          name: 'Force Edit',
+          value: true,
+        },
+      ];
+      element.permission = 'push' as AccessPermissionId;
+      let action = PermissionAction.ALLOW;
+      assert.isTrue(element.computeForce(action));
+      assert.deepEqual(
+        element.computeForceOptions(action),
+        ForcePushOptions.ALLOW
+      );
+
+      action = PermissionAction.BLOCK;
+      assert.isTrue(element.computeForce(action));
+      assert.deepEqual(
+        element.computeForceOptions(action),
+        ForcePushOptions.BLOCK
+      );
+
+      action = PermissionAction.DENY;
+      assert.isFalse(element.computeForce(action));
+      assert.equal(element.computeForceOptions(action).length, 0);
+
+      element.permission = 'editTopicName' as AccessPermissionId;
+      assert.isTrue(element.computeForce());
+      assert.deepEqual(element.computeForceOptions(), FORCE_EDIT_OPTIONS);
+      element.permission = 'submit' as AccessPermissionId;
+      assert.isFalse(element.computeForce());
+      assert.deepEqual(element.computeForceOptions(), []);
+    });
+
+    test('computeSectionClass', () => {
+      element.deleted = true;
+      element.editing = false;
+      assert.equal(element.computeSectionClass(), 'deleted');
+
+      element.deleted = false;
+      assert.equal(element.computeSectionClass(), '');
+
+      element.editing = true;
+      assert.equal(element.computeSectionClass(), 'editing');
+
+      element.deleted = true;
+      assert.equal(element.computeSectionClass(), 'editing deleted');
+    });
+
+    test('getDefaultRuleValues', () => {
+      element.permission = 'priority' as AccessPermissionId;
+      assert.deepEqual(element.getDefaultRuleValues(), {
+        action: PermissionAction.BATCH,
+      });
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.label = {
+        values: [
+          {value: -2, text: 'This shall not be submitted'},
+          {value: -1, text: 'I would prefer this is not submitted as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ],
+      };
+      assert.deepEqual(element.getDefaultRuleValues(), {
+        action: PermissionAction.ALLOW,
+        max: 2,
+        min: -2,
+      });
+      element.permission = 'push' as AccessPermissionId;
+      element.label = undefined;
+      assert.deepEqual(element.getDefaultRuleValues(), {
+        action: PermissionAction.ALLOW,
+        force: false,
+      });
+      element.permission = 'submit' as AccessPermissionId;
+      assert.deepEqual(element.getDefaultRuleValues(), {
+        action: PermissionAction.ALLOW,
+      });
+    });
+
+    test('setDefaultRuleValues', async () => {
+      element.rule = {};
+      const defaultValue = {action: PermissionAction.ALLOW};
+      const getDefaultRuleValuesStub = sinon
+        .stub(element, 'getDefaultRuleValues')
+        .returns(defaultValue);
+      element.setDefaultRuleValues();
+      assert.isTrue(getDefaultRuleValuesStub.called);
+      assert.equal(element.rule.value, defaultValue);
+    });
+
+    test('computeOptions', () => {
+      const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
+      const DROPDOWN_OPTIONS = [
+        PermissionAction.ALLOW,
+        PermissionAction.DENY,
+        PermissionAction.BLOCK,
+      ];
+      element.permission = 'priority' as AccessPermissionId;
+      assert.deepEqual(element.computeOptions(), PRIORITY_OPTIONS);
+      element.permission = 'submit' as AccessPermissionId;
+      assert.deepEqual(element.computeOptions(), DROPDOWN_OPTIONS);
+    });
+
+    test('handleValueChange', () => {
+      const modifiedHandler = sinon.stub();
+      element.rule = {
+        value: {action: PermissionAction.ALLOW},
+      };
+      element.addEventListener('access-modified', modifiedHandler);
+      element.handleValueChange();
+      assert.isNotOk(element.rule.value!.modified);
+      element.originalRuleValues = {action: PermissionAction.ALLOW};
+      element.handleValueChange();
+      assert.isTrue(element.rule.value!.modified);
+      assert.isTrue(modifiedHandler.called);
+    });
+
+    test('handleAccessSaved', () => {
+      const originalValue = {action: PermissionAction.DENY};
+      const newValue = {action: PermissionAction.ALLOW};
+      element.originalRuleValues = originalValue;
+      element.rule = {value: newValue};
+      element.handleAccessSaved();
+      assert.deepEqual(element.originalRuleValues, newValue);
+    });
+
+    test('setOriginalRuleValues', () => {
+      element.rule = {
+        value: {
+          action: PermissionAction.ALLOW,
+          force: false,
+        },
+      };
+      element.setOriginalRuleValues();
+      assert.deepEqual(element.originalRuleValues, element.rule.value);
+    });
+  });
+
+  suite('already existing generic rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'submit' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: PermissionAction.ALLOW,
+          force: false,
+        },
+      };
+      element.section = 'refs/*';
+      element.setupValues();
+      element.setOriginalRuleValues();
+      await element.updateComplete;
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+      assert.isFalse(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+    });
+
+    test('modify and cancel restores original values', async () => {
+      element.rule = {value: {action: PermissionAction.ALLOW}};
+      element.setOriginalRuleValues();
+      element.editing = true;
+      await element.updateComplete;
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.isNotOk(element.rule.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = PermissionAction.DENY;
+      await element.updateComplete;
+      assert.isTrue(element.rule.value!.modified);
+      element.editing = false;
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.deepEqual(element.originalRuleValues, element.rule.value);
+      assert.isNotOk(element.rule.value!.modified);
+      assert.equal(element.rule?.value?.action, PermissionAction.ALLOW);
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        PermissionAction.ALLOW
+      );
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = PermissionAction.DENY;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('all selects are disabled when not in edit mode', async () => {
+      const selects = queryAll<HTMLSelectElement>(element, 'select');
+      for (const select of selects) {
+        assert.isTrue(select.disabled);
+      }
+      element.editing = true;
+      await element.updateComplete;
+      for (const select of selects) {
+        assert.isFalse(select.disabled);
+      }
+    });
+
+    test('remove rule and undo remove', async () => {
+      element.editing = true;
+      element.rule = {value: {action: PermissionAction.ALLOW}};
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert<HTMLDivElement>(
+          element,
+          '#deletedContainer'
+        ).classList.contains('deleted')
+      );
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
+      await element.updateComplete;
+      assert.isTrue(
+        queryAndAssert<HTMLDivElement>(
+          element,
+          '#deletedContainer'
+        ).classList.contains('deleted')
+      );
+      assert.isTrue(element.deleted);
+      assert.isTrue(element.rule.value!.deleted);
+
+      queryAndAssert<GrButton>(element, '#undoRemoveBtn').click();
+      await element.updateComplete;
+      assert.isFalse(element.deleted);
+      assert.isNotOk(element.rule.value!.deleted);
+    });
+
+    test('remove rule and cancel', async () => {
+      element.editing = true;
+      await element.updateComplete;
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+
+      element.rule = {value: {action: PermissionAction.ALLOW}};
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
+      await element.updateComplete;
+      assert.notEqual(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.notEqual(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+      assert.isTrue(element.deleted);
+      assert.isTrue(element.rule.value!.deleted);
+
+      element.editing = false;
+      await element.updateComplete;
+      assert.isFalse(element.deleted);
+      assert.isNotOk(element.rule.value!.deleted);
+      assert.isNotOk(element.rule.value!.modified);
+
+      assert.deepEqual(element.originalRuleValues, element.rule.value);
+      assert.equal(
+        getComputedStyle(queryAndAssert<GrButton>(element, '#removeBtn'))
+          .display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(
+          queryAndAssert<HTMLDivElement>(element, '#deletedContainer')
+        ).display,
+        'none'
+      );
+    });
+
+    test('computeGroupPath', () => {
+      const group = '123';
+      assert.equal(element.computeGroupPath(group), '/admin/groups/123');
+    });
+  });
+
+  suite('new edit rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'editTopicName' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.rule.value!.added = true;
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      const expectedRuleValue = {
+        action: PermissionAction.ALLOW,
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+
+      // values are set correctly
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        expectedRuleValue.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        expectedRuleValue.force
+      );
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+      forceBindValue.bindValue = 'true';
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('remove value', async () => {
+      element.editing = true;
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      queryAndAssert<GrButton>(element, '#removeBtn').click();
+      await element.updateComplete;
+      assert.isTrue(removeStub.called);
+    });
+  });
+
+  suite('already existing rule with labels', () => {
+    setup(async () => {
+      element.label = {
+        values: [
+          {value: -2, text: 'This shall not be submitted'},
+          {value: -1, text: 'I would prefer this is not submitted as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ],
+      };
+      element.groupName = 'Group Name';
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: PermissionAction.ALLOW,
+          force: false,
+          max: 2,
+          min: -2,
+        },
+      };
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+        element.rule!.value!.min
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+        element.rule!.value!.max
+      );
+      assert.isFalse(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+    });
+
+    test('modify value', async () => {
+      const removeStub = sinon.stub();
+      element.addEventListener('added-rule-removed', removeStub);
+      assert.isNotOk(element.rule!.value!.modified);
+      const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+      labelMinBindValue.bindValue = 1;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+      assert.isFalse(removeStub.called);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('new rule with labels', () => {
+    let setDefaultRuleValuesSpy: sinon.SinonSpy;
+
+    setup(async () => {
+      setDefaultRuleValuesSpy = sinon.spy(element, 'setDefaultRuleValues');
+      element.label = {
+        values: [
+          {value: -2, text: 'This shall not be submitted'},
+          {value: -1, text: 'I would prefer this is not submitted as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ],
+      };
+      element.groupName = 'Group Name';
+      element.permission = 'label-Code-Review' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.rule.value!.added = true;
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      assert.isTrue(setDefaultRuleValuesSpy.called);
+
+      const expectedRuleValue = {
+        max: element.label!.values[element.label!.values.length - 1].value,
+        min: element.label!.values[0].value,
+        action: PermissionAction.ALLOW,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+
+      // values are set correctly
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        expectedRuleValue.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMin').bindValue,
+        expectedRuleValue.min
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#labelMax').bindValue,
+        expectedRuleValue.max
+      );
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const labelMinBindValue = queryAndAssert<GrSelect>(element, '#labelMin');
+      labelMinBindValue.bindValue = 1;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('already existing push rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'push' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: PermissionAction.ALLOW,
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        element.rule!.value!.force
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = false;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('new push rule', async () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'push' as AccessPermissionId;
+      element.rule = {};
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.rule.value!.added = true;
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      // Since the element does not already have default values, they should
+      // be set. The original values should be set to those too.
+      assert.isNotOk(element.rule!.value!.modified);
+      const expectedRuleValue = {
+        action: PermissionAction.ALLOW,
+        force: false,
+        added: true,
+      };
+      assert.deepEqual(element.rule!.value, expectedRuleValue);
+      // values are set correctly
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        expectedRuleValue.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        expectedRuleValue.force
+      );
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const forceBindValue = queryAndAssert<GrSelect>(element, '#force');
+      forceBindValue.bindValue = true;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+
+  suite('already existing edit rule', () => {
+    setup(async () => {
+      element.groupName = 'Group Name';
+      element.permission = 'editTopicName' as AccessPermissionId;
+      element.rule = {
+        value: {
+          action: PermissionAction.ALLOW,
+          force: true,
+        },
+      };
+      element.section = 'refs/*';
+      element.setupValues();
+      await element.updateComplete;
+      element.connectedCallback();
+    });
+
+    test('_ruleValues and originalRuleValues are set correctly', () => {
+      assert.deepEqual(element.originalRuleValues, element.rule!.value);
+    });
+
+    test('values are set correctly', () => {
+      assert.isTrue(
+        queryAndAssert<GrSelect>(element, '#force').classList.contains('force')
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#action').bindValue,
+        element.rule!.value!.action
+      );
+      assert.equal(
+        queryAndAssert<GrSelect>(element, '#force').bindValue,
+        element.rule!.value!.force
+      );
+      assert.isNotOk(query<GrSelect>(element, '#labelMin'));
+      assert.isNotOk(query<GrSelect>(element, '#labelMax'));
+    });
+
+    test('modify value', async () => {
+      assert.isNotOk(element.rule!.value!.modified);
+      const actionBindValue = queryAndAssert<GrSelect>(element, '#action');
+      actionBindValue.bindValue = false;
+      await element.updateComplete;
+      assert.isTrue(element.rule!.value!.modified);
+
+      // The original value should now differ from the rule values.
+      assert.notDeepEqual(element.originalRuleValues, element.rule!.value);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
new file mode 100644
index 0000000..a49a95e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {pluralize} from '../../../utils/string-util';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
+import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
+import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
+import '../gr-change-list-hashtag-flow/gr-change-list-hashtag-flow';
+import '../gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow';
+/**
+ * An action bar for the top of a <gr-change-list-section> element. Assumes it
+ * will be used inside a <tr> element.
+ */
+@customElement('gr-change-list-action-bar')
+export class GrChangeListActionBar extends LitElement {
+  static override get styles() {
+    return css`
+      :host {
+        display: contents;
+      }
+      td {
+        padding: 0;
+      }
+      .container {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+      }
+      .actionButtons {
+        margin-right: var(--spacing-l);
+      }
+    `;
+  }
+
+  @state()
+  private numSelected = 0;
+
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChangeNums$,
+      selectedChangeNums => (this.numSelected = selectedChangeNums.length)
+    );
+  }
+
+  override render() {
+    const numSelectedLabel = `${pluralize(
+      this.numSelected,
+      'change'
+    )} selected`;
+    return html`
+      <!--
+        500 chosen to be more than the actual number of columns but less than
+        1000 where the browser apparently decides it is an error and reverts
+        back to colspan="1"
+      -->
+      <td colspan="500">
+        <div class="container">
+          <div class="selectionInfo">
+            ${this.numSelected
+              ? html`<span>${numSelectedLabel}</span>`
+              : nothing}
+          </div>
+          <div class="actionButtons">
+            <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
+            <gr-change-list-topic-flow></gr-change-list-topic-flow>
+            <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
+            <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
+            <gr-change-list-bulk-abandon-flow></gr-change-list-bulk-abandon-flow>
+          </div>
+        </div>
+      </td>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-action-bar': GrChangeListActionBar;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
new file mode 100644
index 0000000..7a4f97c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html, assert} from '@open-wc/testing';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import '../../../test/common-test-setup';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  query,
+  queryAndAssert,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import './gr-change-list-action-bar';
+import type {GrChangeListActionBar} from './gr-change-list-action-bar';
+
+const change1 = {...createChange(), _number: 1 as NumericChangeId, actions: {}};
+const change2 = {...createChange(), _number: 2 as NumericChangeId, actions: {}};
+
+suite('gr-change-list-action-bar tests', () => {
+  let element: GrChangeListActionBar;
+  let model: BulkActionsModel;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChangeNums$, selectedChangeNums =>
+      selectedChangeNums.includes(change._number)
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    model = new BulkActionsModel(getAppContext().restApiService);
+    model.sync([change1, change2]);
+
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-change-list-action-bar></gr-change-list-action-bar>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-action-bar')!;
+    await element.updateComplete;
+  });
+
+  test('renders action bar', async () => {
+    await selectChange(change1);
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <td>
+          <div class="container">
+            <div class="selectionInfo">
+              <span>1 change selected</span>
+            </div>
+            <div class="actionButtons">
+              <gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
+              <gr-change-list-topic-flow></gr-change-list-topic-flow>
+              <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
+              <gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
+              <gr-change-list-bulk-abandon-flow></gr-change-list-bulk-abandon-flow>
+            </div>
+          </div>
+        </td>
+      `
+    );
+  });
+
+  test('label reflects number of selected changes', async () => {
+    // zero case
+    let numSelectedLabel = query<HTMLSpanElement>(
+      element,
+      '.selectionInfo span'
+    );
+    assert.isUndefined(numSelectedLabel);
+
+    // single case
+    await selectChange(change1);
+    numSelectedLabel = queryAndAssert<HTMLSpanElement>(
+      element,
+      '.selectionInfo span'
+    );
+    assert.equal(numSelectedLabel.innerText, '1 change selected');
+
+    // plural case
+    await selectChange(change2);
+
+    numSelectedLabel = queryAndAssert<HTMLSpanElement>(
+      element,
+      '.selectionInfo span'
+    );
+    assert.equal(numSelectedLabel.innerText, '2 changes selected');
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
new file mode 100644
index 0000000..11de37e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {customElement, state, query} from 'lit/decorators.js';
+import {LitElement, html, css} from 'lit';
+import {resolve} from '../../../models/dependency';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {NumericChangeId, ChangeInfo, ChangeStatus} from '../../../api/rest-api';
+import {subscribe} from '../../lit/subscription-controller';
+import {ProgressStatus} from '../../../constants/constants';
+import '../../shared/gr-dialog/gr-dialog';
+import {fireAlert, fireReload} from '../../../utils/event-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+
+@customElement('gr-change-list-bulk-abandon-flow')
+export class GrChangeListBulkAbandonFlow extends LitElement {
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  @state() selectedChanges: ChangeInfo[] = [];
+
+  @state() progress: Map<NumericChangeId, ProgressStatus> = new Map();
+
+  @query('#actionModal') actionModal!: HTMLDialogElement;
+
+  static override get styles() {
+    return [
+      modalStyles,
+      css`
+        section {
+          padding: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => (this.selectedChanges = selectedChanges)
+    );
+  }
+
+  override render() {
+    return html`
+      <gr-button
+        id="abandon"
+        flatten
+        .disabled=${!this.isEnabled()}
+        @click=${() => this.actionModal.showModal()}
+        >Abandon</gr-button
+      >
+      <dialog id="actionModal" tabindex="-1">
+        <gr-dialog
+          .disableCancel=${!this.isCancelEnabled()}
+          .disabled=${!this.isConfirmEnabled()}
+          @confirm=${() => this.handleConfirm()}
+          @cancel=${() => this.handleClose()}
+          .cancelLabel=${'Close'}
+        >
+          <div slot="header">
+            ${this.selectedChanges.length} changes to abandon
+          </div>
+          <div slot="main">
+            <table>
+              <thead>
+                <tr>
+                  <th>Subject</th>
+                  <th>Status</th>
+                </tr>
+              </thead>
+              <tbody>
+                ${this.selectedChanges.map(
+                  change => html`
+                    <tr>
+                      <td>Change: ${change.subject}</td>
+                      <td id="status">
+                        Status: ${this.getStatus(change._number)}
+                      </td>
+                    </tr>
+                  `
+                )}
+              </tbody>
+            </table>
+          </div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
+
+  private getStatus(changeNum: NumericChangeId) {
+    return this.progress.has(changeNum)
+      ? this.progress.get(changeNum)
+      : ProgressStatus.NOT_STARTED;
+  }
+
+  private isEnabled() {
+    return this.selectedChanges.every(
+      change =>
+        !!change.actions?.abandon || change.status === ChangeStatus.ABANDONED
+    );
+  }
+
+  private isConfirmEnabled() {
+    // Action is allowed if none of the changes have any bulk action performed
+    // on them. In case an error happens then we keep the button disabled.
+    for (const status of this.progress.values()) {
+      if (status !== ProgressStatus.NOT_STARTED) return false;
+    }
+    return true;
+  }
+
+  private isCancelEnabled() {
+    for (const status of this.progress.values()) {
+      if (status === ProgressStatus.RUNNING) return false;
+    }
+    return true;
+  }
+
+  private handleConfirm() {
+    this.progress.clear();
+    for (const change of this.selectedChanges) {
+      this.progress.set(change._number, ProgressStatus.RUNNING);
+    }
+    this.requestUpdate();
+    const errFn = (changeNum: NumericChangeId) => {
+      throw new Error(`request for ${changeNum} failed`);
+    };
+    const promises = this.getBulkActionsModel().abandonChanges('', errFn);
+    for (let index = 0; index < promises.length; index++) {
+      const changeNum = this.selectedChanges[index]._number;
+      promises[index]
+        .then(() => {
+          this.progress.set(changeNum, ProgressStatus.SUCCESSFUL);
+          this.requestUpdate();
+        })
+        .catch(() => {
+          this.progress.set(changeNum, ProgressStatus.FAILED);
+          this.requestUpdate();
+        });
+    }
+  }
+
+  private handleClose() {
+    this.actionModal.close();
+    fireAlert(this, 'Reloading page..');
+    fireReload(this, true);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-bulk-abandon-flow': GrChangeListBulkAbandonFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
new file mode 100644
index 0000000..df7a6ce
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
@@ -0,0 +1,344 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {createChange} from '../../../test/test-data-generators';
+import {
+  NumericChangeId,
+  ChangeInfo,
+  ChangeStatus,
+  HttpMethod,
+  PatchSetNum,
+} from '../../../api/rest-api';
+import {GrChangeListBulkAbandonFlow} from './gr-change-list-bulk-abandon-flow';
+import '../../../test/common-test-setup';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+  LoadingState,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import './gr-change-list-bulk-abandon-flow';
+import {fixture, waitUntil, assert} from '@open-wc/testing';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {html} from 'lit';
+import {getAppContext} from '../../../services/app-context';
+import {
+  waitUntilObserved,
+  stubRestApi,
+  queryAndAssert,
+  mockPromise,
+  query,
+} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {ProgressStatus} from '../../../constants/constants';
+import {RequestPayload} from '../../../types/common';
+import {ErrorCallback} from '../../../api/rest';
+
+const change1: ChangeInfo = {...createChange(), _number: 1 as NumericChangeId};
+const change2: ChangeInfo = {...createChange(), _number: 2 as NumericChangeId};
+
+suite('gr-change-list-bulk-abandon-flow tests', () => {
+  let element: GrChangeListBulkAbandonFlow;
+  let model: BulkActionsModel;
+  let getChangesStub: sinon.SinonStub;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChangeNums$, selectedChangeNums =>
+      selectedChangeNums.includes(change._number)
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    model = new BulkActionsModel(getAppContext().restApiService);
+    getChangesStub = stubRestApi('getDetailedChangesWithActions');
+
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-change-list-bulk-abandon-flow></gr-change-list-bulk-abandon-flow>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-bulk-abandon-flow')!;
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-button
+          aria-disabled="false"
+          flatten=""
+          id="abandon"
+          role="button"
+          tabindex="0"
+        >
+          Abandon
+        </gr-button>
+        <dialog id="actionModal" tabindex="-1">
+          <gr-dialog role="dialog">
+            <div slot="header">1 changes to abandon</div>
+            <div slot="main">
+              <table>
+                <thead>
+                  <tr>
+                    <th>Subject</th>
+                    <th>Status</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr>
+                    <td>Change: Test subject</td>
+                    <td id="status">Status: NOT STARTED</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `
+    );
+  });
+
+  test('button state updates as changes are updated', async () => {
+    const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.isFalse(queryAndAssert<GrButton>(element, '#abandon').disabled);
+
+    changes.push({...change2, actions: {}});
+    getChangesStub.restore();
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change2);
+    await element.updateComplete;
+
+    assert.isTrue(queryAndAssert<GrButton>(element, '#abandon').disabled);
+  });
+
+  test('abandon button is enabled if change is already abandoned', async () => {
+    const changes: ChangeInfo[] = [
+      {...change1, actions: {}, status: ChangeStatus.ABANDONED},
+    ];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.isFalse(queryAndAssert<GrButton>(element, '#abandon').disabled);
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+    await waitUntil(
+      () =>
+        queryAndAssert<HTMLTableDataCellElement>(
+          element,
+          '#status'
+        ).innerText.trim() === `Status: ${ProgressStatus.SUCCESSFUL}`
+    );
+  });
+
+  test('progress updates as request is resolved', async () => {
+    const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.NOT_STARTED}`
+    );
+
+    const executeChangeAction = mockPromise<Response>();
+    stubRestApi('executeChangeAction').returns(executeChangeAction);
+
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.RUNNING}`
+    );
+
+    executeChangeAction.resolve({...new Response(), status: 200});
+    await waitUntil(
+      () =>
+        element.progress.get(1 as NumericChangeId) === ProgressStatus.SUCCESSFUL
+    );
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.SUCCESSFUL}`
+    );
+  });
+
+  test('failures are reflected to the progress dialog', async () => {
+    const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.NOT_STARTED}`
+    );
+
+    stubRestApi('executeChangeAction').callsFake(
+      (
+        _changeNum: NumericChangeId,
+        _method: HttpMethod | undefined,
+        _endpoint: string,
+        _patchNum?: PatchSetNum,
+        _payload?: RequestPayload,
+        errFn?: ErrorCallback
+      ) =>
+        Promise.resolve(new Response()).then(res => {
+          errFn && errFn();
+          return res;
+        })
+    );
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.RUNNING}`
+    );
+
+    await waitUntil(
+      () => element.progress.get(1 as NumericChangeId) === ProgressStatus.FAILED
+    );
+
+    assert.equal(
+      queryAndAssert<HTMLTableDataCellElement>(
+        element,
+        '#status'
+      ).innerText.trim(),
+      `Status: ${ProgressStatus.FAILED}`
+    );
+  });
+
+  test('closing dialog triggers a reload', async () => {
+    const changes: ChangeInfo[] = [
+      {...change1, actions: {abandon: {}}},
+      {...change2, actions: {abandon: {}}},
+    ];
+    getChangesStub.returns(changes);
+
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+
+    stubRestApi('executeChangeAction').callsFake(
+      (
+        _changeNum: NumericChangeId,
+        _method: HttpMethod | undefined,
+        _endpoint: string,
+        _patchNum?: PatchSetNum,
+        _payload?: RequestPayload,
+        errFn?: ErrorCallback
+      ) =>
+        Promise.resolve(new Response()).then(res => {
+          errFn && errFn();
+          return res;
+        })
+    );
+
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await selectChange(change2);
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+    await waitUntil(
+      () => element.progress.get(2 as NumericChangeId) === ProgressStatus.FAILED
+    );
+
+    assert.isFalse(fireStub.called);
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
+
+    await waitUntil(() => fireStub.called);
+    assert.equal(fireStub.lastCall.args[0].type, 'reload');
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
new file mode 100644
index 0000000..a9b8028
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -0,0 +1,440 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {customElement, query, state} from 'lit/decorators.js';
+import {LitElement, html, css, nothing} from 'lit';
+import {resolve} from '../../../models/dependency';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {subscribe} from '../../lit/subscription-controller';
+import {ChangeInfo, AccountInfo, NumericChangeId} from '../../../api/rest-api';
+import {
+  getTriggerVotes,
+  computeLabels,
+  computeOrderedLabelValues,
+  mergeLabelInfoMaps,
+  mergeLabelMaps,
+  Label,
+  StandardLabels,
+} from '../../../utils/label-util';
+import {getAppContext} from '../../../services/app-context';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {queryAndAssert} from '../../../utils/common-util';
+import {
+  LabelNameToValuesMap,
+  ReviewInput,
+  LabelNameToValueMap,
+} from '../../../types/common';
+import {GrLabelScoreRow} from '../../change/gr-label-score-row/gr-label-score-row';
+import {ProgressStatus} from '../../../constants/constants';
+import {fireAlert, fireReload} from '../../../utils/event-util';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-icon/gr-icon';
+import '../../change/gr-label-score-row/gr-label-score-row';
+import {getOverallStatus} from '../../../utils/bulk-flow-util';
+import {allSettled} from '../../../utils/async-util';
+import {pluralize} from '../../../utils/string-util';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {Interaction} from '../../../constants/reporting';
+import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+
+@customElement('gr-change-list-bulk-vote-flow')
+export class GrChangeListBulkVoteFlow extends LitElement {
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  @state() selectedChanges: ChangeInfo[] = [];
+
+  @state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
+
+  @query('#actionModal') actionModal!: HTMLDialogElement;
+
+  @query('gr-dialog') dialog?: GrDialog;
+
+  @state() account?: AccountInfo;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      modalStyles,
+      css`
+        gr-dialog {
+          width: 840px;
+        }
+        .scoresTable {
+          display: table;
+          width: 100%;
+        }
+        .scoresTable.newSubmitRequirements {
+          table-layout: fixed;
+        }
+        gr-label-score-row:hover {
+          background-color: var(--hover-background-color);
+        }
+        gr-label-score-row {
+          display: table-row;
+        }
+        /* TODO(dhruvsri): Consider using flex column with gap */
+        .scoresTable:not(:first-of-type) {
+          margin-top: var(--spacing-m);
+        }
+        .vote-type {
+          margin-bottom: var(--spacing-s);
+          margin-top: 0;
+          display: table-caption;
+        }
+        .main-heading {
+          margin-bottom: var(--spacing-m);
+          font-weight: var(--font-weight-h2);
+        }
+        .error-container {
+          background-color: var(--error-background);
+          margin-top: var(--spacing-l);
+        }
+        .code-review-message-container gr-icon,
+        .error-container gr-icon {
+          padding: 10px var(--spacing-xl);
+        }
+        .error-container gr-icon {
+          color: var(--error-foreground);
+        }
+        .code-review-message-container gr-icon {
+          color: var(--selected-foreground);
+        }
+        .error-container .error-text,
+        .code-review-message-container .warning-text {
+          position: relative;
+          top: 10px;
+        }
+        .code-review-message-container {
+          display: table-caption;
+          background-color: var(--code-review-warning-background);
+          margin-bottom: var(--spacing-m);
+        }
+        .code-review-message-layout-container {
+          display: flex;
+        }
+        .code-review-message-container gr-button {
+          margin-top: 6px;
+          margin-right: var(--spacing-xl);
+        }
+        .flex-space {
+          flex-grow: 1;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+        this.resetFlow();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      account => (this.account = account)
+    );
+  }
+
+  override render() {
+    const permittedLabels = this.computePermittedLabels();
+    const triggerLabels = this.computeCommonTriggerLabels(permittedLabels);
+    const nonTriggerLabels = this.computeCommonPermittedLabels(
+      permittedLabels
+    ).filter(label => !triggerLabels.some(l => l.name === label.name));
+    return html`
+      <gr-button id="voteFlowButton" flatten @click=${this.openModal}
+        >Vote</gr-button
+      >
+      <dialog id="actionModal" tabindex="-1">
+        <gr-dialog
+          .disableCancel=${!this.isCancelEnabled()}
+          .disabled=${!this.isConfirmEnabled()}
+          ?loading=${this.isLoading()}
+          .loadingLabel=${'Voting in progress...'}
+          @confirm=${() => this.handleConfirm()}
+          @cancel=${() => this.handleClose()}
+          .confirmLabel=${'Vote'}
+          .cancelLabel=${'Cancel'}
+        >
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
+          <div slot="main">
+            ${this.renderLabels(
+              nonTriggerLabels,
+              'Submit requirements votes',
+              permittedLabels,
+              true
+            )}
+            ${this.renderLabels(
+              triggerLabels,
+              'Trigger Votes',
+              permittedLabels
+            )}
+            ${this.renderErrors()}
+          </div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
+
+  private renderCodeReviewMessage() {
+    return html`
+      <div class="code-review-message-container">
+        <div class="code-review-message-layout-container">
+          <div>
+            <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+            <span class="warning-text">
+              Code Review vote is only available on the individual change page
+            </span>
+          </div>
+          <div class="flex-space"></div>
+          <div>
+            <gr-button
+              aria-label=${`Open ${pluralize(
+                this.selectedChanges.length,
+                'change'
+              )} in different tabs`}
+              flatten
+              link
+              @click=${this.handleOpenChanges}
+              >Open ${pluralize(this.selectedChanges.length, 'change')}
+            </gr-button>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  private handleOpenChanges() {
+    for (const change of this.selectedChanges) {
+      window.open(createChangeUrl({change, usp: 'bulk-vote'}));
+    }
+  }
+
+  private openModal() {
+    this.actionModal.showModal();
+  }
+
+  private renderErrors() {
+    if (getOverallStatus(this.progressByChange) !== ProgressStatus.FAILED) {
+      return nothing;
+    }
+    return html`
+      <div class="error-container">
+        <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+        <span class="error-text">
+          <!-- prettier-ignore -->
+          Failed to vote on ${pluralize(
+            Array.from(this.progressByChange.values()).filter(
+              status => status === ProgressStatus.FAILED
+            ).length,
+            'change'
+          )}
+        </span>
+      </div>
+    `;
+  }
+
+  private renderLabels(
+    labels: Label[],
+    heading: string,
+    permittedLabels?: LabelNameToValuesMap,
+    showCodeReviewWarning?: boolean
+  ) {
+    return html` <div class="scoresTable newSubmitRequirements">
+      <h3 class="heading-4 vote-type">${labels.length ? heading : nothing}</h3>
+      ${showCodeReviewWarning ? this.renderCodeReviewMessage() : nothing}
+      ${labels
+        .filter(
+          label =>
+            permittedLabels?.[label.name] &&
+            permittedLabels?.[label.name].length > 0
+        )
+        .map(
+          label => html`<gr-label-score-row
+            .label=${label}
+            .name=${label.name}
+            .labels=${this.computeLabelNameToInfoMap()}
+            .permittedLabels=${permittedLabels}
+            .orderedLabelValues=${computeOrderedLabelValues(permittedLabels)}
+          ></gr-label-score-row>`
+        )}
+    </div>`;
+  }
+
+  private resetFlow() {
+    this.progressByChange = new Map(
+      this.selectedChanges.map(change => [
+        change._number,
+        ProgressStatus.NOT_STARTED,
+      ])
+    );
+  }
+
+  private isLoading() {
+    return getOverallStatus(this.progressByChange) === ProgressStatus.RUNNING;
+  }
+
+  private isConfirmEnabled() {
+    // Action is allowed if none of the changes have any bulk action performed
+    // on them. In case an error happens then we keep the button disabled.
+    return (
+      getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED
+    );
+  }
+
+  private isCancelEnabled() {
+    return getOverallStatus(this.progressByChange) !== ProgressStatus.RUNNING;
+  }
+
+  private handleClose() {
+    this.actionModal.close();
+    if (getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED)
+      return;
+    fireReload(this, true);
+  }
+
+  private async handleConfirm() {
+    this.progressByChange.clear();
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'vote',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    const reviewInput: ReviewInput = {
+      labels: this.getLabelValues(
+        this.computeCommonPermittedLabels(this.computePermittedLabels())
+      ),
+    };
+    for (const change of this.selectedChanges) {
+      this.progressByChange.set(change._number, ProgressStatus.RUNNING);
+    }
+    this.requestUpdate();
+    const promises = this.getBulkActionsModel().voteChanges(reviewInput);
+
+    await allSettled(
+      promises.map((promise, index) => {
+        const changeNum = this.selectedChanges[index]._number;
+        return promise
+          .then(() => {
+            this.progressByChange.set(changeNum, ProgressStatus.SUCCESSFUL);
+          })
+          .catch(() => {
+            this.progressByChange.set(changeNum, ProgressStatus.FAILED);
+          })
+          .finally(() => {
+            this.requestUpdate();
+            if (
+              getOverallStatus(this.progressByChange) ===
+              ProgressStatus.SUCCESSFUL
+            ) {
+              fireAlert(this, 'Votes added');
+              this.handleClose();
+            }
+          });
+      })
+    );
+    if (getOverallStatus(this.progressByChange) === ProgressStatus.FAILED) {
+      this.reportingService.reportInteraction('bulk-action-failure', {
+        type: 'vote',
+        count: Array.from(this.progressByChange.values()).filter(
+          status => status === ProgressStatus.FAILED
+        ).length,
+      });
+    }
+  }
+
+  // private but used in tests
+  getLabelValues(commonPermittedLabels: Label[]): LabelNameToValueMap {
+    const labels: LabelNameToValueMap = {};
+
+    for (const label of commonPermittedLabels) {
+      const selectorEl = queryAndAssert<GrLabelScoreRow>(
+        this,
+        `gr-label-score-row[name="${label.name}"]`
+      );
+      if (!selectorEl?.selectedItem) continue;
+
+      const selectedVal =
+        typeof selectorEl.selectedValue === 'string'
+          ? Number(selectorEl.selectedValue)
+          : selectorEl.selectedValue;
+
+      if (selectedVal === undefined) continue;
+      labels[label.name] = selectedVal;
+    }
+    return labels;
+  }
+
+  // private but used in tests
+  computePermittedLabels() {
+    // Reduce method for empty array throws error if no initial value specified
+    if (this.selectedChanges.length === 0) return {};
+
+    const permittedLabels = this.selectedChanges
+      .map(changes => changes.permitted_labels)
+      .reduce(mergeLabelMaps);
+    // TODO: show a warning to the user that Code Review cannot be voted upon
+    if (permittedLabels?.[StandardLabels.CODE_REVIEW]) {
+      delete permittedLabels[StandardLabels.CODE_REVIEW];
+    }
+    return permittedLabels;
+  }
+
+  private computeLabelNameToInfoMap() {
+    // Reduce method for empty array throws error if no initial value specified
+    if (this.selectedChanges.length === 0) return {};
+
+    return this.selectedChanges
+      .map(changes => changes.labels)
+      .reduce(mergeLabelInfoMaps);
+  }
+
+  // private but used in tests
+  computeCommonTriggerLabels(permittedLabels?: LabelNameToValuesMap) {
+    if (this.selectedChanges.length === 0) return [];
+    const triggerVotes = this.selectedChanges
+      .map(change => getTriggerVotes(change))
+      .reduce((prev, current) =>
+        current.filter(label => prev.some(l => l === label))
+      );
+    return this.computeCommonPermittedLabels(permittedLabels).filter(label =>
+      triggerVotes.includes(label.name)
+    );
+  }
+
+  // private but used in tests
+  computeCommonPermittedLabels(permittedLabels?: LabelNameToValuesMap) {
+    // Reduce method for empty array throws error if no initial value specified
+    if (this.selectedChanges.length === 0) return [];
+    return this.selectedChanges
+      .map(change => computeLabels(this.account, change))
+      .reduce((prev, current) =>
+        current.filter(label => prev.some(l => l.name === label.name))
+      )
+      .filter(
+        label =>
+          permittedLabels?.[label.name] &&
+          permittedLabels?.[label.name].length > 0
+      );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-bulk-vote-flow': GrChangeListBulkVoteFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
new file mode 100644
index 0000000..654ed91
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -0,0 +1,663 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {GrChangeListBulkVoteFlow} from './gr-change-list-bulk-vote-flow';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+  LoadingState,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {
+  waitUntilObserved,
+  stubRestApi,
+  queryAndAssert,
+  query,
+  mockPromise,
+  queryAll,
+  stubReporting,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, LabelInfo} from '../../../api/rest-api';
+import {getAppContext} from '../../../services/app-context';
+import {fixture, waitUntil, assert} from '@open-wc/testing';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {html} from 'lit';
+import {SinonStubbedMember} from 'sinon';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {
+  createChange,
+  createDetailedLabelInfo,
+  createSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import './gr-change-list-bulk-vote-flow';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {ProgressStatus} from '../../../constants/constants';
+import {StandardLabels} from '../../../utils/label-util';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+
+const change1: ChangeInfo = {
+  ...createChange(),
+  _number: 1 as NumericChangeId,
+  permitted_labels: {
+    [StandardLabels.CODE_REVIEW]: ['-1', '0', '+1', '+2'],
+    A: ['-1', '0', '+1', '+2'],
+    B: ['-1', '0'],
+    C: ['-1', '0'],
+    change1OnlyLabelD: ['0'], // Does not exist on change2
+    change1OnlyTriggerLabelE: ['0'], // Does not exist on change2
+  },
+  labels: {
+    [StandardLabels.CODE_REVIEW]: {value: null} as LabelInfo,
+    A: {value: null} as LabelInfo,
+    B: {value: null} as LabelInfo,
+    C: {value: null} as LabelInfo,
+    change1OnlyLabelD: {value: null} as LabelInfo,
+    change1OnlyTriggerLabelE: {value: null} as LabelInfo,
+  },
+  submit_requirements: [
+    createSubmitRequirementResultInfo(
+      `label:${StandardLabels.CODE_REVIEW}=MAX`
+    ),
+    createSubmitRequirementResultInfo('label:A=MAX'),
+    createSubmitRequirementResultInfo('label:B=MAX'),
+    createSubmitRequirementResultInfo('label:C=MAX'),
+    createSubmitRequirementResultInfo('label:change1OnlyLabelD=MAX'),
+  ],
+};
+const change2: ChangeInfo = {
+  ...createChange(),
+  _number: 2 as NumericChangeId,
+  permitted_labels: {
+    [StandardLabels.CODE_REVIEW]: ['-1', '0', '+1', '+2'],
+    A: ['-1', '0', '+1', '+2'], // Intersects fully with change1
+    B: ['0', ' +1'], // Intersects with change1 on 0
+    C: ['+1', '+2'], // Does not intersect with change1 at all
+  },
+  labels: {
+    [StandardLabels.CODE_REVIEW]: {value: null} as LabelInfo,
+    A: {value: null} as LabelInfo,
+    B: {value: null} as LabelInfo,
+    C: {value: null} as LabelInfo,
+  },
+  submit_requirements: [
+    createSubmitRequirementResultInfo(
+      `label:${StandardLabels.CODE_REVIEW}=MAX`
+    ),
+    createSubmitRequirementResultInfo('label:A=MAX'),
+    createSubmitRequirementResultInfo('label:B=MAX'),
+    createSubmitRequirementResultInfo('label:C=MAX'),
+  ],
+};
+
+suite('gr-change-list-bulk-vote-flow tests', () => {
+  let element: GrChangeListBulkVoteFlow;
+  let model: BulkActionsModel;
+  let dispatchEventStub: sinon.SinonStub;
+  let getChangesStub: SinonStubbedMember<
+    RestApiService['getDetailedChangesWithActions']
+  >;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChangeNums$, selectedChangeNums =>
+      selectedChangeNums.includes(change._number)
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    model = new BulkActionsModel(getAppContext().restApiService);
+    getChangesStub = stubRestApi('getDetailedChangesWithActions');
+    reportingStub = stubReporting('reportInteraction');
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-bulk-vote-flow')!;
+    await element.updateComplete;
+    dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+  });
+
+  test('renders', async () => {
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      `<gr-button
+        aria-disabled="false"
+        flatten=""
+        id="voteFlowButton"
+        role="button"
+        tabindex="0"
+      >
+        Vote
+      </gr-button>
+      <dialog
+        id="actionModal"
+        tabindex="-1"
+      >
+        <gr-dialog role="dialog">
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
+          <div slot="main">
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
+              <div class="code-review-message-container">
+                <div class="code-review-message-layout-container">
+                <div>
+                  <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+                  <span class="warning-text">
+                    Code Review vote is only available on the individual change page
+                  </span>
+                </div>
+                <div class="flex-space"></div>
+                <div>
+                  <gr-button
+                    aria-disabled="false"
+                    flatten=""
+                    link=""
+                    role="button"
+                    aria-label="Open 1 change in different tabs"
+                    tabindex="0"
+                  >
+                    Open 1 change
+                  </gr-button>
+                </div>
+                </div>
+              </div>
+              <gr-label-score-row name="A"> </gr-label-score-row>
+              <gr-label-score-row name="B"> </gr-label-score-row>
+              <gr-label-score-row name="C"> </gr-label-score-row>
+              <gr-label-score-row name="change1OnlyLabelD">
+              </gr-label-score-row>
+            </div>
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
+              <gr-label-score-row name="change1OnlyTriggerLabelE">
+              </gr-label-score-row>
+            </div>
+          </div>
+        </gr-dialog>
+      </dialog> `
+    );
+  });
+
+  test('renders with errors', async () => {
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    stubRestApi('saveChangeReview').callsFake(
+      (_changeNum, _patchNum, _review, errFn) =>
+        Promise.resolve(new Response()).then(res => {
+          errFn && errFn();
+          return res;
+        })
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+    await waitUntil(
+      () =>
+        element.progressByChange.get(1 as NumericChangeId) ===
+        ProgressStatus.FAILED
+    );
+
+    assert.shadowDom.equal(
+      element,
+      `<gr-button
+        aria-disabled="false"
+        flatten=""
+        id="voteFlowButton"
+        role="button"
+        tabindex="0"
+      >
+        Vote
+      </gr-button>
+      <dialog
+        id="actionModal"
+        tabindex="-1"
+      >
+        <gr-dialog role="dialog">
+          <div slot="header">
+            <span class="main-heading"> Vote on selected changes </span>
+          </div>
+          <div slot="main">
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Submit requirements votes</h3>
+              <div class="code-review-message-container">
+                <div class="code-review-message-layout-container">
+                <div>
+                  <gr-icon icon="info" aria-label="Information" role="img"></gr-icon>
+                  <span class="warning-text">
+                    Code Review vote is only available on the individual change page
+                  </span>
+                </div>
+                <div class="flex-space"></div>
+                <div>
+                  <gr-button
+                    aria-disabled="false"
+                    flatten=""
+                    link=""
+                    role="button"
+                    aria-label="Open 1 change in different tabs"
+                    tabindex="0"
+                  >
+                    Open 1 change
+                  </gr-button>
+                </div>
+                </div>
+              </div>
+              <gr-label-score-row name="A"> </gr-label-score-row>
+              <gr-label-score-row name="B"> </gr-label-score-row>
+              <gr-label-score-row name="C"> </gr-label-score-row>
+              <gr-label-score-row name="change1OnlyLabelD">
+              </gr-label-score-row>
+            </div>
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-4 vote-type">Trigger Votes</h3>
+              <gr-label-score-row name="change1OnlyTriggerLabelE">
+              </gr-label-score-row>
+            </div>
+            <div class="error-container">
+              <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+              <span class="error-text"> Failed to vote on 1 change </span>
+            </div>
+          </div>
+        </gr-dialog>
+      </dialog> `
+    );
+  });
+
+  test('button state updates as changes are updated', async () => {
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    await waitEventLoop();
+
+    assert.isNotOk(
+      queryAndAssert<GrButton>(element, '#voteFlowButton').disabled
+    );
+
+    // No common label with change1 so button is disabled
+    change2.labels = {
+      x: {value: null} as LabelInfo,
+      y: {value: null} as LabelInfo,
+      z: {value: null} as LabelInfo,
+    };
+    change2.submit_requirements = [
+      createSubmitRequirementResultInfo('label:x=MAX'),
+      createSubmitRequirementResultInfo('label:y=MAX'),
+      createSubmitRequirementResultInfo('label:z=MAX'),
+    ];
+    changes.push({...change2});
+    getChangesStub.restore();
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change2);
+    await element.updateComplete;
+
+    assert.isNotOk(
+      queryAndAssert<GrButton>(element, '#voteFlowButton').disabled
+    );
+  });
+
+  test('progress updates as request is resolved', async () => {
+    const change = {
+      ...change1,
+      labels: {
+        ...change1.labels,
+        C: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...element.account!,
+              value: -1,
+            },
+          ],
+        },
+      },
+    };
+    const changes: ChangeInfo[] = [{...change}];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change);
+    await element.updateComplete;
+    const saveChangeReview = mockPromise<Response>();
+    stubRestApi('saveChangeReview').returns(saveChangeReview);
+
+    queryAndAssert<GrButton>(element, '#voteFlowButton').click();
+
+    await element.updateComplete;
+
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    const scores = queryAll(element, 'gr-label-score-row');
+    queryAndAssert<GrButton>(scores[0], 'gr-button[data-value="+1"]').click();
+    queryAndAssert<GrButton>(scores[1], 'gr-button[data-value="-1"]').click();
+    queryAndAssert<GrButton>(scores[2], 'gr-button[data-value="0"]').click();
+
+    await element.updateComplete;
+
+    assert.deepEqual(
+      element.getLabelValues(
+        element.computeCommonPermittedLabels(element.computePermittedLabels())
+      ),
+      {
+        A: 1,
+        B: -1,
+        C: 0,
+      }
+    );
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    assert.deepEqual(reportingStub.lastCall.args[1], {
+      type: 'vote',
+      selectedChangeCount: 1,
+    });
+
+    assert.equal(
+      element.progressByChange.get(1 as NumericChangeId),
+      ProgressStatus.RUNNING
+    );
+
+    saveChangeReview.resolve({...new Response(), status: 200});
+    await waitUntil(
+      () =>
+        element.progressByChange.get(1 as NumericChangeId) ===
+        ProgressStatus.SUCCESSFUL
+    );
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    assert.equal(
+      element.progressByChange.get(1 as NumericChangeId),
+      ProgressStatus.SUCCESSFUL
+    );
+
+    // reload event is fired automatically when all requests succeed
+    assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
+    assert.equal(
+      dispatchEventStub.firstCall.args[0].detail.message,
+      'Votes added'
+    );
+  });
+
+  suite('closing dialog triggers reloads', () => {
+    test('closing dialog triggers a reload', async () => {
+      const changes: ChangeInfo[] = [change1, change2];
+      getChangesStub.returns(Promise.resolve(changes));
+
+      stubRestApi('saveChangeReview').callsFake(
+        (_changeNum, _patchNum, _review, errFn) =>
+          Promise.resolve(new Response()).then(res => {
+            errFn && errFn();
+            return res;
+          })
+      );
+
+      model.sync(changes);
+      await waitUntilObserved(
+        model.loadingState$,
+        state => state === LoadingState.LOADED
+      );
+      await selectChange(change1);
+      await selectChange(change2);
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+      await waitUntil(
+        () =>
+          element.progressByChange.get(2 as NumericChangeId) ===
+          ProgressStatus.FAILED
+      );
+
+      // Dialog does not autoclose and fire reload event if some request fails
+      assert.isFalse(dispatchEventStub.called);
+
+      assert.deepEqual(reportingStub.lastCall.args, [
+        'bulk-action-failure',
+        {
+          type: 'vote',
+          count: 2,
+        },
+      ]);
+
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
+
+      await waitUntil(() => dispatchEventStub.called);
+      assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
+    });
+
+    test('closing dialog does not trigger reload if no request made', async () => {
+      const changes: ChangeInfo[] = [change1, change2];
+      getChangesStub.returns(Promise.resolve(changes));
+
+      model.sync(changes);
+      await waitUntilObserved(
+        model.loadingState$,
+        state => state === LoadingState.LOADED
+      );
+      await selectChange(change1);
+      await selectChange(change2);
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
+
+      assert.isFalse(dispatchEventStub.called);
+    });
+  });
+
+  test('computePermittedLabels', async () => {
+    // {} if no change is selected
+    assert.deepEqual(element.computePermittedLabels(), {});
+
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+
+    assert.deepEqual(element.computePermittedLabels(), {
+      A: ['-1', '0', '+1', '+2'],
+      B: ['-1', '0'],
+      C: ['-1', '0'],
+      change1OnlyLabelD: ['0'],
+      change1OnlyTriggerLabelE: ['0'],
+    });
+
+    changes.push(change2);
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change2);
+    await element.updateComplete;
+
+    assert.deepEqual(element.computePermittedLabels(), {
+      A: ['-1', '0', '+1', '+2'],
+      B: ['0'],
+      C: [],
+    });
+  });
+
+  test('computeCommonPermittedLabels', async () => {
+    const createChangeWithLabels = (
+      num: NumericChangeId,
+      labelNames: string[],
+      triggerLabels?: string[]
+    ) => {
+      const change = createChange();
+      change._number = num;
+      change.submit_requirements = [];
+      change.labels = {};
+      change.permitted_labels = {};
+      for (const label of labelNames) {
+        change.labels[label] = {value: null} as LabelInfo;
+        if (!triggerLabels?.includes(label)) {
+          change.submit_requirements.push(
+            createSubmitRequirementResultInfo(`label:${label}=MAX`)
+          );
+        }
+        change.permitted_labels[label] = ['0'];
+      }
+      return change;
+    };
+
+    const changes: ChangeInfo[] = [
+      createChangeWithLabels(
+        1 as NumericChangeId,
+        ['a', 'triggerLabelB', 'c'],
+        ['triggerLabelB']
+      ),
+      createChangeWithLabels(
+        2 as NumericChangeId,
+        ['triggerLabelB', 'c', 'd'],
+        ['triggerLabelB']
+      ),
+      createChangeWithLabels(3 as NumericChangeId, ['c', 'd', 'e']),
+      createChangeWithLabels(4 as NumericChangeId, ['x', 'y', 'z']),
+    ];
+    // Labels for each change are [a,b,c] [b,c,d] [c,d,e] [x,y,z]
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(
+      createChangeWithLabels(1 as NumericChangeId, ['a', 'triggerLabelB', 'c'])
+    );
+    await element.updateComplete;
+
+    // Code-Review is not a common permitted label
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      [
+        {name: 'a', value: null},
+        {name: 'c', value: null},
+        {name: 'triggerLabelB', value: null},
+      ]
+    );
+
+    await selectChange(
+      createChangeWithLabels(2 as NumericChangeId, ['triggerLabelB', 'c', 'd'])
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      [{name: 'triggerLabelB', value: null}]
+    );
+
+    await element.updateComplete;
+
+    // Intersection of [CR, 'a', 'triggerLabelB', 'c']
+    // [CR, 'triggerLabelB', 'c', 'd'] is [triggerLabelB,c]
+    // Code-Review is not a common permitted label
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      [
+        {name: 'c', value: null},
+        {name: 'triggerLabelB', value: null},
+      ]
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      [{name: 'triggerLabelB', value: null}]
+    );
+
+    await selectChange(
+      createChangeWithLabels(3 as NumericChangeId, ['c', 'd', 'e'])
+    );
+
+    await element.updateComplete;
+
+    // Intersection of [a,triggerLabelB,c] [triggerLabelB,c,d] [c,d,e] is [c]
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      [{name: 'c', value: null}]
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      []
+    );
+
+    await selectChange(
+      createChangeWithLabels(4 as NumericChangeId, ['x', 'y', 'z'])
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      []
+    );
+
+    await element.updateComplete;
+
+    // Intersection of [a,triggerLabelB,c] [triggerLabelB,c,d] [c,d,e] [x,y,z]
+    // is []
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      []
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
new file mode 100644
index 0000000..13807c8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
@@ -0,0 +1,232 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
+import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-icon/gr-icon';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {
+  ApprovalInfo,
+  ChangeInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+  LabelInfo,
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
+import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
+import {
+  extractAssociatedLabels,
+  getAllUniqueApprovals,
+  getRequirements,
+  getTriggerVotes,
+  hasNeutralStatus,
+  hasVotes,
+  iconForStatus,
+} from '../../../utils/label-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {capitalizeFirstLetter} from '../../../utils/string-util';
+
+@customElement('gr-change-list-column-requirement')
+export class GrChangeListColumnRequirement extends LitElement {
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  @property()
+  labelName?: string;
+
+  static override get styles() {
+    return [
+      submitRequirementsStyles,
+      sharedStyles,
+      css`
+        .container {
+          display: flex;
+          align-items: center;
+          justify-content: center;
+        }
+        .container.not-applicable {
+          background-color: var(--table-header-background-color);
+          height: calc(var(--line-height-normal) + var(--spacing-m));
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div
+      class="container ${this.computeClass()}"
+      title=${ifDefined(this.computeLabelTitle())}
+    >
+      ${this.renderContent()}
+    </div>`;
+  }
+
+  private renderContent() {
+    if (!this.labelName) return;
+    const requirements = this.getRequirement(this.labelName);
+    if (requirements.length === 0) {
+      return this.renderTriggerVote();
+    }
+
+    const requirement = requirements[0];
+    if (requirement.status === SubmitRequirementStatus.UNSATISFIED) {
+      return this.renderUnsatisfiedState(requirement);
+    } else {
+      return this.renderStatusIcon(requirement.status);
+    }
+  }
+
+  private renderTriggerVote() {
+    if (!this.labelName || !this.isTriggerVote(this.labelName)) return;
+    const allLabels = this.change?.labels ?? {};
+    const labelInfo = allLabels[this.labelName];
+    if (isDetailedLabelInfo(labelInfo)) {
+      // votes sorted from best e.g +2 to worst e.g -2
+      const votes = this.getSortedVotes(this.labelName);
+      if (votes.length > 0) {
+        const bestVote = votes[0];
+        return html`<gr-vote-chip
+          .vote=${bestVote}
+          .label=${labelInfo}
+          tooltip-with-who-voted
+        ></gr-vote-chip>`;
+      }
+    }
+    if (isQuickLabelInfo(labelInfo)) {
+      return html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`;
+    }
+    return;
+  }
+
+  private renderUnsatisfiedState(requirement: SubmitRequirementResultInfo) {
+    const requirementLabels = extractAssociatedLabels(
+      requirement,
+      'onlySubmittability'
+    );
+    const allLabels = this.change?.labels ?? {};
+    const associatedLabels = Object.keys(allLabels).filter(label =>
+      requirementLabels.includes(label)
+    );
+
+    let worstVote: ApprovalInfo | undefined;
+    let labelInfo: LabelInfo | undefined;
+    for (const label of associatedLabels) {
+      // votes sorted from worst e.g -2 to best e.g +2
+      const votes = this.getSortedVotes(label).sort(
+        (a, b) => (a.value ?? 0) - (b.value ?? 0)
+      );
+      if (votes.length === 0) break;
+      if (!worstVote || (worstVote.value ?? 0) > (votes[0].value ?? 0)) {
+        worstVote = votes[0];
+        labelInfo = allLabels[label];
+      }
+    }
+    if (worstVote === undefined) {
+      return this.renderStatusIcon(requirement.status);
+    } else {
+      return html`<gr-vote-chip
+        .vote=${worstVote}
+        .label=${labelInfo}
+        tooltip-with-who-voted
+      ></gr-vote-chip>`;
+    }
+  }
+
+  private renderStatusIcon(status: SubmitRequirementStatus) {
+    const icon = iconForStatus(status);
+    return html`
+      <gr-icon
+        class=${icon.icon}
+        icon=${icon.icon}
+        ?filled=${icon.filled}
+      ></gr-icon>
+    `;
+  }
+
+  private computeClass(): string {
+    if (!this.labelName) return '';
+    const requirements = this.getRequirement(this.labelName);
+    if (requirements.length === 0 && !this.isTriggerVote(this.labelName)) {
+      return 'not-applicable';
+    }
+    return '';
+  }
+
+  private computeLabelTitle() {
+    if (!this.labelName) return;
+    const requirements = this.getRequirement(this.labelName);
+    if (requirements.length === 0) {
+      if (this.isTriggerVote(this.labelName)) {
+        return;
+      } else {
+        return 'Requirement not applicable';
+      }
+    }
+    const requirement = requirements[0];
+    if (requirement.status === SubmitRequirementStatus.UNSATISFIED) {
+      const requirementLabels = extractAssociatedLabels(
+        requirement,
+        'onlySubmittability'
+      );
+      const allLabels = this.change?.labels ?? {};
+      const associatedLabels = Object.keys(allLabels).filter(label =>
+        requirementLabels.includes(label)
+      );
+      const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
+      if (requirementWithoutLabelToVoteOn) {
+        const status = capitalizeFirstLetter(requirement.status.toLowerCase());
+        return status;
+      }
+
+      const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
+        label => !hasVotes(allLabels[label])
+      );
+      if (everyAssociatedLabelsIsWithoutVotes) {
+        return 'No votes';
+      } else {
+        return; // there is a vote with tooltip, so undefined label title
+      }
+    } else {
+      return capitalizeFirstLetter(requirement.status.toLowerCase());
+    }
+  }
+
+  private getRequirement(labelName: string) {
+    const requirements = getRequirements(this.change).filter(
+      sr => sr.name === labelName
+    );
+    // TODO(milutin): Remove this after migration from legacy requirements.
+    if (requirements.length > 1) {
+      return requirements.filter(sr => !sr.is_legacy);
+    } else {
+      return requirements;
+    }
+  }
+
+  private getSortedVotes(label: string) {
+    const allLabels = this.change?.labels ?? {};
+    const labelInfo = allLabels[label];
+    if (isDetailedLabelInfo(labelInfo)) {
+      return getAllUniqueApprovals(labelInfo).filter(
+        approval => !hasNeutralStatus(labelInfo, approval)
+      );
+    }
+    return [];
+  }
+
+  private isTriggerVote(labelName: string) {
+    const triggerVotes = getTriggerVotes(this.change);
+    return triggerVotes.includes(labelName);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-column-requirement': GrChangeListColumnRequirement;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
new file mode 100644
index 0000000..82e8048
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement_test.ts
@@ -0,0 +1,170 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-change-list-column-requirement';
+import {GrChangeListColumnRequirement} from './gr-change-list-column-requirement';
+import {
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+  createChange,
+} from '../../../test/test-data-generators';
+import {
+  AccountId,
+  ChangeInfo,
+  DetailedLabelInfo,
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
+import {StandardLabels} from '../../../utils/label-util';
+import {queryAndAssert, stubFlags} from '../../../test/test-utils';
+
+suite('gr-change-list-column-requirement tests', () => {
+  let element: GrChangeListColumnRequirement;
+  let change: ChangeInfo;
+  setup(() => {
+    stubFlags('isEnabled').returns(true);
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      name: StandardLabels.CODE_REVIEW,
+      submittability_expression_result: {
+        ...createSubmitRequirementExpressionInfo(),
+      },
+    };
+    change = {
+      ...createChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      unresolved_comment_count: 1,
+    };
+  });
+
+  test('renders', async () => {
+    element = await fixture<GrChangeListColumnRequirement>(
+      html`<gr-change-list-column-requirement
+        .change=${change}
+        .labelName=${StandardLabels.CODE_REVIEW}
+      >
+      </gr-change-list-column-requirement>`
+    );
+    assert.shadowDom.equal(
+      element,
+      /* HTML */
+      ` <div class="container" title="Satisfied">
+        <gr-icon class="check_circle" filled icon="check_circle"></gr-icon>
+      </div>`
+    );
+  });
+
+  test('show worst vote when state is not satisfied', async () => {
+    const VALUES_2 = {
+      '-2': 'blocking',
+      '-1': 'bad',
+      '0': 'neutral',
+      '+1': 'good',
+      '+2': 'perfect',
+    };
+    const label: DetailedLabelInfo = {
+      values: VALUES_2,
+      all: [
+        {value: -1, _account_id: 777 as AccountId, name: 'Reviewer'},
+        {value: 1, _account_id: 324 as AccountId, name: 'Reviewer 2'},
+      ],
+    };
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      name: StandardLabels.CODE_REVIEW,
+      status: SubmitRequirementStatus.UNSATISFIED,
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    change = {
+      ...change,
+      submit_requirements: [submitRequirement],
+      labels: {
+        Verified: label,
+      },
+    };
+    element = await fixture<GrChangeListColumnRequirement>(
+      html`<gr-change-list-column-requirement
+        .change=${change}
+        .labelName=${StandardLabels.CODE_REVIEW}
+      >
+      </gr-change-list-column-requirement>`
+    );
+    assert.shadowDom.equal(
+      element,
+      /* HTML */
+      ` <div class="container">
+        <gr-vote-chip tooltip-with-who-voted=""></gr-vote-chip>
+      </div>`
+    );
+    const voteChip = queryAndAssert(element, 'gr-vote-chip');
+    assert.shadowDom.equal(
+      voteChip,
+      /* HTML */
+      ` <gr-tooltip-content
+        class="container"
+        has-tooltip=""
+        title="Reviewer: bad"
+      >
+        <div class="negative vote-chip">-1</div>
+      </gr-tooltip-content>`
+    );
+  });
+
+  test('show trigger vote', async () => {
+    const VALUES_2 = {
+      '-2': 'blocking',
+      '-1': 'bad',
+      '0': 'neutral',
+      '+1': 'good',
+      '+2': 'perfect',
+    };
+    change = {
+      ...change,
+      submit_requirements: [],
+      labels: {
+        'Commit-Queue': {
+          values: VALUES_2,
+          all: [
+            {value: -1, _account_id: 777 as AccountId, name: 'Reviewer'},
+            {value: 1, _account_id: 324 as AccountId, name: 'Reviewer 2'},
+          ],
+        },
+      },
+    };
+    element = await fixture<GrChangeListColumnRequirement>(
+      html`<gr-change-list-column-requirement
+        .change=${change}
+        .labelName=${'Commit-Queue'}
+      >
+      </gr-change-list-column-requirement>`
+    );
+    assert.shadowDom.equal(
+      element,
+      /* HTML */
+      ` <div class="container">
+        <gr-vote-chip tooltip-with-who-voted=""></gr-vote-chip>
+      </div>`
+    );
+    const voteChip = queryAndAssert(element, 'gr-vote-chip');
+    assert.shadowDom.equal(
+      voteChip,
+      /* HTML */
+      ` <gr-tooltip-content
+        class="container"
+        has-tooltip=""
+        title="Reviewer 2: good"
+      >
+        <div class="positive vote-chip">+1</div>
+      </gr-tooltip-content>`
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
new file mode 100644
index 0000000..dbde061
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
@@ -0,0 +1,135 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
+import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-icon/gr-icon';
+import {LitElement, css, html, TemplateResult} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {ChangeInfo, SubmitRequirementStatus} from '../../../api/rest-api';
+import {changeStatuses} from '../../../utils/change-util';
+import {
+  getRequirements,
+  iconForStatus,
+  SubmitRequirementsIcon,
+} from '../../../utils/label-util';
+import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
+import {pluralize} from '../../../utils/string-util';
+
+@customElement('gr-change-list-column-requirements-summary')
+export class GrChangeListColumnRequirementsSummary extends LitElement {
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  static override get styles() {
+    return [
+      submitRequirementsStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        gr-change-status {
+          display: inline-block;
+        }
+        gr-icon.commentIcon {
+          color: var(--warning-foreground);
+        }
+        .unsatisfied {
+          color: var(--primary-text-color);
+        }
+        /* Used to hide the leading separator comma for statuses. */
+        .comma:first-of-type {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const commentIcon = this.renderCommentIcon();
+    return html`${this.renderChangeStatus()} ${commentIcon}`;
+  }
+
+  renderChangeStatus() {
+    if (!this.change) return;
+    const statuses = changeStatuses(this.change);
+    if (statuses.length > 0) {
+      return statuses.map(
+        status =>
+          html`<span class="comma">, </span
+            ><gr-change-status flat .status=${status}></gr-change-status>`
+      );
+    }
+    return this.renderActiveStatus();
+  }
+
+  renderActiveStatus() {
+    const submitRequirements = getRequirements(this.change);
+    if (!submitRequirements.length) return html`n/a`;
+    const numRequirements = submitRequirements.length;
+    const numSatisfied = submitRequirements.filter(
+      req =>
+        req.status === SubmitRequirementStatus.SATISFIED ||
+        req.status === SubmitRequirementStatus.OVERRIDDEN
+    ).length;
+
+    if (numSatisfied === numRequirements) {
+      return this.renderState(
+        iconForStatus(SubmitRequirementStatus.SATISFIED),
+        'Ready'
+      );
+    }
+
+    const numUnsatisfied = submitRequirements.filter(
+      req => req.status === SubmitRequirementStatus.UNSATISFIED
+    ).length;
+
+    return this.renderState(
+      iconForStatus(SubmitRequirementStatus.UNSATISFIED),
+      this.renderSummary(numUnsatisfied)
+    );
+  }
+
+  renderState(
+    icon: SubmitRequirementsIcon,
+    aggregation: string | TemplateResult
+  ) {
+    return html`<span class=${icon.icon} role="button" tabindex="0">
+      <gr-submit-requirement-dashboard-hovercard .change=${this.change}>
+      </gr-submit-requirement-dashboard-hovercard>
+      <gr-icon
+        class=${icon.icon}
+        icon=${icon.icon}
+        ?filled=${icon.filled}
+        role="img"
+      ></gr-icon>
+      ${aggregation}</span
+    >`;
+  }
+
+  renderSummary(numUnsatisfied: number) {
+    return html`<span class="unsatisfied">${numUnsatisfied} missing</span>`;
+  }
+
+  renderCommentIcon() {
+    if (!this.change?.unresolved_comment_count) return;
+    return html`<gr-icon
+      class="commentIcon"
+      icon="chat_bubble"
+      small
+      filled
+      .title=${pluralize(
+        this.change?.unresolved_comment_count,
+        'unresolved comment'
+      )}
+    ></gr-icon>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-column-requirements-summary': GrChangeListColumnRequirementsSummary;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
new file mode 100644
index 0000000..6da91be2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary_test.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-change-list-column-requirements-summary';
+import {GrChangeListColumnRequirementsSummary} from './gr-change-list-column-requirements-summary';
+import {
+  createApproval,
+  createDetailedLabelInfo,
+  createParsedChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+
+suite('gr-change-list-column-requirements-summary tests', () => {
+  let element: GrChangeListColumnRequirementsSummary;
+  let change: ParsedChangeInfo;
+  setup(() => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      status: SubmitRequirementStatus.UNSATISFIED,
+      description: 'Test Description',
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    change = {
+      ...createParsedChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      },
+    };
+  });
+
+  test('renders', async () => {
+    element = await fixture<GrChangeListColumnRequirementsSummary>(
+      html`<gr-change-list-column-requirements-summary .change=${change}>
+      </gr-change-list-column-requirements-summary>`
+    );
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <span class="block" role="button" tabindex="0">
+        <gr-submit-requirement-dashboard-hovercard>
+        </gr-submit-requirement-dashboard-hovercard>
+        <gr-icon class="block" role="img" icon="block"></gr-icon>
+        <span class="unsatisfied">1 missing</span>
+      </span>`
+    );
+  });
+
+  test('renders comment count', async () => {
+    change = {
+      ...change,
+      unresolved_comment_count: 5,
+    };
+    element = await fixture<GrChangeListColumnRequirementsSummary>(
+      html`<gr-change-list-column-requirements-summary .change=${change}>
+      </gr-change-list-column-requirements-summary>`
+    );
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <span class="block" role="button" tabindex="0">
+          <gr-submit-requirement-dashboard-hovercard>
+          </gr-submit-requirement-dashboard-hovercard>
+          <gr-icon class="block" role="img" icon="block"></gr-icon>
+          <span class="unsatisfied">1 missing</span>
+        </span>
+        <gr-icon
+          class="commentIcon"
+          small
+          filled
+          icon="chat_bubble"
+          title="5 unresolved comments"
+        ></gr-icon>`
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts
deleted file mode 100644
index b5dee28..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts
+++ /dev/null
@@ -1,88 +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 '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {ChangeInfo, SubmitRequirementStatus} from '../../../api/rest-api';
-import {changeIsMerged} from '../../../utils/change-util';
-import {getRequirements} from '../../../utils/label-util';
-
-@customElement('gr-change-list-column-requirements')
-export class GrChangeListColumRequirements extends LitElement {
-  @property({type: Object})
-  change?: ChangeInfo;
-
-  static override get styles() {
-    return [
-      css`
-        iron-icon {
-          width: var(--line-height-normal, 20px);
-          height: var(--line-height-normal, 20px);
-          vertical-align: top;
-        }
-        span {
-          line-height: var(--line-height-normal);
-        }
-        .check {
-          color: var(--success-foreground);
-        }
-        iron-icon.close {
-          color: var(--error-foreground);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    if (changeIsMerged(this.change)) {
-      return this.renderState('check', 'Merged');
-    }
-
-    const submitRequirements = getRequirements(this.change);
-    if (!submitRequirements.length) return html`n/a`;
-    const numOfRequirements = submitRequirements.length;
-    const numOfSatisfiedRequirements = submitRequirements.filter(
-      req =>
-        req.status === SubmitRequirementStatus.SATISFIED ||
-        req.status === SubmitRequirementStatus.OVERRIDDEN
-    ).length;
-
-    if (numOfSatisfiedRequirements === numOfRequirements) {
-      return this.renderState('check', 'Ready');
-    }
-    return this.renderState(
-      'close',
-      `${numOfSatisfiedRequirements} of ${numOfRequirements} granted`
-    );
-  }
-
-  renderState(icon: string, message: string) {
-    return html`<span class="${icon}"
-      ><gr-submit-requirement-dashboard-hovercard .change=${this.change}>
-      </gr-submit-requirement-dashboard-hovercard>
-      <iron-icon class="${icon}" icon="gr-icons:${icon}" role="img"></iron-icon
-      >${message}</span
-    >`;
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-change-list-column-requirements': GrChangeListColumRequirements;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
new file mode 100644
index 0000000..f8fcad8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -0,0 +1,375 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, Hashtag} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '@polymer/iron-dropdown/iron-dropdown';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {getAppContext} from '../../../services/app-context';
+import {isDefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map.js';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+import {fireAlert} from '../../../utils/event-util';
+import {pluralize} from '../../../utils/string-util';
+import {Interaction} from '../../../constants/reporting';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+
+@customElement('gr-change-list-hashtag-flow')
+export class GrChangeListHashtagFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  @state() private hashtagToAdd: Hashtag = '' as Hashtag;
+
+  @state() private existingHashtagSuggestions: Hashtag[] = [];
+
+  @state() private loadingText?: string;
+
+  @state() private errorText?: string;
+
+  /** dropdown status is tracked here to lazy-load the inner DOM contents */
+  @state() private isDropdownOpen = false;
+
+  @state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  private selectedExistingHashtags: Set<Hashtag> = new Set();
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      spinnerStyles,
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: 400px;
+          background-color: var(--dialog-background-color);
+          border-radius: 4px;
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
+        }
+        gr-autocomplete {
+          --prominent-border-color: var(--gray-800);
+        }
+        .footer {
+          display: flex;
+          justify-content: space-between;
+          align-items: baseline;
+        }
+        .buttons {
+          padding-top: var(--spacing-m);
+          display: flex;
+          justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .chips {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+          padding-bottom: var(--spacing-l);
+        }
+        .chip {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-radius: 10px;
+          width: fit-content;
+          cursor: pointer;
+          color: var(--primary-text-color);
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--border-color);
+          background: none;
+        }
+        .chip.selected {
+          border: 0;
+          color: var(--selected-foreground);
+          background-color: var(--selected-chip-background);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          gap: var(--spacing-s);
+          align-items: baseline;
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+        .error {
+          color: var(--deemphasized-text-color);
+        }
+        gr-icon {
+          color: var(--error-color);
+          /* Center with text by aligning it to the top and then pushing it down
+             to match the text */
+          vertical-align: top;
+          position: relative;
+          top: 7px;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
+    return html`
+      <gr-button
+        id="start-flow"
+        flatten
+        down-arrow
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
+        >Hashtag</gr-button
+      >
+      <iron-dropdown
+        .horizontalAlign=${'auto'}
+        .verticalAlign=${'auto'}
+        .verticalOffset=${24}
+        @opened-changed=${(e: CustomEvent) =>
+          (this.isDropdownOpen = e.detail.value)}
+      >
+        ${when(
+          this.isDropdownOpen,
+          () => html`
+            <div slot="dropdown-content">
+              ${this.renderExistingHashtags()}
+              <!--
+                The .query function needs to be bound to this because lit's
+                autobind seems to work only for @event handlers.
+              -->
+              <gr-autocomplete
+                .text=${this.hashtagToAdd}
+                .query=${(query: string) => this.getHashtagSuggestions(query)}
+                show-blue-focus-border
+                placeholder="Type hashtag name to create or filter hashtags"
+                @text-changed=${(e: ValueChangedEvent<Hashtag>) =>
+                  (this.hashtagToAdd = e.detail.value)}
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar">
+                  ${this.renderLoadingOrError()}
+                </div>
+                <div class="buttons">
+                  ${when(
+                    this.overallProgress !== ProgressStatus.FAILED,
+                    () => html`
+                      <gr-button
+                        id="add-hashtag-button"
+                        flatten
+                        @click=${() => this.applyHashtags('Adding hashtag...')}
+                        .disabled=${this.isAddHashtagDisabled()}
+                        >Add Hashtag</gr-button
+                      >
+                    `,
+                    () => html`
+                      <gr-button
+                        id="cancel-button"
+                        flatten
+                        @click=${this.closeDropdown}
+                        >Cancel</gr-button
+                      >
+                    `
+                  )}
+                </div>
+              </div>
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private renderExistingHashtags() {
+    const hashtags = this.selectedChanges
+      .flatMap(change => change.hashtags ?? [])
+      .filter(isDefined)
+      .filter(unique);
+    return html`
+      <div class="chips">
+        ${hashtags.map(name => this.renderExistingHashtagChip(name))}
+      </div>
+    `;
+  }
+
+  private renderExistingHashtagChip(name: Hashtag) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingHashtags.has(name),
+    };
+    return html`
+      <button
+        role="listbox"
+        aria-label=${`${name as string} selection`}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingHashtagSelected(name)}
+      >
+        ${name}
+      </button>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    switch (this.overallProgress) {
+      case ProgressStatus.RUNNING:
+        return html`
+          <span class="loadingSpin"></span>
+          <span class="loadingText">${this.loadingText}</span>
+        `;
+      case ProgressStatus.FAILED:
+        return html`
+          <gr-icon icon="error" filled></gr-icon>
+          <div class="error">${this.errorText}</div>
+        `;
+      default:
+        return nothing;
+    }
+  }
+
+  private isAddHashtagDisabled() {
+    const allHashtagsToAdd = [
+      ...this.selectedExistingHashtags.values(),
+      ...(this.hashtagToAdd === '' ? [] : [this.hashtagToAdd]),
+    ];
+    const allHashtagsAreAlreadyAdded = allHashtagsToAdd.every(hashtag =>
+      this.selectedChanges.every(change => change.hashtags?.includes(hashtag))
+    );
+    return (
+      allHashtagsAreAlreadyAdded ||
+      this.overallProgress === ProgressStatus.RUNNING
+    );
+  }
+
+  private toggleDropdown() {
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
+  }
+
+  private reset() {
+    this.hashtagToAdd = '' as Hashtag;
+    this.selectedExistingHashtags = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.reset();
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
+  private async getHashtagSuggestions(
+    query: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getChangesWithSimilarHashtag(
+      query,
+      throwingErrorCallback
+    );
+    this.existingHashtagSuggestions = (suggestions ?? [])
+      .flatMap(change => change.hashtags ?? [])
+      .filter(isDefined)
+      .filter(unique);
+    return this.existingHashtagSuggestions.map(hashtag => {
+      return {name: hashtag, value: hashtag};
+    });
+  }
+
+  private applyHashtags(loadingText: string) {
+    let alert = '';
+    const allHashtagsToAdd = [
+      ...this.selectedExistingHashtags.values(),
+      ...(this.hashtagToAdd === '' ? [] : [this.hashtagToAdd]),
+    ];
+
+    if (allHashtagsToAdd.length > 1) {
+      alert = `${allHashtagsToAdd.length} hashtags added to changes`;
+    } else {
+      alert = `${pluralize(this.selectedChanges.length, 'Change')} added to ${
+        allHashtagsToAdd[0]
+      }`;
+    }
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'add-hashtag',
+      selectedChangeCount: this.selectedChanges.length,
+      hashtagsApplied: allHashtagsToAdd.length,
+    });
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.getBulkActionsModel().addHashtags(allHashtagsToAdd),
+      alert,
+      'Failed to add'
+    );
+  }
+
+  private async trackPromises(
+    promises: Promise<Hashtag[]>[],
+    alert: string,
+    errorText: string
+  ) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      fireAlert(this, alert);
+      this.reset();
+      // iron-dropdown doesn't automatically expand when the new chip adds more
+      // vertical space.
+      this.dropdown?.notifyResize();
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      this.errorText = errorText;
+    }
+  }
+
+  private toggleExistingHashtagSelected(name: Hashtag) {
+    if (this.selectedExistingHashtags.has(name)) {
+      this.selectedExistingHashtags.delete(name);
+    } else {
+      this.selectedExistingHashtags.add(name);
+    }
+    this.requestUpdate();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-hashtag-flow': GrChangeListHashtagFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
new file mode 100644
index 0000000..f7a2531
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -0,0 +1,599 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html, assert} from '@open-wc/testing';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {SinonStubbedMember} from 'sinon';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import '../../../test/common-test-setup';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+  waitEventLoop,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
+import {EventType} from '../../../types/events';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-hashtag-flow';
+import type {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
+
+suite('gr-change-list-hashtag-flow tests', () => {
+  let element: GrChangeListHashtagFlow;
+  let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
+
+  setup(() => {
+    reportingStub = stubReporting('reportInteraction');
+  });
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  suite('dropdown closed', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+    });
+
+    test('skips dropdown render when closed', async () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            aria-hidden="true"
+            style="outline: none; display: none;"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+          </iron-dropdown>
+        `
+      );
+    });
+
+    test('dropdown hidden before flow button clicked', async () => {
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('flow button click shows dropdown', async () => {
+      const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+      button.click();
+      await element.updateComplete;
+
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isTrue(dropdown.opened);
+    });
+
+    test('flow button click when open hides dropdown', async () => {
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(() =>
+        Boolean(
+          queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+        )
+      );
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(
+        () =>
+          !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+  });
+
+  suite('hashtag flow', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        hashtags: ['hashtag1' as Hashtag, 'sharedHashtag' as Hashtag],
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        hashtags: ['hashtag2' as Hashtag, 'sharedHashtag' as Hashtag],
+      },
+      {
+        ...createChange(),
+        _number: 3 as NumericChangeId,
+        subject: 'Subject 3',
+        hashtags: ['sharedHashtag' as Hashtag],
+      },
+    ];
+    let setChangeHashtagPromises: MockPromise<Hashtag[]>[];
+    let setChangeHashtagStub: sinon.SinonStub;
+
+    async function resolvePromises(newHashtags: Hashtag[]) {
+      setChangeHashtagPromises[0].resolve([
+        ...(changes[0].hashtags ?? []),
+        ...newHashtags,
+      ]);
+      setChangeHashtagPromises[1].resolve([
+        ...(changes[1].hashtags ?? []),
+        ...newHashtags,
+      ]);
+      setChangeHashtagPromises[2].resolve([
+        ...(changes[2].hashtags ?? []),
+        ...newHashtags,
+      ]);
+      await element.updateComplete;
+    }
+
+    async function rejectPromises() {
+      setChangeHashtagPromises[0].reject(new Error('error'));
+      setChangeHashtagPromises[1].reject(new Error('error'));
+      setChangeHashtagPromises[2].reject(new Error('error'));
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      setChangeHashtagPromises = [];
+      setChangeHashtagStub = stubRestApi('setChangeHashtag');
+      for (let i = 0; i < changes.length; i++) {
+        const promise = mockPromise<Hashtag[]>();
+        setChangeHashtagPromises.push(promise);
+        setChangeHashtagStub
+          .withArgs(changes[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-hashtag-flow')!;
+
+      // select changes
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await selectChange(changes[2]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 3);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await waitEventLoop();
+    });
+
+    test('renders hashtags flow', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Hashtag</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <div class="chips">
+                <button
+                  role="listbox"
+                  aria-label="hashtag1 selection"
+                  class="chip"
+                >
+                  hashtag1
+                </button>
+                <button
+                  role="listbox"
+                  aria-label="sharedHashtag selection"
+                  class="chip"
+                >
+                  sharedHashtag
+                </button>
+                <button
+                  role="listbox"
+                  aria-label="hashtag2 selection"
+                  class="chip"
+                >
+                  hashtag2
+                </button>
+              </div>
+              <gr-autocomplete
+                placeholder="Type hashtag name to create or filter hashtags"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="add-hashtag-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Add Hashtag</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('add hashtag from selected change', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      // selects "hashtag1"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['hashtag1' as Hashtag]);
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['hashtag1']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['hashtag1']},
+      ]);
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '3 Changes added to hashtag1',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 1,
+      });
+      assert.isTrue(
+        queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+
+    test('shows error when add hashtag fails', async () => {
+      // selects "hashtag1"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to add'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('add multiple hashtag from selected change', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      // selects "hashtag1"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      // selects "hashtag2"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[2].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['hashtag1' as Hashtag, 'hashtag2' as Hashtag]);
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['hashtag1', 'hashtag2']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['hashtag1', 'hashtag2']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['hashtag1', 'hashtag2']},
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '2 hashtags added to changes',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 2,
+      });
+    });
+
+    test('add existing hashtag not on selected changes', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['foo' as Hashtag]);
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['foo']},
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '3 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 1,
+      });
+      assert.isTrue(
+        queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+
+    test('add new hashtag', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await resolvePromises(['foo' as Hashtag]);
+      await waitUntilObserved(model.selectedChanges$, selected =>
+        selected.every(change => change.hashtags?.includes('foo' as Hashtag))
+      );
+      await element.updateComplete;
+
+      assert.isTrue(setChangeHashtagStub.calledThrice);
+      assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+        changes[0]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+        changes[1]._number,
+        {add: ['foo']},
+      ]);
+      assert.deepEqual(setChangeHashtagStub.thirdCall.args, [
+        changes[2]._number,
+        {add: ['foo']},
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '3 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-hashtag',
+        selectedChangeCount: 3,
+        hashtagsApplied: 1,
+      });
+      assert.isTrue(
+        queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+      assert.equal(
+        queryAll<HTMLButtonElement>(element, 'button.chip')[2].innerText,
+        'foo'
+      );
+    });
+
+    test('shows error when add hashtag fails', async () => {
+      const getHashtagsStub = stubRestApi(
+        'getChangesWithSimilarHashtag'
+      ).resolves([]);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#add-hashtag-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Adding hashtag...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to add'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('cannot add existing hashtag already on selected changes', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      // selects "sharedHashtag"
+      queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled
+      );
+    });
+  });
+});
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 43f6730..342b876 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
@@ -1,55 +1,48 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../styles/gr-change-list-styles';
-import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-limited-text/gr-limited-text';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import '../gr-change-list-column/gr-change-list-column';
+import '../gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary';
+import '../gr-change-list-column-requirement/gr-change-list-column-requirement';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-list-item_html';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {getDisplayName} from '../../../utils/display-name-util';
-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 {getAppContext} from '../../../services/app-context';
 import {truncatePath} from '../../../utils/path-list-util';
 import {changeStatuses} from '../../../utils/change-util';
 import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {customElement, property} from '@polymer/decorators';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {
   ChangeInfo,
   ServerInfo,
   AccountInfo,
-  QuickLabelInfo,
   Timestamp,
+  NumericChangeId,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {pluralize} from '../../../utils/string-util';
-import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
+import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
+import {changeListStyles} from '../../../styles/gr-change-list-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {ChangeStatus, ColumnNames, WAITING} from '../../../constants/constants';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {classMap} from 'lit/directives/class-map.js';
+import {createSearchUrl} from '../../../models/views/search';
+import {createChangeUrl} from '../../../models/views/change';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 enum ChangeSize {
   XS = 10,
@@ -69,20 +62,17 @@
   REJECTED = 'REJECTED',
 }
 
-export interface ChangeListToggleReviewedDetail {
-  change: ChangeInfo;
-  reviewed: boolean;
-}
-
 // How many reviewers should be shown with an account-label?
 const PRIMARY_REVIEWERS_COUNT = 2;
 
-@customElement('gr-change-list-item')
-export class GrChangeListItem extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-item': GrChangeListItem;
   }
+}
 
+@customElement('gr-change-list-item')
+export class GrChangeListItem extends LitElement {
   /** The logged-in user's account, or null if no user is logged in. */
   @property({type: Object})
   account: AccountInfo | null = null;
@@ -103,226 +93,637 @@
   @property({type: String})
   sectionName?: string;
 
-  @property({type: String, computed: '_computeChangeURL(change)'})
-  changeURL?: string;
-
-  @property({type: Array, computed: '_changeStatuses(change)'})
-  statuses?: ChangeStates[];
-
   @property({type: Boolean})
   showStar = false;
 
   @property({type: Boolean})
   showNumber = false;
 
-  @property({type: String, computed: '_computeChangeSize(change)'})
-  _changeSize?: string;
+  @property({type: String})
+  usp?: string;
 
-  @property({type: Array})
-  _dynamicCellEndpoints?: string[];
+  /** Index of the item in the overall list. */
+  @property({type: Number})
+  globalIndex = 0;
 
-  reporting: ReportingService = appContext.reportingService;
+  /** Callback to call to request the item to be selected in the list. */
+  @property({type: Function})
+  triggerSelectionCallback?: (globalIndex: number) => void;
+
+  @property({type: Boolean, reflect: true}) selected = false;
+
+  // private but used in tests
+  @property({type: Boolean, reflect: true}) checked = false;
+
+  @state() private dynamicCellEndpoints?: string[];
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChangeNums$,
+      selectedChangeNums => {
+        this.updateCheckedState(selectedChangeNums);
+      }
+    );
+  }
 
   override connectedCallback() {
     super.connectedCallback();
-    getPluginLoader()
+    this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-list-item-cell'
-        );
+        this.dynamicCellEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-list-item-cell'
+          );
       });
+    this.addEventListener('click', this.onItemClick);
   }
 
-  _changeStatuses(change?: ChangeInfo) {
-    if (!change) return [];
-    return changeStatuses(change);
+  override disconnectedCallback() {
+    this.removeEventListener('click', this.onItemClick);
   }
 
-  _computeChangeURL(change?: ChangeInfo) {
-    if (!change) return '';
-    return GerritNav.getUrlForChange(change);
-  }
-
-  _computeLabelTitle(change: ChangeInfo | undefined, labelName: string) {
-    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
-    const category = this._computeLabelCategory(change, labelName);
-    if (!label || category === LabelCategory.NOT_APPLICABLE) {
-      return 'Label not applicable';
+  override willUpdate(changedProperties: PropertyValues<this>) {
+    // When the cursor selects this item, give it focus so that the item is read
+    // out by screen readers and lets users start tabbing through the item
+    if (this.selected && changedProperties.has('selected')) {
+      this.focus();
     }
-    const titleParts: string[] = [];
-    if (category === LabelCategory.UNRESOLVED_COMMENTS) {
-      const num = change?.unresolved_comment_count ?? 0;
-      titleParts.push(pluralize(num, 'unresolved comment'));
-    }
-    const significantLabel =
-      label.rejected || label.approved || label.disliked || label.recommended;
-    if (significantLabel?.name) {
-      titleParts.push(`${labelName} by ${significantLabel.name}`);
-    }
-    if (titleParts.length > 0) {
-      return titleParts.join(',\n');
-    }
-    return labelName;
-  }
 
-  _computeLabelClass(change: ChangeInfo | undefined, labelName: string) {
-    const category = this._computeLabelCategory(change, labelName);
-    const classes = ['cell', 'label'];
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        classes.push('u-gray-background');
-        break;
-      case LabelCategory.APPROVED:
-        classes.push('u-green');
-        break;
-      case LabelCategory.POSITIVE:
-        classes.push('u-monospace');
-        classes.push('u-green');
-        break;
-      case LabelCategory.NEGATIVE:
-        classes.push('u-monospace');
-        classes.push('u-red');
-        break;
-      case LabelCategory.REJECTED:
-        classes.push('u-red');
-        break;
-    }
-    return classes.sort().join(' ');
-  }
-
-  _computeHasLabelIcon(change: ChangeInfo | undefined, labelName: string) {
-    return this._computeLabelIcon(change, labelName) !== '';
-  }
-
-  _computeLabelIcon(change: ChangeInfo | undefined, labelName: string): string {
-    const category = this._computeLabelCategory(change, labelName);
-    switch (category) {
-      case LabelCategory.APPROVED:
-        return 'gr-icons:check';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'gr-icons:comment';
-      case LabelCategory.REJECTED:
-        return 'gr-icons:close';
-      default:
-        return '';
+    if (changedProperties.has('change')) {
+      this.updateCheckedState(
+        this.getBulkActionsModel().getState().selectedChangeNums
+      );
     }
   }
 
-  _computeLabelCategory(change: ChangeInfo | undefined, labelName: string) {
-    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
-    if (!label) {
-      return LabelCategory.NOT_APPLICABLE;
+  private updateCheckedState(selectedChangeNums: NumericChangeId[]) {
+    if (!this.change) {
+      this.checked = false;
+      return;
     }
-    if (label.rejected) {
-      return LabelCategory.REJECTED;
-    }
-    if (label.value && label.value < 0) {
-      return LabelCategory.NEGATIVE;
-    }
-    if (change?.unresolved_comment_count && labelName === 'Code-Review') {
-      return LabelCategory.UNRESOLVED_COMMENTS;
-    }
-    if (label.approved) {
-      return LabelCategory.APPROVED;
-    }
-    if (label.value && label.value > 0) {
-      return LabelCategory.POSITIVE;
-    }
-    return LabelCategory.NEUTRAL;
+    this.checked = selectedChangeNums.includes(this.change._number);
   }
 
-  _computeLabelValue(change: ChangeInfo | undefined, labelName: string) {
-    const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
-    const category = this._computeLabelCategory(change, labelName);
-    switch (category) {
-      case LabelCategory.NOT_APPLICABLE:
-        return '';
-      case LabelCategory.APPROVED:
-        return '\u2713'; // ✓
-      case LabelCategory.POSITIVE:
-        return `+${label?.value}`;
-      case LabelCategory.NEUTRAL:
-        return '';
-      case LabelCategory.UNRESOLVED_COMMENTS:
-        return 'u';
-      case LabelCategory.NEGATIVE:
-        return `${label?.value}`;
-      case LabelCategory.REJECTED:
-        return '\u2715'; // ✕
-    }
+  static override get styles() {
+    return [
+      changeListStyles,
+      sharedStyles,
+      submitRequirementsStyles,
+      css`
+        :host {
+          display: table-row;
+          color: var(--primary-text-color);
+        }
+        :host(:focus) {
+          outline: none;
+        }
+        :host([checked]),
+        :host(:hover) {
+          background-color: var(--hover-background-color);
+        }
+        .container {
+          position: relative;
+        }
+        .strikethrough {
+          color: var(--deemphasized-text-color);
+          text-decoration: line-through;
+        }
+        .content {
+          overflow: hidden;
+          position: absolute;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+          width: 100%;
+        }
+        .content a {
+          display: block;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+          width: 100%;
+        }
+        .comments,
+        .reviewers,
+        .requirements {
+          white-space: nowrap;
+        }
+        .reviewers {
+          --account-max-length: 70px;
+        }
+        .spacer {
+          height: 0;
+          overflow: hidden;
+        }
+        .status {
+          align-items: center;
+          display: inline-flex;
+        }
+        .status .comma {
+          padding-right: var(--spacing-xs);
+        }
+        /* Used to hide the leading separator comma for statuses. */
+        .status .comma:first-of-type {
+          display: none;
+        }
+        .size gr-tooltip-content {
+          margin: -0.4rem -0.6rem;
+          max-width: 2.5rem;
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        .size span {
+          border-radius: var(--border-radius);
+          color: var(--dashboard-size-text);
+          font-size: var(--font-size-small);
+          /* To set height and width of span, it has to be inline block */
+          display: inline-block;
+          height: 20px;
+          width: 20px;
+          text-align: center;
+          vertical-align: top;
+        }
+        .size span.size-xs {
+          background-color: var(--dashboard-size-xs);
+          color: var(--dashboard-size-xs-text);
+        }
+        .size span.size-s {
+          background-color: var(--dashboard-size-s);
+        }
+        .size span.size-m {
+          background-color: var(--dashboard-size-m);
+        }
+        .size span.size-l {
+          background-color: var(--dashboard-size-l);
+        }
+        .size span.size-xl {
+          background-color: var(--dashboard-size-xl);
+          color: var(--dashboard-size-xl-text);
+        }
+        a {
+          color: inherit;
+          cursor: pointer;
+          text-decoration: none;
+        }
+        a:hover {
+          text-decoration: underline;
+        }
+        .subject:hover .content {
+          text-decoration: underline;
+        }
+        .comma,
+        .placeholder {
+          color: var(--deemphasized-text-color);
+        }
+        .cell.selection input {
+          vertical-align: middle;
+        }
+        .selectionLabel {
+          padding: 10px;
+          margin: -10px;
+          display: block;
+        }
+        .cell.label {
+          font-weight: var(--font-weight-normal);
+        }
+        .cell.label gr-icon {
+          vertical-align: top;
+        }
+        /* Requirement child needs whole area */
+        .cell.requirement {
+          padding: 0;
+          margin: 0;
+        }
+        @media only screen and (max-width: 50em) {
+          :host {
+            display: flex;
+          }
+        }
+      `,
+    ];
   }
 
-  _computeRepoUrl(change?: ChangeInfo) {
-    if (!change) return '';
-    return GerritNav.getUrlForProjectChanges(
-      change.project,
-      true,
-      change.internalHost
-    );
+  override render() {
+    const changeUrl = this.computeChangeURL();
+    return html`
+      <td aria-hidden="true" class="cell leftPadding"></td>
+      ${this.renderCellSelectionBox()} ${this.renderCellStar()}
+      ${this.renderCellNumber(changeUrl)} ${this.renderCellSubject(changeUrl)}
+      ${this.renderCellStatus()} ${this.renderCellOwner()}
+      ${this.renderCellReviewers()} ${this.renderCellComments()}
+      ${this.renderCellRepo()} ${this.renderCellBranch()}
+      ${this.renderCellUpdated()} ${this.renderCellSubmitted()}
+      ${this.renderCellWaiting()} ${this.renderCellSize()}
+      ${this.renderCellRequirements()}
+      ${this.labelNames?.map(labelNames => this.renderChangeLabels(labelNames))}
+      ${this.dynamicCellEndpoints?.map(pluginEndpointName =>
+        this.renderChangePluginEndpoint(pluginEndpointName)
+      )}
+    `;
   }
 
-  _computeRepoBranchURL(change?: ChangeInfo) {
-    if (!change) return '';
-    return GerritNav.getUrlForBranch(
-      change.branch,
-      change.project,
-      undefined,
-      change.internalHost
-    );
+  private renderCellSelectionBox() {
+    return html`
+      <td class="cell selection">
+        <!--
+          The .checked property must be used rather than the attribute because
+          the attribute only controls the default checked state and does not
+          update the current checked state.
+          See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#attr-checked
+        -->
+        <label class="selectionLabel">
+          <input
+            type="checkbox"
+            .checked=${this.checked}
+            @click=${this.toggleCheckbox}
+          />
+        </label>
+      </td>
+    `;
   }
 
-  _computeTopicURL(change?: ChangeInfo) {
-    if (!change?.topic) {
-      return '';
-    }
-    return GerritNav.getUrlForTopic(change.topic, change.internalHost);
+  private renderCellStar() {
+    if (!this.showStar) return;
+
+    return html`
+      <td class="cell star">
+        <gr-change-star .change=${this.change}></gr-change-star>
+      </td>
+    `;
   }
 
-  /**
-   * Computes the display string for the project column. If there is a host
-   * specified in the change detail, the string will be prefixed with it.
-   *
-   * @param truncate whether or not the project name should be
-   * truncated. If this value is truthy, the name will be truncated.
-   */
-  _computeRepoDisplay(change?: ChangeInfo) {
-    if (!change?.project) {
-      return '';
-    }
-    let str = '';
-    if (change.internalHost) {
-      str += change.internalHost + '/';
-    }
-    str += change.project;
-    return str;
+  private renderCellNumber(changeUrl: string) {
+    if (!this.showNumber) return;
+
+    return html`
+      <td class="cell number">
+        <a href=${changeUrl}>${this.change?._number}</a>
+      </td>
+    `;
   }
 
-  _computeTruncatedRepoDisplay(change?: ChangeInfo) {
-    if (!change?.project) {
-      return '';
-    }
-    let str = '';
-    if (change.internalHost) {
-      str += change.internalHost + '/';
-    }
-    str += truncatePath(change.project, 2);
-    return str;
-  }
-
-  _computeSizeTooltip(change?: ChangeInfo) {
+  private renderCellSubject(changeUrl: string) {
     if (
-      !change ||
-      change.insertions + change.deletions === 0 ||
-      isNaN(change.insertions + change.deletions)
+      this.computeIsColumnHidden(
+        ColumnNames.SUBJECT,
+        this.visibleChangeTableColumns
+      )
+    )
+      return;
+
+    return html`
+      <td class="cell subject">
+        <a
+          title=${ifDefined(this.change?.subject)}
+          href=${changeUrl}
+          @click=${this.handleChangeClick}
+        >
+          <div class="container">
+            <div
+              class=${classMap({
+                content: true,
+                strikethrough: this.change?.status === ChangeStatus.ABANDONED,
+              })}
+            >
+              ${this.change?.subject}
+            </div>
+            <div class="spacer">${this.change?.subject}</div>
+            <span>&nbsp;</span>
+          </div>
+        </a>
+      </td>
+    `;
+  }
+
+  private renderCellStatus() {
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.STATUS,
+        this.visibleChangeTableColumns
+      )
+    )
+      return;
+
+    return html` <td class="cell status">${this.renderChangeStatus()}</td> `;
+  }
+
+  private renderChangeStatus() {
+    if (!this.changeStatuses().length) {
+      return html`<span class="placeholder">--</span>`;
+    }
+
+    return this.changeStatuses().map(
+      status => html`
+        <div class="comma">,</div>
+        <gr-change-status flat .status=${status}></gr-change-status>
+      `
+    );
+  }
+
+  private renderCellOwner() {
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.OWNER,
+        this.visibleChangeTableColumns
+      )
+    )
+      return;
+
+    return html`
+      <td class="cell owner">
+        <gr-account-label
+          highlightAttention
+          clickable
+          .change=${this.change}
+          .account=${this.change?.owner}
+        ></gr-account-label>
+      </td>
+    `;
+  }
+
+  private renderCellReviewers() {
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.REVIEWERS,
+        this.visibleChangeTableColumns
+      )
+    )
+      return;
+
+    return html`
+      <td class="cell reviewers">
+        <div>
+          ${this.computePrimaryReviewers().map((reviewer, index) =>
+            this.renderChangeReviewers(reviewer, index)
+          )}
+          ${this.computeAdditionalReviewersCount()
+            ? html`<span title=${this.computeAdditionalReviewersTitle()}
+                >+${this.computeAdditionalReviewersCount()}</span
+              >`
+            : ''}
+        </div>
+      </td>
+    `;
+  }
+
+  private renderChangeReviewers(reviewer: AccountInfo, index: number) {
+    return html`
+      <gr-account-label
+        clickable
+        hideAvatar
+        firstName
+        highlightAttention
+        .change=${this.change}
+        .account=${reviewer}
+      ></gr-account-label
+      ><span ?hidden=${this.computeCommaHidden(index)} aria-hidden="true"
+        >,
+      </span>
+    `;
+  }
+
+  private renderCellComments() {
+    if (this.computeIsColumnHidden('Comments', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell comments">
+        ${this.change?.unresolved_comment_count
+          ? html`<gr-icon icon="mode_comment" filled></gr-icon>`
+          : ''}
+        <span
+          >${this.computeComments(this.change?.unresolved_comment_count)}</span
+        >
+      </td>
+    `;
+  }
+
+  private renderCellRepo() {
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.REPO,
+        this.visibleChangeTableColumns
+      )
+    ) {
+      return;
+    }
+
+    const repo = this.change?.project ?? '';
+    return html`
+      <td class="cell repo">
+        <a class="fullRepo" href=${this.computeRepoUrl()}> ${repo} </a>
+        <a class="truncatedRepo" href=${this.computeRepoUrl()} title=${repo}>
+          ${truncatePath(repo, 2)}
+        </a>
+      </td>
+    `;
+  }
+
+  private renderCellBranch() {
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.BRANCH,
+        this.visibleChangeTableColumns
+      )
+    )
+      return;
+
+    return html`
+      <td class="cell branch">
+        <a href=${this.computeRepoBranchURL()}> ${this.change?.branch} </a>
+        ${this.renderChangeBranch()}
+      </td>
+    `;
+  }
+
+  private renderChangeBranch() {
+    if (!this.change?.topic) return;
+
+    return html`
+      (<a href=${this.computeTopicURL()}
+        ><!--
+      --><gr-limited-text .limit=${50} .text=${this.change.topic}>
+        </gr-limited-text
+        ><!--
+    --></a
+      >)
+    `;
+  }
+
+  private renderCellUpdated() {
+    if (this.computeIsColumnHidden('Updated', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell updated">
+        <gr-date-formatter
+          withTooltip
+          .dateStr=${this.formatDate(this.change?.updated)}
+        ></gr-date-formatter>
+      </td>
+    `;
+  }
+
+  private renderCellSubmitted() {
+    if (this.computeIsColumnHidden('Submitted', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell submitted">
+        <gr-date-formatter
+          withTooltip
+          .dateStr=${this.formatDate(this.change?.submitted)}
+        ></gr-date-formatter>
+      </td>
+    `;
+  }
+
+  private renderCellWaiting() {
+    if (this.computeIsColumnHidden(WAITING, this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell waiting">
+        <gr-date-formatter
+          withTooltip
+          forceRelative
+          relativeOptionNoAgo
+          .dateStr=${this.computeWaiting()}
+        ></gr-date-formatter>
+      </td>
+    `;
+  }
+
+  private renderCellSize() {
+    if (this.computeIsColumnHidden('Size', this.visibleChangeTableColumns))
+      return;
+
+    return html`
+      <td class="cell size">
+        <gr-tooltip-content has-tooltip title=${this.computeSizeTooltip()}>
+          ${this.renderChangeSize()}
+        </gr-tooltip-content>
+      </td>
+    `;
+  }
+
+  private renderChangeSize() {
+    const changeSize = this.computeChangeSize();
+    if (!changeSize) return html`<span class="placeholder">--</span>`;
+
+    return html`
+      <span class="size-${changeSize.toLowerCase()}">${changeSize}</span>
+    `;
+  }
+
+  private renderCellRequirements() {
+    if (
+      this.computeIsColumnHidden(
+        ColumnNames.STATUS2,
+        this.visibleChangeTableColumns
+      )
+    )
+      return;
+
+    return html`
+      <td class="cell requirements">
+        <gr-change-list-column-requirements-summary .change=${this.change}>
+        </gr-change-list-column-requirements-summary>
+      </td>
+    `;
+  }
+
+  private renderChangeLabels(labelName: string) {
+    return html` <td class="cell label requirement">
+      <gr-change-list-column-requirement
+        .change=${this.change}
+        .labelName=${labelName}
+      >
+      </gr-change-list-column-requirement>
+    </td>`;
+  }
+
+  private renderChangePluginEndpoint(pluginEndpointName: string) {
+    return html`
+      <td class="cell endpoint">
+        <gr-endpoint-decorator name=${pluginEndpointName}>
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </td>
+    `;
+  }
+
+  private readonly onItemClick = (e: Event) => {
+    // Check the path to verify that the item row itself was directly clicked.
+    // This will allow users using screen readers like VoiceOver to select an
+    // item with j/k and go to the selected change with Ctrl+Option+Space, but
+    // not interfere with clicks on interactive elements within the
+    // gr-change-list-item such as account links, which will bubble through
+    // without triggering this extra navigation.
+    if (this.change && e.composedPath()[0] === this) {
+      this.getNavigation().setUrl(createChangeUrl({change: this.change}));
+    }
+  };
+
+  private changeStatuses() {
+    if (!this.change) return [];
+    return changeStatuses(this.change);
+  }
+
+  private computeChangeURL() {
+    if (!this.change) return '';
+    return createChangeUrl({change: this.change, usp: this.usp});
+  }
+
+  private computeRepoUrl() {
+    if (!this.change) return '';
+    return createSearchUrl({repo: this.change.project, statuses: ['open']});
+  }
+
+  private computeRepoBranchURL() {
+    if (!this.change) return '';
+    return createSearchUrl({
+      branch: this.change.branch,
+      repo: this.change.project,
+    });
+  }
+
+  private computeTopicURL() {
+    if (!this.change?.topic) return '';
+    return createSearchUrl({topic: this.change.topic});
+  }
+
+  private toggleCheckbox() {
+    assertIsDefined(this.change, 'change');
+    this.checked = !this.checked;
+    this.triggerSelectionCallback?.(this.globalIndex);
+    this.getBulkActionsModel().toggleSelectedChangeNum(this.change._number);
+  }
+
+  // private but used in test
+  computeSizeTooltip() {
+    if (
+      !this.change ||
+      this.change.insertions + this.change.deletions === 0 ||
+      isNaN(this.change.insertions + this.change.deletions)
     ) {
       return 'Size unknown';
     } else {
-      return `added ${change.insertions}, removed ${change.deletions} lines`;
+      return `added ${this.change.insertions}, removed ${this.change.deletions} lines`;
     }
   }
 
-  _hasAttention(account: AccountInfo) {
+  private hasAttention(account: AccountInfo) {
     if (!this.change || !this.change.attention_set || !account._account_id) {
       return false;
     }
@@ -332,12 +733,15 @@
   /**
    * Computes the array of all reviewers with sorting the reviewers in the
    * attention set before others, and the current user first.
+   *
+   * private but used in test
    */
-  _computeReviewers(change?: ChangeInfo) {
-    if (!change?.reviewers || !change?.reviewers.REVIEWER) return [];
-    const reviewers = [...change.reviewers.REVIEWER].filter(
+  computeReviewers() {
+    if (!this.change?.reviewers || !this.change?.reviewers.REVIEWER) return [];
+    const reviewers = [...this.change.reviewers.REVIEWER].filter(
       r =>
-        (!change.owner || change.owner._account_id !== r._account_id) &&
+        (!this.change?.owner ||
+          this.change?.owner._account_id !== r._account_id) &&
         !isServiceUser(r)
     );
     reviewers.sort((r1, r2) => {
@@ -345,33 +749,33 @@
         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;
+      if (this.hasAttention(r1) && !this.hasAttention(r2)) return -1;
+      if (this.hasAttention(r2) && !this.hasAttention(r1)) return 1;
       return (r1.name || '').localeCompare(r2.name || '');
     });
     return reviewers;
   }
 
-  _computePrimaryReviewers(change?: ChangeInfo) {
-    return this._computeReviewers(change).slice(0, PRIMARY_REVIEWERS_COUNT);
+  private computePrimaryReviewers() {
+    return this.computeReviewers().slice(0, PRIMARY_REVIEWERS_COUNT);
   }
 
-  _computeAdditionalReviewers(change?: ChangeInfo) {
-    return this._computeReviewers(change).slice(PRIMARY_REVIEWERS_COUNT);
+  private computeAdditionalReviewers() {
+    return this.computeReviewers().slice(PRIMARY_REVIEWERS_COUNT);
   }
 
-  _computeAdditionalReviewersCount(change?: ChangeInfo) {
-    return this._computeAdditionalReviewers(change).length;
+  private computeAdditionalReviewersCount() {
+    return this.computeAdditionalReviewers().length;
   }
 
-  _computeAdditionalReviewersTitle(change?: ChangeInfo, config?: ServerInfo) {
-    if (!change || !config) return '';
-    return this._computeAdditionalReviewers(change)
-      .map(user => getDisplayName(config, user, true))
+  private computeAdditionalReviewersTitle() {
+    if (!this.change || !this.config) return '';
+    return this.computeAdditionalReviewers()
+      .map(user => getDisplayName(this.config, user, true))
       .join(', ');
   }
 
-  _computeComments(unresolved_comment_count?: number) {
+  private computeComments(unresolved_comment_count?: number) {
     if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
     return `${unresolved_comment_count} unresolved`;
   }
@@ -379,10 +783,12 @@
   /**
    * TShirt sizing is based on the following paper:
    * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
+   *
+   * private but used in test
    */
-  _computeChangeSize(change?: ChangeInfo) {
-    if (!change) return null;
-    const delta = change.insertions + change.deletions;
+  computeChangeSize() {
+    if (!this.change) return null;
+    const delta = this.change.insertions + this.change.deletions;
     if (isNaN(delta) || delta === 0) {
       return null; // Unknown
     }
@@ -399,44 +805,28 @@
     }
   }
 
-  _computeWaiting(
-    account?: AccountInfo | null,
-    change?: ChangeInfo | null
-  ): Timestamp | undefined {
-    if (!account?._account_id || !change?.attention_set) return undefined;
-    return change?.attention_set[account._account_id]?.last_update;
+  private computeWaiting(): Timestamp | undefined {
+    if (!this.account?._account_id || !this.change?.attention_set)
+      return undefined;
+    return this.change?.attention_set[this.account._account_id]?.last_update;
   }
 
-  _computeIsColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
+  private computeIsColumnHidden(
+    columnToCheck?: string,
+    columnsToDisplay?: string[]
+  ) {
     if (!columnsToDisplay || !columnToCheck) {
       return false;
     }
     return !columnsToDisplay.includes(columnToCheck);
   }
 
-  toggleReviewed() {
-    if (!this.change) return;
-    const newVal = !this.change?.reviewed;
-    this.set('change.reviewed', newVal);
-    const detail: ChangeListToggleReviewedDetail = {
-      change: this.change,
-      reviewed: newVal,
-    };
-    this.dispatchEvent(
-      new CustomEvent('toggle-reviewed', {
-        bubbles: true,
-        composed: true,
-        detail,
-      })
-    );
-  }
-
-  _formatDate(date: Timestamp | undefined): string | undefined {
+  private formatDate(date: Timestamp | undefined): string | undefined {
     if (!date) return undefined;
     return date.toString();
   }
 
-  _handleChangeClick() {
+  private handleChangeClick() {
     // Don't prevent the default and neither stop bubbling. We just want to
     // report the click, but then let the browser handle the click on the link.
 
@@ -450,19 +840,10 @@
     });
   }
 
-  _computeCommaHidden(index?: number, change?: ChangeInfo) {
-    if (index === undefined) return false;
-    if (change === undefined) return false;
-
-    const additionalCount = this._computeAdditionalReviewersCount(change);
-    const primaryCount = this._computePrimaryReviewers(change).length;
+  private computeCommaHidden(index: number) {
+    const additionalCount = this.computeAdditionalReviewersCount();
+    const primaryCount = this.computePrimaryReviewers().length;
     const isLast = index === primaryCount - 1;
     return isLast && additionalCount === 0;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-change-list-item': GrChangeListItem;
-  }
-}
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
deleted file mode 100644
index cd55e15..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ /dev/null
@@ -1,326 +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: table-row;
-      color: var(--primary-text-color);
-    }
-    :host(:focus) {
-      outline: none;
-    }
-    :host(:hover) {
-      background-color: var(--hover-background-color);
-    }
-    .container {
-      position: relative;
-    }
-    .content {
-      overflow: hidden;
-      position: absolute;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      width: 100%;
-    }
-    .content a {
-      display: block;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-      width: 100%;
-    }
-    .comments,
-    .reviewers,
-    .requirements {
-      white-space: nowrap;
-    }
-    .reviewers {
-      --account-max-length: 70px;
-    }
-    .spacer {
-      height: 0;
-      overflow: hidden;
-    }
-    .status {
-      align-items: center;
-      display: inline-flex;
-    }
-    .status .comma {
-      padding-right: var(--spacing-xs);
-    }
-    /* Used to hide the leading separator comma for statuses. */
-    .status .comma:first-of-type {
-      display: none;
-    }
-    .size gr-tooltip-content {
-      margin: -0.4rem -0.6rem;
-      max-width: 2.5rem;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    a {
-      color: inherit;
-      cursor: pointer;
-      text-decoration: none;
-    }
-    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);
-      line-height: var(--line-height-mono);
-    }
-    .u-green,
-    .u-green iron-icon {
-      color: var(--positive-green-text-color);
-    }
-    .u-red,
-    .u-red iron-icon {
-      color: var(--negative-red-text-color);
-    }
-    .u-gray-background {
-      background-color: var(--table-header-background-color);
-    }
-    .comma,
-    .placeholder {
-      color: var(--deemphasized-text-color);
-    }
-    .cell.label {
-      font-weight: var(--font-weight-normal);
-    }
-    .cell.label iron-icon {
-      vertical-align: top;
-    }
-    @media only screen and (max-width: 50em) {
-      :host {
-        display: flex;
-      }
-    }
-  </style>
-  <style include="gr-change-list-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <td aria-hidden="true" class="cell leftPadding"></td>
-  <td class="cell star" hidden$="[[!showStar]]" hidden="">
-    <gr-change-star change="[[change]]"></gr-change-star>
-  </td>
-  <td class="cell number" hidden$="[[!showNumber]]" hidden="">
-    <a href$="[[changeURL]]">[[change._number]]</a>
-  </td>
-  <td
-    class="cell subject"
-    hidden$="[[_computeIsColumnHidden('Subject', visibleChangeTableColumns)]]"
-  >
-    <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>
-    </a>
-  </td>
-  <td
-    class="cell status"
-    hidden$="[[_computeIsColumnHidden('Status', visibleChangeTableColumns)]]"
-  >
-    <template is="dom-repeat" items="[[statuses]]" as="status">
-      <div class="comma">,</div>
-      <gr-change-status flat="" status="[[status]]"></gr-change-status>
-    </template>
-    <template is="dom-if" if="[[!statuses.length]]">
-      <span class="placeholder">--</span>
-    </template>
-  </td>
-  <td
-    class="cell owner"
-    hidden$="[[_computeIsColumnHidden('Owner', visibleChangeTableColumns)]]"
-  >
-    <gr-account-link
-      highlightAttention
-      change="[[change]]"
-      account="[[change.owner]]"
-    ></gr-account-link>
-  </td>
-  <td
-    class="cell assignee"
-    hidden$="[[_computeIsColumnHidden('Assignee', visibleChangeTableColumns)]]"
-  >
-    <template is="dom-if" if="[[change.assignee]]">
-      <gr-account-link
-        id="assigneeAccountLink"
-        account="[[change.assignee]]"
-      ></gr-account-link>
-    </template>
-    <template is="dom-if" if="[[!change.assignee]]">
-      <span class="placeholder">--</span>
-    </template>
-  </td>
-  <td
-    class="cell reviewers"
-    hidden$="[[_computeIsColumnHidden('Reviewers', visibleChangeTableColumns)]]"
-  >
-    <div>
-      <template
-        is="dom-repeat"
-        items="[[_computePrimaryReviewers(change)]]"
-        as="reviewer"
-        indexAs="index"
-      >
-        <gr-account-link
-          hideAvatar=""
-          hideStatus=""
-          firstName
-          highlightAttention
-          change="[[change]]"
-          account="[[reviewer]]"
-        ></gr-account-link
-        ><span
-          hidden$="[[_computeCommaHidden(index, change)]]"
-          aria-hidden="true"
-          >,
-        </span>
-      </template>
-      <template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
-        <span title="[[_computeAdditionalReviewersTitle(change, config)]]">
-          +[[_computeAdditionalReviewersCount(change)]]
-        </span>
-      </template>
-    </div>
-  </td>
-  <td
-    class="cell comments"
-    hidden$="[[_computeIsColumnHidden('Comments', visibleChangeTableColumns)]]"
-  >
-    <iron-icon
-      hidden$="[[!change.unresolved_comment_count]]"
-      icon="gr-icons:comment"
-    ></iron-icon>
-    <span>[[_computeComments(change.unresolved_comment_count)]]</span>
-  </td>
-  <td
-    class="cell repo"
-    hidden$="[[_computeIsColumnHidden('Repo', visibleChangeTableColumns)]]"
-  >
-    <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
-      [[_computeRepoDisplay(change)]]
-    </a>
-    <a
-      class="truncatedRepo"
-      href$="[[_computeRepoUrl(change)]]"
-      title$="[[_computeRepoDisplay(change)]]"
-    >
-      [[_computeTruncatedRepoDisplay(change)]]
-    </a>
-  </td>
-  <td
-    class="cell branch"
-    hidden$="[[_computeIsColumnHidden('Branch', visibleChangeTableColumns)]]"
-  >
-    <a href$="[[_computeRepoBranchURL(change)]]"> [[change.branch]] </a>
-    <template is="dom-if" if="[[change.topic]]">
-      (<a href$="[[_computeTopicURL(change)]]"
-        ><!--
-       --><gr-limited-text limit="50" text="[[change.topic]]"> </gr-limited-text
-        ><!--
-     --></a
-      >)
-    </template>
-  </td>
-  <td
-    class="cell updated"
-    hidden$="[[_computeIsColumnHidden('Updated', visibleChangeTableColumns)]]"
-  >
-    <gr-date-formatter
-      withTooltip
-      date-str="[[_formatDate(change.updated)]]"
-    ></gr-date-formatter>
-  </td>
-  <td
-    class="cell submitted"
-    hidden$="[[_computeIsColumnHidden('Submitted', visibleChangeTableColumns)]]"
-  >
-    <gr-date-formatter
-      withTooltip
-      date-str="[[_formatDate(change.submitted)]]"
-    ></gr-date-formatter>
-  </td>
-  <td
-    class="cell waiting"
-    hidden$="[[_computeIsColumnHidden('Waiting', visibleChangeTableColumns)]]"
-  >
-    <gr-date-formatter
-      withTooltip
-      forceRelative
-      relativeOptionNoAgo
-      date-str="[[_computeWaiting(account, change)]]"
-    ></gr-date-formatter>
-  </td>
-  <td
-    class="cell size"
-    hidden$="[[_computeIsColumnHidden('Size', visibleChangeTableColumns)]]"
-  >
-    <gr-tooltip-content has-tooltip title="[[_computeSizeTooltip(change)]]">
-      <template is="dom-if" if="[[_changeSize]]">
-        <span>[[_changeSize]]</span>
-      </template>
-      <template is="dom-if" if="[[!_changeSize]]">
-        <span class="placeholder">--</span>
-      </template>
-    </gr-tooltip-content>
-  </td>
-  <td
-    class="cell requirements"
-    hidden$="[[_computeIsColumnHidden('Requirements', visibleChangeTableColumns)]]"
-  >
-    <gr-change-list-column-requirements change="[[change]]">
-    </gr-change-list-column-requirements>
-  </td>
-  <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-    <td
-      title$="[[_computeLabelTitle(change, labelName)]]"
-      class$="[[_computeLabelClass(change, labelName)]]"
-    >
-      <template is="dom-if" if="[[_computeHasLabelIcon(change, labelName)]]">
-        <iron-icon icon="[[_computeLabelIcon(change, labelName)]]"></iron-icon>
-      </template>
-      <template is="dom-if" if="[[!_computeHasLabelIcon(change, labelName)]]">
-        <span>[[_computeLabelValue(change, labelName)]]</span>
-      </template>
-    </td>
-  </template>
-  <template
-    is="dom-repeat"
-    items="[[_dynamicCellEndpoints]]"
-    as="pluginEndpointName"
-  >
-    <td class="cell endpoint">
-      <gr-endpoint-decorator name$="[[pluginEndpointName]]">
-        <gr-endpoint-param name="change" value="[[change]]">
-        </gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </td>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index 35be3de..c7cb5b8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -1,26 +1,29 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import {
+  SubmitRequirementResultInfo,
+  NumericChangeId,
+} from '../../../api/rest-api';
+import '../../../test/common-test-setup';
 import {
   createAccountWithId,
   createChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+  createServerInfo,
 } from '../../../test/test-data-generators';
-import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  query,
+  queryAndAssert,
+  stubRestApi,
+  waitUntilObserved,
+} from '../../../test/test-utils';
 import {
   AccountId,
   BranchName,
@@ -28,386 +31,189 @@
   RepoName,
   TopicName,
 } from '../../../types/common';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {columnNames} from '../gr-change-list/gr-change-list';
+import {StandardLabels} from '../../../utils/label-util';
+import {navigationToken} 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');
+import {GrChangeListItem} from './gr-change-list-item';
+import {
+  DIProviderElement,
+  wrapInProvider,
+} from '../../../models/di-provider-element';
+import {
+  bulkActionsModelToken,
+  BulkActionsModel,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {createTestAppContext} from '../../../test/test-app-context-init';
+import {ColumnNames} from '../../../constants/constants';
+import {testResolver} from '../../../test/common-test-setup';
 
 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;
+  let bulkActionsModel: BulkActionsModel;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    element = basicFixture.instantiate();
-  });
 
-  test('_computeLabelCategory', () => {
-    assert.equal(
-      element._computeLabelCategory({...change, labels: {}}, 'Verified'),
-      LabelCategory.NOT_APPLICABLE
+    bulkActionsModel = new BulkActionsModel(
+      createTestAppContext().restApiService
     );
-    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'
-      ),
-      '✕'
-    );
+    element = (
+      await fixture<DIProviderElement>(
+        wrapInProvider(
+          html`<gr-change-list-item></gr-change-list-item>`,
+          bulkActionsModelToken,
+          bulkActionsModel
+        )
+      )
+    ).element as GrChangeListItem;
+    await element.updateComplete;
   });
 
   test('no hidden columns', async () => {
     element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-      'Requirements',
+      ColumnNames.SUBJECT,
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REVIEWERS,
+      ColumnNames.COMMENTS,
+      ColumnNames.REPO,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+      ColumnNames.SIZE,
+      ColumnNames.STATUS2,
     ];
 
-    await flush();
+    await element.updateComplete;
 
-    for (const column of columnNames) {
-      const elementClass = '.' + column.toLowerCase();
+    for (const column of Object.values(ColumnNames)) {
+      const elementClass = '.' + column.trim().toLowerCase();
       assert.isFalse(
         queryAndAssert(element, elementClass).hasAttribute('hidden')
       );
     }
   });
 
+  suite('checkbox', () => {
+    test('bulk actions checkboxes', async () => {
+      element.change = {...createChange(), _number: 1 as NumericChangeId};
+      bulkActionsModel.sync([element.change]);
+      await element.updateComplete;
+
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '.selection > .selectionLabel > input'
+      );
+      checkbox.click();
+      let selectedChangeNums = await waitUntilObserved(
+        bulkActionsModel.selectedChangeNums$,
+        s => s.length === 1
+      );
+
+      assert.deepEqual(selectedChangeNums, [1]);
+
+      checkbox.click();
+      selectedChangeNums = await waitUntilObserved(
+        bulkActionsModel.selectedChangeNums$,
+        s => s.length === 0
+      );
+
+      assert.deepEqual(selectedChangeNums, []);
+    });
+
+    test('checkbox click calls list selection callback', async () => {
+      const selectionCallback = sinon.stub();
+      element.triggerSelectionCallback = selectionCallback;
+      element.globalIndex = 5;
+      element.change = {...createChange(), _number: 1 as NumericChangeId};
+      bulkActionsModel.sync([element.change]);
+      await element.updateComplete;
+
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '.selection > .selectionLabel > input'
+      );
+      checkbox.click();
+      await element.updateComplete;
+
+      assert.isTrue(selectionCallback.calledWith(5));
+    });
+
+    test('checkbox state updates with model updates', async () => {
+      element.requestUpdate();
+      await element.updateComplete;
+
+      element.change = {...createChange(), _number: 1 as NumericChangeId};
+      bulkActionsModel.sync([element.change]);
+      bulkActionsModel.addSelectedChangeNum(element.change._number);
+      await element.updateComplete;
+
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '.selection > .selectionLabel > input'
+      );
+      assert.isTrue(checkbox.checked);
+
+      bulkActionsModel.removeSelectedChangeNum(element.change._number);
+      await element.updateComplete;
+
+      assert.isFalse(checkbox.checked);
+    });
+
+    test('checkbox state updates with change id update', async () => {
+      element.requestUpdate();
+      await element.updateComplete;
+
+      const changes = [
+        {...createChange(), _number: 1 as NumericChangeId},
+        {...createChange(), _number: 2 as NumericChangeId},
+      ];
+      element.change = changes[0];
+      bulkActionsModel.sync(changes);
+      bulkActionsModel.addSelectedChangeNum(element.change._number);
+      await element.updateComplete;
+
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '.selection > .selectionLabel > input'
+      );
+      assert.isTrue(checkbox.checked);
+
+      element.change = changes[1];
+      await element.updateComplete;
+
+      assert.isFalse(checkbox.checked);
+    });
+  });
+
   test('repo column hidden', async () => {
     element.visibleChangeTableColumns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Branch',
-      'Updated',
-      'Size',
-      'Requirements',
+      ColumnNames.SUBJECT,
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REVIEWERS,
+      ColumnNames.COMMENTS,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+      ColumnNames.SIZE,
+      ColumnNames.STATUS2,
     ];
 
-    await flush();
+    await element.updateComplete;
 
-    for (const column of columnNames) {
-      const elementClass = '.' + column.toLowerCase();
+    for (const column of Object.values(ColumnNames)) {
+      const elementClass = '.' + column.trim().toLowerCase();
       if (column === 'Repo') {
-        assert.isTrue(
-          queryAndAssert(element, elementClass).hasAttribute('hidden')
-        );
+        assert.isNotOk(query(element, elementClass));
       } else {
-        assert.isFalse(
-          queryAndAssert(element, elementClass).hasAttribute('hidden')
-        );
+        assert.isOk(query(element, elementClass));
       }
     }
   });
@@ -431,16 +237,14 @@
       attention_set: {},
     };
     for (let i = 0; i < reviewerIds.length; i++) {
-      element.change!.reviewers.REVIEWER!.push({
+      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);
+    const actual = element.computeReviewers().map(r => r._account_id);
     assert.deepEqual(actual, expected as AccountId[]);
   }
 
@@ -470,111 +274,187 @@
   test('random column does not exist', async () => {
     element.visibleChangeTableColumns = ['Bad'];
 
-    await flush();
+    await element.updateComplete;
     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(),
-      '--'
-    );
+  test('TShirt sizing tooltip', () => {
     element.change = {
       ...change,
-      assignee: {
-        name: 'test',
-        status: 'test',
-      },
+      insertions: NaN,
+      deletions: NaN,
     };
-    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'
-    );
+    assert.equal(element.computeSizeTooltip(), 'Size unknown');
+    element.change = {
+      ...change,
+      insertions: 0,
+      deletions: 0,
+    };
+    assert.equal(element.computeSizeTooltip(), 'Size unknown');
+    element.change = {
+      ...change,
+      insertions: 1,
+      deletions: 2,
+    };
+    assert.equal(element.computeSizeTooltip(), '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'
-    );
+    element.change = {
+      ...change,
+      insertions: NaN,
+      deletions: NaN,
+    };
+    assert.equal(element.computeChangeSize(), null);
+
+    element.change = {
+      ...change,
+      insertions: 1,
+      deletions: 1,
+    };
+    assert.equal(element.computeChangeSize(), 'XS');
+
+    element.change = {
+      ...change,
+      insertions: 9,
+      deletions: 1,
+    };
+    assert.equal(element.computeChangeSize(), 'S');
+
+    element.change = {
+      ...change,
+      insertions: 10,
+      deletions: 200,
+    };
+    assert.equal(element.computeChangeSize(), 'M');
+
+    element.change = {
+      ...change,
+      insertions: 99,
+      deletions: 900,
+    };
+    assert.equal(element.computeChangeSize(), 'L');
+
+    element.change = {
+      ...change,
+      insertions: 99,
+      deletions: 999,
+    };
+    assert.equal(element.computeChangeSize(), 'XL');
   });
 
-  test('change params passed to gr-navigation', async () => {
-    const navStub = sinon.stub(GerritNav);
+  test('clicking item navigates to change', async () => {
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
     element.change = change;
-    await flush();
+    await element.updateComplete;
 
-    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,
-    ]);
+    element.click();
+    await element.updateComplete;
+
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/a/test/repo/+/42');
   });
 
-  test('_computeRepoDisplay', () => {
-    assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
-    assert.equal(
-      element._computeTruncatedRepoDisplay(change),
-      'host/…/test/repo'
+  test('renders', async () => {
+    const change = createChange();
+    bulkActionsModel.sync([change]);
+    bulkActionsModel.addSelectedChangeNum(change._number);
+    element.showStar = true;
+    element.showNumber = true;
+    element.account = createAccountWithId(1);
+    element.config = createServerInfo();
+    element.change = change;
+    await element.updateComplete;
+    assert.isTrue(element.hasAttribute('checked'));
+
+    // TODO: Check table elements. The shadowDom helper does not understand
+    // tables interacting with display: contents, even wrapping the element in a
+    // table, does not help.
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <label class="selectionLabel">
+          <input type="checkbox" />
+        </label>
+        <gr-change-star></gr-change-star>
+        <a href="/c/test-project/+/42">42</a>
+        <a href="/c/test-project/+/42" title="Test subject">
+          <div class="container">
+            <div class="content">Test subject</div>
+            <div class="spacer">Test subject</div>
+            <span></span>
+          </div>
+        </a>
+        <span class="placeholder"> -- </span>
+        <gr-account-label
+          deselected=""
+          clickable=""
+          highlightattention=""
+        ></gr-account-label>
+        <div></div>
+        <span></span>
+        <a class="fullRepo" href="/q/project:test-project+status:open">
+          test-project
+        </a>
+        <a
+          class="truncatedRepo"
+          href="/q/project:test-project+status:open"
+          title="test-project"
+        >
+          test-project
+        </a>
+        <a href="/q/project:test-project+branch:test-branch"> test-branch </a>
+        <gr-date-formatter withtooltip=""></gr-date-formatter>
+        <gr-date-formatter withtooltip=""></gr-date-formatter>
+        <gr-date-formatter
+          forcerelative=""
+          relativeoptionnoago=""
+          withtooltip=""
+        >
+        </gr-date-formatter>
+        <gr-tooltip-content has-tooltip="" title="Size unknown">
+          <span class="placeholder"> -- </span>
+        </gr-tooltip-content>
+        <gr-change-list-column-requirements-summary>
+        </gr-change-list-column-requirements-summary>
+      `
     );
-    delete change.internalHost;
-    assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
-    assert.equal(element._computeTruncatedRepoDisplay(change), '…/test/repo');
+  });
+
+  test('renders requirement with new submit requirements', async () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      name: StandardLabels.CODE_REVIEW,
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    const change: ChangeInfo = {
+      ...createChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      unresolved_comment_count: 1,
+    };
+    const element = (
+      await fixture<DIProviderElement>(
+        wrapInProvider(
+          html`<gr-change-list-item
+            .change=${change}
+            .labelNames=${[StandardLabels.CODE_REVIEW]}
+          ></gr-change-list-item>`,
+          bulkActionsModelToken,
+          bulkActionsModel
+        )
+      )
+    ).element as GrChangeListItem;
+
+    const requirement = queryAndAssert(element, '.requirement');
+    assert.dom.equal(
+      requirement,
+      /* HTML */ ` <gr-change-list-column-requirement>
+      </gr-change-list-column-requirement>`
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
new file mode 100644
index 0000000..ab116f1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -0,0 +1,611 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {ProgressStatus, ReviewerState} from '../../../constants/constants';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {configModelToken} from '../../../models/config/config-model';
+import {resolve} from '../../../models/dependency';
+import {
+  AccountDetailInfo,
+  AccountInfo,
+  ChangeInfo,
+  NumericChangeId,
+  ServerInfo,
+  SuggestedReviewerGroupInfo,
+} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import {getAppContext} from '../../../services/app-context';
+import {
+  GrReviewerSuggestionsProvider,
+  ReviewerSuggestionsProvider,
+} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import '../../shared/gr-account-list/gr-account-list';
+import {getOverallStatus} from '../../../utils/bulk-flow-util';
+import {allSettled} from '../../../utils/async-util';
+import {listForSentence, pluralize} from '../../../utils/string-util';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {
+  AccountInput,
+  GrAccountList,
+} from '../../shared/gr-account-list/gr-account-list';
+import {getReplyByReason} from '../../../utils/attention-set-util';
+import {intersection} from '../../../utils/common-util';
+import {accountKey, getUserId} from '../../../utils/account-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {fireAlert, fireReload} from '../../../utils/event-util';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {Interaction} from '../../../constants/reporting';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+
+@customElement('gr-change-list-reviewer-flow')
+export class GrChangeListReviewerFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  // contents are given to gr-account-lists to mutate
+  // private but used in tests
+  @state() updatedAccountsByReviewerState: Map<ReviewerState, AccountInput[]> =
+    new Map([
+      [ReviewerState.REVIEWER, []],
+      [ReviewerState.CC, []],
+    ]);
+
+  @state() private suggestionsProviderByReviewerState: Map<
+    ReviewerState,
+    ReviewerSuggestionsProvider
+  > = new Map();
+
+  @state() private progressByChangeNum = new Map<
+    NumericChangeId,
+    ProgressStatus
+  >();
+
+  @state() private isOverlayOpen = false;
+
+  @state() private serverConfig?: ServerInfo;
+
+  @state()
+  private groupPendingConfirmationByReviewerState: Map<
+    ReviewerState,
+    SuggestedReviewerGroupInfo | null
+  > = new Map([
+    [ReviewerState.REVIEWER, null],
+    [ReviewerState.CC, null],
+  ]);
+
+  @query('dialog#flow') private modal?: HTMLDialogElement;
+
+  @query('gr-account-list#reviewer-list') private reviewerList?: GrAccountList;
+
+  @query('gr-account-list#cc-list') private ccList?: GrAccountList;
+
+  @query('dialog#confirm-reviewer')
+  private reviewerConfirmModal?: HTMLDialogElement;
+
+  @query('dialog#confirm-cc') private ccConfirmModal?: HTMLDialogElement;
+
+  @query('gr-dialog') dialog?: GrDialog;
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  private isLoggedIn = false;
+
+  private account?: AccountDetailInfo;
+
+  static override get styles() {
+    return [
+      modalStyles,
+      css`
+        gr-dialog {
+          width: 60em;
+        }
+        .grid {
+          display: grid;
+          grid-template-columns: min-content 1fr;
+          column-gap: var(--spacing-l);
+        }
+        gr-account-list {
+          display: flex;
+          flex-wrap: wrap;
+        }
+        .warning,
+        .error {
+          display: flex;
+          align-items: center;
+          gap: var(--spacing-xl);
+          padding: var(--spacing-l);
+          padding-left: var(--spacing-xl);
+          background-color: var(--yellow-50);
+        }
+        .error {
+          background-color: var(--error-background);
+        }
+        .grid + .warning,
+        .error {
+          margin-top: var(--spacing-l);
+        }
+        .warning + .warning {
+          margin-top: var(--spacing-s);
+        }
+        gr-icon {
+          color: var(--orange-800);
+          font-size: 18px;
+        }
+        dialog#confirm-cc,
+        dialog#confirm-reviewer {
+          padding: var(--spacing-l);
+          text-align: center;
+        }
+        .confirmation-buttons {
+          margin-top: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => (this.selectedChanges = selectedChanges)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverConfig => (this.serverConfig = serverConfig)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      account => (this.account = account)
+    );
+  }
+
+  override render() {
+    return html`
+      <gr-button
+        id="start-flow"
+        .disabled=${this.isFlowDisabled()}
+        flatten
+        @click=${() => this.openOverlay()}
+        >add reviewer/cc</gr-button
+      >
+      <dialog id="flow" tabindex="-1">
+        ${this.isOverlayOpen ? this.renderDialog() : nothing}
+      </dialog>
+    `;
+  }
+
+  private renderDialog() {
+    const overallStatus = getOverallStatus(this.progressByChangeNum);
+    return html`
+      <gr-dialog
+        @cancel=${() => this.closeOverlay()}
+        @confirm=${() => this.onConfirm(overallStatus)}
+        .confirmLabel=${'Add'}
+        .disabled=${overallStatus === ProgressStatus.RUNNING}
+        .loadingLabel=${'Adding Reviewer and CC in progress...'}
+        ?loading=${getOverallStatus(this.progressByChangeNum) ===
+        ProgressStatus.RUNNING}
+      >
+        <div slot="header">Add reviewer / CC</div>
+        <div slot="main">
+          <div class="grid">
+            <span>Reviewers</span>
+            ${this.renderAccountList(
+              ReviewerState.REVIEWER,
+              'reviewer-list',
+              'Add reviewer'
+            )}
+            <span>CC</span>
+            ${this.renderAccountList(ReviewerState.CC, 'cc-list', 'Add CC')}
+          </div>
+          ${this.renderAnyOverwriteWarnings()} ${this.renderErrors()}
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private renderAccountList(
+    reviewerState: ReviewerState,
+    id: string,
+    placeholder: string
+  ) {
+    const updatedAccounts =
+      this.updatedAccountsByReviewerState.get(reviewerState);
+    const suggestionsProvider =
+      this.suggestionsProviderByReviewerState.get(reviewerState);
+    if (!updatedAccounts || !suggestionsProvider) {
+      return;
+    }
+    return html`
+      <gr-account-list
+        id=${id}
+        .accounts=${updatedAccounts}
+        .removableValues=${[]}
+        .suggestionsProvider=${suggestionsProvider}
+        .placeholder=${placeholder}
+        .pendingConfirmation=${this.groupPendingConfirmationByReviewerState.get(
+          reviewerState
+        )}
+        @accounts-changed=${() => this.onAccountsChanged(reviewerState)}
+        @pending-confirmation-changed=${(
+          ev: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
+        ) => this.onPendingConfirmationChanged(reviewerState, ev)}
+      >
+      </gr-account-list>
+      ${this.renderConfirmationDialog(reviewerState)}
+    `;
+  }
+
+  private renderConfirmationDialog(reviewerState: ReviewerState) {
+    const id =
+      reviewerState === ReviewerState.CC ? 'confirm-cc' : 'confirm-reviewer';
+    const suggestion =
+      this.groupPendingConfirmationByReviewerState.get(reviewerState);
+    return html`
+      <dialog
+        tabindex="-1"
+        id=${id}
+        @close=${() => this.cancelPendingGroup(reviewerState)}
+      >
+        <div class="confirmation-text">
+          Group
+          <span class="groupName"> ${suggestion?.group.name} </span>
+          has
+          <span class="groupSize"> ${suggestion?.count} </span>
+          members.
+          <br />
+          Are you sure you want to add them all?
+        </div>
+        <div class="confirmation-buttons">
+          <gr-button
+            @click=${() => this.confirmPendingGroup(reviewerState, suggestion)}
+            >Yes</gr-button
+          >
+          <gr-button @click=${() => this.cancelPendingGroup(reviewerState)}
+            >No</gr-button
+          >
+        </div>
+      </dialog>
+    `;
+  }
+
+  private renderAnyOverwriteWarnings() {
+    return html`
+      ${this.renderAnyOverwriteWarning(ReviewerState.REVIEWER)}
+      ${this.renderAnyOverwriteWarning(ReviewerState.CC)}
+    `;
+  }
+
+  private renderErrors() {
+    if (getOverallStatus(this.progressByChangeNum) !== ProgressStatus.FAILED)
+      return nothing;
+    const failedAccounts = [
+      ...(this.updatedAccountsByReviewerState.get(ReviewerState.REVIEWER) ??
+        []),
+      ...(this.updatedAccountsByReviewerState.get(ReviewerState.CC) ?? []),
+    ].map(account => getDisplayName(this.serverConfig, account));
+    if (failedAccounts.length === 0) {
+      return nothing;
+    }
+    return html`
+      <div class="error">
+        <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+        Failed to add ${listForSentence(failedAccounts)} to changes.
+      </div>
+    `;
+  }
+
+  private renderAnyOverwriteWarning(currentReviewerState: ReviewerState) {
+    const updatedReviewerState =
+      currentReviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const overwrittenNames =
+      this.getOverwrittenDisplayNames(currentReviewerState);
+    if (overwrittenNames.length === 0) {
+      return nothing;
+    }
+    const pluralizedVerb = overwrittenNames.length === 1 ? 'is a' : 'are';
+    const currentLabel = `${
+      currentReviewerState === ReviewerState.CC ? 'CC' : 'reviewer'
+    }${overwrittenNames.length > 1 ? 's' : ''}`;
+    const updatedLabel =
+      updatedReviewerState === ReviewerState.CC ? 'CC' : 'reviewer';
+    return html`
+      <div class="warning">
+        <gr-icon
+          icon="warning"
+          filled
+          role="img"
+          aria-label="Warning"
+        ></gr-icon>
+        ${listForSentence(overwrittenNames)} ${pluralizedVerb} ${currentLabel}
+        on some selected changes and will be moved to ${updatedLabel} on all
+        changes.
+      </div>
+    `;
+  }
+
+  private getAccountsInCurrentState(currentReviewerState: ReviewerState) {
+    return this.selectedChanges
+      .flatMap(
+        change =>
+          change.reviewers[currentReviewerState]?.filter(isNotOwner(change)) ??
+          []
+      )
+      .filter(account => account?._account_id !== undefined);
+  }
+
+  private getOverwrittenDisplayNames(
+    currentReviewerState: ReviewerState
+  ): string[] {
+    const updatedReviewerState =
+      currentReviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const accountsInCurrentState =
+      this.getAccountsInCurrentState(currentReviewerState);
+    return this.updatedAccountsByReviewerState
+      .get(updatedReviewerState)!
+      .filter(account =>
+        accountsInCurrentState.some(
+          otherAccount => getUserId(otherAccount) === getUserId(account)
+        )
+      )
+      .map(reviewer => getDisplayName(this.serverConfig, reviewer));
+  }
+
+  private async openOverlay() {
+    this.resetFlow();
+    this.isOverlayOpen = true;
+    // Must await the overlay opening because the dialog is lazily rendered.
+    await this.modal?.showModal();
+  }
+
+  private closeOverlay() {
+    this.isOverlayOpen = false;
+    this.modal?.close();
+  }
+
+  private resetFlow() {
+    this.progressByChangeNum = new Map(
+      this.selectedChanges.map(change => [
+        change._number,
+        ProgressStatus.NOT_STARTED,
+      ])
+    );
+    for (const state of [ReviewerState.REVIEWER, ReviewerState.CC] as const) {
+      this.updatedAccountsByReviewerState.set(
+        state,
+        this.getCurrentAccounts(state)
+      );
+      if (this.selectedChanges.length > 0) {
+        this.suggestionsProviderByReviewerState.set(
+          state,
+          this.createSuggestionsProvider(state)
+        );
+      }
+    }
+    this.requestUpdate();
+  }
+
+  /*
+   * Removes accounts from one list when they are added to the other. Also
+   * trigger re-render so warnings will update as accounts are added, removed,
+   * and confirmed.
+   */
+  private onAccountsChanged(reviewerState: ReviewerState) {
+    const reviewerStateKeys = this.updatedAccountsByReviewerState
+      .get(reviewerState)!
+      .map(getUserId);
+    const oppositeReviewerState =
+      reviewerState === ReviewerState.CC
+        ? ReviewerState.REVIEWER
+        : ReviewerState.CC;
+    const oppositeUpdatedAccounts = this.updatedAccountsByReviewerState.get(
+      oppositeReviewerState
+    )!;
+
+    const notOverwrittenOppositeAccounts = oppositeUpdatedAccounts.filter(
+      acc => !reviewerStateKeys.includes(getUserId(acc))
+    );
+    if (
+      notOverwrittenOppositeAccounts.length !== oppositeUpdatedAccounts.length
+    ) {
+      this.updatedAccountsByReviewerState.set(
+        oppositeReviewerState,
+        notOverwrittenOppositeAccounts
+      );
+    }
+    this.requestUpdate();
+  }
+
+  private async onPendingConfirmationChanged(
+    reviewerState: ReviewerState,
+    ev: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
+  ) {
+    this.groupPendingConfirmationByReviewerState.set(
+      reviewerState,
+      ev.detail.value
+    );
+    this.requestUpdate();
+    await this.updateComplete;
+
+    const modal =
+      reviewerState === ReviewerState.CC
+        ? this.ccConfirmModal
+        : this.reviewerConfirmModal;
+    if (ev.detail.value === null) {
+      modal?.close();
+    } else {
+      await modal?.showModal();
+    }
+  }
+
+  private cancelPendingGroup(reviewerState: ReviewerState) {
+    const modal =
+      reviewerState === ReviewerState.CC
+        ? this.ccConfirmModal
+        : this.reviewerConfirmModal;
+    modal?.close();
+    this.groupPendingConfirmationByReviewerState.set(reviewerState, null);
+    this.requestUpdate();
+  }
+
+  private confirmPendingGroup(
+    reviewerState: ReviewerState,
+    suggestion: SuggestedReviewerGroupInfo | null | undefined
+  ) {
+    if (!suggestion) return;
+    const accountList =
+      reviewerState === ReviewerState.CC ? this.ccList : this.reviewerList;
+    accountList?.confirmGroup(suggestion.group);
+  }
+
+  private onConfirm(overallStatus: ProgressStatus) {
+    switch (overallStatus) {
+      case ProgressStatus.NOT_STARTED:
+        this.saveReviewers();
+        break;
+      case ProgressStatus.SUCCESSFUL:
+        this.modal?.close();
+        break;
+      case ProgressStatus.FAILED:
+        this.modal?.close();
+        break;
+    }
+  }
+
+  private fireSuccessToasts() {
+    const numReviewersAdded =
+      (this.updatedAccountsByReviewerState.get(ReviewerState.REVIEWER)
+        ?.length ?? 0) - this.getCurrentAccounts(ReviewerState.REVIEWER).length;
+    const numCcsAdded =
+      (this.updatedAccountsByReviewerState.get(ReviewerState.CC)?.length ?? 0) -
+      this.getCurrentAccounts(ReviewerState.CC).length;
+    let alert = '';
+    if (numReviewersAdded && numCcsAdded) {
+      alert = `${pluralize(numReviewersAdded, 'reviewer')} and ${pluralize(
+        numCcsAdded,
+        'CC'
+      )} added`;
+    } else if (numReviewersAdded) {
+      alert = `${pluralize(numReviewersAdded, 'reviewer')} added`;
+    } else {
+      alert = `${pluralize(numCcsAdded, 'CC')} added`;
+    }
+    fireAlert(this, alert);
+  }
+
+  private async saveReviewers() {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'add-reviewer',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    this.progressByChangeNum = new Map(
+      this.selectedChanges.map(change => [
+        change._number,
+        ProgressStatus.RUNNING,
+      ])
+    );
+    const inFlightActions = this.getBulkActionsModel().addReviewers(
+      this.updatedAccountsByReviewerState,
+      getReplyByReason(this.account, this.serverConfig)
+    );
+
+    await allSettled(
+      inFlightActions.map((promise, index) => {
+        const change = this.selectedChanges[index];
+        return promise
+          .then(() => {
+            this.progressByChangeNum.set(
+              change._number,
+              ProgressStatus.SUCCESSFUL
+            );
+            this.requestUpdate();
+          })
+          .catch(() => {
+            this.progressByChangeNum.set(change._number, ProgressStatus.FAILED);
+            this.requestUpdate();
+          });
+      })
+    );
+    if (getOverallStatus(this.progressByChangeNum) === ProgressStatus.FAILED) {
+      this.reportingService.reportInteraction('bulk-action-failure', {
+        type: 'add-reviewer',
+        count: Array.from(this.progressByChangeNum.values()).filter(
+          status => status === ProgressStatus.FAILED
+        ).length,
+      });
+    } else {
+      this.fireSuccessToasts();
+      this.closeOverlay();
+      fireReload(this);
+    }
+  }
+
+  private isFlowDisabled() {
+    // No additional checks are necessary. If the user has visibility enough to
+    // see the change, they have permission enough to add reviewers/cc.
+    return this.selectedChanges.length === 0;
+  }
+
+  // private but used in tests
+  getCurrentAccounts(reviewerState: ReviewerState) {
+    const reviewersPerChange = this.selectedChanges.map(
+      change =>
+        change.reviewers[reviewerState]?.filter(isNotOwner(change)) ?? []
+    );
+    return intersection(
+      reviewersPerChange,
+      (account1, account2) => accountKey(account1) === accountKey(account2)
+    );
+  }
+
+  private createSuggestionsProvider(
+    state: ReviewerState.CC | ReviewerState.REVIEWER
+  ): ReviewerSuggestionsProvider {
+    const suggestionsProvider = new GrReviewerSuggestionsProvider(
+      this.restApiService,
+      state,
+      this.serverConfig,
+      this.isLoggedIn,
+      ...this.selectedChanges
+    );
+    return suggestionsProvider;
+  }
+}
+
+function isNotOwner(change: ChangeInfo) {
+  return (account: AccountInfo) =>
+    accountKey(change.owner) !== accountKey(account);
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-reviewer-flow': GrChangeListReviewerFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
new file mode 100644
index 0000000..85e6212
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -0,0 +1,981 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html, assert} from '@open-wc/testing';
+import {SinonStubbedMember} from 'sinon';
+import {
+  AccountInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  ReviewerState,
+} from '../../../api/rest-api';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import '../../../test/common-test-setup';
+import {
+  createAccountWithIdNameAndEmail,
+  createChange,
+  createGroupInfo,
+} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+  waitUntil,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId} from '../../../types/common';
+import {ValueChangedEvent} from '../../../types/events';
+import {query} from '../../../utils/common-util';
+import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import './gr-change-list-reviewer-flow';
+import type {GrChangeListReviewerFlow} from './gr-change-list-reviewer-flow';
+
+const accounts: AccountInfo[] = [
+  createAccountWithIdNameAndEmail(0),
+  createAccountWithIdNameAndEmail(1),
+  createAccountWithIdNameAndEmail(2),
+  createAccountWithIdNameAndEmail(3),
+  createAccountWithIdNameAndEmail(4),
+  createAccountWithIdNameAndEmail(5),
+  createAccountWithIdNameAndEmail(6),
+];
+const groups: GroupInfo[] = [
+  {...createGroupInfo('groupId'), name: 'Group 0' as GroupName},
+];
+const changes: ChangeInfo[] = [
+  {
+    ...createChange(),
+    _number: 1 as NumericChangeId,
+    subject: 'Subject 1',
+    owner: accounts[6],
+    reviewers: {
+      REVIEWER: [accounts[0], accounts[1], accounts[6]],
+      CC: [accounts[3], accounts[4]],
+    },
+  },
+  {
+    ...createChange(),
+    _number: 2 as NumericChangeId,
+    subject: 'Subject 2',
+    owner: accounts[6],
+    reviewers: {REVIEWER: [accounts[0], accounts[6]], CC: [accounts[3]]},
+  },
+];
+
+suite('gr-change-list-reviewer-flow tests', () => {
+  let element: GrChangeListReviewerFlow;
+  let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    stubRestApi('getDetailedChangesWithActions').resolves(changes);
+    reportingStub = stubReporting('reportInteraction');
+    model = new BulkActionsModel(getAppContext().restApiService);
+    model.sync(changes);
+
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>`,
+          bulkActionsModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-change-list-reviewer-flow')!;
+    await selectChange(changes[0]);
+    await selectChange(changes[1]);
+    await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+    await element.updateComplete;
+  });
+
+  test('skips dialog render when closed', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-button
+          id="start-flow"
+          flatten=""
+          aria-disabled="false"
+          role="button"
+          tabindex="0"
+          >add reviewer/cc</gr-button
+        >
+        <dialog id="flow" tabindex="-1"></dialog>
+      `
+    );
+  });
+
+  test('flow button enabled when changes selected', async () => {
+    const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+    assert.isFalse(button.disabled);
+  });
+
+  test('flow button disabled when no changes selected', async () => {
+    model.clearSelectedChangeNums();
+    await waitUntilObserved(model.selectedChanges$, s => s.length === 0);
+    await element.updateComplete;
+
+    const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+    assert.isTrue(button.disabled);
+  });
+
+  test('overlay hidden before flow button clicked', async () => {
+    const dialog = queryAndAssert<HTMLDialogElement>(element, 'dialog');
+    const openStub = sinon.stub(dialog, 'showModal');
+    assert.isFalse(openStub.called);
+  });
+
+  test('flow button click shows overlay', async () => {
+    const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+    const dialog = queryAndAssert<HTMLDialogElement>(element, 'dialog');
+    const openStub = sinon.stub(dialog, 'showModal');
+
+    button.click();
+
+    await element.updateComplete;
+
+    assert.isTrue(openStub.called);
+  });
+
+  suite('dialog flow', () => {
+    let saveChangesPromises: MockPromise<Response>[];
+    let saveChangeReviewStub: sinon.SinonStub;
+    let dialog: GrDialog;
+
+    async function resolvePromises() {
+      saveChangesPromises[0].resolve(new Response());
+      saveChangesPromises[1].resolve(new Response());
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      saveChangesPromises = [];
+      saveChangeReviewStub = stubRestApi('saveChangeReview');
+      for (let i = 0; i < changes.length; i++) {
+        const promise = mockPromise<Response>();
+        saveChangesPromises.push(promise);
+        saveChangeReviewStub
+          .withArgs(changes[i]._number, sinon.match.any, sinon.match.any)
+          .returns(promise);
+      }
+
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      dialog = queryAndAssert<GrDialog>(element, 'gr-dialog');
+      await dialog.updateComplete;
+    });
+
+    test('renders dialog when opened', async () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >add reviewer/cc</gr-button
+          >
+          <dialog id="flow" open="" tabindex="-1">
+            <gr-dialog role="dialog">
+              <div slot="header">Add reviewer / CC</div>
+              <div slot="main">
+                <div class="grid">
+                  <span>Reviewers</span>
+                  <gr-account-list id="reviewer-list"></gr-account-list>
+                  <dialog id="confirm-reviewer" tabindex="-1">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </dialog>
+                  <span>CC</span>
+                  <gr-account-list id="cc-list"></gr-account-list>
+                  <dialog id="confirm-cc" tabindex="-1">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </dialog>
+                </div>
+              </div>
+            </gr-dialog>
+            <div id="gr-hovercard-container"></div>
+          </dialog>
+        `
+      );
+    });
+
+    test('only lists reviewers/CCs shared by all changes', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      // does not include account 1 because it is not shared, does not include
+      // account 6 because it is the owner
+      assert.sameMembers(reviewerList.accounts, [accounts[0]]);
+      // does not include account 4
+      assert.sameMembers(ccList.accounts, [accounts[3]]);
+    });
+
+    test('adds reviewer & CC', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      ccList.accounts.push(accounts[5]);
+
+      assert.isFalse(dialog.loading);
+
+      await element.updateComplete;
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+
+      assert.isTrue(dialog.loading);
+      assert.equal(
+        dialog.loadingLabel,
+        'Adding Reviewer and CC in progress...'
+      );
+
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-reviewer',
+        selectedChangeCount: 2,
+      });
+
+      assert.isTrue(saveChangeReviewStub.calledTwice);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
+        changes[0]._number,
+        'current',
+        {
+          reviewers: [
+            {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
+            {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
+          ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
+        changes[1]._number,
+        'current',
+        {
+          reviewers: [
+            {reviewer: accounts[2]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
+            {reviewer: accounts[5]._account_id, state: ReviewerState.CC},
+          ],
+          ignore_automatic_attention_set_rules: true,
+          // only the reviewer is added to the attention set, not the cc
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: accounts[2]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_1> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+    });
+
+    test('removes from reviewer list when added to cc', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(reviewerList.accounts, [accounts[0]]);
+
+      ccList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[0],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await element.updateComplete;
+
+      assert.isEmpty(reviewerList.accounts);
+    });
+
+    test('removes from cc list when added to reviewer', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      assert.sameOrderedMembers(ccList.accounts, [accounts[3]]);
+
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[3],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await element.updateComplete;
+
+      assert.isEmpty(ccList.accounts);
+    });
+
+    suite('success toasts', () => {
+      test('reviewer only', async () => {
+        const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        const existingReviewers = element.getCurrentAccounts(
+          ReviewerState.REVIEWER
+        );
+        const getCurrentAccountsStub = sinon.stub(
+          element,
+          'getCurrentAccounts'
+        );
+        getCurrentAccountsStub.withArgs(ReviewerState.CC).returns([]);
+        getCurrentAccountsStub
+          .withArgs(ReviewerState.REVIEWER)
+          .returns(existingReviewers);
+        const reviewerList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#reviewer-list'
+        );
+        reviewerList.accounts.push(accounts[2], groups[0]);
+        element.updatedAccountsByReviewerState.set(ReviewerState.CC, []);
+        await element.updateComplete;
+        dialog.confirmButton!.click();
+
+        await resolvePromises();
+        await element.updateComplete;
+
+        await waitUntil(
+          () => dispatchEventStub.callCount > 0,
+          'dispatchEventStub never called'
+        );
+
+        assert.equal(
+          (dispatchEventStub.firstCall.args[0] as CustomEvent).detail.message,
+          '2 reviewers added'
+        );
+      });
+
+      test('ccs only', async () => {
+        const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        const existingCCs = element.getCurrentAccounts(ReviewerState.CC);
+        const getCurrentAccountsStub = sinon.stub(
+          element,
+          'getCurrentAccounts'
+        );
+        getCurrentAccountsStub.withArgs(ReviewerState.CC).returns(existingCCs);
+        getCurrentAccountsStub.withArgs(ReviewerState.REVIEWER).returns([]);
+        const ccsList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#cc-list'
+        );
+        ccsList.accounts.push(accounts[2], groups[0]);
+        ccsList.accounts = [];
+        element.updatedAccountsByReviewerState.set(ReviewerState.REVIEWER, []);
+        await element.updateComplete;
+        dialog.confirmButton!.click();
+
+        await resolvePromises();
+        await element.updateComplete;
+
+        await waitUntil(
+          () => dispatchEventStub.callCount > 0,
+          'dispatchEventStub never called'
+        );
+
+        assert.equal(
+          (dispatchEventStub.firstCall.args[0] as CustomEvent).detail.message,
+          '2 CCs added'
+        );
+      });
+
+      test('reviewers and CC', async () => {
+        const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+        const existingReviewers = element.getCurrentAccounts(
+          ReviewerState.REVIEWER
+        );
+        const existingCCs = element.getCurrentAccounts(ReviewerState.CC);
+        const getCurrentAccountsStub = sinon.stub(
+          element,
+          'getCurrentAccounts'
+        );
+        getCurrentAccountsStub.withArgs(ReviewerState.CC).returns(existingCCs);
+        getCurrentAccountsStub
+          .withArgs(ReviewerState.REVIEWER)
+          .returns(existingReviewers);
+
+        const reviewerList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#reviewer-list'
+        );
+        const ccsList = queryAndAssert<GrAccountList>(
+          dialog,
+          'gr-account-list#cc-list'
+        );
+
+        reviewerList.accounts.push(accounts[2], groups[0]);
+        ccsList.accounts.push(accounts[2], groups[0]);
+        await element.updateComplete;
+        dialog.confirmButton!.click();
+
+        await resolvePromises();
+        await element.updateComplete;
+
+        await waitUntil(
+          () => dispatchEventStub.callCount > 0,
+          'dispatchEventStub never called'
+        );
+
+        assert.equal(
+          (dispatchEventStub.firstCall.args[0] as CustomEvent).detail.message,
+          '2 reviewers and 2 CCs added'
+        );
+      });
+    });
+
+    test('reloads page on success', async () => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      await element.updateComplete;
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      await waitUntil(
+        () => dispatchEventStub.callCount > 0,
+        'dispatchEventStub never called'
+      );
+
+      assert.isTrue(dispatchEventStub.calledTwice);
+      assert.equal(dispatchEventStub.secondCall.args[0].type, 'reload');
+    });
+
+    test('does not reload page on failure', async () => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      await element.updateComplete;
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+      saveChangesPromises[0].reject(new Error('failed!'));
+      saveChangesPromises[1].reject(new Error('failed!'));
+      await element.updateComplete;
+
+      await waitUntil(
+        () => reportingStub.calledWith('bulk-action-failure'),
+        'reporting stub never called'
+      );
+
+      assert.deepEqual(reportingStub.lastCall.args, [
+        'bulk-action-failure',
+        {
+          type: 'add-reviewer',
+          count: 2,
+        },
+      ]);
+      assert.isTrue(dispatchEventStub.notCalled);
+    });
+
+    test('renders warnings when reviewer/cc are overwritten', async () => {
+      const ccList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#cc-list'
+      );
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[4],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      ccList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              account: accounts[1],
+              count: 1,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      await element.updateComplete;
+
+      // prettier and shadowDom string don't agree on the long text in divs
+      assert.shadowDom.equal(
+        element,
+        /* prettier-ignore */
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >add reviewer/cc</gr-button
+          >
+          <dialog id="flow" open="" tabindex="-1">
+            <gr-dialog role="dialog">
+              <div slot="header">Add reviewer / CC</div>
+              <div slot="main">
+                <div class="grid">
+                  <span>Reviewers</span>
+                  <gr-account-list id="reviewer-list"></gr-account-list>
+                  <dialog tabindex="-1" id="confirm-reviewer">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br>
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        No
+                      </gr-button>
+                    </div>
+                  </dialog>
+                  <span>CC</span>
+                  <gr-account-list id="cc-list"></gr-account-list>
+                  <dialog tabindex="-1" id="confirm-cc">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"></span>
+                      has
+                      <span class="groupSize"></span>
+                      members.
+                      <br>
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0">
+                        No
+                      </gr-button>
+                    </div>
+                  </dialog>
+                </div>
+                <div class="warning">
+                  <gr-icon icon="warning" filled role="img" aria-label="Warning"
+                  ></gr-icon>
+                  User-1 is a reviewer
+        on some selected changes and will be moved to CC on all
+        changes.
+                </div>
+                <div class="warning">
+                  <gr-icon icon="warning" filled role="img" aria-label="Warning"
+                  ></gr-icon>
+                  User-4 is a CC
+        on some selected changes and will be moved to reviewer on all
+        changes.
+                </div>
+              </div>
+            </gr-dialog>
+            <div id="gr-hovercard-container">
+            </div>
+          </dialog>
+        `,
+        {
+          // dialog sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['dialog'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('renders errors when requests fail', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+
+      reviewerList.accounts.push(accounts[2], groups[0]);
+      await element.updateComplete;
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+      saveChangesPromises[0].reject(new Error('failed!'));
+      saveChangesPromises[1].reject(new Error('failed!'));
+
+      await waitUntil(() => !!query(dialog, '.error'));
+
+      // prettier and shadowDom string don't agree on the long text in divs
+      assert.shadowDom.equal(
+        element,
+        /* prettier-ignore */
+        /* HTML */ `
+          <gr-button
+            aria-disabled="false"
+            flatten=""
+            id="start-flow"
+            role="button"
+            tabindex="0"
+          >
+            add reviewer/cc
+          </gr-button>
+          <dialog
+            id="flow"
+            tabindex="-1"
+            open=""
+          >
+            <gr-dialog role="dialog">
+              <div slot="header">Add reviewer / CC</div>
+              <div slot="main">
+                <div class="grid">
+                  <span> Reviewers </span>
+                  <gr-account-list id="reviewer-list"> </gr-account-list>
+                  <dialog tabindex="-1" id="confirm-reviewer">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"> </span>
+                      has
+                      <span class="groupSize"> </span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </dialog>
+                  <span> CC </span>
+                  <gr-account-list id="cc-list"> </gr-account-list>
+                  <dialog tabindex="-1" id="confirm-cc">
+                    <div class="confirmation-text">
+                      Group
+                      <span class="groupName"> </span>
+                      has
+                      <span class="groupSize"> </span>
+                      members.
+                      <br />
+                      Are you sure you want to add them all?
+                    </div>
+                    <div class="confirmation-buttons">
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        Yes
+                      </gr-button>
+                      <gr-button
+                        aria-disabled="false"
+                        role="button"
+                        tabindex="0"
+                      >
+                        No
+                      </gr-button>
+                    </div>
+                  </dialog>
+                </div>
+                <div class="error">
+                  <gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
+                  Failed to add User-0, User-2, Group 0, and User-3 to changes.
+                </div>
+              </div>
+            </gr-dialog>
+            <div id="gr-hovercard-container">
+            </div>
+          </dialog>
+        `,
+        {
+          // dialog sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['dialog'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('shows confirmation dialog when large group is added', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'large-group',
+              },
+              count: 12,
+              confirm: true,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+
+      const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
+      await waitUntil(
+        () =>
+          getComputedStyle(confirmDialog).getPropertyValue('display') !== 'none'
+      );
+    });
+
+    test('"yes" button confirms large group', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'large-group',
+              },
+              count: 12,
+              confirm: true,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+      // "Yes" button is first
+      queryAndAssert<GrButton>(
+        element,
+        '.confirmation-buttons > gr-button:first-of-type'
+      ).click();
+      await element.updateComplete;
+
+      const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
+      await waitUntil(
+        () =>
+          getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+      );
+
+      assert.deepEqual(reviewerList.accounts[1], {
+        confirmed: true,
+        id: '5' as GroupId,
+        name: 'large-group',
+      });
+    });
+
+    test('confirmation dialog skipped for small group', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      // "confirm" field is used to decide whether to use the confirmation flow,
+      // not the count. "confirm" value comes from server based on count
+      // threshold
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'small-group',
+              },
+              count: 2,
+              confirm: false,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+      const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
+      assert.isTrue(
+        getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+      );
+      assert.deepEqual(reviewerList.accounts[1], {
+        id: '5' as GroupId,
+        name: 'small-group',
+      });
+    });
+
+    test('"no" button cancels large group', async () => {
+      const reviewerList = queryAndAssert<GrAccountList>(
+        dialog,
+        'gr-account-list#reviewer-list'
+      );
+      reviewerList.handleAdd(
+        new CustomEvent('add', {
+          detail: {
+            value: {
+              group: {
+                id: '5',
+                name: 'large-group',
+              },
+              count: 12,
+              confirm: true,
+            },
+          },
+        }) as unknown as ValueChangedEvent<string>
+      );
+      // reviewerList needs to set the pendingConfirmation property which
+      // triggers an update of ReviewerFlow
+      await reviewerList.updateComplete;
+      await element.updateComplete;
+      // "No" button is last
+      queryAndAssert<GrButton>(
+        element,
+        '.confirmation-buttons > gr-button:last-of-type'
+      ).click();
+      await element.updateComplete;
+
+      const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
+      assert.isTrue(
+        getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+      );
+      // Group not present
+      assert.sameDeepMembers(reviewerList.accounts, [accounts[0]]);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
new file mode 100644
index 0000000..8227e11
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -0,0 +1,411 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {ChangeListSection} from '../gr-change-list/gr-change-list';
+import '../gr-change-list-action-bar/gr-change-list-action-bar';
+import {CLOSED, YOUR_TURN} from '../../../utils/dashboard-util';
+import {getAppContext} from '../../../services/app-context';
+import {ChangeInfo, ServerInfo, AccountInfo} from '../../../api/rest-api';
+import {changeListStyles} from '../../../styles/gr-change-list-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {Metadata} from '../../../utils/change-metadata-util';
+import {WAITING} from '../../../constants/constants';
+import {provide} from '../../../models/dependency';
+import {
+  bulkActionsModelToken,
+  BulkActionsModel,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {subscribe} from '../../lit/subscription-controller';
+import {classMap} from 'lit/directives/class-map.js';
+import {createSearchUrl} from '../../../models/views/search';
+
+const NUMBER_FIXED_COLUMNS = 4;
+const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+const MAX_SHORTCUT_CHARS = 5;
+const INVALID_TOKENS = ['limit:', 'age:', '-age:'];
+
+export function computeLabelShortcut(labelName: string) {
+  if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+    labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+  }
+  // Compute label shortcut by splitting token by - and capitalizing first
+  // letter of each token.
+  return labelName
+    .split('-')
+    .reduce((previousValue, currentValue) => {
+      if (!currentValue) {
+        return previousValue;
+      }
+      return previousValue + currentValue[0].toUpperCase();
+    }, '')
+    .slice(0, MAX_SHORTCUT_CHARS);
+}
+
+@customElement('gr-change-list-section')
+export class GrChangeListSection extends LitElement {
+  @property({type: Array})
+  visibleChangeTableColumns?: string[];
+
+  @property({type: Boolean})
+  showStar = false;
+
+  @property({type: Boolean})
+  showNumber?: boolean; // No default value to prevent flickering.
+
+  @property({type: Number})
+  selectedIndex?: number; // The relative index of the change that is selected
+
+  @property({type: Array})
+  labelNames: string[] = [];
+
+  @property({type: Array})
+  dynamicHeaderEndpoints?: string[];
+
+  @property({type: Object})
+  changeSection!: ChangeListSection;
+
+  @property({type: Object})
+  config?: ServerInfo;
+
+  @property({type: Boolean})
+  isCursorMoving = false;
+
+  /**
+   * The logged-in user's account, or an empty object if no user is logged
+   * in.
+   */
+  @property({type: Object})
+  account: AccountInfo | undefined = undefined;
+
+  @property({type: String})
+  usp?: string;
+
+  /** Index of the first element in the section in the overall list order. */
+  @property({type: Number})
+  startIndex = 0;
+
+  /** Callback to call to request the item to be selected in the list. */
+  @property({type: Function})
+  triggerSelectionCallback?: (globalIndex: number) => void;
+
+  // private but used in tests
+  @state()
+  numSelected = 0;
+
+  @state()
+  private totalChangeCount = 0;
+
+  bulkActionsModel: BulkActionsModel = new BulkActionsModel(
+    getAppContext().restApiService
+  );
+
+  static override get styles() {
+    return [
+      changeListStyles,
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: contents;
+        }
+        .section-count-label {
+          color: var(--deemphasized-text-color);
+          font-family: var(--font-family);
+          font-size: var(--font-size-small);
+          font-weight: var(--font-weight-normal);
+          line-height: var(--line-height-small);
+        }
+        /*
+         * checkbox styles match checkboxes in <gr-change-list-item> rows to
+         * vertically align with them.
+         */
+        input.selection-checkbox {
+          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: 0px;
+          padding: var(--spacing-s);
+          vertical-align: middle;
+        }
+        .showSelectionBorder {
+          border-bottom: 2px solid var(--input-focus-border-color);
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    provide(this, bulkActionsModelToken, () => this.bulkActionsModel);
+    subscribe(
+      this,
+      () => this.bulkActionsModel.selectedChangeNums$,
+      selectedChanges => {
+        this.numSelected = selectedChanges.length;
+      }
+    );
+    subscribe(
+      this,
+      () => this.bulkActionsModel.totalChangeCount$,
+      totalChangeCount => (this.totalChangeCount = totalChangeCount)
+    );
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('changeSection')) {
+      // In case the list of changes is updated due to auto reloading, we want
+      // to ensure the model removes any stale change that is not a part of the
+      // new section changes.
+      this.bulkActionsModel.sync(this.changeSection.results);
+    }
+  }
+
+  override render() {
+    const columns = this.computeColumns();
+    const colSpan = this.computeColspan(columns);
+    return html`
+      ${this.renderSectionHeader(colSpan)}
+      <tbody class="groupContent">
+        ${this.isEmpty()
+          ? this.renderNoChangesRow(colSpan)
+          : this.renderColumnHeaders(columns)}
+        ${this.changeSection.results.map((change, index) =>
+          this.renderChangeRow(change, index, columns)
+        )}
+      </tbody>
+    `;
+  }
+
+  private renderNoChangesRow(colSpan: number) {
+    return html`
+      <tr class="noChanges">
+        <td class="leftPadding" aria-hidden="true"></td>
+        <td
+          class="star"
+          ?aria-hidden=${!this.showStar}
+          ?hidden=${!this.showStar}
+        ></td>
+        <td class="cell" colspan=${colSpan}>
+          ${this.changeSection.emptyStateSlotName
+            ? html`<slot name=${this.changeSection.emptyStateSlotName}></slot>`
+            : 'No changes'}
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderSectionHeader(colSpan: number) {
+    if (
+      this.changeSection.name === undefined ||
+      this.changeSection.countLabel === undefined ||
+      this.changeSection.query === undefined
+    )
+      return;
+
+    return html`
+      <tbody>
+        <tr class="groupHeader">
+          <td aria-hidden="true" class="leftPadding"></td>
+          <td aria-hidden="true" class="star" ?hidden=${!this.showStar}></td>
+          <td class="cell" colspan=${colSpan}>
+            <h2 class="heading-3">
+              <a
+                href=${this.sectionHref(this.changeSection.query)}
+                class="section-title"
+              >
+                <span class="section-name">${this.changeSection.name}</span>
+                <span class="section-count-label"
+                  >${this.changeSection.countLabel}</span
+                >
+              </a>
+            </h2>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+
+  private renderColumnHeaders(columns: string[]) {
+    const showBulkActionsHeader = this.numSelected > 0;
+    return html`
+      <tr
+        class=${classMap({
+          groupTitle: true,
+          showSelectionBorder: showBulkActionsHeader,
+        })}
+      >
+        <td class="leftPadding"></td>
+        ${this.renderSelectionHeader()}
+        ${showBulkActionsHeader
+          ? html`<gr-change-list-action-bar></gr-change-list-action-bar>`
+          : html` <td
+                class="star"
+                aria-label="Star status column"
+                ?hidden=${!this.showStar}
+              ></td>
+              <td class="number" ?hidden=${!this.showNumber}>#</td>
+              ${columns.map(item => this.renderHeaderCell(item))}
+              ${this.labelNames?.map(labelName =>
+                this.renderLabelHeader(labelName)
+              )}
+              ${this.dynamicHeaderEndpoints?.map(pluginHeader =>
+                this.renderEndpointHeader(pluginHeader)
+              )}`}
+      </tr>
+    `;
+  }
+
+  private renderSelectionHeader() {
+    const checked = this.numSelected > 0;
+    const indeterminate =
+      this.numSelected > 0 && this.numSelected !== this.totalChangeCount;
+    return html`
+      <td class="selection">
+        <!--
+          The .checked property must be used rather than the attribute because
+          the attribute only controls the default checked state and does not
+          update the current checked state.
+          See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#attr-checked
+        -->
+        <input
+          class="selection-checkbox"
+          type="checkbox"
+          .checked=${checked}
+          .indeterminate=${indeterminate}
+          @click=${this.handleSelectAllCheckboxClicked}
+        />
+      </td>
+    `;
+  }
+
+  private renderHeaderCell(item: string) {
+    return html`<td class=${item.toLowerCase()}>${item}</td>`;
+  }
+
+  private renderLabelHeader(labelName: string) {
+    return html`
+      <td class="label" title=${labelName}>
+        ${computeLabelShortcut(labelName)}
+      </td>
+    `;
+  }
+
+  private renderEndpointHeader(pluginHeader: string) {
+    return html`
+      <td class="endpoint">
+        <gr-endpoint-decorator .name=${pluginHeader}></gr-endpoint-decorator>
+      </td>
+    `;
+  }
+
+  private renderChangeRow(
+    change: ChangeInfo,
+    index: number,
+    columns: string[]
+  ) {
+    const ariaLabel = this.computeAriaLabel(change);
+    const selected = this.computeItemSelected(index);
+    return html`
+      <gr-change-list-item
+        tabindex="0"
+        .account=${this.account}
+        .selected=${selected}
+        .change=${change}
+        .config=${this.config}
+        .sectionName=${this.changeSection.name}
+        .visibleChangeTableColumns=${columns}
+        .showNumber=${this.showNumber}
+        ?showStar=${this.showStar}
+        .usp=${this.usp}
+        .labelNames=${this.labelNames}
+        .globalIndex=${this.startIndex + index}
+        .triggerSelectionCallback=${this.triggerSelectionCallback}
+        aria-label=${ariaLabel}
+        role="button"
+      ></gr-change-list-item>
+    `;
+  }
+
+  private handleSelectAllCheckboxClicked() {
+    if (this.numSelected === 0) {
+      this.bulkActionsModel.selectAll();
+    } else {
+      this.bulkActionsModel.clearSelectedChangeNums();
+    }
+  }
+
+  /**
+   * This methods allows us to customize the columns per section.
+   * Private but used in test
+   *
+   */
+  computeColumns() {
+    const section = this.changeSection;
+    if (!section || !this.visibleChangeTableColumns) return [];
+    const cols = [...this.visibleChangeTableColumns];
+    const updatedIndex = cols.indexOf(Metadata.UPDATED);
+    if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
+      cols[updatedIndex] = WAITING;
+    }
+    if (section.name === CLOSED.name && updatedIndex !== -1) {
+      cols[updatedIndex] = Metadata.SUBMITTED;
+    }
+    return cols;
+  }
+
+  toggleChange(index: number) {
+    this.bulkActionsModel.toggleSelectedChangeNum(
+      this.changeSection.results[index]._number
+    );
+  }
+
+  // private but used in test
+  computeItemSelected(index: number) {
+    return index === this.selectedIndex;
+  }
+
+  // private but used in test
+  computeColspan(cols: string[]) {
+    if (!cols || !this.labelNames) return 1;
+    return cols.length + this.labelNames.length + NUMBER_FIXED_COLUMNS;
+  }
+
+  // private but used in test
+  processQuery(query: string) {
+    let tokens = query.split(' ');
+    tokens = tokens.filter(
+      token =>
+        !INVALID_TOKENS.some(invalidToken => token.startsWith(invalidToken))
+    );
+    return tokens.join(' ');
+  }
+
+  private sectionHref(query?: string) {
+    if (!query) return '';
+    return createSearchUrl({query: this.processQuery(query)});
+  }
+
+  // private but used in test
+  isEmpty() {
+    return !this.changeSection.results?.length;
+  }
+
+  private computeAriaLabel(change?: ChangeInfo) {
+    const sectionName = this.changeSection.name;
+    if (!change) return '';
+    return change.subject + (sectionName ? `, section: ${sectionName}` : '');
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-section': GrChangeListSection;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
new file mode 100644
index 0000000..8dfecdc
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
@@ -0,0 +1,409 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  GrChangeListSection,
+  computeLabelShortcut,
+} from './gr-change-list-section';
+import '../../../test/common-test-setup';
+import './gr-change-list-section';
+import '../gr-change-list-item/gr-change-list-item';
+import {
+  createChange,
+  createAccountDetailWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {NumericChangeId, ChangeInfoId} from '../../../api/rest-api';
+import {
+  queryAll,
+  query,
+  queryAndAssert,
+  stubFlags,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeListSection} from '../gr-change-list/gr-change-list';
+import {fixture, html, assert} from '@open-wc/testing';
+import {ColumnNames} from '../../../constants/constants';
+
+suite('gr-change-list section', () => {
+  let element: GrChangeListSection;
+
+  setup(async () => {
+    const changeSection: ChangeListSection = {
+      name: 'test',
+      query: 'test',
+      results: [
+        {
+          ...createChange(),
+          _number: 0 as NumericChangeId,
+          id: '0' as ChangeInfoId,
+        },
+        {
+          ...createChange(),
+          _number: 1 as NumericChangeId,
+          id: '1' as ChangeInfoId,
+        },
+      ],
+      emptyStateSlotName: 'test',
+    };
+    element = await fixture<GrChangeListSection>(
+      html`<gr-change-list-section
+        .account=${createAccountDetailWithId(1)}
+        .config=${createServerInfo()}
+        .visibleChangeTableColumns=${Object.values(ColumnNames)}
+        .changeSection=${changeSection}
+      ></gr-change-list-section> `
+    );
+  });
+
+  test('renders headers when no changes are selected', () => {
+    // TODO: Check table elements. The shadowDom helper does not understand
+    // tables interacting with display: contents, even wrapping the element in a
+    // table, does not help.
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <td class="selection">
+        <input class="selection-checkbox" type="checkbox"/>
+      </td>
+      #
+              SubjectStatusOwnerReviewersCommentsRepoBranchUpdatedSize Status
+      <gr-change-list-item
+        aria-label="Test subject, section: test"
+        role="button"
+        tabindex="0"
+      >
+      </gr-change-list-item>
+      <gr-change-list-item
+        aria-label="Test subject, section: test"
+        role="button"
+        tabindex="0"
+      >
+      </gr-change-list-item>
+    `
+    );
+  });
+
+  test('renders action bar when some changes are selected', async () => {
+    assert.isNotOk(query(element, 'gr-change-list-action-bar'));
+    element.bulkActionsModel.setState({
+      ...element.bulkActionsModel.getState(),
+      selectedChangeNums: [1 as NumericChangeId],
+    });
+    await waitUntilObserved(
+      element.bulkActionsModel.selectedChangeNums$,
+      s => s.length === 1
+    );
+
+    element.requestUpdate();
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+        <td class="selection">
+          <input class="selection-checkbox" type="checkbox" />
+        </td>
+        <gr-change-list-action-bar></gr-change-list-action-bar>
+        <gr-change-list-item
+          aria-label="Test subject, section: test"
+          role="button"
+          tabindex="0"
+        >
+        </gr-change-list-item>
+        <gr-change-list-item
+          aria-label="Test subject, section: test"
+          checked=""
+          role="button"
+          tabindex="0"
+        >
+        </gr-change-list-item>
+      `
+    );
+  });
+
+  suite('bulk actions selection', () => {
+    let isEnabled: sinon.SinonStub;
+    setup(async () => {
+      isEnabled = stubFlags('isEnabled');
+      isEnabled.returns(true);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('changing section triggers model sync', async () => {
+      const syncStub = sinon.stub(element.bulkActionsModel, 'sync');
+      assert.isFalse(syncStub.called);
+      element.changeSection = {
+        name: 'test',
+        query: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 1 as NumericChangeId,
+            id: '1' as ChangeInfoId,
+          },
+        ],
+        emptyStateSlotName: 'test',
+      };
+      await element.updateComplete;
+
+      assert.isTrue(syncStub.called);
+    });
+
+    test('actions header is enabled/disabled based on selected changes', async () => {
+      element.bulkActionsModel.setState({
+        ...element.bulkActionsModel.getState(),
+        selectedChangeNums: [],
+      });
+      await waitUntilObserved(
+        element.bulkActionsModel.selectedChangeNums$,
+        s => s.length === 0
+      );
+      assert.isFalse(element.numSelected > 0);
+
+      element.bulkActionsModel.setState({
+        ...element.bulkActionsModel.getState(),
+        selectedChangeNums: [1 as NumericChangeId],
+      });
+      await waitUntilObserved(
+        element.bulkActionsModel.selectedChangeNums$,
+        s => s.length === 1
+      );
+      assert.isTrue(element.numSelected > 0);
+    });
+
+    test('select all checkbox checks all when none are selected', async () => {
+      element.changeSection = {
+        name: 'test',
+        query: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 1 as NumericChangeId,
+            id: '1' as ChangeInfoId,
+          },
+          {
+            ...createChange(),
+            _number: 2 as NumericChangeId,
+            id: '2' as ChangeInfoId,
+          },
+        ],
+        emptyStateSlotName: 'test',
+      };
+      await element.updateComplete;
+      let rows = queryAll(element, 'gr-change-list-item');
+      assert.lengthOf(rows, 2);
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[0], 'input').checked
+      );
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[1], 'input').checked
+      );
+
+      const checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      checkbox.click();
+      await waitUntilObserved(
+        element.bulkActionsModel.selectedChangeNums$,
+        s => s.length === 2
+      );
+      await element.updateComplete;
+
+      rows = queryAll(element, 'gr-change-list-item');
+      assert.lengthOf(rows, 2);
+      assert.isTrue(queryAndAssert<HTMLInputElement>(rows[0], 'input').checked);
+      assert.isTrue(queryAndAssert<HTMLInputElement>(rows[1], 'input').checked);
+    });
+
+    test('checkbox matches partial and fully selected state', async () => {
+      element.changeSection = {
+        name: 'test',
+        query: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 1 as NumericChangeId,
+            id: '1' as ChangeInfoId,
+          },
+          {
+            ...createChange(),
+            _number: 2 as NumericChangeId,
+            id: '2' as ChangeInfoId,
+          },
+        ],
+        emptyStateSlotName: 'test',
+      };
+      await element.updateComplete;
+      const rows = queryAll(element, 'gr-change-list-item');
+
+      // zero case
+      let checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.isFalse(checkbox.checked);
+      assert.isFalse(checkbox.indeterminate);
+
+      // partial case
+      queryAndAssert<HTMLInputElement>(rows[0], 'input').click();
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.isTrue(checkbox.indeterminate);
+
+      // plural case
+      queryAndAssert<HTMLInputElement>(rows[1], 'input').click();
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(element, 'input');
+      assert.isFalse(checkbox.indeterminate);
+      assert.isTrue(checkbox.checked);
+
+      // Clicking Check All checkbox when all checkboxes selected unselects
+      // all checkboxes
+      queryAndAssert<HTMLInputElement>(element, 'input');
+      checkbox.click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[0], 'input').checked
+      );
+      assert.isFalse(
+        queryAndAssert<HTMLInputElement>(rows[1], 'input').checked
+      );
+    });
+  });
+
+  test('colspans', async () => {
+    element.visibleChangeTableColumns = [];
+    element.changeSection = {results: [{...createChange()}]};
+    await element.updateComplete;
+    const tdItemCount = queryAll<HTMLTableElement>(element, 'td').length;
+
+    element.labelNames = [];
+    assert.equal(tdItemCount, element.computeColspan(element.computeColumns()));
+  });
+
+  test('computeItemSelected', () => {
+    element.selectedIndex = 1;
+    assert.isTrue(element.computeItemSelected(1));
+    assert.isFalse(element.computeItemSelected(2));
+  });
+
+  test('computed fields', () => {
+    assert.equal(computeLabelShortcut('Code-Review'), 'CR');
+    assert.equal(computeLabelShortcut('Verified'), 'V');
+    assert.equal(computeLabelShortcut('Library-Compliance'), 'LC');
+    assert.equal(computeLabelShortcut('PolyGerrit-Review'), 'PR');
+    assert.equal(computeLabelShortcut('polygerrit-review'), 'PR');
+    assert.equal(
+      computeLabelShortcut('Invalid-Prolog-Rules-Label-Name--Verified'),
+      'V'
+    );
+    assert.equal(computeLabelShortcut('Some-Special-Label-7'), 'SSL7');
+    assert.equal(computeLabelShortcut('--Too----many----dashes---'), 'TMD');
+    assert.equal(
+      computeLabelShortcut('Really-rather-entirely-too-long-of-a-label-name'),
+      'RRETL'
+    );
+  });
+
+  suite('empty section slots', () => {
+    test('empty section', async () => {
+      element.changeSection = {results: []};
+      await element.updateComplete;
+      const listItems = queryAll<GrChangeListItem>(
+        element,
+        'gr-change-list-item'
+      );
+      assert.equal(listItems.length, 0);
+      const noChangesMsg = queryAll<HTMLTableRowElement>(element, '.noChanges');
+      assert.equal(noChangesMsg.length, 1);
+    });
+
+    test('are shown on empty sections with slot name', async () => {
+      const section = {
+        name: 'test',
+        query: 'test',
+        results: [],
+        emptyStateSlotName: 'test',
+      };
+      element.changeSection = section;
+      await element.updateComplete;
+
+      assert.isEmpty(queryAll(element, 'gr-change-list-item'));
+      queryAndAssert(element, 'slot[name="test"]');
+    });
+
+    test('are not shown on empty sections without slot name', async () => {
+      const section = {name: 'test', query: 'test', results: []};
+      element.changeSection = section;
+      await element.updateComplete;
+
+      assert.isEmpty(queryAll(element, 'gr-change-list-item'));
+      assert.notExists(query(element, 'slot[name="test"]'));
+    });
+
+    test('are not shown on non-empty sections with slot name', async () => {
+      const section = {
+        name: 'test',
+        query: 'test',
+        emptyStateSlotName: 'test',
+        results: [
+          {
+            ...createChange(),
+            _number: 0 as NumericChangeId,
+            labels: {Verified: {approved: {}}},
+          },
+        ],
+      };
+      element.changeSection = section;
+      await element.updateComplete;
+
+      assert.isNotEmpty(queryAll(element, 'gr-change-list-item'));
+      assert.notExists(query(element, 'slot[name="test"]'));
+    });
+  });
+
+  suite('dashboard queries', () => {
+    test('query without age and limit unchanged', () => {
+      const query = 'status:closed owner:me';
+      assert.deepEqual(element.processQuery(query), query);
+    });
+
+    test('query with age and limit', () => {
+      const query = 'status:closed age:1week limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+
+    test('query with age', () => {
+      const query = 'status:closed age:1week owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+
+    test('query with limit', () => {
+      const query = 'status:closed limit:10 owner:me';
+      const expectedQuery = 'status:closed owner:me';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+
+    test('query with age as value and not key', () => {
+      const query = 'status:closed random:age';
+      const expectedQuery = 'status:closed random:age';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+
+    test('query with limit as value and not key', () => {
+      const query = 'status:closed random:limit';
+      const expectedQuery = 'status:closed random:limit';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+
+    test('query with -age key', () => {
+      const query = 'status:closed -age:1week';
+      const expectedQuery = 'status:closed';
+      assert.deepEqual(element.processQuery(query), expectedQuery);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
new file mode 100644
index 0000000..7752476
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -0,0 +1,448 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, TopicName} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '@polymer/iron-dropdown/iron-dropdown';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {getAppContext} from '../../../services/app-context';
+import {isDefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map.js';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+import {fireReload} from '../../../utils/event-util';
+import {fireAlert} from '../../../utils/event-util';
+import {pluralize} from '../../../utils/string-util';
+import {Interaction} from '../../../constants/reporting';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+
+@customElement('gr-change-list-topic-flow')
+export class GrChangeListTopicFlow extends LitElement {
+  @state() private selectedChanges: ChangeInfo[] = [];
+
+  @state() private topicToAdd: TopicName = '' as TopicName;
+
+  @state() private existingTopicSuggestions: TopicName[] = [];
+
+  @state() private loadingText?: string;
+
+  @state() private errorText?: string;
+
+  /** dropdown status is tracked here to lazy-load the inner DOM contents */
+  @state() private isDropdownOpen = false;
+
+  @state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  private selectedExistingTopics: Set<TopicName> = new Set();
+
+  private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+  private restApiService = getAppContext().restApiService;
+
+  private readonly reportingService = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      spinnerStyles,
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: 400px;
+          background-color: var(--dialog-background-color);
+          border-radius: 4px;
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
+        }
+        gr-autocomplete {
+          --prominent-border-color: var(--gray-800);
+        }
+        .footer {
+          display: flex;
+          justify-content: space-between;
+          align-items: baseline;
+        }
+        .buttons {
+          padding-top: var(--spacing-m);
+          display: flex;
+          justify-content: flex-end;
+          gap: var(--spacing-m);
+        }
+        .chips {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+        }
+        .chip {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-radius: 10px;
+          width: fit-content;
+          cursor: pointer;
+          color: var(--primary-text-color);
+        }
+        .chip:not(.selected) {
+          border: var(--spacing-xxs) solid var(--border-color);
+          background: none;
+        }
+        .chip.selected {
+          border: 0;
+          color: var(--selected-foreground);
+          background-color: var(--selected-chip-background);
+          margin: var(--spacing-xxs);
+        }
+        .loadingOrError {
+          display: flex;
+          align-items: baseline;
+          gap: var(--spacing-s);
+        }
+
+        /* The basics of .loadingSpin are defined in spinnerStyles. */
+        .loadingSpin {
+          vertical-align: top;
+          position: relative;
+          top: 3px;
+        }
+        .error {
+          color: var(--deemphasized-text-color);
+        }
+        gr-icon {
+          color: var(--error-color);
+          /* Center with text by aligning it to the top and then pushing it down
+             to match the text */
+          vertical-align: top;
+          position: relative;
+          top: 7px;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getBulkActionsModel().selectedChanges$,
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+      }
+    );
+  }
+
+  override render() {
+    const isFlowDisabled = this.selectedChanges.length === 0;
+    return html`
+      <gr-button
+        id="start-flow"
+        flatten
+        down-arrow
+        @click=${this.toggleDropdown}
+        .disabled=${isFlowDisabled}
+        >Topic</gr-button
+      >
+      <iron-dropdown
+        .horizontalAlign=${'auto'}
+        .verticalAlign=${'auto'}
+        .verticalOffset=${24}
+        @opened-changed=${(e: CustomEvent) =>
+          (this.isDropdownOpen = e.detail.value)}
+      >
+        ${when(
+          this.isDropdownOpen,
+          () => html`
+            <div slot="dropdown-content">
+              ${when(
+                this.selectedChanges.some(change => change.topic),
+                () => this.renderExistingTopicsMode(),
+                () => this.renderNoExistingTopicsMode()
+              )}
+            </div>
+          `
+        )}
+      </iron-dropdown>
+    `;
+  }
+
+  private disableApplyToAllButton() {
+    if (this.selectedExistingTopics.size !== 1) return true;
+    // Ensure there is one selected change that does not have this topic
+    // already
+    return !this.selectedChanges
+      .map(change => change.topic)
+      .filter(unique)
+      .some(topic => !topic || !this.selectedExistingTopics.has(topic));
+  }
+
+  private renderExistingTopicsMode() {
+    const topics = this.selectedChanges
+      .map(change => change.topic)
+      .filter(isDefined)
+      .filter(unique);
+    const removeDisabled =
+      this.selectedExistingTopics.size === 0 ||
+      this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <div class="chips">
+        ${topics.map(name => this.renderExistingTopicChip(name))}
+      </div>
+      <div class="footer">
+        <div class="loadingOrError" role="progressbar">
+          ${this.renderLoadingOrError()}
+        </div>
+        <div class="buttons">
+          ${when(
+            this.overallProgress !== ProgressStatus.FAILED,
+            () => html` <gr-button
+                id="apply-to-all-button"
+                flatten
+                ?disabled=${this.disableApplyToAllButton()}
+                @click=${this.applyTopicToAll}
+                >Apply${this.selectedChanges.length > 1
+                  ? ' to all'
+                  : nothing}</gr-button
+              >
+              <gr-button
+                id="remove-topics-button"
+                flatten
+                ?disabled=${removeDisabled}
+                @click=${this.removeTopics}
+                >Remove</gr-button
+              >`,
+            () =>
+              html`
+                <gr-button
+                  id="cancel-button"
+                  flatten
+                  @click=${this.closeDropdown}
+                  >Cancel</gr-button
+                >
+              `
+          )}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderExistingTopicChip(name: TopicName) {
+    const chipClasses = {
+      chip: true,
+      selected: this.selectedExistingTopics.has(name),
+    };
+    return html`
+      <button
+        role="listbox"
+        aria-label=${`${name as string} selection`}
+        class=${classMap(chipClasses)}
+        @click=${() => this.toggleExistingTopicSelected(name)}
+      >
+        ${name}
+      </button>
+    `;
+  }
+
+  private renderLoadingOrError() {
+    switch (this.overallProgress) {
+      case ProgressStatus.RUNNING:
+        return html`
+          <span class="loadingSpin"></span>
+          <span class="loadingText">${this.loadingText}</span>
+        `;
+      case ProgressStatus.FAILED:
+        return html`
+          <gr-icon icon="error" filled></gr-icon>
+          <div class="error">${this.errorText}</div>
+        `;
+      default:
+        return nothing;
+    }
+  }
+
+  private renderNoExistingTopicsMode() {
+    const isApplyTopicDisabled =
+      this.topicToAdd === '' || this.overallProgress === ProgressStatus.RUNNING;
+    return html`
+      <!--
+        The .query function needs to be bound to this because lit's autobind
+        seems to work only for @event handlers.
+        'this.getTopicSuggestions.bind(this)' gets in trouble with our linter
+        even though the bind is necessary here, so an anonymous function is used
+        instead.
+      -->
+      <gr-autocomplete
+        .text=${this.topicToAdd}
+        .query=${(query: string) => this.getTopicSuggestions(query)}
+        show-blue-focus-border
+        placeholder="Type topic name to create or filter topics"
+        @text-changed=${(e: ValueChangedEvent<TopicName>) =>
+          (this.topicToAdd = e.detail.value)}
+      ></gr-autocomplete>
+      <div class="footer">
+        <div class="loadingOrError" role="progressbar">
+          ${this.renderLoadingOrError()}
+        </div>
+        <div class="buttons">
+          ${when(
+            this.overallProgress !== ProgressStatus.FAILED,
+            () => html`
+              <gr-button
+                id="set-topic-button"
+                flatten
+                @click=${() => this.setTopic('Setting topic...')}
+                .disabled=${isApplyTopicDisabled}
+                >Set Topic</gr-button
+              >
+            `,
+            () => html`
+              <gr-button id="cancel-button" flatten @click=${this.closeDropdown}
+                >Cancel</gr-button
+              >
+            `
+          )}
+        </div>
+      </div>
+    `;
+  }
+
+  private toggleDropdown() {
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
+  }
+
+  private reset() {
+    this.topicToAdd = '' as TopicName;
+    this.selectedExistingTopics = new Set();
+    this.overallProgress = ProgressStatus.NOT_STARTED;
+    this.errorText = undefined;
+  }
+
+  private closeDropdown() {
+    this.isDropdownOpen = false;
+    this.dropdown?.close();
+  }
+
+  private openDropdown() {
+    this.reset();
+    this.isDropdownOpen = true;
+    this.dropdown?.open();
+  }
+
+  private async getTopicSuggestions(
+    query: string
+  ): Promise<AutocompleteSuggestion[]> {
+    const suggestions = await this.restApiService.getChangesWithSimilarTopic(
+      query,
+      throwingErrorCallback
+    );
+    this.existingTopicSuggestions = (suggestions ?? [])
+      .map(change => change.topic)
+      .filter(isDefined)
+      .filter(unique);
+    return this.existingTopicSuggestions.map(topic => {
+      return {name: topic, value: topic};
+    });
+  }
+
+  private removeTopics() {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'removing-topic',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    this.loadingText = `Removing topic${
+      this.selectedExistingTopics.size > 1 ? 's' : ''
+    }...`;
+    this.trackPromises(
+      this.selectedChanges
+        .filter(
+          change =>
+            change.topic && this.selectedExistingTopics.has(change.topic)
+        )
+        .map(change => this.restApiService.setChangeTopic(change._number, '')),
+      `${this.selectedChanges[0].topic} removed from changes`,
+      'Failed to remove topic'
+    );
+  }
+
+  private applyTopicToAll() {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'apply-topic-to-all',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    this.loadingText = 'Applying to all';
+    const topic = Array.from(this.selectedExistingTopics.values())[0];
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(change._number, topic)
+      ),
+      `${topic} applied to all changes`,
+      'Failed to apply topic'
+    );
+  }
+
+  private setTopic(loadingText: string) {
+    this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
+      type: 'add-topic',
+      selectedChangeCount: this.selectedChanges.length,
+    });
+    const alert = `${pluralize(
+      this.selectedChanges.length,
+      'Change'
+    )} added to ${this.topicToAdd}`;
+    this.loadingText = loadingText;
+    this.trackPromises(
+      this.selectedChanges.map(change =>
+        this.restApiService.setChangeTopic(change._number, this.topicToAdd)
+      ),
+      alert,
+      'Failed to set topic'
+    );
+  }
+
+  private async trackPromises(
+    promises: Promise<string>[],
+    alert: string,
+    errorMessage: string
+  ) {
+    this.overallProgress = ProgressStatus.RUNNING;
+    const results = await allSettled(promises);
+    if (results.every(result => result.status === 'fulfilled')) {
+      this.overallProgress = ProgressStatus.SUCCESSFUL;
+      this.closeDropdown();
+      if (alert) {
+        fireAlert(this, alert);
+      }
+      fireReload(this);
+    } else {
+      this.overallProgress = ProgressStatus.FAILED;
+      this.errorText = errorMessage;
+    }
+  }
+
+  private toggleExistingTopicSelected(name: TopicName) {
+    if (this.selectedExistingTopics.has(name)) {
+      this.selectedExistingTopics.delete(name);
+    } else {
+      this.selectedExistingTopics.add(name);
+    }
+    this.requestUpdate();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-topic-flow': GrChangeListTopicFlow;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
new file mode 100644
index 0000000..9125cfd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -0,0 +1,771 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html, assert} from '@open-wc/testing';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {SinonStubbedMember} from 'sinon';
+import {
+  BulkActionsModel,
+  bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import '../../../test/common-test-setup';
+import {createChange} from '../../../test/test-data-generators';
+import {
+  MockPromise,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+  waitEventLoop,
+  waitUntil,
+  waitUntilCalled,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, TopicName} from '../../../types/common';
+import {EventType} from '../../../types/events';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-topic-flow';
+import type {GrChangeListTopicFlow} from './gr-change-list-topic-flow';
+
+suite('gr-change-list-topic-flow tests', () => {
+  let element: GrChangeListTopicFlow;
+  let model: BulkActionsModel;
+  let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>;
+
+  setup(() => {
+    reportingStub = stubReporting('reportInteraction');
+  });
+
+  async function selectChange(change: ChangeInfo) {
+    model.addSelectedChangeNum(change._number);
+    await waitUntilObserved(model.selectedChanges$, selected =>
+      selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  async function deselectChange(change: ChangeInfo) {
+    model.removeSelectedChangeNum(change._number);
+    await waitUntilObserved(
+      model.selectedChanges$,
+      selected => !selected.some(other => other._number === change._number)
+    );
+    await element.updateComplete;
+  }
+
+  suite('dropdown closed', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changes);
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changes);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+      await selectChange(changes[0]);
+      await selectChange(changes[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+    });
+
+    test('skips dropdown render when closed', async () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            aria-hidden="true"
+            style="outline: none; display: none;"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+          </iron-dropdown>
+        `
+      );
+    });
+
+    test('dropdown hidden before flow button clicked', async () => {
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('flow button click shows dropdown', async () => {
+      const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+      button.click();
+      await element.updateComplete;
+
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        'iron-dropdown'
+      );
+      assert.isTrue(dropdown.opened);
+    });
+
+    test('flow button click when open hides dropdown', async () => {
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(() =>
+        Boolean(
+          queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+        )
+      );
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await waitUntil(
+        () =>
+          !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+      );
+    });
+  });
+
+  suite('changes in existing topics', () => {
+    const changesWithTopics: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        topic: 'topic1' as TopicName,
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        topic: 'topic2' as TopicName,
+      },
+    ];
+    let setChangeTopicPromises: MockPromise<string>[];
+    let setChangeTopicStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeTopicPromises[0].resolve('foo');
+      setChangeTopicPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    async function rejectPromises() {
+      setChangeTopicPromises[0].reject(new Error('error'));
+      setChangeTopicPromises[1].reject(new Error('error'));
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(changesWithTopics);
+      setChangeTopicPromises = [];
+      setChangeTopicStub = stubRestApi('setChangeTopic');
+      for (let i = 0; i < changesWithTopics.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeTopicPromises.push(promise);
+        setChangeTopicStub
+          .withArgs(changesWithTopics[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithTopics);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+
+      // select changes
+      await selectChange(changesWithTopics[0]);
+      await selectChange(changesWithTopics[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await waitEventLoop();
+    });
+
+    test('renders existing-topics flow', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <div class="chips">
+                <button
+                  role="listbox"
+                  aria-label="topic1 selection"
+                  class="chip"
+                >
+                  topic1
+                </button>
+                <button
+                  role="listbox"
+                  aria-label="topic2 selection"
+                  class="chip"
+                >
+                  topic2
+                </button>
+              </div>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="apply-to-all-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Apply to all</gr-button
+                  >
+                  <gr-button
+                    id="remove-topics-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Remove</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('apply all button is disabled if all changes have the same topic', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      await deselectChange(changesWithTopics[1]);
+
+      const allChanges = model.getState().allChanges;
+      const change2 = {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+        topic: 'topic1' as TopicName, // same as changesWithTopics[0]
+      };
+      allChanges.set(2 as NumericChangeId, change2);
+      model.setState({
+        ...model.getState(),
+        allChanges,
+      });
+
+      await selectChange(change2);
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('remove single topic', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topic...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different topic
+      assert.isTrue(setChangeTopicStub.calledOnce);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        '',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'topic1 removed from changes',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'removing-topic',
+        selectedChangeCount: 2,
+      });
+    });
+
+    test('remove multiple topics', async () => {
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topics...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      // not called for second change which as a different topic
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        '',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithTopics[1]._number,
+        '',
+      ]);
+    });
+
+    test('shows error when remove topic fails', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, '#remove-topics-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Removing topic...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+
+      await waitUntil(() => query(element, '.error') !== undefined);
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to remove topic'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('can only apply a single topic', async () => {
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
+      await element.updateComplete;
+
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+      );
+    });
+
+    test('applies topic to all changes', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
+      queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '#apply-to-all-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Applying to all'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithTopics[0]._number,
+        'topic1',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithTopics[1]._number,
+        'topic1',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'topic1 applied to all changes',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'apply-topic-to-all',
+        selectedChangeCount: 2,
+      });
+    });
+  });
+
+  suite('change have no existing topics', () => {
+    const changesWithNoTopics: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+    let setChangeTopicPromises: MockPromise<string>[];
+    let setChangeTopicStub: sinon.SinonStub;
+
+    async function resolvePromises() {
+      setChangeTopicPromises[0].resolve('foo');
+      setChangeTopicPromises[1].resolve('foo');
+      await element.updateComplete;
+    }
+
+    async function rejectPromises() {
+      setChangeTopicPromises[0].reject(new Error('error'));
+      setChangeTopicPromises[1].reject(new Error('error'));
+      await element.updateComplete;
+    }
+
+    setup(async () => {
+      stubRestApi('getDetailedChangesWithActions').resolves(
+        changesWithNoTopics
+      );
+      setChangeTopicPromises = [];
+      setChangeTopicStub = stubRestApi('setChangeTopic');
+      for (let i = 0; i < changesWithNoTopics.length; i++) {
+        const promise = mockPromise<string>();
+        setChangeTopicPromises.push(promise);
+        setChangeTopicStub
+          .withArgs(changesWithNoTopics[i]._number, sinon.match.any)
+          .returns(promise);
+      }
+
+      model = new BulkActionsModel(getAppContext().restApiService);
+      model.sync(changesWithNoTopics);
+
+      element = (
+        await fixture(
+          wrapInProvider(
+            html`<gr-change-list-topic-flow></gr-change-list-topic-flow>`,
+            bulkActionsModelToken,
+            model
+          )
+        )
+      ).querySelector('gr-change-list-topic-flow')!;
+
+      // select changes
+      await selectChange(changesWithNoTopics[0]);
+      await selectChange(changesWithNoTopics[1]);
+      await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+      await element.updateComplete;
+
+      // open flow
+      queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+      await element.updateComplete;
+      await waitEventLoop();
+    });
+
+    test('renders no-existing-topics flow', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-button
+            id="start-flow"
+            flatten=""
+            down-arrow=""
+            aria-disabled="false"
+            role="button"
+            tabindex="0"
+            >Topic</gr-button
+          >
+          <iron-dropdown
+            aria-disabled="false"
+            vertical-align="auto"
+            horizontal-align="auto"
+          >
+            <div slot="dropdown-content">
+              <gr-autocomplete
+                placeholder="Type topic name to create or filter topics"
+                show-blue-focus-border=""
+              ></gr-autocomplete>
+              <div class="footer">
+                <div class="loadingOrError" role="progressbar"></div>
+                <div class="buttons">
+                  <gr-button
+                    id="set-topic-button"
+                    flatten=""
+                    aria-disabled="true"
+                    disabled=""
+                    role="button"
+                    tabindex="-1"
+                    >Set Topic</gr-button
+                  >
+                </div>
+              </div>
+            </div>
+          </iron-dropdown>
+        `,
+        {
+          // iron-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+        }
+      );
+    });
+
+    test('create new topic', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
+        []
+      );
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await resolvePromises();
+      await element.updateComplete;
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithNoTopics[0]._number,
+        'foo',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithNoTopics[1]._number,
+        'foo',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '2 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-topic',
+        selectedChangeCount: 2,
+      });
+    });
+
+    test('shows error when create topic fails', async () => {
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
+        []
+      );
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+      await waitUntil(() => query(element, '.error') !== undefined);
+
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to set topic'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+
+    test('apply topic', async () => {
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves([
+        {...createChange(), topic: 'foo' as TopicName},
+      ]);
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await resolvePromises();
+
+      assert.isTrue(setChangeTopicStub.calledTwice);
+      assert.deepEqual(setChangeTopicStub.firstCall.args, [
+        changesWithNoTopics[0]._number,
+        'foo',
+      ]);
+      assert.deepEqual(setChangeTopicStub.secondCall.args, [
+        changesWithNoTopics[1]._number,
+        'foo',
+      ]);
+
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: '2 Changes added to foo',
+        showDismiss: true,
+      });
+      assert.deepEqual(reportingStub.lastCall.args[1], {
+        type: 'add-topic',
+        selectedChangeCount: 2,
+      });
+    });
+
+    test('shows error when setting topic fails', async () => {
+      const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves([
+        {...createChange(), topic: 'foo' as TopicName},
+      ]);
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      const autocomplete = queryAndAssert<GrAutocomplete>(
+        element,
+        'gr-autocomplete'
+      );
+
+      autocomplete.setFocus(true);
+      autocomplete.text = 'foo';
+      await element.updateComplete;
+      await waitUntilCalled(getTopicsStub, 'getTopicsStub');
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, '#set-topic-button').disabled
+      );
+      queryAndAssert<GrButton>(element, '#set-topic-button').click();
+      await element.updateComplete;
+
+      assert.equal(
+        queryAndAssert(element, '.loadingText').textContent,
+        'Setting topic...'
+      );
+
+      await rejectPromises();
+      await element.updateComplete;
+
+      await waitUntil(() => query(element, '.error') !== undefined);
+      assert.equal(
+        queryAndAssert(element, '.error').textContent,
+        'Failed to set topic'
+      );
+      assert.equal(
+        queryAndAssert(element, 'gr-button#cancel-button').textContent,
+        'Cancel'
+      );
+      assert.isUndefined(query(element, '.loadingText'));
+    });
+  });
+});
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 2360312..faaee0b 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
@@ -1,31 +1,12 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../shared/gr-icons/gr-icons';
 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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-list-view_html';
 import {page} from '../../../utils/page-wrapper-utils';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
-import {AppElementParams} from '../../gr-app-types';
 import {
   AccountDetailInfo,
   AccountId,
@@ -35,193 +16,254 @@
   RepoName,
 } from '../../../types/common';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {ChangeListViewState} from '../../../types/types';
-import {fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
-import {GerritView} from '../../../services/router/router-model';
-import {RELOAD_DASHBOARD_INTERVAL_MS} from '../../../constants/constants';
-
-const LOOKUP_QUERY_PATTERNS: RegExp[] = [
-  /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
-  /^\s*[1-9][0-9]*\s*$/g, // CHANGE_NUM
-  /[0-9a-f]{40}/, // COMMIT
-];
-
-const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
-
-const REPO_QUERY_PATTERN =
-  /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css, nothing} from 'lit';
+import {customElement, state, query} from 'lit/decorators.js';
+import {
+  createSearchUrl,
+  searchViewModelToken,
+} from '../../../models/views/search';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
 
 const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
-export interface GrChangeListView {
-  $: {
-    prevArrow: HTMLAnchorElement;
-    nextArrow: HTMLAnchorElement;
-  };
-}
-
 @customElement('gr-change-list-view')
-export class GrChangeListView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeListView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
    * @event title-change
    */
 
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: AppElementParams;
+  @query('#prevArrow') protected prevArrow?: HTMLAnchorElement;
 
-  @property({type: Boolean, computed: '_computeLoggedIn(account)'})
-  _loggedIn?: boolean;
+  @query('#nextArrow') protected nextArrow?: HTMLAnchorElement;
 
-  @property({type: Object})
-  account: AccountDetailInfo | null = null;
+  // private but used in test
+  @state() account?: AccountDetailInfo;
 
-  @property({type: Object, notify: true})
-  viewState: ChangeListViewState = {};
+  // private but used in test
+  @state() loggedIn = false;
 
-  @property({type: Object})
-  preferences?: PreferencesInput;
+  // private but used in test
+  @state() preferences?: PreferencesInput;
 
-  @property({type: Number})
-  _changesPerPage?: number;
+  // private but used in test
+  @state() changesPerPage?: number;
 
-  @property({type: String})
-  _query = '';
+  // private but used in test
+  @state() query = '';
 
-  @property({type: Number})
-  _offset?: number;
+  // private but used in test
+  @state() offset = 0;
 
-  @property({type: Array, observer: '_changesChanged'})
-  _changes?: ChangeInfo[];
+  // private but used in test
+  @state() changes: ChangeInfo[] = [];
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: String})
-  _userId: AccountId | EmailAddress | null = null;
+  // private but used in test
+  @state() userId?: AccountId | EmailAddress;
 
-  @property({type: String})
-  _repo: RepoName | null = null;
+  // private but used in test
+  @state() repo?: RepoName;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private reporting = appContext.reportingService;
+  private reporting = getAppContext().reportingService;
 
-  private lastVisibleTimestampMs = 0;
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getViewModel = resolve(this, searchViewModelToken);
 
   constructor() {
     super();
-    this.addEventListener('next-page', () => this._handleNextPage());
-    this.addEventListener('previous-page', () => this._handlePreviousPage());
-    this.addEventListener('reload', () => this.reload());
-    // We are not currently verifying if the view is actually visible. We rely
-    // on gr-app-element to restamp the component if view changes
-    document.addEventListener('visibilitychange', () => {
-      if (document.visibilityState === 'visible') {
-        if (
-          Date.now() - this.lastVisibleTimestampMs >
-          RELOAD_DASHBOARD_INTERVAL_MS
-        )
-          this.reload();
-      } else {
-        this.lastVisibleTimestampMs = Date.now();
-      }
-    });
-  }
+    this.addEventListener('next-page', () => this.handleNextPage());
+    this.addEventListener('previous-page', () => this.handlePreviousPage());
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this._loadPreferences();
-  }
-
-  reload() {
-    if (this._loading) return;
-    this._loading = true;
-    this._getChanges().then(changes => {
-      this._changes = changes || [];
-      this._loading = false;
-    });
-  }
-
-  _paramsChanged(value: AppElementParams) {
-    if (value.view !== GerritView.SEARCH) return;
-
-    this._loading = true;
-    this._query = value.query;
-    const offset = Number(value.offset);
-    this._offset = isNaN(offset) ? 0 : offset;
-    if (
-      this.viewState.query !== this._query ||
-      this.viewState.offset !== this._offset
-    ) {
-      this.set('viewState.selectedChangeIndex', 0);
-      this.set('viewState.query', this._query);
-      this.set('viewState.offset', this._offset);
-    }
-
-    // NOTE: This method may be called before attachment. Fire title-change
-    // in an async so that attachment to the DOM can take place first.
-    setTimeout(() => fireTitleChange(this, this._query));
-
-    this.restApiService
-      .getPreferences()
-      .then(prefs => {
-        if (!prefs) {
-          throw new Error('getPreferences returned undefined');
-        }
-        this._changesPerPage = prefs.changes_per_page;
-        return this._getChanges();
-      })
-      .then(changes => {
-        changes = changes || [];
-        if (this._query && changes.length === 1) {
-          for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
-            if (this._query.match(queryPattern)) {
-              // "Back"/"Forward" buttons work correctly only with
-              // opt_redirect options
-              GerritNav.navigateToChange(
-                changes[0],
-                undefined,
-                undefined,
-                undefined,
-                true
-              );
-              return;
-            }
-          }
-        }
-        this._changes = changes;
-        this._loading = false;
-      });
-  }
-
-  _loadPreferences() {
-    return this.restApiService.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        this.restApiService.getPreferences().then(preferences => {
-          this.preferences = preferences;
-        });
-      } else {
-        this.preferences = {};
-      }
-    });
-  }
-
-  _getChanges() {
-    return this.restApiService.getChanges(
-      this._changesPerPage,
-      this._query,
-      this._offset
+    subscribe(
+      this,
+      () => this.getViewModel().query$,
+      x => (this.query = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().offsetNumber$,
+      x => (this.offset = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().loading$,
+      x => (this.loading = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().changes$,
+      x => (this.changes = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().userId$,
+      x => (this.userId = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().repo$,
+      x => (this.repo = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferenceChangesPerPage$,
+      x => (this.changesPerPage = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      x => (this.preferences = x)
     );
   }
 
-  _limitFor(query: string, defaultLimit: number) {
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        .loading {
+          color: var(--deemphasized-text-color);
+          padding: var(--spacing-l);
+        }
+        gr-change-list {
+          width: 100%;
+        }
+        gr-user-header,
+        gr-repo-header {
+          border-bottom: 1px solid var(--border-color);
+        }
+        nav {
+          align-items: center;
+          display: flex;
+          height: 3rem;
+          justify-content: flex-end;
+          margin-right: 20px;
+          color: var(--deemphasized-text-color);
+        }
+        gr-icon {
+          font-size: 1.85rem;
+          margin-left: 16px;
+        }
+        .hide {
+          display: none;
+        }
+        @media only screen and (max-width: 50em) {
+          .loading,
+          .error {
+            padding: 0 var(--spacing-l);
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    // In case of an internal reload we want the ChangeList section components
+    // to remain in the DOM so that the Bulk Actions Model associated with them
+    // is not recreated after the reload resulting in user selections being lost
+    return html`
+      <div class="loading" ?hidden=${!this.loading}>Loading...</div>
+      <div ?hidden=${this.loading}>
+        ${this.renderRepoHeader()} ${this.renderUserHeader()}
+        <gr-change-list
+          .account=${this.account}
+          .changes=${this.changes}
+          .preferences=${this.preferences}
+          .showStar=${this.loggedIn}
+          @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
+            this.handleToggleStar(e);
+          }}
+          .usp=${'search'}
+        ></gr-change-list>
+        ${this.renderChangeListViewNav()}
+      </div>
+    `;
+  }
+
+  private renderRepoHeader() {
+    if (!this.repo) return nothing;
+
+    return html` <gr-repo-header .repo=${this.repo}></gr-repo-header> `;
+  }
+
+  private renderUserHeader() {
+    if (!this.userId) return nothing;
+
+    return html`
+      <gr-user-header
+        .userId=${this.userId}
+        showDashboardLink
+        .loggedIn=${this.loggedIn}
+      ></gr-user-header>
+    `;
+  }
+
+  private renderChangeListViewNav() {
+    if (this.loading || !this.changes || !this.changes.length) return nothing;
+
+    return html`
+      <nav>
+        Page ${this.computePage()} ${this.renderPrevArrow()}
+        ${this.renderNextArrow()}
+      </nav>
+    `;
+  }
+
+  private renderPrevArrow() {
+    if (this.offset === 0) return nothing;
+
+    return html`
+      <a id="prevArrow" href=${this.computeNavLink(-1)}>
+        <gr-icon icon="chevron_left" aria-label="Older"></gr-icon>
+      </a>
+    `;
+  }
+
+  private renderNextArrow() {
+    const changesCount = this.changes?.length ?? 0;
+    if (changesCount === 0) return nothing;
+    if (!this.changes?.[changesCount - 1]._more_changes) return nothing;
+
+    return html`
+      <a id="nextArrow" href=${this.computeNavLink(1)}>
+        <gr-icon icon="chevron_right" aria-label="Newer"></gr-icon>
+      </a>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('query')) {
+      fireTitleChange(this, this.query);
+    }
+  }
+
+  // private but used in test
+  limitFor(query: string, defaultLimit?: number) {
+    if (defaultLimit === undefined) return 0;
     const match = query.match(LIMIT_OPERATOR_PATTERN);
     if (!match) {
       return defaultLimit;
@@ -229,93 +271,53 @@
     return Number(match[1]);
   }
 
-  _computeNavLink(
-    query: string,
-    offset: number | undefined,
-    direction: number,
-    changesPerPage: number
-  ) {
-    offset = offset ?? 0;
-    const limit = this._limitFor(query, changesPerPage);
+  // private but used in test
+  computeNavLink(direction: number) {
+    const offset = this.offset ?? 0;
+    const limit = this.limitFor(this.query, this.changesPerPage);
     const newOffset = Math.max(0, offset + limit * direction);
-    return GerritNav.getUrlForSearchQuery(query, newOffset);
+    return createSearchUrl({query: this.query, offset: newOffset});
   }
 
-  _computePrevArrowClass(offset?: number) {
-    return offset === 0 ? 'hide' : '';
+  // private but used in test
+  handleNextPage() {
+    if (!this.nextArrow || !this.changesPerPage) return;
+    // TODO: Use navigation service instead of `page.show()` directly.
+    page.show(this.computeNavLink(1));
   }
 
-  _computeNextArrowClass(changes?: ChangeInfo[]) {
-    const more = changes?.length && changes[changes.length - 1]._more_changes;
-    return more ? '' : 'hide';
+  // private but used in test
+  handlePreviousPage() {
+    if (!this.prevArrow || !this.changesPerPage) return;
+    // TODO: Use navigation service instead of `page.show()` directly.
+    page.show(this.computeNavLink(-1));
   }
 
-  _computeNavClass(loading?: boolean) {
-    return loading || !this._changes || !this._changes.length ? 'hide' : '';
-  }
-
-  _handleNextPage() {
-    if (this.$.nextArrow.hidden || !this._changesPerPage) return;
-    page.show(
-      this._computeNavLink(this._query, this._offset, 1, this._changesPerPage)
+  // private but used in test
+  computePage() {
+    if (this.offset === undefined || this.changesPerPage === undefined) return;
+    // We use Math.ceil in case the offset is not divisible by changesPerPage.
+    // If we did not do this, you'd have page '1.2' and then when pressing left
+    // arrow 'Page 1'.  This way page '1.2' becomes page '2'.
+    return (
+      Math.ceil(this.offset / this.limitFor(this.query, this.changesPerPage)) +
+      1
     );
   }
 
-  _handlePreviousPage() {
-    if (this.$.prevArrow.hidden || !this._changesPerPage) return;
-    page.show(
-      this._computeNavLink(this._query, this._offset, -1, this._changesPerPage)
-    );
-  }
-
-  _changesChanged(changes?: ChangeInfo[]) {
-    this._userId = null;
-    this._repo = null;
-    if (!changes || !changes.length) {
-      return;
-    }
-    if (USER_QUERY_PATTERN.test(this._query)) {
-      const owner = changes[0].owner;
-      const userId = owner._account_id ? owner._account_id : owner.email;
-      if (userId) {
-        this._userId = userId;
-        return;
-      }
-    }
-    if (REPO_QUERY_PATTERN.test(this._query)) {
-      this._repo = changes[0].project;
-    }
-  }
-
-  _computeHeaderClass(id?: string) {
-    return id ? '' : 'hide';
-  }
-
-  _computePage(offset?: number, changesPerPage?: number) {
-    if (offset === undefined || changesPerPage === undefined) return;
-    return offset / changesPerPage + 1;
-  }
-
-  _computeLoggedIn(account?: AccountDetailInfo) {
-    return !!(account && Object.keys(account).length > 0);
-  }
-
-  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+  private async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-change-list');
     }
-    this.restApiService.saveChangeStarred(
+    const msg = e.detail.starred
+      ? 'Starring change...'
+      : 'Unstarring change...';
+    fireAlert(this, msg);
+    await this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
     );
-  }
-
-  /**
-   * Returns `this` as the visibility observer target for the keyboard shortcut
-   * mixin to decide whether shortcuts should be enabled or not.
-   */
-  _computeObserverTarget() {
-    return this;
+    fireEvent(this, 'hide-alert');
   }
 }
 
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
deleted file mode 100644
index 355ef45..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_html.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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    .loading {
-      color: var(--deemphasized-text-color);
-      padding: var(--spacing-l);
-    }
-    gr-change-list {
-      width: 100%;
-    }
-    gr-user-header,
-    gr-repo-header {
-      border-bottom: 1px solid var(--border-color);
-    }
-    nav {
-      align-items: center;
-      display: flex;
-      height: 3rem;
-      justify-content: flex-end;
-      margin-right: 20px;
-    }
-    nav,
-    iron-icon {
-      color: var(--deemphasized-text-color);
-    }
-    iron-icon {
-      height: 1.85rem;
-      margin-left: 16px;
-      width: 1.85rem;
-    }
-    .hide {
-      display: none;
-    }
-    @media only screen and (max-width: 50em) {
-      .loading,
-      .error {
-        padding: 0 var(--spacing-l);
-      }
-    }
-  </style>
-  <div class="loading" hidden$="[[!_loading]]" hidden="">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-repo-header
-      repo="[[_repo]]"
-      class$="[[_computeHeaderClass(_repo)]]"
-    ></gr-repo-header>
-    <gr-user-header
-      user-id="[[_userId]]"
-      showDashboardLink=""
-      logged-in="[[_loggedIn]]"
-      class$="[[_computeHeaderClass(_userId)]]"
-    ></gr-user-header>
-    <gr-change-list
-      account="[[account]]"
-      changes="{{_changes}}"
-      preferences="[[preferences]]"
-      selected-index="{{viewState.selectedChangeIndex}}"
-      show-star="[[_loggedIn]]"
-      on-toggle-star="_handleToggleStar"
-      observer-target="[[_computeObserverTarget()]]"
-    ></gr-change-list>
-    <nav class$="[[_computeNavClass(_loading)]]">
-      Page [[_computePage(_offset, _changesPerPage)]]
-      <a
-        id="prevArrow"
-        href$="[[_computeNavLink(_query, _offset, -1, _changesPerPage)]]"
-        class$="[[_computePrevArrowClass(_offset)]]"
-      >
-        <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
-      </a>
-      <a
-        id="nextArrow"
-        href$="[[_computeNavLink(_query, _offset, 1, _changesPerPage)]]"
-        class$="[[_computeNextArrowClass(_changes)]]"
-      >
-        <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
-        </iron-icon>
-      </a>
-    </nav>
-  </div>
-`;
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
deleted file mode 100644
index 86b2fd1..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
+++ /dev/null
@@ -1,249 +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-list-view.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import 'lodash/lodash.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-list-view');
-
-const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
-const COMMIT_HASH = '12345678';
-
-suite('gr-change-list-view tests', () => {
-  let element;
-
-  setup(() => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getChanges').returns(Promise.resolve([]));
-    stubRestApi('getAccountDetails').returns(Promise.resolve({}));
-    stubRestApi('getAccountStatus').returns(Promise.resolve({}));
-    element = basicFixture.instantiate();
-  });
-
-  teardown(async () => {
-    await flush();
-  });
-
-  test('_computePage', () => {
-    assert.equal(element._computePage(0, 25), 1);
-    assert.equal(element._computePage(50, 25), 3);
-  });
-
-  test('_limitFor', () => {
-    const defaultLimit = 25;
-    const _limitFor = q => element._limitFor(q, defaultLimit);
-    assert.equal(_limitFor(''), defaultLimit);
-    assert.equal(_limitFor('limit:10'), 10);
-    assert.equal(_limitFor('xlimit:10'), defaultLimit);
-    assert.equal(_limitFor('x(limit:10'), 10);
-  });
-
-  test('_computeNavLink', () => {
-    const getUrlStub = sinon.stub(GerritNav, 'getUrlForSearchQuery')
-        .returns('');
-    const query = 'status:open';
-    let offset = 0;
-    let direction = 1;
-    const changesPerPage = 5;
-
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 5);
-
-    direction = -1;
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 0);
-
-    offset = 5;
-    direction = 1;
-    element._computeNavLink(query, offset, direction, changesPerPage);
-    assert.equal(getUrlStub.lastCall.args[1], 10);
-  });
-
-  test('_computePrevArrowClass', () => {
-    let offset = 0;
-    assert.equal(element._computePrevArrowClass(offset), 'hide');
-    offset = 5;
-    assert.equal(element._computePrevArrowClass(offset), '');
-  });
-
-  test('_computeNextArrowClass', () => {
-    let changes = _.times(25, _.constant({_more_changes: true}));
-    assert.equal(element._computeNextArrowClass(changes), '');
-    changes = _.times(25, _.constant({}));
-    assert.equal(element._computeNextArrowClass(changes), 'hide');
-  });
-
-  test('_computeNavClass', () => {
-    let loading = true;
-    assert.equal(element._computeNavClass(loading), 'hide');
-    loading = false;
-    assert.equal(element._computeNavClass(loading), 'hide');
-    element._changes = [];
-    assert.equal(element._computeNavClass(loading), 'hide');
-    element._changes = _.times(5, _.constant({}));
-    assert.equal(element._computeNavClass(loading), '');
-  });
-
-  test('_handleNextPage', () => {
-    const showStub = sinon.stub(page, 'show');
-    element._changesPerPage = 10;
-    element.$.nextArrow.hidden = true;
-    element._handleNextPage();
-    assert.isFalse(showStub.called);
-    element.$.nextArrow.hidden = false;
-    element._handleNextPage();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_handlePreviousPage', () => {
-    const showStub = sinon.stub(page, 'show');
-    element._changesPerPage = 10;
-    element.$.prevArrow.hidden = true;
-    element._handlePreviousPage();
-    assert.isFalse(showStub.called);
-    element.$.prevArrow.hidden = false;
-    element._handlePreviousPage();
-    assert.isTrue(showStub.called);
-  });
-
-  test('_userId query', async () => {
-    assert.isNull(element._userId);
-    element._query = 'owner: foo@bar';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    await flush();
-    assert.equal(element._userId, 'foo@bar');
-
-    element._query = 'foo bar baz';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    assert.isNull(element._userId);
-  });
-
-  test('_userId query without email', async () => {
-    assert.isNull(element._userId);
-    element._query = 'owner: foo@bar';
-    element._changes = [{owner: {}}];
-    await flush();
-    assert.isNull(element._userId);
-  });
-
-  test('_repo query', async () => {
-    assert.isNull(element._repo);
-    element._query = 'project: test-repo';
-    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-    await flush();
-    assert.equal(element._repo, 'test-repo');
-    element._query = 'foo bar baz';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    assert.isNull(element._repo);
-  });
-
-  test('_repo query with open status', async () => {
-    assert.isNull(element._repo);
-    element._query = 'project:test-repo status:open';
-    element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-    await flush();
-    assert.equal(element._repo, 'test-repo');
-    element._query = 'foo bar baz';
-    element._changes = [{owner: {email: 'foo@bar'}}];
-    assert.isNull(element._repo);
-  });
-
-  suite('query based navigation', () => {
-    setup(() => {
-    });
-
-    teardown(async () => {
-      await flush();
-      sinon.restore();
-    });
-
-    test('Searching for a change ID redirects to change', async () => {
-      const change = {_number: 1};
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake(
-          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
-            assert.equal(url, change);
-            assert.isTrue(opt_redirect);
-            promise.resolve();
-          });
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      await promise;
-    });
-
-    test('Searching for a change num redirects to change', async () => {
-      const change = {_number: 1};
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake(
-          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
-            assert.equal(url, change);
-            assert.isTrue(opt_redirect);
-            promise.resolve();
-          });
-
-      element.params = {view: GerritNav.View.SEARCH, query: '1'};
-      await promise;
-    });
-
-    test('Commit hash redirects to change', async () => {
-      const change = {_number: 1};
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([change]));
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake(
-          (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
-            assert.equal(url, change);
-            assert.isTrue(opt_redirect);
-            promise.resolve();
-          });
-
-      element.params = {view: GerritNav.View.SEARCH, query: COMMIT_HASH};
-      await promise;
-    });
-
-    test('Searching for an invalid change ID searches', async () => {
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([]));
-      const stub = sinon.stub(GerritNav, 'navigateToChange');
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      await flush();
-
-      assert.isFalse(stub.called);
-    });
-
-    test('Change ID with multiple search results searches', async () => {
-      sinon.stub(element, '_getChanges')
-          .returns(Promise.resolve([{}, {}]));
-      const stub = sinon.stub(GerritNav, 'navigateToChange');
-
-      element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      await flush();
-
-      assert.isFalse(stub.called);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
new file mode 100644
index 0000000..f4bd8bd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -0,0 +1,197 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-change-list-view';
+import {GrChangeListView} from './gr-change-list-view';
+import {page} from '../../../utils/page-wrapper-utils';
+import {query, queryAndAssert} from '../../../test/test-utils';
+import {createChange} from '../../../test/test-data-generators';
+import {ChangeInfo} from '../../../api/rest-api';
+import {fixture, html, waitUntil, assert} from '@open-wc/testing';
+import {GrChangeList} from '../gr-change-list/gr-change-list';
+import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
+import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
+
+suite('gr-change-list-view tests', () => {
+  let element: GrChangeListView;
+
+  setup(async () => {
+    element = await fixture(html`<gr-change-list-view></gr-change-list-view>`);
+    element.query = 'test-query';
+    await element.updateComplete;
+  });
+
+  teardown(async () => {
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
+    element.changesPerPage = 10;
+    element.loading = false;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="loading" hidden="">Loading...</div>
+        <div>
+          <gr-change-list> </gr-change-list>
+          <nav>Page 1</nav>
+        </div>
+      `
+    );
+  });
+
+  suite('bulk actions', () => {
+    setup(async () => {
+      element.loading = false;
+      element.changes = [createChange()];
+      await element.updateComplete;
+      await element.updateComplete;
+      await waitUntil(() => element.loading === false);
+    });
+
+    test('checkboxes remain checked after soft reload', async () => {
+      const changeListEl = queryAndAssert<GrChangeList>(
+        element,
+        'gr-change-list'
+      );
+      await changeListEl.updateComplete;
+      const changeListSectionEl = queryAndAssert<GrChangeListSection>(
+        changeListEl,
+        'gr-change-list-section'
+      );
+      await changeListSectionEl.updateComplete;
+      const changeListItemEl = queryAndAssert<GrChangeListItem>(
+        changeListSectionEl,
+        'gr-change-list-item'
+      );
+      await changeListItemEl.updateComplete;
+      let checkbox = queryAndAssert<HTMLInputElement>(
+        changeListItemEl,
+        '.selection > .selectionLabel > input'
+      );
+      checkbox.click();
+      await waitUntil(() => checkbox.checked);
+
+      element.changes = [createChange()];
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(
+        query(
+          query(query(element, 'gr-change-list'), 'gr-change-list-section'),
+          'gr-change-list-item'
+        ),
+        '.selection > .selectionLabel > input'
+      );
+      assert.isTrue(checkbox.checked);
+    });
+  });
+
+  test('computePage', () => {
+    element.offset = 0;
+    element.changesPerPage = 25;
+    assert.equal(element.computePage(), 1);
+    element.offset = 50;
+    element.changesPerPage = 25;
+    assert.equal(element.computePage(), 3);
+  });
+
+  test('limitFor', () => {
+    const defaultLimit = 25;
+    const limitFor = (q: string) => element.limitFor(q, defaultLimit);
+    assert.equal(limitFor(''), defaultLimit);
+    assert.equal(limitFor('limit:10'), 10);
+    assert.equal(limitFor('xlimit:10'), defaultLimit);
+    assert.equal(limitFor('x(limit:10'), 10);
+  });
+
+  test('computeNavLink', () => {
+    element.query = 'status:open';
+    element.offset = 0;
+    element.changesPerPage = 5;
+    let direction = 1;
+
+    assert.equal(element.computeNavLink(direction), '/q/status:open,5');
+
+    direction = -1;
+    assert.equal(element.computeNavLink(direction), '/q/status:open');
+
+    element.offset = 5;
+    direction = 1;
+    assert.equal(element.computeNavLink(direction), '/q/status:open,10');
+  });
+
+  test('prevArrow', async () => {
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
+    element.offset = 0;
+    element.loading = false;
+    await element.updateComplete;
+    assert.isNotOk(query(element, '#prevArrow'));
+
+    element.offset = 5;
+    await element.updateComplete;
+    assert.isOk(query(element, '#prevArrow'));
+  });
+
+  test('nextArrow', async () => {
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => ({...createChange(), _more_changes: true} as ChangeInfo));
+    element.loading = false;
+    await element.updateComplete;
+    assert.isOk(query(element, '#nextArrow'));
+
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
+    await element.updateComplete;
+    assert.isNotOk(query(element, '#nextArrow'));
+  });
+
+  test('handleNextPage', async () => {
+    const showStub = sinon.stub(page, 'show');
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
+    element.changesPerPage = 10;
+    element.loading = false;
+    await element.updateComplete;
+    element.handleNextPage();
+    assert.isFalse(showStub.called);
+
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => ({...createChange(), _more_changes: true} as ChangeInfo));
+    element.loading = false;
+    await element.updateComplete;
+    element.handleNextPage();
+    assert.isTrue(showStub.called);
+  });
+
+  test('handlePreviousPage', async () => {
+    const showStub = sinon.stub(page, 'show');
+    element.offset = 0;
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
+    element.changesPerPage = 10;
+    element.loading = false;
+    await element.updateComplete;
+    element.handlePreviousPage();
+    assert.isFalse(showStub.called);
+
+    element.offset = 25;
+    await element.updateComplete;
+    element.handlePreviousPage();
+    assert.isTrue(showStub.called);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 68566f0..4c43da5 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
@@ -1,45 +1,15 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../styles/gr-change-list-styles';
-import '../../../styles/gr-font-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
 import '../gr-change-list-item/gr-change-list-item';
+import '../gr-change-list-section/gr-change-list-section';
+import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-list_html';
-import {appContext} from '../../../services/app-context';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {
-  GerritNav,
-  DashboardSection,
-  YOUR_TURN,
-  CLOSED,
-} from '../../core/gr-navigation/gr-navigation';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {isOwner} from '../../../utils/change-util';
-import {customElement, property, observe} from '@polymer/decorators';
+import {getAppContext} from '../../../services/app-context';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
   AccountInfo,
@@ -47,51 +17,65 @@
   ServerInfo,
   PreferencesInput,
 } from '../../../types/common';
-import {hasAttention} from '../../../utils/attention-set-util';
-import {fireEvent, fireReload} from '../../../utils/event-util';
-import {ScrollMode} from '../../../constants/constants';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {PRIORITY_REQUIREMENTS_ORDER} from '../../../utils/label-util';
-
-const NUMBER_FIXED_COLUMNS = 3;
-const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
-const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
-const MAX_SHORTCUT_CHARS = 5;
-
-export const columnNames = [
-  'Subject',
-  'Status',
-  'Owner',
-  'Assignee',
-  'Reviewers',
-  'Comments',
-  'Repo',
-  'Branch',
-  'Updated',
-  'Size',
-  'Requirements',
-];
+import {fire, fireEvent, fireReload} from '../../../utils/event-util';
+import {ColumnNames, ScrollMode} from '../../../constants/constants';
+import {getRequirements} from '../../../utils/label-util';
+import {Key} from '../../../utils/dom-util';
+import {assertIsDefined, unique} from '../../../utils/common-util';
+import {changeListStyles} from '../../../styles/gr-change-list-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {Shortcut, ShortcutController} from '../../lit/shortcut-controller';
+import {queryAll} from '../../../utils/common-util';
+import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
+import {Execution} from '../../../constants/reporting';
+import {ValueChangedEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 export interface ChangeListSection {
+  countLabel?: string;
+  emptyStateSlotName?: string;
   name?: string;
   query?: string;
   results: ChangeInfo[];
 }
 
-export interface GrChangeList {
-  $: {};
+/**
+ * Calculate the relative index of the currently selected change wrt to the
+ * section it belongs to.
+ * The 10th change in the overall list may be the 4th change in it's section
+ * so this method maps 10 to 4.
+ * selectedIndex contains the index of the change wrt the entire change list.
+ * Private but used in test
+ *
+ */
+export function computeRelativeIndex(
+  selectedIndex?: number,
+  sectionIndex?: number,
+  sections?: ChangeListSection[]
+) {
+  if (
+    selectedIndex === undefined ||
+    sectionIndex === undefined ||
+    sections === undefined
+  )
+    return;
+  for (let i = 0; i < sectionIndex; i++)
+    selectedIndex -= sections[i].results.length;
+  if (selectedIndex < 0) return; // selected change lies in previous sections
+
+  // the selectedIndex lies in the current section
+  if (selectedIndex < sections[sectionIndex].results.length)
+    return selectedIndex;
+  return; // selected change lies in future sections
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-change-list')
-export class GrChangeList extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeList extends LitElement {
   /**
    * Fired when next page key shortcut was pressed.
    *
@@ -111,7 +95,7 @@
   @property({type: Object})
   account: AccountInfo | undefined = undefined;
 
-  @property({type: Array, observer: '_changesChanged'})
+  @property({type: Array})
   changes?: ChangeInfo[];
 
   /**
@@ -119,16 +103,11 @@
    * properties should not be used together.
    */
   @property({type: Array})
-  sections: ChangeListSection[] = [];
+  sections?: ChangeListSection[] = [];
 
-  @property({type: Array, computed: '_computeLabelNames(sections)'})
-  labelNames?: string[];
+  @state() private dynamicHeaderEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicHeaderEndpoints?: string[];
-
-  @property({type: Number, notify: true})
-  selectedIndex?: number;
+  @property({type: Number}) selectedIndex = 0;
 
   @property({type: Boolean})
   showNumber?: boolean; // No default value to prevent flickering.
@@ -142,6 +121,9 @@
   @property({type: Array})
   changeTableColumns?: string[];
 
+  @property({type: String})
+  usp?: string;
+
   @property({type: Array})
   visibleChangeTableColumns?: string[];
 
@@ -151,27 +133,20 @@
   @property({type: Boolean})
   isCursorMoving = false;
 
-  @property({type: Object})
-  _config?: ServerInfo;
+  // private but used in test
+  @state() config?: ServerInfo;
 
-  private readonly flagsService = appContext.flagsService;
+  private readonly flagsService = getAppContext().flagsService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.CURSOR_NEXT_CHANGE, _ => this._nextChange()),
-      listen(Shortcut.CURSOR_PREV_CHANGE, _ => this._prevChange()),
-      listen(Shortcut.NEXT_PAGE, _ => this._nextPage()),
-      listen(Shortcut.PREV_PAGE, _ => this._prevPage()),
-      listen(Shortcut.OPEN_CHANGE, _ => this.openChange()),
-      listen(Shortcut.TOGGLE_CHANGE_REVIEWED, _ =>
-        this._toggleChangeReviewed()
-      ),
-      listen(Shortcut.TOGGLE_CHANGE_STAR, _ => this._toggleChangeStar()),
-      listen(Shortcut.REFRESH_CHANGE_LIST, _ => this._refreshChangeList()),
-    ];
-  }
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly shortcuts = new ShortcutController(this);
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
 
   private cursor = new GrCursorManager();
 
@@ -179,23 +154,39 @@
     super();
     this.cursor.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.cursor.focusOnMove = true;
-    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
-  }
-
-  override ready() {
-    super.ready();
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
-    });
+    this.shortcuts.addAbstract(Shortcut.CURSOR_NEXT_CHANGE, () =>
+      this.nextChange()
+    );
+    this.shortcuts.addAbstract(Shortcut.CURSOR_PREV_CHANGE, () =>
+      this.prevChange()
+    );
+    this.shortcuts.addAbstract(Shortcut.NEXT_PAGE, () => this.nextPage());
+    this.shortcuts.addAbstract(Shortcut.PREV_PAGE, () => this.prevPage());
+    this.shortcuts.addAbstract(Shortcut.OPEN_CHANGE, () => this.openChange());
+    this.shortcuts.addAbstract(Shortcut.TOGGLE_CHANGE_STAR, () =>
+      this.toggleChangeStar()
+    );
+    this.shortcuts.addAbstract(Shortcut.REFRESH_CHANGE_LIST, () =>
+      this.refreshChangeList()
+    );
+    this.shortcuts.addAbstract(Shortcut.TOGGLE_CHECKBOX, () =>
+      this.toggleCheckbox()
+    );
+    this.shortcuts.addGlobal({key: Key.ENTER}, () => this.openChange());
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    getPluginLoader()
+    this.restApiService.getConfig().then(config => {
+      this.config = config;
+    });
+    this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicHeaderEndpoints =
-          getPluginEndpoints().getDynamicEndpoints('change-list-header');
+        this.dynamicHeaderEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-list-header'
+          );
       });
   }
 
@@ -204,274 +195,247 @@
     super.disconnectedCallback();
   }
 
-  /**
-   * shortcut-service catches keyboard events globally. Some keyboard
-   * events must be scoped to a component level (e.g. `enter`) in order to not
-   * override native browser functionality.
-   *
-   * Context: Issue 7294
-   */
-  _scopedKeydownHandler(e: KeyboardEvent) {
-    if (e.keyCode === 13) {
-      // Enter.
-      this.openChange();
+  static override get styles() {
+    return [
+      changeListStyles,
+      fontStyles,
+      sharedStyles,
+      css`
+        #changeList {
+          border-collapse: collapse;
+          width: 100%;
+        }
+        .section-count-label {
+          color: var(--deemphasized-text-color);
+          font-family: var(--font-family);
+          font-size: var(--font-size-small);
+          font-weight: var(--font-weight-normal);
+          line-height: var(--line-height-small);
+        }
+        a.section-title:hover {
+          text-decoration: none;
+        }
+        a.section-title:hover .section-count-label {
+          text-decoration: none;
+        }
+        a.section-title:hover .section-name {
+          text-decoration: underline;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.sections) return;
+    const labelNames = this.computeLabelNames(this.sections);
+    const startIndices = this.calculateStartIndices(this.sections);
+    return html`
+      <table id="changeList">
+        ${this.sections.map((changeSection, sectionIndex) =>
+          this.renderSection(
+            changeSection,
+            sectionIndex,
+            labelNames,
+            startIndices[sectionIndex]
+          )
+        )}
+      </table>
+    `;
+  }
+
+  private calculateStartIndices(sections: ChangeListSection[]): number[] {
+    const startIndices: number[] = new Array(sections.length).fill(0);
+    for (let i = 1; i < sections.length; ++i) {
+      startIndices[i] = startIndices[i - 1] + sections[i - 1].results.length;
+    }
+    return startIndices;
+  }
+
+  private renderSection(
+    changeSection: ChangeListSection,
+    sectionIndex: number,
+    labelNames: string[],
+    startIndex: number
+  ) {
+    return html`
+      <gr-change-list-section
+        .changeSection=${changeSection}
+        .labelNames=${labelNames}
+        .dynamicHeaderEndpoints=${this.dynamicHeaderEndpoints}
+        .isCursorMoving=${this.isCursorMoving}
+        .config=${this.config}
+        .account=${this.account}
+        .selectedIndex=${computeRelativeIndex(
+          this.selectedIndex,
+          sectionIndex,
+          this.sections
+        )}
+        ?showStar=${this.showStar}
+        .showNumber=${this.showNumber}
+        .visibleChangeTableColumns=${this.visibleChangeTableColumns}
+        .usp=${this.usp}
+        .startIndex=${startIndex}
+        .triggerSelectionCallback=${(index: number) => {
+          this.selectedIndex = index;
+          this.cursor.setCursorAtIndex(this.selectedIndex);
+        }}
+      >
+        ${changeSection.emptyStateSlotName
+          ? html`<slot
+              slot=${changeSection.emptyStateSlotName}
+              name=${changeSection.emptyStateSlotName}
+            ></slot>`
+          : nothing}
+      </gr-change-list-section>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('account') ||
+      changedProperties.has('preferences') ||
+      changedProperties.has('config') ||
+      changedProperties.has('sections')
+    ) {
+      this.computeVisibleChangeTableColumns();
+    }
+
+    if (changedProperties.has('changes')) {
+      this.changesChanged();
     }
   }
 
-  _lowerCase(column: string) {
-    return column.toLowerCase();
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('sections')) {
+      this.sectionsChanged();
+    }
+    if (changedProperties.has('selectedIndex')) {
+      fire(this, 'selected-index-changed', {
+        value: this.selectedIndex ?? 0,
+      });
+    }
   }
 
-  @observe('account', 'preferences', '_config')
-  _computePreferences(
-    account?: AccountInfo,
-    preferences?: PreferencesInput,
-    config?: ServerInfo
-  ) {
-    if (!config) {
+  private toggleCheckbox() {
+    assertIsDefined(this.selectedIndex, 'selectedIndex');
+    let selectedIndex = this.selectedIndex;
+    assertIsDefined(this.sections, 'sections');
+    const changeSections = queryAll<GrChangeListSection>(
+      this,
+      'gr-change-list-section'
+    );
+    for (let i = 0; i < this.sections.length; i++) {
+      if (selectedIndex >= this.sections[i].results.length) {
+        selectedIndex -= this.sections[i].results.length;
+        continue;
+      }
+      changeSections[i].toggleChange(selectedIndex);
       return;
     }
+    throw new Error('invalid selected index');
+  }
 
-    this.changeTableColumns = columnNames;
+  private computeVisibleChangeTableColumns() {
+    if (!this.config) return;
+
+    this.changeTableColumns = Object.values(ColumnNames);
     this.showNumber = false;
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
-      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
+      this.isColumnEnabled(col, this.config)
     );
-    if (account && preferences) {
-      this.showNumber = !!(
-        preferences && preferences.legacycid_in_change_table
-      );
-      if (preferences.change_table && preferences.change_table.length > 0) {
-        const prefColumns = preferences.change_table.map(column =>
-          column === 'Project' ? 'Repo' : column
-        );
-        this.visibleChangeTableColumns = prefColumns.filter(col =>
-          this._isColumnEnabled(
-            col,
-            config,
-            this.flagsService.enabledExperiments
-          )
-        );
+    if (this.account && this.preferences) {
+      this.showNumber = !!this.preferences?.legacycid_in_change_table;
+      if (
+        this.preferences?.change_table &&
+        this.preferences.change_table.length > 0
+      ) {
+        const prefColumns = this.preferences.change_table
+          .map(column => (column === 'Project' ? ColumnNames.REPO : column))
+          .map(column =>
+            column === ColumnNames.STATUS ? ColumnNames.STATUS2 : column
+          );
+        this.reporting.reportExecution(Execution.USER_PREFERENCES_COLUMNS, {
+          statusColumn: prefColumns.includes(ColumnNames.STATUS2),
+        });
+        // Order visible column names by columnNames, filter only one that
+        // are in prefColumns and enabled by config
+        this.visibleChangeTableColumns = Object.values(ColumnNames)
+          .filter(col => prefColumns.includes(col))
+          .filter(col => this.isColumnEnabled(col, this.config));
       }
     }
   }
 
   /**
-   * Is the column disabled by a server config or experiment? For example the
-   * assignee feature might be disabled and thus the corresponding column is
-   * also disabled.
-   *
+   * Is the column disabled by a server config or experiment?
    */
-  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+  isColumnEnabled(column: string, config?: ServerInfo) {
+    if (!Object.values(ColumnNames).includes(column as unknown as ColumnNames))
+      return false;
     if (!config || !config.change) return true;
-    if (column === 'Assignee') return !!config.change.enable_assignee;
-    if (column === 'Comments') return experiments.includes('comments-column');
-    if (column === 'Requirements')
-      return experiments.includes(KnownExperimentId.SUBMIT_REQUIREMENTS_UI);
+    if (column === 'Comments')
+      return this.flagsService.isEnabled('comments-column');
+    if (column === 'Status') return false;
+    if (column === ColumnNames.STATUS2) return true;
     return true;
   }
 
-  /**
-   * This methods allows us to customize the columns per section.
-   *
-   * @param visibleColumns are the columns according to configs and user prefs
-   */
-  _computeColumns(
-    section?: ChangeListSection,
-    visibleColumns?: string[]
-  ): string[] {
-    if (!section || !visibleColumns) return [];
-    const cols = [...visibleColumns];
-    const updatedIndex = cols.indexOf('Updated');
-    if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
-      cols[updatedIndex] = 'Waiting';
+  // private but used in test
+  computeLabelNames(sections: ChangeListSection[]) {
+    if (!sections) return [];
+    if (this.config?.submit_requirement_dashboard_columns?.length) {
+      return this.config?.submit_requirement_dashboard_columns;
     }
-    if (section.name === CLOSED.name && updatedIndex !== -1) {
-      cols[updatedIndex] = 'Submitted';
-    }
-    return cols;
-  }
-
-  _computeColspan(
-    section?: ChangeListSection,
-    visibleColumns?: string[],
-    labelNames?: string[]
-  ) {
-    const cols = this._computeColumns(section, visibleColumns);
-    if (!cols || !labelNames) return 1;
-    return cols.length + labelNames.length + NUMBER_FIXED_COLUMNS;
-  }
-
-  _computeLabelNames(sections: ChangeListSection[]) {
-    if (!sections) {
-      return [];
-    }
-    let labels: string[] = [];
-    const nonExistingLabel = function (item: string) {
-      return !labels.includes(item);
-    };
-    for (const section of sections) {
-      if (!section.results) {
-        continue;
-      }
-      for (const change of section.results) {
-        if (!change.labels) {
-          continue;
-        }
-        const currentLabels = Object.keys(change.labels);
-        labels = labels.concat(currentLabels.filter(nonExistingLabel));
-      }
-    }
-    if (
-      this.flagsService.enabledExperiments.includes(
-        KnownExperimentId.SUBMIT_REQUIREMENTS_UI
-      )
-    ) {
-      labels = labels.filter(l => PRIORITY_REQUIREMENTS_ORDER.includes(l));
-    }
+    const changes = sections.map(section => section.results).flat();
+    const labels = (changes ?? [])
+      .map(change => getRequirements(change))
+      .flat()
+      .map(requirement => requirement.name)
+      .filter(unique);
     return labels.sort();
   }
 
-  _computeLabelShortcut(labelName: string) {
-    if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
-      labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
-    }
-    return labelName
-      .split('-')
-      .reduce((a, i) => {
-        if (!i) {
-          return a;
-        }
-        return a + i[0].toUpperCase();
-      }, '')
-      .slice(0, MAX_SHORTCUT_CHARS);
+  private changesChanged() {
+    this.sections = this.changes ? [{results: this.changes}] : [];
   }
 
-  _changesChanged(changes: ChangeInfo[]) {
-    this.sections = changes ? [{results: changes}] : [];
-  }
-
-  _processQuery(query: string) {
-    let tokens = query.split(' ');
-    const invalidTokens = ['limit:', 'age:', '-age:'];
-    tokens = tokens.filter(
-      token =>
-        !invalidTokens.some(invalidToken => token.startsWith(invalidToken))
-    );
-    return tokens.join(' ');
-  }
-
-  _sectionHref(query: string) {
-    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
-  }
-
-  /**
-   * Maps an index local to a particular section to the absolute index
-   * across all the changes on the page.
-   *
-   * @param sectionIndex index of section
-   * @param localIndex index of row within section
-   * @return absolute index of row in the aggregate dashboard
-   */
-  _computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
-    let idx = 0;
-    for (let i = 0; i < sectionIndex; i++) {
-      idx += this.sections[i].results.length;
-    }
-    return idx + localIndex;
-  }
-
-  _computeItemSelected(
-    sectionIndex: number,
-    index: number,
-    selectedIndex: number
-  ) {
-    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
-    return idx === selectedIndex;
-  }
-
-  _computeTabIndex(
-    sectionIndex: number,
-    index: number,
-    selectedIndex: number,
-    isCursorMoving: boolean
-  ) {
-    if (isCursorMoving) return 0;
-    return this._computeItemSelected(sectionIndex, index, selectedIndex)
-      ? 0
-      : undefined;
-  }
-
-  _computeItemHighlight(
-    account?: AccountInfo,
-    change?: ChangeInfo,
-    sectionName?: string
-  ) {
-    if (!change || !account) return false;
-    if (CLOSED_STATUS.indexOf(change.status) !== -1) return false;
-    return (
-      hasAttention(account, change) &&
-      !isOwner(change, account) &&
-      sectionName === YOUR_TURN.name
-    );
-  }
-
-  _nextChange() {
+  private nextChange() {
     this.isCursorMoving = true;
     this.cursor.next();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
   }
 
-  _prevChange() {
+  private prevChange() {
     this.isCursorMoving = true;
     this.cursor.previous();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
   }
 
-  openChange() {
-    const change = this._changeForIndex(this.selectedIndex);
-    if (change) GerritNav.navigateToChange(change);
+  private async openChange() {
+    const change = await this.changeForIndex(this.selectedIndex);
+    if (change) this.getNavigation().setUrl(createChangeUrl({change}));
   }
 
-  _nextPage() {
+  private nextPage() {
     fireEvent(this, 'next-page');
   }
 
-  _prevPage() {
-    this.dispatchEvent(
-      new CustomEvent('previous-page', {
-        composed: true,
-        bubbles: true,
-      })
-    );
+  private prevPage() {
+    fireEvent(this, 'previous-page');
   }
 
-  _toggleChangeReviewed() {
-    this._toggleReviewedForIndex(this.selectedIndex);
-  }
-
-  _toggleReviewedForIndex(index?: number) {
-    const changeEls = this._getListItems();
-    if (index === undefined || index >= changeEls.length || !changeEls[index]) {
-      return;
-    }
-
-    const changeEl = changeEls[index];
-    changeEl.toggleReviewed();
-  }
-
-  _refreshChangeList() {
+  private refreshChangeList() {
     fireReload(this);
   }
 
-  _toggleChangeStar() {
-    this._toggleStarForIndex(this.selectedIndex);
+  private toggleChangeStar() {
+    this.toggleStarForIndex(this.selectedIndex);
   }
 
-  _toggleStarForIndex(index?: number) {
-    const changeEls = this._getListItems();
+  private async toggleStarForIndex(index?: number) {
+    const changeEls = await this.getListItems();
     if (index === undefined || index >= changeEls.length || !changeEls[index]) {
       return;
     }
@@ -481,42 +445,40 @@
     if (grChangeStar) grChangeStar.toggleStar();
   }
 
-  _changeForIndex(index?: number) {
-    const changeEls = this._getListItems();
+  private async changeForIndex(index?: number) {
+    const changeEls = await this.getListItems();
     if (index !== undefined && index < changeEls.length && changeEls[index]) {
       return changeEls[index].change;
     }
     return null;
   }
 
-  _getListItems() {
-    const items = this.root?.querySelectorAll('gr-change-list-item');
-    return !items ? [] : Array.from(items);
+  // Private but used in tests
+  async getListItems() {
+    const items: GrChangeListItem[] = [];
+    const sections = queryAll<GrChangeListSection>(
+      this,
+      'gr-change-list-section'
+    );
+    await Promise.all(Array.from(sections).map(s => s.updateComplete));
+    for (const section of sections) {
+      // getListItems() is triggered when sectionsChanged observer is triggered
+      // In some cases <gr-change-list-item> has not been attached to the DOM
+      // yet and hence queryAll returns []
+      // Once the items have been attached, sectionsChanged() is not called
+      // again and the cursor stops are not updated to have the correct value
+      // hence wait for section to render before querying for items
+      const res = queryAll<GrChangeListItem>(section, 'gr-change-list-item');
+      items.push(...res);
+    }
+    return items;
   }
 
-  @observe('sections.*')
-  _sectionsChanged() {
-    // Flush DOM operations so that the list item elements will be loaded.
-    afterNextRender(this, () => {
-      this.cursor.stops = this._getListItems();
-      this.cursor.moveToStart();
-      if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex);
-    });
-  }
-
-  _getSpecialEmptySlot(section: DashboardSection) {
-    if (section.isOutgoing) return 'empty-outgoing';
-    if (section.name === YOUR_TURN.name) return 'empty-your-turn';
-    return '';
-  }
-
-  _isEmpty(section: DashboardSection) {
-    return !section.results?.length;
-  }
-
-  _computeAriaLabel(change?: ChangeInfo, sectionName?: string) {
-    if (!change) return '';
-    return change.subject + (sectionName ? `, section: ${sectionName}` : '');
+  // Private but used in tests
+  async sectionsChanged() {
+    this.cursor.stops = await this.getListItems();
+    this.cursor.moveToStart();
+    if (this.selectedIndex) this.cursor.setCursorAtIndex(this.selectedIndex);
   }
 }
 
@@ -524,4 +486,7 @@
   interface HTMLElementTagNameMap {
     'gr-change-list': GrChangeList;
   }
+  interface HTMLElementEventMap {
+    'selected-index-changed': ValueChangedEvent<number>;
+  }
 }
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
deleted file mode 100644
index 77320b9..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-change-list-styles">
-    #changeList {
-      border-collapse: collapse;
-      width: 100%;
-    }
-    .section-count-label {
-      color: var(--deemphasized-text-color);
-      font-family: var(--font-family);
-      font-size: var(--font-size-small);
-      font-weight: var(--font-weight-normal);
-      line-height: var(--line-height-small);
-    }
-    a.section-title:hover {
-      text-decoration: none;
-    }
-    a.section-title:hover .section-count-label {
-      text-decoration: none;
-    }
-    a.section-title:hover .section-name {
-      text-decoration: underline;
-    }
-  </style>
-  <table id="changeList">
-    <template
-      is="dom-repeat"
-      items="[[sections]]"
-      as="changeSection"
-      index-as="sectionIndex"
-    >
-      <template is="dom-if" if="[[changeSection.name]]">
-        <tbody>
-          <tr class="groupHeader">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-hidden="true"
-              class="star"
-              hidden$="[[!showStar]]"
-              hidden=""
-            ></td>
-            <td
-              class="cell"
-              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
-            >
-              <h2 class="heading-3">
-                <a
-                  href$="[[_sectionHref(changeSection.query)]]"
-                  class="section-title"
-                >
-                  <span class="section-name">[[changeSection.name]]</span>
-                  <span class="section-count-label"
-                    >[[changeSection.countLabel]]</span
-                  >
-                </a>
-              </h2>
-            </td>
-          </tr>
-        </tbody>
-      </template>
-      <tbody class="groupContent">
-        <template is="dom-if" if="[[_isEmpty(changeSection)]]">
-          <tr class="noChanges">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-hidden="[[!showStar]]"
-              class="star"
-              hidden$="[[!showStar]]"
-            ></td>
-            <td
-              class="cell"
-              colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
-            >
-              <template
-                is="dom-if"
-                if="[[_getSpecialEmptySlot(changeSection)]]"
-              >
-                <slot name="[[_getSpecialEmptySlot(changeSection)]]"></slot>
-              </template>
-              <template
-                is="dom-if"
-                if="[[!_getSpecialEmptySlot(changeSection)]]"
-              >
-                No changes
-              </template>
-            </td>
-          </tr>
-        </template>
-        <template is="dom-if" if="[[!_isEmpty(changeSection)]]">
-          <tr class="groupTitle">
-            <td aria-hidden="true" class="leftPadding"></td>
-            <td
-              aria-label="Star status column"
-              class="star"
-              hidden$="[[!showStar]]"
-              hidden=""
-            ></td>
-            <td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
-            <template
-              is="dom-repeat"
-              items="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
-              as="item"
-            >
-              <td class$="[[_lowerCase(item)]]">[[item]]</td>
-            </template>
-            <template is="dom-repeat" items="[[labelNames]]" as="labelName">
-              <td class="label" title$="[[labelName]]">
-                [[_computeLabelShortcut(labelName)]]
-              </td>
-            </template>
-            <template
-              is="dom-repeat"
-              items="[[_dynamicHeaderEndpoints]]"
-              as="pluginHeader"
-            >
-              <td class="endpoint">
-                <gr-endpoint-decorator name$="[[pluginHeader]]">
-                </gr-endpoint-decorator>
-              </td>
-            </template>
-          </tr>
-        </template>
-        <template is="dom-repeat" items="[[changeSection.results]]" as="change">
-          <gr-change-list-item
-            account="[[account]]"
-            selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-            highlight$="[[_computeItemHighlight(account, change, changeSection.name)]]"
-            change="[[change]]"
-            config="[[_config]]"
-            section-name="[[changeSection.name]]"
-            visible-change-table-columns="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
-            show-number="[[showNumber]]"
-            show-star="[[showStar]]"
-            tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex, isCursorMoving)]]"
-            label-names="[[labelNames]]"
-            aria-label$="[[_computeAriaLabel(change, changeSection.name)]]"
-          ></gr-change-list-item>
-        </template>
-      </tbody>
-    </template>
-  </table>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
deleted file mode 100644
index 472435c..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ /dev/null
@@ -1,519 +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.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {mockPromise} from '../../../test/test-utils.js';
-import {YOUR_TURN} from '../../core/gr-navigation/gr-navigation.js';
-
-const basicFixture = fixtureFromElement('gr-change-list');
-
-suite('gr-change-list basic tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('test show change number not logged in', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.account = undefined;
-      element.preferences = undefined;
-      element._config = {};
-    });
-
-    test('show number disabled', () => {
-      assert.isFalse(element.showNumber);
-    });
-  });
-
-  suite('test show change number preference enabled', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [],
-      };
-      element.account = {_account_id: 1001};
-      element._config = {};
-      flush();
-    });
-
-    test('show number enabled', () => {
-      assert.isTrue(element.showNumber);
-    });
-  });
-
-  suite('test show change number preference disabled', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      // legacycid_in_change_table is not set when false.
-      element.preferences = {
-        time_format: 'HHMM_12',
-        change_table: [],
-      };
-      element.account = {_account_id: 1001};
-      element._config = {};
-      flush();
-    });
-
-    test('show number disabled', () => {
-      assert.isFalse(element.showNumber);
-    });
-  });
-
-  test('computed fields', () => {
-    assert.equal(element._computeLabelNames(
-        [{results: [{_number: 0, labels: {}}]}]).length, 0);
-    assert.equal(element._computeLabelNames([
-      {results: [
-        {_number: 0, labels: {Verified: {approved: {}}}},
-        {
-          _number: 1,
-          labels: {
-            'Verified': {approved: {}},
-            'Code-Review': {approved: {}},
-          },
-        },
-        {
-          _number: 2,
-          labels: {
-            'Verified': {approved: {}},
-            'Library-Compliance': {approved: {}},
-          },
-        },
-      ]},
-    ]).length, 3);
-
-    assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
-    assert.equal(element._computeLabelShortcut('Verified'), 'V');
-    assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
-    assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
-    assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
-    assert.equal(element._computeLabelShortcut(
-        'Invalid-Prolog-Rules-Label-Name--Verified'), 'V');
-    assert.equal(element._computeLabelShortcut(
-        'Some-Special-Label-7'), 'SSL7');
-    assert.equal(element._computeLabelShortcut('--Too----many----dashes---'),
-        'TMD');
-    assert.equal(element._computeLabelShortcut(
-        'Really-rather-entirely-too-long-of-a-label-name'), 'RRETL');
-  });
-
-  test('colspans', () => {
-    element.sections = [
-      {results: [{}]},
-    ];
-    flush();
-    const tdItemCount = element.root.querySelectorAll(
-        'td').length;
-
-    const changeTableColumns = [];
-    const labelNames = [];
-    assert.equal(tdItemCount, element._computeColspan(
-        {}, changeTableColumns, labelNames));
-  });
-
-  test('keyboard shortcuts', async () => {
-    sinon.stub(element, '_computeLabelNames');
-    element.sections = [
-      {results: new Array(1)},
-      {results: new Array(2)},
-    ];
-    element.selectedIndex = 0;
-    element.changes = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    await flush();
-    const promise = mockPromise();
-    afterNextRender(element, () => {
-      promise.resolve();
-    });
-    await promise;
-    const elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 3);
-
-    assert.isTrue(elementItems[0].hasAttribute('selected'));
-    MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-    assert.equal(element.selectedIndex, 1);
-    assert.isTrue(elementItems[1].hasAttribute('selected'));
-    MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-    assert.equal(element.selectedIndex, 2);
-    assert.isTrue(elementItems[2].hasAttribute('selected'));
-
-    const navStub = sinon.stub(GerritNav, 'navigateToChange');
-    assert.equal(element.selectedIndex, 2);
-    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-    assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-        'Should navigate to /c/2/');
-
-    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-    assert.equal(element.selectedIndex, 1);
-    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-    assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-        'Should navigate to /c/1/');
-
-    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-    assert.equal(element.selectedIndex, 0);
-  });
-
-  test('no changes', () => {
-    element.changes = [];
-    flush();
-    const listItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(listItems.length, 0);
-    const noChangesMsg =
-        element.root.querySelector('.noChanges');
-    assert.ok(noChangesMsg);
-  });
-
-  test('empty sections', () => {
-    element.sections = [{results: []}, {results: []}];
-    flush();
-    const listItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(listItems.length, 0);
-    const noChangesMsg = element.root.querySelectorAll(
-        '.noChanges');
-    assert.equal(noChangesMsg.length, 2);
-  });
-
-  suite('empty section', () => {
-    test('not shown on empty non-outgoing sections', () => {
-      const section = {results: []};
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), '');
-    });
-
-    test('shown on empty outgoing sections', () => {
-      const section = {results: [], isOutgoing: true};
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), 'empty-outgoing');
-    });
-
-    test('shown on empty outgoing sections', () => {
-      const section = {results: [], name: YOUR_TURN.name};
-      assert.isTrue(element._isEmpty(section));
-      assert.equal(element._getSpecialEmptySlot(section), 'empty-your-turn');
-    });
-
-    test('not shown on non-empty outgoing sections', () => {
-      const section = {isOutgoing: true, results: [
-        {_number: 0, labels: {Verified: {approved: {}}}}]};
-      assert.isFalse(element._isEmpty(section));
-    });
-  });
-
-  suite('empty column preference', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.sections = [
-        {results: [{}]},
-      ];
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [],
-      };
-      element._config = {};
-      flush();
-    });
-
-    test('show number enabled', () => {
-      assert.isTrue(element.showNumber);
-    });
-
-    test('all columns visible', () => {
-      for (const column of element.changeTableColumns) {
-        const elementClass = '.' + element._lowerCase(column);
-        assert.isFalse(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      }
-    });
-  });
-
-  suite('full column preference', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.sections = [
-        {results: [{}]},
-      ];
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [
-          'Subject',
-          'Status',
-          'Owner',
-          'Assignee',
-          'Reviewers',
-          'Comments',
-          'Repo',
-          'Branch',
-          'Updated',
-          'Size',
-          'Requirements',
-        ],
-      };
-      element._config = {};
-      flush();
-    });
-
-    test('all columns visible', () => {
-      for (const column of element.changeTableColumns) {
-        const elementClass = '.' + element._lowerCase(column);
-        assert.isFalse(element.shadowRoot
-            .querySelector(elementClass).hidden);
-      }
-    });
-  });
-
-  suite('partial column preference', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.sections = [
-        {results: [{}]},
-      ];
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [
-          'Subject',
-          'Status',
-          'Owner',
-          'Assignee',
-          'Reviewers',
-          'Comments',
-          'Branch',
-          'Updated',
-          'Size',
-          'Requirements',
-        ],
-      };
-      element._config = {};
-      flush();
-    });
-
-    test('all columns except repo visible', () => {
-      for (const column of element.changeTableColumns) {
-        const elementClass = '.' + column.toLowerCase();
-        if (column === 'Repo') {
-          assert.isNotOk(element.shadowRoot.querySelector(elementClass));
-        } else {
-          assert.isOk(element.shadowRoot.querySelector(elementClass));
-        }
-      }
-    });
-  });
-
-  suite('random column does not exist', () => {
-    let element;
-
-    /* This would only exist if somebody manually updated the config
-    file. */
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.account = {_account_id: 1001};
-      element.preferences = {
-        legacycid_in_change_table: true,
-        time_format: 'HHMM_12',
-        change_table: [
-          'Bad',
-        ],
-      };
-      flush();
-    });
-
-    test('bad column does not exist', () => {
-      const elementClass = '.bad';
-      assert.isNotOk(element.shadowRoot
-          .querySelector(elementClass));
-    });
-  });
-
-  suite('dashboard queries', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-    });
-
-    teardown(() => { sinon.restore(); });
-
-    test('query without age and limit unchanged', () => {
-      const query = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), query);
-    });
-
-    test('query with age and limit', () => {
-      const query = 'status:closed age:1week limit:10 owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with age', () => {
-      const query = 'status:closed age:1week owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with limit', () => {
-      const query = 'status:closed limit:10 owner:me';
-      const expectedQuery = 'status:closed owner:me';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with age as value and not key', () => {
-      const query = 'status:closed random:age';
-      const expectedQuery = 'status:closed random:age';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with limit as value and not key', () => {
-      const query = 'status:closed random:limit';
-      const expectedQuery = 'status:closed random:limit';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-
-    test('query with -age key', () => {
-      const query = 'status:closed -age:1week';
-      const expectedQuery = 'status:closed';
-      assert.deepEqual(element._processQuery(query), expectedQuery);
-    });
-  });
-
-  suite('gr-change-list sections', () => {
-    let element;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-    });
-
-    test('keyboard shortcuts', async () => {
-      element.selectedIndex = 0;
-      element.sections = [
-        {
-          results: [
-            {_number: 0},
-            {_number: 1},
-            {_number: 2},
-          ],
-        },
-        {
-          results: [
-            {_number: 3},
-            {_number: 4},
-            {_number: 5},
-          ],
-        },
-        {
-          results: [
-            {_number: 6},
-            {_number: 7},
-            {_number: 8},
-          ],
-        },
-      ];
-      await flush();
-      const promise = mockPromise();
-      afterNextRender(element, () => {
-        promise.resolve();
-      });
-      await promise;
-      const elementItems = element.root.querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 9);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-
-      const navStub = sinon.stub(GerritNav, 'navigateToChange');
-      assert.equal(element.selectedIndex, 2);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-          'Should navigate to /c/2/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-          'Should navigate to /c/1/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert.equal(element.selectedIndex, 4);
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
-          'Should navigate to /c/4/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      const change = element._changeForIndex(element.selectedIndex);
-      assert.equal(change.reviewed, true,
-          'Should mark change as reviewed');
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      assert.equal(change.reviewed, false,
-          'Should mark change as unreviewed');
-    });
-
-    test('_computeItemHighlight gives false for null account', () => {
-      assert.isFalse(
-          element._computeItemHighlight(null, {assignee: {_account_id: 42}}));
-    });
-
-    test('_computeItemAbsoluteIndex', () => {
-      sinon.stub(element, '_computeLabelNames');
-      element.sections = [
-        {results: new Array(1)},
-        {results: new Array(2)},
-        {results: new Array(3)},
-      ];
-
-      assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
-      // Out of range but no matter.
-      assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
-
-      assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
-      assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
-      assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
-      assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
-      assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
new file mode 100644
index 0000000..312f384
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -0,0 +1,598 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-change-list';
+import {GrChangeList, computeRelativeIndex} from './gr-change-list';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  pressKey,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubFlags,
+  waitUntil,
+} from '../../../test/test-utils';
+import {Key} from '../../../utils/dom-util';
+import {
+  ColumnNames,
+  createDefaultPreferences,
+  TimeFormat,
+} from '../../../constants/constants';
+import {AccountId, NumericChangeId} from '../../../types/common';
+import {
+  createChange,
+  createServerInfo,
+  createSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
+import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import {testResolver} from '../../../test/common-test-setup';
+
+suite('gr-change-list basic tests', () => {
+  let element: GrChangeList;
+
+  setup(async () => {
+    element = await fixture(html`<gr-change-list></gr-change-list>`);
+  });
+
+  test('renders', async () => {
+    element.preferences = {
+      legacycid_in_change_table: true,
+      time_format: TimeFormat.HHMM_12,
+      change_table: [],
+    };
+    element.account = {_account_id: 1001 as AccountId};
+    element.config = createServerInfo();
+    element.sections = [
+      {
+        results: [{...createChange(), _number: 0 as NumericChangeId}],
+      },
+      {
+        results: [
+          {...createChange(), _number: 1 as NumericChangeId},
+          {...createChange(), _number: 2 as NumericChangeId},
+        ],
+      },
+    ];
+    element.selectedIndex = 0;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-change-list-section> </gr-change-list-section>
+        <gr-change-list-section> </gr-change-list-section>
+        <table id="changeList"></table>
+      `
+    );
+  });
+
+  test('sections receive global startIndex', async () => {
+    element.selectedIndex = 0;
+    element.sections = [
+      {
+        results: [{...createChange(), _number: 0 as NumericChangeId}],
+      },
+      {
+        results: [
+          {...createChange(), _number: 1 as NumericChangeId},
+          {...createChange(), _number: 2 as NumericChangeId},
+        ],
+      },
+      {
+        results: [
+          {...createChange(), _number: 3 as NumericChangeId},
+          {...createChange(), _number: 4 as NumericChangeId},
+        ],
+      },
+    ];
+    await element.updateComplete;
+
+    assert.deepEqual(
+      [...element.shadowRoot!.querySelectorAll('gr-change-list-section')].map(
+        section => section.startIndex
+      ),
+      [0, 1, 3]
+    );
+  });
+
+  test('show change number disabled when not logged in', async () => {
+    element.account = undefined;
+    element.preferences = undefined;
+    element.config = createServerInfo();
+    await element.updateComplete;
+
+    assert.isFalse(element.showNumber);
+  });
+
+  test('show legacy change num when legacycid preference enabled', async () => {
+    element.preferences = {
+      legacycid_in_change_table: true,
+      time_format: TimeFormat.HHMM_12,
+      change_table: [],
+    };
+    element.account = {_account_id: 1001 as AccountId};
+    element.config = createServerInfo();
+    await element.updateComplete;
+
+    assert.isTrue(element.showNumber);
+  });
+
+  test('hide legacy change num if legacycid preference disabled', async () => {
+    // legacycid_in_change_table is not set when false.
+    element.preferences = {
+      time_format: TimeFormat.HHMM_12,
+      change_table: [],
+    };
+    element.account = {_account_id: 1001 as AccountId};
+    element.config = createServerInfo();
+    await element.updateComplete;
+
+    assert.isFalse(element.showNumber);
+  });
+
+  test('computeRelativeIndex', () => {
+    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+
+    let selectedChangeIndex = 0;
+    assert.equal(
+      computeRelativeIndex(selectedChangeIndex, 0, element.sections),
+      0
+    );
+
+    // index lies outside the first section
+    assert.equal(
+      computeRelativeIndex(selectedChangeIndex, 1, element.sections),
+      undefined
+    );
+
+    selectedChangeIndex = 2;
+
+    // index lies outside the first section
+    assert.equal(
+      computeRelativeIndex(selectedChangeIndex, 0, element.sections),
+      undefined
+    );
+
+    // 3rd change belongs to the second section
+    assert.equal(
+      computeRelativeIndex(selectedChangeIndex, 1, element.sections),
+      1
+    );
+  });
+
+  test('computed fields', () => {
+    assert.equal(
+      element.computeLabelNames([
+        {
+          results: [
+            {...createChange(), _number: 0 as NumericChangeId, labels: {}},
+          ],
+        },
+      ]).length,
+      0
+    );
+    assert.equal(
+      element.computeLabelNames([
+        {
+          results: [
+            {
+              ...createChange(),
+              _number: 0 as NumericChangeId,
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+              ],
+            },
+            {
+              ...createChange(),
+              _number: 1 as NumericChangeId,
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Verified',
+                },
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Code-Review',
+                },
+              ],
+            },
+            {
+              ...createChange(),
+              _number: 2 as NumericChangeId,
+              submit_requirements: [
+                {
+                  ...createSubmitRequirementResultInfo(),
+                  name: 'Library-Compliance',
+                },
+              ],
+            },
+          ],
+        },
+      ]).length,
+      3
+    );
+  });
+
+  test('keyboard shortcuts', async () => {
+    sinon.stub(element, 'computeLabelNames');
+    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.selectedIndex = 0;
+    element.preferences = createDefaultPreferences();
+    element.config = createServerInfo();
+    element.changes = [
+      {...createChange(), _number: 0 as NumericChangeId},
+      {...createChange(), _number: 1 as NumericChangeId},
+      {...createChange(), _number: 2 as NumericChangeId},
+    ];
+    // explicitly trigger sectionsChanged so that cursor stops are properly
+    // updated
+    await element.sectionsChanged();
+    await element.updateComplete;
+    const section = queryAndAssert<GrChangeListSection>(
+      element,
+      'gr-change-list-section'
+    );
+    await section.updateComplete;
+    const elementItems = queryAll<GrChangeListItem>(
+      section,
+      'gr-change-list-item'
+    );
+    assert.equal(elementItems.length, 3);
+
+    assert.isTrue(elementItems[0].selected);
+    await element.updateComplete;
+    pressKey(element, 'j');
+    await element.updateComplete;
+    await section.updateComplete;
+
+    assert.equal(element.selectedIndex, 1);
+    assert.isTrue(elementItems[1].selected);
+    pressKey(element, 'j');
+    await element.updateComplete;
+    assert.equal(element.selectedIndex, 2);
+    assert.isTrue(elementItems[2].selected);
+
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+    assert.equal(element.selectedIndex, 2);
+    pressKey(element, Key.ENTER);
+    await waitUntil(() => setUrlStub.callCount >= 1);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/2');
+
+    pressKey(element, 'k');
+    await element.updateComplete;
+    await section.updateComplete;
+
+    assert.equal(element.selectedIndex, 1);
+
+    const prevCount = setUrlStub.callCount;
+    pressKey(element, Key.ENTER);
+
+    await waitUntil(() => setUrlStub.callCount > prevCount);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/1');
+
+    pressKey(element, 'k');
+    pressKey(element, 'k');
+    pressKey(element, 'k');
+    assert.equal(element.selectedIndex, 0);
+  });
+
+  test('toggle checkbox keyboard shortcut', async () => {
+    const getCheckbox = (item: GrChangeListItem) =>
+      queryAndAssert<HTMLInputElement>(query(item, '.selection'), 'input');
+
+    sinon.stub(element, 'computeLabelNames');
+    element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+    element.selectedIndex = 0;
+    element.preferences = createDefaultPreferences();
+    element.config = createServerInfo();
+    element.changes = [
+      {...createChange(), _number: 0 as NumericChangeId},
+      {...createChange(), _number: 1 as NumericChangeId},
+      {...createChange(), _number: 2 as NumericChangeId},
+    ];
+    // explicitly trigger sectionsChanged so that cursor stops are properly
+    // updated
+    await element.sectionsChanged();
+    await element.updateComplete;
+    const section = queryAndAssert<GrChangeListSection>(
+      element,
+      'gr-change-list-section'
+    );
+    await section.updateComplete;
+    const elementItems = queryAll<GrChangeListItem>(
+      section,
+      'gr-change-list-item'
+    );
+    assert.equal(elementItems.length, 3);
+
+    assert.isTrue(elementItems[0].selected);
+    await element.updateComplete;
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+
+    assert.isTrue(getCheckbox(elementItems[0]).checked);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+    assert.isFalse(getCheckbox(elementItems[0]).checked);
+
+    pressKey(element, 'j');
+    await element.updateComplete;
+
+    assert.equal(element.selectedIndex, 1);
+    assert.isTrue(elementItems[1].selected);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+
+    assert.isTrue(getCheckbox(elementItems[1]).checked);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+    assert.isFalse(getCheckbox(elementItems[1]).checked);
+
+    pressKey(element, 'j');
+    await element.updateComplete;
+    assert.equal(element.selectedIndex, 2);
+    assert.isTrue(elementItems[2].selected);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+
+    assert.isTrue(getCheckbox(elementItems[2]).checked);
+
+    pressKey(element, 'x');
+    await element.updateComplete;
+    assert.isFalse(getCheckbox(elementItems[2]).checked);
+  });
+
+  test('no changes', async () => {
+    element.changes = [];
+    await element.updateComplete;
+    const listItems = queryAll<GrChangeListItem>(
+      element,
+      'gr-change-list-item'
+    );
+    assert.equal(listItems.length, 0);
+    const section = queryAndAssert(element, 'gr-change-list-section');
+    const noChangesMsg = queryAndAssert<HTMLTableRowElement>(
+      section,
+      '.noChanges'
+    );
+    assert.ok(noChangesMsg);
+  });
+
+  test('empty sections', async () => {
+    element.sections = [{results: []}, {results: []}];
+    await element.updateComplete;
+    const listItems = queryAll<GrChangeListItem>(
+      element,
+      'gr-change-list-item'
+    );
+    assert.equal(listItems.length, 0);
+    const sections = queryAll<GrChangeListSection>(
+      element,
+      'gr-change-list-section'
+    );
+    sections.forEach(section => {
+      assert.isOk(query(section, '.noChanges'));
+    });
+  });
+
+  suite('empty column preference', () => {
+    let element: GrChangeList;
+
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      element = await fixture(html`<gr-change-list></gr-change-list>`);
+      element.sections = [{results: [{...createChange()}]}];
+      element.account = {_account_id: 1001 as AccountId};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: [],
+      };
+      element.config = createServerInfo();
+      await element.updateComplete;
+    });
+
+    test('show number enabled', () => {
+      assert.isTrue(element.showNumber);
+    });
+
+    test('all columns visible', () => {
+      for (const column of element.changeTableColumns!) {
+        const elementClass = '.' + column.trim().toLowerCase();
+        const section = queryAndAssert(element, 'gr-change-list-section');
+        assert.isFalse(
+          queryAndAssert<HTMLElement>(section, elementClass)!.hidden
+        );
+      }
+    });
+  });
+
+  suite('full column preference', () => {
+    let element: GrChangeList;
+
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      element = await fixture(html`<gr-change-list></gr-change-list>`);
+      element.sections = [{results: [{...createChange()}]}];
+      element.account = {_account_id: 1001 as AccountId};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Reviewers',
+          'Comments',
+          'Repo',
+          'Branch',
+          'Updated',
+          'Size',
+          ColumnNames.STATUS2,
+        ],
+      };
+      element.config = createServerInfo();
+      await element.updateComplete;
+    });
+
+    test('all columns visible', () => {
+      for (const column of element.changeTableColumns!) {
+        const elementClass = '.' + column.trim().toLowerCase();
+        const section = queryAndAssert(element, 'gr-change-list-section');
+        assert.isFalse(
+          queryAndAssert<HTMLElement>(section, elementClass).hidden
+        );
+      }
+    });
+  });
+
+  suite('partial column preference', () => {
+    let element: GrChangeList;
+
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      element = await fixture(html`<gr-change-list></gr-change-list>`);
+      element.sections = [{results: [{...createChange()}]}];
+      element.account = {_account_id: 1001 as AccountId};
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: [
+          'Subject',
+          'Status',
+          'Owner',
+          'Reviewers',
+          'Comments',
+          'Branch',
+          'Updated',
+          'Size',
+          ColumnNames.STATUS2,
+        ],
+      };
+      element.config = createServerInfo();
+      await element.updateComplete;
+    });
+
+    test('all columns except repo visible', () => {
+      for (const column of element.changeTableColumns!) {
+        const elementClass = '.' + column.trim().toLowerCase();
+        const section = queryAndAssert(element, 'gr-change-list-section');
+        if (column === 'Repo') {
+          assert.isNotOk(query<HTMLElement>(section, elementClass));
+        } else {
+          assert.isOk(queryAndAssert<HTMLElement>(section, elementClass));
+        }
+      }
+    });
+
+    test('show default order not preferences order', async () => {
+      element.preferences = {
+        legacycid_in_change_table: true,
+        time_format: TimeFormat.HHMM_12,
+        change_table: ['Owner', 'Subject'],
+      };
+      element.config = createServerInfo();
+      await element.updateComplete;
+      assert.equal(element.visibleChangeTableColumns?.[0], 'Subject');
+      assert.equal(element.visibleChangeTableColumns?.[1], 'Owner');
+    });
+  });
+
+  test('obsolete column in preferences not visible', () => {
+    assert.isTrue(element.isColumnEnabled('Subject'));
+    assert.isFalse(element.isColumnEnabled('Assignee'));
+  });
+
+  test('showStar and showNumber', async () => {
+    element.sections = [{results: [{...createChange()}], name: 'a'}];
+    element.account = {_account_id: 1001 as AccountId};
+    element.preferences = {
+      legacycid_in_change_table: false, // sets showNumber false
+      time_format: TimeFormat.HHMM_12,
+      change_table: [
+        'Subject',
+        'Status',
+        'Owner',
+        'Reviewers',
+        'Comments',
+        'Branch',
+        'Updated',
+        'Size',
+        ColumnNames.STATUS2,
+      ],
+    };
+    element.config = createServerInfo();
+    await element.updateComplete;
+    const section = query<GrChangeListSection>(
+      element,
+      'gr-change-list-section'
+    )!;
+    await section.updateComplete;
+
+    const items = await element.getListItems();
+    assert.equal(items.length, 1);
+
+    assert.isNotOk(query(query(section, 'gr-change-list-item'), '.star'));
+    assert.isNotOk(query(query(section, 'gr-change-list-item'), '.number'));
+
+    element.showStar = true;
+    await element.updateComplete;
+    await section.updateComplete;
+    assert.isOk(query(query(section, 'gr-change-list-item'), '.star'));
+    assert.isNotOk(query(query(section, 'gr-change-list-item'), '.number'));
+
+    element.showNumber = true;
+    await element.updateComplete;
+    await section.updateComplete;
+    assert.isOk(query(query(section, 'gr-change-list-item'), '.star'));
+    assert.isOk(query(query(section, 'gr-change-list-item'), '.number'));
+  });
+
+  test('garbage columns in preference are not shown', async () => {
+    // This would only exist if somebody manually updated the config file.
+    element.account = {_account_id: 1001 as AccountId};
+    element.preferences = {
+      legacycid_in_change_table: true,
+      time_format: TimeFormat.HHMM_12,
+      change_table: ['Bad'],
+    };
+    await element.updateComplete;
+
+    assert.isNotOk(query<HTMLElement>(element, '.bad'));
+  });
+
+  test('Show new status with feature flag', async () => {
+    stubFlags('isEnabled').returns(true);
+    const element: GrChangeList = await fixture(
+      html`<gr-change-list></gr-change-list>`
+    );
+    element.sections = [{results: [{...createChange()}]}];
+    element.account = {_account_id: 1001 as AccountId};
+    element.preferences = {
+      change_table: [
+        'Status', // old status
+      ],
+    };
+    element.config = createServerInfo();
+    await element.updateComplete;
+    assert.isTrue(
+      element.visibleChangeTableColumns?.includes(ColumnNames.STATUS2),
+      'Show new status'
+    );
+    const section = queryAndAssert(element, 'gr-change-list-section');
+    queryAndAssert<HTMLElement>(section, '.status');
+  });
+});
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 fd2a5d7..9c53fea 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
@@ -1,26 +1,13 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
 import {fireEvent} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement} from 'lit/decorators';
+import {customElement} from 'lit/decorators.js';
 import {fontStyles} from '../../../styles/gr-font-styles';
 
 declare global {
@@ -53,10 +40,9 @@
           justify-content: center;
           width: 10em;
         }
-        #graphic iron-icon {
+        #graphic gr-icon {
           color: var(--gray-foreground);
-          height: 5em;
-          width: 5em;
+          font-size: 5em;
         }
         #graphic p {
           color: var(--deemphasized-text-color);
@@ -82,9 +68,10 @@
   }
 
   override render() {
-    return html` <div id="graphic">
+    return html`
+      <div id="graphic">
         <div id="circle">
-          <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
+          <gr-icon id="icon" icon="empty_dashboard"></gr-icon>
         </div>
         <p>No outgoing changes yet</p>
       </div>
@@ -96,7 +83,8 @@
           the step by step instructions.
         </p>
         <gr-button @click=${this._handleCreateTap}>Create Change</gr-button>
-      </div>`;
+      </div>
+    `;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
index e170a74..d5ab511 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
@@ -1,41 +1,53 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-create-change-help';
 import {GrCreateChangeHelp} from './gr-create-change-help';
 import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-create-change-help');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-create-change-help tests', () => {
   let element: GrCreateChangeHelp;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(
+      html`<gr-create-change-help></gr-create-change-help>`
+    );
   });
 
   test('Create change tap', async () => {
     const promise = mockPromise();
     element.addEventListener('create-tap', () => promise.resolve());
-    MockInteractions.tap(queryAndAssert<GrButton>(element, 'gr-button'));
+    queryAndAssert<GrButton>(element, 'gr-button').click();
     await promise;
   });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+        <div id="graphic">
+          <div id="circle">
+            <gr-icon icon="empty_dashboard" id="icon"> </gr-icon>
+          </div>
+          <p>No outgoing changes yet</p>
+        </div>
+        <div id="help">
+          <h2 class="heading-3">Push your first change for code review</h2>
+          <p>
+            Pushing a change for review is easy, but a little different from other
+          git code review tools. Click on the \`Create Change' button and follow
+          the step by step instructions.
+          </p>
+          <gr-button aria-disabled="false" role="button" tabindex="0">
+            Create Change
+          </gr-button>
+        </div>
+      `
+    );
+  });
 });
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 6c9fa68..cf5c26d 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
@@ -1,27 +1,14 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-shell-command/gr-shell-command';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 enum Commands {
   CREATE = 'git commit',
@@ -37,8 +24,8 @@
 
 @customElement('gr-create-commands-dialog')
 export class GrCreateCommandsDialog extends LitElement {
-  @query('#commandsOverlay')
-  commandsOverlay?: GrOverlay;
+  @query('#commandsModal')
+  commandsModal?: HTMLDialogElement;
 
   @property({type: String})
   branch?: string;
@@ -46,6 +33,7 @@
   static override get styles() {
     return [
       sharedStyles,
+      modalStyles,
       css`
         ol {
           list-style: decimal;
@@ -62,13 +50,13 @@
   }
 
   override render() {
-    return html` <gr-overlay id="commandsOverlay" with-backdrop="">
+    return html` <dialog id="commandsModal" tabindex="-1">
       <gr-dialog
         id="commandsDialog"
         confirm-label="Done"
         cancel-label=""
         confirm-on-enter=""
-        @confirm=${() => this.commandsOverlay?.close()}
+        @confirm=${() => this.commandsModal?.close()}
       >
         <div class="header" slot="header">Create change commands</div>
         <div class="main" slot="main">
@@ -78,11 +66,9 @@
             </li>
             <li>
               <p>If you are making a new commit use</p>
-              <gr-shell-command
-                .command="${Commands.CREATE}"
-              ></gr-shell-command>
+              <gr-shell-command .command=${Commands.CREATE}></gr-shell-command>
               <p>Or to amend an existing commit use</p>
-              <gr-shell-command .command="${Commands.AMEND}"></gr-shell-command>
+              <gr-shell-command .command=${Commands.AMEND}></gr-shell-command>
               <p>
                 Please make sure you add a commit message as it becomes the
                 description for your change.
@@ -91,7 +77,7 @@
             <li>
               <p>Push the change for code review</p>
               <gr-shell-command
-                .command="${Commands.PUSH_PREFIX + (this.branch ?? '[BRANCH]')}"
+                .command=${Commands.PUSH_PREFIX + (this.branch ?? '[BRANCH]')}
               ></gr-shell-command>
             </li>
             <li>
@@ -104,10 +90,10 @@
           </ol>
         </div>
       </gr-dialog>
-    </gr-overlay>`;
+    </dialog>`;
   }
 
   open() {
-    this.commandsOverlay?.open();
+    this.commandsModal?.showModal();
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
index ea367f7..3252e3d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
@@ -1,35 +1,72 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import './gr-create-commands-dialog';
 import {GrCreateCommandsDialog} from './gr-create-commands-dialog';
 
-const basicFixture = fixtureFromElement('gr-create-commands-dialog');
-
 suite('gr-create-commands-dialog tests', () => {
   let element: GrCreateCommandsDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-create-commands-dialog></gr-create-commands-dialog>`
+    );
   });
 
   test('branch', () => {
     element.branch = 'master';
     assert.equal(element.branch, 'master');
   });
+
+  test('render', () => {
+    // prettier and shadowDom assert don't agree about wrapping in the <p> tags
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <dialog id="commandsModal" tabindex="-1">
+        <gr-dialog
+          cancel-label=""
+          confirm-label="Done"
+          confirm-on-enter=""
+          id="commandsDialog"
+          role="dialog"
+        >
+          <div class="header" slot="header">Create change commands</div>
+          <div class="main" slot="main">
+            <ol>
+              <li>
+                <p>Make the changes to the files on your machine</p>
+              </li>
+              <li>
+                <p>If you are making a new commit use</p>
+                <gr-shell-command> </gr-shell-command>
+                <p>Or to amend an existing commit use</p>
+                <gr-shell-command> </gr-shell-command>
+                <p>
+                  Please make sure you add a commit message as it becomes the
+                description for your change.
+                </p>
+              </li>
+              <li>
+                <p>Push the change for code review</p>
+                <gr-shell-command> </gr-shell-command>
+              </li>
+              <li>
+                <p>
+                  Close this dialog and you should be able to see your recently
+                created change in the 'Outgoing changes' section on the 'Your
+                changes' page.
+                </p>
+              </li>
+            </ol>
+          </div>
+        </gr-dialog>
+      </dialog>
+    `
+    );
+  });
 });
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 ff9666b..561fffd 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
@@ -1,90 +1,95 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-create-destination-dialog_html';
-import {customElement, property} from '@polymer/decorators';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {RepoName, BranchName} from '../../../types/common';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html} from 'lit';
+import {customElement, state, query} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 export interface CreateDestinationConfirmDetail {
   repo?: RepoName;
   branch?: BranchName;
 }
 
-/**
- * Fired when a destination has been picked. Event details contain the repo
- * name and the branch name.
- *
- * @event confirm
- */
-export interface GrCreateDestinationDialog {
-  $: {
-    createOverlay: GrOverlay;
-  };
-}
-
 @customElement('gr-create-destination-dialog')
-export class GrCreateDestinationDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrCreateDestinationDialog extends LitElement {
+  /**
+   * Fired when a destination has been picked. Event details contain the repo
+   * name and the branch name.
+   *
+   * @event confirm
+   */
+
+  @query('#createModal') private createModal?: HTMLDialogElement;
+
+  @state() private repo?: RepoName;
+
+  @state() private branch?: BranchName;
+
+  static override get styles() {
+    return [sharedStyles, modalStyles];
   }
 
-  @property({type: String})
-  _repo?: RepoName;
-
-  @property({type: String})
-  _branch?: BranchName;
-
-  @property({
-    type: Boolean,
-    computed: '_computeRepoAndBranchSelected(_repo, _branch)',
-  })
-  _repoAndBranchSelected = false;
+  override render() {
+    return html`
+      <dialog id="createModal" tabindex="-1">
+        <gr-dialog
+          confirm-label="View commands"
+          @confirm=${this.pickerConfirm}
+          @cancel=${() => {
+            assertIsDefined(this.createModal, 'createModal');
+            this.createModal.close();
+          }}
+          ?disabled=${!(this.repo && this.branch)}
+        >
+          <div class="header" slot="header">Create change</div>
+          <div class="main" slot="main">
+            <gr-repo-branch-picker
+              .repo=${this.repo}
+              .branch=${this.branch}
+              @repo-changed=${(e: BindValueChangeEvent) => {
+                this.repo = e.detail.value as RepoName;
+              }}
+              @branch-changed=${(e: BindValueChangeEvent) => {
+                this.branch = e.detail.value as BranchName;
+              }}
+            ></gr-repo-branch-picker>
+            <p>
+              If you haven't done so, you will need to clone the repository.
+            </p>
+          </div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
 
   open() {
-    this._repo = '' as RepoName;
-    this._branch = '' as BranchName;
-    this.$.createOverlay.open();
+    assertIsDefined(this.createModal, 'createModal');
+    this.repo = '' as RepoName;
+    this.branch = '' as BranchName;
+    this.createModal.showModal();
   }
 
-  _handleClose() {
-    this.$.createOverlay.close();
-  }
-
-  _pickerConfirm(e: Event) {
-    this.$.createOverlay.close();
+  private pickerConfirm = (e: Event) => {
+    assertIsDefined(this.createModal, 'createModal');
+    this.createModal.close();
     const detail: CreateDestinationConfirmDetail = {
-      repo: this._repo,
-      branch: this._branch,
+      repo: this.repo,
+      branch: this.branch,
     };
     // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
     // 'confirm' event here, so let's stop propagation of the bare event.
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
-  }
-
-  _computeRepoAndBranchSelected(repo?: RepoName, branch?: BranchName) {
-    return !!(repo && branch);
-  }
+  };
 }
 
 declare global {
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
deleted file mode 100644
index 0aed75b..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_html.ts
+++ /dev/null
@@ -1,38 +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"></style>
-  <gr-overlay id="createOverlay" with-backdrop="">
-    <gr-dialog
-      confirm-label="View commands"
-      on-confirm="_pickerConfirm"
-      on-cancel="_handleClose"
-      disabled="[[!_repoAndBranchSelected]]"
-    >
-      <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>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
new file mode 100644
index 0000000..cb27aae
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import './gr-create-destination-dialog';
+import {GrCreateDestinationDialog} from './gr-create-destination-dialog';
+
+suite('gr-create-destination-dialog tests', () => {
+  let element: GrCreateDestinationDialog;
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-create-destination-dialog></gr-create-destination-dialog>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <dialog id="createModal" tabindex="-1">
+          <gr-dialog confirm-label="View commands" disabled="" role="dialog">
+            <div class="header" slot="header">Create change</div>
+            <div class="main" slot="main">
+              <gr-repo-branch-picker> </gr-repo-branch-picker>
+              <p>
+                If you haven't done so, you will need to clone the repository.
+              </p>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `
+    );
+  });
+});
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 9eca3bc..d013654 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
@@ -1,150 +1,336 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/gr-a11y-styles';
-import '../../../styles/shared-styles';
 import '../gr-change-list/gr-change-list';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
 import '../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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-dashboard-view_html';
-import {
-  GerritNav,
-  UserDashboard,
-  YOUR_TURN,
-} from '../../core/gr-navigation/gr-navigation';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {changeIsOpen} from '../../../utils/change-util';
 import {parseDate} from '../../../utils/date-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   ChangeInfo,
   DashboardId,
-  ElementPropertyDeepChange,
   PreferencesInput,
   RepoName,
 } from '../../../types/common';
-import {AppElementDashboardParams, AppElementParams} from '../../gr-app-types';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
 import {
   CreateDestinationConfirmDetail,
   GrCreateDestinationDialog,
 } from '../gr-create-destination-dialog/gr-create-destination-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {DashboardViewState} from '../../../types/types';
-import {firePageError, fireTitleChange} from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
+import {
+  fireAlert,
+  fireEvent,
+  firePageError,
+  fireTitleChange,
+} from '../../../utils/event-util';
 import {RELOAD_DASHBOARD_INTERVAL_MS} from '../../../constants/constants';
+import {ChangeListSection} from '../gr-change-list/gr-change-list';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
+import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {
+  dashboardViewModelToken,
+  DashboardViewState,
+} from '../../../models/views/dashboard';
+import {createSearchUrl} from '../../../models/views/search';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {
+  getUserDashboard,
+  OUTGOING,
+  UserDashboard,
+  YOUR_TURN,
+} from '../../../utils/dashboard-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {Timing} from '../../../constants/reporting';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
 
-export interface GrDashboardView {
-  $: {
-    confirmDeleteDialog: GrDialog;
-    commandsDialog: GrCreateCommandsDialog;
-    destinationDialog: GrCreateDestinationDialog;
-    confirmDeleteOverlay: GrOverlay;
-  };
-}
-
-interface DashboardChange {
-  name: string;
-  countLabel: string;
-  query: string;
-  results: ChangeInfo[];
-  isOutgoing?: boolean;
-}
+const slotNameBySectionName = new Map<string, string>([
+  [YOUR_TURN.name, 'your-turn-slot'],
+  [OUTGOING.name, 'outgoing-slot'],
+]);
 
 @customElement('gr-dashboard-view')
-export class GrDashboardView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDashboardView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
    * @event title-change
    */
 
+  @query('#confirmDeleteDialog') protected confirmDeleteDialog?: GrDialog;
+
+  @query('#commandsDialog') protected commandsDialog?: GrCreateCommandsDialog;
+
+  @query('#destinationDialog')
+  protected destinationDialog?: GrCreateDestinationDialog;
+
+  @query('#confirmDeleteModal')
+  protected confirmDeleteModal?: HTMLDialogElement;
+
   @property({type: Object})
-  account: AccountDetailInfo | null = null;
+  account?: AccountDetailInfo;
 
   @property({type: Object})
   preferences?: PreferencesInput;
 
-  @property({type: Object})
+  @state()
   viewState?: DashboardViewState;
 
-  @property({type: Object})
-  params?: AppElementParams;
+  // private but used in test
+  @state() results?: ChangeListSection[];
 
-  @property({type: Array})
-  _results?: DashboardChange[];
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() showDraftsBanner = false;
 
-  @property({type: Boolean})
-  _showDraftsBanner = false;
+  // private but used in test
+  @state() showNewUserHelp = false;
 
-  @property({type: Boolean})
-  _showNewUserHelp = false;
+  private reporting = getAppContext().reportingService;
 
-  @property({type: Number})
-  _selectedChangeIndex?: number;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private reporting = appContext.reportingService;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly getViewModel = resolve(this, dashboardViewModelToken);
 
   private lastVisibleTimestampMs = 0;
 
+  /**
+   * For `DASHBOARD_DISPLAYED` timing we can only rely on the router to have
+   * reset the timer properly when the dashboard loads for the first time.
+   * Later we won't have a guarantee that the timer was just reset. So we will
+   * just reset the timer at the beginning of `reload()`. The dashboard view
+   * is cached anyway, so there is unlikely a lot of time that has passed
+   * initiating the reload and the reload() method being executed.
+   */
+  private firstTimeLoad = true;
+
+  private readonly shortcuts = new ShortcutController(this);
+
   constructor() {
     super();
-    this.addEventListener('reload', () => this._reload(this.params));
-    // We are not currently verifying if the view is actually visible. We rely
-    // on gr-app-element to restamp the component if view changes
-    document.addEventListener('visibilitychange', () => {
-      if (document.visibilityState === 'visible') {
-        if (
-          Date.now() - this.lastVisibleTimestampMs >
-          RELOAD_DASHBOARD_INTERVAL_MS
-        )
-          this._reload(this.params);
-      } else {
-        this.lastVisibleTimestampMs = Date.now();
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => {
+        this.viewState = x;
+        this.reload();
       }
-    });
+    );
+    this.addEventListener('reload', () => this.reload());
+    this.shortcuts.addAbstract(Shortcut.UP_TO_DASHBOARD, () => this.reload());
   }
 
+  private readonly visibilityChangeListener = () => {
+    if (document.visibilityState === 'visible') {
+      if (
+        Date.now() - this.lastVisibleTimestampMs >
+        RELOAD_DASHBOARD_INTERVAL_MS
+      )
+        this.reload();
+    } else {
+      this.lastVisibleTimestampMs = Date.now();
+    }
+  };
+
   override connectedCallback() {
     super.connectedCallback();
-    this._loadPreferences();
+    this.loadPreferences();
+    document.addEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
   }
 
-  _loadPreferences() {
+  override disconnectedCallback() {
+    document.removeEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+    super.disconnectedCallback();
+  }
+
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      modalStyles,
+      css`
+        :host {
+          display: block;
+        }
+        .loading {
+          color: var(--deemphasized-text-color);
+          padding: var(--spacing-l);
+        }
+        gr-change-list {
+          width: 100%;
+        }
+        gr-user-header {
+          border-bottom: 1px solid var(--border-color);
+        }
+        .banner {
+          align-items: center;
+          background-color: var(--comment-background-color);
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-xs) var(--spacing-l);
+        }
+        .hide {
+          display: none;
+        }
+        #emptyOutgoing {
+          display: block;
+        }
+        @media only screen and (max-width: 50em) {
+          .loading {
+            padding: 0 var(--spacing-l);
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.viewState) return nothing;
+    return html`
+      ${this.renderBanner()} ${this.renderContent()}
+      <dialog id="confirmDeleteModal" tabindex="-1">
+        <gr-dialog
+          id="confirmDeleteDialog"
+          confirm-label="Delete"
+          @confirm=${() => {
+            this.handleConfirmDelete();
+          }}
+          @cancel=${() => {
+            this.closeConfirmDeleteModal();
+          }}
+        >
+          <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.
+          </div>
+        </gr-dialog>
+      </dialog>
+      <gr-create-destination-dialog
+        id="destinationDialog"
+        @confirm=${(e: CustomEvent<CreateDestinationConfirmDetail>) => {
+          this.handleDestinationConfirm(e);
+        }}
+      ></gr-create-destination-dialog>
+      <gr-create-commands-dialog
+        id="commandsDialog"
+      ></gr-create-commands-dialog>
+    `;
+  }
+
+  private renderBanner() {
+    if (!this.showDraftsBanner) return;
+
+    return html`
+      <div class="banner">
+        <div>
+          You have draft comments on closed changes.
+          <a href=${this.computeDraftsLink()} target="_blank">(view all)</a>
+        </div>
+        <div>
+          <gr-button
+            class="delete"
+            link
+            @click=${() => {
+              this.handleOpenDeleteDialog();
+            }}
+            >Delete All</gr-button
+          >
+        </div>
+      </div>
+    `;
+  }
+
+  private renderContent() {
+    // In case of an internal reload we want the ChangeList section components
+    // to remain in the DOM so that the Bulk Actions Model associated with them
+    // is not recreated after the reload resulting in user selections being lost
+    return html`
+      <div class="loading" ?hidden=${!this.loading}>Loading...</div>
+      <div ?hidden=${this.loading}>
+        ${this.renderUserHeader()}
+        <h1 class="assistive-tech-only">Dashboard</h1>
+        <gr-change-list
+          ?showStar=${true}
+          .account=${this.account}
+          .preferences=${this.preferences}
+          .sections=${this.results}
+          .usp=${'dashboard'}
+          @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
+            this.handleToggleStar(e);
+          }}
+        >
+          <div id="emptyOutgoing" slot="outgoing-slot">
+            ${this.renderShowNewUserHelp()}
+          </div>
+          <div id="emptyYourTurn" slot="your-turn-slot">
+            <span>No changes need your attention &nbsp;&#x1f389;</span>
+          </div>
+        </gr-change-list>
+      </div>
+    `;
+  }
+
+  private renderUserHeader() {
+    if (
+      !!this.viewState?.project ||
+      !this.viewState?.user ||
+      this.viewState?.user === 'self'
+    ) {
+      return;
+    }
+
+    return html`
+      <gr-user-header .userId=${this.viewState?.user}></gr-user-header>
+    `;
+  }
+
+  private renderShowNewUserHelp() {
+    if (!this.showNewUserHelp) return ' No changes ';
+
+    return html`
+      <gr-create-change-help
+        @create-tap=${() => {
+          this.handleCreateChangeTap();
+        }}
+      ></gr-create-change-help>
+    `;
+  }
+
+  private loadPreferences() {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         this.restApiService.getPreferences().then(preferences => {
@@ -156,15 +342,17 @@
     });
   }
 
-  _getProjectDashboard(
-    project: RepoName,
-    dashboard: DashboardId
+  // private but used in test
+  getRepositoryDashboard(
+    repo: RepoName,
+    dashboard?: DashboardId
   ): Promise<UserDashboard | undefined> {
     const errFn = (response?: Response | null) => {
       firePageError(response);
     };
+    assertIsDefined(dashboard, 'project dashboard must have id');
     return this.restApiService
-      .getDashboard(project, dashboard, errFn)
+      .getDashboard(repo, dashboard, errFn)
       .then(response => {
         if (!response) {
           return;
@@ -177,7 +365,7 @@
               name: section.name,
               query: (section.query + suffix).replace(
                 PROJECT_PLACEHOLDER_PATTERN,
-                project
+                repo
               ),
             };
           }),
@@ -185,59 +373,36 @@
       });
   }
 
-  _computeTitle(user?: string) {
+  // private but used in test
+  computeTitle(user?: string) {
     if (!user || user === 'self') {
       return 'My Reviews';
     }
     return 'Dashboard for ' + user;
   }
 
-  _isViewActive(params: AppElementParams): params is AppElementDashboardParams {
-    return params.view === GerritView.DASHBOARD;
-  }
-
-  @observe('_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);
-  }
-
   /**
    * Reloads the element.
+   *
+   * private but used in test
    */
-  _reload(params?: AppElementParams) {
-    if (!params || !this._isViewActive(params)) {
-      return Promise.resolve();
-    }
-    this._loading = true;
-    const {project, dashboard, title, user, sections} = params;
-    const dashboardPromise: Promise<UserDashboard | undefined> = project
-      ? this._getProjectDashboard(project, dashboard)
-      : this.restApiService
-          .getConfig()
-          .then(config =>
-            Promise.resolve(
-              GerritNav.getUserDashboard(
-                user,
-                sections,
-                title || this._computeTitle(user),
-                config
-              )
-            )
-          );
+  reload() {
+    if (!this.viewState) return Promise.resolve();
 
+    // See `firstTimeLoad` comment above.
+    if (!this.firstTimeLoad) {
+      this.reporting.time(Timing.DASHBOARD_DISPLAYED);
+    }
+    this.firstTimeLoad = false;
+
+    this.loading = true;
+    const {project, dashboard, title, user, sections} = this.viewState;
+
+    const dashboardPromise: Promise<UserDashboard | undefined> = project
+      ? this.getRepositoryDashboard(project, dashboard)
+      : Promise.resolve(
+          getUserDashboard(user, sections, title || this.computeTitle(user))
+        );
     // 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';
@@ -246,26 +411,28 @@
         if (res && res.title) {
           fireTitleChange(this, res.title);
         }
-        return this._fetchDashboardChanges(res, checkForNewUser);
+        return this.fetchDashboardChanges(res, checkForNewUser);
       })
       .then(() => {
-        this._maybeShowDraftsBanner(params);
+        this.maybeShowDraftsBanner();
         this.reporting.dashboardDisplayed();
       })
       .catch(err => {
-        fireTitleChange(this, title || this._computeTitle(user));
-        this.reporting.error(err);
+        fireTitleChange(this, title || this.computeTitle(user));
+        this.reporting.error('Dashboard reload', err);
       })
-      .then(() => {
-        this._loading = false;
+      .finally(() => {
+        this.loading = false;
       });
   }
 
   /**
-   * Fetches the changes for each dashboard section and sets this._results
+   * Fetches the changes for each dashboard section and sets this.results
    * with the response.
+   *
+   * private but used in test
    */
-  _fetchDashboardChanges(
+  fetchDashboardChanges(
     res: UserDashboard | undefined,
     checkForNewUser: boolean
   ): Promise<void> {
@@ -291,31 +458,35 @@
       }
     }
 
-    return this.restApiService.getChanges(undefined, queries).then(changes => {
-      if (!changes) {
-        throw new Error('getChanges returns undefined');
-      }
-      if (checkForNewUser) {
-        // Last set of results is not meant for dashboard display.
-        const lastResultSet = changes.pop();
-        this._showNewUserHelp = lastResultSet!.length === 0;
-      }
-      this._results = changes
-        .map((results, i) => {
-          return {
-            name: res.sections[i].name,
-            countLabel: this._computeSectionCountLabel(results),
-            query: res.sections[i].query,
-            results: this._maybeSortResults(res.sections[i].name, results),
-            isOutgoing: res.sections[i].isOutgoing,
-          };
-        })
-        .filter(
-          (section, i) =>
-            i < res.sections.length &&
-            (!res.sections[i].hideIfEmpty || section.results.length)
-        );
-    });
+    return this.restApiService
+      .getChangesForMultipleQueries(undefined, queries)
+      .then(changes => {
+        if (!changes) {
+          throw new Error('getChanges returns undefined');
+        }
+        if (checkForNewUser) {
+          // Last set of results is not meant for dashboard display.
+          const lastResultSet = changes.pop();
+          this.showNewUserHelp = lastResultSet!.length === 0;
+        }
+        this.results = changes
+          .map((results, i) => {
+            return {
+              name: res.sections[i].name,
+              countLabel: this.computeSectionCountLabel(results),
+              query: res.sections[i].query,
+              results: this.maybeSortResults(res.sections[i].name, results),
+              emptyStateSlotName: slotNameBySectionName.get(
+                res.sections[i].name
+              ),
+            };
+          })
+          .filter(
+            (section, i) =>
+              i < res.sections.length &&
+              (!res.sections[i].hideIfEmpty || section.results.length)
+          );
+      });
   }
 
   /**
@@ -324,8 +495,8 @@
    * top where the current user is a reviewer. Owned changes are less important.
    * And then we want to emphasize the changes where the waiting time is larger.
    */
-  _maybeSortResults(name: string, results: ChangeInfo[]) {
-    const userId = this.account && this.account._account_id;
+  private maybeSortResults(name: string, results: ChangeInfo[]) {
+    const userId = this.account?._account_id;
     const sortedResults = [...results];
     if (name === YOUR_TURN.name && userId) {
       sortedResults.sort((c1, c2) => {
@@ -346,7 +517,8 @@
     return sortedResults;
   }
 
-  _computeSectionCountLabel(changes: ChangeInfo[]) {
+  // private but used in test
+  computeSectionCountLabel(changes: ChangeInfo[]) {
     if (!changes || !changes.length || changes.length === 0) {
       return '';
     }
@@ -356,37 +528,29 @@
     return `(${numChanges}${andMore})`;
   }
 
-  _computeUserHeaderClass(params: AppElementParams) {
-    if (
-      !params ||
-      params.view !== GerritView.DASHBOARD ||
-      !!params.project ||
-      !params.user ||
-      params.user === 'self'
-    ) {
-      return 'hide';
-    }
-    return '';
-  }
-
-  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
-    this.restApiService.saveChangeStarred(
+  // private but used in test
+  async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    const msg = e.detail.starred
+      ? 'Starring change...'
+      : 'Unstarring change...';
+    fireAlert(this, msg);
+    await this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
     );
+    fireEvent(this, 'hide-alert');
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-dashboard');
     }
     // When a change is updated the same change may appear elsewhere in the
     // dashboard (but is not the same object), so we must update other
     // occurrences of the same change.
-    this._results?.forEach((dashboardChange, dashboardIndex) =>
+    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
-          );
+          this.results![dashboardIndex].results[changeIndex].starred =
+            e.detail.starred;
+          this.requestUpdate('results');
         }
       })
     );
@@ -395,73 +559,66 @@
   /**
    * Banner is shown if a user is on their own dashboard and they have draft
    * comments on closed changes.
+   *
+   * private but used in test
    */
-  _maybeShowDraftsBanner(params: AppElementDashboardParams) {
-    this._showDraftsBanner = false;
-    if (!(params.user === 'self')) {
-      return;
+  maybeShowDraftsBanner() {
+    this.showDraftsBanner = false;
+    if (!(this.viewState?.user === 'self')) return;
+
+    if (!this.results) {
+      throw new Error('this.results must be set. restAPI returned undefined');
     }
 
-    if (!this._results) {
-      throw new Error('this._results must be set. restAPI returned undefined');
-    }
-
-    const draftSection = this._results.find(
+    const draftSection = this.results.find(
       section => section.query === 'has:draft'
     );
-    if (!draftSection || !draftSection.results.length) {
-      return;
-    }
+    if (!draftSection || !draftSection.results.length) return;
 
     const closedChanges = draftSection.results.filter(
       change => !changeIsOpen(change)
     );
-    if (!closedChanges.length) {
-      return;
-    }
+    if (!closedChanges.length) return;
 
-    this._showDraftsBanner = true;
+    this.showDraftsBanner = true;
   }
 
-  _computeBannerClass(show: boolean) {
-    return show ? '' : 'hide';
+  // private but used in test
+  handleOpenDeleteDialog() {
+    assertIsDefined(this.confirmDeleteModal, 'confirmDeleteModal');
+    this.confirmDeleteModal.showModal();
   }
 
-  _handleOpenDeleteDialog() {
-    this.$.confirmDeleteOverlay.open();
-  }
-
-  _handleConfirmDelete() {
-    this.$.confirmDeleteDialog.disabled = true;
+  // private but used in test
+  handleConfirmDelete() {
+    assertIsDefined(this.confirmDeleteDialog, 'confirmDeleteDialog');
+    this.confirmDeleteDialog.disabled = true;
     return this.restApiService.deleteDraftComments('-is:open').then(() => {
-      this._closeConfirmDeleteOverlay();
-      this._reload(this.params);
+      this.closeConfirmDeleteModal();
+      this.reload();
     });
   }
 
-  _closeConfirmDeleteOverlay() {
-    this.$.confirmDeleteOverlay.close();
+  private closeConfirmDeleteModal() {
+    assertIsDefined(this.confirmDeleteModal, 'confirmDeleteModal');
+    this.confirmDeleteModal.close();
   }
 
-  _computeDraftsLink() {
-    return GerritNav.getUrlForSearchQuery('has:draft -is:open');
+  private computeDraftsLink() {
+    return createSearchUrl({query: 'has:draft -is:open'});
   }
 
-  _handleCreateChangeTap() {
-    this.$.destinationDialog.open();
+  private handleCreateChangeTap() {
+    assertIsDefined(this.destinationDialog, 'destinationDialog');
+    this.destinationDialog.open();
   }
 
-  _handleDestinationConfirm(e: CustomEvent<CreateDestinationConfirmDetail>) {
-    this.$.commandsDialog.branch = e.detail.branch;
-    this.$.commandsDialog.open();
-  }
-
-  /**
-   * Returns `this` as the visibility observer target for the keyboard shortcut
-   * mixin to decide whether shortcuts should be enabled or not.
-   */
-  _computeObserverTarget() {
-    return this;
+  private handleDestinationConfirm(
+    e: CustomEvent<CreateDestinationConfirmDetail>
+  ) {
+    assertIsDefined(this.commandsDialog, 'commandsDialog');
+    this.commandsDialog.branch = e.detail.branch;
+    this.commandsDialog.open();
   }
 }
 
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
deleted file mode 100644
index a55befb..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    .loading {
-      color: var(--deemphasized-text-color);
-      padding: var(--spacing-l);
-    }
-    gr-change-list {
-      width: 100%;
-    }
-    gr-user-header {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .banner {
-      align-items: center;
-      background-color: var(--comment-background-color);
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-xs) var(--spacing-l);
-    }
-    .hide {
-      display: none;
-    }
-    #emptyOutgoing {
-      display: block;
-    }
-    @media only screen and (max-width: 50em) {
-      .loading {
-        padding: 0 var(--spacing-l);
-      }
-    }
-  </style>
-  <div class$="banner [[_computeBannerClass(_showDraftsBanner)]]">
-    <div>
-      You have draft comments on closed changes.
-      <a href$="[[_computeDraftsLink(_showDraftsBanner)]]" target="_blank"
-        >(view all)</a
-      >
-    </div>
-    <div>
-      <gr-button class="delete" link="" on-click="_handleOpenDeleteDialog"
-        >Delete All</gr-button
-      >
-    </div>
-  </div>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-user-header
-      user-id="[[params.user]]"
-      class$="[[_computeUserHeaderClass(params)]]"
-    ></gr-user-header>
-    <h1 class="assistive-tech-only">Dashboard</h1>
-    <gr-change-list
-      show-star=""
-      account="[[account]]"
-      preferences="[[preferences]]"
-      selected-index="{{_selectedChangeIndex}}"
-      sections="[[_results]]"
-      on-toggle-star="_handleToggleStar"
-      observer-target="[[_computeObserverTarget()]]"
-    >
-      <div id="emptyOutgoing" slot="empty-outgoing">
-        <template is="dom-if" if="[[_showNewUserHelp]]">
-          <gr-create-change-help
-            on-create-tap="_handleCreateChangeTap"
-          ></gr-create-change-help>
-        </template>
-        <template is="dom-if" if="[[!_showNewUserHelp]]"> No changes </template>
-      </div>
-      <div id="emptyYourTurn" slot="empty-your-turn">
-        <span>No changes need your attention &nbsp;&#x1f389;</span>
-      </div>
-    </gr-change-list>
-  </div>
-  <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
-    <gr-dialog
-      id="confirmDeleteDialog"
-      confirm-label="Delete"
-      on-confirm="_handleConfirmDelete"
-      on-cancel="_closeConfirmDeleteOverlay"
-    >
-      <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.
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-  <gr-create-destination-dialog
-    id="destinationDialog"
-    on-confirm="_handleDestinationConfirm"
-  ></gr-create-destination-dialog>
-  <gr-create-commands-dialog id="commandsDialog"></gr-create-commands-dialog>
-`;
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
deleted file mode 100644
index aa76347..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ /dev/null
@@ -1,452 +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-dashboard-view.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, mockPromise} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-dashboard-view');
-
-suite('gr-dashboard-view tests', () => {
-  let element;
-
-  let paramsChangedPromise;
-  let getChangesStub;
-
-  setup(() => {
-    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;
-    });
-    const paramsChanged = element._paramsChanged.bind(element);
-    sinon.stub(element, '_paramsChanged').callsFake( params => {
-      paramsChanged(params).then(() => resolver());
-    });
-  });
-
-  suite('drafts banner functionality', () => {
-    suite('_maybeShowDraftsBanner', () => {
-      test('not dashboard/self', () => {
-        element._maybeShowDraftsBanner({
-          view: GerritView.DASHBOARD,
-          user: 'notself',
-        });
-        assert.isFalse(element._showDraftsBanner);
-      });
-
-      test('no drafts at all', () => {
-        element._results = [];
-        element._maybeShowDraftsBanner({
-          view: GerritView.DASHBOARD,
-          user: 'self',
-        });
-        assert.isFalse(element._showDraftsBanner);
-      });
-
-      test('no drafts on open changes', () => {
-        const openChange = {status: ChangeStatus.NEW};
-        element._results = [{query: 'has:draft', results: [openChange]}];
-        element._maybeShowDraftsBanner({
-          view: GerritView.DASHBOARD,
-          user: 'self',
-        });
-        assert.isFalse(element._showDraftsBanner);
-      });
-
-      test('no drafts on not open changes', () => {
-        const notOpenChange = {status: '_'};
-        element._results = [{query: 'has:draft', results: [notOpenChange]}];
-        assert.isFalse(changeIsOpen(element._results[0].results[0]));
-        element._maybeShowDraftsBanner({
-          view: GerritView.DASHBOARD,
-          user: 'self',
-        });
-        assert.isTrue(element._showDraftsBanner);
-      });
-    });
-
-    test('_showDraftsBanner', () => {
-      element._showDraftsBanner = false;
-      flush();
-      assert.isTrue(isHidden(element.shadowRoot
-          .querySelector('.banner')));
-
-      element._showDraftsBanner = true;
-      flush();
-      assert.isFalse(isHidden(element.shadowRoot
-          .querySelector('.banner')));
-    });
-
-    test('delete tap opens dialog', () => {
-      sinon.stub(element, '_handleOpenDeleteDialog');
-      element._showDraftsBanner = true;
-      flush();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.banner .delete'));
-      assert.isTrue(element._handleOpenDeleteDialog.called);
-    });
-
-    test('delete comments flow', async () => {
-      sinon.spy(element, '_handleConfirmDelete');
-      sinon.stub(element, '_reload');
-
-      // Set up control over timing of when RPC resolves.
-      let deleteDraftCommentsPromiseResolver;
-      const deleteDraftCommentsPromise = new Promise(resolve => {
-        deleteDraftCommentsPromiseResolver = resolve;
-      });
-      const deleteStub = stubRestApi('deleteDraftComments')
-          .returns(deleteDraftCommentsPromise);
-
-      // Open confirmation dialog and tap confirm button.
-      await element.$.confirmDeleteOverlay.open();
-      MockInteractions.tap(element.$.confirmDeleteDialog.confirmButton);
-      flush();
-      assert.isTrue(deleteStub.calledWithExactly('-is:open'));
-      assert.isTrue(element.$.confirmDeleteDialog.disabled);
-      assert.equal(element._reload.callCount, 0);
-
-      // Verify state after RPC resolves.
-      deleteDraftCommentsPromiseResolver([]);
-      await deleteDraftCommentsPromise;
-      assert.equal(element._reload.callCount, 1);
-    });
-  });
-
-  test('_computeTitle', () => {
-    assert.equal(element._computeTitle('self'), 'My Reviews');
-    assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
-  });
-
-  suite('_computeSectionCountLabel', () => {
-    test('empty changes dont count label', () => {
-      assert.equal('', element._computeSectionCountLabel([]));
-    });
-
-    test('1 change', () => {
-      assert.equal('(1)',
-          element._computeSectionCountLabel(['1']));
-    });
-
-    test('2 changes', () => {
-      assert.equal('(2)',
-          element._computeSectionCountLabel(['1', '2']));
-    });
-
-    test('1 change and more', () => {
-      assert.equal('(1 and more)',
-          element._computeSectionCountLabel([{_more_changes: true}]));
-    });
-  });
-
-  suite('_isViewActive', () => {
-    test('nothing happens when user param is falsy', () => {
-      element.params = {};
-      flush();
-      assert.equal(getChangesStub.callCount, 0);
-
-      element.params = {user: ''};
-      flush();
-      assert.equal(getChangesStub.callCount, 0);
-    });
-
-    test('content is refreshed when user param is updated', () => {
-      element.params = {
-        view: GerritNav.View.DASHBOARD,
-        user: 'self',
-      };
-      return paramsChangedPromise.then(() => {
-        assert.equal(getChangesStub.callCount, 1);
-      });
-    });
-  });
-
-  suite('selfOnly sections', () => {
-    test('viewing self dashboard includes selfOnly sections', () => {
-      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']));
-      });
-    });
-
-    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']));
-      });
-    });
-
-    test('viewing another user\'s dashboard omits selfOnly sections', () => {
-      element.params = {
-        view: GerritNav.View.DASHBOARD,
-        sections: [
-          {query: '1'},
-          {query: '2', selfOnly: true},
-        ],
-        user: 'user',
-      };
-      return paramsChangedPromise.then(() => {
-        assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
-      });
-    });
-  });
-
-  test('suffixForDashboard is included in getChanges query', () => {
-    element.params = {
-      view: GerritNav.View.DASHBOARD,
-      sections: [
-        {query: '1'},
-        {query: '2', suffixForDashboard: 'suffix'},
-      ],
-    };
-    return paramsChangedPromise.then(() => {
-      assert.isTrue(getChangesStub.calledOnce);
-      assert.deepEqual(
-          getChangesStub.firstCall.args, [undefined, ['1', '2 suffix']]);
-    });
-  });
-
-  suite('_getProjectDashboard', () => {
-    test('dashboard with foreach', () => {
-      stubRestApi('getDashboard')
-          .callsFake( () => Promise.resolve({
-            title: 'title',
-            foreach: 'foreach for ${project}',
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: '${project} query 2'},
-            ],
-          }));
-      return element._getProjectDashboard('project', '').then(dashboard => {
-        assert.deepEqual(
-            dashboard,
-            {
-              title: 'title',
-              sections: [
-                {name: 'section 1', query: 'query 1 foreach for project'},
-                {
-                  name: 'section 2',
-                  query: 'project query 2 foreach for project',
-                },
-              ],
-            });
-      });
-    });
-
-    test('dashboard without foreach', () => {
-      stubRestApi('getDashboard').callsFake(
-          () => Promise.resolve({
-            title: 'title',
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: '${project} query 2'},
-            ],
-          }));
-      return element._getProjectDashboard('project', '').then(dashboard => {
-        assert.deepEqual(
-            dashboard,
-            {
-              title: 'title',
-              sections: [
-                {name: 'section 1', query: 'query 1'},
-                {name: 'section 2', query: 'project query 2'},
-              ],
-            });
-      });
-    });
-  });
-
-  test('hideIfEmpty sections', () => {
-    const sections = [
-      {name: 'test1', query: 'test1', hideIfEmpty: true},
-      {name: 'test2', query: 'test2', hideIfEmpty: true},
-    ];
-    getChangesStub.restore();
-    stubRestApi('getChanges')
-        .returns(Promise.resolve([[], ['nonempty']]));
-
-    return element._fetchDashboardChanges({sections}, false).then(() => {
-      assert.equal(element._results.length, 1);
-      assert.equal(element._results[0].name, 'test2');
-    });
-  });
-
-  test('preserve isOutgoing sections', () => {
-    const sections = [
-      {name: 'test1', query: 'test1', isOutgoing: true},
-      {name: 'test2', query: 'test2'},
-    ];
-    getChangesStub.restore();
-    stubRestApi('getChanges')
-        .returns(Promise.resolve([[], []]));
-
-    return element._fetchDashboardChanges({sections}, false).then(() => {
-      assert.equal(element._results.length, 2);
-      assert.isTrue(element._results[0].isOutgoing);
-      assert.isNotOk(element._results[1].isOutgoing);
-    });
-  });
-
-  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('_showNewUserHelp', () => {
-    element._loading = false;
-    element._showNewUserHelp = false;
-    flush();
-
-    assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
-    assert.isNotOk(element.shadowRoot
-        .querySelector('gr-create-change-help'));
-    element._showNewUserHelp = true;
-    flush();
-
-    assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
-    assert.isOk(element.shadowRoot
-        .querySelector('gr-create-change-help'));
-  });
-
-  test('_computeUserHeaderClass', () => {
-    assert.equal(element._computeUserHeaderClass(undefined), 'hide');
-    assert.equal(element._computeUserHeaderClass({}), 'hide');
-    assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
-    assert.equal(element._computeUserHeaderClass({user: 'user'}), 'hide');
-    assert.equal(
-        element._computeUserHeaderClass({
-          view: GerritView.DASHBOARD,
-          user: 'user',
-        }),
-        '');
-    assert.equal(
-        element._computeUserHeaderClass({project: 'p', user: 'user'}),
-        'hide');
-    assert.equal(
-        element._computeUserHeaderClass({
-          view: GerritView.DASHBOARD,
-          project: 'p',
-          user: 'user',
-        }),
-        'hide');
-  });
-
-  test('404 page', async () => {
-    const response = {status: 404};
-    stubRestApi('getDashboard').callsFake(
-        async (project, dashboard, errFn) => {
-          errFn(response);
-        });
-    const promise = mockPromise();
-    addListenerForTest(document, 'page-error', e => {
-      assert.strictEqual(e.detail.response, response);
-      promise.resolve();
-    });
-    element.params = {
-      view: GerritNav.View.DASHBOARD,
-      project: 'project',
-      dashboard: 'dashboard',
-    };
-    await Promise.all([paramsChangedPromise, promise]);
-  });
-
-  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',
-    };
-    await paramsChangedPromise;
-    assert.isTrue(element.reporting.dashboardDisplayed.calledOnce);
-  });
-
-  test('selectedChangeIndex is derived from the params', async () => {
-    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');
-    await paramsChangedPromise;
-    assert.equal(element._selectedChangeIndex, 23);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
new file mode 100644
index 0000000..17d7e95
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -0,0 +1,619 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-dashboard-view';
+import {GrDashboardView} from './gr-dashboard-view';
+import {GerritView} from '../../../services/router/router-model';
+import {changeIsOpen} from '../../../utils/change-util';
+import {ChangeStatus} from '../../../constants/constants';
+import {
+  createAccountDetailWithId,
+  createChange,
+} from '../../../test/test-data-generators';
+import {
+  addListenerForTest,
+  stubReporting,
+  stubRestApi,
+  mockPromise,
+  queryAndAssert,
+  query,
+  stubFlags,
+  waitUntil,
+} from '../../../test/test-utils';
+import {
+  ChangeInfoId,
+  DashboardId,
+  RepoName,
+  Timestamp,
+} from '../../../types/common';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateChangeHelp} from '../gr-create-change-help/gr-create-change-help';
+import {PageErrorEvent} from '../../../types/events';
+import {fixture, html, assert} from '@open-wc/testing';
+import {SinonStubbedMember} from 'sinon';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+suite('gr-dashboard-view tests', () => {
+  let element: GrDashboardView;
+
+  let getChangesStub: SinonStubbedMember<
+    RestApiService['getChangesForMultipleQueries']
+  >;
+
+  setup(async () => {
+    getChangesStub = stubRestApi('getChangesForMultipleQueries');
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getAccountDetails').returns(
+      Promise.resolve({
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+
+    element = await fixture<GrDashboardView>(html`
+      <gr-dashboard-view></gr-dashboard-view>
+    `);
+
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      user: 'self',
+      sections: [
+        {name: 'test1', query: 'test1', hideIfEmpty: true},
+        {name: 'test2', query: 'test2', hideIfEmpty: true},
+      ],
+    };
+    getChangesStub.returns(Promise.resolve([[createChange()]]));
+    await element.reload();
+    element.loading = false;
+    stubFlags('isEnabled').returns(true);
+    element.requestUpdate();
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+        <div class="loading" hidden="">Loading...</div>
+        <div>
+          <h1 class="assistive-tech-only">Dashboard</h1>
+          <gr-change-list showstar="">
+            <div id="emptyOutgoing" slot="outgoing-slot">No changes</div>
+            <div id="emptyYourTurn" slot="your-turn-slot">
+              <span> No changes need your attention &nbsp🎉 </span>
+            </div>
+          </gr-change-list>
+        </div>
+        <dialog
+          id="confirmDeleteModal"
+          tabindex="-1"
+        >
+          <gr-dialog
+            confirm-label="Delete"
+            id="confirmDeleteDialog"
+            role="dialog"
+          >
+            <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.
+            </div>
+          </gr-dialog>
+        </dialog>
+        <gr-create-destination-dialog id="destinationDialog">
+        </gr-create-destination-dialog>
+        <gr-create-commands-dialog id="commandsDialog">
+        </gr-create-commands-dialog>
+      `
+    );
+  });
+
+  suite('bulk actions', () => {
+    setup(async () => {
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'user',
+        sections: [
+          {name: 'test1', query: 'test1', hideIfEmpty: true},
+          {name: 'test2', query: 'test2', hideIfEmpty: true},
+        ],
+      };
+      getChangesStub.returns(Promise.resolve([[createChange()]]));
+      stubFlags('isEnabled').returns(true);
+      await element.reload();
+      element.loading = false;
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('checkboxes remain checked after soft reload', async () => {
+      const checkbox = queryAndAssert<HTMLInputElement>(
+        query(
+          query(query(element, 'gr-change-list'), 'gr-change-list-section'),
+          'gr-change-list-item'
+        ),
+        '.selection > label > input'
+      );
+      checkbox.click();
+      await waitUntil(() => checkbox.checked);
+
+      getChangesStub.restore();
+      getChangesStub.returns(Promise.resolve([[createChange()]]));
+
+      await element.reload();
+      await element.updateComplete;
+      assert.isTrue(checkbox.checked);
+    });
+  });
+
+  suite('drafts banner functionality', () => {
+    setup(async () => {
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'self',
+        sections: [
+          {name: 'test1', query: 'test1', hideIfEmpty: true},
+          {name: 'test2', query: 'test2', hideIfEmpty: true},
+        ],
+      };
+    });
+
+    suite('maybeShowDraftsBanner', () => {
+      test('not dashboard/self', () => {
+        element.viewState = {
+          view: GerritView.DASHBOARD,
+          user: 'notself',
+          dashboard: '' as DashboardId,
+        };
+        element.maybeShowDraftsBanner();
+        assert.isFalse(element.showDraftsBanner);
+      });
+
+      test('no drafts at all', () => {
+        element.results = [];
+        element.viewState = {
+          view: GerritView.DASHBOARD,
+          user: 'self',
+          dashboard: '' as DashboardId,
+        };
+        element.maybeShowDraftsBanner();
+        assert.isFalse(element.showDraftsBanner);
+      });
+
+      test('no drafts on open changes', () => {
+        const openChange = {...createChange(), status: ChangeStatus.NEW};
+        element.results = [
+          {countLabel: '', name: '', query: 'has:draft', results: [openChange]},
+        ];
+        element.viewState = {
+          view: GerritView.DASHBOARD,
+          user: 'self',
+          dashboard: '' as DashboardId,
+        };
+        element.maybeShowDraftsBanner();
+        assert.isFalse(element.showDraftsBanner);
+      });
+
+      test('no drafts on not open changes', () => {
+        const notOpenChange = {...createChange(), status: '_' as ChangeStatus};
+        element.results = [
+          {
+            name: '',
+            countLabel: '',
+            query: 'has:draft',
+            results: [notOpenChange],
+          },
+        ];
+        assert.isFalse(changeIsOpen(element.results[0].results[0]));
+        element.viewState = {
+          view: GerritView.DASHBOARD,
+          user: 'self',
+          dashboard: '' as DashboardId,
+        };
+        element.maybeShowDraftsBanner();
+        assert.isTrue(element.showDraftsBanner);
+      });
+    });
+
+    test('showDraftsBanner', async () => {
+      element.showDraftsBanner = false;
+      await element.updateComplete;
+      assert.isNotOk(query(element, '.banner'));
+
+      element.showDraftsBanner = true;
+      await element.updateComplete;
+      assert.isOk(query(element, '.banner'));
+    });
+
+    test('delete tap opens dialog', async () => {
+      const handleOpenDeleteDialogStub = sinon.stub(
+        element,
+        'handleOpenDeleteDialog'
+      );
+      element.showDraftsBanner = true;
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '.banner .delete').click();
+      assert.isTrue(handleOpenDeleteDialogStub.called);
+    });
+
+    test('delete comments flow', async () => {
+      sinon.spy(element, 'handleConfirmDelete');
+      const reloadStub = sinon.stub(element, 'reload');
+
+      // Set up control over timing of when RPC resolves.
+      let deleteDraftCommentsPromiseResolver: (
+        value: Response | PromiseLike<Response>
+      ) => void;
+      const deleteDraftCommentsPromise: Promise<Response> = new Promise(
+        resolve => {
+          deleteDraftCommentsPromiseResolver = resolve;
+          return Promise.resolve(new Response());
+        }
+      );
+
+      const deleteStub = stubRestApi('deleteDraftComments').returns(
+        deleteDraftCommentsPromise
+      );
+
+      // Open confirmation dialog and tap confirm button.
+      const modal = queryAndAssert<HTMLDialogElement>(
+        element,
+        '#confirmDeleteModal'
+      );
+      modal.showModal();
+      const dialog = queryAndAssert<GrDialog>(modal, '#confirmDeleteDialog');
+      await waitUntil(() => !!dialog.confirmButton);
+      dialog.confirmButton!.click();
+      await element.updateComplete;
+      assert.isTrue(deleteStub.calledWithExactly('-is:open'));
+      assert.isTrue(
+        queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').disabled
+      );
+      assert.equal(reloadStub.callCount, 0);
+
+      // Verify state after RPC resolves.
+      // We have to put this in setTimeout otherwise typescript fails with
+      // variable is used before assigned.
+      setTimeout(() => deleteDraftCommentsPromiseResolver(new Response()), 0);
+      await deleteDraftCommentsPromise;
+      assert.equal(reloadStub.callCount, 1);
+    });
+  });
+
+  test('computeTitle', () => {
+    assert.equal(element.computeTitle('self'), 'My Reviews');
+    assert.equal(element.computeTitle('not self'), 'Dashboard for not self');
+  });
+
+  suite('computeSectionCountLabel', () => {
+    test('empty changes dont count label', () => {
+      assert.equal('', element.computeSectionCountLabel([]));
+    });
+
+    test('1 change', () => {
+      assert.equal('(1)', element.computeSectionCountLabel([createChange()]));
+    });
+
+    test('2 changes', () => {
+      assert.equal(
+        '(2)',
+        element.computeSectionCountLabel([createChange(), createChange()])
+      );
+    });
+
+    test('1 change and more', () => {
+      assert.equal(
+        '(1 and more)',
+        element.computeSectionCountLabel([
+          {...createChange(), _more_changes: true},
+        ])
+      );
+    });
+  });
+
+  suite('selfOnly sections', () => {
+    test('viewing self dashboard includes selfOnly sections', async () => {
+      element.account = undefined;
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'self',
+        dashboard: '' as DashboardId,
+        sections: [
+          {name: '', query: '1'},
+          {name: '', query: '2', selfOnly: true},
+        ],
+      };
+      await element.reload();
+      assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2']));
+    });
+
+    test('viewing dashboard when logged in includes owner:self query', async () => {
+      element.account = createAccountDetailWithId(1);
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'self',
+        dashboard: '' as DashboardId,
+        sections: [
+          {name: '', query: '1'},
+          {name: '', query: '2', selfOnly: true},
+        ],
+      };
+      await element.reload();
+      assert.isTrue(
+        getChangesStub.calledWith(undefined, ['1', '2', 'owner:self limit:1'])
+      );
+    });
+
+    test("viewing another user's dashboard omits selfOnly sections", async () => {
+      element.viewState = {
+        view: GerritView.DASHBOARD,
+        user: 'user',
+        dashboard: '' as DashboardId,
+        sections: [
+          {name: '', query: '1'},
+          {name: '', query: '2', selfOnly: true},
+        ],
+      };
+      await element.reload();
+      assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
+    });
+  });
+
+  test('suffixForDashboard is included in getChanges query', async () => {
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      dashboard: '' as DashboardId,
+      sections: [
+        {name: '', query: '1'},
+        {name: '', query: '2', suffixForDashboard: 'suffix'},
+      ],
+    };
+    await element.reload();
+    assert.isTrue(getChangesStub.calledWith(undefined, ['1', '2 suffix']));
+  });
+
+  suite('getProjectDashboard', () => {
+    test('dashboard with foreach', async () => {
+      stubRestApi('getDashboard').callsFake(() =>
+        Promise.resolve({
+          id: '' as DashboardId,
+          project: 'project' as RepoName,
+          defining_project: '' as RepoName,
+          ref: '',
+          path: '',
+          url: '',
+          title: 'title',
+          foreach: 'foreach for ${project}',
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: '${project} query 2'},
+          ],
+        })
+      );
+      const dashboard = await element.getRepositoryDashboard(
+        'project' as RepoName,
+        '' as DashboardId
+      );
+      assert.deepEqual(dashboard, {
+        title: 'title',
+        sections: [
+          {name: 'section 1', query: 'query 1 foreach for project'},
+          {
+            name: 'section 2',
+            query: 'project query 2 foreach for project',
+          },
+        ],
+      });
+    });
+
+    test('dashboard without foreach', async () => {
+      stubRestApi('getDashboard').callsFake(() =>
+        Promise.resolve({
+          id: '' as DashboardId,
+          project: 'project' as RepoName,
+          defining_project: '' as RepoName,
+          ref: '',
+          path: '',
+          url: '',
+          title: 'title',
+          sections: [
+            {name: 'section 1', query: 'query 1'},
+            {name: 'section 2', query: '${project} query 2'},
+          ],
+        })
+      );
+      const dashboard = await element.getRepositoryDashboard(
+        'project' as RepoName,
+        '' as DashboardId
+      );
+      assert.deepEqual(dashboard, {
+        title: 'title',
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: 'project query 2'},
+        ],
+      });
+    });
+  });
+
+  test('hideIfEmpty sections', async () => {
+    const sections = [
+      {name: 'test1', query: 'test1', hideIfEmpty: true},
+      {name: 'test2', query: 'test2', hideIfEmpty: true},
+    ];
+    getChangesStub.returns(Promise.resolve([[createChange()]]));
+
+    await element.fetchDashboardChanges({sections}, false);
+    assert.equal(element.results!.length, 1);
+    assert.equal(element.results![0].name, 'test1');
+  });
+
+  test('sets slot name to section name if custom state is requested', async () => {
+    const sections = [
+      {name: 'Outgoing reviews', query: 'test1'},
+      {name: 'test2', query: 'test2'},
+    ];
+    getChangesStub.returns(Promise.resolve([[], []]));
+
+    await element.fetchDashboardChanges({sections}, false);
+    assert.equal(element.results!.length, 2);
+    assert.equal(element.results![0].emptyStateSlotName, 'outgoing-slot');
+    assert.isNotOk(element.results![1].emptyStateSlotName);
+  });
+
+  test('toggling star will update change everywhere', async () => {
+    // It is important that the same change is represented by multiple objects
+    // and all are updated.
+    const change = {...createChange(), id: '5' as ChangeInfoId, starred: false};
+    const sameChange = {
+      ...createChange(),
+      id: '5' as ChangeInfoId,
+      starred: false,
+    };
+    const differentChange = {
+      ...createChange(),
+      id: '4' as ChangeInfoId,
+      starred: false,
+    };
+    element.results = [
+      {name: '', countLabel: '', query: 'has:draft', results: [change]},
+      {
+        name: '',
+        countLabel: '',
+        query: 'is:open',
+        results: [sameChange, differentChange],
+      },
+    ];
+
+    await element.handleToggleStar(
+      new CustomEvent('toggle-star', {
+        detail: {
+          change,
+          starred: true,
+        },
+      })
+    );
+
+    assert.isTrue(change.starred);
+    assert.isTrue(sameChange.starred);
+    assert.isFalse(differentChange.starred);
+  });
+
+  test('showNewUserHelp', async () => {
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+    };
+    element.loading = false;
+    element.showNewUserHelp = false;
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#emptyOutgoing'
+      ).textContent!.trim(),
+      'No changes'
+    );
+    query<GrCreateChangeHelp>(element, 'gr-create-change-help');
+    assert.isNotOk(query<GrCreateChangeHelp>(element, 'gr-create-change-help'));
+    element.showNewUserHelp = true;
+    await element.updateComplete;
+
+    assert.notEqual(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#emptyOutgoing'
+      ).textContent!.trim(),
+      'No changes'
+    );
+    assert.isOk(query<GrCreateChangeHelp>(element, 'gr-create-change-help'));
+  });
+
+  test('gr-user-header', async () => {
+    element.viewState = undefined;
+    await element.updateComplete;
+    assert.isNotOk(query(element, 'gr-user-header'));
+
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      dashboard: '' as DashboardId,
+      user: 'self',
+    };
+    await element.updateComplete;
+    assert.isNotOk(query(element, 'gr-user-header'));
+
+    element.loading = false;
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      dashboard: '' as DashboardId,
+      user: 'user',
+    };
+    await element.updateComplete;
+    assert.isOk(query(element, 'gr-user-header'));
+
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      dashboard: '' as DashboardId,
+      project: 'p' as RepoName,
+      user: 'user',
+    };
+    await element.updateComplete;
+    assert.isNotOk(query(element, 'gr-user-header'));
+  });
+
+  test('404 page', async () => {
+    const response = {...new Response(), status: 404};
+    stubRestApi('getDashboard').callsFake(
+      async (_project, _dashboard, errFn) => {
+        if (errFn !== undefined) {
+          errFn(response);
+        }
+        return Promise.resolve(undefined);
+      }
+    );
+    const promise = mockPromise();
+    addListenerForTest(document, 'page-error', e => {
+      assert.strictEqual((e as PageErrorEvent).detail.response, response);
+      promise.resolve();
+    });
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      dashboard: 'dashboard' as DashboardId,
+      project: 'project' as RepoName,
+      user: '',
+    };
+    await Promise.all([element.reload(), promise]);
+  });
+
+  test('viewState change triggers dashboardDisplayed()', async () => {
+    stubRestApi('getDashboard').returns(
+      Promise.resolve({
+        id: '' as DashboardId,
+        project: 'project' as RepoName,
+        defining_project: '' as RepoName,
+        ref: '',
+        path: '',
+        url: '',
+        title: 'title',
+        foreach: 'foreach for ${project}',
+        sections: [],
+      })
+    );
+    getChangesStub.returns(Promise.resolve([]));
+    const dashboardDisplayedStub = stubReporting('dashboardDisplayed');
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      dashboard: 'dashboard' as DashboardId,
+      project: 'project' as RepoName,
+      user: '',
+    };
+    await element.reload();
+    assert.isTrue(dashboardDisplayedStub.calledOnce);
+  });
+});
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 d8949ed..e27274b 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
@@ -1,29 +1,17 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {RepoName} from '../../../types/common';
 import {WebLinkInfo} from '../../../types/diff';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {dashboardHeaderStyles} from '../../../styles/dashboard-header-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
+import {createRepoUrl} from '../../../models/views/repo';
 
 @customElement('gr-repo-header')
 export class GrRepoHeader extends LitElement {
@@ -36,7 +24,7 @@
   @property({type: Array})
   _webLinks: WebLinkInfo[] = [];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -62,7 +50,7 @@
     return html`<div>
       <span class="browse">Browse:</span>
       ${webLinks.map(
-        link => html`<a target="_blank" href="${link.url}">${link.name}</a> `
+        link => html`<a target="_blank" href=${link.url}>${link.name}</a> `
       )}
     </div> `;
   }
@@ -72,7 +60,7 @@
       <h1 class="heading-1">${this.repo}</h1>
       <hr />
       <div>
-        <span>Detail:</span> <a href="${this._repoUrl!}">Repo settings</a>
+        <span>Detail:</span> <a href=${this._repoUrl!}>Repo settings</a>
       </div>
       ${this._renderLinks(this._webLinks)}
     </div>`;
@@ -85,15 +73,15 @@
   }
 
   _repoChanged() {
-    const repoName = this.repo;
-    if (!repoName) {
+    const repo = this.repo;
+    if (!repo) {
       this._repoUrl = null;
       return;
     }
 
-    this._repoUrl = GerritNav.getUrlForRepo(repoName);
+    this._repoUrl = createRepoUrl({repo});
 
-    this.restApiService.getRepo(repoName).then(repo => {
+    this.restApiService.getRepo(repo).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_test.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
index 56ad8bc..9054474 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
@@ -1,44 +1,52 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
 import './gr-repo-header';
 import {GrRepoHeader} from './gr-repo-header';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {stubRestApi} from '../../../test/test-utils';
 import {RepoName, UrlEncodedRepoName} from '../../../types/common';
 
-const basicFixture = fixtureFromElement('gr-repo-header');
-
 suite('gr-repo-header tests', () => {
   let element: GrRepoHeader;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-repo-header .repo=${'test' as RepoName}></gr-repo-header>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="info">
+          <h1 class="heading-1">test</h1>
+          <hr />
+          <div>
+            <span> Detail: </span>
+            <a href="/admin/repos/test"> Repo settings </a>
+          </div>
+          <div>
+            <span class="browse"> Browse: </span>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('repoUrl reset once repo changed', async () => {
-    sinon
-      .stub(GerritNav, 'getUrlForRepo')
-      .callsFake(repoName => `http://test.com/${repoName},general`);
+    element.repo = undefined;
+    await element.updateComplete;
     assert.equal(element._repoUrl, undefined);
+
     element.repo = 'test' as RepoName;
-    await flush();
-    assert.equal(element._repoUrl, 'http://test.com/test,general');
+    await element.updateComplete;
+
+    assert.equal(element._repoUrl, '/admin/repos/test');
   });
 
   test('webLinks set', async () => {
@@ -51,13 +59,15 @@
         },
       ],
     };
-
     stubRestApi('getRepo').returns(Promise.resolve(repoRes));
+    element.repo = undefined;
+    await element.updateComplete;
 
     assert.deepEqual(element._webLinks, []);
 
     element.repo = 'test' as RepoName;
-    await flush();
+    await element.updateComplete;
+
     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 50de7b9..57f9ee6 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
@@ -1,33 +1,21 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {AccountDetailInfo, AccountId} from '../../../types/common';
 import {getDisplayName} from '../../../utils/display-name-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {dashboardHeaderStyles} from '../../../styles/dashboard-header-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
+import {createDashboardUrl} from '../../../models/views/dashboard';
 
 @customElement('gr-user-header')
 export class GrUserHeader extends LitElement {
@@ -46,7 +34,7 @@
   @property({type: String})
   _status = '';
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -65,7 +53,7 @@
 
   override render() {
     return html`<gr-avatar
-        .account="${this._accountDetails}"
+        .account=${this._accountDetails}
         .imageSize=${100}
         aria-label="Account avatar"
       ></gr-avatar>
@@ -85,31 +73,31 @@
         <div>
           <span>Joined:</span>
           <gr-date-formatter
-            dateStr="${this._computeDetail(
+            dateStr=${this._computeDetail(
               this._accountDetails,
               'registered_on'
-            )}"
+            )}
           >
           </gr-date-formatter>
         </div>
         <gr-endpoint-decorator name="user-header">
           <gr-endpoint-param
             name="accountDetails"
-            .value="${this._accountDetails}"
+            .value=${this._accountDetails}
           >
           </gr-endpoint-param>
-          <gr-endpoint-param name="loggedIn" .value="${this.loggedIn}">
+          <gr-endpoint-param name="loggedIn" .value=${this.loggedIn}>
           </gr-endpoint-param>
         </gr-endpoint-decorator>
       </div>
       <div class="info">
         <div
-          class="${this._computeDashboardLinkClass(
+          class=${this._computeDashboardLinkClass(
             this.showDashboardLink,
             this.loggedIn
-          )}"
+          )}
         >
-          <a href="${this._computeDashboardUrl(this._accountDetails)}"
+          <a href=${this._computeDashboardUrl(this._accountDetails)}
             >View dashboard</a
           >
         </div>
@@ -152,18 +140,15 @@
   }
 
   _computeDashboardUrl(accountDetails: AccountDetailInfo | undefined) {
-    if (!accountDetails) {
-      return undefined;
-    }
+    if (!accountDetails) return '';
+
     const id = accountDetails._account_id;
-    if (id) {
-      return GerritNav.getUrlForUserDashboard(String(id));
-    }
+    if (id) return createDashboardUrl({user: String(id)});
+
     const email = accountDetails.email;
-    if (email) {
-      return GerritNav.getUrlForUserDashboard(email);
-    }
-    return undefined;
+    if (email) return createDashboardUrl({user: email});
+
+    return '';
   }
 
   _computeDashboardLinkClass(showDashboardLink: boolean, loggedIn: boolean) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts
index 0e35000..7d204d9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts
@@ -1,33 +1,53 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
 import './gr-user-header';
 import {GrUserHeader} from './gr-user-header';
-import {stubRestApi} from '../../../test/test-utils';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
 import {AccountId, EmailAddress, Timestamp} from '../../../types/common';
 
-const basicFixture = fixtureFromElement('gr-user-header');
-
 suite('gr-user-header tests', () => {
   let element: GrUserHeader;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-user-header></gr-user-header>`);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-avatar aria-label="Account avatar" hidden=""> </gr-avatar>
+        <div class="info">
+          <h1 class="heading-1"></h1>
+          <hr />
+          <div class="hide status">
+            <span> Status: </span>
+          </div>
+          <div>
+            <span> Email: </span>
+            <a href="mailto:"> </a>
+          </div>
+          <div>
+            <span> Joined: </span>
+            <gr-date-formatter datestr=""> </gr-date-formatter>
+          </div>
+          <gr-endpoint-decorator name="user-header">
+            <gr-endpoint-param name="accountDetails"> </gr-endpoint-param>
+            <gr-endpoint-param name="loggedIn"> </gr-endpoint-param>
+          </gr-endpoint-decorator>
+        </div>
+        <div class="info">
+          <div class="dashboardLink hide">
+            <a href=""> View dashboard </a>
+          </div>
+        </div>
+      `
+    );
   });
 
   test('loads and clears account info', async () => {
@@ -41,13 +61,13 @@
     );
 
     element.userId = 10 as AccountId;
-    await flush();
+    await waitEventLoop();
 
     assert.isOk(element._accountDetails);
     assert.isOk(element._status);
 
     element.userId = undefined;
-    await flush();
+    await waitEventLoop();
 
     assert.isUndefined(element._accountDetails);
     assert.equal(element._status, '');
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 536ef92..e47b450 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
@@ -1,25 +1,13 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../admin/gr-create-change-dialog/gr-create-change-dialog';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
-import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-icon/gr-icon';
 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';
@@ -28,12 +16,8 @@
 import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {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 {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {getAppContext} from '../../../services/app-context';
 import {CURRENT} from '../../../utils/patch-set-util';
 import {
   changeIsOpen,
@@ -47,13 +31,13 @@
   HttpMethod,
   NotifyType,
 } from '../../../constants/constants';
-import {EventType as PluginEventType, TargetElement} from '../../../api/plugin';
-import {customElement, observe, property} from '@polymer/decorators';
+import {TargetElement} from '../../../api/plugin';
 import {
   AccountInfo,
   ActionInfo,
   ActionNameToActionInfoMap,
   BranchName,
+  ChangeActionDialog,
   ChangeInfo,
   ChangeViewChangeInfo,
   CherryPickInput,
@@ -64,14 +48,11 @@
   LabelInfo,
   NumericChangeId,
   PatchSetNum,
-  PropertyType,
   RequestPayload,
   RevertSubmissionInfo,
   ReviewInput,
-  ServerInfo,
 } from '../../../types/common';
 import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
 import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
@@ -87,19 +68,23 @@
   ConfirmRebaseEventDetail,
   GrConfirmRebaseDialog,
 } from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {
   GrChangeActionsElement,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
+import {
+  fire,
+  fireAlert,
+  fireEvent,
+  fireReload,
+} from '../../../utils/event-util';
 import {
   getApprovalInfo,
   getVotingRange,
   StandardLabels,
 } from '../../../utils/label-util';
-import {ShowAlertEventDetail} from '../../../types/events';
+import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {
   ActionPriority,
   ActionType,
@@ -109,12 +94,28 @@
 } from '../../../api/change-actions';
 import {ErrorCallback} from '../../../api/rest';
 import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {assertIsDefined, queryAll} from '../../../utils/common-util';
+import {Interaction} from '../../../constants/reporting';
+import {rootUrl} from '../../../utils/url-util';
+import {createSearchUrl} from '../../../models/views/search';
+import {createChangeUrl} from '../../../models/views/change';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {ShowRevisionActionsDetail} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {whenVisible} from '../../../utils/dom-util';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
 const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
 
-enum LabelStatus {
+export enum LabelStatus {
   /**
    * This label provides what is necessary for submission.
    */
@@ -240,21 +241,23 @@
   __type: ActionType.CHANGE,
 };
 
-// Set of keys that have icons. As more icons are added to gr-icons.html, this
-// set should be expanded.
-const ACTIONS_WITH_ICONS = new Set([
-  ChangeActions.ABANDON,
-  ChangeActions.DELETE_EDIT,
-  ChangeActions.EDIT,
-  ChangeActions.PUBLISH_EDIT,
-  ChangeActions.READY,
-  ChangeActions.REBASE_EDIT,
-  ChangeActions.RESTORE,
-  ChangeActions.REVERT,
-  ChangeActions.STOP_EDIT,
-  QUICK_APPROVE_ACTION.key,
-  RevisionActions.REBASE,
-  RevisionActions.SUBMIT,
+// Set of keys that have icons.
+const ACTIONS_WITH_ICONS = new Map<
+  string,
+  Pick<UIActionInfo, 'filled' | 'icon'>
+>([
+  [ChangeActions.ABANDON, {icon: 'block'}],
+  [ChangeActions.DELETE_EDIT, {icon: 'delete', filled: true}],
+  [ChangeActions.EDIT, {icon: 'edit', filled: true}],
+  [ChangeActions.PUBLISH_EDIT, {icon: 'publish', filled: true}],
+  [ChangeActions.READY, {icon: 'visibility', filled: true}],
+  [ChangeActions.REBASE_EDIT, {icon: 'rebase_edit'}],
+  [RevisionActions.REBASE, {icon: 'rebase'}],
+  [ChangeActions.RESTORE, {icon: 'history'}],
+  [ChangeActions.REVERT, {icon: 'undo'}],
+  [ChangeActions.STOP_EDIT, {icon: 'stop', filled: true}],
+  [QUICK_APPROVE_ACTION.key, {icon: 'check'}],
+  [RevisionActions.SUBMIT, {icon: 'done_all'}],
 ]);
 
 const EDIT_ACTIONS: Set<string> = new Set([
@@ -314,40 +317,11 @@
   priority: ActionPriority;
 }
 
-interface ChangeActionDialog extends HTMLElement {
-  resetFocus?(): void;
-  init?(): void;
-}
-
-export interface GrChangeActions {
-  $: {
-    mainContent: Element;
-    overlay: GrOverlay;
-    confirmRebase: GrConfirmRebaseDialog;
-    confirmCherrypick: GrConfirmCherrypickDialog;
-    confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
-    confirmMove: GrConfirmMoveDialog;
-    confirmRevertDialog: GrConfirmRevertDialog;
-    confirmAbandonDialog: GrConfirmAbandonDialog;
-    confirmSubmitDialog: GrConfirmSubmitDialog;
-    createFollowUpDialog: GrDialog;
-    createFollowUpChange: GrCreateChangeDialog;
-    confirmDeleteDialog: GrDialog;
-    confirmDeleteEditDialog: GrDialog;
-    moreActions: GrDropdown;
-    secondaryActions: HTMLElement;
-  };
-}
-
 @customElement('gr-change-actions')
 export class GrChangeActions
-  extends PolymerElement
+  extends LitElement
   implements GrChangeActionsElement
 {
-  static get template() {
-    return htmlTemplate;
-  }
-
   /**
    * Fired when the change should be reloaded.
    *
@@ -372,6 +346,37 @@
    * @event show-error
    */
 
+  @query('#mainContent') mainContent?: Element;
+
+  @query('#actionsModal') actionsModal?: HTMLDialogElement;
+
+  @query('#confirmRebase') confirmRebase?: GrConfirmRebaseDialog;
+
+  @query('#confirmCherrypick') confirmCherrypick?: GrConfirmCherrypickDialog;
+
+  @query('#confirmCherrypickConflict')
+  confirmCherrypickConflict?: GrConfirmCherrypickConflictDialog;
+
+  @query('#confirmMove') confirmMove?: GrConfirmMoveDialog;
+
+  @query('#confirmRevertDialog') confirmRevertDialog?: GrConfirmRevertDialog;
+
+  @query('#confirmAbandonDialog') confirmAbandonDialog?: GrConfirmAbandonDialog;
+
+  @query('#confirmSubmitDialog') confirmSubmitDialog?: GrConfirmSubmitDialog;
+
+  @query('#createFollowUpDialog') createFollowUpDialog?: GrDialog;
+
+  @query('#createFollowUpChange') createFollowUpChange?: GrCreateChangeDialog;
+
+  @query('#confirmDeleteDialog') confirmDeleteDialog?: GrDialog;
+
+  @query('#confirmDeleteEditDialog') confirmDeleteEditDialog?: GrDialog;
+
+  @query('#moreActions') moreActions?: GrDropdown;
+
+  @query('#secondaryActions') secondaryActions?: HTMLElement;
+
   // TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
   // properties are replaced with enums everywhere and remove them from
   // the GrChangeActions class
@@ -381,16 +386,10 @@
 
   RevisionActions = RevisionActions;
 
-  private readonly reporting = appContext.reportingService;
-
-  private readonly jsAPI = appContext.jsApiService;
-
-  private readonly changeService = appContext.changeService;
-
   @property({type: Object})
   change?: ChangeViewChangeInfo;
 
-  @property({type: Object})
+  @state()
   actions: ActionNameToActionInfoMap = {};
 
   @property({type: Array})
@@ -405,8 +404,8 @@
   @property({type: Boolean})
   _hasKnownChainState = false;
 
-  @property({type: Boolean})
-  _hideQuickApproveAction = false;
+  // private but used in test
+  @state() _hideQuickApproveAction = false;
 
   @property({type: Object})
   account?: AccountInfo;
@@ -420,7 +419,7 @@
   @property({type: String})
   commitNum?: CommitId;
 
-  @property({type: Boolean, observer: '_computeChainState'})
+  @property({type: Boolean})
   hasParent?: boolean;
 
   @property({type: String})
@@ -429,58 +428,41 @@
   @property({type: String})
   commitMessage = '';
 
-  @property({type: Object, notify: true})
+  @property({type: Object})
   revisionActions: ActionNameToActionInfoMap = {};
 
-  @property({type: Object, computed: '_getSubmitAction(revisionActions)'})
-  _revisionSubmitAction?: ActionInfo | null;
+  @state() private revisionSubmitAction?: ActionInfo | null;
 
-  @property({type: Object, computed: '_getRebaseAction(revisionActions)'})
-  _revisionRebaseAction?: ActionInfo | null;
+  // used as a proprty type so cannot be private
+  @state() revisionRebaseAction?: ActionInfo | null;
 
   @property({type: String})
   privateByDefault?: InheritedBooleanInfo;
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: String})
-  _actionLoadingMessage = '';
+  // private but used in test
+  @state() actionLoadingMessage = '';
 
-  @property({
-    type: Array,
-    computed:
-      '_computeAllActions(actions.*, revisionActions.*,' +
-      'primaryActionKeys.*, _additionalActions.*, change, ' +
-      '_actionPriorityOverrides.*)',
-  })
-  _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
+  @state() private inProgressActionKeys = new Set<string>();
 
-  @property({
-    type: Array,
-    computed:
-      '_computeTopLevelActions(_allActionValues.*, ' +
-      '_hiddenActions.*, editMode, _overflowActions.*)',
-    observer: '_filterPrimaryActions',
-  })
-  _topLevelActions?: UIActionInfo[];
+  // _computeAllActions always returns an array
+  // private but used in test
+  @state() allActionValues: UIActionInfo[] = [];
 
-  @property({type: Array})
-  _topLevelPrimaryActions?: UIActionInfo[];
+  // private but used in test
+  @state() topLevelActions?: UIActionInfo[];
 
-  @property({type: Array})
-  _topLevelSecondaryActions?: UIActionInfo[];
+  // private but used in test
+  @state() topLevelPrimaryActions?: UIActionInfo[];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeMenuActions(_allActionValues.*, ' +
-      '_hiddenActions.*, _overflowActions.*)',
-  })
-  _menuActions?: MenuAction[];
+  // private but used in test
+  @state() topLevelSecondaryActions?: UIActionInfo[];
 
-  @property({type: Array})
-  _overflowActions: OverflowAction[] = [
+  @state() private menuActions?: MenuAction[];
+
+  @state() private overflowActions: OverflowAction[] = [
     {
       type: ActionType.CHANGE,
       key: ChangeActions.WIP,
@@ -503,14 +485,6 @@
     },
     {
       type: ActionType.CHANGE,
-      key: ChangeActions.IGNORE,
-    },
-    {
-      type: ActionType.CHANGE,
-      key: ChangeActions.UNIGNORE,
-    },
-    {
-      type: ActionType.CHANGE,
       key: ChangeActions.REVIEWED,
     },
     {
@@ -535,17 +509,15 @@
     },
   ];
 
-  @property({type: Array})
-  _actionPriorityOverrides: ActionPriorityOverride[] = [];
+  @state() private actionPriorityOverrides: ActionPriorityOverride[] = [];
 
-  @property({type: Array})
-  _additionalActions: UIActionInfo[] = [];
+  @state() private additionalActions: UIActionInfo[] = [];
 
-  @property({type: Array})
-  _hiddenActions: string[] = [];
+  // private but used in test
+  @state() hiddenActions: string[] = [];
 
-  @property({type: Array})
-  _disabledMenuActions: string[] = [];
+  // private but used in test
+  @state() disabledMenuActions: string[] = [];
 
   @property({type: Boolean})
   editPatchsetLoaded = false;
@@ -556,39 +528,318 @@
   @property({type: Boolean})
   editBasedOnCurrentPatchSet = true;
 
-  @property({type: Object})
-  _config?: ServerInfo;
+  @property({type: Boolean})
+  loggedIn = false;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getStorage = resolve(this, storageServiceToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
 
   constructor() {
     super();
-    this.addEventListener('fullscreen-overlay-opened', () =>
-      this._handleHideBackgroundContent()
-    );
-    this.addEventListener('fullscreen-overlay-closed', () =>
-      this._handleShowBackgroundContent()
-    );
   }
 
-  override ready() {
-    super.ready();
-    this.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
+  override connectedCallback() {
+    super.connectedCallback();
+    this.getPluginLoader().jsApiService.addElement(
+      TargetElement.CHANGE_ACTIONS,
+      this
+    );
+    this.handleLoadingComplete();
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      modalStyles,
+      css`
+        :host {
+          display: flex;
+          font-family: var(--font-family);
+        }
+        #actionLoadingMessage,
+        #mainContent,
+        section {
+          display: flex;
+        }
+        #actionLoadingMessage,
+        gr-button,
+        gr-dropdown {
+          /* px because don't have the same font size */
+          margin-left: 8px;
+        }
+        gr-button {
+          display: block;
+        }
+        #actionLoadingMessage {
+          align-items: center;
+          color: var(--deemphasized-text-color);
+        }
+        #confirmSubmitDialog .changeSubject {
+          margin: var(--spacing-l);
+          text-align: center;
+        }
+        gr-icon {
+          color: inherit;
+          margin-right: var(--spacing-xs);
+        }
+        #moreActions gr-icon {
+          margin: 0;
+        }
+        #moreMessage,
+        .hidden {
+          display: none;
+        }
+        @media screen and (max-width: 50em) {
+          #mainContent {
+            flex-wrap: wrap;
+          }
+          gr-button {
+            --gr-button-padding: var(--spacing-m);
+            white-space: nowrap;
+          }
+          gr-button,
+          gr-dropdown {
+            margin: 0;
+          }
+          #actionLoadingMessage {
+            margin: var(--spacing-m);
+            text-align: center;
+          }
+          #moreMessage {
+            display: inline;
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.change) return nothing;
+    return html`
+      <div id="mainContent">
+        <span id="actionLoadingMessage" ?hidden=${!this.actionLoadingMessage}>
+          ${this.actionLoadingMessage}
+        </span>
+        <section
+          id="primaryActions"
+          ?hidden=${this.loading ||
+          !this.topLevelActions ||
+          !this.topLevelActions.length}
+        >
+          ${this.topLevelPrimaryActions?.map(action =>
+            this.renderUIAction(action)
+          )}
+        </section>
+        <section
+          id="secondaryActions"
+          ?hidden=${this.loading ||
+          !this.topLevelActions ||
+          !this.topLevelActions.length}
+        >
+          ${this.topLevelSecondaryActions?.map(action =>
+            this.renderUIAction(action)
+          )}
+        </section>
+        <gr-button ?hidden=${!this.loading}>Loading actions...</gr-button>
+        <gr-dropdown
+          id="moreActions"
+          link
+          .verticalOffset=${32}
+          .horizontalAlign=${'right'}
+          @tap-item=${this.handleOverflowItemTap}
+          ?hidden=${this.loading ||
+          !this.menuActions ||
+          !this.menuActions.length}
+          .disabledIds=${this.disabledMenuActions}
+          .items=${this.menuActions}
+        >
+          <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
+          <span id="moreMessage">More</span>
+        </gr-dropdown>
+      </div>
+      <dialog id="actionsModal" tabindex="-1">
+        <gr-confirm-rebase-dialog
+          id="confirmRebase"
+          class="confirmDialog"
+          .changeNumber=${this.change?._number}
+          @confirm=${this.handleRebaseConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .disableActions=${this.inProgressActionKeys.has(
+            RevisionActions.REBASE
+          )}
+          .branch=${this.change?.branch}
+          .hasParent=${this.hasParent}
+          .rebaseOnCurrent=${this.revisionRebaseAction
+            ? !!this.revisionRebaseAction.enabled
+            : null}
+        ></gr-confirm-rebase-dialog>
+        <gr-confirm-cherrypick-dialog
+          id="confirmCherrypick"
+          class="confirmDialog"
+          .changeStatus=${this.changeStatus}
+          .commitMessage=${this.commitMessage}
+          .commitNum=${this.commitNum}
+          @confirm=${this.handleCherrypickConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .project=${this.change?.project}
+        ></gr-confirm-cherrypick-dialog>
+        <gr-confirm-cherrypick-conflict-dialog
+          id="confirmCherrypickConflict"
+          class="confirmDialog"
+          @confirm=${this.handleCherrypickConflictConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-cherrypick-conflict-dialog>
+        <gr-confirm-move-dialog
+          id="confirmMove"
+          class="confirmDialog"
+          @confirm=${this.handleMoveConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .project=${this.change?.project}
+        ></gr-confirm-move-dialog>
+        <gr-confirm-revert-dialog
+          id="confirmRevertDialog"
+          class="confirmDialog"
+          @confirm=${this.handleRevertDialogConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-revert-dialog>
+        <gr-confirm-abandon-dialog
+          id="confirmAbandonDialog"
+          class="confirmDialog"
+          @confirm=${this.handleAbandonDialogConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+        ></gr-confirm-abandon-dialog>
+        <gr-confirm-submit-dialog
+          id="confirmSubmitDialog"
+          class="confirmDialog"
+          .action=${this.revisionSubmitAction}
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handleSubmitConfirm}
+        ></gr-confirm-submit-dialog>
+        <gr-dialog
+          id="createFollowUpDialog"
+          class="confirmDialog"
+          confirm-label="Create"
+          @confirm=${this.handleCreateFollowUpChange}
+          @cancel=${this.handleCloseCreateFollowUpChange}
+        >
+          <div class="header" slot="header">Create Follow-Up Change</div>
+          <div class="main" slot="main">
+            <gr-create-change-dialog
+              id="createFollowUpChange"
+              .branch=${this.change?.branch}
+              .baseChange=${this.change?.id}
+              .repoName=${this.change?.project}
+              .privateByDefault=${this.privateByDefault}
+            ></gr-create-change-dialog>
+          </div>
+        </gr-dialog>
+        <gr-dialog
+          id="confirmDeleteDialog"
+          class="confirmDialog"
+          confirm-label="Delete"
+          confirm-on-enter=""
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handleDeleteConfirm}
+        >
+          <div class="header" slot="header">Delete Change</div>
+          <div class="main" slot="main">
+            Do you really want to delete the change?
+          </div>
+        </gr-dialog>
+        <gr-dialog
+          id="confirmDeleteEditDialog"
+          class="confirmDialog"
+          confirm-label="Delete"
+          confirm-on-enter=""
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handleDeleteEditConfirm}
+        >
+          <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>
+      </dialog>
+    `;
+  }
+
+  private renderUIAction(action: UIActionInfo) {
+    return html`
+      <gr-tooltip-content
+        title=${ifDefined(action.title)}
+        .hasTooltip=${!!action.title}
+        ?position-below=${true}
+      >
+        <gr-button
+          link
+          class=${action.__key}
+          data-action-key=${action.__key}
+          data-label=${action.label}
+          ?disabled=${this.calculateDisabled(action)}
+          @click=${(e: MouseEvent) =>
+            this.handleActionTap(e, action.__key, action.__type)}
+        >
+          ${this.renderUIActionIcon(action)} ${action.label}
+        </gr-button>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderUIActionIcon(action: UIActionInfo) {
+    if (!action.icon) return nothing;
+    return html`
+      <gr-icon icon=${action.icon} ?filled=${action.filled}></gr-icon>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('hasParent')) {
+      this.computeChainState();
+    }
+
+    if (changedProperties.has('change')) {
+      this.reload();
+      this.actions = this.change?.actions ?? {};
+    }
+
+    this.editStatusChanged();
+
+    this.actionsChanged();
+    this.allActionValues = this.computeAllActions();
+    this.topLevelActions = this.allActionValues.filter(a => {
+      if (this.hiddenActions.includes(a.__key)) return false;
+      if (this.editMode) return EDIT_ACTIONS.has(a.__key);
+      return this.getActionOverflowIndex(a.__type, a.__key) === -1;
     });
-    this._handleLoadingComplete();
+    this.topLevelPrimaryActions = this.topLevelActions.filter(
+      action => action.__primary
+    );
+    this.topLevelSecondaryActions = this.topLevelActions.filter(
+      action => !action.__primary
+    );
+    this.menuActions = this.computeMenuActions();
+    this.revisionSubmitAction = this.getSubmitAction(this.revisionActions);
+    this.revisionRebaseAction = this.getRebaseAction(this.revisionActions);
   }
 
-  _getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
-    return this._getRevisionAction(revisionActions, 'submit');
+  private getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
+    return this.getRevisionAction(revisionActions, 'submit');
   }
 
-  _getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
-    return this._getRevisionAction(revisionActions, 'rebase');
+  private getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
+    return this.getRevisionAction(revisionActions, 'rebase');
   }
 
-  _getRevisionAction(
+  private getRevisionAction(
     revisionActions: ActionNameToActionInfoMap,
     actionName: string
   ) {
@@ -596,7 +847,7 @@
       return undefined;
     }
     if (revisionActions[actionName] === undefined) {
-      // Return null to fire an event when reveisionActions was loaded
+      // Return null to fire an event when revisionActions was loaded
       // but doesn't contain actionName. undefined doesn't fire an event
       return null;
     }
@@ -609,7 +860,7 @@
     }
     const change = this.change;
 
-    this._loading = true;
+    this.loading = true;
     return this.restApiService
       .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
       .then(revisionActions => {
@@ -618,35 +869,28 @@
         }
 
         this.revisionActions = revisionActions;
-        this._sendShowRevisionActions({
+        this.sendShowRevisionActions({
           change,
           revisionActions,
         });
-        this._handleLoadingComplete();
+        this.handleLoadingComplete();
       })
       .catch(err => {
         fireAlert(this, ERR_REVISION_ACTIONS);
-        this._loading = false;
+        this.loading = false;
         throw err;
       });
   }
 
-  _handleLoadingComplete() {
-    getPluginLoader()
+  private handleLoadingComplete() {
+    this.getPluginLoader()
       .awaitPluginsLoaded()
-      .then(() => (this._loading = false));
+      .then(() => (this.loading = false));
   }
 
-  _sendShowRevisionActions(detail: {
-    change: ChangeInfo;
-    revisionActions: ActionNameToActionInfoMap;
-  }) {
-    this.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
-  }
-
-  @observe('change')
-  _changeChanged() {
-    this.reload();
+  // private but used in test
+  sendShowRevisionActions(detail: ShowRevisionActionsDetail) {
+    this.getPluginLoader().jsApiService.handleShowRevisionActions(detail);
   }
 
   addActionButton(type: ActionType, label: string) {
@@ -660,16 +904,18 @@
       __key:
         ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
     };
-    this.push('_additionalActions', action);
+    this.additionalActions.push(action);
+    this.requestUpdate('additionalActions');
     return action.__key;
   }
 
   removeActionButton(key: string) {
-    const idx = this._indexOfActionButtonWithKey(key);
+    const idx = this.indexOfActionButtonWithKey(key);
     if (idx === -1) {
       return;
     }
-    this.splice('_additionalActions', idx, 1);
+    this.additionalActions.splice(idx, 1);
+    this.requestUpdate('additionalActions');
   }
 
   setActionButtonProp<T extends keyof UIActionInfo>(
@@ -677,26 +923,26 @@
     prop: T,
     value: UIActionInfo[T]
   ) {
-    this.set(
-      ['_additionalActions', this._indexOfActionButtonWithKey(key), prop],
-      value
-    );
+    this.additionalActions[this.indexOfActionButtonWithKey(key)][prop] = value;
+    this.requestUpdate('additionalActions');
   }
 
   setActionOverflow(type: ActionType, key: string, overflow: boolean) {
     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
       throw Error(`Invalid action type given: ${type}`);
     }
-    const index = this._getActionOverflowIndex(type, key);
+    const index = this.getActionOverflowIndex(type, key);
     const action: OverflowAction = {
       type,
       key,
       overflow,
     };
     if (!overflow && index !== -1) {
-      this.splice('_overflowActions', index, 1);
+      this.overflowActions.splice(index, 1);
+      this.requestUpdate('overflowActions');
     } else if (overflow) {
-      this.push('_overflowActions', action);
+      this.overflowActions.push(action);
+      this.requestUpdate('overflowActions');
     }
   }
 
@@ -708,7 +954,7 @@
     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
       throw Error(`Invalid action type given: ${type}`);
     }
-    const index = this._actionPriorityOverrides.findIndex(
+    const index = this.actionPriorityOverrides.findIndex(
       action => action.type === type && action.key === key
     );
     const action: ActionPriorityOverride = {
@@ -717,9 +963,11 @@
       priority,
     };
     if (index !== -1) {
-      this.set('_actionPriorityOverrides', index, action);
+      this.actionPriorityOverrides[index] = action;
+      this.requestUpdate('actionPriorityOverrides');
     } else {
-      this.push('_actionPriorityOverrides', action);
+      this.actionPriorityOverrides.push(action);
+      this.requestUpdate('actionPriorityOverrides');
     }
   }
 
@@ -732,11 +980,13 @@
       throw Error(`Invalid action type given: ${type}`);
     }
 
-    const idx = this._hiddenActions.indexOf(key);
+    const idx = this.hiddenActions.indexOf(key);
     if (hidden && idx === -1) {
-      this.push('_hiddenActions', key);
+      this.hiddenActions.push(key);
+      this.requestUpdate('hiddenActions');
     } else if (!hidden && idx !== -1) {
-      this.splice('_hiddenActions', idx, 1);
+      this.hiddenActions.splice(idx, 1);
+      this.requestUpdate('hiddenActions');
     }
   }
 
@@ -750,175 +1000,111 @@
     }
   }
 
-  _indexOfActionButtonWithKey(key: string) {
-    for (let i = 0; i < this._additionalActions.length; i++) {
-      if (this._additionalActions[i].__key === key) {
+  private indexOfActionButtonWithKey(key: string) {
+    for (let i = 0; i < this.additionalActions.length; i++) {
+      if (this.additionalActions[i].__key === key) {
         return i;
       }
     }
     return -1;
   }
 
-  _shouldHideActions(
-    actions?: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-    loading?: boolean
-  ) {
-    return loading || !actions || !actions.base || !actions.base.length;
-  }
-
-  _keyCount(
-    changeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >
-  ) {
-    return Object.keys(changeRecord?.base || {}).length;
-  }
-
-  @observe('actions.*', 'revisionActions.*', '_additionalActions.*')
-  _actionsChanged(
-    actionsChangeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    revisionActionsChangeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    additionalActionsChangeRecord?: PolymerDeepPropertyChange<
-      UIActionInfo[],
-      UIActionInfo[]
-    >
-  ) {
-    // Polymer 2: check for undefined
-    if (
-      actionsChangeRecord === undefined ||
-      revisionActionsChangeRecord === undefined ||
-      additionalActionsChangeRecord === undefined
-    ) {
-      return;
-    }
-
-    const additionalActions =
-      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
-      [];
+  private actionsChanged() {
     this.hidden =
-      this._keyCount(actionsChangeRecord) === 0 &&
-      this._keyCount(revisionActionsChangeRecord) === 0 &&
-      additionalActions.length === 0;
-    this._actionLoadingMessage = '';
-    this._actionLoadingMessage = '';
-    this._disabledMenuActions = [];
+      Object.keys(this.actions).length === 0 &&
+      Object.keys(this.revisionActions).length === 0 &&
+      this.additionalActions.length === 0;
+    this.actionLoadingMessage = '';
+    this.disabledMenuActions = [];
 
-    const revisionActions = revisionActionsChangeRecord.base || {};
-    if (Object.keys(revisionActions).length !== 0) {
-      if (!revisionActions.download) {
-        this.set('revisionActions.download', DOWNLOAD_ACTION);
+    if (Object.keys(this.revisionActions).length !== 0) {
+      if (!this.revisionActions.download) {
+        this.revisionActions = {
+          ...this.revisionActions,
+          download: DOWNLOAD_ACTION,
+        };
+        fire(this, 'revision-actions-changed', {
+          value: this.revisionActions,
+        });
       }
     }
-    const actions = actionsChangeRecord.base || {};
-    if (!actions.includedIn && this.change?.status === ChangeStatus.MERGED) {
-      this.set('actions.includedIn', INCLUDED_IN_ACTION);
+    if (
+      !this.actions.includedIn &&
+      this.change?.status === ChangeStatus.MERGED
+    ) {
+      this.actions = {...this.actions, includedIn: INCLUDED_IN_ACTION};
     }
   }
 
-  _deleteAndNotify(actionName: string) {
-    if (this.actions && this.actions[actionName]) {
-      delete this.actions[actionName];
-      // We assign a fake value of 'false' to support Polymer 2
-      // see https://github.com/Polymer/polymer/issues/2631
-      this.notifyPath('actions.' + actionName, false);
-    }
-  }
-
-  @observe(
-    'editMode',
-    'editPatchsetLoaded',
-    'editBasedOnCurrentPatchSet',
-    'disableEdit',
-    'actions.*',
-    'change.*'
-  )
-  _editStatusChanged(
-    editMode: boolean,
-    editPatchsetLoaded: boolean,
-    editBasedOnCurrentPatchSet: boolean,
-    disableEdit: boolean,
-    actionsChangeRecord?: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
-  ) {
-    if (actionsChangeRecord === undefined || changeChangeRecord === undefined) {
+  private editStatusChanged() {
+    // Hide change edits if not logged in
+    if (this.change === undefined || !this.loggedIn) {
       return;
     }
-    if (disableEdit) {
-      this._deleteAndNotify('publishEdit');
-      this._deleteAndNotify('rebaseEdit');
-      this._deleteAndNotify('deleteEdit');
-      this._deleteAndNotify('stopEdit');
-      this._deleteAndNotify('edit');
+    if (this.disableEdit) {
+      delete this.actions.rebaseEdit;
+      delete this.actions.publishEdit;
+      delete this.actions.deleteEdit;
+      delete this.actions.stopEdit;
+      delete this.actions.edit;
       return;
     }
-    const actions = actionsChangeRecord.base;
-    const change = changeChangeRecord.base;
-    if (actions && editPatchsetLoaded) {
+    if (this.editPatchsetLoaded) {
       // Only show actions that mutate an edit if an actual edit patch set
       // is loaded.
-      if (changeIsOpen(change)) {
-        if (editBasedOnCurrentPatchSet) {
-          if (!actions.publishEdit) {
-            this.set('actions.publishEdit', PUBLISH_EDIT);
+      if (changeIsOpen(this.change)) {
+        if (this.editBasedOnCurrentPatchSet) {
+          if (!this.actions.publishEdit) {
+            this.actions = {...this.actions, publishEdit: PUBLISH_EDIT};
           }
-          this._deleteAndNotify('rebaseEdit');
+          delete this.actions.rebaseEdit;
         } else {
-          if (!actions.rebaseEdit) {
-            this.set('actions.rebaseEdit', REBASE_EDIT);
+          if (!this.actions.rebaseEdit) {
+            this.actions = {...this.actions, rebaseEdit: REBASE_EDIT};
           }
-          this._deleteAndNotify('publishEdit');
+          delete this.actions.publishEdit;
         }
       }
-      if (!actions.deleteEdit) {
-        this.set('actions.deleteEdit', DELETE_EDIT);
+      if (!this.actions.deleteEdit) {
+        this.actions = {...this.actions, deleteEdit: DELETE_EDIT};
       }
     } else {
-      this._deleteAndNotify('publishEdit');
-      this._deleteAndNotify('rebaseEdit');
-      this._deleteAndNotify('deleteEdit');
+      delete this.actions.rebaseEdit;
+      delete this.actions.publishEdit;
+      delete this.actions.deleteEdit;
     }
 
-    if (actions && changeIsOpen(change)) {
+    if (changeIsOpen(this.change)) {
       // Only show edit button if there is no edit patchset loaded and the
       // file list is not in edit mode.
-      if (editPatchsetLoaded || editMode) {
-        this._deleteAndNotify('edit');
+      if (this.editPatchsetLoaded || this.editMode) {
+        delete this.actions.edit;
       } else {
-        if (!actions.edit) {
-          this.set('actions.edit', EDIT);
+        if (!this.actions.edit) {
+          this.actions = {...this.actions, edit: EDIT};
         }
       }
       // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
       // is loaded.
-      if (editMode && !editPatchsetLoaded) {
-        if (!actions.stopEdit) {
-          this.set('actions.stopEdit', STOP_EDIT);
+      if (this.editMode && !this.editPatchsetLoaded) {
+        if (!this.actions.stopEdit) {
+          this.actions = {...this.actions, stopEdit: STOP_EDIT};
           fireAlert(this, 'Change is in edit mode');
         }
       } else {
-        this._deleteAndNotify('stopEdit');
+        delete this.actions.stopEdit;
       }
     } else {
       // Remove edit button.
-      this._deleteAndNotify('edit');
+      delete this.actions.edit;
     }
   }
 
-  _getValuesFor<T>(obj: {[key: string]: T}): T[] {
+  private getValuesFor<T>(obj: {[key: string]: T}): T[] {
     return Object.keys(obj).map(key => obj[key]);
   }
 
-  _getLabelStatus(label: LabelInfo): LabelStatus {
+  private getLabelStatus(label: LabelInfo): LabelStatus {
     if (isQuickLabelInfo(label)) {
       if (label.approved) {
         return LabelStatus.OK;
@@ -937,7 +1123,7 @@
    * Get highest score for last missing permitted label for current change.
    * Returns null if no labels permitted or more than one label missing.
    */
-  _getTopMissingApproval() {
+  private getTopMissingApproval() {
     if (!this.change || !this.change.labels || !this.change.permitted_labels) {
       return null;
     }
@@ -952,7 +1138,7 @@
       if (this.change.permitted_labels[label].length === 0) {
         continue;
       }
-      const status = this._getLabelStatus(labelInfo);
+      const status = this.getLabelStatus(labelInfo);
       if (status === LabelStatus.NEED) {
         if (result) {
           // More than one label is missing, so check if Code Review can be
@@ -980,7 +1166,6 @@
       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
@@ -1009,20 +1194,20 @@
   }
 
   hideQuickApproveAction() {
-    if (!this._topLevelSecondaryActions) {
-      throw new Error('_topLevelSecondaryActions must be set');
+    if (!this.topLevelSecondaryActions) {
+      throw new Error('topLevelSecondaryActions must be set');
     }
-    this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
+    this.topLevelSecondaryActions = this.topLevelSecondaryActions.filter(
       sa => !isQuickApproveAction(sa)
     );
     this._hideQuickApproveAction = true;
   }
 
-  _getQuickApproveAction(): QuickApproveUIActionInfo | null {
+  private getQuickApproveAction(): QuickApproveUIActionInfo | null {
     if (this._hideQuickApproveAction) {
       return null;
     }
-    const approval = this._getTopMissingApproval();
+    const approval = this.getTopMissingApproval();
     if (!approval) {
       return null;
     }
@@ -1044,32 +1229,23 @@
     return action;
   }
 
-  _getActionValues(
-    actionsChangeRecord: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    primariesChangeRecord: PolymerDeepPropertyChange<
-      PrimaryActionKey[],
-      PrimaryActionKey[]
-    >,
-    additionalActionsChangeRecord: PolymerDeepPropertyChange<
-      UIActionInfo[],
-      UIActionInfo[]
-    >,
+  private getActionValues(
+    actionsChange: ActionNameToActionInfoMap,
+    primariesChange: PrimaryActionKey[],
+    additionalActionsChange: UIActionInfo[],
     type: ActionType
   ): UIActionInfo[] {
-    if (!actionsChangeRecord || !primariesChangeRecord) {
+    if (!actionsChange || !primariesChange) {
       return [];
     }
 
-    const actions = actionsChangeRecord.base || {};
-    const primaryActionKeys = primariesChangeRecord.base || [];
+    const actions = actionsChange;
+    const primaryActionKeys = primariesChange;
     const result: UIActionInfo[] = [];
     const values: Array<ChangeActions | RevisionActions> =
       type === ActionType.CHANGE
-        ? this._getValuesFor(ChangeActions)
-        : this._getValuesFor(RevisionActions);
+        ? this.getValuesFor(ChangeActions)
+        : this.getValuesFor(RevisionActions);
 
     const pluginActions: UIActionInfo[] = [];
     Object.keys(actions).forEach(a => {
@@ -1079,26 +1255,25 @@
       action.__primary = primaryActionKeys.includes(a as PrimaryActionKey);
       // Plugin actions always contain ~ in the key.
       if (a.indexOf('~') !== -1) {
-        this._populateActionUrl(action);
+        this.populateActionUrl(action);
         pluginActions.push(action);
         // Add server-side provided plugin actions to overflow menu.
-        this._overflowActions.push({
+        this.overflowActions.push({
           type,
           key: a,
         });
+        this.requestUpdate('overflowActions');
         return;
       } else if (!values.includes(a as PrimaryActionKey)) {
         return;
       }
-      action.label = this._getActionLabel(action);
+      action.label = this.getActionLabel(action);
 
       // Triggers a re-render by ensuring object inequality.
       result.push({...action});
     });
 
-    let additionalActions =
-      (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
-      [];
+    let additionalActions = additionalActionsChange;
     additionalActions = additionalActions
       .filter(a => a.__type === type)
       .map(a => {
@@ -1109,7 +1284,7 @@
     return result.concat(additionalActions).concat(pluginActions);
   }
 
-  _populateActionUrl(action: UIActionInfo) {
+  private populateActionUrl(action: UIActionInfo) {
     const patchNum =
       action.__type === ActionType.REVISION ? this.latestPatchNum : undefined;
     if (!this.changeNum) {
@@ -1124,7 +1299,7 @@
    * Given a change action, return a display label that uses the appropriate
    * casing or includes explanatory details.
    */
-  _getActionLabel(action: UIActionInfo) {
+  private getActionLabel(action: UIActionInfo) {
     if (action.label === 'Delete') {
       // This label is common within change and revision actions. Make it more
       // explicit to the user.
@@ -1133,34 +1308,38 @@
       return 'Mark as work in progress';
     }
     // Otherwise, just map the name to sentence case.
-    return this._toSentenceCase(action.label);
+    return this.toSentenceCase(action.label);
   }
 
   /**
-   * Capitalize the first letter and lowecase all others.
+   * Capitalize the first letter and lowercase all others.
+   *
+   * private but used in test
    */
-  _toSentenceCase(s: string) {
+  toSentenceCase(s: string) {
     if (!s.length) {
       return '';
     }
     return s[0].toUpperCase() + s.slice(1).toLowerCase();
   }
 
-  _computeLoadingLabel(action: string) {
+  private computeLoadingLabel(action: string) {
     return ActionLoadingLabels[action] || 'Working...';
   }
 
-  _canSubmitChange() {
+  // private but used in test
+  canSubmitChange() {
     if (!this.change) {
       return false;
     }
-    return this.jsAPI.canSubmitChange(
+    return this.getPluginLoader().jsApiService.canSubmitChange(
       this.change,
-      this._getRevision(this.change, this.latestPatchNum)
+      this.getRevision(this.change, this.latestPatchNum)
     );
   }
 
-  _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
+  // private but used in test
+  getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
     for (const rev of Object.values(change.revisions)) {
       if (rev._number === patchNum) {
         return rev;
@@ -1172,31 +1351,39 @@
   showRevertDialog() {
     const change = this.change;
     if (!change) return;
-    // The search is still broken if there is a " in the topic.
     const query = `submissionid: "${change.submission_id}"`;
     /* A chromium plugin expects that the modifyRevertMsg hook will only
     be called after the revert button is pressed, hence we populate the
     revert dialog after revert button is pressed. */
     this.restApiService.getChanges(0, query).then(changes => {
       if (!changes) {
-        this.reporting.error(new Error('changes is undefined'));
+        this.reporting.error(
+          'Change Actions',
+          new Error('getChanges returns undefined')
+        );
         return;
       }
-      this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
-      this._showActionDialog(this.$.confirmRevertDialog);
+      assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
+      this.confirmRevertDialog.populate(
+        change,
+        this.commitMessage,
+        changes.length
+      );
+      this.showActionDialog(this.confirmRevertDialog);
     });
   }
 
   showSubmitDialog() {
-    if (!this._canSubmitChange()) {
+    if (!this.canSubmitChange()) {
       return;
     }
-    this._showActionDialog(this.$.confirmSubmitDialog);
+    assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+    this.showActionDialog(this.confirmSubmitDialog);
   }
 
-  _handleActionTap(e: MouseEvent) {
+  private handleActionTap(e: MouseEvent, key: string, type: string) {
     e.preventDefault();
-    let el = (dom(e) as EventApi).localTarget as Element;
+    let el = e.target as Element;
     while (el.tagName.toLowerCase() !== 'gr-button') {
       if (!el.parentElement) {
         return;
@@ -1204,10 +1391,6 @@
       el = el.parentElement;
     }
 
-    const key = el.getAttribute('data-action-key');
-    if (!key) {
-      throw new Error("Button doesn't have data-action-key attribute");
-    }
     if (
       key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
       key.indexOf('~') !== -1
@@ -1221,13 +1404,12 @@
       );
       return;
     }
-    const type = el.getAttribute('data-action-type') as ActionType;
-    this._handleAction(type, key);
+    this.handleAction(type as ActionType, key);
   }
 
-  _handleOverflowItemTap(e: CustomEvent<MenuAction>) {
+  private handleOverflowItemTap(e: CustomEvent<MenuAction>) {
     e.preventDefault();
-    const el = (dom(e) as EventApi).localTarget as Element;
+    const el = e.target as Element;
     const key = e.detail.action.__key;
     if (
       key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
@@ -1242,164 +1424,183 @@
       );
       return;
     }
-    this._handleAction(e.detail.action.__type, e.detail.action.__key);
+    this.handleAction(e.detail.action.__type, e.detail.action.__key);
   }
 
-  _handleAction(type: ActionType, key: string) {
+  // private but used in test
+  handleAction(type: ActionType, key: string) {
     this.reporting.reportInteraction(`${type}-${key}`);
     switch (type) {
       case ActionType.REVISION:
-        this._handleRevisionAction(key);
+        this.handleRevisionAction(key);
         break;
       case ActionType.CHANGE:
-        this._handleChangeAction(key);
+        this.handleChangeAction(key);
         break;
       default:
-        this._fireAction(
-          this._prependSlash(key),
+        this.fireAction(
+          this.prependSlash(key),
           assertUIActionInfo(this.actions[key]),
           false
         );
     }
   }
 
-  _handleChangeAction(key: string) {
+  // private but used in test
+  handleChangeAction(key: string) {
     switch (key) {
       case ChangeActions.REVERT:
         this.showRevertDialog();
         break;
       case ChangeActions.ABANDON:
-        this._showActionDialog(this.$.confirmAbandonDialog);
+        assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog');
+        this.showActionDialog(this.confirmAbandonDialog);
         break;
       case QUICK_APPROVE_ACTION.key: {
-        const action = this._allActionValues.find(isQuickApproveAction);
+        const action = this.allActionValues.find(isQuickApproveAction);
         if (!action) {
           return;
         }
-        this._fireAction(this._prependSlash(key), action, true, action.payload);
+        this.fireAction(this.prependSlash(key), action, true, action.payload);
         break;
       }
       case ChangeActions.EDIT:
-        this._handleEditTap();
+        this.handleEditTap();
         break;
       case ChangeActions.STOP_EDIT:
-        this._handleStopEditTap();
+        this.handleStopEditTap();
         break;
       case ChangeActions.DELETE:
-        this._handleDeleteTap();
+        this.handleDeleteTap();
         break;
       case ChangeActions.DELETE_EDIT:
-        this._handleDeleteEditTap();
+        this.handleDeleteEditTap();
         break;
       case ChangeActions.FOLLOW_UP:
-        this._handleFollowUpTap();
+        this.handleFollowUpTap();
         break;
       case ChangeActions.WIP:
-        this._handleWipTap();
+        this.handleWipTap();
         break;
       case ChangeActions.MOVE:
-        this._handleMoveTap();
+        this.handleMoveTap();
         break;
       case ChangeActions.PUBLISH_EDIT:
-        this._handlePublishEditTap();
+        this.handlePublishEditTap();
         break;
       case ChangeActions.REBASE_EDIT:
-        this._handleRebaseEditTap();
+        this.handleRebaseEditTap();
         break;
       case ChangeActions.INCLUDED_IN:
-        this._handleIncludedInTap();
+        this.handleIncludedInTap();
         break;
       default:
-        this._fireAction(
-          this._prependSlash(key),
+        this.fireAction(
+          this.prependSlash(key),
           assertUIActionInfo(this.actions[key]),
           false
         );
     }
   }
 
-  _handleRevisionAction(key: string) {
+  private handleRevisionAction(key: string) {
     switch (key) {
       case RevisionActions.REBASE:
-        this._showActionDialog(this.$.confirmRebase);
-        this.$.confirmRebase.fetchRecentChanges();
+        assertIsDefined(this.confirmRebase, 'confirmRebase');
+        this.showActionDialog(this.confirmRebase);
+        this.confirmRebase.fetchRecentChanges();
         break;
       case RevisionActions.CHERRYPICK:
-        this._handleCherrypickTap();
+        this.handleCherrypickTap();
         break;
       case RevisionActions.DOWNLOAD:
-        this._handleDownloadTap();
+        this.handleDownloadTap();
         break;
       case RevisionActions.SUBMIT:
-        if (!this._canSubmitChange()) {
+        if (!this.canSubmitChange()) {
           return;
         }
-        this._showActionDialog(this.$.confirmSubmitDialog);
+        assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+        this.showActionDialog(this.confirmSubmitDialog);
         break;
       default:
-        this._fireAction(
-          this._prependSlash(key),
+        this.fireAction(
+          this.prependSlash(key),
           assertUIActionInfo(this.revisionActions[key]),
           true
         );
     }
   }
 
-  _prependSlash(key: string) {
+  private prependSlash(key: string) {
     return key === '/' ? key : `/${key}`;
   }
 
   /**
    * _hasKnownChainState set to true true if hasParent is defined (can be
    * either true or false). set to false otherwise.
+   *
+   * private but used in test
    */
-  _computeChainState() {
+  computeChainState() {
     this._hasKnownChainState = true;
   }
 
-  _calculateDisabled(action: UIActionInfo, hasKnownChainState: boolean) {
+  // private but used in test
+  calculateDisabled(action: UIActionInfo) {
     if (action.__key === 'rebase') {
       // Rebase button is only disabled when change has no parent(s).
-      return hasKnownChainState === false;
+      return this._hasKnownChainState === false;
     }
     return !action.enabled;
   }
 
-  _handleConfirmDialogCancel() {
-    this._hideAllDialogs();
+  private handleConfirmDialogCancel() {
+    this.hideAllDialogs();
   }
 
-  _hideAllDialogs() {
-    const dialogEls = this.root!.querySelectorAll('.confirmDialog');
+  private hideAllDialogs() {
+    assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog');
+    const dialogEls = queryAll(this, '.confirmDialog');
     for (const dialogEl of dialogEls) {
       (dialogEl as HTMLElement).hidden = true;
     }
-    this.$.overlay.close();
+    assertIsDefined(this.actionsModal, 'actionsModal');
+    this.actionsModal.close();
   }
 
-  _handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
-    const el = this.$.confirmRebase;
-    const payload = {base: e.detail.base};
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction(
-      '/rebase',
+  // private but used in test
+  handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
+    assertIsDefined(this.confirmRebase, 'confirmRebase');
+    assertIsDefined(this.actionsModal, 'actionsModal');
+    const payload = {
+      base: e.detail.base,
+      allow_conflicts: e.detail.allowConflicts,
+    };
+    const rebaseChain = !!e.detail.rebaseChain;
+    this.fireAction(
+      rebaseChain ? '/rebase:chain' : '/rebase',
       assertUIActionInfo(this.revisionActions.rebase),
-      true,
-      payload
+      rebaseChain ? false : true,
+      payload,
+      {allow_conflicts: payload.allow_conflicts}
     );
   }
 
-  _handleCherrypickConfirm() {
-    this._handleCherryPickRestApi(false);
+  // private but used in test
+  handleCherrypickConfirm() {
+    this.handleCherryPickRestApi(false);
   }
 
-  _handleCherrypickConflictConfirm() {
-    this._handleCherryPickRestApi(true);
+  // private but used in test
+  handleCherrypickConflictConfirm() {
+    this.handleCherryPickRestApi(true);
   }
 
-  _handleCherryPickRestApi(conflicts: boolean) {
-    const el = this.$.confirmCherrypick;
+  private handleCherryPickRestApi(conflicts: boolean) {
+    assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
+    assertIsDefined(this.actionsModal, 'actionsModal');
+    const el = this.confirmCherrypick;
     if (!el.branch) {
       fireAlert(this, ERR_BRANCH_EMPTY);
       return;
@@ -1408,9 +1609,9 @@
       fireAlert(this, ERR_COMMIT_EMPTY);
       return;
     }
-    this.$.overlay.close();
+    this.actionsModal.close();
     el.hidden = true;
-    this._fireAction(
+    this.fireAction(
       '/cherrypick',
       assertUIActionInfo(this.revisionActions.cherrypick),
       true,
@@ -1423,29 +1624,34 @@
     );
   }
 
-  _handleMoveConfirm() {
-    const el = this.$.confirmMove;
+  // private but used in test
+  handleMoveConfirm() {
+    assertIsDefined(this.confirmMove, 'confirmMove');
+    assertIsDefined(this.actionsModal, 'actionsModal');
+    const el = this.confirmMove;
     if (!el.branch) {
       fireAlert(this, ERR_BRANCH_EMPTY);
       return;
     }
-    this.$.overlay.close();
+    this.actionsModal.close();
     el.hidden = true;
-    this._fireAction('/move', assertUIActionInfo(this.actions.move), false, {
+    this.fireAction('/move', assertUIActionInfo(this.actions.move), false, {
       destination_branch: el.branch,
       message: el.message,
     });
   }
 
-  _handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+  private handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+    assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
+    assertIsDefined(this.actionsModal, 'actionsModal');
     const revertType = e.detail.revertType;
     const message = e.detail.message;
-    const el = this.$.confirmRevertDialog;
-    this.$.overlay.close();
+    const el = this.confirmRevertDialog;
+    this.actionsModal.close();
     el.hidden = true;
     switch (revertType) {
       case RevertType.REVERT_SINGLE_CHANGE:
-        this._fireAction(
+        this.fireAction(
           '/revert',
           assertUIActionInfo(this.actions.revert),
           false,
@@ -1455,7 +1661,7 @@
       case RevertType.REVERT_SUBMISSION:
         // TODO(dhruvsri): replace with this.actions.revert_submission once
         // BE starts sending it again
-        this._fireAction(
+        this.fireAction(
           '/revert_submission',
           {__key: 'revert_submission', method: HttpMethod.POST} as UIActionInfo,
           false,
@@ -1463,15 +1669,21 @@
         );
         break;
       default:
-        this.reporting.error(new Error('invalid revert type'));
+        this.reporting.error(
+          'Change Actions',
+          new Error('invalid revert type')
+        );
     }
   }
 
-  _handleAbandonDialogConfirm() {
-    const el = this.$.confirmAbandonDialog;
-    this.$.overlay.close();
+  // private but used in test
+  handleAbandonDialogConfirm() {
+    assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog');
+    assertIsDefined(this.actionsModal, 'actionsModal');
+    const el = this.confirmAbandonDialog;
+    this.actionsModal.close();
     el.hidden = true;
-    this._fireAction(
+    this.fireAction(
       '/abandon',
       assertUIActionInfo(this.actions.abandon),
       false,
@@ -1481,54 +1693,64 @@
     );
   }
 
-  _handleCreateFollowUpChange() {
-    this.$.createFollowUpChange.handleCreateChange();
-    this._handleCloseCreateFollowUpChange();
+  private handleCreateFollowUpChange() {
+    assertIsDefined(this.createFollowUpChange, 'createFollowUpChange');
+    this.createFollowUpChange.handleCreateChange();
+    this.handleCloseCreateFollowUpChange();
   }
 
-  _handleCloseCreateFollowUpChange() {
-    this.$.overlay.close();
+  private handleCloseCreateFollowUpChange() {
+    assertIsDefined(this.actionsModal, 'actionsModal');
+    this.actionsModal.close();
   }
 
-  _handleDeleteConfirm() {
-    this._hideAllDialogs();
-    this._fireAction(
+  private handleDeleteConfirm() {
+    this.hideAllDialogs();
+    this.fireAction(
       '/',
       assertUIActionInfo(this.actions[ChangeActions.DELETE]),
       false
     );
   }
 
-  _handleDeleteEditConfirm() {
-    this._hideAllDialogs();
+  private handleDeleteEditConfirm() {
+    this.hideAllDialogs();
 
-    this._fireAction(
+    // We need to make sure that all cached version of a change
+    // edit are deleted.
+    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
+
+    this.fireAction(
       '/edit',
       assertUIActionInfo(this.actions.deleteEdit),
       false
     );
   }
 
-  _handleSubmitConfirm() {
-    if (!this._canSubmitChange()) {
+  // private but used in test
+  handleSubmitConfirm() {
+    if (!this.canSubmitChange()) {
       return;
     }
-    this._hideAllDialogs();
-    this._fireAction(
+    this.hideAllDialogs();
+    this.fireAction(
       '/submit',
       assertUIActionInfo(this.revisionActions.submit),
       true
     );
   }
 
-  _getActionOverflowIndex(type: string, key: string) {
-    return this._overflowActions.findIndex(
+  private getActionOverflowIndex(type: string, key: string) {
+    return this.overflowActions.findIndex(
       action => action.type === type && action.key === key
     );
   }
 
-  _setLoadingOnButtonWithKey(type: string, key: string) {
-    this._actionLoadingMessage = this._computeLoadingLabel(key);
+  // private but used in test
+  setLoadingOnButtonWithKey(action: UIActionInfo) {
+    const key = action.__key;
+    this.inProgressActionKeys.add(key);
+    this.actionLoadingMessage = this.computeLoadingLabel(key);
     let buttonKey = key;
     // TODO(dhruvsri): clean this up later
     // If key is revert-submission, then button key should be 'revert'
@@ -1538,14 +1760,14 @@
     }
 
     // If the action appears in the overflow menu.
-    if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
-      this.push(
-        '_disabledMenuActions',
-        buttonKey === '/' ? 'delete' : buttonKey
-      );
+    if (this.getActionOverflowIndex(action.__type, buttonKey) !== -1) {
+      this.disabledMenuActions.push(buttonKey === '/' ? 'delete' : buttonKey);
+      this.requestUpdate('disabledMenuActions');
       return () => {
-        this._actionLoadingMessage = '';
-        this._disabledMenuActions = [];
+        this.inProgressActionKeys.delete(key);
+        this.actionLoadingMessage = '';
+        this.disabledMenuActions = [];
+        this.requestUpdate();
       };
     }
 
@@ -1559,38 +1781,46 @@
     buttonEl.setAttribute('loading', 'true');
     buttonEl.disabled = true;
     return () => {
-      this._actionLoadingMessage = '';
+      this.inProgressActionKeys.delete(action.__key);
+      this.actionLoadingMessage = '';
       buttonEl.removeAttribute('loading');
       buttonEl.disabled = false;
+      this.requestUpdate();
     };
   }
 
-  _fireAction(
+  // private but used in test
+  fireAction(
     endpoint: string,
     action: UIActionInfo,
     revAction: boolean,
-    payload?: RequestPayload
+    payload?: RequestPayload,
+    toReport?: Object
   ) {
-    const cleanupFn = this._setLoadingOnButtonWithKey(
-      action.__type,
-      action.__key
-    );
+    const cleanupFn = this.setLoadingOnButtonWithKey(action);
+    this.reporting.reportInteraction(Interaction.CHANGE_ACTION_FIRED, {
+      endpoint,
+      toReport,
+    });
 
-    this._send(
+    this.send(
       action.method,
       payload,
       endpoint,
       revAction,
       cleanupFn,
       action
-    ).then(res => this._handleResponse(action, res));
+    ).then(res => this.handleResponse(action, res));
   }
 
-  _showActionDialog(dialog: ChangeActionDialog) {
-    this._hideAllDialogs();
+  // private but used in test
+  showActionDialog(dialog: ChangeActionDialog) {
+    this.hideAllDialogs();
     if (dialog.init) dialog.init();
     dialog.hidden = false;
-    this.$.overlay.open().then(() => {
+    assertIsDefined(this.actionsModal, 'actionsModal');
+    this.actionsModal.showModal();
+    whenVisible(dialog, () => {
       if (dialog.resetFocus) {
         dialog.resetFocus();
       }
@@ -1599,41 +1829,48 @@
 
   // TODO(rmistry): Redo this after
   // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-  _setReviewOnRevert(newChangeId: NumericChangeId) {
-    const review = this.jsAPI.getReviewPostRevert(this.change);
+  // private but used in test
+  setReviewOnRevert(newChangeId: NumericChangeId) {
+    const review = this.getPluginLoader().jsApiService.getReviewPostRevert(
+      this.change
+    );
     if (!review) {
       return Promise.resolve(undefined);
     }
     return this.restApiService.saveChangeReview(newChangeId, CURRENT, review);
   }
 
-  _handleResponse(action: UIActionInfo, response?: Response) {
+  // private but used in test
+  handleResponse(action: UIActionInfo, response?: Response) {
     if (!response) {
       return;
     }
+    // response is guaranteed to be ok (due to semantics of rest-api methods)
     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._setReviewOnRevert(revertChangeInfo._number))
+          this.waitForChangeReachable(revertChangeInfo._number)
+            .then(() => this.setReviewOnRevert(revertChangeInfo._number))
             .then(() => {
-              GerritNav.navigateToChange(revertChangeInfo);
+              this.getNavigation().setUrl(
+                createChangeUrl({change: revertChangeInfo})
+              );
             });
           break;
         }
         case RevisionActions.CHERRYPICK: {
           const cherrypickChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
-          this._waitForChangeReachable(cherrypickChangeInfo._number).then(
-            () => {
-              GerritNav.navigateToChange(cherrypickChangeInfo);
-            }
-          );
+          this.waitForChangeReachable(cherrypickChangeInfo._number).then(() => {
+            this.getNavigation().setUrl(
+              createChangeUrl({change: cherrypickChangeInfo})
+            );
+          });
           break;
         }
         case ChangeActions.DELETE:
           if (action.__type === ActionType.CHANGE) {
-            GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
+            this.getNavigation().setUrl(rootUrl());
           }
           break;
         case ChangeActions.WIP:
@@ -1642,6 +1879,9 @@
         case ChangeActions.REBASE_EDIT:
         case ChangeActions.REBASE:
         case ChangeActions.SUBMIT:
+          // Hide rebase dialog only if the action succeeds
+          this.actionsModal?.close();
+          this.hideAllDialogs();
           fireReload(this, true);
           break;
         case ChangeActions.REVERT_SUBMISSION: {
@@ -1652,10 +1892,9 @@
           )
             return;
           /* If there is only 1 change then gerrit will automatically
-             redirect to that change */
-          GerritNav.navigateToSearchQuery(
-            `topic: ${revertSubmistionInfo.revert_changes[0].topic}`
-          );
+            redirect to that change */
+          const topic = revertSubmistionInfo.revert_changes[0].topic;
+          this.getNavigation().setUrl(createSearchUrl({topic}));
           break;
         }
         default:
@@ -1665,7 +1904,8 @@
     });
   }
 
-  _handleResponseError(
+  // private but used in test
+  handleResponseError(
     action: UIActionInfo,
     response: Response | undefined | null,
     body?: RequestPayload
@@ -1687,7 +1927,12 @@
         body &&
         !(body as CherryPickInput).allow_conflicts
       ) {
-        return this._showActionDialog(this.$.confirmCherrypickConflict);
+        assertIsDefined(
+          this.confirmCherrypickConflict,
+          'confirmCherrypickConflict'
+        );
+        this.showActionDialog(this.confirmCherrypickConflict);
+        return;
       }
     }
     return response.text().then(errText => {
@@ -1704,7 +1949,8 @@
     });
   }
 
-  _send(
+  // private but used in test
+  send(
     method: HttpMethod | undefined,
     payload: RequestPayload | undefined,
     actionEndpoint: string,
@@ -1714,7 +1960,7 @@
   ): Promise<Response | undefined> {
     const handleError: ErrorCallback = response => {
       cleanupFn.call(this);
-      this._handleResponseError(action, response, payload);
+      this.handleResponseError(action, response, payload);
     };
     const change = this.change;
     const changeNum = this.changeNum;
@@ -1723,50 +1969,54 @@
         new Error('Properties change and changeNum must be set.')
       );
     }
-    return this.changeService.fetchChangeUpdates(change).then(result => {
-      if (!result.isLatest) {
-        this.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>('show-alert', {
-            detail: {
-              message:
-                'Cannot set label: a newer patch has been ' +
-                'uploaded to this change.',
-              action: 'Reload',
-              callback: () => fireReload(this, true),
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
+    return this.getChangeModel()
+      .fetchChangeUpdates(change)
+      .then(result => {
+        if (!result.isLatest) {
+          this.dispatchEvent(
+            new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
+              detail: {
+                message:
+                  'Cannot set label: a newer patch has been ' +
+                  'uploaded to this change.',
+                action: 'Reload',
+                callback: () => fireReload(this, true),
+              },
+              composed: true,
+              bubbles: true,
+            })
+          );
 
-        // Because this is not a network error, call the cleanup function
-        // but not the error handler.
-        cleanupFn();
+          // Because this is not a network error, call the cleanup function
+          // but not the error handler.
+          cleanupFn();
 
-        return Promise.resolve(undefined);
-      }
-      const patchNum = revisionAction ? this.latestPatchNum : undefined;
-      return this.restApiService
-        .executeChangeAction(
-          changeNum,
-          method,
-          actionEndpoint,
-          patchNum,
-          payload,
-          handleError
-        )
-        .then(response => {
-          cleanupFn.call(this);
-          return response;
-        });
-    });
+          return Promise.resolve(undefined);
+        }
+        const patchNum = revisionAction ? this.latestPatchNum : undefined;
+        return this.restApiService
+          .executeChangeAction(
+            changeNum,
+            method,
+            actionEndpoint,
+            patchNum,
+            payload,
+            handleError
+          )
+          .then(response => {
+            cleanupFn.call(this);
+            return response;
+          });
+      });
   }
 
-  _handleCherrypickTap() {
+  // private but used in test
+  handleCherrypickTap() {
     if (!this.change) {
       throw new Error('The change property must be set');
     }
-    this.$.confirmCherrypick.branch = '' as BranchName;
+    assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
+    this.confirmCherrypick.branch = '' as BranchName;
     const query = `topic: "${this.change.topic}"`;
     const options = listChangesOptionsToHex(
       ListChangesOption.MESSAGES,
@@ -1776,52 +2026,67 @@
       .getChanges(0, query, undefined, options)
       .then(changes => {
         if (!changes) {
-          this.reporting.error(new Error('getChanges returns undefined'));
+          this.reporting.error(
+            'Change Actions',
+            new Error('getChanges returns undefined')
+          );
           return;
         }
-        this.$.confirmCherrypick.updateChanges(changes);
-        this._showActionDialog(this.$.confirmCherrypick);
+        this.confirmCherrypick!.updateChanges(changes);
+        this.showActionDialog(this.confirmCherrypick!);
       });
   }
 
-  _handleMoveTap() {
-    this.$.confirmMove.branch = '' as BranchName;
-    this.$.confirmMove.message = '';
-    this._showActionDialog(this.$.confirmMove);
+  // private but used in test
+  handleMoveTap() {
+    assertIsDefined(this.confirmMove, 'confirmMove');
+    this.confirmMove.branch = '' as BranchName;
+    this.confirmMove.message = '';
+    this.showActionDialog(this.confirmMove);
   }
 
-  _handleDownloadTap() {
+  // private but used in test
+  handleDownloadTap() {
     fireEvent(this, 'download-tap');
   }
 
-  _handleIncludedInTap() {
+  // private but used in test
+  handleIncludedInTap() {
     fireEvent(this, 'included-tap');
   }
 
-  _handleDeleteTap() {
-    this._showActionDialog(this.$.confirmDeleteDialog);
+  // private but used in test
+  handleDeleteTap() {
+    assertIsDefined(this.confirmDeleteDialog, 'confirmDeleteDialog');
+    this.showActionDialog(this.confirmDeleteDialog);
   }
 
-  _handleDeleteEditTap() {
-    this._showActionDialog(this.$.confirmDeleteEditDialog);
+  // private but used in test
+  handleDeleteEditTap() {
+    assertIsDefined(this.confirmDeleteEditDialog, 'confirmDeleteEditDialog');
+    this.showActionDialog(this.confirmDeleteEditDialog);
   }
 
-  _handleFollowUpTap() {
-    this._showActionDialog(this.$.createFollowUpDialog);
+  private handleFollowUpTap() {
+    assertIsDefined(this.createFollowUpDialog, 'createFollowUpDialog');
+    this.showActionDialog(this.createFollowUpDialog);
   }
 
-  _handleWipTap() {
+  private handleWipTap() {
     if (!this.actions.wip) {
       return;
     }
-    this._fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
+    this.fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
   }
 
-  _handlePublishEditTap() {
-    if (!this.actions.publishEdit) {
-      return;
-    }
-    this._fireAction(
+  private handlePublishEditTap() {
+    if (!this.actions.publishEdit) return;
+
+    // We need to make sure that all cached version of a change
+    // edit are deleted.
+    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
+
+    this.fireAction(
       '/edit:publish',
       assertUIActionInfo(this.actions.publishEdit),
       false,
@@ -1829,93 +2094,58 @@
     );
   }
 
-  _handleRebaseEditTap() {
+  private handleRebaseEditTap() {
     if (!this.actions.rebaseEdit) {
       return;
     }
-    this._fireAction(
+    this.fireAction(
       '/edit:rebase',
       assertUIActionInfo(this.actions.rebaseEdit),
       false
     );
   }
 
-  _handleHideBackgroundContent() {
-    this.$.mainContent.classList.add('overlayOpen');
-  }
-
-  _handleShowBackgroundContent() {
-    this.$.mainContent.classList.remove('overlayOpen');
-  }
-
   /**
    * Merge sources of change actions into a single ordered array of action
    * values.
    */
-  _computeAllActions(
-    changeActionsRecord: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    revisionActionsRecord: PolymerDeepPropertyChange<
-      ActionNameToActionInfoMap,
-      ActionNameToActionInfoMap
-    >,
-    primariesRecord: PolymerDeepPropertyChange<
-      PrimaryActionKey[],
-      PrimaryActionKey[]
-    >,
-    additionalActionsRecord: PolymerDeepPropertyChange<
-      UIActionInfo[],
-      UIActionInfo[]
-    >,
-    change?: ChangeInfo
-  ): UIActionInfo[] {
-    // Polymer 2: check for undefined
-    if (
-      [
-        changeActionsRecord,
-        revisionActionsRecord,
-        primariesRecord,
-        additionalActionsRecord,
-        change,
-      ].includes(undefined)
-    ) {
+  private computeAllActions(): UIActionInfo[] {
+    if (this.change === undefined) {
       return [];
     }
 
-    const revisionActionValues = this._getActionValues(
-      revisionActionsRecord,
-      primariesRecord,
-      additionalActionsRecord,
+    const revisionActionValues = this.getActionValues(
+      this.revisionActions,
+      this.primaryActionKeys,
+      this.additionalActions,
       ActionType.REVISION
     );
-    const changeActionValues = this._getActionValues(
-      changeActionsRecord,
-      primariesRecord,
-      additionalActionsRecord,
+    const changeActionValues = this.getActionValues(
+      this.actions,
+      this.primaryActionKeys,
+      this.additionalActions,
       ActionType.CHANGE
     );
-    const quickApprove = this._getQuickApproveAction();
+    const quickApprove = this.getQuickApproveAction();
     if (quickApprove) {
       changeActionValues.unshift(quickApprove);
     }
 
     return revisionActionValues
       .concat(changeActionValues)
-      .sort((a, b) => this._actionComparator(a, b))
+      .sort((a, b) => this.actionComparator(a, b))
       .map(action => {
-        if (ACTIONS_WITH_ICONS.has(action.__key)) {
-          action.icon = action.__key;
-        }
-        return action;
+        return {
+          ...action,
+          ...(ACTIONS_WITH_ICONS.get(action.__key) ?? {}),
+        };
       })
-      .filter(action => !this._shouldSkipAction(action));
+      .filter(action => !this.shouldSkipAction(action));
   }
 
-  _getActionPriority(action: UIActionInfo) {
+  private getActionPriority(action: UIActionInfo) {
     if (action.__type && action.__key) {
-      const overrideAction = this._actionPriorityOverrides.find(
+      const overrideAction = this.actionPriorityOverrides.find(
         i => i.type === action.__type && i.key === action.__key
       );
 
@@ -1937,10 +2167,12 @@
 
   /**
    * Sort comparator to define the order of change actions.
+   *
+   * private but used in test
    */
-  _actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
+  actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
     const priorityDelta =
-      this._getActionPriority(actionA) - this._getActionPriority(actionB);
+      this.getActionPriority(actionA) - this.getActionPriority(actionB);
     // Sort by the button label if same priority.
     if (priorityDelta === 0) {
       return actionA.label > actionB.label ? 1 : -1;
@@ -1949,41 +2181,15 @@
     }
   }
 
-  _shouldSkipAction(action: UIActionInfo) {
+  private shouldSkipAction(action: UIActionInfo) {
     return SKIP_ACTION_KEYS.includes(action.__key);
   }
 
-  _computeTopLevelActions(
-    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>,
-    editMode: boolean
-  ): UIActionInfo[] {
-    const hiddenActions = hiddenActionsRecord.base || [];
-    return actionRecord.base.filter(a => {
-      if (hiddenActions.includes(a.__key)) return false;
-      if (editMode) return EDIT_ACTIONS.has(a.__key);
-      return this._getActionOverflowIndex(a.__type, a.__key) === -1;
-    });
-  }
-
-  _filterPrimaryActions(_topLevelActions: UIActionInfo[]) {
-    this._topLevelPrimaryActions = _topLevelActions.filter(
-      action => action.__primary
-    );
-    this._topLevelSecondaryActions = _topLevelActions.filter(
-      action => !action.__primary
-    );
-  }
-
-  _computeMenuActions(
-    actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-    hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
-  ): MenuAction[] {
-    const hiddenActions = hiddenActionsRecord.base || [];
-    return actionRecord.base
+  private computeMenuActions(): MenuAction[] {
+    return this.allActionValues
       .filter(a => {
-        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
-        return overflow && !hiddenActions.includes(a.__key);
+        const overflow = this.getActionOverflowIndex(a.__type, a.__key) !== -1;
+        return overflow && !this.hiddenActions.includes(a.__key);
       })
       .map(action => {
         let key = action.__key;
@@ -1999,15 +2205,6 @@
       });
   }
 
-  _computeRebaseOnCurrent(
-    revisionRebaseAction: PropertyType<GrChangeActions, '_revisionRebaseAction'>
-  ) {
-    if (revisionRebaseAction) {
-      return !!revisionRebaseAction.enabled;
-    }
-    return null;
-  }
-
   /**
    * Occasionally, a change created by a change action is not yet known to the
    * API for a brief time. Wait for the given change number to be recognized.
@@ -2015,8 +2212,9 @@
    * Returns a promise that resolves with true if a request is recognized, or
    * false if the change was never recognized after all attempts.
    *
+   * private but used in test
    */
-  _waitForChangeReachable(changeNum: NumericChangeId) {
+  waitForChangeReachable(changeNum: NumericChangeId) {
     let attemptsRemaining = AWAIT_CHANGE_ATTEMPTS;
     return new Promise(resolve => {
       const check = () => {
@@ -2042,24 +2240,19 @@
     });
   }
 
-  _handleEditTap() {
+  private handleEditTap() {
     this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
   }
 
-  _handleStopEditTap() {
+  private handleStopEditTap() {
     this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
   }
-
-  _computeHasTooltip(title?: string) {
-    return !!title;
-  }
-
-  _computeHasIcon(action: UIActionInfo) {
-    return action.icon ? '' : 'hidden';
-  }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'revision-actions-changed': CustomEvent<{value: ActionNameToActionInfoMap}>;
+  }
   interface HTMLElementTagNameMap {
     'gr-change-actions': GrChangeActions;
   }
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
deleted file mode 100644
index 17ca7cf..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ /dev/null
@@ -1,264 +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: flex;
-      font-family: var(--font-family);
-    }
-    #actionLoadingMessage,
-    #mainContent,
-    section {
-      display: flex;
-    }
-    #actionLoadingMessage,
-    gr-button,
-    gr-dropdown {
-      /* px because don't have the same font size */
-      margin-left: 8px;
-    }
-    gr-button {
-      display: block;
-    }
-    #actionLoadingMessage {
-      align-items: center;
-      color: var(--deemphasized-text-color);
-    }
-    #confirmSubmitDialog .changeSubject {
-      margin: var(--spacing-l);
-      text-align: center;
-    }
-    iron-icon {
-      color: inherit;
-      margin-right: var(--spacing-xs);
-    }
-    #moreActions iron-icon {
-      margin: 0;
-    }
-    #moreMessage,
-    .hidden {
-      display: none;
-    }
-    @media screen and (max-width: 50em) {
-      #mainContent {
-        flex-wrap: wrap;
-      }
-      gr-button {
-        --gr-button-padding: var(--spacing-m);
-        white-space: nowrap;
-      }
-      gr-button,
-      gr-dropdown {
-        margin: 0;
-      }
-      #actionLoadingMessage {
-        margin: var(--spacing-m);
-        text-align: center;
-      }
-      #moreMessage {
-        display: inline;
-      }
-    }
-  </style>
-  <div id="mainContent">
-    <span id="actionLoadingMessage" hidden$="[[!_actionLoadingMessage]]">
-      [[_actionLoadingMessage]]</span
-    >
-    <section
-      id="primaryActions"
-      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
-    >
-      <template is="dom-repeat" items="[[_topLevelPrimaryActions]]" as="action">
-        <gr-tooltip-content
-          title$="[[action.title]]"
-          has-tooltip="[[_computeHasTooltip(action.title)]]"
-          position-below="true"
-        >
-          <gr-button
-            link=""
-            data-action-key$="[[action.__key]]"
-            class$="[[action.__key]]"
-            data-action-type$="[[action.__type]]"
-            data-label$="[[action.label]]"
-            disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-            on-click="_handleActionTap"
-          >
-            <iron-icon
-              class$="[[_computeHasIcon(action)]]"
-              icon$="gr-icons:[[action.icon]]"
-            ></iron-icon>
-            [[action.label]]
-          </gr-button>
-        </gr-tooltip-content>
-      </template>
-    </section>
-    <section
-      id="secondaryActions"
-      hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
-    >
-      <template
-        is="dom-repeat"
-        items="[[_topLevelSecondaryActions]]"
-        as="action"
-      >
-        <gr-tooltip-content
-          title$="[[action.title]]"
-          has-tooltip="[[_computeHasTooltip(action.title)]]"
-          position-below="true"
-        >
-          <gr-button
-            link=""
-            data-action-key$="[[action.__key]]"
-            class$="[[action.__key]]"
-            data-action-type$="[[action.__type]]"
-            data-label$="[[action.label]]"
-            disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-            on-click="_handleActionTap"
-          >
-            <iron-icon
-              class$="[[_computeHasIcon(action)]]"
-              icon$="gr-icons:[[action.icon]]"
-            ></iron-icon>
-            [[action.label]]
-          </gr-button>
-        </gr-tooltip-content>
-      </template>
-    </section>
-    <gr-button hidden$="[[!_loading]]" disabled=""
-      >Loading actions...</gr-button
-    >
-    <gr-dropdown
-      id="moreActions"
-      link=""
-      vertical-offset="32"
-      horizontal-align="right"
-      on-tap-item="_handleOverflowItemTap"
-      hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
-      disabled-ids="[[_disabledMenuActions]]"
-      items="[[_menuActions]]"
-    >
-      <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
-      </iron-icon>
-      <span id="moreMessage">More</span>
-    </gr-dropdown>
-  </div>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-rebase-dialog
-      id="confirmRebase"
-      class="confirmDialog"
-      change-number="[[change._number]]"
-      on-confirm="_handleRebaseConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      branch="[[change.branch]]"
-      has-parent="[[hasParent]]"
-      rebase-on-current="[[_computeRebaseOnCurrent(_revisionRebaseAction)]]"
-      hidden=""
-    ></gr-confirm-rebase-dialog>
-    <gr-confirm-cherrypick-dialog
-      id="confirmCherrypick"
-      class="confirmDialog"
-      change-status="[[changeStatus]]"
-      commit-message="[[commitMessage]]"
-      commit-num="[[commitNum]]"
-      on-confirm="_handleCherrypickConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      project="[[change.project]]"
-      hidden=""
-    ></gr-confirm-cherrypick-dialog>
-    <gr-confirm-cherrypick-conflict-dialog
-      id="confirmCherrypickConflict"
-      class="confirmDialog"
-      on-confirm="_handleCherrypickConflictConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-cherrypick-conflict-dialog>
-    <gr-confirm-move-dialog
-      id="confirmMove"
-      class="confirmDialog"
-      on-confirm="_handleMoveConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      project="[[change.project]]"
-      hidden=""
-    ></gr-confirm-move-dialog>
-    <gr-confirm-revert-dialog
-      id="confirmRevertDialog"
-      class="confirmDialog"
-      on-confirm="_handleRevertDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-revert-dialog>
-    <gr-confirm-abandon-dialog
-      id="confirmAbandonDialog"
-      class="confirmDialog"
-      on-confirm="_handleAbandonDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-abandon-dialog>
-    <gr-confirm-submit-dialog
-      id="confirmSubmitDialog"
-      class="confirmDialog"
-      action="[[_revisionSubmitAction]]"
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleSubmitConfirm"
-      hidden=""
-    ></gr-confirm-submit-dialog>
-    <gr-dialog
-      id="createFollowUpDialog"
-      class="confirmDialog"
-      confirm-label="Create"
-      on-confirm="_handleCreateFollowUpChange"
-      on-cancel="_handleCloseCreateFollowUpChange"
-    >
-      <div class="header" slot="header">Create Follow-Up Change</div>
-      <div class="main" slot="main">
-        <gr-create-change-dialog
-          id="createFollowUpChange"
-          branch="[[change.branch]]"
-          base-change="[[change.id]]"
-          repo-name="[[change.project]]"
-          private-by-default="[[privateByDefault]]"
-        ></gr-create-change-dialog>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="confirmDeleteDialog"
-      class="confirmDialog"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      on-cancel="_handleConfirmDialogCancel"
-      on-confirm="_handleDeleteConfirm"
-    >
-      <div class="header" slot="header">Delete Change</div>
-      <div class="main" slot="main">
-        Do you really want to delete the change?
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="confirmDeleteEditDialog"
-      class="confirmDialog"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      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>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 26e2fb4..4602eac 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -1,24 +1,11 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-change-actions';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   createAccountWithId,
   createApproval,
@@ -48,28 +35,36 @@
   CommitId,
   NumericChangeId,
   PatchSetNum,
+  PatchSetNumber,
   RepoName,
   ReviewInput,
   TopicName,
 } from '../../../types/common';
 import {ActionType} from '../../../api/change-actions';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonFakeTimers} from 'sinon';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {UIActionInfo} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {appContext} from '../../../services/app-context';
-
-const basicFixture = fixtureFromElement('gr-change-actions');
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import {GrConfirmRebaseDialog} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import {GrConfirmRevertDialog} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 // TODO(dhruvsri): remove use of _populateRevertMessage as it's private
 suite('gr-change-actions tests', () => {
   let element: GrChangeActions;
 
   suite('basic tests', () => {
-    setup(() => {
+    setup(async () => {
       stubRestApi('getChangeRevisionActions').returns(
         Promise.resolve({
           cherrypick: {
@@ -123,81 +118,196 @@
       });
 
       sinon
-        .stub(getPluginLoader(), 'awaitPluginsLoaded')
+        .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
         .returns(Promise.resolve());
 
-      element = basicFixture.instantiate();
-      element.change = createChangeViewChange();
-      element.changeNum = 42 as NumericChangeId;
-      element.latestPatchNum = 2 as PatchSetNum;
-      element.actions = {
-        '/': {
-          method: HttpMethod.DELETE,
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
+      element.change = {
+        ...createChangeViewChange(),
+        actions: {
+          '/': {
+            method: HttpMethod.DELETE,
+            label: 'Delete Change',
+            title: 'Delete change X_X',
+            enabled: true,
+          },
         },
       };
+      element.changeNum = 42 as NumericChangeId;
+      element.latestPatchNum = 2 as PatchSetNum;
       element.account = {
         _account_id: 123 as AccountId,
       };
       stubRestApi('getRepoBranches').returns(Promise.resolve([]));
 
-      return element.reload();
+      await element.updateComplete;
+      await element.reload();
+    });
+
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div id="mainContent">
+            <span hidden="" id="actionLoadingMessage"> </span>
+            <section id="primaryActions">
+              <gr-tooltip-content
+                has-tooltip=""
+                position-below=""
+                title="Submit patch set 2 into master"
+              >
+                <gr-button
+                  aria-disabled="false"
+                  class="submit"
+                  data-action-key="submit"
+                  data-label="Submit"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  <gr-icon icon="done_all"></gr-icon>
+                  Submit
+                </gr-button>
+              </gr-tooltip-content>
+            </section>
+            <section id="secondaryActions">
+              <gr-tooltip-content
+                has-tooltip=""
+                position-below=""
+                title="Rebase onto tip of branch or parent change"
+              >
+                <gr-button
+                  aria-disabled="true"
+                  class="rebase"
+                  data-action-key="rebase"
+                  data-label="Rebase"
+                  disabled=""
+                  link=""
+                  role="button"
+                  tabindex="-1"
+                >
+                  <gr-icon icon="rebase"> </gr-icon>
+                  Rebase
+                </gr-button>
+              </gr-tooltip-content>
+            </section>
+            <gr-button
+              aria-disabled="false"
+              hidden=""
+              role="button"
+              tabindex="0"
+            >
+              Loading actions...
+            </gr-button>
+            <gr-dropdown id="moreActions" link="">
+              <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
+              <span id="moreMessage"> More </span>
+            </gr-dropdown>
+          </div>
+          <dialog id="actionsModal" tabindex="-1">
+            <gr-confirm-rebase-dialog class="confirmDialog" id="confirmRebase">
+            </gr-confirm-rebase-dialog>
+            <gr-confirm-cherrypick-dialog
+              class="confirmDialog"
+              id="confirmCherrypick"
+            >
+            </gr-confirm-cherrypick-dialog>
+            <gr-confirm-cherrypick-conflict-dialog
+              class="confirmDialog"
+              id="confirmCherrypickConflict"
+            >
+            </gr-confirm-cherrypick-conflict-dialog>
+            <gr-confirm-move-dialog class="confirmDialog" id="confirmMove">
+            </gr-confirm-move-dialog>
+            <gr-confirm-revert-dialog
+              class="confirmDialog"
+              id="confirmRevertDialog"
+            >
+            </gr-confirm-revert-dialog>
+            <gr-confirm-abandon-dialog
+              class="confirmDialog"
+              id="confirmAbandonDialog"
+            >
+            </gr-confirm-abandon-dialog>
+            <gr-confirm-submit-dialog
+              class="confirmDialog"
+              id="confirmSubmitDialog"
+            >
+            </gr-confirm-submit-dialog>
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Create"
+              id="createFollowUpDialog"
+              role="dialog"
+            >
+              <div class="header" slot="header">Create Follow-Up Change</div>
+              <div class="main" slot="main">
+                <gr-create-change-dialog id="createFollowUpChange">
+                </gr-create-change-dialog>
+              </div>
+            </gr-dialog>
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Delete"
+              confirm-on-enter=""
+              id="confirmDeleteDialog"
+              role="dialog"
+            >
+              <div class="header" slot="header">Delete Change</div>
+              <div class="main" slot="main">
+                Do you really want to delete the change?
+              </div>
+            </gr-dialog>
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Delete"
+              confirm-on-enter=""
+              id="confirmDeleteEditDialog"
+              role="dialog"
+            >
+              <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>
+          </dialog>
+        `
+      );
     });
 
     test('show-revision-actions event should fire', async () => {
-      const spy = sinon.spy(element, '_sendShowRevisionActions');
+      const spy = sinon.spy(element, 'sendShowRevisionActions');
       element.reload();
-      await flush();
+      await element.updateComplete;
       assert.isTrue(spy.called);
     });
 
     test('primary and secondary actions split properly', () => {
       // Submit should be the only primary action.
-      assert.equal(element._topLevelPrimaryActions!.length, 1);
-      assert.equal(element._topLevelPrimaryActions![0].label, 'Submit');
+      assert.equal(element.topLevelPrimaryActions!.length, 1);
+      assert.equal(element.topLevelPrimaryActions![0].label, 'Submit');
       assert.equal(
-        element._topLevelSecondaryActions!.length,
-        element._topLevelActions!.length - 1
+        element.topLevelSecondaryActions!.length,
+        element.topLevelActions!.length - 1
       );
     });
 
     test('revert submission action is skipped', () => {
       assert.equal(
-        element._allActionValues.filter(action => action.__key === 'submit')
+        element.allActionValues.filter(action => action.__key === 'submit')
           .length,
         1
       );
       assert.equal(
-        element._allActionValues.filter(
+        element.allActionValues.filter(
           action => action.__key === 'revert_submission'
         ).length,
         0
       );
     });
 
-    test('_shouldHideActions', () => {
-      assert.isTrue(element._shouldHideActions(undefined, true));
-      assert.isTrue(
-        element._shouldHideActions(
-          {base: [] as UIActionInfo[]} as PolymerDeepPropertyChange<
-            UIActionInfo[],
-            UIActionInfo[]
-          >,
-          false
-        )
-      );
-      assert.isFalse(
-        element._shouldHideActions(
-          {
-            base: [{__key: 'test'}] as UIActionInfo[],
-          } as PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
-          false
-        )
-      );
-    });
-
     test('plugin revision actions', async () => {
       const stub = stubRestApi('getChangeActionURL').returns(
         Promise.resolve('the-url')
@@ -206,7 +316,7 @@
         'plugin~action': {},
       };
       assert.isOk(element.revisionActions['plugin~action']);
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         stub.calledWith(
           element.changeNum,
@@ -228,7 +338,7 @@
         'plugin~action': {},
       };
       assert.isOk(element.actions['plugin~action']);
-      await flush();
+      await element.updateComplete;
       assert.isTrue(
         stub.calledWith(element.changeNum, undefined, '/plugin~action')
       );
@@ -265,7 +375,7 @@
     });
 
     test('hide revision action', async () => {
-      await flush();
+      await element.updateComplete;
       let buttonEl: Element | undefined = queryAndAssert(
         element,
         '[data-action-key="submit"]'
@@ -276,14 +386,8 @@
         element.RevisionActions.SUBMIT,
         true
       );
-      assert.lengthOf(element._hiddenActions, 1);
-      element.setActionHidden(
-        element.ActionType.REVISION,
-        element.RevisionActions.SUBMIT,
-        true
-      );
-      assert.lengthOf(element._hiddenActions, 1);
-      await flush();
+      assert.lengthOf(element.hiddenActions, 1);
+      await element.updateComplete;
       buttonEl = query(element, '[data-action-key="submit"]');
       assert.isNotOk(buttonEl);
 
@@ -292,52 +396,58 @@
         element.RevisionActions.SUBMIT,
         false
       );
-      await flush();
+      await element.updateComplete;
       buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
       assert.isFalse(buttonEl.hasAttribute('hidden'));
     });
 
     test('buttons exist', async () => {
-      element._loading = false;
-      await flush();
+      element.loading = false;
+      await element.updateComplete;
       const buttonEls = queryAll(element, 'gr-button');
-      const menuItems = element.$.moreActions.items;
+      const menuItems = queryAndAssert<GrDropdown>(
+        element,
+        '#moreActions'
+      ).items;
 
       // Total button number is one greater than the number of total actions
       // due to the existence of the overflow menu trigger.
       assert.equal(
-        buttonEls!.length + menuItems!.length,
-        element._allActionValues.length + 1
+        buttonEls.length + menuItems!.length,
+        element.allActionValues.length + 1
       );
       assert.isFalse(element.hidden);
     });
 
     test('delete buttons have explicit labels', async () => {
-      await flush();
-      const deleteItems = element.$.moreActions.items!.filter(item =>
-        item.id!.startsWith('delete')
-      );
+      await element.updateComplete;
+      const deleteItems = queryAndAssert<GrDropdown>(
+        element,
+        '#moreActions'
+      ).items!.filter(item => item.id!.startsWith('delete'));
       assert.equal(deleteItems.length, 1);
       assert.equal(deleteItems[0].name, 'Delete change');
     });
 
     test('get revision object from change', () => {
       const revObj = {
-        ...createRevision(),
-        _number: 2 as PatchSetNum,
+        ...createRevision(2),
         foo: 'bar',
       };
       const change = {
         ...createChangeViewChange(),
         revisions: {
-          rev1: {...createRevision(), _number: 1 as PatchSetNum},
+          rev1: createRevision(1),
           rev2: revObj,
         },
       };
-      assert.deepEqual(element._getRevision(change, 2 as PatchSetNum), revObj);
+      assert.deepEqual(
+        element.getRevision(change, 2 as PatchSetNumber),
+        revObj
+      );
     });
 
-    test('_actionComparator sort order', () => {
+    test('actionComparator sort order', () => {
       const actions = [
         {label: '123', __type: ActionType.CHANGE, __key: 'review'},
         {label: 'abc-ro', __type: ActionType.REVISION, __key: 'random'},
@@ -353,65 +463,89 @@
 
       const result = actions.slice();
       result.reverse();
-      result.sort(element._actionComparator.bind(element));
+      result.sort(element.actionComparator.bind(element));
       assert.deepEqual(result, actions);
     });
 
     test('submit change', async () => {
-      const showSpy = sinon.spy(element, '_showActionDialog');
+      const showSpy = sinon.spy(element, 'showActionDialog');
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
       element.change = {
         ...createChangeViewChange(),
         revisions: {
-          rev1: {...createRevision(), _number: 1 as PatchSetNum},
-          rev2: {...createRevision(), _number: 2 as PatchSetNum},
+          rev1: {...createRevision(), _number: 1 as PatchSetNumber},
+          rev2: {...createRevision(), _number: 2 as PatchSetNumber},
         },
       };
-      element.latestPatchNum = 2 as PatchSetNum;
+      element.latestPatchNum = 2 as PatchSetNumber;
 
-      const submitButton = queryAndAssert(
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="submit"]'
-      );
-      tap(submitButton);
+      ).click();
 
-      await flush();
-      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+      await element.updateComplete;
+      assert.isTrue(
+        showSpy.calledWith(
+          queryAndAssert<GrConfirmSubmitDialog>(element, '#confirmSubmitDialog')
+        )
+      );
     });
 
     test('submit change, tap on icon', async () => {
       const submitted = mockPromise();
       sinon
-        .stub(element.$.confirmSubmitDialog, 'resetFocus')
+        .stub(
+          queryAndAssert<GrConfirmSubmitDialog>(
+            element,
+            '#confirmSubmitDialog'
+          ),
+          'resetFocus'
+        )
         .callsFake(() => submitted.resolve());
       stubRestApi('getFromProjectLookup').returns(
         Promise.resolve('test' as RepoName)
       );
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
       element.change = {
         ...createChangeViewChange(),
         revisions: {
-          rev1: {...createRevision(), _number: 1 as PatchSetNum},
-          rev2: {...createRevision(), _number: 2 as PatchSetNum},
+          rev1: {...createRevision(), _number: 1 as PatchSetNumber},
+          rev2: {...createRevision(), _number: 2 as PatchSetNumber},
         },
       };
       element.latestPatchNum = 2 as PatchSetNum;
 
-      const submitIcon = queryAndAssert(
+      queryAndAssert<GrButton>(
         element,
-        'gr-button[data-action-key="submit"] iron-icon'
-      );
-      tap(submitIcon);
+        'gr-button[data-action-key="submit"] gr-icon'
+      ).click();
       await submitted;
     });
 
-    test('_handleSubmitConfirm', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(true);
-      element._handleSubmitConfirm();
+    test('correct icons', async () => {
+      element.loggedIn = true;
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(
+        element,
+        'gr-button[data-action-key="submit"] gr-icon'
+      );
+      queryAndAssert<GrButton>(
+        element,
+        'gr-button[data-action-key="rebase"] gr-icon'
+      );
+      queryAndAssert<GrButton>(
+        element,
+        'gr-button[data-action-key="edit"] gr-icon[filled]'
+      );
+    });
+
+    test('handleSubmitConfirm', () => {
+      const fireStub = sinon.stub(element, 'fireAction');
+      sinon.stub(element, 'canSubmitChange').returns(true);
+      element.handleSubmitConfirm();
       assert.isTrue(fireStub.calledOnce);
       assert.deepEqual(fireStub.lastCall.args, [
         '/submit',
@@ -420,77 +554,66 @@
       ]);
     });
 
-    test('_handleSubmitConfirm when not able to submit', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(false);
-      element._handleSubmitConfirm();
+    test('handleSubmitConfirm when not able to submit', () => {
+      const fireStub = sinon.stub(element, 'fireAction');
+      sinon.stub(element, 'canSubmitChange').returns(false);
+      element.handleSubmitConfirm();
       assert.isFalse(fireStub.called);
     });
 
     test('submit change with plugin hook', async () => {
-      sinon.stub(element, '_canSubmitChange').callsFake(() => false);
-      const fireActionStub = sinon.stub(element, '_fireAction');
-      await flush();
-      const submitButton = queryAndAssert(
+      sinon.stub(element, 'canSubmitChange').callsFake(() => false);
+      const fireActionStub = sinon.stub(element, 'fireAction');
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="submit"]'
-      );
-      tap(submitButton);
+      ).click();
       assert.equal(fireActionStub.callCount, 0);
     });
 
-    test('chain state', () => {
+    test('chain state', async () => {
       assert.equal(element._hasKnownChainState, false);
       element.hasParent = true;
+      await element.updateComplete;
       assert.equal(element._hasKnownChainState, true);
-      element.hasParent = false;
     });
 
-    test('_calculateDisabled', () => {
-      let hasKnownChainState = false;
+    test('calculateDisabled', () => {
       const action = {
         __key: 'rebase',
         enabled: true,
         __type: ActionType.CHANGE,
         label: 'l',
       };
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        true
-      );
+      element._hasKnownChainState = false;
+      assert.equal(element.calculateDisabled(action), true);
 
       action.__key = 'delete';
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        false
-      );
+      assert.equal(element.calculateDisabled(action), false);
 
       action.__key = 'rebase';
-      hasKnownChainState = true;
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        false
-      );
+      element._hasKnownChainState = true;
+      assert.equal(element.calculateDisabled(action), false);
 
       action.enabled = false;
-      assert.equal(
-        element._calculateDisabled(action, hasKnownChainState),
-        false
-      );
+      assert.equal(element.calculateDisabled(action), false);
     });
 
     test('rebase change', async () => {
-      const fireActionStub = sinon.stub(element, '_fireAction');
+      const fireActionStub = sinon.stub(element, 'fireAction');
       const fetchChangesStub = sinon
-        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .stub(
+          queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase'),
+          'fetchRecentChanges'
+        )
         .returns(Promise.resolve([]));
       element._hasKnownChainState = true;
-      await flush();
-      const rebaseButton = queryAndAssert(
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="rebase"]'
-      );
-      tap(rebaseButton);
+      ).click();
       const rebaseAction = {
         __key: 'rebase',
         __type: 'revision',
@@ -501,102 +624,97 @@
         title: 'Rebase onto tip of branch or parent change',
       };
       assert.isTrue(fetchChangesStub.called);
-      element._handleRebaseConfirm(
-        new CustomEvent('', {detail: {base: '1234'}})
+      element.handleRebaseConfirm(
+        new CustomEvent('', {
+          detail: {base: '1234', allowConflicts: false, rebaseChain: false},
+        })
       );
       assert.deepEqual(fireActionStub.lastCall.args, [
         '/rebase',
         assertUIActionInfo(rebaseAction),
         true,
-        {base: '1234'},
+        {base: '1234', allow_conflicts: false},
+        {allow_conflicts: false},
       ]);
     });
 
     test('rebase change fires reload event', async () => {
       const eventStub = sinon.stub(element, 'dispatchEvent');
-      element._handleResponse(
+      await element.handleResponse(
         {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
         new Response()
       );
-      await flush();
       assert.isTrue(eventStub.called);
       assert.equal(eventStub.lastCall.args[0].type, 'reload');
     });
 
     test("rebase dialog gets recent changes each time it's opened", async () => {
       const fetchChangesStub = sinon
-        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .stub(
+          queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase'),
+          'fetchRecentChanges'
+        )
         .returns(Promise.resolve([]));
       element._hasKnownChainState = true;
-      const rebaseButton = queryAndAssert(
+      await element.updateComplete;
+      const rebaseButton = queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="rebase"]'
       );
-      tap(rebaseButton);
+      rebaseButton.click();
+      await element.updateComplete;
       assert.isTrue(fetchChangesStub.calledOnce);
 
-      await flush();
-      element.$.confirmRebase.dispatchEvent(
+      await element.updateComplete;
+      queryAndAssert<GrConfirmRebaseDialog>(
+        element,
+        '#confirmRebase'
+      ).dispatchEvent(
         new CustomEvent('cancel', {
           composed: true,
           bubbles: true,
         })
       );
-      tap(rebaseButton);
+      rebaseButton.click();
       assert.isTrue(fetchChangesStub.calledTwice);
     });
 
     test('two dialogs are not shown at the same time', async () => {
       element._hasKnownChainState = true;
-      await flush();
-      const rebaseButton = queryAndAssert(
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
         element,
         'gr-button[data-action-key="rebase"]'
+      ).click();
+      await element.updateComplete;
+      assert.isFalse(
+        queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase').hidden
       );
-      tap(rebaseButton);
-      await flush();
-      assert.isFalse(element.$.confirmRebase.hidden);
       stubRestApi('getChanges').returns(Promise.resolve([]));
-      element._handleCherrypickTap();
-      await flush();
-      assert.isTrue(element.$.confirmRebase.hidden);
-      assert.isFalse(element.$.confirmCherrypick.hidden);
-    });
-
-    test('fullscreen-overlay-opened hides content', () => {
-      const spy = sinon.spy(element, '_handleHideBackgroundContent');
-      element.$.overlay.dispatchEvent(
-        new CustomEvent('fullscreen-overlay-opened', {
-          composed: true,
-          bubbles: true,
-        })
+      element.handleCherrypickTap();
+      await element.updateComplete;
+      assert.isTrue(
+        queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase').hidden
       );
-      assert.isTrue(spy.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('fullscreen-overlay-closed shows content', () => {
-      const spy = sinon.spy(element, '_handleShowBackgroundContent');
-      element.$.overlay.dispatchEvent(
-        new CustomEvent('fullscreen-overlay-closed', {
-          composed: true,
-          bubbles: true,
-        })
+      assert.isFalse(
+        queryAndAssert<GrConfirmCherrypickDialog>(element, '#confirmCherrypick')
+          .hidden
       );
-      assert.isTrue(spy.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
     });
 
-    test('_setReviewOnRevert', () => {
+    test('setReviewOnRevert', () => {
       const review = {labels: {Foo: 1, 'Bar-Baz': -2}};
       const changeId = 1234 as NumericChangeId;
       sinon
-        .stub(appContext.jsApiService, 'getReviewPostRevert')
+        .stub(
+          testResolver(pluginLoaderToken).jsApiService,
+          'getReviewPostRevert'
+        )
         .returns(review);
       const saveStub = stubRestApi('saveChangeReview').returns(
         Promise.resolve(new Response())
       );
-      const setReviewOnRevert = element._setReviewOnRevert(changeId) as Promise<
+      const setReviewOnRevert = element.setReviewOnRevert(changeId) as Promise<
         undefined | Response
       >;
       return setReviewOnRevert.then((_res: Response | undefined) => {
@@ -608,14 +726,14 @@
 
     suite('change edits', () => {
       test('disableEdit', async () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
+        element.editMode = false;
+        element.editBasedOnCurrentPatchSet = false;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        element.set('disableEdit', true);
-        await flush();
+        element.disableEdit = true;
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -631,32 +749,83 @@
       });
 
       test('shows confirm dialog for delete edit', async () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
+        await element.updateComplete;
 
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        element._handleDeleteEditTap();
-        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteEditDialog'),
-            'gr-button[primary]'
-          )
+        const fireActionStub = sinon.stub(element, 'fireAction');
+        element.handleDeleteEditTap();
+        assert.isFalse(
+          queryAndAssert<GrDialog>(element, '#confirmDeleteEditDialog').hidden
         );
-        await flush();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteEditDialog'),
+          'gr-button[primary]'
+        ).click();
+        await element.updateComplete;
 
         assert.equal(fireActionStub.lastCall.args[0], '/edit');
       });
 
+      test('all cached change edits get deleted on delete edit', async () => {
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
+        await element.updateComplete;
+
+        const storage = testResolver(storageServiceToken);
+        storage.setEditableContentItem(
+          'c42_ps2_index.php',
+          '<?php\necho 42_ps_2'
+        );
+        storage.setEditableContentItem(
+          'c42_psedit_index.php',
+          '<?php\necho 42_ps_edit'
+        );
+
+        assert.equal(
+          storage.getEditableContentItem('c42_ps2_index.php')!.message,
+          '<?php\necho 42_ps_2'
+        );
+        assert.equal(
+          storage.getEditableContentItem('c42_psedit_index.php')!.message,
+          '<?php\necho 42_ps_edit'
+        );
+
+        assert.isOk(storage.getEditableContentItem('c42_psedit_index.php')!);
+        assert.isOk(storage.getEditableContentItem('c42_ps2_index.php')!);
+        assert.isNotOk(storage.getEditableContentItem('c50_psedit_index.php')!);
+
+        const eraseEditableContentItemsForChangeEditSpy = sinon.spy(
+          storage,
+          'eraseEditableContentItemsForChangeEdit'
+        );
+        sinon.stub(element, 'fireAction');
+        element.handleDeleteEditTap();
+        assert.isFalse(
+          queryAndAssert<GrDialog>(element, '#confirmDeleteEditDialog').hidden
+        );
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteEditDialog'),
+          'gr-button[primary]'
+        ).click();
+        await element.updateComplete;
+        assert.isTrue(eraseEditableContentItemsForChangeEditSpy.called);
+        assert.isNotOk(storage.getEditableContentItem('c42_psedit_index.php')!);
+        assert.isNotOk(storage.getEditableContentItem('c42_ps2_index.php')!);
+      });
+
       test('edit patchset is loaded, needs rebase', async () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = false;
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -668,14 +837,15 @@
       });
 
       test('edit patchset is loaded, does not need rebase', async () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = true;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
         element.editBasedOnCurrentPatchSet = true;
-        await flush();
+        await element.updateComplete;
 
         assert.isOk(query(element, 'gr-button[data-action-key="publishEdit"]'));
         assert.isNotOk(
@@ -687,13 +857,14 @@
       });
 
       test('edit mode is loaded, no edit patchset', async () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', false);
+        element.loggedIn = true;
+        element.editMode = true;
+        element.editPatchsetLoaded = false;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -709,13 +880,14 @@
       });
 
       test('normal patch set', async () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
+        element.loggedIn = true;
+        element.editMode = false;
+        element.editPatchsetLoaded = false;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(
           query(element, 'gr-button[data-action-key="publishEdit"]')
@@ -731,16 +903,17 @@
       });
 
       test('edit action', async () => {
+        element.loggedIn = true;
         const editTapped = mockPromise();
         element.addEventListener('edit-tap', () => {
           editTapped.resolve();
         });
-        element.set('editMode', true);
+        element.editMode = true;
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
@@ -748,35 +921,53 @@
           ...createChangeViewChange(),
           status: ChangeStatus.MERGED,
         };
-        await flush();
+        await element.updateComplete;
 
         assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
         element.change = {
           ...createChangeViewChange(),
           status: ChangeStatus.NEW,
         };
-        element.set('editMode', false);
-        await flush();
+        element.editMode = false;
+        await element.updateComplete;
 
-        const editButton = queryAndAssert(
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="edit"]'
-        );
-        tap(editButton);
+        ).click();
         await editTapped;
       });
     });
 
+    test('edit action not shown for logged out user', async () => {
+      element.loggedIn = false;
+      element.editMode = false;
+      element.editPatchsetLoaded = false;
+      element.change = {
+        ...createChangeViewChange(),
+        status: ChangeStatus.NEW,
+      };
+      await element.updateComplete;
+
+      assert.isNotOk(
+        query(element, 'gr-button[data-action-key="publishEdit"]')
+      );
+      assert.isNotOk(query(element, 'gr-button[data-action-key="rebaseEdit"]'));
+      assert.isNotOk(query(element, 'gr-button[data-action-key="deleteEdit"]'));
+      assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+      assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+    });
+
     suite('cherry-pick', () => {
       let fireActionStub: sinon.SinonStub;
 
       setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+        fireActionStub = sinon.stub(element, 'fireAction');
         sinon.stub(window, 'alert');
       });
 
-      test('works', () => {
-        element._handleCherrypickTap();
+      test('works', async () => {
+        element.handleCherrypickTap();
         const action = {
           __key: 'cherrypick',
           __type: 'revision',
@@ -787,24 +978,41 @@
           title: 'Cherry pick change to a different branch',
         };
 
-        element._handleCherrypickConfirm();
+        element.handleCherrypickConfirm();
         assert.equal(fireActionStub.callCount, 0);
 
-        element.$.confirmCherrypick.branch = 'master' as BranchName;
-        element._handleCherrypickConfirm();
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).branch = 'master' as BranchName;
+        element.handleCherrypickConfirm();
         assert.equal(fireActionStub.callCount, 0); // Still needs a message.
 
         // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
-        element.$.confirmCherrypick.commitNum = '123' as CommitId;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitMessage = 'foo message';
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).changeStatus = ChangeStatus.NEW;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitNum = '123' as CommitId;
+        await element.updateComplete;
 
-        element._handleCherrypickConfirm();
+        element.handleCherrypickConfirm();
+        await element.updateComplete;
 
-        const autogrowEl = queryAndAssert(
-          element.$.confirmCherrypick,
+        const autogrowEl = queryAndAssert<IronAutogrowTextareaElement>(
+          queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          ),
           '#messageInput'
-        ) as IronAutogrowTextareaElement;
+        );
         assert.equal(autogrowEl.value, 'foo message');
 
         assert.deepEqual(fireActionStub.lastCall.args, [
@@ -820,8 +1028,8 @@
         ]);
       });
 
-      test('cherry pick even with conflicts', () => {
-        element._handleCherrypickTap();
+      test('cherry pick even with conflicts', async () => {
+        element.handleCherrypickTap();
         const action = {
           __key: 'cherrypick',
           __type: 'revision',
@@ -832,14 +1040,28 @@
           title: 'Cherry pick change to a different branch',
         };
 
-        element.$.confirmCherrypick.branch = 'master' as BranchName;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).branch = 'master' as BranchName;
 
         // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
-        element.$.confirmCherrypick.commitNum = '123' as CommitId;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitMessage = 'foo message';
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).changeStatus = ChangeStatus.NEW;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).commitNum = '123' as CommitId;
+        await element.updateComplete;
 
-        element._handleCherrypickConflictConfirm();
+        element.handleCherrypickConflictConfirm();
+        await element.updateComplete;
 
         assert.deepEqual(fireActionStub.lastCall.args, [
           '/cherrypick',
@@ -856,10 +1078,19 @@
 
       test('branch name cleared when re-open cherrypick', () => {
         const emptyBranchName = '';
-        element.$.confirmCherrypick.branch = 'master' as BranchName;
+        queryAndAssert<GrConfirmCherrypickDialog>(
+          element,
+          '#confirmCherrypick'
+        ).branch = 'master' as BranchName;
 
-        element._handleCherrypickTap();
-        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+        element.handleCherrypickTap();
+        assert.equal(
+          queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          ).branch,
+          emptyBranchName
+        );
       });
 
       suite('cherry pick topics', () => {
@@ -883,20 +1114,28 @@
         ];
         setup(async () => {
           stubRestApi('getChanges').returns(Promise.resolve(changes));
-          element._handleCherrypickTap();
-          await flush();
-          const radioButtons = queryAll(
-            element.$.confirmCherrypick,
+          element.handleCherrypickTap();
+          await element.updateComplete;
+          const confirmCherrypick = queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          );
+          await element.updateComplete;
+          const radioButtons = queryAll<HTMLInputElement>(
+            confirmCherrypick,
             "input[name='cherryPickOptions']"
           );
           assert.equal(radioButtons.length, 2);
-          tap(radioButtons[1]);
-          await flush();
+          radioButtons[1].click();
+          await element.updateComplete;
         });
 
         test('cherry pick topic dialog is rendered', async () => {
-          const dialog = element.$.confirmCherrypick;
-          await flush();
+          const dialog = queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          );
+          await element.updateComplete;
           const changesTable = queryAndAssert(dialog, 'table');
           const headers = Array.from(changesTable.querySelectorAll('th'));
           const expectedHeadings = [
@@ -932,11 +1171,14 @@
         });
 
         test('changes with duplicate project show an error', async () => {
-          const dialog = element.$.confirmCherrypick;
-          const error = queryAndAssert(
+          const dialog = queryAndAssert<GrConfirmCherrypickDialog>(
+            element,
+            '#confirmCherrypick'
+          );
+          const error = queryAndAssert<HTMLSpanElement>(
             dialog,
             '.error-message'
-          ) as HTMLSpanElement;
+          );
           assert.equal(error.innerText, '');
           dialog.updateChanges([
             {
@@ -954,7 +1196,7 @@
               project: 'A' as RepoName,
             },
           ]);
-          await flush();
+          await element.updateComplete;
           assert.equal(
             error.innerText,
             'Two changes cannot be of the same' + ' project'
@@ -966,8 +1208,8 @@
     suite('move change', () => {
       let fireActionStub: sinon.SinonStub;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         sinon.stub(window, 'alert');
         element.actions = {
           move: {
@@ -977,25 +1219,31 @@
             enabled: true,
           },
         };
+        await element.updateComplete;
       });
 
       test('works', () => {
-        element._handleMoveTap();
+        element.handleMoveTap();
 
-        element._handleMoveConfirm();
+        element.handleMoveConfirm();
         assert.equal(fireActionStub.callCount, 0);
 
-        element.$.confirmMove.branch = 'master' as BranchName;
-        element._handleMoveConfirm();
+        queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch =
+          'master' as BranchName;
+        element.handleMoveConfirm();
         assert.equal(fireActionStub.callCount, 1);
       });
 
       test('branch name cleared when re-open move', () => {
         const emptyBranchName = '';
-        element.$.confirmMove.branch = 'master' as BranchName;
+        queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch =
+          'master' as BranchName;
 
-        element._handleMoveTap();
-        assert.equal(element.$.confirmMove.branch, emptyBranchName);
+        element.handleMoveTap();
+        assert.equal(
+          queryAndAssert<GrConfirmMoveDialog>(element, '#confirmMove').branch,
+          emptyBranchName
+        );
       });
     });
 
@@ -1010,27 +1258,41 @@
           key
         );
         element.removeActionButton(key);
-        await flush();
+        await element.updateComplete;
         assert.notOk(query(element, '[data-action-key="' + key + '"]'));
         keyTapped.resolve();
       });
-      await flush();
-      tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
+      await element.updateComplete;
+      await element.updateComplete;
+      queryAndAssert<GrButton>(
+        element,
+        '[data-action-key="' + key + '"]'
+      ).click();
       await keyTapped;
     });
 
-    test('_setLoadingOnButtonWithKey top-level', () => {
+    test('setLoadingOnButtonWithKey top-level', async () => {
       const key = 'rebase';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Rebasing...');
+      const type = ActionType.REVISION;
+      const cleanup = element.setLoadingOnButtonWithKey({
+        __type: type,
+        __key: key,
+        label: 'label',
+      });
+      assert.equal(element.actionLoadingMessage, 'Rebasing...');
 
-      const button = queryAndAssert(
+      const button = queryAndAssert<GrButton>(
         element,
         '[data-action-key="' + key + '"]'
-      ) as GrButton;
+      );
+      const dialog = queryAndAssert<GrConfirmRebaseDialog>(
+        element,
+        'gr-confirm-rebase-dialog'
+      );
       assert.isTrue(button.hasAttribute('loading'));
       assert.isTrue(button.disabled);
+      await dialog.updateComplete;
+      assert.isTrue(dialog.disableActions);
 
       assert.isOk(cleanup);
       assert.isFunction(cleanup);
@@ -1038,29 +1300,35 @@
 
       assert.isFalse(button.hasAttribute('loading'));
       assert.isFalse(button.disabled);
-      assert.isNotOk(element._actionLoadingMessage);
+      assert.isNotOk(element.actionLoadingMessage);
+      await dialog.updateComplete;
+      assert.isFalse(dialog.disableActions);
     });
 
-    test('_setLoadingOnButtonWithKey overflow menu', () => {
+    test('setLoadingOnButtonWithKey overflow menu', () => {
       const key = 'cherrypick';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
-      assert.include(element._disabledMenuActions, 'cherrypick');
+      const type = ActionType.REVISION;
+      const cleanup = element.setLoadingOnButtonWithKey({
+        __type: type,
+        __key: key,
+        label: 'label',
+      });
+      assert.equal(element.actionLoadingMessage, 'Cherry-picking...');
+      assert.include(element.disabledMenuActions, 'cherrypick');
       assert.isFunction(cleanup);
 
       cleanup();
 
-      assert.notOk(element._actionLoadingMessage);
-      assert.notInclude(element._disabledMenuActions, 'cherrypick');
+      assert.notOk(element.actionLoadingMessage);
+      assert.notInclude(element.disabledMenuActions, 'cherrypick');
     });
 
     suite('abandon change', () => {
       let alertStub: sinon.SinonStub;
       let fireActionStub: sinon.SinonStub;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         alertStub = sinon.stub(window, 'alert');
         element.actions = {
           abandon: {
@@ -1070,43 +1338,63 @@
             enabled: true,
           },
         };
-        return element.reload();
+        await element.updateComplete;
+        // test
+        await element.reload();
       });
 
       test('abandon change with message', async () => {
         const newAbandonMsg = 'Test Abandon Message';
-        element.$.confirmAbandonDialog.message = newAbandonMsg;
-        await flush();
-        const abandonButton = queryAndAssert(
+        queryAndAssert<GrConfirmAbandonDialog>(
+          element,
+          '#confirmAbandonDialog'
+        ).message = newAbandonMsg;
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="abandon"]'
-        );
-        tap(abandonButton);
+        ).click();
 
-        assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+        assert.equal(
+          queryAndAssert<GrConfirmAbandonDialog>(
+            element,
+            '#confirmAbandonDialog'
+          ).message,
+          newAbandonMsg
+        );
       });
 
       test('abandon change with no message', async () => {
-        await flush();
-        const abandonButton = queryAndAssert(
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="abandon"]'
-        );
-        tap(abandonButton);
+        ).click();
 
-        assert.equal(element.$.confirmAbandonDialog.message, '');
+        assert.equal(
+          queryAndAssert<GrConfirmAbandonDialog>(
+            element,
+            '#confirmAbandonDialog'
+          ).message,
+          ''
+        );
       });
 
       test('works', () => {
-        element.$.confirmAbandonDialog.message = 'original message';
-        const restoreButton = queryAndAssert(
+        queryAndAssert<GrConfirmAbandonDialog>(
+          element,
+          '#confirmAbandonDialog'
+        ).message = 'original message';
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="abandon"]'
-        );
-        tap(restoreButton);
+        ).click();
 
-        element.$.confirmAbandonDialog.message = 'foo message';
-        element._handleAbandonDialogConfirm();
+        queryAndAssert<GrConfirmAbandonDialog>(
+          element,
+          '#confirmAbandonDialog'
+        ).message = 'foo message';
+        element.handleAbandonDialogConfirm();
         assert.notOk(alertStub.called);
 
         const action = {
@@ -1132,29 +1420,81 @@
     suite('revert change', () => {
       let fireActionStub: sinon.SinonStub;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         element.commitMessage = 'random commit message';
-        element.change!.current_revision = 'abcdef' as CommitId;
-        element.actions = {
-          revert: {
-            method: HttpMethod.POST,
-            label: 'Revert',
-            title: 'Revert the change',
-            enabled: true,
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abcdef' as CommitId,
+          actions: {
+            revert: {
+              method: HttpMethod.POST,
+              label: 'Revert',
+              title: 'Revert the change',
+              enabled: true,
+            },
           },
         };
-        return element.reload();
+        await element.updateComplete;
+        // test
+        await element.reload();
+      });
+
+      test('revert change payload', async () => {
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
+          element,
+          'gr-button[data-action-key="revert"]'
+        ).click();
+        const revertAction = {
+          __key: 'revert',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.POST,
+          label: 'Revert',
+          title: 'Revert the change',
+          enabled: true,
+        };
+        queryAndAssert(element, 'gr-confirm-revert-dialog').dispatchEvent(
+          new CustomEvent('confirm', {
+            detail: {
+              message: 'foo message',
+              revertType: 1,
+            },
+          })
+        );
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/revert',
+          assertUIActionInfo(revertAction),
+          false,
+          {
+            message: 'foo message',
+          },
+        ]);
       });
 
       test('revert change with plugin hook', async () => {
         const newRevertMsg = 'Modified revert msg';
         sinon
-          .stub(element.$.confirmRevertDialog, '_modifyRevertMsg')
+          .stub(
+            queryAndAssert<GrConfirmRevertDialog>(
+              element,
+              '#confirmRevertDialog'
+            ),
+            'modifyRevertMsg'
+          )
           .callsFake(() => newRevertMsg);
         element.change = {
           ...createChangeViewChange(),
           current_revision: 'abc1234' as CommitId,
+          actions: {
+            revert: {
+              method: HttpMethod.POST,
+              label: 'Revert',
+              title: 'Revert the change',
+              enabled: true,
+            },
+          },
         };
         stubRestApi('getChanges').returns(
           Promise.resolve([
@@ -1174,27 +1514,41 @@
         );
         sinon
           .stub(
-            element.$.confirmRevertDialog,
-            '_populateRevertSubmissionMessage'
+            queryAndAssert<GrConfirmRevertDialog>(
+              element,
+              '#confirmRevertDialog'
+            ),
+            'populateRevertSubmissionMessage'
           )
           .callsFake(() => 'original msg');
-        await flush();
-        const revertButton = queryAndAssert(
+        await element.updateComplete;
+        queryAndAssert<GrButton>(
           element,
           'gr-button[data-action-key="revert"]'
+        ).click();
+        await element.updateComplete;
+        assert.equal(
+          queryAndAssert<GrConfirmRevertDialog>(element, '#confirmRevertDialog')
+            .message,
+          newRevertMsg
         );
-        tap(revertButton);
-        await flush();
-        assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
       });
 
       suite('revert change submitted together', () => {
         let getChangesStub: sinon.SinonStub;
-        setup(() => {
+        setup(async () => {
           element.change = {
             ...createChangeViewChange(),
             submission_id: '199 0' as ChangeSubmissionId,
             current_revision: '2000' as CommitId,
+            actions: {
+              revert: {
+                method: HttpMethod.POST,
+                label: 'Revert',
+                title: 'Revert the change',
+                enabled: true,
+              },
+            },
           };
           getChangesStub = stubRestApi('getChanges').returns(
             Promise.resolve([
@@ -1212,25 +1566,29 @@
               },
             ])
           );
+          await element.updateComplete;
         });
 
         test('confirm revert dialog shows both options', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
-          );
-          tap(revertButton);
-          await flush();
+          ).click();
+          await element.updateComplete;
           assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          const revertSingleChangeLabel = queryAndAssert(
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
+          );
+          await element.updateComplete;
+          const revertSingleChangeLabel = queryAndAssert<HTMLLabelElement>(
             confirmRevertDialog,
             '.revertSingleChange'
-          ) as HTMLLabelElement;
-          const revertSubmissionLabel = queryAndAssert(
+          );
+          const revertSubmissionLabel = queryAndAssert<HTMLLabelElement>(
             confirmRevertDialog,
             '.revertSubmission'
-          ) as HTMLLabelElement;
+          );
           assert(
             revertSingleChangeLabel.innerText.trim() === 'Revert single change'
           );
@@ -1242,55 +1600,60 @@
             'Revert submission 199 0' +
             '\n\n' +
             'Reason for revert: <INSERT REASONING HERE>' +
-            '\n' +
-            'Reverted Changes:' +
-            '\n' +
-            '1234567890:random' +
-            '\n' +
-            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-            '\n';
-          assert.equal(confirmRevertDialog._message, expectedMsg);
-          const radioInputs = queryAll(
+            '\n\n' +
+            'Reverted changes: /q/submissionid:199+0\n';
+          assert.equal(confirmRevertDialog.message, expectedMsg);
+          const radioInputs = queryAll<HTMLInputElement>(
             confirmRevertDialog,
             'input[name="revertOptions"]'
           );
-          tap(radioInputs[0]);
-          await flush();
+          radioInputs[0].click();
+          await element.updateComplete;
           expectedMsg =
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
             ' for revert: <INSERT REASONING HERE>\n';
-          assert.equal(confirmRevertDialog._message, expectedMsg);
+          assert.equal(confirmRevertDialog.message, expectedMsg);
         });
 
         test('submit fails if message is not edited', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          await flush();
-          const confirmButton = queryAndAssert(
-            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+          await element.updateComplete;
+          queryAndAssert<GrButton>(
+            queryAndAssert(
+              queryAndAssert<GrConfirmRevertDialog>(
+                element,
+                '#confirmRevertDialog'
+              ),
+              'gr-dialog'
+            ),
             '#confirm'
-          );
-          tap(confirmButton);
-          await flush();
-          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          ).click();
+          await element.updateComplete;
+          assert.isTrue(confirmRevertDialog.showErrorMessage);
           assert.isFalse(fireStub.called);
         });
 
         test('message modification is retained on switching', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          await element.updateComplete;
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          tap(revertButton);
-          await flush();
-          const radioInputs = queryAll(
+          await element.updateComplete;
+          const radioInputs = queryAll<HTMLInputElement>(
             confirmRevertDialog,
             'input[name="revertOptions"]'
           );
@@ -1298,40 +1661,45 @@
             'Revert submission 199 0' +
             '\n\n' +
             'Reason for revert: <INSERT REASONING HERE>' +
-            '\n' +
-            'Reverted Changes:' +
-            '\n' +
-            '1234567890:random' +
-            '\n' +
-            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-            '\n';
+            '\n\n' +
+            'Reverted changes: /q/submissionid:199+0\n';
           const singleChangeMsg =
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
             ' for revert: <INSERT REASONING HERE>\n';
-          assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+          assert.equal(confirmRevertDialog.message, revertSubmissionMsg);
           const newRevertMsg = revertSubmissionMsg + 'random';
           const newSingleChangeMsg = singleChangeMsg + 'random';
-          confirmRevertDialog._message = newRevertMsg;
-          tap(radioInputs[0]);
-          await flush();
-          assert.equal(confirmRevertDialog._message, singleChangeMsg);
-          confirmRevertDialog._message = newSingleChangeMsg;
-          tap(radioInputs[1]);
-          await flush();
-          assert.equal(confirmRevertDialog._message, newRevertMsg);
-          tap(radioInputs[0]);
-          await flush();
-          assert.equal(confirmRevertDialog._message, newSingleChangeMsg);
+          confirmRevertDialog.message = newRevertMsg;
+          await element.updateComplete;
+          radioInputs[0].click();
+          await element.updateComplete;
+          assert.equal(confirmRevertDialog.message, singleChangeMsg);
+          confirmRevertDialog.message = newSingleChangeMsg;
+          await element.updateComplete;
+          radioInputs[1].click();
+          await element.updateComplete;
+          assert.equal(confirmRevertDialog.message, newRevertMsg);
+          radioInputs[0].click();
+          await element.updateComplete;
+          assert.equal(confirmRevertDialog.message, newSingleChangeMsg);
         });
       });
 
       suite('revert single change', () => {
-        setup(() => {
+        setup(async () => {
           element.change = {
             ...createChangeViewChange(),
             submission_id: '199' as ChangeSubmissionId,
             current_revision: '2000' as CommitId,
+            actions: {
+              revert: {
+                method: HttpMethod.POST,
+                label: 'Revert',
+                title: 'Revert the change',
+                enabled: true,
+              },
+            },
           };
           stubRestApi('getChanges').returns(
             Promise.resolve([
@@ -1343,35 +1711,45 @@
               },
             ])
           );
+          await element.updateComplete;
         });
 
         test('submit fails if message is not edited', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          tap(revertButton);
           const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          await flush();
-          const confirmButton = queryAndAssert(
-            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+          await element.updateComplete;
+          queryAndAssert<GrButton>(
+            queryAndAssert(
+              queryAndAssert<GrConfirmRevertDialog>(
+                element,
+                '#confirmRevertDialog'
+              ),
+              'gr-dialog'
+            ),
             '#confirm'
-          );
-          tap(confirmButton);
-          await flush();
-          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          ).click();
+          await element.updateComplete;
+          assert.isTrue(confirmRevertDialog.showErrorMessage);
           assert.isFalse(fireStub.called);
         });
 
         test('confirm revert dialog shows no radio button', async () => {
-          const revertButton = queryAndAssert(
+          queryAndAssert<GrButton>(
             element,
             'gr-button[data-action-key="revert"]'
+          ).click();
+          await element.updateComplete;
+          const confirmRevertDialog = queryAndAssert<GrConfirmRevertDialog>(
+            element,
+            '#confirmRevertDialog'
           );
-          tap(revertButton);
-          await flush();
-          const confirmRevertDialog = element.$.confirmRevertDialog;
           const radioInputs = queryAll(
             confirmRevertDialog,
             'input[name="revertOptions"]'
@@ -1381,15 +1759,30 @@
             'Revert "random commit message"\n\n' +
             'This reverts commit 2000.\n\nReason ' +
             'for revert: <INSERT REASONING HERE>\n';
-          assert.equal(confirmRevertDialog._message, msg);
-          const editedMsg = msg + 'hello';
-          confirmRevertDialog._message += 'hello';
-          const confirmButton = queryAndAssert(
-            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+          assert.equal(confirmRevertDialog.message, msg);
+          let editedMsg = msg + 'hello';
+          confirmRevertDialog.message += 'hello';
+          const confirmButton = queryAndAssert<GrButton>(
+            queryAndAssert(
+              queryAndAssert<GrConfirmRevertDialog>(
+                element,
+                '#confirmRevertDialog'
+              ),
+              'gr-dialog'
+            ),
             '#confirm'
           );
-          tap(confirmButton);
-          await flush();
+          confirmButton.click();
+          await element.updateComplete;
+          // Contains generic template reason so doesn't submit
+          assert.isFalse(fireActionStub.called);
+          confirmRevertDialog.message = confirmRevertDialog.message.replace(
+            '<INSERT REASONING HERE>',
+            ''
+          );
+          editedMsg = editedMsg.replace('<INSERT REASONING HERE>', '');
+          confirmButton.click();
+          await element.updateComplete;
           assert.equal(fireActionStub.getCall(0).args[0], '/revert');
           assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
           assert.equal(fireActionStub.getCall(0).args[3].message, editedMsg);
@@ -1398,7 +1791,7 @@
     });
 
     suite('mark change private', () => {
-      setup(() => {
+      setup(async () => {
         const privateAction = {
           __key: 'private',
           __type: 'change',
@@ -1418,34 +1811,41 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        return element.reload();
+        await element.updateComplete;
+        await element.reload();
       });
 
       test(
         'make sure the mark private change button is not outside of the ' +
           'overflow menu',
         async () => {
-          await flush();
+          await element.updateComplete;
           assert.isNotOk(query(element, '[data-action-key="private"]'));
         }
       );
 
       test('private change', async () => {
-        await flush();
+        await element.updateComplete;
         assert.isOk(
-          query(element.$.moreActions, 'span[data-id="private-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private-change"]'
+          )
         );
         element.setActionOverflow(ActionType.CHANGE, 'private', false);
-        await flush();
+        await element.updateComplete;
         assert.isOk(query(element, '[data-action-key="private"]'));
         assert.isNotOk(
-          query(element.$.moreActions, 'span[data-id="private-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private-change"]'
+          )
         );
       });
     });
 
     suite('unmark private change', () => {
-      setup(() => {
+      setup(async () => {
         const unmarkPrivateAction = {
           __key: 'private.delete',
           __type: 'change',
@@ -1465,28 +1865,35 @@
         element.changeNum = 2 as NumericChangeId;
         element.latestPatchNum = 2 as PatchSetNum;
 
-        return element.reload();
+        await element.updateComplete;
+        await element.reload();
       });
 
       test(
         'make sure the unmark private change button is not outside of the ' +
           'overflow menu',
         async () => {
-          await flush();
+          await element.updateComplete;
           assert.isNotOk(query(element, '[data-action-key="private.delete"]'));
         }
       );
 
       test('unmark the private change', async () => {
-        await flush();
+        await element.updateComplete;
         assert.isOk(
-          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private.delete-change"]'
+          )
         );
         element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
-        await flush();
+        await element.updateComplete;
         assert.isOk(query(element, '[data-action-key="private.delete"]'));
         assert.isNotOk(
-          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+          query(
+            queryAndAssert<GrDropdown>(element, '#moreActions'),
+            'span[data-id="private.delete-change"]'
+          )
         );
       });
     });
@@ -1495,141 +1902,56 @@
       let fireActionStub: sinon.SinonStub;
       let deleteAction: ActionInfo;
 
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        element.change = {
-          ...createChangeViewChange(),
-          current_revision: 'abc1234' as CommitId,
-        };
+      setup(async () => {
+        fireActionStub = sinon.stub(element, 'fireAction');
         deleteAction = {
           method: HttpMethod.DELETE,
           label: 'Delete Change',
           title: 'Delete change X_X',
           enabled: true,
         };
-        element.actions = {
-          '/': deleteAction,
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          actions: {
+            '/': deleteAction,
+          },
         };
+        await element.updateComplete;
       });
 
       test('does not delete on action', () => {
-        element._handleDeleteTap();
+        element.handleDeleteTap();
         assert.isFalse(fireActionStub.called);
       });
 
       test('shows confirm dialog', async () => {
-        element._handleDeleteTap();
+        element.handleDeleteTap();
         assert.isFalse(
-          (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
+          queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').hidden
         );
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteDialog'),
-            'gr-button[primary]'
-          )
-        );
-        await flush();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteDialog'),
+          'gr-button[primary]'
+        ).click();
+        await element.updateComplete;
         assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
       });
 
       test('hides delete confirm on cancel', async () => {
-        element._handleDeleteTap();
-        tap(
-          queryAndAssert(
-            queryAndAssert(element, '#confirmDeleteDialog'),
-            'gr-button:not([primary])'
-          )
-        );
-        await flush();
+        element.handleDeleteTap();
+        queryAndAssert<GrButton>(
+          queryAndAssert(element, '#confirmDeleteDialog'),
+          'gr-button:not([primary])'
+        ).click();
+        await element.updateComplete;
         assert.isTrue(
-          (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
+          queryAndAssert<GrDialog>(element, '#confirmDeleteDialog').hidden
         );
         assert.isFalse(fireActionStub.called);
       });
     });
 
-    suite('ignore change', () => {
-      setup(async () => {
-        sinon.stub(element, '_fireAction');
-
-        const IgnoreAction = {
-          __key: 'ignore',
-          __type: 'change',
-          __primary: false,
-          method: HttpMethod.PUT,
-          label: 'Ignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          ignore: IgnoreAction,
-        };
-
-        element.changeNum = 2 as NumericChangeId;
-        element.latestPatchNum = 2 as PatchSetNum;
-
-        await element.reload();
-        await flush();
-      });
-
-      test('make sure the ignore button is not outside of the overflow menu', () => {
-        assert.isNotOk(query(element, '[data-action-key="ignore"]'));
-      });
-
-      test('ignoring change', async () => {
-        queryAndAssert(element.$.moreActions, 'span[data-id="ignore-change"]');
-        element.setActionOverflow(ActionType.CHANGE, 'ignore', false);
-        await flush();
-        queryAndAssert(element, '[data-action-key="ignore"]');
-        assert.isNotOk(
-          query(element.$.moreActions, 'span[data-id="ignore-change"]')
-        );
-      });
-    });
-
-    suite('unignore change', () => {
-      setup(async () => {
-        sinon.stub(element, '_fireAction');
-
-        const UnignoreAction = {
-          __key: 'unignore',
-          __type: 'change',
-          __primary: false,
-          method: HttpMethod.PUT,
-          label: 'Unignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unignore: UnignoreAction,
-        };
-
-        element.changeNum = 2 as NumericChangeId;
-        element.latestPatchNum = 2 as PatchSetNum;
-
-        await element.reload();
-        await flush();
-      });
-
-      test('unignore button is not outside of the overflow menu', () => {
-        assert.isNotOk(query(element, '[data-action-key="unignore"]'));
-      });
-
-      test('unignoring change', async () => {
-        assert.isOk(
-          query(element.$.moreActions, 'span[data-id="unignore-change"]')
-        );
-        element.setActionOverflow(ActionType.CHANGE, 'unignore', false);
-        await flush();
-        assert.isOk(query(element, '[data-action-key="unignore"]'));
-        assert.isNotOk(
-          query(element.$.moreActions, 'span[data-id="unignore-change"]')
-        );
-      });
-    });
-
     suite('quick approve', () => {
       setup(async () => {
         element.change = {
@@ -1648,7 +1970,7 @@
             foo: ['-1', ' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
       });
 
       test('added when can approve', () => {
@@ -1669,7 +1991,7 @@
 
         // Assert approve button gets removed from list of buttons.
         element.hideQuickApproveAction();
-        await flush();
+        await element.updateComplete;
         const approveButtonUpdated = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1679,8 +2001,10 @@
       });
 
       test('is first in list of secondary actions', () => {
-        const approveButton =
-          element.$.secondaryActions.querySelector('gr-button');
+        const approveButton = queryAndAssert<HTMLElement>(
+          element,
+          '#secondaryActions'
+        ).querySelector('gr-button');
         assert.equal(approveButton!.getAttribute('data-label'), 'foo+1');
       });
 
@@ -1690,7 +2014,7 @@
           status: ChangeStatus.MERGED,
         };
 
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1712,7 +2036,7 @@
             foo: [' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1731,7 +2055,7 @@
             bar: [],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1739,10 +2063,39 @@
         assert.isNotOk(approveButton);
       });
 
+      test('added even when label is optional', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              optional: true,
+              values: {
+                '-1': '',
+                ' 0': '',
+                '+1': '',
+              },
+            },
+          },
+          permitted_labels: {
+            'Code-Review': ['-1', ' 0', '+1'],
+          },
+        };
+        await element.updateComplete;
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isOk(approveButton);
+      });
+
       test('approves when tapped', async () => {
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        tap(queryAndAssert(element, "gr-button[data-action-key='review']"));
-        await flush();
+        const fireActionStub = sinon.stub(element, 'fireAction');
+        queryAndAssert<GrButton>(
+          element,
+          "gr-button[data-action-key='review']"
+        ).click();
+        await element.updateComplete;
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
         const payload = fireActionStub.lastCall.args[3];
@@ -1762,7 +2115,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1792,7 +2145,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1818,7 +2171,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1844,7 +2197,7 @@
             bar: [' 0', '+1'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1870,7 +2223,7 @@
             bar: [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = queryAndAssert(
           element,
           "gr-button[data-action-key='review']"
@@ -1896,8 +2249,8 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
-        const approveButton = queryAndAssert(
+        await element.updateComplete;
+        const approveButton = queryAndAssert<GrButton>(
           element,
           "gr-button[data-action-key='review']"
         );
@@ -1929,7 +2282,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1956,7 +2309,7 @@
             'Code-Review': [' 0', '+1', '+2'],
           },
         };
-        await flush();
+        await element.updateComplete;
         const approveButton = query(
           element,
           "gr-button[data-action-key='review']"
@@ -1969,8 +2322,8 @@
       const handler = sinon.stub();
       element.addEventListener('download-tap', handler);
       assert.ok(element.revisionActions.download);
-      element._handleDownloadTap();
-      await flush();
+      element.handleDownloadTap();
+      await element.updateComplete;
 
       assert.isTrue(handler.called);
     });
@@ -1983,26 +2336,26 @@
       assert.isFalse(reloadStub.called);
     });
 
-    test('_toSentenceCase', () => {
-      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
-      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
-      assert.equal(element._toSentenceCase('b'), 'B');
-      assert.equal(element._toSentenceCase(''), '');
-      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
+    test('toSentenceCase', () => {
+      assert.equal(element.toSentenceCase('blah blah'), 'Blah blah');
+      assert.equal(element.toSentenceCase('BLAH BLAH'), 'Blah blah');
+      assert.equal(element.toSentenceCase('b'), 'B');
+      assert.equal(element.toSentenceCase(''), '');
+      assert.equal(element.toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
     });
 
     suite('setActionOverflow', () => {
       test('move action from overflow', async () => {
         assert.isNotOk(query(element, '[data-action-key="cherrypick"]'));
         assert.strictEqual(
-          element.$.moreActions!.items![0].id,
+          queryAndAssert<GrDropdown>(element, '#moreActions').items![0].id,
           'cherrypick-revision'
         );
         element.setActionOverflow(ActionType.REVISION, 'cherrypick', false);
-        await flush();
+        await element.updateComplete;
         assert.isOk(query(element, '[data-action-key="cherrypick"]'));
         assert.notEqual(
-          element.$.moreActions!.items![0].id,
+          queryAndAssert<GrDropdown>(element, '#moreActions').items![0].id,
           'cherrypick-revision'
         );
       });
@@ -2010,15 +2363,15 @@
       test('move action to overflow', async () => {
         assert.isOk(query(element, '[data-action-key="submit"]'));
         element.setActionOverflow(ActionType.REVISION, 'submit', true);
-        await flush();
+        await element.updateComplete;
         assert.isNotOk(query(element, '[data-action-key="submit"]'));
         assert.strictEqual(
-          element.$.moreActions.items![3].id,
+          queryAndAssert<GrDropdown>(element, '#moreActions').items![3].id,
           'submit-revision'
         );
       });
 
-      suite('_waitForChangeReachable', () => {
+      suite('waitForChangeReachable', () => {
         let clock: SinonFakeTimers;
         setup(() => {
           clock = sinon.useFakeTimers();
@@ -2039,13 +2392,13 @@
         const tickAndFlush = async (repetitions: number) => {
           for (let i = 1; i <= repetitions; i++) {
             clock.tick(1000);
-            await flush();
+            await element.updateComplete;
           }
         };
 
         test('succeed', async () => {
           stubRestApi('getChange').callsFake(makeGetChange(5));
-          const promise = element._waitForChangeReachable(
+          const promise = element.waitForChangeReachable(
             123 as NumericChangeId
           );
           tickAndFlush(5);
@@ -2055,7 +2408,7 @@
 
         test('fail', async () => {
           stubRestApi('getChange').callsFake(makeGetChange(6));
-          const promise = element._waitForChangeReachable(
+          const promise = element.waitForChangeReachable(
             123 as NumericChangeId
           );
           tickAndFlush(6);
@@ -2065,14 +2418,14 @@
       });
     });
 
-    suite('_send', () => {
+    suite('send', () => {
       let cleanup: sinon.SinonStub;
       const payload = {foo: 'bar'};
       let onShowError: sinon.SinonStub;
       let onShowAlert: sinon.SinonStub;
       let getResponseObjectStub: sinon.SinonStub;
 
-      setup(() => {
+      setup(async () => {
         cleanup = sinon.stub();
         element.changeNum = 42 as NumericChangeId;
         element.latestPatchNum = 12 as PatchSetNum;
@@ -2081,12 +2434,13 @@
           revisions: createRevisions(element.latestPatchNum as number),
           messages: createChangeMessages(1),
         };
-        element.change!._number = 42 as NumericChangeId;
+        element.change._number = 42 as NumericChangeId;
+        await element.updateComplete;
 
         onShowError = sinon.stub();
         element.addEventListener('show-error', onShowError);
         onShowAlert = sinon.stub();
-        element.addEventListener('show-alert', onShowAlert);
+        element.addEventListener(EventType.SHOW_ALERT, onShowAlert);
       });
 
       suite('happy path', () => {
@@ -2104,11 +2458,10 @@
           sendStub = stubRestApi('executeChangeAction').returns(
             Promise.resolve(new Response())
           );
-          sinon.stub(GerritNav, 'navigateToChange');
         });
 
         test('change action', async () => {
-          await element._send(
+          await element.send(
             HttpMethod.DELETE,
             payload,
             '/endpoint',
@@ -2130,7 +2483,7 @@
         });
 
         suite('show revert submission dialog', () => {
-          setup(() => {
+          setup(async () => {
             element.change!.submission_id = '199' as ChangeSubmissionId;
             element.change!.current_revision = '2000' as CommitId;
             stubRestApi('getChanges').returns(
@@ -2149,23 +2502,23 @@
                 },
               ])
             );
+            await element.updateComplete;
           });
         });
 
         suite('single changes revert', () => {
-          let navigateToSearchQueryStub: sinon.SinonStub;
+          let setUrlStub: sinon.SinonStub;
           setup(() => {
             getResponseObjectStub.returns(
-              Promise.resolve({revert_changes: [{change_id: 12345}]})
+              Promise.resolve({
+                revert_changes: [{change_id: 12345, topic: 'T'}],
+              })
             );
-            navigateToSearchQueryStub = sinon.stub(
-              GerritNav,
-              'navigateToSearchQuery'
-            );
+            setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
           });
 
           test('revert submission single change', async () => {
-            await element._send(
+            await element.send(
               HttpMethod.POST,
               {message: 'Revert submission'},
               '/revert_submission',
@@ -2173,7 +2526,7 @@
               cleanup,
               {} as UIActionInfo
             );
-            await element._handleResponse(
+            await element.handleResponse(
               {
                 __key: 'revert_submission',
                 __type: ActionType.CHANGE,
@@ -2181,13 +2534,14 @@
               },
               new Response()
             );
-            assert.isTrue(navigateToSearchQueryStub.called);
+            assert.isTrue(setUrlStub.called);
+            assert.equal(setUrlStub.lastCall.args[0], '/q/topic:"T"');
           });
         });
 
         suite('multiple changes revert', () => {
           let showActionDialogStub: sinon.SinonStub;
-          let navigateToSearchQueryStub: sinon.SinonStub;
+          let setUrlStub: sinon.SinonStub;
           setup(() => {
             getResponseObjectStub.returns(
               Promise.resolve({
@@ -2197,15 +2551,12 @@
                 ],
               })
             );
-            showActionDialogStub = sinon.stub(element, '_showActionDialog');
-            navigateToSearchQueryStub = sinon.stub(
-              GerritNav,
-              'navigateToSearchQuery'
-            );
+            showActionDialogStub = sinon.stub(element, 'showActionDialog');
+            setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
           });
 
           test('revert submission multiple change', async () => {
-            await element._send(
+            await element.send(
               HttpMethod.POST,
               {message: 'Revert submission'},
               '/revert_submission',
@@ -2213,7 +2564,7 @@
               cleanup,
               {} as UIActionInfo
             );
-            await element._handleResponse(
+            await element.handleResponse(
               {
                 __key: 'revert_submission',
                 __type: ActionType.CHANGE,
@@ -2222,12 +2573,13 @@
               new Response()
             );
             assert.isFalse(showActionDialogStub.called);
-            assert.isTrue(navigateToSearchQueryStub.calledWith('topic: T'));
+            assert.isTrue(setUrlStub.called);
+            assert.equal(setUrlStub.lastCall.args[0], '/q/topic:"T"');
           });
         });
 
         test('revision action', async () => {
-          await element._send(
+          await element.send(
             HttpMethod.DELETE,
             payload,
             '/endpoint',
@@ -2258,7 +2610,7 @@
           const sendStub = stubRestApi('executeChangeAction');
 
           return element
-            ._send(
+            .send(
               HttpMethod.DELETE,
               payload,
               '/endpoint',
@@ -2285,14 +2637,15 @@
           );
           const sendStub = stubRestApi('executeChangeAction').callsFake(
             (_num, _method, _patchNum, _endpoint, _payload, onErr) => {
+              // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
               onErr!();
               return Promise.resolve(undefined);
             }
           );
-          const handleErrorStub = sinon.stub(element, '_handleResponseError');
+          const handleErrorStub = sinon.stub(element, 'handleResponseError');
 
           return element
-            ._send(
+            .send(
               HttpMethod.DELETE,
               payload,
               '/endpoint',
@@ -2310,12 +2663,12 @@
       });
     });
 
-    test('_handleAction reports', () => {
-      sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_handleChangeAction');
+    test('handleAction reports', () => {
+      sinon.stub(element, 'fireAction');
+      sinon.stub(element, 'handleChangeAction');
 
       const reportStub = stubReporting('reportInteraction');
-      element._handleAction(ActionType.CHANGE, 'key');
+      element.handleAction(ActionType.CHANGE, 'key');
       assert.isTrue(reportStub.called);
       assert.equal(reportStub.lastCall.args[0], 'change-key');
     });
@@ -2326,17 +2679,19 @@
 
     let changeRevisionActions: ActionNameToActionInfoMap = {};
 
-    setup(() => {
+    setup(async () => {
       stubRestApi('getChangeRevisionActions').returns(
         Promise.resolve(changeRevisionActions)
       );
       stubRestApi('send').returns(Promise.reject(new Error('error')));
 
       sinon
-        .stub(getPluginLoader(), 'awaitPluginsLoaded')
+        .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
         .returns(Promise.resolve());
 
-      element = basicFixture.instantiate();
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
       // getChangeRevisionActions is not called without
       // set the following properties
       element.change = createChangeViewChange();
@@ -2344,33 +2699,23 @@
       element.latestPatchNum = 2 as PatchSetNum;
 
       stubRestApi('getRepoBranches').returns(Promise.resolve([]));
-      return element.reload();
+      await element.updateComplete;
+      await element.reload();
     });
 
     test('confirmSubmitDialog and confirmRebase properties are changed', () => {
       changeRevisionActions = {};
       element.reload();
-      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
-      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
-    });
-
-    test('_computeRebaseOnCurrent', () => {
-      const rebaseAction = {
-        enabled: true,
-        label: 'Rebase',
-        method: HttpMethod.POST,
-        title: 'Rebase onto tip of branch or parent change',
-      };
-
-      // When rebase is enabled initially, rebaseOnCurrent should be set to
-      // true.
-      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
-
-      rebaseAction.enabled = false;
-
-      // When rebase is not enabled initially, rebaseOnCurrent should be set to
-      // false.
-      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
+      assert.strictEqual(
+        queryAndAssert<GrConfirmSubmitDialog>(element, '#confirmSubmitDialog')
+          .action,
+        null
+      );
+      assert.strictEqual(
+        queryAndAssert<GrConfirmRebaseDialog>(element, '#confirmRebase')
+          .rebaseOnCurrent,
+        null
+      );
     });
   });
 });
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 8b46cd8..b9a04bd 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
 import '../../../styles/gr-font-styles';
@@ -20,66 +9,58 @@
 import '../../../styles/gr-change-view-integration-shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import '../../plugins/gr-external-style/gr-external-style';
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-editable-label/gr-editable-label';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-limited-text/gr-limited-text';
 import '../../shared/gr-linked-chip/gr-linked-chip';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-submit-requirements/gr-submit-requirements';
-import '../gr-change-requirements/gr-change-requirements';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-reviewer-list/gr-reviewer-list';
-import '../../shared/gr-account-list/gr-account-list';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-metadata_html';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
   ChangeStatus,
   GpgKeyInfoStatus,
+  InheritedBooleanInfoConfiguredValue,
   SubmitType,
 } from '../../../constants/constants';
-import {changeIsOpen} from '../../../utils/change-util';
-import {customElement, property, observe} from '@polymer/decorators';
+import {changeIsOpen, isOwner} from '../../../utils/change-util';
 import {
   AccountDetailInfo,
   AccountInfo,
+  ApprovalInfo,
   BranchName,
   ChangeInfo,
   CommitId,
   CommitInfo,
-  ElementPropertyDeepChange,
+  ConfigInfo,
   GpgKeyInfo,
   Hashtag,
+  isAccount,
+  isDetailedLabelInfo,
+  LabelInfo,
   LabelNameToInfoMap,
   NumericChangeId,
   ParentCommitInfo,
-  PatchSetNum,
+  RevisionPatchSetNum,
   RepoName,
   RevisionInfo,
   ServerInfo,
-  TopicName,
 } from '../../../types/common';
-import {assertNever, unique} from '../../../utils/common-util';
+import {assertIsDefined, 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 {getAppContext} from '../../../services/app-context';
 import {
   Metadata,
   isSectionSet,
   DisplayRules,
 } from '../../../utils/change-metadata-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
 import {
   EditRevisionInfo,
-  notUndefined,
+  isDefined,
   ParsedChangeInfo,
 } from '../../../types/types';
 import {
@@ -88,11 +69,22 @@
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {Interaction} from '../../../constants/reporting';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
+import {LitElement, css, html, nothing, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {changeMetadataStyles} from '../../../styles/gr-change-metadata-shared-styles';
+import {when} from 'lit/directives/when.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {createSearchUrl} from '../../../models/views/search';
+import {createChangeUrl} from '../../../models/views/change';
+import {GeneratedWebLink, getChangeWeblinks} from '../../../utils/weblink-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
-enum ChangeRole {
+export enum ChangeRole {
   OWNER = 'owner',
   UPLOADER = 'uploader',
   AUTHOR = 'author',
@@ -121,316 +113,738 @@
   message: string;
 }
 
-export interface GrChangeMetadata {
-  $: {
-    webLinks: HTMLElement;
-  };
-}
-
 @customElement('gr-change-metadata')
-export class GrChangeMetadata extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrChangeMetadata extends LitElement {
+  @query('#webLinks') webLinks?: HTMLElement;
+
+  @property({type: Object}) change?: ParsedChangeInfo;
+
+  @property({type: Object}) revertedChange?: ChangeInfo;
+
+  @property({type: Object}) account?: AccountDetailInfo;
+
+  @property({type: Object}) revision?: RevisionInfo | EditRevisionInfo;
+
+  @property({type: Object}) commitInfo?: CommitInfoWithRequiredCommit;
+
+  @property({type: Object}) serverConfig?: ServerInfo;
+
+  @property({type: Boolean}) parentIsCurrent?: boolean;
+
+  @property({type: Object}) repoConfig?: ConfigInfo;
+
+  // private but used in test
+  @state() mutable = false;
+
+  @state() private readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
+
+  // private but used in test
+  @state() topicReadOnly = true;
+
+  // private but used in test
+  @state() hashtagReadOnly = true;
+
+  @state() private pushCertificateValidation?: PushCertificateValidationInfo;
+
+  // private but used in test
+  @state() settingTopic = false;
+
+  // private but used in test
+  @state() currentParents: ParentCommitInfo[] = [];
+
+  @state() private showAllSections = false;
+
+  @state() private queryTopic?: AutocompleteQuery;
+
+  @state() private queryHashtag?: AutocompleteQuery;
+
+  private restApiService = getAppContext().restApiService;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  constructor() {
+    super();
+    this.queryTopic = (input: string) => this.getTopicSuggestions(input);
+    this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
 
-  /**
-   * Fired when the change topic is changed.
-   *
-   * @event topic-changed
-   */
+  static override styles = [
+    sharedStyles,
+    fontStyles,
+    changeMetadataStyles,
+    css`
+      :host {
+        display: table;
+      }
+      gr-submit-requirements {
+        --requirements-horizontal-padding: var(--metadata-horizontal-padding);
+      }
+      gr-editable-label {
+        max-width: 9em;
+      }
+      .webLink {
+        display: block;
+      }
+      gr-account-chip[disabled],
+      gr-linked-chip[disabled] {
+        opacity: 0;
+        pointer-events: none;
+      }
+      .hashtagChip {
+        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;
+      }
+      .parentList.merge {
+        list-style-type: decimal;
+        padding-left: var(--spacing-l);
+      }
+      .parentList gr-commit-info {
+        display: inline-block;
+      }
+      .hideDisplay,
+      #parentNotCurrentMessage {
+        display: none;
+      }
+      .icon {
+        margin: -3px 0;
+      }
+      .icon.help,
+      .icon.notTrusted {
+        color: var(--warning-foreground);
+      }
+      .icon.invalid {
+        color: var(--negative-red-text-color);
+      }
+      .icon.trusted {
+        color: var(--positive-green-text-color);
+      }
+      .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
+        --arrow-color: var(--warning-foreground);
+        display: inline-block;
+      }
+      .oldSeparatedSection {
+        margin-top: var(--spacing-l);
+        padding: var(--spacing-m) 0;
+      }
+      .separatedSection {
+        padding: var(--spacing-m) 0;
+      }
+      .hashtag gr-linked-chip,
+      .topic gr-linked-chip {
+        --linked-chip-text-color: var(--link-color);
+      }
+      gr-reviewer-list {
+        --account-max-length: 100px;
+        max-width: 285px;
+      }
+      .metadata-title {
+        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 gr-icon {
+        color: inherit;
+        font-size: 18px;
+      }
+      gr-vote-chip {
+        --gr-vote-chip-width: 14px;
+        --gr-vote-chip-height: 14px;
+      }
+    `,
+  ];
 
-  @property({type: Object})
-  change?: ParsedChangeInfo;
+  override render() {
+    if (!this.change) return nothing;
+    return html`<div>
+      <div class="metadata-header">
+        <h3 class="metadata-title heading-3">Change Info</h3>
+        ${this.renderShowAllButton()}
+      </div>
+      ${this.renderSubmitted()} ${this.renderUpdated()} ${this.renderOwner()}
+      ${this.renderNonOwner(ChangeRole.UPLOADER)}
+      ${this.renderNonOwner(ChangeRole.AUTHOR)}
+      ${this.renderNonOwner(ChangeRole.COMMITTER)} ${this.renderReviewers()}
+      ${this.renderCCs()} ${this.renderProjectBranch()} ${this.renderParent()}
+      ${this.renderMergedAs()} ${this.renderShowRevertCreatedAs()}
+      ${this.renderTopic()} ${this.renderCherryPickOf()}
+      ${this.renderStrategy()} ${this.renderHashTags()}
+      ${this.renderSubmitRequirements()} ${this.renderWeblinks()}
+      <gr-endpoint-decorator name="change-metadata-item">
+        <gr-endpoint-param
+          name="labels"
+          .value=${{...this.change?.labels}}
+        ></gr-endpoint-param>
+        <gr-endpoint-param
+          name="change"
+          .value=${this.change}
+        ></gr-endpoint-param>
+        <gr-endpoint-param
+          name="revision"
+          .value=${this.revision}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </div>`;
+  }
 
-  @property({type: Object})
-  revertedChange?: ChangeInfo;
+  private renderShowAllButton() {
+    return html`<gr-button
+      link
+      class="show-all-button"
+      @click=${this.onShowAllClick}
+      >${this.showAllSections ? 'Show less' : 'Show all'}
+      <gr-icon icon="expand_more" ?hidden=${this.showAllSections}></gr-icon>
+      <gr-icon icon="expand_less" ?hidden=${!this.showAllSections}></gr-icon>
+    </gr-button>`;
+  }
 
-  @property({type: Object, notify: true})
-  labels?: LabelNameToInfoMap;
+  private renderSubmitted() {
+    if (!this.change!.submitted) return nothing;
+    return html`<section class=${this.computeDisplayState(Metadata.SUBMITTED)}>
+      <span class="title">Submitted</span>
+      <span class="value">
+        <gr-date-formatter
+          withTooltip
+          .dateStr=${this.change!.submitted}
+          showYesterday
+        ></gr-date-formatter>
+      </span>
+    </section> `;
+  }
 
-  @property({type: Object})
-  account?: AccountDetailInfo;
+  private renderUpdated() {
+    return html`<section class=${this.computeDisplayState(Metadata.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
+          withTooltip
+          .dateStr=${this.change!.updated}
+          showYesterday
+        ></gr-date-formatter>
+      </span>
+    </section>`;
+  }
 
-  @property({type: Object})
-  revision?: RevisionInfo | EditRevisionInfo;
+  private renderOwner() {
+    const change = this.change!;
+    return html`<section class=${this.computeDisplayState(Metadata.OWNER)}>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip
+          title="This user created or uploaded the first patchset of this change."
+        >
+          Owner
+        </gr-tooltip-content>
+      </span>
+      <span class="value">
+        <gr-account-chip
+          .account=${change.owner}
+          .change=${change}
+          highlightAttention
+          .vote=${this.computeVote(change.owner)}
+          .label=${this.computeCodeReviewLabel()}
+        >
+          <gr-vote-chip
+            slot="vote-chip"
+            .vote=${this.computeVote(change.owner)}
+            .label=${this.computeCodeReviewLabel()}
+            circle-shape
+          ></gr-vote-chip>
+        </gr-account-chip>
+        ${when(
+          this.pushCertificateValidation,
+          () => html`<gr-tooltip-content
+            has-tooltip
+            title=${this.pushCertificateValidation!.message}
+          >
+            <gr-icon
+              icon=${this.pushCertificateValidation!.icon}
+              class="icon ${this.pushCertificateValidation!.class}"
+            ></gr-icon>
+          </gr-tooltip-content>`
+        )}
+      </span>
+    </section>`;
+  }
 
-  @property({type: Object})
-  commitInfo?: CommitInfoWithRequiredCommit;
+  renderNonOwner(role: ChangeRole) {
+    if (!this.getNonOwnerRole(role)) return nothing;
+    let title = '';
+    let name = '';
+    if (role === ChangeRole.UPLOADER) {
+      title =
+        "This user uploaded the patchset to Gerrit (typically by running the 'git push' command).";
+      name = 'Uploader';
+    } else if (role === ChangeRole.AUTHOR) {
+      title = 'This user wrote the code change.';
+      name = 'Author';
+    } else if (role === ChangeRole.COMMITTER) {
+      title =
+        'This user committed the code change to the Git repository (typically to the local Git repo before uploading).';
+      name = 'Committer';
+    }
+    return html`<section>
+      <span class="title">
+        <gr-tooltip-content has-tooltip .title=${title}>
+          ${name}
+        </gr-tooltip-content>
+      </span>
+      <span class="value">
+        <gr-account-chip
+          .account=${this.getNonOwnerRole(role)}
+          .change=${this.change}
+          ?highlightAttention=${role === ChangeRole.UPLOADER}
+          .vote=${this.computeVoteForRole(role)}
+          .label=${this.computeCodeReviewLabel()}
+        >
+          <gr-vote-chip
+            slot="vote-chip"
+            .vote=${this.computeVoteForRole(role)}
+            .label=${this.computeCodeReviewLabel()}
+            circle-shape
+          ></gr-vote-chip>
+        </gr-account-chip>
+      </span>
+    </section>`;
+  }
 
-  @property({type: Boolean, computed: '_computeIsMutable(account)'})
-  _mutable = false;
+  private renderReviewers() {
+    return html`<section class=${this.computeDisplayState(Metadata.REVIEWERS)}>
+      <span class="title">Reviewers</span>
+      <span class="value">
+        <gr-reviewer-list
+          .change=${this.change}
+          ?mutable=${this.mutable}
+          reviewers-only
+          .account=${this.account}
+        ></gr-reviewer-list>
+      </span>
+    </section>`;
+  }
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
+  private renderCCs() {
+    return html`<section class=${this.computeDisplayState(Metadata.CC)}>
+      <span class="title">CC</span>
+      <span class="value">
+        <gr-reviewer-list
+          .change=${this.change}
+          ?mutable=${this.mutable}
+          ccs-only
+          .account=${this.account}
+        ></gr-reviewer-list>
+      </span>
+    </section>`;
+  }
 
-  @property({type: Boolean})
-  parentIsCurrent?: boolean;
+  private renderProjectBranch() {
+    const change = this.change!;
+    return when(
+      this.computeShowRepoBranchTogether(),
+      () =>
+        html`<section class=${this.computeDisplayState(Metadata.REPO_BRANCH)}>
+          <span class="title">
+            <gr-tooltip-content
+              has-tooltip
+              title="Repository and branch that the change will be merged into if submitted."
+            >
+              Repo | Branch
+            </gr-tooltip-content>
+          </span>
+          <span class="value">
+            <a href=${this.computeProjectUrl(change.project)}
+              >${change.project}</a
+            >
+            |
+            <a href=${this.computeBranchUrl(change.project, change.branch)}
+              >${change.branch}</a
+            >
+          </span>
+        </section>`,
 
-  @property({type: String})
-  readonly _notCurrentMessage = NOT_CURRENT_MESSAGE;
-
-  @property({
-    type: Boolean,
-    computed: '_computeTopicReadOnly(_mutable, change)',
-  })
-  _topicReadOnly = true;
-
-  @property({
-    type: Boolean,
-    computed: '_computeHashtagReadOnly(_mutable, change)',
-  })
-  _hashtagReadOnly = true;
-
-  @property({
-    type: Object,
-    computed: '_computePushCertificateValidation(serverConfig, change)',
-  })
-  _pushCertificateValidation?: PushCertificateValidationInfo;
-
-  @property({type: Boolean, computed: '_computeShowRequirements(change)'})
-  _showRequirements = false;
-
-  @property({type: Array})
-  _assignee?: AccountInfo[];
-
-  @property({type: Boolean, computed: '_computeIsWip(change)'})
-  _isWip = false;
-
-  @property({type: String})
-  _newHashtag?: Hashtag;
-
-  @property({type: Boolean})
-  _settingTopic = false;
-
-  @property({type: Array, computed: '_computeParents(change, revision)'})
-  _currentParents: ParentCommitInfo[] = [];
-
-  @property({type: Object})
-  _CHANGE_ROLE = ChangeRole;
-
-  @property({type: Object})
-  _SECTION = Metadata;
-
-  @property({type: Boolean})
-  _showAllSections = false;
-
-  @property({type: Object})
-  queryTopic?: AutocompleteQuery;
-
-  @property({type: Boolean})
-  _isSubmitRequirementsUiEnabled = false;
-
-  restApiService = appContext.restApiService;
-
-  private readonly reporting = appContext.reportingService;
-
-  private readonly flagsService = appContext.flagsService;
-
-  override ready() {
-    super.ready();
-    this.queryTopic = (input: string) => this._getTopicSuggestions(input);
-    this._isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
-      KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+      () => html`<section
+          class=${this.computeDisplayState(Metadata.REPO_BRANCH)}
+        >
+          <span class="title">
+            <gr-tooltip-content
+              has-tooltip
+              title="Repository that the change will be merged into if submitted."
+            >
+              Repo
+            </gr-tooltip-content>
+          </span>
+          <span class="value">
+            <a href=${this.computeProjectUrl(change.project)}>
+              <gr-limited-text
+                limit="40"
+                .text=${change.project}
+              ></gr-limited-text>
+            </a>
+          </span>
+        </section>
+        <section class=${this.computeDisplayState(Metadata.REPO_BRANCH)}>
+          <span class="title">
+            <gr-tooltip-content
+              has-tooltip
+              title="Branch that the change will be merged into if submitted."
+            >
+              Branch
+            </gr-tooltip-content>
+          </span>
+          <span class="value">
+            <a href=${this.computeBranchUrl(change.project, change.branch)}>
+              <gr-limited-text
+                limit="40"
+                .text=${change.branch}
+              ></gr-limited-text>
+            </a>
+          </span>
+        </section>`
     );
   }
 
-  @observe('change.labels')
-  _labelsChanged(labels?: LabelNameToInfoMap) {
-    this.labels = {...labels};
+  private renderParent() {
+    return html`<section class=${this.computeDisplayState(Metadata.PARENT)}>
+      <span class="title"
+        >${this.currentParents.length > 1 ? 'Parents' : 'Parent'}</span
+      >
+      <span class="value">
+        <ol class=${this.computeParentListClass()}>
+          ${this.currentParents.map(
+            parent => html` <li>
+              <gr-commit-info .commitInfo=${parent}></gr-commit-info>
+              <gr-tooltip-content
+                id="parentNotCurrentMessage"
+                has-tooltip
+                show-icon
+                .title=${this.notCurrentMessage}
+              ></gr-tooltip-content>
+            </li>`
+          )}
+        </ol>
+      </span>
+    </section>`;
   }
 
-  @observe('change')
-  _changeChanged(change?: ParsedChangeInfo) {
-    this._assignee = change?.assignee ? [change.assignee] : [];
-    this._settingTopic = false;
+  private renderMergedAs() {
+    const changeMerged = this.change?.status === ChangeStatus.MERGED;
+    if (!changeMerged) return nothing;
+    return html`<section class=${this.computeDisplayState(Metadata.MERGED_AS)}>
+      <span class="title">Merged As</span>
+      <span class="value">
+        <gr-commit-info
+          .commitInfo=${this.computeMergedCommitInfo(
+            this.change?.current_revision,
+            this.change?.revisions
+          )}
+        ></gr-commit-info>
+      </span>
+    </section>`;
   }
 
-  @observe('_assignee.*')
-  _assigneeChanged(
-    assigneeRecord: ElementPropertyDeepChange<GrChangeMetadata, '_assignee'>
-  ) {
-    if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
-      return;
+  private renderShowRevertCreatedAs() {
+    if (!this.showRevertCreatedAs()) return nothing;
+
+    return html`<section
+      class=${this.computeDisplayState(Metadata.REVERT_CREATED_AS)}
+    >
+      <span class="title">${this.getRevertSectionTitle()}</span>
+      <span class="value">
+        <gr-commit-info
+          .commitInfo=${this.computeRevertCommit()}
+        ></gr-commit-info>
+      </span>
+    </section>`;
+  }
+
+  private renderTopic() {
+    const showTopic = this.change?.topic || !this.topicReadOnly;
+    if (!showTopic) return nothing;
+
+    return html`<section
+      class="topic ${this.computeDisplayState(Metadata.TOPIC, this.account)}"
+    >
+      <span class="title">Topic</span>
+      <span class="value">
+        ${when(
+          this.showTopicChip(),
+          () => html` <gr-linked-chip
+            .text=${this.change?.topic}
+            limit="40"
+            href=${createSearchUrl({topic: this.change!.topic!})}
+            ?removable=${!this.topicReadOnly}
+            @remove=${this.handleTopicRemoved}
+          ></gr-linked-chip>`
+        )}
+        ${when(
+          this.showAddTopic(),
+          () =>
+            html` <gr-editable-label
+              class="topicEditableLabel"
+              labelText="Set topic"
+              .confirmLabel=${'Set Topic'}
+              .value=${this.change?.topic}
+              maxLength="1024"
+              .placeholder=${this.computeTopicPlaceholder()}
+              ?readOnly=${this.topicReadOnly}
+              @changed=${this.handleTopicChanged}
+              showAsEditPencil
+              autocomplete
+              .query=${this.queryTopic}
+            ></gr-editable-label>`
+        )}
+      </span>
+    </section>`;
+  }
+
+  private renderCherryPickOf() {
+    if (!this.showCherryPickOf()) return nothing;
+    return html` <section
+      class=${this.computeDisplayState(Metadata.CHERRY_PICK_OF)}
+    >
+      <span class="title">Cherry pick of</span>
+      <span class="value">
+        <a
+          href=${this.computeCherryPickOfUrl(
+            this.change?.cherry_pick_of_change,
+            this.change?.cherry_pick_of_patch_set,
+            this.change?.project
+          )}
+        >
+          <gr-limited-text
+            text="${this.change?.cherry_pick_of_change},${this.change
+              ?.cherry_pick_of_patch_set}"
+            limit="40"
+          >
+          </gr-limited-text>
+        </a>
+      </span>
+    </section>`;
+  }
+
+  private renderStrategy() {
+    if (!changeIsOpen(this.change)) return nothing;
+    return html`<section
+      class="strategy ${this.computeDisplayState(Metadata.STRATEGY)}"
+    >
+      <span class="title">Strategy</span>
+      <span class="value">${this.computeStrategy()}</span>
+    </section>`;
+  }
+
+  private renderHashTags() {
+    return html`<section
+      class="hashtag ${this.computeDisplayState(Metadata.HASHTAGS)}"
+    >
+      <span class="title">Hashtags</span>
+      <span class="value">
+        ${(this.change?.hashtags ?? []).map(
+          hashtag => html`<gr-linked-chip
+            class="hashtagChip"
+            .text=${hashtag}
+            href=${this.computeHashtagUrl(hashtag)}
+            ?removable=${!this.hashtagReadOnly}
+            @remove=${this.handleHashtagRemoved}
+            limit="40"
+          >
+          </gr-linked-chip>`
+        )}
+        ${when(
+          !this.hashtagReadOnly,
+          () => html`
+            <gr-editable-label
+              uppercase
+              labelText="Add a hashtag"
+              .placeholder=${this.computeHashtagPlaceholder()}
+              .readOnly=${this.hashtagReadOnly}
+              @changed=${this.handleHashtagChanged}
+              showAsEditPencil
+              autocomplete
+              .query=${this.queryHashtag}
+            ></gr-editable-label>
+          `
+        )}
+      </span>
+    </section>`;
+  }
+
+  private renderSubmitRequirements() {
+    return html`<div class="separatedSection">
+      <gr-submit-requirements
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+      ></gr-submit-requirements>
+    </div>`;
+  }
+
+  private renderWeblinks() {
+    const webLinks = this.computeWebLinks();
+    if (!webLinks.length) return nothing;
+    return html`<section id="webLinks">
+      <span class="title">Links</span>
+      <span class="value">
+        ${webLinks.map(
+          link => html`<a
+            href=${ifDefined(link.url)}
+            class="webLink"
+            rel="noopener"
+            target="_blank"
+          >
+            ${link.name}
+          </a>`
+        )}
+      </span>
+    </section>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('account')) {
+      this.mutable = this.computeIsMutable();
     }
-    const assignee = assigneeRecord.base;
-    if (assignee?.length) {
-      const acct = assignee[0];
-      if (
-        !acct._account_id ||
-        (this.change.assignee &&
-          acct._account_id === this.change.assignee._account_id)
-      ) {
-        return;
-      }
-      this.set(['change', 'assignee'], acct);
-      this.restApiService.setAssignee(this.change._number, acct._account_id);
-    } else {
-      if (!this.change.assignee) {
-        return;
-      }
-      this.set(['change', 'assignee'], undefined);
-      this.restApiService.deleteAssignee(this.change._number);
+    if (changedProperties.has('mutable') || changedProperties.has('change')) {
+      this.topicReadOnly = this.computeTopicReadOnly();
+      this.hashtagReadOnly = this.computeHashtagReadOnly();
+    }
+    if (changedProperties.has('change')) {
+      this.settingTopic = false;
+    }
+    if (
+      changedProperties.has('serverConfig') ||
+      changedProperties.has('change') ||
+      changedProperties.has('repoConfig')
+    ) {
+      this.pushCertificateValidation = this.computePushCertificateValidation();
+    }
+    if (changedProperties.has('revision') || changedProperties.has('change')) {
+      this.currentParents = this.computeParents();
     }
   }
 
-  _computeHideStrategy(change?: ParsedChangeInfo) {
-    return !changeIsOpen(change);
+  // private but used in test
+  computeWebLinks(): GeneratedWebLink[] {
+    return getChangeWeblinks(this.commitInfo?.web_links, this.serverConfig);
   }
 
-  /**
-   * @return If array is empty, returns undefined instead so
-   * an existential check can be used to hide or show the webLinks
-   * section.
-   */
-  _computeWebLinks(
-    commitInfo?: CommitInfoWithRequiredCommit,
-    serverConfig?: ServerInfo
-  ) {
-    if (!commitInfo) return undefined;
-    const weblinks = GerritNav.getChangeWeblinks(
-      this.change ? this.change.project : ('' as RepoName),
-      commitInfo.commit,
-      {
-        weblinks: commitInfo.web_links,
-        config: serverConfig,
-      }
-    );
-    return weblinks.length ? weblinks : undefined;
-  }
-
-  _isChangeMerged(change?: ParsedChangeInfo) {
-    return change?.status === ChangeStatus.MERGED;
-  }
-
-  _isAssigneeEnabled(serverConfig?: ServerInfo) {
-    return !!serverConfig?.change?.enable_assignee;
-  }
-
-  _computeStrategy(change?: ParsedChangeInfo) {
-    if (!change?.submit_type) {
+  private computeStrategy() {
+    if (!this.change?.submit_type) {
       return '';
     }
 
-    return SubmitTypeLabel.get(change.submit_type);
+    return SubmitTypeLabel.get(this.change.submit_type);
   }
 
-  _computeLabelNames(labels?: LabelNameToInfoMap) {
+  // private but used in test
+  computeLabelNames(labels?: LabelNameToInfoMap) {
     return labels ? Object.keys(labels).sort() : [];
   }
 
-  _handleTopicChanged(e: CustomEvent<string>) {
-    if (!this.change) {
-      throw new Error('change must be set');
-    }
-    const lastTopic = this.change.topic;
+  // private but used in test
+  async handleTopicChanged(e: CustomEvent<string>) {
+    assertIsDefined(this.change, 'change');
     const topic = e.detail.length ? e.detail : undefined;
-    this._settingTopic = true;
-    const topicChangedForChangeNumber = this.change._number;
-    this.restApiService
-      .setChangeTopic(topicChangedForChangeNumber, topic)
-      .then(newTopic => {
-        if (this.change?._number !== topicChangedForChangeNumber) return;
-        this._settingTopic = false;
-        this.set(['change', 'topic'], newTopic);
-        if (newTopic !== lastTopic) {
-          fireEvent(this, 'topic-changed');
-        }
-      });
+    this.settingTopic = true;
+    try {
+      fireAlert(this, 'Saving topic and reloading ...');
+      await this.restApiService.setChangeTopic(this.change._number, topic);
+    } finally {
+      this.settingTopic = false;
+    }
+    fireEvent(this, 'hide-alert');
+    fireReload(this);
   }
 
-  _showAddTopic(
-    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
-    settingTopic?: boolean
-  ) {
-    const hasTopic = !!changeRecord?.base?.topic;
-    return !hasTopic && !settingTopic;
+  // private but used in test
+  showAddTopic() {
+    const hasTopic = !!this.change?.topic;
+    return !hasTopic && !this.settingTopic && this.topicReadOnly === false;
   }
 
-  _showTopicChip(
-    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
-    settingTopic?: boolean
-  ) {
-    const hasTopic = !!changeRecord?.base?.topic;
-    return hasTopic && !settingTopic;
+  // private but used in test
+  showTopicChip() {
+    const hasTopic = !!this.change?.topic;
+    return hasTopic && !this.settingTopic;
   }
 
-  _showCherryPickOf(
-    changeRecord?: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
-  ) {
+  // private but used in test
+  showCherryPickOf() {
     const hasCherryPickOf =
-      !!changeRecord?.base?.cherry_pick_of_change &&
-      !!changeRecord?.base?.cherry_pick_of_patch_set;
+      !!this.change?.cherry_pick_of_change &&
+      !!this.change?.cherry_pick_of_patch_set;
     return hasCherryPickOf;
   }
 
-  _handleHashtagChanged() {
-    if (!this.change) {
-      throw new Error('change must be set');
-    }
-    if (!this._newHashtag?.length) {
-      return;
-    }
-    const newHashtag = this._newHashtag;
-    this._newHashtag = '' as Hashtag;
-    this.restApiService
-      .setChangeHashtag(this.change._number, {add: [newHashtag]})
-      .then(newHashtag => {
-        this.set(['change', 'hashtags'], newHashtag);
-        fireEvent(this, 'hashtag-changed');
-      });
+  // private but used in test
+  async handleHashtagChanged(e: CustomEvent<string>) {
+    assertIsDefined(this.change, 'change');
+    const newHashtag = e.detail.length ? e.detail : undefined;
+    if (!newHashtag?.length) return;
+    fireAlert(this, 'Saving hashtag and reloading ...');
+    await this.restApiService.setChangeHashtag(this.change._number, {
+      add: [newHashtag as Hashtag],
+    });
+    fireEvent(this, 'hide-alert');
+    fireReload(this);
   }
 
-  _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return !mutable || !change?.actions?.topic?.enabled;
+  // private but used in test
+  computeTopicReadOnly() {
+    return !this.mutable || !this.change?.actions?.topic?.enabled;
   }
 
-  _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return !mutable || !change?.actions?.hashtags?.enabled;
+  // private but used in test
+  computeHashtagReadOnly() {
+    return !this.mutable || !this.change?.actions?.hashtags?.enabled;
   }
 
-  _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
-    return !mutable || !change?.actions?.assignee?.enabled;
-  }
-
-  _computeTopicPlaceholder(_topicReadOnly?: boolean) {
+  private computeTopicPlaceholder() {
     // Action items in Material Design are uppercase -- placeholder label text
     // is sentence case.
-    return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
+    return this.topicReadOnly ? 'No topic' : 'Set Topic';
   }
 
-  _computeHashtagPlaceholder(_hashtagReadOnly?: boolean) {
-    return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
-  }
-
-  _computeShowRequirements(change?: ParsedChangeInfo) {
-    if (!change) {
-      return false;
-    }
-    if (change.status !== ChangeStatus.NEW) {
-      // TODO(maximeg) change this to display the stored
-      // requirements, once it is implemented server-side.
-      return false;
-    }
-    const hasRequirements =
-      !!change.requirements && Object.keys(change.requirements).length > 0;
-    const hasLabels = !!change.labels && Object.keys(change.labels).length > 0;
-    return hasRequirements || hasLabels || !!change.work_in_progress;
+  private computeHashtagPlaceholder() {
+    return this.hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
   }
 
   /**
+   * private but used in test
+   *
    * @return object representing data for the push validation.
    */
-  _computePushCertificateValidation(
-    serverConfig?: ServerInfo,
-    change?: ParsedChangeInfo
-  ): PushCertificateValidationInfo | undefined {
-    if (!change || !serverConfig?.receive?.enable_signed_push) return undefined;
+  computePushCertificateValidation():
+    | PushCertificateValidationInfo
+    | undefined {
+    if (!this.change || !this.serverConfig?.receive?.enable_signed_push)
+      return undefined;
 
-    const rev = change.revisions[change.current_revision];
+    if (!this.isEnabledSignedPushOnRepo()) {
+      return undefined;
+    }
+    const rev = this.change.revisions[this.change.current_revision];
     if (!rev.push_certificate?.key) {
       return {
-        class: 'help',
-        icon: 'gr-icons:help',
+        class: 'help filled',
+        icon: 'help',
         message: 'This patch set was created without a push certificate',
       };
     }
@@ -440,14 +854,14 @@
       case GpgKeyInfoStatus.BAD:
         return {
           class: 'invalid',
-          icon: 'gr-icons:close',
-          message: this._problems('Push certificate is invalid', key),
+          icon: 'close',
+          message: this.problems('Push certificate is invalid', key),
         };
       case GpgKeyInfoStatus.OK:
         return {
-          class: 'notTrusted',
-          icon: 'gr-icons:info',
-          message: this._problems(
+          class: 'notTrusted filled',
+          icon: 'info',
+          message: this.problems(
             'Push certificate is valid, but key is not trusted',
             key
           ),
@@ -455,8 +869,8 @@
       case GpgKeyInfoStatus.TRUSTED:
         return {
           class: 'trusted',
-          icon: 'gr-icons:check',
-          message: this._problems(
+          icon: 'check',
+          message: this.problems(
             'Push certificate is valid and key is trusted',
             key
           ),
@@ -469,7 +883,21 @@
     }
   }
 
-  _problems(msg: string, key: GpgKeyInfo) {
+  // private but used in test
+  isEnabledSignedPushOnRepo() {
+    if (!this.repoConfig?.enable_signed_push) return false;
+
+    const enableSignedPush = this.repoConfig.enable_signed_push;
+    return (
+      (enableSignedPush.configured_value ===
+        InheritedBooleanInfoConfiguredValue.INHERIT &&
+        enableSignedPush.inherited_value) ||
+      enableSignedPush.configured_value ===
+        InheritedBooleanInfoConfiguredValue.TRUE
+    );
+  }
+
+  private problems(msg: string, key: GpgKeyInfo) {
     if (!key?.problems || key.problems.length === 0) {
       return msg;
     }
@@ -477,178 +905,176 @@
     return [msg + ':'].concat(key.problems).join('\n');
   }
 
-  _computeShowRepoBranchTogether(repo?: RepoName, branch?: BranchName) {
-    return !!repo && !!branch && repo.length + branch.length < 40;
+  private computeShowRepoBranchTogether() {
+    const {project, branch} = this.change!;
+    return !!project && !!branch && project.length + branch.length < 40;
   }
 
-  _computeProjectUrl(project?: RepoName) {
+  private computeProjectUrl(project?: RepoName) {
     if (!project) return '';
-    return GerritNav.getUrlForProjectChanges(project);
+    return createSearchUrl({repo: project});
   }
 
-  _computeBranchUrl(project?: RepoName, branch?: BranchName) {
+  private computeBranchUrl(project?: RepoName, branch?: BranchName) {
     if (!project || !branch || !this.change || !this.change.status) return '';
-    return GerritNav.getUrlForBranch(
+    return createSearchUrl({
       branch,
-      project,
-      this.change.status === ChangeStatus.NEW
-        ? 'open'
-        : this.change.status.toLowerCase()
-    );
+      repo: project,
+      statuses:
+        this.change.status === ChangeStatus.NEW
+          ? ['open']
+          : [this.change.status.toLowerCase()],
+    });
   }
 
-  _computeCherryPickOfUrl(
+  private computeCherryPickOfUrl(
     change?: NumericChangeId,
-    patchset?: PatchSetNum,
+    patchset?: RevisionPatchSetNum,
     project?: RepoName
   ) {
     if (!change || !project) {
       return '';
     }
-    return GerritNav.getUrlForChangeById(change, project, patchset);
+    return createChangeUrl({
+      changeNum: change,
+      repo: project,
+      usp: 'metadata',
+      patchNum: patchset,
+    });
   }
 
-  _computeTopicUrl(topic: TopicName) {
-    return GerritNav.getUrlForTopic(topic);
+  private computeHashtagUrl(hashtag: Hashtag) {
+    return createSearchUrl({hashtag, statuses: ['open', 'merged']});
   }
 
-  _computeHashtagUrl(hashtag: Hashtag) {
-    return GerritNav.getUrlForHashtag(hashtag);
-  }
-
-  _handleTopicRemoved(e: CustomEvent) {
-    if (!this.change) {
-      throw new Error('change must be set');
-    }
+  private async handleTopicRemoved(e: CustomEvent) {
+    assertIsDefined(this.change, 'change');
     const target = e.composedPath()[0] as GrLinkedChip;
     target.disabled = true;
-    this.restApiService
-      .setChangeTopic(this.change._number)
-      .then(() => {
-        target.disabled = false;
-        this.set(['change', 'topic'], '');
-        fireEvent(this, 'topic-changed');
-      })
-      .catch(() => {
-        target.disabled = false;
-      });
-  }
-
-  _handleHashtagRemoved(e: CustomEvent) {
-    e.preventDefault();
-    if (!this.change) {
-      throw new Error('change must be set');
+    try {
+      fireAlert(this, 'Removing topic and reloading ...');
+      await this.restApiService.setChangeTopic(this.change._number);
+    } finally {
+      target.disabled = false;
     }
-    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+    fireEvent(this, 'hide-alert');
+    fireReload(this);
+  }
+
+  // private but used in test
+  async handleHashtagRemoved(e: CustomEvent) {
+    e.preventDefault();
+    assertIsDefined(this.change, 'change');
+    const target = e.target as GrLinkedChip;
     target.disabled = true;
-    this.restApiService
-      .setChangeHashtag(this.change._number, {remove: [target.text as Hashtag]})
-      .then(newHashtags => {
-        target.disabled = false;
-        this.set(['change', 'hashtags'], newHashtags);
-      })
-      .catch(() => {
-        target.disabled = false;
+    try {
+      fireAlert(this, 'Removing hashtag and reloading ...');
+      await this.restApiService.setChangeHashtag(this.change._number, {
+        remove: [target.text as Hashtag],
       });
+    } finally {
+      target.disabled = false;
+    }
+    fireEvent(this, 'hide-alert');
+    fireReload(this);
   }
 
-  _computeIsWip(change?: ParsedChangeInfo) {
-    return !!change?.work_in_progress;
-  }
-
-  _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
-    return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
-  }
-
-  _computeDisplayState(
-    showAllSections: boolean,
-    change: ParsedChangeInfo | undefined,
-    section: Metadata
-  ) {
+  private computeDisplayState(section: Metadata, account?: AccountDetailInfo) {
+    // special case for Topic - show always for owners, others when set
+    if (section === Metadata.TOPIC) {
+      if (
+        this.showAllSections ||
+        isOwner(this.change, account) ||
+        isSectionSet(section, this.change)
+      ) {
+        return '';
+      } else {
+        return 'hideDisplay';
+      }
+    }
     if (
-      showAllSections ||
+      this.showAllSections ||
       DisplayRules.ALWAYS_SHOW.includes(section) ||
       (DisplayRules.SHOW_IF_SET.includes(section) &&
-        isSectionSet(section, change))
+        isSectionSet(section, this.change))
     ) {
       return '';
     }
     return 'hideDisplay';
   }
 
-  _computeMergedCommitInfo(
-    current_revision: CommitId,
-    revisions: {[revisionId: string]: RevisionInfo}
-  ) {
-    const rev = revisions[current_revision];
-    if (!rev || !rev.commit) {
-      return {};
-    }
+  // private but used in test
+  computeMergedCommitInfo(
+    currentrevision?: CommitId,
+    revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
+  ): CommitInfo | undefined {
+    if (!currentrevision || !revisions) return;
+    const rev = revisions[currentrevision];
+    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;
+      rev.commit.commit = currentrevision;
     }
     return rev.commit;
   }
 
-  _getRevertSectionTitle(
-    _change?: ParsedChangeInfo,
-    revertedChange?: ChangeInfo
-  ) {
-    return revertedChange?.status === ChangeStatus.MERGED
+  private getRevertSectionTitle() {
+    return this.revertedChange?.status === ChangeStatus.MERGED
       ? 'Revert Submitted As'
       : 'Revert Created As';
   }
 
-  _showRevertCreatedAs(change?: ParsedChangeInfo) {
-    if (!change?.messages) return false;
-    return getRevertCreatedChangeIds(change.messages).length > 0;
+  // private but used in test
+  showRevertCreatedAs() {
+    if (!this.change?.messages) return false;
+    return getRevertCreatedChangeIds(this.change.messages).length > 0;
   }
 
-  _computeRevertCommit(change?: ParsedChangeInfo, revertedChange?: ChangeInfo) {
+  // private but used in test
+  computeRevertCommit(): CommitInfo | undefined {
+    const {revertedChange, change} = this;
     if (revertedChange?.current_revision && revertedChange?.revisions) {
+      // TODO(TS): Fix typing
       return {
-        commit: this._computeMergedCommitInfo(
+        commit: this.computeMergedCommitInfo(
           revertedChange.current_revision,
           revertedChange.revisions
         ),
-      };
+      } as CommitInfo;
     }
     if (!change?.messages) return undefined;
-    return {commit: getRevertCreatedChangeIds(change.messages)?.[0]};
+    // TODO(TS): Fix typing
+    return {
+      commit: getRevertCreatedChangeIds(change.messages)?.[0],
+    } as unknown as CommitInfo;
   }
 
-  _computeShowAllLabelText(showAllSections: boolean) {
-    if (showAllSections) {
-      return 'Show less';
-    } else {
-      return 'Show all';
-    }
-  }
-
-  _onShowAllClick() {
-    this._showAllSections = !this._showAllSections;
+  // private but used in test
+  onShowAllClick() {
+    this.showAllSections = !this.showAllSections;
     this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'metadata',
-      toState: this._showAllSections ? 'Show all' : 'Show less',
+      toState: this.showAllSections ? 'Show all' : 'Show less',
     });
   }
 
   /**
    * Get the user with the specified role on the change. Returns undefined if the
    * user with that role is the same as the owner.
+   * private but used in test
    */
-  _getNonOwnerRole(change?: ParsedChangeInfo, role?: ChangeRole) {
-    if (!change?.revisions?.[change.current_revision]) return undefined;
+  getNonOwnerRole(role: ChangeRole) {
+    if (!this.change?.revisions?.[this.change.current_revision])
+      return undefined;
 
-    const rev = change.revisions[change.current_revision];
+    const rev = this.change.revisions[this.change.current_revision];
     if (!rev) return undefined;
 
     if (
       role === ChangeRole.UPLOADER &&
       rev.uploader &&
-      change.owner._account_id !== rev.uploader._account_id
+      this.change.owner._account_id !== rev.uploader._account_id
     ) {
       return rev.uploader;
     }
@@ -656,7 +1082,7 @@
     if (
       role === ChangeRole.AUTHOR &&
       rev.commit?.author &&
-      change.owner.email !== rev.commit.author.email
+      this.change.owner.email !== rev.commit.author.email
     ) {
       return rev.commit.author;
     }
@@ -664,7 +1090,7 @@
     if (
       role === ChangeRole.COMMITTER &&
       rev.commit?.committer &&
-      change.owner.email !== rev.commit.committer.email &&
+      this.change.owner.email !== rev.commit.committer.email &&
       !(
         rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
       )
@@ -675,77 +1101,51 @@
     return undefined;
   }
 
-  _computeParents(
-    change?: ParsedChangeInfo,
-    revision?: RevisionInfo | EditRevisionInfo
-  ): ParentCommitInfo[] {
-    if (!revision || !revision.commit) {
-      if (!change || !change.current_revision) {
-        return [];
-      }
-      revision = change.revisions[change.current_revision];
-      if (!revision || !revision.commit) {
-        return [];
-      }
+  // private but used in test
+  computeParents(): ParentCommitInfo[] {
+    const {change, revision} = this;
+    if (!revision?.commit) {
+      if (!change?.current_revision) return [];
+      const newRevision = change.revisions[change.current_revision];
+      return newRevision?.commit?.parents ?? [];
     }
-    return revision.commit.parents;
+    return revision?.commit?.parents ?? [];
   }
 
-  _computeParentsLabel(parents?: ParentCommitInfo[]) {
-    return parents && parents.length > 1 ? 'Parents' : 'Parent';
-  }
-
-  _computeParentListClass(
-    parents?: ParentCommitInfo[],
-    parentIsCurrent?: boolean
-  ) {
-    // Undefined check for polymer 2
-    if (parents === undefined || parentIsCurrent === undefined) {
-      return '';
-    }
-
+  // private but used in test
+  computeParentListClass() {
     return [
       'parentList',
-      parents && parents.length > 1 ? 'merge' : 'nonMerge',
-      parentIsCurrent ? 'current' : 'notCurrent',
+      this.currentParents.length > 1 ? 'merge' : 'nonMerge',
+      this.parentIsCurrent ? 'current' : 'notCurrent',
     ].join(' ');
   }
 
-  _computeIsMutable(account?: AccountDetailInfo) {
-    return account && !!Object.keys(account).length;
+  private computeIsMutable() {
+    return !!this.account && !!Object.keys(this.account).length;
   }
 
   editTopic() {
-    if (this._topicReadOnly || !this.change || this.change.topic) {
+    if (this.topicReadOnly || !this.change || this.change.topic) {
       return;
     }
-    // Cannot use `this.$.ID` syntax because the element exists inside of a
-    // dom-if.
-    (
-      this.shadowRoot!.querySelector('.topicEditableLabel') as GrEditableLabel
-    ).open();
-  }
-
-  _getReviewerSuggestionsProvider(change?: ParsedChangeInfo) {
-    if (!change) {
-      return undefined;
-    }
-    const provider = GrReviewerSuggestionsProvider.create(
-      this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
+    const topicEditableLabel = this.shadowRoot!.querySelector<GrEditableLabel>(
+      '.topicEditableLabel'
     );
-    provider.init();
-    return provider;
+    if (topicEditableLabel) {
+      topicEditableLabel.open();
+    }
   }
 
-  _getTopicSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+  private getTopicSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getChangesWithSimilarTopic(input)
+      .getChangesWithSimilarTopic(input, throwingErrorCallback)
       .then(response =>
         (response ?? [])
           .map(change => change.topic)
-          .filter(notUndefined)
+          .filter(isDefined)
           .filter(unique)
           .map(topic => {
             return {name: topic, value: topic};
@@ -753,14 +1153,40 @@
       );
   }
 
-  _showNewSubmitRequirements(change?: ParsedChangeInfo) {
-    if (!this._isSubmitRequirementsUiEnabled) return false;
-    return (change?.submit_requirements ?? []).length > 0;
+  private getHashtagSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
+    return this.restApiService
+      .getChangesWithSimilarHashtag(input, throwingErrorCallback)
+      .then(response =>
+        (response ?? [])
+          .flatMap(change => change.hashtags ?? [])
+          .filter(isDefined)
+          .filter(unique)
+          .map(hashtag => {
+            return {name: hashtag, value: hashtag};
+          })
+      );
   }
 
-  _showNewSubmitRequirementWarning(change?: ParsedChangeInfo) {
-    if (!this._isSubmitRequirementsUiEnabled) return false;
-    return (change?.submit_requirements ?? []).length === 0;
+  private computeVoteForRole(role: ChangeRole) {
+    const reviewer = this.getNonOwnerRole(role);
+    if (reviewer && isAccount(reviewer)) {
+      return this.computeVote(reviewer);
+    } else {
+      return;
+    }
+  }
+
+  private computeVote(reviewer: AccountInfo): ApprovalInfo | undefined {
+    const codeReviewLabel = this.computeCodeReviewLabel();
+    if (!codeReviewLabel || !isDetailedLabelInfo(codeReviewLabel)) return;
+    return getApprovalInfo(codeReviewLabel, reviewer);
+  }
+
+  private computeCodeReviewLabel(): LabelInfo | undefined {
+    if (!this.change?.labels) return;
+    return getCodeReviewLabel(this.change.labels);
   }
 }
 
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
deleted file mode 100644
index 25dab25..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ /dev/null
@@ -1,532 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-change-metadata-shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: table;
-    }
-    gr-change-requirements,
-    gr-submit-requirements {
-      --requirements-horizontal-padding: var(--metadata-horizontal-padding);
-    }
-    gr-editable-label {
-      max-width: 9em;
-    }
-    .webLink {
-      display: block;
-    }
-    gr-account-chip[disabled],
-    gr-linked-chip[disabled] {
-      opacity: 0;
-      pointer-events: none;
-    }
-    .hashtagChip {
-      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;
-    }
-    .parentList.merge {
-      list-style-type: decimal;
-      padding-left: var(--spacing-l);
-    }
-    .parentList gr-commit-info {
-      display: inline-block;
-    }
-    .hideDisplay,
-    #parentNotCurrentMessage {
-      display: none;
-    }
-    .icon {
-      margin: -3px 0;
-    }
-    .icon.help,
-    .icon.notTrusted {
-      color: var(--warning-foreground);
-    }
-    .icon.invalid {
-      color: var(--negative-red-text-color);
-    }
-    .icon.trusted {
-      color: var(--positive-green-text-color);
-    }
-    .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
-      --arrow-color: var(--warning-foreground);
-      display: inline-block;
-    }
-    .separatedSection {
-      margin-top: var(--spacing-l);
-      padding: var(--spacing-m) 0;
-    }
-    .hashtag gr-linked-chip,
-    .topic gr-linked-chip {
-      --linked-chip-text-color: var(--link-color);
-    }
-    gr-reviewer-list {
-      --account-max-length: 100px;
-      max-width: 285px;
-    }
-    .metadata-title {
-      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;
-    }
-    .submit-requirement-error {
-      color: var(--deemphasized-text-color);
-      padding-left: var(--metadata-horizontal-padding);
-    }
-  </style>
-  <gr-external-style id="externalStyle" name="change-metadata">
-    <div class="metadata-header">
-      <h3 class="metadata-title heading-3">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
-            withTooltip
-            date-str="[[change.submitted]]"
-            showYesterday=""
-          ></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
-          withTooltip
-          date-str="[[change.updated]]"
-          showYesterday
-        ></gr-date-formatter>
-      </span>
-    </section>
-    <section
-      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.OWNER)]]"
-    >
-      <span class="title">
-        <gr-tooltip-content
-          has-tooltip=""
-          title="This user created or uploaded the first patchset of this change."
-        >
-          Owner
-        </gr-tooltip-content>
-      </span>
-      <span class="value">
-        <gr-account-chip
-          account="[[change.owner]]"
-          change="[[change]]"
-          highlightAttention
-        ></gr-account-chip>
-        <template is="dom-if" if="[[_pushCertificateValidation]]">
-          <gr-tooltip-content
-            has-tooltip=""
-            title$="[[_pushCertificateValidation.message]]"
-          >
-            <iron-icon
-              class$="icon [[_pushCertificateValidation.class]]"
-              icon="[[_pushCertificateValidation.icon]]"
-            >
-            </iron-icon>
-          </gr-tooltip-content>
-        </template>
-      </span>
-    </section>
-    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
-      <span class="title">
-        <gr-tooltip-content
-          has-tooltip=""
-          title="This user uploaded the patchset to Gerrit (typically by running the 'git push' command)."
-        >
-          Uploader
-        </gr-tooltip-content>
-      </span>
-      <span class="value">
-        <gr-account-chip
-          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
-          change="[[change]]"
-          highlightAttention
-        ></gr-account-chip>
-      </span>
-    </section>
-    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
-      <span class="title">
-        <gr-tooltip-content
-          has-tooltip=""
-          title="This user wrote the code change."
-        >
-          Author
-        </gr-tooltip-content>
-      </span>
-      <span class="value">
-        <gr-account-chip
-          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
-          change="[[change]]"
-        ></gr-account-chip>
-      </span>
-    </section>
-    <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
-      <span class="title">
-        <gr-tooltip-content
-          has-tooltip=""
-          title="This user committed the code change to the Git repository (typically to the local Git repo before uploading)."
-        >
-          Committer
-        </gr-tooltip-content>
-      </span>
-      <span class="value">
-        <gr-account-chip
-          account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
-          change="[[change]]"
-        ></gr-account-chip>
-      </span>
-    </section>
-    <template is="dom-if" if="[[_isAssigneeEnabled(serverConfig)]]">
-      <section
-        class$="assignee [[_computeDisplayState(_showAllSections, change, _SECTION.ASSIGNEE)]]"
-      >
-        <span class="title">Assignee</span>
-        <span class="value">
-          <gr-account-list
-            id="assigneeValue"
-            placeholder="Set assignee..."
-            max-count="1"
-            accounts="{{_assignee}}"
-            readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
-            suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
-          >
-          </gr-account-list>
-        </span>
-      </section>
-    </template>
-    <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
-      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>
-    </section>
-    <template
-      is="dom-if"
-      if="[[_computeShowRepoBranchTogether(change.project, change.branch)]]"
-    >
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
-      >
-        <span class="title">Repo | Branch</span>
-        <span class="value">
-          <a href$="[[_computeProjectUrl(change.project)]]"
-            >[[change.project]]</a
-          >
-          |
-          <a href$="[[_computeBranchUrl(change.project, change.branch)]]"
-            >[[change.branch]]</a
-          >
-        </span>
-      </section>
-    </template>
-    <template
-      is="dom-if"
-      if="[[!_computeShowRepoBranchTogether(change.project, change.branch)]]"
-    >
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
-      >
-        <span class="title">Repo</span>
-        <span class="value">
-          <a href$="[[_computeProjectUrl(change.project)]]">
-            <gr-limited-text
-              limit="40"
-              text="[[change.project]]"
-            ></gr-limited-text>
-          </a>
-        </span>
-      </section>
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REPO_BRANCH)]]"
-      >
-        <span class="title">Branch</span>
-        <span class="value">
-          <a href$="[[_computeBranchUrl(change.project, change.branch)]]">
-            <gr-limited-text
-              limit="40"
-              text="[[change.branch]]"
-            ></gr-limited-text>
-          </a>
-        </span>
-      </section>
-    </template>
-    <section
-      class$="[[_computeDisplayState(_showAllSections, change, _SECTION.PARENT)]]"
-    >
-      <span class="title">[[_computeParentsLabel(_currentParents)]]</span>
-      <span class="value">
-        <ol
-          class$="[[_computeParentListClass(_currentParents, parentIsCurrent)]]"
-        >
-          <template is="dom-repeat" items="[[_currentParents]]" as="parent">
-            <li>
-              <gr-commit-info
-                change="[[change]]"
-                commit-info="[[parent]]"
-                server-config="[[serverConfig]]"
-              ></gr-commit-info>
-              <gr-tooltip-content
-                id="parentNotCurrentMessage"
-                has-tooltip
-                show-icon
-                title$="[[_notCurrentMessage]]"
-              ></gr-tooltip-content>
-            </li>
-          </template>
-        </ol>
-      </span>
-    </section>
-    <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>
-    <template is="dom-if" if="[[_showRevertCreatedAs(change)]]">
-      <section
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REVERT_CREATED_AS)]]"
-      >
-        <span class="title"
-          >[[_getRevertSectionTitle(change, revertedChange)]]</span
-        >
-        <span class="value">
-          <gr-commit-info
-            change="[[change]]"
-            commit-info="[[_computeRevertCommit(change, revertedChange)]]"
-            server-config="[[serverConfig]]"
-          ></gr-commit-info>
-        </span>
-      </section>
-    </template>
-    <section
-      class$="topic [[_computeDisplayState(_showAllSections, change, _SECTION.TOPIC)]]"
-    >
-      <span class="title">Topic</span>
-      <span class="value">
-        <template is="dom-if" if="[[_showTopicChip(change.*, _settingTopic)]]">
-          <gr-linked-chip
-            text="[[change.topic]]"
-            limit="40"
-            href="[[_computeTopicUrl(change.topic)]]"
-            removable="[[!_topicReadOnly]]"
-            on-remove="_handleTopicRemoved"
-          ></gr-linked-chip>
-        </template>
-        <template is="dom-if" if="[[_showAddTopic(change.*, _settingTopic)]]">
-          <gr-editable-label
-            class="topicEditableLabel"
-            label-text="Add a topic"
-            value="[[change.topic]]"
-            max-length="1024"
-            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
-        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.CHERRY_PICK_OF)]]"
-      >
-        <span class="title">Cherry pick of</span>
-        <span class="value">
-          <a
-            href$="[[_computeCherryPickOfUrl(change.cherry_pick_of_change, change.cherry_pick_of_patch_set, change.project)]]"
-          >
-            <gr-limited-text
-              text="[[change.cherry_pick_of_change]],[[change.cherry_pick_of_patch_set]]"
-              limit="40"
-            >
-            </gr-limited-text>
-          </a>
-        </span>
-      </section>
-    </template>
-    <section
-      class$="strategy [[_computeDisplayState(_showAllSections, change, _SECTION.STRATEGY)]]"
-      hidden$="[[_computeHideStrategy(change)]]"
-    >
-      <span class="title">Strategy</span>
-      <span class="value">[[_computeStrategy(change)]]</span>
-    </section>
-    <section
-      class$="hashtag [[_computeDisplayState(_showAllSections, change, _SECTION.HASHTAGS)]]"
-    >
-      <span class="title">Hashtags</span>
-      <span class="value">
-        <template is="dom-repeat" items="[[change.hashtags]]">
-          <gr-linked-chip
-            class="hashtagChip"
-            text="[[item]]"
-            href="[[_computeHashtagUrl(item)]]"
-            removable="[[!_hashtagReadOnly]]"
-            on-remove="_handleHashtagRemoved"
-            limit="40"
-          >
-          </gr-linked-chip>
-        </template>
-        <template is="dom-if" if="[[!_hashtagReadOnly]]">
-          <gr-editable-label
-            uppercase=""
-            label-text="Add a hashtag"
-            value="{{_newHashtag}}"
-            placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
-            read-only="[[_hashtagReadOnly]]"
-            on-changed="_handleHashtagChanged"
-            show-as-edit-pencil="true"
-          ></gr-editable-label>
-        </template>
-      </span>
-    </section>
-    <div class="separatedSection">
-      <template is="dom-if" if="[[_showNewSubmitRequirements(change)]]">
-        <gr-submit-requirements
-          change="[[change]]"
-          account="[[account]]"
-          mutable="[[_mutable]]"
-        ></gr-submit-requirements>
-      </template>
-      <template is="dom-if" if="[[!_showNewSubmitRequirements(change)]]">
-        <gr-change-requirements
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[_mutable]]"
-        ></gr-change-requirements>
-      </template>
-      <template is="dom-if" if="[[_showNewSubmitRequirementWarning(change)]]">
-        <div class="submit-requirement-error">
-          New Submit Requirements don't work on this change.
-        </div>
-      </template>
-    </div>
-    <section
-      id="webLinks"
-      hidden$="[[!_computeWebLinks(commitInfo, serverConfig)]]"
-    >
-      <span class="title">Links</span>
-      <span class="value">
-        <template
-          is="dom-repeat"
-          items="[[_computeWebLinks(commitInfo, serverConfig)]]"
-          as="link"
-        >
-          <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
-            [[link.name]]
-          </a>
-        </template>
-      </span>
-    </section>
-    <gr-endpoint-decorator name="change-metadata-item">
-      <gr-endpoint-param name="labels" value="[[labels]]"></gr-endpoint-param>
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-param
-        name="revision"
-        value="[[revision]]"
-      ></gr-endpoint-param>
-    </gr-endpoint-decorator>
-  </gr-external-style>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 422c91b..e45de51 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -1,33 +1,17 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import '../../core/gr-router/gr-router';
+import '../../../test/common-test-setup';
 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 {ChangeRole, GrChangeMetadata} from './gr-change-metadata';
 import {
   createServerInfo,
   createUserConfig,
   createParsedChange,
   createAccountWithId,
-  createRequirement,
   createCommitInfoWithRequiredCommit,
   createWebLinkInfo,
   createGerritInfo,
@@ -35,13 +19,13 @@
   createCommit,
   createRevision,
   createAccountDetailWithId,
-  createChangeConfig,
+  createConfig,
 } from '../../../test/test-data-generators';
 import {
   ChangeStatus,
   SubmitType,
-  RequirementStatus,
   GpgKeyInfoStatus,
+  InheritedBooleanInfoConfiguredValue,
 } from '../../../constants/constants';
 import {
   EmailAddress,
@@ -51,221 +35,241 @@
   RevisionInfo,
   ParentCommitInfo,
   TopicName,
-  ElementPropertyDeepChange,
-  PatchSetNum,
+  RevisionPatchSetNum,
   NumericChangeId,
   LabelValueToDescriptionMap,
   Hashtag,
+  CommitInfo,
 } from '../../../types/common';
-import {SinonStubbedMember} from 'sinon';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
 import {PluginApi} from '../../../api/plugin';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  queryAndAssert,
+  stubRestApi,
+  waitUntilCalled,
+} from '../../../test/test-utils';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
 import {GrButton} from '../../shared/gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-change-metadata');
-
-const pluginApi = _testOnly_initGerritPluginApi();
+import {nothing} from 'lit';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 suite('gr-change-metadata tests', () => {
   let element: GrChangeMetadata;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('getConfig').returns(
       Promise.resolve({
         ...createServerInfo(),
         user: {
           ...createUserConfig(),
-          anonymous_coward_name: 'test coward name',
+          anonymouscowardname: 'test coward name',
         },
       })
     );
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-change-metadata></gr-change-metadata>`);
+    element.change = createParsedChange();
+    await element.updateComplete;
   });
 
-  test('_computeMergedCommitInfo', () => {
+  test('renders', async () => {
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div>
+      <div class="metadata-header">
+        <h3 class="heading-3 metadata-title">Change Info</h3>
+        <gr-button
+          class="show-all-button"
+          link=""
+          role="button"
+          tabindex="0"
+          aria-disabled="false"
+        >
+          Show all <gr-icon icon="expand_more"></gr-icon>
+          <gr-icon hidden="" icon="expand_less"></gr-icon>
+        </gr-button>
+      </div>
+      <section class="hideDisplay">
+        <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 showyesterday="" withtooltip="">
+          </gr-date-formatter>
+        </span>
+      </section>
+      <section>
+        <span class="title">
+          <gr-tooltip-content
+            has-tooltip=""
+            title="This user created or uploaded the first patchset of this change."
+          >
+            Owner
+          </gr-tooltip-content>
+        </span>
+        <span class="value">
+          <gr-account-chip highlightattention=""
+            ><gr-vote-chip circle-shape="" slot="vote-chip"> </gr-vote-chip
+          ></gr-account-chip>
+        </span>
+      </section>
+      <section>
+        <span class="title">
+          <gr-tooltip-content
+            has-tooltip=""
+            title="This user wrote the code change."
+          >
+            Author
+          </gr-tooltip-content>
+        </span>
+        <span class="value">
+          <gr-account-chip><gr-vote-chip circle-shape="" slot="vote-chip"></gr-vote-chip
+          ></gr-account-chip>
+        </span>
+      </section>
+      <section>
+        <span class="title">
+          <gr-tooltip-content
+            has-tooltip=""
+            title="This user committed the code change to the Git repository (typically to the local Git repo before uploading)."
+          >
+            Committer
+          </gr-tooltip-content>
+        </span>
+        <span class="value">
+          <gr-account-chip><gr-vote-chip circle-shape="" slot="vote-chip"></gr-account-chip>
+        </span>
+      </section>
+      <section>
+        <span class="title"> Reviewers </span>
+        <span class="value">
+          <gr-reviewer-list reviewers-only=""> </gr-reviewer-list>
+        </span>
+      </section>
+      <section class="hideDisplay">
+        <span class="title"> CC </span>
+        <span class="value">
+          <gr-reviewer-list ccs-only=""> </gr-reviewer-list>
+        </span>
+      </section>
+      <section>
+          <span class="title">
+            <gr-tooltip-content
+              has-tooltip=""
+              title="Repository and branch that the change will be merged into if submitted."
+            >
+              Repo | Branch
+            </gr-tooltip-content>
+          </span>
+          <span class="value">
+            <a href="/q/project:test-project">
+              test-project
+            </a>
+            |
+            <a href="/q/project:test-project+branch:test-branch+status:open">
+              test-branch
+            </a>
+          </span>
+        </section>
+      <section class="hideDisplay">
+        <span class="title">Parent</span>
+        <span class="value">
+          <ol  class="nonMerge notCurrent parentList"></ol>
+        </span>
+      </section>
+      <section class="hideDisplay strategy">
+        <span class="title"> Strategy </span> <span class="value"> </span>
+      </section>
+      <section class="hashtag hideDisplay">
+        <span class="title"> Hashtags </span>
+        <span class="value"> </span>
+      </section>
+      <div class="separatedSection">
+      <gr-submit-requirements></gr-submit-requirements>
+      </div>
+      <gr-endpoint-decorator name="change-metadata-item">
+        <gr-endpoint-param name="labels"> </gr-endpoint-param>
+        <gr-endpoint-param name="change"> </gr-endpoint-param>
+        <gr-endpoint-param name="revision"> </gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </div>`
+    );
+  });
+
+  test('computeMergedCommitInfo', () => {
     const dummyRevs: {[revisionId: string]: RevisionInfo} = {
       1: createRevision(1),
       2: createRevision(2),
     };
     assert.deepEqual(
-      element._computeMergedCommitInfo('0' as CommitId, dummyRevs),
-      {}
+      element.computeMergedCommitInfo('0' as CommitId, dummyRevs),
+      undefined
     );
     assert.deepEqual(
-      element._computeMergedCommitInfo('1' as CommitId, dummyRevs),
+      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]);
+    const commit = element.computeMergedCommitInfo('2' as CommitId, dummyRevs);
+    assert.notDeepEqual(commit, dummyRevs[2] as unknown as CommitInfo);
     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', () => {
+  test('show strategy for open change', async () => {
     element.change = {
       ...createParsedChange(),
       status: ChangeStatus.NEW,
       submit_type: SubmitType.CHERRY_PICK,
       labels: {},
     };
-    flush();
+    await element.updateComplete;
     const strategy = element.shadowRoot?.querySelector('.strategy');
     assert.ok(strategy);
     assert.isFalse(strategy?.hasAttribute('hidden'));
-    assert.equal(strategy?.children[1].innerHTML, 'Cherry Pick');
+    assert.equal(strategy?.children[1].textContent, 'Cherry Pick');
   });
 
-  test('hide strategy for closed change', () => {
+  test('hide strategy for closed change', async () => {
     element.change = {
       ...createParsedChange(),
       status: ChangeStatus.MERGED,
       labels: {},
     };
-    flush();
-    assert.isTrue(
-      element.shadowRoot?.querySelector('.strategy')?.hasAttribute('hidden')
-    );
+    await element.updateComplete;
+    assert.isNull(element.shadowRoot?.querySelector('.strategy'));
   });
 
-  test('weblinks use GerritNav interface', () => {
-    const weblinksStub = sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .returns([{name: 'stubb', url: '#s'}]);
+  test('weblinks hidden when no weblinks', async () => {
     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);
+    await element.updateComplete;
+    assert.isNull(element.webLinks);
   });
 
-  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', () => {
+  test('weblinks hidden when only gitiles weblink', async () => {
     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);
+    await element.updateComplete;
+    assert.isNull(element.webLinks);
+    assert.equal(element.computeWebLinks().length, 0);
   });
 
-  test('weblinks hidden when sole weblink is set as primary', () => {
+  test('weblinks hidden when sole weblink is set as primary', async () => {
     const browser = 'browser';
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
@@ -278,42 +282,22 @@
         primary_weblink_name: browser,
       },
     };
-    flush();
-    const webLinks = element.$.webLinks;
-    assert.isTrue(webLinks.hasAttribute('hidden'));
+    await element.updateComplete;
+    assert.isNull(element.webLinks);
   });
 
-  test('weblinks are visible when other weblinks', () => {
-    const router = document.createElement('gr-router');
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router._generateWeblinks.bind(router));
-
+  test('weblinks are visible when other weblinks', async () => {
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
       web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
     };
-    flush();
-    const webLinks = element.$.webLinks;
+    await element.updateComplete;
+    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);
+    assert.equal(element.computeWebLinks().length, 1);
   });
 
-  test('weblinks are visible when gitiles and other weblinks', () => {
-    const router = document.createElement('gr-router');
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router._generateWeblinks.bind(router));
-
+  test('weblinks are visible when gitiles and other weblinks', async () => {
     element.commitInfo = {
       ...createCommitInfoWithRequiredCommit(),
       web_links: [
@@ -321,14 +305,14 @@
         {...createWebLinkInfo(), name: 'gitiles', url: '#'},
       ],
     };
-    flush();
-    const webLinks = element.$.webLinks;
+    await element.updateComplete;
+    const webLinks = element.webLinks!;
     assert.isFalse(webLinks.hasAttribute('hidden'));
     // Only the non-gitiles weblink is returned.
-    assert.equal(element._computeWebLinks(element.commitInfo)?.length, 1);
+    assert.equal(element.computeWebLinks().length, 1);
   });
 
-  suite('_getNonOwnerRole', () => {
+  suite('getNonOwnerRole', () => {
     let change: ParsedChangeInfo | undefined;
 
     setup(() => {
@@ -362,95 +346,85 @@
     });
 
     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 for uploader', () => {
+        element.change = change;
+        assert.deepEqual(element.getNonOwnerRole(ChangeRole.UPLOADER), {
+          ...createAccountWithId(),
+          email: 'ghi@def' as EmailAddress,
+          _account_id: 1011123 as AccountId,
+        });
       });
 
-      test('_getNonOwnerRole that it does not return uploader', () => {
+      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)
-        );
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.UPLOADER));
       });
 
-      test('_computeShowRoleClass show uploader', () => {
-        assert.equal(
-          element._computeShowRoleClass(change, element._CHANGE_ROLE.UPLOADER),
-          ''
-        );
+      test('computeShowRoleClass show uploader', () => {
+        element.change = change;
+        assert.notEqual(element.renderNonOwner(ChangeRole.UPLOADER), nothing);
       });
 
-      test('_computeShowRoleClass hide 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'
-        );
+        element.change = change;
+        assert.equal(element.renderNonOwner(ChangeRole.UPLOADER), nothing);
       });
     });
 
     suite('role=committer', () => {
-      test('_getNonOwnerRole for 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' as EmailAddress}
-        );
+        element.change = change;
+        assert.deepEqual(element.getNonOwnerRole(ChangeRole.COMMITTER), {
+          ...createGitPerson(),
+          email: 'ghi@def' as EmailAddress,
+        });
       });
 
-      test('_getNonOwnerRole is null if committer is same as uploader', () => {
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
-        );
+      test('getNonOwnerRole is null if committer is same as uploader', () => {
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.COMMITTER));
       });
 
-      test('_getNonOwnerRole that it does not return 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' as EmailAddress;
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
-        );
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.COMMITTER));
       });
 
-      test('_getNonOwnerRole null for committer with no commit', () => {
+      test('getNonOwnerRole null for committer with no commit', () => {
         delete change!.revisions.rev1.commit;
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
-        );
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.COMMITTER));
       });
     });
 
     suite('role=author', () => {
-      test('_getNonOwnerRole for author', () => {
-        assert.deepEqual(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
-          {...createGitPerson(), email: 'jkl@def' as EmailAddress}
-        );
+      test('getNonOwnerRole for author', () => {
+        element.change = change;
+        assert.deepEqual(element.getNonOwnerRole(ChangeRole.AUTHOR), {
+          ...createGitPerson(),
+          email: 'jkl@def' as EmailAddress,
+        });
       });
 
-      test('_getNonOwnerRole that it does not return author', () => {
+      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' as EmailAddress;
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
-        );
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.AUTHOR));
       });
 
-      test('_getNonOwnerRole null for author with no commit', () => {
+      test('getNonOwnerRole null for author with no commit', () => {
         delete change!.revisions.rev1.commit;
-        assert.isNotOk(
-          element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
-        );
+        element.change = change;
+        assert.isNotOk(element.getNonOwnerRole(ChangeRole.AUTHOR));
       });
     });
   });
@@ -485,6 +459,13 @@
         labels: {},
         mergeable: true,
       };
+      element.repoConfig = {
+        ...createConfig(),
+        enable_signed_push: {
+          configured_value: 'TRUE' as InheritedBooleanInfoConfiguredValue,
+          value: true,
+        },
+      };
     });
 
     test('Push Certificate Validation test BAD', () => {
@@ -495,16 +476,15 @@
           problems: ['No public keys found for key ID E5E20E52'],
         },
       };
-      const result = element._computePushCertificateValidation(
-        serverConfig,
-        change
-      );
+      element.change = change;
+      element.serverConfig = serverConfig;
+      const result = element.computePushCertificateValidation();
       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?.icon, 'close');
       assert.equal(result?.class, 'invalid');
     });
 
@@ -515,34 +495,64 @@
           status: GpgKeyInfoStatus.TRUSTED,
         },
       };
-      const result = element._computePushCertificateValidation(
-        serverConfig,
-        change
-      );
+      element.change = change;
+      element.serverConfig = serverConfig;
+      const result = element.computePushCertificateValidation();
       assert.equal(
         result?.message,
         'Push certificate is valid and key is trusted'
       );
-      assert.equal(result?.icon, 'gr-icons:check');
+      assert.equal(result?.icon, 'check');
       assert.equal(result?.class, 'trusted');
     });
 
     test('Push Certificate Validation is missing test', () => {
-      change!.revisions.rev1! = createRevision(1);
-      const result = element._computePushCertificateValidation(
-        serverConfig,
-        change
-      );
+      change!.revisions.rev1 = createRevision(1);
+      element.change = change;
+      element.serverConfig = serverConfig;
+      const result = element.computePushCertificateValidation();
       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');
+      assert.equal(result?.icon, 'help');
+      assert.equal(result?.class, 'help filled');
+    });
+
+    test('computePushCertificateValidation returns undefined', () => {
+      element.change = change;
+      delete serverConfig!.receive!.enable_signed_push;
+      element.serverConfig = serverConfig;
+      assert.isUndefined(element.computePushCertificateValidation());
+    });
+
+    test('isEnabledSignedPushOnRepo', () => {
+      change!.revisions.rev1!.push_certificate = {
+        certificate: 'Push certificate',
+        key: {
+          status: GpgKeyInfoStatus.TRUSTED,
+        },
+      };
+      element.change = change;
+      element.serverConfig = serverConfig;
+      element.repoConfig!.enable_signed_push!.configured_value =
+        InheritedBooleanInfoConfiguredValue.INHERIT;
+      element.repoConfig!.enable_signed_push!.inherited_value = true;
+      assert.isTrue(element.isEnabledSignedPushOnRepo());
+
+      element.repoConfig!.enable_signed_push!.inherited_value = false;
+      assert.isFalse(element.isEnabledSignedPushOnRepo());
+
+      element.repoConfig!.enable_signed_push!.configured_value =
+        InheritedBooleanInfoConfiguredValue.TRUE;
+      assert.isTrue(element.isEnabledSignedPushOnRepo());
+
+      element.repoConfig = undefined;
+      assert.isFalse(element.isEnabledSignedPushOnRepo());
     });
   });
 
-  test('_computeParents', () => {
+  test('computeParents', () => {
     const parents: ParentCommitInfo[] = [
       {...createCommit(), commit: '123' as CommitId, subject: 'abc'},
     ];
@@ -550,7 +560,9 @@
       ...createRevision(1),
       commit: {...createCommit(), parents},
     };
-    assert.equal(element._computeParents(undefined, revision), parents);
+    element.change = undefined;
+    element.revision = revision;
+    assert.equal(element.computeParents(), parents);
     const change = (current_revision: CommitId): ParsedChangeInfo => {
       return {
         ...createParsedChange(),
@@ -558,22 +570,25 @@
         revisions: {456: revision},
       };
     };
-    const change_bad_revision = change('789' as CommitId);
-    assert.deepEqual(
-      element._computeParents(change_bad_revision, createRevision()),
-      []
-    );
-    const change_no_commit: ParsedChangeInfo = {
+    const changebadrevision = change('789' as CommitId);
+    element.change = changebadrevision;
+    element.revision = createRevision();
+    assert.deepEqual(element.computeParents(), []);
+    const changenocommit: 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);
+    element.change = changenocommit;
+    element.revision = undefined;
+    assert.deepEqual(element.computeParents(), []);
+    const changegood = change('456' as CommitId);
+    element.change = changegood;
+    element.revision = undefined;
+    assert.equal(element.computeParents(), parents);
   });
 
-  test('_currentParents', () => {
+  test('currentParents', async () => {
     const revision = (parent: CommitId): RevisionInfo => {
       return {
         ...createRevision(),
@@ -590,91 +605,116 @@
       owner: {},
     };
     element.revision = revision('222' as CommitId);
-    assert.equal(element._currentParents[0].commit, '222');
+    await element.updateComplete;
+    assert.equal(element.currentParents[0].commit, '222');
     element.revision = revision('333' as CommitId);
-    assert.equal(element._currentParents[0].commit, '333');
+    await element.updateComplete;
+    assert.equal(element.currentParents[0].commit, '333');
     element.revision = undefined;
-    assert.equal(element._currentParents[0].commit, '111');
+    await element.updateComplete;
+    assert.equal(element.currentParents[0].commit, '111');
     element.change = createParsedChange();
-    assert.deepEqual(element._currentParents, []);
+    await element.updateComplete;
+    assert.deepEqual(element.currentParents, []);
   });
 
-  test('_computeParentsLabel', () => {
+  test('computeParentListClass', () => {
     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',
-    };
+    element.currentParents = [parent];
+    element.parentIsCurrent = true;
     assert.equal(
-      element._computeParentListClass([parent], true),
+      element.computeParentListClass(),
       'parentList nonMerge current'
     );
+    element.currentParents = [parent];
+    element.parentIsCurrent = false;
     assert.equal(
-      element._computeParentListClass([parent], false),
+      element.computeParentListClass(),
       'parentList nonMerge notCurrent'
     );
+    element.currentParents = [parent, parent];
+    element.parentIsCurrent = false;
     assert.equal(
-      element._computeParentListClass([parent, parent], false),
+      element.computeParentListClass(),
       'parentList merge notCurrent'
     );
-    assert.equal(
-      element._computeParentListClass([parent, parent], true),
-      'parentList merge current'
-    );
+    element.currentParents = [parent, parent];
+    element.parentIsCurrent = true;
+    assert.equal(element.computeParentListClass(), '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('showAddTopic', () => {
+    const change = createParsedChange();
+    element.change = undefined;
+    element.settingTopic = false;
+    element.topicReadOnly = false;
+    assert.isTrue(element.showAddTopic());
+    // do not show for 'readonly'
+    element.change = undefined;
+    element.settingTopic = false;
+    element.topicReadOnly = true;
+    assert.isFalse(element.showAddTopic());
+    element.change = change;
+    element.settingTopic = false;
+    element.topicReadOnly = false;
+    assert.isTrue(element.showAddTopic());
+    element.change = change;
+    element.settingTopic = true;
+    element.topicReadOnly = false;
+    assert.isFalse(element.showAddTopic());
+    change.topic = 'foo' as TopicName;
+    element.change = change;
+    element.settingTopic = true;
+    element.topicReadOnly = false;
+    assert.isFalse(element.showAddTopic());
+    element.change = change;
+    element.settingTopic = false;
+    element.topicReadOnly = false;
+    assert.isFalse(element.showAddTopic());
   });
 
-  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('showTopicChip', async () => {
+    const change = createParsedChange();
+    element.change = change;
+    element.settingTopic = true;
+    await element.updateComplete;
+    assert.isFalse(element.showTopicChip());
+    element.change = change;
+    element.settingTopic = false;
+    await element.updateComplete;
+    assert.isFalse(element.showTopicChip());
+    element.change = change;
+    element.settingTopic = true;
+    await element.updateComplete;
+    assert.isFalse(element.showTopicChip());
+    change.topic = 'foo' as TopicName;
+    element.change = change;
+    element.settingTopic = true;
+    await element.updateComplete;
+    assert.isFalse(element.showTopicChip());
+    element.change = change;
+    element.settingTopic = false;
+    await element.updateComplete;
+    assert.isTrue(element.showTopicChip());
   });
 
-  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));
+  test('showCherryPickOf', async () => {
+    element.change = undefined;
+    await element.updateComplete;
+    assert.isFalse(element.showCherryPickOf());
+    const change = createParsedChange();
+    element.change = change;
+    await element.updateComplete;
+    assert.isFalse(element.showCherryPickOf());
+    change.cherry_pick_of_change = 123 as NumericChangeId;
+    change.cherry_pick_of_patch_set = 1 as RevisionPatchSetNum;
+    element.change = change;
+    await element.updateComplete;
+    assert.isTrue(element.showCherryPickOf());
   });
 
   suite('Topic removal', () => {
@@ -699,22 +739,27 @@
       };
     });
 
-    test('_computeTopicReadOnly', () => {
+    test('computeTopicReadOnly', () => {
       let mutable = false;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      element.mutable = mutable;
+      element.change = change;
+      assert.isTrue(element.computeTopicReadOnly());
       mutable = true;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
-      change!.actions!.topic!.enabled = true;
-      assert.isFalse(element._computeTopicReadOnly(mutable, change));
+      element.mutable = mutable;
+      assert.isTrue(element.computeTopicReadOnly());
+      change.actions!.topic!.enabled = true;
+      element.mutable = mutable;
+      element.change = change;
+      assert.isFalse(element.computeTopicReadOnly());
       mutable = false;
-      assert.isTrue(element._computeTopicReadOnly(mutable, change));
+      element.mutable = mutable;
+      assert.isTrue(element.computeTopicReadOnly());
     });
 
     test('topic read only hides delete button', async () => {
       element.account = createAccountDetailWithId();
       element.change = change;
-      sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:test');
-      await flush();
+      await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
       assert.isTrue(button.hasAttribute('hidden'));
@@ -724,8 +769,7 @@
       element.account = createAccountDetailWithId();
       change.actions!.topic!.enabled = true;
       element.change = change;
-      sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:test');
-      await flush();
+      await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
       assert.isFalse(button.hasAttribute('hidden'));
@@ -752,40 +796,51 @@
       };
     });
 
-    test('_computeHashtagReadOnly', async () => {
-      await flush();
+    test('computeHashtagReadOnly', async () => {
+      await element.updateComplete;
       let mutable = false;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      element.change = change;
+      element.mutable = mutable;
+      await element.updateComplete;
+      assert.isTrue(element.computeHashtagReadOnly());
       mutable = true;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
-      change!.actions!.hashtags!.enabled = true;
-      assert.isFalse(element._computeHashtagReadOnly(mutable, change));
+      element.change = change;
+      element.mutable = mutable;
+      await element.updateComplete;
+      assert.isTrue(element.computeHashtagReadOnly());
+      change.actions!.hashtags!.enabled = true;
+      element.change = change;
+      element.mutable = mutable;
+      await element.updateComplete;
+      assert.isFalse(element.computeHashtagReadOnly());
       mutable = false;
-      assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      element.change = change;
+      element.mutable = mutable;
+      await element.updateComplete;
+      assert.isTrue(element.computeHashtagReadOnly());
     });
 
     test('hashtag read only hides delete button', async () => {
-      await flush();
       element.account = createAccountDetailWithId();
       element.change = change;
-      sinon
-        .stub(GerritNav, 'getUrlForHashtag')
-        .returns('/q/hashtag:test+(status:open%20OR%20status:merged)');
-      await flush();
+      await element.updateComplete;
+      assert.isTrue(element.mutable, 'Mutable');
+      assert.isFalse(
+        element.change.actions?.hashtags?.enabled,
+        'hashtags disabled'
+      );
+      assert.isTrue(element.hashtagReadOnly, 'hashtag read only');
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
-      assert.isTrue(button.hasAttribute('hidden'));
+      assert.isTrue(button.hasAttribute('hidden'), 'button hidden');
     });
 
     test('hashtag not read only does not hide delete button', async () => {
-      await flush();
+      await element.updateComplete;
       element.account = createAccountDetailWithId();
-      change!.actions!.hashtags!.enabled = true;
+      change.actions!.hashtags!.enabled = true;
       element.change = change;
-      sinon
-        .stub(GerritNav, 'getUrlForHashtag')
-        .returns('/q/hashtag:test+(status:open%20OR%20status:merged)');
-      await flush();
+      await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
       const button = queryAndAssert<GrButton>(chip, 'gr-button');
       assert.isFalse(button.hasAttribute('hidden'));
@@ -793,8 +848,8 @@
   });
 
   suite('remove reviewer votes', () => {
-    setup(() => {
-      sinon.stub(element, '_computeTopicReadOnly').returns(true);
+    setup(async () => {
+      sinon.stub(element, 'computeTopicReadOnly').returns(true);
       element.change = {
         ...createParsedChange(),
         topic: 'the topic' as TopicName,
@@ -807,81 +862,27 @@
         },
         removable_reviewers: [],
       };
-      flush();
+      await element.updateComplete;
     });
 
-    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', () => {
+    test('changing topic', async () => {
       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);
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+
+      element.handleTopicChanged(new CustomEvent('test', {detail: newTopic}));
+
       assert.isTrue(
         setChangeTopicStub.calledWith(42 as NumericChangeId, newTopic)
       );
-      return setChangeTopicStub.lastCall.returnValue.then(() => {
-        assert.equal(element.change!.topic, newTopic);
-        assert.isTrue(topicChangedSpy.called);
+      await setChangeTopicStub.lastCall.returnValue;
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'Saving topic and reloading ...',
+        showDismiss: true,
       });
     });
 
@@ -890,37 +891,45 @@
       const setChangeTopicStub = stubRestApi('setChangeTopic').returns(
         Promise.resolve(newTopic)
       );
-      sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:the+new+topic');
-      await flush();
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      await element.updateComplete;
       const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
-      const remove = queryAndAssert(chip, '#remove');
-      const topicChangedSpy = sinon.spy();
-      element.addEventListener('topic-changed', topicChangedSpy);
-      tap(remove);
+      const remove = queryAndAssert<GrButton>(chip, '#remove');
+
+      remove.click();
+
       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);
+      await setChangeTopicStub.lastCall.returnValue;
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'Removing topic and reloading ...',
+        showDismiss: true,
       });
     });
 
     test('changing hashtag', async () => {
-      await flush();
-      element._newHashtag = 'new hashtag' as Hashtag;
+      await element.updateComplete;
       const newHashtag: Hashtag[] = ['new hashtag' as Hashtag];
       const setChangeHashtagStub = stubRestApi('setChangeHashtag').returns(
         Promise.resolve(newHashtag)
       );
-      element._handleHashtagChanged();
+      const alertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
+      element.handleHashtagChanged(
+        new CustomEvent('test', {detail: 'new hashtag'})
+      );
       assert.isTrue(
         setChangeHashtagStub.calledWith(42 as NumericChangeId, {
           add: ['new hashtag' as Hashtag],
         })
       );
-      return setChangeHashtagStub.lastCall.returnValue.then(() => {
-        assert.equal(element.change!.hashtags, newHashtag);
+      await setChangeHashtagStub.lastCall.returnValue;
+      await waitUntilCalled(alertStub, 'alertStub');
+      assert.deepEqual(alertStub.lastCall.args[0].detail, {
+        message: 'Saving hashtag and reloading ...',
+        showDismiss: true,
       });
     });
   });
@@ -931,7 +940,7 @@
       ...createParsedChange(),
       actions: {topic: {enabled: true}},
     };
-    await flush();
+    await element.updateComplete;
 
     const label = element.shadowRoot!.querySelector(
       '.topicEditableLabel'
@@ -939,38 +948,42 @@
     assert.ok(label);
     const openStub = sinon.stub(label, 'open');
     element.editTopic();
-    await flush();
+    await element.updateComplete;
 
     assert.isTrue(openStub.called);
   });
 
   suite('plugin endpoints', () => {
-    test('endpoint params', async () => {
+    setup(async () => {
+      element = await fixture(html`<gr-change-metadata></gr-change-metadata>`);
       element.change = createParsedChange();
       element.revision = createRevision();
+      await element.updateComplete;
+    });
+
+    test('endpoint params', async () => {
       interface MetadataGrEndpointDecorator extends GrEndpointDecorator {
         plugin: PluginApi;
         change: ParsedChangeInfo;
         revision: RevisionInfo;
       }
-      let hookEl: MetadataGrEndpointDecorator;
       let plugin: PluginApi;
-      pluginApi.install(
+      window.Gerrit.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([]);
-      await flush();
-      assert.strictEqual(hookEl!.plugin, plugin!);
-      assert.strictEqual(hookEl!.change, element.change);
-      assert.strictEqual(hookEl!.revision, element.revision);
+      await element.updateComplete;
+      const hookEl = (await plugin!
+        .hook('change-metadata-item')
+        .getLastAttached()) as MetadataGrEndpointDecorator;
+      testResolver(pluginLoaderToken).loadPlugins([]);
+      await element.updateComplete;
+      assert.strictEqual(hookEl.plugin, plugin!);
+      assert.strictEqual(hookEl.change, element.change);
+      assert.strictEqual(hookEl.revision, element.revision);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
deleted file mode 100644
index 725bb24..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ /dev/null
@@ -1,208 +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 '../../../styles/shared-styles';
-import '../../../styles/gr-font-styles';
-import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-label-info/gr-label-info';
-import '../../shared/gr-limited-text/gr-limited-text';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-requirements_html';
-import {customElement, property, observe} from '@polymer/decorators';
-import {
-  ChangeInfo,
-  AccountInfo,
-  QuickLabelInfo,
-  Requirement,
-  RequirementType,
-  LabelNameToInfoMap,
-  LabelInfo,
-} from '../../../types/common';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {appContext} from '../../../services/app-context';
-import {labelCompare} from '../../../utils/label-util';
-import {Interaction} from '../../../constants/reporting';
-
-interface ChangeRequirement extends Requirement {
-  satisfied: boolean;
-  style: string;
-}
-
-interface ChangeWIP {
-  type: RequirementType;
-  fallback_text: string;
-  tooltip: string;
-}
-
-export interface Label {
-  labelName: string;
-  labelInfo: LabelInfo;
-  icon: string;
-  style: string;
-}
-
-@customElement('gr-change-requirements')
-export class GrChangeRequirements extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
-  change?: ChangeInfo;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  @property({type: Boolean})
-  mutable?: boolean;
-
-  @property({type: Array, computed: '_computeRequirements(change)'})
-  _requirements?: Array<ChangeRequirement | ChangeWIP>;
-
-  @property({type: Array})
-  _requiredLabels: Label[] = [];
-
-  @property({type: Array})
-  _optionalLabels: Label[] = [];
-
-  @property({type: Boolean, computed: '_computeShowWip(change)'})
-  _showWip?: boolean;
-
-  @property({type: Boolean})
-  _showOptionalLabels = true;
-
-  private readonly reporting = appContext.reportingService;
-
-  _computeShowWip(change: ChangeInfo) {
-    return change.work_in_progress;
-  }
-
-  _computeRequirements(change: ChangeInfo) {
-    const _requirements: Array<ChangeRequirement | ChangeWIP> = [];
-
-    if (change.requirements) {
-      for (const requirement of change.requirements) {
-        const satisfied = requirement.status === 'OK';
-        const style = this._computeRequirementClass(satisfied);
-        _requirements.push({...requirement, satisfied, style});
-      }
-    }
-    if (change.work_in_progress) {
-      _requirements.push({
-        type: 'wip' as RequirementType,
-        fallback_text: 'Work-in-progress',
-        tooltip: "Change must not be in 'Work in Progress' state.",
-      });
-    }
-
-    return _requirements;
-  }
-
-  _computeRequirementClass(requirementStatus: boolean) {
-    return requirementStatus ? 'approved' : '';
-  }
-
-  _computeRequirementIcon(requirementStatus: boolean) {
-    return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
-  }
-
-  @observe('change.labels.*')
-  _computeLabels(
-    labelsRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      LabelNameToInfoMap
-    >
-  ) {
-    const labels = labelsRecord.base || {};
-    const allLabels: Label[] = [];
-
-    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);
-  }
-
-  /**
-   * @return The icon name, or undefined if no icon should
-   * be used.
-   */
-  _computeLabelIcon(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'gr-icons:check';
-    }
-    if (labelInfo.rejected) {
-      return 'gr-icons:close';
-    }
-    return 'gr-icons:schedule';
-  }
-
-  _computeLabelClass(labelInfo: QuickLabelInfo) {
-    if (labelInfo.approved) {
-      return 'approved';
-    }
-    if (labelInfo.rejected) {
-      return 'rejected';
-    }
-    return '';
-  }
-
-  _computeShowOptional(
-    optionalFieldsRecord: PolymerDeepPropertyChange<Label[], Label[]>
-  ) {
-    return optionalFieldsRecord.base.length ? '' : 'hidden';
-  }
-
-  _computeLabelValue(value: number) {
-    return `${value > 0 ? '+' : ''}${value}`;
-  }
-
-  _computeSectionClass(show: boolean) {
-    return show ? '' : 'hidden';
-  }
-
-  _handleShowHide() {
-    this._showOptionalLabels = !this._showOptionalLabels;
-    this.reporting.reportInteraction(Interaction.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 {
-  interface HTMLElementTagNameMap {
-    'gr-change-requirements': GrChangeRequirements;
-  }
-}
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
deleted file mode 100644
index 6991c03..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: table;
-      width: 100%;
-    }
-    .status {
-      color: var(--warning-foreground);
-      display: inline-block;
-      text-align: center;
-      vertical-align: top;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    .approved.status {
-      color: var(--positive-green-text-color);
-    }
-    .rejected.status {
-      color: var(--negative-red-text-color);
-    }
-    iron-icon {
-      color: inherit;
-    }
-    .status iron-icon {
-      vertical-align: top;
-    }
-    gr-endpoint-decorator.submit-requirement-endpoints,
-    section {
-      display: table-row;
-    }
-    .show-hide {
-      float: right;
-    }
-    .title {
-      min-width: 10em;
-      padding: var(--spacing-s) var(--spacing-m) 0
-        var(--requirements-horizontal-padding);
-    }
-    .value {
-      padding: var(--spacing-s) 0 0 0;
-    }
-    .title,
-    .value {
-      display: table-cell;
-      vertical-align: top;
-    }
-    .hidden {
-      display: none;
-    }
-    .showHide {
-      cursor: pointer;
-    }
-    .showHide .title {
-      padding-bottom: var(--spacing-m);
-      padding-top: var(--spacing-l);
-    }
-    .showHide .value {
-      padding-top: 0;
-      vertical-align: middle;
-    }
-    .showHide iron-icon {
-      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 heading-3">Submit requirements</h3>
-  <template is="dom-repeat" items="[[_requirements]]">
-    <gr-endpoint-decorator
-      class="submit-requirement-endpoints"
-      name$="[[_computeSubmitRequirementEndpoint(item)]]"
-    >
-      <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      <gr-endpoint-param name="requirement" value="[[item]]">
-      </gr-endpoint-param>
-      <div class="title requirement">
-        <span class$="status [[item.style]]">
-          <iron-icon
-            class="icon"
-            icon="[[_computeRequirementIcon(item.satisfied)]]"
-          ></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          tooltip="[[item.tooltip]]"
-          text="[[item.fallback_text]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-endpoint-slot name="value"></gr-endpoint-slot>
-      </div>
-    </gr-endpoint-decorator>
-  </template>
-  <template is="dom-repeat" items="[[_requiredLabels]]">
-    <section>
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-          showAlwaysOldUI
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section class="spacer"></section>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]]"
-  ></section>
-  <section class$="showHide [[_computeShowOptional(_optionalLabels.*)]]">
-    <div class="title">
-      <h3 class="metadata-title">Other labels</h3>
-    </div>
-    <div class="value">
-      <gr-button link="" class="show-all-button" on-click="_handleShowHide"
-        >[[_computeShowAllLabelText(_showOptionalLabels)]]
-        <iron-icon
-          icon="gr-icons:expand-more"
-          hidden$="[[_showOptionalLabels]]"
-        ></iron-icon
-        ><iron-icon
-          icon="gr-icons:expand-less"
-          hidden$="[[!_showOptionalLabels]]"
-        ></iron-icon>
-      </gr-button>
-    </div>
-  </section>
-  <template is="dom-repeat" items="[[_optionalLabels]]">
-    <section class$="optional [[_computeSectionClass(_showOptionalLabels)]]">
-      <div class="title">
-        <span class$="status [[item.style]]">
-          <template is="dom-if" if="[[item.icon]]">
-            <iron-icon class="icon" icon="[[item.icon]]"></iron-icon>
-          </template>
-          <template is="dom-if" if="[[!item.icon]]">
-            <span>[[_computeLabelValue(item.labelInfo.value)]]</span>
-          </template>
-        </span>
-        <gr-limited-text
-          class="name"
-          limit="25"
-          text="[[item.labelName]]"
-        ></gr-limited-text>
-      </div>
-      <div class="value">
-        <gr-label-info
-          change="{{change}}"
-          account="[[account]]"
-          mutable="[[mutable]]"
-          label="[[item.labelName]]"
-          label-info="[[item.labelInfo]]"
-          showAlwaysOldUI
-        ></gr-label-info>
-      </div>
-    </section>
-  </template>
-  <section
-    class$="spacer [[_computeShowOptional(_optionalLabels.*)]] [[_computeSectionClass(_showOptionalLabels)]]"
-  ></section>
-`;
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
deleted file mode 100644
index 90f9d29..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
+++ /dev/null
@@ -1,222 +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-change-requirements.js';
-import {isHidden} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-change-requirements');
-
-suite('gr-change-metadata tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('requirements computed fields', () => {
-    assert.isTrue(element._computeShowWip({work_in_progress: true}));
-    assert.isFalse(element._computeShowWip({work_in_progress: false}));
-
-    assert.equal(element._computeRequirementClass(true), 'approved');
-    assert.equal(element._computeRequirementClass(false), '');
-
-    assert.equal(element._computeRequirementIcon(true), 'gr-icons:check');
-    assert.equal(element._computeRequirementIcon(false),
-        'gr-icons:schedule');
-  });
-
-  test('label computed fields', () => {
-    assert.equal(element._computeLabelIcon({approved: []}), 'gr-icons:check');
-    assert.equal(element._computeLabelIcon({rejected: []}), 'gr-icons:close');
-    assert.equal(element._computeLabelIcon({}), 'gr-icons:schedule');
-
-    assert.equal(element._computeLabelClass({approved: []}), 'approved');
-    assert.equal(element._computeLabelClass({rejected: []}), 'rejected');
-    assert.equal(element._computeLabelClass({}), '');
-    assert.equal(element._computeLabelClass({value: 0}), '');
-
-    assert.equal(element._computeLabelValue(1), '+1');
-    assert.equal(element._computeLabelValue(-1), '-1');
-    assert.equal(element._computeLabelValue(0), '0');
-  });
-
-  test('_computeLabels', () => {
-    assert.equal(element._optionalLabels.length, 0);
-    assert.equal(element._requiredLabels.length, 0);
-    element._computeLabels({base: {
-      test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        value: 1,
-      },
-      opt_test: {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-        optional: true,
-      },
-    }});
-    assert.equal(element._optionalLabels.length, 1);
-    assert.equal(element._requiredLabels.length, 1);
-
-    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);
-  });
-
-  test('optional show/hide', () => {
-    element._optionalLabels = [{label: 'test'}];
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('section.optional'));
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.show-all-button'));
-    flush();
-
-    assert.isFalse(element._showOptionalLabels);
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('section.optional')));
-  });
-
-  test('properly converts satisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: [],
-        },
-      },
-      requirements: [],
-    };
-    flush();
-
-    assert.ok(element.shadowRoot
-        .querySelector('.approved'));
-    assert.ok(element.shadowRoot
-        .querySelector('.name'));
-    assert.equal(element.shadowRoot
-        .querySelector('.name').text, 'Verified');
-  });
-
-  test('properly converts unsatisfied labels', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {
-        Verified: {
-          approved: false,
-        },
-      },
-    };
-    flush();
-
-    const name = element.shadowRoot
-        .querySelector('.name');
-    assert.ok(name);
-    assert.isFalse(name.hasAttribute('hidden'));
-    assert.equal(name.text, 'Verified');
-  });
-
-  test('properly displays Work In Progress', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [],
-      work_in_progress: true,
-    };
-    flush();
-
-    const changeIsWip = element.shadowRoot
-        .querySelector('.title');
-    assert.ok(changeIsWip);
-  });
-
-  test('properly displays a satisfied requirement', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.isFalse(requirement.hasAttribute('hidden'));
-    assert.ok(requirement.querySelector('.approved'));
-    assert.equal(requirement.querySelector('.name').text,
-        'Resolve all comments');
-  });
-
-  test('satisfied class is applied with OK', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'OK',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.ok(requirement.querySelector('.approved'));
-  });
-
-  test('satisfied class is not applied with NOT_READY', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'NOT_READY',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-
-  test('satisfied class is not applied with RULE_ERROR', () => {
-    element.change = {
-      status: 'NEW',
-      labels: {},
-      requirements: [{
-        fallback_text: 'Resolve all comments',
-        status: 'RULE_ERROR',
-      }],
-    };
-    flush();
-
-    const requirement = element.shadowRoot
-        .querySelector('.requirement');
-    assert.ok(requirement);
-    assert.strictEqual(requirement.querySelector('.approved'), null);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index c4ccb7d..84bdffb 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -1,55 +1,38 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import './gr-checks-chip';
+import './gr-summary-chip';
+import '../../shared/gr-avatar/gr-avatar-stack';
+import '../../shared/gr-icon/gr-icon';
+import '../../checks/gr-checks-action';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
-  allRunsLatestPatchsetLatestAttempt$,
-  aPluginHasRegistered$,
   CheckResult,
   CheckRun,
   ErrorMessages,
-  errorMessagesLatest$,
-  loginCallbackLatest$,
-  someProvidersAreLoadingFirstTime$,
-  topLevelActionsLatest$,
-} from '../../../services/checks/checks-model';
-import {Action, Category, Link, RunStatus} from '../../../api/checks';
-import {fireShowPrimaryTab} from '../../../utils/event-util';
-import '../../shared/gr-avatar/gr-avatar';
-import '../../checks/gr-checks-action';
+} from '../../../models/checks/checks-model';
+import {Action, Category, RunStatus} from '../../../api/checks';
+import {fireShowTab} from '../../../utils/event-util';
 import {
-  firstPrimaryLink,
+  compareByWorstCategory,
   getResultsOf,
   hasCompletedWithoutResults,
   hasResults,
   hasResultsOf,
-  iconFor,
-  isRunning,
-  isRunningOrHasCompleted,
-  isStatus,
-  labelFor,
-} from '../../../services/checks/checks-util';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+  isRunningOrScheduled,
+  isRunningScheduledOrCompleted,
+} from '../../../models/checks/checks-util';
 import {
   CommentThread,
   getFirstComment,
+  getMentionedThreads,
   hasHumanReply,
   isResolved,
   isRobotThread,
@@ -57,324 +40,33 @@
 } 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 {isDefined} from '../../../types/types';
+import {Tab} from '../../../constants/constants';
 import {ChecksTabState, CommentTabState} from '../../../types/events';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 import {modifierPressed} from '../../../utils/dom-util';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
-import {fontStyles} from '../../../styles/gr-font-styles';
-import {account$} from '../../../services/user/user-model';
-import {
-  changeComments$,
-  threads$,
-} from '../../../services/comments/comments-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {resolve} from '../../../models/dependency';
+import {checksModelToken} from '../../../models/checks/checks-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {Interaction} from '../../../constants/reporting';
+import {roleDetails} from '../../../utils/change-util';
 
-export enum SummaryChipStyles {
-  INFO = 'info',
-  WARNING = 'warning',
-  CHECK = 'check',
-  UNDEFINED = '',
-}
+import {SummaryChipStyles} from './gr-summary-chip';
+import {when} from 'lit/directives/when.js';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {combineLatest} from 'rxjs';
+import {userModelToken} from '../../../models/user/user-model';
 
 function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) {
   if (modifierPressed(e)) return;
-  // Only react to `return` and `space`.
-  if (e.keyCode !== 13 && e.keyCode !== 32) return;
+  if (e.key !== 'Enter' && e.key !== ' ') return;
   e.preventDefault();
   e.stopPropagation();
   handler();
 }
 
-@customElement('gr-summary-chip')
-export class GrSummaryChip extends LitElement {
-  @property()
-  icon = '';
-
-  @property()
-  styleType = SummaryChipStyles.UNDEFINED;
-
-  @property()
-  category?: CommentTabState;
-
-  private readonly reporting = appContext.reportingService;
-
-  static override get styles() {
-    return [
-      sharedStyles,
-      fontStyles,
-      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;
-          /* centered position of 20px chips in 24px line-height inline flow */
-          vertical-align: top;
-          position: relative;
-          top: 2px;
-        }
-        iron-icon {
-          width: var(--line-height-small);
-          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:focus-within {
-          background: var(--warning-background-focus);
-        }
-        .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:focus-within {
-          background: var(--gray-background-focus);
-        }
-        .summaryChip.check iron-icon {
-          color: var(--gray-foreground);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    const chipClass = `summaryChip font-small ${this.styleType}`;
-    const grIcon = this.icon ? `gr-icons:${this.icon}` : '';
-    return html`<button class="${chipClass}" @click="${this.handleClick}">
-      ${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 LitElement {
-  @property()
-  statusOrCategory?: Category | RunStatus;
-
-  @property()
-  text = '';
-
-  @property()
-  links: Link[] = [];
-
-  static override get styles() {
-    return [
-      fontStyles,
-      sharedStyles,
-      css`
-        :host {
-          display: inline-block;
-          position: relative;
-          white-space: nowrap;
-        }
-        .checksChip {
-          color: var(--chip-color);
-          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;
-          /* centered position of 20px chips in 24px line-height inline flow */
-          vertical-align: top;
-          position: relative;
-          top: 2px;
-        }
-        .checksChip.hoverFullLength {
-          position: absolute;
-          z-index: 1;
-          display: none;
-        }
-        .checksChip.hoverFullLength .text {
-          max-width: 400px;
-        }
-        :host(:hover) .checksChip.hoverFullLength {
-          display: inline-block;
-        }
-        .checksChip .text {
-          display: inline-block;
-          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 a iron-icon.launch {
-          color: var(--link-color);
-        }
-        .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:focus-within {
-          background: var(--error-background-focus);
-        }
-        .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:focus-within {
-          background: var(--warning-background-focus);
-        }
-        .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:focus-within {
-          background: var(--info-background-focus);
-        }
-        .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:focus-within {
-          background: var(--success-background-focus);
-        }
-        .checksChip.check-circle-outline iron-icon {
-          color: var(--success-foreground);
-        }
-        .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:focus-within {
-          background: var(--gray-background-focus);
-        }
-        .checksChip.timelapse iron-icon {
-          color: var(--gray-foreground);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    if (!this.text) return;
-    if (!this.statusOrCategory) return;
-    const icon = iconFor(this.statusOrCategory);
-    const label = labelFor(this.statusOrCategory);
-    const count = Number(this.text);
-    let ariaLabel = label;
-    if (!isNaN(count)) {
-      const type = isStatus(this.statusOrCategory) ? 'run' : 'result';
-      const plural = count > 1 ? 's' : '';
-      ariaLabel = `${this.text} ${label} ${type}${plural}`;
-    }
-    const chipClass = `checksChip font-small ${icon}`;
-    const chipClassFullLength = `${chipClass} hoverFullLength`;
-    const grIcon = `gr-icons:${icon}`;
-    // 15 is roughly the number of chars for the chip exceeding its 120px width.
-    return html`
-      ${this.text.length > 15
-        ? html` ${this.renderChip(chipClassFullLength, ariaLabel, grIcon)}`
-        : ''}
-      ${this.renderChip(chipClass, ariaLabel, grIcon)}
-    `;
-  }
-
-  private renderChip(clazz: string, ariaLabel: string, icon: string) {
-    return html`
-      <div class="${clazz}" role="link" tabindex="0" aria-label="${ariaLabel}">
-        <iron-icon icon="${icon}"></iron-icon>
-        <div class="text">${this.text}</div>
-        ${this.renderLinks()}
-      </div>
-    `;
-  }
-
-  private renderLinks() {
-    return this.links.map(
-      link => html`
-        <a
-          href="${link.url}"
-          target="_blank"
-          @click="${this.onLinkClick}"
-          @keydown="${this.onLinkKeyDown}"
-          aria-label="Link to check details"
-          ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
-        ></a>
-      `
-    );
-  }
-
-  private onLinkKeyDown(e: KeyboardEvent) {
-    // Prevents onChipKeyDown() from reacting to <a> link keyboard events.
-    e.stopPropagation();
-  }
-
-  private onLinkClick(e: MouseEvent) {
-    // Prevents onChipClick() from reacting to <a> link clicks.
-    e.stopPropagation();
-  }
-}
-
 /** What is the maximum number of detailed checks chips? */
 const DETAILS_QUOTA: Map<RunStatus | Category, number> = new Map();
 DETAILS_QUOTA.set(Category.ERROR, 7);
@@ -384,10 +76,10 @@
 @customElement('gr-change-summary')
 export class GrChangeSummary extends LitElement {
   @state()
-  changeComments?: ChangeComments;
+  commentThreads?: CommentThread[];
 
   @state()
-  commentThreads?: CommentThread[];
+  mentionCount = 0;
 
   @state()
   selfAccount?: AccountInfo;
@@ -410,25 +102,96 @@
   @state()
   actions: Action[] = [];
 
-  private showAllChips = new Map<RunStatus | Category, boolean>();
+  @state()
+  messages: string[] = [];
 
-  private checksService = appContext.checksService;
+  @state()
+  draftCount = 0;
+
+  private readonly showAllChips = new Map<RunStatus | Category, boolean>();
+
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getChecksModel = resolve(this, checksModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly flagsService = getAppContext().flagsService;
 
   constructor() {
     super();
-    subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
-    subscribe(this, aPluginHasRegistered$, x => (this.showChecksSummary = x));
     subscribe(
       this,
-      someProvidersAreLoadingFirstTime$,
+      () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      x => (this.runs = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().aPluginHasRegistered$,
+      x => (this.showChecksSummary = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().someProvidersAreLoadingFirstTime$,
       x => (this.someProvidersAreLoading = x)
     );
-    subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
-    subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
-    subscribe(this, topLevelActionsLatest$, x => (this.actions = x));
-    subscribe(this, changeComments$, x => (this.changeComments = x));
-    subscribe(this, threads$, x => (this.commentThreads = x));
-    subscribe(this, account$, x => (this.selfAccount = x));
+    subscribe(
+      this,
+      () => this.getChecksModel().errorMessagesLatest$,
+      x => (this.errorMessages = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().loginCallbackLatest$,
+      x => (this.loginCallback = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().topLevelActionsLatest$,
+      x => (this.actions = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().topLevelMessagesLatest$,
+      x => (this.messages = x)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().draftsCount$,
+      x => (this.draftCount = x)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
+      x => (this.commentThreads = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.selfAccount = x)
+    );
+    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+      subscribe(
+        this,
+        () =>
+          combineLatest([
+            this.getUserModel().account$,
+            this.getCommentsModel().threads$,
+          ]),
+        ([selfAccount, threads]) => {
+          if (!selfAccount || !selfAccount.email) return;
+          const unresolvedThreadsMentioningSelf = getMentionedThreads(
+            threads,
+            selfAccount
+          ).filter(isUnresolved);
+          this.mentionCount = unresolvedThreadsMentioningSelf.length;
+        }
+      );
+    }
   }
 
   static override get styles() {
@@ -448,6 +211,7 @@
         .loading.zeroState {
           margin-right: var(--spacing-m);
         }
+        div.info,
         div.error,
         .login {
           display: flex;
@@ -456,20 +220,30 @@
           margin: var(--spacing-xs) 0;
           width: 490px;
         }
+        div.info {
+          background-color: var(--info-background);
+        }
         div.error {
           background-color: var(--error-background);
         }
-        div.error iron-icon {
-          color: var(--error-foreground);
-          width: 16px;
-          height: 16px;
+        div.info gr-icon,
+        div.error gr-icon {
+          font-size: 16px;
           position: relative;
           top: 4px;
           margin-right: var(--spacing-s);
         }
+        div.info gr-icon {
+          color: var(--info-foreground);
+        }
+        div.error gr-icon {
+          color: var(--error-foreground);
+        }
+        div.info .right,
         div.error .right {
           overflow: hidden;
         }
+        div.info .right .message,
         div.error .right .message {
           overflow: hidden;
           text-overflow: ellipsis;
@@ -479,7 +253,7 @@
           justify-content: space-between;
           background: var(--info-background);
         }
-        .login iron-icon {
+        .login gr-icon {
           color: var(--info-foreground);
         }
         .login gr-button {
@@ -495,17 +269,13 @@
           padding-bottom: var(--spacing-s);
           line-height: calc(var(--line-height-normal) + var(--spacing-s));
         }
-        iron-icon.launch {
-          color: var(--gray-foreground);
-          width: var(--line-height-small);
-          height: var(--line-height-small);
-          vertical-align: top;
+        gr-avatar-stack {
+          --avatar-size: var(--line-height-small, 16px);
+          --stack-border-color: var(--warning-background);
         }
-        gr-avatar {
-          height: var(--line-height-small, 16px);
-          width: var(--line-height-small, 16px);
-          vertical-align: top;
-          margin-right: var(--spacing-xs);
+        .unresolvedIcon {
+          font-size: var(--line-height-small);
+          color: var(--warning-foreground);
         }
         /* The basics of .loadingSpin are defined in shared styles. */
         .loadingSpin {
@@ -529,6 +299,10 @@
         .actions #moreMessage {
           display: none;
         }
+        .summaryMessage {
+          line-height: var(--line-height-normal);
+          color: var(--primary-text-color);
+        }
       `,
     ];
   }
@@ -555,11 +329,18 @@
 
   private renderAction(action?: Action) {
     if (!action) return;
-    return html`<gr-checks-action .action="${action}"></gr-checks-action>`;
+    return html`<gr-checks-action
+      context="summary"
+      .action=${action}
+    ></gr-checks-action>`;
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.getChecksModel().triggerAction(
+      e.detail,
+      undefined,
+      'summary-dropdown'
+    );
   }
 
   private renderOverflow(items: DropdownLink[], disabledIds: string[] = []) {
@@ -570,27 +351,41 @@
         link=""
         vertical-offset="32"
         horizontal-align="right"
-        @tap-item="${this.handleAction}"
-        .items="${items}"
-        .disabledIds="${disabledIds}"
+        @tap-item=${this.handleAction}
+        .items=${items}
+        .disabledIds=${disabledIds}
       >
-        <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
-        </iron-icon>
+        <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
         <span id="moreMessage">More</span>
       </gr-dropdown>
     `;
   }
 
+  private renderSummaryMessage() {
+    return this.messages.map(
+      m => html`
+        <div class="info">
+          <div class="left">
+            <gr-icon icon="info" filled></gr-icon>
+          </div>
+          <div class="right">
+            <div class="message" title=${m}>${m}</div>
+          </div>
+        </div>
+      `
+    );
+  }
+
   renderErrorMessages() {
     return Object.entries(this.errorMessages).map(
       ([plugin, message]) =>
         html`
           <div class="error zeroState">
             <div class="left">
-              <iron-icon icon="gr-icons:error"></iron-icon>
+              <gr-icon icon="error" filled></gr-icon>
             </div>
             <div class="right">
-              <div class="message" title="${message}">
+              <div class="message" title=${message}>
                 Error while fetching results for ${plugin}: ${message}
               </div>
             </div>
@@ -604,14 +399,11 @@
     return html`
       <div class="login">
         <div class="left">
-          <iron-icon
-            class="info-outline"
-            icon="gr-icons:info-outline"
-          ></iron-icon>
+          <gr-icon icon="info"></gr-icon>
           Not logged in
         </div>
         <div class="right">
-          <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+          <gr-button @click=${this.loginCallback} link>Sign in</gr-button>
         </div>
       </div>
     `;
@@ -620,7 +412,7 @@
   renderChecksZeroState() {
     if (Object.keys(this.errorMessages).length > 0) return;
     if (this.loginCallback) return;
-    if (this.runs.some(isRunningOrHasCompleted)) return;
+    if (this.runs.some(isRunningScheduledOrCompleted)) return;
     const msg = this.someProvidersAreLoading ? 'Loading results' : 'No results';
     return html`<span role="status" class="loading zeroState">${msg}</span>`;
   }
@@ -634,18 +426,19 @@
     if (category === Category.SUCCESS || category === Category.INFO) {
       return this.renderChecksChipsCollapsed(runs, category, count);
     }
-    return this.renderChecksChipsExpanded(runs, category, count);
+    return this.renderChecksChipsExpanded(runs, category);
   }
 
   renderChecksChipRunning() {
-    const runs = this.runs.filter(isRunning);
-    return this.renderChecksChipsExpanded(runs, RunStatus.RUNNING, () => []);
+    const runs = this.runs
+      .filter(isRunningOrScheduled)
+      .sort(compareByWorstCategory);
+    return this.renderChecksChipsExpanded(runs, RunStatus.RUNNING);
   }
 
   renderChecksChipsExpanded(
     runs: CheckRun[],
-    statusOrCategory: RunStatus | Category,
-    resultFilter: (run: CheckRun) => CheckResult[]
+    statusOrCategory: RunStatus | Category
   ) {
     if (runs.length === 0) return;
     const showAll = this.showAllChips.get(statusOrCategory) ?? false;
@@ -655,7 +448,7 @@
     return html`${runs
       .slice(0, count)
       .map(run =>
-        this.renderChecksChipDetailed(run, statusOrCategory, resultFilter)
+        this.renderChecksChipDetailed(run, statusOrCategory)
       )}${this.renderChecksChipPlusMore(statusOrCategory, more)}`;
   }
 
@@ -671,10 +464,10 @@
     if (count === 0) return;
     const handler = () => this.onChipClick({statusOrCategory});
     return html`<gr-checks-chip
-      .statusOrCategory="${statusOrCategory}"
-      .text="${`${count}`}"
-      @click="${handler}"
-      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+      .statusOrCategory=${statusOrCategory}
+      .text=${`${count}`}
+      @click=${handler}
+      @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
   }
 
@@ -689,39 +482,49 @@
       this.requestUpdate();
     };
     return html`<gr-checks-chip
-      .statusOrCategory="${statusOrCategory}"
+      .statusOrCategory=${statusOrCategory}
       .text="+ ${count} more"
-      @click="${handler}"
-      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+      @click=${handler}
+      @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
   }
 
   private renderChecksChipDetailed(
     run: CheckRun,
-    statusOrCategory: RunStatus | Category,
-    resultFilter: (run: CheckRun) => CheckResult[]
+    statusOrCategory: RunStatus | Category
   ) {
-    const allPrimaryLinks = resultFilter(run)
-      .map(firstPrimaryLink)
-      .filter(notUndefined);
-    const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
+    const links = [];
+    if (run.statusLink) links.push(run.statusLink);
     const text = `${run.checkName}`;
     const tabState: ChecksTabState = {
       checkName: run.checkName,
       statusOrCategory,
     };
+    // Scheduled runs are rendered in the RUNNING section, but the icon of the
+    // chip must be the one for SCHEDULED.
+    if (
+      statusOrCategory === RunStatus.RUNNING &&
+      run.status === RunStatus.SCHEDULED
+    ) {
+      statusOrCategory = RunStatus.SCHEDULED;
+    }
     const handler = () => this.onChipClick(tabState);
     return html`<gr-checks-chip
-      .statusOrCategory="${statusOrCategory}"
-      .text="${text}"
-      .links="${links}"
-      @click="${handler}"
-      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+      .statusOrCategory=${statusOrCategory}
+      .text=${text}
+      .links=${links}
+      @click=${handler}
+      @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
   }
 
   private onChipClick(state: ChecksTabState) {
-    fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
+    this.reporting.reportInteraction(Interaction.CHECKS_CHIP_CLICKED, {
+      statusOrCategory: state.statusOrCategory,
+      checkName: state.checkName,
+      ...roleDetails(this.getChangeModel().getChange(), this.selfAccount),
+    });
+    fireShowTab(this, Tab.CHECKS, false, {
       checksTab: state,
     });
   }
@@ -734,100 +537,144 @@
     const unresolvedThreads = commentThreads.filter(isUnresolved);
     const countUnresolvedComments = unresolvedThreads.length;
     const unresolvedAuthors = this.getAccounts(unresolvedThreads);
-    const draftCount = this.changeComments?.computeDraftCount() ?? 0;
-    const hasNonRunningChip = this.runs.some(
-      run => hasCompletedWithoutResults(run) || hasResults(run)
-    );
-    const hasRunningChip = this.runs.some(isRunning);
     return html`
       <div>
         <table>
-          <tr ?hidden=${!this.showChecksSummary}>
-            <td class="key">Checks</td>
-            <td class="value">
-              <div class="checksSummary">
-                ${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
-                  Category.ERROR
-                )}${this.renderChecksChipForCategory(
-                  Category.WARNING
-                )}${this.renderChecksChipForCategory(
-                  Category.INFO
-                )}${this.renderChecksChipForCategory(
-                  Category.SUCCESS
-                )}${hasNonRunningChip && hasRunningChip
-                  ? html`<br />`
-                  : ''}${this.renderChecksChipRunning()}
-                <span
-                  class="loadingSpin"
-                  ?hidden="${!this.someProvidersAreLoading}"
-                ></span>
-                ${this.renderErrorMessages()}${this.renderChecksLogin()}${this.renderActions()}
-              </div>
-            </td>
-          </tr>
           <tr>
             <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}"
-                      imageSize="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
-              >
+              ${this.renderZeroState(
+                countResolvedComments,
+                countUnresolvedComments
+              )}
+              ${this.renderDraftChip()} ${this.renderMentionChip()}
+              ${this.renderUnresolvedCommentsChip(
+                countUnresolvedComments,
+                unresolvedAuthors
+              )}
+              ${this.renderResolvedCommentsChip(countResolvedComments)}
             </td>
           </tr>
-          <tr hidden>
-            <td class="key">Findings</td>
-            <td class="value"></td>
-          </tr>
+          ${this.renderChecksSummary()}
         </table>
       </div>
     `;
   }
 
+  private renderZeroState(
+    countResolvedComments: number,
+    countUnresolvedComments: number
+  ) {
+    if (
+      !!countResolvedComments ||
+      !!this.draftCount ||
+      !!countUnresolvedComments
+    )
+      return nothing;
+    return html`<span class="zeroState"> No comments</span>`;
+  }
+
+  private renderMentionChip() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
+      return nothing;
+    if (!this.mentionCount) return nothing;
+    return html` <gr-summary-chip
+      class="mentionSummary"
+      styleType=${SummaryChipStyles.WARNING}
+      category=${CommentTabState.MENTIONS}
+      icon="alternate_email"
+    >
+      ${pluralize(this.mentionCount, 'mention')}</gr-summary-chip
+    >`;
+  }
+
+  private renderDraftChip() {
+    if (!this.draftCount) return nothing;
+    return html` <gr-summary-chip
+      styleType=${SummaryChipStyles.INFO}
+      category=${CommentTabState.DRAFTS}
+      icon="rate_review"
+      iconFilled
+    >
+      ${pluralize(this.draftCount, 'draft')}</gr-summary-chip
+    >`;
+  }
+
+  private renderUnresolvedCommentsChip(
+    countUnresolvedComments: number,
+    unresolvedAuthors: AccountInfo[]
+  ) {
+    if (!countUnresolvedComments) return nothing;
+    return html` <gr-summary-chip
+      styleType=${SummaryChipStyles.WARNING}
+      category=${CommentTabState.UNRESOLVED}
+      ?hidden=${!countUnresolvedComments}
+    >
+      <gr-avatar-stack .accounts=${unresolvedAuthors} imageSize="32">
+        <gr-icon
+          slot="fallback"
+          icon="chat_bubble"
+          filled
+          class="unresolvedIcon"
+        >
+        </gr-icon>
+      </gr-avatar-stack>
+      ${countUnresolvedComments} unresolved</gr-summary-chip
+    >`;
+  }
+
+  private renderResolvedCommentsChip(countResolvedComments: number) {
+    if (!countResolvedComments) return nothing;
+    return html` <gr-summary-chip
+      styleType=${SummaryChipStyles.CHECK}
+      category=${CommentTabState.SHOW_ALL}
+      icon="mark_chat_read"
+      >${countResolvedComments} resolved</gr-summary-chip
+    >`;
+  }
+
+  private renderChecksSummary() {
+    const hasNonRunningChip = this.runs.some(
+      run => hasCompletedWithoutResults(run) || hasResults(run)
+    );
+    const hasRunningChip = this.runs.some(isRunningOrScheduled);
+    if (!this.showChecksSummary) return nothing;
+    return html` <tr>
+      <td class="key">Checks</td>
+      <td class="value">
+        <div class="checksSummary">
+          ${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
+            Category.ERROR
+          )}${this.renderChecksChipForCategory(
+            Category.WARNING
+          )}${this.renderChecksChipForCategory(
+            Category.INFO
+          )}${this.renderChecksChipForCategory(
+            Category.SUCCESS
+          )}${hasNonRunningChip && hasRunningChip
+            ? html`<br />`
+            : ''}${this.renderChecksChipRunning()}
+          ${when(
+            this.someProvidersAreLoading,
+            () => html`<span class="loadingSpin"></span>`
+          )}
+          ${this.renderErrorMessages()} ${this.renderChecksLogin()}
+          ${this.renderSummaryMessage()} ${this.renderActions()}
+        </div>
+      </td>
+    </tr>`;
+  }
+
   getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
-    const uniqueAuthors = commentThreads
+    return commentThreads
       .map(getFirstComment)
       .map(comment => comment?.author ?? this.selfAccount)
-      .filter(notUndefined)
-      .filter(account => !!account?.avatars?.[0]?.url)
-      .filter(uniqueDefinedAvatar);
-    return uniqueAuthors.slice(0, 3);
+      .filter(isDefined);
   }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-change-summary': GrChangeSummary;
-    'gr-checks-chip': GrChecksChip;
-    'gr-summary-chip': GrSummaryChip;
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index cd297c7..05036ab 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -1,25 +1,175 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrChangeSummary} from './gr-change-summary';
+import {queryAndAssert} from '../../../utils/common-util';
+import {fakeRun0} from '../../../models/checks/checks-fakes';
+import {
+  createAccountWithEmail,
+  createComment,
+  createCommentThread,
+  createDraft,
+} from '../../../test/test-data-generators';
+import {stubFlags} from '../../../test/test-utils';
+import {Timestamp} from '../../../api/rest-api';
+import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
 
 suite('gr-change-summary test', () => {
+  let element: GrChangeSummary;
+  let commentsModel: CommentsModel;
+  let userModel: UserModel;
+
+  setup(async () => {
+    element = await fixture(html`<gr-change-summary></gr-change-summary>`);
+    commentsModel = testResolver(commentsModelToken);
+    userModel = testResolver(userModelToken);
+  });
+
   test('is defined', () => {
     const el = document.createElement('gr-change-summary');
     assert.instanceOf(el, GrChangeSummary);
   });
+
+  test('renders', async () => {
+    commentsModel.setState({
+      drafts: {
+        a: [createDraft(), createDraft(), createDraft()],
+      },
+      discardedDrafts: [],
+    });
+    element.commentThreads = [
+      createCommentThread([createComment()]),
+      createCommentThread([{...createComment(), unresolved: true}]),
+    ];
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div>
+        <table>
+          <tbody>
+            <tr>
+              <td class="key">Comments</td>
+              <td class="value">
+                <gr-summary-chip
+                  category="drafts"
+                  icon="rate_review"
+                  iconFilled
+                  styletype="info"
+                >
+                  3 drafts
+                </gr-summary-chip>
+                <gr-summary-chip category="unresolved" styletype="warning">
+                  <gr-avatar-stack imageSize="32">
+                    <gr-icon
+                      class="unresolvedIcon"
+                      filled
+                      icon="chat_bubble"
+                      slot="fallback"
+                    ></gr-icon>
+                  </gr-avatar-stack>
+                  1 unresolved
+                </gr-summary-chip>
+                <gr-summary-chip
+                  category="show all"
+                  icon="mark_chat_read"
+                  styletype="check"
+                >
+                  1 resolved
+                </gr-summary-chip>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div> `
+    );
+  });
+
+  test('renders checks summary message', async () => {
+    element.runs = [fakeRun0];
+    element.messages = ['a message'];
+    element.showChecksSummary = true;
+    await element.updateComplete;
+    const checksSummary = queryAndAssert(element, '.checksSummary');
+    assert.dom.equal(
+      checksSummary,
+      /* HTML */ `
+        <div class="checksSummary">
+          <gr-checks-chip> </gr-checks-chip>
+          <div class="info">
+            <div class="left">
+              <gr-icon icon="info" filled></gr-icon>
+            </div>
+            <div class="right">
+              <div class="message" title="a message">a message</div>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('renders mentions summary', async () => {
+    stubFlags('isEnabled').returns(true);
+    // recreate element so that flag protected subscriptions are added
+    element = await fixture(html`<gr-change-summary></gr-change-summary>`);
+    await element.updateComplete;
+
+    commentsModel.setState({
+      drafts: {
+        a: [
+          {
+            ...createDraft(),
+            message: 'Hey @abc@def.com pleae take a look at this.',
+            unresolved: true,
+          },
+          // Resolved draft thread hence ignored
+          {...createDraft(), message: 'Hey @abc@def.com this is important.'},
+          createDraft(),
+        ],
+      },
+      comments: {
+        a: [
+          {
+            ...createComment(),
+            message: 'Hey @abc@def.com pleae take a look at this.',
+            unresolved: true,
+          },
+        ],
+        b: [
+          {...createComment(), message: 'Hey @abc@def.com this is important.'},
+        ],
+      },
+      discardedDrafts: [],
+    });
+    userModel.setAccount({
+      ...createAccountWithEmail('abc@def.com'),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    });
+    await element.updateComplete;
+    const mentionSummary = queryAndAssert(element, '.mentionSummary');
+    // Only count occurrences in unresolved threads
+    // Resolved threads are ignored hence mention chip count is 2
+    assert.dom.equal(
+      mentionSummary,
+      /* HTML */ `
+        <gr-summary-chip
+          category="mentions"
+          class="mentionSummary"
+          icon="alternate_email"
+          styletype="warning"
+        >
+          2 mentions
+        </gr-summary-chip>
+      `
+    );
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
new file mode 100644
index 0000000..2ab8ac3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip.ts
@@ -0,0 +1,234 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {getAppContext} from '../../../services/app-context';
+import {Category, RunStatus} from '../../../api/checks';
+import {
+  ChecksIcon,
+  iconFor,
+  isStatus,
+  labelFor,
+} from '../../../models/checks/checks-util';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {Interaction} from '../../../constants/reporting';
+
+@customElement('gr-checks-chip')
+export class GrChecksChip extends LitElement {
+  @property()
+  statusOrCategory?: Category | RunStatus;
+
+  @property()
+  text = '';
+
+  @property({type: Array})
+  links: string[] = [];
+
+  private readonly reporting = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+          position: relative;
+          white-space: nowrap;
+        }
+        .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;
+          /* centered position of 20px chips in 24px line-height inline flow */
+          vertical-align: top;
+          position: relative;
+          top: 2px;
+        }
+        .checksChip.hoverFullLength {
+          position: absolute;
+          z-index: 1;
+          display: none;
+        }
+        .checksChip.hoverFullLength .text {
+          max-width: 500px;
+        }
+        :host(:hover) .checksChip.hoverFullLength {
+          display: inline-block;
+        }
+        .checksChip .text {
+          display: inline-block;
+          max-width: 120px;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          vertical-align: top;
+        }
+        gr-icon {
+          font-size: var(--line-height-small);
+        }
+        .checksChip a gr-icon.launch {
+          color: var(--link-color);
+        }
+        .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:focus-within {
+          background: var(--error-background-focus);
+        }
+        .checksChip.error gr-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:focus-within {
+          background: var(--warning-background-focus);
+        }
+        .checksChip.warning gr-icon {
+          color: var(--warning-foreground);
+        }
+        .checksChip.info {
+          border-color: var(--info-foreground);
+          background: var(--info-background);
+        }
+        .checksChip.info:hover {
+          background: var(--info-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.info:focus-within {
+          background: var(--info-background-focus);
+        }
+        .checksChip.info gr-icon {
+          color: var(--info-foreground);
+        }
+        .checksChip.check_circle {
+          border-color: var(--success-foreground);
+          background: var(--success-background);
+        }
+        .checksChip.check_circle:hover {
+          background: var(--success-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.check_circle:focus-within {
+          background: var(--success-background-focus);
+        }
+        .checksChip.check_circle gr-icon {
+          color: var(--success-foreground);
+        }
+        .checksChip.timelapse,
+        .checksChip.scheduled {
+          border-color: var(--gray-foreground);
+          background: var(--gray-background);
+        }
+        .checksChip.timelapse:hover,
+        .checksChip.pending_actions:hover {
+          background: var(--gray-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .checksChip.timelapse:focus-within,
+        .checksChip.pending_actions:focus-within {
+          background: var(--gray-background-focus);
+        }
+        .checksChip.timelapse gr-icon,
+        .checksChip.pending_actions gr-icon {
+          color: var(--gray-foreground);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.text) return;
+    if (!this.statusOrCategory) return;
+    const icon = iconFor(this.statusOrCategory);
+    const ariaLabel = this.computeAriaLabel();
+    const chipClass = `checksChip font-small ${icon.name}`;
+    const chipClassFullLength = `${chipClass} hoverFullLength`;
+    // 15 is roughly the number of chars for the chip exceeding its 120px width.
+    return html`
+      ${this.text.length > 15
+        ? html` ${this.renderChip(chipClassFullLength, ariaLabel, icon)}`
+        : ''}
+      ${this.renderChip(chipClass, ariaLabel, icon)}
+    `;
+  }
+
+  private computeAriaLabel() {
+    if (!this.statusOrCategory) return '';
+    const label = labelFor(this.statusOrCategory);
+    const type = isStatus(this.statusOrCategory) ? 'run' : 'result';
+    const count = Number(this.text);
+    const isCountChip = !isNaN(count);
+    if (isCountChip) {
+      const plural = count > 1 ? 's' : '';
+      return `${this.text} ${label} ${type}${plural}`;
+    }
+    return `${label} for check ${this.text}`;
+  }
+
+  private renderChip(clazz: string, ariaLabel: string, icon: ChecksIcon) {
+    return html`
+      <div class=${clazz} role="link" tabindex="0" aria-label=${ariaLabel}>
+        <gr-icon icon=${icon.name} ?filled=${icon.filled}></gr-icon>
+        ${this.renderLinks()}
+        <div class="text">${this.text}</div>
+      </div>
+    `;
+  }
+
+  private renderLinks() {
+    return this.links.map(
+      link => html`
+        <a
+          href=${link}
+          target="_blank"
+          @click=${this.onLinkClick}
+          @keydown=${this.onLinkKeyDown}
+          aria-label="Link to check details"
+          ><gr-icon icon="open_in_new" class="launch"></gr-icon
+        ></a>
+      `
+    );
+  }
+
+  private onLinkKeyDown(e: KeyboardEvent) {
+    // Prevents onChipKeyDown() from reacting to <a> link keyboard events.
+    e.stopPropagation();
+  }
+
+  private onLinkClick(e: MouseEvent) {
+    // Prevents onChipClick() from reacting to <a> link clicks.
+    e.stopPropagation();
+    this.reporting.reportInteraction(Interaction.CHECKS_CHIP_LINK_CLICKED, {
+      text: this.text,
+      status: this.statusOrCategory,
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-chip': GrChecksChip;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts
new file mode 100644
index 0000000..7cd019a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-checks-chip_test.ts
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrChecksChip} from './gr-checks-chip';
+import {Category} from '../../../api/checks';
+
+suite('gr-checks-chip test', () => {
+  let element: GrChecksChip;
+  setup(async () => {
+    element = await fixture(html`<gr-checks-chip
+      .statusOrCategory=${Category.SUCCESS}
+      .text=${'0'}
+    ></gr-checks-chip>`);
+  });
+
+  test('is defined', () => {
+    const el = document.createElement('gr-checks-chip');
+    assert.instanceOf(el, GrChecksChip);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div
+        aria-label="0 success result"
+        class="check_circle checksChip font-small"
+        role="link"
+        tabindex="0"
+      >
+        <gr-icon icon="check_circle"></gr-icon>
+        <div class="text">0</div>
+      </div>`
+    );
+  });
+
+  test('renders specific check', async () => {
+    element.text = 'Super Check';
+    element.statusOrCategory = Category.ERROR;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div
+          aria-label="error for check Super Check"
+          class="checksChip error font-small"
+          role="link"
+          tabindex="0"
+        >
+          <gr-icon icon="error" filled></gr-icon>
+          <div class="text">Super Check</div>
+        </div>
+      `
+    );
+  });
+
+  test('renders check with link', async () => {
+    element.text = 'LinkProducer';
+    element.statusOrCategory = Category.WARNING;
+    element.links = ['http://www.google.com'];
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div
+          aria-label="warning for check LinkProducer"
+          class="checksChip warning font-small"
+          role="link"
+          tabindex="0"
+        >
+          <gr-icon icon="warning" filled></gr-icon>
+          <a
+            aria-label="Link to check details"
+            href="http://www.google.com"
+            target="_blank"
+          >
+            <gr-icon class="launch" icon="open_in_new"> </gr-icon>
+          </a>
+          <div class="text">LinkProducer</div>
+        </div>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts
new file mode 100644
index 0000000..34423b7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts
@@ -0,0 +1,133 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../shared/gr-icon/gr-icon';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {getAppContext} from '../../../services/app-context';
+import {fireShowTab} from '../../../utils/event-util';
+import {Tab} from '../../../constants/constants';
+import {CommentTabState} from '../../../types/events';
+import {fontStyles} from '../../../styles/gr-font-styles';
+
+export enum SummaryChipStyles {
+  INFO = 'info',
+  WARNING = 'warning',
+  CHECK = 'check',
+  UNDEFINED = '',
+}
+
+@customElement('gr-summary-chip')
+export class GrSummaryChip extends LitElement {
+  @property()
+  icon = '';
+
+  @property({type: Boolean})
+  iconFilled = false;
+
+  @property()
+  styleType = SummaryChipStyles.UNDEFINED;
+
+  @property()
+  category?: CommentTabState;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      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;
+          /* centered position of 20px chips in 24px line-height inline flow */
+          vertical-align: top;
+          position: relative;
+          top: 2px;
+        }
+        gr-icon {
+          font-size: var(--line-height-small);
+        }
+        .summaryChip.info {
+          border-color: var(--info-foreground);
+          background: var(--info-background);
+        }
+        .summaryChip.info:hover {
+          background: var(--info-background-hover);
+          box-shadow: var(--elevation-level-1);
+        }
+        .summaryChip.info:focus-within {
+          background: var(--info-background-focus);
+        }
+        .summaryChip.info gr-icon {
+          color: var(--info-foreground);
+        }
+        .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:focus-within {
+          background: var(--warning-background-focus);
+        }
+        .summaryChip.warning gr-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:focus-within {
+          background: var(--gray-background-focus);
+        }
+        .summaryChip.check gr-icon {
+          color: var(--gray-foreground);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const chipClass = `summaryChip font-small ${this.styleType}`;
+    return html`<button class=${chipClass} @click=${this.handleClick}>
+      ${this.icon &&
+      html`<gr-icon ?filled=${this.iconFilled} icon=${this.icon}></gr-icon>`}
+      <slot></slot>
+    </button>`;
+  }
+
+  private handleClick(e: MouseEvent) {
+    e.stopPropagation();
+    e.preventDefault();
+    this.reporting.reportInteraction('comment chip click', {
+      category: this.category,
+    });
+    fireShowTab(this, Tab.COMMENT_THREADS, true, {
+      commentTab: this.category,
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-summary-chip': GrSummaryChip;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts
new file mode 100644
index 0000000..9b25591
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrSummaryChip, SummaryChipStyles} from './gr-summary-chip';
+import {CommentTabState} from '../../../types/events';
+
+suite('gr-summary-chip test', () => {
+  let element: GrSummaryChip;
+  setup(async () => {
+    element = await fixture(html`<gr-summary-chip
+      styleType=${SummaryChipStyles.WARNING}
+      category=${CommentTabState.DRAFTS}
+    ></gr-summary-chip>`);
+  });
+  test('is defined', () => {
+    const el = document.createElement('gr-summary-chip');
+    assert.instanceOf(el, GrSummaryChip);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<button class="font-small summaryChip warning">
+        <slot> </slot>
+      </button>`
+    );
+  });
+});
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 8a28cb1..d4627e2 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
@@ -1,40 +1,29 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {BehaviorSubject} from 'rxjs';
+import '../gr-copy-links/gr-copy-links';
 import '@polymer/paper-tabs/paper-tabs';
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/gr-paper-styles';
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import '../../shared/gr-account-link/gr-account-link';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
 import '../../shared/gr-editable-content/gr-editable-content';
-import '../../shared/gr-linked-text/gr-linked-text';
-import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-formatted-text/gr-formatted-text';
 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';
 import '../gr-download-dialog/gr-download-dialog';
 import '../gr-file-list-header/gr-file-list-header';
+import '../gr-file-list/gr-file-list';
 import '../gr-included-in-dialog/gr-included-in-dialog';
 import '../gr-messages-list/gr-messages-list';
 import '../gr-related-changes-list/gr-related-changes-list';
@@ -43,38 +32,23 @@
 import '../gr-thread-list/gr-thread-list';
 import '../../checks/gr-checks-tab';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-view_html';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-  ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {querySelectorAll, windowLocationReload} from '../../../utils/dom-util';
-import {
-  GeneratedWebLink,
-  GerritNav,
-} from '../../core/gr-navigation/gr-navigation';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {whenVisible, windowLocationReload} from '../../../utils/dom-util';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {
   ChangeStatus,
   DefaultBase,
-  PrimaryTab,
-  SecondaryTab,
+  Tab,
   DiffViewMode,
 } from '../../../constants/constants';
-
-import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
+  findEdit,
+  findEditParentRevision,
   hasEditBasedOnCurrentPatchSet,
   hasEditPatchsetLoaded,
   PatchSet,
@@ -84,82 +58,62 @@
   changeIsMerged,
   changeIsOpen,
   changeStatuses,
-  isCc,
   isInvolved,
-  isOwner,
-  isReviewer,
+  roleDetails,
 } from '../../../utils/change-util';
-import {EventType as PluginEventType} from '../../../api/plugin';
-import {customElement, observe, property} from '@polymer/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
 import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
 import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
 import {
   AccountDetailInfo,
   ActionNameToActionInfoMap,
-  ApprovalInfo,
   BasePatchSetNum,
   ChangeId,
   ChangeInfo,
   CommitId,
   CommitInfo,
   ConfigInfo,
-  EditInfo,
-  EditPatchSetNum,
+  DetailedLabelInfo,
+  EDIT,
   LabelNameToInfoMap,
   NumericChangeId,
-  ParentPatchSetNum,
+  PARENT,
   PatchRange,
   PatchSetNum,
+  PatchSetNumber,
   PreferencesInfo,
   QuickLabelInfo,
   RelatedChangeAndCommitInfo,
   RelatedChangesInfo,
   RevisionInfo,
+  RevisionPatchSetNum,
   ServerInfo,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {DiffPreferencesInfo} from '../../../types/diff';
 import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
 import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
 import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   assertIsDefined,
-  hasOwnProperty,
-  query,
+  assert,
+  queryAll,
+  queryAndAssert,
 } from '../../../utils/common-util';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
-  isDraftThread,
   isRobot,
   isUnresolved,
-  UIDraft,
+  DraftInfo,
 } from '../../../utils/comment-util';
-import {
-  PolymerDeepPropertyChange,
-  PolymerSplice,
-  PolymerSpliceChange,
-} from '@polymer/polymer/interfaces';
-import {AppElementChangeViewParams} from '../../gr-app-types';
-import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
-import {
-  DEFAULT_NUM_FILES_SHOWN,
-  GrFileList,
-} from '../gr-file-list/gr-file-list';
-import {
-  ChangeViewState,
-  EditRevisionInfo,
-  isPolymerSpliceChange,
-  ParsedChangeInfo,
-} from '../../../types/types';
+import {GrFileList} from '../gr-file-list/gr-file-list';
+import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
 import {
   CloseFixPreviewEvent,
   EditableContentSaveEvent,
@@ -168,6 +122,7 @@
   ShowAlertEventDetail,
   SwitchTabEvent,
   TabState,
+  ValueChangedEvent,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
@@ -176,37 +131,63 @@
   fireAlert,
   fireDialogChange,
   fireEvent,
-  firePageError,
   fireReload,
   fireTitleChange,
 } from '../../../utils/event-util';
-import {GerritView, routerView$} from '../../../services/router/router-model';
-import {takeUntil} from 'rxjs/operators';
-import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
-import {Subject} from 'rxjs';
-import {debounce, DelayedTask, throttleWrap} from '../../../utils/async-util';
+import {
+  debounce,
+  DelayedTask,
+  throttleWrap,
+  until,
+} from '../../../utils/async-util';
 import {Interaction, Timing} from '../../../constants/reporting';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 import {getRevertCreatedChangeIds} from '../../../utils/message-util';
 import {
-  changeComments$,
-  drafts$,
-} from '../../../services/comments/comments-model';
-import {
   getAddedByReason,
   getRemovedByReason,
   hasAttention,
 } from '../../../utils/attention-set-util';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {preferenceDiffViewMode$} from '../../../services/user/user-model';
+import {
+  Shortcut,
+  ShortcutSection,
+  shortcutsServiceToken,
+} from '../../../services/shortcuts/shortcuts-service';
+import {LoadingStatus} from '../../../models/change/change-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {resolve} from '../../../models/dependency';
+import {checksModelToken} from '../../../models/checks/checks-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {filesModelToken} from '../../../models/change/files-model';
+import {getBaseUrl, prependOrigin} from '../../../utils/url-util';
+import {CopyLink, GrCopyLinks} from '../gr-copy-links/gr-copy-links';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+  ChangeViewState,
+  createChangeUrl,
+  createEditUrl,
+} from '../../../models/views/change';
+import {rootUrl} from '../../../utils/url-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
 const REVIEWERS_REGEX = /^(R|CC)=/gm;
 const MIN_CHECK_INTERVAL_SECS = 0;
 
-const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
-
 const ACCIDENTAL_STARRING_LIMIT_MS = 10 * 1000;
 
 const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
@@ -224,39 +205,10 @@
 // Making the tab names more unique in case a plugin adds one with same name
 const ROBOT_COMMENTS_LIMIT = 10;
 
-export interface GrChangeView {
-  $: {
-    applyFixDialog: GrApplyFixDialog;
-    fileList: GrFileList & Element;
-    fileListHeader: GrFileListHeader;
-    commitMessageEditor: GrEditableContent;
-    includedInOverlay: GrOverlay;
-    includedInDialog: GrIncludedInDialog;
-    downloadOverlay: GrOverlay;
-    downloadDialog: GrDownloadDialog;
-    replyOverlay: GrOverlay;
-    mainContent: HTMLDivElement;
-    changeStar: GrChangeStar;
-    actions: GrChangeActions;
-    commitMessage: HTMLDivElement;
-    commitAndRelated: HTMLDivElement;
-    metadata: GrChangeMetadata;
-    mainChangeInfo: HTMLDivElement;
-    replyBtn: GrButton;
-  };
-}
-
 export type ChangeViewPatchRange = Partial<PatchRange>;
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-change-view')
-export class GrChangeView extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrChangeView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
@@ -275,342 +227,324 @@
    * @event show-auth-required
    */
 
-  private readonly reporting = appContext.reportingService;
+  @query('#applyFixDialog') applyFixDialog?: GrApplyFixDialog;
 
-  private readonly jsAPI = appContext.jsApiService;
+  @query('#fileList') fileList?: GrFileList;
 
-  private readonly changeService = appContext.changeService;
+  @query('#fileListHeader') fileListHeader?: GrFileListHeader;
 
-  /**
-   * URL params passed from the router.
-   */
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: AppElementChangeViewParams;
+  @query('#commitMessageEditor') commitMessageEditor?: GrEditableContent;
 
-  @property({type: Object, notify: true, observer: '_viewStateChanged'})
-  viewState: Partial<ChangeViewState> = {};
+  @query('#includedInModal') includedInModal?: HTMLDialogElement;
+
+  @query('#includedInDialog') includedInDialog?: GrIncludedInDialog;
+
+  @query('#downloadModal') downloadModal?: HTMLDialogElement;
+
+  @query('#downloadDialog') downloadDialog?: GrDownloadDialog;
+
+  @query('#replyModal') replyModal?: HTMLDialogElement;
+
+  @query('#replyDialog') replyDialog?: GrReplyDialog;
+
+  @query('#mainContent') mainContent?: HTMLDivElement;
+
+  @query('#changeStar') changeStar?: GrChangeStar;
+
+  @query('#actions') actions?: GrChangeActions;
+
+  @query('#commitMessage') commitMessage?: HTMLDivElement;
+
+  @query('#commitAndRelated') commitAndRelated?: HTMLDivElement;
+
+  @query('#metadata') metadata?: GrChangeMetadata;
+
+  @query('#mainChangeInfo') mainChangeInfo?: HTMLDivElement;
+
+  @query('#replyBtn') replyBtn?: GrButton;
+
+  @query('#tabs') tabs?: PaperTabsElement;
+
+  @query('gr-messages-list') messagesList?: GrMessagesList;
+
+  @query('gr-thread-list') threadList?: GrThreadList;
+
+  @query('gr-copy-links') private copyLinksDropdown?: GrCopyLinks;
+
+  private _viewState?: ChangeViewState;
+
+  @property({type: Object})
+  get viewState() {
+    return this._viewState;
+  }
+
+  set viewState(viewState: ChangeViewState | undefined) {
+    if (this._viewState === viewState) return;
+    const oldViewState = this._viewState;
+    this._viewState = viewState;
+    this.viewStateChanged();
+    this.requestUpdate('viewState', oldViewState);
+  }
 
   @property({type: String})
   backPage?: string;
 
-  @property({type: Boolean})
-  hasParent?: boolean;
+  @state()
+  private hasParent?: boolean;
 
-  @property({type: Boolean})
-  disableEdit = false;
+  // Private but used in tests.
+  @state()
+  commentThreads?: CommentThread[];
 
-  @property({type: Array})
-  _commentThreads?: CommentThread[];
+  // Don't use, use serverConfig instead.
+  private _serverConfig?: ServerInfo;
 
-  // TODO(taoalpha): Consider replacing diffDrafts
-  // with _draftCommentThreads everywhere, currently only
-  // replaced in reply-dialog
-  @property({type: Array})
-  _draftCommentThreads?: CommentThread[];
+  // Private but used in tests.
+  @state()
+  get serverConfig() {
+    return this._serverConfig;
+  }
 
-  @property({
-    type: Array,
-    computed:
-      '_computeRobotCommentThreads(_commentThreads,' +
-      ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
-  })
-  _robotCommentThreads?: CommentThread[];
+  set serverConfig(serverConfig: ServerInfo | undefined) {
+    if (this._serverConfig === serverConfig) return;
+    const oldServerConfig = this._serverConfig;
+    this._serverConfig = serverConfig;
+    this.startUpdateCheckTimer();
+    this.requestUpdate('serverConfig', oldServerConfig);
+  }
 
-  @property({type: Object, observer: '_startUpdateCheckTimer'})
-  _serverConfig?: ServerInfo;
+  @state()
+  private account?: AccountDetailInfo;
 
-  @property({type: Object})
-  _diffPrefs?: DiffPreferencesInfo;
+  // Private but used in tests.
+  @state()
+  prefs?: PreferencesInfo;
 
-  @property({type: Number, observer: '_numFilesShownChanged'})
-  _numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+  canStartReview() {
+    return !!(
+      this.change &&
+      this.change.actions &&
+      this.change.actions.ready &&
+      this.change.actions.ready.enabled
+    );
+  }
 
-  @property({type: Object})
-  _account?: AccountDetailInfo;
+  // Use change getter/setter instead.
+  private _change?: ParsedChangeInfo;
 
-  @property({type: Object})
-  _prefs?: PreferencesInfo;
+  @state()
+  get change() {
+    return this._change;
+  }
 
-  @property({type: Object})
-  _changeComments?: ChangeComments;
+  set change(change: ParsedChangeInfo | undefined) {
+    if (this._change === change) return;
+    const oldChange = this._change;
+    this._change = change;
+    this.changeChanged(oldChange);
+    this.requestUpdate('change', oldChange);
+  }
 
-  @property({type: Boolean, computed: '_computeCanStartReview(_change)'})
-  _canStartReview?: boolean;
+  // Private but used in tests.
+  @state()
+  commitInfo?: CommitInfo;
 
-  @property({type: Object, observer: '_changeChanged'})
-  _change?: ChangeInfo | ParsedChangeInfo;
+  // Private but used in tests.
+  @state()
+  changeNum?: NumericChangeId;
 
-  @property({type: Object, computed: '_getRevisionInfo(_change)'})
-  _revisionInfo?: RevisionInfoClass;
+  // Private but used in tests.
+  @state()
+  diffDrafts?: {[path: string]: DraftInfo[]} = {};
 
-  @property({type: Object})
-  _commitInfo?: CommitInfo;
+  @state()
+  private editingCommitMessage = false;
 
-  @property({
-    type: Object,
-    computed:
-      '_computeCurrentRevision(_change.current_revision, ' +
-      '_change.revisions)',
-    observer: '_handleCurrentRevisionUpdate',
-  })
-  _currentRevision?: RevisionInfo;
+  @state()
+  private latestCommitMessage: string | null = '';
 
-  @property({type: String})
-  _changeNum?: NumericChangeId;
+  // Use patchRange getter/setter.
+  private _patchRange?: ChangeViewPatchRange;
 
-  @property({type: Object})
-  _diffDrafts?: {[path: string]: UIDraft[]} = {};
+  // Private but used in tests.
+  @state()
+  get patchRange() {
+    return this._patchRange;
+  }
 
-  @property({type: Boolean})
-  _editingCommitMessage = false;
+  set patchRange(patchRange: ChangeViewPatchRange | undefined) {
+    if (this._patchRange === patchRange) return;
+    const oldPatchRange = this._patchRange;
+    this._patchRange = patchRange;
+    this.patchNumChanged();
+    this.requestUpdate('patchRange', oldPatchRange);
+  }
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeHideEditCommitMessage(_loggedIn, ' +
-      '_editingCommitMessage, _change, _editMode)',
-  })
-  _hideEditCommitMessage?: boolean;
+  // Private but used in tests.
+  @state()
+  selectedRevision?: RevisionInfo | EditRevisionInfo;
 
-  @property({type: String})
-  _diffAgainst?: string;
+  /**
+   * <gr-change-actions> populates this via two-way data binding.
+   * Private but used in tests.
+   */
+  @state()
+  currentRevisionActions?: ActionNameToActionInfoMap = {};
 
-  @property({type: String})
-  _latestCommitMessage: string | null = '';
+  @state()
+  private allPatchSets?: PatchSet[];
 
-  @property({type: Object})
-  _constants = {
-    SecondaryTab,
-    PrimaryTab,
-  };
+  // Private but used in tests.
+  @state()
+  loggedIn = false;
 
-  @property({type: Object})
-  _messages = NO_ROBOT_COMMENTS_THREADS_MSG;
+  // Private but used in tests.
+  @state()
+  loading?: boolean;
 
-  @property({type: Number})
-  _lineHeight?: number;
+  @state()
+  private projectConfig?: ConfigInfo;
 
-  @property({type: Object})
-  _patchRange?: ChangeViewPatchRange;
+  @state()
+  private shownFileCount?: number;
 
-  @property({type: String})
-  _filesExpanded?: string;
+  // Private but used in tests.
+  @state()
+  initialLoadComplete = false;
 
-  @property({type: String})
-  _basePatchNum?: string;
+  // Private but used in tests.
+  @state()
+  replyDisabled = true;
 
-  @property({type: Object})
-  _selectedRevision?: RevisionInfo | EditRevisionInfo;
+  // Private but used in tests.
+  @state()
+  changeStatuses: ChangeStates[] = [];
 
-  @property({type: Object})
-  _currentRevisionActions?: ActionNameToActionInfoMap;
+  @state()
+  private updateCheckTimerHandle?: number | null;
 
-  @property({
-    type: Array,
-    computed: '_computeAllPatchSets(_change, _change.revisions.*)',
-  })
-  _allPatchSets?: PatchSet[];
+  // Private but used in tests.
+  getEditMode() {
+    if (!this.patchRange || !this.viewState) {
+      return false;
+    }
 
-  @property({type: Boolean})
-  _loggedIn = false;
+    if (this.viewState.edit) {
+      return true;
+    }
 
-  @property({type: Boolean})
-  _loading?: boolean;
+    return this.patchRange.patchNum === EDIT;
+  }
 
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
+  isSubmitEnabled(): boolean {
+    return !!(
+      this.currentRevisionActions &&
+      this.currentRevisionActions.submit &&
+      this.currentRevisionActions.submit.enabled
+    );
+  }
 
-  @property({
-    type: String,
-    computed: '_computeReplyButtonLabel(_diffDrafts, _canStartReview)',
-  })
-  _replyButtonLabel = 'Reply';
+  // Private but used in tests.
+  @state()
+  mergeable: boolean | null = null;
 
-  @property({type: String})
-  _selectedPatchSet?: string;
+  /**
+   * Plugins can provide (multiple) tabs. For each plugin tab we render an
+   * endpoint for the header. If the plugin tab is active, then we also render
+   * an endpoint for the content.
+   *
+   * This is the list of endpoint names for the headers. The header name that
+   * the user sees is an implementation detail of the plugin that we don't know.
+   */
+  // Private but used in tests.
+  @state()
+  pluginTabsHeaderEndpoints: string[] = [];
 
-  @property({type: Number})
-  _shownFileCount?: number;
+  /**
+   * Plugins can provide (multiple) tabs. For each plugin tab we render an
+   * endpoint for the header. If the plugin tab is active, then we also render
+   * an endpoint for the content.
+   *
+   * This is the list of endpoint names for the content.
+   */
+  @state()
+  private pluginTabsContentEndpoints: string[] = [];
 
-  @property({type: Boolean})
-  _initialLoadComplete = false;
-
-  @property({type: Boolean})
-  _replyDisabled = true;
-
-  @property({
-    type: String,
-    computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
-  })
-  _changeStatuses?: ChangeStates[];
-
-  /** If false, then the "Show more" button was used to expand. */
-  @property({type: Boolean})
-  _commitCollapsed = true;
-
-  /** Is the "Show more/less" button visible? */
-  @property({
-    type: Boolean,
-    computed: '_computeCommitCollapsible(_latestCommitMessage)',
-  })
-  _commitCollapsible?: boolean;
-
-  @property({type: Number})
-  _updateCheckTimerHandle?: number | null;
-
-  @property({
-    type: Boolean,
-    computed: '_computeEditMode(_patchRange.*, params.*)',
-  })
-  _editMode?: boolean;
-
-  @property({
-    type: Boolean,
-    computed: '_isParentCurrent(_currentRevisionActions)',
-  })
-  _parentIsCurrent?: boolean;
-
-  @property({
-    type: Boolean,
-    computed: '_isSubmitEnabled(_currentRevisionActions)',
-  })
-  _submitEnabled?: boolean;
-
-  @property({type: Boolean})
-  _mergeable: boolean | null = null;
-
-  @property({type: Boolean})
-  _showFileTabContent = true;
-
-  @property({type: Array})
-  _dynamicTabHeaderEndpoints: string[] = [];
-
-  @property({type: Array})
-  _dynamicTabContentEndpoints: string[] = [];
-
-  @property({type: String})
-  // The dynamic content of the plugin added tab
-  _selectedTabPluginEndpoint?: string;
-
-  @property({type: String})
-  // The dynamic heading of the plugin added tab
-  _selectedTabPluginHeader?: string;
-
-  @property({
-    type: Array,
-    computed:
-      '_computeRobotCommentsPatchSetDropdownItems(_change, _commentThreads)',
-  })
-  _robotCommentsPatchSetDropdownItems: DropdownLink[] = [];
-
-  @property({type: Number})
-  _currentRobotCommentsPatchSet?: PatchSetNum;
+  @state()
+  private 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;
+  @state()
+  private changeViewAriaHidden = false;
 
   /**
-   * this is a two-element tuple to always
-   * hold the current active tab for both primary and secondary tabs
+   * This can be a string only for plugin provided tabs.
    */
-  @property({type: Array})
-  _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
+  // visible for testing
+  @state()
+  activeTab: Tab | string = Tab.FILES;
 
   @property({type: Boolean})
   unresolvedOnly = true;
 
-  @property({type: Boolean})
-  _showAllRobotComments = false;
+  @state()
+  private showAllRobotComments = false;
 
-  @property({type: Boolean})
-  _showRobotCommentsButton = false;
+  @state()
+  private showRobotCommentsButton = false;
 
-  _throttledToggleChangeStar?: (e: KeyboardEvent) => void;
+  @state()
+  private draftCount = 0;
 
-  @property({type: Boolean})
-  _showChecksTab = false;
+  private throttledToggleChangeStar?: (e: KeyboardEvent) => void;
 
-  @property({type: Boolean})
+  @state()
+  private showChecksTab = false;
+
+  // visible for testing
+  @state()
+  showFindingsTab = false;
+
+  @state()
   private isViewCurrent = false;
 
-  @property({type: String})
-  _tabState?: TabState;
+  @state()
+  private tabState?: TabState;
 
-  @property({type: Object})
-  revertedChange?: ChangeInfo;
+  @state()
+  private revertedChange?: ChangeInfo;
 
-  @property({type: String})
+  // Private but used in tests.
+  @state()
   scrollCommentId?: UrlEncodedCommentId;
 
-  /** Just reflects the `opened` prop of the overlay. */
-  @property({type: Boolean})
-  replyOverlayOpened = false;
+  /** Reflects the `opened` state of the reply dialog. */
+  @state()
+  replyModalOpened = false;
 
-  @property({
-    type: Array,
-    computed: '_computeResolveWeblinks(_change, _commitInfo, _serverConfig)',
-  })
-  resolveWeblinks?: GeneratedWebLink[];
+  // Accessed in tests.
+  readonly reporting = getAppContext().reportingService;
 
-  restApiService = appContext.restApiService;
+  private readonly getChecksModel = resolve(this, checksModelToken);
 
-  private readonly userService = appContext.userService;
+  readonly restApiService = getAppContext().restApiService;
 
-  private readonly commentsService = appContext.commentsService;
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
-  private readonly shortcuts = appContext.shortcutsService;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
-      listen(Shortcut.EMOJI_DROPDOWN, _ => {}), // docOnly
-      listen(Shortcut.REFRESH_CHANGE, _ => fireReload(this, true)),
-      listen(Shortcut.OPEN_REPLY_DIALOG, _ => this._handleOpenReplyDialog()),
-      listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ =>
-        this._handleOpenDownloadDialog()
-      ),
-      listen(Shortcut.TOGGLE_DIFF_MODE, _ => this._handleToggleDiffMode()),
-      listen(Shortcut.TOGGLE_CHANGE_STAR, e => {
-        if (this._throttledToggleChangeStar) {
-          this._throttledToggleChangeStar(e);
-        }
-      }),
-      listen(Shortcut.UP_TO_DASHBOARD, _ => this._determinePageBack()),
-      listen(Shortcut.EXPAND_ALL_MESSAGES, _ =>
-        this._handleExpandAllMessages()
-      ),
-      listen(Shortcut.COLLAPSE_ALL_MESSAGES, _ =>
-        this._handleCollapseAllMessages()
-      ),
-      listen(Shortcut.OPEN_DIFF_PREFS, _ =>
-        this._handleOpenDiffPrefsShortcut()
-      ),
-      listen(Shortcut.EDIT_TOPIC, _ => this.$.metadata.editTopic()),
-      listen(Shortcut.DIFF_AGAINST_BASE, _ => this._handleDiffAgainstBase()),
-      listen(Shortcut.DIFF_AGAINST_LATEST, _ =>
-        this._handleDiffAgainstLatest()
-      ),
-      listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
-        this._handleDiffBaseAgainstLeft()
-      ),
-      listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
-        this._handleDiffRightAgainstLatest()
-      ),
-      listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
-        this._handleDiffBaseAgainstLatest()
-      ),
-      listen(Shortcut.OPEN_SUBMIT_DIALOG, _ => this._handleOpenSubmitDialog()),
-      listen(Shortcut.TOGGLE_ATTENTION_SET, _ =>
-        this._handleToggleAttentionSet()
-      ),
-    ];
-  }
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  disconnected$ = new Subject();
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private replyRefitTask?: DelayedTask;
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getFilesModel = resolve(this, filesModelToken);
+
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   private scrollTask?: DelayedTask;
 
@@ -624,99 +558,42 @@
    */
   private scrollPosition?: number;
 
-  override ready() {
-    super.ready();
-    aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
-      this._showChecksTab = b;
-    });
-    routerView$.pipe(takeUntil(this.disconnected$)).subscribe(view => {
-      this.isViewCurrent = view === GerritView.CHANGE;
-    });
-    drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
-      this._diffDrafts = {...drafts};
-    });
-    preferenceDiffViewMode$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(diffViewMode => {
-        this.diffViewMode = diffViewMode;
-      });
-    changeComments$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(changeComments => {
-        this._changeComments = changeComments;
-      });
-  }
+  private connected$ = new BehaviorSubject(false);
+
+  /**
+   * For `connectedCallback()` to distinguish between connecting to the DOM for
+   * the first time or if just re-connecting.
+   */
+  private isFirstConnection = true;
+
+  /** Simply reflects the router-model value. */
+  // visible for testing
+  viewModelPatchNum?: PatchSetNum;
+
+  private readonly shortcutsController = new ShortcutController(this);
+
+  private readonly getNavigation = resolve(this, navigationToken);
 
   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
-      // background to be scrollable. This will eliminate background scroll by
-      // hiding most of the contents on the screen upon opening, and showing
-      // again upon closing.
-      'fullscreen-overlay-opened',
-      () => this._handleHideBackgroundContent()
-    );
-
-    this.addEventListener('fullscreen-overlay-closed', () =>
-      this._handleShowBackgroundContent()
-    );
-
-    this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+    this.setupListeners();
+    this.setupShortcuts();
+    this.setupSubscriptions();
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
-      this._handleToggleChangeStar()
-    );
-    this._getServerConfig().then(config => {
-      this._serverConfig = config;
-      this._replyDisabled = false;
-    });
-
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.restApiService.getAccount().then(acct => {
-          this._account = acct;
-        });
-      }
-    });
-
-    getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        this._dynamicTabHeaderEndpoints =
-          getPluginEndpoints().getDynamicEndpoints('change-view-tab-header');
-        this._dynamicTabContentEndpoints =
-          getPluginEndpoints().getDynamicEndpoints('change-view-tab-content');
-        if (
-          this._dynamicTabContentEndpoints.length !==
-          this._dynamicTabHeaderEndpoints.length
-        ) {
-          this.reporting.error(new Error('Mismatch of headers and content.'));
-        }
-      })
-      .then(() => this._initActiveTabs(this.params));
-
+  private setupListeners() {
+    this.addEventListener('open-reply-dialog', () => this.openReplyDialog());
     this.addEventListener('change-message-deleted', () => fireReload(this));
     this.addEventListener('editable-content-save', e =>
-      this._handleCommitMessageSave(e)
+      this.handleCommitMessageSave(e)
     );
     this.addEventListener('editable-content-cancel', () =>
-      this._handleCommitMessageCancel()
+      this.handleCommitMessageCancel()
     );
-    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
-    document.addEventListener('visibilitychange', this.handleVisibilityChange);
-    document.addEventListener('scroll', this.handleScroll);
+    this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
+    this.addEventListener('close-fix-preview', e => this.onCloseFixPreview(e));
 
-    this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
-      this._setActivePrimaryTab(e)
-    );
+    this.addEventListener(EventType.SHOW_TAB, e => this.setActiveTab(e));
     this.addEventListener('reload', e => {
       this.loadData(
         /* isLocationChange= */ false,
@@ -725,28 +602,1063 @@
     });
   }
 
+  private setupShortcuts() {
+    // TODO: Do we still need docOnly bindings?
+    this.shortcutsController.addAbstract(Shortcut.EMOJI_DROPDOWN, () => {}); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.MENTIONS_DROPDOWN, () => {}); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.REFRESH_CHANGE, () =>
+      fireReload(this, true)
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_REPLY_DIALOG, () =>
+      this.handleOpenReplyDialog()
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_DOWNLOAD_DIALOG, () =>
+      this.handleOpenDownloadDialog()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_DIFF_MODE, () =>
+      this.handleToggleDiffMode()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_CHANGE_STAR, e => {
+      if (this.throttledToggleChangeStar) {
+        this.throttledToggleChangeStar(e);
+      }
+    });
+    this.shortcutsController.addAbstract(Shortcut.UP_TO_DASHBOARD, () =>
+      this.determinePageBack()
+    );
+    this.shortcutsController.addAbstract(Shortcut.EXPAND_ALL_MESSAGES, () =>
+      this.handleExpandAllMessages()
+    );
+    this.shortcutsController.addAbstract(Shortcut.COLLAPSE_ALL_MESSAGES, () =>
+      this.handleCollapseAllMessages()
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_DIFF_PREFS, () =>
+      this.handleOpenDiffPrefsShortcut()
+    );
+    this.shortcutsController.addAbstract(Shortcut.EDIT_TOPIC, () => {
+      assertIsDefined(this.metadata);
+      this.metadata.editTopic();
+    });
+    this.shortcutsController.addAbstract(Shortcut.DIFF_AGAINST_BASE, () =>
+      this.handleDiffAgainstBase()
+    );
+    this.shortcutsController.addAbstract(Shortcut.DIFF_AGAINST_LATEST, () =>
+      this.handleDiffAgainstLatest()
+    );
+    this.shortcutsController.addAbstract(Shortcut.DIFF_BASE_AGAINST_LEFT, () =>
+      this.handleDiffBaseAgainstLeft()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+      () => this.handleDiffRightAgainstLatest()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.DIFF_BASE_AGAINST_LATEST,
+      () => this.handleDiffBaseAgainstLatest()
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_SUBMIT_DIALOG, () =>
+      this.handleOpenSubmitDialog()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_ATTENTION_SET, () =>
+      this.handleToggleAttentionSet()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.OPEN_COPY_LINKS_DROPDOWN,
+      () => this.copyLinksDropdown?.openDropdown()
+    );
+  }
+
+  private setupSubscriptions() {
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      s => (this.viewState = s)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().tab$,
+      t => (this.activeTab = t ?? Tab.FILES)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().aPluginHasRegistered$,
+      b => {
+        this.showChecksTab = b;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().robotCommentCount$,
+      count => {
+        this.showFindingsTab = count > 0;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().childView$,
+      childView => {
+        this.isViewCurrent = childView === ChangeChildView.OVERVIEW;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().patchNum$,
+      patchNum => {
+        this.viewModelPatchNum = patchNum;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().drafts$,
+      drafts => {
+        this.diffDrafts = {...drafts};
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferenceDiffViewMode$,
+      diffViewMode => {
+        this.diffViewMode = diffViewMode;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().draftsCount$,
+      draftCount => {
+        this.draftCount = draftCount;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
+      threads => {
+        this.commentThreads = threads;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => {
+        // The change view is tied to a specific change number, so don't update
+        // change to undefined.
+        if (change) this.change = change;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      account => {
+        this.account = account;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      loggedIn => {
+        this.loggedIn = loggedIn;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+        this.replyDisabled = false;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().repoConfig$,
+      config => {
+        this.projectConfig = config;
+      }
+    );
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.firstConnectedCallback();
+    this.connected$.next(true);
+
+    // Make sure to reverse everything below this line in disconnectedCallback().
+    // Or consider using either firstConnectedCallback() or constructor().
+    document.addEventListener('visibilitychange', this.handleVisibilityChange);
+    document.addEventListener('scroll', this.handleScroll);
+  }
+
+  override firstUpdated() {
+    // _onTabSizingChanged is called when iron-items-changed event is fired
+    // from iron-selectable but that is called before the element is present
+    // in view which whereas the method requires paper tabs already be visible
+    // as it relies on dom rect calculation.
+    // _onTabSizingChanged ensures the primary tab(Files/Comments/Checks) is
+    // underlined.
+    assertIsDefined(this.tabs, 'tabs');
+    whenVisible(this.tabs, () => this.tabs!._onTabSizingChanged());
+  }
+
+  /**
+   * For initialization that should only happen once, not again when
+   * re-connecting to the DOM later.
+   */
+  private firstConnectedCallback() {
+    if (!this.isFirstConnection) return;
+    this.isFirstConnection = false;
+
+    this.getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this.pluginTabsHeaderEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-view-tab-header'
+          );
+        this.pluginTabsContentEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-view-tab-content'
+          );
+        if (
+          this.pluginTabsContentEndpoints.length !==
+          this.pluginTabsHeaderEndpoints.length
+        ) {
+          this.reporting.error(
+            'Plugin change-view-tab',
+            new Error('Mismatch of headers and content.')
+          );
+        }
+      })
+      .then(() => this.initActiveTab());
+
+    this.throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
+      this.handleToggleChangeStar()
+    );
+  }
+
   override disconnectedCallback() {
-    this.disconnected$.next();
     document.removeEventListener(
       'visibilitychange',
       this.handleVisibilityChange
     );
     document.removeEventListener('scroll', this.handleScroll);
-    this.replyRefitTask?.cancel();
     this.scrollTask?.cancel();
 
-    if (this._updateCheckTimerHandle) {
-      this._cancelUpdateCheckTimer();
+    if (this.updateCheckTimerHandle) {
+      this.cancelUpdateCheckTimer();
     }
+    this.connected$.next(false);
     super.disconnectedCallback();
   }
 
-  get messagesList(): GrMessagesList | null {
-    return this.shadowRoot!.querySelector<GrMessagesList>('gr-messages-list');
+  protected override willUpdate(changedProperties: PropertyValues): void {
+    if (
+      changedProperties.has('change') ||
+      changedProperties.has('mergeable') ||
+      changedProperties.has('currentRevisionActions')
+    ) {
+      this.changeStatuses = this.computeChangeStatusChips();
+    }
   }
 
-  get threadList(): GrThreadList | null {
-    return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list');
+  static override get styles() {
+    return [
+      a11yStyles,
+      paperStyles,
+      sharedStyles,
+      modalStyles,
+      css`
+        .container:not(.loading) {
+          background-color: var(--background-color-tertiary);
+        }
+        .container.loading {
+          color: var(--deemphasized-text-color);
+          padding: var(--spacing-l);
+        }
+        .header {
+          align-items: center;
+          background-color: var(--background-color-primary);
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .header.editMode {
+          background-color: var(--edit-mode-background-color);
+        }
+        .header .download {
+          margin-right: var(--spacing-l);
+        }
+        gr-change-status {
+          margin-left: var(--spacing-s);
+        }
+        gr-change-status:first-child {
+          margin-left: 0;
+        }
+        .headerTitle {
+          align-items: center;
+          display: flex;
+          flex: 1;
+        }
+        .headerSubject {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+          margin-left: var(--spacing-l);
+        }
+        .changeNumberColon {
+          color: transparent;
+        }
+        .changeCopyClipboard {
+          margin-left: var(--spacing-s);
+        }
+        .showCopyLinkDialogButton {
+          --gr-button-padding: 0 0 0 var(--spacing-s);
+          --background-color: transparent;
+          margin-left: var(--spacing-s);
+        }
+        #replyBtn {
+          margin-bottom: var(--spacing-m);
+        }
+        gr-change-star {
+          margin-left: var(--spacing-s);
+        }
+        .showCopyLinkDialogButton gr-change-star {
+          margin-left: 0;
+        }
+        a.changeNumber {
+          margin-left: var(--spacing-xs);
+        }
+        gr-reply-dialog {
+          width: 60em;
+        }
+        .changeStatus {
+          text-transform: capitalize;
+        }
+        /* Strong specificity here is needed due to
+            https://github.com/Polymer/polymer/issues/2531 */
+        .container .changeInfo {
+          display: flex;
+          background-color: var(--background-color-secondary);
+          padding-right: var(--spacing-m);
+        }
+        section {
+          background-color: var(--view-background-color);
+          box-shadow: var(--elevation-level-1);
+        }
+        .changeMetadata {
+          /* Limit meta section to half of the screen at max */
+          max-width: 50%;
+        }
+        .commitMessage {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          margin-right: var(--spacing-l);
+          margin-bottom: var(--spacing-l);
+          /* Account for border and padding and rounding errors. */
+          max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
+        }
+        .commitMessage gr-formatted-text {
+          word-break: break-word;
+        }
+        #commitMessageEditor {
+          /* Account for border and padding and rounding errors. */
+          min-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
+          --collapsed-max-height: 300px;
+        }
+        .changeStatuses,
+        .commitActions {
+          align-items: center;
+          display: flex;
+        }
+        .changeStatuses {
+          flex-wrap: wrap;
+        }
+        .mainChangeInfo {
+          display: flex;
+          flex: 1;
+          flex-direction: column;
+          min-width: 0;
+        }
+        #commitAndRelated {
+          align-content: flex-start;
+          display: flex;
+          flex: 1;
+          overflow-x: hidden;
+        }
+        .relatedChanges {
+          flex: 0 1 auto;
+          overflow: hidden;
+          padding: var(--spacing-l) 0;
+        }
+        .mobile {
+          display: none;
+        }
+        hr {
+          border: 0;
+          border-top: 1px solid var(--border-color);
+          height: 0;
+          margin-bottom: var(--spacing-l);
+        }
+        .emptySpace {
+          flex-grow: 1;
+        }
+        .commitContainer {
+          display: flex;
+          flex-direction: column;
+          flex-shrink: 0;
+          margin: var(--spacing-l) 0;
+          padding: 0 var(--spacing-l);
+        }
+        .showOnEdit {
+          display: none;
+        }
+        .scrollable {
+          overflow: auto;
+        }
+        .text {
+          white-space: pre;
+        }
+        gr-commit-info {
+          display: inline-block;
+        }
+        paper-tabs {
+          background-color: var(--background-color-tertiary);
+          margin-top: var(--spacing-m);
+          height: calc(var(--line-height-h3) + var(--spacing-m));
+          --paper-tabs-selection-bar-color: var(--link-color);
+        }
+        paper-tab {
+          box-sizing: border-box;
+          max-width: 12em;
+          --paper-tab-ink: var(--link-color);
+          --paper-font-common-base_-_font-family: var(--header-font-family);
+          --paper-font-common-base_-_-webkit-font-smoothing: initial;
+          --paper-tab-content_-_margin-bottom: var(--spacing-s);
+          /* paper-tabs uses 700 here, which can look awkward */
+          --paper-tab-content-focused_-_font-weight: var(--font-weight-h3);
+          --paper-tab-content-focused_-_background: var(
+            --gray-background-focus
+          );
+          --paper-tab-content-unselected_-_opacity: 1;
+          --paper-tab-content-unselected_-_color: var(
+            --deemphasized-text-color
+          );
+        }
+        gr-thread-list,
+        gr-messages-list {
+          display: block;
+        }
+        gr-thread-list {
+          min-height: 250px;
+        }
+        #includedInModal {
+          width: 65em;
+        }
+        #uploadHelpOverlay {
+          width: 50em;
+        }
+        #metadata {
+          --metadata-horizontal-padding: var(--spacing-l);
+          padding-top: var(--spacing-l);
+          width: 100%;
+        }
+        gr-change-summary {
+          margin-left: var(--spacing-m);
+        }
+        @media screen and (max-width: 75em) {
+          .relatedChanges {
+            padding: 0;
+          }
+          #relatedChanges {
+            padding-top: var(--spacing-l);
+          }
+          #commitAndRelated {
+            flex-direction: column;
+            flex-wrap: nowrap;
+          }
+          #commitMessageEditor {
+            min-width: 0;
+          }
+          .commitMessage {
+            margin-right: 0;
+          }
+          .mainChangeInfo {
+            padding-right: 0;
+          }
+        }
+        @media screen and (max-width: 50em) {
+          .mobile {
+            display: block;
+          }
+          .header {
+            align-items: flex-start;
+            flex-direction: column;
+            flex: 1;
+            padding: var(--spacing-s) var(--spacing-l);
+          }
+          .headerTitle {
+            flex-wrap: wrap;
+            font-family: var(--header-font-family);
+            font-size: var(--font-size-h3);
+            font-weight: var(--font-weight-h3);
+            line-height: var(--line-height-h3);
+          }
+          .desktop {
+            display: none;
+          }
+          .reply {
+            display: block;
+            margin-right: 0;
+            /* px because don't have the same font size */
+            margin-bottom: 6px;
+          }
+          .changeInfo-column:not(:last-of-type) {
+            margin-right: 0;
+            padding-right: 0;
+          }
+          .changeInfo,
+          #commitAndRelated {
+            flex-direction: column;
+            flex-wrap: nowrap;
+          }
+          .commitContainer {
+            margin: 0;
+            padding: var(--spacing-l);
+          }
+          .changeMetadata {
+            margin-top: var(--spacing-xs);
+            max-width: none;
+          }
+          #metadata,
+          .mainChangeInfo {
+            padding: 0;
+          }
+          .commitActions {
+            display: block;
+            margin-top: var(--spacing-l);
+            width: 100%;
+          }
+          .commitMessage {
+            flex: initial;
+            margin: 0;
+          }
+          gr-reply-dialog {
+            height: 100vh;
+            min-width: initial;
+            width: 100vw;
+          }
+        }
+        .patch-set-dropdown {
+          margin: var(--spacing-m) 0 0 var(--spacing-m);
+        }
+        .show-robot-comments {
+          margin: var(--spacing-m);
+        }
+        .tabContent gr-thread-list::part(threads) {
+          padding: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`${this.renderLoading()}${this.renderMainContent()}`;
+  }
+
+  private renderLoading() {
+    if (!this.loading) return nothing;
+    return html`
+      <div class="container loading" ?hidden=${!this.loading}>Loading...</div>
+    `;
+  }
+
+  private renderMainContent() {
+    return html`
+      <div
+        id="mainContent"
+        class="container"
+        ?hidden=${this.loading}
+        aria-hidden=${this.changeViewAriaHidden ? 'true' : 'false'}
+      >
+        ${this.renderChangeInfoSection()}
+        <h2 class="assistive-tech-only">Files and Comments tabs</h2>
+        ${this.renderTabHeaders()} ${this.renderTabContent()}
+        ${this.renderChangeLog()}
+      </div>
+      <gr-apply-fix-dialog
+        id="applyFixDialog"
+        .change=${this.change}
+        .changeNum=${this.changeNum}
+      ></gr-apply-fix-dialog>
+      <dialog id="downloadModal" tabindex="-1">
+        <gr-download-dialog
+          id="downloadDialog"
+          .change=${this.change}
+          .patchNum=${this.patchRange?.patchNum}
+          .config=${this.serverConfig?.download}
+          @close=${this.handleDownloadDialogClose}
+        ></gr-download-dialog>
+      </dialog>
+      <dialog id="includedInModal" tabindex="-1">
+        <gr-included-in-dialog
+          id="includedInDialog"
+          .changeNum=${this.changeNum}
+          @close=${this.handleIncludedInDialogClose}
+        ></gr-included-in-dialog>
+      </dialog>
+      <dialog id="replyModal" @close=${this.onReplyModalCanceled}>
+        ${when(
+          this.replyModalOpened && this.loggedIn,
+          () => html`
+            <gr-reply-dialog
+              id="replyDialog"
+              .patchNum=${computeLatestPatchNum(this.allPatchSets)}
+              .permittedLabels=${this.change?.permitted_labels}
+              .projectConfig=${this.projectConfig}
+              .canBeStarted=${this.canStartReview()}
+              @send=${this.handleReplySent}
+              @cancel=${this.handleReplyCancel}
+            >
+            </gr-reply-dialog>
+          `
+        )}
+      </dialog>
+    `;
+  }
+
+  private renderChangeInfoSection() {
+    return html`<section class="changeInfoSection">
+      <div class=${this.computeHeaderClass()}>
+        <h1 class="assistive-tech-only">
+          Change ${this.change?._number}: ${this.change?.subject}
+        </h1>
+        ${this.renderHeaderTitle()} ${this.renderCommitActions()}
+      </div>
+      <h2 class="assistive-tech-only">Change metadata</h2>
+      ${this.renderChangeInfo()}
+    </section>`;
+  }
+
+  private renderHeaderTitle() {
+    const resolveWeblinks = this.commitInfo?.resolve_conflicts_web_links ?? [];
+    return html` <div class="headerTitle">
+      <div class="changeStatuses">
+        ${this.changeStatuses.map(
+          status => html` <gr-change-status
+            .change=${this.change}
+            .revertedChange=${this.revertedChange}
+            .status=${status}
+            .resolveWeblinks=${resolveWeblinks}
+          ></gr-change-status>`
+        )}
+      </div>
+      ${this.renderCopyLinksDropdown()}
+      <gr-button
+        flatten
+        down-arrow
+        class="showCopyLinkDialogButton"
+        @click=${(e: MouseEvent) => {
+          // We don't want to handle clicks on the star or the <a> link.
+          // Calling `stopPropagation()` from the click handler of <a> is not an
+          // option, because then the click does not reach the top-level page.js
+          // click handler and would result is a full page reload.
+          if ((e.target as HTMLElement)?.nodeName !== 'GR-BUTTON') return;
+          this.copyLinksDropdown?.toggleDropdown();
+        }}
+        ><gr-change-star
+          id="changeStar"
+          .change=${this.change}
+          @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) =>
+            this.handleToggleStar(e)}
+          ?hidden=${!this.loggedIn}
+        ></gr-change-star>
+        <a
+          class="changeNumber"
+          aria-label=${`Change ${this.change?._number}`}
+          href=${ifDefined(this.computeChangeUrl(true))}
+          >${this.change?._number}</a
+        >
+      </gr-button>
+      <span class="headerSubject">${this.change?.subject}</span>
+      <gr-copy-clipboard
+        class="changeCopyClipboard"
+        hideInput=""
+        text=${this.computeCopyTextForTitle()}
+      >
+      </gr-copy-clipboard>
+    </div>`;
+  }
+
+  private renderCopyLinksDropdown() {
+    const url = this.computeChangeUrl();
+    if (!url) return;
+    const changeURL = prependOrigin(getBaseUrl() + url);
+    const links: CopyLink[] = [
+      {
+        label: 'Change Number',
+        shortcut: 'n',
+        value: `${this.change?._number}`,
+      },
+      {
+        label: 'Change URL',
+        shortcut: 'u',
+        value: changeURL,
+      },
+      {
+        label: 'Title and URL',
+        shortcut: 't',
+        value: `${this.change?._number}: ${this.change?.subject} | ${changeURL}`,
+      },
+      {
+        label: 'URL and title',
+        shortcut: 'r',
+        value: `${changeURL}: ${this.change?.subject}`,
+      },
+      {
+        label: 'Markdown',
+        shortcut: 'm',
+        value: `[${this.change?.subject}](${changeURL})`,
+      },
+      {
+        label: 'Change-Id',
+        shortcut: 'd',
+        value: `${this.change?.id.split('~').pop()}`,
+      },
+    ];
+    if (
+      this.change?.status === ChangeStatus.MERGED &&
+      this.change?.current_revision
+    ) {
+      links.push({
+        label: 'SHA',
+        shortcut: 's',
+        value: this.change.current_revision,
+      });
+    }
+    return html`<gr-copy-links .copyLinks=${links}> </gr-copy-links>`;
+  }
+
+  private renderCommitActions() {
+    return html` <div class="commitActions">
+      <!-- always show gr-change-actions regardless if logged in or not -->
+      <gr-change-actions
+        id="actions"
+        .change=${this.change}
+        .disableEdit=${false}
+        .hasParent=${this.hasParent}
+        .account=${this.account}
+        .changeNum=${this.changeNum}
+        .changeStatus=${this.change?.status}
+        .commitNum=${this.commitInfo?.commit}
+        .latestPatchNum=${computeLatestPatchNum(this.allPatchSets)}
+        .commitMessage=${this.latestCommitMessage}
+        .editPatchsetLoaded=${this.patchRange
+          ? hasEditPatchsetLoaded(this.patchRange)
+          : false}
+        .editMode=${this.getEditMode()}
+        .editBasedOnCurrentPatchSet=${hasEditBasedOnCurrentPatchSet(
+          this.allPatchSets ?? []
+        )}
+        .privateByDefault=${this.projectConfig?.private_by_default}
+        .loggedIn=${this.loggedIn}
+        @edit-tap=${() => this.handleEditTap()}
+        @stop-edit-tap=${() => this.handleStopEditTap()}
+        @download-tap=${() => this.handleOpenDownloadDialog()}
+        @included-tap=${() => this.handleOpenIncludedInDialog()}
+        @revision-actions-changed=${this.handleRevisionActionsChanged}
+      ></gr-change-actions>
+    </div>`;
+  }
+
+  private renderChangeInfo() {
+    const hideEditCommitMessage = this.computeHideEditCommitMessage(
+      this.loggedIn,
+      this.editingCommitMessage,
+      this.change,
+      this.getEditMode()
+    );
+    return html` <div class="changeInfo">
+      <div class="changeInfo-column changeMetadata">
+        <gr-change-metadata
+          id="metadata"
+          .change=${this.change}
+          .revertedChange=${this.revertedChange}
+          .account=${this.account}
+          .revision=${this.selectedRevision}
+          .commitInfo=${this.commitInfo}
+          .serverConfig=${this.serverConfig}
+          .parentIsCurrent=${this.isParentCurrent()}
+          .repoConfig=${this.projectConfig}
+          @show-reply-dialog=${this.handleShowReplyDialog}
+        >
+        </gr-change-metadata>
+      </div>
+      <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
+        <div id="commitAndRelated">
+          <div class="commitContainer">
+            <h3 class="assistive-tech-only">Commit Message</h3>
+            <div>
+              <gr-button
+                id="replyBtn"
+                class="reply"
+                title=${this.createTitle(
+                  Shortcut.OPEN_REPLY_DIALOG,
+                  ShortcutSection.ACTIONS
+                )}
+                ?hidden=${!this.loggedIn}
+                primary=""
+                .disabled=${this.replyDisabled}
+                @click=${this.handleReplyTap}
+                >${this.computeReplyButtonLabel()}</gr-button
+              >
+            </div>
+            <div id="commitMessage" class="commitMessage">
+              <gr-editable-content
+                id="commitMessageEditor"
+                .editing=${this.editingCommitMessage}
+                .content=${this.latestCommitMessage}
+                @editing-changed=${this.handleEditingChanged}
+                @content-changed=${this.handleContentChanged}
+                .storageKey=${`c${this.change?._number}_rev${this.change?.current_revision}`}
+                .hideEditCommitMessage=${hideEditCommitMessage}
+                .commitCollapsible=${this.computeCommitCollapsible()}
+                remove-zero-width-space=""
+              >
+                <gr-formatted-text
+                  .content=${this.latestCommitMessage ?? ''}
+                  .markdown=${false}
+                ></gr-formatted-text>
+              </gr-editable-content>
+            </div>
+            <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
+            <gr-change-summary></gr-change-summary>
+            <gr-endpoint-decorator name="commit-container">
+              <gr-endpoint-param name="change" .value=${this.change}>
+              </gr-endpoint-param>
+              <gr-endpoint-param
+                name="revision"
+                .value=${this.selectedRevision}
+              >
+              </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+          <div class="relatedChanges">
+            <gr-related-changes-list
+              id="relatedChanges"
+              .change=${this.change}
+              .mergeable=${this.mergeable}
+              .patchNum=${computeLatestPatchNum(this.allPatchSets)}
+            ></gr-related-changes-list>
+          </div>
+          <div class="emptySpace"></div>
+        </div>
+      </div>
+    </div>`;
+  }
+
+  private renderTabHeaders() {
+    return html`
+      <paper-tabs
+        id="tabs"
+        @selected-changed=${this.onPaperTabSelectionChanged}
+      >
+        <paper-tab @click=${this.onPaperTabClick} data-name=${Tab.FILES}
+          ><span>Files</span></paper-tab
+        >
+        <paper-tab
+          @click=${this.onPaperTabClick}
+          data-name=${Tab.COMMENT_THREADS}
+          class="commentThreads"
+        >
+          <gr-tooltip-content
+            has-tooltip
+            title=${ifDefined(this.computeTotalCommentCounts())}
+          >
+            <span>Comments</span></gr-tooltip-content
+          >
+        </paper-tab>
+        ${when(
+          this.showChecksTab,
+          () => html`
+            <paper-tab data-name=${Tab.CHECKS} @click=${this.onPaperTabClick}
+              ><span>Checks</span></paper-tab
+            >
+          `
+        )}
+        ${this.pluginTabsHeaderEndpoints.map(
+          tabHeader => html`
+            <paper-tab data-name=${tabHeader}>
+              <gr-endpoint-decorator name=${tabHeader}>
+                <gr-endpoint-param name="change" .value=${this.change}>
+                </gr-endpoint-param>
+                <gr-endpoint-param
+                  name="revision"
+                  .value=${this.selectedRevision}
+                >
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </paper-tab>
+          `
+        )}
+        ${when(
+          this.showFindingsTab,
+          () => html`
+            <paper-tab data-name=${Tab.FINDINGS} @click=${this.onPaperTabClick}>
+              <span>Findings</span>
+            </paper-tab>
+          `
+        )}
+      </paper-tabs>
+    `;
+  }
+
+  private renderTabContent() {
+    return html`
+      <section class="tabContent">
+        ${this.renderFilesTab()} ${this.renderCommentsTab()}
+        ${this.renderChecksTab()} ${this.renderFindingsTab()}
+        ${this.renderPluginTab()}
+      </section>
+    `;
+  }
+
+  private renderFilesTab() {
+    return html`
+      <div ?hidden=${this.activeTab !== Tab.FILES}>
+        <gr-file-list-header
+          id="fileListHeader"
+          .account=${this.account}
+          .allPatchSets=${this.allPatchSets}
+          .change=${this.change}
+          .changeNum=${this.changeNum}
+          .commitInfo=${this.commitInfo}
+          .changeUrl=${this.computeChangeUrl()}
+          .editMode=${this.getEditMode()}
+          .loggedIn=${this.loggedIn}
+          .shownFileCount=${this.shownFileCount}
+          .patchNum=${this.patchRange?.patchNum}
+          .basePatchNum=${this.patchRange?.basePatchNum}
+          .filesExpanded=${this.fileList?.filesExpanded}
+          @open-diff-prefs=${this.handleOpenDiffPrefs}
+          @open-download-dialog=${this.handleOpenDownloadDialog}
+          @expand-diffs=${this.expandAllDiffs}
+          @collapse-diffs=${this.collapseAllDiffs}
+        >
+        </gr-file-list-header>
+        <gr-file-list
+          id="fileList"
+          .change=${this.change}
+          .changeNum=${this.changeNum}
+          .patchRange=${this.patchRange}
+          .editMode=${this.getEditMode()}
+          @files-shown-changed=${(e: CustomEvent<{length: number}>) => {
+            this.shownFileCount = e.detail.length;
+          }}
+          @files-expanded-changed=${(
+            _e: ValueChangedEvent<FilesExpandedState>
+          ) => {
+            this.requestUpdate();
+          }}
+          @file-action-tap=${this.handleFileActionTap}
+        >
+        </gr-file-list>
+      </div>
+    `;
+  }
+
+  private renderCommentsTab() {
+    if (this.activeTab !== Tab.COMMENT_THREADS) return nothing;
+    return html`
+      <h3 class="assistive-tech-only">Comments</h3>
+      <gr-thread-list
+        .threads=${this.commentThreads}
+        .commentTabState=${this.tabState}
+        only-show-robot-comments-with-human-reply
+        .unresolvedOnly=${this.unresolvedOnly}
+        .scrollCommentId=${this.scrollCommentId}
+        show-comment-context
+      ></gr-thread-list>
+    `;
+  }
+
+  private renderChecksTab() {
+    if (this.activeTab !== Tab.CHECKS) return nothing;
+    return html`
+      <h3 class="assistive-tech-only">Checks</h3>
+      <gr-checks-tab id="checksTab" .tabState=${this.tabState}></gr-checks-tab>
+    `;
+  }
+
+  private renderFindingsTab() {
+    if (this.activeTab !== Tab.FINDINGS) return nothing;
+    if (!this.showFindingsTab) return nothing;
+    const robotCommentThreads = this.computeRobotCommentThreads();
+    const robotCommentsPatchSetDropdownItems =
+      this.computeRobotCommentsPatchSetDropdownItems();
+    return html`
+      <gr-dropdown-list
+        class="patch-set-dropdown"
+        .items=${robotCommentsPatchSetDropdownItems}
+        .value=${this.currentRobotCommentsPatchSet}
+        @value-change=${this.handleRobotCommentPatchSetChanged}
+      >
+      </gr-dropdown-list>
+      <gr-thread-list .threads=${robotCommentThreads} hide-dropdown>
+      </gr-thread-list>
+      ${when(
+        this.showRobotCommentsButton,
+        () => html`
+          <gr-button
+            class="show-robot-comments"
+            @click=${this.toggleShowRobotComments}
+          >
+            ${this.showAllRobotComments ? 'Show Less' : 'Show more'}
+          </gr-button>
+        `
+      )}
+    `;
+  }
+
+  private renderPluginTab() {
+    const i = this.pluginTabsHeaderEndpoints.findIndex(
+      t => this.activeTab === t
+    );
+    if (i === -1) return nothing;
+    const pluginTabContentEndpoint = this.pluginTabsContentEndpoints[i];
+    return html`
+      <gr-endpoint-decorator .name=${pluginTabContentEndpoint}>
+        <gr-endpoint-param name="change" .value=${this.change}>
+        </gr-endpoint-param>
+        <gr-endpoint-param name="revision" .value=${this.selectedRevision}></gr-endpoint-param>
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderChangeLog() {
+    return html`
+      <gr-endpoint-decorator name="change-view-integration">
+        <gr-endpoint-param name="change" .value=${this.change}>
+        </gr-endpoint-param>
+        <gr-endpoint-param name="revision" .value=${this.selectedRevision}>
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
+
+      <paper-tabs>
+        <paper-tab data-name="_changeLog" class="changeLog">
+          Change Log
+        </paper-tab>
+      </paper-tabs>
+      <section class="changeLog">
+        <h2 class="assistive-tech-only">Change Log</h2>
+        <gr-messages-list
+          .labels=${this.change?.labels}
+          .messages=${this.change?.messages}
+          .reviewerUpdates=${this.change?.reviewer_updates}
+          @message-anchor-tap=${this.handleMessageAnchorTap}
+          @reply=${this.handleMessageReply}
+        ></gr-messages-list>
+      </section>
+    `;
   }
 
   private readonly handleScroll = () => {
@@ -758,113 +1670,68 @@
     );
   };
 
-  _onOpenFixPreview(e: OpenFixPreviewEvent) {
-    this.$.applyFixDialog.open(e);
+  private onOpenFixPreview(e: OpenFixPreviewEvent) {
+    assertIsDefined(this.applyFixDialog);
+    this.applyFixDialog.open(e);
   }
 
-  _onCloseFixPreview(e: CloseFixPreviewEvent) {
+  private onCloseFixPreview(e: CloseFixPreviewEvent) {
     if (e.detail.fixApplied) fireReload(this);
   }
 
-  _handleToggleDiffMode() {
+  // Private but used in tests.
+  handleToggleDiffMode() {
     if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+      this.getUserModel().updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.userService.updatePreferences({
+      this.getUserModel().updatePreferences({
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       });
     }
   }
 
-  _isTabActive(tab: string, activeTabs: string[]) {
-    return activeTabs.includes(tab);
-  }
+  onPaperTabSelectionChanged(e: ValueChangedEvent) {
+    if (!this.tabs) return;
+    const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')];
+    if (!tabs) return;
 
-  /**
-   * Actual implementation of switching a tab
-   *
-   * @param paperTabs - the parent tabs container
-   */
-  _setActiveTab(
-    paperTabs: PaperTabsElement | null,
-    activeDetails: {
-      activeTabName?: string;
-      activeTabIndex?: number;
-      scrollIntoView?: boolean;
-    },
-    src?: string
-  ) {
-    if (!paperTabs) return;
-    const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
-    const tabs = paperTabs.querySelectorAll(
-      'paper-tab'
-    ) as NodeListOf<HTMLElement>;
-    let activeIndex = -1;
-    if (activeTabIndex !== undefined) {
-      activeIndex = activeTabIndex;
-    } else {
-      for (let i = 0; i <= tabs.length; i++) {
-        const tab = tabs[i];
-        if (tab.dataset['name'] === activeTabName) {
-          activeIndex = i;
-          break;
-        }
-      }
-    }
-    if (activeIndex === -1) {
-      this.reporting.error(new Error(`tab not found for ${activeDetails}`));
-      return;
-    }
-    const tabName = tabs[activeIndex].dataset['name'];
-    if (scrollIntoView) {
-      paperTabs.scrollIntoView({block: 'center'});
-    }
-    if (paperTabs.selected !== activeIndex) {
-      // paperTabs.selected is undefined during rendering
-      if (paperTabs.selected !== undefined) {
-        this.reporting.reportInteraction(Interaction.SHOW_TAB, {tabName, src});
-      }
-      paperTabs.selected = activeIndex;
-    }
-    return tabName;
-  }
-
-  /**
-   * Changes active primary tab.
-   */
-  _setActivePrimaryTab(e: SwitchTabEvent) {
-    const primaryTabs =
-      this.shadowRoot!.querySelector<PaperTabsElement>('#primaryTabs');
-    const activeTabName = this._setActiveTab(
-      primaryTabs,
-      {
-        activeTabName: e.detail.tab,
-        activeTabIndex: e.detail.value,
-        scrollIntoView: e.detail.scrollIntoView,
-      },
-      (e.composedPath()?.[0] as Element | undefined)?.tagName
+    const tabIndex = Number(e.detail.value);
+    assert(
+      Number.isInteger(tabIndex) && 0 <= tabIndex && tabIndex < tabs.length,
+      `${tabIndex} must be integer`
     );
-    if (activeTabName) {
-      this._activeTabs = [activeTabName, this._activeTabs[1]];
+    const tab = tabs[tabIndex].dataset['name'];
 
-      // update plugin endpoint if its a plugin tab
-      const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
-        activeTabName
-      );
-      if (pluginIndex !== -1) {
-        this._selectedTabPluginEndpoint =
-          this._dynamicTabContentEndpoints[pluginIndex];
-        this._selectedTabPluginHeader =
-          this._dynamicTabHeaderEndpoints[pluginIndex];
-      } else {
-        this._selectedTabPluginEndpoint = '';
-        this._selectedTabPluginHeader = '';
-      }
-    }
-    this._tabState = e.detail.tabState;
+    this.getViewModel().updateState({tab});
   }
 
-  _onPaperTabClick(e: MouseEvent) {
+  setActiveTab(e: SwitchTabEvent) {
+    if (!this.tabs) return;
+    const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')];
+    if (!tabs) return;
+
+    const tab = e.detail.tab;
+    const tabIndex = tabs.findIndex(t => t.dataset['name'] === tab);
+    assert(tabIndex !== -1, `tab ${tab} not found`);
+
+    if (this.tabs.selected !== tabIndex) {
+      this.tabs.selected = tabIndex;
+    }
+
+    this.getViewModel().updateState({tab});
+
+    if (e.detail.tabState) this.tabState = e.detail.tabState;
+    if (e.detail.scrollIntoView) this.tabs.scrollIntoView({block: 'center'});
+  }
+
+  /**
+   * Currently there is a bug in this code where this.unresolvedOnly is only
+   * assigned the correct value when onPaperTabClick is triggered which is
+   * only triggered when user explicitly clicks on the tab however the comments
+   * tab can also be opened via the url in which case the correct value to
+   * unresolvedOnly is never assigned.
+   */
+  private onPaperTabClick(e: MouseEvent) {
     let target = e.target as HTMLElement | null;
     let tabName: string | undefined;
     // target can be slot child of papertab, so we search for tabName in parents
@@ -874,10 +1741,11 @@
       target = target?.parentElement as HTMLElement | null;
     } while (target);
 
-    if (tabName === PrimaryTab.COMMENT_THREADS) {
-      // Show unresolved threads by default only if they are present
+    if (tabName === Tab.COMMENT_THREADS) {
+      // Show unresolved threads by default
+      // Show resolved threads only if no unresolved threads exist
       const hasUnresolvedThreads =
-        (this._commentThreads ?? []).filter(thread => isUnresolved(thread))
+        (this.commentThreads ?? []).filter(thread => isUnresolved(thread))
           .length > 0;
       if (!hasUnresolvedThreads) this.unresolvedOnly = false;
     }
@@ -888,67 +1756,79 @@
     });
   }
 
-  _handleCommitMessageSave(e: EditableContentSaveEvent) {
-    assertIsDefined(this._change, '_change');
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
+  private handleEditingChanged(e: ValueChangedEvent<boolean>) {
+    this.editingCommitMessage = e.detail.value;
+  }
+
+  private handleContentChanged(e: ValueChangedEvent) {
+    this.latestCommitMessage = e.detail.value;
+  }
+
+  // Private but used in tests.
+  handleCommitMessageSave(e: EditableContentSaveEvent) {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.changeNum, 'changeNum');
+    // to prevent 2 requests at the same time
+    if (!this.commitMessageEditor || this.commitMessageEditor.disabled) return;
     // Trim trailing whitespace from each line.
     const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
 
-    this.jsAPI.handleCommitMessage(this._change, message);
+    this.getPluginLoader().jsApiService.handleCommitMessage(
+      this.change,
+      message
+    );
 
-    this.$.commitMessageEditor.disabled = true;
+    this.commitMessageEditor.disabled = true;
     this.restApiService
-      .putChangeCommitMessage(this._changeNum, message)
+      .putChangeCommitMessage(this.changeNum, message)
       .then(resp => {
-        this.$.commitMessageEditor.disabled = false;
+        assertIsDefined(this.commitMessageEditor);
+        this.commitMessageEditor.disabled = false;
         if (!resp.ok) {
           return;
         }
 
-        this._latestCommitMessage = this._prepareCommitMsgForLinkify(message);
-        this._editingCommitMessage = false;
-        this._reloadWindow();
+        this.latestCommitMessage = this.prepareCommitMsgForLinkify(message);
+        this.editingCommitMessage = false;
+        this.reloadWindow();
       })
       .catch(() => {
-        this.$.commitMessageEditor.disabled = false;
+        assertIsDefined(this.commitMessageEditor);
+        this.commitMessageEditor.disabled = false;
       });
   }
 
-  _reloadWindow() {
+  private reloadWindow() {
     windowLocationReload();
   }
 
-  _handleCommitMessageCancel() {
-    this._editingCommitMessage = false;
+  private handleCommitMessageCancel() {
+    this.editingCommitMessage = false;
   }
 
-  _computeChangeStatusChips(
-    change: ChangeInfo | undefined,
-    mergeable: boolean | null,
-    submitEnabled?: boolean
-  ) {
-    if (!change) {
-      return undefined;
+  private computeChangeStatusChips() {
+    if (!this.change) {
+      return [];
     }
 
     // Show no chips until mergeability is loaded.
-    if (mergeable === null) {
+    if (this.mergeable === null) {
       return [];
     }
 
     const options = {
       includeDerived: true,
-      mergeable: !!mergeable,
-      submitEnabled: !!submitEnabled,
+      mergeable: !!this.mergeable,
+      submitEnabled: !!this.isSubmitEnabled(),
     };
-    return changeStatuses(change, options);
+    return changeStatuses(this.change as ChangeInfo, options);
   }
 
-  _computeHideEditCommitMessage(
+  // Private but used in tests.
+  computeHideEditCommitMessage(
     loggedIn: boolean,
     editing: boolean,
-    change: ChangeInfo,
+    change?: ParsedChangeInfo,
     editMode?: boolean
   ) {
     if (
@@ -963,7 +1843,8 @@
     return false;
   }
 
-  _robotCommentCountPerPatchSet(threads: CommentThread[]) {
+  // Private but used in tests.
+  robotCommentCountPerPatchSet(threads: CommentThread[]) {
     return threads.reduce((robotCommentCountMap, thread) => {
       const comments = thread.comments;
       const robotCommentsCount = comments.reduce(
@@ -978,83 +1859,63 @@
     }, {} as {[patchset: string]: number});
   }
 
-  /**
-   * Returns `this` as the visibility observer target for the keyboard shortcut
-   * mixin to decide whether shortcuts should be enabled or not.
-   */
-  _computeObserverTarget() {
-    return this;
-  }
-
-  _computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
-    const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
+  // Private but used in tests.
+  computeText(
+    patch: RevisionInfo | EditRevisionInfo,
+    commentThreads: CommentThread[]
+  ) {
+    const commentCount = this.robotCommentCountPerPatchSet(commentThreads);
     const commentCnt = commentCount[patch._number] || 0;
     if (commentCnt === 0) return `Patchset ${patch._number}`;
     return `Patchset ${patch._number} (${pluralize(commentCnt, 'finding')})`;
   }
 
-  _computeRobotCommentsPatchSetDropdownItems(
-    change: ChangeInfo,
-    commentThreads: CommentThread[]
-  ) {
-    if (!change || !commentThreads || !change.revisions) return [];
+  private computeRobotCommentsPatchSetDropdownItems() {
+    if (!this.change || !this.commentThreads || !this.change.revisions)
+      return [];
 
-    return Object.values(change.revisions)
-      .filter(patch => patch._number !== 'edit')
+    return Object.values(this.change.revisions)
+      .filter(patch => patch._number !== EDIT)
       .map(patch => {
         return {
-          text: this._computeText(patch, commentThreads),
+          text: this.computeText(patch, this.commentThreads!),
           value: patch._number,
         };
       })
       .sort((a, b) => (b.value as number) - (a.value as number));
   }
 
-  _handleCurrentRevisionUpdate(currentRevision: RevisionInfo) {
-    this._currentRobotCommentsPatchSet = currentRevision._number;
-  }
-
-  _handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
+  private handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
     const patchSet = Number(e.detail.value) as PatchSetNum;
-    if (patchSet === this._currentRobotCommentsPatchSet) return;
-    this._currentRobotCommentsPatchSet = patchSet;
+    if (patchSet === this.currentRobotCommentsPatchSet) return;
+    this.currentRobotCommentsPatchSet = patchSet;
   }
 
-  _computeShowText(showAllRobotComments: boolean) {
-    return showAllRobotComments ? 'Show Less' : 'Show more';
+  private toggleShowRobotComments() {
+    this.showAllRobotComments = !this.showAllRobotComments;
   }
 
-  _toggleShowRobotComments() {
-    this._showAllRobotComments = !this._showAllRobotComments;
-  }
-
-  _computeRobotCommentThreads(
-    commentThreads: CommentThread[],
-    currentRobotCommentsPatchSet: PatchSetNum,
-    showAllRobotComments: boolean
-  ) {
-    if (!commentThreads || !currentRobotCommentsPatchSet) return [];
-    const threads = commentThreads.filter(thread => {
+  // Private but used in tests.
+  computeRobotCommentThreads() {
+    if (!this.commentThreads || !this.currentRobotCommentsPatchSet) return [];
+    const threads = this.commentThreads.filter(thread => {
       const comments = thread.comments || [];
       return (
         comments.length &&
         isRobot(comments[0]) &&
-        comments[0].patch_set === currentRobotCommentsPatchSet
+        comments[0].patch_set === this.currentRobotCommentsPatchSet
       );
     });
-    this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
+    this.showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
     return threads.slice(
       0,
-      showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT
+      this.showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT
     );
   }
 
-  _computeTotalCommentCounts(
-    unresolvedCount: number,
-    changeComments: ChangeComments
-  ) {
-    if (!changeComments) return undefined;
-    const draftCount = changeComments.computeDraftCount();
+  private computeTotalCommentCounts() {
+    const unresolvedCount = this.change?.unresolved_comment_count ?? 0;
+    const draftCount = this.draftCount;
     const unresolvedString =
       unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
     const draftString = pluralize(draftCount, 'draft');
@@ -1067,64 +1928,76 @@
     );
   }
 
-  _handleReplyTap(e: MouseEvent) {
+  private handleReplyTap(e: MouseEvent) {
     e.preventDefault();
-    this._openReplyDialog(FocusTarget.ANY);
+    this.openReplyDialog(FocusTarget.ANY);
   }
 
-  onReplyOverlayCanceled() {
+  private onReplyModalCanceled() {
     fireDialogChange(this, {canceled: true});
-    this._changeViewAriaHidden = false;
+    this.changeViewAriaHidden = false;
+    this.replyModalOpened = false;
   }
 
-  _handleOpenDiffPrefs() {
-    this.$.fileList.openDiffPrefs();
+  private handleOpenDiffPrefs() {
+    assertIsDefined(this.fileList);
+    this.fileList.openDiffPrefs();
   }
 
-  _handleOpenIncludedInDialog() {
-    this.$.includedInDialog.loadData().then(() => {
-      flush();
-      this.$.includedInOverlay.refit();
-    });
-    this.$.includedInOverlay.open();
+  private handleOpenIncludedInDialog() {
+    assertIsDefined(this.includedInDialog);
+    assertIsDefined(this.includedInModal);
+    this.includedInDialog.loadData();
+    this.includedInModal.showModal();
   }
 
-  _handleIncludedInDialogClose() {
-    this.$.includedInOverlay.close();
+  private handleIncludedInDialogClose() {
+    assertIsDefined(this.includedInModal);
+    this.includedInModal.close();
   }
 
-  _handleOpenDownloadDialog() {
-    this.$.downloadOverlay.open().then(() => {
-      this.$.downloadOverlay.setFocusStops(
-        this.$.downloadDialog.getFocusStops()
+  // Private but used in tests
+  handleOpenDownloadDialog() {
+    assertIsDefined(this.downloadModal);
+    this.downloadModal.showModal();
+    whenVisible(this.downloadModal, () => {
+      assertIsDefined(this.downloadModal);
+      assertIsDefined(this.downloadDialog);
+      this.downloadDialog.focus();
+      const downloadCommands = queryAndAssert(
+        this.downloadDialog,
+        'gr-download-commands'
       );
-      this.$.downloadDialog.focus();
+      const paperTabs = queryAndAssert<PaperTabsElement>(
+        downloadCommands,
+        'paper-tabs'
+      );
+      // Paper Tabs normally listen to 'iron-resize' event to call this method.
+      // After migrating to Dialog element, this event is no longer fired
+      // which means this method is not called which ends up styling the
+      // selected paper tab with an underline.
+      paperTabs._onTabSizingChanged();
     });
   }
 
-  _handleDownloadDialogClose() {
-    this.$.downloadOverlay.close();
+  private handleDownloadDialogClose() {
+    assertIsDefined(this.downloadModal);
+    this.downloadModal.close();
   }
 
-  _handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
+  // Private but used in tests.
+  handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
     const msg: string = e.detail.message.message;
     const quoteStr =
       msg
         .split('\n')
         .map(line => '> ' + line)
         .join('\n') + '\n\n';
-    this._openReplyDialog(FocusTarget.BODY, quoteStr);
+    this.openReplyDialog(FocusTarget.BODY, quoteStr);
   }
 
-  _handleHideBackgroundContent() {
-    this.$.mainContent.classList.add('overlayOpen');
-  }
-
-  _handleShowBackgroundContent() {
-    this.$.mainContent.classList.remove('overlayOpen');
-  }
-
-  _handleReplySent() {
+  // Private but used in tests.
+  handleReplySent() {
     this.addEventListener(
       'change-details-loaded',
       () => {
@@ -1132,41 +2005,34 @@
       },
       {once: true}
     );
-    this.$.replyOverlay.cancel();
+    assertIsDefined(this.replyModal);
+    this.replyModal.close();
     fireReload(this);
   }
 
-  _handleReplyCancel() {
-    this.$.replyOverlay.cancel();
+  private handleReplyCancel() {
+    assertIsDefined(this.replyModal);
+    this.replyModal.close();
+    this.onReplyModalCanceled();
   }
 
-  _handleReplyAutogrow() {
-    // If the textarea resizes, we need to re-fit the overlay.
-    this.replyRefitTask = debounce(
-      this.replyRefitTask,
-      () => this.$.replyOverlay.refit(),
-      REPLY_REFIT_DEBOUNCE_INTERVAL_MS
-    );
-  }
-
-  _handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
+  // Private but used in tests.
+  handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
     let target = FocusTarget.REVIEWERS;
     if (e.detail.value && e.detail.value.ccsOnly) {
       target = FocusTarget.CCS;
     }
-    this._openReplyDialog(target);
+    this.openReplyDialog(target);
   }
 
-  _setShownFiles(e: CustomEvent<{length: number}>) {
-    this._shownFileCount = e.detail.length;
+  private expandAllDiffs() {
+    assertIsDefined(this.fileList);
+    this.fileList.expandAllDiffs();
   }
 
-  _expandAllDiffs() {
-    this.$.fileList.expandAllDiffs();
-  }
-
-  _collapseAllDiffs() {
-    this.$.fileList.collapseAllDiffs();
+  private collapseAllDiffs() {
+    assertIsDefined(this.fileList);
+    this.fileList.collapseAllDiffs();
   }
 
   /**
@@ -1179,44 +2045,41 @@
    * anymore. The app element makes sure that an obsolete change view is not
    * shown anymore, so if the change view is still and doing some update to
    * itself, then that is not dangerous. But for example it should not call
-   * navigateToChange() anymore. That would very likely cause erroneous
-   * behavior.
+   * the navigation service's set/replaceUrl() methods anymore. That would very
+   * likely cause erroneous behavior.
    */
   private isChangeObsolete() {
-    // While this._changeNum is undefined the change view is fresh and has just
-    // not updated it to params.changeNum yet. Not obsolete in that case.
-    if (this._changeNum === undefined) return false;
-    // this.params reflects the current state of the URL. If this._changeNum
+    // While this.changeNum is undefined the change view is fresh and has just
+    // not updated it to viewState.changeNum yet. Not obsolete in that case.
+    if (this.changeNum === undefined) return false;
+    // this.viewState reflects the current state of the URL. If this.changeNum
     // does not match it anymore, then this view must be considered obsolete.
-    return this._changeNum !== this.params?.changeNum;
+    return this.changeNum !== this.viewState?.changeNum;
   }
 
-  hasPatchRangeChanged(value: AppElementChangeViewParams) {
-    if (!this._patchRange) return false;
-    if (this._patchRange.basePatchNum !== value.basePatchNum) return true;
-    return this.hasPatchNumChanged(value);
+  // Private but used in tests.
+  hasPatchRangeChanged(viewState: ChangeViewState) {
+    if (!this.patchRange) return false;
+    if (this.patchRange.basePatchNum !== viewState.basePatchNum) return true;
+    return this.hasPatchNumChanged(viewState);
   }
 
-  hasPatchNumChanged(value: AppElementChangeViewParams) {
-    if (!this._patchRange) return false;
-    if (value.patchNum !== undefined) {
-      return this._patchRange.patchNum !== value.patchNum;
+  // Private but used in tests.
+  hasPatchNumChanged(viewState: ChangeViewState) {
+    if (!this.patchRange) return false;
+    if (viewState.patchNum !== undefined) {
+      return this.patchRange.patchNum !== viewState.patchNum;
     } else {
       // value.patchNum === undefined specifies the latest patchset
       return (
-        this._patchRange.patchNum !== computeLatestPatchNum(this._allPatchSets)
+        this.patchRange.patchNum !== computeLatestPatchNum(this.allPatchSets)
       );
     }
   }
 
-  _paramsChanged(value: AppElementChangeViewParams) {
-    if (value.view !== GerritView.CHANGE) {
-      this._initialLoadComplete = false;
-      querySelectorAll(this, 'gr-overlay').forEach(overlay =>
-        (overlay as GrOverlay).close()
-      );
-      return;
-    }
+  // Private but used in tests.
+  viewStateChanged() {
+    if (!this.viewState) return;
 
     if (this.isChangeObsolete()) {
       // Tell the app element that we are not going to handle the new change
@@ -1225,46 +2088,41 @@
       return;
     }
 
-    if (value.changeNum && value.project) {
-      this.restApiService.setInProjectLookup(value.changeNum, value.project);
-    }
+    if (this.viewState.basePatchNum === undefined)
+      this.viewState.basePatchNum = PARENT;
 
-    if (value.basePatchNum === undefined)
-      value.basePatchNum = ParentPatchSetNum;
+    const patchChanged = this.hasPatchRangeChanged(this.viewState);
 
-    const patchChanged = this.hasPatchRangeChanged(value);
-    let patchNumChanged = this.hasPatchNumChanged(value);
-
-    this._patchRange = {
-      patchNum: value.patchNum,
-      basePatchNum: value.basePatchNum,
+    this.patchRange = {
+      patchNum: this.viewState.patchNum,
+      basePatchNum: this.viewState.basePatchNum,
     };
-    this.scrollCommentId = value.commentId;
+    this.scrollCommentId = this.viewState.commentId;
 
     const patchKnown =
-      !this._patchRange.patchNum ||
-      (this._allPatchSets ?? []).some(
-        ps => ps.num === this._patchRange!.patchNum
+      !this.patchRange.patchNum ||
+      (this.allPatchSets ?? []).some(
+        ps => ps.num === this.patchRange!.patchNum
       );
     // _allPatchsets does not know value.patchNum so force a reload.
-    const forceReload = value.forceReload || !patchKnown;
+    const forceReload = this.viewState.forceReload || !patchKnown;
 
     // If changeNum is defined that means the change has already been
     // rendered once before so a full reload is not required.
-    if (this._changeNum !== undefined && !forceReload) {
-      if (!this._patchRange.patchNum) {
-        this._patchRange = {
-          ...this._patchRange,
-          patchNum: computeLatestPatchNum(this._allPatchSets),
+    if (this.changeNum !== undefined && !forceReload) {
+      if (!this.patchRange.patchNum) {
+        this.patchRange = {
+          ...this.patchRange,
+          patchNum: computeLatestPatchNum(this.allPatchSets),
         };
-        patchNumChanged = true;
       }
       if (patchChanged) {
-        // We need to collapse all diffs when params change so that a non
+        // We need to collapse all diffs when viewState changes so that a non
         // existing diff is not requested. See Issue 125270 for more details.
-        this.$.fileList.collapseAllDiffs();
-        this._reloadPatchNumDependentResources(patchNumChanged).then(() => {
-          this._sendShowChangeEvent();
+        this.fileList?.resetFileState();
+        this.fileList?.collapseAllDiffs();
+        this.reloadPatchNumDependentResources().then(() => {
+          this.sendShowChangeEvent();
         });
       }
 
@@ -1272,128 +2130,109 @@
       // to the diff view and then comes back to change page then there is no
       // need to reload anything and we render the change view component as is.
       document.documentElement.scrollTop = this.scrollPosition ?? 0;
+      this.reporting.reportInteraction('change-view-re-rendered');
+      this.updateTitle(this.change);
+      // We still need to check if post load tasks need to be done such as when
+      // user wants to open the reply dialog when in the diff page, the change
+      // page should open the reply dialog
+      this.performPostLoadTasks();
       return;
     }
 
-    // We need to collapse all diffs when params change so that a non existing
-    // diff is not requested. See Issue 125270 for more details.
-    this.$.fileList.collapseAllDiffs();
-
-    this._initialLoadComplete = false;
-    this._changeNum = value.changeNum;
-    this.loadData(true).then(() => {
-      this._performPostLoadTasks();
+    // We need to collapse all diffs when viewState changes so that a non
+    // existing diff is not requested. See Issue 125270 for more details.
+    this.updateComplete.then(() => {
+      assertIsDefined(this.fileList);
+      this.fileList?.collapseAllDiffs();
+      this.fileList?.resetFileState();
     });
 
-    getPluginLoader()
+    // If the change was loaded before, then we are firing a 'reload' event
+    // instead of calling `loadData()` directly for two reasons:
+    // 1. We want to avoid code such as `this.initialLoadComplete = false` that
+    //    is only relevant for the initial load of a change.
+    // 2. We have to somehow trigger the change-model reloading. Otherwise
+    //    this.change is not updated.
+    if (this.changeNum) {
+      fireReload(this);
+      return;
+    }
+
+    this.initialLoadComplete = false;
+    this.changeNum = this.viewState.changeNum;
+    this.loadData(true).then(() => {
+      this.performPostLoadTasks();
+    });
+
+    this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._initActiveTabs(value);
+        this.initActiveTab();
       });
   }
 
-  _initActiveTabs(params?: AppElementChangeViewParams) {
-    let primaryTab = PrimaryTab.FILES;
-    if (params?.tab) {
-      primaryTab = params?.tab as PrimaryTab;
-    } else if (params && 'commentId' in params) {
-      primaryTab = PrimaryTab.COMMENT_THREADS;
+  private initActiveTab() {
+    let tab = Tab.FILES;
+    if (this.viewState?.tab) {
+      tab = this.viewState?.tab as Tab;
+    } else if (this.viewState?.commentId) {
+      tab = Tab.COMMENT_THREADS;
     }
-    this._setActivePrimaryTab(
-      new CustomEvent('initActiveTab', {
-        detail: {
-          tab: primaryTab,
-        },
-      })
-    );
+    this.setActiveTab(new CustomEvent(EventType.SHOW_TAB, {detail: {tab}}));
   }
 
-  _sendShowChangeEvent() {
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    this.jsAPI.handleEvent(PluginEventType.SHOW_CHANGE, {
-      change: this._change,
-      patchNum: this._patchRange.patchNum,
-      info: {mergeable: this._mergeable},
+  // Private but used in tests.
+  sendShowChangeEvent() {
+    assertIsDefined(this.patchRange, 'patchRange');
+    this.getPluginLoader().jsApiService.handleShowChange({
+      change: this.change,
+      patchNum: this.patchRange.patchNum,
+      info: {mergeable: this.mergeable},
     });
   }
 
-  _performPostLoadTasks() {
-    this._maybeShowReplyDialog();
-    this._maybeShowRevertDialog();
-    this._maybeShowDownloadDialog();
+  private performPostLoadTasks() {
+    this.maybeShowReplyDialog();
+    this.maybeShowRevertDialog();
 
-    this._sendShowChangeEvent();
+    this.sendShowChangeEvent();
 
-    setTimeout(() => {
-      this._maybeScrollToMessage(window.location.hash);
-      this._initialLoadComplete = true;
+    this.updateComplete.then(() => {
+      this.maybeScrollToMessage(window.location.hash);
+      this.initialLoadComplete = true;
     });
   }
 
-  @observe('params', '_change')
-  _paramsAndChangeChanged(
-    value?: AppElementChangeViewParams,
-    change?: ChangeInfo
-  ) {
-    // Polymer 2: check for undefined
-    if (!value || !change) {
-      return;
-    }
-
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    // If the change number or patch range is different, then reset the
-    // selected file index.
-    const patchRangeState = this.viewState.patchRange;
-    if (
-      this.viewState.changeNum !== this._changeNum ||
-      !patchRangeState ||
-      patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
-      patchRangeState.patchNum !== this._patchRange.patchNum
-    ) {
-      this._resetFileListViewState();
-    }
-  }
-
-  _viewStateChanged(viewState: ChangeViewState) {
-    this._numFilesShown = viewState.numFilesShown
-      ? viewState.numFilesShown
-      : DEFAULT_NUM_FILES_SHOWN;
-  }
-
-  _numFilesShownChanged(numFilesShown: number) {
-    this.viewState.numFilesShown = numFilesShown;
-  }
-
-  _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
+  // Private but used in tests.
+  handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
     const hash = PREFIX + e.detail.id;
-    const url = GerritNav.getUrlForChange(
-      this._change,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum,
-      this._editMode,
-      hash
-    );
+    const url = createChangeUrl({
+      change: this.change,
+      patchNum: this.patchRange.patchNum,
+      basePatchNum: this.patchRange.basePatchNum,
+      edit: this.getEditMode(),
+      messageHash: hash,
+    });
     history.replaceState(null, '', url);
   }
 
-  _maybeScrollToMessage(hash: string) {
+  // Private but used in tests.
+  maybeScrollToMessage(hash: string) {
     if (hash.startsWith(PREFIX) && this.messagesList) {
       this.messagesList.scrollToMessage(hash.substr(PREFIX.length));
     }
   }
 
-  _getLocationSearch() {
+  // Private but used in tests.
+  getLocationSearch() {
     // Not inlining to make it easier to test.
     return window.location.search;
   }
 
   _getUrlParameter(param: string) {
-    const pageURL = this._getLocationSearch().substring(1);
+    const pageURL = this.getLocationSearch().substring(1);
     const vars = pageURL.split('&');
     for (let i = 0; i < vars.length; i++) {
       const name = vars[i].split('=');
@@ -1404,295 +2243,315 @@
     return null;
   }
 
-  _maybeShowRevertDialog() {
-    getPluginLoader()
+  // Private but used in tests.
+  maybeShowRevertDialog() {
+    this.getPluginLoader()
       .awaitPluginsLoaded()
-      .then(() => this._getLoggedIn())
-      .then(loggedIn => {
+      .then(() => {
         if (
-          !loggedIn ||
-          !this._change ||
-          this._change.status !== ChangeStatus.MERGED
+          !this.loggedIn ||
+          !this.change ||
+          this.change.status !== ChangeStatus.MERGED
         ) {
           // Do not display dialog if not logged-in or the change is not
           // merged.
           return;
         }
         if (this._getUrlParameter('revert')) {
-          this.$.actions.showRevertDialog();
+          assertIsDefined(this.actions);
+          this.actions.showRevertDialog();
         }
       });
   }
 
-  _maybeShowReplyDialog() {
-    this._getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        return;
-      }
-
-      if (this.viewState.showReplyDialog) {
-        this._openReplyDialog(FocusTarget.ANY);
-        this.set('viewState.showReplyDialog', false);
-      }
-    });
-  }
-
-  _maybeShowDownloadDialog() {
-    if (this.viewState.showDownloadDialog) {
-      this._handleOpenDownloadDialog();
-      this.set('viewState.showDownloadDialog', false);
+  private maybeShowReplyDialog() {
+    if (!this.loggedIn) return;
+    if (this.viewState?.openReplyDialog) {
+      this.openReplyDialog(FocusTarget.ANY);
     }
   }
 
-  _resetFileListViewState() {
-    this.set('viewState.selectedFileIndex', 0);
+  private updateTitle(change?: ChangeInfo | ParsedChangeInfo) {
+    if (!change) return;
+    const title = `${change.subject} (${change._number})`;
+    fireTitleChange(this, title);
+  }
+
+  // Private but used in tests.
+  changeChanged(oldChange: ParsedChangeInfo | undefined) {
+    this.allPatchSets = computeAllPatchSets(this.change);
+    if (!this.change) return;
+    this.labelsChanged(oldChange?.labels, this.change.labels);
     if (
-      !!this.viewState.changeNum &&
-      this.viewState.changeNum !== this._changeNum
+      this.change.current_revision &&
+      this.change.revisions &&
+      this.change.revisions[this.change.current_revision]
     ) {
-      this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
+      this.currentRobotCommentsPatchSet =
+        this.change.revisions[this.change.current_revision]._number;
     }
-    this.set('viewState.changeNum', this._changeNum);
-    this.set('viewState.patchRange', this._patchRange);
-  }
-
-  _changeChanged(change?: ChangeInfo | ParsedChangeInfo) {
-    if (!change || !this._patchRange || !this._allPatchSets) {
+    if (!this.change || !this.patchRange || !this.allPatchSets) {
       return;
     }
 
     // We get the parent first so we keep the original value for basePatchNum
     // and not the updated value.
-    const parent = this._getBasePatchNum(change, this._patchRange);
+    const parent = this.getBasePatchNum();
 
-    this.set(
-      '_patchRange.patchNum',
-      this._patchRange.patchNum || computeLatestPatchNum(this._allPatchSets)
-    );
-
-    this.set('_patchRange.basePatchNum', parent);
-
-    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-    fireTitleChange(this, title);
+    this.patchRange = {
+      ...this.patchRange,
+      basePatchNum: parent,
+      patchNum:
+        this.patchRange.patchNum || computeLatestPatchNum(this.allPatchSets),
+    };
+    this.updateTitle(this.change);
   }
 
   /**
    * Gets base patch number, if it is a parent try and decide from
    * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
+   * Private but used in tests.
    */
-  _getBasePatchNum(
-    change: ChangeInfo | ParsedChangeInfo,
-    patchRange: ChangeViewPatchRange
-  ) {
-    if (patchRange.basePatchNum && patchRange.basePatchNum !== 'PARENT') {
-      return patchRange.basePatchNum;
+  getBasePatchNum() {
+    if (
+      this.patchRange &&
+      this.patchRange.basePatchNum &&
+      this.patchRange.basePatchNum !== PARENT
+    ) {
+      return this.patchRange.basePatchNum;
     }
 
-    const revisionInfo = this._getRevisionInfo(change);
-    if (!revisionInfo) return 'PARENT';
+    const revisionInfo = this.getRevisionInfo();
+    if (!revisionInfo) return PARENT;
 
-    const parentCounts = revisionInfo.getParentCountMap();
-    // check that there is at least 2 parents otherwise fall back to 1,
-    // which means there is only one parent.
-    const parentCount = hasOwnProperty(parentCounts, 1) ? parentCounts[1] : 1;
-
+    // TODO: It is a bit unclear why `1` is used here instead of
+    // `patchRange.patchNum`. Maybe that is a bug? Maybe if one patchset
+    // is a merge commit, then all patchsets are merge commits??
+    const isMerge = revisionInfo.isMergeCommit(1 as PatchSetNumber);
     const preferFirst =
-      this._prefs &&
-      this._prefs.default_base_for_merges === DefaultBase.FIRST_PARENT;
+      this.prefs &&
+      this.prefs.default_base_for_merges === DefaultBase.FIRST_PARENT;
 
-    if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
-      return -1;
+    // Verified via reportExecution that -1 is returned(1-5 times per day)
+    // changeChanged does set this.patchRange?.patchNum so it's still unclear
+    // how it is undefined.
+    if (isMerge && preferFirst && !this.patchRange?.patchNum) {
+      return -1 as BasePatchSetNum;
     }
-
-    return 'PARENT';
+    return PARENT;
   }
 
-  _computeChangeUrl(change: ChangeInfo) {
-    return GerritNav.getUrlForChange(change);
+  private computeChangeUrl(forceReload?: boolean) {
+    if (!this.change) return undefined;
+    return createChangeUrl({
+      change: this.change,
+      forceReload: !!forceReload,
+    });
   }
 
-  _computeReplyButtonLabel(
-    drafts?: {[path: string]: UIDraft[]},
-    canStartReview?: boolean
-  ) {
-    if (drafts === undefined || canStartReview === undefined) {
+  // Private but used in tests.
+  computeReplyButtonLabel() {
+    if (this.diffDrafts === undefined) {
       return 'Reply';
     }
 
-    const draftCount = Object.keys(drafts).reduce(
-      (count, file) => count + drafts[file].length,
+    const draftCount = Object.keys(this.diffDrafts).reduce(
+      (count, file) => count + this.diffDrafts![file].length,
       0
     );
 
-    let label = canStartReview ? 'Start Review' : 'Reply';
+    let label = this.canStartReview() ? 'Start Review' : 'Reply';
     if (draftCount > 0) {
       label += ` (${draftCount})`;
     }
     return label;
   }
 
-  _handleOpenReplyDialog() {
-    this._getLoggedIn().then(isLoggedIn => {
-      if (!isLoggedIn) {
-        fireEvent(this, 'show-auth-required');
-        return;
-      }
-      this._openReplyDialog(FocusTarget.ANY);
-    });
+  private handleOpenReplyDialog() {
+    if (!this.loggedIn) {
+      fireEvent(this, 'show-auth-required');
+      return;
+    }
+    this.openReplyDialog(FocusTarget.ANY);
   }
 
-  _handleOpenSubmitDialog() {
-    if (!this._submitEnabled) return;
-    this.$.actions.showSubmitDialog();
+  private handleOpenSubmitDialog() {
+    if (!this.isSubmitEnabled()) return;
+    assertIsDefined(this.actions);
+    this.actions.showSubmitDialog();
   }
 
-  _handleToggleAttentionSet() {
-    if (!this._change || !this._account?._account_id) return;
-    if (!this._loggedIn || !isInvolved(this._change, this._account)) return;
-    if (!this._change.attention_set) this._change.attention_set = {};
-    if (hasAttention(this._account, this._change)) {
-      const reason = getRemovedByReason(this._account, this._serverConfig);
-      if (this._change.attention_set)
-        delete this._change.attention_set[this._account._account_id];
+  // Private but used in tests.
+  handleToggleAttentionSet() {
+    if (!this.change || !this.account?._account_id) return;
+    if (!this.loggedIn || !isInvolved(this.change, this.account)) return;
+    const newChange = {...this.change};
+    if (!newChange.attention_set) newChange.attention_set = {};
+    if (hasAttention(this.account, this.change)) {
+      const reason = getRemovedByReason(this.account, this.serverConfig);
+      if (newChange.attention_set)
+        delete newChange.attention_set[this.account._account_id];
       fireAlert(this, 'Removing you from the attention set ...');
       this.restApiService
         .removeFromAttentionSet(
-          this._change._number,
-          this._account._account_id,
+          this.change._number,
+          this.account._account_id,
           reason
         )
         .then(() => {
           fireEvent(this, 'hide-alert');
         });
     } else {
-      const reason = getAddedByReason(this._account, this._serverConfig);
+      const reason = getAddedByReason(this.account, this.serverConfig);
       fireAlert(this, 'Adding you to the attention set ...');
-      this._change.attention_set[this._account._account_id!] = {
-        account: this._account,
+      newChange.attention_set[this.account._account_id] = {
+        account: this.account,
         reason,
-        reason_account: this._account,
+        reason_account: this.account,
       };
       this.restApiService
         .addToAttentionSet(
-          this._change._number,
-          this._account._account_id,
+          this.change._number,
+          this.account._account_id,
           reason
         )
         .then(() => {
           fireEvent(this, 'hide-alert');
         });
     }
-    this._change = {...this._change};
+    this.change = newChange;
   }
 
-  _handleDiffAgainstBase() {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+  // Private but used in tests.
+  handleDiffAgainstBase() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+    if (this.patchRange.basePatchNum === PARENT) {
       fireAlert(this, 'Base is already selected.');
       return;
     }
-    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+    this.getNavigation().setUrl(
+      createChangeUrl({change: this.change, patchNum: this.patchRange.patchNum})
+    );
   }
 
-  _handleDiffBaseAgainstLeft() {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+  // Private but used in tests.
+  handleDiffBaseAgainstLeft() {
+    if (this.viewState?.childView !== ChangeChildView.OVERVIEW) return;
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+
+    if (this.patchRange.basePatchNum === PARENT) {
       fireAlert(this, 'Left is already base.');
       return;
     }
-    GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
+      })
+    );
   }
 
-  _handleDiffAgainstLatest() {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (this._patchRange.patchNum === latestPatchNum) {
+  // Private but used in tests.
+  handleDiffAgainstLatest() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
+    if (this.patchRange.patchNum === latestPatchNum) {
       fireAlert(this, 'Latest is already selected.');
       return;
     }
-    GerritNav.navigateToChange(
-      this._change,
-      latestPatchNum,
-      this._patchRange.basePatchNum
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum: latestPatchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+      })
     );
   }
 
-  _handleDiffRightAgainstLatest() {
-    assertIsDefined(this._change, '_change');
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    if (this._patchRange.patchNum === latestPatchNum) {
+  // Private but used in tests.
+  handleDiffRightAgainstLatest() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
+    if (this.patchRange.patchNum === latestPatchNum) {
       fireAlert(this, 'Right is already latest.');
       return;
     }
-    GerritNav.navigateToChange(
-      this._change,
-      latestPatchNum,
-      this._patchRange.patchNum as BasePatchSetNum
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum: latestPatchNum,
+        basePatchNum: this.patchRange.patchNum as BasePatchSetNum,
+      })
     );
   }
 
-  _handleDiffBaseAgainstLatest() {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+  // Private but used in tests.
+  handleDiffBaseAgainstLatest() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+    const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
     if (
-      this._patchRange.patchNum === latestPatchNum &&
-      this._patchRange.basePatchNum === ParentPatchSetNum
+      this.patchRange.patchNum === latestPatchNum &&
+      this.patchRange.basePatchNum === PARENT
     ) {
       fireAlert(this, 'Already diffing base against latest.');
       return;
     }
-    GerritNav.navigateToChange(this._change, latestPatchNum);
+    this.getNavigation().setUrl(
+      createChangeUrl({change: this.change, patchNum: latestPatchNum})
+    );
   }
 
-  _handleToggleChangeStar() {
-    this.$.changeStar.toggleStar();
+  private handleToggleChangeStar() {
+    assertIsDefined(this.changeStar);
+    this.changeStar.toggleStar();
   }
 
-  _handleExpandAllMessages() {
+  private handleExpandAllMessages() {
     if (this.messagesList) {
       this.messagesList.handleExpandCollapse(true);
     }
   }
 
-  _handleCollapseAllMessages() {
+  private handleCollapseAllMessages() {
     if (this.messagesList) {
       this.messagesList.handleExpandCollapse(false);
     }
   }
 
-  _handleOpenDiffPrefsShortcut() {
-    if (!this._loggedIn) return;
-    this.$.fileList.openDiffPrefs();
+  private handleOpenDiffPrefsShortcut() {
+    if (!this.loggedIn) return;
+    assertIsDefined(this.fileList);
+    this.fileList.openDiffPrefs();
   }
 
-  _determinePageBack() {
+  private determinePageBack() {
     // Default backPage to root if user came to change view page
     // via an email link, etc.
-    GerritNav.navigateToRelativeUrl(this.backPage || GerritNav.getUrlForRoot());
+    this.getNavigation().setUrl(this.backPage || rootUrl());
   }
 
-  _handleLabelRemoved(
-    splices: Array<PolymerSplice<ApprovalInfo[]>>,
-    path: string
+  private handleLabelRemoved(
+    oldLabels: LabelNameToInfoMap,
+    newLabels: LabelNameToInfoMap
   ) {
-    for (const splice of splices) {
-      for (const removed of splice.removed) {
-        const changePath = path.split('.');
-        const labelPath = changePath.splice(0, changePath.length - 2);
-        const labelDict = this.get(labelPath) as QuickLabelInfo;
+    for (const key in oldLabels) {
+      if (!Object.prototype.hasOwnProperty.call(oldLabels, key)) continue;
+      const oldLabelInfo: QuickLabelInfo & DetailedLabelInfo = oldLabels[key];
+      const newLabelInfo: (QuickLabelInfo & DetailedLabelInfo) | undefined =
+        newLabels[key];
+      if (!newLabelInfo) continue;
+      if (!oldLabelInfo.all || !newLabelInfo.all) continue;
+      const oldAccounts = oldLabelInfo.all.map(x => x._account_id);
+      const newAccounts = newLabelInfo.all.map(x => x._account_id);
+      for (const account of oldAccounts) {
         if (
-          labelDict.approved &&
-          labelDict.approved._account_id === removed._account_id
+          !newAccounts.includes(account) &&
+          newLabelInfo.approved?._account_id === account
         ) {
           fireReload(this);
           return;
@@ -1701,69 +2560,34 @@
     }
   }
 
-  @observe('_change.labels.*')
-  _labelsChanged(
-    changeRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      PolymerSpliceChange<ApprovalInfo[]>
-    >
+  private labelsChanged(
+    oldLabels: LabelNameToInfoMap | undefined,
+    newLabels: LabelNameToInfoMap | undefined
   ) {
-    if (!changeRecord) {
+    if (!oldLabels || !newLabels) {
       return;
     }
-    if (changeRecord.value && isPolymerSpliceChange(changeRecord.value)) {
-      this._handleLabelRemoved(
-        changeRecord.value.indexSplices,
-        changeRecord.path
-      );
-    }
-    this.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, {
-      change: this._change,
+    this.handleLabelRemoved(oldLabels, newLabels);
+    this.getPluginLoader().jsApiService.handleLabelChange({
+      change: this.change,
     });
   }
 
-  _openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
-    if (!this._change) return;
-    const overlay = this.$.replyOverlay;
-    overlay.open().finally(async () => {
-      // the following code should be executed no matter open succeed or not
-      const dialog = query<GrReplyDialog>(this, '#replyDialog');
-      assertIsDefined(dialog, 'reply dialog');
-      this._resetReplyOverlayFocusStops();
-      dialog.open(focusTarget, quote);
-      const observer = new ResizeObserver(() => overlay.center());
-      observer.observe(dialog);
+  openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
+    if (!this.change) return;
+    this.replyModalOpened = true;
+    assertIsDefined(this.replyModal);
+    this.replyModal.showModal();
+    whenVisible(this.replyModal, () => {
+      assertIsDefined(this.replyDialog, 'replyDialog');
+      this.replyDialog.open(focusTarget, quote);
     });
     fireDialogChange(this, {opened: true});
-    this._changeViewAriaHidden = true;
+    this.changeViewAriaHidden = true;
   }
 
-  _handleGetChangeDetailError(response?: Response | null) {
-    firePageError(response);
-  }
-
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _getServerConfig() {
-    return this.restApiService.getConfig();
-  }
-
-  _getProjectConfig() {
-    assertIsDefined(this._change, '_change');
-    return this.restApiService
-      .getProjectConfig(this._change.project)
-      .then(config => {
-        this._projectConfig = config;
-      });
-  }
-
-  _getPreferences() {
-    return this.restApiService.getPreferences();
-  }
-
-  _prepareCommitMsgForLinkify(msg: string) {
+  // Private but used in tests.
+  prepareCommitMsgForLinkify(msg: string) {
     // TODO(wyatta) switch linkify sequence, see issue 5526.
     // This is a zero-with space. It is added to prevent the linkify library
     // from including R= or CC= as part of the email address.
@@ -1773,11 +2597,15 @@
   /**
    * Utility function to make the necessary modifications to a change in the
    * case an edit exists.
+   * Private but used in tests.
    */
-  _processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
+  processEdit(change: ParsedChangeInfo) {
+    const revisions = Object.values(change.revisions || {});
+    const editRev = findEdit(revisions);
+    const editParentRev = findEditParentRevision(revisions);
     if (
-      !edit &&
-      this._patchRange?.patchNum === EditPatchSetNum &&
+      !editRev &&
+      this.patchRange?.patchNum === EDIT &&
       changeIsOpen(change)
     ) {
       fireAlert(this, 'Change edit not found. Please create a change edit.');
@@ -1786,49 +2614,37 @@
     }
 
     if (
-      !edit &&
+      !editRev &&
       (changeIsMerged(change) || changeIsAbandoned(change)) &&
-      this._editMode
+      this.getEditMode()
     ) {
       fireAlert(
         this,
-        'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
+        'Change edits cannot be created if change is merged or abandoned. Redirecting to non edit mode.'
       );
       fireReload(this, true);
       return;
     }
 
-    if (!edit) return;
+    if (!editRev) return;
+    assertIsDefined(this.patchRange, 'patchRange');
+    assertIsDefined(editRev.commit.commit, 'editRev.commit.commit');
+    assertIsDefined(editParentRev, 'editParentRev');
 
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-
-    if (!edit.commit.commit) throw new Error('undefined edit.commit.commit');
-    const changeWithEdit = change;
-    if (changeWithEdit.revisions)
-      changeWithEdit.revisions[edit.commit.commit] = {
-        _number: EditPatchSetNum,
-        basePatchNum: edit.base_patch_set_number,
-        commit: edit.commit,
-        fetch: edit.fetch,
-      };
-
-    // If the edit is based on the most recent patchset, load it by
-    // default, unless another patch set to load was specified in the URL.
-    if (
-      !this._patchRange.patchNum &&
-      changeWithEdit.current_revision === edit.base_revision
-    ) {
-      changeWithEdit.current_revision = edit.commit.commit;
-      this.set('_patchRange.patchNum', EditPatchSetNum);
-      // Because edits are fibbed as revisions and added to the revisions
-      // array, and revision actions are always derived from the 'latest'
-      // patch set, we must copy over actions from the patch set base.
-      // Context: Issue 7243
-      if (changeWithEdit.revisions) {
-        changeWithEdit.revisions[edit.commit.commit].actions =
-          changeWithEdit.revisions[edit.base_revision].actions;
-      }
+    const latestPsNum = computeLatestPatchNum(computeAllPatchSets(change));
+    // If the change was loaded without a specific patchset, then this normally
+    // means that the *latest* patchset should be loaded. But if there is an
+    // active edit, then automatically switch to that edit as the current
+    // patchset.
+    // TODO: This goes together with `change.current_revision` being set, which
+    // is under change-model control. `patchRange.patchNum` should eventually
+    // also be model managed, so we can reconcile these two code snippets into
+    // one location.
+    if (!this.viewModelPatchNum && latestPsNum === editParentRev._number) {
+      this.patchRange = {...this.patchRange, patchNum: EDIT};
+      // The file list is not reactive (yet) with regards to patch range
+      // changes, so we have to actively trigger it.
+      this.reloadPatchNumDependentResources();
     }
   }
 
@@ -1847,103 +2663,109 @@
       const submittedRevert = changes.find(
         change => change?.status === ChangeStatus.MERGED
       );
-      if (!this._changeStatuses) return;
+      if (!this.changeStatuses) return;
+      // Protect against `computeRevertSubmitted()` being called twice.
+      // TODO: Convert this to be rxjs based, so computeRevertSubmitted() is not
+      // actively called, but instead we can subscribe to something.
+      if (this.changeStatuses.includes(ChangeStates.REVERT_SUBMITTED)) return;
+      if (this.changeStatuses.includes(ChangeStates.REVERT_CREATED)) return;
       if (submittedRevert) {
         this.revertedChange = submittedRevert;
-        this.push('_changeStatuses', ChangeStates.REVERT_SUBMITTED);
+        this.changeStatuses = this.changeStatuses.concat([
+          ChangeStates.REVERT_SUBMITTED,
+        ]);
       } else {
         if (changes[0]) this.revertedChange = changes[0];
-        this.push('_changeStatuses', ChangeStates.REVERT_CREATED);
+        this.changeStatuses = this.changeStatuses.concat([
+          ChangeStates.REVERT_CREATED,
+        ]);
       }
     });
   }
 
-  _getChangeDetail() {
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
-    const detailCompletes = this.restApiService.getChangeDetail(
-      this._changeNum,
-      r => this._handleGetChangeDetailError(r)
+  private async untilModelLoaded() {
+    // NOTE: Wait until this page is connected before determining whether the
+    // model is loaded.  This can happen when viewState changes when setting up
+    // this view. It's unclear whether this issue is related to Polymer
+    // specifically.
+    if (!this.isConnected) {
+      await until(this.connected$, connected => connected);
+    }
+    await until(
+      this.getChangeModel().changeLoadingStatus$,
+      status => status === LoadingStatus.LOADED
     );
-    const editCompletes = this._getEdit();
-    const prefCompletes = this._getPreferences();
+  }
 
-    return Promise.all([detailCompletes, editCompletes, prefCompletes]).then(
-      ([change, edit, prefs]) => {
-        this._prefs = prefs;
+  /**
+   * Process edits
+   * Check if a revert of this change has been submitted
+   * Calculate selected revision
+   */
+  // private but used in tests
+  async performPostChangeLoadTasks() {
+    assertIsDefined(this.changeNum, 'changeNum');
 
-        if (!change) {
-          return false;
-        }
-        this._processEdit(change, edit);
-        // Issue 4190: Coalesce missing topics to null.
-        // TODO(TS): code needs second thought,
-        // it might be that nulls were assigned to trigger some bindings
-        if (!change.topic) {
-          change.topic = null as unknown as undefined;
-        }
-        if (!change.reviewer_updates) {
-          change.reviewer_updates = null as unknown as undefined;
-        }
-        const latestRevisionSha = this._getLatestRevisionSHA(change);
-        if (!latestRevisionSha)
-          throw new Error('Could not find latest Revision Sha');
-        const currentRevision = change.revisions[latestRevisionSha];
-        if (currentRevision.commit && currentRevision.commit.message) {
-          this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-            currentRevision.commit.message
-          );
-        } else {
-          this._latestCommitMessage = null;
-        }
+    const prefCompletes = this.restApiService.getPreferences();
+    await this.untilModelLoaded();
 
-        const lineHeight = getComputedStyle(this).lineHeight;
+    this.prefs = await prefCompletes;
 
-        // Slice returns a number as a string, convert to an int.
-        this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
+    if (!this.change) return false;
 
-        this.changeService.updateChange(change);
-        this._change = change;
-        this.computeRevertSubmitted(change);
-        if (
-          !this._patchRange ||
-          !this._patchRange.patchNum ||
-          this._patchRange.patchNum === currentRevision._number
-        ) {
-          // CommitInfo.commit is optional, and may need patching.
-          if (currentRevision.commit && !currentRevision.commit.commit) {
-            currentRevision.commit.commit = latestRevisionSha as CommitId;
-          }
-          this._commitInfo = currentRevision.commit;
-          this._selectedRevision = currentRevision;
-          // TODO: Fetch and process files.
-        } else {
-          if (!this._change?.revisions || !this._patchRange) return false;
-          this._selectedRevision = Object.values(this._change.revisions).find(
-            revision => {
-              // edit patchset is a special one
-              const thePatchNum = this._patchRange!.patchNum;
-              if (thePatchNum === 'edit') {
-                return revision._number === thePatchNum;
-              }
-              return revision._number === Number(`${thePatchNum}`);
-            }
-          );
-        }
-        return true;
+    this.processEdit(this.change);
+    // Issue 4190: Coalesce missing topics to null.
+    // TODO(TS): code needs second thought,
+    // it might be that nulls were assigned to trigger some bindings
+    if (!this.change.topic) {
+      this.change.topic = null as unknown as undefined;
+    }
+    if (!this.change.reviewer_updates) {
+      this.change.reviewer_updates = null as unknown as undefined;
+    }
+    const latestRevisionSha = this.getLatestRevisionSHA(this.change);
+    if (!latestRevisionSha)
+      throw new Error('Could not find latest Revision Sha');
+    const currentRevision = this.change.revisions[latestRevisionSha];
+    if (currentRevision.commit && currentRevision.commit.message) {
+      this.latestCommitMessage = this.prepareCommitMsgForLinkify(
+        currentRevision.commit.message
+      );
+    } else {
+      this.latestCommitMessage = null;
+    }
+
+    this.computeRevertSubmitted(this.change);
+    if (
+      !this.patchRange ||
+      !this.patchRange.patchNum ||
+      this.patchRange.patchNum === currentRevision._number
+    ) {
+      // CommitInfo.commit is optional, and may need patching.
+      if (currentRevision.commit && !currentRevision.commit.commit) {
+        currentRevision.commit.commit = latestRevisionSha as CommitId;
       }
-    );
+      this.commitInfo = currentRevision.commit;
+      this.selectedRevision = currentRevision;
+      // TODO: Fetch and process files.
+    } else {
+      if (!this.change?.revisions || !this.patchRange) return false;
+      this.selectedRevision = Object.values(this.change.revisions).find(
+        revision => {
+          // edit patchset is a special one
+          const thePatchNum = this.patchRange!.patchNum;
+          if (thePatchNum === EDIT) {
+            return revision._number === thePatchNum;
+          }
+          return revision._number === Number(`${thePatchNum}`);
+        }
+      );
+    }
+    return true;
   }
 
-  _isSubmitEnabled(revisionActions: ActionNameToActionInfoMap) {
-    return !!(
-      revisionActions &&
-      revisionActions.submit &&
-      revisionActions.submit.enabled
-    );
-  }
-
-  _isParentCurrent(revisionActions: ActionNameToActionInfoMap) {
+  private isParentCurrent() {
+    const revisionActions = this.currentRevisionActions;
     if (revisionActions && revisionActions.rebase) {
       return !revisionActions.rebase.enabled;
     } else {
@@ -1951,29 +2773,24 @@
     }
   }
 
-  _getEdit() {
-    if (!this._changeNum)
-      return Promise.reject(new Error('missing required changeNum property'));
-    return this.restApiService.getChangeEdit(this._changeNum, true);
-  }
-
-  _getLatestCommitMessage() {
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
-    const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
+  // Private but used in tests.
+  getLatestCommitMessage() {
+    assertIsDefined(this.changeNum, 'changeNum');
+    const lastpatchNum = computeLatestPatchNum(this.allPatchSets);
     if (lastpatchNum === undefined)
       throw new Error('missing lastPatchNum property');
     return this.restApiService
-      .getChangeCommitInfo(this._changeNum, lastpatchNum)
+      .getChangeCommitInfo(this.changeNum, lastpatchNum)
       .then(commitInfo => {
         if (!commitInfo) return;
-        this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+        this.latestCommitMessage = this.prepareCommitMsgForLinkify(
           commitInfo.message
         );
       });
   }
 
-  _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
+  // Private but used in tests.
+  getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
     if (change.current_revision) return change.current_revision;
     // current_revision may not be present in the case where the latest rev is
     // a draft and the user doesn’t have permission to view that rev.
@@ -1988,56 +2805,14 @@
     return latestRev;
   }
 
-  _getCommitInfo() {
-    if (!this._changeNum)
-      throw new Error('missing required _changeNum property');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    if (this._patchRange.patchNum === undefined)
-      throw new Error('missing required patchNum property');
-
-    // We only call _getEdit if the patchset number is an edit.
-    // We have to do this to ensure we can tell if an edit
-    // exists or not.
-    // This safely works even if a edit does not exist.
-    if (this._patchRange!.patchNum! === EditPatchSetNum) {
-      return this._getEdit().then(edit => {
-        if (!edit) {
-          return Promise.resolve();
-        }
-
-        return this._getChangeCommitInfo();
-      });
-    }
-
-    return this._getChangeCommitInfo();
-  }
-
-  _getChangeCommitInfo() {
+  // visible for testing
+  loadAndSetCommitInfo() {
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchRange?.patchNum, 'patchRange.patchNum');
     return this.restApiService
-      .getChangeCommitInfo(this._changeNum!, this._patchRange!.patchNum!)
+      .getChangeCommitInfo(this.changeNum, this.patchRange.patchNum)
       .then(commitInfo => {
-        this._commitInfo = commitInfo;
-      });
-  }
-
-  @observe('_changeComments')
-  changeCommentsChanged(comments?: ChangeComments) {
-    if (!comments) return;
-    this._changeComments = comments;
-    this._commentThreads = this._changeComments.getAllThreadsForChange();
-    this._draftCommentThreads = this._commentThreads
-      .filter(isDraftThread)
-      .map(thread => {
-        const copiedThread = {...thread};
-        // Make a hardcopy of all comments and collapse all but last one
-        const commentsInThread = (copiedThread.comments = thread.comments.map(
-          comment => {
-            return {...comment, collapsed: true as boolean};
-          }
-        ));
-        commentsInThread[commentsInThread.length - 1].collapsed = false;
-        return copiedThread;
+        this.commitInfo = commitInfo;
       });
   }
 
@@ -2052,20 +2827,15 @@
    * Some non-core data loading may still be in-flight when the core data
    * promise resolves.
    */
-  loadData(isLocationChange?: boolean, clearPatchset?: boolean): Promise<void> {
+  loadData(isLocationChange?: boolean, clearPatchset?: boolean) {
     if (this.isChangeObsolete()) return Promise.resolve();
-    if (clearPatchset && this._change) {
-      GerritNav.navigateToChange(
-        this._change,
-        undefined,
-        undefined,
-        undefined,
-        undefined,
-        true
+    if (clearPatchset && this.change) {
+      this.getNavigation().setUrl(
+        createChangeUrl({change: this.change, forceReload: true})
       );
       return Promise.resolve();
     }
-    this._loading = true;
+    this.loading = true;
     this.reporting.time(Timing.CHANGE_RELOAD);
     this.reporting.time(Timing.CHANGE_DATA);
 
@@ -2074,103 +2844,78 @@
 
     // Resolves when the change detail and the edit patch set (if available)
     // are loaded.
-    const detailCompletes = this._getChangeDetail();
+    const detailCompletes = this.untilModelLoaded();
     allDataPromises.push(detailCompletes);
 
     // Resolves when the loading flag is set to false, meaning that some
     // change content may start appearing.
-    const loadingFlagSet = detailCompletes
-      .then(() => {
-        this._loading = false;
-        fireEvent(this, 'change-details-loaded');
-      })
-      .then(() => {
-        this.reporting.timeEnd(Timing.CHANGE_RELOAD);
-        if (isLocationChange) {
-          this.reporting.changeDisplayed({
-            isOwner: isOwner(this._change, this._account),
-            isReviewer: isReviewer(this._change, this._account),
-            isCc: isCc(this._change, this._account),
-          });
-        }
-      });
-
-    // Resolves when the project config has successfully loaded.
-    const projectConfigLoaded = detailCompletes.then(success => {
-      if (!success) return Promise.resolve();
-      return this._getProjectConfig();
+    const loadingFlagSet = detailCompletes.then(() => {
+      this.loading = false;
+      this.performPostChangeLoadTasks();
     });
-    allDataPromises.push(projectConfigLoaded);
 
     let coreDataPromise;
 
     // If the patch number is specified
-    if (this._patchRange && this._patchRange.patchNum) {
+    if (this.patchRange && this.patchRange.patchNum) {
       // Because a specific patchset is specified, reload the resources that
       // are keyed by patch number or patch range.
-      const patchResourcesLoaded = this._reloadPatchNumDependentResources();
+      const patchResourcesLoaded = this.reloadPatchNumDependentResources();
       allDataPromises.push(patchResourcesLoaded);
 
       // Promise resolves when the change detail and patch dependent resources
       // have loaded.
-      const detailAndPatchResourcesLoaded = Promise.all([
-        patchResourcesLoaded,
-        loadingFlagSet,
-      ]);
-
-      // _getChangeDetail triggers reload of change actions already.
-
-      // The core data is loaded when mergeability is known.
-      coreDataPromise = detailAndPatchResourcesLoaded.then(() =>
-        this._getMergeability()
-      );
+      coreDataPromise = Promise.all([patchResourcesLoaded, loadingFlagSet]);
     } else {
-      // Resolves when the file list has loaded.
-      const fileListReload = loadingFlagSet.then(() =>
-        this.$.fileList.reload()
-      );
-      allDataPromises.push(fileListReload);
-
       const latestCommitMessageLoaded = loadingFlagSet.then(() => {
         // If the latest commit message is known, there is nothing to do.
-        if (this._latestCommitMessage) {
+        if (this.latestCommitMessage) {
           return Promise.resolve();
         }
-        return this._getLatestCommitMessage();
+        return this.getLatestCommitMessage();
       });
       allDataPromises.push(latestCommitMessageLoaded);
 
-      // Core data is loaded when mergeability has been loaded.
-      coreDataPromise = loadingFlagSet.then(() => this._getMergeability());
+      coreDataPromise = loadingFlagSet;
     }
+    const mergeabilityLoaded = coreDataPromise.then(() =>
+      this.getMergeability()
+    );
+    allDataPromises.push(mergeabilityLoaded);
 
-    allDataPromises.push(coreDataPromise);
+    coreDataPromise.then(() => {
+      fireEvent(this, 'change-details-loaded');
+      this.reporting.timeEnd(Timing.CHANGE_RELOAD);
+      if (isLocationChange) {
+        this.reporting.changeDisplayed(roleDetails(this.change, this.account));
+      }
+    });
 
     if (isLocationChange) {
-      this._editingCommitMessage = false;
-      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);
+      this.editingCommitMessage = false;
     }
+    const relatedChangesLoaded = coreDataPromise.then(() => {
+      let relatedChangesPromise:
+        | Promise<RelatedChangesInfo | undefined>
+        | undefined;
+      const patchNum = 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;
+          });
+      }
+      return this.getRelatedChangesList()?.reload(relatedChangesPromise);
+    });
+    allDataPromises.push(relatedChangesLoaded);
+    allDataPromises.push(this.filesLoaded());
 
     Promise.all(allDataPromises).then(() => {
       // Loading of commments data is no longer part of this reporting
@@ -2183,12 +2928,19 @@
     return coreDataPromise;
   }
 
+  private async filesLoaded() {
+    if (!this.isConnected) await until(this.connected$, connected => connected);
+    await until(this.getFilesModel().files$, f => f.length > 0);
+  }
+
   /**
    * 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.
+   *
+   * Private but used in tests.
    */
-  _calculateHasParent(
+  calculateHasParent(
     currentChangeId: ChangeId,
     relatedChanges: RelatedChangeAndCommitInfo[]
   ) {
@@ -2200,253 +2952,190 @@
 
   /**
    * Kicks off requests for resources that rely on the patch range
-   * (`this._patchRange`) being defined.
+   * (`this.patchRange`) being defined.
    */
-  _reloadPatchNumDependentResources(patchNumChanged?: boolean) {
-    assertIsDefined(this._changeNum, '_changeNum');
-    if (!this._patchRange?.patchNum) throw new Error('missing patchNum');
-    const promises = [this._getCommitInfo(), this.$.fileList.reload()];
-    if (patchNumChanged) {
-      promises.push(
-        this.commentsService.reloadPortedComments(
-          this._changeNum,
-          this._patchRange?.patchNum
-        )
-      );
-      promises.push(
-        this.commentsService.reloadPortedDrafts(
-          this._changeNum,
-          this._patchRange?.patchNum
-        )
-      );
-    }
-    return Promise.all(promises);
+  reloadPatchNumDependentResources() {
+    return this.loadAndSetCommitInfo();
   }
 
-  _getMergeability(): Promise<void> {
-    if (!this._change) {
-      this._mergeable = null;
+  // Private but used in tests
+  getMergeability(): Promise<void> {
+    if (!this.change) {
+      this.mergeable = null;
       return Promise.resolve();
     }
     // If the change is closed, it is not mergeable. Note: already merged
     // changes are obviously not mergeable, but the mergeability API will not
     // answer for abandoned changes.
     if (
-      this._change.status === ChangeStatus.MERGED ||
-      this._change.status === ChangeStatus.ABANDONED
+      this.change.status === ChangeStatus.MERGED ||
+      this.change.status === ChangeStatus.ABANDONED
     ) {
-      this._mergeable = false;
+      this.mergeable = false;
       return Promise.resolve();
     }
 
-    if (!this._changeNum) {
+    if (!this.changeNum) {
       return Promise.reject(new Error('missing required changeNum property'));
     }
 
     // If mergeable bit was already returned in detail REST endpoint, use it.
-    if (this._change.mergeable !== undefined) {
-      this._mergeable = this._change.mergeable;
+    if (this.change.mergeable !== undefined) {
+      this.mergeable = this.change.mergeable;
       return Promise.resolve();
     }
 
-    this._mergeable = null;
+    this.mergeable = null;
     return this.restApiService
-      .getMergeable(this._changeNum)
+      .getMergeable(this.changeNum)
       .then(mergableInfo => {
         if (mergableInfo) {
-          this._mergeable = mergableInfo.mergeable;
+          this.mergeable = mergableInfo.mergeable;
         }
       });
   }
 
-  _computeResolveWeblinks(
-    change?: ChangeInfo,
-    commitInfo?: CommitInfo,
-    config?: ServerInfo
-  ) {
-    if (!change || !commitInfo || !config) {
-      return [];
-    }
-    return GerritNav.getResolveConflictsWeblinks(
-      change.project,
-      commitInfo.commit,
-      {
-        weblinks: commitInfo.resolve_conflicts_web_links,
-        config,
-      }
-    );
-  }
-
-  _computeCanStartReview(change: ChangeInfo): boolean {
-    return !!(
-      change.actions &&
-      change.actions.ready &&
-      change.actions.ready.enabled
-    );
-  }
-
-  _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
-    return `Change ${changeNum}`;
-  }
-
   /**
    * Returns the text to be copied when
    * click the copy icon next to change subject
+   * Private but used in tests.
    */
-  _computeCopyTextForTitle(change: ChangeInfo): string {
+  computeCopyTextForTitle(): string {
     return (
-      `${change._number}: ${change.subject} | ` +
+      `${this.change?._number}: ${this.change?.subject} | ` +
       `${location.protocol}//${location.host}` +
-      `${this._computeChangeUrl(change)}`
+      `${this.computeChangeUrl()}`
     );
   }
 
-  _computeCommitCollapsible(commitMessage?: string) {
-    if (!commitMessage) {
+  private computeCommitCollapsible() {
+    if (!this.latestCommitMessage) {
       return false;
     }
-    return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+    return (
+      this.latestCommitMessage.split('\n').length >=
+      MIN_LINES_FOR_COMMIT_COLLAPSE
+    );
   }
 
-  _startUpdateCheckTimer() {
+  private startUpdateCheckTimer() {
     if (
-      !this._serverConfig ||
-      !this._serverConfig.change ||
-      this._serverConfig.change.update_delay === undefined ||
-      this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS
+      !this.serverConfig ||
+      !this.serverConfig.change ||
+      this.serverConfig.change.update_delay === undefined ||
+      this.serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS
     ) {
       return;
     }
 
-    this._updateCheckTimerHandle = window.setTimeout(() => {
-      if (!this.isViewCurrent) {
-        this._startUpdateCheckTimer();
+    this.updateCheckTimerHandle = window.setTimeout(() => {
+      if (!this.isViewCurrent || !this.change) {
+        this.startUpdateCheckTimer();
         return;
       }
-      assertIsDefined(this._change, '_change');
-      const change = this._change;
-      this.changeService.fetchChangeUpdates(change).then(result => {
-        let toastMessage = null;
-        if (!result.isLatest) {
-          toastMessage = ReloadToastMessage.NEWER_REVISION;
-        } else if (result.newStatus === ChangeStatus.MERGED) {
-          toastMessage = ReloadToastMessage.MERGED;
-        } else if (result.newStatus === ChangeStatus.ABANDONED) {
-          toastMessage = ReloadToastMessage.ABANDONED;
-        } else if (result.newStatus === ChangeStatus.NEW) {
-          toastMessage = ReloadToastMessage.RESTORED;
-        } else if (result.newMessages) {
-          toastMessage = ReloadToastMessage.NEW_MESSAGE;
-          if (result.newMessages.author?.name) {
-            toastMessage += ` from ${result.newMessages.author.name}`;
+      const change = this.change;
+      this.getChangeModel()
+        .fetchChangeUpdates(change)
+        .then(result => {
+          let toastMessage = null;
+          if (!result.isLatest) {
+            toastMessage = ReloadToastMessage.NEWER_REVISION;
+          } else if (result.newStatus === ChangeStatus.MERGED) {
+            toastMessage = ReloadToastMessage.MERGED;
+          } else if (result.newStatus === ChangeStatus.ABANDONED) {
+            toastMessage = ReloadToastMessage.ABANDONED;
+          } else if (result.newStatus === ChangeStatus.NEW) {
+            toastMessage = ReloadToastMessage.RESTORED;
+          } else if (result.newMessages) {
+            toastMessage = ReloadToastMessage.NEW_MESSAGE;
+            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.
-        // Since starting to fetch the change update the user may have sent a
-        // reply, or the change might have been reloaded, or it could be in the
-        // process of being reloaded.
-        const changeWasReloaded = change !== this._change;
-        if (
-          !toastMessage ||
-          this._loading ||
-          changeWasReloaded ||
-          !this.isViewCurrent
-        ) {
-          this._startUpdateCheckTimer();
-          return;
-        }
+          // We have to make sure that the update is still relevant for the user.
+          // Since starting to fetch the change update the user may have sent a
+          // reply, or the change might have been reloaded, or it could be in the
+          // process of being reloaded.
+          const changeWasReloaded = change !== this.change;
+          if (
+            !toastMessage ||
+            this.loading ||
+            changeWasReloaded ||
+            !this.isViewCurrent
+          ) {
+            this.startUpdateCheckTimer();
+            return;
+          }
 
-        this._cancelUpdateCheckTimer();
-        this.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>('show-alert', {
-            detail: {
-              message: toastMessage,
-              // Persist this alert.
-              dismissOnNavigation: true,
-              showDismiss: true,
-              action: 'Reload',
-              callback: () => fireReload(this, true),
-            },
-            composed: true,
-            bubbles: true,
-          })
-        );
-      });
-    }, this._serverConfig.change.update_delay * 1000);
+          this.cancelUpdateCheckTimer();
+          this.dispatchEvent(
+            new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
+              detail: {
+                message: toastMessage,
+                // Persist this alert.
+                dismissOnNavigation: true,
+                showDismiss: true,
+                action: 'Reload',
+                callback: () => fireReload(this, true),
+              },
+              composed: true,
+              bubbles: true,
+            })
+          );
+        });
+    }, this.serverConfig.change.update_delay * 1000);
   }
 
-  _cancelUpdateCheckTimer() {
-    if (this._updateCheckTimerHandle) {
-      window.clearTimeout(this._updateCheckTimerHandle);
+  private cancelUpdateCheckTimer() {
+    if (this.updateCheckTimerHandle) {
+      window.clearTimeout(this.updateCheckTimerHandle);
     }
-    this._updateCheckTimerHandle = null;
+    this.updateCheckTimerHandle = null;
   }
 
   private readonly handleVisibilityChange = () => {
-    if (document.hidden && this._updateCheckTimerHandle) {
-      this._cancelUpdateCheckTimer();
-    } else if (!this._updateCheckTimerHandle) {
-      this._startUpdateCheckTimer();
+    if (document.hidden && this.updateCheckTimerHandle) {
+      this.cancelUpdateCheckTimer();
+    } else if (!this.updateCheckTimerHandle) {
+      this.startUpdateCheckTimer();
     }
   };
 
-  _handleTopicChanged() {
-    this.getRelatedChangesList()?.reload();
-  }
-
-  _computeHeaderClass(editMode?: boolean) {
+  // Private but used in tests.
+  computeHeaderClass() {
     const classes = ['header'];
-    if (editMode) {
+    if (this.getEditMode()) {
       classes.push('editMode');
     }
     return classes.join(' ');
   }
 
-  _computeEditMode(
-    patchRangeRecord: PolymerDeepPropertyChange<
-      ChangeViewPatchRange,
-      ChangeViewPatchRange
-    >,
-    paramsRecord: PolymerDeepPropertyChange<
-      AppElementChangeViewParams,
-      AppElementChangeViewParams
-    >
-  ) {
-    if (!patchRangeRecord || !paramsRecord) {
-      return undefined;
-    }
-
-    if (paramsRecord.base && paramsRecord.base.edit) {
-      return true;
-    }
-
-    const patchRange = patchRangeRecord.base || {};
-    return patchRange.patchNum === EditPatchSetNum;
-  }
-
-  _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+  private handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
     e.preventDefault();
+    assertIsDefined(this.fileListHeader);
     const controls =
-      this.$.fileListHeader.shadowRoot!.querySelector<GrEditControls>(
+      this.fileListHeader.shadowRoot!.querySelector<GrEditControls>(
         '#editControls'
       );
     if (!controls) throw new Error('Missing edit controls');
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+
     const path = e.detail.path;
     switch (e.detail.action) {
       case GrEditConstants.Actions.DELETE.id:
         controls.openDeleteDialog(path);
         break;
       case GrEditConstants.Actions.OPEN.id:
-        GerritNav.navigateToRelativeUrl(
-          GerritNav.getEditUrlForDiff(
-            this._change,
-            path,
-            this._patchRange.patchNum
-          )
+        assertIsDefined(this.patchRange.patchNum, 'patchset number');
+        this.getNavigation().setUrl(
+          createEditUrl({
+            changeNum: this.change._number,
+            repo: this.change.project,
+            patchNum: this.patchRange.patchNum,
+            editView: {path},
+          })
         );
         break;
       case GrEditConstants.Actions.RENAME.id:
@@ -2458,90 +3147,71 @@
     }
   }
 
-  _computeCommitMessageKey(number: NumericChangeId, revision: CommitId) {
-    return `c${number}_rev${revision}`;
-  }
-
-  @observe('_patchRange.patchNum')
-  _patchNumChanged(patchNumStr?: PatchSetNum) {
-    if (!this._selectedRevision || !patchNumStr) {
+  private patchNumChanged() {
+    if (!this.selectedRevision || !this.patchRange?.patchNum) {
       return;
     }
-    assertIsDefined(this._change, '_change');
+    assertIsDefined(this.change, 'change');
 
-    let patchNum: PatchSetNum;
-    if (patchNumStr === 'edit') {
-      patchNum = EditPatchSetNum;
-    } else {
-      patchNum = Number(`${patchNumStr}`) as PatchSetNum;
-    }
-
-    if (patchNum === this._selectedRevision._number) {
+    if (this.patchRange.patchNum === this.selectedRevision._number) {
       return;
     }
-    if (this._change.revisions)
-      this._selectedRevision = Object.values(this._change.revisions).find(
-        revision => revision._number === patchNum
-      );
+    if (!this.change.revisions) return;
+    this.selectedRevision = Object.values(this.change.revisions).find(
+      revision => revision._number === this.patchRange!.patchNum
+    );
   }
 
   /**
    * If an edit exists already, load it. Otherwise, toggle edit mode via the
    * navigation API.
    */
-  _handleEditTap() {
-    if (!this._change || !this._change.revisions)
+  private handleEditTap() {
+    if (!this.change || !this.change.revisions)
       throw new Error('missing required change property');
-    const editInfo = Object.values(this._change.revisions).find(
-      info => info._number === EditPatchSetNum
+    const editInfo = Object.values(this.change.revisions).find(
+      info => info._number === EDIT
     );
 
     if (editInfo) {
-      GerritNav.navigateToChange(this._change, EditPatchSetNum);
+      const url = createChangeUrl({change: this.change, patchNum: EDIT});
+      this.getNavigation().setUrl(url);
       return;
     }
 
     // Avoid putting patch set in the URL unless a non-latest patch set is
     // selected.
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
+    assertIsDefined(this.patchRange, 'patchRange');
     let patchNum;
     if (
-      !(this._patchRange.patchNum === computeLatestPatchNum(this._allPatchSets))
+      !(this.patchRange.patchNum === computeLatestPatchNum(this.allPatchSets))
     ) {
-      patchNum = this._patchRange.patchNum;
+      patchNum = this.patchRange.patchNum;
     }
-    GerritNav.navigateToChange(
-      this._change,
-      patchNum,
-      undefined,
-      true,
-      undefined,
-      true
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum,
+        edit: true,
+        forceReload: true,
+      })
     );
   }
 
-  _handleStopEditTap() {
-    assertIsDefined(this._change, '_change');
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    GerritNav.navigateToChange(
-      this._change,
-      this._patchRange.patchNum,
-      undefined,
-      undefined,
-      undefined,
-      true
+  private handleStopEditTap() {
+    assertIsDefined(this.change, 'change');
+    assertIsDefined(this.patchRange, 'patchRange');
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum: this.patchRange.patchNum,
+        forceReload: true,
+      })
     );
   }
 
-  _resetReplyOverlayFocusStops() {
-    const dialog = query<GrReplyDialog>(this, '#replyDialog');
-    if (!dialog) return;
-    this.$.replyOverlay.setFocusStops(dialog.getFocusStops());
-  }
-
-  _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+  // Private but used in tests.
+  async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
     if (e.detail.starred) {
       this.reporting.reportInteraction('change-starred-from-change-view');
       this.lastStarredTimestamp = Date.now();
@@ -2553,58 +3223,20 @@
         this.reporting.reportInteraction('change-accidentally-starred');
       }
     }
-    this.restApiService.saveChangeStarred(
+    const msg = e.detail.starred
+      ? 'Starring change...'
+      : 'Unstarring change...';
+    fireAlert(this, msg);
+    await this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
     );
+    fireEvent(this, 'hide-alert');
   }
 
-  _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo): RevisionInfoClass {
-    return new RevisionInfoClass(change);
-  }
-
-  _computeCurrentRevision(
-    currentRevision: CommitId,
-    revisions: {[revisionId: string]: RevisionInfo}
-  ) {
-    return currentRevision && revisions && revisions[currentRevision];
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeLatestPatchNum(allPatchSets?: PatchSet[]) {
-    return computeLatestPatchNum(allPatchSets);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]): boolean {
-    return hasEditBasedOnCurrentPatchSet(allPatchSets);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _hasEditPatchsetLoaded(
-    patchRangeRecord: PolymerDeepPropertyChange<
-      ChangeViewPatchRange,
-      ChangeViewPatchRange
-    >
-  ): boolean {
-    const patchRange = patchRangeRecord.base;
-    if (!patchRange) {
-      return false;
-    }
-    return hasEditPatchsetLoaded(patchRange);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeAllPatchSets(change: ChangeInfo) {
-    return computeAllPatchSets(change);
+  private getRevisionInfo(): RevisionInfoClass | undefined {
+    if (this.change === undefined) return undefined;
+    return new RevisionInfoClass(this.change);
   }
 
   getRelatedChangesList() {
@@ -2614,7 +3246,13 @@
   }
 
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-    return this.shortcuts.createTitle(shortcutName, section);
+    return this.getShortcutsService().createTitle(shortcutName, section);
+  }
+
+  private handleRevisionActionsChanged(
+    e: CustomEvent<{value: ActionNameToActionInfoMap}>
+  ) {
+    this.currentRevisionActions = e.detail.value;
   }
 }
 
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
deleted file mode 100644
index 9181ca0..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ /dev/null
@@ -1,716 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .container:not(.loading) {
-      background-color: var(--background-color-tertiary);
-    }
-    .container.loading {
-      color: var(--deemphasized-text-color);
-      padding: var(--spacing-l);
-    }
-    .header {
-      align-items: center;
-      background-color: var(--background-color-primary);
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      padding: var(--spacing-s) var(--spacing-l);
-      z-index: 99; /* Less than gr-overlay's backdrop */
-    }
-    .header.editMode {
-      background-color: var(--edit-mode-background-color);
-    }
-    .header .download {
-      margin-right: var(--spacing-l);
-    }
-    gr-change-status {
-      margin-left: var(--spacing-s);
-    }
-    gr-change-status:first-child {
-      margin-left: 0;
-    }
-    .headerTitle {
-      align-items: center;
-      display: flex;
-      flex: 1;
-    }
-    .headerSubject {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-      margin-left: var(--spacing-l);
-    }
-    .changeNumberColon {
-      color: transparent;
-    }
-    .changeCopyClipboard {
-      margin-left: var(--spacing-s);
-    }
-    #replyBtn {
-      margin-bottom: var(--spacing-m);
-    }
-    gr-change-star {
-      margin-left: var(--spacing-s);
-      --gr-change-star-size: var(--line-height-normal);
-    }
-    a.changeNumber {
-      margin-left: var(--spacing-xs);
-    }
-    gr-reply-dialog {
-      width: 60em;
-    }
-    .changeStatus {
-      text-transform: capitalize;
-    }
-    /* Strong specificity here is needed due to
-         https://github.com/Polymer/polymer/issues/2531 */
-    .container .changeInfo {
-      display: flex;
-      background-color: var(--background-color-secondary);
-      padding-right: var(--spacing-m);
-    }
-    section {
-      background-color: var(--view-background-color);
-      box-shadow: var(--elevation-level-1);
-    }
-    .changeMetadata {
-      /* Limit meta section to half of the screen at max */
-      max-width: 50%;
-    }
-    .commitMessage {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      margin-right: var(--spacing-l);
-      margin-bottom: var(--spacing-l);
-      /* Account for border and padding and rounding errors. */
-      max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
-    }
-    .commitMessage gr-linked-text {
-      word-break: break-word;
-    }
-    #commitMessageEditor {
-      /* Account for border and padding and rounding errors. */
-      min-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
-      --collapsed-max-height: 300px;
-    }
-    .changeStatuses,
-    .commitActions {
-      align-items: center;
-      display: flex;
-    }
-    .changeStatuses {
-      flex-wrap: wrap;
-    }
-    .mainChangeInfo {
-      display: flex;
-      flex: 1;
-      flex-direction: column;
-      min-width: 0;
-    }
-    #commitAndRelated {
-      align-content: flex-start;
-      display: flex;
-      flex: 1;
-      overflow-x: hidden;
-    }
-    .relatedChanges {
-      flex: 0 1 auto;
-      overflow: hidden;
-      padding: var(--spacing-l) 0;
-    }
-    .mobile {
-      display: none;
-    }
-    hr {
-      border: 0;
-      border-top: 1px solid var(--border-color);
-      height: 0;
-      margin-bottom: var(--spacing-l);
-    }
-    .emptySpace {
-      flex-grow: 1;
-    }
-    .commitContainer {
-      display: flex;
-      flex-direction: column;
-      flex-shrink: 0;
-      margin: var(--spacing-l) 0;
-      padding: 0 var(--spacing-l);
-    }
-    .showOnEdit {
-      display: none;
-    }
-    .scrollable {
-      overflow: auto;
-    }
-    .text {
-      white-space: pre;
-    }
-    gr-commit-info {
-      display: inline-block;
-    }
-    paper-tabs {
-      background-color: var(--background-color-tertiary);
-      margin-top: var(--spacing-m);
-      height: calc(var(--line-height-h3) + var(--spacing-m));
-      --paper-tabs-selection-bar-color: var(--link-color);
-    }
-    paper-tab {
-      box-sizing: border-box;
-      max-width: 12em;
-      --paper-tab-ink: var(--link-color);
-    }
-    gr-thread-list,
-    gr-messages-list {
-      display: block;
-    }
-    gr-thread-list {
-      min-height: 250px;
-    }
-    #includedInOverlay {
-      width: 65em;
-    }
-    #uploadHelpOverlay {
-      width: 50em;
-    }
-    #metadata {
-      --metadata-horizontal-padding: var(--spacing-l);
-      padding-top: var(--spacing-l);
-      width: 100%;
-    }
-    gr-change-summary {
-      margin-left: var(--spacing-m);
-    }
-    @media screen and (max-width: 75em) {
-      .relatedChanges {
-        padding: 0;
-      }
-      #relatedChanges {
-        padding-top: var(--spacing-l);
-      }
-      #commitAndRelated {
-        flex-direction: column;
-        flex-wrap: nowrap;
-      }
-      #commitMessageEditor {
-        min-width: 0;
-      }
-      .commitMessage {
-        margin-right: 0;
-      }
-      .mainChangeInfo {
-        padding-right: 0;
-      }
-    }
-    @media screen and (max-width: 50em) {
-      .mobile {
-        display: block;
-      }
-      .header {
-        align-items: flex-start;
-        flex-direction: column;
-        flex: 1;
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      gr-change-star {
-        vertical-align: middle;
-      }
-      .headerTitle {
-        flex-wrap: wrap;
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      .desktop {
-        display: none;
-      }
-      .reply {
-        display: block;
-        margin-right: 0;
-        /* px because don't have the same font size */
-        margin-bottom: 6px;
-      }
-      .changeInfo-column:not(:last-of-type) {
-        margin-right: 0;
-        padding-right: 0;
-      }
-      .changeInfo,
-      #commitAndRelated {
-        flex-direction: column;
-        flex-wrap: nowrap;
-      }
-      .commitContainer {
-        margin: 0;
-        padding: var(--spacing-l);
-      }
-      .changeMetadata {
-        margin-top: var(--spacing-xs);
-        max-width: none;
-      }
-      #metadata,
-      .mainChangeInfo {
-        padding: 0;
-      }
-      .commitActions {
-        display: block;
-        margin-top: var(--spacing-l);
-        width: 100%;
-      }
-      .commitMessage {
-        flex: initial;
-        margin: 0;
-      }
-      /* Change actions are the only thing thant need to remain visible due
-        to the fact that they may have the currently visible overlay open. */
-      #mainContent.overlayOpen .hideOnMobileOverlay {
-        display: none;
-      }
-      gr-reply-dialog {
-        height: 100vh;
-        min-width: initial;
-        width: 100vw;
-      }
-      #replyOverlay {
-        z-index: var(--reply-overlay-z-index);
-      }
-    }
-    .patch-set-dropdown {
-      margin: var(--spacing-m) 0 0 var(--spacing-m);
-    }
-    .show-robot-comments {
-      margin: var(--spacing-m);
-    }
-    .patchInfo gr-thread-list::part(threads) {
-      padding: var(--spacing-l);
-    }
-  </style>
-  <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
-  <!-- TODO(taoalpha): remove on-show-checks-table,
-    Gerrit should not have any thing too special for a plugin,
-    replace with a generic event: show-primary-tab. -->
-  <div
-    id="mainContent"
-    class="container"
-    on-show-checks-table="_setActivePrimaryTab"
-    hidden$="{{_loading}}"
-    aria-hidden="[[_changeViewAriaHidden]]"
-  >
-    <section class="changeInfoSection">
-      <div class$="[[_computeHeaderClass(_editMode)]]">
-        <h1 class="assistive-tech-only">
-          Change [[_change._number]]: [[_change.subject]]
-        </h1>
-        <div class="headerTitle">
-          <div class="changeStatuses">
-            <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
-              <gr-change-status
-                change="[[_change]]"
-                reverted-change="[[revertedChange]]"
-                status="[[status]]"
-                resolve-weblinks="[[resolveWeblinks]]"
-              ></gr-change-status>
-            </template>
-          </div>
-          <gr-change-star
-            id="changeStar"
-            change="[[_change]]"
-            on-toggle-star="_handleToggleStar"
-            hidden$="[[!_loggedIn]]"
-          ></gr-change-star>
-
-          <a
-            class="changeNumber"
-            aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
-            href$="[[_computeChangeUrl(_change)]]"
-            >[[_change._number]]</a
-          >
-          <span class="changeNumberColon">:&nbsp;</span>
-          <span class="headerSubject">[[_change.subject]]</span>
-          <gr-copy-clipboard
-            class="changeCopyClipboard"
-            hideInput=""
-            text="[[_computeCopyTextForTitle(_change)]]"
-          >
-          </gr-copy-clipboard>
-        </div>
-        <!-- end headerTitle -->
-        <div class="commitActions" hidden$="[[!_loggedIn]]">
-          <gr-change-actions
-            id="actions"
-            change="[[_change]]"
-            disable-edit="[[disableEdit]]"
-            has-parent="[[hasParent]]"
-            actions="[[_change.actions]]"
-            revision-actions="{{_currentRevisionActions}}"
-            account="[[_account]]"
-            change-num="[[_changeNum]]"
-            change-status="[[_change.status]]"
-            commit-num="[[_commitInfo.commit]]"
-            latest-patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-            commit-message="[[_latestCommitMessage]]"
-            edit-patchset-loaded="[[_hasEditPatchsetLoaded(_patchRange.*)]]"
-            edit-mode="[[_editMode]]"
-            edit-based-on-current-patch-set="[[_hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
-            private-by-default="[[_projectConfig.private_by_default]]"
-            on-edit-tap="_handleEditTap"
-            on-stop-edit-tap="_handleStopEditTap"
-            on-download-tap="_handleOpenDownloadDialog"
-            on-included-tap="_handleOpenIncludedInDialog"
-          ></gr-change-actions>
-        </div>
-        <!-- end commit actions -->
-      </div>
-      <!-- end header -->
-      <h2 class="assistive-tech-only">Change metadata</h2>
-      <div class="changeInfo">
-        <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
-          <gr-change-metadata
-            id="metadata"
-            change="{{_change}}"
-            reverted-change="[[revertedChange]]"
-            account="[[_account]]"
-            revision="[[_selectedRevision]]"
-            commit-info="[[_commitInfo]]"
-            server-config="[[_serverConfig]]"
-            parent-is-current="[[_parentIsCurrent]]"
-            on-show-reply-dialog="_handleShowReplyDialog"
-          >
-          </gr-change-metadata>
-        </div>
-        <div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
-          <div id="commitAndRelated" class="hideOnMobileOverlay">
-            <div class="commitContainer">
-              <h3 class="assistive-tech-only">Commit Message</h3>
-              <div>
-                <gr-button
-                  id="replyBtn"
-                  class="reply"
-                  title="[[createTitle(Shortcut.OPEN_REPLY_DIALOG,
-                        ShortcutSection.ACTIONS)]]"
-                  hidden$="[[!_loggedIn]]"
-                  primary=""
-                  disabled="[[_replyDisabled]]"
-                  on-click="_handleReplyTap"
-                  >[[_replyButtonLabel]]</gr-button
-                >
-              </div>
-              <div id="commitMessage" class="commitMessage">
-                <gr-editable-content
-                  id="commitMessageEditor"
-                  editing="{{_editingCommitMessage}}"
-                  content="{{_latestCommitMessage}}"
-                  storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
-                  hide-edit-commit-message="[[_hideEditCommitMessage]]"
-                  commit-collapsible="[[_commitCollapsible]]"
-                  remove-zero-width-space=""
-                >
-                  <gr-linked-text
-                    pre=""
-                    content="[[_latestCommitMessage]]"
-                    config="[[_projectConfig.commentlinks]]"
-                    remove-zero-width-space=""
-                  ></gr-linked-text>
-                </gr-editable-content>
-              </div>
-              <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
-              <gr-change-summary></gr-change-summary>
-              <gr-endpoint-decorator name="commit-container">
-                <gr-endpoint-param name="change" value="[[_change]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param
-                  name="revision"
-                  value="[[_selectedRevision]]"
-                >
-                </gr-endpoint-param>
-              </gr-endpoint-decorator>
-            </div>
-            <div class="relatedChanges">
-              <gr-related-changes-list
-                change="[[_change]]"
-                id="relatedChanges"
-                mergeable="[[_mergeable]]"
-                patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-              ></gr-related-changes-list>
-            </div>
-            <div class="emptySpace"></div>
-          </div>
-        </div>
-      </div>
-    </section>
-
-    <h2 class="assistive-tech-only">Files and Comments tabs</h2>
-    <paper-tabs id="primaryTabs" on-selected-changed="_setActivePrimaryTab">
-      <paper-tab
-        on-click="_onPaperTabClick"
-        data-name$="[[_constants.PrimaryTab.FILES]]"
-        ><span>Files</span></paper-tab
-      >
-      <paper-tab
-        on-click="_onPaperTabClick"
-        data-name$="[[_constants.PrimaryTab.COMMENT_THREADS]]"
-        class="commentThreads"
-      >
-        <gr-tooltip-content
-          has-tooltip
-          title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]"
-        >
-          <span>Comments</span></gr-tooltip-content
-        >
-      </paper-tab>
-      <template is="dom-if" if="[[_showChecksTab]]">
-        <paper-tab
-          data-name$="[[_constants.PrimaryTab.CHECKS]]"
-          on-click="_onPaperTabClick"
-          ><span>Checks</span></paper-tab
-        >
-      </template>
-      <template
-        is="dom-repeat"
-        items="[[_dynamicTabHeaderEndpoints]]"
-        as="tabHeader"
-      >
-        <paper-tab data-name$="[[tabHeader]]">
-          <gr-endpoint-decorator name$="[[tabHeader]]">
-            <gr-endpoint-param name="change" value="[[_change]]">
-            </gr-endpoint-param>
-            <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-        </paper-tab>
-      </template>
-      <paper-tab
-        data-name$="[[_constants.PrimaryTab.FINDINGS]]"
-        on-click="_onPaperTabClick"
-      >
-        <span>Findings</span>
-      </paper-tab>
-    </paper-tabs>
-
-    <section class="patchInfo">
-      <div
-        hidden$="[[!_isTabActive(_constants.PrimaryTab.FILES, _activeTabs)]]"
-      >
-        <gr-file-list-header
-          id="fileListHeader"
-          account="[[_account]]"
-          all-patch-sets="[[_allPatchSets]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          revision-info="[[_revisionInfo]]"
-          commit-info="[[_commitInfo]]"
-          change-url="[[_computeChangeUrl(_change)]]"
-          edit-mode="[[_editMode]]"
-          logged-in="[[_loggedIn]]"
-          server-config="[[_serverConfig]]"
-          shown-file-count="[[_shownFileCount]]"
-          diff-prefs="[[_diffPrefs]]"
-          patch-num="{{_patchRange.patchNum}}"
-          base-patch-num="{{_patchRange.basePatchNum}}"
-          files-expanded="[[_filesExpanded]]"
-          diff-prefs-disabled="[[!_loggedIn]]"
-          on-open-diff-prefs="_handleOpenDiffPrefs"
-          on-open-download-dialog="_handleOpenDownloadDialog"
-          on-expand-diffs="_expandAllDiffs"
-          on-collapse-diffs="_collapseAllDiffs"
-        >
-        </gr-file-list-header>
-        <gr-file-list
-          id="fileList"
-          class="hideOnMobileOverlay"
-          diff-prefs="{{_diffPrefs}}"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          patch-range="{{_patchRange}}"
-          change-comments="[[_changeComments]]"
-          selected-index="{{viewState.selectedFileIndex}}"
-          diff-view-mode="[[viewState.diffMode]]"
-          edit-mode="[[_editMode]]"
-          num-files-shown="{{_numFilesShown}}"
-          files-expanded="{{_filesExpanded}}"
-          file-list-increment="{{_numFilesShown}}"
-          on-files-shown-changed="_setShownFiles"
-          on-file-action-tap="_handleFileActionTap"
-          observer-target="[[_computeObserverTarget()]]"
-        >
-        </gr-file-list>
-      </div>
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.PrimaryTab.COMMENT_THREADS, _activeTabs)]]"
-      >
-        <h3 class="assistive-tech-only">Comments</h3>
-        <gr-thread-list
-          threads="[[_commentThreads]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          logged-in="[[_loggedIn]]"
-          account="[[_account]]"
-          comment-tab-state="[[_tabState.commentTab]]"
-          only-show-robot-comments-with-human-reply=""
-          unresolved-only="[[unresolvedOnly]]"
-          scroll-comment-id="[[scrollCommentId]]"
-          show-comment-context
-        ></gr-thread-list>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
-      >
-        <h3 class="assistive-tech-only">Checks</h3>
-        <gr-checks-tab
-          id="checksTab"
-          tab-state="[[_tabState.checksTab]]"
-        ></gr-checks-tab>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.PrimaryTab.FINDINGS, _activeTabs)]]"
-      >
-        <gr-dropdown-list
-          class="patch-set-dropdown"
-          items="[[_robotCommentsPatchSetDropdownItems]]"
-          on-value-change="_handleRobotCommentPatchSetChanged"
-          value="[[_currentRobotCommentsPatchSet]]"
-        >
-        </gr-dropdown-list>
-        <gr-thread-list
-          threads="[[_robotCommentThreads]]"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          logged-in="[[_loggedIn]]"
-          hide-dropdown
-          empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
-        >
-        </gr-thread-list>
-        <template is="dom-if" if="[[_showRobotCommentsButton]]">
-          <gr-button
-            class="show-robot-comments"
-            on-click="_toggleShowRobotComments"
-          >
-            [[_computeShowText(_showAllRobotComments)]]
-          </gr-button>
-        </template>
-      </template>
-
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_selectedTabPluginHeader, _activeTabs)]]"
-      >
-        <gr-endpoint-decorator name$="[[_selectedTabPluginEndpoint]]">
-          <gr-endpoint-param name="change" value="[[_change]]">
-          </gr-endpoint-param>
-          <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </template>
-    </section>
-
-    <gr-endpoint-decorator name="change-view-integration">
-      <gr-endpoint-param name="change" value="[[_change]]"> </gr-endpoint-param>
-      <gr-endpoint-param name="revision" value="[[_selectedRevision]]">
-      </gr-endpoint-param>
-    </gr-endpoint-decorator>
-
-    <paper-tabs id="secondaryTabs">
-      <paper-tab
-        data-name$="[[_constants.SecondaryTab.CHANGE_LOG]]"
-        class="changeLog"
-      >
-        Change Log
-      </paper-tab>
-    </paper-tabs>
-    <section class="changeLog">
-      <h2 class="assistive-tech-only">Change Log</h2>
-      <gr-messages-list
-        class="hideOnMobileOverlay"
-        change="[[_change]]"
-        change-num="[[_changeNum]]"
-        labels="[[_change.labels]]"
-        messages="[[_change.messages]]"
-        reviewer-updates="[[_change.reviewer_updates]]"
-        change-comments="[[_changeComments]]"
-        project-name="[[_change.project]]"
-        show-reply-buttons="[[_loggedIn]]"
-        on-message-anchor-tap="_handleMessageAnchorTap"
-        on-reply="_handleMessageReply"
-      ></gr-messages-list>
-    </section>
-  </div>
-
-  <gr-apply-fix-dialog
-    id="applyFixDialog"
-    prefs="[[_diffPrefs]]"
-    change="[[_change]]"
-    change-num="[[_changeNum]]"
-  ></gr-apply-fix-dialog>
-  <gr-overlay id="downloadOverlay" with-backdrop="">
-    <gr-download-dialog
-      id="downloadDialog"
-      change="[[_change]]"
-      patch-num="[[_patchRange.patchNum]]"
-      config="[[_serverConfig.download]]"
-      on-close="_handleDownloadDialogClose"
-    ></gr-download-dialog>
-  </gr-overlay>
-  <gr-overlay id="includedInOverlay" with-backdrop="">
-    <gr-included-in-dialog
-      id="includedInDialog"
-      change-num="[[_changeNum]]"
-      on-close="_handleIncludedInDialogClose"
-    ></gr-included-in-dialog>
-  </gr-overlay>
-  <gr-overlay
-    id="replyOverlay"
-    class="scrollable"
-    no-cancel-on-outside-click=""
-    no-cancel-on-esc-key=""
-    scroll-action="lock"
-    with-backdrop=""
-    opened="{{replyOverlayOpened}}"
-    on-iron-overlay-canceled="onReplyOverlayCanceled"
-  >
-    <template is="dom-if" if="[[replyOverlayOpened]]">
-      <gr-reply-dialog
-        id="replyDialog"
-        change="{{_change}}"
-        patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
-        permitted-labels="[[_change.permitted_labels]]"
-        draft-comment-threads="[[_draftCommentThreads]]"
-        project-config="[[_projectConfig]]"
-        server-config="[[_serverConfig]]"
-        can-be-started="[[_canStartReview]]"
-        on-send="_handleReplySent"
-        on-cancel="_handleReplyCancel"
-        on-autogrow="_handleReplyAutogrow"
-        on-send-disabled-changed="_resetReplyOverlayFocusStops"
-        hidden$="[[!_loggedIn]]"
-      >
-      </gr-reply-dialog>
-    </template>
-  </gr-overlay>
-`;
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 72cd91e..9b78e64 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
@@ -1,53 +1,37 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import '../../edit/gr-edit-constants';
+import '../gr-thread-list/gr-thread-list';
 import './gr-change-view';
 import {
   ChangeStatus,
   CommentSide,
   DefaultBase,
   DiffViewMode,
-  HttpMethod,
   MessageTag,
-  PrimaryTab,
   createDefaultPreferences,
-  createDefaultDiffPrefs,
+  Tab,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
-import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-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 '../../../api/plugin';
-
-import 'lodash/lodash';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {PluginApi} from '../../../api/plugin';
 import {
   mockPromise,
+  pressKey,
   queryAndAssert,
-  stubComments,
+  stubFlags,
   stubRestApi,
-  stubUsers,
+  waitEventLoop,
   waitQueryAndAssert,
   waitUntil,
+  waitUntilVisible,
 } from '../../../test/test-utils';
 import {
-  createAppElementChangeViewParams,
+  createChangeViewState,
   createApproval,
   createChange,
   createChangeMessages,
@@ -65,8 +49,10 @@
   createChangeViewChange,
   createRelatedChangeAndCommitInfo,
   createAccountDetailWithId,
+  createParsedChange,
+  createDraft,
 } from '../../../test/test-data-generators';
-import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
+import {GrChangeView} from './gr-change-view';
 import {
   AccountId,
   ApprovalInfo,
@@ -74,50 +60,56 @@
   ChangeId,
   ChangeInfo,
   CommitId,
-  CommitInfo,
-  EditInfo,
-  EditPatchSetNum,
-  GitRef,
+  EDIT,
   NumericChangeId,
-  ParentPatchSetNum,
-  PatchRange,
-  PatchSetNum,
+  PARENT,
   RelatedChangeAndCommitInfo,
   ReviewInputTag,
   RevisionInfo,
   RevisionPatchSetNum,
   RobotId,
+  RobotCommentInfo,
   Timestamp,
   UrlEncodedCommentId,
+  DetailedLabelInfo,
+  RepoName,
+  QuickLabelInfo,
+  PatchSetNumber,
 } from '../../../types/common';
-import {
-  pressAndReleaseKeyOn,
-  tap,
-} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
-import {AppElementChangeViewParams} from '../../gr-app-types';
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {CommentThread, UIRobot} from '../../../utils/comment-util';
+import {CommentThread} from '../../../utils/comment-util';
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
-import {appContext} from '../../../services/app-context';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {_testOnly_setState} from '../../../services/user/user-model';
+import {
+  ChangeModel,
+  changeModelToken,
+  LoadingStatus,
+} from '../../../models/change/change-model';
 import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-const fixture = fixtureFromElement('gr-change-view');
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {assertIsDefined} from '../../../utils/common-util';
+import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list';
+import {fixture, html, assert} from '@open-wc/testing';
+import {deepClone} from '../../../utils/deep-util';
+import {Modifier} from '../../../utils/dom-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
+import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
+import {rootUrl} from '../../../utils/url-util';
+import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 suite('gr-change-view tests', () => {
   let element: GrChangeView;
-
-  let navigateToChangeStub: SinonStubbedMember<
-    typeof GerritNav.navigateToChange
-  >;
+  let setUrlStub: sinon.SinonStub;
+  let userModel: UserModel;
+  let changeModel: ChangeModel;
 
   const ROBOT_COMMENTS_LIMIT = 10;
 
@@ -132,7 +124,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 2 as PatchSetNum,
+          patch_set: 2 as RevisionPatchSetNum,
           robot_id: 'rb1' as RobotId,
           id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
           line: 5,
@@ -147,7 +139,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 4 as PatchSetNum,
+          patch_set: 4 as RevisionPatchSetNum,
           id: 'ecf0b9fa_fe1a5f62_1' as UrlEncodedCommentId,
           line: 5,
           updated: '2018-02-08 18:49:18.000000000' as Timestamp,
@@ -163,9 +155,7 @@
           message: 'draft',
           unresolved: false,
           __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
-          patch_set: 2 as PatchSetNum,
+          patch_set: 2 as RevisionPatchSetNum,
         },
       ],
       patchNum: 4 as RevisionPatchSetNum,
@@ -183,7 +173,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 3 as PatchSetNum,
+          patch_set: 3 as RevisionPatchSetNum,
           id: 'ecf0b9fa_fe5f62' as UrlEncodedCommentId,
           robot_id: 'rb2' as RobotId,
           line: 5,
@@ -198,7 +188,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 3 as PatchSetNum,
+          patch_set: 3 as RevisionPatchSetNum,
           id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
           side: CommentSide.PARENT,
           updated: '2018-02-13 22:47:19.000000000' as Timestamp,
@@ -206,7 +196,7 @@
           unresolved: false,
         },
       ],
-      patchNum: 3 as PatchSetNum,
+      patchNum: 3 as RevisionPatchSetNum,
       path: 'test.txt',
       rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
       commentSide: CommentSide.PARENT,
@@ -220,7 +210,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 2 as PatchSetNum,
+          patch_set: 2 as RevisionPatchSetNum,
           id: '8caddf38_44770ec1' as UrlEncodedCommentId,
           line: 4,
           updated: '2018-02-13 22:48:40.000000000' as Timestamp,
@@ -239,11 +229,12 @@
         {
           path: '/COMMIT_MSG',
           author: {
+            // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
             _account_id: 1000000 as AccountId,
             name: 'user',
             username: 'user',
           },
-          patch_set: 2 as PatchSetNum,
+          patch_set: 2 as RevisionPatchSetNum,
           id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
           line: 4,
           updated: '2018-02-14 22:48:40.000000000' as Timestamp,
@@ -267,9 +258,7 @@
           message: 'resolved draft',
           unresolved: false,
           __draft: true,
-          __draftID: '0.m683trwff68',
-          __editing: false,
-          patch_set: 2 as PatchSetNum,
+          patch_set: 2 as RevisionPatchSetNum,
         },
       ],
       patchNum: 4 as RevisionPatchSetNum,
@@ -287,7 +276,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 4 as PatchSetNum,
+          patch_set: 4 as RevisionPatchSetNum,
           id: 'rc1' as UrlEncodedCommentId,
           line: 5,
           updated: '2019-02-08 18:49:18.000000000' as Timestamp,
@@ -311,7 +300,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 4 as PatchSetNum,
+          patch_set: 4 as RevisionPatchSetNum,
           id: 'rc2' as UrlEncodedCommentId,
           line: 5,
           updated: '2019-03-08 18:49:18.000000000' as Timestamp,
@@ -326,7 +315,7 @@
             name: 'user',
             username: 'user',
           },
-          patch_set: 4 as PatchSetNum,
+          patch_set: 4 as RevisionPatchSetNum,
           id: 'c2_1' as UrlEncodedCommentId,
           line: 5,
           updated: '2019-03-08 18:49:18.000000000' as Timestamp,
@@ -342,10 +331,8 @@
     },
   ];
 
-  setup(() => {
-    // Since pluginEndpoints are global, must reset state.
-    _testOnly_resetEndpoints();
-    navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+  setup(async () => {
+    setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
 
     stubRestApi('getConfig').returns(
       Promise.resolve({
@@ -362,11 +349,8 @@
     stubRestApi('getDiffComments').returns(Promise.resolve({}));
     stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-    element = fixture.instantiate();
-    element._changeNum = TEST_NUMERIC_CHANGE_ID;
-    sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
-    getPluginLoader().loadPlugins([]);
-    pluginApi.install(
+
+    window.Gerrit.install(
       plugin => {
         plugin.registerDynamicCustomComponent(
           'change-view-tab-header',
@@ -380,112 +364,273 @@
       '0.1',
       'http://some/plugins/url.js'
     );
+    element = await fixture<GrChangeView>(
+      html`<gr-change-view></gr-change-view>`
+    );
+    element.viewState = {
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
+      changeNum: TEST_NUMERIC_CHANGE_ID,
+      repo: 'gerrit' as RepoName,
+    };
+    await element.updateComplete.then(() => {
+      assertIsDefined(element.actions);
+      sinon.stub(element.actions, 'reload').returns(Promise.resolve());
+    });
+    userModel = testResolver(userModelToken);
+    changeModel = testResolver(changeModelToken);
   });
 
   teardown(async () => {
-    await flush();
+    await element.updateComplete;
   });
 
-  test('_handleMessageAnchorTap', () => {
-    element._changeNum = 1 as NumericChangeId;
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container loading">Loading...</div>
+        <div aria-hidden="false" class="container" hidden="" id="mainContent">
+          <section class="changeInfoSection">
+            <div class="header">
+              <h1 class="assistive-tech-only">Change :</h1>
+              <div class="headerTitle">
+                <div class="changeStatuses"></div>
+                <gr-button
+                  aria-disabled="false"
+                  class="showCopyLinkDialogButton"
+                  down-arrow=""
+                  flatten=""
+                  role="button"
+                  tabindex="0"
+                  ><gr-change-star id="changeStar"> </gr-change-star>
+                  <a aria-label="Change undefined" class="changeNumber"> </a>
+                </gr-button>
+                <span class="headerSubject"> </span>
+                <gr-copy-clipboard
+                  class="changeCopyClipboard"
+                  hideinput=""
+                  text="undefined: undefined | http://localhost:9876undefined"
+                >
+                </gr-copy-clipboard>
+              </div>
+              <div class="commitActions">
+                <gr-change-actions hidden="" id="actions"> </gr-change-actions>
+              </div>
+            </div>
+            <h2 class="assistive-tech-only">Change metadata</h2>
+            <div class="changeInfo">
+              <div class="changeInfo-column changeMetadata">
+                <gr-change-metadata id="metadata"> </gr-change-metadata>
+              </div>
+              <div class="changeInfo-column mainChangeInfo" id="mainChangeInfo">
+                <div id="commitAndRelated">
+                  <div class="commitContainer">
+                    <h3 class="assistive-tech-only">Commit Message</h3>
+                    <div>
+                      <gr-button
+                        aria-disabled="false"
+                        class="reply"
+                        id="replyBtn"
+                        primary=""
+                        role="button"
+                        tabindex="0"
+                        title="Open reply dialog to publish comments and add reviewers (shortcut: a)"
+                      >
+                        Reply
+                      </gr-button>
+                    </div>
+                    <div class="commitMessage" id="commitMessage">
+                      <gr-editable-content
+                        id="commitMessageEditor"
+                        remove-zero-width-space=""
+                      >
+                        <gr-formatted-text></gr-formatted-text>
+                      </gr-editable-content>
+                    </div>
+                    <h3 class="assistive-tech-only">
+                      Comments and Checks Summary
+                    </h3>
+                    <gr-change-summary> </gr-change-summary>
+                    <gr-endpoint-decorator name="commit-container">
+                      <gr-endpoint-param name="change"> </gr-endpoint-param>
+                      <gr-endpoint-param name="revision"> </gr-endpoint-param>
+                    </gr-endpoint-decorator>
+                  </div>
+                  <div class="relatedChanges">
+                    <gr-related-changes-list id="relatedChanges">
+                    </gr-related-changes-list>
+                  </div>
+                  <div class="emptySpace"></div>
+                </div>
+              </div>
+            </div>
+          </section>
+          <h2 class="assistive-tech-only">Files and Comments tabs</h2>
+          <paper-tabs dir="null" id="tabs" role="tablist" tabindex="0">
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="true"
+              class="iron-selected"
+              data-name="files"
+              role="tab"
+              tabindex="0"
+            >
+              <span> Files </span>
+            </paper-tab>
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="false"
+              class="commentThreads"
+              data-name="comments"
+              role="tab"
+              tabindex="-1"
+            >
+              <gr-tooltip-content has-tooltip="" title="">
+                <span> Comments </span>
+              </gr-tooltip-content>
+            </paper-tab>
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="false"
+              data-name="change-view-tab-header-url"
+              role="tab"
+              tabindex="-1"
+            >
+              <gr-endpoint-decorator name="change-view-tab-header-url">
+                <gr-endpoint-param name="change"> </gr-endpoint-param>
+                <gr-endpoint-param name="revision"> </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </paper-tab>
+          </paper-tabs>
+          <section class="tabContent">
+            <div>
+              <gr-file-list-header id="fileListHeader"> </gr-file-list-header>
+              <gr-file-list id="fileList"> </gr-file-list>
+            </div>
+          </section>
+          <gr-endpoint-decorator name="change-view-integration">
+            <gr-endpoint-param name="change"> </gr-endpoint-param>
+            <gr-endpoint-param name="revision"> </gr-endpoint-param>
+          </gr-endpoint-decorator>
+          <paper-tabs dir="null" role="tablist" tabindex="0">
+            <paper-tab
+              aria-disabled="false"
+              aria-selected="false"
+              class="changeLog"
+              data-name="_changeLog"
+              role="tab"
+              tabindex="-1"
+            >
+              Change Log
+            </paper-tab>
+          </paper-tabs>
+          <section class="changeLog">
+            <h2 class="assistive-tech-only">Change Log</h2>
+            <gr-messages-list> </gr-messages-list>
+          </section>
+        </div>
+        <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
+        <dialog id="downloadModal" tabindex="-1">
+          <gr-download-dialog id="downloadDialog" role="dialog">
+          </gr-download-dialog>
+        </dialog>
+        <dialog id="includedInModal" tabindex="-1">
+          <gr-included-in-dialog id="includedInDialog"> </gr-included-in-dialog>
+        </dialog>
+        <dialog id="replyModal"></dialog>
+      `
+    );
+  });
+
+  test('handleMessageAnchorTap', async () => {
+    element.changeNum = 1 as NumericChangeId;
+    element.patchRange = {
+      basePatchNum: PARENT,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._change = createChangeViewChange();
-    const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
+    element.change = createChangeViewChange();
+    await element.updateComplete;
     const replaceStateStub = sinon.stub(history, 'replaceState');
-    element._handleMessageAnchorTap(
+    element.handleMessageAnchorTap(
       new CustomEvent('message-anchor-tap', {detail: {id: 'a12345'}})
     );
 
-    assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
     assert.isTrue(replaceStateStub.called);
   });
 
-  test('_handleDiffAgainstBase', () => {
-    element._change = {
+  test('handleDiffAgainstBase', () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element._patchRange = {
+    element.patchRange = {
       patchNum: 3 as RevisionPatchSetNum,
       basePatchNum: 1 as BasePatchSetNum,
     };
-    element._handleDiffAgainstBase();
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[0], element._change);
-    assert.equal(args[1], 3 as PatchSetNum);
+    element.handleDiffAgainstBase();
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3');
   });
 
-  test('_handleDiffAgainstLatest', () => {
-    element._change = {
+  test('handleDiffAgainstLatest', () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element._patchRange = {
+    element.patchRange = {
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    element._handleDiffAgainstLatest();
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[0], element._change);
-    assert.equal(args[1], 10 as PatchSetNum);
-    assert.equal(args[2], 1 as BasePatchSetNum);
+    element.handleDiffAgainstLatest();
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..10');
   });
 
-  test('_handleDiffBaseAgainstLeft', () => {
-    element._change = {
+  test('handleDiffBaseAgainstLeft', () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element._patchRange = {
+    element.patchRange = {
       patchNum: 3 as RevisionPatchSetNum,
       basePatchNum: 1 as BasePatchSetNum,
     };
-    element._handleDiffBaseAgainstLeft();
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[0], element._change);
-    assert.equal(args[1], 1 as PatchSetNum);
+    element.handleDiffBaseAgainstLeft();
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
   });
 
-  test('_handleDiffRightAgainstLatest', () => {
-    element._change = {
+  test('handleDiffRightAgainstLatest', () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element._patchRange = {
+    element.patchRange = {
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    element._handleDiffRightAgainstLatest();
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[1], 10 as PatchSetNum);
-    assert.equal(args[2], 3 as BasePatchSetNum);
+    element.handleDiffRightAgainstLatest();
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3..10');
   });
 
-  test('_handleDiffBaseAgainstLatest', () => {
-    element._change = {
+  test('handleDiffBaseAgainstLatest', () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
-    element._patchRange = {
+    element.patchRange = {
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    element._handleDiffBaseAgainstLatest();
-    assert(navigateToChangeStub.called);
-    const args = navigateToChangeStub.getCall(0).args;
-    assert.equal(args[1], 10 as PatchSetNum);
-    assert.isNotOk(args[2]);
+    element.handleDiffBaseAgainstLatest();
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/10');
   });
 
   test('toggle attention set status', async () => {
-    element._change = {
+    element.change = {
       ...createChangeViewChange(),
       revisions: createRevisions(10),
     };
@@ -496,56 +641,53 @@
     const removeFromAttentionSetStub = stubRestApi(
       'removeFromAttentionSet'
     ).returns(Promise.resolve(new Response()));
-    element._patchRange = {
+    element.patchRange = {
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-
-    assert.isNotOk(element._change.attention_set);
-    await element._getLoggedIn();
-    await element.restApiService.getAccount();
-    element._handleToggleAttentionSet();
+    await element.updateComplete;
+    assert.isNotOk(element.change.attention_set);
+    element.handleToggleAttentionSet();
     assert.isTrue(addToAttentionSetStub.called);
     assert.isFalse(removeFromAttentionSetStub.called);
 
-    element._handleToggleAttentionSet();
+    element.handleToggleAttentionSet();
     assert.isTrue(removeFromAttentionSetStub.called);
   });
 
   suite('plugins adding to file tab', () => {
     setup(async () => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      // Resolving it here instead of during setup() as other tests depend
-      // on flush() not being called during setup.
-      await flush();
+      element.changeNum = TEST_NUMERIC_CHANGE_ID;
+      await element.updateComplete;
+      await waitUntil(() => element.pluginTabsHeaderEndpoints.length > 0);
     });
 
-    test('plugin added tab shows up as a dynamic endpoint', () => {
+    test('plugin added tab shows up as a dynamic endpoint', async () => {
       assert(
-        element._dynamicTabHeaderEndpoints.includes(
-          'change-view-tab-header-url'
-        )
+        element.pluginTabsHeaderEndpoints.includes('change-view-tab-header-url')
       );
-      const primaryTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
-      const paperTabs = primaryTabs.querySelectorAll<HTMLElement>('paper-tab');
-      // 4 Tabs are : Files, Comment Threads, Plugin, Findings
-      assert.equal(primaryTabs.querySelectorAll('paper-tab').length, 4);
+      const tabs = element.shadowRoot!.querySelector('#tabs')!;
+      const paperTabs = tabs.querySelectorAll<HTMLElement>('paper-tab');
+      // 4 Tabs are : Files, Comment Threads, Plugin
+      assert.equal(tabs.querySelectorAll('paper-tab').length, 3);
+      assert.equal(paperTabs[0].dataset.name, 'files');
+      assert.equal(paperTabs[1].dataset.name, 'comments');
       assert.equal(paperTabs[2].dataset.name, 'change-view-tab-header-url');
     });
 
-    test('_setActivePrimaryTab switched tab correctly', async () => {
-      element._setActivePrimaryTab(
+    test('setActiveTab switched tab correctly', async () => {
+      element.setActiveTab(
         new CustomEvent('', {
           detail: {tab: 'change-view-tab-header-url'},
         })
       );
-      await flush();
-      assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+      await element.updateComplete;
+      assert.equal(element.activeTab, 'change-view-tab-header-url');
     });
 
-    test('show-primary-tab switched primary tab correctly', async () => {
+    test('show-tab switched primary tab correctly', async () => {
       element.dispatchEvent(
-        new CustomEvent('show-primary-tab', {
+        new CustomEvent('show-tab', {
           composed: true,
           bubbles: true,
           detail: {
@@ -553,42 +695,49 @@
           },
         })
       );
-      await flush();
-      assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+      await element.updateComplete;
+      assert.equal(element.activeTab, 'change-view-tab-header-url');
     });
 
     test('param change should switch primary tab correctly', async () => {
-      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      assert.equal(element.activeTab, Tab.FILES);
       // view is required
-      element._changeNum = undefined;
-      element.params = {
-        ...createAppElementChangeViewParams(),
-        ...element.params,
-        tab: PrimaryTab.FINDINGS,
+      element.changeNum = undefined;
+      element.viewState = {
+        ...createChangeViewState(),
+        ...element.viewState,
+        tab: Tab.COMMENT_THREADS,
       };
-      await flush();
-      assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
+      await element.updateComplete;
+      assert.equal(element.activeTab, Tab.COMMENT_THREADS);
     });
 
     test('invalid param change should not switch primary tab', async () => {
-      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      assert.equal(element.activeTab, Tab.FILES);
       // view is required
-      element.params = {
-        ...createAppElementChangeViewParams(),
-        ...element.params,
+      element.viewState = {
+        ...createChangeViewState(),
+        ...element.viewState,
         tab: 'random',
       };
-      await flush();
-      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+      await element.updateComplete;
+      assert.equal(element.activeTab, Tab.FILES);
     });
 
-    test('switching tab sets _selectedTabPluginEndpoint', async () => {
-      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
-      tap(paperTabs.querySelectorAll('paper-tab')[2]);
-      await flush();
-      assert.equal(
-        element._selectedTabPluginEndpoint,
-        'change-view-tab-content-url'
+    test('switching to plugin tab renders the plugin tab content', async () => {
+      const paperTabs = element.shadowRoot!.querySelector('#tabs')!;
+      paperTabs.querySelectorAll('paper-tab')[2].click();
+      await element.updateComplete;
+      const tabContent = queryAndAssert(element, '.tabContent');
+      const endpoint = queryAndAssert(tabContent, 'gr-endpoint-decorator');
+      assert.dom.equal(
+        endpoint,
+        /* HTML */ `
+          <gr-endpoint-decorator>
+            <gr-endpoint-param name="change"></gr-endpoint-param>
+            <gr-endpoint-param name="revision"></gr-endpoint-param>
+          </gr-endpoint-decorator>
+        `
       );
     });
   });
@@ -605,154 +754,107 @@
     });
 
     test('t to add topic', () => {
-      const editStub = sinon.stub(element.$.metadata, 'editTopic');
-      pressAndReleaseKeyOn(element, 83, null, 't');
+      assertIsDefined(element.metadata);
+      const editStub = sinon.stub(element.metadata, 'editTopic');
+      pressKey(element, 't');
       assert(editStub.called);
     });
 
     test('S should toggle the CL star', () => {
-      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
-      pressAndReleaseKeyOn(element, 83, null, 's');
+      assertIsDefined(element.changeStar);
+      const starStub = sinon.stub(element.changeStar, 'toggleStar');
+      pressKey(element, 's');
       assert(starStub.called);
     });
 
     test('toggle star is throttled', () => {
-      const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
-      pressAndReleaseKeyOn(element, 83, null, 's');
+      assertIsDefined(element.changeStar);
+      const starStub = sinon.stub(element.changeStar, 'toggleStar');
+      pressKey(element, 's');
       assert(starStub.called);
-      pressAndReleaseKeyOn(element, 83, null, 's');
+      pressKey(element, 's');
       assert.equal(starStub.callCount, 1);
       clock.tick(1000);
-      pressAndReleaseKeyOn(element, 83, null, 's');
+      pressKey(element, 's');
       assert.equal(starStub.callCount, 2);
     });
 
     test('U should navigate to root if no backPage set', () => {
-      const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert.isTrue(relativeNavStub.called);
-      assert.isTrue(
-        relativeNavStub.lastCall.calledWithExactly(GerritNav.getUrlForRoot())
-      );
+      pressKey(element, 'u');
+      assert.isTrue(setUrlStub.called);
+      assert.isTrue(setUrlStub.lastCall.calledWithExactly(rootUrl()));
     });
 
     test('U should navigate to backPage if set', () => {
-      const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
       element.backPage = '/dashboard/self';
-      pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert.isTrue(relativeNavStub.called);
-      assert.isTrue(
-        relativeNavStub.lastCall.calledWithExactly('/dashboard/self')
-      );
+      pressKey(element, 'u');
+      assert.isTrue(setUrlStub.called);
+      assert.isTrue(setUrlStub.lastCall.calledWithExactly('/dashboard/self'));
     });
 
     test('A fires an error event when not logged in', async () => {
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+      userModel.setAccount(undefined);
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      pressAndReleaseKeyOn(element, 65, null, 'a');
-      await flush();
-      assert.isFalse(element.$.replyOverlay.opened);
+      pressKey(element, 'a');
+      await element.updateComplete;
+      assertIsDefined(element.replyModal);
+      assert.isFalse(element.replyModalOpened);
       assert.isTrue(loggedInErrorSpy.called);
     });
 
     test('shift A does not open reply overlay', async () => {
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-      await flush();
-      assert.isFalse(element.$.replyOverlay.opened);
+      pressKey(element, 'a', Modifier.SHIFT_KEY);
+      await element.updateComplete;
+      assertIsDefined(element.replyModal);
+      assert.isFalse(element.replyModalOpened);
     });
 
     test('A toggles overlay when logged in', async () => {
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      element._change = {
+      // restore clock so that setTimeout in waitUntil() works as expected
+      clock.restore();
+      stubRestApi('getChangeDetail').returns(
+        Promise.resolve(createParsedChange())
+      );
+      sinon.stub(element, 'performPostChangeLoadTasks');
+      sinon.stub(element, 'getMergeability');
+      const change = {
         ...createChangeViewChange(),
         revisions: createRevisions(1),
         messages: createChangeMessages(1),
       };
-      element._change.labels = {};
-      stubRestApi('getChangeDetail').callsFake(() =>
-        Promise.resolve({
-          ...createChangeViewChange(),
-          // element has latest info
-          revisions: createRevisions(1),
-          messages: createChangeMessages(1),
-          current_revision: 'rev1' as CommitId,
-        })
-      );
+      change.labels = {};
+      element.change = change;
 
-      const openSpy = sinon.spy(element, '_openReplyDialog');
+      changeModel.setState({
+        loadingStatus: LoadingStatus.LOADED,
+        change,
+      });
 
-      pressAndReleaseKeyOn(element, 65, null, 'a');
-      await flush();
-      assert.isTrue(element.$.replyOverlay.opened);
-      element.$.replyOverlay.close();
-      assert.isFalse(element.$.replyOverlay.opened);
+      await element.updateComplete;
+
+      const openSpy = sinon.spy(element, 'openReplyDialog');
+
+      pressKey(element, 'a');
+      await element.updateComplete;
+      assertIsDefined(element.replyModal);
+      assert.isTrue(element.replyModalOpened);
+      sinon.spy(element.replyDialog!, 'open');
+      await waitUntilVisible(element.replyDialog!);
+      element.replyModal.close();
       assert(
         openSpy.lastCall.calledWithExactly(FocusTarget.ANY),
-        '_openReplyDialog should have been passed ANY'
+        'openReplyDialog should have been passed ANY'
       );
       assert.equal(openSpy.callCount, 1);
-    });
-
-    test('fullscreen-overlay-opened hides content', () => {
-      element._loggedIn = true;
-      element._loading = false;
-      element._change = {
-        ...createChangeViewChange(),
-        labels: {},
-        actions: {
-          abandon: {
-            enabled: true,
-            label: 'Abandon',
-            method: HttpMethod.POST,
-            title: 'Abandon',
-          },
-        },
-      };
-      const handlerSpy = sinon.spy(element, '_handleHideBackgroundContent');
-      const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
-      overlay.dispatchEvent(
-        new CustomEvent('fullscreen-overlay-opened', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-      assert.equal(getComputedStyle(element.$.actions).display, 'flex');
-    });
-
-    test('fullscreen-overlay-closed shows content', () => {
-      element._loggedIn = true;
-      element._loading = false;
-      element._change = {
-        ...createChangeViewChange(),
-        labels: {},
-        actions: {
-          abandon: {
-            enabled: true,
-            label: 'Abandon',
-            method: HttpMethod.POST,
-            title: 'Abandon',
-          },
-        },
-      };
-      const handlerSpy = sinon.spy(element, '_handleShowBackgroundContent');
-      const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
-      overlay.dispatchEvent(
-        new CustomEvent('fullscreen-overlay-closed', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(handlerSpy.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+      await waitUntil(() => !element.replyModalOpened);
     });
 
     test('expand all messages when expand-diffs fired', () => {
-      const handleExpand = sinon.stub(element.$.fileList, 'expandAllDiffs');
-      element.$.fileListHeader.dispatchEvent(
+      assertIsDefined(element.fileList);
+      assertIsDefined(element.fileListHeader);
+      const handleExpand = sinon.stub(element.fileList, 'expandAllDiffs');
+      element.fileListHeader.dispatchEvent(
         new CustomEvent('expand-diffs', {
           composed: true,
           bubbles: true,
@@ -762,8 +864,10 @@
     });
 
     test('collapse all messages when collapse-diffs fired', () => {
-      const handleCollapse = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-      element.$.fileListHeader.dispatchEvent(
+      assertIsDefined(element.fileList);
+      assertIsDefined(element.fileListHeader);
+      const handleCollapse = sinon.stub(element.fileList, 'collapseAllDiffs');
+      element.fileListHeader.dispatchEvent(
         new CustomEvent('collapse-diffs', {
           composed: true,
           bubbles: true,
@@ -773,60 +877,56 @@
     });
 
     test('X should expand all messages', async () => {
-      await flush();
+      await element.updateComplete;
       const handleExpand = sinon.stub(
         element.messagesList!,
         'handleExpandCollapse'
       );
-      pressAndReleaseKeyOn(element, 88, null, 'x');
+      pressKey(element, 'x');
       assert(handleExpand.calledWith(true));
     });
 
     test('Z should collapse all messages', async () => {
-      await flush();
+      await element.updateComplete;
       const handleExpand = sinon.stub(
         element.messagesList!,
         'handleExpandCollapse'
       );
-      pressAndReleaseKeyOn(element, 90, null, 'z');
+      pressKey(element, 'z');
       assert(handleExpand.calledWith(false));
     });
 
     test('d should open download overlay', () => {
-      const stub = sinon
-        .stub(element.$.downloadOverlay, 'open')
-        .returns(Promise.resolve());
-      pressAndReleaseKeyOn(element, 68, null, 'd');
+      assertIsDefined(element.downloadModal);
+      const stub = sinon.stub(element.downloadModal, 'showModal');
+      pressKey(element, 'd');
       assert.isTrue(stub.called);
     });
 
-    test(', should open diff preferences', () => {
-      const stub = sinon.stub(
-        element.$.fileList.$.diffPreferencesDialog,
-        'open'
-      );
-      element._loggedIn = false;
-      pressAndReleaseKeyOn(element, 188, null, ',');
+    test(', should open diff preferences', async () => {
+      assertIsDefined(element.fileList);
+      await element.fileList.updateComplete;
+      assertIsDefined(element.fileList.diffPreferencesDialog);
+      const stub = sinon.stub(element.fileList.diffPreferencesDialog, 'open');
+      element.loggedIn = false;
+      pressKey(element, ',');
       assert.isFalse(stub.called);
 
-      element._loggedIn = true;
-      pressAndReleaseKeyOn(element, 188, null, ',');
+      element.loggedIn = true;
+      pressKey(element, ',');
       assert.isTrue(stub.called);
     });
 
     test('m should toggle diff mode', async () => {
-      const updatePreferencesStub = stubUsers('updatePreferences');
-      await flush();
+      const updatePreferencesStub = sinon.stub(userModel, 'updatePreferences');
+      await element.updateComplete;
 
       const prefs = {
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       };
-      _testOnly_setState({
-        preferences: prefs,
-        diffPreferences: createDefaultDiffPrefs(),
-      });
-      element._handleToggleDiffMode();
+      userModel.setPreferences(prefs);
+      element.handleToggleDiffMode();
       assert.isTrue(
         updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
       );
@@ -835,12 +935,9 @@
         ...createDefaultPreferences(),
         diff_view: DiffViewMode.UNIFIED,
       };
-      _testOnly_setState({
-        preferences: newPrefs,
-        diffPreferences: createDefaultDiffPrefs(),
-      });
-      await flush();
-      element._handleToggleDiffMode();
+      userModel.setPreferences(newPrefs);
+      await element.updateComplete;
+      element.handleToggleDiffMode();
       assert.isTrue(
         updatePreferencesStub.calledWith({diff_view: DiffViewMode.SIDE_BY_SIDE})
       );
@@ -849,12 +946,12 @@
 
   suite('thread list and change log tabs', () => {
     setup(() => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._patchRange = {
-        basePatchNum: ParentPatchSetNum,
+      element.changeNum = TEST_NUMERIC_CHANGE_ID;
+      element.patchRange = {
+        basePatchNum: PARENT,
         patchNum: 1 as RevisionPatchSetNum,
       };
-      element._change = {
+      element.change = {
         ...createChangeViewChange(),
         revisions: {
           rev2: createRevision(2),
@@ -878,15 +975,15 @@
       ) as GrRelatedChangesList;
       sinon.stub(relatedChanges, 'reload');
       sinon.stub(element, 'loadData').returns(Promise.resolve());
-      sinon.spy(element, '_paramsChanged');
-      element.params = createAppElementChangeViewParams();
+      sinon.spy(element, 'viewStateChanged');
+      element.viewState = createChangeViewState();
     });
   });
 
-  suite('Findings comment tab', () => {
+  suite('Comments tab', () => {
     setup(async () => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._change = {
+      element.changeNum = TEST_NUMERIC_CHANGE_ID;
+      element.change = {
         ...createChangeViewChange(),
         revisions: {
           rev2: createRevision(2),
@@ -897,15 +994,58 @@
         },
         current_revision: 'rev4' as CommitId,
       };
-      element._commentThreads = THREADS;
-      await flush();
-      const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
-      tap(paperTabs.querySelectorAll('paper-tab')[3]);
-      await flush();
+      element.commentThreads = THREADS;
+      await element.updateComplete;
+      const paperTabs = element.shadowRoot!.querySelector('#tabs')!;
+      const tabs = paperTabs.querySelectorAll('paper-tab');
+      assert.isTrue(tabs.length > 1);
+      assert.equal(tabs[1].dataset.name, 'comments');
+      tabs[1].click();
+      await element.updateComplete;
+    });
+
+    test('commentId overrides unresolveOnly default', async () => {
+      const threadList = queryAndAssert<GrThreadList>(
+        element,
+        'gr-thread-list'
+      );
+      assert.isTrue(element.unresolvedOnly);
+      assert.isNotOk(element.scrollCommentId);
+      assert.isTrue(threadList.unresolvedOnly);
+
+      element.scrollCommentId = 'abcd' as UrlEncodedCommentId;
+      await element.updateComplete;
+      assert.isFalse(threadList.unresolvedOnly);
+    });
+  });
+
+  suite('Findings robot-comment tab', () => {
+    setup(async () => {
+      element.changeNum = TEST_NUMERIC_CHANGE_ID;
+      element.change = {
+        ...createChangeViewChange(),
+        revisions: {
+          rev2: createRevision(2),
+          rev1: createRevision(1),
+          rev13: createRevision(13),
+          rev3: createRevision(3),
+          rev4: createRevision(4),
+        },
+        current_revision: 'rev4' as CommitId,
+      };
+      element.commentThreads = THREADS;
+      element.showFindingsTab = true;
+      await element.updateComplete;
+      const paperTabs = element.shadowRoot!.querySelector('#tabs')!;
+      const tabs = paperTabs.querySelectorAll('paper-tab');
+      assert.isTrue(tabs.length > 3);
+      assert.equal(tabs[3].dataset.name, 'findings');
+      tabs[3].click();
+      await element.updateComplete;
     });
 
     test('robot comments count per patchset', () => {
-      const count = element._robotCommentCountPerPatchSet(THREADS);
+      const count = element.robotCommentCountPerPatchSet(THREADS);
       const expectedCount = {
         2: 1,
         3: 1,
@@ -913,35 +1053,44 @@
       };
       assert.deepEqual(count, expectedCount);
       assert.equal(
-        element._computeText(createRevision(2), THREADS),
+        element.computeText(createRevision(2), THREADS),
         'Patchset 2 (1 finding)'
       );
       assert.equal(
-        element._computeText(createRevision(4), THREADS),
+        element.computeText(createRevision(4), THREADS),
         'Patchset 4 (2 findings)'
       );
       assert.equal(
-        element._computeText(createRevision(5), THREADS),
+        element.computeText(createRevision(5), THREADS),
         'Patchset 5'
       );
     });
 
     test('only robot comments are rendered', () => {
-      assert.equal(element._robotCommentThreads!.length, 2);
+      assert.equal(element.computeRobotCommentThreads().length, 2);
       assert.equal(
-        (element._robotCommentThreads![0].comments[0] as UIRobot).robot_id,
+        (
+          element.computeRobotCommentThreads()[0]
+            .comments[0] as RobotCommentInfo
+        ).robot_id,
         'rc1'
       );
       assert.equal(
-        (element._robotCommentThreads![1].comments[0] as UIRobot).robot_id,
+        (
+          element.computeRobotCommentThreads()[1]
+            .comments[0] as RobotCommentInfo
+        ).robot_id,
         'rc2'
       );
     });
 
     test('changing patchsets resets robot comments', async () => {
-      element.set('_change.current_revision', 'rev3');
-      await flush();
-      assert.equal(element._robotCommentThreads!.length, 1);
+      assertIsDefined(element.change);
+      const newChange = {...element.change};
+      newChange.current_revision = 'rev3' as CommitId;
+      element.change = newChange;
+      await element.updateComplete;
+      assert.equal(element.computeRobotCommentThreads().length, 1);
     });
 
     test('Show more button is hidden', () => {
@@ -954,35 +1103,42 @@
         for (let i = 0; i <= 30; i++) {
           arr.push(...THREADS);
         }
-        element._commentThreads = arr;
-        await flush();
+        element.commentThreads = arr;
+        await element.updateComplete;
       });
 
       test('Show more button is rendered', () => {
         assert.isOk(element.shadowRoot!.querySelector('.show-robot-comments'));
         assert.equal(
-          element._robotCommentThreads!.length,
+          element.computeRobotCommentThreads().length,
           ROBOT_COMMENTS_LIMIT
         );
       });
 
       test('Clicking show more button renders all comments', async () => {
-        tap(element.shadowRoot!.querySelector('.show-robot-comments')!);
-        await flush();
-        assert.equal(element._robotCommentThreads!.length, 62);
+        element
+          .shadowRoot!.querySelector<GrButton>('.show-robot-comments')!
+          .click();
+        await element.updateComplete;
+        assert.equal(element.computeRobotCommentThreads().length, 62);
       });
     });
   });
 
-  test('reply button is not visible when logged out', () => {
-    assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
-    element._loggedIn = true;
-    assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
+  test('reply button is not visible when logged out', async () => {
+    assertIsDefined(element.replyBtn);
+    element.loggedIn = false;
+    await element.updateComplete;
+    assert.equal(getComputedStyle(element.replyBtn).display, 'none');
+    element.loggedIn = true;
+    await element.updateComplete;
+    assert.notEqual(getComputedStyle(element.replyBtn).display, 'none');
   });
 
-  test('download tap calls _handleOpenDownloadDialog', () => {
-    const openDialogStub = sinon.stub(element, '_handleOpenDownloadDialog');
-    element.$.actions.dispatchEvent(
+  test('download tap calls handleOpenDownloadDialog', () => {
+    assertIsDefined(element.actions);
+    const openDialogStub = sinon.stub(element, 'handleOpenDownloadDialog');
+    element.actions.dispatchEvent(
       new CustomEvent('download-tap', {
         composed: true,
         bubbles: true,
@@ -992,16 +1148,16 @@
   });
 
   test('fetches the server config on attached', async () => {
-    await flush();
+    await element.updateComplete;
     assert.equal(
-      element._serverConfig!.user.anonymous_coward_name,
+      element.serverConfig!.user.anonymous_coward_name,
       'test coward name'
     );
   });
 
-  test('_changeStatuses', () => {
-    element._loading = false;
-    element._change = {
+  test('changeStatuses', async () => {
+    element.loading = false;
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         rev2: createRevision(2),
@@ -1011,7 +1167,6 @@
       },
       current_revision: 'rev3' as CommitId,
       status: ChangeStatus.MERGED,
-      work_in_progress: true,
       labels: {
         test: {
           all: [],
@@ -1021,19 +1176,19 @@
         },
       },
     };
-    element._mergeable = true;
-    const expectedStatuses = [ChangeStates.MERGED, ChangeStates.WIP];
-    assert.deepEqual(element._changeStatuses, expectedStatuses);
-    flush();
+    element.mergeable = true;
+    await element.updateComplete;
+    const expectedStatuses = [ChangeStates.MERGED];
+    assert.deepEqual(element.changeStatuses, expectedStatuses);
     const statusChips =
       element.shadowRoot!.querySelectorAll('gr-change-status');
-    assert.equal(statusChips.length, 2);
+    assert.equal(statusChips.length, 1);
   });
 
   suite('ChangeStatus revert', () => {
     test('do not show any chip if no revert created', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       const getChangeStub = stubRestApi('getChange');
@@ -1047,23 +1202,24 @@
           ...createChange(),
         })
       );
-      element._change = change;
-      element._mergeable = true;
-      element._submitEnabled = true;
-      await flush();
-      element.computeRevertSubmitted(element._change);
-      await flush();
+      element.change = change;
+      element.mergeable = true;
+      element.currentRevisionActions = {submit: {enabled: true}};
+      assert.isTrue(element.isSubmitEnabled());
+      await element.updateComplete;
+      element.computeRevertSubmitted(element.change);
+      await element.updateComplete;
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
       );
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
       );
     });
 
     test('do not show any chip if all reverts are abandoned', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       change.messages[0].message = 'Created a revert of this change as 12345';
@@ -1085,23 +1241,24 @@
           status: ChangeStatus.ABANDONED,
         })
       );
-      element._change = change;
-      element._mergeable = true;
-      element._submitEnabled = true;
-      await flush();
-      element.computeRevertSubmitted(element._change);
-      await flush();
+      element.change = change;
+      element.mergeable = true;
+      element.currentRevisionActions = {submit: {enabled: true}};
+      assert.isTrue(element.isSubmitEnabled());
+      await element.updateComplete;
+      element.computeRevertSubmitted(element.change);
+      await element.updateComplete;
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
       );
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
       );
     });
 
     test('show revert created if no revert is merged', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       change.messages[0].message = 'Created a revert of this change as 12345';
@@ -1121,23 +1278,26 @@
           ...createChange(),
         })
       );
-      element._change = change;
-      element._mergeable = true;
-      element._submitEnabled = true;
-      await flush();
-      element.computeRevertSubmitted(element._change);
-      await flush();
+      element.change = change;
+      element.mergeable = true;
+      element.currentRevisionActions = {submit: {enabled: true}};
+      assert.isTrue(element.isSubmitEnabled());
+      await element.updateComplete;
+      element.computeRevertSubmitted(element.change);
+      // Wait for promises to settle.
+      await waitEventLoop();
+      await element.updateComplete;
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
       );
       assert.isTrue(
-        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
       );
     });
 
     test('show revert submitted if revert is merged', async () => {
       const change = {
-        ...createChange(),
+        ...createParsedChange(),
         messages: createChangeMessages(2),
       };
       change.messages[0].message = 'Created a revert of this change as 12345';
@@ -1154,24 +1314,31 @@
           ...createChange(),
         })
       );
-      element._change = change;
-      element._mergeable = true;
-      element._submitEnabled = true;
-      await flush();
-      element.computeRevertSubmitted(element._change);
-      await flush();
+      element.change = change;
+      element.mergeable = true;
+      element.currentRevisionActions = {submit: {enabled: true}};
+      assert.isTrue(element.isSubmitEnabled());
+      await element.updateComplete;
+      element.computeRevertSubmitted(element.change);
+      // Wait for promises to settle.
+      await waitEventLoop();
+      await element.updateComplete;
       assert.isFalse(
-        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
       );
       assert.isTrue(
-        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+        element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
       );
     });
   });
 
-  test('diff preferences open when open-diff-prefs is fired', () => {
-    const overlayOpenStub = sinon.stub(element.$.fileList, 'openDiffPrefs');
-    element.$.fileListHeader.dispatchEvent(
+  test('diff preferences open when open-diff-prefs is fired', async () => {
+    await element.updateComplete;
+    assertIsDefined(element.fileList);
+    assertIsDefined(element.fileListHeader);
+    await element.fileList.updateComplete;
+    const overlayOpenStub = sinon.stub(element.fileList, 'openDiffPrefs');
+    element.fileListHeader.dispatchEvent(
       new CustomEvent('open-diff-prefs', {
         composed: true,
         bubbles: true,
@@ -1180,40 +1347,42 @@
     assert.isTrue(overlayOpenStub.called);
   });
 
-  test('_prepareCommitMsgForLinkify', () => {
+  test('prepareCommitMsgForLinkify', () => {
     let commitMessage = 'R=test@google.com';
-    let result = element._prepareCommitMsgForLinkify(commitMessage);
+    let result = element.prepareCommitMsgForLinkify(commitMessage);
     assert.equal(result, 'R=\u200Btest@google.com');
 
     commitMessage = 'R=test@google.com\nR=test@google.com';
-    result = element._prepareCommitMsgForLinkify(commitMessage);
+    result = element.prepareCommitMsgForLinkify(commitMessage);
     assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
 
     commitMessage = 'CC=test@google.com';
-    result = element._prepareCommitMsgForLinkify(commitMessage);
+    result = element.prepareCommitMsgForLinkify(commitMessage);
     assert.equal(result, 'CC=\u200Btest@google.com');
   });
 
   test('_isSubmitEnabled', () => {
-    assert.isFalse(element._isSubmitEnabled({}));
-    assert.isFalse(element._isSubmitEnabled({submit: {}}));
-    assert.isTrue(element._isSubmitEnabled({submit: {enabled: true}}));
+    assert.isFalse(element.isSubmitEnabled());
+    element.currentRevisionActions = {submit: {}};
+    assert.isFalse(element.isSubmitEnabled());
+    element.currentRevisionActions = {submit: {enabled: true}};
+    assert.isTrue(element.isSubmitEnabled());
   });
 
-  test('_reload is called when an approved label is removed', () => {
+  test('reload is called when an approved label is removed', async () => {
     const vote: ApprovalInfo = {
       ...createApproval(),
       _account_id: 1 as AccountId,
       name: 'bojack',
       value: 1,
     };
-    element._changeNum = TEST_NUMERIC_CHANGE_ID;
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
+    element.changeNum = TEST_NUMERIC_CHANGE_ID;
+    element.patchRange = {
+      basePatchNum: PARENT,
       patchNum: 1 as RevisionPatchSetNum,
     };
     const change = {
-      ...createChangeViewChange(),
+      ...createParsedChange(),
       owner: createAccountWithIdNameAndEmail(),
       revisions: {
         rev2: createRevision(2),
@@ -1232,77 +1401,82 @@
         },
       },
     };
-    element._change = change;
-    flush();
+    element.change = change;
+    await element.updateComplete;
     const reloadStub = sinon.stub(element, 'loadData');
-    element.splice('_change.labels.test.all', 0, 1);
+    const newChange = {...element.change};
+    (newChange.labels!.test! as DetailedLabelInfo).all = [];
+    element.change = deepClone(newChange);
+    await element.updateComplete;
     assert.isFalse(reloadStub.called);
-    change.labels.test.all.push(vote);
-    change.labels.test.all.push(vote);
-    change.labels.test.approved = vote;
-    flush();
-    element.splice('_change.labels.test.all', 0, 2);
+
+    assert.isDefined(element.change);
+    const testLabels: DetailedLabelInfo & QuickLabelInfo =
+      newChange.labels!.test;
+    assertIsDefined(testLabels);
+    testLabels.all!.push(vote);
+    testLabels.all!.push(vote);
+    testLabels.approved = vote;
+    element.change = deepClone(newChange);
+    await element.updateComplete;
+    assert.isFalse(reloadStub.called);
+
+    assert.isDefined(element.change);
+    (newChange.labels!.test! as DetailedLabelInfo).all = [];
+    element.change = deepClone(newChange);
+    await element.updateComplete;
     assert.isTrue(reloadStub.called);
     assert.isTrue(reloadStub.calledOnce);
   });
 
   test('reply button has updated count when there are drafts', () => {
-    const getLabel = element._computeReplyButtonLabel;
-
-    assert.equal(getLabel(undefined, false), 'Reply');
-    assert.equal(getLabel(undefined, true), 'Reply');
-
-    let drafts = {};
-    assert.equal(getLabel(drafts, false), 'Reply');
-
-    drafts = {
-      'file1.txt': [{}],
-      'file2.txt': [{}, {}],
+    const getLabel = (canReview: boolean) => {
+      element.change!.actions!.ready = {enabled: canReview};
+      return element.computeReplyButtonLabel();
     };
-    assert.equal(getLabel(drafts, false), 'Reply (3)');
-    assert.equal(getLabel(drafts, true), 'Start Review (3)');
+    element.change = createParsedChange();
+    element.change.actions = {};
+    element.diffDrafts = undefined;
+    assert.equal(getLabel(false), 'Reply');
+    assert.equal(getLabel(true), 'Reply');
+
+    element.diffDrafts = {};
+    assert.equal(getLabel(false), 'Reply');
+    assert.equal(getLabel(true), 'Start Review');
+
+    element.diffDrafts = {
+      'file1.txt': [createDraft()],
+      'file2.txt': [createDraft(), createDraft()],
+    };
+    assert.equal(getLabel(false), 'Reply (3)');
+    assert.equal(getLabel(true), 'Start Review (3)');
   });
 
-  test('change num change', () => {
+  test('change num change', async () => {
     const change = {
       ...createChangeViewChange(),
       labels: {},
     } as ParsedChangeInfo;
-    stubRestApi('getChangeDetail').returns(Promise.resolve(change));
-    element._changeNum = undefined;
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
+    element.changeNum = undefined;
+    element.patchRange = {
+      basePatchNum: PARENT,
       patchNum: 2 as RevisionPatchSetNum,
     };
-    element._change = change;
-    element.viewState.changeNum = null;
-    element.viewState.diffMode = DiffViewMode.UNIFIED;
-    assert.equal(element.viewState.numFilesShown, 200);
-    assert.equal(element._numFilesShown, 200);
-    element._numFilesShown = 150;
-    flush();
-    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
-    assert.equal(element.viewState.numFilesShown, 150);
+    element.change = change;
+    assertIsDefined(element.fileList);
+    assert.equal(element.fileList.numFilesShown, DEFAULT_NUM_FILES_SHOWN);
+    element.fileList.numFilesShown = 150;
+    element.fileList.selectedIndex = 15;
+    await element.updateComplete;
 
-    element._changeNum = 1 as NumericChangeId;
-    element.params = {
-      ...createAppElementChangeViewParams(),
-      changeNum: 1 as NumericChangeId,
-    };
-    flush();
-    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
-    assert.equal(element.viewState.changeNum, 1);
-
-    element._changeNum = 2 as NumericChangeId;
-    element.params = {
-      ...createAppElementChangeViewParams(),
+    element.changeNum = 2 as NumericChangeId;
+    element.viewState = {
+      ...createChangeViewState(),
       changeNum: 2 as NumericChangeId,
     };
-    flush();
-    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
-    assert.equal(element.viewState.changeNum, 2);
-    assert.equal(element.viewState.numFilesShown, 200);
-    assert.equal(element._numFilesShown, 200);
+    await element.updateComplete;
+    assert.equal(element.fileList.numFilesShown, DEFAULT_NUM_FILES_SHOWN);
+    assert.equal(element.fileList.selectedIndex, 0);
   });
 
   test('don’t reload entire page when patchRange changes', async () => {
@@ -1310,22 +1484,24 @@
       .stub(element, 'loadData')
       .callsFake(() => Promise.resolve());
     const reloadPatchDependentStub = sinon
-      .stub(element, '_reloadPatchNumDependentResources')
-      .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
-    flush();
-    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-    const value: AppElementChangeViewParams = {
-      ...createAppElementChangeViewParams(),
+      .stub(element, 'reloadPatchNumDependentResources')
+      .callsFake(() => Promise.resolve());
+    assertIsDefined(element.fileList);
+    await element.fileList.updateComplete;
+    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
+    const value: ChangeViewState = {
+      ...createChangeViewState(),
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._changeNum = undefined;
-    element.params = value;
-    await flush();
+    element.changeNum = undefined;
+    element.viewState = value;
+    await element.updateComplete;
     assert.isTrue(reloadStub.calledOnce);
 
-    element._initialLoadComplete = true;
-    element._change = {
+    element.initialLoadComplete = true;
+    element.fileList.selectedIndex = 15;
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         rev1: createRevision(1),
@@ -1335,95 +1511,92 @@
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element.params = {...value};
-    await flush();
+    element.viewState = {...value};
+    await element.updateComplete;
+    await waitEventLoop();
+    assert.equal(element.fileList.selectedIndex, 0);
     assert.isFalse(reloadStub.calledTwice);
     assert.isTrue(reloadPatchDependentStub.calledOnce);
     assert.isTrue(collapseStub.calledTwice);
   });
 
-  test('reload ported comments when patchNum changes', async () => {
-    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
-    sinon.stub(element, '_getCommitInfo');
-    sinon.stub(element.$.fileList, 'reload');
-    flush();
-    const reloadPortedCommentsStub = stubComments('reloadPortedComments');
-    const reloadPortedDraftsStub = stubComments('reloadPortedDrafts');
-    sinon.stub(element.$.fileList, 'collapseAllDiffs');
-
-    const value: AppElementChangeViewParams = {
-      ...createAppElementChangeViewParams(),
-      view: GerritView.CHANGE,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
-    element.params = value;
-    await flush();
-
-    element._initialLoadComplete = true;
-    element._change = {
-      ...createChangeViewChange(),
-      revisions: {
-        rev1: createRevision(1),
-        rev2: createRevision(2),
-      },
-    };
-
-    value.basePatchNum = 1 as BasePatchSetNum;
-    value.patchNum = 2 as RevisionPatchSetNum;
-    element.params = {...value};
-    await flush();
-    assert.isTrue(reloadPortedCommentsStub.calledOnce);
-    assert.isTrue(reloadPortedDraftsStub.calledOnce);
-  });
-
   test('do not reload entire page when patchRange doesnt change', async () => {
+    assertIsDefined(element.fileList);
     const reloadStub = sinon
       .stub(element, 'loadData')
       .callsFake(() => Promise.resolve());
-    const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-    const value: AppElementChangeViewParams =
-      createAppElementChangeViewParams();
-    element.params = value;
+    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
+    const value: ChangeViewState = createChangeViewState();
+    element.viewState = value;
     // change already loaded
-    assert.isOk(element._changeNum);
-    await flush();
+    assert.isOk(element.changeNum);
+    await element.updateComplete;
     assert.isFalse(reloadStub.calledOnce);
-    element._initialLoadComplete = true;
-    element.params = {...value};
-    await flush();
+    element.initialLoadComplete = true;
+    element.viewState = {...value};
+    await element.updateComplete;
     assert.isFalse(reloadStub.calledTwice);
     assert.isFalse(collapseStub.calledTwice);
   });
 
+  test('forceReload updates the change', async () => {
+    assertIsDefined(element.fileList);
+    const getChangeStub = stubRestApi('getChangeDetail').returns(
+      Promise.resolve(createParsedChange())
+    );
+    const loadDataStub = sinon
+      .stub(element, 'loadData')
+      .callsFake(() => Promise.resolve());
+    const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
+    element.viewState = {...createChangeViewState(), forceReload: true};
+    await element.updateComplete;
+    assert.isTrue(getChangeStub.called);
+    assert.isTrue(loadDataStub.called);
+    assert.isTrue(collapseStub.called);
+    // patchNum is set by changeChanged, so this verifies that change was set.
+    assert.isOk(element.patchRange?.patchNum);
+  });
+
   test('do not handle new change numbers', async () => {
     const recreateSpy = sinon.spy();
     element.addEventListener('recreate-change-view', recreateSpy);
 
-    const value: AppElementChangeViewParams =
-      createAppElementChangeViewParams();
-    element.params = value;
-    await flush();
+    const value: ChangeViewState = createChangeViewState();
+    element.viewState = value;
+    await element.updateComplete;
     assert.isFalse(recreateSpy.calledOnce);
 
     value.changeNum = 555111333 as NumericChangeId;
-    element.params = {...value};
-    await flush();
+    element.viewState = {...value};
+    await element.updateComplete;
     assert.isTrue(recreateSpy.calledOnce);
   });
 
-  test('related changes are not updated after other action', async () => {
-    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
-    await flush();
+  test('related changes are updated when loadData is called', async () => {
+    await element.updateComplete;
     const relatedChanges = element.shadowRoot!.querySelector(
       '#relatedChanges'
     ) as GrRelatedChangesList;
-    sinon.stub(relatedChanges, 'reload');
+    const reloadStub = sinon.stub(relatedChanges, 'reload');
+    stubRestApi('getMergeable').returns(
+      Promise.resolve({...createMergeable(), mergeable: true})
+    );
+
+    element.viewState = createChangeViewState();
+    changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
+        ...createChangeViewChange(),
+      },
+    });
+
     await element.loadData(true);
-    assert.isFalse(navigateToChangeStub.called);
+    assert.isFalse(setUrlStub.called);
+    assert.isTrue(reloadStub.called);
   });
 
-  test('_computeCopyTextForTitle', () => {
-    const change: ChangeInfo = {
+  test('computeCopyTextForTitle', () => {
+    element.change = {
       ...createChangeViewChange(),
       _number: 123 as NumericChangeId,
       subject: 'test subject',
@@ -1433,10 +1606,9 @@
       },
       current_revision: 'rev3' as CommitId,
     };
-    sinon.stub(GerritNav, 'getUrlForChange').returns('/change/123');
     assert.equal(
-      element._computeCopyTextForTitle(change),
-      `123: test subject | http://${location.host}/change/123`
+      element.computeCopyTextForTitle(),
+      `123: test subject | http://${location.host}/c/test-project/+/123`
     );
   });
 
@@ -1449,7 +1621,7 @@
       },
       current_revision: 'rev3' as CommitId,
     };
-    assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+    assert.equal(element.getLatestRevisionSHA(change), 'rev3');
     change = {
       ...createChange(),
       revisions: {
@@ -1457,135 +1629,101 @@
       },
       current_revision: undefined,
     };
-    assert.equal(element._getLatestRevisionSHA(change), 'rev1');
+    assert.equal(element.getLatestRevisionSHA(change), 'rev1');
   });
 
   test('show commit message edit button', () => {
-    const change = createChange();
-    const mergedChanged: ChangeInfo = {
-      ...createChangeViewChange(),
+    const change = createParsedChange();
+    const mergedChanged: ParsedChangeInfo = {
+      ...createParsedChange(),
       status: ChangeStatus.MERGED,
     };
-    assert.isTrue(element._computeHideEditCommitMessage(false, false, change));
-    assert.isTrue(element._computeHideEditCommitMessage(true, true, change));
-    assert.isTrue(element._computeHideEditCommitMessage(false, true, change));
-    assert.isFalse(element._computeHideEditCommitMessage(true, false, change));
+    assert.isTrue(element.computeHideEditCommitMessage(false, false, change));
+    assert.isTrue(element.computeHideEditCommitMessage(true, true, change));
+    assert.isTrue(element.computeHideEditCommitMessage(false, true, change));
+    assert.isFalse(element.computeHideEditCommitMessage(true, false, change));
     assert.isTrue(
-      element._computeHideEditCommitMessage(true, false, mergedChanged)
+      element.computeHideEditCommitMessage(true, false, mergedChanged)
     );
     assert.isTrue(
-      element._computeHideEditCommitMessage(true, false, change, true)
+      element.computeHideEditCommitMessage(true, false, change, true)
     );
     assert.isFalse(
-      element._computeHideEditCommitMessage(true, false, change, false)
+      element.computeHideEditCommitMessage(true, false, change, false)
     );
   });
 
-  test('_handleCommitMessageSave trims trailing whitespace', () => {
-    element._change = createChangeViewChange();
+  test('handleCommitMessageSave trims trailing whitespace', async () => {
+    element.change = createChangeViewChange();
     // Response code is 500, because we want to avoid window reloading
     const putStub = stubRestApi('putChangeCommitMessage').returns(
       Promise.resolve(new Response(null, {status: 500}))
     );
-
+    await element.updateComplete;
     const mockEvent = (content: string) =>
       new CustomEvent('', {detail: {content}});
 
-    element._handleCommitMessageSave(mockEvent('test \n  test '));
+    assertIsDefined(element.commitMessageEditor);
+    element.handleCommitMessageSave(mockEvent('test \n  test '));
     assert.equal(putStub.lastCall.args[1], 'test\n  test');
-
-    element._handleCommitMessageSave(mockEvent('  test\ntest'));
+    element.commitMessageEditor.disabled = false;
+    element.handleCommitMessageSave(mockEvent('  test\ntest'));
     assert.equal(putStub.lastCall.args[1], '  test\ntest');
-
-    element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
+    element.commitMessageEditor.disabled = false;
+    element.handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
   });
 
   test('topic is coalesced to null', async () => {
-    sinon.stub(element, '_changeChanged');
-    stubRestApi('getChangeDetail').returns(
-      Promise.resolve({
+    sinon.stub(element, 'changeChanged');
+    changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
         ...createChangeViewChange(),
         labels: {},
         current_revision: 'foo' as CommitId,
         revisions: {foo: createRevision()},
-      })
-    );
+      },
+    });
 
-    await element._getChangeDetail();
-    assert.isNull(element._change!.topic);
+    await element.performPostChangeLoadTasks();
+    assert.isNull(element.change!.topic);
   });
 
   test('commit sha is populated from getChangeDetail', async () => {
-    sinon.stub(element, '_changeChanged');
-    stubRestApi('getChangeDetail').callsFake(() =>
-      Promise.resolve({
+    changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
         ...createChangeViewChange(),
         labels: {},
         current_revision: 'foo' as CommitId,
         revisions: {foo: createRevision()},
-      })
-    );
-
-    await element._getChangeDetail();
-    assert.equal('foo', element._commitInfo!.commit);
-  });
-
-  test('edit is added to change', () => {
-    sinon.stub(element, '_changeChanged');
-    const changeRevision = createRevision();
-    stubRestApi('getChangeDetail').callsFake(() =>
-      Promise.resolve({
-        ...createChangeViewChange(),
-        labels: {},
-        current_revision: 'foo' as CommitId,
-        revisions: {foo: {...changeRevision}},
-      })
-    );
-    const editCommit: CommitInfo = {
-      ...createCommit(),
-      commit: 'bar' as CommitId,
-    };
-    sinon.stub(element, '_getEdit').callsFake(() =>
-      Promise.resolve({
-        base_patch_set_number: 1 as BasePatchSetNum,
-        commit: {...editCommit},
-        base_revision: 'abc',
-        ref: 'some/ref' as GitRef,
-      })
-    );
-    element._patchRange = {};
-
-    return element._getChangeDetail().then(() => {
-      const revs = element._change!.revisions!;
-      assert.equal(Object.keys(revs).length, 2);
-      assert.deepEqual(revs['foo'], changeRevision);
-      assert.deepEqual(revs['bar'], {
-        ...createEditRevision(),
-        commit: editCommit,
-        fetch: undefined,
-      });
+      },
     });
+
+    await element.performPostChangeLoadTasks();
+    assert.equal('foo', element.commitInfo!.commit);
   });
 
-  test('_getBasePatchNum', () => {
-    const _change: ChangeInfo = {
+  test('getBasePatchNum', async () => {
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         '98da160735fb81604b4c40e93c368f380539dd0e': createRevision(),
       },
     };
-    const _patchRange: ChangeViewPatchRange = {
-      basePatchNum: ParentPatchSetNum,
+    element.patchRange = {
+      basePatchNum: PARENT,
     };
-    assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
+    await element.updateComplete;
+    assert.equal(element.getBasePatchNum(), PARENT);
 
-    element._prefs = {
+    element.prefs = {
       ...createPreferences(),
       default_base_for_merges: DefaultBase.FIRST_PARENT,
     };
 
-    const _change2: ChangeInfo = {
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         '98da160735fb81604b4c40e93c368f380539dd0e': {
@@ -1606,29 +1744,33 @@
         },
       },
     };
-    assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
+    await element.updateComplete;
+    assert.equal(element.getBasePatchNum(), -1 as BasePatchSetNum);
 
-    _patchRange.patchNum = 1 as RevisionPatchSetNum;
-    assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
+    element.patchRange.basePatchNum = PARENT;
+    element.patchRange.patchNum = 1 as RevisionPatchSetNum;
+    await element.updateComplete;
+    assert.equal(element.getBasePatchNum(), PARENT);
   });
 
-  test('_openReplyDialog called with `ANY` when coming from tap event', async () => {
-    await flush();
-    const openStub = sinon.stub(element, '_openReplyDialog');
-    tap(element.$.replyBtn);
+  test('openReplyDialog called with `ANY` when coming from tap event', async () => {
+    await element.updateComplete;
+    assertIsDefined(element.replyBtn);
+    const openStub = sinon.stub(element, 'openReplyDialog');
+    element.replyBtn.click();
     assert(
       openStub.lastCall.calledWithExactly(FocusTarget.ANY),
-      '_openReplyDialog should have been passed ANY'
+      'openReplyDialog should have been passed ANY'
     );
     assert.equal(openStub.callCount, 1);
   });
 
   test(
-    '_openReplyDialog called with `BODY` when coming from message reply' +
+    'openReplyDialog called with `BODY` when coming from message reply' +
       'event',
     async () => {
-      await flush();
-      const openStub = sinon.stub(element, '_openReplyDialog');
+      await element.updateComplete;
+      const openStub = sinon.stub(element, 'openReplyDialog');
       element.messagesList!.dispatchEvent(
         new CustomEvent('reply', {
           detail: {message: {message: 'text'}},
@@ -1642,30 +1784,29 @@
   );
 
   test('reply dialog focus can be controlled', () => {
-    const openStub = sinon.stub(element, '_openReplyDialog');
+    const openStub = sinon.stub(element, 'openReplyDialog');
 
     const e = new CustomEvent('show-reply-dialog', {
       detail: {value: {ccsOnly: false}},
     });
-    element._handleShowReplyDialog(e);
+    element.handleShowReplyDialog(e);
     assert(
       openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
-      '_openReplyDialog should have been passed REVIEWERS'
+      'openReplyDialog should have been passed REVIEWERS'
     );
     assert.equal(openStub.callCount, 1);
 
     e.detail.value = {ccsOnly: true};
-    element._handleShowReplyDialog(e);
+    element.handleShowReplyDialog(e);
     assert(
       openStub.lastCall.calledWithExactly(FocusTarget.CCS),
-      '_openReplyDialog should have been passed CCS'
+      'openReplyDialog should have been passed CCS'
     );
     assert.equal(openStub.callCount, 2);
   });
 
   test('getUrlParameter functionality', () => {
-    const locationStub = sinon.stub(element, '_getLocationSearch');
-
+    const locationStub = sinon.stub(element, 'getLocationSearch');
     locationStub.returns('?test');
     assert.equal(element._getUrlParameter('test'), 'test');
     locationStub.returns('?test2=12&test=3');
@@ -1680,14 +1821,14 @@
 
   test('revert dialog opened with revert param', async () => {
     const awaitPluginsLoadedStub = sinon
-      .stub(getPluginLoader(), 'awaitPluginsLoaded')
+      .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
       .callsFake(() => Promise.resolve());
 
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
+    element.patchRange = {
+      basePatchNum: PARENT,
       patchNum: 2 as RevisionPatchSetNum,
     };
-    element._change = {
+    element.change = {
       ...createChangeViewChange(),
       revisions: {
         rev1: createRevision(1),
@@ -1705,37 +1846,31 @@
     });
 
     const promise = mockPromise();
-
+    assertIsDefined(element.actions);
     sinon
-      .stub(element.$.actions, 'showRevertDialog')
+      .stub(element.actions, 'showRevertDialog')
       .callsFake(() => promise.resolve());
 
-    element._maybeShowRevertDialog();
+    element.maybeShowRevertDialog();
     assert.isTrue(awaitPluginsLoadedStub.called);
     await promise;
   });
 
   suite('reply dialog tests', () => {
-    setup(() => {
-      element._change = {
+    setup(async () => {
+      element.change = {
         ...createChangeViewChange(),
-        revisions: createRevisions(1),
+        // element has latest info
+        revisions: {rev1: createRevision()},
         messages: createChangeMessages(1),
+        current_revision: 'rev1' as CommitId,
+        labels: {},
       };
-      element._change.labels = {};
-      stubRestApi('getChangeDetail').callsFake(() =>
-        Promise.resolve({
-          ...createChangeViewChange(),
-          // element has latest info
-          revisions: {rev1: createRevision()},
-          messages: createChangeMessages(1),
-          current_revision: 'rev1' as CommitId,
-        })
-      );
+      await element.updateComplete;
     });
 
     test('show reply dialog on open-reply-dialog event', async () => {
-      const openReplyDialogStub = sinon.stub(element, '_openReplyDialog');
+      const openReplyDialogStub = sinon.stub(element, 'openReplyDialog');
       element.dispatchEvent(
         new CustomEvent('open-reply-dialog', {
           composed: true,
@@ -1743,164 +1878,142 @@
           detail: {},
         })
       );
-      await flush();
+      await element.updateComplete;
       assert.isTrue(openReplyDialogStub.calledOnce);
     });
 
     test('reply from comment adds quote text', async () => {
+      const change = {
+        ...createChangeViewChange(),
+        revisions: createRevisions(1),
+        messages: createChangeMessages(1),
+      };
+      changeModel.setState({
+        loadingStatus: LoadingStatus.LOADED,
+        change,
+      });
       const e = new CustomEvent('', {
         detail: {message: {message: 'quote text'}},
       });
-      element._handleMessageReply(e);
+      element.handleMessageReply(e);
       const dialog = await waitQueryAndAssert<GrReplyDialog>(
         element,
         '#replyDialog'
       );
       const openSpy = sinon.spy(dialog, 'open');
+      await element.updateComplete;
       await waitUntil(() => openSpy.called && !!openSpy.lastCall.args[1]);
       assert.equal(openSpy.lastCall.args[1], '> quote text\n\n');
     });
   });
 
-  test('reply button is disabled until server config is loaded', async () => {
-    assert.isTrue(element._replyDisabled);
-    // fetches the server config on attached
-    await flush();
-    assert.isFalse(element._replyDisabled);
-  });
-
   test('header class computation', () => {
-    assert.equal(element._computeHeaderClass(), 'header');
-    assert.equal(element._computeHeaderClass(true), 'header editMode');
+    assert.equal(element.computeHeaderClass(), 'header');
+    assertIsDefined(element.viewState);
+    element.viewState.edit = true;
+    assert.equal(element.computeHeaderClass(), 'header editMode');
   });
 
-  test('_maybeScrollToMessage', async () => {
-    await flush();
+  test('maybeScrollToMessage', async () => {
+    await element.updateComplete;
     const scrollStub = sinon.stub(element.messagesList!, 'scrollToMessage');
 
-    element._maybeScrollToMessage('');
+    element.maybeScrollToMessage('');
     assert.isFalse(scrollStub.called);
-    element._maybeScrollToMessage('message');
+    element.maybeScrollToMessage('message');
     assert.isFalse(scrollStub.called);
-    element._maybeScrollToMessage('#message-TEST');
+    element.maybeScrollToMessage('#message-TEST');
     assert.isTrue(scrollStub.called);
     assert.equal(scrollStub.lastCall.args[0], 'TEST');
   });
 
-  test('topic update reloads related changes', () => {
-    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);
-  });
-
-  test('_computeEditMode', () => {
-    const callCompute = (
-      range: PatchRange,
-      params: AppElementChangeViewParams
-    ) =>
-      element._computeEditMode(
-        {base: range, path: '', value: range},
-        {base: params, path: '', value: params}
-      );
+  test('computeEditMode', async () => {
+    const callCompute = async (viewState: ChangeViewState) => {
+      element.viewState = viewState;
+      await element.updateComplete;
+      return element.getEditMode();
+    };
     assert.isTrue(
-      callCompute(
-        {basePatchNum: ParentPatchSetNum, patchNum: 1 as RevisionPatchSetNum},
-        {...createAppElementChangeViewParams(), edit: true}
-      )
+      await callCompute({
+        ...createChangeViewState(),
+        edit: true,
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      })
     );
     assert.isFalse(
-      callCompute(
-        {basePatchNum: ParentPatchSetNum, patchNum: 1 as RevisionPatchSetNum},
-        createAppElementChangeViewParams()
-      )
+      await callCompute({
+        ...createChangeViewState(),
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      })
     );
     assert.isTrue(
-      callCompute(
-        {basePatchNum: 1 as BasePatchSetNum, patchNum: EditPatchSetNum},
-        createAppElementChangeViewParams()
-      )
+      await callCompute({
+        ...createChangeViewState(),
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: EDIT,
+      })
     );
   });
 
-  test('_processEdit', () => {
-    element._patchRange = {};
+  test('processEdit', () => {
+    element.patchRange = {};
     const change: ParsedChangeInfo = {
       ...createChangeViewChange(),
       current_revision: 'foo' as CommitId,
       revisions: {
-        foo: {...createRevision(), actions: {cherrypick: {enabled: true}}},
+        foo: {...createRevision()},
       },
     };
-    let mockChange;
 
-    // With no edit, mockChange should be unmodified.
-    element._processEdit((mockChange = _.cloneDeep(change)), false);
-    assert.deepEqual(mockChange, change);
+    // With no edit, nothing happens.
+    element.processEdit(change);
+    assert.equal(element.patchRange.patchNum, undefined);
 
-    const editCommit: CommitInfo = {
-      ...createCommit(),
-      commit: 'bar' as CommitId,
-    };
-    // When edit is not based on the latest PS, current_revision should be
-    // unmodified.
-    const edit: EditInfo = {
-      ref: 'ref/test/abc' as GitRef,
-      base_revision: 'abc',
-      base_patch_set_number: 1 as BasePatchSetNum,
-      commit: {...editCommit},
+    change.revisions['bar'] = {
+      _number: EDIT,
+      basePatchNum: 1 as BasePatchSetNum,
+      commit: {
+        ...createCommit(),
+        commit: 'bar' as CommitId,
+      },
       fetch: {},
     };
-    element._processEdit((mockChange = _.cloneDeep(change)), edit);
-    assert.notDeepEqual(mockChange, change);
-    assert.equal(mockChange.revisions.bar._number, EditPatchSetNum);
-    assert.equal(mockChange.current_revision, change.current_revision);
-    assert.deepEqual(mockChange.revisions.bar.commit, editCommit);
-    assert.notOk(mockChange.revisions.bar.actions);
 
-    edit.base_revision = 'foo';
-    element._processEdit((mockChange = _.cloneDeep(change)), edit);
-    assert.notDeepEqual(mockChange, change);
-    assert.equal(mockChange.current_revision, 'bar');
-    assert.deepEqual(
-      mockChange.revisions.bar.actions,
-      mockChange.revisions.foo.actions
-    );
+    // When edit is set, but not patchNum, then switch to edit ps.
+    element.processEdit(change);
+    assert.equal(element.patchRange.patchNum, EDIT);
 
-    // If _patchRange.patchNum is defined, do not load edit.
-    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 RevisionPatchSetNum);
-    assert.notOk(mockChange.revisions.bar.actions);
+    // When edit is set, but patchNum as well, then keep patchNum.
+    element.patchRange.patchNum = 5 as RevisionPatchSetNum;
+    element.viewModelPatchNum = 5 as RevisionPatchSetNum;
+    element.processEdit(change);
+    assert.equal(element.patchRange.patchNum, 5 as RevisionPatchSetNum);
   });
 
-  test('file-action-tap handling', () => {
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
+  test('file-action-tap handling', async () => {
+    element.patchRange = {
+      basePatchNum: PARENT,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._change = {
+    element.change = {
       ...createChangeViewChange(),
     };
-    const fileList = element.$.fileList;
+    assertIsDefined(element.fileList);
+    assertIsDefined(element.fileListHeader);
+    const fileList = element.fileList;
     const Actions = GrEditConstants.Actions;
-    element.$.fileListHeader.editMode = true;
-    flush();
-    const controls = element.$.fileListHeader.shadowRoot!.querySelector(
+    element.fileListHeader.editMode = true;
+    await element.fileListHeader.updateComplete;
+    await element.updateComplete;
+    const controls = queryAndAssert<GrEditControls>(
+      element.fileListHeader,
       '#editControls'
-    ) as GrEditControls;
+    );
     const openDeleteDialogStub = sinon.stub(controls, 'openDeleteDialog');
     const openRenameDialogStub = sinon.stub(controls, 'openRenameDialog');
     const openRestoreDialogStub = sinon.stub(controls, 'openRestoreDialog');
-    const getEditUrlForDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
-    const navigateToRelativeUrlStub = sinon.stub(
-      GerritNav,
-      'navigateToRelativeUrl'
-    );
 
     // Delete
     fileList.dispatchEvent(
@@ -1910,7 +2023,7 @@
         composed: true,
       })
     );
-    flush();
+    await element.updateComplete;
 
     assert.isTrue(openDeleteDialogStub.called);
     assert.equal(openDeleteDialogStub.lastCall.args[0], 'foo');
@@ -1923,7 +2036,7 @@
         composed: true,
       })
     );
-    flush();
+    await element.updateComplete;
 
     assert.isTrue(openRestoreDialogStub.called);
     assert.equal(openRestoreDialogStub.lastCall.args[0], 'foo');
@@ -1936,7 +2049,7 @@
         composed: true,
       })
     );
-    flush();
+    await element.updateComplete;
 
     assert.isTrue(openRenameDialogStub.called);
     assert.equal(openRenameDialogStub.lastCall.args[0], 'foo');
@@ -1949,19 +2062,17 @@
         composed: true,
       })
     );
-    flush();
+    await element.updateComplete;
 
-    assert.isTrue(getEditUrlForDiffStub.called);
-    assert.equal(getEditUrlForDiffStub.lastCall.args[1], 'foo');
-    assert.equal(getEditUrlForDiffStub.lastCall.args[2], 1 as PatchSetNum);
-    assert.isTrue(navigateToRelativeUrlStub.called);
+    assert.isTrue(setUrlStub.called);
   });
 
-  test('_selectedRevision updates when patchNum is changed', () => {
+  test('selectedRevision updates when patchNum is changed', async () => {
     const revision1: RevisionInfo = createRevision(1);
     const revision2: RevisionInfo = createRevision(2);
-    stubRestApi('getChangeDetail').returns(
-      Promise.resolve({
+    changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
         ...createChangeViewChange(),
         revisions: {
           aaa: revision1,
@@ -1970,27 +2081,26 @@
         labels: {},
         actions: {},
         current_revision: 'bbb' as CommitId,
-      })
-    );
-    sinon.stub(element, '_getEdit').returns(Promise.resolve(false));
-    sinon
-      .stub(element, '_getPreferences')
-      .returns(Promise.resolve(createPreferences()));
-    element._patchRange = {patchNum: 2 as RevisionPatchSetNum};
-    return element._getChangeDetail().then(() => {
-      assert.strictEqual(element._selectedRevision, revision2);
-
-      element.set('_patchRange.patchNum', '1');
-      assert.strictEqual(element._selectedRevision, revision1);
+      },
     });
+    userModel.setPreferences(createPreferences());
+
+    element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
+    await element.performPostChangeLoadTasks();
+    assert.strictEqual(element.selectedRevision, revision2);
+
+    element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
+    await element.updateComplete;
+    assert.strictEqual(element.selectedRevision, revision1);
   });
 
-  test('_selectedRevision is assigned when patchNum is edit', () => {
+  test('selectedRevision is assigned when patchNum is edit', async () => {
     const revision1 = createRevision(1);
     const revision2 = createRevision(2);
     const revision3 = createEditRevision();
-    stubRestApi('getChangeDetail').returns(
-      Promise.resolve({
+    changeModel.setState({
+      loadingStatus: LoadingStatus.LOADED,
+      change: {
         ...createChangeViewChange(),
         revisions: {
           aaa: revision1,
@@ -2000,292 +2110,321 @@
         labels: {},
         actions: {},
         current_revision: 'ccc' as CommitId,
-      })
-    );
-    sinon.stub(element, '_getEdit').returns(Promise.resolve(undefined));
-    sinon
-      .stub(element, '_getPreferences')
-      .returns(Promise.resolve(createPreferences()));
-    element._patchRange = {patchNum: EditPatchSetNum};
-    return element._getChangeDetail().then(() => {
-      assert.strictEqual(element._selectedRevision, revision3);
+      },
     });
+    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
+
+    element.patchRange = {patchNum: EDIT};
+    await element.performPostChangeLoadTasks();
+    assert.strictEqual(element.selectedRevision, revision3);
   });
 
-  test('_sendShowChangeEvent', () => {
+  test('sendShowChangeEvent', () => {
     const change = {...createChangeViewChange(), labels: {}};
-    element._change = {...change};
-    element._patchRange = {patchNum: 4 as RevisionPatchSetNum};
-    element._mergeable = true;
-    const showStub = sinon.stub(appContext.jsApiService, 'handleEvent');
-    element._sendShowChangeEvent();
+    element.change = {...change};
+    element.patchRange = {patchNum: 4 as RevisionPatchSetNum};
+    element.mergeable = true;
+    const showStub = sinon.stub(
+      testResolver(pluginLoaderToken).jsApiService,
+      'handleShowChange'
+    );
+    element.sendShowChangeEvent();
     assert.isTrue(showStub.calledOnce);
-    assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
-    assert.deepEqual(showStub.lastCall.args[1], {
+    assert.deepEqual(showStub.lastCall.args[0], {
       change,
-      patchNum: 4,
+      patchNum: 4 as PatchSetNumber,
       info: {mergeable: true},
     });
   });
 
   test('patch range changed', () => {
-    element._patchRange = undefined;
-    element._change = createChangeViewChange();
-    element._change!.revisions = createRevisions(4);
-    element._change.current_revision = '1' as CommitId;
-    element._change = {...element._change};
+    element.patchRange = undefined;
+    element.change = createChangeViewChange();
+    element.change.revisions = createRevisions(4);
+    element.change.current_revision = '1' as CommitId;
+    element.change = {...element.change};
 
-    const params = createAppElementChangeViewParams();
+    const viewState = createChangeViewState();
 
-    assert.isFalse(element.hasPatchRangeChanged(params));
-    assert.isFalse(element.hasPatchNumChanged(params));
+    assert.isFalse(element.hasPatchRangeChanged(viewState));
+    assert.isFalse(element.hasPatchNumChanged(viewState));
 
-    params.basePatchNum = ParentPatchSetNum;
+    viewState.basePatchNum = PARENT;
     // undefined means navigate to latest patchset
-    params.patchNum = undefined;
+    viewState.patchNum = undefined;
 
-    element._patchRange = {
+    element.patchRange = {
       patchNum: 2 as RevisionPatchSetNum,
-      basePatchNum: ParentPatchSetNum,
+      basePatchNum: PARENT,
     };
 
-    assert.isTrue(element.hasPatchRangeChanged(params));
-    assert.isTrue(element.hasPatchNumChanged(params));
+    assert.isTrue(element.hasPatchRangeChanged(viewState));
+    assert.isTrue(element.hasPatchNumChanged(viewState));
 
-    element._patchRange = {
+    element.patchRange = {
       patchNum: 4 as RevisionPatchSetNum,
-      basePatchNum: ParentPatchSetNum,
+      basePatchNum: PARENT,
     };
 
-    assert.isFalse(element.hasPatchRangeChanged(params));
-    assert.isFalse(element.hasPatchNumChanged(params));
+    assert.isFalse(element.hasPatchRangeChanged(viewState));
+    assert.isFalse(element.hasPatchNumChanged(viewState));
   });
 
-  suite('_handleEditTap', () => {
+  suite('handleEditTap', () => {
     let fireEdit: () => void;
 
     setup(() => {
       fireEdit = () => {
-        element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
+        assertIsDefined(element.actions);
+        element.actions.dispatchEvent(new CustomEvent('edit-tap'));
       };
-      navigateToChangeStub.restore();
 
-      element._change = {
+      element.change = {
         ...createChangeViewChange(),
         revisions: {rev1: createRevision()},
       };
     });
 
     test('edit exists in revisions', async () => {
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 2);
-        assert.equal(args[1], EditPatchSetNum); // patchNum
-        promise.resolve();
-      });
-
-      element.set('_change.revisions.rev2', {
-        _number: EditPatchSetNum,
-      });
-      await flush();
+      assertIsDefined(element.change);
+      const newChange = {...element.change};
+      newChange.revisions.rev2 = createRevision(EDIT);
+      element.change = newChange;
+      await element.updateComplete;
 
       fireEdit();
-      await promise;
+      assert.isTrue(setUrlStub.called);
+      assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/edit');
     });
 
     test('no edit exists in revisions, non-latest patchset', async () => {
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 6);
-        assert.equal(args[1], 1 as PatchSetNum); // patchNum
-        assert.equal(args[3], true); // opt_isEdit
-        promise.resolve();
-      });
-
-      element.set('_change.revisions.rev2', {_number: 2});
-      element._patchRange = {patchNum: 1 as RevisionPatchSetNum};
-      await flush();
+      assertIsDefined(element.change);
+      const newChange = {...element.change};
+      newChange.revisions.rev2 = createRevision(2);
+      element.change = newChange;
+      element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
+      await element.updateComplete;
 
       fireEdit();
-      await promise;
+      assert.isTrue(setUrlStub.called);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42/1,edit?forceReload=true'
+      );
     });
 
     test('no edit exists in revisions, latest patchset', async () => {
-      const promise = mockPromise();
-      sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-        assert.equal(args.length, 6);
-        // No patch should be specified when patchNum == latest.
-        assert.isNotOk(args[1]); // patchNum
-        assert.equal(args[3], true); // opt_isEdit
-        promise.resolve();
-      });
-
-      element.set('_change.revisions.rev2', {_number: 2});
-      element._patchRange = {patchNum: 2 as RevisionPatchSetNum};
-      await flush();
+      assertIsDefined(element.change);
+      const newChange = {...element.change};
+      newChange.revisions.rev2 = createRevision(2);
+      element.change = newChange;
+      element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
+      await element.updateComplete;
 
       fireEdit();
-      await promise;
+      assert.isTrue(setUrlStub.called);
+      assert.equal(
+        setUrlStub.lastCall.firstArg,
+        '/c/test-project/+/42,edit?forceReload=true'
+      );
     });
   });
 
-  test('_handleStopEditTap', async () => {
-    element._change = {
+  test('handleStopEditTap', async () => {
+    element.change = {
       ...createChangeViewChange(),
     };
-    sinon.stub(element.$.metadata, '_computeLabelNames');
-    navigateToChangeStub.restore();
-    const promise = mockPromise();
-    sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
-      assert.equal(args.length, 6);
-      assert.equal(args[1], 1 as PatchSetNum); // patchNum
-      promise.resolve();
-    });
+    await element.updateComplete;
+    assertIsDefined(element.metadata);
+    assertIsDefined(element.actions);
+    sinon.stub(element.metadata, 'computeLabelNames');
 
-    element._patchRange = {patchNum: 1 as RevisionPatchSetNum};
-    element.$.actions.dispatchEvent(
+    element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
+    element.actions.dispatchEvent(
       new CustomEvent('stop-edit-tap', {bubbles: false})
     );
-    await promise;
+
+    assert.isTrue(setUrlStub.called);
+    assert.equal(
+      setUrlStub.lastCall.firstArg,
+      '/c/test-project/+/42/1?forceReload=true'
+    );
   });
 
   suite('plugin endpoints', () => {
     test('endpoint params', async () => {
-      element._change = {...createChangeViewChange(), labels: {}};
-      element._selectedRevision = createRevision();
+      element.change = {...createChangeViewChange(), labels: {}};
+      element.selectedRevision = createRevision();
       const promise = mockPromise();
-      pluginApi.install(promise.resolve, '0.1', 'http://some/plugins/url.js');
-      await flush();
+      window.Gerrit.install(
+        promise.resolve,
+        '0.1',
+        'http://some/plugins/url.js'
+      );
+      await element.updateComplete;
       const plugin: PluginApi = (await promise) as PluginApi;
       const hookEl = await plugin
         .hook('change-view-integration')
         .getLastAttached();
       assert.strictEqual((hookEl as any).plugin, plugin);
-      assert.strictEqual((hookEl as any).change, element._change);
-      assert.strictEqual((hookEl as any).revision, element._selectedRevision);
+      assert.strictEqual((hookEl as any).change, element.change);
+      assert.strictEqual((hookEl as any).revision, element.selectedRevision);
     });
   });
 
-  suite('_getMergeability', () => {
+  suite('getMergeability', () => {
     let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
     setup(() => {
-      element._change = {...createChangeViewChange(), labels: {}};
+      element.change = {...createChangeViewChange(), labels: {}};
       getMergeableStub = stubRestApi('getMergeable').returns(
         Promise.resolve({...createMergeable(), mergeable: true})
       );
     });
 
     test('merged change', () => {
-      element._mergeable = null;
-      element._change!.status = ChangeStatus.MERGED;
-      return element._getMergeability().then(() => {
-        assert.isFalse(element._mergeable);
+      element.mergeable = null;
+      element.change!.status = ChangeStatus.MERGED;
+      return element.getMergeability().then(() => {
+        assert.isFalse(element.mergeable);
         assert.isFalse(getMergeableStub.called);
       });
     });
 
     test('abandoned change', () => {
-      element._mergeable = null;
-      element._change!.status = ChangeStatus.ABANDONED;
-      return element._getMergeability().then(() => {
-        assert.isFalse(element._mergeable);
+      element.mergeable = null;
+      element.change!.status = ChangeStatus.ABANDONED;
+      return element.getMergeability().then(() => {
+        assert.isFalse(element.mergeable);
         assert.isFalse(getMergeableStub.called);
       });
     });
 
     test('open change', () => {
-      element._mergeable = null;
-      return element._getMergeability().then(() => {
-        assert.isTrue(element._mergeable);
+      element.mergeable = null;
+      return element.getMergeability().then(() => {
+        assert.isTrue(element.mergeable);
         assert.isTrue(getMergeableStub.called);
       });
     });
   });
 
-  test('_handleToggleStar called when star is tapped', async () => {
-    element._change = {
+  test('handleToggleStar called when star is tapped', async () => {
+    element.change = {
       ...createChangeViewChange(),
       owner: {_account_id: 1 as AccountId},
       starred: false,
     };
-    element._loggedIn = true;
-    await flush();
+    element.loggedIn = true;
+    await element.updateComplete;
 
-    const stub = sinon.stub(element, '_handleToggleStar');
+    const stub = sinon.stub(element, 'handleToggleStar');
 
     const changeStar = queryAndAssert<GrChangeStar>(element, '#changeStar');
-    tap(queryAndAssert<HTMLButtonElement>(changeStar, 'button')!);
+    queryAndAssert<HTMLButtonElement>(changeStar, 'button')!.click();
     assert.isTrue(stub.called);
   });
 
   suite('gr-reporting tests', () => {
     setup(() => {
-      element._patchRange = {
-        basePatchNum: ParentPatchSetNum,
+      element.patchRange = {
+        basePatchNum: PARENT,
         patchNum: 1 as RevisionPatchSetNum,
       };
-      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(false));
-      sinon.stub(element, '_getProjectConfig').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]));
+        .stub(element, 'performPostChangeLoadTasks')
+        .returns(Promise.resolve(false));
+      sinon.stub(element, 'getMergeability').returns(Promise.resolve());
+      sinon.stub(element, 'getLatestCommitMessage').returns(Promise.resolve());
+      sinon
+        .stub(element, 'reloadPatchNumDependentResources')
+        .returns(Promise.resolve());
     });
 
     test("don't report changeDisplayed on reply", async () => {
       const changeDisplayStub = sinon.stub(
-        appContext.reportingService,
+        element.reporting,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        appContext.reportingService,
+        element.reporting,
         'changeFullyLoaded'
       );
-      element._handleReplySent();
-      await flush();
+      element.handleReplySent();
+      await element.updateComplete;
       assert.isFalse(changeDisplayStub.called);
       assert.isFalse(changeFullyLoadedStub.called);
     });
 
-    test('report changeDisplayed on _paramsChanged', async () => {
+    test('report changeDisplayed on viewStateChanged', async () => {
+      stubRestApi('getChangeOrEditFiles').resolves({
+        'a-file.js': {},
+      });
       const changeDisplayStub = sinon.stub(
-        appContext.reportingService,
+        element.reporting,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        appContext.reportingService,
+        element.reporting,
         'changeFullyLoaded'
       );
       // reset so reload is triggered
-      element._changeNum = undefined;
-      element.params = {
-        ...createAppElementChangeViewParams(),
+      element.changeNum = undefined;
+      element.viewState = {
+        ...createChangeViewState(),
         changeNum: TEST_NUMERIC_CHANGE_ID,
-        project: TEST_PROJECT_NAME,
+        repo: TEST_PROJECT_NAME,
       };
-      await flush();
+      changeModel.setState({
+        loadingStatus: LoadingStatus.LOADED,
+        change: {
+          ...createChangeViewChange(),
+          labels: {},
+          current_revision: 'foo' as CommitId,
+          revisions: {foo: createRevision()},
+        },
+      });
+      await element.updateComplete;
+      await waitEventLoop();
       assert.isTrue(changeDisplayStub.called);
       assert.isTrue(changeFullyLoadedStub.called);
     });
   });
 
-  test('_calculateHasParent', () => {
+  test('calculateHasParent', () => {
     const changeId = '123' as ChangeId;
     const relatedChanges: RelatedChangeAndCommitInfo[] = [];
 
-    assert.equal(element._calculateHasParent(changeId, relatedChanges), false);
+    assert.equal(element.calculateHasParent(changeId, relatedChanges), false);
 
     relatedChanges.push({
       ...createRelatedChangeAndCommitInfo(),
       change_id: '123' as ChangeId,
     });
-    assert.equal(element._calculateHasParent(changeId, relatedChanges), false);
+    assert.equal(element.calculateHasParent(changeId, relatedChanges), false);
 
     relatedChanges.push({
       ...createRelatedChangeAndCommitInfo(),
       change_id: '234' as ChangeId,
     });
-    assert.equal(element._calculateHasParent(changeId, relatedChanges), true);
+    assert.equal(element.calculateHasParent(changeId, relatedChanges), true);
+  });
+
+  test('renders sha in copy links', async () => {
+    stubFlags('isEnabled').returns(true);
+    const sha = '123' as CommitId;
+    element.change = {
+      ...createChangeViewChange(),
+      status: ChangeStatus.MERGED,
+      current_revision: sha,
+    };
+    await element.updateComplete;
+
+    const copyLinksDialog = queryAndAssert<GrCopyLinks>(
+      element,
+      'gr-copy-links'
+    );
+    assert.isTrue(
+      copyLinksDialog.copyLinks.some(copyLink => copyLink.value === sha)
+    );
   });
 });
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 0ce3b07..d6a5327 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
@@ -1,26 +1,18 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {ChangeInfo, CommitInfo, ServerInfo} from '../../../types/common';
+import {CommitInfo, ServerInfo} from '../../../types/common';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {createSearchUrl} from '../../../models/views/search';
+import {getPatchSetWeblink} from '../../../utils/weblink-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -30,18 +22,13 @@
 
 @customElement('gr-commit-info')
 export class GrCommitInfo extends LitElement {
-  // TODO(TS): can not use `?` here as @computed require dependencies as
-  // not optional
+  // TODO(TS): Maybe limit to StandaloneCommitInfo.
   @property({type: Object})
-  change: ChangeInfo | undefined;
+  commitInfo?: CommitInfo;
 
-  // TODO(TS): maybe limit to StandaloneCommitInfo if never pass in
-  // with commit inside RevisionInfo
-  @property({type: Object})
-  commitInfo: CommitInfo | undefined;
+  @state() serverConfig?: ServerInfo;
 
-  @property({type: Object})
-  serverConfig: ServerInfo | undefined;
+  private readonly getConfigModel = resolve(this, configModelToken);
 
   static override get styles() {
     return [
@@ -55,90 +42,45 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => (this.serverConfig = config)
+    );
+  }
+
   override render() {
+    const commit = this.commitInfo?.commit;
+    if (!commit) return nothing;
     return html` <div class="container">
-      <a
-        target="_blank"
-        rel="noopener"
-        href="${this.computeCommitLink(
-          this._webLink,
-          this.change,
-          this.commitInfo,
-          this.serverConfig
-        )}"
-        >${this._computeShortHash(
-          this.change,
-          this.commitInfo,
-          this.serverConfig
-        )}</a
+      <a target="_blank" rel="noopener" href=${this.computeCommitLink()}
+        >${this.getWeblink()?.name ?? ''}</a
       >
       <gr-copy-clipboard
         hastooltip
-        .buttonTitle="${'Copy full SHA to clipboard'}"
+        .buttonTitle=${'Copy full SHA to clipboard'}
         hideinput
-        .text="${this.commitInfo?.commit}"
+        .text=${commit}
       >
       </gr-copy-clipboard>
     </div>`;
   }
 
-  /**
-   * Used only within the tests.
-   */
-  get _showWebLink(): boolean {
-    if (!this.change || !this.commitInfo || !this.serverConfig) {
-      return false;
-    }
-
-    const weblink = this._getWeblink(
-      this.change,
-      this.commitInfo,
+  getWeblink() {
+    return getPatchSetWeblink(
+      this.commitInfo?.commit,
+      this.commitInfo?.web_links,
       this.serverConfig
     );
-    return !!weblink && !!weblink.url;
   }
 
-  get _webLink(): string | undefined {
-    if (!this.change || !this.commitInfo || !this.serverConfig) {
-      return '';
-    }
+  computeCommitLink() {
+    const weblink = this.getWeblink();
+    if (weblink?.url) return weblink.url;
 
-    // TODO(TS): if getPatchSetWeblink always return a valid WebLink,
-    // can remove the fallback here
-    const {url} =
-      this._getWeblink(this.change, this.commitInfo, this.serverConfig) || {};
-    return url;
-  }
-
-  _getWeblink(change: ChangeInfo, commitInfo: CommitInfo, config: ServerInfo) {
-    return GerritNav.getPatchSetWeblink(change.project, commitInfo.commit, {
-      weblinks: commitInfo.web_links,
-      config,
-    });
-  }
-
-  computeCommitLink(
-    webLink?: string,
-    change?: ChangeInfo,
-    commitInfo?: CommitInfo,
-    serverConfig?: ServerInfo
-  ) {
-    if (webLink) return webLink;
-    const hash = this._computeShortHash(change, commitInfo, serverConfig);
-    if (hash === undefined) return '';
-    return GerritNav.getUrlForSearchQuery(hash);
-  }
-
-  _computeShortHash(
-    change?: ChangeInfo,
-    commitInfo?: CommitInfo,
-    serverConfig?: ServerInfo
-  ) {
-    if (!change || !commitInfo || !serverConfig) {
-      return '';
-    }
-
-    const weblink = this._getWeblink(change, commitInfo, serverConfig);
-    return weblink?.name ?? '';
+    const hash = weblink?.name;
+    return hash ? createSearchUrl({query: hash}) : '';
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
index cb6c9e4..d992ffe 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
@@ -1,134 +1,67 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import '../../core/gr-router/gr-router';
+import '../../../test/common-test-setup';
 import './gr-commit-info';
 import {GrCommitInfo} from './gr-commit-info';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
-  createChange,
   createCommit,
   createServerInfo,
 } from '../../../test/test-data-generators';
-import {CommitId, RepoName} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-commit-info');
+import {CommitId} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-commit-info tests', () => {
   let element: GrCommitInfo;
 
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('weblinks use GerritNav interface', async () => {
-    const weblinksStub = sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .returns([{name: 'stubb', url: '#s'}]);
-    element.change = createChange();
-    element.commitInfo = createCommit();
+  setup(async () => {
+    element = await fixture(html`<gr-commit-info></gr-commit-info>`);
     element.serverConfig = createServerInfo();
-    await flush();
-    assert.isTrue(weblinksStub.called);
   });
 
-  test('no web link when unavailable', () => {
+  test('render nothing', async () => {
     element.commitInfo = createCommit();
-    element.serverConfig = createServerInfo();
-    element.change = {...createChange(), labels: {}, project: '' as RepoName};
+    await element.updateComplete;
 
-    assert.isNotOk(element._showWebLink);
+    assert.shadowDom.equal(element, '');
   });
 
-  test('use web link when available', () => {
-    const router = document.createElement('gr-router');
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router._generateWeblinks.bind(router));
-
-    element.change = {...createChange(), labels: {}, project: '' as RepoName};
+  test('web link from commit info', async () => {
     element.commitInfo = {
       ...createCommit(),
-      commit: 'commitsha' as CommitId,
+      commit: 'sha45678901234567890' as CommitId,
       web_links: [{name: 'gitweb', url: 'link-url'}],
     };
-    element.serverConfig = createServerInfo();
+    await element.updateComplete;
 
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'link-url');
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <a href="link-url" rel="noopener" target="_blank">sha4567</a>
+          <gr-copy-clipboard hastooltip="" hideinput=""> </gr-copy-clipboard>
+        </div>
+      `
+    );
   });
 
-  test('does not relativize web links that begin with scheme', () => {
-    const router = document.createElement('gr-router');
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router._generateWeblinks.bind(router));
-
-    element.change = {...createChange(), labels: {}, project: '' as RepoName};
+  test('web link fall back to search query', async () => {
     element.commitInfo = {
       ...createCommit(),
-      commit: 'commitsha' as CommitId,
-      web_links: [{name: 'gitweb', url: 'https://link-url'}],
+      commit: 'sha45678901234567890' as CommitId,
     };
-    element.serverConfig = createServerInfo();
+    await element.updateComplete;
 
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'https://link-url');
-  });
-
-  test('ignore web links that are neither gitweb nor gitiles', () => {
-    const router = document.createElement('gr-router');
-    sinon
-      .stub(GerritNav, '_generateWeblinks')
-      .callsFake(router._generateWeblinks.bind(router));
-
-    element.change = {...createChange(), project: 'project-name' as RepoName};
-    element.commitInfo = {
-      ...createCommit(),
-      commit: 'commit-sha' as CommitId,
-      web_links: [
-        {
-          name: 'ignore',
-          url: 'ignore',
-        },
-        {
-          name: 'gitiles',
-          url: 'https://link-url',
-        },
-      ],
-    };
-    element.serverConfig = createServerInfo();
-
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'https://link-url');
-
-    // Remove gitiles link.
-    element.commitInfo = {
-      ...createCommit(),
-      commit: 'commit-sha' as CommitId,
-      web_links: [
-        {
-          name: 'ignore',
-          url: 'ignore',
-        },
-      ],
-    };
-    assert.isNotOk(element._showWebLink);
-    assert.isNotOk(element._webLink);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <a href="/q/sha4567" rel="noopener" target="_blank">sha4567</a>
+          <gr-copy-clipboard hastooltip="" hideinput=""> </gr-copy-clipboard>
+        </div>
+      `
+    );
   });
 });
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 df537e0..85746df 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
@@ -1,33 +1,19 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-abandon-dialog_html';
-import {customElement, property} from '@polymer/decorators';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
-
-export interface GrConfirmAbandonDialog {
-  $: {
-    messageInput: IronAutogrowTextareaElement;
-  };
-}
+import {Key, Modifier} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {ChangeActionDialog} from '../../../types/common';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -36,11 +22,10 @@
 }
 
 @customElement('gr-confirm-abandon-dialog')
-export class GrConfirmAbandonDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmAbandonDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -53,43 +38,100 @@
    * @event cancel
    */
 
+  @query('#messageInput') private messageInput?: IronAutogrowTextareaElement;
+
   @property({type: String})
   message = '';
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private readonly shortcuts = new ShortcutController(this);
 
-  override disconnectedCallback() {
-    super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
+  constructor() {
+    super();
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+      () => this.confirm()
+    );
+
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+      _ => this.confirm()
+    );
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
-        this._confirm()
-      )
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
-        this._confirm()
-      )
-    );
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        label {
+          cursor: pointer;
+          display: block;
+          width: 100%;
+        }
+        iron-autogrow-textarea {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          width: 73ch; /* Add a char to account for the border. */
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-dialog
+        confirm-label="Abandon"
+        @confirm=${(e: Event) => {
+          this.handleConfirmTap(e);
+        }}
+        @cancel=${(e: Event) => {
+          this.handleCancelTap(e);
+        }}
+      >
+        <div class="header" slot="header">Abandon Change</div>
+        <div class="main" slot="main">
+          <label for="messageInput">Abandon Message</label>
+          <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+            placeholder="&lt;Insert reasoning here&gt;"
+            .bindValue=${this.message}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.handleBindValueChanged(e);
+            }}
+          ></iron-autogrow-textarea>
+        </div>
+      </gr-dialog>
+    `;
   }
 
   resetFocus() {
-    this.$.messageInput.textarea.focus();
+    assertIsDefined(this.messageInput, 'messageInput');
+    this.messageInput.textarea.focus();
   }
 
-  _handleConfirmTap(e: Event) {
+  // private but used in test
+  handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this._confirm();
+    this.confirm();
   }
 
-  _confirm() {
+  // private but used in test
+  confirm() {
     this.dispatchEvent(
       new CustomEvent('confirm', {
         detail: {reason: this.message},
@@ -99,7 +141,8 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  // private but used in test
+  handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -109,4 +152,8 @@
       })
     );
   }
+
+  private handleBindValueChanged(e: BindValueChangeEvent) {
+    this.message = e.detail.value ?? '';
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts
deleted file mode 100644
index 7c1b725..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts
+++ /dev/null
@@ -1,62 +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;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Abandon"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Abandon Change</div>
-    <div class="main" slot="main">
-      <label for="messageInput">Abandon Message</label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        placeholder="<Insert reasoning here>"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
index 08dda14..3602ebc 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
@@ -1,40 +1,51 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-confirm-abandon-dialog';
 import {GrConfirmAbandonDialog} from './gr-confirm-abandon-dialog';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-
-const basicFixture = fixtureFromElement('gr-confirm-abandon-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-confirm-abandon-dialog tests', () => {
   let element: GrConfirmAbandonDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-abandon-dialog></gr-confirm-abandon-dialog>`
+    );
   });
 
-  test('_handleConfirmTap', () => {
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog confirm-label="Abandon" role="dialog">
+          <div class="header" slot="header">Abandon Change</div>
+          <div class="main" slot="main">
+            <label for="messageInput"> Abandon Message </label>
+            <iron-autogrow-textarea
+              aria-disabled="false"
+              autocomplete="on"
+              class="message"
+              id="messageInput"
+              placeholder="<Insert reasoning here>"
+            >
+            </iron-autogrow-textarea>
+          </div>
+        </gr-dialog>
+      `
+    );
+  });
+
+  test('handleConfirmTap', () => {
     const confirmHandler = sinon.stub();
     element.addEventListener('confirm', confirmHandler);
-    const confirmTapSpy = sinon.spy(element, '_handleConfirmTap');
-    const confirmSpy = sinon.spy(element, '_confirm');
+    const confirmTapSpy = sinon.spy(element, 'handleConfirmTap');
+    const confirmSpy = sinon.spy(element, 'confirm');
     queryAndAssert<GrDialog>(element, 'gr-dialog').dispatchEvent(
       new CustomEvent('confirm', {
         composed: true,
@@ -48,10 +59,10 @@
     assert.isTrue(confirmSpy.calledOnce);
   });
 
-  test('_handleCancelTap', () => {
+  test('handleCancelTap', () => {
     const cancelHandler = sinon.stub();
     element.addEventListener('cancel', cancelHandler);
-    const cancelTapSpy = sinon.spy(element, '_handleCancelTap');
+    const cancelTapSpy = sinon.spy(element, 'handleCancelTap');
     queryAndAssert<GrDialog>(element, 'gr-dialog').dispatchEvent(
       new CustomEvent('cancel', {
         composed: true,
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 75b6053..02156df 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
@@ -1,37 +1,19 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/shared-styles';
+import {css, html, LitElement} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ChangeActionDialog} from '../../../types/common';
 import '../../shared/gr-dialog/gr-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-cherrypick-conflict-dialog_html';
-import {customElement} from '@polymer/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-confirm-cherrypick-conflict-dialog': GrConfirmCherrypickConflictDialog;
-  }
-}
 
 @customElement('gr-confirm-cherrypick-conflict-dialog')
-export class GrConfirmCherrypickConflictDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmCherrypickConflictDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -44,7 +26,44 @@
    * @event cancel
    */
 
-  _handleConfirmTap(e: Event) {
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: 0.5;
+        pointer-events: none;
+      }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-dialog
+        confirm-label="Continue"
+        @confirm=${this.handleConfirmTap}
+        @cancel=${this.handleCancelTap}
+      >
+        <div class="header" slot="header">Cherry Pick Conflict!</div>
+        <div class="main" slot="main">
+          <span>Cherry Pick failed! (merge conflicts)</span>
+          <span
+            >Please select "Continue" to continue with conflicts or select
+            "cancel" to close the dialog.</span
+          >
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -55,7 +74,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -66,3 +85,9 @@
     );
   }
 }
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-confirm-cherrypick-conflict-dialog': GrConfirmCherrypickConflictDialog;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts
deleted file mode 100644
index 5cf56b5..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_html.ts
+++ /dev/null
@@ -1,49 +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;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Continue"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Cherry Pick Conflict!</div>
-    <div class="main" slot="main">
-      <span>Cherry Pick failed! (merge conflicts)</span>
-
-      <span
-        >Please select "Continue" to continue with conflicts or select "cancel"
-        to close the dialog.</span
-      >
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
index f811619..891175f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
@@ -1,56 +1,65 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import {queryAndAssert} from '../../../utils/common-util';
-import {fireEvent} from '../../../utils/event-util';
 import './gr-confirm-cherrypick-conflict-dialog';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrConfirmCherrypickConflictDialog} from './gr-confirm-cherrypick-conflict-dialog';
-
-const basicFixture = fixtureFromElement(
-  'gr-confirm-cherrypick-conflict-dialog'
-);
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
   let element: GrConfirmCherrypickConflictDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-cherrypick-conflict-dialog></gr-confirm-cherrypick-conflict-dialog>`
+    );
   });
 
-  test('_handleConfirmTap', () => {
+  test('render', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+        <gr-dialog confirm-label="Continue" role="dialog">
+          <div class="header" slot="header">Cherry Pick Conflict!</div>
+          <div class="main" slot="main">
+            <span>Cherry Pick failed! (merge conflicts)</span>
+            <span
+              >Please select "Continue" to continue with conflicts or select
+            "cancel" to close the dialog.</span
+            >
+          </div>
+        </gr-dialog>
+      `
+    );
+  });
+
+  test('confirm', async () => {
     const confirmHandler = sinon.stub();
     element.addEventListener('confirm', confirmHandler);
-    const confirmTapStub = sinon.spy(element, '_handleConfirmTap');
-    fireEvent(queryAndAssert(element, 'gr-dialog'), 'confirm');
+
+    queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!.click();
+    await element.updateComplete;
+
     assert.isTrue(confirmHandler.called);
     assert.isTrue(confirmHandler.calledOnce);
-    assert.isTrue(confirmTapStub.called);
-    assert.isTrue(confirmTapStub.calledOnce);
   });
 
-  test('_handleCancelTap', () => {
+  test('cancel', async () => {
     const cancelHandler = sinon.stub();
     element.addEventListener('cancel', cancelHandler);
-    const cancelTapStub = sinon.spy(element, '_handleCancelTap');
-    fireEvent(queryAndAssert(element, 'gr-dialog'), 'cancel');
+
+    queryAndAssert<GrButton>(
+      queryAndAssert<GrDialog>(element, 'gr-dialog'),
+      'gr-button#cancel'
+    )!.click();
+    await element.updateComplete;
+
     assert.isTrue(cancelHandler.called);
     assert.isTrue(cancelHandler.calledOnce);
-    assert.isTrue(cancelTapStub.called);
-    assert.isTrue(cancelTapStub.calledOnce);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index b061043..5f3b824 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
@@ -1,41 +1,44 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-cherrypick-dialog_html';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {appContext} from '../../../services/app-context';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {getAppContext} from '../../../services/app-context';
 import {
   ChangeInfo,
   BranchName,
   RepoName,
   CommitId,
   ChangeInfoId,
+  TopicName,
+  ChangeActionDialog,
 } from '../../../types/common';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {customElement, property, observe} from '@polymer/decorators';
-import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {HttpMethod, ChangeStatus} from '../../../constants/constants';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+  GrTypedAutocomplete,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+  HttpMethod,
+  ChangeStatus,
+  ProgressStatus,
+} from '../../../constants/constants';
 import {fireEvent} from '../../../utils/event-util';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {choose} from 'lit/directives/choose.js';
+import {when} from 'lit/directives/when.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {createSearchUrl} from '../../../models/views/search';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -44,15 +47,7 @@
   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};
+export type Statuses = {[changeId: string]: Status};
 
 interface Status {
   status: ProgressStatus;
@@ -65,18 +60,11 @@
   }
 }
 
-export interface GrConfirmCherrypickDialog {
-  $: {
-    branchInput: GrTypedAutocomplete<BranchName>;
-  };
-}
-
 @customElement('gr-confirm-cherrypick-dialog')
-export class GrConfirmCherrypickDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmCherrypickDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -113,40 +101,295 @@
   @property({type: Array})
   changes: ChangeInfo[] = [];
 
-  @property({type: Object})
-  _query?: (input: string) => Promise<{name: BranchName}[]>;
+  @state()
+  private query: AutocompleteQuery;
 
-  @property({type: Boolean})
-  _showCherryPickTopic = false;
+  @state()
+  private showCherryPickTopic = false;
 
-  @property({type: Number})
-  _changesCount?: number;
+  @state()
+  private changesCount?: number;
 
-  @property({type: Number})
-  _cherryPickType = CherryPickType.SINGLE_CHANGE;
+  @state()
+  cherryPickType = CherryPickType.SINGLE_CHANGE;
 
-  @property({type: Boolean})
-  _duplicateProjectChanges = false;
+  @state()
+  private duplicateProjectChanges = false;
 
-  @property({type: Object})
+  @state()
   // Status of each change that is being cherry picked together
-  _statuses: Statuses;
+  private statuses: Statuses;
 
-  @property({type: Boolean})
-  _invalidBranch = false;
+  @state()
+  private invalidBranch = false;
 
-  @property({type: Object})
-  reporting: ReportingService;
+  @query('#branchInput')
+  branchInput!: GrTypedAutocomplete<BranchName>;
 
   private selectedChangeIds = new Set<ChangeInfoId>();
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getNavigation = resolve(this, navigationToken);
 
   constructor() {
     super();
-    this._statuses = {};
-    this.reporting = appContext.reportingService;
-    this._query = (text: string) => this._getProjectBranchesSuggestions(text);
+    this.statuses = {};
+    this.query = (text: string) => this.getProjectBranchesSuggestions(text);
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('branch')) {
+      this.updateBranch();
+    }
+    if (
+      changedProperties.has('changeStatus') ||
+      changedProperties.has('commitNum') ||
+      changedProperties.has('commitMessage')
+    ) {
+      this.computeMessage();
+    }
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: 0.5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+      }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+      .main label,
+      .main input[type='text'] {
+        display: block;
+        width: 100%;
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
+        width: 73ch; /* Add a char to account for the border. */
+      }
+      .cherryPickTopicLayout {
+        display: flex;
+        align-items: center;
+        margin-bottom: var(--spacing-m);
+      }
+      .cherryPickSingleChange,
+      .cherryPickTopic {
+        margin-left: var(--spacing-m);
+      }
+      .cherry-pick-topic-message {
+        margin-bottom: var(--spacing-m);
+      }
+      label[for='messageInput'],
+      label[for='baseInput'] {
+        margin-top: var(--spacing-m);
+      }
+      .title {
+        font-weight: var(--font-weight-bold);
+      }
+      tr > td {
+        padding: var(--spacing-m);
+      }
+      th {
+        color: var(--deemphasized-text-color);
+      }
+      table {
+        border-collapse: collapse;
+      }
+      tr {
+        border-bottom: 1px solid var(--border-color);
+      }
+      .error {
+        color: var(--error-text-color);
+      }
+      .error-message {
+        color: var(--error-text-color);
+        margin: var(--spacing-m) 0 var(--spacing-m) 0;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-dialog
+        confirm-label="Cherry Pick"
+        .cancelLabel=${this.computeCancelLabel()}
+        ?disabled=${this.computeDisableCherryPick(
+          this.cherryPickType,
+          this.duplicateProjectChanges,
+          this.statuses,
+          this.branch
+        )}
+        @confirm=${this.handleConfirmTap}
+        @cancel=${this.handleCancelTap}
+      >
+        <div class="header title" slot="header">
+          Cherry Pick Change to Another Branch
+        </div>
+        <div class="main" slot="main">
+          <gr-endpoint-decorator name="cherrypick-main">
+            <gr-endpoint-param name="changes" .value=${this.changes}>
+            </gr-endpoint-param>
+            <gr-endpoint-slot name="top"></gr-endpoint-slot>
+            ${when(this.showCherryPickTopic, () =>
+              this.renderCherrypickTopicLayout()
+            )}
+            <label for="branchInput"> Cherry Pick to branch </label>
+            <gr-autocomplete
+              id="branchInput"
+              .text=${this.branch}
+              .query=${this.query}
+              placeholder="Destination branch"
+              @text-changed=${(e: BindValueChangeEvent) =>
+                (this.branch = e.detail.value as BranchName)}
+            >
+            </gr-autocomplete>
+            ${when(
+              this.invalidBranch,
+              () => html`
+                <span class="error"
+                  >Branch name cannot contain space or commas.</span
+                >
+              `
+            )}
+            ${choose(this.cherryPickType, [
+              [
+                CherryPickType.SINGLE_CHANGE,
+                () => this.renderCherrypickSingleChangeInputs(),
+              ],
+              [CherryPickType.TOPIC, () => this.renderCherrypickTopicTable()],
+            ])}
+            <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+          </gr-endpoint-decorator>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private renderCherrypickTopicLayout() {
+    return html`
+      <div class="cherryPickTopicLayout">
+        <input
+          name="cherryPickOptions"
+          type="radio"
+          id="cherryPickSingleChange"
+          @change=${this.handlecherryPickSingleChangeClicked}
+          checked
+        />
+        <label for="cherryPickSingleChange" class="cherryPickSingleChange">
+          Cherry Pick single change
+        </label>
+      </div>
+      <div class="cherryPickTopicLayout">
+        <input
+          name="cherryPickOptions"
+          type="radio"
+          id="cherryPickTopic"
+          @change=${this.handlecherryPickTopicClicked}
+        />
+        <label for="cherryPickTopic" class="cherryPickTopic">
+          Cherry Pick entire topic (${this.changesCount} Changes)
+        </label>
+      </div>
+    `;
+  }
+
+  private renderCherrypickSingleChangeInputs() {
+    return html`
+      <label for="baseInput"> Provide base commit sha1 for cherry-pick </label>
+      <iron-input
+        .bindValue=${this.baseCommit}
+        @bind-value-changed=${(e: BindValueChangeEvent) =>
+          (this.baseCommit = e.detail.value)}
+      >
+        <input
+          is="iron-input"
+          id="baseCommitInput"
+          maxlength="40"
+          placeholder="(optional)"
+        />
+      </iron-input>
+      <label for="messageInput"> Cherry Pick Commit Message </label>
+      <iron-autogrow-textarea
+        id="messageInput"
+        class="message"
+        autocomplete="on"
+        rows="4"
+        .maxRows=${15}
+        .bindValue=${this.message}
+        @bind-value-changed=${(e: BindValueChangeEvent) =>
+          (this.message = e.detail.value ?? '')}
+      ></iron-autogrow-textarea>
+    `;
+  }
+
+  private renderCherrypickTopicTable() {
+    return html`
+      <span class="error-message">${this.computeTopicErrorMessage()}</span>
+      <span class="cherry-pick-topic-message">
+        Commit Message will be auto generated
+      </span>
+      <table>
+        <thead>
+          <tr>
+            <th></th>
+            <th>Change</th>
+            <th>Status</th>
+            <th>Subject</th>
+            <th>Project</th>
+            <th>Progress</th>
+            <!-- Error Message -->
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${this.changes.map(
+            item => html`
+              <tr>
+                <td>
+                  <input
+                    type="checkbox"
+                    data-item=${item.id as string}
+                    @change=${this.toggleChangeSelected}
+                    ?checked=${this.isChangeSelected(item.id)}
+                  />
+                </td>
+                <td><span> ${this.getChangeId(item)} </span></td>
+                <td><span> ${item.status} </span></td>
+                <td>
+                  <span> ${this.getTrimmedChangeSubject(item.subject)} </span>
+                </td>
+                <td><span> ${item.project} </span></td>
+                <td>
+                  <span class=${this.computeStatusClass(item, this.statuses)}>
+                    ${this.computeStatus(item, this.statuses)}
+                  </span>
+                </td>
+                <td>
+                  <span class="error">
+                    ${this.computeError(item, this.statuses)}
+                  </span>
+                </td>
+              </tr>
+            `
+          )}
+        </tbody>
+      </table>
+    `;
   }
 
   containsDuplicateProject(changes: ChangeInfo[]) {
@@ -163,63 +406,60 @@
 
   updateChanges(changes: ChangeInfo[]) {
     this.changes = changes;
-    this._statuses = {};
+    this.statuses = {};
     changes.forEach(change => {
       this.selectedChangeIds.add(change.id);
     });
-    this._duplicateProjectChanges = this.containsDuplicateProject(changes);
-    this._changesCount = changes.length;
-    this._showCherryPickTopic = changes.length > 1;
+    this.duplicateProjectChanges = this.containsDuplicateProject(changes);
+    this.changesCount = changes.length;
+    this.showCherryPickTopic = changes.length > 1;
   }
 
-  @observe('branch')
-  _updateBranch(branch: string) {
+  private updateBranch() {
     const invalidChars = [',', ' '];
-    this._invalidBranch = !!(
-      branch && invalidChars.some(c => branch.includes(c))
+    this.invalidBranch = !!(
+      this.branch && invalidChars.some(c => this.branch.includes(c))
     );
   }
 
-  _isChangeSelected(changeId: ChangeInfoId) {
+  private isChangeSelected(changeId: ChangeInfoId) {
     return this.selectedChangeIds.has(changeId);
   }
 
-  _toggleChangeSelected(e: Event) {
-    const changeId = ((dom(e) as EventApi).localTarget as HTMLElement).dataset[
-      'item'
-    ]! as ChangeInfoId;
+  private toggleChangeSelected(e: Event) {
+    const changeId = (e.target as HTMLElement).dataset['item']! as ChangeInfoId;
     if (this.selectedChangeIds.has(changeId))
       this.selectedChangeIds.delete(changeId);
     else this.selectedChangeIds.add(changeId);
     const changes = this.changes.filter(change =>
       this.selectedChangeIds.has(change.id)
     );
-    this._duplicateProjectChanges = this.containsDuplicateProject(changes);
+    this.duplicateProjectChanges = this.containsDuplicateProject(changes);
   }
 
-  _computeTopicErrorMessage(duplicateProjectChanges: boolean) {
-    if (duplicateProjectChanges) {
+  private computeTopicErrorMessage() {
+    if (this.duplicateProjectChanges) {
       return 'Two changes cannot be of the same project';
     }
     return '';
   }
 
   updateStatus(change: ChangeInfo, status: Status) {
-    this._statuses = {...this._statuses, [change.id]: status};
+    this.statuses = {...this.statuses, [change.id]: status};
   }
 
-  _computeStatus(change: ChangeInfo, statuses: Statuses) {
+  private computeStatus(change: ChangeInfo, statuses: Statuses) {
     if (!change || !statuses || !statuses[change.id])
       return ProgressStatus.NOT_STARTED;
     return statuses[change.id].status;
   }
 
-  _computeStatusClass(change: ChangeInfo, statuses: Statuses) {
+  computeStatusClass(change: ChangeInfo, statuses: Statuses) {
     if (!change || !statuses || !statuses[change.id]) return '';
     return statuses[change.id].status === ProgressStatus.FAILED ? 'error' : '';
   }
 
-  _computeError(change: ChangeInfo, statuses: Statuses) {
+  private computeError(change: ChangeInfo, statuses: Statuses) {
     if (!change || !statuses || !statuses[change.id]) return '';
     if (statuses[change.id].status === ProgressStatus.FAILED) {
       return statuses[change.id].msg;
@@ -227,24 +467,24 @@
     return '';
   }
 
-  _getChangeId(change: ChangeInfo) {
+  private getChangeId(change: ChangeInfo) {
     return change.change_id.substring(0, 10);
   }
 
-  _getTrimmedChangeSubject(subject: string) {
+  private getTrimmedChangeSubject(subject: string) {
     if (!subject) return '';
     if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
     return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
   }
 
-  _computeCancelLabel(statuses: Statuses) {
-    const isRunningChange = Object.values(statuses).some(
+  private computeCancelLabel() {
+    const isRunningChange = Object.values(this.statuses).some(
       v => v.status === ProgressStatus.RUNNING
     );
     return isRunningChange ? 'Close' : 'Cancel';
   }
 
-  _computeDisableCherryPick(
+  private computeDisableCherryPick(
     cherryPickType: CherryPickType,
     duplicateProjectChanges: boolean,
     statuses: Statuses,
@@ -261,64 +501,53 @@
     return isRunningChange;
   }
 
-  _computeIfSinglecherryPick(cherryPickType: CherryPickType) {
-    return cherryPickType === CherryPickType.SINGLE_CHANGE;
-  }
-
-  _computeIfCherryPickTopic(cherryPickType: CherryPickType) {
-    return cherryPickType === CherryPickType.TOPIC;
-  }
-
-  _handlecherryPickSingleChangeClicked() {
-    this._cherryPickType = CherryPickType.SINGLE_CHANGE;
+  private handlecherryPickSingleChangeClicked() {
+    this.cherryPickType = CherryPickType.SINGLE_CHANGE;
     fireEvent(this, 'iron-resize');
   }
 
-  _handlecherryPickTopicClicked() {
-    this._cherryPickType = CherryPickType.TOPIC;
+  private handlecherryPickTopicClicked() {
+    this.cherryPickType = CherryPickType.TOPIC;
     fireEvent(this, 'iron-resize');
   }
 
-  @observe('changeStatus', 'commitNum', 'commitMessage')
-  _computeMessage(
-    changeStatus?: string,
-    commitNum?: number,
-    commitMessage?: string
-  ) {
-    // Polymer 2: check for undefined
+  private computeMessage() {
     if (
-      changeStatus === undefined ||
-      commitNum === undefined ||
-      commitMessage === undefined
+      this.changeStatus === undefined ||
+      this.commitNum === undefined ||
+      this.commitMessage === undefined
     ) {
       return;
     }
 
-    let newMessage = commitMessage;
+    let newMessage = this.commitMessage;
 
-    if (changeStatus === 'MERGED') {
+    if (this.changeStatus === 'MERGED') {
       if (!newMessage.endsWith('\n')) {
         newMessage += '\n';
       }
-      newMessage += '(cherry picked from commit ' + commitNum.toString() + ')';
+      newMessage += '(cherry picked from commit ' + this.commitNum + ')';
     }
     this.message = newMessage;
   }
 
-  _generateRandomCherryPickTopic(change: ChangeInfo) {
+  private generateRandomCherryPickTopic(change: ChangeInfo) {
     const randomString = Math.random().toString(36).substr(2, 10);
     const message = `cherrypick-${change.topic}-${randomString}`;
     return message;
   }
 
-  _handleCherryPickFailed(change: ChangeInfo, response?: Response | null) {
+  private handleCherryPickFailed(
+    change: ChangeInfo,
+    response?: Response | null
+  ) {
     if (!response) return;
     response.text().then((errText: string) => {
       this.updateStatus(change, {status: ProgressStatus.FAILED, msg: errText});
     });
   }
 
-  _handleCherryPickTopic() {
+  private handleCherryPickTopic() {
     const changes = this.changes.filter(change =>
       this.selectedChangeIds.has(change.id)
     );
@@ -327,7 +556,7 @@
       errorSpan!.innerHTML = 'No change selected';
       return;
     }
-    const topic = this._generateRandomCherryPickTopic(changes[0]);
+    const topic = this.generateRandomCherryPickTopic(changes[0]);
     changes.forEach(change => {
       this.updateStatus(change, {status: ProgressStatus.RUNNING});
       const payload = {
@@ -338,7 +567,7 @@
         allow_empty: true,
       };
       const handleError = (response?: Response | null) => {
-        this._handleCherryPickFailed(change, response);
+        this.handleCherryPickFailed(change, response);
       };
       // revisions and current_revision must exist hence casting
       const patchNum = change.revisions![change.current_revision!]._number;
@@ -353,24 +582,26 @@
         )
         .then(() => {
           this.updateStatus(change, {status: ProgressStatus.SUCCESSFUL});
-          const failedOrPending = Object.values(this._statuses).find(
+          const failedOrPending = Object.values(this.statuses).find(
             v => v.status !== ProgressStatus.SUCCESSFUL
           );
           if (!failedOrPending) {
-            /* This needs some more work, as the new topic may not always be
-          created, instead we may end up creating a new patchset */
-            GerritNav.navigateToSearchQuery(`topic: "${topic}"`);
+            // This needs some more work, as the new topic may not always be
+            // created, instead we may end up creating a new patchset */
+            this.getNavigation().setUrl(
+              createSearchUrl({topic: topic as TopicName})
+            );
           }
         });
     });
   }
 
-  _handleConfirmTap(e: Event) {
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    if (this._cherryPickType === CherryPickType.TOPIC) {
+    if (this.cherryPickType === CherryPickType.TOPIC) {
       this.reporting.reportInteraction('cherry-pick-topic-clicked', {});
-      this._handleCherryPickTopic();
+      this.handleCherryPickTopic();
       return;
     }
     // Cherry pick single change
@@ -382,7 +613,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -394,16 +625,24 @@
   }
 
   resetFocus() {
-    this.$.branchInput.focus();
+    this.branchInput.focus();
   }
 
-  _getProjectBranchesSuggestions(input: string) {
+  async getProjectBranchesSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion[]> {
     if (!this.project) return Promise.reject(new Error('Missing project'));
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.project,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
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
deleted file mode 100644
index d42f7e5..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts
+++ /dev/null
@@ -1,223 +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;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    .main label,
-    .main input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-    .cherryPickTopicLayout {
-      display: flex;
-      align-items: center;
-      margin-bottom: var(--spacing-m);
-    }
-    .cherryPickSingleChange,
-    .cherryPickTopic {
-      margin-left: var(--spacing-m);
-    }
-    .cherry-pick-topic-message {
-      margin-bottom: var(--spacing-m);
-    }
-    label[for='messageInput'],
-    label[for='baseInput'] {
-      margin-top: var(--spacing-m);
-    }
-    .title {
-      font-weight: var(--font-weight-bold);
-    }
-    tr > td {
-      padding: var(--spacing-m);
-    }
-    th {
-      color: var(--deemphasized-text-color);
-    }
-    table {
-      border-collapse: collapse;
-    }
-    tr {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .error {
-      color: var(--error-text-color);
-    }
-    .error-message {
-      color: var(--error-text-color);
-      margin: var(--spacing-m) 0 var(--spacing-m) 0;
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Cherry Pick"
-    cancel-label="[[_computeCancelLabel(_statuses)]]"
-    disabled$="[[_computeDisableCherryPick(_cherryPickType, _duplicateProjectChanges, _statuses, branch)]]"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header title" slot="header">
-      Cherry Pick Change to Another Branch
-    </div>
-    <div class="main" slot="main">
-      <template is="dom-if" if="[[_showCherryPickTopic]]">
-        <div class="cherryPickTopicLayout">
-          <input
-            name="cherryPickOptions"
-            type="radio"
-            id="cherryPickSingleChange"
-            on-change="_handlecherryPickSingleChangeClicked"
-            checked=""
-          />
-          <label for="cherryPickSingleChange" class="cherryPickSingleChange">
-            Cherry Pick single change
-          </label>
-        </div>
-        <div class="cherryPickTopicLayout">
-          <input
-            name="cherryPickOptions"
-            type="radio"
-            id="cherryPickTopic"
-            on-change="_handlecherryPickTopicClicked"
-          />
-          <label for="cherryPickTopic" class="cherryPickTopic">
-            Cherry Pick entire topic ([[_changesCount]] Changes)
-          </label>
-        </div></template
-      >
-
-      <label for="branchInput"> Cherry Pick to branch </label>
-      <gr-autocomplete
-        id="branchInput"
-        text="{{branch}}"
-        query="[[_query]]"
-        placeholder="Destination branch"
-      >
-      </gr-autocomplete>
-      <template is="dom-if" if="[[_invalidBranch]]">
-        <span class="error"> Branch name cannot contain space or commas. </span>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_computeIfSinglecherryPick(_cherryPickType)]]"
-      >
-        <label for="baseInput">
-          Provide base commit sha1 for cherry-pick
-        </label>
-        <iron-input
-          maxlength="40"
-          placeholder="(optional)"
-          bind-value="{{baseCommit}}"
-        >
-          <input
-            is="iron-input"
-            id="baseCommitInput"
-            maxlength="40"
-            placeholder="(optional)"
-            bind-value="{{baseCommit}}"
-          />
-        </iron-input>
-        <label for="messageInput"> Cherry Pick Commit Message </label>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_computeIfSinglecherryPick(_cherryPickType)]]"
-      >
-        <iron-autogrow-textarea
-          id="messageInput"
-          class="message"
-          autocomplete="on"
-          rows="4"
-          max-rows="15"
-          bind-value="{{message}}"
-        ></iron-autogrow-textarea>
-      </template>
-      <template is="dom-if" if="[[_computeIfCherryPickTopic(_cherryPickType)]]">
-        <span class="error-message"
-          >[[_computeTopicErrorMessage(_duplicateProjectChanges)]]</span
-        >
-        <span class="cherry-pick-topic-message">
-          Commit Message will be auto generated
-        </span>
-        <table>
-          <thead>
-            <tr>
-              <th></th>
-              <th>Change</th>
-              <th>Status</th>
-              <th>Subject</th>
-              <th>Project</th>
-              <th>Progress</th>
-              <!-- Error Message -->
-              <th></th>
-            </tr>
-          </thead>
-          <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>
-                <td><span> [[item.project]] </span></td>
-                <td>
-                  <span class$="[[_computeStatusClass(item, _statuses)]]">
-                    [[_computeStatus(item, _statuses)]]
-                  </span>
-                </td>
-                <td>
-                  <span class="error">
-                    [[_computeError(item, _statuses)]]
-                  </span>
-                </td>
-              </tr>
-            </template>
-          </tbody>
-        </table>
-      </template>
-    </div>
-  </gr-dialog>
-`;
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
deleted file mode 100644
index 3df997f8..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
+++ /dev/null
@@ -1,212 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-cherrypick-dialog.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-cherrypick-dialog');
-
-const CHERRY_PICK_TYPES = {
-  SINGLE_CHANGE: 1,
-  TOPIC: 2,
-};
-suite('gr-confirm-cherrypick-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    stubRestApi('getRepoBranches').callsFake(input => {
-      if (input.startsWith('test')) {
-        return Promise.resolve([
-          {
-            ref: 'refs/heads/test-branch',
-            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
-            can_delete: true,
-          },
-        ]);
-      } else {
-        return Promise.resolve([]);
-      }
-    });
-    element = basicFixture.instantiate();
-    element.project = 'test-project';
-  });
-
-  test('with message missing newline', () => {
-    element.changeStatus = 'MERGED';
-    element.commitMessage = 'message';
-    element.commitNum = '123';
-    element.branch = 'master';
-    flush();
-    const expectedMessage = 'message\n(cherry picked from commit 123)';
-    assert.equal(element.message, expectedMessage);
-  });
-
-  test('with merged change', () => {
-    element.changeStatus = 'MERGED';
-    element.commitMessage = 'message\n';
-    element.commitNum = '123';
-    element.branch = 'master';
-    flush();
-    const expectedMessage = 'message\n(cherry picked from commit 123)';
-    assert.equal(element.message, expectedMessage);
-  });
-
-  test('with unmerged change', () => {
-    element.changeStatus = 'OPEN';
-    element.commitMessage = 'message\n';
-    element.commitNum = '123';
-    element.branch = 'master';
-    flush();
-    const expectedMessage = 'message\n';
-    assert.equal(element.message, expectedMessage);
-  });
-
-  test('with updated commit message', () => {
-    element.changeStatus = 'OPEN';
-    element.commitMessage = 'message\n';
-    element.commitNum = '123';
-    element.branch = 'master';
-    const myNewMessage = 'updated commit message';
-    element.message = myNewMessage;
-    flush();
-    assert.equal(element.message, myNewMessage);
-  });
-
-  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,
-        revisions: {
-          a: {_number: 1},
-        },
-        current_revision: 'a',
-      },
-      {
-        id: '5678',
-        change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-        project: 'B',
-        _number: 2,
-        revisions: {
-          a: {_number: 1},
-        },
-        current_revision: 'a',
-      },
-    ];
-    setup(async () => {
-      element.updateChanges(changes);
-      element._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
-      await flush();
-    });
-
-    test('cherry pick topic submit', async () => {
-      element.branch = 'master';
-      await flush();
-      const executeChangeActionStub = stubRestApi(
-          'executeChangeAction').returns(Promise.resolve([]));
-      MockInteractions.tap(element.shadowRoot.
-          querySelector('gr-dialog').confirmButton);
-      await flush();
-      const args = executeChangeActionStub.args[0];
-      assert.equal(args[0], 1);
-      assert.equal(args[1], 'POST');
-      assert.equal(args[2], '/cherrypick');
-      assert.equal(args[4].destination, 'master');
-      assert.isTrue(args[4].allow_conflicts);
-      assert.isTrue(args[4].allow_empty);
-    });
-
-    test('deselecting a change removes it from being cherry picked',
-        async () => {
-          const duplicateChangesStub = sinon.stub(element,
-              'containsDuplicateProject');
-          element.branch = 'master';
-          await flush();
-          const executeChangeActionStub = stubRestApi(
-              'executeChangeAction').returns(Promise.resolve([]));
-          const checkboxes = element.shadowRoot.querySelectorAll(
-              'input[type="checkbox"]');
-          assert.equal(checkboxes.length, 2);
-          assert.isTrue(checkboxes[0].checked);
-          MockInteractions.tap(checkboxes[0]);
-          MockInteractions.tap(element.shadowRoot.
-              querySelector('gr-dialog').confirmButton);
-          await flush();
-          assert.equal(executeChangeActionStub.callCount, 1);
-          assert.isTrue(duplicateChangesStub.called);
-        });
-
-    test('deselecting all change shows error message', async () => {
-      element.branch = 'master';
-      await flush();
-      const executeChangeActionStub = stubRestApi(
-          'executeChangeAction').returns(Promise.resolve([]));
-      const checkboxes = element.shadowRoot.querySelectorAll(
-          'input[type="checkbox"]');
-      assert.equal(checkboxes.length, 2);
-      MockInteractions.tap(checkboxes[0]);
-      MockInteractions.tap(checkboxes[1]);
-      MockInteractions.tap(element.shadowRoot.
-          querySelector('gr-dialog').confirmButton);
-      await 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'},
-      }), '');
-      assert.equal(element._computeStatusClass({id: 1}, {1: {status: 'FAILED'}}
-      ), 'error');
-    });
-
-    test('submit button is blocked while cherry picks is running', async () => {
-      const confirmButton = element.shadowRoot.querySelector('gr-dialog')
-          .confirmButton;
-      assert.isTrue(confirmButton.hasAttribute('disabled'));
-      element.branch = 'b';
-      await flush();
-      assert.isFalse(confirmButton.hasAttribute('disabled'));
-      element.updateStatus(changes[0], {status: 'RUNNING'});
-      await flush();
-      assert.isTrue(confirmButton.hasAttribute('disabled'));
-    });
-  });
-
-  test('resetFocus', () => {
-    const focusStub = sinon.stub(element.$.branchInput, 'focus');
-    element.resetFocus();
-    assert.isTrue(focusStub.called);
-  });
-
-  test('_getProjectBranchesSuggestions non-empty', async () => {
-    const branches = await element._getProjectBranchesSuggestions(
-        'test-branch');
-    assert.equal(branches.length, 1);
-    assert.equal(branches[0].name, 'test-branch');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
new file mode 100644
index 0000000..dc8dba9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
@@ -0,0 +1,293 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-confirm-cherrypick-dialog';
+import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrConfirmCherrypickDialog} from './gr-confirm-cherrypick-dialog';
+import {
+  BranchName,
+  ChangeId,
+  ChangeInfo,
+  ChangeInfoId,
+  ChangeStatus,
+  CommitId,
+  GitRef,
+  HttpMethod,
+  NumericChangeId,
+  RepoName,
+  TopicName,
+} from '../../../api/rest-api';
+import {createChange, createRevision} from '../../../test/test-data-generators';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {ProgressStatus} from '../../../constants/constants';
+import {fixture, html, assert} from '@open-wc/testing';
+
+const CHERRY_PICK_TYPES = {
+  SINGLE_CHANGE: 1,
+  TOPIC: 2,
+};
+suite('gr-confirm-cherrypick-dialog tests', () => {
+  let element: GrConfirmCherrypickDialog;
+
+  setup(async () => {
+    stubRestApi('getRepoBranches').callsFake(input => {
+      if (input.startsWith('test')) {
+        return Promise.resolve([
+          {
+            ref: 'refs/heads/test-branch' as GitRef,
+            revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+            can_delete: true,
+          },
+        ]);
+      } else {
+        return Promise.resolve([]);
+      }
+    });
+    element = await fixture(
+      html`<gr-confirm-cherrypick-dialog></gr-confirm-cherrypick-dialog>`
+    );
+    element.project = 'test-project' as RepoName;
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog confirm-label="Cherry Pick" disabled="" role="dialog">
+          <div class="header title" slot="header">
+            Cherry Pick Change to Another Branch
+          </div>
+          <div class="main" slot="main">
+            <gr-endpoint-decorator name="cherrypick-main">
+              <gr-endpoint-param name="changes"> </gr-endpoint-param>
+              <gr-endpoint-slot name="top"> </gr-endpoint-slot>
+              <label for="branchInput"> Cherry Pick to branch </label>
+              <gr-autocomplete
+                id="branchInput"
+                placeholder="Destination branch"
+              >
+              </gr-autocomplete>
+              <label for="baseInput">
+                Provide base commit sha1 for cherry-pick
+              </label>
+              <iron-input>
+                <input
+                  id="baseCommitInput"
+                  is="iron-input"
+                  maxlength="40"
+                  placeholder="(optional)"
+                />
+              </iron-input>
+              <label for="messageInput"> Cherry Pick Commit Message </label>
+              <iron-autogrow-textarea
+                aria-disabled="false"
+                autocomplete="on"
+                class="message"
+                id="messageInput"
+                rows="4"
+              >
+              </iron-autogrow-textarea>
+              <gr-endpoint-slot name="bottom"></gr-endpoint-slot>
+            </gr-endpoint-decorator>
+          </div>
+        </gr-dialog>
+      `
+    );
+  });
+
+  test('with message missing newline', async () => {
+    element.changeStatus = ChangeStatus.MERGED;
+    element.commitMessage = 'message';
+    element.commitNum = '123' as CommitId;
+    element.branch = 'master' as BranchName;
+    await element.updateComplete;
+    const expectedMessage = 'message\n(cherry picked from commit 123)';
+    assert.equal(element.message, expectedMessage);
+  });
+
+  test('with merged change', async () => {
+    element.changeStatus = ChangeStatus.MERGED;
+    element.commitMessage = 'message\n';
+    element.commitNum = '123' as CommitId;
+    element.branch = 'master' as BranchName;
+    await element.updateComplete;
+    const expectedMessage = 'message\n(cherry picked from commit 123)';
+    assert.equal(element.message, expectedMessage);
+  });
+
+  test('with unmerged change', async () => {
+    element.changeStatus = ChangeStatus.NEW;
+    element.commitMessage = 'message\n';
+    element.commitNum = '123' as CommitId;
+    element.branch = 'master' as BranchName;
+    await element.updateComplete;
+
+    const expectedMessage = 'message\n';
+    assert.equal(element.message, expectedMessage);
+  });
+
+  test('with updated commit message', async () => {
+    element.changeStatus = ChangeStatus.NEW;
+    element.commitMessage = 'message\n';
+    element.commitNum = '123' as CommitId;
+    element.branch = 'master' as BranchName;
+    await element.updateComplete;
+
+    const myNewMessage = 'updated commit message';
+    element.message = myNewMessage;
+    await element.updateComplete;
+
+    assert.equal(element.message, myNewMessage);
+  });
+
+  test('getProjectBranchesSuggestions empty', async () => {
+    const branches = await element.getProjectBranchesSuggestions('asdf');
+    assert.isEmpty(branches);
+  });
+
+  suite('cherry pick topic', () => {
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        id: '1234' as ChangeInfoId,
+        change_id: '12345678901234' as ChangeId,
+        topic: 'T' as TopicName,
+        subject: 'random',
+        project: 'A' as RepoName,
+        _number: 1 as NumericChangeId,
+        revisions: {
+          a: createRevision(),
+        },
+        current_revision: 'a' as CommitId,
+      },
+      {
+        ...createChange(),
+        id: '5678' as ChangeInfoId,
+        change_id: '23456' as ChangeId,
+        topic: 'T' as TopicName,
+        subject: 'a'.repeat(100),
+        project: 'B' as RepoName,
+        _number: 2 as NumericChangeId,
+        revisions: {
+          a: createRevision(),
+        },
+        current_revision: 'a' as CommitId,
+      },
+    ];
+    setup(async () => {
+      element.updateChanges(changes);
+      element.cherryPickType = CHERRY_PICK_TYPES.TOPIC;
+      await element.updateComplete;
+    });
+
+    test('cherry pick topic submit', async () => {
+      element.branch = 'master' as BranchName;
+      await element.updateComplete;
+      const executeChangeActionStub = stubRestApi(
+        'executeChangeAction'
+      ).returns(Promise.resolve(new Response()));
+      queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!.click();
+      await element.updateComplete;
+      const args = executeChangeActionStub.args[0];
+      assert.equal(args[0], 1);
+      assert.equal(args[1], 'POST' as HttpMethod);
+      assert.equal(args[2], '/cherrypick');
+      assert.equal((args[4] as any).destination, 'master');
+      assert.isTrue((args[4] as any).allow_conflicts);
+      assert.isTrue((args[4] as any).allow_empty);
+    });
+
+    test('deselecting a change removes it from being cherry picked', async () => {
+      const duplicateChangesStub = sinon.stub(
+        element,
+        'containsDuplicateProject'
+      );
+      element.branch = 'master' as BranchName;
+      await element.updateComplete;
+      const executeChangeActionStub = stubRestApi(
+        'executeChangeAction'
+      ).returns(Promise.resolve(new Response()));
+      const checkboxes = queryAll<HTMLInputElement>(
+        element,
+        'input[type="checkbox"]'
+      );
+      assert.equal(checkboxes.length, 2);
+      assert.isTrue(checkboxes[0].checked);
+      checkboxes[0].click();
+      queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!.click();
+      await element.updateComplete;
+      assert.equal(executeChangeActionStub.callCount, 1);
+      assert.isTrue(duplicateChangesStub.called);
+    });
+
+    test('deselecting all change shows error message', async () => {
+      element.branch = 'master' as BranchName;
+      await element.updateComplete;
+      const executeChangeActionStub = stubRestApi(
+        'executeChangeAction'
+      ).returns(Promise.resolve(new Response()));
+      const checkboxes = queryAll<HTMLInputElement>(
+        element,
+        'input[type="checkbox"]'
+      );
+      assert.equal(checkboxes.length, 2);
+      checkboxes[0].click();
+      checkboxes[1].click();
+      queryAndAssert<GrDialog>(element, 'gr-dialog').confirmButton!.click();
+      await element.updateComplete;
+      assert.equal(executeChangeActionStub.callCount, 0);
+      assert.equal(
+        queryAndAssert<HTMLElement>(element, '.error-message').innerText,
+        'No change selected'
+      );
+    });
+
+    test('computeStatusClass', async () => {
+      assert.equal(
+        element.computeStatusClass(
+          {...createChange(), id: '1' as ChangeInfoId},
+          {1: {status: ProgressStatus.RUNNING}}
+        ),
+        ''
+      );
+      assert.equal(
+        element.computeStatusClass(
+          {...createChange(), id: '1' as ChangeInfoId},
+          {1: {status: ProgressStatus.FAILED}}
+        ),
+        'error'
+      );
+    });
+
+    test('submit button is blocked while cherry picks is running', async () => {
+      const confirmButton = queryAndAssert<GrDialog>(
+        element,
+        'gr-dialog'
+      ).confirmButton;
+      assert.isTrue(confirmButton!.hasAttribute('disabled'));
+      element.branch = 'b' as BranchName;
+      await element.updateComplete;
+      assert.isFalse(confirmButton!.hasAttribute('disabled'));
+      element.updateStatus(changes[0], {status: ProgressStatus.RUNNING});
+      await element.updateComplete;
+      assert.isTrue(confirmButton!.hasAttribute('disabled'));
+    });
+  });
+
+  test('resetFocus', async () => {
+    const focusStub = sinon.stub(element.branchInput, 'focus');
+    element.resetFocus();
+    await element.updateComplete;
+
+    assert.isTrue(focusStub.called);
+  });
+
+  test('getProjectBranchesSuggestions non-empty', async () => {
+    const branches = await element.getProjectBranchesSuggestions('test-branch');
+    assert.equal(branches.length, 1);
+    assert.equal(branches[0].name, 'test-branch');
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index b3bbc8a..3f84189 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
@@ -1,46 +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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/shared-styles';
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {BranchName, ChangeActionDialog, RepoName} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-dialog/gr-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-move-dialog_html';
-import {customElement, property} from '@polymer/decorators';
-import {BranchName, RepoName} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
-import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {Key, Modifier} from '../../../utils/dom-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SUGGESTIONS_LIMIT = 15;
 
-// This is used to make sure 'branch'
-// can be typed as BranchName.
-export interface GrConfirmMoveDialog {
-  $: {
-    branchInput: GrTypedAutocomplete<BranchName>;
-  };
-}
-
 @customElement('gr-confirm-move-dialog')
-export class GrConfirmMoveDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmMoveDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -62,40 +44,102 @@
   @property({type: String})
   project?: RepoName;
 
-  @property({type: Object})
-  _query?: (input: string) => Promise<{name: BranchName}[]>;
+  private readonly shortcuts = new ShortcutController(this);
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  constructor() {
+    super();
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+      e => this.handleConfirmTap(e)
+    );
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+      e => this.handleConfirmTap(e)
+    );
+  }
 
   override disconnectedCallback() {
     super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, e =>
-        this._handleConfirmTap(e)
-      )
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, e =>
-        this._handleConfirmTap(e)
-      )
-    );
   }
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  constructor() {
-    super();
-    this._query = (text: string) => this._getProjectBranchesSuggestions(text);
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          width: 30em;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        label {
+          cursor: pointer;
+        }
+        .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        .main label,
+        .main input[type='text'] {
+          display: block;
+          width: 100%;
+        }
+        .main .message {
+          width: 100%;
+        }
+        .warning {
+          color: var(--error-text-color);
+        }
+      `,
+    ];
   }
 
-  _handleConfirmTap(e: Event) {
+  override render() {
+    return html`
+      <gr-dialog
+        confirm-label="Move Change"
+        @confirm=${(e: Event) => this.handleConfirmTap(e)}
+        @cancel=${(e: Event) => this.handleCancelTap(e)}
+      >
+        <div class="header" slot="header">Move Change to Another Branch</div>
+        <div class="main" slot="main">
+          <p class="warning">
+            Warning: moving a change will not change its parents.
+          </p>
+          <label for="branchInput"> Move change to branch </label>
+          <gr-autocomplete
+            id="branchInput"
+            .text=${this.branch}
+            @text-changed=${(e: ValueChangedEvent) =>
+              (this.branch = e.detail.value as BranchName)}
+            .query=${(text: string) => this.getProjectBranchesSuggestions(text)}
+            placeholder="Destination branch"
+          >
+          </gr-autocomplete>
+          <label for="messageInput"> Move Change Message </label>
+          <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+            .rows=${4}
+            .maxRows=${15}
+            .bindValue=${this.message}
+          ></iron-autogrow-textarea>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -106,7 +150,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -117,13 +161,19 @@
     );
   }
 
-  _getProjectBranchesSuggestions(input: string) {
+  private getProjectBranchesSuggestions(input: string) {
     if (!this.project) return Promise.reject(new Error('Missing project'));
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+      .getRepoBranches(
+        input,
+        this.project,
+        SUGGESTIONS_LIMIT,
+        /* offest=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
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
deleted file mode 100644
index 7c3c719..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 30em;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    .main label,
-    .main input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    .main .message {
-      width: 100%;
-    }
-    .warning {
-      color: var(--error-text-color);
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Move Change"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Move Change to Another Branch</div>
-    <div class="main" slot="main">
-      <p class="warning">
-        Warning: moving a change will not change its parents.
-      </p>
-      <label for="branchInput"> Move change to branch </label>
-      <gr-autocomplete
-        id="branchInput"
-        text="{{branch}}"
-        query="[[_query]]"
-        placeholder="Destination branch"
-      >
-      </gr-autocomplete>
-      <label for="messageInput"> Move Change Message </label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        rows="4"
-        max-rows="15"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
index ea5d320..aca7e0ddc 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
@@ -1,32 +1,20 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-confirm-move-dialog';
 import {GrConfirmMoveDialog} from './gr-confirm-move-dialog';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {BranchName, GitRef, RepoName} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-confirm-move-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 
 suite('gr-confirm-move-dialog tests', () => {
   let element: GrConfirmMoveDialog;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getRepoBranches').callsFake((input: string) => {
       if (input.startsWith('test')) {
         return Promise.resolve([
@@ -40,35 +28,73 @@
         return Promise.resolve([]);
       }
     });
-    element = basicFixture.instantiate();
-    element.project = 'test-repo' as RepoName;
+    element = await fixture(
+      html`<gr-confirm-move-dialog
+        .project=${'test-repo' as RepoName}
+      ></gr-confirm-move-dialog>`
+    );
   });
 
-  test('with updated commit message', () => {
+  test('render', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog confirm-label="Move Change" role="dialog">
+          <div class="header" slot="header">Move Change to Another Branch</div>
+          <div class="main" slot="main">
+            <p class="warning">
+              Warning: moving a change will not change its parents.
+            </p>
+            <label for="branchInput"> Move change to branch </label>
+            <gr-autocomplete id="branchInput" placeholder="Destination branch">
+            </gr-autocomplete>
+            <label for="messageInput"> Move Change Message </label>
+            <iron-autogrow-textarea
+              aria-disabled="false"
+              id="messageInput"
+              class="message"
+              autocomplete="on"
+            ></iron-autogrow-textarea>
+          </div>
+        </gr-dialog>
+      `
+    );
+  });
+
+  test('with updated commit message', async () => {
     element.branch = 'master' as BranchName;
     const myNewMessage = 'updated commit message';
     element.message = myNewMessage;
-    flush();
+    await element.updateComplete;
+
     assert.equal(element.message, myNewMessage);
   });
 
-  test('_getProjectBranchesSuggestions empty', async () => {
-    const branches = await element._getProjectBranchesSuggestions(
-      'nonexistent'
+  test('suggestions empty', async () => {
+    const autoComplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
     );
+    const branches = await autoComplete.query!('nonexistent');
     assert.equal(branches.length, 0);
   });
 
-  test('_getProjectBranchesSuggestions non-empty', async () => {
-    const branches = await element._getProjectBranchesSuggestions(
-      'test-branch'
+  test('suggestions non-empty', async () => {
+    const autoComplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
     );
+    const branches = await autoComplete.query!('test-branch');
     assert.equal(branches.length, 1);
     assert.equal(branches[0].name, 'test-branch');
   });
 
-  test('_getProjectBranchesSuggestions input empty string', async () => {
-    const branches = await element._getProjectBranchesSuggestions('');
+  test('suggestions input empty string', async () => {
+    const autoComplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
+    );
+    const branches = await autoComplete.query!('');
     assert.equal(branches.length, 0);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 77b7717..b0dbda5 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
@@ -1,33 +1,28 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../shared/gr-autocomplete/gr-autocomplete';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
+import {
+  NumericChangeId,
+  BranchName,
+  ChangeActionDialog,
+} from '../../../types/common';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-rebase-dialog_html';
-import {customElement, property, observe} from '@polymer/decorators';
-import {NumericChangeId, BranchName} from '../../../types/common';
+import '../../shared/gr-autocomplete/gr-autocomplete';
 import {
   GrAutocomplete,
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {appContext} from '../../../services/app-context';
-import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {getAppContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ValueChangedEvent} from '../../../types/events';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 export interface RebaseChange {
   name: string;
@@ -36,28 +31,15 @@
 
 export interface ConfirmRebaseEventDetail {
   base: string | null;
-}
-
-export interface GrConfirmRebaseDialog {
-  $: {
-    confirmDialog: GrDialog;
-    parentInput: GrAutocomplete;
-    parentUpToDateMsg: HTMLDivElement;
-    rebaseOnParent: HTMLDivElement;
-    rebaseOnParentInput: HTMLInputElement;
-    rebaseOnOtherInput: HTMLInputElement;
-    rebaseOnTip: HTMLDivElement;
-    rebaseOnTipInput: HTMLInputElement;
-    tipUpToDateMsg: HTMLDivElement;
-  };
+  allowConflicts: boolean;
+  rebaseChain: boolean;
 }
 
 @customElement('gr-confirm-rebase-dialog')
-export class GrConfirmRebaseDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmRebaseDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /**
    * Fired when the confirm button is pressed.
    *
@@ -82,20 +64,182 @@
   @property({type: Boolean})
   rebaseOnCurrent?: boolean;
 
-  @property({type: String})
-  _text = '';
+  @property({type: Boolean})
+  disableActions = false;
 
-  @property({type: Object})
-  _query: AutocompleteQuery = () => Promise.resolve([]);
+  @state()
+  text = '';
 
-  @property({type: Array})
-  _recentChanges?: RebaseChange[];
+  @state()
+  private query: AutocompleteQuery;
 
-  private readonly restApiService = appContext.restApiService;
+  @state()
+  recentChanges?: RebaseChange[];
+
+  @query('#rebaseOnParentInput')
+  private rebaseOnParentInput!: HTMLInputElement;
+
+  @query('#rebaseOnTipInput')
+  private rebaseOnTipInput!: HTMLInputElement;
+
+  @query('#rebaseOnOtherInput')
+  rebaseOnOtherInput!: HTMLInputElement;
+
+  @query('#rebaseAllowConflicts')
+  private rebaseAllowConflicts!: HTMLInputElement;
+
+  @query('#rebaseChain')
+  private rebaseChain?: HTMLInputElement;
+
+  @query('#parentInput')
+  parentInput!: GrAutocomplete;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly flagsService = getAppContext().flagsService;
 
   constructor() {
     super();
-    this._query = input => this._getChangeSuggestions(input);
+    this.query = input => this.getChangeSuggestions(input);
+  }
+
+  override willUpdate(changedProperties: PropertyValues): void {
+    if (
+      changedProperties.has('rebaseOnCurrent') ||
+      changedProperties.has('hasParent')
+    ) {
+      this.updateSelectedOption();
+    }
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: block;
+        width: 30em;
+      }
+      :host([disabled]) {
+        opacity: 0.5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+      }
+      .message {
+        font-style: italic;
+      }
+      .parentRevisionContainer label,
+      .parentRevisionContainer input[type='text'] {
+        display: block;
+        width: 100%;
+      }
+      .rebaseAllowConflicts {
+        margin-top: var(--spacing-m);
+      }
+      .rebaseOption {
+        margin: var(--spacing-m) 0;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-dialog
+        id="confirmDialog"
+        confirm-label="Rebase"
+        .disabled=${this.disableActions}
+        @confirm=${this.handleConfirmTap}
+        @cancel=${this.handleCancelTap}
+      >
+        <div class="header" slot="header">Confirm rebase</div>
+        <div class="main" slot="main">
+          <div
+            id="rebaseOnParent"
+            class="rebaseOption"
+            ?hidden=${!this.displayParentOption()}
+          >
+            <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
+            <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
+              Rebase on parent change
+            </label>
+          </div>
+          <div
+            id="parentUpToDateMsg"
+            class="message"
+            ?hidden=${!this.displayParentUpToDateMsg()}
+          >
+            This change is up to date with its parent.
+          </div>
+          <div
+            id="rebaseOnTip"
+            class="rebaseOption"
+            ?hidden=${!this.displayTipOption()}
+          >
+            <input
+              id="rebaseOnTipInput"
+              name="rebaseOptions"
+              type="radio"
+              ?disabled=${!this.displayTipOption()}
+            />
+            <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
+              Rebase on top of the ${this.branch} branch<span
+                ?hidden=${!this.hasParent}
+              >
+                (breaks relation chain)
+              </span>
+            </label>
+          </div>
+          <div
+            id="tipUpToDateMsg"
+            class="message"
+            ?hidden=${this.displayTipOption()}
+          >
+            Change is up to date with the target branch already (${this.branch})
+          </div>
+          <div id="rebaseOnOther" class="rebaseOption">
+            <input
+              id="rebaseOnOtherInput"
+              name="rebaseOptions"
+              type="radio"
+              @click=${this.handleRebaseOnOther}
+            />
+            <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
+              Rebase on a specific change, ref, or commit
+              <span ?hidden=${!this.hasParent}> (breaks relation chain) </span>
+            </label>
+          </div>
+          <div class="parentRevisionContainer">
+            <gr-autocomplete
+              id="parentInput"
+              .query=${this.query}
+              no-debounce
+              .text=${this.text}
+              @text-changed=${(e: ValueChangedEvent) =>
+                (this.text = e.detail.value)}
+              @click=${this.handleEnterChangeNumberClick}
+              allow-non-suggested-values
+              placeholder="Change number, ref, or commit hash"
+            >
+            </gr-autocomplete>
+          </div>
+          <div class="rebaseAllowConflicts">
+            <input id="rebaseAllowConflicts" type="checkbox" />
+            <label for="rebaseAllowConflicts"
+              >Allow rebase with conflicts</label
+            >
+          </div>
+          ${when(
+            this.flagsService.isEnabled(KnownExperimentId.REBASE_CHAIN),
+            () =>
+              html`<div>
+                <input id="rebaseChain" type="checkbox" />
+                <label for="rebaseChain">Rebase all ancestors</label>
+              </div>`
+          )}
+        </div>
+      </gr-dialog>
+    `;
   }
 
   // This is called by gr-change-actions every time the rebase dialog is
@@ -106,7 +250,13 @@
   // last time it was run.
   fetchRecentChanges() {
     return this.restApiService
-      .getChanges(undefined, 'is:open -age:90d')
+      .getChanges(
+        undefined,
+        'is:open -age:90d',
+        /* offset=*/ undefined,
+        /* options=*/ undefined,
+        throwingErrorCallback
+      )
       .then(response => {
         if (!response) return [];
         const changes: RebaseChange[] = [];
@@ -116,25 +266,25 @@
             value: change._number,
           });
         }
-        this._recentChanges = changes;
-        return this._recentChanges;
+        this.recentChanges = changes;
+        return this.recentChanges;
       });
   }
 
-  _getRecentChanges() {
-    if (this._recentChanges) {
-      return Promise.resolve(this._recentChanges);
+  getRecentChanges() {
+    if (this.recentChanges) {
+      return Promise.resolve(this.recentChanges);
     }
     return this.fetchRecentChanges();
   }
 
-  _getChangeSuggestions(input: string) {
-    return this._getRecentChanges().then(changes =>
-      this._filterChanges(input, changes)
+  private getChangeSuggestions(input: string) {
+    return this.getRecentChanges().then(changes =>
+      this.filterChanges(input, changes)
     );
   }
 
-  _filterChanges(
+  filterChanges(
     input: string,
     changes: RebaseChange[]
   ): AutocompleteSuggestion[] {
@@ -152,16 +302,16 @@
       );
   }
 
-  _displayParentOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    return hasParent && rebaseOnCurrent;
+  private displayParentOption() {
+    return this.hasParent && this.rebaseOnCurrent;
   }
 
-  _displayParentUpToDateMsg(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    return hasParent && !rebaseOnCurrent;
+  private displayParentUpToDateMsg() {
+    return this.hasParent && !this.rebaseOnCurrent;
   }
 
-  _displayTipOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    return !(!rebaseOnCurrent && !hasParent);
+  private displayTipOption() {
+    return this.rebaseOnCurrent || this.hasParent;
   }
 
   /**
@@ -171,63 +321,64 @@
    * rebased on top of the target branch. Leaving out the base implies that it
    * should be rebased on top of its current parent.
    */
-  _getSelectedBase() {
-    if (this.$.rebaseOnParentInput.checked) {
+  getSelectedBase() {
+    if (this.rebaseOnParentInput.checked) {
       return null;
     }
-    if (this.$.rebaseOnTipInput.checked) {
+    if (this.rebaseOnTipInput.checked) {
       return '';
     }
-    if (!this._text) {
+    if (!this.text) {
       return '';
     }
     // Change numbers will have their description appended by the
     // autocomplete.
-    return this._text.split(':')[0];
+    return this.text.split(':')[0];
   }
 
-  _handleConfirmTap(e: Event) {
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     const detail: ConfirmRebaseEventDetail = {
-      base: this._getSelectedBase(),
+      base: this.getSelectedBase(),
+      allowConflicts: this.rebaseAllowConflicts.checked,
+      rebaseChain: !!this.rebaseChain?.checked,
     };
     this.dispatchEvent(new CustomEvent('confirm', {detail}));
-    this._text = '';
+    this.text = '';
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('cancel'));
-    this._text = '';
+    this.text = '';
   }
 
-  _handleRebaseOnOther() {
-    this.$.parentInput.focus();
+  private handleRebaseOnOther() {
+    this.parentInput.focus();
   }
 
-  _handleEnterChangeNumberClick() {
-    this.$.rebaseOnOtherInput.checked = true;
+  private handleEnterChangeNumberClick() {
+    this.rebaseOnOtherInput.checked = true;
   }
 
   /**
    * Sets the default radio button based on the state of the app and
    * the corresponding value to be submitted.
    */
-  @observe('rebaseOnCurrent', 'hasParent')
-  _updateSelectedOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    // Polymer 2: check for undefined
+  private updateSelectedOption() {
+    const {rebaseOnCurrent, hasParent} = this;
     if (rebaseOnCurrent === undefined || hasParent === undefined) {
       return;
     }
 
-    if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
-      this.$.rebaseOnParentInput.checked = true;
-    } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
-      this.$.rebaseOnTipInput.checked = true;
+    if (this.displayParentOption()) {
+      this.rebaseOnParentInput.checked = true;
+    } else if (this.displayTipOption()) {
+      this.rebaseOnTipInput.checked = true;
     } else {
-      this.$.rebaseOnOtherInput.checked = true;
+      this.rebaseOnOtherInput.checked = true;
     }
   }
 }
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
deleted file mode 100644
index 1052201..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 30em;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .message {
-      font-style: italic;
-    }
-    .parentRevisionContainer label,
-    .parentRevisionContainer input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    .parentRevisionContainer label {
-      margin-bottom: var(--spacing-xs);
-    }
-    .rebaseOption {
-      margin: var(--spacing-m) 0;
-    }
-  </style>
-  <gr-dialog
-    id="confirmDialog"
-    confirm-label="Rebase"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Confirm rebase</div>
-    <div class="main" slot="main">
-      <div
-        id="rebaseOnParent"
-        class="rebaseOption"
-        hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]"
-      >
-        <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
-        <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
-          Rebase on parent change
-        </label>
-      </div>
-      <div
-        id="parentUpToDateMsg"
-        class="message"
-        hidden$="[[!_displayParentUpToDateMsg(rebaseOnCurrent, hasParent)]]"
-      >
-        This change is up to date with its parent.
-      </div>
-      <div
-        id="rebaseOnTip"
-        class="rebaseOption"
-        hidden$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-      >
-        <input
-          id="rebaseOnTipInput"
-          name="rebaseOptions"
-          type="radio"
-          disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-        />
-        <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
-          Rebase on top of the [[branch]] branch<span hidden$="[[!hasParent]]">
-            (breaks relation chain)
-          </span>
-        </label>
-      </div>
-      <div
-        id="tipUpToDateMsg"
-        class="message"
-        hidden$="[[_displayTipOption(rebaseOnCurrent, hasParent)]]"
-      >
-        Change is up to date with the target branch already ([[branch]])
-      </div>
-      <div id="rebaseOnOther" class="rebaseOption">
-        <input
-          id="rebaseOnOtherInput"
-          name="rebaseOptions"
-          type="radio"
-          on-click="_handleRebaseOnOther"
-        />
-        <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
-          Rebase on a specific change, ref, or commit
-          <span hidden$="[[!hasParent]]"> (breaks relation chain) </span>
-        </label>
-      </div>
-      <div class="parentRevisionContainer">
-        <gr-autocomplete
-          id="parentInput"
-          query="[[_query]]"
-          no-debounce=""
-          text="{{_text}}"
-          on-click="_handleEnterChangeNumberClick"
-          allow-non-suggested-values=""
-          placeholder="Change number, ref, or commit hash"
-        >
-        </gr-autocomplete>
-      </div>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index 74c1b3c..776e923 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -1,112 +1,236 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-confirm-rebase-dialog';
 import {GrConfirmRebaseDialog, RebaseChange} from './gr-confirm-rebase-dialog';
-import {stubRestApi} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {NumericChangeId} from '../../../types/common';
+import {
+  pressKey,
+  queryAndAssert,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
+import {NumericChangeId, BranchName} from '../../../types/common';
 import {createChangeViewChange} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-confirm-rebase-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key} from '../../../utils/dom-util';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 
 suite('gr-confirm-rebase-dialog tests', () => {
   let element: GrConfirmRebaseDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-rebase-dialog></gr-confirm-rebase-dialog>`
+    );
   });
 
-  test('controls with parent and rebase on current available', () => {
+  test('render', async () => {
+    element.branch = 'test' as BranchName;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<gr-dialog
+        confirm-label="Rebase"
+        id="confirmDialog"
+        role="dialog"
+      >
+        <div class="header" slot="header">Confirm rebase</div>
+        <div class="main" slot="main">
+          <div class="rebaseOption" hidden="" id="rebaseOnParent">
+            <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
+            <label for="rebaseOnParentInput" id="rebaseOnParentLabel">
+              Rebase on parent change
+            </label>
+          </div>
+          <div class="message" hidden="" id="parentUpToDateMsg">
+            This change is up to date with its parent.
+          </div>
+          <div class="rebaseOption" hidden="" id="rebaseOnTip">
+            <input
+              disabled=""
+              id="rebaseOnTipInput"
+              name="rebaseOptions"
+              type="radio"
+            />
+            <label for="rebaseOnTipInput" id="rebaseOnTipLabel">
+              Rebase on top of the test branch
+              <span hidden=""> (breaks relation chain) </span>
+            </label>
+          </div>
+          <div class="message" id="tipUpToDateMsg">
+            Change is up to date with the target branch already (test)
+          </div>
+          <div class="rebaseOption" id="rebaseOnOther">
+            <input id="rebaseOnOtherInput" name="rebaseOptions" type="radio" />
+            <label for="rebaseOnOtherInput" id="rebaseOnOtherLabel">
+              Rebase on a specific change, ref, or commit
+              <span hidden=""> (breaks relation chain) </span>
+            </label>
+          </div>
+          <div class="parentRevisionContainer">
+            <gr-autocomplete
+              allow-non-suggested-values=""
+              id="parentInput"
+              no-debounce=""
+              placeholder="Change number, ref, or commit hash"
+            >
+            </gr-autocomplete>
+          </div>
+          <div class="rebaseAllowConflicts">
+            <input id="rebaseAllowConflicts" type="checkbox" />
+            <label for="rebaseAllowConflicts">
+              Allow rebase with conflicts
+            </label>
+          </div>
+        </div>
+      </gr-dialog> `
+    );
+  });
+
+  test('disableActions property disables dialog confirm', async () => {
+    element.disableActions = false;
+    await element.updateComplete;
+
+    const dialog = queryAndAssert<GrDialog>(element, 'gr-dialog');
+    assert.isFalse(dialog.disabled);
+
+    element.disableActions = true;
+    await element.updateComplete;
+
+    assert.isTrue(dialog.disabled);
+  });
+
+  test('controls with parent and rebase on current available', async () => {
     element.rebaseOnCurrent = true;
     element.hasParent = true;
-    flush();
-    assert.isTrue(element.$.rebaseOnParentInput.checked);
-    assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(element, '#rebaseOnParentInput').checked
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('controls with parent rebase on current not available', () => {
+  test('controls with parent rebase on current not available', async () => {
     element.rebaseOnCurrent = false;
     element.hasParent = true;
-    flush();
-    assert.isTrue(element.$.rebaseOnTipInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('controls without parent and rebase on current available', () => {
+  test('controls without parent and rebase on current available', async () => {
     element.rebaseOnCurrent = true;
     element.hasParent = false;
-    flush();
-    assert.isTrue(element.$.rebaseOnTipInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('controls without parent rebase on current not available', () => {
+  test('controls without parent rebase on current not available', async () => {
     element.rebaseOnCurrent = false;
     element.hasParent = false;
-    flush();
-    assert.isTrue(element.$.rebaseOnOtherInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isTrue(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isFalse(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(element.rebaseOnOtherInput.checked);
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('input cleared on cancel or submit', () => {
-    element._text = '123';
-    element.$.confirmDialog.dispatchEvent(
+  test('input cleared on cancel or submit', async () => {
+    element.text = '123';
+    await element.updateComplete;
+    queryAndAssert(element, '#confirmDialog').dispatchEvent(
       new CustomEvent('confirm', {
         composed: true,
         bubbles: true,
       })
     );
-    assert.equal(element._text, '');
+    assert.equal(element.text, '');
 
-    element._text = '123';
-    element.$.confirmDialog.dispatchEvent(
+    element.text = '123';
+    await element.updateComplete;
+
+    queryAndAssert(element, '#confirmDialog').dispatchEvent(
       new CustomEvent('cancel', {
         composed: true,
         bubbles: true,
       })
     );
-    assert.equal(element._text, '');
+    assert.equal(element.text, '');
   });
 
-  test('_getSelectedBase', () => {
-    element._text = '5fab321c';
-    element.$.rebaseOnParentInput.checked = true;
-    assert.equal(element._getSelectedBase(), null);
-    element.$.rebaseOnParentInput.checked = false;
-    element.$.rebaseOnTipInput.checked = true;
-    assert.equal(element._getSelectedBase(), '');
-    element.$.rebaseOnTipInput.checked = false;
-    assert.equal(element._getSelectedBase(), element._text);
-    element._text = '101: Test';
-    assert.equal(element._getSelectedBase(), '101');
+  test('_getSelectedBase', async () => {
+    element.text = '5fab321c';
+    await element.updateComplete;
+
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnParentInput').checked =
+      true;
+    assert.equal(element.getSelectedBase(), null);
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnParentInput').checked =
+      false;
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked =
+      true;
+    assert.equal(element.getSelectedBase(), '');
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked =
+      false;
+    assert.equal(element.getSelectedBase(), element.text);
+    element.text = '101: Test';
+    await element.updateComplete;
+
+    assert.equal(element.getSelectedBase(), '101');
   });
 
   suite('parent suggestions', () => {
@@ -149,47 +273,50 @@
       );
     });
 
-    test('_getRecentChanges', () => {
-      const recentChangesSpy = sinon.spy(element, '_getRecentChanges');
-      return element
-        ._getRecentChanges()
-        .then(() => {
-          assert.deepEqual(element._recentChanges, recentChanges);
-          assert.equal(getChangesStub.callCount, 1);
-          // When called a second time, should not re-request recent changes.
-          element._getRecentChanges();
-        })
-        .then(() => {
-          assert.equal(recentChangesSpy.callCount, 2);
-          assert.equal(getChangesStub.callCount, 1);
-        });
+    test('_getRecentChanges', async () => {
+      const recentChangesSpy = sinon.spy(element, 'getRecentChanges');
+      await element.getRecentChanges();
+      await element.updateComplete;
+
+      assert.deepEqual(element.recentChanges, recentChanges);
+      assert.equal(getChangesStub.callCount, 1);
+
+      // When called a second time, should not re-request recent changes.
+      await element.getRecentChanges();
+      await element.updateComplete;
+
+      assert.equal(recentChangesSpy.callCount, 2);
+      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);
+    test('_filterChanges', async () => {
+      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);
 
       element.changeNumber = 123 as NumericChangeId;
-      assert.equal(element._filterChanges('123', recentChanges).length, 0);
-      assert.equal(element._filterChanges('124', recentChanges).length, 1);
-      assert.equal(element._filterChanges('awesome', recentChanges).length, 2);
+      await element.updateComplete;
+
+      assert.equal(element.filterChanges('123', recentChanges).length, 0);
+      assert.equal(element.filterChanges('124', recentChanges).length, 1);
+      assert.equal(element.filterChanges('awesome', recentChanges).length, 2);
     });
 
-    test('input text change triggers function', () => {
-      const recentChangesSpy = sinon.spy(element, '_getRecentChanges');
-      element.$.parentInput.noDebounce = true;
-      MockInteractions.pressAndReleaseKeyOn(
-        element.$.parentInput.$.input,
-        13,
-        null,
-        'enter'
+    test('input text change triggers function', async () => {
+      const recentChangesSpy = sinon.spy(element, 'getRecentChanges');
+      element.parentInput.noDebounce = true;
+      pressKey(
+        queryAndAssert(queryAndAssert(element, '#parentInput'), '#input'),
+        Key.ENTER
       );
-      element._text = '1';
-      assert.isTrue(recentChangesSpy.calledOnce);
-      element._text = '12';
-      assert.isTrue(recentChangesSpy.calledTwice);
+      await element.updateComplete;
+      element.text = '1';
+
+      await waitUntil(() => recentChangesSpy.calledOnce);
+      element.text = '12';
+
+      await waitUntil(() => recentChangesSpy.calledTwice);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index b971039..dd9a5ee 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
@@ -1,31 +1,23 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-revert-dialog_html';
-import {customElement, property} from '@polymer/decorators';
-import {ChangeInfo, CommitId} from '../../../types/common';
-import {fireAlert} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {ChangeActionDialog, ChangeInfo, CommitId} from '../../../types/common';
+import {fire, fireAlert} from '../../../utils/event-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {createSearchUrl} from '../../../models/views/search';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
-const CHANGE_SUBJECT_LIMIT = 50;
+const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
 
 // TODO(dhruvsri): clean up repeated definitions after moving to js modules
 export enum RevertType {
@@ -38,77 +30,181 @@
   message?: string;
 }
 
-@customElement('gr-confirm-revert-dialog')
-export class GrConfirmRevertDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export interface CancelRevertEventDetail {
+  revertType: RevertType;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    /** Fired when the confirm button is pressed. */
+    // prettier-ignore
+    'confirm': CustomEvent<ConfirmRevertEventDetail>;
+    /** Fired when the cancel button is pressed. */
+    // prettier-ignore
+    'cancel': CustomEvent<CancelRevertEventDetail>;
   }
+}
 
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
+@customElement('gr-confirm-revert-dialog')
+export class GrConfirmRevertDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   /* The revert message updated by the user
       The default value is set by the dialog */
-  @property({type: String})
-  _message = '';
+  @state()
+  message = '';
 
-  @property({type: Number})
-  _revertType = RevertType.REVERT_SINGLE_CHANGE;
+  @state()
+  private revertType = RevertType.REVERT_SINGLE_CHANGE;
 
-  @property({type: Boolean})
-  _showRevertSubmission = false;
+  @state()
+  private showRevertSubmission = false;
 
-  @property({type: Number})
-  _changesCount?: number;
+  // Value supplied by populate(). Non-private for access in tests.
+  @state()
+  changesCount?: number;
 
-  @property({type: Boolean})
-  _showErrorMessage = false;
+  @state()
+  showErrorMessage = false;
 
   /* store the default revert messages per revert type so that we can
   check if user has edited the revert message or not
   Set when populate() is called */
-  @property({type: Array})
-  _originalRevertMessages: string[] = [];
+  @state()
+  private originalRevertMessages: string[] = [];
 
   // Store the actual messages that the user has edited
-  @property({type: Array})
-  _revertMessages: string[] = [];
+  @state()
+  private revertMessages: string[] = [];
 
-  private readonly jsAPI = appContext.jsApiService;
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
-  _computeIfSingleRevert(revertType: number) {
-    return revertType === RevertType.REVERT_SINGLE_CHANGE;
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: 0.5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+        display: block;
+        width: 100%;
+      }
+      .revertSubmissionLayout {
+        display: flex;
+        align-items: center;
+      }
+      .label {
+        margin-left: var(--spacing-m);
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
+        width: 73ch; /* Add a char to account for the border. */
+      }
+      .error {
+        color: var(--error-text-color);
+        margin-bottom: var(--spacing-m);
+      }
+      label[for='messageInput'] {
+        margin-top: var(--spacing-m);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-dialog
+        .confirmLabel=${'Create Revert Change'}
+        @confirm=${(e: Event) => this.handleConfirmTap(e)}
+        @cancel=${(e: Event) => this.handleCancelTap(e)}
+      >
+        <div class="header" slot="header">Revert Merged Change</div>
+        <div class="main" slot="main">
+          <div class="error" ?hidden=${!this.showErrorMessage}>
+            <span> A reason is required </span>
+          </div>
+          ${this.showRevertSubmission
+            ? html`
+                <div class="revertSubmissionLayout">
+                  <input
+                    name="revertOptions"
+                    type="radio"
+                    id="revertSingleChange"
+                    @change=${() => this.handleRevertSingleChangeClicked()}
+                    ?checked=${this.computeIfSingleRevert()}
+                  />
+                  <label
+                    for="revertSingleChange"
+                    class="label revertSingleChange"
+                  >
+                    Revert single change
+                  </label>
+                </div>
+                <div class="revertSubmissionLayout">
+                  <input
+                    name="revertOptions"
+                    type="radio"
+                    id="revertSubmission"
+                    @change=${() => this.handleRevertSubmissionClicked()}
+                    .checked=${this.computeIfRevertSubmission()}
+                  />
+                  <label for="revertSubmission" class="label revertSubmission">
+                    Revert entire submission (${this.changesCount} Changes)
+                  </label>
+                </div>
+              `
+            : nothing}
+          <gr-endpoint-decorator name="confirm-revert-change">
+            <label for="messageInput"> Revert Commit Message </label>
+            <iron-autogrow-textarea
+              id="messageInput"
+              class="message"
+              .autocomplete=${'on'}
+              .maxRows=${15}
+              .bindValue=${this.message}
+              @bind-value-changed=${this.handleBindValueChanged}
+            ></iron-autogrow-textarea>
+          </gr-endpoint-decorator>
+        </div>
+      </gr-dialog>
+    `;
   }
 
-  _computeIfRevertSubmission(revertType: number) {
-    return revertType === RevertType.REVERT_SUBMISSION;
+  private computeIfSingleRevert() {
+    return this.revertType === RevertType.REVERT_SINGLE_CHANGE;
   }
 
-  _modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
-    return this.jsAPI.modifyRevertMsg(change, message, commitMessage);
+  private computeIfRevertSubmission() {
+    return this.revertType === RevertType.REVERT_SUBMISSION;
   }
 
-  populate(change: ChangeInfo, commitMessage: string, changes: ChangeInfo[]) {
-    this._changesCount = changes.length;
+  modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
+    return this.getPluginLoader().jsApiService.modifyRevertMsg(
+      change,
+      message,
+      commitMessage
+    );
+  }
+
+  populate(change: ChangeInfo, commitMessage: string, changesCount: number) {
+    this.changesCount = changesCount;
     // The option to revert a single change is always available
-    this._populateRevertSingleChangeMessage(
+    this.populateRevertSingleChangeMessage(
       change,
       commitMessage,
       change.current_revision
     );
-    this._populateRevertSubmissionMessage(change, changes, commitMessage);
+    this.populateRevertSubmissionMessage(change, commitMessage);
   }
 
-  _populateRevertSingleChangeMessage(
+  populateRevertSingleChangeMessage(
     change: ChangeInfo,
     commitMessage: string,
     commitHash?: CommitId
@@ -124,110 +220,98 @@
 
     const message =
       `${revertTitle}\n\n${revertCommitText}\n\n` +
-      'Reason for revert: <INSERT REASONING HERE>\n';
+      `Reason for revert: ${INSERT_REASON_STRING}\n`;
     // This is to give plugins a chance to update message
-    this._message = this._modifyRevertMsg(change, commitMessage, message);
-    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
-    this._showRevertSubmission = false;
-    this._revertMessages[this._revertType] = this._message;
-    this._originalRevertMessages[this._revertType] = this._message;
+    this.message = this.modifyRevertMsg(change, commitMessage, message);
+    this.revertType = RevertType.REVERT_SINGLE_CHANGE;
+    this.showRevertSubmission = false;
+    this.revertMessages[this.revertType] = this.message;
+    this.originalRevertMessages[this.revertType] = this.message;
   }
 
-  _getTrimmedChangeSubject(subject: string) {
-    if (!subject) return '';
-    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
-    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
-  }
-
-  _modifyRevertSubmissionMsg(
+  private modifyRevertSubmissionMsg(
     change: ChangeInfo,
     msg: string,
     commitMessage: string
   ) {
-    return this.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
+    return this.getPluginLoader().jsApiService.modifyRevertSubmissionMsg(
+      change,
+      msg,
+      commitMessage
+    );
   }
 
-  _populateRevertSubmissionMessage(
-    change: ChangeInfo,
-    changes: ChangeInfo[],
-    commitMessage: string
-  ) {
+  populateRevertSubmissionMessage(change: ChangeInfo, commitMessage: string) {
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
       fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
-    if (!changes || changes.length <= 1) return;
-    const revertTitle = `Revert submission ${change.submission_id}`;
-    let message =
-      revertTitle +
+    if (this.changesCount! <= 1) return;
+    const message =
+      `Revert submission ${change.submission_id}` +
       '\n\n' +
       'Reason for revert: <INSERT ' +
-      'REASONING HERE>\n';
-    message += 'Reverted Changes:\n';
-    changes.forEach(change => {
-      message +=
-        `${change.change_id.substring(0, 10)}:` +
-        `${this._getTrimmedChangeSubject(change.subject)}\n`;
-    });
-    this._message = this._modifyRevertSubmissionMsg(
+      'REASONING HERE>\n\n' +
+      'Reverted changes: ' +
+      createSearchUrl({query: `submissionid:${change.submission_id}`}) +
+      '\n';
+    this.message = this.modifyRevertSubmissionMsg(
       change,
       message,
       commitMessage
     );
-    this._revertType = RevertType.REVERT_SUBMISSION;
-    this._revertMessages[this._revertType] = this._message;
-    this._originalRevertMessages[this._revertType] = this._message;
-    this._showRevertSubmission = true;
+    this.revertType = RevertType.REVERT_SUBMISSION;
+    this.revertMessages[this.revertType] = this.message;
+    this.originalRevertMessages[this.revertType] = this.message;
+    this.showRevertSubmission = true;
   }
 
-  _handleRevertSingleChangeClicked() {
-    this._showErrorMessage = false;
-    if (this._message)
-      this._revertMessages[RevertType.REVERT_SUBMISSION] = this._message;
-    this._message = this._revertMessages[RevertType.REVERT_SINGLE_CHANGE];
-    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
+  private handleBindValueChanged(e: BindValueChangeEvent) {
+    this.message = e.detail.value ?? '';
   }
 
-  _handleRevertSubmissionClicked() {
-    this._showErrorMessage = false;
-    this._revertType = RevertType.REVERT_SUBMISSION;
-    if (this._message)
-      this._revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this._message;
-    this._message = this._revertMessages[RevertType.REVERT_SUBMISSION];
+  private handleRevertSingleChangeClicked() {
+    this.showErrorMessage = false;
+    if (this.message)
+      this.revertMessages[RevertType.REVERT_SUBMISSION] = this.message;
+    this.message = this.revertMessages[RevertType.REVERT_SINGLE_CHANGE];
+    this.revertType = RevertType.REVERT_SINGLE_CHANGE;
   }
 
-  _handleConfirmTap(e: Event) {
+  private handleRevertSubmissionClicked() {
+    this.showErrorMessage = false;
+    this.revertType = RevertType.REVERT_SUBMISSION;
+    if (this.message)
+      this.revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this.message;
+    this.message = this.revertMessages[RevertType.REVERT_SUBMISSION];
+  }
+
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    if (this._message === this._originalRevertMessages[this._revertType]) {
-      this._showErrorMessage = true;
+    if (
+      this.message === this.originalRevertMessages[this.revertType] ||
+      this.message.includes(INSERT_REASON_STRING)
+    ) {
+      this.showErrorMessage = true;
       return;
     }
     const detail: ConfirmRevertEventDetail = {
-      revertType: this._revertType,
-      message: this._message,
+      revertType: this.revertType,
+      message: this.message,
     };
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        detail,
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fire(this, 'confirm', detail);
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        detail: {revertType: this._revertType},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    const detail: ConfirmRevertEventDetail = {
+      revertType: this.revertType,
+    };
+    fire(this, 'cancel', detail);
   }
 }
 
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
deleted file mode 100644
index b2acff2..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
+++ /dev/null
@@ -1,102 +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;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    .revertSubmissionLayout {
-      display: flex;
-      align-items: center;
-    }
-    .label {
-      margin-left: var(--spacing-m);
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-    .error {
-      color: var(--error-text-color);
-      margin-bottom: var(--spacing-m);
-    }
-    label[for='messageInput'] {
-      margin-top: var(--spacing-m);
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Revert"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <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>
-      </div>
-      <template is="dom-if" if="[[_showRevertSubmission]]">
-        <div class="revertSubmissionLayout">
-          <input
-            name="revertOptions"
-            type="radio"
-            id="revertSingleChange"
-            on-change="_handleRevertSingleChangeClicked"
-            checked="[[_computeIfSingleRevert(_revertType)]]"
-          />
-          <label for="revertSingleChange" class="label revertSingleChange">
-            Revert single change
-          </label>
-        </div>
-        <div class="revertSubmissionLayout">
-          <input
-            name="revertOptions"
-            type="radio"
-            id="revertSubmission"
-            on-change="_handleRevertSubmissionClicked"
-            checked="[[_computeIfRevertSubmission(_revertType)]]"
-          />
-          <label for="revertSubmission" class="label revertSubmission">
-            Revert entire submission ([[_changesCount]] Changes)
-          </label>
-        </div>
-      </template>
-      <gr-endpoint-decorator name="confirm-revert-change">
-        <label for="messageInput"> Revert Commit Message </label>
-        <iron-autogrow-textarea
-          id="messageInput"
-          class="message"
-          autocomplete="on"
-          max-rows="15"
-          bind-value="{{_message}}"
-        ></iron-autogrow-textarea>
-      </gr-endpoint-decorator>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 38429b1..38309f2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -1,40 +1,54 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import {createChange} from '../../../test/test-data-generators';
-import {CommitId} from '../../../types/common';
+import {ChangeSubmissionId, CommitId} from '../../../types/common';
+import {EventType} from '../../../types/events';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
 
-const basicFixture = fixtureFromElement('gr-confirm-revert-dialog');
-
 suite('gr-confirm-revert-dialog tests', () => {
   let element: GrConfirmRevertDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-revert-dialog></gr-confirm-revert-dialog>`
+    );
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog role="dialog">
+          <div class="header" slot="header">Revert Merged Change</div>
+          <div class="main" slot="main">
+            <div class="error" hidden="">
+              <span> A reason is required </span>
+            </div>
+            <gr-endpoint-decorator name="confirm-revert-change">
+              <label for="messageInput"> Revert Commit Message </label>
+              <iron-autogrow-textarea
+                id="messageInput"
+                class="message"
+                aria-disabled="false"
+              ></iron-autogrow-textarea>
+            </gr-endpoint-decorator>
+          </div>
+        </gr-dialog>
+      `
+    );
   });
 
   test('no match', () => {
-    assert.isNotOk(element._message);
+    assert.isNotOk(element.message);
     const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
-    element._populateRevertSingleChangeMessage(
+    element.addEventListener(EventType.SHOW_ALERT, alertStub);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'not a commitHash in sight',
       undefined
@@ -43,8 +57,8 @@
   });
 
   test('single line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'one line commit\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -53,12 +67,12 @@
       'Revert "one line commit"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 
   test('multi line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -67,12 +81,12 @@
       'Revert "many lines"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 
   test('issue above change id', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -81,12 +95,12 @@
       'Revert "much lines"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 
   test('revert a revert', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'Revert "one line commit"\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -95,6 +109,24 @@
       'Revert "Revert "one line commit""\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
+  });
+
+  test('revert submission', () => {
+    element.changesCount = 3;
+    element.populateRevertSubmissionMessage(
+      {
+        ...createChange(),
+        submission_id: '5545' as ChangeSubmissionId,
+        current_revision: 'abcd123' as CommitId,
+      },
+      'one line commit\n\nChange-Id: abcdefg\n'
+    );
+
+    const expected =
+      'Revert submission 5545\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n\n' +
+      'Reverted changes: /q/submissionid:5545\n';
+    assert.equal(element.message, expected);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index a9b7b81..1b5f171 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
@@ -1,40 +1,32 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-icon/iron-icon';
-import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-icon/gr-icon';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../gr-thread-list/gr-thread-list';
-import {ActionInfo} from '../../../types/common';
+import {ActionInfo, ChangeActionDialog, EDIT} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {pluralize} from '../../../utils/string-util';
 import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {subscribe} from '../../lit/subscription-controller';
-import {change$} from '../../../services/change/change-model';
-import {threads$} from '../../../services/comments/comments-model';
 import {ParsedChangeInfo} from '../../../types/types';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {resolve} from '../../../models/dependency';
 
 @customElement('gr-confirm-submit-dialog')
-export class GrConfirmSubmitDialog extends LitElement {
+export class GrConfirmSubmitDialog
+  extends LitElement
+  implements ChangeActionDialog
+{
   @query('#dialog')
   dialog?: GrDialog;
 
@@ -62,6 +54,10 @@
   @state()
   initialised = false;
 
+  private getCommentsModel = resolve(this, commentsModelToken);
+
+  private getChangeModel = resolve(this, changeModelToken);
+
   static override get styles() {
     return [
       sharedStyles,
@@ -90,10 +86,14 @@
 
   constructor() {
     super();
-    subscribe(this, change$, x => (this.change = x));
     subscribe(
       this,
-      threads$,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
       x => (this.unresolvedThreads = x.filter(isUnresolved))
     );
   }
@@ -102,10 +102,7 @@
     if (!this.change?.is_private) return '';
     return html`
       <p>
-        <iron-icon
-          icon="gr-icons:warning"
-          class="warningBeforeSubmit"
-        ></iron-icon>
+        <gr-icon icon="warning" filled class="warningBeforeSubmit"></gr-icon>
         <strong>Heads Up!</strong>
         Submitting this private change will also make it public.
       </p>
@@ -113,21 +110,15 @@
   }
 
   private renderUnresolvedCommentCount() {
-    if (!this.change?.unresolved_comment_count) return '';
+    if (!this.unresolvedThreads?.length) return '';
     return html`
       <p>
-        <iron-icon
-          icon="gr-icons:warning"
-          class="warningBeforeSubmit"
-        ></iron-icon>
+        <gr-icon icon="warning" filled class="warningBeforeSubmit"></gr-icon>
         ${this.computeUnresolvedCommentsWarning()}
       </p>
       <gr-thread-list
         id="commentList"
-        .threads="${this.unresolvedThreads}"
-        .change="${this.change}"
-        .changeNum="${this.change?._number}"
-        logged-in
+        .threads=${this.unresolvedThreads}
         hide-dropdown
       >
       </gr-thread-list>
@@ -137,12 +128,9 @@
   private renderChangeEdit() {
     if (!this.computeHasChangeEdit()) return '';
     return html`
-      <iron-icon
-        icon="gr-icons:warning"
-        class="warningBeforeSubmit"
-      ></iron-icon>
+      <gr-icon icon="warning" filled class="warningBeforeSubmit"></gr-icon>
       Your unpublished edit will not be submitted. Did you forget to click
-      <b>PUBLISH</b>
+      <b>PUBLISH</b> after pressing <b>EDIT</b>?
     `;
   }
 
@@ -157,11 +145,11 @@
           ${this.renderChangeEdit()}
           <gr-endpoint-param
             name="change"
-            .value="${this.change}"
+            .value=${this.change}
           ></gr-endpoint-param>
           <gr-endpoint-param
             name="action"
-            .value="${this.action}"
+            .value=${this.action}
           ></gr-endpoint-param>
         </gr-endpoint-decorator>
       </div>
@@ -191,14 +179,13 @@
   // Private method, but visible for testing.
   computeHasChangeEdit() {
     return Object.values(this.change?.revisions ?? {}).some(
-      rev => rev._number === 'edit'
+      rev => rev._number === EDIT
     );
   }
 
   // Private method, but visible for testing.
   computeUnresolvedCommentsWarning() {
-    if (!this.change) return '';
-    const unresolvedCount = this.change.unresolved_comment_count;
+    const unresolvedCount = this.unresolvedThreads.length;
     if (!unresolvedCount) throw new Error('unresolved comments undefined or 0');
     return `Heads Up! ${pluralize(unresolvedCount, 'unresolved comment')}.`;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
index 0426cb6..82de7b0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
@@ -1,63 +1,73 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {
   createParsedChange,
   createRevision,
+  createThread,
 } from '../../../test/test-data-generators';
-import {queryAndAssert} from '../../../test/test-utils';
-import {PatchSetNum} from '../../../types/common';
+import {EDIT} from '../../../types/common';
 import {GrConfirmSubmitDialog} from './gr-confirm-submit-dialog';
-
-const basicFixture = fixtureFromElement('gr-confirm-submit-dialog');
+import './gr-confirm-submit-dialog';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-confirm-submit-dialog tests', () => {
   let element: GrConfirmSubmitDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-submit-dialog></gr-confirm-submit-dialog>`
+    );
     element.initialised = true;
   });
 
-  test('display', async () => {
+  test('render', async () => {
     element.action = {label: 'my-label'};
     element.change = {
       ...createParsedChange(),
       subject: 'my-subject',
       revisions: {},
     };
-    await flush();
-    const header = queryAndAssert(element, '.header');
-    assert.equal(header.textContent!.trim(), 'my-label');
+    await element.updateComplete;
 
-    const message = queryAndAssert(element, '.main p');
-    assert.isNotEmpty(message.textContent);
-    assert.notEqual(message.textContent!.indexOf('my-subject'), -1);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog
+          confirm-label="Continue"
+          confirm-on-enter=""
+          id="dialog"
+          role="dialog"
+        >
+          <div class="header" slot="header">my-label</div>
+          <div class="main" slot="main">
+            <gr-endpoint-decorator name="confirm-submit-change">
+              <p>
+                Ready to submit “
+                <strong> my-subject </strong>
+                ”?
+              </p>
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+              <gr-endpoint-param name="action"> </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        </gr-dialog>
+      `
+    );
   });
 
   test('computeUnresolvedCommentsWarning', () => {
-    element.change = {...createParsedChange(), unresolved_comment_count: 1};
+    element.change = {...createParsedChange()};
+    element.unresolvedThreads = [createThread()];
     assert.equal(
       element.computeUnresolvedCommentsWarning(),
       'Heads Up! 1 unresolved comment.'
     );
 
-    element.change = {...createParsedChange(), unresolved_comment_count: 2};
+    element.unresolvedThreads = [...element.unresolvedThreads, createThread()];
     assert.equal(
       element.computeUnresolvedCommentsWarning(),
       'Heads Up! 2 unresolved comments.'
@@ -68,10 +78,7 @@
     element.change = {
       ...createParsedChange(),
       revisions: {
-        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-          ...createRevision(),
-          _number: 'edit' as PatchSetNum,
-        },
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: createRevision(EDIT),
       },
       unresolved_comment_count: 0,
     };
@@ -81,10 +88,7 @@
     element.change = {
       ...createParsedChange(),
       revisions: {
-        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: {
-          ...createRevision(),
-          _number: 2 as PatchSetNum,
-        },
+        d442ff05d6c4f2a3af0eeca1f67374b39f9dc3d8: createRevision(2),
       },
     };
     assert.isFalse(element.computeHasChangeEdit());
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
new file mode 100644
index 0000000..4858a31
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
@@ -0,0 +1,164 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '@polymer/iron-dropdown/iron-dropdown';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {strToClassName} from '../../../utils/dom-util';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {copyToClipbard, queryAndAssert} from '../../../utils/common-util';
+import {ValueChangedEvent} from '../../../types/events';
+
+export interface CopyLink {
+  label: string;
+  shortcut: string;
+  value: string;
+}
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+
+@customElement('gr-copy-links')
+export class GrCopyLinks extends LitElement {
+  @property({type: Array})
+  copyLinks: CopyLink[] = [];
+
+  @state() isDropdownOpen = false;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  static override get styles() {
+    return [
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: min(90vw, 640px);
+          background-color: var(--dialog-background-color);
+          border-radius: var(--border-radius);
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-m) var(--spacing-l) var(--spacing-m);
+        }
+        .copy-link-row {
+          margin-bottom: var(--spacing-m);
+          display: flex;
+          align-items: center;
+        }
+        .copy-link-row label {
+          flex: 0 0 120px;
+          color: var(--deemphasized-text-color);
+        }
+        .copy-link-row input {
+          flex: 1 1 420px;
+        }
+        .copy-link-row .shortcut {
+          width: 27px;
+          margin: 0 var(--spacing-m);
+          color: var(--deemphasized-text-color);
+        }
+        .copy-link-row gr-copy-clipboard {
+          flex: 0 0 20px;
+        }
+        /* TODO(milutin): It's from shared styles, move it to input styles */
+        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);
+          font: inherit;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.copyLinks) return nothing;
+    return html`<iron-dropdown
+      .horizontalAlign=${'left'}
+      .verticalAlign=${'top'}
+      .verticalOffset=${20}
+      @keydown=${this.handleKeydown}
+      @opened-changed=${(e: ValueChangedEvent<boolean>) =>
+        (this.isDropdownOpen = e.detail.value)}
+    >
+      ${this.renderCopyLinks()}
+    </iron-dropdown>`;
+  }
+
+  private renderCopyLinks() {
+    return html`<div slot="dropdown-content">
+      ${this.copyLinks?.map(link => this.renderCopyLinkRow(link))}
+    </div>`;
+  }
+
+  private renderCopyLinkRow(copyLink: CopyLink) {
+    const {label, shortcut, value} = copyLink;
+    const id = `${strToClassName(label, '')}-field`;
+    // TODO(milutin): Use input in gr-copy-clipboard instead of creating new
+    // one. Move shorcut to gr-copy-clipboard.
+    return html`<div class="copy-link-row">
+      <label for=${id}>${label}</label
+      ><input type="text" readonly="" id=${id} class="input" .value=${value} />
+      <span class="shortcut">${`l - ${shortcut}`}</span>
+      <gr-copy-clipboard
+        hideInput=""
+        text=${value}
+        id=${`${id}-copy-clipboard`}
+      ></gr-copy-clipboard>
+    </div>`;
+  }
+
+  private async handleKeydown(e: KeyboardEvent) {
+    const copyLink = this.copyLinks?.find(link => link.shortcut === e.key);
+    if (!copyLink) return;
+    await copyToClipbard(copyLink.value, copyLink.label);
+    this.closeDropdown();
+  }
+
+  toggleDropdown() {
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
+  }
+
+  private closeDropdown() {
+    this.dropdown?.close();
+  }
+
+  openDropdown() {
+    this.dropdown?.open();
+    this.awaitOpen(() => {
+      queryAndAssert<HTMLInputElement>(this.dropdown, 'input')?.select();
+    });
+  }
+
+  /**
+   * NOTE: (milutin) Slightly hacky way to listen to the overlay actually
+   * opening. It's from gr-editable-label. It will be removed when we
+   * migrate out of iron-* components.
+   */
+  private awaitOpen(fn: () => void) {
+    let iters = 0;
+    const step = () => {
+      setTimeout(() => {
+        if (this.dropdown?.style.display !== 'none') {
+          fn.call(this);
+        } else if (iters++ < AWAIT_MAX_ITERS) {
+          step.call(this);
+        }
+      }, AWAIT_STEP);
+    };
+    step.call(this);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-copy-links': GrCopyLinks;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
new file mode 100644
index 0000000..4a0742b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import './gr-copy-links';
+import {GrCopyLinks} from './gr-copy-links';
+import {pressKey, queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrCopyClipboard} from '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+
+suite('gr-copy-links tests', () => {
+  let element: GrCopyLinks;
+  setup(async () => {
+    const links = [
+      {
+        label: 'Change ID',
+        shortcut: 'd',
+        value: '123456',
+      },
+    ];
+    element = await fixture<GrCopyLinks>(
+      html`<gr-copy-links .copyLinks=${links}></gr-copy-links>`
+    );
+    await element.updateComplete;
+    element.openDropdown();
+    await waitUntil(() => element.isDropdownOpen);
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<iron-dropdown
+        aria-disabled="false"
+        horizontal-align="left"
+        vertical-align="top"
+      >
+      <div slot="dropdown-content">
+          <div class="copy-link-row">
+            <label for="Change_ID-field">Change ID</label>
+            <input
+              class="input"
+              id="Change_ID-field"
+              readonly=""
+              type="text"
+            >
+            <span class="shortcut">l - d</span>
+            <gr-copy-clipboard hideinput="" text="123456" id="Change_ID-field-copy-clipboard">
+            </gr-copy-clipboard>
+          </div>
+      </iron-dropdown>`,
+      {
+        // iron-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+      }
+    );
+  });
+
+  test('click writes to clipboard', () => {
+    const clipboardStub = sinon.stub(navigator.clipboard, 'writeText');
+    const copyClipboard = queryAndAssert<GrCopyClipboard>(
+      element,
+      'gr-copy-clipboard'
+    );
+    const copyBtn = queryAndAssert<GrButton>(copyClipboard, '.copyToClipboard');
+    copyBtn.click();
+    assert.isTrue(clipboardStub.called);
+    assert.isTrue(clipboardStub.calledWith('123456'));
+  });
+
+  test('shorcuts writes to clipboard', () => {
+    const clipboardStub = sinon.stub(window.navigator.clipboard, 'writeText');
+    const ironDropdown = queryAndAssert<IronDropdownElement>(
+      element,
+      'iron-dropdown'
+    );
+    pressKey(ironDropdown, 'd');
+    assert.isTrue(clipboardStub.called);
+    assert.isTrue(clipboardStub.calledWith('123456'));
+  });
+});
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 92f4a87..d291ebb 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
@@ -1,59 +1,37 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/gr-font-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-download-commands/gr-download-commands';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-download-dialog_html';
 import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
-import {customElement, property, computed, observe} from '@polymer/decorators';
-import {
-  ChangeInfo,
-  DownloadInfo,
-  PatchSetNum,
-  RevisionInfo,
-} from '../../../types/common';
+import {ChangeInfo, DownloadInfo, PatchSetNum} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
-import {addShortcut} from '../../../utils/dom-util';
-
-export interface GrDownloadDialog {
-  $: {
-    download: HTMLAnchorElement;
-    downloadCommands: GrDownloadCommands;
-    closeButton: GrButton;
-  };
-}
+import {copyToClipbard, hasOwnProperty} from '../../../utils/common-util';
+import {fireEvent} from '../../../utils/event-util';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 @customElement('gr-download-dialog')
-export class GrDownloadDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDownloadDialog extends LitElement {
   /**
    * Fired when the user presses the close button.
    *
    * @event close
    */
 
+  @query('#download') protected download?: HTMLAnchorElement;
+
+  @query('#downloadCommands') protected downloadCommands?: GrDownloadCommands;
+
+  @query('#closeButton') protected closeButton?: GrButton;
+
   @property({type: Object})
   change: ChangeInfo | undefined;
 
@@ -63,30 +41,165 @@
   @property({type: String})
   patchNum: PatchSetNum | undefined;
 
-  @property({type: String})
-  _selectedScheme?: string;
+  @state() private selectedScheme?: string;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private readonly shortcuts = new ShortcutController(this);
 
-  override disconnectedCallback() {
-    super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
+  constructor() {
+    super();
     for (const key of ['1', '2', '3', '4', '5']) {
-      this.cleanups.push(
-        addShortcut(this, {key}, e => this._handleNumberKey(e))
-      );
+      this.shortcuts.addLocal({key}, e => this.handleNumberKey(e));
     }
   }
 
-  @computed('change', 'patchNum')
-  get _schemes() {
-    // Polymer 2: check for undefined
+  static override get styles() {
+    return [
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          padding: var(--spacing-m) 0;
+        }
+        section {
+          display: flex;
+          padding: var(--spacing-m) var(--spacing-xl);
+        }
+        .flexContainer {
+          display: flex;
+          justify-content: space-between;
+          padding-top: var(--spacing-m);
+        }
+        .footer {
+          justify-content: flex-end;
+        }
+        .closeButtonContainer {
+          align-items: flex-end;
+          display: flex;
+          flex: 0;
+          justify-content: flex-end;
+        }
+        .patchFiles,
+        .archivesContainer {
+          padding-bottom: var(--spacing-m);
+        }
+        .patchFiles {
+          margin-right: var(--spacing-xxl);
+        }
+        .patchFiles a,
+        .archives a {
+          display: inline-block;
+          margin-right: var(--spacing-l);
+        }
+        .patchFiles a:last-of-type,
+        .archives a:last-of-type {
+          margin-right: 0;
+        }
+        gr-download-commands {
+          width: min(80vw, 1200px);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const revisions = this.change?.revisions;
+    return html`
+      <section>
+        <h3 class="heading-3">
+          Patch set ${this.patchNum} of
+          ${revisions ? Object.keys(revisions).length : 0}
+        </h3>
+      </section>
+      ${this.renderDownloadCommands()}
+      <section class="flexContainer">
+        ${this.renderPatchFiles()} ${this.renderArchives()}
+      </section>
+      <section class="footer">
+        <span class="closeButtonContainer">
+          <gr-button
+            id="closeButton"
+            link
+            @click=${(e: Event) => {
+              this.handleCloseTap(e);
+            }}
+            >Close</gr-button
+          >
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDownloadCommands() {
+    const cssClass = this.schemes.length ? '' : 'hidden';
+
+    return html`
+      <section class=${cssClass}>
+        <gr-download-commands
+          id="downloadCommands"
+          .commands=${this.computeDownloadCommands()}
+          .schemes=${this.schemes}
+          .selectedScheme=${this.selectedScheme}
+          show-keyboard-shortcut-tooltips
+          @selected-scheme-changed=${(e: BindValueChangeEvent) => {
+            this.selectedScheme = e.detail.value;
+          }}
+        ></gr-download-commands>
+      </section>
+    `;
+  }
+
+  private renderPatchFiles() {
+    if (this.computeHidePatchFile()) return;
+
+    return html`
+      <div class="patchFiles">
+        <label>Patch file</label>
+        <div>
+          <a id="download" .href=${this.computeDownloadLink()} download>
+            ${this.computeDownloadFilename()}
+          </a>
+          <a .href=${this.computeDownloadLink(true)} download>
+            ${this.computeDownloadFilename(true)}
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderArchives() {
+    if (!this.config?.archives.length) return;
+
+    return html`
+      <div class="archivesContainer">
+        <label>Archive</label>
+        <div id="archives" class="archives">
+          ${this.config.archives.map(format => this.renderArchivesLink(format))}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderArchivesLink(format: string) {
+    return html`
+      <a .href=${this.computeArchiveDownloadLink(format)} download>
+        ${format}
+      </a>
+    `;
+  }
+
+  override firstUpdated(changedProperties: PropertyValues) {
+    super.firstUpdated(changedProperties);
+    if (!this.getAttribute('role')) this.setAttribute('role', 'dialog');
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('change') || changedProperties.has('patchNum')) {
+      this.schemesChanged();
+    }
+  }
+
+  get schemes() {
     if (this.change === undefined || this.patchNum === undefined) {
       return [];
     }
@@ -103,55 +216,38 @@
     return [];
   }
 
-  _handleNumberKey(e: KeyboardEvent) {
+  private async handleNumberKey(e: KeyboardEvent) {
     const index = Number(e.key) - 1;
-    const commands = this._computeDownloadCommands(
-      this.change,
-      this.patchNum,
-      this._selectedScheme
-    );
+    const commands = this.computeDownloadCommands();
     if (index > commands.length) return;
-    navigator.clipboard.writeText(commands[index].command).then(() => {
-      fireAlert(this, `${commands[index].title} command copied to clipboard`);
-      fireEvent(this, 'close');
-    });
-  }
-
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
+    await copyToClipbard(
+      commands[index].command,
+      `${commands[index].title} command`
+    );
+    fireEvent(this, 'close');
   }
 
   override focus() {
-    if (this._schemes.length) {
-      this.$.downloadCommands.focusOnCopy();
+    if (this.schemes.length) {
+      assertIsDefined(this.downloadCommands, 'downloadCommands');
+      this.updateComplete.then(() => this.downloadCommands!.focusOnCopy());
     } else {
-      this.$.download.focus();
+      assertIsDefined(this.download, 'download');
+      this.download.focus();
     }
   }
 
-  getFocusStops(): GrOverlayStops {
-    return {
-      start: this.$.downloadCommands.$.downloadTabs,
-      end: this.$.closeButton,
-    };
-  }
-
-  _computeDownloadCommands(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    selectedScheme?: string
-  ) {
+  private computeDownloadCommands() {
     let commandObj;
-    if (!change || !selectedScheme) return [];
-    for (const rev of Object.values(change.revisions || {})) {
+    if (!this.change || !this.selectedScheme) return [];
+    for (const rev of Object.values(this.change.revisions || {})) {
       if (
-        rev._number === patchNum &&
+        rev._number === this.patchNum &&
         rev &&
         rev.fetch &&
-        hasOwnProperty(rev.fetch, selectedScheme)
+        hasOwnProperty(rev.fetch, this.selectedScheme)
       ) {
-        commandObj = rev.fetch[selectedScheme].commands;
+        commandObj = rev.fetch[this.selectedScheme].commands;
         break;
       }
     }
@@ -162,53 +258,35 @@
     return commands;
   }
 
-  _computeZipDownloadLink(change?: ChangeInfo, patchNum?: PatchSetNum) {
-    return this._computeDownloadLink(change, patchNum, true);
-  }
-
-  _computeZipDownloadFilename(change?: ChangeInfo, patchNum?: PatchSetNum) {
-    return this._computeDownloadFilename(change, patchNum, true);
-  }
-
-  _computeDownloadLink(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    zip?: boolean
-  ) {
-    // Polymer 2: check for undefined
-    if (change === undefined || patchNum === undefined) {
+  private computeDownloadLink(zip?: boolean) {
+    if (this.change === undefined || this.patchNum === undefined) {
       return '';
     }
     return (
-      changeBaseURL(change.project, change._number, patchNum) +
+      changeBaseURL(this.change.project, this.change._number, this.patchNum) +
       '/patch?' +
       (zip ? 'zip' : 'download')
     );
   }
 
-  _computeDownloadFilename(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    zip?: boolean
-  ) {
-    // Polymer 2: check for undefined
-    if (change === undefined || patchNum === undefined) {
+  private computeDownloadFilename(zip?: boolean) {
+    if (this.change === undefined || this.patchNum === undefined) {
       return '';
     }
 
-    const rev = getRevisionKey(change, patchNum) ?? '';
+    const rev = getRevisionKey(this.change, this.patchNum) ?? '';
     const shortRev = rev.substr(0, 7);
 
     return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
   }
 
-  _computeHidePatchFile(change?: ChangeInfo, patchNum?: PatchSetNum) {
-    // Polymer 2: check for undefined
-    if (change === undefined || patchNum === undefined) {
+  // private but used in test
+  computeHidePatchFile() {
+    if (this.change === undefined || this.patchNum === undefined) {
       return false;
     }
-    for (const rev of Object.values(change.revisions || {})) {
-      if (rev._number === patchNum) {
+    for (const rev of Object.values(this.change.revisions || {})) {
+      if (rev._number === this.patchNum) {
         const parentLength =
           rev.commit && rev.commit.parents ? rev.commit.parents.length : 0;
         return parentLength === 0 || parentLength > 1;
@@ -217,52 +295,36 @@
     return false;
   }
 
-  _computeArchiveDownloadLink(
-    change?: ChangeInfo,
-    patchNum?: PatchSetNum,
-    format?: string
-  ) {
-    // Polymer 2: check for undefined
+  // private but used in test
+  computeArchiveDownloadLink(format?: string) {
     if (
-      change === undefined ||
-      patchNum === undefined ||
+      this.change === undefined ||
+      this.patchNum === undefined ||
       format === undefined
     ) {
       return '';
     }
     return (
-      changeBaseURL(change.project, change._number, patchNum) +
+      changeBaseURL(this.change.project, this.change._number, this.patchNum) +
       '/archive?format=' +
       format
     );
   }
 
-  _computePatchSetQuantity(revisions?: {[revisionId: string]: RevisionInfo}) {
-    if (!revisions) {
-      return 0;
-    }
-    return Object.keys(revisions).length;
-  }
-
-  _handleCloseTap(e: Event) {
+  private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     fireEvent(this, 'close');
   }
 
-  @observe('_schemes')
-  _schemesChanged(schemes: string[]) {
-    if (schemes.length === 0) {
+  private schemesChanged() {
+    if (this.schemes.length === 0) {
       return;
     }
-    if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
-      this._selectedScheme = schemes.sort()[0];
+    if (!this.selectedScheme || !this.schemes.includes(this.selectedScheme)) {
+      this.selectedScheme = this.schemes.sort()[0];
     }
   }
-
-  _computeShowDownloadCommands(schemes: string[]) {
-    return schemes.length ? '' : 'hidden';
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
deleted file mode 100644
index 097fb0e..0000000
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-      padding: var(--spacing-m) 0;
-    }
-    section {
-      display: flex;
-      padding: var(--spacing-m) var(--spacing-xl);
-    }
-    .flexContainer {
-      display: flex;
-      justify-content: space-between;
-      padding-top: var(--spacing-m);
-    }
-    .footer {
-      justify-content: flex-end;
-    }
-    .closeButtonContainer {
-      align-items: flex-end;
-      display: flex;
-      flex: 0;
-      justify-content: flex-end;
-    }
-    .patchFiles,
-    .archivesContainer {
-      padding-bottom: var(--spacing-m);
-    }
-    .patchFiles {
-      margin-right: var(--spacing-xxl);
-    }
-    .patchFiles a,
-    .archives a {
-      display: inline-block;
-      margin-right: var(--spacing-l);
-    }
-    .patchFiles a:last-of-type,
-    .archives a:last-of-type {
-      margin-right: 0;
-    }
-    .hidden {
-      display: none;
-    }
-    gr-download-commands {
-      width: min(80vw, 1200px);
-    }
-  </style>
-  <section>
-    <h3 class="heading-3">
-      Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
-    </h3>
-  </section>
-  <section class$="[[_computeShowDownloadCommands(_schemes)]]">
-    <gr-download-commands
-      id="downloadCommands"
-      commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
-      schemes="[[_schemes]]"
-      selected-scheme="{{_selectedScheme}}"
-      show-keyboard-shortcut-tooltips
-    ></gr-download-commands>
-  </section>
-  <section class="flexContainer">
-    <div
-      class="patchFiles"
-      hidden="[[_computeHidePatchFile(change, patchNum)]]"
-    >
-      <label>Patch file</label>
-      <div>
-        <a
-          id="download"
-          href$="[[_computeDownloadLink(change, patchNum)]]"
-          download=""
-        >
-          [[_computeDownloadFilename(change, patchNum)]]
-        </a>
-        <a href$="[[_computeZipDownloadLink(change, patchNum)]]" download="">
-          [[_computeZipDownloadFilename(change, patchNum)]]
-        </a>
-      </div>
-    </div>
-    <div
-      class="archivesContainer"
-      hidden$="[[!config.archives.length]]"
-      hidden=""
-    >
-      <label>Archive</label>
-      <div id="archives" class="archives">
-        <template is="dom-repeat" items="[[config.archives]]" as="format">
-          <a
-            href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
-            download=""
-          >
-            [[format]]
-          </a>
-        </template>
-      </div>
-    </div>
-  </section>
-  <section class="footer">
-    <span class="closeButtonContainer">
-      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
-        >Close</gr-button
-      >
-    </span>
-  </section>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index f61bb68..e5f40a6 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -1,28 +1,14 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../test/common-test-setup';
 import {
   createChange,
   createCommit,
   createDownloadInfo,
   createRevision,
-  createRevisions,
 } from '../../../test/test-data-generators';
 import {
   CommitId,
@@ -30,10 +16,12 @@
   PatchSetNum,
   RepoName,
 } from '../../../types/common';
+import './gr-download-dialog';
 import {GrDownloadDialog} from './gr-download-dialog';
-import {mockPromise} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-download-dialog');
+import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 function getChangeObject() {
   return {
@@ -103,37 +91,94 @@
   };
 }
 
-function getChangeObjectNoFetch() {
-  return {
-    ...createChange(),
-    current_revision: '34685798fe548b6d17d1e8e5edc43a26d055cc72' as CommitId,
-    revisions: createRevisions(1),
-  };
-}
-
 suite('gr-download-dialog', () => {
   let element: GrDownloadDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-download-dialog></gr-download-dialog>`);
     element.patchNum = 1 as PatchSetNum;
     element.config = createDownloadInfo();
-    flush();
+    await element.updateComplete;
+  });
+
+  test('render', () => {
+    // prettier and shadowDom string don't agree on the long text in the h3
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <section>
+        <h3 class="heading-3">
+          Patch set 1 of
+          0
+        </h3>
+      </section>
+      <section class="hidden">
+        <gr-download-commands
+          id="downloadCommands"
+          show-keyboard-shortcut-tooltips=""
+        >
+        </gr-download-commands>
+      </section>
+      <section class="flexContainer">
+        <div class="patchFiles">
+          <label> Patch file </label>
+          <div>
+            <a download="" href="" id="download"> </a>
+            <a download="" href=""> </a>
+          </div>
+        </div>
+        <div class="archivesContainer">
+          <label> Archive </label>
+          <div class="archives" id="archives">
+            <a download="" href=""> tgz </a>
+            <a download="" href=""> tar </a>
+          </div>
+        </div>
+      </section>
+      <section class="footer">
+        <span class="closeButtonContainer">
+          <gr-button
+            aria-disabled="false"
+            id="closeButton"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Close
+          </gr-button>
+        </span>
+      </section>
+    `
+    );
   });
 
   test('anchors use download attribute', () => {
-    const anchors = Array.from(element.root!.querySelectorAll('a'));
+    const anchors = Array.from(queryAll(element, 'a'));
     assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
   });
 
   suite('gr-download-dialog tests with no fetch options', () => {
-    setup(() => {
-      element.change = getChangeObjectNoFetch();
-      flush();
+    setup(async () => {
+      element.change = {
+        ...createChange(),
+        revisions: {
+          r1: {
+            ...createRevision(),
+            commit: {
+              ...createCommit(),
+              parents: [{commit: 'p1' as CommitId, subject: 'subject1'}],
+            },
+          },
+        },
+      };
+      await element.updateComplete;
     });
 
     test('focuses on first download link if no copy links', () => {
-      const focusStub = sinon.stub(element.$.download, 'focus');
+      const focusStub = sinon.stub(
+        queryAndAssert<HTMLAnchorElement>(element, '#download'),
+        'focus'
+      );
       element.focus();
       assert.isTrue(focusStub.called);
       focusStub.restore();
@@ -141,30 +186,31 @@
   });
 
   suite('gr-download-dialog with fetch options', () => {
-    setup(() => {
+    setup(async () => {
       element.change = getChangeObject();
-      flush();
+      await element.updateComplete;
     });
 
-    test('focuses on first copy link', () => {
-      const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
+    test('focuses on first copy link', async () => {
+      const focusStub = sinon.stub(
+        queryAndAssert<GrDownloadCommands>(element, '#downloadCommands'),
+        'focusOnCopy'
+      );
       element.focus();
-      flush();
+      await element.updateComplete;
       assert.isTrue(focusStub.called);
       focusStub.restore();
     });
 
     test('computed fields', () => {
+      element.change = {
+        ...createChange(),
+        project: 'test/project' as RepoName,
+        _number: 123 as NumericChangeId,
+      };
+      element.patchNum = 2 as PatchSetNum;
       assert.equal(
-        element._computeArchiveDownloadLink(
-          {
-            ...createChange(),
-            project: 'test/project' as RepoName,
-            _number: 123 as NumericChangeId,
-          },
-          2 as PatchSetNum,
-          'tgz'
-        ),
+        element.computeArchiveDownloadLink('tgz'),
         '/changes/test%2Fproject~123/revisions/2/archive?format=tgz'
       );
     });
@@ -174,31 +220,27 @@
       element.addEventListener('close', () => {
         closeCalled.resolve();
       });
-      const closeButton = element.shadowRoot!.querySelector(
+      const closeButton = queryAndAssert<GrButton>(
+        element,
         '.closeButtonContainer gr-button'
       );
-      tap(closeButton!);
+      closeButton.click();
       await closeCalled;
     });
   });
 
-  test('_computeShowDownloadCommands', () => {
-    assert.equal(element._computeShowDownloadCommands([]), 'hidden');
-    assert.equal(element._computeShowDownloadCommands(['test']), '');
-  });
+  test('computeHidePatchFile', () => {
+    element.patchNum = 1 as PatchSetNum;
 
-  test('_computeHidePatchFile', () => {
-    const patchNum = 1 as PatchSetNum;
-
-    const changeWithNoParent = {
+    element.change = {
       ...createChange(),
       revisions: {
         r1: {...createRevision(), commit: createCommit()},
       },
     };
-    assert.isTrue(element._computeHidePatchFile(changeWithNoParent, patchNum));
+    assert.isTrue(element.computeHidePatchFile());
 
-    const changeWithOneParent = {
+    element.change = {
       ...createChange(),
       revisions: {
         r1: {
@@ -210,11 +252,9 @@
         },
       },
     };
-    assert.isFalse(
-      element._computeHidePatchFile(changeWithOneParent, patchNum)
-    );
+    assert.isFalse(element.computeHidePatchFile());
 
-    const changeWithMultipleParents = {
+    element.change = {
       ...createChange(),
       revisions: {
         r1: {
@@ -229,8 +269,6 @@
         },
       },
     };
-    assert.isTrue(
-      element._computeHidePatchFile(changeWithMultipleParents, patchNum)
-    );
+    assert.isTrue(element.computeHidePatchFile());
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.ts b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
index 0e55494..b498e6a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 export enum FilesExpandedState {
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 920844b..c1e866c 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
@@ -1,73 +1,48 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/shared-styles';
-import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import '../../../embed/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-select/gr-select';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-commit-info/gr-commit-info';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-file-list-header_html';
 import {FilesExpandedState} from '../gr-file-list-constants';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {computeLatestPatchNum, PatchSet} from '../../../utils/patch-set-util';
-import {property, customElement} from '@polymer/decorators';
+import {property, customElement, query, state} from 'lit/decorators.js';
 import {
   AccountInfo,
   ChangeInfo,
   PatchSetNum,
   CommitInfo,
   ServerInfo,
-  RevisionInfo,
-  NumericChangeId,
   BasePatchSetNum,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {when} from 'lit/directives/when.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {
   Shortcut,
   ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {appContext} from '../../../services/app-context';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-file-list-header': GrFileListHeader;
-  }
-}
-
-export interface GrFileListHeader {
-  $: {
-    modeSelect: GrDiffModeSelector;
-    expandBtn: GrButton;
-    collapseBtn: GrButton;
-  };
-}
+  shortcutsServiceToken,
+} from '../../../services/shortcuts/shortcuts-service';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-file-list-header')
-export class GrFileListHeader extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrFileListHeader extends LitElement {
   /**
    * @event expand-diffs
    */
@@ -94,9 +69,6 @@
   change: ChangeInfo | undefined;
 
   @property({type: String})
-  changeNum?: NumericChangeId;
-
-  @property({type: String})
   changeUrl?: string;
 
   @property({type: Object})
@@ -108,14 +80,8 @@
   @property({type: Boolean})
   loggedIn: boolean | undefined;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
   @property({type: Number})
-  shownFileCount?: number;
-
-  @property({type: Object})
-  diffPrefs?: DiffPreferencesInfo;
+  shownFileCount = 0;
 
   @property({type: String})
   patchNum?: PatchSetNum;
@@ -126,25 +92,288 @@
   @property({type: String})
   filesExpanded?: FilesExpandedState;
 
+  @state()
+  diffPrefs?: DiffPreferencesInfo;
+
+  @state()
+  serverConfig?: ServerInfo;
+
+  @query('#modeSelect')
+  modeSelect?: GrDiffModeSelector;
+
+  @query('#expandBtn')
+  expandBtn?: GrButton;
+
+  @query('#collapseBtn')
+  collapseBtn?: GrButton;
+
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   // Caps the number of files that can be shown and have the 'show diffs' /
   // 'hide diffs' buttons still be functional.
-  @property({type: Number})
-  readonly _maxFilesForBulkActions = 225;
+  private readonly maxFilesForBulkActions = 225;
 
-  @property({type: Object})
-  revisionInfo?: RevisionInfo;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly shortcuts = appContext.shortcutsService;
+  private readonly getNavigation = resolve(this, navigationToken);
 
-  _expandAllDiffs() {
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.diffPrefs = diffPreferences;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      .prefsButton {
+        float: right;
+      }
+      .patchInfoOldPatchSet.patchInfo-header {
+        background-color: var(--emphasis-color);
+      }
+      .patchInfo-header {
+        align-items: center;
+        display: flex;
+        padding: var(--spacing-s) var(--spacing-l);
+      }
+      .patchInfo-left {
+        align-items: baseline;
+        display: flex;
+      }
+      .patchInfoContent {
+        align-items: center;
+        display: flex;
+        flex-wrap: wrap;
+      }
+      .patchInfo-header .container.latestPatchContainer {
+        display: none;
+      }
+      .patchInfoOldPatchSet .container.latestPatchContainer {
+        display: initial;
+      }
+      .editMode.patchInfoOldPatchSet .container.latestPatchContainer {
+        display: none;
+      }
+      .latestPatchContainer a {
+        text-decoration: none;
+      }
+      .mobile {
+        display: none;
+      }
+      .patchInfo-header .container {
+        align-items: center;
+        display: flex;
+      }
+      .downloadContainer,
+      .uploadContainer {
+        margin-right: 16px;
+      }
+      .uploadContainer.hide {
+        display: none;
+      }
+      .rightControls {
+        align-self: flex-end;
+        margin: auto 0 auto auto;
+        align-items: center;
+        display: flex;
+        flex-wrap: wrap;
+        font-weight: var(--font-weight-normal);
+        justify-content: flex-end;
+      }
+      #collapseBtn,
+      .allExpanded #expandBtn,
+      .fileViewActions {
+        display: none;
+      }
+      .someExpanded #expandBtn {
+        margin-right: 8px;
+      }
+      .someExpanded #collapseBtn,
+      .allExpanded #collapseBtn,
+      .openFile .fileViewActions {
+        align-items: center;
+        display: flex;
+      }
+      .rightControls gr-button,
+      gr-patch-range-select {
+        margin: 0 -4px;
+      }
+      .fileViewActions gr-button {
+        margin: 0;
+        --gr-button-padding: 2px 4px;
+      }
+      .editMode .hideOnEdit {
+        display: none;
+      }
+      .showOnEdit {
+        display: none;
+      }
+      .editMode .showOnEdit {
+        display: initial;
+      }
+      .editMode .showOnEdit.flexContainer {
+        align-items: center;
+        display: flex;
+      }
+      .label {
+        font-weight: var(--font-weight-bold);
+        margin-right: 24px;
+      }
+      gr-commit-info,
+      gr-edit-controls {
+        margin-right: -5px;
+      }
+      .fileViewActionsLabel {
+        margin-right: var(--spacing-xs);
+      }
+      @media screen and (max-width: 50em) {
+        .patchInfo-header .desktop {
+          display: none;
+        }
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.change || !this.diffPrefs) {
+      return;
+    }
+    const editModeClass = this.computeEditModeClass(this.editMode);
+    const patchInfoClass = this.computePatchInfoClass(
+      this.patchNum,
+      this.allPatchSets
+    );
+    const expandedClass = this.computeExpandedClass(this.filesExpanded);
+    return html`
+      <div class="patchInfo-header ${editModeClass} ${patchInfoClass}">
+        <div class="patchInfo-left">
+          <div class="patchInfoContent">
+            <gr-patch-range-select
+              id="rangeSelect"
+              @patch-range-change=${this.handlePatchChange}
+            >
+            </gr-patch-range-select>
+            <span class="separator"></span>
+            <gr-commit-info .commitInfo=${this.commitInfo}></gr-commit-info>
+            <span class="container latestPatchContainer">
+              <span class="separator"></span>
+              <a href=${ifDefined(this.changeUrl)}>Go to latest patch set</a>
+            </span>
+          </div>
+        </div>
+        <div class="rightControls ${expandedClass}">
+          ${when(
+            this.editMode,
+            () => html`
+              <span class="showOnEdit flexContainer">
+                <gr-edit-controls
+                  id="editControls"
+                  .patchNum=${this.patchNum}
+                  .change=${this.change}
+                ></gr-edit-controls>
+                <span class="separator"></span>
+              </span>
+            `
+          )}
+          ${when(
+            this.loggedIn && this.diffPrefs,
+            () => html`
+              <div class="fileViewActions">
+                <span class="fileViewActionsLabel">Diff view:</span>
+                <gr-diff-mode-selector
+                  id="modeSelect"
+                  .saveOnChange=${true}
+                ></gr-diff-mode-selector>
+                <span id="diffPrefsContainer" class="hideOnEdit">
+                  <gr-tooltip-content has-tooltip title="Diff preferences">
+                    <gr-button
+                      link
+                      class="prefsButton desktop"
+                      @click=${this.handlePrefsTap}
+                      ><gr-icon icon="settings" filled></gr-icon
+                    ></gr-button>
+                  </gr-tooltip-content>
+                </span>
+                <span class="separator"></span>
+              </div>
+            `
+          )}
+          <span class="downloadContainer desktop">
+            <gr-tooltip-content
+              has-tooltip
+              title=${this.createTitle(
+                Shortcut.OPEN_DOWNLOAD_DIALOG,
+                ShortcutSection.ACTIONS
+              )}
+            >
+              <gr-button link class="download" @click=${this.handleDownloadTap}
+                >Download</gr-button
+              >
+            </gr-tooltip-content>
+          </span>
+          ${when(
+            this.fileListActionsVisible(
+              this.shownFileCount,
+              this.maxFilesForBulkActions
+            ),
+            () => html` <gr-tooltip-content
+                has-tooltip
+                title=${this.createTitle(
+                  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+                  ShortcutSection.FILE_LIST
+                )}
+              >
+                <gr-button id="expandBtn" link @click=${this.expandAllDiffs}
+                  >Expand All</gr-button
+                >
+              </gr-tooltip-content>
+              <gr-tooltip-content
+                has-tooltip
+                title=${this.createTitle(
+                  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+                  ShortcutSection.FILE_LIST
+                )}
+              >
+                <gr-button id="collapseBtn" link @click=${this.collapseAllDiffs}
+                  >Collapse All</gr-button
+                >
+              </gr-tooltip-content>`,
+            () => html`
+              <div class="warning">
+                Bulk actions disabled because there are too many files.
+              </div>
+            `
+          )}
+        </div>
+      </div>
+    `;
+  }
+
+  private expandAllDiffs() {
     fireEvent(this, 'expand-diffs');
   }
 
-  _collapseAllDiffs() {
+  private collapseAllDiffs() {
     fireEvent(this, 'collapse-diffs');
   }
 
-  _computeExpandedClass(filesExpanded: FilesExpandedState) {
+  private computeExpandedClass(filesExpanded?: FilesExpandedState) {
     const classes = [];
     if (filesExpanded === FilesExpandedState.ALL) {
       classes.push('openFile');
@@ -156,18 +385,14 @@
     return classes.join(' ');
   }
 
-  _computePrefsButtonHidden(prefs: DiffPreferencesInfo, loggedIn: boolean) {
-    return !loggedIn || !prefs;
-  }
-
-  _fileListActionsVisible(
+  private fileListActionsVisible(
     shownFileCount: number,
     maxFilesForBulkActions: number
   ) {
     return shownFileCount <= maxFilesForBulkActions;
   }
 
-  _handlePatchChange(e: CustomEvent) {
+  handlePatchChange(e: CustomEvent) {
     const {basePatchNum, patchNum} = e.detail;
     if (
       (basePatchNum === this.basePatchNum && patchNum === this.patchNum) ||
@@ -175,15 +400,17 @@
     ) {
       return;
     }
-    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
+    this.getNavigation().setUrl(
+      createChangeUrl({change: this.change, patchNum, basePatchNum})
+    );
   }
 
-  _handlePrefsTap(e: Event) {
+  private handlePrefsTap(e: Event) {
     e.preventDefault();
     fireEvent(this, 'open-diff-prefs');
   }
 
-  _handleDownloadTap(e: Event) {
+  private handleDownloadTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -191,11 +418,11 @@
     );
   }
 
-  _computeEditModeClass(editMode?: boolean) {
+  private computeEditModeClass(editMode?: boolean) {
     return editMode ? 'editMode' : '';
   }
 
-  _computePatchInfoClass(patchNum?: PatchSetNum, allPatchSets?: PatchSet[]) {
+  computePatchInfoClass(patchNum?: PatchSetNum, allPatchSets?: PatchSet[]) {
     const latestNum = computeLatestPatchNum(allPatchSets);
     if (patchNum === latestNum) {
       return '';
@@ -203,7 +430,13 @@
     return 'patchInfoOldPatchSet';
   }
 
-  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-    return this.shortcuts.createTitle(shortcutName, section);
+  private createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.getShortcutsService().createTitle(shortcutName, section);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-list-header': GrFileListHeader;
   }
 }
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
deleted file mode 100644
index fbba2fc..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ /dev/null
@@ -1,234 +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">
-    .prefsButton {
-      float: right;
-    }
-    .patchInfoOldPatchSet.patchInfo-header {
-      background-color: var(--emphasis-color);
-    }
-    .patchInfo-header {
-      align-items: center;
-      display: flex;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .patchInfo-left {
-      align-items: baseline;
-      display: flex;
-    }
-    .patchInfoContent {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-    }
-    .patchInfo-header .container.latestPatchContainer {
-      display: none;
-    }
-    .patchInfoOldPatchSet .container.latestPatchContainer {
-      display: initial;
-    }
-    .editMode.patchInfoOldPatchSet .container.latestPatchContainer {
-      display: none;
-    }
-    .latestPatchContainer a {
-      text-decoration: none;
-    }
-    .mobile {
-      display: none;
-    }
-    .patchInfo-header .container {
-      align-items: center;
-      display: flex;
-    }
-    .downloadContainer,
-    .uploadContainer {
-      margin-right: 16px;
-    }
-    .uploadContainer.hide {
-      display: none;
-    }
-    .rightControls {
-      align-self: flex-end;
-      margin: auto 0 auto auto;
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-      font-weight: var(--font-weight-normal);
-      justify-content: flex-end;
-    }
-    #collapseBtn,
-    .allExpanded #expandBtn,
-    .fileViewActions {
-      display: none;
-    }
-    .someExpanded #expandBtn {
-      margin-right: 8px;
-    }
-    .someExpanded #collapseBtn,
-    .allExpanded #collapseBtn,
-    .openFile .fileViewActions {
-      align-items: center;
-      display: flex;
-    }
-    .rightControls gr-button,
-    gr-patch-range-select {
-      margin: 0 -4px;
-    }
-    .fileViewActions gr-button {
-      margin: 0;
-      --gr-button-padding: 2px 4px;
-    }
-    .editMode .hideOnEdit {
-      display: none;
-    }
-    .showOnEdit {
-      display: none;
-    }
-    .editMode .showOnEdit {
-      display: initial;
-    }
-    .editMode .showOnEdit.flexContainer {
-      align-items: center;
-      display: flex;
-    }
-    .label {
-      font-weight: var(--font-weight-bold);
-      margin-right: 24px;
-    }
-    gr-commit-info,
-    gr-edit-controls {
-      margin-right: -5px;
-    }
-    .fileViewActionsLabel {
-      margin-right: var(--spacing-xs);
-    }
-    @media screen and (max-width: 50em) {
-      .patchInfo-header .desktop {
-        display: none;
-      }
-    }
-  </style>
-  <div
-    class$="patchInfo-header [[_computeEditModeClass(editMode)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]"
-  >
-    <div class="patchInfo-left">
-      <div class="patchInfoContent">
-        <gr-patch-range-select
-          id="rangeSelect"
-          change-num="[[changeNum]]"
-          patch-num="[[patchNum]]"
-          base-patch-num="[[basePatchNum]]"
-          available-patches="[[allPatchSets]]"
-          revisions="[[change.revisions]]"
-          revision-info="[[revisionInfo]]"
-          on-patch-range-change="_handlePatchChange"
-        >
-        </gr-patch-range-select>
-        <span class="separator"></span>
-        <gr-commit-info
-          change="[[change]]"
-          server-config="[[serverConfig]]"
-          commit-info="[[commitInfo]]"
-        ></gr-commit-info>
-        <span class="container latestPatchContainer">
-          <span class="separator"></span>
-          <a href$="[[changeUrl]]">Go to latest patch set</a>
-        </span>
-      </div>
-    </div>
-    <div class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
-      <template is="dom-if" if="[[editMode]]">
-        <span class="showOnEdit flexContainer">
-          <gr-edit-controls
-            id="editControls"
-            patch-num="[[patchNum]]"
-            change="[[change]]"
-          ></gr-edit-controls>
-          <span class="separator"></span>
-        </span>
-      </template>
-      <div class="fileViewActions">
-        <span class="fileViewActionsLabel">Diff view:</span>
-        <gr-diff-mode-selector
-          id="modeSelect"
-          save-on-change="[[loggedIn]]"
-        ></gr-diff-mode-selector>
-        <span
-          id="diffPrefsContainer"
-          class="hideOnEdit"
-          hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
-          hidden=""
-        >
-          <gr-tooltip-content has-tooltip title="Diff preferences">
-            <gr-button
-              link=""
-              class="prefsButton desktop"
-              on-click="_handlePrefsTap"
-              ><iron-icon icon="gr-icons:settings"></iron-icon
-            ></gr-button>
-          </gr-tooltip-content>
-        </span>
-        <span class="separator"></span>
-      </div>
-      <span class="downloadContainer desktop">
-        <gr-tooltip-content
-          has-tooltip
-          title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
-                   ShortcutSection.ACTIONS)]]"
-        >
-          <gr-button link="" class="download" on-click="_handleDownloadTap"
-            >Download</gr-button
-          >
-        </gr-tooltip-content>
-      </span>
-      <template
-        is="dom-if"
-        if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
-      >
-        <gr-tooltip-content
-          has-tooltip
-          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-                  ShortcutSection.FILE_LIST)]]"
-        >
-          <gr-button id="expandBtn" link="" on-click="_expandAllDiffs"
-            >Expand All</gr-button
-          >
-        </gr-tooltip-content>
-        <gr-tooltip-content
-          has-tooltip
-          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-                  ShortcutSection.FILE_LIST)]]"
-        >
-          <gr-button id="collapseBtn" link="" on-click="_collapseAllDiffs"
-            >Collapse All</gr-button
-          >
-        </gr-tooltip-content>
-      </template>
-      <template
-        is="dom-if"
-        if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
-      >
-        <div class="warning">
-          Bulk actions disabled because there are too many files.
-        </div>
-      </template>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
deleted file mode 100644
index 479a9a1..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
+++ /dev/null
@@ -1,203 +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-file-list-header.js';
-import {FilesExpandedState} from '../gr-file-list-constants.js';
-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');
-
-suite('gr-file-list-header tests', () => {
-  let element;
-
-  setup(() => {
-    stubRestApi('getConfig').returns(Promise.resolve({test: 'config'}));
-    stubRestApi('getAccount').returns(Promise.resolve(null));
-    element = basicFixture.instantiate();
-  });
-
-  teardown(async () => {
-    await flush();
-  });
-
-  test('Diff preferences hidden when no prefs', () => {
-    assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-    element.diffPrefs = {font_size: '12'};
-    element.loggedIn = true;
-    flush();
-    assert.isFalse(element.$.diffPrefsContainer.hidden);
-  });
-
-  test('expandAllDiffs called when expand button clicked', () => {
-    element.shownFileCount = 1;
-    flush();
-    sinon.stub(element, '_expandAllDiffs');
-    MockInteractions.tap(element.root.querySelector(
-        '#expandBtn'));
-    assert.isTrue(element._expandAllDiffs.called);
-  });
-
-  test('collapseAllDiffs called when collapse button clicked', () => {
-    element.shownFileCount = 1;
-    flush();
-    sinon.stub(element, '_collapseAllDiffs');
-    MockInteractions.tap(element.root.querySelector(
-        '#collapseBtn'));
-    assert.isTrue(element._collapseAllDiffs.called);
-  });
-
-  test('show/hide diffs disabled for large amounts of files', async () => {
-    const computeSpy = sinon.spy(element, '_fileListActionsVisible');
-    element._files = [];
-    element.changeNum = '42';
-    element.basePatchNum = 'PARENT';
-    element.patchNum = '2';
-    element.shownFileCount = 1;
-    await flush();
-    assert.isTrue(computeSpy.lastCall.returnValue);
-    _.times(element._maxFilesForBulkActions + 1, () => {
-      element.shownFileCount = element.shownFileCount + 1;
-    });
-    assert.isFalse(computeSpy.lastCall.returnValue);
-  });
-
-  test('fileViewActions are properly hidden', async () => {
-    const actions = element.shadowRoot
-        .querySelector('.fileViewActions');
-    assert.equal(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = FilesExpandedState.SOME;
-    await flush();
-    assert.notEqual(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = FilesExpandedState.ALL;
-    await flush();
-    assert.notEqual(getComputedStyle(actions).display, 'none');
-    element.filesExpanded = FilesExpandedState.NONE;
-    await flush();
-    assert.equal(getComputedStyle(actions).display, 'none');
-  });
-
-  test('expand/collapse buttons are toggled correctly', async () => {
-    // Only the expand button should be visible in the initial state when
-    // NO files are expanded.
-    element.shownFileCount = 10;
-    await 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;
-    await flush();
-    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-    assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
-
-    // Only the collapse button should be visible when ALL files are expanded.
-    element.filesExpanded = FilesExpandedState.ALL;
-    await flush();
-    assert.equal(getComputedStyle(expandBtn).display, 'none');
-    assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
-
-    // Only the expand button should be visible when NO files are expanded.
-    element.filesExpanded = FilesExpandedState.NONE;
-    await flush();
-    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
-    assert.equal(getComputedStyle(collapseBtn).display, 'none');
-  });
-
-  test('navigateToChange called when range select changes', () => {
-    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element.change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev2: {_number: 2},
-        rev1: {_number: 1},
-        rev13: {_number: 13},
-        rev3: {_number: 3},
-      },
-      status: 'NEW',
-      labels: {},
-    };
-    element.basePatchNum = 1;
-    element.patchNum = 2;
-
-    element._handlePatchChange({detail: {basePatchNum: 1, patchNum: 3}});
-    assert.equal(navigateToChangeStub.callCount, 1);
-    assert.isTrue(navigateToChangeStub.lastCall
-        .calledWithExactly(element.change, 3, 1));
-  });
-
-  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),
-        'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass(2, allPatchSets),
-        'patchInfoOldPatchSet');
-    assert.equal(element._computePatchInfoClass(4, allPatchSets), '');
-  });
-
-  suite('editMode behavior', () => {
-    setup(() => {
-      element.loggedIn = true;
-      element.diffPrefs = {};
-    });
-
-    const isVisible = el => {
-      assert.ok(el);
-      return getComputedStyle(el).getPropertyValue('display') !== 'none';
-    };
-
-    test('patch specific elements', () => {
-      element.editMode = true;
-      element.allPatchSets = createRevisions(2);
-      flush();
-
-      assert.isFalse(isVisible(element.$.diffPrefsContainer));
-
-      element.editMode = false;
-      flush();
-
-      assert.isTrue(isVisible(element.$.diffPrefsContainer));
-    });
-
-    test('edit-controls visibility', () => {
-      element.editMode = false;
-      flush();
-      // on the first render, when editMode is false, editControls are not
-      // in the DOM to reduce size of DOM and make first render faster.
-      assert.isNull(element.shadowRoot
-          .querySelector('#editControls'));
-
-      element.editMode = true;
-      flush();
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('#editControls').parentElement));
-
-      element.editMode = false;
-      flush();
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('#editControls').parentElement));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
new file mode 100644
index 0000000..6c2282b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
@@ -0,0 +1,318 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-file-list-header';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {createChange, createRevision} from '../../../test/test-data-generators';
+import {
+  isVisible,
+  query,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrFileListHeader} from './gr-file-list-header';
+import {
+  BasePatchSetNum,
+  ChangeId,
+  PARENT,
+  PatchSetNum,
+  PatchSetNumber,
+} from '../../../types/common';
+import {ChangeInfo, ChangeStatus} from '../../../api/rest-api';
+import {PatchSet} from '../../../utils/patch-set-util';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+
+suite('gr-file-list-header tests', () => {
+  let element: GrFileListHeader;
+  const change: ChangeInfo = {
+    ...createChange(),
+    change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca' as ChangeId,
+    revisions: {
+      rev2: createRevision(2),
+      rev1: createRevision(1),
+      rev13: createRevision(13),
+      rev3: createRevision(3),
+    },
+    status: 'NEW' as ChangeStatus,
+    labels: {},
+  };
+
+  setup(async () => {
+    stubRestApi('getAccount').resolves(undefined);
+    element = await fixture(
+      html`<gr-file-list-header
+        .change=${change}
+        .shownFileCount=${3}
+      ></gr-file-list-header>`
+    );
+    element.loggedIn = true;
+    element.diffPrefs = createDefaultDiffPrefs();
+    await element.updateComplete;
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="patchInfo-header">
+          <div class="patchInfo-left">
+            <div class="patchInfoContent">
+              <gr-patch-range-select id="rangeSelect"> </gr-patch-range-select>
+              <span class="separator"> </span>
+              <gr-commit-info> </gr-commit-info>
+              <span class="container latestPatchContainer">
+                <span class="separator"> </span>
+                <a> Go to latest patch set </a>
+              </span>
+            </div>
+          </div>
+          <div class="rightControls">
+            <div class="fileViewActions">
+              <span class="fileViewActionsLabel"> Diff view: </span>
+              <gr-diff-mode-selector id="modeSelect"> </gr-diff-mode-selector>
+              <span class="hideOnEdit" id="diffPrefsContainer">
+                <gr-tooltip-content has-tooltip="" title="Diff preferences">
+                  <gr-button
+                    aria-disabled="false"
+                    class="desktop prefsButton"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    <gr-icon filled icon="settings"></gr-icon>
+                  </gr-button>
+                </gr-tooltip-content>
+              </span>
+              <span class="separator"> </span>
+            </div>
+            <span class="desktop downloadContainer">
+              <gr-tooltip-content
+                has-tooltip=""
+                title="Open download overlay (shortcut: d)"
+              >
+                <gr-button
+                  aria-disabled="false"
+                  class="download"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Download
+                </gr-button>
+              </gr-tooltip-content>
+            </span>
+            <gr-tooltip-content
+              has-tooltip=""
+              title="Show/hide all inline diffs (shortcut: Shift+i)"
+            >
+              <gr-button
+                aria-disabled="false"
+                id="expandBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Expand All
+              </gr-button>
+            </gr-tooltip-content>
+            <gr-tooltip-content
+              has-tooltip=""
+              title="Show/hide all inline diffs (shortcut: Shift+i)"
+            >
+              <gr-button
+                aria-disabled="false"
+                id="collapseBtn"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Collapse All
+              </gr-button>
+            </gr-tooltip-content>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('Diff preferences hidden when no prefs', async () => {
+    assert.isOk(query<HTMLElement>(element, '#diffPrefsContainer'));
+
+    element.diffPrefs = undefined;
+    element.loggedIn = true;
+    await element.updateComplete;
+
+    assert.isNotOk(query<HTMLElement>(element, '#diffPrefsContainer'));
+  });
+
+  test('expandAllDiffs called when expand button clicked', async () => {
+    const expandDiffsListener = sinon.stub();
+    element.addEventListener('expand-diffs', expandDiffsListener);
+
+    queryAndAssert<GrButton>(element, 'gr-button#expandBtn').click();
+    await element.updateComplete;
+
+    assert.isTrue(expandDiffsListener.called);
+  });
+
+  test('collapseAllDiffs called when collapse button clicked', async () => {
+    const collapseAllDiffsListener = sinon.stub();
+    element.addEventListener('collapse-diffs', collapseAllDiffsListener);
+
+    queryAndAssert<GrButton>(element, 'gr-button#collapseBtn').click();
+    await element.updateComplete;
+
+    assert.isTrue(collapseAllDiffsListener.called);
+  });
+
+  test('show/hide diffs disabled for large amounts of files', async () => {
+    element.basePatchNum = PARENT;
+    element.patchNum = '2' as PatchSetNum;
+    element.shownFileCount = 1;
+    await element.updateComplete;
+
+    queryAndAssert(element, 'gr-button#expandBtn');
+    queryAndAssert(element, 'gr-button#collapseBtn');
+    assert.isNotOk(query(element, '.warning'));
+
+    element.shownFileCount = 226; // more than element.maxFilesForBulkActions
+    await element.updateComplete;
+
+    assert.isNotOk(query(element, 'gr-button#expandBtn'));
+    assert.isNotOk(query(element, 'gr-button#collapseBtn'));
+    queryAndAssert(element, '.warning');
+  });
+
+  test('fileViewActions are properly hidden', async () => {
+    const actions = queryAndAssert(element, '.fileViewActions');
+    assert.equal(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = FilesExpandedState.SOME;
+    await element.updateComplete;
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = FilesExpandedState.ALL;
+    await element.updateComplete;
+    assert.notEqual(getComputedStyle(actions).display, 'none');
+    element.filesExpanded = FilesExpandedState.NONE;
+    await element.updateComplete;
+    assert.equal(getComputedStyle(actions).display, 'none');
+  });
+
+  test('expand/collapse buttons are toggled correctly', async () => {
+    // Only the expand button should be visible in the initial state when
+    // NO files are expanded.
+    element.shownFileCount = 10;
+    await element.updateComplete;
+    const expandBtn = queryAndAssert(element, '#expandBtn');
+    const collapseBtn = queryAndAssert(element, '#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;
+    await element.updateComplete;
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
+
+    // Only the collapse button should be visible when ALL files are expanded.
+    element.filesExpanded = FilesExpandedState.ALL;
+    await element.updateComplete;
+    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;
+    await element.updateComplete;
+    assert.notEqual(getComputedStyle(expandBtn).display, 'none');
+    assert.equal(getComputedStyle(collapseBtn).display, 'none');
+  });
+
+  test('setUrl called when range select changes', async () => {
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+    element.basePatchNum = 1 as BasePatchSetNum;
+    element.patchNum = 2 as PatchSetNum;
+    await element.updateComplete;
+
+    element.handlePatchChange({
+      detail: {basePatchNum: 1, patchNum: 3},
+    } as CustomEvent);
+    await element.updateComplete;
+
+    assert.equal(setUrlStub.callCount, 1);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..3');
+  });
+
+  test('class is applied to file list on old patch set', () => {
+    const allPatchSets: PatchSet[] = [
+      {num: 4 as PatchSetNumber, desc: undefined, sha: ''},
+      {num: 2 as PatchSetNumber, desc: undefined, sha: ''},
+      {num: 1 as PatchSetNumber, desc: undefined, sha: ''},
+    ];
+    assert.equal(
+      element.computePatchInfoClass(1 as PatchSetNum, allPatchSets),
+      'patchInfoOldPatchSet'
+    );
+    assert.equal(
+      element.computePatchInfoClass(2 as PatchSetNum, allPatchSets),
+      'patchInfoOldPatchSet'
+    );
+    assert.equal(
+      element.computePatchInfoClass(4 as PatchSetNum, allPatchSets),
+      ''
+    );
+  });
+
+  suite('editMode behavior', () => {
+    setup(async () => {
+      element.loggedIn = true;
+      await element.updateComplete;
+    });
+
+    test('patch specific elements', async () => {
+      element.editMode = true;
+      element.allPatchSets = [
+        {num: 1 as PatchSetNumber, desc: undefined, sha: ''},
+        {num: 2 as PatchSetNumber, desc: undefined, sha: ''},
+        {num: 3 as PatchSetNumber, desc: undefined, sha: ''},
+      ];
+      await element.updateComplete;
+
+      assert.isFalse(
+        isVisible(queryAndAssert<HTMLElement>(element, '#diffPrefsContainer'))
+      );
+
+      element.editMode = false;
+      await element.updateComplete;
+
+      assert.isTrue(
+        isVisible(queryAndAssert<HTMLElement>(element, '#diffPrefsContainer'))
+      );
+    });
+
+    test('edit-controls visibility', async () => {
+      element.editMode = false;
+      await element.updateComplete;
+
+      assert.isNotOk(query(element, '#editControls'));
+
+      element.editMode = true;
+      await element.updateComplete;
+
+      assert.isTrue(
+        isVisible(queryAndAssert<HTMLElement>(element, '#editControls'))
+      );
+
+      element.editMode = false;
+      await element.updateComplete;
+
+      assert.isNotOk(query<HTMLElement>(element, '#editControls'));
+    });
+  });
+});
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 d02f09b..d4defcb 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
@@ -1,101 +1,95 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
-import '../../diff/gr-diff-cursor/gr-diff-cursor';
+import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import '../../diff/gr-diff-host/gr-diff-host';
 import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-cursor-manager/gr-cursor-manager';
-import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-icon/gr-icon';
 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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-file-list_html';
-import {asyncForeach, debounce, DelayedTask} from '../../../utils/async-util';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import '../../shared/gr-file-status/gr-file-status';
+import {assertIsDefined} from '../../../utils/common-util';
+import {asyncForeach} from '../../../utils/async-util';
 import {FilesExpandedState} from '../gr-file-list-constants';
-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 {diffFilePaths, pluralize} from '../../../utils/string-util';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {getAppContext} from '../../../services/app-context';
 import {
   DiffViewMode,
+  FileInfoStatus,
   ScrollMode,
   SpecialFilePath,
 } from '../../../constants/constants';
+import {descendedFromClass, Key, toggleClass} from '../../../utils/dom-util';
 import {
-  addGlobalShortcut,
-  addShortcut,
-  descendedFromClass,
-  Key,
-  toggleClass,
-} from '../../../utils/dom-util';
-import {
-  addUnmodifiedFiles,
   computeDisplayPath,
   computeTruncatedPath,
   isMagicPath,
-  specialFilePathCompare,
 } from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   BasePatchSetNum,
-  EditPatchSetNum,
-  ElementPropertyDeepChange,
+  EDIT,
   FileInfo,
-  FileNameToFileInfoMap,
   NumericChangeId,
+  PARENT,
   PatchRange,
 } 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 {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
+import {GrDiffCursor} from '../../../embed/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 {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
-import {Timing} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
+import {select} from '../../../utils/observable-util';
+import {resolve} from '../../../models/dependency';
+import {browserModelToken} from '../../../models/browser/browser-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {filesModelToken} from '../../../models/change/files-model';
+import {ShortcutController} from '../../lit/shortcut-controller';
 import {
-  diffPreferences$,
-  sizeBarInChangeTable$,
-} from '../../../services/user/user-model';
-import {changeComments$} from '../../../services/comments/comments-model';
-import {Subject} from 'rxjs';
-import {takeUntil} from 'rxjs/operators';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
-import {diffViewMode$} from '../../../services/browser/browser-model';
+  css,
+  html,
+  LitElement,
+  nothing,
+  PropertyValues,
+  TemplateResult,
+} from 'lit';
+import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
+import {fire} from '../../../utils/event-util';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ValueChangedEvent} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {when} from 'lit/directives/when.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {incrementalRepeat} from '../../lit/incremental-repeat';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {HtmlPatched} from '../../../utils/lit-util';
+import {
+  createDiffUrl,
+  createEditUrl,
+  createChangeUrl,
+} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {FileMode, fileModeToString} from '../../../utils/file-util';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
 const WARN_SHOW_ALL_THRESHOLD = 1000;
-const LOADING_DEBOUNCE_INTERVAL = 100;
 
 const SIZE_BAR_MAX_WIDTH = 61;
 const SIZE_BAR_GAP_WIDTH = 1;
@@ -103,16 +97,7 @@
 
 const FILE_ROW_CLASS = 'file-row';
 
-export interface GrFileList {
-  $: {
-    diffPreferencesDialog: GrDiffPreferencesDialog;
-  };
-}
-
-interface ReviewedFileInfo extends FileInfo {
-  isReviewed?: boolean;
-}
-export interface NormalizedFileInfo extends ReviewedFileInfo {
+export interface NormalizedFileInfo extends FileInfo {
   __path: string;
 }
 
@@ -161,8 +146,6 @@
   element: HTMLElement;
 }
 
-export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo};
-
 /**
  * Type for FileInfo
  *
@@ -178,14 +161,25 @@
  * @property {number} lines_inserted - fallback to 0 if not present in api
  */
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
-@customElement('gr-file-list')
-export class GrFileList extends base {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementEventMap {
+    'files-shown-changed': CustomEvent<{length: number}>;
+    'files-expanded-changed': ValueChangedEvent<FilesExpandedState>;
+    'diff-prefs-changed': ValueChangedEvent<DiffPreferencesInfo>;
   }
+  interface HTMLElementTagNameMap {
+    'gr-file-list': GrFileList;
+  }
+}
+@customElement('gr-file-list')
+export class GrFileList extends LitElement {
+  /**
+   * @event files-expanded-changed
+   * @event files-shown-changed
+   * @event diff-prefs-changed
+   */
+  @query('#diffPreferencesDialog')
+  diffPreferencesDialog?: GrDiffPreferencesDialog;
 
   @property({type: Object})
   patchRange?: PatchRange;
@@ -199,309 +193,1477 @@
   @property({type: Object})
   changeComments?: ChangeComments;
 
-  @property({type: Number, notify: true})
-  selectedIndex = -1;
+  @state() selectedIndex = 0;
 
   @property({type: Object})
   change?: ParsedChangeInfo;
 
-  @property({type: String, notify: true, observer: '_updateDiffPreferences'})
+  @state()
   diffViewMode?: DiffViewMode;
 
-  @property({type: Boolean, observer: '_editModeChanged'})
+  @property({type: Boolean})
   editMode?: boolean;
 
-  @property({type: String, notify: true})
-  filesExpanded = FilesExpandedState.NONE;
+  private _filesExpanded = FilesExpandedState.NONE;
 
-  @property({type: Object})
-  _filesByPath?: FileNameToFileInfoMap;
+  get filesExpanded() {
+    return this._filesExpanded;
+  }
 
-  @property({type: Array, observer: '_filesChanged'})
-  _files: NormalizedFileInfo[] = [];
+  set filesExpanded(filesExpanded: FilesExpandedState) {
+    if (this._filesExpanded === filesExpanded) return;
+    const oldFilesExpanded = this._filesExpanded;
+    this._filesExpanded = filesExpanded;
+    fire(this, 'files-expanded-changed', {value: this._filesExpanded});
+    this.requestUpdate('filesExpanded', oldFilesExpanded);
+  }
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  // Private but used in tests.
+  @state()
+  files: NormalizedFileInfo[] = [];
 
-  @property({type: Array})
-  _reviewed?: string[] = [];
+  // Private but used in tests.
+  @state() filesLeftBase: NormalizedFileInfo[] = [];
 
-  @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
+  @state() private filesRightBase: NormalizedFileInfo[] = [];
+
+  // Private but used in tests.
+  @state()
+  loggedIn = false;
+
+  /**
+   * List of paths of files that are marked as reviewed. Direct model
+   * subscription.
+   */
+  @state()
+  reviewed: string[] = [];
+
+  @state()
   diffPrefs?: DiffPreferencesInfo;
 
-  @property({type: Number, notify: true})
-  numFilesShown: number = DEFAULT_NUM_FILES_SHOWN;
+  @state() numFilesShown = DEFAULT_NUM_FILES_SHOWN;
 
-  @property({type: Object, computed: '_calculatePatchChange(_files)'})
-  _patchChange: PatchChange = createDefaultPatchChange();
-
-  @property({type: Number})
+  @state()
   fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
 
-  @property({type: Boolean, computed: '_shouldHideChangeTotals(_patchChange)'})
-  _hideChangeTotals = true;
+  // Private but used in tests.
+  shownFiles: NormalizedFileInfo[] = [];
 
-  @property({
-    type: Boolean,
-    computed: '_shouldHideBinaryChangeTotals(_patchChange)',
-  })
-  _hideBinaryChangeTotals = true;
+  @state()
+  private reportinShownFilesIncrement = 0;
 
-  @property({
-    type: Array,
-    computed: '_computeFilesShown(numFilesShown, _files)',
-  })
-  _shownFiles: NormalizedFileInfo[] = [];
+  // Private but used in tests.
+  @state()
+  expandedFiles: PatchSetFile[] = [];
 
-  @property({type: Number})
-  _reportinShownFilesIncrement = 0;
+  // Private but used in tests.
+  @state()
+  displayLine?: boolean;
 
-  @property({type: Array})
-  _expandedFiles: PatchSetFile[] = [];
-
-  @property({type: Boolean})
-  _displayLine?: boolean;
-
-  @property({type: Boolean, observer: '_loadingChanged'})
-  _loading?: boolean;
-
-  @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
-  _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
-
-  @property({type: Boolean})
-  _showSizeBars = true;
+  // Private but used in tests.
+  @state()
+  showSizeBars = true;
 
   // For merge commits vs Auto Merge, an extra file row is shown detailing the
   // files that were merged without conflict. These files are also passed to any
   // plugins.
-  @property({type: Array})
-  _cleanlyMergedPaths: string[] = [];
+  @state()
+  private cleanlyMergedPaths: string[] = [];
 
-  @property({type: Array})
-  _cleanlyMergedOldPaths: string[] = [];
+  // Private but used in tests.
+  @state()
+  cleanlyMergedOldPaths: string[] = [];
 
-  private _cancelForEachDiff?: () => void;
+  private cancelForEachDiff?: () => void;
 
-  loadingTask?: DelayedTask;
+  @state()
+  private dynamicHeaderEndpoints?: string[];
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
-      '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
-  })
-  _showDynamicColumns = false;
+  @state()
+  private dynamicContentEndpoints?: string[];
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeShowPrependedDynamicColumns(' +
-      '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
-  })
-  _showPrependedDynamicColumns = false;
+  @state()
+  private dynamicSummaryEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicHeaderEndpoints?: string[];
+  @state()
+  private dynamicPrependedHeaderEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicContentEndpoints?: string[];
+  @state()
+  private dynamicPrependedContentEndpoints?: string[];
 
-  @property({type: Array})
-  _dynamicSummaryEndpoints?: string[];
+  private readonly reporting = getAppContext().reportingService;
 
-  @property({type: Array})
-  _dynamicPrependedHeaderEndpoints?: string[];
+  private readonly restApiService = getAppContext().restApiService;
 
-  @property({type: Array})
-  _dynamicPrependedContentEndpoints?: string[];
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
-  private readonly reporting = appContext.reportingService;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly userService = appContext.userService;
+  private readonly getFilesModel = resolve(this, filesModelToken);
 
-  disconnected$ = new Subject();
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  override keyboardShortcuts(): ShortcutListener[] {
+  private readonly patched = new HtmlPatched(key => {
+    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
+      component: this.tagName,
+      key: key.substring(0, 300),
+    });
+  });
+
+  shortcutsController = new ShortcutController(this);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  // private but used in test
+  fileCursor = new GrCursorManager();
+
+  // private but used in test
+  diffCursor?: GrDiffCursor;
+
+  static override get styles() {
     return [
-      listen(Shortcut.LEFT_PANE, _ => this._handleLeftPane()),
-      listen(Shortcut.RIGHT_PANE, _ => this._handleRightPane()),
-      listen(Shortcut.TOGGLE_INLINE_DIFF, _ => this._handleToggleInlineDiff()),
-      listen(Shortcut.TOGGLE_ALL_INLINE_DIFFS, _ => this._toggleInlineDiffs()),
-      listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
-        toggleClass(this, 'hideComments')
-      ),
-      listen(Shortcut.CURSOR_NEXT_FILE, e => this._handleCursorNext(e)),
-      listen(Shortcut.CURSOR_PREV_FILE, e => this._handleCursorPrev(e)),
-      // This is already been taken care of by CURSOR_NEXT_FILE above. The two
-      // shortcuts share the same bindings. It depends on whether all files
-      // are expanded whether the cursor moves to the next file or line.
-      listen(Shortcut.NEXT_LINE, _ => {}), // docOnly
-      // This is already been taken care of by CURSOR_PREV_FILE above. The two
-      // shortcuts share the same bindings. It depends on whether all files
-      // are expanded whether the cursor moves to the previous file or line.
-      listen(Shortcut.PREV_LINE, _ => {}), // docOnly
-      listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
-      listen(Shortcut.OPEN_LAST_FILE, _ =>
-        this._openSelectedFile(this._files.length - 1)
-      ),
-      listen(Shortcut.OPEN_FIRST_FILE, _ => this._openSelectedFile(0)),
-      listen(Shortcut.OPEN_FILE, _ => this.handleOpenFile()),
-      listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
-      listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
-      listen(Shortcut.NEXT_COMMENT_THREAD, _ => this._handleNextComment()),
-      listen(Shortcut.PREV_COMMENT_THREAD, _ => this._handlePrevComment()),
-      listen(Shortcut.TOGGLE_FILE_REVIEWED, _ =>
-        this._handleToggleFileReviewed()
-      ),
-      listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
-      listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
-      listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        .row {
+          align-items: center;
+          border-top: 1px solid var(--border-color);
+          display: flex;
+          min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
+          padding: var(--spacing-xs) var(--spacing-l);
+        }
+        /* The class defines a content visible only to screen readers */
+        .noCommentsScreenReaderText {
+          opacity: 0;
+          max-width: 1px;
+          overflow: hidden;
+          display: none;
+          vertical-align: top;
+        }
+        div[role='gridcell']
+          > div.comments
+          > span:empty
+          + span:empty
+          + span.noCommentsScreenReaderText {
+          /* inline-block instead of block, such that it can control width */
+          display: inline-block;
+        }
+        :host(.editMode) .hideOnEdit {
+          display: none;
+        }
+        .showOnEdit {
+          display: none;
+        }
+        :host(.editMode) .showOnEdit {
+          display: initial;
+        }
+        .invisible {
+          visibility: hidden;
+        }
+        .header-row {
+          background-color: var(--background-color-secondary);
+        }
+        .controlRow {
+          align-items: center;
+          display: flex;
+          height: 2.25em;
+          justify-content: center;
+        }
+        .controlRow.invisible,
+        .show-hide.invisible {
+          display: none;
+        }
+        .reviewed {
+          align-items: center;
+          display: inline-flex;
+        }
+        .reviewed {
+          display: inline-block;
+          text-align: left;
+          width: 1.5em;
+        }
+        .file-row {
+          cursor: pointer;
+        }
+        .file-row.expanded {
+          border-bottom: 1px solid var(--border-color);
+          position: -webkit-sticky;
+          position: sticky;
+          top: 0;
+          /* Has to visible above the diff view, and by default has a lower
+            z-index. setting to 1 places it directly above. */
+          z-index: 1;
+        }
+        .file-row:hover {
+          background-color: var(--hover-background-color);
+        }
+        .file-row.selected {
+          background-color: var(--selection-background-color);
+        }
+        .file-row.expanded,
+        .file-row.expanded:hover {
+          background-color: var(--expanded-background-color);
+        }
+        .status {
+          margin-right: var(--spacing-m);
+          display: flex;
+          width: 20px;
+          justify-content: flex-end;
+        }
+        .status.extended {
+          width: 56px;
+        }
+        .status > * {
+          display: block;
+        }
+        .header-row .status .content {
+          width: 20px;
+          text-align: center;
+        }
+        .path {
+          cursor: pointer;
+          flex: 1;
+          /* Wrap it into multiple lines if too long. */
+          white-space: normal;
+          word-break: break-word;
+        }
+        .oldPath {
+          color: var(--deemphasized-text-color);
+        }
+        .header-stats {
+          text-align: center;
+          min-width: 7.5em;
+        }
+        .stats {
+          text-align: right;
+          min-width: 7.5em;
+        }
+        .comments {
+          padding-left: var(--spacing-l);
+          min-width: 7.5em;
+          white-space: nowrap;
+        }
+        .row:not(.header-row) .stats,
+        .total-stats {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          display: flex;
+        }
+        .sizeBars {
+          margin-left: var(--spacing-m);
+          min-width: 7em;
+          text-align: center;
+        }
+        .sizeBars.hide {
+          display: none;
+        }
+        .added,
+        .removed {
+          display: inline-block;
+          min-width: 3.5em;
+        }
+        .added {
+          color: var(--positive-green-text-color);
+        }
+        .removed {
+          color: var(--negative-red-text-color);
+          text-align: left;
+          min-width: 4em;
+          padding-left: var(--spacing-s);
+        }
+        .drafts {
+          color: var(--error-foreground);
+          font-weight: var(--font-weight-bold);
+        }
+        .show-hide-icon:focus {
+          outline: none;
+        }
+        .show-hide {
+          margin-left: var(--spacing-s);
+          width: 1.9em;
+        }
+        .fileListButton {
+          margin: var(--spacing-m);
+        }
+        .totalChanges {
+          justify-content: flex-end;
+          text-align: right;
+        }
+        .warning {
+          color: var(--deemphasized-text-color);
+        }
+        input.show-hide {
+          display: none;
+        }
+        label.show-hide {
+          cursor: pointer;
+          display: block;
+          min-width: 2em;
+        }
+        gr-diff {
+          display: block;
+          overflow-x: auto;
+        }
+        .matchingFilePath {
+          color: var(--deemphasized-text-color);
+        }
+        .newFilePath {
+          color: var(--primary-text-color);
+        }
+        .fileName {
+          color: var(--link-color);
+        }
+        .truncatedFileName {
+          display: none;
+        }
+        .mobile {
+          display: none;
+        }
+        .reviewed {
+          margin-left: var(--spacing-xxl);
+          width: 15em;
+        }
+        .reviewedSwitch {
+          color: var(--link-color);
+          opacity: 0;
+          justify-content: flex-end;
+          width: 100%;
+        }
+        .reviewedSwitch:hover {
+          cursor: pointer;
+          opacity: 100;
+        }
+        .showParentButton {
+          line-height: var(--line-height-normal);
+          margin-bottom: calc(var(--spacing-s) * -1);
+          margin-left: var(--spacing-m);
+          margin-top: calc(var(--spacing-s) * -1);
+        }
+        .row:focus {
+          outline: none;
+        }
+        .row:hover .reviewedSwitch,
+        .row:focus-within .reviewedSwitch,
+        .row.expanded .reviewedSwitch {
+          opacity: 100;
+        }
+        .reviewedLabel {
+          color: var(--deemphasized-text-color);
+          margin-right: var(--spacing-l);
+          opacity: 0;
+        }
+        .reviewedLabel.isReviewed {
+          display: initial;
+          opacity: 100;
+        }
+        .editFileControls {
+          width: 7em;
+        }
+        .markReviewed:focus {
+          outline: none;
+        }
+        .markReviewed,
+        .pathLink {
+          display: inline-block;
+          margin: -2px 0;
+          padding: var(--spacing-s) 0;
+          text-decoration: none;
+        }
+        .pathLink:hover span.fullFileName,
+        .pathLink:hover span.truncatedFileName {
+          text-decoration: underline;
+        }
+
+        /** copy on file path **/
+        .pathLink gr-copy-clipboard,
+        .oldPath gr-copy-clipboard {
+          display: inline-block;
+          visibility: hidden;
+          vertical-align: bottom;
+          --gr-button-padding: 0px;
+        }
+        .row:focus-within gr-copy-clipboard,
+        .row:hover gr-copy-clipboard {
+          visibility: visible;
+        }
+
+        .file-status-arrow {
+          font-size: 16px;
+          position: relative;
+          top: 2px;
+          display: block;
+        }
+        .file-mode-warning {
+          font-size: 16px;
+          position: relative;
+          top: 2px;
+          color: var(--warning-foreground);
+        }
+        .file-mode-content {
+          display: inline-block;
+          color: var(--deemphasized-text-color);
+        }
+
+        @media screen and (max-width: 1200px) {
+          gr-endpoint-decorator.extra-col {
+            display: none;
+          }
+        }
+
+        @media screen and (max-width: 1000px) {
+          .reviewed {
+            display: none;
+          }
+        }
+
+        @media screen and (max-width: 800px) {
+          .desktop {
+            display: none;
+          }
+          .mobile {
+            display: block;
+          }
+          .row.selected {
+            background-color: var(--view-background-color);
+          }
+          .stats {
+            display: none;
+          }
+          .reviewed,
+          .status {
+            justify-content: flex-start;
+          }
+          .comments {
+            min-width: initial;
+          }
+          .expanded .fullFileName,
+          .truncatedFileName {
+            display: inline;
+          }
+          .expanded .truncatedFileName,
+          .fullFileName {
+            display: none;
+          }
+        }
+        :host(.hideComments) {
+          --gr-comment-thread-display: none;
+        }
+      `,
     ];
   }
 
-  private fileCursor = new GrCursorManager();
-
-  private diffCursor = new GrDiffCursor();
-
   constructor() {
     super();
     this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.fileCursor.cursorTargetClass = 'selected';
     this.fileCursor.focusOnMove = true;
+    this.shortcutsController.addAbstract(Shortcut.LEFT_PANE, _ =>
+      this.handleLeftPane()
+    );
+    this.shortcutsController.addAbstract(Shortcut.RIGHT_PANE, _ =>
+      this.handleRightPane()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_INLINE_DIFF, _ =>
+      this.handleToggleInlineDiff()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_ALL_INLINE_DIFFS, _ =>
+      this.toggleInlineDiffs()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+      _ => toggleClass(this, 'hideComments')
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.CURSOR_NEXT_FILE,
+      e => this.handleCursorNext(e),
+      {preventDefault: false}
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.CURSOR_PREV_FILE,
+      e => this.handleCursorPrev(e),
+      {preventDefault: false}
+    );
+    // This is already been taken care of by CURSOR_NEXT_FILE above. The two
+    // shortcuts share the same bindings. It depends on whether all files
+    // are expanded whether the cursor moves to the next file or line.
+    this.shortcutsController.addAbstract(Shortcut.NEXT_LINE, _ => {}, {
+      preventDefault: false,
+    }); // docOnly
+    // This is already been taken care of by CURSOR_PREV_FILE above. The two
+    // shortcuts share the same bindings. It depends on whether all files
+    // are expanded whether the cursor moves to the previous file or line.
+    this.shortcutsController.addAbstract(Shortcut.PREV_LINE, _ => {}, {
+      preventDefault: false,
+    }); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.NEW_COMMENT, _ =>
+      this.handleNewComment()
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_LAST_FILE, _ =>
+      this.openSelectedFile(this.files.length - 1)
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_FIRST_FILE, _ =>
+      this.openSelectedFile(0)
+    );
+    this.shortcutsController.addAbstract(Shortcut.OPEN_FILE, _ =>
+      this.handleOpenFile()
+    );
+    this.shortcutsController.addAbstract(Shortcut.NEXT_CHUNK, _ =>
+      this.handleNextChunk()
+    );
+    this.shortcutsController.addAbstract(Shortcut.PREV_CHUNK, _ =>
+      this.handlePrevChunk()
+    );
+    this.shortcutsController.addAbstract(Shortcut.NEXT_COMMENT_THREAD, _ =>
+      this.handleNextComment()
+    );
+    this.shortcutsController.addAbstract(Shortcut.PREV_COMMENT_THREAD, _ =>
+      this.handlePrevComment()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_FILE_REVIEWED, _ =>
+      this.handleToggleFileReviewed()
+    );
+    this.shortcutsController.addAbstract(Shortcut.TOGGLE_LEFT_PANE, _ =>
+      this.handleToggleLeftPane()
+    );
+    this.shortcutsController.addGlobal({key: Key.ESC}, _ =>
+      this.handleEscKey()
+    );
+    this.shortcutsController.addAbstract(
+      Shortcut.EXPAND_ALL_COMMENT_THREADS,
+      _ => {}
+    ); // docOnly
+    this.shortcutsController.addAbstract(
+      Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+      _ => {}
+    ); // docOnly
+    this.shortcutsController.addLocal(
+      {key: Key.ENTER},
+      _ => this.handleOpenFile(),
+      {
+        shouldSuppress: true,
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().changeComments$,
+      changeComments => {
+        this.changeComments = changeComments;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFilesModel().filesIncludingUnmodified$,
+      files => {
+        this.files = [...files];
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFilesModel().filesLeftBase$,
+      files => {
+        this.filesLeftBase = [...files];
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFilesModel().filesRightBase$,
+      files => {
+        this.filesRightBase = [...files];
+      }
+    );
+    subscribe(
+      this,
+      () => this.getBrowserModel().diffViewMode$,
+      diffView => {
+        this.diffViewMode = diffView;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      diffPreferences => {
+        this.diffPrefs = diffPreferences;
+      }
+    );
+    subscribe(
+      this,
+      () =>
+        select(
+          this.getUserModel().preferences$,
+          prefs => !!prefs?.size_bar_in_change_table
+        ),
+      sizeBarInChangeTable => {
+        this.showSizeBars = sizeBarInChangeTable;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      loggedIn => {
+        this.loggedIn = loggedIn;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().reviewedFiles$,
+      reviewedFiles => {
+        this.reviewed = reviewedFiles ?? [];
+      }
+    );
+  }
+
+  override willUpdate(changedProperties: PropertyValues): void {
+    if (
+      changedProperties.has('diffPrefs') ||
+      changedProperties.has('diffViewMode')
+    ) {
+      this.updateDiffPreferences();
+    }
+    if (changedProperties.has('files')) {
+      this.filesChanged();
+    }
+    if (
+      changedProperties.has('files') ||
+      changedProperties.has('numFilesShown')
+    ) {
+      this.shownFiles = this.computeFilesShown();
+    }
+    if (changedProperties.has('expandedFiles')) {
+      this.expandedFilesChanged(changedProperties.get('expandedFiles'));
+    }
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    changeComments$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(changeComments => {
-        this.changeComments = changeComments;
-      });
-    diffViewMode$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(diffView => (this.diffViewMode = diffView));
-    diffPreferences$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(diffPreferences => {
-        this.diffPrefs = diffPreferences;
-      });
-    sizeBarInChangeTable$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(sizeBarInChangeTable => {
-        this._showSizeBars = sizeBarInChangeTable;
-      });
 
-    getPluginLoader()
+    this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-view-file-list-header'
-        );
-        this._dynamicContentEndpoints =
-          getPluginEndpoints().getDynamicEndpoints(
+        this.dynamicHeaderEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+            'change-view-file-list-header'
+          );
+        this.dynamicContentEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
             'change-view-file-list-content'
           );
-        this._dynamicPrependedHeaderEndpoints =
-          getPluginEndpoints().getDynamicEndpoints(
+        this.dynamicPrependedHeaderEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
             'change-view-file-list-header-prepend'
           );
-        this._dynamicPrependedContentEndpoints =
-          getPluginEndpoints().getDynamicEndpoints(
+        this.dynamicPrependedContentEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
             'change-view-file-list-content-prepend'
           );
-        this._dynamicSummaryEndpoints =
-          getPluginEndpoints().getDynamicEndpoints(
+        this.dynamicSummaryEndpoints =
+          this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
             'change-view-file-list-summary'
           );
 
         if (
-          this._dynamicHeaderEndpoints.length !==
-          this._dynamicContentEndpoints.length
+          this.dynamicHeaderEndpoints.length !==
+          this.dynamicContentEndpoints.length
         ) {
-          this.reporting.error(new Error('dynamic header/content mismatch'));
+          this.reporting.error(
+            'Plugin change-view-file-list',
+            new Error('dynamic header/content mismatch')
+          );
         }
         if (
-          this._dynamicPrependedHeaderEndpoints.length !==
-          this._dynamicPrependedContentEndpoints.length
+          this.dynamicPrependedHeaderEndpoints.length !==
+          this.dynamicPrependedContentEndpoints.length
         ) {
-          this.reporting.error(new Error('dynamic header/content mismatch'));
+          this.reporting.error(
+            'Plugin change-view-file-list',
+            new Error('dynamic prepend header/content mismatch')
+          );
         }
         if (
-          this._dynamicHeaderEndpoints.length !==
-          this._dynamicSummaryEndpoints.length
+          this.dynamicHeaderEndpoints.length !==
+          this.dynamicSummaryEndpoints.length
         ) {
-          this.reporting.error(new Error('dynamic header/content mismatch'));
+          this.reporting.error(
+            'Plugin change-view-file-list',
+            new Error('dynamic header/summary mismatch')
+          );
         }
       });
-    this.cleanups.push(
-      addGlobalShortcut({key: Key.ESC}, _ => this._handleEscKey()),
-      addShortcut(this, {key: Key.ENTER}, _ => this.handleOpenFile(), {
-        shouldSuppress: true,
-      })
-    );
+    this.diffCursor = new GrDiffCursor();
+    this.diffCursor.replaceDiffs(this.diffs);
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
-    this.diffCursor.dispose();
+    this.diffCursor?.dispose();
     this.fileCursor.unsetCursor();
-    this._cancelDiffs();
-    this.loadingTask?.cancel();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
+    this.cancelDiffs();
     super.disconnectedCallback();
   }
 
-  reload() {
-    if (!this.changeNum || !this.patchRange?.patchNum) {
-      return Promise.resolve();
+  protected override async getUpdateComplete(): Promise<boolean> {
+    const result = await super.getUpdateComplete();
+    await Promise.all(this.diffs.map(d => d.updateComplete));
+    return result;
+  }
+
+  override render() {
+    this.classList.toggle('editMode', this.editMode);
+    const patchChange = this.calculatePatchChange();
+    return html`
+      <h3 class="assistive-tech-only">File list</h3>
+      ${this.renderContainer()} ${this.renderChangeTotals(patchChange)}
+      ${this.renderBinaryTotals(patchChange)} ${this.renderControlRow()}
+      <gr-diff-preferences-dialog
+        id="diffPreferencesDialog"
+        @reload-diff-preference=${this.handleReloadingDiffPreference}
+      >
+      </gr-diff-preferences-dialog>
+    `;
+  }
+
+  private renderContainer() {
+    return html`
+      <div
+        id="container"
+        @click=${(e: MouseEvent) => this.handleFileListClick(e)}
+        role="grid"
+        aria-label="Files list"
+      >
+        ${this.renderHeaderRow()} ${this.renderShownFiles()}
+        ${when(this.computeShowNumCleanlyMerged(), () =>
+          this.renderCleanlyMerged()
+        )}
+      </div>
+    `;
+  }
+
+  private renderHeaderRow() {
+    const showPrependedDynamicColumns =
+      this.computeShowPrependedDynamicColumns();
+    const showDynamicColumns = this.computeShowDynamicColumns();
+    return html` <div class="header-row row" role="row">
+      <!-- endpoint: change-view-file-list-header-prepend -->
+      ${when(showPrependedDynamicColumns, () =>
+        this.renderPrependedHeaderEndpoints()
+      )}
+      ${this.renderFileStatus()}
+      <div class="path" role="columnheader">File</div>
+      <div class="comments desktop" role="columnheader">Comments</div>
+      <div class="comments mobile" role="columnheader" title="Comments">C</div>
+      ${when(
+        this.showSizeBars,
+        () => html`<div class="sizeBars desktop" role="columnheader">Size</div>`
+      )}
+      <div class="header-stats" role="columnheader">Delta</div>
+      <!-- endpoint: change-view-file-list-header -->
+      ${when(showDynamicColumns, () => this.renderDynamicHeaderEndpoints())}
+      <!-- Empty div here exists to keep spacing in sync with file rows. -->
+      <div
+        class="reviewed hideOnEdit"
+        ?hidden=${!this.loggedIn}
+        aria-hidden="true"
+      ></div>
+      <div class="editFileControls showOnEdit" aria-hidden="true"></div>
+      <div class="show-hide" aria-hidden="true"></div>
+    </div>`;
+  }
+
+  private renderPrependedHeaderEndpoints() {
+    return this.dynamicPrependedHeaderEndpoints?.map(
+      headerEndpoint => html`
+        <gr-endpoint-decorator
+          class="prepended-col"
+          .name=${headerEndpoint}
+          role="columnheader"
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="files" .value=${this.files}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  }
+
+  private renderDynamicHeaderEndpoints() {
+    return this.dynamicHeaderEndpoints?.map(
+      headerEndpoint => html`
+        <gr-endpoint-decorator
+          class="extra-col"
+          .name=${headerEndpoint}
+          role="columnheader"
+        ></gr-endpoint-decorator>
+      `
+    );
+  }
+
+  // for DIFF_AUTOCLOSE logging purposes only
+  private shownFilesOld: NormalizedFileInfo[] = this.shownFiles;
+
+  private renderShownFiles() {
+    const showDynamicColumns = this.computeShowDynamicColumns();
+    const showPrependedDynamicColumns =
+      this.computeShowPrependedDynamicColumns();
+    const sizeBarLayout = this.computeSizeBarLayout();
+
+    // for DIFF_AUTOCLOSE logging purposes only
+    if (
+      this.shownFilesOld.length > 0 &&
+      this.shownFiles !== this.shownFilesOld
+    ) {
+      this.reporting.reportInteraction(
+        Interaction.DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED
+      );
     }
-    const changeNum = this.changeNum;
-    const patchRange = this.patchRange;
-
-    this._loading = true;
-
-    this.collapseAllDiffs();
-    const promises = [];
-
-    promises.push(
-      this.restApiService
-        .getChangeOrEditFiles(changeNum, patchRange)
-        .then(filesByPath => {
-          this._filesByPath = filesByPath;
-        })
-    );
-
-    promises.push(
-      this._getLoggedIn()
-        .then(loggedIn => (this._loggedIn = loggedIn))
-        .then(loggedIn => {
-          if (!loggedIn) {
-            return;
-          }
-
-          return this._getReviewedFiles(changeNum, patchRange).then(
-            reviewed => {
-              this._reviewed = reviewed;
-            }
-          );
-        })
-    );
-
-    return Promise.all(promises).then(() => {
-      this._loading = false;
-      this._detectChromiteButler();
-      this.reporting.fileListDisplayed();
+    this.shownFilesOld = this.shownFiles;
+    return incrementalRepeat({
+      values: this.shownFiles,
+      mapFn: (f, i) =>
+        this.renderFileRow(
+          f as NormalizedFileInfo,
+          i,
+          sizeBarLayout,
+          showDynamicColumns,
+          showPrependedDynamicColumns
+        ),
+      initialCount: this.fileListIncrement,
+      targetFrameRate: 1,
     });
   }
 
-  @observe('_filesByPath')
-  async _updateCleanlyMergedPaths(filesByPath?: FileNameToFileInfoMap) {
+  private renderFileRow(
+    file: NormalizedFileInfo,
+    index: number,
+    sizeBarLayout: SizeBarLayout,
+    showDynamicColumns: boolean,
+    showPrependedDynamicColumns: boolean
+  ) {
+    this.reportRenderedRow(index);
+    const previousFileName = this.shownFiles[index - 1]?.__path;
+    const patchSetFile = this.computePatchSetFile(file);
+    return html` <div class="stickyArea">
+      <div
+        class=${`file-row row ${this.computePathClass(file.__path)}`}
+        data-file=${JSON.stringify(patchSetFile)}
+        tabindex="-1"
+        role="row"
+        aria-label=${file.__path}
+      >
+        <!-- endpoint: change-view-file-list-content-prepend -->
+        ${when(showPrependedDynamicColumns, () =>
+          this.renderPrependedContentEndpointsForFile(file)
+        )}
+        ${this.renderFileStatus(file)}
+        ${this.renderFilePath(file, previousFileName)}
+        ${this.renderFileComments(file)}
+        ${this.renderSizeBar(file, sizeBarLayout)} ${this.renderFileStats(file)}
+        ${when(showDynamicColumns, () =>
+          this.renderDynamicContentEndpointsForFile(file)
+        )}
+        <!-- endpoint: change-view-file-list-content -->
+        ${this.renderReviewed(file)} ${this.renderFileControls(file)}
+        ${this.renderShowHide(file)}
+      </div>
+      ${when(
+        this.isFileExpanded(file.__path),
+        () => this.patched.html`
+          <gr-diff-host
+            ?noAutoRender=${true}
+            ?showLoadFailure=${true}
+            .displayLine=${this.displayLine}
+            .changeNum=${this.changeNum}
+            .change=${this.change}
+            .patchRange=${this.patchRange}
+            .file=${patchSetFile}
+            .path=${file.__path}
+            .projectName=${this.change?.project}
+            ?noRenderOnPrefsChange=${true}
+          ></gr-diff-host>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderPrependedContentEndpointsForFile(file: NormalizedFileInfo) {
+    return this.dynamicPrependedContentEndpoints?.map(
+      contentEndpoint => html`
+        <gr-endpoint-decorator
+          class="prepended-col"
+          .name=${contentEndpoint}
+          role="gridcell"
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="changeNum" .value=${this.changeNum}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="path" .value=${file.__path}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="oldPath" .value=${this.getOldPath(file)}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  }
+
+  private renderFileStatus(file?: NormalizedFileInfo) {
+    const hasExtendedStatus = this.filesLeftBase.length > 0;
+    const leftStatus = this.renderFileStatusLeft(file?.__path);
+    const rightStatus = this.renderFileStatusRight(file);
+    return html`<div
+      class=${classMap({status: true, extended: hasExtendedStatus})}
+      role="gridcell"
+    >
+      ${leftStatus}${rightStatus}
+    </div>`;
+  }
+
+  private renderDivWithTooltip(
+    content: TemplateResult | string,
+    tooltip: string,
+    cssClass = 'content'
+  ) {
+    return html`
+      <gr-tooltip-content title=${tooltip} has-tooltip>
+        <div class=${cssClass}>${content}</div>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderFileStatusRight(file?: NormalizedFileInfo) {
+    const hasExtendedStatus = this.filesLeftBase.length > 0;
+    // no file means "header row"
+    if (!file) {
+      const psNum = this.patchRange?.patchNum;
+      return hasExtendedStatus
+        ? this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)
+        : nothing;
+    }
+    if (isMagicPath(file.__path)) return nothing;
+
+    const fileWasAlreadyChanged = this.filesLeftBase.some(
+      info => info.__path === file?.__path
+    );
+    const fileIsReverted =
+      fileWasAlreadyChanged &&
+      !this.filesRightBase.some(info => info.__path === file?.__path);
+    const newlyChanged = hasExtendedStatus && !fileWasAlreadyChanged;
+
+    const status = fileIsReverted
+      ? FileInfoStatus.REVERTED
+      : file?.status ?? FileInfoStatus.MODIFIED;
+    const left = `patchset ${this.patchRange?.basePatchNum}`;
+    const right = `patchset ${this.patchRange?.patchNum}`;
+    const postfix = ` between ${left} and ${right}`;
+
+    return html`<gr-file-status
+      .status=${status}
+      .labelPostfix=${postfix}
+      ?newlyChanged=${newlyChanged}
+    ></gr-file-status>`;
+  }
+
+  private renderFileStatusLeft(path?: string) {
+    if (this.filesLeftBase.length === 0) return nothing;
+    const arrow = html`
+      <gr-icon
+        icon="arrow_right_alt"
+        class="file-status-arrow"
+        aria-label="then"
+      ></gr-icon>
+    `;
+    // no path means "header row"
+    const psNum = this.patchRange?.basePatchNum;
+    if (!path) {
+      return html`
+        ${this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)} ${arrow}
+      `;
+    }
+    if (isMagicPath(path)) return nothing;
+    const file = this.filesLeftBase.find(info => info.__path === path);
+    if (!file) return nothing;
+
+    const status = file.status ?? FileInfoStatus.MODIFIED;
+    const left = 'base';
+    const right = `patchset ${this.patchRange?.basePatchNum}`;
+    const postfix = ` between ${left} and ${right}`;
+
+    return html`
+      <gr-file-status
+        .status=${status}
+        .labelPostfix=${postfix}
+      ></gr-file-status>
+      ${arrow}
+    `;
+  }
+
+  private renderFilePath(file: NormalizedFileInfo, previousFilePath?: string) {
+    return html`
+      <span class="path" role="gridcell">
+        <a class="pathLink" href=${ifDefined(this.computeDiffURL(file.__path))}>
+          <span title=${computeDisplayPath(file.__path)} class="fullFileName">
+            ${this.renderStyledPath(file.__path, previousFilePath)}
+          </span>
+          <span
+            title=${computeDisplayPath(file.__path)}
+            class="truncatedFileName"
+          >
+            ${computeTruncatedPath(file.__path)}
+          </span>
+          ${this.renderFileMode(file)}
+          <gr-copy-clipboard
+            ?hideInput=${true}
+            .text=${file.__path}
+          ></gr-copy-clipboard>
+        </a>
+        ${when(
+          file.old_path,
+          () => html`
+            <div class="oldPath" title=${ifDefined(file.old_path)}>
+              ${file.old_path}
+              <gr-copy-clipboard
+                ?hideInput=${true}
+                .text=${file.old_path}
+              ></gr-copy-clipboard>
+            </div>
+          `
+        )}
+      </span>
+    `;
+  }
+
+  private renderFileMode(file: NormalizedFileInfo) {
+    const {old_mode, new_mode} = file;
+
+    // For added, modified or deleted regular files we do not want to render
+    // anything. Only if a file changed from something else to regular, then let
+    // the user know.
+    if (new_mode === undefined) return nothing;
+    let newModeStr = fileModeToString(new_mode, false);
+    if (new_mode === FileMode.REGULAR_FILE) {
+      if (old_mode === undefined) return nothing;
+      if (old_mode === FileMode.REGULAR_FILE) return nothing;
+      newModeStr = `non-${fileModeToString(old_mode, false)}`;
+    }
+
+    const changed = old_mode !== undefined && old_mode !== new_mode;
+    const icon = changed
+      ? html`<gr-icon icon="warning" class="file-mode-warning"></gr-icon> `
+      : '';
+    const action = changed
+      ? `changed from ${fileModeToString(old_mode)} to`
+      : 'is';
+    return this.renderDivWithTooltip(
+      html`${icon}(${newModeStr})`,
+      `file mode ${action} ${fileModeToString(new_mode)}`,
+      'file-mode-content'
+    );
+  }
+
+  private renderStyledPath(filePath: string, previousFilePath?: string) {
+    const {matchingFolders, newFolders, fileName} = diffFilePaths(
+      filePath,
+      previousFilePath
+    );
+    return [
+      matchingFolders.length > 0
+        ? html`<span class="matchingFilePath">${matchingFolders}</span>`
+        : nothing,
+      newFolders.length > 0
+        ? html`<span class="newFilePath">${newFolders}</span>`
+        : nothing,
+      html`<span class="fileName">${fileName}</span>`,
+    ];
+  }
+
+  private renderFileComments(file: NormalizedFileInfo) {
+    return html` <div role="gridcell">
+      <div class="comments desktop">
+        <span class="drafts">${this.computeDraftsString(file)}</span>
+        <span>${this.computeCommentsString(file)}</span>
+        <span class="noCommentsScreenReaderText">
+          <!-- Screen readers read the following content only if 2 other
+          spans in the parent div is empty. The content is not visible on
+          the page.
+          Without this span, screen readers don't navigate correctly inside
+          table, because empty div doesn't rendered. For example, VoiceOver
+          jumps back to the whole table.
+          We can use &nbsp instead, but it sounds worse.
+          -->
+          No comments
+        </span>
+      </div>
+      <div class="comments mobile">
+        <span class="drafts">${this.computeDraftsStringMobile(file)}</span>
+        <span>${this.computeCommentsStringMobile(file)}</span>
+        <span class="noCommentsScreenReaderText">
+          <!-- The same as for desktop comments -->
+          No comments
+        </span>
+      </div>
+    </div>`;
+  }
+
+  private renderSizeBar(
+    file: NormalizedFileInfo,
+    sizeBarLayout: SizeBarLayout
+  ) {
+    return html` <div class="desktop" role="gridcell">
+      <!-- The content must be in a separate div. It guarantees, that
+          gridcell always visible for screen readers.
+          For example, without a nested div screen readers pronounce the
+          "Commit message" row content with incorrect column headers.
+        -->
+      <div class=${this.computeSizeBarsClass(file.__path)} aria-hidden="true">
+        <svg width="61" height="8">
+          <rect
+            x=${this.computeBarAdditionX(file, sizeBarLayout)}
+            y="0"
+            height="8"
+            fill="var(--positive-green-text-color)"
+            width=${this.computeBarAdditionWidth(file, sizeBarLayout)}
+          ></rect>
+          <rect
+            x=${this.computeBarDeletionX(sizeBarLayout)}
+            y="0"
+            height="8"
+            fill="var(--negative-red-text-color)"
+            width=${this.computeBarDeletionWidth(file, sizeBarLayout)}
+          ></rect>
+        </svg>
+      </div>
+    </div>`;
+  }
+
+  private renderFileStats(file: NormalizedFileInfo) {
+    return html` <div class="stats" role="gridcell">
+      <!-- The content must be in a separate div. It guarantees, that
+        gridcell always visible for screen readers.
+        For example, without a nested div screen readers pronounce the
+        "Commit message" row content with incorrect column headers.
+        -->
+      <div class=${this.computeClass('', file.__path)}>
+        <span
+          class="added"
+          tabindex="0"
+          aria-label=${`${file.lines_inserted} added`}
+          ?hidden=${file.binary}
+        >
+          +${file.lines_inserted}
+        </span>
+        <span
+          class="removed"
+          tabindex="0"
+          aria-label=${`${file.lines_deleted} removed`}
+          ?hidden=${file.binary}
+        >
+          -${file.lines_deleted}
+        </span>
+        <span
+          class=${ifDefined(this.computeBinaryClass(file.size_delta))}
+          ?hidden=${!file.binary}
+        >
+          ${this.formatBytes(file.size_delta)}
+          ${this.formatPercentage(file.size, file.size_delta)}
+        </span>
+      </div>
+    </div>`;
+  }
+
+  private renderDynamicContentEndpointsForFile(file: NormalizedFileInfo) {
+    return this.dynamicContentEndpoints?.map(
+      contentEndpoint => html` <div
+        class=${this.computeClass('', file.__path)}
+        role="gridcell"
+      >
+        <gr-endpoint-decorator class="extra-col" .name=${contentEndpoint}>
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="changeNum" .value=${this.changeNum}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="path" .value=${file.__path}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>`
+    );
+  }
+
+  private renderReviewed(file: NormalizedFileInfo) {
+    if (!this.loggedIn) return nothing;
+    const isReviewed = this.reviewed.includes(file.__path);
+    const reviewedTitle = `Mark as ${
+      isReviewed ? 'not ' : ''
+    }reviewed (shortcut: r)`;
+    const reviewedText = isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+    return html` <div class="reviewed hideOnEdit" role="gridcell">
+      <span
+        class=${`reviewedLabel ${isReviewed ? 'isReviewed' : ''}`}
+        aria-hidden=${this.booleanToString(!isReviewed)}
+        >Reviewed</span
+      >
+      <!-- Do not use input type="checkbox" with hidden input and
+              visible label here. Screen readers don't read/interract
+              correctly with such input.
+          -->
+      <span
+        class="reviewedSwitch"
+        role="switch"
+        tabindex="0"
+        @click=${(e: MouseEvent) => this.reviewedClick(e)}
+        @keydown=${(e: KeyboardEvent) => this.reviewedClick(e)}
+        aria-label="Reviewed"
+        aria-checked=${this.booleanToString(isReviewed)}
+      >
+        <!-- Trick with tabindex to avoid outline on mouse focus, but
+            preserve focus outline for keyboard navigation -->
+        <span tabindex="-1" class="markReviewed" title=${reviewedTitle}
+          >${reviewedText}</span
+        >
+      </span>
+    </div>`;
+  }
+
+  private renderFileControls(file: NormalizedFileInfo) {
+    return html` <div
+      class="editFileControls showOnEdit"
+      role="gridcell"
+      aria-hidden=${this.booleanToString(!this.editMode)}
+    >
+      ${when(
+        this.editMode,
+        () => html`
+          <gr-edit-file-controls
+            class=${this.computeClass('', file.__path)}
+            .filePath=${file.__path}
+          ></gr-edit-file-controls>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderShowHide(file: NormalizedFileInfo) {
+    const expanded = this.isFileExpanded(file.__path);
+    return html` <div class="show-hide" role="gridcell">
+      <!-- Do not use input type="checkbox" with hidden input and
+            visible label here. Screen readers don't read/interract
+            correctly with such input.
+        -->
+      <span
+        class="show-hide"
+        data-path=${file.__path}
+        data-expand="true"
+        role="switch"
+        tabindex="0"
+        aria-checked=${this.isFileExpandedStr(file.__path)}
+        aria-label=${expanded ? 'collapse' : 'expand'}
+        aria-description=${expanded
+          ? 'Collapse diff of this file'
+          : 'Expand diff of this file'}
+        @click=${this.expandedClick}
+        @keydown=${this.expandedClick}
+      >
+        <!-- Trick with tabindex to avoid outline on mouse focus, but
+          preserve focus outline for keyboard navigation -->
+        <gr-icon
+          class="show-hide-icon"
+          tabindex="-1"
+          id="icon"
+          icon=${expanded ? 'expand_less' : 'expand_more'}
+        ></gr-icon>
+      </span>
+    </div>`;
+  }
+
+  private renderCleanlyMerged() {
+    const showPrependedDynamicColumns =
+      this.computeShowPrependedDynamicColumns();
+    return html` <div class="row">
+      <!-- endpoint: change-view-file-list-content-prepend -->
+      ${when(showPrependedDynamicColumns, () =>
+        this.renderPrependedContentEndpoints()
+      )}
+      <div role="gridcell">
+        <div>
+          <span class="cleanlyMergedText">
+            ${this.computeCleanlyMergedText()}
+          </span>
+          <gr-button
+            link
+            class="showParentButton"
+            @click=${this.handleShowParent1}
+          >
+            Show Parent 1
+          </gr-button>
+        </div>
+      </div>
+    </div>`;
+  }
+
+  private renderPrependedContentEndpoints() {
+    return this.dynamicPrependedContentEndpoints?.map(
+      contentEndpoint => html`
+        <gr-endpoint-decorator
+          class="prepended-col"
+          .name=${contentEndpoint}
+          role="gridcell"
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="changeNum" .value=${this.changeNum}>
+          </gr-endpoint-param>
+          <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+          </gr-endpoint-param>
+          <gr-endpoint-param
+            name="cleanlyMergedPaths"
+            .value=${this.cleanlyMergedPaths}
+          >
+          </gr-endpoint-param>
+          <gr-endpoint-param
+            name="cleanlyMergedOldPaths"
+            .value=${this.cleanlyMergedOldPaths}
+          >
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  }
+
+  private renderChangeTotals(patchChange: PatchChange) {
+    const showDynamicColumns = this.computeShowDynamicColumns();
+    if (this.shouldHideChangeTotals(patchChange)) return nothing;
+    return html`
+      <div class="row totalChanges">
+        <div class="total-stats">
+          <div>
+            <span
+              class="added"
+              tabindex="0"
+              aria-label="Total ${patchChange.inserted} lines added"
+            >
+              +${patchChange.inserted}
+            </span>
+            <span
+              class="removed"
+              tabindex="0"
+              aria-label="Total ${patchChange.deleted} lines removed"
+            >
+              -${patchChange.deleted}
+            </span>
+          </div>
+        </div>
+        ${when(showDynamicColumns, () =>
+          this.dynamicSummaryEndpoints?.map(
+            summaryEndpoint => html`
+              <gr-endpoint-decorator class="extra-col" name=${summaryEndpoint}>
+                <gr-endpoint-param name="change" .value=${this.change}>
+                </gr-endpoint-param>
+                <gr-endpoint-param name="patchRange" .value=${this.patchRange}>
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            `
+          )
+        )}
+
+        <!-- Empty div here exists to keep spacing in sync with file rows. -->
+        <div class="reviewed hideOnEdit" ?hidden=${!this.loggedIn}></div>
+        <div class="editFileControls showOnEdit"></div>
+        <div class="show-hide"></div>
+      </div>
+    `;
+  }
+
+  private renderBinaryTotals(patchChange: PatchChange) {
+    if (this.shouldHideBinaryChangeTotals(patchChange)) return nothing;
+    const deltaInserted = this.formatBytes(patchChange.size_delta_inserted);
+    const deltaDeleted = this.formatBytes(patchChange.size_delta_deleted);
+    return html`
+      <div class="row totalChanges">
+        <div class="total-stats">
+          <span
+            class="added"
+            aria-label="Total bytes inserted: ${deltaInserted}"
+          >
+            ${deltaInserted}
+            ${this.formatPercentage(
+              patchChange.total_size,
+              patchChange.size_delta_inserted
+            )}
+          </span>
+          <span
+            class="removed"
+            aria-label="Total bytes removed: ${deltaDeleted}"
+          >
+            ${deltaDeleted}
+            ${this.formatPercentage(
+              patchChange.total_size,
+              patchChange.size_delta_deleted
+            )}
+          </span>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderControlRow() {
+    return html`<div
+      class=${`row controlRow ${this.computeFileListControlClass()}`}
+    >
+      <gr-button
+        class="fileListButton"
+        id="incrementButton"
+        link=""
+        @click=${this.incrementNumFilesShown}
+      >
+        ${this.computeIncrementText()}
+      </gr-button>
+      <gr-tooltip-content
+        ?has-tooltip=${this.computeWarnShowAll()}
+        ?show-icon=${this.computeWarnShowAll()}
+        .title=${this.computeShowAllWarning()}
+      >
+        <gr-button
+          class="fileListButton"
+          id="showAllButton"
+          link=""
+          @click=${this.showAllFiles}
+        >
+          ${this.computeShowAllText()}
+        </gr-button>
+      </gr-tooltip-content>
+    </div>`;
+  }
+
+  protected override firstUpdated(): void {
+    this.detectChromiteButler();
+    this.reporting.fileListDisplayed();
+  }
+
+  protected override updated(): void {
+    // for DIFF_AUTOCLOSE logging purposes only
+    const ids = this.diffs.map(d => d.uid);
+    if (ids.length > 0) {
+      this.reporting.reportInteraction(
+        Interaction.DIFF_AUTOCLOSE_FILE_LIST_UPDATED,
+        {l: ids.length, ids: ids.slice(0, 10)}
+      );
+    }
+  }
+
+  // TODO: Move into files-model.
+  // visible for testing
+  async updateCleanlyMergedPaths() {
     // When viewing Auto Merge base vs a patchset, add an additional row that
     // knows how many files were cleanly merged. This requires an additional RPC
     // for the diffs between target parent and the patch set. The cleanly merged
@@ -512,8 +1674,8 @@
       this.changeNum &&
       this.patchRange?.patchNum &&
       new RevisionInfo(this.change).isMergeCommit(this.patchRange.patchNum) &&
-      this.patchRange.basePatchNum === 'PARENT' &&
-      this.patchRange.patchNum !== EditPatchSetNum
+      this.patchRange.basePatchNum === PARENT &&
+      this.patchRange.patchNum !== EDIT
     ) {
       const allFilesByPath = await this.restApiService.getChangeOrEditFiles(
         this.changeNum,
@@ -522,21 +1684,21 @@
           patchNum: this.patchRange.patchNum,
         }
       );
-      if (!allFilesByPath || !filesByPath) return;
-      const conflictingPaths = Object.keys(filesByPath);
-      this._cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
+      if (!allFilesByPath) return;
+      const conflictingPaths = this.files.map(f => f.__path);
+      this.cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
         path => !conflictingPaths.includes(path)
       );
-      this._cleanlyMergedOldPaths = this._cleanlyMergedPaths
+      this.cleanlyMergedOldPaths = this.cleanlyMergedPaths
         .map(path => allFilesByPath[path].old_path)
         .filter((oldPath): oldPath is string => !!oldPath);
     } else {
-      this._cleanlyMergedPaths = [];
-      this._cleanlyMergedOldPaths = [];
+      this.cleanlyMergedPaths = [];
+      this.cleanlyMergedOldPaths = [];
     }
   }
 
-  _detectChromiteButler() {
+  private detectChromiteButler() {
     const hasButler = !!document.getElementById('butler-suggested-owners');
     if (hasButler) {
       this.reporting.reportExtension('butler');
@@ -544,7 +1706,7 @@
   }
 
   get diffs(): GrDiffHost[] {
-    const diffs = this.root!.querySelectorAll('gr-diff-host');
+    const diffs = this.shadowRoot!.querySelectorAll('gr-diff-host');
     // It is possible that a bogus diff element is hanging around invisibly
     // from earlier with a different patch set choice and associated with a
     // different entry in the files array. So filter on visible items only.
@@ -553,13 +1715,20 @@
     );
   }
 
-  openDiffPrefs() {
-    this.$.diffPreferencesDialog.open();
+  resetFileState() {
+    this.numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+    this.selectedIndex = 0;
+    this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
   }
 
-  _calculatePatchChange(files: NormalizedFileInfo[]): PatchChange {
-    const magicFilesExcluded = files.filter(
-      files => !isMagicPath(files.__path)
+  openDiffPrefs() {
+    this.diffPreferencesDialog?.open();
+  }
+
+  // Private but used in tests.
+  calculatePatchChange(): PatchChange {
+    const magicFilesExcluded = this.files.filter(
+      file => !isMagicPath(file.__path)
     );
 
     return magicFilesExcluded.reduce((acc, obj) => {
@@ -567,9 +1736,9 @@
       const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
       const total_size = obj.size && obj.binary ? obj.size : 0;
       const size_delta_inserted =
-        obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+        obj.binary && obj.size_delta && obj.size_delta > 0 ? obj.size_delta : 0;
       const size_delta_deleted =
-        obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
+        obj.binary && obj.size_delta && obj.size_delta < 0 ? obj.size_delta : 0;
 
       return {
         inserted: acc.inserted + inserted,
@@ -581,49 +1750,44 @@
     }, createDefaultPatchChange());
   }
 
-  _getDiffPreferences() {
-    return this.restApiService.getDiffPreferences();
-  }
-
-  _getPreferences() {
-    return this.restApiService.getPreferences();
-  }
-
-  private _toggleFileExpanded(file: PatchSetFile) {
+  // private but used in test
+  toggleFileExpanded(file: PatchSetFile) {
     // Is the path in the list of expanded diffs? If so, remove it, otherwise
     // add it to the list.
-    const indexInExpanded = this._expandedFiles.findIndex(
+    const indexInExpanded = this.expandedFiles.findIndex(
       f => f.path === file.path
     );
     if (indexInExpanded === -1) {
-      this.push('_expandedFiles', file);
+      this.expandedFiles = this.expandedFiles.concat([file]);
     } else {
-      this.splice('_expandedFiles', indexInExpanded, 1);
+      this.expandedFiles = this.expandedFiles.filter(
+        (_val, idx) => idx !== indexInExpanded
+      );
     }
-    const indexInAll = this._files.findIndex(f => f.__path === file.path);
-    this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)[
+    const indexInAll = this.files.findIndex(f => f.__path === file.path);
+    this.shadowRoot!.querySelectorAll(`.${FILE_ROW_CLASS}`)[
       indexInAll
     ].scrollIntoView({block: 'nearest'});
   }
 
-  _toggleFileExpandedByIndex(index: number) {
-    this._toggleFileExpanded(this._computePatchSetFile(this._files[index]));
+  private toggleFileExpandedByIndex(index: number) {
+    this.toggleFileExpanded(this.computePatchSetFile(this.files[index]));
   }
 
-  _updateDiffPreferences() {
+  // Private but used in tests.
+  updateDiffPreferences() {
     if (!this.diffs.length) {
       return;
     }
-    // Re-render all expanded diffs sequentially.
-    this.reporting.time(Timing.FILE_EXPAND_ALL);
-    this._renderInOrder(
-      this._expandedFiles,
-      this.diffs,
-      this._expandedFiles.length
+    this.reporting.reportInteraction(
+      Interaction.DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS
     );
+
+    // Re-render all expanded diffs sequentially.
+    this.renderInOrder(this.expandedFiles, this.diffs);
   }
 
-  _forEachDiff(fn: (host: GrDiffHost) => void) {
+  private forEachDiff(fn: (host: GrDiffHost) => void) {
     const diffs = this.diffs;
     for (let i = 0; i < diffs.length; i++) {
       fn(diffs[i]);
@@ -635,54 +1799,45 @@
     // expanded list.
     const newFiles: PatchSetFile[] = [];
     let path: string;
-    for (let i = 0; i < this._shownFiles.length; i++) {
-      path = this._shownFiles[i].__path;
-      if (!this._expandedFiles.some(f => f.path === path)) {
-        newFiles.push(this._computePatchSetFile(this._shownFiles[i]));
+    for (let i = 0; i < this.shownFiles.length; i++) {
+      path = this.shownFiles[i].__path;
+      if (!this.expandedFiles.some(f => f.path === path)) {
+        newFiles.push(this.computePatchSetFile(this.shownFiles[i]));
       }
     }
 
-    this.splice('_expandedFiles', 0, 0, ...newFiles);
+    this.expandedFiles = newFiles.concat(this.expandedFiles);
   }
 
   collapseAllDiffs() {
-    this._expandedFiles = [];
-    this.filesExpanded = this._computeExpandedFiles(
-      this._expandedFiles.length,
-      this._files.length
-    );
-    this.diffCursor.handleDiffUpdate();
+    this.expandedFiles = [];
   }
 
   /**
    * Computes a string with the number of comments and unresolved comments.
    */
-  _computeCommentsString(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
+  computeCommentsString(file?: NormalizedFileInfo) {
     if (
-      changeComments === undefined ||
-      patchRange === undefined ||
+      this.changeComments === undefined ||
+      this.patchRange === undefined ||
       file?.__path === undefined
     ) {
       return '';
     }
-    return changeComments.computeCommentsString(patchRange, file.__path, file);
+    return this.changeComments.computeCommentsString(
+      this.patchRange,
+      file.__path,
+      file
+    );
   }
 
   /**
    * Computes a string with the number of drafts.
    */
-  _computeDraftsString(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
-    if (changeComments === undefined) return '';
-    const draftCount = changeComments.computeDraftCountForFile(
-      patchRange,
+  computeDraftsString(file?: NormalizedFileInfo) {
+    if (this.changeComments === undefined) return '';
+    const draftCount = this.changeComments.computeDraftCountForFile(
+      this.patchRange,
       file
     );
     if (draftCount === 0) return '';
@@ -691,15 +1846,12 @@
 
   /**
    * Computes a shortened string with the number of drafts.
+   * Private but used in tests.
    */
-  _computeDraftsStringMobile(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
-    if (changeComments === undefined) return '';
-    const draftCount = changeComments.computeDraftCountForFile(
-      patchRange,
+  computeDraftsStringMobile(file?: NormalizedFileInfo) {
+    if (this.changeComments === undefined) return '';
+    const draftCount = this.changeComments.computeDraftCountForFile(
+      this.patchRange,
       file
     );
     return draftCount === 0 ? '' : `${draftCount}d`;
@@ -708,51 +1860,38 @@
   /**
    * Computes a shortened string with the number of comments.
    */
-  _computeCommentsStringMobile(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    file?: NormalizedFileInfo
-  ) {
+  computeCommentsStringMobile(file?: NormalizedFileInfo) {
     if (
-      changeComments === undefined ||
-      patchRange === undefined ||
+      this.changeComments === undefined ||
+      this.patchRange === undefined ||
       file === undefined
     ) {
       return '';
     }
     const commentThreadCount =
-      changeComments.computeCommentThreadCount({
-        patchNum: patchRange.basePatchNum,
+      this.changeComments.computeCommentThreadCount({
+        patchNum: this.patchRange.basePatchNum,
         path: file.__path,
       }) +
-      changeComments.computeCommentThreadCount({
-        patchNum: patchRange.patchNum,
+      this.changeComments.computeCommentThreadCount({
+        patchNum: this.patchRange.patchNum,
         path: file.__path,
       });
     return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
   }
 
-  private _reviewFile(path: string, reviewed?: boolean) {
-    if (this.editMode) {
-      return Promise.resolve();
-    }
-    const index = this._files.findIndex(file => file.__path === path);
-    reviewed = reviewed || !this._files[index].isReviewed;
-
-    this.set(['_files', index, 'isReviewed'], reviewed);
-    if (index < this._shownFiles.length) {
-      this.notifyPath(`_shownFiles.${index}.isReviewed`);
-    }
-
+  // Private but used in tests.
+  reviewFile(path: string, reviewed?: boolean) {
+    if (this.editMode) return Promise.resolve();
+    reviewed = reviewed ?? !this.reviewed.includes(path);
     return this._saveReviewedState(path, reviewed);
   }
 
   _saveReviewedState(path: string, reviewed: boolean) {
-    if (!this.changeNum || !this.patchRange) {
-      throw new Error('changeNum and patchRange must be set');
-    }
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.patchRange, 'patchRange');
 
-    return this.restApiService.saveFileReviewed(
+    return this.getChangeModel().setReviewedFilesStatus(
       this.changeNum,
       this.patchRange.patchNum,
       path,
@@ -760,41 +1899,13 @@
     );
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
-    if (this.editMode) {
-      return Promise.resolve([]);
-    }
-    return this.restApiService.getReviewedFiles(changeNum, patchRange.patchNum);
-  }
-
-  _normalizeChangeFilesResponse(
-    response: FileNameToReviewedFileInfoMap
-  ): NormalizedFileInfo[] {
-    const paths = Object.keys(response).sort(specialFilePathCompare);
-    const files: NormalizedFileInfo[] = [];
-    for (let i = 0; i < paths.length; i++) {
-      // TODO(TS): make copy instead of as NormalizedFileInfo
-      const info = response[paths[i]] as NormalizedFileInfo;
-      info.__path = paths[i];
-      info.lines_inserted = info.lines_inserted || 0;
-      info.lines_deleted = info.lines_deleted || 0;
-      info.size_delta = info.size_delta || 0;
-      files.push(info);
-    }
-    return files;
-  }
-
   /**
    * Returns true if the event e is a click on an element.
    *
    * The click is: mouse click or pressing Enter or Space key
    * P.S> Screen readers sends click event as well
    */
-  _isClickEvent(e: MouseEvent | KeyboardEvent) {
+  private isClickEvent(e: MouseEvent | KeyboardEvent) {
     if (e.type === 'click') {
       return true;
     }
@@ -803,41 +1914,43 @@
     return ke.type === 'keydown' && isSpaceOrEnter;
   }
 
-  _fileActionClick(
+  private fileActionClick(
     e: MouseEvent | KeyboardEvent,
     fileAction: (file: PatchSetFile) => void
   ) {
-    if (this._isClickEvent(e)) {
-      const fileRow = this._getFileRowFromEvent(e);
+    if (this.isClickEvent(e)) {
+      const fileRow = this.getFileRowFromEvent(e);
       if (!fileRow) {
         return;
       }
       // Prevent default actions (e.g. scrolling for space key)
       e.preventDefault();
-      // Prevent _handleFileListClick handler call
+      // Prevent handleFileListClick handler call
       e.stopPropagation();
       this.fileCursor.setCursor(fileRow.element);
       fileAction(fileRow.file);
     }
   }
 
-  _reviewedClick(e: MouseEvent | KeyboardEvent) {
-    this._fileActionClick(e, file => this._reviewFile(file.path));
+  // Private but used in tests.
+  reviewedClick(e: MouseEvent | KeyboardEvent) {
+    this.fileActionClick(e, file => this.reviewFile(file.path));
   }
 
-  _expandedClick(e: MouseEvent | KeyboardEvent) {
-    this._fileActionClick(e, file => this._toggleFileExpanded(file));
+  private expandedClick(e: MouseEvent | KeyboardEvent) {
+    this.fileActionClick(e, file => this.toggleFileExpanded(file));
   }
 
   /**
    * Handle all events from the file list dom-repeat so event handlers don't
    * have to get registered for potentially very long lists.
+   * Private but used in tests.
    */
-  _handleFileListClick(e: MouseEvent) {
+  handleFileListClick(e: MouseEvent) {
     if (!e.target) {
       return;
     }
-    const fileRow = this._getFileRowFromEvent(e);
+    const fileRow = this.getFileRowFromEvent(e);
     if (!fileRow) {
       return;
     }
@@ -857,10 +1970,10 @@
 
     e.preventDefault();
     this.fileCursor.setCursor(fileRow.element);
-    this._toggleFileExpanded(file);
+    this.toggleFileExpanded(file);
   }
 
-  _getFileRowFromEvent(e: Event): FileRow | null {
+  private getFileRowFromEvent(e: Event): FileRow | null {
     // Traverse upwards to find the row element if the target is not the row.
     let row = e.target as HTMLElement;
     while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
@@ -881,7 +1994,7 @@
   /**
    * Generates file range from file info object.
    */
-  _computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
+  private computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
     const fileData: PatchSetFile = {
       path: file.__path,
     };
@@ -891,90 +2004,109 @@
     return fileData;
   }
 
-  _handleLeftPane() {
-    if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveLeft();
+  private handleLeftPane() {
+    if (this.noDiffsExpanded()) return;
+    this.diffCursor?.moveLeft();
   }
 
-  _handleRightPane() {
-    if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveRight();
+  private handleRightPane() {
+    if (this.noDiffsExpanded()) return;
+    this.diffCursor?.moveRight();
   }
 
-  _handleToggleInlineDiff() {
+  private handleToggleInlineDiff() {
     if (this.fileCursor.index === -1) return;
-    this._toggleFileExpandedByIndex(this.fileCursor.index);
+    this.toggleFileExpandedByIndex(this.fileCursor.index);
   }
 
-  _handleCursorNext(e: KeyboardEvent) {
+  // Private but used in tests.
+  handleCursorNext(e: KeyboardEvent) {
+    // We want to allow users to use arrow keys for standard browser scrolling
+    // when files are not expanded. That is also why we use the `preventDefault`
+    // option when registering the shortcut.
+    if (this.filesExpanded !== FilesExpandedState.ALL && e.key === Key.DOWN) {
+      return;
+    }
+
+    e.preventDefault();
+    e.stopPropagation();
     if (this.filesExpanded === FilesExpandedState.ALL) {
-      this.diffCursor.moveDown();
-      this._displayLine = true;
+      this.diffCursor?.moveDown();
+      this.displayLine = true;
     } else {
-      if (e.key === Key.DOWN) return;
       this.fileCursor.next({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
 
-  _handleCursorPrev(e: KeyboardEvent) {
+  // Private but used in tests.
+  handleCursorPrev(e: KeyboardEvent) {
+    // We want to allow users to use arrow keys for standard browser scrolling
+    // when files are not expanded. That is also why we use the `preventDefault`
+    // option when registering the shortcut.
+    if (this.filesExpanded !== FilesExpandedState.ALL && e.key === Key.UP) {
+      return;
+    }
+
+    e.preventDefault();
+    e.stopPropagation();
     if (this.filesExpanded === FilesExpandedState.ALL) {
-      this.diffCursor.moveUp();
-      this._displayLine = true;
+      this.diffCursor?.moveUp();
+      this.displayLine = true;
     } else {
-      if (e.key === Key.UP) return;
       this.fileCursor.previous({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
 
-  _handleNewComment() {
+  private handleNewComment() {
     this.classList.remove('hideComments');
-    this.diffCursor.createCommentInPlace();
+    this.diffCursor?.createCommentInPlace();
   }
 
+  // Private but used in tests.
   handleOpenFile() {
     if (this.filesExpanded === FilesExpandedState.ALL) {
-      this._openCursorFile();
+      this.openCursorFile();
       return;
     }
-    this._openSelectedFile();
+    this.openSelectedFile();
   }
 
-  _handleNextChunk() {
-    if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveToNextChunk();
+  private handleNextChunk() {
+    if (this.noDiffsExpanded()) return;
+    this.diffCursor?.moveToNextChunk();
   }
 
-  _handleNextComment() {
-    if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveToNextCommentThread();
+  private handleNextComment() {
+    if (this.noDiffsExpanded()) return;
+    this.diffCursor?.moveToNextCommentThread();
   }
 
-  _handlePrevChunk() {
-    if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveToPreviousChunk();
+  private handlePrevChunk() {
+    if (this.noDiffsExpanded()) return;
+    this.diffCursor?.moveToPreviousChunk();
   }
 
-  _handlePrevComment() {
-    if (this._noDiffsExpanded()) return;
-    this.diffCursor.moveToPreviousCommentThread();
+  private handlePrevComment() {
+    if (this.noDiffsExpanded()) return;
+    this.diffCursor?.moveToPreviousCommentThread();
   }
 
-  _handleToggleFileReviewed() {
-    if (!this._files[this.fileCursor.index]) {
+  private handleToggleFileReviewed() {
+    if (!this.files[this.fileCursor.index]) {
       return;
     }
-    this._reviewFile(this._files[this.fileCursor.index].__path);
+    this.reviewFile(this.files[this.fileCursor.index].__path);
   }
 
-  _handleToggleLeftPane() {
-    this._forEachDiff(diff => {
+  private handleToggleLeftPane() {
+    this.forEachDiff(diff => {
       diff.toggleLeftDiff();
     });
   }
 
-  _toggleInlineDiffs() {
+  private toggleInlineDiffs() {
     if (this.filesExpanded === FilesExpandedState.ALL) {
       this.collapseAllDiffs();
     } else {
@@ -982,75 +2114,85 @@
     }
   }
 
-  _openCursorFile() {
-    const diff = this.diffCursor.getTargetDiffElement();
+  // Private but used in tests.
+  openCursorFile() {
+    const diff = this.diffCursor?.getTargetDiffElement();
     if (!this.change || !diff || !this.patchRange || !diff.path) {
       throw new Error('change, diff and patchRange must be all set and valid');
     }
-    GerritNav.navigateToDiff(
-      this.change,
-      diff.path,
-      this.patchRange.patchNum,
-      this.patchRange.basePatchNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        patchNum: this.patchRange.patchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+        diffView: {path: diff.path},
+      })
     );
   }
 
-  _openSelectedFile(index?: number) {
+  // Private but used in tests.
+  openSelectedFile(index?: number) {
     if (index !== undefined) {
       this.fileCursor.setCursorAtIndex(index);
     }
-    if (!this._files[this.fileCursor.index]) {
+    if (!this.files[this.fileCursor.index]) {
       return;
     }
     if (!this.change || !this.patchRange) {
       throw new Error('change and patchRange must be set');
     }
-    GerritNav.navigateToDiff(
-      this.change,
-      this._files[this.fileCursor.index].__path,
-      this.patchRange.patchNum,
-      this.patchRange.basePatchNum
+    this.getNavigation().setUrl(
+      createDiffUrl({
+        change: this.change,
+        patchNum: this.patchRange.patchNum,
+        basePatchNum: this.patchRange.basePatchNum,
+        diffView: {path: this.files[this.fileCursor.index].__path},
+      })
     );
   }
 
-  _shouldHideChangeTotals(_patchChange: PatchChange): boolean {
-    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
+  // Private but used in tests.
+  shouldHideChangeTotals(patchChange: PatchChange): boolean {
+    return patchChange.inserted === 0 && patchChange.deleted === 0;
   }
 
-  _shouldHideBinaryChangeTotals(_patchChange: PatchChange) {
+  // Private but used in tests.
+  shouldHideBinaryChangeTotals(patchChange: PatchChange) {
     return (
-      _patchChange.size_delta_inserted === 0 &&
-      _patchChange.size_delta_deleted === 0
+      patchChange.size_delta_inserted === 0 &&
+      patchChange.size_delta_deleted === 0
     );
   }
 
-  _computeDiffURL(
-    change?: ParsedChangeInfo,
-    patchRange?: PatchRange,
-    path?: string,
-    editMode?: boolean
-  ) {
-    // Polymer 2: check for undefined
+  // Private but used in tests
+  computeDiffURL(path?: string) {
     if (
-      change === undefined ||
-      !patchRange?.patchNum ||
+      this.change === undefined ||
+      this.patchRange?.patchNum === undefined ||
       path === undefined ||
-      editMode === undefined
+      this.editMode === undefined
     ) {
       return;
     }
-    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
-      return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum);
+    if (this.editMode && path !== SpecialFilePath.MERGE_LIST) {
+      return createEditUrl({
+        changeNum: this.change._number,
+        repo: this.change.project,
+        patchNum: this.patchRange.patchNum,
+        editView: {path},
+      });
     }
-    return GerritNav.getUrlForDiff(
-      change,
-      path,
-      patchRange.patchNum,
-      patchRange.basePatchNum
-    );
+    return createDiffUrl({
+      changeNum: this.change._number,
+      repo: this.change.project,
+      patchNum: this.patchRange.patchNum,
+      basePatchNum: this.patchRange.basePatchNum,
+      diffView: {path},
+    });
   }
 
-  _formatBytes(bytes?: number) {
+  // Private but used in tests.
+  formatBytes(bytes?: number) {
     if (!bytes) return '+/-0 B';
     const bits = 1024;
     const decimals = 1;
@@ -1063,7 +2205,8 @@
     return `${prepend}${value} ${sizes[exponent]}`;
   }
 
-  _formatPercentage(size?: number, delta?: number) {
+  // Private but used in tests.
+  formatPercentage(size?: number, delta?: number) {
     if (size === undefined || delta === undefined) {
       return '';
     }
@@ -1077,111 +2220,48 @@
     return `(${delta > 0 ? '+' : '-'}${percentage}%)`;
   }
 
-  _computeBinaryClass(delta?: number) {
+  private computeBinaryClass(delta?: number) {
     if (!delta) {
       return;
     }
     return delta > 0 ? 'added' : 'removed';
   }
 
-  _computeClass(baseClass?: string, path?: string) {
+  private computeClass(baseClass?: string, path?: string) {
     const classes = [];
-    if (baseClass) {
-      classes.push(baseClass);
-    }
-    if (
-      path === SpecialFilePath.COMMIT_MESSAGE ||
-      path === SpecialFilePath.MERGE_LIST
-    ) {
-      classes.push('invisible');
-    }
+    if (baseClass) classes.push(baseClass);
+    if (isMagicPath(path)) classes.push('invisible');
     return classes.join(' ');
   }
 
-  _computePathClass(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
+  private computePathClass(path: string | undefined) {
+    return this.isFileExpanded(path) ? 'expanded' : '';
   }
 
-  _computeShowHideIcon(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return this._isFileExpanded(path, expandedFilesRecord)
-      ? 'gr-icons:expand-less'
-      : 'gr-icons:expand-more';
+  private computeShowNumCleanlyMerged(): boolean {
+    return this.cleanlyMergedPaths.length > 0;
   }
 
-  _computeShowNumCleanlyMerged(cleanlyMergedPaths: string[]): boolean {
-    return cleanlyMergedPaths.length > 0;
-  }
-
-  _computeCleanlyMergedText(cleanlyMergedPaths: string[]): string {
-    const fileCount = pluralize(cleanlyMergedPaths.length, 'file');
+  private computeCleanlyMergedText(): string {
+    const fileCount = pluralize(this.cleanlyMergedPaths.length, 'file');
     return `${fileCount} merged cleanly in Parent 1`;
   }
 
-  _handleShowParent1(): void {
+  private handleShowParent1(): void {
     if (!this.change || !this.patchRange) return;
-    GerritNav.navigateToChange(
-      this.change,
-      this.patchRange.patchNum,
-      -1 as BasePatchSetNum // Parent 1
+    this.getNavigation().setUrl(
+      createChangeUrl({
+        change: this.change,
+        patchNum: this.patchRange.patchNum,
+        basePatchNum: -1 as BasePatchSetNum, // Parent 1
+      })
     );
   }
 
-  @observe(
-    '_filesByPath',
-    'changeComments',
-    'patchRange',
-    '_reviewed',
-    '_loading'
-  )
-  _computeFiles(
-    filesByPath?: FileNameToFileInfoMap,
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    reviewed?: string[],
-    loading?: boolean
-  ) {
-    // Polymer 2: check for undefined
-    if (
-      filesByPath === undefined ||
-      changeComments === undefined ||
-      patchRange === undefined ||
-      reviewed === undefined ||
-      loading === undefined
-    ) {
-      return;
-    }
-    // Await all promises resolving from reload. @See Issue 9057
-    if (loading || !changeComments) {
-      return;
-    }
-    const commentedPaths = changeComments.getPaths(patchRange);
-    const files: FileNameToReviewedFileInfoMap = {...filesByPath};
-    addUnmodifiedFiles(files, commentedPaths);
-    const reviewedSet = new Set(reviewed || []);
-    for (const [filePath, reviewedFileInfo] of Object.entries(files)) {
-      reviewedFileInfo.isReviewed = reviewedSet.has(filePath);
-    }
-    this._files = this._normalizeChangeFilesResponse(files);
-  }
+  private computeFilesShown(): NormalizedFileInfo[] {
+    const previousNumFilesShown = this.shownFiles ? this.shownFiles.length : 0;
 
-  _computeFilesShown(
-    numFilesShown: number,
-    files: NormalizedFileInfo[]
-  ): NormalizedFileInfo[] | undefined {
-    // Polymer 2: check for undefined
-    if (numFilesShown === undefined || files === undefined) return undefined;
-
-    const previousNumFilesShown = this._shownFiles
-      ? this._shownFiles.length
-      : 0;
-
-    const filesShown = files.slice(0, numFilesShown);
+    const filesShown = this.files.slice(0, this.numFilesShown);
     this.dispatchEvent(
       new CustomEvent('files-shown-changed', {
         detail: {length: filesShown.length},
@@ -1190,13 +2270,13 @@
       })
     );
 
-    // Start the timer for the rendering work hwere because this is where the
-    // _shownFiles property is being set, and _shownFiles is used in the
+    // Start the timer for the rendering work here because this is where the
+    // shownFiles property is being set, and shownFiles is used in the
     // dom-repeat binding.
     this.reporting.time(Timing.FILE_RENDER);
 
     // How many more files are being shown (if it's an increase).
-    this._reportinShownFilesIncrement = Math.max(
+    this.reportinShownFilesIncrement = Math.max(
       0,
       filesShown.length - previousNumFilesShown
     );
@@ -1204,59 +2284,56 @@
     return filesShown;
   }
 
-  _updateDiffCursor() {
+  // Private but used in tests.
+  updateDiffCursor() {
     // Overwrite the cursor's list of diffs:
-    this.diffCursor.replaceDiffs(this.diffs);
+    this.diffCursor?.replaceDiffs(this.diffs);
   }
 
-  _filesChanged() {
-    if (this._files && this._files.length > 0) {
-      flush();
-      this.fileCursor.stops = Array.from(
-        this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)
-      );
-      this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
-    }
+  async filesChanged() {
+    if (this.expandedFiles.length > 0) this.expandedFiles = [];
+    await this.updateCleanlyMergedPaths();
+    if (!this.files || this.files.length === 0) return;
+    await this.updateComplete;
+    this.fileCursor.stops = Array.from(
+      this.shadowRoot?.querySelectorAll(`.${FILE_ROW_CLASS}`) ?? []
+    );
+    this.fileCursor.setCursorAtIndex(this.selectedIndex, true);
   }
 
-  _incrementNumFilesShown() {
+  private incrementNumFilesShown() {
     this.numFilesShown += this.fileListIncrement;
   }
 
-  _computeFileListControlClass(
-    numFilesShown?: number,
-    files?: NormalizedFileInfo[]
-  ) {
-    if (numFilesShown === undefined || files === undefined) return 'invisible';
-    return numFilesShown >= files.length ? 'invisible' : '';
+  private computeFileListControlClass() {
+    return this.numFilesShown >= this.files.length ? 'invisible' : '';
   }
 
-  _computeIncrementText(numFilesShown?: number, files?: NormalizedFileInfo[]) {
-    if (numFilesShown === undefined || files === undefined) return '';
-    const text = Math.min(this.fileListIncrement, files.length - numFilesShown);
+  private computeIncrementText() {
+    const text = Math.min(
+      this.fileListIncrement,
+      this.files.length - this.numFilesShown
+    );
     return `Show ${text} more`;
   }
 
-  _computeShowAllText(files: NormalizedFileInfo[]) {
-    if (!files) {
+  private computeShowAllText() {
+    return `Show all ${this.files.length} files`;
+  }
+
+  private computeWarnShowAll() {
+    return this.files.length > WARN_SHOW_ALL_THRESHOLD;
+  }
+
+  private computeShowAllWarning() {
+    if (!this.computeWarnShowAll()) {
       return '';
     }
-    return `Show all ${files.length} files`;
+    return `Warning: showing all ${this.files.length} files may take several seconds.`;
   }
 
-  _computeWarnShowAll(files: NormalizedFileInfo[]) {
-    return files.length > WARN_SHOW_ALL_THRESHOLD;
-  }
-
-  _computeShowAllWarning(files: NormalizedFileInfo[]) {
-    if (!this._computeWarnShowAll(files)) {
-      return '';
-    }
-    return `Warning: showing all ${files.length} files may take several seconds.`;
-  }
-
-  _showAllFiles() {
-    this.numFilesShown = this._files.length;
+  private showAllFiles() {
+    this.numFilesShown = this.files.length;
   }
 
   /**
@@ -1268,33 +2345,22 @@
    *
    * @return 'true' if val is true-like, otherwise false
    */
-  _booleanToString(val?: unknown) {
+  private booleanToString(val?: unknown) {
     return val ? 'true' : 'false';
   }
 
-  _isFileExpanded(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return expandedFilesRecord.base.some(f => f.path === path);
+  private isFileExpanded(path: string | undefined) {
+    return this.expandedFiles.some(f => f.path === path);
   }
 
-  _isFileExpandedStr(
-    path: string | undefined,
-    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
-  ) {
-    return this._booleanToString(
-      this._isFileExpanded(path, expandedFilesRecord)
-    );
+  private isFileExpandedStr(path: string | undefined) {
+    return this.booleanToString(this.isFileExpanded(path));
   }
 
-  private _computeExpandedFiles(
-    expandedCount: number,
-    totalCount: number
-  ): FilesExpandedState {
-    if (expandedCount === 0) {
+  private computeExpandedFiles(): FilesExpandedState {
+    if (this.expandedFiles.length === 0) {
       return FilesExpandedState.NONE;
-    } else if (expandedCount === totalCount) {
+    } else if (this.expandedFiles.length === this.files.length) {
       return FilesExpandedState.ALL;
     }
     return FilesExpandedState.SOME;
@@ -1306,45 +2372,35 @@
    * order by waiting for the previous diff to finish before starting the next
    * one.
    *
-   * @param record The splice record in the expanded paths list.
+   * @param newFiles The new files that have been added.
+   * Private but used in tests.
    */
-  @observe('_expandedFiles.splices')
-  _expandedFilesChanged(record?: PolymerSpliceChange<PatchSetFile[]>) {
+  async expandedFilesChanged(oldFiles: Array<PatchSetFile>) {
     // Clear content for any diffs that are not open so if they get re-opened
     // the stale content does not flash before it is cleared and reloaded.
     const collapsedDiffs = this.diffs.filter(
-      diff => this._expandedFiles.findIndex(f => f.path === diff.path) === -1
+      diff => this.expandedFiles.findIndex(f => f.path === diff.path) === -1
     );
-    this._clearCollapsedDiffs(collapsedDiffs);
+    this.clearCollapsedDiffs(collapsedDiffs);
 
-    if (!record) {
-      return;
-    } // Happens after "Collapse all" clicked.
+    this.filesExpanded = this.computeExpandedFiles();
 
-    this.filesExpanded = this._computeExpandedFiles(
-      this._expandedFiles.length,
-      this._files.length
-    );
-
-    // Find the paths introduced by the new index splices:
-    const newFiles = record.indexSplices.flatMap(splice =>
-      splice.object.slice(splice.index, splice.index + splice.addedCount)
+    const newFiles = this.expandedFiles.filter(
+      file => (oldFiles ?? []).findIndex(f => f.path === file.path) === -1
     );
 
     // Required so that the newly created diff view is included in this.diffs.
-    flush();
-
-    this.reporting.time(Timing.FILE_EXPAND_ALL);
+    await this.updateComplete;
 
     if (newFiles.length) {
-      this._renderInOrder(newFiles, this.diffs, newFiles.length);
+      await this.renderInOrder(newFiles, this.diffs);
     }
-
-    this._updateDiffCursor();
-    this.diffCursor.reInitAndUpdateStops();
+    this.updateDiffCursor();
+    this.diffCursor?.reInitAndUpdateStops();
   }
 
-  private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+  // private but used in test
+  clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
     for (const diff of collapsedDiffs) {
       diff.cancel();
       diff.clearDiffContent();
@@ -1356,82 +2412,90 @@
    * for each path in order, awaiting the previous render to complete before
    * continuing.
    *
-   * @param initialCount The total number of paths in the pass. This
-   * is used to generate log messages.
+   * private but used in test
+   *
+   * @param initialCount The total number of paths in the pass.
    */
-  private _renderInOrder(
-    files: PatchSetFile[],
-    diffElements: GrDiffHost[],
-    initialCount: number
-  ) {
-    let iter = 0;
+  async renderInOrder(files: PatchSetFile[], diffElements: GrDiffHost[]) {
+    this.reporting.time(Timing.FILE_EXPAND_ALL);
 
     for (const file of files) {
       const path = file.path;
-      const diffElem = this._findDiffByPath(path, diffElements);
-      if (diffElem) {
-        diffElem.prefetchDiff();
-      }
-    }
-
-    asyncForeach(files, (file, cancel) => {
-      const path = file.path;
-      this._cancelForEachDiff = cancel;
-
-      iter++;
-      console.info('Expanding diff', iter, 'of', initialCount, ':', path);
-      const diffElem = this._findDiffByPath(path, diffElements);
+      const diffElem = this.findDiffByPath(path, diffElements);
       if (!diffElem) {
         this.reporting.error(
+          'GrFileList',
           new Error(`Did not find <gr-diff-host> element for ${path}`)
         );
-        return Promise.resolve();
+        return;
+      }
+      diffElem.prefetchDiff();
+    }
+
+    await asyncForeach(files, async (file, cancel) => {
+      const path = file.path;
+      this.cancelForEachDiff = cancel;
+
+      const diffElem = this.findDiffByPath(path, diffElements);
+      if (!diffElem) {
+        this.reporting.error(
+          'GrFileList',
+          new Error(`Did not find <gr-diff-host> element for ${path}`)
+        );
+        return;
       }
       if (!this.diffPrefs) {
         throw new Error('diffPrefs must be set');
       }
 
-      const promises: Array<Promise<unknown>> = [diffElem.reload()];
-      if (this._loggedIn && !this.diffPrefs.manual_review) {
-        promises.push(this._reviewFile(path, true));
+      // When one file is expanded individually then automatically mark as
+      // reviewed, if the user's diff prefs request it. Doing this for
+      // "Expand All" would not be what the user wants, because there is no
+      // control over which diffs were actually seen. And for lots of diffs
+      // that would even be a problem for write QPS quota.
+      if (
+        this.loggedIn &&
+        !this.diffPrefs.manual_review &&
+        files.length === 1
+      ) {
+        await this.reviewFile(path, true);
       }
-      return Promise.all(promises);
-    }).then(() => {
-      this._cancelForEachDiff = undefined;
-      console.info('Finished expanding', initialCount, 'diff(s)');
-      this.reporting.timeEndWithAverage(
-        Timing.FILE_EXPAND_ALL,
-        Timing.FILE_EXPAND_ALL_AVG,
-        initialCount
-      );
-      /* Block diff cursor from auto scrolling after files are done rendering.
-      * This prevents the bug where the screen jumps to the first diff chunk
-      * after files are done being rendered after the user has already begun
-      * scrolling.
-      * This also however results in the fact that the cursor does not auto
-      * focus on the first diff chunk on a small screen. This is however, a use
-      * case we are willing to not support for now.
-
-      * Using handleDiffUpdate resulted in diffCursor.row being set which
-      * prevented the issue of scrolling to top when we expand the second
-      * file individually.
-      */
-      this.diffCursor.reInitAndUpdateStops();
+      await diffElem.reload();
     });
+
+    this.cancelForEachDiff = undefined;
+    this.reporting.timeEnd(Timing.FILE_EXPAND_ALL, {
+      count: files.length,
+      height: this.clientHeight,
+    });
+    /*
+    * Block diff cursor from auto scrolling after files are done rendering.
+    * This prevents the bug where the screen jumps to the first diff chunk
+    * after files are done being rendered after the user has already begun
+    * scrolling.
+    * This also however results in the fact that the cursor does not auto
+    * focus on the first diff chunk on a small screen. This is however, a use
+    * case we are willing to not support for now.
+
+    * Using reInit resulted in diffCursor.row being set which
+    * prevented the issue of scrolling to top when we expand the second
+    * file individually.
+    */
+    this.diffCursor?.reInitAndUpdateStops();
   }
 
   /** Cancel the rendering work of every diff in the list */
-  _cancelDiffs() {
-    if (this._cancelForEachDiff) {
-      this._cancelForEachDiff();
+  private cancelDiffs() {
+    if (this.cancelForEachDiff) {
+      this.cancelForEachDiff();
     }
-    this._forEachDiff(d => d.cancel());
+    this.forEachDiff(d => d.cancel());
   }
 
   /**
    * In the given NodeList of diff elements, find the diff for the given path.
    */
-  private _findDiffByPath(path: string, diffElements: GrDiffHost[]) {
+  private findDiffByPath(path: string, diffElements: GrDiffHost[]) {
     for (let i = 0; i < diffElements.length; i++) {
       if (diffElements[i].path === path) {
         return diffElements[i];
@@ -1440,62 +2504,19 @@
     return undefined;
   }
 
-  _handleEscKey() {
-    this._displayLine = false;
-  }
-
-  /**
-   * Update the loading class for the file list rows. The update is inside a
-   * debouncer so that the file list doesn't flash gray when the API requests
-   * are reasonably fast.
-   */
-  _loadingChanged(loading?: boolean) {
-    this.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.
-        this.classList.toggle('loading', loading && !!this._files.length);
-      },
-      LOADING_DEBOUNCE_INTERVAL
-    );
-  }
-
-  _editModeChanged(editMode?: boolean) {
-    this.classList.toggle('editMode', editMode);
-  }
-
-  _computeReviewedClass(isReviewed?: boolean) {
-    return isReviewed ? 'isReviewed' : '';
-  }
-
-  _computeReviewedText(isReviewed?: boolean) {
-    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
-  }
-
-  /**
-   * Given a file path, return whether that path should have visible size bars
-   * and be included in the size bars calculation.
-   */
-  _showBarsForPath(path?: string) {
-    return (
-      path !== SpecialFilePath.COMMIT_MESSAGE &&
-      path !== SpecialFilePath.MERGE_LIST
-    );
+  // Private but used in tests.
+  handleEscKey() {
+    this.displayLine = false;
   }
 
   /**
    * Compute size bar layout values from the file list.
+   * Private but used in tests.
    */
-  _computeSizeBarLayout(
-    shownFilesRecord?: ElementPropertyDeepChange<GrFileList, '_shownFiles'>
-  ) {
+  computeSizeBarLayout() {
     const stats: SizeBarLayout = createDefaultSizeBarLayout();
-    if (!shownFilesRecord || !shownFilesRecord.base) {
-      return stats;
-    }
-    shownFilesRecord.base
-      .filter(f => this._showBarsForPath(f.__path))
+    this.shownFiles
+      .filter(f => !isMagicPath(f.__path))
       .forEach(f => {
         if (f.lines_inserted) {
           stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
@@ -1517,14 +2538,15 @@
 
   /**
    * Get the width of the addition bar for a file.
+   * Private but used in tests.
    */
-  _computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+  computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
     if (
       !file ||
       !stats ||
       stats.maxInserted === 0 ||
       !file.lines_inserted ||
-      !this._showBarsForPath(file.__path)
+      !!isMagicPath(file.__path)
     ) {
       return 0;
     }
@@ -1535,22 +2557,24 @@
 
   /**
    * Get the x-offset of the addition bar for a file.
+   * Private but used in tests.
    */
-  _computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+  computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
     if (!file || !stats) return;
-    return stats.maxAdditionWidth - this._computeBarAdditionWidth(file, stats);
+    return stats.maxAdditionWidth - this.computeBarAdditionWidth(file, stats);
   }
 
   /**
    * Get the width of the deletion bar for a file.
+   * Private but used in tests.
    */
-  _computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+  computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
     if (
       !file ||
       !stats ||
       stats.maxDeleted === 0 ||
       !file.lines_deleted ||
-      !this._showBarsForPath(file.__path)
+      !!isMagicPath(file.__path)
     ) {
       return 0;
     }
@@ -1562,18 +2586,19 @@
   /**
    * Get the x-offset of the deletion bar for a file.
    */
-  _computeBarDeletionX(stats: SizeBarLayout) {
+  private computeBarDeletionX(stats: SizeBarLayout) {
     return stats.deletionOffset;
   }
 
-  _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
+  // Private but used in tests.
+  computeSizeBarsClass(path?: string) {
     let hideClass = '';
-    if (!showSizeBars) {
+    if (!this.showSizeBars) {
       hideClass = 'hide';
-    } else if (!this._showBarsForPath(path)) {
+    } else if (isMagicPath(path)) {
       hideClass = 'invisible';
     }
-    return `sizeBars desktop ${hideClass}`;
+    return `sizeBars ${hideClass}`;
   }
 
   /**
@@ -1582,18 +2607,15 @@
    * Ideally, there should be a better way to enforce the expectation of the
    * dependencies between dynamic endpoints.
    */
-  _computeShowDynamicColumns(
-    headerEndpoints?: string,
-    contentEndpoints?: string,
-    summaryEndpoints?: string
-  ) {
-    return (
-      headerEndpoints &&
-      contentEndpoints &&
-      summaryEndpoints &&
-      headerEndpoints.length &&
-      headerEndpoints.length === contentEndpoints.length &&
-      headerEndpoints.length === summaryEndpoints.length
+  private computeShowDynamicColumns() {
+    return !!(
+      this.dynamicHeaderEndpoints &&
+      this.dynamicContentEndpoints &&
+      this.dynamicSummaryEndpoints &&
+      this.dynamicHeaderEndpoints.length &&
+      this.dynamicHeaderEndpoints.length ===
+        this.dynamicContentEndpoints.length &&
+      this.dynamicHeaderEndpoints.length === this.dynamicSummaryEndpoints.length
     );
   }
 
@@ -1601,22 +2623,21 @@
    * Shows registered dynamic prepended columns iff the 'header', 'content'
    * endpoints are registered the exact same number of times.
    */
-  _computeShowPrependedDynamicColumns(
-    headerEndpoints?: string,
-    contentEndpoints?: string
-  ) {
-    return (
-      headerEndpoints &&
-      contentEndpoints &&
-      headerEndpoints.length &&
-      headerEndpoints.length === contentEndpoints.length
+  private computeShowPrependedDynamicColumns() {
+    return !!(
+      this.dynamicPrependedHeaderEndpoints &&
+      this.dynamicPrependedContentEndpoints &&
+      this.dynamicPrependedHeaderEndpoints.length &&
+      this.dynamicPrependedHeaderEndpoints.length ===
+        this.dynamicPrependedContentEndpoints.length
     );
   }
 
   /**
    * Returns true if none of the inline diffs have been expanded.
+   * Private but used in tests.
    */
-  _noDiffsExpanded() {
+  noDiffsExpanded() {
     return this.filesExpanded === FilesExpandedState.NONE;
   }
 
@@ -1626,47 +2647,23 @@
    * rendering.
    *
    * @param index The index of the row being rendered.
+   * Private but used in tests.
    */
-  _reportRenderedRow(index: number) {
-    if (index === this._shownFiles.length - 1) {
+  reportRenderedRow(index: number) {
+    if (index === this.shownFiles.length - 1) {
       setTimeout(() => {
-        this.reporting.timeEndWithAverage(
-          Timing.FILE_RENDER,
-          Timing.FILE_RENDER_AVG,
-          this._reportinShownFilesIncrement
-        );
+        this.reporting.timeEnd(Timing.FILE_RENDER, {
+          count: this.reportinShownFilesIncrement,
+        });
       }, 1);
     }
-    return '';
   }
 
-  _reviewedTitle(reviewed?: boolean) {
-    if (reviewed) {
-      return 'Mark as not reviewed (shortcut: r)';
-    }
-
-    return 'Mark as reviewed (shortcut: r)';
+  private handleReloadingDiffPreference() {
+    this.getUserModel().getDiffPreferences();
   }
 
-  _handleReloadingDiffPreference() {
-    this.userService.getDiffPreferences();
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeDisplayPath(path: string) {
-    return computeDisplayPath(path);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeTruncatedPath(path: string) {
-    return computeTruncatedPath(path);
-  }
-
-  _getOldPath(file: NormalizedFileInfo) {
+  private 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
@@ -1676,9 +2673,3 @@
     return file.old_path ?? null;
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-file-list': GrFileList;
-  }
-}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
deleted file mode 100644
index e8371e3..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ /dev/null
@@ -1,792 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    .row {
-      align-items: center;
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      min-height: calc(var(--line-height-normal) + 2 * var(--spacing-s));
-      padding: var(--spacing-xs) var(--spacing-l);
-    }
-    /* The class defines a content visible only to screen readers */
-    .noCommentsScreenReaderText {
-      opacity: 0;
-      max-width: 1px;
-      overflow: hidden;
-      display: none;
-    }
-    div[role='gridcell']
-      > div.comments
-      > span:empty
-      + span:empty
-      + span.noCommentsScreenReaderText {
-      display: inline;
-    }
-    :host(.loading) .row {
-      opacity: 0.5;
-    }
-    :host(.editMode) .hideOnEdit {
-      display: none;
-    }
-    .showOnEdit {
-      display: none;
-    }
-    :host(.editMode) .showOnEdit {
-      display: initial;
-    }
-    .invisible {
-      visibility: hidden;
-    }
-    .header-row {
-      background-color: var(--background-color-secondary);
-    }
-    .controlRow {
-      align-items: center;
-      display: flex;
-      height: 2.25em;
-      justify-content: center;
-    }
-    .controlRow.invisible,
-    .show-hide.invisible {
-      display: none;
-    }
-    .reviewed,
-    .status {
-      align-items: center;
-      display: inline-flex;
-    }
-    .reviewed {
-      display: inline-block;
-      text-align: left;
-      width: 1.5em;
-    }
-    .file-row {
-      cursor: pointer;
-    }
-    .file-row.expanded {
-      border-bottom: 1px solid var(--border-color);
-      position: -webkit-sticky;
-      position: sticky;
-      top: 0;
-      /* Has to visible above the diff view, and by default has a lower
-         z-index. setting to 1 places it directly above. */
-      z-index: 1;
-    }
-    .file-row:hover {
-      background-color: var(--hover-background-color);
-    }
-    .file-row.selected {
-      background-color: var(--selection-background-color);
-    }
-    .file-row.expanded,
-    .file-row.expanded:hover {
-      background-color: var(--expanded-background-color);
-    }
-    .path {
-      cursor: pointer;
-      flex: 1;
-      /* Wrap it into multiple lines if too long. */
-      white-space: normal;
-      word-break: break-word;
-    }
-    .oldPath {
-      color: var(--deemphasized-text-color);
-    }
-    .header-stats {
-      text-align: center;
-      min-width: 7.5em;
-    }
-    .stats {
-      text-align: right;
-      min-width: 7.5em;
-    }
-    .comments {
-      padding-left: var(--spacing-l);
-      min-width: 7.5em;
-    }
-    .row:not(.header-row) .stats,
-    .total-stats {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      display: flex;
-    }
-    .sizeBars {
-      margin-left: var(--spacing-m);
-      min-width: 7em;
-      text-align: center;
-    }
-    .sizeBars.hide {
-      display: none;
-    }
-    .added,
-    .removed {
-      display: inline-block;
-      min-width: 3.5em;
-    }
-    .added {
-      color: var(--positive-green-text-color);
-    }
-    .removed {
-      color: var(--negative-red-text-color);
-      text-align: left;
-      min-width: 4em;
-      padding-left: var(--spacing-s);
-    }
-    .drafts {
-      color: var(--error-foreground);
-      font-weight: var(--font-weight-bold);
-    }
-    .show-hide-icon:focus {
-      outline: none;
-    }
-    .show-hide {
-      margin-left: var(--spacing-s);
-      width: 1.9em;
-    }
-    .fileListButton {
-      margin: var(--spacing-m);
-    }
-    .totalChanges {
-      justify-content: flex-end;
-      text-align: right;
-    }
-    .warning {
-      color: var(--deemphasized-text-color);
-    }
-    input.show-hide {
-      display: none;
-    }
-    label.show-hide {
-      cursor: pointer;
-      display: block;
-      min-width: 2em;
-    }
-    gr-diff {
-      display: block;
-      overflow-x: auto;
-    }
-    .truncatedFileName {
-      display: none;
-    }
-    .mobile {
-      display: none;
-    }
-    .reviewed {
-      margin-left: var(--spacing-xxl);
-      width: 15em;
-    }
-    .reviewedSwitch {
-      color: var(--link-color);
-      opacity: 0;
-      justify-content: flex-end;
-      width: 100%;
-    }
-    .reviewedSwitch:hover {
-      cursor: pointer;
-      opacity: 100;
-    }
-    .showParentButton {
-      line-height: var(--line-height-normal);
-      margin-bottom: calc(var(--spacing-s) * -1);
-      margin-left: var(--spacing-m);
-      margin-top: calc(var(--spacing-s) * -1);
-    }
-    .row:focus {
-      outline: none;
-    }
-    .row:hover .reviewedSwitch,
-    .row:focus-within .reviewedSwitch,
-    .row.expanded .reviewedSwitch {
-      opacity: 100;
-    }
-    .reviewedLabel {
-      color: var(--deemphasized-text-color);
-      margin-right: var(--spacing-l);
-      opacity: 0;
-    }
-    .reviewedLabel.isReviewed {
-      display: initial;
-      opacity: 100;
-    }
-    .editFileControls {
-      width: 7em;
-    }
-    .markReviewed:focus {
-      outline: none;
-    }
-    .markReviewed,
-    .pathLink {
-      display: inline-block;
-      margin: -2px 0;
-      padding: var(--spacing-s) 0;
-      text-decoration: none;
-    }
-    .pathLink:hover span.fullFileName,
-    .pathLink:hover span.truncatedFileName {
-      text-decoration: underline;
-    }
-
-    /** copy on file path **/
-    .pathLink gr-copy-clipboard,
-    .oldPath gr-copy-clipboard {
-      display: inline-block;
-      visibility: hidden;
-      vertical-align: bottom;
-      --gr-button-padding: 0px;
-    }
-    .row:focus-within gr-copy-clipboard,
-    .row:hover gr-copy-clipboard {
-      visibility: visible;
-    }
-
-    /** small screen breakpoint: 768px */
-    @media screen and (max-width: 55em) {
-      .desktop {
-        display: none;
-      }
-      .mobile {
-        display: block;
-      }
-      .row.selected {
-        background-color: var(--view-background-color);
-      }
-      .stats {
-        display: none;
-      }
-      .reviewed,
-      .status {
-        justify-content: flex-start;
-      }
-      .reviewed {
-        display: none;
-      }
-      .comments {
-        min-width: initial;
-      }
-      .expanded .fullFileName,
-      .truncatedFileName {
-        display: inline;
-      }
-      .expanded .truncatedFileName,
-      .fullFileName {
-        display: none;
-      }
-    }
-    :host(.hideComments) {
-      --gr-comment-thread-display: none;
-    }
-  </style>
-  <h3 class="assistive-tech-only">File list</h3>
-  <div
-    id="container"
-    on-click="_handleFileListClick"
-    role="grid"
-    aria-label="Files list"
-  >
-    <div class="header-row row" role="row">
-      <!-- endpoint: change-view-file-list-header-prepend -->
-      <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
-        <template
-          is="dom-repeat"
-          items="[[_dynamicPrependedHeaderEndpoints]]"
-          as="headerEndpoint"
-        >
-          <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
-            <gr-endpoint-param name="change" value="[[change]]">
-            </gr-endpoint-param>
-            <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-            </gr-endpoint-param>
-            <gr-endpoint-param name="files" value="[[_files]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-        </template>
-      </template>
-      <div class="path" role="columnheader">File</div>
-      <div class="comments" role="columnheader">Comments</div>
-      <div class="sizeBars" role="columnheader">Size</div>
-      <div class="header-stats" role="columnheader">Delta</div>
-      <!-- endpoint: change-view-file-list-header -->
-      <template is="dom-if" if="[[_showDynamicColumns]]">
-        <template
-          is="dom-repeat"
-          items="[[_dynamicHeaderEndpoints]]"
-          as="headerEndpoint"
-        >
-          <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
-          </gr-endpoint-decorator>
-        </template>
-      </template>
-      <!-- Empty div here exists to keep spacing in sync with file rows. -->
-      <div
-        class="reviewed hideOnEdit"
-        hidden$="[[!_loggedIn]]"
-        aria-hidden="true"
-      ></div>
-      <div class="editFileControls showOnEdit" aria-hidden="true"></div>
-      <div class="show-hide" aria-hidden="true"></div>
-    </div>
-
-    <template
-      is="dom-repeat"
-      items="[[_shownFiles]]"
-      id="files"
-      as="file"
-      initial-count="[[fileListIncrement]]"
-      target-framerate="1"
-    >
-      [[_reportRenderedRow(index)]]
-      <div class="stickyArea">
-        <div
-          class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
-          data-file$="[[_computePatchSetFile(file)]]"
-          tabindex="-1"
-          role="row"
-        >
-          <!-- endpoint: change-view-file-list-content-prepend -->
-          <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
-            <template
-              is="dom-repeat"
-              items="[[_dynamicPrependedContentEndpoints]]"
-              as="contentEndpoint"
-            >
-              <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
-                <gr-endpoint-param name="change" value="[[change]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="path" value="[[file.__path]]">
-                </gr-endpoint-param>
-                <gr-endpoint-param name="oldPath" value="[[_getOldPath(file)]]">
-                </gr-endpoint-param>
-              </gr-endpoint-decorator>
-            </template>
-          </template>
-          <!-- TODO: Remove data-url as it appears its not used -->
-          <span
-            data-url="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
-            class="path"
-            role="gridcell"
-          >
-            <a
-              class="pathLink"
-              href$="[[_computeDiffURL(change, patchRange, file.__path, editMode)]]"
-            >
-              <span
-                title$="[[_computeDisplayPath(file.__path)]]"
-                class="fullFileName"
-              >
-                [[_computeDisplayPath(file.__path)]]
-              </span>
-              <span
-                title$="[[_computeDisplayPath(file.__path)]]"
-                class="truncatedFileName"
-              >
-                [[_computeTruncatedPath(file.__path)]]
-              </span>
-              <gr-file-status-chip file="[[file]]"></gr-file-status-chip>
-              <gr-copy-clipboard
-                hideInput=""
-                text="[[file.__path]]"
-              ></gr-copy-clipboard>
-            </a>
-            <template is="dom-if" if="[[file.old_path]]">
-              <div class="oldPath" title$="[[file.old_path]]">
-                [[file.old_path]]
-                <gr-copy-clipboard
-                  hideInput=""
-                  text="[[file.old_path]]"
-                ></gr-copy-clipboard>
-              </div>
-            </template>
-          </span>
-          <div role="gridcell">
-            <div class="comments desktop">
-              <span class="drafts"
-                ><!-- This comments ensure that span is empty when the function
-                returns empty string.
-              -->[[_computeDraftsString(changeComments, patchRange, file)]]<!-- This comments ensure that span is empty when
-                the function returns empty string.
-           --></span
-              >
-              <span
-                ><!--
-              -->[[_computeCommentsString(changeComments, patchRange, file)]]<!--
-           --></span
-              >
-              <span class="noCommentsScreenReaderText">
-                <!-- Screen readers read the following content only if 2 other
-              spans in the parent div is empty. The content is not visible on
-              the page.
-              Without this span, screen readers don't navigate correctly inside
-              table, because empty div doesn't rendered. For example, VoiceOver
-              jumps back to the whole table.
-              We can use &nbsp instead, but it sounds worse.
-              -->
-                No comments
-              </span>
-            </div>
-            <div class="comments mobile">
-              <span class="drafts"
-                ><!-- This comments ensure that span is empty when the function
-                returns empty string.
-              -->[[_computeDraftsStringMobile(changeComments, patchRange,
-                file)]]<!-- This comments ensure that span is empty when
-                the function returns empty string.
-           --></span
-              >
-              <span
-                ><!--
-             -->[[_computeCommentsStringMobile(changeComments, patchRange,
-                file)]]<!--
-           --></span
-              >
-              <span class="noCommentsScreenReaderText">
-                <!-- The same as for desktop comments -->
-                No comments
-              </span>
-            </div>
-          </div>
-          <div role="gridcell">
-            <!-- The content must be in a separate div. It guarantees, that
-              gridcell always visible for screen readers.
-              For example, without a nested div screen readers pronounce the
-              "Commit message" row content with incorrect column headers.
-            -->
-            <div
-              class$="[[_computeSizeBarsClass(_showSizeBars, file.__path)]]"
-              aria-label="A bar that represents the addition and deletion ratio for the current file"
-            >
-              <svg width="61" height="8">
-                <rect
-                  x$="[[_computeBarAdditionX(file, _sizeBarLayout)]]"
-                  y="0"
-                  height="8"
-                  fill="var(--positive-green-text-color)"
-                  width$="[[_computeBarAdditionWidth(file, _sizeBarLayout)]]"
-                ></rect>
-                <rect
-                  x$="[[_computeBarDeletionX(_sizeBarLayout)]]"
-                  y="0"
-                  height="8"
-                  fill="var(--negative-red-text-color)"
-                  width$="[[_computeBarDeletionWidth(file, _sizeBarLayout)]]"
-                ></rect>
-              </svg>
-            </div>
-          </div>
-          <div class="stats" role="gridcell">
-            <!-- The content must be in a separate div. It guarantees, that
-            gridcell always visible for screen readers.
-            For example, without a nested div screen readers pronounce the
-            "Commit message" row content with incorrect column headers.
-            -->
-            <div class$="[[_computeClass('', file.__path)]]">
-              <span
-                class="added"
-                tabindex="0"
-                aria-label$="[[file.lines_inserted]] lines added"
-                hidden$="[[file.binary]]"
-              >
-                +[[file.lines_inserted]]
-              </span>
-              <span
-                class="removed"
-                tabindex="0"
-                aria-label$="[[file.lines_deleted]] lines removed"
-                hidden$="[[file.binary]]"
-              >
-                -[[file.lines_deleted]]
-              </span>
-              <span
-                class$="[[_computeBinaryClass(file.size_delta)]]"
-                hidden$="[[!file.binary]]"
-              >
-                [[_formatBytes(file.size_delta)]] [[_formatPercentage(file.size,
-                file.size_delta)]]
-              </span>
-            </div>
-          </div>
-          <!-- endpoint: change-view-file-list-content -->
-          <template is="dom-if" if="[[_showDynamicColumns]]">
-            <template
-              is="dom-repeat"
-              items="[[_dynamicContentEndpoints]]"
-              as="contentEndpoint"
-            >
-              <div class$="[[_computeClass('', file.__path)]]" role="gridcell">
-                <gr-endpoint-decorator name="[[contentEndpoint]]">
-                  <gr-endpoint-param name="change" value="[[change]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-                  </gr-endpoint-param>
-                  <gr-endpoint-param name="path" value="[[file.__path]]">
-                  </gr-endpoint-param>
-                </gr-endpoint-decorator>
-              </div>
-            </template>
-          </template>
-          <div
-            class="reviewed hideOnEdit"
-            role="gridcell"
-            hidden$="[[!_loggedIn]]"
-          >
-            <span
-              class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]"
-              aria-hidden$="[[!file.isReviewed]]"
-              >Reviewed</span
-            >
-            <!-- Do not use input type="checkbox" with hidden input and
-                  visible label here. Screen readers don't read/interract
-                  correctly with such input.
-              -->
-            <span
-              class="reviewedSwitch"
-              role="switch"
-              tabindex="0"
-              on-click="_reviewedClick"
-              on-keydown="_reviewedClick"
-              aria-label="Reviewed"
-              aria-checked$="[[_booleanToString(file.isReviewed)]]"
-            >
-              <!-- Trick with tabindex to avoid outline on mouse focus, but
-                preserve focus outline for keyboard navigation -->
-              <span
-                tabindex="-1"
-                class="markReviewed"
-                title$="[[_reviewedTitle(file.isReviewed)]]"
-                >[[_computeReviewedText(file.isReviewed)]]</span
-              >
-            </span>
-          </div>
-          <div
-            class="editFileControls showOnEdit"
-            role="gridcell"
-            aria-hidden$="[[!editMode]]"
-          >
-            <template is="dom-if" if="[[editMode]]">
-              <gr-edit-file-controls
-                class$="[[_computeClass('', file.__path)]]"
-                file-path="[[file.__path]]"
-              ></gr-edit-file-controls>
-            </template>
-          </div>
-          <div class="show-hide" role="gridcell">
-            <!-- Do not use input type="checkbox" with hidden input and
-                visible label here. Screen readers don't read/interract
-                correctly with such input.
-            -->
-            <span
-              class="show-hide"
-              data-path$="[[file.__path]]"
-              data-expand="true"
-              role="switch"
-              tabindex="0"
-              aria-checked$="[[_isFileExpandedStr(file.__path, _expandedFiles.*)]]"
-              aria-label="Expand file"
-              on-click="_expandedClick"
-              on-keydown="_expandedClick"
-            >
-              <!-- Trick with tabindex to avoid outline on mouse focus, but
-              preserve focus outline for keyboard navigation -->
-              <iron-icon
-                class="show-hide-icon"
-                tabindex="-1"
-                id="icon"
-                icon="[[_computeShowHideIcon(file.__path, _expandedFiles.*)]]"
-              >
-              </iron-icon>
-            </span>
-          </div>
-        </div>
-        <template
-          is="dom-if"
-          if="[[_isFileExpanded(file.__path, _expandedFiles.*)]]"
-        >
-          <gr-diff-host
-            no-auto-render=""
-            show-load-failure=""
-            display-line="[[_displayLine]]"
-            hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
-            change-num="[[changeNum]]"
-            change="[[change]]"
-            patch-range="[[patchRange]]"
-            file="[[_computePatchSetFile(file)]]"
-            path="[[file.__path]]"
-            prefs="[[diffPrefs]]"
-            project-name="[[change.project]]"
-            no-render-on-prefs-change=""
-          ></gr-diff-host>
-        </template>
-      </div>
-    </template>
-    <template
-      is="dom-if"
-      if="[[_computeShowNumCleanlyMerged(_cleanlyMergedPaths)]]"
-    >
-      <div class="row">
-        <!-- endpoint: change-view-file-list-content-prepend -->
-        <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
-          <template
-            is="dom-repeat"
-            items="[[_dynamicPrependedContentEndpoints]]"
-            as="contentEndpoint"
-          >
-            <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
-              <gr-endpoint-param name="change" value="[[change]]">
-              </gr-endpoint-param>
-              <gr-endpoint-param name="changeNum" value="[[changeNum]]">
-              </gr-endpoint-param>
-              <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-              </gr-endpoint-param>
-              <gr-endpoint-param
-                name="cleanlyMergedPaths"
-                value="[[_cleanlyMergedPaths]]"
-              >
-              </gr-endpoint-param>
-              <gr-endpoint-param
-                name="cleanlyMergedOldPaths"
-                value="[[_cleanlyMergedOldPaths]]"
-              >
-              </gr-endpoint-param>
-            </gr-endpoint-decorator>
-          </template>
-        </template>
-        <div role="gridcell">
-          <div>
-            <span class="cleanlyMergedText">
-              [[_computeCleanlyMergedText(_cleanlyMergedPaths)]]
-            </span>
-            <gr-button
-              link
-              class="showParentButton"
-              on-click="_handleShowParent1"
-            >
-              Show Parent 1
-            </gr-button>
-          </div>
-        </div>
-      </div>
-    </template>
-  </div>
-  <div class="row totalChanges" hidden$="[[_hideChangeTotals]]">
-    <div class="total-stats">
-      <div>
-        <span
-          class="added"
-          tabindex="0"
-          aria-label$="Total [[_patchChange.inserted]] lines added"
-        >
-          +[[_patchChange.inserted]]
-        </span>
-        <span
-          class="removed"
-          tabindex="0"
-          aria-label$="Total [[_patchChange.deleted]] lines removed"
-        >
-          -[[_patchChange.deleted]]
-        </span>
-      </div>
-    </div>
-    <!-- endpoint: change-view-file-list-summary -->
-    <template is="dom-if" if="[[_showDynamicColumns]]">
-      <template
-        is="dom-repeat"
-        items="[[_dynamicSummaryEndpoints]]"
-        as="summaryEndpoint"
-      >
-        <gr-endpoint-decorator name="[[summaryEndpoint]]">
-          <gr-endpoint-param
-            name="change"
-            value="[[change]]"
-          ></gr-endpoint-param>
-          <gr-endpoint-param name="patchRange" value="[[patchRange]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-      </template>
-    </template>
-    <!-- Empty div here exists to keep spacing in sync with file rows. -->
-    <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]"></div>
-    <div class="editFileControls showOnEdit"></div>
-    <div class="show-hide"></div>
-  </div>
-  <div class="row totalChanges" hidden$="[[_hideBinaryChangeTotals]]">
-    <div class="total-stats">
-      <span
-        class="added"
-        aria-label$="Total bytes inserted: [[_formatBytes(_patchChange.size_delta_inserted)]] "
-      >
-        [[_formatBytes(_patchChange.size_delta_inserted)]]
-        [[_formatPercentage(_patchChange.total_size,
-        _patchChange.size_delta_inserted)]]
-      </span>
-      <span
-        class="removed"
-        aria-label$="Total bytes removed: [[_formatBytes(_patchChange.size_delta_deleted)]]"
-      >
-        [[_formatBytes(_patchChange.size_delta_deleted)]]
-        [[_formatPercentage(_patchChange.total_size,
-        _patchChange.size_delta_deleted)]]
-      </span>
-    </div>
-  </div>
-  <div
-    class$="row controlRow [[_computeFileListControlClass(numFilesShown, _files)]]"
-  >
-    <gr-button
-      class="fileListButton"
-      id="incrementButton"
-      link=""
-      on-click="_incrementNumFilesShown"
-    >
-      [[_computeIncrementText(numFilesShown, _files)]]
-    </gr-button>
-    <gr-tooltip-content
-      has-tooltip="[[_computeWarnShowAll(_files)]]"
-      show-icon="[[_computeWarnShowAll(_files)]]"
-      title$="[[_computeShowAllWarning(_files)]]"
-    >
-      <gr-button
-        class="fileListButton"
-        id="showAllButton"
-        link=""
-        on-click="_showAllFiles"
-      >
-        [[_computeShowAllText(_files)]] </gr-button
-      ><!--
-  --></gr-tooltip-content>
-  </div>
-  <gr-diff-preferences-dialog
-    id="diffPreferencesDialog"
-    diff-prefs="{{diffPrefs}}"
-    on-reload-diff-preference="_handleReloadingDiffPreference"
-  >
-  </gr-diff-preferences-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_screenshot_test.ts
new file mode 100644
index 0000000..f80f48b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_screenshot_test.ts
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import {fixture, html} from '@open-wc/testing';
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {FileInfo, PARENT, RevisionPatchSetNum} from '../../../api/rest-api';
+import {normalize} from '../../../models/change/files-model';
+import {PatchRange} from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {NormalizedFileInfo, GrFileList} from './gr-file-list';
+import './gr-file-list';
+
+suite('gr-file-list screenshot tests', () => {
+  let element: GrFileList;
+
+  function createFiles(
+    count: number,
+    fileInfo: FileInfo
+  ): NormalizedFileInfo[] {
+    return Array.from(Array(count).keys()).map(index =>
+      normalize(fileInfo, `/file${index}`)
+    );
+  }
+
+  setup(async () => {
+    const patchRange: PatchRange = {
+      basePatchNum: PARENT,
+      patchNum: 2 as RevisionPatchSetNum,
+    };
+    const diffPrefs: DiffPreferencesInfo = {
+      context: 10,
+      tab_size: 8,
+      font_size: 12,
+      line_length: 100,
+      ignore_whitespace: 'IGNORE_NONE',
+    };
+    element = await fixture(
+      html`<gr-file-list
+        .patchRange=${patchRange}
+        .diffPrefs=${diffPrefs}
+      ></gr-file-list>`
+    );
+  });
+
+  test('screenshot', async () => {
+    element.files = [
+      ...createFiles(3, {lines_inserted: 9}),
+      ...createFiles(2, {lines_deleted: 14}),
+    ];
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-file-list');
+  });
+});
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
deleted file mode 100644
index ee65837..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ /dev/null
@@ -1,1742 +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 '../../shared/gr-date-formatter/gr-date-formatter.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import './gr-file-list.js';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {FilesExpandedState} from '../gr-file-list-constants.js';
-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 {
-  listenOnce,
-  mockPromise,
-  query,
-  spyRestApi,
-  stubRestApi,
-} from '../../../test/test-utils.js';
-import {EditPatchSetNum} from '../../../types/common.js';
-import {createCommentThreads} from '../../../utils/comment-util.js';
-import {
-  createChange,
-  createChangeComments,
-  createCommit,
-  createParsedChange,
-  createRevision,
-} from '../../../test/test-data-generators.js';
-import {createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {queryAndAssert} from '../../../utils/common-util.js';
-
-const commentApiMock = createCommentApiMockWithTemplateElement(
-    'gr-file-list-comment-api-mock', html`
-    <gr-file-list id="fileList"
-        change-comments="[[_changeComments]]"></gr-file-list>
-`);
-
-const basicFixture = fixtureFromElement(commentApiMock.is);
-
-suite('gr-diff a11y test', () => {
-  test('audit', async () => {
-    await runA11yAudit(basicFixture);
-  });
-});
-
-suite('gr-file-list tests', () => {
-  let element;
-  let commentApiWrapper;
-
-  let saveStub;
-
-  suite('basic tests', () => {
-    setup(async () => {
-      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.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.fileList;
-
-      element._loading = false;
-      element.diffPrefs = {};
-      element.numFilesShown = 200;
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      saveStub = sinon.stub(element, '_saveReviewedState').callsFake(
-          () => Promise.resolve());
-    });
-
-    test('correct number of files are shown', () => {
-      element.fileListIncrement = 300;
-      element._filesByPath = Array(500).fill(0)
-          .reduce((_filesByPath, _, idx) => {
-            _filesByPath['/file' + idx] = {lines_inserted: 9};
-            return _filesByPath;
-          }, {});
-
-      flush();
-      assert.equal(
-          element.root.querySelectorAll('.file-row').length,
-          element.numFilesShown);
-      const controlRow = element.shadowRoot
-          .querySelector('.controlRow');
-      assert.isFalse(controlRow.classList.contains('invisible'));
-      assert.equal(element.$.incrementButton.textContent.trim(),
-          'Show 300 more');
-      assert.equal(element.$.showAllButton.textContent.trim(),
-          'Show all 500 files');
-
-      MockInteractions.tap(element.$.showAllButton);
-      flush();
-
-      assert.equal(element.numFilesShown, 500);
-      assert.equal(element._shownFiles.length, 500);
-      assert.isTrue(controlRow.classList.contains('invisible'));
-    });
-
-    test('rendering each row calls the _reportRenderedRow method', () => {
-      const renderedStub = sinon.stub(element, '_reportRenderedRow');
-      element._filesByPath = Array(10).fill(0)
-          .reduce((_filesByPath, _, idx) => {
-            _filesByPath['/file' + idx] = {lines_inserted: 9};
-            return _filesByPath;
-          }, {});
-      flush();
-      assert.equal(
-          element.root.querySelectorAll('.file-row').length, 10);
-      assert.equal(renderedStub.callCount, 10);
-    });
-
-    test('calculate totals for patch number', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {
-          lines_inserted: 9,
-        },
-        '/MERGE_LIST': {
-          lines_inserted: 9,
-        },
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with a commit message that isn't the first file.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-        '/COMMIT_MSG': {
-          lines_inserted: 9,
-        },
-        '/MERGE_LIST': {
-          lines_inserted: 9,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with no commit message.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-        },
-      };
-
-      assert.deepEqual(element._patchChange, {
-        inserted: 2,
-        deleted: 2,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-
-      // Test with files missing either lines_inserted or lines_deleted.
-      element._filesByPath = {
-        'file_added_in_rev2.txt': {lines_inserted: 1},
-        'myfile.txt': {lines_deleted: 1},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 1,
-        deleted: 1,
-        size_delta_inserted: 0,
-        size_delta_deleted: 0,
-        total_size: 0,
-      });
-      assert.isTrue(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-    });
-
-    test('binary only files', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_binary_1': {binary: true, size_delta: 10, size: 100},
-        'file_binary_2': {binary: true, size_delta: -5, size: 120},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 0,
-        deleted: 0,
-        size_delta_inserted: 10,
-        size_delta_deleted: -5,
-        total_size: 220,
-      });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isTrue(element._hideChangeTotals);
-    });
-
-    test('binary and regular files', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_binary_1': {binary: true, size_delta: 10, size: 100},
-        'file_binary_2': {binary: true, size_delta: -5, size: 120},
-        'myfile.txt': {lines_deleted: 5, size_delta: -10, size: 100},
-        'myfile2.txt': {lines_inserted: 10},
-      };
-      assert.deepEqual(element._patchChange, {
-        inserted: 10,
-        deleted: 5,
-        size_delta_inserted: 10,
-        size_delta_deleted: -5,
-        total_size: 220,
-      });
-      assert.isFalse(element._hideBinaryChangeTotals);
-      assert.isFalse(element._hideChangeTotals);
-    });
-
-    test('_formatBytes function', () => {
-      const table = {
-        '64': '+64 B',
-        '1023': '+1023 B',
-        '1024': '+1 KiB',
-        '4096': '+4 KiB',
-        '1073741824': '+1 GiB',
-        '-64': '-64 B',
-        '-1023': '-1023 B',
-        '-1024': '-1 KiB',
-        '-4096': '-4 KiB',
-        '-1073741824': '-1 GiB',
-        '0': '+/-0 B',
-      };
-      for (const [bytes, expected] of Object.entries(table)) {
-        assert.equal(element._formatBytes(Number(bytes)), expected);
-      }
-    });
-
-    test('_formatPercentage function', () => {
-      const table = [
-        {size: 100,
-          delta: 100,
-          display: '',
-        },
-        {size: 195060,
-          delta: 64,
-          display: '(+0%)',
-        },
-        {size: 195060,
-          delta: -64,
-          display: '(-0%)',
-        },
-        {size: 394892,
-          delta: -7128,
-          display: '(-2%)',
-        },
-        {size: 90,
-          delta: -10,
-          display: '(-10%)',
-        },
-        {size: 110,
-          delta: 10,
-          display: '(+10%)',
-        },
-      ];
-
-      for (const item of table) {
-        assert.equal(element._formatPercentage(
-            item.size, item.delta), item.display);
-      }
-    });
-
-    test('comment filtering', () => {
-      element.changeComments = createChangeComments();
-      const parentTo1 = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-
-      const parentTo2 = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-
-      const _1To2 = {
-        basePatchNum: 1,
-        patchNum: 2,
-      };
-
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, parentTo1
-              , {__path: '/COMMIT_MSG'}), '2c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2
-              , {__path: '/COMMIT_MSG'}), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              {__path: 'unresolved.file'}), '1 draft');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              {__path: 'unresolved.file'}), '1 draft');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              {__path: 'unresolved.file'}), '1d');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              {__path: 'unresolved.file'}), '1d');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo1,
-              {__path: 'myfile.txt'}
-          ), '1c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              {__path: 'myfile.txt'}), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              {__path: 'myfile.txt'}), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              {__path: 'myfile.txt'}), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              {__path: 'myfile.txt'}), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              {__path: 'myfile.txt'}), '');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo1,
-              {__path: 'file_added_in_rev2.txt'}
-          ), '');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              {__path: 'file_added_in_rev2.txt'}), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              {__path: 'file_added_in_rev2.txt'}), '');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              {__path: 'file_added_in_rev2.txt'}), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              {__path: 'file_added_in_rev2.txt'}), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              {__path: 'file_added_in_rev2.txt'}), '');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo2,
-              {__path: '/COMMIT_MSG'}
-          ), '1c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              {__path: '/COMMIT_MSG'}), '3c');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, parentTo1,
-              {__path: '/COMMIT_MSG'}), '2 drafts');
-      assert.equal(
-          element._computeDraftsString(element.changeComments, _1To2,
-              {__path: '/COMMIT_MSG'}), '2 drafts');
-      assert.equal(
-          element._computeDraftsStringMobile(
-              element.changeComments,
-              parentTo1,
-              {__path: '/COMMIT_MSG'}
-          ), '2d');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              {__path: '/COMMIT_MSG'}), '2d');
-      assert.equal(
-          element._computeCommentsStringMobile(
-              element.changeComments,
-              parentTo2,
-              {__path: 'myfile.txt'}
-          ), '2c');
-      assert.equal(
-          element._computeCommentsStringMobile(element.changeComments, _1To2,
-              {__path: 'myfile.txt'}), '3c');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, parentTo2,
-              {__path: 'myfile.txt'}), '');
-      assert.equal(
-          element._computeDraftsStringMobile(element.changeComments, _1To2,
-              {__path: 'myfile.txt'}), '');
-    });
-
-    test('_reviewedTitle', () => {
-      assert.equal(
-          element._reviewedTitle(true), 'Mark as not reviewed (shortcut: r)');
-
-      assert.equal(
-          element._reviewedTitle(false), 'Mark as reviewed (shortcut: r)');
-    });
-
-    suite('keyboard shortcuts', () => {
-      setup(() => {
-        element._filesByPath = {
-          '/COMMIT_MSG': {},
-          'file_added_in_rev2.txt': {},
-          'myfile.txt': {},
-        };
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: 2,
-        };
-        element.change = {_number: 42};
-        element.fileCursor.setCursorAtIndex(0);
-      });
-
-      test('toggle left diff via shortcut', () => {
-        const toggleLeftDiffStub = sinon.stub();
-        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
-        // https://github.com/sinonjs/sinon/issues/781
-        const diffsStub = sinon.stub(element, 'diffs')
-            .get(() => [{toggleLeftDiff: toggleLeftDiffStub}]);
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
-        assert.isTrue(toggleLeftDiffStub.calledOnce);
-        diffsStub.restore();
-      });
-
-      test('keyboard shortcuts', () => {
-        flush();
-
-        const items = [...element.root.querySelectorAll('.file-row')];
-        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, null, 'J');
-        assert.equal(element.fileCursor.index, 0);
-        // down should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
-        assert.equal(element.fileCursor.index, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        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.selectedIndex, 2);
-
-        // k with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'K');
-        assert.equal(element.fileCursor.index, 2);
-
-        // up should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'up');
-        assert.equal(element.fileCursor.index, 2);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.fileCursor.index, 1);
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
-
-        assert(navStub.lastCall.calledWith(element.change,
-            'file_added_in_rev2.txt', 2),
-        'Should navigate to /c/42/2/file_added_in_rev2.txt');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.fileCursor.index, 1);
-        assert.equal(element.selectedIndex, 1);
-
-        const createCommentInPlaceStub = sinon.stub(element.diffCursor,
-            'createCommentInPlace');
-        MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
-        assert.isTrue(createCommentInPlaceStub.called);
-      });
-
-      test('i key shows/hides selected inline diff', () => {
-        const paths = Object.keys(element._filesByPath);
-        sinon.stub(element, '_expandedFilesChanged');
-        flush();
-        const files = [...element.root.querySelectorAll('.file-row')];
-        element.fileCursor.stops = files;
-        element.fileCursor.setCursorAtIndex(0);
-        assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
-        assert.equal(element.diffs.length, 1);
-        assert.equal(element.diffs[0].path, paths[0]);
-        assert.equal(element._expandedFiles.length, 1);
-        assert.equal(element._expandedFiles[0].path, paths[0]);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
-        assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
-
-        element.fileCursor.setCursorAtIndex(1);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-        flush();
-        assert.equal(element.diffs.length, 1);
-        assert.equal(element.diffs[0].path, paths[1]);
-        assert.equal(element._expandedFiles.length, 1);
-        assert.equal(element._expandedFiles[0].path, paths[1]);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-        flush();
-        assert.equal(element.diffs.length, paths.length);
-        assert.equal(element._expandedFiles.length, paths.length);
-        for (const diff of element.diffs) {
-          assert.isTrue(element._expandedFiles.some(f => f.path === diff.path));
-        }
-        // since _expandedFilesChanged is stubbed
-        element.filesExpanded = FilesExpandedState.ALL;
-        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-        flush();
-        assert.equal(element.diffs.length, 0);
-        assert.equal(element._expandedFiles.length, 0);
-      });
-
-      test('r key toggles reviewed flag', () => {
-        const reducer = (accum, file) => (file.isReviewed ? ++accum : accum);
-        const getNumReviewed = () => element._files.reduce(reducer, 0);
-        flush();
-
-        // Default state should be unreviewed.
-        assert.equal(getNumReviewed(), 0);
-
-        // Press the review key to toggle it (set the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        flush();
-        assert.equal(getNumReviewed(), 1);
-
-        // Press the review key to toggle it (clear the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.equal(getNumReviewed(), 0);
-      });
-
-      suite('handleOpenFile', () => {
-        let interact;
-
-        setup(() => {
-          const openCursorStub = sinon.stub(element, '_openCursorFile');
-          const openSelectedStub = sinon.stub(element, '_openSelectedFile');
-          const expandStub = sinon.stub(element, '_toggleFileExpanded');
-
-          interact = function() {
-            openCursorStub.reset();
-            openSelectedStub.reset();
-            expandStub.reset();
-            element.handleOpenFile();
-            const result = {};
-            if (openCursorStub.called) {
-              result.opened_cursor = true;
-            }
-            if (openSelectedStub.called) {
-              result.opened_selected = true;
-            }
-            if (expandStub.called) {
-              result.expanded = true;
-            }
-            return result;
-          };
-        });
-
-        test('open from selected file', () => {
-          element.filesExpanded = FilesExpandedState.NONE;
-          assert.deepEqual(interact(), {opened_selected: true});
-        });
-
-        test('open from diff cursor', () => {
-          element.filesExpanded = FilesExpandedState.ALL;
-          assert.deepEqual(interact(), {opened_cursor: true});
-        });
-
-        test('expand when user prefers', () => {
-          element.filesExpanded = FilesExpandedState.NONE;
-          assert.deepEqual(interact(), {opened_selected: true});
-          element._userPrefs = {};
-          assert.deepEqual(interact(), {opened_selected: true});
-        });
-      });
-
-      test('shift+left/shift+right', () => {
-        const moveLeftStub = sinon.stub(element.diffCursor, 'moveLeft');
-        const moveRightStub = sinon.stub(element.diffCursor, 'moveRight');
-
-        let noDiffsExpanded = true;
-        sinon.stub(element, '_noDiffsExpanded')
-            .callsFake(() => noDiffsExpanded);
-
-        MockInteractions.pressAndReleaseKeyOn(
-            element, 73, 'shift', 'ArrowLeft');
-        assert.isFalse(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(
-            element, 73, 'shift', 'ArrowRight');
-        assert.isFalse(moveRightStub.called);
-
-        noDiffsExpanded = false;
-
-        MockInteractions.pressAndReleaseKeyOn(
-            element, 73, 'shift', 'ArrowLeft');
-        assert.isTrue(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(
-            element, 73, 'shift', 'ArrowRight');
-        assert.isTrue(moveRightStub.called);
-      });
-    });
-
-    test('file review status', () => {
-      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'file_added_in_rev2.txt': {},
-        'myfile.txt': {},
-      };
-      element._loggedIn = true;
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element.fileCursor.setCursorAtIndex(0);
-      const reviewSpy = sinon.spy(element, '_reviewFile');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
-
-      flush();
-      const fileRows =
-          element.root.querySelectorAll('.row:not(.header-row)');
-      const checkSelector = 'span.reviewedSwitch[role="switch"]';
-      const commitMsg = fileRows[0].querySelector(checkSelector);
-      const fileAdded = fileRows[1].querySelector(checkSelector);
-      const myFile = fileRows[2].querySelector(checkSelector);
-
-      assert.equal(commitMsg.getAttribute('aria-checked'), 'true');
-      assert.equal(fileAdded.getAttribute('aria-checked'), 'false');
-      assert.equal(myFile.getAttribute('aria-checked'), 'true');
-
-      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
-      const markReviewLabel = fileRows[0].querySelector('.markReviewed');
-      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
-
-      const clickSpy = sinon.spy(element, '_reviewedClick');
-      MockInteractions.tap(markReviewLabel);
-      // assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
-      // assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
-      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
-      assert.isTrue(reviewSpy.calledOnce);
-
-      MockInteractions.tap(markReviewLabel);
-      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
-      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
-      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
-      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
-      assert.isTrue(reviewSpy.calledTwice);
-
-      assert.isFalse(toggleExpandSpy.called);
-    });
-
-    test('_handleFileListClick', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'f1.txt': {},
-        'f2.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-
-      const clickSpy = sinon.spy(element, '_handleFileListClick');
-      const reviewStub = sinon.stub(element, '_reviewFile');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
-
-      const row = dom(element.root)
-          .querySelector(`.row[data-file='{"path":"f1.txt"}']`);
-
-      // Click on the expand button, resulting in _toggleFileExpanded being
-      // called and not resulting in a call to _reviewFile.
-      row.querySelector('div.show-hide').click();
-      assert.isTrue(clickSpy.calledOnce);
-      assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isFalse(reviewStub.called);
-
-      // Click inside the diff. This should result in no additional calls to
-      // _toggleFileExpanded or _reviewFile.
-      element.root.querySelector('gr-diff-host')
-          .click();
-      assert.isTrue(clickSpy.calledTwice);
-      assert.isTrue(toggleExpandSpy.calledOnce);
-      assert.isFalse(reviewStub.called);
-    });
-
-    test('_handleFileListClick editMode', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-        'f1.txt': {},
-        'f2.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element.editMode = true;
-      flush();
-      const clickSpy = sinon.spy(element, '_handleFileListClick');
-      const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
-
-      // Tap the edit controls. Should be ignored by _handleFileListClick.
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.editFileControls'));
-      assert.isTrue(clickSpy.calledOnce);
-      assert.isFalse(toggleExpandSpy.called);
-    });
-
-    test('checkbox shows/hides diff inline', () => {
-      element._filesByPath = {
-        'myfile.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element.fileCursor.setCursorAtIndex(0);
-      sinon.stub(element, '_expandedFilesChanged');
-      flush();
-      const fileRows =
-          element.root.querySelectorAll('.row:not(.header-row)');
-      // Because the label surrounds the input, the tap event is triggered
-      // there first.
-      const showHideCheck = fileRows[0].querySelector(
-          'span.show-hide[role="switch"]');
-      const showHideLabel = showHideCheck.querySelector('.show-hide-icon');
-      assert.equal(showHideCheck.getAttribute('aria-checked'), 'false');
-      MockInteractions.tap(showHideLabel);
-      assert.equal(showHideCheck.getAttribute('aria-checked'), 'true');
-      assert.notEqual(
-          element._expandedFiles.findIndex(f => f.path === 'myfile.txt'),
-          -1);
-    });
-
-    test('diff mode correctly toggles the diffs', () => {
-      element._filesByPath = {
-        'myfile.txt': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      sinon.spy(element, '_updateDiffPreferences');
-      element.fileCursor.setCursorAtIndex(0);
-      flush();
-
-      // Tap on a file to generate the diff.
-      const row = dom(element.root)
-          .querySelectorAll('.row:not(.header-row) span.show-hide')[0];
-
-      MockInteractions.tap(row);
-      flush();
-      element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
-      element.set('diffViewMode', 'UNIFIED_DIFF');
-      assert.isTrue(element._updateDiffPreferences.called);
-    });
-
-    test('expanded attribute not set on path when not expanded', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-      };
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.expanded'));
-    });
-
-    test('tapping row ignores links', () => {
-      element._filesByPath = {
-        '/COMMIT_MSG': {},
-      };
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      sinon.stub(element, '_expandedFilesChanged');
-      flush();
-      const commitMsgFile = dom(element.root)
-          .querySelectorAll('.row:not(.header-row) a.pathLink')[0];
-
-      // Remove href attribute so the app doesn't route to a diff view
-      commitMsgFile.removeAttribute('href');
-      const togglePathSpy = sinon.spy(element, '_toggleFileExpanded');
-
-      MockInteractions.tap(commitMsgFile);
-      flush();
-      assert(togglePathSpy.notCalled, 'file is opened as diff view');
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.expanded'));
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.show-hide')).display,
-      'none');
-    });
-
-    test('_toggleFileExpanded', () => {
-      const path = 'path/to/my/file.txt';
-      element._filesByPath = {[path]: {}};
-      const renderSpy = sinon.spy(element, '_renderInOrder');
-      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
-
-      assert.equal(element.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
-      assert.equal(element._expandedFiles.length, 0);
-      element._toggleFileExpanded({path});
-      flush();
-      assert.equal(collapseStub.lastCall.args[0].length, 0);
-      assert.equal(element.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-less');
-
-      assert.equal(renderSpy.callCount, 1);
-      assert.isTrue(element._expandedFiles.some(f => f.path === path));
-      element._toggleFileExpanded({path});
-      flush();
-
-      assert.equal(element.shadowRoot
-          .querySelector('iron-icon').icon, 'gr-icons:expand-more');
-      assert.equal(renderSpy.callCount, 1);
-      assert.isFalse(element._expandedFiles.some(f => f.path === path));
-      assert.equal(collapseStub.lastCall.args[0].length, 1);
-    });
-
-    test('expandAllDiffs and collapseAllDiffs', () => {
-      const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
-      const cursorUpdateStub = sinon.stub(element.diffCursor,
-          'handleDiffUpdate');
-      const reInitStub = sinon.stub(element.diffCursor,
-          'reInitAndUpdateStops');
-
-      const path = 'path/to/my/file.txt';
-      element._filesByPath = {[path]: {}};
-      element.expandAllDiffs();
-      flush();
-      assert.equal(element.filesExpanded, FilesExpandedState.ALL);
-      assert.isTrue(reInitStub.calledOnce);
-      assert.equal(collapseStub.lastCall.args[0].length, 0);
-
-      element.collapseAllDiffs();
-      flush();
-      assert.equal(element._expandedFiles.length, 0);
-      assert.equal(element.filesExpanded, FilesExpandedState.NONE);
-      assert.isTrue(cursorUpdateStub.calledOnce);
-      assert.equal(collapseStub.lastCall.args[0].length, 1);
-    });
-
-    test('_expandedFilesChanged', async () => {
-      sinon.stub(element, '_reviewFile');
-      const path = 'path/to/my/file.txt';
-      const promise = mockPromise();
-      const diffs = [{
-        path,
-        style: {},
-        reload() {
-          promise.resolve();
-        },
-        prefetchDiff() {},
-        cancel() {},
-        getCursorStops() { return []; },
-        addEventListener(eventName, callback) {
-          if (['render-start', 'render-content', 'scroll']
-              .indexOf(eventName) >= 0) {
-            callback(new Event(eventName));
-          }
-        },
-      }];
-      sinon.stub(element, 'diffs').get(() => diffs);
-      element.push('_expandedFiles', {path});
-      await promise;
-    });
-
-    test('_clearCollapsedDiffs', () => {
-      const diff = {
-        cancel: sinon.stub(),
-        clearDiffContent: sinon.stub(),
-      };
-      element._clearCollapsedDiffs([diff]);
-      assert.isTrue(diff.cancel.calledOnce);
-      assert.isTrue(diff.clearDiffContent.calledOnce);
-    });
-
-    test('filesExpanded value updates to correct enum', () => {
-      element._filesByPath = {
-        'foo.bar': {},
-        'baz.bar': {},
-      };
-      flush();
-      assert.equal(element.filesExpanded,
-          FilesExpandedState.NONE);
-      element.push('_expandedFiles', {path: 'baz.bar'});
-      flush();
-      assert.equal(element.filesExpanded,
-          FilesExpandedState.SOME);
-      element.push('_expandedFiles', {path: 'foo.bar'});
-      flush();
-      assert.equal(element.filesExpanded,
-          FilesExpandedState.ALL);
-      element.collapseAllDiffs();
-      flush();
-      assert.equal(element.filesExpanded,
-          FilesExpandedState.NONE);
-      element.expandAllDiffs();
-      flush();
-      assert.equal(element.filesExpanded,
-          FilesExpandedState.ALL);
-    });
-
-    test('_renderInOrder', async () => {
-      const reviewStub = sinon.stub(element, '_reviewFile');
-      let callCount = 0;
-      const diffs = [{
-        path: 'p0',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(callCount++, 2);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p1',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(callCount++, 1);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p2',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(callCount++, 0);
-          return Promise.resolve();
-        },
-      }];
-      element._renderInOrder([
-        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3);
-      await flush();
-      assert.isFalse(reviewStub.called);
-    });
-
-    test('_renderInOrder logged in', async () => {
-      element._loggedIn = true;
-      const reviewStub = sinon.stub(element, '_reviewFile');
-      let callCount = 0;
-      const diffs = [{
-        path: 'p0',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(reviewStub.callCount, 2);
-          assert.equal(callCount++, 2);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p1',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(reviewStub.callCount, 1);
-          assert.equal(callCount++, 1);
-          return Promise.resolve();
-        },
-      }, {
-        path: 'p2',
-        style: {},
-        prefetchDiff() {},
-        reload() {
-          assert.equal(reviewStub.callCount, 0);
-          assert.equal(callCount++, 0);
-          return Promise.resolve();
-        },
-      }];
-      element._renderInOrder([
-        {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3);
-      await flush();
-      assert.equal(reviewStub.callCount, 3);
-    });
-
-    test('_renderInOrder respects diffPrefs.manual_review', async () => {
-      element._loggedIn = true;
-      element.diffPrefs = {manual_review: true};
-      const reviewStub = sinon.stub(element, '_reviewFile');
-      const diffs = [{
-        path: 'p',
-        style: {},
-        prefetchDiff() {},
-        reload() { return Promise.resolve(); },
-      }];
-
-      element._renderInOrder([{path: 'p'}], diffs, 1);
-      await flush();
-      assert.isFalse(reviewStub.called);
-      delete element.diffPrefs.manual_review;
-      element._renderInOrder([{path: 'p'}], diffs, 1);
-      await flush();
-      assert.isTrue(reviewStub.called);
-      assert.isTrue(reviewStub.calledWithExactly('p', true));
-    });
-
-    test('_loadingChanged fired from reload in debouncer', async () => {
-      const reloadBlocker = mockPromise();
-      stubRestApi('getChangeOrEditFiles').resolves({'foo.bar': {}});
-      stubRestApi('getReviewedFiles').resolves(null);
-      stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
-      stubRestApi('getLoggedIn').returns(reloadBlocker.then(() => false));
-
-      element.changeNum = 123;
-      element.patchRange = {patchNum: 12};
-      element._filesByPath = {'foo.bar': {}};
-      element.change = {...createParsedChange(), _number: 123};
-
-      const reloaded = element.reload();
-      assert.isTrue(element._loading);
-      assert.isFalse(element.classList.contains('loading'));
-      element.loadingTask.flush();
-      assert.isTrue(element.classList.contains('loading'));
-
-      reloadBlocker.resolve();
-      await reloaded;
-
-      assert.isFalse(element._loading);
-      element.loadingTask.flush();
-      assert.isFalse(element.classList.contains('loading'));
-    });
-
-    test('_loadingChanged does not set class when there are no files', () => {
-      const reloadBlocker = mockPromise();
-      stubRestApi('getLoggedIn').returns(reloadBlocker.then(() => false));
-      sinon.stub(element, '_getReviewedFiles').resolves([]);
-      element.changeNum = 123;
-      element.patchRange = {patchNum: 12};
-      element.change = {...createParsedChange(), _number: 123};
-      element.reload();
-
-      assert.isTrue(element._loading);
-
-      element.loadingTask.flush();
-
-      assert.isFalse(element.classList.contains('loading'));
-    });
-
-    suite('for merge commits', () => {
-      let filesStub;
-
-      setup(async () => {
-        filesStub = stubRestApi('getChangeOrEditFiles')
-            .onFirstCall()
-            .resolves({'conflictingFile.js': {}})
-            .onSecondCall()
-            .resolves({'conflictingFile.js': {}, 'cleanlyMergedFile.js': {}});
-        stubRestApi('getReviewedFiles').resolves([]);
-        stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
-        const changeWithMultipleParents = {
-          ...createChange(),
-          revisions: {
-            r1: {
-              ...createRevision(),
-              commit: {
-                ...createCommit(),
-                parents: [
-                  {commit: 'p1', subject: 'subject1'},
-                  {commit: 'p2', subject: 'subject2'},
-                ],
-              },
-            },
-          },
-        };
-        element.changeNum = changeWithMultipleParents._number;
-        element.change = changeWithMultipleParents;
-        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-        await flush();
-      });
-
-      test('displays cleanly merged file count', async () => {
-        await element.reload();
-        await flush();
-
-        const message = queryAndAssert(element, '.cleanlyMergedText')
-            .textContent.trim();
-        assert.equal(message, '1 file merged cleanly in Parent 1');
-      });
-
-      test('displays plural cleanly merged file count', async () => {
-        filesStub.restore();
-        stubRestApi('getChangeOrEditFiles')
-            .onFirstCall()
-            .resolves({'conflictingFile.js': {}})
-            .onSecondCall()
-            .resolves({
-              'conflictingFile.js': {},
-              'cleanlyMergedFile.js': {},
-              'anotherCleanlyMergedFile.js': {},
-            });
-        await element.reload();
-        await flush();
-
-        const message = queryAndAssert(
-            element,
-            '.cleanlyMergedText'
-        ).textContent.trim();
-        assert.equal(message, '2 files merged cleanly in Parent 1');
-      });
-
-      test('displays button for navigating to parent 1 base', async () => {
-        await element.reload();
-        await flush();
-
-        queryAndAssert(element, '.showParentButton');
-      });
-
-      test('computes old paths for cleanly merged files', async () => {
-        filesStub.restore();
-        stubRestApi('getChangeOrEditFiles')
-            .onFirstCall()
-            .resolves({'conflictingFile.js': {}})
-            .onSecondCall()
-            .resolves({
-              'conflictingFile.js': {},
-              'cleanlyMergedFile.js': {old_path: 'cleanlyMergedFileOldName.js'},
-            });
-        await element.reload();
-        await flush();
-
-        assert.deepEqual(element._cleanlyMergedOldPaths, [
-          'cleanlyMergedFileOldName.js',
-        ]);
-      });
-
-      test('not shown for non-Auto Merge base parents', async () => {
-        element.patchRange = {basePatchNum: 1, patchNum: 2};
-        await element.reload();
-        await flush();
-
-        assert.notOk(query(element, '.cleanlyMergedText'));
-        assert.notOk(query(element, '.showParentButton'));
-      });
-
-      test('not shown in edit mode', async () => {
-        element.patchRange = {basePatchNum: 1, patchNum: EditPatchSetNum};
-        await element.reload();
-        await flush();
-
-        assert.notOk(query(element, '.cleanlyMergedText'));
-        assert.notOk(query(element, '.showParentButton'));
-      });
-    });
-  });
-
-  suite('diff url file list', () => {
-    test('diff url', () => {
-      const diffStub = sinon.stub(GerritNav, 'getUrlForDiff')
-          .returns('/c/gerrit/+/1/1/index.php');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = 'index.php';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, path, false),
-          '/c/gerrit/+/1/1/index.php');
-      diffStub.restore();
-    });
-
-    test('diff url commit msg', () => {
-      const diffStub = sinon.stub(GerritNav, 'getUrlForDiff')
-          .returns('/c/gerrit/+/1/1//COMMIT_MSG');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = '/COMMIT_MSG';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, path, false),
-          '/c/gerrit/+/1/1//COMMIT_MSG');
-      diffStub.restore();
-    });
-
-    test('edit url', () => {
-      const editStub = sinon.stub(GerritNav, 'getEditUrlForDiff')
-          .returns('/c/gerrit/+/1/edit/index.php,edit');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = 'index.php';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, path, true),
-          '/c/gerrit/+/1/edit/index.php,edit');
-      editStub.restore();
-    });
-
-    test('edit url commit msg', () => {
-      const editStub = sinon.stub(GerritNav, 'getEditUrlForDiff')
-          .returns('/c/gerrit/+/1/edit//COMMIT_MSG,edit');
-      const change = {
-        _number: 1,
-        project: 'gerrit',
-      };
-      const path = '/COMMIT_MSG';
-      const patchRange = {
-        patchNum: 1,
-      };
-      assert.equal(
-          element._computeDiffURL(change, patchRange, path, true),
-          '/c/gerrit/+/1/edit//COMMIT_MSG,edit');
-      editStub.restore();
-    });
-  });
-
-  suite('size bars', () => {
-    test('_computeSizeBarLayout', () => {
-      const defaultSizeBarLayout = {
-        maxInserted: 0,
-        maxDeleted: 0,
-        maxAdditionWidth: 0,
-        maxDeletionWidth: 0,
-        deletionOffset: 0,
-      };
-
-      assert.deepEqual(
-          element._computeSizeBarLayout(null),
-          defaultSizeBarLayout);
-      assert.deepEqual(
-          element._computeSizeBarLayout({}),
-          defaultSizeBarLayout);
-      assert.deepEqual(
-          element._computeSizeBarLayout({base: []}),
-          defaultSizeBarLayout);
-
-      const files = [
-        {__path: '/COMMIT_MSG', lines_inserted: 10000},
-        {__path: 'foo', lines_inserted: 4, lines_deleted: 10},
-        {__path: 'bar', lines_inserted: 5, lines_deleted: 8},
-      ];
-      const layout = element._computeSizeBarLayout({base: files});
-      assert.equal(layout.maxInserted, 5);
-      assert.equal(layout.maxDeleted, 10);
-    });
-
-    test('_computeBarAdditionWidth', () => {
-      const file = {
-        __path: 'foo/bar.baz',
-        lines_inserted: 5,
-        lines_deleted: 0,
-      };
-      const stats = {
-        maxInserted: 10,
-        maxDeleted: 0,
-        maxAdditionWidth: 60,
-        maxDeletionWidth: 0,
-        deletionOffset: 60,
-      };
-
-      // Uses half the space when file is half the largest addition and there
-      // are no deletions.
-      assert.equal(element._computeBarAdditionWidth(file, stats), 30);
-
-      // If there are no insertions, there is no width.
-      stats.maxInserted = 0;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
-
-      // If the insertions is not present on the file, there is no width.
-      stats.maxInserted = 10;
-      file.lines_inserted = undefined;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
-
-      // If the file is a commit message, returns zero.
-      file.lines_inserted = 5;
-      file.__path = '/COMMIT_MSG';
-      assert.equal(element._computeBarAdditionWidth(file, stats), 0);
-
-      // Width bottoms-out at the minimum width.
-      file.__path = 'stuff.txt';
-      file.lines_inserted = 1;
-      stats.maxInserted = 1000000;
-      assert.equal(element._computeBarAdditionWidth(file, stats), 1.5);
-    });
-
-    test('_computeBarAdditionX', () => {
-      const file = {
-        __path: 'foo/bar.baz',
-        lines_inserted: 5,
-        lines_deleted: 0,
-      };
-      const stats = {
-        maxInserted: 10,
-        maxDeleted: 0,
-        maxAdditionWidth: 60,
-        maxDeletionWidth: 0,
-        deletionOffset: 60,
-      };
-      assert.equal(element._computeBarAdditionX(file, stats), 30);
-    });
-
-    test('_computeBarDeletionWidth', () => {
-      const file = {
-        __path: 'foo/bar.baz',
-        lines_inserted: 0,
-        lines_deleted: 5,
-      };
-      const stats = {
-        maxInserted: 10,
-        maxDeleted: 10,
-        maxAdditionWidth: 30,
-        maxDeletionWidth: 30,
-        deletionOffset: 31,
-      };
-
-      // Uses a quarter the space when file is half the largest deletions and
-      // there are equal additions.
-      assert.equal(element._computeBarDeletionWidth(file, stats), 15);
-
-      // If there are no deletions, there is no width.
-      stats.maxDeleted = 0;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
-
-      // If the deletions is not present on the file, there is no width.
-      stats.maxDeleted = 10;
-      file.lines_deleted = undefined;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
-
-      // If the file is a commit message, returns zero.
-      file.lines_deleted = 5;
-      file.__path = '/COMMIT_MSG';
-      assert.equal(element._computeBarDeletionWidth(file, stats), 0);
-
-      // Width bottoms-out at the minimum width.
-      file.__path = 'stuff.txt';
-      file.lines_deleted = 1;
-      stats.maxDeleted = 1000000;
-      assert.equal(element._computeBarDeletionWidth(file, stats), 1.5);
-    });
-
-    test('_computeSizeBarsClass', () => {
-      assert.equal(element._computeSizeBarsClass(false, 'foo/bar.baz'),
-          'sizeBars desktop hide');
-      assert.equal(element._computeSizeBarsClass(true, '/COMMIT_MSG'),
-          'sizeBars desktop invisible');
-      assert.equal(element._computeSizeBarsClass(true, 'foo/bar.baz'),
-          'sizeBars desktop ');
-    });
-  });
-
-  suite('gr-file-list inline diff tests', () => {
-    let element;
-
-    const commitMsgComments = [
-      {
-        patch_set: 2,
-        path: '/p',
-        id: 'ecf0b9fa_fe1a5f62',
-        line: 20,
-        updated: '2018-02-08 18:49:18.000000000',
-        message: 'another comment',
-        unresolved: true,
-      },
-      {
-        patch_set: 2,
-        path: '/p',
-        id: '503008e2_0ab203ee',
-        line: 10,
-        updated: '2018-02-14 22:07:43.000000000',
-        message: 'a comment',
-        unresolved: true,
-      },
-      {
-        patch_set: 2,
-        path: '/p',
-        id: 'cc788d2c_cb1d728c',
-        line: 20,
-        in_reply_to: 'ecf0b9fa_fe1a5f62',
-        updated: '2018-02-13 22:07:43.000000000',
-        message: 'response',
-        unresolved: true,
-      },
-    ];
-
-    async function setupDiff(diff) {
-      diff.threads = diff.path === '/COMMIT_MSG' ?
-        createCommentThreads(commitMsgComments) : [];
-      diff.prefs = {
-        context: 10,
-        tab_size: 8,
-        font_size: 12,
-        line_length: 100,
-        cursor_blink_rate: 0,
-        line_wrapping: false,
-        show_line_endings: true,
-        show_tabs: true,
-        show_whitespace_errors: true,
-        syntax_highlighting: true,
-        theme: 'DEFAULT',
-        ignore_whitespace: 'IGNORE_NONE',
-      };
-      diff.diff = getMockDiffResponse();
-      sinon.stub(diff.changeComments, 'getCommentsForPath')
-          .withArgs('/COMMIT_MSG', {
-            basePatchNum: 'PARENT',
-            patchNum: 2,
-          })
-          .returns(diff.comments);
-      await listenOnce(diff, 'render');
-    }
-
-    async function renderAndGetNewDiffs(index) {
-      const diffs =
-          element.root.querySelectorAll('gr-diff-host');
-
-      for (let i = index; i < diffs.length; i++) {
-        await setupDiff(diffs[i]);
-      }
-
-      element._updateDiffCursor();
-      element.diffCursor.handleDiffUpdate();
-      return diffs;
-    }
-
-    setup(async () => {
-      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.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.fileList;
-      element.diffPrefs = {};
-      element.change = {_number: 42, project: 'testRepo'};
-      sinon.stub(element, '_reviewFile');
-
-      element._loading = false;
-      element.numFilesShown = 75;
-      element.selectedIndex = 0;
-      element._filesByPath = {
-        '/COMMIT_MSG': {lines_inserted: 9},
-        'file_added_in_rev2.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-        'myfile.txt': {
-          lines_inserted: 1,
-          lines_deleted: 1,
-          size_delta: 10,
-          size: 100,
-        },
-      };
-      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
-      element._loggedIn = true;
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      sinon.stub(window, 'fetch').callsFake(() => Promise.resolve());
-      await flush();
-    });
-
-    test('cursor with individually opened files', async () => {
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-      await flush();
-      let diffs = await renderAndGetNewDiffs(0);
-      const diffStops = diffs[0].getCursorStops();
-
-      // 1 diff should be rendered.
-      assert.equal(diffs.length, 1);
-
-      // No line number is selected.
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-
-      // Tapping content on a line selects the line number.
-      MockInteractions.tap(dom(
-          diffStops[10]).querySelectorAll('.contentText')[0]);
-      await flush();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-
-      // Keyboard shortcuts are still moving the file cursor, not the diff
-      // cursor.
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      await flush();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-      assert.isFalse(diffStops[11].classList.contains('target-row'));
-
-      // The file cursor is now at 1.
-      assert.equal(element.fileCursor.index, 1);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
-      await flush();
-      diffs = await renderAndGetNewDiffs(1);
-
-      // Two diffs should be rendered.
-      assert.equal(diffs.length, 2);
-      const diffStopsFirst = diffs[0].getCursorStops();
-      const diffStopsSecond = diffs[1].getCursorStops();
-
-      // The line on the first diff is still selected
-      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
-      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
-    });
-
-    test('cursor with toggle all files', async () => {
-      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
-      await flush();
-
-      const diffs = await renderAndGetNewDiffs(0);
-      const diffStops = diffs[0].getCursorStops();
-
-      // 1 diff should be rendered.
-      assert.equal(diffs.length, 3);
-
-      // No line number is selected.
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-
-      // Tapping content on a line selects the line number.
-      MockInteractions.tap(dom(
-          diffStops[10]).querySelectorAll('.contentText')[0]);
-      await flush();
-      assert.isTrue(diffStops[10].classList.contains('target-row'));
-
-      // Keyboard shortcuts are still moving the file cursor, not the diff
-      // cursor.
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      await flush();
-      assert.isFalse(diffStops[10].classList.contains('target-row'));
-      assert.isTrue(diffStops[11].classList.contains('target-row'));
-
-      // The file cursor is still at 0.
-      assert.equal(element.fileCursor.index, 0);
-    });
-
-    suite('n key presses', () => {
-      let nextCommentStub;
-      let nextChunkStub;
-      let fileRows;
-
-      setup(() => {
-        sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
-        nextCommentStub = sinon.stub(element.diffCursor,
-            'moveToNextCommentThread');
-        nextChunkStub = sinon.stub(element.diffCursor,
-            'moveToNextChunk');
-        fileRows =
-            element.root.querySelectorAll('.row:not(.header-row)');
-      });
-
-      test('n key with some files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
-        await flush();
-        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nextChunkStub.calledOnce);
-      });
-
-      test('N key with some files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
-        await flush();
-        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
-        assert.isTrue(nextCommentStub.calledOnce);
-      });
-
-      test('n key with all files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
-        await flush();
-        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nextChunkStub.calledOnce);
-      });
-
-      test('N key with all files expanded', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
-        await flush();
-        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
-        assert.isTrue(nextCommentStub.called);
-      });
-    });
-
-    test('_openSelectedFile behavior', async () => {
-      const _filesByPath = element._filesByPath;
-      element.set('_filesByPath', {});
-      const navStub = sinon.stub(GerritNav, 'navigateToDiff');
-      // Noop when there are no files.
-      element._openSelectedFile();
-      assert.isFalse(navStub.called);
-
-      element.set('_filesByPath', _filesByPath);
-      await flush();
-      // Navigates when a file is selected.
-      element._openSelectedFile();
-      assert.isTrue(navStub.called);
-    });
-
-    test('_displayLine', () => {
-      element.filesExpanded = FilesExpandedState.ALL;
-
-      element._displayLine = false;
-      element._handleCursorNext(new KeyboardEvent('keydown'));
-      assert.isTrue(element._displayLine);
-
-      element._displayLine = false;
-      element._handleCursorPrev(new KeyboardEvent('keydown'));
-      assert.isTrue(element._displayLine);
-
-      element._displayLine = true;
-      element._handleEscKey();
-      assert.isFalse(element._displayLine);
-    });
-
-    suite('editMode behavior', () => {
-      test('reviewed checkbox', async () => {
-        element._reviewFile.restore();
-        const saveReviewStub = sinon.stub(element, '_saveReviewedState');
-
-        element.editMode = false;
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.isTrue(saveReviewStub.calledOnce);
-
-        element.editMode = true;
-        await flush();
-
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-        assert.isTrue(saveReviewStub.calledOnce);
-      });
-
-      test('_getReviewedFiles does not call API', () => {
-        const apiSpy = spyRestApi('getReviewedFiles');
-        element.editMode = true;
-        return element._getReviewedFiles().then(files => {
-          assert.equal(files.length, 0);
-          assert.isFalse(apiSpy.called);
-        });
-      });
-    });
-
-    test('editing actions', async () => {
-      // Edit controls are guarded behind a dom-if initially and not rendered.
-      assert.isNotOk(dom(element.root)
-          .querySelector('gr-edit-file-controls'));
-
-      element.editMode = true;
-      await flush();
-
-      // Commit message should not have edit controls.
-      const editControls =
-          Array.from(
-              dom(element.root)
-                  .querySelectorAll('.row:not(.header-row)'))
-              .map(row => row.querySelector('gr-edit-file-controls'));
-      assert.isTrue(editControls[0].classList.contains('invisible'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
new file mode 100644
index 0000000..1fbddc5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -0,0 +1,2357 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import './gr-file-list';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  mockPromise,
+  query,
+  stubRestApi,
+  waitUntil,
+  pressKey,
+  stubElement,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {
+  BasePatchSetNum,
+  CommitId,
+  EDIT,
+  NumericChangeId,
+  PARENT,
+  RepoName,
+  RevisionPatchSetNum,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {createCommentThreads} from '../../../utils/comment-util';
+import {
+  createChangeComments,
+  createCommit,
+  createDiff,
+  createParsedChange,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {
+  createDefaultDiffPrefs,
+  DiffViewMode,
+} from '../../../constants/constants';
+import {
+  assertIsDefined,
+  queryAll,
+  queryAndAssert,
+} from '../../../utils/common-util';
+import {GrFileList, NormalizedFileInfo} from './gr-file-list';
+import {FileInfo, PatchSetNumber} from '../../../api/rest-api';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {ParsedChangeInfo} from '../../../types/types';
+import {normalize} from '../../../models/change/files-model';
+import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
+import {GrEditFileControls} from '../../edit/gr-edit-file-controls/gr-edit-file-controls';
+import {GrIcon} from '../../shared/gr-icon/gr-icon';
+import {fixture, html, assert} from '@open-wc/testing';
+import {Modifier} from '../../../utils/dom-util';
+import {testResolver} from '../../../test/common-test-setup';
+import {FileMode} from '../../../utils/file-util';
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    assert.isAccessible(await fixture(html`<gr-file-list></gr-file-list>`));
+  });
+});
+
+function createFiles(
+  count: number,
+  fileInfo: FileInfo = {}
+): NormalizedFileInfo[] {
+  const files = Array(count).fill({});
+  return files.map((_, idx) => normalize(fileInfo, `path/file${idx}`));
+}
+
+suite('gr-file-list tests', () => {
+  let element: GrFileList;
+
+  let saveStub: sinon.SinonStub;
+
+  suite('basic tests', async () => {
+    setup(async () => {
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
+      stubElement('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
+        Promise.resolve()
+      );
+      stubElement('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
+      stubElement('gr-diff-host', 'prefetchDiff').callsFake(() => {});
+
+      element = await fixture(html`<gr-file-list></gr-file-list>`);
+
+      element.diffPrefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        ignore_whitespace: 'IGNORE_NONE',
+      };
+      element.numFilesShown = 200;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      saveStub = sinon
+        .stub(element, '_saveReviewedState')
+        .callsFake(() => Promise.resolve());
+      await element.updateComplete;
+      element.showSizeBars = true;
+      // Wait for expandedFilesChanged to complete.
+      await waitEventLoop();
+    });
+
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<h3 class="assistive-tech-only">File list</h3>
+          <div aria-label="Files list" id="container" role="grid">
+            <div class="header-row row" role="row">
+              <div class="status" role="gridcell"></div>
+              <div class="path" role="columnheader">File</div>
+              <div class="comments desktop" role="columnheader">Comments</div>
+              <div class="comments mobile" role="columnheader" title="Comments">
+                C
+              </div>
+              <div class="desktop sizeBars" role="columnheader">Size</div>
+              <div class="header-stats" role="columnheader">Delta</div>
+              <div aria-hidden="true" class="hideOnEdit reviewed"></div>
+              <div aria-hidden="true" class="editFileControls showOnEdit"></div>
+              <div aria-hidden="true" class="show-hide"></div>
+            </div>
+          </div>
+          <div class="controlRow invisible row">
+            <gr-button
+              aria-disabled="false"
+              class="fileListButton"
+              id="incrementButton"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Show -200 more
+            </gr-button>
+            <gr-tooltip-content title="">
+              <gr-button
+                aria-disabled="false"
+                class="fileListButton"
+                id="showAllButton"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Show all 0 files
+              </gr-button>
+            </gr-tooltip-content>
+          </div>
+          <gr-diff-preferences-dialog
+            id="diffPreferencesDialog"
+          ></gr-diff-preferences-dialog>`
+      );
+    });
+
+    test('renders file row', async () => {
+      element.files = createFiles(1, {lines_inserted: 9});
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+      assert.dom.equal(
+        fileRows?.[0],
+        /* HTML */ `<div
+          class="file-row row"
+          data-file='{"path":"path/file0"}'
+          role="row"
+          tabindex="-1"
+          aria-label="path/file0"
+        >
+          <div class="status" role="gridcell">
+            <gr-file-status></gr-file-status>
+          </div>
+          <span class="path" role="gridcell">
+            <a class="pathLink">
+              <span class="fullFileName" title="path/file0">
+                <span class="newFilePath"> path/ </span>
+                <span class="fileName"> file0 </span>
+              </span>
+              <span class="truncatedFileName" title="path/file0">
+                …/file0
+              </span>
+              <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+            </a>
+          </span>
+          <div role="gridcell">
+            <div class="comments desktop">
+              <span class="drafts"> </span> <span> </span>
+              <span class="noCommentsScreenReaderText"> No comments </span>
+            </div>
+            <div class="comments mobile">
+              <span class="drafts"> </span> <span> </span>
+              <span class="noCommentsScreenReaderText"> No comments </span>
+            </div>
+          </div>
+          <div class="desktop" role="gridcell">
+            <div aria-hidden="true" class="sizeBars"></div>
+          </div>
+          <div class="stats" role="gridcell">
+            <div>
+              <span aria-label="9 added" class="added" tabindex="0"> +9 </span>
+              <span aria-label="0 removed" class="removed" tabindex="0">
+                -0
+              </span>
+              <span hidden=""> +/-0 B </span>
+            </div>
+          </div>
+          <div class="hideOnEdit reviewed" role="gridcell">
+            <span aria-hidden="true" class="reviewedLabel"> Reviewed </span>
+            <span
+              aria-checked="false"
+              aria-label="Reviewed"
+              class="reviewedSwitch"
+              role="switch"
+              tabindex="0"
+            >
+              <span
+                class="markReviewed"
+                tabindex="-1"
+                title="Mark as reviewed (shortcut: r)"
+              >
+                MARK REVIEWED
+              </span>
+            </span>
+          </div>
+          <div
+            aria-hidden="true"
+            class="editFileControls showOnEdit"
+            role="gridcell"
+          ></div>
+          <div class="show-hide" role="gridcell">
+            <span
+              aria-checked="false"
+              aria-label="expand"
+              aria-description="Expand diff of this file"
+              class="show-hide"
+              data-expand="true"
+              data-path="path/file0"
+              role="switch"
+              tabindex="0"
+            >
+              <gr-icon
+                icon="expand_more"
+                class="show-hide-icon"
+                id="icon"
+                tabindex="-1"
+              ></gr-icon>
+            </span>
+          </div>
+        </div>`
+      );
+    });
+
+    test('renders file paths', async () => {
+      element.files = createFiles(2, {lines_inserted: 9});
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+
+      assert.dom.equal(
+        fileRows[0].querySelector('.path'),
+        /* HTML */ `
+          <span class="path" role="gridcell">
+            <a class="pathLink">
+              <span class="fullFileName" title="path/file0">
+                <span class="newFilePath"> path/ </span>
+                <span class="fileName"> file0 </span>
+              </span>
+              <span class="truncatedFileName" title="path/file0">
+                …/file0
+              </span>
+              <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+            </a>
+          </span>
+        `
+      );
+      // The second row will have a matchingFilePath instead of newFilePath.
+      assert.dom.equal(
+        fileRows[1].querySelector('.path'),
+        /* HTML */ `
+          <span class="path" role="gridcell">
+            <a class="pathLink">
+              <span class="fullFileName" title="path/file1">
+                <span class="matchingFilePath"> path/ </span>
+                <span class="fileName"> file1 </span>
+              </span>
+              <span class="truncatedFileName" title="path/file1">
+                …/file1
+              </span>
+              <gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
+            </a>
+          </span>
+        `
+      );
+    });
+
+    test('renders file status column', async () => {
+      element.files = createFiles(1, {lines_inserted: 9});
+      element.filesLeftBase = createFiles(1, {lines_inserted: 9});
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+      const statusCol = queryAndAssert(fileRows?.[0], '.status');
+      assert.dom.equal(
+        statusCol,
+        /* HTML */ `
+          <div class="extended status" role="gridcell">
+            <gr-file-status></gr-file-status>
+            <gr-icon
+              aria-label="then"
+              class="file-status-arrow"
+              icon="arrow_right_alt"
+            ></gr-icon>
+            <gr-file-status></gr-file-status>
+          </div>
+        `
+      );
+    });
+
+    test('renders file mode', async () => {
+      element.files = createFiles(1, {
+        old_mode: FileMode.REGULAR_FILE,
+        new_mode: FileMode.EXECUTABLE_FILE,
+      });
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+      const fileMode = queryAndAssert(
+        fileRows?.[0],
+        '.path gr-tooltip-content'
+      );
+      assert.dom.equal(
+        fileMode,
+        /* HTML */ `
+          <gr-tooltip-content
+            has-tooltip=""
+            title="file mode changed from regular (100644) to executable (100755)"
+          >
+            <div class="file-mode-content">
+              <gr-icon class="file-mode-warning" icon="warning"> </gr-icon>
+              (executable)
+            </div>
+          </gr-tooltip-content>
+        `
+      );
+    });
+
+    test('renders file mode, but not for regular files', async () => {
+      element.files = createFiles(3, {
+        old_mode: FileMode.REGULAR_FILE,
+        new_mode: FileMode.REGULAR_FILE,
+      });
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+      const fileMode = query(fileRows?.[0], '.path gr-tooltip-content');
+      assert.notOk(fileMode);
+    });
+
+    test('renders file status column header', async () => {
+      element.files = createFiles(1, {lines_inserted: 9});
+      element.filesLeftBase = createFiles(1, {lines_inserted: 9});
+      element.patchRange!.basePatchNum = 1 as PatchSetNumber;
+      await element.updateComplete;
+      const fileRows = queryAll<HTMLDivElement>(element, '.header-row');
+      const statusCol = queryAndAssert(fileRows?.[0], '.status');
+      assert.dom.equal(
+        statusCol,
+        /* HTML */ `
+          <div class="extended status" role="gridcell">
+            <gr-tooltip-content has-tooltip="" title="Patchset 1">
+              <div class="content">1</div>
+            </gr-tooltip-content>
+            <gr-icon
+              aria-label="then"
+              class="file-status-arrow"
+              icon="arrow_right_alt"
+            ></gr-icon>
+            <gr-tooltip-content has-tooltip="" title="Patchset 2">
+              <div class="content">2</div>
+            </gr-tooltip-content>
+          </div>
+        `
+      );
+    });
+
+    test('correct number of files are shown', async () => {
+      element.fileListIncrement = 100;
+      element.files = createFiles(250);
+      await element.updateComplete;
+      await waitEventLoop();
+
+      assert.equal(
+        queryAll<HTMLDivElement>(element, '.file-row').length,
+        element.numFilesShown
+      );
+      const controlRow = queryAndAssert<HTMLDivElement>(element, '.controlRow');
+      assert.isFalse(controlRow.classList.contains('invisible'));
+      assert.equal(
+        queryAndAssert<GrButton>(
+          element,
+          '#incrementButton'
+        ).textContent!.trim(),
+        'Show 50 more'
+      );
+      assert.equal(
+        queryAndAssert<GrButton>(element, '#showAllButton').textContent!.trim(),
+        'Show all 250 files'
+      );
+
+      queryAndAssert<GrButton>(element, '#showAllButton').click();
+      await element.updateComplete;
+      await waitEventLoop();
+
+      assert.equal(element.numFilesShown, 250);
+      assert.equal(element.shownFiles.length, 250);
+      assert.isTrue(controlRow.classList.contains('invisible'));
+    });
+
+    test('rendering each row calls the reportRenderedRow method', async () => {
+      const renderedStub = sinon.stub(element, 'reportRenderedRow');
+      element.files = createFiles(10);
+      await element.updateComplete;
+
+      assert.equal(queryAll<HTMLDivElement>(element, '.file-row').length, 10);
+      assert.equal(renderedStub.callCount, 10);
+    });
+
+    test('calculate totals for patch number', async () => {
+      element.files = [
+        {
+          __path: '/COMMIT_MSG',
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        {
+          __path: '/MERGE_LIST',
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      ];
+      await element.updateComplete;
+
+      let patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
+
+      // Test with a commit message that isn't the first file.
+      element.files = [
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+        {
+          __path: '/COMMIT_MSG',
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        {
+          __path: '/MERGE_LIST',
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+      ];
+      await element.updateComplete;
+
+      patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
+
+      // Test with no commit message.
+      element.files = [
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+      ];
+      await element.updateComplete;
+
+      patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
+
+      // Test with files missing either lines_inserted or lines_deleted.
+      element.files = [
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_deleted: 1,
+          size: 0,
+          size_delta: 0,
+        },
+      ];
+      await element.updateComplete;
+
+      patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
+        inserted: 1,
+        deleted: 1,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
+    });
+
+    test('binary only files', async () => {
+      element.files = [
+        {
+          __path: '/COMMIT_MSG',
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        {
+          __path: 'file_binary_1',
+          binary: true,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'file_binary_2',
+          binary: true,
+          size_delta: -5,
+          size: 120,
+        },
+      ];
+      await element.updateComplete;
+
+      const patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
+        inserted: 0,
+        deleted: 0,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isTrue(element.shouldHideChangeTotals(patchChange));
+    });
+
+    test('binary and regular files', async () => {
+      element.files = [
+        {
+          __path: '/COMMIT_MSG',
+          lines_inserted: 9,
+          size: 0,
+          size_delta: 0,
+        },
+        {
+          __path: 'file_binary_1',
+          binary: true,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'file_binary_2',
+          binary: true,
+          size_delta: -5,
+          size: 120,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_deleted: 5,
+          size_delta: -10,
+          size: 100,
+        },
+        {
+          __path: 'myfile2.txt',
+          lines_inserted: 10,
+          size: 0,
+          size_delta: 0,
+        },
+      ];
+      await element.updateComplete;
+
+      const patchChange = element.calculatePatchChange();
+      assert.deepEqual(patchChange, {
+        inserted: 10,
+        deleted: 5,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element.shouldHideBinaryChangeTotals(patchChange));
+      assert.isFalse(element.shouldHideChangeTotals(patchChange));
+    });
+
+    test('formatBytes function', () => {
+      const table = {
+        '64': '+64 B',
+        '1023': '+1023 B',
+        '1024': '+1 KiB',
+        '4096': '+4 KiB',
+        '1073741824': '+1 GiB',
+        '-64': '-64 B',
+        '-1023': '-1023 B',
+        '-1024': '-1 KiB',
+        '-4096': '-4 KiB',
+        '-1073741824': '-1 GiB',
+        '0': '+/-0 B',
+      };
+      for (const [bytes, expected] of Object.entries(table)) {
+        assert.equal(element.formatBytes(Number(bytes)), expected);
+      }
+    });
+
+    test('formatPercentage function', () => {
+      const table = [
+        {size: 100, delta: 100, display: ''},
+        {size: 195060, delta: 64, display: '(+0%)'},
+        {size: 195060, delta: -64, display: '(-0%)'},
+        {size: 394892, delta: -7128, display: '(-2%)'},
+        {size: 90, delta: -10, display: '(-10%)'},
+        {size: 110, delta: 10, display: '(+10%)'},
+      ];
+
+      for (const item of table) {
+        assert.equal(
+          element.formatPercentage(item.size, item.delta),
+          item.display
+        );
+      }
+    });
+
+    test('comment filtering', () => {
+      element.changeComments = createChangeComments();
+      const parentTo1 = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+
+      const parentTo2 = {
+        basePatchNum: PARENT,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+
+      const _1To2 = {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '2c'
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '3c'
+      );
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeDraftsString({
+          __path: 'unresolved.file',
+          size: 0,
+          size_delta: 0,
+        }),
+        '1 draft'
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeDraftsString({
+          __path: 'unresolved.file',
+          size: 0,
+          size_delta: 0,
+        }),
+        '1 draft'
+      );
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeDraftsStringMobile({
+          __path: 'unresolved.file',
+          size: 0,
+          size_delta: 0,
+        }),
+        '1d'
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeDraftsStringMobile({
+          __path: 'unresolved.file',
+          size: 0,
+          size_delta: 0,
+        }),
+        '1d'
+      );
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        '1c'
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        '3c'
+      );
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeDraftsString({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeDraftsString({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeDraftsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeDraftsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeDraftsString({
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeDraftsString({
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeDraftsStringMobile({
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeDraftsStringMobile({
+          __path: 'file_added_in_rev2.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = parentTo2;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '1c'
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '3c'
+      );
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeDraftsString({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '2 drafts'
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeDraftsString({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '2 drafts'
+      );
+      element.patchRange = parentTo1;
+      assert.equal(
+        element.computeDraftsStringMobile({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '2d'
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeDraftsStringMobile({
+          __path: '/COMMIT_MSG',
+          size: 0,
+          size_delta: 0,
+        }),
+        '2d'
+      );
+      element.patchRange = parentTo2;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        '2c'
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeCommentsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        '3c'
+      );
+      element.patchRange = parentTo2;
+      assert.equal(
+        element.computeDraftsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+      element.patchRange = _1To2;
+      assert.equal(
+        element.computeDraftsStringMobile({
+          __path: 'myfile.txt',
+          size: 0,
+          size_delta: 0,
+        }),
+        ''
+      );
+    });
+
+    suite('keyboard shortcuts', () => {
+      setup(async () => {
+        element.files = [
+          normalize({}, '/COMMIT_MSG'),
+          normalize({}, 'file_added_in_rev2.txt'),
+          normalize({}, 'myfile.txt'),
+        ];
+        element.changeNum = 42 as NumericChangeId;
+        element.patchRange = {
+          basePatchNum: PARENT,
+          patchNum: 2 as RevisionPatchSetNum,
+        };
+        element.change = {
+          _number: 42 as NumericChangeId,
+          project: 'test-project',
+        } as ParsedChangeInfo;
+        element.fileCursor.setCursorAtIndex(0);
+        await element.updateComplete;
+        await waitEventLoop();
+      });
+
+      test('toggle left diff via shortcut', () => {
+        const toggleLeftDiffStub = sinon.stub();
+        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
+        // https://github.com/sinonjs/sinon/issues/781
+        const diffsStub = sinon
+          .stub(element, 'diffs')
+          .get(() => [{toggleLeftDiff: toggleLeftDiffStub}]);
+        pressKey(element, 'A');
+        assert.isTrue(toggleLeftDiffStub.calledOnce);
+        diffsStub.restore();
+      });
+
+      test('keyboard shortcuts', async () => {
+        const items = [...queryAll<HTMLDivElement>(element, '.file-row')];
+        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.
+        pressKey(element, 'J');
+        assert.equal(element.fileCursor.index, 0);
+        // down should not move the cursor.
+        pressKey(element, 'ArrowDown');
+        assert.equal(element.fileCursor.index, 0);
+
+        pressKey(element, 'j');
+        assert.equal(element.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        pressKey(element, 'j');
+
+        const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+        assert.equal(element.fileCursor.index, 2);
+        assert.equal(element.selectedIndex, 2);
+
+        // k with a modifier should not move the cursor.
+        pressKey(element, 'K');
+        assert.equal(element.fileCursor.index, 2);
+
+        // up should not move the cursor.
+        pressKey(element, 'ArrowUp');
+        assert.equal(element.fileCursor.index, 2);
+
+        pressKey(element, 'k');
+        assert.equal(element.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        pressKey(element, 'o');
+
+        assert.equal(setUrlStub.callCount, 1);
+        assert.equal(
+          setUrlStub.lastCall.firstArg,
+          '/c/test-project/+/42/2/file_added_in_rev2.txt'
+        );
+
+        pressKey(element, 'k');
+        pressKey(element, 'k');
+        pressKey(element, 'k');
+        assert.equal(element.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
+        assertIsDefined(element.diffCursor);
+
+        const createCommentInPlaceStub = sinon.stub(
+          element.diffCursor,
+          'createCommentInPlace'
+        );
+        pressKey(element, 'c');
+        assert.isTrue(createCommentInPlaceStub.called);
+      });
+
+      test('i key shows/hides selected inline diff', async () => {
+        const paths = element.files.map(f => f.__path);
+        sinon.stub(element, 'expandedFilesChanged');
+        const files = [...queryAll<HTMLDivElement>(element, '.file-row')];
+        element.fileCursor.stops = files;
+        element.fileCursor.setCursorAtIndex(0);
+        await element.updateComplete;
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element.expandedFiles.length, 0);
+
+        pressKey(element, 'i');
+        await element.updateComplete;
+        assert.equal(element.diffs.length, 1);
+        assert.equal(element.diffs[0].path, paths[0]);
+        assert.equal(element.expandedFiles.length, 1);
+        assert.equal(element.expandedFiles[0].path, paths[0]);
+
+        pressKey(element, 'i');
+        await element.updateComplete;
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element.expandedFiles.length, 0);
+
+        element.fileCursor.setCursorAtIndex(1);
+        pressKey(element, 'i');
+        await element.updateComplete;
+        assert.equal(element.diffs.length, 1);
+        assert.equal(element.diffs[0].path, paths[1]);
+        assert.equal(element.expandedFiles.length, 1);
+        assert.equal(element.expandedFiles[0].path, paths[1]);
+
+        pressKey(element, 'I');
+        await element.updateComplete;
+        assert.equal(element.diffs.length, paths.length);
+        assert.equal(element.expandedFiles.length, paths.length);
+        for (const diff of element.diffs) {
+          assert.isTrue(element.expandedFiles.some(f => f.path === diff.path));
+        }
+        // since _expandedFilesChanged is stubbed
+        element.filesExpanded = FilesExpandedState.ALL;
+        pressKey(element, 'I');
+        await element.updateComplete;
+        assert.equal(element.diffs.length, 0);
+        assert.equal(element.expandedFiles.length, 0);
+      });
+
+      test('r key sets reviewed flag', async () => {
+        await element.updateComplete;
+
+        pressKey(element, 'r');
+        await element.updateComplete;
+
+        assert.isTrue(saveStub.called);
+        assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
+      });
+
+      test('r key clears reviewed flag', async () => {
+        element.reviewed = ['/COMMIT_MSG'];
+        await element.updateComplete;
+
+        pressKey(element, 'r');
+        await element.updateComplete;
+
+        assert.isTrue(saveStub.called);
+        assert.isTrue(
+          saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false)
+        );
+      });
+
+      suite('handleOpenFile', () => {
+        let interact: Function;
+
+        setup(() => {
+          const openCursorStub = sinon.stub(element, 'openCursorFile');
+          const openSelectedStub = sinon.stub(element, 'openSelectedFile');
+          const expandStub = sinon.stub(element, 'toggleFileExpanded');
+
+          interact = function () {
+            openCursorStub.reset();
+            openSelectedStub.reset();
+            expandStub.reset();
+            element.handleOpenFile();
+            const result = {} as any;
+            if (openCursorStub.called) {
+              result.opened_cursor = true;
+            }
+            if (openSelectedStub.called) {
+              result.opened_selected = true;
+            }
+            if (expandStub.called) {
+              result.expanded = true;
+            }
+            return result;
+          };
+        });
+
+        test('open from selected file', () => {
+          element.filesExpanded = FilesExpandedState.NONE;
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+
+        test('open from diff cursor', () => {
+          element.filesExpanded = FilesExpandedState.ALL;
+          assert.deepEqual(interact(), {opened_cursor: true});
+        });
+
+        test('expand when user prefers', () => {
+          element.filesExpanded = FilesExpandedState.NONE;
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+      });
+
+      test('shift+left/shift+right', () => {
+        assertIsDefined(element.diffCursor);
+        const moveLeftStub = sinon.stub(element.diffCursor, 'moveLeft');
+        const moveRightStub = sinon.stub(element.diffCursor, 'moveRight');
+
+        let noDiffsExpanded = true;
+        sinon.stub(element, 'noDiffsExpanded').callsFake(() => noDiffsExpanded);
+
+        pressKey(element, 'ArrowLeft', Modifier.SHIFT_KEY);
+        assert.isFalse(moveLeftStub.called);
+        pressKey(element, 'ArrowRight', Modifier.SHIFT_KEY);
+        assert.isFalse(moveRightStub.called);
+
+        noDiffsExpanded = false;
+
+        pressKey(element, 'ArrowLeft', Modifier.SHIFT_KEY);
+        assert.isTrue(moveLeftStub.called);
+        pressKey(element, 'ArrowRight', Modifier.SHIFT_KEY);
+        assert.isTrue(moveRightStub.called);
+      });
+    });
+
+    test('file review status', async () => {
+      element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element.files = [
+        normalize({}, '/COMMIT_MSG'),
+        normalize({}, 'file_added_in_rev2.txt'),
+        normalize({}, 'myfile.txt'),
+      ];
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      element.fileCursor.setCursorAtIndex(0);
+
+      const reviewSpy = sinon.spy(element, 'reviewFile');
+      const toggleExpandSpy = sinon.spy(element, 'toggleFileExpanded');
+
+      await element.updateComplete;
+
+      const fileRows = queryAll(element, '.row:not(.header-row)');
+      const checkSelector = 'span.reviewedSwitch[role="switch"]';
+      const commitMsg = fileRows[0].querySelector(checkSelector);
+      const fileAdded = fileRows[1].querySelector(checkSelector);
+      const myFile = fileRows[2].querySelector(checkSelector);
+
+      assert.equal(commitMsg!.getAttribute('aria-checked'), 'true');
+      assert.equal(fileAdded!.getAttribute('aria-checked'), 'false');
+      assert.equal(myFile!.getAttribute('aria-checked'), 'true');
+
+      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
+      assert.isOk(commitReviewLabel);
+      const markReviewLabel =
+        fileRows[0].querySelector<HTMLSpanElement>('.markReviewed');
+      assert.isOk(markReviewLabel);
+      assert.isTrue(commitReviewLabel!.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel!.textContent, 'MARK UNREVIEWED');
+
+      const clickSpy = sinon.spy(element, 'reviewedClick');
+      markReviewLabel!.click();
+      await element.updateComplete;
+
+      assert.isTrue(clickSpy.calledOnce);
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(reviewSpy.calledOnce);
+      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
+
+      element.reviewed = ['myfile.txt'];
+      await element.updateComplete;
+
+      assert.isFalse(commitReviewLabel!.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel!.textContent, 'MARK REVIEWED');
+
+      markReviewLabel!.click();
+      await element.updateComplete;
+
+      assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
+      assert.isTrue(clickSpy.lastCall.args[0].defaultPrevented);
+      assert.isTrue(reviewSpy.calledTwice);
+      assert.isFalse(toggleExpandSpy.called);
+
+      element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      await element.updateComplete;
+
+      assert.isTrue(commitReviewLabel!.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel!.textContent, 'MARK UNREVIEWED');
+    });
+
+    test('handleFileListClick', async () => {
+      element.files = [
+        normalize({}, '/COMMIT_MSG'),
+        normalize({}, 'f1.txt'),
+        normalize({}, 'f2.txt'),
+      ];
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      await element.updateComplete;
+
+      const clickSpy = sinon.spy(element, 'handleFileListClick');
+      const reviewStub = sinon.stub(element, 'reviewFile');
+      const toggleExpandSpy = sinon.spy(element, 'toggleFileExpanded');
+
+      const row = queryAndAssert(
+        element,
+        '.row[data-file=\'{"path":"f1.txt"}\']'
+      );
+
+      // Click on the expand button, resulting in toggleFileExpanded being
+      // called and resulting in a call to reviewFile().
+      queryAndAssert<HTMLDivElement>(row, 'div.show-hide').click();
+      await element.updateComplete;
+
+      assert.isTrue(clickSpy.calledOnce);
+      assert.isTrue(toggleExpandSpy.calledOnce);
+      await waitUntil(() => reviewStub.calledOnce);
+
+      // Click inside the diff. This should result in no additional calls to
+      // toggleFileExpanded or reviewFile.
+      queryAndAssert<GrDiffHost>(element, 'gr-diff-host').click();
+      await element.updateComplete;
+      assert.isTrue(clickSpy.calledTwice);
+      assert.isTrue(toggleExpandSpy.calledOnce);
+      assert.isTrue(reviewStub.calledOnce);
+    });
+
+    test('handleFileListClick editMode', async () => {
+      element.files = [
+        normalize({}, '/COMMIT_MSG'),
+        normalize({}, 'f1.txt'),
+        normalize({}, 'f2.txt'),
+      ];
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      element.editMode = true;
+      await element.updateComplete;
+
+      const clickSpy = sinon.spy(element, 'handleFileListClick');
+      const toggleExpandSpy = sinon.spy(element, 'toggleFileExpanded');
+
+      // Tap the edit controls. Should be ignored by handleFileListClick.
+      queryAndAssert<HTMLDivElement>(element, '.editFileControls').click();
+      await element.updateComplete;
+
+      assert.isTrue(clickSpy.calledOnce);
+      assert.isFalse(toggleExpandSpy.called);
+    });
+
+    test('checkbox shows/hides diff inline', async () => {
+      element.files = [normalize({}, 'myfile.txt')];
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      element.fileCursor.setCursorAtIndex(0);
+      sinon.stub(element, 'expandedFilesChanged');
+      await element.updateComplete;
+      const fileRows = queryAll(element, '.row:not(.header-row)');
+      // Because the label surrounds the input, the tap event is triggered
+      // there first.
+      const showHideCheck = fileRows[0].querySelector(
+        'span.show-hide[role="switch"]'
+      );
+      const showHideLabel =
+        showHideCheck!.querySelector<GrIcon>('.show-hide-icon');
+      assert.equal(showHideCheck!.getAttribute('aria-checked'), 'false');
+      showHideLabel!.click();
+      await element.updateComplete;
+
+      assert.equal(showHideCheck!.getAttribute('aria-checked'), 'true');
+      assert.notEqual(
+        element.expandedFiles.findIndex(f => f.path === 'myfile.txt'),
+        -1
+      );
+    });
+
+    test('diff mode correctly toggles the diffs', async () => {
+      element.files = [normalize({}, 'myfile.txt')];
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      const updateDiffPrefSpy = sinon.spy(element, 'updateDiffPreferences');
+      element.fileCursor.setCursorAtIndex(0);
+      await element.updateComplete;
+
+      // Tap on a file to generate the diff.
+      const row = queryAll<HTMLSpanElement>(
+        element,
+        '.row:not(.header-row) span.show-hide'
+      )[0];
+
+      row.click();
+
+      element.diffViewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+
+      assert.isTrue(updateDiffPrefSpy.called);
+    });
+
+    test('expanded attribute not set on path when not expanded', () => {
+      element.files = [normalize({}, '/COMMIT_MSG')];
+      assert.isNotOk(query(element, 'expanded'));
+    });
+
+    test('tapping row ignores links', async () => {
+      element.files = [normalize({}, '/COMMIT_MSG')];
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      sinon.stub(element, 'expandedFilesChanged');
+      await element.updateComplete;
+      const commitMsgFile = queryAll<HTMLAnchorElement>(
+        element,
+        '.row:not(.header-row) a.pathLink'
+      )[0];
+
+      // Remove href attribute so the app doesn't route to a diff view
+      commitMsgFile.removeAttribute('href');
+      const togglePathSpy = sinon.spy(element, 'toggleFileExpanded');
+
+      commitMsgFile.click();
+      await element.updateComplete;
+      assert(togglePathSpy.notCalled, 'file is opened as diff view');
+      assert.isNotOk(query(element, '.expanded'));
+      assert.notEqual(
+        getComputedStyle(queryAndAssert(element, '.show-hide')).display,
+        'none'
+      );
+    });
+
+    test('toggleFileExpanded', async () => {
+      const path = 'path/to/my/file.txt';
+      element.files = [normalize({}, path)];
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await waitEventLoop();
+
+      const renderSpy = sinon.spy(element, 'renderInOrder');
+      const collapseStub = sinon.stub(element, 'clearCollapsedDiffs');
+
+      assert.equal(
+        queryAndAssert<GrIcon>(element, 'gr-icon').icon,
+        'expand_more'
+      );
+      assert.equal(element.expandedFiles.length, 0);
+      element.toggleFileExpanded({path});
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await waitEventLoop();
+
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
+      assert.equal(
+        queryAndAssert<GrIcon>(element, 'gr-icon').icon,
+        'expand_less'
+      );
+
+      assert.equal(renderSpy.callCount, 1);
+      assert.isTrue(element.expandedFiles.some(f => f.path === path));
+      element.toggleFileExpanded({path});
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await waitEventLoop();
+
+      assert.equal(
+        queryAndAssert<GrIcon>(element, 'gr-icon').icon,
+        'expand_more'
+      );
+      assert.equal(renderSpy.callCount, 1);
+      assert.isFalse(element.expandedFiles.some(f => f.path === path));
+      assert.equal(collapseStub.lastCall.args[0].length, 1);
+    });
+
+    test('expandAllDiffs and collapseAllDiffs', async () => {
+      const collapseStub = sinon.stub(element, 'clearCollapsedDiffs');
+      assertIsDefined(element.diffCursor);
+      const reInitStub = sinon.stub(element.diffCursor, 'reInitAndUpdateStops');
+
+      const path = 'path/to/my/file.txt';
+      element.files = [normalize({}, path)];
+      // Wait for diffs to be computed.
+      await element.updateComplete;
+      await waitEventLoop();
+      element.expandAllDiffs();
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await waitEventLoop();
+      assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+      assert.isTrue(reInitStub.calledTwice);
+      assert.equal(collapseStub.lastCall.args[0].length, 0);
+
+      element.collapseAllDiffs();
+      await element.updateComplete;
+      // Wait for expandedFilesChanged to finish.
+      await waitEventLoop();
+      assert.equal(element.expandedFiles.length, 0);
+      assert.equal(element.filesExpanded, FilesExpandedState.NONE);
+      assert.equal(collapseStub.lastCall.args[0].length, 1);
+    });
+
+    test('expandedFilesChanged', async () => {
+      sinon.stub(element, 'reviewFile');
+      const path = 'path/to/my/file.txt';
+      const promise = mockPromise();
+      const diffs = [
+        {
+          path,
+          style: {},
+          reload() {
+            promise.resolve();
+          },
+          prefetchDiff() {},
+          cancel() {},
+          getCursorStops() {
+            return [];
+          },
+          addEventListener(eventName: string, callback: Function) {
+            if (
+              ['render-start', 'render-content', 'scroll'].indexOf(eventName) >=
+              0
+            ) {
+              callback(new Event(eventName));
+            }
+          },
+        },
+      ];
+      sinon.stub(element, 'diffs').get(() => diffs);
+      element.expandedFiles = element.expandedFiles.concat([{path}]);
+      await element.updateComplete;
+      await waitEventLoop();
+      await promise;
+    });
+
+    test('clearCollapsedDiffs', () => {
+      // Have to type as any because the type is 'GrDiffHost'
+      // which would require stubbing so many different
+      // methods / properties that it isn't worth it.
+      const diff = {
+        cancel: sinon.stub(),
+        clearDiffContent: sinon.stub(),
+      } as any;
+      element.clearCollapsedDiffs([diff]);
+      assert.isTrue(diff.cancel.calledOnce);
+      assert.isTrue(diff.clearDiffContent.calledOnce);
+    });
+
+    test('filesExpanded value updates to correct enum', async () => {
+      element.files = [normalize({}, 'foo.bar'), normalize({}, 'baz.bar')];
+      await element.updateComplete;
+      assert.equal(element.filesExpanded, FilesExpandedState.NONE);
+      element.expandedFiles.push({path: 'baz.bar'});
+      element.expandedFilesChanged([{path: 'baz.bar'}]);
+      await element.updateComplete;
+      assert.equal(element.filesExpanded, FilesExpandedState.SOME);
+      element.expandedFiles.push({path: 'foo.bar'});
+      element.expandedFilesChanged([{path: 'foo.bar'}]);
+      await element.updateComplete;
+      assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+      element.collapseAllDiffs();
+      await element.updateComplete;
+      assert.equal(element.filesExpanded, FilesExpandedState.NONE);
+      element.expandAllDiffs();
+      await element.updateComplete;
+      assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+    });
+
+    test('renderInOrder', async () => {
+      const reviewStub = sinon.stub(element, 'reviewFile');
+      let callCount = 0;
+      // Have to type as any because the type is 'GrDiffHost'
+      // which would require stubbing so many different
+      // methods / properties that it isn't worth it.
+      const diffs = [
+        {
+          path: 'p0',
+          style: {},
+          prefetchDiff() {},
+          reload() {
+            assert.equal(callCount++, 2);
+            return Promise.resolve();
+          },
+        },
+        {
+          path: 'p1',
+          style: {},
+          prefetchDiff() {},
+          reload() {
+            assert.equal(callCount++, 1);
+            return Promise.resolve();
+          },
+        },
+        {
+          path: 'p2',
+          style: {},
+          prefetchDiff() {},
+          reload() {
+            assert.equal(callCount++, 0);
+            return Promise.resolve();
+          },
+        },
+      ] as any;
+      element.renderInOrder([{path: 'p2'}, {path: 'p1'}, {path: 'p0'}], diffs);
+      await element.updateComplete;
+      assert.isFalse(reviewStub.called);
+    });
+
+    test('renderInOrder logged in', async () => {
+      const reviewStub = sinon.stub(element, 'reviewFile');
+      let callCount = 0;
+      // Have to type as any because the type is 'GrDiffHost'
+      // which would require stubbing so many different
+      // methods / properties that it isn't worth it.
+      const diffs = [
+        {
+          path: 'p2',
+          style: {},
+          prefetchDiff() {},
+          reload() {
+            assert.equal(reviewStub.callCount, 0);
+            assert.equal(callCount++, 0);
+            return Promise.resolve();
+          },
+        },
+      ] as any;
+      element.renderInOrder([{path: 'p2'}], diffs);
+      await element.updateComplete;
+      assert.equal(reviewStub.callCount, 1);
+    });
+
+    test('renderInOrder respects diffPrefs.manual_review', async () => {
+      element.diffPrefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        ignore_whitespace: 'IGNORE_NONE',
+        manual_review: true,
+      };
+      const reviewStub = sinon.stub(element, 'reviewFile');
+      // Have to type as any because the type is 'GrDiffHost'
+      // which would require stubbing so many different
+      // methods / properties that it isn't worth it.
+      const diffs = [
+        {
+          path: 'p',
+          style: {},
+          prefetchDiff() {},
+          reload() {
+            return Promise.resolve();
+          },
+        },
+      ] as any;
+
+      element.renderInOrder([{path: 'p'}], diffs);
+      await element.updateComplete;
+      assert.isFalse(reviewStub.called);
+      delete element.diffPrefs.manual_review;
+      element.renderInOrder([{path: 'p'}], diffs);
+      await element.updateComplete;
+      // Wait for renderInOrder to finish
+      await waitEventLoop();
+      assert.isTrue(reviewStub.called);
+      assert.isTrue(reviewStub.calledWithExactly('p', true));
+    });
+
+    suite('for merge commits', () => {
+      let filesStub: sinon.SinonStub;
+
+      setup(async () => {
+        element.files = [
+          normalize({size: 0, size_delta: 0}, 'conflictingFile.js'),
+        ];
+        filesStub = stubRestApi('getChangeOrEditFiles')
+          .onFirstCall()
+          .resolves({
+            'conflictingFile.js': {size: 0, size_delta: 0},
+            'cleanlyMergedFile.js': {size: 0, size_delta: 0},
+          });
+        stubRestApi('getReviewedFiles').resolves([]);
+        stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
+        const changeWithMultipleParents = {
+          ...createParsedChange(),
+          revisions: {
+            r1: {
+              ...createRevision(),
+              commit: {
+                ...createCommit(),
+                parents: [
+                  {commit: 'p1' as CommitId, subject: 'subject1'},
+                  {commit: 'p2' as CommitId, subject: 'subject2'},
+                ],
+              },
+            },
+          },
+        };
+        element.changeNum = changeWithMultipleParents._number;
+        element.change = changeWithMultipleParents;
+        element.patchRange = {
+          basePatchNum: PARENT,
+          patchNum: 1 as RevisionPatchSetNum,
+        };
+        await element.updateComplete;
+        await waitEventLoop();
+      });
+
+      test('displays cleanly merged file count', async () => {
+        await waitUntil(() => !!query(element, '.cleanlyMergedText'));
+
+        const message = queryAndAssert<HTMLSpanElement>(
+          element,
+          '.cleanlyMergedText'
+        ).textContent!.trim();
+        assert.equal(message, '1 file merged cleanly in Parent 1');
+      });
+
+      test('displays plural cleanly merged file count', async () => {
+        filesStub.restore();
+        stubRestApi('getChangeOrEditFiles')
+          .onFirstCall()
+          .resolves({
+            'conflictingFile.js': {size: 0, size_delta: 0},
+            'cleanlyMergedFile.js': {size: 0, size_delta: 0},
+            'anotherCleanlyMergedFile.js': {size: 0, size_delta: 0},
+          });
+        await element.updateCleanlyMergedPaths();
+        await element.updateComplete;
+        await waitUntil(() => !!query(element, '.cleanlyMergedText'));
+
+        const message = queryAndAssert(
+          element,
+          '.cleanlyMergedText'
+        ).textContent!.trim();
+        assert.equal(message, '2 files merged cleanly in Parent 1');
+      });
+
+      test('displays button for navigating to parent 1 base', async () => {
+        await waitUntil(() => !!query(element, '.showParentButton'));
+
+        queryAndAssert(element, '.showParentButton');
+      });
+
+      test('computes old paths for cleanly merged files', async () => {
+        filesStub.restore();
+        stubRestApi('getChangeOrEditFiles')
+          .onFirstCall()
+          .resolves({
+            'conflictingFile.js': {size: 0, size_delta: 0},
+            'cleanlyMergedFile.js': {
+              old_path: 'cleanlyMergedFileOldName.js',
+              size: 0,
+              size_delta: 0,
+            },
+          });
+        await element.updateCleanlyMergedPaths();
+
+        assert.deepEqual(element.cleanlyMergedOldPaths, [
+          'cleanlyMergedFileOldName.js',
+        ]);
+      });
+
+      test('not shown for non-Auto Merge base parents', async () => {
+        element.patchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 2 as RevisionPatchSetNum,
+        };
+        await element.updateCleanlyMergedPaths();
+        await element.updateComplete;
+
+        assert.notOk(query(element, '.cleanlyMergedText'));
+        assert.notOk(query(element, '.showParentButton'));
+      });
+
+      test('not shown in edit mode', async () => {
+        element.patchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: EDIT,
+        };
+        await element.updateCleanlyMergedPaths();
+        await element.updateComplete;
+
+        assert.notOk(query(element, '.cleanlyMergedText'));
+        assert.notOk(query(element, '.showParentButton'));
+      });
+    });
+  });
+
+  suite('diff url file list', () => {
+    test('diff url', () => {
+      element.change = {
+        ...createParsedChange(),
+        _number: 1 as NumericChangeId,
+        project: 'gerrit' as RepoName,
+      };
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      const path = 'index.php';
+      element.editMode = false;
+      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1/index.php');
+    });
+
+    test('diff url commit msg', () => {
+      element.change = {
+        ...createParsedChange(),
+        _number: 1 as NumericChangeId,
+        project: 'gerrit' as RepoName,
+      };
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.editMode = false;
+      const path = '/COMMIT_MSG';
+      assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1//COMMIT_MSG');
+    });
+
+    test('edit url', () => {
+      element.change = {
+        ...createParsedChange(),
+        _number: 1 as NumericChangeId,
+        project: 'gerrit' as RepoName,
+      };
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.editMode = true;
+      const path = 'index.php';
+      assert.equal(
+        element.computeDiffURL(path),
+        '/c/gerrit/+/1/1/index.php,edit'
+      );
+    });
+
+    test('edit url commit msg', () => {
+      element.change = {
+        ...createParsedChange(),
+        _number: 1 as NumericChangeId,
+        project: 'gerrit' as RepoName,
+      };
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      element.editMode = true;
+      const path = '/COMMIT_MSG';
+      assert.equal(
+        element.computeDiffURL(path),
+        '/c/gerrit/+/1/1//COMMIT_MSG,edit'
+      );
+    });
+  });
+
+  suite('size bars', () => {
+    test('computeSizeBarLayout', async () => {
+      const defaultSizeBarLayout = {
+        maxInserted: 0,
+        maxDeleted: 0,
+        maxAdditionWidth: 0,
+        maxDeletionWidth: 0,
+        deletionOffset: 0,
+      };
+
+      element.files = [];
+      await element.updateComplete;
+      assert.deepEqual(element.computeSizeBarLayout(), defaultSizeBarLayout);
+
+      element.files = [
+        {
+          __path: '/COMMIT_MSG',
+          lines_inserted: 10000,
+          size_delta: 10000,
+          size: 10000,
+        },
+        {
+          __path: 'foo',
+          lines_inserted: 4,
+          lines_deleted: 10,
+          size_delta: 14,
+          size: 20,
+        },
+        {
+          __path: 'bar',
+          lines_inserted: 5,
+          lines_deleted: 8,
+          size_delta: 13,
+          size: 21,
+        },
+      ];
+      await element.updateComplete;
+      const layout = element.computeSizeBarLayout();
+      assert.equal(layout.maxInserted, 5);
+      assert.equal(layout.maxDeleted, 10);
+    });
+
+    test('computeBarAdditionWidth', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 5,
+        lines_deleted: 0,
+        size: 0,
+        size_delta: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 0,
+        maxAdditionWidth: 60,
+        maxDeletionWidth: 0,
+        deletionOffset: 60,
+      };
+
+      // Uses half the space when file is half the largest addition and there
+      // are no deletions.
+      assert.equal(element.computeBarAdditionWidth(file, stats), 30);
+
+      // If there are no insertions, there is no width.
+      stats.maxInserted = 0;
+      assert.equal(element.computeBarAdditionWidth(file, stats), 0);
+
+      // If the insertions is not present on the file, there is no width.
+      stats.maxInserted = 10;
+      file.lines_inserted = 0;
+      assert.equal(element.computeBarAdditionWidth(file, stats), 0);
+
+      // If the file is a commit message, returns zero.
+      file.lines_inserted = 5;
+      file.__path = '/COMMIT_MSG';
+      assert.equal(element.computeBarAdditionWidth(file, stats), 0);
+
+      // Width bottoms-out at the minimum width.
+      file.__path = 'stuff.txt';
+      file.lines_inserted = 1;
+      stats.maxInserted = 1000000;
+      assert.equal(element.computeBarAdditionWidth(file, stats), 1.5);
+    });
+
+    test('_computeBarAdditionX', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 5,
+        lines_deleted: 0,
+        size: 0,
+        size_delta: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 0,
+        maxAdditionWidth: 60,
+        maxDeletionWidth: 0,
+        deletionOffset: 60,
+      };
+      assert.equal(element.computeBarAdditionX(file, stats), 30);
+    });
+
+    test('computeBarDeletionWidth', () => {
+      const file = {
+        __path: 'foo/bar.baz',
+        lines_inserted: 0,
+        lines_deleted: 5,
+        size: 0,
+        size_delta: 0,
+      };
+      const stats = {
+        maxInserted: 10,
+        maxDeleted: 10,
+        maxAdditionWidth: 30,
+        maxDeletionWidth: 30,
+        deletionOffset: 31,
+      };
+
+      // Uses a quarter the space when file is half the largest deletions and
+      // there are equal additions.
+      assert.equal(element.computeBarDeletionWidth(file, stats), 15);
+
+      // If there are no deletions, there is no width.
+      stats.maxDeleted = 0;
+      assert.equal(element.computeBarDeletionWidth(file, stats), 0);
+
+      // If the deletions is not present on the file, there is no width.
+      stats.maxDeleted = 10;
+      file.lines_deleted = 0;
+      assert.equal(element.computeBarDeletionWidth(file, stats), 0);
+
+      // If the file is a commit message, returns zero.
+      file.lines_deleted = 5;
+      file.__path = '/COMMIT_MSG';
+      assert.equal(element.computeBarDeletionWidth(file, stats), 0);
+
+      // Width bottoms-out at the minimum width.
+      file.__path = 'stuff.txt';
+      file.lines_deleted = 1;
+      stats.maxDeleted = 1000000;
+      assert.equal(element.computeBarDeletionWidth(file, stats), 1.5);
+    });
+
+    test('_computeSizeBarsClass', () => {
+      element.showSizeBars = false;
+      assert.equal(
+        element.computeSizeBarsClass('foo/bar.baz'),
+        'sizeBars hide'
+      );
+      element.showSizeBars = true;
+      assert.equal(
+        element.computeSizeBarsClass('/COMMIT_MSG'),
+        'sizeBars invisible'
+      );
+      assert.equal(element.computeSizeBarsClass('foo/bar.baz'), 'sizeBars ');
+    });
+  });
+
+  suite('gr-file-list inline diff tests', () => {
+    let element: GrFileList;
+    let reviewFileStub: sinon.SinonStub;
+
+    const commitMsgComments = [
+      {
+        patch_set: 2 as RevisionPatchSetNum,
+        path: '/p',
+        id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        line: 20,
+        updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+        message: 'another comment',
+        unresolved: true,
+      },
+      {
+        patch_set: 2 as RevisionPatchSetNum,
+        path: '/p',
+        id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+        line: 10,
+        updated: '2018-02-14 22:07:43.000000000' as Timestamp,
+        message: 'a comment',
+        unresolved: true,
+      },
+      {
+        patch_set: 2 as RevisionPatchSetNum,
+        path: '/p',
+        id: 'cc788d2c_cb1d728c' as UrlEncodedCommentId,
+        line: 20,
+        in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        updated: '2018-02-13 22:07:43.000000000' as Timestamp,
+        message: 'response',
+        unresolved: true,
+      },
+    ];
+
+    async function setupDiff(diff: GrDiffHost) {
+      diff.threads =
+        diff.path === '/COMMIT_MSG'
+          ? createCommentThreads(commitMsgComments)
+          : [];
+      diff.prefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        ignore_whitespace: 'IGNORE_NONE',
+      };
+      await diff.waitForReloadToRender();
+    }
+
+    async function renderAndGetNewDiffs(index: number) {
+      const diffs = queryAll<GrDiffHost>(element, 'gr-diff-host');
+
+      for (let i = index; i < diffs.length; i++) {
+        await setupDiff(diffs[i]);
+      }
+
+      assertIsDefined(element.diffCursor);
+      element.updateDiffCursor();
+      element.diffCursor.reInitCursor();
+      return diffs;
+    }
+
+    setup(async () => {
+      stubRestApi('getPreferences').returns(Promise.resolve(undefined));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubElement('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
+        Promise.resolve()
+      );
+      stubRestApi('getDiff').callsFake(() => Promise.resolve(createDiff()));
+      stubElement('gr-diff-host', 'prefetchDiff').callsFake(() => {});
+
+      element = await fixture(html`<gr-file-list></gr-file-list>`);
+      element.diffPrefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        ignore_whitespace: 'IGNORE_NONE',
+      };
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        project: 'testRepo' as RepoName,
+      };
+      reviewFileStub = sinon.stub(element, 'reviewFile');
+
+      element.numFilesShown = 75;
+      element.selectedIndex = 0;
+      element.files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9, size: 0, size_delta: 0},
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      ];
+      element.reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element.changeNum = 42 as NumericChangeId;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      sinon
+        .stub(window, 'fetch')
+        .callsFake(() => Promise.resolve(new Response()));
+      await element.updateComplete;
+    });
+
+    test('cursor with individually opened files', async () => {
+      await element.updateComplete;
+      pressKey(element, 'i');
+
+      await waitUntil(async () => {
+        const diffs = await renderAndGetNewDiffs(0);
+        return diffs.length > 0;
+      });
+      let diffs = await renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 1);
+      assert.isTrue(diffStops.length > 12);
+
+      // No line number is selected.
+      assert.isFalse(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+
+      // Tapping content on a line selects the line number.
+      queryAll<HTMLDivElement>(
+        diffStops[10] as HTMLElement,
+        '.contentText'
+      )[0].click();
+      await element.updateComplete;
+      assert.isTrue(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      pressKey(element, 'j');
+      await element.updateComplete;
+      assert.isTrue(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+      assert.isFalse(
+        (diffStops[11] as HTMLElement).classList.contains('target-row')
+      );
+
+      // The file cursor is now at 1.
+      assert.equal(element.fileCursor.index, 1);
+
+      pressKey(element, 'i');
+      await element.updateComplete;
+      diffs = await renderAndGetNewDiffs(1);
+
+      // Two diffs should be rendered.
+      assert.equal(diffs.length, 2);
+      const diffStopsFirst = diffs[0].getCursorStops();
+      const diffStopsSecond = diffs[1].getCursorStops();
+
+      // The line on the first diff is still selected
+      assert.isTrue(
+        (diffStopsFirst[10] as HTMLElement).classList.contains('target-row')
+      );
+      assert.isFalse(
+        (diffStopsSecond[10] as HTMLElement).classList.contains('target-row')
+      );
+    });
+
+    test('cursor with toggle all files', async () => {
+      pressKey(element, 'I');
+      await element.updateComplete;
+
+      const diffs = await renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 3);
+      assert.isTrue(diffStops.length > 12);
+
+      // No line number is selected.
+      assert.isFalse(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+
+      // Tapping content on a line selects the line number.
+      queryAll<HTMLDivElement>(
+        diffStops[10] as HTMLElement,
+        '.contentText'
+      )[0].click();
+      await element.updateComplete;
+      assert.isTrue(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      pressKey(element, 'j');
+      await element.updateComplete;
+      assert.isFalse(
+        (diffStops[10] as HTMLElement).classList.contains('target-row')
+      );
+      assert.isTrue(
+        (diffStops[11] as HTMLElement).classList.contains('target-row')
+      );
+
+      // The file cursor is still at 0.
+      assert.equal(element.fileCursor.index, 0);
+    });
+
+    suite('n key presses', () => {
+      let nextCommentStub: sinon.SinonStub;
+      let nextChunkStub: sinon.SinonStub;
+      let fileRows: NodeListOf<HTMLDivElement>;
+
+      setup(() => {
+        sinon.stub(element, 'renderInOrder').returns(Promise.resolve());
+        assertIsDefined(element.diffCursor);
+        nextCommentStub = sinon.stub(
+          element.diffCursor,
+          'moveToNextCommentThread'
+        );
+        nextChunkStub = sinon.stub(element.diffCursor, 'moveToNextChunk');
+        fileRows = queryAll<HTMLDivElement>(element, '.row:not(.header-row)');
+      });
+
+      test('correct number of files expanded', async () => {
+        pressKey(fileRows[0], 'i');
+        await element.updateComplete;
+        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
+
+        pressKey(element, 'n');
+        await element.updateComplete;
+        assert.isTrue(nextChunkStub.calledOnce);
+      });
+
+      test('N key with some files expanded', async () => {
+        pressKey(fileRows[0], 'i');
+        await element.updateComplete;
+        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
+
+        pressKey(element, 'N');
+        await element.updateComplete;
+        assert.isTrue(nextCommentStub.calledOnce);
+      });
+
+      test('n key with all files expanded', async () => {
+        pressKey(fileRows[0], 'I');
+        await element.updateComplete;
+        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+
+        pressKey(element, 'n');
+        await element.updateComplete;
+        assert.isTrue(nextChunkStub.calledOnce);
+      });
+
+      test('N key with all files expanded', async () => {
+        pressKey(fileRows[0], 'I');
+        await element.updateComplete;
+        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+
+        pressKey(element, 'N');
+        await element.updateComplete;
+        assert.isTrue(nextCommentStub.called);
+      });
+    });
+
+    test('openSelectedFile behavior', async () => {
+      const files = element.files;
+      element.files = [];
+      await element.updateComplete;
+      const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+      // Noop when there are no files.
+      element.openSelectedFile();
+      assert.isFalse(setUrlStub.calledOnce);
+
+      element.files = files;
+      await element.updateComplete;
+      // Navigates when a file is selected.
+      element.openSelectedFile();
+      assert.isTrue(setUrlStub.calledOnce);
+    });
+
+    test('displayLine', () => {
+      element.filesExpanded = FilesExpandedState.ALL;
+
+      element.displayLine = false;
+      element.handleCursorNext(new KeyboardEvent('keydown'));
+      assert.isTrue(element.displayLine);
+
+      element.displayLine = false;
+      element.handleCursorPrev(new KeyboardEvent('keydown'));
+      assert.isTrue(element.displayLine);
+
+      element.displayLine = true;
+      element.handleEscKey();
+      assert.isFalse(element.displayLine);
+    });
+
+    suite('editMode behavior', () => {
+      test('reviewed checkbox', async () => {
+        reviewFileStub.restore();
+        const saveReviewStub = sinon.stub(element, '_saveReviewedState');
+
+        element.editMode = false;
+        pressKey(element, 'r');
+        assert.isTrue(saveReviewStub.calledOnce);
+
+        element.editMode = true;
+        await element.updateComplete;
+
+        pressKey(element, 'r');
+        assert.isTrue(saveReviewStub.calledOnce);
+      });
+    });
+
+    test('editing actions', async () => {
+      // Edit controls are guarded behind a dom-if initially and not rendered.
+      assert.isNotOk(
+        query<GrEditFileControls>(element, 'gr-edit-file-controls')
+      );
+
+      element.editMode = true;
+      await element.updateComplete;
+
+      // Commit message should not have edit controls.
+      const editControls = Array.from(
+        queryAll(element, '.row:not(.header-row)')
+      ).map(row => row.querySelector('gr-edit-file-controls'));
+      assert.isTrue(editControls[0]!.classList.contains('invisible'));
+    });
+  });
+});
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 d50e00f..fcfe209 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
@@ -1,28 +1,17 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-font-styles';
 import '../../shared/gr-button/gr-button';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-included-in-dialog_html';
-import {customElement, property} from '@polymer/decorators';
 import {IncludedInInfo, NumericChangeId} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
 
 interface DisplayGroup {
   title: string;
@@ -30,77 +19,182 @@
 }
 
 @customElement('gr-included-in-dialog')
-export class GrIncludedInDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrIncludedInDialog extends LitElement {
   /**
    * Fired when the user presses the close button.
    *
    * @event close
    */
 
-  @property({type: Object, observer: '_resetData'})
+  @property({type: Object})
   changeNum?: NumericChangeId;
 
-  @property({type: Object})
-  _includedIn?: IncludedInInfo;
+  // private but used in test
+  @state() includedIn?: IncludedInInfo;
 
-  @property({type: Boolean})
-  _loaded = false;
+  @state() private loaded = false;
 
-  @property({type: String})
-  _filterText = '';
+  // private but used in test
+  @state() filterText = '';
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          background-color: var(--dialog-background-color);
+          display: block;
+          max-height: 80vh;
+          overflow-y: auto;
+          padding: 4.5em var(--spacing-l) var(--spacing-l) var(--spacing-l);
+        }
+        header {
+          background-color: var(--dialog-background-color);
+          border-bottom: 1px solid var(--border-color);
+          left: 0;
+          padding: var(--spacing-l);
+          position: absolute;
+          right: 0;
+          top: 0;
+        }
+        #title {
+          display: inline-block;
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+          margin-top: var(--spacing-xs);
+        }
+        #filterInput {
+          display: block;
+          float: right;
+          margin: 0 var(--spacing-l);
+          padding: var(--spacing-xs);
+        }
+        .closeButtonContainer {
+          float: right;
+        }
+        ul {
+          margin-bottom: var(--spacing-l);
+        }
+        ul li {
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          background: var(--chip-background-color);
+          display: inline-block;
+          margin: 0 var(--spacing-xs) var(--spacing-s) var(--spacing-xs);
+          padding: var(--spacing-xs) var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <header>
+        <h1 id="title" class="heading-1">Included In:</h1>
+        <span class="closeButtonContainer">
+          <gr-button
+            id="closeButton"
+            link
+            @click=${(e: Event) => {
+              this.handleCloseTap(e);
+            }}
+            >Close</gr-button
+          >
+        </span>
+        <iron-input
+          id="filterInput"
+          .bindValue=${this.filterText}
+          @bind-value-changed=${(e: BindValueChangeEvent) => {
+            this.filterText = e.detail.value ?? '';
+          }}
+        >
+          <input placeholder="Filter" />
+        </iron-input>
+      </header>
+      ${this.renderLoading()}
+      ${this.computeGroups().map(group => this.renderGroup(group))}
+    `;
+  }
+
+  private renderLoading() {
+    if (this.loaded) return;
+
+    return html`<div>Loading...</div>`;
+  }
+
+  private renderGroup(group: DisplayGroup) {
+    return html`
+      <div>
+        <span>${group.title}:</span>
+        <ul>
+          ${group.items.map(item => this.renderGroupItem(item))}
+        </ul>
+      </div>
+    `;
+  }
+
+  private renderGroupItem(item: string) {
+    return html`<li>${item}</li>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('changeNum')) {
+      this.resetData();
+    }
+  }
 
   loadData() {
     if (!this.changeNum) {
       return Promise.reject(new Error('missing required property changeNum'));
     }
-    this._filterText = '';
+    this.filterText = '';
     return this.restApiService
       .getChangeIncludedIn(this.changeNum)
       .then(configs => {
         if (!configs) {
           return;
         }
-        this._includedIn = configs;
-        this._loaded = true;
+        this.includedIn = configs;
+        this.loaded = true;
       });
   }
 
-  _resetData() {
-    this._includedIn = undefined;
-    this._loaded = false;
+  private resetData() {
+    this.includedIn = undefined;
+    this.loaded = false;
   }
 
-  _computeGroups(includedIn: IncludedInInfo | undefined, filterText: string) {
-    if (!includedIn || filterText === undefined) {
+  // private but used in test
+  computeGroups() {
+    if (!this.includedIn || this.filterText === undefined) {
       return [];
     }
 
     const filter = (item: string) =>
-      !filterText.length ||
-      item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+      !this.filterText.length ||
+      item.toLowerCase().indexOf(this.filterText.toLowerCase()) !== -1;
 
     const groups: DisplayGroup[] = [
-      {title: 'Branches', items: includedIn.branches.filter(filter)},
-      {title: 'Tags', items: includedIn.tags.filter(filter)},
+      {title: 'Branches', items: this.includedIn.branches.filter(filter)},
+      {title: 'Tags', items: this.includedIn.tags.filter(filter)},
     ];
-    if (includedIn.external) {
-      for (const externalKey of Object.keys(includedIn.external)) {
+    if (this.includedIn.external) {
+      for (const externalKey of Object.keys(this.includedIn.external)) {
         groups.push({
           title: externalKey,
-          items: includedIn.external[externalKey].filter(filter),
+          items: this.includedIn.external[externalKey].filter(filter),
         });
       }
     }
     return groups.filter(g => g.items.length);
   }
 
-  _handleCloseTap(e: Event) {
+  private handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -110,10 +204,6 @@
       })
     );
   }
-
-  _computeLoadingClass(loaded: boolean) {
-    return loaded ? 'loading loaded' : 'loading';
-  }
 }
 
 declare global {
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
deleted file mode 100644
index 674b7e7..0000000
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_html.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      background-color: var(--dialog-background-color);
-      display: block;
-      max-height: 80vh;
-      overflow-y: auto;
-      padding: 4.5em var(--spacing-l) var(--spacing-l) var(--spacing-l);
-    }
-    header {
-      background-color: var(--dialog-background-color);
-      border-bottom: 1px solid var(--border-color);
-      left: 0;
-      padding: var(--spacing-l);
-      position: absolute;
-      right: 0;
-      top: 0;
-    }
-    #title {
-      display: inline-block;
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-      margin-top: var(--spacing-xs);
-    }
-    #filterInput {
-      display: inline-block;
-      float: right;
-      margin: 0 var(--spacing-l);
-      padding: var(--spacing-xs);
-    }
-    .closeButtonContainer {
-      float: right;
-    }
-    ul {
-      margin-bottom: var(--spacing-l);
-    }
-    ul li {
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      background: var(--chip-background-color);
-      display: inline-block;
-      margin: 0 var(--spacing-xs) var(--spacing-s) var(--spacing-xs);
-      padding: var(--spacing-xs) var(--spacing-s);
-    }
-    .loading.loaded {
-      display: none;
-    }
-  </style>
-  <header>
-    <h1 id="title" class="heading-1">Included In:</h1>
-    <span class="closeButtonContainer">
-      <gr-button id="closeButton" link="" on-click="_handleCloseTap"
-        >Close</gr-button
-      >
-    </span>
-    <iron-input
-      id="filterInput"
-      placeholder="Filter"
-      bind-value="{{_filterText}}"
-    >
-      <input
-        is="iron-input"
-        placeholder="Filter"
-        bind-value="{{_filterText}}"
-      />
-    </iron-input>
-  </header>
-  <div class$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
-  <template
-    is="dom-repeat"
-    items="[[_computeGroups(_includedIn, _filterText)]]"
-    as="group"
-  >
-    <div>
-      <span>[[group.title]]:</span>
-      <ul>
-        <template is="dom-repeat" items="[[group.items]]">
-          <li>[[item]]</li>
-        </template>
-      </ul>
-    </div>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts
index 4e02155..3851255 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts
@@ -1,92 +1,106 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-included-in-dialog';
 import {GrIncludedInDialog} from './gr-included-in-dialog';
 import {BranchName, IncludedInInfo, TagName} from '../../../types/common';
 import {IronInputElement} from '@polymer/iron-input';
 import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-included-in-dialog');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-included-in-dialog', () => {
   let element: GrIncludedInDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-included-in-dialog></gr-included-in-dialog>`
+    );
   });
 
-  test('_computeGroups', () => {
-    const includedIn = {branches: [], tags: []} as IncludedInInfo;
-    let filterText = '';
-    assert.deepEqual(element._computeGroups(includedIn, filterText), []);
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <header>
+          <h1 class="heading-1" id="title">Included In:</h1>
+          <span class="closeButtonContainer">
+            <gr-button
+              aria-disabled="false"
+              id="closeButton"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Close
+            </gr-button>
+          </span>
+          <iron-input id="filterInput">
+            <input placeholder="Filter" />
+          </iron-input>
+        </header>
+        <div>Loading...</div>
+      `
+    );
+  });
 
-    includedIn.branches.push(
+  test('computeGroups', () => {
+    element.includedIn = {branches: [], tags: []} as IncludedInInfo;
+    element.filterText = '';
+    assert.deepEqual(element.computeGroups(), []);
+
+    element.includedIn.branches.push(
       'master' as BranchName,
       'development' as BranchName,
       'stable-2.0' as BranchName
     );
-    includedIn.tags.push(
+    element.includedIn.tags.push(
       'v1.9' as TagName,
       'v2.0' as TagName,
       'v2.1' as TagName
     );
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
       {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
     ]);
 
-    includedIn.external = {};
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    element.includedIn.external = {};
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
       {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
     ]);
 
-    includedIn.external.foo = ['abc', 'def', 'ghi'];
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    element.includedIn.external.foo = ['abc', 'def', 'ghi'];
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
       {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
       {title: 'foo', items: ['abc', 'def', 'ghi']},
     ]);
 
-    filterText = 'v2';
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    element.filterText = 'v2';
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Tags', items: ['v2.0', 'v2.1']},
     ]);
 
     // Filtering is case-insensitive.
-    filterText = 'V2';
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    element.filterText = 'V2';
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Tags', items: ['v2.0', 'v2.1']},
     ]);
   });
 
-  test('_computeGroups with .bindValue', async () => {
+  test('computeGroups with .bindValue', async () => {
     queryAndAssert<IronInputElement>(element, '#filterInput')!.bindValue =
       'stable-3.2';
-    const includedIn = {branches: [], tags: []} as IncludedInInfo;
-    includedIn.branches.push(
+    element.includedIn = {branches: [], tags: []} as IncludedInInfo;
+    element.includedIn.branches.push(
       'master' as BranchName,
       'stable-3.2' as BranchName
     );
-    await flush();
-    const filterText = element._filterText;
-    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+    await element.updateComplete;
+    assert.deepEqual(element.computeGroups(), [
       {title: 'Branches', items: ['stable-3.2']},
     ]);
   });
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 9a38095..50c5caf 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
@@ -1,50 +1,23 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-selector/iron-selector';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-label-score-row_html';
-import {customElement, property} from '@polymer/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {IronSelectorElement} from '@polymer/iron-selector/iron-selector';
 import {
-  LabelNameToValueMap,
   LabelNameToInfoMap,
   QuickLabelInfo,
   DetailedLabelInfo,
 } from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
-
-export interface Label {
-  name: string;
-  value: string | null;
-}
-
-// TODO(TS): add description to explain what this is after moving
-// gr-label-scores to ts
-export interface LabelValuesMap {
-  [key: number]: number;
-}
-
-export interface GrLabelScoreRow {
-  $: {
-    labelSelector: IronSelectorElement;
-  };
-}
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
+import {Label} from '../../../utils/label-util';
+import {LabelNameToValuesMap} from '../../../api/rest-api';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -53,115 +26,286 @@
 }
 
 @customElement('gr-label-score-row')
-export class GrLabelScoreRow extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrLabelScoreRow extends LitElement {
   /**
    * Fired when any label is changed.
    *
    * @event labels-changed
    */
 
+  @query('#labelSelector')
+  labelSelector?: IronSelectorElement;
+
   @property({type: Object})
   label: Label | undefined | null;
 
   @property({type: Object})
   labels?: LabelNameToInfoMap;
 
-  @property({type: String, reflectToAttribute: true})
+  @property({type: String, reflect: true})
   name?: string;
 
   @property({type: Object})
-  permittedLabels: LabelNameToValueMap | undefined | null;
+  permittedLabels: LabelNameToValuesMap | undefined | null;
 
-  @property({type: Object})
-  labelValues?: LabelValuesMap;
+  @property({type: Array})
+  orderedLabelValues?: number[];
 
-  @property({type: String})
-  _selectedValueText = 'No value selected';
+  @state()
+  private selectedValueText = 'No value selected';
 
-  @property({
-    computed: '_computePermittedLabelValues(permittedLabels, label.name)',
-    type: Array,
-  })
-  _items!: string[];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .labelNameCell,
+        .buttonsCell,
+        .selectedValueCell {
+          padding: var(--spacing-s) var(--spacing-m);
+          display: table-cell;
+        }
+        /* We want the :hover highlight to extend to the border of the dialog. */
+        .labelNameCell {
+          padding-left: var(--label-score-padding-left, 0);
+          width: 160px;
+        }
+        .selectedValueCell {
+          padding-right: var(--spacing-xl);
+        }
+        /* This is a trick to let the selectedValueCell take the remaining width. */
+        .labelNameCell,
+        .buttonsCell {
+          white-space: nowrap;
+        }
+        .selectedValueCell {
+          width: 52%;
+        }
+        .labelMessage {
+          color: var(--deemphasized-text-color);
+        }
+        gr-button {
+          min-width: 42px;
+          box-sizing: border-box;
+          --vote-text-color: var(--vote-chip-unselected-text-color);
+        }
+        gr-button.iron-selected {
+          --vote-text-color: var(--vote-chip-selected-text-color);
+        }
+        gr-button::part(paper-button) {
+          padding: 0 var(--spacing-m);
+          background-color: var(
+            --button-background-color,
+            var(--table-header-background-color)
+          );
+          border-color: var(--vote-chip-unselected-outline-color);
+        }
+        gr-button.iron-selected::part(paper-button) {
+          border-color: transparent;
+        }
+        gr-button {
+          --button-background-color: var(--vote-chip-unselected-color);
+        }
+        gr-button[data-vote='max'].iron-selected {
+          --button-background-color: var(--vote-chip-selected-positive-color);
+        }
+        gr-button[data-vote='positive'].iron-selected {
+          --button-background-color: var(--vote-chip-selected-positive-color);
+        }
+        gr-button[data-vote='neutral'].iron-selected {
+          --button-background-color: var(--vote-chip-selected-neutral-color);
+        }
+        gr-button[data-vote='negative'].iron-selected {
+          --button-background-color: var(--vote-chip-selected-negative-color);
+        }
+        gr-button[data-vote='min'].iron-selected {
+          --button-background-color: var(--vote-chip-selected-negative-color);
+        }
+        gr-button > gr-tooltip-content {
+          margin: 0px -10px;
+          padding: 0px 10px;
+        }
+        .placeholder {
+          display: inline-block;
+          width: 42px;
+          height: 1px;
+        }
+        .placeholder::before {
+          content: ' ';
+        }
+        .selectedValueCell {
+          color: var(--deemphasized-text-color);
+          font-style: italic;
+        }
+        .selectedValueCell.hidden {
+          display: none;
+        }
+        @media only screen and (max-width: 50em) {
+          .selectedValueCell {
+            display: none;
+          }
+        }
+      `,
+    ];
+  }
 
-  get selectedItem() {
-    if (!this._ironSelector) {
+  override render() {
+    return html`
+      <span class="labelNameCell" id="labelName" aria-hidden="true"
+        >${this.label?.name ?? ''}</span
+      >
+      ${this.renderButtonsCell()} ${this.renderSelectedValue()}
+    `;
+  }
+
+  private renderButtonsCell() {
+    return html`
+      <div class="buttonsCell">
+        ${this.renderBlankItems('start')} ${this.renderLabelSelector()}
+        ${this.renderBlankItems('end')}
+      </div>
+    `;
+  }
+
+  // Render blank cells so that all same value votes are aligned
+  private renderBlankItems(position: string) {
+    const blankItemCount = this.computeBlankItemsCount(position);
+    return new Array(blankItemCount)
+      .fill('')
+      .map(
+        () => html`
+          <span class="placeholder" data-label=${this.label?.name ?? ''}>
+          </span>
+        `
+      );
+  }
+
+  private renderLabelSelector() {
+    return html`
+      <iron-selector
+        id="labelSelector"
+        .attrForSelected=${'data-value'}
+        selected=${ifDefined(this._computeLabelValue())}
+        @selected-item-changed=${this.setSelectedValueText}
+        role="radiogroup"
+        aria-labelledby="labelName"
+      >
+        ${this.renderPermittedLabels()}
+      </iron-selector>
+    `;
+  }
+
+  private renderPermittedLabels() {
+    const items = this.computePermittedLabelValues();
+    return items.map(
+      (value, index) => html`
+        <gr-button
+          role="button"
+          title=${ifDefined(this.computeLabelValueTitle(value))}
+          data-vote=${this._computeVoteAttribute(
+            Number(value),
+            index,
+            items.length
+          )}
+          data-name=${ifDefined(this.label?.name)}
+          data-value=${value}
+          aria-label=${value}
+          voteChip
+          flatten
+        >
+          <gr-tooltip-content
+            has-tooltip
+            light-tooltip
+            title=${ifDefined(this.computeLabelValueTitle(value))}
+          >
+            ${value}
+          </gr-tooltip-content>
+        </gr-button>
+      `
+    );
+  }
+
+  private renderSelectedValue() {
+    return html`
+      <div class="selectedValueCell">
+        <span id="selectedValueLabel">${this.selectedValueText}</span>
+      </div>
+    `;
+  }
+
+  get selectedItem(): IronSelectorElement | undefined {
+    if (!this.labelSelector) {
       return undefined;
     }
-    return this._ironSelector.selectedItem;
+    return this.labelSelector.selectedItem as IronSelectorElement;
   }
 
   get selectedValue() {
-    if (!this._ironSelector) {
+    if (!this.labelSelector) {
       return undefined;
     }
-    return this._ironSelector.selected;
+    return this.labelSelector.selected;
   }
 
   setSelectedValue(value: string) {
     // The selector may not be present if it’s not at the latest patch set.
-    if (!this._ironSelector) {
+    if (!this.labelSelector) {
       return;
     }
-    this._ironSelector.select(value);
+    this.labelSelector.select(value);
   }
 
-  get _ironSelector() {
-    return this.$ && this.$.labelSelector;
-  }
-
-  _computeBlankItems(
-    permittedLabels: LabelNameToValueMap,
-    label: string,
-    side: string
-  ) {
+  // Private but used in tests.
+  computeBlankItemsCount(side: string) {
     if (
-      !permittedLabels ||
-      !permittedLabels[label] ||
-      !permittedLabels[label].length ||
-      !this.labelValues ||
-      !Object.keys(this.labelValues).length
+      !this.label ||
+      !this.permittedLabels?.[this.label.name] ||
+      !this.permittedLabels[this.label.name].length ||
+      !this.orderedLabelValues?.length
     ) {
-      return [];
+      return 0;
     }
-    const startPosition = this.labelValues[Number(permittedLabels[label][0])];
+    const orderedLabelValues = this.orderedLabelValues;
+    const permittedLabel = this.permittedLabels[this.label.name];
+    // How many empty cells need to be rendered to the left before showing
+    // the first value of the label range. If min value of the label is -1 and
+    // overall min possible is -2 then we render one empty cell. If overall min
+    // is -1 then we don't render any empty cell.
     if (side === 'start') {
-      return new Array(startPosition);
+      return Number(permittedLabel[0]) - orderedLabelValues[0];
     }
-    const endPosition =
-      this.labelValues[
-        Number(permittedLabels[label][permittedLabels[label].length - 1])
-      ];
-    return new Array(Object.keys(this.labelValues).length - endPosition - 1);
+    // How many empty cells need to be rendered to the right after showing the
+    // last value of the label range. If max value is +1 and overall max value
+    // is +2 we add one empty cell to the right.
+    return (
+      orderedLabelValues[orderedLabelValues.length - 1] -
+      Number(permittedLabel[permittedLabel.length - 1])
+    );
   }
 
-  _getLabelValue(
-    labels: LabelNameToInfoMap,
-    permittedLabels: LabelNameToValueMap,
-    label: Label
-  ) {
-    if (label.value) {
-      return label.value;
+  private getLabelValue() {
+    assertIsDefined(this.labels);
+    assertIsDefined(this.label);
+    assertIsDefined(this.permittedLabels);
+    if (this.label.value) {
+      return this.label.value;
     } else if (
-      hasOwnProperty(labels[label.name], 'default_value') &&
-      hasOwnProperty(permittedLabels, label.name)
+      hasOwnProperty(this.labels[this.label.name], 'default_value') &&
+      hasOwnProperty(this.permittedLabels, this.label.name)
     ) {
       // default_value is an int, convert it to string label, e.g. "+1".
-      return permittedLabels[label.name].find(
+      return this.permittedLabels[this.label.name].find(
         value =>
-          Number(value) === (labels[label.name] as QuickLabelInfo).default_value
+          Number(value) ===
+          (this.labels![this.label!.name] as QuickLabelInfo).default_value
       );
     }
     return;
   }
 
   /**
+   * Private but used in tests.
    * Maps the label value to exactly one of: min, max, positive, negative,
-   * neutral. Used for the 'vote' attribute, because we don't want to
+   * neutral. Used for the 'data-vote' attribute, because we don't want to
    * interfere with <iron-selector> using the 'class' attribute for setting
    * 'iron-selected'.
    */
@@ -179,37 +323,28 @@
     }
   }
 
-  _computeLabelValue(
-    labels?: LabelNameToInfoMap,
-    permittedLabels?: LabelNameToValueMap,
-    label?: Label
-  ) {
-    // Polymer 2+ undefined check
-    if (
-      labels === undefined ||
-      permittedLabels === undefined ||
-      label === undefined
-    ) {
-      return null;
+  // Private but used in tests.
+  _computeLabelValue() {
+    if (!this.labels || !this.permittedLabels || !this.label) {
+      return undefined;
     }
 
-    if (!labels[label.name]) {
-      return null;
+    if (!this.labels[this.label.name]) {
+      return undefined;
     }
-    const labelValue = this._getLabelValue(labels, permittedLabels, label);
-    const len = permittedLabels[label.name]
-      ? permittedLabels[label.name].length
-      : 0;
+    const labelValue = this.getLabelValue();
+    const permittedLabel = this.permittedLabels[this.label.name];
+    const len = permittedLabel ? permittedLabel.length : 0;
     for (let i = 0; i < len; i++) {
-      const val = permittedLabels[label.name][i];
+      const val = permittedLabel[i];
       if (val === labelValue) {
         return val;
       }
     }
-    return null;
+    return undefined;
   }
 
-  _setSelectedValueText(e: Event) {
+  private setSelectedValueText = (e: Event) => {
     // Needed because when the selected item changes, it first changes to
     // nothing and then to the new item.
     const selectedItem = (e.target as IronSelectorElement)
@@ -217,19 +352,17 @@
     if (!selectedItem) {
       return;
     }
-    if (!this.$.labelSelector.items) {
+    if (!this.labelSelector?.items) {
       return;
     }
-    for (const item of this.$.labelSelector.items) {
+    for (const item of this.labelSelector.items) {
       if (selectedItem === item) {
         item.setAttribute('aria-checked', 'true');
       } else {
         item.removeAttribute('aria-checked');
       }
     }
-    this._selectedValueText = selectedItem.getAttribute('title') || '';
-    // Needed to update the style of the selected button.
-    this.updateStyles();
+    this.selectedValueText = selectedItem.getAttribute('title') || '';
     const name = selectedItem.dataset['name'];
     const value = selectedItem.dataset['value'];
     this.dispatchEvent(
@@ -239,47 +372,35 @@
         composed: true,
       })
     );
-  }
+  };
 
-  _computeAnyPermittedLabelValues(
-    permittedLabels: LabelNameToValueMap,
-    labelName: string
-  ) {
-    return (
-      permittedLabels &&
-      hasOwnProperty(permittedLabels, labelName) &&
-      permittedLabels[labelName].length
-    );
-  }
-
-  _computeHiddenClass(permittedLabels: LabelNameToValueMap, labelName: string) {
-    return !this._computeAnyPermittedLabelValues(permittedLabels, labelName)
-      ? 'hidden'
-      : '';
-  }
-
-  _computePermittedLabelValues(
-    permittedLabels?: LabelNameToValueMap,
-    labelName?: string
-  ) {
-    // Polymer 2: check for undefined
-    if (permittedLabels === undefined || labelName === undefined) {
+  private computePermittedLabelValues() {
+    if (!this.permittedLabels || !this.label) {
       return [];
     }
 
-    return permittedLabels[labelName] || [];
+    return this.permittedLabels[this.label.name] || [];
   }
 
-  _computeLabelValueTitle(
-    labels: LabelNameToInfoMap,
-    label: string,
-    value: string
-  ) {
-    // TODO(TS): maybe add a type guard for DetailedLabelInfo and QuickLabelInfo
-    return (
-      labels[label] &&
-      (labels[label] as DetailedLabelInfo).values &&
-      (labels[label] as DetailedLabelInfo).values![value]
-    );
+  // private but used in tests
+  computeLabelValueTitle(value: string) {
+    if (!this.labels || !this.label) return '';
+    const label = this.labels[this.label.name] as DetailedLabelInfo;
+    if (label && label.values) {
+      // In case the user already voted a certain value and then selects 0
+      // we should show "Reset Vote" instead of "No Value selected"
+      if (
+        Number(value) === 0 &&
+        this.label.value &&
+        Number(this.label.value) !== 0
+      ) {
+        return 'Reset Vote';
+      }
+      // TODO(TS): maybe add a type guard for DetailedLabelInfo and
+      // QuickLabelInfo
+      return label.values[value];
+    } else {
+      return '';
+    }
   }
 }
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
deleted file mode 100644
index e57a216..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .labelNameCell,
-    .buttonsCell,
-    .selectedValueCell {
-      padding: var(--spacing-s) var(--spacing-m);
-      display: table-cell;
-    }
-    /* We want the :hover highlight to extend to the border of the dialog. */
-    .labelNameCell {
-      padding-left: var(--spacing-xl);
-    }
-    .selectedValueCell {
-      padding-right: var(--spacing-xl);
-    }
-    /* This is a trick to let the selectedValueCell take the remaining width. */
-    .labelNameCell,
-    .buttonsCell {
-      white-space: nowrap;
-    }
-    .selectedValueCell {
-      width: 75%;
-    }
-    .labelMessage {
-      color: var(--deemphasized-text-color);
-    }
-    gr-button {
-      min-width: 42px;
-      box-sizing: border-box;
-    }
-    gr-button::part(paper-button) {
-      background-color: var(
-        --button-background-color,
-        var(--table-header-background-color)
-      );
-      padding: 0 var(--spacing-m);
-    }
-    gr-button[vote='max'].iron-selected {
-      --button-background-color: var(--vote-color-approved);
-    }
-    gr-button[vote='positive'].iron-selected {
-      --button-background-color: var(--vote-color-recommended);
-    }
-    gr-button[vote='min'].iron-selected {
-      --button-background-color: var(--vote-color-rejected);
-    }
-    gr-button[vote='negative'].iron-selected {
-      --button-background-color: var(--vote-color-disliked);
-    }
-    gr-button[vote='neutral'].iron-selected {
-      --button-background-color: var(--vote-color-neutral);
-    }
-    gr-button[vote='positive'].iron-selected::part(paper-button) {
-      border-color: var(--vote-outline-recommended);
-    }
-    gr-button[vote='negative'].iron-selected::part(paper-button) {
-      border-color: var(--vote-outline-disliked);
-    }
-    gr-button > gr-tooltip-content {
-      margin: 0px -10px;
-      padding: 0px 10px;
-    }
-    .placeholder {
-      display: inline-block;
-      width: 42px;
-      height: 1px;
-    }
-    .placeholder::before {
-      content: ' ';
-    }
-    .selectedValueCell {
-      color: var(--deemphasized-text-color);
-      font-style: italic;
-    }
-    .selectedValueCell.hidden {
-      display: none;
-    }
-    @media only screen and (max-width: 50em) {
-      .selectedValueCell {
-        display: none;
-      }
-    }
-  </style>
-  <span class="labelNameCell" id="labelName" aria-hidden="true"
-    >[[label.name]]</span
-  >
-  <div class="buttonsCell">
-    <template
-      is="dom-repeat"
-      items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
-      as="value"
-    >
-      <span class="placeholder" data-label$="[[label.name]]"></span>
-    </template>
-    <iron-selector
-      id="labelSelector"
-      attr-for-selected="data-value"
-      selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
-      hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
-      on-selected-item-changed="_setSelectedValueText"
-      role="radiogroup"
-      aria-labelledby="labelName"
-    >
-      <template is="dom-repeat" items="[[_items]]" as="value">
-        <gr-button
-          role="radio"
-          vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
-          title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
-          data-name$="[[label.name]]"
-          data-value$="[[value]]"
-          aria-label$="[[value]]"
-          voteChip
-        >
-          <gr-tooltip-content
-            has-tooltip
-            title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
-          >
-            [[value]]
-          </gr-tooltip-content>
-        </gr-button>
-      </template>
-    </iron-selector>
-    <template
-      is="dom-repeat"
-      items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
-      as="value"
-    >
-      <span class="placeholder" data-label$="[[label.name]]"></span>
-    </template>
-    <span
-      class="labelMessage"
-      hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
-    >
-      You don't have permission to edit this label.
-    </span>
-  </div>
-  <div
-    class$="selectedValueCell [[_computeHiddenClass(permittedLabels, label.name)]]"
-  >
-    <span id="selectedValueLabel">[[_selectedValueText]]</span>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
deleted file mode 100644
index 51d76b2..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
+++ /dev/null
@@ -1,339 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-label-score-row.js';
-
-const basicFixture = fixtureFromElement('gr-label-score-row');
-
-suite('gr-label-row-score tests', () => {
-  let element;
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    element.labels = {
-      'Code-Review': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: 0,
-        value: 1,
-        all: [{
-          _account_id: 123,
-          value: 1,
-        }],
-      },
-      'Verified': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: 0,
-        value: 1,
-        all: [{
-          _account_id: 123,
-          value: 1,
-        }],
-      },
-    };
-
-    element.permittedLabels = {
-      'Code-Review': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-
-    element.labelValues = {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
-
-    element.label = {
-      name: 'Verified',
-      value: '+1',
-    };
-
-    await flush();
-  });
-
-  function checkAriaCheckedValid() {
-    const items = element.$.labelSelector.items;
-    const selectedItem = element.selectedItem;
-    for (let i = 0; i < items.length; i++) {
-      const item = items[i];
-      if (items[i] === selectedItem) {
-        assert.isTrue(item.hasAttribute('aria-checked'), `item ${i}`);
-        assert.equal(item.getAttribute('aria-checked'), 'true', `item ${i}`);
-      } else {
-        assert.isFalse(item.hasAttribute('aria-checked'), `item ${i}`);
-      }
-    }
-  }
-
-  test('label picker', async () => {
-    const labelsChangedHandler = sinon.stub();
-    element.addEventListener('labels-changed', labelsChangedHandler);
-    assert.ok(element.$.labelSelector);
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('gr-button[data-value="-1"]'));
-    await flush();
-    assert.strictEqual(element.selectedValue, '-1');
-    assert.strictEqual(element.selectedItem.textContent.trim(), '-1');
-    assert.strictEqual(element.$.selectedValueLabel.textContent.trim(), 'bad');
-    const detail = labelsChangedHandler.args[0][0].detail;
-    assert.equal(detail.name, 'Verified');
-    assert.equal(detail.value, '-1');
-    checkAriaCheckedValid();
-  });
-
-  test('_computeVoteAttribute', () => {
-    let value = 1;
-    let index = 0;
-    const totalItems = 5;
-    // positive and first position
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'positive');
-    // negative and first position
-    value = -1;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'min');
-    // negative but not first position
-    index = 1;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'negative');
-    // neutral
-    value = 0;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'neutral');
-    // positive but not last position
-    value = 1;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'positive');
-    // positive and last position
-    index = 4;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'max');
-    // negative and last position
-    value = -1;
-    assert.equal(
-        element._computeVoteAttribute(value, index, totalItems), 'negative');
-  });
-
-  test('correct item is selected', () => {
-    // 1 should be the value of the selected item
-    assert.strictEqual(element.$.labelSelector.selected, '+1');
-    assert.strictEqual(
-        element.$.labelSelector.selectedItem.textContent.trim(), '+1');
-    assert.strictEqual(element.$.selectedValueLabel.textContent.trim(), 'good');
-    checkAriaCheckedValid();
-  });
-
-  test('_computeLabelValue', () => {
-    assert.strictEqual(
-        element._computeLabelValue(
-            element.labels, element.permittedLabels, element.label),
-        '+1');
-  });
-
-  test('_computeBlankItems', () => {
-    element.labelValues = {
-      '-2': 0,
-      '-1': 1,
-      '0': 2,
-      '1': 3,
-      '2': 4,
-    };
-
-    assert.strictEqual(
-        element._computeBlankItems(element.permittedLabels, 'Code-Review')
-            .length,
-        0);
-
-    assert.strictEqual(
-        element._computeBlankItems(element.permittedLabels, 'Verified').length,
-        1);
-  });
-
-  test('labelValues returns no keys', () => {
-    element.labelValues = {};
-
-    assert.deepEqual(
-        element._computeBlankItems(element.permittedLabels, 'Code-Review'), []);
-  });
-
-  test('changes in label score are reflected in the DOM', async () => {
-    element.labels = {
-      'Code-Review': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: 0,
-      },
-      'Verified': {
-        values: {
-          ' 0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: 0,
-      },
-    };
-    await flush();
-    const selector = element.$.labelSelector;
-    element.set('label', {name: 'Verified', value: ' 0'});
-    await flush();
-    assert.strictEqual(selector.selected, ' 0');
-    assert.strictEqual(
-        element.$.selectedValueLabel.textContent.trim(), 'No score');
-    checkAriaCheckedValid();
-  });
-
-  test('without permitted labels', async () => {
-    element.permittedLabels = {
-      Verified: [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    await flush();
-    assert.isOk(element.$.labelSelector);
-    assert.isFalse(element.$.labelSelector.hidden);
-
-    element.permittedLabels = {};
-    await flush();
-    assert.isOk(element.$.labelSelector);
-    assert.isTrue(element.$.labelSelector.hidden);
-
-    element.permittedLabels = {Verified: []};
-    await flush();
-    assert.isOk(element.$.labelSelector);
-    assert.isTrue(element.$.labelSelector.hidden);
-  });
-
-  test('asymmetrical labels', async () => {
-    element.permittedLabels = {
-      'Code-Review': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-      'Verified': [
-        ' 0',
-        '+1',
-      ],
-    };
-    await flush();
-    assert.strictEqual(element.$.labelSelector.items.length, 2);
-    assert.strictEqual(element.root.querySelectorAll('.placeholder').length, 3);
-
-    element.permittedLabels = {
-      'Code-Review': [
-        ' 0',
-        '+1',
-      ],
-      'Verified': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-    };
-    await flush();
-    assert.strictEqual(element.$.labelSelector.items.length, 5);
-    assert.strictEqual(element.root.querySelectorAll('.placeholder').length, 0);
-  });
-
-  test('default_value', () => {
-    element.permittedLabels = {
-      Verified: [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    element.labels = {
-      Verified: {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: -1,
-      },
-    };
-    element.label = {
-      name: 'Verified',
-      value: null,
-    };
-    flush();
-    assert.strictEqual(element.selectedValue, '-1');
-    checkAriaCheckedValid();
-  });
-
-  test('default_value is null if not permitted', () => {
-    element.permittedLabels = {
-      Verified: [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    element.labels = {
-      'Code-Review': {
-        values: {
-          '0': 'No score',
-          '+1': 'good',
-          '+2': 'excellent',
-          '-1': 'bad',
-          '-2': 'terrible',
-        },
-        default_value: -1,
-      },
-    };
-    element.label = {
-      name: 'Code-Review',
-      value: null,
-    };
-    flush();
-    assert.isNull(element.selectedValue);
-  });
-});
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
new file mode 100644
index 0000000..8752a6c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.ts
@@ -0,0 +1,391 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-label-score-row';
+import {GrLabelScoreRow} from './gr-label-score-row';
+import {AccountId} from '../../../api/rest-api';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitEventLoop} from '../../../test/test-utils';
+
+suite('gr-label-row-score tests', () => {
+  let element: GrLabelScoreRow;
+
+  setup(async () => {
+    element = await fixture(html`<gr-label-score-row></gr-label-score-row>`);
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+        value: 1,
+        all: [
+          {
+            _account_id: 123 as unknown as AccountId,
+            value: 1,
+          },
+        ],
+      },
+      Verified: {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+        value: 1,
+        all: [
+          {
+            _account_id: 123 as unknown as AccountId,
+            value: 1,
+          },
+        ],
+      },
+    };
+
+    element.permittedLabels = {
+      'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+      Verified: ['-1', ' 0', '+1'],
+    };
+
+    element.orderedLabelValues = [-2, -1, 0, 1, 2];
+    //  {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
+
+    element.label = {
+      name: 'Verified',
+      value: '+1',
+    };
+
+    await element.updateComplete;
+    await waitEventLoop();
+  });
+
+  function checkAriaCheckedValid() {
+    const items = element.labelSelector!.items;
+    assert.ok(items);
+    const selectedItem = element.selectedItem;
+    for (let i = 0; i < items!.length; i++) {
+      const item = items![i];
+      if (items![i] === selectedItem) {
+        assert.isTrue(item.hasAttribute('aria-checked'), `item ${i}`);
+        assert.equal(item.getAttribute('aria-checked'), 'true', `item ${i}`);
+      } else {
+        assert.isFalse(item.hasAttribute('aria-checked'), `item ${i}`);
+      }
+    }
+  }
+
+  test('label picker', async () => {
+    const labelsChangedHandler = sinon.stub();
+    element.addEventListener('labels-changed', labelsChangedHandler);
+    assert.ok(element.labelSelector);
+    const button = element.shadowRoot!.querySelector(
+      'gr-button[data-value="-1"]'
+    ) as GrButton;
+    button.click();
+    await element.updateComplete;
+
+    assert.strictEqual(element.selectedValue, '-1');
+    assert.strictEqual(element.selectedItem!.textContent!.trim(), '-1');
+    const selectedValueLabel = element.shadowRoot!.querySelector(
+      '#selectedValueLabel'
+    );
+    assert.strictEqual(selectedValueLabel!.textContent!.trim(), 'bad');
+    const detail = labelsChangedHandler.args[0][0].detail;
+    assert.equal(detail.name, 'Verified');
+    assert.equal(detail.value, '-1');
+    checkAriaCheckedValid();
+  });
+
+  test('Reset Vote title', () => {
+    // User already voted +1 so we show reset vote
+    assert.equal(element.computeLabelValueTitle('0'), 'Reset Vote');
+    element.label = {
+      name: 'Verified',
+      value: '0',
+    };
+    // User voted 0 and selected 0 hence no score
+    assert.equal(element.computeLabelValueTitle('0'), 'No score');
+  });
+
+  test('_computeVoteAttribute', () => {
+    let value = 1;
+    let index = 0;
+    const totalItems = 5;
+    // positive and first position
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'positive'
+    );
+    // negative and first position
+    value = -1;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'min'
+    );
+    // negative but not first position
+    index = 1;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'negative'
+    );
+    // neutral
+    value = 0;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'neutral'
+    );
+    // positive but not last position
+    value = 1;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'positive'
+    );
+    // positive and last position
+    index = 4;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'max'
+    );
+    // negative and last position
+    value = -1;
+    assert.equal(
+      element._computeVoteAttribute(value, index, totalItems),
+      'negative'
+    );
+  });
+
+  test('correct item is selected', () => {
+    // 1 should be the value of the selected item
+    assert.strictEqual(element.labelSelector!.selected, '+1');
+    assert.strictEqual(element.selectedItem!.textContent!.trim(), '+1');
+    const selectedValueLabel = element.shadowRoot!.querySelector(
+      '#selectedValueLabel'
+    );
+    assert.strictEqual(selectedValueLabel!.textContent!.trim(), 'good');
+    checkAriaCheckedValid();
+  });
+
+  test('_computeLabelValue', () => {
+    assert.strictEqual(element._computeLabelValue(), '+1');
+  });
+
+  test('computeBlankItemsCount', () => {
+    element.orderedLabelValues = [-2, -1, 0, 1, 2];
+    element.label = {name: 'Code-Review', value: ' 0'};
+    assert.strictEqual(element.computeBlankItemsCount('start'), 0);
+
+    element.label = {name: 'Verified', value: ' 0'};
+    assert.strictEqual(element.computeBlankItemsCount('start'), 1);
+  });
+
+  test('labelValues returns no keys', () => {
+    element.orderedLabelValues = [];
+    element.label = {name: 'Code-Review', value: ' 0'};
+
+    assert.deepEqual(element.computeBlankItemsCount('start'), 0);
+  });
+
+  test('changes in label score are reflected in the DOM', async () => {
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+      },
+      Verified: {
+        values: {
+          ' 0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: 0,
+      },
+    };
+    // For some reason we need the element.labels to flush first before we
+    // change the element.label
+    await element.updateComplete;
+
+    element.label = {name: 'Verified', value: ' 0'};
+    await element.updateComplete;
+    // Wait for @selected-item-changed to fire
+    await waitEventLoop();
+
+    const selector = element.labelSelector;
+    assert.strictEqual(selector!.selected, ' 0');
+    const selectedValueLabel = element.shadowRoot!.querySelector(
+      '#selectedValueLabel'
+    );
+    assert.strictEqual(selectedValueLabel!.textContent!.trim(), 'Reset Vote');
+    checkAriaCheckedValid();
+  });
+
+  test('asymmetrical labels', async () => {
+    element.permittedLabels = {
+      'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+      Verified: [' 0', '+1'],
+    };
+    await element.updateComplete;
+    assert.strictEqual(element.labelSelector!.items!.length, 2);
+    assert.strictEqual(
+      element.shadowRoot!.querySelectorAll('.placeholder').length,
+      3
+    );
+
+    element.permittedLabels = {
+      'Code-Review': [' 0', '+1'],
+      Verified: ['-2', '-1', ' 0', '+1', '+2'],
+    };
+    await element.updateComplete;
+    assert.strictEqual(element.labelSelector!.items!.length, 5);
+    assert.strictEqual(
+      element.shadowRoot!.querySelectorAll('.placeholder').length,
+      0
+    );
+  });
+
+  test('default_value', async () => {
+    element.permittedLabels = {
+      Verified: ['-1', ' 0', '+1'],
+    };
+    element.labels = {
+      Verified: {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: -1,
+      },
+    };
+    element.label = {
+      name: 'Verified',
+      value: null,
+    };
+    await element.updateComplete;
+    assert.strictEqual(element.selectedValue, '-1');
+    checkAriaCheckedValid();
+  });
+
+  test('default_value is null if not permitted', async () => {
+    element.permittedLabels = {
+      Verified: ['-1', ' 0', '+1'],
+    };
+    element.labels = {
+      'Code-Review': {
+        values: {
+          '0': 'No score',
+          '+1': 'good',
+          '+2': 'excellent',
+          '-1': 'bad',
+          '-2': 'terrible',
+        },
+        default_value: -1,
+      },
+    };
+    element.label = {
+      name: 'Code-Review',
+      value: null,
+    };
+    await element.updateComplete;
+    assert.isNull(element.selectedValue);
+  });
+
+  test('shadowDom test', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <span class="labelNameCell" id="labelName" aria-hidden="true">
+          Verified
+        </span>
+        <div class="buttonsCell">
+          <span class="placeholder" data-label="Verified"></span>
+          <iron-selector
+            aria-labelledby="labelName"
+            id="labelSelector"
+            role="radiogroup"
+            selected="+1"
+          >
+            <gr-button
+              aria-disabled="false"
+              aria-label="-1"
+              data-name="Verified"
+              data-value="-1"
+              role="button"
+              tabindex="0"
+              title="bad"
+              data-vote="min"
+              votechip=""
+              flatten=""
+            >
+              <gr-tooltip-content light-tooltip="" has-tooltip="" title="bad">
+                -1
+              </gr-tooltip-content>
+            </gr-button>
+            <gr-button
+              aria-disabled="false"
+              aria-label=" 0"
+              data-name="Verified"
+              data-value=" 0"
+              role="button"
+              tabindex="0"
+              data-vote="neutral"
+              title="Reset Vote"
+              votechip=""
+              flatten=""
+            >
+              <gr-tooltip-content
+                light-tooltip=""
+                title="Reset Vote"
+                has-tooltip=""
+              >
+                0
+              </gr-tooltip-content>
+            </gr-button>
+            <gr-button
+              aria-checked="true"
+              aria-disabled="false"
+              aria-label="+1"
+              class="iron-selected"
+              data-name="Verified"
+              data-value="+1"
+              role="button"
+              tabindex="0"
+              title="good"
+              data-vote="max"
+              votechip=""
+              flatten=""
+            >
+              <gr-tooltip-content light-tooltip="" has-tooltip="" title="good">
+                +1
+              </gr-tooltip-content>
+            </gr-button>
+          </iron-selector>
+          <span class="placeholder" data-label="Verified"></span>
+        </div>
+        <div class="selectedValueCell ">
+          <span id="selectedValueLabel">good</span>
+        </div>
+      `
+    );
+  });
+});
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 962ccef..873e6ce 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
@@ -1,48 +1,34 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-label-score-row/gr-label-score-row';
 import '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {customElement, property} from 'lit/decorators.js';
 import {
-  LabelNameToValueMap,
   ChangeInfo,
   AccountInfo,
-  DetailedLabelInfo,
-  LabelNameToInfoMap,
-  LabelNameToValuesMap,
+  LabelNameToValueMap,
 } from '../../../types/common';
+import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {
-  GrLabelScoreRow,
+  getTriggerVotes,
+  computeLabels,
   Label,
-  LabelValuesMap,
-} from '../gr-label-score-row/gr-label-score-row';
-import {appContext} from '../../../services/app-context';
-import {getTriggerVotes, labelCompare} from '../../../utils/label-util';
-import {Execution} from '../../../constants/reporting';
+  computeOrderedLabelValues,
+  getDefaultValue,
+  getApplicableLabels,
+} from '../../../utils/label-util';
 import {ChangeStatus} from '../../../constants/constants';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {LabelNameToValuesMap} from '../../../api/rest-api';
 
 @customElement('gr-label-scores')
 export class GrLabelScores extends LitElement {
   @property({type: Object})
-  permittedLabels?: LabelNameToValueMap;
+  permittedLabels?: LabelNameToValuesMap;
 
   @property({type: Object})
   change?: ChangeInfo;
@@ -50,10 +36,6 @@
   @property({type: Object})
   account?: AccountInfo;
 
-  private readonly reporting = appContext.reportingService;
-
-  private readonly flagsService = appContext.flagsService;
-
   static override get styles() {
     return [
       fontStyles,
@@ -61,12 +43,16 @@
         .scoresTable {
           display: table;
           width: 100%;
+          table-layout: fixed;
         }
         .mergedMessage,
         .abandonedMessage {
           font-style: italic;
           text-align: center;
-          width: 100%;
+        }
+        .permissionMessage {
+          color: var(--deemphasized-text-color);
+          padding-left: var(--label-score-padding-left, 0);
         }
         gr-label-score-row:hover {
           background-color: var(--hover-background-color);
@@ -74,15 +60,12 @@
         gr-label-score-row {
           display: table-row;
         }
-        gr-label-score-row.no-access {
-          display: none;
-        }
-        .heading-3 {
-          padding-left: var(--spacing-xl);
-          margin-bottom: var(--spacing-m);
+        .heading-4 {
+          padding-left: var(--label-score-padding-left, 0);
+          margin-bottom: var(--spacing-s);
           margin-top: var(--spacing-l);
         }
-        .heading-3:first-of-type {
+        .heading-4:first-of-type {
           margin-top: 0;
         }
       `,
@@ -90,56 +73,66 @@
   }
 
   override render() {
-    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderOldSubmitRequirements() {
-    const labels = this._computeLabels();
-    return html`${this.renderLabels(labels)}${this.renderErrorMessages()}`;
-  }
-
-  private renderNewSubmitRequirements() {
     return html`${this.renderSubmitReqsLabels()}${this.renderTriggerVotes()}
     ${this.renderErrorMessages()}`;
   }
 
   private renderSubmitReqsLabels() {
     const triggerVotes = getTriggerVotes(this.change);
-    const labels = this._computeLabels().filter(
-      label => !triggerVotes.includes(label.name)
-    );
+    const applicableLabels = getApplicableLabels(this.change);
+    const labels = computeLabels(this.account, this.change)
+      .filter(label => !triggerVotes.includes(label.name))
+      .filter(label => applicableLabels.includes(label.name));
     if (!labels.length) return;
-    return html`<h3 class="heading-3">Submit requirements votes</h3>
+    if (
+      labels.filter(
+        label => !this.permittedLabels || this.permittedLabels[label.name]
+      ).length === 0
+    ) {
+      return html`<h3 class="heading-4">Submit requirements votes</h3>
+        <div class="permissionMessage">You don't have permission to vote</div>`;
+    }
+    return html`<h3 class="heading-4">Submit requirements votes</h3>
       ${this.renderLabels(labels)}`;
   }
 
   private renderTriggerVotes() {
     const triggerVotes = getTriggerVotes(this.change);
-    const labels = this._computeLabels().filter(label =>
+    const labels = computeLabels(this.account, this.change).filter(label =>
       triggerVotes.includes(label.name)
     );
     if (!labels.length) return;
-    return html`<h3 class="heading-3">Trigger Votes</h3>
+    if (
+      labels.filter(
+        label => !this.permittedLabels || this.permittedLabels[label.name]
+      ).length === 0
+    ) {
+      return html`<h3 class="heading-4">Trigger Votes</h3>
+        <div class="permissionMessage">You don't have permission to vote</div>`;
+    }
+    return html`<h3 class="heading-4">Trigger Votes</h3>
       ${this.renderLabels(labels)}`;
   }
 
   private renderLabels(labels: Label[]) {
-    const labelValues = this._computeColumns();
     return html`<div class="scoresTable">
-      ${labels.map(
-        label => html`<gr-label-score-row
-          class="${this.computeLabelAccessClass(label.name)}"
-          .label="${label}"
-          .name="${label.name}"
-          .labels="${this.change?.labels}"
-          .permittedLabels="${this.permittedLabels}"
-          .labelValues="${labelValues}"
-        ></gr-label-score-row>`
-      )}
+      ${labels
+        .filter(
+          label =>
+            this.permittedLabels?.[label.name] &&
+            this.permittedLabels?.[label.name].length > 0
+        )
+        .map(
+          label => html`<gr-label-score-row
+            .label=${label}
+            .name=${label.name}
+            .labels=${this.change?.labels}
+            .permittedLabels=${this.permittedLabels}
+            .orderedLabelValues=${computeOrderedLabelValues(
+              this.permittedLabels
+            )}
+          ></gr-label-score-row>`
+        )}
     </div>`;
   }
 
@@ -158,15 +151,15 @@
       </div>`;
   }
 
-  getLabelValues(includeDefaults = true): LabelNameToValuesMap {
-    const labels: LabelNameToValuesMap = {};
+  getLabelValues(includeDefaults = true): LabelNameToValueMap {
+    const labels: LabelNameToValueMap = {};
     if (this.shadowRoot === null || !this.change) {
       return labels;
     }
     for (const label of Object.keys(this.permittedLabels ?? {})) {
-      const selectorEl = this.shadowRoot.querySelector(
+      const selectorEl = this.shadowRoot.querySelector<GrLabelScoreRow>(
         `gr-label-score-row[name="${label}"]`
-      ) as null | GrLabelScoreRow;
+      );
       if (!selectorEl?.selectedItem) continue;
 
       const selectedVal =
@@ -176,106 +169,13 @@
 
       if (selectedVal === undefined) continue;
 
-      const defValNum = this.getDefaultValue(label);
+      const defValNum = getDefaultValue(this.change?.labels, label);
       if (includeDefaults || selectedVal !== defValNum) {
         labels[label] = selectedVal;
       }
     }
     return labels;
   }
-
-  private getStringLabelValue(
-    labels: LabelNameToInfoMap,
-    labelName: string,
-    numberValue?: number
-  ): string {
-    const detailedInfo = labels[labelName] as DetailedLabelInfo;
-    if (detailedInfo.values) {
-      for (const labelValue of Object.keys(detailedInfo.values)) {
-        if (Number(labelValue) === numberValue) {
-          return labelValue;
-        }
-      }
-    }
-    const stringVal = `${numberValue}`;
-    this.reporting.reportExecution(Execution.REACHABLE_CODE, {
-      value: stringVal,
-      id: 'label-value-not-found',
-    });
-    return stringVal;
-  }
-
-  private getDefaultValue(labelName?: string) {
-    const labels = this.change?.labels;
-    if (!labelName || !labels?.[labelName]) return undefined;
-    const labelInfo = labels[labelName] as DetailedLabelInfo;
-    return labelInfo.default_value;
-  }
-
-  _getVoteForAccount(labelName: string): string | null {
-    const labels = this.change?.labels;
-    if (!labels) return null;
-    const votes = labels[labelName] as DetailedLabelInfo;
-    if (votes.all && votes.all.length > 0) {
-      for (let i = 0; i < votes.all.length; i++) {
-        if (
-          this.account &&
-          // TODO(TS): Replace == with === and check code can assign string to _account_id instead of number
-          // eslint-disable-next-line eqeqeq
-          votes.all[i]._account_id == this.account._account_id
-        ) {
-          return this.getStringLabelValue(
-            labels,
-            labelName,
-            votes.all[i].value
-          );
-        }
-      }
-    }
-    return null;
-  }
-
-  _computeLabels(): Label[] {
-    if (!this.account) return [];
-    const labelsObj = this.change?.labels;
-    if (!labelsObj) return [];
-    return Object.keys(labelsObj)
-      .sort(labelCompare)
-      .map(key => {
-        return {
-          name: key,
-          value: this._getVoteForAccount(key),
-        };
-      });
-  }
-
-  _computeColumns() {
-    if (!this.permittedLabels) return;
-    const labels = Object.keys(this.permittedLabels);
-    const values: Set<number> = new Set();
-    for (const label of labels) {
-      for (const value of this.permittedLabels[label]) {
-        values.add(Number(value));
-      }
-    }
-
-    const orderedValues = Array.from(values.values()).sort((a, b) => a - b);
-
-    const labelValues: LabelValuesMap = {};
-    for (let i = 0; i < orderedValues.length; i++) {
-      labelValues[orderedValues[i]] = i;
-    }
-    return labelValues;
-  }
-
-  private computeLabelAccessClass(label?: string) {
-    if (!this.permittedLabels || !label) return '';
-
-    return hasOwnProperty(this.permittedLabels, label) &&
-      this.permittedLabels[label].length
-      ? 'access'
-      : 'no-access';
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
index f529464..2e02402 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
@@ -1,23 +1,16 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-label-scores';
-import {isHidden, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  isHidden,
+  queryAndAssert,
+  stubRestApi,
+  waitEventLoop,
+} from '../../../test/test-utils';
 import {GrLabelScores} from './gr-label-scores';
 import {AccountId} from '../../../types/common';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
@@ -26,8 +19,8 @@
   createChange,
 } from '../../../test/test-data-generators';
 import {ChangeStatus} from '../../../constants/constants';
-
-const basicFixture = fixtureFromElement('gr-label-scores');
+import {getVoteForAccount} from '../../../utils/label-util';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-label-scores tests', () => {
   const accountId = 123 as AccountId;
@@ -35,7 +28,7 @@
 
   setup(async () => {
     stubRestApi('getLoggedIn').resolves(false);
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-label-scores></gr-label-scores>`);
     element.change = {
       ...createChange(),
       labels: {
@@ -82,7 +75,26 @@
       'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
       Verified: ['-1', ' 0', '+1'],
     };
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h3 class="heading-4">Trigger Votes</h3>
+        <div class="scoresTable">
+          <gr-label-score-row name="Code-Review"> </gr-label-score-row>
+          <gr-label-score-row name="Verified"> </gr-label-score-row>
+        </div>
+        <div class="mergedMessage" hidden="">
+          Because this change has been merged, votes may not be decreased.
+        </div>
+        <div class="abandonedMessage" hidden="">
+          Because this change has been abandoned, you cannot vote.
+        </div>
+      `
+    );
   });
 
   test('get and set label scores', async () => {
@@ -93,7 +105,7 @@
       );
       row.setSelectedValue('-1');
     }
-    await flush();
+    await element.updateComplete;
     assert.deepEqual(element.getLabelValues(), {
       'Code-Review': -1,
       Verified: -1,
@@ -110,90 +122,27 @@
         },
       },
     };
-    await flush();
+    await element.updateComplete;
 
     assert.deepEqual(element.getLabelValues(true), {'Code-Review': 0});
     assert.deepEqual(element.getLabelValues(false), {});
   });
 
-  test('_getVoteForAccount', () => {
+  test('getVoteForAccount', () => {
     const labelName = 'Code-Review';
-    assert.strictEqual(element._getVoteForAccount(labelName), '+1');
+    assert.strictEqual(
+      getVoteForAccount(labelName, element.account, element.change),
+      '+1'
+    );
   });
 
-  test('_computeColumns', () => {
-    const labelValues = element._computeColumns();
-    assert.deepEqual(labelValues, {
-      '-2': 0,
-      '-1': 1,
-      '0': 2,
-      '1': 3,
-      '2': 4,
-    });
-  });
-
-  test('changes in label score are reflected in _labels', async () => {
-    const change = {
-      ...createChange(),
-      labels: {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-        Verified: {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-      },
-    };
-    element.change = change;
-    await flush();
-    let labels = element._computeLabels();
-    assert.deepEqual(labels, [
-      {name: 'Code-Review', value: null},
-      {name: 'Verified', value: null},
-    ]);
-    element.change = {
-      ...change,
-      labels: {
-        ...change.labels,
-        Verified: {
-          ...change.labels.Verified,
-          all: [
-            {
-              _account_id: accountId,
-              value: 1,
-            },
-          ],
-        },
-      },
-    };
-    await flush();
-    labels = element._computeLabels();
-    assert.deepEqual(labels, [
-      {name: 'Code-Review', value: null},
-      {name: 'Verified', value: '+1'},
-    ]);
-  });
   suite('message', () => {
     test('shown when change is abandoned', async () => {
       element.change = {
         ...createChange(),
         status: ChangeStatus.ABANDONED,
       };
-      await flush();
+      await waitEventLoop();
       assert.isFalse(isHidden(queryAndAssert(element, '.abandonedMessage')));
       assert.isTrue(isHidden(queryAndAssert(element, '.mergedMessage')));
     });
@@ -202,7 +151,7 @@
         ...createChange(),
         status: ChangeStatus.MERGED,
       };
-      await flush();
+      await waitEventLoop();
       assert.isFalse(isHidden(queryAndAssert(element, '.mergedMessage')));
       assert.isTrue(isHidden(queryAndAssert(element, '.abandonedMessage')));
     });
@@ -211,7 +160,7 @@
         ...createChange(),
         status: ChangeStatus.NEW,
       };
-      await flush();
+      await waitEventLoop();
       assert.isTrue(isHidden(queryAndAssert(element, '.mergedMessage')));
       assert.isTrue(isHidden(queryAndAssert(element, '.abandonedMessage')));
     });
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
new file mode 100644
index 0000000..9799880
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -0,0 +1,183 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-trigger-vote/gr-trigger-vote';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {ChangeInfo} from '../../../api/rest-api';
+import {
+  ChangeMessage,
+  LabelExtreme,
+  PATCH_SET_PREFIX_PATTERN,
+} from '../../../utils/comment-util';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {getTriggerVotes} from '../../../utils/label-util';
+
+const VOTE_RESET_TEXT = '0 (vote reset)';
+
+interface Score {
+  label?: string;
+  value?: string;
+}
+
+export const LABEL_TITLE_SCORE_PATTERN =
+  /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.:]?$/;
+
+@customElement('gr-message-scores')
+export class GrMessageScores extends LitElement {
+  @property()
+  labelExtremes?: LabelExtreme;
+
+  @property({type: Object})
+  message?: ChangeMessage;
+
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  static override get styles() {
+    return css`
+      .score,
+      gr-trigger-vote {
+        padding: 0 var(--spacing-s);
+        margin-right: var(--spacing-s);
+        display: inline-block;
+      }
+      .score {
+        box-sizing: border-box;
+        border-radius: var(--border-radius);
+        color: var(--vote-text-color);
+        text-align: center;
+        min-width: 115px;
+      }
+      .score.removed {
+        background-color: var(--vote-color-neutral);
+      }
+      .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);
+      }
+
+      @media screen and (max-width: 50em) {
+        .score {
+          min-width: 0px;
+        }
+      }
+    `;
+  }
+
+  override render() {
+    const scores = this._getScores(this.message, this.labelExtremes);
+    const triggerVotes = getTriggerVotes(this.change);
+    return scores.map(score => this.renderScore(score, triggerVotes));
+  }
+
+  private renderScore(score: Score, triggerVotes: string[]) {
+    if (
+      score.label &&
+      triggerVotes.includes(score.label) &&
+      !score.value?.includes(VOTE_RESET_TEXT)
+    ) {
+      const labels = this.change?.labels ?? {};
+      return html`<gr-trigger-vote
+        .label=${score.label}
+        .displayValue=${score.value}
+        .labelInfo=${labels[score.label]}
+        .change=${this.change}
+        .mutable=${false}
+        disable-hovercards
+      >
+      </gr-trigger-vote>`;
+    }
+    return html`<span
+      class="score ${this._computeScoreClass(score, this.labelExtremes)}"
+    >
+      ${score.label} ${score.value}
+    </span>`;
+  }
+
+  _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
+    if (score === undefined || labelExtremes === undefined) {
+      return '';
+    }
+    if (!score.value) {
+      return '';
+    }
+    if (score.value.includes(VOTE_RESET_TEXT)) {
+      return 'removed';
+    }
+    const classes = [];
+    if (Number(score.value) > 0) {
+      classes.push('positive');
+    } else if (Number(score.value) < 0) {
+      classes.push('negative');
+    }
+    if (score.label) {
+      const extremes = labelExtremes[score.label];
+      if (extremes) {
+        const intScore = Number(score.value);
+        if (intScore === extremes.max) {
+          classes.push('max');
+        } else if (intScore === extremes.min) {
+          classes.push('min');
+        }
+      }
+    }
+    return classes.join(' ');
+  }
+
+  _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
+    if (!message || !message.message || !labelExtremes) {
+      return [];
+    }
+    const line = message.message.split('\n', 1)[0];
+    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
+    if (!line.match(patchSetPrefix)) {
+      return [];
+    }
+    const scoresRaw = line.split(patchSetPrefix)[1];
+    if (!scoresRaw) {
+      return [];
+    }
+    return scoresRaw
+      .split(' ')
+      .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+      .filter(
+        ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2])
+      )
+      .map(ms => {
+        const label = ms?.[2];
+        const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
+        return {label, value};
+      });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-message-scores': GrMessageScores;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores_test.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores_test.ts
new file mode 100644
index 0000000..a757b37
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores_test.ts
@@ -0,0 +1,195 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-message-scores';
+import {
+  createChange,
+  createChangeMessage,
+  createDetailedLabelInfo,
+} from '../../../test/test-data-generators';
+import {queryAll, stubFlags} from '../../../test/test-utils';
+import {GrMessageScores} from './gr-message-scores';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-message-score tests', () => {
+  let element: GrMessageScores;
+
+  setup(async () => {
+    element = await fixture(html`<gr-message-scores></gr-message-scores>`);
+  });
+
+  test('render', async () => {
+    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},
+    };
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <span class="max positive score"> Verified +1 </span>
+        <span class="min negative score"> Code-Review -2 </span>
+        <span class="positive score"> Trybot-Label3 +1 </span>
+      `
+    );
+  });
+
+  test('votes', async () => {
+    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},
+    };
+    await element.updateComplete;
+    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', async () => {
+    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},
+    };
+    await element.updateComplete;
+    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 and rebased', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Uploaded patch set 4: Commit-Queue+1: Patch Set 3 was rebased.',
+    };
+    element.labelExtremes = {
+      'Commit-Queue': {max: 2, min: -2},
+    };
+    await element.updateComplete;
+    const scoreChips = queryAll(element, '.score');
+    assert.equal(scoreChips.length, 1);
+    assert.isTrue(scoreChips[0].classList.contains('positive'));
+  });
+
+  test('removed votes', async () => {
+    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},
+    };
+    await element.updateComplete;
+    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', async () => {
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
+    };
+    element.labelExtremes = {};
+    await element.updateComplete;
+    const scoreChips = element.shadowRoot?.querySelectorAll('.score');
+    assert.equal(scoreChips?.length, 0);
+  });
+
+  test('reset vote', async () => {
+    stubFlags('isEnabled').returns(true);
+    element = await fixture(html`<gr-message-scores></gr-message-scores>`);
+    element.change = {
+      ...createChange(),
+      labels: {
+        'Commit-Queue': createDetailedLabelInfo(),
+        'Auto-Submit': createDetailedLabelInfo(),
+      },
+    };
+    element.message = {
+      ...createChangeMessage(),
+      author: {},
+      expanded: false,
+      message: 'Patch Set 10: Auto-Submit+1 -Commit-Queue',
+    };
+    element.labelExtremes = {
+      'Commit-Queue': {max: 2, min: 0},
+      'Auto-Submit': {max: 1, min: 0},
+    };
+    await element.updateComplete;
+    const triggerChips =
+      element.shadowRoot?.querySelectorAll('gr-trigger-vote');
+    assert.equal(triggerChips?.length, 1);
+    const triggerChip = triggerChips?.[0];
+    assert.shadowDom.equal(
+      triggerChip,
+      `<div class="container">
+      <span class="label">Auto-Submit</span>
+      <gr-vote-chip></gr-vote-chip>
+    </div>`
+    );
+    const voteChips = triggerChip?.shadowRoot?.querySelectorAll('gr-vote-chip');
+    assert.equal(voteChips?.length, 1);
+    assert.shadowDom.equal(voteChips?.[0], '');
+    const scoreChips = element.shadowRoot?.querySelectorAll('.score');
+    assert.equal(scoreChips?.length, 1);
+    assert.dom.equal(
+      scoreChips?.[0],
+      /* HTML */ `
+        <span class="removed score"> Commit-Queue 0 (vote reset) </span>
+      `
+    );
+  });
+});
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 95e4301..a4da747 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -1,63 +1,56 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '@polymer/iron-icon/iron-icon';
 import '../../shared/gr-account-label/gr-account-label';
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-message_html';
+import '../gr-message-scores/gr-message-scores';
+import {css, html, LitElement, nothing} from 'lit';
 import {MessageTag, SpecialFilePath} from '../../../constants/constants';
-import {customElement, property, computed, observe} from '@polymer/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
+import {hasOwnProperty} from '../../../utils/common-util';
 import {
   ChangeInfo,
-  ChangeMessageInfo,
   ServerInfo,
-  ConfigInfo,
-  RepoName,
   ReviewInputTag,
-  VotingRangeInfo,
   NumericChangeId,
   ChangeMessageId,
-  PatchSetNum,
+  RevisionPatchSetNum,
   AccountInfo,
   BasePatchSetNum,
+  LabelNameToInfoMap,
 } from '../../../types/common';
-import {CommentThread} from '../../../utils/comment-util';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {appContext} from '../../../services/app-context';
+import {
+  ChangeMessage,
+  CommentThread,
+  isFormattedReviewerUpdate,
+  LabelExtreme,
+  PATCH_SET_PREFIX_PATTERN,
+  isUnresolved,
+} from '../../../utils/comment-util';
+import {LABEL_TITLE_SCORE_PATTERN} from '../gr-message-scores/gr-message-scores';
+import {getAppContext} from '../../../services/app-context';
 import {pluralize} from '../../../utils/string-util';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
   computePredecessor,
 } from '../../../utils/patch-set-util';
 import {isServiceUser, replaceTemplates} from '../../../utils/account-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {when} from 'lit/directives/when.js';
+import {FormattedReviewerUpdateInfo} from '../../../types/types';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
 
-const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
-const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
-const VOTE_RESET_TEXT = '0 (vote reset)';
-
 declare global {
   interface HTMLElementTagNameMap {
     'gr-message': GrMessage;
@@ -68,26 +61,8 @@
   id: ChangeMessageId;
 }
 
-export interface ChangeMessage extends ChangeMessageInfo {
-  // TODO(TS): maybe should be an enum instead
-  type: string;
-  expanded: boolean;
-  commentThreads: CommentThread[];
-}
-
-export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
-
-interface Score {
-  label?: string;
-  value?: string;
-}
-
 @customElement('gr-message')
-export class GrMessage extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrMessage extends LitElement {
   /**
    * Fired when this message's reply link is tapped.
    *
@@ -113,12 +88,11 @@
   changeNum?: NumericChangeId;
 
   @property({type: Object})
-  message: ChangeMessage | undefined;
+  message?: ChangeMessage | (ChangeMessage & FormattedReviewerUpdateInfo);
 
   @property({type: Array})
   commentThreads: CommentThread[] = [];
 
-  @computed('message')
   get author() {
     return this.message?.author || this.message?.updated_by;
   }
@@ -129,32 +103,6 @@
   @property({type: Boolean})
   hideAutomated = false;
 
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-    computed: '_computeIsHidden(hideAutomated, isAutomated)',
-  })
-  override hidden = false;
-
-  @computed('message')
-  get isAutomated() {
-    return !!this.message && this._computeIsAutomated(this.message);
-  }
-
-  @computed('message')
-  get showOnBehalfOf() {
-    return !!this.message && this._computeShowOnBehalfOf(this.message);
-  }
-
-  @property({
-    type: Boolean,
-    computed: '_computeShowReplyButton(message, _loggedIn)',
-  })
-  showReplyButton = false;
-
-  @property({type: String})
-  projectName?: string;
-
   /**
    * A mapping from label names to objects representing the minimum and
    * maximum possible values for that label.
@@ -162,51 +110,25 @@
   @property({type: Object})
   labelExtremes?: LabelExtreme;
 
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
-
   @property({type: Boolean})
-  _loggedIn = false;
+  loggedIn = false;
 
-  @property({type: Boolean})
-  _isAdmin = false;
+  @state()
+  private isAdmin = false;
 
-  @property({type: Boolean})
-  _isDeletingChangeMsg = false;
+  @state()
+  private isDeletingChangeMsg = false;
 
-  @property({type: Boolean, computed: '_computeExpanded(message.expanded)'})
-  _expanded = false;
+  private readonly restApiService = getAppContext().restApiService;
 
-  @property({
-    type: String,
-    computed:
-      '_computeMessageContentExpanded(_expanded, message.message,' +
-      ' message.accounts_in_message,' +
-      ' message.tag)',
-  })
-  _messageContentExpanded = '';
+  private readonly getNavigation = resolve(this, navigationToken);
 
-  @property({
-    type: String,
-    computed:
-      '_computeMessageContentCollapsed(message.message,' +
-      ' message.accounts_in_message,' +
-      ' message.tag,' +
-      ' commentThreads)',
-  })
-  _messageContentCollapsed = '';
-
-  @property({
-    type: String,
-    computed: '_computeCommentCountText(commentThreads)',
-  })
-  _commentCountText = '';
-
-  private readonly restApiService = appContext.restApiService;
+  // for COMMENTS_AUTOCLOSE logging purposes only
+  readonly uid = performance.now().toString(36) + Math.random().toString(36);
 
   constructor() {
     super();
-    this.addEventListener('click', e => this._handleClick(e));
+    this.addEventListener('click', e => this.handleClick(e));
   }
 
   override connectedCallback() {
@@ -215,44 +137,416 @@
       this.config = config;
     });
     this.restApiService.getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
+      this.loggedIn = loggedIn;
     });
     this.restApiService.getIsAdmin().then(isAdmin => {
-      this._isAdmin = !!isAdmin;
+      this.isAdmin = !!isAdmin;
     });
   }
 
-  @observe('message.expanded')
-  _updateExpandedClass(expanded: boolean) {
-    if (expanded) {
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          position: relative;
+          cursor: pointer;
+          overflow-y: hidden;
+        }
+        :host(.expanded) {
+          cursor: auto;
+        }
+        .collapsed .contentContainer {
+          align-items: center;
+          color: var(--deemphasized-text-color);
+          display: flex;
+          white-space: nowrap;
+        }
+        .contentContainer {
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        .expanded .contentContainer {
+          background-color: var(--background-color-secondary);
+        }
+        .collapsed .contentContainer {
+          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);
+        }
+        .message {
+          --gr-formatted-text-prose-max-width: 120ch;
+        }
+        .collapsed .message {
+          max-width: none;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+        .collapsed .author,
+        .collapsed .content,
+        .collapsed .message,
+        .collapsed .updateCategory,
+        gr-account-chip {
+          display: inline;
+        }
+        gr-button {
+          margin: 0 -4px;
+        }
+        .collapsed gr-thread-list,
+        .collapsed .replyBtn,
+        .collapsed .deleteBtn,
+        .collapsed .hideOnCollapsed,
+        .hideOnOpen {
+          display: none;
+        }
+        .replyBtn {
+          margin-right: var(--spacing-m);
+        }
+        .collapsed .hideOnOpen {
+          display: block;
+        }
+        .collapsed .content {
+          flex: 1;
+          margin-right: var(--spacing-m);
+          min-width: 0;
+          overflow: hidden;
+        }
+        .collapsed .content.messageContent {
+          text-overflow: ellipsis;
+        }
+        .collapsed .dateContainer {
+          position: static;
+        }
+        .collapsed .author {
+          overflow: hidden;
+          color: var(--primary-text-color);
+          margin-right: var(--spacing-s);
+        }
+        .authorLabel {
+          min-width: 130px;
+          --account-max-length: 120px;
+          margin-right: var(--spacing-s);
+        }
+        .expanded .author {
+          cursor: pointer;
+          margin-bottom: var(--spacing-m);
+        }
+        .expanded .content {
+          padding-left: 40px;
+        }
+        .dateContainer {
+          position: absolute;
+          /* right and top values should match .contentContainer padding */
+          right: var(--spacing-l);
+          top: var(--spacing-m);
+        }
+        .dateContainer gr-icon {
+          margin-right: var(--spacing-m);
+          color: var(--deemphasized-text-color);
+        }
+        .dateContainer .patchset:before {
+          content: 'Patchset ';
+        }
+        .dateContainer .patchsetDiffButton {
+          margin-right: var(--spacing-m);
+          --gr-button-padding: 0 var(--spacing-m);
+        }
+        span.date {
+          color: var(--deemphasized-text-color);
+        }
+        span.date:hover {
+          text-decoration: underline;
+        }
+        .dateContainer gr-icon {
+          cursor: pointer;
+          vertical-align: top;
+        }
+        .commentsSummary {
+          margin-right: var(--spacing-s);
+        }
+        .expanded .commentsSummary {
+          display: none;
+        }
+        gr-icon.commentsIcon {
+          vertical-align: top;
+        }
+        gr-icon.unresolved.commentsIcon {
+          color: var(--warning-foreground);
+        }
+        .numberOfComments {
+          padding-right: var(--spacing-m);
+        }
+        gr-account-label::part(gr-account-label-text) {
+          font-weight: var(--font-weight-bold);
+        }
+        @media screen and (max-width: 50em) {
+          .expanded .content {
+            padding-left: 0;
+          }
+          .commentsSummary {
+            min-width: 0px;
+          }
+          .authorLabel {
+            width: 100px;
+          }
+          .dateContainer .patchset:before {
+            content: 'PS ';
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.message) return nothing;
+    if (this.hideAutomated && this.computeIsAutomated()) return nothing;
+    this.updateExpandedClass();
+    return html` <div class=${this.computeClass()}>
+      <div class="contentContainer">
+        ${this.renderAuthor()} ${this.renderCommentsSummary()}
+        ${this.renderMessageContent()} ${this.renderReviewerUpdate()}
+        ${this.renderDateContainer()}
+      </div>
+    </div>`;
+  }
+
+  private renderAuthor() {
+    assertIsDefined(this.message, 'message');
+    return html` <div class="author" @click=${this.handleAuthorClick}>
+      ${when(
+        this.computeShowOnBehalfOf(),
+        () => html`
+          <span>
+            <span class="name">${this.message?.real_author?.name}</span>
+            on behalf of
+          </span>
+        `
+      )}
+      <gr-account-label
+        .account=${this.author}
+        .change=${this.change}
+        class="authorLabel"
+      ></gr-account-label>
+      <gr-message-scores
+        .labelExtremes=${this.labelExtremes}
+        .message=${this.message}
+        .change=${this.change}
+      ></gr-message-scores>
+    </div>`;
+  }
+
+  private renderCommentIcon({
+    commentThreadsCount,
+    unresolved,
+  }: {
+    commentThreadsCount: number;
+    unresolved: boolean;
+  }) {
+    if (commentThreadsCount === 0) {
+      return nothing;
+    }
+    return html` <span
+      class="numberOfComments"
+      title=${pluralize(
+        commentThreadsCount,
+        (unresolved ? 'unresolved' : 'resolved') + ' comment'
+      )}
+    >
+      <gr-icon
+        small
+        icon=${unresolved ? 'chat_bubble' : 'mark_chat_read'}
+        ?filled=${unresolved}
+        class="${unresolved ? 'unresolved ' : ''}commentsIcon"
+      ></gr-icon>
+      ${commentThreadsCount}</span
+    >`;
+  }
+
+  private renderCommentsSummary() {
+    if (!this.commentThreads?.length) return nothing;
+
+    const unresolvedThreadsCount =
+      this.commentThreads.filter(isUnresolved).length;
+    const resolvedThreadsCount =
+      this.commentThreads.length - unresolvedThreadsCount;
+
+    return html`
+      <div class="commentsSummary">
+        ${this.renderCommentIcon({
+          commentThreadsCount: unresolvedThreadsCount,
+          unresolved: true,
+        })}
+        ${this.renderCommentIcon({
+          commentThreadsCount: resolvedThreadsCount,
+          unresolved: false,
+        })}
+      </div>
+    `;
+  }
+
+  private renderMessageContent() {
+    if (!this.message?.message) return nothing;
+    const messageContentCollapsed =
+      this.computeMessageContent(
+        false,
+        this.message.message.substring(0, 1000),
+        this.message.accounts_in_message,
+        this.message.tag,
+        this.change?.labels
+      ) || this.patchsetCommentSummary();
+    return html` <div class="content messageContent">
+      <div class="message hideOnOpen">${messageContentCollapsed}</div>
+      ${this.renderExpandedMessageContent()}
+    </div>`;
+  }
+
+  private renderExpandedMessageContent() {
+    if (!this.message?.expanded) return nothing;
+    const messageContentExpanded = this.computeMessageContent(
+      true,
+      this.message.message,
+      this.message.accounts_in_message,
+      this.message.tag,
+      this.change?.labels
+    );
+    return html`
+      <gr-formatted-text
+        class="message hideOnCollapsed"
+        .markdown=${true}
+        .content=${messageContentExpanded}
+      ></gr-formatted-text>
+      ${when(messageContentExpanded, () => this.renderActionContainer())}
+      <gr-thread-list
+        ?hidden=${!this.commentThreads.length}
+        .threads=${this.commentThreads}
+        hide-dropdown
+        show-comment-context
+        .messageId=${this.message.id}
+      >
+      </gr-thread-list>
+    `;
+  }
+
+  private renderActionContainer() {
+    if (!this.computeShowReplyButton()) return nothing;
+    return html` <div class="replyActionContainer">
+      <gr-button class="replyBtn" link="" @click=${this.handleReplyTap}>
+        Reply
+      </gr-button>
+      ${when(
+        this.isAdmin,
+        () => html`
+          <gr-button
+            ?disabled=${this.isDeletingChangeMsg}
+            class="deleteBtn"
+            link=""
+            @click=${this.handleDeleteMessage}
+          >
+            Delete
+          </gr-button>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderReviewerUpdate() {
+    assertIsDefined(this.message, 'message');
+    if (!isFormattedReviewerUpdate(this.message)) return;
+    return html` <div class="content">
+      ${this.message.updates.map(update => this.renderMessageUpdate(update))}
+    </div>`;
+  }
+
+  private renderMessageUpdate(update: {
+    message: string;
+    reviewers: AccountInfo[];
+  }) {
+    return html`<div class="updateCategory">
+      ${update.message}
+      ${update.reviewers.map(
+        reviewer => html`
+          <gr-account-chip .account=${reviewer} .change=${this.change}>
+          </gr-account-chip>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderDateContainer() {
+    return html`<span class="dateContainer">
+      ${this.renderDiffButton()}
+      ${when(
+        this.message?._revision_number,
+        () => html`
+          <span class="patchset">${this.message?._revision_number} |</span>
+        `
+      )}
+      ${when(
+        this.message?.id,
+        () => html`
+          <span class="date" @click=${this.handleAnchorClick}>
+            <gr-date-formatter
+              withTooltip
+              showDateAndTime
+              .dateStr=${this.message?.date}
+            ></gr-date-formatter>
+          </span>
+        `,
+        () => html`
+          <span class="date">
+            <gr-date-formatter
+              withTooltip
+              showDateAndTime
+              .dateStr=${this.message?.date}
+            ></gr-date-formatter>
+          </span>
+        `
+      )}
+      <gr-icon
+        id="expandToggle"
+        @click=${this.toggleExpanded}
+        title="Toggle expanded state"
+        icon=${this.computeExpandToggleIcon()}
+      ></gr-icon>
+    </span>`;
+  }
+
+  private renderDiffButton() {
+    if (!this.showViewDiffButton()) return nothing;
+    return html` <gr-button
+      class="patchsetDiffButton"
+      @click=${this.handleViewPatchsetDiff}
+      link
+    >
+      View Diff
+    </gr-button>`;
+  }
+
+  private updateExpandedClass() {
+    if (this.message?.expanded) {
       this.classList.add('expanded');
     } else {
       this.classList.remove('expanded');
     }
   }
 
-  _computeCommentCountText(commentThreads?: CommentThread[]) {
-    if (!commentThreads?.length) {
-      return undefined;
-    }
-
-    return pluralize(commentThreads.length, 'comment');
-  }
-
-  _computeMessageContentExpanded(
-    expanded: boolean,
-    content?: string,
-    accountsInMessage?: AccountInfo[],
-    tag?: ReviewInputTag
-  ) {
-    if (!expanded) return '';
-    return this._computeMessageContent(true, content, accountsInMessage, tag);
-  }
-
-  _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
+  // Private but used in tests.
+  patchsetCommentSummary() {
     const id = this.message?.id;
     if (!id) return '';
-    const patchsetThreads = commentThreads.filter(
+    const patchsetThreads = (this.commentThreads ?? []).filter(
       thread => thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
     );
     for (const thread of patchsetThreads) {
@@ -273,53 +567,38 @@
     return '';
   }
 
-  _computeMessageContentCollapsed(
-    content?: string,
-    accountsInMessage?: AccountInfo[],
-    tag?: ReviewInputTag,
-    commentThreads?: CommentThread[]
-  ) {
-    // Content is under text-overflow, so it's always shorten
-    const shortenedContent = content?.substring(0, 1000);
-    const summary = this._computeMessageContent(
-      false,
-      shortenedContent,
-      accountsInMessage,
-      tag
-    );
-    if (summary || !commentThreads) return summary;
-    return this._patchsetCommentSummary(commentThreads);
-  }
-
-  _showViewDiffButton(message?: ChangeMessage) {
+  private showViewDiffButton() {
     return (
-      this._isNewPatchsetTag(message?.tag) || this._isMergePatchset(message)
+      this.isNewPatchsetTag(this.message?.tag) ||
+      this.isMergePatchset(this.message)
     );
   }
 
-  _isMergePatchset(message?: ChangeMessage) {
+  private isMergePatchset(message?: ChangeMessage) {
     return (
       message?.tag === MessageTag.TAG_MERGED &&
       message?.message.match(MERGED_PATCHSET_PATTERN)
     );
   }
 
-  _isNewPatchsetTag(tag?: ReviewInputTag) {
+  private isNewPatchsetTag(tag?: ReviewInputTag) {
     return (
       tag === MessageTag.TAG_NEW_PATCHSET ||
-      tag === MessageTag.TAG_NEW_WIP_PATCHSET
+      tag === MessageTag.TAG_NEW_WIP_PATCHSET ||
+      tag === MessageTag.TAG_NEW_PATCHSET_OUTDATED_VOTES
     );
   }
 
-  _handleViewPatchsetDiff(e: Event) {
+  // Private but used in tests
+  handleViewPatchsetDiff(e: Event) {
     if (!this.message || !this.change) return;
-    let patchNum: PatchSetNum;
-    let basePatchNum: PatchSetNum;
+    let patchNum: RevisionPatchSetNum;
+    let basePatchNum: BasePatchSetNum;
     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;
+      patchNum = Number(match[1]) as RevisionPatchSetNum;
       basePatchNum = computePredecessor(patchNum)!;
     } else if (this.message.message.match(MERGED_PATCHSET_PATTERN)) {
       const match = this.message.message.match(MERGED_PATCHSET_PATTERN)!;
@@ -333,19 +612,23 @@
       patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
       basePatchNum = computePredecessor(patchNum)!;
     }
-    GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
+    this.getNavigation().setUrl(
+      createChangeUrl({change: this.change, patchNum, basePatchNum})
+    );
     // stop propagation to stop message expansion
     e.stopPropagation();
   }
 
-  _computeMessageContent(
+  // private but used in tests
+  computeMessageContent(
     isExpanded: boolean,
     content?: string,
     accountsInMessage?: AccountInfo[],
-    tag?: ReviewInputTag
+    tag?: ReviewInputTag,
+    labels?: LabelNameToInfoMap
   ) {
     if (!content) return '';
-    const isNewPatchSet = this._isNewPatchsetTag(tag);
+    const isNewPatchSet = this.isNewPatchsetTag(tag);
 
     if (accountsInMessage) {
       content = replaceTemplates(content, accountsInMessage, this.config);
@@ -362,8 +645,24 @@
       if (line.startsWith('(') && line.endsWith(' comments)')) {
         return false;
       }
-      if (!isNewPatchSet && line.match(PATCH_SET_PREFIX_PATTERN)) {
-        return false;
+      if (!isNewPatchSet && labels) {
+        // Legacy change messages may contain the 'Patch Set' prefix
+        // and a message(not containing label scores) on the same line.
+        // To handle them correctly, only filter out lines which contain
+        // the 'Patch Set' prefix and label scores.
+        const match = line.match(PATCH_SET_PREFIX_PATTERN);
+        if (match && match[1]) {
+          const message = match[1].split(' ');
+          if (
+            message
+              .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+              .filter(
+                ms => ms && ms.length === 4 && hasOwnProperty(labels, ms[2])
+              ).length === message.length
+          ) {
+            return false;
+          }
+        }
       }
       return true;
     });
@@ -378,7 +677,7 @@
       // Only make this replacement if the line starts with Patch Set, since if
       // it starts with "Uploaded patch set" (e.g for votes) we want to keep the
       // "Uploaded patch set".
-      if (isNewPatchSet && line.startsWith('Patch Set')) {
+      if (line.startsWith('Patch Set')) {
         line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
       }
       return line;
@@ -386,131 +685,68 @@
     return mappedLines.join('\n').trim();
   }
 
-  _computeAuthor(message: ChangeMessage) {
-    return message.author || message.updated_by;
-  }
-
-  _computeShowOnBehalfOf(message: ChangeMessage) {
-    const author = this._computeAuthor(message);
+  // private but used in tests
+  computeShowOnBehalfOf() {
+    if (!this.message) return false;
     return !!(
-      author &&
-      message.real_author &&
-      author._account_id !== message.real_author._account_id
+      this.author &&
+      this.message.real_author &&
+      this.author._account_id !== this.message.real_author._account_id
     );
   }
 
-  _computeShowReplyButton(message?: ChangeMessage, loggedIn?: boolean) {
+  // private but used in tests.
+  computeShowReplyButton() {
     return (
-      message &&
-      !!message.message &&
-      loggedIn &&
-      !this._computeIsAutomated(message)
+      !!this.message &&
+      !!this.message.message &&
+      this.loggedIn &&
+      !this.computeIsAutomated()
     );
   }
 
-  _computeExpanded(expanded: boolean) {
-    return expanded;
-  }
-
-  _handleClick(e: Event) {
-    if (this.message?.expanded) {
+  private handleClick(e: Event) {
+    if (!this.message || this.message?.expanded) {
       return;
     }
     e.stopPropagation();
-    this.set('message.expanded', true);
+    this.message.expanded = true;
+    this.requestUpdate();
   }
 
-  _handleAuthorClick(e: Event) {
-    if (!this.message?.expanded) {
+  private handleAuthorClick(e: Event) {
+    if (!this.message || !this.message?.expanded) {
       return;
     }
     e.stopPropagation();
-    this.set('message.expanded', false);
+    this.message.expanded = false;
+    this.requestUpdate();
   }
 
-  _computeIsAutomated(message: ChangeMessage) {
+  // private but used in tests.
+  computeIsAutomated() {
     return !!(
-      message.reviewer ||
-      this._computeIsReviewerUpdate(message) ||
-      (message.tag && message.tag.startsWith('autogenerated'))
+      this.message?.reviewer ||
+      this.computeIsReviewerUpdate() ||
+      (this.message?.tag && this.message.tag.startsWith('autogenerated'))
     );
   }
 
-  _computeIsHidden(hideAutomated: boolean, isAutomated: boolean) {
-    return hideAutomated && isAutomated;
+  private computeIsReviewerUpdate() {
+    return this.message?.type === 'REVIEWER_UPDATE';
   }
 
-  _computeIsReviewerUpdate(message: ChangeMessage) {
-    return message.type === 'REVIEWER_UPDATE';
-  }
-
-  _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
-    if (!message || !message.message || !labelExtremes) {
-      return [];
-    }
-    const line = message.message.split('\n', 1)[0];
-    const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
-    if (!line.match(patchSetPrefix)) {
-      return [];
-    }
-    const scoresRaw = line.split(patchSetPrefix)[1];
-    if (!scoresRaw) {
-      return [];
-    }
-    return scoresRaw
-      .split(' ')
-      .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
-      .filter(
-        ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2])
-      )
-      .map(ms => {
-        const label = ms?.[2];
-        const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
-        return {label, value};
-      });
-  }
-
-  _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
-    // Polymer 2: check for undefined
-    if (score === undefined || labelExtremes === undefined) {
-      return '';
-    }
-    if (!score.value) {
-      return '';
-    }
-    if (score.value.includes(VOTE_RESET_TEXT)) {
-      return 'removed';
-    }
-    const classes = [];
-    if (Number(score.value) > 0) {
-      classes.push('positive');
-    } else if (Number(score.value) < 0) {
-      classes.push('negative');
-    }
-    if (score.label) {
-      const extremes = labelExtremes[score.label];
-      if (extremes) {
-        const intScore = Number(score.value);
-        if (intScore === extremes.max) {
-          classes.push('max');
-        } else if (intScore === extremes.min) {
-          classes.push('min');
-        }
-      }
-    }
-    return classes.join(' ');
-  }
-
-  _computeClass(expanded?: boolean, author?: AccountInfo) {
+  private computeClass() {
+    const expanded = this.message?.expanded;
     const classes = [];
     classes.push(expanded ? 'expanded' : 'collapsed');
-    if (isServiceUser(author)) classes.push('serviceUser');
+    if (isServiceUser(this.author)) classes.push('serviceUser');
     return classes.join(' ');
   }
 
-  _handleAnchorClick(e: Event) {
+  private handleAnchorClick(e: Event) {
     e.preventDefault();
-    // The element which triggers _handleAnchorClick is rendered only if
+    // The element which triggers handleAnchorClick is rendered only if
     // message.id defined: the element is wrapped in dom-if if="[[message.id]]"
     const detail: MessageAnchorTapDetail = {
       id: this.message!.id,
@@ -524,7 +760,7 @@
     );
   }
 
-  _handleReplyTap(e: Event) {
+  private handleReplyTap(e: Event) {
     e.preventDefault();
     this.dispatchEvent(
       new CustomEvent('reply', {
@@ -535,14 +771,14 @@
     );
   }
 
-  _handleDeleteMessage(e: Event) {
+  private handleDeleteMessage(e: Event) {
     e.preventDefault();
     if (!this.message || !this.message.id || !this.changeNum) return;
-    this._isDeletingChangeMsg = true;
+    this.isDeletingChangeMsg = true;
     this.restApiService
       .deleteChangeCommitMessage(this.changeNum, this.message.id)
       .then(() => {
-        this._isDeletingChangeMsg = false;
+        this.isDeletingChangeMsg = false;
         this.dispatchEvent(
           new CustomEvent('change-message-deleted', {
             detail: {message: this.message},
@@ -553,19 +789,13 @@
       });
   }
 
-  @observe('projectName')
-  _projectNameChanged(name: string) {
-    this.restApiService.getProjectConfig(name as RepoName).then(config => {
-      this._projectConfig = config;
-    });
+  private computeExpandToggleIcon() {
+    return this.message?.expanded ? 'expand_less' : 'expand_more';
   }
 
-  _computeExpandToggleIcon(expanded: boolean) {
-    return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
-  }
-
-  _toggleExpanded(e: Event) {
+  private toggleExpanded(e: Event) {
     e.stopPropagation();
-    this.set('message.expanded', !this.message?.expanded);
+    if (!this.message) return;
+    this.message = {...this.message, expanded: !this.message.expanded};
   }
 }
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
deleted file mode 100644
index 7f3e9de..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ /dev/null
@@ -1,352 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: block;
-      position: relative;
-      cursor: pointer;
-      overflow-y: hidden;
-    }
-    :host(.expanded) {
-      cursor: auto;
-    }
-    .collapsed .contentContainer {
-      align-items: center;
-      color: var(--deemphasized-text-color);
-      display: flex;
-      white-space: nowrap;
-    }
-    .contentContainer {
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    .expanded .contentContainer {
-      background-color: var(--background-color-secondary);
-    }
-    .collapsed .contentContainer {
-      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);
-    }
-    .message {
-      --gr-formatted-text-prose-max-width: 120ch;
-    }
-    .collapsed .message {
-      max-width: none;
-      overflow: hidden;
-      text-overflow: ellipsis;
-    }
-    .collapsed .author,
-    .collapsed .content,
-    .collapsed .message,
-    .collapsed .updateCategory,
-    gr-account-chip {
-      display: inline;
-    }
-    gr-button {
-      margin: 0 -4px;
-    }
-    .collapsed gr-thread-list,
-    .collapsed .replyBtn,
-    .collapsed .deleteBtn,
-    .collapsed .hideOnCollapsed,
-    .hideOnOpen {
-      display: none;
-    }
-    .replyBtn {
-      margin-right: var(--spacing-m);
-    }
-    .collapsed .hideOnOpen {
-      display: block;
-    }
-    .collapsed .content {
-      flex: 1;
-      margin-right: var(--spacing-m);
-      min-width: 0;
-      overflow: hidden;
-    }
-    .collapsed .content.messageContent {
-      text-overflow: ellipsis;
-    }
-    .collapsed .dateContainer {
-      position: static;
-    }
-    .collapsed .author {
-      overflow: hidden;
-      color: var(--primary-text-color);
-      margin-right: var(--spacing-s);
-    }
-    .authorLabel {
-      min-width: 130px;
-      --account-max-length: 120px;
-      margin-right: var(--spacing-s);
-    }
-    .expanded .author {
-      cursor: pointer;
-      margin-bottom: var(--spacing-m);
-    }
-    .expanded .content {
-      padding-left: 40px;
-    }
-    .dateContainer {
-      position: absolute;
-      /* right and top values should match .contentContainer padding */
-      right: var(--spacing-l);
-      top: var(--spacing-m);
-    }
-    .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);
-      --gr-button-padding: 0 var(--spacing-m);
-    }
-    span.date {
-      color: var(--deemphasized-text-color);
-    }
-    span.date:hover {
-      text-decoration: underline;
-    }
-    .dateContainer iron-icon {
-      cursor: pointer;
-      vertical-align: top;
-    }
-    .score {
-      box-sizing: border-box;
-      border-radius: var(--border-radius);
-      color: var(--vote-text-color);
-      display: inline-block;
-      padding: 0 var(--spacing-s);
-      text-align: center;
-    }
-    .score,
-    .commentsSummary {
-      margin-right: var(--spacing-s);
-      min-width: 115px;
-    }
-    .expanded .commentsSummary {
-      display: none;
-    }
-    .commentsIcon {
-      vertical-align: top;
-    }
-    .score.removed {
-      background-color: var(--vote-color-neutral);
-    }
-    .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::part(gr-account-label-text) {
-      font-weight: var(--font-weight-bold);
-    }
-    iron-icon {
-      --iron-icon-height: 20px;
-      --iron-icon-width: 20px;
-    }
-    @media screen and (max-width: 50em) {
-      .expanded .content {
-        padding-left: 0;
-      }
-      .score,
-      .commentsSummary {
-        min-width: 0px;
-      }
-      .authorLabel {
-        width: 100px;
-      }
-      .dateContainer .patchset:before {
-        content: 'PS ';
-      }
-    }
-  </style>
-  <div class$="[[_computeClass(_expanded, author)]]">
-    <div class="contentContainer">
-      <div class="author" on-click="_handleAuthorClick">
-        <span hidden$="[[!showOnBehalfOf]]">
-          <span class="name">[[message.real_author.name]]</span>
-          on behalf of
-        </span>
-        <gr-account-label
-          account="[[author]]"
-          class="authorLabel"
-        ></gr-account-label>
-        <template
-          is="dom-repeat"
-          items="[[_getScores(message, labelExtremes)]]"
-          as="score"
-        >
-          <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
-            [[score.label]] [[score.value]]
-          </span>
-        </template>
-      </div>
-      <template is="dom-if" if="[[_commentCountText]]">
-        <div class="commentsSummary">
-          <iron-icon icon="gr-icons:comment" class="commentsIcon"></iron-icon>
-          <span class="numberOfComments">[[_commentCountText]]</span>
-        </div>
-      </template>
-      <template is="dom-if" if="[[message.message]]">
-        <div class="content messageContent">
-          <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
-          <template is="dom-if" if="[[_expanded]]">
-            <gr-formatted-text
-              noTrailingMargin
-              class="message hideOnCollapsed"
-              content="[[_messageContentExpanded]]"
-              config="[[_projectConfig.commentlinks]]"
-            ></gr-formatted-text>
-            <template is="dom-if" if="[[_messageContentExpanded]]">
-              <div
-                class="replyActionContainer"
-                hidden$="[[!showReplyButton]]"
-                hidden=""
-              >
-                <gr-button
-                  class="replyBtn"
-                  link=""
-                  small=""
-                  on-click="_handleReplyTap"
-                >
-                  Reply
-                </gr-button>
-                <gr-button
-                  disabled$="[[_isDeletingChangeMsg]]"
-                  class="deleteBtn"
-                  hidden$="[[!_isAdmin]]"
-                  hidden=""
-                  link=""
-                  small=""
-                  on-click="_handleDeleteMessage"
-                >
-                  Delete
-                </gr-button>
-              </div>
-            </template>
-            <gr-thread-list
-              change="[[change]]"
-              hidden$="[[!commentThreads.length]]"
-              threads="[[commentThreads]]"
-              change-num="[[changeNum]]"
-              logged-in="[[_loggedIn]]"
-              hide-dropdown
-              show-comment-context
-            >
-            </gr-thread-list>
-          </template>
-        </div>
-      </template>
-      <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
-        <div class="content">
-          <template is="dom-repeat" items="[[message.updates]]" as="update">
-            <div class="updateCategory">
-              [[update.message]]
-              <template
-                is="dom-repeat"
-                items="[[update.reviewers]]"
-                as="reviewer"
-              >
-                <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>
-        </template>
-        <template is="dom-if" if="[[!message.id]]">
-          <span class="date">
-            <gr-date-formatter
-              withTooltip
-              showDateAndTime
-              date-str="[[message.date]]"
-            ></gr-date-formatter>
-          </span>
-        </template>
-        <template is="dom-if" if="[[message.id]]">
-          <span class="date" on-click="_handleAnchorClick">
-            <gr-date-formatter
-              withTooltip
-              showDateAndTime
-              date-str="[[message.date]]"
-            ></gr-date-formatter>
-          </span>
-        </template>
-        <iron-icon
-          id="expandToggle"
-          on-click="_toggleExpanded"
-          title="Toggle expanded state"
-          icon="[[_computeExpandToggleIcon(_expanded)]]"
-        ></iron-icon>
-      </span>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index f87c4c3..34292d6 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -1,59 +1,48 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-message';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   createAccountWithIdNameAndEmail,
   createChange,
   createChangeMessage,
   createComment,
   createRevisions,
+  createLabelInfo,
+  createCommentThread,
 } from '../../../test/test-data-generators';
 import {
   mockPromise,
   query,
-  queryAll,
   queryAndAssert,
   stubRestApi,
+  waitEventLoop,
 } from '../../../test/test-utils';
 import {GrMessage} from './gr-message';
 import {
   AccountId,
-  BasePatchSetNum,
   ChangeMessageId,
   EmailAddress,
   NumericChangeId,
-  PatchSetNum,
+  RevisionPatchSetNum,
   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';
-
-const basicFixture = fixtureFromElement('gr-message');
+import {SinonStub} from 'sinon';
+import {html} from 'lit';
+import {fixture, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-message tests', () => {
   let element: GrMessage;
@@ -61,8 +50,7 @@
   suite('when admin and logged in', () => {
     setup(async () => {
       stubRestApi('getIsAdmin').returns(Promise.resolve(true));
-      element = basicFixture.instantiate();
-      await flush();
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
     test('reply event', async () => {
@@ -76,7 +64,7 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'Uploaded patch set 1.',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
 
@@ -85,11 +73,9 @@
         assert.deepEqual(e.detail.message, element.message);
         promise.resolve();
       });
-      await flush();
-      assert.isFalse(
-        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
-      );
-      tap(queryAndAssert(element, '.replyBtn'));
+      await waitEventLoop();
+      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
+      queryAndAssert<GrButton>(element, '.replyBtn').click();
       await promise;
     });
 
@@ -104,12 +90,12 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'Uploaded patch set 1.',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
+      await element.updateComplete;
 
-      await flush();
-      assert.isFalse(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+      assert.isOk(query<HTMLElement>(element, '.deleteBtn'));
     });
 
     test('delete change message', async () => {
@@ -124,99 +110,274 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'Uploaded patch set 1.',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
+      await element.updateComplete;
 
       const promise = mockPromise();
       element.addEventListener(
         'change-message-deleted',
-        (e: CustomEvent<ChangeMessageDeletedEventDetail>) => {
+        async (e: CustomEvent<ChangeMessageDeletedEventDetail>) => {
+          await element.updateComplete;
           assert.deepEqual(e.detail.message, element.message);
           assert.isFalse(
-            (queryAndAssert(element, '.deleteBtn') as GrButton).disabled
+            queryAndAssert<GrButton>(element, '.deleteBtn').disabled
           );
           promise.resolve();
         }
       );
-      await flush();
-      tap(queryAndAssert(element, '.deleteBtn'));
-      assert.isTrue(
-        (queryAndAssert(element, '.deleteBtn') as GrButton).disabled
-      );
+      queryAndAssert<GrButton>(element, '.deleteBtn').click();
+      await element.updateComplete;
+      assert.isTrue(queryAndAssert<GrButton>(element, '.deleteBtn').disabled);
       await promise;
     });
 
-    test('autogenerated prefix hiding', () => {
+    test('autogenerated prefix hiding', async () => {
       element.message = {
         ...createChangeMessage(),
         tag: 'autogenerated:gerrit:test' as ReviewInputTag,
         expanded: false,
       };
+      await element.updateComplete;
 
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isTrue(element.computeIsAutomated());
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<div class="collapsed">
+          <div class="contentContainer">
+            <div class="author">
+              <gr-account-label class="authorLabel"> </gr-account-label>
+              <gr-message-scores> </gr-message-scores>
+            </div>
+            <div class="content messageContent">
+              <div class="hideOnOpen message">
+                This is a message with id cm_id_1
+              </div>
+            </div>
+            <span class="dateContainer">
+              <span class="date">
+                <gr-date-formatter showdateandtime="" withtooltip="">
+                </gr-date-formatter>
+              </span>
+              <gr-icon
+                icon="expand_more"
+                id="expandToggle"
+                title="Toggle expanded state"
+              ></gr-icon>
+            </span>
+          </div>
+        </div>`
+      );
 
       element.hideAutomated = true;
+      await element.updateComplete;
 
-      assert.isTrue(element.hidden);
+      assert.shadowDom.equal(element, /* HTML */ '');
     });
 
-    test('reviewer message treated as autogenerated', () => {
+    test('reviewer message treated as autogenerated', async () => {
       element.message = {
         ...createChangeMessage(),
         tag: 'autogenerated:gerrit:test' as ReviewInputTag,
         reviewer: {},
         expanded: false,
       };
+      await element.updateComplete;
 
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isTrue(element.computeIsAutomated());
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<div class="collapsed">
+          <div class="contentContainer">
+            <div class="author">
+              <gr-account-label class="authorLabel"> </gr-account-label>
+              <gr-message-scores> </gr-message-scores>
+            </div>
+            <div class="content messageContent">
+              <div class="hideOnOpen message">
+                This is a message with id cm_id_1
+              </div>
+            </div>
+            <span class="dateContainer">
+              <span class="date">
+                <gr-date-formatter showdateandtime="" withtooltip="">
+                </gr-date-formatter>
+              </span>
+              <gr-icon
+                icon="expand_more"
+                id="expandToggle"
+                title="Toggle expanded state"
+              ></gr-icon>
+            </span>
+          </div>
+        </div>`
+      );
 
       element.hideAutomated = true;
+      await element.updateComplete;
 
-      assert.isTrue(element.hidden);
+      assert.shadowDom.equal(element, /* HTML */ '');
     });
 
-    test('batch reviewer message treated as autogenerated', () => {
+    test('batch reviewer message treated as autogenerated', async () => {
       element.message = {
         ...createChangeMessage(),
         type: 'REVIEWER_UPDATE',
         reviewer: {},
         expanded: false,
+        updates: [],
       };
+      await element.updateComplete;
 
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isTrue(element.computeIsAutomated());
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<div class="collapsed">
+          <div class="contentContainer">
+            <div class="author">
+              <gr-account-label class="authorLabel"> </gr-account-label>
+              <gr-message-scores> </gr-message-scores>
+            </div>
+            <div class="content messageContent">
+              <div class="hideOnOpen message">
+                This is a message with id cm_id_1
+              </div>
+            </div>
+            <div class="content"></div>
+            <span class="dateContainer">
+              <span class="date">
+                <gr-date-formatter showdateandtime="" withtooltip="">
+                </gr-date-formatter>
+              </span>
+              <gr-icon
+                icon="expand_more"
+                id="expandToggle"
+                title="Toggle expanded state"
+              ></gr-icon>
+            </span>
+          </div>
+        </div>`
+      );
 
       element.hideAutomated = true;
+      await element.updateComplete;
 
-      assert.isTrue(element.hidden);
+      assert.shadowDom.equal(element, /* HTML */ '');
     });
 
-    test('tag that is not autogenerated prefix does not hide', () => {
+    test('tag that is not autogenerated prefix does not hide', async () => {
       element.message = {
         ...createChangeMessage(),
         tag: 'something' as ReviewInputTag,
         expanded: false,
       };
+      await element.updateComplete;
 
-      assert.isFalse(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isFalse(element.computeIsAutomated());
+      const rendered = /* HTML */ `<div class="collapsed">
+        <div class="contentContainer">
+          <div class="author">
+            <gr-account-label class="authorLabel"> </gr-account-label>
+            <gr-message-scores> </gr-message-scores>
+          </div>
+          <div class="content messageContent">
+            <div class="hideOnOpen message">
+              This is a message with id cm_id_1
+            </div>
+          </div>
+          <span class="dateContainer">
+            <span class="date">
+              <gr-date-formatter showdateandtime="" withtooltip="">
+              </gr-date-formatter>
+            </span>
+            <gr-icon
+              icon="expand_more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            ></gr-icon>
+          </span>
+        </div>
+      </div>`;
+      assert.shadowDom.equal(element, rendered);
 
       element.hideAutomated = true;
+      await element.updateComplete;
+      console.error(element.computeIsAutomated());
 
-      assert.isFalse(element.hidden);
+      assert.shadowDom.equal(element, rendered);
+    });
+
+    test('renders comment message', async () => {
+      element.commentThreads = [
+        createCommentThread([
+          createComment({message: 'hello 1', unresolved: true}),
+        ]),
+        createCommentThread([createComment({message: 'hello 2'})]),
+      ];
+      element.message = {
+        ...createChangeMessage(),
+        commentThreads: element.commentThreads,
+      };
+      await element.updateComplete;
+
+      const rendered = /* HTML */ `<div class="collapsed">
+        <div class="contentContainer">
+          <div class="author">
+            <gr-account-label class="authorLabel"> </gr-account-label>
+            <gr-message-scores> </gr-message-scores>
+          </div>
+          <div class="commentsSummary">
+            <span class="numberOfComments" title="1 unresolved comment">
+              <gr-icon
+                class="commentsIcon unresolved"
+                small
+                filled
+                icon="chat_bubble"
+              >
+              </gr-icon>
+              1
+            </span>
+            <span class="numberOfComments" title="1 resolved comment">
+              <gr-icon
+                class="commentsIcon"
+                small
+                icon="mark_chat_read"
+              ></gr-icon>
+              1
+            </span>
+          </div>
+          <div class="content messageContent">
+            <div class="hideOnOpen message">
+              This is a message with id cm_id_1
+            </div>
+          </div>
+          <span class="dateContainer">
+            <span class="date">
+              <gr-date-formatter showdateandtime="" withtooltip="">
+              </gr-date-formatter>
+            </span>
+            <gr-icon
+              icon="expand_more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            ></gr-icon>
+          </span>
+        </div>
+      </div>`;
+      assert.shadowDom.equal(element, rendered);
     });
 
     test('reply button hidden unless logged in', () => {
-      const message = {
+      element.message = {
         ...createChangeMessage(),
         message: 'Uploaded patch set 1.',
         expanded: false,
       };
-      assert.isFalse(element._computeShowReplyButton(message, false));
-      assert.isTrue(element._computeShowReplyButton(message, true));
+      element.loggedIn = false;
+      assert.isFalse(element.computeShowReplyButton());
+      element.loggedIn = true;
+      assert.isTrue(element.computeShowReplyButton());
     });
 
     test('_computeShowOnBehalfOf', () => {
@@ -225,57 +386,47 @@
         message: '...',
         expanded: false,
       };
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      element.message = message;
+      assert.isNotOk(element.computeShowOnBehalfOf());
       message.author = {_account_id: 1115495 as AccountId};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      assert.isNotOk(element.computeShowOnBehalfOf());
       message.real_author = {_account_id: 1115495 as AccountId};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      assert.isNotOk(element.computeShowOnBehalfOf());
       message.real_author._account_id = 123456 as AccountId;
-      assert.isOk(element._computeShowOnBehalfOf(message));
+      assert.isOk(element.computeShowOnBehalfOf());
       message.updated_by = message.author;
       delete message.author;
-      assert.isOk(element._computeShowOnBehalfOf(message));
+      assert.isOk(element.computeShowOnBehalfOf());
       delete message.updated_by;
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      assert.isNotOk(element.computeShowOnBehalfOf());
     });
 
-    ['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', () => {
+    test('clicking on date link fires event', async () => {
       element.message = {
         ...createChangeMessage(),
         type: 'REVIEWER_UPDATE',
         reviewer: {},
         id: '47c43261_55aa2c41' as ChangeMessageId,
         expanded: false,
+        updates: [],
       };
-      flush();
+      await element.updateComplete;
+
       const stub = sinon.stub();
       element.addEventListener('message-anchor-tap', stub);
-      const dateEl = queryAndAssert(element, '.date');
+      const dateEl = queryAndAssert<HTMLSpanElement>(element, '.date');
       assert.ok(dateEl);
-      tap(dateEl);
+      dateEl.click();
 
       assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
+      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>;
+      let setUrlStub: SinonStub;
       setup(() => {
         element.change = {...createChange(), revisions: createRevisions(4)};
-        navStub = sinon.stub(GerritNav, 'navigateToChange');
+        setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
       });
 
       test('Patchset 1 navigates to Base', () => {
@@ -283,14 +434,10 @@
           ...createChangeMessage(),
           message: 'Uploaded patch set 1.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(
-          navStub.calledWithExactly(
-            element.change!,
-            1 as PatchSetNum,
-            'PARENT' as BasePatchSetNum
-          )
-        );
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
+
+        assert.isTrue(setUrlStub.calledOnce);
+        assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
       });
 
       test('Patchset X navigates to X vs X - 1', () => {
@@ -298,26 +445,21 @@
           ...createChangeMessage(),
           message: 'Uploaded patch set 2.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(
-          navStub.calledWithExactly(
-            element.change!,
-            2 as PatchSetNum,
-            1 as BasePatchSetNum
-          )
-        );
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
+
+        assert.isTrue(setUrlStub.calledOnce);
+        assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..2');
 
         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
-          )
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
+
+        assert.isTrue(setUrlStub.calledTwice);
+        assert.equal(
+          setUrlStub.lastCall.firstArg,
+          '/c/test-project/+/42/199..200'
         );
       });
 
@@ -326,14 +468,10 @@
           ...createChangeMessage(),
           message: 'Commit message updated.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
-        assert.isTrue(
-          navStub.calledWithExactly(
-            element.change!,
-            4 as PatchSetNum,
-            3 as BasePatchSetNum
-          )
-        );
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
+
+        assert.isTrue(setUrlStub.calledOnce);
+        assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3..4');
       });
 
       test('Merged patchset change message', () => {
@@ -341,34 +479,36 @@
           ...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
-          )
-        );
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
+
+        assert.isTrue(setUrlStub.calledOnce);
+        assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3..4');
       });
     });
 
     suite('compute messages', () => {
+      const labels = {
+        'Code-Review': createLabelInfo(1),
+        'Code-Style': createLabelInfo(1),
+      };
       test('empty', () => {
         assert.equal(
-          element._computeMessageContent(
+          element.computeMessageContent(
             true,
             '',
             undefined,
-            '' as ReviewInputTag
+            '' as ReviewInputTag,
+            labels
           ),
           ''
         );
         assert.equal(
-          element._computeMessageContent(
+          element.computeMessageContent(
             false,
             '',
             undefined,
-            '' as ReviewInputTag
+            '' as ReviewInputTag,
+            labels
           ),
           ''
         );
@@ -377,13 +517,19 @@
       test('new patchset', () => {
         const original = 'Uploaded patch set 1.';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
+          element.computeMessageContent(true, original, [], tag, labels)
         );
         assert.equal(actual, original);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(
+          false,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, original);
       });
 
@@ -391,13 +537,25 @@
         const original = 'Patch Set 27: Patch Set 26 was rebased';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
         const expected = 'Patch Set 26 was rebased';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(
+          true,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
+          element.computeMessageContent(true, original, [], tag)
         );
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(
+          false,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
       });
 
@@ -405,31 +563,89 @@
         const original = 'Patch Set 1:\n\nThis change is ready for review.';
         const tag = undefined;
         const expected = 'This change is ready for review.';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(
+          true,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
+          element.computeMessageContent(true, original, [], tag)
         );
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(
+          false,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
       });
       test('new patchset with vote', () => {
         const original = 'Uploaded patch set 2: Code-Review+1';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
         const expected = 'Uploaded patch set 2: Code-Review+1';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(
+          true,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(
+          false,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
       });
       test('vote', () => {
         const original = 'Patch Set 1: Code-Style+1';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(
+          true,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(
+          false,
+          original,
+          [],
+          tag,
+          labels
+        );
+        assert.equal(actual, expected);
+      });
+
+      test('legacy change message', () => {
+        const original = 'Patch Set 1: Legacy Message';
+        const tag = undefined;
+        const expected = 'Legacy Message';
+        let actual = element.computeMessageContent(
+          true,
+          original,
+          [],
+          tag,
+          labels
+        );
+        assert.equal(actual, expected);
+        actual = element.computeMessageContent(
+          false,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
       });
 
@@ -437,9 +653,21 @@
         const original = 'Patch Set 1:\n\n(3 comments)';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(
+          true,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(
+          false,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
       });
 
@@ -453,18 +681,20 @@
           createAccountWithIdNameAndEmail(1),
           createAccountWithIdNameAndEmail(2),
         ];
-        let actual = element._computeMessageContent(
+        let actual = element.computeMessageContent(
           true,
           original,
           accountsInMessage,
-          tag
+          tag,
+          labels
         );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           accountsInMessage,
-          tag
+          tag,
+          labels
         );
         assert.equal(actual, expected);
       });
@@ -475,109 +705,34 @@
         const tag = undefined;
         const expected =
           'Removed vote: \n\n * Code-Style+1 by Gerrit Account 1\n * Code-Style-1 by Gerrit Account 2';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(
+          true,
+          original,
+          [],
+          tag,
+          labels
+        );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(
+          false,
+          original,
+          [],
+          tag,
+          labels
+        );
         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(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getIsAdmin').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      await flush();
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
-    test('reply and delete button should be hidden', () => {
+    test('reply and delete button should be hidden', async () => {
       element.message = {
         ...createChangeMessage(),
         id: '47c43261_55aa2c41' as ChangeMessageId,
@@ -588,82 +743,71 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'Uploaded patch set 1.',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
 
-      flush();
-      assert.isTrue(
-        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
-      );
-      assert.isTrue(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+      await element.updateComplete;
+      assert.isNotOk(query<HTMLElement>(element, '.replyActionContainer'));
+      assert.isNotOk(query<HTMLElement>(element, '.deleteBtn'));
     });
   });
 
-  suite('patchset comment summary', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
+  suite('patchset comment summary', async () => {
+    setup(async () => {
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
       element.message = {
         ...createChangeMessage(),
         id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3' as ChangeMessageId,
       };
+      await element.updateComplete;
     });
 
     test('single patchset comment posted', () => {
-      const threads = [
+      element.commentThreads = [
         {
           comments: [
             {
               ...createComment(),
               change_message_id:
                 '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3' as ChangeMessageId,
-              patch_set: 1 as PatchSetNum,
+              patch_set: 1 as RevisionPatchSetNum,
               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,
+          patchNum: 1 as RevisionPatchSetNum,
           path: '/PATCHSET_LEVEL',
           rootId: 'e365b138_bed65caa' as UrlEncodedCommentId,
           commentSide: CommentSide.REVISION,
         },
       ];
+      assert.equal(element.patchsetCommentSummary(), 'testing the load');
       assert.equal(
-        element._computeMessageContentCollapsed(
-          '',
-          undefined,
-          undefined,
-          threads
-        ),
-        'testing the load'
-      );
-      assert.equal(
-        element._computeMessageContent(false, '', undefined, undefined),
+        element.computeMessageContent(false, '', undefined, undefined),
         ''
       );
     });
 
     test('single patchset comment with reply', () => {
-      const threads = [
+      element.commentThreads = [
         {
           comments: [
             {
               ...createComment(),
-              patch_set: 1 as PatchSetNum,
+              patch_set: 1 as RevisionPatchSetNum,
               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,
+              patch_set: 1 as RevisionPatchSetNum,
               id: 'd6efcc85_4cbbb6f4' as UrlEncodedCommentId,
               in_reply_to: 'e365b138_bed65caa' as UrlEncodedCommentId,
               updated: '2020-05-15 16:55:28.000000000' as Timestamp,
@@ -671,26 +815,17 @@
               unresolved: false,
               path: '/PATCHSET_LEVEL',
               __draft: true,
-              collapsed: true,
             },
           ],
-          patchNum: 1 as PatchSetNum,
+          patchNum: 1 as RevisionPatchSetNum,
           path: '/PATCHSET_LEVEL',
           rootId: 'e365b138_bed65caa' as UrlEncodedCommentId,
           commentSide: CommentSide.REVISION,
         },
       ];
+      assert.equal(element.patchsetCommentSummary(), 'n');
       assert.equal(
-        element._computeMessageContentCollapsed(
-          '',
-          undefined,
-          undefined,
-          threads
-        ),
-        'n'
-      );
-      assert.equal(
-        element._computeMessageContent(false, '', undefined, undefined),
+        element.computeMessageContent(false, '', undefined, undefined),
         ''
       );
     });
@@ -699,11 +834,10 @@
   suite('when logged in but not admin', () => {
     setup(async () => {
       stubRestApi('getIsAdmin').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      await flush();
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
-    test('can see reply but not delete button', () => {
+    test('can see reply but not delete button', async () => {
       element.message = {
         ...createChangeMessage(),
         id: '47c43261_55aa2c41' as ChangeMessageId,
@@ -714,20 +848,19 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'Uploaded patch set 1.',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
+      await element.updateComplete;
 
-      flush();
-      assert.isFalse(
-        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
-      );
-      assert.isTrue(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
+      assert.isNotOk(query<HTMLElement>(element, '.deleteBtn'));
     });
 
-    test('reply button shown when message is updated', () => {
+    test('reply button shown when message is updated', async () => {
       element.message = undefined;
-      flush();
+      await element.updateComplete;
+
       let replyEl = query(element, '.replyActionContainer');
       // We don't even expect the button to show up in the DOM when the message
       // is undefined.
@@ -743,13 +876,13 @@
         },
         date: '2016-01-12 20:24:49.448000000' as Timestamp,
         message: 'not empty',
-        _revision_number: 1 as PatchSetNum,
+        _revision_number: 1 as RevisionPatchSetNum,
         expanded: true,
       };
-      flush();
+      await element.updateComplete;
+
       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 c3acfb0..c46f4fc 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
@@ -1,54 +1,49 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-icons/gr-icons';
 import '../gr-message/gr-message';
 import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-messages-list_html';
-import {
-  Shortcut,
-  ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {parseDate} from '../../../utils/date-util';
 import {MessageTag} from '../../../constants/constants';
-import {appContext} from '../../../services/app-context';
-import {customElement, property} from '@polymer/decorators';
+import {getAppContext} from '../../../services/app-context';
+import {customElement, property, state} from 'lit/decorators.js';
 import {
   ChangeId,
   ChangeMessageId,
   ChangeMessageInfo,
-  ChangeViewChangeInfo,
   LabelNameToInfoMap,
   NumericChangeId,
   PatchSetNum,
-  RepoName,
-  ReviewerUpdateInfo,
   VotingRangeInfo,
 } from '../../../types/common';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {CommentThread, isRobot} from '../../../utils/comment-util';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
 import {getVotingRange} from '../../../utils/label-util';
-import {FormattedReviewerUpdateInfo} from '../../../types/types';
+import {
+  FormattedReviewerUpdateInfo,
+  ParsedChangeInfo,
+} from '../../../types/types';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {resolve} from '../../../models/dependency';
+import {query, queryAll} from '../../../utils/common-util';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {when} from 'lit/directives/when.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {
+  Shortcut,
+  ShortcutSection,
+  shortcutsServiceToken,
+} from '../../../services/shortcuts/shortcuts-service';
+import {GrFormattedText} from '../../shared/gr-formatted-text/gr-formatted-text';
+import {Interaction} from '../../../constants/reporting';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -63,7 +58,7 @@
   all: number;
 }
 
-type CombinedMessage = Omit<
+export type CombinedMessage = Omit<
   FormattedReviewerUpdateInfo | ChangeMessageInfo,
   'tag'
 > & {
@@ -92,17 +87,10 @@
   message: CombinedMessage,
   allThreadsForChange: CommentThread[]
 ): CommentThread[] {
-  if (message._index === undefined) {
-    return [];
-  }
+  if (message._index === undefined) return [];
   const messageId = getMessageId(message);
   return allThreadsForChange.filter(thread =>
-    thread.comments.some(comment => {
-      const matchesMessage = comment.change_message_id === messageId;
-      if (!matchesMessage) return false;
-      comment.collapsed = !matchesMessage;
-      return matchesMessage;
-    })
+    thread.comments.some(comment => comment.change_message_id === messageId)
   );
 }
 
@@ -119,6 +107,9 @@
  *
  * 3. Everything beyond the ~ character is cut off from the tag. That gives
  * tools control over which messages will be hidden.
+ *
+ * 4. (Non-WIP) patchset uploads get a separate tag when they invalidate any
+ * votes.
  */
 function computeTag(message: CombinedMessage) {
   if (!message.tag) {
@@ -131,12 +122,18 @@
     return hasRobotComments ? 'autogenerated:has-robot-comments' : undefined;
   }
 
+  if (message.tag === MessageTag.TAG_NEW_PATCHSET) {
+    const hasOutdatedVotes =
+      isChangeMessageInfo(message) &&
+      message.message.indexOf('\nOutdated Votes:\n') !== -1;
+
+    return hasOutdatedVotes
+      ? MessageTag.TAG_NEW_PATCHSET_OUTDATED_VOTES
+      : MessageTag.TAG_NEW_PATCHSET;
+  }
   if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
     return MessageTag.TAG_NEW_PATCHSET;
   }
-  if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
-    return MessageTag.TAG_SET_ASSIGNEE;
-  }
   if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
     return MessageTag.TAG_SET_PRIVATE;
   }
@@ -169,6 +166,63 @@
 }
 
 /**
+ * Merges change messages and reviewer updates into one array. Also processes
+ * all messages and updates, aligns or massages some of the properties.
+ */
+function computeCombinedMessages(
+  messages: ChangeMessageInfo[],
+  reviewerUpdates: FormattedReviewerUpdateInfo[],
+  commentThreads: CommentThread[]
+): CombinedMessage[] {
+  let mi = 0;
+  let ri = 0;
+  let combinedMessages: CombinedMessage[] = [];
+  let mDate;
+  let rDate;
+  for (let i = 0; i < messages.length; i++) {
+    // TODO(TS): clone message instead and avoid API object mutation
+    (messages[i] as CombinedMessage)._index = i;
+  }
+
+  while (mi < messages.length || ri < reviewerUpdates.length) {
+    if (mi >= messages.length) {
+      combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
+      break;
+    }
+    if (ri >= reviewerUpdates.length) {
+      combinedMessages = combinedMessages.concat(messages.slice(mi));
+      break;
+    }
+    mDate = mDate || parseDate(messages[mi].date);
+    rDate = rDate || parseDate(reviewerUpdates[ri].date);
+    if (rDate < mDate) {
+      combinedMessages.push(reviewerUpdates[ri++]);
+      rDate = null;
+    } else {
+      combinedMessages.push(messages[mi++]);
+      mDate = null;
+    }
+  }
+
+  for (let i = 0; i < combinedMessages.length; i++) {
+    const message = combinedMessages[i];
+    if (message.expanded === undefined) {
+      message.expanded = false;
+    }
+    message.commentThreads = computeThreads(message, commentThreads);
+    message._revision_number = computeRevision(message, combinedMessages);
+    message.tag = computeTag(message);
+  }
+  // computeIsImportant() depends on tags and revision numbers already being
+  // updated for all messages, so we have to compute this in its own forEach
+  // loop.
+  combinedMessages.forEach(m => {
+    m.isImportant = computeIsImportant(m, combinedMessages);
+  });
+  return combinedMessages;
+}
+
+/**
  * Unimportant messages are initially hidden.
  *
  * Human messages are always important. They have an undefined tag.
@@ -195,201 +249,244 @@
   computeIsImportant,
 };
 
-export interface GrMessagesList {
-  $: {
-    messageRepeat: DomRepeat;
-  };
-}
-
 @customElement('gr-messages-list')
-export class GrMessagesList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrMessagesList extends LitElement {
+  // TODO: Evaluate if we still need to have display: flex on the :host and
+  // .header.
+  static override get styles() {
+    return [
+      sharedStyles,
+      paperStyles,
+      css`
+        :host {
+          display: flex;
+          justify-content: space-between;
+        }
+        .header {
+          align-items: center;
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .highlighted {
+          animation: 3s fadeOut;
+        }
+        @keyframes fadeOut {
+          0% {
+            background-color: var(--emphasis-color);
+          }
+          100% {
+            background-color: var(--view-background-color);
+          }
+        }
+        .container {
+          align-items: center;
+          display: flex;
+        }
+        .hiddenEntries {
+          color: var(--deemphasized-text-color);
+        }
+        gr-message:not(:last-of-type) {
+          border-bottom: 1px solid var(--border-color);
+        }
+      `,
+    ];
   }
 
-  @property({type: Object})
-  change?: ChangeViewChangeInfo;
-
-  @property({type: String})
-  changeNum?: ChangeId | NumericChangeId;
-
   @property({type: Array})
   messages: ChangeMessageInfo[] = [];
 
   @property({type: Array})
-  reviewerUpdates: ReviewerUpdateInfo[] = [];
-
-  @property({type: Object})
-  changeComments?: ChangeComments;
-
-  @property({type: String})
-  projectName?: RepoName;
-
-  @property({type: Boolean})
-  showReplyButtons = false;
+  reviewerUpdates: FormattedReviewerUpdateInfo[] = [];
 
   @property({type: Object})
   labels?: LabelNameToInfoMap;
 
-  @property({type: String})
-  _expandAllState = ExpandAllState.EXPAND_ALL;
+  @state()
+  private change?: ParsedChangeInfo;
 
-  @property({type: String, computed: '_computeExpandAllTitle(_expandAllState)'})
-  _expandAllTitle = '';
+  @state()
+  private changeNum?: ChangeId | NumericChangeId;
 
-  @property({type: Boolean, observer: '_observeShowAllActivity'})
-  _showAllActivity = false;
+  @state()
+  private commentThreads: CommentThread[] = [];
 
-  @property({
-    type: Array,
-    computed:
-      '_computeCombinedMessages(messages, reviewerUpdates, ' +
-      'changeComments)',
-    observer: '_combinedMessagesChanged',
-  })
-  _combinedMessages: CombinedMessage[] = [];
+  @state()
+  expandAllState = ExpandAllState.EXPAND_ALL;
 
-  @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
-  _labelExtremes: {[labelName: string]: VotingRangeInfo} = {};
+  // Private but used in tests.
+  @state()
+  showAllActivity = false;
 
-  private readonly reporting = appContext.reportingService;
+  @state()
+  private combinedMessages: CombinedMessage[] = [];
 
-  private readonly shortcuts = appContext.shortcutsService;
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  scrollToMessage(messageID: string) {
+  private readonly changeModel = resolve(this, changeModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getCommentsModel().threads$,
+      x => {
+        this.commentThreads = x;
+      }
+    );
+    subscribe(
+      this,
+      () => this.changeModel().change$,
+      x => {
+        this.change = x;
+      }
+    );
+    subscribe(
+      this,
+      () => this.changeModel().changeNum$,
+      x => {
+        this.changeNum = x;
+      }
+    );
+    // for COMMENTS_AUTOCLOSE logging purposes only
+    this.reporting.reportInteraction(
+      Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED
+    );
+  }
+
+  override updated(): void {
+    // for COMMENTS_AUTOCLOSE logging purposes only
+    const messages = this.shadowRoot!.querySelectorAll('gr-message');
+    if (messages.length > 0) {
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED,
+        {uid: messages[0].uid}
+      );
+    }
+  }
+
+  override willUpdate(changedProperties: PropertyValues): void {
+    if (
+      changedProperties.has('messages') ||
+      changedProperties.has('reviewerUpdates') ||
+      changedProperties.has('commentThreads')
+    ) {
+      this.combinedMessages = computeCombinedMessages(
+        this.messages ?? [],
+        this.reviewerUpdates ?? [],
+        this.commentThreads ?? []
+      );
+      this.combinedMessagesChanged();
+    }
+  }
+
+  override render() {
+    const labelExtremes = this.computeLabelExtremes();
+    return html`${this.renderHeader()}
+    ${this.combinedMessages
+      .filter(m => this.showAllActivity || m.isImportant)
+      .map(
+        message => html`<gr-message
+          .change=${this.change}
+          .changeNum=${this.changeNum}
+          .message=${message}
+          .commentThreads=${message.commentThreads}
+          @message-anchor-tap=${this.handleAnchorClick}
+          .labelExtremes=${labelExtremes}
+          data-message-id=${ifDefined(getMessageId(message) as String)}
+        ></gr-message>`
+      )}`;
+  }
+
+  private renderHeader() {
+    return html`<div class="header">
+      <div id="showAllActivityToggleContainer" class="container">
+        ${when(
+          this.combinedMessages.some(m => !m.isImportant),
+          () => html`
+            <paper-toggle-button
+              class="showAllActivityToggle"
+              ?checked=${this.showAllActivity}
+              @change=${this.handleShowAllActivityChanged}
+              aria-labelledby="showAllEntriesLabel"
+              role="switch"
+              @click=${this.onTapShowAllActivityToggle}
+            ></paper-toggle-button>
+            <div id="showAllEntriesLabel" aria-hidden="true">
+              <span>Show all entries</span>
+              <span class="hiddenEntries" ?hidden=${this.showAllActivity}>
+                (${this.combinedMessages.filter(m => !m.isImportant).length}
+                hidden)
+              </span>
+            </div>
+            <span class="transparent separator"></span>
+          `
+        )}
+      </div>
+      <gr-button
+        id="collapse-messages"
+        link
+        .title=${this.computeExpandAllTitle()}
+        @click=${this.handleExpandCollapseTap}
+      >
+        ${this.expandAllState}
+      </gr-button>
+    </div>`;
+  }
+
+  async scrollToMessage(messageID: string) {
     const selector = `[data-message-id="${messageID}"]`;
     const el = this.shadowRoot!.querySelector(selector) as
       | GrMessage
       | undefined;
 
-    if (!el && this._showAllActivity) {
+    if (!el && this.showAllActivity) {
       this.reporting.error(
+        'GrMessagesList scroll',
         new Error(`Failed to scroll to message: ${messageID}`)
       );
       return;
     }
-    if (!el) {
-      this._showAllActivity = true;
+    if (!el || !el.message) {
+      this.showAllActivity = true;
       setTimeout(() => this.scrollToMessage(messageID));
       return;
     }
 
-    el.set('message.expanded', true);
-    let top = el.offsetTop;
-    for (
-      let offsetParent = el.offsetParent as HTMLElement | null;
-      offsetParent;
-      offsetParent = offsetParent.offsetParent as HTMLElement | null
-    ) {
-      top += offsetParent.offsetTop;
-    }
-    window.scrollTo(0, top);
-    this._highlightEl(el);
+    el.message.expanded = true;
+    // Must wait for message to expand and render before we can scroll to it
+    el.requestUpdate();
+    await el.updateComplete;
+    await query<GrFormattedText>(el, 'gr-formatted-text.message')
+      ?.updateComplete;
+    el.scrollIntoView();
+    this.highlightEl(el);
   }
 
-  _observeShowAllActivity() {
-    // We have to call render() such that the dom-repeat filter picks up the
-    // change.
-    this.$.messageRepeat.render();
+  private handleShowAllActivityChanged(e: Event) {
+    this.showAllActivity = (e.target as HTMLInputElement).checked ?? false;
   }
 
-  /**
-   * Filter for the dom-repeat of combinedMessages.
-   */
-  _isMessageVisible(message: CombinedMessage) {
-    return this._showAllActivity || message.isImportant;
-  }
-
-  /**
-   * Merges change messages and reviewer updates into one array. Also processes
-   * all messages and updates, aligns or massages some of the properties.
-   */
-  _computeCombinedMessages(
-    messages?: ChangeMessageInfo[],
-    reviewerUpdates?: FormattedReviewerUpdateInfo[],
-    changeComments?: ChangeComments
-  ) {
-    if (
-      messages === undefined ||
-      reviewerUpdates === undefined ||
-      changeComments === undefined
-    )
-      return [];
-
-    let mi = 0;
-    let ri = 0;
-    let combinedMessages: CombinedMessage[] = [];
-    let mDate;
-    let rDate;
-    for (let i = 0; i < messages.length; i++) {
-      // TODO(TS): clone message instead and avoid API object mutation
-      (messages[i] as CombinedMessage)._index = i;
-    }
-
-    while (mi < messages.length || ri < reviewerUpdates.length) {
-      if (mi >= messages.length) {
-        combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
-        break;
-      }
-      if (ri >= reviewerUpdates.length) {
-        combinedMessages = combinedMessages.concat(messages.slice(mi));
-        break;
-      }
-      mDate = mDate || parseDate(messages[mi].date);
-      rDate = rDate || parseDate(reviewerUpdates[ri].date);
-      if (rDate < mDate) {
-        combinedMessages.push(reviewerUpdates[ri++]);
-        rDate = null;
-      } else {
-        combinedMessages.push(messages[mi++]);
-        mDate = null;
-      }
-    }
-
-    const allThreadsForChange = changeComments.getAllThreadsForChange();
-    // collapse all by default
-    for (const thread of allThreadsForChange) {
-      for (const comment of thread.comments) {
-        comment.collapsed = true;
-      }
-    }
-
-    for (let i = 0; i < combinedMessages.length; i++) {
-      const message = combinedMessages[i];
-      if (message.expanded === undefined) {
-        message.expanded = false;
-      }
-      message.commentThreads = computeThreads(message, allThreadsForChange);
-      message._revision_number = computeRevision(message, combinedMessages);
-      message.tag = computeTag(message);
-    }
-    // computeIsImportant() depends on tags and revision numbers already being
-    // updated for all messages, so we have to compute this in its own forEach
-    // loop.
-    combinedMessages.forEach(m => {
-      m.isImportant = computeIsImportant(m, combinedMessages);
-    });
-    return combinedMessages;
-  }
-
-  _updateExpandedStateOfAllMessages(exp: boolean) {
-    if (this._combinedMessages) {
-      for (let i = 0; i < this._combinedMessages.length; i++) {
-        this._combinedMessages[i].expanded = exp;
-        this.notifyPath(`_combinedMessages.${i}.expanded`);
-      }
+  private refreshMessages() {
+    for (const message of queryAll<GrMessage>(this, 'gr-message')) {
+      message.requestUpdate();
     }
   }
 
-  _computeExpandAllTitle(_expandAllState?: string) {
-    if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
-      return this.shortcuts.createTitle(
+  private computeExpandAllTitle() {
+    if (this.expandAllState === ExpandAllState.COLLAPSE_ALL) {
+      return this.getShortcutsService().createTitle(
         Shortcut.COLLAPSE_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
     }
-    if (_expandAllState === ExpandAllState.EXPAND_ALL) {
-      return this.shortcuts.createTitle(
+    if (this.expandAllState === ExpandAllState.EXPAND_ALL) {
+      return this.getShortcutsService().createTitle(
         Shortcut.EXPAND_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
@@ -397,8 +494,10 @@
     return '';
   }
 
-  _highlightEl(el: HTMLElement) {
-    const highlightedEls = this.root!.querySelectorAll('.highlighted');
+  // Private but used in tests.
+  highlightEl(el: HTMLElement) {
+    const highlightedEls =
+      this.shadowRoot?.querySelectorAll('.highlighted') ?? [];
     for (const highlightedEl of highlightedEls) {
       highlightedEl.classList.remove('highlighted');
     }
@@ -410,42 +509,36 @@
     el.classList.add('highlighted');
   }
 
+  // Private but used in tests.
   handleExpandCollapse(expand: boolean) {
-    this._expandAllState = expand
+    this.expandAllState = expand
       ? ExpandAllState.COLLAPSE_ALL
       : ExpandAllState.EXPAND_ALL;
-    this._updateExpandedStateOfAllMessages(expand);
+    if (!this.combinedMessages) return;
+    for (let i = 0; i < this.combinedMessages.length; i++) {
+      this.combinedMessages[i].expanded = expand;
+    }
+    this.refreshMessages();
   }
 
-  _handleExpandCollapseTap(e: Event) {
+  private handleExpandCollapseTap(e: Event) {
     e.preventDefault();
     this.handleExpandCollapse(
-      this._expandAllState === ExpandAllState.EXPAND_ALL
+      this.expandAllState === ExpandAllState.EXPAND_ALL
     );
   }
 
-  _handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
+  private handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
     this.scrollToMessage(e.detail.id);
   }
 
-  _isVisibleShowAllActivityToggle(messages: CombinedMessage[] = []) {
-    return messages.some(m => !m.isImportant);
-  }
-
-  _computeHiddenEntriesCount(messages: CombinedMessage[] = []) {
-    return messages.filter(m => !m.isImportant).length;
-  }
-
   /**
-   * Called when this._combinedMessages has changed.
+   * Called when this.combinedMessages has changed.
    */
-  _combinedMessagesChanged(combinedMessages?: CombinedMessage[]) {
-    if (!combinedMessages) return;
-    if (combinedMessages.length === 0) return;
-    for (let i = 0; i < combinedMessages.length; i++) {
-      this.notifyPath(`_combinedMessages.${i}.commentThreads`);
-    }
-    const tags = combinedMessages.map(
+  private combinedMessagesChanged() {
+    if (this.combinedMessages.length === 0) return;
+    this.refreshMessages();
+    const tags = this.combinedMessages.map(
       message =>
         message.tag || (message as FormattedReviewerUpdateInfo).type || 'none'
     );
@@ -454,7 +547,7 @@
         acc[val] = (acc[val] || 0) + 1;
         return acc;
       },
-      {all: combinedMessages.length} as TagsCountReportInfo
+      {all: this.combinedMessages.length} as TagsCountReportInfo
     );
     this.reporting.reportInteraction('messages-count', tagsCounted);
   }
@@ -462,20 +555,15 @@
   /**
    * Compute a mapping from label name to objects representing the minimum and
    * maximum possible values for that label.
+   * Private but used in tests.
    */
-  _computeLabelExtremes(
-    labelRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      LabelNameToInfoMap
-    >
-  ) {
+  computeLabelExtremes() {
     const extremes: {[labelName: string]: VotingRangeInfo} = {};
-    const labels = labelRecord.base;
-    if (!labels) {
+    if (!this.labels) {
       return extremes;
     }
-    for (const key of Object.keys(labels)) {
-      const range = getVotingRange(labels[key]);
+    for (const key of Object.keys(this.labels)) {
+      const range = getVotingRange(this.labels[key]);
       if (range) {
         extremes[key] = range;
       }
@@ -486,7 +574,7 @@
   /**
    * Work around a issue on iOS when clicking turns into double tap
    */
-  _onTapShowAllActivityToggle(e: Event) {
+  private onTapShowAllActivityToggle(e: Event) {
     e.preventDefault();
   }
 }
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
deleted file mode 100644
index 087ee19..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ /dev/null
@@ -1,107 +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: flex;
-      justify-content: space-between;
-    }
-    .header {
-      align-items: center;
-      border-bottom: 1px solid var(--border-color);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .highlighted {
-      animation: 3s fadeOut;
-    }
-    @keyframes fadeOut {
-      0% {
-        background-color: var(--emphasis-color);
-      }
-      100% {
-        background-color: var(--view-background-color);
-      }
-    }
-    .container {
-      align-items: center;
-      display: flex;
-    }
-    .hiddenEntries {
-      color: var(--deemphasized-text-color);
-    }
-    gr-message:not(:last-of-type) {
-      border-bottom: 1px solid var(--border-color);
-    }
-  </style>
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="header">
-    <div id="showAllActivityToggleContainer" class="container">
-      <template
-        is="dom-if"
-        if="[[_isVisibleShowAllActivityToggle(_combinedMessages)]]"
-      >
-        <paper-toggle-button
-          class="showAllActivityToggle"
-          checked="{{_showAllActivity}}"
-          aria-labelledby="showAllEntriesLabel"
-          role="switch"
-          on-click="_onTapShowAllActivityToggle"
-        ></paper-toggle-button>
-        <div id="showAllEntriesLabel" aria-hidden="true">
-          <span>Show all entries</span>
-          <span class="hiddenEntries" hidden$="[[_showAllActivity]]">
-            ([[_computeHiddenEntriesCount(_combinedMessages)]] hidden)
-          </span>
-        </div>
-        <span class="transparent separator"></span>
-      </template>
-    </div>
-    <gr-button
-      id="collapse-messages"
-      link=""
-      title="[[_expandAllTitle]]"
-      on-click="_handleExpandCollapseTap"
-    >
-      [[_expandAllState]]
-    </gr-button>
-  </div>
-  <template
-    id="messageRepeat"
-    is="dom-repeat"
-    items="[[_combinedMessages]]"
-    as="message"
-    filter="_isMessageVisible"
-  >
-    <gr-message
-      change="[[change]]"
-      change-num="[[changeNum]]"
-      message="[[message]]"
-      comment-threads="[[message.commentThreads]]"
-      project-name="[[projectName]]"
-      show-reply-button="[[showReplyButtons]]"
-      on-message-anchor-tap="_handleAnchorClick"
-      label-extremes="[[_labelExtremes]]"
-      data-message-id$="[[message.id]]"
-    ></gr-message>
-  </template>
-`;
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
deleted file mode 100644
index a3b8873..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ /dev/null
@@ -1,532 +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 './gr-messages-list.js';
-import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
-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';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api.js';
-
-createCommentApiMockWithTemplateElement(
-    'gr-messages-list-comment-mock-api', html`
-     <gr-messages-list
-         id="messagesList"
-         change-comments="[[_changeComments]]"></gr-messages-list>
-`);
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-messages-list-comment-mock-api>
-  <gr-messages-list></gr-messages-list>
-</gr-messages-list-comment-mock-api>
-`);
-
-const randomMessage = function(opt_params) {
-  const params = opt_params || {};
-  const author1 = {
-    _account_id: 1115495,
-    name: 'Andrew Bonventre',
-    email: 'andybons@chromium.org',
-  };
-  return {
-    id: params.id || Math.random().toString(),
-    date: params.date || '2016-01-12 20:28:33.038000',
-    message: params.message || Math.random().toString(),
-    _revision_number: params._revision_number || 1,
-    author: params.author || author1,
-    tag: params.tag,
-  };
-};
-
-function generateRandomMessages(count) {
-  return new Array(count).fill()
-      .map(() => randomMessage());
-}
-
-suite('gr-messages-list tests', () => {
-  let element;
-  let messages;
-
-  let commentApiWrapper;
-
-  const getMessages = function() {
-    return element.root.querySelectorAll('gr-message');
-  };
-
-  const MESSAGE_ID_0 = '1234ccc949c6d482b061be6a28e10782abf0e7af';
-  const MESSAGE_ID_1 = '8c19ccc949c6d482b061be6a28e10782abf0e7af';
-  const MESSAGE_ID_2 = 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5';
-
-  const author = {
-    _account_id: 42,
-    name: 'Marvin the Paranoid Android',
-    email: 'marvin@sirius.org',
-  };
-
-  const createComment = function() {
-    return {
-      id: '1a2b3c4d',
-      message: 'some random test text',
-      change_message_id: '8a7b6c5d',
-      updated: '2016-01-01 01:02:03.000000000',
-      line: 1,
-      patch_set: 1,
-      author,
-    };
-  };
-
-  const comments = {
-    file1: [
-      {
-        ...createComment(),
-        change_message_id: MESSAGE_ID_0,
-        in_reply_to: '6505d749_f0bec0aa',
-        author: {
-          email: 'some@email.com',
-          _account_id: 123,
-        },
-      },
-      {
-        ...createComment(),
-        id: '2b3c4d5e',
-        change_message_id: MESSAGE_ID_1,
-        in_reply_to: 'c5912363_6b820105',
-      },
-      {
-        ...createComment(),
-        id: '2b3c4d5e',
-        change_message_id: MESSAGE_ID_1,
-        in_reply_to: '6505d749_f0bec0aa',
-      },
-      {
-        ...createComment(),
-        id: '34ed05d749_10ed44b2',
-        change_message_id: MESSAGE_ID_2,
-      },
-    ],
-    file2: [
-      {
-        ...createComment(),
-        change_message_id: MESSAGE_ID_1,
-        in_reply_to: 'c5912363_4b7d450a',
-        id: '450a935e_4f260d25',
-      },
-    ],
-  };
-
-  suite('basic tests', () => {
-    setup(() => {
-      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
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.messagesList;
-      element.changeComments = new ChangeComments(comments);
-      element.messages = messages;
-      flush();
-    });
-
-    test('expand/collapse all', () => {
-      let allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        message._expanded = false;
-      }
-      MockInteractions.tap(allMessageEls[1]);
-      assert.isTrue(allMessageEls[1]._expanded);
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages'));
-      allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        assert.isTrue(message._expanded);
-      }
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('#collapse-messages'));
-      allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        assert.isFalse(message._expanded);
-      }
-    });
-
-    test('expand/collapse from external keypress', () => {
-      // Start with one expanded message. -> not all collapsed
-      element.scrollToMessage(messages[1].id);
-      assert.isFalse([...getMessages()].filter(m => m._expanded).length == 0);
-
-      // Press 'z' -> all collapsed
-      element.handleExpandCollapse(false);
-      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
-
-      // Press 'x' -> all expanded
-      element.handleExpandCollapse(true);
-      assert.isTrue([...getMessages()].filter(m => !m._expanded).length == 0);
-
-      // Press 'z' -> all collapsed
-      element.handleExpandCollapse(false);
-      assert.isTrue([...getMessages()].filter(m => m._expanded).length == 0);
-    });
-
-    test('showAllActivity does not appear when all msgs are important', () => {
-      assert.isOk(element.shadowRoot
-          .querySelector('#showAllActivityToggleContainer'));
-      assert.isNotOk(element.shadowRoot
-          .querySelector('.showAllActivityToggle'));
-    });
-
-    test('scroll to message', () => {
-      const allMessageEls = getMessages();
-      for (const message of allMessageEls) {
-        message.set('message.expanded', false);
-      }
-
-      const scrollToStub = sinon.stub(window, 'scrollTo');
-      const highlightStub = sinon.stub(element, '_highlightEl');
-
-      element.scrollToMessage('invalid');
-
-      for (const message of allMessageEls) {
-        assert.isFalse(message._expanded,
-            'expected gr-message to not be expanded');
-      }
-
-      const messageID = messages[1].id;
-      element.scrollToMessage(messageID);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('[data-message-id="' + messageID + '"]')
-              ._expanded);
-
-      assert.isTrue(scrollToStub.calledOnce);
-      assert.isTrue(highlightStub.calledOnce);
-    });
-
-    test('scroll to message offscreen', () => {
-      const scrollToStub = sinon.stub(window, 'scrollTo');
-      const highlightStub = sinon.stub(element, '_highlightEl');
-      element.messages = generateRandomMessages(25);
-      flush();
-      assert.isFalse(scrollToStub.called);
-      assert.isFalse(highlightStub.called);
-
-      const messageID = element.messages[1].id;
-      element.scrollToMessage(messageID);
-      assert.isTrue(scrollToStub.calledOnce);
-      assert.isTrue(highlightStub.calledOnce);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('[data-message-id="' + messageID + '"]')
-              ._expanded);
-    });
-
-    test('associating messages with comments', () => {
-      const messages = [].concat(
-          randomMessage(),
-          {
-            _index: 5,
-            _revision_number: 4,
-            message: 'Uploaded patch set 4.',
-            date: '2016-09-28 13:36:33.000000000',
-            author,
-            id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-          },
-          {
-            _index: 6,
-            _revision_number: 4,
-            message: 'Patch Set 4:\n\n(6 comments)',
-            date: '2016-09-28 13:36:33.000000000',
-            author,
-            id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
-          }
-      );
-      element.messages = messages;
-      flush();
-      const messageElements = getMessages();
-      assert.equal(messageElements.length, messages.length);
-      assert.deepEqual(messageElements[1].message, messages[1]);
-      assert.deepEqual(messageElements[2].message, messages[2]);
-    });
-
-    test('threads', () => {
-      const messages = [
-        {
-          _index: 5,
-          _revision_number: 4,
-          message: 'Uploaded patch set 4.',
-          date: '2016-09-28 13:36:33.000000000',
-          author,
-          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-        },
-      ];
-      element.messages = messages;
-      flush();
-      const messageElements = getMessages();
-      // threads
-      assert.equal(
-          messageElements[0].message.commentThreads.length,
-          3);
-      // first thread contains 1 comment
-      assert.equal(
-          messageElements[0].message.commentThreads[0].comments.length,
-          1);
-    });
-
-    test('updateTag human message', () => {
-      const m = randomMessage();
-      assert.equal(TEST_ONLY.computeTag(m), undefined);
-    });
-
-    test('updateTag nothing to change', () => {
-      const m = randomMessage();
-      const tag = 'something-normal';
-      m.tag = tag;
-      assert.equal(TEST_ONLY.computeTag(m), tag);
-    });
-
-    test('updateTag TAG_NEW_WIP_PATCHSET', () => {
-      const m = randomMessage();
-      m.tag = MessageTag.TAG_NEW_WIP_PATCHSET;
-      assert.equal(TEST_ONLY.computeTag(m), MessageTag.TAG_NEW_PATCHSET);
-    });
-
-    test('updateTag remove postfix', () => {
-      const m = randomMessage();
-      m.tag = 'something~withpostfix';
-      assert.equal(TEST_ONLY.computeTag(m), 'something');
-    });
-
-    test('updateTag with robot comments', () => {
-      const m = randomMessage();
-      m.commentThreads = [{
-        comments: [{
-          robot_id: 'id314',
-          change_message_id: m.id,
-        }],
-      }];
-      assert.notEqual(TEST_ONLY.computeTag(m), undefined);
-    });
-
-    test('setRevisionNumber nothing to change', () => {
-      const m1 = randomMessage();
-      const m2 = randomMessage();
-      assert.equal(TEST_ONLY.computeRevision(m1, [m1, m2]), 1);
-      assert.equal(TEST_ONLY.computeRevision(m2, [m1, m2]), 1);
-    });
-
-    test('setRevisionNumber reviewer updates', () => {
-      const m1 = randomMessage(
-          {
-            tag: MessageTag.TAG_REVIEWER_UPDATE,
-            date: '2020-01-01 10:00:00.000000000',
-          });
-      m1._revision_number = undefined;
-      const m2 = randomMessage(
-          {
-            date: '2020-01-02 10:00:00.000000000',
-          });
-      m2._revision_number = 1;
-      const m3 = randomMessage(
-          {
-            tag: MessageTag.TAG_REVIEWER_UPDATE,
-            date: '2020-01-03 10:00:00.000000000',
-          });
-      m3._revision_number = undefined;
-      const m4 = randomMessage(
-          {
-            date: '2020-01-04 10:00:00.000000000',
-          });
-      m4._revision_number = 2;
-      const m5 = randomMessage(
-          {
-            tag: MessageTag.TAG_REVIEWER_UPDATE,
-            date: '2020-01-05 10:00:00.000000000',
-          });
-      m5._revision_number = undefined;
-      const allMessages = [m1, m2, m3, m4, m5];
-      assert.equal(TEST_ONLY.computeRevision(m1, allMessages), undefined);
-      assert.equal(TEST_ONLY.computeRevision(m2, allMessages), 1);
-      assert.equal(TEST_ONLY.computeRevision(m3, allMessages), 1);
-      assert.equal(TEST_ONLY.computeRevision(m4, allMessages), 2);
-      assert.equal(TEST_ONLY.computeRevision(m5, allMessages), 2);
-    });
-
-    test('isImportant human message', () => {
-      const m = randomMessage();
-      assert.isTrue(TEST_ONLY.computeIsImportant(m, []));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m, [m]));
-    });
-
-    test('isImportant even with a tag', () => {
-      const m1 = randomMessage();
-      const m2 = randomMessage({tag: 'autogenerated:gerrit1'});
-      const m3 = randomMessage({tag: 'autogenerated:gerrit2'});
-      assert.isTrue(TEST_ONLY.computeIsImportant(m2, []));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
-    });
-
-    test('isImportant filters same tag and older revision', () => {
-      const m1 = randomMessage({tag: 'auto', _revision_number: 2});
-      const m2 = randomMessage({tag: 'auto', _revision_number: 1});
-      const m3 = randomMessage({tag: 'auto'});
-      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m2]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2]));
-      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m3]));
-      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m3]));
-      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
-      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
-      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
-    });
-
-    test('isImportant is evaluated after tag update', () => {
-      const m1 = randomMessage(
-          {tag: MessageTag.TAG_NEW_PATCHSET, _revision_number: 1});
-      const m2 = randomMessage(
-          {tag: MessageTag.TAG_NEW_WIP_PATCHSET, _revision_number: 2});
-      element.messages = [m1, m2];
-      flush();
-      assert.isFalse(m1.isImportant);
-      assert.isTrue(m2.isImportant);
-    });
-
-    test('messages without author do not throw', () => {
-      const messages = [{
-        _index: 5,
-        _revision_number: 4,
-        message: 'Uploaded patch set 4.',
-        date: '2016-09-28 13:36:33.000000000',
-        id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
-      }];
-      element.messages = messages;
-      flush();
-      const messageEls = getMessages();
-      assert.equal(messageEls.length, 1);
-      assert.equal(messageEls[0].message.message, messages[0].message);
-    });
-  });
-
-  suite('gr-messages-list automate tests', () => {
-    let element;
-    let messages;
-
-    let commentApiWrapper;
-
-    setup(() => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-
-      messages = [
-        randomMessage(),
-        randomMessage({tag: 'auto', _revision_number: 2}),
-        randomMessage({tag: 'auto', _revision_number: 3}),
-      ];
-
-      // Element must be wrapped in an element with direct access to the
-      // comment API.
-      commentApiWrapper = basicFixture.instantiate();
-      element = commentApiWrapper.$.messagesList;
-      element.changeComments = new ChangeComments();
-      element.messages = messages;
-      flush();
-    });
-
-    test('hide autogenerated button is not hidden', () => {
-      const toggle = element.root.querySelector('.showAllActivityToggle');
-      assert.isOk(toggle);
-    });
-
-    test('one unimportant message is hidden initially', () => {
-      const displayedMsgs = element.root.querySelectorAll('gr-message');
-      assert.equal(displayedMsgs.length, 2);
-    });
-
-    test('unimportant messages hidden after toggle', () => {
-      element._showAllActivity = true;
-      const toggle = element.root.querySelector('.showAllActivityToggle');
-      assert.isOk(toggle);
-      MockInteractions.tap(toggle);
-      flush();
-      const displayedMsgs = element.root.querySelectorAll('gr-message');
-      assert.equal(displayedMsgs.length, 2);
-    });
-
-    test('unimportant messages shown after toggle', () => {
-      element._showAllActivity = false;
-      const toggle = element.root.querySelector('.showAllActivityToggle');
-      assert.isOk(toggle);
-      MockInteractions.tap(toggle);
-      flush();
-      const displayedMsgs = element.root.querySelectorAll('gr-message');
-      assert.equal(displayedMsgs.length, 3);
-    });
-
-    test('_computeLabelExtremes', () => {
-      const computeSpy = sinon.spy(element, '_computeLabelExtremes');
-
-      element.labels = null;
-      assert.isTrue(computeSpy.calledOnce);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {};
-      assert.isTrue(computeSpy.calledTwice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {}};
-      assert.isTrue(computeSpy.calledThrice);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {values: {}}};
-      assert.equal(computeSpy.callCount, 4);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {});
-
-      element.labels = {'my-label': {values: {'-12': {}}}};
-      assert.equal(computeSpy.callCount, 5);
-      assert.deepEqual(computeSpy.lastCall.returnValue,
-          {'my-label': {min: -12, max: -12}});
-
-      element.labels = {
-        'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
-      };
-      assert.equal(computeSpy.callCount, 6);
-      assert.deepEqual(computeSpy.lastCall.returnValue,
-          {'my-label': {min: -2, max: 2}});
-
-      element.labels = {
-        'my-label': {values: {'-12': {}}},
-        'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
-      };
-      assert.equal(computeSpy.callCount, 7);
-      assert.deepEqual(computeSpy.lastCall.returnValue, {
-        'my-label': {min: -12, max: -12},
-        'other-label': {min: -1, max: 1},
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
new file mode 100644
index 0000000..84f3bf9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -0,0 +1,644 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-messages-list';
+import {CombinedMessage, GrMessagesList, TEST_ONLY} from './gr-messages-list';
+import {MessageTag} from '../../../constants/constants';
+import {
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrMessage} from '../gr-message/gr-message';
+import {
+  AccountId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  EmailAddress,
+  LabelNameToInfoMap,
+  NumericChangeId,
+  PatchSetNum,
+  ReviewInputTag,
+  RevisionPatchSetNum,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {assertIsDefined} from '../../../utils/common-util';
+import {html} from 'lit';
+import {fixture, assert} from '@open-wc/testing';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+
+const author = {
+  _account_id: 42 as AccountId,
+  name: 'Marvin the Paranoid Android',
+  email: 'marvin@sirius.org' as EmailAddress,
+};
+
+const createComment = function () {
+  return {
+    id: '1a2b3c4d' as UrlEncodedCommentId,
+    message: 'some random test text',
+    change_message_id: '8a7b6c5d',
+    updated: '2016-01-01 01:02:03.000000000' as Timestamp,
+    line: 1,
+    patch_set: 1 as RevisionPatchSetNum,
+    author,
+  };
+};
+
+const randomMessage = function (opt_params?: ChangeMessageInfo) {
+  const params = opt_params || ({} as ChangeMessageInfo);
+  const author1 = {
+    _account_id: 1115495 as AccountId,
+    name: 'Andrew Bonventre',
+    email: 'andybons@chromium.org' as EmailAddress,
+  };
+  return {
+    id: (params.id || Math.random().toString()) as ChangeMessageId,
+    date: (params.date || '2016-01-12 20:28:33.038000') as Timestamp,
+    message: params.message || Math.random().toString(),
+    _revision_number: (params._revision_number || 1) as PatchSetNum,
+    author: params.author || author1,
+    tag: params.tag,
+  };
+};
+
+function generateRandomMessages(count: number) {
+  return new Array(count)
+    .fill(undefined)
+    .map(() => randomMessage()) as ChangeMessageInfo[];
+}
+
+suite('gr-messages-list tests', () => {
+  let element: GrMessagesList;
+  let messages: ChangeMessageInfo[];
+
+  const getMessages = function () {
+    return queryAll<GrMessage>(element, 'gr-message');
+  };
+
+  const MESSAGE_ID_0 = '1234ccc949c6d482b061be6a28e10782abf0e7af';
+  const MESSAGE_ID_1 = '8c19ccc949c6d482b061be6a28e10782abf0e7af';
+  const MESSAGE_ID_2 = 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5';
+
+  const comments = {
+    file1: [
+      {
+        ...createComment(),
+        change_message_id: MESSAGE_ID_0,
+        in_reply_to: '6505d749_f0bec0aa' as UrlEncodedCommentId,
+        author: {
+          email: 'some@email.com' as EmailAddress,
+          _account_id: 123 as AccountId,
+        },
+      },
+      {
+        ...createComment(),
+        id: '2b3c4d5e' as UrlEncodedCommentId,
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: 'c5912363_6b820105' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        id: '2b3c4d5e' as UrlEncodedCommentId,
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: '6505d749_f0bec0aa' as UrlEncodedCommentId,
+      },
+      {
+        ...createComment(),
+        id: '34ed05d749_10ed44b2' as UrlEncodedCommentId,
+        change_message_id: MESSAGE_ID_2,
+      },
+    ],
+    file2: [
+      {
+        ...createComment(),
+        change_message_id: MESSAGE_ID_1,
+        in_reply_to: 'c5912363_4b7d450a' as UrlEncodedCommentId,
+        id: '450a935e_4f260d25' as UrlEncodedCommentId,
+      },
+    ],
+  };
+
+  suite('basic tests', () => {
+    setup(async () => {
+      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 = await fixture<GrMessagesList>(
+        html`<gr-messages-list></gr-messages-list>`
+      );
+      await testResolver(commentsModelToken).reloadComments(
+        0 as NumericChangeId
+      );
+      element.messages = messages;
+      await element.updateComplete;
+    });
+
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="header">
+            <div class="container" id="showAllActivityToggleContainer"></div>
+            <gr-button
+              aria-disabled="false"
+              id="collapse-messages"
+              link=""
+              role="button"
+              tabindex="0"
+              title="Expand all messages (shortcut: x)"
+            >
+              Expand All
+            </gr-button>
+          </div>
+          <gr-message data-message-id="${messages[0].id}"> </gr-message>
+          <gr-message data-message-id="${messages[1].id}"> </gr-message>
+          <gr-message data-message-id="${messages[2].id}"> </gr-message>
+        `
+      );
+    });
+
+    test('expand/collapse all', async () => {
+      let allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assertIsDefined(message.message);
+        message.message = {...message.message, expanded: false};
+        await message.updateComplete;
+      }
+      allMessageEls[1].click();
+      await element.updateComplete;
+      assert.isTrue(allMessageEls[1].message?.expanded);
+
+      queryAndAssert<GrButton>(element, '#collapse-messages').click();
+      await element.updateComplete;
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isTrue(message.message?.expanded);
+      }
+
+      queryAndAssert<GrButton>(element, '#collapse-messages').click();
+      await element.updateComplete;
+      allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isFalse(message.message?.expanded);
+      }
+    });
+
+    test('expand/collapse from external keypress', () => {
+      // Start with one expanded message. -> not all collapsed
+      element.scrollToMessage(messages[1].id);
+      assert.isFalse(
+        [...getMessages()].filter(m => m.message?.expanded).length === 0
+      );
+
+      // Press 'z' -> all collapsed
+      element.handleExpandCollapse(false);
+      assert.isTrue(
+        [...getMessages()].filter(m => m.message?.expanded).length === 0
+      );
+
+      // Press 'x' -> all expanded
+      element.handleExpandCollapse(true);
+      assert.isTrue(
+        [...getMessages()].filter(m => !m.message?.expanded).length === 0
+      );
+
+      // Press 'z' -> all collapsed
+      element.handleExpandCollapse(false);
+      assert.isTrue(
+        [...getMessages()].filter(m => m.message?.expanded).length === 0
+      );
+    });
+
+    test('showAllActivity does not appear when all msgs are important', () => {
+      assert.isOk(query(element, '#showAllActivityToggleContainer'));
+      assert.isNotOk(query(element, '.showAllActivityToggle'));
+    });
+
+    test('scroll to message', async () => {
+      const allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assertIsDefined(message.message);
+        message.message = {...message.message, expanded: false};
+      }
+
+      const highlightStub = sinon.stub(element, 'highlightEl');
+
+      await element.scrollToMessage('invalid');
+
+      for (const message of allMessageEls) {
+        assertIsDefined(message.message);
+        assert.isFalse(
+          message.message.expanded,
+          'expected gr-message to not be expanded'
+        );
+      }
+
+      const messageID = messages[1].id;
+
+      const selector = `[data-message-id="${messageID}"]`;
+      const el = queryAndAssert<GrMessage>(element, selector);
+      const scrollToStub = sinon.stub(el, 'scrollIntoView');
+
+      await element.scrollToMessage(messageID);
+      assert.isTrue(
+        queryAndAssert<GrMessage>(element, `[data-message-id="${messageID}"]`)
+          .message?.expanded
+      );
+
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+    });
+
+    test('scroll to message offscreen', async () => {
+      const highlightStub = sinon.stub(element, 'highlightEl');
+      element.messages = generateRandomMessages(25);
+      await element.updateComplete;
+      assert.isFalse(highlightStub.called);
+
+      const messageID = element.messages[1].id;
+      const selector = `[data-message-id="${messageID}"]`;
+      const el = queryAndAssert<GrMessage>(element, selector);
+      const scrollToStub = sinon.stub(el, 'scrollIntoView');
+
+      assert.isFalse(scrollToStub.called);
+
+      await element.scrollToMessage(messageID);
+      assert.isTrue(scrollToStub.calledOnce);
+      assert.isTrue(highlightStub.calledOnce);
+      assert.isTrue(
+        queryAndAssert<GrMessage>(element, `[data-message-id="${messageID}"]`)
+          .message?.expanded
+      );
+    });
+
+    test('associating messages with comments', async () => {
+      // Have to type as any otherwise fails with
+      // Argument of type 'ChangeMessageInfo[]' is not assignable to
+      // parameter of type 'ConcatArray<never>'.
+      const messages = ([] as any).concat(
+        randomMessage(),
+        {
+          _index: 5,
+          _revision_number: 4 as PatchSetNum,
+          message: 'Uploaded patch set 4.',
+          date: '2016-09-28 13:36:33.000000000' as Timestamp,
+          author,
+          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af' as ChangeMessageId,
+        } as CombinedMessage,
+        {
+          _index: 6,
+          _revision_number: 4 as PatchSetNum,
+          message: 'Patch Set 4:\n\n(6 comments)',
+          date: '2016-09-28 13:36:33.000000000' as Timestamp,
+          author,
+          id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5' as ChangeMessageId,
+        } as CombinedMessage
+      );
+      element.messages = messages;
+      await element.updateComplete;
+      const messageElements = getMessages();
+      assert.equal(messageElements.length, messages.length);
+      assert.deepEqual(messageElements[1].message, messages[1]);
+      assert.deepEqual(messageElements[2].message, messages[2]);
+    });
+
+    test('threads', async () => {
+      const messages = [
+        {
+          _index: 5,
+          _revision_number: 4 as PatchSetNum,
+          message: 'Uploaded patch set 4.',
+          date: '2016-09-28 13:36:33.000000000' as Timestamp,
+          author,
+          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af' as ChangeMessageId,
+        },
+      ];
+      element.messages = messages;
+      await element.updateComplete;
+      const messageElements = getMessages();
+      // threads
+      assert.equal(messageElements[0].message!.commentThreads.length, 3);
+      // first thread contains 1 comment
+      assert.equal(
+        messageElements[0].message!.commentThreads[0].comments.length,
+        1
+      );
+    });
+
+    test('updateTag human message', () => {
+      const m = randomMessage();
+      assert.equal(TEST_ONLY.computeTag(m), undefined);
+    });
+
+    test('updateTag nothing to change', () => {
+      const m = randomMessage();
+      const tag = 'something-normal' as ReviewInputTag;
+      m.tag = tag;
+      assert.equal(TEST_ONLY.computeTag(m), tag);
+    });
+
+    test('updateTag TAG_NEW_WIP_PATCHSET', () => {
+      const m = randomMessage();
+      m.tag = MessageTag.TAG_NEW_WIP_PATCHSET as ReviewInputTag;
+      assert.equal(TEST_ONLY.computeTag(m), MessageTag.TAG_NEW_PATCHSET);
+    });
+
+    test('updateTag for outdated votes', () => {
+      const m = randomMessage();
+      m.tag = MessageTag.TAG_NEW_PATCHSET as ReviewInputTag;
+      m.message = '\nUploaded patch set 35.\n\nOutdated Votes:\n';
+      assert.equal(
+        TEST_ONLY.computeTag(m),
+        MessageTag.TAG_NEW_PATCHSET_OUTDATED_VOTES
+      );
+
+      m.tag = MessageTag.TAG_NEW_WIP_PATCHSET as ReviewInputTag;
+      assert.equal(TEST_ONLY.computeTag(m), MessageTag.TAG_NEW_PATCHSET);
+    });
+
+    test('updateTag remove postfix', () => {
+      const m = randomMessage();
+      m.tag = 'something~withpostfix' as ReviewInputTag;
+      assert.equal(TEST_ONLY.computeTag(m), 'something');
+    });
+
+    test('updateTag with robot comments', () => {
+      const m = randomMessage();
+      (m as any).commentThreads = [
+        {
+          comments: [
+            {
+              robot_id: 'id314',
+              change_message_id: m.id,
+            },
+          ],
+        },
+      ];
+      assert.notEqual(TEST_ONLY.computeTag(m), undefined);
+    });
+
+    test('setRevisionNumber nothing to change', () => {
+      const m1 = randomMessage();
+      const m2 = randomMessage();
+      assert.equal(TEST_ONLY.computeRevision(m1, [m1, m2]), 1 as PatchSetNum);
+      assert.equal(TEST_ONLY.computeRevision(m2, [m1, m2]), 1 as PatchSetNum);
+    });
+
+    test('setRevisionNumber reviewer updates', () => {
+      const m1 = randomMessage({
+        ...randomMessage(),
+        tag: MessageTag.TAG_REVIEWER_UPDATE as ReviewInputTag,
+        date: '2020-01-01 10:00:00.000000000' as Timestamp,
+      });
+      m1._revision_number = 0 as PatchSetNum;
+      const m2 = randomMessage({
+        ...randomMessage(),
+        date: '2020-01-02 10:00:00.000000000' as Timestamp,
+      });
+      m2._revision_number = 1 as PatchSetNum;
+      const m3 = randomMessage({
+        ...randomMessage(),
+        tag: MessageTag.TAG_REVIEWER_UPDATE as ReviewInputTag,
+        date: '2020-01-03 10:00:00.000000000' as Timestamp,
+      });
+      m3._revision_number = 0 as PatchSetNum;
+      const m4 = randomMessage({
+        ...randomMessage(),
+        date: '2020-01-04 10:00:00.000000000' as Timestamp,
+      });
+      m4._revision_number = 2 as PatchSetNum;
+      const m5 = randomMessage({
+        ...randomMessage(),
+        tag: MessageTag.TAG_REVIEWER_UPDATE as ReviewInputTag,
+        date: '2020-01-05 10:00:00.000000000' as Timestamp,
+      });
+      m5._revision_number = 0 as PatchSetNum;
+      const allMessages = [m1, m2, m3, m4, m5];
+      assert.equal(TEST_ONLY.computeRevision(m1, allMessages), undefined);
+      assert.equal(
+        TEST_ONLY.computeRevision(m2, allMessages),
+        1 as PatchSetNum
+      );
+      assert.equal(
+        TEST_ONLY.computeRevision(m3, allMessages),
+        1 as PatchSetNum
+      );
+      assert.equal(
+        TEST_ONLY.computeRevision(m4, allMessages),
+        2 as PatchSetNum
+      );
+      assert.equal(
+        TEST_ONLY.computeRevision(m5, allMessages),
+        2 as PatchSetNum
+      );
+    });
+
+    test('isImportant human message', () => {
+      const m = randomMessage();
+      assert.isTrue(TEST_ONLY.computeIsImportant(m, []));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m, [m]));
+    });
+
+    test('isImportant even with a tag', () => {
+      const m1 = randomMessage();
+      const m2 = randomMessage({
+        ...randomMessage(),
+        tag: 'autogenerated:gerrit1' as ReviewInputTag,
+      });
+      const m3 = randomMessage({
+        ...randomMessage(),
+        tag: 'autogenerated:gerrit2' as ReviewInputTag,
+      });
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, []));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
+    });
+
+    test('isImportant filters same tag and older revision', () => {
+      const m1 = randomMessage({
+        ...randomMessage(),
+        tag: 'auto' as ReviewInputTag,
+        _revision_number: 2 as PatchSetNum,
+      });
+      const m2 = randomMessage({
+        ...randomMessage(),
+        tag: 'auto' as ReviewInputTag,
+        _revision_number: 1 as PatchSetNum,
+      });
+      const m3 = randomMessage({
+        ...randomMessage(),
+        tag: 'auto' as ReviewInputTag,
+      });
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m2, [m2]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m3]));
+      assert.isTrue(TEST_ONLY.computeIsImportant(m1, [m1, m2, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m2, [m1, m2, m3]));
+      assert.isFalse(TEST_ONLY.computeIsImportant(m3, [m1, m2, m3]));
+    });
+
+    test('isImportant is evaluated after tag update', async () => {
+      const m1 = randomMessage({
+        ...randomMessage(),
+        tag: MessageTag.TAG_NEW_PATCHSET as ReviewInputTag,
+        _revision_number: 1 as PatchSetNum,
+      });
+      const m2 = randomMessage({
+        ...randomMessage(),
+        tag: MessageTag.TAG_NEW_WIP_PATCHSET as ReviewInputTag,
+        _revision_number: 2 as PatchSetNum,
+      });
+      element.messages = [m1, m2];
+      await element.updateComplete;
+      assert.isFalse((m1 as CombinedMessage).isImportant);
+      assert.isTrue((m2 as CombinedMessage).isImportant);
+    });
+
+    test('messages without author do not throw', async () => {
+      const messages = [
+        {
+          _index: 5,
+          _revision_number: 4 as PatchSetNum,
+          message: 'Uploaded patch set 4.',
+          date: '2016-09-28 13:36:33.000000000' as Timestamp,
+          id: '8c19ccc949c6d482b061be6a28e10782abf0e7af' as ChangeMessageId,
+        },
+      ];
+      element.messages = messages;
+      await element.updateComplete;
+      const messageEls = getMessages();
+      assert.equal(messageEls.length, 1);
+      assert.equal(messageEls[0].message!.message, messages[0].message);
+    });
+  });
+
+  suite('gr-messages-list automate tests', () => {
+    let element: GrMessagesList;
+    let messages: ChangeMessageInfo[];
+
+    setup(async () => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+
+      messages = [
+        randomMessage(),
+        randomMessage({
+          ...randomMessage(),
+          tag: MessageTag.TAG_NEW_PATCHSET as ReviewInputTag,
+          message:
+            '\nUploaded patch set 35.\n\nInitial upload\n\nOutdated Votes:\n',
+        }),
+        randomMessage({
+          ...randomMessage(),
+          tag: 'auto' as ReviewInputTag,
+          _revision_number: 2 as PatchSetNum,
+        }),
+        randomMessage({
+          ...randomMessage(),
+          tag: 'auto' as ReviewInputTag,
+          _revision_number: 3 as PatchSetNum,
+        }),
+      ];
+
+      element = await fixture<GrMessagesList>(
+        html`<gr-messages-list></gr-messages-list>`
+      );
+      element.messages = messages;
+      await element.updateComplete;
+    });
+
+    test('hide autogenerated button is not hidden', () => {
+      const toggle = queryAndAssert(element, '.showAllActivityToggle');
+      assert.isOk(toggle);
+    });
+
+    test('one unimportant message is hidden initially', () => {
+      const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
+      assert.equal(displayedMsgs.length, 3);
+    });
+
+    test('unimportant messages hidden after toggle', async () => {
+      element.showAllActivity = true;
+      await element.updateComplete;
+      const toggle = queryAndAssert<PaperToggleButtonElement>(
+        element,
+        '.showAllActivityToggle'
+      );
+      assert.isOk(toggle);
+      toggle.click();
+      await element.updateComplete;
+      const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
+      assert.equal(displayedMsgs.length, 3);
+    });
+
+    test('unimportant messages shown after toggle', async () => {
+      element.showAllActivity = false;
+      await element.updateComplete;
+      const toggle = queryAndAssert<PaperToggleButtonElement>(
+        element,
+        '.showAllActivityToggle'
+      );
+      assert.isOk(toggle);
+      toggle.click();
+      await element.updateComplete;
+      const displayedMsgs = queryAll<GrMessage>(element, 'gr-message');
+      assert.equal(displayedMsgs.length, 4);
+    });
+
+    test('_computeLabelExtremes', () => {
+      // Have to type as any to be able to use null.
+      element.labels = null as any;
+      assert.deepEqual(element.computeLabelExtremes(), {});
+
+      element.labels = {};
+      assert.deepEqual(element.computeLabelExtremes(), {});
+
+      element.labels = {'my-label': {}};
+      assert.deepEqual(element.computeLabelExtremes(), {});
+
+      element.labels = {'my-label': {values: {}}};
+      assert.deepEqual(element.computeLabelExtremes(), {});
+
+      element.labels = {
+        'my-label': {values: {'-12': {}}},
+      } as LabelNameToInfoMap;
+      assert.deepEqual(element.computeLabelExtremes(), {
+        'my-label': {min: -12, max: -12},
+      });
+
+      element.labels = {
+        'my-label': {values: {'-2': {}, '-1': {}, '0': {}, '+1': {}, '+2': {}}},
+      } as LabelNameToInfoMap;
+      assert.deepEqual(element.computeLabelExtremes(), {
+        'my-label': {min: -2, max: 2},
+      });
+
+      element.labels = {
+        'my-label': {values: {'-12': {}}},
+        'other-label': {values: {'-1': {}, ' 0': {}, '+1': {}}},
+      } as LabelNameToInfoMap;
+      assert.deepEqual(element.computeLabelExtremes(), {
+        'my-label': {min: -12, max: -12},
+        'other-label': {min: -1, max: 1},
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index 744db3b..90b05f6 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   ChangeInfo,
@@ -24,11 +13,11 @@
 } from '../../../types/common';
 import {ChangeStatus} from '../../../constants/constants';
 import {isChangeInfo} from '../../../utils/change-util';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
 
 @customElement('gr-related-change')
 export class GrRelatedChange extends LitElement {
-  @property()
+  @property({type: Object})
   change?: ChangeInfo | RelatedChangeAndCommitInfo;
 
   @property()
@@ -37,17 +26,17 @@
   @property()
   label?: string;
 
-  @property()
+  @property({type: Boolean, attribute: 'show-submittable-check'})
   showSubmittableCheck = false;
 
-  @property()
+  @property({type: Boolean, attribute: 'show-change-status'})
   showChangeStatus = false;
 
   /*
    * Needed for calculation if change is direct or indirect ancestor/descendant
    * to current change.
    */
-  @property()
+  @property({type: Array})
   connectedRevisions?: CommitId[];
 
   static override get styles() {
@@ -80,8 +69,8 @@
         .notCurrent {
           color: var(--warning-foreground);
         }
-        .indirectAncestor {
-          color: var(--indirect-ancestor-text-color);
+        .indirectRelation {
+          color: var(--indirect-relation-text-color);
         }
         .submittableCheck {
           padding-left: var(--spacing-s);
@@ -110,13 +99,13 @@
   override render() {
     const change = this.change;
     if (!change) throw new Error('Missing change');
-    const linkClass = this._computeLinkClass(change);
+    const linkClass = this.computeLinkClass(change);
     return html`
       <div class="changeContainer">
         <a
-          href="${ifDefined(this.href)}"
-          aria-label="${ifDefined(this.label)}"
-          class="${linkClass}"
+          href=${ifDefined(this.href)}
+          aria-label=${ifDefined(this.label)}
+          class=${linkClass}
           ><slot></slot
         ></a>
         ${this.showSubmittableCheck
@@ -129,16 +118,16 @@
               >✓</span
             >`
           : ''}
-        ${this.showChangeStatus && !isChangeInfo(change)
-          ? html`<span class="${this._computeChangeStatusClass(change)}">
-              (${this._computeChangeStatus(change)})
+        ${this.showChangeStatus
+          ? html`<span class=${this.computeChangeStatusClass(change)}>
+              (${this.computeChangeStatus(change)})
             </span>`
           : ''}
       </div>
     `;
   }
 
-  _computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
+  private computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
     const statuses = [];
     if (change.status === ChangeStatus.ABANDONED) {
       statuses.push('strikethrough');
@@ -149,12 +138,17 @@
     return statuses.join(' ');
   }
 
-  _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
+  private computeChangeStatusClass(
+    change: RelatedChangeAndCommitInfo | ChangeInfo
+  ) {
     const classes = ['status'];
-    if (change._revision_number !== change._current_revision_number) {
+    if (
+      !isChangeInfo(change) &&
+      change._revision_number !== change._current_revision_number
+    ) {
       classes.push('notCurrent');
-    } else if (this._isIndirectAncestor(change)) {
-      classes.push('indirectAncestor');
+    } else if (!isChangeInfo(change) && this.isIndirectRelation(change)) {
+      classes.push('indirectRelation');
     } else if (change.submittable) {
       classes.push('submittable');
     } else if (change.status === ChangeStatus.NEW) {
@@ -163,24 +157,27 @@
     return classes.join(' ');
   }
 
-  _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
+  private computeChangeStatus(change: RelatedChangeAndCommitInfo | ChangeInfo) {
     switch (change.status) {
       case ChangeStatus.MERGED:
         return 'Merged';
       case ChangeStatus.ABANDONED:
         return 'Abandoned';
     }
-    if (change._revision_number !== change._current_revision_number) {
+    if (
+      !isChangeInfo(change) &&
+      change._revision_number !== change._current_revision_number
+    ) {
       return 'Not current';
-    } else if (this._isIndirectAncestor(change)) {
-      return 'Indirect ancestor';
+    } else if (!isChangeInfo(change) && this.isIndirectRelation(change)) {
+      return 'Indirect relation';
     } else if (change.submittable) {
       return 'Submittable';
     }
     return '';
   }
 
-  _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
+  private isIndirectRelation(change: RelatedChangeAndCommitInfo) {
     return (
       this.connectedRevisions &&
       !this.connectedRevisions.includes(change.commit.commit)
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 963c009..cac9ae5 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,50 +1,38 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import './gr-related-change';
+import './gr-related-collapse';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
-import {classMap} from 'lit/directives/class-map';
-import {LitElement, css, html, nothing, TemplateResult} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import '../../shared/gr-icon/gr-icon';
+import {classMap} from 'lit/directives/class-map.js';
+import {LitElement, css, html, TemplateResult} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
-  SubmittedTogetherInfo,
   ChangeInfo,
+  CommitId,
+  PatchSetNum,
   RelatedChangeAndCommitInfo,
   RelatedChangesInfo,
-  PatchSetNum,
-  CommitId,
+  RevisionPatchSetNum,
+  SubmittedTogetherInfo,
 } from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {truncatePath} from '../../../utils/path-list-util';
 import {pluralize} from '../../../utils/string-util';
 import {
   changeIsOpen,
+  getChangeNumber,
   getRevisionKey,
-  isChangeInfo,
 } from '../../../utils/change-util';
-import {Interaction} from '../../../constants/reporting';
-import {fontStyles} from '../../../styles/gr-font-styles';
-
-/** What is the maximum number of shown changes in collapsed list? */
-const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
+import {DEFALT_NUM_CHANGES_WHEN_COLLAPSED} from './gr-related-collapse';
+import {createChangeUrl} from '../../../models/views/change';
 
 export interface ChangeMarkersInList {
   showCurrentChangeArrow: boolean;
@@ -63,13 +51,13 @@
 
 @customElement('gr-related-changes-list')
 export class GrRelatedChangesList extends LitElement {
-  @property()
+  @property({type: Object})
   change?: ParsedChangeInfo;
 
   @property({type: String})
   patchNum?: PatchSetNum;
 
-  @property()
+  @property({type: Boolean})
   mergeable?: boolean;
 
   @state()
@@ -90,7 +78,7 @@
   @state()
   sameTopicChanges: ChangeInfo[] = [];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -120,6 +108,20 @@
           height: 1px;
           min-width: 20px;
         }
+        .repo {
+          margin-left: var(--spacing-m);
+        }
+        .repo,
+        .branch {
+          color: var(--primary-text-color);
+        }
+        @media screen and (max-width: 1400px) {
+          .repo,
+          .branch {
+            display: none;
+          }
+        }
+
         gr-related-collapse[collapsed] .marker.arrow {
           visibility: visible;
           min-width: auto;
@@ -202,32 +204,34 @@
     return html`<section id="relatedChanges">
       <gr-related-collapse
         title="Relation chain"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.relatedChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
       >
         ${this.relatedChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   relatedChangesMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 relatedChangesMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .connectedRevisions="${connectedRevisions}"
-                .href="${change?._change_number
-                  ? GerritNav.getUrlForChangeById(
-                      change._change_number,
-                      change.project,
-                      change._revision_number as PatchSetNum
-                    )
-                  : ''}"
-                .showChangeStatus=${true}
+                .change=${change}
+                .connectedRevisions=${connectedRevisions}
+                .href=${change?._change_number
+                  ? createChangeUrl({
+                      changeNum: change._change_number,
+                      repo: change.project,
+                      usp: 'related-change',
+                      patchNum: change._revision_number as RevisionPatchSetNum,
+                    })
+                  : ''}
+                show-change-status
+                show-submittable-check
                 >${change.commit.subject}</gr-related-change
               >
             </div>`
@@ -259,31 +263,22 @@
     return html`<section id="submittedTogether">
       <gr-related-collapse
         title="Submitted together"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${submittedTogetherChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
       >
         ${submittedTogetherChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   submittedTogetherMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
-              )}<gr-related-change
-                .label="${this.renderChangeTitle(change)}"
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}"
-                .showSubmittableCheck=${true}
-                >${this.renderChangeLine(change)}</gr-related-change
-              >
+              )}${this.renderSubmittedTogetherLine(change, true)}
             </div>`
         )}
       </gr-related-collapse>
@@ -293,6 +288,24 @@
     </section>`;
   }
 
+  private renderSubmittedTogetherLine(
+    change: ChangeInfo,
+    showSubmittabilityCheck: boolean
+  ) {
+    const truncatedRepo = truncatePath(change.project, 2);
+    return html`
+      <gr-related-change
+        .label=${this.renderChangeTitle(change)}
+        .change=${change}
+        .href=${createChangeUrl({change, usp: 'submitted-together'})}
+        ?show-submittable-check=${showSubmittabilityCheck}
+        >${change.subject}</gr-related-change
+      >
+      <span class="repo" .title=${change.project}>${truncatedRepo}</span
+      ><span class="branch">&nbsp;|&nbsp;${change.branch}&nbsp;</span>
+    `;
+  }
+
   private renderSameTopic(
     isFirst: boolean,
     sectionSize: (section: Section) => number
@@ -309,30 +322,22 @@
     return html`<section id="sameTopic">
       <gr-related-collapse
         title="Same topic"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.sameTopicChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
       >
         ${this.sameTopicChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   sameTopicMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 sameTopicMarkersPredicate(index)
-              )}<gr-related-change
-                .change="${change}"
-                .label="${this.renderChangeTitle(change)}"
-                .href="${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}"
-                >${this.renderChangeLine(change)}</gr-related-change
-              >
+              )}${this.renderSubmittedTogetherLine(change, false)}
             </div>`
         )}
       </gr-related-collapse>
@@ -354,27 +359,24 @@
     return html`<section id="mergeConflicts">
       <gr-related-collapse
         title="Merge conflicts"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.conflictingChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
       >
         ${this.conflictingChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   mergeConflictsMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 mergeConflictsMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}"
+                .change=${change}
+                .href=${createChangeUrl({change, usp: 'merge-conflict'})}
                 >${change.subject}</gr-related-change
               >
             </div>`
@@ -398,27 +400,25 @@
     return html`<section id="cherryPicks">
       <gr-related-collapse
         title="Cherry picks"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.cherryPickChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
       >
         ${this.cherryPickChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   cherryPicksMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 cherryPicksMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
-                  change._number,
-                  change.project
-                )}"
+                .change=${change}
+                .href=${createChangeUrl({change, usp: 'cherry-pick'})}
+                show-change-status
                 >${change.branch}: ${change.subject}</gr-related-change
               >
             </div>`
@@ -431,13 +431,6 @@
     return `${change.project}: ${change.branch}: ${change.subject}`;
   }
 
-  private renderChangeLine(change: ChangeInfo) {
-    const truncatedRepo = truncatePath(change.project, 2);
-    return html`<span class="truncatedRepo" .title="${change.project}"
-        >${truncatedRepo}</span
-      >: ${change.branch}: ${change.subject}`;
-  }
-
   sectionSizeFactory(
     relatedChangesLen: number,
     submittedTogetherLen: number,
@@ -560,7 +553,7 @@
         role="img"
         class="marker arrow"
         aria-label="Arrow marking change has collapsed ancestors"
-        ><iron-icon icon="gr-icons:arrowDropUp"></iron-icon
+        ><gr-icon icon="arrow_drop_up"></gr-icon
       ></span> `;
     }
     if (changeMarkers.showBottomArrow) {
@@ -568,7 +561,7 @@
         role="img"
         class="marker arrow"
         aria-label="Arrow marking change has collapsed descendants"
-        ><iron-icon icon="gr-icons:arrowDropDown"></iron-icon
+        ><gr-icon icon="arrow_drop_down"></gr-icon
       ></span> `;
     }
     return html`<span class="marker space"></span>`;
@@ -620,7 +613,10 @@
         this.restApiService.getConfig().then(config => {
           if (config && !config.change.submit_whole_topic) {
             return this.restApiService
-              .getChangesWithSameTopic(changeTopic, change._number)
+              .getChangesWithSameTopic(changeTopic, {
+                openChangesOnly: true,
+                changeToExclude: change._number,
+              })
               .then(response => {
                 if (changeTopic === this.change?.topic) {
                   this.sameTopicChanges = response ?? [];
@@ -644,29 +640,12 @@
     a?: ChangeInfo | RelatedChangeAndCommitInfo,
     b?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
   ) {
-    const aNum = this._getChangeNumber(a);
-    const bNum = this._getChangeNumber(b);
+    if (!a || !b) return false;
+    const aNum = getChangeNumber(a);
+    const bNum = getChangeNumber(b);
     return aNum === bNum;
   }
 
-  /**
-   * Get the change number from either a ChangeInfo (such as those included in
-   * SubmittedTogetherInfo responses) or get the change number from a
-   * RelatedChangeAndCommitInfo (such as those included in a
-   * RelatedChangesInfo response).
-   */
-  _getChangeNumber(
-    change?: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
-  ) {
-    // Default to 0 if change property is not defined.
-    if (!change) return 0;
-
-    if (isChangeInfo(change)) {
-      return change._number;
-    }
-    return change._change_number;
-  }
-
   /*
    * A list of commit ids connected to change to understand if other change
    * is direct or indirect ancestor / descendant.
@@ -708,92 +687,8 @@
   }
 }
 
-@customElement('gr-related-collapse')
-export class GrRelatedCollapse extends LitElement {
-  @property()
-  override title = '';
-
-  @property({type: Boolean})
-  showAll = false;
-
-  @property({type: Boolean, reflect: true})
-  collapsed = true;
-
-  @property()
-  length = 0;
-
-  @property()
-  numChangesWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
-
-  private readonly reporting = appContext.reportingService;
-
-  static override get styles() {
-    return [
-      sharedStyles,
-      fontStyles,
-      css`
-        .title {
-          color: var(--deemphasized-text-color);
-          display: flex;
-          align-self: flex-end;
-          margin-left: 20px;
-        }
-        gr-button {
-          display: flex;
-        }
-        gr-button iron-icon {
-          color: inherit;
-          --iron-icon-height: 18px;
-          --iron-icon-width: 18px;
-        }
-        .container {
-          justify-content: space-between;
-          display: flex;
-          margin-bottom: var(--spacing-s);
-        }
-        :host(.first) .container {
-          margin-bottom: var(--spacing-m);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    const title = html`<h3 class="title heading-3">${this.title}</h3>`;
-
-    const collapsible = this.length > this.numChangesWhenCollapsed;
-    this.collapsed = !this.showAll && collapsible;
-
-    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>
-      <div><slot></slot></div>`;
-  }
-
-  private toggle(e: MouseEvent) {
-    e.stopPropagation();
-    this.showAll = !this.showAll;
-    this.reporting.reportInteraction(Interaction.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_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index d0b56fb..3e90145 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -1,25 +1,15 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
+import {fixture, html, assert} from '@open-wc/testing';
 import {SinonStubbedMember} from 'sinon';
 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 '../../../test/common-test-setup';
+import {testResolver} from '../../../test/common-test-setup';
 import {
   createChange,
   createCommitInfoWithRequiredCommit,
@@ -32,8 +22,8 @@
 import {
   query,
   queryAndAssert,
-  resetPlugins,
   stubRestApi,
+  waitEventLoop,
 } from '../../../test/test-utils';
 import {
   ChangeId,
@@ -46,26 +36,24 @@
   SubmittedTogetherInfo,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
+import {getChangeNumber} from '../../../utils/change-util';
 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 {pluginLoaderToken} 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');
+import {GrRelatedCollapse} from './gr-related-collapse';
 
 suite('gr-related-changes-list', () => {
   let element: GrRelatedChangesList;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-related-changes-list></gr-related-changes-list>`
+    );
   });
 
   suite('show when collapsed', () => {
@@ -207,6 +195,72 @@
       element.patchNum = 1 as PatchSetNum;
     });
 
+    test('render', async () => {
+      stubRestApi('getRelatedChanges').returns(
+        Promise.resolve(relatedChangeInfo)
+      );
+      stubRestApi('getChangesSubmittedTogether').returns(
+        Promise.resolve(submittedTogether)
+      );
+      stubRestApi('getChangeCherryPicks').returns(
+        Promise.resolve([createChange()])
+      );
+      await element.reload();
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="related-changes-section">
+            <gr-endpoint-param name="change"> </gr-endpoint-param>
+            <gr-endpoint-slot name="top"> </gr-endpoint-slot>
+            <section id="relatedChanges">
+              <gr-related-collapse class="first" title="Relation chain">
+                <div class="relatedChangeLine show-when-collapsed">
+                  <span class="marker space"> </span>
+                  <gr-related-change
+                    show-change-status=""
+                    show-submittable-check=""
+                  >
+                    Test commit subject
+                  </gr-related-change>
+                </div>
+              </gr-related-collapse>
+            </section>
+            <section id="submittedTogether">
+              <gr-related-collapse title="Submitted together">
+                <div class="relatedChangeLine show-when-collapsed">
+                  <span
+                    aria-label="Arrow marking current change"
+                    class="arrowToCurrentChange marker"
+                    role="img"
+                  >
+                    ➔
+                  </span>
+                  <gr-related-change show-submittable-check="">
+                    Test subject
+                  </gr-related-change>
+                  <span class="repo" title="test-project">test-project</span>
+                  <span class="branch">&nbsp;|&nbsp;test-branch&nbsp;</span>
+                </div>
+              </gr-related-collapse>
+              <div class="note" hidden="">(+ )</div>
+            </section>
+            <section id="cherryPicks">
+              <gr-related-collapse title="Cherry picks">
+                <div class="relatedChangeLine show-when-collapsed">
+                  <span class="marker space"> </span>
+                  <gr-related-change show-change-status="">
+                    test-branch: Test subject
+                  </gr-related-change>
+                </div>
+              </gr-related-collapse>
+            </section>
+            <gr-endpoint-slot name="bottom"> </gr-endpoint-slot>
+          </gr-endpoint-decorator>
+        `
+      );
+    });
+
     test('first list', async () => {
       stubRestApi('getRelatedChanges').returns(
         Promise.resolve(relatedChangeInfo)
@@ -217,7 +271,7 @@
         section,
         'gr-related-collapse'
       );
-      assert.isTrue(relatedChanges!.classList.contains('first'));
+      assert.isTrue(relatedChanges.classList.contains('first'));
     });
 
     test('first empty second non-empty', async () => {
@@ -234,7 +288,7 @@
         queryAndAssert<HTMLElement>(element, '#submittedTogether'),
         'gr-related-collapse'
       );
-      assert.isTrue(submittedTogetherSection!.classList.contains('first'));
+      assert.isTrue(submittedTogetherSection.classList.contains('first'));
     });
 
     test('first non-empty second empty third non-empty', async () => {
@@ -248,11 +302,12 @@
         Promise.resolve([createChange()])
       );
       await element.reload();
+
       const relatedChanges = queryAndAssert<GrRelatedCollapse>(
         queryAndAssert<HTMLElement>(element, '#relatedChanges'),
         'gr-related-collapse'
       );
-      assert.isTrue(relatedChanges!.classList.contains('first'));
+      assert.isTrue(relatedChanges.classList.contains('first'));
       const submittedTogetherSection = query<HTMLElement>(
         element,
         '#submittedTogether'
@@ -262,7 +317,7 @@
         queryAndAssert<HTMLElement>(element, '#cherryPicks'),
         'gr-related-collapse'
       );
-      assert.isFalse(cherryPicks!.classList.contains('first'));
+      assert.isFalse(cherryPicks.classList.contains('first'));
     });
   });
 
@@ -305,16 +360,18 @@
       change_id: '456' as ChangeId,
       _number: 1 as NumericChangeId,
     };
-    assert.equal(element._getChangeNumber(change1), 0);
-    assert.equal(element._getChangeNumber(change2), 1);
+    assert.equal(getChangeNumber(change1), 0);
+    assert.equal(getChangeNumber(change2), 1);
   });
 
   suite('get conflicts tests', () => {
     let element: GrRelatedChangesList;
     let conflictsStub: SinonStubbedMember<RestApiService['getChangeConflicts']>;
 
-    setup(() => {
-      element = basicFixture.instantiate();
+    setup(async () => {
+      element = await fixture(
+        html`<gr-related-changes-list></gr-related-changes-list>`
+      );
       conflictsStub = stubRestApi('getChangeConflicts').returns(
         Promise.resolve(undefined)
       );
@@ -588,13 +645,10 @@
   suite('gr-related-changes-list plugin tests', () => {
     let element: GrRelatedChangesList;
 
-    setup(() => {
-      resetPlugins();
-      element = basicFixture.instantiate();
-    });
-
-    teardown(() => {
-      resetPlugins();
+    setup(async () => {
+      element = await fixture(
+        html`<gr-related-changes-list></gr-related-changes-list>`
+      );
     });
 
     test('endpoint params', async () => {
@@ -606,7 +660,7 @@
       }
       let hookEl: RelatedChangesListGrEndpointDecorator;
       let plugin: PluginApi;
-      pluginApi.install(
+      window.Gerrit.install(
         p => {
           plugin = p;
           plugin
@@ -617,8 +671,8 @@
         '0.1',
         'http://some/plugins/url1.js'
       );
-      getPluginLoader().loadPlugins([]);
-      await flush();
+      testResolver(pluginLoaderToken).loadPlugins([]);
+      await waitEventLoop();
       assert.strictEqual(hookEl!.plugin, plugin!);
       assert.strictEqual(hookEl!.change, element.change);
     });
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
new file mode 100644
index 0000000..61136d7
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, css, html, nothing, TemplateResult} from 'lit';
+import '../../shared/gr-icon/gr-icon';
+import {customElement, property} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {getAppContext} from '../../../services/app-context';
+import {Interaction} from '../../../constants/reporting';
+import {fontStyles} from '../../../styles/gr-font-styles';
+
+/** What is the maximum number of shown changes in collapsed list? */
+export const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
+
+@customElement('gr-related-collapse')
+export class GrRelatedCollapse extends LitElement {
+  @property()
+  override title = '';
+
+  @property({type: Boolean})
+  showAll = false;
+
+  @property({type: Boolean, reflect: true})
+  collapsed = true;
+
+  @property({type: Number})
+  length = 0;
+
+  @property({type: Number})
+  numChangesWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        .title {
+          color: var(--deemphasized-text-color);
+          display: flex;
+          align-self: flex-end;
+          margin-left: 20px;
+        }
+        gr-button {
+          display: flex;
+        }
+        gr-button gr-icon {
+          color: inherit;
+          font-size: 18px;
+        }
+        .container {
+          justify-content: space-between;
+          display: flex;
+          margin-bottom: var(--spacing-s);
+        }
+        :host(.first) .container {
+          margin-bottom: var(--spacing-m);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const title = html`<h3 class="title heading-3">${this.title}</h3>`;
+
+    const collapsible = this.length > this.numChangesWhenCollapsed;
+    this.collapsed = !this.showAll && collapsible;
+
+    let button: TemplateResult | typeof nothing = nothing;
+    if (collapsible) {
+      const buttonText = this.showAll
+        ? 'Show less'
+        : `Show all (${this.length})`;
+      const buttonIcon = this.showAll ? 'expand_less' : 'expand_more';
+      button = html`<gr-button link="" @click=${this.toggle}
+        >${buttonText}<gr-icon icon=${buttonIcon}></gr-icon
+      ></gr-button>`;
+    }
+
+    return html`<div class="container">${title}${button}</div>
+      <div><slot></slot></div>`;
+  }
+
+  private toggle(e: MouseEvent) {
+    e.stopPropagation();
+    this.showAll = !this.showAll;
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
+      sectionName: this.title,
+      toState: this.showAll ? 'Show all' : 'Show less',
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-related-collapse': GrRelatedCollapse;
+  }
+}
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
deleted file mode 100644
index 486f37a..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ /dev/null
@@ -1,130 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-reply-dialog.js';
-
-import {queryAndAssert, resetPlugins, stubRestApi} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-const basicFixture = fixtureFromElement('gr-reply-dialog');
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-reply-dialog-it tests', () => {
-  let element;
-  let changeNum;
-  let patchNum;
-
-  const setupElement = element => {
-    element.change = {
-      _number: changeNum,
-      labels: {
-        'Verified': {
-          values: {
-            '-1': 'Fails',
-            ' 0': 'No score',
-            '+1': 'Verified',
-          },
-          default_value: 0,
-        },
-        'Code-Review': {
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didn\'t submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          all: [{_account_id: 42, value: 0}],
-          default_value: 0,
-        },
-      },
-    };
-    element.patchNum = patchNum;
-    element.permittedLabels = {
-      'Code-Review': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-  };
-
-  setup(() => {
-    changeNum = 42;
-    patchNum = 1;
-
-    stubRestApi('getAccount').returns(Promise.resolve({_account_id: 42}));
-
-    element = basicFixture.instantiate();
-    setupElement(element);
-    // Allow the elements created by dom-repeat to be stamped.
-    flush();
-  });
-
-  teardown(() => {
-    resetPlugins();
-  });
-
-  test('_submit blocked when invalid email is supplied to ccs', () => {
-    const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
-
-    element.$.ccs.$.entry.setText('test');
-    MockInteractions.tap(element.shadowRoot.querySelector('gr-button.send'));
-    assert.isFalse(sendStub.called);
-    flush();
-
-    element.$.ccs.$.entry.setText('test@test.test');
-    MockInteractions.tap(element.shadowRoot.querySelector('gr-button.send'));
-    assert.isTrue(sendStub.called);
-  });
-
-  test('lgtm plugin', async () => {
-    resetPlugins();
-    pluginApi.install(plugin => {
-      const replyApi = plugin.changeReply();
-      replyApi.addReplyTextChangedCallback(text => {
-        const label = 'Code-Review';
-        const labelValue = replyApi.getLabelValue(label);
-        if (labelValue && labelValue === ' 0' && text.indexOf('LGTM') === 0) {
-          replyApi.setLabelValue(label, '+1');
-        }
-      });
-    }, null, 'http://test.com/plugins/lgtm.js');
-    element = basicFixture.instantiate();
-    setupElement(element);
-    getPluginLoader().loadPlugins([]);
-    await getPluginLoader().awaitPluginsLoaded();
-    await flush();
-    const textarea = queryAndAssert(element, 'gr-textarea').getNativeTextarea();
-    textarea.value = 'LGTM';
-    textarea.dispatchEvent(
-        new CustomEvent('input', {bubbles: true, composed: true}));
-    await flush();
-    const labelScoreRows = element.getLabelScores().shadowRoot.querySelector(
-        'gr-label-score-row[name="Code-Review"]');
-    const selectedBtn =
-        labelScoreRows.shadowRoot.querySelector('gr-button[data-value="+1"]');
-    assert.isOk(selectedBtn);
-  });
-});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
new file mode 100644
index 0000000..25e3e51
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-reply-dialog';
+import {
+  queryAndAssert,
+  stubRestApi,
+  waitEventLoop,
+} from '../../../test/test-utils';
+
+import {GrReplyDialog} from './gr-reply-dialog';
+import {fixture, html, assert} from '@open-wc/testing';
+import {
+  AccountId,
+  NumericChangeId,
+  PatchSetNum,
+  Timestamp,
+} from '../../../types/common';
+import {createChange} from '../../../test/test-data-generators';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
+suite('gr-reply-dialog-it tests', () => {
+  let element: GrReplyDialog;
+  let changeNum: NumericChangeId;
+  let patchNum: PatchSetNum;
+
+  const setupElement = (element: GrReplyDialog) => {
+    element.change = {
+      ...createChange(),
+      _number: changeNum,
+      labels: {
+        Verified: {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
+          },
+          default_value: 0,
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'This shall not be submitted',
+            '-1': 'I would prefer this is not submitted as is',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          all: [{_account_id: 42 as AccountId, value: 0}],
+          default_value: 0,
+        },
+      },
+    };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': ['-1', ' 0', '+1'],
+      Verified: ['-1', ' 0', '+1'],
+    };
+  };
+
+  setup(async () => {
+    changeNum = 42 as NumericChangeId;
+    patchNum = 1 as PatchSetNum;
+
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        _account_id: 42 as AccountId,
+        registered_on: '' as Timestamp,
+      })
+    );
+
+    element = await fixture<GrReplyDialog>(html`
+      <gr-reply-dialog></gr-reply-dialog>
+    `);
+    setupElement(element);
+
+    await element.updateComplete;
+  });
+
+  test('submit blocked when invalid email is supplied to ccs', async () => {
+    const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
+
+    element.ccsList!.entry!.setText('test');
+    queryAndAssert<GrButton>(element, 'gr-button.send').click();
+    assert.isFalse(element.ccsList!.submitEntryText());
+    assert.isFalse(sendStub.called);
+    await waitEventLoop();
+
+    element.ccsList!.entry!.setText('test@test.test');
+    queryAndAssert<GrButton>(element, 'gr-button.send').click();
+    assert.isTrue(sendStub.called);
+  });
+
+  test('lgtm plugin', async () => {
+    window.Gerrit.install(
+      plugin => {
+        const replyApi = plugin.changeReply();
+        replyApi.addReplyTextChangedCallback(text => {
+          const label = 'Code-Review';
+          const labelValue = replyApi.getLabelValue(label);
+          if (labelValue && labelValue === ' 0' && text.indexOf('LGTM') === 0) {
+            replyApi.setLabelValue(label, '+1');
+          }
+        });
+      },
+      undefined,
+      'http://test.com/plugins/lgtm.js'
+    );
+    element = await fixture(html`<gr-reply-dialog></gr-reply-dialog>`);
+    setupElement(element);
+    const pluginLoader = testResolver(pluginLoaderToken);
+    pluginLoader.loadPlugins([]);
+    await pluginLoader.awaitPluginsLoaded();
+    await waitEventLoop();
+    await waitEventLoop();
+    const labelScoreRows = queryAndAssert(
+      element.getLabelScores(),
+      'gr-label-score-row[name="Code-Review"]'
+    );
+    const selectedBtn = queryAndAssert(
+      labelScoreRows,
+      'gr-button[data-value="+1"]'
+    );
+    assert.isOk(selectedBtn);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 5109b72..d4cff78 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
@@ -1,37 +1,21 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 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 '../../shared/gr-account-chip/gr-account-chip';
-import '../../shared/gr-textarea/gr-textarea';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../shared/gr-overlay/gr-overlay';
 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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-reply-dialog_html';
-import {
-  GrReviewerSuggestionsProvider,
-  SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
-import {appContext} from '../../../services/app-context';
+import {GrReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {getAppContext} from '../../../services/app-context';
 import {
   ChangeStatus,
   DraftsAction,
@@ -39,21 +23,24 @@
   SpecialFilePath,
 } from '../../../constants/constants';
 import {
-  accountOrGroupKey,
-  isReviewerOrCC,
-  mapReviewer,
+  getUserId,
+  isAccountNewlyAdded,
   removeServiceUsers,
+  toReviewInput,
 } from '../../../utils/account-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {TargetElement} from '../../../api/plugin';
-import {customElement, observe, property} from '@polymer/decorators';
-import {FixIronA11yAnnouncer} from '../../../types/types';
 import {
-  AccountAddition,
+  FixIronA11yAnnouncer,
+  isDefined,
+  ParsedChangeInfo,
+} from '../../../types/types';
+import {
   AccountInfoInput,
+  AccountInput,
+  AccountInputDetail,
   GrAccountList,
   GroupInfoInput,
-  GroupObjectInput,
   RawAccountInput,
 } from '../../shared/gr-account-list/gr-account-list';
 import {
@@ -61,42 +48,42 @@
   AccountInfo,
   AttentionSetInput,
   ChangeInfo,
-  CommentInput,
-  EmailAddress,
-  GroupId,
   GroupInfo,
   isAccount,
   isDetailedLabelInfo,
   isReviewerAccountSuggestion,
   isReviewerGroupSuggestion,
-  LabelNameToValueMap,
   ParsedJSON,
   PatchSetNum,
-  ProjectInfo,
   ReviewerInput,
-  Reviewers,
   ReviewInput,
   ReviewResult,
   ServerInfo,
+  SuggestedReviewerGroupInfo,
   Suggestion,
+  UserId,
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {
-  PolymerDeepPropertyChange,
-  PolymerSpliceChange,
-} from '@polymer/polymer/interfaces';
-import {
   areSetsEqual,
   assertIsDefined,
   containsAll,
+  difference,
   queryAndAssert,
 } from '../../../utils/common-util';
-import {CommentThread, isUnresolved} from '../../../utils/comment-util';
-import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {
+  CommentThread,
+  createPatchsetLevelUnsavedDraft,
+  DraftInfo,
+  getFirstComment,
+  isDraft,
+  isPatchsetLevel,
+  isUnresolved,
+  UnsavedInfo,
+} from '../../../utils/comment-util';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
   getApprovalInfo,
   getMaxAccounts,
@@ -111,13 +98,42 @@
   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 {DelayedTask} from '../../../utils/async-util';
 import {Interaction, Timing} from '../../../constants/reporting';
-import {getReplyByReason} from '../../../utils/attention-set-util';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
-
-const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+import {
+  getMentionedReason,
+  getReplyByReason,
+} from '../../../utils/attention-set-util';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {
+  ConfigInfo,
+  LabelNameToValuesMap,
+  PatchSetNumber,
+} from '../../../api/rest-api';
+import {css, html, PropertyValues, LitElement, nothing} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {when} from 'lit/directives/when.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {customElement, property, state, query} from 'lit/decorators.js';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {hasHumanReviewer, isOwner} from '../../../utils/change-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {
+  CommentEditingChangedDetail,
+  GrComment,
+} from '../../shared/gr-comment/gr-comment';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {userModelToken} from '../../../models/user/user-model';
+import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 export enum FocusTarget {
   ANY = 'any',
@@ -151,24 +167,8 @@
 
 const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
 
-export interface GrReplyDialog {
-  $: {
-    reviewers: GrAccountList;
-    ccs: GrAccountList;
-    cancelButton: GrButton;
-    sendButton: GrButton;
-    labelScores: GrLabelScores;
-    textarea: GrTextarea;
-    reviewerConfirmationOverlay: GrOverlay;
-  };
-}
-
 @customElement('gr-reply-dialog')
-export class GrReplyDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrReplyDialog extends LitElement {
   /**
    * Fired when a reply is successfully sent.
    *
@@ -182,13 +182,6 @@
    */
 
   /**
-   * Fired when the main textarea's value changes, which may have triggered
-   * a change in size for the dialog.
-   *
-   * @event autogrow
-   */
-
-  /**
    * Fires to show an alert when a send is attempted on the non-latest patch.
    *
    * @event show-alert
@@ -215,12 +208,15 @@
 
   FocusTarget = FocusTarget;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly changeService = appContext.changeService;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  // TODO: update type to only ParsedChangeInfo
   @property({type: Object})
-  change?: ChangeInfo;
+  change?: ParsedChangeInfo | ChangeInfo;
 
   @property({type: String})
   patchNum?: PatchSetNum;
@@ -228,148 +224,474 @@
   @property({type: Boolean})
   canBeStarted = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
-  @property({
-    type: Boolean,
-    computed: '_computeHasDrafts(draft, draftCommentThreads.*)',
-  })
-  hasDrafts = false;
-
-  @property({type: String, observer: '_draftChanged'})
-  draft = '';
+  @state()
+  draftCommentThreads: CommentThread[] = [];
 
   @property({type: Object})
+  permittedLabels?: LabelNameToValuesMap;
+
+  @property({type: Object})
+  projectConfig?: ConfigInfo;
+
+  @query('#patchsetLevelComment') patchsetLevelGrComment?: GrComment;
+
+  @query('#reviewers') reviewersList?: GrAccountList;
+
+  @query('#ccs') ccsList?: GrAccountList;
+
+  @query('#cancelButton') cancelButton?: GrButton;
+
+  @query('#sendButton') sendButton?: GrButton;
+
+  @query('#labelScores') labelScores?: GrLabelScores;
+
+  @query('#reviewerConfirmationModal')
+  reviewerConfirmationModal?: HTMLDialogElement;
+
+  @state() serverConfig?: ServerInfo;
+
+  @state()
+  patchsetLevelDraftMessage = '';
+
+  @state()
   filterReviewerSuggestion: (input: Suggestion) => boolean;
 
-  @property({type: Object})
+  @state()
   filterCCSuggestion: (input: Suggestion) => boolean;
 
-  @property({type: Object})
-  permittedLabels?: LabelNameToValueMap;
-
-  @property({type: Object})
-  projectConfig?: ProjectInfo;
-
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
-  @property({type: String})
+  @state()
   knownLatestState?: LatestPatchState;
 
-  @property({type: Boolean})
+  @state()
   underReview = true;
 
-  @property({type: Object})
-  _account?: AccountInfo;
+  @state()
+  account?: AccountInfo;
 
-  @property({type: Array})
-  _ccs: (AccountInfo | GroupInfo)[] = [];
+  get ccs() {
+    return [
+      ...this._ccs,
+      ...this.mentionedUsers.filter(v => !this.isAlreadyReviewerOrCC(v)),
+    ];
+  }
 
-  @property({type: Number})
-  _attentionCcsCount = 0;
+  /**
+   * We pass the ccs object to AccountInput for modifying where it needs to
+   * add a value to CC. The returned value contains both mentionedUsers and
+   * normal ccs hence separate the two when setting ccs.
+   */
+  set ccs(ccs: AccountInput[]) {
+    this._ccs = ccs.filter(
+      cc =>
+        !this.mentionedUsers.some(
+          mentionedCC => getUserId(mentionedCC) === getUserId(cc)
+        )
+    );
+    this.requestUpdate('ccs', ccs);
+  }
 
-  @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
-  _ccPendingConfirmation: GroupObjectInput | null = null;
+  @state()
+  _ccs: AccountInput[] = [];
 
-  @property({
-    type: String,
-    computed: '_computeMessagePlaceholder(canBeStarted)',
-  })
-  _messagePlaceholder?: string;
+  /**
+   * Maintain a separate list of users added to cc due to being mentioned in
+   * unresolved drafts.
+   * If the draft is discarded or edited to remove the mention then we want to
+   * remove the user from being added to CC.
+   * Instead of figuring out when we should remove the mentioned user ie when
+   * they get removed from the last comment, we recompute this property when
+   * any of the draft comments change.
+   * If we add the user to the existing ccs object then we cannot differentiate
+   * if the user was added manually to CC or added due to being mentioned hence
+   * we cannot reset the mentioned ccs when drafts change.
+   */
+  @state()
+  mentionedUsers: AccountInput[] = [];
 
-  @property({type: Object})
-  _owner?: AccountInfo;
+  @state()
+  mentionedUsersInUnresolvedDrafts: AccountInfo[] = [];
 
-  @property({type: Object, computed: '_computeUploader(change)'})
-  _uploader?: AccountInfo;
+  @state()
+  attentionCcsCount = 0;
 
-  @property({type: Object})
-  _pendingConfirmationDetails: GroupObjectInput | null = null;
+  @state()
+  ccPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
-  @property({type: Boolean})
-  _includeComments = true;
+  @state()
+  messagePlaceholder?: string;
 
-  @property({type: Array})
-  _reviewers: (AccountInfo | GroupInfo)[] = [];
+  @state()
+  uploader?: AccountInfo;
 
-  @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
-  _reviewerPendingConfirmation: GroupObjectInput | null = null;
+  @state()
+  pendingConfirmationDetails: SuggestedReviewerGroupInfo | null = null;
 
-  @property({type: Boolean, observer: '_handleHeightChanged'})
-  _previewFormatting = false;
+  @state()
+  includeComments = true;
 
-  @property({type: String, computed: '_computeSendButtonLabel(canBeStarted)'})
-  _sendButtonLabel?: string;
+  @state() reviewers: AccountInput[] = [];
 
-  @property({type: Boolean})
-  _savingComments = false;
+  @state()
+  reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
-  @property({type: Boolean})
-  _reviewersMutated = false;
+  @state()
+  sendButtonLabel?: string;
+
+  @state()
+  savingComments = false;
+
+  @state()
+  reviewersMutated = false;
 
   /**
    * Signifies that the user has changed their vote on a label or (if they have
    * not yet voted on a label) if a selected vote is different from the default
    * vote.
    */
-  @property({type: Boolean})
-  _labelsChanged = false;
+  @state()
+  labelsChanged = false;
 
-  @property({type: String})
-  readonly _saveTooltip: string = ButtonTooltips.SAVE;
+  @state()
+  readonly saveTooltip: string = ButtonTooltips.SAVE;
 
-  @property({type: String})
-  _pluginMessage = '';
+  @state()
+  pluginMessage = '';
 
-  @property({type: Boolean})
-  _commentEditing = false;
+  @state()
+  commentEditing = false;
 
-  @property({type: Boolean})
-  _attentionExpanded = false;
+  @state()
+  attentionExpanded = false;
 
-  @property({type: Object})
-  _currentAttentionSet: Set<AccountId> = new Set();
+  @state()
+  currentAttentionSet: Set<UserId> = new Set();
 
-  @property({type: Object})
-  _newAttentionSet: Set<AccountId> = new Set();
+  @state()
+  newAttentionSet: Set<UserId> = new Set();
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeSendButtonDisabled(canBeStarted, ' +
-      'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
-      '_includeComments, disabled, _commentEditing, change, _account)',
-    observer: '_sendDisabledChanged',
-  })
-  _sendDisabled?: boolean;
+  @state()
+  sendDisabled?: boolean;
 
-  @property({type: Array, observer: '_handleHeightChanged'})
-  draftCommentThreads: CommentThread[] | undefined;
+  @state()
+  patchsetLevelDraftIsResolved = true;
 
-  @property({type: Boolean})
-  _isResolvedPatchsetLevelComment = true;
+  @state()
+  patchsetLevelComment?: UnsavedInfo | DraftInfo;
 
-  @property({type: Array, computed: '_computeAllReviewers(_reviewers.*)'})
-  _allReviewers: (AccountInfo | GroupInfo)[] = [];
+  private readonly restApiService: RestApiService =
+    getAppContext().restApiService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly flagsService = getAppContext().flagsService;
 
-  private readonly storage = appContext.storageService;
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
-  private readonly jsAPI = appContext.jsApiService;
+  private readonly getConfigModel = resolve(this, configModelToken);
 
-  private storeTask?: DelayedTask;
+  private readonly getAccountsModel = resolve(this, accountsModelToken);
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private latestPatchNum?: PatchSetNumber;
+
+  storeTask?: DelayedTask;
+
+  private isLoggedIn = false;
+
+  private readonly shortcuts = new ShortcutController(this);
+
+  static override styles = [
+    sharedStyles,
+    modalStyles,
+    css`
+      :host {
+        background-color: var(--dialog-background-color);
+        display: block;
+        max-height: 90vh;
+        --label-score-padding-left: var(--spacing-xl);
+      }
+      :host([disabled]) {
+        pointer-events: none;
+      }
+      :host([disabled]) .container {
+        opacity: 0.5;
+      }
+      section {
+        border-top: 1px solid var(--border-color);
+        flex-shrink: 0;
+        padding: var(--spacing-m) var(--spacing-xl);
+        width: 100%;
+      }
+      section.labelsContainer {
+        /* We want the :hover highlight to extend to the border of the dialog. */
+        padding: var(--spacing-m) 0;
+      }
+      .stickyBottom {
+        background-color: var(--dialog-background-color);
+        box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
+        margin-top: var(--spacing-s);
+        bottom: 0;
+        position: sticky;
+        /* @see Issue 8602 */
+        z-index: 1;
+      }
+      .stickyBottom.newReplyDialog {
+        margin-top: unset;
+      }
+      .actions {
+        display: flex;
+        justify-content: space-between;
+      }
+      .actions .right gr-button {
+        margin-left: var(--spacing-l);
+      }
+      .peopleContainer,
+      .labelsContainer {
+        flex-shrink: 0;
+      }
+      .peopleContainer {
+        border-top: none;
+        display: table;
+      }
+      .peopleList {
+        display: flex;
+      }
+      .peopleListLabel {
+        color: var(--deemphasized-text-color);
+        margin-top: var(--spacing-xs);
+        min-width: 6em;
+        padding-right: var(--spacing-m);
+      }
+      gr-account-list {
+        display: flex;
+        flex-wrap: wrap;
+        flex: 1;
+      }
+      #reviewerConfirmationModal {
+        padding: var(--spacing-l);
+        text-align: center;
+      }
+      .reviewerConfirmationButtons {
+        margin-top: var(--spacing-l);
+      }
+      .groupName {
+        font-weight: var(--font-weight-bold);
+      }
+      .groupSize {
+        font-style: italic;
+      }
+      .textareaContainer {
+        min-height: 12em;
+        position: relative;
+      }
+      .newReplyDialog.textareaContainer {
+        min-height: unset;
+      }
+      textareaContainer,
+      gr-endpoint-decorator[name='reply-text'] {
+        display: flex;
+        width: 100%;
+      }
+      gr-endpoint-decorator[name='reply-text'] {
+        flex-direction: column;
+      }
+      #checkingStatusLabel,
+      #notLatestLabel {
+        margin-left: var(--spacing-l);
+      }
+      #checkingStatusLabel {
+        color: var(--deemphasized-text-color);
+        font-style: italic;
+      }
+      #notLatestLabel,
+      #savingLabel {
+        color: var(--error-text-color);
+      }
+      #savingLabel {
+        display: none;
+      }
+      #savingLabel.saving {
+        display: inline;
+      }
+      #pluginMessage {
+        color: var(--deemphasized-text-color);
+        margin-left: var(--spacing-l);
+        margin-bottom: var(--spacing-m);
+      }
+      #pluginMessage:empty {
+        display: none;
+      }
+      .attention .edit-attention-button {
+        vertical-align: top;
+        --gr-button-padding: 0px 4px;
+      }
+      .attention .edit-attention-button gr-icon {
+        color: inherit;
+        /* The line-height:26px hack (see below) requires us to do this.
+           Normally the gr-icon would account for a proper positioning
+           within the standard line-height:20px context. */
+        top: 5px;
+      }
+      .attention a,
+      .attention-detail a {
+        text-decoration: none;
+      }
+      .attentionSummary {
+        display: flex;
+        justify-content: space-between;
+      }
+      .attentionSummary {
+        /* The account label for selection is misbehaving currently: It consumes
+          26px height instead of 20px, which is the default line-height and thus
+          the max that can be nicely fit into an inline layout flow. We
+          acknowledge that using a fixed 26px value here is a hack and not a
+          great solution. */
+        line-height: 26px;
+      }
+      .attentionSummary gr-account-label,
+      .attention-detail gr-account-label {
+        --account-max-length: 120px;
+        display: inline-block;
+        padding: var(--spacing-xs) var(--spacing-m);
+        user-select: none;
+        --label-border-radius: 8px;
+      }
+      .attentionSummary gr-account-label {
+        margin: 0 var(--spacing-xs);
+        line-height: var(--line-height-normal);
+        vertical-align: top;
+      }
+      .attention-detail .peopleListValues {
+        line-height: calc(var(--line-height-normal) + 10px);
+      }
+      .attention-detail gr-account-label {
+        line-height: var(--line-height-normal);
+      }
+      .attentionSummary gr-account-label:focus,
+      .attention-detail gr-account-label:focus {
+        outline: none;
+      }
+      .attentionSummary gr-account-label:hover,
+      .attention-detail gr-account-label:hover {
+        box-shadow: var(--elevation-level-1);
+        cursor: pointer;
+      }
+      .attention-detail .attentionDetailsTitle {
+        display: flex;
+        justify-content: space-between;
+      }
+      .attention-detail .selectUsers {
+        color: var(--deemphasized-text-color);
+        margin-bottom: var(--spacing-m);
+      }
+      .attentionTip {
+        padding: var(--spacing-m);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        margin-top: var(--spacing-m);
+        background-color: var(--assignee-highlight-color);
+      }
+      .attentionTip div gr-icon {
+        margin-right: var(--spacing-s);
+      }
+      .patchsetLevelContainer {
+        width: 80ch;
+        border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-2);
+      }
+      .patchsetLevelContainer.resolved {
+        background-color: var(--comment-background-color);
+      }
+      .patchsetLevelContainer.unresolved {
+        background-color: var(--unresolved-comment-background-color);
+      }
+      .privateVisiblityInfo {
+        display: flex;
+        justify-content: center;
+        background-color: var(--info-background);
+        padding: var(--spacing-s) 0;
+      }
+      .privateVisiblityInfo gr-icon {
+        margin-right: var(--spacing-m);
+        color: var(--info-foreground);
+      }
+    `,
+  ];
 
   constructor() {
     super();
     this.filterReviewerSuggestion =
-      this._filterReviewerSuggestionGenerator(false);
-    this.filterCCSuggestion = this._filterReviewerSuggestionGenerator(true);
+      this.filterReviewerSuggestionGenerator(false);
+    this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true);
+
+    this.shortcuts.addLocal({key: Key.ESC}, () => this.cancel());
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+      () => this.submit()
+    );
+    this.shortcuts.addLocal(
+      {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+      () => this.submit()
+    );
+
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      isLoggedIn => (this.isLoggedIn = isLoggedIn)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().mentionedUsersInDrafts$,
+      x => {
+        this.mentionedUsers = x;
+        this.reviewersMutated =
+          this.reviewersMutated || this.mentionedUsers.length > 0;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().mentionedUsersInUnresolvedDrafts$,
+      x => {
+        if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+          return;
+        }
+        this.mentionedUsersInUnresolvedDrafts = x.filter(
+          v => !this.isAlreadyReviewerOrCC(v)
+        );
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().patchsetLevelDrafts$,
+      x => (this.patchsetLevelComment = x[0])
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().draftThreads$,
+      threads =>
+        (this.draftCommentThreads = threads.filter(
+          t => !(isDraft(getFirstComment(t)) && isPatchsetLevel(t))
+        ))
+    );
   }
 
   override connectedCallback() {
@@ -377,24 +699,32 @@
     (
       IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
     ).requestAvailability();
-    this._getAccount().then(account => {
-      if (account) this._account = account;
+
+    this.getPluginLoader().jsApiService.addElement(
+      TargetElement.REPLY_DIALOG,
+      this
+    );
+
+    this.restApiService.getAccount().then(account => {
+      if (account) this.account = account;
     });
 
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
-        this._submit()
-      )
+    this.addEventListener(
+      'comment-editing-changed',
+      (e: CustomEvent<CommentEditingChangedDetail>) => {
+        // Patchset level comment is always in editing mode which means it would
+        // set commentEditing = true and the send button would be permanently
+        // disabled.
+        if (e.detail.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) return;
+        const commentList = queryAndAssert<GrThreadList>(this, '#commentList');
+        // It can be one or more comments were in editing mode. Wwitching one
+        // thread in editing, we need to check if there are still other threads
+        // in editing.
+        this.commentEditing = Array.from(commentList.threadElements ?? []).some(
+          thread => thread.editing
+        );
+      }
     );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
-        this._submit()
-      )
-    );
-    this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
-    this.addEventListener('comment-editing-changed', e => {
-      this._commentEditing = (e as CustomEvent).detail;
-    });
 
     // Plugins on reply-reviewers endpoint can take advantage of these
     // events to add / remove reviewers
@@ -402,28 +732,541 @@
     this.addEventListener('add-reviewer', e => {
       // Only support account type, see more from:
       // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
-      this.$.reviewers.addAccountItem({
+      this.reviewersList?.addAccountItem({
         account: (e as CustomEvent).detail.reviewer,
+        count: 1,
       });
     });
 
     this.addEventListener('remove-reviewer', e => {
-      this.$.reviewers.removeAccount((e as CustomEvent).detail.reviewer);
+      this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
     });
   }
 
-  override ready() {
-    super.ready();
-    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('ccPendingConfirmation')) {
+      this.pendingConfirmationUpdated(this.ccPendingConfirmation);
+    }
+    if (changedProperties.has('reviewerPendingConfirmation')) {
+      this.pendingConfirmationUpdated(this.reviewerPendingConfirmation);
+    }
+    if (changedProperties.has('change')) {
+      this.computeUploader();
+      this.rebuildReviewerArrays();
+    }
+    if (changedProperties.has('canBeStarted')) {
+      this.computeMessagePlaceholder();
+      this.computeSendButtonLabel();
+    }
+    if (changedProperties.has('sendDisabled')) {
+      this.sendDisabledChanged();
+    }
+    if (changedProperties.has('attentionExpanded')) {
+      this.onAttentionExpandedChange();
+    }
+    if (
+      changedProperties.has('account') ||
+      changedProperties.has('reviewers') ||
+      changedProperties.has('ccs') ||
+      changedProperties.has('change') ||
+      changedProperties.has('draftCommentThreads') ||
+      changedProperties.has('mentionedUsersInUnresolvedDrafts') ||
+      changedProperties.has('includeComments') ||
+      changedProperties.has('labelsChanged') ||
+      changedProperties.has('patchsetLevelDraftMessage') ||
+      changedProperties.has('mentionedCCs')
+    ) {
+      this.computeNewAttention();
+    }
   }
 
   override disconnectedCallback() {
-    this.storeTask?.cancel();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
+    this.storeTask?.flush();
     super.disconnectedCallback();
   }
 
+  override render() {
+    if (!this.change) return;
+    this.sendDisabled = this.computeSendButtonDisabled();
+    return html`
+      <div tabindex="-1">
+        <section class="peopleContainer">
+          <gr-endpoint-decorator name="reply-reviewers">
+            <gr-endpoint-param
+              name="change"
+              .value=${this.change}
+            ></gr-endpoint-param>
+            <gr-endpoint-param name="reviewers" .value=${[...this.reviewers]}>
+            </gr-endpoint-param>
+            ${this.renderReviewerList()}
+            <gr-endpoint-slot name="below"></gr-endpoint-slot>
+          </gr-endpoint-decorator>
+          ${this.renderCCList()} ${this.renderReviewConfirmation()}
+          ${this.renderPrivateVisiblityInfo()}
+        </section>
+        <section class="labelsContainer">${this.renderLabels()}</section>
+        <section class="newReplyDialog textareaContainer">
+          ${this.renderReplyText()}
+        </section>
+        ${this.renderDraftsSection()}
+        <div class="stickyBottom newReplyDialog">
+          <gr-endpoint-decorator name="reply-bottom">
+            <gr-endpoint-param
+              name="change"
+              .value=${this.change}
+            ></gr-endpoint-param>
+            ${this.renderAttentionSummarySection()}
+            ${this.renderAttentionDetailsSection()}
+            <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+            ${this.renderActionsSection()}
+          </gr-endpoint-decorator>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderReviewerList() {
+    return html`
+      <div class="peopleList">
+        <div class="peopleListLabel">Reviewers</div>
+        <gr-account-list
+          id="reviewers"
+          .accounts=${[...this.reviewers]}
+          .change=${this.change}
+          .reviewerState=${ReviewerState.REVIEWER}
+          @account-added=${this.handleAccountAdded}
+          @accounts-changed=${this.handleReviewersChanged}
+          .removableValues=${this.change?.removable_reviewers}
+          .filter=${this.filterReviewerSuggestion}
+          .pendingConfirmation=${this.reviewerPendingConfirmation}
+          @pending-confirmation-changed=${this
+            .handleReviewersConfirmationChanged}
+          .placeholder=${'Add reviewer...'}
+          @account-text-changed=${this.handleAccountTextEntry}
+          .suggestionsProvider=${this.getReviewerSuggestionsProvider(
+            this.change
+          )}
+        >
+        </gr-account-list>
+        <gr-endpoint-slot name="right"></gr-endpoint-slot>
+      </div>
+    `;
+  }
+
+  private renderCCList() {
+    return html`
+      <div class="peopleList">
+        <div class="peopleListLabel">CC</div>
+        <gr-account-list
+          id="ccs"
+          .accounts=${[...this.ccs]}
+          .change=${this.change}
+          .reviewerState=${ReviewerState.CC}
+          @account-added=${this.handleAccountAdded}
+          @accounts-changed=${this.handleCcsChanged}
+          .filter=${this.filterCCSuggestion}
+          .pendingConfirmation=${this.ccPendingConfirmation}
+          @pending-confirmation-changed=${this.handleCcsConfirmationChanged}
+          allow-any-input
+          .placeholder=${'Add CC...'}
+          @account-text-changed=${this.handleAccountTextEntry}
+          .suggestionsProvider=${this.getCcSuggestionsProvider(this.change)}
+        >
+        </gr-account-list>
+      </div>
+    `;
+  }
+
+  private renderReviewConfirmation() {
+    return html`
+      <dialog
+        tabindex="-1"
+        id="reviewerConfirmationModal"
+        @close=${this.cancelPendingReviewer}
+      >
+        <div class="reviewerConfirmation">
+          Group
+          <span class="groupName">
+            ${this.pendingConfirmationDetails?.group.name}
+          </span>
+          has
+          <span class="groupSize">
+            ${this.pendingConfirmationDetails?.count}
+          </span>
+          members.
+          <br />
+          Are you sure you want to add them all?
+        </div>
+        <div class="reviewerConfirmationButtons">
+          <gr-button @click=${this.confirmPendingReviewer}>Yes</gr-button>
+          <gr-button @click=${this.cancelPendingReviewer}>No</gr-button>
+        </div>
+      </dialog>
+    `;
+  }
+
+  private renderPrivateVisiblityInfo() {
+    const addedAccounts = [
+      ...(this.reviewersList?.additions() ?? []),
+      ...(this.ccsList?.additions() ?? []),
+    ];
+    if (!this.change?.is_private || !addedAccounts.length) return nothing;
+    return html`
+      <div class="privateVisiblityInfo">
+        <gr-icon icon="info"></gr-icon>
+        <div>
+          Adding a reviewer/CC will make this private change visible to them
+        </div>
+      </div>
+    `;
+  }
+
+  private renderLabels() {
+    if (!this.change || !this.account || !this.permittedLabels) return;
+    return html`
+      <gr-endpoint-decorator name="reply-label-scores">
+        <gr-label-scores
+          id="labelScores"
+          .account=${this.account}
+          .change=${this.change}
+          @labels-changed=${this._handleLabelsChanged}
+          .permittedLabels=${this.permittedLabels}
+        ></gr-label-scores>
+        <gr-endpoint-param
+          name="change"
+          .value=${this.change}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+      <div id="pluginMessage">${this.pluginMessage}</div>
+    `;
+  }
+
+  private renderPatchsetLevelComment() {
+    if (!this.patchsetLevelComment)
+      this.patchsetLevelComment = createPatchsetLevelUnsavedDraft(
+        this.latestPatchNum,
+        this.patchsetLevelDraftMessage,
+        !this.patchsetLevelDraftIsResolved
+      );
+    return html`
+      <gr-comment
+        id="patchsetLevelComment"
+        .comment=${this.patchsetLevelComment}
+        .comments=${[this.patchsetLevelComment]}
+        @comment-unresolved-changed=${(e: ValueChangedEvent<boolean>) => {
+          this.patchsetLevelDraftIsResolved = !e.detail.value;
+        }}
+        @comment-text-changed=${(e: ValueChangedEvent<string>) => {
+          this.patchsetLevelDraftMessage = e.detail.value;
+        }}
+        .messagePlaceholder=${this.messagePlaceholder}
+        hide-header
+        permanent-editing-mode
+      ></gr-comment>
+    `;
+  }
+
+  private renderReplyText() {
+    if (!this.change) return;
+    return html`
+      <div
+        class=${classMap({
+          patchsetLevelContainer: true,
+          [this.getUnresolvedPatchsetLevelClass(
+            this.patchsetLevelDraftIsResolved
+          )]: true,
+        })}
+      >
+        <gr-endpoint-decorator name="reply-text">
+          ${this.renderPatchsetLevelComment()}
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    `;
+  }
+
+  private renderDraftsSection() {
+    if (this.computeHideDraftList(this.draftCommentThreads)) return;
+    return html`
+      <section class="draftsContainer">
+        <div class="includeComments">
+          <input
+            type="checkbox"
+            id="includeComments"
+            @change=${this.handleIncludeCommentsChanged}
+            ?checked=${this.includeComments}
+          />
+          <label for="includeComments"
+            >Publish ${this.computeDraftsTitle(this.draftCommentThreads)}</label
+          >
+        </div>
+        ${when(
+          this.includeComments,
+          () => html`
+            <gr-thread-list
+              id="commentList"
+              .threads=${this.draftCommentThreads}
+              hide-dropdown
+            >
+            </gr-thread-list>
+          `
+        )}
+        <span
+          id="savingLabel"
+          class=${this.computeSavingLabelClass(this.savingComments)}
+        >
+          Saving comments...
+        </span>
+      </section>
+    `;
+  }
+
+  private renderAttentionSummarySection() {
+    if (this.attentionExpanded) return;
+    return html`
+      <section class="attention">
+        <div class="attentionSummary">
+          <div>
+            ${when(
+              this.computeShowNoAttentionUpdate(),
+              () => html` <span>${this.computeDoNotUpdateMessage()}</span> `
+            )}
+            ${when(
+              !this.computeShowNoAttentionUpdate(),
+              () => html`
+                <span>Bring to attention of</span>
+                ${this.computeNewAttentionAccounts().map(
+                  account => html`
+                    <gr-account-label
+                      .account=${account}
+                      .forceAttention=${this.computeHasNewAttention(account)}
+                      .selected=${this.computeHasNewAttention(account)}
+                      .hideHovercard=${true}
+                      .selectionChipStyle=${true}
+                      @click=${this.handleAttentionClick}
+                    ></gr-account-label>
+                  `
+                )}
+              `
+            )}
+            <gr-tooltip-content
+              has-tooltip
+              title=${this.computeAttentionButtonTitle()}
+            >
+              <gr-button
+                class="edit-attention-button"
+                @click=${this.handleAttentionModify}
+                ?disabled=${this.sendDisabled}
+                link
+                position-below
+                data-label="Edit"
+                data-action-type="change"
+                data-action-key="edit"
+                role="button"
+                tabindex="0"
+              >
+                <div>
+                  <gr-icon icon="edit" filled small></gr-icon>
+                  <span>Modify</span>
+                </div>
+              </gr-button>
+            </gr-tooltip-content>
+          </div>
+          <div>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              target="_blank"
+            >
+              <gr-icon icon="help" title="read documentation"></gr-icon>
+            </a>
+          </div>
+        </div>
+      </section>
+    `;
+  }
+
+  private renderAttentionDetailsSection() {
+    if (!this.attentionExpanded) return;
+    return html`
+      <section class="attention-detail">
+        <div class="attentionDetailsTitle">
+          <div>
+            <span>Modify attention to</span>
+          </div>
+          <div></div>
+          <div>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              target="_blank"
+            >
+              <gr-icon icon="help" title="read documentation"></gr-icon>
+            </a>
+          </div>
+        </div>
+        <div class="selectUsers">
+          <span
+            >Select chips to set who will be in the attention set after sending
+            this reply</span
+          >
+        </div>
+        <div class="peopleList">
+          <div class="peopleListLabel">Owner</div>
+          <div class="peopleListValues">
+            <gr-account-label
+              .account=${this.change?.owner}
+              ?forceAttention=${this.computeHasNewAttention(this.change?.owner)}
+              .selected=${this.computeHasNewAttention(this.change?.owner)}
+              .hideHovercard=${true}
+              .selectionChipStyle=${true}
+              @click=${this.handleAttentionClick}
+            >
+            </gr-account-label>
+          </div>
+        </div>
+        ${when(
+          this.uploader,
+          () => html`
+            <div class="peopleList">
+              <div class="peopleListLabel">Uploader</div>
+              <div class="peopleListValues">
+                <gr-account-label
+                  .account=${this.uploader}
+                  ?forceAttention=${this.computeHasNewAttention(this.uploader)}
+                  .selected=${this.computeHasNewAttention(this.uploader)}
+                  .hideHovercard=${true}
+                  .selectionChipStyle=${true}
+                  @click=${this.handleAttentionClick}
+                >
+                </gr-account-label>
+              </div>
+            </div>
+          `
+        )}
+        <div class="peopleList">
+          <div class="peopleListLabel">Reviewers</div>
+          <div class="peopleListValues">
+            ${removeServiceUsers(this.reviewers).map(
+              account => html`
+                <gr-account-label
+                  .account=${account}
+                  ?forceAttention=${this.computeHasNewAttention(account)}
+                  .selected=${this.computeHasNewAttention(account)}
+                  .hideHovercard=${true}
+                  .selectionChipStyle=${true}
+                  @click=${this.handleAttentionClick}
+                >
+                </gr-account-label>
+              `
+            )}
+          </div>
+        </div>
+
+        ${when(
+          this.attentionCcsCount,
+          () => html`
+            <div class="peopleList">
+              <div class="peopleListLabel">CC</div>
+              <div class="peopleListValues">
+                ${removeServiceUsers(this.ccs).map(
+                  account => html`
+                    <gr-account-label
+                      .account=${account}
+                      ?forceAttention=${this.computeHasNewAttention(account)}
+                      .selected=${this.computeHasNewAttention(account)}
+                      .hideHovercard=${true}
+                      .selectionChipStyle=${true}
+                      @click=${this.handleAttentionClick}
+                    >
+                    </gr-account-label>
+                  `
+                )}
+              </div>
+            </div>
+          `
+        )}
+        ${when(
+          this.computeShowAttentionTip(),
+          () => html`
+            <div class="attentionTip">
+              <gr-icon icon="lightbulb"></gr-icon>
+              Please be mindful of requiring attention from too many users.
+            </div>
+          `
+        )}
+      </section>
+    `;
+  }
+
+  private renderActionsSection() {
+    return html`
+      <section class="actions">
+        <div class="left">
+          ${when(
+            this.knownLatestState === LatestPatchState.CHECKING,
+            () => html`
+              <span id="checkingStatusLabel">
+                Checking whether patch ${this.patchNum} is latest...
+              </span>
+            `
+          )}
+          ${when(
+            this.knownLatestState === LatestPatchState.NOT_LATEST,
+            () => html`
+              <span id="notLatestLabel">
+                ${this.computePatchSetWarning()}
+                <gr-button link @click=${this._reload}>Reload</gr-button>
+              </span>
+            `
+          )}
+        </div>
+        <div class="right">
+          <gr-button
+            link
+            id="cancelButton"
+            class="action cancel"
+            @click=${this.cancelTapHandler}
+            >Cancel</gr-button
+          >
+          ${when(
+            this.canBeStarted,
+            () => html`
+              <!-- Use 'Send' here as the change may only about reviewers / ccs
+            and when this button is visible, the next button will always
+            be 'Start review' -->
+              <gr-tooltip-content has-tooltip title=${this.saveTooltip}>
+                <gr-button
+                  link
+                  ?disabled=${this.knownLatestState ===
+                  LatestPatchState.NOT_LATEST}
+                  class="action save"
+                  @click=${this.saveClickHandler}
+                  >Send As WIP</gr-button
+                >
+              </gr-tooltip-content>
+            `
+          )}
+          <gr-tooltip-content
+            has-tooltip
+            title=${this.computeSendButtonTooltip(
+              this.canBeStarted,
+              this.commentEditing
+            )}
+          >
+            <gr-button
+              id="sendButton"
+              primary
+              ?disabled=${this.sendDisabled}
+              class="action send"
+              @click=${this.sendTapHandler}
+              >${this.sendButtonLabel}
+            </gr-button>
+          </gr-tooltip-content>
+        </div>
+      </section>
+    `;
+  }
+
   /**
    * Note that this method is not actually *opening* the dialog. Opening and
    * showing the dialog is dealt with by the overlay. This method is used by the
@@ -433,166 +1276,125 @@
   open(focusTarget?: FocusTarget, quote?: string) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
-    this.changeService.fetchChangeUpdates(this.change).then(result => {
-      this.knownLatestState = result.isLatest
-        ? LatestPatchState.LATEST
-        : LatestPatchState.NOT_LATEST;
-    });
+    this.getChangeModel()
+      .fetchChangeUpdates(this.change)
+      .then(result => {
+        this.knownLatestState = result.isLatest
+          ? LatestPatchState.LATEST
+          : LatestPatchState.NOT_LATEST;
+      });
 
-    this._focusOn(focusTarget);
+    this.focusOn(focusTarget);
     if (quote?.length) {
       // If a reply quote has been provided, use it.
-      this.draft = quote;
-    } else {
-      // Otherwise, check for an unsaved draft in localstorage.
-      this.draft = this._loadStoredDraft();
+      this.patchsetLevelDraftMessage = quote;
     }
     if (this.restApiService.hasPendingDiffDrafts()) {
-      this._savingComments = true;
+      this.savingComments = true;
       this.restApiService.awaitPendingDiffDrafts().then(() => {
         fireEvent(this, 'comment-refresh');
-        this._savingComments = false;
+        this.savingComments = false;
       });
     }
   }
 
-  _computeHasDrafts(
-    draft: string,
-    draftCommentThreads: PolymerDeepPropertyChange<
-      CommentThread[] | undefined,
-      CommentThread[] | undefined
-    >
-  ) {
-    if (draftCommentThreads.base === undefined) return false;
-    return draft.length > 0 || draftCommentThreads.base.length > 0;
+  hasDrafts() {
+    return (
+      this.patchsetLevelDraftMessage.length > 0 ||
+      this.draftCommentThreads.length > 0
+    );
   }
 
   override focus() {
-    this._focusOn(FocusTarget.ANY);
+    this.focusOn(FocusTarget.ANY);
   }
 
-  getFocusStops() {
-    const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
-    return {
-      start: this.$.reviewers.focusStart,
-      end,
-    };
+  private handleIncludeCommentsChanged(e: Event) {
+    if (!(e.target instanceof HTMLInputElement)) return;
+    this.includeComments = e.target.checked;
   }
 
-  setLabelValue(label: string, value: string) {
-    const selectorEl = this.getLabelScores().shadowRoot?.querySelector(
-      `gr-label-score-row[name="${label}"]`
-    );
-    if (!selectorEl) {
-      return;
-    }
-    (selectorEl as GrLabelScoreRow).setSelectedValue(value);
+  setLabelValue(label: string, value: string): void {
+    const selectorEl =
+      this.getLabelScores().shadowRoot?.querySelector<GrLabelScoreRow>(
+        `gr-label-score-row[name="${label}"]`
+      );
+    selectorEl?.setSelectedValue(value);
   }
 
   getLabelValue(label: string) {
-    const selectorEl = this.getLabelScores().shadowRoot?.querySelector(
-      `gr-label-score-row[name="${label}"]`
-    );
-    if (!selectorEl) {
-      return null;
-    }
-
-    return (selectorEl as GrLabelScoreRow).selectedValue;
+    const selectorEl =
+      this.getLabelScores().shadowRoot?.querySelector<GrLabelScoreRow>(
+        `gr-label-score-row[name="${label}"]`
+      );
+    return selectorEl?.selectedValue;
   }
 
-  @observe('_ccs.splices')
-  _ccsChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
-    this._reviewerTypeChanged(splices, ReviewerType.CC);
-  }
-
-  @observe('_reviewers.splices')
-  _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
-    this._reviewerTypeChanged(splices, ReviewerType.REVIEWER);
-  }
-
-  _reviewerTypeChanged(
-    splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>,
-    reviewerType: ReviewerType
-  ) {
-    if (splices && splices.indexSplices) {
-      this._reviewersMutated = true;
-      let key: AccountId | EmailAddress | GroupId | undefined;
-      let index;
-      let account;
-      // Remove any accounts that already exist as a CC for reviewer
-      // or vice versa.
-      const isReviewer = ReviewerType.REVIEWER === reviewerType;
-      for (const splice of splices.indexSplices) {
-        for (let i = 0; i < splice.addedCount; i++) {
-          account = splice.object[splice.index + i];
-          key = accountOrGroupKey(account);
-          const array = isReviewer ? this._ccs : this._reviewers;
-          index = array.findIndex(
-            account => accountOrGroupKey(account) === key
-          );
-          if (index >= 0) {
-            this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
-            const moveFrom = isReviewer ? 'CC' : 'reviewer';
-            const moveTo = isReviewer ? 'reviewer' : 'CC';
-            const id = account.name || key;
-            const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
-            fireAlert(this, message);
-          }
-        }
-      }
+  // TODO: Combine logic into handleReviewersChanged & handleCCsChanged and
+  // remove account-added event from GrAccountList.
+  handleAccountAdded(e: CustomEvent<AccountInputDetail>) {
+    const account = e.detail.account;
+    const key = getUserId(account);
+    const reviewerType =
+      (e.target as GrAccountList).getAttribute('id') === 'ccs'
+        ? ReviewerType.CC
+        : ReviewerType.REVIEWER;
+    const isReviewer = ReviewerType.REVIEWER === reviewerType;
+    const reviewerList = isReviewer ? this.ccsList : this.reviewersList;
+    // Remove any accounts that already exist as a CC for reviewer
+    // or vice versa.
+    if (reviewerList?.removeAccount(account)) {
+      const moveFrom = isReviewer ? 'CC' : 'reviewer';
+      const moveTo = isReviewer ? 'reviewer' : 'CC';
+      const id = account.name || key;
+      const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
+      fireAlert(this, message);
     }
   }
 
-  getUnresolvedPatchsetLevelClass(isResolvedPatchsetLevelComment: boolean) {
-    return isResolvedPatchsetLevelComment ? 'resolved' : 'unresolved';
+  getUnresolvedPatchsetLevelClass(patchsetLevelDraftIsResolved: boolean) {
+    return patchsetLevelDraftIsResolved ? 'resolved' : 'unresolved';
   }
 
-  computeReviewers(change: ChangeInfo) {
+  computeReviewers() {
     const reviewers: ReviewerInput[] = [];
-    const addToReviewInput = (
-      additions: AccountAddition[],
-      state?: ReviewerState
-    ) => {
-      additions.forEach(addition => {
-        const reviewer = mapReviewer(addition);
-        if (state) reviewer.state = state;
-        reviewers.push(reviewer);
-      });
-    };
-    addToReviewInput(this.$.reviewers.additions(), ReviewerState.REVIEWER);
-    addToReviewInput(this.$.ccs.additions(), ReviewerState.CC);
-    addToReviewInput(
-      this.$.reviewers.removals().filter(
-        r =>
-          isReviewerOrCC(change, r) &&
-          // ignore removal from reviewer request if being added to CC
-          !this.$.ccs
-            .additions()
-            .some(
-              account =>
-                mapReviewer(account).reviewer === mapReviewer(r).reviewer
-            )
-      ),
-      ReviewerState.REMOVED
+    const reviewerAdditions = this.reviewersList?.additions() ?? [];
+    reviewers.push(
+      ...reviewerAdditions.map(v => toReviewInput(v, ReviewerState.REVIEWER))
     );
-    addToReviewInput(
-      this.$.ccs.removals().filter(
-        r =>
-          isReviewerOrCC(change, r) &&
-          // ignore removal from CC request if being added as reviewer
-          !this.$.reviewers
-            .additions()
-            .some(
-              account =>
-                mapReviewer(account).reviewer === mapReviewer(r).reviewer
-            )
-      ),
-      ReviewerState.REMOVED
+
+    const ccAdditions = this.ccsList?.additions() ?? [];
+    reviewers.push(...ccAdditions.map(v => toReviewInput(v, ReviewerState.CC)));
+
+    // ignore removal from reviewer request if being added as CC
+    let removals = difference(
+      this.reviewersList?.removals() ?? [],
+      ccAdditions,
+      (a, b) => getUserId(a) === getUserId(b)
+    ).map(v => toReviewInput(v, ReviewerState.REMOVED));
+    reviewers.push(...removals);
+
+    // ignore removal from CC request if being added as reviewer
+    removals = difference(
+      this.ccsList?.removals() ?? [],
+      reviewerAdditions,
+      (a, b) => getUserId(a) === getUserId(b)
+    ).map(v => toReviewInput(v, ReviewerState.REMOVED));
+    reviewers.push(...removals);
+
+    // The owner is returned as a reviewer in the ChangeInfo object in some
+    // cases, and trying to remove the owner as a reviewer returns in a
+    // 500 server error.
+    return reviewers.filter(
+      reviewerInput =>
+        !(
+          this.change?.owner._account_id === reviewerInput.reviewer &&
+          reviewerInput.state === ReviewerState.REMOVED
+        )
     );
-    return reviewers;
   }
 
-  send(includeComments: boolean, startReview: boolean) {
+  async send(includeComments: boolean, startReview: boolean) {
     this.reporting.time(Timing.SEND_REPLY);
     const labels = this.getLabelScores().getLabelValues();
 
@@ -605,45 +1407,73 @@
 
     if (startReview) {
       reviewInput.ready = true;
+    } else if (this.change?.work_in_progress) {
+      const addedAccounts = [
+        ...(this.reviewersList?.additions() ?? []),
+        ...(this.ccsList?.additions() ?? []),
+      ];
+      if (addedAccounts.length > 0) {
+        fireAlert(this, 'Reviewers are not notified for WIP changes');
+      }
     }
 
-    const reason = getReplyByReason(this._account, this.serverConfig);
+    this.disabled = true;
+
+    const reason = getReplyByReason(this.account, this.serverConfig);
 
     reviewInput.ignore_automatic_attention_set_rules = true;
     reviewInput.add_to_attention_set = [];
-    for (const user of this._newAttentionSet) {
-      if (!this._currentAttentionSet.has(user)) {
-        reviewInput.add_to_attention_set.push({user, reason});
+    const allAccounts = this.allAccounts();
+
+    const newAttentionSetAdditions: AccountInfo[] = Array.from(
+      this.newAttentionSet
+    )
+      .filter(user => !this.currentAttentionSet.has(user))
+      .map(user => allAccounts.find(a => getUserId(a) === user))
+      .filter(isDefined);
+
+    const newAttentionSetUsers = (
+      await Promise.all(
+        newAttentionSetAdditions.map(a =>
+          this.getAccountsModel().fillDetails(a)
+        )
+      )
+    ).filter(isDefined);
+
+    for (const user of newAttentionSetUsers) {
+      let reason;
+      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+        reason =
+          getMentionedReason(
+            this.draftCommentThreads,
+            this.account,
+            user,
+            this.serverConfig
+          ) ?? '';
+      } else {
+        reason = getReplyByReason(this.account, this.serverConfig);
       }
+      reviewInput.add_to_attention_set.push({user: getUserId(user), reason});
     }
     reviewInput.remove_from_attention_set = [];
-    for (const user of this._currentAttentionSet) {
-      if (!this._newAttentionSet.has(user)) {
+    for (const user of this.currentAttentionSet) {
+      if (!this.newAttentionSet.has(user)) {
         reviewInput.remove_from_attention_set.push({user, reason});
       }
     }
     this.reportAttentionSetChanges(
-      this._attentionExpanded,
+      this.attentionExpanded,
       reviewInput.add_to_attention_set,
       reviewInput.remove_from_attention_set
     );
 
-    if (this.draft) {
-      const comment: CommentInput = {
-        message: this.draft,
-        unresolved: !this._isResolvedPatchsetLevelComment,
-      };
-      reviewInput.comments = {
-        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
-      };
-    }
+    await this.patchsetLevelGrComment?.save();
 
     assertIsDefined(this.change, 'change');
-    reviewInput.reviewers = this.computeReviewers(this.change);
-    this.disabled = true;
+    reviewInput.reviewers = this.computeReviewers();
 
-    const errFn = (r?: Response | null) => this._handle400Error(r);
-    return this._saveReview(reviewInput, errFn)
+    const errFn = (r?: Response | null) => this.handle400Error(r);
+    return this.saveReview(reviewInput, errFn)
       .then(response => {
         if (!response) {
           // Null or undefined response indicates that an error handler
@@ -655,8 +1485,8 @@
           return;
         }
 
-        this.draft = '';
-        this._includeComments = true;
+        this.patchsetLevelDraftMessage = '';
+        this.includeComments = true;
         this.dispatchEvent(
           new CustomEvent('send', {
             composed: true,
@@ -676,48 +1506,39 @@
       });
   }
 
-  _focusOn(section?: FocusTarget) {
+  focusOn(section?: FocusTarget) {
     // Safeguard- always want to focus on something.
     if (!section || section === FocusTarget.ANY) {
-      section = this._chooseFocusTarget();
+      section = this.chooseFocusTarget();
     }
-    if (section === FocusTarget.BODY) {
-      const textarea = queryAndAssert<GrTextarea>(this, 'gr-textarea');
-      setTimeout(() => textarea.getNativeTextarea().focus());
-    } else if (section === FocusTarget.REVIEWERS) {
-      const reviewerEntry = this.$.reviewers.focusStart;
-      setTimeout(() => reviewerEntry.focus());
-    } else if (section === FocusTarget.CCS) {
-      const ccEntry = this.$.ccs.focusStart;
-      setTimeout(() => ccEntry.focus());
-    }
+    whenVisible(this, () => {
+      if (section === FocusTarget.REVIEWERS) {
+        const reviewerEntry = this.reviewersList?.focusStart;
+        reviewerEntry?.focus();
+      } else if (section === FocusTarget.CCS) {
+        const ccEntry = this.ccsList?.focusStart;
+        ccEntry?.focus();
+      } else {
+        this.patchsetLevelGrComment?.focus();
+      }
+    });
   }
 
-  _chooseFocusTarget() {
-    // If we are the owner and the reviewers field is empty, focus on that.
-    if (
-      this._account &&
-      this.change &&
-      this.change.owner &&
-      this._account._account_id === this.change.owner._account_id &&
-      (!this._reviewers || this._reviewers.length === 0)
-    ) {
-      return FocusTarget.REVIEWERS;
-    }
-
-    // Default to BODY.
-    return FocusTarget.BODY;
+  chooseFocusTarget() {
+    if (!isOwner(this.change, this.account)) return FocusTarget.BODY;
+    if (hasHumanReviewer(this.change)) return FocusTarget.BODY;
+    return FocusTarget.REVIEWERS;
   }
 
-  _isOwner(account?: AccountInfo, change?: ChangeInfo) {
+  isOwner(account?: AccountInfo, change?: ParsedChangeInfo | ChangeInfo) {
     if (!account || !change || !change.owner) return false;
     return account._account_id === change.owner._account_id;
   }
 
-  _handle400Error(r?: Response | null) {
+  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
+    // 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 error handling would result in a large
     // JSON response body being displayed to the user in the gr-error-manager
@@ -736,7 +1557,7 @@
     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
+      // Only perform custom error handling for 400s and a parsable
       // ReviewResult response.
       if (response.status === 400 && result && result.reviewers) {
         const errors: string[] = [];
@@ -752,240 +1573,177 @@
     });
   }
 
-  _computeHideDraftList(draftCommentThreads?: CommentThread[]) {
+  computeHideDraftList(draftCommentThreads?: CommentThread[]) {
     return !draftCommentThreads || draftCommentThreads.length === 0;
   }
 
-  _computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
+  computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
     const total = draftCommentThreads ? draftCommentThreads.length : 0;
     return pluralize(total, 'Draft');
   }
 
-  _computeMessagePlaceholder(canBeStarted: boolean) {
-    return canBeStarted
+  computeMessagePlaceholder() {
+    this.messagePlaceholder = this.canBeStarted
       ? 'Add a note for your reviewers...'
       : 'Say something nice...';
   }
 
-  @observe('change.reviewers.*', 'change.owner')
-  _changeUpdated(
-    changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
-    owner: AccountInfo
-  ) {
-    if (changeRecord === undefined || owner === undefined) return;
-    this._rebuildReviewerArrays(changeRecord.base, owner);
+  rebuildReviewerArrays() {
+    if (!this.change?.owner || !this.change?.reviewers) return;
+    const getAccounts = (state: ReviewerState) =>
+      Object.values(this.change?.reviewers[state] ?? []).filter(
+        account => account._account_id !== this.change!.owner._account_id
+      );
+
+    this.ccs = getAccounts(ReviewerState.CC);
+    this.reviewers = getAccounts(ReviewerState.REVIEWER);
   }
 
-  _rebuildReviewerArrays(changeReviewers: Reviewers, owner: AccountInfo) {
-    this._owner = owner;
-
-    const reviewers = [];
-    const ccs = [];
-
-    if (changeReviewers) {
-      for (const key of Object.keys(changeReviewers)) {
-        if (key !== 'REVIEWER' && key !== 'CC') {
-          this.reporting.error(new Error(`Unexpected reviewer state: ${key}`));
-          continue;
-        }
-        if (!changeReviewers[key]) continue;
-        for (const entry of changeReviewers[key]!) {
-          if (entry._account_id === owner._account_id) {
-            continue;
-          }
-          switch (key) {
-            case 'REVIEWER':
-              reviewers.push(entry);
-              break;
-            case 'CC':
-              ccs.push(entry);
-              break;
-          }
-        }
-      }
-    }
-
-    this._ccs = ccs;
-    this._reviewers = reviewers;
+  handleAttentionModify() {
+    this.attentionExpanded = true;
   }
 
-  _handleAttentionModify() {
-    this._attentionExpanded = true;
-  }
-
-  @observe('_attentionExpanded')
-  _onAttentionExpandedChange() {
+  onAttentionExpandedChange() {
     // If the attention-detail section is expanded without dispatching this
     // event, then the dialog may expand beyond the screen's bottom border.
     fireEvent(this, 'iron-resize');
   }
 
-  _showAttentionSummary(attentionExpanded?: boolean) {
-    return !attentionExpanded;
-  }
-
-  _showAttentionDetails(attentionExpanded?: boolean) {
-    return attentionExpanded;
-  }
-
-  _computeAttentionButtonTitle(sendDisabled?: boolean) {
+  computeAttentionButtonTitle(sendDisabled?: boolean) {
     return sendDisabled
       ? 'Modify the attention set by adding a comment or use the account ' +
           'hovercard in the change page.'
       : 'Edit attention set changes';
   }
 
-  _handleAttentionClick(e: Event) {
-    const id = (e.target as GrAccountChip)?.account?._account_id;
-    if (!id) return;
+  handleAttentionClick(e: Event) {
+    const targetAccount = (e.target as GrAccountChip)?.account;
+    if (!targetAccount) return;
+    const id = getUserId(targetAccount);
+    if (!id || !this.account || !this.change?.owner) return;
 
-    const selfId = (this._account && this._account._account_id) || -1;
-    const ownerId =
-      (this.change && this.change.owner && this.change.owner._account_id) || -1;
-    const self = id === selfId ? '_SELF' : '';
-    const role = id === ownerId ? '_OWNER' : '_REVIEWER';
+    const self = id === getUserId(this.account) ? '_SELF' : '';
+    const role = id === getUserId(this.change.owner) ? 'OWNER' : '_REVIEWER';
 
-    if (this._newAttentionSet.has(id)) {
-      this._newAttentionSet.delete(id);
+    if (this.newAttentionSet.has(id)) {
+      this.newAttentionSet.delete(id);
       this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `REMOVE${self}${role}`,
       });
     } else {
-      this._newAttentionSet.add(id);
+      this.newAttentionSet.add(id);
       this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `ADD${self}${role}`,
       });
     }
 
-    // Ensure that Polymer picks up the change.
-    this._newAttentionSet = new Set(this._newAttentionSet);
+    this.requestUpdate();
   }
 
-  _computeHasNewAttention(
-    account?: AccountInfo,
-    newAttention?: Set<AccountId>
-  ) {
-    return (
-      newAttention &&
-      account &&
-      account._account_id &&
-      newAttention.has(account._account_id)
-    );
+  computeHasNewAttention(account?: AccountInfo) {
+    return !!(account && this.newAttentionSet?.has(getUserId(account)));
   }
 
-  @observe(
-    '_account',
-    '_reviewers.*',
-    '_ccs.*',
-    'change',
-    'draftCommentThreads',
-    '_includeComments',
-    '_labelsChanged',
-    'hasDrafts'
-  )
-  _computeNewAttention(
-    currentUser?: AccountInfo,
-    reviewers?: PolymerDeepPropertyChange<
-      AccountInfoInput[],
-      AccountInfoInput[]
-    >,
-    ccs?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
-    change?: ChangeInfo,
-    draftCommentThreads?: CommentThread[],
-    includeComments?: boolean,
-    _labelsChanged?: boolean,
-    hasDrafts?: boolean
-  ) {
+  computeNewAttention() {
     if (
-      currentUser === undefined ||
-      currentUser._account_id === undefined ||
-      reviewers === undefined ||
-      ccs === undefined ||
-      change === undefined ||
-      draftCommentThreads === undefined ||
-      includeComments === undefined
+      this.account?._account_id === undefined ||
+      this.change === undefined ||
+      this.includeComments === undefined
     ) {
       return;
     }
     // The draft comments are only relevant for the attention set as long as the
     // user actually plans to publish their drafts.
-    draftCommentThreads = includeComments ? draftCommentThreads : [];
-    const hasVote = !!_labelsChanged;
-    const isOwner = this._isOwner(currentUser, change);
-    const isUploader = this._uploader?._account_id === currentUser._account_id;
-    this._attentionCcsCount = removeServiceUsers(ccs.base).length;
-    this._currentAttentionSet = new Set(
-      Object.keys(change.attention_set || {}).map(id => Number(id) as AccountId)
+    const draftCommentThreads = this.includeComments
+      ? this.draftCommentThreads
+      : [];
+    const hasVote = !!this.labelsChanged;
+    const isOwner = this.isOwner(this.account, this.change);
+    const isUploader = this.uploader?._account_id === this.account._account_id;
+
+    this.attentionCcsCount = removeServiceUsers(this.ccs).length;
+    this.currentAttentionSet = new Set(
+      Object.keys(this.change.attention_set || {}).map(
+        id => Number(id) as AccountId
+      )
     );
-    const newAttention = new Set(this._currentAttentionSet);
-    if (change.status === ChangeStatus.NEW) {
+    const newAttention = new Set(this.currentAttentionSet);
+
+    for (const user of this.mentionedUsersInUnresolvedDrafts) {
+      newAttention.add(getUserId(user));
+    }
+
+    if (this.change.status === ChangeStatus.NEW) {
       // Add everyone that the user is replying to in a comment thread.
-      this._computeCommentAccounts(draftCommentThreads).forEach(id =>
+      this.computeCommentAccounts(draftCommentThreads).forEach(id =>
         newAttention.add(id)
       );
       // Remove the current user.
-      newAttention.delete(currentUser._account_id);
+      newAttention.delete(this.account._account_id);
       // Add all new reviewers, but not the current reviewer, if they are also
       // sending a draft or a label vote.
       const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
-        !(r._account_id === currentUser._account_id && (hasDrafts || hasVote));
-      reviewers.base
-        .filter(r => r._account_id)
-        .filter(r => r._pendingAdd || (this.canBeStarted && isOwner))
+        !(
+          r._account_id === this.account!._account_id &&
+          (this.hasDrafts() || hasVote)
+        );
+      this.reviewers
+        .filter(r => isAccount(r))
+        .filter(
+          r =>
+            isAccountNewlyAdded(r, ReviewerState.REVIEWER, this.change) ||
+            (this.canBeStarted && isOwner)
+        )
         .filter(notIsReviewerAndHasDraftOrLabel)
-        .forEach(r => newAttention.add(r._account_id!));
+        .forEach(r => newAttention.add((r as AccountInfo)._account_id!));
       // Add owner and uploader, if someone else replies.
-      if (hasDrafts || hasVote) {
-        if (this._uploader?._account_id && !isUploader) {
-          newAttention.add(this._uploader._account_id);
+      if (this.hasDrafts() || hasVote) {
+        if (this.uploader?._account_id && !isUploader) {
+          newAttention.add(this.uploader._account_id);
         }
-        if (change.owner?._account_id && !isOwner) {
-          newAttention.add(change.owner._account_id);
+        if (this.change.owner?._account_id && !isOwner) {
+          newAttention.add(this.change.owner._account_id);
         }
       }
     } else {
       // The only reason for adding someone to the attention set for merged or
       // abandoned changes is that someone makes a comment thread unresolved.
       const hasUnresolvedDraft = draftCommentThreads.some(isUnresolved);
-      if (change.owner && hasUnresolvedDraft) {
-        // A change owner must have an _account_id.
-        newAttention.add(change.owner._account_id!);
+      if (this.change.owner && hasUnresolvedDraft) {
+        // A change owner must have an account_id.
+        newAttention.add(this.change.owner._account_id!);
       }
       // Remove the current user.
-      newAttention.delete(currentUser._account_id);
+      newAttention.delete(this.account._account_id);
     }
     // Finally make sure that everyone in the attention set is still active as
     // owner, reviewer or cc.
-    const allAccountIds = this._allAccounts()
-      .map(a => a._account_id)
+    const allAccountIds = this.allAccounts()
+      .map(a => getUserId(a))
       .filter(id => !!id);
-    this._newAttentionSet = new Set(
-      [...newAttention].filter(id => allAccountIds.includes(id))
-    );
-    this._attentionExpanded = this._computeShowAttentionTip(
-      currentUser,
-      change.owner,
-      this._currentAttentionSet,
-      this._newAttentionSet
-    );
+    this.newAttentionSet = new Set([
+      ...[...newAttention].filter(id => allAccountIds.includes(id)),
+    ]);
+
+    this.attentionExpanded = this.computeShowAttentionTip();
   }
 
-  _computeShowAttentionTip(
-    currentUser?: AccountInfo,
-    owner?: AccountInfo,
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>
-  ) {
-    if (!currentUser || !owner || !currentAttentionSet || !newAttentionSet)
+  computeShowAttentionTip() {
+    if (
+      !this.account ||
+      !this.change?.owner ||
+      !this.currentAttentionSet ||
+      !this.newAttentionSet
+    )
       return false;
-    const isOwner = currentUser._account_id === owner._account_id;
-    const addedIds = [...newAttentionSet].filter(
-      id => !currentAttentionSet.has(id)
+    const isOwner = this.account._account_id === this.change.owner._account_id;
+    const addedIds = [...this.newAttentionSet].filter(
+      id => !this.currentAttentionSet.has(id)
     );
     return isOwner && addedIds.length > 2;
   }
 
-  _computeCommentAccounts(threads: CommentThread[]) {
+  computeCommentAccounts(threads: CommentThread[]) {
     const crLabel = this.change?.labels?.[StandardLabels.CODE_REVIEW];
     const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
     const accountIds = new Set<AccountId>();
@@ -993,7 +1751,7 @@
       const unresolved = isUnresolved(thread);
       thread.comments.forEach(comment => {
         if (comment.author) {
-          // A comment author must have an _account_id.
+          // A comment author must have an account_id.
           const authorId = comment.author._account_id!;
           const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId);
           if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId);
@@ -1003,180 +1761,156 @@
     return accountIds;
   }
 
-  _computeShowNoAttentionUpdate(
-    config?: ServerInfo,
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>,
-    sendDisabled?: boolean
-  ) {
-    return (
-      sendDisabled ||
-      this._computeNewAttentionAccounts(
-        config,
-        currentAttentionSet,
-        newAttentionSet
-      ).length === 0
-    );
+  computeShowNoAttentionUpdate() {
+    return this.sendDisabled || this.computeNewAttentionAccounts().length === 0;
   }
 
-  _computeDoNotUpdateMessage(
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>,
-    sendDisabled?: boolean
-  ) {
-    if (!currentAttentionSet || !newAttentionSet) return '';
-    if (sendDisabled || areSetsEqual(currentAttentionSet, newAttentionSet)) {
+  computeDoNotUpdateMessage() {
+    if (!this.currentAttentionSet || !this.newAttentionSet) return '';
+    if (
+      this.sendDisabled ||
+      areSetsEqual(this.currentAttentionSet, this.newAttentionSet)
+    ) {
       return 'No changes to the attention set.';
     }
-    if (containsAll(currentAttentionSet, newAttentionSet)) {
+    if (containsAll(this.currentAttentionSet, this.newAttentionSet)) {
       return 'No additions to the attention set.';
     }
     this.reporting.error(
+      'computeDoNotUpdateMessage',
       new Error(
-        '_computeDoNotUpdateMessage()' +
+        'computeDoNotUpdateMessage()' +
           'should not be called when users were added to the attention set.'
       )
     );
     return '';
   }
 
-  _computeNewAttentionAccounts(
-    _?: ServerInfo,
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>
-  ) {
-    if (currentAttentionSet === undefined || newAttentionSet === undefined) {
+  computeNewAttentionAccounts(): AccountInfo[] {
+    if (
+      this.currentAttentionSet === undefined ||
+      this.newAttentionSet === undefined
+    ) {
       return [];
     }
-    return [...newAttentionSet]
-      .filter(id => !currentAttentionSet.has(id))
-      .map(id => this._findAccountById(id))
-      .filter(account => !!account);
+    return [...this.newAttentionSet]
+      .filter(id => !this.currentAttentionSet.has(id))
+      .map(id => this.findAccountById(id))
+      .filter(account => !!account) as AccountInfo[];
   }
 
-  _findAccountById(accountId: AccountId) {
-    return this._allAccounts().find(r => r._account_id === accountId);
+  findAccountById(userId: UserId) {
+    return this.allAccounts().find(r => getUserId(r) === userId);
   }
 
-  _allAccounts() {
+  allAccounts() {
     let allAccounts: (AccountInfoInput | GroupInfoInput)[] = [];
     if (this.change && this.change.owner) allAccounts.push(this.change.owner);
-    if (this._uploader) allAccounts.push(this._uploader);
-    if (this._reviewers) allAccounts = [...allAccounts, ...this._reviewers];
-    if (this._ccs) allAccounts = [...allAccounts, ...this._ccs];
+    if (this.uploader) allAccounts.push(this.uploader);
+    if (this.reviewers) allAccounts = [...allAccounts, ...this.reviewers];
+    if (this.ccs) allAccounts = [...allAccounts, ...this.ccs];
     return removeServiceUsers(allAccounts.filter(isAccount));
   }
 
-  /**
-   * The newAttentionSet param is only used to force re-computation.
-   */
-  _removeServiceUsers(accounts: AccountInfo[], _: Set<AccountId>) {
-    return removeServiceUsers(accounts);
-  }
-
-  _computeUploader(change: ChangeInfo) {
+  computeUploader() {
     if (
-      !change ||
-      !change.current_revision ||
-      !change.revisions ||
-      !change.revisions[change.current_revision]
+      !this.change?.current_revision ||
+      !this.change?.revisions?.[this.change.current_revision]
     ) {
-      return undefined;
+      this.uploader = undefined;
+      return;
     }
-    const rev = change.revisions[change.current_revision];
+    const rev = this.change.revisions[this.change.current_revision];
 
     if (
       !rev.uploader ||
-      change.owner._account_id === rev.uploader._account_id
+      this.change?.owner._account_id === rev.uploader._account_id
     ) {
-      return undefined;
+      this.uploader = undefined;
+      return;
     }
-    return rev.uploader;
+    this.uploader = rev.uploader;
   }
 
   /**
    * Generates a function to filter out reviewer/CC entries. When isCCs is
-   * truthy, the function filters out entries that already exist in this._ccs.
-   * When falsy, the function filters entries that exist in this._reviewers.
+   * truthy, the function filters out entries that already exist in this.ccs.
+   * When falsy, the function filters entries that exist in this.reviewers.
    */
-  _filterReviewerSuggestionGenerator(
+  filterReviewerSuggestionGenerator(
     isCCs: boolean
   ): (input: Suggestion) => boolean {
     return suggestion => {
       let entry: AccountInfo | GroupInfo;
       if (isReviewerAccountSuggestion(suggestion)) {
         entry = suggestion.account;
-        if (entry._account_id === this._owner?._account_id) {
+        if (entry._account_id === this.change?.owner?._account_id) {
           return false;
         }
       } else if (isReviewerGroupSuggestion(suggestion)) {
         entry = suggestion.group;
       } else {
         this.reporting.error(
+          'Reviewer Suggestion',
           new Error(`Suggestion is neither account nor group: ${suggestion}`)
         );
         return false;
       }
 
-      const key = accountOrGroupKey(entry);
+      const key = getUserId(entry);
       const finder = (entry: AccountInfo | GroupInfo) =>
-        accountOrGroupKey(entry) === key;
+        getUserId(entry) === key;
       if (isCCs) {
-        return this._ccs.find(finder) === undefined;
+        return this.ccs.find(finder) === undefined;
       }
-      return this._reviewers.find(finder) === undefined;
+      return this.reviewers.find(finder) === undefined;
     };
   }
 
-  _getAccount() {
-    return this.restApiService.getAccount();
-  }
-
-  _cancelTapHandler(e: Event) {
+  cancelTapHandler(e: Event) {
     e.preventDefault();
     this.cancel();
   }
 
-  cancel() {
+  async cancel() {
     assertIsDefined(this.change, 'change');
-    if (!this._owner) throw new Error('missing required _owner property');
+    if (!this.change?.owner) throw new Error('missing required owner property');
     this.dispatchEvent(
       new CustomEvent('cancel', {
         composed: true,
         bubbles: false,
       })
     );
-    queryAndAssert<GrTextarea>(this, 'gr-textarea').closeDropdown();
-    this.$.reviewers.clearPendingRemovals();
-    this._rebuildReviewerArrays(this.change.reviewers, this._owner);
+    await this.patchsetLevelGrComment?.save();
+    this.rebuildReviewerArrays();
   }
 
-  _saveClickHandler(e: Event) {
+  saveClickHandler(e: Event) {
     e.preventDefault();
-    if (!this.$.ccs.submitEntryText()) {
+    if (!this.ccsList?.submitEntryText()) {
       // Do not proceed with the save if there is an invalid email entry in
       // the text field of the CC entry.
       return;
     }
-    this.send(this._includeComments, false);
+    this.send(this.includeComments, false);
   }
 
-  _sendTapHandler(e: Event) {
+  sendTapHandler(e: Event) {
     e.preventDefault();
-    this._submit();
+    this.submit();
   }
 
-  _submit() {
-    if (!this.$.ccs.submitEntryText()) {
+  submit() {
+    if (!this.ccsList?.submitEntryText()) {
       // Do not proceed with the send if there is an invalid email entry in
       // the text field of the CC entry.
       return;
     }
-    if (this._sendDisabled) {
+    if (this.sendDisabled) {
       fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
-    return this.send(this._includeComments, this.canBeStarted).catch(err => {
+    return this.send(this.includeComments, this.canBeStarted).catch(err => {
       this.dispatchEvent(
         new CustomEvent('show-error', {
           bubbles: true,
@@ -1187,7 +1921,7 @@
     });
   }
 
-  _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
+  saveReview(review: ReviewInput, errFn?: ErrorCallback) {
     assertIsDefined(this.change, 'change');
     assertIsDefined(this.patchNum, 'patchNum');
     return this.restApiService.saveChangeReview(
@@ -1198,95 +1932,95 @@
     );
   }
 
-  _reviewerPendingConfirmationUpdated(reviewer: RawAccountInput | null) {
+  pendingConfirmationUpdated(reviewer: RawAccountInput | null) {
     if (reviewer === null) {
-      this.$.reviewerConfirmationOverlay.close();
+      this.reviewerConfirmationModal?.close();
     } else {
-      this._pendingConfirmationDetails =
-        this._ccPendingConfirmation || this._reviewerPendingConfirmation;
-      this.$.reviewerConfirmationOverlay.open();
+      this.pendingConfirmationDetails =
+        this.ccPendingConfirmation || this.reviewerPendingConfirmation;
+      this.reviewerConfirmationModal?.showModal();
     }
   }
 
-  _confirmPendingReviewer() {
-    if (this._ccPendingConfirmation) {
-      this.$.ccs.confirmGroup(this._ccPendingConfirmation.group);
-      this._focusOn(FocusTarget.CCS);
+  confirmPendingReviewer() {
+    this.reviewerConfirmationModal?.close();
+    if (this.ccPendingConfirmation) {
+      this.ccsList?.confirmGroup(this.ccPendingConfirmation.group);
+      this.focusOn(FocusTarget.CCS);
       return;
     }
-    if (this._reviewerPendingConfirmation) {
-      this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
-      this._focusOn(FocusTarget.REVIEWERS);
+    if (this.reviewerPendingConfirmation) {
+      this.reviewersList?.confirmGroup(this.reviewerPendingConfirmation.group);
+      this.focusOn(FocusTarget.REVIEWERS);
       return;
     }
     this.reporting.error(
-      new Error('_confirmPendingReviewer called without pending confirm')
+      'confirmPendingReviewer',
+      new Error('confirmPendingReviewer called without pending confirm')
     );
   }
 
-  _cancelPendingReviewer() {
-    this._ccPendingConfirmation = null;
-    this._reviewerPendingConfirmation = null;
+  cancelPendingReviewer() {
+    this.reviewerConfirmationModal?.close();
+    this.ccPendingConfirmation = null;
+    this.reviewerPendingConfirmation = null;
 
-    const target = this._ccPendingConfirmation
+    const target = this.ccPendingConfirmation
       ? FocusTarget.CCS
       : FocusTarget.REVIEWERS;
-    this._focusOn(target);
+    this.focusOn(target);
   }
 
-  _getStorageLocation(): StorageLocation {
-    assertIsDefined(this.change, 'change');
-    return {
-      changeNum: this.change._number,
-      patchNum: '@change',
-      path: '@change',
-    };
-  }
-
-  _loadStoredDraft() {
-    const draft = this.storage.getDraftComment(this._getStorageLocation());
-    return draft?.message ?? '';
-  }
-
-  _handleAccountTextEntry() {
+  handleAccountTextEntry() {
     // When either of the account entries has input added to the autocomplete,
     // it should trigger the save button to enable/
     //
     // Note: if the text is removed, the save button will not get disabled.
-    this._reviewersMutated = true;
+    this.reviewersMutated = true;
   }
 
-  _draftChanged(newDraft: string, oldDraft?: string) {
-    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());
-        } else if (newDraft.length) {
-          this.storage.setDraftComment(this._getStorageLocation(), this.draft);
-        }
-      },
-      STORAGE_DEBOUNCE_INTERVAL_MS
+  private alreadyExists(ccs: AccountInput[], user: AccountInfoInput) {
+    return ccs
+      .filter(cc => isAccount(cc))
+      .some(cc => getUserId(cc) === getUserId(user));
+  }
+
+  private isAlreadyReviewerOrCC(user: AccountInfo) {
+    return (
+      this.alreadyExists(this.reviewers, user) ||
+      this.alreadyExists(this._ccs, user)
     );
   }
 
-  _handleHeightChanged() {
-    fireEvent(this, 'autogrow');
-  }
-
-  getLabelScores() {
-    return this.$.labelScores || queryAndAssert(this, 'gr-label-scores');
+  getLabelScores(): GrLabelScores {
+    return this.labelScores || queryAndAssert(this, 'gr-label-scores');
   }
 
   _handleLabelsChanged() {
-    this._labelsChanged =
+    this.labelsChanged =
       Object.keys(this.getLabelScores().getLabelValues(false)).length !== 0;
   }
 
-  _isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) {
-    return knownLatestState === value;
+  handleReviewersChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) {
+    this.reviewers = [...e.detail.value];
+    this.reviewersMutated = true;
+  }
+
+  handleCcsChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) {
+    this.ccs = [...e.detail.value];
+    this.reviewersMutated = true;
+  }
+
+  handleReviewersConfirmationChanged(
+    e: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
+  ) {
+    this.reviewerPendingConfirmation = e.detail.value;
+  }
+
+  handleCcsConfirmationChanged(
+    e: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
+  ) {
+    this.ccPendingConfirmation = e.detail.value;
   }
 
   _reload() {
@@ -1294,98 +2028,96 @@
     this.cancel();
   }
 
-  _computeSendButtonLabel(canBeStarted: boolean) {
-    return canBeStarted
+  computeSendButtonLabel() {
+    this.sendButtonLabel = this.canBeStarted
       ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW
       : ButtonLabels.SEND;
   }
 
-  _computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) {
+  computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) {
     if (commentEditing) {
       return ButtonTooltips.DISABLED_COMMENT_EDITING;
     }
     return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
   }
 
-  _computeSavingLabelClass(savingComments: boolean) {
+  computeSavingLabelClass(savingComments: boolean) {
     return savingComments ? 'saving' : '';
   }
 
-  _computeSendButtonDisabled(
-    canBeStarted?: boolean,
-    draftCommentThreads?: CommentThread[],
-    text?: string,
-    reviewersMutated?: boolean,
-    labelsChanged?: boolean,
-    includeComments?: boolean,
-    disabled?: boolean,
-    commentEditing?: boolean,
-    change?: ChangeInfo,
-    account?: AccountInfo
-  ) {
+  computeSendButtonDisabled() {
     if (
-      canBeStarted === undefined ||
-      draftCommentThreads === undefined ||
-      text === undefined ||
-      reviewersMutated === undefined ||
-      labelsChanged === undefined ||
-      includeComments === undefined ||
-      disabled === undefined ||
-      commentEditing === undefined ||
-      change?.labels === undefined ||
-      account === undefined
+      this.canBeStarted === undefined ||
+      this.patchsetLevelDraftMessage === undefined ||
+      this.reviewersMutated === undefined ||
+      this.labelsChanged === undefined ||
+      this.includeComments === undefined ||
+      this.disabled === undefined ||
+      this.commentEditing === undefined ||
+      this.change?.labels === undefined ||
+      this.account === undefined
     ) {
       return undefined;
     }
-    if (commentEditing || disabled) {
+    if (this.commentEditing || this.disabled) {
       return true;
     }
-    if (canBeStarted === true) {
+    if (this.canBeStarted === true) {
       return false;
     }
-    const existingVote = Object.values(change.labels).some(
-      label => isDetailedLabelInfo(label) && getApprovalInfo(label, account)
+    const existingVote = Object.values(this.change.labels).some(
+      label =>
+        isDetailedLabelInfo(label) && getApprovalInfo(label, this.account!)
     );
-    const revotingOrNewVote = labelsChanged || existingVote;
-    const hasDrafts = includeComments && draftCommentThreads.length;
+    const revotingOrNewVote = this.labelsChanged || existingVote;
+    const hasDrafts =
+      (this.includeComments && this.draftCommentThreads.length > 0) ||
+      this.patchsetLevelDraftMessage.length > 0;
     return (
-      !hasDrafts && !text.length && !reviewersMutated && !revotingOrNewVote
+      !hasDrafts &&
+      !this.patchsetLevelDraftMessage.length &&
+      !this.reviewersMutated &&
+      !revotingOrNewVote
     );
   }
 
-  _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
-    let str = `Patch ${patchNum} is not latest.`;
-    if (labelsChanged) {
+  computePatchSetWarning() {
+    let str = `Patch ${this.patchNum} is not latest.`;
+    if (this.labelsChanged) {
       str += ' Voting may have no effect.';
     }
     return str;
   }
 
   setPluginMessage(message: string) {
-    this._pluginMessage = message;
+    this.pluginMessage = message;
   }
 
-  _sendDisabledChanged() {
+  sendDisabledChanged() {
     this.dispatchEvent(new CustomEvent('send-disabled-changed'));
   }
 
-  _getReviewerSuggestionsProvider(change: ChangeInfo) {
-    const provider = GrReviewerSuggestionsProvider.create(
+  getReviewerSuggestionsProvider(change?: ChangeInfo | ParsedChangeInfo) {
+    if (!change) return;
+    const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
+      ReviewerState.REVIEWER,
+      this.serverConfig,
+      this.isLoggedIn,
+      change
     );
-    provider.init();
     return provider;
   }
 
-  _getCcSuggestionsProvider(change: ChangeInfo) {
-    const provider = GrReviewerSuggestionsProvider.create(
+  getCcSuggestionsProvider(change?: ChangeInfo | ParsedChangeInfo) {
+    if (!change) return;
+    const provider = new GrReviewerSuggestionsProvider(
       this.restApiService,
-      change._number,
-      SUGGESTIONS_PROVIDERS_USERS_TYPES.CC
+      ReviewerState.CC,
+      this.serverConfig,
+      this.isLoggedIn,
+      change
     );
-    provider.init();
     return provider;
   }
 
@@ -1397,25 +2129,21 @@
     const actions = modified ? ['MODIFIED'] : ['NOT_MODIFIED'];
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
-    const selfId = (this._account && this._account._account_id) || -1;
+    const selfId = (this.account && this.account._account_id) || -1;
     for (const added of addedSet || []) {
       const addedId = added.user;
       const self = addedId === selfId ? '_SELF' : '';
-      const role = addedId === ownerId ? '_OWNER' : '_REVIEWER';
+      const role = addedId === ownerId ? 'OWNER' : '_REVIEWER';
       actions.push('ADD' + self + role);
     }
     for (const removed of removedSet || []) {
       const removedId = removed.user;
       const self = removedId === selfId ? '_SELF' : '';
-      const role = removedId === ownerId ? '_OWNER' : '_REVIEWER';
+      const role = removedId === ownerId ? 'OWNER' : '_REVIEWER';
       actions.push('REMOVE' + self + role);
     }
     this.reporting.reportInteraction('attention-set-actions', {actions});
   }
-
-  _computeAllReviewers() {
-    return [...this._reviewers];
-  }
 }
 
 declare global {
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
deleted file mode 100644
index 4a8b996..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ /dev/null
@@ -1,650 +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;
-      max-height: 90vh;
-    }
-    :host([disabled]) {
-      pointer-events: none;
-    }
-    :host([disabled]) .container {
-      opacity: 0.5;
-    }
-    .container {
-      display: flex;
-      flex-direction: column;
-      max-height: 100%;
-    }
-    section {
-      border-top: 1px solid var(--border-color);
-      flex-shrink: 0;
-      padding: var(--spacing-m) var(--spacing-xl);
-      width: 100%;
-    }
-    section.labelsContainer {
-      /* We want the :hover highlight to extend to the border of the dialog. */
-      padding: var(--spacing-m) 0;
-    }
-    .stickyBottom {
-      background-color: var(--dialog-background-color);
-      box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
-      margin-top: var(--spacing-s);
-      bottom: 0;
-      position: sticky;
-      /* @see Issue 8602 */
-      z-index: 1;
-    }
-    .stickyBottom.newReplyDialog {
-      margin-top: unset;
-    }
-    .actions {
-      display: flex;
-      justify-content: space-between;
-    }
-    .actions .right gr-button {
-      margin-left: var(--spacing-l);
-    }
-    .peopleContainer,
-    .labelsContainer {
-      flex-shrink: 0;
-    }
-    .peopleContainer {
-      border-top: none;
-      display: table;
-    }
-    .peopleList {
-      display: flex;
-    }
-    .peopleListLabel {
-      color: var(--deemphasized-text-color);
-      margin-top: var(--spacing-xs);
-      min-width: 6em;
-      padding-right: var(--spacing-m);
-    }
-    gr-account-list {
-      display: flex;
-      flex-wrap: wrap;
-      flex: 1;
-    }
-    #reviewerConfirmationOverlay {
-      padding: var(--spacing-l);
-      text-align: center;
-    }
-    .reviewerConfirmationButtons {
-      margin-top: var(--spacing-l);
-    }
-    .groupName {
-      font-weight: var(--font-weight-bold);
-    }
-    .groupSize {
-      font-style: italic;
-    }
-    .textareaContainer {
-      min-height: 12em;
-      position: relative;
-    }
-    .newReplyDialog.textareaContainer {
-      min-height: unset;
-    }
-    textareaContainer,
-    #textarea,
-    gr-endpoint-decorator[name='reply-text'] {
-      display: flex;
-      width: 100%;
-    }
-    .newReplyDialog .textareaContainer,
-    #textarea,
-    gr-endpoint-decorator[name='reply-text'] {
-      display: block;
-      width: unset;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-code);
-      line-height: calc(var(--font-size-code) + var(--spacing-s));
-      font-weight: var(--font-weight-normal);
-    }
-    .newReplyDialog#textarea {
-      padding: var(--spacing-m);
-    }
-    gr-endpoint-decorator[name='reply-text'] {
-      flex-direction: column;
-    }
-    #textarea {
-      flex: 1;
-    }
-    .previewContainer {
-      border-top: none;
-    }
-    .previewContainer gr-formatted-text {
-      background: var(--table-header-background-color);
-      padding: var(--spacing-l);
-    }
-    #checkingStatusLabel,
-    #notLatestLabel {
-      margin-left: var(--spacing-l);
-    }
-    #checkingStatusLabel {
-      color: var(--deemphasized-text-color);
-      font-style: italic;
-    }
-    #notLatestLabel,
-    #savingLabel {
-      color: var(--error-text-color);
-    }
-    #savingLabel {
-      display: none;
-    }
-    #savingLabel.saving {
-      display: inline;
-    }
-    #pluginMessage {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-l);
-      margin-bottom: var(--spacing-m);
-    }
-    #pluginMessage:empty {
-      display: none;
-    }
-    .preview-formatting {
-      margin-left: var(--spacing-m);
-    }
-    .attention-icon {
-      width: 14px;
-      height: 14px;
-      vertical-align: top;
-      position: relative;
-      top: 3px;
-      --iron-icon-height: 24px;
-      --iron-icon-width: 24px;
-    }
-    .attention .edit-attention-button {
-      vertical-align: top;
-      --gr-button-padding: 0px 4px;
-    }
-    .attention .edit-attention-button iron-icon {
-      color: inherit;
-    }
-    .attention a,
-    .attention-detail a {
-      text-decoration: none;
-    }
-    .attentionSummary {
-      display: flex;
-      justify-content: space-between;
-    }
-    .attentionSummary {
-      /* The account label for selection is misbehaving currently: It consumes
-         26px height instead of 20px, which is the default line-height and thus
-         the max that can be nicely fit into an inline layout flow. We
-         acknowledge that using a fixed 26px value here is a hack and not a
-         great solution. */
-      line-height: 26px;
-    }
-    .attentionSummary gr-account-label,
-    .attention-detail gr-account-label {
-      --account-max-length: 120px;
-      display: inline-block;
-      padding: var(--spacing-xs) var(--spacing-m);
-      user-select: none;
-      --label-border-radius: 8px;
-    }
-    .attentionSummary gr-account-label {
-      margin: 0 var(--spacing-xs);
-      line-height: var(--line-height-normal);
-      vertical-align: top;
-    }
-    .attention-detail .peopleListValues {
-      line-height: calc(var(--line-height-normal) + 10px);
-    }
-    .attention-detail gr-account-label {
-      line-height: var(--line-height-normal);
-    }
-    .attentionSummary gr-account-label:focus,
-    .attention-detail gr-account-label:focus {
-      outline: none;
-    }
-    .attentionSummary gr-account-label:hover,
-    .attention-detail gr-account-label:hover {
-      box-shadow: var(--elevation-level-1);
-      cursor: pointer;
-    }
-    .attention-detail .attentionDetailsTitle {
-      display: flex;
-      justify-content: space-between;
-    }
-    .attention-detail .selectUsers {
-      color: var(--deemphasized-text-color);
-      margin-bottom: var(--spacing-m);
-    }
-    .attentionTip {
-      padding: var(--spacing-m);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin-top: var(--spacing-m);
-      background-color: var(--assignee-highlight-color);
-    }
-    .attentionTip div iron-icon {
-      margin-right: var(--spacing-s);
-    }
-    .patchsetLevelContainer {
-      width: 80ch;
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-2);
-    }
-    .patchsetLevelContainer.resolved{
-      background-color: var(--comment-background-color);
-    }
-    .patchsetLevelContainer.unresolved{
-      background-color: var(--unresolved-comment-background-color);
-    }
-    .labelContainer {
-      padding-left: var(--spacing-m);
-      padding-bottom: var(--spacing-m);
-    }
-
-  </style>
-  <div class$="container" tabindex="-1">
-    <section class="peopleContainer">
-      <gr-endpoint-decorator name="reply-reviewers">
-        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-        <gr-endpoint-param name="reviewers" value="[[_allReviewers]]">
-        </gr-endpoint-param>
-        <div class="peopleList">
-          <div class="peopleListLabel">Reviewers</div>
-          <gr-account-list
-            id="reviewers"
-            accounts="{{_reviewers}}"
-            removable-values="[[change.removable_reviewers]]"
-            filter="[[filterReviewerSuggestion]]"
-            pending-confirmation="{{_reviewerPendingConfirmation}}"
-            placeholder="Add reviewer..."
-            on-account-text-changed="_handleAccountTextEntry"
-            suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
-          >
-          </gr-account-list>
-          <gr-endpoint-slot name="right"></gr-endpoint-slot>
-        </div>
-        <gr-endpoint-slot name="below"></gr-endpoint-slot>
-      </gr-endpoint-decorator>
-      <div class="peopleList">
-        <div class="peopleListLabel">CC</div>
-        <gr-account-list
-          id="ccs"
-          accounts="{{_ccs}}"
-          filter="[[filterCCSuggestion]]"
-          pending-confirmation="{{_ccPendingConfirmation}}"
-          allow-any-input=""
-          placeholder="Add CC..."
-          on-account-text-changed="_handleAccountTextEntry"
-          suggestions-provider="[[_getCcSuggestionsProvider(change)]]"
-        >
-        </gr-account-list>
-      </div>
-      <gr-overlay
-        id="reviewerConfirmationOverlay"
-        on-iron-overlay-canceled="_cancelPendingReviewer"
-      >
-        <div class="reviewerConfirmation">
-          Group
-          <span class="groupName">
-            [[_pendingConfirmationDetails.group.name]]
-          </span>
-          has
-          <span class="groupSize"> [[_pendingConfirmationDetails.count]] </span>
-          members.
-          <br />
-          Are you sure you want to add them all?
-        </div>
-        <div class="reviewerConfirmationButtons">
-          <gr-button on-click="_confirmPendingReviewer">Yes</gr-button>
-          <gr-button on-click="_cancelPendingReviewer">No</gr-button>
-        </div>
-      </gr-overlay>
-    </section>
-
-    <section class="labelsContainer">
-      <gr-endpoint-decorator name="reply-label-scores">
-        <gr-label-scores
-          id="labelScores"
-          account="[[_account]]"
-          change="[[change]]"
-          on-labels-changed="_handleLabelsChanged"
-          permitted-labels="[[permittedLabels]]"
-        ></gr-label-scores>
-        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
-      </gr-endpoint-decorator>
-      <div id="pluginMessage">[[_pluginMessage]]</div>
-    </section>
-    <section class="newReplyDialog textareaContainer">
-      <div class$="patchsetLevelContainer [[getUnresolvedPatchsetLevelClass(_isResolvedPatchsetLevelComment)]]">
-        <gr-endpoint-decorator name="reply-text">
-          <gr-textarea
-            id="textarea"
-            class="message newReplyDialog"
-            autocomplete="on"
-            placeholder="[[_messagePlaceholder]]"
-            monospace="true"
-            disabled="{{disabled}}"
-            rows="4"
-            text="{{draft}}"
-            on-bind-value-changed="_handleHeightChanged"
-          >
-          </gr-textarea>
-          <gr-endpoint-param name="change" value="[[change]]">
-          </gr-endpoint-param>
-        </gr-endpoint-decorator>
-        <div class="labelContainer">
-          <label>
-            <input
-              id="resolvedPatchsetLevelCommentCheckbox"
-              type="checkbox"
-              checked="{{_isResolvedPatchsetLevelComment::change}}"
-            />
-            Resolved
-          </label>
-          <label class="preview-formatting">
-            <input type="checkbox" checked="{{_previewFormatting::change}}" />
-            Preview formatting
-          </label>
-        </div>
-      </div>
-    </section>
-    <template is="dom-if" if="[[_previewFormatting]]">
-      <section class="previewContainer">
-        <gr-formatted-text
-          content="[[draft]]"
-          config="[[projectConfig.commentlinks]]"
-        ></gr-formatted-text>
-    </template>
-    </section>
-
-    <section
-      class="draftsContainer"
-      hidden$="[[_computeHideDraftList(draftCommentThreads)]]"
-    >
-      <div class="includeComments">
-        <input
-          type="checkbox"
-          id="includeComments"
-          checked="{{_includeComments::change}}"
-        />
-        <label for="includeComments"
-          >Publish [[_computeDraftsTitle(draftCommentThreads)]]</label
-        >
-      </div>
-      <gr-thread-list
-        id="commentList"
-        hidden$="[[!_includeComments]]"
-        threads="[[draftCommentThreads]]"
-        change="[[change]]"
-        change-num="[[change._number]]"
-        logged-in="true"
-        hide-dropdown=""
-      >
-      </gr-thread-list>
-      <span
-        id="savingLabel"
-        class$="[[_computeSavingLabelClass(_savingComments)]]"
-      >
-        Saving comments...
-      </span>
-    </section>
-    <div class$="stickyBottom newReplyDialog">
-      <section
-        hidden$="[[!_showAttentionSummary(_attentionExpanded)]]"
-        class="attention"
-      >
-        <div class="attentionSummary">
-          <div>
-            <template
-              is="dom-if"
-              if="[[_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
-            >
-              <span
-                >[[_computeDoNotUpdateMessage(_currentAttentionSet,
-                _newAttentionSet, _sendDisabled)]]</span
-              >
-            </template>
-            <template
-              is="dom-if"
-              if="[[!_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
-            >
-              <span>Bring to attention of</span>
-              <template
-                is="dom-repeat"
-                items="[[_computeNewAttentionAccounts(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
-                as="account"
-              >
-                <gr-account-label
-                  account="[[account]]"
-                  force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hideHovercard
-                  selectionChipStyle
-                  on-click="_handleAttentionClick"
-                ></gr-account-label>
-              </template>
-            </template>
-            <gr-tooltip-content
-              has-tooltip
-              title="[[_computeAttentionButtonTitle(_sendDisabled)]]"
-            >
-              <gr-button
-                class="edit-attention-button"
-                on-click="_handleAttentionModify"
-                disabled="[[_sendDisabled]]"
-                link=""
-                position-below=""
-                data-label="Edit"
-                data-action-type="change"
-                data-action-key="edit"
-                role="button"
-                tabindex="0"
-              >
-                <iron-icon icon="gr-icons:edit"></iron-icon>
-                Modify
-              </gr-button>
-            </gr-tooltip-content>
-          </div>
-          <div>
-            <a
-              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
-              target="_blank"
-            >
-              <iron-icon
-                icon="gr-icons:help-outline"
-                title="read documentation"
-              ></iron-icon>
-            </a>
-          </div>
-        </div>
-      </section>
-      <section
-        hidden$="[[!_showAttentionDetails(_attentionExpanded)]]"
-        class="attention-detail"
-      >
-        <div class="attentionDetailsTitle">
-          <div>
-            <span>Modify attention to</span>
-          </div>
-          <div></div>
-          <div>
-            <a
-              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
-              target="_blank"
-            >
-              <iron-icon
-                icon="gr-icons:help-outline"
-                title="read documentation"
-              ></iron-icon>
-            </a>
-          </div>
-        </div>
-        <div class="selectUsers">
-          <span
-            >Select chips to set who will be in the attention set after sending
-            this reply</span
-          >
-        </div>
-        <div class="peopleList">
-          <div class="peopleListLabel">Owner</div>
-          <div class="peopleListValues">
-            <gr-account-label
-              account="[[_owner]]"
-              force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              hideHovercard
-              selectionChipStyle
-              on-click="_handleAttentionClick"
-            >
-            </gr-account-label>
-          </div>
-        </div>
-        <template is="dom-if" if="[[_uploader]]">
-          <div class="peopleList">
-            <div class="peopleListLabel">Uploader</div>
-            <div class="peopleListValues">
-              <gr-account-label
-                account="[[_uploader]]"
-                force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                hideHovercard
-                selectionChipStyle
-                on-click="_handleAttentionClick"
-              >
-              </gr-account-label>
-            </div>
-          </div>
-        </template>
-        <div class="peopleList">
-          <div class="peopleListLabel">Reviewers</div>
-          <div class="peopleListValues">
-            <template
-              is="dom-repeat"
-              items="[[_removeServiceUsers(_reviewers, _newAttentionSet)]]"
-              as="account"
-            >
-              <gr-account-label
-                account="[[account]]"
-                force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                hideHovercard
-                selectionChipStyle
-                on-click="_handleAttentionClick"
-              >
-              </gr-account-label>
-            </template>
-          </div>
-        </div>
-        <template is="dom-if" if="[[_attentionCcsCount]]">
-          <div class="peopleList">
-            <div class="peopleListLabel">CC</div>
-            <div class="peopleListValues">
-              <template
-                is="dom-repeat"
-                items="[[_removeServiceUsers(_ccs, _newAttentionSet)]]"
-                as="account"
-              >
-                <gr-account-label
-                  account="[[account]]"
-                  force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hideHovercard
-                  selectionChipStyle
-                  on-click="_handleAttentionClick"
-                >
-                </gr-account-label>
-              </template>
-            </div>
-          </div>
-        </template>
-        <template
-          is="dom-if"
-          if="[[_computeShowAttentionTip(_account, _owner, _currentAttentionSet, _newAttentionSet)]]"
-        >
-          <div class="attentionTip">
-            <iron-icon
-              class="pointer"
-              icon="gr-icons:lightbulb-outline"
-            ></iron-icon>
-            Be mindful of requiring attention from too many users.
-          </div>
-        </template>
-      </section>
-      <section class="actions">
-        <div class="left">
-          <span
-            id="checkingStatusLabel"
-            hidden$="[[!_isState(knownLatestState, 'checking')]]"
-          >
-            Checking whether patch [[patchNum]] is latest...
-          </span>
-          <span
-            id="notLatestLabel"
-            hidden$="[[!_isState(knownLatestState, 'not-latest')]]"
-          >
-            [[_computePatchSetWarning(patchNum, _labelsChanged)]]
-            <gr-button link="" on-click="_reload">Reload</gr-button>
-          </span>
-        </div>
-        <div class="right">
-          <gr-button
-            link=""
-            id="cancelButton"
-            class="action cancel"
-            on-click="_cancelTapHandler"
-            >Cancel</gr-button
-          >
-          <template is="dom-if" if="[[canBeStarted]]">
-            <!-- Use 'Send' here as the change may only about reviewers / ccs
-                and when this button is visible, the next button will always
-                be 'Start review' -->
-            <gr-tooltip-content
-              has-tooltip=""
-              title$="[[_saveTooltip]]"
-            >
-              <gr-button
-                link=""
-                disabled="[[_isState(knownLatestState, 'not-latest')]]"
-                class="action save"
-                on-click="_saveClickHandler"
-                >Send As WIP</gr-button
-              >
-            </gr-tooltip-content>
-          </template>
-          <gr-tooltip-content
-            has-tooltip=""
-            title$="[[_computeSendButtonTooltip(canBeStarted, _commentEditing)]]"
-          >
-            <gr-button
-              id="sendButton"
-              primary=""
-              disabled="[[_sendDisabled]]"
-              class="action send"
-              on-click="_sendTapHandler"
-              >[[_sendButtonLabel]]
-            </gr-button>
-          </gr-tooltip-content>
-        </div>
-      </section>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index cf31a4f..12b1c40 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -1,55 +1,42 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-reply-dialog';
 import {
   addListenerForTest,
+  isVisible,
   mockPromise,
+  pressKey,
+  query,
   queryAll,
   queryAndAssert,
+  stubFlags,
   stubRestApi,
-  stubStorage,
+  waitUntilVisible,
 } from '../../../test/test-utils';
-import {
-  ChangeStatus,
-  ReviewerState,
-  SpecialFilePath,
-} from '../../../constants/constants';
-import {appContext} from '../../../services/app-context';
+import {ChangeStatus, ReviewerState} from '../../../constants/constants';
 import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {StandardLabels} from '../../../utils/label-util';
 import {
+  createAccountWithEmail,
   createAccountWithId,
   createChange,
+  createComment,
   createCommentThread,
   createDraft,
   createRevision,
+  createServiceUserWithId,
 } from '../../../test/test-data-generators';
-import {
-  pressAndReleaseKeyOn,
-  tap,
-} from '@polymer/iron-test-helpers/mock-interactions';
-import {FocusTarget, GrReplyDialog} from './gr-reply-dialog';
+import {GrReplyDialog} from './gr-reply-dialog';
 import {
   AccountId,
   AccountInfo,
   CommitId,
   DetailedLabelInfo,
+  EmailAddress,
   GroupId,
   GroupName,
   NumericChangeId,
@@ -57,21 +44,29 @@
   ReviewerInput,
   ReviewInput,
   ReviewResult,
+  RevisionPatchSetNum,
   Suggestion,
+  Timestamp,
   UrlEncodedCommentId,
+  UserId,
 } from '../../../types/common';
 import {CommentThread} from '../../../utils/comment-util';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {
-  AccountInfoInput,
-  GrAccountList,
-} from '../../shared/gr-account-list/gr-account-list';
+import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-
-const basicFixture = fixtureFromElement('gr-reply-dialog');
+import {fixture, html, waitUntil, assert} from '@open-wc/testing';
+import {accountKey} from '../../../utils/account-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {Key, Modifier} from '../../../utils/dom-util';
+import {GrComment} from '../../shared/gr-comment/gr-comment';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
 
 function cloneableResponse(status: number, text: string) {
   return {
@@ -97,20 +92,14 @@
   let element: GrReplyDialog;
   let changeNum: NumericChangeId;
   let patchNum: PatchSetNum;
-
-  let getDraftCommentStub: sinon.SinonStub;
-  let setDraftCommentStub: sinon.SinonStub;
-  let eraseDraftCommentStub: sinon.SinonStub;
-
-  const emptyAccountInfoInputChanges =
-    [] as unknown as PolymerDeepPropertyChange<
-      AccountInfoInput[],
-      AccountInfoInput[]
-    >;
+  let commentsModel: CommentsModel;
 
   let lastId = 1;
   const makeAccount = function () {
-    return {_account_id: lastId++ as AccountId};
+    return {
+      _account_id: lastId++ as AccountId,
+      email: `${lastId}.com` as EmailAddress,
+    };
   };
   const makeGroup = function () {
     return {id: `${lastId++}` as GroupId};
@@ -123,15 +112,17 @@
     stubRestApi('getChange').returns(Promise.resolve({...createChange()}));
     stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
 
-    sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
+    element = await fixture<GrReplyDialog>(html`
+      <gr-reply-dialog></gr-reply-dialog>
+    `);
 
-    element = basicFixture.instantiate();
     element.change = {
       ...createChange(),
       _number: changeNum,
       owner: {
-        _account_id: 999 as AccountId as AccountId,
+        _account_id: 999 as AccountId,
         display_name: 'Kermit',
+        email: 'abcd' as EmailAddress,
       },
       labels: {
         Verified: {
@@ -144,8 +135,8 @@
         },
         'Code-Review': {
           values: {
-            '-2': 'Do not submit',
-            '-1': "I would prefer that you didn't submit this",
+            '-2': 'This shall not be submitted',
+            '-1': 'I would prefer this is not submitted as is',
             ' 0': 'No score',
             '+1': 'Looks good to me, but someone else must approve',
             '+2': 'Looks good to me, approved',
@@ -159,22 +150,16 @@
       'Code-Review': ['-1', ' 0', '+1'],
       Verified: ['-1', ' 0', '+1'],
     };
+    element.draftCommentThreads = [];
 
-    getDraftCommentStub = stubStorage('getDraftComment');
-    setDraftCommentStub = stubStorage('setDraftComment');
-    eraseDraftCommentStub = stubStorage('eraseDraftComment');
-
-    // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
-    //     .returns(Promise.resolve({isLatest: true}));
-
-    // Allow the elements created by dom-repeat to be stamped.
-    await flush();
+    await element.updateComplete;
+    commentsModel = testResolver(commentsModelToken);
   });
 
   function stubSaveReview(
     jsonResponseProducer: (input: ReviewInput) => ReviewResult | void
   ) {
-    return sinon.stub(element, '_saveReview').callsFake(
+    return sinon.stub(element, 'saveReview').callsFake(
       review =>
         new Promise((resolve, reject) => {
           try {
@@ -205,18 +190,274 @@
     return promise;
   }
 
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div tabindex="-1">
+          <section class="peopleContainer">
+            <gr-endpoint-decorator name="reply-reviewers">
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+              <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+              <div class="peopleList">
+                <div class="peopleListLabel">Reviewers</div>
+                <gr-account-list id="reviewers"> </gr-account-list>
+                <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+              </div>
+              <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+            </gr-endpoint-decorator>
+            <div class="peopleList">
+              <div class="peopleListLabel">CC</div>
+              <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+            </div>
+            <dialog tabindex="-1" id="reviewerConfirmationModal">
+              <div class="reviewerConfirmation">
+                Group
+                <span class="groupName"> </span>
+                has
+                <span class="groupSize"> </span>
+                members.
+                <br />
+                Are you sure you want to add them all?
+              </div>
+              <div class="reviewerConfirmationButtons">
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  Yes
+                </gr-button>
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  No
+                </gr-button>
+              </div>
+            </dialog>
+          </section>
+          <section class="labelsContainer">
+            <gr-endpoint-decorator name="reply-label-scores">
+              <gr-label-scores id="labelScores"> </gr-label-scores>
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+            </gr-endpoint-decorator>
+            <div id="pluginMessage"></div>
+          </section>
+          <section class="newReplyDialog textareaContainer">
+            <div class="patchsetLevelContainer resolved">
+              <gr-endpoint-decorator name="reply-text">
+                <gr-comment
+                  hide-header=""
+                  id="patchsetLevelComment"
+                  permanent-editing-mode=""
+                >
+                </gr-comment>
+                <gr-endpoint-param name="change"> </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          </section>
+          <div class="newReplyDialog stickyBottom">
+            <gr-endpoint-decorator name="reply-bottom">
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+              <section class="attention">
+                <div class="attentionSummary">
+                  <div>
+                    <span> No changes to the attention set. </span>
+                    <gr-tooltip-content
+                      has-tooltip=""
+                      title="Edit attention set changes"
+                    >
+                      <gr-button
+                        aria-disabled="true"
+                        disabled=""
+                        class="edit-attention-button"
+                        data-action-key="edit"
+                        data-action-type="change"
+                        data-label="Edit"
+                        link=""
+                        position-below=""
+                        role="button"
+                        tabindex="-1"
+                      >
+                        <div>
+                          <gr-icon icon="edit" filled small></gr-icon>
+                          <span>Modify</span>
+                        </div>
+                      </gr-button>
+                    </gr-tooltip-content>
+                  </div>
+                  <div>
+                    <a
+                      href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+                      target="_blank"
+                    >
+                      <gr-icon icon="help" title="read documentation"></gr-icon>
+                    </a>
+                  </div>
+                </div>
+              </section>
+              <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
+              <section class="actions">
+                <div class="left"></div>
+                <div class="right">
+                  <gr-button
+                    aria-disabled="false"
+                    class="action cancel"
+                    id="cancelButton"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Cancel
+                  </gr-button>
+                  <gr-tooltip-content has-tooltip="" title="Send reply">
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      class="action send"
+                      id="sendButton"
+                      primary=""
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Send
+                    </gr-button>
+                  </gr-tooltip-content>
+                </div>
+              </section>
+            </gr-endpoint-decorator>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('renders private change info when reviewer is added', async () => {
+    element.change!.is_private = true;
+    element.requestUpdate();
+    await element.updateComplete;
+    const peopleContainer = queryAndAssert<HTMLDivElement>(
+      element,
+      '.peopleContainer'
+    );
+
+    // Info is rendered only if reviewer is added
+    assert.dom.equal(
+      peopleContainer,
+      `
+      <section class="peopleContainer">
+        <gr-endpoint-decorator name="reply-reviewers">
+          <gr-endpoint-param name="change"> </gr-endpoint-param>
+          <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+          <div class="peopleList">
+            <div class="peopleListLabel">Reviewers</div>
+            <gr-account-list id="reviewers"> </gr-account-list>
+            <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+          </div>
+          <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+        </gr-endpoint-decorator>
+        <div class="peopleList">
+          <div class="peopleListLabel">CC</div>
+          <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+        </div>
+        <dialog
+          tabindex="-1"
+          id="reviewerConfirmationModal"
+        >
+          <div class="reviewerConfirmation">
+            Group
+            <span class="groupName"> </span>
+            has
+            <span class="groupSize"> </span>
+            members.
+            <br />
+            Are you sure you want to add them all?
+          </div>
+          <div class="reviewerConfirmationButtons">
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              Yes
+            </gr-button>
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              No
+            </gr-button>
+          </div>
+        </dialog>
+      </section>
+    `
+    );
+
+    const account = createAccountWithId(22);
+    element.reviewersList!.accounts = [];
+    element.reviewersList!.addAccountItem({account, count: 1});
+    element.reviewersList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account},
+      })
+    );
+    element.requestUpdate();
+    await element.updateComplete;
+
+    assert.dom.equal(
+      peopleContainer,
+      `
+      <section class="peopleContainer">
+        <gr-endpoint-decorator name="reply-reviewers">
+          <gr-endpoint-param name="change"> </gr-endpoint-param>
+          <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+          <div class="peopleList">
+            <div class="peopleListLabel">Reviewers</div>
+            <gr-account-list id="reviewers"> </gr-account-list>
+            <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+          </div>
+          <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+        </gr-endpoint-decorator>
+        <div class="peopleList">
+          <div class="peopleListLabel">CC</div>
+          <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+        </div>
+        <dialog
+          tabindex="-1"
+          id="reviewerConfirmationModal"
+        >
+          <div class="reviewerConfirmation">
+            Group
+            <span class="groupName"> </span>
+            has
+            <span class="groupSize"> </span>
+            members.
+            <br />
+            Are you sure you want to add them all?
+          </div>
+          <div class="reviewerConfirmationButtons">
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              Yes
+            </gr-button>
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              No
+            </gr-button>
+          </div>
+        </dialog>
+        <div class="privateVisiblityInfo">
+          <gr-icon icon="info">
+          </gr-icon>
+          <div>
+            Adding a reviewer/CC will make this private change visible to them
+          </div>
+        </div>
+      </section>
+    `
+    );
+  });
+
   test('default to publishing draft comments with reply', async () => {
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
-    await flush();
-    element.draft = 'I wholeheartedly disapprove';
+    await element.updateComplete;
+    element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
+    element.draftCommentThreads = [createCommentThread([createComment()])];
+
+    element.includeComments = true;
     const saveReviewPromise = interceptSaveReview();
 
     // This is needed on non-Blink engines most likely due to the ways in
     // which the dom-repeat elements are stamped.
-    await flush();
-    tap(queryAndAssert(element, '.send'));
-    await flush();
+    await element.updateComplete;
+    queryAndAssert<GrButton>(element, '.send').click();
+    await element.updateComplete;
 
     const review = await saveReviewPromise;
     assert.deepEqual(review, {
@@ -225,34 +466,31 @@
         'Code-Review': 0,
         Verified: 0,
       },
-      comments: {
-        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
-          {
-            message: 'I wholeheartedly disapprove',
-            unresolved: false,
-          },
-        ],
-      },
       reviewers: [],
-      add_to_attention_set: [],
+      add_to_attention_set: [
+        {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+      ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
     assert.isFalse(
-      (queryAndAssert(element, '#commentList') as GrThreadList).hidden
+      queryAndAssert<GrThreadList>(element, '#commentList').hidden
     );
   });
 
   test('modified attention set', async () => {
-    await flush();
-    element._account = {_account_id: 123 as AccountId};
-    element._newAttentionSet = new Set([314 as AccountId]);
-    const saveReviewPromise = interceptSaveReview();
-    const modifyButton = queryAndAssert(element, '.edit-attention-button');
-    tap(modifyButton);
-    await flush();
+    await element.updateComplete;
 
-    tap(queryAndAssert(element, '.send'));
+    // required so that "Send" button is enabled
+    element.canBeStarted = true;
+    await element.updateComplete;
+
+    element.account = {_account_id: 123 as AccountId};
+    element.newAttentionSet = new Set([314 as AccountId]);
+    element.uploader = createAccountWithId(314);
+    const saveReviewPromise = interceptSaveReview();
+
+    queryAndAssert<GrButton>(element, '.send').click();
     const review = await saveReviewPromise;
 
     assert.deepEqual(review, {
@@ -265,21 +503,25 @@
         {reason: '<GERRIT_ACCOUNT_123> replied on the change', user: 314},
       ],
       reviewers: [],
+      ready: true,
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
   });
 
   test('modified attention set by anonymous', async () => {
-    await flush();
-    element._account = {};
-    element._newAttentionSet = new Set([314 as AccountId]);
-    const saveReviewPromise = interceptSaveReview();
-    const modifyButton = queryAndAssert(element, '.edit-attention-button');
-    tap(modifyButton);
-    await flush();
+    await element.updateComplete;
 
-    tap(queryAndAssert(element, '.send'));
+    // required so that "Send" button is enabled
+    element.canBeStarted = true;
+    await element.updateComplete;
+
+    element.account = {};
+    element.uploader = createAccountWithId(314);
+    element.newAttentionSet = new Set([314 as AccountId]);
+    const saveReviewPromise = interceptSaveReview();
+
+    queryAndAssert<GrButton>(element, '.send').click();
     const review = await saveReviewPromise;
 
     assert.deepEqual(review, {
@@ -289,17 +531,20 @@
         Verified: 0,
       },
       add_to_attention_set: [
-        {reason: 'Anonymous replied on the change', user: 314},
+        // Name coming from createUserConfig in test-data-generator
+        {reason: 'Name of user not set replied on the change', user: 314},
       ],
       reviewers: [],
+      ready: true,
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
-    element._newAttentionSet = new Set();
-    await flush();
+    element.newAttentionSet = new Set();
+    await element.updateComplete;
   });
 
-  function checkComputeAttention(
+  async function checkComputeAttention(
+    element: GrReplyDialog,
     status: ChangeStatus,
     userId?: AccountId,
     reviewerIds?: AccountId[],
@@ -311,28 +556,22 @@
     hasDraft = true,
     includeComments = true
   ) {
-    const user = {_account_id: userId};
-    const reviewers = {
-      base: reviewerIds?.map(id => {
+    element.account = {_account_id: userId};
+    element.reviewers =
+      reviewerIds?.map(id => {
         return {_account_id: id};
-      }),
-    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
+      }) ?? [];
     let draftThreads: CommentThread[] = [];
     if (hasDraft) {
       draftThreads = [
         {
-          ...createCommentThread([
-            {
-              ...createDraft(),
-              __draft: true,
-              unresolved: true,
-            },
-          ]),
+          ...createCommentThread([{...createDraft(), unresolved: true}]),
         },
       ];
     }
     replyToIds?.forEach(id =>
       draftThreads[0].comments.push({
+        ...createComment(),
         author: {_account_id: id},
       })
     );
@@ -340,6 +579,9 @@
       ...createChange(),
       owner: {_account_id: ownerId},
       status,
+      reviewers: {
+        [ReviewerState.REVIEWER]: element.reviewers,
+      },
     };
     attSetIds?.forEach(id => {
       if (!change.attention_set) change.attention_set = {};
@@ -355,25 +597,19 @@
       };
     }
     element.change = change;
-    element._reviewers = reviewers.base!;
+    element._ccs = [];
+    element.draftCommentThreads = draftThreads;
+    element.includeComments = includeComments;
 
-    flush();
-    const hasDrafts = draftThreads.length > 0;
-    element._computeNewAttention(
-      user,
-      reviewers!,
-      emptyAccountInfoInputChanges,
-      change,
-      draftThreads,
-      includeComments,
-      undefined,
-      hasDrafts
-    );
-    assert.sameMembers([...element._newAttentionSet], expectedIds!);
+    await element.updateComplete;
+
+    element.computeNewAttention();
+    assert.sameMembers([...element.newAttentionSet], expectedIds!);
   }
 
-  test('computeNewAttention NEW', () => {
-    checkComputeAttention(
+  test('computeNewAttention NEW', async () => {
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -382,7 +618,8 @@
       [],
       [999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -391,7 +628,8 @@
       [],
       [999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -400,7 +638,8 @@
       [],
       [999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -409,7 +648,8 @@
       [],
       [22 as AccountId, 999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -418,7 +658,8 @@
       [22 as AccountId],
       [22 as AccountId, 999 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -428,7 +669,8 @@
       [22 as AccountId, 33 as AccountId, 999 as AccountId]
     );
     // If the owner replies, then do not add them.
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -437,7 +679,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -446,7 +689,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -456,7 +700,8 @@
       []
     );
 
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId],
@@ -465,7 +710,8 @@
       [22 as AccountId],
       [22 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -474,7 +720,8 @@
       [22 as AccountId],
       [22 as AccountId, 33 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -483,7 +730,8 @@
       [22 as AccountId],
       [22 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -492,7 +740,8 @@
       [22 as AccountId, 33 as AccountId],
       [22 as AccountId, 33 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -502,7 +751,8 @@
       [22 as AccountId, 33 as AccountId]
     );
     // with uploader
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -512,7 +762,8 @@
       [2 as AccountId],
       2 as AccountId
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -522,7 +773,8 @@
       [2 as AccountId],
       2 as AccountId
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.NEW,
       1 as AccountId,
       [],
@@ -534,8 +786,9 @@
     );
   });
 
-  test('computeNewAttention MERGED', () => {
-    checkComputeAttention(
+  test('computeNewAttention MERGED', async () => {
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       undefined,
       [],
@@ -546,7 +799,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -557,7 +811,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -568,7 +823,8 @@
       undefined,
       true
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -580,7 +836,8 @@
       true,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -591,7 +848,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -602,7 +860,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -613,7 +872,8 @@
       undefined,
       false
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -622,7 +882,8 @@
       [22 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -631,7 +892,8 @@
       [22 as AccountId],
       [33 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -640,7 +902,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -651,7 +914,8 @@
       undefined,
       true
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -660,7 +924,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [],
@@ -671,7 +936,8 @@
       undefined,
       true
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -680,7 +946,8 @@
       [],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId],
@@ -689,7 +956,8 @@
       [22 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -698,7 +966,8 @@
       [22 as AccountId],
       [33 as AccountId]
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -707,7 +976,8 @@
       [22 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -716,7 +986,8 @@
       [22 as AccountId, 33 as AccountId],
       []
     );
-    checkComputeAttention(
+    await checkComputeAttention(
+      element,
       ChangeStatus.MERGED,
       1 as AccountId,
       [22 as AccountId, 33 as AccountId],
@@ -727,118 +998,101 @@
     );
   });
 
-  test('computeNewAttention when adding reviewers', () => {
-    const user = {_account_id: 1 as AccountId};
-    const reviewers = {
-      base: [
-        {_account_id: 1 as AccountId, _pendingAdd: true},
-        {_account_id: 2 as AccountId, _pendingAdd: true},
-      ],
-    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
-    const change = {
+  test('computeNewAttention when adding reviewers', async () => {
+    element.account = {_account_id: 1 as AccountId};
+    element.change = {
       ...createChange(),
       owner: {_account_id: 5 as AccountId},
       status: ChangeStatus.NEW,
       attention_set: {},
     };
-    element.change = change;
-    element._reviewers = reviewers.base;
-    flush();
+    // let rebuildReviewers triggered by change update finish running
+    await element.updateComplete;
 
-    element._computeNewAttention(
-      user,
-      reviewers,
-      emptyAccountInfoInputChanges,
-      change,
-      [],
-      true
+    element.reviewers = [
+      {_account_id: 1 as AccountId},
+      {_account_id: 2 as AccountId},
+    ];
+    element._ccs = [];
+    element.draftCommentThreads = [];
+    element.includeComments = true;
+    element.canBeStarted = true;
+    await element.updateComplete;
+    assert.sameMembers(
+      [...element.newAttentionSet],
+      [1 as AccountId, 2 as AccountId]
     );
-    assert.sameMembers([...element._newAttentionSet], [1, 2]);
 
     // If the user votes on the change, then they should not be added to the
     // attention set, even if they have just added themselves as reviewer.
     // But voting should also add the owner (5).
-    const labelsChanged = true;
-    element._computeNewAttention(
-      user,
-      reviewers,
-      emptyAccountInfoInputChanges,
-      change,
-      [],
-      true,
-      labelsChanged
+    element.labelsChanged = true;
+    await element.updateComplete;
+    assert.sameMembers(
+      [...element.newAttentionSet],
+      [2 as AccountId, 5 as AccountId]
     );
-    assert.sameMembers([...element._newAttentionSet], [2, 5]);
   });
 
-  test('computeNewAttention when sending wip change for review', () => {
-    const reviewers = {
-      base: [{...createAccountWithId(2)}, {...createAccountWithId(3)}],
-    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
-    const change = {
+  test('computeNewAttention when sending wip change for review', async () => {
+    element.change = {
       ...createChange(),
       owner: {_account_id: 1 as AccountId},
       status: ChangeStatus.NEW,
       attention_set: {},
+      reviewers: {
+        [ReviewerState.REVIEWER]: [
+          {...createAccountWithId(2)},
+          {...createAccountWithId(3)},
+        ],
+      },
     };
-    element.change = change;
-    element._reviewers = reviewers.base;
-    flush();
+    // let rebuildReviewers triggered by change update finish running
+    await element.updateComplete;
+
+    element.reviewers = [
+      {...createAccountWithId(2)},
+      {...createAccountWithId(3)},
+    ];
+
+    element._ccs = [];
+    element.draftCommentThreads = [];
+    element.includeComments = false;
+    element.account = {_account_id: 1 as AccountId};
+
+    await element.updateComplete;
 
     // For an active change there is no reason to add anyone to the set.
-    let user = {_account_id: 1 as AccountId};
-    element._computeNewAttention(
-      user,
-      reviewers,
-      emptyAccountInfoInputChanges,
-      change,
-      [],
-      false
-    );
-    assert.sameMembers([...element._newAttentionSet], []);
+    element.computeNewAttention();
+    assert.sameMembers([...element.newAttentionSet], []);
 
     // If the change is "work in progress" and the owner sends a reply, then
     // add all reviewers.
     element.canBeStarted = true;
-    flush();
-    user = {_account_id: 1 as AccountId};
-    element._computeNewAttention(
-      user,
-      reviewers,
-      emptyAccountInfoInputChanges,
-      change,
-      [],
-      false
+    element.computeNewAttention();
+    await element.updateComplete;
+    assert.sameMembers(
+      [...element.newAttentionSet],
+      [2 as AccountId, 3 as AccountId]
     );
-    assert.sameMembers([...element._newAttentionSet], [2, 3]);
 
     // ... but not when someone else replies.
-    user = {_account_id: 4 as AccountId};
-    element._computeNewAttention(
-      user,
-      reviewers,
-      emptyAccountInfoInputChanges,
-      change,
-      [],
-      false
-    );
-    assert.sameMembers([...element._newAttentionSet], []);
+    element.account = {_account_id: 4 as AccountId};
+    element.computeNewAttention();
+    assert.sameMembers([...element.newAttentionSet], []);
   });
 
   test('computeNewAttentionAccounts', () => {
-    element._reviewers = [
+    element.reviewers = [
       {_account_id: 123 as AccountId, display_name: 'Ernie'},
       {_account_id: 321 as AccountId, display_name: 'Bert'},
     ];
     element._ccs = [{_account_id: 7 as AccountId, display_name: 'Elmo'}];
-    const compute = (currentAtt: AccountId[], newAtt: AccountId[]) =>
-      element
-        ._computeNewAttentionAccounts(
-          undefined,
-          new Set(currentAtt),
-          new Set(newAtt)
-        )
-        .map(a => a!._account_id);
+    const compute = (currentAtt: AccountId[], newAtt: AccountId[]) => {
+      element.currentAttentionSet = new Set(currentAtt);
+      element.newAttentionSet = new Set(newAtt);
+      return element.computeNewAttentionAccounts().map(a => a?._account_id);
+    };
 
     assert.sameMembers(compute([], []), []);
     assert.sameMembers(compute([], [999 as AccountId]), [999 as AccountId]);
@@ -857,7 +1111,7 @@
     );
   });
 
-  test('_computeCommentAccounts', () => {
+  test('computeCommentAccounts', () => {
     element.change = {
       ...createChange(),
       labels: {
@@ -868,8 +1122,8 @@
             {_account_id: 3 as AccountId, value: 2},
           ],
           values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didnt submit this',
+            '-2': 'This shall not be submitted',
+            '-1': 'I would prefer this is not submitted as is',
             ' 0': 'No score',
             '+1': 'Looks good to me, but someone else must approve',
             '+2': 'Looks good to me, approved',
@@ -881,11 +1135,13 @@
       {
         ...createCommentThread([
           {
+            ...createComment(),
             id: '1' as UrlEncodedCommentId,
             author: {_account_id: 1 as AccountId},
             unresolved: false,
           },
           {
+            ...createComment(),
             id: '2' as UrlEncodedCommentId,
             in_reply_to: '1' as UrlEncodedCommentId,
             author: {_account_id: 2 as AccountId},
@@ -896,11 +1152,13 @@
       {
         ...createCommentThread([
           {
+            ...createComment(),
             id: '3' as UrlEncodedCommentId,
             author: {_account_id: 3 as AccountId},
             unresolved: false,
           },
           {
+            ...createComment(),
             id: '4' as UrlEncodedCommentId,
             in_reply_to: '3' as UrlEncodedCommentId,
             author: {_account_id: 4 as AccountId},
@@ -909,54 +1167,16 @@
         ]),
       },
     ];
-    const actualAccounts = [...element._computeCommentAccounts(threads)];
+    const actualAccounts = [...element.computeCommentAccounts(threads)];
     // Account 3 is not included, because the comment is resolved *and* they
     // have given the highest possible vote on the Code-Review label.
     assert.sameMembers(actualAccounts, [1, 2, 4]);
   });
 
-  test('toggle resolved checkbox', async () => {
-    const checkboxEl = queryAndAssert(
-      element,
-      '#resolvedPatchsetLevelCommentCheckbox'
-    );
-    tap(checkboxEl);
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    await flush();
-    element.draft = 'I wholeheartedly disapprove';
-    const saveReviewPromise = interceptSaveReview();
-
-    // This is needed on non-Blink engines most likely due to the ways in
-    // which the dom-repeat elements are stamped.
-    await flush();
-    tap(queryAndAssert(element, '.send'));
-
-    const review = await saveReviewPromise;
-    assert.deepEqual(review, {
-      drafts: 'PUBLISH_ALL_REVISIONS',
-      labels: {
-        'Code-Review': 0,
-        Verified: 0,
-      },
-      comments: {
-        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
-          {
-            message: 'I wholeheartedly disapprove',
-            unresolved: true,
-          },
-        ],
-      },
-      reviewers: [],
-      add_to_attention_set: [],
-      remove_from_attention_set: [],
-      ignore_automatic_attention_set_rules: true,
-    });
-  });
-
   test('label picker', async () => {
-    element.draft = 'I wholeheartedly disapprove';
+    element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
+    element.draftCommentThreads = [createCommentThread([createComment()])];
+
     const saveReviewPromise = interceptSaveReview();
 
     sinon.stub(element.getLabelScores(), 'getLabelValues').callsFake(() => {
@@ -968,106 +1188,94 @@
 
     // This is needed on non-Blink engines most likely due to the ways in
     // which the dom-repeat elements are stamped.
-    await flush();
-    tap(queryAndAssert(element, '.send'));
+    await element.updateComplete;
+    queryAndAssert<GrButton>(element, '.send').click();
     assert.isTrue(element.disabled);
 
     const review = await saveReviewPromise;
-    await flush();
-    assert.isFalse(
-      element.disabled,
-      'Element should be enabled when done sending reply.'
-    );
-    assert.equal(element.draft.length, 0);
+    await element.updateComplete;
+    await waitUntil(() => element.disabled === false);
+    assert.equal(element.patchsetLevelDraftMessage.length, 0);
     assert.deepEqual(review, {
       drafts: 'PUBLISH_ALL_REVISIONS',
       labels: {
         'Code-Review': -1,
         Verified: -1,
       },
-      comments: {
-        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
-          {
-            message: 'I wholeheartedly disapprove',
-            unresolved: false,
-          },
-        ],
-      },
       reviewers: [],
-      add_to_attention_set: [],
+      add_to_attention_set: [
+        {user: 999, reason: '<GERRIT_ACCOUNT_1> replied on the change'},
+      ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
   });
 
   test('keep draft comments with reply', async () => {
-    tap(queryAndAssert(element, '#includeComments'));
-    assert.equal(element._includeComments, false);
+    element.draftCommentThreads = [createCommentThread([createComment()])];
+    await element.updateComplete;
+
+    queryAndAssert<HTMLInputElement>(element, '#includeComments').click();
+    assert.equal(element.includeComments, false);
 
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
-    await flush();
-    element.draft = 'I wholeheartedly disapprove';
+    await element.updateComplete;
+    element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
+
     const saveReviewPromise = interceptSaveReview();
 
     // This is needed on non-Blink engines most likely due to the ways in
     // which the dom-repeat elements are stamped.
-    await flush();
-    tap(queryAndAssert(element, '.send'));
+    await element.updateComplete;
+    queryAndAssert<GrButton>(element, '.send').click();
 
     const review = await saveReviewPromise;
-    await flush();
+    await element.updateComplete;
     assert.deepEqual(review, {
       drafts: 'KEEP',
       labels: {
         'Code-Review': 0,
         Verified: 0,
       },
-      comments: {
-        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
-          {
-            message: 'I wholeheartedly disapprove',
-            unresolved: false,
-          },
-        ],
-      },
       reviewers: [],
-      add_to_attention_set: [],
+      add_to_attention_set: [
+        {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+      ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
   });
 
   test('getlabelValue returns value', async () => {
-    await flush();
-    const el = queryAndAssert(
+    const el = queryAndAssert<GrLabelScoreRow>(
       queryAndAssert(element, 'gr-label-scores'),
       'gr-label-score-row[name="Verified"]'
-    ) as GrLabelScoreRow;
+    );
     el.setSelectedValue('-1');
     assert.equal('-1', element.getLabelValue('Verified'));
   });
 
   test('getlabelValue when no score is selected', async () => {
-    await flush();
-    const el = queryAndAssert(
+    const el = queryAndAssert<GrLabelScoreRow>(
       queryAndAssert(element, 'gr-label-scores'),
       'gr-label-score-row[name="Code-Review"]'
-    ) as GrLabelScoreRow;
+    );
     el.setSelectedValue('-1');
     assert.strictEqual(element.getLabelValue('Verified'), ' 0');
   });
 
   test('setlabelValue', async () => {
-    element._account = {_account_id: 1 as AccountId};
-    await flush();
+    element.account = {_account_id: 1 as AccountId};
+    await element.updateComplete;
     const label = 'Verified';
     const value = '+1';
     element.setLabelValue(label, value);
-    await flush();
+    await element.updateComplete;
 
-    const labels = (
-      queryAndAssert(element, '#labelScores') as GrLabelScores
+    const labels = queryAndAssert<GrLabelScores>(
+      element,
+      '#labelScores'
     ).getLabelValues();
     assert.deepEqual(labels, {
       'Code-Review': 0,
@@ -1075,187 +1283,137 @@
     });
   });
 
-  function getActiveElement() {
-    return document.activeElement;
-  }
-
-  function isVisible(el: Element) {
-    assert.ok(el);
-    return getComputedStyle(el).getPropertyValue('display') !== 'none';
-  }
-
-  function overlayObserver(mode: string) {
-    return new Promise(resolve => {
-      function listener() {
-        element.removeEventListener('iron-overlay-' + mode, listener);
-        resolve(mode);
-      }
-      element.addEventListener('iron-overlay-' + mode, listener);
-    });
-  }
-
-  function isFocusInsideElement(element: Element) {
-    // In Polymer 2 focused element either <paper-input> or nested
-    // native input <input> element depending on the current focus
-    // in browser window.
-    // For example, the focus is changed if the developer console
-    // get a focus.
-    let activeElement = getActiveElement();
-    while (activeElement) {
-      if (activeElement === element) {
-        return true;
-      }
-      if (activeElement.parentElement) {
-        activeElement = activeElement.parentElement;
-      } else {
-        activeElement = (activeElement.getRootNode() as ShadowRoot).host;
-      }
-    }
-    return false;
-  }
-
   async function testConfirmationDialog(cc?: boolean) {
-    const yesButton = queryAndAssert(
+    const yesButton = queryAndAssert<GrButton>(
       element,
       '.reviewerConfirmationButtons gr-button:first-child'
     );
-    const noButton = queryAndAssert(
+    const noButton = queryAndAssert<GrButton>(
       element,
       '.reviewerConfirmationButtons gr-button:last-child'
     );
 
-    element._ccPendingConfirmation = null;
-    element._reviewerPendingConfirmation = null;
-    flush();
+    element.ccPendingConfirmation = null;
+    element.reviewerPendingConfirmation = null;
+    await element.updateComplete;
     assert.isFalse(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+      isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
 
-    // Cause the confirmation dialog to display.
-    let observer = overlayObserver('opened');
     const group = {
       id: 'id' as GroupId,
       name: 'name' as GroupName,
     };
     if (cc) {
-      element._ccPendingConfirmation = {
+      element.ccPendingConfirmation = {
         group,
         confirm: false,
+        count: 10,
       };
     } else {
-      element._reviewerPendingConfirmation = {
+      element.reviewerPendingConfirmation = {
         group,
         confirm: false,
+        count: 10,
       };
     }
-    flush();
+    await element.updateComplete;
 
     if (cc) {
       assert.deepEqual(
-        element._ccPendingConfirmation,
-        element._pendingConfirmationDetails
+        element.ccPendingConfirmation,
+        element.pendingConfirmationDetails
       );
     } else {
       assert.deepEqual(
-        element._reviewerPendingConfirmation,
-        element._pendingConfirmationDetails
+        element.reviewerPendingConfirmation,
+        element.pendingConfirmationDetails
       );
     }
 
-    await observer;
     assert.isTrue(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+      isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
-    observer = overlayObserver('closed');
     const expected = 'Group name has 10 members';
     assert.notEqual(
-      (
-        queryAndAssert(element, 'reviewerConfirmationOverlay') as GrOverlay
+      queryAndAssert<HTMLElement>(
+        element,
+        '#reviewerConfirmationModal'
       ).innerText.indexOf(expected),
       -1
     );
-    tap(noButton); // close the overlay
-
-    await observer;
-    assert.isFalse(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    noButton.click(); // close the dialog
+    await waitUntil(
+      () => !isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
 
+    // TODO(dhruvsri): figure out why focus is not on the input element
     // We should be focused on account entry input.
-    assert.isTrue(
-      isFocusInsideElement(
-        (queryAndAssert(element, '#reviewers') as GrAccountList).$.entry.$.input
-          .$.input
-      )
-    );
+    // const reviewersEntry = queryAndAssert<GrAccountList>(element, '#reviewers');
+    // assert.isTrue(
+    //   isFocusInsideElement(
+    //     queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
+    //   )
+    // );
 
     // No reviewer/CC should have been added.
-    assert.equal(
-      (queryAndAssert(element, '#ccs') as GrAccountList).additions().length,
-      0
-    );
-    assert.equal(
-      (queryAndAssert(element, '#reviewers') as GrAccountList).additions()
-        .length,
-      0
-    );
+    assert.equal(element.ccsList?.additions().length, 0);
+    assert.equal(element.reviewersList?.additions().length, 0);
 
-    // Reopen confirmation dialog.
-    observer = overlayObserver('opened');
     if (cc) {
-      element._ccPendingConfirmation = {
+      element.ccPendingConfirmation = {
         group,
         confirm: false,
+        count: 1,
       };
     } else {
-      element._reviewerPendingConfirmation = {
+      element.reviewerPendingConfirmation = {
         group,
         confirm: false,
+        count: 1,
       };
     }
+    await element.updateComplete;
 
-    await observer;
     assert.isTrue(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+      isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
-    observer = overlayObserver('closed');
-    tap(yesButton); // Confirm the group.
 
-    await observer;
-    assert.isFalse(
-      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    yesButton.click(); // Confirm the group.
+    await waitUntil(
+      () => !isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
     );
     const additions = cc
-      ? (queryAndAssert(element, '#ccs') as GrAccountList).additions()
-      : (queryAndAssert(element, '#reviewers') as GrAccountList).additions();
+      ? element.ccsList?.additions()
+      : element.reviewersList?.additions();
     assert.deepEqual(additions, [
       {
-        group: {
-          id: 'id' as GroupId,
-          name: 'name' as GroupName,
-          confirmed: true,
-          _group: true,
-          _pendingAdd: true,
-        },
+        confirmed: true,
+        id: 'id' as GroupId,
+        name: 'name' as GroupName,
       },
     ]);
 
     // We should be focused on account entry input.
-    if (cc) {
-      assert.isTrue(
-        isFocusInsideElement(
-          (queryAndAssert(element, '#ccs') as GrAccountList).$.entry.$.input.$
-            .input
-        )
-      );
-    } else {
-      assert.isTrue(
-        isFocusInsideElement(
-          (queryAndAssert(element, '#reviewers') as GrAccountList).$.entry.$
-            .input.$.input
-        )
-      );
-    }
+    // TODO(dhruvsri): figure out why focus is not on the input element
+    // if (cc) {
+    //   const ccsEntry = queryAndAssert<GrAccountList>(element, '#ccs');
+    //   assert.isTrue(
+    //     isFocusInsideElement(
+    //       queryAndAssert<GrAutocomplete>(ccsEntry.entry, '#input').input!
+    //     )
+    //   );
+    // } else {
+    //   const reviewersEntry = queryAndAssert<GrAccountList>(
+    //     element,
+    //     '#reviewers'
+    //   );
+    //   assert.isTrue(
+    //     isFocusInsideElement(
+    //       queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
+    //     )
+    //   );
+    // }
   }
 
   test('cc confirmation', async () => {
@@ -1266,84 +1424,86 @@
     testConfirmationDialog(false);
   });
 
-  test('_getStorageLocation', () => {
-    const actual = element._getStorageLocation();
-    assert.equal(actual.changeNum, changeNum);
-    assert.equal(actual.patchNum, '@change');
-    assert.equal(actual.path, '@change');
+  suite('reviewer toast for WIP changes', () => {
+    let fireStub: sinon.SinonStub;
+    setup(() => {
+      fireStub = sinon.stub(element, 'dispatchEvent');
+    });
+
+    test('toast not fired if change is already active', async () => {
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+      };
+      element.send(false, false);
+
+      await waitUntil(() => fireStub.called);
+
+      const events = fireStub.args.map(arg => arg[0].type || '');
+      assert.isFalse(events.includes('show-alert'));
+    });
+
+    test('toast is not fired if change is WIP and becomes active', async () => {
+      const account = createAccountWithId(22);
+      element.reviewersList!.accounts = [];
+      element.reviewersList!.addAccountItem({account, count: 1});
+      element.reviewersList!.dispatchEvent(
+        new CustomEvent('account-added', {
+          detail: {account},
+        })
+      );
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+        work_in_progress: true,
+      };
+      element.send(false, true);
+
+      await waitUntil(() => fireStub.called);
+
+      const events = fireStub.args.map(arg => arg[0].type || '');
+      assert.isFalse(events.includes('show-alert'));
+    });
+
+    test('toast is fired if change is WIP and becomes active and reviewer added', async () => {
+      const account = createAccountWithId(22);
+      element.reviewersList!.accounts = [];
+      element.reviewersList!.addAccountItem({account, count: 1});
+      element.reviewersList!.dispatchEvent(
+        new CustomEvent('account-added', {
+          detail: {account},
+        })
+      );
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+        work_in_progress: true,
+      };
+      element.send(false, false);
+
+      await waitUntil(() => fireStub.called);
+
+      const events = fireStub.args.map(arg => arg[0].type || '');
+      assert.isTrue(events.includes('show-alert'));
+    });
   });
 
-  test('_reviewersMutated when account-text-change is fired from ccs', () => {
-    flush();
-    assert.isFalse(element._reviewersMutated);
-    assert.isTrue(
-      (queryAndAssert(element, '#ccs') as GrAccountList).allowAnyInput
-    );
+  test('reviewersMutated when account-text-change is fired from ccs', () => {
+    assert.isFalse(element.reviewersMutated);
+    assert.isTrue(queryAndAssert<GrAccountList>(element, '#ccs').allowAnyInput);
     assert.isFalse(
-      (queryAndAssert(element, '#reviewers') as GrAccountList).allowAnyInput
+      queryAndAssert<GrAccountList>(element, '#reviewers').allowAnyInput
     );
     queryAndAssert(element, '#ccs').dispatchEvent(
       new CustomEvent('account-text-changed', {bubbles: true, composed: true})
     );
-    assert.isTrue(element._reviewersMutated);
-  });
-
-  test('gets draft from storage on open', () => {
-    const storedDraft = 'hello world';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, storedDraft);
-  });
-
-  test('gets draft from storage even when text is already present', () => {
-    const storedDraft = 'hello world';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.draft = 'foo bar';
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, storedDraft);
-  });
-
-  test('blank if no stored draft', () => {
-    getDraftCommentStub.returns(null);
-    element.draft = 'foo bar';
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, '');
-  });
-
-  test('does not check stored draft when quote is present', () => {
-    const storedDraft = 'hello world';
-    const quote = '> foo bar';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.open(FocusTarget.ANY, quote);
-    assert.isFalse(getDraftCommentStub.called);
-    assert.equal(element.draft, quote);
-  });
-
-  test('updates stored draft on edits', async () => {
-    const clock = sinon.useFakeTimers();
-
-    const firstEdit = 'hello';
-    const location = element._getStorageLocation();
-
-    element.draft = firstEdit;
-    clock.tick(1000);
-    await flush();
-
-    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
-
-    element.draft = '';
-    clock.tick(1000);
-    await flush();
-
-    assert.isTrue(eraseDraftCommentStub.calledWith(location));
+    assert.isTrue(element.reviewersMutated);
   });
 
   test('400 converts to human-readable server-error', async () => {
     stubRestApi('saveChangeReview').callsFake(
       (_changeNum, _patchNum, _review, errFn) => {
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
         errFn!(
           cloneableResponse(
             400,
@@ -1365,7 +1525,7 @@
     };
     addListenerForTest(document, 'server-error', listener);
 
-    await flush();
+    await element.updateComplete;
     element.send(false, false);
     await promise;
   });
@@ -1373,6 +1533,7 @@
   test('non-json 400 is treated as a normal server-error', async () => {
     stubRestApi('saveChangeReview').callsFake(
       (_changeNum, _patchNum, _review, errFn) => {
+        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
         errFn!(cloneableResponse(400, 'Comment validation error!') as Response);
         return Promise.resolve(new Response());
       }
@@ -1390,7 +1551,7 @@
 
     // Async tick is needed because iron-selector content is distributed and
     // distributed content requires an observer to be set up.
-    await flush();
+    await element.updateComplete;
     element.send(false, false);
     await promise;
   });
@@ -1401,10 +1562,11 @@
     const reviewer2 = makeGroup();
     const cc1 = makeAccount();
     const cc2 = makeGroup();
-    let filter = element._filterReviewerSuggestionGenerator(false);
+    let filter = element.filterReviewerSuggestionGenerator(false);
 
-    element._owner = owner;
-    element._reviewers = [reviewer1, reviewer2];
+    element.change = createChange();
+    element.change.owner = owner;
+    element.reviewers = [reviewer1, reviewer2];
     element._ccs = [cc1, cc2];
 
     assert.isTrue(filter({account: makeAccount()} as Suggestion));
@@ -1417,80 +1579,86 @@
     assert.isFalse(filter({account: reviewer1} as Suggestion));
     assert.isFalse(filter({group: reviewer2} as Suggestion));
 
-    filter = element._filterReviewerSuggestionGenerator(true);
+    filter = element.filterReviewerSuggestionGenerator(true);
 
     // Existing and pending CCs should be excluded when isCC = true;.
     assert.isFalse(filter({account: cc1} as Suggestion));
     assert.isFalse(filter({group: cc2} as Suggestion));
   });
 
-  test('_focusOn', async () => {
-    const chooseFocusTargetSpy = sinon.spy(element, '_chooseFocusTarget');
-    element._focusOn();
-    await flush();
+  test('focusOn', async () => {
+    await element.updateComplete;
+    const chooseFocusTargetSpy = sinon.spy(element, 'chooseFocusTarget');
+    element.focusOn();
+    await waitUntilVisible(element); // let whenVisible resolve
+
     assert.equal(chooseFocusTargetSpy.callCount, 1);
-    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
-    assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
-
-    element._focusOn(element.FocusTarget.ANY);
-    await flush();
-    assert.equal(chooseFocusTargetSpy.callCount, 2);
-    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
-    assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
-
-    element._focusOn(element.FocusTarget.BODY);
-    await flush();
-    assert.equal(chooseFocusTargetSpy.callCount, 2);
-    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
-    assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
-
-    element._focusOn(element.FocusTarget.REVIEWERS);
-    await flush();
-    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
     assert.equal(
-      element?.shadowRoot?.activeElement?.tagName,
-      'GR-ACCOUNT-LIST'
+      element?.shadowRoot?.activeElement?.id,
+      'patchsetLevelComment'
+    );
+
+    element.focusOn(element.FocusTarget.ANY);
+    await waitUntilVisible(element); // let whenVisible resolve
+
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
+    assert.equal(
+      element?.shadowRoot?.activeElement?.id,
+      'patchsetLevelComment'
+    );
+
+    element.focusOn(element.FocusTarget.BODY);
+    await waitUntilVisible(element); // let whenVisible resolve
+
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
+    assert.equal(
+      element?.shadowRoot?.activeElement?.id,
+      'patchsetLevelComment'
+    );
+
+    element.focusOn(element.FocusTarget.REVIEWERS);
+    await waitUntilVisible(element); // let whenVisible resolve
+
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    await waitUntil(
+      () => element?.shadowRoot?.activeElement?.tagName === 'GR-ACCOUNT-LIST'
     );
     assert.equal(element?.shadowRoot?.activeElement?.id, 'reviewers');
 
-    element._focusOn(element.FocusTarget.CCS);
-    await flush();
+    element.focusOn(element.FocusTarget.CCS);
     assert.equal(chooseFocusTargetSpy.callCount, 2);
     assert.equal(
       element?.shadowRoot?.activeElement?.tagName,
       'GR-ACCOUNT-LIST'
     );
-    assert.equal(element?.shadowRoot?.activeElement?.id, 'ccs');
+    await waitUntil(() => element?.shadowRoot?.activeElement?.id === 'ccs');
   });
 
-  test('_chooseFocusTarget', () => {
-    element._account = undefined;
-    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+  test('chooseFocusTarget', () => {
+    element.account = undefined;
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
 
-    element._account = {_account_id: 1 as AccountId};
-    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+    element.account = element.change!.owner;
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.REVIEWERS);
 
-    element.change!.owner = {_account_id: 2 as AccountId};
-    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+    element.change!.reviewers.REVIEWER = [createAccountWithId(314)];
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
 
-    element.change!.owner._account_id = 1 as AccountId;
-    assert.strictEqual(
-      element._chooseFocusTarget(),
-      element.FocusTarget.REVIEWERS
-    );
+    element.change!.reviewers.REVIEWER = [createServiceUserWithId(314)];
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.REVIEWERS);
 
-    element._reviewers = [];
-    assert.strictEqual(
-      element._chooseFocusTarget(),
-      element.FocusTarget.REVIEWERS
-    );
-
-    element._reviewers.push({});
-    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+    element.change!.reviewers.REVIEWER = [
+      createAccountWithId(314),
+      createServiceUserWithId(314),
+    ];
+    assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
   });
 
   test('only send labels that have changed', async () => {
-    await flush();
+    await element.updateComplete;
     stubSaveReview((review: ReviewInput) => {
       assert.deepEqual(review?.labels, {
         'Code-Review': 0,
@@ -1502,22 +1670,24 @@
     element.addEventListener('send', () => {
       promise.resolve();
     });
-    // Without wrapping this test in flush(), the below two calls to
-    // tap() cause a race in some situations in shadow DOM.
-    // The send button can be tapped before the others, causing the test to
-    // fail.
-    const el = queryAndAssert(
+    // Without wrapping this test in await element.updateComplete, the below two
+    // calls to tap() cause a race in some situations in shadow DOM. The send
+    // button can be tapped before the others, causing the test to fail.
+    const el = queryAndAssert<GrLabelScoreRow>(
       queryAndAssert(element, 'gr-label-scores'),
       'gr-label-score-row[name="Verified"]'
-    ) as GrLabelScoreRow;
+    );
     el.setSelectedValue('-1');
-    tap(queryAndAssert(element, '.send'));
+
+    // required so that "Send" button is enabled
+    element.canBeStarted = true;
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(element, '.send').click();
     await promise;
   });
 
-  test('moving from cc to reviewer', () => {
-    flush();
-
+  test('moving from cc to reviewer', async () => {
     const reviewer1 = makeAccount();
     const reviewer2 = makeAccount();
     const reviewer3 = makeAccount();
@@ -1525,23 +1695,41 @@
     const cc2 = makeAccount();
     const cc3 = makeAccount();
     const cc4 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2, reviewer3];
-    element._ccs = [cc1, cc2, cc3, cc4];
-    element.push('_reviewers', cc1);
-    flush();
+    element.reviewersList!.accounts = [reviewer1, reviewer2, reviewer3];
+    element.ccsList!.accounts = [cc1, cc2, cc3, cc4];
+    await element.updateComplete;
+    element.reviewersList!.accounts.push(cc1);
 
-    assert.deepEqual(element._reviewers, [
-      reviewer1,
-      reviewer2,
-      reviewer3,
-      cc1,
-    ]);
-    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
+    element.reviewers = element.reviewersList!.accounts;
+    element.ccs = element.ccsList!.accounts;
 
-    element.push('_reviewers', cc4, cc3);
-    flush();
+    element.reviewersList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: cc1},
+      })
+    );
+    await element.updateComplete;
 
-    assert.deepEqual(element._reviewers, [
+    assert.deepEqual(element.reviewers, [reviewer1, reviewer2, reviewer3, cc1]);
+    assert.deepEqual(element.ccs, [cc2, cc3, cc4]);
+
+    element.reviewersList!.addAccountItem({account: cc4, count: 1});
+    element.reviewersList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: cc4},
+      })
+    );
+    await element.updateComplete;
+
+    element.reviewersList!.addAccountItem({account: cc3, count: 1});
+    element.reviewersList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: cc3},
+      })
+    );
+    await element.updateComplete;
+
+    assert.deepEqual(element.reviewers, [
       reviewer1,
       reviewer2,
       reviewer3,
@@ -1549,54 +1737,68 @@
       cc4,
       cc3,
     ]);
-    assert.deepEqual(element._ccs, [cc2]);
+    assert.deepEqual(element.ccs, [cc2]);
   });
 
-  test('update attention section when reviewers and ccs change', () => {
-    element._account = makeAccount();
-    element._reviewers = [makeAccount(), makeAccount()];
+  test('update attention section when reviewers and ccs change', async () => {
+    element.account = makeAccount();
+    element.reviewers = [makeAccount(), makeAccount()];
     element._ccs = [makeAccount(), makeAccount()];
     element.draftCommentThreads = [];
-    const modifyButton = queryAndAssert(element, '.edit-attention-button');
-    tap(modifyButton);
-    flush();
 
-    // "Modify" button disabled, because "Send" button is disabled.
-    assert.isFalse(element._attentionExpanded);
-    element.draft = 'a test comment';
-    tap(modifyButton);
-    flush();
-    assert.isTrue(element._attentionExpanded);
+    const modifyButton = queryAndAssert<GrButton>(
+      element,
+      '.edit-attention-button'
+    );
+    modifyButton.click();
+
+    await element.updateComplete;
+
+    assert.isFalse(element.attentionExpanded);
+
+    element.patchsetLevelDraftMessage = 'a test comment';
+    await element.updateComplete;
+
+    modifyButton.click();
+
+    await element.updateComplete;
+
+    assert.isTrue(element.attentionExpanded);
 
     let accountLabels = Array.from(
       queryAll(element, '.attention-detail gr-account-label')
     );
     assert.equal(accountLabels.length, 5);
 
-    element.push('_reviewers', makeAccount());
-    element.push('_ccs', makeAccount());
-    flush();
+    element.reviewers = [...element.reviewers, makeAccount()];
+    element._ccs = [...element.ccs, makeAccount()];
+    await element.updateComplete;
 
     // The 'attention modified' section collapses and resets when reviewers or
     // ccs change.
-    assert.isFalse(element._attentionExpanded);
+    assert.isFalse(element.attentionExpanded);
 
-    tap(queryAndAssert(element, '.edit-attention-button'));
-    flush();
+    queryAndAssert<GrButton>(element, '.edit-attention-button').click();
+    await element.updateComplete;
 
-    assert.isTrue(element._attentionExpanded);
+    assert.isTrue(element.attentionExpanded);
     accountLabels = Array.from(
       queryAll(element, '.attention-detail gr-account-label')
     );
     assert.equal(accountLabels.length, 7);
 
-    element.pop('_reviewers');
-    element.pop('_reviewers');
-    element.pop('_ccs');
-    element.pop('_ccs');
+    element.reviewers.pop();
+    element.reviewers.pop();
+    element._ccs.pop();
+    element._ccs.pop();
+    element.reviewers = [...element.reviewers];
+    element._ccs = [...element.ccs]; // trigger willUpdate observer
 
-    tap(queryAndAssert(element, '.edit-attention-button'));
-    flush();
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(element, '.edit-attention-button').click();
+
+    await element.updateComplete;
 
     accountLabels = Array.from(
       queryAll(element, '.attention-detail gr-account-label')
@@ -1604,9 +1806,7 @@
     assert.equal(accountLabels.length, 3);
   });
 
-  test('moving from reviewer to cc', () => {
-    flush();
-
+  test('moving from reviewer to cc', async () => {
     const reviewer1 = makeAccount();
     const reviewer2 = makeAccount();
     const reviewer3 = makeAccount();
@@ -1614,19 +1814,43 @@
     const cc2 = makeAccount();
     const cc3 = makeAccount();
     const cc4 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2, reviewer3];
-    element._ccs = [cc1, cc2, cc3, cc4];
-    element.push('_ccs', reviewer1);
-    flush();
+    element.reviewersList!.accounts = [reviewer1, reviewer2, reviewer3];
+    element.ccsList!.accounts = [cc1, cc2, cc3, cc4];
+    element.reviewers = element.reviewersList!.accounts;
+    element._ccs = element.ccsList!.accounts;
+    element._ccs.push(reviewer1);
+    element.ccsList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: reviewer1},
+      })
+    );
 
-    assert.deepEqual(element._reviewers, [reviewer2, reviewer3]);
-    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
+    await element.updateComplete;
+    await element.updateComplete;
+    await element.updateComplete;
+    await element.updateComplete;
 
-    element.push('_ccs', reviewer3, reviewer2);
-    flush();
+    assert.deepEqual(element.reviewers, [reviewer2, reviewer3]);
+    assert.deepEqual(element.ccs, [cc1, cc2, cc3, cc4, reviewer1]);
 
-    assert.deepEqual(element._reviewers, []);
-    assert.deepEqual(element._ccs, [
+    element.ccsList!.addAccountItem({account: reviewer3, count: 1});
+    element.ccsList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: reviewer3},
+      })
+    );
+    await element.updateComplete;
+
+    element.ccsList!.addAccountItem({account: reviewer2, count: 1});
+    element.ccsList!.dispatchEvent(
+      new CustomEvent('account-added', {
+        detail: {account: reviewer2},
+      })
+    );
+    await element.updateComplete;
+
+    assert.deepEqual(element.reviewers, []);
+    assert.deepEqual(element.ccs, [
       cc1,
       cc2,
       cc3,
@@ -1638,28 +1862,30 @@
   });
 
   test('migrate reviewers between states', async () => {
-    flush();
-    const reviewers = queryAndAssert(element, '#reviewers') as GrAccountList;
-    const ccs = queryAndAssert(element, '#ccs') as GrAccountList;
+    const reviewers = queryAndAssert<GrAccountList>(element, '#reviewers');
+    const ccs = queryAndAssert<GrAccountList>(element, '#ccs');
     const reviewer1 = makeAccount();
     const reviewer2 = makeAccount();
     const cc1 = makeAccount();
     const cc2 = makeAccount();
     const cc3 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2];
+    element.reviewers = [reviewer1, reviewer2];
     element._ccs = [cc1, cc2, cc3];
 
     element.change!.reviewers = {
       [ReviewerState.CC]: [],
       [ReviewerState.REVIEWER]: [{_account_id: 33 as AccountId}],
     };
+    await element.updateComplete;
 
     const mutations: ReviewerInput[] = [];
 
     stubSaveReview((review: ReviewInput) => {
-      mutations.push(...review!.reviewers!);
+      mutations.push(...review.reviewers!);
     });
 
+    assert.isFalse(element.reviewersMutated);
+
     // Remove and add to other field.
     reviewers.dispatchEvent(
       new CustomEvent('remove', {
@@ -1668,7 +1894,10 @@
         bubbles: true,
       })
     );
-    ccs.$.entry.dispatchEvent(
+
+    await element.updateComplete;
+    assert.isTrue(element.reviewersMutated);
+    ccs.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: reviewer1}},
         composed: true,
@@ -1689,7 +1918,7 @@
         bubbles: true,
       })
     );
-    reviewers.$.entry.dispatchEvent(
+    reviewers.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: cc1}},
         composed: true,
@@ -1697,16 +1926,36 @@
       })
     );
 
-    // Add to other field without removing from former field.
-    // (Currently not possible in UI, but this is a good consistency check).
-    reviewers.$.entry.dispatchEvent(
+    assert.deepEqual(
+      element.reviewers.map(v => accountKey(v)),
+      [reviewer2, cc1].map(v => accountKey(v))
+    );
+    assert.deepEqual(
+      element.ccs.map(v => accountKey(v)),
+      [cc2, reviewer1].map(v => accountKey(v))
+    );
+
+    // Add to Reviewer/CC which will automatically remove from CC/Reviewer.
+    reviewers.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: cc2}},
         composed: true,
         bubbles: true,
       })
     );
-    ccs.$.entry.dispatchEvent(
+
+    await element.updateComplete;
+
+    assert.deepEqual(
+      element.reviewers.map(v => accountKey(v)),
+      [reviewer2, cc1, cc2].map(v => accountKey(v))
+    );
+    assert.deepEqual(
+      element.ccs.map(v => accountKey(v)),
+      [reviewer1].map(v => accountKey(v))
+    );
+
+    ccs.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: reviewer2}},
         composed: true,
@@ -1714,6 +1963,17 @@
       })
     );
 
+    await element.updateComplete;
+
+    assert.deepEqual(
+      element.reviewers.map(v => accountKey(v)),
+      [cc1, cc2].map(v => accountKey(v))
+    );
+    assert.deepEqual(
+      element.ccs.map(v => accountKey(v)),
+      [reviewer1, reviewer2].map(v => accountKey(v))
+    );
+
     const mapReviewer = function (
       reviewer: AccountInfo,
       opt_state?: ReviewerState
@@ -1729,33 +1989,27 @@
 
     // Send and purge and verify moves, delete cc3.
     await element.send(false, false);
-    expect(mutations).to.have.lengthOf(5);
-    expect(mutations[0]).to.deep.equal(
-      mapReviewer(cc1, ReviewerState.REVIEWER)
-    );
-    expect(mutations[1]).to.deep.equal(
-      mapReviewer(cc2, ReviewerState.REVIEWER)
-    );
-    expect(mutations[2]).to.deep.equal(
-      mapReviewer(reviewer1, ReviewerState.CC)
-    );
-    expect(mutations[3]).to.deep.equal(
-      mapReviewer(reviewer2, ReviewerState.CC)
-    );
+    await element.updateComplete;
+    assert.equal(mutations.length, 5);
+
+    assert.deepEqual(mutations[0], mapReviewer(cc1, ReviewerState.REVIEWER));
+    assert.deepEqual(mutations[1], mapReviewer(cc2, ReviewerState.REVIEWER));
+    assert.deepEqual(mutations[2], mapReviewer(reviewer1, ReviewerState.CC));
+    assert.deepEqual(mutations[3], mapReviewer(reviewer2, ReviewerState.CC));
 
     // Only 1 account was initially part of the change
-    expect(mutations[4]).to.deep.equal({
-      reviewer: 33,
+    assert.deepEqual(mutations[4], {
+      reviewer: 33 as UserId,
       state: ReviewerState.REMOVED,
     });
   });
 
   test('Ignore removal requests if being added as reviewer/CC', async () => {
-    flush();
-    const reviewers = queryAndAssert(element, '#reviewers') as GrAccountList;
-    const ccs = queryAndAssert(element, '#ccs') as GrAccountList;
+    await element.updateComplete;
+    const reviewers = queryAndAssert<GrAccountList>(element, '#reviewers');
+    const ccs = queryAndAssert<GrAccountList>(element, '#ccs');
     const reviewer1 = makeAccount();
-    element._reviewers = [reviewer1];
+    element.reviewers = [reviewer1];
     element._ccs = [];
 
     element.change!.reviewers = {
@@ -1763,10 +2017,12 @@
       [ReviewerState.REVIEWER]: [{_account_id: reviewer1._account_id}],
     };
 
+    await element.updateComplete;
+
     const mutations: ReviewerInput[] = [];
 
     stubSaveReview((review: ReviewInput) => {
-      mutations.push(...review!.reviewers!);
+      mutations.push(...review.reviewers!);
     });
 
     // Remove and add to other field.
@@ -1777,7 +2033,7 @@
         bubbles: true,
       })
     );
-    ccs.$.entry.dispatchEvent(
+    ccs.entry!.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: {account: reviewer1}},
         composed: true,
@@ -1786,19 +2042,43 @@
     );
 
     await element.send(false, false);
-    expect(mutations).to.have.lengthOf(1);
+    assert.lengthOf(mutations, 1);
     // Only 1 account was initially part of the change
-    expect(mutations[0]).to.deep.equal({
+    assert.deepEqual(mutations[0], {
       reviewer: reviewer1._account_id,
       state: ReviewerState.CC,
     });
   });
 
-  test('emits cancel on esc key', () => {
+  test('Ignore removal requests from reviewer if owner', async () => {
+    await element.updateComplete;
+    const reviewer1 = makeAccount();
+    element.reviewers = [reviewer1];
+    element._ccs = [];
+    element.change!.owner = reviewer1;
+
+    element.change!.reviewers = {
+      [ReviewerState.CC]: [],
+      [ReviewerState.REVIEWER]: [{_account_id: reviewer1._account_id}],
+    };
+
+    await element.updateComplete;
+
+    const mutations: ReviewerInput[] = [];
+
+    stubSaveReview((review: ReviewInput) => {
+      mutations.push(...review.reviewers!);
+    });
+
+    await element.send(false, false);
+    assert.lengthOf(mutations, 0);
+  });
+
+  test('emits cancel on esc key', async () => {
     const cancelHandler = sinon.spy();
     element.addEventListener('cancel', cancelHandler);
-    pressAndReleaseKeyOn(element, 27, null, 'Escape');
-    flush();
+    pressKey(element, Key.ESC);
+    await element.updateComplete;
 
     assert.isTrue(cancelHandler.called);
   });
@@ -1806,37 +2086,45 @@
   test('should not send on enter key', () => {
     stubSaveReview(() => undefined);
     element.addEventListener('send', () => assert.fail('wrongly called'));
-    pressAndReleaseKeyOn(element, 13, null, 'Enter');
+    pressKey(element, Key.ENTER);
   });
 
   test('emit send on ctrl+enter key', async () => {
+    // required so that "Send" button is enabled
+    element.canBeStarted = true;
+    await element.updateComplete;
+
     stubSaveReview(() => undefined);
     const promise = mockPromise();
     element.addEventListener('send', () => promise.resolve());
-    pressAndReleaseKeyOn(element, 13, 'ctrl', 'Enter');
+    pressKey(element, Key.ENTER, Modifier.CTRL_KEY);
     await promise;
   });
 
-  test('_computeMessagePlaceholder', () => {
+  test('computeMessagePlaceholder', async () => {
+    element.canBeStarted = false;
+    await element.updateComplete;
+    assert.equal(element.messagePlaceholder, 'Say something nice...');
+
+    element.canBeStarted = true;
+    await element.updateComplete;
     assert.equal(
-      element._computeMessagePlaceholder(false),
-      'Say something nice...'
-    );
-    assert.equal(
-      element._computeMessagePlaceholder(true),
+      element.messagePlaceholder,
       'Add a note for your reviewers...'
     );
   });
 
-  test('_computeSendButtonLabel', () => {
-    assert.equal(element._computeSendButtonLabel(false), 'Send');
-    assert.equal(
-      element._computeSendButtonLabel(true),
-      'Send and Start review'
-    );
+  test('computeSendButtonLabel', async () => {
+    element.canBeStarted = false;
+    await element.updateComplete;
+    assert.equal(element.sendButtonLabel, 'Send');
+
+    element.canBeStarted = true;
+    await element.updateComplete;
+    assert.equal(element.sendButtonLabel, 'Send and Start review');
   });
 
-  test('_handle400Error reviewers and CCs', async () => {
+  test('handle400Error reviewers and CCs', async () => {
     const error1 = 'error 1';
     const error2 = 'error 2';
     const error3 = 'error 3';
@@ -1866,21 +2154,10 @@
       });
     };
     addListenerForTest(document, 'server-error', listener);
-    element._handle400Error(cloneableResponse(400, text) as Response);
+    element.handle400Error(cloneableResponse(400, text) as Response);
     await promise;
   });
 
-  test('fires height change when the drafts comments load', async () => {
-    // Flush DOM operations before binding to the autogrow event so we don't
-    // catch the events fired from the initial layout.
-    await flush();
-    const autoGrowHandler = sinon.stub();
-    element.addEventListener('autogrow', autoGrowHandler);
-    element.draftCommentThreads = [];
-    await flush();
-    assert.isTrue(autoGrowHandler.called);
-  });
-
   suite('start review and save buttons', () => {
     let sendStub: sinon.SinonStub;
 
@@ -1888,18 +2165,18 @@
       sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
       element.canBeStarted = true;
       // Flush to make both Start/Save buttons appear in DOM.
-      await flush();
+      await element.updateComplete;
     });
 
     test('start review sets ready', async () => {
-      tap(queryAndAssert(element, '.send'));
-      await flush();
+      queryAndAssert<GrButton>(element, '.send').click();
+      await element.updateComplete;
       assert.isTrue(sendStub.calledWith(true, true));
     });
 
     test("save review doesn't set ready", async () => {
-      tap(queryAndAssert(element, '.save'));
-      await flush();
+      queryAndAssert<GrButton>(element, '.save').click();
+      await element.updateComplete;
       assert.isTrue(sendStub.calledWith(true, false));
     });
   });
@@ -1918,15 +2195,15 @@
     const expectedError = new Error('test');
 
     setup(() => {
-      element.draft = expectedDraft;
+      element.patchsetLevelDraftMessage = expectedDraft;
     });
 
     function assertDialogOpenAndEnabled() {
-      assert.strictEqual(expectedDraft, element.draft);
+      assert.strictEqual(expectedDraft, element.patchsetLevelDraftMessage);
       assert.isFalse(element.disabled);
     }
 
-    test('error occurs in _saveReview', () => {
+    test('error occurs in saveReview', () => {
       stubSaveReview(() => {
         throw expectedError;
       });
@@ -1947,243 +2224,530 @@
         element.open();
 
         assert.isFalse(refreshSpy.called);
-        assert.isTrue(element._savingComments);
+        assert.isTrue(element.savingComments);
 
         promise.resolve();
-        await flush();
+        await element.updateComplete;
 
         assert.isTrue(refreshSpy.called);
-        assert.isFalse(element._savingComments);
+        assert.isFalse(element.savingComments);
       });
 
       test('no', () => {
         stubRestApi('hasPendingDiffDrafts').returns(0);
         element.open();
-        assert.isFalse(element._savingComments);
+        assert.isFalse(element.savingComments);
       });
     });
   });
 
-  test('_computeSendButtonDisabled_canBeStarted', () => {
+  test('computeSendButtonDisabled_canBeStarted', () => {
     // Mock canBeStarted
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ true,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = true;
+    element.draftCommentThreads = [];
+    element.patchsetLevelDraftMessage = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_allFalse', () => {
+  test('computeSendButtonDisabled_allFalse', () => {
     // Mock everything false
-    assert.isTrue(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = false;
+    element.draftCommentThreads = [];
+    element.patchsetLevelDraftMessage = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+    assert.isTrue(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_draftCommentsSend', () => {
-    // Mock nonempty comment draft array, with sending comments.
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [
-          {...createCommentThread([{__draft: true}])},
-        ],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ true,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+  test('computeSendButtonDisabled_draftCommentsSend', () => {
+    // Mock nonempty comment draft array; with sending comments.
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.patchsetLevelDraftMessage = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = true;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
-    // Mock nonempty comment draft array, without sending comments.
-    assert.isTrue(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [
-          {...createCommentThread([{__draft: true}])},
-        ],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+  test('computeSendButtonDisabled_draftCommentsDoNotSend', () => {
+    // Mock nonempty comment draft array; without sending comments.
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.patchsetLevelDraftMessage = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+
+    assert.isTrue(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_changeMessage', () => {
+  test('computeSendButtonDisabled_changeMessage', () => {
     // Mock nonempty change message.
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
-        /* text= */ 'test',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.patchsetLevelDraftMessage = 'test';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_reviewersChanged', () => {
+  test('computeSendButtonDisabledreviewersChanged', () => {
     // Mock reviewers mutated.
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
-        /* text= */ '',
-        /* reviewersMutated= */ true,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.patchsetLevelDraftMessage = '';
+    element.reviewersMutated = true;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_labelsChanged', () => {
+  test('computeSendButtonDisabled_labelsChanged', () => {
     // Mock labels changed.
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.patchsetLevelDraftMessage = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = true;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = makeAccount();
+
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_dialogDisabled', () => {
+  test('computeSendButtonDisabled_dialogDisabled', () => {
     // Whole dialog is disabled.
-    assert.isTrue(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ true,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-      )
-    );
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.patchsetLevelDraftMessage = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = true;
+    element.includeComments = false;
+    element.disabled = true;
+    element.commentEditing = false;
+    element.account = makeAccount();
+
+    assert.isTrue(element.computeSendButtonDisabled());
   });
 
-  test('_computeSendButtonDisabled_existingVote', async () => {
+  test('computeSendButtonDisabled_existingVote', async () => {
     const account = createAccountWithId();
     (
       element.change!.labels![StandardLabels.CODE_REVIEW]! as DetailedLabelInfo
     ).all = [account];
-    await flush();
+    element.canBeStarted = false;
+    element.draftCommentThreads = [{...createCommentThread([createComment()])}];
+    element.patchsetLevelDraftMessage = '';
+    element.reviewersMutated = false;
+    element.labelsChanged = false;
+    element.includeComments = false;
+    element.disabled = false;
+    element.commentEditing = false;
+    element.account = account;
 
     // User has already voted.
-    assert.isFalse(
-      element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ account
-      )
-    );
+    assert.isFalse(element.computeSendButtonDisabled());
   });
 
   test('_submit blocked when no mutations exist', async () => {
     const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
     element.draftCommentThreads = [];
-    await flush();
+    await element.updateComplete;
 
-    tap(queryAndAssert(element, 'gr-button.send'));
+    queryAndAssert<GrButton>(element, 'gr-button.send').click();
     assert.isFalse(sendStub.called);
 
     element.draftCommentThreads = [
       {
         ...createCommentThread([
-          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+          {
+            ...createDraft(),
+            path: 'test',
+            line: 1,
+            patch_set: 1 as RevisionPatchSetNum,
+          },
         ]),
       },
     ];
-    await flush();
+    await element.updateComplete;
 
-    tap(queryAndAssert(element, 'gr-button.send'));
+    queryAndAssert<GrButton>(element, 'gr-button.send').click();
     assert.isTrue(sendStub.called);
   });
 
-  test('getFocusStops', async () => {
-    // Setting draftCommentThreads to an empty object causes _sendDisabled to be
-    // computed to false.
-    element.draftCommentThreads = [];
-    await flush();
+  suite('patchset level comment using GrComment', () => {
+    setup(async () => {
+      element.account = createAccountWithId(1);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
 
-    assert.equal(
-      element.getFocusStops().end,
-      queryAndAssert(element, '#cancelButton')
-    );
-    element.draftCommentThreads = [
-      {
-        ...createCommentThread([
-          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
-        ]),
-      },
-    ];
-    await flush();
+    test('renders GrComment', () => {
+      assert.dom.equal(
+        query(element, '.patchsetLevelContainer'),
+        /* HTML */ `
+          <div class="patchsetLevelContainer resolved">
+            <gr-endpoint-decorator name="reply-text">
+              <gr-comment
+                hide-header=""
+                id="patchsetLevelComment"
+                permanent-editing-mode=""
+              >
+              </gr-comment>
+              <gr-endpoint-param name="change"> </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </div>
+        `
+      );
+    });
 
-    assert.equal(
-      element.getFocusStops().end,
-      queryAndAssert(element, '#sendButton')
-    );
+    test('send button updates state as text is typed in patchset comment', async () => {
+      assert.isTrue(element.computeSendButtonDisabled());
+
+      queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
+        'hello';
+      await waitUntil(() => element.patchsetLevelDraftMessage === 'hello');
+
+      assert.isFalse(element.computeSendButtonDisabled());
+
+      queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
+        '';
+      await waitUntil(() => element.patchsetLevelDraftMessage === '');
+
+      assert.isTrue(element.computeSendButtonDisabled());
+    });
+
+    test('sending patchset level comment', async () => {
+      const patchsetLevelComment = queryAndAssert<GrComment>(
+        element,
+        '#patchsetLevelComment'
+      );
+      const autoSaveStub = sinon
+        .stub(patchsetLevelComment, 'save')
+        .returns(Promise.resolve());
+
+      patchsetLevelComment.messageText = 'hello world';
+      await waitUntil(
+        () => element.patchsetLevelDraftMessage === 'hello world'
+      );
+
+      const saveReviewPromise = interceptSaveReview();
+
+      assert.deepEqual(autoSaveStub.callCount, 0);
+
+      queryAndAssert<GrButton>(element, '.send').click();
+
+      const review = await saveReviewPromise;
+
+      assert.deepEqual(autoSaveStub.callCount, 1);
+
+      assert.deepEqual(review, {
+        drafts: 'PUBLISH_ALL_REVISIONS',
+        labels: {
+          'Code-Review': 0,
+          Verified: 0,
+        },
+        reviewers: [],
+        add_to_attention_set: [
+          {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+        ],
+        remove_from_attention_set: [],
+        ignore_automatic_attention_set_rules: true,
+      });
+    });
+
+    test('comment is auto saved when dialog is canceled', async () => {
+      const patchsetLevelComment = queryAndAssert<GrComment>(
+        element,
+        '#patchsetLevelComment'
+      );
+      const autoSaveStub = sinon
+        .stub(patchsetLevelComment, 'save')
+        .returns(Promise.resolve());
+
+      patchsetLevelComment.messageText = 'hello world';
+
+      await waitUntil(
+        () => element.patchsetLevelDraftMessage === 'hello world'
+      );
+      assert.deepEqual(autoSaveStub.callCount, 0);
+
+      patchsetLevelComment.messageText = '';
+      queryAndAssert<GrButton>(element, '#cancelButton').click();
+
+      await waitUntil(() => autoSaveStub.callCount === 1);
+
+      assert.deepEqual(patchsetLevelComment.messageText, '');
+    });
+
+    test('replies to patchset level comments are not filtered out', async () => {
+      const draft = {...createDraft(), in_reply_to: '1' as UrlEncodedCommentId};
+      commentsModel.setState({
+        drafts: {
+          'abc.txt': [draft],
+        },
+        discardedDrafts: [],
+      });
+      await waitUntil(() => element.draftCommentThreads.length === 1);
+
+      // patchset level draft as a reply is not loaded in patchsetLevel comment
+      assert.equal(element.patchsetLevelDraftMessage, '');
+
+      assert.deepEqual(element.draftCommentThreads[0].comments[0], draft);
+    });
   });
 
-  test('setPluginMessage', () => {
+  suite('mention users', () => {
+    setup(async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(true);
+      element.account = createAccountWithId(1);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('mentioned user in resolved draft is added to CC', async () => {
+      const account = {
+        ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+        _account_id: 1234 as AccountId,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      };
+      stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+      const draft = {
+        ...createDraft(),
+        message: 'hey @abcd@def take a look at this',
+      };
+      commentsModel.setState({
+        comments: {},
+        robotComments: {},
+        drafts: {
+          a: [draft],
+        },
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+
+      element.draftCommentThreads = [createCommentThread([draft])];
+      await waitUntil(() => element.mentionedUsers.length > 0);
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.mentionedUsers, [account]);
+      assert.deepEqual(element.ccs, [account]);
+
+      // owner(999) is added since (accountId = 1) replied to the change
+      assert.sameMembers([...element.newAttentionSet], [999 as AccountId]);
+    });
+
+    test('mentioned user in unresolved draft is added to CC and AttentionSet', async () => {
+      const account = {
+        ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+        _account_id: 1234 as AccountId,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      };
+      stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+      const draft = {
+        ...createDraft(),
+        message: 'hey @abcd@def.com take a look at this',
+        unresolved: true,
+      };
+      commentsModel.setState({
+        comments: {},
+        robotComments: {},
+        drafts: {
+          a: [draft],
+        },
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+      element.draftCommentThreads = [createCommentThread([draft])];
+      await waitUntil(
+        () => element.mentionedUsersInUnresolvedDrafts.length > 0
+      );
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.mentionedUsers, [account]);
+      assert.deepEqual(element.ccs, [account]);
+
+      // owner(999) is added since (accountId = 1) replied to the change
+      assert.sameMembers(
+        [...element.newAttentionSet],
+        [999 as AccountId, 1234 as AccountId]
+      );
+    });
+
+    test('mention user can be manually removed from attention set', async () => {
+      stubRestApi('getAccountDetails').returns(
+        Promise.resolve({
+          ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+          _account_id: 1234 as AccountId,
+          registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+        })
+      );
+      const draft = {
+        ...createDraft(),
+        message: 'hey @abcd@def.com take a look at this',
+        unresolved: true,
+      };
+      commentsModel.setState({
+        comments: {},
+        robotComments: {},
+        drafts: {
+          a: [draft],
+        },
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+      element.draftCommentThreads = [createCommentThread([draft])];
+      await waitUntil(
+        () => element.mentionedUsersInUnresolvedDrafts.length > 0
+      );
+
+      await element.updateComplete;
+
+      // owner(999) is added since (accountId = 1) replied to the change
+      assert.sameMembers(
+        [...element.newAttentionSet],
+        [999 as AccountId, 1234 as AccountId]
+      );
+
+      const modifyButton = queryAndAssert<GrButton>(
+        element,
+        '.edit-attention-button'
+      );
+      modifyButton.click();
+      await element.updateComplete;
+
+      const accountsChips = Array.from(
+        queryAll<GrAccountLabel>(element, '.attention-detail gr-account-label')
+      );
+      assert.deepEqual(accountsChips[1].account, {
+        email: 'abcd@def.com' as EmailAddress,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+        _account_id: 1234 as AccountId,
+      } as AccountInfo);
+      accountsChips[1].click();
+
+      await element.updateComplete;
+
+      assert.sameMembers([...element.newAttentionSet], [999 as AccountId]);
+    });
+
+    test('mention user who is already CCed', async () => {
+      const account = {
+        ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+        _account_id: 1234 as AccountId,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      };
+      stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+
+      commentsModel.setState({
+        comments: {},
+        robotComments: {},
+        drafts: {
+          a: [
+            {
+              ...createDraft(),
+              message: 'hey @abcd@def.com take a look at this',
+              unresolved: true,
+            },
+          ],
+        },
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+
+      await element.updateComplete;
+      await waitUntil(() => element.mentionedUsers.length > 0);
+
+      assert.deepEqual(element.ccs, [account]);
+      assert.deepEqual(element.mentionedUsers, [account]);
+      element._ccs = [account];
+
+      await element.updateComplete;
+
+      assert.deepEqual(element.mentionedUsers, [account]);
+      assert.deepEqual(element.ccs, [account]);
+    });
+
+    test('mention user who is already a reviewer', async () => {
+      const account = {
+        ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+        _account_id: 1234 as AccountId,
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      };
+      stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+      commentsModel.setState({
+        comments: {},
+        robotComments: {},
+        drafts: {
+          a: [
+            {
+              ...createDraft(),
+              message: 'hey @abcd@def.com take a look at this',
+              unresolved: true,
+            },
+          ],
+        },
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+
+      await element.updateComplete;
+      await waitUntil(() => element.mentionedUsers.length > 0);
+
+      assert.deepEqual(element.mentionedUsers, [account]);
+
+      // ensure updates to reviewers is reflected to mentionedUsers property
+      element.reviewers = [account];
+
+      await element.updateComplete;
+
+      // overall ccs is empty since we filter out existing reviewers
+      assert.deepEqual(element.ccs, []);
+      assert.deepEqual(element.mentionedUsers, [account]);
+      assert.deepEqual(element.reviewers, [account]);
+    });
+  });
+
+  test('setPluginMessage', async () => {
     element.setPluginMessage('foo');
+    await element.updateComplete;
     assert.equal(queryAndAssert(element, '#pluginMessage').textContent, 'foo');
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index de11b16..9408b82 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
@@ -1,228 +1,180 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-vote-chip/gr-vote-chip';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-reviewer-list_html';
-import {customElement, property, computed, observe} from '@polymer/decorators';
+import {LitElement, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+
 import {
   ChangeInfo,
-  LabelNameToValueMap,
   AccountInfo,
   ApprovalInfo,
-  Reviewers,
-  AccountId,
-  EmailAddress,
   AccountDetailInfo,
   isDetailedLabelInfo,
   LabelInfo,
 } from '../../../types/common';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {isRemovableReviewer} from '../../../utils/change-util';
-import {ReviewerState} from '../../../constants/constants';
-import {appContext} from '../../../services/app-context';
-import {fireAlert} from '../../../utils/event-util';
 import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
 import {sortReviewers} from '../../../utils/attention-set-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css} from 'lit';
+import {nothing} from 'lit';
 
 @customElement('gr-reviewer-list')
-export class GrReviewerList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrReviewerList extends LitElement {
   /**
    * Fired when the "Add reviewer..." button is tapped.
    *
    * @event show-reply-dialog
    */
 
-  @property({type: Object})
-  change?: ChangeInfo;
+  @property({type: Object}) change?: ChangeInfo;
 
-  @property({type: Object})
-  account?: AccountDetailInfo;
+  @property({type: Object}) account?: AccountDetailInfo;
 
-  @property({type: Boolean, reflectToAttribute: true})
-  disabled = false;
+  @property({type: Boolean, reflect: true}) disabled = false;
 
-  @property({type: Boolean})
-  mutable = false;
+  @property({type: Boolean}) mutable = false;
 
-  @property({type: Boolean})
-  reviewersOnly = false;
+  @property({type: Boolean, attribute: 'reviewers-only'}) reviewersOnly = false;
 
-  @property({type: Boolean})
-  ccsOnly = false;
+  @property({type: Boolean, attribute: 'ccs-only'}) ccsOnly = false;
 
-  @property({type: Array})
-  _displayedReviewers: AccountInfo[] = [];
+  @state() displayedReviewers: AccountInfo[] = [];
 
-  @property({type: Array})
-  _reviewers: AccountInfo[] = [];
+  @state() reviewers: AccountInfo[] = [];
 
-  @property({type: Boolean})
-  _showInput = false;
+  @state() hiddenReviewerCount?: number;
 
-  @property({type: Object})
-  _xhrPromise?: Promise<Response | undefined>;
+  @state() showAllReviewers = false;
 
-  private readonly restApiService = appContext.restApiService;
-
-  @computed('ccsOnly')
-  get _addLabel() {
-    return this.ccsOnly ? 'Add CC' : 'Add reviewer';
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) {
+          opacity: 0.8;
+          pointer-events: none;
+        }
+        .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 gr-icon {
+          color: inherit;
+        }
+        .controlsContainer {
+          display: inline-block;
+        }
+        gr-button.addReviewer {
+          vertical-align: top;
+          --gr-button-padding: var(--spacing-s);
+          --margin: calc(0px - var(--spacing-s));
+        }
+        gr-button {
+          line-height: var(--line-height-normal);
+          --gr-button-padding: 0px;
+        }
+        gr-account-chip {
+          line-height: var(--line-height-normal);
+          vertical-align: top;
+          display: inline-block;
+        }
+        gr-vote-chip {
+          --gr-vote-chip-width: 14px;
+          --gr-vote-chip-height: 14px;
+        }
+      `,
+    ];
   }
 
-  @computed('_reviewers', '_displayedReviewers')
-  get _hiddenReviewerCount() {
-    // Polymer 2: check for undefined
-    if (
-      this._reviewers === undefined ||
-      this._displayedReviewers === undefined
-    ) {
-      return undefined;
-    }
-    return this._reviewers.length - this._displayedReviewers.length;
+  override render() {
+    this.displayedReviewers = this.computeDisplayedReviewers() ?? [];
+    this.hiddenReviewerCount =
+      this.reviewers.length - this.displayedReviewers.length;
+    return html`
+      <div class="container">
+        <div>
+          ${this.displayedReviewers.map(reviewer =>
+            this.renderAccountChip(reviewer)
+          )}
+          <div class="controlsContainer" ?hidden=${!this.mutable}>
+            <gr-button
+              link
+              id="addReviewer"
+              class="addReviewer"
+              @click=${this.handleAddTap}
+              title=${this.ccsOnly ? 'Add CC' : 'Add reviewer'}
+            >
+              <div>
+                <gr-icon icon="edit" filled small></gr-icon>
+              </div>
+            </gr-button>
+          </div>
+        </div>
+        <gr-button
+          class="hiddenReviewers"
+          link=""
+          ?hidden=${!this.hiddenReviewerCount}
+          @click=${() => {
+            this.showAllReviewers = true;
+          }}
+          >and ${this.hiddenReviewerCount} more</gr-button
+        >
+      </div>
+    `;
   }
 
-  /**
-   * Converts change.permitted_labels to an array of hashes of label keys to
-   * numeric scores.
-   * Example:
-   * [{
-   *   'Code-Review': ['-1', ' 0', '+1']
-   * }]
-   * will be converted to
-   * [{
-   *   label: 'Code-Review',
-   *   scores: [-1, 0, 1]
-   * }]
-   */
-  _permittedLabelsToNumericScores(labels: LabelNameToValueMap | undefined) {
-    if (!labels) return [];
-    return Object.keys(labels).map(label => {
-      return {
-        label,
-        scores: labels[label].map(v => Number(v)),
-      };
-    });
+  private renderAccountChip(reviewer: AccountInfo) {
+    const change = this.change;
+    if (!change) return nothing;
+    return html`
+      <gr-account-chip
+        class="reviewer"
+        .account=${reviewer}
+        .change=${change}
+        highlightAttention
+        .vote=${this.computeVote(reviewer)}
+        .label=${this.computeCodeReviewLabel()}
+      >
+        <gr-vote-chip
+          slot="vote-chip"
+          .vote=${this.computeVote(reviewer)}
+          .label=${this.computeCodeReviewLabel()}
+          circle-shape
+        ></gr-vote-chip>
+      </gr-account-chip>
+    `;
   }
 
-  /**
-   * Returns hash of labels to max permitted score.
-   *
-   * @returns labels to max permitted scores hash
-   */
-  _getMaxPermittedScores(change: ChangeInfo) {
-    return this._permittedLabelsToNumericScores(change.permitted_labels)
-      .map(({label, scores}) => {
-        return {
-          [label]: scores.reduce((a, b) => Math.max(a, b)),
-        };
-      })
-      .reduce((acc, i) => Object.assign(acc, i), {});
-  }
-
-  /**
-   * Returns max permitted score for reviewer.
-   */
-  _getReviewerPermittedScore(
-    reviewer: AccountInfo,
-    change: ChangeInfo,
-    label: string
-  ) {
-    // Note (issue 7874): sometimes the "all" list is not included in change
-    // detail responses, even when DETAILED_LABELS is included in options.
-    if (!change.labels) {
-      return NaN;
-    }
-    const detailedLabel = change.labels[label];
-    if (!isDetailedLabelInfo(detailedLabel) || !detailedLabel.all) {
-      return NaN;
-    }
-    const approvalInfo = getApprovalInfo(detailedLabel, reviewer);
-    if (!approvalInfo) {
-      return NaN;
-    }
-    if (hasOwnProperty(approvalInfo, 'permitted_voting_range')) {
-      if (!approvalInfo.permitted_voting_range) return NaN;
-      return approvalInfo.permitted_voting_range.max;
-    } else if (hasOwnProperty(approvalInfo, 'value')) {
-      // If present, user can vote on the label.
-      return 0;
-    }
-    return NaN;
-  }
-
-  _computeVoteableText(reviewer: AccountInfo, change: ChangeInfo) {
-    if (!change || !change.labels) {
-      return '';
-    }
-    const maxScores = [];
-    const maxPermitted = this._getMaxPermittedScores(change);
-    for (const label of Object.keys(change.labels)) {
-      const maxScore = this._getReviewerPermittedScore(reviewer, change, label);
-      if (isNaN(maxScore) || maxScore < 0) {
-        continue;
-      }
-      if (maxScore > 0 && maxScore === maxPermitted[label]) {
-        maxScores.push(`${label}: +${maxScore}`);
-      } else {
-        maxScores.push(`${label}`);
-      }
-    }
-    return maxScores.join(', ');
-  }
-
-  _computeVote(
-    reviewer: AccountInfo,
-    change?: ChangeInfo
-  ): ApprovalInfo | undefined {
-    const codeReviewLabel = this._computeCodeReviewLabel(change);
+  private computeVote(reviewer: AccountInfo): ApprovalInfo | undefined {
+    const codeReviewLabel = this.computeCodeReviewLabel();
     if (!codeReviewLabel || !isDetailedLabelInfo(codeReviewLabel)) return;
     return getApprovalInfo(codeReviewLabel, reviewer);
   }
 
-  _computeCodeReviewLabel(change?: ChangeInfo): LabelInfo | undefined {
-    if (!change || !change.labels) return;
-    return getCodeReviewLabel(change.labels);
+  private computeCodeReviewLabel(): LabelInfo | undefined {
+    if (!this.change?.labels) return;
+    return getCodeReviewLabel(this.change.labels);
   }
 
-  @observe('change.reviewers.*', 'change.owner')
-  _reviewersChanged(
-    changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
-    owner: AccountInfo
-  ) {
-    // Polymer 2: check for undefined
-    if (
-      changeRecord === undefined ||
-      owner === undefined ||
-      this.change === undefined
-    ) {
+  private computeDisplayedReviewers() {
+    if (this.change?.owner === undefined) {
       return;
     }
     let result: AccountInfo[] = [];
-    const reviewers = changeRecord.base;
+    const reviewers = this.change.reviewers;
     for (const key of Object.keys(reviewers)) {
       if (this.reviewersOnly && key !== 'REVIEWER') {
         continue;
@@ -234,65 +186,21 @@
         result = result.concat(reviewers[key]!);
       }
     }
-    this._reviewers = result
-      .filter(reviewer => reviewer._account_id !== owner._account_id)
+    this.reviewers = result
+      .filter(
+        reviewer => reviewer._account_id !== this.change?.owner._account_id
+      )
       .sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
 
-    if (this._reviewers.length > 8) {
-      this._displayedReviewers = this._reviewers.slice(0, 6);
+    if (this.reviewers.length > 8 && !this.showAllReviewers) {
+      return this.reviewers.slice(0, 6);
     } else {
-      this._displayedReviewers = this._reviewers;
+      return this.reviewers;
     }
   }
 
-  _computeCanRemoveReviewer(reviewer: AccountInfo, mutable: boolean) {
-    return mutable && isRemovableReviewer(this.change, reviewer);
-  }
-
-  _handleRemove(e: Event) {
-    e.preventDefault();
-    const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
-    if (!target.account || !this.change?.reviewers) return;
-    const accountID = target.account._account_id || target.account.email;
-    if (!accountID) return;
-    const reviewers = this.change.reviewers;
-    let removedAccount: AccountInfo | undefined;
-    let removedType: ReviewerState | undefined;
-    for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
-      const reviewerStateByType = reviewers[type] || [];
-      reviewers[type] = reviewerStateByType;
-      for (let i = 0; i < reviewerStateByType.length; i++) {
-        if (
-          reviewerStateByType[i]._account_id === accountID ||
-          reviewerStateByType[i].email === accountID
-        ) {
-          removedAccount = reviewerStateByType[i];
-          removedType = type;
-          this.splice(`change.reviewers.${type}`, i, 1);
-          break;
-        }
-      }
-    }
-    const curChange = this.change;
-    this.disabled = true;
-    this._xhrPromise = this._removeReviewer(accountID)
-      .then(response => {
-        this.disabled = false;
-        if (!this.change?.reviewers || this.change !== curChange) return;
-        if (!response?.ok) {
-          this.push(`change.reviewers.${removedType}`, removedAccount);
-          fireAlert(this, `Cannot remove a ${removedType}`);
-          return response;
-        }
-        return;
-      })
-      .catch((err: Error) => {
-        this.disabled = false;
-        throw err;
-      });
-  }
-
-  _handleAddTap(e: Event) {
+  // private but used in tests
+  handleAddTap(e: Event) {
     e.preventDefault();
     const value = {
       reviewersOnly: false,
@@ -312,15 +220,6 @@
       })
     );
   }
-
-  _handleViewAll() {
-    this._displayedReviewers = this._reviewers;
-  }
-
-  _removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
-    if (!this.change) return Promise.resolve(undefined);
-    return this.restApiService.removeChangeReviewer(this.change._number, id);
-  }
 }
 
 declare global {
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
deleted file mode 100644
index dec65e2..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
+++ /dev/null
@@ -1,102 +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;
-    }
-    :host([disabled]) {
-      opacity: 0.8;
-      pointer-events: none;
-    }
-    .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 {
-      --gr-button-padding: 1px 0px;
-      vertical-align: top;
-      top: 1px;
-    }
-    gr-button {
-      line-height: var(--line-height-normal);
-      --gr-button-padding: 0px;
-    }
-    gr-account-chip {
-      line-height: var(--line-height-normal);
-      vertical-align: top;
-      display: inline-block;
-    }
-    gr-vote-chip {
-      --gr-vote-chip-width: 14px;
-      --gr-vote-chip-height: 14px;
-      margin-right: var(--spacing-s);
-    }
-  </style>
-  <div class="container">
-    <div>
-      <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
-        <gr-account-chip
-          class="reviewer"
-          account="[[reviewer]]"
-          change="[[change]]"
-          on-remove="_handleRemove"
-          highlightAttention
-          voteable-text="[[_computeVoteableText(reviewer, change)]]"
-          removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]"
-        >
-          <gr-vote-chip
-            slot="vote-chip"
-            vote="[[_computeVote(reviewer, change)]]"
-            label="[[_computeCodeReviewLabel(change)]]"
-          ></gr-vote-chip>
-        </gr-account-chip>
-      </template>
-      <div class="controlsContainer" hidden$="[[!mutable]]">
-        <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"
-      link=""
-      hidden$="[[!_hiddenReviewerCount]]"
-      on-click="_handleViewAll"
-      >and [[_hiddenReviewerCount]] more</gr-button
-    >
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
index bf15bb5..1f1bef3 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -1,58 +1,76 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-reviewer-list';
-import {
-  mockPromise,
-  queryAndAssert,
-  stubRestApi,
-} from '../../../test/test-utils';
+import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrReviewerList} from './gr-reviewer-list';
 import {
   createAccountDetailWithId,
   createChange,
-  createDetailedLabelInfo,
 } from '../../../test/test-data-generators';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {AccountId, EmailAddress} from '../../../types/common';
-import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-
-const basicFixture = fixtureFromElement('gr-reviewer-list');
+import './gr-reviewer-list';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-reviewer-list tests', () => {
   let element: GrReviewerList;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-reviewer-list></gr-reviewer-list>`);
+  });
 
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <div>
+            <div class="controlsContainer" hidden="">
+              <gr-button
+                aria-disabled="false"
+                class="addReviewer"
+                id="addReviewer"
+                link=""
+                role="button"
+                tabindex="0"
+                title="Add reviewer"
+              >
+                <div>
+                  <gr-icon icon="edit" filled small></gr-icon>
+                </div>
+              </gr-button>
+            </div>
+          </div>
+          <gr-button
+            aria-disabled="false"
+            class="hiddenReviewers"
+            hidden=""
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            and 0 more
+          </gr-button>
+        </div>
+      `
     );
   });
 
-  test('controls hidden on immutable element', () => {
-    flush();
+  test('controls hidden on immutable element', async () => {
     element.mutable = false;
+    await element.updateComplete;
+
     assert.isTrue(
       queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
     );
+
     element.mutable = true;
+    await element.updateComplete;
+
     assert.isFalse(
       queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
     );
@@ -63,171 +81,11 @@
     element.addEventListener('show-reply-dialog', () => {
       dialogShown.resolve();
     });
-    await flush();
-    tap(queryAndAssert(element, '.addReviewer'));
+    queryAndAssert<GrButton>(element, '.addReviewer').click();
     await dialogShown;
   });
 
-  test('only show remove for removable reviewers', async () => {
-    element.mutable = true;
-    element.change = {
-      ...createChange(),
-      owner: {
-        ...createAccountDetailWithId(1),
-      },
-      reviewers: {
-        REVIEWER: [
-          {
-            ...createAccountDetailWithId(2),
-            name: 'Bojack Horseman',
-            email: 'SecretariatRulez96@hotmail.com' as EmailAddress,
-          },
-          {
-            _account_id: 3 as AccountId,
-            name: 'Pinky Penguin',
-          },
-        ],
-        CC: [
-          {
-            ...createAccountDetailWithId(4),
-            name: 'Diane Nguyen',
-            email: 'macarthurfellow2B@juno.com' as EmailAddress,
-          },
-          {
-            email: 'test@e.mail' as EmailAddress,
-          },
-        ],
-      },
-      removable_reviewers: [
-        {
-          _account_id: 3 as AccountId,
-          name: 'Pinky Penguin',
-        },
-        {
-          ...createAccountDetailWithId(4),
-          name: 'Diane Nguyen',
-          email: 'macarthurfellow2B@juno.com' as EmailAddress,
-        },
-        {
-          email: 'test@e.mail' as EmailAddress,
-        },
-      ],
-    };
-    await flush();
-    const chips = element.root!.querySelectorAll('gr-account-chip');
-    assert.equal(chips.length, 4);
-
-    for (const el of Array.from(chips)) {
-      const accountID = el.account!._account_id || el.account!.email;
-      assert.ok(accountID);
-
-      const buttonEl = queryAndAssert(el, 'gr-button');
-      if (accountID === 2) {
-        assert.isTrue(buttonEl.hasAttribute('hidden'));
-      } else {
-        assert.isFalse(buttonEl.hasAttribute('hidden'));
-      }
-    }
-  });
-
-  suite('_handleRemove', () => {
-    let removeReviewerStub: sinon.SinonStub;
-    let reviewersChangedSpy: sinon.SinonSpy;
-
-    const reviewerWithId = {
-      ...createAccountDetailWithId(2),
-      name: 'Some name',
-    };
-
-    const reviewerWithIdAndEmail = {
-      ...createAccountDetailWithId(4),
-      name: 'Some other name',
-      email: 'example@' as EmailAddress,
-    };
-
-    const reviewerWithEmailOnly = {
-      email: 'example2@example' as EmailAddress,
-    };
-
-    let chips: GrAccountChip[];
-
-    setup(() => {
-      removeReviewerStub = sinon
-        .stub(element, '_removeReviewer')
-        .returns(Promise.resolve(new Response()));
-      element.mutable = true;
-
-      const allReviewers = [
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ];
-
-      element.change = {
-        ...createChange(),
-        owner: {
-          ...createAccountDetailWithId(1),
-        },
-        reviewers: {
-          REVIEWER: allReviewers,
-        },
-        removable_reviewers: allReviewers,
-      };
-      flush();
-      chips = Array.from(element.root!.querySelectorAll('gr-account-chip'));
-      assert.equal(chips.length, allReviewers.length);
-      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
-    });
-
-    test('_handleRemove for account with accountId only', async () => {
-      const accountChip = chips.find(
-        chip => chip.account!._account_id === reviewerWithId._account_id
-      );
-      accountChip!._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with accountId and email', async () => {
-      const accountChip = chips.find(
-        chip => chip.account!._account_id === reviewerWithIdAndEmail._account_id
-      );
-      accountChip!._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(
-        removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id)
-      );
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with email only', async () => {
-      const accountChip = chips.find(
-        chip => chip.account!.email === reviewerWithEmailOnly.email
-      );
-      accountChip!._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-      ]);
-    });
-  });
-
-  test('tracking reviewers and ccs', () => {
+  test('tracking reviewers and ccs', async () => {
     let counter = 0;
     function makeAccount() {
       return {_account_id: counter++ as AccountId};
@@ -249,7 +107,8 @@
       owner,
       reviewers,
     };
-    assert.deepEqual(element._reviewers, [reviewer, cc]);
+    await element.updateComplete;
+    assert.deepEqual(element.reviewers, [reviewer, cc]);
 
     element.reviewersOnly = true;
     element.change = {
@@ -257,7 +116,9 @@
       owner,
       reviewers,
     };
-    assert.deepEqual(element._reviewers, [reviewer]);
+    await element.updateComplete;
+
+    assert.deepEqual(element.reviewers, [reviewer]);
 
     element.ccsOnly = true;
     element.reviewersOnly = false;
@@ -266,16 +127,18 @@
       owner,
       reviewers,
     };
-    assert.deepEqual(element._reviewers, [cc]);
+    await element.updateComplete;
+
+    assert.deepEqual(element.reviewers, [cc]);
   });
 
-  test('_handleAddTap passes mode with event', () => {
+  test('handleAddTap passes mode with event', () => {
     const fireStub = sinon.stub(element, 'dispatchEvent');
     const e = {...new Event(''), preventDefault() {}};
 
     element.ccsOnly = false;
     element.reviewersOnly = false;
-    element._handleAddTap(e);
+    element.handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
       value: {
@@ -285,7 +148,7 @@
     });
 
     element.reviewersOnly = true;
-    element._handleAddTap(e);
+    element.handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
       value: {reviewersOnly: true, ccsOnly: false},
@@ -293,14 +156,14 @@
 
     element.ccsOnly = true;
     element.reviewersOnly = false;
-    element._handleAddTap(e);
+    element.handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
       value: {ccsOnly: true, reviewersOnly: false},
     });
   });
 
-  test('dont show all reviewers button with 4 reviewers', () => {
+  test('dont show all reviewers button with 4 reviewers', async () => {
     const reviewers = [];
     for (let i = 0; i < 4; i++) {
       reviewers.push({
@@ -320,15 +183,15 @@
         CC: reviewers,
       },
     };
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 4);
-    assert.equal(element._reviewers.length, 4);
-    assert.isTrue(
-      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
-    );
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 0);
+    assert.equal(element.displayedReviewers.length, 4);
+    assert.equal(element.reviewers.length, 4);
+    assert.isTrue(queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden);
   });
 
-  test('account owner comes first in list of reviewers', () => {
+  test('account owner comes first in list of reviewers', async () => {
     const reviewers = [];
     for (let i = 0; i < 4; i++) {
       reviewers.push({
@@ -350,11 +213,12 @@
         REVIEWER: reviewers,
       },
     };
-    flush();
-    assert.equal(element._displayedReviewers[0]._account_id, 1 as AccountId);
+    await element.updateComplete;
+
+    assert.equal(element.displayedReviewers[0]._account_id, 1 as AccountId);
   });
 
-  test('show all reviewers button with 9 reviewers', () => {
+  test('show all reviewers button with 9 reviewers', async () => {
     const reviewers = [];
     for (let i = 0; i < 9; i++) {
       reviewers.push({
@@ -374,15 +238,17 @@
         CC: reviewers,
       },
     };
-    assert.equal(element._hiddenReviewerCount, 3);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 9);
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 3);
+    assert.equal(element.displayedReviewers.length, 6);
+    assert.equal(element.reviewers.length, 9);
     assert.isFalse(
-      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+      queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden
     );
   });
 
-  test('show all reviewers button', () => {
+  test('show all reviewers button', async () => {
     const reviewers = [];
     for (let i = 0; i < 100; i++) {
       reviewers.push({
@@ -402,84 +268,23 @@
         CC: reviewers,
       },
     };
-    assert.equal(element._hiddenReviewerCount, 94);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 100);
+
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 94);
+    assert.equal(element.displayedReviewers.length, 6);
+    assert.equal(element.reviewers.length, 100);
     assert.isFalse(
-      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+      queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden
     );
 
-    tap(queryAndAssert(element, '.hiddenReviewers'));
+    queryAndAssert<GrButton>(element, '.hiddenReviewers').click();
 
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 100);
-    assert.equal(element._reviewers.length, 100);
-    assert.isTrue(
-      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
-    );
-  });
+    await element.updateComplete;
 
-  test('votable labels', () => {
-    const change = {
-      ...createChange(),
-      labels: {
-        Foo: {
-          ...createDetailedLabelInfo(),
-          all: [
-            {
-              _account_id: 7 as AccountId,
-              permitted_voting_range: {max: 2, min: 0},
-            },
-          ],
-        },
-        Bar: {
-          ...createDetailedLabelInfo(),
-          all: [
-            {
-              ...createAccountDetailWithId(1),
-              permitted_voting_range: {max: 1, min: 0},
-            },
-            {
-              _account_id: 7 as AccountId,
-              permitted_voting_range: {max: 1, min: 0},
-            },
-          ],
-        },
-        FooBar: {
-          ...createDetailedLabelInfo(),
-          all: [{_account_id: 7 as AccountId, value: 0}],
-        },
-      },
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-        FooBar: ['-1', ' 0'],
-      },
-    };
-    assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
-      'Bar'
-    );
-    assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(7)}, change),
-      'Foo: +2, Bar, FooBar'
-    );
-    assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(2)}, change),
-      ''
-    );
-  });
-
-  test('fails gracefully when all is not included', () => {
-    const change = {
-      ...createChange(),
-      labels: {Foo: {}},
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-      },
-    };
-    assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
-      ''
-    );
+    assert.equal(element.hiddenReviewerCount, 0);
+    assert.equal(element.displayedReviewers.length, 100);
+    assert.equal(element.reviewers.length, 100);
+    assert.isTrue(queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts
index ebe3ce3..e4d9c12 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-submit-requirements/gr-submit-requirements';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {css, html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
 import {ParsedChangeInfo} from '../../../types/types';
@@ -44,7 +33,9 @@
     return html`<div id="container" role="tooltip" tabindex="-1">
       <gr-submit-requirements
         .change=${this.change}
+        disable-hovercards
         suppress-title
+        disable-endpoints
       ></gr-submit-requirements>
     </div>`;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard_test.ts
new file mode 100644
index 0000000..f1ebd99
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard_test.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-submit-requirement-dashboard-hovercard';
+import {GrSubmitRequirementDashboardHovercard} from './gr-submit-requirement-dashboard-hovercard';
+import {createParsedChange} from '../../../test/test-data-generators';
+
+suite('gr-submit-requirement-dashboard-hovercard tests', () => {
+  let element: GrSubmitRequirementDashboardHovercard;
+  setup(async () => {
+    element = await fixture<GrSubmitRequirementDashboardHovercard>(
+      html`<gr-submit-requirement-dashboard-hovercard
+        .change=${createParsedChange()}
+      ></gr-submit-requirement-dashboard-hovercard>`
+    );
+    await element.updateComplete;
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <gr-submit-requirements
+            disable-endpoints=""
+            disable-hovercards=""
+            suppress-title=""
+          >
+          </gr-submit-requirements>
+        </div>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 74f430c..66ad38c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -1,36 +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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-label-info/gr-label-info';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {
   AccountInfo,
+  ChangeStatus,
+  isDetailedLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
 } from '../../../api/rest-api';
 import {
+  canVote,
   extractAssociatedLabels,
-  iconForStatus,
+  getApprovalInfo,
+  hasVotes,
+  iconForRequirement,
 } from '../../../utils/label-util';
 import {ParsedChangeInfo} from '../../../types/types';
-import {Label} from '../gr-change-requirements/gr-change-requirements';
 import {css, html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {DraftsAction} from '../../../constants/constants';
+import {ReviewInput} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fireReload} from '../../../utils/event-util';
+import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
+import {
+  atomizeExpression,
+  SubmitRequirementExpressionAtomStatus,
+} from '../../../utils/submit-requirement-util';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -52,9 +57,12 @@
   @property({type: Boolean})
   expanded = false;
 
+  private readonly restApiService = getAppContext().restApiService;
+
   static override get styles() {
     return [
       fontStyles,
+      submitRequirementsStyles,
       base.styles || [],
       css`
         #container {
@@ -92,29 +100,30 @@
         div.sectionIcon {
           flex: 0 0 30px;
         }
-        div.sectionIcon iron-icon {
+        div.sectionIcon gr-icon {
           position: relative;
-          width: 20px;
-          height: 20px;
         }
-        .condition {
+        .section.condition > .sectionContent {
           background-color: var(--gray-background);
           padding: var(--spacing-m);
           flex-grow: 1;
         }
+        .button ~ .condition {
+          margin-top: var(--spacing-m);
+        }
         .expression {
           color: var(--gray-foreground);
         }
-        iron-icon.check {
-          color: var(--success-foreground);
+        .expression .failing.atom {
+          border-bottom: 2px solid var(--error-foreground);
         }
-        iron-icon.close {
-          color: var(--warning-foreground);
+        .expression .passing.atom {
+          border-bottom: 2px solid var(--success-foreground);
         }
-        .showConditions iron-icon {
+        .button gr-icon {
           color: inherit;
         }
-        div.showConditions {
+        div.button {
           border-top: 1px solid var(--border-color);
           margin-top: var(--spacing-m);
           padding: var(--spacing-m) var(--spacing-xl) 0;
@@ -125,12 +134,9 @@
 
   override render() {
     if (!this.requirement) return;
-    const icon = iconForStatus(this.requirement.status);
     return html` <div id="container" role="tooltip" tabindex="-1">
       <div class="section">
-        <div class="sectionIcon">
-          <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
-        </div>
+        <div class="sectionIcon">${this.renderStatus(this.requirement)}</div>
         <div class="sectionContent">
           <h3 class="name heading-3">
             <span>${this.requirement.name}</span>
@@ -139,7 +145,7 @@
       </div>
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:info-outline"></iron-icon>
+          <gr-icon class="small" icon="info"></gr-icon>
         </div>
         <div class="sectionContent">
           <div class="row">
@@ -148,12 +154,65 @@
           </div>
         </div>
       </div>
-      ${this.renderLabelSection()} ${this.renderConditionSection()}
+      ${this.renderLabelSection()}${this.renderDescription()}
+      ${this.renderShowHideConditionButton()}${this.renderConditionSection()}
+      ${this.renderVotingButtons()}
+    </div>`;
+  }
+
+  private renderStatus(requirement: SubmitRequirementResultInfo) {
+    const icon = iconForRequirement(requirement);
+    return html`<gr-icon
+      class=${icon.icon}
+      icon=${icon.icon}
+      ?filled=${icon.filled}
+      role="img"
+      aria-label=${requirement.status.toLowerCase()}
+    ></gr-icon>`;
+  }
+
+  private renderDescription() {
+    let description = this.requirement?.description;
+    if (this.requirement?.status === SubmitRequirementStatus.ERROR) {
+      const submitRecord = this.change?.submit_records?.filter(
+        record => record.rule_name === this.requirement?.name
+      );
+      if (submitRecord?.length === 1 && submitRecord[0].error_message) {
+        description = submitRecord[0].error_message;
+      }
+    }
+    if (!description) return;
+    return html`<div class="section description">
+      <div class="sectionIcon">
+        <gr-icon icon="description"></gr-icon>
+      </div>
+      <div class="sectionContent">
+        <gr-formatted-text
+          .markdown=${true}
+          .content=${description}
+        ></gr-formatted-text>
+      </div>
     </div>`;
   }
 
   private renderLabelSection() {
-    const labels = this.computeLabels();
+    if (!this.requirement) return;
+    const requirementLabels = extractAssociatedLabels(this.requirement);
+    const allLabels = this.change?.labels ?? {};
+    const labels: string[] = [];
+    for (const label of Object.keys(allLabels)) {
+      if (requirementLabels.includes(label)) {
+        const labelInfo = allLabels[label];
+        const canSomeoneVote = (this.change?.reviewers['REVIEWER'] ?? []).some(
+          reviewer => canVote(labelInfo, reviewer)
+        );
+        if (hasVotes(labelInfo) || canSomeoneVote) {
+          labels.push(label);
+        }
+      }
+    }
+
+    if (labels.length === 0) return;
     const showLabelName = labels.length >= 2;
     return html` <div class="section">
       <div class="sectionIcon"></div>
@@ -163,73 +222,156 @@
     </div>`;
   }
 
-  private renderLabel(label: Label, showLabelName: boolean) {
+  private renderLabel(labelName: string, showLabelName: boolean) {
+    const labels = this.change?.labels ?? {};
     return html`
-      ${showLabelName ? html`<div>${label.labelName} votes</div>` : ''}
+      ${showLabelName ? html`<div>${labelName} votes</div>` : ''}
       <gr-label-info
         .change=${this.change}
         .account=${this.account}
         .mutable=${this.mutable}
-        .label="${label.labelName}"
-        .labelInfo="${label.labelInfo}"
+        .label=${labelName}
+        .labelInfo=${labels[labelName]}
       ></gr-label-info>
     `;
   }
 
-  private renderConditionSection() {
-    if (!this.expanded) {
-      return html` <div class="showConditions">
-        <gr-button
-          link=""
-          class="showConditions"
-          @click="${(_: MouseEvent) => this.handleShowConditions()}"
-        >
-          View condition
-          <iron-icon icon="gr-icons:expand-more"></iron-icon
-        ></gr-button>
-      </div>`;
+  private renderShowHideConditionButton() {
+    const buttonText = this.expanded ? 'Hide conditions' : 'View conditions';
+    const icon = this.expanded ? 'expand_less' : 'expand_more';
+
+    return html` <div class="button">
+      <gr-button
+        link=""
+        id="toggleConditionsButton"
+        @click=${(_: MouseEvent) => this.toggleConditionsVisibility()}
+      >
+        ${buttonText}
+        <gr-icon .icon=${icon}></gr-icon>
+      </gr-button>
+    </div>`;
+  }
+
+  private renderVotingButtons() {
+    if (!this.requirement) return;
+    if (!this.account) return;
+    if (this.change?.status === ChangeStatus.MERGED) return;
+
+    const submittabilityLabels = extractAssociatedLabels(
+      this.requirement,
+      'onlySubmittability'
+    );
+    const submittabilityVotes = submittabilityLabels.map(labelName =>
+      this.renderLabelVote(labelName, 'submittability')
+    );
+
+    const overrideLabels = extractAssociatedLabels(
+      this.requirement,
+      'onlyOverride'
+    );
+    const overrideVotes = overrideLabels.map(labelName =>
+      this.renderLabelVote(labelName, 'override')
+    );
+
+    return submittabilityVotes.concat(overrideVotes);
+  }
+
+  private renderLabelVote(
+    labelName: string,
+    type: 'override' | 'submittability'
+  ) {
+    const labels = this.change?.labels ?? {};
+    const labelInfo = labels[labelName];
+    if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
+    if (!this.account || !canVote(labelInfo, this.account)) return;
+
+    const approvalInfo = getApprovalInfo(labelInfo, this.account);
+    const maxVote = approvalInfo?.permitted_voting_range?.max;
+    if (!maxVote || maxVote <= 0) return;
+    if (approvalInfo?.value === maxVote) return; // Already voted maxVote
+    return html` <div class="button quickApprove">
+      <gr-button
+        link=""
+        @click=${(_: MouseEvent) => this.quickApprove(labelName, maxVote)}
+      >
+        ${this.computeVoteButtonName(labelName, maxVote, type)}
+      </gr-button>
+    </div>`;
+  }
+
+  private computeVoteButtonName(
+    labelName: string,
+    maxVote: number,
+    type: 'override' | 'submittability'
+  ) {
+    if (type === 'override') {
+      return `Override (${labelName})`;
     } else {
-      return html`
-        <div class="section">
-          <div class="sectionIcon">
-            <iron-icon icon="gr-icons:description"></iron-icon>
-          </div>
-          <div class="sectionContent">${this.requirement?.description}</div>
-        </div>
-        ${this.renderCondition(
-          'Blocking condition',
-          this.requirement?.submittability_expression_result
-        )}
-        ${this.renderCondition(
-          'Application condition',
-          this.requirement?.applicability_expression_result
-        )}
-        ${this.renderCondition(
-          'Override condition',
-          this.requirement?.override_expression_result
-        )}
-      `;
+      return `Vote ${labelName} +${maxVote}`;
     }
   }
 
-  private computeLabels() {
-    if (!this.requirement) return [];
-    const requirementLabels = extractAssociatedLabels(this.requirement);
-    const labels = this.change?.labels ?? {};
+  private quickApprove(label: string, score: number) {
+    assertIsDefined(this.change, 'change');
+    const review: ReviewInput = {
+      drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
+      labels: {
+        [label]: score,
+      },
+    };
+    return this.restApiService
+      .saveChangeReview(
+        this.change._number,
+        this.change.current_revision,
+        review
+      )
+      .then(() => {
+        fireReload(this, true);
+      });
+  }
 
-    const allLabels: Label[] = [];
+  private renderConditionSection() {
+    if (!this.expanded) return;
+    return html`
+      ${this.renderCondition(
+        'Submit condition',
+        this.requirement?.submittability_expression_result
+      )}
+      ${this.renderCondition(
+        'Application condition',
+        this.requirement?.applicability_expression_result
+      )}
+      ${this.renderCondition(
+        'Override condition',
+        this.requirement?.override_expression_result
+      )}
+    `;
+  }
 
-    for (const label of Object.keys(labels)) {
-      if (requirementLabels.includes(label)) {
-        allLabels.push({
-          labelName: label,
-          icon: '',
-          style: '',
-          labelInfo: labels[label],
-        });
-      }
+  private getClassFromAtomStatus(
+    status: SubmitRequirementExpressionAtomStatus
+  ) {
+    switch (status) {
+      case SubmitRequirementExpressionAtomStatus.PASSING:
+        return 'passing atom';
+      case SubmitRequirementExpressionAtomStatus.FAILING:
+        return 'failing atom';
+      default:
+        return 'atom';
     }
-    return allLabels;
+  }
+
+  private getTitleFromAtomStatus(
+    status: SubmitRequirementExpressionAtomStatus
+  ) {
+    switch (status) {
+      case SubmitRequirementExpressionAtomStatus.PASSING:
+        return 'Atom evaluates to True';
+      case SubmitRequirementExpressionAtomStatus.FAILING:
+        return 'Atom evaluates to False';
+      default:
+        return 'Atom value is unknown';
+    }
   }
 
   private renderCondition(
@@ -238,18 +380,27 @@
   ) {
     if (!expression?.expression) return '';
     return html`
-      <div class="section">
-        <div class="sectionIcon"></div>
-        <div class="sectionContent condition">
+      <div class="section condition">
+        <div class="sectionContent">
           ${name}:<br />
-          <span class="expression"> ${expression.expression} </span>
+          <span class="expression">
+            ${atomizeExpression(expression).map(part =>
+              part.isAtom
+                ? html`<span
+                    class=${this.getClassFromAtomStatus(part.atomStatus!)}
+                    title=${this.getTitleFromAtomStatus(part.atomStatus!)}
+                    >${part.value}</span
+                  >`
+                : part.value
+            )}
+          </span>
         </div>
       </div>
     `;
   }
 
-  private handleShowConditions() {
-    this.expanded = true;
+  private toggleConditionsVisibility() {
+    this.expanded = !this.expanded;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
new file mode 100644
index 0000000..4e78e8a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -0,0 +1,390 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-submit-requirement-hovercard';
+import {GrSubmitRequirementHovercard} from './gr-submit-requirement-hovercard';
+import {
+  createAccountWithId,
+  createApproval,
+  createChange,
+  createDetailedLabelInfo,
+  createParsedChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {ParsedChangeInfo} from '../../../types/types';
+import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {ChangeStatus, SubmitRequirementResultInfo} from '../../../api/rest-api';
+
+suite('gr-submit-requirement-hovercard tests', () => {
+  let element: GrSubmitRequirementHovercard;
+
+  setup(async () => {
+    element = await fixture<GrSubmitRequirementHovercard>(
+      html`<gr-submit-requirement-hovercard
+        .requirement=${createSubmitRequirementResultInfo()}
+        .change=${createChange()}
+        .account=${createAccountWithId()}
+      ></gr-submit-requirement-hovercard>`
+    );
+  });
+
+  test('renders', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon
+                aria-label="satisfied"
+                role="img"
+                class="check_circle"
+                filled
+                icon="check_circle"
+              >
+              </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <h3 class="heading-3 name">
+                <span> Verified </span>
+              </h3>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="info"></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Status</div>
+                <div>SATISFIED</div>
+              </div>
+            </div>
+          </div>
+          <div class="button">
+            <gr-button
+              aria-disabled="false"
+              id="toggleConditionsButton"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              View conditions
+              <gr-icon icon="expand_more"></gr-icon>
+            </gr-button>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('renders conditions after click', async () => {
+    const button = queryAndAssert<GrButton>(element, '#toggleConditionsButton');
+    button.click();
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon
+                aria-label="satisfied"
+                role="img"
+                class="check_circle"
+                filled
+                icon="check_circle"
+              >
+              </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <h3 class="heading-3 name">
+                <span> Verified </span>
+              </h3>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="info"></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Status</div>
+                <div>SATISFIED</div>
+              </div>
+            </div>
+          </div>
+          <div class="button">
+            <gr-button
+              aria-disabled="false"
+              id="toggleConditionsButton"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Hide conditions
+              <gr-icon icon="expand_less"></gr-icon>
+            </gr-button>
+          </div>
+          <div class="section condition">
+            <div class="sectionContent">
+              Submit condition:
+              <br />
+              <span class="expression">
+                <span class="passing atom" title="Atom evaluates to True">
+                  label:Verified=MAX
+                </span>
+                <span class="passing atom" title="Atom evaluates to True">
+                  -label:Verified=MIN
+                </span>
+              </span>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('renders label', async () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      description: 'Test Description',
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      },
+    };
+    const element = await fixture<GrSubmitRequirementHovercard>(
+      html`<gr-submit-requirement-hovercard
+        .requirement=${submitRequirement}
+        .change=${change}
+        .account=${createAccountWithId()}
+      ></gr-submit-requirement-hovercard>`
+    );
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon
+                aria-label="satisfied"
+                role="img"
+                class="check_circle"
+                filled
+                icon="check_circle"
+              ></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <h3 class="heading-3 name">
+                <span> Verified </span>
+              </h3>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="info"></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Status</div>
+                <div>SATISFIED</div>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon"></div>
+            <div class="row">
+              <div>
+                <gr-label-info> </gr-label-info>
+              </div>
+            </div>
+          </div>
+          <div class="section description">
+            <div class="sectionIcon">
+              <gr-icon icon="description"></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <gr-formatted-text></gr-formatted-text>
+            </div>
+          </div>
+          <div class="button">
+            <gr-button
+              aria-disabled="false"
+              id="toggleConditionsButton"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              View conditions
+              <gr-icon icon="expand_more"></gr-icon>
+            </gr-button>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  suite('quick approve label', () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      description: 'Test Description',
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    const account = createAccountWithId();
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      status: ChangeStatus.NEW,
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              _account_id: account._account_id,
+              permitted_voting_range: {
+                min: -2,
+                max: 2,
+              },
+            },
+          ],
+        },
+      },
+    };
+    test('renders', async () => {
+      const element = await fixture<GrSubmitRequirementHovercard>(
+        html`<gr-submit-requirement-hovercard
+          .requirement=${submitRequirement}
+          .change=${change}
+          .account=${account}
+        ></gr-submit-requirement-hovercard>`
+      );
+      const quickApprove = queryAndAssert(element, '.quickApprove');
+      assert.dom.equal(
+        quickApprove,
+        /* HTML */ `
+          <div class="button quickApprove">
+            <gr-button aria-disabled="false" link="" role="button" tabindex="0">
+              Vote Verified +2
+            </gr-button>
+          </div>
+        `
+      );
+    });
+
+    test("doesn't render when already voted max vote", async () => {
+      const changeWithVote = {
+        ...change,
+        labels: {
+          ...change.labels,
+          Verified: {
+            ...createDetailedLabelInfo(),
+            all: [
+              {
+                ...createApproval(),
+                _account_id: account._account_id,
+                permitted_voting_range: {
+                  min: -2,
+                  max: 2,
+                },
+                value: 2,
+              },
+            ],
+          },
+        },
+      };
+      const element = await fixture<GrSubmitRequirementHovercard>(
+        html`<gr-submit-requirement-hovercard
+          .requirement=${submitRequirement}
+          .change=${changeWithVote}
+          .account=${account}
+        ></gr-submit-requirement-hovercard>`
+      );
+      assert.isUndefined(query(element, '.quickApprove'));
+    });
+
+    test('uses patchset from change', async () => {
+      const saveChangeReview = stubRestApi('saveChangeReview').resolves();
+      const element = await fixture<GrSubmitRequirementHovercard>(
+        html`<gr-submit-requirement-hovercard
+          .requirement=${submitRequirement}
+          .change=${change}
+          .account=${account}
+        ></gr-submit-requirement-hovercard>`
+      );
+
+      queryAndAssert<GrButton>(element, '.quickApprove > gr-button').click();
+
+      assert.equal(saveChangeReview.callCount, 1);
+      assert.equal(saveChangeReview.firstCall.args[1], change.current_revision);
+    });
+
+    test('override button renders', async () => {
+      const submitRequirement: SubmitRequirementResultInfo = {
+        ...createSubmitRequirementResultInfo(),
+        description: 'Test Description',
+        submittability_expression_result:
+          createSubmitRequirementExpressionInfo(),
+        override_expression_result: createSubmitRequirementExpressionInfo(
+          'label:Build-Cop=MAX'
+        ),
+      };
+      const account = createAccountWithId();
+      const change: ParsedChangeInfo = {
+        ...createParsedChange(),
+        status: ChangeStatus.NEW,
+        labels: {
+          'Build-Cop': {
+            ...createDetailedLabelInfo(),
+            all: [
+              {
+                ...createApproval(),
+                _account_id: account._account_id,
+                permitted_voting_range: {
+                  min: -2,
+                  max: 2,
+                },
+              },
+            ],
+          },
+        },
+      };
+      const element = await fixture<GrSubmitRequirementHovercard>(
+        html`<gr-submit-requirement-hovercard
+          .requirement=${submitRequirement}
+          .change=${change}
+          .account=${account}
+        ></gr-submit-requirement-hovercard>`
+      );
+      const quickApprove = queryAndAssert(element, '.quickApprove');
+      assert.dom.equal(
+        quickApprove,
+        /* HTML */ `
+          <div class="button quickApprove">
+            <gr-button aria-disabled="false" link="" role="button" tabindex="0"
+              >Override (Build-Cop)
+            </gr-button>
+          </div>
+        `
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 986db65..640b9d0 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -1,30 +1,22 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-label-info/gr-label-info';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
-import '../gr-trigger-vote-hovercard/gr-trigger-vote-hovercard';
-import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import '../gr-trigger-vote/gr-trigger-vote';
+import '../gr-change-summary/gr-change-summary';
+import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-vote-chip/gr-vote-chip';
+import {LitElement, css, html, TemplateResult, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
 import {ParsedChangeInfo} from '../../../types/types';
 import {
   AccountInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
-  LabelInfo,
   LabelNameToInfoMap,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
@@ -36,19 +28,22 @@
   getTriggerVotes,
   hasNeutralStatus,
   hasVotes,
-  iconForStatus,
+  iconForRequirement,
   orderSubmitRequirements,
 } from '../../../utils/label-util';
 import {fontStyles} from '../../../styles/gr-font-styles';
-import {charsOnly, pluralize} from '../../../utils/string-util';
+import {capitalizeFirstLetter, charsOnly} from '../../../utils/string-util';
 import {subscribe} from '../../lit/subscription-controller';
-import {
-  allRunsLatestPatchsetLatestAttempt$,
-  CheckRun,
-} from '../../../services/checks/checks-model';
-import {getResultsOf, hasResultsOf} from '../../../services/checks/checks-util';
-import {Category} from '../../../api/checks';
-import '../../shared/gr-vote-chip/gr-vote-chip';
+import {CheckRun} from '../../../models/checks/checks-model';
+import {getResultsOf, hasResultsOf} from '../../../models/checks/checks-util';
+import {Category, RunStatus} from '../../../api/checks';
+import {fireShowTab} from '../../../utils/event-util';
+import {Tab} from '../../../constants/constants';
+import {submitRequirementsStyles} from '../../../styles/gr-submit-requirements-styles';
+import {resolve} from '../../../models/dependency';
+import {checksModelToken} from '../../../models/checks/checks-model';
+import {join} from 'lit/directives/join.js';
+import {map} from 'lit/directives/map.js';
 
 /**
  * @attr {Boolean} suppress-title - hide titles, currently for hovercard view
@@ -64,12 +59,19 @@
   @property({type: Boolean})
   mutable?: boolean;
 
+  @property({type: Boolean, attribute: 'disable-hovercards'})
+  disableHovercards = false;
+
+  @property({type: Boolean, attribute: 'disable-endpoints'})
+  disableEndpoints = false;
+
   @state()
   runs: CheckRun[] = [];
 
   static override get styles() {
     return [
       fontStyles,
+      submitRequirementsStyles,
       css`
         :host([suppress-title]) .metadata-title {
           display: none;
@@ -78,19 +80,10 @@
           color: var(--deemphasized-text-color);
           padding-left: var(--metadata-horizontal-padding);
           margin: 0 0 var(--spacing-s);
-          border-top: 1px solid var(--border-color);
           padding-top: var(--spacing-s);
         }
-        iron-icon {
-          width: var(--line-height-normal, 20px);
-          height: var(--line-height-normal, 20px);
-        }
-        iron-icon.check,
-        iron-icon.overridden {
-          color: var(--success-foreground);
-        }
-        iron-icon.close {
-          color: var(--error-foreground);
+        gr-icon {
+          font-size: var(--line-height-normal, 20px);
         }
         .requirements,
         section.trigger-votes {
@@ -116,34 +109,47 @@
         td {
           padding: var(--spacing-s);
           white-space: nowrap;
+          vertical-align: top;
         }
         .votes-cell {
           display: flex;
+          flex-flow: wrap;
         }
-        .check-error {
-          margin-right: var(--spacing-l);
-        }
-        .check-error iron-icon {
-          color: var(--error-foreground);
-          vertical-align: top;
+        .votes-cell .separator {
+          width: 100%;
+          margin-top: var(--spacing-s);
         }
         gr-vote-chip {
           margin-right: var(--spacing-s);
         }
+        gr-checks-chip {
+          /* .checksChip has top: 2px, this is canceling it */
+          margin-top: -2px;
+        }
       `,
     ];
   }
 
+  private readonly getChecksModel = resolve(this, checksModelToken);
+
   constructor() {
     super();
-    subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
+    subscribe(
+      this,
+      () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$,
+      x => (this.runs = x)
+    );
   }
 
   override render() {
+    return html`${this.renderSubmitRequirements()}${this.renderTriggerVotes()}`;
+  }
+
+  private renderSubmitRequirements() {
     const submit_requirements = orderSubmitRequirements(
       getRequirements(this.change)
     );
-
+    if (submit_requirements.length === 0) return nothing;
     return html` <h3
         class="metadata-title heading-3"
         id="submit-requirements-caption"
@@ -159,83 +165,131 @@
           </tr>
         </thead>
         <tbody>
-          ${submit_requirements.map(requirement =>
-            this.renderRequirement(requirement)
+          ${submit_requirements.map((requirement, index) =>
+            this.renderRequirement(requirement, index)
           )}
         </tbody>
       </table>
-      ${submit_requirements.map(
-        requirement => html`
-          <gr-submit-requirement-hovercard
-            for="requirement-${charsOnly(requirement.name)}"
-            .requirement="${requirement}"
-            .change="${this.change}"
-            .account="${this.account}"
-            .mutable="${this.mutable ?? false}"
-          ></gr-submit-requirement-hovercard>
-        `
-      )}
-      ${this.renderTriggerVotes()}`;
+      ${this.disableHovercards
+        ? ''
+        : submit_requirements.map(
+            (requirement, index) => html`
+              <gr-submit-requirement-hovercard
+                for="requirement-${index}-${charsOnly(requirement.name)}"
+                .requirement=${requirement}
+                .change=${this.change}
+                .account=${this.account}
+                .mutable=${this.mutable ?? false}
+              ></gr-submit-requirement-hovercard>
+            `
+          )}`;
   }
 
-  renderRequirement(requirement: SubmitRequirementResultInfo) {
-    return html`
-      <tr id="requirement-${charsOnly(requirement.name)}">
-        <td>${this.renderStatus(requirement.status)}</td>
+  private renderRequirement(
+    requirement: SubmitRequirementResultInfo,
+    index: number
+  ) {
+    const row = html`
+     <td>${this.renderStatus(requirement)}</td>
         <td class="name">
           <gr-limited-text
             class="name"
-            limit="25"
-            .text="${requirement.name}"
+            .text=${requirement.name}
           ></gr-limited-text>
         </td>
         <td>
-          <gr-endpoint-decorator
-            class="votes-cell"
-            name="${`submit-requirement-${charsOnly(
-              requirement.name
-            ).toLowerCase()}`}"
-          >
-            <gr-endpoint-param
-              name="change"
-              .value=${this.change}
-            ></gr-endpoint-param>
-            <gr-endpoint-param
-              name="requirement"
-              .value=${requirement}
-            ></gr-endpoint-param>
-            ${this.renderVotes(requirement)}${this.renderChecks(requirement)}
-          </gr-endpoint-decorator>
+          ${this.renderEndpoint(requirement, this.renderVoteCell(requirement))}
         </td>
       </tr>
     `;
+
+    if (this.disableHovercards) {
+      // when hovercards are disabled, we don't make line focusable (tabindex)
+      // since otherwise there is no action associated with the line
+      return html`<tr>
+        ${row}
+      </tr>`;
+    } else {
+      return html`<tr
+        id="requirement-${index}-${charsOnly(requirement.name)}"
+        role="button"
+        tabindex="0"
+      >
+        ${row}
+      </tr>`;
+    }
   }
 
-  renderStatus(status: SubmitRequirementStatus) {
-    const icon = iconForStatus(status);
-    return html`<iron-icon
-      class="${icon}"
-      icon="gr-icons:${icon}"
+  renderEndpoint(
+    requirement: SubmitRequirementResultInfo,
+    slot: TemplateResult
+  ) {
+    if (this.disableEndpoints)
+      return html`<div class="votes-cell">${slot}</div>`;
+
+    const endpointName = this.computeEndpointName(requirement.name);
+    return html`<gr-endpoint-decorator class="votes-cell" name=${endpointName}>
+      <gr-endpoint-param
+        name="change"
+        .value=${this.change}
+      ></gr-endpoint-param>
+      <gr-endpoint-param
+        name="requirement"
+        .value=${requirement}
+      ></gr-endpoint-param>
+      ${slot}
+    </gr-endpoint-decorator>`;
+  }
+
+  private renderStatus(requirement: SubmitRequirementResultInfo) {
+    const icon = iconForRequirement(requirement);
+    return html`<gr-icon
+      class=${icon.icon}
+      ?filled=${icon.filled}
+      .icon=${icon.icon}
       role="img"
-      aria-label="${status.toLowerCase()}"
-    ></iron-icon>`;
+      aria-label=${requirement.status.toLowerCase()}
+    ></gr-icon>`;
   }
 
-  renderVotes(requirement: SubmitRequirementResultInfo) {
+  renderVoteCell(requirement: SubmitRequirementResultInfo) {
+    if (requirement.status === SubmitRequirementStatus.ERROR) {
+      return html`<span class="error">Error</span>`;
+    }
+
     const requirementLabels = extractAssociatedLabels(requirement);
     const allLabels = this.change?.labels ?? {};
     const associatedLabels = Object.keys(allLabels).filter(label =>
       requirementLabels.includes(label)
     );
 
+    const requirementWithoutLabelToVoteOn = associatedLabels.length === 0;
+    if (requirementWithoutLabelToVoteOn) {
+      const status = capitalizeFirstLetter(requirement.status.toLowerCase());
+      return this.renderChecks(requirement) || html`${status}`;
+    }
+
     const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
       label => !hasVotes(allLabels[label])
     );
-    if (everyAssociatedLabelsIsWithoutVotes) return html`No votes`;
+    if (everyAssociatedLabelsIsWithoutVotes) {
+      return this.renderChecks(requirement) || html`No votes`;
+    }
 
-    return associatedLabels.map(label =>
-      this.renderLabelVote(label, allLabels)
+    const associatedLabelsWithVotes = associatedLabels.filter(label =>
+      hasVotes(allLabels[label])
     );
+
+    return html`${join(
+      map(
+        associatedLabelsWithVotes,
+        label =>
+          html`${this.renderLabelVote(label, allLabels)}
+          ${this.renderOverrideLabels(requirement, label)}`
+      ),
+      html`<span class="separator"></span>`
+    )}
+    ${this.renderChecks(requirement)}`;
   }
 
   renderLabelVote(label: string, labels: LabelNameToInfoMap) {
@@ -247,158 +301,145 @@
       return uniqueApprovals.map(
         approvalInfo =>
           html`<gr-vote-chip
-            .vote="${approvalInfo}"
-            .label="${labelInfo}"
-            .more="${(labelInfo.all ?? []).filter(
+            .vote=${approvalInfo}
+            .label=${labelInfo}
+            .more=${(labelInfo.all ?? []).filter(
               other => other.value === approvalInfo.value
-            ).length > 1}"
+            ).length > 1}
           ></gr-vote-chip>`
       );
     } else if (isQuickLabelInfo(labelInfo)) {
-      return [html`<gr-vote-chip .label="${labelInfo}"></gr-vote-chip>`];
+      return [html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`];
     } else {
       return html``;
     }
   }
 
+  renderOverrideLabels(
+    requirement: SubmitRequirementResultInfo,
+    forLabel: string
+  ) {
+    if (requirement.status !== SubmitRequirementStatus.OVERRIDDEN) return;
+    const requirementLabels = extractAssociatedLabels(
+      requirement,
+      'onlyOverride'
+    )
+      .filter(label => label === forLabel)
+      .filter(label => {
+        const allLabels = this.change?.labels ?? {};
+        return allLabels[label] && hasVotes(allLabels[label]);
+      });
+    return requirementLabels.map(
+      label => html`<span class="overrideLabel">${label}</span>`
+    );
+  }
+
   renderChecks(requirement: SubmitRequirementResultInfo) {
     const requirementLabels = extractAssociatedLabels(requirement);
-    const requirementRuns = this.runs
+    const errorRuns = this.runs
       .filter(run => hasResultsOf(run, Category.ERROR))
       .filter(
         run => run.labelName && requirementLabels.includes(run.labelName)
       );
-    const runsCount = requirementRuns.reduce(
+    const errorRunsCount = errorRuns.reduce(
       (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
       0
     );
-    if (runsCount > 0) {
-      return html`<span class="check-error"
-        ><iron-icon icon="gr-icons:error"></iron-icon>${pluralize(
-          runsCount,
-          'error'
-        )}</span
-      >`;
+    if (errorRunsCount > 0) {
+      return this.renderChecksCategoryChip(
+        errorRuns,
+        errorRunsCount,
+        Category.ERROR
+      );
+    }
+    const runningRuns = this.runs
+      .filter(r => r.isLatestAttempt)
+      .filter(
+        r => r.status === RunStatus.RUNNING || r.status === RunStatus.SCHEDULED
+      )
+      .filter(
+        run => run.labelName && requirementLabels.includes(run.labelName)
+      );
+
+    const runningRunsCount = runningRuns.length;
+    if (runningRunsCount > 0) {
+      return this.renderChecksCategoryChip(
+        runningRuns,
+        runningRunsCount,
+        RunStatus.RUNNING
+      );
     }
     return;
   }
 
+  renderChecksCategoryChip(
+    runs: CheckRun[],
+    runsCount: Number,
+    category: Category | RunStatus
+  ) {
+    if (runsCount === 0) return;
+    const links = [];
+    if (runs.length === 1 && runs[0].statusLink) {
+      links.push(runs[0].statusLink);
+    }
+    return html`<gr-checks-chip
+      .text=${`${runsCount}`}
+      .links=${links}
+      .statusOrCategory=${category}
+      @click=${() => {
+        fireShowTab(this, Tab.CHECKS, false, {
+          checksTab: {
+            statusOrCategory: category,
+          },
+        });
+      }}
+    ></gr-checks-chip>`;
+  }
+
   renderTriggerVotes() {
     const labels = this.change?.labels ?? {};
     const triggerVotes = getTriggerVotes(this.change).filter(label =>
       hasVotes(labels[label])
     );
     if (!triggerVotes.length) return;
-    return html`<h3 class="metadata-title heading-3">Trigger Votes</h3>
+    return html`<h3 class="metadata-title heading-3">
+        ${this.computeTriggerVotesTitle()}
+      </h3>
       <section class="trigger-votes">
         ${triggerVotes.map(
           label =>
             html`<gr-trigger-vote
-              .label="${label}"
-              .labelInfo="${labels[label]}"
-              .change="${this.change}"
-              .account="${this.account}"
-              .mutable="${this.mutable ?? false}"
+              .label=${label}
+              .labelInfo=${labels[label]}
+              .change=${this.change}
+              .account=${this.account}
+              .mutable=${this.mutable ?? false}
+              .disableHovercards=${this.disableHovercards}
             ></gr-trigger-vote>`
         )}
       </section>`;
   }
-}
 
-@customElement('gr-trigger-vote')
-export class GrTriggerVote extends LitElement {
-  @property()
-  label?: string;
-
-  @property({type: Object})
-  labelInfo?: LabelInfo;
-
-  @property({type: Object})
-  change?: ParsedChangeInfo;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  @property({type: Boolean})
-  mutable?: boolean;
-
-  static override get styles() {
-    return css`
-      :host {
-        display: block;
-      }
-      .container {
-        box-sizing: border-box;
-        border: 1px solid var(--border-color);
-        border-radius: calc(var(--border-radius) + 2px);
-        background-color: var(--background-color-primary);
-        display: flex;
-        padding: 0;
-        padding-left: var(--spacing-s);
-        padding-right: var(--spacing-xxs);
-        align-items: center;
-      }
-      .label {
-        padding-right: var(--spacing-s);
-        font-weight: var(--font-weight-bold);
-      }
-      gr-vote-chip {
-        --gr-vote-chip-width: 14px;
-        --gr-vote-chip-height: 14px;
-        margin-right: 0px;
-        margin-left: var(--spacing-xs);
-      }
-      gr-vote-chip:first-of-type {
-        margin-left: 0px;
-      }
-    `;
-  }
-
-  override render() {
-    if (!this.labelInfo) return;
-    return html`
-      <div class="container">
-        <gr-trigger-vote-hovercard .labelName=${this.label}>
-          <gr-label-info
-            slot="label-info"
-            .change=${this.change}
-            .account=${this.account}
-            .mutable=${this.mutable}
-            .label=${this.label}
-            .labelInfo=${this.labelInfo}
-            .showAllReviewers=${false}
-          ></gr-label-info>
-        </gr-trigger-vote-hovercard>
-        <span class="label">${this.label}</span>
-        ${this.renderVotes()}
-      </div>
-    `;
-  }
-
-  private renderVotes() {
-    const {labelInfo} = this;
-    if (!labelInfo) return;
-    if (isDetailedLabelInfo(labelInfo)) {
-      const approvals = getAllUniqueApprovals(labelInfo).filter(
-        approval => !hasNeutralStatus(labelInfo, approval)
-      );
-      return approvals.map(
-        approvalInfo => html`<gr-vote-chip
-          .vote="${approvalInfo}"
-          .label="${labelInfo}"
-        ></gr-vote-chip>`
-      );
-    } else if (isQuickLabelInfo(labelInfo)) {
-      return [html`<gr-vote-chip .label="${this.labelInfo}"></gr-vote-chip>`];
+  private computeTriggerVotesTitle() {
+    if (getRequirements(this.change).length === 0) {
+      // This is special case for old changes without submit requirements.
+      return 'Label Votes';
     } else {
-      return html``;
+      return 'Trigger Votes';
     }
   }
+
+  // not private for tests
+  computeEndpointName(requirementName: string) {
+    // remove class name annnotation after ~
+    const name = requirementName.split('~')[0];
+    const normalizedName = charsOnly(name).toLowerCase();
+    return `submit-requirement-${normalizedName}`;
+  }
 }
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-submit-requirements': GrSubmitRequirements;
-    'gr-trigger-vote': GrTriggerVote;
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
new file mode 100644
index 0000000..fa12eaa
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -0,0 +1,293 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-submit-requirements';
+import {GrSubmitRequirements} from './gr-submit-requirements';
+import {
+  createAccountWithIdNameAndEmail,
+  createApproval,
+  createDetailedLabelInfo,
+  createParsedChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+  createRunResult,
+  createCheckResult,
+} from '../../../test/test-data-generators';
+import {
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+import {RunStatus} from '../../../api/checks';
+
+suite('gr-submit-requirements tests', () => {
+  let element: GrSubmitRequirements;
+  let change: ParsedChangeInfo;
+  setup(async () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      description: 'Test Description',
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    change = {
+      ...createParsedChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      },
+    };
+    const account = createAccountWithIdNameAndEmail();
+    element = await fixture<GrSubmitRequirements>(
+      html`<gr-submit-requirements
+        .change=${change}
+        .account=${account}
+      ></gr-submit-requirements>`
+    );
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h3 class="heading-3 metadata-title" id="submit-requirements-caption">
+          Submit Requirements
+        </h3>
+        <table
+          aria-labelledby="submit-requirements-caption"
+          class="requirements"
+        >
+          <thead hidden="">
+            <tr>
+              <th>Status</th>
+              <th>Name</th>
+              <th>Votes</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr id="requirement-0-Verified" role="button" tabindex="0">
+              <td>
+                <gr-icon
+                  aria-label="satisfied"
+                  role="img"
+                  class="check_circle"
+                  filled
+                  icon="check_circle"
+                >
+                </gr-icon>
+              </td>
+              <td class="name">
+                <gr-limited-text class="name"></gr-limited-text>
+              </td>
+              <td>
+                <gr-endpoint-decorator
+                  class="votes-cell"
+                  name="submit-requirement-verified"
+                >
+                  <gr-endpoint-param name="change"></gr-endpoint-param>
+                  <gr-endpoint-param name="requirement"></gr-endpoint-param>
+                  <gr-vote-chip></gr-vote-chip>
+                </gr-endpoint-decorator>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+        <gr-submit-requirement-hovercard for="requirement-0-Verified">
+        </gr-submit-requirement-hovercard>
+      `
+    );
+  });
+
+  suite('votes-cell', () => {
+    setup(async () => {
+      element.disableEndpoints = true;
+      await element.updateComplete;
+    });
+    test('with vote', () => {
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ `
+          <div class="votes-cell">
+            <gr-vote-chip> </gr-vote-chip>
+          </div>
+        `
+      );
+    });
+
+    test('no votes', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Verified: {
+          ...createDetailedLabelInfo(),
+        },
+      };
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ ' <div class="votes-cell">No votes</div> '
+      );
+    });
+
+    test('without label to vote on', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.submit_requirements![0]!.submittability_expression_result.expression =
+        'hasfooter:"Release-Notes"';
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ ' <div class="votes-cell">Satisfied</div> '
+      );
+    });
+
+    test('checks', async () => {
+      element.runs = [
+        {
+          ...createRunResult(),
+          labelName: 'Verified',
+          results: [createCheckResult()],
+        },
+      ];
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ `
+          <div class="votes-cell">
+            <gr-vote-chip></gr-vote-chip>
+            <gr-checks-chip></gr-checks-chip>
+          </div>
+        `
+      );
+    });
+
+    test('running checks', async () => {
+      element.runs = [
+        {
+          ...createRunResult(),
+          status: RunStatus.RUNNING,
+          labelName: 'Verified',
+          results: [createCheckResult()],
+        },
+      ];
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ `
+          <div class="votes-cell">
+            <gr-vote-chip></gr-vote-chip>
+            <gr-checks-chip></gr-checks-chip>
+          </div>
+        `
+      );
+    });
+
+    test('with override label', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Override: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      };
+      modifiedChange.submit_requirements = [
+        {
+          ...createSubmitRequirementResultInfo(),
+          status: SubmitRequirementStatus.OVERRIDDEN,
+          override_expression_result: createSubmitRequirementExpressionInfo(
+            'label:Override=MAX -label:Override=MIN'
+          ),
+        },
+      ];
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ `<div class="votes-cell">
+          <gr-vote-chip> </gr-vote-chip>
+          <span class="overrideLabel"> Override </span>
+        </div>`
+      );
+    });
+
+    test('with override with 2 labels', async () => {
+      const modifiedChange = {...change};
+      modifiedChange.labels = {
+        Override: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+        Override2: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      };
+      modifiedChange.submit_requirements = [
+        {
+          ...createSubmitRequirementResultInfo(),
+          status: SubmitRequirementStatus.OVERRIDDEN,
+          override_expression_result: createSubmitRequirementExpressionInfo(
+            'label:Override=MAX label:Override2=MAX'
+          ),
+        },
+      ];
+      element.change = modifiedChange;
+      await element.updateComplete;
+      const votesCell = element.shadowRoot?.querySelectorAll('.votes-cell');
+      assert.dom.equal(
+        votesCell?.[0],
+        /* HTML */ `<div class="votes-cell">
+          <gr-vote-chip> </gr-vote-chip>
+          <span class="overrideLabel"> Override </span>
+          <span class="separator"></span>
+          <gr-vote-chip> </gr-vote-chip>
+          <span class="overrideLabel"> Override2 </span>
+        </div>`
+      );
+    });
+  });
+
+  test('calculateEndpointName()', () => {
+    assert.equal(
+      element.computeEndpointName('code-owners~CodeOwnerSub'),
+      'submit-requirement-codeowners'
+    );
+  });
+});
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 deea4ab..80a1a9a 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
@@ -1,66 +1,51 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
 import '../../shared/gr-comment-thread/gr-comment-thread';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
-
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-thread-list_html';
-import {parseDate} from '../../../utils/date-util';
-
-import {CommentSide, SpecialFilePath} from '../../../constants/constants';
-import {computed, customElement, observe, property} from '@polymer/decorators';
-import {
-  PolymerSpliceChange,
-  PolymerDeepPropertyChange,
-} from '@polymer/polymer/interfaces';
+import {SpecialFilePath} from '../../../constants/constants';
 import {
   AccountDetailInfo,
   AccountInfo,
-  ChangeInfo,
   NumericChangeId,
   UrlEncodedCommentId,
 } from '../../../types/common';
+import {ChangeMessageId} from '../../../api/rest-api';
 import {
   CommentThread,
-  isDraft,
-  isUnresolved,
-  isDraftThread,
-  isRobotThread,
-  hasHumanReply,
   getCommentAuthors,
-  computeId,
-  UIComment,
+  getMentionedThreads,
+  hasHumanReply,
+  isDraft,
+  isDraftThread,
+  isMentionedThread,
+  isRobotThread,
+  isUnresolved,
+  lastUpdated,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
-import {assertIsDefined, assertNever} from '../../../utils/common-util';
-import {CommentTabState} from '../../../types/events';
+import {assertIsDefined} from '../../../utils/common-util';
+import {CommentTabState, TabState} from '../../../types/events';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-
-interface CommentThreadWithInfo {
-  thread: CommentThread;
-  hasRobotComment: boolean;
-  hasHumanReplyToRobotComment: boolean;
-  unresolved: boolean;
-  isEditing: boolean;
-  hasDraft: boolean;
-  updated?: Date;
-}
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, queryAll, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ParsedChangeInfo} from '../../../types/types';
+import {repeat} from 'lit/directives/repeat.js';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {getAppContext} from '../../../services/app-context';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {Interaction} from '../../../constants/reporting';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {HtmlPatched} from '../../../utils/lit-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {specialFilePathCompare} from '../../../utils/path-list-util';
 
 enum SortDropdownState {
   TIMESTAMP = 'Latest timestamp',
@@ -69,571 +54,587 @@
 
 export const __testOnly_SortDropdownState = SortDropdownState;
 
-@customElement('gr-thread-list')
-export class GrThreadList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+/**
+ * Order as follows:
+ * - Patchset level threads (descending based on patchset number)
+ * - unresolved
+ * - comments with drafts
+ * - comments without drafts
+ * - resolved
+ * - comments with drafts
+ * - comments without drafts
+ * - File name
+ * - Line number
+ * - Unresolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ * - Resolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ */
+export function compareThreads(
+  c1: CommentThread,
+  c2: CommentThread,
+  byTimestamp = false
+) {
+  if (byTimestamp) {
+    const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+    const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+    const timeDiff = c2Time - c1Time;
+    if (timeDiff !== 0) return c2Time - c1Time;
   }
 
-  @property({type: Object})
-  change?: ChangeInfo;
+  if (c1.path !== c2.path) {
+    // '/PATCHSET' will not come before '/COMMIT' when sorting
+    // alphabetically so move it to the front explicitly
+    if (c1.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return -1;
+    }
+    if (c2.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+      return 1;
+    }
+    return specialFilePathCompare(c1.path, c2.path);
+  }
 
+  // Convert 'FILE' and 'LOST' to undefined.
+  const line1 = typeof c1.line === 'number' ? c1.line : undefined;
+  const line2 = typeof c2.line === 'number' ? c2.line : undefined;
+  if (line1 !== line2) {
+    // one of them is a FILE/LOST comment, show first
+    if (line1 === undefined) return -1;
+    if (line2 === undefined) return 1;
+    // Lower line numbers first.
+    return line1 < line2 ? -1 : 1;
+  }
+
+  if (c1.patchNum !== c2.patchNum) {
+    // `patchNum` should be required, but show undefined first.
+    if (c1.patchNum === undefined) return -1;
+    if (c2.patchNum === undefined) return 1;
+    // Higher patchset numbers first.
+    return c1.patchNum > c2.patchNum ? -1 : 1;
+  }
+
+  // Sorting should not be based on the thread being unresolved or being a draft
+  // thread, because that would be a surprising re-sort when the thread changes
+  // state.
+
+  const c1Time = lastUpdated(c1)?.getTime() ?? 0;
+  const c2Time = lastUpdated(c2)?.getTime() ?? 0;
+  if (c2Time !== c1Time) {
+    // Newer comments first.
+    return c2Time - c1Time;
+  }
+
+  return 0;
+}
+
+@customElement('gr-thread-list')
+export class GrThreadList extends LitElement {
+  @queryAll('gr-comment-thread')
+  threadElements?: NodeListOf<GrCommentThread>;
+
+  /**
+   * Raw list of threads for the component to show.
+   *
+   * ATTENTION! this.threads should never be used directly within the component.
+   *
+   * Either use getAllThreads(), which applies filters that are inherent to what
+   * the component is supposed to render,
+   * e.g. onlyShowRobotCommentsWithHumanReply.
+   *
+   * Or use getDisplayedThreads(), which applies the currently selected filters
+   * on top.
+   */
   @property({type: Array})
   threads: CommentThread[] = [];
 
-  @property({type: String})
-  changeNum?: NumericChangeId;
-
-  @property({type: Boolean})
-  loggedIn?: boolean;
-
-  @property({type: Array})
-  _sortedThreads: CommentThread[] = [];
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-comment-context'})
   showCommentContext = false;
 
-  @property({
-    computed:
-      '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
-      '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)',
-    type: Array,
-  })
-  _displayedThreads: CommentThread[] = [];
-
-  // thread-list is used in multiple places like the change log, hence
-  // keeping the default to be false. When used in comments tab, it's
-  // set as true.
-  @property({type: Boolean})
+  /** Along with `draftsOnly` is the currently selected filter. */
+  @property({type: Boolean, attribute: 'unresolved-only'})
   unresolvedOnly = false;
 
-  @property({type: Boolean})
-  _draftsOnly = false;
-
-  @property({type: Boolean})
+  @property({
+    type: Boolean,
+    attribute: 'only-show-robot-comments-with-human-reply',
+  })
   onlyShowRobotCommentsWithHumanReply = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'hide-dropdown'})
   hideDropdown = false;
 
-  @property({type: Object, observer: '_commentTabStateChange'})
-  commentTabState?: CommentTabState;
+  @property({type: Object, attribute: 'comment-tab-state'})
+  commentTabState?: TabState;
 
-  @property({type: Object})
-  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
-
-  @property({type: Array, notify: true})
-  selectedAuthors: AccountInfo[] = [];
-
-  @property({type: Object})
-  account?: AccountDetailInfo;
-
-  @computed('unresolvedOnly', '_draftsOnly')
-  get commentsDropdownValue() {
-    // set initial value and triggered when comment summary chips are clicked
-    if (this._draftsOnly) return CommentTabState.DRAFTS;
-    return this.unresolvedOnly
-      ? CommentTabState.UNRESOLVED
-      : CommentTabState.SHOW_ALL;
-  }
-
-  @property({type: String})
+  @property({type: String, attribute: 'scroll-comment-id'})
   scrollCommentId?: UrlEncodedCommentId;
 
-  _showEmptyThreadsMessage(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
-  ) {
-    if (!threads || !displayedThreads) return false;
-    return !threads.length || (unresolvedOnly && !displayedThreads.length);
+  /**
+   * Optional context information when threads are being displayed for a
+   * specific change message. That influences which comments are expanded or
+   * collapsed by default.
+   */
+  @property({type: String, attribute: 'message-id'})
+  messageId?: ChangeMessageId;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  change?: ParsedChangeInfo;
+
+  @state()
+  account?: AccountDetailInfo;
+
+  @state()
+  selectedAuthors: AccountInfo[] = [];
+
+  @state()
+  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
+
+  /** Along with `unresolvedOnly` is the currently selected filter. */
+  @state()
+  draftsOnly = false;
+
+  @state()
+  mentionsOnly = false;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly flagsService = getAppContext().flagsService;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly patched = new HtmlPatched(key => {
+    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
+      component: this.tagName,
+      key: key.substring(0, 300),
+    });
+  });
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.account = x)
+    );
+    // for COMMENTS_AUTOCLOSE logging purposes only
+    this.reporting.reportInteraction(
+      Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED
+    );
   }
 
-  _computeEmptyThreadsMessage(threads: CommentThread[]) {
-    return !threads.length ? 'No comments' : 'No unresolved comments';
+  override willUpdate(changed: PropertyValues) {
+    if (changed.has('commentTabState')) this.onCommentTabStateUpdate();
+    if (changed.has('scrollCommentId')) this.onScrollCommentIdUpdate();
   }
 
-  _showPartyPopper(threads: CommentThread[]) {
-    return !!threads.length;
-  }
-
-  _computeResolvedCommentsMessage(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean,
-    onlyShowRobotCommentsWithHumanReply: boolean
-  ) {
-    if (onlyShowRobotCommentsWithHumanReply) {
-      threads = this.filterRobotThreadsWithoutHumanReply(threads) ?? [];
+  private onCommentTabStateUpdate() {
+    switch (this.commentTabState?.commentTab) {
+      case CommentTabState.MENTIONS:
+        this.handleOnlyMentions();
+        break;
+      case CommentTabState.UNRESOLVED:
+        this.handleOnlyUnresolved();
+        break;
+      case CommentTabState.DRAFTS:
+        this.handleOnlyDrafts();
+        break;
+      case CommentTabState.SHOW_ALL:
+        this.handleAllComments();
+        break;
     }
-    if (unresolvedOnly && threads.length && !displayedThreads.length) {
-      return `Show ${pluralize(threads.length, 'resolved comment')}`;
+  }
+
+  /**
+   * When user wants to scroll to a comment, render all comments so that the
+   * appropriate comment can be scrolled into view.
+   */
+  private onScrollCommentIdUpdate() {
+    if (this.scrollCommentId) this.handleAllComments();
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        #threads {
+          display: block;
+        }
+        gr-comment-thread {
+          display: block;
+          margin-bottom: var(--spacing-m);
+        }
+        .header {
+          align-items: center;
+          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;
+          padding: var(--spacing-s) 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] {
+          display: block;
+        }
+        .thread-separator {
+          border-top: 1px solid var(--border-color);
+          margin-top: var(--spacing-xl);
+        }
+        .show-resolved-comments {
+          box-shadow: none;
+          padding-left: var(--spacing-m);
+        }
+        .partypopper {
+          margin-right: var(--spacing-s);
+        }
+        gr-dropdown-list {
+          --trigger-style-text-color: var(--primary-text-color);
+          --trigger-style-font-family: var(--font-family);
+        }
+        .filter-text,
+        .sort-text,
+        .author-text {
+          margin-right: var(--spacing-s);
+          color: var(--deemphasized-text-color);
+        }
+        .author-text {
+          margin-left: var(--spacing-m);
+        }
+        gr-account-label {
+          --account-max-length: 120px;
+          display: inline-block;
+          user-select: none;
+          --label-border-radius: 8px;
+          margin: 0 var(--spacing-xs);
+          padding: var(--spacing-xs) var(--spacing-m);
+          line-height: var(--line-height-normal);
+          cursor: pointer;
+        }
+        gr-account-label:focus {
+          outline: none;
+        }
+        gr-account-label:hover,
+        gr-account-label:hover {
+          box-shadow: var(--elevation-level-1);
+          cursor: pointer;
+        }
+      `,
+    ];
+  }
+
+  override updated(): void {
+    // for COMMENTS_AUTOCLOSE logging purposes only
+    const threads = this.shadowRoot!.querySelectorAll('gr-comment-thread');
+    if (threads.length > 0) {
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED,
+        {uid: threads[0].uid}
+      );
     }
-    return '';
   }
 
-  _showResolvedCommentsButton(
-    threads: CommentThread[],
-    displayedThreads: CommentThread[],
-    unresolvedOnly: boolean
-  ) {
-    return unresolvedOnly && threads.length && !displayedThreads.length;
+  override render() {
+    return html`
+      ${this.renderDropdown()}
+      <div id="threads" part="threads">
+        ${this.renderEmptyThreadsMessage()} ${this.renderCommentThreads()}
+      </div>
+    `;
   }
 
-  _handleResolvedCommentsMessageClick() {
-    this.unresolvedOnly = !this.unresolvedOnly;
+  private renderDropdown() {
+    if (this.hideDropdown) return;
+    return html`
+      <div class="header">
+        <span class="sort-text">Sort By:</span>
+        <gr-dropdown-list
+          id="sortDropdown"
+          .value=${this.sortDropdownValue}
+          @value-change=${(e: CustomEvent) =>
+            (this.sortDropdownValue = e.detail.value)}
+          .items=${this.getSortDropdownEntries()}
+        >
+        </gr-dropdown-list>
+        <span class="separator"></span>
+        <span class="filter-text">Filter By:</span>
+        <gr-dropdown-list
+          id="filterDropdown"
+          .value=${this.getCommentsDropdownValue()}
+          @value-change=${this.handleCommentsDropdownValueChange}
+          .items=${this.getCommentsDropdownEntries()}
+        >
+        </gr-dropdown-list>
+        ${this.renderAuthorChips()}
+      </div>
+    `;
   }
 
-  getSortDropdownEntires() {
+  private renderEmptyThreadsMessage() {
+    const threads = this.getAllThreads();
+    const threadsEmpty = threads.length === 0;
+    const displayedEmpty = this.getDisplayedThreads().length === 0;
+    if (!displayedEmpty) return;
+    const showPopper = this.unresolvedOnly && !threadsEmpty;
+    const popper = html`<span class="partypopper">&#x1F389;</span>`;
+    const showButton = this.unresolvedOnly && !threadsEmpty;
+    const button = html`
+      <gr-button
+        class="show-resolved-comments"
+        link
+        @click=${this.handleAllComments}
+        >Show ${pluralize(threads.length, 'resolved comment')}</gr-button
+      >
+    `;
+    return html`
+      <div>
+        <span>
+          ${showPopper ? popper : undefined}
+          ${threadsEmpty ? 'No comments' : 'No unresolved comments'}
+          ${showButton ? button : undefined}
+        </span>
+      </div>
+    `;
+  }
+
+  private renderCommentThreads() {
+    const threads = this.getDisplayedThreads();
+    return repeat(
+      threads,
+      thread => thread.rootId,
+      (thread, index) => {
+        const isFirst =
+          index === 0 || threads[index - 1].path !== threads[index].path;
+        const separator =
+          index !== 0 && isFirst
+            ? this.patched.html`<div class="thread-separator"></div>`
+            : undefined;
+        const commentThread = this.renderCommentThread(thread, isFirst);
+        return this.patched.html`${separator}${commentThread}`;
+      }
+    );
+  }
+
+  private renderCommentThread(thread: CommentThread, isFirst: boolean) {
+    return this.patched.html`
+      <gr-comment-thread
+        .thread=${thread}
+        show-file-path
+        ?show-ported-comment=${thread.ported}
+        ?show-comment-context=${this.showCommentContext}
+        ?show-file-name=${isFirst}
+        .messageId=${this.messageId}
+        ?should-scroll-into-view=${thread.rootId === this.scrollCommentId}
+        @comment-thread-editing-changed=${() => {
+          this.requestUpdate();
+        }}
+      ></gr-comment-thread>
+    `;
+  }
+
+  private renderAuthorChips() {
+    const authors = getCommentAuthors(this.getDisplayedThreads(), this.account);
+    if (authors.length === 0) return;
+    return html`<span class="author-text">From:</span>${authors.map(author =>
+        this.renderAccountChip(author)
+      )}`;
+  }
+
+  private renderAccountChip(account: AccountInfo) {
+    const selected = this.selectedAuthors.some(
+      a => a._account_id === account._account_id
+    );
+    return html`
+      <gr-account-label
+        .account=${account}
+        @click=${this.handleAccountClicked}
+        selectionChipStyle
+        noStatusIcons
+        ?selected=${selected}
+      ></gr-account-label>
+    `;
+  }
+
+  private getCommentsDropdownValue() {
+    if (this.mentionsOnly) return CommentTabState.MENTIONS;
+    if (this.draftsOnly) return CommentTabState.DRAFTS;
+    if (this.unresolvedOnly) return CommentTabState.UNRESOLVED;
+    return CommentTabState.SHOW_ALL;
+  }
+
+  private getSortDropdownEntries() {
     return [
       {text: SortDropdownState.FILES, value: SortDropdownState.FILES},
       {text: SortDropdownState.TIMESTAMP, value: SortDropdownState.TIMESTAMP},
     ];
   }
 
-  getCommentsDropdownEntires(threads: CommentThread[], loggedIn?: boolean) {
-    const items: DropdownItem[] = [
-      {
-        text: `Unresolved (${this._countUnresolved(threads)})`,
-        value: CommentTabState.UNRESOLVED,
-      },
-      {
-        text: `All (${this._countAllThreads(threads)})`,
-        value: CommentTabState.SHOW_ALL,
-      },
-    ];
-    if (loggedIn)
-      items.splice(1, 0, {
-        text: `Drafts (${this._countDrafts(threads)})`,
+  // private, but visible for testing
+  getCommentsDropdownEntries() {
+    const items: DropdownItem[] = [];
+    const threads = this.getAllThreads();
+    items.push({
+      text: `Unresolved (${threads.filter(isUnresolved).length})`,
+      value: CommentTabState.UNRESOLVED,
+    });
+    if (this.account) {
+      if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+        items.push({
+          text: `Mentions (${
+            getMentionedThreads(threads, this.account).length
+          })`,
+          value: CommentTabState.MENTIONS,
+        });
+      }
+      items.push({
+        text: `Drafts (${threads.filter(isDraftThread).length})`,
         value: CommentTabState.DRAFTS,
       });
+    }
+    items.push({
+      text: `All (${threads.length})`,
+      value: CommentTabState.SHOW_ALL,
+    });
     return items;
   }
 
-  getCommentAuthors(threads?: CommentThread[], account?: AccountDetailInfo) {
-    return getCommentAuthors(threads, account);
-  }
-
-  handleAccountClicked(e: MouseEvent) {
+  private handleAccountClicked(e: MouseEvent) {
     const account = (e.target as GrAccountChip).account;
     assertIsDefined(account, 'account');
-    const index = this.selectedAuthors.findIndex(
-      author => author._account_id === account._account_id
-    );
-    if (index === -1) this.push('selectedAuthors', account);
-    else this.splice('selectedAuthors', index, 1);
-    // re-assign so that isSelected template method is called
-    this.selectedAuthors = [...this.selectedAuthors];
+    const predicate = (a: AccountInfo) => a._account_id === account._account_id;
+    const found = this.selectedAuthors.find(predicate);
+    if (found) {
+      this.selectedAuthors = this.selectedAuthors.filter(a => !predicate(a));
+    } else {
+      this.selectedAuthors = [...this.selectedAuthors, account];
+    }
   }
 
-  isSelected(author: AccountInfo, selectedAuthors: AccountInfo[]) {
-    return selectedAuthors.some(a => a._account_id === author._account_id);
-  }
-
-  computeShouldScrollIntoView(
-    comments: UIComment[],
-    scrollCommentId?: UrlEncodedCommentId
-  ) {
-    const comment = comments?.[0];
-    if (!comment) return false;
-    return computeId(comment) === scrollCommentId;
-  }
-
-  handleSortDropdownValueChange(e: CustomEvent) {
-    this.sortDropdownValue = e.detail.value;
-    /*
-     * Ideally we would have updateSortedThreads observe on sortDropdownValue
-     * but the method triggered re-render only when the length of threads
-     * changes, hence keep the explicit resortThreads method
-     */
-    this.resortThreads(this.threads);
-  }
-
+  // private, but visible for testing
   handleCommentsDropdownValueChange(e: CustomEvent) {
     const value = e.detail.value;
-    if (value === CommentTabState.UNRESOLVED) this._handleOnlyUnresolved();
-    else if (value === CommentTabState.DRAFTS) this._handleOnlyDrafts();
-    else this._handleAllComments();
-  }
-
-  _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
-    if (
-      this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
-      !this.hideDropdown
-    ) {
-      if (c1.updated && c2.updated) return c1.updated > c2.updated ? -1 : 1;
+    switch (value) {
+      case CommentTabState.UNRESOLVED:
+        this.handleOnlyUnresolved();
+        break;
+      case CommentTabState.MENTIONS:
+        this.handleOnlyMentions();
+        break;
+      case CommentTabState.DRAFTS:
+        this.handleOnlyDrafts();
+        break;
+      default:
+        this.handleAllComments();
     }
-
-    if (c1.thread.path !== c2.thread.path) {
-      // '/PATCHSET' will not come before '/COMMIT' when sorting
-      // alphabetically so move it to the front explicitly
-      if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return -1;
-      }
-      if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return 1;
-      }
-      return c1.thread.path.localeCompare(c2.thread.path);
-    }
-
-    // Patchset comments have no line/range associated with them
-    if (c1.thread.line !== c2.thread.line) {
-      if (!c1.thread.line || !c2.thread.line) {
-        // one of them is a file level comment, show first
-        return c1.thread.line ? 1 : -1;
-      }
-      return c1.thread.line < c2.thread.line ? -1 : 1;
-    }
-
-    if (c1.thread.patchNum !== c2.thread.patchNum) {
-      if (!c1.thread.patchNum) return 1;
-      if (!c2.thread.patchNum) return -1;
-      // 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;
-    }
-
-    if (c2.unresolved !== c1.unresolved) {
-      if (!c1.unresolved) return 1;
-      if (!c2.unresolved) return -1;
-    }
-
-    if (c2.hasDraft !== c1.hasDraft) {
-      if (!c1.hasDraft) return 1;
-      if (!c2.hasDraft) return -1;
-    }
-
-    if (c2.updated !== c1.updated) {
-      if (!c1.updated) return 1;
-      if (!c2.updated) return -1;
-      return c2.updated.getTime() - c1.updated.getTime();
-    }
-
-    if (c2.thread.rootId !== c1.thread.rootId) {
-      if (!c1.thread.rootId) return 1;
-      if (!c2.thread.rootId) return -1;
-      return c1.thread.rootId.localeCompare(c2.thread.rootId);
-    }
-
-    return 0;
-  }
-
-  resortThreads(threads: CommentThread[]) {
-    const threadsWithInfo = threads.map(thread =>
-      this._getThreadWithStatusInfo(thread)
-    );
-    this._sortedThreads = threadsWithInfo
-      .sort((t1, t2) => this._compareThreads(t1, t2))
-      .map(threadInfo => threadInfo.thread);
   }
 
   /**
-   * Observer on threads and update _sortedThreads when needed.
-   * Order as follows:
-   * - Patchset level threads (descending based on patchset number)
-   * - unresolved
-   * - comments with drafts
-   * - comments without drafts
-   * - resolved
-   * - comments with drafts
-   * - comments without drafts
-   * - File name
-   * - Line number
-   * - Unresolved (descending based on patchset number)
-   * - comments with drafts
-   * - comments without drafts
-   * - Resolved (descending based on patchset number)
-   * - comments with drafts
-   * - comments without drafts
-   *
-   * @param threads
-   * @param spliceRecord
+   * Returns all threads that the list may show.
    */
-  @observe('threads', 'threads.splices')
-  _updateSortedThreads(
-    threads: CommentThread[],
-    _: PolymerSpliceChange<CommentThread[]>
-  ) {
-    if (!threads || threads.length === 0) {
-      this._sortedThreads = [];
-      this._displayedThreads = [];
-      return;
-    }
-    // We only want to sort on thread additions / removals to avoid
-    // re-rendering on modifications (add new reply / edit draft etc.).
-    // https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
-    // TODO(TS): We have removed a buggy check of the splices here. A splice
-    // with addedCount > 0 or removed.length > 0 should also cause re-sorting
-    // and re-rendering, but apparently spliceRecord is always undefined for
-    // whatever reason.
-    // If there is an unsaved draftThread which is supposed to be replaced with
-    // a saved draftThread then resort all threads
-    const unsavedThread = this._sortedThreads.some(thread =>
-      thread.rootId?.includes('draft__')
-    );
-    if (this._sortedThreads.length === threads.length && !unsavedThread) {
-      // Instead of replacing the _sortedThreads which will trigger a re-render,
-      // we override all threads inside of it.
-      for (const thread of threads) {
-        const idxInSortedThreads = this._sortedThreads.findIndex(
-          t => t.rootId === thread.rootId
-        );
-        this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread});
-      }
-      return;
-    }
-
-    this.resortThreads(threads);
-  }
-
-  _computeDisplayedThreads(
-    sortedThreadsRecord?: PolymerDeepPropertyChange<
-      CommentThread[],
-      CommentThread[]
-    >,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
-    return sortedThreadsRecord.base.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
+  // private, but visible for testing
+  getAllThreads() {
+    return this.threads.filter(
+      t =>
+        !this.onlyShowRobotCommentsWithHumanReply ||
+        !isRobotThread(t) ||
+        hasHumanReply(t)
     );
   }
 
-  _isFirstThreadWithFileName(
-    displayedThreads: CommentThread[],
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    const threads = displayedThreads.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
+  /**
+   * Returns all threads that are currently shown in the list, respecting the
+   * currently selected filter.
+   */
+  // private, but visible for testing
+  getDisplayedThreads() {
+    const byTimestamp =
+      this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
+      !this.hideDropdown;
+    return this.getAllThreads()
+      .sort((t1, t2) => compareThreads(t1, t2, byTimestamp))
+      .filter(t => this.shouldShowThread(t));
+  }
+
+  private isASelectedAuthor(account?: AccountInfo) {
+    if (!account) return false;
+    return this.selectedAuthors.some(
+      author => account._account_id === author._account_id
     );
-    const index = threads.findIndex(t => t.rootId === thread.rootId);
-    if (index === -1) {
+  }
+
+  private shouldShowThread(thread: CommentThread) {
+    // Never make a thread disappear while the user is editing it.
+    assertIsDefined(thread.rootId, 'thread.rootId');
+    const el = this.queryThreadElement(thread.rootId);
+    if (el?.editing) return true;
+
+    if (this.selectedAuthors.length > 0) {
+      const hasACommentFromASelectedAuthor = thread.comments.some(
+        c =>
+          (isDraft(c) && this.isASelectedAuthor(this.account)) ||
+          this.isASelectedAuthor(c.author)
+      );
+      if (!hasACommentFromASelectedAuthor) return false;
+    }
+
+    // This is probably redundant, because getAllThreads() filters this out.
+    if (this.onlyShowRobotCommentsWithHumanReply) {
+      if (isRobotThread(thread) && !hasHumanReply(thread)) return false;
+    }
+
+    if (this.mentionsOnly && !isMentionedThread(thread, this.account))
       return false;
-    }
-    return index === 0 || threads[index - 1].path !== threads[index].path;
+
+    if (this.draftsOnly && !isDraftThread(thread)) return false;
+    if (this.unresolvedOnly && !isUnresolved(thread)) return false;
+
+    return true;
   }
 
-  _shouldRenderSeparator(
-    displayedThreads: CommentThread[],
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    const threads = displayedThreads.filter(t =>
-      this._shouldShowThread(
-        t,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
-    );
-    const index = threads.findIndex(t => t.rootId === thread.rootId);
-    if (index === -1) {
-      return false;
-    }
-    return (
-      index > 0 &&
-      this._isFirstThreadWithFileName(
-        displayedThreads,
-        thread,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors
-      )
-    );
-  }
-
-  _shouldShowThread(
-    thread: CommentThread,
-    unresolvedOnly?: boolean,
-    draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean,
-    selectedAuthors?: AccountInfo[]
-  ) {
-    if (
-      [
-        thread,
-        unresolvedOnly,
-        draftsOnly,
-        onlyShowRobotCommentsWithHumanReply,
-        selectedAuthors,
-      ].includes(undefined)
-    ) {
-      return false;
-    }
-
-    if (selectedAuthors!.length) {
-      if (
-        !thread.comments.some(
-          c =>
-            c.author &&
-            selectedAuthors!.some(
-              author => c.author!._account_id === author._account_id
-            )
-        )
-      ) {
-        return false;
-      }
-    }
-
-    if (
-      !draftsOnly &&
-      !unresolvedOnly &&
-      !onlyShowRobotCommentsWithHumanReply
-    ) {
-      return true;
-    }
-
-    const threadInfo = this._getThreadWithStatusInfo(thread);
-
-    if (threadInfo.isEditing) {
-      return true;
-    }
-
-    if (
-      threadInfo.hasRobotComment &&
-      onlyShowRobotCommentsWithHumanReply &&
-      !threadInfo.hasHumanReplyToRobotComment
-    ) {
-      return false;
-    }
-
-    let filtersCheck = true;
-    if (draftsOnly && unresolvedOnly) {
-      filtersCheck = threadInfo.hasDraft && threadInfo.unresolved;
-    } else if (draftsOnly) {
-      filtersCheck = threadInfo.hasDraft;
-    } else if (unresolvedOnly) {
-      filtersCheck = threadInfo.unresolved;
-    }
-
-    return filtersCheck;
-  }
-
-  _getThreadWithStatusInfo(thread: CommentThread): CommentThreadWithInfo {
-    const comments = thread.comments;
-    const lastComment = comments.length
-      ? comments[comments.length - 1]
-      : undefined;
-    const hasRobotComment = isRobotThread(thread);
-    const hasHumanReplyToRobotComment =
-      hasRobotComment && hasHumanReply(thread);
-    let updated = undefined;
-    if (lastComment) {
-      if (isDraft(lastComment)) updated = lastComment.__date;
-      if (lastComment.updated) updated = parseDate(lastComment.updated);
-    }
-
-    return {
-      thread,
-      hasRobotComment,
-      hasHumanReplyToRobotComment,
-      unresolved: !!lastComment && !!lastComment.unresolved,
-      isEditing: isDraft(lastComment) && !!lastComment.__editing,
-      hasDraft: !!lastComment && isDraft(lastComment),
-      updated,
-    };
-  }
-
-  _isOnParent(side?: CommentSide) {
-    // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
-    // classified as parent??
-    return !!side;
-  }
-
-  _handleOnlyUnresolved() {
+  private handleOnlyUnresolved() {
     this.unresolvedOnly = true;
-    this._draftsOnly = false;
+    this.draftsOnly = false;
+    this.mentionsOnly = false;
   }
 
-  _handleOnlyDrafts() {
-    this._draftsOnly = true;
+  private handleOnlyMentions() {
+    this.mentionsOnly = true;
+    this.unresolvedOnly = true;
+    this.draftsOnly = false;
+  }
+
+  private handleOnlyDrafts() {
+    this.draftsOnly = true;
     this.unresolvedOnly = false;
+    this.mentionsOnly = false;
   }
 
-  _handleAllComments() {
-    this._draftsOnly = false;
+  private handleAllComments() {
+    this.draftsOnly = false;
     this.unresolvedOnly = false;
+    this.mentionsOnly = 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);
+  private queryThreadElement(rootId: string): GrCommentThread | undefined {
+    const els = [...(this.threadElements ?? [])] as GrCommentThread[];
+    return els.find(el => el.rootId === rootId);
   }
 }
 
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
deleted file mode 100644
index 3eb28c9..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ /dev/null
@@ -1,170 +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">
-    #threads {
-      display: block;
-    }
-    gr-comment-thread {
-      display: block;
-      margin-bottom: var(--spacing-m);
-    }
-    .header {
-      align-items: center;
-      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;
-      padding: var(--spacing-s) 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] {
-      display: block;
-    }
-    .thread-separator {
-      border-top: 1px solid var(--border-color);
-      margin-top: var(--spacing-xl);
-    }
-    .show-resolved-comments {
-      box-shadow: none;
-      padding-left: var(--spacing-m);
-    }
-    .partypopper{
-      margin-right: var(--spacing-s);
-    }
-    gr-dropdown-list {
-      --trigger-style-text-color: var(--primary-text-color);
-      --trigger-style-font-family: var(--font-family);
-    }
-    .filter-text, .sort-text, .author-text {
-      margin-right: var(--spacing-s);
-      color: var(--deemphasized-text-color);
-    }
-    .author-text {
-      margin-left: var(--spacing-m);
-    }
-    gr-account-label {
-      --account-max-length: 120px;
-      display: inline-block;
-      user-select: none;
-      --label-border-radius: 8px;
-      margin: 0 var(--spacing-xs);
-      padding: var(--spacing-xs) var(--spacing-m);
-      line-height: var(--line-height-normal);
-      cursor: pointer;
-    }
-    gr-account-label:focus {
-      outline: none;
-    }
-    gr-account-label:hover,
-    gr-account-label:hover {
-      box-shadow: var(--elevation-level-1);
-      cursor: pointer;
-    }
-  </style>
-  <template is="dom-if" if="[[!hideDropdown]]">
-    <div class="header">
-      <span class="sort-text">Sort By:</span>
-      <gr-dropdown-list
-        id="sortDropdown"
-        value="[[sortDropdownValue]]"
-        on-value-change="handleSortDropdownValueChange"
-        items="[[getSortDropdownEntires()]]"
-      >
-      </gr-dropdown-list>
-      <span class="separator"></span>
-      <span class="filter-text">Filter By:</span>
-      <gr-dropdown-list
-        id="filterDropdown"
-        value="[[commentsDropdownValue]]"
-        on-value-change="handleCommentsDropdownValueChange"
-        items="[[getCommentsDropdownEntires(threads, loggedIn)]]"
-      >
-      </gr-dropdown-list>
-      <template is="dom-if" if="[[_displayedThreads.length]]">
-        <span class="author-text">From:</span>
-        <template is="dom-repeat" items="[[getCommentAuthors(_displayedThreads, account)]]">
-          <gr-account-label
-            account="[[item]]"
-            on-click="handleAccountClicked"
-            selectionChipStyle
-            selected="[[isSelected(item, selectedAuthors)]]"
-          > </gr-account-label>
-        </template>
-      </template>
-    </div>
-  </template>
-  <div id="threads" part="threads">
-    <template
-      is="dom-if"
-      if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
-    >
-      <div>
-        <span>
-          <template is="dom-if" if="[[_showPartyPopper(threads)]]">
-            <span class="partypopper">\&#x1F389</span>
-          </template>
-          [[_computeEmptyThreadsMessage(threads, _displayedThreads,
-          unresolvedOnly)]]
-          <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
-            <gr-button
-              class="show-resolved-comments"
-              link
-              on-click="_handleResolvedCommentsMessageClick">
-                [[_computeResolvedCommentsMessage(threads, _displayedThreads,
-                unresolvedOnly, onlyShowRobotCommentsWithHumanReply)]]
-            </gr-button>
-          </template>
-        </span>
-      </div>
-    </template>
-    <template
-      is="dom-repeat"
-      items="[[_displayedThreads]]"
-      as="thread"
-      initial-count="10"
-      target-framerate="60"
-    >
-      <template
-        is="dom-if"
-        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
-      >
-        <div class="thread-separator"></div>
-      </template>
-      <gr-comment-thread
-        show-file-path=""
-        show-ported-comment="[[thread.ported]]"
-        show-comment-context="[[showCommentContext]]"
-        change-num="[[changeNum]]"
-        comments="[[thread.comments]]"
-        diff-side="[[thread.diffSide]]"
-        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
-        project-name="[[change.project]]"
-        is-on-parent="[[_isOnParent(thread.commentSide)]]"
-        line-num="[[thread.line]]"
-        patch-num="[[thread.patchNum]]"
-        path="[[thread.path]]"
-        root-id="{{thread.rootId}}"
-        should-scroll-into-view="[[computeShouldScrollIntoView(thread.comments, scrollCommentId)]]"
-      ></gr-comment-thread>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
deleted file mode 100644
index aab5cee..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ /dev/null
@@ -1,673 +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-thread-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {CommentTabState} from '../../../types/events.js';
-import {__testOnly_SortDropdownState} from './gr-thread-list.js';
-import {queryAll} from '../../../test/test-utils.js';
-import {accountOrGroupKey} from '../../../utils/account-util.js';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
-import {createAccountDetailWithId} from '../../../test/test-data-generators.js';
-
-const basicFixture = fixtureFromElement('gr-thread-list');
-
-suite('gr-thread-list tests', () => {
-  let element;
-
-  function getVisibleThreads() {
-    return [...dom(element.root)
-        .querySelectorAll('gr-comment-thread')]
-        .filter(e => e.style.display !== 'none');
-  }
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    element.changeNum = 123;
-    element.change = {
-      project: 'testRepo',
-    };
-    element.threads = [
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000001,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'ecf0b9fa_fe1a5f62',
-            line: 5,
-            updated: '1',
-            message: 'test',
-            unresolved: true,
-          },
-          {
-            id: '503008e2_0ab203ee',
-            path: '/COMMIT_MSG',
-            line: 5,
-            in_reply_to: 'ecf0b9fa_fe1a5f62',
-            updated: '1',
-            message: 'draft',
-            unresolved: true,
-            __draft: true,
-            __draftID: '0.m683trwff68',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'ecf0b9fa_fe1a5f62',
-        updated: '1',
-      },
-      {
-        comments: [
-          {
-            path: 'test.txt',
-            author: {
-              _account_id: 1000002,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 3,
-            id: '09a9fb0a_1484e6cf',
-            side: 'PARENT',
-            updated: '2',
-            message: 'Some comment on another patchset.',
-            unresolved: false,
-          },
-        ],
-        patchNum: 3,
-        path: 'test.txt',
-        rootId: '09a9fb0a_1484e6cf',
-        updated: '2',
-        commentSide: 'PARENT',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000002,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: '8caddf38_44770ec1',
-            updated: '3',
-            message: 'Another unresolved comment',
-            unresolved: false,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        rootId: '8caddf38_44770ec1',
-        updated: '3',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000003,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 2,
-            id: 'scaddf38_44770ec1',
-            line: 4,
-            updated: '4',
-            message: 'Yet another unresolved comment',
-            unresolved: true,
-          },
-        ],
-        patchNum: 2,
-        path: '/COMMIT_MSG',
-        line: 4,
-        rootId: 'scaddf38_44770ec1',
-        updated: '4',
-      },
-      {
-        comments: [
-          {
-            id: 'zcf0b9fa_fe1a5f62',
-            path: '/COMMIT_MSG',
-            line: 6,
-            updated: '5',
-            message: 'resolved draft',
-            unresolved: false,
-            __draft: true,
-            __draftID: '0.m683trwff69',
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 6,
-        rootId: 'zcf0b9fa_fe1a5f62',
-        updated: '5',
-      },
-      {
-        comments: [
-          {
-            id: 'patchset_level_1',
-            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '6',
-            message: 'patchset comment 1',
-            unresolved: false,
-            __editing: false,
-            patch_set: '2',
-          },
-        ],
-        patchNum: 2,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        rootId: 'patchset_level_1',
-        updated: '6',
-      },
-      {
-        comments: [
-          {
-            id: 'patchset_level_2',
-            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '7',
-            message: 'patchset comment 2',
-            unresolved: false,
-            __editing: false,
-            patch_set: '3',
-          },
-        ],
-        patchNum: 3,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        rootId: 'patchset_level_2',
-        updated: '7',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc1',
-            line: 5,
-            updated: '8',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc1',
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 5,
-        rootId: 'rc1',
-        updated: '8',
-      },
-      {
-        comments: [
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'rc2',
-            line: 7,
-            updated: '9',
-            message: 'test',
-            unresolved: true,
-            robot_id: 'rc2',
-          },
-          {
-            path: '/COMMIT_MSG',
-            author: {
-              _account_id: 1000000,
-              name: 'user',
-              username: 'user',
-            },
-            patch_set: 4,
-            id: 'c2_1',
-            line: 5,
-            updated: '10',
-            message: 'test',
-            unresolved: true,
-          },
-        ],
-        patchNum: 4,
-        path: '/COMMIT_MSG',
-        line: 7,
-        rootId: 'rc2',
-        updated: '10',
-      },
-    ];
-
-    // use flush to render all (bypass initial-count set on dom-repeat)
-    await flush();
-  });
-
-  test('draft dropdown item only appears when logged in', () => {
-    element.loggedIn = false;
-    flush();
-    assert.equal(element.getCommentsDropdownEntires(element.threads,
-        element.loggedIn).length, 2);
-    element.loggedIn = true;
-    flush();
-    assert.equal(element.getCommentsDropdownEntires(element.threads,
-        element.loggedIn).length, 3);
-  });
-
-  test('show all threads by default', () => {
-    assert.equal(dom(element.root)
-        .querySelectorAll('gr-comment-thread').length, element.threads.length);
-    assert.equal(getVisibleThreads().length, element.threads.length);
-  });
-
-  test('show unresolved threads if unresolvedOnly is set', async () => {
-    element.unresolvedOnly = true;
-    await flush();
-    const unresolvedThreads = element.threads.filter(t => t.comments.some(
-        c => c.unresolved
-    ));
-    assert.equal(getVisibleThreads().length, unresolvedThreads.length);
-  });
-
-  test('showing file name takes visible threads into account', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
-    true);
-    element.unresolvedOnly = true;
-    assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
-        element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
-    false);
-  });
-
-  test('onlyShowRobotCommentsWithHumanReply ', () => {
-    element.onlyShowRobotCommentsWithHumanReply = true;
-    flush();
-    assert.equal(
-        getVisibleThreads().length,
-        element.threads.length - 1);
-    assert.isNotOk(getVisibleThreads().find(th => th.rootId === 'rc1'));
-  });
-
-  suite('_compareThreads', () => {
-    setup(() => {
-      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    });
-
-    test('patchset comes before any other file', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
-      const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
-
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      // assigning values to properties such that t2 should come first
-      t1.patchNum = 1;
-      t2.patchNum = 2;
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('file path is compared lexicographically', () => {
-      const t1 = {thread: {path: 'a.txt'}};
-      const t2 = {thread: {path: 'b.txt'}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      t1.patchNum = 1;
-      t2.patchNum = 2;
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('patchset comments sorted by reverse patchset', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}};
-      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 2}};
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('patchset comments with same patchset picks unresolved first', () => {
-      const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}, unresolved: true};
-      const t2 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        patchNum: 1}, unresolved: false};
-      t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('file level comment before line', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2}};
-      const t2 = {thread: {path: 'a.txt'}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      // give preference to t1 in unresolved/draft properties
-      t1.unresolved = t1.hasDraft = true;
-      t2.unresolved = t2.unresolved = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('comments sorted by line', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2}};
-      const t2 = {thread: {path: 'a.txt', line: 3}};
-      t1.patchNum = t2.patchNum = 1;
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-
-      t1.unresolved = t1.hasDraft = false;
-      t2.unresolved = t2.unresolved = true;
-      assert.equal(element._compareThreads(t1, t2), -1);
-      assert.equal(element._compareThreads(t2, t1), 1);
-    });
-
-    test('comments on same line sorted by reverse patchset', () => {
-      const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1}};
-      const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 2}};
-      t1.unresolved = t2.unresolved = t1.hasDraft = t2.hasDraft = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-
-      // give preference to t1 in unresolved/draft properties
-      t1.unresolved = t1.hasDraft = true;
-      t2.unresolved = t2.unresolved = false;
-      assert.equal(element._compareThreads(t1, t2), 1);
-      assert.equal(element._compareThreads(t2, t1), -1);
-    });
-
-    test('comments on same line & patchset sorted by unresolved first',
-        () => {
-          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true};
-          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: false};
-          t1.patchNum = t2.patchNum = 1;
-          assert.equal(element._compareThreads(t1, t2), -1);
-          assert.equal(element._compareThreads(t2, t1), 1);
-
-          t2.hasDraft = true;
-          t1.hasDraft = false;
-          assert.equal(element._compareThreads(t1, t2), -1);
-          assert.equal(element._compareThreads(t2, t1), 1);
-        });
-
-    test('comments on same line & patchset & unresolved sorted by draft',
-        () => {
-          const t1 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true, hasDraft: false};
-          const t2 = {thread: {path: 'a.txt', line: 2, patchNum: 1},
-            unresolved: true, hasDraft: true};
-          t1.patchNum = t2.patchNum = 1;
-          assert.equal(element._compareThreads(t1, t2), 1);
-          assert.equal(element._compareThreads(t2, t1), -1);
-        });
-  });
-
-  test('_computeSortedThreads', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    assert.equal(element._sortedThreads.length, 9);
-    const expectedSortedRootIds = [
-      'patchset_level_2', // Posted on Patchset 3
-      'patchset_level_1', // Posted on Patchset 2
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('_computeSortedThreads with timestamp', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
-    element.resortThreads(element.threads);
-    assert.equal(element._sortedThreads.length, 9);
-    const expectedSortedRootIds = [
-      'rc2',
-      'rc1',
-      'patchset_level_2',
-      'patchset_level_1',
-      'zcf0b9fa_fe1a5f62',
-      'scaddf38_44770ec1',
-      '8caddf38_44770ec1',
-      '09a9fb0a_1484e6cf',
-      'ecf0b9fa_fe1a5f62',
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('tapping single author chips', () => {
-    element.account = createAccountDetailWithId(1);
-    flush();
-    const chips = Array.from(queryAll(element, 'gr-account-label'));
-    const authors = chips.map(
-        chip => accountOrGroupKey(chip.account))
-        .sort();
-    assert.deepEqual(authors, [1, 1000000, 1000001, 1000002, 1000003]);
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 9);
-
-    // accountId 1000001
-    const chip = chips.find(chip => chip.account._account_id === 1000001);
-
-    tap(chip);
-    flush();
-
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 1);
-    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
-        1000001);
-
-    tap(chip); // tapping again resets
-    flush();
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 9);
-  });
-
-  test('tapping multiple author chips', () => {
-    element.account = createAccountDetailWithId(1);
-    flush();
-    const chips = Array.from(queryAll(element, 'gr-account-label'));
-
-    tap(chips.find(chip => chip.account._account_id === 1000001));
-    tap(chips.find(chip => chip.account._account_id === 1000002));
-    flush();
-
-    assert.equal(element.threads.length, 9);
-    assert.equal(element._displayedThreads.length, 3);
-    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
-        1000002);
-    assert.equal(element._displayedThreads[1].comments[0].author._account_id,
-        1000002);
-    assert.equal(element._displayedThreads[2].comments[0].author._account_id,
-        1000001);
-  });
-
-  test('thread removal and sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const index = element.threads.findIndex(t => t.rootId === 'rc2');
-    element.threads.splice(index, 1);
-    element.threads = [...element.threads]; // trigger observers
-    flush();
-    assert.equal(element._sortedThreads.length, 8);
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('modification on thread shold not trigger sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const currentSortedThreads = [...element._sortedThreads];
-    for (const thread of currentSortedThreads) {
-      thread.comments = [...thread.comments];
-    }
-    const modifiedThreads = [...element.threads];
-    modifiedThreads[5] = {...modifiedThreads[5]};
-    modifiedThreads[5].comments = [...modifiedThreads[5].comments, {
-      ...modifiedThreads[5].comments[0],
-      unresolved: false,
-    }];
-    element.threads = modifiedThreads;
-    assert.notDeepEqual(currentSortedThreads, element._sortedThreads);
-
-    // exact same order as in _computeSortedThreads
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('reset sortedThreads when threads set to undefiend', () => {
-    element.threads = undefined;
-    assert.deepEqual(element._sortedThreads, []);
-  });
-
-  test('non-equal length of sortThreads and threads' +
-    ' should trigger sort again', () => {
-    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
-    const modifiedThreads = [...element.threads];
-    const currentSortedThreads = [...element._sortedThreads];
-    element._sortedThreads = [];
-    element.threads = modifiedThreads;
-    assert.deepEqual(currentSortedThreads, element._sortedThreads);
-
-    // exact same order as in _computeSortedThreads
-    const expectedSortedRootIds = [
-      'patchset_level_2',
-      'patchset_level_1',
-      '8caddf38_44770ec1', // File level on COMMIT_MSG
-      'scaddf38_44770ec1', // Line 4 on COMMIT_MSG
-      'ecf0b9fa_fe1a5f62', // Line 5 on COMMIT_MESSAGE but with drafts
-      'rc1', // Line 5 on COMMIT_MESSAGE without drafts
-      'zcf0b9fa_fe1a5f62', // Line 6 on COMMIT_MSG
-      'rc2', // Line 7 on COMMIT_MSG
-      '09a9fb0a_1484e6cf', // File level on test.txt
-    ];
-    element._sortedThreads.forEach((thread, index) => {
-      assert.equal(thread.rootId, expectedSortedRootIds[index]);
-    });
-  });
-
-  test('show all comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.SHOW_ALL}});
-    flush();
-    assert.equal(getVisibleThreads().length, 9);
-  });
-
-  test('unresolved shows all unresolved comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.UNRESOLVED}});
-    flush();
-    assert.equal(getVisibleThreads().length, 4);
-  });
-
-  test('toggle drafts only shows threads with draft comments', () => {
-    element.handleCommentsDropdownValueChange({detail: {
-      value: CommentTabState.DRAFTS}});
-    flush();
-    assert.equal(getVisibleThreads().length, 2);
-  });
-
-  suite('hideDropdown', () => {
-    setup(async () => {
-      element.hideDropdown = true;
-      await flush();
-    });
-
-    test('toggle buttons are hidden', () => {
-      assert.equal(element.shadowRoot.querySelector('.header').style.display,
-          'none');
-    });
-  });
-
-  suite('empty thread', () => {
-    setup(async () => {
-      element.threads = [];
-      await flush();
-    });
-
-    test('default empty message should show', () => {
-      assert.isTrue(
-          element.shadowRoot.querySelector('#threads').textContent.trim()
-              .includes('No comments'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
new file mode 100644
index 0000000..64f3e74
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -0,0 +1,675 @@
+/**
+ * @license
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-thread-list';
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
+import {CommentTabState} from '../../../types/events';
+import {
+  compareThreads,
+  GrThreadList,
+  __testOnly_SortDropdownState,
+} from './gr-thread-list';
+import {queryAll, stubFlags} from '../../../test/test-utils';
+import {getUserId} from '../../../utils/account-util';
+import {
+  createAccountDetailWithId,
+  createComment,
+  createCommentThread,
+  createDraft,
+  createParsedChange,
+  createThread,
+} from '../../../test/test-data-generators';
+import {
+  AccountId,
+  EmailAddress,
+  NumericChangeId,
+  Timestamp,
+} from '../../../api/rest-api';
+import {
+  RobotId,
+  UrlEncodedCommentId,
+  RevisionPatchSetNum,
+} from '../../../types/common';
+import {CommentThread, isDraft} from '../../../utils/comment-util';
+import {query, queryAndAssert} from '../../../utils/common-util';
+import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
+import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+
+suite('gr-thread-list tests', () => {
+  let element: GrThreadList;
+
+  setup(async () => {
+    element = await fixture(html`<gr-thread-list></gr-thread-list>`);
+    element.changeNum = 123 as NumericChangeId;
+    element.change = createParsedChange();
+    element.account = createAccountDetailWithId();
+    element.threads = [
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000001 as AccountId,
+              name: 'user',
+              username: 'user',
+              email: 'abcd' as EmailAddress,
+            },
+            patch_set: 4 as RevisionPatchSetNum,
+            id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-01 15:15:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+          },
+          {
+            id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+            path: '/COMMIT_MSG',
+            line: 5,
+            in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            updated: '2015-12-01 15:16:15.000000000' as Timestamp,
+            message: 'draft',
+            unresolved: true,
+            __draft: true,
+            patch_set: '2' as RevisionPatchSetNum,
+          },
+        ],
+        patchNum: 4 as RevisionPatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: 'test.txt',
+            author: {
+              _account_id: 1000002 as AccountId,
+              name: 'user',
+              username: 'user',
+              email: 'abcd' as EmailAddress,
+            },
+            patch_set: 3 as RevisionPatchSetNum,
+            id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+            updated: '2015-12-02 15:16:15.000000000' as Timestamp,
+            message: 'Some comment on another patchset.',
+            unresolved: false,
+          },
+        ],
+        patchNum: 3 as RevisionPatchSetNum,
+        path: 'test.txt',
+        rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000002 as AccountId,
+              name: 'user',
+              username: 'user',
+              email: 'abcd' as EmailAddress,
+            },
+            patch_set: 2 as RevisionPatchSetNum,
+            id: '8caddf38_44770ec1' as UrlEncodedCommentId,
+            updated: '2015-12-03 15:16:15.000000000' as Timestamp,
+            message: 'Another unresolved comment',
+            unresolved: false,
+          },
+        ],
+        patchNum: 2 as RevisionPatchSetNum,
+        path: '/COMMIT_MSG',
+        rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000003 as AccountId,
+              name: 'user',
+              username: 'user',
+              email: 'abcd' as EmailAddress,
+            },
+            patch_set: 2 as RevisionPatchSetNum,
+            id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+            line: 4,
+            updated: '2015-12-04 15:16:15.000000000' as Timestamp,
+            message: 'Yet another unresolved comment',
+            unresolved: true,
+          },
+        ],
+        patchNum: 2 as RevisionPatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 4,
+        rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+            path: '/COMMIT_MSG',
+            line: 6,
+            updated: '2015-12-05 15:16:15.000000000' as Timestamp,
+            message: 'resolved draft',
+            unresolved: false,
+            __draft: true,
+            patch_set: '2' as RevisionPatchSetNum,
+          },
+        ],
+        patchNum: 4 as RevisionPatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 6,
+        rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_1' as UrlEncodedCommentId,
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2015-12-06 15:16:15.000000000' as Timestamp,
+            message: 'patchset comment 1',
+            unresolved: false,
+            patch_set: '2' as RevisionPatchSetNum,
+          },
+        ],
+        patchNum: 2 as RevisionPatchSetNum,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            id: 'patchset_level_2' as UrlEncodedCommentId,
+            path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+            updated: '2015-12-07 15:16:15.000000000' as Timestamp,
+            message: 'patchset comment 2',
+            unresolved: false,
+            patch_set: '3' as RevisionPatchSetNum,
+          },
+        ],
+        patchNum: 3 as RevisionPatchSetNum,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        rootId: 'patchset_level_2' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+              email: 'abcd' as EmailAddress,
+            },
+            patch_set: 4 as RevisionPatchSetNum,
+            id: 'rc1' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-08 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc1' as RobotId,
+          },
+        ],
+        patchNum: 4 as RevisionPatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 5,
+        rootId: 'rc1' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+      {
+        comments: [
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+              email: 'abcd' as EmailAddress,
+            },
+            patch_set: 4 as RevisionPatchSetNum,
+            id: 'rc2' as UrlEncodedCommentId,
+            line: 7,
+            updated: '2015-12-09 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+            robot_id: 'rc2' as RobotId,
+          },
+          {
+            path: '/COMMIT_MSG',
+            author: {
+              _account_id: 1000000 as AccountId,
+              name: 'user',
+              username: 'user',
+              email: 'abcd' as EmailAddress,
+            },
+            patch_set: 4 as RevisionPatchSetNum,
+            id: 'c2_1' as UrlEncodedCommentId,
+            line: 5,
+            updated: '2015-12-10 15:16:15.000000000' as Timestamp,
+            message: 'test',
+            unresolved: true,
+          },
+        ],
+        patchNum: 4 as RevisionPatchSetNum,
+        path: '/COMMIT_MSG',
+        line: 7,
+        rootId: 'rc2' as UrlEncodedCommentId,
+        commentSide: CommentSide.REVISION,
+      },
+    ];
+    await element.updateComplete;
+  });
+
+  suite('sort threads', () => {
+    test('sort all threads', () => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+      assert.equal(element.getDisplayedThreads().length, 9);
+      const expected: UrlEncodedCommentId[] = [
+        'patchset_level_2' as UrlEncodedCommentId, // Posted on Patchset 3
+        'patchset_level_1' as UrlEncodedCommentId, // Posted on Patchset 2
+        '8caddf38_44770ec1' as UrlEncodedCommentId, // File level on COMMIT_MSG
+        'scaddf38_44770ec1' as UrlEncodedCommentId, // Line 4 on COMMIT_MSG
+        'rc1' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE newer
+        'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 5 on COMMIT_MESSAGE older
+        'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId, // Line 6 on COMMIT_MSG
+        'rc2' as UrlEncodedCommentId, // Line 7 on COMMIT_MSG
+        '09a9fb0a_1484e6cf' as UrlEncodedCommentId, // File level on test.txt
+      ];
+      const actual = element.getDisplayedThreads().map(t => t.rootId);
+      assert.sameOrderedMembers(actual, expected);
+    });
+
+    test('respects special cases for ordering', async () => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+      element.threads = [
+        {
+          ...createThread(createComment({path: '/app/test.cc'})),
+          path: '/app/test.cc',
+        },
+        {
+          ...createThread(createComment({path: '/app/test.h'})),
+          path: '/app/test.h',
+        },
+        {
+          ...createThread(
+            createComment({path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS})
+          ),
+          path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        },
+      ];
+      await element.updateComplete;
+
+      const paths = Array.from(
+        queryAll<GrCommentThread>(element, 'gr-comment-thread')
+      ).map(threadElement => threadElement.thread?.path);
+
+      // Patchset comment is always first, then we have a special case where .h
+      // files should appear above other files of the same name regardless of
+      // their alphabetical ordering.
+      assert.sameOrderedMembers(paths, [
+        SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        '/app/test.h',
+        '/app/test.cc',
+      ]);
+    });
+
+    test('sort all threads by timestamp', () => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
+      assert.equal(element.getDisplayedThreads().length, 9);
+      const expected: UrlEncodedCommentId[] = [
+        'rc2' as UrlEncodedCommentId,
+        'rc1' as UrlEncodedCommentId,
+        'patchset_level_2' as UrlEncodedCommentId,
+        'patchset_level_1' as UrlEncodedCommentId,
+        'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+        'scaddf38_44770ec1' as UrlEncodedCommentId,
+        '8caddf38_44770ec1' as UrlEncodedCommentId,
+        '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+        'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+      ];
+      const actual = element.getDisplayedThreads().map(t => t.rootId);
+      assert.sameOrderedMembers(actual, expected);
+    });
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="header">
+          <span class="sort-text">Sort By:</span>
+          <gr-dropdown-list id="sortDropdown"></gr-dropdown-list>
+          <span class="separator"></span>
+          <span class="filter-text">Filter By:</span>
+          <gr-dropdown-list id="filterDropdown"></gr-dropdown-list>
+          <span class="author-text">From:</span>
+          <gr-account-label
+            deselected=""
+            selectionchipstyle=""
+            nostatusicons=""
+          ></gr-account-label>
+          <gr-account-label
+            deselected=""
+            selectionchipstyle=""
+            nostatusicons=""
+          ></gr-account-label>
+          <gr-account-label
+            deselected=""
+            selectionchipstyle=""
+            nostatusicons=""
+          ></gr-account-label>
+          <gr-account-label
+            deselected=""
+            selectionchipstyle=""
+            nostatusicons=""
+          ></gr-account-label>
+          <gr-account-label
+            deselected=""
+            selectionchipstyle=""
+            nostatusicons=""
+          ></gr-account-label>
+        </div>
+        <div id="threads" part="threads">
+          <gr-comment-thread
+            show-file-name=""
+            show-file-path=""
+          ></gr-comment-thread>
+          <gr-comment-thread show-file-path=""></gr-comment-thread>
+          <div class="thread-separator"></div>
+          <gr-comment-thread
+            show-file-name=""
+            show-file-path=""
+          ></gr-comment-thread>
+          <gr-comment-thread show-file-path=""></gr-comment-thread>
+          <div class="thread-separator"></div>
+          <gr-comment-thread
+            has-draft=""
+            show-file-name=""
+            show-file-path=""
+          ></gr-comment-thread>
+          <gr-comment-thread show-file-path=""></gr-comment-thread>
+          <gr-comment-thread show-file-path=""></gr-comment-thread>
+          <div class="thread-separator"></div>
+          <gr-comment-thread
+            show-file-name=""
+            show-file-path=""
+          ></gr-comment-thread>
+          <div class="thread-separator"></div>
+          <gr-comment-thread
+            has-draft=""
+            show-file-name=""
+            show-file-path=""
+          ></gr-comment-thread>
+        </div>
+      `
+    );
+  });
+
+  test('renders empty', async () => {
+    element.threads = [];
+    await element.updateComplete;
+    assert.dom.equal(
+      queryAndAssert(element, 'div#threads'),
+      /* HTML */ `
+        <div id="threads" part="threads">
+          <div><span>No comments</span></div>
+        </div>
+      `
+    );
+  });
+
+  test('tapping single author chips', async () => {
+    element.account = createAccountDetailWithId(1);
+    await element.updateComplete;
+    const chips = Array.from(
+      queryAll<GrAccountLabel>(element, 'gr-account-label')
+    );
+    const authors = chips.map(chip => getUserId(chip.account!)).sort();
+    assert.deepEqual(authors, [
+      1 as AccountId,
+      1000000 as AccountId,
+      1000001 as AccountId,
+      1000002 as AccountId,
+      1000003 as AccountId,
+    ]);
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 9);
+
+    const chip = chips.find(chip => chip.account!._account_id === 1000001);
+    chip!.click();
+    await element.updateComplete;
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 1);
+    assert.equal(
+      element.getDisplayedThreads()[0].comments[0].author?._account_id,
+      1000001 as AccountId
+    );
+
+    chip!.click();
+    await element.updateComplete;
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 9);
+  });
+
+  test('tapping single author with only drafts', async () => {
+    element.account = createAccountDetailWithId(1);
+    element.threads = [createThread(createDraft())];
+    await element.updateComplete;
+    const chips = Array.from(
+      queryAll<GrAccountLabel>(element, 'gr-account-label')
+    );
+    const authors = chips.map(chip => getUserId(chip.account!)).sort();
+    assert.deepEqual(authors, [1 as AccountId]);
+    assert.equal(element.threads.length, 1);
+    assert.equal(element.getDisplayedThreads().length, 1);
+
+    const chip = chips.find(chip => chip.account!._account_id === 1);
+    chip!.click();
+    await element.updateComplete;
+
+    assert.equal(element.threads.length, 1);
+    assert.equal(element.getDisplayedThreads().length, 1);
+    assert.isTrue(isDraft(element.getDisplayedThreads()[0].comments[0]));
+
+    chip!.click();
+    await element.updateComplete;
+    assert.equal(element.threads.length, 1);
+    assert.equal(element.getDisplayedThreads().length, 1);
+  });
+
+  test('tapping multiple author chips', async () => {
+    element.account = createAccountDetailWithId(1);
+    await element.updateComplete;
+    const chips = Array.from(
+      queryAll<GrAccountLabel>(element, 'gr-account-label')
+    );
+
+    chips.find(chip => chip.account?._account_id === 1000001)!.click();
+    chips.find(chip => chip.account?._account_id === 1000002)!.click();
+    await element.updateComplete;
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element.getDisplayedThreads().length, 3);
+    assert.equal(
+      element.getDisplayedThreads()[0].comments[0].author?._account_id,
+      1000002 as AccountId
+    );
+    assert.equal(
+      element.getDisplayedThreads()[1].comments[0].author?._account_id,
+      1000002 as AccountId
+    );
+    assert.equal(
+      element.getDisplayedThreads()[2].comments[0].author?._account_id,
+      1000001 as AccountId
+    );
+  });
+
+  test('show all comments', async () => {
+    const filterDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      '#filterDropdown'
+    );
+    filterDropdown.value = CommentTabState.SHOW_ALL;
+    await filterDropdown.updateComplete;
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 9);
+  });
+
+  test('unresolved shows all unresolved comments', async () => {
+    const filterDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      '#filterDropdown'
+    );
+    filterDropdown.value = CommentTabState.UNRESOLVED;
+    await filterDropdown.updateComplete;
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 4);
+  });
+
+  test('toggle drafts only shows threads with draft comments', async () => {
+    const filterDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      '#filterDropdown'
+    );
+    filterDropdown.value = CommentTabState.DRAFTS;
+    await filterDropdown.updateComplete;
+    await element.updateComplete;
+    assert.equal(element.getDisplayedThreads().length, 2);
+  });
+
+  suite('mention threads', () => {
+    let mentionedThreads: CommentThread[];
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      mentionedThreads = [
+        createCommentThread([
+          {
+            ...createComment(),
+            message: 'random text with no emails',
+          },
+        ]),
+        // Resolved thread does not contribute to the count
+        createCommentThread([
+          {
+            ...createComment(),
+            message: '@abcd@def.com please take a look',
+          },
+          {
+            ...createComment(),
+            message: '@abcd@def.com please take a look again at this',
+          },
+        ]),
+        createCommentThread([
+          {
+            ...createComment(),
+            message: '@abcd@def.com this is important',
+            unresolved: true,
+          },
+        ]),
+      ];
+      element.account!.email = 'abcd@def.com' as EmailAddress;
+      element.threads.push(...mentionedThreads);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('mentions filter', async () => {
+      const filterDropdown = queryAndAssert<GrDropdownList>(
+        element,
+        '#filterDropdown'
+      );
+      filterDropdown.value = CommentTabState.MENTIONS;
+      await filterDropdown.updateComplete;
+      await element.updateComplete;
+      assert.deepEqual(element.getDisplayedThreads(), [mentionedThreads[2]]);
+    });
+  });
+
+  suite('hideDropdown', () => {
+    test('header hidden for hideDropdown=true', async () => {
+      element.hideDropdown = true;
+      await element.updateComplete;
+      assert.isUndefined(query(element, '.header'));
+    });
+
+    test('header shown for hideDropdown=false', async () => {
+      element.hideDropdown = false;
+      await element.updateComplete;
+      assert.isDefined(query(element, '.header'));
+    });
+  });
+
+  suite('empty thread', () => {
+    setup(async () => {
+      element.threads = [];
+      await element.updateComplete;
+    });
+
+    test('default empty message should show', () => {
+      const threadsEl = queryAndAssert(element, '#threads');
+      assert.isTrue(threadsEl.textContent?.trim().includes('No comments'));
+    });
+  });
+});
+
+suite('compareThreads', () => {
+  let t1: CommentThread;
+  let t2: CommentThread;
+
+  const sortPredicate = (thread1: CommentThread, thread2: CommentThread) =>
+    compareThreads(thread1, thread2);
+
+  const checkOrder = (expected: CommentThread[]) => {
+    assert.sameOrderedMembers([t1, t2].sort(sortPredicate), expected);
+    assert.sameOrderedMembers([t2, t1].sort(sortPredicate), expected);
+  };
+
+  setup(() => {
+    t1 = createThread({});
+    t2 = createThread({});
+  });
+
+  test('patchset-level before file comments', () => {
+    t1.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+    t2.path = SpecialFilePath.COMMIT_MESSAGE;
+    checkOrder([t1, t2]);
+  });
+
+  test('paths lexicographically', () => {
+    t1.path = 'a.txt';
+    t2.path = 'b.txt';
+    checkOrder([t1, t2]);
+  });
+
+  test('patchsets in reverse order', () => {
+    t1.patchNum = 2 as RevisionPatchSetNum;
+    t2.patchNum = 3 as RevisionPatchSetNum;
+    checkOrder([t2, t1]);
+  });
+
+  test('file level comment before line', () => {
+    t1.line = 123;
+    t2.line = 'FILE';
+    checkOrder([t2, t1]);
+  });
+
+  test('comments sorted by line', () => {
+    t1.line = 123;
+    t2.line = 321;
+    checkOrder([t1, t2]);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
index 552cc69..db49e8d 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
@@ -1,23 +1,14 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {customElement, property} from 'lit/decorators';
+import '../../shared/gr-icon/gr-icon';
+import {customElement, property} from 'lit/decorators.js';
 import {css, html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {LabelInfo} from '../../../api/rest-api';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -27,6 +18,9 @@
   @property()
   labelName?: string;
 
+  @property({type: Object})
+  labelInfo?: LabelInfo;
+
   static override get styles() {
     return [
       fontStyles,
@@ -52,10 +46,9 @@
         div.sectionIcon {
           flex: 0 0 30px;
         }
-        div.sectionIcon iron-icon {
+        div.sectionIcon gr-icon {
           position: relative;
-          width: 20px;
-          height: 20px;
+          font-size: 20px;
         }
       `,
     ];
@@ -72,7 +65,7 @@
       </div>
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:info-outline"></iron-icon>
+          <gr-icon icon="info" class="small"></gr-icon></span>
         </div>
         <div class="sectionContent">
           <div class="row">
@@ -83,6 +76,23 @@
           </div>
         </div>
       </div>
+      ${this.renderDescription()}
+    </div>`;
+  }
+
+  private renderDescription() {
+    const description = this.labelInfo?.description;
+    if (!description) return;
+    return html`<div class="section description">
+      <div class="sectionIcon">
+        <gr-icon icon="description"></gr-icon>
+      </div>
+      <div class="sectionContent">
+        <gr-formatted-text
+          .markdown=${true}
+          .content=${description}
+        ></gr-formatted-text>
+      </div>
     </div>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard_test.ts
new file mode 100644
index 0000000..305ddd3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard_test.ts
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-trigger-vote-hovercard';
+import {GrTriggerVoteHovercard} from './gr-trigger-vote-hovercard';
+import {createLabelInfo} from '../../../test/test-data-generators';
+
+suite('gr-trigger-vote-hovercard tests', () => {
+  let element: GrTriggerVoteHovercard;
+  setup(async () => {
+    element = await fixture<GrTriggerVoteHovercard>(
+      html`<gr-trigger-vote-hovercard
+        .labelInfo=${createLabelInfo()}
+        .labelName=${'Foo'}
+      ></gr-trigger-vote-hovercard>`
+    );
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <div class="section">
+            <div class="sectionContent">
+              <h3 class="heading-3 name">
+                <span> Foo </span>
+              </h3>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon icon="info" class=" small"></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Status</div>
+                <div>
+                  <slot name="label-info"> </slot>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
new file mode 100644
index 0000000..0e4410c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
@@ -0,0 +1,141 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../shared/gr-label-info/gr-label-info';
+import '../../shared/gr-vote-chip/gr-vote-chip';
+import '../gr-trigger-vote-hovercard/gr-trigger-vote-hovercard';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {ParsedChangeInfo} from '../../../types/types';
+import {
+  AccountInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+  LabelInfo,
+} from '../../../api/rest-api';
+import {
+  getAllUniqueApprovals,
+  hasNeutralStatus,
+} from '../../../utils/label-util';
+
+@customElement('gr-trigger-vote')
+export class GrTriggerVote extends LitElement {
+  @property()
+  label?: string;
+
+  @property({type: Object})
+  labelInfo?: LabelInfo;
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  /**
+   * If defined, trigger-vote is shown with this value instead of the latest
+   * vote. This is useful for change log.
+   */
+  @property()
+  displayValue?: string;
+
+  @property({type: Boolean})
+  mutable?: boolean;
+
+  @property({type: Boolean, attribute: 'disable-hovercards'})
+  disableHovercards = false;
+
+  static override get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      .container {
+        box-sizing: border-box;
+        border: 1px solid var(--border-color);
+        border-radius: calc(var(--border-radius) + 2px);
+        background-color: var(--background-color-primary);
+        display: flex;
+        padding: 0;
+        padding-left: var(--spacing-s);
+        padding-right: var(--spacing-xxs);
+        align-items: center;
+      }
+      .label {
+        padding-right: var(--spacing-s);
+        font-weight: var(--font-weight-bold);
+      }
+      gr-vote-chip {
+        --gr-vote-chip-width: 14px;
+        --gr-vote-chip-height: 14px;
+        margin-right: 0px;
+        margin-left: var(--spacing-xs);
+      }
+      gr-vote-chip:first-of-type {
+        margin-left: 0px;
+      }
+    `;
+  }
+
+  override render() {
+    if (!this.labelInfo) return;
+    return html`
+      <div class="container">
+        ${this.renderHovercard()}
+        <span class="label">${this.label}</span>
+        ${this.renderVotes()}
+      </div>
+    `;
+  }
+
+  private renderHovercard() {
+    if (this.disableHovercards) return;
+    return html`<gr-trigger-vote-hovercard
+      .labelName=${this.label}
+      .labelInfo=${this.labelInfo}
+    >
+      <gr-label-info
+        slot="label-info"
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+        .label=${this.label}
+        .labelInfo=${this.labelInfo}
+        .showAllReviewers=${false}
+      ></gr-label-info>
+    </gr-trigger-vote-hovercard>`;
+  }
+
+  private renderVotes() {
+    const {labelInfo} = this;
+    if (!labelInfo) return;
+    if (this.displayValue)
+      return html`<gr-vote-chip
+        .displayValue=${this.displayValue}
+        .label=${labelInfo}
+      ></gr-vote-chip>`;
+    if (isDetailedLabelInfo(labelInfo)) {
+      const approvals = getAllUniqueApprovals(labelInfo).filter(
+        approval => !hasNeutralStatus(labelInfo, approval)
+      );
+      return approvals.map(
+        approvalInfo => html`<gr-vote-chip
+          .vote=${approvalInfo}
+          .label=${labelInfo}
+        ></gr-vote-chip>`
+      );
+    } else if (isQuickLabelInfo(labelInfo)) {
+      return [html`<gr-vote-chip .label=${this.labelInfo}></gr-vote-chip>`];
+    } else {
+      return html``;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-trigger-vote': GrTriggerVote;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
new file mode 100644
index 0000000..6247472
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-trigger-vote';
+import {GrTriggerVote} from './gr-trigger-vote';
+import {
+  createAccountWithIdNameAndEmail,
+  createApproval,
+  createDetailedLabelInfo,
+  createParsedChange,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
+} from '../../../test/test-data-generators';
+import {SubmitRequirementResultInfo} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+
+suite('gr-trigger-vote tests', () => {
+  let element: GrTriggerVote;
+  setup(async () => {
+    const submitRequirement: SubmitRequirementResultInfo = {
+      ...createSubmitRequirementResultInfo(),
+      description: 'Test Description',
+      submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    };
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      submit_requirements: [
+        submitRequirement,
+        createNonApplicableSubmitRequirementResultInfo(),
+      ],
+      labels: {
+        Verified: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createApproval(),
+              value: 2,
+            },
+          ],
+        },
+      },
+    };
+    const account = createAccountWithIdNameAndEmail();
+    const label = 'Verified';
+    const labelInfo = change?.labels?.[label];
+    element = await fixture<GrTriggerVote>(
+      html`<gr-trigger-vote
+        .label=${label}
+        .labelInfo=${labelInfo}
+        .change=${change}
+        .account=${account}
+        .mutable=${false}
+      ></gr-trigger-vote>`
+    );
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <div class="container">
+        <gr-trigger-vote-hovercard>
+          <gr-label-info slot="label-info"></gr-label-info>
+        </gr-trigger-vote-hovercard>
+        <span class="label"> Verified </span>
+        <gr-vote-chip> </gr-vote-chip>
+      </div>`
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index 859fd33..1c494fa 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -1,25 +1,14 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {Action} from '../../api/checks';
-import {checkRequiredProperty} from '../../utils/common-util';
-import {appContext} from '../../services/app-context';
-
+import {assertIsDefined} from '../../utils/common-util';
+import {resolve} from '../../models/dependency';
+import {checksModelToken} from '../../models/checks/checks-model';
 @customElement('gr-checks-action')
 export class GrChecksAction extends LitElement {
   @property({type: Object})
@@ -28,11 +17,15 @@
   @property({type: Object})
   eventTarget: HTMLElement | null = null;
 
-  private checksService = appContext.checksService;
+  /** In what context is <gr-checks-action> rendered? Just for reporting. */
+  @property({type: String})
+  context = 'unknown';
+
+  private getChecksModel = resolve(this, checksModelToken);
 
   override connectedCallback() {
     super.connectedCallback();
-    checkRequiredProperty(this.action, 'action');
+    assertIsDefined(this.action, 'action');
   }
 
   static override get styles() {
@@ -59,9 +52,9 @@
     return html`
       <gr-button
         link
-        ?disabled="${this.action.disabled}"
+        ?disabled=${this.action.disabled}
         class="action"
-        @click="${(e: Event) => this.handleClick(e)}"
+        @click=${(e: Event) => this.handleClick(e)}
       >
         ${this.action.name}
       </gr-button>
@@ -72,7 +65,7 @@
   private renderTooltip() {
     if (!this.action.tooltip) return;
     return html`
-      <paper-tooltip offset="5" fit-to-visible-bounds>
+      <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
         ${this.action.tooltip}
       </paper-tooltip>
     `;
@@ -80,7 +73,7 @@
 
   handleClick(e: Event) {
     e.stopPropagation();
-    this.checksService.triggerAction(this.action);
+    this.getChecksModel().triggerAction(this.action, undefined, this.context);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
index 69152b2..8c0143c 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {CheckRun} from '../../services/checks/checks-model';
+import {customElement, property} from 'lit/decorators.js';
+import {CheckRun} from '../../models/checks/checks-model';
 import {ordinal} from '../../utils/string-util';
 
 @customElement('gr-checks-attempt')
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 9c27cdb..5792230 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -1,28 +1,24 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {classMap} from 'lit/directives/class-map';
-import {repeat} from 'lit/directives/repeat';
-import {ifDefined} from 'lit/directives/if-defined';
-import {LitElement, css, html, PropertyValues, TemplateResult} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import '../shared/gr-icon/gr-icon';
+import {classMap} from 'lit/directives/class-map.js';
+import {repeat} from 'lit/directives/repeat.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {
+  LitElement,
+  css,
+  html,
+  PropertyValues,
+  TemplateResult,
+  nothing,
+} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import './gr-checks-action';
 import './gr-hovercard-run';
 import '@polymer/paper-tooltip/paper-tooltip';
-import '@polymer/iron-icon/iron-icon';
 import {
   Action,
   Category,
@@ -32,16 +28,17 @@
   Tag,
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
+import {CheckRun, RunResult} from '../../models/checks/checks-model';
 import {
-  CheckRun,
-  checksSelectedPatchsetNumber$,
-  RunResult,
-  someProvidersAreLoadingSelected$,
-  topLevelActionsSelected$,
-  topLevelLinksSelected$,
-} from '../../services/checks/checks-model';
-import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  attemptChoiceLabel,
+  isAttemptChoice,
+  LATEST_ATTEMPT,
+  sortAttemptChoices,
+  stringToAttemptChoice,
   allResults,
+  createFixAction,
   firstPrimaryLink,
   hasCompletedWithoutResults,
   iconFor,
@@ -50,34 +47,52 @@
   otherPrimaryLinks,
   secondaryLinks,
   tooltipForLink,
-} from '../../services/checks/checks-util';
-import {assertIsDefined, check} from '../../utils/common-util';
+} from '../../models/checks/checks-util';
+import {assertIsDefined, assert, unique} from '../../utils/common-util';
 import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
 import {charsOnly} from '../../utils/string-util';
 import {isAttemptSelected, matches} from './gr-checks-util';
-import {ChecksTabState} from '../../types/events';
-import {
-  ConfigInfo,
-  LabelNameToInfoMap,
-  PatchSetNumber,
-} from '../../types/common';
-import {labels$, latestPatchNum$} from '../../services/change/change-model';
-import {appContext} from '../../services/app-context';
-import {repoConfig$} from '../../services/config/config-model';
+import {ChecksTabState, ValueChangedEvent} from '../../types/events';
+import {LabelNameToInfoMap, PatchSetNumber} from '../../types/common';
 import {spinnerStyles} from '../../styles/gr-spinner-styles';
 import {
   getLabelStatus,
   getRepresentativeValue,
   valueString,
 } from '../../utils/label-util';
-import {GerritNav} from '../core/gr-navigation/gr-navigation';
 import {DropdownLink} from '../shared/gr-dropdown/gr-dropdown';
 import {subscribe} from '../lit/subscription-controller';
 import {fontStyles} from '../../styles/gr-font-styles';
+import {fire} from '../../utils/event-util';
+import {resolve} from '../../models/dependency';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {Interaction} from '../../constants/reporting';
+import {Deduping} from '../../api/reporting';
+import {changeModelToken} from '../../models/change/change-model';
+import {getAppContext} from '../../services/app-context';
+import {when} from 'lit/directives/when.js';
+import {HtmlPatched} from '../../utils/lit-util';
+import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
+import './gr-checks-attempt';
+import {createDiffUrl, changeViewModelToken} from '../../models/views/change';
+
+/**
+ * Firing this event sets the regular expression of the results filter.
+ */
+export interface ChecksResultsFilterDetail {
+  filterRegExp?: string;
+}
+export type ChecksResultsFilterEvent = CustomEvent<ChecksResultsFilterDetail>;
+
+declare global {
+  interface HTMLElementEventMap {
+    'checks-results-filter': ChecksResultsFilterEvent;
+  }
+}
 
 @customElement('gr-result-row')
-class GrResultRow extends LitElement {
+export class GrResultRow extends LitElement {
   @query('td.nameCol div.name')
   nameEl?: HTMLElement;
 
@@ -96,11 +111,35 @@
   @state()
   labels?: LabelNameToInfoMap;
 
-  private checksService = appContext.checksService;
+  @state()
+  latestPatchNum?: PatchSetNumber;
+
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
+
+  private getChangeModel = resolve(this, changeModelToken);
+
+  private getChecksModel = resolve(this, checksModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
 
   constructor() {
     super();
-    subscribe(this, labels$, x => (this.labels = x));
+    subscribe(
+      this,
+      () => this.getChangeModel().labels$,
+      x => (this.labels = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
   }
 
   static override get styles() {
@@ -122,7 +161,7 @@
         a.link {
           margin-right: var(--spacing-s);
         }
-        iron-icon.link {
+        gr-icon.link {
           color: var(--link-color);
         }
         td.nameCol div.flex {
@@ -215,6 +254,7 @@
           background-color: var(--tag-background);
           padding: 0 var(--spacing-m);
           margin-left: var(--spacing-s);
+          cursor: pointer;
         }
         td .summary-cell .tag.gray {
           background-color: var(--tag-gray);
@@ -240,7 +280,7 @@
           margin: -4px 0;
           vertical-align: top;
         }
-        #moreActions iron-icon {
+        #moreActions gr-icon {
           color: var(--link-color);
         }
         #moreMessage {
@@ -282,10 +322,18 @@
 
   override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('result')) {
-      this.isExpandable = !!this.result?.summary && !!this.result?.message;
+      this.isExpandable = this.computeIsExpandable();
     }
   }
 
+  private computeIsExpandable() {
+    const hasSummary = !!this.result?.summary;
+    const hasMessage = !!this.result?.message;
+    const hasLinks = (this.result?.links ?? []).length > 0;
+    const hasPointers = (this.result?.codePointers ?? []).length > 0;
+    return hasSummary && (hasMessage || hasLinks || hasPointers);
+  }
+
   override focus() {
     if (this.nameEl) this.nameEl.focus();
   }
@@ -316,19 +364,20 @@
       `;
     }
     return html`
-      <tr class="${classMap({container: true, collapsed: !this.isExpanded})}">
-        <td class="nameCol" @click="${this.toggleExpandedClick}">
+      <tr class=${classMap({container: true, collapsed: !this.isExpanded})}>
+        <td class="nameCol" @click=${this.toggleExpandedClick}>
           <div class="flex">
-            <gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
+            <gr-hovercard-run .run=${this.result}></gr-hovercard-run>
             <div
               class="name"
               role="button"
               tabindex="0"
-              @click="${this.toggleExpandedClick}"
-              @keydown="${this.toggleExpandedPress}"
+              @click=${this.toggleExpandedClick}
+              @keydown=${this.toggleExpandedPress}
             >
               ${this.result.checkName}
             </div>
+            ${this.renderAttempt()}
             <div class="space"></div>
           </div>
         </td>
@@ -336,7 +385,7 @@
           <div class="summary-cell">
             ${this.renderLink(firstPrimaryLink(this.result))}
             ${this.renderSummary(this.result.summary)}
-            <div class="message" @click="${this.toggleExpandedClick}">
+            <div class="message" @click=${this.toggleExpandedClick}>
               ${this.isExpanded ? '' : this.result.message}
             </div>
             ${this.renderLinks()} ${this.renderActions()}
@@ -346,36 +395,39 @@
             ${this.renderLabel()}
           </div>
         </td>
-        <td class="expanderCol" @click="${this.toggleExpandedClick}">
+        <td class="expanderCol" @click=${this.toggleExpandedClick}>
           <div
             class="show-hide"
             role="switch"
             tabindex="0"
-            ?hidden="${!this.isExpandable}"
-            aria-checked="${this.isExpanded ? 'true' : 'false'}"
-            aria-label="${this.isExpanded
+            ?hidden=${!this.isExpandable}
+            aria-checked=${this.isExpanded ? 'true' : 'false'}
+            aria-label=${this.isExpanded
               ? 'Collapse result row'
-              : 'Expand result row'}"
-            @keydown="${this.toggleExpandedPress}"
+              : 'Expand result row'}
+            @keydown=${this.toggleExpandedPress}
           >
-            <iron-icon
-              icon="${this.isExpanded
-                ? 'gr-icons:expand-less'
-                : 'gr-icons:expand-more'}"
-            ></iron-icon>
+            <gr-icon
+              icon=${this.isExpanded ? 'expand_less' : 'expand_more'}
+            ></gr-icon>
           </div>
         </td>
       </tr>
-      <tr class="${classMap({detailsRow: true, collapsed: !this.isExpanded})}">
+      <tr class=${classMap({detailsRow: true, collapsed: !this.isExpanded})}>
         <td class="expandedCol" colspan="3">${this.renderExpanded()}</td>
       </tr>
     `;
   }
 
+  private renderAttempt() {
+    if (this.selectedAttempt !== ALL_ATTEMPTS) return nothing;
+    return html`<gr-checks-attempt .run=${this.result}></gr-checks-attempt>`;
+  }
+
   private renderExpanded() {
     if (!this.isExpanded) return;
     return html`<gr-result-expanded
-      .result="${this.result}"
+      .result=${this.result}
     ></gr-result-expanded>`;
   }
 
@@ -386,11 +438,20 @@
     this.toggleExpanded();
   }
 
+  private tagClick(e: MouseEvent, tagName: string) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.reporting.reportInteraction(Interaction.CHECKS_TAG_CLICKED, {
+      tagName,
+      checkName: this.result?.checkName,
+    });
+    fire(this, 'checks-results-filter', {filterRegExp: tagName});
+  }
+
   private toggleExpandedPress(e: KeyboardEvent) {
     if (!this.isExpandable) return;
     if (modifierPressed(e)) return;
-    // Only react to `return` and `space`.
-    if (e.keyCode !== 13 && e.keyCode !== 32) return;
+    if (e.key !== 'Enter' && e.key !== ' ') return;
     e.preventDefault();
     e.stopPropagation();
     this.toggleExpanded();
@@ -399,6 +460,10 @@
   private toggleExpanded() {
     if (!this.isExpandable) return;
     this.isExpanded = !this.isExpanded;
+    this.reporting.reportInteraction(Interaction.CHECKS_RESULT_ROW_TOGGLE, {
+      expanded: this.isExpanded,
+      checkName: this.result?.checkName,
+    });
   }
 
   renderSummary(text?: string) {
@@ -406,7 +471,9 @@
     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>
+      <div class="summary" @click=${this.toggleExpanded} title=${text}>
+        ${text}&nbsp;
+      </div>
     `;
   }
 
@@ -416,6 +483,12 @@
     const label = this.result?.labelName;
     if (!label) return;
     if (!this.result?.isLatestAttempt) return;
+    // For check results on older patchsets it is impossible to decide whether
+    // the current label score is still influenced by them. But typically it
+    // is really confusing for the user, if we claim that an old (error) result
+    // influences the current (positive) score. So we prefer to be conservative
+    // and only display the label chip for checks results on the latest ps.
+    if (this.result.patchset !== this.latestPatchNum) return;
     const info = this.labels?.[label];
     const status = getLabelStatus(info).toLowerCase();
     const value = getRepresentativeValue(info);
@@ -426,7 +499,7 @@
     return html`
       <div class="label ${status}">
         <span>${label} ${valueStr}</span>
-        <paper-tooltip offset="5" ?fitToVisibleBounds="${true}">
+        <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
           The check result has (probably) influenced this label vote.
         </paper-tooltip>
       </div>
@@ -453,18 +526,22 @@
     if (this.isExpanded) return;
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
-    return html`<a href="${link.url}" class="link" target="_blank"
-      ><iron-icon
+    const icon = iconForLink(link.icon);
+    return html`<a href=${link.url} class="link" target="_blank"
+      ><gr-icon
+        icon=${icon.name}
+        ?filled=${icon.filled}
         aria-label="external link to details"
         class="link"
-        icon="gr-icons:${iconForLink(link.icon)}"
-      ></iron-icon
+      ></gr-icon
       ><paper-tooltip offset="5">${tooltipText}</paper-tooltip></a
     >`;
   }
 
   private renderActions() {
-    const actions = this.result?.actions ?? [];
+    const actions = [...(this.result?.actions ?? [])];
+    const fixAction = createFixAction(this, this.result);
+    if (fixAction) actions.unshift(fixAction);
     if (actions.length === 0) return;
     const overflowItems = actions.slice(2).map(action => {
       return {...action, id: action.name};
@@ -479,27 +556,33 @@
         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}"
-        .disabledIds="${disabledItems}"
+        @tap-item=${this.handleAction}
+        @opened-changed=${(e: ValueChangedEvent<boolean>) =>
+          toggleClass(this, 'dropdown-open', e.detail.value)}
+        ?hidden=${overflowItems.length === 0}
+        .items=${overflowItems}
+        .disabledIds=${disabledItems}
       >
-        <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
-        </iron-icon>
+        <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
         <span id="moreMessage">More</span>
       </gr-dropdown>
     </div>`;
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.getChecksModel().triggerAction(
+      e.detail,
+      this.result,
+      'result-row-dropdown'
+    );
   }
 
   private renderAction(action?: Action) {
     if (!action) return;
-    return html`<gr-checks-action .action="${action}"></gr-checks-action>`;
+    return html`<gr-checks-action
+      context="result-row"
+      .action=${action}
+    ></gr-checks-action>`;
   }
 
   renderPrimaryActions() {
@@ -521,12 +604,16 @@
   }
 
   renderTag(tag: Tag) {
-    return html`<div class="tag ${tag.color}">
+    return html`<button
+      class="tag ${tag.color}"
+      @click=${(e: MouseEvent) => this.tagClick(e, tag.name)}
+    >
       <span>${tag.name}</span>
-      <paper-tooltip offset="5" ?fitToVisibleBounds="${true}">
-        ${tag.tooltip ?? 'A category tag for this check result'}
+      <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
+        ${tag.tooltip ??
+        'A category tag for this check result. Click to filter.'}
       </paper-tooltip>
-    </div>`;
+    </button>`;
   }
 }
 
@@ -535,10 +622,10 @@
   @property({attribute: false})
   result?: RunResult;
 
-  @state()
-  repoConfig?: ConfigInfo;
+  @property({type: Boolean})
+  hideCodePointers = false;
 
-  private changeService = appContext.changeService;
+  private getChangeModel = resolve(this, changeModelToken);
 
   static override get styles() {
     return [
@@ -551,7 +638,7 @@
           display: inline-block;
           margin-right: var(--spacing-xl);
         }
-        .links a iron-icon {
+        .links a gr-icon {
           margin-right: var(--spacing-xs);
         }
         .message {
@@ -561,11 +648,6 @@
     ];
   }
 
-  constructor() {
-    super();
-    subscribe(this, repoConfig$, x => (this.repoConfig = x));
-  }
-
   override render() {
     if (!this.result) return '';
     return html`
@@ -573,21 +655,17 @@
       ${this.renderSecondaryLinks()} ${this.renderCodePointers()}
       <gr-endpoint-decorator
         name="check-result-expanded"
-        .targetPlugin="${this.result.pluginName}"
+        .targetPlugin=${this.result.pluginName}
       >
-        <gr-endpoint-param
-          name="run"
-          .value="${this.result}"
-        ></gr-endpoint-param>
+        <gr-endpoint-param name="run" .value=${this.result}></gr-endpoint-param>
         <gr-endpoint-param
           name="result"
-          .value="${this.result}"
+          .value=${this.result}
         ></gr-endpoint-param>
         <gr-formatted-text
-          noTrailingMargin
           class="message"
-          .content="${this.result.message}"
-          .config="${this.repoConfig?.commentlinks}"
+          .markdown=${true}
+          .content=${this.result.message ?? ''}
         ></gr-formatted-text>
       </gr-endpoint-decorator>
     `;
@@ -616,6 +694,7 @@
   }
 
   private renderCodePointers() {
+    if (this.hideCodePointers) return;
     const pointers = this.result?.codePointers ?? [];
     if (pointers.length === 0) return;
     const links = pointers.map(pointer => {
@@ -624,7 +703,7 @@
       const end = pointer?.range?.end_line;
       if (start) rangeText += `#${start}`;
       if (end && start !== end) rangeText += `-${end}`;
-      const change = this.changeService.getChange();
+      const change = this.getChangeModel().getChange();
       assertIsDefined(change);
       const path = pointer.path;
       const patchset = this.result?.patchset as PatchSetNumber | undefined;
@@ -632,7 +711,12 @@
       return {
         icon: LinkIcon.CODE,
         tooltip: `${path}${rangeText}`,
-        url: GerritNav.getUrlForDiff(change, path, patchset, undefined, line),
+        url: createDiffUrl({
+          changeNum: change._number,
+          repo: change.project,
+          patchNum: patchset,
+          diffView: {path, lineNum: line},
+        }),
         primary: true,
       };
     });
@@ -645,12 +729,10 @@
     if (!link) return;
     const text = link.tooltip ?? tooltipForLink(link.icon);
     const target = targetBlank ? '_blank' : undefined;
-    return html`<a href="${link.url}" target="${ifDefined(target)}">
-      <iron-icon
-        class="link"
-        icon="gr-icons:${iconForLink(link.icon)}"
-      ></iron-icon
-      ><span>${text}</span>
+    const icon = iconForLink(link.icon);
+    return html`<a href=${link.url} target=${ifDefined(target)}>
+      <gr-icon icon=${icon.name} class="link" ?filled=${icon.filled}></gr-icon>
+      <span>${text}</span>
     </a>`;
   }
 }
@@ -676,7 +758,7 @@
   filterInput?: HTMLInputElement;
 
   @state()
-  filterRegExp = new RegExp('');
+  filterRegExp = '';
 
   /** All runs. Shown should only the selected/filtered ones. */
   @property({attribute: false})
@@ -686,8 +768,8 @@
    * 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({attribute: false})
-  selectedRuns: string[] = [];
+  @state()
+  selectedRuns: Set<string> = new Set();
 
   @state()
   actions: Action[] = [];
@@ -707,12 +789,8 @@
   @state()
   latestPatchsetNumber: PatchSetNumber | undefined = undefined;
 
-  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @property({attribute: false})
-  selectedAttempts: Map<string, number | undefined> = new Map<
-    string,
-    number | undefined
-  >();
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
 
   /** Maintains the state of which result sections should show all results. */
   @state()
@@ -732,23 +810,63 @@
    */
   private isSectionExpandedByUser = new Map<Category, boolean>();
 
-  private readonly checksService = appContext.checksService;
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getChecksModel = resolve(this, checksModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly patched = new HtmlPatched(key => {
+    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
+      component: this.tagName,
+      key: key.substring(0, 300),
+    });
+  });
 
   constructor() {
     super();
-    subscribe(this, topLevelActionsSelected$, x => (this.actions = x));
-    subscribe(this, topLevelLinksSelected$, x => (this.links = x));
     subscribe(
       this,
-      checksSelectedPatchsetNumber$,
+      () => this.getChecksModel().topLevelActionsSelected$,
+      x => (this.actions = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().topLevelLinksSelected$,
+      x => (this.links = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
-    subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
     subscribe(
       this,
-      someProvidersAreLoadingSelected$,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchsetNumber = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().someProvidersAreLoadingSelected$,
       x => (this.someProvidersAreLoading = x)
     );
+    subscribe(
+      this,
+      () => this.getViewModel().checksRunsSelected$,
+      x => (this.selectedRuns = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().checksResultsFilter$,
+      x => (this.filterRegExp = x)
+    );
   }
 
   static override get styles() {
@@ -773,7 +891,6 @@
         }
         .headerTopRow,
         .headerBottomRow {
-          max-width: 1600px;
           display: flex;
           justify-content: space-between;
           align-items: flex-end;
@@ -813,11 +930,13 @@
         .notLatest .headerTopRow .right .goToLatest {
           display: block;
         }
+        .headerTopRow .right > * {
+          margin-left: var(--spacing-m);
+        }
         .headerTopRow .right .goToLatest gr-button {
-          margin-right: var(--spacing-m);
           --gr-button-padding: var(--spacing-s) var(--spacing-m);
         }
-        .headerBottomRow iron-icon {
+        .headerBottomRow gr-icon {
           color: var(--link-color);
         }
         .headerBottomRow .space {
@@ -828,7 +947,7 @@
         .headerBottomRow a {
           margin-right: var(--spacing-l);
         }
-        #moreActions iron-icon {
+        #moreActions gr-icon {
           color: var(--link-color);
         }
         #moreMessage {
@@ -883,6 +1002,9 @@
         .categoryHeader .statusIcon.success {
           color: var(--success-foreground);
         }
+        .categoryHeader.empty gr-icon.statusIcon {
+          color: var(--deemphasized-text-color);
+        }
         .categoryHeader .filtered {
           color: var(--deemphasized-text-color);
         }
@@ -896,7 +1018,6 @@
         }
         .noResultsMessage {
           width: 100%;
-          max-width: 1600px;
           margin-top: var(--spacing-m);
           background-color: var(--background-color-primary);
           box-shadow: var(--elevation-level-1);
@@ -905,7 +1026,6 @@
         }
         table.resultsTable {
           width: 100%;
-          max-width: 1600px;
           table-layout: fixed;
           margin-top: var(--spacing-m);
           background-color: var(--background-color-primary);
@@ -917,8 +1037,13 @@
           padding: var(--spacing-s);
         }
         tr.headerRow th.nameCol {
-          width: 200px;
           padding-left: var(--spacing-l);
+          width: 200px;
+        }
+        @media screen and (min-width: 1400px) {
+          tr.headerRow th.nameCol.longNames {
+            width: 300px;
+          }
         }
         tr.headerRow th.summaryCol {
           width: 99%;
@@ -940,6 +1065,9 @@
 
   protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
+    if (changedProperties.has('filterRegExp') && this.filterInput) {
+      this.filterInput.value = this.filterRegExp;
+    }
     if (changedProperties.has('tabState') && this.tabState) {
       const {statusOrCategory, checkName} = this.tabState;
       if (isCategory(statusOrCategory)) {
@@ -979,28 +1107,37 @@
       header: true,
       notLatest: !!this.checksPatchsetNumber,
     };
+    const attemptItems = this.createAttemptDropdownItems();
     return html`
-      <div class="${classMap(headerClasses)}">
+      <div class=${classMap(headerClasses)}>
         <div class="headerTopRow">
           <div class="left">
             <h2 class="heading-2">Results</h2>
-            <div class="loading" ?hidden="${!this.someProvidersAreLoading}">
+            <div class="loading" ?hidden=${!this.someProvidersAreLoading}>
               <span>Loading results </span>
               <span class="loadingSpin"></span>
             </div>
           </div>
           <div class="right">
             <div class="goToLatest">
-              <gr-button @click="${this.goToLatestPatchset}" link
+              <gr-button @click=${this.goToLatestPatchset} link
                 >Go to latest patchset</gr-button
               >
             </div>
+            ${when(
+              attemptItems.length > 0,
+              () => html` <gr-dropdown-list
+                value=${this.selectedAttempt ?? 0}
+                .items=${attemptItems}
+                @value-change=${this.onAttemptSelected}
+              ></gr-dropdown-list>`
+            )}
             <gr-dropdown-list
-              value="${this.checksPatchsetNumber ??
+              value=${this.checksPatchsetNumber ??
               this.latestPatchsetNumber ??
-              0}"
-              .items="${this.createPatchsetDropdownItems()}"
-              @value-change="${this.onPatchsetSelected}"
+              0}
+              .items=${this.createPatchsetDropdownItems()}
+              @value-change=${this.onPatchsetSelected}
             ></gr-dropdown-list>
           </div>
         </div>
@@ -1065,13 +1202,15 @@
   private renderLink(link?: Link) {
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
-    return html`<a href="${link.url}" target="_blank"
-      ><iron-icon
-        aria-label="${tooltipText}"
+    const icon = iconForLink(link.icon);
+    return html`<a href=${link.url} target="_blank"
+      ><gr-icon
+        icon=${icon.name}
+        aria-label=${tooltipText}
         class="link"
-        icon="gr-icons:${iconForLink(link.icon)}"
-      ></iron-icon
-      ><paper-tooltip offset="5">${tooltipText}</paper-tooltip></a
+        ?filled=${icon.filled}
+      ></gr-icon>
+      <paper-tooltip offset="5">${tooltipText}</paper-tooltip></a
     >`;
   }
 
@@ -1083,34 +1222,73 @@
         link=""
         vertical-offset="32"
         horizontal-align="right"
-        @tap-item="${this.handleAction}"
-        .items="${items}"
-        .disabledIds="${disabledIds}"
+        @tap-item=${this.handleAction}
+        .items=${items}
+        .disabledIds=${disabledIds}
       >
-        <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
-        </iron-icon>
+        <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon>
         <span id="moreMessage">More</span>
       </gr-dropdown>
     `;
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    this.checksService.triggerAction(e.detail);
+    this.getChecksModel().triggerAction(
+      e.detail,
+      undefined,
+      'results-dropdown'
+    );
+  }
+
+  private handleFilter(e: ChecksResultsFilterEvent) {
+    const newValue = e.detail.filterRegExp ?? '';
+    this.getViewModel().updateState({
+      checksResultsFilter: this.filterRegExp === newValue ? '' : newValue,
+    });
   }
 
   private renderAction(action?: Action) {
     if (!action) return;
-    return html`<gr-checks-action .action="${action}"></gr-checks-action>`;
+    return html`<gr-checks-action
+      context="results"
+      .action=${action}
+    ></gr-checks-action>`;
+  }
+
+  private onAttemptSelected(e: CustomEvent<{value: string | undefined}>) {
+    const attempt = stringToAttemptChoice(e.detail.value);
+    assertIsDefined(attempt, `unexpected attempt choice ${e.detail.value}`);
+    this.getChecksModel().updateStateSetAttempt(attempt);
   }
 
   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);
+    let patchset: number | undefined = Number(e.detail.value);
+    assert(Number.isInteger(patchset), `patchset must be integer: ${patchset}`);
+    if (patchset === this.latestPatchsetNumber) patchset = undefined;
+    this.getChecksModel().updateStateSetPatchset(
+      patchset as PatchSetNumber | undefined
+    );
   }
 
   private goToLatestPatchset() {
-    this.checksService.setPatchset(undefined);
+    this.getChecksModel().updateStateSetPatchset(undefined);
+  }
+
+  private createAttemptDropdownItems() {
+    if (this.runs.every(run => run.isSingleAttempt)) return [];
+    const attempts: AttemptChoice[] = this.runs
+      .map(run => run.attempt ?? 0)
+      .filter(isAttemptChoice)
+      .filter(unique);
+    attempts.push(LATEST_ATTEMPT);
+    attempts.push(ALL_ATTEMPTS);
+    const items: DropdownItem[] = attempts.sort(sortAttemptChoices).map(a => {
+      return {
+        value: a,
+        text: attemptChoiceLabel(a),
+      };
+    });
+    return items;
   }
 
   private createPatchsetDropdownItems() {
@@ -1127,21 +1305,19 @@
   }
 
   isRunSelected(run: {checkName: string}) {
-    return (
-      this.selectedRuns.length === 0 ||
-      this.selectedRuns.includes(run.checkName)
-    );
+    return this.selectedRuns.size === 0 || this.selectedRuns.has(run.checkName);
   }
 
   renderFilter() {
     const runs = this.runs.filter(
       run =>
-        this.isRunSelected(run) && isAttemptSelected(this.selectedAttempts, run)
+        this.isRunSelected(run) && isAttemptSelected(this.selectedAttempt, run)
     );
-    if (this.selectedRuns.length === 0 && allResults(runs).length <= 3) {
-      if (this.filterRegExp.source.length > 0) {
-        this.filterRegExp = new RegExp('');
-      }
+    if (
+      this.selectedRuns.size === 0 &&
+      allResults(runs).length <= 3 &&
+      this.filterRegExp === ''
+    ) {
       return;
     }
     return html`
@@ -1149,16 +1325,23 @@
         <input
           id="filterInput"
           type="text"
-          placeholder="Filter results by regular expression"
-          @input="${this.onInput}"
+          placeholder="Filter results by tag or regular expression"
+          @input=${this.onFilterInputChange}
         />
       </div>
     `;
   }
 
-  onInput() {
+  onFilterInputChange() {
     assertIsDefined(this.filterInput, 'filter <input> element');
-    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+    this.reporting.reportInteraction(
+      Interaction.CHECKS_RESULT_FILTER_CHANGED,
+      {},
+      {deduping: Deduping.EVENT_ONCE_PER_CHANGE}
+    );
+    this.getViewModel().updateState({
+      checksResultsFilter: this.filterInput.value,
+    });
   }
 
   renderSection(category: Category) {
@@ -1166,7 +1349,7 @@
     const isWarningOrError =
       category === Category.WARNING || category === Category.ERROR;
     const allRuns = this.runs.filter(run =>
-      isAttemptSelected(this.selectedAttempts, run)
+      isAttemptSelected(this.selectedAttempt, run)
     );
     const all = allRuns.reduce(
       (results: RunResult[], run) => [
@@ -1175,21 +1358,35 @@
       ],
       []
     );
-    const isSelection = this.selectedRuns.length > 0;
+    const isSelectionActive = this.selectedRuns.size > 0;
     const selected = all.filter(result => this.isRunSelected(result));
-    const filtered = selected.filter(result =>
-      matches(result, this.filterRegExp)
-    );
+    const re = new RegExp(this.filterRegExp, 'i');
+    const filtered = selected.filter(result => matches(result, re));
+    const isFilterActiveWithResults =
+      this.filterRegExp !== '' && filtered.length > 0;
+
+    // The logic for deciding whether to expand a section by default is a bit
+    // complicated, but we want to collapse empty and info/success sections by
+    // default for a clean and focused user experience. However, as soon as the
+    // user starts selecting or filtering we must take this into account and
+    // prefer to expand the sections.
     let expanded = this.isSectionExpanded.get(category);
     const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false;
     if (!expandedByUser || expanded === undefined) {
-      expanded = selected.length > 0 && (isWarningOrError || isSelection);
+      // Note that we are using `selected` for `isEmpty` and not `filtered`,
+      // because if the filter is what makes a section empty, then we want to
+      // show an expanded section, which contains a message about this.
+      const isEmpty = selected.length === 0;
+      expanded =
+        !isEmpty &&
+        (isWarningOrError || isSelectionActive || isFilterActiveWithResults);
       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 resultCount = filtered.length;
+    const empty = resultCount === 0 ? 'empty' : '';
     const resultLimit = isShowAll ? 1000 : 20;
     const showAllButton = this.renderShowAllButton(
       category,
@@ -1197,18 +1394,23 @@
       resultLimit,
       resultCount
     );
+    const icon = iconFor(category);
     return html`
-      <div class="${expandedClass}">
+      <div class=${expandedClass}>
         <h3
-          class="categoryHeader ${catString} heading-3"
-          @click="${() => this.toggleExpanded(category)}"
+          class="categoryHeader ${catString} ${empty} heading-3"
+          @click=${() => this.toggleExpanded(category)}
         >
-          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <gr-icon
+            class="expandIcon"
+            icon=${expanded ? 'expand_less' : 'expand_more'}
+          ></gr-icon>
           <div class="statusIconWrapper">
-            <iron-icon
-              icon="gr-icons:${iconFor(category)}"
+            <gr-icon
+              icon=${icon.name}
+              ?filled=${icon.filled}
               class="statusIcon ${catString}"
-            ></iron-icon>
+            ></gr-icon>
             <span class="title">${catString}</span>
             <span class="count">${this.renderCount(all, filtered)}</span>
             <paper-tooltip offset="5"
@@ -1216,12 +1418,14 @@
             >
           </div>
         </h3>
-        ${this.renderResults(
-          all,
-          selected,
-          filtered,
-          resultLimit,
-          showAllButton
+        ${when(expanded, () =>
+          this.renderResults(
+            all,
+            selected,
+            filtered,
+            resultLimit,
+            showAllButton
+          )
         )}
       </div>
     `;
@@ -1239,7 +1443,7 @@
     return html`
       <tr class="showAllRow">
         <td colspan="3">
-          <gr-button class="showAll" link @click="${handler}"
+          <gr-button class="showAll" link @click=${handler}
             >${message}</gr-button
           >
         </td>
@@ -1250,6 +1454,13 @@
   toggleShowAll(category: Category) {
     const current = this.isShowAll.get(category) ?? false;
     this.isShowAll.set(category, !current);
+    this.reporting.reportInteraction(
+      Interaction.CHECKS_RESULT_SECTION_SHOW_ALL,
+      {
+        category,
+        showAll: !current,
+      }
+    );
     this.requestUpdate();
   }
 
@@ -1274,23 +1485,27 @@
       </div>`;
     }
     filtered = filtered.slice(0, limit);
+    // Some hosts/plugins use really long check names. If we have space and the
+    // check names are indeed very long, then set a more generous nameCol width.
+    const longestNameLength = Math.max(...all.map(r => r.checkName.length));
+    const nameColClasses = {nameCol: true, longNames: longestNameLength > 25};
     return html`
       <table class="resultsTable">
         <thead>
           <tr class="headerRow">
-            <th class="nameCol">Run</th>
+            <th class=${classMap(nameColClasses)}>Run</th>
             <th class="summaryCol">Summary</th>
             <th class="expanderCol"></th>
           </tr>
         </thead>
-        <tbody>
+        <tbody @checks-results-filter=${this.handleFilter}>
           ${repeat(
             filtered,
             result => result.internalResultId,
-            (result?: RunResult) => html`
+            (result?: RunResult) => this.patched.html`
               <gr-result-row
-                class="${charsOnly(result!.checkName)}"
-                .result="${result}"
+                class=${charsOnly(result!.checkName)}
+                .result=${result}
               ></gr-result-row>
             `
           )}
@@ -1312,6 +1527,10 @@
     assertIsDefined(expanded, 'expanded must have been set in initial render');
     this.isSectionExpanded.set(category, !expanded);
     this.isSectionExpandedByUser.set(category, true);
+    this.reporting.reportInteraction(Interaction.CHECKS_RESULT_SECTION_TOGGLE, {
+      expanded: !expanded,
+      category,
+    });
     this.requestUpdate();
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index babfd42..113470c 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -1,26 +1,338 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import '../../test/common-test-setup';
+import './gr-checks-results';
+import {GrChecksResults, GrResultRow} from './gr-checks-results';
+import {html} from 'lit';
+import {fixture, assert} from '@open-wc/testing';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {fakeRun0, setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {resolve} from '../../models/dependency';
+import {createLabelInfo} from '../../test/test-data-generators';
+import {queryAndAssert, query, assertIsDefined} from '../../utils/common-util';
+import {PatchSetNumber} from '../../api/rest-api';
+import {GrDropdownList} from '../shared/gr-dropdown-list/gr-dropdown-list';
 
-import '../../test/common-test-setup-karma';
-import {GrChecksResults} from './gr-checks-results';
+suite('gr-result-row test', () => {
+  let element: GrResultRow;
+
+  setup(async () => {
+    const result = {...fakeRun0, ...fakeRun0.results![0]};
+    element = await fixture<GrResultRow>(
+      html`<gr-result-row .result=${result}></gr-result-row>`
+    );
+    element.shouldRender = true;
+  });
+
+  test('renders label association', async () => {
+    element.result = {...element.result!, labelName: 'test-label', patchset: 1};
+    element.labels = {'test-label': createLabelInfo()};
+
+    // don't show when patchset does not match latest
+    element.latestPatchNum = 2 as PatchSetNumber;
+    await element.updateComplete;
+    let labelDiv = query(element, '.label');
+    assert.isNotOk(labelDiv);
+
+    element.latestPatchNum = 1 as PatchSetNumber;
+    await element.updateComplete;
+    labelDiv = queryAndAssert(element, '.label');
+    assert.dom.equal(
+      labelDiv,
+      /* HTML */ `
+        <div class="approved label">
+          <span> test-label +1 </span>
+          <paper-tooltip
+            fittovisiblebounds=""
+            offset="5"
+            role="tooltip"
+            tabindex="-1"
+          >
+            The check result has (probably) influenced this label vote.
+          </paper-tooltip>
+        </div>
+      `
+    );
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+      <div class="flex">
+        <gr-hovercard-run> </gr-hovercard-run>
+        <div class="name" role="button" tabindex="0">
+          FAKE Error Finder Finder Finder Finder Finder Finder Finder
+        </div>
+        <div class="space"></div>
+      </div>
+        <div class="summary-cell">
+          <a class="link" href="https://www.google.com" target="_blank">
+            <gr-icon
+              icon="open_in_new"
+              aria-label="external link to details"
+              class="link"
+            ></gr-icon>
+            <paper-tooltip offset="5" role="tooltip" tabindex="-1">
+              Link to details
+            </paper-tooltip>
+          </a>
+          <div
+            class="summary"
+            title="I would like to point out this error: 1 is not equal to 2!"
+          >
+            I would like to point out this error: 1 is not equal to 2!
+          </div>
+          <div class="message"></div>
+          <div class="tags">
+            <button class="tag">
+              <span> OBSOLETE </span>
+              <paper-tooltip
+                fittovisiblebounds=""
+                offset="5"
+                role="tooltip"
+                tabindex="-1"
+              >
+                A category tag for this check result. Click to filter.
+              </paper-tooltip>
+            </button>
+            <button class="tag">
+              <span> E2E </span>
+              <paper-tooltip
+                fittovisiblebounds=""
+                offset="5"
+                role="tooltip"
+                tabindex="-1"
+              >
+                A category tag for this check result. Click to filter.
+              </paper-tooltip>
+            </button>
+          </div>
+        </div>
+        <div
+          aria-checked="false"
+          aria-label="Expand result row"
+          class="show-hide"
+          role="switch"
+          tabindex="0"
+        >
+          <gr-icon icon="expand_more"></gr-icon>
+        </div>
+      </div>
+    `
+    );
+  });
+});
 
 suite('gr-checks-results test', () => {
-  test('is defined', () => {
-    const el = document.createElement('gr-checks-results');
-    assert.instanceOf(el, GrChecksResults);
+  let element: GrChecksResults;
+
+  setup(async () => {
+    element = await fixture<GrChecksResults>(
+      html`<gr-checks-results></gr-checks-results>`
+    );
+    const getChecksModel = resolve(element, checksModelToken);
+    getChecksModel().allRunsSelectedPatchset$.subscribe(
+      runs => (element.runs = runs)
+    );
+    setAllFakeRuns(getChecksModel());
+    await element.updateComplete;
+  });
+
+  test('attempt dropdown items', async () => {
+    const attemptDropdown = queryAndAssert<GrDropdownList>(
+      element,
+      'gr-dropdown-list'
+    );
+    assertIsDefined(attemptDropdown.items);
+    assert.equal(attemptDropdown.items.length, 42);
+    assert.deepEqual(attemptDropdown.items[0], {
+      text: 'Latest Attempt',
+      value: 'latest',
+    });
+    assert.deepEqual(attemptDropdown.items[1], {
+      text: 'All Attempts',
+      value: 'all',
+    });
+    assert.deepEqual(attemptDropdown.items[2], {
+      text: 'Attempt 0',
+      value: 0,
+    });
+    assert.deepEqual(attemptDropdown.items[41], {
+      text: 'Attempt 40',
+      value: 40,
+    });
+  });
+
+  test('renders', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="header">
+          <div class="headerTopRow">
+            <div class="left">
+              <h2 class="heading-2">Results</h2>
+              <div class="loading" hidden="">
+                <span> Loading results </span>
+                <span class="loadingSpin"> </span>
+              </div>
+            </div>
+            <div class="right">
+              <div class="goToLatest">
+                <gr-button link=""> Go to latest patchset </gr-button>
+              </div>
+              <gr-dropdown-list value="latest"> </gr-dropdown-list>
+              <gr-dropdown-list value="0"> </gr-dropdown-list>
+            </div>
+          </div>
+          <div class="headerBottomRow">
+            <div class="left">
+              <div class="filterDiv">
+                <input
+                  id="filterInput"
+                  placeholder="Filter results by tag or regular expression"
+                  type="text"
+                />
+              </div>
+            </div>
+            <div class="right">
+              <a href="https://www.google.com" target="_blank">
+                <gr-icon
+                  icon="bug_report"
+                  filled
+                  aria-label="Fake Bug Report 1"
+                  class="link"
+                ></gr-icon>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </a>
+              <a href="https://www.google.com" target="_blank">
+                <gr-icon
+                  icon="open_in_new"
+                  aria-label="Fake Link 1"
+                  class="link"
+                ></gr-icon>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </a>
+              <a href="https://www.google.com" target="_blank">
+                <gr-icon icon="code" aria-label="Fake Code Link" class="link">
+                </gr-icon>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </a>
+              <a href="https://www.google.com" target="_blank">
+                <gr-icon
+                  icon="image"
+                  filled
+                  aria-label="Fake Image Link"
+                  class="link"
+                ></gr-icon>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </a>
+              <div class="space"></div>
+              <gr-checks-action context="results"> </gr-checks-action>
+              <gr-dropdown
+                horizontal-align="right"
+                id="moreActions"
+                link=""
+                vertical-offset="32"
+              >
+                <gr-icon
+                  icon="more_vert"
+                  aria-labelledby="moreMessage"
+                ></gr-icon>
+                <span id="moreMessage"> More </span>
+              </gr-dropdown>
+            </div>
+          </div>
+        </div>
+        <div class="body">
+          <div class="expanded">
+            <h3 class="categoryHeader error heading-3">
+              <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+              <div class="statusIconWrapper">
+                <gr-icon icon="error" filled class="error statusIcon"></gr-icon>
+                <span class="title"> error </span>
+                <span class="count"> (3) </span>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </div>
+            </h3>
+            <gr-result-row
+              class="FAKEErrorFinderFinderFinderFinderFinderFinderFinder"
+              isexpandable
+            >
+            </gr-result-row>
+            <gr-result-row
+              isexpandable
+              class="FAKEErrorFinderFinderFinderFinderFinderFinderFinder"
+            >
+            </gr-result-row>
+            <gr-result-row isexpandable class="FAKESuperCheck"> </gr-result-row>
+            <table class="resultsTable">
+              <thead>
+                <tr class="headerRow">
+                  <th class="longNames nameCol">Run</th>
+                  <th class="summaryCol">Summary</th>
+                  <th class="expanderCol"></th>
+                </tr>
+              </thead>
+              <tbody></tbody>
+            </table>
+          </div>
+          <div class="expanded">
+            <h3 class="categoryHeader heading-3 warning">
+              <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+              <div class="statusIconWrapper">
+                <gr-icon icon="warning" filled class="warning statusIcon">
+                </gr-icon>
+                <span class="title"> warning </span>
+                <span class="count"> (1) </span>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </div>
+            </h3>
+            <gr-result-row class="FAKESuperCheck" isexpandable> </gr-result-row>
+            <table class="resultsTable">
+              <thead>
+                <tr class="headerRow">
+                  <th class="nameCol">Run</th>
+                  <th class="summaryCol">Summary</th>
+                  <th class="expanderCol"></th>
+                </tr>
+              </thead>
+              <tbody></tbody>
+            </table>
+          </div>
+          <div class="collapsed">
+            <h3 class="categoryHeader heading-3 info">
+              <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
+              <div class="statusIconWrapper">
+                <gr-icon icon="info" class="info statusIcon"></gr-icon>
+                <span class="title"> info </span>
+                <span class="count"> (3) </span>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </div>
+            </h3>
+          </div>
+          <div class="collapsed">
+            <h3 class="categoryHeader empty heading-3 success">
+              <gr-icon icon="expand_more" class="expandIcon"></gr-icon>
+              <div class="statusIconWrapper">
+                <gr-icon icon="check_circle" class="statusIcon success">
+                </gr-icon>
+                <span class="title"> success </span>
+                <span class="count"> (0) </span>
+                <paper-tooltip offset="5"> </paper-tooltip>
+              </div>
+            </h3>
+          </div>
+        </div>
+      `,
+      {
+        ignoreChildren: ['paper-tooltip'],
+        ignoreAttributes: ['tabindex', 'aria-disabled', 'role'],
+      }
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index a643c18..128a9b0a 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -1,28 +1,21 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-icon/iron-icon';
-import {classMap} from 'lit/directives/class-map';
+import '../shared/gr-icon/gr-icon';
+import {classMap} from 'lit/directives/class-map.js';
 import './gr-hovercard-run';
 import {css, html, LitElement, nothing, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import './gr-checks-attempt';
 import {Action, Link, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  attemptChoiceLabel,
+  LATEST_ATTEMPT,
   AttemptDetail,
   compareByWorstCategory,
   headerForStatus,
@@ -31,13 +24,14 @@
   PRIMARY_STATUS_ACTIONS,
   primaryRunAction,
   worstCategory,
-} from '../../services/checks/checks-util';
+} from '../../models/checks/checks-util';
 import {
-  allRunsSelectedPatchset$,
   CheckRun,
   ChecksPatchset,
   ErrorMessages,
-  errorMessagesLatest$,
+} from '../../models/checks/checks-model';
+import {
+  clearAllFakeRuns,
   fakeActions,
   fakeLinks,
   fakeRun0,
@@ -45,22 +39,25 @@
   fakeRun2,
   fakeRun3,
   fakeRun4Att,
-  loginCallbackLatest$,
-  updateStateSetResults,
-} from '../../services/checks/checks-model';
+  fakeRun5,
+  setAllFakeRuns,
+} from '../../models/checks/checks-fakes';
 import {assertIsDefined} from '../../utils/common-util';
 import {modifierPressed, whenVisible} from '../../utils/dom-util';
-import {
-  fireAttemptSelected,
-  fireRunSelected,
-  fireRunSelectionReset,
-} from './gr-checks-util';
+import {fireRunSelected, RunSelectedEvent} from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
 import {charsOnly} from '../../utils/string-util';
-import {appContext} from '../../services/app-context';
+import {getAppContext} from '../../services/app-context';
 import {KnownExperimentId} from '../../services/flags/flags';
 import {subscribe} from '../lit/subscription-controller';
 import {fontStyles} from '../../styles/gr-font-styles';
+import {durationString} from '../../utils/date-util';
+import {resolve} from '../../models/dependency';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {Interaction} from '../../constants/reporting';
+import {Deduping} from '../../api/reporting';
+import {when} from 'lit/directives/when.js';
+import {changeViewModelToken} from '../../models/views/change';
 
 @customElement('gr-checks-run')
 export class GrChecksRun extends LitElement {
@@ -72,6 +69,14 @@
           display: block;
           --thick-border: 6px;
         }
+        :host([condensed]) .eta,
+        :host([condensed]) .middle,
+        :host([condensed]) .right {
+          display: none;
+        }
+        :host([condensed]) * {
+          pointer-events: none;
+        }
         .chip {
           display: flex;
           justify-content: space-between;
@@ -98,40 +103,45 @@
         .name {
           font-weight: var(--font-weight-bold);
         }
+        .eta {
+          color: var(--deemphasized-text-color);
+          padding-left: var(--spacing-s);
+        }
         .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 {
+        .chip.info {
           border-left: var(--thick-border) solid var(--info-foreground);
         }
-        .chip.check-circle-outline {
+        .chip.check_circle {
           border-left: var(--thick-border) solid var(--success-foreground);
         }
-        .chip.timelapse {
+        .chip.timelapse,
+        .chip.pending_actions {
           border-left: var(--thick-border) solid var(--border-color);
         }
         .chip.placeholder {
           border-left: var(--thick-border) solid var(--border-color);
         }
-        .chip.placeholder iron-icon {
+        .chip.placeholder gr-icon {
           display: none;
         }
-        iron-icon.error {
+        gr-icon.error {
           color: var(--error-foreground);
         }
-        iron-icon.warning {
+        gr-icon.warning {
           color: var(--warning-foreground);
         }
-        iron-icon.info-outline {
+        gr-icon.info {
           color: var(--info-foreground);
         }
-        iron-icon.check-circle-outline {
+        gr-icon.check_circle {
           color: var(--success-foreground);
         }
-        div.chip:hover {
+        :host(:not([condensed])) div.chip:hover {
           background-color: var(--hover-background-color);
         }
         div.chip:focus-within {
@@ -144,7 +154,7 @@
           padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
         }
         div.chip.selected .name,
-        div.chip.selected iron-icon.filter {
+        div.chip.selected gr-icon.filter {
           color: var(--selected-foreground);
         }
         gr-checks-action {
@@ -189,42 +199,43 @@
   @property({attribute: false})
   selected = false;
 
-  @property({attribute: false})
-  selectedAttempt?: number;
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
 
   @property({attribute: false})
   deselected = false;
 
+  @property({type: Boolean})
+  condensed = false;
+
   @state()
   shouldRender = false;
 
+  private readonly reporting = getAppContext().reportingService;
+
+  private getChecksModel = resolve(this, checksModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
+  }
+
   override firstUpdated() {
     assertIsDefined(this.chipElement, 'chip element');
     whenVisible(this.chipElement, () => (this.shouldRender = true), 200);
   }
 
-  protected override 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;
-    }
-  }
-
   override render() {
     if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
 
     const icon = iconForRun(this.run);
     const classes = {
       chip: true,
-      [icon]: true,
+      [icon.name]: true,
       selected: this.selected,
       deselected: this.deselected,
     };
@@ -232,81 +243,100 @@
 
     return html`
       <div
-        @click="${this.handleChipClick}"
-        @keydown="${this.handleChipKey}"
-        class="${classMap(classes)}"
+        @click=${this.handleChipClick}
+        @keydown=${this.handleChipKey}
+        class=${classMap(classes)}
         tabindex="0"
       >
-        <div class="left">
-          <gr-hovercard-run .run="${this.run}"></gr-hovercard-run>
+        <div class="left" tabindex="0">
+          <gr-hovercard-run .run=${this.run}></gr-hovercard-run>
           ${this.renderFilterIcon()}
-          <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          <gr-icon
+            class=${icon.name}
+            icon=${icon.name}
+            ?filled=${icon.filled}
+          ></gr-icon>
           ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
+          ${this.renderETA()}
         </div>
         <div class="middle">
-          <gr-checks-attempt .run="${this.run}"></gr-checks-attempt>
+          <gr-checks-attempt .run=${this.run}></gr-checks-attempt>
           ${this.renderStatusLink()}
         </div>
         <div class="right">
           ${action
-            ? html`<gr-checks-action .action="${action}"></gr-checks-action>`
+            ? html`<gr-checks-action
+                context="runs"
+                .action=${action}
+              ></gr-checks-action>`
             : ''}
         </div>
       </div>
       <div
         class="attemptDetails"
-        ?hidden="${this.run.isSingleAttempt || !this.selected}"
+        ?hidden=${this.run.isSingleAttempt || !this.selected}
       >
+        ${this.renderAttempt({attempt: LATEST_ATTEMPT})}
+        ${this.renderAttempt({attempt: ALL_ATTEMPTS})}
         ${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 attempt = detail.attempt ?? 0;
     const checkNameId = charsOnly(this.run.checkName).toLowerCase();
     const id = `attempt-${detail.attempt}`;
-    const icon = detail.icon;
-    const wasNotRun = icon === iconFor(RunStatus.RUNNABLE);
+    const icon = detail.icon ?? {name: ''};
+    const wasNotRun =
+      icon?.name === iconFor(RunStatus.RUNNABLE)?.name &&
+      attempt !== LATEST_ATTEMPT &&
+      attempt !== ALL_ATTEMPTS;
+    const selected = this.selectedAttempt === attempt;
     return html`<div class="attemptDetail">
       <input
         type="radio"
-        id="${id}"
-        name="${`${checkNameId}-attempt-choice`}"
-        ?checked="${this.isSelected(detail)}"
-        ?disabled="${!this.isSelected(detail) && wasNotRun}"
-        @change="${() => this.handleAttemptChange(detail)}"
+        id=${id}
+        name=${`${checkNameId}-attempt-choice`}
+        .checked=${selected}
+        ?disabled=${!selected && wasNotRun}
+        @change=${() => this.handleAttemptChange(attempt)}
       />
-      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
-      <label for="${id}">
-        Attempt ${detail.attempt}${wasNotRun ? ' (not run)' : ''}
+      <gr-icon
+        icon=${icon.name}
+        class=${icon.name}
+        ?filled=${icon.filled}
+      ></gr-icon>
+      <label for=${id}>
+        ${attemptChoiceLabel(attempt)}${wasNotRun ? ' (not run)' : ''}
       </label>
     </div>`;
   }
 
-  handleAttemptChange(detail: AttemptDetail) {
-    if (!this.isSelected(detail)) {
-      fireAttemptSelected(this, this.run.checkName, detail.attempt);
-    }
+  handleAttemptChange(attempt: AttemptChoice) {
+    this.getChecksModel().updateStateSetAttempt(attempt);
+  }
+
+  renderETA() {
+    if (this.run.status !== RunStatus.RUNNING) return;
+    if (!this.run.finishedTimestamp) return;
+    const now = new Date();
+    if (this.run.finishedTimestamp.getTime() < now.getTime()) return;
+    const eta = durationString(new Date(), this.run.finishedTimestamp, true);
+    return html`<span class="eta">ETA: ${eta}</span>`;
   }
 
   renderStatusLink() {
     const link = this.run.statusLink;
     if (!link) return;
     return html`
-      <a href="${link}" target="_blank" @click="${this.onLinkClick}"
-        ><iron-icon
+      <a href=${link} target="_blank" @click=${this.onLinkClick}
+        ><gr-icon
+          icon="open_in_new"
           class="statusLinkIcon"
-          icon="gr-icons:launch"
           aria-label="external link to run status details"
-        ></iron-icon>
+        ></gr-icon>
         <paper-tooltip offset="5">Link to run status details</paper-tooltip>
       </a>
     `;
@@ -315,13 +345,15 @@
   private onLinkClick(e: MouseEvent) {
     // Prevents handleChipClick() from reacting to <a> link clicks.
     e.stopPropagation();
+    this.reporting.reportInteraction(Interaction.CHECKS_RUN_LINK_CLICKED, {
+      checkName: this.run.checkName,
+      status: this.run.status,
+    });
   }
 
   renderFilterIcon() {
     if (!this.selected) return;
-    return html`
-      <iron-icon class="filter" icon="gr-icons:filter"></iron-icon>
-    `;
+    return html`<gr-icon icon="filter_alt" filled class="filter"></gr-icon>`;
   }
 
   /**
@@ -334,7 +366,11 @@
     if (!category) return nothing;
     const icon = iconFor(category);
     return html`
-      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+      <gr-icon
+        icon=${icon.name}
+        class=${icon.name}
+        ?filled=${icon.filled}
+      ></gr-icon>
     `;
   }
 
@@ -347,7 +383,7 @@
   private handleChipKey(e: KeyboardEvent) {
     if (modifierPressed(e)) return;
     // Only react to `return` and `space`.
-    if (e.keyCode !== 13 && e.keyCode !== 32) return;
+    if (e.key !== 'Enter' && e.key !== ' ') return;
     e.preventDefault();
     e.stopPropagation();
     fireRunSelected(this, this.run.checkName);
@@ -360,7 +396,7 @@
   filterInput?: HTMLInputElement;
 
   @state()
-  filterRegExp = new RegExp('');
+  filterRegExp = '';
 
   @property({attribute: false})
   runs: CheckRun[] = [];
@@ -368,15 +404,11 @@
   @property({type: Boolean, reflect: true})
   collapsed = false;
 
-  @property({attribute: false})
-  selectedRuns: string[] = [];
+  @state()
+  selectedRuns: Set<string> = new Set();
 
-  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @property({attribute: false})
-  selectedAttempts: Map<string, number | undefined> = new Map<
-    string,
-    number | undefined
-  >();
+  @state()
+  selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
 
   @property({attribute: false})
   tabState?: ChecksTabState;
@@ -389,15 +421,49 @@
 
   private isSectionExpanded = new Map<RunStatus, boolean>();
 
-  private flagService = appContext.flagsService;
+  private flagService = getAppContext().flagsService;
 
-  private checksService = appContext.checksService;
+  private getChecksModel = resolve(this, checksModelToken);
+
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
+  private readonly reporting = getAppContext().reportingService;
 
   constructor() {
     super();
-    subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
-    subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
-    subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
+    subscribe(
+      this,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
+      x => (this.runs = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().errorMessagesLatest$,
+      x => (this.errorMessages = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().loginCallbackLatest$,
+      x => (this.loginCallback = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedAttemptNumber$,
+      x => (this.selectedAttempt = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().runFilterRegexp$,
+      x => (this.filterRegExp = x)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().checksRunsSelected$,
+      x => (this.selectedRuns = x)
+    );
+    this.addEventListener('click', () => {
+      if (this.collapsed) this.toggleCollapsed();
+    });
   }
 
   static override get styles() {
@@ -409,12 +475,22 @@
           display: block;
         }
         :host(:not([collapsed])) {
-          min-width: 320px;
+          width: 20%;
           padding: var(--spacing-l) var(--spacing-xl) var(--spacing-xl)
             var(--spacing-xl);
         }
         :host([collapsed]) {
-          padding: var(--spacing-l) 0;
+          width: 90px;
+          padding: var(--spacing-l) var(--spacing-l) var(--spacing-xl)
+            var(--spacing-l);
+          max-height: 600px;
+          overflow: hidden;
+        }
+        :host([collapsed]) * {
+          pointer-events: none;
+        }
+        :host([collapsed]:hover) {
+          cursor: pointer;
         }
         .title {
           display: flex;
@@ -429,25 +505,28 @@
         .title gr-button.expandButton {
           --gr-button-padding: var(--spacing-xs) var(--spacing-s);
         }
-        :host(:not([collapsed])) .expandButton {
+        :host .expandButton {
           margin-right: calc(0px - var(--spacing-m));
         }
-        .expandIcon {
-          width: var(--line-height-h3);
-          height: var(--line-height-h3);
+        :host([collapsed]:hover) .expandButton {
+          background: var(--gray-background-hover);
+          border-radius: var(--border-radius);
         }
         .sectionHeader {
           padding-top: var(--spacing-l);
           text-transform: capitalize;
           cursor: default;
         }
+        :host([collapsed]) .sectionHeader {
+          cursor: pointer;
+        }
         .sectionHeader h3 {
           display: inline-block;
         }
-        .collapsed .sectionRuns {
+        :host(:not([collapsed])) .collapsed .sectionRuns {
           display: none;
         }
-        .collapsed {
+        :host(:not([collapsed])) .collapsed {
           border-bottom: 1px solid var(--border-color);
           padding-bottom: var(--spacing-m);
         }
@@ -485,14 +564,14 @@
           display: flex;
           background-color: var(--error-background);
         }
-        .error iron-icon {
+        .error gr-icon {
           color: var(--error-foreground);
           margin-right: var(--spacing-m);
         }
         .login {
           background: var(--info-background);
         }
-        .login iron-icon {
+        .login gr-icon {
           color: var(--info-foreground);
         }
         .login .buttonRow {
@@ -524,9 +603,6 @@
   }
 
   override render() {
-    if (this.collapsed) {
-      return html`${this.renderCollapseButton()}`;
-    }
     return html`
       <h2 class="title">
         <div class="heading-2">Runs</div>
@@ -538,8 +614,9 @@
         id="filterInput"
         type="text"
         placeholder="Filter runs by regular expression"
-        ?hidden="${!this.showFilter()}"
-        @input="${this.onInput}"
+        ?hidden=${!this.showFilter()}
+        .value=${this.filterRegExp}
+        @input=${this.onInput}
       />
       ${this.renderSection(RunStatus.RUNNING)}
       ${this.renderSection(RunStatus.COMPLETED)}
@@ -548,49 +625,51 @@
   }
 
   private renderZeroState() {
+    if (this.collapsed) return;
     if (this.runs.length > 0) return;
     return html`<div class="zero">No Check Run to show</div>`;
   }
 
   private renderErrors() {
-    return Object.entries(this.errorMessages).map(
-      ([plugin, message]) =>
-        html`
-          <div class="error">
-            <div class="left">
-              <iron-icon icon="gr-icons:error"></iron-icon>
-            </div>
-            <div class="right">
-              <div class="message">
-                Error while fetching results for ${plugin}:<br />${message}
-              </div>
-            </div>
+    return Object.entries(this.errorMessages).map(([plugin, message]) => {
+      const msg = this.collapsed
+        ? 'Error'
+        : `Error while fetching results for ${plugin}:<br />${message}`;
+      return html`
+        <div class="error">
+          <div class="left">
+            <gr-icon icon="error" filled></gr-icon>
           </div>
-        `
-    );
+          <div class="right">
+            <div class="message">${msg}</div>
+          </div>
+        </div>
+      `;
+    });
   }
 
   private renderSignIn() {
     if (!this.loginCallback) return;
+    const message = this.collapsed
+      ? 'Sign in'
+      : 'Sign in to Checks Plugin to see runs and results';
     return html`
       <div class="login">
         <div>
-          <iron-icon
-            class="info-outline"
-            icon="gr-icons:info-outline"
-          ></iron-icon>
-          Sign in to Checks Plugin to see runs and results
+          <gr-icon icon="info"></gr-icon>
+          ${message}
         </div>
         <div class="buttonRow">
-          <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+          <gr-button @click=${this.loginCallback} link>Sign in</gr-button>
         </div>
       </div>
     `;
   }
 
   private renderTitleButtons() {
-    if (this.selectedRuns.length < 2) return;
-    const actions = this.selectedRuns.map(selected => {
+    if (this.collapsed) return;
+    if (this.selectedRuns.size < 2) return;
+    const actions = [...this.selectedRuns].map(selected => {
       const run = this.runs.find(
         run => run.isLatestAttempt && run.checkName === selected
       );
@@ -605,22 +684,33 @@
       <gr-button
         class="font-normal"
         link
-        @click="${() => fireRunSelectionReset(this)}"
+        @click=${() =>
+          this.getViewModel().updateState({checksRunsSelected: undefined})}
         >Unselect All</gr-button
       >
       <gr-tooltip-content
-        title="${runButtonDisabled
+        title=${runButtonDisabled
           ? 'Disabled. Unselect checks without a "Run" action to enable the button.'
-          : ''}"
+          : ''}
         ?has-tooltip=${runButtonDisabled}
       >
         <gr-button
           class="font-normal"
           link
           ?disabled=${runButtonDisabled}
-          @click="${() => {
-            actions.forEach(action => this.checksService.triggerAction(action));
-          }}"
+          @click=${() => {
+            actions.forEach(action => {
+              if (!action) return;
+              this.getChecksModel().triggerAction(
+                action,
+                undefined,
+                'run-selected'
+              );
+            });
+            this.reporting.reportInteraction(
+              Interaction.CHECKS_RUNS_SELECTED_TRIGGERED
+            );
+          }}
           >Run Selected</gr-button
         >
       </gr-tooltip-content>
@@ -631,89 +721,96 @@
     return html`
       <gr-tooltip-content
         has-tooltip
-        title="${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}"
+        title=${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}
       >
         <gr-button
           link
-          class="expandButton"
+          class="expandButton font-normal"
           role="switch"
-          aria-checked="${this.collapsed ? 'true' : 'false'}"
-          aria-label="${this.collapsed
+          aria-checked=${this.collapsed ? 'true' : 'false'}
+          aria-label=${this.collapsed
             ? 'Expand runs panel'
-            : 'Collapse runs panel'}"
-          @click="${() => (this.collapsed = !this.collapsed)}"
-          ><iron-icon
-            class="expandIcon"
-            icon="${this.collapsed
-              ? 'gr-icons:chevron-right'
-              : 'gr-icons:chevron-left'}"
-          ></iron-icon>
+            : 'Collapse runs panel'}
+          @click=${this.toggleCollapsed}
+        >
+          <div>
+            <gr-icon
+              icon=${this.collapsed ? 'chevron_right' : 'chevron_left'}
+              class="expandIcon"
+            >
+            </gr-icon>
+          </div>
         </gr-button>
       </gr-tooltip-content>
     `;
   }
 
+  private toggleCollapsed(event?: Event) {
+    if (event) event.stopPropagation();
+    this.collapsed = !this.collapsed;
+    this.reporting.reportInteraction(Interaction.CHECKS_RUNS_PANEL_TOGGLE, {
+      collapsed: this.collapsed,
+    });
+  }
+
   onInput() {
     assertIsDefined(this.filterInput, 'filter <input> element');
-    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
-  }
-
-  none() {
-    updateStateSetResults('f0', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f1', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f2', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f3', [], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f4', [], [], [], ChecksPatchset.LATEST);
-  }
-
-  all() {
-    updateStateSetResults(
-      'f0',
-      [fakeRun0],
-      fakeActions,
-      fakeLinks,
-      ChecksPatchset.LATEST
+    this.reporting.reportInteraction(
+      Interaction.CHECKS_RUN_FILTER_CHANGED,
+      {},
+      {deduping: Deduping.EVENT_ONCE_PER_CHANGE}
     );
-    updateStateSetResults('f1', [fakeRun1], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f2', [fakeRun2], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f3', [fakeRun3], [], [], ChecksPatchset.LATEST);
-    updateStateSetResults('f4', fakeRun4Att, [], [], ChecksPatchset.LATEST);
+    const value = this.filterInput.value;
+    this.getChecksModel().updateStateSetRunFilter(value ?? '');
   }
 
   toggle(
     plugin: string,
     runs: CheckRun[],
     actions: Action[] = [],
-    links: Link[] = []
+    links: Link[] = [],
+    summaryMessage: string | undefined = undefined
   ) {
     const newRuns = this.runs.includes(runs[0]) ? [] : runs;
-    updateStateSetResults(
+    this.getChecksModel().updateStateSetResults(
       plugin,
       newRuns,
       actions,
       links,
+      summaryMessage,
       ChecksPatchset.LATEST
     );
   }
 
   renderSection(status: RunStatus) {
+    const regExp = new RegExp(this.filterRegExp, 'i');
     const runs = this.runs
       .filter(r => r.isLatestAttempt)
-      .filter(r => r.status === status)
-      .filter(r => this.filterRegExp.test(r.checkName))
+      .filter(
+        r =>
+          r.status === status ||
+          (status === RunStatus.RUNNING && r.status === RunStatus.SCHEDULED)
+      )
+      .filter(r => regExp.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';
+    const icon = expanded ? 'expand_less' : 'expand_more';
+    let header = headerForStatus(status);
+    if (runs.some(r => r.status === RunStatus.SCHEDULED)) {
+      header = `${header} / ${headerForStatus(RunStatus.SCHEDULED)}`;
+    }
+    const count = when(!this.collapsed, () => html` (${runs.length})`);
+    const grIcon = when(
+      !this.collapsed,
+      () => html`<gr-icon icon=${icon} class="expandIcon"></gr-icon>`
+    );
     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">${headerForStatus(status)}</h3>
+        <div class="sectionHeader" @click=${() => this.toggleExpanded(status)}>
+          ${grIcon}
+          <h3 class="heading-3">${header}${count}</h3>
         </div>
         <div class="sectionRuns">${runs.map(run => this.renderRun(run))}</div>
       </div>
@@ -721,56 +818,72 @@
   }
 
   toggleExpanded(status: RunStatus) {
+    if (this.collapsed) return;
     const expanded = this.isSectionExpanded.get(status) ?? true;
     this.isSectionExpanded.set(status, !expanded);
+    this.reporting.reportInteraction(Interaction.CHECKS_RUN_SECTION_TOGGLE, {
+      status,
+      expanded: !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;
+    const selectedRun = this.selectedRuns.has(run.checkName);
+    const deselected = !selectedRun && this.selectedRuns.size > 0;
     return html`<gr-checks-run
-      .run="${run}"
-      .selected="${selectedRun}"
-      .selectedAttempt="${selectedAttempt}"
-      .deselected="${deselected}"
+      .run=${run}
+      ?condensed=${this.collapsed}
+      .selected=${selectedRun}
+      .deselected=${deselected}
+      @run-selected=${this.handleRunSelected}
     ></gr-checks-run>`;
   }
 
-  showFilter(): boolean {
-    const show = this.runs.length > 10;
-    if (!show && this.filterRegExp.source.length > 0) {
-      this.filterRegExp = new RegExp('');
+  handleRunSelected(e: RunSelectedEvent) {
+    if (e.detail.checkName) {
+      this.getViewModel().toggleSelectedCheckRun(e.detail.checkName);
     }
-    return show;
+  }
+
+  showFilter(): boolean {
+    if (this.collapsed) return false;
+    return this.runs.length > 10 || !!this.filterRegExp;
   }
 
   renderFakeControls() {
     if (!this.flagService.isEnabled(KnownExperimentId.CHECKS_DEVELOPER)) return;
+    if (this.collapsed) return;
     return html`
       <div class="testing">
         <div>Toggle fake runs by clicking buttons:</div>
-        <gr-button link @click="${this.none}">none</gr-button>
+        <gr-button link @click=${() => clearAllFakeRuns(this.getChecksModel())}
+          >none</gr-button
+        >
         <gr-button
           link
-          @click="${() =>
-            this.toggle('f0', [fakeRun0], fakeActions, fakeLinks)}"
+          @click=${() =>
+            this.toggle('f0', [fakeRun0], fakeActions, fakeLinks, 'ETA: 1 min')}
           >0</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f1', [fakeRun1])}"
+        <gr-button link @click=${() => this.toggle('f1', [fakeRun1])}
           >1</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f2', [fakeRun2])}"
+        <gr-button link @click=${() => this.toggle('f2', [fakeRun2])}
           >2</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f3', [fakeRun3])}"
+        <gr-button link @click=${() => this.toggle('f3', [fakeRun3])}
           >3</gr-button
         >
         <gr-button link @click="${() => this.toggle('f4', fakeRun4Att)}}"
           >4</gr-button
         >
-        <gr-button link @click="${this.all}">all</gr-button>
+        <gr-button link @click=${() => this.toggle('f5', [fakeRun5])}
+          >5</gr-button
+        >
+        <gr-button link @click=${() => setAllFakeRuns(this.getChecksModel())}
+          >all</gr-button
+        >
       </div>
     `;
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index 4d54200..4bd3446 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -1,26 +1,224 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma';
-import {GrChecksRuns} from './gr-checks-runs';
+import '../../test/common-test-setup';
+import './gr-checks-runs';
+import {GrChecksRun, GrChecksRuns} from './gr-checks-runs';
+import {html} from 'lit';
+import {fixture, assert} from '@open-wc/testing';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {fakeRun0, setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {resolve} from '../../models/dependency';
+import {queryAll} from '../../utils/common-util';
 
 suite('gr-checks-runs test', () => {
-  test('is defined', () => {
-    const el = document.createElement('gr-checks-runs');
-    assert.instanceOf(el, GrChecksRuns);
+  let element: GrChecksRuns;
+
+  setup(async () => {
+    element = await fixture<GrChecksRuns>(
+      html`<gr-checks-runs></gr-checks-runs>`
+    );
+    const getChecksModel = resolve(element, checksModelToken);
+    setAllFakeRuns(getChecksModel());
+    await element.updateComplete;
+  });
+
+  test('filterRegExp', async () => {
+    // Without a filter all 6 fake runs (0-5) will be rendered.
+    assert.equal(queryAll(element, 'gr-checks-run').length, 6);
+
+    // This filter will only match fakeRun2 (checkName: 'FAKE Mega Analysis').
+    element.filterRegExp = 'Mega';
+    await element.updateComplete;
+    assert.equal(queryAll(element, 'gr-checks-run').length, 1);
+  });
+
+  test('renders', async () => {
+    assert.equal(element.runs.length, 44);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h2 class="title">
+          <div class="heading-2">Runs</div>
+          <div class="flex-space"></div>
+          <gr-tooltip-content has-tooltip="" title="Collapse runs panel">
+            <gr-button
+              aria-checked="false"
+              aria-label="Collapse runs panel"
+              class="expandButton font-normal"
+              link=""
+              role="switch"
+            >
+              <div>
+                <gr-icon icon="chevron_left" class="expandIcon"></gr-icon>
+              </div>
+            </gr-button>
+          </gr-tooltip-content>
+        </h2>
+        <input
+          id="filterInput"
+          placeholder="Filter runs by regular expression"
+          type="text"
+        />
+        <div class="expanded running">
+          <div class="sectionHeader">
+            <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+            <h3 class="heading-3">Running / Scheduled (2)</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run></gr-checks-run>
+            <gr-checks-run></gr-checks-run>
+          </div>
+        </div>
+        <div class="completed expanded">
+          <div class="sectionHeader">
+            <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+            <h3 class="heading-3">Completed (3)</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run></gr-checks-run>
+            <gr-checks-run></gr-checks-run>
+            <gr-checks-run></gr-checks-run>
+          </div>
+        </div>
+        <div class="expanded runnable">
+          <div class="sectionHeader">
+            <gr-icon icon="expand_less" class="expandIcon"></gr-icon>
+            <h3 class="heading-3">Not run (1)</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run></gr-checks-run>
+          </div>
+        </div>
+      `,
+      {ignoreAttributes: ['tabindex', 'aria-disabled']}
+    );
+  });
+
+  test('renders collapsed', async () => {
+    element.collapsed = true;
+    await element.updateComplete;
+    assert.equal(element.runs.length, 44);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h2 class="title">
+          <div class="heading-2">Runs</div>
+          <div class="flex-space"></div>
+          <gr-tooltip-content has-tooltip="" title="Expand runs panel">
+            <gr-button
+              aria-checked="true"
+              aria-label="Expand runs panel"
+              class="expandButton font-normal"
+              link=""
+              role="switch"
+            >
+              <div>
+                <gr-icon icon="chevron_right" class="expandIcon"></gr-icon>
+              </div>
+            </gr-button>
+          </gr-tooltip-content>
+        </h2>
+        <input
+          hidden
+          id="filterInput"
+          placeholder="Filter runs by regular expression"
+          type="text"
+        />
+        <div class="expanded running">
+          <div class="sectionHeader">
+            <h3 class="heading-3">Running / Scheduled</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run condensed></gr-checks-run>
+            <gr-checks-run condensed></gr-checks-run>
+          </div>
+        </div>
+        <div class="completed expanded">
+          <div class="sectionHeader">
+            <h3 class="heading-3">Completed</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run condensed></gr-checks-run>
+            <gr-checks-run condensed></gr-checks-run>
+            <gr-checks-run condensed></gr-checks-run>
+          </div>
+        </div>
+        <div class="expanded runnable">
+          <div class="sectionHeader">
+            <h3 class="heading-3">Not run</h3>
+          </div>
+          <div class="sectionRuns">
+            <gr-checks-run condensed></gr-checks-run>
+          </div>
+        </div>
+      `,
+      {ignoreAttributes: ['tabindex', 'aria-disabled']}
+    );
+  });
+});
+
+suite('gr-checks-run test', () => {
+  let element: GrChecksRun;
+
+  setup(async () => {
+    element = await fixture<GrChecksRun>(html`<gr-checks-run></gr-checks-run>`);
+    const getChecksModel = resolve(element, checksModelToken);
+    setAllFakeRuns(getChecksModel());
+    await element.updateComplete;
+  });
+
+  test('renders loading', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ' <div class="chip">Loading ...</div> '
+    );
+  });
+
+  test('renders fakeRun0', async () => {
+    element.shouldRender = true;
+    element.run = fakeRun0;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="chip error" tabindex="0">
+          <div class="left" tabindex="0">
+            <gr-hovercard-run> </gr-hovercard-run>
+            <gr-icon class="error" filled="" icon="error"> </gr-icon>
+            <span class="name">
+              FAKE Error Finder Finder Finder Finder Finder Finder Finder
+            </span>
+          </div>
+          <div class="middle">
+            <gr-checks-attempt> </gr-checks-attempt>
+          </div>
+          <div class="right"></div>
+          </div>
+          <div class="attemptDetails" hidden="">
+            <div class="attemptDetail">
+              <input
+                id="attempt-latest"
+                name="fakeerrorfinderfinderfinderfinderfinderfinderfinder-attempt-choice"
+                type="radio"
+              />
+              <gr-icon icon=""> </gr-icon>
+              <label for="attempt-latest"> Latest Attempt </label>
+            </div>
+            <div class="attemptDetail">
+              <input
+                id="attempt-all"
+                name="fakeerrorfinderfinderfinderfinderfinderfinderfinder-attempt-choice"
+                type="radio"
+              />
+              <gr-icon icon=""> </gr-icon>
+              <label for="attempt-all"> All Attempts </label>
+            </div>
+          </div>
+        </div>
+      `
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
index dbc2bec..6284892 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
@@ -1,40 +1,23 @@
 /**
  * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {css} from 'lit';
 
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
-
 const $_documentContainer = document.createElement('template');
 
 export const checksStyles = css`
-  iron-icon.error {
+  gr-icon.error {
     color: var(--error-foreground);
   }
-  iron-icon.warning {
+  gr-icon.warning {
     color: var(--warning-foreground);
   }
-  iron-icon.info-outline {
+  gr-icon.info {
     color: var(--info-foreground);
   }
-  iron-icon.check-circle-outline {
+  gr-icon.check_circle {
     color: var(--success-foreground);
   }
 `;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index ed6117a..c1bcdc1 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -1,38 +1,26 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {LitElement, css, html, PropertyValues} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
-import {Action} from '../../api/checks';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {
   CheckResult,
   CheckRun,
-  allResultsSelected$,
-  checksSelectedPatchsetNumber$,
-  allRunsSelectedPatchset$,
-} from '../../services/checks/checks-model';
+  checksModelToken,
+} from '../../models/checks/checks-model';
+import {changeModelToken} from '../../models/change/change-model';
 import './gr-checks-runs';
 import './gr-checks-results';
-import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
-import {ActionTriggeredEvent} from '../../services/checks/checks-util';
-import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
-import {ChecksTabState} from '../../types/events';
-import {appContext} from '../../services/app-context';
+import {TabState} from '../../types/events';
+import {getAppContext} from '../../services/app-context';
 import {subscribe} from '../lit/subscription-controller';
+import {Deduping} from '../../api/reporting';
+import {Interaction} from '../../constants/reporting';
+import {resolve} from '../../models/dependency';
+import {GrChecksRuns} from './gr-checks-runs';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -40,6 +28,9 @@
  */
 @customElement('gr-checks-tab')
 export class GrChecksTab extends LitElement {
+  @query('.runs')
+  checksRuns?: GrChecksRuns;
+
   @state()
   runs: CheckRun[] = [];
 
@@ -47,7 +38,7 @@
   results: CheckResult[] = [];
 
   @property({type: Object})
-  tabState?: ChecksTabState;
+  tabState?: TabState;
 
   @state()
   checksPatchsetNumber: PatchSetNumber | undefined = undefined;
@@ -58,33 +49,49 @@
   @state()
   changeNum: NumericChangeId | undefined = undefined;
 
-  @state()
-  selectedRuns: string[] = [];
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @state()
-  selectedAttempts: Map<string, number | undefined> = new Map<
-    string,
-    number | undefined
-  >();
+  private readonly getChecksModel = resolve(this, checksModelToken);
 
-  private readonly checksService = appContext.checksService;
+  private readonly reporting = getAppContext().reportingService;
+
+  private offsetWidthBefore = 0;
 
   constructor() {
     super();
-    subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
-    subscribe(this, allResultsSelected$, x => (this.results = x));
     subscribe(
       this,
-      checksSelectedPatchsetNumber$,
+      () => this.getChecksModel().allRunsSelectedPatchset$,
+      x => (this.runs = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().allResultsSelected$,
+      x => (this.results = x)
+    );
+    subscribe(
+      this,
+      () => this.getChecksModel().checksSelectedPatchsetNumber$,
       x => (this.checksPatchsetNumber = x)
     );
-    subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
-    subscribe(this, changeNum$, x => (this.changeNum = x));
-
-    this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
-      this.handleActionTriggered(e.detail.action, e.detail.run)
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchsetNumber = x)
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    const observer = new ResizeObserver(() => {
+      if (!this.checksRuns) return;
+      // The appearance of a scroll bar (<40px width) should not trigger.
+      if (Math.abs(this.offsetWidth - this.offsetWidthBefore) < 40) return;
+      this.offsetWidthBefore = this.offsetWidth;
+      this.checksRuns.collapsed = this.offsetWidth < 1200;
+    });
+    observer.observe(this);
   }
 
   static override get styles() {
@@ -98,78 +105,39 @@
       .runs {
         min-height: 400px;
         border-right: 1px solid var(--border-color);
+        flex: 0 0 auto;
       }
       .results {
-        flex-grow: 1;
+        flex: 1 1 auto;
       }
     `;
   }
 
   override render() {
+    this.reporting.reportInteraction(
+      Interaction.CHECKS_TAB_RENDERED,
+      {
+        checkName: this.tabState?.checksTab?.checkName,
+        statusOrCategory: this.tabState?.checksTab?.statusOrCategory,
+      },
+      {deduping: Deduping.DETAILS_ONCE_PER_CHANGE}
+    );
     return html`
       <div class="container">
         <gr-checks-runs
           class="runs"
-          ?collapsed="${this.offsetWidth < 1000}"
-          .runs="${this.runs}"
-          .selectedRuns="${this.selectedRuns}"
-          .selectedAttempts="${this.selectedAttempts}"
-          .tabState="${this.tabState}"
-          @run-selected="${this.handleRunSelected}"
-          @attempt-selected="${this.handleAttemptSelected}"
+          ?collapsed=${this.offsetWidth < 1000}
+          .runs=${this.runs}
+          .tabState=${this.tabState?.checksTab}
         ></gr-checks-runs>
         <gr-checks-results
           class="results"
-          .tabState="${this.tabState}"
-          .runs="${this.runs}"
-          .selectedRuns="${this.selectedRuns}"
-          .selectedAttempts="${this.selectedAttempts}"
-          @run-selected="${this.handleRunSelected}"
+          .tabState=${this.tabState?.checksTab}
+          .runs=${this.runs}
         ></gr-checks-results>
       </div>
     `;
   }
-
-  protected override updated(changedProperties: PropertyValues) {
-    super.updated(changedProperties);
-    if (changedProperties.has('tabState')) {
-      if (this.tabState) {
-        this.selectedRuns = [];
-      }
-    }
-  }
-
-  handleActionTriggered(action: Action, run?: CheckRun) {
-    this.checksService.triggerAction(action, run);
-  }
-
-  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 {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
index 85183ed..f8e4e69 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -1,26 +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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
+import {html} from 'lit';
+import './gr-checks-tab';
 import {GrChecksTab} from './gr-checks-tab';
+import {fixture, assert} from '@open-wc/testing';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {resolve} from '../../models/dependency';
 
 suite('gr-checks-tab test', () => {
-  test('is defined', () => {
-    const el = document.createElement('gr-checks-tab');
-    assert.instanceOf(el, GrChecksTab);
+  let element: GrChecksTab;
+
+  setup(async () => {
+    element = await fixture<GrChecksTab>(html`<gr-checks-tab></gr-checks-tab>`);
+    const getChecksModel = resolve(element, checksModelToken);
+    setAllFakeRuns(getChecksModel());
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    assert.equal(element.runs.length, 44);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <gr-checks-runs class="runs" collapsed=""> </gr-checks-runs>
+          <gr-checks-results class="results"> </gr-checks-results>
+        </div>
+      `
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index e9bbb22..c7477c4 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -1,50 +1,16 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {CheckRun, RunResult} 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,
-    })
-  );
-}
+import {CheckRun, RunResult} from '../../models/checks/checks-model';
+import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  LATEST_ATTEMPT,
+} from '../../models/checks/checks-util';
 
 export interface RunSelectedEventDetail {
-  reset: boolean;
   checkName?: string;
 }
 
@@ -66,24 +32,13 @@
   );
 }
 
-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>,
+  selectedAttempt: AttemptChoice,
   run: CheckRun
 ) {
-  const selected = selectedAttempts.get(run.checkName);
-  return (
-    (selected === undefined && run.isLatestAttempt) || selected === run.attempt
-  );
+  if (selectedAttempt === LATEST_ATTEMPT) return run.isLatestAttempt;
+  if (selectedAttempt === ALL_ATTEMPTS) return true;
+  return selectedAttempt === (run.attempt ?? 0);
 }
 
 export function matches(result: RunResult, regExp: RegExp) {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts
index 698a4a1..a09f0ec 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts
@@ -1,23 +1,13 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import {createRunResult} from '../../test/test-data-generators';
 import {matches} from './gr-checks-util';
-import {RunResult} from '../../services/checks/checks-model';
+import {RunResult} from '../../models/checks/checks-model';
+import {assert} from '@open-wc/testing';
 
 suite('gr-checks-util test', () => {
   test('regexp filter matching results', () => {
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
new file mode 100644
index 0000000..efc6efe
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -0,0 +1,280 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '@polymer/paper-tooltip/paper-tooltip';
+import '../shared/gr-icon/gr-icon';
+import {LitElement, css, html, PropertyValues, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {RunResult} from '../../models/checks/checks-model';
+import {
+  createFixAction,
+  createPleaseFixComment,
+  iconFor,
+} from '../../models/checks/checks-util';
+import {modifierPressed} from '../../utils/dom-util';
+import './gr-checks-results';
+import './gr-hovercard-run';
+import {fontStyles} from '../../styles/gr-font-styles';
+import {Action} from '../../api/checks';
+import {assertIsDefined} from '../../utils/common-util';
+import {resolve} from '../../models/dependency';
+import {commentsModelToken} from '../../models/comments/comments-model';
+import {subscribe} from '../lit/subscription-controller';
+import {changeModelToken} from '../../models/change/change-model';
+
+@customElement('gr-diff-check-result')
+export class GrDiffCheckResult extends LitElement {
+  @property({attribute: false})
+  result?: RunResult;
+
+  /**
+   * This is required by <gr-diff> as an identifier for this component. It will
+   * be set to the internalResultId of the check result.
+   */
+  @property({type: String})
+  rootId?: string;
+
+  @state()
+  isExpanded = false;
+
+  @state()
+  isExpandable = false;
+
+  @state()
+  isOwner = false;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  static override get styles() {
+    return [
+      fontStyles,
+      css`
+        .container {
+          font-family: var(--font-family);
+          margin: 0 var(--spacing-s) var(--spacing-s);
+          background-color: var(--unresolved-comment-background-color);
+          box-shadow: var(--elevation-level-2);
+          border-radius: var(--border-radius);
+          padding: var(--spacing-xs) var(--spacing-m);
+          border: 1px solid #888;
+        }
+        .container.info {
+          border-color: var(--info-foreground);
+          background-color: var(--info-background);
+        }
+        .container.info gr-icon {
+          color: var(--info-foreground);
+        }
+        .container.warning {
+          border-color: var(--warning-foreground);
+          background-color: var(--warning-background);
+        }
+        .container.warning gr-icon {
+          color: var(--warning-foreground);
+        }
+        .container.error {
+          border-color: var(--error-foreground);
+          background-color: var(--error-background);
+        }
+        .container.error gr-icon {
+          color: var(--error-foreground);
+        }
+        .header {
+          display: flex;
+          white-space: nowrap;
+          cursor: pointer;
+        }
+        .icon {
+          margin-right: var(--spacing-s);
+        }
+        .name {
+          margin-right: var(--spacing-m);
+        }
+        .summary {
+          font-weight: var(--font-weight-bold);
+          flex-shrink: 1;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          margin-right: var(--spacing-s);
+        }
+        .message {
+          flex-grow: 1;
+          /* Looks a bit unexpected, 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;
+          color: var(--deemphasized-text-color);
+        }
+        gr-result-expanded {
+          display: block;
+          margin-top: var(--spacing-m);
+        }
+        gr-icon {
+          font-size: var(--line-height-normal);
+        }
+        .icon gr-icon {
+          font-size: calc(var(--line-height-normal) - 4px);
+          position: relative;
+          top: 2px;
+        }
+        div.actions {
+          display: flex;
+          justify-content: flex-end;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().isOwner$,
+      x => (this.isOwner = x)
+    );
+  }
+
+  override render() {
+    if (!this.result) return;
+    const cat = this.result.category.toLowerCase();
+    const icon = iconFor(this.result.category);
+    return html`
+      <div class="${cat} container font-normal">
+        <div class="header" @click=${this.toggleExpandedClick}>
+          <div class="icon">
+            <gr-icon icon=${icon.name} ?filled=${icon.filled}></gr-icon>
+          </div>
+          <div class="name">
+            <gr-hovercard-run .run=${this.result}></gr-hovercard-run>
+            <div
+              class="name"
+              role="button"
+              tabindex="0"
+              @keydown=${this.toggleExpandedPress}
+            >
+              ${this.result.checkName}
+            </div>
+          </div>
+          <!-- The &nbsp; is for being able to shrink a tiny amount without
+                the text itself getting shrunk with an ellipsis. -->
+          <div class="summary">${this.result.summary}&nbsp;</div>
+          <div class="message">
+            ${this.isExpanded ? nothing : this.result.message}
+          </div>
+          ${this.renderToggle()}
+        </div>
+        <div class="details">
+          ${this.renderExpanded()}${this.renderActions()}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderToggle() {
+    if (!this.isExpandable) return nothing;
+    return html`
+      <div
+        class="show-hide"
+        role="switch"
+        tabindex="0"
+        aria-checked=${this.isExpanded ? 'true' : 'false'}
+        aria-label=${this.isExpanded
+          ? 'Collapse result row'
+          : 'Expand result row'}
+        @keydown=${this.toggleExpandedPress}
+      >
+        <gr-icon
+          icon=${this.isExpanded ? 'expand_less' : 'expand_more'}
+        ></gr-icon>
+      </div>
+    `;
+  }
+
+  private renderExpanded() {
+    if (!this.isExpanded) return nothing;
+    return html`
+      <gr-result-expanded
+        hidecodepointers
+        .result=${this.result}
+      ></gr-result-expanded>
+    `;
+  }
+
+  private renderActions() {
+    if (!this.isExpanded) return nothing;
+    return html`<div class="actions">
+      ${this.renderPleaseFixButton()}${this.renderShowFixButton()}
+    </div>`;
+  }
+
+  private renderPleaseFixButton() {
+    if (this.isOwner) return nothing;
+    const action: Action = {
+      name: 'Please Fix',
+      callback: () => {
+        assertIsDefined(this.result, 'result');
+        this.getCommentsModel().saveDraft(createPleaseFixComment(this.result));
+        return undefined;
+      },
+    };
+    return html`
+      <gr-checks-action
+        id="please-fix"
+        context="diff-fix"
+        .action=${action}
+      ></gr-checks-action>
+    `;
+  }
+
+  private renderShowFixButton() {
+    const action = createFixAction(this, this.result);
+    if (!action) return nothing;
+    return html`
+      <gr-checks-action
+        id="show-fix"
+        context="diff-fix"
+        .action=${action}
+      ></gr-checks-action>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('result')) {
+      this.isExpandable = !!this.result?.summary && !!this.result?.message;
+    }
+  }
+
+  private toggleExpandedClick(e: MouseEvent) {
+    if (!this.isExpandable) return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.toggleExpanded();
+  }
+
+  private toggleExpandedPress(e: KeyboardEvent) {
+    if (!this.isExpandable) return;
+    if (modifierPressed(e)) return;
+    // Only react to `return` and `space`.
+    if (e.key !== 'Enter' && e.key !== ' ') return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.toggleExpanded();
+  }
+
+  private toggleExpanded() {
+    if (!this.isExpandable) return;
+    this.isExpanded = !this.isExpanded;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-check-result': GrDiffCheckResult;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
new file mode 100644
index 0000000..0377e0e
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -0,0 +1,80 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {fakeRun1} from '../../models/checks/checks-fakes';
+import {RunResult} from '../../models/checks/checks-model';
+import '../../test/common-test-setup';
+import {queryAndAssert} from '../../utils/common-util';
+import './gr-diff-check-result';
+import {GrDiffCheckResult} from './gr-diff-check-result';
+
+suite('gr-diff-check-result tests', () => {
+  let element: GrDiffCheckResult;
+
+  setup(async () => {
+    element = document.createElement('gr-diff-check-result');
+    document.body.appendChild(element);
+    await element.updateComplete;
+  });
+
+  teardown(() => {
+    if (element) element.remove();
+  });
+
+  test('renders', async () => {
+    element.result = {...fakeRun1, ...fakeRun1.results?.[0]} as RunResult;
+    await element.updateComplete;
+    // cannot use /* HTML */ because formatted long message will not match.
+    assert.shadowDom.equal(
+      element,
+      `
+      <div class="container font-normal warning">
+        <div class="header">
+          <div class="icon">
+            <gr-icon icon="warning" filled></gr-icon>
+          </div>
+          <div class="name">
+            <gr-hovercard-run> </gr-hovercard-run>
+            <div class="name" role="button" tabindex="0">FAKE Super Check</div>
+          </div>
+          <div class="summary">We think that you could improve this.</div>
+          <div class="message">
+            There is a lot to be said. A lot. I say, a lot.
+                So please keep reading.
+          </div>
+        </div>
+        <div class="details"></div>
+      </div>
+    `
+    );
+  });
+
+  test('renders expanded', async () => {
+    element.result = {...fakeRun1, ...fakeRun1.results?.[2]} as RunResult;
+    element.isExpanded = true;
+    await element.updateComplete;
+
+    const details = queryAndAssert(element, 'div.details');
+    assert.dom.equal(
+      details,
+      /* HTML */ `
+        <div class="details">
+          <gr-result-expanded hidecodepointers=""></gr-result-expanded>
+          <div class="actions">
+            <gr-checks-action
+              id="please-fix"
+              context="diff-fix"
+            ></gr-checks-action>
+            <gr-checks-action
+              id="show-fix"
+              context="diff-fix"
+            ></gr-checks-action>
+          </div>
+        </div>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index 95b7157..9aa837d 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -1,29 +1,20 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import '../shared/gr-icon/gr-icon';
 import {fontStyles} from '../../styles/gr-font-styles';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import './gr-checks-action';
-import {CheckRun} from '../../services/checks/checks-model';
+import {CheckRun} from '../../models/checks/checks-model';
 import {
   AttemptDetail,
+  ChecksIcon,
   iconFor,
   runActions,
   worstCategory,
-} from '../../services/checks/checks-util';
+} from '../../models/checks/checks-util';
 import {durationString, fromNow} from '../../utils/date-util';
 import {RunStatus} from '../../api/checks';
 import {ordinal} from '../../utils/string-util';
@@ -79,38 +70,34 @@
         div.sectionIcon {
           flex: 0 0 30px;
         }
-        div.chip iron-icon {
-          width: 16px;
-          height: 16px;
+        div.chip gr-icon {
+          font-size: 16px;
           /* Positioning of a 16px icon in the middle of a 20px line. */
           position: relative;
           top: 2px;
         }
-        div.sectionIcon iron-icon {
+        div.sectionIcon gr-icon {
           position: relative;
           top: 2px;
-          width: 20px;
-          height: 20px;
+          font-size: 20px;
         }
-        div.sectionIcon iron-icon.small {
+        div.sectionIcon gr-icon.small {
           position: relative;
           top: 6px;
-          width: 16px;
-          height: 16px;
+          font-size: 16px;
         }
-        div.sectionContent iron-icon.link {
+        div.sectionContent gr-icon.link {
           color: var(--link-color);
         }
-        div.sectionContent .attemptIcon iron-icon,
-        div.sectionContent iron-icon.small {
-          width: 16px;
-          height: 16px;
+        div.sectionContent .attemptIcon gr-icon,
+        div.sectionContent gr-icon.small {
+          font-size: 16px;
           margin-right: var(--spacing-s);
           /* Positioning of a 16px icon in the middle of a 20px line. */
           position: relative;
           top: 2px;
         }
-        div.sectionContent .attemptIcon iron-icon {
+        div.sectionContent .attemptIcon gr-icon {
           margin-right: 0;
         }
         .attemptIcon,
@@ -133,22 +120,30 @@
   override render() {
     if (!this.run) return '';
     const icon = this.computeIcon();
+    const chipIcon = this.computeChipIcon();
     return html`
       <div id="container" role="tooltip" tabindex="-1">
         <div class="section">
           <div
-            ?hidden="${!this.run || this.run.status === RunStatus.RUNNABLE}"
+            ?hidden=${!this.run || this.run.status === RunStatus.RUNNABLE}
             class="chipRow"
           >
             <div class="chip">
-              <iron-icon icon="gr-icons:${this.computeChipIcon()}"></iron-icon>
+              <gr-icon
+                icon=${chipIcon.name}
+                ?filled=${chipIcon.filled}
+              ></gr-icon>
               <span>${this.run.status}</span>
             </div>
           </div>
         </div>
         <div class="section">
-          <div class="sectionIcon" ?hidden="${icon.length === 0}">
-            <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          <div class="sectionIcon" ?hidden=${icon.name.length === 0}>
+            <gr-icon
+              icon=${icon.name}
+              class=${icon.name}
+              ?filled=${icon.filled}
+            ></gr-icon>
           </div>
           <div class="sectionContent">
             <h3 class="name heading-3">
@@ -170,19 +165,19 @@
     return html`
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:info-outline"></iron-icon>
+          <gr-icon icon="info" class="small"></gr-icon>
         </div>
         <div class="sectionContent">
           ${this.run.statusLink
             ? html` <div class="row">
                 <div class="title">Status</div>
                 <div>
-                  <a href="${this.run.statusLink}" target="_blank"
-                    ><iron-icon
+                  <a href=${this.run.statusLink} target="_blank"
+                    ><gr-icon
+                      icon="open_in_new"
                       aria-label="external link to check status"
                       class="small link"
-                      icon="gr-icons:launch"
-                    ></iron-icon
+                    ></gr-icon
                     >${this.computeHostName(this.run.statusLink)}
                   </a>
                 </div>
@@ -205,7 +200,7 @@
     return html`
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:arrow-forward"></iron-icon>
+          <gr-icon icon="arrow_forward" class="small"></gr-icon>
         </div>
         <div class="sectionContent">
           <div class="attempts row">
@@ -218,15 +213,18 @@
   }
 
   private renderAttempt(attempt: AttemptDetail) {
+    const attemptNumber = attempt.attempt;
+    const icon = attempt.icon ?? {name: ''};
+    if (attemptNumber !== undefined && typeof attemptNumber !== 'number') {
+      return;
+    }
     return html`
       <div>
         <div class="attemptIcon">
-          <iron-icon
-            class="${attempt.icon}"
-            icon="gr-icons:${attempt.icon}"
-          ></iron-icon>
+          <gr-icon class=${icon.name} icon=${icon.name} ?filled=${icon.filled}>
+          </gr-icon>
         </div>
-        <div class="attemptNumber">${ordinal(attempt.attempt)}</div>
+        <div class="attemptNumber">${ordinal(attemptNumber)}</div>
       </div>
     `;
   }
@@ -240,28 +238,62 @@
     )
       return;
 
+    const scheduled =
+      this.run.scheduledTimestamp && !this.run.startedTimestamp
+        ? html`<div class="row">
+            <div class="title">Scheduled</div>
+            <div>${fromNow(this.run.scheduledTimestamp)}</div>
+          </div>`
+        : '';
+
+    const started = this.run.startedTimestamp
+      ? html`<div class="row">
+          <div class="title">Started</div>
+          <div>${fromNow(this.run.startedTimestamp)}</div>
+        </div>`
+      : '';
+
+    const finished =
+      this.run.finishedTimestamp && this.run.status === RunStatus.COMPLETED
+        ? html`<div class="row">
+            <div class="title">Ended</div>
+            <div>${fromNow(this.run.finishedTimestamp)}</div>
+          </div>`
+        : '';
+
+    const completed =
+      this.run.startedTimestamp &&
+      this.run.finishedTimestamp &&
+      this.run.status === RunStatus.COMPLETED
+        ? html`<div class="row">
+            <div class="title">Completion</div>
+            <div>
+              ${durationString(
+                this.run.startedTimestamp,
+                this.run.finishedTimestamp,
+                true
+              )}
+            </div>
+          </div>`
+        : '';
+
+    const eta =
+      this.run.finishedTimestamp && this.run.status === RunStatus.RUNNING
+        ? html`<div class="row">
+            <div class="title">ETA</div>
+            <div>
+              ${durationString(new Date(), this.run.finishedTimestamp, true)}
+            </div>
+          </div>`
+        : '';
+
     return html`
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:schedule"></iron-icon>
+          <gr-icon icon="schedule" class="small"></gr-icon>
         </div>
         <div class="sectionContent">
-          <div ?hidden="${this.hideScheduled()}" class="row">
-            <div class="title">Scheduled</div>
-            <div>${this.computeDuration(this.run.scheduledTimestamp)}</div>
-          </div>
-          <div ?hidden="${!this.run.startedTimestamp}" class="row">
-            <div class="title">Started</div>
-            <div>${this.computeDuration(this.run.startedTimestamp)}</div>
-          </div>
-          <div ?hidden="${!this.run.finishedTimestamp}" class="row">
-            <div class="title">Ended</div>
-            <div>${this.computeDuration(this.run.finishedTimestamp)}</div>
-          </div>
-          <div ?hidden="${this.hideCompletion()}" class="row">
-            <div class="title">Completion</div>
-            <div>${this.computeCompletionDuration()}</div>
-          </div>
+          ${scheduled} ${started} ${finished} ${completed} ${eta}
         </div>
       </div>
     `;
@@ -273,7 +305,7 @@
     return html`
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="small" icon="gr-icons:link"></iron-icon>
+          <gr-icon icon="link" class="small"></gr-icon>
         </div>
         <div class="sectionContent">
           ${this.run.checkDescription
@@ -286,12 +318,12 @@
             ? html` <div class="row">
                 <div class="title">Documentation</div>
                 <div>
-                  <a href="${this.run.checkLink}" target="_blank"
-                    ><iron-icon
+                  <a href=${this.run.checkLink} target="_blank"
+                    ><gr-icon
+                      icon="open_in_new"
                       aria-label="external link to check documentation"
                       class="small link"
-                      icon="gr-icons:launch"
-                    ></iron-icon
+                    ></gr-icon
                     >${this.computeHostName(this.run.checkLink)}
                   </a>
                 </div>
@@ -309,47 +341,44 @@
         html`
           <div class="action">
             <gr-checks-action
-              .eventTarget="${this._target}"
-              .action="${action}"
+              context="hovercard"
+              .eventTarget=${this._target}
+              .action=${action}
             ></gr-checks-action>
           </div>
         `
     );
   }
 
-  computeIcon() {
-    if (!this.run) return '';
+  computeIcon(): ChecksIcon {
+    if (!this.run) return {name: ''};
     const category = worstCategory(this.run);
     if (category) return iconFor(category);
     return this.run.status === RunStatus.COMPLETED
       ? iconFor(RunStatus.COMPLETED)
-      : '';
+      : {name: ''};
   }
 
   computeAttempts(): AttemptDetail[] {
-    const details = this.run?.attemptDetails ?? [];
-    const more =
-      details.length > 7 ? [{icon: 'more-horiz', attempt: undefined}] : [];
+    const details: AttemptDetail[] = this.run?.attemptDetails ?? [];
+    const more: AttemptDetail[] =
+      details.length > 7
+        ? [{icon: {name: 'more_horiz'}, attempt: undefined}]
+        : [];
     return [...more, ...details.slice(-7)];
   }
 
-  private computeChipIcon() {
-    if (this.run?.status === RunStatus.COMPLETED) return 'check';
-    if (this.run?.status === RunStatus.RUNNING) return 'timelapse';
-    return '';
-  }
-
-  private computeCompletionDuration() {
-    if (!this.run?.finishedTimestamp || !this.run?.startedTimestamp) return '';
-    return durationString(
-      this.run.startedTimestamp,
-      this.run.finishedTimestamp,
-      true
-    );
-  }
-
-  private computeDuration(date?: Date) {
-    return date ? fromNow(date) : '';
+  private computeChipIcon(): ChecksIcon {
+    if (this.run?.status === RunStatus.COMPLETED) {
+      return {name: 'check'};
+    }
+    if (this.run?.status === RunStatus.RUNNING) {
+      return iconFor(RunStatus.RUNNING);
+    }
+    if (this.run?.status === RunStatus.SCHEDULED) {
+      return iconFor(RunStatus.SCHEDULED);
+    }
+    return {name: ''};
   }
 
   private computeHostName(link?: string) {
@@ -360,14 +389,6 @@
     const attemptCount = this.run?.attemptDetails?.length;
     return attemptCount === undefined || attemptCount < 2;
   }
-
-  private hideScheduled() {
-    return !this.run?.scheduledTimestamp || !!this.run?.startedTimestamp;
-  }
-
-  private hideCompletion() {
-    return !this.run?.startedTimestamp || !this.run?.finishedTimestamp;
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
index 352219a..4ae2a46 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
@@ -1,42 +1,194 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import './gr-hovercard-run';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrHovercardRun} from './gr-hovercard-run';
-
-const basicFixture = fixtureFromTemplate(html`
-  <gr-hovercard-run class="hovered"></gr-hovercard-run>
-`);
+import {fakeRun4Att, fakeRun4_4} from '../../models/checks/checks-fakes';
+import {createAttemptMap} from '../../models/checks/checks-util';
+import {CheckRun} from '../../models/checks/checks-model';
 
 suite('gr-hovercard-run tests', () => {
   let element: GrHovercardRun;
 
   setup(async () => {
-    element = basicFixture.instantiate() as GrHovercardRun;
-    await flush();
+    const fakeNow = new Date('Sep 26 2022 12:00:00');
+    sinon.useFakeTimers(fakeNow);
+    element = await fixture<GrHovercardRun>(html`
+      <gr-hovercard-run class="hovered"></gr-hovercard-run>
+    `);
   });
 
   teardown(() => {
-    element.hide(new MouseEvent('click'));
+    element.mouseHide(new MouseEvent('click'));
   });
 
-  test('hovercard is shown', () => {
-    assert.equal(element.computeIcon(), '');
+  test('render fakeRun4', async () => {
+    const attemptMap = createAttemptMap(fakeRun4Att);
+    const attemptDetails = attemptMap.get(fakeRun4_4.checkName)!.attempts;
+    const run: CheckRun = {...fakeRun4_4, attemptDetails};
+    element.run = run;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <div class="section">
+            <div class="chipRow">
+              <div class="chip">
+                <gr-icon icon="check"></gr-icon>
+                <span> COMPLETED </span>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="info" icon="info"></gr-icon>
+            </div>
+            <div class="sectionContent">
+              <h3 class="heading-3 name">
+                <span> FAKE Elimination Long Long Long Long Long </span>
+              </h3>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="info"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Status</div>
+                <div>
+                  <a href="https://www.google.com" target="_blank">
+                    <gr-icon
+                      aria-label="external link to check status"
+                      class="link small"
+                      icon="open_in_new"
+                    >
+                    </gr-icon>
+                    www.google.com
+                  </a>
+                </div>
+              </div>
+              <div class="row">
+                <div class="title">Message</div>
+                <div>Everything was eliminated already.</div>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="arrow_forward"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="attempts row">
+                <div class="title">Attempt</div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="more_horiz" icon="more_horiz"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber"></div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="error" filled="" icon="error"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">34th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="check_circle" icon="check_circle">
+                    </gr-icon>
+                  </div>
+                  <div class="attemptNumber">35th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="error" filled="" icon="error"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">36th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="check_circle" icon="check_circle">
+                    </gr-icon>
+                  </div>
+                  <div class="attemptNumber">37th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="error" filled="" icon="error"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">38th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="check_circle" icon="check_circle">
+                    </gr-icon>
+                  </div>
+                  <div class="attemptNumber">39th</div>
+                </div>
+                <div>
+                  <div class="attemptIcon">
+                    <gr-icon class="info" icon="info"> </gr-icon>
+                  </div>
+                  <div class="attemptNumber">40th</div>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="schedule"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Started</div>
+                <div>1 year 6 m ago</div>
+              </div>
+              <div class="row">
+                <div class="title">Ended</div>
+                <div>1 year 6 m ago</div>
+              </div>
+              <div class="row">
+                <div class="title">Completion</div>
+                <div>1 minute</div>
+              </div>
+            </div>
+          </div>
+          <div class="section">
+            <div class="sectionIcon">
+              <gr-icon class="small" icon="link"> </gr-icon>
+            </div>
+            <div class="sectionContent">
+              <div class="row">
+                <div class="title">Description</div>
+                <div>Shows you the possible eliminations.</div>
+              </div>
+              <div class="row">
+                <div class="title">Documentation</div>
+                <div>
+                  <a href="https://www.google.com" target="_blank">
+                    <gr-icon
+                      aria-label="external link to check documentation"
+                      class="link small"
+                      icon="open_in_new"
+                    >
+                    </gr-icon>
+                    www.google.com
+                  </a>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div class="action">
+            <gr-checks-action context="hovercard"> </gr-checks-action>
+          </div>
+        </div>
+      `
+    );
   });
 });
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 03fb4d5..b46d2b9 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
@@ -1,24 +1,13 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-avatar/gr-avatar';
 import {getUserName} from '../../../utils/display-name-util';
 import {AccountInfo, ServerInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
 import {
   DropdownContent,
@@ -26,7 +15,7 @@
 } from '../../shared/gr-dropdown/gr-dropdown';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
 
@@ -53,12 +42,12 @@
   @property({type: String})
   _switchAccountUrl = '';
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
     this.handleLocationChange();
-    window.addEventListener('location-change', this.handleLocationChange);
+    document.addEventListener('location-change', this.handleLocationChange);
     this.restApiService.getConfig().then(cfg => {
       this.config = cfg;
 
@@ -72,7 +61,7 @@
   }
 
   override disconnectedCallback() {
-    window.removeEventListener('location-change', this.handleLocationChange);
+    document.removeEventListener('location-change', this.handleLocationChange);
     super.disconnectedCallback();
   }
 
@@ -97,16 +86,16 @@
   override render() {
     return html`<gr-dropdown
       link=""
-      .items="${this.links}"
-      .topContent="${this.topContent}"
+      .items=${this.links}
+      .topContent=${this.topContent}
       @tap-item-shortcuts=${this._handleShortcutsTap}
       .horizontalAlign=${'right'}
     >
-      <span ?hidden="${this._hasAvatars}"
+      <span ?hidden=${this._hasAvatars}
         >${this._accountName(this.account)}</span
       >
       <gr-avatar
-        .account="${this.account}"
+        .account=${this.account}
         ?hidden=${!this._hasAvatars}
         .imageSize=${56}
         aria-label="Account avatar"
@@ -123,7 +112,6 @@
   }
 
   _getLinks(switchAccountUrl?: string, path?: string) {
-    // Polymer 2: check for undefined
     if (switchAccountUrl === undefined || path === undefined) {
       return undefined;
     }
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
index 88dccad..c224a6b 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
@@ -1,33 +1,35 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-account-dropdown';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrAccountDropdown} from './gr-account-dropdown';
 import {AccountInfo} from '../../../types/common';
 import {createServerInfo} from '../../../test/test-data-generators';
 
-const basicFixture = fixtureFromElement('gr-account-dropdown');
-
 suite('gr-account-dropdown tests', () => {
   let element: GrAccountDropdown;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-account-dropdown></gr-account-dropdown>`);
+  });
+
+  test('renders', async () => {
+    element.account = {name: 'John Doe', email: 'john@doe.com'} as AccountInfo;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dropdown link="">
+          <span>John Doe</span>
+          <gr-avatar aria-label="Account avatar" hidden=""> </gr-avatar>
+        </gr-dropdown>
+      `
+    );
   });
 
   test('account information', () => {
@@ -41,7 +43,7 @@
   test('test for account without a name', () => {
     element.account = {id: '0001'} as AccountInfo;
     assert.deepEqual(element.topContent, [
-      {text: 'Anonymous', bold: true},
+      {text: 'Name of user not set', bold: true},
       {text: ''},
     ]);
   });
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 63b5bc8..461781e 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
@@ -1,24 +1,12 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-error-dialog_html';
-import {customElement, property} from '@polymer/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -27,11 +15,7 @@
 }
 
 @customElement('gr-error-dialog')
-export class GrErrorDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrErrorDialog extends LitElement {
   /**
    * Fired when the dismiss button is pressed.
    *
@@ -47,7 +31,58 @@
   @property({type: Boolean})
   showSignInButton = false;
 
-  _handleConfirm() {
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .main {
+          max-height: 40em;
+          max-width: 60em;
+          overflow-y: auto;
+          white-space: pre-wrap;
+        }
+        @media screen and (max-width: 50em) {
+          .main {
+            max-height: none;
+            max-width: 50em;
+          }
+        }
+        .signInLink {
+          text-decoration: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-dialog
+        id="dialog"
+        cancel-label=""
+        @confirm=${() => {
+          this.handleConfirm();
+        }}
+        confirm-label="Dismiss"
+        confirm-on-enter=""
+      >
+        <div class="header" slot="header">An error occurred</div>
+        <div class="main" slot="main">${this.text}</div>
+        ${this.renderSignButton()}
+      </gr-dialog>
+    `;
+  }
+
+  private renderSignButton() {
+    if (!this.showSignInButton) return;
+
+    return html`
+      <gr-button id="signIn" class="signInLink" link="" slot="footer">
+        <a class="signInLink" href=${this.loginUrl}>Sign in</a>
+      </gr-button>
+    `;
+  }
+
+  private handleConfirm() {
     this.dispatchEvent(new CustomEvent('dismiss'));
   }
 }
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts
deleted file mode 100644
index 10476cd..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_html.ts
+++ /dev/null
@@ -1,56 +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">
-    .main {
-      max-height: 40em;
-      max-width: 60em;
-      overflow-y: auto;
-      white-space: pre-wrap;
-    }
-    @media screen and (max-width: 50em) {
-      .main {
-        max-height: none;
-        max-width: 50em;
-      }
-    }
-    .signInLink {
-      text-decoration: none;
-    }
-  </style>
-  <gr-dialog
-    id="dialog"
-    cancel-label=""
-    on-confirm="_handleConfirm"
-    confirm-label="Dismiss"
-    confirm-on-enter=""
-  >
-    <div class="header" slot="header">An error occurred</div>
-    <div class="main" slot="main">[[text]]</div>
-    <gr-button
-      id="signIn"
-      class$="signInLink"
-      hidden$="[[!showSignInButton]]"
-      link=""
-      slot="footer"
-    >
-      <a href$="[[loginUrl]]" class="signInLink">Sign in</a>
-    </gr-button>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
index c51988e..148b5c4 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
@@ -1,42 +1,44 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import {mockPromise, 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');
+import './gr-error-dialog';
 
 suite('gr-error-dialog tests', () => {
   let element: GrErrorDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(html`<gr-error-dialog></gr-error-dialog>`);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dialog
+          cancel-label=""
+          confirm-label="Dismiss"
+          confirm-on-enter=""
+          id="dialog"
+          role="dialog"
+        >
+          <div class="header" slot="header">An error occurred</div>
+          <div class="main" slot="main"></div>
+        </gr-dialog>
+      `
+    );
   });
 
   test('dismiss tap fires event', async () => {
     const dismissCalled = mockPromise();
     element.addEventListener('dismiss', () => dismissCalled.resolve());
-    MockInteractions.tap(
-      (queryAndAssert(element, '#dialog') as GrDialog).confirmButton!
-    );
+    queryAndAssert<GrDialog>(element, '#dialog').confirmButton!.click();
     await dismissCalled;
   });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 3b09d8c..1176ce3 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
@@ -1,36 +1,19 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-/* Import to get Gerrit interface */
-/* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
 import '../gr-error-dialog/gr-error-dialog';
 import '../../shared/gr-alert/gr-alert';
-import '../../shared/gr-overlay/gr-overlay';
-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 {getAppContext} from '../../../services/app-context';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {customElement, property} from '@polymer/decorators';
-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 {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
 import {AccountId} from '../../../types/common';
 import {
+  AuthErrorEvent,
   EventType,
   NetworkErrorEvent,
   ServerErrorEvent,
@@ -40,6 +23,11 @@
 import {windowLocationReload} from '../../../utils/dom-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {fireIronAnnounce} from '../../../utils/event-util';
+import {LitElement, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {authServiceToken} from '../../../services/gr-auth/gr-auth';
+import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 const HIDE_ALERT_TIMEOUT_MS = 10 * 1000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -47,10 +35,6 @@
 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,14 +55,6 @@
 
 export const __testOnly_ErrorType = ErrorType;
 
-export interface GrErrorManager {
-  $: {
-    noInteractionOverlay: GrOverlay;
-    errorDialog: GrErrorDialog;
-    errorOverlay: GrOverlay;
-  };
-}
-
 export function constructServerErrorMsg({
   errorText,
   status,
@@ -111,45 +87,38 @@
 }
 
 @customElement('gr-error-manager')
-export class GrErrorManager extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrErrorManager extends LitElement {
   /**
    * The ID of the account that was logged in when the app was launched. If
    * not set, then there was no account at launch.
    */
-  @property({type: Number})
-  knownAccountId?: AccountId | null;
+  @state() knownAccountId?: AccountId | null;
 
-  @property({type: Object})
-  _alertElement: GrAlert | null = null;
+  @state() alertElement: GrAlert | null = null;
 
-  @property({type: Number})
-  _hideAlertHandle: number | null = null;
+  @state() hideAlertHandle: number | null = null;
 
-  @property({type: Boolean})
-  _refreshingCredentials = false;
+  @state() refreshingCredentials = false;
+
+  @query('#signInModal') signInModal!: HTMLDialogElement;
+
+  @query('#errorDialog') errorDialog!: GrErrorDialog;
+
+  @query('#errorModal') errorModal!: HTMLDialogElement;
 
   /**
    * The time (in milliseconds) since the most recent credential check.
    */
-  @property({type: Number})
-  _lastCredentialCheck: number = Date.now();
+  @state() lastCredentialCheck: number = Date.now();
 
   @property({type: String})
   loginUrl = '/login';
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private readonly _authService = appContext.authService;
+  private readonly getAuthService = resolve(this, authServiceToken);
 
-  private readonly eventEmitter = appContext.eventEmitter;
-
-  _authErrorHandlerDeregistrationHook?: Function;
-
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   private checkLoggedInTask?: DelayedTask;
 
@@ -162,13 +131,7 @@
     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',
-      event => {
-        this._handleAuthError(event.message, event.action);
-      }
-    );
+    document.addEventListener('auth-error', this.handleAuthError);
 
     (
       IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
@@ -176,7 +139,7 @@
   }
 
   override disconnectedCallback() {
-    this._clearHideAlertHandle();
+    this.clearHideAlertHandle();
     document.removeEventListener(
       EventType.SERVER_ERROR,
       this.handleServerError
@@ -195,31 +158,62 @@
     document.removeEventListener('show-auth-required', this.handleAuthRequired);
     this.checkLoggedInTask?.cancel();
 
-    if (this._authErrorHandlerDeregistrationHook) {
-      this._authErrorHandlerDeregistrationHook();
-    }
+    document.removeEventListener('auth-error', this.handleAuthError);
     super.disconnectedCallback();
   }
 
-  _shouldSuppressError(msg: string) {
-    return (
-      msg.includes(TOO_MANY_FILES) ||
-      msg.includes(CONFLICTS_OPERATOR_IS_NOT_SUPPORTED)
-    );
+  static override get styles() {
+    return [modalStyles];
+  }
+
+  override render() {
+    return html`
+      <dialog id="errorModal" tabindex="-1">
+        <gr-error-dialog
+          id="errorDialog"
+          @dismiss=${() => this.errorModal.close()}
+          .loginUrl=${this.loginUrl}
+        ></gr-error-dialog>
+      </dialog>
+      <dialog
+        id="signInModal"
+        @keydown=${(e: KeyboardEvent) => {
+          if (e.key === 'Escape') {
+            e.preventDefault();
+            e.stopPropagation();
+          }
+        }}
+        tabindex="-1"
+      >
+        <gr-dialog
+          id="signInDialog"
+          confirm-label="Sign In"
+          @confirm=${() => {
+            this.createLoginPopup();
+          }}
+          cancel-label=""
+        >
+          <div class="header" slot="header">Refresh Credentials</div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
+
+  private shouldSuppressError(msg: string) {
+    return msg.includes(TOO_MANY_FILES);
   }
 
   private readonly handleAuthRequired = () => {
-    this._showAuthErrorAlert(
+    this.showAuthErrorAlert(
       'Log in is required to perform that action.',
       'Log in.'
     );
   };
 
-  _handleAuthError(msg: string, action: string) {
-    this.$.noInteractionOverlay.open().then(() => {
-      this._showAuthErrorAlert(msg, action);
-    });
-  }
+  private handleAuthError = (event: AuthErrorEvent) => {
+    this.signInModal.showModal();
+    this.showAuthErrorAlert(event.detail.message, event.detail.action);
+  };
 
   private readonly handleServerError = (e: ServerErrorEvent) => {
     const {request, response} = e.detail;
@@ -228,7 +222,7 @@
       const {status, statusText} = response;
       if (
         response.status === 403 &&
-        !this._authService.isAuthed &&
+        !this.getAuthService().isAuthed &&
         errorText === AUTHENTICATION_REQUIRED
       ) {
         // if not authed previously, this is trying to access auth required APIs
@@ -236,19 +230,19 @@
         this.handleAuthRequired();
       } else if (
         response.status === 403 &&
-        this._authService.isAuthed &&
+        this.getAuthService().isAuthed &&
         errorText === AUTHENTICATION_REQUIRED
       ) {
         // The app was logged at one point and is now getting auth errors.
         // This indicates the auth token may no longer valid.
         // Re-check on auth
-        this._authService.clearCache();
+        this.getAuthService().clearCache();
         this.restApiService.getLoggedIn();
-      } else if (!this._shouldSuppressError(errorText)) {
+      } else if (!this.shouldSuppressError(errorText)) {
         const trace =
           response.headers && response.headers.get('X-Gerrit-Trace');
         if (response.status === 404) {
-          this._showNotFoundMessageWithTip({
+          this.showNotFoundMessageWithTip({
             status,
             statusText,
             errorText,
@@ -256,9 +250,9 @@
             trace,
           });
         } else if (response.status === 429) {
-          this._showQuotaExceeded({status, statusText});
+          this.showQuotaExceeded({status, statusText});
         } else {
-          this._showErrorDialog(
+          this.showErrorDialog(
             constructServerErrorMsg({
               status,
               statusText,
@@ -269,11 +263,11 @@
           );
         }
       }
-      this.reporting.error(new Error(`Server error: ${errorText}`));
+      this.reporting.error('Server error', new Error(errorText));
     });
   };
 
-  _showNotFoundMessageWithTip({
+  private showNotFoundMessageWithTip({
     status,
     statusText,
     errorText,
@@ -284,7 +278,7 @@
       const tip = isLoggedIn
         ? 'You might have not enough privileges.'
         : 'You might have not enough privileges. Sign in and try again.';
-      this._showErrorDialog(
+      this.showErrorDialog(
         constructServerErrorMsg({
           status,
           statusText,
@@ -300,10 +294,10 @@
     });
   }
 
-  _showQuotaExceeded({status, statusText}: ErrorMsg) {
+  private showQuotaExceeded({status, statusText}: ErrorMsg) {
     const tip = 'Try again later';
     const errorText = 'Too many requests from this client';
-    this._showErrorDialog(
+    this.showErrorDialog(
       constructServerErrorMsg({
         status,
         statusText,
@@ -326,12 +320,13 @@
 
   private readonly handleNetworkError = (e: NetworkErrorEvent) => {
     this._showAlert('Server unavailable');
-    this.reporting.error(new Error(`network error: ${e.detail.error.message}`));
+    this.reporting.error('Network error', new Error(e.detail.error.message));
   };
 
-  // TODO(dhruvsr): allow less priority alerts to override high priority alerts
+  // TODO(dhruvsri): allow less priority alerts to override high priority alerts
   // In some use cases we may want generic alerts to show along/over errors
-  _canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
+  // private but used in tests
+  canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
     return ErrorTypePriority[incoming] >= ErrorTypePriority[existing];
   }
 
@@ -343,71 +338,74 @@
     type?: ErrorType,
     showDismiss?: boolean
   ) {
-    if (this._alertElement) {
+    if (this.alertElement) {
       // check priority before hiding
-      if (!this._canOverride(type, this._alertElement.type)) return;
+      if (!this.canOverride(type, this.alertElement.type)) return;
       this.hideAlert();
     }
 
-    this._clearHideAlertHandle();
+    this.clearHideAlertHandle();
     if (dismissOnNavigation) {
       // Persist alert until navigation.
       document.addEventListener('location-change', this.hideAlert);
     } else {
-      this._hideAlertHandle = window.setTimeout(
+      this.hideAlertHandle = window.setTimeout(
         this.hideAlert,
         HIDE_ALERT_TIMEOUT_MS
       );
     }
-    const el = this._createToastAlert(showDismiss);
+    const el = this.createToastAlert(showDismiss);
     el.show(text, actionText, actionCallback);
-    this._alertElement = el;
+    this.alertElement = el;
     fireIronAnnounce(this, `Alert: ${text}`);
-    this.reporting.reportInteraction('show-alert', {text});
+    this.reporting.reportInteraction(EventType.SHOW_ALERT, {text});
   }
 
   private readonly hideAlert = () => {
-    if (!this._alertElement) {
+    if (!this.alertElement) {
       return;
     }
 
-    this._alertElement.hide();
-    this._alertElement = null;
+    this.alertElement.hide();
+    this.alertElement = null;
 
     // Remove listener for page navigation, if it exists.
     document.removeEventListener('location-change', this.hideAlert);
   };
 
-  _clearHideAlertHandle() {
-    if (this._hideAlertHandle !== null) {
-      window.clearTimeout(this._hideAlertHandle);
-      this._hideAlertHandle = null;
+  private clearHideAlertHandle() {
+    if (this.hideAlertHandle !== null) {
+      window.clearTimeout(this.hideAlertHandle);
+      this.hideAlertHandle = null;
     }
   }
 
-  _showAuthErrorAlert(errorText: string, actionText?: string) {
+  // private but used in tests
+  showAuthErrorAlert(errorText: string, actionText?: string) {
     // hide any existing alert like `reload`
     // as auth error should have the highest priority
-    if (this._alertElement) {
-      this._alertElement.hide();
+    if (this.alertElement) {
+      this.alertElement.hide();
     }
 
-    this._alertElement = this._createToastAlert();
-    this._alertElement.type = ErrorType.AUTH;
-    this._alertElement.show(errorText, actionText, () =>
-      this._createLoginPopup()
+    this.alertElement = this.createToastAlert();
+    this.alertElement.type = ErrorType.AUTH;
+    this.alertElement.show(errorText, actionText, () =>
+      this.createLoginPopup()
     );
     fireIronAnnounce(this, errorText);
     this.reporting.reportInteraction('show-auth-error', {text: errorText});
-    this._refreshingCredentials = true;
-    this._requestCheckLoggedIn();
+    this.refreshingCredentials = true;
+    this.requestCheckLoggedIn();
     if (!document.hidden) {
       this.handleVisibilityChange();
     }
   }
 
-  _createToastAlert(showDismiss?: boolean) {
+  // private but used in tests
+  createToastAlert(showDismiss?: boolean) {
     const el = document.createElement('gr-alert');
+    el.owner = this;
     el.toast = true;
     el.showDismiss = !!showDismiss;
     return el;
@@ -420,49 +418,51 @@
     // If not currently refreshing credentials and the credentials are old,
     // request them to confirm their validity or (display an auth toast if it
     // fails).
-    const timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
+    const timeSinceLastCheck = Date.now() - this.lastCredentialCheck;
     if (
-      !this._refreshingCredentials &&
+      !this.refreshingCredentials &&
       this.knownAccountId !== undefined &&
       timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS
     ) {
       this.reporting.reportInteraction('visibility-sign-in-check');
-      this._lastCredentialCheck = Date.now();
+      this.lastCredentialCheck = Date.now();
 
       // check auth status in case:
       // - user signed out
       // - user switched account
-      this._checkSignedIn();
+      this.checkSignedIn();
     }
   };
 
-  _requestCheckLoggedIn() {
+  // private but used in tests
+  requestCheckLoggedIn() {
     this.checkLoggedInTask = debounce(
       this.checkLoggedInTask,
-      () => this._checkSignedIn(),
+      () => this.checkSignedIn(),
       CHECK_SIGN_IN_INTERVAL_MS
     );
   }
 
-  _checkSignedIn() {
-    this._lastCredentialCheck = Date.now();
+  // private but used in tests
+  checkSignedIn() {
+    this.lastCredentialCheck = Date.now();
 
     // force to refetch account info
     this.restApiService.invalidateAccountsCache();
-    this._authService.clearCache();
+    this.getAuthService().clearCache();
 
     this.restApiService.getLoggedIn().then(isLoggedIn => {
-      if (!this._refreshingCredentials) return;
+      if (!this.refreshingCredentials) return;
 
       if (!isLoggedIn) {
         // check later
         // 1. guest mode
         // 2. or signed out
         // in case #2, auth-error is taken care of separately
-        this._requestCheckLoggedIn();
+        this.requestCheckLoggedIn();
       } else {
         this.restApiService.getAccount().then(account => {
-          if (this._refreshingCredentials) {
+          if (this.refreshingCredentials) {
             // If the credentials were refreshed but the account is different,
             // then reload the page completely.
             if (account?._account_id !== this.knownAccountId) {
@@ -470,7 +470,7 @@
                 oldAccount: !!this.knownAccountId,
                 newAccount: !!account?._account_id,
               });
-              this._reloadPage();
+              this.reloadPage();
               return;
             }
 
@@ -481,11 +481,11 @@
     });
   }
 
-  _reloadPage() {
+  reloadPage() {
     windowLocationReload();
   }
 
-  _createLoginPopup() {
+  private createLoginPopup() {
     const left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
     const top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
     const options = [
@@ -502,15 +502,16 @@
     window.addEventListener('focus', this.handleWindowFocus);
   }
 
+  // private but used in tests
   handleCredentialRefreshed() {
     window.removeEventListener('focus', this.handleWindowFocus);
-    this._refreshingCredentials = false;
+    this.refreshingCredentials = false;
     this.hideAlert();
     this._showAlert('Credentials refreshed.');
-    this.$.noInteractionOverlay.close();
+    this.signInModal.close();
 
     // Clear the cache for auth
-    this._authService.clearCache();
+    this.getAuthService().clearCache();
   }
 
   private readonly handleWindowFocus = () => {
@@ -518,19 +519,18 @@
   };
 
   private readonly handleShowErrorDialog = (e: ShowErrorEvent) => {
-    this._showErrorDialog(e.detail.message);
+    this.showErrorDialog(e.detail.message);
   };
 
-  _handleDismissErrorDialog() {
-    this.$.errorOverlay.close();
-  }
-
-  _showErrorDialog(message: string, options?: {showSignInButton?: boolean}) {
+  // private but used in tests
+  showErrorDialog(message: string, options?: {showSignInButton?: boolean}) {
     this.reporting.reportErrorDialog(message);
-    this.$.errorDialog.text = message;
-    this.$.errorDialog.showSignInButton =
-      !!options && !!options.showSignInButton;
-    this.$.errorOverlay.open();
+    this.errorDialog.text = message;
+    this.errorDialog.showSignInButton = !!options && !!options.showSignInButton;
+    if (this.errorModal.hasAttribute('open')) {
+      this.errorModal.close();
+    }
+    this.errorModal.showModal();
   }
 }
 
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
deleted file mode 100644
index c67ed07..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_html.ts
+++ /dev/null
@@ -1,37 +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-overlay with-backdrop="" id="errorOverlay">
-    <gr-error-dialog
-      id="errorDialog"
-      on-dismiss="_handleDismissErrorDialog"
-      confirm-label="Dismiss"
-      confirm-on-enter=""
-      login-url="[[loginUrl]]"
-    ></gr-error-dialog>
-  </gr-overlay>
-  <gr-overlay
-    id="noInteractionOverlay"
-    with-backdrop=""
-    always-on-top=""
-    no-cancel-on-esc-key=""
-    no-cancel-on-outside-click=""
-  >
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
index 80ebf2d..f4ee5ed 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
@@ -1,38 +1,32 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-error-manager';
 import {
   constructServerErrorMsg,
   GrErrorManager,
   __testOnly_ErrorType,
 } from './gr-error-manager';
-import {stubAuth, stubReporting, stubRestApi} from '../../../test/test-utils';
-import {appContext} from '../../../services/app-context';
+import {
+  stubReporting,
+  stubRestApi,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {AppContext, getAppContext} from '../../../services/app-context';
 import {
   createAccountDetailWithId,
   createPreferences,
 } from '../../../test/test-data-generators';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {AccountId} from '../../../types/common';
 import {waitUntil} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-error-manager');
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {authServiceToken} from '../../../services/gr-auth/gr-auth';
 
 suite('gr-error-manager tests', () => {
   let element: GrErrorManager;
@@ -41,20 +35,25 @@
     let toastSpy: sinon.SinonSpy;
     let fetchStub: sinon.SinonStub;
     let getLoggedInStub: sinon.SinonStub;
+    let appContext: AppContext;
 
-    setup(() => {
-      fetchStub = stubAuth('fetch').returns(
-        Promise.resolve({...new Response(), ok: true, status: 204})
-      );
+    setup(async () => {
+      fetchStub = sinon
+        .stub(testResolver(authServiceToken), 'fetch')
+        .returns(Promise.resolve({...new Response(), ok: true, status: 204}));
+      appContext = getAppContext();
       getLoggedInStub = stubRestApi('getLoggedIn').callsFake(() =>
         appContext.authService.authCheck()
       );
       stubRestApi('getPreferences').returns(
         Promise.resolve(createPreferences())
       );
-      element = basicFixture.instantiate();
+      element = await fixture<GrErrorManager>(
+        html`<gr-error-manager></gr-error-manager>`
+      );
       appContext.authService.clearCache();
-      toastSpy = sinon.spy(element, '_createToastAlert');
+      toastSpy = sinon.spy(element, 'createToastAlert');
+      await element.updateComplete;
     });
 
     teardown(() => {
@@ -63,8 +62,29 @@
       });
     });
 
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <dialog id="errorModal" tabindex="-1">
+            <gr-error-dialog id="errorDialog"> </gr-error-dialog>
+          </dialog>
+          <dialog id="signInModal" tabindex="-1">
+            <gr-dialog
+              id="signInDialog"
+              confirm-label="Sign In"
+              role="dialog"
+              cancel-label=""
+            >
+              <div class="header" slot="header">Refresh Credentials</div>
+            </gr-dialog>
+          </dialog>
+        `
+      );
+    });
+
     test('does not show auth error on 403 by default', async () => {
-      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      const showAuthErrorStub = sinon.stub(element, 'showAuthErrorAlert');
       const responseText = Promise.resolve('server says no.');
       element.dispatchEvent(
         new CustomEvent('server-error', {
@@ -80,12 +100,12 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.isFalse(showAuthErrorStub.calledOnce);
     });
 
     test('show auth required for 403 with auth error and not authed before', async () => {
-      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      const showAuthErrorStub = sinon.stub(element, 'showAuthErrorAlert');
       const responseText = Promise.resolve('Authentication required\n');
       getLoggedInStub.returns(Promise.resolve(true));
       element.dispatchEvent(
@@ -102,7 +122,7 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.isTrue(showAuthErrorStub.calledOnce);
     });
 
@@ -125,12 +145,12 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.isTrue(getLoggedInStub.calledOnce);
     });
 
     test('show logged in error', () => {
-      const spy = sinon.spy(element, '_showAuthErrorAlert');
+      const spy = sinon.spy(element, 'showAuthErrorAlert');
       element.dispatchEvent(
         new CustomEvent('show-auth-required', {
           composed: true,
@@ -146,7 +166,7 @@
     });
 
     test('show normal Error', async () => {
-      const showErrorSpy = sinon.spy(element, '_showErrorDialog');
+      const showErrorSpy = sinon.spy(element, 'showErrorDialog');
       const textSpy = sinon.spy(() => Promise.resolve('ZOMG'));
       element.dispatchEvent(
         new CustomEvent('server-error', {
@@ -157,7 +177,7 @@
       );
 
       assert.isTrue(textSpy.called);
-      await flush();
+      await waitEventLoop();
       assert.isTrue(showErrorSpy.calledOnce);
       assert.isTrue(showErrorSpy.lastCall.calledWithExactly('Error 500: ZOMG'));
     });
@@ -216,11 +236,8 @@
           bubbles: true,
         })
       );
-      await flush();
-      assert.equal(
-        element.$.errorDialog.text,
-        'Error 500: 500\nTrace Id: xxxx'
-      );
+      await waitEventLoop();
+      assert.equal(element.errorDialog.text, 'Error 500: 500\nTrace Id: xxxx');
     });
 
     test('suppress TOO_MANY_FILES error', async () => {
@@ -237,25 +254,7 @@
       );
 
       assert.isTrue(textSpy.called);
-      await flush();
-      assert.isFalse(showAlertStub.called);
-    });
-
-    test('suppress CONFLICTS_OPERATOR_IS_NOT_SUPPORTED error', async () => {
-      const showAlertStub = sinon.stub(element, '_showAlert');
-      const textSpy = sinon.spy(() =>
-        Promise.resolve("'conflicts:' operator is not supported by server")
-      );
-      element.dispatchEvent(
-        new CustomEvent('server-error', {
-          detail: {response: {status: 500, text: textSpy}},
-          composed: true,
-          bubbles: true,
-        })
-      );
-
-      assert.isTrue(textSpy.called);
-      await flush();
+      await waitEventLoop();
       assert.isFalse(showAlertStub.called);
     });
 
@@ -268,38 +267,36 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       assert.isTrue(showAlertStub.calledOnce);
       assert.isTrue(
         showAlertStub.lastCall.calledWithExactly('Server unavailable')
       );
     });
 
-    test('_canOverride alerts', () => {
+    test('canOverride alerts', () => {
+      assert.isFalse(element.canOverride(undefined, __testOnly_ErrorType.AUTH));
       assert.isFalse(
-        element._canOverride(undefined, __testOnly_ErrorType.AUTH)
-      );
-      assert.isFalse(
-        element._canOverride(undefined, __testOnly_ErrorType.NETWORK)
+        element.canOverride(undefined, __testOnly_ErrorType.NETWORK)
       );
       assert.isTrue(
-        element._canOverride(undefined, __testOnly_ErrorType.GENERIC)
+        element.canOverride(undefined, __testOnly_ErrorType.GENERIC)
       );
-      assert.isTrue(element._canOverride(undefined, undefined));
+      assert.isTrue(element.canOverride(undefined, undefined));
 
       assert.isTrue(
-        element._canOverride(__testOnly_ErrorType.NETWORK, undefined)
+        element.canOverride(__testOnly_ErrorType.NETWORK, undefined)
       );
-      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH, undefined));
+      assert.isTrue(element.canOverride(__testOnly_ErrorType.AUTH, undefined));
       assert.isFalse(
-        element._canOverride(
+        element.canOverride(
           __testOnly_ErrorType.NETWORK,
           __testOnly_ErrorType.AUTH
         )
       );
 
       assert.isTrue(
-        element._canOverride(
+        element.canOverride(
           __testOnly_ErrorType.AUTH,
           __testOnly_ErrorType.NETWORK
         )
@@ -333,15 +330,15 @@
         })
       );
       assert.equal(fetchStub.callCount, 1);
-      await flush();
+      await waitEventLoop();
 
-      // here needs two flush as there are two chanined
-      // promises on server-error handler and flush only flushes one
+      // here needs two waitEventLoop() as there are two chained promises on
+      // server-error handler and waitEventLoop() only flushes one
       assert.equal(fetchStub.callCount, 2);
-      await flush();
+      await waitEventLoop();
       // Sometime overlay opens with delay, waiting while open is complete
       clock.tick(1000);
-      await flush();
+      await waitEventLoop();
       // auth-error fired
       assert.isTrue(toastSpy.called);
 
@@ -351,19 +348,13 @@
       assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
       assert.include(toast.shadowRoot.textContent, 'Refresh credentials');
 
-      // noInteractionOverlay
-      const noInteractionOverlay = element.$.noInteractionOverlay;
-      assert.isOk(noInteractionOverlay);
-      const noInteractionOverlayCloseSpy = sinon.spy(
-        noInteractionOverlay,
-        'close'
-      );
-      assert.equal(
-        noInteractionOverlay.backdropElement.getAttribute('opened'),
-        ''
-      );
+      // signInModal
+      const signInModal = element.signInModal;
+      assert.isOk(signInModal);
+      const signInModalCloseSpy = sinon.spy(signInModal, 'close');
+      assert.isTrue(signInModal.hasAttribute('open'));
       assert.isFalse(windowOpen.called);
-      tap(toast.shadowRoot.querySelector('gr-button.action'));
+      toast.shadowRoot.querySelector('gr-button.action')!.click();
       assert.isTrue(windowOpen.called);
 
       // @see Issue 5822: noopener breaks closeAfterLogin
@@ -376,8 +367,8 @@
 
       clock.tick(1000);
       element.knownAccountId = 5 as AccountId;
-      element._checkSignedIn();
-      await flush();
+      element.checkSignedIn();
+      await waitEventLoop();
 
       assert.isTrue(refreshStub.called);
       assert.isTrue(hideToastSpy.called);
@@ -389,7 +380,7 @@
       assert.include(toast.shadowRoot.textContent, 'Credentials refreshed');
 
       // close overlay
-      assert.isTrue(noInteractionOverlayCloseSpy.called);
+      assert.isTrue(signInModalCloseSpy.called);
     });
 
     test('auth toast should dismiss existing toast', async () => {
@@ -400,13 +391,13 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent('show-alert', {
+        new CustomEvent(EventType.SHOW_ALERT, {
           detail: {message: 'test reload', action: 'reload'},
           composed: true,
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       let toast = toastSpy.lastCall.returnValue;
       assert.isOk(toast);
       assert.include(toast.shadowRoot.textContent, 'test reload');
@@ -427,15 +418,15 @@
           bubbles: true,
         })
       );
-      await flush();
-      await flush();
-      // here needs two flush as there are two chained
-      // promises on server-error handler and flush only flushes one
+      await waitEventLoop();
+      await waitEventLoop();
+      // here needs two waitEventLoop() as there are two chained promises on
+      // server-error handler and waitEventLoop() only flushes one
       assert.equal(fetchStub.callCount, 2);
-      await flush();
+      await waitEventLoop();
       // Sometime overlay opens with delay, waiting while open is complete
       clock.tick(1000);
-      await flush();
+      await waitEventLoop();
       // toast
       toast = toastSpy.lastCall.returnValue;
       assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
@@ -448,26 +439,26 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent('show-alert', {
+        new CustomEvent(EventType.SHOW_ALERT, {
           detail: {message: 'test reload', action: 'reload'},
           composed: true,
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       let toast = toastSpy.lastCall.returnValue;
       assert.isOk(toast);
       assert.include(toast.shadowRoot.textContent, 'test reload');
 
       // new alert
       element.dispatchEvent(
-        new CustomEvent('show-alert', {
+        new CustomEvent(EventType.SHOW_ALERT, {
           detail: {message: 'second-test', action: 'reload'},
           composed: true,
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
       toast = toastSpy.lastCall.returnValue;
       assert.include(toast.shadowRoot.textContent, 'second-test');
     });
@@ -494,12 +485,12 @@
         })
       );
       assert.equal(fetchStub.callCount, 1);
-      await flush();
+      await waitEventLoop();
 
-      // here needs two flush as there are two chained
-      // promises on server-error handler and flush only flushes one
+      // here needs two waitEventLoop() as there are two chained promises on
+      // server-error handler and waitEventLoop() only flushes one
       assert.equal(fetchStub.callCount, 2);
-      await flush();
+      await waitEventLoop();
       await waitUntil(() => toastSpy.calledOnce);
       let toast = toastSpy.lastCall.returnValue;
       assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
@@ -507,7 +498,7 @@
 
       // fake an alert
       element.dispatchEvent(
-        new CustomEvent('show-alert', {
+        new CustomEvent(EventType.SHOW_ALERT, {
           detail: {
             message: 'test-alert',
             action: 'reload',
@@ -517,7 +508,7 @@
         })
       );
 
-      await flush();
+      await waitEventLoop();
       assert.isTrue(toastSpy.calledOnce);
       toast = toastSpy.lastCall.returnValue;
       assert.isOk(toast);
@@ -528,7 +519,7 @@
       const alertObj = {message: 'foo'};
       const showAlertStub = sinon.stub(element, '_showAlert');
       element.dispatchEvent(
-        new CustomEvent('show-alert', {
+        new CustomEvent(EventType.SHOW_ALERT, {
           detail: alertObj,
           composed: true,
           bubbles: true,
@@ -541,15 +532,15 @@
     });
 
     test('checks stale credentials on visibility change', () => {
-      const refreshStub = sinon.stub(element, '_checkSignedIn');
+      const refreshStub = sinon.stub(element, 'checkSignedIn');
       sinon.stub(Date, 'now').returns(999999);
-      element._lastCredentialCheck = 0;
+      element.lastCredentialCheck = 0;
 
       document.dispatchEvent(new CustomEvent('visibilitychange'));
 
       // Since there is no known account, it should not test credentials.
       assert.isFalse(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 0);
+      assert.equal(element.lastCredentialCheck, 0);
 
       element.knownAccountId = 123 as AccountId;
 
@@ -557,7 +548,7 @@
 
       // Should test credentials, since there is a known account.
       assert.isTrue(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 999999);
+      assert.equal(element.lastCredentialCheck, 999999);
     });
 
     test('refreshes with same credentials', async () => {
@@ -565,33 +556,33 @@
         ...createAccountDetailWithId(1234),
       });
       stubRestApi('getAccount').returns(accountPromise);
-      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const requestCheckStub = sinon.stub(element, 'requestCheckLoggedIn');
       const handleRefreshStub = sinon.stub(
         element,
         'handleCredentialRefreshed'
       );
-      const reloadStub = sinon.stub(element, '_reloadPage');
+      const reloadStub = sinon.stub(element, 'reloadPage');
 
       element.knownAccountId = 1234 as AccountId;
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
+      element.refreshingCredentials = true;
+      element.checkSignedIn();
 
-      await flush();
+      await waitEventLoop();
       assert.isFalse(requestCheckStub.called);
       assert.isTrue(handleRefreshStub.called);
       assert.isFalse(reloadStub.called);
     });
 
     test('_showAlert hides existing alerts', () => {
-      element._alertElement = element._createToastAlert();
+      element.alertElement = element.createToastAlert();
       // const hideStub = sinon.stub(element, 'hideAlert');
       // element._showAlert('');
       // assert.isTrue(hideStub.calledOnce);
     });
 
     test('show-error', async () => {
-      const openStub = sinon.stub(element.$.errorOverlay, 'open');
-      const closeStub = sinon.stub(element.$.errorOverlay, 'close');
+      const openStub = sinon.stub(element.errorModal, 'showModal');
+      const closeStub = sinon.stub(element.errorModal, 'close');
       const reportStub = stubReporting('reportErrorDialog');
 
       const message = 'test message';
@@ -602,19 +593,19 @@
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
 
       assert.isTrue(openStub.called);
       assert.isTrue(reportStub.called);
-      assert.equal(element.$.errorDialog.text, message);
+      assert.equal(element.errorDialog.text, message);
 
-      element.$.errorDialog.dispatchEvent(
+      element.errorDialog.dispatchEvent(
         new CustomEvent('dismiss', {
           composed: true,
           bubbles: true,
         })
       );
-      await flush();
+      await waitEventLoop();
 
       assert.isTrue(closeStub.called);
     });
@@ -624,18 +615,18 @@
         ...createAccountDetailWithId(1234),
       });
       stubRestApi('getAccount').returns(accountPromise);
-      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const requestCheckStub = sinon.stub(element, 'requestCheckLoggedIn');
       const handleRefreshStub = sinon.stub(
         element,
         'handleCredentialRefreshed'
       );
-      const reloadStub = sinon.stub(element, '_reloadPage');
+      const reloadStub = sinon.stub(element, 'reloadPage');
 
       element.knownAccountId = 4321 as AccountId; // Different from 1234
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
+      element.refreshingCredentials = true;
+      element.checkSignedIn();
 
-      await flush();
+      await waitEventLoop();
 
       assert.isFalse(requestCheckStub.called);
       assert.isFalse(handleRefreshStub.called);
@@ -645,10 +636,13 @@
 
   suite('when not authed', () => {
     let toastSpy: sinon.SinonSpy;
-    setup(() => {
+    setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      toastSpy = sinon.spy(element, '_createToastAlert');
+      element = await fixture<GrErrorManager>(
+        html`<gr-error-manager></gr-error-manager>`
+      );
+      toastSpy = sinon.spy(element, 'createToastAlert');
+      await element.updateComplete;
     });
 
     teardown(() => {
@@ -658,17 +652,17 @@
     });
 
     test('refresh loop continues on credential fail', async () => {
-      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const requestCheckStub = sinon.stub(element, 'requestCheckLoggedIn');
       const handleRefreshStub = sinon.stub(
         element,
         'handleCredentialRefreshed'
       );
-      const reloadStub = sinon.stub(element, '_reloadPage');
+      const reloadStub = sinon.stub(element, 'reloadPage');
 
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
+      element.refreshingCredentials = true;
+      element.checkSignedIn();
 
-      await flush();
+      await waitEventLoop();
       assert.isTrue(requestCheckStub.called);
       assert.isFalse(handleRefreshStub.called);
       assert.isFalse(reloadStub.called);
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
index 1ae0992..6b64aec 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
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -25,6 +14,9 @@
 
 @customElement('gr-key-binding-display')
 export class GrKeyBindingDisplay extends LitElement {
+  @property({type: Array})
+  binding: string[][] = [];
+
   static override get styles() {
     return [
       css`
@@ -53,9 +45,6 @@
     return html`${items}`;
   }
 
-  @property({type: Array})
-  binding: string[][] = [];
-
   _computeModifiers(binding: string[]) {
     return binding.slice(0, binding.length - 1);
   }
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
index dd023cb..31256f8 100644
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display_test.ts
@@ -1,52 +1,63 @@
 /**
  * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
 import './gr-key-binding-display';
 import {GrKeyBindingDisplay} from './gr-key-binding-display';
 
-const basicFixture = fixtureFromElement('gr-key-binding-display');
+const x = ['x'];
+const ctrlX = ['Ctrl', 'x'];
+const shiftMetaX = ['Shift', 'Meta', 'x'];
 
 suite('gr-key-binding-display tests', () => {
   let element: GrKeyBindingDisplay;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-key-binding-display
+        .binding=${[x, ctrlX, shiftMetaX]}
+      ></gr-key-binding-display>`
+    );
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <span class="key"> x </span>
+        or
+        <span class="key modifier"> Ctrl </span>
+        <span class="key"> x </span>
+        or
+        <span class="key modifier"> Shift </span>
+        <span class="key modifier"> Meta </span>
+        <span class="key"> x </span>
+      `
+    );
   });
 
   suite('_computeKey', () => {
     test('unmodified key', () => {
-      assert.strictEqual(element._computeKey(['x']), 'x');
+      assert.strictEqual(element._computeKey(x), 'x');
     });
 
     test('key with modifiers', () => {
-      assert.strictEqual(element._computeKey(['Ctrl', 'x']), 'x');
-      assert.strictEqual(element._computeKey(['Shift', 'Meta', 'x']), 'x');
+      assert.strictEqual(element._computeKey(ctrlX), 'x');
+      assert.strictEqual(element._computeKey(shiftMetaX), 'x');
     });
   });
 
   suite('_computeModifiers', () => {
     test('single unmodified key', () => {
-      assert.deepEqual(element._computeModifiers(['x']), []);
+      assert.deepEqual(element._computeModifiers(x), []);
     });
 
     test('key with modifiers', () => {
-      assert.deepEqual(element._computeModifiers(['Ctrl', 'x']), ['Ctrl']);
-      assert.deepEqual(element._computeModifiers(['Shift', 'Meta', 'x']), [
+      assert.deepEqual(element._computeModifiers(ctrlX), ['Ctrl']);
+      assert.deepEqual(element._computeModifiers(shiftMetaX), [
         '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 8610999..45ba33b 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
@@ -1,32 +1,21 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
 import '../gr-key-binding-display/gr-key-binding-display';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-font-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
 import {
   ShortcutSection,
   SectionView,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {property, customElement} from '@polymer/decorators';
-import {appContext} from '../../../services/app-context';
-import {ShortcutViewListener} from '../../../services/shortcuts/shortcuts-service';
+  shortcutsServiceToken,
+  ShortcutViewListener,
+} from '../../../services/shortcuts/shortcuts-service';
+import {resolve} from '../../../models/dependency';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -40,49 +29,137 @@
 }
 
 @customElement('gr-keyboard-shortcuts-dialog')
-export class GrKeyboardShortcutsDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrKeyboardShortcutsDialog extends LitElement {
   /**
    * Fired when the user presses the close button.
    *
    * @event close
    */
 
-  @property({type: Array})
-  _left?: SectionShortcut[];
+  // private but used in tests
+  @state() left?: SectionShortcut[];
 
-  @property({type: Array})
-  _right?: SectionShortcut[];
+  // private but used in tests
+  @state() right?: SectionShortcut[];
 
   private readonly shortcutListener: ShortcutViewListener;
 
-  private readonly shortcuts = appContext.shortcutsService;
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   constructor() {
     super();
     this.shortcutListener = (d?: Map<ShortcutSection, SectionView>) =>
-      this._onDirectoryUpdated(d);
+      this.onDirectoryUpdated(d);
   }
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        :host {
+          display: block;
+          max-height: 100vh;
+          min-width: 60vw;
+        }
+        main {
+          display: flex;
+          padding: 0 var(--spacing-xxl) var(--spacing-xxl);
+        }
+        .column {
+          flex: 50%;
+        }
+        header {
+          padding: var(--spacing-l) var(--spacing-xxl);
+          align-items: center;
+          border-bottom: 1px solid var(--border-color);
+          display: flex;
+          justify-content: space-between;
+        }
+        table caption {
+          padding: var(--spacing-l) var(--spacing-s);
+        }
+        td {
+          padding: var(--spacing-xs) 0;
+          vertical-align: middle;
+          width: 200px;
+        }
+        td:first-child,
+        th:first-child {
+          padding-right: var(--spacing-m);
+          text-align: right;
+        }
+        td:last-child,
+        th:last-child {
+          text-align: left;
+        }
+        td:last-child {
+          color: var(--deemphasized-text-color);
+        }
+        th {
+          color: var(--deemphasized-text-color);
+        }
+        .modifier {
+          font-weight: var(--font-weight-normal);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <header>
+        <h3 class="heading-2">Keyboard shortcuts</h3>
+        <gr-button link="" @click=${this.handleCloseTap}>Close</gr-button>
+      </header>
+      <main>
+        <div class="column">
+          ${this.left?.map(section => this.renderSection(section))}
+        </div>
+        <div class="column">
+          ${this.right?.map(section => this.renderSection(section))}
+        </div>
+      </main>
+      <footer></footer>
+    `;
+  }
+
+  private renderSection(section: SectionShortcut) {
+    return html`<table>
+      <caption class="heading-3">
+        ${section.section}
+      </caption>
+      <thead>
+        <tr>
+          <th><strong>Action</strong></th>
+          <th><strong>Key</strong></th>
+        </tr>
+      </thead>
+      <tbody>
+        ${section.shortcuts?.map(
+          shortcut => html`<tr>
+            <td>${shortcut.text}</td>
+            <td>
+              <gr-key-binding-display .binding=${shortcut.binding}>
+              </gr-key-binding-display>
+            </td>
+          </tr>`
+        )}
+      </tbody>
+    </table>`;
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.shortcuts.addListener(this.shortcutListener);
+    this.getShortcutsService().addListener(this.shortcutListener);
   }
 
   override disconnectedCallback() {
-    this.shortcuts.removeListener(this.shortcutListener);
+    this.getShortcutsService().removeListener(this.shortcutListener);
     super.disconnectedCallback();
   }
 
-  _handleCloseTap(e: MouseEvent) {
+  private handleCloseTap(e: MouseEvent) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -93,7 +170,7 @@
     );
   }
 
-  _onDirectoryUpdated(directory?: Map<ShortcutSection, SectionView>) {
+  onDirectoryUpdated(directory?: Map<ShortcutSection, SectionView>) {
     if (!directory) {
       return;
     }
@@ -122,14 +199,14 @@
     }
 
     if (directory.has(ShortcutSection.REPLY_DIALOG)) {
-      right.push({
+      left.push({
         section: ShortcutSection.REPLY_DIALOG,
         shortcuts: directory.get(ShortcutSection.REPLY_DIALOG),
       });
     }
 
     if (directory.has(ShortcutSection.FILE_LIST)) {
-      right.push({
+      left.push({
         section: ShortcutSection.FILE_LIST,
         shortcuts: directory.get(ShortcutSection.FILE_LIST),
       });
@@ -142,7 +219,7 @@
       });
     }
 
-    this.set('_left', left);
-    this.set('_right', right);
+    this.right = right;
+    this.left = left;
   }
 }
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
deleted file mode 100644
index 4992daa..0000000
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_html.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-      max-height: 100vh;
-      overflow-y: auto;
-    }
-    header {
-      padding: var(--spacing-l);
-    }
-    main {
-      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 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,
-    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);
-      padding-top: var(--spacing-l);
-    }
-    .modifier {
-      font-weight: var(--font-weight-normal);
-    }
-  </style>
-  <header>
-    <h3 class="heading-3">Keyboard shortcuts</h3>
-    <gr-button link="" on-click="_handleCloseTap">Close</gr-button>
-  </header>
-  <main>
-    <div class="column">
-      <template is="dom-repeat" items="[[_left]]">
-        <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>
-    <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.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.ts
index 2c76704..5208359 100644
--- 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
@@ -1,120 +1,196 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
+import './gr-keyboard-shortcuts-dialog';
 import {GrKeyboardShortcutsDialog} from './gr-keyboard-shortcuts-dialog';
 import {
   SectionView,
   ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+} from '../../../services/shortcuts/shortcuts-service';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitEventLoop} from '../../../test/test-utils';
 
-const basicFixture = fixtureFromElement('gr-keyboard-shortcuts-dialog');
+const x = ['x'];
+const ctrlX = ['Ctrl', 'x'];
+const shiftMetaX = ['Shift', 'Meta', 'x'];
 
 suite('gr-keyboard-shortcuts-dialog tests', () => {
   let element: GrKeyboardShortcutsDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-keyboard-shortcuts-dialog></gr-keyboard-shortcuts-dialog>`
+    );
+    await waitEventLoop();
   });
 
-  function update(directory: Map<ShortcutSection, SectionView>) {
-    element._onDirectoryUpdated(directory);
-    flush();
+  async function update(directory: Map<ShortcutSection, SectionView>) {
+    element.onDirectoryUpdated(directory);
+    await waitEventLoop();
   }
 
-  suite('_left and _right contents', () => {
+  test('renders left and right contents', async () => {
+    const directory = new Map([
+      [
+        ShortcutSection.NAVIGATION,
+        [{binding: [x, ctrlX], text: 'navigation shortcuts'}],
+      ],
+      [
+        ShortcutSection.ACTIONS,
+        [{binding: [shiftMetaX], text: 'navigation shortcuts'}],
+      ],
+    ]);
+    await update(directory);
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <header>
+          <h3 class="heading-2">Keyboard shortcuts</h3>
+          <gr-button aria-disabled="false" link="" role="button" tabindex="0">
+            Close
+          </gr-button>
+        </header>
+        <main>
+          <div class="column">
+            <table>
+              <caption class="heading-3">
+                Navigation
+              </caption>
+              <thead>
+                <tr>
+                  <th>
+                    <strong> Action </strong>
+                  </th>
+                  <th>
+                    <strong> Key </strong>
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <td>navigation shortcuts</td>
+                  <td>
+                    <gr-key-binding-display> </gr-key-binding-display>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+          <div class="column">
+            <table>
+              <caption class="heading-3">
+                Actions
+              </caption>
+              <thead>
+                <tr>
+                  <th>
+                    <strong> Action </strong>
+                  </th>
+                  <th>
+                    <strong> Key </strong>
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <td>navigation shortcuts</td>
+                  <td>
+                    <gr-key-binding-display> </gr-key-binding-display>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </main>
+        <footer></footer>
+      `
+    );
+  });
+
+  suite('left and right contents', () => {
     test('empty dialog', () => {
-      assert.isEmpty(element._left);
-      assert.isEmpty(element._right);
+      assert.isEmpty(element.left);
+      assert.isEmpty(element.right);
     });
 
-    test('everywhere goes on left', () => {
+    test('everywhere goes on left', async () => {
       const sectionView = [{binding: [], text: 'everywhere shortcuts'}];
-      update(new Map([[ShortcutSection.EVERYWHERE, sectionView]]));
-      assert.deepEqual(element._left, [
+      await update(new Map([[ShortcutSection.EVERYWHERE, sectionView]]));
+      assert.deepEqual(element.left, [
         {
           section: ShortcutSection.EVERYWHERE,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._right);
+      assert.isEmpty(element.right);
     });
 
-    test('navigation goes on left', () => {
+    test('navigation goes on left', async () => {
       const sectionView = [{binding: [], text: 'navigation shortcuts'}];
-      update(new Map([[ShortcutSection.NAVIGATION, sectionView]]));
-      assert.deepEqual(element._left, [
+      await update(new Map([[ShortcutSection.NAVIGATION, sectionView]]));
+      assert.deepEqual(element.left, [
         {
           section: ShortcutSection.NAVIGATION,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._right);
+      assert.isEmpty(element.right);
     });
 
-    test('actions go on right', () => {
+    test('actions go on right', async () => {
       const sectionView = [{binding: [], text: 'actions shortcuts'}];
-      update(new Map([[ShortcutSection.ACTIONS, sectionView]]));
-      assert.deepEqual(element._right, [
+      await update(new Map([[ShortcutSection.ACTIONS, sectionView]]));
+      assert.deepEqual(element.right, [
         {
           section: ShortcutSection.ACTIONS,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._left);
+      assert.isEmpty(element.left);
     });
 
-    test('reply dialog goes on right', () => {
+    test('reply dialog goes on left', async () => {
       const sectionView = [{binding: [], text: 'reply dialog shortcuts'}];
-      update(new Map([[ShortcutSection.REPLY_DIALOG, sectionView]]));
-      assert.deepEqual(element._right, [
+      await update(new Map([[ShortcutSection.REPLY_DIALOG, sectionView]]));
+      assert.deepEqual(element.left, [
         {
           section: ShortcutSection.REPLY_DIALOG,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._left);
+      assert.isEmpty(element.right);
     });
 
-    test('file list goes on right', () => {
+    test('file list goes on left', async () => {
       const sectionView = [{binding: [], text: 'file list shortcuts'}];
-      update(new Map([[ShortcutSection.FILE_LIST, sectionView]]));
-      assert.deepEqual(element._right, [
+      await update(new Map([[ShortcutSection.FILE_LIST, sectionView]]));
+      assert.deepEqual(element.left, [
         {
           section: ShortcutSection.FILE_LIST,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._left);
+      assert.isEmpty(element.right);
     });
 
-    test('diffs go on right', () => {
+    test('diffs go on right', async () => {
       const sectionView = [{binding: [], text: 'diffs shortcuts'}];
-      update(new Map([[ShortcutSection.DIFFS, sectionView]]));
-      assert.deepEqual(element._right, [
+      await update(new Map([[ShortcutSection.DIFFS, sectionView]]));
+      assert.deepEqual(element.right, [
         {
           section: ShortcutSection.DIFFS,
           shortcuts: sectionView,
         },
       ]);
-      assert.isEmpty(element._left);
+      assert.isEmpty(element.left);
     });
 
-    test('multiple sections on each side', () => {
+    test('multiple sections on each side', async () => {
       const actionsSectionView = [{binding: [], text: 'actions shortcuts'}];
       const diffsSectionView = [{binding: [], text: 'diffs shortcuts'}];
       const everywhereSectionView = [
@@ -123,7 +199,7 @@
       const navigationSectionView = [
         {binding: [], text: 'navigation shortcuts'},
       ];
-      update(
+      await update(
         new Map([
           [ShortcutSection.ACTIONS, actionsSectionView],
           [ShortcutSection.DIFFS, diffsSectionView],
@@ -131,7 +207,7 @@
           [ShortcutSection.NAVIGATION, navigationSectionView],
         ])
       );
-      assert.deepEqual(element._left, [
+      assert.deepEqual(element.left, [
         {
           section: ShortcutSection.EVERYWHERE,
           shortcuts: everywhereSectionView,
@@ -141,7 +217,7 @@
           shortcuts: navigationSectionView,
         },
       ]);
-      assert.deepEqual(element._right, [
+      assert.deepEqual(element.right, [
         {
           section: ShortcutSection.ACTIONS,
           shortcuts: actionsSectionView,
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 9d34929..833a91a 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
@@ -1,30 +1,17 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {Subscription} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../shared/gr-dropdown/gr-dropdown';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-account-dropdown/gr-account-dropdown';
 import '../gr-smart-search/gr-smart-search';
-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} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   RequireProperties,
@@ -34,12 +21,15 @@
 } from '../../../types/common';
 import {AuthType} from '../../../constants/constants';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
-import {appContext} from '../../../services/app-context';
-import {Subject} from 'rxjs';
-import {serverConfig$} from '../../../services/config/config-model';
-import {takeUntil} from 'rxjs/operators';
-import {myTopMenuItems$} from '../../../services/user/user-model';
-import {assertIsDefined} from '../../../utils/common-util';
+import {getAppContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {fireEvent} from '../../../utils/event-util';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -103,121 +93,415 @@
   AuthType.CUSTOM_EXTENSION,
 ]);
 
-@customElement('gr-main-header')
-export class GrMainHeader extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-main-header': GrMainHeader;
   }
+}
 
-  @property({type: String, notify: true})
+@customElement('gr-main-header')
+export class GrMainHeader extends LitElement {
+  @property({type: String})
   searchQuery = '';
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   loggedIn?: boolean;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   loading?: boolean;
 
-  @property({type: Object})
-  _account?: AccountDetailInfo;
-
-  @property({type: Array})
-  _adminLinks: NavLink[] = [];
-
-  @property({type: String})
-  _docBaseUrl: string | null = null;
-
-  @property({
-    type: Array,
-    computed: '_computeLinks(_userLinks, _adminLinks, _topMenus, _docBaseUrl)',
-  })
-  _links?: MainHeaderLinkGroup[];
-
   @property({type: String})
   loginUrl = '/login';
 
-  @property({type: Array})
-  _userLinks: MainHeaderLink[] = [];
-
-  @property({type: Array})
-  _topMenus?: TopMenuEntryInfo[] = [];
-
-  @property({type: String})
-  _registerText = 'Sign up';
-
-  // Empty string means that the register <div> will be hidden.
-  @property({type: String})
-  _registerURL = '';
-
-  @property({type: String})
-  _feedbackURL = '';
-
   @property({type: Boolean})
   mobileSearchHidden = false;
 
-  private readonly restApiService = appContext.restApiService;
+  // private but used in test
+  @state() account?: AccountDetailInfo;
 
-  private readonly jsAPI = appContext.jsApiService;
+  @state() private adminLinks: NavLink[] = [];
 
-  private readonly disconnected$ = new Subject();
+  @state() private docBaseUrl: string | null = null;
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'banner');
-  }
+  @state() private userLinks: MainHeaderLink[] = [];
+
+  @state() private topMenus?: TopMenuEntryInfo[] = [];
+
+  // private but used in test
+  @state() registerText = 'Sign up';
+
+  // Empty string means that the register <div> will be hidden.
+  // private but used in test
+  @state() registerURL = '';
+
+  // private but used in test
+  @state() feedbackURL = '';
+
+  @state() private serverConfig?: ServerInfo;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private subscriptions: Subscription[] = [];
 
   override connectedCallback() {
-    // TODO(brohlfs): This just ensures that the userService is instantiated at
-    // all. We need the service to manage the model, but we are not making any
-    // direct calls. Will need to find a better solution to this problem ...
-    assertIsDefined(appContext.userService);
-
     super.connectedCallback();
-    this._loadAccount();
+    this.loadAccount();
 
-    myTopMenuItems$.pipe(takeUntil(this.disconnected$)).subscribe(items => {
-      this._userLinks = items.map(this._createHeaderLink);
-    });
-
-    serverConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
-      if (!config) return;
-      this._retrieveFeedbackURL(config);
-      this._retrieveRegisterURL(config);
-      getDocsBaseUrl(config, this.restApiService).then(docBaseUrl => {
-        this._docBaseUrl = docBaseUrl;
-      });
-    });
+    this.subscriptions.push(
+      this.getUserModel()
+        .preferences$.pipe(
+          map(preferences => preferences?.my ?? []),
+          distinctUntilChanged()
+        )
+        .subscribe(items => {
+          this.userLinks = items.map(this.createHeaderLink);
+        })
+    );
+    this.subscriptions.push(
+      this.getConfigModel().serverConfig$.subscribe(config => {
+        if (!config) return;
+        this.serverConfig = config;
+        this.retrieveFeedbackURL(config);
+        this.retrieveRegisterURL(config);
+        getDocsBaseUrl(config, this.restApiService).then(docBaseUrl => {
+          this.docBaseUrl = docBaseUrl;
+        });
+      })
+    );
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
     super.disconnectedCallback();
   }
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        nav {
+          align-items: center;
+          display: flex;
+        }
+        .bigTitle {
+          color: var(--header-text-color);
+          font-size: var(--header-title-font-size);
+          text-decoration: none;
+        }
+        .bigTitle:hover {
+          text-decoration: underline;
+        }
+        .titleText::before {
+          --icon-width: var(--header-icon-width, var(--header-icon-size, 0));
+          --icon-height: var(--header-icon-height, var(--header-icon-size, 0));
+          background-image: var(--header-icon);
+          background-size: var(--icon-width) var(--icon-height);
+          background-repeat: no-repeat;
+          content: '';
+          display: inline-block;
+          height: var(--icon-height);
+          /* If size or height are set, then use 'spacing-m', 0px otherwise. */
+          margin-right: clamp(0px, var(--icon-height), var(--spacing-m));
+          vertical-align: text-bottom;
+          width: var(--icon-width);
+        }
+        .titleText::after {
+          content: var(--header-title-content);
+          white-space: nowrap;
+        }
+        ul {
+          list-style: none;
+          padding-left: var(--spacing-l);
+        }
+        .links > li {
+          cursor: default;
+          display: inline-block;
+          padding: 0;
+          position: relative;
+        }
+        .linksTitle {
+          display: inline-block;
+          font-weight: var(--font-weight-bold);
+          position: relative;
+          text-transform: uppercase;
+        }
+        .linksTitle:hover {
+          opacity: 0.75;
+        }
+        .rightItems {
+          align-items: center;
+          display: flex;
+          flex: 1;
+          justify-content: flex-end;
+        }
+        .rightItems gr-endpoint-decorator:not(:empty) {
+          margin-left: var(--spacing-l);
+        }
+        gr-smart-search {
+          flex-grow: 1;
+          margin: 0 var(--spacing-m);
+          max-width: 500px;
+          min-width: 150px;
+        }
+        gr-dropdown,
+        .browse {
+          padding: var(--spacing-m);
+        }
+        gr-dropdown {
+          --gr-dropdown-item-color: var(--primary-text-color);
+        }
+        .settingsButton {
+          margin-left: var(--spacing-m);
+        }
+        .feedbackButton {
+          margin-left: var(--spacing-s);
+        }
+        .browse {
+          color: var(--header-text-color);
+          /* Same as gr-button */
+          margin: 5px 4px;
+          text-decoration: none;
+        }
+        .invisible,
+        .settingsButton,
+        gr-account-dropdown {
+          display: none;
+        }
+        :host([loading]) .accountContainer,
+        :host([loggedIn]) .loginButton,
+        :host([loggedIn]) .registerButton {
+          display: none;
+        }
+        :host([loggedIn]) .settingsButton,
+        :host([loggedIn]) gr-account-dropdown {
+          display: inline;
+        }
+        .accountContainer {
+          align-items: center;
+          display: flex;
+          margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .loginButton,
+        .registerButton {
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        .dropdown-trigger {
+          text-decoration: none;
+        }
+        .dropdown-content {
+          background-color: var(--view-background-color);
+          box-shadow: var(--elevation-level-2);
+        }
+        /*
+           * We are not using :host to do this, because :host has a lowest css priority
+           * compared to others. This means that using :host to do this would break styles.
+           */
+        .linksTitle,
+        .bigTitle,
+        .loginButton,
+        .registerButton,
+        gr-icon,
+        gr-dropdown,
+        gr-account-dropdown {
+          --gr-button-text-color: var(--header-text-color);
+          color: var(--header-text-color);
+        }
+        #mobileSearch {
+          display: none;
+        }
+        @media screen and (max-width: 50em) {
+          .bigTitle {
+            font-family: var(--header-font-family);
+            font-size: var(--font-size-h3);
+            font-weight: var(--font-weight-h3);
+            line-height: var(--line-height-h3);
+          }
+          gr-smart-search,
+          .browse,
+          .rightItems .hideOnMobile,
+          .links > li.hideOnMobile {
+            display: none;
+          }
+          #mobileSearch {
+            display: inline-flex;
+          }
+          .accountContainer {
+            margin-left: var(--spacing-m) !important;
+          }
+          gr-dropdown {
+            padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+  <nav>
+    <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
+      <gr-endpoint-decorator name="header-title">
+        <span class="titleText"></span>
+      </gr-endpoint-decorator>
+    </a>
+    <ul class="links">
+      ${this.computeLinks(
+        this.userLinks,
+        this.adminLinks,
+        this.topMenus,
+        this.docBaseUrl
+      ).map(linkGroup => this.renderLinkGroup(linkGroup))}
+    </ul>
+    <div class="rightItems">
+      <gr-endpoint-decorator
+        class="hideOnMobile"
+        name="header-small-banner"
+      ></gr-endpoint-decorator>
+      <gr-smart-search
+        id="search"
+        label="Search for changes"
+        .searchQuery=${this.searchQuery}
+        .serverConfig=${this.serverConfig}
+      ></gr-smart-search>
+      <gr-endpoint-decorator
+        class="hideOnMobile"
+        name="header-browse-source"
+      ></gr-endpoint-decorator>
+      <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
+        ${this.renderFeedback()}
+      </gr-endpoint-decorator>
+      </div>
+      ${this.renderAccount()}
+    </div>
+  </nav>
+    `;
+  }
+
+  private renderLinkGroup(linkGroup: MainHeaderLinkGroup) {
+    return html`
+      <li class=${linkGroup.class ?? ''}>
+        <gr-dropdown
+          link
+          down-arrow
+          .items=${linkGroup.links}
+          horizontal-align="left"
+        >
+          <span class="linksTitle" id=${linkGroup.title}>
+            ${linkGroup.title}
+          </span>
+        </gr-dropdown>
+      </li>
+    `;
+  }
+
+  private renderFeedback() {
+    if (!this.feedbackURL) return;
+
+    return html`
+      <a
+        href=${this.feedbackURL}
+        title="File a bug"
+        aria-label="File a bug"
+        target="_blank"
+        role="button"
+      >
+        <gr-icon icon="bug_report" filled></gr-icon>
+      </a>
+    `;
+  }
+
+  private renderAccount() {
+    return html`
+      <div class="accountContainer" id="accountContainer">
+        <div>
+          <gr-icon
+            id="mobileSearch"
+            icon="search"
+            @click=${(e: Event) => {
+              this.onMobileSearchTap(e);
+            }}
+            role="button"
+            aria-label=${this.mobileSearchHidden
+              ? 'Show Searchbar'
+              : 'Hide Searchbar'}
+          ></gr-icon>
+        </div>
+        ${this.renderRegister()}
+        <a class="loginButton" href=${this.loginUrl}>Sign in</a>
+        <a
+          class="settingsButton"
+          href="${getBaseUrl()}/settings/"
+          title="Settings"
+          aria-label="Settings"
+          role="button"
+        >
+          <gr-icon icon="settings" filled></gr-icon>
+        </a>
+        ${this.renderAccountDropdown()}
+      </div>
+    `;
+  }
+
+  private renderRegister() {
+    if (!this.registerURL) return;
+
+    return html`
+      <div class="registerDiv">
+        <a class="registerButton" href=${this.registerURL}>
+          ${this.registerText}
+        </a>
+      </div>
+    `;
+  }
+
+  private renderAccountDropdown() {
+    if (!this.account) return;
+
+    return html`
+      <gr-account-dropdown .account=${this.account}></gr-account-dropdown>
+    `;
+  }
+
+  override firstUpdated(changedProperties: PropertyValues) {
+    super.firstUpdated(changedProperties);
+    if (!this.getAttribute('role')) this.setAttribute('role', 'banner');
+  }
+
   reload() {
-    this._loadAccount();
+    this.loadAccount();
   }
 
-  _computeRelativeURL(path: string) {
-    return '//' + window.location.host + getBaseUrl() + path;
-  }
-
-  _computeLinks(
-    userLinks?: TopMenuItemInfo[],
+  // private but used in test
+  computeLinks(
+    userLinks?: MainHeaderLink[],
     adminLinks?: NavLink[],
     topMenus?: TopMenuEntryInfo[],
     docBaseUrl?: string | null,
     // defaultLinks parameter is used in tests only
     defaultLinks = DEFAULT_LINKS
   ) {
-    // Polymer 2: check for undefined
     if (
       userLinks === undefined ||
       adminLinks === undefined ||
       topMenus === undefined ||
       docBaseUrl === undefined
     ) {
-      return undefined;
+      return [];
     }
 
     const links: MainHeaderLinkGroup[] = defaultLinks.map(menu => {
@@ -232,7 +516,7 @@
         links: userLinks.slice(),
       });
     }
-    const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+    const docLinks = this.getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
     if (docLinks.length) {
       links.push({
         title: 'Documentation',
@@ -249,7 +533,7 @@
       topMenuLinks[link.title] = link.links;
     });
     for (const m of topMenus) {
-      const items = m.items.map(this._createHeaderLink).filter(
+      const items = m.items.map(this.createHeaderLink).filter(
         link =>
           // Ignore GWT project links
           !link.url.includes('${projectName}')
@@ -268,7 +552,8 @@
     return links;
   }
 
-  _getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
+  // private but used in test
+  getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
     if (!docBaseUrl) {
       return [];
     }
@@ -285,19 +570,20 @@
     });
   }
 
-  _loadAccount() {
+  // private but used in test
+  loadAccount() {
     this.loading = true;
 
     return Promise.all([
       this.restApiService.getAccount(),
       this.restApiService.getTopMenus(),
-      getPluginLoader().awaitPluginsLoaded(),
+      this.getPluginLoader().awaitPluginsLoaded(),
     ]).then(result => {
       const account = result[0];
-      this._account = account;
+      this.account = account;
       this.loggedIn = !!account;
       this.loading = false;
-      this._topMenus = result[1];
+      this.topMenus = result[1];
 
       return getAdminLinks(
         account,
@@ -308,33 +594,32 @@
             }
             return capabilities;
           }),
-        () => this.jsAPI.getAdminMenuLinks()
+        () => this.getPluginLoader().jsApiService.getAdminMenuLinks()
       ).then(res => {
-        this._adminLinks = res.links;
+        this.adminLinks = res.links;
       });
     });
   }
 
-  _retrieveFeedbackURL(config: ServerInfo) {
+  // private but used in test
+  retrieveFeedbackURL(config: ServerInfo) {
     if (config.gerrit?.report_bug_url) {
-      this._feedbackURL = config.gerrit.report_bug_url;
+      this.feedbackURL = config.gerrit.report_bug_url;
     }
   }
 
-  _retrieveRegisterURL(config: ServerInfo) {
+  // private but used in test
+  retrieveRegisterURL(config: ServerInfo) {
     if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
-      this._registerURL = config.auth.register_url ?? '';
+      this.registerURL = config.auth.register_url ?? '';
       if (config.auth.register_text) {
-        this._registerText = config.auth.register_text;
+        this.registerText = config.auth.register_text;
       }
     }
   }
 
-  _computeRegisterHidden(registerURL: string) {
-    return !registerURL;
-  }
-
-  _createHeaderLink(linkObj: TopMenuItemInfo): MainHeaderLink {
+  // private but used in test
+  createHeaderLink(linkObj: TopMenuItemInfo): MainHeaderLink {
     // Delete target property due to complications of
     // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
     //
@@ -353,36 +638,9 @@
     return headerLink;
   }
 
-  _generateSettingsLink() {
-    return getBaseUrl() + '/settings/';
-  }
-
-  _onMobileSearchTap(e: Event) {
+  private onMobileSearchTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('mobile-search', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-  }
-
-  _computeLinkGroupClass(linkGroup: MainHeaderLinkGroup) {
-    return linkGroup.class ?? '';
-  }
-
-  _computeShowHideAriaLabel(mobileSearchHidden: boolean) {
-    if (mobileSearchHidden) {
-      return 'Show Searchbar';
-    } else {
-      return 'Hide Searchbar';
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-main-header': GrMainHeader;
+    fireEvent(this, 'mobile-search');
   }
 }
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
deleted file mode 100644
index b623e8e..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
+++ /dev/null
@@ -1,257 +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;
-    }
-    nav {
-      align-items: center;
-      display: flex;
-    }
-    .bigTitle {
-      color: var(--header-text-color);
-      font-size: var(--header-title-font-size);
-      text-decoration: none;
-    }
-    .bigTitle:hover {
-      text-decoration: underline;
-    }
-    .titleText::before {
-      background-image: var(--header-icon);
-      background-size: var(--header-icon-size) var(--header-icon-size);
-      background-repeat: no-repeat;
-      content: '';
-      display: inline-block;
-      height: var(--header-icon-size);
-      margin-right: calc(var(--header-icon-size) / 4);
-      vertical-align: text-bottom;
-      width: var(--header-icon-size);
-    }
-    .titleText::after {
-      content: var(--header-title-content);
-    }
-    ul {
-      list-style: none;
-      padding-left: var(--spacing-l);
-    }
-    .links > li {
-      cursor: default;
-      display: inline-block;
-      padding: 0;
-      position: relative;
-    }
-    .linksTitle {
-      display: inline-block;
-      font-weight: var(--font-weight-bold);
-      position: relative;
-      text-transform: uppercase;
-    }
-    .linksTitle:hover {
-      opacity: 0.75;
-    }
-    .rightItems {
-      align-items: center;
-      display: flex;
-      flex: 1;
-      justify-content: flex-end;
-    }
-    .rightItems gr-endpoint-decorator:not(:empty) {
-      margin-left: var(--spacing-l);
-    }
-    gr-smart-search {
-      flex-grow: 1;
-      margin: 0 var(--spacing-m);
-      max-width: 500px;
-      min-width: 150px;
-    }
-    gr-dropdown,
-    .browse {
-      padding: var(--spacing-m);
-    }
-    gr-dropdown {
-      --gr-dropdown-item-color: var(--primary-text-color);
-    }
-    .settingsButton {
-      margin-left: var(--spacing-m);
-    }
-    .feedbackButton {
-      margin-left: var(--spacing-s);
-    }
-    .browse {
-      color: var(--header-text-color);
-      /* Same as gr-button */
-      margin: 5px 4px;
-      text-decoration: none;
-    }
-    .invisible,
-    .settingsButton,
-    gr-account-dropdown {
-      display: none;
-    }
-    :host([loading]) .accountContainer,
-    :host([logged-in]) .loginButton,
-    :host([logged-in]) .registerButton {
-      display: none;
-    }
-    :host([logged-in]) .settingsButton,
-    :host([logged-in]) gr-account-dropdown {
-      display: inline;
-    }
-    .accountContainer {
-      align-items: center;
-      display: flex;
-      margin: 0 calc(0 - var(--spacing-m)) 0 var(--spacing-m);
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    .loginButton,
-    .registerButton {
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    .dropdown-trigger {
-      text-decoration: none;
-    }
-    .dropdown-content {
-      background-color: var(--view-background-color);
-      box-shadow: var(--elevation-level-2);
-    }
-    /*
-       * We are not using :host to do this, because :host has a lowest css priority
-       * compared to others. This means that using :host to do this would break styles.
-       */
-    .linksTitle,
-    .bigTitle,
-    .loginButton,
-    .registerButton,
-    iron-icon,
-    gr-account-dropdown {
-      color: var(--header-text-color);
-    }
-    #mobileSearch {
-      display: none;
-    }
-    @media screen and (max-width: 50em) {
-      .bigTitle {
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      gr-smart-search,
-      .browse,
-      .rightItems .hideOnMobile,
-      .links > li.hideOnMobile {
-        display: none;
-      }
-      #mobileSearch {
-        display: inline-flex;
-      }
-      .accountContainer {
-        margin-left: var(--spacing-m) !important;
-      }
-      gr-dropdown {
-        padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
-      }
-    }
-  </style>
-  <nav>
-    <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">
-      <gr-endpoint-decorator name="header-title">
-        <span class="titleText"></span>
-      </gr-endpoint-decorator>
-    </a>
-    <ul class="links">
-      <template is="dom-repeat" items="[[_links]]" as="linkGroup">
-        <li class$="[[_computeLinkGroupClass(linkGroup)]]">
-          <gr-dropdown
-            link=""
-            down-arrow=""
-            items="[[linkGroup.links]]"
-            horizontal-align="left"
-          >
-            <span class="linksTitle" id="[[linkGroup.title]]">
-              [[linkGroup.title]]
-            </span>
-          </gr-dropdown>
-        </li>
-      </template>
-    </ul>
-    <div class="rightItems">
-      <gr-endpoint-decorator
-        class="hideOnMobile"
-        name="header-small-banner"
-      ></gr-endpoint-decorator>
-      <gr-smart-search
-        id="search"
-        label="Search for changes"
-        search-query="{{searchQuery}}"
-      ></gr-smart-search>
-      <gr-endpoint-decorator
-        class="hideOnMobile"
-        name="header-browse-source"
-      ></gr-endpoint-decorator>
-      <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
-        <template is="dom-if" if="[[_feedbackURL]]">
-          <a
-            href$="[[_feedbackURL]]"
-            title="File a bug"
-            aria-label="File a bug"
-            target="_blank"
-            role="button"
-          >
-            <iron-icon icon="gr-icons:bug"></iron-icon>
-          </a>
-        </template>
-      </gr-endpoint-decorator>
-      </div>
-      <div class="accountContainer" id="accountContainer">
-        <iron-icon
-          id="mobileSearch"
-          icon="gr-icons:search"
-          on-click="_onMobileSearchTap"
-          role="button"
-          aria-label="[[_computeShowHideAriaLabel(mobileSearchHidden)]]"
-        ></iron-icon>
-        <div
-          class="registerDiv"
-          hidden="[[_computeRegisterHidden(_registerURL)]]"
-        >
-          <a class="registerButton" href$="[[_registerURL]]">
-            [[_registerText]]
-          </a>
-        </div>
-        <a class="loginButton" href$="[[loginUrl]]">Sign in</a>
-        <a
-          class="settingsButton"
-          href$="[[_generateSettingsLink()]]"
-          title="Settings"
-          aria-label="Settings"
-          role="button"
-        >
-          <iron-icon icon="gr-icons:settings"></iron-icon>
-        </a>
-        <template is="dom-if" if="[[_account]]">
-          <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
-        </template>
-      </div>
-    </div>
-  </nav>
-`;
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 0f58ac0..7eb19f0 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
@@ -1,22 +1,15 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {isHidden, query, stubRestApi} from '../../../test/test-utils';
+import '../../../test/common-test-setup';
+import {
+  isHidden,
+  query,
+  stubElement,
+  stubRestApi,
+} from '../../../test/test-utils';
 import './gr-main-header';
 import {GrMainHeader} from './gr-main-header';
 import {
@@ -27,35 +20,106 @@
 import {NavLink} from '../../../utils/admin-nav-util';
 import {ServerInfo, TopMenuItemInfo} from '../../../types/common';
 import {AuthType} from '../../../constants/constants';
-
-const basicFixture = fixtureFromElement('gr-main-header');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-main-header tests', () => {
   let element: GrMainHeader;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('probePath').returns(Promise.resolve(false));
-    stub('gr-main-header', '_loadAccount').callsFake(() => Promise.resolve());
-    element = basicFixture.instantiate();
+    stubElement('gr-main-header', 'loadAccount').callsFake(() =>
+      Promise.resolve()
+    );
+    element = await fixture(html`<gr-main-header></gr-main-header>`);
   });
 
-  test('link visibility', () => {
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <nav>
+          <a class="bigTitle" href="//localhost:9876/">
+            <gr-endpoint-decorator name="header-title">
+              <span class="titleText"> </span>
+            </gr-endpoint-decorator>
+          </a>
+          <ul class="links">
+            <li>
+              <gr-dropdown down-arrow="" horizontal-align="left" link="">
+                <span class="linksTitle" id="Changes"> Changes </span>
+              </gr-dropdown>
+            </li>
+            <li>
+              <gr-dropdown down-arrow="" horizontal-align="left" link="">
+                <span class="linksTitle" id="Browse"> Browse </span>
+              </gr-dropdown>
+            </li>
+          </ul>
+          <div class="rightItems">
+            <gr-endpoint-decorator
+              class="hideOnMobile"
+              name="header-small-banner"
+            >
+            </gr-endpoint-decorator>
+            <gr-smart-search id="search" label="Search for changes">
+            </gr-smart-search>
+            <gr-endpoint-decorator
+              class="hideOnMobile"
+              name="header-browse-source"
+            >
+            </gr-endpoint-decorator>
+            <gr-endpoint-decorator
+              class="feedbackButton"
+              name="header-feedback"
+            >
+            </gr-endpoint-decorator>
+          </div>
+          <div class="accountContainer" id="accountContainer">
+            <div>
+              <gr-icon
+                aria-label="Hide Searchbar"
+                icon="search"
+                id="mobileSearch"
+                role="button"
+              >
+              </gr-icon>
+            </div>
+            <a class="loginButton" href="/login"> Sign in </a>
+            <a
+              aria-label="Settings"
+              class="settingsButton"
+              href="/settings/"
+              role="button"
+              title="Settings"
+            >
+              <gr-icon icon="settings" filled></gr-icon>
+            </a>
+          </div>
+        </nav>
+      `
+    );
+  });
+
+  test('link visibility', async () => {
     element.loading = true;
+    await element.updateComplete;
     assert.isTrue(isHidden(query(element, '.accountContainer')));
 
     element.loading = false;
     element.loggedIn = false;
+    await element.updateComplete;
     assert.isFalse(isHidden(query(element, '.accountContainer')));
     assert.isFalse(isHidden(query(element, '.loginButton')));
-    assert.isFalse(isHidden(query(element, '.registerButton')));
-    assert.isTrue(isHidden(query(element, '.registerDiv')));
+    assert.isNotOk(query(element, '.registerDiv'));
+    assert.isNotOk(query(element, '.registerButton'));
 
-    element._account = createAccountDetailWithId(1);
-    flush();
+    element.account = createAccountDetailWithId(1);
+    await element.updateComplete;
     assert.isTrue(isHidden(query(element, 'gr-account-dropdown')));
     assert.isTrue(isHidden(query(element, '.settingsButton')));
 
     element.loggedIn = true;
+    await element.updateComplete;
     assert.isTrue(isHidden(query(element, '.loginButton')));
     assert.isTrue(isHidden(query(element, '.registerButton')));
     assert.isFalse(isHidden(query(element, 'gr-account-dropdown')));
@@ -67,7 +131,7 @@
       [
         {url: 'https://awesometown.com/#hashyhash', name: '', target: ''},
         {url: 'url', name: '', target: '_blank'},
-      ].map(element._createHeaderLink),
+      ].map(element.createHeaderLink),
       [
         {url: 'https://awesometown.com/#hashyhash', name: ''},
         {url: 'url', name: ''},
@@ -99,13 +163,13 @@
         name: 'Repos',
         url: '/repos',
         noBaseUrl: true,
-        view: null,
+        view: undefined,
       },
     ];
 
     // When no admin links are passed, it should use the default.
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         adminLinks,
         /* topMenus= */ [],
@@ -118,7 +182,7 @@
       })
     );
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         userLinks,
         adminLinks,
         /* topMenus= */ [],
@@ -146,11 +210,11 @@
       },
     ];
 
-    assert.deepEqual(element._getDocLinks(null, docLinks), []);
-    assert.deepEqual(element._getDocLinks('', docLinks), []);
-    assert.deepEqual(element._getDocLinks('base', []), []);
+    assert.deepEqual(element.getDocLinks(null, docLinks), []);
+    assert.deepEqual(element.getDocLinks('', docLinks), []);
+    assert.deepEqual(element.getDocLinks('base', []), []);
 
-    assert.deepEqual(element._getDocLinks('base', docLinks), [
+    assert.deepEqual(element.getDocLinks('base', docLinks), [
       {
         name: 'Table of Contents',
         target: '_blank',
@@ -158,7 +222,7 @@
       },
     ]);
 
-    assert.deepEqual(element._getDocLinks('base/', docLinks), [
+    assert.deepEqual(element.getDocLinks('base/', docLinks), [
       {
         name: 'Table of Contents',
         target: '_blank',
@@ -173,7 +237,7 @@
         name: 'Repos',
         url: '/repos',
         noBaseUrl: true,
-        view: null,
+        view: undefined,
       },
     ];
     const topMenus = [
@@ -189,7 +253,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         adminLinks,
         topMenus,
@@ -220,7 +284,7 @@
         name: 'Repos',
         url: '/repos',
         noBaseUrl: true,
-        view: null,
+        view: undefined,
       },
     ];
     const topMenus = [
@@ -241,7 +305,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         adminLinks,
         topMenus,
@@ -272,7 +336,7 @@
         name: 'Repos',
         url: '/repos',
         noBaseUrl: true,
-        view: null,
+        view: undefined,
       },
     ];
     const topMenus = [
@@ -298,7 +362,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         adminLinks,
         topMenus,
@@ -352,7 +416,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         /* adminLinks= */ [],
         topMenus,
@@ -398,7 +462,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         userLinks,
         /* adminLinks= */ [],
         topMenus,
@@ -434,7 +498,7 @@
         name: 'Repos',
         url: '/repos',
         noBaseUrl: true,
-        view: null,
+        view: undefined,
       },
     ];
     const topMenus = [
@@ -450,7 +514,7 @@
       },
     ];
     assert.deepEqual(
-      element._computeLinks(
+      element.computeLinks(
         /* userLinks= */ [],
         adminLinks,
         topMenus,
@@ -473,7 +537,7 @@
   });
 
   test('shows feedback icon when URL provided', async () => {
-    assert.isEmpty(element._feedbackURL);
+    assert.isEmpty(element.feedbackURL);
     assert.isNotOk(query(element, '.feedbackButton > a'));
 
     const url = 'report_bug_url';
@@ -484,14 +548,14 @@
         report_bug_url: url,
       },
     };
-    element._retrieveFeedbackURL(config);
-    await flush();
+    element.retrieveFeedbackURL(config);
+    await element.updateComplete;
 
-    assert.equal(element._feedbackURL, url);
+    assert.equal(element.feedbackURL, url);
     assert.ok(query(element, '.feedbackButton > a'));
   });
 
-  test('register URL', () => {
+  test('register URL', async () => {
     assert.isTrue(isHidden(query(element, '.registerDiv')));
     const config: ServerInfo = {
       ...createServerInfo(),
@@ -501,19 +565,21 @@
         editable_account_fields: [],
       },
     };
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, config.auth.register_url);
-    assert.equal(element._registerText, 'Sign up');
+    element.retrieveRegisterURL(config);
+    await element.updateComplete;
+    assert.equal(element.registerURL, config.auth.register_url);
+    assert.equal(element.registerText, 'Sign up');
     assert.isFalse(isHidden(query(element, '.registerDiv')));
 
     config.auth.register_text = 'Create account';
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, config.auth.register_url);
-    assert.equal(element._registerText, config.auth.register_text);
+    element.retrieveRegisterURL(config);
+    await element.updateComplete;
+    assert.equal(element.registerURL, config.auth.register_url);
+    assert.equal(element.registerText, config.auth.register_text);
     assert.isFalse(isHidden(query(element, '.registerDiv')));
   });
 
-  test('register URL ignored for wrong auth type', () => {
+  test('register URL ignored for wrong auth type', async () => {
     const config: ServerInfo = {
       ...createServerInfo(),
       auth: {
@@ -522,9 +588,10 @@
         editable_account_fields: [],
       },
     };
-    element._retrieveRegisterURL(config);
-    assert.equal(element._registerURL, '');
-    assert.equal(element._registerText, 'Sign up');
+    element.retrieveRegisterURL(config);
+    await element.updateComplete;
+    assert.equal(element.registerURL, '');
+    assert.equal(element.registerText, 'Sign up');
     assert.isTrue(isHidden(query(element, '.registerDiv')));
   });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 6b4006c..4af24cc 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -1,1046 +1,29 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {
-  BasePatchSetNum,
-  BranchName,
-  ChangeConfigInfo,
-  ChangeInfo,
-  CommentLinks,
-  CommitId,
-  DashboardId,
-  EditPatchSetNum,
-  GroupId,
-  Hashtag,
-  NumericChangeId,
-  ParentPatchSetNum,
-  PatchSetNum,
-  RepoName,
-  ServerInfo,
-  TopicName,
-  UrlEncodedCommentId,
-} from '../../../types/common';
-import {GerritView} from '../../../services/router/router-model';
-import {ParsedChangeInfo} from '../../../types/types';
+import {define} from '../../../models/dependency';
 
-// Navigation parameters object format:
-//
-// Each object has a `view` property with a value from GerritNav.View. The
-// remaining properties depend on the value used for view.
-//
-//  - GerritNav.View.CHANGE:
-//    - `changeNum`, required, String: the numeric ID of the change.
-//    - `project`, optional, String: the project name.
-//    - `patchNum`, optional, Number: the patch for the right-hand-side of
-//        the diff.
-//    - `basePatchNum`, optional, Number: the patch for the left-hand-side
-//        of the diff. If `basePatchNum` is provided, then `patchNum` must
-//        also be provided.
-//    - `edit`, optional, Boolean: whether or not to load the file list with
-//        edit controls.
-//    - `messageHash`, optional, String: the hash of the change message to
-//        scroll to.
-//
-// - GerritNav.View.SEARCH:
-//    - `query`, optional, String: the literal search query. If provided,
-//        the string will be used as the query, and all other params will be
-//        ignored.
-//    - `owner`, optional, String: the owner name.
-//    - `project`, optional, String: the project name.
-//    - `branch`, optional, String: the branch name.
-//    - `topic`, optional, String: the topic name.
-//    - `hashtag`, optional, String: the hashtag name.
-//    - `statuses`, optional, Array<String>: the list of change statuses to
-//        search for. If more than one is provided, the search will OR them
-//        together.
-//    - `offset`, optional, Number: the offset for the query.
-//
-//  - GerritNav.View.DIFF:
-//    - `changeNum`, required, String: the numeric ID of the change.
-//    - `path`, required, String: the filepath of the diff.
-//    - `patchNum`, required, Number: the patch for the right-hand-side of
-//        the diff.
-//    - `basePatchNum`, optional, Number: the patch for the left-hand-side
-//        of the diff. If `basePatchNum` is provided, then `patchNum` must
-//        also be provided.
-//    - `lineNum`, optional, Number: the line number to be selected on load.
-//    - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
-//        of true selects the line from base of the patch range. False by
-//        default.
-//
-//  - GerritNav.View.GROUP:
-//    - `groupId`, required, String: the ID of the group.
-//    - `detail`, optional, String: the name of the group detail view.
-//      Takes any value from GerritNav.GroupDetailView.
-//
-//  - GerritNav.View.REPO:
-//    - `repoName`, required, String: the name of the repo
-//    - `detail`, optional, String: the name of the repo detail view.
-//      Takes any value from GerritNav.RepoDetailView.
-//
-//  - GerritNav.View.DASHBOARD
-//    - `repo`, optional, String.
-//    - `sections`, optional, Array of objects with `title` and `query`
-//      strings.
-//    - `user`, optional, String.
-//
-//  - GerritNav.View.ROOT:
-//    - no possible parameters.
+export const navigationToken = define<NavigationService>('navigation');
 
-const uninitialized = () => {
-  console.warn('Use of uninitialized routing');
-};
-
-const uninitializedNavigate: NavigateCallback = () => {
-  uninitialized();
-  return '';
-};
-
-const uninitializedGenerateUrl: GenerateUrlCallback = () => {
-  uninitialized();
-  return '';
-};
-
-const uninitializedGenerateWebLinks: GenerateWebLinksCallback = () => {
-  uninitialized();
-  return [];
-};
-
-const uninitializedMapCommentLinks: MapCommentLinksCallback = () => {
-  uninitialized();
-  return {};
-};
-
-const USER_PLACEHOLDER_PATTERN = /\${user}/g;
-
-export interface DashboardSection {
-  name: string;
-  query: string;
-  suffixForDashboard?: string;
-  selfOnly?: boolean;
-  hideIfEmpty?: boolean;
-  assigneeOnly?: boolean;
-  isOutgoing?: boolean;
-  results?: ChangeInfo[];
-}
-
-export interface UserDashboardConfig {
-  change?: ChangeConfigInfo;
-}
-
-export interface UserDashboard {
-  title?: string;
-  sections: DashboardSection[];
-}
-
-// NOTE: These queries are tested in Java. Any changes made to definitions
-// here require corresponding changes to:
-// java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
-const HAS_DRAFTS: DashboardSection = {
-  // Changes with unpublished draft comments. This section is omitted when
-  // viewing other users, so we don't need to filter anything out.
-  name: 'Has draft comments',
-  query: 'has:draft',
-  selfOnly: true,
-  hideIfEmpty: true,
-  suffixForDashboard: 'limit:10',
-};
-export const YOUR_TURN: DashboardSection = {
-  // Changes where the user is in the attention set.
-  name: 'Your Turn',
-  query: 'attention:${user}',
-  hideIfEmpty: false,
-  suffixForDashboard: 'limit:25',
-};
-const ASSIGNED: DashboardSection = {
-  // Changes that are assigned to the viewed user.
-  name: 'Assigned reviews',
-  query:
-    'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
-    'is:open -is:ignored',
-  hideIfEmpty: true,
-  suffixForDashboard: 'limit:25',
-  assigneeOnly: true,
-};
-const WIP: DashboardSection = {
-  // WIP open changes owned by viewing user. This section is omitted when
-  // viewing other users, so we don't need to filter anything out.
-  name: 'Work in progress',
-  query: 'is:open owner:${user} is:wip',
-  selfOnly: true,
-  hideIfEmpty: true,
-  suffixForDashboard: 'limit:25',
-};
-const OUTGOING: DashboardSection = {
-  // Non-WIP open changes owned by viewed user. Filter out changes ignored
-  // by the viewing user.
-  name: 'Outgoing reviews',
-  query: 'is:open owner:${user} -is:wip -is:ignored',
-  isOutgoing: true,
-  suffixForDashboard: 'limit:25',
-};
-const INCOMING: DashboardSection = {
-  // Non-WIP open changes not owned by the viewed user, that the viewed user
-  // is associated with (as either a reviewer or the assignee). Changes
-  // ignored by the viewing user are filtered out.
-  name: 'Incoming reviews',
-  query:
-    'is:open -owner:${user} -is:wip -is:ignored ' +
-    '(reviewer:${user} OR assignee:${user})',
-  suffixForDashboard: 'limit:25',
-};
-const CCED: DashboardSection = {
-  // Open changes the viewed user is CCed on. Changes ignored by the viewing
-  // user are filtered out.
-  name: 'CCed on',
-  query: 'is:open -is:ignored -is:wip cc:${user}',
-  suffixForDashboard: 'limit:10',
-};
-export const CLOSED: DashboardSection = {
-  name: 'Recently closed',
-  // Closed changes where viewed user is owner, reviewer, or assignee.
-  // Changes ignored by the viewing user are filtered out, and so are WIP
-  // changes not owned by the viewing user (the one instance of
-  // 'owner:self' is intentional and implements this logic).
-  query:
-    'is:closed -is:ignored (-is:wip OR owner:self) ' +
-    '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
-    'OR cc:${user})',
-  suffixForDashboard: '-age:4w limit:10',
-};
-const DEFAULT_SECTIONS: DashboardSection[] = [
-  HAS_DRAFTS,
-  YOUR_TURN,
-  ASSIGNED,
-  WIP,
-  OUTGOING,
-  INCOMING,
-  CCED,
-  CLOSED,
-];
-
-export interface GenerateUrlSearchViewParameters {
-  view: GerritView.SEARCH;
-  query?: string;
-  offset?: number;
-  project?: RepoName;
-  branch?: BranchName;
-  topic?: TopicName;
-  // TODO(TS): Define more precise type (enum?)
-  statuses?: string[];
-  hashtag?: string;
-  host?: string;
-  owner?: string;
-}
-
-export interface GenerateUrlChangeViewParameters {
-  view: GerritView.CHANGE;
-  // TODO(TS): NumericChangeId - not sure about it, may be it can be removed
-  changeNum: NumericChangeId;
-  project: RepoName;
-  patchNum?: PatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-  edit?: boolean;
-  host?: string;
-  messageHash?: string;
-  commentId?: UrlEncodedCommentId;
-  forceReload?: boolean;
-  tab?: string;
-}
-
-export interface GenerateUrlRepoViewParameters {
-  view: GerritView.REPO;
-  repoName: RepoName;
-  detail?: RepoDetailView;
-}
-
-export interface GenerateUrlDashboardViewParameters {
-  view: GerritView.DASHBOARD;
-  user?: string;
-  repo?: RepoName;
-  dashboard?: DashboardId;
-
-  // TODO(TS): properties bellow aren't set anywhere, try to remove
-  project?: RepoName;
-  sections?: DashboardSection[];
-  title?: string;
-}
-
-export interface GenerateUrlGroupViewParameters {
-  view: GerritView.GROUP;
-  groupId: GroupId;
-  detail?: GroupDetailView;
-}
-
-export interface GenerateUrlEditViewParameters {
-  view: GerritView.EDIT;
-  changeNum: NumericChangeId;
-  project: RepoName;
-  path: string;
-  patchNum: PatchSetNum;
-  lineNum?: number | string;
-}
-
-export interface GenerateUrlRootViewParameters {
-  view: GerritView.ROOT;
-}
-
-export interface GenerateUrlSettingsViewParameters {
-  view: GerritView.SETTINGS;
-}
-
-export interface GenerateUrlDiffViewParameters {
-  view: GerritView.DIFF;
-  changeNum: NumericChangeId;
-  project: RepoName;
-  path?: string;
-  patchNum?: PatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-  lineNum?: number | string;
-  leftSide?: boolean;
-  commentId?: UrlEncodedCommentId;
-  // TODO(TS): remove - property is set but never used
-  commentLink?: boolean;
-}
-
-export type GenerateUrlParameters =
-  | GenerateUrlSearchViewParameters
-  | GenerateUrlChangeViewParameters
-  | GenerateUrlRepoViewParameters
-  | GenerateUrlDashboardViewParameters
-  | GenerateUrlGroupViewParameters
-  | GenerateUrlEditViewParameters
-  | GenerateUrlRootViewParameters
-  | GenerateUrlSettingsViewParameters
-  | GenerateUrlDiffViewParameters;
-
-export function isGenerateUrlChangeViewParameters(
-  x: GenerateUrlParameters
-): x is GenerateUrlChangeViewParameters {
-  return x.view === GerritView.CHANGE;
-}
-
-export function isGenerateUrlEditViewParameters(
-  x: GenerateUrlParameters
-): x is GenerateUrlEditViewParameters {
-  return x.view === GerritView.EDIT;
-}
-
-export function isGenerateUrlDiffViewParameters(
-  x: GenerateUrlParameters
-): x is GenerateUrlDiffViewParameters {
-  return x.view === GerritView.DIFF;
-}
-
-export interface GenerateWebLinksOptions {
-  weblinks?: GeneratedWebLink[];
-  config?: ServerInfo;
-}
-
-export interface GenerateWebLinksPatchsetParameters {
-  type: WeblinkType.PATCHSET;
-  repo: RepoName;
-  commit?: CommitId;
-  options?: GenerateWebLinksOptions;
-}
-export interface GenerateWebLinksResolveConflictsParameters {
-  type: WeblinkType.RESOLVE_CONFLICTS;
-  repo: RepoName;
-  commit?: CommitId;
-  options?: GenerateWebLinksOptions;
-}
-export interface GenerateWebLinksEditParameters {
-  type: WeblinkType.EDIT;
-  repo: RepoName;
-  commit: CommitId;
-  file: string;
-  options?: GenerateWebLinksOptions;
-}
-export interface GenerateWebLinksFileParameters {
-  type: WeblinkType.FILE;
-  repo: RepoName;
-  commit: CommitId;
-  file: string;
-  options?: GenerateWebLinksOptions;
-}
-export interface GenerateWebLinksChangeParameters {
-  type: WeblinkType.CHANGE;
-  repo: RepoName;
-  commit: CommitId;
-  options?: GenerateWebLinksOptions;
-}
-
-export type GenerateWebLinksParameters =
-  | GenerateWebLinksPatchsetParameters
-  | GenerateWebLinksResolveConflictsParameters
-  | GenerateWebLinksEditParameters
-  | GenerateWebLinksFileParameters
-  | GenerateWebLinksChangeParameters;
-
-export type NavigateCallback = (target: string, redirect?: boolean) => void;
-export type GenerateUrlCallback = (params: GenerateUrlParameters) => string;
-// TODO: Refactor to return only GeneratedWebLink[]
-export type GenerateWebLinksCallback = (
-  params: GenerateWebLinksParameters
-) => GeneratedWebLink[] | GeneratedWebLink;
-
-export type MapCommentLinksCallback = (patterns: CommentLinks) => CommentLinks;
-
-export interface WebLink {
-  name?: string;
-  label: string;
-  url: string;
-}
-
-export interface GeneratedWebLink {
-  name?: string;
-  label?: string;
-  url?: string;
-}
-
-export enum GroupDetailView {
-  MEMBERS = 'members',
-  LOG = 'log',
-}
-
-export enum RepoDetailView {
-  GENERAL = 'general',
-  ACCESS = 'access',
-  BRANCHES = 'branches',
-  COMMANDS = 'commands',
-  DASHBOARDS = 'dashboards',
-  TAGS = 'tags',
-}
-
-export enum WeblinkType {
-  CHANGE = 'change',
-  EDIT = 'edit',
-  FILE = 'file',
-  PATCHSET = 'patchset',
-  RESOLVE_CONFLICTS = 'resolve-conflicts',
-}
-
-// TODO(dmfilippov) Convert to class, extract consts, give better name and
-// expose as a service from appContext
-export const GerritNav = {
-  View: GerritView,
-
-  GroupDetailView,
-
-  RepoDetailView,
-
-  WeblinkType,
-
-  _navigate: uninitializedNavigate,
-
-  _generateUrl: uninitializedGenerateUrl,
-
-  _generateWeblinks: uninitializedGenerateWebLinks,
-
-  mapCommentlinks: uninitializedMapCommentLinks,
-
-  _checkPatchRange(patchNum?: PatchSetNum, basePatchNum?: BasePatchSetNum) {
-    if (basePatchNum && !patchNum) {
-      throw new Error('Cannot use base patch number without patch number.');
-    }
-  },
-
+export interface NavigationService {
   /**
-   * Setup router implementation.
+   * This is similar to letting the browser navigate to this URL when the user
+   * clicks it, or to just setting `window.location.href` directly.
    *
-   * @param navigate the router-abstracted equivalent of
-   *     `window.location.href = ...` or window.location.replace(...). The
-   *     string is a new location and boolean defines is it redirect or not
-   *     (true means redirect, i.e. equivalent of window.location.replace).
-   * @param generateUrl generates a URL given
-   *     navigation parameters, detailed in the file header.
-   * @param generateWeblinks weblinks generator
-   *     function takes single payload parameter with type property that
-   *  determines which
-   *     part of the UI is the consumer of the weblinks. type property can
-   *     be one of file, change, or patchset.
-   *     - For file type, payload will also contain string properties: repo,
-   *         commit, file.
-   *     - For patchset type, payload will also contain string properties:
-   *         repo, commit.
-   *     - For change type, payload will also contain string properties:
-   *         repo, commit. If server provides weblinks, those will be passed
-   *         as options.weblinks property on the main payload object.
-   * @param mapCommentlinks provides an escape
-   *     hatch to modify the commentlinks object, e.g. if it contains any
-   *     relative URLs.
+   * This adds a new entry to the browser location history. Consier using
+   * `replaceUrl()`, if you want to avoid that.
+   *
+   * page.show() eventually just calls `window.history.pushState()`.
    */
-  setup(
-    navigate: NavigateCallback,
-    generateUrl: GenerateUrlCallback,
-    generateWeblinks: GenerateWebLinksCallback,
-    mapCommentlinks: MapCommentLinksCallback
-  ) {
-    this._navigate = navigate;
-    this._generateUrl = generateUrl;
-    this._generateWeblinks = generateWeblinks;
-    this.mapCommentlinks = mapCommentlinks;
-  },
-
-  destroy() {
-    this._navigate = uninitializedNavigate;
-    this._generateUrl = uninitializedGenerateUrl;
-    this._generateWeblinks = uninitializedGenerateWebLinks;
-    this.mapCommentlinks = uninitializedMapCommentLinks;
-  },
+  setUrl(url: string): void;
 
   /**
-   * Generate a URL for the given route parameters.
+   * Navigate to this URL, but replace the current URL in the history instead of
+   * adding a new one (which is what `setUrl()` would do).
+   *
+   * page.redirect() eventually just calls `window.history.replaceState()`.
    */
-  _getUrlFor(params: GenerateUrlParameters) {
-    return this._generateUrl(params);
-  },
-
-  getUrlForSearchQuery(query: string, offset?: number) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      query,
-      offset,
-    });
-  },
-
-  /**
-   * @param openOnly When true, only search open changes in the project.
-   * @param host The host in which to search.
-   */
-  getUrlForProjectChanges(
-    project: RepoName,
-    openOnly?: boolean,
-    host?: string
-  ) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      project,
-      statuses: openOnly ? ['open'] : [],
-      host,
-    });
-  },
-
-  /**
-   * @param status The status to search.
-   * @param host The host in which to search.
-   */
-  getUrlForBranch(
-    branch: BranchName,
-    project: RepoName,
-    status?: string,
-    host?: string
-  ) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      branch,
-      project,
-      statuses: status ? [status] : undefined,
-      host,
-    });
-  },
-
-  /**
-   * @param topic The name of the topic.
-   * @param host The host in which to search.
-   */
-  getUrlForTopic(topic: TopicName, host?: string) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      topic,
-      host,
-    });
-  },
-
-  /**
-   * @param hashtag The name of the hashtag.
-   */
-  getUrlForHashtag(hashtag: Hashtag) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      hashtag,
-      statuses: ['open', 'merged'],
-    });
-  },
-
-  /**
-   * Navigate to a search for changes with the given status.
-   */
-  navigateToStatusSearch(status: string) {
-    this._navigate(
-      this._getUrlFor({
-        view: GerritView.SEARCH,
-        statuses: [status],
-      })
-    );
-  },
-
-  /**
-   * Navigate to a search query
-   */
-  navigateToSearchQuery(query: string, offset?: number) {
-    return this._navigate(this.getUrlForSearchQuery(query, offset));
-  },
-
-  /**
-   * Navigate to the user's dashboard
-   */
-  navigateToUserDashboard() {
-    return this._navigate(this.getUrlForUserDashboard('self'));
-  },
-
-  /**
-   * @param basePatchNum The string 'PARENT' can be used for none.
-   */
-  getUrlForChange(
-    change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
-    patchNum?: PatchSetNum,
-    basePatchNum?: BasePatchSetNum,
-    isEdit?: boolean,
-    messageHash?: string,
-    forceReload?: boolean
-  ) {
-    if (basePatchNum === ParentPatchSetNum) {
-      basePatchNum = undefined;
-    }
-
-    this._checkPatchRange(patchNum, basePatchNum);
-    return this._getUrlFor({
-      view: GerritView.CHANGE,
-      changeNum: change._number,
-      project: change.project,
-      patchNum,
-      basePatchNum,
-      edit: isEdit,
-      host: change.internalHost || undefined,
-      messageHash,
-      forceReload,
-    });
-  },
-
-  getUrlForChangeById(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    patchNum?: PatchSetNum
-  ) {
-    return this._getUrlFor({
-      view: GerritView.CHANGE,
-      changeNum,
-      project,
-      patchNum,
-    });
-  },
-
-  /**
-   * @param basePatchNum The string 'PARENT' can be used for none.
-   * @param redirect redirect to a change - if true, the current
-   *     location (i.e. page which makes redirect) is not added to a history.
-   *     I.e. back/forward buttons skip current location
-   * @param forceReload Some views are smart about how to handle the reload
-   *     of the view. In certain cases we want to force the view to reload
-   *     and re-render everything.
-   */
-  // TODO(dhruvsri): move the arguments into one options object
-  navigateToChange(
-    change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
-    patchNum?: PatchSetNum,
-    basePatchNum?: BasePatchSetNum,
-    isEdit?: boolean,
-    redirect?: boolean,
-    forceReload?: boolean
-  ) {
-    this._navigate(
-      this.getUrlForChange(
-        change,
-        patchNum,
-        basePatchNum,
-        isEdit,
-        undefined,
-        forceReload
-      ),
-      redirect
-    );
-  },
-
-  /**
-   * @param basePatchNum The string 'PARENT' can be used for none.
-   */
-  getUrlForDiff(
-    change: ChangeInfo | ParsedChangeInfo,
-    filePath: string,
-    patchNum?: PatchSetNum,
-    basePatchNum?: BasePatchSetNum,
-    lineNum?: number
-  ) {
-    return this.getUrlForDiffById(
-      change._number,
-      change.project,
-      filePath,
-      patchNum,
-      basePatchNum,
-      lineNum
-    );
-  },
-
-  getUrlForComment(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    commentId: UrlEncodedCommentId
-  ) {
-    return this._getUrlFor({
-      view: GerritView.DIFF,
-      changeNum,
-      project,
-      commentId,
-    });
-  },
-
-  getUrlForCommentsTab(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    commentId: UrlEncodedCommentId
-  ) {
-    return this._getUrlFor({
-      view: GerritView.CHANGE,
-      changeNum,
-      project,
-      commentId,
-    });
-  },
-
-  /**
-   * @param basePatchNum The string 'PARENT' can be used for none.
-   */
-  getUrlForDiffById(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    filePath: string,
-    patchNum?: PatchSetNum,
-    basePatchNum?: BasePatchSetNum,
-    lineNum?: number,
-    leftSide?: boolean
-  ) {
-    if (basePatchNum === ParentPatchSetNum) {
-      basePatchNum = undefined;
-    }
-
-    this._checkPatchRange(patchNum, basePatchNum);
-    return this._getUrlFor({
-      view: GerritView.DIFF,
-      changeNum,
-      project,
-      path: filePath,
-      patchNum,
-      basePatchNum,
-      lineNum,
-      leftSide,
-    });
-  },
-
-  getEditUrlForDiff(
-    change: ChangeInfo | ParsedChangeInfo,
-    filePath: string,
-    patchNum?: PatchSetNum,
-    lineNum?: number
-  ) {
-    return this.getEditUrlForDiffById(
-      change._number,
-      change.project,
-      filePath,
-      patchNum,
-      lineNum
-    );
-  },
-
-  /**
-   * @param patchNum The patchNum the file content should be based on, or
-   *   ${EditPatchSetNum} if left undefined.
-   * @param lineNum The line number to pass to the inline editor.
-   */
-  getEditUrlForDiffById(
-    changeNum: NumericChangeId,
-    project: RepoName,
-    filePath: string,
-    patchNum?: PatchSetNum,
-    lineNum?: number
-  ) {
-    return this._getUrlFor({
-      view: GerritView.EDIT,
-      changeNum,
-      project,
-      path: filePath,
-      patchNum: patchNum || EditPatchSetNum,
-      lineNum,
-    });
-  },
-
-  /**
-   * @param basePatchNum The string 'PARENT' can be used for none.
-   */
-  navigateToDiff(
-    change: ChangeInfo | ParsedChangeInfo,
-    filePath: string,
-    patchNum?: PatchSetNum,
-    basePatchNum?: BasePatchSetNum,
-    lineNum?: number
-  ) {
-    this._navigate(
-      this.getUrlForDiff(change, filePath, patchNum, basePatchNum, lineNum)
-    );
-  },
-
-  /**
-   * @param owner The name of the owner.
-   */
-  getUrlForOwner(owner: string) {
-    return this._getUrlFor({
-      view: GerritView.SEARCH,
-      owner,
-    });
-  },
-
-  /**
-   * @param user The name of the user.
-   */
-  getUrlForUserDashboard(user: string) {
-    return this._getUrlFor({
-      view: GerritView.DASHBOARD,
-      user,
-    });
-  },
-
-  getUrlForRoot() {
-    return this._getUrlFor({
-      view: GerritView.ROOT,
-    });
-  },
-
-  /**
-   * @param repo The name of the repo.
-   * @param dashboard The ID of the dashboard, in the form of '<ref>:<path>'.
-   */
-  getUrlForRepoDashboard(repo: RepoName, dashboard: DashboardId) {
-    return this._getUrlFor({
-      view: GerritView.DASHBOARD,
-      repo,
-      dashboard,
-    });
-  },
-
-  /**
-   * Navigate to an arbitrary relative URL.
-   */
-  navigateToRelativeUrl(relativeUrl: string) {
-    if (!relativeUrl.startsWith('/')) {
-      throw new Error('navigateToRelativeUrl with non-relative URL');
-    }
-    this._navigate(relativeUrl);
-  },
-
-  getUrlForRepo(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      detail: RepoDetailView.GENERAL,
-      repoName,
-    });
-  },
-
-  /**
-   * Navigate to a repo settings page.
-   */
-  navigateToRepo(repoName: RepoName) {
-    this._navigate(this.getUrlForRepo(repoName));
-  },
-
-  getUrlForRepoTags(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      repoName,
-      detail: RepoDetailView.TAGS,
-    });
-  },
-
-  getUrlForRepoBranches(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      repoName,
-      detail: GerritNav.RepoDetailView.BRANCHES,
-    });
-  },
-
-  getUrlForRepoAccess(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      repoName,
-      detail: GerritNav.RepoDetailView.ACCESS,
-    });
-  },
-
-  getUrlForRepoCommands(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      repoName,
-      detail: GerritNav.RepoDetailView.COMMANDS,
-    });
-  },
-
-  getUrlForRepoDashboards(repoName: RepoName) {
-    return this._getUrlFor({
-      view: GerritView.REPO,
-      repoName,
-      detail: GerritNav.RepoDetailView.DASHBOARDS,
-    });
-  },
-
-  getUrlForGroup(groupId: GroupId) {
-    return this._getUrlFor({
-      view: GerritView.GROUP,
-      groupId,
-    });
-  },
-
-  getUrlForGroupLog(groupId: GroupId) {
-    return this._getUrlFor({
-      view: GerritView.GROUP,
-      groupId,
-      detail: GerritNav.GroupDetailView.LOG,
-    });
-  },
-
-  getUrlForGroupMembers(groupId: GroupId) {
-    return this._getUrlFor({
-      view: GerritView.GROUP,
-      groupId,
-      detail: GroupDetailView.MEMBERS,
-    });
-  },
-
-  getUrlForSettings() {
-    return this._getUrlFor({view: GerritView.SETTINGS});
-  },
-
-  getEditWebLinks(
-    repo: RepoName,
-    commit: CommitId,
-    file: string,
-    options?: GenerateWebLinksOptions
-  ): GeneratedWebLink[] {
-    const params: GenerateWebLinksEditParameters = {
-      type: WeblinkType.EDIT,
-      repo,
-      commit,
-      file,
-    };
-    if (options) {
-      params.options = options;
-    }
-    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
-  },
-
-  getFileWebLinks(
-    repo: RepoName,
-    commit: CommitId,
-    file: string,
-    options?: GenerateWebLinksOptions
-  ): GeneratedWebLink[] {
-    const params: GenerateWebLinksFileParameters = {
-      type: WeblinkType.FILE,
-      repo,
-      commit,
-      file,
-    };
-    if (options) {
-      params.options = options;
-    }
-    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
-  },
-
-  getPatchSetWeblink(
-    repo: RepoName,
-    commit?: CommitId,
-    options?: GenerateWebLinksOptions
-  ): GeneratedWebLink {
-    const params: GenerateWebLinksPatchsetParameters = {
-      type: WeblinkType.PATCHSET,
-      repo,
-      commit,
-    };
-    if (options) {
-      params.options = options;
-    }
-    const result = this._generateWeblinks(params);
-    if (Array.isArray(result)) {
-      // TODO(TS): Unclear what to do with empty array.
-      // Either write a comment why result can't be empty or change the return
-      // type or add a check.
-      return result.pop()!;
-    } else {
-      return result;
-    }
-  },
-
-  getResolveConflictsWeblinks(
-    repo: RepoName,
-    commit?: CommitId,
-    options?: GenerateWebLinksOptions
-  ): GeneratedWebLink[] {
-    const params: GenerateWebLinksResolveConflictsParameters = {
-      type: WeblinkType.RESOLVE_CONFLICTS,
-      repo,
-      commit,
-    };
-    if (options) {
-      params.options = options;
-    }
-    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
-  },
-
-  getChangeWeblinks(
-    repo: RepoName,
-    commit: CommitId,
-    options?: GenerateWebLinksOptions
-  ): GeneratedWebLink[] {
-    const params: GenerateWebLinksChangeParameters = {
-      type: WeblinkType.CHANGE,
-      repo,
-      commit,
-    };
-    if (options) {
-      params.options = options;
-    }
-    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
-  },
-
-  getUserDashboard(
-    user = 'self',
-    sections = DEFAULT_SECTIONS,
-    title = '',
-    config: UserDashboardConfig = {}
-  ): UserDashboard {
-    const assigneeEnabled = config.change && !!config.change.enable_assignee;
-    sections = sections
-      .filter(section => assigneeEnabled || !section.assigneeOnly)
-      .filter(section => user === 'self' || !section.selfOnly)
-      .map(section => {
-        return {
-          ...section,
-          name: section.name,
-          query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
-        };
-      });
-    return {title, sections};
-  },
-};
+  replaceUrl(url: string): void;
+}
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js
deleted file mode 100644
index 93a1e9e..0000000
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {GerritNav} from './gr-navigation.js';
-
-suite('gr-navigation tests', () => {
-  test('invalid patch ranges throw exceptions', () => {
-    assert.throw(() => GerritNav.getUrlForChange('123', undefined, 12));
-    assert.throw(() => GerritNav.getUrlForDiff('123', 'x.c', undefined, 12));
-  });
-
-  suite('_getUserDashboard', () => {
-    const sections = [
-      {name: 'section 1', query: 'query 1'},
-      {name: 'section 2', query: 'query 2 for ${user}'},
-      {name: 'section 3', query: 'self only query', selfOnly: true},
-      {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
-    ];
-
-    test('dashboard for self', () => {
-      const dashboard =
-           GerritNav.getUserDashboard('self', sections, 'title');
-      assert.deepEqual(
-          dashboard,
-          {
-            title: 'title',
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: 'query 2 for self'},
-              {
-                name: 'section 3',
-                query: 'self only query',
-                selfOnly: true,
-              }, {
-                name: 'section 4',
-                query: 'query 4',
-                suffixForDashboard: 'suffix',
-              },
-            ],
-          });
-    });
-
-    test('dashboard for other user', () => {
-      const dashboard =
-           GerritNav.getUserDashboard('user', sections, 'title');
-      assert.deepEqual(
-          dashboard,
-          {
-            title: 'title',
-            sections: [
-              {name: 'section 1', query: 'query 1'},
-              {name: 'section 2', query: 'query 2 for user'},
-              {
-                name: 'section 4',
-                query: 'query 4',
-                suffixForDashboard: 'suffix',
-              },
-            ],
-          });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
new file mode 100644
index 0000000..ab95711
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {serviceWorkerInstallerToken} from '../../../services/service-worker-installer';
+import {subscribe} from '../../lit/subscription-controller';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {navigationToken} from '../gr-navigation/gr-navigation';
+import {createSettingsUrl} from '../../../models/views/settings';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-notifications-prompt': GrNotificationsPrompt;
+  }
+}
+
+@customElement('gr-notifications-prompt')
+export class GrNotificationsPrompt extends LitElement {
+  @state() private hideNotificationsPrompt = false;
+
+  @state() private shouldShowPrompt = false;
+
+  private readonly serviceWorkerInstaller = resolve(
+    this,
+    serviceWorkerInstallerToken
+  );
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.serviceWorkerInstaller().shouldShowPrompt$,
+      shouldShowPrompt => {
+        this.shouldShowPrompt = !!shouldShowPrompt;
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      fontStyles,
+      css`
+        #notificationsPrompt {
+          position: absolute;
+          right: 30px;
+          top: 50px;
+          z-index: 150; /* Less than gr-hovercard's, higher than rest */
+          display: flex;
+          background-color: var(--background-color-primary);
+          padding: var(--spacing-l);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          box-shadow: var(--elevation-level-5);
+        }
+        h3 {
+          margin: 0;
+          padding: 0;
+        }
+        .icon {
+          flex: 0 0 30px;
+        }
+        .content {
+          width: 300px;
+        }
+        div.section {
+          margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+        }
+        div.sectionIcon {
+          flex: 0 0 30px;
+        }
+        .message {
+          margin: var(--spacing-m) 0;
+        }
+        div.sectionIcon gr-icon {
+          position: relative;
+        }
+        b {
+          font-weight: var(--font-weight-bold);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (this.hideNotificationsPrompt) return nothing;
+    if (!this.shouldShowPrompt) return nothing;
+    return html`<div id="notificationsPrompt" role="dialog">
+      <div class="icon">
+        <gr-icon icon="info"></gr-icon>
+      </div>
+      <div class="content">
+        <h3 class="heading-3">Missing your turn notifications?</h3>
+        <div class="message">
+          Get notified whenever it's your turn on a change. Gerrit needs
+          permission to send notifications. To turn on notifications, click
+          <b>Continue</b> and then <b>Allow</b> when prompted by your browser.
+        </div>
+        <div class="buttons">
+          <gr-button
+            primary=""
+            @click=${() => {
+              this.hideNotificationsPrompt = true;
+              this.serviceWorkerInstaller().requestPermission();
+            }}
+            >Continue</gr-button
+          >
+          <gr-button
+            @click=${() => {
+              this.hideNotificationsPrompt = true;
+              this.getNavigation().setUrl(createSettingsUrl());
+            }}
+            >Disable in settings</gr-button
+          >
+        </div>
+      </div>
+      <div class="icon">
+        <gr-button
+          @click=${() => {
+            this.hideNotificationsPrompt = true;
+          }}
+          link
+        >
+          <gr-icon icon="close"></gr-icon>
+        </gr-button>
+      </div>
+    </div>`;
+  }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
new file mode 100644
index 0000000..d06b405
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-notifications-prompt';
+import {GrNotificationsPrompt} from './gr-notifications-prompt';
+import {fixture, html, assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  ServiceWorkerInstaller,
+  serviceWorkerInstallerToken,
+} from '../../../services/service-worker-installer';
+import {waitUntilObserved} from '../../../test/test-utils';
+import {createDefaultPreferences} from '../../../constants/constants';
+import {userModelToken} from '../../../models/user/user-model';
+
+suite('gr-notifications-prompt tests', () => {
+  let element: GrNotificationsPrompt;
+  let serviceWorkerInstaller: ServiceWorkerInstaller;
+
+  setup(async () => {
+    sinon
+      .stub(window.navigator.serviceWorker, 'register')
+      .returns(Promise.resolve({} as ServiceWorkerRegistration));
+    const flagsService = getAppContext().flagsService;
+    sinon.stub(flagsService, 'isEnabled').returns(true);
+    const userModel = testResolver(userModelToken);
+    const prefs = {
+      ...createDefaultPreferences(),
+      allow_browser_notifications: true,
+    };
+    userModel.setPreferences(prefs);
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === true
+    );
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === true
+    );
+    serviceWorkerInstaller = testResolver(serviceWorkerInstallerToken);
+    // Since we cannot stub Notification.permission, we stub shouldShowPrompt.
+    sinon.stub(serviceWorkerInstaller, 'shouldShowPrompt').returns(true);
+    element = await fixture(
+      html`<gr-notifications-prompt></gr-notifications-prompt>`
+    );
+    await waitUntilObserved(
+      serviceWorkerInstaller.shouldShowPrompt$,
+      shouldShowPrompt => shouldShowPrompt === true
+    );
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element, // cannot format with HTML because test will not pass.
+      `<div id="notificationsPrompt" role="dialog">
+        <div class="icon"><gr-icon icon="info"> </gr-icon></div>
+        <div class="content">
+          <h3 class="heading-3">Missing your turn notifications?</h3>
+          <div class="message">
+            Get notified whenever it's your turn on a change. Gerrit needs
+          permission to send notifications. To turn on notifications, click
+            <b> Continue </b> and then <b> Allow </b>
+            when prompted by your browser.
+          </div>
+          <div class="buttons">
+            <gr-button
+              aria-disabled="false"
+              primary=""
+              role="button"
+              tabindex="0"
+            >
+              Continue
+            </gr-button>
+            <gr-button aria-disabled="false" role="button" tabindex="0">
+              Disable in settings
+            </gr-button>
+          </div>
+        </div>
+        <div class="icon">
+          <gr-button aria-disabled="false" link="" role="button" tabindex="0">
+            <gr-icon icon="close"> </gr-icon>
+          </gr-button>
+        </div>
+      </div>`
+    );
+  });
+});
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 65ac9df..bcf6937 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -1,82 +1,104 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {
   page,
   PageContext,
   PageNextCallback,
 } from '../../../utils/page-wrapper-utils';
-import {htmlTemplate} from './gr-router_html';
+import {NavigationService} from '../gr-navigation/gr-navigation';
+import {getAppContext} from '../../../services/app-context';
 import {
-  DashboardSection,
-  GeneratedWebLink,
-  GenerateUrlChangeViewParameters,
-  GenerateUrlDashboardViewParameters,
-  GenerateUrlDiffViewParameters,
-  GenerateUrlEditViewParameters,
-  GenerateUrlGroupViewParameters,
-  GenerateUrlParameters,
-  GenerateUrlRepoViewParameters,
-  GenerateUrlSearchViewParameters,
-  GenerateWebLinksChangeParameters,
-  GenerateWebLinksEditParameters,
-  GenerateWebLinksFileParameters,
-  GenerateWebLinksParameters,
-  GenerateWebLinksPatchsetParameters,
-  GenerateWebLinksResolveConflictsParameters,
-  GerritNav,
-  GroupDetailView,
-  isGenerateUrlDiffViewParameters,
-  RepoDetailView,
-  WeblinkType,
-} from '../gr-navigation/gr-navigation';
-import {appContext} from '../../../services/app-context';
-import {convertToPatchSetNum} from '../../../utils/patch-set-util';
-import {customElement, property} from '@polymer/decorators';
-import {assertNever} from '../../../utils/common-util';
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  convertToPatchSetNum,
+} from '../../../utils/patch-set-util';
+import {assertIsDefined} from '../../../utils/common-util';
 import {
   BasePatchSetNum,
   DashboardId,
   GroupId,
   NumericChangeId,
-  PatchSetNum,
+  RevisionPatchSetNum,
   RepoName,
-  ServerInfo,
   UrlEncodedCommentId,
-  ParentPatchSetNum,
+  PARENT,
+  PatchSetNumber,
+  BranchName,
 } from '../../../types/common';
-import {
-  AppElement,
-  AppElementAgreementParam,
-  AppElementParams,
-} from '../../gr-app-types';
+import {AppElement, 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 {GerritView, RouterModel} from '../../../services/router/router-model';
+import {fireAlert, firePageError} from '../../../utils/event-util';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {
-  encodeURL,
   getBaseUrl,
+  PatchRangeParams,
   toPath,
   toPathname,
   toSearchParams,
 } from '../../../utils/url-util';
-import {Execution, LifeCycle, Timing} from '../../../constants/reporting';
+import {LifeCycle, Timing} from '../../../constants/reporting';
+import {
+  LATEST_ATTEMPT,
+  stringToAttemptChoice,
+} from '../../../models/checks/checks-util';
+import {
+  AdminChildView,
+  AdminViewModel,
+  AdminViewState,
+} from '../../../models/views/admin';
+import {
+  AgreementViewModel,
+  AgreementViewState,
+} from '../../../models/views/agreement';
+import {
+  RepoDetailView,
+  RepoViewModel,
+  RepoViewState,
+} from '../../../models/views/repo';
+import {
+  createGroupUrl,
+  GroupDetailView,
+  GroupViewModel,
+  GroupViewState,
+} from '../../../models/views/group';
+import {
+  ChangeChildView,
+  ChangeViewModel,
+  ChangeViewState,
+  createChangeViewUrl,
+  createDiffUrl,
+} from '../../../models/views/change';
+import {
+  DashboardViewModel,
+  DashboardViewState,
+} from '../../../models/views/dashboard';
+import {
+  SettingsViewModel,
+  SettingsViewState,
+} from '../../../models/views/settings';
+import {define} from '../../../models/dependency';
+import {Finalizable} from '../../../services/registry';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {
+  DocumentationViewModel,
+  DocumentationViewState,
+} from '../../../models/views/documentation';
+import {PluginViewModel, PluginViewState} from '../../../models/views/plugin';
+import {SearchViewModel, SearchViewState} from '../../../models/views/search';
+import {DashboardSection} from '../../../utils/dashboard-util';
+import {Subscription} from 'rxjs';
+import {
+  addPath,
+  findComment,
+  getPatchRangeForCommentUrl,
+  isInBaseOfPatchRange,
+} from '../../../utils/comment-util';
+import {isFileUnchanged} from '../../../embed/diff/gr-diff/gr-diff-utils';
 
 const RoutePattern = {
   ROOT: '/',
@@ -103,7 +125,7 @@
   // Redirects /groups/self to /settings/#Groups for GWT compatibility
   GROUP_SELF: /^\/groups\/self/,
 
-  // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
+  // Matches /admin/groups/[uuid-]<group>,info (backwards compat with gwtui)
   // Redirects to /admin/groups/[uuid-]<group>
   GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
 
@@ -132,6 +154,10 @@
   // Matches /admin/repos/<repo>,commands.
   REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
 
+  // For creating a change, and going directly into editing mode for one file.
+  REPO_EDIT_FILE:
+    /^\/admin\/repos\/edit\/repo\/(.+)\/branch\/(.+)\/file\/(.+)$/,
+
   REPO_GENERAL: /^\/admin\/repos\/(.+),general$/,
 
   // Matches /admin/repos/<repos>,access.
@@ -243,29 +269,17 @@
 const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
 
 /**
- * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
- */
-const PLUS_PATTERN = /\+/g;
-
-/**
- * Pattern to recognize leading '?' in window.location.search, for stripping.
- */
-const QUESTION_PATTERN = /^\?*/;
-
-/**
  * GWT UI would use @\d+ at the end of a path to indicate linenum.
  */
 const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
 
 const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
 
-const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
-
 // Polymer makes `app` intrinsically defined on the window by virtue of the
-// custom element having the id "app", but it is made explicit here.
+// custom element having the id "pg-app", but it is made explicit here.
 // If you move this code to other place, please update comment about
 // gr-router and gr-app in the PolyGerritIndexHtml.soy file if needed
-const app = document.querySelector('#app');
+const app = document.querySelector('gr-app');
 if (!app) {
   console.info('No gr-app found (running tests)');
 }
@@ -273,434 +287,147 @@
 // Setup listeners outside of the router component initialization.
 (function () {
   window.addEventListener('WebComponentsReady', () => {
-    appContext.reportingService.timeEnd(Timing.WEB_COMPONENTS_READY);
+    getAppContext().reportingService.timeEnd(Timing.WEB_COMPONENTS_READY);
   });
 })();
 
-export interface PageContextWithQueryMap extends PageContext {
-  queryMap: Map<string, string> | URLSearchParams;
-}
+export const routerToken = define<GrRouter>('router');
 
-type QueryStringItem = [string, string]; // [key, value]
-
-interface PatchRangeParams {
-  patchNum?: PatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-}
-
-@customElement('gr-router')
-export class GrRouter extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
+export class GrRouter implements Finalizable, NavigationService {
   readonly _app = app;
 
-  @property({type: Boolean})
   _isRedirecting?: boolean;
 
   // This variable is to differentiate between internal navigation (false)
   // and for first navigation in app after loaded from server (true).
-  @property({type: Boolean})
   _isInitialLoad = true;
 
-  private readonly reporting = appContext.reportingService;
+  private subscriptions: Subscription[] = [];
 
-  private readonly restApiService = appContext.restApiService;
+  private view?: GerritView;
+
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly routerModel: RouterModel,
+    private readonly restApiService: RestApiService,
+    private readonly adminViewModel: AdminViewModel,
+    private readonly agreementViewModel: AgreementViewModel,
+    private readonly changeViewModel: ChangeViewModel,
+    private readonly dashboardViewModel: DashboardViewModel,
+    private readonly documentationViewModel: DocumentationViewModel,
+    private readonly groupViewModel: GroupViewModel,
+    private readonly pluginViewModel: PluginViewModel,
+    private readonly repoViewModel: RepoViewModel,
+    private readonly searchViewModel: SearchViewModel,
+    private readonly settingsViewModel: SettingsViewModel
+  ) {
+    this.subscriptions = [
+      // TODO: Do the same for other view models.
+      // We want to make sure that the current view model state is always
+      // reflected back into the URL bar.
+      this.changeViewModel.state$.subscribe(state => {
+        if (!state) return;
+        // Note that router model view must be updated before view model state.
+        // So this check is slightly fragile, but should work.
+        if (this.view !== GerritView.CHANGE) return;
+        const browserUrl = new URL(window.location.toString());
+        const stateUrl = new URL(createChangeViewUrl(state), browserUrl);
+
+        // Keeping the hash and certain parameters are stop-gap solution. We
+        // should find better ways of maintaining an overall consistent URL
+        // state.
+        stateUrl.hash = browserUrl.hash;
+        for (const p of browserUrl.searchParams.entries()) {
+          if (p[0] === 'experiment') stateUrl.searchParams.append(p[0], p[1]);
+        }
+
+        if (browserUrl.toString() !== stateUrl.toString()) {
+          page.replace(
+            stateUrl.toString(),
+            null,
+            /* init: */ false,
+            /* dispatch: */ false
+          );
+        }
+      }),
+      this.routerModel.routerView$.subscribe(view => (this.view = view)),
+    ];
+  }
+
+  finalize(): void {
+    for (const subscription of this.subscriptions) {
+      subscription.unsubscribe();
+    }
+    this.subscriptions = [];
+  }
 
   start() {
     if (!this._app) {
       return;
     }
-    this._startRouter();
+    this.startRouter();
   }
 
-  _setParams(params: AppElementParams | GenerateUrlParameters) {
-    updateState(
-      params.view,
-      'changeNum' in params ? params.changeNum : undefined,
-      'patchNum' in params ? params.patchNum ?? undefined : undefined
-    );
-    this._appElement().params = params;
+  setState(state: AppElementParams) {
+    if ('repo' in state && state.repo !== undefined && 'changeNum' in state)
+      this.restApiService.setInProjectLookup(state.changeNum, state.repo);
+
+    this.routerModel.setState({view: state.view});
+    // We are trying to reset the change (view) model when navigating to other
+    // views, because we don't trust our reset logic at the moment. The models
+    // singletons and might unintentionally keep state from one change to
+    // another. TODO: Let's find some way to avoid that.
+    if (state.view !== GerritView.CHANGE) {
+      this.changeViewModel.setState(undefined);
+    }
+    this.appElement().params = state;
   }
 
-  _appElement(): AppElement {
+  private appElement(): AppElement {
     // In Polymer2 you have to reach through the shadow root of the app
     // element. This obviously breaks encapsulation.
     // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
     // explicitly in app, or by delegating to it.
 
     // It is expected that application has a GrAppElement(id=='app-element')
-    // at the document level or inside the shadow root of the GrApp (id='app')
+    // at the document level or inside the shadow root of the GrApp ('gr-app')
     // element.
     return (document.getElementById('app-element') ||
       document
-        .getElementById('app')!
+        .querySelector('gr-app')!
         .shadowRoot!.getElementById('app-element')!) as AppElement;
   }
 
-  _redirect(url: string) {
+  redirect(url: string) {
     this._isRedirecting = true;
     page.redirect(url);
   }
 
-  _generateUrl(params: GenerateUrlParameters) {
-    const base = getBaseUrl();
-    let url = '';
-
-    if (params.view === GerritView.SEARCH) {
-      url = this._generateSearchUrl(params);
-    } else if (params.view === GerritView.CHANGE) {
-      url = this._generateChangeUrl(params);
-    } else if (params.view === GerritView.DASHBOARD) {
-      url = this._generateDashboardUrl(params);
-    } else if (
-      params.view === GerritView.DIFF ||
-      params.view === GerritView.EDIT
-    ) {
-      url = this._generateDiffOrEditUrl(params);
-    } else if (params.view === GerritView.GROUP) {
-      url = this._generateGroupUrl(params);
-    } else if (params.view === GerritView.REPO) {
-      url = this._generateRepoUrl(params);
-    } else if (params.view === GerritView.ROOT) {
-      url = '/';
-    } else if (params.view === GerritView.SETTINGS) {
-      url = this._generateSettingsUrl();
-    } else {
-      assertNever(params, "Can't generate");
-    }
-
-    return base + url;
-  }
-
-  _generateWeblinks(
-    params: GenerateWebLinksParameters
-  ): GeneratedWebLink[] | GeneratedWebLink {
-    switch (params.type) {
-      case WeblinkType.EDIT:
-        return this._getEditWebLinks(params);
-      case WeblinkType.FILE:
-        return this._getFileWebLinks(params);
-      case WeblinkType.CHANGE:
-        return this._getChangeWeblinks(params);
-      case WeblinkType.PATCHSET:
-        return this._getPatchSetWeblink(params);
-      case WeblinkType.RESOLVE_CONFLICTS:
-        return this._getResolveConflictsWeblinks(params);
-      default:
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        assertNever(params, `Unsupported weblink ${(params as any).type}!`);
-    }
-  }
-
-  _getPatchSetWeblink(
-    params: GenerateWebLinksPatchsetParameters
-  ): GeneratedWebLink {
-    const {commit, options} = params;
-    const {weblinks, config} = options || {};
-    const name = commit && commit.slice(0, 7);
-    const weblink = this._getBrowseCommitWeblink(weblinks, config);
-    if (!weblink || !weblink.url) {
-      return {name};
-    } else {
-      return {name, url: weblink.url};
-    }
-  }
-
-  _getResolveConflictsWeblinks(
-    params: GenerateWebLinksResolveConflictsParameters
-  ): GeneratedWebLink[] {
-    return params.options?.weblinks ?? [];
-  }
-
-  _firstCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
-    // This is an ordered allowed list of web link types that provide direct
-    // links to the commit in the url property.
-    const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
-    for (let i = 0; i < codeBrowserLinks.length; i++) {
-      const weblink = weblinks.find(
-        weblink => weblink.name === codeBrowserLinks[i]
-      );
-      if (weblink) {
-        return weblink;
-      }
-    }
-    return null;
-  }
-
-  _getBrowseCommitWeblink(weblinks?: GeneratedWebLink[], config?: ServerInfo) {
-    if (!weblinks) {
-      return null;
-    }
-    let weblink;
-    // Use primary weblink if configured and exists.
-    if (config?.gerrit?.primary_weblink_name) {
-      const primaryWeblinkName = config.gerrit.primary_weblink_name;
-      weblink = weblinks.find(weblink => weblink.name === primaryWeblinkName);
-    }
-    if (!weblink) {
-      weblink = this._firstCodeBrowserWeblink(weblinks);
-    }
-    if (!weblink) {
-      return null;
-    }
-    return weblink;
-  }
-
-  _getChangeWeblinks(
-    params: GenerateWebLinksChangeParameters
-  ): GeneratedWebLink[] {
-    const weblinks = params.options?.weblinks;
-    const config = params.options?.config;
-    if (!weblinks || !weblinks.length) return [];
-    const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
-    return weblinks.filter(
-      weblink =>
-        !commitWeblink ||
-        !commitWeblink.name ||
-        weblink.name !== commitWeblink.name
-    );
-  }
-
-  _getEditWebLinks(params: GenerateWebLinksEditParameters): GeneratedWebLink[] {
-    return params.options?.weblinks ?? [];
-  }
-
-  _getFileWebLinks(params: GenerateWebLinksFileParameters): GeneratedWebLink[] {
-    return params.options?.weblinks ?? [];
-  }
-
-  _generateSearchUrl(params: GenerateUrlSearchViewParameters) {
-    let offsetExpr = '';
-    if (params.offset && params.offset > 0) {
-      offsetExpr = `,${params.offset}`;
-    }
-
-    if (params.query) {
-      return '/q/' + encodeURL(params.query, true) + offsetExpr;
-    }
-
-    const operators: string[] = [];
-    if (params.owner) {
-      operators.push('owner:' + encodeURL(params.owner, false));
-    }
-    if (params.project) {
-      operators.push('project:' + encodeURL(params.project, false));
-    }
-    if (params.branch) {
-      operators.push('branch:' + encodeURL(params.branch, false));
-    }
-    if (params.topic) {
-      operators.push(
-        'topic:' +
-          addQuotesWhen(encodeURL(params.topic, false), /\s/.test(params.topic))
-      );
-    }
-    if (params.hashtag) {
-      operators.push(
-        'hashtag:' +
-          addQuotesWhen(
-            encodeURL(params.hashtag.toLowerCase(), false),
-            /\s/.test(params.hashtag)
-          )
-      );
-    }
-    if (params.statuses) {
-      if (params.statuses.length === 1) {
-        operators.push('status:' + encodeURL(params.statuses[0], false));
-      } else if (params.statuses.length > 1) {
-        operators.push(
-          '(' +
-            params.statuses
-              .map(s => `status:${encodeURL(s, false)}`)
-              .join(' OR ') +
-            ')'
-        );
-      }
-    }
-
-    return '/q/' + operators.join('+') + offsetExpr;
-  }
-
-  _generateChangeUrl(params: GenerateUrlChangeViewParameters) {
-    let range = this._getPatchRangeExpression(params);
-    if (range.length) {
-      range = '/' + range;
-    }
-    let suffix = `${range}`;
-    let queryString = '';
-    if (params.forceReload) {
-      queryString = 'forceReload=true';
-    }
-    if (params.edit) {
-      suffix += ',edit';
-    }
-    if (params.commentId) {
-      suffix = suffix + `/comments/${params.commentId}`;
-    }
-    if (queryString) {
-      suffix += '?' + queryString;
-    }
-    if (params.messageHash) {
-      suffix += params.messageHash;
-    }
-    if (params.project) {
-      const encodedProject = encodeURL(params.project, true);
-      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
-    } else {
-      return `/c/${params.changeNum}${suffix}`;
-    }
-  }
-
-  _generateDashboardUrl(params: GenerateUrlDashboardViewParameters) {
-    const repoName = params.repo || params.project || undefined;
-    if (params.sections) {
-      // Custom dashboard.
-      const queryParams = this._sectionsToEncodedParams(
-        params.sections,
-        repoName
-      );
-      if (params.title) {
-        queryParams.push('title=' + encodeURIComponent(params.title));
-      }
-      const user = params.user ? params.user : '';
-      return `/dashboard/${user}?${queryParams.join('&')}`;
-    } else if (repoName) {
-      // Project dashboard.
-      const encodedRepo = encodeURL(repoName, true);
-      return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
-    } else {
-      // User dashboard.
-      return `/dashboard/${params.user || 'self'}`;
-    }
-  }
-
-  _sectionsToEncodedParams(sections: DashboardSection[], repoName?: RepoName) {
-    return sections.map(section => {
-      // If there is a repo name provided, make sure to substitute it into the
-      // ${repo} (or legacy ${project}) query tokens.
-      const query = repoName
-        ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
-        : section.query;
-      return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
-    });
-  }
-
-  _generateDiffOrEditUrl(
-    params: GenerateUrlDiffViewParameters | GenerateUrlEditViewParameters
-  ) {
-    let range = this._getPatchRangeExpression(params);
-    if (range.length) {
-      range = '/' + range;
-    }
-
-    let suffix = `${range}/${encodeURL(params.path || '', true)}`;
-
-    if (params.view === GerritView.EDIT) {
-      suffix += ',edit';
-    }
-
-    if (params.lineNum) {
-      suffix += '#';
-      if (isGenerateUrlDiffViewParameters(params) && params.leftSide) {
-        suffix += 'b';
-      }
-      suffix += params.lineNum;
-    }
-
-    if (isGenerateUrlDiffViewParameters(params) && params.commentId) {
-      suffix = `/comment/${params.commentId}` + suffix;
-    }
-
-    if (params.project) {
-      const encodedProject = encodeURL(params.project, true);
-      return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
-    } else {
-      return `/c/${params.changeNum}${suffix}`;
-    }
-  }
-
-  _generateGroupUrl(params: GenerateUrlGroupViewParameters) {
-    let url = `/admin/groups/${encodeURL(`${params.groupId}`, true)}`;
-    if (params.detail === GroupDetailView.MEMBERS) {
-      url += ',members';
-    } else if (params.detail === GroupDetailView.LOG) {
-      url += ',audit-log';
-    }
-    return url;
-  }
-
-  _generateRepoUrl(params: GenerateUrlRepoViewParameters) {
-    let url = `/admin/repos/${encodeURL(`${params.repoName}`, true)}`;
-    if (params.detail === RepoDetailView.GENERAL) {
-      url += ',general';
-    } else if (params.detail === RepoDetailView.ACCESS) {
-      url += ',access';
-    } else if (params.detail === RepoDetailView.BRANCHES) {
-      url += ',branches';
-    } else if (params.detail === RepoDetailView.TAGS) {
-      url += ',tags';
-    } else if (params.detail === RepoDetailView.COMMANDS) {
-      url += ',commands';
-    } else if (params.detail === RepoDetailView.DASHBOARDS) {
-      url += ',dashboards';
-    }
-    return url;
-  }
-
-  _generateSettingsUrl() {
-    return '/settings';
-  }
-
   /**
-   * Given an object of parameters, potentially including a `patchNum` or a
-   * `basePatchNum` or both, return a string representation of that range. If
-   * no range is indicated in the params, the empty string is returned.
+   * Normalizes the patchset numbers of the params object.
    */
-  _getPatchRangeExpression(params: PatchRangeParams) {
-    let range = '';
-    if (params.patchNum) {
-      range = `${params.patchNum}`;
-    }
-    if (params.basePatchNum && params.basePatchNum !== ParentPatchSetNum) {
-      range = `${params.basePatchNum}..${range}`;
-    }
-    return range;
-  }
-
-  /**
-   * Normalizes the params object, and determines if the URL needs to be
-   * modified to fit the proper schema.
-   *
-   */
-  _normalizePatchRangeParams(params: PatchRangeParams) {
-    if (params.basePatchNum === undefined) {
-      return false;
-    }
-    const hasPatchNum = params.patchNum !== undefined;
-    let needsRedirect = false;
+  normalizePatchRangeParams(params: PatchRangeParams) {
+    if (params.basePatchNum === undefined) return;
 
     // Diffing a patch against itself is invalid, so if the base and revision
     // patches are equal clear the base.
     if (params.patchNum && params.basePatchNum === params.patchNum) {
-      needsRedirect = true;
-      params.basePatchNum = ParentPatchSetNum;
-    } else if (!hasPatchNum) {
-      // Regexes set basePatchNum instead of patchNum when only one is
-      // specified. Redirect is not needed in this case.
-      params.patchNum = params.basePatchNum;
-      params.basePatchNum = ParentPatchSetNum;
+      params.basePatchNum = PARENT;
+      return;
     }
-    return needsRedirect;
+    // Regexes set basePatchNum instead of patchNum when only one is
+    // specified.
+    if (params.patchNum === undefined) {
+      params.patchNum = params.basePatchNum as RevisionPatchSetNum;
+      params.basePatchNum = PARENT;
+    }
   }
 
   /**
    * Redirect the user to login using the given return-URL for redirection
    * after authentication success.
    */
-  _redirectToLogin(returnUrl: string) {
+  redirectToLogin(returnUrl: string) {
     const basePath = getBaseUrl() || '';
     page('/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
   }
@@ -712,11 +439,11 @@
    *
    * @return Everything after the first '#' ("a#b#c" -> "b#c").
    */
-  _getHashFromCanonicalPath(canonicalPath: string) {
+  getHashFromCanonicalPath(canonicalPath: string) {
     return canonicalPath.split('#').slice(1).join('#');
   }
 
-  _parseLineAddress(hash: string) {
+  parseLineAddress(hash: string) {
     const match = hash.match(LINE_ADDRESS_PATTERN);
     if (!match) {
       return null;
@@ -732,48 +459,27 @@
    * resolves if the user is logged in. If the user us not logged in, the
    * promise is rejected and the page is redirected to the login flow.
    *
-   * @return A promise yielding the original route data
+   * @return A promise yielding the original route ctx
    * (if it resolves).
    */
-  _redirectIfNotLoggedIn(data: PageContext) {
+  redirectIfNotLoggedIn(ctx: PageContext) {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
         return Promise.resolve();
       } else {
-        this._redirectToLogin(data.canonicalPath);
+        this.redirectToLogin(ctx.canonicalPath);
         return Promise.reject(new Error());
       }
     });
   }
 
   /**  Page.js middleware that warms the REST API's logged-in cache line. */
-  _loadUserMiddleware(_: PageContext, next: PageNextCallback) {
+  private loadUserMiddleware(_: PageContext, next: PageNextCallback) {
     this.restApiService.getLoggedIn().then(() => {
       next();
     });
   }
 
-  /**  Page.js middleware that try parse the querystring into queryMap. */
-  _queryStringMiddleware(ctx: PageContext, next: PageNextCallback) {
-    (ctx as PageContextWithQueryMap).queryMap = this.createQueryMap(ctx);
-    next();
-  }
-
-  private createQueryMap(ctx: PageContext) {
-    if (ctx.querystring) {
-      // https://caniuse.com/#search=URLSearchParams
-      if (window.URLSearchParams) {
-        return new URLSearchParams(ctx.querystring);
-      } else {
-        this.reporting.reportExecution(Execution.REACHABLE_CODE, {
-          id: 'noURLSearchParams',
-        });
-        return new Map(this._parseQueryString(ctx.querystring));
-      }
-    }
-    return new Map<string, string>();
-  }
-
   /**
    * Map a route to a method on the router.
    *
@@ -785,54 +491,72 @@
    * @param authRedirect If true, then auth is checked before
    * executing the handler. If the user is not logged in, it will redirect
    * to the login flow and the handler will not be executed. The login
-   * redirect specifies the matched URL to be used after successfull auth.
+   * redirect specifies the matched URL to be used after successful auth.
    */
-  _mapRoute(
+  mapRoute(
     pattern: string | RegExp,
-    handlerName: keyof GrRouter,
+    handlerName: string,
+    handler: (ctx: PageContext) => void,
     authRedirect?: boolean
   ) {
-    if (!this[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),
+      (ctx, next) => this.loadUserMiddleware(ctx, next),
       ctx => {
         this.reporting.locationChanged(handlerName);
         const promise = authRedirect
-          ? this._redirectIfNotLoggedIn(ctx)
+          ? this.redirectIfNotLoggedIn(ctx)
           : Promise.resolve();
         promise.then(() => {
-          this[handlerName](ctx as PageContextWithQueryMap);
+          handler(ctx);
         });
       }
     );
   }
 
-  _startRouter() {
+  /**
+   * This is similar to letting the browser navigate to this URL when the user
+   * clicks it, or to just setting `window.location.href` directly.
+   *
+   * This adds a new entry to the browser location history. Consier using
+   * `replaceUrl()`, if you want to avoid that.
+   *
+   * page.show() eventually just calls `window.history.pushState()`.
+   */
+  setUrl(url: string) {
+    page.show(url);
+  }
+
+  /**
+   * Navigate to this URL, but replace the current URL in the history instead of
+   * adding a new one (which is what `setUrl()` would do).
+   *
+   * page.redirect() eventually just calls `window.history.replaceState()`.
+   */
+  replaceUrl(url: string) {
+    this.redirect(url);
+  }
+
+  private dispatchLocationChangeEvent() {
+    const detail: LocationChangeEventDetail = {
+      hash: window.location.hash,
+      pathname: window.location.pathname,
+    };
+    document.dispatchEvent(
+      new CustomEvent('location-change', {
+        detail,
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  startRouter() {
     const base = getBaseUrl();
     if (base) {
       page.base(base);
     }
 
-    GerritNav.setup(
-      (url, redirect?) => {
-        if (redirect) {
-          page.redirect(url);
-        } else {
-          page.show(url);
-        }
-      },
-      params => this._generateUrl(params),
-      params => this._generateWeblinks(params),
-      x => x
-    );
-
     page.exit('*', (_, next) => {
       if (!this._isRedirecting) {
         this.reporting.beforeLocationChanged();
@@ -852,7 +576,7 @@
           const usp = searchParams.get('usp');
           this.reporting.reportLifeCycle(LifeCycle.USER_REFERRED_FROM, {usp});
           searchParams.delete('usp');
-          this._redirect(toPath(pathname, searchParams));
+          this.redirect(toPath(pathname, searchParams));
           return;
         }
       }
@@ -867,247 +591,369 @@
         // Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
         // This is needed to allow plugins to add basic #/x/ screen links to
         // any location.
-        this._redirect(ctx.hash);
+        this.redirect(ctx.hash);
         return;
       }
 
       // Fire asynchronously so that the URL is changed by the time the event
       // is processed.
       setTimeout(() => {
-        const detail: LocationChangeEventDetail = {
-          hash: window.location.hash,
-          pathname: window.location.pathname,
-        };
-        this.dispatchEvent(
-          new CustomEvent('location-change', {
-            detail,
-            composed: true,
-            bubbles: true,
-          })
-        );
+        this.dispatchLocationChangeEvent();
       }, 1);
       next();
     });
 
-    this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
+    this.mapRoute(RoutePattern.ROOT, 'handleRootRoute', ctx =>
+      this.handleRootRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
+    this.mapRoute(RoutePattern.DASHBOARD, 'handleDashboardRoute', ctx =>
+      this.handleDashboardRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.CUSTOM_DASHBOARD,
-      '_handleCustomDashboardRoute'
+      'handleCustomDashboardRoute',
+      ctx => this.handleCustomDashboardRoute(ctx)
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.PROJECT_DASHBOARD,
-      '_handleProjectDashboardRoute'
+      'handleProjectDashboardRoute',
+      ctx => this.handleProjectDashboardRoute(ctx)
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.LEGACY_PROJECT_DASHBOARD,
-      '_handleLegacyProjectDashboardRoute'
+      'handleLegacyProjectDashboardRoute',
+      ctx => this.handleLegacyProjectDashboardRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
+    this.mapRoute(
+      RoutePattern.GROUP_INFO,
+      'handleGroupInfoRoute',
+      ctx => this.handleGroupInfoRoute(ctx),
+      true
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_AUDIT_LOG,
-      '_handleGroupAuditLogRoute',
+      'handleGroupAuditLogRoute',
+      ctx => this.handleGroupAuditLogRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_MEMBERS,
-      '_handleGroupMembersRoute',
+      'handleGroupMembersRoute',
+      ctx => this.handleGroupMembersRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_LIST_OFFSET,
-      '_handleGroupListOffsetRoute',
+      'handleGroupListOffsetRoute',
+      ctx => this.handleGroupListOffsetRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_LIST_FILTER_OFFSET,
-      '_handleGroupListFilterOffsetRoute',
+      'handleGroupListFilterOffsetRoute',
+      ctx => this.handleGroupListFilterOffsetRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_LIST_FILTER,
-      '_handleGroupListFilterRoute',
+      'handleGroupListFilterRoute',
+      ctx => this.handleGroupListFilterRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.GROUP_SELF,
-      '_handleGroupSelfRedirectRoute',
+      'handleGroupSelfRedirectRoute',
+      ctx => this.handleGroupSelfRedirectRoute(ctx),
       true
     );
 
-    this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
+    this.mapRoute(
+      RoutePattern.GROUP,
+      'handleGroupRoute',
+      ctx => this.handleGroupRoute(ctx),
+      true
+    );
 
-    this._mapRoute(RoutePattern.PROJECT_OLD, '_handleProjectsOldRoute');
+    this.mapRoute(RoutePattern.PROJECT_OLD, 'handleProjectsOldRoute', ctx =>
+      this.handleProjectsOldRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.REPO_COMMANDS,
-      '_handleRepoCommandsRoute',
+      'handleRepoCommandsRoute',
+      ctx => this.handleRepoCommandsRoute(ctx),
       true
     );
 
-    this._mapRoute(RoutePattern.REPO_GENERAL, '_handleRepoGeneralRoute');
+    this.mapRoute(
+      RoutePattern.REPO_EDIT_FILE,
+      'handleRepoEditFileRoute',
+      ctx => this.handleRepoEditFileRoute(ctx),
+      true
+    );
 
-    this._mapRoute(RoutePattern.REPO_ACCESS, '_handleRepoAccessRoute');
+    this.mapRoute(RoutePattern.REPO_GENERAL, 'handleRepoGeneralRoute', ctx =>
+      this.handleRepoGeneralRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.REPO_DASHBOARDS, '_handleRepoDashboardsRoute');
+    this.mapRoute(RoutePattern.REPO_ACCESS, 'handleRepoAccessRoute', ctx =>
+      this.handleRepoAccessRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
+      RoutePattern.REPO_DASHBOARDS,
+      'handleRepoDashboardsRoute',
+      ctx => this.handleRepoDashboardsRoute(ctx)
+    );
+
+    this.mapRoute(
       RoutePattern.BRANCH_LIST_OFFSET,
-      '_handleBranchListOffsetRoute'
+      'handleBranchListOffsetRoute',
+      ctx => this.handleBranchListOffsetRoute(ctx)
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.BRANCH_LIST_FILTER_OFFSET,
-      '_handleBranchListFilterOffsetRoute'
+      'handleBranchListFilterOffsetRoute',
+      ctx => this.handleBranchListFilterOffsetRoute(ctx)
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.BRANCH_LIST_FILTER,
-      '_handleBranchListFilterRoute'
+      'handleBranchListFilterRoute',
+      ctx => this.handleBranchListFilterRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.TAG_LIST_OFFSET, '_handleTagListOffsetRoute');
+    this.mapRoute(
+      RoutePattern.TAG_LIST_OFFSET,
+      'handleTagListOffsetRoute',
+      ctx => this.handleTagListOffsetRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.TAG_LIST_FILTER_OFFSET,
-      '_handleTagListFilterOffsetRoute'
+      'handleTagListFilterOffsetRoute',
+      ctx => this.handleTagListFilterOffsetRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.TAG_LIST_FILTER, '_handleTagListFilterRoute');
+    this.mapRoute(
+      RoutePattern.TAG_LIST_FILTER,
+      'handleTagListFilterRoute',
+      ctx => this.handleTagListFilterRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.LEGACY_CREATE_GROUP,
-      '_handleCreateGroupRoute',
+      'handleCreateGroupRoute',
+      ctx => this.handleCreateGroupRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.LEGACY_CREATE_PROJECT,
-      '_handleCreateProjectRoute',
+      'handleCreateProjectRoute',
+      ctx => this.handleCreateProjectRoute(ctx),
       true
     );
 
-    this._mapRoute(RoutePattern.REPO_LIST_OFFSET, '_handleRepoListOffsetRoute');
+    this.mapRoute(
+      RoutePattern.REPO_LIST_OFFSET,
+      'handleRepoListOffsetRoute',
+      ctx => this.handleRepoListOffsetRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.REPO_LIST_FILTER_OFFSET,
-      '_handleRepoListFilterOffsetRoute'
+      'handleRepoListFilterOffsetRoute',
+      ctx => this.handleRepoListFilterOffsetRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.REPO_LIST_FILTER, '_handleRepoListFilterRoute');
+    this.mapRoute(
+      RoutePattern.REPO_LIST_FILTER,
+      'handleRepoListFilterRoute',
+      ctx => this.handleRepoListFilterRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
+    this.mapRoute(RoutePattern.REPO, 'handleRepoRoute', ctx =>
+      this.handleRepoRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
+    this.mapRoute(RoutePattern.PLUGINS, 'handlePassThroughRoute', () =>
+      this.handlePassThroughRoute()
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.PLUGIN_LIST_OFFSET,
-      '_handlePluginListOffsetRoute',
+      'handlePluginListOffsetRoute',
+      ctx => this.handlePluginListOffsetRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
-      '_handlePluginListFilterOffsetRoute',
+      'handlePluginListFilterOffsetRoute',
+      ctx => this.handlePluginListFilterOffsetRoute(ctx),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.PLUGIN_LIST_FILTER,
-      '_handlePluginListFilterRoute',
+      'handlePluginListFilterRoute',
+      ctx => this.handlePluginListFilterRoute(ctx),
       true
     );
 
-    this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
+    this.mapRoute(
+      RoutePattern.PLUGIN_LIST,
+      'handlePluginListRoute',
+      ctx => this.handlePluginListRoute(ctx),
+      true
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.QUERY_LEGACY_SUFFIX,
-      '_handleQueryLegacySuffixRoute'
+      'handleQueryLegacySuffixRoute',
+      ctx => this.handleQueryLegacySuffixRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
+    this.mapRoute(RoutePattern.QUERY, 'handleQueryRoute', ctx =>
+      this.handleQueryRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.CHANGE_ID_QUERY, '_handleChangeIdQueryRoute');
+    this.mapRoute(
+      RoutePattern.CHANGE_ID_QUERY,
+      'handleChangeIdQueryRoute',
+      ctx => this.handleChangeIdQueryRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
+    this.mapRoute(
+      RoutePattern.DIFF_LEGACY_LINENUM,
+      'handleLegacyLinenum',
+      ctx => this.handleLegacyLinenum(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.CHANGE_NUMBER_LEGACY,
-      '_handleChangeNumberLegacyRoute'
+      'handleChangeNumberLegacyRoute',
+      ctx => this.handleChangeNumberLegacyRoute(ctx)
     );
 
-    this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
+    this.mapRoute(
+      RoutePattern.DIFF_EDIT,
+      'handleDiffEditRoute',
+      ctx => this.handleDiffEditRoute(ctx),
+      true
+    );
 
-    this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
+    this.mapRoute(
+      RoutePattern.CHANGE_EDIT,
+      'handleChangeEditRoute',
+      ctx => this.handleChangeEditRoute(ctx),
+      true
+    );
 
-    this._mapRoute(RoutePattern.COMMENT, '_handleCommentRoute');
+    this.mapRoute(RoutePattern.COMMENT, 'handleCommentRoute', ctx =>
+      this.handleCommentRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.COMMENTS_TAB, '_handleCommentsRoute');
+    this.mapRoute(RoutePattern.COMMENTS_TAB, 'handleCommentsRoute', ctx =>
+      this.handleCommentsRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
+    this.mapRoute(RoutePattern.DIFF, 'handleDiffRoute', ctx =>
+      this.handleDiffRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
+    this.mapRoute(RoutePattern.CHANGE, 'handleChangeRoute', ctx =>
+      this.handleChangeRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
+    this.mapRoute(RoutePattern.CHANGE_LEGACY, 'handleChangeLegacyRoute', ctx =>
+      this.handleChangeLegacyRoute(ctx)
+    );
 
-    this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
+    this.mapRoute(
+      RoutePattern.AGREEMENTS,
+      'handleAgreementsRoute',
+      () => this.handleAgreementsRoute(),
+      true
+    );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.NEW_AGREEMENTS,
-      '_handleNewAgreementsRoute',
+      'handleNewAgreementsRoute',
+      () => this.handleNewAgreementsRoute(),
       true
     );
 
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.SETTINGS_LEGACY,
-      '_handleSettingsLegacyRoute',
+      'handleSettingsLegacyRoute',
+      ctx => this.handleSettingsLegacyRoute(ctx),
       true
     );
 
-    this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
-
-    this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
-
-    this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
-
-    this._mapRoute(
-      RoutePattern.IMPROPERLY_ENCODED_PLUS,
-      '_handleImproperlyEncodedPlusRoute'
+    this.mapRoute(
+      RoutePattern.SETTINGS,
+      'handleSettingsRoute',
+      ctx => this.handleSettingsRoute(ctx),
+      true
     );
 
-    this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
+    this.mapRoute(RoutePattern.REGISTER, 'handleRegisterRoute', ctx =>
+      this.handleRegisterRoute(ctx)
+    );
 
-    this._mapRoute(
+    this.mapRoute(RoutePattern.LOG_IN_OR_OUT, 'handlePassThroughRoute', () =>
+      this.handlePassThroughRoute()
+    );
+
+    this.mapRoute(
+      RoutePattern.IMPROPERLY_ENCODED_PLUS,
+      'handleImproperlyEncodedPlusRoute',
+      ctx => this.handleImproperlyEncodedPlusRoute(ctx)
+    );
+
+    this.mapRoute(RoutePattern.PLUGIN_SCREEN, 'handlePluginScreen', ctx =>
+      this.handlePluginScreen(ctx)
+    );
+
+    this.mapRoute(
       RoutePattern.DOCUMENTATION_SEARCH_FILTER,
-      '_handleDocumentationSearchRoute'
+      'handleDocumentationSearchRoute',
+      ctx => this.handleDocumentationSearchRoute(ctx)
     );
 
     // redirects /Documentation/q/* to /Documentation/q/filter:*
-    this._mapRoute(
+    this.mapRoute(
       RoutePattern.DOCUMENTATION_SEARCH,
-      '_handleDocumentationSearchRedirectRoute'
+      'handleDocumentationSearchRedirectRoute',
+      ctx => this.handleDocumentationSearchRedirectRoute(ctx)
     );
 
-    // Makes sure /Documentation/* links work (doin't return 404)
-    this._mapRoute(
+    // Makes sure /Documentation/* links work (don't return 404)
+    this.mapRoute(
       RoutePattern.DOCUMENTATION,
-      '_handleDocumentationRedirectRoute'
+      'handleDocumentationRedirectRoute',
+      ctx => this.handleDocumentationRedirectRoute(ctx)
     );
 
     // Note: this route should appear last so it only catches URLs unmatched
     // by other patterns.
-    this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
+    this.mapRoute(RoutePattern.DEFAULT, 'handleDefaultRoute', () =>
+      this.handleDefaultRoute()
+    );
 
     page.start();
   }
@@ -1116,13 +962,13 @@
    * @return if handling the route involves asynchrony, then a
    * promise is returned. Otherwise, synchronous handling returns null.
    */
-  _handleRootRoute(data: PageContextWithQueryMap) {
-    if (data.querystring.match(/^closeAfterLogin/)) {
+  handleRootRoute(ctx: PageContext) {
+    if (ctx.querystring.match(/^closeAfterLogin/)) {
       // Close child window on redirect after login.
       window.close();
       return null;
     }
-    let hash = this._getHashFromCanonicalPath(data.canonicalPath);
+    let hash = this.getHashFromCanonicalPath(ctx.canonicalPath);
     // For backward compatibility with GWT links.
     if (hash) {
       // In certain login flows the server may redirect to a hash without
@@ -1130,7 +976,7 @@
       if (hash[0] !== '/') {
         hash = '/' + hash;
       }
-      if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
+      if (hash.includes('/ /') && ctx.canonicalPath.includes('/+/')) {
         // Path decodes all '+' to ' ' -- this breaks project-based URLs.
         // See Issue 6888.
         hash = hash.replace('/ /', '/+/');
@@ -1140,605 +986,707 @@
       if (hash.startsWith('/VE/')) {
         newUrl = base + '/settings' + hash;
       }
-      this._redirect(newUrl);
+      this.redirect(newUrl);
       return null;
     }
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
-        this._redirect('/dashboard/self');
+        this.redirect('/dashboard/self');
       } else {
-        this._redirect('/q/status:open+-is:wip');
+        this.redirect('/q/status:open+-is:wip');
       }
     });
   }
 
   /**
-   * Decode an application/x-www-form-urlencoded string.
-   *
-   * @param qs The application/x-www-form-urlencoded string.
-   * @return The decoded string.
-   */
-  _decodeQueryString(qs: string) {
-    return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
-  }
-
-  /**
-   * Parse a query string (e.g. window.location.search) into an array of
-   * name/value pairs.
-   *
-   * @param qs The application/x-www-form-urlencoded query string.
-   * @return An array of name/value pairs, where each
-   * element is a 2-element array.
-   */
-  _parseQueryString(qs: string): Array<QueryStringItem> {
-    qs = qs.replace(QUESTION_PATTERN, '');
-    if (!qs) {
-      return [];
-    }
-    const params: Array<[string, string]> = [];
-    qs.split('&').forEach(param => {
-      const idx = param.indexOf('=');
-      let name;
-      let value;
-      if (idx < 0) {
-        name = this._decodeQueryString(param);
-        value = '';
-      } else {
-        name = this._decodeQueryString(param.substring(0, idx));
-        value = this._decodeQueryString(param.substring(idx + 1));
-      }
-      if (name) {
-        params.push([name, value]);
-      }
-    });
-    return params;
-  }
-
-  /**
    * Handle dashboard routes. These may be user, or project dashboards.
    */
-  _handleDashboardRoute(data: PageContextWithQueryMap) {
+  handleDashboardRoute(ctx: PageContext) {
     // 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.restApiService.getLoggedIn().then(loggedIn => {
       if (!loggedIn) {
-        if (data.params[0].toLowerCase() === 'self') {
-          this._redirectToLogin(data.canonicalPath);
+        if (ctx.params[0].toLowerCase() === 'self') {
+          this.redirectToLogin(ctx.canonicalPath);
         } else {
-          this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
+          this.redirect('/q/owner:' + encodeURIComponent(ctx.params[0]));
         }
       } else {
-        this._setParams({
+        const state: DashboardViewState = {
           view: GerritView.DASHBOARD,
-          user: data.params[0],
-        });
+          user: ctx.params[0],
+        };
+        // Note that router model view must be updated before view models.
+        this.setState(state);
+        this.dashboardViewModel.setState(state);
       }
     });
   }
 
-  /**
-   * Handle custom dashboard routes.
-   *
-   * @param qs Optional query string associated with the route.
-   * If not given, window.location.search is used. (Used by tests).
-   */
-  _handleCustomDashboardRoute(
-    _: PageContextWithQueryMap,
-    qs: string = window.location.search
-  ) {
-    const queryParams = this._parseQueryString(qs);
-    let title = 'Custom Dashboard';
-    const titleParam = queryParams.find(
-      elem => elem[0].toLowerCase() === 'title'
-    );
-    if (titleParam) {
-      title = titleParam[1];
-    }
-    // Dashboards support a foreach param which adds a base query to any
-    // additional query.
-    const forEachParam = queryParams.find(
-      elem => elem[0].toLowerCase() === 'foreach'
-    );
-    let forEachQuery: string | null = null;
-    if (forEachParam) {
-      forEachQuery = forEachParam[1];
-    }
-    const sectionParams = queryParams.filter(
-      elem =>
-        elem[0] &&
-        elem[1] &&
-        elem[0].toLowerCase() !== 'title' &&
-        elem[0].toLowerCase() !== 'foreach'
-    );
-    const sections = sectionParams.map(elem => {
-      const query = forEachQuery ? `${forEachQuery} ${elem[1]}` : elem[1];
-      return {
-        name: elem[0],
-        query,
-      };
-    });
+  handleCustomDashboardRoute(ctx: PageContext) {
+    const queryParams = new URLSearchParams(ctx.querystring);
 
-    if (sections.length > 0) {
-      // Custom dashboard view.
-      this._setParams({
-        view: GerritView.DASHBOARD,
-        user: 'self',
-        sections,
-        title,
-      });
+    let title = 'Custom Dashboard';
+    const titleParam = queryParams.get('title');
+    if (titleParam) title = titleParam;
+    queryParams.delete('title');
+
+    let forEachQuery = '';
+    const forEachParam = queryParams.get('foreach');
+    if (forEachParam) forEachQuery = forEachParam + ' ';
+    queryParams.delete('foreach');
+
+    const sections: DashboardSection[] = [];
+    for (const [name, query] of queryParams) {
+      if (!name || !query) continue;
+      sections.push({name, query: `${forEachQuery}${query}`});
+    }
+
+    if (sections.length === 0) {
+      this.redirect('/dashboard/self');
       return Promise.resolve();
     }
 
-    // Redirect /dashboard/ -> /dashboard/self.
-    this._redirect('/dashboard/self');
+    const state: DashboardViewState = {
+      view: GerritView.DASHBOARD,
+      user: 'self',
+      sections,
+      title,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.dashboardViewModel.setState(state);
     return Promise.resolve();
   }
 
-  _handleProjectDashboardRoute(data: PageContextWithQueryMap) {
-    const project = data.params[0] as RepoName;
-    this._setParams({
+  handleProjectDashboardRoute(ctx: PageContext) {
+    const project = ctx.params[0] as RepoName;
+    const state: DashboardViewState = {
       view: GerritView.DASHBOARD,
       project,
-      dashboard: decodeURIComponent(data.params[1]) as DashboardId,
-    });
+      dashboard: decodeURIComponent(ctx.params[1]) as DashboardId,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.dashboardViewModel.setState(state);
     this.reporting.setRepoName(project);
   }
 
-  _handleLegacyProjectDashboardRoute(data: PageContextWithQueryMap) {
-    this._redirect('/p/' + data.params[0] + '/+/dashboard/' + data.params[1]);
+  handleLegacyProjectDashboardRoute(ctx: PageContext) {
+    this.redirect('/p/' + ctx.params[0] + '/+/dashboard/' + ctx.params[1]);
   }
 
-  _handleGroupInfoRoute(data: PageContextWithQueryMap) {
-    this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
+  handleGroupInfoRoute(ctx: PageContext) {
+    const groupId = ctx.params[0] as GroupId;
+    this.redirect(createGroupUrl({groupId}));
   }
 
-  _handleGroupSelfRedirectRoute(_: PageContextWithQueryMap) {
-    this._redirect('/settings/#Groups');
+  handleGroupSelfRedirectRoute(_: PageContext) {
+    this.redirect('/settings/#Groups');
   }
 
-  _handleGroupRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupRoute(ctx: PageContext) {
+    const state: GroupViewState = {
       view: GerritView.GROUP,
-      groupId: data.params[0] as GroupId,
-    });
+      groupId: ctx.params[0] as GroupId,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.groupViewModel.setState(state);
   }
 
-  _handleGroupAuditLogRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupAuditLogRoute(ctx: PageContext) {
+    const state: GroupViewState = {
       view: GerritView.GROUP,
       detail: GroupDetailView.LOG,
-      groupId: data.params[0] as GroupId,
-    });
+      groupId: ctx.params[0] as GroupId,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.groupViewModel.setState(state);
   }
 
-  _handleGroupMembersRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupMembersRoute(ctx: PageContext) {
+    const state: GroupViewState = {
       view: GerritView.GROUP,
       detail: GroupDetailView.MEMBERS,
-      groupId: data.params[0] as GroupId,
-    });
+      groupId: ctx.params[0] as GroupId,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.groupViewModel.setState(state);
   }
 
-  _handleGroupListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupListOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-admin-group-list',
-      offset: data.params[1] || 0,
+      adminView: AdminChildView.GROUPS,
+      offset: ctx.params[1] || 0,
       filter: null,
-      openCreateModal: data.hash === 'create',
-    });
+      openCreateModal: ctx.hash === 'create',
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  _handleGroupListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupListFilterOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-admin-group-list',
-      offset: data.params['offset'],
-      filter: data.params['filter'],
-    });
+      adminView: AdminChildView.GROUPS,
+      offset: ctx.params['offset'],
+      filter: ctx.params['filter'],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  _handleGroupListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleGroupListFilterRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-admin-group-list',
-      filter: data.params['filter'] || null,
-    });
+      adminView: AdminChildView.GROUPS,
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  _handleProjectsOldRoute(data: PageContextWithQueryMap) {
+  handleProjectsOldRoute(ctx: PageContext) {
     let params = '';
-    if (data.params[1]) {
-      params = encodeURIComponent(data.params[1]);
-      if (data.params[1].includes(',')) {
-        params = encodeURIComponent(data.params[1]).replace('%2C', ',');
+    if (ctx.params[1]) {
+      params = encodeURIComponent(ctx.params[1]);
+      if (ctx.params[1].includes(',')) {
+        params = encodeURIComponent(ctx.params[1]).replace('%2C', ',');
       }
     }
 
-    this._redirect(`/admin/repos/${params}`);
+    // TODO: Change the route pattern to match `repo` and `detailView`
+    // separately, and then use `createRepoUrl()` here.
+    this.redirect(`/admin/repos/${params}`);
   }
 
-  _handleRepoCommandsRoute(data: PageContextWithQueryMap) {
-    const repo = data.params[0] as RepoName;
-    this._setParams({
+  handleRepoCommandsRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.COMMANDS,
       repo,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
   }
 
-  _handleRepoGeneralRoute(data: PageContextWithQueryMap) {
-    const repo = data.params[0] as RepoName;
-    this._setParams({
+  handleRepoEditFileRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const branch = ctx.params[1] as BranchName;
+    const path = ctx.params[2];
+    const state: RepoViewState = {
+      view: GerritView.REPO,
+      detail: RepoDetailView.COMMANDS,
+      repo,
+      createEdit: {branch, path},
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
+    this.reporting.setRepoName(repo);
+  }
+
+  handleRepoGeneralRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.GENERAL,
       repo,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
   }
 
-  _handleRepoAccessRoute(data: PageContextWithQueryMap) {
-    const repo = data.params[0] as RepoName;
-    this._setParams({
+  handleRepoAccessRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.ACCESS,
       repo,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
   }
 
-  _handleRepoDashboardsRoute(data: PageContextWithQueryMap) {
-    const repo = data.params[0] as RepoName;
-    this._setParams({
+  handleRepoDashboardsRoute(ctx: PageContext) {
+    const repo = ctx.params[0] as RepoName;
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.DASHBOARDS,
       repo,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
     this.reporting.setRepoName(repo);
   }
 
-  _handleBranchListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleBranchListOffsetRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
-      repo: data.params[0] as RepoName,
-      offset: data.params[2] || 0,
+      repo: ctx.params[0] as RepoName,
+      offset: ctx.params[2] || 0,
       filter: null,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  _handleBranchListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleBranchListFilterOffsetRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
-      repo: data.params['repo'] as RepoName,
-      offset: data.params['offset'],
-      filter: data.params['filter'],
-    });
+      repo: ctx.params['repo'] as RepoName,
+      offset: ctx.params['offset'],
+      filter: ctx.params['filter'],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  _handleBranchListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleBranchListFilterRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.BRANCHES,
-      repo: data.params['repo'] as RepoName,
-      filter: data.params['filter'] || null,
-    });
+      repo: ctx.params['repo'] as RepoName,
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  _handleTagListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleTagListOffsetRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
-      repo: data.params[0] as RepoName,
-      offset: data.params[2] || 0,
+      repo: ctx.params[0] as RepoName,
+      offset: ctx.params[2] || 0,
       filter: null,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  _handleTagListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleTagListFilterOffsetRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
-      repo: data.params['repo'] as RepoName,
-      offset: data.params['offset'],
-      filter: data.params['filter'],
-    });
+      repo: ctx.params['repo'] as RepoName,
+      offset: ctx.params['offset'],
+      filter: ctx.params['filter'],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  _handleTagListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleTagListFilterRoute(ctx: PageContext) {
+    const state: RepoViewState = {
       view: GerritView.REPO,
       detail: RepoDetailView.TAGS,
-      repo: data.params['repo'] as RepoName,
-      filter: data.params['filter'] || null,
-    });
+      repo: ctx.params['repo'] as RepoName,
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.repoViewModel.setState(state);
   }
 
-  _handleRepoListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleRepoListOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-repo-list',
-      offset: data.params[1] || 0,
+      adminView: AdminChildView.REPOS,
+      offset: ctx.params[1] || 0,
       filter: null,
-      openCreateModal: data.hash === 'create',
-    });
+      openCreateModal: ctx.hash === 'create',
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  _handleRepoListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleRepoListFilterOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-repo-list',
-      offset: data.params['offset'],
-      filter: data.params['filter'],
-    });
+      adminView: AdminChildView.REPOS,
+      offset: ctx.params['offset'],
+      filter: ctx.params['filter'],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  _handleRepoListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleRepoListFilterRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-repo-list',
-      filter: data.params['filter'] || null,
-    });
+      adminView: AdminChildView.REPOS,
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  _handleCreateProjectRoute(_: PageContextWithQueryMap) {
+  handleCreateProjectRoute(_: PageContext) {
     // Redirects the legacy route to the new route, which displays the project
     // list with a hash 'create'.
-    this._redirect('/admin/repos#create');
+    this.redirect('/admin/repos#create');
   }
 
-  _handleCreateGroupRoute(_: PageContextWithQueryMap) {
+  handleCreateGroupRoute(_: PageContext) {
     // Redirects the legacy route to the new route, which displays the group
     // list with a hash 'create'.
-    this._redirect('/admin/groups#create');
+    this.redirect('/admin/groups#create');
   }
 
-  _handleRepoRoute(data: PageContextWithQueryMap) {
-    this._redirect(data.path + ',general');
+  handleRepoRoute(ctx: PageContext) {
+    this.redirect(ctx.path + ',general');
   }
 
-  _handlePluginListOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handlePluginListOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-plugin-list',
-      offset: data.params[1] || 0,
+      adminView: AdminChildView.PLUGINS,
+      offset: ctx.params[1] || 0,
       filter: null,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  _handlePluginListFilterOffsetRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handlePluginListFilterOffsetRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-plugin-list',
-      offset: data.params['offset'],
-      filter: data.params['filter'],
-    });
+      adminView: AdminChildView.PLUGINS,
+      offset: ctx.params['offset'],
+      filter: ctx.params['filter'],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  _handlePluginListFilterRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handlePluginListFilterRoute(ctx: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-plugin-list',
-      filter: data.params['filter'] || null,
-    });
+      adminView: AdminChildView.PLUGINS,
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  _handlePluginListRoute(_: PageContextWithQueryMap) {
-    this._setParams({
+  handlePluginListRoute(_: PageContext) {
+    const state: AdminViewState = {
       view: GerritView.ADMIN,
-      adminView: 'gr-plugin-list',
-    });
+      adminView: AdminChildView.PLUGINS,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.adminViewModel.setState(state);
   }
 
-  _handleQueryRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleQueryRoute(ctx: PageContext) {
+    const state: Partial<SearchViewState> = {
       view: GerritView.SEARCH,
-      query: data.params[0],
-      offset: data.params[2],
-    });
+      query: ctx.params[0],
+      offset: ctx.params[2],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state as AppElementParams);
+    this.searchViewModel.updateState(state);
   }
 
-  _handleChangeIdQueryRoute(data: PageContextWithQueryMap) {
+  handleChangeIdQueryRoute(ctx: PageContext) {
     // TODO(pcc): This will need to indicate that this was a change ID query if
     // standard queries gain the ability to search places like commit messages
     // for change IDs.
-    this._setParams({
-      view: GerritNav.View.SEARCH,
-      query: data.params[0],
-    });
+    const state: Partial<SearchViewState> = {
+      view: GerritView.SEARCH,
+      query: ctx.params[0],
+      offset: undefined,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state as AppElementParams);
+    this.searchViewModel.updateState(state);
   }
 
-  _handleQueryLegacySuffixRoute(ctx: PageContextWithQueryMap) {
-    this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
+  handleQueryLegacySuffixRoute(ctx: PageContext) {
+    this.redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
   }
 
-  _handleChangeNumberLegacyRoute(ctx: PageContextWithQueryMap) {
-    this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
+  handleChangeNumberLegacyRoute(ctx: PageContext) {
+    this.redirect('/c/' + encodeURIComponent(ctx.params[0]));
   }
 
-  _handleChangeRoute(ctx: PageContextWithQueryMap) {
+  handleChangeRoute(ctx: PageContext) {
     // 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,
+    const state: ChangeViewState = {
+      repo: ctx.params[0] as RepoName,
       changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
-      patchNum: convertToPatchSetNum(ctx.params[6]),
+      patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
     };
 
-    if (ctx.queryMap.has('forceReload')) {
-      params.forceReload = true;
-      history.replaceState(
-        null,
-        '',
-        location.href.replace(/[?&]forceReload=true/, '')
+    const queryMap = new URLSearchParams(ctx.querystring);
+    if (queryMap.has('forceReload')) state.forceReload = true;
+    if (queryMap.has('openReplyDialog')) state.openReplyDialog = true;
+
+    const tab = queryMap.get('tab');
+    if (tab) state.tab = tab;
+    const checksPatchset = Number(queryMap.get('checksPatchset'));
+    if (Number.isInteger(checksPatchset) && checksPatchset > 0) {
+      state.checksPatchset = checksPatchset as PatchSetNumber;
+    }
+    const filter = queryMap.get('filter');
+    if (filter) state.filter = filter;
+    const checksResultsFilter = queryMap.get('checksResultsFilter');
+    if (checksResultsFilter) state.checksResultsFilter = checksResultsFilter;
+    const attempt = stringToAttemptChoice(queryMap.get('attempt'));
+    if (attempt && attempt !== LATEST_ATTEMPT) state.attempt = attempt;
+    const selected = queryMap.get('checksRunsSelected');
+    if (selected) state.checksRunsSelected = new Set(selected.split(','));
+
+    assertIsDefined(state.repo, 'project');
+    this.reporting.setRepoName(state.repo);
+    this.reporting.setChangeId(changeNum);
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.changeViewModel.setState(state);
+  }
+
+  async handleCommentRoute(ctx: PageContext) {
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
+    const repo = ctx.params[0] as RepoName;
+    const commentId = ctx.params[2] as UrlEncodedCommentId;
+
+    const comments = await this.restApiService.getDiffComments(changeNum);
+    const change = await this.restApiService.getChangeDetail(changeNum);
+
+    const comment = findComment(addPath(comments), commentId);
+    const path = comment?.path;
+    const patchsets = computeAllPatchSets(change);
+    const latestPatchNum = computeLatestPatchNum(patchsets);
+    if (!comment || !path || !latestPatchNum) {
+      this.show404();
+      return;
+    }
+    let {basePatchNum, patchNum} = getPatchRangeForCommentUrl(
+      comment,
+      latestPatchNum
+    );
+
+    if (basePatchNum !== PARENT) {
+      const diff = await this.restApiService.getDiff(
+        changeNum,
+        basePatchNum,
+        patchNum,
+        path
       );
+      if (diff && isFileUnchanged(diff)) {
+        fireAlert(
+          document,
+          `File is unchanged between Patchset ${basePatchNum} and ${patchNum}.
+           Showing diff of Base vs ${basePatchNum}.`
+        );
+        patchNum = basePatchNum as RevisionPatchSetNum;
+        basePatchNum = PARENT;
+      }
     }
 
-    const tab = ctx.queryMap.get('tab');
-    if (tab) params.tab = tab;
-
-    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,
+    const diffUrl = createDiffUrl({
       changeNum,
-      commentId: ctx.params[2] as UrlEncodedCommentId,
-      view: GerritView.DIFF,
-      commentLink: true,
-    };
-    this.reporting.setRepoName(params.project);
-    this.reporting.setChangeId(changeNum);
-    this._redirectOrNavigate(params);
+      repo,
+      patchNum,
+      basePatchNum,
+      diffView: {
+        path,
+        lineNum: comment.line,
+        leftSide: isInBaseOfPatchRange(comment, {basePatchNum, patchNum}),
+      },
+    });
+    this.redirect(diffUrl);
   }
 
-  _handleCommentsRoute(ctx: PageContextWithQueryMap) {
+  handleCommentsRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
-    const params: GenerateUrlChangeViewParameters = {
-      project: ctx.params[0] as RepoName,
+    const state: ChangeViewState = {
+      repo: ctx.params[0] as RepoName,
       changeNum,
       commentId: ctx.params[2] as UrlEncodedCommentId,
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
     };
-    this.reporting.setRepoName(params.project);
+    assertIsDefined(state.repo);
+    this.reporting.setRepoName(state.repo);
     this.reporting.setChangeId(changeNum);
-    this._redirectOrNavigate(params);
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.changeViewModel.setState(state);
   }
 
-  _handleDiffRoute(ctx: PageContextWithQueryMap) {
+  handleDiffRoute(ctx: PageContext) {
     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,
+    const state: ChangeViewState = {
+      repo: ctx.params[0] as RepoName,
       changeNum,
       basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
-      patchNum: convertToPatchSetNum(ctx.params[6]),
-      path: ctx.params[8],
-      view: GerritView.DIFF,
+      patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.DIFF,
+      diffView: {path: ctx.params[8]},
     };
-    const address = this._parseLineAddress(ctx.hash);
+    const address = this.parseLineAddress(ctx.hash);
     if (address) {
-      params.leftSide = address.leftSide;
-      params.lineNum = address.lineNum;
+      state.diffView!.leftSide = address.leftSide;
+      state.diffView!.lineNum = address.lineNum;
     }
-    this.reporting.setRepoName(params.project);
+    this.reporting.setRepoName(state.repo ?? '');
     this.reporting.setChangeId(changeNum);
-    this._redirectOrNavigate(params);
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.changeViewModel.setState(state);
   }
 
-  _handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
+  handleChangeLegacyRoute(ctx: PageContext) {
     const changeNum = Number(ctx.params[0]) as NumericChangeId;
     if (!changeNum) {
-      this._show404();
+      this.show404();
       return;
     }
     this.restApiService.getFromProjectLookup(changeNum).then(project => {
       // Show a 404 and terminate if the lookup request failed. Attempting
       // to redirect after failing to get the project loops infinitely.
       if (!project) {
-        this._show404();
+        this.show404();
         return;
       }
-      this._redirect(`/c/${project}/+/${changeNum}/${ctx.params[1]}`);
+      this.redirect(`/c/${project}/+/${changeNum}/${ctx.params[1]}`);
     });
   }
 
-  _handleLegacyLinenum(ctx: PageContextWithQueryMap) {
-    this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
+  handleLegacyLinenum(ctx: PageContext) {
+    this.redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
   }
 
-  _handleDiffEditRoute(ctx: PageContextWithQueryMap) {
+  handleDiffEditRoute(ctx: PageContext) {
     // 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,
+    const state: ChangeViewState = {
+      repo: project,
       changeNum,
       // for edit view params, patchNum cannot be undefined
-      patchNum: convertToPatchSetNum(ctx.params[2])!,
-      path: ctx.params[3],
-      lineNum: ctx.hash,
-      view: GerritView.EDIT,
-    });
+      patchNum: convertToPatchSetNum(ctx.params[2]) as RevisionPatchSetNum,
+      view: GerritView.CHANGE,
+      childView: ChangeChildView.EDIT,
+      editView: {path: ctx.params[3], lineNum: Number(ctx.hash)},
+    };
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.changeViewModel.setState(state);
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
   }
 
-  _handleChangeEditRoute(ctx: PageContextWithQueryMap) {
+  handleChangeEditRoute(ctx: PageContext) {
     // 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;
-    const params: GenerateUrlChangeViewParameters = {
-      project,
+    const queryMap = new URLSearchParams(ctx.querystring);
+    const state: ChangeViewState = {
+      repo: project,
       changeNum,
-      patchNum: convertToPatchSetNum(ctx.params[3]),
+      patchNum: convertToPatchSetNum(ctx.params[3]) as RevisionPatchSetNum,
       view: GerritView.CHANGE,
+      childView: ChangeChildView.OVERVIEW,
       edit: true,
-      tab: ctx.queryMap.get('tab') ?? '',
     };
-    if (ctx.queryMap.has('forceReload')) {
-      params.forceReload = true;
+    const tab = queryMap.get('tab');
+    if (tab) state.tab = tab;
+    if (queryMap.has('forceReload')) {
+      state.forceReload = true;
       history.replaceState(
         null,
         '',
         location.href.replace(/[?&]forceReload=true/, '')
       );
     }
-    this._redirectOrNavigate(params);
-
+    this.normalizePatchRangeParams(state);
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.changeViewModel.setState(state);
     this.reporting.setRepoName(project);
     this.reporting.setChangeId(changeNum);
   }
 
-  /**
-   * Normalize the patch range params for a the change or diff view and
-   * redirect if URL upgrade is needed.
-   */
-  _redirectOrNavigate(params: GenerateUrlParameters & PatchRangeParams) {
-    const needsRedirect = this._normalizePatchRangeParams(params);
-    if (needsRedirect) {
-      this._redirect(this._generateUrl(params));
-    } else {
-      this._setParams(params);
-    }
+  handleAgreementsRoute() {
+    this.redirect('/settings/#Agreements');
   }
 
-  _handleAgreementsRoute() {
-    this._redirect('/settings/#Agreements');
+  handleNewAgreementsRoute() {
+    const state: AgreementViewState = {
+      view: GerritView.AGREEMENTS,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.agreementViewModel.setState(state);
   }
 
-  _handleNewAgreementsRoute(data: PageContextWithQueryMap) {
-    data.params['view'] = GerritView.AGREEMENTS;
-    // TODO(TS): create valid object
-    this._setParams(data.params as unknown as AppElementAgreementParam);
-  }
-
-  _handleSettingsLegacyRoute(data: PageContextWithQueryMap) {
+  handleSettingsLegacyRoute(ctx: PageContext) {
     // email tokens may contain '+' but no space.
     // The parameter parsing replaces all '+' with a space,
     // undo that to have valid tokens.
-    const token = data.params[0].replace(/ /g, '+');
-    this._setParams({
+    const token = ctx.params[0].replace(/ /g, '+');
+    const state: SettingsViewState = {
       view: GerritView.SETTINGS,
       emailToken: token,
-    });
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.settingsViewModel.setState(state);
   }
 
-  _handleSettingsRoute(_: PageContextWithQueryMap) {
-    this._setParams({view: GerritView.SETTINGS});
+  handleSettingsRoute(_: PageContext) {
+    const state: SettingsViewState = {view: GerritView.SETTINGS};
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.settingsViewModel.setState(state);
   }
 
-  _handleRegisterRoute(ctx: PageContextWithQueryMap) {
-    this._setParams({justRegistered: true});
+  handleRegisterRoute(ctx: PageContext) {
+    this.setState({justRegistered: true});
     let path = ctx.params[0] || '/';
 
     // Prevent redirect looping.
@@ -1749,14 +1697,14 @@
     if (path[0] !== '/') {
       return;
     }
-    this._redirect(getBaseUrl() + path);
+    this.redirect(getBaseUrl() + path);
   }
 
   /**
    * Handler for routes that should pass through the router and not be caught
    * by the catchall _handleDefaultRoute handler.
    */
-  _handlePassThroughRoute() {
+  handlePassThroughRoute() {
     windowLocationReload();
   }
 
@@ -1764,66 +1712,67 @@
    * URL may sometimes have /+/ encoded to / /.
    * Context: Issue 6888, Issue 7100
    */
-  _handleImproperlyEncodedPlusRoute(ctx: PageContextWithQueryMap) {
-    let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
+  handleImproperlyEncodedPlusRoute(ctx: PageContext) {
+    let hash = this.getHashFromCanonicalPath(ctx.canonicalPath);
     if (hash.length) {
       hash = '#' + hash;
     }
-    this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
+    this.redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
   }
 
-  _handlePluginScreen(ctx: PageContextWithQueryMap) {
-    const view = GerritView.PLUGIN_SCREEN;
-    const plugin = ctx.params[0];
-    const screen = ctx.params[1];
-    this._setParams({view, plugin, screen});
+  handlePluginScreen(ctx: PageContext) {
+    const state: PluginViewState = {
+      view: GerritView.PLUGIN_SCREEN,
+      plugin: ctx.params[0],
+      screen: ctx.params[1],
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.pluginViewModel.setState(state);
   }
 
-  _handleDocumentationSearchRoute(data: PageContextWithQueryMap) {
-    this._setParams({
+  handleDocumentationSearchRoute(ctx: PageContext) {
+    const state: DocumentationViewState = {
       view: GerritView.DOCUMENTATION_SEARCH,
-      filter: data.params['filter'] || null,
-    });
+      filter: ctx.params['filter'] || null,
+    };
+    // Note that router model view must be updated before view models.
+    this.setState(state);
+    this.documentationViewModel.setState(state);
   }
 
-  _handleDocumentationSearchRedirectRoute(data: PageContextWithQueryMap) {
-    this._redirect(
-      '/Documentation/q/filter:' + encodeURIComponent(data.params[0])
+  handleDocumentationSearchRedirectRoute(ctx: PageContext) {
+    this.redirect(
+      '/Documentation/q/filter:' + encodeURIComponent(ctx.params[0])
     );
   }
 
-  _handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
-    if (data.params[1]) {
+  handleDocumentationRedirectRoute(ctx: PageContext) {
+    if (ctx.params[1]) {
       windowLocationReload();
     } else {
       // Redirect /Documentation to /Documentation/index.html
-      this._redirect('/Documentation/index.html');
+      this.redirect('/Documentation/index.html');
     }
   }
 
   /**
    * Catchall route for when no other route is matched.
    */
-  _handleDefaultRoute() {
+  handleDefaultRoute() {
     if (this._isInitialLoad) {
       // Server recognized this route as polygerrit, so we show 404.
-      this._show404();
+      this.show404();
     } else {
       // Route can be recognized by server, so we pass it to server.
-      this._handlePassThroughRoute();
+      this.handlePassThroughRoute();
     }
   }
 
-  _show404() {
+  private show404() {
     // 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.
     firePageError(new Response('', {status: 404}));
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-router': GrRouter;
-  }
-}
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
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_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/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
deleted file mode 100644
index 7f1a40b..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ /dev/null
@@ -1,1594 +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-router.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {GerritNav} from '../gr-navigation/gr-navigation.js';
-import {stubBaseUrl, stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
-import {_testOnly_RoutePattern} from './gr-router.js';
-import {GerritView} from '../../../services/router/router-model.js';
-import {ParentPatchSetNum} from '../../../types/common.js';
-
-const basicFixture = fixtureFromElement('gr-router');
-
-suite('gr-router tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_firstCodeBrowserWeblink', () => {
-    assert.deepEqual(element._firstCodeBrowserWeblink([
-      {name: 'gitweb'},
-      {name: 'gitiles'},
-      {name: 'browse'},
-      {name: 'test'}]), {name: 'gitiles'});
-
-    assert.deepEqual(element._firstCodeBrowserWeblink([
-      {name: 'gitweb'},
-      {name: 'test'}]), {name: 'gitweb'});
-  });
-
-  test('_getBrowseCommitWeblink', () => {
-    const browserLink = {name: 'browser', url: 'browser/url'};
-    const link = {name: 'test', url: 'test/url'};
-    const weblinks = [browserLink, link];
-    const config = {gerrit: {primary_weblink_name: browserLink.name}};
-    sinon.stub(element, '_firstCodeBrowserWeblink').returns(link);
-
-    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, config),
-        browserLink);
-
-    assert.deepEqual(element._getBrowseCommitWeblink(weblinks, {}), link);
-  });
-
-  test('_getChangeWeblinks', () => {
-    const link = {name: 'test', url: 'test/url'};
-    const browserLink = {name: 'browser', url: 'browser/url'};
-    const mapLinksToConfig = weblinks => { return {options: {weblinks}}; };
-    sinon.stub(element, '_getBrowseCommitWeblink').returns(browserLink);
-
-    assert.deepEqual(
-        element._getChangeWeblinks(mapLinksToConfig([link, browserLink]))[0],
-        {name: 'test', url: 'test/url'});
-
-    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
-        {name: 'test', url: 'test/url'});
-
-    link.url = 'https://' + link.url;
-    assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig([link]))[0],
-        {name: 'test', url: 'https://test/url'});
-  });
-
-  test('_getHashFromCanonicalPath', () => {
-    let url = '/foo/bar';
-    let hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, '');
-
-    url = '';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, '');
-
-    url = '/foo#bar';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, 'bar');
-
-    url = '/foo#bar#baz';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, 'bar#baz');
-
-    url = '#foo#bar#baz';
-    hash = element._getHashFromCanonicalPath(url);
-    assert.equal(hash, 'foo#bar#baz');
-  });
-
-  suite('_parseLineAddress', () => {
-    test('returns null for empty and invalid hashes', () => {
-      let actual = element._parseLineAddress('');
-      assert.isNull(actual);
-
-      actual = element._parseLineAddress('foobar');
-      assert.isNull(actual);
-
-      actual = element._parseLineAddress('foo123');
-      assert.isNull(actual);
-
-      actual = element._parseLineAddress('123bar');
-      assert.isNull(actual);
-    });
-
-    test('parses correctly', () => {
-      let actual = element._parseLineAddress('1234');
-      assert.isOk(actual);
-      assert.equal(actual.lineNum, 1234);
-      assert.isFalse(actual.leftSide);
-
-      actual = element._parseLineAddress('a4');
-      assert.isOk(actual);
-      assert.equal(actual.lineNum, 4);
-      assert.isTrue(actual.leftSide);
-
-      actual = element._parseLineAddress('b77');
-      assert.isOk(actual);
-      assert.equal(actual.lineNum, 77);
-      assert.isTrue(actual.leftSide);
-    });
-  });
-
-  test('_startRouter requires auth for the right handlers', () => {
-    // This test encodes the lists of route handler methods that gr-router
-    // automatically checks for authentication before triggering.
-
-    const requiresAuth = {};
-    const doesNotRequireAuth = {};
-    sinon.stub(GerritNav, 'setup');
-    sinon.stub(page, 'start');
-    sinon.stub(page, 'base');
-    sinon.stub(element, '_mapRoute').callsFake(
-        (pattern, methodName, usesAuth) => {
-          if (usesAuth) {
-            requiresAuth[methodName] = true;
-          } else {
-            doesNotRequireAuth[methodName] = true;
-          }
-        });
-    element._startRouter();
-
-    const actualRequiresAuth = Object.keys(requiresAuth);
-    actualRequiresAuth.sort();
-    const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
-    actualDoesNotRequireAuth.sort();
-
-    const shouldRequireAutoAuth = [
-      '_handleAgreementsRoute',
-      '_handleChangeEditRoute',
-      '_handleCreateGroupRoute',
-      '_handleCreateProjectRoute',
-      '_handleDiffEditRoute',
-      '_handleGroupAuditLogRoute',
-      '_handleGroupInfoRoute',
-      '_handleGroupListFilterOffsetRoute',
-      '_handleGroupListFilterRoute',
-      '_handleGroupListOffsetRoute',
-      '_handleGroupMembersRoute',
-      '_handleGroupRoute',
-      '_handleGroupSelfRedirectRoute',
-      '_handleNewAgreementsRoute',
-      '_handlePluginListFilterOffsetRoute',
-      '_handlePluginListFilterRoute',
-      '_handlePluginListOffsetRoute',
-      '_handlePluginListRoute',
-      '_handleRepoCommandsRoute',
-      '_handleSettingsLegacyRoute',
-      '_handleSettingsRoute',
-    ];
-    assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
-
-    const unauthenticatedHandlers = [
-      '_handleBranchListFilterOffsetRoute',
-      '_handleBranchListFilterRoute',
-      '_handleBranchListOffsetRoute',
-      '_handleChangeIdQueryRoute',
-      '_handleChangeNumberLegacyRoute',
-      '_handleChangeRoute',
-      '_handleCommentRoute',
-      '_handleCommentsRoute',
-      '_handleDiffRoute',
-      '_handleDefaultRoute',
-      '_handleChangeLegacyRoute',
-      '_handleDocumentationRedirectRoute',
-      '_handleDocumentationSearchRoute',
-      '_handleDocumentationSearchRedirectRoute',
-      '_handleLegacyLinenum',
-      '_handleImproperlyEncodedPlusRoute',
-      '_handlePassThroughRoute',
-      '_handleProjectDashboardRoute',
-      '_handleLegacyProjectDashboardRoute',
-      '_handleProjectsOldRoute',
-      '_handleRepoAccessRoute',
-      '_handleRepoDashboardsRoute',
-      '_handleRepoGeneralRoute',
-      '_handleRepoListFilterOffsetRoute',
-      '_handleRepoListFilterRoute',
-      '_handleRepoListOffsetRoute',
-      '_handleRepoRoute',
-      '_handleQueryLegacySuffixRoute',
-      '_handleQueryRoute',
-      '_handleRegisterRoute',
-      '_handleTagListFilterOffsetRoute',
-      '_handleTagListFilterRoute',
-      '_handleTagListOffsetRoute',
-      '_handlePluginScreen',
-    ];
-
-    // Handler names that check authentication themselves, and thus don't need
-    // it performed for them.
-    const selfAuthenticatingHandlers = [
-      '_handleDashboardRoute',
-      '_handleCustomDashboardRoute',
-      '_handleRootRoute',
-    ];
-
-    const shouldNotRequireAuth = unauthenticatedHandlers
-        .concat(selfAuthenticatingHandlers);
-    shouldNotRequireAuth.sort();
-    assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
-  });
-
-  test('_redirectIfNotLoggedIn while logged in', () => {
-    stubRestApi('getLoggedIn')
-        .returns(Promise.resolve(true));
-    const data = {canonicalPath: ''};
-    const redirectStub = sinon.stub(element, '_redirectToLogin');
-    return element._redirectIfNotLoggedIn(data).then(() => {
-      assert.isFalse(redirectStub.called);
-    });
-  });
-
-  test('_redirectIfNotLoggedIn while logged out', () => {
-    stubRestApi('getLoggedIn')
-        .returns(Promise.resolve(false));
-    const redirectStub = sinon.stub(element, '_redirectToLogin');
-    const data = {canonicalPath: ''};
-    return new Promise(resolve => {
-      element._redirectIfNotLoggedIn(data)
-          .then(() => {
-            assert.isTrue(false, 'Should never execute');
-          })
-          .catch(() => {
-            assert.isTrue(redirectStub.calledOnce);
-            resolve();
-          });
-    });
-  });
-
-  suite('generateUrl', () => {
-    test('search', () => {
-      let params = {
-        view: GerritNav.View.SEARCH,
-        owner: 'a%b',
-        project: 'c%d',
-        branch: 'e%f',
-        topic: 'g%h',
-        statuses: ['op%en'],
-      };
-      assert.equal(element._generateUrl(params),
-          '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
-          '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');
-      delete params.offset;
-
-      // The presence of the query param overrides other params.
-      params.query = 'foo$bar';
-      assert.equal(element._generateUrl(params), '/q/foo%2524bar');
-
-      params.offset = 100;
-      assert.equal(element._generateUrl(params), '/q/foo%2524bar,100');
-
-      params = {
-        view: GerritNav.View.SEARCH,
-        statuses: ['a', 'b', 'c'],
-      };
-      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', () => {
-      const params = {
-        view: GerritView.CHANGE,
-        changeNum: '1234',
-        project: 'test',
-      };
-
-      assert.equal(element._generateUrl(params), '/c/test/+/1234');
-
-      params.patchNum = 10;
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
-
-      params.basePatchNum = 5;
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
-
-      params.messageHash = '#123';
-      assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10#123');
-    });
-
-    test('change with repo name encoding', () => {
-      const params = {
-        view: GerritView.CHANGE,
-        changeNum: '1234',
-        project: 'x+/y+/z+/w',
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/x%252B/y%252B/z%252B/w/+/1234');
-    });
-
-    test('diff', () => {
-      const params = {
-        view: GerritView.DIFF,
-        changeNum: '42',
-        path: 'x+y/path.cpp',
-        patchNum: 12,
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/42/12/x%252By/path.cpp');
-
-      params.project = 'test';
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/12/x%252By/path.cpp');
-
-      params.basePatchNum = 6;
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/6..12/x%252By/path.cpp');
-
-      params.path = 'foo bar/my+file.txt%';
-      params.patchNum = 2;
-      delete params.basePatchNum;
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525');
-
-      params.path = 'file.cpp';
-      params.lineNum = 123;
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/2/file.cpp#123');
-
-      params.leftSide = true;
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/2/file.cpp#b123');
-    });
-
-    test('diff with repo name encoding', () => {
-      const params = {
-        view: GerritView.DIFF,
-        changeNum: '42',
-        path: 'x+y/path.cpp',
-        patchNum: 12,
-        project: 'x+/y',
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/x%252B/y/+/42/12/x%252By/path.cpp');
-    });
-
-    test('edit', () => {
-      const params = {
-        view: GerritNav.View.EDIT,
-        changeNum: '42',
-        project: 'test',
-        path: 'x+y/path.cpp',
-      };
-      assert.equal(element._generateUrl(params),
-          '/c/test/+/42/x%252By/path.cpp,edit');
-    });
-
-    test('_getPatchRangeExpression', () => {
-      const params = {};
-      let actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '');
-
-      params.patchNum = 4;
-      actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '4');
-
-      params.basePatchNum = 2;
-      actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '2..4');
-
-      delete params.patchNum;
-      actual = element._getPatchRangeExpression(params);
-      assert.equal(actual, '2..');
-    });
-
-    suite('dashboard', () => {
-      test('self dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-        };
-        assert.equal(element._generateUrl(params), '/dashboard/self');
-      });
-
-      test('user dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          user: 'user',
-        };
-        assert.equal(element._generateUrl(params), '/dashboard/user');
-      });
-
-      test('custom self dashboard, no title', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          sections: [
-            {name: 'section 1', query: 'query 1'},
-            {name: 'section 2', query: 'query 2'},
-          ],
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/dashboard/?section%201=query%201&section%202=query%202');
-      });
-
-      test('custom repo dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          sections: [
-            {name: 'section 1', query: 'query 1 ${project}'},
-            {name: 'section 2', query: 'query 2 ${repo}'},
-          ],
-          repo: 'repo-name',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/dashboard/?section%201=query%201%20repo-name&' +
-            'section%202=query%202%20repo-name');
-      });
-
-      test('custom user dashboard, with title', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          user: 'user',
-          sections: [{name: 'name', query: 'query'}],
-          title: 'custom dashboard',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/dashboard/user?name=query&title=custom%20dashboard');
-      });
-
-      test('repo dashboard', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          repo: 'gerrit/repo',
-          dashboard: 'default:main',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/p/gerrit/repo/+/dashboard/default:main');
-      });
-
-      test('project dashboard (legacy)', () => {
-        const params = {
-          view: GerritNav.View.DASHBOARD,
-          project: 'gerrit/project',
-          dashboard: 'default:main',
-        };
-        assert.equal(
-            element._generateUrl(params),
-            '/p/gerrit/project/+/dashboard/default:main');
-      });
-    });
-
-    suite('groups', () => {
-      test('group info', () => {
-        const params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-        };
-        assert.equal(element._generateUrl(params), '/admin/groups/1234');
-      });
-
-      test('group members', () => {
-        const params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-          detail: 'members',
-        };
-        assert.equal(element._generateUrl(params),
-            '/admin/groups/1234,members');
-      });
-
-      test('group audit log', () => {
-        const params = {
-          view: GerritNav.View.GROUP,
-          groupId: 1234,
-          detail: 'log',
-        };
-        assert.equal(element._generateUrl(params),
-            '/admin/groups/1234,audit-log');
-      });
-    });
-  });
-
-  suite('param normalization', () => {
-    suite('_normalizePatchRangeParams', () => {
-      test('range n..n normalizes to n', () => {
-        const params = {basePatchNum: 4, patchNum: 4};
-        const needsRedirect = element._normalizePatchRangeParams(params);
-        assert.isTrue(needsRedirect);
-        assert.equal(params.basePatchNum, ParentPatchSetNum);
-        assert.equal(params.patchNum, 4);
-      });
-
-      test('range n.. normalizes to n', () => {
-        const params = {basePatchNum: 4};
-        const needsRedirect = element._normalizePatchRangeParams(params);
-        assert.isFalse(needsRedirect);
-        assert.equal(params.basePatchNum, ParentPatchSetNum);
-        assert.equal(params.patchNum, 4);
-      });
-    });
-  });
-
-  suite('route handlers', () => {
-    let redirectStub;
-    let setParamsStub;
-    let handlePassThroughRoute;
-
-    // Simple route handlers are direct mappings from parsed route data to a
-    // new set of app.params. This test helper asserts that passing `data`
-    // into `methodName` results in setting the params specified in `params`.
-    function assertDataToParams(data, methodName, params) {
-      element[methodName](data);
-      assert.deepEqual(setParamsStub.lastCall.args[0], params);
-    }
-
-    setup(() => {
-      redirectStub = sinon.stub(element, '_redirect');
-      setParamsStub = sinon.stub(element, '_setParams');
-      handlePassThroughRoute = sinon.stub(element, '_handlePassThroughRoute');
-    });
-
-    test('_handleLegacyProjectDashboardRoute', () => {
-      const params = {0: 'gerrit/project', 1: 'dashboard:main'};
-      element._handleLegacyProjectDashboardRoute({params});
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0],
-          '/p/gerrit/project/+/dashboard/dashboard:main');
-    });
-
-    test('_handleAgreementsRoute', () => {
-      const data = {params: {}};
-      element._handleAgreementsRoute(data);
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
-    });
-
-    test('_handleNewAgreementsRoute', () => {
-      element._handleNewAgreementsRoute({params: {}});
-      assert.isTrue(setParamsStub.calledOnce);
-      assert.equal(setParamsStub.lastCall.args[0].view,
-          GerritNav.View.AGREEMENTS);
-    });
-
-    test('_handleSettingsLegacyRoute', () => {
-      const data = {params: {0: 'my-token'}};
-      assertDataToParams(data, '_handleSettingsLegacyRoute', {
-        view: GerritNav.View.SETTINGS,
-        emailToken: 'my-token',
-      });
-    });
-
-    test('_handleSettingsLegacyRoute with +', () => {
-      const data = {params: {0: 'my-token test'}};
-      assertDataToParams(data, '_handleSettingsLegacyRoute', {
-        view: GerritNav.View.SETTINGS,
-        emailToken: 'my-token+test',
-      });
-    });
-
-    test('_handleSettingsRoute', () => {
-      const data = {};
-      assertDataToParams(data, '_handleSettingsRoute', {
-        view: GerritNav.View.SETTINGS,
-      });
-    });
-
-    test('_handleDefaultRoute on first load', () => {
-      const spy = sinon.spy();
-      addListenerForTest(document, 'page-error', spy);
-      element._handleDefaultRoute();
-      assert.isTrue(spy.calledOnce);
-      assert.equal(spy.lastCall.args[0].detail.response.status, 404);
-    });
-
-    test('_handleDefaultRoute after internal navigation', () => {
-      let onExit = null;
-      const onRegisteringExit = (match, _onExit) => {
-        onExit = _onExit;
-      };
-      sinon.stub(page, 'exit').callsFake( onRegisteringExit);
-      sinon.stub(GerritNav, 'setup');
-      sinon.stub(page, 'start');
-      sinon.stub(page, 'base');
-      element._startRouter();
-
-      element._handleDefaultRoute();
-
-      onExit('', () => {}); // we left page;
-
-      element._handleDefaultRoute();
-      assert.isTrue(handlePassThroughRoute.calledOnce);
-    });
-
-    test('_handleImproperlyEncodedPlusRoute', () => {
-      // Regression test for Issue 7100.
-      element._handleImproperlyEncodedPlusRoute(
-          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(
-          redirectStub.lastCall.args[0],
-          '/c/test/+/42');
-
-      sinon.stub(element, '_getHashFromCanonicalPath').returns('foo');
-      element._handleImproperlyEncodedPlusRoute(
-          {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
-      assert.equal(
-          redirectStub.lastCall.args[0],
-          '/c/test/+/42#foo');
-    });
-
-    test('_handleQueryRoute', () => {
-      const data = {params: ['project:foo/bar/baz']};
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: undefined,
-      });
-
-      data.params.push(',123', '123');
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: '123',
-      });
-    });
-
-    test('_handleQueryLegacySuffixRoute', () => {
-      element._handleQueryLegacySuffixRoute({path: '/q/foo+bar,n,z'});
-      assert.isTrue(redirectStub.calledOnce);
-      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
-    });
-
-    test('_handleQueryRoute', () => {
-      const data = {params: ['project:foo/bar/baz']};
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: undefined,
-      });
-
-      data.params.push(',123', '123');
-      assertDataToParams(data, '_handleQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'project:foo/bar/baz',
-        offset: '123',
-      });
-    });
-
-    test('_handleChangeIdQueryRoute', () => {
-      const data = {params: ['I0123456789abcdef0123456789abcdef01234567']};
-      assertDataToParams(data, '_handleChangeIdQueryRoute', {
-        view: GerritNav.View.SEARCH,
-        query: 'I0123456789abcdef0123456789abcdef01234567',
-      });
-    });
-
-    suite('_handleRegisterRoute', () => {
-      test('happy path', () => {
-        const ctx = {params: ['/foo/bar']};
-        element._handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-      });
-
-      test('no param', () => {
-        const ctx = {params: ['']};
-        element._handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-      });
-
-      test('prevent redirect', () => {
-        const ctx = {params: ['/register']};
-        element._handleRegisterRoute(ctx);
-        assert.isTrue(redirectStub.calledWithExactly('/'));
-        assert.isTrue(setParamsStub.calledOnce);
-        assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
-      });
-    });
-
-    suite('_handleRootRoute', () => {
-      test('closes for closeAfterLogin', () => {
-        const data = {querystring: 'closeAfterLogin', canonicalPath: ''};
-        const closeStub = sinon.stub(window, 'close');
-        const result = element._handleRootRoute(data);
-        assert.isNotOk(result);
-        assert.isTrue(closeStub.called);
-        assert.isFalse(redirectStub.called);
-      });
-
-      test('redirects to dashboard if logged in', () => {
-        const data = {
-          canonicalPath: '/', path: '/', querystring: '', hash: '',
-        };
-        const result = element._handleRootRoute(data);
-        assert.isOk(result);
-        return result.then(() => {
-          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
-        });
-      });
-
-      test('redirects to open changes if not logged in', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const data = {
-          canonicalPath: '/', path: '/', querystring: '', hash: '',
-        };
-        const result = element._handleRootRoute(data);
-        assert.isOk(result);
-        return result.then(() => {
-          assert.isTrue(
-              redirectStub.calledWithExactly('/q/status:open+-is:wip'));
-        });
-      });
-
-      suite('GWT hash-path URLs', () => {
-        test('redirects hash-path URLs', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar/baz',
-            hash: '/foo/bar/baz',
-            querystring: '',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
-        });
-
-        test('redirects hash-path URLs w/o leading slash', () => {
-          const data = {
-            canonicalPath: '/#foo/bar/baz',
-            querystring: '',
-            hash: 'foo/bar/baz',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
-        });
-
-        test('normalizes "/ /" in hash to "/+/"', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar/+/123/4',
-            querystring: '',
-            hash: '/foo/bar/ /123/4',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
-        });
-
-        test('prepends baseurl to hash-path', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar',
-            querystring: '',
-            hash: '/foo/bar',
-          };
-          stubBaseUrl('/baz');
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
-        });
-
-        test('normalizes /VE/ settings hash-paths', () => {
-          const data = {
-            canonicalPath: '/#/VE/foo/bar',
-            querystring: '',
-            hash: '/VE/foo/bar',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly(
-              '/settings/VE/foo/bar'));
-        });
-
-        test('does not drop "inner hashes"', () => {
-          const data = {
-            canonicalPath: '/#/foo/bar#baz',
-            querystring: '',
-            hash: '/foo/bar',
-          };
-          const result = element._handleRootRoute(data);
-          assert.isNotOk(result);
-          assert.isTrue(redirectStub.called);
-          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
-        });
-      });
-    });
-
-    suite('_handleDashboardRoute', () => {
-      let redirectToLoginStub;
-
-      setup(() => {
-        redirectToLoginStub = sinon.stub(element, '_redirectToLogin');
-      });
-
-      test('own dashboard but signed out redirects to login', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const data = {canonicalPath: '/dashboard/', params: {0: 'seLF'}};
-        return element._handleDashboardRoute(data, '').then(() => {
-          assert.isTrue(redirectToLoginStub.calledOnce);
-          assert.isFalse(redirectStub.called);
-          assert.isFalse(setParamsStub.called);
-        });
-      });
-
-      test('non-self dashboard but signed out does not redirect', () => {
-        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
-        return element._handleDashboardRoute(data, '').then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
-        });
-      });
-
-      test('dashboard while signed in sets params', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: 'foo'}};
-        return element._handleDashboardRoute(data, '').then(() => {
-          assert.isFalse(redirectToLoginStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(setParamsStub.calledOnce);
-          assert.deepEqual(setParamsStub.lastCall.args[0], {
-            view: GerritNav.View.DASHBOARD,
-            user: 'foo',
-          });
-        });
-      });
-    });
-
-    suite('_handleCustomDashboardRoute', () => {
-      let redirectToLoginStub;
-
-      setup(() => {
-        redirectToLoginStub = sinon.stub(element, '_redirectToLogin');
-      });
-
-      test('no user specified', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data, '').then(() => {
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.called);
-          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
-        });
-      });
-
-      test('custom dashboard without title', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data, '?a=b&c&d=e')
-            .then(() => {
-              assert.isFalse(redirectStub.called);
-              assert.isTrue(setParamsStub.calledOnce);
-              assert.deepEqual(setParamsStub.lastCall.args[0], {
-                view: GerritNav.View.DASHBOARD,
-                user: 'self',
-                sections: [
-                  {name: 'a', query: 'b'},
-                  {name: 'd', query: 'e'},
-                ],
-                title: 'Custom Dashboard',
-              });
-            });
-      });
-
-      test('custom dashboard with title', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data,
-            '?a=b&c&d=&=e&title=t')
-            .then(() => {
-              assert.isFalse(redirectToLoginStub.called);
-              assert.isFalse(redirectStub.called);
-              assert.isTrue(setParamsStub.calledOnce);
-              assert.deepEqual(setParamsStub.lastCall.args[0], {
-                view: GerritNav.View.DASHBOARD,
-                user: 'self',
-                sections: [
-                  {name: 'a', query: 'b'},
-                ],
-                title: 't',
-              });
-            });
-      });
-
-      test('custom dashboard with foreach', () => {
-        const data = {canonicalPath: '/dashboard/', params: {0: ''}};
-        return element._handleCustomDashboardRoute(data,
-            '?a=b&c&d=&=e&foreach=is:open')
-            .then(() => {
-              assert.isFalse(redirectToLoginStub.called);
-              assert.isFalse(redirectStub.called);
-              assert.isTrue(setParamsStub.calledOnce);
-              assert.deepEqual(setParamsStub.lastCall.args[0], {
-                view: GerritNav.View.DASHBOARD,
-                user: 'self',
-                sections: [
-                  {name: 'a', query: 'is:open b'},
-                ],
-                title: 'Custom Dashboard',
-              });
-            });
-      });
-    });
-
-    suite('group routes', () => {
-      test('_handleGroupInfoRoute', () => {
-        const data = {params: {0: 1234}};
-        element._handleGroupInfoRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
-      });
-
-      test('_handleGroupAuditLogRoute', () => {
-        const data = {params: {0: 1234}};
-        assertDataToParams(data, '_handleGroupAuditLogRoute', {
-          view: GerritNav.View.GROUP,
-          detail: 'log',
-          groupId: 1234,
-        });
-      });
-
-      test('_handleGroupMembersRoute', () => {
-        const data = {params: {0: 1234}};
-        assertDataToParams(data, '_handleGroupMembersRoute', {
-          view: GerritNav.View.GROUP,
-          detail: 'members',
-          groupId: 1234,
-        });
-      });
-
-      test('_handleGroupListOffsetRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 0,
-          filter: null,
-          openCreateModal: false,
-        });
-
-        data.params[1] = 42;
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 42,
-          filter: null,
-          openCreateModal: false,
-        });
-
-        data.hash = 'create';
-        assertDataToParams(data, '_handleGroupListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 42,
-          filter: null,
-          openCreateModal: true,
-        });
-      });
-
-      test('_handleGroupListFilterOffsetRoute', () => {
-        const data = {params: {filter: 'foo', offset: 42}};
-        assertDataToParams(data, '_handleGroupListFilterOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          offset: 42,
-          filter: 'foo',
-        });
-      });
-
-      test('_handleGroupListFilterRoute', () => {
-        const data = {params: {filter: 'foo'}};
-        assertDataToParams(data, '_handleGroupListFilterRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-admin-group-list',
-          filter: 'foo',
-        });
-      });
-
-      test('_handleGroupRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleGroupRoute', {
-          view: GerritNav.View.GROUP,
-          groupId: 4321,
-        });
-      });
-    });
-
-    suite('repo routes', () => {
-      test('_handleProjectsOldRoute', () => {
-        const data = {params: {}};
-        element._handleProjectsOldRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
-      });
-
-      test('_handleProjectsOldRoute test', () => {
-        const data = {params: {1: 'test'}};
-        element._handleProjectsOldRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
-      });
-
-      test('_handleProjectsOldRoute test,branches', () => {
-        const data = {params: {1: 'test,branches'}};
-        element._handleProjectsOldRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-            redirectStub.lastCall.args[0], '/admin/repos/test,branches');
-      });
-
-      test('_handleRepoRoute', () => {
-        const data = {path: '/admin/repos/test'};
-        element._handleRepoRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.equal(
-            redirectStub.lastCall.args[0], '/admin/repos/test,general');
-      });
-
-      test('_handleRepoGeneralRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleRepoGeneralRoute', {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.GENERAL,
-          repo: 4321,
-        });
-      });
-
-      test('_handleRepoCommandsRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleRepoCommandsRoute', {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.COMMANDS,
-          repo: 4321,
-        });
-      });
-
-      test('_handleRepoAccessRoute', () => {
-        const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleRepoAccessRoute', {
-          view: GerritNav.View.REPO,
-          detail: GerritNav.RepoDetailView.ACCESS,
-          repo: 4321,
-        });
-      });
-
-      suite('branch list routes', () => {
-        test('_handleBranchListOffsetRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleBranchListOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            offset: 0,
-            filter: null,
-          });
-
-          data.params[2] = 42;
-          assertDataToParams(data, '_handleBranchListOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            offset: 42,
-            filter: null,
-          });
-        });
-
-        test('_handleBranchListFilterOffsetRoute', () => {
-          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handleBranchListFilterRoute', () => {
-          const data = {params: {repo: 4321, filter: 'foo'}};
-          assertDataToParams(data, '_handleBranchListFilterRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.BRANCHES,
-            repo: 4321,
-            filter: 'foo',
-          });
-        });
-      });
-
-      suite('tag list routes', () => {
-        test('_handleTagListOffsetRoute', () => {
-          const data = {params: {0: 4321}};
-          assertDataToParams(data, '_handleTagListOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            offset: 0,
-            filter: null,
-          });
-        });
-
-        test('_handleTagListFilterOffsetRoute', () => {
-          const data = {params: {repo: 4321, filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handleTagListFilterRoute', () => {
-          const data = {params: {repo: 4321}};
-          assertDataToParams(data, '_handleTagListFilterRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            filter: null,
-          });
-
-          data.params.filter = 'foo';
-          assertDataToParams(data, '_handleTagListFilterRoute', {
-            view: GerritNav.View.REPO,
-            detail: GerritNav.RepoDetailView.TAGS,
-            repo: 4321,
-            filter: 'foo',
-          });
-        });
-      });
-
-      suite('repo list routes', () => {
-        test('_handleRepoListOffsetRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 0,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          data.params[1] = 42;
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: null,
-            openCreateModal: false,
-          });
-
-          data.hash = 'create';
-          assertDataToParams(data, '_handleRepoListOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: null,
-            openCreateModal: true,
-          });
-        });
-
-        test('_handleRepoListFilterOffsetRoute', () => {
-          const data = {params: {filter: 'foo', offset: 42}};
-          assertDataToParams(data, '_handleRepoListFilterOffsetRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            offset: 42,
-            filter: 'foo',
-          });
-        });
-
-        test('_handleRepoListFilterRoute', () => {
-          const data = {params: {}};
-          assertDataToParams(data, '_handleRepoListFilterRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            filter: null,
-          });
-
-          data.params.filter = 'foo';
-          assertDataToParams(data, '_handleRepoListFilterRoute', {
-            view: GerritNav.View.ADMIN,
-            adminView: 'gr-repo-list',
-            filter: 'foo',
-          });
-        });
-      });
-    });
-
-    suite('plugin routes', () => {
-      test('_handlePluginListOffsetRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handlePluginListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          offset: 0,
-          filter: null,
-        });
-
-        data.params[1] = 42;
-        assertDataToParams(data, '_handlePluginListOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          offset: 42,
-          filter: null,
-        });
-      });
-
-      test('_handlePluginListFilterOffsetRoute', () => {
-        const data = {params: {filter: 'foo', offset: 42}};
-        assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          offset: 42,
-          filter: 'foo',
-        });
-      });
-
-      test('_handlePluginListFilterRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handlePluginListFilterRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          filter: null,
-        });
-
-        data.params.filter = 'foo';
-        assertDataToParams(data, '_handlePluginListFilterRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-          filter: 'foo',
-        });
-      });
-
-      test('_handlePluginListRoute', () => {
-        const data = {params: {}};
-        assertDataToParams(data, '_handlePluginListRoute', {
-          view: GerritNav.View.ADMIN,
-          adminView: 'gr-plugin-list',
-        });
-      });
-    });
-
-    suite('change/diff routes', () => {
-      test('_handleChangeNumberLegacyRoute', () => {
-        const data = {params: {0: 12345}};
-        element._handleChangeNumberLegacyRoute(data);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
-      });
-
-      test('_handleChangeLegacyRoute', async () => {
-        stubRestApi('getFromProjectLookup').returns(Promise.resolve('project'));
-        const ctx = {
-          params: [
-            1234, // 0 Change number
-            'comment/6789',
-          ],
-          querystring: '',
-        };
-        element._handleChangeLegacyRoute(ctx);
-        await flush();
-        assert.isTrue(redirectStub.calledWithExactly('/c/project/+/1234' +
-            '/comment/6789'));
-      });
-
-      test('_handleLegacyLinenum w/ @321', () => {
-        const ctx = {path: '/c/1234/3..8/foo/bar@321'};
-        element._handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly(
-            '/c/1234/3..8/foo/bar#321'));
-      });
-
-      test('_handleLegacyLinenum w/ @b123', () => {
-        const ctx = {path: '/c/1234/3..8/foo/bar@b123'};
-        element._handleLegacyLinenum(ctx);
-        assert.isTrue(redirectStub.calledOnce);
-        assert.isTrue(redirectStub.calledWithExactly(
-            '/c/1234/3..8/foo/bar#b123'));
-      });
-
-      suite('_handleChangeRoute', () => {
-        let normalizeRangeStub;
-
-        function makeParams(path, hash) {
-          return {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              null, // 2 Unused
-              null, // 3 Unused
-              4, // 4 Base patch number
-              null, // 5 Unused
-              7, // 6 Patch number
-            ],
-            queryMap: new Map(),
-          };
-        }
-
-        setup(() => {
-          normalizeRangeStub = sinon.stub(element,
-              '_normalizePatchRangeParams');
-          stubRestApi('setInProjectLookup');
-        });
-
-        test('needs redirect', () => {
-          normalizeRangeStub.returns(true);
-          sinon.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams(null, '');
-          element._handleChangeRoute(ctx);
-          assert.isTrue(normalizeRangeStub.called);
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly('foo'));
-        });
-
-        test('change view', () => {
-          normalizeRangeStub.returns(false);
-          sinon.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams(null, '');
-          assertDataToParams(ctx, '_handleChangeRoute', {
-            view: GerritView.CHANGE,
-            project: 'foo/bar',
-            changeNum: 1234,
-            basePatchNum: 4,
-            patchNum: 7,
-          });
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeStub.called);
-        });
-      });
-
-      suite('_handleDiffRoute', () => {
-        let normalizeRangeStub;
-
-        function makeParams(path, hash) {
-          return {
-            params: [
-              'foo/bar', // 0 Project
-              1234, // 1 Change number
-              null, // 2 Unused
-              null, // 3 Unused
-              4, // 4 Base patch number
-              null, // 5 Unused
-              7, // 6 Patch number
-              null, // 7 Unused,
-              path, // 8 Diff path
-            ],
-            hash,
-          };
-        }
-
-        setup(() => {
-          normalizeRangeStub = sinon.stub(element,
-              '_normalizePatchRangeParams');
-          stubRestApi('setInProjectLookup');
-        });
-
-        test('needs redirect', () => {
-          normalizeRangeStub.returns(true);
-          sinon.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams(null, '');
-          element._handleDiffRoute(ctx);
-          assert.isTrue(normalizeRangeStub.called);
-          assert.isFalse(setParamsStub.called);
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isTrue(redirectStub.calledWithExactly('foo'));
-        });
-
-        test('diff view', () => {
-          normalizeRangeStub.returns(false);
-          sinon.stub(element, '_generateUrl').returns('foo');
-          const ctx = makeParams('foo/bar/baz', 'b44');
-          assertDataToParams(ctx, '_handleDiffRoute', {
-            view: GerritView.DIFF,
-            project: 'foo/bar',
-            changeNum: 1234,
-            basePatchNum: 4,
-            patchNum: 7,
-            path: 'foo/bar/baz',
-            leftSide: true,
-            lineNum: 44,
-          });
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(normalizeRangeStub.called);
-        });
-
-        test('comment route', () => {
-          const url = '/c/gerrit/+/264833/comment/00049681_f34fd6a9/';
-          const groups = url.match(_testOnly_RoutePattern.COMMENT);
-          assert.deepEqual(groups.slice(1), [
-            'gerrit', // project
-            '264833', // changeNum
-            '00049681_f34fd6a9', // commentId
-          ]);
-          assertDataToParams({params: groups.slice(1)}, '_handleCommentRoute', {
-            project: 'gerrit',
-            changeNum: 264833,
-            commentId: '00049681_f34fd6a9',
-            commentLink: true,
-            view: GerritView.DIFF,
-          });
-        });
-
-        test('comments route', () => {
-          const url = '/c/gerrit/+/264833/comments/00049681_f34fd6a9/';
-          const groups = url.match(_testOnly_RoutePattern.COMMENTS_TAB);
-          assert.deepEqual(groups.slice(1), [
-            'gerrit', // project
-            '264833', // changeNum
-            '00049681_f34fd6a9', // commentId
-          ]);
-          assertDataToParams({params: groups.slice(1)},
-              '_handleCommentsRoute', {
-                project: 'gerrit',
-                changeNum: 264833,
-                commentId: '00049681_f34fd6a9',
-                view: GerritView.CHANGE,
-              });
-        });
-      });
-
-      test('_handleDiffEditRoute', () => {
-        const normalizeRangeSpy =
-            sinon.spy(element, '_normalizePatchRangeParams');
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          params: [
-            'foo/bar', // 0 Project
-            1234, // 1 Change number
-            3, // 2 Patch num
-            'foo/bar/baz', // 3 File path
-          ],
-        };
-        const appParams = {
-          project: 'foo/bar',
-          changeNum: 1234,
-          view: GerritNav.View.EDIT,
-          path: 'foo/bar/baz',
-          patchNum: 3,
-          lineNum: undefined,
-        };
-
-        element._handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRangeSpy.calledOnce);
-        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-      });
-
-      test('_handleDiffEditRoute with lineNum', () => {
-        const normalizeRangeSpy =
-            sinon.spy(element, '_normalizePatchRangeParams');
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          params: [
-            'foo/bar', // 0 Project
-            1234, // 1 Change number
-            3, // 2 Patch num
-            'foo/bar/baz', // 3 File path
-          ],
-          hash: 4,
-        };
-        const appParams = {
-          project: 'foo/bar',
-          changeNum: 1234,
-          view: GerritNav.View.EDIT,
-          path: 'foo/bar/baz',
-          patchNum: 3,
-          lineNum: 4,
-        };
-
-        element._handleDiffEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRangeSpy.calledOnce);
-        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-      });
-
-      test('_handleChangeEditRoute', () => {
-        const normalizeRangeSpy =
-            sinon.spy(element, '_normalizePatchRangeParams');
-        stubRestApi('setInProjectLookup');
-        const ctx = {
-          params: [
-            'foo/bar', // 0 Project
-            1234, // 1 Change number
-            null,
-            3, // 3 Patch num
-          ],
-          queryMap: new Map(),
-        };
-        const appParams = {
-          project: 'foo/bar',
-          changeNum: 1234,
-          view: GerritView.CHANGE,
-          patchNum: 3,
-          edit: true,
-          tab: '',
-        };
-
-        element._handleChangeEditRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRangeSpy.calledOnce);
-        assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
-        assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
-        assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
-      });
-    });
-
-    test('_handlePluginScreen', () => {
-      const ctx = {params: ['foo', 'bar']};
-      assertDataToParams(ctx, '_handlePluginScreen', {
-        view: GerritNav.View.PLUGIN_SCREEN,
-        plugin: 'foo',
-        screen: 'bar',
-      });
-      assert.isFalse(redirectStub.called);
-    });
-  });
-
-  suite('_parseQueryString', () => {
-    test('empty queries', () => {
-      assert.deepEqual(element._parseQueryString(''), []);
-      assert.deepEqual(element._parseQueryString('?'), []);
-      assert.deepEqual(element._parseQueryString('??'), []);
-      assert.deepEqual(element._parseQueryString('&&&'), []);
-    });
-
-    test('url decoding', () => {
-      assert.deepEqual(element._parseQueryString('+'), [[' ', '']]);
-      assert.deepEqual(element._parseQueryString('???+%3d+'), [[' = ', '']]);
-      assert.deepEqual(
-          element._parseQueryString('%6e%61%6d%65=%76%61%6c%75%65'),
-          [['name', 'value']]);
-    });
-
-    test('multiple parameters', () => {
-      assert.deepEqual(
-          element._parseQueryString('a=b&c=d&e=f'),
-          [['a', 'b'], ['c', 'd'], ['e', 'f']]);
-      assert.deepEqual(
-          element._parseQueryString('&a=b&&&e=f&c'),
-          [['a', 'b'], ['e', 'f'], ['c', '']]);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
new file mode 100644
index 0000000..d8761bf
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -0,0 +1,1400 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-router';
+import {page, PageContext} from '../../../utils/page-wrapper-utils';
+import {
+  stubBaseUrl,
+  stubRestApi,
+  addListenerForTest,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {GrRouter, routerToken, _testOnly_RoutePattern} from './gr-router';
+import {GerritView} from '../../../services/router/router-model';
+import {
+  BasePatchSetNum,
+  GroupId,
+  NumericChangeId,
+  PARENT,
+  RepoName,
+  RevisionPatchSetNum,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {AppElementParams} from '../../gr-app-types';
+import {assert} from '@open-wc/testing';
+import {AdminChildView} from '../../../models/views/admin';
+import {RepoDetailView} from '../../../models/views/repo';
+import {GroupDetailView} from '../../../models/views/group';
+import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
+import {PatchRangeParams} from '../../../utils/url-util';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  createComment,
+  createDiff,
+  createParsedChange,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {ParsedChangeInfo} from '../../../types/types';
+
+suite('gr-router tests', () => {
+  let router: GrRouter;
+
+  setup(() => {
+    router = testResolver(routerToken);
+  });
+
+  test('getHashFromCanonicalPath', () => {
+    let url = '/foo/bar';
+    let hash = router.getHashFromCanonicalPath(url);
+    assert.equal(hash, '');
+
+    url = '';
+    hash = router.getHashFromCanonicalPath(url);
+    assert.equal(hash, '');
+
+    url = '/foo#bar';
+    hash = router.getHashFromCanonicalPath(url);
+    assert.equal(hash, 'bar');
+
+    url = '/foo#bar#baz';
+    hash = router.getHashFromCanonicalPath(url);
+    assert.equal(hash, 'bar#baz');
+
+    url = '#foo#bar#baz';
+    hash = router.getHashFromCanonicalPath(url);
+    assert.equal(hash, 'foo#bar#baz');
+  });
+
+  suite('parseLineAddress', () => {
+    test('returns null for empty and invalid hashes', () => {
+      let actual = router.parseLineAddress('');
+      assert.isNull(actual);
+
+      actual = router.parseLineAddress('foobar');
+      assert.isNull(actual);
+
+      actual = router.parseLineAddress('foo123');
+      assert.isNull(actual);
+
+      actual = router.parseLineAddress('123bar');
+      assert.isNull(actual);
+    });
+
+    test('parses correctly', () => {
+      let actual = router.parseLineAddress('1234');
+      assert.isOk(actual);
+      assert.equal(actual!.lineNum, 1234);
+      assert.isFalse(actual!.leftSide);
+
+      actual = router.parseLineAddress('a4');
+      assert.isOk(actual);
+      assert.equal(actual!.lineNum, 4);
+      assert.isTrue(actual!.leftSide);
+
+      actual = router.parseLineAddress('b77');
+      assert.isOk(actual);
+      assert.equal(actual!.lineNum, 77);
+      assert.isTrue(actual!.leftSide);
+    });
+  });
+
+  test('startRouter requires auth for the right handlers', () => {
+    // This test encodes the lists of route handler methods that gr-router
+    // automatically checks for authentication before triggering.
+
+    const requiresAuth: any = {};
+    const doesNotRequireAuth: any = {};
+    sinon.stub(page, 'start');
+    sinon.stub(page, 'base');
+    sinon
+      .stub(router, 'mapRoute')
+      .callsFake((_pattern, methodName, _method, usesAuth) => {
+        if (usesAuth) {
+          requiresAuth[methodName] = true;
+        } else {
+          doesNotRequireAuth[methodName] = true;
+        }
+      });
+    router.startRouter();
+
+    const actualRequiresAuth = Object.keys(requiresAuth);
+    actualRequiresAuth.sort();
+    const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
+    actualDoesNotRequireAuth.sort();
+
+    const shouldRequireAutoAuth = [
+      'handleAgreementsRoute',
+      'handleChangeEditRoute',
+      'handleCreateGroupRoute',
+      'handleCreateProjectRoute',
+      'handleDiffEditRoute',
+      'handleGroupAuditLogRoute',
+      'handleGroupInfoRoute',
+      'handleGroupListFilterOffsetRoute',
+      'handleGroupListFilterRoute',
+      'handleGroupListOffsetRoute',
+      'handleGroupMembersRoute',
+      'handleGroupRoute',
+      'handleGroupSelfRedirectRoute',
+      'handleNewAgreementsRoute',
+      'handlePluginListFilterOffsetRoute',
+      'handlePluginListFilterRoute',
+      'handlePluginListOffsetRoute',
+      'handlePluginListRoute',
+      'handleRepoCommandsRoute',
+      'handleRepoEditFileRoute',
+      'handleSettingsLegacyRoute',
+      'handleSettingsRoute',
+    ];
+    assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
+
+    const unauthenticatedHandlers = [
+      'handleBranchListFilterOffsetRoute',
+      'handleBranchListFilterRoute',
+      'handleBranchListOffsetRoute',
+      'handleChangeIdQueryRoute',
+      'handleChangeNumberLegacyRoute',
+      'handleChangeRoute',
+      'handleCommentRoute',
+      'handleCommentsRoute',
+      'handleDiffRoute',
+      'handleDefaultRoute',
+      'handleChangeLegacyRoute',
+      'handleDocumentationRedirectRoute',
+      'handleDocumentationSearchRoute',
+      'handleDocumentationSearchRedirectRoute',
+      'handleLegacyLinenum',
+      'handleImproperlyEncodedPlusRoute',
+      'handlePassThroughRoute',
+      'handleProjectDashboardRoute',
+      'handleLegacyProjectDashboardRoute',
+      'handleProjectsOldRoute',
+      'handleRepoAccessRoute',
+      'handleRepoDashboardsRoute',
+      'handleRepoGeneralRoute',
+      'handleRepoListFilterOffsetRoute',
+      'handleRepoListFilterRoute',
+      'handleRepoListOffsetRoute',
+      'handleRepoRoute',
+      'handleQueryLegacySuffixRoute',
+      'handleQueryRoute',
+      'handleRegisterRoute',
+      'handleTagListFilterOffsetRoute',
+      'handleTagListFilterRoute',
+      'handleTagListOffsetRoute',
+      'handlePluginScreen',
+    ];
+
+    // Handler names that check authentication themselves, and thus don't need
+    // it performed for them.
+    const selfAuthenticatingHandlers = [
+      'handleDashboardRoute',
+      'handleCustomDashboardRoute',
+      'handleRootRoute',
+    ];
+
+    const shouldNotRequireAuth = unauthenticatedHandlers.concat(
+      selfAuthenticatingHandlers
+    );
+    shouldNotRequireAuth.sort();
+    assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
+  });
+
+  test('redirectIfNotLoggedIn while logged in', () => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+    const ctx = {
+      save() {},
+      handled: true,
+      canonicalPath: '',
+      path: '',
+      querystring: '',
+      pathname: '',
+      state: '',
+      title: '',
+      hash: '',
+      params: {test: 'test'},
+    };
+    const redirectStub = sinon.stub(router, 'redirectToLogin');
+    return router.redirectIfNotLoggedIn(ctx).then(() => {
+      assert.isFalse(redirectStub.called);
+    });
+  });
+
+  test('redirectIfNotLoggedIn while logged out', () => {
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    const redirectStub = sinon.stub(router, 'redirectToLogin');
+    const ctx = {
+      save() {},
+      handled: true,
+      canonicalPath: '',
+      path: '',
+      querystring: '',
+      pathname: '',
+      state: '',
+      title: '',
+      hash: '',
+      params: {test: 'test'},
+    };
+    return new Promise(resolve => {
+      router
+        .redirectIfNotLoggedIn(ctx)
+        .then(() => {
+          assert.isTrue(false, 'Should never execute');
+        })
+        .catch(() => {
+          assert.isTrue(redirectStub.calledOnce);
+          resolve(Promise.resolve());
+        });
+    });
+  });
+
+  suite('param normalization', () => {
+    suite('normalizePatchRangeParams', () => {
+      test('range n..n normalizes to n', () => {
+        const params: PatchRangeParams = {
+          basePatchNum: 4 as BasePatchSetNum,
+          patchNum: 4 as RevisionPatchSetNum,
+        };
+        router.normalizePatchRangeParams(params);
+        assert.equal(params.basePatchNum, PARENT);
+        assert.equal(params.patchNum, 4 as RevisionPatchSetNum);
+      });
+
+      test('range n.. normalizes to n', () => {
+        const params: PatchRangeParams = {basePatchNum: 4 as BasePatchSetNum};
+        router.normalizePatchRangeParams(params);
+        assert.equal(params.basePatchNum, PARENT);
+        assert.equal(params.patchNum, 4 as RevisionPatchSetNum);
+      });
+    });
+  });
+
+  suite('route handlers', () => {
+    let redirectStub: sinon.SinonStub;
+    let setStateStub: sinon.SinonStub;
+    let handlePassThroughRoute: sinon.SinonStub;
+
+    // Simple route handlers are direct mappings from parsed route ctx to a
+    // new set of app.params. This test helper asserts that passing `ctx`
+    // into `methodName` results in setting the params specified in `params`.
+    function assertctxToParams(
+      ctx: PageContext,
+      methodName: string,
+      params: AppElementParams
+    ) {
+      (router as any)[methodName](ctx);
+      assert.deepEqual(setStateStub.lastCall.args[0], params);
+    }
+
+    function createPageContext(): PageContext {
+      return {
+        canonicalPath: '',
+        path: '',
+        querystring: '',
+        pathname: '',
+        hash: '',
+        params: {},
+      };
+    }
+
+    setup(() => {
+      redirectStub = sinon.stub(router, 'redirect');
+      setStateStub = sinon.stub(router, 'setState');
+      handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+    });
+
+    test('handleLegacyProjectDashboardRoute', () => {
+      const params = {
+        ...createPageContext(),
+        params: {0: 'gerrit/project', 1: 'dashboard:main'},
+      };
+      router.handleLegacyProjectDashboardRoute(params);
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(
+        redirectStub.lastCall.args[0],
+        '/p/gerrit/project/+/dashboard/dashboard:main'
+      );
+    });
+
+    test('handleAgreementsRoute', () => {
+      router.handleAgreementsRoute();
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
+    });
+
+    test('handleNewAgreementsRoute', () => {
+      router.handleNewAgreementsRoute();
+      assert.isTrue(setStateStub.calledOnce);
+      assert.equal(setStateStub.lastCall.args[0].view, GerritView.AGREEMENTS);
+    });
+
+    test('handleSettingsLegacyRoute', () => {
+      const ctx = {...createPageContext(), params: {0: 'my-token'}};
+      assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
+        view: GerritView.SETTINGS,
+        emailToken: 'my-token',
+      });
+    });
+
+    test('handleSettingsLegacyRoute with +', () => {
+      const ctx = {...createPageContext(), params: {0: 'my-token test'}};
+      assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
+        view: GerritView.SETTINGS,
+        emailToken: 'my-token+test',
+      });
+    });
+
+    test('handleSettingsRoute', () => {
+      const ctx = createPageContext();
+      assertctxToParams(ctx, 'handleSettingsRoute', {
+        view: GerritView.SETTINGS,
+      });
+    });
+
+    test('handleDefaultRoute on first load', () => {
+      const spy = sinon.spy();
+      addListenerForTest(document, 'page-error', spy);
+      router.handleDefaultRoute();
+      assert.isTrue(spy.calledOnce);
+      assert.equal(spy.lastCall.args[0].detail.response.status, 404);
+    });
+
+    test('handleDefaultRoute after internal navigation', () => {
+      let onExit: Function | null = null;
+      const onRegisteringExit = (
+        _match: string | RegExp,
+        _onExit: Function
+      ) => {
+        onExit = _onExit;
+      };
+      sinon.stub(page, 'exit').callsFake(onRegisteringExit);
+      sinon.stub(page, 'start');
+      sinon.stub(page, 'base');
+      router.startRouter();
+
+      router.handleDefaultRoute();
+
+      onExit!('', () => {}); // we left page;
+
+      router.handleDefaultRoute();
+      assert.isTrue(handlePassThroughRoute.calledOnce);
+    });
+
+    test('handleImproperlyEncodedPlusRoute', () => {
+      const params = {
+        ...createPageContext(),
+        canonicalPath: '/c/test/%20/42',
+        params: {0: 'test', 1: '42'},
+      };
+      // Regression test for Issue 7100.
+      router.handleImproperlyEncodedPlusRoute(params);
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42');
+
+      sinon.stub(router, 'getHashFromCanonicalPath').returns('foo');
+      router.handleImproperlyEncodedPlusRoute(params);
+      assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42#foo');
+    });
+
+    test('handleQueryRoute', () => {
+      const ctx: PageContext = {
+        ...createPageContext(),
+        params: {0: 'project:foo/bar/baz'},
+      };
+      assertctxToParams(ctx, 'handleQueryRoute', {
+        view: GerritView.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: undefined,
+      } as AppElementParams);
+
+      ctx.params[1] = '123';
+      ctx.params[2] = '123';
+      assertctxToParams(ctx, 'handleQueryRoute', {
+        view: GerritView.SEARCH,
+        query: 'project:foo/bar/baz',
+        offset: '123',
+      } as AppElementParams);
+    });
+
+    test('handleQueryLegacySuffixRoute', () => {
+      const params = {...createPageContext(), path: '/q/foo+bar,n,z'};
+      router.handleQueryLegacySuffixRoute(params);
+      assert.isTrue(redirectStub.calledOnce);
+      assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
+    });
+
+    test('handleChangeIdQueryRoute', () => {
+      const ctx = {
+        ...createPageContext(),
+        params: {0: 'I0123456789abcdef0123456789abcdef01234567'},
+      };
+      assertctxToParams(ctx, 'handleChangeIdQueryRoute', {
+        view: GerritView.SEARCH,
+        query: 'I0123456789abcdef0123456789abcdef01234567',
+        offset: undefined,
+      } as AppElementParams);
+    });
+
+    suite('handleRegisterRoute', () => {
+      test('happy path', () => {
+        const ctx = {...createPageContext(), params: {0: '/foo/bar'}};
+        router.handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
+        assert.isTrue(setStateStub.calledOnce);
+        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
+      });
+
+      test('no param', () => {
+        const ctx = createPageContext();
+        router.handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/'));
+        assert.isTrue(setStateStub.calledOnce);
+        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
+      });
+
+      test('prevent redirect', () => {
+        const ctx = {...createPageContext(), params: {0: '/register'}};
+        router.handleRegisterRoute(ctx);
+        assert.isTrue(redirectStub.calledWithExactly('/'));
+        assert.isTrue(setStateStub.calledOnce);
+        assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
+      });
+    });
+
+    suite('handleRootRoute', () => {
+      test('closes for closeAfterLogin', () => {
+        const ctx = {...createPageContext(), querystring: 'closeAfterLogin'};
+        const closeStub = sinon.stub(window, 'close');
+        const result = router.handleRootRoute(ctx);
+        assert.isNotOk(result);
+        assert.isTrue(closeStub.called);
+        assert.isFalse(redirectStub.called);
+      });
+
+      test('redirects to dashboard if logged in', () => {
+        const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
+        const result = router.handleRootRoute(ctx);
+        assert.isOk(result);
+        return result!.then(() => {
+          assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
+        });
+      });
+
+      test('redirects to open changes if not logged in', () => {
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+        const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
+        const result = router.handleRootRoute(ctx);
+        assert.isOk(result);
+        return result!.then(() => {
+          assert.isTrue(
+            redirectStub.calledWithExactly('/q/status:open+-is:wip')
+          );
+        });
+      });
+
+      suite('GWT hash-path URLs', () => {
+        test('redirects hash-path URLs', () => {
+          const ctx = {
+            ...createPageContext(),
+            canonicalPath: '/#/foo/bar/baz',
+            hash: '/foo/bar/baz',
+          };
+          const result = router.handleRootRoute(ctx);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        });
+
+        test('redirects hash-path URLs w/o leading slash', () => {
+          const ctx = {
+            ...createPageContext(),
+            canonicalPath: '/#foo/bar/baz',
+            hash: 'foo/bar/baz',
+          };
+          const result = router.handleRootRoute(ctx);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+        });
+
+        test('normalizes "/ /" in hash to "/+/"', () => {
+          const ctx = {
+            ...createPageContext(),
+            canonicalPath: '/#/foo/bar/+/123/4',
+            hash: '/foo/bar/ /123/4',
+          };
+          const result = router.handleRootRoute(ctx);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
+        });
+
+        test('prepends baseurl to hash-path', () => {
+          const ctx = {
+            ...createPageContext(),
+            canonicalPath: '/#/foo/bar',
+            hash: '/foo/bar',
+          };
+          stubBaseUrl('/baz');
+          const result = router.handleRootRoute(ctx);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
+        });
+
+        test('normalizes /VE/ settings hash-paths', () => {
+          const ctx = {
+            ...createPageContext(),
+            canonicalPath: '/#/VE/foo/bar',
+            hash: '/VE/foo/bar',
+          };
+          const result = router.handleRootRoute(ctx);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/settings/VE/foo/bar'));
+        });
+
+        test('does not drop "inner hashes"', () => {
+          const ctx = {
+            ...createPageContext(),
+            canonicalPath: '/#/foo/bar#baz',
+            hash: '/foo/bar',
+          };
+          const result = router.handleRootRoute(ctx);
+          assert.isNotOk(result);
+          assert.isTrue(redirectStub.called);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
+        });
+      });
+    });
+
+    suite('handleDashboardRoute', () => {
+      let redirectToLoginStub: sinon.SinonStub;
+
+      setup(() => {
+        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+      });
+
+      test('own dashboard but signed out redirects to login', () => {
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+        const ctx = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: 'seLF'},
+        };
+        return router.handleDashboardRoute(ctx).then(() => {
+          assert.isTrue(redirectToLoginStub.calledOnce);
+          assert.isFalse(redirectStub.called);
+          assert.isFalse(setStateStub.called);
+        });
+      });
+
+      test('non-self dashboard but signed out does not redirect', () => {
+        stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+        const ctx = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: 'foo'},
+        };
+        return router.handleDashboardRoute(ctx).then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(setStateStub.called);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
+        });
+      });
+
+      test('dashboard while signed in sets params', () => {
+        const ctx = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: 'foo'},
+        };
+        return router.handleDashboardRoute(ctx).then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.DASHBOARD,
+            user: 'foo',
+          });
+        });
+      });
+    });
+
+    suite('handleCustomDashboardRoute', () => {
+      let redirectToLoginStub: sinon.SinonStub;
+
+      setup(() => {
+        redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+      });
+
+      test('no user specified', () => {
+        const ctx: PageContext = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: ''},
+          querystring: '',
+        };
+        return router.handleCustomDashboardRoute(ctx).then(() => {
+          assert.isFalse(setStateStub.called);
+          assert.isTrue(redirectStub.called);
+          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+        });
+      });
+
+      test('custom dashboard without title', () => {
+        const ctx: PageContext = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: ''},
+          querystring: '?a=b&c&d=e',
+        };
+        return router.handleCustomDashboardRoute(ctx).then(() => {
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.DASHBOARD,
+            user: 'self',
+            sections: [
+              {name: 'a', query: 'b'},
+              {name: 'd', query: 'e'},
+            ],
+            title: 'Custom Dashboard',
+          });
+        });
+      });
+
+      test('custom dashboard with title', () => {
+        const ctx: PageContext = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: ''},
+          querystring: '?a=b&c&d=&=e&title=t',
+        };
+        return router.handleCustomDashboardRoute(ctx).then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.DASHBOARD,
+            user: 'self',
+            sections: [{name: 'a', query: 'b'}],
+            title: 't',
+          });
+        });
+      });
+
+      test('custom dashboard with foreach', () => {
+        const ctx: PageContext = {
+          ...createPageContext(),
+          canonicalPath: '/dashboard/',
+          params: {0: ''},
+          querystring: '?a=b&c&d=&=e&foreach=is:open',
+        };
+        return router.handleCustomDashboardRoute(ctx).then(() => {
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(setStateStub.calledOnce);
+          assert.deepEqual(setStateStub.lastCall.args[0], {
+            view: GerritView.DASHBOARD,
+            user: 'self',
+            sections: [{name: 'a', query: 'is:open b'}],
+            title: 'Custom Dashboard',
+          });
+        });
+      });
+    });
+
+    suite('group routes', () => {
+      test('handleGroupInfoRoute', () => {
+        const ctx = {...createPageContext(), params: {0: '1234'}};
+        router.handleGroupInfoRoute(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+      });
+
+      test('handleGroupAuditLogRoute', () => {
+        const ctx = {...createPageContext(), params: {0: '1234'}};
+        assertctxToParams(ctx, 'handleGroupAuditLogRoute', {
+          view: GerritView.GROUP,
+          detail: GroupDetailView.LOG,
+          groupId: '1234' as GroupId,
+        });
+      });
+
+      test('handleGroupMembersRoute', () => {
+        const ctx = {...createPageContext(), params: {0: '1234'}};
+        assertctxToParams(ctx, 'handleGroupMembersRoute', {
+          view: GerritView.GROUP,
+          detail: GroupDetailView.MEMBERS,
+          groupId: '1234' as GroupId,
+        });
+      });
+
+      test('handleGroupListOffsetRoute', () => {
+        const ctx = createPageContext();
+        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.GROUPS,
+          offset: 0,
+          filter: null,
+          openCreateModal: false,
+        });
+
+        ctx.params[1] = '42';
+        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.GROUPS,
+          offset: '42',
+          filter: null,
+          openCreateModal: false,
+        });
+
+        ctx.hash = 'create';
+        assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.GROUPS,
+          offset: '42',
+          filter: null,
+          openCreateModal: true,
+        });
+      });
+
+      test('handleGroupListFilterOffsetRoute', () => {
+        const ctx = {
+          ...createPageContext(),
+          params: {filter: 'foo', offset: '42'},
+        };
+        assertctxToParams(ctx, 'handleGroupListFilterOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.GROUPS,
+          offset: '42',
+          filter: 'foo',
+        });
+      });
+
+      test('handleGroupListFilterRoute', () => {
+        const ctx = {...createPageContext(), params: {filter: 'foo'}};
+        assertctxToParams(ctx, 'handleGroupListFilterRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.GROUPS,
+          filter: 'foo',
+        });
+      });
+
+      test('handleGroupRoute', () => {
+        const ctx = {...createPageContext(), params: {0: '4321'}};
+        assertctxToParams(ctx, 'handleGroupRoute', {
+          view: GerritView.GROUP,
+          groupId: '4321' as GroupId,
+        });
+      });
+    });
+
+    suite('repo routes', () => {
+      test('handleProjectsOldRoute', () => {
+        const ctx = {...createPageContext(), params: {}};
+        router.handleProjectsOldRoute(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
+      });
+
+      test('handleProjectsOldRoute test', () => {
+        const ctx = {...createPageContext(), params: {1: 'test'}};
+        router.handleProjectsOldRoute(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
+      });
+
+      test('handleProjectsOldRoute test,branches', () => {
+        const ctx = {...createPageContext(), params: {1: 'test,branches'}};
+        router.handleProjectsOldRoute(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(
+          redirectStub.lastCall.args[0],
+          '/admin/repos/test,branches'
+        );
+      });
+
+      test('handleRepoRoute', () => {
+        const ctx = {...createPageContext(), path: '/admin/repos/test'};
+        router.handleRepoRoute(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(
+          redirectStub.lastCall.args[0],
+          '/admin/repos/test,general'
+        );
+      });
+
+      test('handleRepoGeneralRoute', () => {
+        const ctx = {...createPageContext(), params: {0: '4321'}};
+        assertctxToParams(ctx, 'handleRepoGeneralRoute', {
+          view: GerritView.REPO,
+          detail: RepoDetailView.GENERAL,
+          repo: '4321' as RepoName,
+        });
+      });
+
+      test('handleRepoCommandsRoute', () => {
+        const ctx = {...createPageContext(), params: {0: '4321'}};
+        assertctxToParams(ctx, 'handleRepoCommandsRoute', {
+          view: GerritView.REPO,
+          detail: RepoDetailView.COMMANDS,
+          repo: '4321' as RepoName,
+        });
+      });
+
+      test('handleRepoAccessRoute', () => {
+        const ctx = {...createPageContext(), params: {0: '4321'}};
+        assertctxToParams(ctx, 'handleRepoAccessRoute', {
+          view: GerritView.REPO,
+          detail: RepoDetailView.ACCESS,
+          repo: '4321' as RepoName,
+        });
+      });
+
+      suite('branch list routes', () => {
+        test('handleBranchListOffsetRoute', () => {
+          const ctx: PageContext = {
+            ...createPageContext(),
+            params: {0: '4321'},
+          };
+          assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
+            view: GerritView.REPO,
+            detail: RepoDetailView.BRANCHES,
+            repo: '4321' as RepoName,
+            offset: 0,
+            filter: null,
+          });
+
+          ctx.params[2] = '42';
+          assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
+            view: GerritView.REPO,
+            detail: RepoDetailView.BRANCHES,
+            repo: '4321' as RepoName,
+            offset: '42',
+            filter: null,
+          });
+        });
+
+        test('handleBranchListFilterOffsetRoute', () => {
+          const ctx = {
+            ...createPageContext(),
+            params: {repo: '4321', filter: 'foo', offset: '42'},
+          };
+          assertctxToParams(ctx, 'handleBranchListFilterOffsetRoute', {
+            view: GerritView.REPO,
+            detail: RepoDetailView.BRANCHES,
+            repo: '4321' as RepoName,
+            offset: '42',
+            filter: 'foo',
+          });
+        });
+
+        test('handleBranchListFilterRoute', () => {
+          const ctx = {
+            ...createPageContext(),
+            params: {repo: '4321', filter: 'foo'},
+          };
+          assertctxToParams(ctx, 'handleBranchListFilterRoute', {
+            view: GerritView.REPO,
+            detail: RepoDetailView.BRANCHES,
+            repo: '4321' as RepoName,
+            filter: 'foo',
+          });
+        });
+      });
+
+      suite('tag list routes', () => {
+        test('handleTagListOffsetRoute', () => {
+          const ctx = {...createPageContext(), params: {0: '4321'}};
+          assertctxToParams(ctx, 'handleTagListOffsetRoute', {
+            view: GerritView.REPO,
+            detail: RepoDetailView.TAGS,
+            repo: '4321' as RepoName,
+            offset: 0,
+            filter: null,
+          });
+        });
+
+        test('handleTagListFilterOffsetRoute', () => {
+          const ctx = {
+            ...createPageContext(),
+            params: {repo: '4321', filter: 'foo', offset: '42'},
+          };
+          assertctxToParams(ctx, 'handleTagListFilterOffsetRoute', {
+            view: GerritView.REPO,
+            detail: RepoDetailView.TAGS,
+            repo: '4321' as RepoName,
+            offset: '42',
+            filter: 'foo',
+          });
+        });
+
+        test('handleTagListFilterRoute', () => {
+          const ctx: PageContext = {
+            ...createPageContext(),
+            params: {repo: '4321'},
+          };
+          assertctxToParams(ctx, 'handleTagListFilterRoute', {
+            view: GerritView.REPO,
+            detail: RepoDetailView.TAGS,
+            repo: '4321' as RepoName,
+            filter: null,
+          });
+
+          ctx.params.filter = 'foo';
+          assertctxToParams(ctx, 'handleTagListFilterRoute', {
+            view: GerritView.REPO,
+            detail: RepoDetailView.TAGS,
+            repo: '4321' as RepoName,
+            filter: 'foo',
+          });
+        });
+      });
+
+      suite('repo list routes', () => {
+        test('handleRepoListOffsetRoute', () => {
+          const ctx = createPageContext();
+          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
+            view: GerritView.ADMIN,
+            adminView: AdminChildView.REPOS,
+            offset: 0,
+            filter: null,
+            openCreateModal: false,
+          });
+
+          ctx.params[1] = '42';
+          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
+            view: GerritView.ADMIN,
+            adminView: AdminChildView.REPOS,
+            offset: '42',
+            filter: null,
+            openCreateModal: false,
+          });
+
+          ctx.hash = 'create';
+          assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
+            view: GerritView.ADMIN,
+            adminView: AdminChildView.REPOS,
+            offset: '42',
+            filter: null,
+            openCreateModal: true,
+          });
+        });
+
+        test('handleRepoListFilterOffsetRoute', () => {
+          const ctx = {
+            ...createPageContext(),
+            params: {filter: 'foo', offset: '42'},
+          };
+          assertctxToParams(ctx, 'handleRepoListFilterOffsetRoute', {
+            view: GerritView.ADMIN,
+            adminView: AdminChildView.REPOS,
+            offset: '42',
+            filter: 'foo',
+          });
+        });
+
+        test('handleRepoListFilterRoute', () => {
+          const ctx = createPageContext();
+          assertctxToParams(ctx, 'handleRepoListFilterRoute', {
+            view: GerritView.ADMIN,
+            adminView: AdminChildView.REPOS,
+            filter: null,
+          });
+
+          ctx.params.filter = 'foo';
+          assertctxToParams(ctx, 'handleRepoListFilterRoute', {
+            view: GerritView.ADMIN,
+            adminView: AdminChildView.REPOS,
+            filter: 'foo',
+          });
+        });
+      });
+    });
+
+    suite('plugin routes', () => {
+      test('handlePluginListOffsetRoute', () => {
+        const ctx = createPageContext();
+        assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.PLUGINS,
+          offset: 0,
+          filter: null,
+        });
+
+        ctx.params[1] = '42';
+        assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.PLUGINS,
+          offset: '42',
+          filter: null,
+        });
+      });
+
+      test('handlePluginListFilterOffsetRoute', () => {
+        const ctx = {
+          ...createPageContext(),
+          params: {filter: 'foo', offset: '42'},
+        };
+        assertctxToParams(ctx, 'handlePluginListFilterOffsetRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.PLUGINS,
+          offset: '42',
+          filter: 'foo',
+        });
+      });
+
+      test('handlePluginListFilterRoute', () => {
+        const ctx = createPageContext();
+        assertctxToParams(ctx, 'handlePluginListFilterRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.PLUGINS,
+          filter: null,
+        });
+
+        ctx.params.filter = 'foo';
+        assertctxToParams(ctx, 'handlePluginListFilterRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.PLUGINS,
+          filter: 'foo',
+        });
+      });
+
+      test('handlePluginListRoute', () => {
+        const ctx = createPageContext();
+        assertctxToParams(ctx, 'handlePluginListRoute', {
+          view: GerritView.ADMIN,
+          adminView: AdminChildView.PLUGINS,
+        });
+      });
+    });
+
+    suite('change/diff routes', () => {
+      test('handleChangeNumberLegacyRoute', () => {
+        const ctx = {...createPageContext(), params: {0: '12345'}};
+        router.handleChangeNumberLegacyRoute(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
+      });
+
+      test('handleChangeLegacyRoute', async () => {
+        stubRestApi('getFromProjectLookup').returns(
+          Promise.resolve('project' as RepoName)
+        );
+        const ctx = {
+          ...createPageContext(),
+          params: {0: '1234', 1: 'comment/6789'},
+        };
+        router.handleChangeLegacyRoute(ctx);
+        await waitEventLoop();
+        assert.isTrue(
+          redirectStub.calledWithExactly('/c/project/+/1234' + '/comment/6789')
+        );
+      });
+
+      test('handleLegacyLinenum w/ @321', () => {
+        const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@321'};
+        router.handleLegacyLinenum(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(
+          redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#321')
+        );
+      });
+
+      test('handleLegacyLinenum w/ @b123', () => {
+        const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@b123'};
+        router.handleLegacyLinenum(ctx);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.isTrue(
+          redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#b123')
+        );
+      });
+
+      suite('handleChangeRoute', () => {
+        function makeParams(_path: string, _hash: string): PageContext {
+          return {
+            ...createPageContext(),
+            params: {
+              0: 'foo/bar', // 0 Project
+              1: '1234', // 1 Change number
+              2: '', // 2 Unused
+              3: '', // 3 Unused
+              4: '4', // 4 Base patch number
+              5: '', // 5 Unused
+              6: '7', // 6 Patch number
+            },
+          };
+        }
+
+        setup(() => {
+          stubRestApi('setInProjectLookup');
+        });
+
+        test('change view', () => {
+          const ctx = makeParams('', '');
+          assertctxToParams(ctx, 'handleChangeRoute', {
+            view: GerritView.CHANGE,
+            childView: ChangeChildView.OVERVIEW,
+            repo: 'foo/bar' as RepoName,
+            changeNum: 1234 as NumericChangeId,
+            basePatchNum: 4 as BasePatchSetNum,
+            patchNum: 7 as RevisionPatchSetNum,
+          });
+          assert.isFalse(redirectStub.called);
+        });
+
+        test('params', () => {
+          const ctx = makeParams('', '');
+          const queryMap = new URLSearchParams();
+          queryMap.set('tab', 'checks');
+          queryMap.set('filter', 'fff');
+          queryMap.set('select', 'sss');
+          queryMap.set('attempt', '1');
+          queryMap.set('checksRunsSelected', 'asdf,qwer');
+          queryMap.set('checksResultsFilter', 'asdf.*qwer');
+          ctx.querystring = queryMap.toString();
+          assertctxToParams(ctx, 'handleChangeRoute', {
+            view: GerritView.CHANGE,
+            childView: ChangeChildView.OVERVIEW,
+            repo: 'foo/bar' as RepoName,
+            changeNum: 1234 as NumericChangeId,
+            basePatchNum: 4 as BasePatchSetNum,
+            patchNum: 7 as RevisionPatchSetNum,
+            attempt: 1,
+            filter: 'fff',
+            tab: 'checks',
+            checksRunsSelected: new Set(['asdf', 'qwer']),
+            checksResultsFilter: 'asdf.*qwer',
+          });
+        });
+      });
+
+      suite('handleDiffRoute', () => {
+        function makeParams(path: string, hash: string): PageContext {
+          return {
+            ...createPageContext(),
+            hash,
+            params: {
+              0: 'foo/bar', // 0 Project
+              1: '1234', // 1 Change number
+              2: '', // 2 Unused
+              3: '', // 3 Unused
+              4: '4', // 4 Base patch number
+              5: '', // 5 Unused
+              6: '7', // 6 Patch number
+              7: '', // 7 Unused,
+              8: path, // 8 Diff path
+            },
+          };
+        }
+
+        setup(() => {
+          stubRestApi('setInProjectLookup');
+        });
+
+        test('diff view', () => {
+          const ctx = makeParams('foo/bar/baz', 'b44');
+          assertctxToParams(ctx, 'handleDiffRoute', {
+            view: GerritView.CHANGE,
+            childView: ChangeChildView.DIFF,
+            repo: 'foo/bar' as RepoName,
+            changeNum: 1234 as NumericChangeId,
+            basePatchNum: 4 as BasePatchSetNum,
+            patchNum: 7 as RevisionPatchSetNum,
+            diffView: {
+              path: 'foo/bar/baz',
+              lineNum: 44,
+              leftSide: true,
+            },
+          });
+          assert.isFalse(redirectStub.called);
+        });
+
+        test('comment route base..1', async () => {
+          const change: ParsedChangeInfo = createParsedChange();
+          const repo = change.project;
+          const changeNum = change._number;
+          const ps = 1 as RevisionPatchSetNum;
+          const line = 23;
+          const id = '00049681_f34fd6a9' as UrlEncodedCommentId;
+          stubRestApi('getChangeDetail').resolves(change);
+          stubRestApi('getDiffComments').resolves({
+            filepath: [{...createComment(), id, patch_set: ps, line}],
+          });
+
+          const url = `/c/${repo}/+/${changeNum}/comment/${id}/`;
+          const groups = url.match(_testOnly_RoutePattern.COMMENT);
+          assert.deepEqual(groups!.slice(1), [repo, `${changeNum}`, id]);
+
+          await router.handleCommentRoute({params: groups!.slice(1)} as any);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(
+            redirectStub.lastCall.args[0],
+            `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
+          );
+        });
+
+        test('comment route 1..2', async () => {
+          const change: ParsedChangeInfo = {
+            ...createParsedChange(),
+            revisions: {
+              abc: createRevision(1),
+              def: createRevision(2),
+            },
+          };
+          const repo = change.project;
+          const changeNum = change._number;
+          const ps = 1 as RevisionPatchSetNum;
+          const line = 23;
+          const id = '00049681_f34fd6a9' as UrlEncodedCommentId;
+
+          stubRestApi('getChangeDetail').resolves(change);
+          stubRestApi('getDiffComments').resolves({
+            filepath: [{...createComment(), id, patch_set: ps, line}],
+          });
+          const diffStub = stubRestApi('getDiff');
+
+          const url = `/c/${repo}/+/${changeNum}/comment/${id}/`;
+          const groups = url.match(_testOnly_RoutePattern.COMMENT);
+
+          // If getDiff() returns a diff with changes, then we will compare
+          // the patchset of the comment (1) against latest (2).
+          diffStub.onFirstCall().resolves(createDiff());
+          await router.handleCommentRoute({params: groups!.slice(1)} as any);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(
+            redirectStub.lastCall.args[0],
+            `/c/${repo}/+/${changeNum}/${ps}..2/filepath#b${line}`
+          );
+
+          // If getDiff() returns an unchanged diff, then we will compare
+          // the patchset of the comment (1) against base.
+          diffStub.onSecondCall().resolves({
+            ...createDiff(),
+            content: [],
+          });
+          await router.handleCommentRoute({params: groups!.slice(1)} as any);
+          assert.isTrue(redirectStub.calledTwice);
+          assert.equal(
+            redirectStub.lastCall.args[0],
+            `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
+          );
+        });
+
+        test('comments route', () => {
+          const url = '/c/gerrit/+/264833/comments/00049681_f34fd6a9/';
+          const groups = url.match(_testOnly_RoutePattern.COMMENTS_TAB);
+          assert.deepEqual(groups!.slice(1), [
+            'gerrit', // project
+            '264833', // changeNum
+            '00049681_f34fd6a9', // commentId
+          ]);
+          assertctxToParams(
+            {params: groups!.slice(1)} as any,
+            'handleCommentsRoute',
+            {
+              repo: 'gerrit' as RepoName,
+              changeNum: 264833 as NumericChangeId,
+              commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
+              view: GerritView.CHANGE,
+              childView: ChangeChildView.OVERVIEW,
+            }
+          );
+        });
+      });
+
+      test('handleDiffEditRoute', () => {
+        stubRestApi('setInProjectLookup');
+        const ctx = {
+          ...createPageContext(),
+          hash: '',
+          params: {
+            0: 'foo/bar', // 0 Project
+            1: '1234', // 1 Change number
+            2: '3', // 2 Patch num
+            3: 'foo/bar/baz', // 3 File path
+          },
+        };
+        const appParams: ChangeViewState = {
+          repo: 'foo/bar' as RepoName,
+          changeNum: 1234 as NumericChangeId,
+          view: GerritView.CHANGE,
+          childView: ChangeChildView.EDIT,
+          patchNum: 3 as RevisionPatchSetNum,
+          editView: {path: 'foo/bar/baz', lineNum: 0},
+        };
+
+        router.handleDiffEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
+      });
+
+      test('handleDiffEditRoute with lineNum', () => {
+        stubRestApi('setInProjectLookup');
+        const ctx = {
+          ...createPageContext(),
+          hash: '4',
+          params: {
+            0: 'foo/bar', // 0 Project
+            1: '1234', // 1 Change number
+            2: '3', // 2 Patch num
+            3: 'foo/bar/baz', // 3 File path
+          },
+        };
+        const appParams: ChangeViewState = {
+          repo: 'foo/bar' as RepoName,
+          changeNum: 1234 as NumericChangeId,
+          view: GerritView.CHANGE,
+          childView: ChangeChildView.EDIT,
+          patchNum: 3 as RevisionPatchSetNum,
+          editView: {path: 'foo/bar/baz', lineNum: 4},
+        };
+
+        router.handleDiffEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
+      });
+
+      test('handleChangeEditRoute', () => {
+        stubRestApi('setInProjectLookup');
+        const ctx = {
+          ...createPageContext(),
+          params: {
+            0: 'foo/bar', // 0 Project
+            1: '1234', // 1 Change number
+            2: '',
+            3: '3', // 3 Patch num
+          },
+        };
+        const appParams: ChangeViewState = {
+          repo: 'foo/bar' as RepoName,
+          changeNum: 1234 as NumericChangeId,
+          view: GerritView.CHANGE,
+          childView: ChangeChildView.OVERVIEW,
+          patchNum: 3 as RevisionPatchSetNum,
+          edit: true,
+        };
+
+        router.handleChangeEditRoute(ctx);
+        assert.isFalse(redirectStub.called);
+        assert.deepEqual(setStateStub.lastCall.args[0], appParams);
+      });
+    });
+
+    test('handlePluginScreen', () => {
+      const ctx = {...createPageContext(), params: {0: 'foo', 1: 'bar'}};
+      assertctxToParams(ctx, 'handlePluginScreen', {
+        view: GerritView.PLUGIN_SCREEN,
+        plugin: 'foo',
+        screen: 'bar',
+      });
+      assert.isFalse(redirectStub.called);
+    });
+  });
+});
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 2901b8a..17edc19 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
@@ -1,40 +1,30 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-autocomplete/gr-autocomplete';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-search-bar_html';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {customElement, property} from '@polymer/decorators';
+import '../../shared/gr-icon/gr-icon';
 import {ServerInfo} from '../../../types/common';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
   GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {getDocsBaseUrl} from '../../../utils/url-util';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
-import {appContext} from '../../../services/app-context';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {
+  customElement,
+  property,
+  state,
+  query as queryDec,
+} from 'lit/decorators.js';
+import {Shortcut, ShortcutController} from '../../lit/shortcut-controller';
+import {assertIsDefined} from '../../../utils/common-util';
+import {configModelToken} from '../../../models/config/config-model';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -42,7 +32,6 @@
   'after:',
   'age:',
   'age:1week', // Give an example age
-  'assignee:',
   'attention:',
   'author:',
   'before:',
@@ -76,16 +65,15 @@
   'intopic:',
   'is:',
   'is:abandoned',
-  'is:assigned',
   'is:attention',
   'is:cherrypick',
   'is:closed',
-  'is:ignored',
   'is:merge',
   'is:merged',
   'is:open',
   'is:owner',
   'is:private',
+  'is:pure-revert',
   'is:reviewed',
   'is:reviewer',
   'is:starred',
@@ -143,34 +131,18 @@
   inputVal: string;
 }
 
-export interface GrSearchBar {
-  $: {
-    searchInput: GrAutocomplete;
-  };
-}
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-search-bar')
-export class GrSearchBar extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  private searchOperators = new Set(SEARCH_OPERATORS_WITH_NEGATIONS_SET);
-
+export class GrSearchBar extends LitElement {
   /**
    * Fired when a search is committed
    *
    * @event handle-search
    */
 
-  @property({type: String, notify: true, observer: '_valueChanged'})
-  value = '';
+  @queryDec('#searchInput') protected searchInput?: GrAutocomplete;
 
-  @property({type: Object})
-  query: AutocompleteQuery;
+  @property({type: String})
+  value = '';
 
   @property({type: Object})
   projectSuggestions: SuggestionProvider = () => Promise.resolve([]);
@@ -181,76 +153,137 @@
   @property({type: Object})
   accountSuggestions: SuggestionProvider = () => Promise.resolve([]);
 
-  @property({type: String})
-  _inputVal = '';
+  @state()
+  serverConfig?: ServerInfo;
 
-  @property({type: Number})
-  _threshold = 1;
+  @state()
+  mergeabilityComputationBehavior?: MergeabilityComputationBehavior;
 
   @property({type: String})
   label = '';
 
-  @property({type: String})
-  docBaseUrl: string | null = null;
+  // private but used in test
+  @state() inputVal = '';
 
-  private readonly restApiService = appContext.restApiService;
+  // private but used in test
+  @state() docsBaseUrl: string | null = null;
+
+  @state() private query: AutocompleteQuery;
+
+  @state() private threshold = 1;
+
+  private readonly shortcuts = new ShortcutController(this);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
 
   constructor() {
     super();
-    this.query = (input: string) => this._getSearchSuggestions(input);
+    this.query = (input: string) => this.getSearchSuggestions(input);
+    this.shortcuts.addAbstract(Shortcut.SEARCH, () => this.handleSearch());
+    subscribe(
+      this,
+      () => this.getConfigModel().mergeabilityComputationBehavior$,
+      mergeabilityComputationBehavior => {
+        this.mergeabilityComputationBehavior = mergeabilityComputationBehavior;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getConfig().then((serverConfig?: ServerInfo) => {
-      const mergeability =
-        serverConfig &&
-        serverConfig.change &&
-        serverConfig.change.mergeability_computation_behavior;
-      if (
-        mergeability ===
-          MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX ||
-        mergeability ===
-          MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX
-      ) {
-        // add 'is:mergeable' to searchOperators
-        this._addOperator('is:mergeable');
-      }
-      if (serverConfig) {
-        getDocsBaseUrl(serverConfig, this.restApiService).then(baseUrl => {
-          this.docBaseUrl = baseUrl;
-        });
-      }
-    });
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        form {
+          display: flex;
+        }
+        gr-autocomplete {
+          background-color: var(--view-background-color);
+          border-radius: var(--border-radius);
+          flex: 1;
+          outline: none;
+        }
+      `,
+    ];
   }
 
-  _computeHelpDocLink(docBaseUrl: string | null) {
+  override render() {
+    return html`
+      <form>
+        <gr-autocomplete
+          id="searchInput"
+          .label=${this.label}
+          show-search-icon
+          .text=${this.inputVal}
+          .query=${this.query}
+          allow-non-suggested-values
+          multi
+          .threshold=${this.threshold}
+          tab-complete
+          .verticalOffset=${30}
+          @commit=${(e: Event) => {
+            this.handleInputCommit(e);
+          }}
+          @text-changed=${(e: CustomEvent) => {
+            this.handleSearchTextChanged(e);
+          }}
+        >
+          <a
+            class="help"
+            slot="suffix"
+            href=${this.computeHelpDocLink()}
+            target="_blank"
+            tabindex="-1"
+          >
+            <gr-icon icon="help" title="read documentation"></gr-icon>
+          </a>
+        </gr-autocomplete>
+      </form>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('value')) {
+      this.valueChanged();
+    }
+  }
+
+  private valueChanged() {
+    this.inputVal = this.value;
+  }
+
+  private searchOperators() {
+    const set = new Set(SEARCH_OPERATORS_WITH_NEGATIONS_SET);
+    if (
+      this.mergeabilityComputationBehavior ===
+        MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX ||
+      this.mergeabilityComputationBehavior ===
+        MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX
+    ) {
+      set.add('is:mergeable');
+      set.add('-is:mergeable');
+    }
+    return set;
+  }
+
+  // private but used in test
+  computeHelpDocLink() {
     // fallback to gerrit's official doc
     let baseUrl =
-      docBaseUrl || 'https://gerrit-review.googlesource.com/documentation/';
+      this.docsBaseUrl ||
+      'https://gerrit-review.googlesource.com/documentation/';
     if (baseUrl.endsWith('/')) {
       baseUrl = baseUrl.substring(0, baseUrl.length - 1);
     }
     return `${baseUrl}/user-search.html`;
   }
 
-  _addOperator(name: string, include_neg = true) {
-    this.searchOperators.add(name);
-    if (include_neg) {
-      this.searchOperators.add(`-${name}`);
-    }
-  }
-
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [listen(Shortcut.SEARCH, _ => this._handleSearch())];
-  }
-
-  _valueChanged(value: string) {
-    this._inputVal = value;
-  }
-
-  _handleInputCommit(e: Event) {
-    this._preventDefaultAndNavigateToInputVal(e);
+  private handleInputCommit(e: Event) {
+    this.preventDefaultAndNavigateToInputVal(e);
   }
 
   /**
@@ -259,27 +292,19 @@
    * - e.target is the gr-autocomplete widget (#searchInput)
    * - e.target is the input element wrapped within #searchInput
    */
-  _preventDefaultAndNavigateToInputVal(e: Event) {
+  private preventDefaultAndNavigateToInputVal(e: Event) {
     e.preventDefault();
-    const target = (dom(e) as EventApi).rootTarget as PolymerElement;
-    // If the target is the #searchInput or has a sub-input component, that
-    // is what holds the focus as opposed to the target from the DOM event.
-    if (target.$['input']) {
-      (target.$['input'] as HTMLElement).blur();
-    } else {
-      target.blur();
-    }
-    if (!this._inputVal) return;
-    const trimmedInput = this._inputVal.trim();
+    if (!this.inputVal) return;
+    const trimmedInput = this.inputVal.trim();
     if (trimmedInput) {
-      const predefinedOpOnlyQuery = [...this.searchOperators].some(
+      const predefinedOpOnlyQuery = [...this.searchOperators()].some(
         op => op.endsWith(':') && op === trimmedInput
       );
       if (predefinedOpOnlyQuery) {
         return;
       }
       const detail: SearchBarHandleSearchDetail = {
-        inputVal: this._inputVal,
+        inputVal: this.inputVal,
       };
       this.dispatchEvent(
         new CustomEvent('handle-search', {
@@ -291,13 +316,13 @@
 
   /**
    * Determine what array of possible suggestions should be provided
-   * to _getSearchSuggestions.
+   * to getSearchSuggestions.
    *
    * @param input - The full search term, in lowercase.
    * @return This returns a promise that resolves to an array of
    * suggestion objects.
    */
-  _fetchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+  private fetchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
     // Split the input on colon to get a two part predicate/expression.
     const splitInput = input.split(':');
     const predicate = splitInput[0];
@@ -315,7 +340,6 @@
         // Fetch projects.
         return this.projectSuggestions(predicate, expression);
 
-      case 'assignee':
       case 'attention':
       case 'author':
       case 'cc':
@@ -330,7 +354,7 @@
 
       default:
         return Promise.resolve(
-          [...this.searchOperators]
+          [...this.searchOperators()]
             .filter(operator => operator.includes(input))
             .map(operator => {
               return {text: operator};
@@ -345,14 +369,16 @@
    * @param input - The complete search query.
    * @return This returns a promise that resolves to an array of
    * suggestions.
+   *
+   * private but used in test
    */
-  _getSearchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+  getSearchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
     // Allow spaces within quoted terms.
     const tokens = input.match(TOKENIZE_REGEX);
     if (tokens === null) return Promise.resolve([]);
     const trimmedInput = tokens[tokens.length - 1].toLowerCase();
 
-    return this._fetchSuggestions(trimmedInput).then(suggestions => {
+    return this.fetchSuggestions(trimmedInput).then(suggestions => {
       if (!suggestions || !suggestions.length) {
         return [];
       }
@@ -390,9 +416,14 @@
     });
   }
 
-  _handleSearch() {
-    this.$.searchInput.focus();
-    this.$.searchInput.selectAll();
+  private handleSearch() {
+    assertIsDefined(this.searchInput, 'searchInput');
+    this.searchInput.focus();
+    this.searchInput.selectAll();
+  }
+
+  private handleSearchTextChanged(e: CustomEvent) {
+    this.inputVal = e.detail.value;
   }
 }
 
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
deleted file mode 100644
index a0de7f2..0000000
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
+++ /dev/null
@@ -1,59 +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">
-    form {
-      display: flex;
-    }
-    gr-autocomplete {
-      background-color: var(--view-background-color);
-      border-radius: var(--border-radius);
-      flex: 1;
-      outline: none;
-    }
-  </style>
-  <form>
-    <gr-autocomplete
-      label="[[label]]"
-      show-search-icon=""
-      id="searchInput"
-      text="{{_inputVal}}"
-      query="[[query]]"
-      on-commit="_handleInputCommit"
-      allow-non-suggested-values=""
-      multi=""
-      threshold="[[_threshold]]"
-      tab-complete=""
-      vertical-offset="30"
-    >
-      <a
-        slot="suffix"
-        href$="[[_computeHelpDocLink(docBaseUrl)]]"
-        target="_blank"
-        class="help"
-        tabindex="-1"
-      >
-        <iron-icon
-          icon="gr-icons:help-outline"
-          title="read documentation"
-        ></iron-icon>
-      </a>
-    </gr-autocomplete>
-  </form>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index b6d0579..01e2fb6 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -1,47 +1,113 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-search-bar';
-import '../../../scripts/util';
 import {GrSearchBar} from './gr-search-bar';
-import {stubRestApi, mockPromise} from '../../../test/test-utils';
-import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import '../../../utils/async-util';
+import {
+  mockPromise,
+  pressKey,
+  waitUntil,
+  waitUntilObserved,
+} from '../../../test/test-utils';
 import {
   createChangeConfig,
-  createGerritInfo,
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
-
-const basicFixture = fixtureFromElement('gr-search-bar');
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key} from '../../../utils/dom-util';
+import {getAppContext} from '../../../services/app-context';
+import {changeModelToken} from '../../../models/change/change-model';
+import {
+  ConfigModel,
+  configModelToken,
+} from '../../../models/config/config-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-search-bar tests', () => {
   let element: GrSearchBar;
+  let configModel: ConfigModel;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    configModel = new ConfigModel(
+      testResolver(changeModelToken),
+      getAppContext().restApiService
+    );
+    const serverConfig = createServerInfo();
+    serverConfig.gerrit.doc_url = 'https://mydocumentationurl.google.com/';
+    configModel.updateServerConfig(serverConfig);
+    await waitUntilObserved(
+      configModel.docsBaseUrl$,
+      docsBaseUrl => docsBaseUrl === 'https://mydocumentationurl.google.com/'
+    );
+
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-search-bar></gr-search-bar>`,
+          configModelToken,
+          configModel
+        )
+      )
+    ).querySelector('gr-search-bar')!;
   });
 
-  test('value is propagated to _inputVal', () => {
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <form>
+          <gr-autocomplete
+            allow-non-suggested-values=""
+            id="searchInput"
+            multi=""
+            show-search-icon=""
+            tab-complete=""
+          >
+            <a
+              class="help"
+              href="https://mydocumentationurl.google.com/user-search.html"
+              slot="suffix"
+              tabindex="-1"
+              target="_blank"
+            >
+              <gr-icon icon="help" title="read documentation"></gr-icon>
+            </a>
+          </gr-autocomplete>
+        </form>
+      `
+    );
+  });
+
+  test('falls back to gerrit docs url', async () => {
+    const configWithoutDocsUrl = createServerInfo();
+    configWithoutDocsUrl.gerrit.doc_url = undefined;
+
+    configModel.updateServerConfig(configWithoutDocsUrl);
+    await waitUntilObserved(
+      configModel.docsBaseUrl$,
+      docsBaseUrl => docsBaseUrl === 'https://mydocumentationurl.google.com/'
+    );
+    await element.updateComplete;
+
+    assert.equal(
+      queryAndAssert<HTMLAnchorElement>(element, 'a')!.href,
+      'https://mydocumentationurl.google.com/user-search.html'
+    );
+  });
+
+  test('value is propagated to inputVal', async () => {
     element.value = 'foo';
-    assert.equal(element._inputVal, 'foo');
+    await element.updateComplete;
+    assert.equal(element.inputVal, 'foo');
   });
 
   const getActiveElement = () =>
@@ -52,148 +118,142 @@
   test('enter in search input fires event', async () => {
     const promise = mockPromise();
     element.addEventListener('handle-search', () => {
-      assert.notEqual(getActiveElement(), element.$.searchInput);
+      assert.notEqual(
+        getActiveElement(),
+        queryAndAssert<GrAutocomplete>(element, '#searchInput')
+      );
       promise.resolve();
     });
     element.value = 'test';
-    MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
-      13,
-      null,
-      'enter'
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
     await promise;
   });
 
-  test('input blurred after commit', () => {
-    const blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
-    element.$.searchInput.text = 'fate/stay';
-    MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
-      13,
-      null,
-      'enter'
-    );
-    assert.isTrue(blurSpy.called);
-  });
-
-  test('empty search query does not trigger nav', () => {
+  test('empty search query does not trigger nav', async () => {
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = '';
-    MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
-      13,
-      null,
-      'enter'
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
     assert.isFalse(searchSpy.called);
   });
 
-  test('Predefined query op with no predication doesnt trigger nav', () => {
+  test('Predefined query op with no predication doesnt trigger nav', async () => {
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'added:';
-    MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
-      13,
-      null,
-      'enter'
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
     assert.isFalse(searchSpy.called);
   });
 
-  test('predefined predicate query triggers nav', () => {
+  test('predefined predicate query triggers nav', async () => {
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'age:1week';
-    MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
-      13,
-      null,
-      'enter'
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
-    assert.isTrue(searchSpy.called);
+    await waitUntil(() => searchSpy.called);
   });
 
-  test('undefined predicate query triggers nav', () => {
+  test('undefined predicate query triggers nav', async () => {
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'random:1week';
-    MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
-      13,
-      null,
-      'enter'
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
-    assert.isTrue(searchSpy.called);
+    await waitUntil(() => searchSpy.called);
   });
 
-  test('empty undefined predicate query triggers nav', () => {
+  test('empty undefined predicate query triggers nav', async () => {
     const searchSpy = sinon.spy();
     element.addEventListener('handle-search', searchSpy);
     element.value = 'random:';
-    MockInteractions.pressAndReleaseKeyOn(
-      element.$.searchInput.$.input,
-      13,
-      null,
-      'enter'
+    await element.updateComplete;
+    const searchInput = queryAndAssert<GrAutocomplete>(element, '#searchInput');
+    pressKey(
+      queryAndAssert<PaperInputElement>(searchInput, '#input'),
+      Key.ENTER
     );
-    assert.isTrue(searchSpy.called);
+    await waitUntil(() => searchSpy.called);
   });
 
-  test('keyboard shortcuts', () => {
-    const focusSpy = sinon.spy(element.$.searchInput, 'focus');
-    const selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
-    MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
+  test('keyboard shortcuts', async () => {
+    const focusSpy = sinon.spy(
+      queryAndAssert<GrAutocomplete>(element, '#searchInput'),
+      'focus'
+    );
+    const selectAllSpy = sinon.spy(
+      queryAndAssert<GrAutocomplete>(element, '#searchInput'),
+      'selectAll'
+    );
+    pressKey(document.body, '/');
     assert.isTrue(focusSpy.called);
     assert.isTrue(selectAllSpy.called);
   });
 
-  suite('_getSearchSuggestions', () => {
-    setup(() => {
-      // Ensure that config.change.mergeability_computation_behavior is not set.
-      element = basicFixture.instantiate();
+  suite('getSearchSuggestions', () => {
+    setup(async () => {
+      element = await fixture(html`<gr-search-bar></gr-search-bar>`);
+      element.mergeabilityComputationBehavior =
+        MergeabilityComputationBehavior.NEVER;
+      await element.updateComplete;
     });
 
-    test('Autocompletes accounts', () => {
-      sinon
-        .stub(element, 'accountSuggestions')
-        .callsFake(() => Promise.resolve([{text: 'owner:fred@goog.co'}]));
-      return element._getSearchSuggestions('owner:fr').then(s => {
-        assert.equal(s[0].value, 'owner:fred@goog.co');
-      });
+    test('Autocompletes accounts', async () => {
+      element.accountSuggestions = () =>
+        Promise.resolve([{text: 'owner:fred@goog.co'}]);
+      await element.updateComplete;
+      const s = await element.getSearchSuggestions('owner:fr');
+      assert.equal(s[0].value, 'owner:fred@goog.co');
     });
 
     test('Autocompletes groups', async () => {
-      sinon
-        .stub(element, 'groupSuggestions')
-        .callsFake(() =>
-          Promise.resolve([
-            {text: 'ownerin:Polygerrit'},
-            {text: 'ownerin:gerrit'},
-          ])
-        );
-      const s = await element._getSearchSuggestions('ownerin:pol');
+      element.groupSuggestions = () =>
+        Promise.resolve([
+          {text: 'ownerin:Polygerrit'},
+          {text: 'ownerin:gerrit'},
+        ]);
+      await element.updateComplete;
+      const s = await element.getSearchSuggestions('ownerin:pol');
       assert.equal(s[0].value, 'ownerin:Polygerrit');
     });
 
     test('Autocompletes projects', async () => {
-      sinon
-        .stub(element, 'projectSuggestions')
-        .callsFake(() =>
-          Promise.resolve([
-            {text: 'project:Polygerrit'},
-            {text: 'project:gerrit'},
-            {text: 'project:gerrittest'},
-          ])
-        );
-      const s = await element._getSearchSuggestions('project:pol');
+      element.projectSuggestions = () =>
+        Promise.resolve([
+          {text: 'project:Polygerrit'},
+          {text: 'project:gerrit'},
+          {text: 'project:gerrittest'},
+        ]);
+      await element.updateComplete;
+      const s = await element.getSearchSuggestions('project:pol');
       assert.equal(s[0].value, 'project:Polygerrit');
     });
 
     test('Autocompletes simple searches', async () => {
-      const s = await element._getSearchSuggestions('is:o');
+      const s = await element.getSearchSuggestions('is:o');
       assert.equal(s[0].name, 'is:open');
       assert.equal(s[0].value, 'is:open');
       assert.equal(s[1].name, 'is:owner');
@@ -201,12 +261,12 @@
     });
 
     test('Does not autocomplete with no match', async () => {
-      const s = await element._getSearchSuggestions('asdasdasdasd');
+      const s = await element.getSearchSuggestions('asdasdasdasd');
       assert.equal(s.length, 0);
     });
 
     test('Autocompletes without is:mergable when disabled', async () => {
-      const s = await element._getSearchSuggestions('is:mergeab');
+      const s = await element.getSearchSuggestions('is:mergeab');
       assert.isEmpty(s);
     });
   });
@@ -217,23 +277,20 @@
   ].forEach(mergeability => {
     suite(`mergeability as ${mergeability}`, () => {
       setup(async () => {
-        stubRestApi('getConfig').returns(
-          Promise.resolve({
-            ...createServerInfo(),
-            change: {
-              ...createChangeConfig(),
-              mergeability_computation_behavior:
-                mergeability as MergeabilityComputationBehavior,
-            },
-          })
-        );
-
-        element = basicFixture.instantiate();
-        await flush();
+        element = await fixture(html`<gr-search-bar></gr-search-bar>`);
+        element.serverConfig = {
+          ...createServerInfo(),
+          change: {
+            ...createChangeConfig(),
+            mergeability_computation_behavior:
+              mergeability as MergeabilityComputationBehavior,
+          },
+        };
+        await element.updateComplete;
       });
 
       test('Autocompltes with is:mergable when enabled', async () => {
-        const s = await element._getSearchSuggestions('is:mergeab');
+        const s = await element.getSearchSuggestions('is:mergeab');
         assert.equal(s.length, 2);
         assert.equal(s[0].name, 'is:mergeable');
         assert.equal(s[0].value, 'is:mergeable');
@@ -245,32 +302,23 @@
 
   suite('doc url', () => {
     setup(async () => {
-      stubRestApi('getConfig').returns(
-        Promise.resolve({
-          ...createServerInfo(),
-          gerrit: {
-            ...createGerritInfo(),
-            doc_url: 'https://doc.com/',
-          },
-        })
-      );
-
-      _testOnly_clearDocsBaseUrlCache();
-      element = basicFixture.instantiate();
-      await flush();
+      element = await fixture(html`<gr-search-bar></gr-search-bar>`);
     });
 
-    test('compute help doc url with correct path', () => {
-      assert.equal(element.docBaseUrl, 'https://doc.com/');
+    test('compute help doc url with correct path', async () => {
+      element.docsBaseUrl = 'https://doc.com/';
+      await element.updateComplete;
       assert.equal(
-        element._computeHelpDocLink(element.docBaseUrl),
+        element.computeHelpDocLink(),
         'https://doc.com/user-search.html'
       );
     });
 
-    test('compute help doc url fallback to gerrit url', () => {
+    test('compute help doc url fallback to gerrit url', async () => {
+      element.docsBaseUrl = null;
+      await element.updateComplete;
       assert.equal(
-        element._computeHelpDocLink(null),
+        element.computeHelpDocLink(),
         'https://gerrit-review.googlesource.com/documentation/' +
           'user-search.html'
       );
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index 6a73d8d..b9c920a 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
@@ -1,32 +1,25 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-search-bar/gr-search-bar';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-smart-search_html';
-import {GerritNav} from '../gr-navigation/gr-navigation';
+import {navigationToken} from '../gr-navigation/gr-navigation';
 import {getUserName} from '../../../utils/display-name-util';
-import {customElement, property} from '@polymer/decorators';
 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';
+import {getAppContext} from '../../../services/app-context';
+import {LitElement, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {createSearchUrl} from '../../../models/views/search';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const MAX_AUTOCOMPLETE_RESULTS = 10;
 const SELF_EXPRESSION = 'self';
@@ -36,49 +29,59 @@
   interface HTMLElementEventMap {
     'handle-search': CustomEvent<SearchBarHandleSearchDetail>;
   }
+  interface HTMLElementTagNameMap {
+    'gr-smart-search': GrSmartSearch;
+  }
 }
 
 @customElement('gr-smart-search')
-export class GrSmartSearch extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrSmartSearch extends LitElement {
   @property({type: String})
   searchQuery = '';
 
-  @property({type: Object})
-  _config?: ServerInfo;
-
-  @property({type: Object})
-  _projectSuggestions: SuggestionProvider = (predicate, expression) =>
-    this._fetchProjects(predicate, expression);
-
-  @property({type: Object})
-  _groupSuggestions: SuggestionProvider = (predicate, expression) =>
-    this._fetchGroups(predicate, expression);
-
-  @property({type: Object})
-  _accountSuggestions: SuggestionProvider = (predicate, expression) =>
-    this._fetchAccounts(predicate, expression);
+  @state()
+  serverConfig?: ServerInfo;
 
   @property({type: String})
   label = '';
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getConfig().then(cfg => {
-      this._config = cfg;
-    });
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
   }
 
-  _handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
-    const input = e.detail.inputVal;
-    if (input) {
-      GerritNav.navigateToSearchQuery(input);
-    }
+  override render() {
+    const accountSuggestions: SuggestionProvider = (predicate, expression) =>
+      this.fetchAccounts(predicate, expression);
+    const groupSuggestions: SuggestionProvider = (predicate, expression) =>
+      this.fetchGroups(predicate, expression);
+    const projectSuggestions: SuggestionProvider = (predicate, expression) =>
+      this.fetchProjects(predicate, expression);
+    return html`
+      <gr-search-bar
+        id="search"
+        .label=${this.label}
+        .value=${this.searchQuery}
+        .projectSuggestions=${projectSuggestions}
+        .groupSuggestions=${groupSuggestions}
+        .accountSuggestions=${accountSuggestions}
+        @handle-search=${(e: CustomEvent<SearchBarHandleSearchDetail>) => {
+          this.handleSearch(e);
+        }}
+      ></gr-search-bar>
+    `;
   }
 
   /**
@@ -88,13 +91,19 @@
    * 'project'
    * @param expression - The second part of the search term, e.g.
    * 'gerr'
+   *
+   * private but used in test
    */
-  _fetchProjects(
+  fetchProjects(
     predicate: string,
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
     return this.restApiService
-      .getSuggestedProjects(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedRepos(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS,
+        throwingErrorCallback
+      )
       .then(projects => {
         if (!projects) {
           return [];
@@ -113,8 +122,10 @@
    * 'ownerin'
    * @param expression - The second part of the search term, e.g.
    * 'polyger'
+   *
+   * private but used in test
    */
-  _fetchGroups(
+  fetchGroups(
     predicate: string,
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
@@ -122,7 +133,12 @@
       return Promise.resolve([]);
     }
     return this.restApiService
-      .getSuggestedGroups(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedGroups(
+        expression,
+        undefined,
+        MAX_AUTOCOMPLETE_RESULTS,
+        throwingErrorCallback
+      )
       .then(groups => {
         if (!groups) {
           return [];
@@ -141,8 +157,10 @@
    * 'owner'
    * @param expression - The second part of the search term, e.g.
    * 'kasp'
+   *
+   * private but used in test
    */
-  _fetchAccounts(
+  fetchAccounts(
     predicate: string,
     expression: string
   ): Promise<AutocompleteSuggestion[]> {
@@ -150,12 +168,18 @@
       return Promise.resolve([]);
     }
     return this.restApiService
-      .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .getSuggestedAccounts(
+        expression,
+        MAX_AUTOCOMPLETE_RESULTS,
+        /* canSee=*/ undefined,
+        /* filterActive=*/ undefined,
+        throwingErrorCallback
+      )
       .then(accounts => {
         if (!accounts) {
           return [];
         }
-        return this._mapAccountsHelper(accounts, predicate);
+        return this.mapAccountsHelper(accounts, predicate);
       })
       .then(accounts => {
         // When the expression supplied is a beginning substring of 'self',
@@ -170,12 +194,12 @@
       });
   }
 
-  _mapAccountsHelper(
+  private mapAccountsHelper(
     accounts: AccountInfo[],
     predicate: string
   ): AutocompleteSuggestion[] {
     return accounts.map(account => {
-      const userName = getUserName(this._config, account);
+      const userName = getUserName(this.serverConfig, account);
       return {
         label: account.name || '',
         text: account.email
@@ -184,10 +208,10 @@
       };
     });
   }
-}
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-smart-search': GrSmartSearch;
+  private handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
+    const query = e.detail.inputVal;
+    if (!query) return;
+    this.getNavigation().setUrl(createSearchUrl({query}));
   }
 }
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
deleted file mode 100644
index c08df15..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_html.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles"></style>
-  <gr-search-bar
-    id="search"
-    label="[[label]]"
-    value="{{searchQuery}}"
-    on-handle-search="_handleSearch"
-    project-suggestions="[[_projectSuggestions]]"
-    group-suggestions="[[_groupSuggestions]]"
-    account-suggestions="[[_accountSuggestions]]"
-  ></gr-search-bar>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
index 0218a8f..7e3b896 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
@@ -1,33 +1,27 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-smart-search';
 import {GrSmartSearch} from './gr-smart-search';
 import {stubRestApi} from '../../../test/test-utils';
 import {EmailAddress, GroupId, UrlEncodedRepoName} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-smart-search');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-smart-search tests', () => {
   let element: GrSmartSearch;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-smart-search></gr-smart-search>`);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ' <gr-search-bar id="search"> </gr-search-bar> '
+    );
   });
 
   test('Autocompletes accounts', () => {
@@ -39,7 +33,7 @@
         },
       ])
     );
-    return element._fetchAccounts('owner', 'fr').then(s => {
+    return element.fetchAccounts('owner', 'fr').then(s => {
       assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
     });
   });
@@ -54,12 +48,12 @@
       ])
     );
     element
-      ._fetchAccounts('owner', 's')
+      .fetchAccounts('owner', 's')
       .then(s => {
         assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
         assert.deepEqual(s[1], {text: 'owner:self'});
       })
-      .then(() => element._fetchAccounts('owner', 'selfs'))
+      .then(() => element.fetchAccounts('owner', 'selfs'))
       .then(s => {
         assert.notEqual(s[0], {text: 'owner:self'});
       });
@@ -75,12 +69,12 @@
       ])
     );
     return element
-      ._fetchAccounts('owner', 'm')
+      .fetchAccounts('owner', 'm')
       .then(s => {
         assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
         assert.deepEqual(s[1], {text: 'owner:me'});
       })
-      .then(() => element._fetchAccounts('owner', 'meme'))
+      .then(() => element.fetchAccounts('owner', 'meme'))
       .then(s => {
         assert.notEqual(s[0], {text: 'owner:me'});
       });
@@ -94,16 +88,16 @@
         gerrittest: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
       })
     );
-    return element._fetchGroups('ownerin', 'pol').then(s => {
+    return element.fetchGroups('ownerin', 'pol').then(s => {
       assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
     });
   });
 
   test('Autocompletes projects', () => {
-    stubRestApi('getSuggestedProjects').callsFake(() =>
+    stubRestApi('getSuggestedRepos').callsFake(() =>
       Promise.resolve({Polygerrit: {id: 'test' as UrlEncodedRepoName}})
     );
-    return element._fetchProjects('project', 'pol').then(s => {
+    return element.fetchProjects('project', 'pol').then(s => {
       assert.deepEqual(s[0], {text: 'project:Polygerrit'});
     });
   });
@@ -116,7 +110,7 @@
         gerrittest: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
       })
     );
-    return element._fetchGroups('ownerin', 'gerrit').then(s => {
+    return element.fetchGroups('ownerin', 'gerrit').then(s => {
       assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
       assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
       assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
@@ -127,7 +121,7 @@
     stubRestApi('getSuggestedAccounts').callsFake(() =>
       Promise.resolve([{name: 'fred'}])
     );
-    return element._fetchAccounts('owner', 'fr').then(s => {
+    return element.fetchAccounts('owner', 'fr').then(s => {
       assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
     });
   });
@@ -136,7 +130,7 @@
     stubRestApi('getSuggestedAccounts').callsFake(() =>
       Promise.resolve([{email: 'fred@goog.co' as EmailAddress}])
     );
-    return element._fetchAccounts('owner', 'fr').then(s => {
+    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.ts b/polygerrit-ui/app/elements/custom-dark-theme_test.ts
deleted file mode 100644
index 71e0740..0000000
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../test/common-test-setup-karma';
-import {getComputedStyleValue} from '../utils/dom-util';
-import './gr-app';
-import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
-import {GrApp} from './gr-app';
-
-const basicFixture = fixtureFromElement('gr-app');
-
-suite('gr-app custom dark theme tests', () => {
-  let element: GrApp;
-  setup(async () => {
-    window.localStorage.setItem('dark-theme', 'true');
-
-    element = basicFixture.instantiate();
-    getPluginLoader().loadPlugins([]);
-    await getPluginLoader().awaitPluginsLoaded();
-    await flush();
-  });
-
-  teardown(() => {
-    window.localStorage.removeItem('dark-theme');
-    // The app sends requests to server. This can lead to
-    // unexpected gr-alert elements in document.body
-    document.body.querySelectorAll('gr-alert').forEach(grAlert => {
-      grAlert.remove();
-    });
-  });
-
-  test('should tried to load dark theme', () => {
-    assert.isTrue(!!document.head.querySelector('#dark-theme'));
-  });
-
-  test('applies the right theme', () => {
-    assert.equal(
-      getComputedStyleValue('--header-background-color', element).toLowerCase(),
-      '#3c4043'
-    );
-    assert.equal(
-      getComputedStyleValue('--footer-background-color', element).toLowerCase(),
-      '#3c4043'
-    );
-  });
-});
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.ts b/polygerrit-ui/app/elements/custom-light-theme_test.ts
deleted file mode 100644
index 80a7cab..0000000
--- a/polygerrit-ui/app/elements/custom-light-theme_test.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 '../test/common-test-setup-karma';
-import {getComputedStyleValue} from '../utils/dom-util';
-import './gr-app';
-import '../styles/themes/app-theme';
-import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
-import {stubRestApi} from '../test/test-utils';
-import {GrApp} from './gr-app';
-import {
-  createAccountDetailWithId,
-  createServerInfo,
-} from '../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-app');
-
-suite('gr-app custom light theme tests', () => {
-  let element: GrApp;
-  setup(async () => {
-    window.localStorage.removeItem('dark-theme');
-    stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
-    stubRestApi('getAccount').returns(
-      Promise.resolve(createAccountDetailWithId())
-    );
-    stubRestApi('getDiffComments').returns(Promise.resolve({}));
-    stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-    stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-    element = basicFixture.instantiate();
-    getPluginLoader().loadPlugins([]);
-    await getPluginLoader().awaitPluginsLoaded();
-    await flush();
-  });
-  teardown(() => {
-    // The app sends requests to server. This can lead to
-    // unexpected gr-alert elements in document.body
-    document.body.querySelectorAll('gr-alert').forEach(grAlert => {
-      grAlert.remove();
-    });
-  });
-
-  test('should not load dark theme', () => {
-    assert.isFalse(!!document.head.querySelector('#dark-theme'));
-    assert.isTrue(!!document.head.querySelector('#light-theme'));
-  });
-
-  test('applies the right theme', () => {
-    assert.equal(
-      getComputedStyleValue('--header-background-color', element).toLowerCase(),
-      '#f1f3f4'
-    );
-    assert.equal(
-      getComputedStyleValue('--footer-background-color', element).toLowerCase(),
-      'transparent'
-    );
-  });
-});
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index a6176e1..b146e93 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
@@ -1,53 +1,42 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-icon/iron-icon';
 import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
-import '../gr-diff/gr-diff';
-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 '../../shared/gr-icon/gr-icon';
+import '../../../embed/diff/gr-diff/gr-diff';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   NumericChangeId,
-  EditPatchSetNum,
-  FixId,
+  EDIT,
   FixSuggestionInfo,
   PatchSetNum,
-  RobotId,
   BasePatchSetNum,
+  FilePathToDiffInfoMap,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {isRobot} from '../../../utils/comment-util';
+import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
 import {OpenFixPreviewEvent} from '../../../types/events';
-import {appContext} from '../../../services/app-context';
-import {fireCloseFixPreview, fireEvent} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
+import {fireCloseFixPreview} from '../../../utils/event-util';
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
-
-export interface GrApplyFixDialog {
-  $: {
-    applyFixOverlay: GrOverlay;
-    nextFix: GrButton;
-  };
-}
+import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {assert} from '../../../utils/common-util';
+import {resolve} from '../../../models/dependency';
+import {createChangeUrl} from '../../../models/views/change';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
 
 interface FilePreview {
   filepath: string;
@@ -55,257 +44,337 @@
 }
 
 @customElement('gr-apply-fix-dialog')
-export class GrApplyFixDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrApplyFixDialog extends LitElement {
+  @query('#applyFixModal')
+  applyFixModal?: HTMLDialogElement;
 
-  @property({type: Object})
-  prefs?: DiffPreferencesInfo;
+  @query('#applyFixDialog')
+  applyFixDialog?: GrDialog;
+
+  /** The currently observed dialog by `dialogOberserver`. */
+  observedDialog?: GrDialog;
+
+  /** The current observer observing the `observedDialog`. */
+  dialogObserver?: ResizeObserver;
+
+  @query('#nextFix')
+  nextFix?: GrButton;
 
   @property({type: Object})
   change?: ParsedChangeInfo;
 
-  @property({type: String})
+  @property({type: Number})
   changeNum?: NumericChangeId;
 
-  @property({type: Number})
-  _patchNum?: PatchSetNum;
+  @state()
+  patchNum?: PatchSetNum;
 
-  @property({type: String})
-  _robotId?: RobotId;
+  @state()
+  currentFix?: FixSuggestionInfo;
 
-  @property({type: Object})
-  _currentFix?: FixSuggestionInfo;
+  @state()
+  currentPreviews: FilePreview[] = [];
 
-  @property({type: Array})
-  _currentPreviews: FilePreview[] = [];
+  @state()
+  fixSuggestions?: FixSuggestionInfo[];
 
-  @property({type: Array})
-  _fixSuggestions?: FixSuggestionInfo[];
+  @state()
+  isApplyFixLoading = false;
 
-  @property({type: Boolean})
-  _isApplyFixLoading = false;
+  @state()
+  selectedFixIdx = 0;
 
-  @property({type: Number})
-  _selectedFixIdx = 0;
-
-  @property({
-    type: Boolean,
-    computed:
-      '_computeDisableApplyFixButton(_isApplyFixLoading, change, ' +
-      '_patchNum)',
-  })
-  _disableApplyFixButton = false;
-
-  @property({type: Array})
+  @state()
   layers: DiffLayer[] = [];
 
-  private refitOverlay?: () => void;
+  @state()
+  diffPrefs?: DiffPreferencesInfo;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  private readonly syntaxLayer = new GrSyntaxLayerWorker(
+    resolve(this, highlightServiceToken),
+    () => getAppContext().reportingService
+  );
 
   constructor() {
     super();
-    this.restApiService.getPreferences().then(prefs => {
-      if (!prefs?.disable_token_highlighting) {
-        this.layers = [new TokenHighlightLayer(this)];
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      preferences => {
+        const layers: DiffLayer[] = [this.syntaxLayer];
+        if (!preferences?.disable_token_highlighting) {
+          layers.push(new TokenHighlightLayer(this));
+        }
+        this.layers = layers;
       }
-    });
-  }
-
-  /**
-   * Given robot comment CustomEvent object, fetch diffs associated
-   * with first robot comment suggested fix and open dialog.
-   *
-   * @param e to be passed from gr-comment with robot comment detail.
-   * @return Promise that resolves either when all
-   * preview diffs are fetched or no fix suggestions in custom event detail.
-   */
-  open(e: OpenFixPreviewEvent) {
-    const detail = e.detail;
-    const comment = detail.comment;
-    if (!detail.patchNum || !comment || !isRobot(comment)) {
-      return Promise.resolve();
-    }
-    this._patchNum = detail.patchNum;
-    this._fixSuggestions = comment.fix_suggestions;
-    this._robotId = comment.robot_id;
-    if (!this._fixSuggestions || !this._fixSuggestions.length) {
-      return Promise.resolve();
-    }
-    this._selectedFixIdx = 0;
-    const promises = [];
-    promises.push(
-      this._showSelectedFixSuggestion(this._fixSuggestions[0]),
-      this.$.applyFixOverlay.open()
     );
-    return Promise.all(promises).then(() => {
-      // ensures gr-overlay repositions overlay in center
-      fireEvent(this.$.applyFixOverlay, 'iron-resize');
-    });
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.diffPrefs = diffPreferences;
+        this.syntaxLayer.setEnabled(!!this.diffPrefs.syntax_highlighting);
+      }
+    );
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.refitOverlay = () => {
-      // re-center the dialog as content changed
-      fireEvent(this.$.applyFixOverlay, 'iron-resize');
-    };
-    this.addEventListener('diff-context-expanded', this.refitOverlay);
+  static override styles = [
+    sharedStyles,
+    modalStyles,
+    css`
+      .diffContainer {
+        padding: var(--spacing-l) 0;
+        border-bottom: 1px solid var(--border-color);
+      }
+      .file-name {
+        display: block;
+        padding: var(--spacing-s) var(--spacing-l);
+        background-color: var(--background-color-secondary);
+        border-bottom: 1px solid var(--border-color);
+      }
+      gr-button {
+        margin-left: var(--spacing-m);
+      }
+      .fix-picker {
+        display: flex;
+        align-items: center;
+        margin-right: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <dialog id="applyFixModal" tabindex="-1">
+        <gr-dialog
+          id="applyFixDialog"
+          .confirmLabel=${this.isApplyFixLoading ? 'Saving...' : 'Apply Fix'}
+          .confirmTooltip=${this.computeTooltip()}
+          ?disabled=${this.computeDisableApplyFixButton()}
+          @confirm=${this.handleApplyFix}
+          @cancel=${this.onCancel}
+        >
+          ${this.renderHeader()} ${this.renderMain()} ${this.renderFooter()}
+        </gr-dialog>
+      </dialog>
+    `;
   }
 
   override disconnectedCallback() {
-    if (this.refitOverlay) {
-      this.removeEventListener('diff-context-expanded', this.refitOverlay);
-    }
     super.disconnectedCallback();
   }
 
-  _showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
-    this._currentFix = fixSuggestion;
-    return this._fetchFixPreview(fixSuggestion.fix_id);
+  private renderHeader() {
+    return html`
+      <div slot="header">${this.currentFix?.description ?? ''}</div>
+    `;
   }
 
-  _fetchFixPreview(fixId: FixId) {
-    if (!this.changeNum || !this._patchNum) {
+  private renderMain() {
+    const items = this.currentPreviews.map(
+      item => html`
+        <div class="file-name">
+          <span>${item.filepath}</span>
+        </div>
+        <div class="diffContainer">${this.renderDiff(item)}</div>
+      `
+    );
+    return html`<div slot="main">${items}</div>`;
+  }
+
+  private renderDiff(preview: FilePreview) {
+    const diff = preview.preview;
+    if (!anyLineTooLong(diff)) {
+      this.syntaxLayer.process(diff);
+    }
+    return html`<gr-diff
+      .prefs=${this.overridePartialDiffPrefs()}
+      .path=${preview.filepath}
+      .diff=${diff}
+      .layers=${this.layers}
+    ></gr-diff>`;
+  }
+
+  private renderFooter() {
+    const id = this.selectedFixIdx;
+    const fixCount = this.fixSuggestions?.length ?? 0;
+    if (fixCount < 2) return;
+    return html`
+      <div slot="footer" class="fix-picker">
+        <span>Suggested fix ${id + 1} of ${fixCount}</span>
+        <gr-button
+          id="prevFix"
+          @click=${this.onPrevFixClick}
+          ?disabled=${id === 0}
+        >
+          <gr-icon icon="chevron_left"></gr-icon>
+        </gr-button>
+        <gr-button
+          id="nextFix"
+          @click=${this.onNextFixClick}
+          ?disabled=${id === fixCount - 1}
+        >
+          <gr-icon icon="chevron_right"></gr-icon>
+        </gr-button>
+      </div>
+    `;
+  }
+
+  /**
+   * Given event with fixSuggestions, fetch diffs associated with first
+   * suggested fix and open dialog.
+   */
+  open(e: OpenFixPreviewEvent) {
+    this.patchNum = e.detail.patchNum;
+    this.fixSuggestions = e.detail.fixSuggestions;
+    assert(this.fixSuggestions.length > 0, 'no fix in the event');
+    this.selectedFixIdx = 0;
+    this.applyFixModal?.showModal();
+    return this.showSelectedFixSuggestion(this.fixSuggestions[0]);
+  }
+
+  private async showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
+    this.currentFix = fixSuggestion;
+    await this.fetchFixPreview(fixSuggestion);
+  }
+
+  private async fetchFixPreview(fixSuggestion: FixSuggestionInfo) {
+    if (!this.changeNum || !this.patchNum) {
       return Promise.reject(
-        new Error('Both _patchNum and changeNum must be set')
+        new Error('Both patchNum and changeNum must be set')
       );
     }
-    return this.restApiService
-      .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
-      .then(res => {
-        if (res) {
-          this._currentPreviews = Object.keys(res).map(key => {
-            return {filepath: key, preview: res[key]};
-          });
-        }
-      })
-      .catch(err => {
-        this._close(false);
-        throw err;
-      });
+    let res: FilePathToDiffInfoMap | undefined;
+    try {
+      if (fixSuggestion.fix_id === PROVIDED_FIX_ID) {
+        res = await this.restApiService.getFixPreview(
+          this.changeNum,
+          this.patchNum,
+          fixSuggestion.replacements
+        );
+      } else {
+        res = await this.restApiService.getRobotCommentFixPreview(
+          this.changeNum,
+          this.patchNum,
+          fixSuggestion.fix_id
+        );
+      }
+      if (res) {
+        this.currentPreviews = Object.keys(res).map(key => {
+          return {filepath: key, preview: res![key]};
+        });
+      }
+    } catch (e) {
+      this.close(false);
+      throw e;
+    }
+    return res;
   }
 
-  hasSingleFix(_fixSuggestions?: FixSuggestionInfo[]) {
-    return (_fixSuggestions || []).length === 1;
-  }
-
-  overridePartialPrefs(prefs?: DiffPreferencesInfo) {
-    if (!prefs) return undefined;
+  private overridePartialDiffPrefs() {
+    if (!this.diffPrefs) return undefined;
     // generate a smaller gr-diff than fullscreen for dialog
-    return {...prefs, line_length: 50};
+    return {
+      ...this.diffPrefs,
+      line_length: Math.min(this.diffPrefs.line_length, 100),
+    };
   }
 
+  // visible for testing
   onCancel(e: Event) {
-    if (e) {
-      e.stopPropagation();
-    }
-    this._close(false);
-  }
-
-  addOneTo(_selectedFixIdx: number) {
-    return _selectedFixIdx + 1;
-  }
-
-  _onPrevFixClick(e: Event) {
     if (e) e.stopPropagation();
-    if (this._selectedFixIdx >= 1 && this._fixSuggestions) {
-      this._selectedFixIdx -= 1;
-      this._showSelectedFixSuggestion(
-        this._fixSuggestions[this._selectedFixIdx]
-      );
+    this.close(false);
+  }
+
+  // visible for testing
+  onPrevFixClick(e: Event) {
+    if (e) e.stopPropagation();
+    if (this.selectedFixIdx >= 1 && this.fixSuggestions) {
+      this.selectedFixIdx -= 1;
+      this.showSelectedFixSuggestion(this.fixSuggestions[this.selectedFixIdx]);
     }
   }
 
-  _onNextFixClick(e: Event) {
+  // visible for testing
+  onNextFixClick(e: Event) {
     if (e) e.stopPropagation();
     if (
-      this._fixSuggestions &&
-      this._selectedFixIdx < this._fixSuggestions.length
+      this.fixSuggestions &&
+      this.selectedFixIdx < this.fixSuggestions.length
     ) {
-      this._selectedFixIdx += 1;
-      this._showSelectedFixSuggestion(
-        this._fixSuggestions[this._selectedFixIdx]
-      );
+      this.selectedFixIdx += 1;
+      this.showSelectedFixSuggestion(this.fixSuggestions[this.selectedFixIdx]);
     }
   }
 
-  _noPrevFix(_selectedFixIdx: number) {
-    return _selectedFixIdx === 0;
-  }
-
-  _noNextFix(_selectedFixIdx: number, fixSuggestions?: FixSuggestionInfo[]) {
-    if (!fixSuggestions) return true;
-    return _selectedFixIdx === fixSuggestions.length - 1;
-  }
-
-  _close(fixApplied: boolean) {
-    this._currentFix = undefined;
-    this._currentPreviews = [];
-    this._isApplyFixLoading = false;
+  private close(fixApplied: boolean) {
+    this.currentFix = undefined;
+    this.currentPreviews = [];
+    this.isApplyFixLoading = false;
 
     fireCloseFixPreview(this, fixApplied);
-    this.$.applyFixOverlay.close();
+    this.applyFixModal?.close();
   }
 
-  _getApplyFixButtonLabel(isLoading: boolean) {
-    return isLoading ? 'Saving...' : 'Apply Fix';
-  }
-
-  _computeTooltip(change?: ParsedChangeInfo, patchNum?: PatchSetNum) {
-    if (!change || !patchNum) return '';
-    const latestPatchNum = change.revisions[change.current_revision]._number;
-    return latestPatchNum !== patchNum
+  private computeTooltip() {
+    if (!this.change || !this.patchNum) return '';
+    const latestPatchNum =
+      this.change.revisions[this.change.current_revision]._number;
+    return latestPatchNum !== this.patchNum
       ? 'Fix can only be applied to the latest patchset'
       : '';
   }
 
-  _computeDisableApplyFixButton(
-    isApplyFixLoading: boolean,
-    change?: ParsedChangeInfo,
-    patchNum?: PatchSetNum
-  ) {
-    if (!change || isApplyFixLoading === undefined || patchNum === undefined) {
-      return true;
-    }
-    const currentPatchNum = change.revisions[change.current_revision]._number;
-    if (patchNum !== currentPatchNum) {
-      return true;
-    }
-    return isApplyFixLoading;
+  private computeDisableApplyFixButton() {
+    if (!this.change || !this.patchNum) return true;
+    const latestPatchNum =
+      this.change.revisions[this.change.current_revision]._number;
+    return this.patchNum !== latestPatchNum || this.isApplyFixLoading;
   }
 
-  _handleApplyFix(e: Event) {
-    if (e) {
-      e.stopPropagation();
-    }
+  // visible for testing
+  async handleApplyFix(e: Event) {
+    if (e) e.stopPropagation();
 
     const changeNum = this.changeNum;
-    const patchNum = this._patchNum;
+    const patchNum = this.patchNum;
     const change = this.change;
-    if (!changeNum || !patchNum || !change || !this._currentFix) {
-      return Promise.reject(new Error('Not all required properties are set.'));
+    if (!changeNum || !patchNum || !change || !this.currentFix) {
+      throw new Error('Not all required properties are set.');
     }
-    this._isApplyFixLoading = true;
-    return this.restApiService
-      .applyFixSuggestion(changeNum, patchNum, this._currentFix.fix_id)
-      .then(res => {
-        if (res && res.ok) {
-          GerritNav.navigateToChange(
-            change,
-            EditPatchSetNum,
-            patchNum as BasePatchSetNum
-          );
-          this._close(true);
-        }
-        this._isApplyFixLoading = false;
-      });
-  }
-
-  getFixDescription(currentFix?: FixSuggestionInfo) {
-    return currentFix && currentFix.description ? currentFix.description : '';
+    this.isApplyFixLoading = true;
+    let res;
+    if (this.fixSuggestions?.[0].fix_id === PROVIDED_FIX_ID) {
+      res = await this.restApiService.applyFixSuggestion(
+        changeNum,
+        patchNum,
+        this.fixSuggestions[0].replacements
+      );
+    } else {
+      res = await this.restApiService.applyRobotFixSuggestion(
+        changeNum,
+        patchNum,
+        this.currentFix.fix_id
+      );
+    }
+    if (res && res.ok) {
+      this.getNavigation().setUrl(
+        createChangeUrl({
+          change,
+          patchNum: EDIT,
+          basePatchNum: 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
deleted file mode 100644
index b0716dd..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-diff {
-      --content-width: 90vw;
-    }
-    .diffContainer {
-      padding: var(--spacing-l) 0;
-      border-bottom: 1px solid var(--border-color);
-    }
-    .file-name {
-      display: block;
-      padding: var(--spacing-s) var(--spacing-l);
-      background-color: var(--background-color-secondary);
-      border-bottom: 1px solid var(--border-color);
-    }
-    gr-button {
-      margin-left: var(--spacing-m);
-    }
-    .fix-picker {
-      display: flex;
-      align-items: center;
-      margin-right: var(--spacing-l);
-    }
-  </style>
-  <gr-overlay id="applyFixOverlay" with-backdrop="">
-    <gr-dialog
-      id="applyFixDialog"
-      on-confirm="_handleApplyFix"
-      confirm-label="[[_getApplyFixButtonLabel(_isApplyFixLoading)]]"
-      disabled="[[_disableApplyFixButton]]"
-      confirm-tooltip="[[_computeTooltip(change, _patchNum)]]"
-      on-cancel="onCancel"
-    >
-      <div slot="header">[[_robotId]] - [[getFixDescription(_currentFix)]]</div>
-      <div slot="main">
-        <template is="dom-repeat" items="[[_currentPreviews]]">
-          <div class="file-name">
-            <span>[[item.filepath]]</span>
-          </div>
-          <div class="diffContainer">
-            <gr-diff
-              prefs="[[overridePartialPrefs(prefs)]]"
-              change-num="[[changeNum]]"
-              path="[[item.filepath]]"
-              diff="[[item.preview]]"
-              layers="[[layers]]"
-            ></gr-diff>
-          </div>
-        </template>
-      </div>
-      <div
-        slot="footer"
-        class="fix-picker"
-        hidden$="[[hasSingleFix(_fixSuggestions)]]"
-      >
-        <span
-          >Suggested fix [[addOneTo(_selectedFixIdx)]] of
-          [[_fixSuggestions.length]]</span
-        >
-        <gr-button
-          id="prevFix"
-          on-click="_onPrevFixClick"
-          disabled$="[[_noPrevFix(_selectedFixIdx)]]"
-        >
-          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
-        </gr-button>
-        <gr-button
-          id="nextFix"
-          on-click="_onNextFixClick"
-          disabled$="[[_noNextFix(_selectedFixIdx, _fixSuggestions)]]"
-        >
-          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-        </gr-button>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index 94d37f5..24dadf7 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -1,34 +1,14 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-apply-fix-dialog';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} 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 {PatchSetNum} from '../../../types/common';
 import {
   createFixSuggestionInfo,
   createParsedChange,
@@ -37,38 +17,31 @@
 } 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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {SinonStub} from 'sinon';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-apply-fix-dialog tests', () => {
   let element: GrApplyFixDialog;
+  let setUrlStub: SinonStub;
 
-  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: [
+  const TWO_FIXES: OpenFixPreviewEventDetail = {
+    patchNum: 2 as PatchSetNum,
+    fixSuggestions: [
       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')],
+  const ONE_FIX: OpenFixPreviewEventDetail = {
+    patchNum: 2 as PatchSetNum,
+    fixSuggestions: [createFixSuggestionInfo('fix_1')],
   };
 
   function getConfirmButton(): GrButton {
@@ -78,22 +51,35 @@
     );
   }
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  async function open(detail: OpenFixPreviewEventDetail) {
+    element.open(
+      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+        detail,
+      })
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+    element = await fixture<GrApplyFixDialog>(
+      html`<gr-apply-fix-dialog></gr-apply-fix-dialog>`
+    );
     const change = {
       ...createParsedChange(),
       revisions: createRevisions(2),
       current_revision: getCurrentRevision(1),
     };
     element.changeNum = change._number;
-    element._patchNum = change.revisions[change.current_revision]._number;
+    element.patchNum = change.revisions[change.current_revision]._number;
     element.change = change;
-    element.prefs = {
+    element.diffPrefs = {
       ...createDefaultDiffPrefs(),
       font_size: 12,
       line_length: 100,
       tab_size: 4,
     };
+    await element.updateComplete;
   });
 
   suite('dialog open', () => {
@@ -156,37 +142,21 @@
           f2: diffInfo2,
         })
       );
-      sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+      sinon.stub(element.applyFixModal!, 'showModal');
     });
 
     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);
+      await open(TWO_FIXES);
+      assert.equal(element.currentFix!.fix_id, 'fix_1');
+      assert.equal(element.currentPreviews.length, 2);
       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;
-      await flush();
+      element.isApplyFixLoading = true;
+      await open(TWO_FIXES);
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(button.getAttribute('title'), '');
@@ -198,15 +168,7 @@
         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();
+      await open(TWO_FIXES);
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(
@@ -216,52 +178,72 @@
     });
   });
 
+  test('renders', async () => {
+    await open(TWO_FIXES);
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <dialog id="applyFixModal" tabindex="-1" open="">
+          <gr-dialog id="applyFixDialog" role="dialog">
+            <div slot="header">Fix fix_1</div>
+            <div slot="main"></div>
+            <div class="fix-picker" slot="footer">
+              <span>Suggested fix 1 of 2</span>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                id="prevFix"
+                role="button"
+                tabindex="-1"
+              >
+                <gr-icon icon="chevron_left"></gr-icon>
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                id="nextFix"
+                role="button"
+                tabindex="0"
+              >
+                <gr-icon icon="chevron_right"></gr-icon>
+              </gr-button>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `,
+      {ignoreAttributes: ['style']}
+    );
+  });
+
   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);
+    await open(ONE_FIX);
+    await element.updateComplete;
+    assert.notOk(element.nextFix);
+    element.applyFixModal?.close();
+
+    await open(TWO_FIXES);
+    assert.ok(element.nextFix);
+    assert.notOk(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);
+    try {
+      await open(TWO_FIXES);
+    } catch (error) {
+      // expected
+    }
+    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 applyRobotFixSuggestionStub = stubRestApi(
+      'applyRobotFixSuggestion'
+    ).returns(Promise.resolve(new Response(null, {status: 200})));
+    element.currentFix = createFixSuggestionInfo('123');
 
     const closeFixPreviewEventSpy = sinon.spy();
     // Element is recreated after each test, removeEventListener isn't required
@@ -269,20 +251,17 @@
       EventType.CLOSE_FIX_PREVIEW,
       closeFixPreviewEventSpy
     );
-    await element._handleApplyFix(new CustomEvent('confirm'));
+
+    await element.handleApplyFix(new CustomEvent('confirm'));
 
     sinon.assert.calledOnceWithExactly(
-      applyFixSuggestionStub,
+      applyRobotFixSuggestionStub,
       element.change!._number,
       2 as PatchSetNum,
       '123'
     );
-    sinon.assert.calledWithExactly(
-      navigateToChangeStub,
-      element.change!,
-      EditPatchSetNum,
-      element.change!.revisions[2]._number as BasePatchSetNum
-    );
+    assert.isTrue(setUrlStub.called);
+    assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/2..edit');
 
     sinon.assert.calledOnceWithExactly(
       closeFixPreviewEventSpy,
@@ -292,54 +271,44 @@
         },
       })
     );
-
     // reset gr-apply-fix-dialog and close
-    assert.equal(element._currentFix, undefined);
-    assert.equal(element._currentPreviews.length, 0);
+    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');
+    const applyRobotFixSuggestionStub = stubRestApi(
+      'applyRobotFixSuggestion'
+    ).returns(Promise.resolve(new Response(null, {status: 500})));
+    element.currentFix = createFixSuggestionInfo('fix_123');
 
-    await element._handleApplyFix(new CustomEvent('confirm'));
+    await element.handleApplyFix(new CustomEvent('confirm'));
+
     sinon.assert.calledWithExactly(
-      applyFixSuggestionStub,
+      applyRobotFixSuggestionStub,
       element.change!._number,
       2 as PatchSetNum,
       'fix_123'
     );
-    assert.isTrue(navigateToChangeStub.notCalled);
-
-    assert.equal(element._isApplyFixLoading, false);
+    assert.isFalse(setUrlStub.called);
+    assert.equal(element.isApplyFixLoading, false);
   });
 
   test('select fix forward and back of multiple suggested fixes', async () => {
-    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+    sinon.stub(element.applyFixModal!, 'showModal');
 
-    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');
+    await open(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(
+    stubRestApi('applyRobotFixSuggestion').returns(
       Promise.reject(new Error('backend error'))
     );
-    const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._currentFix = createFixSuggestionInfo('fix_123');
+    element.currentFix = createFixSuggestionInfo('fix_123');
 
     const closeFixPreviewEventSpy = sinon.spy();
     // Element is recreated after each test, removeEventListener isn't required
@@ -349,11 +318,11 @@
     );
 
     let expectedError;
-    await element._handleApplyFix(new CustomEvent('click')).catch(e => {
+    await element.handleApplyFix(new CustomEvent('click')).catch(e => {
       expectedError = e;
     });
     assert.isOk(expectedError);
-    assert.isFalse(navigateToChangeStub.called);
+    assert.isFalse(setUrlStub.called);
     sinon.assert.notCalled(closeFixPreviewEventSpy);
   });
 
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 32c732e..6eb5243 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
@@ -1,28 +1,15 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {
-  CommentBasics,
   PatchRange,
   PatchSetNum,
   RobotCommentInfo,
-  UrlEncodedCommentId,
   PathToCommentsInfoMap,
   FileInfo,
-  ParentPatchSetNum,
+  PARENT,
   CommentInfo,
 } from '../../../types/common';
 import {
@@ -31,17 +18,14 @@
   CommentThread,
   DraftInfo,
   isUnresolved,
-  UIComment,
   createCommentThreads,
   isInPatchRange,
   isDraftThread,
-  isInBaseOfPatchRange,
-  isInRevisionOfPatchRange,
   isPatchsetLevel,
   addPath,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
-import {CommentSide, Side} from '../../../constants/constants';
+import {CommentSide} from '../../../constants/constants';
 import {pluralize} from '../../../utils/string-util';
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
@@ -49,6 +33,7 @@
   [urlEncodedCommentId: string]: CommentThread;
 };
 
+// TODO: Move file out of elements/ directory
 export class ChangeComments {
   private readonly _comments: PathToCommentsInfoMap;
 
@@ -60,10 +45,6 @@
 
   private readonly _portedDrafts: PathToCommentsInfoMap;
 
-  /**
-   * Construct a change comments object, which can be data-bound to child
-   * elements of that which uses the gr-comment-api.
-   */
   constructor(
     comments?: PathToCommentsInfoMap,
     robotComments?: {[path: string]: RobotCommentInfo[]},
@@ -82,26 +63,6 @@
     return this._drafts;
   }
 
-  findCommentById(
-    commentId?: UrlEncodedCommentId
-  ): CommentInfo | DraftInfo | undefined {
-    if (!commentId) return undefined;
-    const findComment = (comments: {
-      [path: string]: (CommentInfo | DraftInfo)[];
-    }) => {
-      let comment;
-      for (const path of Object.keys(comments)) {
-        comment = comment || comments[path].find(c => c.id === commentId);
-      }
-      return comment;
-    };
-    return (
-      findComment(this._comments) ||
-      findComment(this._robotComments) ||
-      findComment(this._drafts)
-    );
-  }
-
   /**
    * Get an object mapping file paths to a boolean representing whether that
    * path contains diff comments in the given patch set (including drafts and
@@ -114,7 +75,7 @@
    * patchNum and basePatchNum properties to represent the range.
    */
   getPaths(patchRange?: PatchRange): CommentMap {
-    const responses: {[path: string]: UIComment[]}[] = [
+    const responses: {[path: string]: Comment[]}[] = [
       this._comments,
       this.drafts,
       this._robotComments,
@@ -124,6 +85,8 @@
       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))) {
+          // TODO: Replace the CommentMap type with just an array or set. We
+          // never set the value to false.
           commentMap[path] = true;
         }
       }
@@ -139,25 +102,11 @@
   }
 
   /**
-   * Gets all the comments for a particular thread group. Used for refreshing
-   * comments after the thread group has already been built.
-   */
-  getCommentsForThread(rootId: UrlEncodedCommentId) {
-    const allThreads = this.getAllThreadsForChange();
-    const threadMatch = allThreads.find(t => t.rootId === rootId);
-
-    // In the event that a single draft comment was removed by the thread-list
-    // and the diff view is updating comments, there will no longer be a thread
-    // found.  In this case, return null.
-    return threadMatch ? threadMatch.comments : null;
-  }
-
-  /**
    * Gets all the comments and robot comments for the given change.
    */
   getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
     const paths = this.getPaths();
-    const publishedComments: {[path: string]: CommentBasics[]} = {};
+    const publishedComments: {[path: string]: CommentInfo[]} = {};
     for (const path of Object.keys(paths)) {
       publishedComments[path] = this.getAllCommentsForPath(
         path,
@@ -191,8 +140,8 @@
     path: string,
     patchNum?: PatchSetNum,
     includeDrafts?: boolean
-  ): Comment[] {
-    const comments: Comment[] = this._comments[path] || [];
+  ): CommentInfo[] {
+    const comments: CommentInfo[] = this._comments[path] || [];
     const robotComments = this._robotComments[path] || [];
     let allComments = comments.concat(robotComments);
     if (includeDrafts) {
@@ -228,43 +177,18 @@
     return allComments;
   }
 
-  cloneWithUpdatedDrafts(drafts: {[path: string]: DraftInfo[]} | 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.
    *
    * This will return a shallow copy of all drafts every time,
    * so changes on any copy will not affect other copies.
    */
-  getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
-    let comments = this._drafts[path] || [];
+  getAllDraftsForPath(path: string, patchNum?: PatchSetNum): DraftInfo[] {
+    let drafts = this._drafts[path] || [];
     if (patchNum) {
-      comments = comments.filter(c => c.patch_set === patchNum);
+      drafts = drafts.filter(c => c.patch_set === patchNum);
     }
-    return comments.map(c => {
-      return {...c, __draft: true};
-    });
+    return drafts;
   }
 
   /**
@@ -272,7 +196,7 @@
    *
    * // TODO(taoalpha): maybe merge in *ForPath
    */
-  getAllDraftsForFile(file: PatchSetFile): Comment[] {
+  getAllDraftsForFile(file: PatchSetFile): CommentInfo[] {
     let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
     if (file.basePath) {
       allDrafts = allDrafts.concat(
@@ -289,11 +213,9 @@
    *
    * @param patchRange The patch-range object containing patchNum
    * and basePatchNum properties to represent the range.
-   * @param projectConfig Optional project config object to
-   * include in the meta sub-object.
    */
-  getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
-    let comments: Comment[] = [];
+  getCommentsForPath(path: string, patchRange: PatchRange): CommentInfo[] {
+    let comments: CommentInfo[] = [];
     let drafts: DraftInfo[] = [];
     let robotComments: RobotCommentInfo[] = [];
     if (this._comments && this._comments[path]) {
@@ -306,17 +228,13 @@
       robotComments = this._robotComments[path];
     }
 
-    drafts.forEach(d => {
-      d.__draft = true;
-    });
-
-    return comments
-      .concat(drafts)
-      .concat(robotComments)
+    const all = comments.concat(drafts).concat(robotComments);
+    const final = all
       .filter(c => isInPatchRange(c, patchRange))
       .map(c => {
         return {...c};
       });
+    return final;
   }
 
   /**
@@ -367,11 +285,11 @@
     // 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);
+    const allComments: CommentInfo[] = 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
+      // the robot comment will be ported over, therefore it's possible to
       // have the root comment of the thread not be ported, hence loop over
       // entire thread
       const portedComment = portedComments.find(portedComment =>
@@ -383,34 +301,28 @@
         comment => comment.id === portedComment.id
       )!;
 
+      // Original comment shown anyway? No need to port.
+      if (isInPatchRange(originalComment, patchRange)) return false;
+
+      if (thread.commentSide === CommentSide.PARENT) {
+        // TODO(dhruvsri): Add handling for merge parents
+        if (patchRange.basePatchNum !== PARENT || !!thread.mergeParentNum)
+          return false;
+      }
+
+      if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
+
       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;
-
+      // TODO: It probably makes more sense to set the patch_set in
+      // portedComment either in the backend or in the RestApi layer. Then we
+      // could check `!isInPatchRange(portedComment, patchRange)` and then set
+      // thread.patchNum = portedComment.patch_set;
+      thread.patchNum = patchRange.patchNum;
       thread.range = portedComment.range;
       thread.line = portedComment.line;
       thread.ported = true;
@@ -423,8 +335,7 @@
     patchRange: PatchRange
   ): CommentThread[] {
     const threads = createCommentThreads(
-      this.getCommentsForFile(file, patchRange),
-      patchRange
+      this.getCommentsForFile(file, patchRange)
     );
     threads.push(...this._getPortedCommentThreads(file, patchRange));
     return threads;
@@ -439,10 +350,11 @@
    *
    * @param patchRange The patch-range object containing patchNum
    * and basePatchNum properties to represent the range.
-   * @param projectConfig Optional project config object to
-   * include in the meta sub-object.
    */
-  getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
+  getCommentsForFile(
+    file: PatchSetFile,
+    patchRange: PatchRange
+  ): CommentInfo[] {
     const comments = this.getCommentsForPath(file.path, patchRange);
     if (file.basePath) {
       comments.push(...this.getCommentsForPath(file.basePath, patchRange));
@@ -464,11 +376,11 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: Comment[] = [];
+    let comments: CommentInfo[] = [];
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
     } else {
-      comments = this._commentObjToArray(
+      comments = this._commentObjToArray<CommentInfo>(
         this.getAllPublishedComments(file.patchNum)
       );
     }
@@ -579,8 +491,8 @@
     file: PatchSetFile | PatchNumOnly,
     ignorePatchsetLevelComments?: boolean
   ) {
-    let comments: Comment[] = [];
-    let drafts: Comment[] = [];
+    let comments: CommentInfo[] = [];
+    let drafts: CommentInfo[] = [];
 
     if (isPatchSetFile(file)) {
       comments = this.getAllCommentsForFile(file);
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
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_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/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
deleted file mode 100644
index 7e01371..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ /dev/null
@@ -1,852 +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-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');
-
-suite('gr-comment-api tests', () => {
-  const PARENT = 'PARENT';
-
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('_changeComment methods', () => {
-    setup(() => {
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-    });
-
-    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(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      patchRange.basePatchNum = PARENT;
-      assert.isFalse(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.side = PARENT;
-      assert.isFalse(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.patch_set = 2;
-      assert.isTrue(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      patchRange.basePatchNum = -2;
-      comment.side = PARENT;
-      comment.parent = 1;
-      assert.isFalse(isInBaseOfPatchRange(comment,
-          patchRange));
-
-      comment.parent = 2;
-      assert.isTrue(isInBaseOfPatchRange(comment,
-          patchRange));
-    });
-
-    test('isInRevisionOfPatchRange', () => {
-      const comment = {patch_set: 123};
-      const patchRange = {basePatchNum: 122, patchNum: 124};
-      assert.isFalse(isInRevisionOfPatchRange(
-          comment, patchRange));
-
-      patchRange.patchNum = 123;
-      assert.isTrue(isInRevisionOfPatchRange(
-          comment, patchRange));
-
-      comment.side = PARENT;
-      assert.isFalse(isInRevisionOfPatchRange(
-          comment, patchRange));
-    });
-
-    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': [
-            commentObjs['12'],
-            commentObjs['13'],
-          ],
-          'file/two': [
-            commentObjs['14'],
-          ],
-        };
-        const robotComments = {
-          'file/one': [
-            commentObjs['01'], commentObjs['02'],
-          ],
-        };
-        const comments = {
-          '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, {}, {});
-      });
-
-      test('getPaths', () => {
-        const patchRange = {basePatchNum: 1, patchNum: 4};
-        let paths = element._changeComments.getPaths(patchRange);
-        assert.equal(Object.keys(paths).length, 0);
-
-        patchRange.basePatchNum = PARENT;
-        patchRange.patchNum = 3;
-        paths = element._changeComments.getPaths(patchRange);
-        assert.notProperty(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.notProperty(paths, 'file/four');
-
-        patchRange.patchNum = 2;
-        paths = element._changeComments.getPaths(patchRange);
-        assert.property(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.notProperty(paths, 'file/four');
-
-        paths = element._changeComments.getPaths();
-        assert.property(paths, 'file/one');
-        assert.property(paths, 'file/two');
-        assert.property(paths, 'file/three');
-        assert.property(paths, 'file/four');
-      });
-
-      test('getCommentsForPath', () => {
-        const patchRange = {basePatchNum: 1, patchNum: 3};
-        let path = 'file/one';
-        let comments = element._changeComments.getCommentsForPath(path,
-            patchRange);
-        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.getCommentsForPath(path,
-            patchRange);
-        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.getCommentsForPath(path,
-            patchRange);
-        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.getCommentsForPath(path,
-            patchRange);
-        assert.equal(comments.filter(c => isInBaseOfPatchRange(c, patchRange))
-            .length, 0);
-        assert.equal(comments.filter(c => isInRevisionOfPatchRange(c,
-            patchRange)).length, 1);
-      });
-
-      test('getAllCommentsForPath', () => {
-        let path = 'file/one';
-        let comments = element._changeComments.getAllCommentsForPath(path);
-        assert.equal(comments.length, 4);
-        path = 'file/two';
-        comments = element._changeComments.getAllCommentsForPath(path, 2);
-        assert.equal(comments.length, 1);
-        const aCopyOfComments = element._changeComments
-            .getAllCommentsForPath(path, 2);
-        assert.deepEqual(comments, aCopyOfComments);
-        assert.notEqual(comments[0], aCopyOfComments[0]);
-      });
-
-      test('getAllDraftsForPath', () => {
-        const path = 'file/one';
-        const drafts = element._changeComments.getAllDraftsForPath(path);
-        assert.equal(drafts.length, 2);
-        const aCopyOfDrafts = element._changeComments
-            .getAllDraftsForPath(path);
-        assert.deepEqual(drafts, aCopyOfDrafts);
-        assert.notEqual(drafts[0], aCopyOfDrafts[0]);
-      });
-
-      test('computeUnresolvedNum', () => {
-        assert.equal(element._changeComments
-            .computeUnresolvedNum({
-              patchNum: 2,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeUnresolvedNum({
-              patchNum: 1,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeUnresolvedNum({
-              patchNum: 2,
-              path: 'file/three',
-            }), 1);
-      });
-
-      test('computeUnresolvedNum w/ non-linear thread', () => {
-        const comments = {
-          path: [{
-            id: '9c6ba3c6_28b7d467',
-            patch_set: 1,
-            updated: '2018-02-28 14:41:13.000000000',
-            unresolved: true,
-          }, {
-            id: '3df7b331_0bead405',
-            patch_set: 1,
-            in_reply_to: '1c346623_ab85d14a',
-            updated: '2018-02-28 23:07:55.000000000',
-            unresolved: false,
-          }, {
-            id: '6153dce6_69958d1e',
-            patch_set: 1,
-            in_reply_to: '9c6ba3c6_28b7d467',
-            updated: '2018-02-28 17:11:31.000000000',
-            unresolved: true,
-          }, {
-            id: '1c346623_ab85d14a',
-            patch_set: 1,
-            in_reply_to: '9c6ba3c6_28b7d467',
-            updated: '2018-02-28 23:01:39.000000000',
-            unresolved: false,
-          }],
-        };
-        element._changeComments = new ChangeComments(comments, {}, {}, 1234);
-        assert.equal(
-            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({
-              patchNum: 2,
-              path: 'file/one',
-            }), 3);
-        assert.equal(element._changeComments
-            .computeCommentThreadCount({
-              patchNum: 1,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeCommentThreadCount({
-              patchNum: 2,
-              path: 'file/three',
-            }), 1);
-      });
-
-      test('computeDraftCount', () => {
-        assert.equal(element._changeComments
-            .computeDraftCount({
-              patchNum: 2,
-              path: 'file/one',
-            }), 2);
-        assert.equal(element._changeComments
-            .computeDraftCount({
-              patchNum: 1,
-              path: 'file/one',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeDraftCount({
-              patchNum: 2,
-              path: 'file/three',
-            }), 0);
-        assert.equal(element._changeComments
-            .computeDraftCount(), 3);
-      });
-
-      test('getAllPublishedComments', () => {
-        let publishedComments = element._changeComments
-            .getAllPublishedComments();
-        assert.equal(Object.keys(publishedComments).length, 4);
-        assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
-        assert.equal(Object.keys(publishedComments[['file/two']]).length, 2);
-        publishedComments = element._changeComments
-            .getAllPublishedComments(2);
-        assert.equal(Object.keys(publishedComments[['file/one']]).length, 4);
-        assert.equal(Object.keys(publishedComments[['file/two']]).length, 1);
-      });
-
-      test('getAllComments', () => {
-        let comments = element._changeComments.getAllComments();
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 4);
-        assert.equal(Object.keys(comments[['file/two']]).length, 2);
-        comments = element._changeComments.getAllComments(false, 2);
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 4);
-        assert.equal(Object.keys(comments[['file/two']]).length, 1);
-        // Include drafts
-        comments = element._changeComments.getAllComments(true);
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 6);
-        assert.equal(Object.keys(comments[['file/two']]).length, 3);
-        comments = element._changeComments.getAllComments(true, 2);
-        assert.equal(Object.keys(comments).length, 4);
-        assert.equal(Object.keys(comments[['file/one']]).length, 6);
-        assert.equal(Object.keys(comments[['file/two']]).length, 1);
-      });
-
-      test('computeAllThreads', () => {
-        const expectedThreads = [
-          {
-            ...createCommentThread([{...commentObjs['01'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['03'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['04'], path: 'file/one'},
-              {...commentObjs['02'], path: 'file/one'},
-              {...commentObjs['13'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['05'], path: 'file/two'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['06'], path: 'file/two'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['07'], path: 'file/three'},
-              {...commentObjs['08'], path: 'file/three'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['09'], path: 'file/three'}]
-            ),
-          }, {
-            ...createCommentThread([{...commentObjs['10'], path: 'file/four'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['11'], path: 'file/four'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['12'], path: 'file/one'}]),
-          }, {
-            ...createCommentThread([{...commentObjs['14'], path: 'file/two'}]),
-          },
-        ];
-        const threads = element._changeComments.getAllThreadsForChange();
-        assert.deepEqual(threads, expectedThreads);
-      });
-
-      test('getCommentsForThreadGroup', () => {
-        let expectedComments = [
-          {...commentObjs['04'], path: 'file/one'},
-          {...commentObjs['02'], path: 'file/one'},
-          {...commentObjs['13'], path: 'file/one'},
-        ];
-        assert.deepEqual(element._changeComments.getCommentsForThread('04'),
-            expectedComments);
-
-        expectedComments = [{...commentObjs['12'], path: 'file/one'}];
-
-        assert.deepEqual(element._changeComments.getCommentsForThread('12'),
-            expectedComments);
-
-        assert.deepEqual(element._changeComments.getCommentsForThread('1000'),
-            null);
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
new file mode 100644
index 0000000..b5ce12883
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
@@ -0,0 +1,1043 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {ChangeComments} from './gr-comment-api';
+import {
+  isInRevisionOfPatchRange,
+  isInBaseOfPatchRange,
+  isDraftThread,
+  isUnresolved,
+  createCommentThreads,
+  DraftInfo,
+  CommentThread,
+} from '../../../utils/comment-util';
+import {
+  createDraft,
+  createComment,
+  createChangeComments,
+  createCommentThread,
+  createFileInfo,
+  createRobotComment,
+} from '../../../test/test-data-generators';
+import {CommentSide, FileInfoStatus} from '../../../constants/constants';
+import {
+  BasePatchSetNum,
+  CommentInfo,
+  PARENT,
+  PatchRange,
+  PatchSetNum,
+  PathToCommentsInfoMap,
+  RevisionPatchSetNum,
+  RobotCommentInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {stubRestApi} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
+
+suite('ChangeComments tests', () => {
+  let changeComments: ChangeComments;
+
+  suite('_changeComment methods', () => {
+    setup(() => {
+      stubRestApi('getDiffComments').resolves({});
+      stubRestApi('getDiffRobotComments').resolves({});
+      stubRestApi('getDiffDrafts').resolves({});
+    });
+
+    suite('ported comments', () => {
+      let portedComments: PathToCommentsInfoMap;
+      const comment1: CommentInfo = {
+        ...createComment(),
+        unresolved: true,
+        id: '1' as UrlEncodedCommentId,
+        line: 136,
+        patch_set: 2 as RevisionPatchSetNum,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 1,
+        },
+      };
+
+      const comment2: CommentInfo = {
+        ...createComment(),
+        patch_set: 2 as RevisionPatchSetNum,
+        id: '2' as UrlEncodedCommentId,
+        line: 5,
+      };
+
+      const comment3: CommentInfo = {
+        ...createComment(),
+        side: CommentSide.PARENT,
+        line: 10,
+        unresolved: true,
+      };
+
+      const comment4: CommentInfo = {
+        ...comment3,
+        parent: -2,
+      };
+
+      const draft1: DraftInfo = {
+        ...createDraft(),
+        id: 'db977012_e1f13828' as UrlEncodedCommentId,
+        line: 4,
+        patch_set: 2 as RevisionPatchSetNum,
+      };
+      const draft2: DraftInfo = {
+        ...createDraft(),
+        id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+        line: 11,
+        unresolved: true,
+        // slightly larger timestamp so it's sorted higher
+        updated: '2018-02-13 22:49:48.018000001' as Timestamp,
+        patch_set: 2 as RevisionPatchSetNum,
+      };
+
+      setup(() => {
+        portedComments = {
+          'karma.conf.js': [
+            {
+              ...comment1,
+              patch_set: 4 as RevisionPatchSetNum,
+              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 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+
+        assert.equal(portedThreads.length, 1);
+        // check that the location of the thread matches the ported comment
+        assert.equal(portedThreads[0].patchNum, 4 as RevisionPatchSetNum);
+        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 as RevisionPatchSetNum,
+              basePatchNum: 1 as BasePatchSetNum,
+            }
+          ).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 as RevisionPatchSetNum, basePatchNum: PARENT}
+          ).length,
+          0
+        );
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 2 as RevisionPatchSetNum,
+              basePatchNum: 1 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+
+        // original thread attached to left side
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 3 as RevisionPatchSetNum,
+              basePatchNum: 2 as BasePatchSetNum,
+            }
+          ).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 as RevisionPatchSetNum, 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 as RevisionPatchSetNum,
+              },
+            ],
+          },
+          {} /* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+        assert.equal(portedThreads.length, 1);
+        assert.equal(portedThreads[0].line, 31);
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: -2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: 2 as BasePatchSetNum,
+            }
+          ).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 as RevisionPatchSetNum,
+              },
+            ],
+          },
+          {} /* ported drafts */
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, basePatchNum: PARENT}
+        );
+        assert.equal(portedThreads.length, 0);
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: -2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+
+        assert.equal(
+          changeComments._getPortedCommentThreads(
+            {path: 'karma.conf.js'},
+            {
+              patchNum: 4 as RevisionPatchSetNum,
+              basePatchNum: 2 as BasePatchSetNum,
+            }
+          ).length,
+          0
+        );
+      });
+
+      test('ported comments contribute to comment count', () => {
+        const fileInfo = createFileInfo();
+        assert.equal(
+          changeComments.computeCommentsString(
+            {basePatchNum: PARENT, patchNum: 2 as RevisionPatchSetNum},
+            'karma.conf.js',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+
+        // comment1 is ported over to patchset 4
+        assert.equal(
+          changeComments.computeCommentsString(
+            {basePatchNum: PARENT, patchNum: 4 as RevisionPatchSetNum},
+            'karma.conf.js',
+            fileInfo
+          ),
+          '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 as RevisionPatchSetNum,
+              },
+              {
+                ...draft2,
+                line: 31,
+                patch_set: 4 as RevisionPatchSetNum,
+              },
+            ],
+          }
+        );
+
+        const portedThreads = changeComments._getPortedCommentThreads(
+          {path: 'karma.conf.js'},
+          {patchNum: 4 as RevisionPatchSetNum, 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')
+          ).length,
+          0
+        );
+      });
+    });
+
+    test('_isInBaseOfPatchRange', () => {
+      const comment: {
+        patch_set?: PatchSetNum;
+        side?: CommentSide;
+        parent?: number;
+      } = {patch_set: 1 as PatchSetNum};
+      const patchRange = {
+        basePatchNum: 1 as BasePatchSetNum,
+        patchNum: 2 as RevisionPatchSetNum,
+      };
+      assert.isTrue(isInBaseOfPatchRange(comment, patchRange));
+
+      patchRange.basePatchNum = PARENT;
+      assert.isFalse(isInBaseOfPatchRange(comment, patchRange));
+
+      comment.side = CommentSide.PARENT;
+      assert.isFalse(isInBaseOfPatchRange(comment, patchRange));
+
+      comment.patch_set = 2 as PatchSetNum;
+      assert.isTrue(isInBaseOfPatchRange(comment, patchRange));
+
+      patchRange.basePatchNum = -2 as BasePatchSetNum;
+      comment.side = CommentSide.PARENT;
+      comment.parent = 1;
+      assert.isFalse(isInBaseOfPatchRange(comment, patchRange));
+
+      comment.parent = 2;
+      assert.isTrue(isInBaseOfPatchRange(comment, patchRange));
+    });
+
+    test('isInRevisionOfPatchRange', () => {
+      const comment: {
+        patch_set?: PatchSetNum;
+        side?: CommentSide;
+      } = {patch_set: 123 as PatchSetNum};
+      const patchRange: PatchRange = {
+        basePatchNum: 122 as BasePatchSetNum,
+        patchNum: 124 as RevisionPatchSetNum,
+      };
+      assert.isFalse(isInRevisionOfPatchRange(comment, patchRange));
+
+      patchRange.patchNum = 123 as RevisionPatchSetNum;
+      assert.isTrue(isInRevisionOfPatchRange(comment, patchRange));
+
+      comment.side = CommentSide.PARENT;
+      assert.isFalse(isInRevisionOfPatchRange(comment, patchRange));
+    });
+
+    suite('comment ranges and paths', () => {
+      const comments = [
+        {
+          ...createRobotComment(),
+          id: '01' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          side: CommentSide.PARENT,
+          line: 1,
+          updated: makeTime(1),
+          range: {
+            start_line: 1,
+            start_character: 2,
+            end_line: 2,
+            end_character: 2,
+          },
+        },
+        {
+          ...createRobotComment(),
+          id: '02' as UrlEncodedCommentId,
+          in_reply_to: '04' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          unresolved: true,
+          line: 1,
+          updated: makeTime(3),
+        },
+        {
+          ...createComment(),
+          id: '03' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          side: CommentSide.PARENT,
+          line: 2,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '04' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          path: 'file/one',
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '05' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          line: 2,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '06' as UrlEncodedCommentId,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 2,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '07' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          unresolved: false,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '08' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          unresolved: true,
+          in_reply_to: '07' as UrlEncodedCommentId,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '09' as UrlEncodedCommentId,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '10' as UrlEncodedCommentId,
+          patch_set: 5 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createComment(),
+          id: '11' as UrlEncodedCommentId,
+          patch_set: 5 as RevisionPatchSetNum,
+          line: 1,
+          updated: makeTime(1),
+        },
+        {
+          ...createDraft(),
+          id: '12' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          side: CommentSide.PARENT,
+          line: 1,
+          updated: makeTime(3),
+          path: 'file/one',
+        },
+        {
+          ...createDraft(),
+          id: '13' as UrlEncodedCommentId,
+          in_reply_to: '04' as UrlEncodedCommentId,
+          patch_set: 2 as RevisionPatchSetNum,
+          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),
+          path: 'file/one',
+        },
+        {
+          ...createDraft(),
+          id: '14' as UrlEncodedCommentId,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 1,
+          path: 'file/two',
+          updated: makeTime(3),
+        },
+      ] as const;
+      const drafts: {[path: string]: DraftInfo[]} = {
+        'file/one': [comments[11], comments[12]],
+        'file/two': [comments[13]],
+      };
+      const robotComments: {[path: string]: RobotCommentInfo[]} = {
+        'file/one': [comments[0], comments[1]],
+      };
+      const commentsByFile: PathToCommentsInfoMap = {
+        'file/one': [comments[2], comments[3]],
+        'file/two': [comments[4], comments[5]],
+        'file/three': [comments[6], comments[7], comments[8]],
+        'file/four': [comments[9], comments[10]],
+      };
+
+      function makeTime(mins: number) {
+        return `2013-02-26 15:0${mins}:43.986000000` as Timestamp;
+      }
+
+      setup(() => {
+        changeComments = new ChangeComments(
+          commentsByFile,
+          robotComments,
+          drafts,
+          {} /* portedComments */,
+          {} /* portedDrafts */
+        );
+      });
+
+      test('getPaths', () => {
+        const patchRange: PatchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 4 as RevisionPatchSetNum,
+        };
+        let paths = changeComments.getPaths(patchRange);
+        assert.equal(Object.keys(paths).length, 0);
+
+        patchRange.basePatchNum = PARENT;
+        patchRange.patchNum = 3 as RevisionPatchSetNum;
+        paths = changeComments.getPaths(patchRange);
+        assert.notProperty(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.notProperty(paths, 'file/four');
+
+        patchRange.patchNum = 2 as RevisionPatchSetNum;
+        paths = changeComments.getPaths(patchRange);
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.notProperty(paths, 'file/four');
+
+        paths = changeComments.getPaths();
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+        assert.property(paths, 'file/four');
+      });
+
+      test('getCommentsForPath', () => {
+        const patchRange: PatchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 3 as RevisionPatchSetNum,
+        };
+        let path = 'file/one';
+        let comments = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          0
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          0
+        );
+
+        path = 'file/two';
+        comments = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          0
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          2
+        );
+
+        patchRange.basePatchNum = 2 as BasePatchSetNum;
+        comments = changeComments.getCommentsForPath(path, patchRange);
+        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 = changeComments.getCommentsForPath(path, patchRange);
+        assert.equal(
+          comments.filter(c => isInBaseOfPatchRange(c, patchRange)).length,
+          0
+        );
+        assert.equal(
+          comments.filter(c => isInRevisionOfPatchRange(c, patchRange)).length,
+          1
+        );
+      });
+
+      test('getAllCommentsForPath', () => {
+        let path = 'file/one';
+        let comments = changeComments.getAllCommentsForPath(path);
+        assert.equal(comments.length, 4);
+        path = 'file/two';
+        comments = changeComments.getAllCommentsForPath(path, 2 as PatchSetNum);
+        assert.equal(comments.length, 1);
+        const aCopyOfComments = changeComments.getAllCommentsForPath(
+          path,
+          2 as PatchSetNum
+        );
+        assert.deepEqual(comments, aCopyOfComments);
+        assert.notEqual(comments[0], aCopyOfComments[0]);
+      });
+
+      test('getAllDraftsForPath', () => {
+        const path = 'file/one';
+        const drafts = changeComments.getAllDraftsForPath(path);
+        assert.equal(drafts.length, 2);
+      });
+
+      test('computeUnresolvedNum', () => {
+        assert.equal(
+          changeComments.computeUnresolvedNum({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeUnresolvedNum({
+            patchNum: 1 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeUnresolvedNum({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/three',
+          }),
+          1
+        );
+      });
+
+      test('computeUnresolvedNum w/ non-linear thread', () => {
+        const comments: PathToCommentsInfoMap = {
+          path: [
+            {
+              id: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              updated: '2018-02-28 14:41:13.000000000' as Timestamp,
+              unresolved: true,
+            },
+            {
+              id: '3df7b331_0bead405' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              in_reply_to: '1c346623_ab85d14a' as UrlEncodedCommentId,
+              updated: '2018-02-28 23:07:55.000000000' as Timestamp,
+              unresolved: false,
+            },
+            {
+              id: '6153dce6_69958d1e' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              in_reply_to: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
+              updated: '2018-02-28 17:11:31.000000000' as Timestamp,
+              unresolved: true,
+            },
+            {
+              id: '1c346623_ab85d14a' as UrlEncodedCommentId,
+              patch_set: 1 as RevisionPatchSetNum,
+              in_reply_to: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
+              updated: '2018-02-28 23:01:39.000000000' as Timestamp,
+              unresolved: false,
+            },
+          ],
+        };
+        changeComments = new ChangeComments(comments, {}, {}, {});
+        assert.equal(
+          changeComments.computeUnresolvedNum(
+            {patchNum: 1 as PatchSetNum},
+            true
+          ),
+          0
+        );
+      });
+
+      test('computeCommentsString', () => {
+        const changeComments = createChangeComments();
+        const parentTo1: PatchRange = {
+          basePatchNum: PARENT,
+          patchNum: 1 as RevisionPatchSetNum,
+        };
+        const parentTo2: PatchRange = {
+          basePatchNum: PARENT,
+          patchNum: 2 as RevisionPatchSetNum,
+        };
+        const _1To2: PatchRange = {
+          basePatchNum: 1 as BasePatchSetNum,
+          patchNum: 2 as RevisionPatchSetNum,
+        };
+        const fileInfo = createFileInfo();
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            '/COMMIT_MSG',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            '/COMMIT_MSG',
+            {...fileInfo, status: FileInfoStatus.UNMODIFIED},
+            true
+          ),
+          '2 comments (1 unresolved)(no changes)'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, '/COMMIT_MSG', fileInfo),
+          '3 comments (1 unresolved)'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            'myfile.txt',
+            fileInfo
+          ),
+          '1 comment'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, 'myfile.txt', fileInfo),
+          '3 comments'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo1,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            _1To2,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            '/COMMIT_MSG',
+            fileInfo
+          ),
+
+          '1 comment'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, '/COMMIT_MSG', fileInfo),
+          '3 comments (1 unresolved)'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            'myfile.txt',
+            fileInfo
+          ),
+          '2 comments'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(_1To2, 'myfile.txt', fileInfo),
+          '3 comments'
+        );
+
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            _1To2,
+            'file_added_in_rev2.txt',
+            fileInfo
+          ),
+          ''
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            parentTo2,
+            'unresolved.file',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+        assert.equal(
+          changeComments.computeCommentsString(
+            _1To2,
+            'unresolved.file',
+            fileInfo
+          ),
+          '2 comments (1 unresolved)'
+        );
+      });
+
+      test('computeCommentThreadCount', () => {
+        assert.equal(
+          changeComments.computeCommentThreadCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/one',
+          }),
+          3
+        );
+        assert.equal(
+          changeComments.computeCommentThreadCount({
+            patchNum: 1 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeCommentThreadCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/three',
+          }),
+          1
+        );
+      });
+
+      test('computeDraftCount', () => {
+        assert.equal(
+          changeComments.computeDraftCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/one',
+          }),
+          2
+        );
+        assert.equal(
+          changeComments.computeDraftCount({
+            patchNum: 1 as PatchSetNum,
+            path: 'file/one',
+          }),
+          0
+        );
+        assert.equal(
+          changeComments.computeDraftCount({
+            patchNum: 2 as PatchSetNum,
+            path: 'file/three',
+          }),
+          0
+        );
+        assert.equal(changeComments.computeDraftCount(), 3);
+      });
+
+      test('getAllPublishedComments', () => {
+        let publishedComments = changeComments.getAllPublishedComments();
+        assert.equal(Object.keys(publishedComments).length, 4);
+        assert.equal(Object.keys(publishedComments['file/one']).length, 4);
+        assert.equal(Object.keys(publishedComments['file/two']).length, 2);
+        publishedComments = changeComments.getAllPublishedComments(
+          2 as PatchSetNum
+        );
+        assert.equal(Object.keys(publishedComments['file/one']).length, 4);
+        assert.equal(Object.keys(publishedComments['file/two']).length, 1);
+      });
+
+      test('getAllComments', () => {
+        let comments = changeComments.getAllComments();
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 4);
+        assert.equal(Object.keys(comments['file/two']).length, 2);
+        comments = changeComments.getAllComments(false, 2 as PatchSetNum);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 4);
+        assert.equal(Object.keys(comments['file/two']).length, 1);
+        // Include drafts
+        comments = changeComments.getAllComments(true);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 6);
+        assert.equal(Object.keys(comments['file/two']).length, 3);
+        comments = changeComments.getAllComments(true, 2 as PatchSetNum);
+        assert.equal(Object.keys(comments).length, 4);
+        assert.equal(Object.keys(comments['file/one']).length, 6);
+        assert.equal(Object.keys(comments['file/two']).length, 1);
+      });
+
+      test('computeAllThreads', () => {
+        const expectedThreads: CommentThread[] = [
+          {
+            ...createCommentThread([{...comments[0], path: 'file/one'}]),
+          },
+          {
+            ...createCommentThread([{...comments[2], path: 'file/one'}]),
+          },
+          {
+            ...createCommentThread([
+              {...comments[3], path: 'file/one'},
+              {...comments[1], path: 'file/one'},
+              {...comments[12], path: 'file/one'},
+            ]),
+          },
+          {
+            ...createCommentThread([{...comments[4], path: 'file/two'}]),
+          },
+          {
+            ...createCommentThread([{...comments[5], path: 'file/two'}]),
+          },
+          {
+            ...createCommentThread([
+              {...comments[6], path: 'file/three'},
+              {...comments[7], path: 'file/three'},
+            ]),
+          },
+          {
+            ...createCommentThread([{...comments[8], path: 'file/three'}]),
+          },
+          {
+            ...createCommentThread([{...comments[9], path: 'file/four'}]),
+          },
+          {
+            ...createCommentThread([{...comments[10], path: 'file/four'}]),
+          },
+          {
+            ...createCommentThread([{...comments[11], path: 'file/one'}]),
+          },
+          {
+            ...createCommentThread([{...comments[13], path: 'file/two'}]),
+          },
+        ];
+        const threads = changeComments.getAllThreadsForChange();
+        assert.deepEqual(threads, expectedThreads);
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
deleted file mode 100644
index af9c9d4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
+++ /dev/null
@@ -1,545 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@polymer/paper-button/paper-button';
-import '@polymer/paper-card/paper-card';
-import '@polymer/paper-checkbox/paper-checkbox';
-import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
-import '@polymer/paper-fab/paper-fab';
-import '@polymer/paper-icon-button/paper-icon-button';
-import '@polymer/paper-item/paper-item';
-import '@polymer/paper-listbox/paper-listbox';
-import '@polymer/paper-tooltip/paper-tooltip';
-import {of, EMPTY, Subject} from 'rxjs';
-import {switchMap, delay, takeUntil} from 'rxjs/operators';
-
-import '../../shared/gr-button/gr-button';
-import {pluralize} from '../../../utils/string-util';
-import {fire} from '../../../utils/event-util';
-import {DiffInfo} from '../../../types/diff';
-import {assertIsDefined} from '../../../utils/common-util';
-import {css, html, LitElement, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators';
-
-import {
-  ContextButtonType,
-  DiffContextButtonHoveredDetail,
-  RenderPreferences,
-  SyntaxBlock,
-} from '../../../api/diff';
-
-import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
-
-declare global {
-  interface HTMLElementEventMap {
-    'diff-context-button-hovered': CustomEvent<DiffContextButtonHoveredDetail>;
-  }
-}
-
-const PARTIAL_CONTEXT_AMOUNT = 10;
-
-/**
- * Traverses a hierarchical structure of syntax blocks and
- * finds the most local/nested block that can be associated line.
- * It finds the closest block that contains the whole line and
- * returns the whole path from the syntax layer (blocks) sent as parameter
- * to the most nested block - the complete path from the top to bottom layer of
- * a syntax tree. Example: [myNamepace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
- *
- * @param lineNum line number for the targeted line.
- * @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
- */
-function findBlockTreePathForLine(
-  lineNum: number,
-  blocks?: SyntaxBlock[]
-): SyntaxBlock[] {
-  const containingBlock = blocks?.find(
-    ({range}) => range.start_line < lineNum && range.end_line > lineNum
-  );
-  if (!containingBlock) return [];
-  const innerPathInChild = findBlockTreePathForLine(
-    lineNum,
-    containingBlock?.children
-  );
-  return [containingBlock].concat(innerPathInChild);
-}
-
-export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
-
-@customElement('gr-context-controls')
-export class GrContextControls extends LitElement {
-  @property({type: Object}) renderPreferences?: RenderPreferences;
-
-  @property({type: Object}) diff?: DiffInfo;
-
-  @property({type: Object}) section?: HTMLElement;
-
-  @property({type: Object}) contextGroups: GrDiffGroup[] = [];
-
-  @property({type: String, reflect: true})
-  showConfig: GrContextControlsShowConfig = 'both';
-
-  private expandButtonsHover = new Subject<{
-    eventType: 'enter' | 'leave';
-    buttonType: ContextButtonType;
-    linesToExpand: number;
-  }>();
-
-  private disconnected$ = new Subject();
-
-  static override styles = css`
-    :host {
-      display: flex;
-      justify-content: center;
-      flex-direction: column;
-      position: relative;
-    }
-
-    :host([showConfig='above']) {
-      justify-content: flex-end;
-      margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
-      margin-bottom: var(--gr-context-controls-margin-bottom);
-      height: calc(var(--line-height-normal) + var(--spacing-s));
-      .horizontalFlex {
-        align-items: end;
-      }
-    }
-
-    :host([showConfig='below']) {
-      justify-content: flex-start;
-      margin-top: 1px;
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      .horizontalFlex {
-        align-items: start;
-      }
-    }
-
-    :host([showConfig='both']) {
-      margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      height: calc(
-        2 * var(--line-height-normal) + 2 * var(--spacing-s) +
-          var(--divider-height)
-      );
-      .horizontalFlex {
-        align-items: center;
-      }
-    }
-
-    .contextControlButton {
-      background-color: var(--default-button-background-color);
-      font: var(--context-control-button-font, inherit);
-    }
-
-    paper-button {
-      text-transform: none;
-      align-items: center;
-      background-color: var(--background-color);
-      font-family: inherit;
-      margin: var(--margin, 0);
-      min-width: var(--border, 0);
-      color: var(--diff-context-control-color);
-      border: solid var(--border-color);
-      border-width: 1px;
-      border-radius: var(--border-radius);
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-
-    paper-button:hover {
-      /* same as defined in gr-button */
-      background: rgba(0, 0, 0, 0.12);
-    }
-
-    .aboveBelowButtons {
-      display: flex;
-      flex-direction: column;
-      justify-content: center;
-      margin-left: var(--spacing-m);
-      position: relative;
-    }
-    .aboveBelowButtons:first-child {
-      margin-left: 0;
-      /* Places a default background layer behind the "all button" that can have opacity */
-      background-color: var(--default-button-background-color);
-    }
-
-    .horizontalFlex {
-      display: flex;
-      justify-content: center;
-      align-items: var(--gr-context-controls-horizontal-align-items, center);
-    }
-
-    .aboveButton {
-      border-bottom-width: 0;
-      border-bottom-right-radius: 0;
-      border-bottom-left-radius: 0;
-      padding: var(--spacing-xxs) var(--spacing-l);
-    }
-    .belowButton {
-      border-top-width: 0;
-      border-top-left-radius: 0;
-      border-top-right-radius: 0;
-      padding: var(--spacing-xxs) var(--spacing-l);
-      margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
-    }
-    .belowButton:first-child {
-      margin-top: 0;
-    }
-    .breadcrumbTooltip {
-      white-space: nowrap;
-    }
-  `;
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.setupButtonHoverHandler();
-  }
-
-  override disconnectedCallback() {
-    this.disconnected$.next();
-  }
-
-  private showBoth() {
-    return this.showConfig === 'both';
-  }
-
-  private showAbove() {
-    return this.showBoth() || this.showConfig === 'above';
-  }
-
-  private showBelow() {
-    return this.showBoth() || this.showConfig === 'below';
-  }
-
-  setupButtonHoverHandler() {
-    this.expandButtonsHover
-      .pipe(
-        switchMap(e => {
-          if (e.eventType === 'leave') {
-            // cancel any previous delay
-            // for mouse enter
-            return EMPTY;
-          }
-          return of(e).pipe(delay(500));
-        }),
-        takeUntil(this.disconnected$)
-      )
-      .subscribe(({buttonType, linesToExpand}) => {
-        fire(this, 'diff-context-button-hovered', {
-          buttonType,
-          linesToExpand,
-        });
-      });
-  }
-
-  private numLines() {
-    const {leftStart, leftEnd} = this.contextRange();
-    return leftEnd - leftStart + 1;
-  }
-
-  private createExpandAllButtonContainer() {
-    return html` <div
-      class="style-scope gr-diff aboveBelowButtons fullExpansion"
-    >
-      ${this.createContextButton(ContextButtonType.ALL, this.numLines())}
-    </div>`;
-  }
-
-  /**
-   * Creates a specific expansion button (e.g. +X common lines, +10, +Block).
-   */
-  private createContextButton(
-    type: ContextButtonType,
-    linesToExpand: number,
-    tooltip?: TemplateResult
-  ) {
-    let text = '';
-    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
-    let ariaLabel = '';
-    let classes = 'contextControlButton showContext ';
-
-    if (type === ContextButtonType.ALL) {
-      text = `+${pluralize(linesToExpand, 'common line')}`;
-      ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
-      classes += this.showBoth()
-        ? 'centeredButton'
-        : this.showAbove()
-        ? 'aboveButton'
-        : 'belowButton';
-      if (this.partialContent) {
-        // Expanding content would require load of more data
-        text += ' (too large)';
-      }
-      groups.push(...this.contextGroups);
-    } else if (type === ContextButtonType.ABOVE) {
-      groups = hideInContextControl(
-        this.contextGroups,
-        linesToExpand,
-        this.numLines()
-      );
-      text = `+${linesToExpand}`;
-      classes += 'aboveButton';
-      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} above`;
-    } else if (type === ContextButtonType.BELOW) {
-      groups = hideInContextControl(
-        this.contextGroups,
-        0,
-        this.numLines() - linesToExpand
-      );
-      text = `+${linesToExpand}`;
-      classes += 'belowButton';
-      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} below`;
-    } else if (type === ContextButtonType.BLOCK_ABOVE) {
-      groups = hideInContextControl(
-        this.contextGroups,
-        linesToExpand,
-        this.numLines()
-      );
-      text = '+Block';
-      classes += 'aboveButton';
-      ariaLabel = 'Show block above';
-    } else if (type === ContextButtonType.BLOCK_BELOW) {
-      groups = hideInContextControl(
-        this.contextGroups,
-        0,
-        this.numLines() - linesToExpand
-      );
-      text = '+Block';
-      classes += 'belowButton';
-      ariaLabel = 'Show block below';
-    }
-    const expandHandler = this.createExpansionHandler(
-      linesToExpand,
-      type,
-      groups
-    );
-
-    const mouseHandler = (eventType: 'enter' | 'leave') => {
-      this.expandButtonsHover.next({
-        eventType,
-        buttonType: type,
-        linesToExpand,
-      });
-    };
-
-    const button = html` <paper-button
-      class="${classes}"
-      aria-label="${ariaLabel}"
-      @click="${expandHandler}"
-      @mouseenter="${() => mouseHandler('enter')}"
-      @mouseleave="${() => mouseHandler('leave')}"
-    >
-      <span class="showContext">${text}</span>
-      ${tooltip}
-    </paper-button>`;
-    return button;
-  }
-
-  private createExpansionHandler(
-    linesToExpand: number,
-    type: ContextButtonType,
-    groups: GrDiffGroup[]
-  ) {
-    return (e: Event) => {
-      e.stopPropagation();
-      if (type === ContextButtonType.ALL && this.partialContent) {
-        const {leftStart, leftEnd, rightStart, rightEnd} = this.contextRange();
-        const lineRange = {
-          left: {
-            start_line: leftStart,
-            end_line: leftEnd,
-          },
-          right: {
-            start_line: rightStart,
-            end_line: rightEnd,
-          },
-        };
-        fire(this, 'content-load-needed', {
-          lineRange,
-        });
-      } else {
-        assertIsDefined(this.section, 'section');
-        fire(this, 'diff-context-expanded', {
-          groups,
-          section: this.section!,
-          numLines: this.numLines(),
-          buttonType: type,
-          expandedLines: linesToExpand,
-        });
-      }
-    };
-  }
-
-  private showPartialLinks() {
-    return this.numLines() > PARTIAL_CONTEXT_AMOUNT;
-  }
-
-  /**
-   * Creates a container div with partial (+10) expansion buttons (above and/or below).
-   */
-  private createPartialExpansionButtons() {
-    if (!this.showPartialLinks()) {
-      return undefined;
-    }
-    let aboveButton;
-    let belowButton;
-    if (this.showAbove()) {
-      aboveButton = this.createContextButton(
-        ContextButtonType.ABOVE,
-        PARTIAL_CONTEXT_AMOUNT
-      );
-    }
-    if (this.showBelow()) {
-      belowButton = this.createContextButton(
-        ContextButtonType.BELOW,
-        PARTIAL_CONTEXT_AMOUNT
-      );
-    }
-    return aboveButton || belowButton
-      ? html` <div class="aboveBelowButtons partialExpansion">
-          ${aboveButton} ${belowButton}
-        </div>`
-      : undefined;
-  }
-
-  /**
-   * Checks if the collapsed section contains unavailable content (skip chunks).
-   */
-  private get partialContent() {
-    return this.contextGroups.some(c => !!c.skip);
-  }
-
-  /**
-   * Creates a container div with block expansion buttons (above and/or below).
-   */
-  private createBlockExpansionButtons() {
-    if (
-      !this.showPartialLinks() ||
-      !this.renderPreferences?.use_block_expansion ||
-      this.partialContent
-    ) {
-      return undefined;
-    }
-    let aboveBlockButton;
-    let belowBlockButton;
-    if (this.showAbove()) {
-      aboveBlockButton = this.createBlockButton(
-        ContextButtonType.BLOCK_ABOVE,
-        this.numLines(),
-        this.contextRange().rightStart - 1
-      );
-    }
-    if (this.showBelow()) {
-      belowBlockButton = this.createBlockButton(
-        ContextButtonType.BLOCK_BELOW,
-        this.numLines(),
-        this.contextRange().rightEnd + 1
-      );
-    }
-    if (aboveBlockButton || belowBlockButton) {
-      return html` <div class="aboveBelowButtons blockExpansion">
-        ${aboveBlockButton} ${belowBlockButton}
-      </div>`;
-    }
-    return undefined;
-  }
-
-  private createBlockButtonTooltip(
-    buttonType: ContextButtonType,
-    syntaxPath: SyntaxBlock[],
-    linesToExpand: number
-  ) {
-    // Create breadcrumb string:
-    // myNamepace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
-    const tooltipText = syntaxPath.length
-      ? syntaxPath.map(b => b.name || '(anonymous)').join(' > ')
-      : `${linesToExpand} common lines`;
-
-    const position =
-      buttonType === ContextButtonType.BLOCK_ABOVE ? 'top' : 'bottom';
-    return html`<paper-tooltip offset="10" position="${position}"
-      ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
-    >`;
-  }
-
-  private createBlockButton(
-    buttonType: ContextButtonType,
-    numLines: number,
-    referenceLine: number
-  ) {
-    assertIsDefined(this.diff, 'diff');
-    const syntaxTree = this.diff!.meta_b.syntax_tree;
-    const outlineSyntaxPath = findBlockTreePathForLine(
-      referenceLine,
-      syntaxTree
-    );
-    let linesToExpand = numLines;
-    if (outlineSyntaxPath.length) {
-      const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
-      const targetLine =
-        buttonType === ContextButtonType.BLOCK_ABOVE
-          ? range.end_line
-          : range.start_line;
-      const distanceToTargetLine = Math.abs(targetLine - referenceLine);
-      if (distanceToTargetLine < numLines) {
-        linesToExpand = distanceToTargetLine;
-      }
-    }
-    const tooltip = this.createBlockButtonTooltip(
-      buttonType,
-      outlineSyntaxPath,
-      linesToExpand
-    );
-    return this.createContextButton(buttonType, linesToExpand, tooltip);
-  }
-
-  private contextRange() {
-    return {
-      leftStart: this.contextGroups[0].lineRange.left.start_line,
-      leftEnd:
-        this.contextGroups[this.contextGroups.length - 1].lineRange.left
-          .end_line,
-      rightStart: this.contextGroups[0].lineRange.right.start_line,
-      rightEnd:
-        this.contextGroups[this.contextGroups.length - 1].lineRange.right
-          .end_line,
-    };
-  }
-
-  private hasValidProperties() {
-    return !!(this.diff && this.section && this.contextGroups?.length);
-  }
-
-  override render() {
-    if (!this.hasValidProperties()) {
-      console.error('Invalid properties for gr-context-controls!');
-      return html`<p>invalid properties</p>`;
-    }
-    return html`
-      <div class="horizontalFlex">
-        ${this.createExpandAllButtonContainer()}
-        ${this.createPartialExpansionButtons()}
-        ${this.createBlockExpansionButtons()}
-      </div>
-    `;
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-context-controls': GrContextControls;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
deleted file mode 100644
index cacea42..0000000
--- a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
+++ /dev/null
@@ -1,378 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma';
-import '../gr-diff/gr-diff-group';
-import './gr-context-controls';
-import {GrContextControls} from './gr-context-controls';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
-
-const blankFixture = fixtureFromElement('div');
-
-suite('gr-context-control tests', () => {
-  let element: GrContextControls;
-
-  setup(async () => {
-    element = document.createElement('gr-context-controls');
-    element.diff = {content: []} as any as DiffInfo;
-    element.renderPreferences = {};
-    element.section = document.createElement('div');
-    blankFixture.instantiate().appendChild(element);
-    await flush();
-  });
-
-  function createContextGroups(options: {offset?: number; count?: number}) {
-    const offset = options.offset || 0;
-    const numLines = options.count || 10;
-    const lines = [];
-    for (let i = 0; i < numLines; i++) {
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = offset + i + 1;
-      line.afterNumber = offset + i + 1;
-      line.text = 'lorem upsum';
-      lines.push(line);
-    }
-
-    return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
-  }
-
-  test('no +10 buttons for 10 or less lines', async () => {
-    element.contextGroups = createContextGroups({count: 10});
-
-    await flush();
-
-    const buttons = element.shadowRoot!.querySelectorAll(
-      'paper-button.showContext'
-    );
-    assert.equal(buttons.length, 1);
-    assert.equal(buttons[0].textContent!.trim(), '+10 common lines');
-  });
-
-  test('context control at the top', async () => {
-    element.contextGroups = createContextGroups({offset: 0, count: 20});
-    element.showConfig = 'below';
-
-    await flush();
-
-    const buttons = element.shadowRoot!.querySelectorAll(
-      'paper-button.showContext'
-    );
-
-    assert.equal(buttons.length, 2);
-    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
-    assert.equal(buttons[1].textContent!.trim(), '+10');
-
-    assert.include([...buttons[0].classList.values()], 'belowButton');
-    assert.include([...buttons[1].classList.values()], 'belowButton');
-  });
-
-  test('context control in the middle', async () => {
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
-    element.showConfig = 'both';
-
-    await flush();
-
-    const buttons = element.shadowRoot!.querySelectorAll(
-      'paper-button.showContext'
-    );
-
-    assert.equal(buttons.length, 3);
-    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
-    assert.equal(buttons[1].textContent!.trim(), '+10');
-    assert.equal(buttons[2].textContent!.trim(), '+10');
-
-    assert.include([...buttons[0].classList.values()], 'centeredButton');
-    assert.include([...buttons[1].classList.values()], 'aboveButton');
-    assert.include([...buttons[2].classList.values()], 'belowButton');
-  });
-
-  test('context control at the bottom', async () => {
-    element.contextGroups = createContextGroups({offset: 30, count: 20});
-    element.showConfig = 'above';
-
-    await flush();
-
-    const buttons = element.shadowRoot!.querySelectorAll(
-      'paper-button.showContext'
-    );
-
-    assert.equal(buttons.length, 2);
-    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
-    assert.equal(buttons[1].textContent!.trim(), '+10');
-
-    assert.include([...buttons[0].classList.values()], 'aboveButton');
-    assert.include([...buttons[1].classList.values()], 'aboveButton');
-  });
-
-  function prepareForBlockExpansion(syntaxTree: SyntaxBlock[]) {
-    element.renderPreferences!.use_block_expansion = true;
-    element.diff!.meta_b = {
-      syntax_tree: syntaxTree,
-    } as any as DiffFileMetaInfo;
-  }
-
-  test('context control with block expansion at the top', async () => {
-    prepareForBlockExpansion([]);
-    element.contextGroups = createContextGroups({offset: 0, count: 20});
-    element.showConfig = 'below';
-
-    await flush();
-
-    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.fullExpansion paper-button'
-    );
-    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.partialExpansion paper-button'
-    );
-    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.blockExpansion paper-button'
-    );
-    assert.equal(fullExpansionButtons.length, 1);
-    assert.equal(partialExpansionButtons.length, 1);
-    assert.equal(blockExpansionButtons.length, 1);
-    assert.equal(
-      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
-      '+Block'
-    );
-    assert.include(
-      [...blockExpansionButtons[0].classList.values()],
-      'belowButton'
-    );
-  });
-
-  test('context control with block expansion in the middle', async () => {
-    prepareForBlockExpansion([]);
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
-    element.showConfig = 'both';
-
-    await flush();
-
-    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.fullExpansion paper-button'
-    );
-    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.partialExpansion paper-button'
-    );
-    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.blockExpansion paper-button'
-    );
-    assert.equal(fullExpansionButtons.length, 1);
-    assert.equal(partialExpansionButtons.length, 2);
-    assert.equal(blockExpansionButtons.length, 2);
-    assert.equal(
-      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
-      '+Block'
-    );
-    assert.equal(
-      blockExpansionButtons[1].querySelector('span')!.textContent!.trim(),
-      '+Block'
-    );
-    assert.include(
-      [...blockExpansionButtons[0].classList.values()],
-      'aboveButton'
-    );
-    assert.include(
-      [...blockExpansionButtons[1].classList.values()],
-      'belowButton'
-    );
-  });
-
-  test('context control with block expansion at the bottom', async () => {
-    prepareForBlockExpansion([]);
-    element.contextGroups = createContextGroups({offset: 30, count: 20});
-    element.showConfig = 'above';
-
-    await flush();
-
-    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.fullExpansion paper-button'
-    );
-    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.partialExpansion paper-button'
-    );
-    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.blockExpansion paper-button'
-    );
-    assert.equal(fullExpansionButtons.length, 1);
-    assert.equal(partialExpansionButtons.length, 1);
-    assert.equal(blockExpansionButtons.length, 1);
-    assert.equal(
-      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
-      '+Block'
-    );
-    assert.include(
-      [...blockExpansionButtons[0].classList.values()],
-      'aboveButton'
-    );
-  });
-
-  test('+ Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
-    prepareForBlockExpansion([
-      {
-        name: 'aSpecificFunction',
-        range: {start_line: 1, start_column: 0, end_line: 25, end_column: 0},
-        children: [],
-      },
-      {
-        name: 'anotherFunction',
-        range: {start_line: 26, start_column: 0, end_line: 50, end_column: 0},
-        children: [],
-      },
-    ]);
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
-    element.showConfig = 'both';
-
-    await flush();
-
-    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.blockExpansion paper-button'
-    );
-    assert.equal(
-      blockExpansionButtons[0]
-        .querySelector('.breadcrumbTooltip')!
-        .textContent?.trim(),
-      'aSpecificFunction'
-    );
-    assert.equal(
-      blockExpansionButtons[1]
-        .querySelector('.breadcrumbTooltip')!
-        .textContent?.trim(),
-      'anotherFunction'
-    );
-  });
-
-  test('+Block tooltip shows nested syntax blocks as breadcrumbs', async () => {
-    prepareForBlockExpansion([
-      {
-        name: 'aSpecificNamespace',
-        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
-        children: [
-          {
-            name: 'MyClass',
-            range: {
-              start_line: 2,
-              start_column: 0,
-              end_line: 100,
-              end_column: 0,
-            },
-            children: [
-              {
-                name: 'aMethod',
-                range: {
-                  start_line: 5,
-                  start_column: 0,
-                  end_line: 80,
-                  end_column: 0,
-                },
-                children: [],
-              },
-            ],
-          },
-        ],
-      },
-    ]);
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
-    element.showConfig = 'both';
-
-    await flush();
-
-    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.blockExpansion paper-button'
-    );
-    assert.equal(
-      blockExpansionButtons[0]
-        .querySelector('.breadcrumbTooltip')!
-        .textContent?.trim(),
-      'aSpecificNamespace > MyClass > aMethod'
-    );
-  });
-
-  test('+Block tooltip shows (anonymous) for empty blocks', async () => {
-    prepareForBlockExpansion([
-      {
-        name: 'aSpecificNamespace',
-        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
-        children: [
-          {
-            name: '',
-            range: {
-              start_line: 2,
-              start_column: 0,
-              end_line: 100,
-              end_column: 0,
-            },
-            children: [
-              {
-                name: 'aMethod',
-                range: {
-                  start_line: 5,
-                  start_column: 0,
-                  end_line: 80,
-                  end_column: 0,
-                },
-                children: [],
-              },
-            ],
-          },
-        ],
-      },
-    ]);
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
-    element.showConfig = 'both';
-    await flush();
-
-    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.blockExpansion paper-button'
-    );
-    assert.equal(
-      blockExpansionButtons[0]
-        .querySelector('.breadcrumbTooltip')!
-        .textContent?.trim(),
-      'aSpecificNamespace > (anonymous) > aMethod'
-    );
-  });
-
-  test('+Block tooltip shows "all common lines" for empty syntax tree', async () => {
-    prepareForBlockExpansion([]);
-
-    element.contextGroups = createContextGroups({offset: 10, count: 20});
-    element.showConfig = 'both';
-    await flush();
-
-    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
-      '.blockExpansion paper-button'
-    );
-    const tooltipAbove =
-      blockExpansionButtons[0].querySelector('paper-tooltip')!;
-    const tooltipBelow =
-      blockExpansionButtons[1].querySelector('paper-tooltip')!;
-    assert.equal(
-      tooltipAbove.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
-      '20 common lines'
-    );
-    assert.equal(
-      tooltipBelow.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
-      '20 common lines'
-    );
-    assert.equal(tooltipAbove!.getAttribute('position'), 'top');
-    assert.equal(tooltipBelow!.getAttribute('position'), 'bottom');
-  });
-});
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
deleted file mode 100644
index f121113..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
+++ /dev/null
@@ -1,120 +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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-coverage-layer_html';
-import {CoverageRange, CoverageType, DiffLayer} from '../../../types/types';
-import {customElement, property} from '@polymer/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-coverage-layer': GrCoverageLayer;
-  }
-}
-
-const TOOLTIP_MAP = new Map([
-  [CoverageType.COVERED, 'Covered by tests.'],
-  [CoverageType.NOT_COVERED, 'Not covered by tests.'],
-  [CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
-  [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
-]);
-
-@customElement('gr-coverage-layer')
-export class GrCoverageLayer extends PolymerElement implements DiffLayer {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Must be sorted by code_range.start_line.
-   * Must only contain ranges that match the side.
-   */
-  @property({type: Array})
-  coverageRanges: CoverageRange[] = [];
-
-  @property({type: String})
-  side?: string;
-
-  /**
-   * We keep track of the line number from the previous annotate() call,
-   * and also of the index of the coverage range that had matched.
-   * annotate() calls are coming in with increasing line numbers and
-   * coverage ranges are sorted by line number. So this is a very simple
-   * and efficient way for finding the coverage range that matches a given
-   * line number.
-   */
-  @property({type: Number})
-  _lineNumber = 0;
-
-  @property({type: Number})
-  _index = 0;
-
-  /**
-   * Layer method to add annotations to a line.
-   *
-   * @param _el Not used for this layer. (unused parameter)
-   * @param lineNumberEl The <td> element with the line number.
-   * @param line Not used for this layer.
-   */
-  annotate(_el: HTMLElement, lineNumberEl: HTMLElement) {
-    if (
-      !this.side ||
-      !lineNumberEl ||
-      !lineNumberEl.classList.contains(this.side)
-    ) {
-      return;
-    }
-    let elementLineNumber;
-    const dataValue = lineNumberEl.getAttribute('data-value');
-    if (dataValue) {
-      elementLineNumber = Number(dataValue);
-    }
-    if (!elementLineNumber || elementLineNumber < 1) return;
-
-    // If the line number is smaller than before, then we have to reset our
-    // algorithm and start searching the coverage ranges from the beginning.
-    // That happens for example when you expand diff sections.
-    if (elementLineNumber < this._lineNumber) {
-      this._index = 0;
-    }
-    this._lineNumber = elementLineNumber;
-
-    // We simply loop through all the coverage ranges until we find one that
-    // matches the line number.
-    while (this._index < this.coverageRanges.length) {
-      const coverageRange = this.coverageRanges[this._index];
-
-      // If the line number has moved past the current coverage range, then
-      // try the next coverage range.
-      if (this._lineNumber > coverageRange.code_range.end_line) {
-        this._index++;
-        continue;
-      }
-
-      // If the line number has not reached the next coverage range (and the
-      // range before also did not match), then this line has not been
-      // instrumented. Nothing to do for this line.
-      if (this._lineNumber < coverageRange.code_range.start_line) {
-        return;
-      }
-
-      // The line number is within the current coverage range. Style it!
-      lineNumberEl.classList.add(coverageRange.type);
-      lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type) || '';
-      return;
-    }
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_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/diff/gr-coverage-layer/gr-coverage-layer_test.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.js
deleted file mode 100644
index e886e61..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer_test.js
+++ /dev/null
@@ -1,124 +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-diff/gr-diff-line.js';
-import './gr-coverage-layer.js';
-
-const basicFixture = fixtureFromElement('gr-coverage-layer');
-
-suite('gr-coverage-layer', () => {
-  let element;
-
-  setup(() => {
-    const initialCoverageRanges = [
-      {
-        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,
-        },
-      },
-      {
-        type: 'PARTIALLY_COVERED',
-        side: 'right',
-        code_range: {
-          start_line: 5,
-          end_line: 6,
-        },
-      },
-      {
-        type: 'NOT_INSTRUMENTED',
-        side: 'right',
-        code_range: {
-          start_line: 8,
-          end_line: 9,
-        },
-      },
-    ];
-
-    element = basicFixture.instantiate();
-    element.coverageRanges = initialCoverageRanges;
-    element.side = 'right';
-  });
-
-  suite('annotate', () => {
-    function createLine(lineNumber) {
-      const lineEl = document.createElement('div');
-      lineEl.setAttribute('data-side', 'right');
-      lineEl.setAttribute('data-value', lineNumber);
-      lineEl.className = 'right';
-      return lineEl;
-    }
-
-    function checkLine(lineNumber, className, opt_negated) {
-      const line = createLine(lineNumber);
-      element.annotate(undefined, line, undefined);
-      let contains = line.classList.contains(className);
-      if (opt_negated) contains = !contains;
-      assert.isTrue(contains);
-    }
-
-    test('line 1-2 are covered', () => {
-      checkLine(1, 'COVERED');
-      checkLine(2, 'COVERED');
-    });
-
-    test('line 3-4 are not covered', () => {
-      checkLine(3, 'NOT_COVERED');
-      checkLine(4, 'NOT_COVERED');
-    });
-
-    test('line 5-6 are partially covered', () => {
-      checkLine(5, 'PARTIALLY_COVERED');
-      checkLine(6, 'PARTIALLY_COVERED');
-    });
-
-    test('line 7 is implicitly not instrumented', () => {
-      checkLine(7, 'COVERED', true);
-      checkLine(7, 'NOT_COVERED', true);
-      checkLine(7, 'PARTIALLY_COVERED', true);
-      checkLine(7, 'NOT_INSTRUMENTED', true);
-    });
-
-    test('line 8-9 are not instrumented', () => {
-      checkLine(8, 'NOT_INSTRUMENTED');
-      checkLine(9, 'NOT_INSTRUMENTED');
-    });
-
-    test('coverage correct, if annotate is called out of order', () => {
-      checkLine(8, 'NOT_INSTRUMENTED');
-      checkLine(1, 'COVERED');
-      checkLine(5, 'PARTIALLY_COVERED');
-      checkLine(3, 'NOT_COVERED');
-      checkLine(6, 'PARTIALLY_COVERED');
-      checkLine(4, 'NOT_COVERED');
-      checkLine(9, 'NOT_INSTRUMENTED');
-      checkLine(2, 'COVERED');
-    });
-  });
-});
-
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
deleted file mode 100644
index 4186a10..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.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.
- */
-
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {queryAndAssert} from '../../../utils/common-util';
-import {RenderPreferences} from '../../../api/diff';
-
-export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement
-  ) {
-    super(diff, prefs, outputEl);
-  }
-
-  override buildSectionElement(): HTMLElement {
-    const section = this._createElement('tbody', 'binary-diff');
-    const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
-    const fileRow = this._createRow(line);
-    const contentTd = queryAndAssert(fileRow, 'td.both.file')!;
-    contentTd.textContent = ' Difference in binary files';
-    section.appendChild(fileRow);
-    return section;
-  }
-
-  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
deleted file mode 100644
index 67f5a58..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ /dev/null
@@ -1,539 +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 '../gr-coverage-layer/gr-coverage-layer';
-import '../gr-diff-processor/gr-diff-processor';
-import '../../shared/gr-hovercard/gr-hovercard';
-import '../gr-ranged-comment-layer/gr-ranged-comment-layer';
-import './gr-diff-builder-side-by-side';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-builder-element_html';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffBuilder} from './gr-diff-builder';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
-import {GrDiffBuilderImage} from './gr-diff-builder-image';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
-import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {CancelablePromise, util} from '../../../scripts/util';
-import {customElement, property, observe} from '@polymer/decorators';
-import {BlameInfo, ImageInfo} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {CoverageRange, DiffLayer} from '../../../types/types';
-import {
-  GrDiffProcessor,
-  KeyLocations,
-} from '../gr-diff-processor/gr-diff-processor';
-import {
-  CommentRangeLayer,
-  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, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
-
-const TRAILING_WHITESPACE_PATTERN = /\s+$/;
-
-// https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
-const COMMIT_MSG_PATH = '/COMMIT_MSG';
-const COMMIT_MSG_LINE_LENGTH = 72;
-
-export interface GrDiffBuilderElement {
-  $: {
-    processor: GrDiffProcessor;
-    rangeLayer: GrRangedCommentLayer;
-    coverageLayerLeft: GrCoverageLayer;
-    coverageLayerRight: GrCoverageLayer;
-  };
-}
-
-export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
-  return prefs.font_size * 4;
-}
-
-@customElement('gr-diff-builder')
-export class GrDiffBuilderElement extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the diff begins rendering.
-   *
-   * @event render-start
-   */
-
-  /**
-   * Fired when the diff finishes rendering text content.
-   *
-   * @event render-content
-   */
-
-  @property({type: Object})
-  diff?: DiffInfo;
-
-  @property({type: String})
-  changeNum?: string;
-
-  @property({type: String})
-  patchNum?: string;
-
-  @property({type: String})
-  viewMode?: string;
-
-  @property({type: Boolean})
-  isImageDiff?: boolean;
-
-  @property({type: Object})
-  baseImage: ImageInfo | null = null;
-
-  @property({type: Object})
-  revisionImage: ImageInfo | null = null;
-
-  @property({type: Number})
-  parentIndex?: number;
-
-  @property({type: String})
-  path?: string;
-
-  @property({type: Object})
-  _builder?: GrDiffBuilder;
-
-  @property({type: Array})
-  _groups: GrDiffGroup[] = [];
-
-  /**
-   * Layers passed in from the outside.
-   */
-  @property({type: Array})
-  layers: DiffLayer[] = [];
-
-  /**
-   * All layers, both from the outside and the default ones.
-   */
-  @property({type: Array})
-  _layers: DiffLayer[] = [];
-
-  @property({type: Boolean})
-  _showTabs?: boolean;
-
-  @property({type: Boolean})
-  _showTrailingWhitespace?: boolean;
-
-  @property({type: Array})
-  commentRanges: CommentRangeLayer[] = [];
-
-  @property({type: Array})
-  coverageRanges: CoverageRange[] = [];
-
-  @property({type: Boolean})
-  useNewImageDiffUi = false;
-
-  @property({
-    type: Array,
-    computed: '_computeLeftCoverageRanges(coverageRanges)',
-  })
-  _leftCoverageRanges?: CoverageRange[];
-
-  @property({
-    type: Array,
-    computed: '_computeRightCoverageRanges(coverageRanges)',
-  })
-  _rightCoverageRanges?: CoverageRange[];
-
-  /**
-   * The promise last returned from `render()` while the asynchronous
-   * rendering is running - `null` otherwise. Provides a `cancel()`
-   * method that rejects it with `{isCancelled: true}`.
-   */
-  @property({type: Object})
-  _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
-
-  override disconnectedCallback() {
-    if (this._builder) {
-      this._builder.clear();
-    }
-    super.disconnectedCallback();
-  }
-
-  get diffElement(): HTMLTableElement {
-    // Not searching in shadowRoot, because the diff table is slotted!
-    return this.querySelector('#diffTable') as HTMLTableElement;
-  }
-
-  _computeLeftCoverageRanges(coverageRanges: CoverageRange[]) {
-    return coverageRanges.filter(range => range && range.side === 'left');
-  }
-
-  _computeRightCoverageRanges(coverageRanges: CoverageRange[]) {
-    return coverageRanges.filter(range => range && range.side === 'right');
-  }
-
-  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
-    // attached before plugins are installed.
-    this._setupAnnotationLayers();
-
-    this._showTabs = !!prefs.show_tabs;
-    this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
-
-    // Stop the processor if it's running.
-    this.cancel();
-
-    if (this._builder) {
-      this._builder.clear();
-    }
-    if (!this.diff) {
-      throw Error('Cannot render a diff without DiffInfo.');
-    }
-    this._builder = this._getDiffBuilder(this.diff, prefs, renderPrefs);
-
-    this.$.processor.context = prefs.context;
-    this.$.processor.keyLocations = keyLocations;
-
-    this._clearDiffContent();
-    this._builder.addColumns(this.diffElement, getLineNumberCellWidth(prefs));
-
-    const isBinary = !!(this.isImageDiff || this.diff.binary);
-
-    fireEvent(this, 'render-start');
-    this._cancelableRenderPromise = util.makeCancelable(
-      this.$.processor.process(this.diff.content, isBinary).then(() => {
-        if (this.isImageDiff) {
-          (this._builder as GrDiffBuilderImage).renderDiff();
-        }
-        fireEvent(this, 'render-content');
-      })
-    );
-    return (
-      this._cancelableRenderPromise
-        .finally(() => {
-          this._cancelableRenderPromise = null;
-        })
-        // Mocca testing does not like uncaught rejections, so we catch
-        // the cancels which are expected and should not throw errors in
-        // tests.
-        .catch(e => {
-          if (!e.isCanceled) return Promise.reject(e);
-          return;
-        })
-    );
-  }
-
-  _setupAnnotationLayers() {
-    const layers: DiffLayer[] = [
-      this._createTrailingWhitespaceLayer(),
-      this._createIntralineLayer(),
-      this._createTabIndicatorLayer(),
-      this._createSpecialCharacterIndicatorLayer(),
-      this.$.rangeLayer,
-      this.$.coverageLayerLeft,
-      this.$.coverageLayerRight,
-    ];
-
-    if (this.layers) {
-      layers.push(...this.layers);
-    }
-    this._layers = layers;
-  }
-
-  getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
-    if (!this._builder) return null;
-    return this._builder.getContentTdByLine(lineNumber, side, root);
-  }
-
-  _getDiffRowByChild(child: Element) {
-    while (!child.classList.contains('diff-row') && child.parentElement) {
-      child = child.parentElement;
-    }
-    return child;
-  }
-
-  getContentTdByLineEl(lineEl?: Element): Element | null {
-    if (!lineEl) return null;
-    const line = getLineNumber(lineEl);
-    if (!line) return null;
-    const side = getSideByLineEl(lineEl);
-    // Performance optimization because we already have an element in the
-    // correct row
-    const row = this._getDiffRowByChild(lineEl);
-    return this.getContentTdByLine(line, side, row);
-  }
-
-  getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    const sideSelector = side ? '.' + side : '';
-    return this.diffElement.querySelector(
-      `.lineNum[data-value="${lineNumber}"]${sideSelector}`
-    );
-  }
-
-  emitGroup(group: GrDiffGroup, sectionEl: HTMLElement) {
-    if (!this._builder) return;
-    this._builder.emitGroup(group, sectionEl);
-  }
-
-  showContext(newGroups: GrDiffGroup[], sectionEl: HTMLElement) {
-    if (!this._builder) return;
-    const groups = this._builder.groups;
-
-    const contextIndex = groups.findIndex(group => group.element === sectionEl);
-    groups.splice(contextIndex, 1, ...newGroups);
-
-    for (const newGroup of newGroups) {
-      this._builder.emitGroup(newGroup, sectionEl);
-    }
-    if (sectionEl.parentNode) {
-      sectionEl.parentNode.removeChild(sectionEl);
-    }
-
-    setTimeout(() => fireEvent(this, 'render-content'), 1);
-  }
-
-  cancel() {
-    this.$.processor.cancel();
-    if (this._cancelableRenderPromise) {
-      this._cancelableRenderPromise.cancel();
-      this._cancelableRenderPromise = null;
-    }
-  }
-
-  _handlePreferenceError(pref: string): never {
-    const message =
-      `The value of the '${pref}' user preference is ` +
-      'invalid. Fix in diff preferences';
-    fireAlert(this, message);
-    throw Error(`Invalid preference value: ${pref}`);
-  }
-
-  _getDiffBuilder(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    renderPrefs?: RenderPreferences
-  ): GrDiffBuilder {
-    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
-      this._handlePreferenceError('tab size');
-    }
-
-    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
-      this._handlePreferenceError('diff width');
-    }
-
-    const localPrefs = {...prefs};
-    if (this.path === COMMIT_MSG_PATH) {
-      // override line_length for commit msg the same way as
-      // in gr-diff
-      localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
-    }
-
-    let builder = null;
-    if (this.isImageDiff) {
-      builder = new GrDiffBuilderImage(
-        diff,
-        localPrefs,
-        this.diffElement,
-        this.baseImage,
-        this.revisionImage,
-        renderPrefs,
-        this.useNewImageDiffUi
-      );
-    } else if (diff.binary) {
-      // If the diff is binary, but not an image.
-      return new GrDiffBuilderBinary(diff, localPrefs, this.diffElement);
-    } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      builder = new GrDiffBuilderSideBySide(
-        diff,
-        localPrefs,
-        this.diffElement,
-        this._layers,
-        renderPrefs
-      );
-    } else if (this.viewMode === DiffViewMode.UNIFIED) {
-      builder = new GrDiffBuilderUnified(
-        diff,
-        localPrefs,
-        this.diffElement,
-        this._layers,
-        renderPrefs
-      );
-    }
-    if (!builder) {
-      throw Error(`Unsupported diff view mode: ${this.viewMode}`);
-    }
-    return builder;
-  }
-
-  _clearDiffContent() {
-    this.diffElement.innerHTML = '';
-  }
-
-  @observe('_groups.splices')
-  _groupsChanged(changeRecord: PolymerSpliceChange<GrDiffGroup[]>) {
-    if (!changeRecord || !this._builder) {
-      return;
-    }
-    for (const splice of changeRecord.indexSplices) {
-      let group;
-      for (let i = 0; i < splice.addedCount; i++) {
-        group = splice.object[splice.index + i];
-        this._builder.groups.push(group);
-        this._builder.emitGroup(group, null);
-      }
-    }
-  }
-
-  _createIntralineLayer(): DiffLayer {
-    return {
-      // Take a DIV.contentText element and a line object with intraline
-      // differences to highlight and apply them to the element as
-      // annotations.
-      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        const HL_CLASS = 'style-scope gr-diff intraline';
-        for (const highlight of line.highlights) {
-          // The start and end indices could be the same if a highlight is
-          // meant to start at the end of a line and continue onto the
-          // next one. Ignore it.
-          if (highlight.startIndex === highlight.endIndex) {
-            continue;
-          }
-
-          // If endIndex isn't present, continue to the end of the line.
-          const endIndex =
-            highlight.endIndex === undefined
-              ? line.text.length
-              : highlight.endIndex;
-
-          GrAnnotation.annotateElement(
-            contentEl,
-            highlight.startIndex,
-            endIndex - highlight.startIndex,
-            HL_CLASS
-          );
-        }
-      },
-    };
-  }
-
-  _createTabIndicatorLayer(): DiffLayer {
-    const show = () => this._showTabs;
-    return {
-      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        // If visible tabs are disabled, do nothing.
-        if (!show()) {
-          return;
-        }
-
-        // Find and annotate the locations of tabs.
-        const split = line.text.split('\t');
-        if (!split) {
-          return;
-        }
-        for (let i = 0, pos = 0; i < split.length - 1; i++) {
-          // Skip forward by the length of the content
-          pos += split[i].length;
-
-          GrAnnotation.annotateElement(
-            contentEl,
-            pos,
-            1,
-            'style-scope gr-diff tab-indicator'
-          );
-
-          // Skip forward by one tab character.
-          pos++;
-        }
-      },
-    };
-  }
-
-  _createSpecialCharacterIndicatorLayer(): DiffLayer {
-    return {
-      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        // Find and annotate the locations of soft hyphen.
-        const split = line.text.split('\u00AD'); // \u00AD soft hyphen
-        if (!split || split.length < 2) {
-          return;
-        }
-        for (let i = 0, pos = 0; i < split.length - 1; i++) {
-          // Skip forward by the length of the content
-          pos += split[i].length;
-
-          GrAnnotation.annotateElement(
-            contentEl,
-            pos,
-            1,
-            'style-scope gr-diff special-char-indicator'
-          );
-
-          pos++;
-        }
-      },
-    };
-  }
-
-  _createTrailingWhitespaceLayer(): DiffLayer {
-    const show = () => this._showTrailingWhitespace;
-
-    return {
-      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        if (!show()) {
-          return;
-        }
-
-        const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
-        if (match) {
-          // Normalize string positions in case there is unicode before or
-          // within the match.
-          const index = GrAnnotation.getStringLength(
-            line.text.substr(0, match.index)
-          );
-          const length = GrAnnotation.getStringLength(match[0]);
-          GrAnnotation.annotateElement(
-            contentEl,
-            index,
-            length,
-            'style-scope gr-diff trailing-whitespace'
-          );
-        }
-      },
-    };
-  }
-
-  setBlame(blame: BlameInfo[] | null) {
-    if (!this._builder) return;
-    this._builder.setBlame(blame);
-  }
-
-  updateRenderPrefs(renderPrefs: RenderPreferences) {
-    this._builder?.updateRenderPrefs(renderPrefs);
-    this.$.processor.updateRenderPrefs(renderPrefs);
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-builder': GrDiffBuilderElement;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts
deleted file mode 100644
index 573f559..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts
+++ /dev/null
@@ -1,38 +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`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-  <gr-ranged-comment-layer
-    id="rangeLayer"
-    comment-ranges="[[commentRanges]]"
-  ></gr-ranged-comment-layer>
-  <gr-coverage-layer
-    id="coverageLayerLeft"
-    coverage-ranges="[[_leftCoverageRanges]]"
-    side="left"
-  ></gr-coverage-layer>
-  <gr-coverage-layer
-    id="coverageLayerRight"
-    coverage-ranges="[[_rightCoverageRanges]]"
-    side="right"
-  ></gr-coverage-layer>
-  <gr-diff-processor id="processor" groups="{{_groups}}"></gr-diff-processor>
-`;
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
deleted file mode 100644
index 44b0b8b..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ /dev/null
@@ -1,1163 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-group.js';
-import './gr-diff-builder.js';
-import '../gr-context-controls/gr-context-controls.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import './gr-diff-builder-element.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
-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>
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-const divWithTextFixture = fixtureFromTemplate(html`
-<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-`);
-
-const mockDiffFixture = fixtureFromTemplate(html`
-<gr-diff-builder view-mode="SIDE_BY_SIDE">
-      <table id="diffTable"></table>
-    </gr-diff-builder>
-`);
-
-suite('gr-diff-builder tests', () => {
-  let prefs;
-  let element;
-  let builder;
-
-  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
-  const WBR_HTML = '<wbr class="style-scope gr-diff">';
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-    stubBaseUrl('/r');
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    builder = new GrDiffBuilder({content: []}, prefs);
-  });
-
-  test('_createElement classStr applies all classes', () => {
-    const node = builder._createElement('div', 'test classes');
-    assert.isTrue(node.classList.contains('gr-diff'));
-    assert.isTrue(node.classList.contains('test'));
-    assert.isTrue(node.classList.contains('classes'));
-  });
-
-  test('newlines 1', () => {
-    let text = 'abcdef';
-
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML, text);
-    text = 'a'.repeat(20);
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
-        'a'.repeat(10) +
-        LINE_BREAK_HTML +
-        'a'.repeat(10));
-  });
-
-  test('newlines 2', () => {
-    const text = '<span class="thumbsup">👍</span>';
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
-        '&lt;span clas' +
-        LINE_BREAK_HTML +
-        's="thumbsu' +
-        LINE_BREAK_HTML +
-        'p"&gt;👍&lt;/span' +
-        LINE_BREAK_HTML +
-        '&gt;');
-  });
-
-  test('newlines 3', () => {
-    const text = '01234\t56789';
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
-        '01234' + builder._getTabWrapper(3).outerHTML + '56' +
-        LINE_BREAK_HTML +
-        '789');
-  });
-
-  test('newlines 4', () => {
-    const text = '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍';
-    assert.equal(builder._formatText(text, 'NONE', 4, 20).innerHTML,
-        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-        LINE_BREAK_HTML +
-        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-        LINE_BREAK_HTML +
-        '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
-  });
-
-  test('line_length applied with <wbr> if line_wrapping is true', () => {
-    builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
-    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  test('line_length applied with line break if line_wrapping is false', () => {
-    builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-    const text = 'a'.repeat(51);
-
-    const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
-    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, expected);
-  });
-
-  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE]
-      .forEach(mode => {
-        test(`line_length used for regular files under ${mode}`, () => {
-          element.path = '/a.txt';
-          element.viewMode = mode;
-          builder = element._getDiffBuilder(
-              {}, {tab_size: 4, line_length: 50}
-          );
-          assert.equal(builder._prefs.line_length, 50);
-        });
-
-        test(`line_length ignored for commit msg under ${mode}`, () => {
-          element.path = '/COMMIT_MSG';
-          element.viewMode = mode;
-          builder = element._getDiffBuilder(
-              {}, {tab_size: 4, line_length: 50}
-          );
-          assert.equal(builder._prefs.line_length, 72);
-        });
-      });
-
-  test('_createTextEl linewrap with tabs', () => {
-    const text = '\t'.repeat(7) + '!';
-    const line = {text, highlights: []};
-    const el = builder._createTextEl(undefined, line);
-    assert.equal(el.innerText, text);
-    // With line length 10 and tab size 2, there should be a line break
-    // after every two tabs.
-    const newlineEl = el.querySelector('.contentText > .br');
-    assert.isOk(newlineEl);
-    assert.equal(
-        el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
-        newlineEl);
-  });
-
-  test('text length with tabs and unicode', () => {
-    function expectTextLength(text, tabSize, expected) {
-      // Formatting to |expected| columns should not introduce line breaks.
-      const result = builder._formatText(text, 'NONE', tabSize, expected);
-      assert.isNotOk(result.querySelector('.contentText > .br'),
-          `  Expected the result of: \n` +
-          `      _formatText(${text}', 'NONE',  ${tabSize}, ${expected})\n` +
-          `  to not contain a br. But the actual result HTML was:\n` +
-          `      '${result.innerHTML}'\nwhereupon`);
-
-      // Increasing the line limit should produce the same markup.
-      assert.equal(
-          builder._formatText(text, 'NONE', tabSize, Infinity).innerHTML,
-          result.innerHTML);
-      assert.equal(
-          builder._formatText(text, 'NONE', tabSize, expected + 1).innerHTML,
-          result.innerHTML);
-
-      // Decreasing the line limit should introduce line breaks.
-      if (expected > 0) {
-        const tooSmall = builder._formatText(text,
-            'NONE', tabSize, expected - 1);
-        assert.isOk(tooSmall.querySelector('.contentText > .br'),
-            `  Expected the result of: \n` +
-            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
-            `  to contain a br. But the actual result HTML was:\n` +
-            `      '${tooSmall.innerHTML}'\nwhereupon`);
-      }
-    }
-    expectTextLength('12345', 4, 5);
-    expectTextLength('\t\t12', 4, 10);
-    expectTextLength('abc💢123', 4, 7);
-    expectTextLength('abc\t', 8, 8);
-    expectTextLength('abc\t\t', 10, 20);
-    expectTextLength('', 10, 0);
-    // 17 Thai combining chars.
-    expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
-    expectTextLength('abc\tde', 10, 12);
-    expectTextLength('abc\tde\t', 10, 20);
-    expectTextLength('\t\t\t\t\t', 20, 100);
-  });
-
-  test('tab wrapper insertion', () => {
-    const html = 'abc\tdef';
-    const tabSize = builder._prefs.tab_size;
-    const wrapper = builder._getTabWrapper(tabSize - 3);
-    assert.ok(wrapper);
-    assert.equal(wrapper.innerText, '\t');
-    assert.equal(
-        builder._formatText(html, 'NONE', tabSize, Infinity).innerHTML,
-        'abc' + wrapper.outerHTML + 'def');
-  });
-
-  test('tab wrapper style', () => {
-    const pattern = new RegExp('^<span class="style-scope gr-diff tab" ' +
-      'style="((?:-moz-)?tab-size: (\\d+);.?)+">\\t<\\/span>$');
-
-    for (const size of [1, 3, 8, 55]) {
-      const html = builder._getTabWrapper(size).outerHTML;
-      expect(html).to.match(pattern);
-      assert.equal(html.match(pattern)[2], size);
-    }
-  });
-
-  test('_handlePreferenceError throws with invalid preference', () => {
-    const prefs = {tab_size: 0};
-    assert.throws(() => element._getDiffBuilder(element.diff, prefs));
-  });
-
-  test('_handlePreferenceError triggers alert and javascript error', () => {
-    const errorStub = sinon.stub();
-    element.addEventListener('show-alert', errorStub);
-    assert.throws(() => element._handlePreferenceError('tab size'));
-    assert.equal(errorStub.lastCall.args[0].detail.message,
-        `The value of the 'tab size' user preference is invalid. ` +
-      `Fix in diff preferences`);
-  });
-
-  suite('_isTotal', () => {
-    test('is total for add', () => {
-      const group = new GrDiffGroup(GrDiffGroupType.DELTA);
-      for (let idx = 0; idx < 10; idx++) {
-        group.addLine(new GrDiffLine(GrDiffLineType.ADD));
-      }
-      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
-    });
-
-    test('is total for remove', () => {
-      const group = new GrDiffGroup(GrDiffGroupType.DELTA);
-      for (let idx = 0; idx < 10; idx++) {
-        group.addLine(new GrDiffLine(GrDiffLineType.REMOVE));
-      }
-      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
-    });
-
-    test('not total for empty', () => {
-      const group = new GrDiffGroup(GrDiffGroupType.BOTH);
-      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
-    });
-
-    test('not total for non-delta', () => {
-      const group = new GrDiffGroup(GrDiffGroupType.DELTA);
-      for (let idx = 0; idx < 10; idx++) {
-        group.addLine(new GrDiffLine(GrDiffLineType.BOTH));
-      }
-      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
-    });
-  });
-
-  suite('intraline differences', () => {
-    let el;
-    let str;
-    let annotateElementSpy;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    function slice(str, start, end) {
-      return Array.from(str).slice(start, end)
-          .join('');
-    }
-
-    setup(() => {
-      el = divWithTextFixture.instantiate();
-      str = el.textContent;
-      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
-      layer = document.createElement('gr-diff-builder')
-          ._createIntralineLayer();
-    });
-
-    test('annotate no highlights', () => {
-      const line = {
-        text: str,
-        highlights: [],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      // The content is unchanged.
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(str, el.childNodes[0].textContent);
-    });
-
-    test('annotate with highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-          {startIndex: 18, endIndex: 22},
-        ],
-      };
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12, 18);
-      const str3 = slice(str, 18, 22);
-      const str4 = slice(str, 22);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 5);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-
-      assert.notInstanceOf(el.childNodes[3], Text);
-      assert.equal(el.childNodes[3].textContent, str3);
-
-      assert.instanceOf(el.childNodes[4], Text);
-      assert.equal(el.childNodes[4].textContent, str4);
-    });
-
-    test('annotate without endIndex', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28},
-        ],
-      };
-
-      const str0 = slice(str, 0, 28);
-      const str1 = slice(str, 28);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-
-    test('annotate ignores empty highlights', () => {
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 28, endIndex: 28},
-        ],
-      };
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-    });
-
-    test('annotate handles unicode', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6, endIndex: 12},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 3);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-    });
-
-    test('annotate handles unicode w/o endIndex', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-
-      const line = {
-        text: str,
-        highlights: [
-          {startIndex: 6},
-        ],
-      };
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6);
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-  });
-
-  suite('tab indicators', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTabs = true;
-      layer = element._createTabIndicatorLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no tabs', () => {
-      const str = 'lorem ipsum no tabs';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates tab at beginning', () => {
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTabs = false;
-
-      const str = '\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates multiple in beginning', () => {
-      const str = '\t\tlorem upsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 2);
-
-      let args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-
-      args = annotateElementStub.getCalls()[1].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 1, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('annotates intermediate tabs', () => {
-      const str = 'lorem\tupsum';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, line);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 5, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-  });
-
-  suite('layers', () => {
-    let element;
-    let initialLayersCount;
-    let withLayerCount;
-    setup(() => {
-      const layers = [];
-      element = basicFixture.instantiate();
-      element.layers = layers;
-      element._showTrailingWhitespace = true;
-      element._setupAnnotationLayers();
-      initialLayersCount = element._layers.length;
-    });
-
-    test('no layers', () => {
-      element._setupAnnotationLayers();
-      assert.equal(element._layers.length, initialLayersCount);
-    });
-
-    suite('with layers', () => {
-      const layers = [{}, {}];
-      setup(() => {
-        element = basicFixture.instantiate();
-        element.layers = layers;
-        element._showTrailingWhitespace = true;
-        element._setupAnnotationLayers();
-        withLayerCount = element._layers.length;
-      });
-      test('with layers', () => {
-        element._setupAnnotationLayers();
-        assert.equal(element._layers.length, withLayerCount);
-        assert.equal(initialLayersCount + layers.length,
-            withLayerCount);
-      });
-    });
-  });
-
-  suite('trailing whitespace', () => {
-    let element;
-    let layer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element._showTrailingWhitespace = true;
-      layer = element._createTrailingWhitespaceLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const line = {text: ''};
-      const el = document.createElement('div');
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no trailing whitespace', () => {
-      const str = 'lorem ipsum blah blah';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates trailing spaces', () => {
-      const str = 'lorem ipsum   ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates trailing tabs', () => {
-      const str = 'lorem ipsum\t\t\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates mixed trailing whitespace', () => {
-      const str = 'lorem ipsum\t \t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('unicode preceding trailing whitespace', () => {
-      const str = '💢\t';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 1);
-      assert.equal(annotateElementStub.lastCall.args[2], 1);
-    });
-
-    test('does not annotate when disabled', () => {
-      element._showTrailingWhitespace = false;
-      const str = 'lorem upsum\t \t ';
-      const line = {text: str};
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub =
-          sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, line);
-      assert.isFalse(annotateElementStub.called);
-    });
-  });
-
-  suite('rendering text, images and binary files', () => {
-    let processStub;
-    let keyLocations;
-    let prefs;
-    let content;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sinon.stub(element.$.processor, 'process')
-          .returns(Promise.resolve());
-      keyLocations = {left: {}, right: {}};
-      prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-    });
-
-    test('text', () => {
-      element.diff = {content};
-      return element.render(keyLocations, prefs).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isFalse(processStub.lastCall.args[1]);
-      });
-    });
-
-    test('image', () => {
-      element.diff = {content, binary: true};
-      element.isImageDiff = true;
-      return element.render(keyLocations, prefs).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isTrue(processStub.lastCall.args[1]);
-      });
-    });
-
-    test('binary', () => {
-      element.diff = {content, binary: true};
-      return element.render(keyLocations, prefs).then(() => {
-        assert.isTrue(processStub.calledOnce);
-        assert.isTrue(processStub.lastCall.args[1]);
-      });
-    });
-  });
-
-  suite('rendering', () => {
-    let content;
-    let outputEl;
-    let keyLocations;
-
-    setup(async () => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      content = [
-        {
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        },
-        {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        },
-      ];
-      element = basicFixture.instantiate();
-      outputEl = element.querySelector('#diffTable');
-      keyLocations = {left: {}, right: {}};
-      sinon.stub(element, '_getDiffBuilder').callsFake(() => {
-        const builder = new GrDiffBuilderSideBySide({content}, prefs, outputEl);
-        sinon.stub(builder, 'addColumns');
-        builder.buildSectionElement = function(group) {
-          const section = document.createElement('stub');
-          section.textContent = group.lines
-              .reduce((acc, line) => acc + line.text, '');
-          return section;
-        };
-        return builder;
-      });
-      element.diff = {content};
-      await element.render(keyLocations, prefs);
-    });
-
-    test('addColumns is called', async () => {
-      await element.render(keyLocations, {});
-      assert.isTrue(element._builder.addColumns.called);
-    });
-
-    test('getSectionsByLineRange one line', () => {
-      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);
-    });
-
-    test('getSectionsByLineRange over diff', () => {
-      const section = [
-        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);
-      assert.strictEqual(sections[0], section[0]);
-      assert.strictEqual(sections[1], section[1]);
-    });
-
-    test('render-start and render-content are fired', async () => {
-      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      await element.render(keyLocations, {});
-      const firedEventTypes = dispatchEventStub.getCalls()
-          .map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-start');
-      assert.include(firedEventTypes, 'render-content');
-    });
-
-    test('cancel', () => {
-      const processorCancelStub = sinon.stub(element.$.processor, 'cancel');
-      element.cancel();
-      assert.isTrue(processorCancelStub.called);
-    });
-  });
-
-  suite('mock-diff', () => {
-    let element;
-    let builder;
-    let diff;
-    let prefs;
-    let keyLocations;
-
-    setup(async () => {
-      element = mockDiffFixture.instantiate();
-      diff = getMockDiffResponse();
-      element.diff = diff;
-
-      prefs = {
-        line_length: 80,
-        show_tabs: true,
-        tab_size: 4,
-      };
-      keyLocations = {left: {}, right: {}};
-
-      await element.render(keyLocations, prefs);
-      builder = element._builder;
-    });
-
-    test('aria-labels on added line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.right')[5];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(deltaLineNumberButton.getAttribute('aria-label'), '5 added');
-    });
-
-    test('aria-labels on removed line numbers', () => {
-      const deltaLineNumberButton = element.diffElement.querySelectorAll(
-          '.lineNumButton.left')[10];
-
-      assert.isOk(deltaLineNumberButton);
-      assert.equal(
-          deltaLineNumberButton.getAttribute('aria-label'), '10 removed');
-    });
-
-    test('getContentByLine', () => {
-      let actual;
-
-      actual = builder.getContentByLine(2, 'left');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(2, 'right');
-      assert.equal(actual.textContent, diff.content[0].ab[1]);
-
-      actual = builder.getContentByLine(5, 'left');
-      assert.equal(actual.textContent, diff.content[2].ab[0]);
-
-      actual = builder.getContentByLine(5, 'right');
-      assert.equal(actual.textContent, diff.content[1].b[0]);
-    });
-
-    test('getContentTdByLineEl works both with button and td', () => {
-      const diffRow = element.diffElement.querySelectorAll('tr.diff-row')[2];
-
-      const lineNumTdLeft = diffRow.querySelector('td.lineNum.left');
-      const lineNumButtonLeft = lineNumTdLeft.querySelector('button');
-      const contentTdLeft = diffRow.querySelectorAll('.content')[0];
-
-      const lineNumTdRight = diffRow.querySelector('td.lineNum.right');
-      const lineNumButtonRight = lineNumTdRight.querySelector('button');
-      const contentTdRight = diffRow.querySelectorAll('.content')[1];
-
-      assert.equal(element.getContentTdByLineEl(lineNumTdLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonLeft), contentTdLeft);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumTdRight), contentTdRight);
-      assert.equal(
-          element.getContentTdByLineEl(lineNumButtonRight), contentTdRight);
-    });
-
-    test('findLinesByRange', () => {
-      const lines = [];
-      const elems = [];
-      const start = 6;
-      const end = 10;
-      const count = end - start + 1;
-
-      builder.findLinesByRange(start, end, 'right', lines, elems);
-
-      assert.equal(lines.length, count);
-      assert.equal(elems.length, count);
-
-      for (let i = 0; i < 5; i++) {
-        assert.instanceOf(lines[i], GrDiffLine);
-        assert.equal(lines[i].afterNumber, start + i);
-        assert.instanceOf(elems[i], HTMLElement);
-        assert.equal(lines[i].text, elems[i].textContent);
-      }
-    });
-
-    test('_renderContentByRange', () => {
-      const spy = sinon.spy(builder, '_createTextEl');
-      const start = 9;
-      const end = 14;
-      const count = end - start + 1;
-
-      builder._renderContentByRange(start, end, 'left');
-
-      assert.equal(spy.callCount, count);
-      spy.getCalls().forEach((call, i) => {
-        assert.equal(call.args[1].beforeNumber, start + i);
-      });
-    });
-
-    test('_renderContentByRange notexistent elements', () => {
-      const spy = sinon.spy(builder, '_createTextEl');
-
-      sinon.stub(builder, '_getLineNumberEl').returns(
-          document.createElement('div')
-      );
-      sinon.stub(builder, 'findLinesByRange').callsFake(
-          (s, e, d, lines, elements) => {
-            // Add a line and a corresponding element.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            const tr = document.createElement('tr');
-            const td = document.createElement('td');
-            const el = document.createElement('div');
-            tr.appendChild(td);
-            td.appendChild(el);
-            elements.push(el);
-
-            // Add 2 lines without corresponding elements.
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-            lines.push(new GrDiffLine(GrDiffLineType.BOTH));
-          });
-
-      builder._renderContentByRange(1, 10, 'left');
-      // Should be called only once because only one line had a corresponding
-      // element.
-      assert.equal(spy.callCount, 1);
-    });
-
-    test('_getLineNumberEl side-by-side left', () => {
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('_getLineNumberEl side-by-side right', () => {
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('_getLineNumberEl unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('left'));
-    });
-
-    test('_getLineNumberEl unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
-      builder = element._builder;
-
-      const contentEl = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
-      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-      assert.isTrue(lineNumberEl.classList.contains('right'));
-    });
-
-    test('_getNextContentOnSide side-by-side left', () => {
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder._getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('_getNextContentOnSide side-by-side right', () => {
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder._getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('_getNextContentOnSide unified left', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'left',
-          element.$.diffTable);
-      const expectedStartString = diff.content[2].ab[0];
-      const expectedNextString = diff.content[2].ab[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder._getNextContentOnSide(startElem,
-          'left');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('_getNextContentOnSide unified right', async () => {
-      // Re-render as unified:
-      element.viewMode = 'UNIFIED_DIFF';
-      await element.render(keyLocations, prefs);
-      builder = element._builder;
-
-      const startElem = builder.getContentByLine(5, 'right',
-          element.$.diffTable);
-      const expectedStartString = diff.content[1].b[0];
-      const expectedNextString = diff.content[1].b[1];
-      assert.equal(startElem.textContent, expectedStartString);
-
-      const nextElem = builder._getNextContentOnSide(startElem,
-          'right');
-      assert.equal(nextElem.textContent, expectedNextString);
-    });
-
-    test('escaping HTML', () => {
-      let input = '<script>alert("XSS");<' + '/script>';
-      let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
-      let result = builder._formatText(input, 1, Infinity).innerHTML;
-      assert.equal(result, expected);
-
-      input = '& < > " \' / `';
-      expected = '&amp; &lt; &gt; " \' / `';
-      result = builder._formatText(input, 1, Infinity).innerHTML;
-      assert.equal(result, expected);
-    });
-  });
-
-  suite('blame', () => {
-    let mockBlame;
-
-    setup(() => {
-      mockBlame = [
-        {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
-        {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
-      ];
-    });
-
-    test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sinon.stub(builder, '_getBlameByLineNum')
-          .returns(null);
-      builder.setBlame(mockBlame);
-      assert.equal(getBlameStub.callCount, 32);
-    });
-
-    test('_getBlameCommitForBaseLine', () => {
-      sinon.stub(builder, '_getBlameByLineNum').returns(null);
-      builder.setBlame(mockBlame);
-      assert.isOk(builder._getBlameCommitForBaseLine(1));
-      assert.equal(builder._getBlameCommitForBaseLine(1).id, 'commit 1');
-
-      assert.isOk(builder._getBlameCommitForBaseLine(11));
-      assert.equal(builder._getBlameCommitForBaseLine(11).id, 'commit 1');
-
-      assert.isOk(builder._getBlameCommitForBaseLine(32));
-      assert.equal(builder._getBlameCommitForBaseLine(32).id, 'commit 2');
-
-      assert.isNull(builder._getBlameCommitForBaseLine(33));
-    });
-
-    test('_getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isNull(builder._getBlameCommitForBaseLine(1));
-      assert.isNull(builder._getBlameCommitForBaseLine(11));
-      assert.isNull(builder._getBlameCommitForBaseLine(31));
-    });
-
-    test('_createBlameCell', () => {
-      const mocbBlameCell = document.createElement('span');
-      const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
-          .returns(mocbBlameCell);
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const result = builder._createBlameCell(line.beforeNumber);
-
-      assert.isTrue(getBlameStub.calledWithExactly(3));
-      assert.equal(result.getAttribute('data-line-number'), '3');
-      assert.equal(result.firstChild, mocbBlameCell);
-    });
-
-    test('_getBlameForBaseLine', () => {
-      const mockCommit = {
-        time: 1576105200,
-        id: 1234567890,
-        author: 'Clark Kent',
-        commit_msg: 'Testing Commit',
-        ranges: [1],
-      };
-      const blameNode = builder._getBlameForBaseLine(1, mockCommit);
-
-      const authors = blameNode.getElementsByClassName('blameAuthor');
-      assert.equal(authors.length, 1);
-      assert.equal(authors[0].innerText, ' Clark');
-
-      const date = (new Date(mockCommit.time * 1000)).toLocaleDateString();
-      flush();
-      const cards = blameNode.getElementsByClassName('blameHoverCard');
-      assert.equal(cards.length, 1);
-      assert.equal(cards[0].innerHTML,
-          `Commit 1234567890<br>Author: Clark Kent<br>Date: ${date}`
-        + '<br><br>Testing Commit'
-      );
-
-      const url = blameNode.getElementsByClassName('blameDate');
-      assert.equal(url[0].getAttribute('href'), '/r/q/1234567890');
-    });
-  });
-});
-
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
deleted file mode 100644
index 5629aa4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ /dev/null
@@ -1,240 +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 {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
-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.
-const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
-
-export class GrDiffBuilderImage extends GrDiffBuilderSideBySide {
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    private readonly _baseImage: ImageInfo | null,
-    private readonly _revisionImage: ImageInfo | null,
-    renderPrefs?: RenderPreferences,
-    private readonly _useNewImageDiffUi: boolean = false
-  ) {
-    super(diff, prefs, outputEl, [], renderPrefs);
-  }
-
-  public renderDiff() {
-    const section = this._createElement('tbody', 'image-diff');
-
-    if (this._useNewImageDiffUi) {
-      this._emitImageViewer(section);
-
-      this._outputEl.appendChild(section);
-    } else {
-      this._emitImagePair(section);
-      this._emitImageLabels(section);
-
-      this._outputEl.appendChild(section);
-      this._outputEl.appendChild(this._createEndpoint());
-    }
-  }
-
-  private _createEndpoint() {
-    const tbody = this._createElement('tbody');
-    const tr = this._createElement('tr');
-    const td = this._createElement('td');
-
-    // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
-    // column limit.
-    td.setAttribute('colspan', '4');
-    const endpointDomApi = this._createElement('gr-endpoint-decorator');
-    endpointDomApi.setAttribute('name', 'image-diff');
-    endpointDomApi.appendChild(
-      this._createEndpointParam('baseImage', this._baseImage)
-    );
-    endpointDomApi.appendChild(
-      this._createEndpointParam('revisionImage', this._revisionImage)
-    );
-    td.appendChild(endpointDomApi);
-    tr.appendChild(td);
-    tbody.appendChild(tr);
-    return tbody;
-  }
-
-  private _createEndpointParam(name: string, value: ImageInfo | null) {
-    const endpointParam = this._createElement(
-      'gr-endpoint-param'
-    ) as GrEndpointParam;
-    endpointParam.name = name;
-    endpointParam.value = value;
-    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);
-    imageViewer.automaticBlink =
-      !!this._renderPrefs?.image_diff_prefs?.automatic_blink;
-
-    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');
-
-    tr.appendChild(this._createElement('td', 'left lineNum blank'));
-    tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
-
-    tr.appendChild(this._createElement('td', 'right lineNum blank'));
-    tr.appendChild(
-      this._createImageCell(this._revisionImage, 'right', section)
-    );
-
-    section.appendChild(tr);
-  }
-
-  private _createImageCell(
-    image: ImageInfo | null,
-    className: string,
-    section: HTMLElement
-  ) {
-    const td = this._createElement('td', className);
-    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.addEventListener('error', (e: Event) => {
-        imageEl.remove();
-        td.textContent = '[Image failed to load] ' + e.type;
-      });
-      imageEl.setAttribute('src', src);
-      td.appendChild(imageEl);
-    }
-    return td;
-  }
-
-  private _updateImageLabel(
-    section: HTMLElement,
-    className: string,
-    image: ImageInfo
-  ) {
-    const label = section.querySelector(
-      '.' + className + ' span.label'
-    ) as HTMLElement;
-    this._setLabelText(label, image);
-  }
-
-  private _setLabelText(label: HTMLElement, image: ImageInfo | null) {
-    label.textContent = _getImageLabel(image);
-  }
-
-  private _emitImageLabels(section: HTMLElement) {
-    const tr = this._createElement('tr');
-
-    let addNamesInLabel = false;
-
-    if (
-      this._baseImage &&
-      this._revisionImage &&
-      this._baseImage._name !== this._revisionImage._name
-    ) {
-      addNamesInLabel = true;
-    }
-
-    tr.appendChild(this._createElement('td', 'left lineNum blank'));
-    let td = this._createElement('td', 'left');
-    let label = this._createElement('label');
-    let nameSpan;
-    let labelSpan = this._createElement('span', 'label');
-
-    if (addNamesInLabel) {
-      nameSpan = this._createElement('span', 'name');
-      nameSpan.textContent = this._baseImage?._name ?? '';
-      label.appendChild(nameSpan);
-      label.appendChild(this._createElement('br'));
-    }
-
-    this._setLabelText(labelSpan, this._baseImage);
-
-    label.appendChild(labelSpan);
-    td.appendChild(label);
-    tr.appendChild(td);
-
-    tr.appendChild(this._createElement('td', 'right lineNum blank'));
-    td = this._createElement('td', 'right');
-    label = this._createElement('label');
-    labelSpan = this._createElement('span', 'label');
-
-    if (addNamesInLabel) {
-      nameSpan = this._createElement('span', 'name');
-      nameSpan.textContent = this._revisionImage?._name ?? '';
-      label.appendChild(nameSpan);
-      label.appendChild(this._createElement('br'));
-    }
-
-    this._setLabelText(labelSpan, this._revisionImage);
-
-    label.appendChild(labelSpan);
-    td.appendChild(label);
-    tr.appendChild(td);
-
-    section.appendChild(tr);
-  }
-
-  override updateRenderPrefs(renderPrefs: RenderPreferences) {
-    const imageViewer = this._outputEl.querySelector(
-      'gr-image-viewer'
-    ) as GrImageViewer;
-    if (this._useNewImageDiffUi && imageViewer) {
-      imageViewer.automaticBlink =
-        !!renderPrefs?.image_diff_prefs?.automatic_blink;
-    }
-  }
-}
-
-function _getImageLabel(image: ImageInfo | null) {
-  if (image) {
-    const type = image.type ?? image._expectedType;
-    if (image._width && image._height) {
-      return `${image._width}×${image._height} ${type}`;
-    } else {
-      return type;
-    }
-  }
-  return 'No image';
-}
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
deleted file mode 100644
index bd7dc29..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ /dev/null
@@ -1,142 +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 {GrDiffBuilder} from './gr-diff-builder';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-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,
-    layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    super(diff, prefs, outputEl, layers, renderPrefs);
-  }
-
-  _getMoveControlsConfig() {
-    return {
-      numberOfCells: 4,
-      movedOutIndex: 1,
-      movedInIndex: 3,
-      lineNumberCols: [0, 2],
-    };
-  }
-
-  buildSectionElement(group: GrDiffGroup) {
-    const sectionEl = this._createElement('tbody', 'section');
-    sectionEl.classList.add(group.type);
-    if (this._isTotal(group)) {
-      sectionEl.classList.add('total');
-    }
-    if (group.dueToRebase) {
-      sectionEl.classList.add('dueToRebase');
-    }
-    if (group.moveDetails) {
-      sectionEl.classList.add('dueToMove');
-      sectionEl.appendChild(this._buildMoveControls(group));
-    }
-    if (group.ignoredWhitespaceOnly) {
-      sectionEl.classList.add('ignoredWhitespaceOnly');
-    }
-    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      this._createContextControls(
-        sectionEl,
-        group.contextGroups,
-        DiffViewMode.SIDE_BY_SIDE
-      );
-      return sectionEl;
-    }
-
-    const pairs = group.getSideBySidePairs();
-    for (let i = 0; i < pairs.length; i++) {
-      sectionEl.appendChild(this._createRow(pairs[i].left, pairs[i].right));
-    }
-    return sectionEl;
-  }
-
-  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
-    const colgroup = document.createElement('colgroup');
-
-    // Add the blame column.
-    let col = this._createElement('col', 'blame');
-    colgroup.appendChild(col);
-
-    // Add left-side line number.
-    col = this._createElement('col', 'left');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    // Add left-side content.
-    colgroup.appendChild(this._createElement('col', 'left'));
-
-    // Add right-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    // Add right-side content.
-    colgroup.appendChild(document.createElement('col'));
-
-    outputEl.appendChild(colgroup);
-  }
-
-  _createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
-    const row = this._createElement('tr');
-    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));
-
-    this._appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
-    this._appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
-    return row;
-  }
-
-  _appendPair(
-    row: HTMLElement,
-    line: GrDiffLine,
-    lineNumber: LineNumber,
-    side: Side
-  ) {
-    const lineNumberEl = this._createLineEl(line, lineNumber, line.type, side);
-    row.appendChild(lineNumberEl);
-    row.appendChild(this._createTextEl(lineNumberEl, line, side));
-  }
-
-  _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
-    let tr: HTMLElement = content.parentElement!.parentElement!;
-    while ((tr = tr.nextSibling as HTMLElement)) {
-      const nextContent = tr.querySelector(
-        'td.content .contentText[data-side="' + side + '"]'
-      );
-      if (nextContent) return nextContent as HTMLElement;
-    }
-    return null;
-  }
-
-  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
deleted file mode 100644
index a7b3a42..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ /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 {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/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,
-    layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    super(diff, prefs, outputEl, layers, renderPrefs);
-  }
-
-  _getMoveControlsConfig() {
-    return {
-      numberOfCells: 3,
-      movedOutIndex: 2,
-      movedInIndex: 2,
-      lineNumberCols: [0, 1],
-    };
-  }
-
-  buildSectionElement(group: GrDiffGroup): HTMLElement {
-    const sectionEl = this._createElement('tbody', 'section');
-    sectionEl.classList.add(group.type);
-    if (this._isTotal(group)) {
-      sectionEl.classList.add('total');
-    }
-    if (group.dueToRebase) {
-      sectionEl.classList.add('dueToRebase');
-    }
-    if (group.moveDetails) {
-      sectionEl.classList.add('dueToMove');
-      sectionEl.appendChild(this._buildMoveControls(group));
-    }
-    if (group.ignoredWhitespaceOnly) {
-      sectionEl.classList.add('ignoredWhitespaceOnly');
-    }
-    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      this._createContextControls(
-        sectionEl,
-        group.contextGroups,
-        DiffViewMode.UNIFIED
-      );
-      return sectionEl;
-    }
-
-    for (let i = 0; i < group.lines.length; ++i) {
-      const line = group.lines[i];
-      // If only whitespace has changed and the settings ask for whitespace to
-      // be ignored, only render the right-side line in unified diff mode.
-      if (group.ignoredWhitespaceOnly && line.type === GrDiffLineType.REMOVE) {
-        continue;
-      }
-      sectionEl.appendChild(this._createRow(line));
-    }
-    return sectionEl;
-  }
-
-  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
-    const colgroup = document.createElement('colgroup');
-
-    // Add the blame column.
-    let col = this._createElement('col', 'blame');
-    colgroup.appendChild(col);
-
-    // Add left-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    // Add right-side line number.
-    col = document.createElement('col');
-    col.setAttribute('width', lineNumberWidth.toString());
-    colgroup.appendChild(col);
-
-    // Add the content.
-    colgroup.appendChild(document.createElement('col'));
-
-    outputEl.appendChild(colgroup);
-  }
-
-  _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(
-      line,
-      line.beforeNumber,
-      GrDiffLineType.REMOVE,
-      Side.LEFT
-    );
-    row.appendChild(lineNumberEl);
-    lineNumberEl = this._createLineEl(
-      line,
-      line.afterNumber,
-      GrDiffLineType.ADD,
-      Side.RIGHT
-    );
-    row.appendChild(lineNumberEl);
-    let side = undefined;
-    if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
-      side = Side.RIGHT;
-    }
-    if (line.type === GrDiffLineType.REMOVE) {
-      side = Side.LEFT;
-    }
-    row.appendChild(this._createTextEl(lineNumberEl, line, side));
-    return row;
-  }
-
-  _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
-    let tr: HTMLElement = content.parentElement!.parentElement!;
-    while ((tr = tr.nextSibling as HTMLElement)) {
-      if (
-        tr.classList.contains('both') ||
-        (side === 'left' && tr.classList.contains('remove')) ||
-        (side === 'right' && tr.classList.contains('add'))
-      ) {
-        return tr.querySelector('.contentText');
-      }
-    }
-    return null;
-  }
-
-  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
deleted file mode 100644
index 2be85c3..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ /dev/null
@@ -1,242 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-group.js';
-import './gr-diff-builder.js';
-import './gr-diff-builder-unified.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
-
-suite('GrDiffBuilderUnified tests', () => {
-  let prefs;
-  let outputEl;
-  let diffBuilder;
-
-  setup(()=> {
-    prefs = {
-      line_length: 10,
-      show_tabs: true,
-      tab_size: 4,
-    };
-    outputEl = document.createElement('div');
-    diffBuilder = new GrDiffBuilderUnified({}, prefs, outputEl, []);
-  });
-
-  suite('buildSectionElement for BOTH group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
-        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
-        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World";';
-      lines[2].text = '  return True';
-
-      group = new GrDiffGroup(GrDiffGroupType.BOTH, lines);
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('both'));
-    });
-
-    test('creates each unchanged row once', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 3);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[0].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[1].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.left').textContent,
-          lines[2].beforeNumber);
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-    });
-  });
-
-  suite('buildSectionElement for moved chunks', () => {
-    test('creates a moved out group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 15),
-        new GrDiffLine(GrDiffLineType.REMOVE, 16),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved out');
-    });
-
-    test('creates a moved in group', () => {
-      const lines = [
-        new GrDiffLine(GrDiffLineType.ADD, 37),
-        new GrDiffLine(GrDiffLineType.ADD, 38),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
-      group.moveDetails = {changed: false};
-
-      const sectionEl = diffBuilder.buildSectionElement(group);
-
-      const rowEls = sectionEl.querySelectorAll('tr');
-      const moveControlsRow = rowEls[0];
-      const cells = moveControlsRow.querySelectorAll('td');
-      assert.isTrue(sectionEl.classList.contains('dueToMove'));
-      assert.equal(rowEls.length, 3);
-      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
-      assert.equal(cells.length, 3);
-      assert.isTrue(cells[2].classList.contains('moveHeader'));
-      assert.equal(cells[2].textContent, 'Moved in');
-    });
-  });
-
-  suite('buildSectionElement for DELTA group', () => {
-    let lines;
-    let group;
-
-    setup(() => {
-      lines = [
-        new GrDiffLine(GrDiffLineType.REMOVE, 1),
-        new GrDiffLine(GrDiffLineType.REMOVE, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 2),
-        new GrDiffLine(GrDiffLineType.ADD, 3),
-      ];
-      lines[0].text = 'def hello_world():';
-      lines[1].text = '  print "Hello World"';
-      lines[2].text = 'def hello_universe()';
-      lines[3].text = '  print "Hello Universe"';
-
-      group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
-    });
-
-    test('creates the section', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('section'));
-      assert.isTrue(sectionEl.classList.contains('delta'));
-    });
-
-    test('creates the section with class if ignoredWhitespaceOnly', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
-    });
-
-    test('creates the section with class if dueToRebase', () => {
-      group.dueToRebase = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
-    });
-
-    test('creates first the removed and then the added rows', () => {
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 4);
-
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.left').textContent,
-          lines[0].beforeNumber);
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[0].text);
-
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.left').textContent,
-          lines[1].beforeNumber);
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[1].text);
-
-      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[2].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[2].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[3].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[3].querySelector('.content').textContent, lines[3].text);
-    });
-
-    test('creates only the added rows if only ignored whitespace', () => {
-      group.ignoredWhitespaceOnly = true;
-      const sectionEl = diffBuilder.buildSectionElement(group);
-      const rowEls = sectionEl.querySelectorAll('.diff-row');
-
-      assert.equal(rowEls.length, 2);
-
-      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[0].querySelector('.lineNum.right').textContent,
-          lines[2].afterNumber);
-      assert.equal(
-          rowEls[0].querySelector('.content').textContent, lines[2].text);
-
-      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
-      assert.equal(
-          rowEls[1].querySelector('.lineNum.right').textContent,
-          lines[3].afterNumber);
-      assert.equal(
-          rowEls[1].querySelector('.content').textContent, lines[3].text);
-    });
-  });
-});
-
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
deleted file mode 100644
index 663ee7e..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ /dev/null
@@ -1,924 +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 {
-  ContentLoadNeededEventDetail,
-  DiffContextExpandedExternalDetail,
-  MovedLinkClickedEventDetail,
-  RenderPreferences,
-} from '../../../api/diff';
-import {getBaseUrl} from '../../../utils/url-util';
-import {fire} from '../../../utils/event-util';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-
-import '../gr-context-controls/gr-context-controls';
-import {
-  GrContextControls,
-  GrContextControlsShowConfig,
-} from '../gr-context-controls/gr-context-controls';
-import {BlameInfo} from '../../../types/common';
-import {
-  DiffInfo,
-  DiffPreferencesInfo,
-  DiffResponsiveMode,
-} from '../../../types/diff';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-
-/**
- * In JS, unicode code points above 0xFFFF occupy two elements of a string.
- * 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
- * these are two cases where '1 char' does not automatically imply '1 column'.
- *
- * TODO: For human languages whose orthographies use combining marks, this
- * approach won't correctly identify the grapheme boundaries. In those cases,
- * a grapheme consists of multiple code points that should count as only one
- * character against the column limit. Getting that correct (if it's desired)
- * is probably beyond the limits of a regex, but there are nonstandard APIs to
- * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
- *
- * Further reading:
- *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
- *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
- *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
- */
-const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
-
-export interface DiffContextExpandedEventDetail
-  extends DiffContextExpandedExternalDetail {
-  groups: GrDiffGroup[];
-  section: HTMLElement;
-  numLines: number;
-}
-
-declare global {
-  interface HTMLElementEventMap {
-    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
-    'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
-  }
-}
-
-export function getResponsiveMode(
-  prefs: DiffPreferencesInfo,
-  renderPrefs?: RenderPreferences
-): DiffResponsiveMode {
-  if (renderPrefs?.responsive_mode) {
-    return renderPrefs.responsive_mode;
-  }
-  // Backwards compatibility to the line_wrapping param.
-  if (prefs.line_wrapping) {
-    return 'FULL_RESPONSIVE';
-  }
-  return 'NONE';
-}
-
-export function isResponsive(responsiveMode: DiffResponsiveMode) {
-  return (
-    responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY'
-  );
-}
-
-export abstract class GrDiffBuilder {
-  private readonly _diff: DiffInfo;
-
-  private readonly _numLinesLeft: number;
-
-  private readonly _prefs: DiffPreferencesInfo;
-
-  protected readonly _renderPrefs?: RenderPreferences;
-
-  protected readonly _outputEl: HTMLElement;
-
-  readonly groups: GrDiffGroup[];
-
-  private blameInfo: BlameInfo[] | null;
-
-  private readonly _layerUpdateListener: (
-    start: LineNumber,
-    end: LineNumber,
-    side: Side
-  ) => void;
-
-  constructor(
-    diff: DiffInfo,
-    prefs: DiffPreferencesInfo,
-    outputEl: HTMLElement,
-    readonly layers: DiffLayer[] = [],
-    renderPrefs?: RenderPreferences
-  ) {
-    this._diff = diff;
-    this._numLinesLeft = this._diff.content
-      ? this._diff.content.reduce((sum, chunk) => {
-          const left = chunk.a || chunk.ab;
-          return sum + (left?.length || chunk.skip || 0);
-        }, 0)
-      : 0;
-    this._prefs = prefs;
-    this._renderPrefs = renderPrefs;
-    this._outputEl = outputEl;
-    this.groups = [];
-    this.blameInfo = null;
-
-    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
-      throw Error('Invalid tab size from preferences.');
-    }
-
-    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
-      throw Error('Invalid line length from preferences.');
-    }
-
-    this._layerUpdateListener = (
-      start: LineNumber,
-      end: LineNumber,
-      side: Side
-    ) => this._handleLayerUpdate(start, end, side);
-    for (const layer of this.layers) {
-      if (layer.addListener) {
-        layer.addListener(this._layerUpdateListener);
-      }
-    }
-  }
-
-  clear() {
-    for (const layer of this.layers) {
-      if (layer.removeListener) {
-        layer.removeListener(this._layerUpdateListener);
-      }
-    }
-  }
-
-  // TODO(TS): Convert to enum.
-  static readonly GroupType = {
-    ADDED: 'b',
-    BOTH: 'ab',
-    REMOVED: 'a',
-  };
-
-  // TODO(TS): Convert to enum.
-  static readonly Highlights = {
-    ADDED: 'edit_b',
-    REMOVED: 'edit_a',
-  };
-
-  abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
-
-  abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
-
-  emitGroup(group: GrDiffGroup, beforeSection: HTMLElement | null) {
-    const element = this.buildSectionElement(group);
-    this._outputEl.insertBefore(element, beforeSection);
-    group.element = element;
-  }
-
-  getGroupsByLineRange(
-    startLine: LineNumber,
-    endLine: LineNumber,
-    side?: Side
-  ) {
-    const groups = [];
-    for (let i = 0; i < this.groups.length; i++) {
-      const group = this.groups[i];
-      if (group.lines.length === 0) {
-        continue;
-      }
-      let groupStartLine = 0;
-      let groupEndLine = 0;
-      if (side) {
-        const range =
-          side === Side.LEFT ? group.lineRange.left : group.lineRange.right;
-        groupStartLine = range.start_line;
-        groupEndLine = range.end_line;
-      }
-
-      if (groupStartLine === 0) {
-        // Line was removed or added.
-        groupStartLine = groupEndLine;
-      }
-      if (groupEndLine === 0) {
-        // Line was removed or added.
-        groupEndLine = groupStartLine;
-      }
-      if (startLine <= groupEndLine && endLine >= groupStartLine) {
-        groups.push(group);
-      }
-    }
-    return groups;
-  }
-
-  getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root: Element = this._outputEl
-  ): Element | null {
-    const sideSelector: string = side ? `.${side}` : '';
-    return root.querySelector(
-      `td.lineNum[data-value="${lineNumber}"]${sideSelector} ~ td.content`
-    );
-  }
-
-  getContentByLine(
-    lineNumber: LineNumber,
-    side?: Side,
-    root?: HTMLElement
-  ): HTMLElement | null {
-    const td = this.getContentTdByLine(lineNumber, side, root);
-    return td ? td.querySelector('.contentText') : null;
-  }
-
-  /**
-   * Find line elements or line objects by a range of line numbers and a side.
-   *
-   * @param start The first line number
-   * @param end The last line number
-   * @param side The side of the range. Either 'left' or 'right'.
-   * @param out_lines The output list of line objects. Use null if not desired.
-   * @param out_elements The output list of line elements. Use null if not
-   *        desired.
-   */
-  findLinesByRange(
-    start: LineNumber,
-    end: LineNumber,
-    side: Side,
-    out_lines: GrDiffLine[] | null,
-    out_elements: HTMLElement[] | null
-  ) {
-    const groups = this.getGroupsByLineRange(start, end, side);
-    for (const group of groups) {
-      let content: HTMLElement | null = null;
-      for (const line of group.lines) {
-        if (
-          (side === 'left' && line.type === GrDiffLineType.ADD) ||
-          (side === 'right' && line.type === GrDiffLineType.REMOVE)
-        ) {
-          continue;
-        }
-        const lineNumber =
-          side === 'left' ? line.beforeNumber : line.afterNumber;
-        if (lineNumber < start || lineNumber > end) {
-          continue;
-        }
-
-        if (out_lines) {
-          out_lines.push(line);
-        }
-        if (out_elements) {
-          if (content) {
-            content = this._getNextContentOnSide(content, side);
-          } else {
-            content = this.getContentByLine(lineNumber, side, group.element);
-          }
-          if (content) {
-            out_elements.push(content);
-          }
-        }
-      }
-    }
-  }
-
-  /**
-   * Re-renders the DIV.contentText elements for the given side and range of
-   * diff content.
-   */
-  _renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
-    const lines: GrDiffLine[] = [];
-    const elements: HTMLElement[] = [];
-    let line;
-    let el;
-    this.findLinesByRange(start, end, side, lines, elements);
-    for (let i = 0; i < lines.length; i++) {
-      line = lines[i];
-      el = elements[i];
-      if (!el || !el.parentElement) {
-        // Cannot re-render an element if it does not exist. This can happen
-        // if lines are collapsed and not visible on the page yet.
-        continue;
-      }
-      const lineNumberEl = this._getLineNumberEl(el, side);
-      el.parentElement.replaceChild(
-        this._createTextEl(lineNumberEl, line, side).firstChild!,
-        el
-      );
-    }
-  }
-
-  getSectionsByLineRange(
-    startLine: LineNumber,
-    endLine: LineNumber,
-    side: Side
-  ) {
-    return this.getGroupsByLineRange(startLine, endLine, side).map(
-      group => group.element
-    );
-  }
-
-  _createContextControls(
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    viewMode: DiffViewMode
-  ) {
-    const leftStart = contextGroups[0].lineRange.left.start_line;
-    const leftEnd =
-      contextGroups[contextGroups.length - 1].lineRange.left.end_line;
-    const firstGroupIsSkipped = !!contextGroups[0].skip;
-    const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
-
-    const containsWholeFile = this._numLinesLeft === leftEnd - leftStart + 1;
-    const showAbove =
-      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
-    const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
-
-    if (showAbove) {
-      const paddingRow = this._createContextControlPaddingRow(viewMode);
-      paddingRow.classList.add('above');
-      section.appendChild(paddingRow);
-    }
-    section.appendChild(
-      this._createContextControlRow(
-        section,
-        contextGroups,
-        showAbove,
-        showBelow,
-        viewMode
-      )
-    );
-    if (showBelow) {
-      const paddingRow = this._createContextControlPaddingRow(viewMode);
-      paddingRow.classList.add('below');
-      section.appendChild(paddingRow);
-    }
-  }
-
-  /**
-   * Creates context controls. Buttons extend from the gap created by this
-   * method up or down into the area of code that they affect.
-   */
-  _createContextControlRow(
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    showAbove: boolean,
-    showBelow: boolean,
-    viewMode: DiffViewMode
-  ): HTMLElement {
-    const row = this._createElement('tr', 'dividerRow');
-    let showConfig: GrContextControlsShowConfig;
-    if (showAbove && !showBelow) {
-      showConfig = 'above';
-    } else if (!showAbove && showBelow) {
-      showConfig = 'below';
-    } else {
-      // Note that !showAbove && !showBelow also intentionally creates
-      // "show-both". This means the file is completely collapsed, which is
-      // unusual, but at least happens in one test.
-      showConfig = 'both';
-    }
-    row.classList.add(`show-${showConfig}`);
-
-    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 contextControls = this._createElement(
-      'gr-context-controls'
-    ) as GrContextControls;
-    contextControls.diff = this._diff;
-    contextControls.renderPreferences = this._renderPrefs;
-    contextControls.section = section;
-    contextControls.contextGroups = contextGroups;
-    contextControls.showConfig = showConfig;
-    cell.appendChild(contextControls);
-    return row;
-  }
-
-  /**
-   * Creates a table row to serve as padding between code and context controls.
-   * Blame column, line gutters, and content area will continue visually, but
-   * context controls can render over this background to map more clearly to
-   * the area of code they expand.
-   */
-  _createContextControlPaddingRow(viewMode: DiffViewMode) {
-    const row = this._createElement('tr', 'contextBackground');
-
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.classList.add('side-by-side');
-      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
-      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
-    } else {
-      row.classList.add('unified');
-    }
-
-    row.appendChild(this._createBlameCell(0));
-    row.appendChild(this._createElement('td', 'contextLineNum'));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(this._createElement('td'));
-    }
-    row.appendChild(this._createElement('td', 'contextLineNum'));
-    row.appendChild(this._createElement('td'));
-
-    return row;
-  }
-
-  _createLineEl(
-    line: GrDiffLine,
-    number: LineNumber,
-    type: GrDiffLineType,
-    side: Side
-  ) {
-    const td = this._createElement('td');
-    td.classList.add(side);
-    if (line.type === GrDiffLineType.BLANK) {
-      return td;
-    }
-    if (line.type === GrDiffLineType.BOTH || line.type === type) {
-      td.classList.add('lineNum');
-      td.dataset['value'] = number.toString();
-
-      if (
-        ((this._prefs.show_file_comment_button === false ||
-          this._renderPrefs?.show_file_comment_button === false) &&
-          number === 'FILE') ||
-        number === 'LOST'
-      ) {
-        return td;
-      }
-
-      const button = this._createElement('button');
-      td.appendChild(button);
-      button.tabIndex = -1;
-      button.classList.add('lineNumButton');
-      button.classList.add(side);
-      button.dataset['value'] = number.toString();
-      button.textContent = number === 'FILE' ? 'File' : number.toString();
-      if (number === 'FILE') {
-        button.setAttribute('aria-label', 'Add file comment');
-      }
-
-      // Add aria-labels for valid line numbers.
-      // For unified diff, this method will be called with number set to 0 for
-      // the empty line number column for added/removed lines. This should not
-      // be announced to the screenreader.
-      if (number > 0) {
-        if (line.type === GrDiffLineType.REMOVE) {
-          button.setAttribute('aria-label', `${number} removed`);
-        } else if (line.type === GrDiffLineType.ADD) {
-          button.setAttribute('aria-label', `${number} added`);
-        }
-      }
-      this._addLineNumberMouseEvents(td, number, side);
-    }
-    return td;
-  }
-
-  _addLineNumberMouseEvents(el: HTMLElement, number: LineNumber, side: Side) {
-    el.addEventListener('mouseenter', () => {
-      fire(el, 'line-mouse-enter', {lineNum: number, side});
-    });
-    el.addEventListener('mouseleave', () => {
-      fire(el, 'line-mouse-leave', {lineNum: number, side});
-    });
-  }
-
-  _createTextEl(
-    lineNumberEl: HTMLElement | null,
-    line: GrDiffLine,
-    side?: Side
-  ) {
-    const td = this._createElement('td');
-    if (line.type !== GrDiffLineType.BLANK) {
-      td.classList.add('content');
-    }
-
-    // If intraline info is not available, the entire line will be
-    // considered as changed and marked as dark red / green color
-    if (!line.hasIntralineInfo) {
-      td.classList.add('no-intraline-info');
-    }
-    td.classList.add(line.type);
-
-    const {beforeNumber, afterNumber} = line;
-    if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
-      const responsiveMode = getResponsiveMode(this._prefs, this._renderPrefs);
-      const contentText = this._formatText(
-        line.text,
-        responsiveMode,
-        this._prefs.tab_size,
-        this._prefs.line_length
-      );
-
-      if (side) {
-        contentText.setAttribute('data-side', side);
-        const number = side === Side.LEFT ? beforeNumber : afterNumber;
-        this._addLineNumberMouseEvents(td, number, side);
-      }
-
-      if (lineNumberEl && side) {
-        for (const layer of this.layers) {
-          if (typeof layer.annotate === 'function') {
-            layer.annotate(contentText, lineNumberEl, line, side);
-          }
-        }
-      } else {
-        console.error('lineNumberEl or side not set, skipping layer.annotate');
-      }
-
-      td.appendChild(contentText);
-    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
-    else if (line.beforeNumber === 'LOST') td.classList.add('lost');
-
-    return td;
-  }
-
-  private createLineBreak(responsive: boolean) {
-    return responsive
-      ? this._createElement('wbr')
-      : this._createElement('span', 'br');
-  }
-
-  /**
-   * Returns a 'div' element containing the supplied |text| as its innerText,
-   * with '\t' characters expanded to a width determined by |tabSize|, and the
-   * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
-   * desired.
-   *
-   * @param text The text to be formatted.
-   * @param tabSize The width of each tab stop.
-   * @param lineLimit The column after which to wrap lines.
-   */
-  _formatText(
-    text: string,
-    responsiveMode: DiffResponsiveMode,
-    tabSize: number,
-    lineLimit: number
-  ): HTMLElement {
-    const contentText = this._createElement('div', 'contentText');
-    contentText.ariaLabel = text;
-    const responsive = isResponsive(responsiveMode);
-    let columnPos = 0;
-    let textOffset = 0;
-    for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
-      if (segment) {
-        // |segment| contains only normal characters. If |segment| doesn't fit
-        // entirely on the current line, append chunks of |segment| followed by
-        // line breaks.
-        let rowStart = 0;
-        let rowEnd = lineLimit - columnPos;
-        while (rowEnd < segment.length) {
-          contentText.appendChild(
-            document.createTextNode(segment.substring(rowStart, rowEnd))
-          );
-          contentText.appendChild(this.createLineBreak(responsive));
-          columnPos = 0;
-          rowStart = rowEnd;
-          rowEnd += lineLimit;
-        }
-        // Append the last part of |segment|, which fits on the current line.
-        contentText.appendChild(
-          document.createTextNode(segment.substring(rowStart))
-        );
-        columnPos += segment.length - rowStart;
-        textOffset += segment.length;
-      }
-      if (textOffset < text.length) {
-        // Handle the special character at |textOffset|.
-        if (text.startsWith('\t', textOffset)) {
-          // Append a single '\t' character.
-          let effectiveTabSize = tabSize - (columnPos % tabSize);
-          if (columnPos + effectiveTabSize > lineLimit) {
-            contentText.appendChild(this.createLineBreak(responsive));
-            columnPos = 0;
-            effectiveTabSize = tabSize;
-          }
-          contentText.appendChild(this._getTabWrapper(effectiveTabSize));
-          columnPos += effectiveTabSize;
-          textOffset++;
-        } else {
-          // Append a single surrogate pair.
-          if (columnPos >= lineLimit) {
-            contentText.appendChild(this.createLineBreak(responsive));
-            columnPos = 0;
-          }
-          contentText.appendChild(
-            document.createTextNode(text.substring(textOffset, textOffset + 2))
-          );
-          textOffset += 2;
-          columnPos += 1;
-        }
-      }
-    }
-    return contentText;
-  }
-
-  /**
-   * Returns a <span> element holding a '\t' character, that will visually
-   * occupy |tabSize| many columns.
-   *
-   * @param tabSize The effective size of this tab stop.
-   */
-  _getTabWrapper(tabSize: number): HTMLElement {
-    // Force this to be a number to prevent arbitrary injection.
-    const result = this._createElement('span', 'tab');
-    result.setAttribute(
-      'style',
-      `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};`
-    );
-    result.innerText = '\t';
-    return result;
-  }
-
-  _createElement(tagName: string, classStr?: string): HTMLElement {
-    const el = document.createElement(tagName);
-    // When Shady DOM is being used, these classes are added to account for
-    // Polymer's polyfill behavior. In order to guarantee sufficient
-    // specificity within the CSS rules, these are added to every element.
-    // Since the Polymer DOM utility functions (which would do this
-    // automatically) are not being used for performance reasons, this is
-    // done manually.
-    el.classList.add('style-scope', 'gr-diff');
-    if (classStr) {
-      for (const className of classStr.split(' ')) {
-        el.classList.add(className);
-      }
-    }
-    return el;
-  }
-
-  _handleLayerUpdate(start: LineNumber, end: LineNumber, side: Side) {
-    this._renderContentByRange(start, end, side);
-  }
-
-  /**
-   * Finds the next DIV.contentText element following the given element, and on
-   * the same side. Will only search within a group.
-   */
-  abstract _getNextContentOnSide(
-    content: HTMLElement,
-    side: Side
-  ): HTMLElement | null;
-
-  /**
-   * Gets configuration for creating move controls for chunks marked with
-   * dueToMove
-   */
-  abstract _getMoveControlsConfig(): {
-    numberOfCells: number;
-    movedOutIndex: number;
-    movedInIndex: number;
-    lineNumberCols: number[];
-  };
-
-  /**
-   * Determines whether the given group is either totally an addition or totally
-   * a removal.
-   */
-  _isTotal(group: GrDiffGroup): boolean {
-    return (
-      group.type === GrDiffGroupType.DELTA &&
-      (!group.adds.length || !group.removes.length) &&
-      !(!group.adds.length && !group.removes.length)
-    );
-  }
-
-  /**
-   * Set the blame information for the diff. For any already-rendered line,
-   * re-render its blame cell content.
-   */
-  setBlame(blame: BlameInfo[] | null) {
-    this.blameInfo = blame;
-    if (!blame) return;
-
-    // TODO(wyatta): make this loop asynchronous.
-    for (const commit of blame) {
-      for (const range of commit.ranges) {
-        for (let i = range.start; i <= range.end; i++) {
-          // TODO(wyatta): this query is expensive, but, when traversing a
-          // range, the lines are consecutive, and given the previous blame
-          // cell, the next one can be reached cheaply.
-          const el = this._getBlameByLineNum(i);
-          if (!el) {
-            continue;
-          }
-          // Remove the element's children (if any).
-          while (el.hasChildNodes()) {
-            el.removeChild(el.lastChild!);
-          }
-          const blame = this._getBlameForBaseLine(i, commit);
-          if (blame) el.appendChild(blame);
-        }
-      }
-    }
-  }
-
-  _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 {numberOfCells, movedOutIndex, movedInIndex, lineNumberCols} =
-      this._getMoveControlsConfig();
-
-    let controlsClass;
-    let descriptionIndex;
-    const descriptionTextDiv = this._createMoveDescriptionDiv(movedIn, group);
-    if (movedIn) {
-      controlsClass = 'movedIn';
-      descriptionIndex = movedInIndex;
-    } else {
-      controlsClass = 'movedOut';
-      descriptionIndex = movedOutIndex;
-    }
-
-    const controls = this._createElement('tr', `moveControls ${controlsClass}`);
-    const cells = [...Array(numberOfCells).keys()].map(() =>
-      this._createElement('td')
-    );
-    lineNumberCols.forEach(index => {
-      cells[index].classList.add('moveControlsLineNumCol');
-    });
-
-    const moveRangeHeader = this._createElement('gr-range-header');
-    moveRangeHeader.setAttribute('icon', 'gr-icons:move-item');
-    moveRangeHeader.appendChild(descriptionTextDiv);
-    cells[descriptionIndex].classList.add('moveHeader');
-    cells[descriptionIndex].appendChild(moveRangeHeader);
-    cells.forEach(c => {
-      controls.appendChild(c);
-    });
-    return controls;
-  }
-
-  /**
-   * Find the blame cell for a given line number.
-   */
-  _getBlameByLineNum(lineNum: number): Element | null {
-    return this._outputEl.querySelector(
-      `td.blame[data-line-number="${lineNum}"]`
-    );
-  }
-
-  /**
-   * Given a base line number, return the commit containing that line in the
-   * current set of blame information. If no blame information has been
-   * provided, null is returned.
-   *
-   * @return The commit information.
-   */
-  _getBlameCommitForBaseLine(lineNum: LineNumber) {
-    if (!this.blameInfo) {
-      return null;
-    }
-
-    for (const blameCommit of this.blameInfo) {
-      for (const range of blameCommit.ranges) {
-        if (range.start <= lineNum && range.end >= lineNum) {
-          return blameCommit;
-        }
-      }
-    }
-    return null;
-  }
-
-  /**
-   * Given the number of a base line, get the content for the blame cell of that
-   * line. If there is no blame information for that line, returns null.
-   *
-   * @param commit Optionally provide the commit object, so that
-   *     it does not need to be searched.
-   */
-  _getBlameForBaseLine(
-    lineNum: LineNumber,
-    commit: BlameInfo | null = this._getBlameCommitForBaseLine(lineNum)
-  ): HTMLElement | null {
-    if (!commit) {
-      return null;
-    }
-
-    const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
-
-    const date = new Date(commit.time * 1000).toLocaleDateString();
-    const blameNode = this._createElement(
-      'span',
-      isStartOfRange ? 'startOfRange' : ''
-    );
-
-    const shaNode = this._createElement('a', 'blameDate');
-    shaNode.innerText = `${date}`;
-    shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`);
-    blameNode.appendChild(shaNode);
-
-    const shortName = commit.author.split(' ')[0];
-    const authorNode = this._createElement('span', 'blameAuthor');
-    authorNode.innerText = ` ${shortName}`;
-    blameNode.appendChild(authorNode);
-
-    const hoverCardFragment = this._createElement('span', 'blameHoverCard');
-    hoverCardFragment.innerText = `Commit ${commit.id}
-Author: ${commit.author}
-Date: ${date}
-
-${commit.commit_msg}`;
-    const hovercard = this._createElement('gr-hovercard');
-    hovercard.appendChild(hoverCardFragment);
-    blameNode.appendChild(hovercard);
-
-    return blameNode;
-  }
-
-  /**
-   * Create a blame cell for the given base line. Blame information will be
-   * included in the cell if available.
-   */
-  _createBlameCell(lineNumber: LineNumber): HTMLTableDataCellElement {
-    const blameTd = this._createElement(
-      'td',
-      'blame'
-    ) as HTMLTableDataCellElement;
-    blameTd.setAttribute('data-line-number', lineNumber.toString());
-    if (lineNumber) {
-      const content = this._getBlameForBaseLine(lineNumber);
-      if (content) {
-        blameTd.appendChild(content);
-      }
-    }
-    return blameTd;
-  }
-
-  /**
-   * Finds the line number element given the content element by walking up the
-   * DOM tree to the diff row and then querying for a .lineNum element on the
-   * requested side.
-   *
-   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
-   */
-  _getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
-    let row: HTMLElement | null = content;
-    while (row && !row.classList.contains('diff-row')) row = row.parentElement;
-    return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
-  }
-
-  updateRenderPrefs(_renderPrefs: RenderPreferences) {}
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
deleted file mode 100644
index 54b2450f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
+++ /dev/null
@@ -1,352 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {DiffLayer, DiffLayerListener} from '../../../types/types';
-import {GrDiffLine, Side, TokenHighlightListener} from '../../../api/diff';
-import {assertIsDefined} from '../../../utils/common-util';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-
-import {
-  getLineElByChild,
-  getSideByLineEl,
-  getPreviousContentNodes,
-} from '../gr-diff/gr-diff-utils';
-
-import {
-  getLineNumberByChild,
-  lineNumberToNumber,
-} from '../gr-diff/gr-diff-utils';
-
-const tokenMatcher = new RegExp(/[\w]+/g);
-
-/** CSS class for all tokens. */
-const CSS_TOKEN = 'token';
-
-/** CSS class for the currently hovered token. */
-const CSS_HIGHLIGHT = 'token-highlight';
-
-export const HOVER_DELAY_MS = 200;
-
-const LINE_LENGTH_LIMIT = 500;
-
-const TOKEN_LENGTH_LIMIT = 100;
-
-const TOKEN_COUNT_LIMIT = 10000;
-
-const TOKEN_OCCURRENCES_LIMIT = 1000;
-
-/**
- * Token highlighting is only useful for code on-screen, so we only highlight
- * the nearest set of tokens up to this limit.
- */
-const TOKEN_HIGHLIGHT_LIMIT = 100;
-
-/**
- * When a user hovers over a token in the diff, then this layer makes sure that
- * all occurrences of this token are annotated with the 'token-highlight' css
- * class. And removes that class when the user moves the mouse away from the
- * token.
- *
- * The layer does not react to mouse events directly by adding a css class to
- * the appropriate elements, but instead it just sets the currently highlighted
- * token and notifies the diff renderer that certain lines must be re-rendered.
- * And when that re-rendering happens the appropriate css class is added.
- */
-export class TokenHighlightLayer implements DiffLayer {
-  /** The only listener is typically the renderer of gr-diff. */
-  private listeners: DiffLayerListener[] = [];
-
-  /** The currently highlighted token. */
-  private currentHighlight?: string;
-
-  /** Trigger when a new token starts or stoped being highlighted.*/
-  private readonly tokenHighlightListener?: TokenHighlightListener;
-
-  /**
-   * The line of the currently highlighted token. We store this in order to
-   * re-render only relevant lines of the diff. Only lines visible on the screen
-   * need a highlight. For example in a file with 10,000 lines it is sufficient
-   * to just re-render the ~100 lines that are visible to the user.
-   *
-   * It is a known issue that we are only storing the line number on the side of
-   * where the user is hovering and we use that also to determine which line
-   * numbers to re-render on the other side, but it is non-trivial to look up or
-   * store a reliable mapping of line numbers, so we just accept this
-   * shortcoming with the reasoning that the user is mostly interested in the
-   * tokens on the side where they are hovering anyway.
-   *
-   * Another known issue is that we are not able to see past collapsed lines
-   * with the current implementation.
-   */
-  private currentHighlightLineNumber = 0;
-
-  /**
-   * Keeps track of where tokens occur in a file during rendering, so that it is
-   * easy to look up when processing mouse events.
-   */
-  private tokenToLinesLeft = new Map<string, Set<number>>();
-
-  private tokenToLinesRight = new Map<string, Set<number>>();
-
-  private hoveredElement?: Element;
-
-  private updateTokenTask?: DelayedTask;
-
-  constructor(
-    container: HTMLElement = document.documentElement,
-    tokenHighlightListener?: TokenHighlightListener
-  ) {
-    this.tokenHighlightListener = tokenHighlightListener;
-    container.addEventListener('click', e => {
-      this.handleContainerClick(e);
-    });
-  }
-
-  annotate(
-    el: HTMLElement,
-    _: HTMLElement,
-    line: GrDiffLine,
-    side: Side
-  ): void {
-    const text = el.textContent;
-    if (!text) return;
-    // Binary files encoded as text for example can have super long lines
-    // with super long tokens. Let's guard against against this scenario.
-    if (text.length > LINE_LENGTH_LIMIT) return;
-    let match;
-    let atLeastOneTokenMatched = false;
-    while ((match = tokenMatcher.exec(text))) {
-      const token = match[0];
-      const index = match.index;
-      const length = token.length;
-      // Binary files encoded as text for example can have super long lines
-      // with super long tokens. Let's guard against this scenario.
-      if (length > TOKEN_LENGTH_LIMIT) continue;
-      atLeastOneTokenMatched = true;
-      const css = token === this.currentHighlight ? CSS_HIGHLIGHT : CSS_TOKEN;
-      // We add the tk-* class so that we can look up the token later easily
-      // even if the token element was split up into multiple smaller nodes.
-      GrAnnotation.annotateElement(el, index, length, `tk-${token} ${css}`);
-      // We could try to detect whether we are re-rendering instead of initially
-      // rendering the line. Then we would not have to call storeLineForToken()
-      // again. But since the Set swallows the duplicates we don't care.
-      this.storeLineForToken(token, line, side);
-    }
-    if (atLeastOneTokenMatched) {
-      // These listeners do not have to be cleaned, because listeners are
-      // garbage collected along with the element itself once it is not attached
-      // to the DOM anymore and no references exist anymore.
-      el.addEventListener('mouseover', e => {
-        this.handleTokenMouseOver(e);
-      });
-      el.addEventListener('mouseout', e => {
-        this.handleTokenMouseOut(e);
-      });
-    }
-  }
-
-  private storeLineForToken(token: string, line: GrDiffLine, side: Side) {
-    const tokenToLines =
-      side === Side.LEFT ? this.tokenToLinesLeft : this.tokenToLinesRight;
-    // Just to make sure that we don't break down on large files.
-    if (tokenToLines.size > TOKEN_COUNT_LIMIT) return;
-    let numbers = tokenToLines.get(token);
-    if (!numbers) {
-      numbers = new Set<number>();
-      tokenToLines.set(token, numbers);
-    }
-    // Just to make sure that we don't break down on large files.
-    if (numbers.size > TOKEN_OCCURRENCES_LIMIT) return;
-    const lineNumber =
-      side === Side.LEFT ? line.beforeNumber : line.afterNumber;
-    numbers.add(Number(lineNumber));
-  }
-
-  private handleTokenMouseOut(e: MouseEvent) {
-    // If there's no ongoing hover-task, terminate early.
-    if (!this.updateTokenTask?.isActive()) return;
-    if (e.buttons > 0 || this.interferesWithSelection()) return;
-    const {element} = this.findTokenAncestor(e?.target);
-    if (!element) return;
-    if (element === this.hoveredElement) {
-      // If we are moving out of the currently hovered element, cancel the
-      // update task.
-      this.hoveredElement = undefined;
-      this.updateTokenTask?.cancel();
-    }
-  }
-
-  private handleTokenMouseOver(e: MouseEvent) {
-    if (e.buttons > 0 || this.interferesWithSelection()) return;
-    const {
-      line,
-      token: newHighlight,
-      element,
-    } = this.findTokenAncestor(e?.target);
-    if (!newHighlight || newHighlight === this.currentHighlight) return;
-    this.hoveredElement = element;
-    this.updateTokenTask = debounce(
-      this.updateTokenTask,
-      () => {
-        this.updateTokenHighlight(newHighlight, line, element);
-      },
-      HOVER_DELAY_MS
-    );
-  }
-
-  private handleContainerClick(e: MouseEvent) {
-    if (this.interferesWithSelection()) return;
-    // Ignore the click if the click is on a token.
-    // We can't use e.target becauses it gets retargetted to the container as
-    // it's a shadow dom.
-    const {element} = this.findTokenAncestor(e.composedPath()[0]);
-    if (element) return;
-    this.hoveredElement = undefined;
-    this.updateTokenTask?.cancel();
-    this.updateTokenHighlight(undefined, 0, undefined);
-  }
-
-  private interferesWithSelection() {
-    return document.getSelection()?.type === 'Range';
-  }
-
-  findTokenAncestor(el?: EventTarget | Element | null): {
-    token?: string;
-    line: number;
-    element?: Element;
-  } {
-    if (!(el instanceof Element))
-      return {line: 0, token: undefined, element: undefined};
-    if (
-      el.classList.contains(CSS_TOKEN) ||
-      el.classList.contains(CSS_HIGHLIGHT)
-    ) {
-      const tkClass = [...el.classList].find(c => c.startsWith('tk-'));
-      const line = lineNumberToNumber(getLineNumberByChild(el));
-      if (!line || !tkClass)
-        return {line: 0, token: undefined, element: undefined};
-      return {line, token: tkClass.substring(3), element: el};
-    }
-    if (el.tagName === 'TD')
-      return {line: 0, token: undefined, element: undefined};
-    return this.findTokenAncestor(el.parentElement);
-  }
-
-  private updateTokenHighlight(
-    newHighlight: string | undefined,
-    newLineNumber: number,
-    newHoveredElement: Element | undefined
-  ) {
-    if (
-      this.currentHighlight === newHighlight &&
-      this.currentHighlightLineNumber === newLineNumber
-    )
-      return;
-    const oldHighlight = this.currentHighlight;
-    const oldLineNumber = this.currentHighlightLineNumber;
-    this.currentHighlight = newHighlight;
-    this.currentHighlightLineNumber = newLineNumber;
-    this.triggerTokenHighlightEvent(
-      newHighlight,
-      newLineNumber,
-      newHoveredElement
-    );
-    this.notifyForToken(oldHighlight, oldLineNumber);
-    this.notifyForToken(newHighlight, newLineNumber);
-  }
-
-  triggerTokenHighlightEvent(
-    token: string | undefined,
-    line: number,
-    element: Element | undefined
-  ) {
-    if (!this.tokenHighlightListener) {
-      return;
-    }
-    if (!token || !element) {
-      this.tokenHighlightListener(undefined);
-      return;
-    }
-    const previousTextLength = getPreviousContentNodes(element)
-      .map(sib => sib.textContent!.length)
-      .reduce((partial_sum, a) => partial_sum + a, 0);
-    const lineEl = getLineElByChild(element);
-    assertIsDefined(lineEl, 'Line element should be found!');
-    const side = getSideByLineEl(lineEl);
-    const range = {
-      start_line: line,
-      start_column: previousTextLength + 1, // 1-based inclusive
-      end_line: line,
-      end_column: previousTextLength + token.length, // 1-based inclusive
-    };
-    this.tokenHighlightListener({token, element, side, range});
-  }
-
-  getSortedLinesForSide(
-    lineMapping: Map<string, Set<number>>,
-    token: string | undefined,
-    lineNumber: number
-  ): Array<number> {
-    if (!token) return [];
-    const lineSet = lineMapping.get(token);
-    if (!lineSet) return [];
-    const lines = [...lineSet];
-    lines.sort((a, b) => {
-      const da = Math.abs(a - lineNumber);
-      const db = Math.abs(b - lineNumber);
-      // For equal distance, prefer lines later in the file over earlier in the
-      // file. This ensures total ordering.
-      if (da === db) return b - a;
-      // Compare the distance to lineNumber.
-      return da - db;
-    });
-    return lines.slice(0, TOKEN_HIGHLIGHT_LIMIT);
-  }
-
-  notifyForToken(token: string | undefined, lineNumber: number) {
-    const leftLines = this.getSortedLinesForSide(
-      this.tokenToLinesLeft,
-      token,
-      lineNumber
-    );
-    for (const line of leftLines) {
-      this.notifyListeners(line, Side.LEFT);
-    }
-    const rightLines = this.getSortedLinesForSide(
-      this.tokenToLinesRight,
-      token,
-      lineNumber
-    );
-    for (const line of rightLines) {
-      this.notifyListeners(line, Side.RIGHT);
-    }
-  }
-
-  addListener(listener: DiffLayerListener) {
-    this.listeners.push(listener);
-  }
-
-  removeListener(listener: DiffLayerListener) {
-    this.listeners = this.listeners.filter(f => f !== listener);
-  }
-
-  notifyListeners(line: number, side: Side) {
-    for (const listener of this.listeners) {
-      listener(line, line, side);
-    }
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
deleted file mode 100644
index a0670b8..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ /dev/null
@@ -1,367 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {Side, TokenHighlightEventDetails} from '../../../api/diff';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {html, render} from 'lit';
-import {_testOnly_allTasks} from '../../../utils/async-util';
-import {queryAndAssert} from '../../../test/test-utils';
-
-// MockInteractions.makeMouseEvent always sets buttons to 1.
-function dispatchMouseEvent(
-  type: string,
-  xy: {x: number; y: number},
-  node: Element
-) {
-  const props = {
-    bubbles: true,
-    cancellable: true,
-    composed: true,
-    clientX: xy.x,
-    clientY: xy.y,
-    buttons: 0,
-  };
-  node.dispatchEvent(new MouseEvent(type, props));
-}
-
-class MockListener {
-  private results: any[][] = [];
-
-  notify(...args: any[]) {
-    this.results.push(args);
-  }
-
-  shift() {
-    return this.results.shift();
-  }
-
-  flush() {
-    this.results = [];
-  }
-
-  get pending(): number {
-    return this.results.length;
-  }
-}
-
-suite('token-highlight-layer', () => {
-  let container: HTMLElement;
-  let listener: MockListener;
-  let highlighter: TokenHighlightLayer;
-  let tokenHighlightingCalls: {details?: TokenHighlightEventDetails}[] = [];
-
-  function tokenHighlightListener(
-    highlightDetails?: TokenHighlightEventDetails
-  ) {
-    tokenHighlightingCalls.push({details: highlightDetails});
-  }
-
-  setup(async () => {
-    listener = new MockListener();
-    tokenHighlightingCalls = [];
-    container = document.createElement('div');
-    document.body.appendChild(container);
-    highlighter = new TokenHighlightLayer(container, tokenHighlightListener);
-    highlighter.addListener((...args) => listener.notify(...args));
-  });
-
-  teardown(() => {
-    document.body.removeChild(container);
-  });
-
-  function annotate(el: HTMLElement, side: Side = Side.LEFT, line = 1) {
-    const diffLine = new GrDiffLine(GrDiffLineType.BOTH);
-    diffLine.afterNumber = line;
-    diffLine.beforeNumber = line;
-    highlighter.annotate(el, document.createElement('div'), diffLine, side);
-    return el;
-  }
-
-  let uniqueId = 0;
-  function createLineId() {
-    uniqueId++;
-    return `line-${uniqueId.toString()}`;
-  }
-
-  function createLine(text: string, line = 1): HTMLElement {
-    const lineId = createLineId();
-    const template = html`
-      <div class="line">
-        <div data-value=${line} class="lineNum right"></div>
-        <div class="content">
-          <div id=${lineId} class="contentText">${text}</div>
-        </div>
-      </div>
-    `;
-
-    const div = document.createElement('div');
-    render(template, div);
-    container.appendChild(div);
-    const el = queryAndAssert(container, `#${lineId}`);
-    return el as HTMLElement;
-  }
-
-  suite('annotate', () => {
-    function assertAnnotation(
-      args: any[],
-      el: HTMLElement,
-      start: number,
-      length: number,
-      cssClass: string
-    ) {
-      assert.equal(args[0], el);
-      assert.equal(args[1], start);
-      assert.equal(args[2], length);
-      assert.equal(args[3], cssClass);
-    }
-
-    test('annotate adds css token', () => {
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      const el = createLine('these are words');
-      annotate(el);
-      assert.isTrue(annotateElementStub.calledThrice);
-      assertAnnotation(annotateElementStub.args[0], el, 0, 5, 'tk-these token');
-      assertAnnotation(annotateElementStub.args[1], el, 6, 3, 'tk-are token');
-      assertAnnotation(
-        annotateElementStub.args[2],
-        el,
-        10,
-        5,
-        'tk-words token'
-      );
-    });
-
-    test('annotate adds mouse handlers', () => {
-      const el = createLine('these are words');
-      const addEventListenerStub = sinon.stub(el, 'addEventListener');
-      annotate(el);
-      assert.isTrue(addEventListenerStub.calledTwice);
-      assert.equal(addEventListenerStub.args[0][0], 'mouseover');
-      assert.equal(addEventListenerStub.args[1][0], 'mouseout');
-    });
-
-    test('annotate does not add mouse handlers without words', () => {
-      const el = createLine('  ');
-      const addEventListenerStub = sinon.stub(el, 'addEventListener');
-      annotate(el);
-      assert.isFalse(addEventListenerStub.called);
-    });
-
-    test('annotate adds mouse handlers for longest word', () => {
-      const el = createLine('w'.repeat(100));
-      const addEventListenerStub = sinon.stub(el, 'addEventListener');
-      annotate(el);
-      assert.isTrue(addEventListenerStub.called);
-    });
-
-    test('annotate does not add mouse handlers for long words', () => {
-      const el = createLine('w'.repeat(101));
-      const addEventListenerStub = sinon.stub(el, 'addEventListener');
-      annotate(el);
-      assert.isFalse(addEventListenerStub.called);
-    });
-  });
-
-  suite('highlight', () => {
-    test('highlighting hover delay', async () => {
-      const clock = sinon.useFakeTimers();
-      const line1 = createLine('two words');
-      annotate(line1);
-      const line2 = createLine('three words');
-      annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-words');
-      assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
-
-      assert.equal(listener.pending, 0);
-      assert.equal(_testOnly_allTasks.size, 1);
-
-      // Too early for hover behavior to trigger.
-      clock.tick(100);
-      assert.equal(listener.pending, 0);
-      assert.equal(_testOnly_allTasks.size, 1);
-
-      // After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
-      clock.tick(HOVER_DELAY_MS - 100);
-      assert.equal(listener.pending, 2);
-      assert.equal(_testOnly_allTasks.size, 0);
-      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
-      assert.deepEqual(listener.shift(), [2, 2, Side.RIGHT]);
-    });
-
-    test('highlighting spans many lines', async () => {
-      const clock = sinon.useFakeTimers();
-      const line1 = createLine('two words');
-      annotate(line1);
-      const line2 = createLine('three words');
-      annotate(line2, Side.RIGHT, 1000);
-      const words1 = queryAndAssert(line1, '.tk-words');
-      assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
-
-      assert.equal(listener.pending, 0);
-
-      // After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
-      clock.tick(HOVER_DELAY_MS);
-      assert.equal(listener.pending, 2);
-      assert.equal(_testOnly_allTasks.size, 0);
-      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
-      assert.deepEqual(listener.shift(), [1000, 1000, Side.RIGHT]);
-    });
-
-    test('highlighting mouse out before delay', async () => {
-      const clock = sinon.useFakeTimers();
-      const line1 = createLine('two words');
-      annotate(line1);
-      const line2 = createLine('three words', 2);
-      annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-words');
-      assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
-      assert.equal(listener.pending, 0);
-      clock.tick(100);
-      // Mouse out after 100ms but before hover delay.
-      dispatchMouseEvent(
-        'mouseout',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
-      assert.equal(listener.pending, 0);
-      clock.tick(HOVER_DELAY_MS - 100);
-      assert.equal(listener.pending, 0);
-      assert.equal(_testOnly_allTasks.size, 0);
-    });
-
-    test('triggers listener for applying and clearing highlighting', async () => {
-      const clock = sinon.useFakeTimers();
-      const line1 = createLine('two words');
-      annotate(line1);
-      const line2 = createLine('three words', 2);
-      annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-words');
-      assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
-      assert.equal(tokenHighlightingCalls.length, 0);
-      clock.tick(HOVER_DELAY_MS);
-      assert.equal(tokenHighlightingCalls.length, 1);
-      assert.deepEqual(tokenHighlightingCalls[0].details, {
-        token: 'words',
-        side: Side.RIGHT,
-        element: words1,
-        range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
-      });
-
-      MockInteractions.click(container);
-      assert.equal(tokenHighlightingCalls.length, 2);
-      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
-    });
-
-    test('triggers listener on token with single occurrence', async () => {
-      const clock = sinon.useFakeTimers();
-      const line1 = createLine('a tokenWithSingleOccurence');
-      const line2 = createLine('can be highlighted', 2);
-      annotate(line1);
-      annotate(line2, Side.RIGHT, 2);
-      const tokenNode = queryAndAssert(line1, '.tk-tokenWithSingleOccurence');
-      assert.isTrue(tokenNode.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(tokenNode),
-        tokenNode
-      );
-      assert.equal(tokenHighlightingCalls.length, 0);
-      clock.tick(HOVER_DELAY_MS);
-      assert.equal(tokenHighlightingCalls.length, 1);
-      assert.deepEqual(tokenHighlightingCalls[0].details, {
-        token: 'tokenWithSingleOccurence',
-        side: Side.RIGHT,
-        element: tokenNode,
-        range: {start_line: 1, start_column: 3, end_line: 1, end_column: 26},
-      });
-
-      MockInteractions.click(container);
-      assert.equal(tokenHighlightingCalls.length, 2);
-      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
-    });
-
-    test('clicking clears highlight', async () => {
-      const clock = sinon.useFakeTimers();
-      const line1 = createLine('two words');
-      annotate(line1);
-      const line2 = createLine('three words', 2);
-      annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-words');
-      assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
-      assert.equal(listener.pending, 0);
-      clock.tick(HOVER_DELAY_MS);
-      assert.equal(listener.pending, 2);
-      listener.flush();
-      assert.equal(listener.pending, 0);
-      MockInteractions.click(container);
-      assert.equal(listener.pending, 2);
-      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
-      assert.deepEqual(listener.shift(), [2, 2, Side.RIGHT]);
-    });
-
-    test('clicking on word does not clear highlight', async () => {
-      const clock = sinon.useFakeTimers();
-      const line1 = createLine('two words');
-      annotate(line1);
-      const line2 = createLine('three words', 2);
-      annotate(line2, Side.RIGHT, 2);
-      const words1 = queryAndAssert(line1, '.tk-words');
-      assert.isTrue(words1.classList.contains('token'));
-      dispatchMouseEvent(
-        'mouseover',
-        MockInteractions.middleOfNode(words1),
-        words1
-      );
-      assert.equal(listener.pending, 0);
-      clock.tick(HOVER_DELAY_MS);
-      assert.equal(listener.pending, 2);
-      listener.flush();
-      assert.equal(listener.pending, 0);
-      MockInteractions.click(words1);
-      assert.equal(listener.pending, 0);
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
deleted file mode 100644
index 958f367..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ /dev/null
@@ -1,581 +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 {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {Subscription} from 'rxjs';
-import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
-import {
-  DiffViewMode,
-  GrDiffCursor as GrDiffCursorApi,
-  LineNumberEventDetail,
-} from '../../../api/diff';
-import {ScrollMode, Side} from '../../../constants/constants';
-import {PolymerDomWrapper} from '../../../types/types';
-import {toggleClass} from '../../../utils/dom-util';
-import {
-  GrCursorManager,
-  isTargetable,
-} from '../../shared/gr-cursor-manager/gr-cursor-manager';
-import {GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {GrDiff} from '../gr-diff/gr-diff';
-
-type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
-
-const LEFT_SIDE_CLASS = 'target-side-left';
-const RIGHT_SIDE_CLASS = 'target-side-right';
-
-/** A subset of the GrDiff API that the cursor is using. */
-export interface GrDiffCursorable extends HTMLElement {
-  isRangeSelected(): boolean;
-  createRangeComment(): void;
-  getCursorStops(): Stop[];
-  path?: string;
-}
-
-export class GrDiffCursor implements GrDiffCursorApi {
-  private preventAutoScrollOnManualScroll = false;
-
-  set side(side: Side) {
-    if (this.sideInternal === side) {
-      return;
-    }
-    if (this.sideInternal && this.diffRow) {
-      this.fireCursorMoved(
-        'line-cursor-moved-out',
-        this.diffRow,
-        this.sideInternal
-      );
-    }
-    this.sideInternal = side;
-    this.updateSideClass();
-    if (this.diffRow) {
-      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
-    }
-  }
-
-  get side(): Side {
-    return this.sideInternal;
-  }
-
-  private sideInternal = Side.RIGHT;
-
-  set diffRow(diffRow: HTMLElement | undefined) {
-    if (this.diffRowInternal) {
-      this.diffRowInternal.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
-      this.fireCursorMoved(
-        'line-cursor-moved-out',
-        this.diffRowInternal,
-        this.side
-      );
-    }
-    this.diffRowInternal = diffRow;
-
-    this.updateSideClass();
-    if (this.diffRow) {
-      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
-    }
-  }
-
-  get diffRow(): HTMLElement | undefined {
-    return this.diffRowInternal;
-  }
-
-  private diffRowInternal?: HTMLElement;
-
-  private diffs: GrDiffCursorable[] = [];
-
-  /**
-   * If set, the cursor will attempt to move to the line number (instead of
-   * the first chunk) the next time the diff renders. It is set back to null
-   * when used. It should be only used if you want the line to be focused
-   * after initialization of the component and page should scroll
-   * to that position. This parameter should be set at most for one gr-diff
-   * element in the page.
-   */
-  initialLineNumber: number | null = null;
-
-  private cursorManager = new GrCursorManager();
-
-  private targetSubscription?: Subscription;
-
-  constructor() {
-    this.cursorManager.cursorTargetClass = 'target-row';
-    this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
-    this.cursorManager.focusOnMove = true;
-
-    window.addEventListener('scroll', this._boundHandleWindowScroll);
-    this.targetSubscription = this.cursorManager.target$.subscribe(target => {
-      this.diffRow = target || undefined;
-    });
-  }
-
-  dispose() {
-    if (this.targetSubscription) this.targetSubscription.unsubscribe();
-    window.removeEventListener('scroll', this._boundHandleWindowScroll);
-    this.cursorManager.unsetCursor();
-  }
-
-  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
-  isAtStart() {
-    return this.cursorManager.isAtStart();
-  }
-
-  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
-  isAtEnd() {
-    return this.cursorManager.isAtEnd();
-  }
-
-  moveLeft() {
-    this.side = Side.LEFT;
-    if (this._isTargetBlank()) {
-      this.moveUp();
-    }
-  }
-
-  moveRight() {
-    this.side = Side.RIGHT;
-    if (this._isTargetBlank()) {
-      this.moveUp();
-    }
-  }
-
-  moveDown() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      return this.cursorManager.next({
-        filter: (row: Element) => this._rowHasSide(row),
-      });
-    } else {
-      return this.cursorManager.next();
-    }
-  }
-
-  moveUp() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      return this.cursorManager.previous({
-        filter: (row: Element) => this._rowHasSide(row),
-      });
-    } else {
-      return this.cursorManager.previous();
-    }
-  }
-
-  moveToVisibleArea() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.cursorManager.moveToVisibleArea((row: Element) =>
-        this._rowHasSide(row)
-      );
-    } else {
-      this.cursorManager.moveToVisibleArea();
-    }
-  }
-
-  moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
-    const result = this.cursorManager.next({
-      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
-      getTargetHeight: target =>
-        (target?.parentNode as HTMLElement)?.scrollHeight || 0,
-      clipToTop,
-    });
-    this._fixSide();
-    return result;
-  }
-
-  moveToPreviousChunk(): CursorMoveResult {
-    const result = this.cursorManager.previous({
-      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
-    });
-    this._fixSide();
-    return result;
-  }
-
-  moveToNextCommentThread(): CursorMoveResult {
-    if (this.isAtEnd()) {
-      return CursorMoveResult.CLIPPED;
-    }
-    const result = this.cursorManager.next({
-      filter: (row: HTMLElement) => this._rowHasThread(row),
-    });
-    this._fixSide();
-    return result;
-  }
-
-  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);
-    }
-  }
-
-  /**
-   * Get the line number element targeted by the cursor row and side.
-   */
-  getTargetLineElement(): HTMLElement | null {
-    let lineElSelector = '.lineNum';
-
-    if (!this.diffRow) {
-      return null;
-    }
-
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      lineElSelector += this.side === Side.LEFT ? '.left' : '.right';
-    }
-
-    return this.diffRow.querySelector(lineElSelector);
-  }
-
-  getTargetDiffElement(): GrDiff | null {
-    if (!this.diffRow) return null;
-
-    const hostOwner = (dom(this.diffRow) as PolymerDomWrapper).getOwnerRoot();
-    if (hostOwner?.host?.tagName === 'GR-DIFF') {
-      return hostOwner.host as GrDiff;
-    }
-    return null;
-  }
-
-  moveToFirstChunk() {
-    this.cursorManager.moveToStart();
-    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
-      this.moveToNextChunk(true);
-    } else {
-      this._fixSide();
-    }
-  }
-
-  moveToLastChunk() {
-    this.cursorManager.moveToEnd();
-    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
-      this.moveToPreviousChunk();
-    } else {
-      this._fixSide();
-    }
-  }
-
-  /**
-   * Move the cursor either to initialLineNumber or the first chunk and
-   * reset scroll behavior.
-   *
-   * This may grab the focus from the app.
-   *
-   * If you do not want to move the cursor or grab focus, and just want to
-   * reset the scroll behavior, use reInit() instead.
-   */
-  reInitCursor() {
-    if (!this.diffRow) {
-      // does not scroll during init unless requested
-      this.cursorManager.scrollMode = this.initialLineNumber
-        ? ScrollMode.KEEP_VISIBLE
-        : ScrollMode.NEVER;
-      if (this.initialLineNumber) {
-        this.moveToLineNumber(this.initialLineNumber, this.side);
-        this.initialLineNumber = null;
-      } else {
-        this.moveToFirstChunk();
-      }
-    }
-    this.resetScrollMode();
-  }
-
-  resetScrollMode() {
-    this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
-  }
-
-  private _boundHandleWindowScroll = () => {
-    if (this.preventAutoScrollOnManualScroll) {
-      this.cursorManager.scrollMode = ScrollMode.NEVER;
-      this.cursorManager.focusOnMove = false;
-      this.preventAutoScrollOnManualScroll = false;
-    }
-  };
-
-  reInitAndUpdateStops() {
-    this.resetScrollMode();
-    this._updateStops();
-  }
-
-  handleDiffUpdate() {
-    this._updateStops();
-    this.reInitCursor();
-  }
-
-  private boundHandleDiffLoadingChanged = () => {
-    this._updateStops();
-  };
-
-  private _boundHandleDiffRenderStart = () => {
-    this.preventAutoScrollOnManualScroll = true;
-  };
-
-  private _boundHandleDiffRenderContent = () => {
-    this._updateStops();
-    // When done rendering, turn focus on move and automatic scrolling back on
-    this.cursorManager.focusOnMove = true;
-    this.preventAutoScrollOnManualScroll = false;
-  };
-
-  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 =>
-      diff.isRangeSelected()
-    );
-    if (diffWithRangeSelected) {
-      diffWithRangeSelected.createRangeComment();
-    } else {
-      const line = this.getTargetLineElement();
-      const diff = this.getTargetDiffElement();
-      if (diff && line) {
-        diff.addDraftAtLine(line);
-      }
-    }
-  }
-
-  /**
-   * Get an object describing the location of the cursor. Such as
-   * {leftSide: false, number: 123} for line 123 of the revision, or
-   * {leftSide: true, number: 321} for line 321 of the base patch.
-   * Returns null if an address is not available.
-   */
-  getAddress() {
-    if (!this.diffRow) {
-      return null;
-    }
-    // Get the line-number cell targeted by the cursor. If the mode is unified
-    // then prefer the revision cell if available.
-    return this.getAddressFor(this.diffRow, this.side);
-  }
-
-  private getAddressFor(diffRow: HTMLElement, side: Side) {
-    let cell;
-    if (this._getViewMode() === DiffViewMode.UNIFIED) {
-      cell = diffRow.querySelector('.lineNum.right');
-      if (!cell) {
-        cell = diffRow.querySelector('.lineNum.left');
-      }
-    } else {
-      cell = diffRow.querySelector('.lineNum.' + side);
-    }
-    if (!cell) {
-      return null;
-    }
-
-    const number = cell.getAttribute('data-value');
-    if (!number || number === 'FILE') {
-      return null;
-    }
-
-    return {
-      leftSide: cell.matches('.left'),
-      number: Number(number),
-    };
-  }
-
-  _getViewMode() {
-    if (!this.diffRow) {
-      return null;
-    }
-
-    if (this.diffRow.classList.contains('side-by-side')) {
-      return DiffViewMode.SIDE_BY_SIDE;
-    } else {
-      return DiffViewMode.UNIFIED;
-    }
-  }
-
-  _rowHasSide(row: Element) {
-    const selector =
-      (this.side === Side.LEFT ? '.left' : '.right') + ' + .content';
-    return !!row.querySelector(selector);
-  }
-
-  _isFirstRowOfChunk(row: HTMLElement) {
-    const parentClassList = (row.parentNode as HTMLElement).classList;
-    const isInChunk =
-      parentClassList.contains('section') && parentClassList.contains('delta');
-    const previousRow = row.previousSibling as HTMLElement;
-    const firstContentRow =
-      !previousRow || previousRow.classList.contains('moveControls');
-    return isInChunk && firstContentRow;
-  }
-
-  _rowHasThread(row: HTMLElement): boolean {
-    return !!row.querySelector('.thread-group');
-  }
-
-  /**
-   * If we jumped to a row where there is no content on the current side then
-   * switch to the alternate side.
-   */
-  _fixSide() {
-    if (
-      this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
-      this._isTargetBlank()
-    ) {
-      this.side = this.side === Side.LEFT ? Side.RIGHT : Side.LEFT;
-    }
-  }
-
-  _isTargetBlank() {
-    if (!this.diffRow) {
-      return false;
-    }
-
-    const actions = this._getActionsForRow();
-    return (
-      (this.side === Side.LEFT && !actions.left) ||
-      (this.side === Side.RIGHT && !actions.right)
-    );
-  }
-
-  private fireCursorMoved(
-    event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
-    row: HTMLElement,
-    side: Side
-  ) {
-    const address = this.getAddressFor(row, side);
-    if (address) {
-      const {leftSide, number} = address;
-      row.dispatchEvent(
-        new CustomEvent<LineNumberEventDetail>(event, {
-          detail: {
-            lineNum: number,
-            side: leftSide ? Side.LEFT : Side.RIGHT,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    }
-  }
-
-  private updateSideClass() {
-    if (!this.diffRow) {
-      return;
-    }
-    toggleClass(this.diffRow, LEFT_SIDE_CLASS, this.side === Side.LEFT);
-    toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
-  }
-
-  _isActionType(type: GrDiffRowType) {
-    return (
-      type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
-    );
-  }
-
-  _getActionsForRow() {
-    const actions = {left: false, right: false};
-    if (this.diffRow) {
-      actions.left = this._isActionType(
-        this.diffRow.getAttribute('left-type') as GrDiffRowType
-      );
-      actions.right = this._isActionType(
-        this.diffRow.getAttribute('right-type') as GrDiffRowType
-      );
-    }
-    return actions;
-  }
-
-  _updateStops() {
-    this.cursorManager.stops = this.diffs.reduce(
-      (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
-      []
-    );
-  }
-
-  replaceDiffs(diffs: GrDiffCursorable[]) {
-    for (const diff of this.diffs) {
-      this.removeEventListeners(diff);
-    }
-    this.diffs = [];
-    for (const diff of diffs) {
-      this.addEventListeners(diff);
-    }
-    this.diffs.push(...diffs);
-    this._updateStops();
-  }
-
-  unregisterDiff(diff: GrDiffCursorable) {
-    // This can happen during destruction - just don't unregister then.
-    if (!this.diffs) return;
-    const i = this.diffs.indexOf(diff);
-    if (i !== -1) {
-      this.diffs.splice(i, 1);
-    }
-  }
-
-  private removeEventListeners(diff: GrDiffCursorable) {
-    diff.removeEventListener(
-      'loading-changed',
-      this.boundHandleDiffLoadingChanged
-    );
-    diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
-    diff.removeEventListener(
-      'render-content',
-      this._boundHandleDiffRenderContent
-    );
-    diff.removeEventListener(
-      'line-selected',
-      this._boundHandleDiffLineSelected
-    );
-  }
-
-  private addEventListeners(diff: GrDiffCursorable) {
-    diff.addEventListener(
-      'loading-changed',
-      this.boundHandleDiffLoadingChanged
-    );
-    diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
-    diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
-    diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
-  }
-
-  _findRowByNumberAndFile(
-    targetNumber: number,
-    side: Side,
-    path?: string
-  ): HTMLElement | undefined {
-    let stops: Array<HTMLElement | AbortStop>;
-    if (path) {
-      const diff = this.diffs.filter(diff => diff.path === path)[0];
-      stops = diff.getCursorStops();
-    } else {
-      stops = this.cursorManager.stops;
-    }
-    // Sadly needed for type narrowing to understand that the result is always
-    // targetable.
-    const targetableStops: HTMLElement[] = stops.filter(isTargetable);
-    const selector = `.lineNum.${side}[data-value="${targetNumber}"]`;
-    return targetableStops.find(stop => stop.querySelector(selector));
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
deleted file mode 100644
index 3b604eb..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ /dev/null
@@ -1,703 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff.js';
-import './gr-diff-cursor.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {listenOnce, mockPromise} from '../../../test/test-utils.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import {createDefaultDiffPrefs} from '../../../constants/constants.js';
-import {GrDiffCursor} from './gr-diff-cursor.js';
-
-const basicFixture = fixtureFromTemplate(html`
-  <gr-diff></gr-diff>
-`);
-
-suite('gr-diff-cursor tests', () => {
-  let cursor;
-  let diffElement;
-  let diff;
-
-  setup(async () => {
-    diffElement = basicFixture.instantiate();
-    cursor = new GrDiffCursor();
-
-    // Register the diff with the cursor.
-    cursor.replaceDiffs([diffElement]);
-
-    diffElement.loggedIn = false;
-    diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
-    diffElement.comments = {
-      left: [],
-      right: [],
-      meta: {patchRange: undefined},
-    };
-    diffElement.path = 'some/path.ts';
-    const promise = mockPromise();
-    const setupDone = () => {
-      cursor._updateStops();
-      cursor.moveToFirstChunk();
-      diffElement.removeEventListener('render', setupDone);
-      promise.resolve();
-    };
-    diffElement.addEventListener('render', setupDone);
-
-    diff = getMockDiffResponse();
-    diffElement.prefs = createDefaultDiffPrefs();
-    diffElement.diff = diff;
-    await promise;
-  });
-
-  test('diff cursor functionality (side-by-side)', () => {
-    // The cursor has been initialized to the first delta.
-    assert.isOk(cursor.diffRow);
-
-    const firstDeltaRow = diffElement.shadowRoot
-        .querySelector('.section.delta .diff-row');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-
-    cursor.moveDown();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-    cursor.moveUp();
-
-    assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-    assert.equal(cursor.diffRow, firstDeltaRow);
-  });
-
-  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();
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToNextChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToFirstChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('moveToLastChunk', async () => {
-    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();
-    cursor._updateStops();
-
-    const chunks = Array.from(diffElement.root.querySelectorAll(
-        '.section.delta'));
-    assert.equal(chunks.length, 2);
-
-    // Verify it works on fresh diff.
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-
-    // Verify it works from other cursor positions.
-    cursor.moveToPreviousChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
-    assert.equal(cursor.side, 'left');
-    cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
-    assert.equal(cursor.side, 'right');
-  });
-
-  test('cursor scroll behavior', () => {
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-
-    diffElement.dispatchEvent(new Event('render-start'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    window.dispatchEvent(new Event('scroll'));
-    assert.equal(cursor.cursorManager.scrollMode, 'never');
-    assert.isFalse(cursor.cursorManager.focusOnMove);
-
-    diffElement.dispatchEvent(new Event('render-content'));
-    assert.isTrue(cursor.cursorManager.focusOnMove);
-
-    cursor.reInitCursor();
-    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-  });
-
-  test('moves to selected line', () => {
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-
-    diffElement.dispatchEvent(
-        new CustomEvent('line-selected', {
-          detail: {number: '123', side: 'right', path: 'some/file'},
-        }));
-
-    assert.isTrue(moveToNumStub.called);
-    assert.equal(moveToNumStub.lastCall.args[0], '123');
-    assert.equal(moveToNumStub.lastCall.args[1], 'right');
-    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
-  });
-
-  suite('unified diff', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      // We must allow the diff to re-render after setting the viewMode.
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      diffElement.addEventListener('render', renderHandler);
-      diffElement.viewMode = 'UNIFIED_DIFF';
-      await promise;
-    });
-
-    test('diff cursor functionality (unified)', () => {
-      // The cursor has been initialized to the first delta.
-      assert.isOk(cursor.diffRow);
-
-      let firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      firstDeltaRow = diffElement.shadowRoot
-          .querySelector('.section.delta .diff-row');
-      assert.equal(cursor.diffRow, firstDeltaRow);
-
-      cursor.moveDown();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow);
-      assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
-
-      cursor.moveUp();
-
-      assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
-      assert.equal(cursor.diffRow, firstDeltaRow);
-    });
-  });
-
-  test('cursor side functionality', () => {
-    // The side only applies to side-by-side mode, which should be the default
-    // mode.
-    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
-
-    const firstDeltaSection = diffElement.shadowRoot
-        .querySelector('.section.delta');
-    const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
-
-    // Because the first delta in this diff is on the right, it should be set
-    // to the right side.
-    assert.equal(cursor.side, 'right');
-    assert.equal(cursor.diffRow, firstDeltaRow);
-    const firstIndex = cursor.cursorManager.index;
-
-    // Move the side to the left. Because this delta only has a right side, we
-    // should be moved up to the previous line where there is content on the
-    // right. The previous row is part of the previous section.
-    cursor.moveLeft();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(cursor.cursorManager.index, firstIndex - 1);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.previousSibling);
-
-    // If we move down, we should skip everything in the first delta because
-    // we are on the left side and the first delta has no content on the left.
-    cursor.moveDown();
-
-    assert.equal(cursor.side, 'left');
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.isTrue(cursor.cursorManager.index > firstIndex);
-    assert.equal(cursor.diffRow.parentElement,
-        firstDeltaSection.nextSibling);
-  });
-
-  test('chunk skip functionality', () => {
-    const chunks = diffElement.root.querySelectorAll(
-        '.section.delta');
-    const indexOfChunk = function(chunk) {
-      return Array.prototype.indexOf.call(chunks, chunk);
-    };
-
-    // We should be initialized to the first chunk. Since this chunk only has
-    // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, 0);
-    assert.equal(cursor.side, 'right');
-
-    // Move to the next chunk.
-    cursor.moveToNextChunk();
-
-    // Since this chunk only has content on the left side. we should have been
-    // automatically moved over.
-    const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursor.diffRow.parentElement);
-    assert.equal(currentIndex, previousIndex + 1);
-    assert.equal(cursor.side, 'left');
-  });
-
-  suite('moved chunks without line range)', () => {
-    setup(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      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},
-        },
-        {
-          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},
-        },
-        {
-          ab: [
-            'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    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(async () => {
-      const promise = mockPromise();
-      const renderHandler = function() {
-        diffElement.removeEventListener('render', renderHandler);
-        cursor.reInitCursor();
-        promise.resolve();
-      };
-      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.',
-          ],
-        },
-      ]};
-      await promise;
-    });
-
-    test('renders moveControls with simple descriptions', () => {
-      const [movedIn, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
-      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
-    });
-
-    test('startLineAnchor of movedIn chunk fires events', async () => {
-      const [movedIn] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [startLineAnchor] = movedIn.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'left'});
-        promise.resolve();
-      };
-      assert.equal(startLineAnchor.textContent, '4');
-      startLineAnchor
-          .addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(startLineAnchor);
-      await promise;
-    });
-
-    test('endLineAnchor of movedOut fires events', async () => {
-      const [, movedOut] = diffElement.root
-          .querySelectorAll('.dueToMove .moveControls');
-      const [, endLineAnchor] = movedOut.querySelectorAll('a');
-
-      const promise = mockPromise();
-      const onMovedLinkClicked = e => {
-        assert.deepEqual(e.detail, {lineNum: 4, side: 'right'});
-        promise.resolve();
-      };
-      assert.equal(endLineAnchor.textContent, '4');
-      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
-      MockInteractions.click(endLineAnchor);
-      await promise;
-    });
-  });
-
-  test('initialLineNumber not provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-
-    const promise = mockPromise();
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursor.reInitCursor();
-      assert.isFalse(moveToNumStub.called);
-      assert.isTrue(moveToChunkStub.called);
-      assert.equal(scrollBehaviorDuringMove, 'never');
-      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-      promise.resolve();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    diffElement._diffChanged(getMockDiffResponse());
-    await promise;
-  });
-
-  test('initialLineNumber provided', async () => {
-    let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber')
-        .callsFake(() => {
-          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
-        });
-    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
-    const promise = mockPromise();
-    function renderHandler() {
-      diffElement.removeEventListener('render', renderHandler);
-      cursor.reInitCursor();
-      assert.isFalse(moveToChunkStub.called);
-      assert.isTrue(moveToNumStub.called);
-      assert.equal(moveToNumStub.lastCall.args[0], 10);
-      assert.equal(moveToNumStub.lastCall.args[1], 'right');
-      assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
-      promise.resolve();
-    }
-    diffElement.addEventListener('render', renderHandler);
-    cursor.initialLineNumber = 10;
-    cursor.side = 'right';
-
-    diffElement._diffChanged(getMockDiffResponse());
-    await promise;
-  });
-
-  test('getTargetDiffElement', () => {
-    cursor.initialLineNumber = 1;
-    assert.isTrue(!!cursor.diffRow);
-    assert.equal(
-        cursor.getTargetDiffElement(),
-        diffElement
-    );
-  });
-
-  suite('createCommentInPlace', () => {
-    setup(() => {
-      diffElement.loggedIn = true;
-    });
-
-    test('adds new draft for selected line on the left', async () => {
-      cursor.moveToLineNumber(2, 'left');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 2);
-        assert.equal(range, undefined);
-        assert.equal(side, 'left');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('adds draft for selected line on the right', async () => {
-      cursor.moveToLineNumber(4, 'right');
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 4);
-        assert.equal(range, undefined);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('creates comment for range if selected', async () => {
-      const someRange = {
-        start_line: 2,
-        start_character: 3,
-        end_line: 6,
-        end_character: 1,
-      };
-      diffElement.$.highlights.selectedRange = {
-        side: 'right',
-        range: someRange,
-      };
-      const promise = mockPromise();
-      diffElement.addEventListener('create-comment', e => {
-        const {lineNum, range, side} = e.detail;
-        assert.equal(lineNum, 6);
-        assert.equal(range, someRange);
-        assert.equal(side, 'right');
-        promise.resolve();
-      });
-      cursor.createCommentInPlace();
-      await promise;
-    });
-
-    test('ignores call if nothing is selected', () => {
-      const createRangeCommentStub = sinon.stub(diffElement,
-          'createRangeComment');
-      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursor.diffRow = undefined;
-      cursor.createCommentInPlace();
-      assert.isFalse(createRangeCommentStub.called);
-      assert.isFalse(addDraftAtLineStub.called);
-    });
-  });
-
-  test('getAddress', () => {
-    // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // Revision line 4 is up.
-    cursor.moveUp();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 4});
-
-    // Base line 4 is left.
-    cursor.moveLeft();
-    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
-
-    // Moving to the next chunk takes it back to the start.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: false, number: 5});
-
-    // The following chunk is a removal starting on line 10 of the base.
-    cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(),
-        {leftSide: true, number: 10});
-
-    // Should be null if there is no selection.
-    cursor.cursorManager.unsetCursor();
-    assert.isNotOk(cursor.getAddress());
-  });
-
-  test('_findRowByNumberAndFile', () => {
-    // Get the first ab row after the first chunk.
-    const row = diffElement.root.querySelectorAll('tr')[9];
-
-    // It should be line 8 on the right, but line 5 on the left.
-    assert.equal(cursor._findRowByNumberAndFile(8, 'right'), row);
-    assert.equal(cursor._findRowByNumberAndFile(5, 'left'), row);
-  });
-
-  test('expand context updates stops', async () => {
-    sinon.spy(cursor, '_updateStops');
-    MockInteractions.tap(diffElement.shadowRoot
-        .querySelector('gr-context-controls').shadowRoot
-        .querySelector('.showContext'));
-    await flush();
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  test('updates stops when loading changes', () => {
-    sinon.spy(cursor, '_updateStops');
-    diffElement.dispatchEvent(new Event('loading-changed'));
-    assert.isTrue(cursor._updateStops.called);
-  });
-
-  suite('multi diff', () => {
-    const multiDiffFixture = fixtureFromTemplate(html`
-      <gr-diff></gr-diff>
-      <gr-diff></gr-diff>
-      <gr-diff></gr-diff>
-    `);
-
-    let diffElements;
-
-    setup(() => {
-      diffElements = multiDiffFixture.instantiate();
-      cursor = new GrDiffCursor();
-
-      // Register the diff with the cursor.
-      cursor.replaceDiffs(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(cursor.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
-      cursor.moveToLineNumber(lastLine - 1, 'right');
-      assert.equal(
-          cursor.getTargetLineElement().textContent, lastLine - 1);
-
-      // Can move down until we reach the loading file
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Cannot move down while still loading the diff we would switch to
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
-
-      // Diff 1 finishing to load
-      diffElements[1].diff = getMockDiffResponse();
-      await diffRenderedPromises[1];
-
-      // Now we can go down
-      cursor.moveDown();
-      assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursor.getTargetLineElement().textContent, 'File');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
deleted file mode 100644
index 79e7dbc..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
+++ /dev/null
@@ -1,288 +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 {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings';
-
-// TODO(wyatta): refactor this to be <MARK> rather than <HL>.
-const ANNOTATION_TAG = 'HL';
-
-// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
-const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
-
-export const GrAnnotation = {
-  /**
-   * The DOM API textContent.length calculation is broken when the text
-   * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
-   *
-   */
-  getLength(node: Node) {
-    if (node instanceof Comment) return 0;
-    return this.getStringLength(node.textContent || '');
-  },
-
-  getStringLength(str: string) {
-    return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
-  },
-
-  /**
-   * Annotates the [offset, offset+length) text segment in the parent with the
-   * element definition provided as arguments.
-   *
-   * @param parent the node whose contents will be annotated.
-   * If parent is Text then parent.parentNode must not be null
-   * @param offset the 0-based offset from which the annotation will
-   * start.
-   * @param length of the annotated text.
-   * @param elementSpec the spec to create the
-   * annotating element.
-   */
-  annotateWithElement(
-    parent: Node,
-    offset: number,
-    length: number,
-    elSpec: ElementSpec
-  ) {
-    const tagName = elSpec.tagName;
-    const attributes = elSpec.attributes || {};
-    let childNodes: Node[];
-
-    if (parent instanceof Element) {
-      childNodes = Array.from(parent.childNodes);
-    } else if (parent instanceof Text) {
-      childNodes = [parent];
-      parent = parent.parentNode!;
-    } else {
-      return;
-    }
-
-    const nestedNodes: Node[] = [];
-    for (let node of childNodes) {
-      const initialNodeLength = this.getLength(node);
-      // If the current node is completely before the offset.
-      if (offset > 0 && initialNodeLength <= offset) {
-        offset -= initialNodeLength;
-        continue;
-      }
-
-      if (offset > 0) {
-        node = this.splitNode(node, offset);
-        offset = 0;
-      }
-      if (this.getLength(node) > length) {
-        this.splitNode(node, length);
-      }
-      nestedNodes.push(node);
-
-      length -= this.getLength(node);
-      if (!length) break;
-    }
-
-    const wrapper = document.createElement(tagName);
-    const sanitizer = getSanitizeDOMValue();
-    for (let [name, value] of Object.entries(attributes)) {
-      if (!value) continue;
-      if (sanitizer) {
-        value = sanitizer(value, name, 'attribute', wrapper) as string;
-      }
-      wrapper.setAttribute(name, value);
-    }
-    for (const inner of nestedNodes) {
-      parent.replaceChild(wrapper, inner);
-      wrapper.appendChild(inner);
-    }
-  },
-
-  /**
-   * Surrounds the element's text at specified range in an ANNOTATION_TAG
-   * element. If the element has child elements, the range is split and
-   * applied as deeply as possible.
-   */
-  annotateElement(
-    parent: HTMLElement,
-    offset: number,
-    length: number,
-    cssClass: string
-  ) {
-    const nodes: Array<HTMLElement | Text> = [].slice.apply(parent.childNodes);
-    let nodeLength;
-    let subLength;
-
-    for (const node of nodes) {
-      nodeLength = this.getLength(node);
-
-      // If the current node is completely before the offset.
-      if (nodeLength <= offset) {
-        offset -= nodeLength;
-        continue;
-      }
-
-      // Sublength is the annotation length for the current node.
-      subLength = Math.min(length, nodeLength - offset);
-
-      if (node instanceof Text) {
-        this._annotateText(node, offset, subLength, cssClass);
-      } else if (node instanceof Element) {
-        this.annotateElement(node, offset, subLength, cssClass);
-      }
-
-      // If there is still more to annotate, then shift the indices, otherwise
-      // work is done, so break the loop.
-      if (subLength < length) {
-        length -= subLength;
-        offset = 0;
-      } else {
-        break;
-      }
-    }
-  },
-
-  /**
-   * Wraps node in annotation tag with cssClass, replacing the node in DOM.
-   */
-  wrapInHighlight(node: Element | Text, cssClass: string) {
-    let hl;
-    if (!(node instanceof Text) && node.tagName === ANNOTATION_TAG) {
-      hl = node;
-      hl.classList.add(cssClass);
-    } else {
-      hl = document.createElement(ANNOTATION_TAG);
-      hl.className = cssClass;
-      if (node.parentElement) node.parentElement.replaceChild(hl, node);
-      hl.appendChild(node);
-    }
-    return hl;
-  },
-
-  /**
-   * Splits Text Node and wraps it in hl with cssClass.
-   * Wraps trailing part after split, tailing one if firstPart is true.
-   */
-  splitAndWrapInHighlight(
-    node: Text,
-    offset: number,
-    cssClass: string,
-    firstPart?: boolean
-  ) {
-    if (this.getLength(node) === offset || offset === 0) {
-      return this.wrapInHighlight(node, cssClass);
-    } else {
-      if (firstPart) {
-        this.splitNode(node, offset);
-        // Node points to first part of the Text, second one is sibling.
-      } else {
-        // if node is Text then splitNode will return a Text
-        node = this.splitNode(node, offset) as Text;
-      }
-      return this.wrapInHighlight(node, cssClass);
-    }
-  },
-
-  /**
-   * Splits Node at offset.
-   * If Node is Element, it's cloned and the node at offset is split too.
-   */
-  splitNode(element: Node, offset: number) {
-    if (element instanceof Text) {
-      return this.splitTextNode(element, offset);
-    }
-    const tail = element.cloneNode(false);
-
-    if (element.parentElement)
-      element.parentElement.insertBefore(tail, element.nextSibling);
-    // Skip nodes before offset.
-    let node = element.firstChild;
-    while (
-      node &&
-      (this.getLength(node) <= offset || this.getLength(node) === 0)
-    ) {
-      offset -= this.getLength(node);
-      node = node.nextSibling;
-    }
-    if (node && this.getLength(node) > offset) {
-      tail.appendChild(this.splitNode(node, offset));
-    }
-    while (node && node.nextSibling) {
-      tail.appendChild(node.nextSibling);
-    }
-    return tail;
-  },
-
-  /**
-   * Node.prototype.splitText Unicode-valid alternative.
-   *
-   * DOM Api for splitText() is broken for Unicode:
-   * https://mathiasbynens.be/notes/javascript-unicode
-   *
-   * @return Trailing Text Node.
-   */
-  splitTextNode(node: Text, offset: number) {
-    if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
-      // TODO (viktard): Polyfill Array.from for IE10.
-      const head = Array.from(node.textContent);
-      const tail = head.splice(offset);
-      const parent = node.parentNode;
-
-      // Split the content of the original node.
-      node.textContent = head.join('');
-
-      const tailNode = document.createTextNode(tail.join(''));
-      if (parent) {
-        parent.insertBefore(tailNode, node.nextSibling);
-      }
-      return tailNode;
-    } else {
-      return node.splitText(offset);
-    }
-  },
-
-  _annotateText(node: Text, offset: number, length: number, cssClass: string) {
-    const nodeLength = this.getLength(node);
-
-    // There are four cases:
-    //  1) Entire node is highlighted.
-    //  2) Highlight is at the start.
-    //  3) Highlight is at the end.
-    //  4) Highlight is in the middle.
-
-    if (offset === 0 && nodeLength === length) {
-      // Case 1.
-      this.wrapInHighlight(node, cssClass);
-    } else if (offset === 0) {
-      // Case 2.
-      this.splitAndWrapInHighlight(node, length, cssClass, true);
-    } else if (offset + length === nodeLength) {
-      // Case 3
-      this.splitAndWrapInHighlight(node, offset, cssClass, false);
-    } else {
-      // Case 4
-      this.splitAndWrapInHighlight(
-        this.splitTextNode(node, offset),
-        length,
-        cssClass,
-        true
-      );
-    }
-  },
-};
-
-/**
- * Data used to construct an element.
- *
- */
-export interface ElementSpec {
-  tagName: string;
-  attributes?: {[attributeName: string]: string | undefined};
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js
deleted file mode 100644
index d8295a5..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js
+++ /dev/null
@@ -1,299 +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.
- */
-
-const basicFixture = fixtureFromTemplate(html`
-<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-`);
-
-import '../../../test/common-test-setup-karma.js';
-import {GrAnnotation} from './gr-annotation.js';
-import {sanitizeDOMValue, setSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-suite('annotation', () => {
-  let str;
-  let parent;
-  let textNode;
-
-  setup(() => {
-    parent = basicFixture.instantiate();
-    textNode = parent.childNodes[0];
-    str = textNode.textContent;
-  });
-
-  test('_annotateText Case 1', () => {
-    GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 1);
-    assert.instanceOf(parent.childNodes[0], HTMLElement);
-    assert.equal(parent.childNodes[0].className, 'foobar');
-    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
-    assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
-  });
-
-  test('_annotateText Case 2', () => {
-    const length = 12;
-    const substr = str.substr(0, length);
-    const remainder = str.substr(length);
-
-    GrAnnotation._annotateText(textNode, 0, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 2);
-
-    assert.instanceOf(parent.childNodes[0], HTMLElement);
-    assert.equal(parent.childNodes[0].className, 'foobar');
-    assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
-    assert.equal(parent.childNodes[0].childNodes[0].textContent, substr);
-
-    assert.instanceOf(parent.childNodes[1], Text);
-    assert.equal(parent.childNodes[1].textContent, remainder);
-  });
-
-  test('_annotateText Case 3', () => {
-    const index = 12;
-    const length = str.length - index;
-    const remainder = str.substr(0, index);
-    const substr = str.substr(index);
-
-    GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 2);
-
-    assert.instanceOf(parent.childNodes[0], Text);
-    assert.equal(parent.childNodes[0].textContent, remainder);
-
-    assert.instanceOf(parent.childNodes[1], HTMLElement);
-    assert.equal(parent.childNodes[1].className, 'foobar');
-    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
-    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
-  });
-
-  test('_annotateText Case 4', () => {
-    const index = str.indexOf('dolor');
-    const length = 'dolor '.length;
-
-    const remainderPre = str.substr(0, index);
-    const substr = str.substr(index, length);
-    const remainderPost = str.substr(index + length);
-
-    GrAnnotation._annotateText(textNode, index, length, 'foobar');
-
-    assert.equal(parent.childNodes.length, 3);
-
-    assert.instanceOf(parent.childNodes[0], Text);
-    assert.equal(parent.childNodes[0].textContent, remainderPre);
-
-    assert.instanceOf(parent.childNodes[1], HTMLElement);
-    assert.equal(parent.childNodes[1].className, 'foobar');
-    assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
-    assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
-
-    assert.instanceOf(parent.childNodes[2], Text);
-    assert.equal(parent.childNodes[2].textContent, remainderPost);
-  });
-
-  test('_annotateElement design doc example', () => {
-    const layers = [
-      'amet, ',
-      'inceptos ',
-      'amet, ',
-      'et, suspendisse ince',
-    ];
-
-    // Apply the layers successively.
-    layers.forEach((layer, i) => {
-      GrAnnotation.annotateElement(
-          parent, str.indexOf(layer), layer.length, `layer-${i + 1}`);
-    });
-
-    assert.equal(parent.textContent, str);
-
-    // Layer 1:
-    const layer1 = parent.querySelectorAll('.layer-1');
-    assert.equal(layer1.length, 1);
-    assert.equal(layer1[0].textContent, layers[0]);
-    assert.equal(layer1[0].parentElement, parent);
-
-    // Layer 2:
-    const layer2 = parent.querySelectorAll('.layer-2');
-    assert.equal(layer2.length, 1);
-    assert.equal(layer2[0].textContent, layers[1]);
-    assert.equal(layer2[0].parentElement, parent);
-
-    // Layer 3:
-    const layer3 = parent.querySelectorAll('.layer-3');
-    assert.equal(layer3.length, 1);
-    assert.equal(layer3[0].textContent, layers[2]);
-    assert.equal(layer3[0].parentElement, layer1[0]);
-
-    // Layer 4:
-    const layer4 = parent.querySelectorAll('.layer-4');
-    assert.equal(layer4.length, 3);
-
-    assert.equal(layer4[0].textContent, 'et, ');
-    assert.equal(layer4[0].parentElement, layer3[0]);
-
-    assert.equal(layer4[1].textContent, 'suspendisse ');
-    assert.equal(layer4[1].parentElement, parent);
-
-    assert.equal(layer4[2].textContent, 'ince');
-    assert.equal(layer4[2].parentElement, layer2[0]);
-
-    assert.equal(layer4[0].textContent +
-        layer4[1].textContent +
-        layer4[2].textContent,
-    layers[3]);
-  });
-
-  test('splitTextNode', () => {
-    const helloString = 'hello';
-    const asciiString = 'ASCII';
-    const unicodeString = 'Unic💢de';
-
-    let node;
-    let tail;
-
-    // Non-unicode path:
-    node = document.createTextNode(helloString + asciiString);
-    tail = GrAnnotation.splitTextNode(node, helloString.length);
-    assert(node.textContent, helloString);
-    assert(tail.textContent, asciiString);
-
-    // Unicdoe path:
-    node = document.createTextNode(helloString + unicodeString);
-    tail = GrAnnotation.splitTextNode(node, helloString.length);
-    assert(node.textContent, helloString);
-    assert(tail.textContent, unicodeString);
-  });
-
-  suite('annotateWithElement', () => {
-    const fullText = '01234567890123456789';
-    let mockSanitize;
-    let originalSanitizeDOMValue;
-
-    setup(() => {
-      originalSanitizeDOMValue = sanitizeDOMValue;
-      assert.isDefined(originalSanitizeDOMValue);
-      mockSanitize = sinon.spy(originalSanitizeDOMValue);
-      setSanitizeDOMValue(mockSanitize);
-    });
-
-    teardown(() => {
-      setSanitizeDOMValue(originalSanitizeDOMValue);
-    });
-
-    test('annotates when fully contained', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>1234567890</test-wrapper>123456789');
-    });
-
-    test('annotates when spanning multiple nodes', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateElement(container, 5, length, 'testclass');
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0' +
-          '<test-wrapper>' +
-          '1234' +
-          '<hl class="testclass">567890</hl>' +
-          '</test-wrapper>' +
-          '<hl class="testclass">1234</hl>' +
-          '56789');
-    });
-
-    test('annotates text node', () => {
-      const length = 10;
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      GrAnnotation.annotateWithElement(
-          container.childNodes[0], 1, length, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>1234567890</test-wrapper>123456789');
-    });
-
-    test('handles zero-length nodes', () => {
-      const container = document.createElement('div');
-      container.appendChild(document.createTextNode('0123456789'));
-      container.appendChild(document.createElement('span'));
-      container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(
-          container, 1, 10, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
-    });
-
-    test('handles comment nodes', () => {
-      const container = document.createElement('div');
-      container.appendChild(document.createComment('comment1'));
-      container.appendChild(document.createTextNode('0123456789'));
-      container.appendChild(document.createComment('comment2'));
-      container.appendChild(document.createElement('span'));
-      container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(
-          container, 1, 10, {tagName: 'test-wrapper'});
-
-      assert.equal(
-          container.innerHTML,
-          '<!--comment1-->' +
-          '0<test-wrapper>123456789' +
-          '<!--comment2-->' +
-          '<span></span>0</test-wrapper>123456789');
-    });
-
-    test('sets sanitized attributes', () => {
-      const container = document.createElement('div');
-      container.textContent = fullText;
-      const attributes = {
-        'href': 'foo',
-        'data-foo': 'bar',
-        'class': 'hello world',
-      };
-      GrAnnotation.annotateWithElement(
-          container, 1, length, {tagName: 'test-wrapper', attributes});
-      assert(mockSanitize.calledWith(
-          'foo', 'href', 'attribute', sinon.match.instanceOf(Element)));
-      assert(mockSanitize.calledWith(
-          'bar', 'data-foo', 'attribute', sinon.match.instanceOf(Element)));
-      assert(mockSanitize.calledWith(
-          'hello world',
-          'class',
-          'attribute',
-          sinon.match.instanceOf(Element)));
-      const el = container.querySelector('test-wrapper');
-      assert.equal(el.getAttribute('href'), 'foo');
-      assert.equal(el.getAttribute('data-foo'), 'bar');
-      assert.equal(el.getAttribute('class'), 'hello world');
-    });
-  });
-});
-
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
deleted file mode 100644
index a47db20..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ /dev/null
@@ -1,589 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles';
-import '../gr-selection-action-box/gr-selection-action-box';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-highlight_html';
-import {GrAnnotation} from './gr-annotation';
-import {normalize} from './gr-range-normalizer';
-import {strToClassName} from '../../../utils/dom-util';
-import {customElement, property} from '@polymer/decorators';
-import {Side} from '../../../constants/constants';
-import {CommentRange} from '../../../types/common';
-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 {
-  getLineElByChild,
-  getLineNumberByChild,
-  getRange,
-  getSide,
-  getSideByLineEl,
-} from '../gr-diff/gr-diff-utils';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {queryAndAssert} from '../../../utils/common-util';
-
-interface SidedRange {
-  side: Side;
-  range: CommentRange;
-}
-
-interface NormalizedPosition {
-  node: Node | null;
-  side: Side;
-  line: number;
-  column: number;
-}
-
-interface NormalizedRange {
-  start: NormalizedPosition | null;
-  end: NormalizedPosition | null;
-}
-
-// TODO(TS): Replace by GrCommentThread once that is converted.
-interface CommentThreadElement extends HTMLElement {
-  rootId: string;
-}
-
-@customElement('gr-diff-highlight')
-export class GrDiffHighlight extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Array, notify: true})
-  commentRanges: SidedRange[] = [];
-
-  @property({type: Boolean})
-  loggedIn?: boolean;
-
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
-
-  @property({type: Object, notify: true})
-  selectedRange?: SidedRange;
-
-  private selectionChangeTask?: DelayedTask;
-
-  constructor() {
-    super();
-    this.addEventListener('comment-thread-mouseleave', e =>
-      this._handleCommentThreadMouseleave(e)
-    );
-    this.addEventListener('comment-thread-mouseenter', e =>
-      this._handleCommentThreadMouseenter(e)
-    );
-    this.addEventListener('create-comment-requested', e =>
-      this._handleRangeCommentRequest(e)
-    );
-  }
-
-  override disconnectedCallback() {
-    this.selectionChangeTask?.cancel();
-    super.disconnectedCallback();
-  }
-
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
-    }
-    return this._cachedDiffBuilder;
-  }
-
-  /**
-   * Determines side/line/range for a DOM selection and shows a tooltip.
-   *
-   * With native shadow DOM, gr-diff-highlight cannot access a selection that
-   * references the DOM elements making up the diff because they are in the
-   * shadow DOM the gr-diff element. For this reason, we listen to the
-   * selectionchange event and retrieve the selection in gr-diff, and then
-   * call this method to process the Selection.
-   *
-   * @param selection A DOM Selection living in the shadow DOM of
-   * the diff element.
-   * @param isMouseUp If true, this is called due to a mouseup
-   * event, in which case we might want to immediately create a comment,
-   * because isMouseUp === true combined with an existing selection must
-   * mean that this is the end of a double-click.
-   */
-  handleSelectionChange(
-    selection: Selection | Range | null,
-    isMouseUp: boolean
-  ) {
-    if (selection === null) return;
-    // Debounce is not just nice for waiting until the selection has settled,
-    // it is also vital for being able to click on the action box before it is
-    // removed.
-    // If you wait longer than 50 ms, then you don't properly catch a very
-    // 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.selectionChangeTask = debounce(
-      this.selectionChangeTask,
-      () => this._handleSelection(selection, isMouseUp),
-      10
-    );
-  }
-
-  _getThreadEl(e: Event): CommentThreadElement | null {
-    const path = (dom(e) as EventApi).path || [];
-    for (const pathEl of path) {
-      if (
-        pathEl instanceof HTMLElement &&
-        pathEl.classList.contains('comment-thread')
-      ) {
-        return pathEl as CommentThreadElement;
-      }
-    }
-    return null;
-  }
-
-  _toggleRangeElHighlight(
-    threadEl: CommentThreadElement,
-    highlightRange = false
-  ) {
-    // We don't want to re-create the line just for highlighting the range which
-    // is creating annoying bugs: @see Issue 12934
-    // As gr-ranged-comment-layer now does not notify the layer re-render and
-    // lack of access to the thread or the lineEl from the ranged-comment-layer,
-    // need to update range class for styles here.
-    let curNode: HTMLElement | null = threadEl.assignedSlot;
-    while (curNode) {
-      if (curNode.nodeName === 'TABLE') break;
-      curNode = curNode.parentElement;
-    }
-    if (curNode?.querySelectorAll) {
-      if (highlightRange) {
-        const rangeNodes = curNode.querySelectorAll(
-          `.range.${strToClassName(threadEl.rootId)}`
-        );
-        rangeNodes.forEach(rangeNode => {
-          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(
-          `.rangeHoverHighlight.${strToClassName(threadEl.rootId)}`
-        );
-        rangeNodes.forEach(rangeNode => {
-          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')
-            );
-        }
-      }
-    }
-  }
-
-  _handleCommentThreadMouseenter(e: Event) {
-    const threadEl = this._getThreadEl(e)!;
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], true);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
-  }
-
-  _handleCommentThreadMouseleave(e: Event) {
-    const threadEl = this._getThreadEl(e)!;
-    const index = this._indexForThreadEl(threadEl);
-
-    if (index !== undefined) {
-      this.set(['commentRanges', index, 'hovering'], false);
-    }
-
-    this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
-  }
-
-  _indexForThreadEl(threadEl: HTMLElement) {
-    const side = getSide(threadEl);
-    const range = getRange(threadEl);
-    if (!side || !range) return undefined;
-    return this._indexOfCommentRange(side, range);
-  }
-
-  _indexOfCommentRange(side: Side, range: CommentRange) {
-    function rangesEqual(a: CommentRange, b: CommentRange) {
-      if (!a && !b) {
-        return true;
-      }
-      if (!a || !b) {
-        return false;
-      }
-      return (
-        a.start_line === b.start_line &&
-        a.start_character === b.start_character &&
-        a.end_line === b.end_line &&
-        a.end_character === b.end_character
-      );
-    }
-
-    return this.commentRanges.findIndex(
-      commentRange =>
-        commentRange.side === side && rangesEqual(commentRange.range, range)
-    );
-  }
-
-  /**
-   * Get current normalized selection.
-   * Merges multiple ranges, accounts for triple click, accounts for
-   * syntax highligh, convert native DOM Range objects to Gerrit concepts
-   * (line, side, etc).
-   */
-  _getNormalizedRange(selection: Selection | Range) {
-    /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
-       we can get is a single Range */
-    if (selection instanceof Range) {
-      return this._normalizeRange(selection);
-    }
-    const rangeCount = selection.rangeCount;
-    if (rangeCount === 0) {
-      return null;
-    } else if (rangeCount === 1) {
-      return this._normalizeRange(selection.getRangeAt(0));
-    } else {
-      const startRange = this._normalizeRange(selection.getRangeAt(0));
-      const endRange = this._normalizeRange(
-        selection.getRangeAt(rangeCount - 1)
-      );
-      return {
-        start: startRange.start,
-        end: endRange.end,
-      };
-    }
-  }
-
-  /**
-   * Normalize a specific DOM Range.
-   *
-   * @return fixed normalized range
-   */
-  _normalizeRange(domRange: Range): NormalizedRange {
-    const range = normalize(domRange);
-    return this._fixTripleClickSelection(
-      {
-        start: this._normalizeSelectionSide(
-          range.startContainer,
-          range.startOffset
-        ),
-        end: this._normalizeSelectionSide(range.endContainer, range.endOffset),
-      },
-      domRange
-    );
-  }
-
-  /**
-   * Adjust triple click selection for the whole line.
-   * A triple click always results in:
-   * - start.column == end.column == 0
-   * - end.line == start.line + 1
-   *
-   * @param range Normalized range, ie column/line numbers
-   * @param domRange DOM Range object
-   * @return fixed normalized range
-   */
-  _fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
-    if (!range.start) {
-      // Selection outside of current diff.
-      return range;
-    }
-    const start = range.start;
-    const end = range.end;
-    // Happens when triple click in side-by-side mode with other side empty.
-    const endsAtOtherEmptySide =
-      !end &&
-      domRange.endOffset === 0 &&
-      domRange.endContainer instanceof HTMLElement &&
-      domRange.endContainer.nodeName === 'TD' &&
-      (domRange.endContainer.classList.contains('left') ||
-        domRange.endContainer.classList.contains('right'));
-    const endsAtBeginningOfNextLine =
-      end &&
-      start.column === 0 &&
-      end.column === 0 &&
-      end.line === start.line + 1;
-    const content = domRange.cloneContents().querySelector('.contentText');
-    const lineLength = (content && this._getLength(content)) || 0;
-    if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
-      // Move the selection to the end of the previous line.
-      range.end = {
-        node: start.node,
-        column: lineLength,
-        side: start.side,
-        line: start.line,
-      };
-    }
-    return range;
-  }
-
-  /**
-   * Convert DOM Range selection to concrete numbers (line, column, side).
-   * Moves range end if it's not inside td.content.
-   * Returns null if selection end is not valid (outside of diff).
-   *
-   * @param node td.content child
-   * @param offset offset within node
-   */
-  _normalizeSelectionSide(
-    node: Node | null,
-    offset: number
-  ): NormalizedPosition | null {
-    let column;
-    if (!node || !this.contains(node)) return null;
-    const lineEl = getLineElByChild(node);
-    if (!lineEl) return null;
-    const side = getSideByLineEl(lineEl);
-    if (!side) return null;
-    const line = getLineNumberByChild(lineEl);
-    if (!line || line === FILE || line === 'LOST') return null;
-    const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
-    if (!contentTd) return null;
-    const contentText = contentTd.querySelector('.contentText');
-    if (!contentTd.contains(node)) {
-      node = contentText;
-      column = 0;
-    } else {
-      const thread = contentTd.querySelector('.comment-thread');
-      if (thread?.contains(node)) {
-        column = this._getLength(contentText);
-        node = contentText;
-      } else {
-        column = this._convertOffsetToColumn(node, offset);
-      }
-    }
-
-    return {
-      node,
-      side,
-      line,
-      column,
-    };
-  }
-
-  /**
-   * The only line in which add a comment tooltip is cut off is the first
-   * line. Even if there is a collapsed section, The first visible line is
-   * in the position where the second line would have been, if not for the
-   * collapsed section, so don't need to worry about this case for
-   * positioning the tooltip.
-   */
-  _positionActionBox(
-    actionBox: GrSelectionActionBox,
-    startLine: number,
-    range: Text | Element | Range
-  ) {
-    if (startLine > 1) {
-      actionBox.positionBelow = false;
-      actionBox.placeAbove(range);
-      return;
-    }
-    actionBox.positionBelow = true;
-    actionBox.placeBelow(range);
-  }
-
-  _isRangeValid(range: NormalizedRange | null) {
-    if (!range || !range.start || !range.start.node || !range.end) {
-      return false;
-    }
-    const start = range.start;
-    const end = range.end;
-    return !(
-      start.side !== end.side ||
-      end.line < start.line ||
-      (start.line === end.line && start.column === end.column)
-    );
-  }
-
-  _handleSelection(selection: Selection | Range, isMouseUp: boolean) {
-    /* On Safari, the selection events may return a null range that should
-       be ignored */
-    if (!selection) {
-      return;
-    }
-    const normalizedRange = this._getNormalizedRange(selection);
-    if (!this._isRangeValid(normalizedRange)) {
-      this._removeActionBox();
-      return;
-    }
-    /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
-       we can get is a single Range */
-    const domRange =
-      selection instanceof Range ? selection : selection.getRangeAt(0);
-    const start = normalizedRange!.start!;
-    const end = normalizedRange!.end!;
-
-    // TODO (viktard): Drop empty first and last lines from selection.
-
-    // If the selection is from the end of one line to the start of the next
-    // line, then this must have been a double-click, or you have started
-    // dragging. Showing the action box is bad in the former case and not very
-    // useful in the latter, so never do that.
-    // If this was a mouse-up event, we create a comment immediately if
-    // the selection is from the end of a line to the start of the next line.
-    // In a perfect world we would only do this for double-click, but it is
-    // extremely rare that a user would drag from the end of one line to the
-    // start of the next and release the mouse, so we don't bother.
-    // TODO(brohlfs): This does not work, if the double-click is before a new
-    // diff chunk (start will be equal to end), and neither before an "expand
-    // the diff context" block (end line will match the first line of the new
-    // section and thus be greater than start line + 1).
-    if (start.line === end.line - 1 && end.column === 0) {
-      // Rather than trying to find the line contents (for comparing
-      // start.column with the content length), we just check if the selection
-      // is empty to see that it's at the end of a line.
-      const content = domRange.cloneContents().querySelector('.contentText');
-      if (isMouseUp && this._getLength(content) === 0) {
-        this._fireCreateRangeComment(start.side, {
-          start_line: start.line,
-          start_character: 0,
-          end_line: start.line,
-          end_character: start.column,
-        });
-      }
-      return;
-    }
-
-    let actionBox = this.shadowRoot!.querySelector(
-      'gr-selection-action-box'
-    ) as GrSelectionActionBox | null;
-    if (!actionBox) {
-      actionBox = document.createElement('gr-selection-action-box');
-      this.root!.insertBefore(actionBox, this.root!.firstElementChild);
-    }
-    this.selectedRange = {
-      range: {
-        start_line: start.line,
-        start_character: start.column,
-        end_line: end.line,
-        end_character: end.column,
-      },
-      side: start.side,
-    };
-    if (start.line === end.line) {
-      this._positionActionBox(actionBox, start.line, domRange);
-    } else if (start.node instanceof Text) {
-      if (start.column) {
-        this._positionActionBox(
-          actionBox,
-          start.line,
-          start.node.splitText(start.column)
-        );
-      }
-      start.node.parentElement!.normalize(); // Undo splitText from above.
-    } else if (
-      start.node instanceof HTMLElement &&
-      start.node.classList.contains('content') &&
-      (start.node.firstChild instanceof Element ||
-        start.node.firstChild instanceof Text)
-    ) {
-      this._positionActionBox(actionBox, start.line, start.node.firstChild);
-    } else if (start.node instanceof Element || start.node instanceof Text) {
-      this._positionActionBox(actionBox, start.line, start.node);
-    } else {
-      console.warn('Failed to position comment action box.');
-      this._removeActionBox();
-    }
-  }
-
-  _fireCreateRangeComment(side: Side, range: CommentRange) {
-    this.dispatchEvent(
-      new CustomEvent('create-range-comment', {
-        detail: {side, range},
-        composed: true,
-        bubbles: true,
-      })
-    );
-    this._removeActionBox();
-  }
-
-  _handleRangeCommentRequest(e: Event) {
-    e.stopPropagation();
-    if (!this.selectedRange) {
-      throw Error('Selected Range is needed for new range comment!');
-    }
-    const {side, range} = this.selectedRange;
-    this._fireCreateRangeComment(side, range);
-  }
-
-  _removeActionBox() {
-    this.selectedRange = undefined;
-    const actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
-    if (actionBox) {
-      this.root!.removeChild(actionBox);
-    }
-  }
-
-  _convertOffsetToColumn(el: Node, offset: number) {
-    if (el instanceof Element && el.classList.contains('content')) {
-      return offset;
-    }
-    while (
-      el.previousSibling ||
-      !el.parentElement?.classList.contains('content')
-    ) {
-      if (el.previousSibling) {
-        el = el.previousSibling;
-        offset += this._getLength(el);
-      } else {
-        el = el.parentElement!;
-      }
-    }
-    return offset;
-  }
-
-  /**
-   * Get length of a node. If the node is a content node, then only give the
-   * length of its .contentText child.
-   *
-   * @param node this is sometimes passed as null.
-   */
-  _getLength(node: Node | null): number {
-    if (node === null) return 0;
-    if (node instanceof Element && node.classList.contains('content')) {
-      return this._getLength(queryAndAssert(node, '.contentText'));
-    } else {
-      return GrAnnotation.getLength(node);
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-highlight': GrDiffHighlight;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.ts
deleted file mode 100644
index 5a6cb1c..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_html.ts
+++ /dev/null
@@ -1,35 +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 {
-      position: relative;
-    }
-    gr-selection-action-box {
-      /**
-         * Needs z-index to appear above wrapped content, since it's inserted
-         * into DOM before it.
-         */
-      z-index: 10;
-    }
-  </style>
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
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
deleted file mode 100644
index 4c1295f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
+++ /dev/null
@@ -1,589 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-highlight.js';
-import {_getTextOffset} from './gr-range-normalizer.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-// Splitting long lines in html into shorter rows breaks tests:
-// zero-length text nodes and new lines are not expected in some places
-/* eslint-disable max-len */
-const basicFixture = fixtureFromTemplate(html`
-<style>
-      .tab-indicator:before {
-        color: #C62828;
-        /* >> character */
-        content: '\\00BB';
-      }
-    </style>
-    <gr-diff-highlight>
-      <table id="diffTable">
-
-        <tbody class="section both">
-           <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="1"></td>
-            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div></td>
-            <td class="right lineNum" data-value="2"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-<tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="138"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-            <td class="right lineNum" data-value="119"></td>
-            <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta">
-          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="140"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
-                [Yet another random diff thread content here]
-            </div></td>
-            <td class="right lineNum" data-value="120"></td>
-            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="141"></td>
-            <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>complectitur<span class="tab-indicator" style="tab-size:8;">\u0009</span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-            <td class="right lineNum" data-value="130"></td>
-            <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section contextControl">
-          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
-            <td class="left contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-            <td class="right contextLineNum"></td>
-            <td>
-              <gr-button>+10↑</gr-button>
-              -
-              <gr-button>Show 21 common lines</gr-button>
-              -
-              <gr-button>+10↓</gr-button>
-            </td>
-          </tr>
-        </tbody>
-
-        <tbody class="section delta total">
-          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
-            <td class="left"></td>
-            <td class="blank"></td>
-            <td class="right lineNum" data-value="146"></td>
-            <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
-          </tr>
-        </tbody>
-
-        <tbody class="section both">
-          <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="165"></td>
-            <td class="content both"><div class="contentText"></div></td>
-            <td class="right lineNum" data-value="147"></td>
-            <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
-          </tr>
-        </tbody>
-
-      </table>
-    </gr-diff-highlight>
-`);
-/* eslint-enable max-len */
-
-suite('gr-diff-highlight', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate()[1];
-  });
-
-  suite('comment events', () => {
-    let builder;
-
-    setup(() => {
-      builder = {
-        getContentsByLineRange: sinon.stub().returns([]),
-        getLineElByChild: sinon.stub().returns({}),
-        getSideByLineEl: sinon.stub().returns('other-side'),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    test('comment-thread-mouseenter from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test('comment-thread-mouseenter from ranged comment causes set', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      threadEl.setAttribute('range', JSON.stringify({
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }));
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right', range: {
-        start_line: 3,
-        start_character: 4,
-        end_line: 5,
-        end_character: 6,
-      }}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseenter', {bubbles: true, composed: true}));
-      assert.isTrue(element.set.called);
-      const args = element.set.lastCall.args;
-      assert.deepEqual(args[0], ['commentRanges', 0, 'hovering']);
-      assert.deepEqual(args[1], true);
-    });
-
-    test('comment-thread-mouseleave from line comments is ignored', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      element.commentRanges = [{side: 'right'}];
-
-      sinon.stub(element, 'set');
-      threadEl.dispatchEvent(new CustomEvent(
-          'comment-thread-mouseleave', {bubbles: true, composed: true}));
-      assert.isFalse(element.set.called);
-    });
-
-    test(`create-range-comment for range when create-comment-requested
-          is fired`, () => {
-      sinon.stub(element, '_removeActionBox');
-      element.selectedRange = {
-        side: 'left',
-        range: {
-          start_line: 7,
-          start_character: 11,
-          end_line: 24,
-          end_character: 42,
-        },
-      };
-      const requestEvent = new CustomEvent('create-comment-requested');
-      let createRangeEvent;
-      element.addEventListener('create-range-comment', e => {
-        createRangeEvent = e;
-      });
-      element.dispatchEvent(requestEvent);
-      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
-      assert.isTrue(element._removeActionBox.called);
-    });
-  });
-
-  suite('selection', () => {
-    let diff;
-    let builder;
-    let contentStubs;
-
-    const stubContent = (line, side, opt_child) => {
-      const contentTd = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"] ~ .content`);
-      const contentText = contentTd.querySelector('.contentText');
-      const lineEl = diff.querySelector(
-          `.${side}.lineNum[data-value="${line}"]`);
-      contentStubs.push({
-        lineEl,
-        contentTd,
-        contentText,
-      });
-      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
-      builder.getLineNumberByChild.withArgs(lineEl).returns(line);
-      builder.getContentTdByLine.withArgs(line, side).returns(contentTd);
-      builder.getSideByLineEl.withArgs(lineEl).returns(side);
-      return contentText;
-    };
-
-    const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
-      const selection = document.getSelection();
-      const range = document.createRange();
-      range.setStart(startNode, startOffset);
-      range.setEnd(endNode, endOffset);
-      selection.addRange(range);
-      element._handleSelection(selection);
-    };
-
-    const getLineElByChild = node => {
-      const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
-      return stubs && stubs.lineEl;
-    };
-
-    setup(() => {
-      contentStubs = [];
-      stub('gr-selection-action-box', 'placeAbove');
-      stub('gr-selection-action-box', 'placeBelow');
-      diff = element.querySelector('#diffTable');
-      builder = {
-        getContentTdByLine: sinon.stub(),
-        getContentTdByLineEl: sinon.stub(),
-        getLineElByChild,
-        getLineNumberByChild: sinon.stub(),
-        getSideByLineEl: sinon.stub(),
-      };
-      element._cachedDiffBuilder = builder;
-    });
-
-    teardown(() => {
-      contentStubs = null;
-      document.getSelection().removeAllRanges();
-    });
-
-    test('single first line', () => {
-      const content = stubContent(1, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('multiline starting on first line', () => {
-      const startContent = stubContent(1, 'right');
-      const endContent = stubContent(2, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      assert.isTrue(actionBox.positionBelow);
-    });
-
-    test('single line', () => {
-      const content = stubContent(138, 'left');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(content.firstChild, 5, content.firstChild, 12);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 138,
-        start_character: 5,
-        end_line: 138,
-        end_character: 12,
-      });
-      assert.equal(side, 'left');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiline', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      sinon.spy(element, '_positionActionBox');
-      emulateSelection(
-          startContent.firstChild, 10, endContent.lastChild, 7);
-      const actionBox = element.shadowRoot
-          .querySelector('gr-selection-action-box');
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-      assert.equal(side, 'right');
-      assert.notOk(actionBox.positionBelow);
-    });
-
-    test('multiple ranges aka firefox implementation', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-
-      const startRange = document.createRange();
-      startRange.setStart(startContent.firstChild, 10);
-      startRange.setEnd(startContent.firstChild, 11);
-
-      const endRange = document.createRange();
-      endRange.setStart(endContent.lastChild, 6);
-      endRange.setEnd(endContent.lastChild, 7);
-
-      const getRangeAtStub = sinon.stub();
-      getRangeAtStub
-          .onFirstCall().returns(startRange)
-          .onSecondCall()
-          .returns(endRange);
-      const selection = {
-        rangeCount: 2,
-        getRangeAt: getRangeAtStub,
-        removeAllRanges: sinon.stub(),
-      };
-      element._handleSelection(selection);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 36,
-      });
-    });
-
-    test('multiline grow end highlight over tabs', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 10,
-        end_line: 120,
-        end_character: 2,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('collapsed', () => {
-      const content = stubContent(138, 'left');
-      emulateSelection(content.firstChild, 5, content.firstChild, 5);
-      assert.isOk(document.getSelection().getRangeAt(0).startContainer);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.foo');
-      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 8,
-        end_line: 140,
-        end_character: 23,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends inside hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelector('.bar');
-      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
-      const {range} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 18,
-        end_line: 140,
-        end_character: 27,
-      });
-    });
-
-    test('multiple hl', () => {
-      const content = stubContent(140, 'left');
-      const hl = content.querySelectorAll('hl')[4];
-      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 2,
-        end_line: 140,
-        end_character: 61,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts outside of diff', () => {
-      const contentText = stubContent(140, 'left');
-      const contentTd = contentText.parentElement;
-
-      emulateSelection(contentTd.parentElement, 0,
-          contentText.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends outside of diff', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(content.nextElementSibling.firstChild, 2,
-          content.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts and ends on different sides', () => {
-      const startContent = stubContent(140, 'left');
-      const endContent = stubContent(130, 'right');
-      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('starts in comment thread element', () => {
-      const startContent = stubContent(140, 'left');
-      const comment = startContent.parentElement.querySelector(
-          '.comment-thread');
-      const endContent = stubContent(141, 'left');
-      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 83,
-        end_line: 141,
-        end_character: 4,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('ends in comment thread element', () => {
-      const content = stubContent(140, 'left');
-      const comment = content.parentElement.querySelector(
-          '.comment-thread');
-      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 4,
-        end_line: 140,
-        end_character: 83,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(146, 'right');
-      emulateSelection(contextControl, 0, content.firstChild, 7);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('ends in context element', () => {
-      const contextControl =
-          diff.querySelector('.contextControl').querySelector('gr-button');
-      const content = stubContent(141, 'left');
-      emulateSelection(content.firstChild, 2, contextControl, 1);
-      // TODO (viktard): Select nearest line.
-      assert.isFalse(!!element.selectedRange);
-    });
-
-    test('selection containing context element', () => {
-      const startContent = stubContent(130, 'right');
-      const endContent = stubContent(146, 'right');
-      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 130,
-        start_character: 3,
-        end_line: 146,
-        end_character: 14,
-      });
-      assert.equal(side, 'right');
-    });
-
-    test('ends at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.firstChild, 1, content.querySelector('span'), 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 1,
-        end_line: 140,
-        end_character: 51,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('starts at a tab', () => {
-      const content = stubContent(140, 'left');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1].nextSibling, 1);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 140,
-        start_character: 51,
-        end_line: 140,
-        end_character: 71,
-      });
-      assert.equal(side, 'left');
-    });
-
-    test('properly accounts for syntax highlighting', () => {
-      const content = stubContent(140, 'left');
-      const spy = sinon.spy(element, '_normalizeRange');
-      emulateSelection(
-          content.querySelectorAll('hl')[3], 0,
-          content.querySelectorAll('span')[1], 0);
-      const spyCall = spy.getCall(0);
-      const range = document.getSelection().getRangeAt(0);
-      assert.notDeepEqual(spyCall.returnValue, range);
-    });
-
-    test('GrRangeNormalizer._getTextOffset computes text offset', () => {
-      let content = stubContent(140, 'left');
-      let child = content.lastChild.lastChild;
-      let result = _getTextOffset(content, child);
-      assert.equal(result, 75);
-      content = stubContent(146, 'right');
-      child = content.lastChild;
-      result = _getTextOffset(content, child);
-      assert.equal(result, 0);
-    });
-
-    test('_fixTripleClickSelection', () => {
-      const startContent = stubContent(119, 'right');
-      const endContent = stubContent(120, 'right');
-      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 119,
-        start_character: 0,
-        end_line: 119,
-        end_character: element._getLength(startContent),
-      });
-      assert.equal(side, 'right');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.ts
deleted file mode 100644
index 469c24a..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.ts
+++ /dev/null
@@ -1,114 +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.
- */
-
-// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
-const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
-
-export interface NormalizedRange {
-  endContainer: Node;
-  endOffset: number;
-  startContainer: Node;
-  startOffset: number;
-}
-
-/**
- * Remap DOM range to whole lines of a diff if necessary. If the start or
- * end containers are DOM elements that are singular pieces of syntax
- * highlighting, the containers are remapped to the .contentText divs that
- * contain the entire line of code.
- *
- * @param range - the standard DOM selector range.
- * @return A modified version of the range that correctly accounts
- *     for syntax highlighting.
- */
-export function normalize(range: Range): NormalizedRange {
-  const startContainer = _getContentTextParent(range.startContainer);
-  const startOffset =
-    range.startOffset + _getTextOffset(startContainer, range.startContainer);
-  const endContainer = _getContentTextParent(range.endContainer);
-  const endOffset =
-    range.endOffset + _getTextOffset(endContainer, range.endContainer);
-  return {
-    startContainer,
-    startOffset,
-    endContainer,
-    endOffset,
-  };
-}
-
-function _getContentTextParent(target: Node): Node {
-  if (!target.parentElement) return target;
-
-  let element: Element | null;
-  if (target instanceof Element) {
-    element = target;
-  } else {
-    element = target.parentElement;
-  }
-
-  while (element && !element.classList.contains('contentText')) {
-    if (element.parentElement === null) {
-      return target;
-    }
-    element = element.parentElement;
-  }
-  return element ? element : target;
-}
-
-/**
- * Gets the character offset of the child within the parent.
- * Performs a synchronous in-order traversal from top to bottom of the node
- * element, counting the length of the syntax until child is found.
- *
- * @param node The root DOM element to be searched through.
- * @param child The child element being searched for.
- */
-// TODO(TS): Only export for test.
-export function _getTextOffset(node: Node | null, child: Node): number {
-  let count = 0;
-  let stack = [node];
-  while (stack.length) {
-    const n = stack.pop();
-    if (n === child) {
-      break;
-    }
-    if (n?.childNodes && n.childNodes.length !== 0) {
-      const arr = [];
-      for (const childNode of n.childNodes) {
-        arr.push(childNode);
-      }
-      arr.reverse();
-      stack = stack.concat(arr);
-    } else {
-      count += _getLength(n);
-    }
-  }
-  return count;
-}
-
-/**
- * The DOM API textContent.length calculation is broken when the text
- * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
- *
- * @param node A text node.
- * @return The length of the text.
- */
-function _getLength(node?: Node | null) {
-  return node && node.textContent
-    ? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
-    : 0;
-}
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 025e477..de92b16 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
@@ -1,61 +1,43 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-comment-thread/gr-comment-thread';
-import '../gr-diff/gr-diff';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-host_html';
-import {
-  GerritNav,
-  GeneratedWebLink,
-} from '../../core/gr-navigation/gr-navigation';
+import '../../checks/gr-diff-check-result';
+import '../../../embed/diff/gr-diff/gr-diff';
 import {
   anyLineTooLong,
+  getDiffLength,
   getLine,
-  getRange,
   getSide,
-  rangesEqual,
   SYNTAX_MAX_LINE_LENGTH,
-} from '../gr-diff/gr-diff-utils';
-import {appContext} from '../../../services/app-context';
+} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {getAppContext} from '../../../services/app-context';
 import {
   getParentIndex,
   isAParent,
   isMergeParent,
   isNumber,
 } from '../../../utils/patch-set-util';
-import {CommentThread} from '../../../utils/comment-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
-  CommitRange,
-  CoverageRange,
-  DiffLayer,
-  DiffLayerListener,
-  PatchSetFile,
-} from '../../../types/types';
+  CommentThread,
+  equalLocation,
+  isInBaseOfPatchRange,
+  isInRevisionOfPatchRange,
+} from '../../../utils/comment-util';
+import {CoverageRange, DiffLayer, PatchSetFile} from '../../../types/types';
 import {
   Base64ImageFile,
   BlameInfo,
   ChangeInfo,
-  EditPatchSetNum,
+  EDIT,
   NumericChangeId,
-  ParentPatchSetNum,
+  PARENT,
   PatchRange,
   PatchSetNum,
   RepoName,
+  RevisionPatchSetNum,
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {
@@ -66,13 +48,10 @@
 import {
   CreateCommentEventDetail,
   GrDiff,
-  LineOfInterest,
-} from '../gr-diff/gr-diff';
-import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
+} from '../../../embed/diff/gr-diff/gr-diff';
 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, FILE} from '../gr-diff/gr-diff-line';
+import {LineNumber, FILE} from '../../../embed/diff/gr-diff/gr-diff-line';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {
@@ -81,18 +60,41 @@
   fireServerError,
   fireEvent,
   waitForEventOnce,
+  fire,
 } 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 {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
-import {Timing} from '../../../constants/reporting';
-import {changeComments$} from '../../../services/comments/comments-model';
+import {DiffContextExpandedEventDetail} from '../../../embed/diff/gr-diff-builder/gr-diff-builder';
+import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
+import {Timing, Interaction} from '../../../constants/reporting';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
-import {Subject} from 'rxjs';
-import {RenderPreferences} from '../../../api/diff';
-import {diffViewMode$} from '../../../services/browser/browser-model';
-import {takeUntil} from 'rxjs/operators';
+import {Subscription} from 'rxjs';
+import {DisplayLine, RenderPreferences} from '../../../api/diff';
+import {resolve} from '../../../models/dependency';
+import {browserModelToken} from '../../../models/browser/browser-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {checksModelToken, RunResult} from '../../../models/checks/checks-model';
+import {GrDiffCheckResult} from '../../checks/gr-diff-check-result';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+import {deepEqual} from '../../../utils/deep-util';
+import {Category} from '../../../api/checks';
+import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
+import {
+  CODE_MAX_LINES,
+  highlightServiceToken,
+} from '../../../services/highlight/highlight-service';
+import {html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {
+  debounceP,
+  DelayedPromise,
+  DELAYED_CANCELLATION,
+  noAwait,
+} from '../../../utils/async-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {GeneratedWebLink} from '../../../utils/weblink-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 const EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -100,12 +102,6 @@
 const EVENT_ZERO_REBASE = 'rebase-percent-zero';
 const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
 
-// Disable syntax highlighting if the overall diff is too large.
-const SYNTAX_MAX_DIFF_LENGTH = 20000;
-
-// 120 lines is good enough threshold for full-sized window viewport
-const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
-
 function isImageDiff(diff?: DiffInfo) {
   if (!diff) return false;
 
@@ -115,15 +111,28 @@
   return !!(diff.binary && (isA || isB));
 }
 
-interface LineInfo {
+// visible for testing
+export interface LineInfo {
   beforeNumber?: LineNumber;
   afterNumber?: LineNumber;
 }
 
-export interface GrDiffHost {
-  $: {
-    diff: GrDiff;
-  };
+declare global {
+  interface HTMLElementEventMap {
+    /* prettier-ignore */
+    'render': CustomEvent;
+    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
+    'create-comment': CustomEvent<CreateCommentEventDetail>;
+    'is-blame-loaded-changed': ValueChangedEvent<boolean>;
+    'diff-changed': ValueChangedEvent<DiffInfo | undefined>;
+    'edit-weblinks-changed': ValueChangedEvent<GeneratedWebLink[] | undefined>;
+    'files-weblinks-changed': ValueChangedEvent<FilesWebLinks | undefined>;
+    'is-image-diff-changed': ValueChangedEvent<boolean>;
+    // Fired when the user selects a line (See gr-diff).
+    'line-selected': CustomEvent;
+    // Fired if being logged in is required.
+    'show-auth-required': void;
+  }
 }
 
 /**
@@ -134,22 +143,9 @@
  * specific component, while <gr-diff> is a re-usable component.
  */
 @customElement('gr-diff-host')
-export class GrDiffHost extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the user selects a line.
-   *
-   * @event line-selected
-   */
-
-  /**
-   * Fired if being logged in is required.
-   *
-   * @event show-auth-required
-   */
+export class GrDiffHost extends LitElement {
+  @query('#diff')
+  diffElement?: GrDiff;
 
   @property({type: Number})
   changeNum?: NumericChangeId;
@@ -178,36 +174,60 @@
   @property({type: Boolean})
   displayLine = false;
 
-  @property({
-    type: Boolean,
-    computed: '_computeIsImageDiff(diff)',
-    notify: true,
-  })
-  isImageDiff?: boolean;
+  @state()
+  private _isImageDiff = false;
 
-  @property({type: Object})
-  commitRange?: CommitRange;
+  get isImageDiff() {
+    return this._isImageDiff;
+  }
 
-  @property({type: Object, notify: true})
-  editWeblinks?: GeneratedWebLink[];
+  set isImageDiff(isImageDiff: boolean) {
+    if (this._isImageDiff === isImageDiff) return;
+    this._isImageDiff = isImageDiff;
+    fire(this, 'is-image-diff-changed', {value: isImageDiff});
+  }
 
-  @property({type: Object, notify: true})
-  filesWeblinks: FilesWebLinks | {} = {};
+  @state()
+  private _editWeblinks?: GeneratedWebLink[];
 
-  @property({type: Boolean, reflectToAttribute: true})
+  get editWeblinks() {
+    return this._editWeblinks;
+  }
+
+  set editWeblinks(editWeblinks: GeneratedWebLink[] | undefined) {
+    if (this._editWeblinks === editWeblinks) return;
+    this._editWeblinks = editWeblinks;
+    fire(this, 'edit-weblinks-changed', {value: editWeblinks});
+  }
+
+  @state()
+  private _filesWeblinks?: FilesWebLinks;
+
+  get filesWeblinks() {
+    return this._filesWeblinks;
+  }
+
+  set filesWeblinks(filesWeblinks: FilesWebLinks | undefined) {
+    if (this._filesWeblinks === filesWeblinks) return;
+    this._filesWeblinks = filesWeblinks;
+    fire(this, 'files-weblinks-changed', {value: filesWeblinks});
+  }
+
+  @property({type: Boolean, reflect: true})
   override hidden = false;
 
   @property({type: Boolean})
   noRenderOnPrefsChange = false;
 
-  @property({type: Object, observer: '_threadsChanged'})
-  threads?: CommentThread[];
+  // Private but used in tests.
+  @state()
+  threads: CommentThread[] = [];
 
   @property({type: Boolean})
   lineWrapping = false;
 
   @property({type: Object})
-  lineOfInterest?: LineOfInterest;
+  lineOfInterest?: DisplayLine;
 
   @property({type: String})
   viewMode = DiffViewMode.SIDE_BY_SIDE;
@@ -215,75 +235,122 @@
   @property({type: Boolean})
   showLoadFailure?: boolean;
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed: '_computeIsBlameLoaded(_blame)',
-  })
-  isBlameLoaded?: boolean;
+  @state()
+  private loggedIn = false;
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  // Private but used in tests.
+  @state()
+  errorMessage: string | null = null;
 
-  @property({type: String})
-  _errorMessage: string | null = null;
+  @state()
+  private baseImage?: Base64ImageFile;
 
-  @property({type: Object})
-  _baseImage: Base64ImageFile | null = null;
+  @state()
+  private revisionImage?: Base64ImageFile;
 
-  @property({type: Object})
-  _revisionImage: Base64ImageFile | null = null;
+  // Do not use, use diff instead through the getters and setters.
+  // This is not a regular @state because we need to also send the
+  // 'diff-changed' event when it is changed. And if we rely on @state
+  // then the name to look for in willUpdate/update/updated is '_diff'.
+  private _diff?: DiffInfo;
 
-  @property({type: Object, notify: true, observer: 'diffChanged'})
-  diff?: DiffInfo;
+  get diff() {
+    return this._diff;
+  }
 
-  @property({type: Object})
-  changeComments?: ChangeComments;
+  set diff(diff: DiffInfo | undefined) {
+    if (this._diff === diff) return;
+    const oldDiff = this._diff;
+    this._diff = diff;
+    this.isImageDiff = isImageDiff(this._diff);
+    fire(this, 'diff-changed', {value: this._diff});
+    this.requestUpdate('diff', oldDiff);
+  }
 
-  @property({type: Object})
-  _fetchDiffPromise: Promise<DiffInfo> | null = null;
+  @state()
+  private changeComments?: ChangeComments;
 
-  @property({type: Object})
-  _blame: BlameInfo[] | null = null;
+  @state()
+  private fetchDiffPromise: Promise<DiffInfo> | null = null;
 
-  @property({type: Array})
-  _coverageRanges: CoverageRange[] = [];
+  // Do not use, use blame instead through the getters and setters. This is not
+  // a regular @state because we need to also send the
+  // 'is-blame-loading-changed' event when it is changed. And if we rely on
+  // @state then the name to look for in willUpdate/update/updated is '_blame'.
+  private _blame: BlameInfo[] | null = null;
 
-  @property({type: String})
-  _loadedWhitespaceLevel?: IgnoreWhitespaceType;
+  @state()
+  get blame() {
+    return this._blame;
+  }
 
-  @property({type: Number, computed: '_computeParentIndex(patchRange.*)'})
-  _parentIndex: number | null = null;
+  set blame(blame: BlameInfo[] | null) {
+    if (this._blame === blame) return;
+    const oldBlame = this._blame;
+    this._blame = blame;
+    fire(this, 'is-blame-loaded-changed', {value: !!this._blame});
+    this.requestUpdate('blame', oldBlame);
+  }
 
-  @property({
-    type: Boolean,
-    computed: '_isSyntaxHighlightingEnabled(prefs.*, diff)',
-    observer: '_syntaxHighlightingEnabledChanged',
-  })
-  _syntaxHighlightingEnabled?: boolean;
+  @state()
+  private coverageRanges: CoverageRange[] = [];
 
-  @property({type: Array})
-  _layers: DiffLayer[] = [];
+  @state()
+  private loadedWhitespaceLevel?: IgnoreWhitespaceType;
 
-  @property({type: Object})
-  _renderPrefs: RenderPreferences = {
+  @state()
+  private layers: DiffLayer[] = [];
+
+  @state()
+  private renderPrefs: RenderPreferences = {
     num_lines_rendered_at_once: 128,
   };
 
-  private readonly reporting = appContext.reportingService;
+  // Debounces across multiple reload calls and ensures that waiters can
+  // wait on it whenever a reload is requested.  If more than one reload is
+  // requested within a given time-frame, the first one is canceled but will
+  // still be resolved when the second one is resolved. (and inductively, any
+  // further ones that were requested within a animation-frame).
+  private reloadPromise?: DelayedPromise<void>;
 
-  private readonly flags = appContext.flagsService;
+  private readonly getBrowserModel = resolve(this, browserModelToken);
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private readonly jsAPI = appContext.jsApiService;
+  private readonly getChecksModel = resolve(this, checksModelToken);
 
-  private readonly syntaxLayer = new GrSyntaxLayer();
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
-  disconnected$ = new Subject();
+  // visible for testing
+  readonly reporting = getAppContext().reportingService;
+
+  private readonly flags = getAppContext().flagsService;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  // visible for testing
+  readonly getUserModel = resolve(this, userModelToken);
+
+  // visible for testing
+  readonly syntaxLayer: GrSyntaxLayerWorker;
+
+  private checksSubscription?: Subscription;
+
+  // for DIFF_AUTOCLOSE logging purposes only
+  readonly uid = performance.now().toString(36) + Math.random().toString(36);
 
   constructor() {
     super();
+    this.syntaxLayer = new GrSyntaxLayerWorker(
+      resolve(this, highlightServiceToken),
+      () => getAppContext().reportingService
+    );
+    this.renderPrefs = {
+      ...this.renderPrefs,
+      use_lit_components: this.flags.isEnabled(
+        KnownExperimentId.DIFF_RENDERING_LIT
+      ),
+    };
     this.addEventListener(
       // These are named inconsistently for a reason:
       // The create-comment event is fired to indicate that we should
@@ -292,81 +359,260 @@
       // change in some way, and that we should update any models we may want
       // to keep in sync.
       'create-comment',
-      e => this._handleCreateComment(e)
-    );
-    this.addEventListener('render-start', () => this._handleRenderStart());
-    this.addEventListener('render-content', () => this._handleRenderContent());
-    this.addEventListener('normalize-range', event =>
-      this._handleNormalizeRange(event)
+      e => this.handleCreateThread(e)
     );
     this.addEventListener('diff-context-expanded', event =>
-      this._handleDiffContextExpanded(event)
+      this.handleDiffContextExpanded(event)
     );
+    subscribe(
+      this,
+      () => this.getBrowserModel().diffViewMode$,
+      diffView => (this.viewMode = diffView)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      loggedIn => (this.loggedIn = loggedIn)
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().changeComments$,
+      changeComments => {
+        this.changeComments = changeComments;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      diffPreferences => {
+        this.prefs = diffPreferences;
+      }
+    );
+    this.logForDiffAutoClose();
   }
 
-  override ready() {
-    super.ready();
-    if (this._canReload()) {
-      this.reload();
-    }
+  // for DIFF_AUTOCLOSE logging purposes only
+  private logForDiffAutoClose() {
+    this.reporting.reportInteraction(
+      Interaction.DIFF_AUTOCLOSE_DIFF_HOST_CREATED,
+      {uid: this.uid}
+    );
+    setTimeout(() => {
+      if (!this.hasReloadBeenCalledOnce) {
+        this.reporting.reportInteraction(
+          Interaction.DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING,
+          {uid: this.uid}
+        );
+      }
+    }, /* 10 seconds */ 10000);
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    diffViewMode$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(diffView => (this.viewMode = diffView));
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-    changeComments$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(changeComments => {
-        this.changeComments = changeComments;
-      });
+    this.subscribeToChecks();
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
+    if (this.reloadPromise) {
+      this.reloadPromise.cancel();
+      this.reloadPromise = undefined;
+    }
+    if (this.checksSubscription) {
+      this.checksSubscription.unsubscribe();
+      this.checksSubscription = undefined;
+    }
     this.clear();
     super.disconnectedCallback();
   }
 
+  protected override willUpdate(changedProperties: PropertyValues) {
+    // Important to call as this will call render, see LitElement.
+    super.willUpdate(changedProperties);
+    if (changedProperties.has('diff')) {
+      this.isImageDiff = isImageDiff(this.diff);
+    }
+    if (
+      changedProperties.has('changeComments') ||
+      changedProperties.has('patchRange') ||
+      changedProperties.has('file')
+    ) {
+      this.threads = this.computeFileThreads(
+        this.changeComments,
+        this.patchRange,
+        this.file
+      );
+    }
+    if (
+      changedProperties.has('noRenderOnPrefsChange') ||
+      changedProperties.has('prefs') ||
+      changedProperties.has('path') ||
+      changedProperties.has('changeNum')
+    ) {
+      this.syntaxHighlightingChanged(
+        this.noRenderOnPrefsChange,
+        changedProperties.get('prefs'),
+        this.prefs,
+        this.path,
+        this.changeNum
+      );
+    }
+    if (
+      changedProperties.has('prefs') ||
+      changedProperties.has('loadedWhitespaceLevel') ||
+      changedProperties.has('noRenderOnPrefsChange') ||
+      changedProperties.has('path') ||
+      changedProperties.has('changeNum')
+    ) {
+      this.whitespaceChanged(
+        this.prefs?.ignore_whitespace,
+        this.loadedWhitespaceLevel,
+        this.noRenderOnPrefsChange,
+        this.path,
+        this.changeNum
+      );
+    }
+  }
+
+  protected override updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    // This needs to happen in updated() because it has to happen post-render as
+    // this method calls getThreadEls which inspects the DOM. Also <gr-diff>
+    // only starts observing nodes (for thread element changes) after rendering
+    // is done.
+    if (changedProperties.has('threads')) {
+      this.threadsChanged(this.threads);
+    }
+  }
+
+  async waitForReloadToRender(): Promise<void> {
+    await this.updateComplete;
+    if (this.reloadPromise) {
+      try {
+        // If we are reloading, wait for the reload to finish and then ensure
+        // that any changes are captured in another update.
+        await this.reloadPromise;
+      } catch (e: unknown) {
+        // TODO: Consider moving this logic to a helper method.
+        if (e === DELAYED_CANCELLATION) {
+          // Do nothing.
+        } else if (e instanceof Error) {
+          this.reporting.error('GrDiffHost Reload:', e);
+        } else {
+          this.reporting.error(
+            'GrDiffHost Reload:',
+            new Error('reloadPromise error'),
+            e
+          );
+        }
+      }
+      await this.updateComplete;
+    }
+  }
+
+  override render() {
+    const showNewlineWarningLeft =
+      this.hasTrailingNewlines(this.diff, true) === false;
+    const showNewlineWarningRight =
+      this.hasTrailingNewlines(this.diff, false) === false;
+    const useNewImageDiffUi = this.flags.isEnabled(
+      KnownExperimentId.NEW_IMAGE_DIFF_UI
+    );
+
+    return html` <gr-diff
+      id="diff"
+      ?hidden=${this.hidden}
+      .noAutoRender=${this.noAutoRender}
+      .path=${this.path}
+      .prefs=${this.prefs}
+      .displayLine=${this.displayLine}
+      .isImageDiff=${this.isImageDiff}
+      .noRenderOnPrefsChange=${this.noRenderOnPrefsChange}
+      .renderPrefs=${this.renderPrefs}
+      .lineWrapping=${this.lineWrapping}
+      .viewMode=${this.viewMode}
+      .lineOfInterest=${this.lineOfInterest}
+      .loggedIn=${this.loggedIn}
+      .errorMessage=${this.errorMessage}
+      .baseImage=${this.baseImage}
+      .revisionImage=${this.revisionImage}
+      .coverageRanges=${this.coverageRanges}
+      .blame=${this.blame}
+      .layers=${this.layers}
+      .diff=${this.diff}
+      .showNewlineWarningLeft=${showNewlineWarningLeft}
+      .showNewlineWarningRight=${showNewlineWarningRight}
+      .useNewImageDiffUi=${useNewImageDiffUi}
+    ></gr-diff>`;
+  }
+
   async initLayers() {
-    const preferencesPromise = appContext.restApiService.getPreferences();
-    await getPluginLoader().awaitPluginsLoaded();
+    const preferencesPromise = this.restApiService.getPreferences();
     const prefs = await preferencesPromise;
     const enableTokenHighlight = !prefs?.disable_token_highlighting;
 
     assertIsDefined(this.path, 'path');
-    this._layers = this.getLayers(this.path, enableTokenHighlight);
-    this._coverageRanges = [];
+    this.layers = this.getLayers(enableTokenHighlight);
+    this.coverageRanges = [];
     // We kick off fetching the data here, but we don't return the promise,
     // so awaiting initLayers() will not wait for coverage data to be
     // completely loaded.
-    this._getCoverageData();
-  }
-
-  diffChanged(diff?: DiffInfo) {
-    this.syntaxLayer.init(diff);
+    noAwait(this.getCoverageData());
   }
 
   /**
    * @param shouldReportMetric indicate a new Diff Page. This is a
    * signal to report metrics event that started on location change.
    */
-  async reload(shouldReportMetric?: boolean) {
+  reload(shouldReportMetric?: boolean): Promise<void> {
+    this.reloadPromise = debounceP(
+      this.reloadPromise,
+      async () => {
+        try {
+          await this.reloadInternal(shouldReportMetric);
+          return;
+        } catch (e: unknown) {
+          if (e instanceof Error) {
+            this.reporting.error('GrDiffHost Reload:', e);
+          } else {
+            this.reporting.error(
+              'GrDiffHost Reload:',
+              new Error('reloadInternal error'),
+              e
+            );
+          }
+        } finally {
+          this.reloadPromise = undefined;
+        }
+      },
+      0
+    );
+    return this.reloadPromise;
+  }
+
+  // for DIFF_AUTOCLOSE logging purposes only
+  private reloadOngoing = false;
+
+  // for DIFF_AUTOCLOSE logging purposes only
+  private hasReloadBeenCalledOnce = false;
+
+  async reloadInternal(shouldReportMetric?: boolean) {
+    this.hasReloadBeenCalledOnce = true;
+    this.reporting.time(Timing.DIFF_TOTAL);
+    this.reporting.time(Timing.DIFF_LOAD);
+    // TODO: Find better names for these 3 clear/cancel methods. Ideally the
+    // <gr-diff-host> should not re-used at all for another diff rendering pass.
     this.clear();
+    this.cancel();
+    this.clearDiffContent();
     assertIsDefined(this.path, 'path');
     assertIsDefined(this.changeNum, 'changeNum');
     this.diff = undefined;
-    this._errorMessage = null;
-    const whitespaceLevel = this._getIgnoreWhitespace();
-
-    if (shouldReportMetric) {
-      // We listen on render viewport only on DiffPage (on paramsChanged)
-      this._listenToViewportRender();
+    this.errorMessage = null;
+    const whitespaceLevel = this.getIgnoreWhitespace();
+    if (this.reloadOngoing) {
+      this.reporting.reportInteraction(Interaction.DIFF_AUTOCLOSE_DIFF_ONGOING);
     }
+    this.reloadOngoing = true;
 
     try {
       // We are carefully orchestrating operations that have to wait for another
@@ -375,11 +621,16 @@
       // 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);
+      const diff = await this.getDiff();
+      if (diff === undefined) {
+        this.reporting.reportInteraction(
+          Interaction.DIFF_AUTOCLOSE_DIFF_UNDEFINED
+        );
+      }
+      this.loadedWhitespaceLevel = whitespaceLevel;
+      this.reportDiff(diff);
 
-      await this._loadDiffAssets(diff);
+      await this.loadDiffAssets(diff);
       // Only now we are awaiting layers (and plugin loading), which was kicked
       // off above.
       await layerPromise;
@@ -387,52 +638,200 @@
       // Not waiting for coverage ranges intentionally as
       // plugin loading should not block the content rendering
 
-      this.editWeblinks = this._getEditWeblinks(diff);
-      this.filesWeblinks = this._getFilesWeblinks(diff);
+      this.editWeblinks = this.getEditWeblinks(diff);
+      this.filesWeblinks = this.getFilesWeblinks(diff);
       this.diff = diff;
-      const event = (await waitForEventOnce(this, 'render')) as CustomEvent;
+      this.reporting.timeEnd(Timing.DIFF_LOAD, this.timingDetails());
+
+      this.reporting.time(Timing.DIFF_CONTENT);
+      this.syntaxLayer.setEnabled(this.isSyntaxHighlightingEnabled());
+      const syntaxLayerPromise = this.syntaxLayer.process(diff);
+      await waitForEventOnce(this, 'render');
+      this.subscribeToChecks();
+      this.reporting.timeEnd(Timing.DIFF_CONTENT, this.timingDetails());
+
       if (shouldReportMetric) {
         // We report diffViewContentDisplayed only on reload caused
         // by params changed - expected only on Diff Page.
         this.reporting.diffViewContentDisplayed();
       }
-      const needsSyntaxHighlighting = !!event.detail?.contentRendered;
-      if (needsSyntaxHighlighting) {
-        this.reporting.time(Timing.DIFF_SYNTAX);
-        try {
-          await this.syntaxLayer.process();
-        } finally {
-          this.reporting.timeEnd(Timing.DIFF_SYNTAX);
-        }
-      }
-    } catch (e) {
+
+      this.reporting.time(Timing.DIFF_SYNTAX);
+      await syntaxLayerPromise;
+      this.reporting.timeEnd(Timing.DIFF_SYNTAX, this.timingDetails());
+    } catch (e: unknown) {
       if (e instanceof Response) {
-        this._handleGetDiffError(e);
+        this.handleGetDiffError(e);
+      } else if (e instanceof Error) {
+        this.reporting.error('GrDiffHost Reload:', e);
       } else {
-        this.reporting.error(e);
+        this.reporting.error(
+          'GrDiffHost Reload:',
+          new Error('reload error'),
+          e
+        );
       }
     } finally {
-      this.reporting.timeEnd(Timing.DIFF_TOTAL);
+      this.reporting.timeEnd(Timing.DIFF_TOTAL, this.timingDetails());
+      this.reloadOngoing = false;
     }
   }
 
-  private getLayers(path: string, enableTokenHighlight: boolean): DiffLayer[] {
+  /**
+   * Produces an event detail object for reporting.
+   */
+  private timingDetails() {
+    if (!this.diff) return {};
+    const metaLines =
+      (this.diff.meta_a?.lines ?? 0) + (this.diff.meta_b?.lines ?? 0);
+
+    let contentLines = 0;
+    let contentChanged = 0;
+    let contentUnchanged = 0;
+    for (const chunk of this.diff.content) {
+      const ab = chunk.ab?.length ?? 0;
+      const a = chunk.a?.length ?? 0;
+      const b = chunk.b?.length ?? 0;
+      contentLines += ab + ab + a + b;
+      contentChanged += a + b;
+      contentUnchanged += ab + ab;
+    }
+    return {
+      metaLines,
+      contentLines,
+      contentUnchanged,
+      contentChanged,
+      height:
+        this.diffElement?.shadowRoot?.querySelector('.diffContainer')
+          ?.clientHeight,
+    };
+  }
+
+  private getLayers(enableTokenHighlight: boolean): DiffLayer[] {
     const layers = [];
     if (enableTokenHighlight) {
       layers.push(new TokenHighlightLayer(this));
     }
     layers.push(this.syntaxLayer);
-    // Get layers from plugins (if any).
-    layers.push(...this.jsAPI.getDiffLayers(path));
     return layers;
   }
 
   clear() {
-    if (this.path) this.jsAPI.disposeDiffLayers(this.path);
-    this._layers = [];
+    this.layers = [];
   }
 
-  _getCoverageData() {
+  /**
+   * This should be called when either `path` or `patchRange` has changed.
+   * We will then subscribe to the checks model and filter the relevant
+   * check results for this diff. Path and patchset must match, and a code
+   * pointer must be included.
+   */
+  private subscribeToChecks() {
+    if (this.checksSubscription) {
+      this.checksSubscription.unsubscribe();
+      this.checksSubscription = undefined;
+      this.checksChanged([]);
+    }
+
+    const path = this.path;
+    const patchNum = this.patchRange?.patchNum;
+    if (!path || !patchNum || patchNum === EDIT) return;
+    this.checksSubscription = this.getChecksModel()
+      .allResults$.pipe(
+        map(results =>
+          results.filter(result => {
+            if (result.patchset !== patchNum) return false;
+            if (result.category === Category.SUCCESS) return false;
+            // Only one code pointer is supported. See API docs.
+            const pointer = result.codePointers?.[0];
+            return pointer?.path === this.path && !!pointer?.range;
+          })
+        ),
+        distinctUntilChanged(deepEqual)
+      )
+      .subscribe(results => this.checksChanged(results));
+  }
+
+  /**
+   * Similar to threadsChanged(), but a bit simpler. We compare the elements
+   * that are already in <gr-diff> with the current results emitted from the
+   * model. Exists? Update. New? Create and attach. Old? Remove.
+   */
+  private checksChanged(checks: RunResult[]) {
+    const idToEl = new Map<string, GrDiffCheckResult>();
+    const checkEls = this.getCheckEls();
+    const dontRemove = new Set<GrDiffCheckResult>();
+    let createdCount = 0;
+    let updatedCount = 0;
+    let removedCount = 0;
+    const checksCount = checks.length;
+    const checkElsCount = checkEls.length;
+    if (checksCount === 0 && checkElsCount === 0) return;
+    for (const el of checkEls) {
+      const id = el.result?.internalResultId;
+      assertIsDefined(id, 'result.internalResultId of gr-diff-check-result');
+      idToEl.set(id, el);
+    }
+    for (const check of checks) {
+      const id = check.internalResultId;
+      const existingEl = idToEl.get(id);
+      if (existingEl) {
+        existingEl.result = check;
+        dontRemove.add(existingEl);
+        updatedCount++;
+      } else {
+        const newEl = this.createCheckEl(check);
+        dontRemove.add(newEl);
+        createdCount++;
+      }
+    }
+    // Remove all check els that don't have a matching check anymore.
+    for (const el of checkEls) {
+      if (dontRemove.has(el)) continue;
+      el.remove();
+      removedCount++;
+    }
+    this.reporting.reportInteraction(
+      Interaction.COMMENTS_AUTOCLOSE_CHECKS_UPDATED,
+      {createdCount, updatedCount, removedCount, checksCount, checkElsCount}
+    );
+  }
+
+  /**
+   * This is very similar to createThreadElement(). It creates a new
+   * <gr-diff-check-result> element, sets its props/attributes and adds it to
+   * <gr-diff>.
+   */
+  // Visible for testing
+  createCheckEl(check: RunResult) {
+    const pointer = check.codePointers?.[0];
+    assertIsDefined(pointer, 'code pointer of check result in diff');
+    const line: LineNumber =
+      pointer.range?.end_line || pointer.range?.start_line || 'FILE';
+    const el = document.createElement('gr-diff-check-result');
+    // This is what gr-diff expects, even though this is a check, not a comment.
+    el.className = 'comment-thread';
+    el.rootId = check.internalResultId;
+    el.result = check;
+    // These attributes are the "interface" between comments/checks and gr-diff.
+    // <gr-comment-thread> does not care about them and is not affected by them.
+    el.setAttribute('slot', `${Side.RIGHT}-${line}`);
+    el.setAttribute('diff-side', `${Side.RIGHT}`);
+    el.setAttribute('line-num', `${line}`);
+    if (
+      pointer.range?.start_line > 0 &&
+      pointer.range?.end_line > 0 &&
+      pointer.range?.start_character >= 0 &&
+      pointer.range?.end_character >= 0
+    ) {
+      el.setAttribute('range', `${JSON.stringify(pointer.range)}`);
+    }
+    assertIsDefined(this.diffElement);
+    this.diffElement.appendChild(el);
+    return el;
+  }
+
+  private async getCoverageData() {
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.change, 'change');
     assertIsDefined(this.path, 'path');
@@ -447,118 +846,84 @@
 
     const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
     const patchNum = toNumberOnly(this.patchRange.patchNum);
-    this.jsAPI
-      .getCoverageAnnotationApis()
-      .then(coverageAnnotationApis => {
-        coverageAnnotationApis.forEach(coverageAnnotationApi => {
-          const provider = coverageAnnotationApi.getCoverageProvider();
-          if (!provider) return;
-          provider(changeNum, path, basePatchNum, patchNum, change)
-            .then(coverageRanges => {
-              assertIsDefined(this.patchRange, 'patchRange');
-              if (
-                !coverageRanges ||
-                changeNum !== this.changeNum ||
-                change !== this.change ||
-                path !== this.path ||
-                basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
-                patchNum !== toNumberOnly(this.patchRange.patchNum)
-              ) {
-                return;
-              }
-
-              const existingCoverageRanges = this._coverageRanges;
-              this._coverageRanges = coverageRanges;
-
-              // Notify with existing coverage ranges in case there is some
-              // existing coverage data that needs to be removed
-              existingCoverageRanges.forEach(range => {
-                coverageAnnotationApi.notify(
-                  path,
-                  range.code_range.start_line,
-                  range.code_range.end_line,
-                  range.side
-                );
-              });
-
-              // Notify with new coverage data
-              coverageRanges.forEach(range => {
-                coverageAnnotationApi.notify(
-                  path,
-                  range.code_range.start_line,
-                  range.code_range.end_line,
-                  range.side
-                );
-              });
-            })
-            .catch(err => {
-              this.reporting.error(err);
-            });
-        });
-      })
-      .catch(err => {
-        this.reporting.error(err);
-      });
+    // We are simply waiting here for all plugins to be loaded. Ideally we would
+    // just react to state changes, but plugins are loaded quickly once at app
+    // startup, and coordinating incoming coverage providers with the reloading
+    // process seems to be complex enough to avoid it for the time being.
+    await this.getPluginLoader().awaitPluginsLoaded();
+    const plugins =
+      this.getPluginLoader().pluginsModel.getState().coveragePlugins;
+    const providers = plugins.map(p => p.provider);
+    for (const provider of providers) {
+      try {
+        const coverageRanges = await provider(
+          changeNum,
+          path,
+          basePatchNum,
+          patchNum,
+          change
+        );
+        assertIsDefined(this.patchRange, 'patchRange');
+        if (
+          !coverageRanges ||
+          changeNum !== this.changeNum ||
+          change !== this.change ||
+          path !== this.path ||
+          basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
+          patchNum !== toNumberOnly(this.patchRange.patchNum)
+        ) {
+          continue;
+        }
+        this.coverageRanges = coverageRanges;
+      } catch (e) {
+        if (e instanceof Error) this.reporting.error('GrDiffHost Coverage', e);
+      }
+    }
   }
 
-  _getEditWeblinks(diff: DiffInfo) {
-    if (!this.projectName || !this.commitRange || !this.path) return undefined;
-    return GerritNav.getEditWebLinks(
-      this.projectName,
-      this.commitRange.baseCommit,
-      this.path,
-      {weblinks: diff?.edit_web_links}
-    );
-  }
-
-  @observe('changeComments', 'patchRange', 'file')
-  computeFileThreads(
+  private computeFileThreads(
     changeComments?: ChangeComments,
     patchRange?: PatchRange,
     file?: PatchSetFile
   ) {
-    if (!changeComments || !patchRange || !file) return;
-    this.threads = changeComments.getThreadsBySideForFile(file, patchRange);
+    if (!changeComments || !patchRange || !file) return this.threads;
+    return changeComments.getThreadsBySideForFile(file, patchRange);
   }
 
-  _getFilesWeblinks(diff: DiffInfo) {
-    if (!this.projectName || !this.commitRange || !this.path) return {};
+  private getEditWeblinks(diff: DiffInfo) {
+    return diff?.edit_web_links ?? [];
+  }
+
+  private getFilesWeblinks(diff: DiffInfo) {
     return {
-      meta_a: GerritNav.getFileWebLinks(
-        this.projectName,
-        this.commitRange.baseCommit,
-        this.path,
-        {weblinks: diff?.meta_a?.web_links}
-      ),
-      meta_b: GerritNav.getFileWebLinks(
-        this.projectName,
-        this.commitRange.commit,
-        this.path,
-        {weblinks: diff?.meta_b?.web_links}
-      ),
+      meta_a: diff?.meta_a?.web_links ?? [],
+      meta_b: diff?.meta_b?.web_links ?? [],
     };
   }
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
-    this.$.diff.cancel();
-    this.syntaxLayer.cancel();
+    this.diffElement?.cancel();
   }
 
   getCursorStops() {
-    return this.$.diff.getCursorStops();
+    assertIsDefined(this.diffElement);
+    return this.diffElement.getCursorStops();
   }
 
   isRangeSelected() {
-    return this.$.diff.isRangeSelected();
+    assertIsDefined(this.diffElement);
+    return this.diffElement.isRangeSelected();
   }
 
   createRangeComment() {
-    return this.$.diff.createRangeComment();
+    assertIsDefined(this.diffElement);
+    this.diffElement.createRangeComment();
   }
 
   toggleLeftDiff() {
-    this.$.diff.toggleLeftDiff();
+    assertIsDefined(this.diffElement);
+    this.diffElement.toggleLeftDiff();
   }
 
   /**
@@ -576,39 +941,38 @@
           return Promise.reject(EMPTY_BLAME);
         }
 
-        this._blame = blame;
+        this.blame = blame;
         return blame;
       });
   }
 
   clearBlame() {
-    this._blame = null;
+    this.blame = null;
   }
 
   getThreadEls(): GrCommentThread[] {
-    return Array.from(this.$.diff.querySelectorAll('.comment-thread'));
+    assertIsDefined(this.diffElement);
+    return Array.from(this.diffElement.querySelectorAll('gr-comment-thread'));
+  }
+
+  getCheckEls(): GrDiffCheckResult[] {
+    return Array.from(
+      this.diffElement?.querySelectorAll('gr-diff-check-result') ?? []
+    );
   }
 
   addDraftAtLine(el: Element) {
-    this.$.diff.addDraftAtLine(el);
+    assertIsDefined(this.diffElement);
+    this.diffElement.addDraftAtLine(el);
   }
 
   clearDiffContent() {
-    this.$.diff.clearDiffContent();
+    this.diffElement?.clearDiffContent();
   }
 
   toggleAllContext() {
-    this.$.diff.toggleAllContext();
-  }
-
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  _canReload() {
-    return (
-      !!this.changeNum && !!this.patchRange && !!this.path && !this.noAutoRender
-    );
+    assertIsDefined(this.diffElement);
+    this.diffElement.toggleAllContext();
   }
 
   // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
@@ -617,16 +981,17 @@
       !!this.changeNum &&
       !!this.patchRange &&
       !!this.path &&
-      this._fetchDiffPromise === null
+      this.fetchDiffPromise === null
     ) {
-      this._fetchDiffPromise = this._getDiff();
+      this.fetchDiffPromise = this.getDiff();
     }
   }
 
-  _getDiff(): Promise<DiffInfo> {
-    if (this._fetchDiffPromise !== null) {
-      const fetchDiffPromise = this._fetchDiffPromise;
-      this._fetchDiffPromise = null;
+  // Private but used in tests.
+  getDiff(): Promise<DiffInfo> {
+    if (this.fetchDiffPromise !== null) {
+      const fetchDiffPromise = this.fetchDiffPromise;
+      this.fetchDiffPromise = null;
       return fetchDiffPromise;
     }
     // Wrap the diff request in a new promise so that the error handler
@@ -641,14 +1006,15 @@
           this.patchRange.basePatchNum,
           this.patchRange.patchNum,
           this.path,
-          this._getIgnoreWhitespace(),
+          this.getIgnoreWhitespace(),
           reject
         )
         .then(diff => resolve(diff!)); // reject is called in case of error, so we can't get undefined here
     });
   }
 
-  _handleGetDiffError(response: Response) {
+  // Private but used in tests.
+  handleGetDiffError(response: Response) {
     // Loading the diff may respond with 409 if the file is too large. In this
     // case, use a toast error..
     if (response.status === 409) {
@@ -657,7 +1023,7 @@
     }
 
     if (this.showLoadFailure) {
-      this._errorMessage = [
+      this.errorMessage = [
         'Encountered error when loading the diff:',
         response.status,
         response.statusText,
@@ -670,8 +1036,10 @@
 
   /**
    * Report info about the diff response.
+   *
+   * Private but used in tests.
    */
-  _reportDiff(diff?: DiffInfo) {
+  reportDiff(diff?: DiffInfo) {
     if (!diff || !diff.content) return;
 
     // Count the delta lines stemming from normal deltas, and from
@@ -703,7 +1071,7 @@
     // Report the due_to_rebase percentage in the "diff" category when
     // applicable.
     assertIsDefined(this.patchRange, 'patchRange');
-    if (this.patchRange.basePatchNum === 'PARENT') {
+    if (this.patchRange.basePatchNum === PARENT) {
       this.reporting.reportInteraction(EVENT_AGAINST_PARENT);
     } else if (percentRebaseDelta === 0) {
       this.reporting.reportInteraction(EVENT_ZERO_REBASE);
@@ -714,50 +1082,99 @@
     }
   }
 
-  _loadDiffAssets(diff?: DiffInfo) {
+  private loadDiffAssets(diff?: DiffInfo) {
     if (isImageDiff(diff)) {
       // diff! is justified, because isImageDiff() returns false otherwise
-      return this._getImages(diff!).then(images => {
-        this._baseImage = images.baseImage;
-        this._revisionImage = images.revisionImage;
+      return this.getImages(diff!).then(images => {
+        this.baseImage = images.baseImage ?? undefined;
+        this.revisionImage = images.revisionImage ?? undefined;
       });
     } else {
-      this._baseImage = null;
-      this._revisionImage = null;
+      this.baseImage = undefined;
+      this.revisionImage = undefined;
       return Promise.resolve();
     }
   }
 
-  _computeIsImageDiff(diff?: DiffInfo) {
-    return isImageDiff(diff);
-  }
-
-  _threadsChanged(threads: CommentThread[]) {
-    const threadEls = new Set<GrCommentThread>();
+  private threadsChanged(threads: CommentThread[]) {
     const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
-    for (const threadEl of this.getThreadEls()) {
+    const unsavedThreadEls: GrCommentThread[] = [];
+    const threadEls = this.getThreadEls();
+    for (const threadEl of threadEls) {
       if (threadEl.rootId) {
         rootIdToThreadEl.set(threadEl.rootId, threadEl);
+      } else {
+        // Unsaved thread els must have editing:true, just being defensive here.
+        if (threadEl.editing) unsavedThreadEls.push(threadEl);
       }
     }
+    const dontRemove = new Set<GrCommentThread>();
+    let createdCount = 0;
+    let updatedCount = 0;
+    let removedCount = 0;
+    const threadCount = threads.length;
+    const threadElCount = threadEls.length;
+    if (threadCount === 0 && threadElCount === 0) return;
+
     for (const thread of threads) {
-      const existingThreadEl =
+      // Let's find an existing DOM element matching the thread. Normally this
+      // is as simple as matching the rootIds.
+      let existingThreadEl =
         thread.rootId && rootIdToThreadEl.get(thread.rootId);
-      if (existingThreadEl) {
-        this._updateThreadElement(existingThreadEl, thread);
-        threadEls.add(existingThreadEl);
+      // But unsaved threads don't have rootIds. The incoming thread might be
+      // the saved version of the unsaved thread element. To verify that we
+      // check that the thread only has one comment and that their location is
+      // identical.
+      // TODO(brohlfs): This matching is not perfect. You could quickly create
+      // two new threads on the same line/range. Then this code just makes a
+      // random guess.
+      if (!existingThreadEl && thread.comments?.length === 1) {
+        for (const unsavedThreadEl of unsavedThreadEls) {
+          if (equalLocation(unsavedThreadEl.thread, thread)) {
+            existingThreadEl = unsavedThreadEl;
+            break;
+          }
+        }
+      }
+      // There is a case possible where the rootIds match but the locations
+      // are different. Such as when a thread was originally attached on the
+      // right side of the diff but now should be attached on the left side of
+      // the diff.
+      // There is another case possible where the original thread element was
+      // associated with a ported thread, hence had the LineNum set to LOST.
+      // In this case we cannot reuse the thread element if the same thread
+      // now is being attached in it's proper location since the LineNum needs
+      // to be updated hence create a new thread element.
+      if (
+        existingThreadEl &&
+        existingThreadEl.getAttribute('diff-side') ===
+          this.getDiffSide(thread) &&
+        existingThreadEl.thread!.ported === thread.ported
+      ) {
+        existingThreadEl.thread = thread;
+        dontRemove.add(existingThreadEl);
+        updatedCount++;
       } else {
-        const threadEl = this._createThreadElement(thread);
-        this._attachThreadElement(threadEl);
-        threadEls.add(threadEl);
+        const threadEl = this.createThreadElement(thread);
+        this.attachThreadElement(threadEl);
+        dontRemove.add(threadEl);
+        createdCount++;
       }
     }
     // Remove all threads that are no longer existing.
     for (const threadEl of this.getThreadEls()) {
-      if (threadEls.has(threadEl)) continue;
-      const parent = threadEl.parentNode;
-      if (parent) parent.removeChild(threadEl);
+      if (dontRemove.has(threadEl)) continue;
+      // The user may have opened a couple of comment boxes for editing. They
+      // might be unsaved and thus not be reflected in `threads` yet, so let's
+      // keep them open.
+      if (threadEl.editing && threadEl.thread?.comments.length === 0) continue;
+      removedCount++;
+      threadEl.remove();
     }
+    this.reporting.reportInteraction(
+      Interaction.COMMENTS_AUTOCLOSE_THREADS_UPDATED,
+      {createdCount, updatedCount, removedCount, threadCount, threadElCount}
+    );
     const portedThreadsCount = threads.filter(thread => thread.ported).length;
     const portedThreadsWithoutRange = threads.filter(
       thread => thread.ported && thread.rangeInfoLost
@@ -770,11 +1187,7 @@
     }
   }
 
-  _computeIsBlameLoaded(blame: BlameInfo[] | null) {
-    return !!blame;
-  }
-
-  _getImages(diff: DiffInfo) {
+  private getImages(diff: DiffInfo) {
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.patchRange, 'patchRange');
     return this.restApiService.getImagesForDiff(
@@ -784,10 +1197,10 @@
     );
   }
 
-  _handleCreateComment(e: CustomEvent<CreateCommentEventDetail>) {
+  handleCreateThread(e: CustomEvent<CreateCommentEventDetail>) {
     if (!this.patchRange) throw Error('patch range not set');
 
-    const {lineNum, side, range, path} = e.detail;
+    const {lineNum, side, range} = 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.
@@ -805,22 +1218,32 @@
         ? CommentSide.PARENT
         : CommentSide.REVISION;
     if (!this.canCommentOnPatchSetNum(patchNum)) return;
-    const threadEl = this._getOrCreateThread({
+    const path =
+      this.file?.basePath &&
+      side === Side.LEFT &&
+      commentSide === CommentSide.REVISION
+        ? this.file?.basePath
+        : this.path;
+    assertIsDefined(path, 'path');
+
+    const parentIndex = this.computeParentIndex();
+    const newThread: CommentThread = {
+      rootId: undefined,
       comments: [],
-      path,
-      diffSide: side,
+      patchNum: patchNum as RevisionPatchSetNum,
       commentSide,
-      patchNum,
+      // TODO: Maybe just compute from patchRange.base on the fly?
+      mergeParentNum: parentIndex ?? undefined,
+      path,
       line: lineNum,
       range,
-    });
-    threadEl.addOrEditDraft(lineNum, range);
-
-    this.reporting.recordDraftInteraction();
+    };
+    const el = this.createThreadElement(newThread);
+    this.attachThreadElement(el);
   }
 
   private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
-    if (!this._loggedIn) {
+    if (!this.loggedIn) {
       fireEvent(this, 'show-auth-required');
       return false;
     }
@@ -829,10 +1252,8 @@
       return false;
     }
 
-    const isEdit = patchNum === EditPatchSetNum;
-    const isEditBase =
-      patchNum === ParentPatchSetNum &&
-      this.patchRange.patchNum === EditPatchSetNum;
+    const isEdit = patchNum === EDIT;
+    const isEditBase = patchNum === PARENT && this.patchRange.patchNum === EDIT;
 
     if (isEdit) {
       fireAlert(this, 'You cannot comment on an edit.');
@@ -845,97 +1266,53 @@
     return true;
   }
 
-  /**
-   * Gets or creates a comment thread at a given location.
-   * May provide a range, to get/create a range comment.
-   */
-  _getOrCreateThread(thread: CommentThread): GrCommentThread {
-    let threadEl = this._getThreadEl(thread);
-    if (!threadEl) {
-      threadEl = this._createThreadElement(thread);
-      this._attachThreadElement(threadEl);
+  private attachThreadElement(threadEl: Element) {
+    assertIsDefined(this.diffElement);
+    this.diffElement.appendChild(threadEl);
+  }
+
+  private getDiffSide(thread: CommentThread) {
+    let diffSide: Side;
+    assertIsDefined(this.patchRange, 'patchRange');
+    const commentProps = {
+      patch_set: thread.patchNum,
+      side: thread.commentSide,
+      parent: thread.mergeParentNum,
+    };
+    if (isInBaseOfPatchRange(commentProps, this.patchRange)) {
+      diffSide = Side.LEFT;
+    } else if (isInRevisionOfPatchRange(commentProps, this.patchRange)) {
+      diffSide = Side.RIGHT;
     } else {
-      this._updateThreadElement(threadEl, thread);
+      const propsStr = JSON.stringify(commentProps);
+      const rangeStr = JSON.stringify(this.patchRange);
+      throw new Error(`comment ${propsStr} not in range ${rangeStr}`);
     }
-    return threadEl;
+    return diffSide;
   }
 
-  _attachThreadElement(threadEl: Element) {
-    this.$.diff.appendChild(threadEl);
-  }
+  private createThreadElement(thread: CommentThread) {
+    const diffSide = this.getDiffSide(thread);
 
-  _clearThreads() {
-    for (const threadEl of this.getThreadEls()) {
-      const parent = threadEl.parentNode;
-      if (parent) parent.removeChild(threadEl);
-    }
-  }
-
-  _createThreadElement(thread: CommentThread) {
     const threadEl = document.createElement('gr-comment-thread');
     threadEl.className = 'comment-thread';
-    threadEl.setAttribute(
-      'slot',
-      `${thread.diffSide}-${thread.line || 'LOST'}`
-    );
-    this._updateThreadElement(threadEl, thread);
+    threadEl.rootId = thread.rootId;
+    threadEl.thread = thread;
+    threadEl.showPatchset = false;
+    threadEl.showPortedComment = !!thread.ported;
+    // These attributes are the "interface" between comment threads and gr-diff.
+    // <gr-comment-thread> does not care about them and is not affected by them.
+    threadEl.setAttribute('slot', `${diffSide}-${thread.line || 'LOST'}`);
+    threadEl.setAttribute('diff-side', `${diffSide}`);
+    threadEl.setAttribute('line-num', `${thread.line || 'LOST'}`);
+    if (thread.range) {
+      threadEl.setAttribute('range', `${JSON.stringify(thread.range)}`);
+    }
     return threadEl;
   }
 
-  _updateThreadElement(threadEl: GrCommentThread, thread: CommentThread) {
-    threadEl.comments = thread.comments;
-    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.diffSide === Side.LEFT &&
-      !threadEl.isOnParent
-    ) {
-      threadEl.path = this.file.basePath;
-    } else {
-      threadEl.path = this.path;
-    }
-    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.
-    else threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
-    threadEl.projectName = this.projectName;
-    threadEl.range = thread.range;
-  }
-
-  /**
-   * Gets a comment thread element at a given location.
-   * May provide a range, to get a range comment.
-   */
-  _getThreadEl(thread: CommentThread): GrCommentThread | null {
-    let line: LineInfo;
-    if (thread.diffSide === Side.LEFT) {
-      line = {beforeNumber: thread.line};
-    } else if (thread.diffSide === Side.RIGHT) {
-      line = {afterNumber: thread.line};
-    } else {
-      throw new Error(`Unknown side: ${thread.diffSide}`);
-    }
-    function matchesRange(threadEl: GrCommentThread) {
-      return rangesEqual(getRange(threadEl), thread.range);
-    }
-
-    const filteredThreadEls = this._filterThreadElsForLocation(
-      this.getThreadEls(),
-      line,
-      thread.diffSide
-    ).filter(matchesRange);
-    return filteredThreadEls.length ? filteredThreadEls[0] : null;
-  }
-
-  _filterThreadElsForLocation(
+  // Private but used in tests.
+  filterThreadElsForLocation(
     threadEls: GrCommentThread[],
     lineInfo: LineInfo,
     side: Side
@@ -972,75 +1349,71 @@
     );
   }
 
-  _getIgnoreWhitespace(): IgnoreWhitespaceType {
+  private getIgnoreWhitespace(): IgnoreWhitespaceType {
     if (!this.prefs || !this.prefs.ignore_whitespace) {
       return 'IGNORE_NONE';
     }
     return this.prefs.ignore_whitespace;
   }
 
-  @observe(
-    'prefs.ignore_whitespace',
-    '_loadedWhitespaceLevel',
-    'noRenderOnPrefsChange'
-  )
-  _whitespaceChanged(
-    preferredWhitespaceLevel?: IgnoreWhitespaceType,
-    loadedWhitespaceLevel?: IgnoreWhitespaceType,
-    noRenderOnPrefsChange?: boolean
-  ) {
+  private whitespaceChanged(
+    preferredWhitespaceLevel: IgnoreWhitespaceType | undefined,
+    loadedWhitespaceLevel: IgnoreWhitespaceType | undefined,
+    noRenderOnPrefsChange: boolean | undefined,
+    path: string | undefined,
+    changeNum: NumericChangeId | undefined
+  ): void | Promise<void> {
     if (preferredWhitespaceLevel === undefined) return;
     if (loadedWhitespaceLevel === undefined) return;
     if (noRenderOnPrefsChange === undefined) return;
+    if (path === undefined) return;
+    if (changeNum === undefined) return;
 
-    this._fetchDiffPromise = null;
+    this.fetchDiffPromise = null;
     if (
       preferredWhitespaceLevel !== loadedWhitespaceLevel &&
       !noRenderOnPrefsChange
     ) {
-      this.reload();
+      this.reporting.reportInteraction(
+        Interaction.DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE
+      );
+      return this.reload();
     }
   }
 
-  @observe('noRenderOnPrefsChange', 'prefs.*')
-  _syntaxHighlightingChanged(
-    noRenderOnPrefsChange?: boolean,
-    prefsChangeRecord?: PolymerDeepPropertyChange<
-      DiffPreferencesInfo,
-      DiffPreferencesInfo
-    >
-  ) {
+  private syntaxHighlightingChanged(
+    noRenderOnPrefsChange: boolean | undefined,
+    oldPrefs: DiffPreferencesInfo | undefined,
+    prefs: DiffPreferencesInfo | undefined,
+    path: string | undefined,
+    changeNum: NumericChangeId | undefined
+  ): void | Promise<void> {
     if (noRenderOnPrefsChange === undefined) return;
-    if (prefsChangeRecord === undefined) return;
-    if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') return;
+    if (prefs === undefined) return;
+    if (path === undefined) return;
+    if (changeNum === undefined) return;
+    if (oldPrefs?.syntax_highlighting === prefs.syntax_highlighting) return;
 
-    if (!noRenderOnPrefsChange) this.reload();
+    if (!noRenderOnPrefsChange) {
+      this.reporting.reportInteraction(
+        Interaction.DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX
+      );
+      return this.reload();
+    }
   }
 
-  _computeParentIndex(
-    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
-  ) {
-    if (!patchRangeRecord.base) return null;
-    return isMergeParent(patchRangeRecord.base.basePatchNum)
-      ? getParentIndex(patchRangeRecord.base.basePatchNum)
+  private computeParentIndex() {
+    if (!this.patchRange) return null;
+    return isMergeParent(this.patchRange.basePatchNum)
+      ? getParentIndex(this.patchRange.basePatchNum)
       : null;
   }
 
-  _syntaxHighlightingEnabledChanged(_syntaxHighlightingEnabled: boolean) {
-    this.syntaxLayer.setEnabled(_syntaxHighlightingEnabled);
-  }
-
-  _isSyntaxHighlightingEnabled(
-    preferenceChangeRecord?: PolymerDeepPropertyChange<
-      DiffPreferencesInfo,
-      DiffPreferencesInfo
-    >,
-    diff?: DiffInfo
-  ) {
-    if (!preferenceChangeRecord?.base?.syntax_highlighting || !diff) {
+  private isSyntaxHighlightingEnabled() {
+    if (!this.prefs?.syntax_highlighting || !this.diff) {
       return false;
     }
-    if (anyLineTooLong(diff)) {
+    if (anyLineTooLong(this.diff)) {
       fireAlert(
         this,
         `Files with line longer than ${SYNTAX_MAX_LINE_LENGTH} characters` +
@@ -1048,10 +1421,11 @@
       );
       return false;
     }
-    if (this.$.diff.getDiffLength(diff) > SYNTAX_MAX_DIFF_LENGTH) {
+    assertIsDefined(this.diffElement);
+    if (getDiffLength(this.diff) > CODE_MAX_LINES) {
       fireAlert(
         this,
-        `Files with more than ${SYNTAX_MAX_DIFF_LENGTH} lines` +
+        `Files with more than ${CODE_MAX_LINES} lines` +
           '  will not be syntax highlighted.'
       );
       return false;
@@ -1059,34 +1433,9 @@
     return true;
   }
 
-  _listenToViewportRender() {
-    const renderUpdateListener: DiffLayerListener = start => {
-      if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
-        this.reporting.diffViewDisplayed();
-        this.syntaxLayer.removeListener(renderUpdateListener);
-      }
-    };
-
-    this.syntaxLayer.addListener(renderUpdateListener);
-  }
-
-  _handleRenderStart() {
-    this.reporting.time(Timing.DIFF_TOTAL);
-    this.reporting.time(Timing.DIFF_CONTENT);
-  }
-
-  _handleRenderContent() {
-    this.reporting.timeEnd(Timing.DIFF_CONTENT);
-  }
-
-  _handleNormalizeRange(event: CustomEvent) {
-    this.reporting.reportInteraction('normalize-range', {
-      side: event.detail.side,
-      lineNum: event.detail.lineNum,
-    });
-  }
-
-  _handleDiffContextExpanded(e: CustomEvent<DiffContextExpandedEventDetail>) {
+  private handleDiffContextExpanded(
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) {
     this.reporting.reportInteraction('diff-context-expanded', {
       numLines: e.detail.numLines,
     });
@@ -1099,8 +1448,10 @@
    * false if testing the revision.
    * @return returns the chunk object or null if there was
    * no chunk for that side.
+   *
+   * Private but used in tests.
    */
-  _lastChunkForSide(diff: DiffInfo | undefined, leftSide: boolean) {
+  lastChunkForSide(diff: DiffInfo | undefined, leftSide: boolean) {
     if (!diff?.content.length) {
       return null;
     }
@@ -1139,9 +1490,11 @@
    * @return Return true if the side has a trailing newline.
    * Return false if it doesn't. Return null if not applicable (for
    * example, if the diff has no content on the specified side).
+   *
+   * Private but used in tests.
    */
-  _hasTrailingNewlines(diff: DiffInfo | undefined, leftSide: boolean) {
-    const chunk = this._lastChunkForSide(diff, leftSide);
+  hasTrailingNewlines(diff: DiffInfo | undefined, leftSide: boolean) {
+    const chunk = this.lastChunkForSide(diff, leftSide);
     if (!chunk) return null;
     let lines;
     if (chunk.ab) {
@@ -1152,18 +1505,6 @@
     if (!lines) return null;
     return lines[lines.length - 1] === '';
   }
-
-  _showNewlineWarningLeft(diff?: DiffInfo) {
-    return this._hasTrailingNewlines(diff, true) === false;
-  }
-
-  _showNewlineWarningRight(diff?: DiffInfo) {
-    return this._hasTrailingNewlines(diff, false) === false;
-  }
-
-  _useNewImageDiffUi() {
-    return this.flags.isEnabled(KnownExperimentId.NEW_IMAGE_DIFF_UI);
-  }
 }
 
 declare global {
@@ -1171,17 +1512,3 @@
     'gr-diff-host': GrDiffHost;
   }
 }
-
-// TODO(TS): Be more specific than CustomEvent, which has detail:any.
-declare global {
-  interface HTMLElementEventMap {
-    /* prettier-ignore */
-    'render': CustomEvent;
-    'normalize-range': CustomEvent;
-    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
-    'create-comment': CustomEvent;
-    'comment-update': CustomEvent;
-    'comment-save': CustomEvent;
-    'root-id-changed': CustomEvent;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
deleted file mode 100644
index e4efb5a..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
+++ /dev/null
@@ -1,47 +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-diff
-    id="diff"
-    change-num="[[changeNum]]"
-    no-auto-render="[[noAutoRender]]"
-    path="[[path]]"
-    prefs="[[prefs]]"
-    display-line="[[displayLine]]"
-    is-image-diff="[[isImageDiff]]"
-    hidden$="[[hidden]]"
-    no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
-    render-prefs="[[_renderPrefs]]"
-    line-wrapping="[[lineWrapping]]"
-    view-mode="[[viewMode]]"
-    line-of-interest="[[lineOfInterest]]"
-    logged-in="[[_loggedIn]]"
-    error-message="[[_errorMessage]]"
-    base-image="[[_baseImage]]"
-    revision-image="[[_revisionImage]]"
-    coverage-ranges="[[_coverageRanges]]"
-    blame="[[_blame]]"
-    layers="[[_layers]]"
-    diff="[[diff]]"
-    show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
-    show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
-    use-new-image-diff-ui="[[_useNewImageDiffUi()]]"
-  >
-  </gr-diff>
-`;
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
deleted file mode 100644
index 8d75230..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ /dev/null
@@ -1,1563 +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-diff-host.js';
-
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {createDefaultDiffPrefs, Side} from '../../../constants/constants.js';
-import {createChange, createComment, createCommentThread} from '../../../test/test-data-generators.js';
-import {addListenerForTest, mockPromise, stubRestApi} from '../../../test/test-utils.js';
-import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
-import {CoverageType} from '../../../types/types.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
-
-const basicFixture = fixtureFromElement('gr-diff-host');
-
-suite('gr-diff-host tests', () => {
-  let element;
-
-  let loggedIn;
-
-  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(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element.jsAPI, 'getDiffLayers').returns(pluginLayers);
-      element.changeNum = 123;
-      element.path = 'some/path';
-    });
-    test('plugin layers requested', async () => {
-      element.patchRange = {};
-      element.change = createChange();
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      assert(element.jsAPI.getDiffLayers.called);
-    });
-  });
-
-  suite('render reporting', () => {
-    test('starts total and content timer on render-start', () => {
-      element.dispatchEvent(
-          new CustomEvent('render-start', {bubbles: true, composed: true}));
-      assert.isTrue(element.reporting.time.calledWithExactly(
-          'Diff Total Render'));
-      assert.isTrue(element.reporting.time.calledWithExactly(
-          'Diff Content Render'));
-    });
-
-    test('ends content timer on render-content', () => {
-      element.dispatchEvent(
-          new CustomEvent('render-content', {bubbles: true, composed: true}));
-      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
-          'Diff Content Render'));
-    });
-
-    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 => {
-            notifySyntaxProcessed = resolve;
-          })
-      );
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      element.patchRange = {};
-      element.change = createChange();
-      element.prefs = createDefaultDiffPrefs();
-      element.reload(true);
-      // Multiple cascading microtasks are scheduled.
-      await flush();
-      notifySyntaxProcessed();
-      // Multiple cascading microtasks are scheduled.
-      await flush();
-      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
-          'Diff Total Render'));
-      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
-          'Diff Syntax Render'));
-      assert.isTrue(element.reporting.diffViewContentDisplayed.called);
-    });
-
-    test('ends total timer w/ no syntax layer processing', async () => {
-      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(
-          element.reporting.timeEnd.getCalls()
-              .filter(call => call.calledWithExactly('Diff Total Render'))
-              .length,
-          1);
-    });
-
-    test('completes reload promise after syntax layer processing', async () => {
-      let notifySyntaxProcessed;
-      sinon.stub(element.syntaxLayer, 'process').returns(new Promise(
-          resolve => {
-            notifySyntaxProcessed = resolve;
-          }));
-      stubRestApi('getDiff').returns(
-          Promise.resolve({content: []}));
-      element.patchRange = {};
-      element.change = createChange();
-      let reloadComplete = false;
-      element.prefs = createDefaultDiffPrefs();
-      element.reload().then(() => {
-        reloadComplete = true;
-      });
-      // Multiple cascading microtasks are scheduled.
-      await flush();
-      assert.isFalse(reloadComplete);
-      notifySyntaxProcessed();
-      // Assert after the notification task is processed.
-      await flush();
-      assert.isTrue(reloadComplete);
-    });
-  });
-
-  test('reload() cancels before network resolves', () => {
-    const cancelStub = sinon.stub(element.$.diff, 'cancel');
-
-    // Stub the network calls into requests that never resolve.
-    sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
-    element.patchRange = {};
-    element.change = createChange();
-
-    // Needs to be set to something first for it to cancel.
-    element.diff = {
-      content: [{
-        a: ['foo'],
-      }],
-    };
-
-    element.reload();
-    assert.isTrue(cancelStub.called);
-  });
-
-  test('reload() loads files weblinks', async () => {
-    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 = {};
-
-    await element.reload();
-
-    assert.equal(weblinksStub.callCount, 3);
-    assert.deepEqual(weblinksStub.firstCall.args[0], {
-      commit: 'test-base',
-      file: 'test-path',
-      options: {
-        weblinks: undefined,
-      },
-      repo: 'test-project',
-      type: GerritNav.WeblinkType.EDIT});
-    assert.deepEqual(element.editWeblinks, [{
-      name: 'stubb', url: '#s',
-    }]);
-    assert.deepEqual(weblinksStub.secondCall.args[0], {
-      commit: 'test-base',
-      file: 'test-path',
-      options: {
-        weblinks: undefined,
-      },
-      repo: 'test-project',
-      type: GerritNav.WeblinkType.FILE});
-    assert.deepEqual(weblinksStub.thirdCall.args[0], {
-      commit: 'test-commit',
-      file: 'test-path',
-      options: {
-        weblinks: undefined,
-      },
-      repo: 'test-project',
-      type: GerritNav.WeblinkType.FILE});
-    assert.deepEqual(element.filesWeblinks, {
-      meta_a: [{name: 'stubb', url: '#s'}],
-      meta_b: [{name: 'stubb', url: '#s'}],
-    });
-  });
-
-  test('prefetch getDiff', async () => {
-    const diffRestApiStub = stubRestApi('getDiff')
-        .returns(Promise.resolve({content: []}));
-    element.changeNum = 123;
-    element.patchRange = {basePatchNum: 1, patchNum: 2};
-    element.path = 'file.txt';
-    element.prefetchDiff();
-    await element._getDiff();
-    assert.isTrue(diffRestApiStub.calledOnce);
-  });
-
-  test('_getDiff handles null diff responses', async () => {
-    stubRestApi('getDiff').returns(Promise.resolve(null));
-    element.changeNum = 123;
-    element.patchRange = {basePatchNum: 1, patchNum: 2};
-    element.path = 'file.txt';
-    await element._getDiff();
-  });
-
-  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', async () => {
-      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 promise = mockPromise();
-      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);
-            promise.resolve();
-          }
-        });
-
-        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);
-            promise.resolve();
-          }
-        });
-      };
-
-      element.addEventListener('render', rendered);
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-
-    test('renders image diffs with a different file name', async () => {
-      const mockDiff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-          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 promise = mockPromise();
-      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);
-            promise.resolve();
-          }
-        });
-
-        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);
-            promise.resolve();
-          }
-        });
-      };
-
-      element.addEventListener('render', rendered);
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-
-    test('renders added image', async () => {
-      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',
-        },
-      }));
-
-      const promise = mockPromise();
-      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);
-        promise.resolve();
-      });
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-
-    test('renders removed image', async () => {
-      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,
-      }));
-
-      const promise = mockPromise();
-      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);
-        promise.resolve();
-      });
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-
-    test('does not render disallowed image type', async () => {
-      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,
-      }));
-
-      const promise = mockPromise();
-      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);
-        promise.resolve();
-      });
-
-      element.prefs = createDefaultDiffPrefs();
-      element.reload();
-      await promise;
-    });
-  });
-
-  test('cannot create comments when not logged in', () => {
-    element.patchRange = {
-      basePatchNum: 'PARENT',
-      patchNum: 2,
-    };
-    const showAuthRequireSpy = sinon.spy();
-    element.addEventListener('show-auth-required', showAuthRequireSpy);
-
-    element.dispatchEvent(new CustomEvent('create-comment', {
-      detail: {
-        lineNum: 3,
-        side: Side.LEFT,
-        path: '/p',
-      },
-    }));
-
-    const threads = dom(element.$.diff)
-        .queryDistributedElements('gr-comment-thread');
-
-    assert.equal(threads.length, 0);
-
-    assert.isTrue(showAuthRequireSpy.called);
-  });
-
-  test('delegates cancel()', () => {
-    const stub = sinon.stub(element.$.diff, 'cancel');
-    element.patchRange = {};
-    element.cancel();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates getCursorStops()', () => {
-    const returnValue = [document.createElement('b')];
-    const stub = sinon.stub(element.$.diff, 'getCursorStops')
-        .returns(returnValue);
-    assert.equal(element.getCursorStops(), returnValue);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates isRangeSelected()', () => {
-    const returnValue = true;
-    const stub = sinon.stub(element.$.diff, 'isRangeSelected')
-        .returns(returnValue);
-    assert.equal(element.isRangeSelected(), returnValue);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates toggleLeftDiff()', () => {
-    const stub = sinon.stub(element.$.diff, 'toggleLeftDiff');
-    element.toggleLeftDiff();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  suite('blame', () => {
-    setup(async () => {
-      element = basicFixture.instantiate();
-      element.changeNum = 123;
-      element.path = 'some/path';
-      await flush();
-    });
-
-    test('clearBlame', () => {
-      element._blame = [];
-      const setBlameSpy = sinon.spy(element.$.diff.$.diffBuilder, 'setBlame');
-      element.clearBlame();
-      assert.isNull(element._blame);
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
-      assert.equal(element.isBlameLoaded, false);
-    });
-
-    test('loadBlame', () => {
-      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-      const showAlertStub = sinon.stub();
-      element.addEventListener('show-alert', showAlertStub);
-      const getBlameStub = stubRestApi('getBlame')
-          .returns(Promise.resolve(mockBlame));
-      element.changeNum = 42;
-      element.patchRange = {patchNum: 5, basePatchNum: 4};
-      element.path = 'foo/bar.baz';
-      return element.loadBlame().then(() => {
-        assert.isTrue(getBlameStub.calledWithExactly(
-            42, 5, 'foo/bar.baz', true));
-        assert.isFalse(showAlertStub.called);
-        assert.equal(element._blame, mockBlame);
-        assert.equal(element.isBlameLoaded, true);
-      });
-    });
-
-    test('loadBlame empty', () => {
-      const mockBlame = [];
-      const showAlertStub = sinon.stub();
-      element.addEventListener('show-alert', showAlertStub);
-      stubRestApi('getBlame')
-          .returns(Promise.resolve(mockBlame));
-      element.changeNum = 42;
-      element.patchRange = {patchNum: 5, basePatchNum: 4};
-      element.path = 'foo/bar.baz';
-      return element.loadBlame()
-          .then(() => {
-            assert.isTrue(false, 'Promise should not resolve');
-          })
-          .catch(() => {
-            assert.isTrue(showAlertStub.calledOnce);
-            assert.isNull(element._blame);
-            assert.equal(element.isBlameLoaded, false);
-          });
-    });
-  });
-
-  test('getThreadEls() returns .comment-threads', () => {
-    const threadEl = document.createElement('div');
-    threadEl.className = 'comment-thread';
-    element.$.diff.appendChild(threadEl);
-    assert.deepEqual(element.getThreadEls(), [threadEl]);
-  });
-
-  test('delegates addDraftAtLine(el)', () => {
-    const param0 = document.createElement('b');
-    const stub = sinon.stub(element.$.diff, 'addDraftAtLine');
-    element.addDraftAtLine(param0);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 1);
-    assert.equal(stub.lastCall.args[0], param0);
-  });
-
-  test('delegates clearDiffContent()', () => {
-    const stub = sinon.stub(element.$.diff, 'clearDiffContent');
-    element.clearDiffContent();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('delegates toggleAllContext()', () => {
-    const stub = sinon.stub(element.$.diff, 'toggleAllContext');
-    element.toggleAllContext();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
-  });
-
-  test('passes in changeNum', () => {
-    element.changeNum = 12345;
-    assert.equal(element.$.diff.changeNum, 12345);
-  });
-
-  test('passes in noAutoRender', () => {
-    const value = true;
-    element.noAutoRender = value;
-    assert.equal(element.$.diff.noAutoRender, value);
-  });
-
-  test('passes in path', () => {
-    const value = 'some/file/path';
-    element.path = value;
-    assert.equal(element.$.diff.path, value);
-  });
-
-  test('passes in prefs', () => {
-    const value = {};
-    element.prefs = value;
-    assert.equal(element.$.diff.prefs, value);
-  });
-
-  test('passes in changeNum', () => {
-    element.changeNum = 12345;
-    assert.equal(element.$.diff.changeNum, 12345);
-  });
-
-  test('passes in displayLine', () => {
-    const value = true;
-    element.displayLine = value;
-    assert.equal(element.$.diff.displayLine, value);
-  });
-
-  test('passes in hidden', () => {
-    const value = true;
-    element.hidden = value;
-    assert.equal(element.$.diff.hidden, value);
-    assert.isNotNull(element.getAttribute('hidden'));
-  });
-
-  test('passes in noRenderOnPrefsChange', () => {
-    const value = true;
-    element.noRenderOnPrefsChange = value;
-    assert.equal(element.$.diff.noRenderOnPrefsChange, value);
-  });
-
-  test('passes in lineWrapping', () => {
-    const value = true;
-    element.lineWrapping = value;
-    assert.equal(element.$.diff.lineWrapping, value);
-  });
-
-  test('passes in viewMode', () => {
-    const value = 'SIDE_BY_SIDE';
-    element.viewMode = value;
-    assert.equal(element.$.diff.viewMode, value);
-  });
-
-  test('passes in lineOfInterest', () => {
-    const value = {number: 123, leftSide: true};
-    element.lineOfInterest = value;
-    assert.equal(element.$.diff.lineOfInterest, value);
-  });
-
-  suite('_reportDiff', () => {
-    let reportStub;
-
-    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', () => {
-      element._reportDiff(null);
-      assert.isFalse(reportStub.called);
-
-      element._reportDiff({});
-      assert.isFalse(reportStub.called);
-    });
-
-    test('diff w/ no delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {ab: ['baz', 'foo']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-
-    test('diff w/ no rebase delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo']},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], b: ['bar', 'baz']},
-          {ab: ['foo', 'bar']},
-          {b: ['baz', 'foo']},
-          {ab: ['foo', 'bar']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-
-    test('diff w/ some rebase delta', () => {
-      const diff = {
-        content: [
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], due_to_rebase: true},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo'], b: ['bar', 'baz']},
-          {ab: ['foo', 'bar']},
-          {b: ['baz', 'foo'], due_to_rebase: true},
-          {ab: ['foo', 'bar']},
-          {a: ['baz', 'foo']},
-        ],
-      };
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.isTrue(reportStub.calledWith(
-          'rebase-percent-nonzero',
-          {percentRebaseDelta: 50}
-      ));
-    });
-
-    test('diff w/ all rebase delta', () => {
-      const diff = {content: [{
-        a: ['foo', 'bar'],
-        b: ['baz', 'foo'],
-        due_to_rebase: true,
-      }]};
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.isTrue(reportStub.calledWith(
-          'rebase-percent-nonzero',
-          {percentRebaseDelta: 100}
-      ));
-    });
-
-    test('diff against parent event', () => {
-      element.patchRange.basePatchNum = 'PARENT';
-      const diff = {content: [{
-        a: ['foo', 'bar'],
-        b: ['baz', 'foo'],
-      }]};
-      element._reportDiff(diff);
-      assert.isTrue(reportStub.calledOnce);
-      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
-      assert.isUndefined(reportStub.lastCall.args[1]);
-    });
-  });
-
-  suite('create-comment', () => {
-    setup(async () => {
-      loggedIn = true;
-      element.connectedCallback();
-      await flush();
-    });
-
-    test('creates comments if they do not exist yet', () => {
-      const diffSide = Side.LEFT;
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          lineNum: 3,
-          side: diffSide,
-          path: '/p',
-        },
-      }));
-
-      let threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      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);
-
-      // Try to fetch a thread with a different range.
-      const range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 1,
-        end_character: 3,
-      };
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          lineNum: 1,
-          side: diffSide,
-          path: '/p',
-          range,
-        },
-      }));
-
-      threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      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('should not be on parent if on the right', () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.RIGHT,
-        },
-      }));
-
-      const thread = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.isFalse(thread.isOnParent);
-    });
-
-    test('should be on parent if right and base is PARENT', () => {
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-        },
-      }));
-
-      const thread = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.isTrue(thread.isOnParent);
-    });
-
-    test('should be on parent if right and base negative', () => {
-      element.patchRange = {
-        basePatchNum: -2, // merge parents have negative numbers
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-        },
-      }));
-
-      const thread = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.isTrue(thread.isOnParent);
-    });
-
-    test('should not be on parent otherwise', () => {
-      element.patchRange = {
-        basePatchNum: 2, // merge parents have negative numbers
-        patchNum: 3,
-      };
-
-      element.dispatchEvent(new CustomEvent('create-comment', {
-        detail: {
-          side: Side.LEFT,
-        },
-      }));
-
-      const thread = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread')[0];
-
-      assert.isFalse(thread.isOnParent);
-    });
-
-    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};
-
-      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.basePath);
-    });
-
-    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};
-
-      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('multiple threads created on the same range', () => {
-      element.patchRange = {
-        basePatchNum: 2,
-        patchNum: 3,
-      };
-      element.file = {basePath: 'file_renamed.txt', path: element.path};
-
-      const comment = createComment();
-      comment.range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 2,
-        end_character: 2,
-      };
-      const thread = createCommentThread([comment]);
-      element.threads = [thread];
-
-      let threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-
-      assert.equal(threads.length, 1);
-      element.threads= [...element.threads, thread];
-
-      threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-      // Threads have same rootId so element is reused
-      assert.equal(threads.length, 1);
-
-      const newThread = {...thread};
-      newThread.rootId = 'differentRootId';
-      element.threads= [...element.threads, newThread];
-      threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
-      // New thread has a different rootId
-      assert.equal(threads.length, 2);
-    });
-
-    test('thread should use new file path if first created' +
-    'on patch set (left) but is base', () => {
-      const diffSide = Side.LEFT;
-      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', () => {
-    const line = {beforeNumber: 3, afterNumber: 5};
-
-    const threads = [];
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-        Side.LEFT), []);
-    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
-        Side.RIGHT), []);
-  });
-
-  test('_filterThreadElsForLocation for line comments', () => {
-    const line = {beforeNumber: 3, afterNumber: 5};
-
-    const l3 = document.createElement('div');
-    l3.setAttribute('line-num', 3);
-    l3.setAttribute('diff-side', Side.LEFT);
-
-    const l5 = document.createElement('div');
-    l5.setAttribute('line-num', 5);
-    l5.setAttribute('diff-side', Side.LEFT);
-
-    const r3 = document.createElement('div');
-    r3.setAttribute('line-num', 3);
-    r3.setAttribute('diff-side', Side.RIGHT);
-
-    const r5 = document.createElement('div');
-    r5.setAttribute('line-num', 5);
-    r5.setAttribute('diff-side', Side.RIGHT);
-
-    const threadEls = [l3, l5, r3, r5];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.LEFT), [l3]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.RIGHT), [r5]);
-  });
-
-  test('_filterThreadElsForLocation for file comments', () => {
-    const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
-
-    const l = document.createElement('div');
-    l.setAttribute('diff-side', Side.LEFT);
-    l.setAttribute('line-num', 'FILE');
-
-    const r = document.createElement('div');
-    r.setAttribute('diff-side', Side.RIGHT);
-    r.setAttribute('line-num', 'FILE');
-
-    const threadEls = [l, r];
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.LEFT), [l]);
-    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
-        Side.RIGHT), [r]);
-  });
-
-  suite('syntax layer with syntax_highlighting on', () => {
-    setup(() => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      element.patchRange = {};
-      element.prefs = prefs;
-      element.changeNum = 123;
-      element.change = createChange();
-      element.path = 'some/path';
-    });
-
-    test('gr-diff-host provides syntax highlighting layer', async () => {
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
-    });
-
-    test('rendering normal-sized diff does not disable syntax', () => {
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      assert.isTrue(element.syntaxLayer.enabled);
-    });
-
-    test('rendering large diff disables syntax', () => {
-      // Before it renders, set the first diff line to 500 '*' characters.
-      element.diff = {
-        content: [{
-          a: [new Array(501).join('*')],
-        }],
-      };
-      assert.isFalse(element.syntaxLayer.enabled);
-    });
-
-    test('starts syntax layer processing on render event', async () => {
-      sinon.stub(element.syntaxLayer, 'process')
-          .returns(Promise.resolve());
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      element.dispatchEvent(
-          new CustomEvent('render', {bubbles: true, composed: true}));
-      assert.isTrue(element.syntaxLayer.process.called);
-    });
-  });
-
-  suite('syntax layer with syntax_highlighting off', () => {
-    setup(() => {
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-      };
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      element.patchRange = {};
-      element.change = createChange();
-      element.prefs = prefs;
-    });
-
-    test('gr-diff-host provides syntax highlighting layer', async () => {
-      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
-      await element.reload();
-      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
-    });
-
-    test('syntax layer should be disabled', () => {
-      assert.isFalse(element.syntaxLayer.enabled);
-    });
-
-    test('still disabled for large diff', () => {
-      // Before it renders, set the first diff line to 500 '*' characters.
-      element.diff = {
-        content: [{
-          a: [new Array(501).join('*')],
-        }],
-      };
-      assert.isFalse(element.syntaxLayer.enabled);
-    });
-  });
-
-  suite('coverage layer', () => {
-    let notifyStub;
-    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();
-      coverageProviderStub = sinon.stub().returns(
-          Promise.resolve(exampleRanges));
-
-      element = basicFixture.instantiate();
-      sinon.stub(element.jsAPI, 'getCoverageAnnotationApis').returns(
-          Promise.resolve([{
-            notify: notifyStub,
-            getCoverageProvider() {
-              return coverageProviderStub;
-            },
-          }]));
-      element.changeNum = 123;
-      element.change = createChange();
-      element.path = 'some/path';
-      const prefs = {
-        line_length: 10,
-        show_tabs: true,
-        tab_size: 4,
-        context: -1,
-      };
-      element.diff = {
-        content: [{
-          a: ['foo'],
-        }],
-      };
-      element.patchRange = {};
-      element.prefs = prefs;
-      stubRestApi('getDiff').returns(Promise.resolve(element.diff));
-      await flush();
-    });
-
-    test('getCoverageAnnotationApis should be called', async () => {
-      await element.reload();
-      assert.isTrue(element.jsAPI.getCoverageAnnotationApis.calledOnce);
-    });
-
-    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', () => {
-    setup(() => {
-    });
-
-    suite('_lastChunkForSide', () => {
-      test('deltas', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar'], b: ['baz']},
-          {ab: ['foo', 'bar', 'baz']},
-          {b: ['foo']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
-
-        diff.content.push({a: ['foo'], b: ['bar']});
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
-      });
-
-      test('addition with a undefined', () => {
-        const diff = {content: [
-          {b: ['foo', 'bar', 'baz']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-
-      test('addition with a empty', () => {
-        const diff = {content: [
-          {a: [], b: ['foo', 'bar', 'baz']},
-        ]};
-        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-
-      test('deletion with b undefined', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar', 'baz']},
-        ]};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-      });
-
-      test('deletion with b empty', () => {
-        const diff = {content: [
-          {a: ['foo', 'bar', 'baz'], b: []},
-        ]};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
-      });
-
-      test('empty', () => {
-        const diff = {content: []};
-        assert.isNull(element._lastChunkForSide(diff, false));
-        assert.isNull(element._lastChunkForSide(diff, true));
-      });
-    });
-
-    suite('_hasTrailingNewlines', () => {
-      test('shared no trailing', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide')
-            .returns({ab: ['foo', 'bar']});
-        assert.isFalse(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('delta trailing in right', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide')
-            .returns({a: ['foo', 'bar'], b: ['baz', '']});
-        assert.isTrue(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('addition', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
-          if (leftSide) { return null; }
-          return {b: ['foo', '']};
-        });
-        assert.isTrue(element._hasTrailingNewlines(diff, false));
-        assert.isNull(element._hasTrailingNewlines(diff, true));
-      });
-
-      test('deletion', () => {
-        const diff = undefined;
-        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
-          if (!leftSide) { return null; }
-          return {a: ['foo']};
-        });
-        assert.isNull(element._hasTrailingNewlines(diff, false));
-        assert.isFalse(element._hasTrailingNewlines(diff, true));
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
new file mode 100644
index 0000000..be03afd
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -0,0 +1,1757 @@
+/**
+ * @license
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-host';
+import {
+  CommentSide,
+  createDefaultDiffPrefs,
+  Side,
+} from '../../../constants/constants';
+import {
+  createAccountDetailWithId,
+  createBlame,
+  createChange,
+  createComment,
+  createCommentThread,
+  createDiff,
+  createPatchRange,
+  createRunResult,
+} from '../../../test/test-data-generators';
+import {
+  addListenerForTest,
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  BasePatchSetNum,
+  BlameInfo,
+  CommentRange,
+  EDIT,
+  ImageInfo,
+  NumericChangeId,
+  PARENT,
+  PatchSetNum,
+  RevisionPatchSetNum,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {CoverageType} from '../../../types/types';
+import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-builder-image';
+import {GrDiffHost, LineInfo} from './gr-diff-host';
+import {DiffInfo, DiffViewMode, IgnoreWhitespaceType} from '../../../api/diff';
+import {ErrorCallback} from '../../../api/rest';
+import {SinonStub} from 'sinon';
+import {RunResult} from '../../../models/checks/checks-model';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken, UserModel} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
+suite('gr-diff-host tests', () => {
+  let element: GrDiffHost;
+  let account = createAccountDetailWithId(1);
+  let getDiffRestApiStub: SinonStub;
+  let userModel: UserModel;
+
+  setup(async () => {
+    stubRestApi('getAccount').callsFake(() => Promise.resolve(account));
+    element = await fixture(html`<gr-diff-host></gr-diff-host>`);
+    element.changeNum = 123 as NumericChangeId;
+    element.path = 'some/path';
+    element.change = createChange();
+    element.patchRange = createPatchRange();
+    getDiffRestApiStub = stubRestApi('getDiff');
+    // Fall back in case a test forgets to set one up
+    getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+    await element.updateComplete;
+    userModel = testResolver(userModelToken);
+  });
+
+  suite('render reporting', () => {
+    test('ends total and syntax timer after syntax layer', async () => {
+      const displayedStub = stubReporting('diffViewContentDisplayed');
+
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+      element.prefs = createDefaultDiffPrefs();
+      await element.updateComplete;
+      // Force a reload because it's not possible to wait on the reload called
+      // from update().
+      await element.reload();
+      const timeEndStub = sinon.stub(element.reporting, 'timeEnd');
+      let notifySyntaxProcessed: () => void = () => {};
+      sinon.stub(element.syntaxLayer, 'process').returns(
+        new Promise(resolve => {
+          notifySyntaxProcessed = resolve;
+        })
+      );
+      const promise = element.reload(true);
+      // Multiple cascading microtasks are scheduled.
+      notifySyntaxProcessed();
+      await element.updateComplete;
+      await promise;
+      const calls = timeEndStub.getCalls();
+      assert.equal(calls.length, 4);
+      assert.equal(calls[0].args[0], 'Diff Load Render');
+      assert.equal(calls[1].args[0], 'Diff Content Render');
+      assert.equal(calls[2].args[0], 'Diff Syntax Render');
+      assert.equal(calls[3].args[0], 'Diff Total Render');
+      assert.isTrue(displayedStub.called);
+    });
+
+    test('completes reload promise after syntax layer processing', async () => {
+      let notifySyntaxProcessed: () => void = () => {};
+      sinon.stub(element.syntaxLayer, 'process').returns(
+        new Promise(resolve => {
+          notifySyntaxProcessed = resolve;
+        })
+      );
+      getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+      let reloadComplete = false;
+      element.prefs = createDefaultDiffPrefs();
+      const promise = mockPromise();
+      element.reload().then(() => {
+        reloadComplete = true;
+        promise.resolve();
+      });
+      // Multiple cascading microtasks are scheduled.
+      assert.isFalse(reloadComplete);
+      notifySyntaxProcessed();
+      await promise;
+      assert.isTrue(reloadComplete);
+    });
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-diff
+          id="diff"
+          style="--line-limit-marker:-1px; --content-width:100ch; --diff-max-width:none; --font-size:12px;"
+        >
+        </gr-diff>
+      `
+    );
+  });
+
+  test('reload() cancels before network resolves', async () => {
+    assertIsDefined(element.diffElement);
+    const cancelStub = sinon.stub(element.diffElement, 'cancel');
+
+    // Stub the network calls into requests that never resolve.
+    sinon.stub(element, 'getDiff').callsFake(() => new Promise(() => {}));
+    element.patchRange = createPatchRange();
+    element.change = createChange();
+    element.prefs = undefined;
+
+    // Needs to be set to something first for it to cancel.
+    element.diff = createDiff();
+    await element.updateComplete;
+
+    element.reload();
+    assert.isTrue(cancelStub.called);
+  });
+
+  test('prefetch getDiff', async () => {
+    getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+    element.changeNum = 123 as NumericChangeId;
+    element.patchRange = createPatchRange();
+    element.path = 'file.txt';
+    element.prefetchDiff();
+    await element.getDiff();
+    assert.isTrue(getDiffRestApiStub.calledOnce);
+  });
+
+  test('getDiff handles undefined diff responses', async () => {
+    getDiffRestApiStub.returns(Promise.resolve(undefined));
+    element.changeNum = 123 as NumericChangeId;
+    element.patchRange = createPatchRange();
+    element.path = 'file.txt';
+    await element.getDiff();
+  });
+
+  test('reload resolves on error', () => {
+    const onErrStub = sinon.stub(element, 'handleGetDiffError');
+    const error = new Response(null, {status: 500});
+    getDiffRestApiStub.callsFake(
+      (
+        _1: NumericChangeId,
+        _2: PatchSetNum,
+        _3: PatchSetNum,
+        _4: string,
+        _5?: IgnoreWhitespaceType,
+        onErr?: ErrorCallback
+      ) => {
+        if (onErr) onErr(error);
+        return Promise.resolve(undefined);
+      }
+    );
+    element.patchRange = createPatchRange();
+    return element.reload().then(() => {
+      assert.isTrue(onErrStub.calledOnce);
+    });
+  });
+
+  suite('handleGetDiffError', () => {
+    let serverErrorStub: sinon.SinonStub;
+    let pageErrorStub: sinon.SinonStub;
+
+    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} as Response);
+      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(''),
+      } as Response);
+      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!',
+      } as Response);
+      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: ImageInfo;
+    let mockFile2: ImageInfo;
+    setup(() => {
+      mockFile1 = {
+        body:
+          'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAAAAAA/w==',
+        type: 'image/bmp',
+        _expectedType: 'image/bmp',
+        _name: 'carrot.bmp',
+      };
+      mockFile2 = {
+        body:
+          'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+          'wsAAAAAAAAAAAAA/////w==',
+        type: 'image/bmp',
+        _expectedType: 'image/bmp',
+        _name: 'potato.bmp',
+      };
+
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+    });
+
+    test('renders image diffs with same file name', async () => {
+      const mockDiff: DiffInfo = {
+        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,
+      };
+      getDiffRestApiStub.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',
+          },
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await element.waitForReloadToRender();
+
+      // Recognizes that it should be an image diff.
+      assert.isTrue(element.isImageDiff);
+      assertIsDefined(element.diffElement);
+      assert.instanceOf(
+        element.diffElement.diffBuilder.builder,
+        GrDiffBuilderImage
+      );
+
+      // Left image rendered with the parent commit's version of the file.
+      assertIsDefined(element.diffElement);
+      assertIsDefined(element.diffElement.diffTable);
+      const diffTable = element.diffElement.diffTable;
+      const leftImage = queryAndAssert(diffTable, 'td.left img');
+      const leftLabel = queryAndAssert(diffTable, 'td.left label');
+      const leftLabelContent = leftLabel.querySelector('.label');
+      const leftLabelName = leftLabel.querySelector('.name');
+
+      const rightImage = queryAndAssert(diffTable, 'td.right img');
+      const rightLabel = queryAndAssert(diffTable, 'td.right label');
+      const rightLabelContent = rightLabel.querySelector('.label');
+      const rightLabelName = rightLabel.querySelector('.name');
+
+      assert.isOk(leftImage);
+      assert.equal(
+        leftImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile1.body
+      );
+      assert.isTrue(leftLabelContent?.textContent?.includes('image/bmp'));
+      assert.isNotOk(leftLabelName);
+
+      assert.isOk(rightImage);
+      assert.equal(
+        rightImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile2.body
+      );
+      assert.isTrue(rightLabelContent?.textContent?.includes('image/bmp'));
+      assert.isNotOk(rightLabelName);
+    });
+
+    test('renders image diffs with a different file name', async () => {
+      const mockDiff: DiffInfo = {
+        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,
+      };
+      getDiffRestApiStub.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',
+          },
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await element.waitForReloadToRender();
+
+      // Recognizes that it should be an image diff.
+      assert.isTrue(element.isImageDiff);
+      assertIsDefined(element.diffElement);
+      assert.instanceOf(
+        element.diffElement.diffBuilder.builder,
+        GrDiffBuilderImage
+      );
+
+      // Left image rendered with the parent commit's version of the file.
+      assertIsDefined(element.diffElement.diffTable);
+      const diffTable = element.diffElement.diffTable;
+      const leftImage = queryAndAssert(diffTable, 'td.left img');
+      const leftLabel = queryAndAssert(diffTable, 'td.left label');
+      const leftLabelContent = leftLabel.querySelector('.label');
+      const leftLabelName = leftLabel.querySelector('.name');
+
+      const rightImage = queryAndAssert(diffTable, 'td.right img');
+      const rightLabel = queryAndAssert(diffTable, '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);
+
+      assert.isOk(leftImage);
+      assert.equal(
+        leftImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile1.body
+      );
+      assert.isTrue(leftLabelContent?.textContent?.includes('image/bmp'));
+
+      assert.isOk(rightImage);
+      assert.equal(
+        rightImage.getAttribute('src'),
+        'data:image/bmp;base64,' + mockFile2.body
+      );
+      assert.isTrue(rightLabelContent?.textContent?.includes('image/bmp'));
+    });
+
+    test('renders added image', async () => {
+      const mockDiff: DiffInfo = {
+        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,
+      };
+      getDiffRestApiStub.returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: null,
+          revisionImage: {
+            ...mockFile2,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot2.jpg',
+          },
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await element.waitForReloadToRender().then(() => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assertIsDefined(element.diffElement);
+        assert.instanceOf(
+          element.diffElement.diffBuilder.builder,
+          GrDiffBuilderImage
+        );
+        assertIsDefined(element.diffElement.diffTable);
+        const diffTable = element.diffElement.diffTable;
+
+        const leftImage = query(diffTable, 'td.left img');
+        const rightImage = queryAndAssert(diffTable, 'td.right img');
+
+        assert.isNotOk(leftImage);
+        assert.isOk(rightImage);
+      });
+    });
+
+    test('renders removed image', async () => {
+      const mockDiff: DiffInfo = {
+        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,
+      };
+      getDiffRestApiStub.returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: {
+            ...mockFile1,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+          revisionImage: null,
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.reload();
+      await element.waitForReloadToRender().then(() => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assertIsDefined(element.diffElement);
+        assert.instanceOf(
+          element.diffElement.diffBuilder.builder,
+          GrDiffBuilderImage
+        );
+
+        assertIsDefined(element.diffElement.diffTable);
+        const diffTable = element.diffElement.diffTable;
+
+        const leftImage = queryAndAssert(diffTable, 'td.left img');
+        const rightImage = query(diffTable, 'td.right img');
+
+        assert.isOk(leftImage);
+        assert.isNotOk(rightImage);
+      });
+    });
+
+    test('does not render disallowed image type', async () => {
+      const mockDiff: DiffInfo = {
+        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';
+
+      getDiffRestApiStub.returns(Promise.resolve(mockDiff));
+      stubRestApi('getImagesForDiff').returns(
+        Promise.resolve({
+          baseImage: {
+            ...mockFile1,
+            _expectedType: 'image/jpeg',
+            _name: 'carrot.jpg',
+          },
+          revisionImage: null,
+        })
+      );
+
+      element.prefs = createDefaultDiffPrefs();
+      element.updateComplete.then(() => {
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assertIsDefined(element.diffElement);
+        assert.instanceOf(
+          element.diffElement.diffBuilder.builder,
+          GrDiffBuilderImage
+        );
+        assertIsDefined(element.diffElement.diffTable);
+        const diffTable = element.diffElement.diffTable;
+
+        const leftImage = query(diffTable, 'td.left img');
+        assert.isNotOk(leftImage);
+      });
+    });
+  });
+
+  test('cannot create comments when not logged in', () => {
+    userModel.setAccount(undefined);
+    element.patchRange = createPatchRange();
+    const showAuthRequireSpy = sinon.spy();
+    element.addEventListener('show-auth-required', showAuthRequireSpy);
+
+    element.dispatchEvent(
+      new CustomEvent('create-comment', {
+        detail: {
+          lineNum: 3,
+          side: Side.LEFT,
+          path: '/p',
+        },
+      })
+    );
+
+    assertIsDefined(element.diffElement);
+    const threads = queryAll(element.diffElement, 'gr-comment-thread');
+    assert.equal(threads.length, 0);
+    assert.isTrue(showAuthRequireSpy.called);
+  });
+
+  test('delegates cancel()', () => {
+    assertIsDefined(element.diffElement);
+    const stub = sinon.stub(element.diffElement, 'cancel');
+    element.patchRange = createPatchRange();
+    element.cancel();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates getCursorStops()', () => {
+    const returnValue = [document.createElement('b')];
+    assertIsDefined(element.diffElement);
+    const stub = sinon
+      .stub(element.diffElement, 'getCursorStops')
+      .returns(returnValue);
+    assert.equal(element.getCursorStops(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates isRangeSelected()', () => {
+    const returnValue = true;
+    assertIsDefined(element.diffElement);
+    const stub = sinon
+      .stub(element.diffElement, 'isRangeSelected')
+      .returns(returnValue);
+    assert.equal(element.isRangeSelected(), returnValue);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates toggleLeftDiff()', () => {
+    assertIsDefined(element.diffElement);
+    const stub = sinon.stub(element.diffElement, 'toggleLeftDiff');
+    element.toggleLeftDiff();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  suite('blame', () => {
+    setup(async () => {
+      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
+      element.changeNum = 123 as NumericChangeId;
+      element.path = 'some/path';
+      await element.updateComplete;
+    });
+
+    test('clearBlame', async () => {
+      element.blame = [];
+      await element.updateComplete;
+      assertIsDefined(element.diffElement);
+      const setBlameSpy = sinon.spy(
+        element.diffElement.diffBuilder,
+        'setBlame'
+      );
+      const isBlameLoadedStub = sinon.stub();
+      element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
+      element.clearBlame();
+      await element.updateComplete;
+      assert.isNull(element.blame);
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isTrue(isBlameLoadedStub.calledOnce);
+      assert.isFalse(isBlameLoadedStub.args[0][0].detail.value);
+    });
+
+    test('loadBlame', async () => {
+      const mockBlame: BlameInfo[] = [createBlame()];
+      const showAlertStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, showAlertStub);
+      const getBlameStub = stubRestApi('getBlame').returns(
+        Promise.resolve(mockBlame)
+      );
+      const changeNum = 42 as NumericChangeId;
+      element.changeNum = changeNum;
+      element.patchRange = createPatchRange();
+      element.path = 'foo/bar.baz';
+      await element.updateComplete;
+      const isBlameLoadedStub = sinon.stub();
+      element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
+
+      return element.loadBlame().then(() => {
+        assert.isTrue(
+          getBlameStub.calledWithExactly(
+            changeNum,
+            1 as RevisionPatchSetNum,
+            'foo/bar.baz',
+            true
+          )
+        );
+        assert.isFalse(showAlertStub.called);
+        assert.equal(element.blame, mockBlame);
+        assert.isTrue(isBlameLoadedStub.calledOnce);
+        assert.isTrue(isBlameLoadedStub.args[0][0].detail.value);
+      });
+    });
+
+    test('loadBlame empty', async () => {
+      const mockBlame: BlameInfo[] = [];
+      const showAlertStub = sinon.stub();
+      const isBlameLoadedStub = sinon.stub();
+      element.addEventListener(EventType.SHOW_ALERT, showAlertStub);
+      element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
+      stubRestApi('getBlame').returns(Promise.resolve(mockBlame));
+      const changeNum = 42 as NumericChangeId;
+      element.changeNum = changeNum;
+      element.patchRange = createPatchRange();
+      element.path = 'foo/bar.baz';
+      await element.updateComplete;
+      return element
+        .loadBlame()
+        .then(() => {
+          assert.isTrue(false, 'Promise should not resolve');
+        })
+        .catch(() => {
+          assert.isTrue(showAlertStub.calledOnce);
+          assert.isNull(element.blame);
+          // We don't expect a call because
+          assert.isTrue(isBlameLoadedStub.notCalled);
+        });
+    });
+  });
+
+  test('getThreadEls() returns .comment-threads', () => {
+    const threadEl = document.createElement('gr-comment-thread');
+    threadEl.className = 'comment-thread';
+    assertIsDefined(element.diffElement);
+    element.diffElement.appendChild(threadEl);
+    assert.deepEqual(element.getThreadEls(), [threadEl]);
+  });
+
+  test('delegates addDraftAtLine(el)', () => {
+    const param0 = document.createElement('b');
+    assertIsDefined(element.diffElement);
+    const stub = sinon.stub(element.diffElement, 'addDraftAtLine');
+    element.addDraftAtLine(param0);
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 1);
+    assert.equal(stub.lastCall.args[0], param0);
+  });
+
+  test('delegates clearDiffContent()', () => {
+    assertIsDefined(element.diffElement);
+    const stub = sinon.stub(element.diffElement, 'clearDiffContent');
+    element.clearDiffContent();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('delegates toggleAllContext()', () => {
+    assertIsDefined(element.diffElement);
+    const stub = sinon.stub(element.diffElement, 'toggleAllContext');
+    element.toggleAllContext();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.args.length, 0);
+  });
+
+  test('passes in noAutoRender', async () => {
+    const value = true;
+    element.noAutoRender = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.noAutoRender, value);
+  });
+
+  test('passes in path', async () => {
+    const value = 'some/file/path';
+    element.path = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.path, value);
+  });
+
+  test('passes in prefs', async () => {
+    const value = createDefaultDiffPrefs();
+    element.prefs = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.prefs, value);
+  });
+
+  test('passes in displayLine', async () => {
+    const value = true;
+    element.displayLine = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.displayLine, value);
+  });
+
+  test('passes in hidden', async () => {
+    const value = true;
+    element.hidden = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.hidden, value);
+    assert.isNotNull(element.getAttribute('hidden'));
+  });
+
+  test('passes in noRenderOnPrefsChange', async () => {
+    const value = true;
+    element.noRenderOnPrefsChange = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.noRenderOnPrefsChange, value);
+  });
+
+  test('passes in lineWrapping', async () => {
+    const value = true;
+    element.lineWrapping = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.lineWrapping, value);
+  });
+
+  test('passes in viewMode', async () => {
+    const value = DiffViewMode.SIDE_BY_SIDE;
+    element.viewMode = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.viewMode, value);
+  });
+
+  test('passes in lineOfInterest', async () => {
+    const value = {lineNum: 123, side: Side.LEFT};
+    element.lineOfInterest = value;
+    await element.updateComplete;
+    assertIsDefined(element.diffElement);
+    assert.equal(element.diffElement.lineOfInterest, value);
+  });
+
+  suite('reportDiff', () => {
+    let reportStub: SinonStub;
+
+    setup(async () => {
+      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
+      element.changeNum = 123 as NumericChangeId;
+      element.path = 'file.txt';
+      element.patchRange = createPatchRange(1, 2);
+      reportStub = sinon.stub(element.reporting, 'reportInteraction');
+      await element.updateComplete;
+      reportStub.reset();
+    });
+
+    test('undefined', () => {
+      element.reportDiff(undefined);
+      assert.isFalse(reportStub.called);
+    });
+
+    test('diff w/ no delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [{ab: ['foo', 'bar']}, {ab: ['baz', 'foo']}],
+      };
+      element.reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ no rebase delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo']},
+          {ab: ['foo', 'bar']},
+        ],
+      };
+      element.reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+
+    test('diff w/ some rebase delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo'], b: ['bar', 'baz']},
+          {ab: ['foo', 'bar']},
+          {b: ['baz', 'foo'], due_to_rebase: true},
+          {ab: ['foo', 'bar']},
+          {a: ['baz', 'foo']},
+        ],
+      };
+      element.reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(
+        reportStub.calledWith('rebase-percent-nonzero', {
+          percentRebaseDelta: 50,
+        })
+      );
+    });
+
+    test('diff w/ all rebase delta', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {
+            a: ['foo', 'bar'],
+            b: ['baz', 'foo'],
+            due_to_rebase: true,
+          },
+        ],
+      };
+      element.reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.isTrue(
+        reportStub.calledWith('rebase-percent-nonzero', {
+          percentRebaseDelta: 100,
+        })
+      );
+    });
+
+    test('diff against parent event', () => {
+      element.patchRange = createPatchRange();
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {
+            a: ['foo', 'bar'],
+            b: ['baz', 'foo'],
+          },
+        ],
+      };
+      element.reportDiff(diff);
+      assert.isTrue(reportStub.calledOnce);
+      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
+      assert.isUndefined(reportStub.lastCall.args[1]);
+    });
+  });
+
+  suite('createCheckEl method', () => {
+    test('start_line:12', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [{path: 'a', range: {start_line: 12} as CommentRange}],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-12');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '12');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+
+    test('start_line:13 end_line:14 without char positions', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [
+          {path: 'a', range: {start_line: 13, end_line: 14} as CommentRange},
+        ],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-14');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '14');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+
+    test('start_line:13 end_line:14 with char positions', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [
+          {
+            path: 'a',
+            range: {
+              start_line: 13,
+              end_line: 14,
+              start_character: 5,
+              end_character: 7,
+            },
+          },
+        ],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-14');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), '14');
+      assert.equal(
+        el.getAttribute('range'),
+        '{"start_line":13,' +
+          '"end_line":14,' +
+          '"start_character":5,' +
+          '"end_character":7}'
+      );
+      assert.equal(el.result, result);
+    });
+
+    test('empty range', () => {
+      const result: RunResult = {
+        ...createRunResult(),
+        codePointers: [{path: 'a', range: {} as CommentRange}],
+      };
+      const el = element.createCheckEl(result);
+      assert.equal(el.getAttribute('slot'), 'right-FILE');
+      assert.equal(el.getAttribute('diff-side'), 'right');
+      assert.equal(el.getAttribute('line-num'), 'FILE');
+      assert.equal(el.getAttribute('range'), null);
+      assert.equal(el.result, result);
+    });
+  });
+
+  suite('create-comment', () => {
+    setup(async () => {
+      account = createAccountDetailWithId(1);
+      element.disconnectedCallback();
+      element.connectedCallback();
+      await element.updateComplete;
+    });
+
+    test('creates comments if they do not exist yet', async () => {
+      element.patchRange = createPatchRange();
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            lineNum: 3,
+            side: Side.LEFT,
+            path: '/p',
+          },
+        })
+      );
+      assertIsDefined(element.diffElement);
+      let threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[0].thread?.range, undefined);
+      assert.equal(threads[0].thread?.patchNum, 1 as RevisionPatchSetNum);
+
+      // Try to fetch a thread with a different range.
+      const range = {
+        start_line: 1,
+        start_character: 1,
+        end_line: 1,
+        end_character: 3,
+      };
+      element.patchRange = createPatchRange();
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            lineNum: 1,
+            side: Side.LEFT,
+            path: '/p',
+            range,
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+
+      assert.equal(threads.length, 2);
+      assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+      assert.equal(threads[1].thread?.range, range);
+      assert.equal(threads[1].thread?.patchNum, 1 as RevisionPatchSetNum);
+    });
+
+    test('should not be on parent if on the right', async () => {
+      element.patchRange = createPatchRange(2, 3);
+      // Need to recompute threads.
+      await element.updateComplete;
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.RIGHT,
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      assert.equal(threads.length, 1);
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
+    });
+
+    test('should be on parent if right and base is PARENT', () => {
+      element.patchRange = createPatchRange();
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.LEFT,
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+    });
+
+    test('should be on parent if right and base negative', () => {
+      element.patchRange = createPatchRange(-2, 3);
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.LEFT,
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+    });
+
+    test('should not be on parent otherwise', () => {
+      element.patchRange = createPatchRange(2, 3);
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.LEFT,
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      const threadEl = threads[0];
+
+      assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
+      assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+    });
+
+    test(
+      'thread should use old file path if first created ' +
+        'on patch set (left) before renaming',
+      async () => {
+        element.patchRange = createPatchRange(2, 3);
+        element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+        await element.updateComplete;
+
+        element.dispatchEvent(
+          new CustomEvent('create-comment', {
+            detail: {
+              side: Side.LEFT,
+              path: '/p',
+            },
+          })
+        );
+
+        assertIsDefined(element.diffElement);
+        const threads =
+          element.diffElement.querySelectorAll<GrCommentThread>(
+            'gr-comment-thread'
+          );
+        assert.equal(threads.length, 1);
+        assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+        assert.equal(threads[0].thread?.path, element.file.basePath);
+      }
+    );
+
+    test(
+      'thread should use new file path if first created ' +
+        'on patch set (right) after renaming',
+      async () => {
+        element.patchRange = createPatchRange(2, 3);
+        element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+        await element.updateComplete;
+
+        element.dispatchEvent(
+          new CustomEvent('create-comment', {
+            detail: {
+              side: Side.RIGHT,
+              path: '/p',
+            },
+          })
+        );
+
+        assertIsDefined(element.diffElement);
+        const threads =
+          element.diffElement.querySelectorAll<GrCommentThread>(
+            'gr-comment-thread'
+          );
+
+        assert.equal(threads.length, 1);
+        assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
+        assert.equal(threads[0].thread?.path, element.file.path);
+      }
+    );
+
+    test('multiple threads created on the same range', async () => {
+      element.patchRange = createPatchRange(2, 3);
+      element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+      await element.updateComplete;
+
+      const comment = {
+        ...createComment(),
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 2,
+        },
+        patch_set: 3 as RevisionPatchSetNum,
+      };
+      const thread = createCommentThread([comment]);
+      element.threads = [thread];
+      await element.updateComplete;
+
+      assertIsDefined(element.diffElement);
+      let threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+
+      assert.equal(threads.length, 1);
+      element.threads = [...element.threads, thread];
+      await element.updateComplete;
+
+      assertIsDefined(element.diffElement);
+      threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      // Threads have same rootId so element is reused
+      assert.equal(threads.length, 1);
+
+      const newThread = {...thread};
+      newThread.rootId = 'differentRootId' as UrlEncodedCommentId;
+      element.threads = [...element.threads, newThread];
+      await element.updateComplete;
+      threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      // New thread has a different rootId
+      assert.equal(threads.length, 2);
+    });
+
+    test('unsaved thread changes to draft', async () => {
+      element.patchRange = createPatchRange(2, 3);
+      element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+      element.threads = [];
+      await element.updateComplete;
+
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: Side.RIGHT,
+            path: element.path,
+            lineNum: 13,
+          },
+        })
+      );
+      await element.updateComplete;
+      assert.equal(element.getThreadEls().length, 1);
+      const threadEl = element.getThreadEls()[0];
+      assert.equal(threadEl.thread?.line, 13);
+      assert.isDefined(threadEl.unsavedComment);
+      assert.equal(threadEl.thread?.comments.length, 0);
+
+      const draftThread = createCommentThread([
+        {
+          path: element.path,
+          patch_set: 3 as RevisionPatchSetNum,
+          line: 13,
+          __draft: true,
+        },
+      ]);
+      element.threads = [draftThread];
+      await element.updateComplete;
+
+      // We expect that no additional thread element was created.
+      assert.equal(element.getThreadEls().length, 1);
+      // In fact the thread element must still be the same.
+      assert.equal(element.getThreadEls()[0], threadEl);
+      // But it must have been updated from unsaved to draft:
+      assert.isUndefined(threadEl.unsavedComment);
+      assert.equal(threadEl.thread?.comments.length, 1);
+    });
+
+    test(
+      'thread should use new file path if first created ' +
+        'on patch set (left) but is base',
+      async () => {
+        element.patchRange = createPatchRange();
+        element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
+        await element.updateComplete;
+
+        element.dispatchEvent(
+          new CustomEvent('create-comment', {
+            detail: {
+              side: Side.LEFT,
+              path: '/p',
+            },
+          })
+        );
+
+        assertIsDefined(element.diffElement);
+        const threads =
+          element.diffElement.querySelectorAll<GrCommentThread>(
+            'gr-comment-thread'
+          );
+
+        assert.equal(threads.length, 1);
+        assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
+        assert.equal(threads[0].thread?.path, element.file.path);
+      }
+    );
+
+    test('cannot create thread on an edit', () => {
+      const alertSpy = sinon.spy();
+      element.addEventListener(EventType.SHOW_ALERT, alertSpy);
+
+      const diffSide = Side.RIGHT;
+      element.patchRange = {
+        basePatchNum: 3 as BasePatchSetNum,
+        patchNum: EDIT,
+      };
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: diffSide,
+            path: '/p',
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          '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(EventType.SHOW_ALERT, alertSpy);
+
+      const diffSide = Side.LEFT;
+      element.patchRange = {
+        basePatchNum: PARENT,
+        patchNum: EDIT,
+      };
+      element.dispatchEvent(
+        new CustomEvent('create-comment', {
+          detail: {
+            side: diffSide,
+            path: '/p',
+          },
+        })
+      );
+
+      assertIsDefined(element.diffElement);
+      const threads =
+        element.diffElement.querySelectorAll<GrCommentThread>(
+          'gr-comment-thread'
+        );
+      assert.equal(threads.length, 0);
+      assert.isTrue(alertSpy.called);
+    });
+  });
+
+  test('filterThreadElsForLocation with no threads', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+    const threads: GrCommentThread[] = [];
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threads, line, Side.LEFT),
+      []
+    );
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threads, line, Side.RIGHT),
+      []
+    );
+  });
+
+  test('filterThreadElsForLocation for line comments', () => {
+    const line = {beforeNumber: 3, afterNumber: 5};
+
+    const l3 = document.createElement('gr-comment-thread');
+    l3.setAttribute('line-num', '3');
+    l3.setAttribute('diff-side', Side.LEFT);
+
+    const l5 = document.createElement('gr-comment-thread');
+    l5.setAttribute('line-num', '5');
+    l5.setAttribute('diff-side', Side.LEFT);
+
+    const r3 = document.createElement('gr-comment-thread');
+    r3.setAttribute('line-num', '3');
+    r3.setAttribute('diff-side', Side.RIGHT);
+
+    const r5 = document.createElement('gr-comment-thread');
+    r5.setAttribute('line-num', '5');
+    r5.setAttribute('diff-side', Side.RIGHT);
+
+    const threadEls: GrCommentThread[] = [l3, l5, r3, r5];
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threadEls, line, Side.LEFT),
+      [l3]
+    );
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threadEls, line, Side.RIGHT),
+      [r5]
+    );
+  });
+
+  test('filterThreadElsForLocation for file comments', () => {
+    const line: LineInfo = {beforeNumber: 'FILE', afterNumber: 'FILE'};
+
+    const l = document.createElement('gr-comment-thread');
+    l.setAttribute('diff-side', Side.LEFT);
+    l.setAttribute('line-num', 'FILE');
+
+    const r = document.createElement('gr-comment-thread');
+    r.setAttribute('diff-side', Side.RIGHT);
+    r.setAttribute('line-num', 'FILE');
+
+    const threadEls: GrCommentThread[] = [l, r];
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threadEls, line, Side.LEFT),
+      [l]
+    );
+    assert.deepEqual(
+      element.filterThreadElsForLocation(threadEls, line, Side.RIGHT),
+      [r]
+    );
+  });
+
+  suite('syntax layer with syntax_highlighting on', async () => {
+    setup(async () => {
+      const prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      element.patchRange = createPatchRange();
+      element.prefs = prefs;
+      element.changeNum = 123 as NumericChangeId;
+      element.change = createChange();
+      element.path = 'some/path';
+    });
+
+    test('gr-diff-host provides syntax highlighting layer', async () => {
+      getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+      await element.updateComplete;
+      assertIsDefined(element.diffElement);
+      assertIsDefined(element.diffElement.layers);
+      assert.equal(element.diffElement.layers[1], element.syntaxLayer);
+    });
+
+    test('rendering normal-sized diff does not disable syntax', async () => {
+      element.diff = createDiff();
+      getDiffRestApiStub.returns(Promise.resolve(element.diff));
+      await element.updateComplete;
+      assert.isTrue(element.syntaxLayer.enabled);
+    });
+
+    test('rendering large diff disables syntax', async () => {
+      // Before it renders, set the first diff line to 500 '*' characters.
+      getDiffRestApiStub.returns(
+        Promise.resolve({
+          ...createDiff(),
+          content: [
+            {
+              a: [new Array(501).join('*')],
+            },
+          ],
+        })
+      );
+      element.reload();
+      await element.waitForReloadToRender();
+      assert.isFalse(element.syntaxLayer.enabled);
+    });
+
+    test('starts syntax layer processing on render event', async () => {
+      const stub = sinon
+        .stub(element.syntaxLayer, 'process')
+        .returns(Promise.resolve());
+      getDiffRestApiStub.returns(Promise.resolve(createDiff()));
+      await element.reload();
+      element.dispatchEvent(
+        new CustomEvent('render', {bubbles: true, composed: true})
+      );
+      assert.isTrue(stub.called);
+    });
+  });
+
+  suite('syntax layer with syntax_highlighting off', () => {
+    setup(async () => {
+      const prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+        syntax_highlighting: false,
+      };
+      element.patchRange = createPatchRange();
+      element.change = createChange();
+      element.prefs = prefs;
+    });
+
+    test('gr-diff-host provides syntax highlighting layer', async () => {
+      await element.waitForReloadToRender();
+      assertIsDefined(element.diffElement);
+      assertIsDefined(element.diffElement.layers);
+      assert.equal(element.diffElement.layers[1], element.syntaxLayer);
+    });
+
+    test('syntax layer should be disabled', async () => {
+      await element.waitForReloadToRender();
+      assert.isFalse(element.syntaxLayer.enabled);
+    });
+
+    test('still disabled for large diff', async () => {
+      getDiffRestApiStub.callsFake(() =>
+        Promise.resolve({
+          ...createDiff(),
+          content: [
+            {
+              a: [new Array(501).join('*')],
+            },
+          ],
+        })
+      );
+      await element.waitForReloadToRender();
+      assert.isFalse(element.syntaxLayer.enabled);
+    });
+  });
+
+  suite('coverage layer', () => {
+    let coverageProviderStub: SinonStub;
+    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 () => {
+      coverageProviderStub = sinon
+        .stub()
+        .returns(Promise.resolve(exampleRanges));
+      element = await fixture(html`<gr-diff-host></gr-diff-host>`);
+      element.changeNum = 123 as NumericChangeId;
+      element.change = createChange();
+      element.path = 'some/path';
+      const prefs = {
+        ...createDefaultDiffPrefs(),
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+        context: -1,
+      };
+      element.patchRange = createPatchRange();
+      element.prefs = prefs;
+      await element.updateComplete;
+
+      getDiffRestApiStub.returns(
+        Promise.resolve({
+          ...createDiff(),
+          content: [{a: ['foo']}],
+        })
+      );
+      testResolver(pluginLoaderToken).pluginsModel.coverageRegister({
+        pluginName: 'test-coverage-plugin',
+        provider: coverageProviderStub,
+      });
+      await element.reload();
+    });
+
+    test('provider is called with appropriate params', async () => {
+      element.patchRange = createPatchRange(1, 3);
+      await element.updateComplete;
+      await element.reload();
+      await element.waitForReloadToRender();
+      assert.isTrue(
+        coverageProviderStub.calledWithExactly(
+          123,
+          'some/path',
+          1,
+          3,
+          element.change
+        )
+      );
+    });
+
+    test('provider is called with appropriate params - special patchset values', async () => {
+      element.patchRange = createPatchRange();
+      await element.waitForReloadToRender();
+      assert.isTrue(
+        coverageProviderStub.calledWithExactly(
+          123,
+          'some/path',
+          undefined,
+          1,
+          element.change
+        )
+      );
+    });
+  });
+
+  suite('trailing newlines', () => {
+    setup(() => {});
+
+    suite('lastChunkForSide', () => {
+      test('deltas', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [
+            {a: ['foo', 'bar'], b: ['baz']},
+            {ab: ['foo', 'bar', 'baz']},
+            {b: ['foo']},
+          ],
+        };
+        assert.equal(element.lastChunkForSide(diff, false), diff.content[2]);
+        assert.equal(element.lastChunkForSide(diff, true), diff.content[1]);
+
+        diff.content.push({a: ['foo'], b: ['bar']});
+        assert.equal(element.lastChunkForSide(diff, false), diff.content[3]);
+        assert.equal(element.lastChunkForSide(diff, true), diff.content[3]);
+      });
+
+      test('addition with a undefined', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{b: ['foo', 'bar', 'baz']}],
+        };
+        assert.equal(element.lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element.lastChunkForSide(diff, true));
+      });
+
+      test('addition with a empty', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{a: [], b: ['foo', 'bar', 'baz']}],
+        };
+        assert.equal(element.lastChunkForSide(diff, false), diff.content[0]);
+        assert.isNull(element.lastChunkForSide(diff, true));
+      });
+
+      test('deletion with b undefined', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{a: ['foo', 'bar', 'baz']}],
+        };
+        assert.isNull(element.lastChunkForSide(diff, false));
+        assert.equal(element.lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('deletion with b empty', () => {
+        const diff: DiffInfo = {
+          ...createDiff(),
+          content: [{a: ['foo', 'bar', 'baz'], b: []}],
+        };
+        assert.isNull(element.lastChunkForSide(diff, false));
+        assert.equal(element.lastChunkForSide(diff, true), diff.content[0]);
+      });
+
+      test('empty', () => {
+        const diff: DiffInfo = {...createDiff(), content: []};
+        assert.isNull(element.lastChunkForSide(diff, false));
+        assert.isNull(element.lastChunkForSide(diff, true));
+      });
+    });
+
+    suite('hasTrailingNewlines', () => {
+      test('shared no trailing', () => {
+        const diff = undefined;
+        sinon.stub(element, 'lastChunkForSide').returns({ab: ['foo', 'bar']});
+        assert.isFalse(element.hasTrailingNewlines(diff, false));
+        assert.isFalse(element.hasTrailingNewlines(diff, true));
+      });
+
+      test('delta trailing in right', () => {
+        const diff = undefined;
+        sinon
+          .stub(element, 'lastChunkForSide')
+          .returns({a: ['foo', 'bar'], b: ['baz', '']});
+        assert.isTrue(element.hasTrailingNewlines(diff, false));
+        assert.isFalse(element.hasTrailingNewlines(diff, true));
+      });
+
+      test('addition', () => {
+        const diff: DiffInfo | undefined = undefined;
+        sinon
+          .stub(element, 'lastChunkForSide')
+          .callsFake((_: DiffInfo | undefined, leftSide: boolean) => {
+            if (leftSide) {
+              return null;
+            }
+            return {b: ['foo', '']};
+          });
+        assert.isTrue(element.hasTrailingNewlines(diff, false));
+        assert.isNull(element.hasTrailingNewlines(diff, true));
+      });
+
+      test('deletion', () => {
+        const diff: DiffInfo | undefined = undefined;
+        sinon
+          .stub(element, 'lastChunkForSide')
+          .callsFake((_: DiffInfo | undefined, leftSide: boolean) => {
+            if (!leftSide) {
+              return null;
+            }
+            return {a: ['foo']};
+          });
+        assert.isNull(element.hasTrailingNewlines(diff, false));
+        assert.isFalse(element.hasTrailingNewlines(diff, true));
+      });
+    });
+  });
+});
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
deleted file mode 100644
index 32f5f39..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ /dev/null
@@ -1,968 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '@polymer/paper-button/paper-button';
-import '@polymer/paper-card/paper-card';
-import '@polymer/paper-checkbox/paper-checkbox';
-import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
-import '@polymer/paper-fab/paper-fab';
-import '@polymer/paper-icon-button/paper-icon-button';
-import '@polymer/paper-item/paper-item';
-import '@polymer/paper-listbox/paper-listbox';
-import './gr-overview-image';
-import './gr-zoomed-image';
-
-import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
-import {RESEMBLEJS_LIBRARY_CONFIG} from '../../shared/gr-lib-loader/resemblejs_config';
-
-import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
-import {classMap} from 'lit/directives/class-map';
-import {StyleInfo, styleMap} from 'lit/directives/style-map';
-
-import {
-  createEvent,
-  Dimensions,
-  fitToFrame,
-  FrameConstrainer,
-  Point,
-  Rect,
-} from './util';
-
-const DRAG_DEAD_ZONE_PIXELS = 5;
-
-const DEFAULT_AUTOMATIC_BLINK_TIME_MS = 1000;
-
-const AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS = 350;
-
-/**
- * This components allows the user to rapidly switch between two given images
- * rendered in the same location, to make subtle differences more noticeable.
- * 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 = '';
-
-  /**
-   * When true, cycle automatically between base and revision image, if both
-   * are available.
-   */
-  @property({type: Boolean}) automaticBlink = false;
-
-  @state() protected baseSelected = false;
-
-  @state() protected scaledSelected = true;
-
-  @state() protected followMouse = false;
-
-  @state() protected scale = 1;
-
-  @state() protected checkerboardSelected = true;
-
-  @state() protected backgroundColor = '';
-
-  @state() protected automaticBlinkShown = false;
-
-  @state() protected zoomedImageStyle: StyleInfo = {};
-
-  @query('.imageArea') protected imageArea!: HTMLDivElement;
-
-  @query('gr-zoomed-image') protected zoomedImage!: Element;
-
-  @query('#source-image') protected sourceImage!: HTMLImageElement;
-
-  @query('#automatic-blink-button') protected automaticBlinkButton?: Element;
-
-  private imageSize: Dimensions = {width: 0, height: 0};
-
-  @state()
-  protected magnifierSize: Dimensions = {width: 0, height: 0};
-
-  @state()
-  protected magnifierFrame: Rect = {
-    origin: {x: 0, y: 0},
-    dimensions: {width: 0, height: 0},
-  };
-
-  @state()
-  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,
-  ];
-
-  @state() protected grabbing = false;
-
-  @state() protected canHighlightDiffs = false;
-
-  @state() protected diffHighlightSrc?: string;
-
-  @state() protected showHighlight = 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,
-          };
-        }
-      }
-    }
-  );
-
-  // Ensure constant function references, so that render() does not bind a new
-  // event listener on every call, as it would with lambdas.
-  private createColorPickerCallback(color: string) {
-    return {color, callback: () => this.pickColor(color)};
-  }
-
-  private readonly colorPickerCallbacks = [
-    this.createColorPickerCallback('#fff'),
-    this.createColorPickerCallback('#000'),
-    this.createColorPickerCallback('#aaa'),
-  ];
-
-  private automaticBlinkTimer?: ReturnType<typeof setInterval>;
-
-  // TODO(hermannloose): Make GrLibLoader a singleton.
-  private static readonly libLoader = new GrLibLoader();
-
-  static override styles = css`
-    :host {
-      display: grid;
-      grid-template-rows: 1fr auto;
-      grid-template-columns: 1fr auto;
-      width: 100%;
-      height: 100%;
-      box-sizing: border-box;
-      text-align: initial !important;
-      font-size: var(--font-size-normal);
-      --image-border-width: 2px;
-    }
-    .imageArea {
-      grid-row-start: 1;
-      grid-column-start: 1;
-      box-sizing: border-box;
-      flex-grow: 1;
-      overflow: hidden;
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      margin: var(--spacing-m);
-      padding: var(--image-border-width);
-      max-height: 100%;
-      position: relative;
-    }
-    #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));
-    }
-    #automatic-blink-button {
-      position: absolute;
-      right: var(--spacing-xl);
-      bottom: var(--spacing-xl);
-      opacity: 0;
-      transition: opacity 200ms ease;
-      --paper-fab-background: var(--primary-button-background-color);
-      --paper-fab-keyboard-focus-background: var(
-        --primary-button-background-color
-      );
-    }
-    #automatic-blink-button.show,
-    #automatic-blink-button:focus-visible {
-      opacity: 1;
-    }
-    .checkerboard {
-      --square-size: var(--checkerboard-square-size, 10px);
-      --square-color: var(--checkerboard-square-color, #808080);
-      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;
-    }
-    .dimensions {
-      grid-row-start: 2;
-      justify-self: center;
-      align-self: center;
-      background: var(--primary-button-background-color);
-      color: var(--primary-button-text-color);
-      font-family: var(--font-family);
-      font-size: var(--font-size-small);
-      line-height: var(--line-height-small);
-      border-radius: var(--border-radius, 4px);
-      margin: var(--spacing-s);
-      padding: var(--spacing-xxs) var(--spacing-s);
-    }
-    .controls {
-      grid-column-start: 2;
-      flex-grow: 0;
-      display: flex;
-      flex-direction: column;
-      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);
-      outline: 1px solid transparent;
-      border: 1px solid var(--primary-button-background-color);
-    }
-    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);
-    }
-    #version-switcher {
-      display: flex;
-      align-items: center;
-      margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
-      /* Start a stacking context to contain FAB below. */
-      z-index: 0;
-    }
-    #version-switcher paper-button {
-      flex-grow: 1;
-      margin: 0;
-      /*
-        The floating action button below overlaps part of the version buttons.
-        This min-width ensures the button text still appears somewhat balanced.
-        */
-      min-width: 7rem;
-    }
-    #version-switcher paper-fab {
-      /* Round button overlaps Base and Revision buttons. */
-      z-index: 1;
-      margin: 0 -12px;
-      /* Styled as an outlined button. */
-      color: var(--primary-button-background-color);
-      border: 1px solid var(--primary-button-background-color);
-      --paper-fab-background: var(--primary-background-color);
-      --paper-fab-keyboard-focus-background: var(--primary-background-color);
-    }
-    #version-explanation {
-      color: var(--deemphasized-text-color);
-      text-align: center;
-      margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
-    }
-    #highlight-changes {
-      margin: var(--spacing-m) var(--spacing-xl);
-    }
-    gr-overview-image {
-      min-width: 200px;
-      min-height: 150px;
-      margin-top: var(--spacing-m);
-    }
-    #zoom-control {
-      margin: 0 var(--spacing-xl);
-    }
-    paper-item {
-      cursor: pointer;
-    }
-    paper-item:hover {
-      background-color: var(--hover-background-color);
-    }
-    #follow-mouse {
-      margin: var(--spacing-m) var(--spacing-xl);
-    }
-    .color-picker {
-      margin: var(--spacing-m) var(--spacing-xl) 0;
-    }
-    .color-picker .label {
-      margin-bottom: var(--spacing-s);
-    }
-    .color-picker .options {
-      display: flex;
-      /* Ignore selection border for alignment, for visual balance. */
-      margin-left: -3px;
-    }
-    .color-picker-button {
-      border-width: 2px;
-      border-style: solid;
-      border-color: transparent;
-      border-radius: 50%;
-      width: 24px;
-      height: 24px;
-      padding: 1px;
-    }
-    .color-picker-button.selected {
-      border-color: var(--primary-button-background-color);
-    }
-    .color-picker-button:focus-within:not(.selected) {
-      /* Not an actual outline, as those do not follow border-radius. */
-      border-color: var(--outline-color-focus);
-    }
-    .color-picker-button .color {
-      border: 1px solid var(--border-color);
-      border-radius: 50%;
-      width: 100%;
-      height: 100%;
-      box-sizing: border-box;
-    }
-    #source-plus-highlight-container {
-      position: relative;
-    }
-    #source-plus-highlight-container img {
-      position: absolute;
-      top: 0;
-      left: 0;
-    }
-  `;
-
-  private renderColorPickerButton(color: string, colorPicked: () => void) {
-    const selected =
-      color === this.backgroundColor && !this.checkerboardSelected;
-    return html`
-      <div
-        class="${classMap({
-          'color-picker-button': true,
-          selected,
-        })}"
-      >
-        <paper-icon-button
-          class="color"
-          style="${styleMap({backgroundColor: color})}"
-          @click="${colorPicked}"
-        ></paper-icon-button>
-      </div>
-    `;
-  }
-
-  private renderCheckerboardButton() {
-    return html`
-      <div
-        class="${classMap({
-          'color-picker-button': true,
-          selected: this.checkerboardSelected,
-        })}"
-      >
-        <paper-icon-button
-          class="color checkerboard"
-          @click="${this.pickCheckerboard}"
-        >
-        </paper-icon-button>
-      </div>
-    `;
-  }
-
-  override render() {
-    const src = this.baseSelected ? this.baseUrl : this.revisionUrl;
-
-    const sourceImage = html`
-      <img
-        id="source-image"
-        src="${src}"
-        class="${classMap({checkerboard: this.checkerboardSelected})}"
-        style="${styleMap({
-          backgroundColor: this.checkerboardSelected
-            ? ''
-            : this.backgroundColor,
-        })}"
-        @load="${this.updateSizes}"
-      />
-    `;
-
-    const sourceImageWithHighlight = html`
-      <div id="source-plus-highlight-container">
-        ${sourceImage}
-        <img
-          id="highlight-image"
-          style="${styleMap({
-            opacity: this.showHighlight ? '1' : '0',
-            // When the highlight layer is not being shown, saving the image or
-            // opening it in a new tab from the context menu, e.g. for external
-            // comparison, should give back the source image, not the highlight
-            // layer.
-            'pointer-events': this.showHighlight ? 'auto' : 'none',
-          })}"
-          src="${this.diffHighlightSrc}"
-        />
-      </div>
-    `;
-
-    const versionExplanation = html`
-      <div id="version-explanation">
-        This file is being ${this.revisionUrl ? 'added' : 'deleted'}.
-      </div>
-    `;
-
-    // This uses the unelevated and outlined attributes from mwc-button with
-    // manual styling, for a more seamless transition later.
-    const leftClasses = {
-      left: true,
-      unelevated: this.baseSelected,
-      outlined: !this.baseSelected,
-    };
-    const rightClasses = {
-      right: true,
-      unelevated: !this.baseSelected,
-      outlined: this.baseSelected,
-    };
-    const versionToggle = html`
-      <div id="version-switcher">
-        <paper-button
-          class="${classMap(leftClasses)}"
-          @click="${this.selectBase}"
-        >
-          Base
-        </paper-button>
-        <paper-fab mini icon="gr-icons:swapHoriz" @click="${this.manualBlink}">
-        </paper-fab>
-        <paper-button
-          class="${classMap(rightClasses)}"
-          @click="${this.selectRevision}"
-        >
-          Revision
-        </paper-button>
-      </div>
-    `;
-
-    const versionSwitcher = html`
-      ${this.baseUrl && this.revisionUrl ? versionToggle : versionExplanation}
-    `;
-
-    const highlightSwitcher = this.diffHighlightSrc
-      ? html`
-          <paper-checkbox
-            id="highlight-changes"
-            ?checked="${this.showHighlight}"
-            @change="${this.showHighlightChanged}"
-          >
-            Highlight differences
-          </paper-checkbox>
-        `
-      : '';
-
-    const overviewImage = html`
-      <gr-overview-image
-        .frameRect="${this.overviewFrame}"
-        @center-updated="${this.onOverviewCenterUpdated}"
-      >
-        <img
-          src="${src}"
-          class="${classMap({checkerboard: this.checkerboardSelected})}"
-          style="${styleMap({
-            backgroundColor: this.checkerboardSelected
-              ? ''
-              : this.backgroundColor,
-          })}"
-        />
-      </gr-overview-image>
-    `;
-
-    const zoomControl = html`
-      <paper-dropdown-menu id="zoom-control" label="Zoom">
-        <paper-listbox
-          slot="dropdown-content"
-          selected="fit"
-          .attrForSelected="${'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>
-    `;
-
-    const backgroundPicker = html`
-      <div class="color-picker">
-        <div class="label">Background</div>
-        <div class="options">
-          ${this.renderCheckerboardButton()}
-          ${this.colorPickerCallbacks.map(({color, callback}) =>
-            this.renderColorPickerButton(color, callback)
-          )}
-        </div>
-      </div>
-    `;
-
-    /*
-     * We want the content to fill the available space until it can display
-     * without being cropped, the maximum of which will be determined by
-     * (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>
-    `;
-
-    const automaticBlink = html`
-      <paper-fab
-        id="automatic-blink-button"
-        class="${classMap({show: this.automaticBlinkShown})}"
-        title="Automatic blink"
-        icon="gr-icons:${this.automaticBlink ? 'pause' : 'playArrow'}"
-        @click="${this.toggleAutomaticBlink}"
-      >
-      </paper-fab>
-    `;
-
-    // To pass CSS mixins for @apply to Polymer components, they need to appear
-    // in <style> inside the template.
-    /* eslint-disable lit/prefer-static-styles */
-    const customStyle = html`
-      <style>
-        paper-item {
-          --paper-item-min-height: 48;
-          --paper-item: {
-            min-height: 48px;
-            padding: 0 var(--spacing-xl);
-          }
-          --paper-item-focused-before: {
-            background-color: var(--selection-background-color);
-          }
-          --paper-item-focused: {
-            background-color: var(--selection-background-color);
-          }
-        }
-      </style>
-    `;
-
-    return html`
-      ${customStyle}
-      <div
-        class="imageArea"
-        @mousemove="${this.mousemoveImageArea}"
-        @mouseleave="${this.mouseleaveImageArea}"
-      >
-        <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}"
-        >
-          ${sourceImageWithHighlight}
-        </gr-zoomed-image>
-        ${this.baseUrl && this.revisionUrl ? automaticBlink : ''} ${spacer}
-      </div>
-
-      <div class="dimensions">
-        ${this.imageSize.width} x ${this.imageSize.height}
-      </div>
-
-      <paper-card class="controls">
-        ${versionSwitcher} ${highlightSwitcher} ${overviewImage} ${zoomControl}
-        ${!this.scaledSelected ? followMouse : ''} ${backgroundPicker}
-      </paper-card>
-    `;
-  }
-
-  override firstUpdated() {
-    this.resizeObserver.observe(this.imageArea, {box: 'content-box'});
-    GrImageViewer.libLoader.getLibrary(RESEMBLEJS_LIBRARY_CONFIG).then(() => {
-      this.canHighlightDiffs = true;
-      this.computeDiffImage();
-    });
-  }
-
-  // We don't want property changes in updateSizes() to trigger infinite update
-  // loops, so we perform this in update() instead of updated().
-  override update(changedProperties: PropertyValues) {
-    // eslint-disable-next-line lit/no-property-change-update
-    if (!this.baseUrl) this.baseSelected = false;
-    // eslint-disable-next-line lit/no-property-change-update
-    if (!this.revisionUrl) this.baseSelected = true;
-    this.updateSizes();
-    super.update(changedProperties);
-  }
-
-  override updated(changedProperties: PropertyValues) {
-    if (
-      (changedProperties.has('baseUrl') && this.baseSelected) ||
-      (changedProperties.has('revisionUrl') && !this.baseSelected)
-    ) {
-      this.frameConstrainer.requestCenter({x: 0, y: 0});
-    }
-    if (changedProperties.has('automaticBlink')) {
-      this.updateAutomaticBlink();
-    }
-    if (
-      this.canHighlightDiffs &&
-      (changedProperties.has('baseUrl') || changedProperties.has('revisionUrl'))
-    ) {
-      this.computeDiffImage();
-    }
-  }
-
-  private computeDiffImage() {
-    if (!(this.baseUrl && this.revisionUrl)) return;
-    window
-      .resemble(this.baseUrl)
-      .compareTo(this.revisionUrl)
-      // By default Resemble.js applies some color / alpha tolerance as well as
-      // min / max brightness beyond which to ignore changes. Until we have
-      // controls to let the user affect these options, always highlight all
-      // changed pixels.
-      .ignoreNothing()
-      .onComplete(result => {
-        this.diffHighlightSrc = result.getImageDataUrl();
-      });
-  }
-
-  selectBase() {
-    if (!this.baseUrl) return;
-    this.baseSelected = true;
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'base'})
-    );
-  }
-
-  selectRevision() {
-    if (!this.revisionUrl) return;
-    this.baseSelected = false;
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'revision'})
-    );
-  }
-
-  manualBlink() {
-    this.toggleImage();
-    this.dispatchEvent(
-      createEvent({type: 'version-switcher-clicked', button: 'switch'})
-    );
-  }
-
-  private toggleImage() {
-    if (this.baseUrl && this.revisionUrl) {
-      this.baseSelected = !this.baseSelected;
-    }
-  }
-
-  toggleAutomaticBlink() {
-    this.automaticBlink = !this.automaticBlink;
-    this.dispatchEvent(
-      createEvent({type: 'automatic-blink-changed', value: this.automaticBlink})
-    );
-  }
-
-  private updateAutomaticBlink() {
-    if (this.automaticBlink) {
-      this.toggleImage();
-      this.setBlinkInterval();
-    } else {
-      this.clearBlinkInterval();
-    }
-  }
-
-  private setBlinkInterval() {
-    this.clearBlinkInterval();
-    this.automaticBlinkTimer = setInterval(() => {
-      this.toggleImage();
-    }, DEFAULT_AUTOMATIC_BLINK_TIME_MS);
-  }
-
-  private clearBlinkInterval() {
-    if (this.automaticBlinkTimer) {
-      clearInterval(this.automaticBlinkTimer);
-      this.automaticBlinkTimer = undefined;
-    }
-  }
-
-  showHighlightChanged() {
-    this.toggleHighlight('controls');
-  }
-
-  private toggleHighlight(source: 'controls' | 'magnifier') {
-    this.showHighlight = !this.showHighlight;
-    this.dispatchEvent(
-      createEvent({
-        type: 'highlight-changes-changed',
-        value: this.showHighlight,
-        source,
-      })
-    );
-  }
-
-  zoomControlChanged(event: CustomEvent) {
-    const value = event.detail.value;
-    if (!value) return;
-    if (value === 'fit') {
-      this.scaledSelected = true;
-      this.dispatchEvent(
-        createEvent({type: 'zoom-level-changed', scale: 'fit'})
-      );
-    }
-    if (value > 0) {
-      this.scaledSelected = false;
-      this.scale = value;
-      this.dispatchEvent(
-        createEvent({type: 'zoom-level-changed', scale: value})
-      );
-    }
-    this.updateSizes();
-  }
-
-  followMouseChanged() {
-    this.followMouse = !this.followMouse;
-    this.dispatchEvent(
-      createEvent({type: 'follow-mouse-changed', value: this.followMouse})
-    );
-  }
-
-  pickColor(value: string) {
-    this.checkerboardSelected = false;
-    this.backgroundColor = value;
-    this.dispatchEvent(createEvent({type: 'background-color-changed', value}));
-  }
-
-  pickCheckerboard() {
-    this.checkerboardSelected = true;
-    this.dispatchEvent(
-      createEvent({type: 'background-color-changed', value: 'checkerboard'})
-    );
-  }
-
-  mousemoveImageArea(event: MouseEvent) {
-    if (this.automaticBlinkButton) {
-      this.updateAutomaticBlinkVisibility(event);
-    }
-    this.mousemoveMagnifier(event);
-  }
-
-  private updateAutomaticBlinkVisibility(event: MouseEvent) {
-    const rect = this.automaticBlinkButton!.getBoundingClientRect();
-    const centerX = rect.left + (rect.right - rect.left) / 2;
-    const centerY = rect.top + (rect.bottom - rect.top) / 2;
-    const distX = Math.abs(centerX - event.clientX);
-    const distY = Math.abs(centerY - event.clientY);
-    this.automaticBlinkShown =
-      distX < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS &&
-      distY < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS;
-  }
-
-  mouseleaveImageArea() {
-    this.automaticBlinkShown = false;
-  }
-
-  mousedownMagnifier(event: MouseEvent) {
-    if (event.buttons === 1) {
-      this.ownsMouseDown = true;
-      this.centerOnDown = this.frameConstrainer.getCenter();
-      this.pointerOnDown = {
-        x: event.clientX,
-        y: event.clientY,
-      };
-    }
-  }
-
-  mouseupMagnifier(event: MouseEvent) {
-    if (!this.ownsMouseDown) return;
-    this.grabbing = false;
-    this.ownsMouseDown = false;
-
-    if (event.shiftKey && this.diffHighlightSrc) {
-      this.toggleHighlight('magnifier');
-      return;
-    }
-
-    const offsetX = event.clientX - this.pointerOnDown.x;
-    const offsetY = event.clientY - this.pointerOnDown.y;
-    const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
-    // Consider very short drags as clicks. These tend to happen more often on
-    // external mice.
-    if (distance < DRAG_DEAD_ZONE_PIXELS) {
-      this.toggleImage();
-      this.dispatchEvent(createEvent({type: 'magnifier-clicked'}));
-    } else {
-      this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
-    }
-  }
-
-  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() {
-    if (!this.ownsMouseDown) return;
-    this.grabbing = false;
-    this.ownsMouseDown = false;
-    this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
-  }
-
-  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
deleted file mode 100644
index 28e6d82..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ /dev/null
@@ -1,325 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
-import {StyleInfo, styleMap} from 'lit/directives/style-map';
-import {ImageDiffAction} from '../../../api/diff';
-
-import {createEvent, 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}};
-
-  @state() protected contentStyle: StyleInfo = {};
-
-  @state() protected contentTransformStyle: StyleInfo = {};
-
-  @state() protected frameStyle: StyleInfo = {};
-
-  @state() 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;
-
-  protected overlay?: 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 override 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;
-    }
-  `;
-
-  override 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>
-    `;
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    if (this.isConnected) {
-      this.overlay = document.createElement('div');
-      // The overlay is added directly to document body to ensure it fills the
-      // entire screen to capture events, without being clipped by any parent
-      // overflow properties. This means it has to be styled manually, since
-      // component styles will not affect it.
-      this.overlay.style.position = 'fixed';
-      this.overlay.style.top = '0';
-      this.overlay.style.left = '0';
-      // We subtract 20 pixels in each dimension to prevent the overlay from
-      // extending offscreen under any existing scrollbar and causing the
-      // scrollbar for the other dimension to show up unnecessarily.
-      this.overlay.style.width = 'calc(100vw - 20px)';
-      this.overlay.style.height = 'calc(100vh - 20px)';
-      this.overlay.style.zIndex = '10000';
-      this.overlay.style.display = 'none';
-
-      this.overlay.addEventListener('mousemove', (event: MouseEvent) =>
-        this.maybeDragFrame(event)
-      );
-      this.overlay.addEventListener('mouseleave', (event: MouseEvent) => {
-        // Ignore mouseleave events that are due to closeOverlay() calls.
-        if (this.overlay?.style.display !== 'none') {
-          this.releaseFrame(event);
-        }
-      });
-      this.overlay.addEventListener('mouseup', (event: MouseEvent) =>
-        this.releaseFrame(event)
-      );
-
-      document.body.appendChild(this.overlay);
-    }
-  }
-
-  override disconnectedCallback() {
-    if (this.overlay) {
-      document.body.removeChild(this.overlay);
-      this.overlay = undefined;
-    }
-    super.disconnectedCallback();
-  }
-
-  override firstUpdated() {
-    this.resizeObserver.observe(this.contentBox);
-    this.resizeObserver.observe(this.contentTransform);
-  }
-
-  override updated(changedProperties: PropertyValues) {
-    if (changedProperties.has('frameRect')) {
-      this.updateFrameStyle();
-    }
-  }
-
-  clickOverview(event: MouseEvent) {
-    if (event.buttons !== 1) return;
-    event.preventDefault();
-
-    this.dragging = true;
-    this.openOverlay();
-
-    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.dragging = true;
-    this.openOverlay();
-
-    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();
-
-    const detail: ImageDiffAction = {
-      type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked',
-    };
-    this.dispatchEvent(createEvent(detail));
-
-    this.dragging = false;
-    this.closeOverlay();
-    this.grabOffset = {x: 0, y: 0};
-  }
-
-  private openOverlay() {
-    if (this.overlay) {
-      this.overlay.style.display = 'block';
-      this.overlay.style.cursor = 'grabbing';
-    }
-  }
-
-  private closeOverlay() {
-    if (this.overlay) {
-      this.overlay.style.display = 'none';
-      this.overlay.style.cursor = '';
-    }
-  }
-
-  private updateScale() {
-    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 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
deleted file mode 100644
index 66d4671..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
-import {StyleInfo, styleMap} from 'lit/directives/style-map';
-import {Rect} from './util';
-
-/**
- * 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}};
-
-  @state() protected imageStyles: StyleInfo = {};
-
-  static override 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;
-    }
-  `;
-
-  override render() {
-    return html`
-      <div id="clip">
-        <div id="transform" style="${styleMap(this.imageStyles)}">
-          <slot></slot>
-        </div>
-      </div>
-    `;
-  }
-
-  override 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
deleted file mode 100644
index 7036ce4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {ImageDiffAction} from '../../../api/diff';
-
-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,
-    };
-  }
-}
-
-export function createEvent(
-  detail: ImageDiffAction
-): CustomEvent<ImageDiffAction> {
-  return new CustomEvent('image-diff-action', {
-    detail,
-    bubbles: true,
-    composed: true,
-  });
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
deleted file mode 100644
index 80cfa36..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
+++ /dev/null
@@ -1,171 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../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
deleted file mode 100644
index 3d43ef3..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ /dev/null
@@ -1,122 +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 '@polymer/iron-icon/iron-icon';
-import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import '../../../styles/shared-styles';
-import '../../shared/gr-button/gr-button';
-import {DiffViewMode} from '../../../constants/constants';
-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';
-import {appContext} from '../../../services/app-context';
-import {fireIronAnnounce} from '../../../utils/event-util';
-import {diffViewMode$} from '../../../services/browser/browser-model';
-import {Subject} from 'rxjs';
-import {takeUntil} from 'rxjs/operators';
-
-@customElement('gr-diff-mode-selector')
-export class GrDiffModeSelector extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, notify: true})
-  mode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
-
-  /**
-   * If set to true, the user's preference will be updated every time a
-   * button is tapped. Don't set to true if there is no user.
-   */
-  @property({type: Boolean})
-  saveOnChange = false;
-
-  @property({type: Boolean})
-  showTooltipBelow = false;
-
-  private readonly userService = appContext.userService;
-
-  disconnected$ = new Subject();
-
-  constructor() {
-    super();
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    (
-      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
-    ).requestAvailability();
-    diffViewMode$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(diffView => (this.mode = diffView));
-  }
-
-  override disconnectedCallback() {
-    this.disconnected$.next();
-  }
-
-  /**
-   * Set the mode. If save on change is enabled also update the preference.
-   */
-  setMode(newMode: DiffViewMode) {
-    if (this.saveOnChange && this.mode && this.mode !== newMode) {
-      this.userService.updatePreferences({diff_view: newMode});
-    }
-    this.mode = newMode;
-    let announcement;
-    if (this.isUnifiedSelected(newMode)) {
-      announcement = 'Changed diff view to unified';
-    } else if (this.isSideBySideSelected(newMode)) {
-      announcement = 'Changed diff view to side by side';
-    }
-    if (announcement) {
-      fireIronAnnounce(this, announcement);
-    }
-  }
-
-  _computeSideBySideSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.SIDE_BY_SIDE ? 'selected' : '';
-  }
-
-  _computeUnifiedSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.UNIFIED ? 'selected' : '';
-  }
-
-  isSideBySideSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.SIDE_BY_SIDE;
-  }
-
-  isUnifiedSelected(mode?: DiffViewMode) {
-    return mode === DiffViewMode.UNIFIED;
-  }
-
-  _handleSideBySideTap() {
-    this.setMode(DiffViewMode.SIDE_BY_SIDE);
-  }
-
-  _handleUnifiedTap() {
-    this.setMode(DiffViewMode.UNIFIED);
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-mode-selector': GrDiffModeSelector;
-  }
-}
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
deleted file mode 100644
index 8a6d95d..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
+++ /dev/null
@@ -1,63 +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 {
-      /* Used to remove horizontal whitespace between the icons. */
-      display: flex;
-    }
-    gr-button.selected iron-icon {
-      color: var(--link-color);
-    }
-    iron-icon {
-      height: 1.3rem;
-      width: 1.3rem;
-    }
-  </style>
-  <gr-tooltip-content
-    has-tooltip=""
-    title="Side-by-side diff"
-    position-below="[[showTooltipBelow]]"
-  >
-    <gr-button
-      id="sideBySideBtn"
-      link=""
-      class$="[[_computeSideBySideSelected(mode)]]"
-      aria-pressed$="[[isSideBySideSelected(mode)]]"
-      on-click="_handleSideBySideTap"
-    >
-      <iron-icon icon="gr-icons:side-by-side"></iron-icon>
-    </gr-button>
-  </gr-tooltip-content>
-  <gr-tooltip-content
-    has-tooltip=""
-    position-below="[[showTooltipBelow]]"
-    title="Unified diff"
-  >
-    <gr-button
-      id="unifiedBtn"
-      link=""
-      class$="[[_computeUnifiedSelected(mode)]]"
-      aria-pressed$="[[isUnifiedSelected(mode)]]"
-      on-click="_handleUnifiedTap"
-    >
-      <iron-icon icon="gr-icons:unified"></iron-icon>
-    </gr-button>
-  </gr-tooltip-content>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
deleted file mode 100644
index fe5f389..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ /dev/null
@@ -1,76 +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';
-import './gr-diff-mode-selector';
-import {GrDiffModeSelector} from './gr-diff-mode-selector';
-import {DiffViewMode} from '../../../constants/constants';
-import {stubUsers} from '../../../test/test-utils';
-import {_testOnly_setState} from '../../../services/browser/browser-model';
-
-const basicFixture = fixtureFromElement('gr-diff-mode-selector');
-
-suite('gr-diff-mode-selector tests', () => {
-  let element: GrDiffModeSelector;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_computeSelectedClass', () => {
-    assert.equal(
-      element._computeSideBySideSelected(DiffViewMode.SIDE_BY_SIDE),
-      'selected'
-    );
-    assert.equal(element._computeSideBySideSelected(DiffViewMode.UNIFIED), '');
-    assert.equal(
-      element._computeUnifiedSelected(DiffViewMode.UNIFIED),
-      'selected'
-    );
-    assert.equal(
-      element._computeUnifiedSelected(DiffViewMode.SIDE_BY_SIDE),
-      ''
-    );
-  });
-
-  test('setMode', () => {
-    _testOnly_setState({screenWidth: 0});
-    const saveStub = stubUsers('updatePreferences');
-
-    flush();
-    // Setting the mode initially does not save prefs.
-    element.saveOnChange = true;
-    element.setMode(DiffViewMode.SIDE_BY_SIDE);
-    assert.isFalse(saveStub.called);
-
-    // Setting the mode to itself does not save prefs.
-    element.setMode(DiffViewMode.SIDE_BY_SIDE);
-    assert.isFalse(saveStub.called);
-
-    // Setting the mode to something else does not save prefs if saveOnChange
-    // is false.
-    element.saveOnChange = false;
-    element.setMode(DiffViewMode.UNIFIED);
-    assert.isFalse(saveStub.called);
-
-    // Setting the mode to something else does not save prefs if saveOnChange
-    // is false.
-    element.saveOnChange = true;
-    element.setMode(DiffViewMode.SIDE_BY_SIDE);
-    assert.isTrue(saveStub.calledOnce);
-  });
-});
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 9f38655b..531d2ae 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
@@ -1,103 +1,139 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
-import '../../shared/gr-overlay/gr-overlay';
-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/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
-export interface GrDiffPreferencesDialog {
-  $: {
-    diffPreferences: GrDiffPreferences;
-    saveButton: GrButton;
-    cancelButton: GrButton;
-    diffPrefsOverlay: GrOverlay;
-  };
-}
 @customElement('gr-diff-preferences-dialog')
-export class GrDiffPreferencesDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrDiffPreferencesDialog extends LitElement {
+  @query('#diffPreferences') private diffPreferences?: GrDiffPreferences;
+
+  @query('#diffPrefsModal') private diffPrefsModal?: HTMLDialogElement;
+
+  @state() diffPrefsChanged?: boolean;
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      modalStyles,
+      css`
+        .diffHeader,
+        .diffActions {
+          padding: var(--spacing-l) var(--spacing-xl);
+        }
+        .diffHeader,
+        .diffActions {
+          background-color: var(--dialog-background-color);
+        }
+        .diffHeader {
+          border-bottom: 1px solid var(--border-color);
+          font-weight: var(--font-weight-bold);
+        }
+        .diffActions {
+          border-top: 1px solid var(--border-color);
+          display: flex;
+          justify-content: flex-end;
+        }
+        .diffPrefsModal gr-button {
+          margin-left: var(--spacing-l);
+        }
+        div.edited:after {
+          color: var(--deemphasized-text-color);
+          content: ' *';
+        }
+        #diffPreferences {
+          display: flex;
+          padding: var(--spacing-s) var(--spacing-xl);
+        }
+      `,
+    ];
   }
 
-  @property({type: Object})
-  diffPrefs?: DiffPreferencesInfo;
-
-  @property({type: Object})
-  _editableDiffPrefs?: DiffPreferencesInfo;
-
-  @property({type: Boolean, observer: '_onDiffPrefsChanged'})
-  _diffPrefsChanged?: boolean;
-
-  getFocusStops() {
-    return {
-      start: this.$.diffPreferences.$.contextSelect,
-      end: this.$.saveButton.disabled ? this.$.cancelButton : this.$.saveButton,
-    };
+  override render() {
+    return html`
+      <dialog id="diffPrefsModal" tabindex="-1">
+        <div role="dialog" aria-labelledby="diffPreferencesTitle">
+          <h3
+            class="heading-3 diffHeader ${this.diffPrefsChanged
+              ? 'edited'
+              : ''}"
+            id="diffPreferencesTitle"
+          >
+            Diff Preferences
+          </h3>
+          <gr-diff-preferences
+            id="diffPreferences"
+            @has-unsaved-changes-changed=${this.handleHasUnsavedChangesChanged}
+          ></gr-diff-preferences>
+          <div class="diffActions">
+            <gr-button
+              id="cancelButton"
+              link=""
+              @click=${this.handleCancelDiff}
+            >
+              Cancel
+            </gr-button>
+            <gr-button
+              id="saveButton"
+              link=""
+              primary=""
+              @click=${() => {
+                this.handleSaveDiffPreferences();
+              }}
+              ?disabled=${!this.diffPrefsChanged}
+            >
+              Save
+            </gr-button>
+          </div>
+        </div>
+      </dialog>
+    `;
   }
 
   resetFocus() {
-    this.$.diffPreferences.$.contextSelect.focus();
+    assertIsDefined(this.diffPreferences, 'diffPreferences');
+
+    this.diffPreferences.contextSelect!.focus();
   }
 
-  _computeHeaderClass(changed: boolean) {
-    return changed ? 'edited' : '';
-  }
-
-  _handleCancelDiff(e: MouseEvent) {
+  private readonly handleCancelDiff = (e: MouseEvent) => {
     e.stopPropagation();
-    this.$.diffPrefsOverlay.close();
-  }
-
-  _onDiffPrefsChanged() {
-    this.$.diffPrefsOverlay.setFocusStops(this.getFocusStops());
-  }
+    assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
+    this.diffPrefsModal.close();
+  };
 
   open() {
-    // JSON.parse(JSON.stringify(...)) makes a deep clone of diffPrefs.
-    // It is known, that diffPrefs is obtained from an RestAPI call and
-    // it is safe to clone the object this way.
-    this._editableDiffPrefs = JSON.parse(
-      JSON.stringify(this.diffPrefs)
-    ) as DiffPreferencesInfo;
-    this.$.diffPrefsOverlay.open().then(() => {
-      const focusStops = this.getFocusStops();
-      this.$.diffPrefsOverlay.setFocusStops(focusStops);
-      this.resetFocus();
-    });
+    assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
+    this.diffPrefsModal.showModal();
   }
 
-  async _handleSaveDiffPreferences() {
-    this.diffPrefs = this._editableDiffPrefs;
-    await this.$.diffPreferences.save();
+  private async handleSaveDiffPreferences() {
+    assertIsDefined(this.diffPreferences, 'diffPreferences');
+    assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
+    await this.diffPreferences.save();
     this.dispatchEvent(
       new CustomEvent('reload-diff-preference', {
         composed: true,
         bubbles: false,
       })
     );
-    this.$.diffPrefsOverlay.close();
+    this.diffPrefsModal.close();
   }
+
+  private readonly handleHasUnsavedChangesChanged = (
+    e: ValueChangedEvent<boolean>
+  ) => {
+    this.diffPrefsChanged = e.detail.value;
+  };
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
deleted file mode 100644
index 85edc12..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
+++ /dev/null
@@ -1,79 +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">
-    .diffHeader,
-    .diffActions {
-      padding: var(--spacing-l) var(--spacing-xl);
-    }
-    .diffHeader,
-    .diffActions {
-      background-color: var(--dialog-background-color);
-    }
-    .diffHeader {
-      border-bottom: 1px solid var(--border-color);
-      font-weight: var(--font-weight-bold);
-    }
-    .diffActions {
-      border-top: 1px solid var(--border-color);
-      display: flex;
-      justify-content: flex-end;
-    }
-    .diffPrefsOverlay gr-button {
-      margin-left: var(--spacing-l);
-    }
-    div.edited:after {
-      color: var(--deemphasized-text-color);
-      content: ' *';
-    }
-    #diffPreferences {
-      display: flex;
-      padding: var(--spacing-s) var(--spacing-xl);
-    }
-  </style>
-  <gr-overlay id="diffPrefsOverlay" with-backdrop="">
-    <div role="dialog" aria-labelledby="diffPreferencesTitle">
-      <h3
-        class$="heading-3 diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]"
-        id="diffPreferencesTitle"
-      >
-        Diff Preferences
-      </h3>
-      <gr-diff-preferences
-        id="diffPreferences"
-        diff-prefs="{{_editableDiffPrefs}}"
-        has-unsaved-changes="{{_diffPrefsChanged}}"
-      ></gr-diff-preferences>
-      <div class="diffActions">
-        <gr-button id="cancelButton" link="" on-click="_handleCancelDiff">
-          Cancel
-        </gr-button>
-        <gr-button
-          id="saveButton"
-          link=""
-          primary=""
-          on-click="_handleSaveDiffPreferences"
-          disabled$="[[!_diffPrefsChanged]]"
-        >
-          Save
-        </gr-button>
-      </div>
-    </div>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
index 5c62e7a..7fc1044 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
@@ -1,57 +1,111 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-diff-preferences-dialog';
 import {GrDiffPreferencesDialog} from './gr-diff-preferences-dialog';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-diff-preferences-dialog');
+import {queryAndAssert, stubRestApi, waitUntil} from '../../../test/test-utils';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {ParsedJSON} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-diff-preferences-dialog', () => {
   let element: GrDiffPreferencesDialog;
+  let originalDiffPrefs: DiffPreferencesInfo;
 
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('changes applies only on save', async () => {
-    const originalDiffPrefs = {
+  setup(async () => {
+    originalDiffPrefs = {
       ...createDefaultDiffPrefs(),
       line_wrapping: true,
     };
-    element.diffPrefs = originalDiffPrefs;
 
+    stubRestApi('getDiffPreferences').returns(
+      Promise.resolve(originalDiffPrefs)
+    );
+
+    element = await fixture<GrDiffPreferencesDialog>(html`
+      <gr-diff-preferences-dialog></gr-diff-preferences-dialog>
+    `);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <dialog id="diffPrefsModal" tabindex="-1">
+          <div aria-labelledby="diffPreferencesTitle" role="dialog">
+            <h3 class="diffHeader heading-3" id="diffPreferencesTitle">
+              Diff Preferences
+            </h3>
+            <gr-diff-preferences id="diffPreferences"> </gr-diff-preferences>
+            <div class="diffActions">
+              <gr-button
+                aria-disabled="false"
+                id="cancelButton"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Cancel
+              </gr-button>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                id="saveButton"
+                link=""
+                primary=""
+                role="button"
+                tabindex="-1"
+              >
+                Save
+              </gr-button>
+            </div>
+          </div>
+        </dialog>
+      `
+    );
+  });
+
+  test('changes applies only on save', async () => {
     element.open();
-    await flush();
-    assert.isTrue(element.$.diffPreferences.$.lineWrappingInput.checked);
+    await element.updateComplete;
+    assert.isUndefined(element.diffPrefsChanged);
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(
+        queryAndAssert(element, '#diffPreferences'),
+        '#lineWrappingInput'
+      ).checked
+    );
 
-    MockInteractions.tap(element.$.diffPreferences.$.lineWrappingInput);
-    await flush();
-    assert.isFalse(element.$.diffPreferences.$.lineWrappingInput.checked);
-    assert.isTrue(element._diffPrefsChanged);
-    assert.isTrue(element.diffPrefs.line_wrapping);
+    queryAndAssert<HTMLInputElement>(
+      queryAndAssert(element, '#diffPreferences'),
+      '#lineWrappingInput'
+    ).click();
+    await element.updateComplete;
+    assert.isFalse(
+      queryAndAssert<HTMLInputElement>(
+        queryAndAssert(element, '#diffPreferences'),
+        '#lineWrappingInput'
+      ).checked
+    );
+    assert.isTrue(element.diffPrefsChanged);
     assert.isTrue(originalDiffPrefs.line_wrapping);
 
-    MockInteractions.tap(element.$.saveButton);
-    await flush();
+    stubRestApi('getResponseObject').returns(
+      Promise.resolve({
+        ...originalDiffPrefs,
+        line_wrapping: false,
+      } as unknown as ParsedJSON)
+    );
+
+    queryAndAssert<GrButton>(element, '#saveButton').click();
+    await element.updateComplete;
     // Original prefs must remains unchanged, dialog must expose a new object
     assert.isTrue(originalDiffPrefs.line_wrapping);
-    assert.isFalse(element.diffPrefs.line_wrapping);
+    await waitUntil(() => element.diffPrefsChanged === false);
   });
 });
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
deleted file mode 100644
index b1e15a8..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
+++ /dev/null
@@ -1,746 +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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {
-  GrDiffLine,
-  GrDiffLineType,
-  FILE,
-  Highlights,
-  LineNumber,
-} from '../gr-diff/gr-diff-line';
-import {
-  GrDiffGroup,
-  GrDiffGroupType,
-  hideInContextControl,
-} from '../gr-diff/gr-diff-group';
-import {CancelablePromise, util} from '../../../scripts/util';
-import {customElement, property} from '@polymer/decorators';
-import {DiffContent} from '../../../types/diff';
-import {Side} from '../../../constants/constants';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {RenderPreferences} from '../../../api/diff';
-
-const WHOLE_FILE = -1;
-
-interface State {
-  lineNums: {
-    left: number;
-    right: number;
-  };
-  chunkIndex: number;
-}
-
-interface ChunkEnd {
-  offset: number;
-  keyLocation: boolean;
-}
-
-export interface KeyLocations {
-  left: {[key: string]: boolean};
-  right: {[key: string]: boolean};
-}
-
-/**
- * The maximum size for an addition or removal chunk before it is broken down
- * into a series of chunks that are this size at most.
- *
- * Note: The value of 120 is chosen so that it is larger than the default
- * _asyncThreshold of 64, but feel free to tune this constant to your
- * performance needs.
- */
-function calcMaxGroupSize(asyncThreshold?: number): number {
-  if (!asyncThreshold) return 120;
-  return asyncThreshold * 2;
-}
-
-/**
- * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
- *
- * Glossary:
- * - "chunk": A single `DiffContent` as returned by the API.
- * - "group": A single `GrDiffGroup` as used for rendering.
- * - "common" chunk/group: A chunk/group that should be considered unchanged
- *   for diffing purposes. This can mean its either actually unchanged, or it
- *   has only whitespace changes.
- * - "key location": A line number and side of the diff that should not be
- *   collapsed e.g. because a comment is attached to it, or because it was
- *   provided in the URL and thus should be visible
- * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
- *   or cannot be collapsed because it contains a key location
- *
- * Here a a number of tasks this processor performs:
- *  - splitting large chunks to allow more granular async rendering
- *  - adding a group for the "File" pseudo line that file-level comments can
- *    be attached to
- *  - replacing common parts of the diff that are outside the user's
- *    context setting and do not have comments with a group representing the
- *    "expand context" widget. This may require splitting a chunk/group so
- *    that the part that is within the context or has comments is shown, while
- *    the rest is not.
- */
-@customElement('gr-diff-processor')
-export class GrDiffProcessor extends PolymerElement {
-  @property({type: Number})
-  context = 3;
-
-  @property({type: Array, notify: true})
-  groups: GrDiffGroup[] = [];
-
-  @property({type: Object})
-  keyLocations: KeyLocations = {left: {}, right: {}};
-
-  @property({type: Number})
-  _asyncThreshold = 64;
-
-  @property({type: Number})
-  _nextStepHandle: number | null = null;
-
-  @property({type: Object})
-  _processPromise: CancelablePromise<void> | null = null;
-
-  @property({type: Boolean})
-  _isScrolling?: boolean;
-
-  private resetIsScrollingTask?: DelayedTask;
-
-  override connectedCallback() {
-    super.connectedCallback();
-    window.addEventListener('scroll', this.handleWindowScroll);
-  }
-
-  override disconnectedCallback() {
-    this.resetIsScrollingTask?.cancel();
-    this.cancel();
-    window.removeEventListener('scroll', this.handleWindowScroll);
-    super.disconnectedCallback();
-  }
-
-  private readonly handleWindowScroll = () => {
-    this._isScrolling = true;
-    this.resetIsScrollingTask = debounce(
-      this.resetIsScrollingTask,
-      () => (this._isScrolling = false),
-      50
-    );
-  };
-
-  /**
-   * Asynchronously process the diff chunks into groups. As it processes, it
-   * will splice groups into the `groups` property of the component.
-   *
-   * @return A promise that resolves with an
-   * array of GrDiffGroups when the diff is completely processed.
-   */
-  process(chunks: DiffContent[], isBinary: boolean) {
-    // Cancel any still running process() calls, because they append to the
-    // same groups field.
-    this.cancel();
-
-    this.groups = [];
-    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.
-    if (isBinary) {
-      return Promise.resolve();
-    }
-
-    this._processPromise = util.makeCancelable(
-      new Promise(resolve => {
-        const state = {
-          lineNums: {left: 0, right: 0},
-          chunkIndex: 0,
-        };
-
-        chunks = this._splitLargeChunks(chunks);
-        chunks = this._splitCommonChunksWithKeyLocations(chunks);
-
-        let currentBatch = 0;
-        const nextStep = () => {
-          if (this._isScrolling) {
-            this._nextStepHandle = window.setTimeout(nextStep, 100);
-            return;
-          }
-          // If we are done, resolve the promise.
-          if (state.chunkIndex >= chunks.length) {
-            resolve();
-            this._nextStepHandle = null;
-            return;
-          }
-
-          // Process the next chunk and incorporate the result.
-          const stateUpdate = this._processNext(state, chunks);
-          for (const group of stateUpdate.groups) {
-            this.push('groups', group);
-            currentBatch += group.lines.length;
-          }
-          state.lineNums.left += stateUpdate.lineDelta.left;
-          state.lineNums.right += stateUpdate.lineDelta.right;
-
-          // Increment the index and recurse.
-          state.chunkIndex = stateUpdate.newChunkIndex;
-          if (currentBatch >= this._asyncThreshold) {
-            currentBatch = 0;
-            this._nextStepHandle = window.setTimeout(nextStep, 1);
-          } else {
-            nextStep.call(this);
-          }
-        };
-
-        nextStep.call(this);
-      })
-    );
-    return this._processPromise.finally(() => {
-      this._processPromise = null;
-    });
-  }
-
-  /**
-   * Cancel any jobs that are running.
-   */
-  cancel() {
-    if (this._nextStepHandle !== null) {
-      window.clearTimeout(this._nextStepHandle);
-      this._nextStepHandle = null;
-    }
-    if (this._processPromise) {
-      this._processPromise.cancel();
-    }
-  }
-
-  /**
-   * Process the next uncollapsible chunk, or the next collapsible chunks.
-   */
-  _processNext(state: State, chunks: DiffContent[]) {
-    const firstUncollapsibleChunkIndex = this._firstUncollapsibleChunkIndex(
-      chunks,
-      state.chunkIndex
-    );
-    if (firstUncollapsibleChunkIndex === state.chunkIndex) {
-      const chunk = chunks[state.chunkIndex];
-      return {
-        lineDelta: {
-          left: this._linesLeft(chunk).length,
-          right: this._linesRight(chunk).length,
-        },
-        groups: [
-          this._chunkToGroup(
-            chunk,
-            state.lineNums.left + 1,
-            state.lineNums.right + 1
-          ),
-        ],
-        newChunkIndex: state.chunkIndex + 1,
-      };
-    }
-
-    return this._processCollapsibleChunks(
-      state,
-      chunks,
-      firstUncollapsibleChunkIndex
-    );
-  }
-
-  _linesLeft(chunk: DiffContent) {
-    return chunk.ab || chunk.a || [];
-  }
-
-  _linesRight(chunk: DiffContent) {
-    return chunk.ab || chunk.b || [];
-  }
-
-  _firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
-    let chunkIndex = offset;
-    while (
-      chunkIndex < chunks.length &&
-      this._isCollapsibleChunk(chunks[chunkIndex])
-    ) {
-      chunkIndex++;
-    }
-    return chunkIndex;
-  }
-
-  _isCollapsibleChunk(chunk: DiffContent) {
-    return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
-  }
-
-  /**
-   * Process a stretch of collapsible chunks.
-   *
-   * Outputs up to three groups:
-   * 1) Visible context before the hidden common code, unless it's the
-   * very beginning of the file.
-   * 2) Context hidden behind a context bar, unless empty.
-   * 3) Visible context after the hidden common code, unless it's the very
-   * end of the file.
-   */
-  _processCollapsibleChunks(
-    state: State,
-    chunks: DiffContent[],
-    firstUncollapsibleChunkIndex: number
-  ) {
-    const collapsibleChunks = chunks.slice(
-      state.chunkIndex,
-      firstUncollapsibleChunkIndex
-    );
-    const lineCount = collapsibleChunks.reduce(
-      (sum, chunk) => sum + this._commonChunkLength(chunk),
-      0
-    );
-
-    let groups = this._chunksToGroups(
-      collapsibleChunks,
-      state.lineNums.left + 1,
-      state.lineNums.right + 1
-    );
-
-    const hasSkippedGroup = !!groups.find(g => g.skip);
-    if (this.context !== WHOLE_FILE || hasSkippedGroup) {
-      const contextNumLines = this.context > 0 ? this.context : 0;
-      const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
-      const hiddenEnd =
-        lineCount -
-        (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
-      groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
-    }
-
-    return {
-      lineDelta: {
-        left: lineCount,
-        right: lineCount,
-      },
-      groups,
-      newChunkIndex: firstUncollapsibleChunkIndex,
-    };
-  }
-
-  _commonChunkLength(chunk: DiffContent) {
-    if (chunk.skip) {
-      return chunk.skip;
-    }
-    console.assert(!!chunk.ab || !!chunk.common);
-
-    console.assert(
-      !chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
-      'common chunk needs same number of a and b lines: ',
-      chunk
-    );
-    return this._linesLeft(chunk).length;
-  }
-
-  _chunksToGroups(
-    chunks: DiffContent[],
-    offsetLeft: number,
-    offsetRight: number
-  ): GrDiffGroup[] {
-    return chunks.map(chunk => {
-      const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
-      const chunkLength = this._commonChunkLength(chunk);
-      offsetLeft += chunkLength;
-      offsetRight += chunkLength;
-      return group;
-    });
-  }
-
-  _chunkToGroup(
-    chunk: DiffContent,
-    offsetLeft: number,
-    offsetRight: number
-  ): GrDiffGroup {
-    const type =
-      chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
-    const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
-    const group = new GrDiffGroup(type, lines);
-    group.keyLocation = !!chunk.keyLocation;
-    group.dueToRebase = !!chunk.due_to_rebase;
-    group.moveDetails = chunk.move_details;
-    group.skip = chunk.skip;
-    group.ignoredWhitespaceOnly = !!chunk.common;
-    if (chunk.skip) {
-      group.lineRange = {
-        left: {start_line: offsetLeft, end_line: offsetLeft + chunk.skip - 1},
-        right: {
-          start_line: offsetRight,
-          end_line: offsetRight + chunk.skip - 1,
-        },
-      };
-    }
-    return group;
-  }
-
-  _linesFromChunk(chunk: DiffContent, offsetLeft: number, offsetRight: number) {
-    if (chunk.ab) {
-      return chunk.ab.map((row, i) =>
-        this._lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
-      );
-    }
-    let lines: GrDiffLine[] = [];
-    if (chunk.a) {
-      // Avoiding a.push(...b) because that causes callstack overflows for
-      // large b, which can occur when large files are added removed.
-      lines = lines.concat(
-        this._linesFromRows(
-          GrDiffLineType.REMOVE,
-          chunk.a,
-          offsetLeft,
-          chunk.edit_a
-        )
-      );
-    }
-    if (chunk.b) {
-      // Avoiding a.push(...b) because that causes callstack overflows for
-      // large b, which can occur when large files are added removed.
-      lines = lines.concat(
-        this._linesFromRows(
-          GrDiffLineType.ADD,
-          chunk.b,
-          offsetRight,
-          chunk.edit_b
-        )
-      );
-    }
-    return lines;
-  }
-
-  _linesFromRows(
-    lineType: GrDiffLineType,
-    rows: string[],
-    offset: number,
-    intralineInfos?: number[][]
-  ): GrDiffLine[] {
-    const grDiffHighlights = intralineInfos
-      ? this._convertIntralineInfos(rows, intralineInfos)
-      : undefined;
-    return rows.map((row, i) =>
-      this._lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
-    );
-  }
-
-  _lineFromRow(
-    type: GrDiffLineType,
-    offsetLeft: number,
-    offsetRight: number,
-    row: string,
-    i: number,
-    highlights?: Highlights[]
-  ): GrDiffLine {
-    const line = new GrDiffLine(type);
-    line.text = row;
-    if (type !== GrDiffLineType.ADD) line.beforeNumber = offsetLeft + i;
-    if (type !== GrDiffLineType.REMOVE) line.afterNumber = offsetRight + i;
-    if (highlights) {
-      line.hasIntralineInfo = true;
-      line.highlights = highlights.filter(hl => hl.contentIndex === i);
-    } else {
-      line.hasIntralineInfo = false;
-    }
-    return line;
-  }
-
-  _makeGroup(number: LineNumber) {
-    const line = new GrDiffLine(GrDiffLineType.BOTH);
-    line.beforeNumber = number;
-    line.afterNumber = number;
-    return new GrDiffGroup(GrDiffGroupType.BOTH, [line]);
-  }
-
-  /**
-   * Split chunks into smaller chunks of the same kind.
-   *
-   * This is done to prevent doing too much work on the main thread in one
-   * uninterrupted rendering step, which would make the browser unresponsive.
-   *
-   * Note that in the case of unmodified chunks, we only split chunks if the
-   * context is set to file (because otherwise they are split up further down
-   * the processing into the visible and hidden context), and only split it
-   * into 2 chunks, one max sized one and the rest (for reasons that are
-   * unclear to me).
-   *
-   * @param chunks Chunks as returned from the server
-   * @return Finer grained chunks.
-   */
-  _splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
-    const newChunks = [];
-
-    for (const chunk of chunks) {
-      if (!chunk.ab) {
-        for (const subChunk of this._breakdownChunk(chunk)) {
-          newChunks.push(subChunk);
-        }
-        continue;
-      }
-
-      // If the context is set to "whole file", then break down the shared
-      // chunks so they can be rendered incrementally. Note: this is not
-      // enabled for any other context preference because manipulating the
-      // chunks in this way violates assumptions by the context grouper logic.
-      const MAX_GROUP_SIZE = calcMaxGroupSize(this._asyncThreshold);
-      if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
-        // Split large shared chunks in two, where the first is the maximum
-        // group size.
-        newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
-        newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
-      } else {
-        newChunks.push(chunk);
-      }
-    }
-    return newChunks;
-  }
-
-  /**
-   * In order to show key locations, such as comments, out of the bounds of
-   * the selected context, treat them as separate chunks within the model so
-   * that the content (and context surrounding it) renders correctly.
-   *
-   * @param chunks DiffContents as returned from server.
-   * @return Finer grained DiffContents.
-   */
-  _splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
-    const result = [];
-    let leftLineNum = 1;
-    let rightLineNum = 1;
-
-    for (const chunk of chunks) {
-      // If it isn't a common chunk, append it as-is and update line numbers.
-      if (!chunk.ab && !chunk.skip && !chunk.common) {
-        if (chunk.a) {
-          leftLineNum += chunk.a.length;
-        }
-        if (chunk.b) {
-          rightLineNum += chunk.b.length;
-        }
-        result.push(chunk);
-        continue;
-      }
-
-      if (chunk.common && chunk.a!.length !== chunk.b!.length) {
-        throw new Error(
-          'DiffContent with common=true must always have equal length'
-        );
-      }
-      const numLines = this._commonChunkLength(chunk);
-      const chunkEnds = this._findChunkEndsAtKeyLocations(
-        numLines,
-        leftLineNum,
-        rightLineNum
-      );
-      leftLineNum += numLines;
-      rightLineNum += numLines;
-
-      if (chunk.skip) {
-        result.push({
-          ...chunk,
-          skip: chunk.skip,
-          keyLocation: false,
-        });
-      } else if (chunk.ab) {
-        result.push(
-          ...this._splitAtChunkEnds(chunk.ab, chunkEnds).map(
-            ({lines, keyLocation}) => {
-              return {
-                ...chunk,
-                ab: lines,
-                keyLocation,
-              };
-            }
-          )
-        );
-      } else if (chunk.common) {
-        const aChunks = this._splitAtChunkEnds(chunk.a!, chunkEnds);
-        const bChunks = this._splitAtChunkEnds(chunk.b!, chunkEnds);
-        result.push(
-          ...aChunks.map(({lines, keyLocation}, i) => {
-            return {
-              ...chunk,
-              a: lines,
-              b: bChunks[i].lines,
-              keyLocation,
-            };
-          })
-        );
-      }
-    }
-
-    return result;
-  }
-
-  /**
-   * @return Offsets of the new chunk ends, including whether it's a key
-   * location.
-   */
-  _findChunkEndsAtKeyLocations(
-    numLines: number,
-    leftOffset: number,
-    rightOffset: number
-  ): ChunkEnd[] {
-    const result = [];
-    let lastChunkEnd = 0;
-    for (let i = 0; i < numLines; i++) {
-      // If this line should not be collapsed.
-      if (
-        this.keyLocations[Side.LEFT][leftOffset + i] ||
-        this.keyLocations[Side.RIGHT][rightOffset + i]
-      ) {
-        // If any lines have been accumulated into the chunk leading up to
-        // this non-collapse line, then add them as a chunk and start a new
-        // one.
-        if (i > lastChunkEnd) {
-          result.push({offset: i, keyLocation: false});
-          lastChunkEnd = i;
-        }
-
-        // Add the non-collapse line as its own chunk.
-        result.push({offset: i + 1, keyLocation: true});
-      }
-    }
-
-    if (numLines > lastChunkEnd) {
-      result.push({offset: numLines, keyLocation: false});
-    }
-
-    return result;
-  }
-
-  _splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
-    const result = [];
-    let lastChunkEndOffset = 0;
-    for (const {offset, keyLocation} of chunkEnds) {
-      result.push({
-        lines: lines.slice(lastChunkEndOffset, offset),
-        keyLocation,
-      });
-      lastChunkEndOffset = offset;
-    }
-    return result;
-  }
-
-  /**
-   * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
-   * for rendering.
-   */
-  _convertIntralineInfos(
-    rows: string[],
-    intralineInfos: number[][]
-  ): Highlights[] {
-    let rowIndex = 0;
-    let idx = 0;
-    const normalized = [];
-    for (const [skipLength, markLength] of intralineInfos) {
-      let line = rows[rowIndex] + '\n';
-      let j = 0;
-      while (j < skipLength) {
-        if (idx === line.length) {
-          idx = 0;
-          line = rows[++rowIndex] + '\n';
-          continue;
-        }
-        idx++;
-        j++;
-      }
-      let lineHighlight: Highlights = {
-        contentIndex: rowIndex,
-        startIndex: idx,
-      };
-
-      j = 0;
-      while (line && j < markLength) {
-        if (idx === line.length) {
-          idx = 0;
-          line = rows[++rowIndex] + '\n';
-          normalized.push(lineHighlight);
-          lineHighlight = {
-            contentIndex: rowIndex,
-            startIndex: idx,
-          };
-          continue;
-        }
-        idx++;
-        j++;
-      }
-      lineHighlight.endIndex = idx;
-      normalized.push(lineHighlight);
-    }
-    return normalized;
-  }
-
-  /**
-   * If a group is an addition or a removal, break it down into smaller groups
-   * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
-   * or a delta it is returned as the single element of the result array.
-   */
-  _breakdownChunk(chunk: DiffContent): DiffContent[] {
-    let key: 'a' | 'b' | 'ab' | null = null;
-    const {a, b, ab, move_details} = chunk;
-    if (a?.length && !b?.length) {
-      key = 'a';
-    } else if (b?.length && !a?.length) {
-      key = 'b';
-    } else if (ab?.length) {
-      key = 'ab';
-    }
-
-    // Move chunks should not be divided because of move label
-    // positioned in the top of the chunk
-    if (!key || move_details) {
-      return [chunk];
-    }
-
-    const MAX_GROUP_SIZE = calcMaxGroupSize(this._asyncThreshold);
-    return this._breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
-      const subChunk: DiffContent = {};
-      subChunk[key!] = subChunkLines;
-      if (chunk.due_to_rebase) {
-        subChunk.due_to_rebase = true;
-      }
-      if (chunk.move_details) {
-        subChunk.move_details = chunk.move_details;
-      }
-      return subChunk;
-    });
-  }
-
-  /**
-   * Given an array and a size, return an array of arrays where no inner array
-   * is larger than that size, preserving the original order.
-   */
-  _breakdown<T>(array: T[], size: number): T[][] {
-    if (!array.length) {
-      return [];
-    }
-    if (array.length < size) {
-      return [array];
-    }
-
-    const head = array.slice(0, array.length - size);
-    const tail = array.slice(array.length - size);
-
-    return this._breakdown(head, size).concat([tail]);
-  }
-
-  updateRenderPrefs(renderPrefs: RenderPreferences) {
-    if (renderPrefs.num_lines_rendered_at_once) {
-      this._asyncThreshold = renderPrefs.num_lines_rendered_at_once;
-    }
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-processor': GrDiffProcessor;
-  }
-}
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
deleted file mode 100644
index bebdf34..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
+++ /dev/null
@@ -1,1121 +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 'lodash/lodash.js';
-import './gr-diff-processor.js';
-import {GrDiffLineType, FILE} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
-
-const basicFixture = fixtureFromElement('gr-diff-processor');
-
-suite('gr-diff-processor tests', () => {
-  const WHOLE_FILE = -1;
-  const loremIpsum =
-      'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
-      'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
-      'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
-      'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
-      'fugit assum per.';
-
-  let element;
-
-  setup(() => {
-
-  });
-
-  suite('not logged in', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-
-      element.context = 4;
-    });
-
-    test('process loaded content', () => {
-      const content = [
-        {
-          ab: [
-            '<!DOCTYPE html>',
-            '<meta charset="utf-8">',
-          ],
-        },
-        {
-          a: [
-            '  Welcome ',
-            '  to the wooorld of tomorrow!',
-          ],
-          b: [
-            '  Hello, world!',
-          ],
-        },
-        {
-          ab: [
-            'Leela: This is the only place the ship can’t hear us, so ',
-            'everyone pretend to shower.',
-            'Fry: Same as every day. Got it.',
-          ],
-        },
-      ];
-
-      return element.process(content).then(() => {
-        const groups = element.groups;
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-        assert.equal(groups.length, 4);
-
-        let group = groups[0];
-        assert.equal(group.type, GrDiffGroupType.BOTH);
-        assert.equal(group.lines.length, 1);
-        assert.equal(group.lines[0].text, '');
-        assert.equal(group.lines[0].beforeNumber, FILE);
-        assert.equal(group.lines[0].afterNumber, FILE);
-
-        group = groups[1];
-        assert.equal(group.type, GrDiffGroupType.BOTH);
-        assert.equal(group.lines.length, 2);
-
-        function beforeNumberFn(l) { return l.beforeNumber; }
-        function afterNumberFn(l) { return l.afterNumber; }
-        function textFn(l) { return l.text; }
-
-        assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
-        assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
-        assert.deepEqual(group.lines.map(textFn), [
-          '<!DOCTYPE html>',
-          '<meta charset="utf-8">',
-        ]);
-
-        group = groups[2];
-        assert.equal(group.type, GrDiffGroupType.DELTA);
-        assert.equal(group.lines.length, 3);
-        assert.equal(group.adds.length, 1);
-        assert.equal(group.removes.length, 2);
-        assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
-        assert.deepEqual(group.adds.map(afterNumberFn), [3]);
-        assert.deepEqual(group.removes.map(textFn), [
-          '  Welcome ',
-          '  to the wooorld of tomorrow!',
-        ]);
-        assert.deepEqual(group.adds.map(textFn), [
-          '  Hello, world!',
-        ]);
-
-        group = groups[3];
-        assert.equal(group.type, GrDiffGroupType.BOTH);
-        assert.equal(group.lines.length, 3);
-        assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
-        assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
-        assert.deepEqual(group.lines.map(textFn), [
-          'Leela: This is the only place the ship can’t hear us, so ',
-          'everyone pretend to shower.',
-          'Fry: Same as every day. Got it.',
-        ]);
-      });
-    });
-
-    test('first group is for file', () => {
-      const content = [
-        {b: ['foo']},
-      ];
-
-      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);
-        assert.equal(groups[0].lines[0].text, '');
-        assert.equal(groups[0].lines[0].beforeNumber, FILE);
-        assert.equal(groups[0].lines[0].afterNumber, FILE);
-      });
-    });
-
-    suite('context groups', () => {
-      test('at the beginning, larger than context', () => {
-        element.context = 10;
-        const content = [
-          {ab: new Array(100)
-              .fill('all work and no play make jack a dull boy')},
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-          // group[0] is the file group
-
-          assert.equal(groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
-          assert.instanceOf(groups[1].contextGroups[0], GrDiffGroup);
-          assert.equal(groups[1].contextGroups[0].lines.length, 90);
-          for (const l of groups[1].contextGroups[0].lines) {
-            assert.equal(l.text, 'all work and no play make jack a dull boy');
-          }
-
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 10);
-          for (const l of groups[2].lines) {
-            assert.equal(l.text, 'all work and no play make jack a dull boy');
-          }
-        });
-      });
-
-      test('at the beginning with skip chunks', async () => {
-        element.context = 10;
-        const content = [
-          {ab: new Array(20)
-              .fill('all work and no play make jack a dull boy')},
-          {skip: 43900},
-          {ab: new Array(30)
-              .fill('some other content')},
-          {a: ['some other content']},
-        ];
-
-        await element.process(content);
-
-        const groups = element.groups;
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-        // group[0] is the file group
-
-        const commonGroup = groups[1];
-
-        // Hidden context before
-        assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
-        assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
-        assert.equal(commonGroup.contextGroups[0].lines.length, 20);
-        for (const l of commonGroup.contextGroups[0].lines) {
-          assert.equal(l.text, 'all work and no play make jack a dull boy');
-        }
-
-        // Skipped group
-        const skipGroup = commonGroup.contextGroups[1];
-        assert.equal(skipGroup.skip, 43900);
-        const expectedRange = {
-          left: {start_line: 21, end_line: 43920},
-          right: {start_line: 21, end_line: 43920},
-        };
-        assert.deepEqual(skipGroup.lineRange, expectedRange);
-
-        // Hidden context after
-        assert.equal(commonGroup.contextGroups[2].lines.length, 20);
-        for (const l of commonGroup.contextGroups[2].lines) {
-          assert.equal(l.text, 'some other content');
-        }
-
-        // Displayed lines
-        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-        assert.equal(groups[2].lines.length, 10);
-        for (const l of groups[2].lines) {
-          assert.equal(l.text, 'some other content');
-        }
-      });
-
-      test('at the beginning, smaller than context', () => {
-        element.context = 10;
-        const content = [
-          {ab: new Array(5)
-              .fill('all work and no play make jack a dull boy')},
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        return element.process(content).then(() => {
-          const groups = element.groups;
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-          // group[0] is the file group
-
-          assert.equal(groups[1].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[1].lines.length, 5);
-          for (const l of groups[1].lines) {
-            assert.equal(l.text, 'all work and no play make jack a dull boy');
-          }
-        });
-      });
-
-      test('at the end, larger than context', () => {
-        element.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(100)
-              .fill('all work and no play make jill a dull girl')},
-        ];
-
-        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
-
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 10);
-          for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
-          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
-          assert.equal(groups[3].contextGroups[0].lines.length, 90);
-          for (const l of groups[3].contextGroups[0].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-        });
-      });
-
-      test('at the end, smaller than context', () => {
-        element.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(5)
-              .fill('all work and no play make jill a dull girl')},
-        ];
-
-        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
-
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 5);
-          for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-        });
-      });
-
-      test('for interleaved ab and common: true chunks', () => {
-        element.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
-          {
-            a: new Array(3).fill(
-                'all work and no play make jill a dull girl'),
-            b: new Array(3).fill(
-                '  all work and no play make jill a dull girl'),
-            common: true,
-          },
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
-          {
-            a: new Array(3).fill(
-                'all work and no play make jill a dull girl'),
-            b: new Array(3).fill(
-                '  all work and no play make jill a dull girl'),
-            common: true,
-          },
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
-        ];
-
-        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
-
-          // The first three interleaved chunks are completely shown because
-          // they are part of the context (3 * 3 <= 10)
-
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 3);
-          for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[3].type, GrDiffGroupType.DELTA);
-          assert.equal(groups[3].lines.length, 6);
-          assert.equal(groups[3].adds.length, 3);
-          assert.equal(groups[3].removes.length, 3);
-          for (const l of groups[3].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-          for (const l of groups[3].adds) {
-            assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[4].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[4].lines.length, 3);
-          for (const l of groups[4].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-
-          // The next chunk is partially shown, so it results in two groups
-
-          assert.equal(groups[5].type, GrDiffGroupType.DELTA);
-          assert.equal(groups[5].lines.length, 2);
-          assert.equal(groups[5].adds.length, 1);
-          assert.equal(groups[5].removes.length, 1);
-          for (const l of groups[5].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-          for (const l of groups[5].adds) {
-            assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
-          assert.equal(groups[6].contextGroups.length, 2);
-
-          assert.equal(groups[6].contextGroups[0].lines.length, 4);
-          assert.equal(groups[6].contextGroups[0].removes.length, 2);
-          assert.equal(groups[6].contextGroups[0].adds.length, 2);
-          for (const l of groups[6].contextGroups[0].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-          for (const l of groups[6].contextGroups[0].adds) {
-            assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
-          }
-
-          // The final chunk is completely hidden
-          assert.equal(
-              groups[6].contextGroups[1].type,
-              GrDiffGroupType.BOTH);
-          assert.equal(groups[6].contextGroups[1].lines.length, 3);
-          for (const l of groups[6].contextGroups[1].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-        });
-      });
-
-      test('in the middle, larger than context', () => {
-        element.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(100)
-              .fill('all work and no play make jill a dull girl')},
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        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
-
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 10);
-          for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
-          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
-          assert.equal(groups[3].contextGroups[0].lines.length, 80);
-          for (const l of groups[3].contextGroups[0].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-
-          assert.equal(groups[4].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[4].lines.length, 10);
-          for (const l of groups[4].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-        });
-      });
-
-      test('in the middle, smaller than context', () => {
-        element.context = 10;
-        const content = [
-          {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(5)
-              .fill('all work and no play make jill a dull girl')},
-          {a: ['all work and no play make andybons a dull boy']},
-        ];
-
-        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
-
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 5);
-          for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
-          }
-        });
-      });
-    });
-
-    test('in the middle with skip chunks', async () => {
-      element.context = 10;
-      const content = [
-        {a: ['all work and no play make andybons a dull boy']},
-        {ab: new Array(20)
-            .fill('all work and no play make jill a dull girl')},
-        {skip: 60},
-        {ab: new Array(20)
-            .fill('all work and no play make jill a dull girl')},
-        {a: ['all work and no play make andybons a dull boy']},
-      ];
-
-      await element.process(content);
-
-      const groups = element.groups;
-      groups.shift(); // remove portedThreadsWithoutRangeGroup
-
-      // group[0] is the file group
-      // group[1] is the chunk with a
-      // group[2] is the displayed part of ab before
-
-      const commonGroup = groups[3];
-
-      // Hidden context before
-      assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
-      assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
-      assert.equal(commonGroup.contextGroups[0].lines.length, 10);
-      for (const l of commonGroup.contextGroups[0].lines) {
-        assert.equal(
-            l.text, 'all work and no play make jill a dull girl');
-      }
-
-      // Skipped group
-      const skipGroup = commonGroup.contextGroups[1];
-      assert.equal(skipGroup.skip, 60);
-      const expectedRange = {
-        left: {start_line: 22, end_line: 81},
-        right: {start_line: 21, end_line: 80},
-      };
-      assert.deepEqual(skipGroup.lineRange, expectedRange);
-
-      // Hidden context after
-      assert.equal(commonGroup.contextGroups[2].lines.length, 10);
-      for (const l of commonGroup.contextGroups[2].lines) {
-        assert.equal(
-            l.text, 'all work and no play make jill a dull girl');
-      }
-      // group[4] is the displayed part of the second ab
-    });
-
-    test('break up common diff chunks', () => {
-      element.keyLocations = {
-        left: {1: true},
-        right: {10: true},
-      };
-
-      const content = [
-        {
-          ab: [
-            '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.',
-          ],
-        },
-      ];
-      const result =
-          element._splitCommonChunksWithKeyLocations(content);
-      assert.deepEqual(result, [
-        {
-          ab: ['Copyright (C) 2015 The Android Open Source Project'],
-          keyLocation: true,
-        },
-        {
-          ab: [
-            '',
-            'Licensed under the Apache License, Version 2.0 (the "License");',
-            'you may not use this file except in compliance with the ' +
-                'License.',
-            'You may obtain a copy of the License at',
-            '',
-            'http://www.apache.org/licenses/LICENSE-2.0',
-            '',
-            'Unless required by applicable law or agreed to in writing, ',
-          ],
-          keyLocation: false,
-        },
-        {
-          ab: [
-            'software distributed under the License is distributed on an '],
-          keyLocation: true,
-        },
-        {
-          ab: [
-            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
-            'either express or implied. See the License for the specific ',
-            'language governing permissions and limitations under the ' +
-                'License.',
-          ],
-          keyLocation: false,
-        },
-      ]);
-    });
-
-    test('breaks down shared chunks w/ whole-file', () => {
-      const maxGroupSize = 128;
-      const size = maxGroupSize * 2 + 5;
-      const content = [{
-        ab: _.times(size, () => `${Math.random()}`),
-      }];
-      element.context = -1;
-      const result = element._splitLargeChunks(content);
-      assert.equal(result.length, 2);
-      assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
-      assert.deepEqual(result[1].ab, content[0].ab.slice(maxGroupSize));
-    });
-
-    test('breaks down added chunks', () => {
-      const maxGroupSize = 128;
-      const size = maxGroupSize * 2 + 5;
-      const content = _.times(size, () => `${Math.random()}`);
-      element.context = 5;
-      const splitContent = element._splitLargeChunks([{a: [], b: content}])
-          .map(r => r.b);
-      assert.equal(splitContent.length, 3);
-      assert.deepEqual(splitContent[0], content.slice(0, 5));
-      assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
-      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
-    });
-
-    test('breaks down removed chunks', () => {
-      const maxGroupSize = 128;
-      const size = maxGroupSize * 2 + 5;
-      const content = _.times(size, () => `${Math.random()}`);
-      element.context = 5;
-      const splitContent = element._splitLargeChunks([{a: content, b: []}])
-          .map(r => r.a);
-      assert.equal(splitContent.length, 3);
-      assert.deepEqual(splitContent[0], content.slice(0, 5));
-      assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
-      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
-    });
-
-    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()}`),
-      }];
-      element.context = 4;
-      const result =
-          element._splitCommonChunksWithKeyLocations(content);
-      assert.equal(result.length, 1);
-      assert.deepEqual(result[0].ab, content[0].ab);
-      assert.isFalse(result[0].keyLocation);
-    });
-
-    test('intraline normalization', () => {
-      // The content and highlights are in the format returned by the Gerrit
-      // REST API.
-      let content = [
-        '      <section class="summary">',
-        '        <gr-linked-text content="' +
-            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
-        '      </section>',
-      ];
-      let highlights = [
-        [31, 34], [42, 26],
-      ];
-
-      let results = element._convertIntralineInfos(content,
-          highlights);
-      assert.deepEqual(results, [
-        {
-          contentIndex: 0,
-          startIndex: 31,
-        },
-        {
-          contentIndex: 1,
-          startIndex: 0,
-          endIndex: 33,
-        },
-        {
-          contentIndex: 1,
-          startIndex: 75,
-        },
-        {
-          contentIndex: 2,
-          startIndex: 0,
-          endIndex: 6,
-        },
-      ]);
-      const lines = element._linesFromRows(
-          GrDiffGroupType.BOTH, content, 0, highlights);
-      assert.equal(lines.length, 3);
-      assert.isTrue(lines[0].hasIntralineInfo);
-      assert.equal(lines[0].highlights.length, 1);
-      assert.isTrue(lines[1].hasIntralineInfo);
-      assert.equal(lines[1].highlights.length, 2);
-      assert.isTrue(lines[2].hasIntralineInfo);
-      assert.equal(lines[2].highlights.length, 1);
-
-      content = [
-        '        this._path = value.path;',
-        '',
-        '        // When navigating away from the page, there is a ' +
-          'possibility that the',
-        '        // patch number is no longer a part of the URL ' +
-          '(say when navigating to',
-        '        // the top-level change info view) and therefore ' +
-          'undefined in `params`.',
-        '        if (!this._patchRange.patchNum) {',
-      ];
-      highlights = [
-        [14, 17],
-        [11, 70],
-        [12, 67],
-        [12, 67],
-        [14, 29],
-      ];
-      results = element._convertIntralineInfos(content, highlights);
-      assert.deepEqual(results, [
-        {
-          contentIndex: 0,
-          startIndex: 14,
-          endIndex: 31,
-        },
-        {
-          contentIndex: 2,
-          startIndex: 8,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 3,
-          startIndex: 11,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 4,
-          startIndex: 11,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 5,
-          startIndex: 12,
-          endIndex: 41,
-        },
-      ]);
-    });
-
-    test('scrolling pauses rendering', () => {
-      const contentRow = {
-        ab: [
-          '',
-          '',
-        ],
-      };
-      const content = _.times(200, _.constant(contentRow));
-      element._isScrolling = true;
-      element.process(content);
-      // Just the files group - no more processing during scrolling.
-      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, 3);
-    });
-
-    test('image diffs', () => {
-      const contentRow = {
-        ab: [
-          '',
-          '',
-        ],
-      };
-      const content = _.times(200, _.constant(contentRow));
-      element.process(content, true);
-      assert.equal(element.groups.length, 2);
-
-      // Image diffs don't process content, just the 'FILE' line.
-      assert.equal(element.groups[0].lines.length, 1);
-    });
-
-    suite('_processNext', () => {
-      let rows;
-
-      setup(() => {
-        rows = loremIpsum.split(' ');
-      });
-
-      test('WHOLE_FILE', () => {
-        element.context = WHOLE_FILE;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 1,
-        };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-
-        // Results in one, uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1);
-        assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
-        assert.equal(result.groups[0].lines.length, rows.length);
-
-        // Line numbers are set correctly.
-        assert.equal(
-            result.groups[0].lines[0].beforeNumber,
-            state.lineNums.left + 1);
-        assert.equal(
-            result.groups[0].lines[0].afterNumber,
-            state.lineNums.right + 1);
-
-        assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
-            state.lineNums.left + rows.length);
-        assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
-            state.lineNums.right + rows.length);
-      });
-
-      test('WHOLE_FILE with skip chunks still get collapsed', () => {
-        element.context = WHOLE_FILE;
-        const lineNums = {left: 10, right: 100};
-        const state = {
-          lineNums,
-          chunkIndex: 1,
-        };
-        const skip = 10000;
-        const chunks = [
-          {a: ['foo']},
-          {skip},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-        // Results in one, uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1);
-        assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
-
-        // Skip and ab group are hidden in the same context control
-        assert.equal(result.groups[0].contextGroups.length, 2);
-        const [skippedGroup, abGroup] = result.groups[0].contextGroups;
-
-        // Line numbers are set correctly.
-        assert.deepEqual(
-            skippedGroup.lineRange,
-            {
-              left: {
-                start_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_line: lineNums.left + skip + 1,
-                end_line: lineNums.left + skip + rows.length,
-              },
-              right: {
-                start_line: lineNums.right + skip + 1,
-                end_line: lineNums.right + skip + rows.length,
-              },
-            });
-      });
-
-      test('with context', () => {
-        element.context = 10;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 1,
-        };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-        const expectedCollapseSize = rows.length - 2 * element.context;
-
-        assert.equal(result.groups.length, 3, 'Results in three groups');
-
-        // The first and last are uncollapsed context, whereas the middle has
-        // a single context-control line.
-        assert.equal(result.groups[0].lines.length, element.context);
-        assert.equal(result.groups[2].lines.length, element.context);
-
-        // The collapsed group has the hidden lines as its context group.
-        assert.equal(result.groups[1].contextGroups[0].lines.length,
-            expectedCollapseSize);
-      });
-
-      test('first', () => {
-        element.context = 10;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 0,
-        };
-        const chunks = [
-          {ab: rows},
-          {a: ['foo']},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-        const expectedCollapseSize = rows.length - element.context;
-
-        assert.equal(result.groups.length, 2, 'Results in two groups');
-
-        // Only the first group is collapsed.
-        assert.equal(result.groups[1].lines.length, element.context);
-
-        // The collapsed group has the hidden lines as its context group.
-        assert.equal(result.groups[0].contextGroups[0].lines.length,
-            expectedCollapseSize);
-      });
-
-      test('few-rows', () => {
-        // Only ten rows.
-        rows = rows.slice(0, 10);
-        element.context = 10;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 0,
-        };
-        const chunks = [
-          {ab: rows},
-          {a: ['foo']},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-
-        // Results in one uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1, 'Results in one group');
-        assert.equal(result.groups[0].lines.length, rows.length);
-      });
-
-      test('no single line collapse', () => {
-        rows = rows.slice(0, 7);
-        element.context = 3;
-        const state = {
-          lineNums: {left: 10, right: 100},
-          chunkIndex: 1,
-        };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
-
-        // Results in one uncollapsed group with all rows.
-        assert.equal(result.groups.length, 1, 'Results in one group');
-        assert.equal(result.groups[0].lines.length, rows.length);
-      });
-
-      suite('with key location', () => {
-        let state;
-        let chunks;
-
-        setup(() => {
-          state = {
-            lineNums: {left: 10, right: 100},
-          };
-          element.context = 10;
-          chunks = [
-            {ab: rows},
-            {ab: ['foo'], keyLocation: true},
-            {ab: rows},
-          ];
-        });
-
-        test('context before', () => {
-          state.chunkIndex = 0;
-          const result = element._processNext(state, chunks);
-
-          // The first chunk is split into two groups:
-          // 1) A context-control, hiding everything but the context before
-          //    the key location.
-          // 2) The context before the key location.
-          // The key location is not processed in this call to _processNext
-          assert.equal(result.groups.length, 2);
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(result.groups[0].contextGroups[0].lines.length,
-              rows.length - element.context);
-          assert.equal(result.groups[1].lines.length, element.context);
-        });
-
-        test('key location itself', () => {
-          state.chunkIndex = 1;
-          const result = element._processNext(state, chunks);
-
-          // The second chunk results in a single group, that is just the
-          // line with the key location
-          assert.equal(result.groups.length, 1);
-          assert.equal(result.groups[0].lines.length, 1);
-          assert.equal(result.lineDelta.left, 1);
-          assert.equal(result.lineDelta.right, 1);
-        });
-
-        test('context after', () => {
-          state.chunkIndex = 2;
-          const result = element._processNext(state, chunks);
-
-          // The last chunk is split into two groups:
-          // 1) The context after the key location.
-          // 1) A context-control, hiding everything but the context after the
-          //    key location.
-          assert.equal(result.groups.length, 2);
-          assert.equal(result.groups[0].lines.length, element.context);
-          // The collapsed group has the hidden lines as its context group.
-          assert.equal(result.groups[1].contextGroups[0].lines.length,
-              rows.length - element.context);
-        });
-      });
-    });
-
-    suite('gr-diff-processor helpers', () => {
-      let rows;
-
-      setup(() => {
-        rows = loremIpsum.split(' ');
-      });
-
-      test('_linesFromRows', () => {
-        const startLineNum = 10;
-        let result = element._linesFromRows(GrDiffLineType.ADD, rows,
-            startLineNum + 1);
-
-        assert.equal(result.length, rows.length);
-        assert.equal(result[0].type, GrDiffLineType.ADD);
-        assert.notOk(result[0].hasIntralineInfo);
-        assert.equal(result[0].afterNumber, startLineNum + 1);
-        assert.notOk(result[0].beforeNumber);
-        assert.equal(result[result.length - 1].afterNumber,
-            startLineNum + rows.length);
-        assert.notOk(result[result.length - 1].beforeNumber);
-
-        result = element._linesFromRows(GrDiffLineType.REMOVE, rows,
-            startLineNum + 1);
-
-        assert.equal(result.length, rows.length);
-        assert.equal(result[0].type, GrDiffLineType.REMOVE);
-        assert.notOk(result[0].hasIntralineInfo);
-        assert.equal(result[0].beforeNumber, startLineNum + 1);
-        assert.notOk(result[0].afterNumber);
-        assert.equal(result[result.length - 1].beforeNumber,
-            startLineNum + rows.length);
-        assert.notOk(result[result.length - 1].afterNumber);
-      });
-    });
-
-    suite('_breakdown*', () => {
-      test('_breakdownChunk breaks down additions', () => {
-        sinon.spy(element, '_breakdown');
-        const chunk = {b: ['blah', 'blah', 'blah']};
-        const result = element._breakdownChunk(chunk);
-        assert.deepEqual(result, [chunk]);
-        assert.isTrue(element._breakdown.called);
-      });
-
-      test('_breakdownChunk keeps due_to_rebase for broken down additions',
-          () => {
-            sinon.spy(element, '_breakdown');
-            const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-            const result = element._breakdownChunk(chunk);
-            for (const subResult of result) {
-              assert.isTrue(subResult.due_to_rebase);
-            }
-          });
-
-      test('_breakdown common case', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-            .split(' ');
-        const size = 3;
-
-        const result = element._breakdown(array, size);
-
-        for (const subResult of result) {
-          assert.isAtMost(subResult.length, size);
-        }
-        const flattened = result
-            .reduce((a, b) => a.concat(b), []);
-        assert.deepEqual(flattened, array);
-      });
-
-      test('_breakdown smaller than size', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-            .split(' ');
-        const size = 10;
-        const expected = [array];
-
-        const result = element._breakdown(array, size);
-
-        assert.deepEqual(result, expected);
-      });
-
-      test('_breakdown empty', () => {
-        const array = [];
-        const size = 10;
-        const expected = [];
-
-        const result = element._breakdown(array, size);
-
-        assert.deepEqual(result, expected);
-      });
-    });
-  });
-
-  test('detaching cancels', () => {
-    element = basicFixture.instantiate();
-    sinon.stub(element, 'cancel');
-    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
deleted file mode 100644
index 2665ef0..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
+++ /dev/null
@@ -1,393 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles';
-import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-selection_html';
-import {
-  normalize,
-  NormalizedRange,
-} 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/diff';
-import {Side} from '../../../constants/constants';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
-import {
-  getLineElByChild,
-  getSide,
-  getSideByLineEl,
-  isThreadEl,
-} from '../gr-diff/gr-diff-utils';
-
-/**
- * Possible CSS classes indicating the state of selection. Dynamically added/
- * removed based on where the user clicks within the diff.
- */
-const SelectionClass = {
-  COMMENT: 'selected-comment',
-  LEFT: 'selected-left',
-  RIGHT: 'selected-right',
-  BLAME: 'selected-blame',
-};
-
-interface LinesCache {
-  left: string[] | null;
-  right: string[] | null;
-}
-
-function getNewCache(): LinesCache {
-  return {left: null, right: null};
-}
-
-@customElement('gr-diff-selection')
-export class GrDiffSelection extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object})
-  diff?: DiffInfo;
-
-  @property({type: Object})
-  _cachedDiffBuilder?: GrDiffBuilderElement;
-
-  @property({type: Object})
-  _linesCache: LinesCache = {left: null, right: null};
-
-  constructor() {
-    super();
-    this.addEventListener('copy', e => this._handleCopy(e));
-    addListener(this, 'down', e => this._handleDown(e));
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.classList.add(SelectionClass.RIGHT);
-  }
-
-  get diffBuilder() {
-    if (!this._cachedDiffBuilder) {
-      this._cachedDiffBuilder = this.querySelector(
-        'gr-diff-builder'
-      ) as GrDiffBuilderElement;
-    }
-    return this._cachedDiffBuilder;
-  }
-
-  @observe('diff')
-  _diffChanged() {
-    this._linesCache = getNewCache();
-  }
-
-  _handleDownOnRangeComment(node: Element) {
-    if (isThreadEl(node)) {
-      this._setClasses([
-        SelectionClass.COMMENT,
-        getSide(node) === Side.LEFT
-          ? SelectionClass.LEFT
-          : SelectionClass.RIGHT,
-      ]);
-      return true;
-    }
-    return false;
-  }
-
-  _handleDown(e: Event) {
-    const target = e.target;
-    if (!(target instanceof Element)) return;
-    // Handle the down event on comment thread in Polymer 2
-    const handled = this._handleDownOnRangeComment(target);
-    if (handled) return;
-    const lineEl = getLineElByChild(target);
-    const blameSelected = this._elementDescendedFromClass(target, 'blame');
-    if (!lineEl && !blameSelected) {
-      return;
-    }
-
-    const targetClasses = [];
-
-    if (blameSelected) {
-      targetClasses.push(SelectionClass.BLAME);
-    } else if (lineEl) {
-      const commentSelected = this._elementDescendedFromClass(
-        target,
-        'gr-comment'
-      );
-      const side = getSideByLineEl(lineEl);
-
-      targetClasses.push(
-        side === 'left' ? SelectionClass.LEFT : SelectionClass.RIGHT
-      );
-
-      if (commentSelected) {
-        targetClasses.push(SelectionClass.COMMENT);
-      }
-    }
-
-    this._setClasses(targetClasses);
-  }
-
-  /**
-   * Set the provided list of classes on the element, to the exclusion of all
-   * other SelectionClass values.
-   */
-  _setClasses(targetClasses: string[]) {
-    // Remove any selection classes that do not belong.
-    for (const className of Object.values(SelectionClass)) {
-      if (!targetClasses.includes(className)) {
-        this.classList.remove(className);
-      }
-    }
-    // Add new selection classes iff they are not already present.
-    for (const _class of targetClasses) {
-      if (!this.classList.contains(_class)) {
-        this.classList.add(_class);
-      }
-    }
-  }
-
-  _getCopyEventTarget(e: Event) {
-    return (dom(e) as EventApi).rootTarget;
-  }
-
-  /**
-   * Utility function to determine whether an element is a descendant of
-   * another element with the particular className.
-   */
-  _elementDescendedFromClass(element: Element, className: string) {
-    return descendedFromClass(element, className, this.diffBuilder.diffElement);
-  }
-
-  _handleCopy(e: ClipboardEvent) {
-    let commentSelected = false;
-    const target = this._getCopyEventTarget(e);
-    if (!(target instanceof Element)) return;
-    if (target instanceof HTMLTextAreaElement) return;
-    if (!this._elementDescendedFromClass(target, 'diff-row')) return;
-    if (this.classList.contains(SelectionClass.COMMENT)) {
-      commentSelected = true;
-    }
-    const lineEl = getLineElByChild(target);
-    if (!lineEl) return;
-    const side = getSideByLineEl(lineEl);
-    const text = this._getSelectedText(side, commentSelected);
-    if (text && e.clipboardData) {
-      e.clipboardData.setData('Text', text);
-      e.preventDefault();
-    }
-  }
-
-  _getSelection() {
-    const diffHosts = querySelectorAll(document.body, 'gr-diff');
-    if (!diffHosts.length) return document.getSelection();
-
-    const curDiffHost = diffHosts.find(diffHost => {
-      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
-      return selection && selection.type !== 'None';
-    });
-
-    return curDiffHost?.shadowRoot?.getSelection
-      ? curDiffHost.shadowRoot.getSelection()
-      : document.getSelection();
-  }
-
-  /**
-   * Get the text of the current selection. If commentSelected is
-   * true, it returns only the text of comments within the selection.
-   * Otherwise it returns the text of the selected diff region.
-   *
-   * @param side The side that is selected.
-   * @param commentSelected Whether or not a comment is selected.
-   * @return The selected text.
-   */
-  _getSelectedText(side: Side, commentSelected: boolean) {
-    const sel = this._getSelection();
-    if (!sel || sel.rangeCount !== 1) {
-      return ''; // No multi-select support yet.
-    }
-    if (commentSelected) {
-      return this._getCommentLines(sel, side);
-    }
-    const range = normalize(sel.getRangeAt(0));
-    const startLineEl = getLineElByChild(range.startContainer);
-    if (!startLineEl) return;
-    const endLineEl = getLineElByChild(range.endContainer);
-    // Happens when triple click in side-by-side mode with other side empty.
-    const endsAtOtherEmptySide =
-      !endLineEl &&
-      range.endOffset === 0 &&
-      range.endContainer.nodeName === 'TD' &&
-      range.endContainer instanceof HTMLTableCellElement &&
-      (range.endContainer.classList.contains('left') ||
-        range.endContainer.classList.contains('right'));
-    const startLineDataValue = startLineEl.getAttribute('data-value');
-    if (!startLineDataValue) return;
-    const startLineNum = Number(startLineDataValue);
-    let endLineNum;
-    if (endsAtOtherEmptySide) {
-      endLineNum = startLineNum + 1;
-    } else if (endLineEl) {
-      const endLineDataValue = endLineEl.getAttribute('data-value');
-      if (endLineDataValue) endLineNum = Number(endLineDataValue);
-    }
-
-    return this._getRangeFromDiff(
-      startLineNum,
-      range.startOffset,
-      endLineNum,
-      range.endOffset,
-      side
-    );
-  }
-
-  /**
-   * Query the diff object for the selected lines.
-   */
-  _getRangeFromDiff(
-    startLineNum: number,
-    startOffset: number,
-    endLineNum: number | undefined,
-    endOffset: number,
-    side: Side
-  ) {
-    const skipChunk = this.diff?.content.find(chunk => chunk.skip);
-    if (skipChunk) {
-      startLineNum -= skipChunk.skip!;
-      if (endLineNum) endLineNum -= skipChunk.skip!;
-    }
-    const lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
-    if (lines.length) {
-      lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
-      lines[0] = lines[0].substring(startOffset);
-    }
-    return lines.join('\n');
-  }
-
-  /**
-   * Query the diff object for the lines from a particular side.
-   *
-   * @param side The side that is currently selected.
-   * @return An array of strings indexed by line number.
-   */
-  _getDiffLines(side: Side): string[] {
-    if (this._linesCache[side]) {
-      return this._linesCache[side]!;
-    }
-    if (!this.diff) return [];
-    let lines: string[] = [];
-    for (const chunk of this.diff.content) {
-      if (chunk.ab) {
-        lines = lines.concat(chunk.ab);
-      } else if (side === Side.LEFT && chunk.a) {
-        lines = lines.concat(chunk.a);
-      } else if (side === Side.RIGHT && chunk.b) {
-        lines = lines.concat(chunk.b);
-      }
-    }
-    this._linesCache[side] = lines;
-    return lines;
-  }
-
-  /**
-   * Query the diffElement for comments and check whether they lie inside the
-   * selection range.
-   *
-   * @param sel The selection of the window.
-   * @param side The side that is currently selected.
-   * @return The selected comment text.
-   */
-  _getCommentLines(sel: Selection, side: Side) {
-    const range = normalize(sel.getRangeAt(0));
-    const content = [];
-    // Query the diffElement for comments.
-    const messages = this.diffBuilder.diffElement.querySelectorAll(
-      `.side-by-side [data-side="${side}"] .message *, .unified .message *`
-    );
-
-    for (let i = 0; i < messages.length; i++) {
-      const el = messages[i];
-      // Check if the comment element exists inside the selection.
-      if (sel.containsNode(el, true)) {
-        // Padded elements require newlines for accurate spacing.
-        if (
-          el.parentElement!.id === 'container' ||
-          el.parentElement!.nodeName === 'BLOCKQUOTE'
-        ) {
-          if (content.length && content[content.length - 1] !== '') {
-            content.push('');
-          }
-        }
-
-        if (
-          el.id === 'output' &&
-          !this._elementDescendedFromClass(el, 'collapsed')
-        ) {
-          content.push(this._getTextContentForRange(el, sel, range));
-        }
-      }
-    }
-
-    return content.join('\n');
-  }
-
-  /**
-   * Given a DOM node, a selection, and a selection range, recursively get all
-   * of the text content within that selection.
-   * Using a domNode that isn't in the selection returns an empty string.
-   *
-   * @param domNode The root DOM node.
-   * @param sel The selection.
-   * @param range The normalized selection range.
-   * @return The text within the selection.
-   */
-  _getTextContentForRange(
-    domNode: Node,
-    sel: Selection,
-    range: NormalizedRange
-  ) {
-    if (!sel.containsNode(domNode, true)) {
-      return '';
-    }
-
-    let text = '';
-    if (domNode instanceof Text) {
-      text = domNode.textContent || '';
-      if (domNode === range.endContainer) {
-        text = text.substring(0, range.endOffset);
-      }
-      if (domNode === range.startContainer) {
-        text = text.substring(range.startOffset);
-      }
-    } else {
-      for (const childNode of domNode.childNodes) {
-        text += this._getTextContentForRange(childNode, sel, range);
-      }
-    }
-    return text;
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-selection': GrDiffSelection;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.ts
deleted file mode 100644
index bd0e034..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_html.ts
+++ /dev/null
@@ -1,23 +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`
-  <div class="contentWrapper">
-    <slot></slot>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
deleted file mode 100644
index 15454f9..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
+++ /dev/null
@@ -1,389 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-selection.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-// Splitting long lines in html into shorter rows breaks tests:
-// zero-length text nodes and new lines are not expected in some places
-/* eslint-disable max-len */
-const basicFixture = fixtureFromTemplate(html`
-<gr-diff-selection>
-      <table id="diffTable" class="side-by-side">
-        <tr class="diff-row">
-          <td class="blame" data-line-number="1"></td>
-          <td class="lineNum left" data-value="1">1</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ba ba</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is a comment</span>
-                </div>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="1">1</td>
-          <td class="content">
-            <div class="contentText" data-side="right">some other text</div>
-          </td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="2"></td>
-          <td class="lineNum left" data-value="2">2</td>
-          <td class="content">
-            <div class="contentText" data-side="left">zin</div>
-          </td>
-          <td class="lineNum right" data-value="2">2</td>
-          <td class="content">
-            <div class="contentText" data-side="right">more more more</div>
-            <div data-side="right">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is a comment on the right</span>
-                </div>
-              </div>
-            </div>
-          </td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="3"></td>
-          <td class="lineNum left" data-value="3">3</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ga ga</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <div class="gr-formatted-text message">
-                  <span id="output" class="gr-linked-text">This is <a>a</a> different comment 💩 unicode is fun</span>
-                </div>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="3">3</td>
-        </tr>
-        <tr class="diff-row">
-          <td class="blame" data-line-number="4"></td>
-          <td class="lineNum left" data-value="4">4</td>
-          <td class="content">
-            <div class="contentText" data-side="left">ga ga</div>
-            <div data-side="left">
-              <div class="comment-thread">
-                <textarea data-side="right">test for textarea copying</textarea>
-              </div>
-            </div>
-          </td>
-          <td class="lineNum right" data-value="4">4</td>
-        </tr>
-        <tr class="not-diff-row">
-          <td class="other">
-            <div class="contentText" data-side="right">some other text</div>
-          </td>
-        </tr>
-      </table>
-    </gr-diff-selection>
-`);
-/* eslint-enable max-len */
-
-suite('gr-diff-selection', () => {
-  let element;
-
-  const emulateCopyOn = function(target) {
-    const fakeEvent = {
-      target,
-      preventDefault: sinon.stub(),
-      clipboardData: {
-        setData: sinon.stub(),
-      },
-    };
-    element._getCopyEventTarget.returns(target);
-    element._handleCopy(fakeEvent);
-    return fakeEvent;
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-
-    sinon.stub(element, '_getCopyEventTarget');
-    element._cachedDiffBuilder = {
-      getLineElByChild: sinon.stub().returns({}),
-      getSideByLineEl: sinon.stub(),
-      diffElement: element.querySelector('#diffTable'),
-    };
-    element.diff = {
-      content: [
-        {
-          a: ['ba ba'],
-          b: ['some other text'],
-        },
-        {
-          a: ['zin'],
-          b: ['more more more'],
-        },
-        {
-          a: ['ga ga'],
-          b: ['some other text'],
-        },
-      ],
-    };
-  });
-
-  test('applies selected-left on left side click', () => {
-    element.classList.add('selected-right');
-    const lineNumberEl = element.querySelector('.lineNum.left');
-    MockInteractions.down(lineNumberEl);
-    assert.isTrue(
-        element.classList.contains('selected-left'), 'adds selected-left');
-    assert.isFalse(
-        element.classList.contains('selected-right'),
-        'removes selected-right');
-  });
-
-  test('applies selected-right on right side click', () => {
-    element.classList.add('selected-left');
-    const lineNumberEl = element.querySelector('.lineNum.right');
-    MockInteractions.down(lineNumberEl);
-    assert.isTrue(
-        element.classList.contains('selected-right'), 'adds selected-right');
-    assert.isFalse(
-        element.classList.contains('selected-left'), 'removes selected-left');
-  });
-
-  test('applies selected-blame on blame click', () => {
-    element.classList.add('selected-left');
-    element.diffBuilder.getLineElByChild.returns(null);
-    sinon.stub(element, '_elementDescendedFromClass').callsFake(
-        (el, className) => className === 'blame');
-    MockInteractions.down(element);
-    assert.isTrue(
-        element.classList.contains('selected-blame'), 'adds selected-right');
-    assert.isFalse(
-        element.classList.contains('selected-left'), 'removes selected-left');
-  });
-
-  test('ignores copy for non-content Element', () => {
-    sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('.not-diff-row'));
-    assert.isFalse(element._getSelectedText.called);
-  });
-
-  test('asks for text for left side Elements', () => {
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
-    assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
-  });
-
-  test('reacts to copy for content Elements', () => {
-    sinon.stub(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('div.contentText'));
-    assert.isTrue(element._getSelectedText.called);
-  });
-
-  test('copy event is prevented for content Elements', () => {
-    sinon.stub(element, '_getSelectedText');
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    element._getSelectedText.returns('test');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
-    assert.isTrue(event.preventDefault.called);
-  });
-
-  test('inserts text into clipboard on copy', () => {
-    sinon.stub(element, '_getSelectedText').returns('the text');
-    const event = emulateCopyOn(element.querySelector('div.contentText'));
-    assert.deepEqual(
-        ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
-  });
-
-  test('_setClasses adds given SelectionClass values, removes others', () => {
-    element.classList.add('selected-right');
-    element._setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(element.classList.contains('selected-comment'));
-    assert.isTrue(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isFalse(element.classList.contains('selected-blame'));
-
-    element._setClasses(['selected-blame']);
-    assert.isFalse(element.classList.contains('selected-comment'));
-    assert.isFalse(element.classList.contains('selected-left'));
-    assert.isFalse(element.classList.contains('selected-right'));
-    assert.isTrue(element.classList.contains('selected-blame'));
-  });
-
-  test('_setClasses removes before it ads', () => {
-    element.classList.add('selected-right');
-    const addStub = sinon.stub(element.classList, 'add');
-    const removeStub = sinon.stub(element.classList, 'remove').callsFake(
-        () => {
-          assert.isFalse(addStub.called);
-        });
-    element._setClasses(['selected-comment', 'selected-left']);
-    assert.isTrue(addStub.called);
-    assert.isTrue(removeStub.called);
-  });
-
-  test('copies content correctly', () => {
-    // Fetch the line number.
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[4].firstChild, 2);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-  });
-
-  test('copies comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(
-        element.querySelector('.gr-formatted-text *').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('.gr-formatted-text *')[2].childNodes[2], 7);
-    selection.addRange(range);
-    assert.equal('s is a comment\nThis is a differ',
-        element._getSelectedText('left', true));
-  });
-
-  test('respects astral chars in comments', () => {
-    element.classList.add('selected-left');
-    element.classList.add('selected-comment');
-    element.classList.remove('selected-right');
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    const nodes = element.querySelectorAll('.gr-formatted-text *');
-    range.setStart(nodes[2].childNodes[2], 13);
-    range.setEnd(nodes[2].childNodes[2], 23);
-    selection.addRange(range);
-    assert.equal('mment 💩 u',
-        element._getSelectedText('left', true));
-  });
-
-  test('defers to default behavior for textarea', () => {
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-    const selectedTextSpy = sinon.spy(element, '_getSelectedText');
-    emulateCopyOn(element.querySelector('textarea'));
-    assert.isFalse(selectedTextSpy.called);
-  });
-
-  test('regression test for 4794', () => {
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-
-    element.classList.add('selected-right');
-    element.classList.remove('selected-left');
-
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(
-        element.querySelectorAll('div.contentText')[1].firstChild, 4);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[1].firstChild, 10);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('right'), ' other');
-  });
-
-  test('copies to end of side (issue 7895)', () => {
-    element._cachedDiffBuilder.getLineElByChild = function(child) {
-      // Return null for the end container.
-      if (child.textContent === 'ga ga') { return null; }
-      while (!child.classList.contains('content') && child.parentElement) {
-        child = child.parentElement;
-      }
-      return child.previousElementSibling;
-    };
-    element.classList.add('selected-left');
-    element.classList.remove('selected-right');
-    const selection = document.getSelection();
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(element.querySelector('div.contentText').firstChild, 3);
-    range.setEnd(
-        element.querySelectorAll('div.contentText')[4].firstChild, 2);
-    selection.addRange(range);
-    assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
-  });
-
-  suite('_getTextContentForRange', () => {
-    let selection;
-    let range;
-    let nodes;
-
-    setup(() => {
-      element.classList.add('selected-left');
-      element.classList.add('selected-comment');
-      element.classList.remove('selected-right');
-      selection = document.getSelection();
-      selection.removeAllRanges();
-      range = document.createRange();
-      nodes = element.querySelectorAll('.gr-formatted-text *');
-    });
-
-    test('multi level element contained in range', () => {
-      range.setStart(nodes[2].childNodes[0], 1);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'his is a differ');
-    });
-
-    test('multi level element as startContainer of range', () => {
-      range.setStart(nodes[2].childNodes[1], 0);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'a differ');
-    });
-
-    test('startContainer === endContainer', () => {
-      range.setStart(nodes[0].firstChild, 2);
-      range.setEnd(nodes[0].firstChild, 12);
-      selection.addRange(range);
-      assert.equal(element._getTextContentForRange(element, selection, range),
-          'is is a co');
-    });
-  });
-
-  test('cache is reset when diff changes', () => {
-    element._linesCache = {left: 'test', right: 'test'};
-    element.diff = {};
-    flush();
-    assert.deepEqual(element._linesCache, {left: null, right: null});
-  });
-});
-
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 64186f4..c74993f 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-dropdown/iron-dropdown';
 import '@polymer/iron-input/iron-input';
@@ -21,137 +10,110 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dropdown/gr-dropdown';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
-import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-icon/gr-icon';
 import '../../shared/gr-select/gr-select';
 import '../../shared/revision-info/revision-info';
-import '../gr-comment-api/gr-comment-api';
-import '../gr-diff-cursor/gr-diff-cursor';
+import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import '../gr-diff-host/gr-diff-host';
-import '../gr-diff-mode-selector/gr-diff-mode-selector';
+import '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-view_html';
+import '../../change/gr-download-dialog/gr-download-dialog';
+import {getAppContext} from '../../../services/app-context';
+import {isMergeParent, getParentIndex} from '../../../utils/patch-set-util';
 import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-  ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {
-  GeneratedWebLink,
-  GerritNav,
-} from '../../core/gr-navigation/gr-navigation';
-import {appContext} from '../../../services/app-context';
-import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-  PatchSet,
-} from '../../../utils/patch-set-util';
-import {
-  addUnmodifiedFiles,
   computeDisplayPath,
   computeTruncatedPath,
   isMagicPath,
-  specialFilePathCompare,
 } from '../../../utils/path-list-util';
 import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
-import {customElement, observe, property} from '@polymer/decorators';
-import {GrDiffHost} from '../gr-diff-host/gr-diff-host';
+import {GrDiffHost} from '../../diff/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} from '../gr-comment-api/gr-comment-api';
-import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   BasePatchSetNum,
-  ChangeInfo,
-  CommitId,
-  ConfigInfo,
-  EditInfo,
-  EditPatchSetNum,
-  FileInfo,
+  EDIT,
   NumericChangeId,
-  ParentPatchSetNum,
+  PARENT,
   PatchRange,
-  PatchSetNum,
+  PatchSetNumber,
   PreferencesInfo,
   RepoName,
-  RevisionInfo,
   RevisionPatchSetNum,
+  ServerInfo,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {ChangeViewState, CommitRange, FileRange} from '../../../types/types';
+import {FileRange, ParsedChangeInfo} 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 {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
 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,
-  getPatchRangeForCommentUrl,
-  isInBaseOfPatchRange,
-} from '../../../utils/comment-util';
-import {AppElementParams, AppElementDiffViewParam} from '../../gr-app-types';
-import {EventType, OpenFixPreviewEvent} from '../../../types/events';
+  EventType,
+  OpenFixPreviewEvent,
+  ValueChangedEvent,
+} 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 {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {Key, toggleClass, whenVisible} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
 import {throttleWrap} from '../../../utils/async-util';
-import {changeComments$} from '../../../services/comments/comments-model';
-import {takeUntil} from 'rxjs/operators';
-import {Subject} from 'rxjs';
-import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {filter, take, switchMap} from 'rxjs/operators';
+import {combineLatest} from 'rxjs';
 import {
-  preferences$,
-  diffPreferences$,
-} from '../../../services/user/user-model';
+  Shortcut,
+  ShortcutSection,
+  shortcutsServiceToken,
+} from '../../../services/shortcuts/shortcuts-service';
+import {DisplayLine} from '../../../api/diff';
+import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {resolve} from '../../../models/dependency';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {subscribe} from '../../lit/subscription-controller';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {configModelToken} from '../../../models/config/config-model';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+import {
+  createDiffUrl,
+  ChangeChildView,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {GeneratedWebLink} from '../../../utils/weblink-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {GrDiffPreferencesDialog} from '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import {
+  FileNameToNormalizedFileInfoMap,
+  filesModelToken,
+} from '../../../models/change/files-model';
 
-const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const LOADING_BLAME = 'Loading blame...';
 const LOADED_BLAME = 'Blame loaded';
 
 // Time in which pressing n key again after the toast navigates to next file
 const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
 
-interface Files {
-  sortedFileList: string[];
-  changeFilesByPath: {[path: string]: FileInfo};
+// visible for testing
+export interface Files {
+  /** All file paths sorted by `specialFilePathCompare`. */
+  sortedPaths: string[];
+  changeFilesByPath: FileNameToNormalizedFileInfoMap;
 }
 
-interface CommentSkips {
-  previous: string | null;
-  next: string | null;
-}
-
-export interface GrDiffView {
-  $: {
-    diffHost: GrDiffHost;
-    reviewed: HTMLInputElement;
-    dropdown: GrDropdownList;
-    diffPreferencesDialog: GrOverlay;
-    applyFixDialog: GrApplyFixDialog;
-    modeSelect: GrDiffModeSelector;
-  };
-}
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-diff-view')
-export class GrDiffView extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDiffView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
@@ -163,466 +125,1031 @@
    *
    * @event show-alert
    */
+  @query('#diffHost')
+  diffHost?: GrDiffHost;
 
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: AppElementParams;
+  @state()
+  reviewed = false;
 
-  @property({type: Object, notify: true})
-  changeViewState: Partial<ChangeViewState> = {};
+  @query('#downloadModal')
+  downloadModal?: HTMLDialogElement;
 
-  @property({type: Object})
-  _patchRange?: PatchRange;
+  @query('#downloadDialog')
+  downloadDialog?: GrDownloadDialog;
 
-  @property({type: Object})
-  _commitRange?: CommitRange;
+  @query('#dropdown')
+  dropdown?: GrDropdownList;
 
-  @property({type: Object})
-  _change?: ChangeInfo;
+  @query('#applyFixDialog')
+  applyFixDialog?: GrApplyFixDialog;
 
-  @property({type: Object})
-  _changeComments?: ChangeComments;
+  @query('#diffPreferencesDialog')
+  diffPreferencesDialog?: GrDiffPreferencesDialog;
 
-  @property({type: String})
-  _changeNum?: NumericChangeId;
+  // Private but used in tests.
+  @state()
+  get patchRange(): PatchRange | undefined {
+    if (!this.patchNum) return undefined;
+    return {
+      patchNum: this.patchNum,
+      basePatchNum: this.basePatchNum,
+    };
+  }
 
-  @property({type: Object})
-  _diff?: DiffInfo;
+  // Private but used in tests.
+  @state()
+  patchNum?: RevisionPatchSetNum;
 
-  @property({
-    type: Array,
-    computed: '_formatFilesForDropdown(_files, _patchRange, _changeComments)',
-  })
-  _formattedFiles?: DropdownItem[];
+  // Private but used in tests.
+  @state()
+  basePatchNum: BasePatchSetNum = PARENT;
 
-  @property({type: Array, computed: '_getSortedFileList(_files)'})
-  _fileList?: string[];
+  // Private but used in tests.
+  @state()
+  change?: ParsedChangeInfo;
 
-  @property({type: Object})
-  _files: Files = {sortedFileList: [], changeFilesByPath: {}};
+  @state()
+  latestPatchNum?: PatchSetNumber;
 
-  @property({type: Object, computed: '_getCurrentFile(_files, _path)'})
-  _file?: FileInfo;
+  // Private but used in tests.
+  @state()
+  changeComments?: ChangeComments;
 
-  @property({type: String, observer: '_pathChanged'})
+  // Private but used in tests.
+  @state()
+  changeNum?: NumericChangeId;
+
+  // Private but used in tests.
+  @state()
+  diff?: DiffInfo;
+
+  // Private but used in tests.
+  @state()
+  files: Files = {sortedPaths: [], changeFilesByPath: {}};
+
+  // Private but used in tests
+  // Use path getter/setter.
   _path?: string;
 
-  @property({type: Number, computed: '_computeFileNum(_path, _formattedFiles)'})
-  _fileNum?: number;
+  get path() {
+    return this._path;
+  }
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  set path(path: string | undefined) {
+    if (this._path === path) return;
+    const oldPath = this._path;
+    this._path = path;
+    this.pathChanged();
+    this.requestUpdate('path', oldPath);
+  }
 
-  @property({type: Boolean})
-  _loading = true;
+  /** Allows us to react when the user switches to the DIFF view. */
+  // Private but used in tests.
+  @state() isActiveChildView = false;
+
+  // Private but used in tests.
+  @state()
+  loggedIn = false;
 
   @property({type: Object})
-  _prefs?: DiffPreferencesInfo;
+  prefs?: DiffPreferencesInfo;
 
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
+  @state()
+  private serverConfig?: ServerInfo;
 
-  @property({type: Object})
-  _userPrefs?: PreferencesInfo;
+  // Private but used in tests.
+  @state()
+  userPrefs?: PreferencesInfo;
 
-  @property({type: Boolean})
-  _isImageDiff?: boolean;
+  @state()
+  private isImageDiff?: boolean;
 
-  @property({type: Object})
-  _editWeblinks?: GeneratedWebLink[];
+  @state()
+  private editWeblinks?: GeneratedWebLink[];
 
-  @property({type: Object})
-  _filesWeblinks?: FilesWebLinks;
+  @state()
+  private filesWeblinks?: FilesWebLinks;
 
-  @property({type: Object})
-  _commentMap?: CommentMap;
+  // Private but used in tests.
+  @state()
+  isBlameLoaded?: boolean;
 
-  @property({
-    type: Object,
-    computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
-  })
-  _commentSkips?: CommentSkips;
+  @state()
+  private isBlameLoading = false;
 
-  @property({type: Boolean, computed: '_computeEditMode(_patchRange.*)'})
-  _editMode?: boolean;
+  /** Directly reflects the view model property `diffView.lineNum`. */
+  // Private but used in tests.
+  @state()
+  focusLineNum?: number;
 
-  @property({type: Boolean})
-  _isBlameLoaded?: boolean;
+  /** Directly reflects the view model property `diffView.leftSide`. */
+  @state()
+  leftSide = false;
 
-  @property({type: Boolean})
-  _isBlameLoading = false;
+  // visible for testing
+  reviewedFiles = new Set<string>();
 
-  @property({
-    type: Array,
-    computed: '_computeAllPatchSets(_change, _change.revisions.*)',
-  })
-  _allPatchSets?: PatchSet[] = [];
+  private readonly reporting = getAppContext().reportingService;
 
-  @property({type: Object, computed: '_getRevisionInfo(_change)'})
-  _revisionInfo?: RevisionInfoObj;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  @property({type: Object})
-  _reviewedFiles = new Set<string>();
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  @property({type: Number})
-  _focusLineNum?: number;
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private getReviewedParams: {
-    changeNum?: NumericChangeId;
-    patchNum?: PatchSetNum;
-  } = {};
+  private readonly getFilesModel = resolve(this, filesModelToken);
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.LEFT_PANE, _ => this.cursor.moveLeft()),
-      listen(Shortcut.RIGHT_PANE, _ => this.cursor.moveRight()),
-      listen(Shortcut.NEXT_LINE, _ => this._handleNextLine()),
-      listen(Shortcut.PREV_LINE, _ => this._handlePrevLine()),
-      listen(Shortcut.VISIBLE_LINE, _ => this.cursor.moveToVisibleArea()),
-      listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ =>
-        this._moveToNextFileWithComment()
-      ),
-      listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ =>
-        this._moveToPreviousFileWithComment()
-      ),
-      listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
-      listen(Shortcut.SAVE_COMMENT, _ => {}),
-      listen(Shortcut.NEXT_FILE, _ => this._handleNextFile()),
-      listen(Shortcut.PREV_FILE, _ => this._handlePrevFile()),
-      listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
-      listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
-      listen(Shortcut.NEXT_COMMENT_THREAD, _ =>
-        this._handleNextCommentThread()
-      ),
-      listen(Shortcut.PREV_COMMENT_THREAD, _ =>
-        this._handlePrevCommentThread()
-      ),
-      listen(Shortcut.OPEN_REPLY_DIALOG, _ => this._handleOpenReplyDialog()),
-      listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
-      listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ =>
-        this._handleOpenDownloadDialog()
-      ),
-      listen(Shortcut.UP_TO_CHANGE, _ => this._handleUpToChange()),
-      listen(Shortcut.OPEN_DIFF_PREFS, _ => this._handleCommaKey()),
-      listen(Shortcut.TOGGLE_DIFF_MODE, _ => this._handleToggleDiffMode()),
-      listen(Shortcut.TOGGLE_FILE_REVIEWED, e => {
-        if (this._throttledToggleFileReviewed) {
-          this._throttledToggleFileReviewed(e);
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
+  private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
+
+  @state()
+  cursor?: GrDiffCursor;
+
+  private readonly shortcutsController = new ShortcutController(this);
+
+  constructor() {
+    super();
+    this.setupKeyboardShortcuts();
+    this.setupSubscriptions();
+    subscribe(
+      this,
+      () => this.getFilesModel().filesIncludingUnmodified$,
+      files => {
+        const filesByPath: FileNameToNormalizedFileInfoMap = {};
+        for (const f of files) filesByPath[f.__path] = f;
+        this.files = {
+          sortedPaths: files.map(f => f.__path),
+          changeFilesByPath: filesByPath,
+        };
+      }
+    );
+  }
+
+  private setupKeyboardShortcuts() {
+    const listen = (shortcut: Shortcut, fn: (e: KeyboardEvent) => void) => {
+      this.shortcutsController.addAbstract(shortcut, fn);
+    };
+    listen(Shortcut.LEFT_PANE, _ => this.cursor?.moveLeft());
+    listen(Shortcut.RIGHT_PANE, _ => this.cursor?.moveRight());
+    listen(Shortcut.NEXT_LINE, _ => this.handleNextLine());
+    listen(Shortcut.PREV_LINE, _ => this.handlePrevLine());
+    listen(Shortcut.VISIBLE_LINE, _ => this.cursor?.moveToVisibleArea());
+    listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ =>
+      this.moveToFileWithComment(1)
+    );
+    listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ =>
+      this.moveToFileWithComment(-1)
+    );
+    listen(Shortcut.NEW_COMMENT, _ => this.handleNewComment());
+    listen(Shortcut.SAVE_COMMENT, _ => {});
+    listen(Shortcut.NEXT_FILE, _ => this.handleNextFile());
+    listen(Shortcut.PREV_FILE, _ => this.handlePrevFile());
+    listen(Shortcut.NEXT_CHUNK, _ => this.handleNextChunk());
+    listen(Shortcut.PREV_CHUNK, _ => this.handlePrevChunk());
+    listen(Shortcut.NEXT_COMMENT_THREAD, _ => this.handleNextCommentThread());
+    listen(Shortcut.PREV_COMMENT_THREAD, _ => this.handlePrevCommentThread());
+    listen(Shortcut.OPEN_REPLY_DIALOG, _ => this.handleOpenReplyDialog());
+    listen(Shortcut.TOGGLE_LEFT_PANE, _ => this.handleToggleLeftPane());
+    listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ => this.handleOpenDownloadDialog());
+    listen(Shortcut.UP_TO_CHANGE, _ =>
+      this.getChangeModel().navigateToChange()
+    );
+    listen(Shortcut.OPEN_DIFF_PREFS, _ => this.handleCommaKey());
+    listen(Shortcut.TOGGLE_DIFF_MODE, _ => this.handleToggleDiffMode());
+    listen(Shortcut.TOGGLE_FILE_REVIEWED, e => {
+      if (this.throttledToggleFileReviewed) {
+        this.throttledToggleFileReviewed(e);
+      }
+    });
+    listen(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, _ =>
+      this.handleToggleAllDiffContext()
+    );
+    listen(Shortcut.NEXT_UNREVIEWED_FILE, _ => this.handleNextUnreviewedFile());
+    listen(Shortcut.TOGGLE_BLAME, _ => this.toggleBlame());
+    listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
+      this.handleToggleHideAllCommentThreads()
+    );
+    listen(Shortcut.OPEN_FILE_LIST, _ => this.handleOpenFileList());
+    listen(Shortcut.DIFF_AGAINST_BASE, _ => this.handleDiffAgainstBase());
+    listen(Shortcut.DIFF_AGAINST_LATEST, _ => this.handleDiffAgainstLatest());
+    listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
+      this.handleDiffBaseAgainstLeft()
+    );
+    listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
+      this.handleDiffRightAgainstLatest()
+    );
+    listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
+      this.handleDiffBaseAgainstLatest()
+    );
+    listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}); // docOnly
+    listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}); // docOnly
+    this.shortcutsController.addGlobal({key: Key.ESC}, _ => {
+      assertIsDefined(this.diffHost, 'diffHost');
+      this.diffHost.displayLine = false;
+    });
+  }
+
+  private setupSubscriptions() {
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      loggedIn => {
+        this.loggedIn = loggedIn;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().changeComments$,
+      changeComments => {
+        this.changeComments = changeComments;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      preferences => {
+        this.userPrefs = preferences;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      diffPreferences => {
+        this.prefs = diffPreferences;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => {
+        // The diff view is tied to a specific change number, so don't update
+        // change to undefined.
+        if (change) this.change = change;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      latestPatchNum => (this.latestPatchNum = latestPatchNum)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().reviewedFiles$,
+      reviewedFiles => {
+        this.reviewedFiles = new Set(reviewedFiles) ?? new Set();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().changeNum$,
+      changeNum => {
+        if (!changeNum || this.changeNum === changeNum) return;
+
+        // We are only setting the changeNum of the diff view once!
+        // Everything in the diff view is tied to the change. It seems better to
+        // force the re-creation of the diff view when the change number changes.
+        if (!this.changeNum) {
+          this.changeNum = changeNum;
+        } else {
+          fireEvent(this, EventType.RECREATE_DIFF_VIEW);
         }
-      }),
-      listen(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, _ =>
-        this._handleToggleAllDiffContext()
-      ),
-      listen(Shortcut.NEXT_UNREVIEWED_FILE, _ =>
-        this._handleNextUnreviewedFile()
-      ),
-      listen(Shortcut.TOGGLE_BLAME, _ => this._handleToggleBlame()),
-      listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
-        this._handleToggleHideAllCommentThreads()
-      ),
-      listen(Shortcut.OPEN_FILE_LIST, _ => this._handleOpenFileList()),
-      listen(Shortcut.DIFF_AGAINST_BASE, _ => this._handleDiffAgainstBase()),
-      listen(Shortcut.DIFF_AGAINST_LATEST, _ =>
-        this._handleDiffAgainstLatest()
-      ),
-      listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
-        this._handleDiffBaseAgainstLeft()
-      ),
-      listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
-        this._handleDiffRightAgainstLatest()
-      ),
-      listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
-        this._handleDiffBaseAgainstLatest()
-      ),
-      listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
-      listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
+      }
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().childView$,
+      childView => (this.isActiveChildView = childView === ChangeChildView.DIFF)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().diffPath$,
+      path => (this.path = path)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().diffLine$,
+      line => (this.focusLineNum = line)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().diffLeftSide$,
+      leftSide => (this.leftSide = leftSide)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().patchNum$,
+      patchNum => (this.patchNum = patchNum)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().basePatchNum$,
+      basePatchNum => (this.basePatchNum = basePatchNum ?? PARENT)
+    );
+    subscribe(
+      this,
+      () =>
+        combineLatest([
+          this.getViewModel().diffPath$,
+          this.getChangeModel().reviewedFiles$,
+        ]),
+      ([path, files]) => {
+        this.reviewed = !!path && !!files && files.includes(path);
+      }
+    );
+
+    // When user initially loads the diff view, we want to automatically mark
+    // the file as reviewed if they have it enabled. We can't observe these
+    // properties since the method will be called anytime a property updates
+    // but we only want to call this on the initial load.
+    subscribe(
+      this,
+      () =>
+        this.getViewModel().diffPath$.pipe(
+          filter(diffPath => !!diffPath),
+          switchMap(() =>
+            combineLatest([
+              this.getChangeModel().patchNum$,
+              this.getViewModel().childView$,
+              this.getUserModel().diffPreferences$,
+              this.getChangeModel().reviewedFiles$,
+            ]).pipe(
+              filter(
+                ([patchNum, childView, diffPrefs, reviewedFiles]) =>
+                  !!patchNum &&
+                  childView === ChangeChildView.DIFF &&
+                  !!diffPrefs &&
+                  !!reviewedFiles
+              ),
+              take(1)
+            )
+          )
+        ),
+      ([patchNum, _routerView, diffPrefs]) => {
+        // `patchNum` must be defined, because of the `!!patchNum` filter above.
+        assertIsDefined(patchNum, 'patchNum');
+        this.setReviewedStatus(patchNum, diffPrefs);
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      modalStyles,
+      css`
+        :host {
+          display: block;
+          background-color: var(--view-background-color);
+        }
+        .hidden {
+          display: none;
+        }
+        gr-patch-range-select {
+          display: block;
+        }
+        gr-diff {
+          border: none;
+        }
+        .stickyHeader {
+          background-color: var(--view-background-color);
+          position: sticky;
+          top: 0;
+          /* TODO(dhruvsri): This is required only because of 'position:relative' in
+            <gr-diff-highlight> (which could maybe be removed??). */
+          z-index: 1;
+          box-shadow: var(--elevation-level-1);
+          /* This is just for giving the box-shadow some space. */
+          margin-bottom: 2px;
+        }
+        header,
+        .subHeader {
+          align-items: center;
+          display: flex;
+          justify-content: space-between;
+        }
+        header {
+          padding: var(--spacing-s) var(--spacing-xl);
+          border-bottom: 1px solid var(--border-color);
+        }
+        .changeNumberColon {
+          color: transparent;
+        }
+        .headerSubject {
+          margin-right: var(--spacing-m);
+          font-weight: var(--font-weight-bold);
+        }
+        .patchRangeLeft {
+          align-items: center;
+          display: flex;
+        }
+        .navLink:not([href]) {
+          color: var(--deemphasized-text-color);
+        }
+        .navLinks {
+          align-items: center;
+          display: flex;
+          white-space: nowrap;
+        }
+        .navLink {
+          padding: 0 var(--spacing-xs);
+        }
+        .reviewed {
+          display: inline-block;
+          margin: 0 var(--spacing-xs);
+          vertical-align: top;
+          position: relative;
+          top: 8px;
+        }
+        .jumpToFileContainer {
+          display: inline-block;
+          word-break: break-all;
+        }
+        .mobile {
+          display: none;
+        }
+        gr-button {
+          padding: var(--spacing-s) 0;
+          text-decoration: none;
+        }
+        .loading {
+          color: var(--deemphasized-text-color);
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h1);
+          font-weight: var(--font-weight-h1);
+          line-height: var(--line-height-h1);
+          height: 100%;
+          padding: var(--spacing-l);
+          text-align: center;
+        }
+        .subHeader {
+          background-color: var(--background-color-secondary);
+          flex-wrap: wrap;
+          padding: 0 var(--spacing-l);
+        }
+        .prefsButton {
+          text-align: right;
+        }
+        .editMode .hideOnEdit {
+          display: none;
+        }
+        .blameLoader,
+        .fileNum {
+          display: none;
+        }
+        .blameLoader.show,
+        .fileNum.show,
+        .download,
+        .preferences,
+        .rightControls {
+          align-items: center;
+          display: flex;
+        }
+        .diffModeSelector,
+        .editButton {
+          align-items: center;
+          display: flex;
+        }
+        .diffModeSelector span,
+        .editButton span {
+          margin-right: var(--spacing-xs);
+        }
+        .diffModeSelector.hide,
+        .separator.hide {
+          display: none;
+        }
+        .editButtona a {
+          text-decoration: none;
+        }
+        @media screen and (max-width: 50em) {
+          header {
+            padding: var(--spacing-s) var(--spacing-l);
+          }
+          .dash {
+            display: none;
+          }
+          .desktop {
+            display: none;
+          }
+          .fileNav {
+            align-items: flex-start;
+            display: flex;
+            margin: 0 var(--spacing-xs);
+          }
+          .fullFileName {
+            display: block;
+            font-style: italic;
+            min-width: 50%;
+            padding: 0 var(--spacing-xxs);
+            text-align: center;
+            width: 100%;
+            word-wrap: break-word;
+          }
+          .reviewed {
+            vertical-align: -1px;
+          }
+          .mobileNavLink {
+            color: var(--primary-text-color);
+            font-family: var(--header-font-family);
+            font-size: var(--font-size-h2);
+            font-weight: var(--font-weight-h2);
+            line-height: var(--line-height-h2);
+            text-decoration: none;
+          }
+          .mobileNavLink:not([href]) {
+            color: var(--deemphasized-text-color);
+          }
+          .jumpToFileContainer {
+            display: block;
+            width: 100%;
+            word-break: break-all;
+          }
+          /* prettier formatter removes semi-colons after css mixins. */
+          /* prettier-ignore */
+          gr-dropdown-list {
+            width: 100%;
+            --gr-select-style-width: 100%;
+            --gr-select-style-display: block;
+            --native-select-style-width: 100%;
+          }
+        }
+        :host(.hideComments) {
+          --gr-comment-thread-display: none;
+        }
+      `,
     ];
   }
 
-  private readonly reporting = appContext.reportingService;
-
-  private readonly restApiService = appContext.restApiService;
-
-  private readonly userService = appContext.userService;
-
-  private readonly shortcuts = appContext.shortcutsService;
-
-  _throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
-
-  _onRenderHandler?: EventListener;
-
-  private cursor = new GrDiffCursor();
-
-  disconnected$ = new Subject();
-
   override connectedCallback() {
     super.connectedCallback();
-    this._throttledToggleFileReviewed = throttleWrap(_ =>
-      this._handleToggleFileReviewed()
+    this.throttledToggleFileReviewed = throttleWrap(_ =>
+      this.handleToggleFileReviewed()
     );
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-
-    changeComments$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(changeComments => {
-        this._changeComments = changeComments;
-      });
-
-    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(preferences => {
-      this._userPrefs = preferences;
-    });
-    diffPreferences$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(diffPreferences => {
-        this._prefs = diffPreferences;
-      });
-    this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.cursor.replaceDiffs([this.$.diffHost]);
-    this._onRenderHandler = (_: Event) => {
-      this.cursor.reInitCursor();
-    };
-    this.$.diffHost.addEventListener('render', this._onRenderHandler);
-    this.cleanups.push(
-      addGlobalShortcut(
-        {key: Key.ESC},
-        _ => (this.$.diffHost.displayLine = false)
-      )
-    );
+    this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
+    this.cursor = new GrDiffCursor();
+    if (this.diffHost) this.reInitCursor();
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
-    this.cursor.dispose();
-    if (this._onRenderHandler) {
-      this.$.diffHost.removeEventListener('render', this._onRenderHandler);
-    }
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
+    this.cursor?.dispose();
     super.disconnectedCallback();
   }
 
-  @observe('_changeComments', '_path', '_patchRange')
-  computeThreads(
-    changeComments?: ChangeComments,
-    path?: string,
-    patchRange?: PatchRange
-  ) {
+  private reInitCursor() {
+    if (!this.diffHost) return;
+    this.cursor?.replaceDiffs([this.diffHost]);
+    this.cursor?.reInitCursor();
+  }
+
+  protected override updated(changedProperties: PropertyValues): void {
+    super.updated(changedProperties);
     if (
-      changeComments === undefined ||
-      path === undefined ||
-      patchRange === undefined
+      changedProperties.has('change') ||
+      changedProperties.has('path') ||
+      changedProperties.has('patchNum') ||
+      changedProperties.has('basePatchNum')
     ) {
-      return;
+      this.reloadDiff();
+    } else if (
+      changedProperties.has('isActiveChildView') &&
+      this.isActiveChildView
+    ) {
+      this.initializePositions();
     }
-    // TODO(dhruvsri): check if basePath should be set here
-    this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
-      {path},
-      patchRange
-    );
+    if (
+      changedProperties.has('focusLineNum') ||
+      changedProperties.has('leftSide')
+    ) {
+      this.initCursor();
+    }
+    if (
+      changedProperties.has('change') ||
+      changedProperties.has('changeComments') ||
+      changedProperties.has('path') ||
+      changedProperties.has('patchNum') ||
+      changedProperties.has('basePatchNum') ||
+      changedProperties.has('files')
+    ) {
+      if (this.change && this.changeComments && this.path && this.patchRange) {
+        assertIsDefined(this.diffHost, 'diffHost');
+        const file = this.files?.changeFilesByPath?.[this.path];
+        this.diffHost.updateComplete.then(() => {
+          assertIsDefined(this.path);
+          assertIsDefined(this.patchRange);
+          assertIsDefined(this.diffHost);
+          assertIsDefined(this.changeComments);
+          this.diffHost.threads = this.changeComments.getThreadsBySideForFile(
+            {path: this.path, basePath: file?.old_path},
+            this.patchRange
+          );
+        });
+      }
+    }
   }
 
-  _getLoggedIn(): Promise<boolean> {
-    return this.restApiService.getLoggedIn();
+  override render() {
+    if (!this.isActiveChildView) return nothing;
+    if (!this.patchNum || !this.changeNum || !this.change || !this.path) {
+      return html`<div class="loading">Loading...</div>`;
+    }
+    const file = this.getFileRange();
+    return html`
+      ${this.renderStickyHeader()}
+      <h2 class="assistive-tech-only">Diff view</h2>
+      <gr-diff-host
+        id="diffHost"
+        .changeNum=${this.changeNum}
+        .change=${this.change}
+        .patchRange=${this.patchRange}
+        .file=${file}
+        .lineOfInterest=${this.getLineOfInterest()}
+        .path=${this.path}
+        .projectName=${this.change?.project}
+        @is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
+        @comment-anchor-tap=${this.onLineSelected}
+        @line-selected=${this.onLineSelected}
+        @diff-changed=${this.onDiffChanged}
+        @edit-weblinks-changed=${this.onEditWeblinksChanged}
+        @files-weblinks-changed=${this.onFilesWeblinksChanged}
+        @is-image-diff-changed=${this.onIsImageDiffChanged}
+        @render=${this.reInitCursor}
+      >
+      </gr-diff-host>
+      ${this.renderDialogs()}
+    `;
   }
 
-  @observe('_change.project')
-  _getProjectConfig(project?: RepoName) {
-    if (!project) return;
-    return this.restApiService.getProjectConfig(project).then(config => {
-      this._projectConfig = config;
-    });
+  private renderStickyHeader() {
+    return html` <div
+      class="stickyHeader ${this.patchNum === EDIT ? 'editMode' : ''}"
+    >
+      <h1 class="assistive-tech-only">
+        Diff of ${this.path ? computeTruncatedPath(this.path) : ''}
+      </h1>
+      <header>${this.renderHeader()}</header>
+      <div class="subHeader">
+        ${this.renderPatchRangeLeft()} ${this.renderRightControls()}
+      </div>
+      <div class="fileNav mobile">
+        <a class="mobileNavLink" href=${ifDefined(this.computeNavLinkURL(-1))}
+          >&lt;</a
+        >
+        <div class="fullFileName mobile">${computeDisplayPath(this.path)}</div>
+        <a class="mobileNavLink" href=${ifDefined(this.computeNavLinkURL(1))}
+          >&gt;</a
+        >
+      </div>
+    </div>`;
   }
 
-  _getChangeDetail(changeNum: NumericChangeId) {
-    return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
-      if (!change) throw new Error('Missing "change" in API response.');
-      this._change = change;
-      return change;
-    });
+  private renderHeader() {
+    const formattedFiles = this.formatFilesForDropdown();
+    const fileNum = this.computeFileNum(formattedFiles);
+    const fileNumClass = this.computeFileNumClass(fileNum, formattedFiles);
+    return html` <div>
+        <a href=${ifDefined(this.getChangeModel().changeUrl())}
+          >${this.changeNum}</a
+        ><span class="changeNumberColon">:</span>
+        <span class="headerSubject">${this.change?.subject}</span>
+        <input
+          id="reviewed"
+          class="reviewed hideOnEdit"
+          type="checkbox"
+          ?hidden=${!this.loggedIn}
+          title="Toggle reviewed status of file"
+          aria-label="file reviewed"
+          .checked=${this.reviewed}
+          @change=${this.handleReviewedChange}
+        />
+        <div class="jumpToFileContainer">
+          <gr-dropdown-list
+            id="dropdown"
+            .value=${this.path}
+            .items=${formattedFiles}
+            show-copy-for-trigger-text
+            @value-change=${this.handleFileChange}
+          ></gr-dropdown-list>
+        </div>
+      </div>
+      <div class="navLinks desktop">
+        <span class="fileNum ${ifDefined(fileNumClass)}">
+          File ${fileNum} of ${formattedFiles.length}
+          <span class="separator"></span>
+        </span>
+        <a
+          class="navLink"
+          title=${this.createTitle(
+            Shortcut.PREV_FILE,
+            ShortcutSection.NAVIGATION
+          )}
+          href=${ifDefined(this.computeNavLinkURL(-1))}
+          >Prev</a
+        >
+        <span class="separator"></span>
+        <a
+          class="navLink"
+          title=${this.createTitle(
+            Shortcut.UP_TO_CHANGE,
+            ShortcutSection.NAVIGATION
+          )}
+          href=${ifDefined(this.getChangeModel().changeUrl())}
+          >Up</a
+        >
+        <span class="separator"></span>
+        <a
+          class="navLink"
+          title=${this.createTitle(
+            Shortcut.NEXT_FILE,
+            ShortcutSection.NAVIGATION
+          )}
+          href=${ifDefined(this.computeNavLinkURL(1))}
+          >Next</a
+        >
+      </div>`;
   }
 
-  _getChangeEdit() {
-    assertIsDefined(this._changeNum, '_changeNum');
-    return this.restApiService.getChangeEdit(this._changeNum);
+  private renderPatchRangeLeft() {
+    return html` <div class="patchRangeLeft">
+      <gr-patch-range-select
+        id="rangeSelect"
+        .filesWeblinks=${this.filesWeblinks}
+        @patch-range-change=${this.handlePatchChange}
+      >
+      </gr-patch-range-select>
+      <span class="download desktop">
+        <span class="separator"></span>
+        <gr-dropdown
+          link=""
+          down-arrow=""
+          .items=${this.computeDownloadDropdownLinks()}
+          horizontal-align="left"
+        >
+          <span class="downloadTitle"> Download </span>
+        </gr-dropdown>
+      </span>
+    </div>`;
   }
 
-  _getSortedFileList(files?: Files) {
-    if (!files) return [];
-    return files.sortedFileList;
+  private renderRightControls() {
+    const blameLoaderClass =
+      !isMagicPath(this.path) && !this.isImageDiff ? 'show' : '';
+    const blameToggleLabel =
+      this.isBlameLoaded && !this.isBlameLoading ? 'Hide blame' : 'Show blame';
+    const diffModeSelectorClass = !this.diff || this.diff.binary ? 'hide' : '';
+    return html` <div class="rightControls">
+      <span class="blameLoader ${blameLoaderClass}">
+        <gr-button
+          link=""
+          id="toggleBlame"
+          title=${this.createTitle(
+            Shortcut.TOGGLE_BLAME,
+            ShortcutSection.DIFFS
+          )}
+          ?disabled=${this.isBlameLoading}
+          @click=${this.toggleBlame}
+          >${blameToggleLabel}</gr-button
+        >
+      </span>
+      ${when(
+        this.computeCanEdit(),
+        () => html`
+          <span class="separator"></span>
+          <span class="editButton">
+            <gr-button
+              link=""
+              title="Edit current file"
+              @click=${this.goToEditFile}
+              >edit</gr-button
+            >
+          </span>
+        `
+      )}
+      ${when(
+        this.computeShowEditLinks(),
+        () => html`
+          <span class="separator"></span>
+          ${this.editWeblinks!.map(
+            weblink => html`
+              <a target="_blank" href=${ifDefined(weblink.url)}
+                >${weblink.name}</a
+              >
+            `
+          )}
+        `
+      )}
+      ${when(
+        this.loggedIn && this.prefs,
+        () => html`
+          <span class="separator"></span>
+          <div class="diffModeSelector ${diffModeSelectorClass}">
+            <span>Diff view:</span>
+            <gr-diff-mode-selector
+              id="modeSelect"
+              .saveOnChange=${this.loggedIn}
+              show-tooltip-below
+            ></gr-diff-mode-selector>
+          </div>
+          <span id="diffPrefsContainer">
+            <span class="preferences desktop">
+              <gr-tooltip-content
+                has-tooltip=""
+                position-below=""
+                title="Diff preferences"
+              >
+                <gr-button
+                  link=""
+                  class="prefsButton"
+                  @click=${(e: Event) => this.handlePrefsTap(e)}
+                  ><gr-icon icon="settings" filled></gr-icon
+                ></gr-button>
+              </gr-tooltip-content>
+            </span>
+          </span>
+        `
+      )}
+      <gr-endpoint-decorator name="annotation-toggler">
+        <span hidden="" id="annotation-span">
+          <label for="annotation-checkbox" id="annotation-label"></label>
+          <iron-input>
+            <input
+              is="iron-input"
+              type="checkbox"
+              id="annotation-checkbox"
+              disabled=""
+            />
+          </iron-input>
+        </span>
+      </gr-endpoint-decorator>
+    </div>`;
   }
 
-  _getCurrentFile(files?: Files, path?: string) {
-    if (!files || !path) return;
-    const fileInfo = files.changeFilesByPath[path];
-    const fileRange: FileRange = {path};
-    if (fileInfo && fileInfo.old_path) {
+  private renderDialogs() {
+    return html` <gr-apply-fix-dialog
+        id="applyFixDialog"
+        .change=${this.change}
+        .changeNum=${this.changeNum}
+      >
+      </gr-apply-fix-dialog>
+      <gr-diff-preferences-dialog
+        id="diffPreferencesDialog"
+        @reload-diff-preference=${this.handleReloadingDiffPreference}
+      >
+      </gr-diff-preferences-dialog>
+      <dialog id="downloadModal" tabindex="-1">
+        <gr-download-dialog
+          id="downloadDialog"
+          .change=${this.change}
+          .patchNum=${this.patchNum}
+          .config=${this.serverConfig?.download}
+          @close=${this.handleDownloadDialogClose}
+        ></gr-download-dialog>
+      </dialog>`;
+  }
+
+  /**
+   * Set initial review status of the file.
+   * automatically mark the file as reviewed if manual review is not set.
+   */
+  setReviewedStatus(
+    patchNum: RevisionPatchSetNum,
+    diffPrefs: DiffPreferencesInfo
+  ) {
+    if (!this.loggedIn) return;
+    if (!diffPrefs.manual_review) {
+      this.setReviewed(true, patchNum);
+    }
+  }
+
+  private getFileRange() {
+    if (!this.files || !this.path) return;
+    const fileInfo = this.files.changeFilesByPath[this.path];
+    const fileRange: FileRange = {path: this.path};
+    if (fileInfo?.old_path) {
       fileRange.basePath = fileInfo.old_path;
     }
     return fileRange;
   }
 
-  @observe('_changeNum', '_patchRange.*', '_changeComments')
-  _getFiles(
-    changeNum: NumericChangeId,
-    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
-    changeComments: ChangeComments
+  private handleReviewedChange(e: Event) {
+    const input = e.target as HTMLInputElement;
+    this.setReviewed(input.checked ?? false);
+  }
+
+  // Private but used in tests.
+  setReviewed(
+    reviewed: boolean,
+    patchNum: RevisionPatchSetNum | undefined = this.patchNum
   ) {
-    // Polymer 2: check for undefined
-    if (
-      [changeNum, patchRangeRecord, patchRangeRecord.base, changeComments].some(
-        arg => arg === undefined
-      )
-    ) {
-      return Promise.resolve();
-    }
-
-    if (!patchRangeRecord.base.patchNum) {
-      return Promise.resolve();
-    }
-
-    const patchRange = patchRangeRecord.base;
-    return this.restApiService
-      .getChangeFiles(changeNum, patchRange)
-      .then(changeFiles => {
-        if (!changeFiles) return;
-        const commentedPaths = changeComments.getPaths(patchRange);
-        const files = {...changeFiles};
-        addUnmodifiedFiles(files, commentedPaths);
-        this._files = {
-          sortedFileList: Object.keys(files).sort(specialFilePathCompare),
-          changeFilesByPath: files,
-        };
-      });
-  }
-
-  _getPreferences() {
-    return this.restApiService.getPreferences();
-  }
-
-  _handleReviewedChange(e: Event) {
-    this._setReviewed(
-      ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
-    );
-  }
-
-  _setReviewed(reviewed: boolean) {
-    if (this._editMode) return;
-    this.$.reviewed.checked = reviewed;
-    if (!this._patchRange?.patchNum || !this._path) return;
-    const path = this._path;
+    if (this.patchNum === EDIT) return;
+    if (!patchNum || !this.path || !this.changeNum) return;
     // if file is already reviewed then do not make a saveReview request
-    if (this._reviewedFiles.has(path) && reviewed) return;
-    if (reviewed) this._reviewedFiles.add(path);
-    else this._reviewedFiles.delete(path);
-    this._saveReviewedState(reviewed).catch(err => {
-      if (this._reviewedFiles.has(path)) this._reviewedFiles.delete(path);
-      else this._reviewedFiles.add(path);
-      fireAlert(this, ERR_REVIEW_STATUS);
-      throw err;
-    });
-  }
-
-  _saveReviewedState(reviewed: boolean): Promise<Response | undefined> {
-    if (!this._changeNum) return Promise.resolve(undefined);
-    if (!this._patchRange?.patchNum) return Promise.resolve(undefined);
-    if (!this._path) return Promise.resolve(undefined);
-    return this.restApiService.saveFileReviewed(
-      this._changeNum,
-      this._patchRange?.patchNum,
-      this._path,
+    if (this.reviewedFiles.has(this.path) && reviewed) return;
+    // optimistic update
+    this.reviewed = reviewed;
+    this.getChangeModel().setReviewedFilesStatus(
+      this.changeNum,
+      patchNum,
+      this.path,
       reviewed
     );
   }
 
-  _handleToggleFileReviewed() {
-    this._setReviewed(!this.$.reviewed.checked);
+  // Private but used in tests.
+  handleToggleFileReviewed() {
+    this.setReviewed(!this.reviewed);
   }
 
-  _handlePrevLine() {
-    this.$.diffHost.displayLine = true;
-    this.cursor.moveUp();
+  private handlePrevLine() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost.displayLine = true;
+    this.cursor?.moveUp();
   }
 
-  _onOpenFixPreview(e: OpenFixPreviewEvent) {
-    this.$.applyFixDialog.open(e);
+  private onOpenFixPreview(e: OpenFixPreviewEvent) {
+    assertIsDefined(this.applyFixDialog, 'applyFixDialog');
+    this.applyFixDialog.open(e);
   }
 
-  _handleNextLine() {
-    this.$.diffHost.displayLine = true;
-    this.cursor.moveDown();
+  private onIsBlameLoadedChanged(e: ValueChangedEvent<boolean>) {
+    this.isBlameLoaded = e.detail.value;
   }
 
-  _moveToPreviousFileWithComment() {
-    if (!this._commentSkips) return;
-    if (!this._change) return;
-    if (!this._patchRange?.patchNum) return;
+  private onDiffChanged(e: ValueChangedEvent<DiffInfo>) {
+    this.diff = e.detail.value;
+  }
 
-    // If there is no previous diff with comments, then return to the change
-    // view.
-    if (!this._commentSkips.previous) {
-      this._navToChangeView();
-      return;
+  private onEditWeblinksChanged(
+    e: ValueChangedEvent<GeneratedWebLink[] | undefined>
+  ) {
+    this.editWeblinks = e.detail.value;
+  }
+
+  private onFilesWeblinksChanged(
+    e: ValueChangedEvent<FilesWebLinks | undefined>
+  ) {
+    this.filesWeblinks = e.detail.value;
+  }
+
+  private onIsImageDiffChanged(e: ValueChangedEvent<boolean>) {
+    this.isImageDiff = e.detail.value;
+  }
+
+  private handleNextLine() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost.displayLine = true;
+    this.cursor?.moveDown();
+  }
+
+  // Private but used in tests.
+  moveToFileWithComment(direction: -1 | 1) {
+    const path = this.findFileWithComment(direction);
+    if (!path) {
+      this.getChangeModel().navigateToChange();
+    } else {
+      this.getChangeModel().navigateToDiff({path});
     }
-
-    GerritNav.navigateToDiff(
-      this._change,
-      this._commentSkips.previous,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum
-    );
   }
 
-  _moveToNextFileWithComment() {
-    if (!this._commentSkips) return;
-    if (!this._change) return;
-    if (!this._patchRange?.patchNum) return;
-
-    // If there is no next diff with comments, then return to the change view.
-    if (!this._commentSkips.next) {
-      this._navToChangeView();
-      return;
-    }
-
-    GerritNav.navigateToDiff(
-      this._change,
-      this._commentSkips.next,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum
-    );
-  }
-
-  _handleNewComment() {
+  private handleNewComment() {
     this.classList.remove('hideComments');
-    this.cursor.createCommentInPlace();
+    this.cursor?.createCommentInPlace();
   }
 
-  _handlePrevFile() {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    this._navToFile(this._path, this._fileList, -1);
+  private handlePrevFile() {
+    if (!this.path) return;
+    if (!this.files?.sortedPaths) return;
+    this.navToFile(this.files.sortedPaths, -1);
   }
 
-  _handleNextFile() {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    this._navToFile(this._path, this._fileList, 1);
+  private handleNextFile() {
+    if (!this.path) return;
+    if (!this.files?.sortedPaths) return;
+    this.navToFile(this.files.sortedPaths, 1);
   }
 
-  _handleNextChunk() {
-    const result = this.cursor.moveToNextChunk();
-    if (result === CursorMoveResult.CLIPPED && this.cursor.isAtEnd()) {
+  private handleNextChunk() {
+    const result = this.cursor?.moveToNextChunk();
+    if (result === CursorMoveResult.CLIPPED && this.cursor?.isAtEnd()) {
       this.showToastAndNavigateFile('next', 'n');
     }
   }
 
-  _handleNextCommentThread() {
-    const result = this.cursor.moveToNextCommentThread();
+  private handleNextCommentThread() {
+    const result = this.cursor?.moveToNextCommentThread();
     if (result === CursorMoveResult.CLIPPED) {
-      this._navigateToNextFileWithCommentThread();
+      this.navigateToNextFileWithCommentThread();
     }
   }
 
@@ -652,166 +1179,141 @@
   }
 
   private navigateToUnreviewedFile(direction: string) {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    if (!this._reviewedFiles) return;
+    if (!this.path) return;
+    if (!this.files?.sortedPaths) 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)
+    const unreviewedFiles = this.files.sortedPaths.filter(
+      file => file === this.path || !this.reviewedFiles.has(file)
     );
 
-    this._navToFile(this._path, unreviewedFiles, direction === 'next' ? 1 : -1);
+    this.navToFile(unreviewedFiles, direction === 'next' ? 1 : -1);
   }
 
-  _handlePrevChunk() {
-    this.cursor.moveToPreviousChunk();
-    if (this.cursor.isAtStart()) {
+  private handlePrevChunk() {
+    this.cursor?.moveToPreviousChunk();
+    if (this.cursor?.isAtStart()) {
       this.showToastAndNavigateFile('previous', 'p');
     }
   }
 
-  _handlePrevCommentThread() {
-    this.cursor.moveToPreviousCommentThread();
+  private handlePrevCommentThread() {
+    this.cursor?.moveToPreviousCommentThread();
   }
 
-  // Similar to gr-change-view._handleOpenReplyDialog
-  _handleOpenReplyDialog() {
-    this._getLoggedIn().then(isLoggedIn => {
-      if (!isLoggedIn) {
-        fireEvent(this, 'show-auth-required');
-        return;
-      }
+  // Similar to gr-change-view.handleOpenReplyDialog
+  private handleOpenReplyDialog() {
+    if (!this.loggedIn) {
+      fireEvent(this, 'show-auth-required');
+      return;
+    }
+    this.getChangeModel().navigateToChange(true);
+  }
 
-      this.set('changeViewState.showReplyDialog', true);
-      this._navToChangeView();
+  private handleToggleLeftPane() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost.toggleLeftDiff();
+  }
+
+  private handleOpenDownloadDialog() {
+    assertIsDefined(this.downloadModal, 'downloadModal');
+    this.downloadModal.showModal();
+    whenVisible(this.downloadModal, () => {
+      assertIsDefined(this.downloadModal, 'downloadModal');
+      assertIsDefined(this.downloadDialog, 'downloadDialog');
+      this.downloadDialog.focus();
+      const downloadCommands = queryAndAssert(
+        this.downloadDialog,
+        'gr-download-commands'
+      );
+      const paperTabs = queryAndAssert<PaperTabsElement>(
+        downloadCommands,
+        'paper-tabs'
+      );
+      // Paper Tabs normally listen to 'iron-resize' event to call this method.
+      // After migrating to Dialog element, this event is no longer fired
+      // which means this method is not called which ends up styling the
+      // selected paper tab with an underline.
+      paperTabs._onTabSizingChanged();
     });
   }
 
-  _handleToggleLeftPane() {
-    this.$.diffHost.toggleLeftDiff();
+  private handleDownloadDialogClose() {
+    assertIsDefined(this.downloadModal, 'downloadModal');
+    this.downloadModal.close();
   }
 
-  _handleOpenDownloadDialog() {
-    this.set('changeViewState.showDownloadDialog', true);
-    this._navToChangeView();
+  private handleCommaKey() {
+    if (!this.loggedIn) return;
+    assertIsDefined(this.diffPreferencesDialog, 'diffPreferencesDialog');
+    this.diffPreferencesDialog.open();
   }
 
-  _handleUpToChange() {
-    this._navToChangeView();
-  }
-
-  _handleCommaKey() {
-    if (!this._loggedIn) return;
-    this.$.diffPreferencesDialog.open();
-  }
-
-  _handleToggleDiffMode() {
-    if (!this._userPrefs) return;
-    if (this._userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
-      this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+  // Private but used in tests.
+  handleToggleDiffMode() {
+    if (!this.userPrefs) return;
+    if (this.userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
+      this.getUserModel().updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.userService.updatePreferences({
+      this.getUserModel().updatePreferences({
         diff_view: DiffViewMode.SIDE_BY_SIDE,
       });
     }
   }
 
-  _navToChangeView() {
-    if (!this._changeNum || !this._patchRange?.patchNum) {
-      return;
-    }
-    this._navigateToChange(
-      this._change,
-      this._patchRange,
-      this._change && this._change.revisions
-    );
-  }
-
-  _navToFile(
-    path: string,
+  // Private but used in tests.
+  navToFile(
     fileList: string[],
     direction: -1 | 1,
     navigateToFirstComment?: boolean
   ) {
-    const newPath = this._getNavLinkPath(path, fileList, direction);
+    const newPath = this.getNavLinkPath(fileList, direction);
     if (!newPath) return;
-    if (!this._change) return;
-    if (!this._patchRange) return;
+    if (!this.patchRange) return;
 
     if (newPath.up) {
-      this._navigateToChange(
-        this._change,
-        this._patchRange,
-        this._change && this._change.revisions
-      );
+      this.getChangeModel().navigateToChange();
       return;
     }
 
     if (!newPath.path) return;
     let lineNum;
     if (navigateToFirstComment)
-      lineNum = this._changeComments?.getCommentsForPath(
+      lineNum = this.changeComments?.getCommentsForPath(
         newPath.path,
-        this._patchRange
+        this.patchRange
       )?.[0].line;
-    GerritNav.navigateToDiff(
-      this._change,
-      newPath.path,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum,
-      lineNum
-    );
+    this.getChangeModel().navigateToDiff({path: newPath.path, lineNum});
   }
 
   /**
-   * @param path The path of the current file being shown.
-   * @param fileList The list of files in this change and
-   * patch range.
    * @param direction Either 1 (next file) or -1 (prev file).
    * @return The next URL when proceeding in the specified
    * direction.
    */
-  _computeNavLinkURL(
-    change?: ChangeInfo,
-    path?: string,
-    fileList?: string[],
-    direction?: -1 | 1
-  ) {
-    if (!change) return null;
-    if (!path) return null;
-    if (!fileList) return null;
-    if (!direction) return null;
+  private computeNavLinkURL(direction?: -1 | 1) {
+    if (!this.change) return;
+    if (!this.path) return;
+    if (!this.files?.sortedPaths) return;
+    if (!direction) return;
 
-    const newPath = this._getNavLinkPath(path, fileList, direction);
-    if (!newPath) {
-      return null;
-    }
-
-    if (newPath.up) {
-      return this._getChangePath(
-        this._change,
-        this._patchRange,
-        this._change && this._change.revisions
-      );
-    }
-    return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+    const newPath = this.getNavLinkPath(this.files.sortedPaths, direction);
+    if (!newPath) return;
+    if (newPath.up) return this.getChangeModel().changeUrl();
+    if (!newPath.path) return;
+    return this.getChangeModel().diffUrl({path: newPath.path});
   }
 
-  _goToEditFile() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  private goToEditFile() {
+    assertIsDefined(this.path, 'path');
 
     // TODO(taoalpha): add a shortcut for editing
-    const cursorAddress = this.cursor.getAddress();
-    const editUrl = GerritNav.getEditUrlForDiff(
-      this._change,
-      this._path,
-      this._patchRange.patchNum,
-      cursorAddress?.number
-    );
-    GerritNav.navigateToRelativeUrl(editUrl);
+    const cursorAddress = this.cursor?.getAddress();
+    this.getChangeModel().navigateToEdit({
+      path: this.path,
+      lineNum: cursorAddress?.number,
+    });
   }
 
   /**
@@ -829,12 +1331,11 @@
    * patch range.
    * @param direction Either 1 (next file) or -1 (prev file).
    */
-  _getNavLinkPath(path: string, fileList: string[], direction: -1 | 1) {
-    if (!path || !fileList || fileList.length === 0) {
+  private getNavLinkPath(fileList: string[], direction: -1 | 1) {
+    if (!this.path || !fileList || fileList.length === 0) {
       return null;
     }
-
-    let idx = fileList.indexOf(path);
+    let idx = fileList.indexOf(this.path);
     if (idx === -1) {
       const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
       return {path: file};
@@ -850,555 +1351,171 @@
     return {path: fileList[idx]};
   }
 
-  _getReviewedFiles(changeNum?: NumericChangeId, patchNum?: PatchSetNum) {
-    if (!changeNum || !patchNum) return;
-    if (
-      this.getReviewedParams.changeNum === changeNum &&
-      this.getReviewedParams.patchNum === patchNum
-    ) {
-      return;
-    }
-    this.getReviewedParams = {
-      changeNum,
-      patchNum,
-    };
-    this.restApiService.getReviewedFiles(changeNum, patchNum).then(files => {
-      this._reviewedFiles = new Set(files);
+  private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
+    if (!this.change) return;
+    if (!this.patchNum) return;
+    if (!this.changeNum) return;
+    if (!this.path) return;
+    const url = createDiffUrl({
+      changeNum: this.changeNum,
+      repo: this.change.project,
+      patchNum: this.patchNum,
+      basePatchNum: this.basePatchNum,
+      diffView: {
+        path: this.path,
+        lineNum,
+        leftSide,
+      },
     });
-  }
-
-  _getReviewedStatus(path: string) {
-    if (this._editMode) return false;
-    return this._reviewedFiles.has(path);
-  }
-
-  _initLineOfInterestAndCursor(leftSide: boolean) {
-    this.$.diffHost.lineOfInterest = this._getLineOfInterest(leftSide);
-    this._initCursor(leftSide);
-  }
-
-  _displayDiffBaseAgainstLeftToast() {
-    if (!this._patchRange) return;
-    fireAlert(
-      this,
-      `Patchset ${this._patchRange.basePatchNum} vs ` +
-        `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
-        `Base vs ${this._patchRange.basePatchNum}`
-    );
-  }
-
-  _displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
-    if (!this._patchRange) return;
-    const leftPatchset =
-      this._patchRange.basePatchNum === ParentPatchSetNum
-        ? 'Base'
-        : `Patchset ${this._patchRange.basePatchNum}`;
-    fireAlert(
-      this,
-      `${leftPatchset} vs
-            ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
-            ${leftPatchset} vs Patchset ${latestPatchNum}`
-    );
-  }
-
-  _displayToasts() {
-    if (!this._patchRange) return;
-    if (this._patchRange.basePatchNum !== ParentPatchSetNum) {
-      this._displayDiffBaseAgainstLeftToast();
-      return;
-    }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (this._patchRange.patchNum !== latestPatchNum) {
-      this._displayDiffAgainstLatestToast(latestPatchNum);
-      return;
-    }
-  }
-
-  _initCommitRange() {
-    let commit: CommitId | undefined;
-    let baseCommit: CommitId | undefined;
-    if (!this._change) return;
-    if (!this._patchRange || !this._patchRange.patchNum) return;
-    const revisions = this._change.revisions ?? {};
-    for (const [commitSha, revision] of Object.entries(revisions)) {
-      const patchNum = revision._number;
-      if (patchNum === this._patchRange.patchNum) {
-        commit = commitSha as CommitId;
-        const commitObj = revision.commit;
-        const parents = commitObj?.parents || [];
-        if (
-          this._patchRange.basePatchNum === ParentPatchSetNum &&
-          parents.length
-        ) {
-          baseCommit = parents[parents.length - 1].commit;
-        }
-      } 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;
-    if (this.params?.view !== GerritView.DIFF) return;
-    if (this.params?.commentId) {
-      const comment = this._changeComments?.findCommentById(
-        this.params.commentId
-      );
-      if (!comment) {
-        fireAlert(this, 'comment not found');
-        GerritNav.navigateToChange(this._change);
-        return;
-      }
-      this._path = comment.path;
-
-      const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-      if (!latestPatchNum) throw new Error('Missing _allPatchSets');
-      this._patchRange = getPatchRangeForCommentUrl(comment, latestPatchNum);
-      leftSide = isInBaseOfPatchRange(comment, this._patchRange);
-
-      this._focusLineNum = comment.line;
-    } else {
-      if (this.params.path) {
-        this._path = this.params.path;
-      }
-      if (this.params.patchNum) {
-        this._patchRange = {
-          patchNum: this.params.patchNum,
-          basePatchNum: this.params.basePatchNum || ParentPatchSetNum,
-        };
-      }
-      if (this.params.lineNum) {
-        this._focusLineNum = this.params.lineNum;
-        leftSide = !!this.params.leftSide;
-      }
-    }
-    assertIsDefined(this._patchRange, '_patchRange');
-    this._initLineOfInterestAndCursor(leftSide);
-
-    if (this.params?.commentId) {
-      // url is of type /comment/{commentId} which isn't meaningful
-      this._updateUrlToDiffUrl(this._focusLineNum, leftSide);
-    }
-
-    this._commentMap = this._getPaths(this._patchRange);
+  async reloadDiff() {
+    if (!this.diffHost) return;
+    await this.diffHost.reload(true);
+    this.reporting.diffViewDisplayed();
+    if (this.isBlameLoaded) this.loadBlame();
   }
 
-  _isFileUnchanged(diff?: DiffInfo) {
-    if (!diff || !diff.content) return false;
-    return !diff.content.some(
-      content =>
-        (content.a && !content.common) || (content.b && !content.common)
-    );
-  }
-
-  private isSameDiffLoaded(value: AppElementDiffViewParam) {
-    return (
-      this._patchRange?.basePatchNum === value.basePatchNum &&
-      this._patchRange?.patchNum === value.patchNum &&
-      this._path === value.path
-    );
-  }
-
-  _paramsChanged(value: AppElementParams) {
-    if (value.view !== GerritView.DIFF) {
-      return;
-    }
-
+  /**
+   * (Re-initialize) the diff view without actually reloading the diff. The
+   * typical user journey is that the user comes back from the change page.
+   */
+  initializePositions() {
     // The diff view is kept in the background once created. If the user
     // scrolls in the change page, the scrolling is reflected in the diff view
     // as well, which means the diff is scrolled to a random position based
     // on how much the change view was scrolled.
     // Hence, reset the scroll position here.
     document.documentElement.scrollTop = 0;
-
-    // Everything in the diff view is tied to the change. It seems better to
-    // force the re-creation of the diff view when the change number changes.
-    const changeChanged = this._changeNum !== value.changeNum;
-    if (this._changeNum !== undefined && changeChanged) {
-      fireEvent(this, EventType.RECREATE_DIFF_VIEW);
-      return;
-    } else if (this._changeNum !== undefined && this.isSameDiffLoaded(value)) {
-      // changeNum has not changed, so check if there are changes in patchRange
-      // path. If no changes then we can simply render the view as is.
-      return;
-    }
-
-    this._files = {sortedFileList: [], changeFilesByPath: {}};
-    this._path = undefined;
-    this._patchRange = undefined;
-    this._commitRange = undefined;
-    this._focusLineNum = undefined;
-
-    if (value.changeNum && value.project) {
-      this.restApiService.setInProjectLookup(value.changeNum, value.project);
-    }
-
-    this._changeNum = value.changeNum;
+    this.reInitCursor();
+    this.diffHost?.initLayers();
     this.classList.remove('hideComments');
-
-    // When navigating away from the page, there is a possibility that the
-    // patch number is no longer a part of the URL (say when navigating to
-    // the top-level change info view) and therefore undefined in `params`.
-    // If route is of type /comment/<commentId>/ then no patchNum is present
-    if (!value.patchNum && !value.commentLink) {
-      this.reporting.error(
-        new Error(`Invalid diff view URL, no patchNum found: ${value}`)
-      );
-      return;
-    }
-
-    const promises: Promise<unknown>[] = [];
-
-    if (!this._change) promises.push(this._getChangeDetail(this._changeNum));
-
-    promises.push(this._getChangeEdit());
-
-    this.$.diffHost.cancel();
-    this.$.diffHost.clearDiffContent();
-    this._loading = true;
-    return Promise.all(promises)
-      .then(r => {
-        this._loading = false;
-        this._initPatchRange();
-        this._initCommitRange();
-
-        const edit = r[4] as EditInfo | undefined;
-        if (edit) {
-          this.set(`_change.revisions.${edit.commit.commit}`, {
-            _number: EditPatchSetNum,
-            basePatchNum: edit.base_patch_set_number,
-            commit: edit.commit,
-          });
-        }
-        return this.$.diffHost.reload(true);
-      })
-      .then(() => {
-        this.reporting.diffViewFullyLoaded();
-        // If diff view displayed has not ended yet, it ends here.
-        this.reporting.diffViewDisplayed();
-      })
-      .then(() => {
-        const fileUnchanged = this._isFileUnchanged(this._diff);
-        if (fileUnchanged && value.commentLink) {
-          assertIsDefined(this._change, '_change');
-          assertIsDefined(this._path, '_path');
-          assertIsDefined(this._patchRange, '_patchRange');
-
-          if (this._patchRange.basePatchNum === ParentPatchSetNum) {
-            // file is unchanged between Base vs X
-            // hence should not show diff between Base vs Base
-            return;
-          }
-
-          fireAlert(
-            this,
-            `File is unchanged between Patchset
-                  ${this._patchRange.basePatchNum} and
-                  ${this._patchRange.patchNum}. Showing diff of Base vs
-                  ${this._patchRange.basePatchNum}`
-          );
-          GerritNav.navigateToDiff(
-            this._change,
-            this._path,
-            this._patchRange.basePatchNum,
-            ParentPatchSetNum,
-            this._focusLineNum
-          );
-          return;
-        }
-        if (value.commentLink) {
-          this._displayToasts();
-        }
-        // If the blame was loaded for a previous file and user navigates to
-        // another file, then we load the blame for this file too
-        if (this._isBlameLoaded) this._loadBlame();
-      });
-  }
-
-  @observe('_path', '_prefs', '_reviewedFiles', '_patchRange')
-  _setReviewedObserver(
-    path?: string,
-    prefs?: DiffPreferencesInfo,
-    reviewedFiles?: Set<string>,
-    patchRange?: PatchRange
-  ) {
-    if (prefs === undefined) return;
-    if (path === undefined) return;
-    if (reviewedFiles === undefined) return;
-    if (patchRange === undefined) return;
-    if (prefs.manual_review) {
-      // Checkbox state needs to be set explicitly only when manual_review
-      // is specified.
-      this.$.reviewed.checked = this._getReviewedStatus(path);
-    } else {
-      this._setReviewed(true);
-    }
-  }
-
-  @observe('_loggedIn', '_changeNum', '_patchRange')
-  getReviewedFiles(
-    _loggedIn?: boolean,
-    _changeNum?: NumericChangeId,
-    patchRange?: PatchRange
-  ) {
-    if (_loggedIn === undefined) return;
-    if (_changeNum === undefined) return;
-    if (patchRange === undefined) return;
-
-    if (!_loggedIn) {
-      return;
-    }
-
-    this._getReviewedFiles(this._changeNum, patchRange.patchNum);
   }
 
   /**
    * If the params specify a diff address then configure the diff cursor.
+   * Private but used in tests.
    */
-  _initCursor(leftSide: boolean) {
-    if (this._focusLineNum === undefined) {
-      return;
-    }
-    if (leftSide) {
-      this.cursor.side = Side.LEFT;
-    } else {
-      this.cursor.side = Side.RIGHT;
-    }
-    this.cursor.initialLineNumber = this._focusLineNum;
+  initCursor() {
+    if (!this.focusLineNum) return;
+    if (!this.cursor) return;
+    this.cursor.side = this.leftSide ? Side.LEFT : Side.RIGHT;
+    this.cursor.initialLineNumber = this.focusLineNum;
   }
 
-  _getLineOfInterest(leftSide: boolean): LineOfInterest | undefined {
+  // Private but used in tests.
+  getLineOfInterest(): DisplayLine | undefined {
     // If there is a line number specified, pass it along to the diff so that
     // it will not get collapsed.
-    if (!this._focusLineNum) {
-      return undefined;
+    if (!this.focusLineNum) return undefined;
+
+    return {
+      lineNum: this.focusLineNum,
+      side: this.leftSide ? Side.LEFT : Side.RIGHT,
+    };
+  }
+
+  private pathChanged() {
+    if (this.path) {
+      fireTitleChange(this, computeTruncatedPath(this.path));
     }
-
-    return {number: this._focusLineNum, leftSide};
   }
 
-  _pathChanged(path: string) {
-    if (path) {
-      fireTitleChange(this, computeTruncatedPath(path));
-    }
-
-    if (!this._fileList || this._fileList.length === 0) return;
-
-    this.set('changeViewState.selectedFileIndex', this._fileList.indexOf(path));
-  }
-
-  _getDiffUrl(change?: ChangeInfo, patchRange?: PatchRange, path?: string) {
-    if (!change || !patchRange || !path) return '';
-    return GerritNav.getUrlForDiff(
-      change,
-      path,
-      patchRange.patchNum,
-      patchRange.basePatchNum
-    );
-  }
-
-  /**
-   * When the latest patch of the change is selected (and there is no base
-   * patch) then the patch range need not appear in the URL. Return a patch
-   * range object with undefined values when a range is not needed.
-   */
-  _getChangeUrlRange(
-    patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo}
-  ) {
-    let patchNum = undefined;
-    let basePatchNum = undefined;
-    let latestPatchNum = -1;
-    for (const rev of Object.values(revisions || {})) {
-      if (typeof rev._number === 'number') {
-        latestPatchNum = Math.max(latestPatchNum, rev._number);
-      }
-    }
-    if (!patchRange) return {patchNum, basePatchNum};
-    if (
-      patchRange.basePatchNum !== ParentPatchSetNum ||
-      patchRange.patchNum !== latestPatchNum
-    ) {
-      patchNum = patchRange.patchNum;
-      basePatchNum = patchRange.basePatchNum;
-    }
-    return {patchNum, basePatchNum};
-  }
-
-  _getChangePath(
-    change?: ChangeInfo,
-    patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo}
-  ) {
-    if (!change) return '';
-    if (!patchRange) return '';
-
-    const range = this._getChangeUrlRange(patchRange, revisions);
-    return GerritNav.getUrlForChange(
-      change,
-      range.patchNum,
-      range.basePatchNum
-    );
-  }
-
-  _navigateToChange(
-    change?: ChangeInfo,
-    patchRange?: PatchRange,
-    revisions?: {[revisionId: string]: RevisionInfo}
-  ) {
-    if (!change) return;
-    const range = this._getChangeUrlRange(patchRange, revisions);
-    GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
-  }
-
-  _computeChangePath(
-    change?: ChangeInfo,
-    patchRangeRecord?: PolymerDeepPropertyChange<PatchRange, PatchRange>,
-    revisions?: {[revisionId: string]: RevisionInfo}
-  ) {
-    if (!patchRangeRecord) return '';
-    return this._getChangePath(change, patchRangeRecord.base, revisions);
-  }
-
-  _formatFilesForDropdown(
-    files?: Files,
-    patchRange?: PatchRange,
-    changeComments?: ChangeComments
-  ): DropdownItem[] {
-    if (!files) return [];
-    if (!patchRange) return [];
-    if (!changeComments) return [];
+  // Private but used in tests
+  formatFilesForDropdown(): DropdownItem[] {
+    if (!this.files) return [];
+    if (!this.patchRange) return [];
+    if (!this.changeComments) return [];
 
     const dropdownContent: DropdownItem[] = [];
-    for (const path of files.sortedFileList) {
+    for (const path of this.files.sortedPaths) {
+      const file = this.files.changeFilesByPath[path];
       dropdownContent.push({
         text: computeDisplayPath(path),
         mobileText: computeTruncatedPath(path),
         value: path,
-        bottomText: changeComments.computeCommentsString(
-          patchRange,
+        bottomText: this.changeComments.computeCommentsString(
+          this.patchRange,
           path,
-          files.changeFilesByPath[path],
+          file,
           /* includeUnmodified= */ true
         ),
-        file: {...files.changeFilesByPath[path], __path: path},
+        file,
       });
     }
     return dropdownContent;
   }
 
-  _computePrefsButtonHidden(prefs?: DiffPreferencesInfo, loggedIn?: boolean) {
-    return !loggedIn || !prefs;
+  // Private but used in tests.
+  handleFileChange(e: ValueChangedEvent<string>) {
+    const path: string = e.detail.value;
+    if (path === this.path) return;
+    this.getChangeModel().navigateToDiff({path});
   }
 
-  _handleFileChange(e: CustomEvent) {
-    if (!this._change) return;
-    if (!this._patchRange) return;
-
-    // This is when it gets set initially.
-    const path = e.detail.value;
-    if (path === this._path) {
-      return;
-    }
-
-    GerritNav.navigateToDiff(
-      this._change,
-      path,
-      this._patchRange.patchNum,
-      this._patchRange.basePatchNum
-    );
-  }
-
-  _handlePatchChange(e: CustomEvent) {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handlePatchChange(e: CustomEvent) {
+    if (!this.path) return;
+    if (!this.patchNum) return;
 
     const {basePatchNum, patchNum} = e.detail;
-    if (
-      basePatchNum === this._patchRange.basePatchNum &&
-      patchNum === this._patchRange.patchNum
-    ) {
+    if (basePatchNum === this.basePatchNum && patchNum === this.patchNum) {
       return;
     }
-    GerritNav.navigateToDiff(this._change, this._path, patchNum, basePatchNum);
-  }
-
-  _handlePrefsTap(e: Event) {
-    e.preventDefault();
-    this.$.diffPreferencesDialog.open();
-  }
-
-  _computeModeSelectHideClass(diff?: DiffInfo) {
-    return !diff || diff.binary ? 'hide' : '';
-  }
-
-  _onLineSelected(
-    _: Event,
-    detail: {side: Side | CommentSide; number: number}
-  ) {
-    // for on-comment-anchor-tap side can be PARENT/REVISIONS
-    // for on-line-selected side can be left/right
-    this._updateUrlToDiffUrl(
-      detail.number,
-      detail.side === Side.LEFT || detail.side === CommentSide.PARENT
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      patchNum,
+      basePatchNum
     );
   }
 
-  _computeDownloadDropdownLinks(
-    project?: RepoName,
-    changeNum?: NumericChangeId,
-    patchRange?: PatchRange,
-    path?: string,
-    diff?: DiffInfo
-  ) {
-    if (!project) return [];
-    if (!changeNum) return [];
-    if (!patchRange || !patchRange.patchNum) return [];
-    if (!path) return [];
+  // Private but used in tests.
+  handlePrefsTap(e: Event) {
+    e.preventDefault();
+    assertIsDefined(this.diffPreferencesDialog, 'diffPreferencesDialog');
+    this.diffPreferencesDialog.open();
+  }
+
+  // Private but used in tests.
+  onLineSelected(e: CustomEvent) {
+    // for on-comment-anchor-tap side can be PARENT/REVISIONS
+    // for on-line-selected side can be left/right
+    this.updateUrlToDiffUrl(
+      e.detail.number,
+      e.detail.side === Side.LEFT || e.detail.side === CommentSide.PARENT
+    );
+  }
+
+  // Private but used in tests.
+  computeDownloadDropdownLinks() {
+    if (!this.change?.project) return [];
+    if (!this.changeNum) return [];
+    if (!this.patchRange) return [];
+    if (!this.path) return [];
 
     const links = [
       {
-        url: this._computeDownloadPatchLink(
-          project,
-          changeNum,
-          patchRange,
-          path
+        url: this.computeDownloadPatchLink(
+          this.change.project,
+          this.changeNum,
+          this.patchRange,
+          this.path
         ),
         name: 'Patch',
       },
     ];
 
-    if (diff && diff.meta_a) {
-      let leftPath = path;
-      if (diff.change_type === 'RENAMED') {
-        leftPath = diff.meta_a.name;
+    if (this.diff && this.diff.meta_a) {
+      let leftPath = this.path;
+      if (this.diff.change_type === 'RENAMED') {
+        leftPath = this.diff.meta_a.name;
       }
       links.push({
-        url: this._computeDownloadFileLink(
-          project,
-          changeNum,
-          patchRange,
+        url: this.computeDownloadFileLink(
+          this.change.project,
+          this.changeNum,
+          this.patchRange,
           leftPath,
           true
         ),
@@ -1406,13 +1523,13 @@
       });
     }
 
-    if (diff && diff.meta_b) {
+    if (this.diff && this.diff.meta_b) {
       links.push({
-        url: this._computeDownloadFileLink(
-          project,
-          changeNum,
-          patchRange,
-          path,
+        url: this.computeDownloadFileLink(
+          this.change.project,
+          this.changeNum,
+          this.patchRange,
+          this.path,
           false
         ),
         name: 'Right Content',
@@ -1422,137 +1539,77 @@
     return links;
   }
 
-  _computeDownloadFileLink(
-    project: RepoName,
+  // TODO: Move to view-model or router.
+  // Private but used in tests.
+  computeDownloadFileLink(
+    repo: RepoName,
     changeNum: NumericChangeId,
     patchRange: PatchRange,
     path: string,
     isBase?: boolean
   ) {
     let patchNum = patchRange.patchNum;
+    let parent: number | undefined = undefined;
 
-    const comparedAgainstParent = patchRange.basePatchNum === 'PARENT';
-
-    if (isBase && !comparedAgainstParent) {
-      patchNum = patchRange.basePatchNum as RevisionPatchSetNum;
+    if (isBase) {
+      if (isMergeParent(patchRange.basePatchNum)) {
+        parent = getParentIndex(patchRange.basePatchNum);
+      } else if (patchRange.basePatchNum === PARENT) {
+        parent = 1;
+      } else {
+        patchNum = patchRange.basePatchNum as PatchSetNumber;
+      }
     }
-
     let url =
-      changeBaseURL(project, changeNum, patchNum) +
+      changeBaseURL(repo, changeNum, patchNum) +
       `/files/${encodeURIComponent(path)}/download`;
-
-    if (isBase && comparedAgainstParent) {
-      url += '?parent=1';
-    }
+    if (parent) url += `?parent=${parent}`;
 
     return url;
   }
 
-  _computeDownloadPatchLink(
-    project: RepoName,
+  // TODO: Move to view-model or router.
+  // Private but used in tests.
+  computeDownloadPatchLink(
+    repo: RepoName,
     changeNum: NumericChangeId,
     patchRange: PatchRange,
     path: string
   ) {
-    let url = changeBaseURL(project, changeNum, patchRange.patchNum);
+    let url = changeBaseURL(repo, changeNum, patchRange.patchNum);
     url += '/patch?zip&path=' + encodeURIComponent(path);
     return url;
   }
 
-  @observe(
-    '_changeComments',
-    '_files.changeFilesByPath',
-    '_path',
-    '_patchRange',
-    '_projectConfig'
-  )
-  _recomputeComments(
-    changeComments?: ChangeComments,
-    files?: {[path: string]: FileInfo},
-    path?: string,
-    patchRange?: PatchRange,
-    projectConfig?: ConfigInfo
-  ) {
-    if (!files) return;
-    if (!path) return;
-    if (!patchRange) return;
-    if (!projectConfig) return;
-    if (!changeComments) return;
+  // Private but used in tests.
+  findFileWithComment(direction: -1 | 1): string | undefined {
+    const fileList = this.files?.sortedPaths;
+    const commentMap: CommentMap =
+      this.changeComments?.getPaths(this.patchRange) ?? {};
+    if (!fileList || fileList.length === 0) return undefined;
+    if (!this.path) return undefined;
 
-    const file = files[path];
-    if (file && file.old_path) {
-      this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
-        {path, basePath: file.old_path},
-        patchRange
-      );
+    const pathIndex = fileList.indexOf(this.path);
+    const stopIndex = direction === 1 ? fileList.length : -1;
+    for (let i = pathIndex + direction; i !== stopIndex; i += direction) {
+      if (commentMap[fileList[i]]) return fileList[i];
     }
+    return undefined;
   }
 
-  _getPaths(patchRange: PatchRange) {
-    if (!this._changeComments) return {};
-    return this._changeComments.getPaths(patchRange);
-  }
-
-  _computeCommentSkips(
-    commentMap?: CommentMap,
-    fileList?: string[],
-    path?: string
-  ) {
-    if (!commentMap) return undefined;
-    if (!fileList) return undefined;
-    if (!path) return undefined;
-
-    const skips: CommentSkips = {previous: null, next: null};
-    if (!fileList.length) {
-      return skips;
-    }
-    const pathIndex = fileList.indexOf(path);
-
-    // Scan backward for the previous file.
-    for (let i = pathIndex - 1; i >= 0; i--) {
-      if (commentMap[fileList[i]]) {
-        skips.previous = fileList[i];
-        break;
-      }
-    }
-
-    // Scan forward for the next file.
-    for (let i = pathIndex + 1; i < fileList.length; i++) {
-      if (commentMap[fileList[i]]) {
-        skips.next = fileList[i];
-        break;
-      }
-    }
-
-    return skips;
-  }
-
-  _computeContainerClass(editMode: boolean) {
-    return editMode ? 'editMode' : '';
-  }
-
-  _computeEditMode(
-    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
-  ) {
-    const patchRange = patchRangeRecord.base || {};
-    return patchRange.patchNum === EditPatchSetNum;
-  }
-
-  _computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
-    return loaded && !loading ? 'Hide blame' : 'Show blame';
-  }
-
-  _loadBlame() {
-    this._isBlameLoading = true;
+  // Private but used in tests.
+  loadBlame() {
+    this.isBlameLoading = true;
     fireAlert(this, LOADING_BLAME);
-    this.$.diffHost
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost
       .loadBlame()
       .then(() => {
-        this._isBlameLoading = false;
+        this.isBlameLoading = false;
         fireAlert(this, LOADED_BLAME);
       })
       .catch(() => {
-        this._isBlameLoading = false;
+        this.isBlameLoading = false;
       });
   }
 
@@ -1560,203 +1617,170 @@
    * Load and display blame information if it has not already been loaded.
    * Otherwise hide it.
    */
-  _toggleBlame() {
-    if (this._isBlameLoaded) {
-      this.$.diffHost.clearBlame();
+  private toggleBlame() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    if (this.isBlameLoaded) {
+      this.diffHost.clearBlame();
       return;
     }
-    this._loadBlame();
+    this.loadBlame();
   }
 
-  _handleToggleBlame() {
-    this._toggleBlame();
-  }
-
-  _handleToggleHideAllCommentThreads() {
+  private handleToggleHideAllCommentThreads() {
     toggleClass(this, 'hideComments');
   }
 
-  _handleOpenFileList() {
-    this.$.dropdown.open();
+  private handleOpenFileList() {
+    assertIsDefined(this.dropdown, 'dropdown');
+    this.dropdown.open();
   }
 
-  _handleDiffAgainstBase() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleDiffAgainstBase() {
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+    if (this.basePatchNum === PARENT) {
       fireAlert(this, 'Base is already selected.');
       return;
     }
-    GerritNav.navigateToDiff(
-      this._change,
-      this._path,
-      this._patchRange.patchNum
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.patchNum,
+      PARENT
     );
   }
 
-  _handleDiffBaseAgainstLeft() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleDiffBaseAgainstLeft() {
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+    if (this.basePatchNum === PARENT) {
       fireAlert(this, 'Left is already base.');
       return;
     }
-    GerritNav.navigateToDiff(
-      this._change,
-      this._path,
-      this._patchRange.basePatchNum,
-      'PARENT' as BasePatchSetNum,
-      this.params?.view === GerritView.DIFF && this.params?.commentLink
-        ? this._focusLineNum
-        : undefined
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.basePatchNum as RevisionPatchSetNum,
+      PARENT
     );
   }
 
-  _handleDiffAgainstLatest() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleDiffAgainstLatest() {
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (this._patchRange.patchNum === latestPatchNum) {
+    if (this.patchNum === this.latestPatchNum) {
       fireAlert(this, 'Latest is already selected.');
       return;
     }
 
-    GerritNav.navigateToDiff(
-      this._change,
-      this._path,
-      latestPatchNum,
-      this._patchRange.basePatchNum
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.latestPatchNum,
+      this.basePatchNum
     );
   }
 
-  _handleDiffRightAgainstLatest() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleDiffRightAgainstLatest() {
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (this._patchRange.patchNum === latestPatchNum) {
+    if (this.patchNum === this.latestPatchNum) {
       fireAlert(this, 'Right is already latest.');
       return;
     }
-    GerritNav.navigateToDiff(
-      this._change,
-      this._path,
-      latestPatchNum,
-      this._patchRange.patchNum as BasePatchSetNum
+
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.latestPatchNum,
+      this.patchNum as BasePatchSetNum
     );
   }
 
-  _handleDiffBaseAgainstLatest() {
-    if (!this._change) return;
-    if (!this._path) return;
-    if (!this._patchRange) return;
+  // Private but used in tests.
+  handleDiffBaseAgainstLatest() {
+    if (!this.isActiveChildView) return;
+    assertIsDefined(this.path, 'path');
+    assertIsDefined(this.patchNum, 'patchNum');
 
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (
-      this._patchRange.patchNum === latestPatchNum &&
-      this._patchRange.basePatchNum === ParentPatchSetNum
-    ) {
+    if (this.patchNum === this.latestPatchNum && this.basePatchNum === PARENT) {
       fireAlert(this, 'Already diffing base against latest.');
       return;
     }
-    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
+
+    this.getChangeModel().navigateToDiff(
+      {path: this.path},
+      this.latestPatchNum,
+      PARENT
+    );
   }
 
-  _computeBlameLoaderClass(isImageDiff?: boolean, path?: string) {
-    return !isMagicPath(path) && !isImageDiff ? 'show' : '';
+  // Private but used in tests.
+  computeFileNum(files: DropdownItem[]) {
+    if (!this.path || !files) return undefined;
+
+    return files.findIndex(({value}) => value === this.path) + 1;
   }
 
-  _getRevisionInfo(change: ChangeInfo) {
-    return new RevisionInfoObj(change);
-  }
-
-  _computeFileNum(file?: string, files?: DropdownItem[]) {
-    if (!file || !files) return undefined;
-
-    return files.findIndex(({value}) => value === file) + 1;
-  }
-
-  _computeFileNumClass(fileNum?: number, files?: DropdownItem[]) {
+  // Private but used in tests.
+  computeFileNumClass(fileNum?: number, files?: DropdownItem[]) {
     if (files && fileNum && fileNum > 0) {
       return 'show';
     }
     return '';
   }
 
-  _handleToggleAllDiffContext() {
-    this.$.diffHost.toggleAllContext();
+  private handleToggleAllDiffContext() {
+    assertIsDefined(this.diffHost, 'diffHost');
+    this.diffHost.toggleAllContext();
   }
 
-  _handleNextUnreviewedFile() {
-    this._setReviewed(true);
+  private handleNextUnreviewedFile() {
+    this.setReviewed(true);
     this.navigateToUnreviewedFile('next');
   }
 
-  _navigateToNextFileWithCommentThread() {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    if (!this._patchRange) return;
-    if (!this._change) return;
+  private navigateToNextFileWithCommentThread() {
+    if (!this.path) return;
+    if (!this.files?.sortedPaths) return;
+    const range = this.patchRange;
+    if (!range) 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.changeComments?.getCommentsForPath(path, range)?.length ?? 0 > 0;
+    const filesWithComments = this.files.sortedPaths.filter(
+      file => file === this.path || hasComment(file)
     );
-    this._navToFile(this._path, filesWithComments, 1, true);
+    this.navToFile(filesWithComments, 1, true);
   }
 
-  _handleReloadingDiffPreference() {
-    this.userService.getDiffPreferences();
+  private handleReloadingDiffPreference() {
+    this.getUserModel().getDiffPreferences();
   }
 
-  _computeCanEdit(
-    loggedIn?: boolean,
-    editWeblinks?: GeneratedWebLink[],
-    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
-  ) {
-    if (!changeChangeRecord?.base) return false;
+  private computeCanEdit() {
     return (
-      loggedIn &&
-      changeIsOpen(changeChangeRecord.base) &&
-      (!editWeblinks || editWeblinks.length === 0)
+      !!this.change &&
+      !!this.loggedIn &&
+      changeIsOpen(this.change) &&
+      !this.computeShowEditLinks()
     );
   }
 
-  _computeShowEditLinks(editWeblinks?: GeneratedWebLink[]) {
-    return !!editWeblinks && editWeblinks.length > 0;
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeAllPatchSets(change: ChangeInfo) {
-    return computeAllPatchSets(change);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeDisplayPath(path: string) {
-    return computeDisplayPath(path);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeTruncatedPath(path?: string) {
-    return path ? computeTruncatedPath(path) : '';
+  private computeShowEditLinks() {
+    return !!this.editWeblinks && this.editWeblinks.length > 0;
   }
 
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-    return this.shortcuts.createTitle(shortcutName, section);
+    return this.getShortcutsService().createTitle(shortcutName, section);
   }
 }
 
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
deleted file mode 100644
index d87d192..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ /dev/null
@@ -1,429 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-      background-color: var(--view-background-color);
-    }
-    .hidden {
-      display: none;
-    }
-    gr-patch-range-select {
-      display: block;
-    }
-    gr-diff {
-      border: none;
-    }
-    .stickyHeader {
-      background-color: var(--view-background-color);
-      position: sticky;
-      top: 0;
-      /* TODO(dhruvsri): This is required only because of 'position:relative' in
-         <gr-diff-highlight> (which could maybe be removed??). */
-      z-index: 1;
-      box-shadow: var(--elevation-level-1);
-      /* This is just for giving the box-shadow some space. */
-      margin-bottom: 2px;
-    }
-    header,
-    .subHeader {
-      align-items: center;
-      display: flex;
-      justify-content: space-between;
-    }
-    header {
-      padding: var(--spacing-s) var(--spacing-xl);
-      border-bottom: 1px solid var(--border-color);
-    }
-    .changeNumberColon {
-      color: transparent;
-    }
-    .headerSubject {
-      margin-right: var(--spacing-m);
-      font-weight: var(--font-weight-bold);
-    }
-    .patchRangeLeft {
-      align-items: center;
-      display: flex;
-    }
-    .navLink:not([href]) {
-      color: var(--deemphasized-text-color);
-    }
-    .navLinks {
-      align-items: center;
-      display: flex;
-      white-space: nowrap;
-    }
-    .navLink {
-      padding: 0 var(--spacing-xs);
-    }
-    .reviewed {
-      display: inline-block;
-      margin: 0 var(--spacing-xs);
-      vertical-align: top;
-      position: relative;
-      top: 8px;
-    }
-    .jumpToFileContainer {
-      display: inline-block;
-      word-break: break-all;
-    }
-    .mobile {
-      display: none;
-    }
-    gr-button {
-      padding: var(--spacing-s) 0;
-      text-decoration: none;
-    }
-    .loading {
-      color: var(--deemphasized-text-color);
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h1);
-      font-weight: var(--font-weight-h1);
-      line-height: var(--line-height-h1);
-      height: 100%;
-      padding: var(--spacing-l);
-      text-align: center;
-    }
-    .subHeader {
-      background-color: var(--background-color-secondary);
-      flex-wrap: wrap;
-      padding: 0 var(--spacing-l);
-    }
-    .prefsButton {
-      text-align: right;
-    }
-    .editMode .hideOnEdit {
-      display: none;
-    }
-    .blameLoader,
-    .fileNum {
-      display: none;
-    }
-    .blameLoader.show,
-    .fileNum.show,
-    .download,
-    .preferences,
-    .rightControls {
-      align-items: center;
-      display: flex;
-    }
-    .diffModeSelector,
-    .editButton {
-      align-items: center;
-      display: flex;
-    }
-    .diffModeSelector span,
-    .editButton span {
-      margin-right: var(--spacing-xs);
-    }
-    .diffModeSelector.hide,
-    .separator.hide {
-      display: none;
-    }
-    .editButtona a {
-      text-decoration: none;
-    }
-    @media screen and (max-width: 50em) {
-      header {
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      .dash {
-        display: none;
-      }
-      .desktop {
-        display: none;
-      }
-      .fileNav {
-        align-items: flex-start;
-        display: flex;
-        margin: 0 var(--spacing-xs);
-      }
-      .fullFileName {
-        display: block;
-        font-style: italic;
-        min-width: 50%;
-        padding: 0 var(--spacing-xxs);
-        text-align: center;
-        width: 100%;
-        word-wrap: break-word;
-      }
-      .reviewed {
-        vertical-align: -1px;
-      }
-      .mobileNavLink {
-        color: var(--primary-text-color);
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h2);
-        font-weight: var(--font-weight-h2);
-        line-height: var(--line-height-h2);
-        text-decoration: none;
-      }
-      .mobileNavLink:not([href]) {
-        color: var(--deemphasized-text-color);
-      }
-      .jumpToFileContainer {
-        display: block;
-        width: 100%;
-        word-break: break-all;
-      }
-      gr-dropdown-list {
-        width: 100%;
-        --gr-select-style: {
-          display: block;
-          width: 100%;
-        }
-        --native-select-style: {
-          width: 100%;
-        }
-      }
-    }
-    :host(.hideComments) {
-      --gr-comment-thread-display: none;
-    }
-  </style>
-  <div class$="stickyHeader [[_computeContainerClass(_editMode)]]">
-    <h1 class="assistive-tech-only">
-      Diff of [[_computeTruncatedPath(_path)]]
-    </h1>
-    <header>
-      <div>
-        <a
-          href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]"
-          >[[_changeNum]]</a
-        ><!--
-       --><span class="changeNumberColon">:</span>
-        <span class="headerSubject">[[_change.subject]]</span>
-        <input
-          id="reviewed"
-          class="reviewed hideOnEdit"
-          type="checkbox"
-          on-change="_handleReviewedChange"
-          hidden$="[[!_loggedIn]]"
-          hidden=""
-          title="Toggle reviewed status of file"
-          aria-label="file reviewed"
-        /><!--
-       -->
-        <div class="jumpToFileContainer">
-          <gr-dropdown-list
-            id="dropdown"
-            value="[[_path]]"
-            on-value-change="_handleFileChange"
-            items="[[_formattedFiles]]"
-            initial-count="75"
-            show-copy-for-trigger-text
-          >
-          </gr-dropdown-list>
-        </div>
-      </div>
-      <div class="navLinks desktop">
-        <span
-          class$="fileNum [[_computeFileNumClass(_fileNum, _formattedFiles)]]"
-        >
-          File [[_fileNum]] of [[_formattedFiles.length]]
-          <span class="separator"></span>
-        </span>
-        <a
-          class="navLink"
-          title="[[createTitle(Shortcut.PREV_FILE,
-                    ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1)]]"
-        >
-          Prev</a
-        >
-        <span class="separator"></span>
-        <a
-          class="navLink"
-          title="[[createTitle(Shortcut.UP_TO_CHANGE,
-                ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]"
-        >
-          Up</a
-        >
-        <span class="separator"></span>
-        <a
-          class="navLink"
-          title="[[createTitle(Shortcut.NEXT_FILE,
-                ShortcutSection.NAVIGATION)]]"
-          href$="[[_computeNavLinkURL(_change, _path, _fileList, 1)]]"
-        >
-          Next</a
-        >
-      </div>
-    </header>
-    <div class="subHeader">
-      <div class="patchRangeLeft">
-        <gr-patch-range-select
-          id="rangeSelect"
-          change-num="[[_changeNum]]"
-          change-comments="[[_changeComments]]"
-          patch-num="[[_patchRange.patchNum]]"
-          base-patch-num="[[_patchRange.basePatchNum]]"
-          files-weblinks="[[_filesWeblinks]]"
-          available-patches="[[_allPatchSets]]"
-          revisions="[[_change.revisions]]"
-          revision-info="[[_revisionInfo]]"
-          on-patch-range-change="_handlePatchChange"
-        >
-        </gr-patch-range-select>
-        <span class="download desktop">
-          <span class="separator"></span>
-          <gr-dropdown
-            link=""
-            down-arrow=""
-            items="[[_computeDownloadDropdownLinks(_change.project, _changeNum, _patchRange, _path, _diff)]]"
-            horizontal-align="left"
-          >
-            <span class="downloadTitle"> Download </span>
-          </gr-dropdown>
-        </span>
-      </div>
-      <div class="rightControls">
-        <span
-          class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff, _path)]]"
-        >
-          <gr-button
-            link=""
-            id="toggleBlame"
-            title="[[createTitle(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS)]]"
-            disabled="[[_isBlameLoading]]"
-            on-click="_toggleBlame"
-            >[[_computeBlameToggleLabel(_isBlameLoaded,
-            _isBlameLoading)]]</gr-button
-          >
-        </span>
-        <template
-          is="dom-if"
-          if="[[_computeCanEdit(_loggedIn, _editWeblinks, _change.*)]]"
-        >
-          <span class="separator"></span>
-          <span class="editButton">
-            <gr-button
-              link=""
-              title="Edit current file"
-              on-click="_goToEditFile"
-              >edit</gr-button
-            >
-          </span>
-        </template>
-        <template is="dom-if" if="[[_computeShowEditLinks(_editWeblinks)]]">
-          <span class="separator"></span>
-          <template is="dom-repeat" items="[[_editWeblinks]]" as="weblink">
-            <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
-          </template>
-        </template>
-        <span class="separator"></span>
-        <div class$="diffModeSelector [[_computeModeSelectHideClass(_diff)]]">
-          <span>Diff view:</span>
-          <gr-diff-mode-selector
-            id="modeSelect"
-            save-on-change="[[_loggedIn]]"
-            show-tooltip-below=""
-          ></gr-diff-mode-selector>
-        </div>
-        <span
-          id="diffPrefsContainer"
-          hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]"
-          hidden=""
-        >
-          <span class="preferences desktop">
-            <gr-tooltip-content
-              has-tooltip=""
-              position-below=""
-              title="Diff preferences"
-            >
-              <gr-button link="" class="prefsButton" on-click="_handlePrefsTap"
-                ><iron-icon icon="gr-icons:settings"></iron-icon
-              ></gr-button>
-            </gr-tooltip-content>
-          </span>
-        </span>
-        <gr-endpoint-decorator name="annotation-toggler">
-          <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>
-        </gr-endpoint-decorator>
-      </div>
-    </div>
-    <div class="fileNav mobile">
-      <a
-        class="mobileNavLink"
-        href$="[[_computeNavLinkURL(_change, _path, _fileList, -1)]]"
-      >
-        &lt;</a
-      >
-      <div class="fullFileName mobile">[[_computeDisplayPath(_path)]]</div>
-      <a
-        class="mobileNavLink"
-        href$="[[_computeNavLinkURL(_change, _path, _fileList, 1)]]"
-      >
-        &gt;</a
-      >
-    </div>
-  </div>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <h2 class="assistive-tech-only">Diff view</h2>
-  <gr-diff-host
-    id="diffHost"
-    hidden=""
-    hidden$="[[_loading]]"
-    is-image-diff="{{_isImageDiff}}"
-    edit-weblinks="{{_editWeblinks}}"
-    files-weblinks="{{_filesWeblinks}}"
-    diff="{{_diff}}"
-    change-num="[[_changeNum]]"
-    change="[[_change]]"
-    commit-range="[[_commitRange]]"
-    patch-range="[[_patchRange]]"
-    file="[[_file]]"
-    path="[[_path]]"
-    prefs="[[_prefs]]"
-    project-name="[[_change.project]]"
-    is-blame-loaded="{{_isBlameLoaded}}"
-    on-comment-anchor-tap="_onLineSelected"
-    on-line-selected="_onLineSelected"
-  >
-  </gr-diff-host>
-  <gr-apply-fix-dialog
-    id="applyFixDialog"
-    prefs="[[_prefs]]"
-    change="[[_change]]"
-    change-num="[[_changeNum]]"
-  >
-  </gr-apply-fix-dialog>
-  <gr-diff-preferences-dialog
-    id="diffPreferencesDialog"
-    diff-prefs="{{_prefs}}"
-    on-reload-diff-preference="_handleReloadingDiffPreference"
-  >
-  </gr-diff-preferences-dialog>
-`;
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
deleted file mode 100644
index 5c04ab0..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ /dev/null
@@ -1,2070 +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-diff-view.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {ChangeStatus, DiffViewMode} from '../../../constants/constants.js';
-import {stubRestApi, stubUsers} from '../../../test/test-utils.js';
-import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
-import {GerritView} from '../../../services/router/router-model.js';
-import {
-  createChange,
-  createRevisions,
-  createComment,
-} from '../../../test/test-data-generators.js';
-import {EditPatchSetNum} from '../../../types/common.js';
-import {CursorMoveResult} from '../../../api/core.js';
-import {EventType} from '../../../types/events.js';
-import {_testOnly_setState} from '../../../services/browser/browser-model.js';
-
-const basicFixture = fixtureFromElement('gr-diff-view');
-
-suite('gr-diff-view tests', () => {
-  suite('basic tests', () => {
-    let element;
-    let clock;
-    let diffCommentsStub;
-
-    const PARENT = 'PARENT';
-
-    function getFilesFromFileList(fileList) {
-      const changeFilesByPath = fileList.reduce((files, path) => {
-        files[path] = {};
-        return files;
-      }, {});
-      return {
-        sortedFileList: fileList,
-        changeFilesByPath,
-      };
-    }
-
-    let getDiffChangeDetailStub;
-    setup(async () => {
-      clock = sinon.useFakeTimers();
-      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());
-      diffCommentsStub = stubRestApi('getDiffComments');
-      diffCommentsStub.returns(Promise.resolve({}));
-      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
-      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      stubRestApi('getPortedComments').returns(Promise.resolve({}));
-
-      element = basicFixture.instantiate();
-      element._changeNum = '42';
-      element._path = 'some/path.txt';
-      element._change = {};
-      element._diff = {content: []};
-      element._patchRange = {
-        patchNum: 77,
-        basePatchNum: 'PARENT',
-      };
-      element._changeComments = new ChangeComments({'/COMMIT_MSG': [
-        {
-          ...createComment(),
-          id: 'c1',
-          line: 10,
-          patch_set: 2,
-          path: '/COMMIT_MSG',
-        }, {
-          ...createComment(),
-          id: 'c3',
-          line: 10,
-          patch_set: 'PARENT',
-          path: '/COMMIT_MSG',
-        },
-      ]});
-      await flush();
-    });
-
-    teardown(() => {
-      clock.restore();
-      sinon.restore();
-    });
-
-    test('params change triggers diffViewDisplayed()', () => {
-      sinon.stub(element.reporting, 'diffViewDisplayed');
-      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-      sinon.stub(element, '_initPatchRange');
-      sinon.stub(element, '_getFiles');
-      sinon.spy(element, '_paramsChanged');
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: 2,
-        basePatchNum: 1,
-        path: '/COMMIT_MSG',
-      };
-      element._path = '/COMMIT_MSG';
-      element._patchRange = {};
-      return element._paramsChanged.returnValues[0].then(() => {
-        assert.isTrue(element.reporting.diffViewDisplayed.calledOnce);
-      });
-    });
-
-    suite('comment route', () => {
-      let initLineOfInterestAndCursorStub; let getUrlStub; let replaceStateStub;
-      setup(() => {
-        initLineOfInterestAndCursorStub =
-        sinon.stub(element, '_initLineOfInterestAndCursor');
-        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),
-        }));
-      });
-
-      test('comment url resolves to comment.patch_set vs latest', () => {
-        diffCommentsStub.returns(Promise.resolve({
-          '/COMMIT_MSG': [
-            {
-              ...createComment(),
-              id: 'c1',
-              line: 10,
-              patch_set: 2,
-              path: '/COMMIT_MSG',
-            }, {
-              ...createComment(),
-              id: 'c3',
-              line: 10,
-              patch_set: 'PARENT',
-              path: '/COMMIT_MSG',
-            },
-          ]}));
-        element.params = {
-          view: GerritNav.View.DIFF,
-          changeNum: '42',
-          commentLink: true,
-          commentId: 'c1',
-          path: 'abcd',
-        };
-        element._change = {
-          ...createChange(),
-          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));
-        });
-      });
-    });
-
-    test('params change causes blame to load if it was set to true', () => {
-      // Blame loads for subsequent files if it was loaded for one file
-      element._isBlameLoaded = true;
-      sinon.stub(element.reporting, 'diffViewDisplayed');
-      sinon.stub(element, '_loadBlame');
-      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-      sinon.spy(element, '_paramsChanged');
-      sinon.stub(element, '_initPatchRange');
-      sinon.stub(element, '_getFiles');
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: 2,
-        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);
-      });
-    });
-
-    test('unchanged diff X vs latest from comment links navigates to base vs X'
-        , () => {
-          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          diffCommentsStub.returns(Promise.resolve({
-            '/COMMIT_MSG': [
-              {
-                ...createComment(),
-                id: 'c1',
-                line: 10,
-                patch_set: 2,
-                path: '/COMMIT_MSG',
-              }, {
-                ...createComment(),
-                id: 'c3',
-                line: 10,
-                patch_set: 'PARENT',
-                path: '/COMMIT_MSG',
-              },
-            ]}));
-          sinon.stub(element.reporting, 'diffViewDisplayed');
-          sinon.stub(element, '_loadBlame');
-          sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-          sinon.stub(element, '_isFileUnchanged').returns(true);
-          sinon.spy(element, '_paramsChanged');
-          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
-            ...createChange(),
-            revisions: createRevisions(11),
-          }));
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '42',
-            path: '/COMMIT_MSG',
-            commentLink: true,
-            commentId: 'c1',
-          };
-          element._change = {
-            ...createChange(),
-            revisions: createRevisions(11),
-          };
-          return element._paramsChanged.returnValues[0].then(() => {
-            assert.isTrue(diffNavStub.lastCall.calledWithExactly(
-                element._change, '/COMMIT_MSG', 2, 'PARENT', 10));
-          });
-        });
-
-    test('unchanged diff Base vs latest from comment does not navigate'
-        , () => {
-          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          diffCommentsStub.returns(Promise.resolve({
-            '/COMMIT_MSG': [
-              {
-                ...createComment(),
-                id: 'c1',
-                line: 10,
-                patch_set: 2,
-                path: '/COMMIT_MSG',
-              }, {
-                ...createComment(),
-                id: 'c3',
-                line: 10,
-                patch_set: 'PARENT',
-                path: '/COMMIT_MSG',
-              },
-            ]}));
-          sinon.stub(element.reporting, 'diffViewDisplayed');
-          sinon.stub(element, '_loadBlame');
-          sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-          sinon.stub(element, '_isFileUnchanged').returns(true);
-          sinon.spy(element, '_paramsChanged');
-          sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
-            ...createChange(),
-            revisions: createRevisions(11),
-          }));
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '42',
-            path: '/COMMIT_MSG',
-            commentLink: true,
-            commentId: 'c3',
-          };
-          element._change = {
-            ...createChange(),
-            revisions: createRevisions(11),
-          };
-          return element._paramsChanged.returnValues[0].then(() => {
-            assert.isFalse(diffNavStub.called);
-          });
-        });
-
-    test('_isFileUnchanged', () => {
-      let diff = {
-        content: [
-          {a: 'abcd', ab: 'ef'},
-          {b: 'ancd', a: 'xx'},
-        ],
-      };
-      assert.equal(element._isFileUnchanged(diff), false);
-      diff = {
-        content: [
-          {ab: 'abcd'},
-          {ab: 'ancd'},
-        ],
-      };
-      assert.equal(element._isFileUnchanged(diff), true);
-      diff = {
-        content: [
-          {a: 'abcd', ab: 'ef', common: true},
-          {b: 'ancd', ab: 'xx'},
-        ],
-      };
-      assert.equal(element._isFileUnchanged(diff), false);
-      diff = {
-        content: [
-          {a: 'abcd', ab: 'ef', common: true},
-          {b: 'ancd', ab: 'xx', common: true},
-        ],
-      };
-      assert.equal(element._isFileUnchanged(diff), true);
-    });
-
-    test('change detail is not rerequested if changeNum doesnt change',
-        async () => {
-          const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-          assert.isFalse(getDiffChangeDetailStub.called);
-          sinon.stub(element.reporting, 'diffViewDisplayed');
-          sinon.stub(element, '_loadBlame');
-          sinon.stub(element, '_pathChanged');
-          sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-          sinon.spy(element, '_paramsChanged');
-          element._change = undefined;
-          getDiffChangeDetailStub.returns(
-              Promise.resolve({
-                ...createChange(),
-                revisions: createRevisions(11),
-              }));
-          element._patchRange = {
-            patchNum: 2,
-            basePatchNum: 1,
-          };
-          sinon.stub(element, '_isFileUnchanged').returns(false);
-
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '42',
-            project: 'p',
-            commentId: 'c1',
-            commentLink: true,
-          };
-          await element._paramsChanged.returnValues[0];
-
-          assert.equal(getDiffChangeDetailStub.callCount, 1);
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '42',
-            project: 'p',
-            commentId: 'c1',
-            commentLink: true,
-          };
-          await element._paramsChanged.returnValues[0];
-
-          assert.equal(getDiffChangeDetailStub.callCount, 1);
-          element.params = {
-            view: GerritNav.View.DIFF,
-            changeNum: '43',
-            project: 'p',
-            commentId: 'c1',
-            commentLink: true,
-          };
-          await element._paramsChanged.returnValues[0];
-
-          // change page is recreated now
-          assert.equal(dispatchEventStub.lastCall.args[0].type,
-              EventType.RECREATE_DIFF_VIEW);
-        });
-
-    test('diff toast to go to latest is shown and not base', async () => {
-      diffCommentsStub.returns(Promise.resolve({
-        '/COMMIT_MSG': [
-          {
-            ...createComment(),
-            id: 'c1',
-            line: 10,
-            patch_set: 2,
-            path: '/COMMIT_MSG',
-          }, {
-            ...createComment(),
-            id: 'c3',
-            line: 10,
-            patch_set: 'PARENT',
-            path: '/COMMIT_MSG',
-          },
-        ]}));
-      sinon.stub(element.reporting, 'diffViewDisplayed');
-      sinon.stub(element, '_loadBlame');
-      sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
-      sinon.spy(element, '_paramsChanged');
-      element._change = undefined;
-      getDiffChangeDetailStub.returns(
-          Promise.resolve({
-            ...createChange(),
-            revisions: createRevisions(11),
-          }));
-      element._patchRange = {
-        patchNum: 2,
-        basePatchNum: 1,
-      };
-      sinon.stub(element, '_isFileUnchanged').returns(false);
-      const toastStub =
-          sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        project: 'p',
-        commentId: 'c1',
-        commentLink: true,
-      };
-      await element._paramsChanged.returnValues[0];
-      assert.isTrue(toastStub.called);
-    });
-
-    test('toggle left diff with a hotkey', () => {
-      const toggleLeftDiffStub = sinon.stub(
-          element.$.diffHost, 'toggleLeftDiff');
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
-      assert.isTrue(toggleLeftDiffStub.calledOnce);
-    });
-
-    test('keyboard shortcuts', () => {
-      element._changeNum = '42';
-      _testOnly_setState({screenWidth: 0});
-      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;
-
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWith(element._change),
-          'Should navigate to /c/42/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
-          10, PARENT), 'Should navigate to /c/42/10/wheatley.md');
-      element._path = 'wheatley.md';
-      assert.equal(element.changeViewState.selectedFileIndex, 2);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
-          10, PARENT), 'Should navigate to /c/42/10/glados.txt');
-      element._path = 'glados.txt';
-      assert.equal(element.changeViewState.selectedFileIndex, 1);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', 10,
-          PARENT), 'Should navigate to /c/42/10/chell.go');
-      element._path = 'chell.go';
-      assert.equal(element.changeViewState.selectedFileIndex, 0);
-      assert.isTrue(element._loading);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(changeNavStub.lastCall.calledWith(element._change),
-          'Should navigate to /c/42/');
-      assert.equal(element.changeViewState.selectedFileIndex, 0);
-      assert.isTrue(element._loading);
-
-      const showPrefsStub =
-          sinon.stub(element.$.diffPreferencesDialog, 'open').callsFake(
-              () => Promise.resolve());
-
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert(showPrefsStub.calledOnce);
-
-      let scrollStub = sinon.stub(element.cursor, 'moveToNextChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sinon.stub(element.cursor, 'moveToPreviousChunk');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sinon.stub(element.cursor, 'moveToNextCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
-      assert(scrollStub.calledOnce);
-
-      scrollStub = sinon.stub(element.cursor,
-          'moveToPreviousCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'P');
-      assert(scrollStub.calledOnce);
-
-      const computeContainerClassStub = sinon.stub(element.$.diffHost.$.diff,
-          '_computeContainerClass');
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, DiffViewMode.SIDE_BY_SIDE, true));
-
-      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
-      assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, DiffViewMode.SIDE_BY_SIDE, false));
-
-      // Note that stubbing _setReviewed means that the value of the
-      // `element.$.reviewed` checkbox is not flipped.
-      sinon.stub(element, '_setReviewed');
-      sinon.spy(element, '_handleToggleFileReviewed');
-      element.$.reviewed.checked = false;
-      assert.isFalse(element._handleToggleFileReviewed.called);
-      assert.isFalse(element._setReviewed.called);
-
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
-      assert.isTrue(element._setReviewed.calledOnce);
-      assert.equal(element._setReviewed.lastCall.args[0], true);
-
-      // Handler is throttled, so another key press within 500 ms is ignored.
-      clock.tick(100);
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
-      assert.isTrue(element._setReviewed.calledOnce);
-
-      clock.tick(1000);
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
-      assert.isTrue(element._handleToggleFileReviewed.calledTwice);
-      assert.isTrue(element._setReviewed.calledTwice);
-    });
-
-    test('moveToNextCommentThread navigates to next file', () => {
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      const diffChangeStub = sinon.stub(element, '_navigateToChange');
-      sinon.stub(element.cursor, 'isAtEnd').returns(true);
-      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, null, 'N');
-      flush();
-      assert.isTrue(diffNavStub.calledWithExactly(
-          element._change, 'wheatley.md', 10, PARENT, 21));
-
-      element._path = 'wheatley.md'; // navigated to next file
-
-      MockInteractions.pressAndReleaseKeyOn(element, 78, null, '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, null, 'X');
-      flush();
-      assert.isTrue(toggleStub.called);
-    });
-
-    test('diff against base', () => {
-      element._patchRange = {
-        basePatchNum: 5,
-        patchNum: 10,
-      };
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._handleDiffAgainstBase(new CustomEvent(''));
-      const args = diffNavStub.getCall(0).args;
-      assert.equal(args[2], 10);
-      assert.isNotOk(args[3]);
-    });
-
-    test('diff against latest', () => {
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(12),
-      };
-      element._patchRange = {
-        basePatchNum: 5,
-        patchNum: 10,
-      };
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._handleDiffAgainstLatest(new CustomEvent(''));
-      const args = diffNavStub.getCall(0).args;
-      assert.equal(args[2], 12);
-      assert.equal(args[3], 5);
-    });
-
-    test('_handleDiffBaseAgainstLeft', () => {
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(10),
-      };
-      element._patchRange = {
-        patchNum: 3,
-        basePatchNum: 1,
-      };
-      element.params = {};
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._handleDiffBaseAgainstLeft(new CustomEvent(''));
-      assert(diffNavStub.called);
-      const args = diffNavStub.getCall(0).args;
-      assert.equal(args[2], 1);
-      assert.equal(args[3], 'PARENT');
-      assert.isNotOk(args[4]);
-    });
-
-    test('_handleDiffBaseAgainstLeft when initially navigating to a comment',
-        () => {
-          element._change = {
-            ...createChange(),
-            revisions: createRevisions(10),
-          };
-          element._patchRange = {
-            patchNum: 3,
-            basePatchNum: 1,
-          };
-          sinon.stub(element, '_paramsChanged');
-          element.params = {commentLink: true, view: GerritView.DIFF};
-          element._focusLineNum = 10;
-          const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-          element._handleDiffBaseAgainstLeft(new CustomEvent(''));
-          assert(diffNavStub.called);
-          const args = diffNavStub.getCall(0).args;
-          assert.equal(args[2], 1);
-          assert.equal(args[3], 'PARENT');
-          assert.equal(args[4], 10);
-        });
-
-    test('_handleDiffRightAgainstLatest', () => {
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(10),
-      };
-      element._patchRange = {
-        basePatchNum: 1,
-        patchNum: 3,
-      };
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._handleDiffRightAgainstLatest(new CustomEvent(''));
-      assert(diffNavStub.called);
-      const args = diffNavStub.getCall(0).args;
-      assert.equal(args[2], 10);
-      assert.equal(args[3], 3);
-    });
-
-    test('_handleDiffBaseAgainstLatest', () => {
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(10),
-      };
-      element._patchRange = {
-        basePatchNum: 1,
-        patchNum: 3,
-      };
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._handleDiffBaseAgainstLatest(new CustomEvent(''));
-      assert(diffNavStub.called);
-      const args = diffNavStub.getCall(0).args;
-      assert.equal(args[2], 10);
-      assert.isNotOk(args[3]);
-    });
-
-    test('A fires an error event when not logged in', async () => {
-      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
-      const loggedInErrorSpy = sinon.spy();
-      element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      await flush();
-      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-        'should only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-      assert.isTrue(loggedInErrorSpy.called);
-    });
-
-    test('A navigates to change with logged in', async () => {
-      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');
-      await flush();
-      assert.isTrue(element.changeViewState.showReplyDialog);
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
-          5), 'Should navigate to /c/42/5..10');
-      assert.isFalse(loggedInErrorSpy.called);
-    });
-
-    test('A navigates to change with old patch number with logged in',
-        async () => {
-          element._changeNum = '42';
-          element._patchRange = {
-            basePatchNum: PARENT,
-            patchNum: 1,
-          };
-          element._change = {
-            _number: 42,
-            revisions: {
-              a: {_number: 1, commit: {parents: []}},
-              b: {_number: 2, commit: {parents: []}},
-            },
-          };
-          const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-          sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-          const loggedInErrorSpy = sinon.spy();
-          element.addEventListener('show-auth-required', loggedInErrorSpy);
-          MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-          await flush();
-          assert.isTrue(element.changeViewState.showReplyDialog);
-          assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
-              PARENT), 'Should navigate to /c/42/1');
-          assert.isFalse(loggedInErrorSpy.called);
-        });
-
-    test('keyboard shortcuts with patch range', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 5,
-        patchNum: 10,
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 10, commit: {parents: []}},
-          b: {_number: 5, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
-          5), 'Should navigate to /c/42/5..10');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(element._change,
-          'wheatley.md', 10, 5, 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, undefined),
-      'Should navigate to /c/42/5..10/glados.txt');
-      element._path = 'glados.txt';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(diffNavStub.lastCall.calledWithExactly(
-          element._change,
-          'chell.go',
-          10,
-          5,
-          undefined),
-      'Should navigate to /c/42/5..10/chell.go');
-      element._path = 'chell.go';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert.isTrue(element._loading);
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
-          5),
-      'Should navigate to /c/42/5..10');
-
-      assert.isUndefined(element.changeViewState.showDownloadDialog);
-      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
-      assert.isTrue(element.changeViewState.showDownloadDialog);
-    });
-
-    test('keyboard shortcuts with old patch number', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1,
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      element._files = getFilesFromFileList(
-          ['chell.go', 'glados.txt', 'wheatley.md']);
-      element._path = 'glados.txt';
-
-      const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
-      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-
-      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, 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, undefined),
-      'Should navigate to /c/42/1/glados.txt');
-      element._path = 'glados.txt';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(diffNavStub.lastCall.calledWithExactly(
-          element._change,
-          'chell.go',
-          1,
-          PARENT,
-          undefined), 'Should navigate to /c/42/1/chell.go');
-      element._path = 'chell.go';
-
-      changeNavStub.reset();
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
-          PARENT), 'Should navigate to /c/42/1');
-      assert.isTrue(changeNavStub.calledOnce);
-    });
-
-    test('edit should redirect to edit page', async () => {
-      element._loggedIn = true;
-      element._path = 't.txt';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1,
-      };
-      element._change = {
-        _number: 42,
-        project: 'gerrit',
-        status: ChangeStatus.NEW,
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      await flush();
-      const editBtn = element.shadowRoot
-          .querySelector('.editButton gr-button');
-      assert.isTrue(!!editBtn);
-      MockInteractions.tap(editBtn);
-      assert.isTrue(redirectStub.called);
-      assert.isTrue(redirectStub.lastCall.calledWithExactly(
-          GerritNav.getEditUrlForDiff(
-              element._change,
-              element._path,
-              element._patchRange.patchNum
-          )));
-    });
-
-    test('edit should redirect to edit page with line number', async () => {
-      const lineNumber = 42;
-      element._loggedIn = true;
-      element._path = 't.txt';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1,
-      };
-      element._change = {
-        _number: 42,
-        project: 'gerrit',
-        status: ChangeStatus.NEW,
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      sinon.stub(element.cursor, 'getAddress')
-          .returns({number: lineNumber, isLeftSide: false});
-      const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      await flush();
-      const editBtn = element.shadowRoot
-          .querySelector('.editButton gr-button');
-      assert.isTrue(!!editBtn);
-      MockInteractions.tap(editBtn);
-      assert.isTrue(redirectStub.called);
-      assert.isTrue(redirectStub.lastCall.calledWithExactly(
-          GerritNav.getEditUrlForDiff(
-              element._change,
-              element._path,
-              element._patchRange.patchNum,
-              lineNumber
-          )));
-    });
-
-    function isEditVisibile({loggedIn, changeStatus}) {
-      return new Promise(resolve => {
-        element._loggedIn = loggedIn;
-        element._path = 't.txt';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 1,
-        };
-        element._change = {
-          _number: 42,
-          status: changeStatus,
-          revisions: {
-            a: {_number: 1, commit: {parents: []}},
-            b: {_number: 2, commit: {parents: []}},
-          },
-        };
-        flush(() => {
-          const editBtn = element.shadowRoot
-              .querySelector('.editButton gr-button');
-          resolve(!!editBtn);
-        });
-      });
-    }
-
-    test('edit visible only when logged and status NEW', async () => {
-      for (const changeStatus of Object.keys(ChangeStatus)) {
-        assert.isFalse(await isEditVisibile({loggedIn: false, changeStatus}),
-            `loggedIn: false, changeStatus: ${changeStatus}`);
-
-        if (changeStatus !== ChangeStatus.NEW) {
-          assert.isFalse(await isEditVisibile({loggedIn: true, changeStatus}),
-              `loggedIn: true, changeStatus: ${changeStatus}`);
-        } else {
-          assert.isTrue(await isEditVisibile({loggedIn: true, changeStatus}),
-              `loggedIn: true, changeStatus: ${changeStatus}`);
-        }
-      }
-    });
-
-    test('edit visible when logged and status NEW', async () => {
-      assert.isTrue(await isEditVisibile(
-          {loggedIn: true, changeStatus: ChangeStatus.NEW}));
-    });
-
-    test('edit hidden when logged and status ABANDONED', async () => {
-      assert.isFalse(await isEditVisibile(
-          {loggedIn: true, changeStatus: ChangeStatus.ABANDONED}));
-    });
-
-    test('edit hidden when logged and status MERGED', async () => {
-      assert.isFalse(await isEditVisibile(
-          {loggedIn: true, changeStatus: ChangeStatus.MERGED}));
-    });
-
-    suite('diff prefs hidden', () => {
-      test('when no prefs or logged out', () => {
-        element._prefs = undefined;
-        element.disableDiffPrefs = false;
-        element._loggedIn = false;
-        flush();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = true;
-        flush();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = false;
-        element._prefs = {font_size: '12'};
-        flush();
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-        element._loggedIn = true;
-        element._prefs = {font_size: '12'};
-        flush();
-        assert.isFalse(element.$.diffPrefsContainer.hidden);
-      });
-    });
-
-    test('prefsButton opens gr-diff-preferences', () => {
-      const handlePrefsTapSpy = sinon.spy(element, '_handlePrefsTap');
-      const overlayOpenStub = sinon.stub(element.$.diffPreferencesDialog,
-          'open');
-      const prefsButton =
-          element.root.querySelector('.prefsButton');
-
-      MockInteractions.tap(prefsButton);
-
-      assert.isTrue(handlePrefsTapSpy.called);
-      assert.isTrue(overlayOpenStub.called);
-    });
-
-    suite('url params', () => {
-      setup(() => {
-        sinon.stub(element, '_getFiles');
-        sinon.stub(
-            GerritNav,
-            'getUrlForDiff')
-            .callsFake((c, p, pn, bpn) => `${c._number}-${p}-${pn}-${bpn}`);
-        sinon.stub(
-            GerritNav
-            , 'getUrlForChange')
-            .callsFake((c, pn, bpn) => `${c._number}-${pn}-${bpn}`);
-      });
-
-      test('_formattedFiles', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: PARENT,
-          patchNum: 10,
-        };
-        element._change = {_number: 42};
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md',
-              '/COMMIT_MSG', '/MERGE_LIST']);
-        element._path = 'glados.txt';
-        const expectedFormattedFiles = [
-          {
-            text: 'chell.go',
-            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',
-            },
-          },
-        ];
-
-        assert.deepEqual(element._formattedFiles, expectedFormattedFiles);
-        assert.equal(element._formattedFiles[1].value, element._path);
-      });
-
-      test('prev/up/next links', () => {
-        element._changeNum = '42';
-        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';
-        flush();
-        const linkEls = element.root.querySelectorAll('.navLink');
-        assert.equal(linkEls.length, 3);
-        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'),
-            '42-wheatley.md-10-PARENT');
-        element._path = 'wheatley.md';
-        flush();
-        assert.equal(linkEls[0].getAttribute('href'),
-            '42-glados.txt-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'), '42-undefined-undefined');
-        element._path = 'chell.go';
-        flush();
-        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');
-        element._path = 'not_a_real_file';
-        flush();
-        assert.equal(linkEls[0].getAttribute('href'),
-            '42-wheatley.md-10-PARENT');
-        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
-        assert.equal(linkEls[2].getAttribute('href'), '42-chell.go-10-PARENT');
-      });
-
-      test('prev/up/next links with patch range', () => {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: 5,
-          patchNum: 10,
-        };
-        element._change = {
-          _number: 42,
-          revisions: {
-            a: {_number: 5, commit: {parents: []}},
-            b: {_number: 10, commit: {parents: []}},
-          },
-        };
-        element._files = getFilesFromFileList(
-            ['chell.go', 'glados.txt', 'wheatley.md']);
-        element._path = 'glados.txt';
-        flush();
-        const linkEls = element.root.querySelectorAll('.navLink');
-        assert.equal(linkEls.length, 3);
-        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
-        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
-        element._path = 'wheatley.md';
-        flush();
-        assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
-        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
-        assert.equal(linkEls[2].getAttribute('href'), '42-10-5');
-        element._path = 'chell.go';
-        flush();
-        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');
-      });
-    });
-
-    test('_handlePatchChange calls navigateToDiff correctly', () => {
-      const navigateStub = sinon.stub(GerritNav, 'navigateToDiff');
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._path = 'path/to/file.txt';
-
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 3,
-      };
-
-      const detail = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-
-      element.$.rangeSelect.dispatchEvent(
-          new CustomEvent('patch-range-change', {detail, bubbles: false}));
-
-      assert(navigateStub.lastCall.calledWithExactly(element._change,
-          element._path, 1, 'PARENT'));
-    });
-
-    test('_prefs.manual_review is respected', () => {
-      const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
-          .callsFake(() => Promise.resolve());
-      const getReviewedStub = sinon.stub(element, '_getReviewedStatus')
-          .returns(false);
-
-      sinon.stub(element.$.diffHost, 'reload');
-      element._loggedIn = true;
-      element._prefs = {manual_review: true};
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: 2,
-        basePatchNum: 1,
-        path: '/COMMIT_MSG',
-      };
-      element._patchRange = {
-        patchNum: 2,
-        basePatchNum: 1,
-      };
-      flush();
-
-      assert.isFalse(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.called);
-
-      const oldCount = getReviewedStub.callCount;
-
-      element._prefs = {};
-      element._path = 'abcd';
-      flush();
-
-      assert.isTrue(saveReviewedStub.called);
-      assert.equal(getReviewedStub.callCount, oldCount);
-    });
-
-    test('file review status', () => {
-      const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
-          .callsFake(() => Promise.resolve());
-      sinon.stub(element.$.diffHost, 'reload');
-
-      element._loggedIn = true;
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: 2,
-        basePatchNum: 1,
-        path: '/COMMIT_MSG',
-      };
-      element._patchRange = {
-        patchNum: 2,
-        basePatchNum: 1,
-      };
-      element._path = 'abcd';
-      element._prefs = {};
-      flush();
-
-      const commitMsg = element.root.querySelector(
-          'input[type="checkbox"]');
-
-      assert.isTrue(commitMsg.checked);
-      MockInteractions.tap(commitMsg);
-      assert.isFalse(commitMsg.checked);
-      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(false));
-
-      MockInteractions.tap(commitMsg);
-      assert.isTrue(commitMsg.checked);
-      assert.isTrue(saveReviewedStub.lastCall.calledWithExactly(true));
-      const callCount = saveReviewedStub.callCount;
-
-      element.set('params.view', GerritNav.View.CHANGE);
-      flush();
-
-      // saveReviewedState observer observes params, but should not fire when
-      // view !== GerritNav.View.DIFF.
-      assert.equal(saveReviewedStub.callCount, callCount);
-    });
-
-    test('file review status with edit loaded', () => {
-      const saveReviewedStub = sinon.stub(element, '_saveReviewedState');
-
-      element._patchRange = {patchNum: EditPatchSetNum};
-      flush();
-
-      assert.isTrue(element._editMode);
-      element._setReviewed();
-      assert.isFalse(saveReviewedStub.called);
-    });
-
-    test('hash is determined from params', async () => {
-      sinon.stub(element.$.diffHost, 'reload');
-      sinon.stub(element, '_initLineOfInterestAndCursor');
-
-      element._loggedIn = true;
-      element.params = {
-        view: GerritNav.View.DIFF,
-        changeNum: '42',
-        patchNum: 2,
-        basePatchNum: 1,
-        path: '/COMMIT_MSG',
-        hash: 10,
-      };
-
-      await flush();
-      assert.isTrue(element._initLineOfInterestAndCursor.calledOnce);
-    });
-
-    test('diff mode selector correctly toggles the diff', () => {
-      const select = element.$.modeSelect;
-      const diffDisplay = element.$.diffHost;
-      element._userPrefs = {diff_view: DiffViewMode.SIDE_BY_SIDE};
-      _testOnly_setState({screenWidth: 0});
-
-      const userStub = stubUsers('updatePreferences');
-
-      flush();
-      // The mode selected in the view state reflects the selected option.
-      // assert.equal(element._userPrefs.diff_view, select.mode);
-
-      // The mode selected in the view state reflects the view rednered in the
-      // diff.
-      assert.equal(select.mode, diffDisplay.viewMode);
-
-      // We will simulate a user change of the selected mode.
-      element._handleToggleDiffMode();
-      assert.isTrue(userStub.calledWithExactly({
-        diff_view: DiffViewMode.UNIFIED}));
-    });
-
-    test('diff mode selector should be hidden for binary', async () => {
-      element._diff = {binary: true, content: []};
-
-      await flush();
-      const diffModeSelector = element.shadowRoot
-          .querySelector('.diffModeSelector');
-      assert.isTrue(diffModeSelector.classList.contains('hide'));
-    });
-
-    suite('_commitRange', () => {
-      const change = {
-        _number: 42,
-        revisions: {
-          'commit-sha-1': {
-            _number: 1,
-            commit: {
-              parents: [{commit: 'sha-1-parent'}],
-            },
-          },
-          'commit-sha-2': {_number: 2, commit: {parents: []}},
-          'commit-sha-3': {_number: 3, commit: {parents: []}},
-          'commit-sha-4': {_number: 4, commit: {parents: []}},
-          'commit-sha-5': {
-            _number: 5,
-            commit: {
-              parents: [{commit: 'sha-5-parent'}],
-            },
-          },
-        },
-      };
-      setup(() => {
-        sinon.stub(element.$.diffHost, 'reload');
-        sinon.stub(element, '_initCursor');
-        element._change = change;
-        sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
-            change));
-      });
-
-      test('uses the patchNum and basePatchNum ', async () => {
-        element.params = {
-          view: GerritNav.View.DIFF,
-          changeNum: '42',
-          patchNum: 4,
-          basePatchNum: 2,
-          path: '/COMMIT_MSG',
-        };
-        element._change = change;
-        await flush();
-        assert.deepEqual(element._commitRange, {
-          baseCommit: 'commit-sha-2',
-          commit: 'commit-sha-4',
-        });
-      });
-
-      test('uses the parent when there is no base patch num ', async () => {
-        element.params = {
-          view: GerritNav.View.DIFF,
-          changeNum: '42',
-          patchNum: 5,
-          path: '/COMMIT_MSG',
-        };
-        element._change = change;
-        await flush();
-        assert.deepEqual(element._commitRange, {
-          commit: 'commit-sha-5',
-          baseCommit: 'sha-5-parent',
-        });
-      });
-    });
-
-    test('_initCursor', () => {
-      assert.isNotOk(element.cursor.initialLineNumber);
-
-      // Does nothing when params specify no cursor address:
-      element._initCursor(false);
-      assert.isNotOk(element.cursor.initialLineNumber);
-
-      // Does nothing when params specify side but no number:
-      element._initCursor(true);
-      assert.isNotOk(element.cursor.initialLineNumber);
-
-      // Revision hash: specifies lineNum but not side.
-
-      element._focusLineNum = 234;
-      element._initCursor(false);
-      assert.equal(element.cursor.initialLineNumber, 234);
-      assert.equal(element.cursor.side, 'right');
-
-      // Base hash: specifies lineNum and side.
-      element._focusLineNum = 345;
-      element._initCursor(true);
-      assert.equal(element.cursor.initialLineNumber, 345);
-      assert.equal(element.cursor.side, 'left');
-
-      // Specifies right side:
-      element._focusLineNum = 123;
-      element._initCursor(false);
-      assert.equal(element.cursor.initialLineNumber, 123);
-      assert.equal(element.cursor.side, 'right');
-    });
-
-    test('_getLineOfInterest', () => {
-      assert.isUndefined(element._getLineOfInterest(false));
-
-      element._focusLineNum = 12;
-      let result = element._getLineOfInterest(false);
-      assert.equal(result.number, 12);
-      assert.isNotOk(result.leftSide);
-
-      result = element._getLineOfInterest(true);
-      assert.equal(result.number, 12);
-      assert.isOk(result.leftSide);
-    });
-
-    test('_onLineSelected', () => {
-      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
-      const replaceStateStub = sinon.stub(history, 'replaceState');
-      sinon.stub(element.cursor, 'getAddress')
-          .returns({number: 123, isLeftSide: false});
-
-      element._changeNum = 321;
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._patchRange = {
-        basePatchNum: 3,
-        patchNum: 5,
-      };
-      const e = {};
-      const detail = {number: 123, side: 'right'};
-
-      element._onLineSelected(e, detail);
-
-      assert.isTrue(replaceStateStub.called);
-      assert.isTrue(getUrlStub.called);
-      assert.isFalse(getUrlStub.lastCall.args[6]);
-    });
-
-    test('line selected on left side', () => {
-      const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
-      const replaceStateStub = sinon.stub(history, 'replaceState');
-      sinon.stub(element.cursor, 'getAddress')
-          .returns({number: 123, isLeftSide: true});
-
-      element._changeNum = 321;
-      element._change = {_number: 321, project: 'foo/bar'};
-      element._patchRange = {
-        basePatchNum: 3,
-        patchNum: 5,
-      };
-      const e = {};
-      const detail = {number: 123, side: 'left'};
-
-      element._onLineSelected(e, detail);
-
-      assert.isTrue(replaceStateStub.called);
-      assert.isTrue(getUrlStub.called);
-      assert.isTrue(getUrlStub.lastCall.args[6]);
-    });
-
-    test('_handleToggleDiffMode', () => {
-      const userStub = stubUsers('updatePreferences');
-      const e = new CustomEvent('keydown', {
-        detail: {keyboardEvent: new KeyboardEvent('keydown'), key: 'x'},
-      });
-      element._userPrefs = {diff_view: DiffViewMode.SIDE_BY_SIDE};
-
-      element._handleToggleDiffMode(e);
-      assert.deepEqual(userStub.lastCall.args[0], {
-        diff_view: DiffViewMode.UNIFIED});
-
-      element._userPrefs = {diff_view: DiffViewMode.UNIFIED};
-
-      element._handleToggleDiffMode(e);
-      assert.deepEqual(userStub.lastCall.args[0], {
-        diff_view: DiffViewMode.SIDE_BY_SIDE});
-    });
-
-    suite('_initPatchRange', () => {
-      setup(async () => {
-        stubRestApi('getDiff').returns(Promise.resolve({}));
-        element.params = {
-          view: GerritView.DIFF,
-          changeNum: '42',
-          patchNum: 3,
-          path: 'abcd',
-        };
-        await flush();
-      });
-      test('empty', () => {
-        sinon.stub(element, '_getPaths').returns(new Map());
-        element._initPatchRange();
-        assert.equal(Object.keys(element._commentMap).length, 0);
-      });
-
-      test('has paths', () => {
-        sinon.stub(element, '_getFiles');
-        sinon.stub(element, '_getPaths').returns({
-          'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
-          'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
-        });
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: 3,
-          patchNum: 5,
-        };
-        element._initPatchRange();
-        assert.deepEqual(Object.keys(element._commentMap),
-            ['path/to/file/one.cpp', 'path-to/file/two.py']);
-      });
-    });
-
-    suite('_computeCommentSkips', () => {
-      test('empty file list', () => {
-        const commentMap = {
-          'path/one.jpg': true,
-          'path/three.wav': true,
-        };
-        const path = 'path/two.m4v';
-        const fileList = [];
-        const result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.isNull(result.previous);
-        assert.isNull(result.next);
-      });
-
-      test('finds skips', () => {
-        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
-        let path = fileList[1];
-        const commentMap = {};
-        commentMap[fileList[0]] = true;
-        commentMap[fileList[1]] = false;
-        commentMap[fileList[2]] = true;
-
-        let result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[0]);
-        assert.equal(result.next, fileList[2]);
-
-        commentMap[fileList[1]] = true;
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[0]);
-        assert.equal(result.next, fileList[2]);
-
-        path = fileList[0];
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.isNull(result.previous);
-        assert.equal(result.next, fileList[1]);
-
-        path = fileList[2];
-
-        result = element._computeCommentSkips(commentMap, fileList, path);
-        assert.equal(result.previous, fileList[1]);
-        assert.isNull(result.next);
-      });
-
-      suite('skip next/previous', () => {
-        let navToChangeStub;
-        let navToDiffStub;
-
-        setup(() => {
-          navToChangeStub = sinon.stub(element, '_navToChangeView');
-          navToDiffStub = sinon.stub(GerritNav, 'navigateToDiff');
-          element._files = getFilesFromFileList([
-            'path/one.jpg', 'path/two.m4v', 'path/three.wav',
-          ]);
-          element._patchRange = {patchNum: 2, basePatchNum: 1};
-        });
-
-        suite('_moveToPreviousFileWithComment', () => {
-          test('no skips', () => {
-            element._moveToPreviousFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('no previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = false;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToPreviousFileWithComment();
-            assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('w/ previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToPreviousFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isTrue(navToDiffStub.calledOnce);
-          });
-        });
-
-        suite('_moveToNextFileWithComment', () => {
-          test('no skips', () => {
-            element._moveToNextFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('no previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = false;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToNextFileWithComment();
-            assert.isTrue(navToChangeStub.calledOnce);
-            assert.isFalse(navToDiffStub.called);
-          });
-
-          test('w/ previous', () => {
-            const commentMap = {};
-            commentMap[element._fileList[0]] = true;
-            commentMap[element._fileList[1]] = false;
-            commentMap[element._fileList[2]] = true;
-            element._commentMap = commentMap;
-            element._path = element._fileList[1];
-
-            element._moveToNextFileWithComment();
-            assert.isFalse(navToChangeStub.called);
-            assert.isTrue(navToDiffStub.calledOnce);
-          });
-        });
-      });
-    });
-
-    test('_computeEditMode', () => {
-      const callCompute = range => element._computeEditMode({base: range});
-      assert.isFalse(callCompute({}));
-      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}));
-      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}));
-      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}));
-    });
-
-    test('_computeFileNum', () => {
-      assert.equal(element._computeFileNum('/foo',
-          [{value: '/foo'}, {value: '/bar'}]), 1);
-      assert.equal(element._computeFileNum('/bar',
-          [{value: '/foo'}, {value: '/bar'}]), 2);
-    });
-
-    test('_computeFileNumClass', () => {
-      assert.equal(element._computeFileNumClass(0, []), '');
-      assert.equal(element._computeFileNumClass(1,
-          [{value: '/foo'}, {value: '/bar'}]), 'show');
-    });
-
-    test('f open file dropdown', () => {
-      assert.isFalse(element.$.dropdown.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 70, null, 'f');
-      flush();
-      assert.isTrue(element.$.dropdown.$.dropdown.opened);
-    });
-
-    suite('blame', () => {
-      test('toggle blame with button', () => {
-        const toggleBlame = sinon.stub(
-            element.$.diffHost, 'loadBlame')
-            .callsFake(() => Promise.resolve());
-        MockInteractions.tap(element.$.toggleBlame);
-        assert.isTrue(toggleBlame.calledOnce);
-      });
-      test('toggle blame with shortcut', () => {
-        const toggleBlame = sinon.stub(
-            element.$.diffHost, 'loadBlame').callsFake(() => Promise.resolve());
-        MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
-        assert.isTrue(toggleBlame.calledOnce);
-      });
-    });
-
-    suite('editMode behavior', () => {
-      setup(() => {
-        element._loggedIn = true;
-      });
-
-      const isVisible = el => {
-        assert.ok(el);
-        return getComputedStyle(el).getPropertyValue('display') !== 'none';
-      };
-
-      test('reviewed checkbox', () => {
-        sinon.stub(element, '_handlePatchChange');
-        element._patchRange = {patchNum: 1};
-        // Reviewed checkbox should be shown.
-        assert.isTrue(isVisible(element.$.reviewed));
-        element.set('_patchRange.patchNum', EditPatchSetNum);
-        flush();
-
-        assert.isFalse(isVisible(element.$.reviewed));
-      });
-    });
-
-    suite('switching files', () => {
-      let dispatchEventStub;
-      let navToFileStub;
-      let moveToPreviousChunkStub;
-      let moveToNextChunkStub;
-      let isAtStartStub;
-      let isAtEndStub;
-      let nowStub;
-
-      setup(() => {
-        dispatchEventStub = sinon.stub(
-            element, 'dispatchEvent').callThrough();
-        navToFileStub = sinon.stub(element, '_navToFile');
-        moveToPreviousChunkStub =
-            sinon.stub(element.cursor, 'moveToPreviousChunk');
-        moveToNextChunkStub =
-            sinon.stub(element.cursor, 'moveToNextChunk');
-        isAtStartStub = sinon.stub(element.cursor, 'isAtStart');
-        isAtEndStub = sinon.stub(element.cursor, 'isAtEnd');
-        nowStub = sinon.stub(Date, 'now');
-      });
-
-      test('shows toast when at the end of file', () => {
-        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtEndStub.returns(true);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-
-        assert.isTrue(moveToNextChunkStub.called);
-        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
-        assert.isFalse(navToFileStub.called);
-      });
-
-      test('navigates to next file when n is tapped again', () => {
-        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtEndStub.returns(true);
-
-        element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-        element._reviewedFiles = new Set(['file2']);
-        element._path = 'file1';
-
-        nowStub.returns(5);
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        nowStub.returns(10);
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-
-        assert.isTrue(navToFileStub.called);
-        assert.deepEqual(navToFileStub.lastCall.args, [
-          'file1',
-          ['file1', 'file3'],
-          1,
-        ]);
-      });
-
-      test('does not navigate if n is tapped twice too slow', () => {
-        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtEndStub.returns(true);
-
-        nowStub.returns(5);
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        nowStub.returns(6000);
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-
-        assert.isFalse(navToFileStub.called);
-      });
-
-      test('shows toast when at the start of file', () => {
-        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtStartStub.returns(true);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-
-        assert.isTrue(moveToPreviousChunkStub.called);
-        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
-        assert.isFalse(navToFileStub.called);
-      });
-
-      test('navigates to prev file when p is tapped again', () => {
-        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtStartStub.returns(true);
-
-        element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-        element._reviewedFiles = new Set(['file2']);
-        element._path = 'file3';
-
-        nowStub.returns(5);
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-        nowStub.returns(10);
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-
-        assert.isTrue(navToFileStub.called);
-        assert.deepEqual(navToFileStub.lastCall.args, [
-          'file3',
-          ['file1', 'file3'],
-          -1,
-        ]);
-      });
-
-      test('does not navigate if p is tapped twice too slow', () => {
-        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtStartStub.returns(true);
-
-        nowStub.returns(5);
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-        nowStub.returns(6000);
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-
-        assert.isFalse(navToFileStub.called);
-      });
-
-      test('does not navigate when tapping n then p', () => {
-        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtEndStub.returns(true);
-
-        nowStub.returns(5);
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-
-        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
-        isAtStartStub.returns(true);
-
-        nowStub.returns(10);
-        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
-
-        assert.isFalse(navToFileStub.called);
-      });
-    });
-
-    test('shift+m navigates to next unreviewed file', () => {
-      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      element._reviewedFiles = new Set(['file1', 'file2']);
-      element._path = 'file1';
-      const reviewedStub = sinon.stub(element, '_setReviewed');
-      const navStub = sinon.stub(element, '_navToFile');
-      MockInteractions.pressAndReleaseKeyOn(element, 77, null, 'M');
-      flush();
-
-      assert.isTrue(reviewedStub.lastCall.args[0]);
-      assert.deepEqual(navStub.lastCall.args, [
-        'file1',
-        ['file1', 'file3'],
-        1,
-      ]);
-    });
-
-    test('File change should trigger navigateToDiff once', async () => {
-      element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
-      sinon.stub(element, '_initLineOfInterestAndCursor');
-      sinon.stub(GerritNav, 'navigateToDiff');
-
-      // Load file1
-      element.params = {
-        view: GerritNav.View.DIFF,
-        patchNum: 1,
-        changeNum: 101,
-        project: 'test-project',
-        path: 'file1',
-      };
-      element._patchRange = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-      element._change = {
-        ...createChange(),
-        revisions: createRevisions(1),
-      };
-      await flush();
-      assert.isTrue(GerritNav.navigateToDiff.notCalled);
-
-      // Switch to file2
-      element._handleFileChange({detail: {value: 'file2'}});
-      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
-
-      // This is to mock the param change triggered by above navigate
-      element.params = {
-        view: GerritNav.View.DIFF,
-        patchNum: 1,
-        changeNum: 101,
-        project: 'test-project',
-        path: 'file2',
-      };
-      element._patchRange = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-
-      // No extra call
-      assert.isTrue(GerritNav.navigateToDiff.calledOnce);
-    });
-
-    test('_computeDownloadDropdownLinks', () => {
-      const downloadLinks = [
-        {
-          url: '/changes/test~12/revisions/1/patch?zip&path=index.php',
-          name: 'Patch',
-        },
-        {
-          url: '/changes/test~12/revisions/1' +
-              '/files/index.php/download?parent=1',
-          name: 'Left Content',
-        },
-        {
-          url: '/changes/test~12/revisions/1' +
-              '/files/index.php/download',
-          name: 'Right Content',
-        },
-      ];
-
-      const side = {
-        meta_a: true,
-        meta_b: true,
-      };
-
-      const base = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-
-      assert.deepEqual(
-          element._computeDownloadDropdownLinks(
-              'test', 12, base, 'index.php', side),
-          downloadLinks);
-    });
-
-    test('_computeDownloadDropdownLinks diff returns renamed', () => {
-      const downloadLinks = [
-        {
-          url: '/changes/test~12/revisions/3/patch?zip&path=index.php',
-          name: 'Patch',
-        },
-        {
-          url: '/changes/test~12/revisions/2' +
-              '/files/index2.php/download',
-          name: 'Left Content',
-        },
-        {
-          url: '/changes/test~12/revisions/3' +
-              '/files/index.php/download',
-          name: 'Right Content',
-        },
-      ];
-
-      const side = {
-        change_type: 'RENAMED',
-        meta_a: {
-          name: 'index2.php',
-        },
-        meta_b: true,
-      };
-
-      const base = {
-        patchNum: 3,
-        basePatchNum: 2,
-      };
-
-      assert.deepEqual(
-          element._computeDownloadDropdownLinks(
-              'test', 12, base, 'index.php', side),
-          downloadLinks);
-    });
-
-    test('_computeDownloadFileLink', () => {
-      const base = {
-        patchNum: 1,
-        basePatchNum: 'PARENT',
-      };
-
-      assert.equal(
-          element._computeDownloadFileLink(
-              'test', 12, base, 'index.php', true),
-          '/changes/test~12/revisions/1/files/index.php/download?parent=1');
-
-      assert.equal(
-          element._computeDownloadFileLink(
-              'test', 12, base, 'index.php', false),
-          '/changes/test~12/revisions/1/files/index.php/download');
-    });
-
-    test('_computeDownloadPatchLink', () => {
-      assert.equal(
-          element._computeDownloadPatchLink(
-              'test', 12, {patchNum: 1}, 'index.php'),
-          '/changes/test~12/revisions/1/patch?zip&path=index.php');
-    });
-  });
-
-  suite('unmodified files with comments', () => {
-    let element;
-    setup(() => {
-      const changedFiles = {
-        'file1.txt': {},
-        'a/b/test.c': {},
-      };
-      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';
-    });
-
-    test('_getFiles add files with comments without changes', () => {
-      const patchChangeRecord = {
-        base: {
-          basePatchNum: 5,
-          patchNum: 10,
-        },
-      };
-      const changeComments = {
-        getPaths: sinon.stub().returns({
-          'file2.txt': {},
-          'file1.txt': {},
-        }),
-      };
-      return element._getFiles(23, patchChangeRecord, changeComments)
-          .then(() => {
-            assert.deepEqual(element._files, {
-              sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
-              changeFilesByPath: {
-                'file1.txt': {},
-                'file2.txt': {status: 'U'},
-                'a/b/test.c': {},
-              },
-            });
-          });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
new file mode 100644
index 0000000..889e9dd
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -0,0 +1,1975 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-view';
+import {
+  ChangeStatus,
+  DiffViewMode,
+  createDefaultDiffPrefs,
+  createDefaultPreferences,
+} from '../../../constants/constants';
+import {
+  isVisible,
+  pressKey,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+  waitEventLoop,
+  waitUntil,
+} from '../../../test/test-utils';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {
+  createRevisions,
+  createComment as createCommentGeneric,
+  createDiff,
+  createServerInfo,
+  createConfig,
+  createParsedChange,
+  createRevision,
+  createFileInfo,
+  createDiffViewState,
+  TEST_NUMERIC_CHANGE_ID,
+} from '../../../test/test-data-generators';
+import {
+  BasePatchSetNum,
+  CommentInfo,
+  EDIT,
+  NumericChangeId,
+  PARENT,
+  PatchSetNum,
+  PatchSetNumber,
+  PathToCommentsInfoMap,
+  RepoName,
+  RevisionPatchSetNum,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {CursorMoveResult} from '../../../api/core';
+import {Side} from '../../../api/diff';
+import {Files, GrDiffView} from './gr-diff-view';
+import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {SinonFakeTimers, SinonStub} from 'sinon';
+import {
+  changeModelToken,
+  ChangeModel,
+  LoadingStatus,
+} from '../../../models/change/change-model';
+import {assertIsDefined} from '../../../utils/common-util';
+import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
+import {Key} from '../../../utils/dom-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+  commentsModelToken,
+  CommentsModel,
+} from '../../../models/comments/comments-model';
+import {
+  BrowserModel,
+  browserModelToken,
+} from '../../../models/browser/browser-model';
+import {
+  ChangeViewModel,
+  changeViewModelToken,
+} from '../../../models/views/change';
+import {FileNameToNormalizedFileInfoMap} from '../../../models/change/files-model';
+
+function createComment(
+  id: string,
+  line: number,
+  ps: number | PatchSetNum,
+  path: string
+): CommentInfo {
+  return {
+    ...createCommentGeneric(),
+    id: id as UrlEncodedCommentId,
+    line,
+    patch_set: ps as RevisionPatchSetNum,
+    path,
+  };
+}
+
+suite('gr-diff-view tests', () => {
+  suite('basic tests', () => {
+    let element: GrDiffView;
+    let clock: SinonFakeTimers;
+    let diffCommentsStub;
+    let getDiffRestApiStub: SinonStub;
+    let navToChangeStub: SinonStub;
+    let navToDiffStub: SinonStub;
+    let navToEditStub: SinonStub;
+    let changeModel: ChangeModel;
+    let viewModel: ChangeViewModel;
+    let commentsModel: CommentsModel;
+    let browserModel: BrowserModel;
+    let userModel: UserModel;
+
+    function getFilesFromFileList(fileList: string[]): Files {
+      const changeFilesByPath = fileList.reduce((files, path) => {
+        files[path] = createFileInfo(path);
+        return files;
+      }, {} as FileNameToNormalizedFileInfoMap);
+      return {
+        sortedPaths: fileList,
+        changeFilesByPath,
+      };
+    }
+
+    setup(async () => {
+      stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+      stubRestApi('getChangeFiles').returns(
+        Promise.resolve({
+          'chell.go': createFileInfo(),
+          'glados.txt': createFileInfo(),
+          'wheatley.md': createFileInfo(),
+        })
+      );
+      stubRestApi('saveFileReviewed').returns(Promise.resolve(new Response()));
+      diffCommentsStub = stubRestApi('getDiffComments');
+      diffCommentsStub.returns(Promise.resolve({}));
+      stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
+      stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
+      stubRestApi('getPortedComments').returns(Promise.resolve({}));
+
+      element = await fixture(html`<gr-diff-view></gr-diff-view>`);
+      viewModel = testResolver(changeViewModelToken);
+      viewModel.setState(createDiffViewState());
+      await waitUntil(() => element.changeNum === TEST_NUMERIC_CHANGE_ID);
+      element.path = 'some/path.txt';
+      element.change = createParsedChange();
+      element.diff = {...createDiff(), content: []};
+      getDiffRestApiStub = stubRestApi('getDiff');
+      // Delayed in case a test updates element.diff.
+      getDiffRestApiStub.callsFake(() => Promise.resolve(element.diff));
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      element.changeComments = new ChangeComments({
+        '/COMMIT_MSG': [
+          createComment('c1', 10, 2, '/COMMIT_MSG'),
+          createComment('c3', 10, PARENT, '/COMMIT_MSG'),
+        ],
+      });
+      await element.updateComplete;
+      commentsModel = testResolver(commentsModelToken);
+      changeModel = testResolver(changeModelToken);
+      browserModel = testResolver(browserModelToken);
+      userModel = testResolver(userModelToken);
+      navToChangeStub = sinon.stub(changeModel, 'navigateToChange');
+      navToDiffStub = sinon.stub(changeModel, 'navigateToDiff');
+      navToEditStub = sinon.stub(changeModel, 'navigateToEdit');
+
+      commentsModel.setState({
+        comments: {},
+        robotComments: {},
+        drafts: {},
+        portedComments: {},
+        portedDrafts: {},
+        discardedDrafts: [],
+      });
+    });
+
+    teardown(() => {
+      clock && clock.restore();
+      sinon.restore();
+    });
+
+    test('toggle left diff with a hotkey', () => {
+      assertIsDefined(element.diffHost);
+      const toggleLeftDiffStub = sinon.stub(element.diffHost, 'toggleLeftDiff');
+      pressKey(element, 'A');
+      assert.isTrue(toggleLeftDiffStub.calledOnce);
+    });
+
+    test('renders', async () => {
+      browserModel.setScreenWidth(0);
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      const change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(10),
+        },
+      };
+      changeModel.updateStateChange(change);
+      element.files = getFilesFromFileList([
+        'chell.go',
+        'glados.txt',
+        'wheatley.md',
+      ]);
+      element.path = 'glados.txt';
+      element.loggedIn = true;
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="stickyHeader">
+            <h1 class="assistive-tech-only">Diff of glados.txt</h1>
+            <header>
+              <div>
+                <a href="/c/test-project/+/42"> 42 </a>
+                <span class="changeNumberColon"> : </span>
+                <span class="headerSubject"> Test subject </span>
+                <input
+                  aria-label="file reviewed"
+                  class="hideOnEdit reviewed"
+                  id="reviewed"
+                  title="Toggle reviewed status of file"
+                  type="checkbox"
+                />
+                <div class="jumpToFileContainer">
+                  <gr-dropdown-list id="dropdown" show-copy-for-trigger-text="">
+                  </gr-dropdown-list>
+                </div>
+              </div>
+              <div class="desktop navLinks">
+                <span class="fileNum show">
+                  File 2 of 3
+                  <span class="separator"> </span>
+                </span>
+                <a
+                  class="navLink"
+                  href="/c/test-project/+/42/10/chell.go"
+                  title="Go to previous file (shortcut: [)"
+                >
+                  Prev
+                </a>
+                <span class="separator"> </span>
+                <a
+                  class="navLink"
+                  href="/c/test-project/+/42"
+                  title="Up to change (shortcut: u)"
+                >
+                  Up
+                </a>
+                <span class="separator"> </span>
+                <a
+                  class="navLink"
+                  href="/c/test-project/+/42/10/wheatley.md"
+                  title="Go to next file (shortcut: ])"
+                >
+                  Next
+                </a>
+              </div>
+            </header>
+            <div class="subHeader">
+              <div class="patchRangeLeft">
+                <gr-patch-range-select id="rangeSelect">
+                </gr-patch-range-select>
+                <span class="desktop download">
+                  <span class="separator"> </span>
+                  <gr-dropdown down-arrow="" horizontal-align="left" link="">
+                    <span class="downloadTitle"> Download </span>
+                  </gr-dropdown>
+                </span>
+              </div>
+              <div class="rightControls">
+                <span class="blameLoader show">
+                  <gr-button
+                    aria-disabled="false"
+                    id="toggleBlame"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                    title="Toggle blame (shortcut: b)"
+                  >
+                    Show blame
+                  </gr-button>
+                </span>
+                <span class="separator"> </span>
+                <span class="editButton">
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                    title="Edit current file"
+                  >
+                    edit
+                  </gr-button>
+                </span>
+                <span class="separator"> </span>
+                <div class="diffModeSelector">
+                  <span> Diff view: </span>
+                  <gr-diff-mode-selector id="modeSelect" show-tooltip-below="">
+                  </gr-diff-mode-selector>
+                </div>
+                <span id="diffPrefsContainer">
+                  <span class="desktop preferences">
+                    <gr-tooltip-content
+                      has-tooltip=""
+                      position-below=""
+                      title="Diff preferences"
+                    >
+                      <gr-button
+                        aria-disabled="false"
+                        class="prefsButton"
+                        link=""
+                        role="button"
+                        tabindex="0"
+                      >
+                        <gr-icon icon="settings" filled></gr-icon>
+                      </gr-button>
+                    </gr-tooltip-content>
+                  </span>
+                </span>
+                <gr-endpoint-decorator name="annotation-toggler">
+                  <span hidden="" id="annotation-span">
+                    <label for="annotation-checkbox" id="annotation-label">
+                    </label>
+                    <iron-input>
+                      <input
+                        disabled=""
+                        id="annotation-checkbox"
+                        is="iron-input"
+                        type="checkbox"
+                        value=""
+                      />
+                    </iron-input>
+                  </span>
+                </gr-endpoint-decorator>
+              </div>
+            </div>
+            <div class="fileNav mobile">
+              <a class="mobileNavLink" href="/c/test-project/+/42/10/chell.go">
+                <
+              </a>
+              <div class="fullFileName mobile">glados.txt</div>
+              <a
+                class="mobileNavLink"
+                href="/c/test-project/+/42/10/wheatley.md"
+              >
+                >
+              </a>
+            </div>
+          </div>
+          <h2 class="assistive-tech-only">Diff view</h2>
+          <gr-diff-host id="diffHost"> </gr-diff-host>
+          <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
+          <gr-diff-preferences-dialog id="diffPreferencesDialog">
+          </gr-diff-preferences-dialog>
+          <dialog id="downloadModal" tabindex="-1">
+            <gr-download-dialog id="downloadDialog" role="dialog">
+            </gr-download-dialog>
+          </dialog>
+        `
+      );
+    });
+
+    test('keyboard shortcuts', async () => {
+      clock = sinon.useFakeTimers();
+      element.changeNum = 42 as NumericChangeId;
+      browserModel.setScreenWidth(0);
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(10),
+        },
+      };
+      element.files = getFilesFromFileList([
+        'chell.go',
+        'glados.txt',
+        'wheatley.md',
+      ]);
+      element.path = 'glados.txt';
+      element.loggedIn = true;
+      await element.updateComplete;
+      navToChangeStub.reset();
+
+      pressKey(element, 'u');
+      assert.isTrue(navToChangeStub.calledOnce);
+      await element.updateComplete;
+
+      pressKey(element, ']');
+      assert.equal(navToDiffStub.callCount, 1);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: undefined},
+      ]);
+
+      element.path = 'wheatley.md';
+      await element.updateComplete;
+
+      pressKey(element, '[');
+      assert.equal(navToDiffStub.callCount, 2);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'glados.txt', lineNum: undefined},
+      ]);
+
+      element.path = 'glados.txt';
+      await element.updateComplete;
+
+      pressKey(element, '[');
+      assert.equal(navToDiffStub.callCount, 3);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'chell.go', lineNum: undefined},
+      ]);
+
+      element.path = 'chell.go';
+      await element.updateComplete;
+
+      pressKey(element, '[');
+      assert.equal(navToChangeStub.callCount, 2);
+      await element.updateComplete;
+
+      assertIsDefined(element.diffPreferencesDialog);
+      const showPrefsStub = sinon
+        .stub(element.diffPreferencesDialog, 'open')
+        .callsFake(() => Promise.resolve());
+
+      pressKey(element, ',');
+      await element.updateComplete;
+      assert(showPrefsStub.calledOnce);
+
+      assertIsDefined(element.cursor);
+      let scrollStub = sinon.stub(element.cursor, 'moveToNextChunk');
+      pressKey(element, 'n');
+      await element.updateComplete;
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.cursor, 'moveToPreviousChunk');
+      pressKey(element, 'p');
+      await element.updateComplete;
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.cursor, 'moveToNextCommentThread');
+      pressKey(element, 'N');
+      await element.updateComplete;
+      assert(scrollStub.calledOnce);
+
+      scrollStub = sinon.stub(element.cursor, 'moveToPreviousCommentThread');
+      pressKey(element, 'P');
+      await element.updateComplete;
+      assert(scrollStub.calledOnce);
+
+      assertIsDefined(element.diffHost);
+      assertIsDefined(element.diffHost.diffElement);
+      pressKey(element, 'j');
+      await element.updateComplete;
+      assert.equal(
+        element.diffHost.diffElement.viewMode,
+        DiffViewMode.SIDE_BY_SIDE
+      );
+      assert.isTrue(element.diffHost.diffElement.displayLine);
+
+      pressKey(element, Key.ESC);
+      await element.updateComplete;
+      assert.equal(
+        element.diffHost.diffElement.viewMode,
+        DiffViewMode.SIDE_BY_SIDE
+      );
+      assert.isFalse(element.diffHost.diffElement.displayLine);
+
+      const setReviewedStub = sinon.stub(element, 'setReviewed');
+      const handleToggleSpy = sinon.spy(element, 'handleToggleFileReviewed');
+      assert.isFalse(handleToggleSpy.called);
+      assert.isFalse(setReviewedStub.called);
+
+      pressKey(element, 'r');
+      assert.isTrue(handleToggleSpy.calledOnce);
+      assert.isTrue(setReviewedStub.calledOnce);
+      assert.equal(setReviewedStub.lastCall.args[0], true);
+
+      // Handler is throttled, so another key press within 500 ms is ignored.
+      clock.tick(100);
+      pressKey(element, 'r');
+      assert.isTrue(handleToggleSpy.calledOnce);
+      assert.isTrue(setReviewedStub.calledOnce);
+
+      clock.tick(1000);
+      pressKey(element, 'r');
+      assert.isTrue(handleToggleSpy.calledTwice);
+      assert.isTrue(setReviewedStub.calledTwice);
+      clock.restore();
+    });
+
+    test('moveToNextCommentThread navigates to next file', async () => {
+      assertIsDefined(element.cursor);
+      sinon.stub(element.cursor, 'isAtEnd').returns(true);
+      element.changeNum = 42 as NumericChangeId;
+      const comment: PathToCommentsInfoMap = {
+        'wheatley.md': [createComment('c2', 21, 10, 'wheatley.md')],
+      };
+      element.changeComments = new ChangeComments(comment);
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(10),
+        },
+      };
+      element.files = getFilesFromFileList([
+        'chell.go',
+        'glados.txt',
+        'wheatley.md',
+      ]);
+      element.path = 'glados.txt';
+      element.loggedIn = true;
+      await element.updateComplete;
+      navToDiffStub.reset();
+
+      pressKey(element, 'N');
+      await element.updateComplete;
+      assert.equal(navToDiffStub.callCount, 1);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: 21},
+      ]);
+
+      element.path = 'wheatley.md'; // navigated to next file
+
+      pressKey(element, 'N');
+      await element.updateComplete;
+
+      assert.equal(navToChangeStub.callCount, 1);
+    });
+
+    test('shift+x shortcut toggles all diff context', async () => {
+      assertIsDefined(element.diffHost);
+      const toggleStub = sinon.stub(element.diffHost, 'toggleAllContext');
+      pressKey(element, 'X');
+      await element.updateComplete;
+      assert.isTrue(toggleStub.called);
+    });
+
+    test('diff against base', async () => {
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
+      await element.updateComplete;
+      element.handleDiffAgainstBase();
+      const expected = [{path: 'some/path.txt'}, 10, PARENT];
+      assert.deepEqual(navToDiffStub.lastCall.args, expected);
+    });
+
+    test('diff against latest', async () => {
+      element.path = 'foo';
+      element.latestPatchNum = 12 as PatchSetNumber;
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
+      await element.updateComplete;
+      element.handleDiffAgainstLatest();
+      const expected = [{path: 'foo'}, 12, 5];
+      assert.deepEqual(navToDiffStub.lastCall.args, expected);
+    });
+
+    test('handleDiffBaseAgainstLeft', async () => {
+      element.path = 'foo';
+      element.latestPatchNum = 10 as PatchSetNumber;
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 1 as BasePatchSetNum;
+      viewModel.setState({
+        ...createDiffViewState(),
+        patchNum: 3 as RevisionPatchSetNum,
+        basePatchNum: 1 as BasePatchSetNum,
+        diffView: {path: 'foo'},
+      });
+      await element.updateComplete;
+      element.handleDiffBaseAgainstLeft();
+      const expected = [{path: 'foo'}, 1, PARENT];
+      assert.deepEqual(navToDiffStub.lastCall.args, expected);
+    });
+
+    test('handleDiffRightAgainstLatest', async () => {
+      element.path = 'foo';
+      element.latestPatchNum = 10 as PatchSetNumber;
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 1 as BasePatchSetNum;
+      await element.updateComplete;
+      element.handleDiffRightAgainstLatest();
+      const expected = [{path: 'foo'}, 10, 3];
+      assert.deepEqual(navToDiffStub.lastCall.args, expected);
+    });
+
+    test('handleDiffBaseAgainstLatest', async () => {
+      element.latestPatchNum = 10 as PatchSetNumber;
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 1 as BasePatchSetNum;
+      await element.updateComplete;
+      element.handleDiffBaseAgainstLatest();
+      const expected = [{path: 'some/path.txt'}, 10, PARENT];
+      assert.deepEqual(navToDiffStub.lastCall.args, expected);
+    });
+
+    test('A fires an error event when not logged in', async () => {
+      element.loggedIn = false;
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      pressKey(element, 'a');
+      await element.updateComplete;
+      assert.isFalse(navToDiffStub.calledOnce);
+      assert.isTrue(loggedInErrorSpy.called);
+    });
+
+    test('A navigates to change with logged in', async () => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(10),
+          b: createRevision(5),
+        },
+      };
+      element.loggedIn = true;
+      await element.updateComplete;
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      navToDiffStub.reset();
+
+      pressKey(element, 'a');
+
+      await element.updateComplete;
+      assert.isTrue(navToChangeStub.calledOnce);
+      assert.deepEqual(navToChangeStub.lastCall.args, [true]);
+      assert.isFalse(loggedInErrorSpy.called);
+    });
+
+    test('A navigates to change with old patch number with logged in', async () => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(1),
+          b: createRevision(2),
+        },
+      };
+      element.loggedIn = true;
+      const loggedInErrorSpy = sinon.spy();
+      element.addEventListener('show-auth-required', loggedInErrorSpy);
+      pressKey(element, 'a');
+      await element.updateComplete;
+      assert.isTrue(navToChangeStub.calledOnce);
+      assert.deepEqual(navToChangeStub.lastCall.args, [true]);
+      assert.isFalse(loggedInErrorSpy.called);
+    });
+
+    test('keyboard shortcuts with patch range', () => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 10 as RevisionPatchSetNum;
+      element.basePatchNum = 5 as BasePatchSetNum;
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(10),
+          b: createRevision(5),
+        },
+      };
+      element.files = getFilesFromFileList([
+        'chell.go',
+        'glados.txt',
+        'wheatley.md',
+      ]);
+      element.path = 'glados.txt';
+
+      pressKey(element, 'u');
+      assert.equal(navToChangeStub.callCount, 1);
+
+      pressKey(element, ']');
+      assert.equal(navToDiffStub.callCount, 1);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: undefined},
+      ]);
+      element.path = 'wheatley.md';
+
+      pressKey(element, '[');
+      assert.equal(navToDiffStub.callCount, 2);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'glados.txt', lineNum: undefined},
+      ]);
+      element.path = 'glados.txt';
+
+      pressKey(element, '[');
+      assert.equal(navToDiffStub.callCount, 3);
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'chell.go', lineNum: undefined},
+      ]);
+      element.path = 'chell.go';
+
+      pressKey(element, '[');
+      assert.equal(navToChangeStub.callCount, 2);
+
+      assertIsDefined(element.downloadModal);
+      const downloadModalStub = sinon.stub(element.downloadModal, 'showModal');
+      pressKey(element, 'd');
+      assert.isTrue(downloadModalStub.called);
+    });
+
+    test('keyboard shortcuts with old patch number', async () => {
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        revisions: {
+          a: createRevision(1),
+          b: createRevision(2),
+        },
+      };
+      element.files = getFilesFromFileList([
+        'chell.go',
+        'glados.txt',
+        'wheatley.md',
+      ]);
+      element.path = 'glados.txt';
+
+      pressKey(element, 'u');
+      assert.isTrue(navToChangeStub.calledOnce);
+
+      pressKey(element, ']');
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'wheatley.md', lineNum: undefined},
+      ]);
+      element.path = 'wheatley.md';
+
+      pressKey(element, '[');
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'glados.txt', lineNum: undefined},
+      ]);
+      element.path = 'glados.txt';
+
+      pressKey(element, '[');
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: 'chell.go', lineNum: undefined},
+      ]);
+
+      element.path = 'chell.go';
+      await element.updateComplete;
+      navToDiffStub.reset();
+      pressKey(element, '[');
+      assert.equal(navToChangeStub.callCount, 2);
+    });
+
+    test('reloadDiff is called when patchNum changes', async () => {
+      const reloadStub = sinon.stub(element, 'reloadDiff');
+      element.patchNum = 5 as RevisionPatchSetNum;
+      await element.updateComplete;
+      assert.isTrue(reloadStub.called);
+    });
+
+    test('initializePositions is called when view becomes active', async () => {
+      const reloadStub = sinon.stub(element, 'reloadDiff');
+      const initializeStub = sinon.stub(element, 'initializePositions');
+
+      element.isActiveChildView = false;
+      await element.updateComplete;
+      element.isActiveChildView = true;
+      await element.updateComplete;
+
+      assert.isTrue(initializeStub.calledOnce);
+      assert.isFalse(reloadStub.called);
+    });
+
+    test('edit should redirect to edit page', async () => {
+      element.loggedIn = true;
+      element.path = 't.txt';
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      await element.updateComplete;
+      const editBtn = queryAndAssert<GrButton>(
+        element,
+        '.editButton gr-button'
+      );
+      assert.isTrue(!!editBtn);
+      editBtn.click();
+      assert.equal(navToEditStub.callCount, 1);
+      assert.deepEqual(navToEditStub.lastCall.args, [
+        {path: 't.txt', lineNum: undefined},
+      ]);
+    });
+
+    test('edit should redirect to edit page with line number', async () => {
+      const lineNumber = 42;
+      element.loggedIn = true;
+      element.path = 't.txt';
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      assertIsDefined(element.cursor);
+      sinon
+        .stub(element.cursor, 'getAddress')
+        .returns({number: lineNumber, leftSide: false});
+      await element.updateComplete;
+      const editBtn = queryAndAssert<GrButton>(
+        element,
+        '.editButton gr-button'
+      );
+      assert.isTrue(!!editBtn);
+      editBtn.click();
+      assert.equal(navToEditStub.callCount, 1);
+      assert.deepEqual(navToEditStub.lastCall.args, [
+        {path: 't.txt', lineNum: 42},
+      ]);
+    });
+
+    async function isEditVisibile({
+      loggedIn,
+      changeStatus,
+    }: {
+      loggedIn: boolean;
+      changeStatus: ChangeStatus;
+    }): Promise<boolean> {
+      element.loggedIn = loggedIn;
+      element.path = 't.txt';
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      element.change = {
+        ...createParsedChange(),
+        _number: 42 as NumericChangeId,
+        status: changeStatus,
+        revisions: {
+          a: createRevision(1),
+          b: createRevision(2),
+        },
+      };
+      await element.updateComplete;
+      const editBtn = query(element, '.editButton gr-button');
+      return !!editBtn;
+    }
+
+    test('edit visible only when logged and status NEW', async () => {
+      for (const changeStatus of Object.keys(ChangeStatus) as ChangeStatus[]) {
+        assert.isFalse(
+          await isEditVisibile({loggedIn: false, changeStatus}),
+          `loggedIn: false, changeStatus: ${changeStatus}`
+        );
+
+        if (changeStatus !== ChangeStatus.NEW) {
+          assert.isFalse(
+            await isEditVisibile({loggedIn: true, changeStatus}),
+            `loggedIn: true, changeStatus: ${changeStatus}`
+          );
+        } else {
+          assert.isTrue(
+            await isEditVisibile({loggedIn: true, changeStatus}),
+            `loggedIn: true, changeStatus: ${changeStatus}`
+          );
+        }
+      }
+    });
+
+    test('edit visible when logged and status NEW', async () => {
+      assert.isTrue(
+        await isEditVisibile({loggedIn: true, changeStatus: ChangeStatus.NEW})
+      );
+    });
+
+    test('edit hidden when logged and status ABANDONED', async () => {
+      assert.isFalse(
+        await isEditVisibile({
+          loggedIn: true,
+          changeStatus: ChangeStatus.ABANDONED,
+        })
+      );
+    });
+
+    test('edit hidden when logged and status MERGED', async () => {
+      assert.isFalse(
+        await isEditVisibile({
+          loggedIn: true,
+          changeStatus: ChangeStatus.MERGED,
+        })
+      );
+    });
+
+    suite('diff prefs hidden', () => {
+      test('when no prefs or logged out', async () => {
+        const getDiffPrefsContainer = () =>
+          query<HTMLSpanElement>(element, '#diffPrefsContainer');
+        element.prefs = undefined;
+        element.loggedIn = false;
+        await element.updateComplete;
+        assert.isNotOk(getDiffPrefsContainer());
+
+        element.loggedIn = true;
+        await element.updateComplete;
+        assert.isNotOk(getDiffPrefsContainer());
+
+        element.loggedIn = false;
+        element.prefs = {...createDefaultDiffPrefs(), font_size: 12};
+        await element.updateComplete;
+        assert.isNotOk(getDiffPrefsContainer());
+
+        element.loggedIn = true;
+        element.prefs = {...createDefaultDiffPrefs(), font_size: 12};
+        await element.updateComplete;
+        assert.isOk(getDiffPrefsContainer());
+      });
+    });
+
+    test('prefsButton opens gr-diff-preferences', () => {
+      const handlePrefsTapSpy = sinon.spy(element, 'handlePrefsTap');
+      assertIsDefined(element.diffPreferencesDialog);
+      const overlayOpenStub = sinon.stub(element.diffPreferencesDialog, 'open');
+      const prefsButton = queryAndAssert<GrButton>(element, '.prefsButton');
+      prefsButton.click();
+
+      assert.isTrue(handlePrefsTapSpy.called);
+      assert.isTrue(overlayOpenStub.called);
+    });
+
+    suite('url parameters', () => {
+      test('_formattedFiles', () => {
+        element.changeNum = 42 as NumericChangeId;
+        element.patchNum = 10 as RevisionPatchSetNum;
+        element.basePatchNum = PARENT;
+        element.change = {
+          ...createParsedChange(),
+          _number: 42 as NumericChangeId,
+        };
+        element.files = getFilesFromFileList([
+          'chell.go',
+          'glados.txt',
+          'wheatley.md',
+          '/COMMIT_MSG',
+          '/MERGE_LIST',
+        ]);
+        element.path = 'glados.txt';
+        const expectedFormattedFiles: DropdownItem[] = [
+          {
+            text: 'chell.go',
+            mobileText: 'chell.go',
+            value: 'chell.go',
+            bottomText: '',
+            file: {
+              ...createFileInfo(),
+              __path: 'chell.go',
+            },
+          },
+          {
+            text: 'glados.txt',
+            mobileText: 'glados.txt',
+            value: 'glados.txt',
+            bottomText: '',
+            file: {
+              ...createFileInfo(),
+              __path: 'glados.txt',
+            },
+          },
+          {
+            text: 'wheatley.md',
+            mobileText: 'wheatley.md',
+            value: 'wheatley.md',
+            bottomText: '',
+            file: {
+              ...createFileInfo(),
+              __path: 'wheatley.md',
+            },
+          },
+          {
+            text: 'Commit message',
+            mobileText: 'Commit message',
+            value: '/COMMIT_MSG',
+            bottomText: '',
+            file: {
+              ...createFileInfo(),
+              __path: '/COMMIT_MSG',
+            },
+          },
+          {
+            text: 'Merge list',
+            mobileText: 'Merge list',
+            value: '/MERGE_LIST',
+            bottomText: '',
+            file: {
+              ...createFileInfo(),
+              __path: '/MERGE_LIST',
+            },
+          },
+        ];
+
+        const result = element.formatFilesForDropdown();
+
+        assert.deepEqual(result, expectedFormattedFiles);
+        assert.equal(result[1].value, element.path);
+      });
+
+      test('prev/up/next links', async () => {
+        viewModel.setState({
+          ...createDiffViewState(),
+        });
+        const change = {
+          ...createParsedChange(),
+          _number: 42 as NumericChangeId,
+          revisions: {
+            a: createRevision(10),
+          },
+        };
+        changeModel.updateStateChange(change);
+        await element.updateComplete;
+
+        element.files = getFilesFromFileList([
+          'chell.go',
+          'glados.txt',
+          'wheatley.md',
+        ]);
+        element.path = 'glados.txt';
+        await element.updateComplete;
+
+        const linkEls = queryAll(element, '.navLink');
+        assert.equal(linkEls.length, 3);
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/10/chell.go'
+        );
+        assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/10/wheatley.md'
+        );
+
+        element.path = 'wheatley.md';
+        await element.updateComplete;
+
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/10/glados.txt'
+        );
+        assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
+        assert.equal(linkEls[2].getAttribute('href'), '/c/test-project/+/42');
+
+        element.path = 'chell.go';
+        await element.updateComplete;
+
+        assert.equal(linkEls[0].getAttribute('href'), '/c/test-project/+/42');
+        assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/10/glados.txt'
+        );
+
+        element.path = 'not_a_real_file';
+        await element.updateComplete;
+
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/10/wheatley.md'
+        );
+        assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/10/chell.go'
+        );
+      });
+
+      test('prev/up/next links with patch range', async () => {
+        viewModel.setState({
+          ...createDiffViewState(),
+          basePatchNum: 5 as BasePatchSetNum,
+          patchNum: 10 as RevisionPatchSetNum,
+          diffView: {path: 'glados.txt'},
+        });
+        const change = {
+          ...createParsedChange(),
+          _number: 42 as NumericChangeId,
+          revisions: {
+            a: createRevision(5),
+            b: createRevision(10),
+            c: createRevision(12),
+          },
+        };
+        changeModel.updateStateChange(change);
+        element.files = getFilesFromFileList([
+          'chell.go',
+          'glados.txt',
+          'wheatley.md',
+        ]);
+        await waitUntil(() => element.path === 'glados.txt');
+        await waitUntil(() => element.patchRange?.patchNum === 10);
+
+        const linkEls = queryAll(element, '.navLink');
+        assert.equal(linkEls.length, 3);
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/5..10/chell.go'
+        );
+        assert.equal(
+          linkEls[1].getAttribute('href'),
+          '/c/test-project/+/42/5..10'
+        );
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/5..10/wheatley.md'
+        );
+
+        viewModel.updateState({diffView: {path: 'wheatley.md'}});
+        await waitUntil(() => element.path === 'wheatley.md');
+
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/5..10/glados.txt'
+        );
+        assert.equal(
+          linkEls[1].getAttribute('href'),
+          '/c/test-project/+/42/5..10'
+        );
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/5..10'
+        );
+
+        viewModel.updateState({diffView: {path: 'chell.go'}});
+        await waitUntil(() => element.path === 'chell.go');
+
+        assert.equal(
+          linkEls[0].getAttribute('href'),
+          '/c/test-project/+/42/5..10'
+        );
+        assert.equal(
+          linkEls[1].getAttribute('href'),
+          '/c/test-project/+/42/5..10'
+        );
+        assert.equal(
+          linkEls[2].getAttribute('href'),
+          '/c/test-project/+/42/5..10/glados.txt'
+        );
+      });
+    });
+
+    test('handlePatchChange calls setUrl correctly', async () => {
+      element.path = 'path/to/file.txt';
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      await element.updateComplete;
+
+      const detail = {
+        basePatchNum: PARENT,
+        patchNum: 1 as RevisionPatchSetNum,
+      };
+      queryAndAssert(element, '#rangeSelect').dispatchEvent(
+        new CustomEvent('patch-range-change', {detail, bubbles: false})
+      );
+
+      assert.deepEqual(navToDiffStub.lastCall.args, [
+        {path: element.path},
+        detail.patchNum,
+        detail.basePatchNum,
+      ]);
+    });
+
+    test(
+      '_prefs.manual_review true means set reviewed is not ' +
+        'automatically called',
+      async () => {
+        const setReviewedFileStatusStub = sinon
+          .stub(changeModel, 'setReviewedFilesStatus')
+          .callsFake(() => Promise.resolve());
+
+        const setReviewedStatusStub = sinon.spy(element, 'setReviewedStatus');
+
+        assertIsDefined(element.diffHost);
+        sinon.stub(element.diffHost, 'reload');
+        element.loggedIn = true;
+        const diffPreferences = {
+          ...createDefaultDiffPrefs(),
+          manual_review: true,
+        };
+        userModel.setDiffPreferences(diffPreferences);
+        viewModel.updateState({diffView: {path: 'wheatley.md'}});
+        changeModel.setState({
+          change: createParsedChange(),
+          reviewedFiles: [],
+          loadingStatus: LoadingStatus.LOADED,
+        });
+
+        await waitUntil(() => setReviewedStatusStub.called);
+
+        assert.isFalse(setReviewedFileStatusStub.called);
+
+        // if prefs are updated then the reviewed status should not be set again
+        userModel.setDiffPreferences(createDefaultDiffPrefs());
+
+        await element.updateComplete;
+        assert.isFalse(setReviewedFileStatusStub.called);
+      }
+    );
+
+    test('_prefs.manual_review false means set reviewed is called', async () => {
+      const setReviewedFileStatusStub = sinon
+        .stub(changeModel, 'setReviewedFilesStatus')
+        .callsFake(() => Promise.resolve());
+
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload');
+      element.loggedIn = true;
+      const diffPreferences = {
+        ...createDefaultDiffPrefs(),
+        manual_review: false,
+      };
+      userModel.setDiffPreferences(diffPreferences);
+      viewModel.updateState({diffView: {path: 'wheatley.md'}});
+      changeModel.setState({
+        change: createParsedChange(),
+        reviewedFiles: [],
+        loadingStatus: LoadingStatus.LOADED,
+      });
+
+      await waitUntil(() => setReviewedFileStatusStub.called);
+
+      assert.isTrue(setReviewedFileStatusStub.called);
+    });
+
+    test('file review status', async () => {
+      const saveReviewedStub = sinon
+        .stub(changeModel, 'setReviewedFilesStatus')
+        .callsFake(() => Promise.resolve());
+      userModel.setDiffPreferences(createDefaultDiffPrefs());
+      viewModel.updateState({
+        patchNum: 1 as RevisionPatchSetNum,
+        basePatchNum: PARENT,
+        diffView: {path: '/COMMIT_MSG'},
+      });
+      changeModel.setState({
+        change: createParsedChange(),
+        reviewedFiles: [],
+        loadingStatus: LoadingStatus.LOADED,
+      });
+      element.loggedIn = true;
+      await waitUntil(() => element.patchRange?.patchNum === 1);
+      await element.updateComplete;
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload');
+
+      await waitUntil(() => saveReviewedStub.called);
+
+      changeModel.updateStateFileReviewed('/COMMIT_MSG', true);
+      await element.updateComplete;
+
+      const reviewedStatusCheckBox = queryAndAssert<HTMLInputElement>(
+        element,
+        'input#reviewed'
+      );
+
+      assert.isTrue(reviewedStatusCheckBox.checked);
+      assert.deepEqual(saveReviewedStub.lastCall.args, [
+        42,
+        1,
+        '/COMMIT_MSG',
+        true,
+      ]);
+
+      reviewedStatusCheckBox.click();
+      assert.isFalse(reviewedStatusCheckBox.checked);
+      assert.deepEqual(saveReviewedStub.lastCall.args, [
+        42,
+        1,
+        '/COMMIT_MSG',
+        false,
+      ]);
+
+      changeModel.updateStateFileReviewed('/COMMIT_MSG', false);
+      await element.updateComplete;
+
+      reviewedStatusCheckBox.click();
+      assert.isTrue(reviewedStatusCheckBox.checked);
+      assert.deepEqual(saveReviewedStub.lastCall.args, [
+        42,
+        1,
+        '/COMMIT_MSG',
+        true,
+      ]);
+
+      const callCount = saveReviewedStub.callCount;
+
+      viewModel.setState({
+        ...createDiffViewState(),
+        repo: 'test' as RepoName,
+      });
+      await element.updateComplete;
+
+      // saveReviewedState observer observes viewState, but should not fire when
+      // view !== GerritView.DIFF.
+      assert.equal(saveReviewedStub.callCount, callCount);
+    });
+
+    test('do not set file review status for EDIT patchset', async () => {
+      const saveReviewedStub = sinon.stub(
+        changeModel,
+        'setReviewedFilesStatus'
+      );
+
+      element.patchNum = EDIT;
+      element.basePatchNum = 1 as BasePatchSetNum;
+      await waitEventLoop();
+
+      element.setReviewed(true);
+
+      assert.isFalse(saveReviewedStub.called);
+    });
+
+    test('hash is determined from viewState', async () => {
+      assertIsDefined(element.diffHost);
+      sinon.stub(element.diffHost, 'reload');
+      const initLineStub = sinon.stub(element, 'initCursor');
+
+      element.focusLineNum = 123;
+
+      await element.updateComplete;
+      await waitEventLoop();
+      assert.isTrue(initLineStub.calledOnce);
+    });
+
+    test('diff mode selector correctly toggles the diff', async () => {
+      const select = queryAndAssert<GrDiffModeSelector>(element, '#modeSelect');
+      const diffDisplay = element.diffHost;
+      assertIsDefined(diffDisplay);
+      element.userPrefs = {
+        ...createDefaultPreferences(),
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      };
+      browserModel.setScreenWidth(0);
+
+      const userStub = sinon.stub(userModel, 'updatePreferences');
+
+      await element.updateComplete;
+      // The mode selected in the view state reflects the selected option.
+      // assert.equal(element.userPrefs.diff_view, select.mode);
+
+      // The mode selected in the view state reflects the view rednered in the
+      // diff.
+      assert.equal(select.mode, diffDisplay.viewMode);
+
+      // We will simulate a user change of the selected mode.
+      element.handleToggleDiffMode();
+      assert.isTrue(
+        userStub.calledWithExactly({
+          diff_view: DiffViewMode.UNIFIED,
+        })
+      );
+    });
+
+    test('diff mode selector should be hidden for binary', async () => {
+      element.diff = {
+        ...createDiff(),
+        binary: true,
+        content: [],
+      };
+
+      await element.updateComplete;
+      const diffModeSelector = queryAndAssert(element, '.diffModeSelector');
+      assert.isTrue(diffModeSelector.classList.contains('hide'));
+    });
+
+    test('initCursor', () => {
+      assertIsDefined(element.cursor);
+      assert.isNotOk(element.cursor.initialLineNumber);
+
+      // Does nothing when viewState specify no cursor address:
+      element.leftSide = false;
+      element.initCursor();
+      assert.isNotOk(element.cursor.initialLineNumber);
+
+      // Does nothing when viewState specify side but no number:
+      element.leftSide = true;
+      element.initCursor();
+      assert.isNotOk(element.cursor.initialLineNumber);
+
+      // Revision hash: specifies lineNum but not side.
+
+      element.focusLineNum = 234;
+      element.leftSide = false;
+      element.initCursor();
+      assert.equal(element.cursor.initialLineNumber, 234);
+      assert.equal(element.cursor.side, Side.RIGHT);
+
+      // Base hash: specifies lineNum and side.
+      element.focusLineNum = 345;
+      element.leftSide = true;
+      element.initCursor();
+      assert.equal(element.cursor.initialLineNumber, 345);
+      assert.equal(element.cursor.side, Side.LEFT);
+
+      // Specifies right side:
+      element.focusLineNum = 123;
+      element.leftSide = false;
+      element.initCursor();
+      assert.equal(element.cursor.initialLineNumber, 123);
+      assert.equal(element.cursor.side, Side.RIGHT);
+    });
+
+    test('getLineOfInterest', () => {
+      element.leftSide = false;
+      assert.isUndefined(element.getLineOfInterest());
+
+      element.focusLineNum = 12;
+      element.leftSide = false;
+      let result = element.getLineOfInterest();
+      assert.isOk(result);
+      assert.equal(result!.lineNum, 12);
+      assert.equal(result!.side, Side.RIGHT);
+
+      element.leftSide = true;
+      result = element.getLineOfInterest();
+      assert.isOk(result);
+      assert.equal(result!.lineNum, 12);
+      assert.equal(result!.side, Side.LEFT);
+    });
+
+    test('onLineSelected', () => {
+      const replaceStateStub = sinon.stub(history, 'replaceState');
+      assertIsDefined(element.cursor);
+      sinon
+        .stub(element.cursor, 'getAddress')
+        .returns({number: 123, leftSide: false});
+
+      element.changeNum = 321 as NumericChangeId;
+      element.change = {
+        ...createParsedChange(),
+        _number: 321 as NumericChangeId,
+        project: 'foo/bar' as RepoName,
+      };
+      element.patchNum = 5 as RevisionPatchSetNum;
+      element.basePatchNum = 3 as BasePatchSetNum;
+      const e = {detail: {number: 123, side: Side.RIGHT}} as CustomEvent;
+
+      element.onLineSelected(e);
+
+      assert.isTrue(replaceStateStub.called);
+    });
+
+    test('line selected on left side', () => {
+      const replaceStateStub = sinon.stub(history, 'replaceState');
+      assertIsDefined(element.cursor);
+      sinon
+        .stub(element.cursor, 'getAddress')
+        .returns({number: 123, leftSide: true});
+
+      element.changeNum = 321 as NumericChangeId;
+      element.change = {
+        ...createParsedChange(),
+        _number: 321 as NumericChangeId,
+        project: 'foo/bar' as RepoName,
+      };
+      element.patchNum = 5 as RevisionPatchSetNum;
+      element.basePatchNum = 3 as BasePatchSetNum;
+      const e = {detail: {number: 123, side: Side.LEFT}} as CustomEvent;
+
+      element.onLineSelected(e);
+
+      assert.isTrue(replaceStateStub.called);
+    });
+
+    test('handleToggleDiffMode', () => {
+      const userStub = sinon.stub(userModel, 'updatePreferences');
+      element.userPrefs = {
+        ...createDefaultPreferences(),
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      };
+
+      element.handleToggleDiffMode();
+      assert.deepEqual(userStub.lastCall.args[0], {
+        diff_view: DiffViewMode.UNIFIED,
+      });
+
+      element.userPrefs = {
+        ...createDefaultPreferences(),
+        diff_view: DiffViewMode.UNIFIED,
+      };
+
+      element.handleToggleDiffMode();
+      assert.deepEqual(userStub.lastCall.args[0], {
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      });
+    });
+
+    suite('findFileWithComment', () => {
+      test('empty file list', () => {
+        element.changeComments = new ChangeComments({
+          'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+          'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+        });
+        element.path = 'path/two.m4v';
+        assert.isUndefined(element.findFileWithComment(-1));
+        assert.isUndefined(element.findFileWithComment(1));
+      });
+
+      test('finds skips', () => {
+        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
+        element.files = {sortedPaths: fileList, changeFilesByPath: {}};
+        element.path = fileList[1];
+        element.changeComments = new ChangeComments({
+          'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+          'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+        });
+
+        assert.equal(element.findFileWithComment(-1), fileList[0]);
+        assert.equal(element.findFileWithComment(1), fileList[2]);
+
+        element.changeComments = new ChangeComments({
+          'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+          'path/two.m4v': [createComment('c1', 1, 1, 'path/two.m4v')],
+          'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+        });
+
+        assert.equal(element.findFileWithComment(-1), fileList[0]);
+        assert.equal(element.findFileWithComment(1), fileList[2]);
+
+        element.path = fileList[0];
+
+        assert.isUndefined(element.findFileWithComment(-1));
+        assert.equal(element.findFileWithComment(1), fileList[1]);
+
+        element.path = fileList[2];
+
+        assert.equal(element.findFileWithComment(-1), fileList[1]);
+        assert.isUndefined(element.findFileWithComment(1));
+      });
+
+      suite('skip next/previous', () => {
+        setup(() => {
+          element.files = getFilesFromFileList([
+            'path/one.jpg',
+            'path/two.m4v',
+            'path/three.wav',
+          ]);
+          element.patchNum = 2 as RevisionPatchSetNum;
+          element.basePatchNum = 1 as BasePatchSetNum;
+        });
+
+        suite('moveToFileWithComment previous', () => {
+          test('no previous', async () => {
+            element.changeComments = new ChangeComments({
+              'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+            });
+            element.path = element.files.sortedPaths[1];
+            await element.updateComplete;
+
+            element.moveToFileWithComment(-1);
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('w/ previous', async () => {
+            element.changeComments = new ChangeComments({
+              'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+              'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+            });
+            element.path = element.files.sortedPaths[1];
+            await element.updateComplete;
+
+            element.moveToFileWithComment(-1);
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(navToDiffStub.calledOnce);
+          });
+        });
+
+        suite('moveToFileWithComment next', () => {
+          test('no previous', async () => {
+            element.changeComments = new ChangeComments({
+              'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+            });
+            element.path = element.files.sortedPaths[1];
+            await element.updateComplete;
+
+            element.moveToFileWithComment(1);
+            assert.isTrue(navToChangeStub.calledOnce);
+            assert.isFalse(navToDiffStub.called);
+          });
+
+          test('w/ previous', async () => {
+            element.changeComments = new ChangeComments({
+              'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+              'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+            });
+            element.path = element.files.sortedPaths[1];
+            await element.updateComplete;
+
+            element.moveToFileWithComment(1);
+            assert.isFalse(navToChangeStub.called);
+            assert.isTrue(navToDiffStub.calledOnce);
+          });
+        });
+      });
+    });
+
+    test('computeFileNum', () => {
+      element.path = '/foo';
+      assert.equal(
+        element.computeFileNum([
+          {text: '/foo', value: '/foo'},
+          {text: '/bar', value: '/bar'},
+        ]),
+        1
+      );
+      element.path = '/bar';
+      assert.equal(
+        element.computeFileNum([
+          {text: '/foo', value: '/foo'},
+          {text: '/bar', value: '/bar'},
+        ]),
+        2
+      );
+    });
+
+    test('computeFileNumClass', () => {
+      assert.equal(element.computeFileNumClass(0, []), '');
+      assert.equal(
+        element.computeFileNumClass(1, [
+          {text: '/foo', value: '/foo'},
+          {text: '/bar', value: '/bar'},
+        ]),
+        'show'
+      );
+    });
+
+    test('f open file dropdown', async () => {
+      assertIsDefined(element.dropdown);
+      assertIsDefined(element.dropdown.dropdown);
+      assert.isFalse(element.dropdown.dropdown.opened);
+      pressKey(element, 'f');
+      await element.updateComplete;
+      assert.isTrue(element.dropdown.dropdown.opened);
+    });
+
+    suite('blame', () => {
+      test('toggle blame with button', () => {
+        assertIsDefined(element.diffHost);
+        const toggleBlame = sinon
+          .stub(element.diffHost, 'loadBlame')
+          .callsFake(() => Promise.resolve([]));
+        queryAndAssert<GrButton>(element, '#toggleBlame').click();
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+      test('toggle blame with shortcut', () => {
+        assertIsDefined(element.diffHost);
+        const toggleBlame = sinon
+          .stub(element.diffHost, 'loadBlame')
+          .callsFake(() => Promise.resolve([]));
+        pressKey(element, 'b');
+        assert.isTrue(toggleBlame.calledOnce);
+      });
+    });
+
+    suite('editMode behavior', () => {
+      setup(async () => {
+        element.loggedIn = true;
+        await element.updateComplete;
+      });
+
+      test('reviewed checkbox', async () => {
+        sinon.stub(element, 'handlePatchChange');
+        element.patchNum = 1 as RevisionPatchSetNum;
+        element.basePatchNum = PARENT;
+        await element.updateComplete;
+
+        let checkbox = queryAndAssert(element, '#reviewed');
+        assert.isTrue(isVisible(checkbox));
+
+        element.patchNum = EDIT;
+        await element.updateComplete;
+
+        checkbox = queryAndAssert(element, '#reviewed');
+        assert.isFalse(isVisible(checkbox));
+      });
+    });
+
+    suite('switching files', () => {
+      let dispatchEventStub: SinonStub;
+      let navToFileStub: SinonStub;
+      let moveToPreviousChunkStub: SinonStub;
+      let moveToNextChunkStub: SinonStub;
+      let isAtStartStub: SinonStub;
+      let isAtEndStub: SinonStub;
+      let nowStub: SinonStub;
+
+      setup(() => {
+        dispatchEventStub = sinon.stub(element, 'dispatchEvent').callThrough();
+        navToFileStub = sinon.stub(element, 'navToFile');
+        assertIsDefined(element.cursor);
+        moveToPreviousChunkStub = sinon.stub(
+          element.cursor,
+          'moveToPreviousChunk'
+        );
+        moveToNextChunkStub = sinon.stub(element.cursor, 'moveToNextChunk');
+        isAtStartStub = sinon.stub(element.cursor, 'isAtStart');
+        isAtEndStub = sinon.stub(element.cursor, 'isAtEnd');
+        nowStub = sinon.stub(Date, 'now');
+      });
+
+      test('shows toast when at the end of file', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        pressKey(element, 'n');
+
+        assert.isTrue(moveToNextChunkStub.called);
+        assert.equal(
+          dispatchEventStub.lastCall.args[0].type,
+          EventType.SHOW_ALERT
+        );
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('navigates to next file when n is tapped again', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
+        element.reviewedFiles = new Set(['file2']);
+        element.path = 'file1';
+
+        nowStub.returns(5);
+        pressKey(element, 'n');
+        nowStub.returns(10);
+        pressKey(element, 'n');
+
+        assert.isTrue(navToFileStub.called);
+        assert.deepEqual(navToFileStub.lastCall.args, [['file1', 'file3'], 1]);
+      });
+
+      test('does not navigate if n is tapped twice too slow', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        nowStub.returns(5);
+        pressKey(element, 'n');
+        nowStub.returns(6000);
+        pressKey(element, 'n');
+
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('shows toast when at the start of file', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        pressKey(element, 'p');
+
+        assert.isTrue(moveToPreviousChunkStub.called);
+        assert.equal(
+          dispatchEventStub.lastCall.args[0].type,
+          EventType.SHOW_ALERT
+        );
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('navigates to prev file when p is tapped again', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
+        element.reviewedFiles = new Set(['file2']);
+        element.path = 'file3';
+
+        nowStub.returns(5);
+        pressKey(element, 'p');
+        nowStub.returns(10);
+        pressKey(element, 'p');
+
+        assert.isTrue(navToFileStub.called);
+        assert.deepEqual(navToFileStub.lastCall.args, [['file1', 'file3'], -1]);
+      });
+
+      test('does not navigate if p is tapped twice too slow', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        nowStub.returns(5);
+        pressKey(element, 'p');
+        nowStub.returns(6000);
+        pressKey(element, 'p');
+
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('does not navigate when tapping n then p', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        nowStub.returns(5);
+        pressKey(element, 'n');
+
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        nowStub.returns(10);
+        pressKey(element, 'p');
+
+        assert.isFalse(navToFileStub.called);
+      });
+    });
+
+    test('shift+m navigates to next unreviewed file', async () => {
+      element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      element.reviewedFiles = new Set(['file1', 'file2']);
+      element.path = 'file1';
+      const reviewedStub = sinon.stub(element, 'setReviewed');
+      const navStub = sinon.stub(element, 'navToFile');
+      pressKey(element, 'M');
+      await waitEventLoop();
+
+      assert.isTrue(reviewedStub.lastCall.args[0]);
+      assert.deepEqual(navStub.lastCall.args, [['file1', 'file3'], 1]);
+    });
+
+    test('File change should trigger setUrl once', async () => {
+      element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
+      sinon.stub(element, 'initCursor');
+
+      // Load file1
+      viewModel.setState({
+        ...createDiffViewState(),
+        patchNum: 1 as RevisionPatchSetNum,
+        repo: 'test-project' as RepoName,
+        diffView: {path: 'file1'},
+      });
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      element.change = {
+        ...createParsedChange(),
+        revisions: createRevisions(1),
+      };
+      await element.updateComplete;
+      assert.isFalse(navToDiffStub.called);
+
+      // Switch to file2
+      element.handleFileChange(
+        new CustomEvent('value-change', {detail: {value: 'file2'}})
+      );
+      assert.isTrue(navToDiffStub.calledOnce);
+      assert.deepEqual(navToDiffStub.lastCall.firstArg, {path: 'file2'});
+
+      // This is to mock the param change triggered by above navigate
+      viewModel.setState({
+        ...createDiffViewState(),
+        patchNum: 1 as RevisionPatchSetNum,
+        repo: 'test-project' as RepoName,
+        diffView: {path: 'file2'},
+      });
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+
+      // No extra call
+      assert.isTrue(navToDiffStub.calledOnce);
+    });
+
+    test('_computeDownloadDropdownLinks', () => {
+      const downloadLinks = [
+        {
+          url: '/changes/test~12/revisions/1/patch?zip&path=index.php',
+          name: 'Patch',
+        },
+        {
+          url: '/changes/test~12/revisions/1/files/index.php/download?parent=1',
+          name: 'Left Content',
+        },
+        {
+          url: '/changes/test~12/revisions/1/files/index.php/download',
+          name: 'Right Content',
+        },
+      ];
+
+      element.change = createParsedChange();
+      element.change.project = 'test' as RepoName;
+      element.changeNum = 12 as NumericChangeId;
+      element.patchNum = 1 as RevisionPatchSetNum;
+      element.basePatchNum = PARENT;
+      element.path = 'index.php';
+      element.diff = createDiff();
+      assert.deepEqual(element.computeDownloadDropdownLinks(), downloadLinks);
+    });
+
+    test('_computeDownloadDropdownLinks diff returns renamed', () => {
+      const downloadLinks = [
+        {
+          url: '/changes/test~12/revisions/3/patch?zip&path=index.php',
+          name: 'Patch',
+        },
+        {
+          url: '/changes/test~12/revisions/2/files/index2.php/download',
+          name: 'Left Content',
+        },
+        {
+          url: '/changes/test~12/revisions/3/files/index.php/download',
+          name: 'Right Content',
+        },
+      ];
+
+      const diff = createDiff();
+      diff.change_type = 'RENAMED';
+      diff.meta_a!.name = 'index2.php';
+
+      element.change = createParsedChange();
+      element.change.project = 'test' as RepoName;
+      element.changeNum = 12 as NumericChangeId;
+      element.patchNum = 3 as RevisionPatchSetNum;
+      element.basePatchNum = 2 as BasePatchSetNum;
+      element.path = 'index.php';
+      element.diff = diff;
+      assert.deepEqual(element.computeDownloadDropdownLinks(), downloadLinks);
+    });
+
+    test('computeDownloadFileLink', () => {
+      assert.equal(
+        element.computeDownloadFileLink(
+          'test' as RepoName,
+          12 as NumericChangeId,
+          {patchNum: 1 as PatchSetNumber, basePatchNum: PARENT},
+          'index.php',
+          true
+        ),
+        '/changes/test~12/revisions/1/files/index.php/download?parent=1'
+      );
+
+      assert.equal(
+        element.computeDownloadFileLink(
+          'test' as RepoName,
+          12 as NumericChangeId,
+          {patchNum: 1 as PatchSetNumber, basePatchNum: -2 as PatchSetNumber},
+          'index.php',
+          true
+        ),
+        '/changes/test~12/revisions/1/files/index.php/download?parent=2'
+      );
+
+      assert.equal(
+        element.computeDownloadFileLink(
+          'test' as RepoName,
+          12 as NumericChangeId,
+          {patchNum: 3 as PatchSetNumber, basePatchNum: 2 as PatchSetNumber},
+          'index.php',
+          true
+        ),
+        '/changes/test~12/revisions/2/files/index.php/download'
+      );
+
+      assert.equal(
+        element.computeDownloadFileLink(
+          'test' as RepoName,
+          12 as NumericChangeId,
+          {patchNum: 3 as PatchSetNumber, basePatchNum: 2 as PatchSetNumber},
+          'index.php',
+          false
+        ),
+        '/changes/test~12/revisions/3/files/index.php/download'
+      );
+    });
+
+    test('computeDownloadPatchLink', () => {
+      assert.equal(
+        element.computeDownloadPatchLink(
+          'test' as RepoName,
+          12 as NumericChangeId,
+          {basePatchNum: PARENT, patchNum: 1 as RevisionPatchSetNum},
+          'index.php'
+        ),
+        '/changes/test~12/revisions/1/patch?zip&path=index.php'
+      );
+    });
+  });
+});
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
deleted file mode 100644
index ba6fe7e..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
+++ /dev/null
@@ -1,363 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the 'License');
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an 'AS IS' BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
-import {LineRange} from '../../../api/diff';
-
-export enum GrDiffGroupType {
-  /** Unchanged context. */
-  BOTH = 'both',
-
-  /** A widget used to show more context. */
-  CONTEXT_CONTROL = 'contextControl',
-
-  /** Added, removed or modified chunk. */
-  DELTA = 'delta',
-}
-
-export interface GrDiffLinePair {
-  left: GrDiffLine;
-  right: GrDiffLine;
-}
-
-/**
- * Hides lines in the given range behind a context control group.
- *
- * Groups that would be partially visible are split into their visible and
- * hidden parts, respectively.
- * The groups need to be "common groups", meaning they have to have either
- * originated from an `ab` chunk, or from an `a`+`b` chunk with
- * `common: true`.
- *
- * If the hidden range is 1 line or less, nothing is hidden and no context
- * control group is created.
- *
- * @param groups Common groups, ordered by their line ranges.
- * @param hiddenStart The first element to be hidden, as a
- *     non-negative line number offset relative to the first group's start
- *     line, left and right respectively.
- * @param hiddenEnd The first visible element after the hidden range,
- *     as a non-negative line number offset relative to the first group's
- *     start line, left and right respectively.
- */
-export function hideInContextControl(
-  groups: GrDiffGroup[],
-  hiddenStart: number,
-  hiddenEnd: number
-): GrDiffGroup[] {
-  if (groups.length === 0) return [];
-  // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
-  hiddenStart = Math.max(hiddenStart, 0);
-  hiddenEnd = Math.max(hiddenEnd, hiddenStart);
-
-  let before: GrDiffGroup[] = [];
-  let hidden = groups;
-  let after: GrDiffGroup[] = [];
-
-  const numHidden = hiddenEnd - hiddenStart;
-
-  // 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_line;
-        const beforeEnd = before[before.length - 1].lineRange.left.end_line;
-        beforeLength = beforeEnd - beforeStart + 1;
-      }
-      [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - beforeLength);
-    }
-  } else {
-    [hidden, after] = [[], hidden];
-  }
-
-  const result = [...before];
-  if (hidden.length) {
-    const ctxGroup = new GrDiffGroup(GrDiffGroupType.CONTEXT_CONTROL, []);
-    ctxGroup.contextGroups = hidden;
-    result.push(ctxGroup);
-  }
-  result.push(...after);
-  return result;
-}
-
-/**
- * Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
- * used in function _splitCommonGroups
- * Groups with some lines before and some lines after the split will be split
- * into two groups, which will be put into the first and second list.
- *
- * @param group The group to be split in two
- * @param leftSplit The line number relative to the split on the left side
- * @param rightSplit The line number relative to the split on the right side
- * @return two new groups, one before the split and another after it
- */
-function _splitGroupInTwo(
-  group: GrDiffGroup,
-  leftSplit: number,
-  rightSplit: number
-) {
-  let beforeSplit: GrDiffGroup | undefined;
-  let afterSplit: GrDiffGroup | undefined;
-  // split line is in the middle of a group, we need to break the group
-  // in lines before and after the split.
-  if (group.skip) {
-    // Currently we assume skip chunks "refuse" to be split. Expanding this
-    // group will in the future mean load more data - and therefore we want to
-    // fire an event when user wants to do it.
-    const closerToStartThanEnd =
-      leftSplit - group.lineRange.left.start_line <
-      group.lineRange.right.end_line - leftSplit;
-    if (closerToStartThanEnd) {
-      afterSplit = group;
-    } else {
-      beforeSplit = group;
-    }
-  } else {
-    const before = [];
-    const after = [];
-    for (const line of group.lines) {
-      if (
-        (line.beforeNumber && line.beforeNumber < leftSplit) ||
-        (line.afterNumber && line.afterNumber < rightSplit)
-      ) {
-        before.push(line);
-      } else {
-        after.push(line);
-      }
-    }
-    if (before.length) {
-      beforeSplit =
-        before.length === group.lines.length
-          ? group
-          : group.cloneWithLines(before);
-    }
-    if (after.length) {
-      afterSplit =
-        after.length === group.lines.length
-          ? group
-          : group.cloneWithLines(after);
-    }
-  }
-  return {beforeSplit, afterSplit};
-}
-
-/**
- * Splits a list of common groups into two lists of groups.
- *
- * Groups where all lines are before or all lines are after the split will be
- * retained as is and put into the first or second list respectively. Groups
- * with some lines before and some lines after the split will be split into
- * two groups, which will be put into the first and second list.
- *
- * @param split A line number offset relative to the first group's
- *     start line at which the groups should be split.
- * @return The outer array has 2 elements, the
- *   list of groups before and the list of groups after the split.
- */
-function _splitCommonGroups(
-  groups: GrDiffGroup[],
-  split: number
-): GrDiffGroup[][] {
-  if (groups.length === 0) return [[], []];
-  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_line < leftSplit ||
-      group.lineRange.right.end_line < rightSplit;
-    const isCompletelyAfter =
-      leftSplit <= group.lineRange.left.start_line ||
-      rightSplit <= group.lineRange.right.start_line;
-    if (isCompletelyBefore) {
-      beforeGroups.push(group);
-    } else if (isCompletelyAfter) {
-      afterGroups.push(group);
-    } else {
-      const {beforeSplit, afterSplit} = _splitGroupInTwo(
-        group,
-        leftSplit,
-        rightSplit
-      );
-      if (beforeSplit) {
-        beforeGroups.push(beforeSplit);
-      }
-      if (afterSplit) {
-        afterGroups.push(afterSplit);
-      }
-    }
-  }
-  return [beforeGroups, afterGroups];
-}
-
-/**
- * A chunk of the diff that should be rendered together.
- *
- * @constructor
- * @param {!GrDiffGroupType} type
- * @param {!Array<!GrDiffLine>=} opt_lines
- */
-export class GrDiffGroup {
-  constructor(readonly type: GrDiffGroupType, lines: GrDiffLine[] = []) {
-    lines.forEach((line: GrDiffLine) => this.addLine(line));
-  }
-
-  dueToRebase = false;
-
-  /**
-   * True means all changes in this line are whitespace changes that should
-   * not be highlighted as changed as per the user settings.
-   */
-  ignoredWhitespaceOnly = false;
-
-  /**
-   * True means it should not be collapsed (because it was in the URL, or
-   * there is a comment on that line)
-   */
-  keyLocation = false;
-
-  element?: HTMLElement;
-
-  lines: GrDiffLine[] = [];
-
-  adds: GrDiffLine[] = [];
-
-  removes: GrDiffLine[] = [];
-
-  contextGroups: GrDiffGroup[] = [];
-
-  skip?: number;
-
-  /** Both start and end line are inclusive. */
-  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;
-    };
-  };
-
-  /**
-   * Creates a new group with the same properties but different lines.
-   *
-   * The element property is not copied, because the original element is still a
-   * rendering of the old lines, so that would not make sense.
-   */
-  cloneWithLines(lines: GrDiffLine[]): GrDiffGroup {
-    const group = new GrDiffGroup(this.type, lines);
-    group.dueToRebase = this.dueToRebase;
-    group.ignoredWhitespaceOnly = this.ignoredWhitespaceOnly;
-    return group;
-  }
-
-  addLine(line: GrDiffLine) {
-    this.lines.push(line);
-
-    const notDelta =
-      this.type === GrDiffGroupType.BOTH ||
-      this.type === GrDiffGroupType.CONTEXT_CONTROL;
-    if (
-      notDelta &&
-      (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.REMOVE)
-    ) {
-      throw Error('Cannot add delta line to a non-delta group.');
-    }
-
-    if (line.type === GrDiffLineType.ADD) {
-      this.adds.push(line);
-    } else if (line.type === GrDiffLineType.REMOVE) {
-      this.removes.push(line);
-    }
-    this._updateRange(line);
-  }
-
-  getSideBySidePairs(): GrDiffLinePair[] {
-    if (
-      this.type === GrDiffGroupType.BOTH ||
-      this.type === GrDiffGroupType.CONTEXT_CONTROL
-    ) {
-      return this.lines.map(line => {
-        return {
-          left: line,
-          right: line,
-        };
-      });
-    }
-
-    const pairs: GrDiffLinePair[] = [];
-    let i = 0;
-    let j = 0;
-    while (i < this.removes.length || j < this.adds.length) {
-      pairs.push({
-        left: this.removes[i] || BLANK_LINE,
-        right: this.adds[j] || BLANK_LINE,
-      });
-      i++;
-      j++;
-    }
-    return pairs;
-  }
-
-  _updateRange(line: GrDiffLine) {
-    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_line === 0 ||
-        line.afterNumber < this.lineRange.right.start_line
-      ) {
-        this.lineRange.right.start_line = line.afterNumber;
-      }
-      if (line.afterNumber > this.lineRange.right.end_line) {
-        this.lineRange.right.end_line = line.afterNumber;
-      }
-    }
-
-    if (
-      line.type === GrDiffLineType.REMOVE ||
-      line.type === GrDiffLineType.BOTH
-    ) {
-      if (
-        this.lineRange.left.start_line === 0 ||
-        line.beforeNumber < this.lineRange.left.start_line
-      ) {
-        this.lineRange.left.start_line = 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
deleted file mode 100644
index 4c7d346..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
+++ /dev/null
@@ -1,222 +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 {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType, hideInContextControl} from './gr-diff-group.js';
-
-suite('gr-diff-group tests', () => {
-  test('delta line pairs', () => {
-    let group = new GrDiffGroup(GrDiffGroupType.DELTA);
-    const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
-    const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
-    const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
-    group.addLine(l1);
-    group.addLine(l2);
-    group.addLine(l3);
-    assert.deepEqual(group.lines, [l1, l2, l3]);
-    assert.deepEqual(group.adds, [l1, l2]);
-    assert.deepEqual(group.removes, [l3]);
-    assert.deepEqual(group.lineRange, {
-      left: {start_line: 64, end_line: 64},
-      right: {start_line: 128, end_line: 129},
-    });
-
-    let pairs = group.getSideBySidePairs();
-    assert.deepEqual(pairs, [
-      {left: l3, right: l1},
-      {left: BLANK_LINE, right: l2},
-    ]);
-
-    group = new GrDiffGroup(GrDiffGroupType.DELTA, [l1, l2, l3]);
-    assert.deepEqual(group.lines, [l1, l2, l3]);
-    assert.deepEqual(group.adds, [l1, l2]);
-    assert.deepEqual(group.removes, [l3]);
-
-    pairs = group.getSideBySidePairs();
-    assert.deepEqual(pairs, [
-      {left: l3, right: l1},
-      {left: BLANK_LINE, right: l2},
-    ]);
-  });
-
-  test('group/header line pairs', () => {
-    const l1 = new GrDiffLine(GrDiffLineType.BOTH, 64, 128);
-    const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
-    const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
-
-    let group = new GrDiffGroup(GrDiffGroupType.BOTH, [l1, l2, l3]);
-
-    assert.deepEqual(group.lines, [l1, l2, l3]);
-    assert.deepEqual(group.adds, []);
-    assert.deepEqual(group.removes, []);
-
-    assert.deepEqual(group.lineRange, {
-      left: {start_line: 64, end_line: 66},
-      right: {start_line: 128, end_line: 130},
-    });
-
-    let pairs = group.getSideBySidePairs();
-    assert.deepEqual(pairs, [
-      {left: l1, right: l1},
-      {left: l2, right: l2},
-      {left: l3, right: l3},
-    ]);
-
-    group = new GrDiffGroup(GrDiffGroupType.CONTEXT_CONTROL, [l1, l2, l3]);
-    assert.deepEqual(group.lines, [l1, l2, l3]);
-    assert.deepEqual(group.adds, []);
-    assert.deepEqual(group.removes, []);
-
-    pairs = group.getSideBySidePairs();
-    assert.deepEqual(pairs, [
-      {left: l1, right: l1},
-      {left: l2, right: l2},
-      {left: l3, right: l3},
-    ]);
-  });
-
-  test('adding delta lines to non-delta group', () => {
-    const l1 = new GrDiffLine(GrDiffLineType.ADD);
-    const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
-    const l3 = new GrDiffLine(GrDiffLineType.BOTH);
-
-    let group = new GrDiffGroup(GrDiffGroupType.BOTH);
-    assert.throws(group.addLine.bind(group, l1));
-    assert.throws(group.addLine.bind(group, l2));
-    assert.doesNotThrow(group.addLine.bind(group, l3));
-
-    group = new GrDiffGroup(GrDiffGroupType.CONTEXT_CONTROL);
-    assert.throws(group.addLine.bind(group, l1));
-    assert.throws(group.addLine.bind(group, l2));
-    assert.doesNotThrow(group.addLine.bind(group, l3));
-  });
-
-  suite('hideInContextControl', () => {
-    let groups;
-    setup(() => {
-      groups = [
-        new GrDiffGroup(GrDiffGroupType.BOTH, [
-          new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-          new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-          new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-        ]),
-        new GrDiffGroup(GrDiffGroupType.DELTA, [
-          new GrDiffLine(GrDiffLineType.REMOVE, 8),
-          new GrDiffLine(GrDiffLineType.ADD, 0, 10),
-          new GrDiffLine(GrDiffLineType.REMOVE, 9),
-          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, 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, 7);
-      assert.equal(collapsedGroups.length, 3);
-
-      assert.equal(collapsedGroups[0], groups[0]);
-
-      assert.equal(collapsedGroups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
-      assert.equal(collapsedGroups[1].contextGroups.length, 1);
-      assert.equal(collapsedGroups[1].contextGroups[0], groups[1]);
-
-      assert.equal(collapsedGroups[2], groups[2]);
-    });
-
-    test('splits partially hidden groups', () => {
-      const collapsedGroups = hideInContextControl(groups, 4, 8);
-      assert.equal(collapsedGroups.length, 4);
-      assert.equal(collapsedGroups[0], groups[0]);
-
-      assert.equal(collapsedGroups[1].type, GrDiffGroupType.DELTA);
-      assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
-      assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
-
-      assert.equal(collapsedGroups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
-      assert.equal(collapsedGroups[2].contextGroups.length, 2);
-
-      assert.equal(
-          collapsedGroups[2].contextGroups[0].type,
-          GrDiffGroupType.DELTA);
-      assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].adds,
-          groups[1].adds.slice(1));
-      assert.deepEqual(
-          collapsedGroups[2].contextGroups[0].removes,
-          groups[1].removes.slice(1));
-
-      assert.equal(
-          collapsedGroups[2].contextGroups[1].type,
-          GrDiffGroupType.BOTH);
-      assert.deepEqual(
-          collapsedGroups[2].contextGroups[1].lines,
-          [groups[2].lines[0]]);
-
-      assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
-      assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
-    });
-
-    suite('with skip chunks', () => {
-      setup(() => {
-        const skipGroup = new GrDiffGroup(GrDiffGroupType.BOTH);
-        skipGroup.skip = 60;
-        skipGroup.lineRange = {
-          left: {start_line: 8, end_line: 67},
-          right: {start_line: 10, end_line: 69},
-        };
-        groups = [
-          new GrDiffGroup(GrDiffGroupType.BOTH, [
-            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
-            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
-            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
-          ]),
-          skipGroup,
-          new GrDiffGroup(GrDiffGroupType.BOTH, [
-            new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
-            new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
-            new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
-          ]),
-        ];
-      });
-
-      test('refuses to split skip group when closer to before', () => {
-        const collapsedGroups = hideInContextControl(groups, 4, 10);
-        assert.deepEqual(groups, collapsedGroups);
-      });
-    });
-
-    test('groups unchanged if the hidden range is empty', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 0, 0), groups);
-    });
-
-    test('groups unchanged if there is only 1 line to hide', () => {
-      assert.deepEqual(
-          hideInContextControl(groups, 3, 4), groups);
-    });
-  });
-});
-
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
deleted file mode 100644
index 2927101..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
+++ /dev/null
@@ -1,62 +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 {
-  GrDiffLine as GrDiffLineApi,
-  GrDiffLineType,
-  LineNumber,
-} from '../../../api/diff';
-
-export {GrDiffLineType, LineNumber};
-
-export const FILE = 'FILE';
-
-export class GrDiffLine implements GrDiffLineApi {
-  constructor(
-    readonly type: GrDiffLineType,
-    public beforeNumber: LineNumber = 0,
-    public afterNumber: LineNumber = 0
-  ) {}
-
-  hasIntralineInfo = false;
-
-  highlights: Highlights[] = [];
-
-  text = '';
-
-  // TODO(TS): remove this properties
-  static readonly Type = GrDiffLineType;
-
-  static readonly File = FILE;
-}
-
-/**
- * A line highlight object consists of three fields:
- * - contentIndex: The index of the chunk `content` field (the line
- *   being referred to).
- * - startIndex: Index of the character where the highlight should begin.
- * - endIndex: (optional) Index of the character where the highlight should
- *   end. If omitted, the highlight is meant to be a continuation onto the
- *   next line.
- */
-export interface Highlights {
-  contentIndex: number;
-  startIndex: number;
-  endIndex?: number;
-}
-
-export const BLANK_LINE = new GrDiffLine(GrDiffLineType.BLANK);
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
deleted file mode 100644
index 7393606..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ /dev/null
@@ -1,174 +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 {CommentRange} from '../../../types/common';
-import {FILE, LineNumber} from './gr-diff-line';
-import {Side} from '../../../constants/constants';
-import {DiffInfo} from '../../../types/diff';
-
-// If any line of the diff is more than the character limit, then disable
-// syntax highlighting for the entire file.
-export const SYNTAX_MAX_LINE_LENGTH = 500;
-
-/**
- * Compare two ranges. Either argument may be falsy, but will only return
- * true if both are falsy or if neither are falsy and have the same position
- * values.
- */
-export function rangesEqual(a?: CommentRange, b?: CommentRange): boolean {
-  if (!a && !b) {
-    return true;
-  }
-  if (!a || !b) {
-    return false;
-  }
-  return (
-    a.start_line === b.start_line &&
-    a.start_character === b.start_character &&
-    a.end_line === b.end_line &&
-    a.end_character === b.end_character
-  );
-}
-
-export function isLongCommentRange(range: CommentRange): boolean {
-  return range.end_line - range.start_line > 10;
-}
-
-export function getLineNumberByChild(node?: Node) {
-  return getLineNumber(getLineElByChild(node));
-}
-
-export function lineNumberToNumber(lineNumber?: LineNumber | null): number {
-  if (!lineNumber) return 0;
-  if (lineNumber === 'LOST') return 0;
-  if (lineNumber === 'FILE') return 0;
-  return lineNumber;
-}
-
-export function getLineElByChild(node?: Node): HTMLElement | null {
-  while (node) {
-    if (node instanceof Element) {
-      if (node.classList.contains('lineNum')) {
-        return node as HTMLElement;
-      }
-      if (node.classList.contains('section')) {
-        return null;
-      }
-    }
-    node = node.previousSibling ?? node.parentElement ?? undefined;
-  }
-  return null;
-}
-
-export function getSideByLineEl(lineEl: Element) {
-  return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
-}
-
-export function getLineNumber(lineEl?: Element | null): LineNumber | null {
-  if (!lineEl) return null;
-  const lineNumberStr = lineEl.getAttribute('data-value');
-  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;
-}
-
-const VISIBLE_TEXT_NODE_TYPES = [Node.TEXT_NODE, Node.ELEMENT_NODE];
-
-export function getPreviousContentNodes(node?: Node | null) {
-  const sibs = [];
-  while (node) {
-    const {parentNode, previousSibling} = node;
-    const topContentLevel =
-      parentNode &&
-      (parentNode as HTMLElement).classList.contains('contentText');
-    let previousEl: Node | undefined | null;
-    if (previousSibling) {
-      previousEl = previousSibling;
-    } else if (!topContentLevel) {
-      previousEl = parentNode?.previousSibling;
-    }
-    if (previousEl && VISIBLE_TEXT_NODE_TYPES.includes(previousEl.nodeType)) {
-      sibs.push(previousEl);
-    }
-    node = previousEl;
-  }
-  return sibs;
-}
-
-export function isThreadEl(node: Node): node is GrDiffThreadElement {
-  return (
-    node.nodeType === Node.ELEMENT_NODE &&
-    (node as Element).classList.contains('comment-thread')
-  );
-}
-
-/**
- * @return whether any of the lines in diff are longer
- * than SYNTAX_MAX_LINE_LENGTH.
- */
-export function anyLineTooLong(diff?: DiffInfo) {
-  if (!diff) return false;
-  return diff.content.some(section => {
-    const lines = section.ab
-      ? section.ab
-      : (section.a || []).concat(section.b || []);
-    return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
-  });
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
deleted file mode 100644
index 6003a2f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ /dev/null
@@ -1,1107 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles';
-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 {htmlTemplate} from './gr-diff_html';
-import {LineNumber} from './gr-diff-line';
-import {
-  getLine,
-  getLineElByChild,
-  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,
-  NumericChangeId,
-} from '../../../types/common';
-import {
-  DiffInfo,
-  DiffPreferencesInfo,
-  DiffPreferencesInfoKey,
-} from '../../../types/diff';
-import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
-import {
-  GrDiffBuilderElement,
-  getLineNumberCellWidth,
-} from '../gr-diff-builder/gr-diff-builder-element';
-import {
-  CoverageRange,
-  DiffLayer,
-  PolymerDomWrapper,
-} from '../../../types/types';
-import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
-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 {fireAlert, fireEvent} from '../../../utils/event-util';
-import {MovedLinkClickedEvent} from '../../../types/events';
-import {getContentEditableRange} from '../../../utils/safari-selection-util';
-import {AbortStop} from '../../../api/core';
-import {
-  CreateCommentEventDetail as CreateCommentEventDetailApi,
-  RenderPreferences,
-  GrDiff as GrDiffApi,
-} from '../../../api/diff';
-import {isSafari, toggleClass} from '../../../utils/dom-util';
-import {assertIsDefined} from '../../../utils/common-util';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {
-  DiffContextExpandedEventDetail,
-  getResponsiveMode,
-  isResponsive,
-} from '../gr-diff-builder/gr-diff-builder';
-
-const NO_NEWLINE_BASE = 'No newline at end of base file.';
-const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
-
-const LARGE_DIFF_THRESHOLD_LINES = 10000;
-const FULL_CONTEXT = -1;
-
-const COMMIT_MSG_PATH = '/COMMIT_MSG';
-/**
- * 72 is the unofficial length standard for git commit messages.
- * Derived from the fact that git log/show appends 4 ws in the beginning of
- * each line when displaying commit messages. To center the commit message
- * in an 80 char terminal a 4 ws border is added to the rightmost side:
- * 4 + 72 + 4
- */
-const COMMIT_MSG_LINE_LENGTH = 72;
-
-export interface LineOfInterest {
-  number: number;
-  leftSide: boolean;
-}
-
-export interface GrDiff {
-  $: {
-    highlights: GrDiffHighlight;
-    diffBuilder: GrDiffBuilderElement;
-    diffTable: HTMLTableElement;
-  };
-}
-
-export interface CreateCommentEventDetail extends CreateCommentEventDetailApi {
-  path: string;
-}
-
-@customElement('gr-diff')
-export class GrDiff extends PolymerElement implements GrDiffApi {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the user selects a line.
-   *
-   * @event line-selected
-   */
-
-  /**
-   * Fired if being logged in is required.
-   *
-   * @event show-auth-required
-   */
-
-  /**
-   * Fired when a comment is created
-   *
-   * @event create-comment
-   */
-
-  /**
-   * Fired when rendering, including syntax highlighting, is done. Also fired
-   * when no rendering can be done because required preferences are not set.
-   *
-   * @event render
-   */
-
-  /**
-   * Fired for interaction reporting when a diff context is expanded.
-   * Contains an event.detail with numLines about the number of lines that
-   * were expanded.
-   *
-   * @event diff-context-expanded
-   */
-
-  @property({type: String})
-  changeNum?: NumericChangeId;
-
-  @property({type: Boolean})
-  noAutoRender = false;
-
-  @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;
-
-  @property({type: Boolean})
-  isImageDiff?: boolean;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  override hidden = false;
-
-  @property({type: Boolean})
-  noRenderOnPrefsChange?: boolean;
-
-  @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[] = [];
-
-  @property({type: Boolean, observer: '_lineWrappingObserver'})
-  lineWrapping = false;
-
-  @property({type: String, observer: '_viewModeObserver'})
-  viewMode = DiffViewMode.SIDE_BY_SIDE;
-
-  @property({type: Object})
-  lineOfInterest?: LineOfInterest;
-
-  /**
-   * 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;
-
-  @property({type: Object, observer: '_diffChanged'})
-  diff?: DiffInfo;
-
-  @property({type: Array, computed: '_computeDiffHeaderItems(diff.*)'})
-  _diffHeaderItems: string[] = [];
-
-  @property({type: String})
-  _diffTableClass = '';
-
-  @property({type: Object})
-  baseImage?: ImageInfo;
-
-  @property({type: Object})
-  revisionImage?: ImageInfo;
-
-  /**
-   * In order to allow multi-select in Safari browsers, a workaround is required
-   * to trigger 'beforeinput' events to get a list of static ranges. This is
-   * obtained by making the content of the diff table "contentEditable".
-   */
-  @property({type: Boolean})
-  override isContentEditable = isSafari();
-
-  /**
-   * Whether the safety check for large diffs when whole-file is set has
-   * been bypassed. If the value is null, then the safety has not been
-   * bypassed. If the value is a number, then that number represents the
-   * context preference to use when rendering the bypassed diff.
-   */
-  @property({type: Number})
-  _safetyBypass: number | null = null;
-
-  @property({type: Boolean})
-  _showWarning?: boolean;
-
-  @property({type: String})
-  errorMessage: string | null = null;
-
-  @property({type: Object, observer: '_blameChanged'})
-  blame: BlameInfo[] | null = null;
-
-  @property({type: Number})
-  parentIndex?: number;
-
-  @property({type: Boolean})
-  showNewlineWarningLeft = false;
-
-  @property({type: Boolean})
-  showNewlineWarningRight = false;
-
-  @property({type: String, observer: '_useNewImageDiffUiObserver'})
-  useNewImageDiffUi = false;
-
-  @property({
-    type: String,
-    computed:
-      '_computeNewlineWarning(' +
-      'showNewlineWarningLeft, showNewlineWarningRight)',
-  })
-  _newlineWarning: string | null = null;
-
-  @property({type: Number})
-  _diffLength?: number;
-
-  /**
-   * Observes comment nodes added or removed after the initial render.
-   * Can be used to unregister when the entire diff is (re-)rendered or upon
-   * detachment.
-   */
-  @property({type: Object})
-  _incrementalNodeObserver?: FlattenedNodesObserver;
-
-  /**
-   * Observes comment nodes added or removed at any point.
-   * Can be used to unregister upon detachment.
-   */
-  @property({type: Object})
-  _nodeObserver?: FlattenedNodesObserver;
-
-  @property({type: Array})
-  layers?: DiffLayer[];
-
-  @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 connectedCallback() {
-    super.connectedCallback();
-    this._observeNodes();
-    this.isAttached = true;
-  }
-
-  override disconnectedCallback() {
-    this.isAttached = false;
-    this.renderDiffTableTask?.cancel();
-    this._unobserveIncrementalNodes();
-    this._unobserveNodes();
-    super.disconnectedCallback();
-  }
-
-  getLineNumEls(side: Side): HTMLElement[] {
-    return Array.from(
-      this.root?.querySelectorAll<HTMLElement>(`.lineNum.${side}`) ?? []
-    );
-  }
-
-  showNoChangeMessage(
-    loading?: boolean,
-    prefs?: DiffPreferencesInfo,
-    diffLength?: number,
-    diff?: DiffInfo
-  ) {
-    return (
-      !loading &&
-      diff &&
-      !diff.binary &&
-      prefs &&
-      prefs.ignore_whitespace !== 'IGNORE_NONE' &&
-      diffLength === 0
-    );
-  }
-
-  @observe('loggedIn', 'isAttached')
-  _enableSelectionObserver(loggedIn: boolean, isAttached: boolean) {
-    if (loggedIn && isAttached) {
-      document.addEventListener('selectionchange', this.handleSelectionChange);
-      document.addEventListener('mouseup', this.handleMouseUp);
-    } else {
-      document.removeEventListener(
-        'selectionchange',
-        this.handleSelectionChange
-      );
-      document.removeEventListener('mouseup', this.handleMouseUp);
-    }
-  }
-
-  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);
-  };
-
-  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() {
-    // When using native shadow DOM, the selection returned by
-    // document.getSelection() cannot reference the actual DOM elements making
-    // up the diff in Safari because they are in the shadow DOM of the gr-diff
-    // element. This takes the shadow DOM selection if one exists.
-    return this.root instanceof ShadowRoot && this.root.getSelection
-      ? this.root.getSelection()
-      : isSafari()
-      ? getContentEditableRange()
-      : document.getSelection();
-  }
-
-  _observeNodes() {
-    this._nodeObserver = (dom(this) as PolymerDomWrapper).observeNodes(info => {
-      const addedThreadEls = info.addedNodes.filter(isThreadEl);
-      const removedThreadEls = info.removedNodes.filter(isThreadEl);
-      this._updateRanges(addedThreadEls, removedThreadEls);
-      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: GrDiffThreadElement[],
-    removedThreadEls: GrDiffThreadElement[]
-  ) {
-    function commentRangeFromThreadEl(
-      threadEl: GrDiffThreadElement
-    ): CommentRangeLayer | undefined {
-      const side = getSide(threadEl);
-      if (!side) return undefined;
-      const range = getRange(threadEl);
-      if (!range) return undefined;
-
-      return {side, range, hovering: false, rootId: threadEl.rootId};
-    }
-
-    // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
-    const addedCommentRanges = addedThreadEls
-      .map(commentRangeFromThreadEl)
-      .filter(range => !!range) as CommentRangeLayer[];
-    const removedCommentRanges = removedThreadEls
-      .map(commentRangeFromThreadEl)
-      .filter(range => !!range) as CommentRangeLayer[];
-    for (const removedCommentRange of removedCommentRanges) {
-      const i = this._commentRanges.findIndex(
-        cr =>
-          cr.side === removedCommentRange.side &&
-          rangesEqual(cr.range, removedCommentRange.range)
-      );
-      this.splice('_commentRanges', i, 1);
-    }
-
-    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.
-   *
-   */
-  _computeKeyLocations() {
-    const keyLocations: KeyLocations = {left: {}, right: {}};
-    if (this.lineOfInterest) {
-      const side = this.lineOfInterest.leftSide ? Side.LEFT : Side.RIGHT;
-      keyLocations[side][this.lineOfInterest.number] = true;
-    }
-    const threadEls = (dom(this) as PolymerDomWrapper)
-      .getEffectiveChildNodes()
-      .filter(isThreadEl);
-
-    for (const threadEl of threadEls) {
-      const side = getSide(threadEl);
-      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) {
-        keyLocations[side][commentRange.start_line] = true;
-      }
-    }
-    return keyLocations;
-  }
-
-  // Dispatch events that are handled by the gr-diff-highlight.
-  _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.renderDiffTableTask?.cancel();
-  }
-
-  getCursorStops(): Array<HTMLElement | AbortStop> {
-    if (this.hidden && this.noAutoRender) return [];
-
-    if (this.loading) {
-      return [new AbortStop()];
-    }
-
-    return Array.from(
-      this.root?.querySelectorAll<HTMLElement>(
-        ':not(.contextControl) > .diff-row'
-      ) || []
-    ).filter(tr => tr.querySelector('button'));
-  }
-
-  isRangeSelected() {
-    return !!this.$.highlights.selectedRange;
-  }
-
-  toggleLeftDiff() {
-    toggleClass(this, 'no-left');
-  }
-
-  _blameChanged(newValue?: BlameInfo[] | null) {
-    if (newValue === undefined) return;
-    this.$.diffBuilder.setBlame(newValue);
-    if (newValue) {
-      this.classList.add('showBlame');
-    } else {
-      this.classList.remove('showBlame');
-    }
-  }
-
-  _computeContainerClass(
-    loggedIn: boolean,
-    viewMode: DiffViewMode,
-    displayLine: boolean
-  ) {
-    const classes = ['diffContainer'];
-    if (viewMode === DiffViewMode.UNIFIED) classes.push('unified');
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) classes.push('sideBySide');
-    if (getHiddenScroll()) classes.push('hiddenscroll');
-    if (loggedIn) classes.push('canComment');
-    if (displayLine) classes.push('displayLine');
-    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.getAttribute('data-value') !== 'LOST' &&
-      (el.classList.contains('lineNum') ||
-        el.classList.contains('lineNumButton'))
-    ) {
-      this.addDraftAtLine(el);
-    } else if (
-      el.tagName === 'HL' ||
-      el.classList.contains('content') ||
-      el.classList.contains('contentText')
-    ) {
-      const target = getLineElByChild(el);
-      if (target) {
-        this._selectLine(target);
-      }
-    }
-  }
-
-  _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: {
-          number,
-          side,
-          path: this.path,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _movedLinkClicked(e: MovedLinkClickedEvent) {
-    this._dispatchSelectedLine(e.detail.lineNum, e.detail.side);
-  }
-
-  addDraftAtLine(el: Element) {
-    this._selectLine(el);
-
-    const lineNum = getLineNumber(el);
-    if (lineNum === null) {
-      fireAlert(this, 'Invalid line number');
-      return;
-    }
-
-    this._createComment(el, lineNum);
-  }
-
-  createRangeComment() {
-    if (!this.isRangeSelected()) {
-      throw Error('Selection is needed for new range comment');
-    }
-    const selectedRange = this.$.highlights.selectedRange;
-    if (!selectedRange) throw Error('selected range not set');
-    const {side, range} = selectedRange;
-    this._createCommentForSelection(side, range);
-  }
-
-  _createCommentForSelection(side: Side, range: CommentRange) {
-    const lineNum = range.end_line;
-    const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
-    if (lineEl) {
-      this._createComment(lineEl, lineNum, side, range);
-    }
-  }
-
-  _handleCreateRangeComment(e: CustomEvent) {
-    const range = e.detail.range;
-    const side = e.detail.side;
-    this._createCommentForSelection(side, range);
-  }
-
-  _createComment(
-    lineEl: Element,
-    lineNum: LineNumber,
-    side?: Side,
-    range?: CommentRange
-  ) {
-    const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
-    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<CreateCommentEventDetail>('create-comment', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          path: this.path,
-          side,
-          lineNum,
-          range,
-        },
-      })
-    );
-  }
-
-  _getThreadGroupForLine(contentEl: Element) {
-    return contentEl.querySelector('.thread-group');
-  }
-
-  /**
-   * Gets or creates a comment thread group for a specific line and side on a
-   * diff.
-   */
-  _getOrCreateThreadGroup(contentEl: Element, commentSide: Side) {
-    // Check if thread group exists.
-    let threadGroupEl = this._getThreadGroupForLine(contentEl);
-    if (!threadGroupEl) {
-      threadGroupEl = document.createElement('div');
-      threadGroupEl.className = 'thread-group';
-      threadGroupEl.setAttribute('data-side', commentSide);
-      contentEl.appendChild(threadGroupEl);
-    }
-    return threadGroupEl;
-  }
-
-  _getCommentSideByLineAndContent(lineEl: Element, contentEl: Element): Side {
-    return lineEl.classList.contains(Side.LEFT) ||
-      contentEl.classList.contains('remove')
-      ? Side.LEFT
-      : Side.RIGHT;
-  }
-
-  _prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
-    if (!this._prefsEqual(newPrefs, oldPrefs)) {
-      this._prefsChanged(newPrefs);
-    }
-  }
-
-  _prefsEqual(prefs1: DiffPreferencesInfo, prefs2: DiffPreferencesInfo) {
-    if (prefs1 === prefs2) {
-      return true;
-    }
-    if (!prefs1 || !prefs2) {
-      return false;
-    }
-    // Scan the preference objects one level deep to see if they differ.
-    const keys1 = Object.keys(prefs1) as DiffPreferencesInfoKey[];
-    const keys2 = Object.keys(prefs2) as DiffPreferencesInfoKey[];
-    return (
-      keys1.length === keys2.length &&
-      keys1.every(key => prefs1[key] === prefs2[key]) &&
-      keys2.every(key => prefs1[key] === prefs2[key])
-    );
-  }
-
-  _pathObserver() {
-    // Call _prefsChanged(), because line-limit style value depends on path.
-    this._prefsChanged(this.prefs);
-  }
-
-  _viewModeObserver() {
-    this._prefsChanged(this.prefs);
-  }
-
-  _cleanup() {
-    this.cancel();
-    this.blame = null;
-    this._safetyBypass = null;
-    this._showWarning = false;
-    this.clearDiffContent();
-  }
-
-  _lineWrappingObserver() {
-    this._prefsChanged(this.prefs);
-  }
-
-  _useNewImageDiffUiObserver() {
-    this._prefsChanged(this.prefs);
-  }
-
-  _prefsChanged(prefs?: DiffPreferencesInfo) {
-    if (!prefs) return;
-
-    this.blame = null;
-    this._updatePreferenceStyles(prefs, this.renderPrefs);
-
-    if (this.diff && !this.noRenderOnPrefsChange) {
-      this._debounceRenderDiffTable();
-    }
-  }
-
-  _updatePreferenceStyles(
-    prefs: DiffPreferencesInfo,
-    renderPrefs?: RenderPreferences
-  ) {
-    const lineLength =
-      this.path === COMMIT_MSG_PATH
-        ? COMMIT_MSG_LINE_LENGTH
-        : prefs.line_length;
-    const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
-    const stylesToUpdate: {[key: string]: string} = {};
-
-    const responsiveMode = getResponsiveMode(prefs, renderPrefs);
-    const responsive = isResponsive(responsiveMode);
-    this._diffTableClass = responsive ? 'responsive' : '';
-    const lineLimit = `${lineLength}ch`;
-    stylesToUpdate['--line-limit-marker'] =
-      responsiveMode === 'FULL_RESPONSIVE' ? lineLimit : '-1px';
-    stylesToUpdate['--content-width'] = responsive ? 'none' : lineLimit;
-    if (responsiveMode === 'SHRINK_ONLY') {
-      // Calculating ideal (initial) width for the whole table including
-      // width of each table column (content and line number columns) and
-      // border. We also add a 1px correction as some values are calculated
-      // in 'ch'.
-
-      // We might have 1 to 2 columns for content depending if side-by-side
-      // or unified mode
-      const contentWidth = `${sideBySide ? 2 : 1} * ${lineLimit}`;
-
-      // We always have 2 columns for line number
-      const lineNumberWidth = `2 * ${getLineNumberCellWidth(prefs)}px`;
-
-      // border-right in ".section" css definition (in gr-diff_html.ts)
-      const sectionRightBorder = '1px';
-
-      // As some of these calculations are done using 'ch' we end up
-      // having <1px difference between ideal and calculated size for each side
-      // leading to lines using the max columns (e.g. 80) to wrap (decided
-      // exclusively by the browser).This happens even in monospace fonts.
-      // Empirically adding 2px as correction to be sure wrapping won't happen in these
-      // cases so it doesn' block further experimentation with the SHRINK_MODE.
-      // This was previously set to 1px but due to to a more aggressive
-      // text wrapping (via word-break: break-all; - check .contextText)
-      // we need to be even more lenient in some cases.
-      // If we find another way to avoid this correction we will change it.
-      const dontWrapCorrection = '2px';
-      stylesToUpdate[
-        '--diff-max-width'
-      ] = `calc(${contentWidth} + ${lineNumberWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`;
-    } else {
-      stylesToUpdate['--diff-max-width'] = 'none';
-    }
-    if (prefs.font_size) {
-      stylesToUpdate['--font-size'] = `${prefs.font_size}px`;
-    }
-
-    this.updateStyles(stylesToUpdate);
-  }
-
-  _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');
-    }
-    if (this.prefs) {
-      this._updatePreferenceStyles(this.prefs, renderPrefs);
-    }
-    this.$.diffBuilder.updateRenderPrefs(renderPrefs);
-  }
-
-  _diffChanged(newValue?: DiffInfo) {
-    this._setLoading(true);
-    this._cleanup();
-    if (newValue) {
-      this._diffLength = this.getDiffLength(newValue);
-      this._debounceRenderDiffTable();
-    }
-  }
-
-  /**
-   * When called multiple times from the same microtask, will call
-   * _renderDiffTable only once, in the next microtask, unless it is cancelled
-   * before that microtask runs.
-   *
-   * This should be used instead of calling _renderDiffTable directly to
-   * render the diff in response to an input change, because there may be
-   * multiple inputs changing in the same microtask, but we only want to
-   * render once.
-   */
-  _debounceRenderDiffTable() {
-    this.renderDiffTableTask = debounce(this.renderDiffTableTask, () =>
-      this._renderDiffTable()
-    );
-  }
-
-  _renderDiffTable() {
-    if (!this.prefs) {
-      fireEvent(this, 'render');
-      return;
-    }
-    if (
-      this.prefs.context === -1 &&
-      this._diffLength &&
-      this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
-      this._safetyBypass === null
-    ) {
-      this._showWarning = true;
-      fireEvent(this, 'render');
-      return;
-    }
-
-    this._showWarning = false;
-
-    const keyLocations = this._computeKeyLocations();
-    const bypassPrefs = this._getBypassPrefs(this.prefs);
-    this.$.diffBuilder
-      .render(keyLocations, bypassPrefs, this.renderPrefs)
-      .then(() => {
-        this.dispatchEvent(
-          new CustomEvent('render', {
-            bubbles: true,
-            composed: true,
-            detail: {contentRendered: true},
-          })
-        );
-      });
-  }
-
-  _handleRenderContent() {
-    this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
-      element.remove()
-    );
-    this._setLoading(false);
-    this._unobserveIncrementalNodes();
-    this._incrementalNodeObserver = (
-      dom(this) as PolymerDomWrapper
-    ).observeNodes(info => {
-      const addedThreadEls = info.addedNodes.filter(isThreadEl);
-      // Removed nodes do not need to be handled because all this code does is
-      // adding a slot for the added thread elements, and the extra slots do
-      // not hurt. It's probably a bigger performance cost to remove them than
-      // to keep them around. Medium term we can even consider to add one slot
-      // for each line from the start.
-      let lastEl;
-      for (const threadEl of addedThreadEls) {
-        const lineNum = getLine(threadEl);
-        const commentSide = getSide(threadEl);
-        const range = getRange(threadEl);
-        if (!commentSide) continue;
-        const lineEl = this.$.diffBuilder.getLineElByNumber(
-          lineNum,
-          commentSide
-        );
-        // When the line the comment refers to does not exist, log an error
-        // but don't crash. This can happen e.g. if the API does not fully
-        // validate e.g. (robot) comments
-        if (!lineEl) {
-          console.error(
-            'thread attached to line ',
-            commentSide,
-            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.
-        // The thread group may already have a slot with the right name, but
-        // that is okay because the first matching slot is used and the rest
-        // are ignored.
-        const slot = document.createElement('slot') as HTMLSlotElement;
-        if (slotAtt) slot.name = slotAtt;
-        threadGroupEl.appendChild(slot);
-        lastEl = threadEl;
-      }
-
-      // Safari is not binding newly created comment-thread
-      // with the slot somehow, replace itself will rebind it
-      // @see Issue 11182
-      if (isSafari() && 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(
-        this._incrementalNodeObserver
-      );
-    }
-  }
-
-  _unobserveNodes() {
-    if (this._nodeObserver) {
-      (dom(this) as PolymerDomWrapper).unobserveNodes(this._nodeObserver);
-    }
-  }
-
-  /**
-   * Get the preferences object including the safety bypass context (if any).
-   */
-  _getBypassPrefs(prefs: DiffPreferencesInfo) {
-    if (this._safetyBypass !== null) {
-      return {...prefs, context: this._safetyBypass};
-    }
-    return prefs;
-  }
-
-  clearDiffContent() {
-    this._unobserveIncrementalNodes();
-    while (this.$.diffTable.hasChildNodes()) {
-      this.$.diffTable.removeChild(this.$.diffTable.lastChild!);
-    }
-  }
-
-  _computeDiffHeaderItems(
-    diffInfoRecord: PolymerDeepPropertyChange<DiffInfo, DiffInfo>
-  ) {
-    const diffInfo = diffInfoRecord.base;
-    if (!diffInfo || !diffInfo.diff_header) {
-      return [];
-    }
-    return diffInfo.diff_header.filter(
-      item =>
-        !(
-          item.startsWith('diff --git ') ||
-          item.startsWith('index ') ||
-          item.startsWith('+++ ') ||
-          item.startsWith('--- ') ||
-          item === 'Binary files differ'
-        )
-    );
-  }
-
-  _computeDiffHeaderHidden(items: string[]) {
-    return items.length === 0;
-  }
-
-  _handleFullBypass() {
-    this._safetyBypass = FULL_CONTEXT;
-    this._debounceRenderDiffTable();
-  }
-
-  _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();
-  }
-
-  _computeWarningClass(showWarning?: boolean) {
-    return showWarning ? 'warn' : '';
-  }
-
-  _computeErrorClass(errorMessage?: string | null) {
-    return errorMessage ? 'showError' : '';
-  }
-
-  toggleAllContext() {
-    if (!this.prefs) {
-      return;
-    }
-    if (this._getBypassPrefs(this.prefs).context < 0) {
-      this._collapseContext();
-    } else {
-      this._handleFullBypass();
-    }
-  }
-
-  _computeNewlineWarning(warnLeft: boolean, warnRight: boolean) {
-    const messages = [];
-    if (warnLeft) {
-      messages.push(NO_NEWLINE_BASE);
-    }
-    if (warnRight) {
-      messages.push(NO_NEWLINE_REVISION);
-    }
-    if (!messages.length) {
-      return null;
-    }
-    return messages.join(' \u2014 '); // \u2014 - '—'
-  }
-
-  _computeNewlineWarningClass(warning: boolean, loading: boolean) {
-    if (loading || !warning) {
-      return 'newlineWarning hidden';
-    }
-    return 'newlineWarning';
-  }
-
-  /**
-   * Get the approximate length of the diff as the sum of the maximum
-   * length of the chunks.
-   */
-  getDiffLength(diff?: DiffInfo) {
-    if (!diff) return 0;
-    return diff.content.reduce((sum, sec) => {
-      if (sec.ab) {
-        return sum + sec.ab.length;
-      } else {
-        return (
-          sum + Math.max(sec.a ? sec.a.length : 0, sec.b ? sec.b.length : 0)
-        );
-      }
-    }, 0);
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff': GrDiff;
-  }
-}
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
deleted file mode 100644
index 83b0aad..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ /dev/null
@@ -1,625 +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(.no-left) .sideBySide .left,
-    :host(.no-left) .sideBySide .left + td,
-    :host(.no-left) .sideBySide .right:not([data-value]),
-    :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));
-      /* usually 16px = 12px + 4px */
-      line-height: calc(
-        var(--font-size, var(--font-size-code, 12px)) + var(--spacing-s, 4px)
-      );
-    }
-
-    .thread-group {
-      display: block;
-      max-width: var(--content-width, 80ch);
-      white-space: normal;
-      background-color: var(--diff-blank-background-color);
-    }
-    .diffContainer {
-      max-width: var(--diff-max-width, none);
-      display: flex;
-      font-family: var(--monospace-font-family);
-    }
-    .diffContainer.hiddenscroll {
-      margin-bottom: var(--spacing-m);
-    }
-    table {
-      border-collapse: collapse;
-      table-layout: fixed;
-    }
-    td.lineNum {
-      /* Enforces background whenever lines wrap */
-      background-color: var(--diff-blank-background-color);
-    }
-
-    /* Provides the option to add side borders (left and right) to the line number column. */
-    td.left,
-    td.right,
-    td.moveControlsLineNumCol,
-    td.contextLineNum {
-      box-shadow: var(--line-number-box-shadow, unset);
-    }
-
-    /*
-      Context controls break up the table visually, so we set the right border
-      on individual sections to leave a gap for the divider.
-
-      Also taken into account for max-width calculations in SHRINK_ONLY
-      mode (check GrDiff._updatePreferenceStyles).
-      */
-    .section {
-      border-right: 1px solid var(--border-color);
-    }
-    .section.contextControl {
-      /*
-       * Divider inside this section must not have border; we set borders on
-       * the padding rows below.
-       */
-      border-right-width: 0;
-    }
-    /*
-     * 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);
-    }
-    .contextBackground.above {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .contextBackground.below {
-      border-top: 1px solid var(--border-color);
-    }
-
-    .lineNumButton {
-      display: block;
-      width: 100%;
-      height: 100%;
-      background-color: var(--diff-blank-background-color);
-      box-shadow: var(--line-number-box-shadow, unset);
-    }
-    td.lineNum {
-      vertical-align: top;
-    }
-
-    /*
-      The only way to focus this (clicking) will apply our own focus styling,
-      so this default styling is not needed and distracting.
-      */
-    .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);
-      /*
-        Defined by paper-styles default-theme and used in various components.
-        background-color-secondary is a compromise between fairly light in
-        light theme (where we ideally would want background-color-primary) yet
-        slightly offset against the app background in dark mode, where drop
-        shadows e.g. around paper-card are almost invisible.
-        */
-      --primary-background-color: var(--background-color-secondary);
-    }
-    .image-diff .gr-diff {
-      text-align: center;
-    }
-    .image-diff img {
-      box-shadow: var(--elevation-level-1);
-      max-width: 50em;
-    }
-    .image-diff .right.lineNumButton {
-      border-left: 1px solid var(--border-color);
-    }
-    .image-diff label,
-    .binary-diff label {
-      font-family: var(--font-family);
-      font-style: italic;
-    }
-    .diff-row {
-      outline: none;
-      user-select: none;
-    }
-    .diff-row.target-row.target-side-left .lineNumButton.left,
-    .diff-row.target-row.target-side-right .lineNumButton.right,
-    .diff-row.target-row.unified .lineNumButton {
-      background-color: var(--diff-selection-background-color);
-      color: var(--primary-text-color);
-    }
-    .content {
-      background-color: var(--diff-blank-background-color);
-    }
-    /*
-      The file line, which has no contentText, add some margin before the first
-      comment. We cannot add padding the container because we only want it if
-      there is at least one comment thread, and the slotting makes :empty not
-      work as expected.
-     */
-    .content.file slot:first-child::slotted(.comment-thread) {
-      display: block;
-      margin-top: var(--spacing-xs);
-    }
-    .contentText {
-      background-color: var(--view-background-color);
-    }
-    .blank {
-      background-color: var(--diff-blank-background-color);
-    }
-    .image-diff .content {
-      background-color: var(--diff-blank-background-color);
-    }
-    .responsive {
-      width: 100%;
-    }
-    .responsive .contentText {
-      white-space: break-spaces;
-      word-break: break-all;
-    }
-    .lineNumButton,
-    .content {
-      vertical-align: top;
-      white-space: pre;
-    }
-    .contextLineNum,
-    .lineNumButton {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-
-      color: var(--deemphasized-text-color);
-      padding: 0 var(--spacing-m);
-      text-align: right;
-    }
-    .canComment .lineNumButton {
-      cursor: pointer;
-    }
-    .content {
-      /* Set min width since setting width on table cells still
-           allows them to shrink. Do not set max width because
-           CJK (Chinese-Japanese-Korean) glyphs have variable width */
-      min-width: var(--content-width, 80ch);
-      width: var(--content-width, 80ch);
-    }
-    .content.add .contentText .intraline,
-      /* If there are no intraline info, consider everything changed */
-      .content.add.no-intraline-info .contentText,
-      .delta.total .content.add .contentText {
-      background-color: var(--dark-add-highlight-color);
-    }
-    .content.add .contentText {
-      background-color: var(--light-add-highlight-color);
-    }
-    .content.remove .contentText .intraline,
-      /* If there are no intraline info, consider everything changed */
-      .content.remove.no-intraline-info .contentText,
-      .delta.total .content.remove .contentText {
-      background-color: var(--dark-remove-highlight-color);
-    }
-    .content.remove .contentText {
-      background-color: var(--light-remove-highlight-color);
-    }
-
-    /* dueToRebase */
-    .dueToRebase .content.add .contentText .intraline,
-    .delta.total.dueToRebase .content.add .contentText {
-      background-color: var(--dark-rebased-add-highlight-color);
-    }
-    .dueToRebase .content.add .contentText {
-      background-color: var(--light-rebased-add-highlight-color);
-    }
-    .dueToRebase .content.remove .contentText .intraline,
-    .delta.total.dueToRebase .content.remove .contentText {
-      background-color: var(--dark-rebased-remove-highlight-color);
-    }
-    .dueToRebase .content.remove .contentText {
-      background-color: var(--light-remove-add-highlight-color);
-    }
-
-    /* dueToMove */
-    .dueToMove .content.add .contentText,
-    .dueToMove .moveControls.movedIn .moveHeader,
-    .delta.total.dueToMove .content.add .contentText {
-      background-color: var(--diff-moved-in-background);
-    }
-
-    .dueToMove .content.remove .contentText,
-    .dueToMove .moveControls.movedOut .moveHeader,
-    .delta.total.dueToMove .content.remove .contentText {
-      background-color: var(--diff-moved-out-background);
-    }
-
-    .delta.dueToMove .movedIn .moveHeader {
-      --gr-range-header-color: var(--diff-moved-in-label-color);
-    }
-    .delta.dueToMove .movedOut .moveHeader {
-      --gr-range-header-color: var(--diff-moved-out-label-color);
-    }
-
-    .moveHeader a {
-      color: inherit;
-    }
-
-    /* ignoredWhitespaceOnly */
-    .ignoredWhitespaceOnly .content.add .contentText .intraline,
-    .delta.total.ignoredWhitespaceOnly .content.add .contentText,
-    .ignoredWhitespaceOnly .content.add .contentText,
-    .ignoredWhitespaceOnly .content.remove .contentText .intraline,
-    .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
-    .ignoredWhitespaceOnly .content.remove .contentText {
-      background-color: var(--view-background-color);
-    }
-
-    .content .contentText:empty:after {
-      /* Newline, to ensure empty lines are one line-height tall. */
-      content: '\\A';
-    }
-
-    /* Context controls */
-    .contextControl {
-      display: var(--context-control-display, table-row-group);
-      background-color: transparent;
-      border: none;
-      --divider-height: var(--spacing-s);
-      --divider-border: 1px;
-    }
-    .contextControl gr-button iron-icon {
-      /* should match line-height of gr-button */
-      width: var(--line-height-mono, 18px);
-      height: var(--line-height-mono, 18px);
-    }
-    .contextControl td:not(.lineNumButton) {
-      text-align: center;
-    }
-
-    /*
-     * 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);
-    }
-    .contextBackground > td:not(.contextLineNum) {
-      background-color: var(--view-background-color);
-    }
-    .contextBackground {
-      /*
-       * One line of background behind the context expanders which they can
-       * render on top of, plus some padding.
-       */
-      height: calc(var(--line-height-normal) + var(--spacing-s));
-    }
-
-    .dividerCell {
-      vertical-align: top;
-    }
-    .dividerRow.show-both .dividerCell {
-      height: var(--divider-height);
-    }
-    .dividerRow.show-above .dividerCell,
-    .dividerRow.show-above .dividerCell {
-      height: 0;
-    }
-
-    .displayLine .diff-row.target-row td {
-      box-shadow: inset 0 -1px var(--border-color);
-    }
-    .br:after {
-      /* Line feed */
-      content: '\\A';
-    }
-    .tab {
-      display: inline-block;
-    }
-    .tab-indicator:before {
-      color: var(--diff-tab-indicator-color);
-      /* >> character */
-      content: '\\00BB';
-      position: absolute;
-    }
-    .special-char-indicator {
-      /* spacing so elements don't collide */
-      padding-right: var(--spacing-m);
-    }
-    .special-char-indicator:before {
-      color: var(--diff-tab-indicator-color);
-      content: '•';
-      position: absolute;
-    }
-    /* Is defined after other background-colors, such that this
-         rule wins in case of same specificity. */
-    .trailing-whitespace,
-    .content .trailing-whitespace,
-    .trailing-whitespace .intraline,
-    .content .trailing-whitespace .intraline {
-      border-radius: var(--border-radius, 4px);
-      background-color: var(--diff-trailing-whitespace-indicator);
-    }
-    #diffHeader {
-      background-color: var(--table-header-background-color);
-      border-bottom: 1px solid var(--border-color);
-      color: var(--link-color);
-      padding: var(--spacing-m) 0 var(--spacing-m) 48px;
-    }
-    #diffTable:focus {
-      outline: none;
-    }
-    #loadingError,
-    #sizeWarning {
-      display: none;
-      margin: var(--spacing-l) auto;
-      max-width: 60em;
-      text-align: center;
-    }
-    #loadingError {
-      color: var(--error-text-color);
-    }
-    #sizeWarning gr-button {
-      margin: var(--spacing-l);
-    }
-    #loadingError.showError,
-    #sizeWarning.warn {
-      display: block;
-    }
-    .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;
-    }
-    td.blame {
-      display: none;
-      padding: 0 var(--spacing-m);
-      white-space: pre;
-    }
-    :host(.showBlame) col.blame {
-      display: table-column;
-    }
-    :host(.showBlame) td.blame {
-      display: table-cell;
-    }
-    td.blame > span {
-      opacity: 0.6;
-    }
-    td.blame > span.startOfRange {
-      opacity: 1;
-    }
-    td.blame .blameDate {
-      font-family: var(--monospace-font-family);
-      color: var(--link-color);
-      text-decoration: none;
-    }
-    .responsive td.blame {
-      overflow: hidden;
-      width: 200px;
-    }
-    /** Support the line length indicator **/
-    .responsive td.content .contentText {
-      /*
-      Same strategy as in https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
-      */
-      background-image: linear-gradient(
-        var(--line-length-indicator-color),
-        var(--line-length-indicator-color)
-      );
-      background-size: 1px 100%;
-      background-position: var(--line-limit-marker) 0;
-      background-repeat: no-repeat;
-    }
-    .newlineWarning {
-      color: var(--deemphasized-text-color);
-      text-align: center;
-    }
-    .newlineWarning.hidden {
-      display: none;
-    }
-    .lineNum.COVERED .lineNumButton {
-      background-color: var(--coverage-covered, #e0f2f1);
-    }
-    .lineNum.NOT_COVERED .lineNumButton {
-      background-color: var(--coverage-not-covered, #ffd1a4);
-    }
-    .lineNum.PARTIALLY_COVERED .lineNumButton {
-      background: linear-gradient(
-        to right bottom,
-        var(--coverage-not-covered, #ffd1a4) 0%,
-        var(--coverage-not-covered, #ffd1a4) 50%,
-        var(--coverage-covered, #e0f2f1) 50%,
-        var(--coverage-covered, #e0f2f1) 100%
-      );
-    }
-
-    /** BEGIN: Select and copy for Polymer 2 */
-    /** Below was copied and modified from the original css in gr-diff-selection.html */
-    .content,
-    .contextControl,
-    .blame {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-    }
-
-    .selected-left:not(.selected-comment)
-      .side-by-side
-      .left
-      + .content
-      .contentText,
-    .selected-right:not(.selected-comment)
-      .side-by-side
-      .right
-      + .content
-      .contentText,
-    .selected-left:not(.selected-comment)
-      .unified
-      .left.lineNum
-      ~ .content:not(.both)
-      .contentText,
-    .selected-right:not(.selected-comment)
-      .unified
-      .right.lineNum
-      ~ .content
-      .contentText,
-    .selected-left.selected-comment .side-by-side .left + .content .message,
-    .selected-right.selected-comment
-      .side-by-side
-      .right
-      + .content
-      .message
-      :not(.collapsedContent),
-    .selected-comment .unified .message :not(.collapsedContent),
-    .selected-blame .blame {
-      -webkit-user-select: text;
-      -moz-user-select: text;
-      -ms-user-select: text;
-      user-select: text;
-    }
-
-    /** Make comments selectable when selected */
-    .selected-left.selected-comment
-      ::slotted(gr-comment-thread[diff-side='left']),
-    .selected-right.selected-comment
-      ::slotted(gr-comment-thread[diff-side='right']) {
-      -webkit-user-select: text;
-      -moz-user-select: text;
-      -ms-user-select: text;
-      user-select: text;
-    }
-    /** END: Select and copy for Polymer 2 */
-
-    .whitespace-change-only-message {
-      background-color: var(--diff-context-control-background-color);
-      border: 1px solid var(--diff-context-control-border-color);
-      text-align: center;
-    }
-
-    .token-highlight {
-      background-color: var(--token-highlighting-color, #fffd54);
-    }
-  </style>
-  <style include="gr-syntax-theme">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-ranged-comment-theme">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
-    <template is="dom-repeat" items="[[_diffHeaderItems]]">
-      <div>[[item]]</div>
-    </template>
-  </div>
-  <div
-    class$="[[_computeContainerClass(loggedIn, viewMode, displayLine)]]"
-    on-click="_handleTap"
-    on-diff-context-expanded="_handleDiffContextExpanded"
-  >
-    <gr-diff-selection diff="[[diff]]">
-      <gr-diff-highlight
-        id="highlights"
-        logged-in="[[loggedIn]]"
-        comment-ranges="{{_commentRanges}}"
-      >
-        <gr-diff-builder
-          id="diffBuilder"
-          comment-ranges="[[_commentRanges]]"
-          coverage-ranges="[[coverageRanges]]"
-          diff="[[diff]]"
-          path="[[path]]"
-          change-num="[[changeNum]]"
-          patch-num="[[patchRange.patchNum]]"
-          view-mode="[[viewMode]]"
-          is-image-diff="[[isImageDiff]]"
-          base-image="[[baseImage]]"
-          layers="[[layers]]"
-          revision-image="[[revisionImage]]"
-          use-new-image-diff-ui="[[useNewImageDiffUi]]"
-        >
-          <table
-            id="diffTable"
-            class$="[[_diffTableClass]]"
-            role="presentation"
-            contenteditable$="[[isContentEditable]]"
-          ></table>
-
-          <template
-            is="dom-if"
-            if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
-          >
-            <div class="whitespace-change-only-message">
-              This file only contains whitespace changes. Modify the whitespace
-              setting to see the changes.
-            </div>
-          </template>
-        </gr-diff-builder>
-      </gr-diff-highlight>
-    </gr-diff-selection>
-  </div>
-  <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
-    [[_newlineWarning]]
-  </div>
-  <div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
-    [[errorMessage]]
-  </div>
-  <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
-    <p>
-      Prevented render because "Whole file" is enabled and this diff is very
-      large (about [[_diffLength]] lines).
-    </p>
-    <gr-button on-click="_collapseContext">
-      Render with limited context
-    </gr-button>
-    <gr-button on-click="_handleFullBypass">
-      Render anyway (may be slow)
-    </gr-button>
-  </div>
-`;
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
deleted file mode 100644
index 9ee779c..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ /dev/null
@@ -1,1248 +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 {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import './gr-diff.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 {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-diff');
-
-suite('gr-diff a11y test', () => {
-  test('audit', async () => {
-    await runA11yAudit(basicFixture);
-  });
-});
-
-suite('gr-diff tests', () => {
-  let element;
-
-  const MINIMAL_PREFS = {tab_size: 2, line_length: 80, font_size: 12};
-
-  setup(() => {
-
-  });
-
-  suite('selectionchange event handling', () => {
-    const emulateSelection = function() {
-      document.dispatchEvent(new CustomEvent('selectionchange'));
-    };
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element.$.highlights, 'handleSelectionChange');
-    });
-
-    test('enabled if logged in', async () => {
-      element.loggedIn = true;
-      emulateSelection();
-      await flush();
-      assert.isTrue(element.$.highlights.handleSelectionChange.called);
-    });
-
-    test('ignored if logged out', async () => {
-      element.loggedIn = false;
-      emulateSelection();
-      await flush();
-      assert.isFalse(element.$.highlights.handleSelectionChange.called);
-    });
-  });
-
-  test('cancel', () => {
-    element = basicFixture.instantiate();
-    const cancelStub = sinon.stub(element.$.diffBuilder, 'cancel');
-    element.cancel();
-    assert.isTrue(cancelStub.calledOnce);
-  });
-
-  test('line limit with line_wrapping', () => {
-    element = basicFixture.instantiate();
-    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
-    flush();
-    assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
-  });
-
-  test('line limit without line_wrapping', () => {
-    element = basicFixture.instantiate();
-    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
-    flush();
-    assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
-  });
-  suite('FULL_RESPONSIVE mode', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {...MINIMAL_PREFS};
-      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
-    });
-
-    test('line limit is based on line_length', () => {
-      element.prefs = {...element.prefs, line_length: 100};
-      flush();
-      assert.equal(getComputedStyleValue('--line-limit-marker', element),
-          '100ch');
-    });
-
-    test('content-width should not be defined', () => {
-      flush();
-      assert.equal(getComputedStyleValue('--content-width', element), 'none');
-    });
-  });
-
-  suite('SHRINK_ONLY mode', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {...MINIMAL_PREFS};
-      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
-    });
-
-    test('content-width should not be defined', () => {
-      flush();
-      assert.equal(getComputedStyleValue('--content-width', element), 'none');
-    });
-
-    test('max-width considers two content columns in side-by-side', () => {
-      element.viewMode = 'SIDE_BY_SIDE';
-      flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 1px + 2px)');
-    });
-
-    test('max-width considers one content column in unified', () => {
-      element.viewMode = 'UNIFIED_DIFF';
-      flush();
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(1 * 80ch + 2 * 48px + 1px + 2px)');
-    });
-
-    test('max-width considers font-size', () => {
-      element.prefs = {...element.prefs, font_size: 13};
-      flush();
-      // Each line number column: 4 * 13 = 52px
-      assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 52px + 1px + 2px)');
-    });
-  });
-
-  suite('not logged in', () => {
-    setup(() => {
-      const getLoggedInPromise = Promise.resolve(false);
-      stubRestApi('getLoggedIn').returns(getLoggedInPromise);
-      element = basicFixture.instantiate();
-      return getLoggedInPromise;
-    });
-
-    test('toggleLeftDiff', () => {
-      element.toggleLeftDiff();
-      assert.isTrue(element.classList.contains('no-left'));
-      element.toggleLeftDiff();
-      assert.isFalse(element.classList.contains('no-left'));
-    });
-
-    test('view does not start with displayLine classList', () => {
-      assert.isFalse(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
-    });
-
-    test('displayLine class added called when displayLine is true', () => {
-      const spy = sinon.spy(element, '_computeContainerClass');
-      element.displayLine = true;
-      assert.isTrue(spy.called);
-      assert.isTrue(
-          element.shadowRoot
-              .querySelector('.diffContainer')
-              .classList
-              .contains('displayLine'));
-    });
-
-    test('thread groups', () => {
-      const contentEl = document.createElement('div');
-
-      element.changeNum = 123;
-      element.patchRange = {basePatchNum: 1, patchNum: 2};
-      element.path = 'file.txt';
-
-      element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
-          getMockDiffResponse(), {...MINIMAL_PREFS});
-
-      // No thread groups.
-      assert.isNotOk(element._getThreadGroupForLine(contentEl));
-
-      // A thread group gets created.
-      const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
-      assert.isOk(threadGroupEl);
-
-      // The new thread group can be fetched.
-      assert.isOk(element._getThreadGroupForLine(contentEl));
-
-      assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
-    });
-
-    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.isImageDiff = true;
-        element.prefs = {
-          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,
-          theme: 'DEFAULT',
-        };
-      });
-
-      test('renders image diffs with same file name', async () => {
-        const leftRendered = mockPromise();
-        const rightRendered = mockPromise();
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.diffTable.querySelector(
-              'td.right label');
-          const rightLabelContent = rightLabel.querySelector('.label');
-          const rightLabelName = rightLabel.querySelector('.name');
-
-          assert.isNotOk(rightLabelName);
-          assert.isNotOk(leftLabelName);
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-            leftRendered.resolve();
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-
-            rightRendered.resolve();
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.revisionImage = mockFile2;
-        element.diff = {
-          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,
-        };
-        await Promise.all([leftRendered, rightRendered]);
-        element.removeEventListener('render', rendered);
-      });
-
-      test('renders image diffs with a different file name', async () => {
-        const mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
-            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,
-        };
-        const leftRendered = mockPromise();
-        const rightRendered = mockPromise();
-        const rendered = () => {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          // Left image rendered with the parent commit's version of the file.
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const leftLabel =
-              element.$.diffTable.querySelector('td.left label');
-          const leftLabelContent = leftLabel.querySelector('.label');
-          const leftLabelName = leftLabel.querySelector('.name');
-
-          const rightImage =
-              element.$.diffTable.querySelector('td.right img');
-          const rightLabel = element.$.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);
-
-          leftImage.addEventListener('load', () => {
-            assert.isOk(leftImage);
-            assert.equal(leftImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile1.body);
-            assert.equal(leftLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-            leftRendered.resolve();
-          });
-
-          rightImage.addEventListener('load', () => {
-            assert.isOk(rightImage);
-            assert.equal(rightImage.getAttribute('src'),
-                'data:image/bmp;base64,' + mockFile2.body);
-            assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
-
-            rightRendered.resolve();
-          });
-        };
-
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.baseImage._name = mockDiff.meta_a.name;
-        element.revisionImage = mockFile2;
-        element.revisionImage._name = mockDiff.meta_b.name;
-        element.diff = mockDiff;
-        await Promise.all([leftRendered, rightRendered]);
-        element.removeEventListener('render', rendered);
-      });
-
-      test('renders added image', async () => {
-        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,
-        };
-
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.revisionImage = mockFile2;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
-        assert.isNotOk(leftImage);
-        assert.isOk(rightImage);
-      });
-
-      test('renders removed image', async () => {
-        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,
-        };
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        const rightImage = element.$.diffTable.querySelector('td.right img');
-
-        assert.isOk(leftImage);
-        assert.isNotOk(rightImage);
-      });
-
-      test('does not render disallowed image type', async () => {
-        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';
-
-        const promise = mockPromise();
-        function rendered() { promise.resolve(); }
-        element.addEventListener('render', rendered);
-
-        element.baseImage = mockFile1;
-        element.diff = mockDiff;
-        await promise;
-        element.removeEventListener('render', rendered);
-        // Recognizes that it should be an image diff.
-        assert.isTrue(element.isImageDiff);
-        assert.instanceOf(
-            element.$.diffBuilder._builder, GrDiffBuilderImage);
-        const leftImage = element.$.diffTable.querySelector('td.left img');
-        assert.isNotOk(leftImage);
-      });
-    });
-
-    test('_handleTap lineNum', async () => {
-      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
-      const el = document.createElement('div');
-      el.className = 'lineNum';
-      const promise = mockPromise();
-      el.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(addDraftStub.called);
-        assert.equal(addDraftStub.lastCall.args[0], el);
-        promise.resolve();
-      });
-      el.click();
-      await promise;
-    });
-
-    test('_handleTap context', async () => {
-      const showContextStub =
-          sinon.stub(element.$.diffBuilder, 'showContext');
-      const el = document.createElement('div');
-      el.className = 'showContext';
-      const promise = mockPromise();
-      el.addEventListener('click', e => {
-        element._handleDiffContextExpanded(e);
-        assert.isTrue(showContextStub.called);
-        promise.resolve();
-      });
-      el.click();
-      await promise;
-    });
-
-    test('_handleTap content', async () => {
-      const content = document.createElement('div');
-      const lineEl = document.createElement('div');
-      lineEl.className = 'lineNum';
-      const row = document.createElement('div');
-      row.appendChild(lineEl);
-      row.appendChild(content);
-
-      const selectStub = sinon.stub(element, '_selectLine');
-
-      content.className = 'content';
-      const promise = mockPromise();
-      content.addEventListener('click', e => {
-        element._handleTap(e);
-        assert.isTrue(selectStub.called);
-        assert.equal(selectStub.lastCall.args[0], lineEl);
-        promise.resolve();
-      });
-      content.click();
-      await promise;
-    });
-
-    suite('getCursorStops', () => {
-      function setupDiff() {
-        element.diff = getMockDiffResponse();
-        element.prefs = {
-          context: 10,
-          tab_size: 8,
-          font_size: 12,
-          line_length: 100,
-          cursor_blink_rate: 0,
-          line_wrapping: false,
-
-          show_line_endings: true,
-          show_tabs: true,
-          show_whitespace_errors: true,
-          syntax_highlighting: true,
-          theme: 'DEFAULT',
-          ignore_whitespace: 'IGNORE_NONE',
-        };
-
-        element._renderDiffTable();
-        element._setLoading(false);
-        flush();
-      }
-
-      test('getCursorStops returns [] when hidden and noAutoRender', () => {
-        element.noAutoRender = true;
-        setupDiff();
-        element.hidden = true;
-        assert.equal(element.getCursorStops().length, 0);
-      });
-
-      test('getCursorStops', () => {
-        setupDiff();
-        const ROWS = 48;
-        const FILE_ROW = 1;
-        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
-      });
-    });
-
-    test('adds .hiddenscroll', () => {
-      _setHiddenScroll(true);
-      element.displayLine = true;
-      assert.include(element.shadowRoot
-          .querySelector('.diffContainer').className, 'hiddenscroll');
-    });
-  });
-
-  suite('logged in', () => {
-    let fakeLineEl;
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.loggedIn = true;
-      element.patchRange = {};
-
-      fakeLineEl = {
-        getAttribute: sinon.stub().returns(42),
-        classList: {
-          contains: sinon.stub().returns(true),
-        },
-      };
-    });
-
-    test('addDraftAtLine', () => {
-      sinon.stub(element, '_selectLine');
-      sinon.stub(element, '_createComment');
-      element.addDraftAtLine(fakeLineEl);
-      assert.isTrue(element._createComment
-          .calledWithExactly(fakeLineEl, 42));
-    });
-
-    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('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 = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-            lines: 560},
-          diff_header: [],
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          content: [{skip: 66}],
-        };
-        element.renderDiffTableTask.flush();
-      });
-
-      test('change in preferences re-renders diff', () => {
-        sinon.stub(element, '_renderDiffTable');
-        element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-      });
-
-      test('adding/removing property in preferences re-renders diff', () => {
-        const stub = sinon.stub(element, '_renderDiffTable');
-        const newPrefs1 = {...MINIMAL_PREFS,
-          line_wrapping: true};
-        element.prefs = newPrefs1;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-        stub.reset();
-
-        const newPrefs2 = {...newPrefs1};
-        delete newPrefs2.line_wrapping;
-        element.prefs = newPrefs2;
-        element.renderDiffTableTask.flush();
-        assert.isTrue(element._renderDiffTable.called);
-      });
-
-      test('change in preferences does not re-renders diff with ' +
-          'noRenderOnPrefsChange', () => {
-        sinon.stub(element, '_renderDiffTable');
-        element.noRenderOnPrefsChange = true;
-        element.prefs = {
-          ...MINIMAL_PREFS, time_format: 'HHMM_12'};
-        element.renderDiffTableTask.flush();
-        assert.isFalse(element._renderDiffTable.called);
-      });
-    });
-  });
-
-  suite('diff header', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.diff = {
-        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
-          lines: 560},
-        diff_header: [],
-        intraline_status: 'OK',
-        change_type: 'MODIFIED',
-        content: [{skip: 66}],
-      };
-    });
-
-    test('hidden', () => {
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', '--- a/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', '+++ b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'test');
-      assert.equal(element._diffHeaderItems.length, 1);
-      flush();
-
-      assert.equal(element.$.diffHeader.textContent.trim(), 'test');
-    });
-
-    test('binary files', () => {
-      element.diff.binary = true;
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
-      assert.equal(element._diffHeaderItems.length, 0);
-      element.push('diff.diff_header', 'test');
-      assert.equal(element._diffHeaderItems.length, 1);
-      element.push('diff.diff_header', 'Binary files differ');
-      assert.equal(element._diffHeaderItems.length, 1);
-    });
-  });
-
-  suite('safety and bypass', () => {
-    let renderStub;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      renderStub = sinon.stub(element.$.diffBuilder, 'render').callsFake(
-          () => {
-            element.$.diffBuilder.dispatchEvent(
-                new CustomEvent('render', {bubbles: true, composed: true}));
-            return Promise.resolve({});
-          });
-      sinon.stub(element, 'getDiffLength').returns(10000);
-      element.diff = getMockDiffResponse();
-      element.noRenderOnPrefsChange = true;
-    });
-
-    test('large render w/ context = 10', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: 10};
-      const promise = mockPromise();
-      function rendered() {
-        assert.isTrue(renderStub.called);
-        assert.isFalse(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('large render w/ whole file and bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-      element._safetyBypass = 10;
-      const promise = mockPromise();
-      function rendered() {
-        assert.isTrue(renderStub.called);
-        assert.isFalse(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    test('large render w/ whole file and no bypass', async () => {
-      element.prefs = {...MINIMAL_PREFS, context: -1};
-      const promise = mockPromise();
-      function rendered() {
-        assert.isFalse(renderStub.called);
-        assert.isTrue(element._showWarning);
-        promise.resolve();
-        element.removeEventListener('render', rendered);
-      }
-      element.addEventListener('render', rendered);
-      element._renderDiffTable();
-      await promise;
-    });
-
-    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', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-    });
-
-    test('unsetting', () => {
-      element.blame = [];
-      const setBlameSpy = sinon.spy(element.$.diffBuilder, 'setBlame');
-      element.classList.add('showBlame');
-      element.blame = null;
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
-      assert.isFalse(element.classList.contains('showBlame'));
-    });
-
-    test('setting', () => {
-      element.blame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
-      assert.isTrue(element.classList.contains('showBlame'));
-    });
-  });
-
-  suite('trailing newline warnings', () => {
-    const NO_NEWLINE_BASE = 'No newline at end of base file.';
-    const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
-
-    const getWarning = element =>
-      element.shadowRoot.querySelector('.newlineWarning').textContent;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.showNewlineWarningLeft = false;
-      element.showNewlineWarningRight = false;
-    });
-
-    test('shows combined warning if both sides set to warn', () => {
-      element.showNewlineWarningLeft = true;
-      element.showNewlineWarningRight = true;
-      assert.include(getWarning(element),
-          NO_NEWLINE_BASE + ' \u2014 ' + NO_NEWLINE_REVISION);// \u2014 - '—'
-    });
-
-    suite('showNewlineWarningLeft', () => {
-      test('show warning if true', () => {
-        element.showNewlineWarningLeft = true;
-        assert.include(getWarning(element), NO_NEWLINE_BASE);
-      });
-
-      test('hide warning if false', () => {
-        element.showNewlineWarningLeft = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
-      });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningLeft = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_BASE);
-      });
-    });
-
-    suite('showNewlineWarningRight', () => {
-      test('show warning if true', () => {
-        element.showNewlineWarningRight = true;
-        assert.include(getWarning(element), NO_NEWLINE_REVISION);
-      });
-
-      test('hide warning if false', () => {
-        element.showNewlineWarningRight = false;
-        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
-      });
-
-      test('hide warning if undefined', () => {
-        element.showNewlineWarningRight = undefined;
-        assert.notInclude(getWarning(element), NO_NEWLINE_REVISION);
-      });
-    });
-
-    test('_computeNewlineWarningClass', () => {
-      const hidden = 'newlineWarning hidden';
-      const shown = 'newlineWarning';
-      assert.equal(element._computeNewlineWarningClass(null, true), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
-      assert.equal(element._computeNewlineWarningClass(null, false), hidden);
-      assert.equal(element._computeNewlineWarningClass('foo', false), shown);
-    });
-
-    test('_prefsEqual', () => {
-      element = basicFixture.instantiate();
-      assert.isTrue(element._prefsEqual(null, null));
-      assert.isTrue(element._prefsEqual({}, {}));
-      assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-      assert.isTrue(
-          element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-      const somePref = {abc: 'def', p: true};
-      assert.isTrue(element._prefsEqual(somePref, somePref));
-
-      assert.isFalse(element._prefsEqual({}, null));
-      assert.isFalse(element._prefsEqual(null, {}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-      assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-      assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-    });
-  });
-
-  suite('key locations', () => {
-    let renderStub;
-
-    setup(() => {
-      element = basicFixture.instantiate();
-      element.prefs = {};
-      renderStub = sinon.stub(element.$.diffBuilder, 'render')
-          .returns(new Promise(() => {}));
-    });
-
-    test('lineOfInterest is a key location', () => {
-      element.lineOfInterest = {number: 789, leftSide: true};
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {789: true},
-        right: {},
-      });
-    });
-
-    test('line comments are key locations', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', 3);
-      element.appendChild(threadEl);
-      flush();
-
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {},
-        right: {3: true},
-      });
-    });
-
-    test('file comments are key locations', () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'left');
-      element.appendChild(threadEl);
-      flush();
-
-      element._renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {FILE: true},
-        right: {},
-      });
-    });
-  });
-  const setupSampleDiff = function(params) {
-    const {ignore_whitespace, content} = params;
-    // binary can't be undefined, use false if not set
-    const binary = params.binary || false;
-    element = basicFixture.instantiate();
-    element.prefs = {
-      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
-      context: 10,
-      cursor_blink_rate: 0,
-      font_size: 12,
-
-      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',
-    };
-    element.diff = {
-      intraline_status: 'OK',
-      change_type: 'MODIFIED',
-      diff_header: [
-        'diff --git a/carrot.js b/carrot.js',
-        'index 2adc47d..f9c2f2c 100644',
-        '--- a/carrot.js',
-        '+++ b/carrot.jjs',
-        'file differ',
-      ],
-      content,
-      binary,
-    };
-    element._renderDiffTable();
-    flush();
-  };
-
-  test('clear diff table content as soon as diff changes', () => {
-    const content = [{
-      a: ['all work and no play make andybons a dull boy'],
-    }, {
-      b: [
-        'Non eram nescius, Brute, cum, quae summis ingeniis ',
-      ],
-    }];
-    function assertDiffTableWithContent() {
-      assert.isTrue(element.$.diffTable.innerText.includes(content[0].a));
-    }
-    setupSampleDiff({content});
-    assertDiffTableWithContent();
-    element.diff = {...element.diff};
-    // immediately cleaned up
-    assert.equal(element.$.diffTable.innerHTML, '');
-    element._renderDiffTable();
-    flush();
-    // rendered again
-    assertDiffTableWithContent();
-  });
-
-  suite('selection test', () => {
-    test('user-select set correctly on side-by-side view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
-      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      // click to mark it as selected
-      MockInteractions.tap(diffLine);
-      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
-    });
-
-    test('user-select set correctly on unified view', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      element.viewMode = 'UNIFIED_DIFF';
-      flush();
-      const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
-      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
-      MockInteractions.tap(diffLine);
-      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
-    });
-  });
-
-  suite('whitespace changes only message', () => {
-    test('show the message if ignore_whitespace is criteria matches', () => {
-      setupSampleDiff({content: [{skip: 100}]});
-      assert.isTrue(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message for binary files', () => {
-      setupSampleDiff({content: [{skip: 100}], binary: true});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message if still loading', () => {
-      setupSampleDiff({content: [{skip: 100}]});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ true,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show the message if contains valid changes', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({content});
-      assert.equal(element._diffLength, 3);
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-
-    test('do not show message if ignore whitespace is disabled', () => {
-      const content = [{
-        a: ['all work and no play make andybons a dull boy'],
-        b: ['elgoog elgoog elgoog'],
-      }, {
-        ab: [
-          'Non eram nescius, Brute, cum, quae summis ingeniis ',
-          'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-        ],
-      }];
-      setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
-      assert.isFalse(element.showNoChangeMessage(
-          /* loading= */ false,
-          element.prefs,
-          element._diffLength,
-          element.diff
-      ));
-    });
-  });
-
-  test('getDiffLength', () => {
-    const diff = getMockDiffResponse();
-    assert.equal(element.getDiffLength(diff), 52);
-  });
-
-  test('`render` event has contentRendered field in detail', async () => {
-    element = basicFixture.instantiate();
-    element.prefs = {};
-    sinon.stub(element.$.diffBuilder, 'render')
-        .returns(Promise.resolve());
-    const promise = mockPromise();
-    element.addEventListener('render', event => {
-      assert.isTrue(event.detail.contentRendered);
-      promise.resolve();
-    });
-    element._renderDiffTable();
-    await promise;
-  });
-
-  test('_prefsEqual', () => {
-    element = basicFixture.instantiate();
-    assert.isTrue(element._prefsEqual(null, null));
-    assert.isTrue(element._prefsEqual({}, {}));
-    assert.isTrue(element._prefsEqual({x: 1}, {x: 1}));
-    assert.isTrue(element._prefsEqual({x: 1, abc: 'def'}, {x: 1, abc: 'def'}));
-    const somePref = {abc: 'def', p: true};
-    assert.isTrue(element._prefsEqual(somePref, somePref));
-
-    assert.isFalse(element._prefsEqual({}, null));
-    assert.isFalse(element._prefsEqual(null, {}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 2}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1, y: 'abcd'}));
-    assert.isFalse(element._prefsEqual({x: 1, y: 'abc'}, {x: 1}));
-    assert.isFalse(element._prefsEqual({x: 1}, {x: 1, y: 'abc'}));
-  });
-});
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 5d4405f..325798c 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
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-select/gr-select';
 import {convertToString, pluralize} from '../../../utils/string-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
   computeLatestPatchNum,
   findSortedIndex,
@@ -29,29 +18,35 @@
   convertToPatchSetNum,
 } from '../../../utils/patch-set-util';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {hasOwnProperty} from '../../../utils/common-util';
 import {
   BasePatchSetNum,
-  ParentPatchSetNum,
+  EDIT,
+  NumericChangeId,
+  PARENT,
   PatchSetNum,
   RevisionInfo,
+  RevisionPatchSetNum,
   Timestamp,
 } from '../../../types/common';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {
   DropdownItem,
-  DropDownValueChangeEvent,
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {GeneratedWebLink} from '../../core/gr-navigation/gr-navigation';
 import {EditRevisionInfo} from '../../../types/types';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, css, html} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
 import {subscribe} from '../../lit/subscription-controller';
-import {changeComments$} from '../../../services/comments/comments-model';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {resolve} from '../../../models/dependency';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {GeneratedWebLink} from '../../../utils/weblink-util';
+import {changeModelToken} from '../../../models/change/change-model';
+import {changeViewModelToken} from '../../../models/views/change';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
@@ -73,9 +68,6 @@
 }
 
 declare global {
-  interface HTMLElementEventMap {
-    'value-change': DropDownValueChangeEvent;
-  }
   interface HTMLElementTagNameMap {
     'gr-patch-range-select': GrPatchRangeSelect;
   }
@@ -94,41 +86,76 @@
   @query('#patchNumDropdown')
   patchNumDropdown?: GrDropdownList;
 
-  @property({type: Array})
-  availablePatches?: PatchSet[];
+  @state()
+  availablePatches: PatchSet[] = [];
 
-  @property({type: String})
-  changeNum?: string;
+  @state()
+  changeNum?: NumericChangeId;
 
   @property({type: Object})
   filesWeblinks?: FilesWebLinks;
 
-  @property({type: String})
-  patchNum?: PatchSetNum;
+  @state()
+  patchNum?: RevisionPatchSetNum;
 
-  @property({type: String})
+  @state()
   basePatchNum?: BasePatchSetNum;
 
-  /** Not used directly. Translated into `sortedRevisions` in willUpdate(). */
-  @property({type: Object})
-  revisions: (RevisionInfo | EditRevisionInfo)[] = [];
-
-  @property({type: Object})
+  @state()
   revisionInfo?: RevisionInfoClass;
 
-  /** Private internal state, derived from `revisions` in willUpdate(). */
   @state()
-  private sortedRevisions: (RevisionInfo | EditRevisionInfo)[] = [];
+  sortedRevisions: (RevisionInfo | EditRevisionInfo)[] = [];
 
-  /** Private internal state, visible for testing. */
   @state()
   changeComments?: ChangeComments;
 
-  private readonly reporting: ReportingService = appContext.reportingService;
+  private readonly reporting: ReportingService =
+    getAppContext().reportingService;
+
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getViewModel = resolve(this, changeViewModelToken);
 
   constructor() {
     super();
-    subscribe(this, changeComments$, x => (this.changeComments = x));
+    subscribe(
+      this,
+      () => this.getViewModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.revisionInfo = x ? new RevisionInfoClass(x) : undefined)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().patchNum$,
+      x => (this.patchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().basePatchNum$,
+      x => (this.basePatchNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().patchsets$,
+      x => (this.availablePatches = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revisions$,
+      x => (this.sortedRevisions = sortRevisions(Object.values(x || {})))
+    );
+    subscribe(
+      this,
+      () => this.getCommentsModel().changeComments$,
+      x => (this.changeComments = x)
+    );
   }
 
   static override get styles() {
@@ -155,10 +182,12 @@
           .filesWeblinks {
             display: none;
           }
+          /* prettier formatter removes semi-colons after css mixins. */
+          /* prettier-ignore */
           gr-dropdown-list {
             --native-select-style: {
               max-width: 5.25em;
-            }
+            };
           }
         }
       `,
@@ -166,13 +195,16 @@
   }
 
   override render() {
+    if (!this.changeNum || !this.patchNum || !this.basePatchNum) {
+      return nothing;
+    }
     return html`
       <h3 class="assistive-tech-only">Patchset Range Selection</h3>
       <span class="patchRange" aria-label="patch range starts with">
         <gr-dropdown-list
           id="basePatchDropdown"
-          .value="${convertToString(this.basePatchNum)}"
-          .items="${this.computeBaseDropdownContent()}"
+          .value=${convertToString(this.basePatchNum)}
+          .items=${this.computeBaseDropdownContent()}
           @value-change=${this.handlePatchChange}
         >
         </gr-dropdown-list>
@@ -182,8 +214,8 @@
       <span class="patchRange" aria-label="patch range ends with">
         <gr-dropdown-list
           id="patchNumDropdown"
-          .value="${convertToString(this.patchNum)}"
-          .items="${this.computePatchDropdownContent()}"
+          .value=${convertToString(this.patchNum)}
+          .items=${this.computePatchDropdownContent()}
           @value-change=${this.handlePatchChange}
         >
         </gr-dropdown-list>
@@ -197,7 +229,7 @@
     return html`<span class="filesWeblinks">
       ${fileLinks.map(
         weblink => html`
-          <a target="_blank" rel="noopener" href="${weblink.url}">
+          <a target="_blank" rel="noopener" href=${ifDefined(weblink.url)}>
             ${weblink.name}
           </a>
         `
@@ -205,16 +237,9 @@
     > `;
   }
 
-  override willUpdate(changedProperties: PropertyValues) {
-    if (changedProperties.has('revisions')) {
-      this.sortedRevisions = sortRevisions(Object.values(this.revisions || {}));
-    }
-  }
-
   // Private method, but visible for testing.
   computeBaseDropdownContent(): DropdownItem[] {
     if (
-      this.availablePatches === undefined ||
       this.patchNum === undefined ||
       this.changeComments === undefined ||
       this.revisionInfo === undefined
@@ -222,12 +247,9 @@
       return [];
     }
 
-    const parentCounts = this.revisionInfo.getParentCountMap();
-    const currentParentCount = hasOwnProperty(parentCounts, this.patchNum)
-      ? parentCounts[this.patchNum as number]
-      : 1;
     const maxParents = this.revisionInfo.getMaxParents();
-    const isMerge = currentParentCount > 1;
+    const isMerge = this.revisionInfo.isMergeCommit(this.patchNum);
+    const parentCount = this.revisionInfo.getParentCount(this.patchNum);
 
     const dropdownContent: DropdownItem[] = [];
     for (const basePatch of this.availablePatches) {
@@ -245,12 +267,12 @@
 
     dropdownContent.push({
       text: isMerge ? 'Auto Merge' : 'Base',
-      value: 'PARENT',
+      value: PARENT,
     });
 
     for (let idx = 0; isMerge && idx < maxParents; idx++) {
       dropdownContent.push({
-        disabled: idx >= currentParentCount,
+        disabled: idx >= parentCount,
         triggerText: `Parent ${idx + 1}`,
         text: `Parent ${idx + 1}`,
         mobileText: `Parent ${idx + 1}`,
@@ -284,7 +306,7 @@
       const patchNum = patch.num;
       const entry = this.createDropdownEntry(
         patchNum,
-        patchNum === 'edit' ? '' : 'Patchset ',
+        patchNum === EDIT ? '' : 'Patchset ',
         getShaForPatch(patch)
       );
       dropdownContent.push({
@@ -347,7 +369,7 @@
    * is sorted in reverse order (higher patchset nums first), invalid patch
    * nums have an index greater than the index of basePatchNum.
    *
-   * In addition, if the current basePatchNum is 'PARENT', all patchNums are
+   * In addition, if the current basePatchNum is PARENT, all patchNums are
    * valid.
    *
    * If the current basePatchNum is a parent index, then only patches that have
@@ -359,10 +381,10 @@
    * @param patchNum The possible patch num.
    */
   computeRightDisabled(
-    basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum
+    basePatchNum: BasePatchSetNum,
+    patchNum: RevisionPatchSetNum
   ): boolean {
-    if (basePatchNum === ParentPatchSetNum) {
+    if (basePatchNum === PARENT) {
       return false;
     }
 
@@ -435,7 +457,7 @@
    * Catches value-change events from the patchset dropdowns and determines
    * whether or not a patch change event should be fired.
    */
-  private handlePatchChange(e: DropDownValueChangeEvent) {
+  private handlePatchChange(e: ValueChangedEvent<string>) {
     const detail: PatchRangeChangeDetail = {
       patchNum: this.patchNum,
       basePatchNum: this.basePatchNum,
@@ -444,13 +466,13 @@
     const patchSetValue = convertToPatchSetNum(e.detail.value)!;
     const latestPatchNum = computeLatestPatchNum(this.availablePatches);
     if (target === this.patchNumDropdown) {
-      if (detail.patchNum === e.detail.value) return;
+      if (detail.patchNum === patchSetValue) return;
       this.reporting.reportInteraction('right-patchset-changed', {
         previous: detail.patchNum,
-        current: e.detail.value,
+        current: patchSetValue,
         latest: latestPatchNum,
         commentCount: this.changeComments?.computeCommentThreadCount({
-          patchNum: e.detail.value as PatchSetNum,
+          patchNum: patchSetValue,
         }),
       });
       detail.patchNum = patchSetValue;
@@ -458,7 +480,7 @@
       if (detail.basePatchNum === patchSetValue) return;
       this.reporting.reportInteraction('left-patchset-changed', {
         previous: detail.basePatchNum,
-        current: e.detail.value,
+        current: patchSetValue,
         commentCount: this.changeComments?.computeCommentThreadCount({
           patchNum: patchSetValue,
         }),
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index 342fe3a..584fcd7 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -1,33 +1,22 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import '../gr-comment-api/gr-comment-api';
+import '../../../test/common-test-setup';
 import '../../shared/revision-info/revision-info';
 import './gr-patch-range-select';
 import {GrPatchRangeSelect} from './gr-patch-range-select';
-import '../../../test/mocks/comment-api';
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
-import {stubRestApi} from '../../../test/test-utils';
+import {stubReporting} from '../../../test/test-utils';
 import {
   BasePatchSetNum,
-  EditPatchSetNum,
+  EDIT,
+  RevisionPatchSetNum,
+  PARENT,
   PatchSetNum,
+  PatchSetNumber,
   RevisionInfo,
   Timestamp,
   UrlEncodedCommentId,
@@ -36,8 +25,11 @@
 import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
 import {SpecialFilePath} from '../../../constants/constants';
 import {
+  createChangeViewState,
   createEditRevision,
+  createParsedChange,
   createRevision,
+  createRevisions,
 } from '../../../test/test-data-generators';
 import {PatchSet} from '../../../utils/patch-set-util';
 import {
@@ -45,8 +37,11 @@
   GrDropdownList,
 } from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-patch-range-select');
+import {fire} from '../../../utils/event-util';
+import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {changeViewModelToken} from '../../../models/views/change';
+import {changeModelToken} from '../../../models/change/change-model';
 
 type RevIdToRevisionInfo = {
   [revisionId: string]: RevisionInfo | EditRevisionInfo;
@@ -64,35 +59,57 @@
   }
 
   setup(async () => {
-    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.
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-patch-range-select></gr-patch-range-select>`
+    );
 
+    const viewModel = testResolver(changeViewModelToken);
+    viewModel.setState({
+      ...createChangeViewState(),
+      patchNum: 1 as RevisionPatchSetNum,
+      basePatchNum: PARENT,
+    });
+    const changeModel = testResolver(changeModelToken);
+    changeModel.updateStateChange({
+      ...createParsedChange(),
+      revisions: createRevisions(5),
+    });
     // Stub methods on the changeComments object after changeComments has
     // been initialized.
     element.changeComments = new ChangeComments();
     await element.updateComplete;
   });
 
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h3 class="assistive-tech-only">Patchset Range Selection</h3>
+        <span aria-label="patch range starts with" class="patchRange">
+          <gr-dropdown-list id="basePatchDropdown"> </gr-dropdown-list>
+        </span>
+        <span aria-hidden="true" class="arrow"> → </span>
+        <span aria-label="patch range ends with" class="patchRange">
+          <gr-dropdown-list id="patchNumDropdown"> </gr-dropdown-list>
+        </span>
+      `
+    );
+  });
+
   test('enabled/disabled options', async () => {
-    element.revisions = [
-      createRevision(3) as RevisionInfo,
-      createEditRevision(2) as EditRevisionInfo,
-      createRevision(2) as RevisionInfo,
-      createRevision(1) as RevisionInfo,
+    element.sortedRevisions = [
+      createRevision(3),
+      createEditRevision(2),
+      createRevision(2),
+      createRevision(1),
     ];
     await element.updateComplete;
 
-    const parent = 'PARENT' as PatchSetNum;
-    const edit = EditPatchSetNum;
-
     for (const patchNum of [1, 2, 3]) {
       assert.isFalse(
-        element.computeRightDisabled(parent, patchNum as PatchSetNum)
+        element.computeRightDisabled(PARENT, patchNum as PatchSetNumber)
       );
     }
     for (const basePatchNum of [1, 2]) {
@@ -106,34 +123,30 @@
     assert.isTrue(
       element.computeLeftDisabled(3 as PatchSetNum, 3 as PatchSetNum)
     );
-    assert.isTrue(element.computeRightDisabled(edit, 1 as PatchSetNum));
-    assert.isTrue(element.computeRightDisabled(edit, 2 as PatchSetNum));
-    assert.isFalse(element.computeRightDisabled(edit, 3 as PatchSetNum));
-    assert.isTrue(element.computeRightDisabled(edit, edit));
   });
 
   test('computeBaseDropdownContent', async () => {
     element.availablePatches = [
-      {num: 'edit', sha: '1'} as PatchSet,
+      {num: EDIT, sha: '1'} as PatchSet,
       {num: 3, sha: '2'} as PatchSet,
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
     ];
-    element.revisions = [
-      createRevision(2),
-      createRevision(3),
-      createRevision(1),
+    element.sortedRevisions = [
       createRevision(4),
+      createRevision(3),
+      createRevision(2),
+      createRevision(1),
     ];
-    element.revisionInfo = getInfo(element.revisions);
+    element.revisionInfo = getInfo(element.sortedRevisions);
     const expectedResult: DropdownItem[] = [
       {
         disabled: true,
         triggerText: 'Patchset edit',
         text: 'Patchset edit | 1',
-        mobileText: 'edit',
+        mobileText: EDIT,
         bottomText: '',
-        value: 'edit',
+        value: EDIT,
       },
       {
         disabled: true,
@@ -164,57 +177,57 @@
       } as DropdownItem,
       {
         text: 'Base',
-        value: 'PARENT',
+        value: PARENT,
       } as DropdownItem,
     ];
-    element.patchNum = 1 as PatchSetNum;
-    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    element.patchNum = 1 as PatchSetNumber;
+    element.basePatchNum = PARENT;
     await element.updateComplete;
 
     assert.deepEqual(element.computeBaseDropdownContent(), expectedResult);
   });
 
   test('computeBaseDropdownContent called when patchNum updates', async () => {
-    element.revisions = [
-      createRevision(2),
-      createRevision(3),
-      createRevision(1),
+    element.sortedRevisions = [
       createRevision(4),
+      createRevision(3),
+      createRevision(2),
+      createRevision(1),
     ];
-    element.revisionInfo = getInfo(element.revisions);
+    element.revisionInfo = getInfo(element.sortedRevisions);
     element.availablePatches = [
       {num: 1, sha: '1'} as PatchSet,
       {num: 2, sha: '2'} as PatchSet,
       {num: 3, sha: '3'} as PatchSet,
-      {num: 'edit', sha: '4'} as PatchSet,
+      {num: EDIT, sha: '4'} as PatchSet,
     ];
-    element.patchNum = 2 as PatchSetNum;
-    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    element.patchNum = 2 as PatchSetNumber;
+    element.basePatchNum = PARENT as BasePatchSetNum;
     await element.updateComplete;
 
     const baseDropDownStub = sinon.stub(element, 'computeBaseDropdownContent');
 
     // Should be recomputed for each available patch
-    element.patchNum = 1 as PatchSetNum;
+    element.patchNum = 1 as PatchSetNumber;
     await element.updateComplete;
     assert.equal(baseDropDownStub.callCount, 1);
   });
 
   test('computeBaseDropdownContent called when changeComments update', async () => {
-    element.revisions = [
-      createRevision(2),
-      createRevision(3),
-      createRevision(1),
+    element.sortedRevisions = [
       createRevision(4),
+      createRevision(3),
+      createRevision(2),
+      createRevision(1),
     ];
-    element.revisionInfo = getInfo(element.revisions);
+    element.revisionInfo = getInfo(element.sortedRevisions);
     element.availablePatches = [
       {num: 3, sha: '2'} as PatchSet,
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
     ];
-    element.patchNum = 2 as PatchSetNum;
-    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    element.patchNum = 2 as PatchSetNumber;
+    element.basePatchNum = PARENT as BasePatchSetNum;
     await element.updateComplete;
 
     // Should be recomputed for each available patch
@@ -226,21 +239,21 @@
   });
 
   test('computePatchDropdownContent called when basePatchNum updates', async () => {
-    element.revisions = [
+    element.sortedRevisions = [
       createRevision(2),
       createRevision(3),
       createRevision(1),
       createRevision(4),
     ];
-    element.revisionInfo = getInfo(element.revisions);
+    element.revisionInfo = getInfo(element.sortedRevisions);
     element.availablePatches = [
       {num: 1, sha: '1'} as PatchSet,
       {num: 2, sha: '2'} as PatchSet,
       {num: 3, sha: '3'} as PatchSet,
-      {num: 'edit', sha: '4'} as PatchSet,
+      {num: EDIT, sha: '4'} as PatchSet,
     ];
-    element.patchNum = 2 as PatchSetNum;
-    element.basePatchNum = 'PARENT' as BasePatchSetNum;
+    element.patchNum = 2 as PatchSetNumber;
+    element.basePatchNum = PARENT as BasePatchSetNum;
     await element.updateComplete;
 
     // Should be recomputed for each available patch
@@ -252,28 +265,28 @@
 
   test('computePatchDropdownContent', async () => {
     element.availablePatches = [
-      {num: 'edit', sha: '1'} as PatchSet,
+      {num: EDIT, sha: '1'} as PatchSet,
       {num: 3, sha: '2'} as PatchSet,
       {num: 2, sha: '3'} as PatchSet,
       {num: 1, sha: '4'} as PatchSet,
     ];
     element.basePatchNum = 1 as BasePatchSetNum;
-    element.revisions = [
-      createRevision(3) as RevisionInfo,
-      createEditRevision(2) as EditRevisionInfo,
-      createRevision(2, 'description') as RevisionInfo,
-      createRevision(1) as RevisionInfo,
+    element.sortedRevisions = [
+      createRevision(3),
+      createEditRevision(2),
+      createRevision(2, 'description'),
+      createRevision(1),
     ];
     await element.updateComplete;
 
     const expectedResult: DropdownItem[] = [
       {
         disabled: false,
-        triggerText: 'edit',
+        triggerText: EDIT,
         text: 'edit | 1',
-        mobileText: 'edit',
+        mobileText: EDIT,
         bottomText: '',
-        value: 'edit',
+        value: EDIT,
       },
       {
         disabled: false,
@@ -340,7 +353,7 @@
         {
           id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
           message: 'test',
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           unresolved: true,
           updated: '2017-10-11 20:48:40.000000000' as Timestamp,
         },
@@ -349,13 +362,13 @@
         {
           id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
           message: 'test',
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           updated: '2017-10-12 20:48:40.000000000' as Timestamp,
         },
         {
           id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
           message: 'test',
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           updated: '2017-10-13 20:48:40.000000000' as Timestamp,
         },
       ],
@@ -365,7 +378,7 @@
         {
           id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
           message: 'test',
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           unresolved: true,
           updated: '2017-10-11 20:48:40.000000000' as Timestamp,
         },
@@ -392,30 +405,74 @@
     assert.equal(element.computePatchSetCommentsString(1 as PatchSetNum), '');
   });
 
-  test('patch-range-change fires', () => {
+  test('patch-range-change fires', async () => {
     const handler = sinon.stub();
     element.basePatchNum = 1 as BasePatchSetNum;
-    element.patchNum = 3 as PatchSetNum;
-    element.addEventListener('patch-range-change', handler);
+    element.patchNum = 3 as PatchSetNumber;
+    element.availablePatches = [
+      {num: EDIT, sha: '1'} as PatchSet,
+      {num: 3, sha: '2'} as PatchSet,
+      {num: 2, sha: '3'} as PatchSet,
+      {num: 1, sha: '4'} as PatchSet,
+    ];
+    element.sortedRevisions = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(element.sortedRevisions);
+    await element.updateComplete;
 
-    queryAndAssert<GrDropdownList>(
+    element.addEventListener('patch-range-change', handler);
+    const basePatchDropdown = queryAndAssert<GrDropdownList>(
       element,
       '#basePatchDropdown'
-    )._handleValueChange('2', [{text: '', value: '2'}]);
-    assert.isTrue(handler.calledOnce);
+    );
+    basePatchDropdown.value = '2';
+    await basePatchDropdown.updateComplete;
+    assert.equal(handler.callCount, 1);
     assert.deepEqual(handler.lastCall.args[0].detail, {
       basePatchNum: 2,
       patchNum: 3,
     });
 
     // BasePatchNum should not have changed, due to one-way data binding.
-    queryAndAssert<GrDropdownList>(
+    const patchNumDropdown = queryAndAssert<GrDropdownList>(
       element,
       '#patchNumDropdown'
-    )._handleValueChange('edit', [{text: '', value: 'edit'}]);
+    );
+    patchNumDropdown.value = EDIT;
+    await patchNumDropdown.updateComplete;
     assert.deepEqual(handler.lastCall.args[0].detail, {
       basePatchNum: 1,
-      patchNum: 'edit',
+      patchNum: EDIT,
     });
   });
+
+  test('handlePatchChange', async () => {
+    element.availablePatches = [
+      {num: EDIT, sha: '1'} as PatchSet,
+      {num: 3, sha: '2'} as PatchSet,
+      {num: 2, sha: '3'} as PatchSet,
+      {num: 1, sha: '4'} as PatchSet,
+    ];
+    element.sortedRevisions = [
+      createRevision(2),
+      createRevision(3),
+      createRevision(1),
+      createRevision(4),
+    ];
+    element.revisionInfo = getInfo(element.sortedRevisions);
+    element.patchNum = 1 as PatchSetNumber;
+    element.basePatchNum = PARENT;
+    await element.updateComplete;
+
+    const stub = stubReporting('reportInteraction');
+    fire(element.patchNumDropdown!, 'value-change', {value: '1'});
+    assert.isFalse(stub.called);
+
+    fire(element.patchNumDropdown!, 'value-change', {value: '2'});
+    assert.isTrue(stub.called);
+  });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
deleted file mode 100644
index 8ce8ce2..0000000
--- a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import '@polymer/iron-icon/iron-icon';
-
-/**
- * Represents a header (label) for a code chunk whenever showing
- * diffs.
- * Used as a labeled header to describe selections in code for cases
- * like long comments and moved in/out chunks.
- */
-@customElement('gr-range-header')
-export class GrRangeHeader extends LitElement {
-  @property({type: String})
-  icon?: string;
-
-  static override get styles() {
-    return [
-      css`
-        .row {
-          color: var(--gr-range-header-color);
-          display: flex;
-          font-family: var(--font-family, ''), 'Roboto Mono';
-          font-size: var(--font-size-small, 12px);
-          font-weight: var(--code-hint-font-weight, 500);
-          line-height: var(--line-height-small, 16px);
-          justify-content: flex-end;
-          padding: var(--spacing-s) var(--spacing-l);
-        }
-        .icon {
-          color: var(--gr-range-header-color);
-          height: var(--line-height-small, 16px);
-          width: var(--line-height-small, 16px);
-          margin-right: var(--spacing-s);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    const icon = this.icon ?? '';
-    return html` <div class="row">
-      <iron-icon class="icon" .icon=${icon} aria-hidden="true"></iron-icon>
-      <slot></slot>
-    </div>`;
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-range-header': GrRangeHeader;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
deleted file mode 100644
index 3f2258d..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../gr-range-header/gr-range-header';
-import {CommentRange} from '../../../types/common';
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {sharedStyles} from '../../../styles/shared-styles';
-import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
-
-@customElement('gr-ranged-comment-hint')
-export class GrRangedCommentHint extends LitElement {
-  @property({type: Object})
-  range?: CommentRange;
-
-  static override get styles() {
-    return [
-      grRangedCommentTheme,
-      sharedStyles,
-      css`
-        .row {
-          display: flex;
-        }
-        gr-range-header {
-          flex-grow: 1;
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    // To pass CSS mixins for @apply to Polymer components, they need to appear
-    // in <style> inside the template.
-    /* eslint-disable lit/prefer-static-styles */
-    const customStyle = html`
-      <style>
-        .row {
-          --gr-range-header-color: var(--ranged-comment-hint-text-color);
-        }
-      </style>
-    `;
-    return html`${customStyle}
-      <div class="rangeHighlight row">
-        <gr-range-header icon="gr-icons:comment"
-          >${this._computeRangeLabel(this.range)}</gr-range-header
-        >
-      </div>`;
-  }
-
-  _computeRangeLabel(range?: CommentRange): string {
-    if (!range) return '';
-    return `Long comment range ${range.start_line} - ${range.end_line}`;
-  }
-}
-
-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_test.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
deleted file mode 100644
index 5782e4a..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma';
-import './gr-ranged-comment-hint';
-import {CommentRange} from '../../../types/common';
-import {GrRangedCommentHint} from './gr-ranged-comment-hint';
-import {queryAndAssert} from '../../../test/test-utils';
-import {GrRangeHeader} from '../gr-range-header/gr-range-header';
-
-const basicFixture = fixtureFromElement('gr-ranged-comment-hint');
-
-suite('gr-ranged-comment-hint tests', () => {
-  let element: GrRangedCommentHint;
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
-  });
-
-  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 = queryAndAssert<GrRangeHeader>(element, 'gr-range-header');
-    assert.equal(textDiv?.innerText.trim(), 'Long comment range 2 - 5');
-  });
-});
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
deleted file mode 100644
index 11712dc..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ /dev/null
@@ -1,311 +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 {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-ranged-comment-layer_html';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {strToClassName} from '../../../utils/dom-util';
-import {customElement, property, observe} from '@polymer/decorators';
-import {Side} from '../../../constants/constants';
-import {
-  PolymerDeepPropertyChange,
-  PolymerSpliceChange,
-} 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
- * outside.
- *
- * TODO(TS): Unify with what is used in gr-diff when these objects are created.
- */
-export interface CommentRangeLayer {
-  side: Side;
-  range: CommentRange;
-  hovering: boolean;
-  rootId: string;
-}
-
-/**
- * This class breaks down all comment ranges into individual line segment
- * highlights.
- */
-interface CommentRangeLineLayer {
-  hovering: boolean;
-  longRange: boolean;
-  rootId: string;
-  start: number;
-  end: number;
-}
-
-type LinesMap = {
-  [line in number]: CommentRangeLineLayer[];
-};
-
-type RangesMap = {
-  [side in Side]: LinesMap;
-};
-
-// Polymer 1 adds # before array's key, while Polymer 2 doesn't
-const HOVER_PATH_PATTERN = /^(commentRanges\.#?\d+)\.hovering$/;
-
-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 PolymerElement implements DiffLayer {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the range in a range comment was malformed and had to be
-   * normalized.
-   *
-   * It's `detail` has a `lineNum` and `side` parameter.
-   *
-   * @event normalize-range
-   */
-
-  @property({type: Array})
-  commentRanges: CommentRangeLayer[] = [];
-
-  @property({type: Array})
-  _listeners: DiffLayerListener[] = [];
-
-  @property({type: Object})
-  _rangesMap: RangesMap = {left: {}, right: {}};
-
-  get styleModuleName() {
-    return 'gr-ranged-comment-styles';
-  }
-
-  /**
-   * Layer method to add annotations to a line.
-   *
-   * @param el The DIV.contentText element to apply the annotation to.
-   */
-  annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-    let ranges: CommentRangeLineLayer[] = [];
-    if (
-      line.type === GrDiffLineType.REMOVE ||
-      (line.type === GrDiffLineType.BOTH &&
-        el.getAttribute('data-side') !== 'right')
-    ) {
-      ranges = ranges.concat(this._getRangesForLine(line, Side.LEFT));
-    }
-    if (
-      line.type === GrDiffLineType.ADD ||
-      (line.type === GrDiffLineType.BOTH &&
-        el.getAttribute('data-side') !== 'left')
-    ) {
-      ranges = ranges.concat(this._getRangesForLine(line, Side.RIGHT));
-    }
-
-    for (const range of ranges) {
-      GrAnnotation.annotateElement(
-        el,
-        range.start,
-        range.end - range.start,
-        (range.hovering
-          ? HOVER_HIGHLIGHT
-          : range.longRange
-          ? RANGE_BASE_ONLY
-          : RANGE_HIGHLIGHT) + ` ${strToClassName(range.rootId)}`
-      );
-    }
-  }
-
-  /**
-   * Register a listener for layer updates.
-   */
-  addListener(listener: DiffLayerListener) {
-    this._listeners.push(listener);
-  }
-
-  removeListener(listener: DiffLayerListener) {
-    this._listeners = this._listeners.filter(f => f !== listener);
-  }
-
-  /**
-   * Notify Layer listeners of changes to annotations.
-   */
-  _notifyUpdateRange(start: number, end: number, side: Side) {
-    for (const listener of this._listeners) {
-      listener(start, end, side);
-    }
-  }
-
-  /**
-   * Handle change in the ranges by updating the ranges maps and by
-   * emitting appropriate update notifications.
-   */
-  @observe('commentRanges.*')
-  _handleCommentRangesChange(
-    record: PolymerDeepPropertyChange<
-      CommentRangeLayer[],
-      PolymerSpliceChange<CommentRangeLayer[]>
-    >
-  ) {
-    if (!record) return;
-
-    // If the entire set of comments was changed.
-    if (record.path === 'commentRanges') {
-      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, longRange});
-          },
-        });
-      }
-    }
-
-    // If the change only changed the `hovering` property of a comment.
-    const match = record.path.match(HOVER_PATH_PATTERN);
-    if (match) {
-      // The #number indicates the key of that item in the array
-      // not the index, especially in polymer 1.
-      const {side, range, hovering, rootId} = this.get(match[1]);
-
-      this._updateRangesMap({
-        side,
-        range,
-        hovering,
-        skipLayerUpdate: true,
-        operation: (forLine, start, end, hovering) => {
-          const index = forLine.findIndex(
-            lineRange => lineRange.start === start && lineRange.end === end
-          );
-          forLine[index].hovering = hovering;
-          forLine[index].rootId = rootId;
-        },
-      });
-    }
-
-    // If comments were spliced in or out.
-    if (record.path === 'commentRanges.splices') {
-      const value = record.value as PolymerSpliceChange<CommentRangeLayer[]>;
-      for (const indexSplice of value.indexSplices) {
-        const removed = indexSplice.removed;
-        for (const {side, range, hovering, rootId} of removed) {
-          this._updateRangesMap({
-            side,
-            range,
-            hovering,
-            operation: (forLine, start, end) => {
-              const index = forLine.findIndex(
-                lineRange =>
-                  lineRange.start === start &&
-                  lineRange.end === end &&
-                  rootId === lineRange.rootId
-              );
-              forLine.splice(index, 1);
-            },
-          });
-        }
-        const added = indexSplice.object.slice(
-          indexSplice.index,
-          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, longRange});
-            },
-          });
-        }
-      }
-    }
-  }
-
-  _updateRangesMap(options: {
-    side: Side;
-    range: CommentRange;
-    hovering: boolean;
-    operation: (
-      forLine: CommentRangeLineLayer[],
-      start: number,
-      end: number,
-      hovering: boolean
-    ) => void;
-    skipLayerUpdate?: boolean;
-  }) {
-    const {side, range, hovering, operation, skipLayerUpdate} = options;
-    const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
-    for (let line = range.start_line; line <= range.end_line; line++) {
-      const forLine = forSide[line] || (forSide[line] = []);
-      const start = line === range.start_line ? range.start_character : 0;
-      const end = line === range.end_line ? range.end_character : -1;
-      operation(forLine, start, end, hovering);
-    }
-    if (!skipLayerUpdate) {
-      this._notifyUpdateRange(range.start_line, range.end_line, side);
-    }
-  }
-
-  _getRangesForLine(line: GrDiffLine, side: Side) {
-    const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
-    const ranges: CommentRangeLineLayer[] =
-      this.get(['_rangesMap', side, lineNum]) || [];
-    return (
-      ranges
-        .map(range => {
-          // Make a copy, so that the normalization below does not mess with
-          // our map.
-          range = {...range};
-          range.end = range.end === -1 ? line.text.length : range.end;
-
-          // Normalize invalid ranges where the start is after the end but the
-          // start still makes sense. Set the end to the end of the line.
-          // @see Issue 5744
-          if (range.start! >= range.end! && range.start! < line.text.length) {
-            range.end = line.text.length;
-            this.dispatchEvent(
-              new CustomEvent('normalize-range', {
-                bubbles: true,
-                composed: true,
-                detail: {lineNum, side},
-              })
-            );
-          }
-
-          return range;
-        })
-        // Sort the ranges so that hovering highlights are on top.
-        .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0))
-    );
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-ranged-comment-layer': GrRangedCommentLayer;
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_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/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
deleted file mode 100644
index 8279ab1..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
+++ /dev/null
@@ -1,353 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-line.js';
-import './gr-ranged-comment-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-ranged-comment-layer');
-
-suite('gr-ranged-comment-layer', () => {
-  let element;
-
-  setup(() => {
-    const initialCommentRanges = [
-      {
-        side: 'left',
-        range: {
-          end_character: 9,
-          end_line: 39,
-          start_character: 6,
-          start_line: 36,
-        },
-        rootId: 'a',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 22,
-          end_line: 12,
-          start_character: 10,
-          start_line: 10,
-        },
-        rootId: 'b',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 15,
-          end_line: 100,
-          start_character: 5,
-          start_line: 100,
-        },
-        rootId: 'c',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 2,
-          end_line: 55,
-          start_character: 32,
-          start_line: 55,
-        },
-        rootId: 'd',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 1,
-          end_line: 71,
-          start_character: 1,
-          start_line: 60,
-        },
-      },
-    ];
-
-    element = basicFixture.instantiate();
-    element.commentRanges = initialCommentRanges;
-  });
-
-  suite('annotate', () => {
-    let el;
-    let line;
-    let annotateElementStub;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      el = document.createElement('div');
-      el.setAttribute('data-side', 'left');
-      line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
-    });
-
-    test('type=Remove no-comment', () => {
-      line.type = GrDiffLineType.REMOVE;
-      line.beforeNumber = 40;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('type=Remove has-comment', () => {
-      line.type = GrDiffLineType.REMOVE;
-      line.beforeNumber = 36;
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      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 rangeHighlight generated_a'
-      );
-    });
-
-    test('type=Remove has-comment hovering', () => {
-      line.type = GrDiffLineType.REMOVE;
-      line.beforeNumber = 36;
-      element.set(['commentRanges', 0, 'hovering'], true);
-
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      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 rangeHoverHighlight generated_a'
-      );
-    });
-
-    test('type=Both has-comment', () => {
-      line.type = GrDiffLineType.BOTH;
-      line.beforeNumber = 36;
-
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      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 rangeHighlight generated_a'
-      );
-    });
-
-    test('type=Both has-comment off side', () => {
-      line.type = GrDiffLineType.BOTH;
-      line.beforeNumber = 36;
-      el.setAttribute('data-side', 'right');
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('type=Add has-comment', () => {
-      line.type = GrDiffLineType.ADD;
-      line.afterNumber = 12;
-      el.setAttribute('data-side', 'right');
-
-      const expectedStart = 0;
-      const expectedLength = 22;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      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 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_'
-      );
-    });
-  });
-
-  test('_handleCommentRangesChange overwrite', () => {
-    element.set('commentRanges', []);
-
-    assert.equal(Object.keys(element._rangesMap.left).length, 0);
-    assert.equal(Object.keys(element._rangesMap.right).length, 0);
-  });
-
-  test('_handleCommentRangesChange hovering', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-    const updateRangesMapSpy = sinon.spy(element, '_updateRangesMap');
-
-    element.set(['commentRanges', 1, 'hovering'], true);
-
-    // notify will be skipped for hovering
-    assert.isFalse(notifyStub.called);
-
-    assert.isTrue(updateRangesMapSpy.called);
-  });
-
-  test('_handleCommentRangesChange splice out', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-
-    element.splice('commentRanges', 1, 1);
-
-    assert.isTrue(notifyStub.called);
-    const lastCall = notifyStub.lastCall;
-    assert.equal(lastCall.args[0], 10);
-    assert.equal(lastCall.args[1], 12);
-    assert.equal(lastCall.args[2], 'right');
-  });
-
-  test('_handleCommentRangesChange splice in', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-
-    element.splice('commentRanges', 1, 0, {
-      side: 'left',
-      range: {
-        end_character: 15,
-        end_line: 275,
-        start_character: 5,
-        start_line: 250,
-      },
-    });
-
-    assert.isTrue(notifyStub.called);
-    const lastCall = notifyStub.lastCall;
-    assert.equal(lastCall.args[0], 250);
-    assert.equal(lastCall.args[1], 275);
-    assert.equal(lastCall.args[2], 'left');
-  });
-
-  test('_handleCommentRangesChange mixed actions', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-    const updateRangesMapSpy = sinon.spy(element, '_updateRangesMap');
-
-    element.set(['commentRanges', 1, 'hovering'], true);
-    assert.isTrue(updateRangesMapSpy.callCount === 1);
-    element.splice('commentRanges', 1, 1);
-    assert.isTrue(updateRangesMapSpy.callCount === 2);
-    element.splice('commentRanges', 1, 1);
-    assert.isTrue(updateRangesMapSpy.callCount === 3);
-    element.splice('commentRanges', 1, 0, {
-      side: 'left',
-      range: {
-        end_character: 15,
-        end_line: 275,
-        start_character: 5,
-        start_line: 250,
-      },
-    });
-    assert.isTrue(updateRangesMapSpy.callCount === 4);
-    element.set(['commentRanges', 2, 'hovering'], true);
-    assert.isTrue(updateRangesMapSpy.callCount === 5);
-  });
-
-  test('_computeCommentMap creates maps correctly', () => {
-    // There is only one ranged comment on the left, but it spans ll.36-39.
-    const leftKeys = [];
-    for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
-    assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
-        leftKeys.sort());
-
-    assert.equal(element._rangesMap.left[36].length, 1);
-    assert.equal(element._rangesMap.left[36][0].start, 6);
-    assert.equal(element._rangesMap.left[36][0].end, -1);
-
-    assert.equal(element._rangesMap.left[37].length, 1);
-    assert.equal(element._rangesMap.left[37][0].start, 0);
-    assert.equal(element._rangesMap.left[37][0].end, -1);
-
-    assert.equal(element._rangesMap.left[38].length, 1);
-    assert.equal(element._rangesMap.left[38][0].start, 0);
-    assert.equal(element._rangesMap.left[38][0].end, -1);
-
-    assert.equal(element._rangesMap.left[39].length, 1);
-    assert.equal(element._rangesMap.left[39][0].start, 0);
-    assert.equal(element._rangesMap.left[39][0].end, 9);
-
-    // 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());
-
-    assert.equal(element._rangesMap.right[10].length, 1);
-    assert.equal(element._rangesMap.right[10][0].start, 10);
-    assert.equal(element._rangesMap.right[10][0].end, -1);
-
-    assert.equal(element._rangesMap.right[11].length, 1);
-    assert.equal(element._rangesMap.right[11][0].start, 0);
-    assert.equal(element._rangesMap.right[11][0].end, -1);
-
-    assert.equal(element._rangesMap.right[12].length, 1);
-    assert.equal(element._rangesMap.right[12][0].start, 0);
-    assert.equal(element._rangesMap.right[12][0].end, 22);
-
-    assert.equal(element._rangesMap.right[100].length, 1);
-    assert.equal(element._rangesMap.right[100][0].start, 5);
-    assert.equal(element._rangesMap.right[100][0].end, 15);
-  });
-
-  test('_getRangesForLine normalizes invalid ranges', () => {
-    const line = {
-      afterNumber: 55,
-      text: '_getRangesForLine normalizes invalid ranges',
-    };
-    const ranges = element._getRangesForLine(line, 'right');
-    assert.equal(ranges.length, 1);
-    const range = ranges[0];
-    assert.isTrue(range.start < range.end, 'start and end are normalized');
-    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
deleted file mode 100644
index fb20a91..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
+++ /dev/null
@@ -1,44 +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 {css} from 'lit';
-
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
-
-const $_documentContainer = document.createElement('template');
-
-export const grRangedCommentTheme = css`
-  .rangeHighlight {
-    background-color: var(--diff-highlight-range-color);
-  }
-  .rangeHoverHighlight {
-    background-color: var(--diff-highlight-range-hover-color);
-  }
-`;
-
-$_documentContainer.innerHTML = `<dom-module id="gr-ranged-comment-theme">
-  <template>
-    <style>
-    ${grRangedCommentTheme.cssText}
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
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
deleted file mode 100644
index 0f64d9e..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles';
-import '../../shared/gr-tooltip/gr-tooltip';
-import {GrTooltip} from '../../shared/gr-tooltip/gr-tooltip';
-import {customElement, property} from '@polymer/decorators';
-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 {
-    'gr-selection-action-box': GrSelectionActionBox;
-  }
-}
-
-export interface GrSelectionActionBox {
-  $: {
-    tooltip: GrTooltip;
-  };
-}
-
-@customElement('gr-selection-action-box')
-export class GrSelectionActionBox extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the comment creation action was taken (click).
-   *
-   * @event create-comment-requested
-   */
-
-  @property({type: Boolean})
-  positionBelow = false;
-
-  constructor() {
-    super();
-    // See https://crbug.com/gerrit/4767
-    this.addEventListener('mousedown', e => this._handleMouseDown(e));
-  }
-
-  async placeAbove(el: Text | Element | Range) {
-    await this.$.tooltip.updateComplete;
-    const rect = this._getTargetBoundingRect(el);
-    const boxRect = this.$.tooltip.getBoundingClientRect();
-    const parentRect = this._getParentBoundingClientRect();
-    if (parentRect === null) {
-      return;
-    }
-    this.style.top = `${rect.top - parentRect.top - boxRect.height - 6}px`;
-    this.style.left = `${
-      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
-    }px`;
-  }
-
-  async placeBelow(el: Text | Element | Range) {
-    await this.$.tooltip.updateComplete;
-    const rect = this._getTargetBoundingRect(el);
-    const boxRect = this.$.tooltip.getBoundingClientRect();
-    const parentRect = this._getParentBoundingClientRect();
-    if (parentRect === null) {
-      return;
-    }
-    this.style.top = `${rect.top - parentRect.top + boxRect.height - 6}px`;
-    this.style.left = `${
-      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
-    }px`;
-  }
-
-  private _getParentBoundingClientRect() {
-    // With native shadow DOM, the parent is the shadow root, not the gr-diff
-    // element
-    if (this.parentElement) {
-      return this.parentElement.getBoundingClientRect();
-    }
-    if (this.parentNode !== null) {
-      return (this.parentNode as ShadowRoot).host.getBoundingClientRect();
-    }
-    return null;
-  }
-
-  private _getTargetBoundingRect(el: Text | Element | Range) {
-    let rect;
-    if (el instanceof Text) {
-      const range = document.createRange();
-      range.selectNode(el);
-      rect = range.getBoundingClientRect();
-      range.detach();
-    } else {
-      rect = el.getBoundingClientRect();
-    }
-    return rect;
-  }
-
-  private _handleMouseDown(e: MouseEvent) {
-    if (e.button !== 0) {
-      return;
-    } // 0 = main button
-    e.preventDefault();
-    e.stopPropagation();
-    fireEvent(this, 'create-comment-requested');
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
deleted file mode 100644
index 558742a..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_html.ts
+++ /dev/null
@@ -1,36 +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 {
-      cursor: pointer;
-      font-family: var(--font-family);
-      position: absolute;
-      white-space: nowrap;
-      /* This prevents the mouse over the tooltip from interfering with the
-         selection. */
-      pointer-events: none;
-    }
-  </style>
-  <gr-tooltip
-    id="tooltip"
-    text="Press c to comment"
-    position-below="[[positionBelow]]"
-  ></gr-tooltip>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
deleted file mode 100644
index c978c37..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.js
+++ /dev/null
@@ -1,119 +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-selection-action-box.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-  <div>
-    <gr-selection-action-box></gr-selection-action-box>
-    <div class="target">some text</div>
-  </div>
-`);
-
-suite('gr-selection-action-box', () => {
-  let container;
-  let element;
-
-  setup(() => {
-    container = basicFixture.instantiate();
-    element = container.querySelector('gr-selection-action-box');
-
-    sinon.stub(element, 'dispatchEvent');
-  });
-
-  test('ignores regular keys', () => {
-    MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
-    assert.isFalse(element.dispatchEvent.called);
-  });
-
-  suite('mousedown reacts only to main button', () => {
-    let e;
-
-    setup(() => {
-      e = {
-        button: 0,
-        preventDefault: sinon.stub(),
-        stopPropagation: sinon.stub(),
-      };
-    });
-
-    test('event handled if main button', () => {
-      element._handleMouseDown(e);
-      assert.isTrue(e.preventDefault.called);
-      assert.equal(
-          element.dispatchEvent.lastCall.args[0].type,
-          'create-comment-requested'
-      );
-    });
-
-    test('event ignored if not main button', () => {
-      e.button = 1;
-      element._handleMouseDown(e);
-      assert.isFalse(e.preventDefault.called);
-      assert.isFalse(element.dispatchEvent.called);
-    });
-  });
-
-  suite('placeAbove', () => {
-    let target;
-
-    setup(() => {
-      target = container.querySelector('.target');
-      sinon.stub(container, 'getBoundingClientRect').returns(
-          {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
-      sinon.stub(element, '_getTargetBoundingRect').returns(
-          {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
-      sinon.stub(element.$.tooltip, 'getBoundingClientRect').returns(
-          {width: 10, height: 10});
-    });
-
-    test('placeAbove for Element argument', async () => {
-      await element.placeAbove(target);
-      assert.equal(element.style.top, '25px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('placeAbove for Text Node argument', async () => {
-      await element.placeAbove(target.firstChild);
-      assert.equal(element.style.top, '25px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('placeBelow for Element argument', async () => {
-      await element.placeBelow(target);
-      assert.equal(element.style.top, '45px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('placeBelow for Text Node argument', async () => {
-      await element.placeBelow(target.firstChild);
-      assert.equal(element.style.top, '45px');
-      assert.equal(element.style.left, '72px');
-    });
-
-    test('uses document.createRange', async () => {
-      sinon.spy(document, 'createRange');
-      element._getTargetBoundingRect.restore();
-      sinon.spy(element, '_getTargetBoundingRect');
-      await element.placeAbove(target.firstChild);
-      assert.isTrue(document.createRange.called);
-    });
-  });
-});
-
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
deleted file mode 100644
index dddd6a4..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ /dev/null
@@ -1,586 +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 {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {CancelablePromise, util} from '../../../scripts/util';
-import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
-import {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types';
-import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
-import {HLJS_LIBRARY_CONFIG} from '../../shared/gr-lib-loader/highlightjs_config';
-import {Side} from '../../../constants/constants';
-
-const LANGUAGE_MAP = new Map<string, string>([
-  ['application/dart', 'dart'],
-  ['application/json', 'json'],
-  ['application/x-powershell', 'powershell'],
-  ['application/typescript', 'typescript'],
-  ['application/xml', 'xml'],
-  ['application/xquery', 'xquery'],
-  ['application/x-erb', 'erb'],
-  ['text/css', 'css'],
-  ['text/html', 'html'],
-  ['text/javascript', 'js'],
-  ['text/jsx', 'jsx'],
-  ['text/tsx', 'jsx'],
-  ['text/x-c', 'cpp'],
-  ['text/x-c++src', 'cpp'],
-  ['text/x-clojure', 'clojure'],
-  ['text/x-cmake', 'cmake'],
-  ['text/x-coffeescript', 'coffeescript'],
-  ['text/x-common-lisp', 'lisp'],
-  ['text/x-crystal', 'crystal'],
-  ['text/x-csharp', 'csharp'],
-  ['text/x-csrc', 'cpp'],
-  ['text/x-d', 'd'],
-  ['text/x-diff', 'diff'],
-  ['text/x-django', 'django'],
-  ['text/x-dockerfile', 'dockerfile'],
-  ['text/x-ebnf', 'ebnf'],
-  ['text/x-elm', 'elm'],
-  ['text/x-erlang', 'erlang'],
-  ['text/x-fortran', 'fortran'],
-  ['text/x-fsharp', 'fsharp'],
-  ['text/x-gherkin', 'gherkin'],
-  ['text/x-go', 'go'],
-  ['text/x-groovy', 'groovy'],
-  ['text/x-haml', 'haml'],
-  ['text/x-handlebars', 'handlebars'],
-  ['text/x-haskell', 'haskell'],
-  ['text/x-haxe', 'haxe'],
-  ['text/x-ini', 'ini'],
-  ['text/x-java', 'java'],
-  ['text/x-julia', 'julia'],
-  ['text/x-kotlin', 'kotlin'],
-  ['text/x-latex', 'latex'],
-  ['text/x-less', 'less'],
-  ['text/x-lua', 'lua'],
-  ['text/x-mathematica', 'mathematica'],
-  ['text/x-nginx-conf', 'nginx'],
-  ['text/x-nsis', 'nsis'],
-  ['text/x-objectivec', 'objectivec'],
-  ['text/x-ocaml', 'ocaml'],
-  ['text/x-perl', 'perl'],
-  ['text/x-pgsql', 'pgsql'], // postgresql
-  ['text/x-php', 'php'],
-  ['text/x-properties', 'properties'],
-  ['text/x-protobuf', 'protobuf'],
-  ['text/x-puppet', 'puppet'],
-  ['text/x-python', 'python'],
-  ['text/x-q', 'q'],
-  ['text/x-ruby', 'ruby'],
-  ['text/x-rustsrc', 'rust'],
-  ['text/x-scala', 'scala'],
-  ['text/x-scss', 'scss'],
-  ['text/x-scheme', 'scheme'],
-  ['text/x-shell', 'shell'],
-  ['text/x-soy', 'soy'],
-  ['text/x-spreadsheet', 'excel'],
-  ['text/x-sh', 'bash'],
-  ['text/x-sql', 'sql'],
-  ['text/x-swift', 'swift'],
-  ['text/x-systemverilog', 'sv'],
-  ['text/x-tcl', 'tcl'],
-  ['text/x-torque', 'torque'],
-  ['text/x-twig', 'twig'],
-  ['text/x-vb', 'vb'],
-  ['text/x-verilog', 'v'],
-  ['text/x-vhdl', 'vhdl'],
-  ['text/x-yaml', 'yaml'],
-  ['text/vbscript', 'vbscript'],
-]);
-const ASYNC_DELAY = 10;
-
-const CLASS_SAFELIST = new Set<string>([
-  'gr-diff gr-syntax gr-syntax-attr',
-  'gr-diff gr-syntax gr-syntax-attribute',
-  'gr-diff gr-syntax gr-syntax-built_in',
-  'gr-diff gr-syntax gr-syntax-comment',
-  'gr-diff gr-syntax gr-syntax-doctag',
-  'gr-diff gr-syntax gr-syntax-function',
-  'gr-diff gr-syntax gr-syntax-keyword',
-  'gr-diff gr-syntax gr-syntax-link',
-  'gr-diff gr-syntax gr-syntax-literal',
-  'gr-diff gr-syntax gr-syntax-meta',
-  'gr-diff gr-syntax gr-syntax-meta-keyword',
-  'gr-diff gr-syntax gr-syntax-name',
-  'gr-diff gr-syntax gr-syntax-number',
-  'gr-diff gr-syntax gr-syntax-params',
-  'gr-diff gr-syntax gr-syntax-property',
-  'gr-diff gr-syntax gr-syntax-regexp',
-  'gr-diff gr-syntax gr-syntax-selector-attr',
-  'gr-diff gr-syntax gr-syntax-selector-class',
-  'gr-diff gr-syntax gr-syntax-selector-id',
-  'gr-diff gr-syntax gr-syntax-selector-pseudo',
-  'gr-diff gr-syntax gr-syntax-selector-tag',
-  'gr-diff gr-syntax gr-syntax-string',
-  'gr-diff gr-syntax gr-syntax-tag',
-  'gr-diff gr-syntax gr-syntax-template-tag',
-  'gr-diff gr-syntax gr-syntax-template-variable',
-  'gr-diff gr-syntax gr-syntax-title',
-  'gr-diff gr-syntax gr-syntax-type',
-  'gr-diff gr-syntax gr-syntax-variable',
-]);
-
-const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
-const CPP_WCHAR_PATTERN = /L'(\\)?.'/g;
-const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
-const GO_BACKSLASH_LITERAL = "'\\\\'";
-const GLOBAL_LT_PATTERN = /</g;
-
-interface SyntaxLayerRange {
-  start: number;
-  length: number;
-  className: string;
-}
-
-interface SyntaxLayerState {
-  sectionIndex: number;
-  lineIndex: number;
-  baseContext: unknown;
-  revisionContext: unknown;
-  lineNums: {left: number; right: number};
-  lastNotify: {left: number; right: number};
-}
-
-export class GrSyntaxLayer implements DiffLayer {
-  diff?: DiffInfo;
-
-  enabled = true;
-
-  private baseRanges: SyntaxLayerRange[][] = [];
-
-  private revisionRanges: SyntaxLayerRange[][] = [];
-
-  private baseLanguage?: string;
-
-  private revisionLanguage?: string;
-
-  private listeners: DiffLayerListener[] = [];
-
-  private processHandle: number | null = null;
-
-  private processPromise: CancelablePromise<unknown> | null = null;
-
-  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.listeners.push(listener);
-  }
-
-  removeListener(listener: DiffLayerListener) {
-    this.listeners = this.listeners.filter(f => f !== listener);
-  }
-
-  /**
-   * Annotation layer method to add syntax annotations to the given element
-   * for the given line.
-   */
-  annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-    if (!this.enabled) return;
-    if (line.beforeNumber === FILE || line.afterNumber === FILE) return;
-    if (line.beforeNumber === 'LOST' || line.afterNumber === 'LOST') return;
-    // Determine the side.
-    let side;
-    if (
-      line.type === GrDiffLineType.REMOVE ||
-      (line.type === GrDiffLineType.BOTH &&
-        el.getAttribute('data-side') !== 'right')
-    ) {
-      side = 'left';
-    } else if (
-      line.type === GrDiffLineType.ADD ||
-      el.getAttribute('data-side') !== 'left'
-    ) {
-      side = 'right';
-    }
-
-    // Find the relevant syntax ranges, if any.
-    let ranges: SyntaxLayerRange[] = [];
-    if (side === 'left' && this.baseRanges.length >= line.beforeNumber) {
-      ranges = this.baseRanges[line.beforeNumber - 1] || [];
-    } else if (
-      side === 'right' &&
-      this.revisionRanges.length >= line.afterNumber
-    ) {
-      ranges = this.revisionRanges[line.afterNumber - 1] || [];
-    }
-
-    // Apply the ranges to the element.
-    for (const range of ranges) {
-      GrAnnotation.annotateElement(
-        el,
-        range.start,
-        range.length,
-        range.className
-      );
-    }
-  }
-
-  _getLanguage(metaInfo: DiffFileMetaInfo) {
-    // The Gerrit API provides only content-type, but for other users of
-    // gr-diff it may be more convenient to specify the language directly.
-    return metaInfo.language ?? LANGUAGE_MAP.get(metaInfo.content_type);
-  }
-
-  /**
-   * Start processing syntax for the loaded diff and notify layer listeners
-   * as syntax info comes online.
-   */
-  process() {
-    // Cancel any still running process() calls, because they append to the
-    // same baseRanges and revisionRanges fields.
-    this.cancel();
-
-    // Discard existing ranges.
-    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);
-    }
-    if (this.diff.meta_b) {
-      this.revisionLanguage = this._getLanguage(this.diff.meta_b);
-    }
-    if (!this.baseLanguage && !this.revisionLanguage) {
-      return Promise.resolve();
-    }
-
-    const state: SyntaxLayerState = {
-      sectionIndex: 0,
-      lineIndex: 0,
-      baseContext: undefined,
-      revisionContext: undefined,
-      lineNums: {left: 1, right: 1},
-      lastNotify: {left: 1, right: 1},
-    };
-
-    const rangesCache = new Map<string, SyntaxLayerRange[]>();
-
-    this.processPromise = util.makeCancelable(
-      this._loadHLJS().then(
-        () =>
-          new Promise<void>(resolve => {
-            const nextStep = () => {
-              this.processHandle = null;
-              this._processNextLine(state, rangesCache);
-
-              // Move to the next line in the section.
-              state.lineIndex++;
-
-              // If the section has been exhausted, move to the next one.
-              if (this._isSectionDone(state)) {
-                state.lineIndex = 0;
-                state.sectionIndex++;
-              }
-
-              // If all sections have been exhausted, finish.
-              if (
-                !this.diff ||
-                state.sectionIndex >= this.diff.content.length
-              ) {
-                resolve();
-                this._notify(state);
-                return;
-              }
-
-              if (state.lineIndex % 100 === 0) {
-                this._notify(state);
-                this.processHandle = window.setTimeout(nextStep, ASYNC_DELAY);
-              } else {
-                nextStep.call(this);
-              }
-            };
-
-            this.processHandle = window.setTimeout(nextStep, 1);
-          })
-      )
-    );
-    return this.processPromise.finally(() => {
-      this.processPromise = null;
-    });
-  }
-
-  /**
-   * Cancel any asynchronous syntax processing jobs.
-   */
-  cancel() {
-    if (this.processHandle !== null) {
-      clearTimeout(this.processHandle);
-      this.processHandle = null;
-    }
-    if (this.processPromise) {
-      this.processPromise.cancel();
-    }
-  }
-
-  /**
-   * 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
-   * markers.
-   *
-   * @param str The string of HTML.
-   * @param rangesCache A map for caching
-   * ranges for each string. A cache is read and written by this method.
-   * Since diff is mostly comparing same file on two sides, there is good rate
-   * of duplication at least for parts that are on left and right parts.
-   * @return The list of ranges.
-   */
-  _rangesFromString(
-    str: string,
-    rangesCache: Map<string, SyntaxLayerRange[]>
-  ): SyntaxLayerRange[] {
-    const cached = rangesCache.get(str);
-    if (cached) return cached;
-
-    const div = document.createElement('div');
-    div.innerHTML = str;
-    const ranges = this._rangesFromElement(div, 0);
-    rangesCache.set(str, ranges);
-    return ranges;
-  }
-
-  _rangesFromElement(elem: Element, offset: number): SyntaxLayerRange[] {
-    let result: SyntaxLayerRange[] = [];
-    for (const node of elem.childNodes) {
-      const nodeLength = GrAnnotation.getLength(node);
-      // Note: HLJS may emit a span with class undefined when it thinks there
-      // may be a syntax error.
-      if (
-        node instanceof Element &&
-        node.tagName === 'SPAN' &&
-        node.className !== 'undefined'
-      ) {
-        if (CLASS_SAFELIST.has(node.className)) {
-          result.push({
-            start: offset,
-            length: nodeLength,
-            className: node.className,
-          });
-        }
-        if (node.children.length) {
-          result = result.concat(this._rangesFromElement(node, offset));
-        }
-      }
-      offset += nodeLength;
-    }
-    return result;
-  }
-
-  /**
-   * For a given state, process the syntax for the next line (or pair of
-   * lines).
-   */
-  _processNextLine(
-    state: SyntaxLayerState,
-    rangesCache: Map<string, SyntaxLayerRange[]>
-  ) {
-    if (!this.diff) return;
-    if (!this.hljs) return;
-
-    let baseLine;
-    let revisionLine;
-    const section = this.diff.content[state.sectionIndex];
-    if (section.ab) {
-      baseLine = section.ab[state.lineIndex];
-      revisionLine = section.ab[state.lineIndex];
-      state.lineNums.left++;
-      state.lineNums.right++;
-    } else {
-      if (section.a && section.a.length > state.lineIndex) {
-        baseLine = section.a[state.lineIndex];
-        state.lineNums.left++;
-      }
-      if (section.b && section.b.length > state.lineIndex) {
-        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 &&
-      baseLine !== undefined &&
-      this.hljs.getLanguage(this.baseLanguage)
-    ) {
-      baseLine = this._workaround(this.baseLanguage, baseLine);
-      result = this.hljs.highlight(
-        this.baseLanguage,
-        baseLine,
-        true,
-        state.baseContext
-      );
-      this.baseRanges.push(this._rangesFromString(result.value, rangesCache));
-      state.baseContext = result.top;
-    }
-
-    if (
-      this.revisionLanguage &&
-      revisionLine !== undefined &&
-      this.hljs.getLanguage(this.revisionLanguage)
-    ) {
-      revisionLine = this._workaround(this.revisionLanguage, revisionLine);
-      result = this.hljs.highlight(
-        this.revisionLanguage,
-        revisionLine,
-        true,
-        state.revisionContext
-      );
-      this.revisionRanges.push(
-        this._rangesFromString(result.value, rangesCache)
-      );
-      state.revisionContext = result.top;
-    }
-  }
-
-  /**
-   * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained
-   * cases before sending them into HLJS so that they parse correctly.
-   *
-   * Important notes:
-   * * These tests should be as constrained as possible to avoid interfering
-   * 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 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
-   * must be consistent.
-   *
-   * @param language The name of the HLJS language plugin in use.
-   * @param line The line of code to potentially rewrite.
-   * @return A potentially-rewritten line of code.
-   */
-  _workaround(language: string, line: string) {
-    if (language === 'cpp') {
-      /**
-       * Prevent confusing < and << operators for the start of a meta string
-       * by converting them to a different operator.
-       * {@see Issue 4864}
-       * {@see https://github.com/isagalaev/highlight.js/issues/1341}
-       */
-      if (CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) {
-        line = line.replace(GLOBAL_LT_PATTERN, '|');
-      }
-
-      /**
-       * Rewrite CPP wchar_t characters literals to wchar_t string literals
-       * because HLJS only understands the string form.
-       * {@see Issue 5242}
-       * {#see https://github.com/isagalaev/highlight.js/issues/1412}
-       */
-      if (CPP_WCHAR_PATTERN.test(line)) {
-        line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
-      }
-
-      return line;
-    }
-
-    /**
-     * Prevent confusing the closing paren of a parameterized Java annotation
-     * being applied to a formal argument as the closing paren of the argument
-     * list. Rewrite the parens as spaces.
-     * {@see Issue 4776}
-     * {@see https://github.com/isagalaev/highlight.js/issues/1324}
-     */
-    if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) {
-      return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 ');
-    }
-
-    /**
-     * HLJS misunderstands backslash character literals in Go.
-     * {@see Issue 5007}
-     * {#see https://github.com/isagalaev/highlight.js/issues/1411}
-     */
-    if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) {
-      return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
-    }
-
-    return line;
-  }
-
-  /**
-   * Tells whether the state has exhausted its current section.
-   */
-  _isSectionDone(state: SyntaxLayerState) {
-    if (!this.diff) return true;
-    const section = this.diff.content[state.sectionIndex];
-    if (section.ab) {
-      return state.lineIndex >= section.ab.length;
-    } else {
-      return (
-        (!section.a || state.lineIndex >= section.a.length) &&
-        (!section.b || state.lineIndex >= section.b.length)
-      );
-    }
-  }
-
-  /**
-   * For a given state, notify layer listeners of any processed line ranges
-   * that have not yet been notified.
-   */
-  _notify(state: SyntaxLayerState) {
-    if (state.lineNums.left - state.lastNotify.left) {
-      this._notifyRange(state.lastNotify.left, state.lineNums.left, Side.LEFT);
-      state.lastNotify.left = state.lineNums.left;
-    }
-    if (state.lineNums.right - state.lastNotify.right) {
-      this._notifyRange(
-        state.lastNotify.right,
-        state.lineNums.right,
-        Side.RIGHT
-      );
-      state.lastNotify.right = state.lineNums.right;
-    }
-  }
-
-  _notifyRange(start: number, end: number, side: Side) {
-    for (const listener of this.listeners) {
-      listener(start, end, side);
-    }
-  }
-
-  _loadHLJS() {
-    return this.libLoader.getLibrary(HLJS_LIBRARY_CONFIG).then(hljs => {
-      this.hljs = hljs as HighlightJS;
-    });
-  }
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
deleted file mode 100644
index c907a80..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
+++ /dev/null
@@ -1,473 +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 {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import './gr-syntax-layer.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-import {GrSyntaxLayer} from './gr-syntax-layer.js';
-
-suite('gr-syntax-layer tests', () => {
-  let diff;
-  let element;
-  const lineNumberEl = document.createElement('td');
-
-  function getMockHLJS() {
-    const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
-        'ipsum</span>';
-    return {
-      configure() {},
-      highlight(lang, line, ignore, state) {
-        return {
-          value: line.replace(/ipsum/, html),
-          top: state === undefined ? 1 : state + 1,
-        };
-      },
-      // Return something truthy because this method is used to check if the
-      // language is supported.
-      getLanguage(s) {
-        return {};
-      },
-    };
-  }
-
-  setup(() => {
-    element = new GrSyntaxLayer();
-    diff = getMockDiffResponse();
-    element.diff = diff;
-  });
-
-  teardown(() => {
-    if (window.hljs) {
-      delete window.hljs;
-    }
-  });
-
-  test('annotate without range does nothing', () => {
-    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
-    const el = document.createElement('div');
-    el.textContent = 'Etiam dui, blandit wisi.';
-    const line = new GrDiffLine(GrDiffLineType.REMOVE);
-    line.beforeNumber = 12;
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isFalse(annotationSpy.called);
-  });
-
-  test('annotate with range applies it', () => {
-    const str = 'Etiam dui, blandit wisi.';
-    const start = 6;
-    const length = 3;
-    const className = 'foobar';
-
-    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
-    const el = document.createElement('div');
-    el.textContent = str;
-    const line = new GrDiffLine(GrDiffLineType.REMOVE);
-    line.beforeNumber = 12;
-    element.baseRanges[11] = [{
-      start,
-      length,
-      className,
-    }];
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isTrue(annotationSpy.called);
-    assert.equal(annotationSpy.lastCall.args[0], el);
-    assert.equal(annotationSpy.lastCall.args[1], start);
-    assert.equal(annotationSpy.lastCall.args[2], length);
-    assert.equal(annotationSpy.lastCall.args[3], className);
-    assert.isOk(el.querySelector('hl.' + className));
-  });
-
-  test('annotate with range but disabled does nothing', () => {
-    const str = 'Etiam dui, blandit wisi.';
-    const start = 6;
-    const length = 3;
-    const className = 'foobar';
-
-    const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
-    const el = document.createElement('div');
-    el.textContent = str;
-    const line = new GrDiffLine(GrDiffLineType.REMOVE);
-    line.beforeNumber = 12;
-    element.baseRanges[11] = [{
-      start,
-      length,
-      className,
-    }];
-    element.enabled = false;
-
-    element.annotate(el, lineNumberEl, line);
-
-    assert.isFalse(annotationSpy.called);
-  });
-
-  test('process on empty diff does nothing', async () => {
-    element.diff = {
-      meta_a: {content_type: 'application/json'},
-      meta_b: {content_type: 'application/json'},
-      content: [],
-    };
-    const processNextSpy = sinon.spy(element, '_processNextLine');
-
-    await element.process();
-
-    assert.isFalse(processNextSpy.called);
-    assert.equal(element.baseRanges.length, 0);
-    assert.equal(element.revisionRanges.length, 0);
-  });
-
-  test('process for unsupported languages does nothing', async () => {
-    element.diff = {
-      meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
-      meta_b: {content_type: 'application/not-a-real-language'},
-      content: [],
-    };
-    const processNextSpy = sinon.spy(element, '_processNextLine');
-
-    await element.process();
-
-    assert.isFalse(processNextSpy.called);
-    assert.equal(element.baseRanges.length, 0);
-    assert.equal(element.revisionRanges.length, 0);
-  });
-
-  test('process while disabled does nothing', async () => {
-    const processNextSpy = sinon.spy(element, '_processNextLine');
-    element.enabled = false;
-    const loadHLJSSpy = sinon.spy(element, '_loadHLJS');
-
-    await element.process();
-
-    assert.isFalse(processNextSpy.called);
-    assert.equal(element.baseRanges.length, 0);
-    assert.equal(element.revisionRanges.length, 0);
-    assert.isFalse(loadHLJSSpy.called);
-  });
-
-  test('process highlight ipsum', async () => {
-    element.diff.meta_a.content_type = 'application/json';
-    element.diff.meta_b.content_type = 'application/json';
-
-    const mockHLJS = getMockHLJS();
-    window.hljs = mockHLJS;
-    const highlightSpy = sinon.spy(mockHLJS, 'highlight');
-    const processNextSpy = sinon.spy(element, '_processNextLine');
-    await element.process();
-
-    const linesA = diff.meta_a.lines;
-    const linesB = diff.meta_b.lines;
-
-    assert.isTrue(processNextSpy.called);
-    assert.equal(element.baseRanges.length, linesA);
-    assert.equal(element.revisionRanges.length, linesB);
-
-    assert.equal(highlightSpy.callCount, linesA + linesB);
-
-    // The first line of both sides have a range.
-    let ranges = [element.baseRanges[0], element.revisionRanges[0]];
-    for (const range of ranges) {
-      assert.equal(range.length, 1);
-      assert.equal(range[0].className,
-          'gr-diff gr-syntax gr-syntax-string');
-      assert.equal(range[0].start, 'lorem '.length);
-      assert.equal(range[0].length, 'ipsum'.length);
-    }
-
-    // There are no ranges from ll.1-12 on the left and ll.1-11 on the
-    // right.
-    ranges = element.baseRanges.slice(1, 12)
-        .concat(element.revisionRanges.slice(1, 11));
-
-    for (const range of ranges) {
-      assert.equal(range.length, 0);
-    }
-
-    // There should be another pair of ranges on l.13 for the left and
-    // l.12 for the right.
-    ranges = [element.baseRanges[13], element.revisionRanges[12]];
-
-    for (const range of ranges) {
-      assert.equal(range.length, 1);
-      assert.equal(range[0].className,
-          'gr-diff gr-syntax gr-syntax-string');
-      assert.equal(range[0].start, 32);
-      assert.equal(range[0].length, 'ipsum'.length);
-    }
-
-    // The next group should have a similar instance on either side.
-
-    let range = element.baseRanges[15];
-    assert.equal(range.length, 1);
-    assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-    assert.equal(range[0].start, 34);
-    assert.equal(range[0].length, 'ipsum'.length);
-
-    range = element.revisionRanges[14];
-    assert.equal(range.length, 1);
-    assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-    assert.equal(range[0].start, 35);
-    assert.equal(range[0].length, 'ipsum'.length);
-  });
-
-  test('init calls cancel', () => {
-    const cancelSpy = sinon.spy(element, 'cancel');
-    element.init({content: []});
-    assert.isTrue(cancelSpy.called);
-  });
-
-  test('_rangesFromElement no ranges', () => {
-    const elem = document.createElement('span');
-    elem.textContent = 'Etiam dui, blandit wisi.';
-    const offset = 100;
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 0);
-  });
-
-  test('_rangesFromElement single range', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui, blandit';
-    const str2 = ' wisi.';
-    const className = 'gr-diff gr-syntax gr-syntax-string';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    const span = document.createElement('span');
-    span.textContent = str1;
-    span.className = className;
-    elem.appendChild(span);
-    elem.appendChild(document.createTextNode(str2));
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 1);
-    assert.equal(result[0].start, str0.length + offset);
-    assert.equal(result[0].length, str1.length);
-    assert.equal(result[0].className, className);
-  });
-
-  test('_rangesFromElement non-allowed', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui, blandit';
-    const str2 = ' wisi.';
-    const className = 'not-in-the-safelist';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    const span = document.createElement('span');
-    span.textContent = str1;
-    span.className = className;
-    elem.appendChild(span);
-    elem.appendChild(document.createTextNode(str2));
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 0);
-  });
-
-  test('_rangesFromElement milti range', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui,';
-    const str2 = ' blandit';
-    const str3 = ' wisi.';
-    const className = 'gr-diff gr-syntax gr-syntax-string';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    let span = document.createElement('span');
-    span.textContent = str1;
-    span.className = className;
-    elem.appendChild(span);
-    elem.appendChild(document.createTextNode(str2));
-    span = document.createElement('span');
-    span.textContent = str3;
-    span.className = className;
-    elem.appendChild(span);
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 2);
-
-    assert.equal(result[0].start, str0.length + offset);
-    assert.equal(result[0].length, str1.length);
-    assert.equal(result[0].className, className);
-
-    assert.equal(result[1].start,
-        str0.length + str1.length + str2.length + offset);
-    assert.equal(result[1].length, str3.length);
-    assert.equal(result[1].className, className);
-  });
-
-  test('_rangesFromElement nested range', () => {
-    const str0 = 'Etiam ';
-    const str1 = 'dui,';
-    const str2 = ' blandit';
-    const str3 = ' wisi.';
-    const className = 'gr-diff gr-syntax gr-syntax-string';
-    const offset = 100;
-
-    const elem = document.createElement('span');
-    elem.appendChild(document.createTextNode(str0));
-    const span1 = document.createElement('span');
-    span1.textContent = str1;
-    span1.className = className;
-    elem.appendChild(span1);
-    const span2 = document.createElement('span');
-    span2.textContent = str2;
-    span2.className = className;
-    span1.appendChild(span2);
-    elem.appendChild(document.createTextNode(str3));
-
-    const result = element._rangesFromElement(elem, offset);
-
-    assert.equal(result.length, 2);
-
-    assert.equal(result[0].start, str0.length + offset);
-    assert.equal(result[0].length, str1.length + str2.length);
-    assert.equal(result[0].className, className);
-
-    assert.equal(result[1].start, str0.length + str1.length + offset);
-    assert.equal(result[1].length, str2.length);
-    assert.equal(result[1].className, className);
-  });
-
-  test('_rangesFromString safelist allows recursion', () => {
-    const str = [
-      '<span class="non-whtelisted-class">',
-      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
-      '</span>'].join('');
-    const result = element._rangesFromString(str, new Map());
-    assert.notEqual(result.length, 0);
-  });
-
-  test('_rangesFromString cache same syntax markers', () => {
-    sinon.spy(element, '_rangesFromElement');
-    const str =
-      '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>';
-    const cacheMap = new Map();
-    element._rangesFromString(str, cacheMap);
-    element._rangesFromString(str, cacheMap);
-    assert.isTrue(element._rangesFromElement.calledOnce);
-  });
-
-  test('_isSectionDone', () => {
-    let state = {sectionIndex: 0, lineIndex: 0};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 0, lineIndex: 2};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 0, lineIndex: 4};
-    assert.isTrue(element._isSectionDone(state));
-
-    state = {sectionIndex: 1, lineIndex: 2};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 1, lineIndex: 3};
-    assert.isTrue(element._isSectionDone(state));
-
-    state = {sectionIndex: 3, lineIndex: 0};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 3, lineIndex: 3};
-    assert.isFalse(element._isSectionDone(state));
-
-    state = {sectionIndex: 3, lineIndex: 4};
-    assert.isTrue(element._isSectionDone(state));
-  });
-
-  test('workaround CPP LT directive', () => {
-    // Does nothing to regular line.
-    let line = 'int main(int argc, char** argv) { return 0; }';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Does nothing to include directive.
-    line = '#include <stdio>';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Converts left-shift operator in #define.
-    line = '#define GiB (1ull << 30)';
-    let expected = '#define GiB (1ull || 30)';
-    assert.equal(element._workaround('cpp', line), expected);
-
-    // Converts less-than operator in #if.
-    line = '  #if __GNUC__ < 4 || (__GNUC__ == 4 && __GNUC_MINOR__ < 1)';
-    expected = '  #if __GNUC__ | 4 || (__GNUC__ == 4 && __GNUC_MINOR__ | 1)';
-    assert.equal(element._workaround('cpp', line), expected);
-  });
-
-  test('workaround Java param-annotation', () => {
-    // Does nothing to regular line.
-    let line = 'public static void foo(int bar) { }';
-    assert.equal(element._workaround('java', line), line);
-
-    // Does nothing to regular annotation.
-    line = 'public static void foo(@Nullable int bar) { }';
-    assert.equal(element._workaround('java', line), line);
-
-    // Converts parameterized annotation.
-    line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
-    const expected = 'public static void foo(@SuppressWarnings "unused" ' +
-        ' int bar) { }';
-    assert.equal(element._workaround('java', line), expected);
-  });
-
-  test('workaround CPP whcar_t character literals', () => {
-    // Does nothing to regular line.
-    let line = 'int main(int argc, char** argv) { return 0; }';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Does nothing to wchar_t string.
-    line = 'wchar_t* sz = L"abc 123";';
-    assert.equal(element._workaround('cpp', line), line);
-
-    // Converts wchar_t character literal to string.
-    line = 'wchar_t myChar = L\'#\'';
-    let expected = 'wchar_t myChar = L"."';
-    assert.equal(element._workaround('cpp', line), expected);
-
-    // Converts wchar_t character literal with escape sequence to string.
-    line = 'wchar_t myChar = L\'\\"\'';
-    expected = 'wchar_t myChar = L"\\."';
-    assert.equal(element._workaround('cpp', line), expected);
-  });
-
-  test('workaround go backslash character literals', () => {
-    // Does nothing to regular line.
-    let line = 'func foo(in []byte) (lit []byte, n int, err error) {';
-    assert.equal(element._workaround('go', line), line);
-
-    // Does nothing to string with backslash literal
-    line = 'c := "\\\\"';
-    assert.equal(element._workaround('go', line), line);
-
-    // Converts backslash literal character to a string.
-    line = 'c := \'\\\\\'';
-    const expected = 'c := "\\\\"';
-    assert.equal(element._workaround('go', line), expected);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
deleted file mode 100644
index 7f495fa..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
+++ /dev/null
@@ -1,123 +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.
- */
-
-// 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');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-syntax-theme">
-  <template>
-    <style>
-      /**
-       * @overview Highlight.js emits the following classes that do not have
-       * styles here:
-       *    subst, symbol, class, function, doctag, meta-string, section, name,
-       *    builtin-name, bulletm, code, formula, quote, addition, deletion,
-       *    attribute
-       * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
-       */
-
-      .contentText {
-        color: var(--syntax-default-color);
-      }
-      .gr-syntax-attribute {
-        color: var(--syntax-attribute-color);
-      }
-      .gr-syntax-function {
-        color: var(--syntax-function-color);
-      }
-      .gr-syntax-meta {
-        color: var(--syntax-meta-color);
-      }
-      .gr-syntax-keyword,
-      .gr-syntax-name {
-        color: var(--syntax-keyword-color);
-      }
-      .gr-syntax-number {
-        color: var(--syntax-number-color);
-      }
-      .gr-syntax-selector-class {
-        color: var(--syntax-selector-class-color);
-      }
-      .gr-syntax-variable {
-        color: var(--syntax-variable-color);
-      }
-      .gr-syntax-template-variable {
-        color: var(--syntax-template-variable-color);
-      }
-      .gr-syntax-comment {
-        color: var(--syntax-comment-color);
-      }
-      .gr-syntax-string {
-        color: var(--syntax-string-color);
-      }
-      .gr-syntax-selector-id {
-        color: var(--syntax-selector-id-color);
-      }
-      .gr-syntax-built_in {
-        color: var(--syntax-built_in-color);
-      }
-      .gr-syntax-tag {
-        color: var(--syntax-tag-color);
-      }
-      .gr-syntax-link {
-        color: var(--syntax-link-color);
-      }
-      .gr-syntax-meta-keyword {
-        color: var(--syntax-meta-keyword-color);
-      }
-      .gr-syntax-type {
-        color: var(--syntax-type-color);
-      }
-      .gr-syntax-title {
-        color: var(--syntax-title-color);
-      }
-      .gr-syntax-attr {
-        color: var(--syntax-attr-color);
-      }
-      .gr-syntax-literal { /* XML/HTML Attribute */
-        color: var(--syntax-literal-color);
-      }
-      .gr-syntax-property {
-        color: var(--syntax-property-color);
-      }
-      .gr-syntax-selector-pseudo {
-        color: var(--syntax-selector-pseudo-color);
-      }
-      .gr-syntax-regexp {
-        color: var(--syntax-regexp-color);
-      }
-      .gr-syntax-selector-attr {
-        color: var(--syntax-selector-attr-color);
-      }
-      .gr-syntax-template-tag {
-        color: var(--syntax-template-tag-color);
-      }
-      .gr-syntax-params {
-        color: var(--syntax-params-color);
-      }
-      .gr-syntax-doctag {
-        font-weight: var(--syntax-doctag-weight);
-      }
-    </style>
-  </template>
-</dom-module>`;
-
-document.head.appendChild($_documentContainer.content);
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 173a27e..6dbdca6 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
@@ -1,48 +1,47 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-list-view/gr-list-view';
 import {getBaseUrl} from '../../../utils/url-util';
 import {DocResult} from '../../../types/common';
 import {fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
-import {ListViewParams} from '../../gr-app-types';
+import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
-import {LitElement, PropertyValues, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {LitElement, html} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {documentationViewModelToken} from '../../../models/views/documentation';
 
 @customElement('gr-documentation-search')
 export class GrDocumentationSearch extends LitElement {
-  /**
-   * URL params passed from the router.
-   */
-  @property({type: Object})
-  params?: ListViewParams;
+  // private but used in test
+  @state() documentationSearches?: DocResult[];
 
-  @property({type: Array})
-  _documentationSearches?: DocResult[];
+  // private but used in test
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _loading = true;
+  // private but used in test
+  @state() filter = '';
 
-  @property({type: String})
-  _filter?: string;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly getViewModel = resolve(this, documentationViewModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      x => {
+        this.filter = x?.filter ?? '';
+        if (x !== undefined) this.getDocumentationSearches();
+      }
+    );
+  }
 
   override connectedCallback() {
     super.connectedCallback();
@@ -55,10 +54,10 @@
 
   override render() {
     return html` <gr-list-view
-      .filter="${this._filter}"
-      .offset="${0}"
-      .loading="${this._loading}"
-      .path="/Documentation"
+      .filter=${this.filter}
+      .offset=${0}
+      .loading=${this.loading}
+      .path=${'/Documentation'}
     >
       <table id="list" class="genericList">
         <tbody>
@@ -67,69 +66,53 @@
             <th class="name topHeader"></th>
             <th class="name topHeader"></th>
           </tr>
-          <tr
-            id="loading"
-            class="loadingMsg ${this.computeLoadingClass(this._loading)}"
-          >
+          <tr id="loading" class="loadingMsg ${this.loading ? 'loading' : ''}">
             <td>Loading...</td>
           </tr>
         </tbody>
-        <tbody class="${this.computeLoadingClass(this._loading)}">
-          ${this._documentationSearches?.map(
-            search => html`
-              <tr class="table">
-                <td class="name">
-                  <a href="${this._computeSearchUrl(search.url)}"
-                    >${search.title}</a
-                  >
-                </td>
-                <td></td>
-                <td></td>
-              </tr>
-            `
+        <tbody class=${this.loading ? 'loading' : ''}>
+          ${this.documentationSearches?.map(search =>
+            this.renderDocumentationList(search)
           )}
         </tbody>
       </table>
     </gr-list-view>`;
   }
 
-  override updated(changedProperties: PropertyValues) {
-    if (changedProperties.has('params')) {
-      this._paramsChanged(this.params);
-    }
+  private renderDocumentationList(search: DocResult) {
+    return html`
+      <tr class="table">
+        <td class="name">
+          <a href=${this.computeSearchUrl(search.url)}>${search.title}</a>
+        </td>
+        <td></td>
+        <td></td>
+      </tr>
+    `;
   }
 
-  _paramsChanged(params?: ListViewParams) {
-    this._loading = true;
-    this._filter = params?.filter ?? '';
-
-    return this._getDocumentationSearches(this._filter);
-  }
-
-  _getDocumentationSearches(filter: string) {
-    this._documentationSearches = [];
+  getDocumentationSearches() {
+    const filter = this.filter;
+    this.loading = true;
+    this.documentationSearches = [];
     return this.restApiService
       .getDocumentationSearches(filter)
       .then(searches => {
         // Late response.
-        if (filter !== this._filter || !searches) {
+        if (filter !== this.filter || !searches) {
           return;
         }
-        this._documentationSearches = searches;
-        this._loading = false;
+        this.documentationSearches = searches;
+      })
+      .finally(() => {
+        this.loading = false;
       });
   }
 
-  _computeSearchUrl(url?: string) {
-    if (!url) {
-      return '';
-    }
+  private computeSearchUrl(url?: string) {
+    if (!url) return '';
     return `${getBaseUrl()}/${url}`;
   }
-
-  computeLoadingClass(loading: boolean) {
-    return loading ? 'loading' : '';
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index 47c83da..0092193 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -1,102 +1,348 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-documentation-search';
 import {GrDocumentationSearch} from './gr-documentation-search';
 import {page} from '../../../utils/page-wrapper-utils';
-import 'lodash/lodash';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {DocResult} from '../../../types/common';
-import {ListViewParams} from '../../gr-app-types';
+import {fixture, html, assert} from '@open-wc/testing';
 
-const basicFixture = fixtureFromElement('gr-documentation-search');
-
-let counter: number;
-const documentationGenerator = () => {
+function documentationGenerator(counter: number) {
   return {
-    title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
+    title: `Gerrit Code Review - REST API Developers Notes${counter}`,
     url: 'Documentation/dev-rest-api.html',
   };
-};
+}
+
+function createDocumentationList(n: number) {
+  const list = [];
+  for (let i = 0; i < n; ++i) {
+    list.push(documentationGenerator(i));
+  }
+  return list;
+}
 
 suite('gr-documentation-search tests', () => {
   let element: GrDocumentationSearch;
   let documentationSearches: DocResult[];
 
-  let value: ListViewParams;
-
   setup(async () => {
     sinon.stub(page, 'show');
-    element = basicFixture.instantiate();
-    counter = 0;
-    await flush();
+    element = await fixture(
+      html`<gr-documentation-search></gr-documentation-search>`
+    );
   });
 
   suite('list with searches for documentation', () => {
     setup(async () => {
-      documentationSearches = _.times(26, documentationGenerator);
+      documentationSearches = createDocumentationList(26);
       stubRestApi('getDocumentationSearches').returns(
         Promise.resolve(documentationSearches)
       );
-      await element._paramsChanged(value);
-      await flush();
+      await element.getDocumentationSearches();
+      await element.updateComplete;
     });
 
     test('test for test repo in the list', async () => {
       assert.equal(
-        element._documentationSearches![0].title,
+        element.documentationSearches![1].title,
         'Gerrit Code Review - REST API Developers Notes1'
       );
       assert.equal(
-        element._documentationSearches![0].url,
+        element.documentationSearches![1].url,
         'Documentation/dev-rest-api.html'
       );
     });
+
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-list-view>
+            <table class="genericList" id="list">
+              <tbody>
+                <tr class="headerRow">
+                  <th class="name topHeader">Name</th>
+                  <th class="name topHeader"></th>
+                  <th class="name topHeader"></th>
+                </tr>
+                <tr class="loadingMsg" id="loading">
+                  <td>Loading...</td>
+                </tr>
+              </tbody>
+              <tbody>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes0
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes1
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes2
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes3
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes4
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes5
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes6
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes7
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes8
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes9
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes10
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes11
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes12
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes13
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes14
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes15
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes16
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes17
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes18
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes19
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes20
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes21
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes22
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes23
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes24
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+                <tr class="table">
+                  <td class="name">
+                    <a href="/Documentation/dev-rest-api.html">
+                      Gerrit Code Review - REST API Developers Notes25
+                    </a>
+                  </td>
+                  <td></td>
+                  <td></td>
+                </tr>
+              </tbody>
+            </table>
+          </gr-list-view>
+        `
+      );
+    });
   });
 
   suite('filter', () => {
     setup(() => {
-      documentationSearches = _.times(25, documentationGenerator);
+      documentationSearches = createDocumentationList(25);
     });
 
-    test('_paramsChanged', async () => {
+    test('paramsChanged', async () => {
       const stub = stubRestApi('getDocumentationSearches').returns(
         Promise.resolve(documentationSearches)
       );
-      const value = {filter: 'test'};
-      await element._paramsChanged(value);
+      element.filter = 'test';
+      await element.getDocumentationSearches();
       assert.isTrue(stub.lastCall.calledWithExactly('test'));
     });
   });
 
   suite('loading', () => {
     test('correct contents are displayed', async () => {
-      assert.isTrue(element._loading);
-      assert.equal(element.computeLoadingClass(element._loading), 'loading');
+      assert.isTrue(element.loading);
       assert.equal(
         getComputedStyle(queryAndAssert(element, '#loading')).display,
         'block'
       );
 
-      element._loading = false;
+      element.loading = false;
 
-      await flush();
-      assert.equal(element.computeLoadingClass(element._loading), '');
+      await element.updateComplete;
       assert.equal(
         getComputedStyle(queryAndAssert(element, '#loading')).display,
         'none'
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 5312be2..7229c63 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
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -61,7 +50,7 @@
   override render() {
     return html` <textarea
       id="textarea"
-      .value="${this.fileContent}"
+      .value=${this.fileContent}
       @input=${this._handleTextareaInput}
     ></textarea>`;
   }
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts
index 6b7ce34..39b70ec 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts
@@ -1,34 +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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-default-editor';
 import {GrDefaultEditor} from './gr-default-editor';
-import {mockPromise, queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-default-editor');
+import {
+  mockPromise,
+  queryAndAssert,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-default-editor tests', () => {
   let element: GrDefaultEditor;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-default-editor></gr-default-editor>`);
     element.fileContent = '';
-    await flush();
+    await waitEventLoop();
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ' <textarea id="textarea"></textarea> '
+    );
   });
 
   test('fires content-change event', async () => {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.ts b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
index af3fbb2..f94b885 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 export interface GrEditAction {
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 1615a23..ec1e48e 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
@@ -1,91 +1,294 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-dropdown/gr-dropdown';
-import '../../shared/gr-overlay/gr-overlay';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-edit-controls_html';
 import {GrEditAction, GrEditConstants} from '../gr-edit-constants';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
-import {ChangeInfo, PatchSetNum} from '../../../types/common';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {ChangeInfo, RevisionPatchSetNum} from '../../../types/common';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {
   AutocompleteQuery,
   AutocompleteSuggestion,
+  GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {appContext} from '../../../services/app-context';
-import {IronInputElement} from '@polymer/iron-input';
+import {getAppContext} from '../../../services/app-context';
 import {fireAlert, fireReload} from '../../../utils/event-util';
-
-export interface GrEditControls {
-  $: {
-    newPathIronInput: IronInputElement;
-    overlay: GrOverlay;
-    openDialog: GrDialog;
-    deleteDialog: GrDialog;
-    renameDialog: GrDialog;
-    restoreDialog: GrDialog;
-  };
-}
+import {
+  assertIsDefined,
+  query as queryUtil,
+  queryAll,
+} from '../../../utils/common-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
+import {createEditUrl} from '../../../models/views/change';
+import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {whenVisible} from '../../../utils/dom-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 @customElement('gr-edit-controls')
-export class GrEditControls extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrEditControls extends LitElement {
+  // private but used in test
+  @query('#newPathIronInput') newPathIronInput?: IronInputElement;
+
+  @query('#modal') modal?: HTMLDialogElement;
+
+  // private but used in test
+  @query('#openDialog') openDialog?: GrDialog;
+
+  // private but used in test
+  @query('#deleteDialog') deleteDialog?: GrDialog;
+
+  // private but used in test
+  @query('#renameDialog') renameDialog?: GrDialog;
+
+  // private but used in test
+  @query('#restoreDialog') restoreDialog?: GrDialog;
 
   @property({type: Object})
-  change!: ChangeInfo;
+  change?: ChangeInfo;
 
   @property({type: String})
-  patchNum!: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
 
   @property({type: Array})
   hiddenActions: string[] = [GrEditConstants.Actions.RESTORE.id];
 
-  @property({type: Array})
-  _actions: GrEditAction[] = Object.values(GrEditConstants.Actions);
+  // private but used in test
+  @state() actions: GrEditAction[] = Object.values(GrEditConstants.Actions);
 
-  @property({type: String})
-  _path = '';
+  // private but used in test
+  @state() path = '';
 
-  @property({type: String})
-  _newPath = '';
+  // private but used in test
+  @state() newPath = '';
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  private readonly query: AutocompleteQuery = (input: string) =>
+    this.queryFiles(input);
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  constructor() {
-    super();
-    this._query = (input: string) => this._queryFiles(input);
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      modalStyles,
+      css`
+        :host {
+          align-items: center;
+          display: flex;
+          justify-content: flex-end;
+        }
+        .invisible {
+          display: none;
+        }
+        gr-button {
+          margin-left: var(--spacing-l);
+          text-decoration: none;
+        }
+        gr-dialog {
+          width: 50em;
+        }
+        gr-dialog .main {
+          width: 100%;
+        }
+        gr-dialog .main > iron-input {
+          width: 100%;
+        }
+        input {
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          margin: var(--spacing-m) 0;
+          padding: var(--spacing-s);
+          width: 100%;
+          box-sizing: content-box;
+        }
+        #fileUploadBrowse {
+          margin-left: 0;
+        }
+        #dragDropArea {
+          border: 2px dashed var(--border-color);
+          border-radius: var(--border-radius);
+          margin-top: var(--spacing-l);
+          padding: var(--spacing-xxl) var(--spacing-xxl);
+          text-align: center;
+        }
+        #dragDropArea > p {
+          font-weight: var(--font-weight-bold);
+          padding: var(--spacing-s);
+        }
+        @media screen and (max-width: 50em) {
+          gr-dialog {
+            width: 100vw;
+          }
+        }
+      `,
+    ];
   }
 
-  _handleTap(e: Event) {
+  override render() {
+    return html`
+      ${this.actions.map(action => this.renderAction(action))}
+      <dialog id="modal" tabindex="-1">
+        ${this.renderOpenDialog()} ${this.renderDeleteDialog()}
+        ${this.renderRenameDialog()} ${this.renderRestoreDialog()}
+      </dialog>
+    `;
+  }
+
+  private renderAction(action: GrEditAction) {
+    return html`
+      <gr-button
+        id=${action.id}
+        class=${this.computeIsInvisible(action.id)}
+        link=""
+        @click=${this.handleTap}
+        >${action.label}</gr-button
+      >
+    `;
+  }
+
+  private renderOpenDialog() {
+    return html`
+      <gr-dialog
+        id="openDialog"
+        class="invisible dialog"
+        ?disabled=${!this.isValidPath(this.path)}
+        confirm-label="Confirm"
+        confirm-on-enter=""
+        @confirm=${this.handleOpenConfirm}
+        @cancel=${this.handleDialogCancel}
+      >
+        <div class="header" slot="header">
+          Add a new file or open an existing file
+        </div>
+        <div class="main" slot="main">
+          <gr-autocomplete
+            placeholder="Enter an existing or new full file path."
+            .query=${this.query}
+            .text=${this.path}
+            @text-changed=${this.handleTextChanged}
+          ></gr-autocomplete>
+          <div
+            id="dragDropArea"
+            contenteditable="true"
+            @drop=${this.handleDragAndDropUpload}
+            @keypress=${this.handleKeyPress}
+          >
+            <p>Drag and drop a file here</p>
+            <p>or</p>
+            <p>
+              <iron-input>
+                <input
+                  id="fileUploadInput"
+                  type="file"
+                  @change=${this.handleFileUploadChanged}
+                  multiple
+                  hidden
+                />
+              </iron-input>
+              <label for="fileUploadInput">
+                <gr-button id="fileUploadBrowse">Browse</gr-button>
+              </label>
+            </p>
+          </div>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private renderDeleteDialog() {
+    return html`
+      <gr-dialog
+        id="deleteDialog"
+        class="invisible dialog"
+        ?disabled=${!this.isValidPath(this.path)}
+        confirm-label="Delete"
+        confirm-on-enter=""
+        @confirm=${this.handleDeleteConfirm}
+        @cancel=${this.handleDialogCancel}
+      >
+        <div class="header" slot="header">Delete a file from the repo</div>
+        <div class="main" slot="main">
+          <gr-autocomplete
+            placeholder="Enter an existing full file path."
+            .query=${this.query}
+            .text=${this.path}
+            @text-changed=${this.handleTextChanged}
+          ></gr-autocomplete>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private renderRenameDialog() {
+    return html`
+      <gr-dialog
+        id="renameDialog"
+        class="invisible dialog"
+        ?disabled=${!this.isValidPath(this.path) ||
+        !this.isValidPath(this.newPath)}
+        confirm-label="Rename"
+        confirm-on-enter=""
+        @confirm=${this.handleRenameConfirm}
+        @cancel=${this.handleDialogCancel}
+      >
+        <div class="header" slot="header">Rename a file in the repo</div>
+        <div class="main" slot="main">
+          <gr-autocomplete
+            placeholder="Enter an existing full file path."
+            .query=${this.query}
+            .text=${this.path}
+            @text-changed=${this.handleTextChanged}
+          ></gr-autocomplete>
+          <iron-input
+            id="newPathIronInput"
+            .bindValue=${this.newPath}
+            @bind-value-changed=${this.handleBindValueChangedNewPath}
+          >
+            <input id="newPathInput" placeholder="Enter the new path." />
+          </iron-input>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private renderRestoreDialog() {
+    return html`
+      <gr-dialog
+        id="restoreDialog"
+        class="invisible dialog"
+        confirm-label="Restore"
+        confirm-on-enter=""
+        @confirm=${this.handleRestoreConfirm}
+        @cancel=${this.handleDialogCancel}
+      >
+        <div class="header" slot="header">Restore this file?</div>
+        <div class="main" slot="main">
+          <iron-input
+            .bindValue=${this.path}
+            @bind-value-changed=${this.handleBindValueChangedPath}
+          >
+            <input ?disabled=${''} />
+          </iron-input>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private readonly handleTap = (e: Event) => {
     e.preventDefault();
-    const target = (dom(e) as EventApi).localTarget as Element;
+    const target = e.target as Element;
     const action = target.id;
     switch (action) {
       case GrEditConstants.Actions.OPEN.id:
@@ -101,91 +304,96 @@
         this.openRestoreDialog();
         return;
     }
-  }
+  };
 
   openOpenDialog(path?: string) {
     if (path) {
-      this._path = path;
+      this.path = path;
     }
-    return this._showDialog(this.$.openDialog);
+    assertIsDefined(this.openDialog, 'openDialog');
+    this.showDialog(this.openDialog);
   }
 
   openDeleteDialog(path?: string) {
     if (path) {
-      this._path = path;
+      this.path = path;
     }
-    return this._showDialog(this.$.deleteDialog);
+    assertIsDefined(this.deleteDialog, 'deleteDialog');
+    this.showDialog(this.deleteDialog);
   }
 
   openRenameDialog(path?: string) {
     if (path) {
-      this._path = path;
+      this.path = path;
     }
-    return this._showDialog(this.$.renameDialog);
+    assertIsDefined(this.renameDialog, 'renameDialog');
+    this.showDialog(this.renameDialog);
   }
 
   openRestoreDialog(path?: string) {
+    assertIsDefined(this.restoreDialog, 'restoreDialog');
     if (path) {
-      this._path = path;
+      this.path = path;
     }
-    return this._showDialog(this.$.restoreDialog);
+    this.showDialog(this.restoreDialog);
   }
 
   /**
    * Given a path string, checks that it is a valid file path.
+   *
+   * private but used in test
    */
-  _isValidPath(path: string) {
+  isValidPath(path: string) {
     // Double negation needed for strict boolean return type.
     return !!path.length && !path.endsWith('/');
   }
 
-  _computeRenameDisabled(path: string, newPath: string) {
-    return this._isValidPath(path) && this._isValidPath(newPath);
-  }
-
   /**
    * Given a dom event, gets the dialog that lies along this event path.
+   *
+   * private but used in test
    */
-  _getDialogFromEvent(e: Event): GrDialog | undefined {
-    return (dom(e) as EventApi).path.find(element => {
+  getDialogFromEvent(e: Event): GrDialog | undefined {
+    return e.composedPath().find(element => {
       if (!(element instanceof Element)) return false;
       if (!element.classList) return false;
       return element.classList.contains('dialog');
     }) as GrDialog | undefined;
   }
 
-  _showDialog(dialog: GrDialog) {
+  // private but used in test
+  showDialog(dialog: GrDialog) {
+    assertIsDefined(this.modal, 'modal');
+
     // Some dialogs may not fire their on-close event when closed in certain
     // ways (e.g. by clicking outside the dialog body). This call prevents
-    // multiple dialogs from being shown in the same overlay.
-    this._hideAllDialogs();
+    // multiple dialogs from being shown in the same modal.
+    this.hideAllDialogs();
 
-    return this.$.overlay.open().then(() => {
+    this.modal.showModal();
+    whenVisible(this.modal, () => {
       dialog.classList.toggle('invisible', false);
-      const autocomplete = dialog.querySelector('gr-autocomplete');
+      const autocomplete = queryUtil<GrAutocomplete>(dialog, 'gr-autocomplete');
       if (autocomplete) {
         autocomplete.focus();
       }
-      setTimeout(() => {
-        this.$.overlay.center();
-      }, 1);
     });
   }
 
-  _hideAllDialogs() {
-    const dialogs = this.root!.querySelectorAll(
-      '.dialog'
-    ) as NodeListOf<GrDialog>;
+  // private but used in test
+  hideAllDialogs() {
+    const dialogs = queryAll<GrDialog>(this, '.dialog');
     for (const dialog of dialogs) {
       // We set the second param to false, because this function
-      // is called by _showDialog which when you open either restore,
+      // is called by showDialog which when you open either restore,
       // delete or rename dialogs, it reseted the automatically
       // set input.
-      this._closeDialog(dialog, false);
+      this.closeDialog(dialog, false);
     }
   }
 
-  _closeDialog(dialog?: GrDialog, clearInputs = true) {
+  // private but used in test
+  closeDialog(dialog?: GrDialog, clearInputs = true) {
     if (!dialog) return;
 
     if (clearInputs) {
@@ -202,32 +410,38 @@
     }
 
     dialog.classList.toggle('invisible', true);
-    return this.$.overlay.close();
+
+    assertIsDefined(this.modal, 'modal');
+    this.modal.close();
   }
 
-  _handleDialogCancel(e: Event) {
-    this._closeDialog(this._getDialogFromEvent(e));
-  }
+  private readonly handleDialogCancel = (e: Event) => {
+    this.closeDialog(this.getDialogFromEvent(e));
+  };
 
-  _handleOpenConfirm(e: Event) {
-    if (!this.change || !this._path) {
+  private readonly handleOpenConfirm = (e: Event) => {
+    if (!this.change || !this.path) {
       fireAlert(this, 'You must enter a path.');
-      this._closeDialog(this.$.openDialog);
+      this.closeDialog(this.openDialog);
       return;
     }
-    const url = GerritNav.getEditUrlForDiff(
-      this.change,
-      this._path,
-      this.patchNum
-    );
-    GerritNav.navigateToRelativeUrl(url);
-    this._closeDialog(this._getDialogFromEvent(e));
-  }
+    assertIsDefined(this.patchNum, 'patchset number');
+    const url = createEditUrl({
+      changeNum: this.change._number,
+      repo: this.change.project,
+      patchNum: this.patchNum,
+      editView: {path: this.path},
+    });
 
-  _handleUploadConfirm(path: string, fileData: string) {
+    this.getNavigation().setUrl(url);
+    this.closeDialog(this.getDialogFromEvent(e));
+  };
+
+  // private but used in test
+  handleUploadConfirm(path: string, fileData: string) {
     if (!this.change || !path || !fileData) {
       fireAlert(this, 'You must enter a path and data.');
-      this._closeDialog(this.$.openDialog);
+      this.closeDialog(this.openDialog);
       return Promise.resolve();
     }
     return this.restApiService
@@ -236,70 +450,77 @@
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(this.$.openDialog);
+        this.closeDialog(this.openDialog);
         fireReload(this, true);
       });
   }
 
-  _handleDeleteConfirm(e: Event) {
+  private readonly handleDeleteConfirm = (e: Event) => {
     // Get the dialog before the api call as the event will change during bubbling
     // which will make Polymer.dom(e).path an empty array in polymer 2
-    const dialog = this._getDialogFromEvent(e);
-    if (!this.change || !this._path) {
+    const dialog = this.getDialogFromEvent(e);
+    if (!this.change || !this.path) {
       fireAlert(this, 'You must enter a path.');
-      this._closeDialog(dialog);
+      this.closeDialog(dialog);
       return;
     }
     this.restApiService
-      .deleteFileInChangeEdit(this.change._number, this._path)
+      .deleteFileInChangeEdit(this.change._number, this.path)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog);
+        this.closeDialog(dialog);
         fireReload(this);
       });
-  }
+  };
 
-  _handleRestoreConfirm(e: Event) {
-    const dialog = this._getDialogFromEvent(e);
-    if (!this.change || !this._path) {
+  private readonly handleRestoreConfirm = (e: Event) => {
+    const dialog = this.getDialogFromEvent(e);
+    if (!this.change || !this.path) {
       fireAlert(this, 'You must enter a path.');
-      this._closeDialog(dialog);
+      this.closeDialog(dialog);
       return;
     }
     this.restApiService
-      .restoreFileInChangeEdit(this.change._number, this._path)
+      .restoreFileInChangeEdit(this.change._number, this.path)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog);
+        this.closeDialog(dialog);
         fireReload(this);
       });
-  }
+  };
 
-  _handleRenameConfirm(e: Event) {
-    const dialog = this._getDialogFromEvent(e);
-    if (!this.change || !this._path || !this._newPath) {
+  private readonly handleRenameConfirm = (e: Event) => {
+    const dialog = this.getDialogFromEvent(e);
+    if (!this.change || !this.path || !this.newPath) {
       fireAlert(this, 'You must enter a old path and a new path.');
-      this._closeDialog(dialog);
+      this.closeDialog(dialog);
       return;
     }
-    return this.restApiService
-      .renameFileInChangeEdit(this.change._number, this._path, this._newPath)
+    this.restApiService
+      .renameFileInChangeEdit(this.change._number, this.path, this.newPath)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog);
+        this.closeDialog(dialog);
         fireReload(this, true);
       });
-  }
+  };
 
-  _queryFiles(input: string): Promise<AutocompleteSuggestion[]> {
+  private queryFiles(input: string): Promise<AutocompleteSuggestion[]> {
+    assertIsDefined(this.change, 'this.change');
+    assertIsDefined(this.patchNum, 'this.patchNum');
     return this.restApiService
-      .queryChangeFiles(this.change._number, this.patchNum, input)
+      .queryChangeFiles(
+        this.change._number,
+        this.patchNum,
+        input,
+        throwingErrorCallback
+      )
       .then(res => {
         if (!res)
           throw new Error('Failed to retrieve files. Response not set.');
@@ -309,31 +530,31 @@
       });
   }
 
-  _computeIsInvisible(id: string, hiddenActions: string[]) {
-    return hiddenActions.includes(id) ? 'invisible' : '';
+  private computeIsInvisible(id: string) {
+    return this.hiddenActions.includes(id) ? 'invisible' : '';
   }
 
-  _handleDragAndDropUpload(event: DragEvent) {
-    event.preventDefault();
-    event.stopPropagation();
+  private readonly handleDragAndDropUpload = (e: DragEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
 
-    if (!event.dataTransfer) return;
-    this._fileUpload(event.dataTransfer.files);
-  }
+    if (!e.dataTransfer) return;
+    this.fileUpload(e.dataTransfer.files);
+  };
 
-  _handleFileUploadChanged(event: InputEvent) {
-    if (!event.target) return;
-    if (!(event.target instanceof HTMLInputElement)) return;
-    const input = event.target as HTMLInputElement;
+  private readonly handleFileUploadChanged = (e: InputEvent) => {
+    if (!e.target) return;
+    if (!(e.target instanceof HTMLInputElement)) return;
+    const input = e.target;
     if (!input.files) return;
-    this._fileUpload(input.files);
-  }
+    this.fileUpload(input.files);
+  };
 
-  _fileUpload(files: FileList) {
+  private fileUpload(files: FileList) {
     for (const file of files) {
       if (!file) continue;
 
-      let path = this._path;
+      let path = this.path;
       if (!path) {
         path = file.name;
       }
@@ -345,16 +566,30 @@
         if (!fileLoadEvent) return;
         const fileData = fileLoadEvent.target!.result;
         if (typeof fileData !== 'string') return;
-        this._handleUploadConfirm(path, fileData);
+        this.handleUploadConfirm(path, fileData);
       };
       fr.readAsDataURL(file);
     }
   }
 
-  _handleKeyPress(event: KeyboardEvent) {
-    event.preventDefault();
-    event.stopImmediatePropagation();
-  }
+  private readonly handleKeyPress = (e: KeyboardEvent) => {
+    e.preventDefault();
+    e.stopImmediatePropagation();
+  };
+
+  private readonly handleTextChanged = (e: BindValueChangeEvent) => {
+    this.path = e.detail.value ?? '';
+  };
+
+  private readonly handleBindValueChangedNewPath = (
+    e: BindValueChangeEvent
+  ) => {
+    this.newPath = e.detail.value ?? '';
+  };
+
+  private readonly handleBindValueChangedPath = (e: BindValueChangeEvent) => {
+    this.path = e.detail.value ?? '';
+  };
 }
 
 declare global {
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
deleted file mode 100644
index 2e25659..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_html.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: flex;
-      justify-content: flex-end;
-    }
-    .invisible {
-      display: none;
-    }
-    gr-button {
-      margin-left: var(--spacing-l);
-      text-decoration: none;
-    }
-    gr-dialog {
-      width: 50em;
-    }
-    gr-dialog .main {
-      width: 100%;
-    }
-    gr-dialog .main > iron-input {
-      width: 100%;
-    }
-    input {
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin: var(--spacing-m) 0;
-      padding: var(--spacing-s);
-      width: 100%;
-      box-sizing: content-box;
-    }
-    #fileUploadBrowse {
-      margin-left: 0;
-    }
-    #dragDropArea {
-      border: 2px dashed var(--border-color);
-      border-radius: var(--border-radius);
-      margin-top: var(--spacing-l);
-      padding: var(--spacing-xxl) var(--spacing-xxl);
-      text-align: center;
-    }
-    #dragDropArea > p {
-      font-weight: var(--font-weight-bold);
-      padding: var(--spacing-s);
-    }
-    @media screen and (max-width: 50em) {
-      gr-dialog {
-        width: 100vw;
-      }
-    }
-  </style>
-  <template is="dom-repeat" items="[[_actions]]" as="action">
-    <gr-button
-      id$="[[action.id]]"
-      class$="[[_computeIsInvisible(action.id, hiddenActions)]]"
-      link=""
-      on-click="_handleTap"
-      >[[action.label]]</gr-button
-    >
-  </template>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-dialog
-      id="openDialog"
-      class="invisible dialog"
-      disabled$="[[!_isValidPath(_path)]]"
-      confirm-label="Confirm"
-      confirm-on-enter=""
-      on-confirm="_handleOpenConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">
-        Add a new file or open an existing file
-      </div>
-      <div class="main" slot="main">
-        <gr-autocomplete
-          placeholder="Enter an existing or new full file path."
-          query="[[_query]]"
-          text="{{_path}}"
-        ></gr-autocomplete>
-        <div
-          id="dragDropArea"
-          contenteditable="true"
-          on-drop="_handleDragAndDropUpload"
-          on-keypress="_handleKeyPress"
-        >
-          <p>Drag and drop a file here</p>
-          <p>or</p>
-          <p>
-            <iron-input>
-              <input
-                id="fileUploadInput"
-                type="file"
-                on-change="_handleFileUploadChanged"
-                multiple
-                hidden
-              />
-            </iron-input>
-            <label for="fileUploadInput">
-              <gr-button id="fileUploadBrowse">Browse</gr-button>
-            </label>
-          </p>
-        </div>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="deleteDialog"
-      class="invisible dialog"
-      disabled$="[[!_isValidPath(_path)]]"
-      confirm-label="Delete"
-      confirm-on-enter=""
-      on-confirm="_handleDeleteConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">Delete a file from the repo</div>
-      <div class="main" slot="main">
-        <gr-autocomplete
-          placeholder="Enter an existing full file path."
-          query="[[_query]]"
-          text="{{_path}}"
-        ></gr-autocomplete>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="renameDialog"
-      class="invisible dialog"
-      disabled$="[[!_computeRenameDisabled(_path, _newPath)]]"
-      confirm-label="Rename"
-      confirm-on-enter=""
-      on-confirm="_handleRenameConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">Rename a file in the repo</div>
-      <div class="main" slot="main">
-        <gr-autocomplete
-          placeholder="Enter an existing full file path."
-          query="[[_query]]"
-          text="{{_path}}"
-        ></gr-autocomplete>
-        <iron-input
-          id="newPathIronInput"
-          bind-value="{{_newPath}}"
-          placeholder="Enter the new path."
-        >
-          <input id="newPathInput" placeholder="Enter the new path." />
-        </iron-input>
-      </div>
-    </gr-dialog>
-    <gr-dialog
-      id="restoreDialog"
-      class="invisible dialog"
-      confirm-label="Restore"
-      confirm-on-enter=""
-      on-confirm="_handleRestoreConfirm"
-      on-cancel="_handleDialogCancel"
-    >
-      <div class="header" slot="header">Restore this file?</div>
-      <div class="main" slot="main">
-        <iron-input disabled="" bind-value="{{_path}}">
-          <input disabled="" />
-        </iron-input>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index 6198f17..31283ad 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -1,33 +1,33 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-edit-controls';
 import {GrEditControls} from './gr-edit-controls';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {stubRestApi} from '../../../test/test-utils';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+  queryAll,
+  stubRestApi,
+  waitUntil,
+  waitUntilVisible,
+} from '../../../test/test-utils';
 import {createChange, createRevision} from '../../../test/test-data-generators';
 import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {CommitId, NumericChangeId, PatchSetNum} from '../../../types/common';
+import {
+  CommitId,
+  NumericChangeId,
+  PatchSetNumber,
+  RevisionPatchSetNum,
+} from '../../../types/common';
 import {RepoName} from '../../../api/rest-api';
 import {queryAndAssert} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-edit-controls');
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-edit-controls tests', () => {
   let element: GrEditControls;
@@ -37,87 +37,219 @@
   let hideDialogStub: sinon.SinonStub;
   let queryStub: sinon.SinonStub;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture<GrEditControls>(html`
+      <gr-edit-controls></gr-edit-controls>
+    `);
     element.change = createChange();
-    showDialogSpy = sinon.spy(element, '_showDialog');
-    closeDialogSpy = sinon.spy(element, '_closeDialog');
-    hideDialogStub = sinon.stub(element, '_hideAllDialogs');
+    element.patchNum = 1 as RevisionPatchSetNum;
+    showDialogSpy = sinon.spy(element, 'showDialog');
+    closeDialogSpy = sinon.spy(element, 'closeDialog');
+    hideDialogStub = sinon.stub(element, 'hideAllDialogs');
     queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
-    flush();
+    await element.updateComplete;
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-button
+          aria-disabled="false"
+          id="open"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Add/Open/Upload
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          id="delete"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Delete
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          id="rename"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Rename
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          class="invisible"
+          id="restore"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Restore
+        </gr-button>
+        <dialog id="modal" tabindex="-1">
+          <gr-dialog
+            class="dialog invisible"
+            confirm-label="Confirm"
+            confirm-on-enter=""
+            disabled=""
+            id="openDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">
+              Add a new file or open an existing file
+            </div>
+            <div class="main" slot="main">
+              <gr-autocomplete
+                placeholder="Enter an existing or new full file path."
+              >
+              </gr-autocomplete>
+              <div contenteditable="true" id="dragDropArea">
+                <p>Drag and drop a file here</p>
+                <p>or</p>
+                <p>
+                  <iron-input>
+                    <input
+                      hidden=""
+                      id="fileUploadInput"
+                      multiple=""
+                      type="file"
+                    />
+                  </iron-input>
+                  <label for="fileUploadInput">
+                    <gr-button
+                      aria-disabled="false"
+                      id="fileUploadBrowse"
+                      role="button"
+                      tabindex="0"
+                    >
+                      Browse
+                    </gr-button>
+                  </label>
+                </p>
+              </div>
+            </div>
+          </gr-dialog>
+          <gr-dialog
+            class="dialog invisible"
+            confirm-label="Delete"
+            confirm-on-enter=""
+            disabled=""
+            id="deleteDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Delete a file from the repo</div>
+            <div class="main" slot="main">
+              <gr-autocomplete placeholder="Enter an existing full file path.">
+              </gr-autocomplete>
+            </div>
+          </gr-dialog>
+          <gr-dialog
+            class="dialog invisible"
+            confirm-label="Rename"
+            confirm-on-enter=""
+            disabled=""
+            id="renameDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Rename a file in the repo</div>
+            <div class="main" slot="main">
+              <gr-autocomplete placeholder="Enter an existing full file path.">
+              </gr-autocomplete>
+              <iron-input id="newPathIronInput">
+                <input id="newPathInput" placeholder="Enter the new path." />
+              </iron-input>
+            </div>
+          </gr-dialog>
+          <gr-dialog
+            class="dialog invisible"
+            confirm-label="Restore"
+            confirm-on-enter=""
+            id="restoreDialog"
+            role="dialog"
+          >
+            <div class="header" slot="header">Restore this file?</div>
+            <div class="main" slot="main">
+              <iron-input>
+                <input />
+              </iron-input>
+            </div>
+          </gr-dialog>
+        </dialog>
+      `
+    );
   });
 
   test('all actions exist', () => {
     // We take 1 away from the total found, due to an extra button being
     // added for the file uploads (browse).
     assert.equal(
-      element.root!.querySelectorAll('gr-button').length - 1,
-      element._actions.length
+      queryAll<GrButton>(element, 'gr-button').length - 1,
+      element.actions.length
     );
   });
 
   suite('edit button CUJ', () => {
-    let editDiffStub: sinon.SinonStub;
-    let navStub: sinon.SinonStub;
+    let setUrlStub: sinon.SinonStub;
     let openAutoComplete: GrAutocomplete;
 
     setup(() => {
-      editDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
-      navStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      openAutoComplete =
-        element.$.openDialog!.querySelector('gr-autocomplete')!;
+      setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+      openAutoComplete = queryAndAssert<GrAutocomplete>(
+        element.openDialog,
+        'gr-autocomplete'
+      );
     });
 
-    test('_isValidPath', () => {
-      assert.isFalse(element._isValidPath(''));
-      assert.isFalse(element._isValidPath('test/'));
-      assert.isFalse(element._isValidPath('/'));
-      assert.isTrue(element._isValidPath('test/path.cpp'));
-      assert.isTrue(element._isValidPath('test.js'));
+    test('isValidPath', () => {
+      assert.isFalse(element.isValidPath(''));
+      assert.isFalse(element.isValidPath('test/'));
+      assert.isFalse(element.isValidPath('/'));
+      assert.isTrue(element.isValidPath('test/path.cpp'));
+      assert.isTrue(element.isValidPath('test.js'));
     });
 
     test('open', async () => {
       assert.isFalse(hideDialogStub.called);
-      MockInteractions.tap(queryAndAssert(element, '#open'));
-      element.patchNum = 1 as PatchSetNum;
-      await showDialogSpy.lastCall.returnValue;
+      queryAndAssert<GrButton>(element, '#open').click();
+      element.patchNum = 1 as RevisionPatchSetNum;
+      await waitUntilVisible(element.modal!);
       assert.isTrue(hideDialogStub.called);
-      assert.isTrue(element.$.openDialog.disabled);
+      assert.isTrue(element.openDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup _focused manually - in headless mode Chrome sometimes don't
-      // setup focus. flush and/or flushAsynchronousOperations don't help
-      openAutoComplete._focused = true;
+      // Setup focused manually - in headless mode Chrome sometimes doesn't
+      // setup focus. waitEventLoop() doesn't help.
+      openAutoComplete.focused = true;
       openAutoComplete.noDebounce = true;
       openAutoComplete.text = 'src/test.cpp';
-      await flush();
+      await element.updateComplete;
       assert.isTrue(queryStub.called);
-      assert.isFalse(element.$.openDialog.disabled);
-      MockInteractions.tap(
-        queryAndAssert(element.$.openDialog, 'gr-button[primary]')
-      );
-      assert.isTrue(editDiffStub.called);
-      assert.isTrue(navStub.called);
-      assert.deepEqual(editDiffStub.lastCall.args, [
-        element.change,
-        'src/test.cpp',
-        element.patchNum,
-      ]);
+      await waitUntil(() => !element.openDialog!.disabled);
+      queryAndAssert<GrButton>(
+        element.openDialog,
+        'gr-button[primary]'
+      ).click();
+
+      assert.isTrue(setUrlStub.called);
       assert.isTrue(closeDialogSpy.called);
     });
 
-    test('cancel', () => {
-      MockInteractions.tap(queryAndAssert(element, '#open'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.openDialog.disabled);
-        openAutoComplete.noDebounce = true;
-        openAutoComplete.text = 'src/test.cpp';
-        assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(queryAndAssert(element.$.openDialog, 'gr-button'));
-        assert.isFalse(editDiffStub.called);
-        assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, '');
-      });
+    test('cancel', async () => {
+      queryAndAssert<GrButton>(element, '#open').click();
+      await waitUntilVisible(element.modal!);
+      assert.isTrue(element.openDialog!.disabled);
+      openAutoComplete.noDebounce = true;
+      openAutoComplete.text = 'src/test.cpp';
+      await element.updateComplete;
+      await waitUntil(() => !element.openDialog!.disabled);
+      queryAndAssert<GrButton>(element.openDialog, 'gr-button').click();
+      assert.isFalse(setUrlStub.called);
+      await waitUntil(() => closeDialogSpy.called);
+      assert.equal(element.path, '');
     });
   });
 
@@ -129,54 +261,59 @@
     setup(() => {
       eventStub = sinon.stub(element, 'dispatchEvent');
       deleteStub = stubRestApi('deleteFileInChangeEdit');
-      deleteAutocomplete =
-        element.$.deleteDialog!.querySelector('gr-autocomplete')!;
+      const deleteDialog = element.deleteDialog;
+      deleteAutocomplete = queryAndAssert<GrAutocomplete>(
+        deleteDialog,
+        'gr-autocomplete'
+      );
     });
 
     test('delete', async () => {
       deleteStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(queryAndAssert(element, '#delete'));
+      queryAndAssert<GrButton>(element, '#delete').click();
       await showDialogSpy.lastCall.returnValue;
-      assert.isTrue(element.$.deleteDialog.disabled);
+      assert.isTrue(element.deleteDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup _focused manually - in headless mode Chrome sometimes don't
-      // setup focus. flush and/or flushAsynchronousOperations don't help
-      deleteAutocomplete._focused = true;
+      // Setup focused manually - in headless mode Chrome sometimes doesn't
+      // setup focus. waitEventLoop() doesn't help.
+      deleteAutocomplete.focused = true;
       deleteAutocomplete.noDebounce = true;
       deleteAutocomplete.text = 'src/test.cpp';
-      await flush();
+      await element.updateComplete;
       assert.isTrue(queryStub.called);
-      assert.isFalse(element.$.deleteDialog.disabled);
-      MockInteractions.tap(
-        queryAndAssert(element.$.deleteDialog, 'gr-button[primary]')
-      );
-      await flush();
+      await waitUntil(() => !element.deleteDialog!.disabled);
+      queryAndAssert<GrButton>(
+        element.deleteDialog,
+        'gr-button[primary]'
+      ).click();
+      await element.updateComplete;
 
       assert.isTrue(deleteStub.called);
       await deleteStub.lastCall.returnValue;
-      assert.equal(element._path, '');
+      assert.equal(element.path, '');
       assert.equal(eventStub.firstCall.args[0].type, 'reload');
       assert.isTrue(closeDialogSpy.called);
     });
 
     test('delete fails', async () => {
       deleteStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(queryAndAssert(element, '#delete'));
+      queryAndAssert<GrButton>(element, '#delete').click();
       await showDialogSpy.lastCall.returnValue;
-      assert.isTrue(element.$.deleteDialog.disabled);
+      assert.isTrue(element.deleteDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup _focused manually - in headless mode Chrome sometimes don't
-      // setup focus. flush and/or flushAsynchronousOperations don't help
-      deleteAutocomplete._focused = true;
+      // Setup focused manually - in headless mode Chrome sometimes doesn't
+      // setup focus. waitEventLoop() doesn't help.
+      deleteAutocomplete.focused = true;
       deleteAutocomplete.noDebounce = true;
       deleteAutocomplete.text = 'src/test.cpp';
-      await flush();
+      await element.updateComplete;
       assert.isTrue(queryStub.called);
-      assert.isFalse(element.$.deleteDialog.disabled);
-      MockInteractions.tap(
-        queryAndAssert(element.$.deleteDialog, 'gr-button[primary]')
-      );
-      await flush();
+      await waitUntil(() => !element.deleteDialog!.disabled);
+      queryAndAssert<GrButton>(
+        element.deleteDialog,
+        'gr-button[primary]'
+      ).click();
+      await element.updateComplete;
 
       assert.isTrue(deleteStub.called);
 
@@ -185,20 +322,20 @@
       assert.isFalse(closeDialogSpy.called);
     });
 
-    test('cancel', () => {
-      MockInteractions.tap(queryAndAssert(element, '#delete'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.deleteDialog.disabled);
-        element.$.deleteDialog!.querySelector('gr-autocomplete')!.text =
-          'src/test.cpp';
-        assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(
-          queryAndAssert(element.$.deleteDialog, 'gr-button')
-        );
-        assert.isFalse(eventStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, '');
-      });
+    test('cancel', async () => {
+      queryAndAssert<GrButton>(element, '#delete').click();
+      await waitUntilVisible(element.modal!);
+      assert.isTrue(element.deleteDialog!.disabled);
+      queryAndAssert<GrAutocomplete>(
+        element.deleteDialog,
+        'gr-autocomplete'
+      ).text = 'src/test.cpp';
+      await element.updateComplete;
+      await waitUntil(() => !element.deleteDialog!.disabled);
+      queryAndAssert<GrButton>(element.deleteDialog, 'gr-button').click();
+      assert.isFalse(eventStub.called);
+      assert.isTrue(closeDialogSpy.called);
+      await waitUntil(() => element.path === '');
     });
   });
 
@@ -210,64 +347,69 @@
     setup(() => {
       eventStub = sinon.stub(element, 'dispatchEvent');
       renameStub = stubRestApi('renameFileInChangeEdit');
-      renameAutocomplete =
-        element.$.renameDialog!.querySelector('gr-autocomplete')!;
+      const renameDialog = element.renameDialog;
+      renameAutocomplete = queryAndAssert<GrAutocomplete>(
+        renameDialog,
+        'gr-autocomplete'
+      );
     });
 
     test('rename', async () => {
       renameStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(queryAndAssert(element, '#rename'));
+      queryAndAssert<GrButton>(element, '#rename').click();
       await showDialogSpy.lastCall.returnValue;
-      assert.isTrue(element.$.renameDialog.disabled);
+      assert.isTrue(element.renameDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup _focused manually - in headless mode Chrome sometimes don't
-      // setup focus. flush and/or flushAsynchronousOperations don't help
-      renameAutocomplete._focused = true;
+      // Setup focused manually - in headless mode Chrome sometimes doesn't
+      // setup focus. waitEventLoop() doesn't help.
+      renameAutocomplete.focused = true;
       renameAutocomplete.noDebounce = true;
       renameAutocomplete.text = 'src/test.cpp';
-      await flush();
+      await element.updateComplete;
       assert.isTrue(queryStub.called);
-      assert.isTrue(element.$.renameDialog.disabled);
+      assert.isTrue(element.renameDialog!.disabled);
 
-      element.$.newPathIronInput.bindValue = 'src/test.newPath';
-      await flush();
+      element.newPathIronInput!.bindValue = 'src/test.newPath';
+      await element.updateComplete;
 
-      assert.isFalse(element.$.renameDialog.disabled);
-      MockInteractions.tap(
-        queryAndAssert(element.$.renameDialog, 'gr-button[primary]')
-      );
-      await flush();
+      assert.isFalse(element.renameDialog!.disabled);
+      queryAndAssert<GrButton>(
+        element.renameDialog,
+        'gr-button[primary]'
+      ).click();
+      await element.updateComplete;
       assert.isTrue(renameStub.called);
 
       await renameStub.lastCall.returnValue;
-      assert.equal(element._path, '');
+      assert.equal(element.path, '');
       assert.equal(eventStub.firstCall.args[0].type, 'reload');
       assert.isTrue(closeDialogSpy.called);
     });
 
     test('rename fails', async () => {
       renameStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(queryAndAssert(element, '#rename'));
+      queryAndAssert<GrButton>(element, '#rename').click();
       await showDialogSpy.lastCall.returnValue;
-      assert.isTrue(element.$.renameDialog.disabled);
+      assert.isTrue(element.renameDialog!.disabled);
       assert.isFalse(queryStub.called);
-      // Setup _focused manually - in headless mode Chrome sometimes don't
-      // setup focus. flush and/or flushAsynchronousOperations don't help
-      renameAutocomplete._focused = true;
+      // Setup focused manually - in headless mode Chrome sometimes doesn't
+      // setup focus. waitEventLoop() doesn't help.
+      renameAutocomplete.focused = true;
       renameAutocomplete.noDebounce = true;
       renameAutocomplete.text = 'src/test.cpp';
-      await flush();
+      await element.updateComplete;
       assert.isTrue(queryStub.called);
-      assert.isTrue(element.$.renameDialog.disabled);
+      assert.isTrue(element.renameDialog!.disabled);
 
-      element.$.newPathIronInput.bindValue = 'src/test.newPath';
-      await flush();
+      element.newPathIronInput!.bindValue = 'src/test.newPath';
+      await element.updateComplete;
 
-      assert.isFalse(element.$.renameDialog.disabled);
-      MockInteractions.tap(
-        queryAndAssert(element.$.renameDialog, 'gr-button[primary]')
-      );
-      await flush();
+      assert.isFalse(element.renameDialog!.disabled);
+      queryAndAssert<GrButton>(
+        element.renameDialog,
+        'gr-button[primary]'
+      ).click();
+      await element.updateComplete;
 
       assert.isTrue(renameStub.called);
 
@@ -276,22 +418,21 @@
       assert.isFalse(closeDialogSpy.called);
     });
 
-    test('cancel', () => {
-      MockInteractions.tap(queryAndAssert(element, '#rename'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.renameDialog.disabled);
-        element.$.renameDialog!.querySelector('gr-autocomplete')!.text =
-          'src/test.cpp';
-        element.$.newPathIronInput.bindValue = 'src/test.newPath';
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(
-          queryAndAssert(element.$.renameDialog, 'gr-button')
-        );
-        assert.isFalse(eventStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, '');
-        assert.equal(element._newPath, '');
-      });
+    test('cancel', async () => {
+      queryAndAssert<GrButton>(element, '#rename').click();
+      await waitUntilVisible(element.modal!);
+      assert.isTrue(element.renameDialog!.disabled);
+      queryAndAssert<GrAutocomplete>(
+        element.renameDialog,
+        'gr-autocomplete'
+      ).text = 'src/test.cpp';
+      element.newPathIronInput!.bindValue = 'src/test.newPath';
+      await element.updateComplete;
+      assert.isFalse(element.renameDialog!.disabled);
+      queryAndAssert<GrButton>(element.renameDialog, 'gr-button').click();
+      assert.isFalse(eventStub.called);
+      assert.isTrue(closeDialogSpy.called);
+      await waitUntil(() => element.path === '');
     });
   });
 
@@ -306,73 +447,68 @@
 
     test('restore hidden by default', () => {
       assert.isTrue(
-        queryAndAssert(element, '#restore').classList!.contains('invisible')!
+        queryAndAssert(element, '#restore').classList.contains('invisible')!
       );
     });
 
-    test('restore', () => {
+    test('restore', async () => {
       restoreStub.returns(Promise.resolve({ok: true}));
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(queryAndAssert(element, '#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(
-          queryAndAssert(element.$.restoreDialog, 'gr-button[primary]')
-        );
-        flush();
+      element.path = 'src/test.cpp';
+      queryAndAssert<GrButton>(element, '#restore').click();
+      await waitUntilVisible(element.modal!);
+      queryAndAssert<GrButton>(
+        element.restoreDialog,
+        'gr-button[primary]'
+      ).click();
+      await element.updateComplete;
 
-        assert.isTrue(restoreStub.called);
-        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-        return restoreStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
-          assert.equal(eventStub.firstCall.args[0].type, 'reload');
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('restore fails', () => {
-      restoreStub.returns(Promise.resolve({ok: false}));
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(queryAndAssert(element, '#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(
-          queryAndAssert(element.$.restoreDialog, 'gr-button[primary]')
-        );
-        flush();
-
-        assert.isTrue(restoreStub.called);
-        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-        return restoreStub.lastCall.returnValue.then(() => {
-          assert.isFalse(eventStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('cancel', () => {
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(queryAndAssert(element, '#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(
-          queryAndAssert(element.$.restoreDialog, 'gr-button')
-        );
-        assert.isFalse(eventStub.called);
+      assert.isTrue(restoreStub.called);
+      assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+      return restoreStub.lastCall.returnValue.then(() => {
+        assert.equal(element.path, '');
+        assert.equal(eventStub.firstCall.args[0].type, 'reload');
         assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, '');
       });
     });
+
+    test('restore fails', async () => {
+      restoreStub.returns(Promise.resolve({ok: false}));
+      element.path = 'src/test.cpp';
+      queryAndAssert<GrButton>(element, '#restore').click();
+      await waitUntilVisible(element.modal!);
+      queryAndAssert<GrButton>(
+        element.restoreDialog,
+        'gr-button[primary]'
+      ).click();
+      await element.updateComplete;
+
+      assert.isTrue(restoreStub.called);
+      assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+      return restoreStub.lastCall.returnValue.then(() => {
+        assert.isFalse(eventStub.called);
+        assert.isFalse(closeDialogSpy.called);
+      });
+    });
+
+    test('cancel', async () => {
+      element.path = 'src/test.cpp';
+      queryAndAssert<GrButton>(element, '#restore').click();
+      await waitUntilVisible(element.modal!);
+      queryAndAssert<GrButton>(element.restoreDialog, 'gr-button').click();
+      assert.isFalse(eventStub.called);
+      assert.isTrue(closeDialogSpy.called);
+      assert.equal(element.path, '');
+    });
   });
 
   suite('save file upload', () => {
-    let navStub: sinon.SinonStub;
     let fileStub: sinon.SinonStub;
 
     setup(() => {
-      navStub = sinon.stub(GerritNav, 'navigateToChange');
       fileStub = stubRestApi('saveFileUploadChangeEdit');
     });
 
-    test('_handleUploadConfirm', () => {
+    test('handleUploadConfirm', async () => {
       fileStub.returns(Promise.resolve({ok: true}));
 
       element.change = {
@@ -382,51 +518,58 @@
         revisions: {
           abcd: {
             ...createRevision(1),
-            _number: 1 as PatchSetNum,
+            _number: 1 as PatchSetNumber,
           },
           efgh: {
             ...createRevision(2),
-            _number: 2 as PatchSetNum,
+            _number: 2 as PatchSetNumber,
           },
         },
         current_revision: 'efgh' as CommitId,
       };
 
-      element._handleUploadConfirm('test.php', 'base64').then(() => {
-        assert.isTrue(navStub.calledWithExactly(1 as NumericChangeId));
-      });
+      element.handleUploadConfirm('test.php', 'base64');
+
+      assert.isTrue(fileStub.calledOnce);
+      assert.equal(fileStub.lastCall.args[0], 1);
+      assert.equal(fileStub.lastCall.args[1], 'test.php');
+      assert.equal(fileStub.lastCall.args[2], 'base64');
+      await waitForEventOnce(element, 'reload');
     });
   });
 
   test('openOpenDialog', async () => {
-    await element.openOpenDialog('test/path.cpp');
-    assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
-    assert.equal(
-      element.$.openDialog!.querySelector('gr-autocomplete')!.text,
-      'test/path.cpp'
+    element.openOpenDialog('test/path.cpp');
+    assert.isFalse(element.openDialog!.hasAttribute('hidden'));
+    await waitUntil(
+      () =>
+        queryAndAssert<GrAutocomplete>(element.openDialog, 'gr-autocomplete')
+          .text === 'test/path.cpp'
     );
   });
 
-  test('_getDialogFromEvent', () => {
-    const spy = sinon.spy(element, '_getDialogFromEvent');
-    element.addEventListener('tap', element._getDialogFromEvent);
+  test('getDialogFromEvent', async () => {
+    const spy = sinon.spy(element, 'getDialogFromEvent');
+    element.addEventListener('tap', element.getDialogFromEvent);
 
-    MockInteractions.tap(element.$.openDialog);
-    flush();
-    assert.equal(spy!.lastCall!.returnValue!.id, 'openDialog');
+    element.openDialog!.click();
+    await element.updateComplete;
+    assert.equal(spy.lastCall.returnValue!.id, 'openDialog');
 
-    MockInteractions.tap(element.$.deleteDialog);
-    flush();
-    assert.equal(spy!.lastCall!.returnValue!.id, 'deleteDialog');
+    element.deleteDialog!.click();
+    await element.updateComplete;
+    assert.equal(spy.lastCall.returnValue!.id, 'deleteDialog');
 
-    MockInteractions.tap(
-      element.$.deleteDialog!.querySelector('gr-autocomplete')!
-    );
-    flush();
-    assert.equal(spy!.lastCall!.returnValue!.id, 'deleteDialog');
+    queryAndAssert<GrAutocomplete>(
+      element.deleteDialog,
+      'gr-autocomplete'
+    ).click();
 
-    MockInteractions.tap(element);
-    flush();
-    assert.notOk(spy!.lastCall!.returnValue);
+    await element.updateComplete;
+    assert.equal(spy.lastCall.returnValue!.id, 'deleteDialog');
+
+    element.click();
+    await element.updateComplete;
+    assert.notOk(spy.lastCall.returnValue);
   });
 });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index 1b854b4..c442aa6 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
@@ -1,25 +1,13 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../shared/gr-dropdown/gr-dropdown';
 import {GrEditConstants} from '../gr-edit-constants';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 interface EditAction {
   label: string;
@@ -52,6 +40,9 @@
         gr-dropdown {
           --gr-dropdown-item-color: var(--link-color);
           --gr-button-padding: var(--spacing-xs) var(--spacing-s);
+          --gr-dropdown-item-background-color: transparent;
+          --gr-dropdown-item-border: none;
+          --gr-dropdown-item-text-transform: uppercase;
         }
         #actions {
           margin-right: var(--spacing-l);
@@ -61,31 +52,16 @@
   }
 
   override render() {
-    // To pass CSS mixins for @apply to Polymer components, they need to appear
-    // in <style> inside the template.
-    /* eslint-disable lit/prefer-static-styles */
-    const customStyle = html`
-      <style>
-        gr-dropdown {
-          --gr-dropdown-item: {
-            background-color: transparent;
-            border: none;
-            text-transform: uppercase;
-          }
-        }
-      </style>
-    `;
     const fileActions = this._computeFileActions(this._allFileActions);
-    return html`${customStyle}
-      <gr-dropdown
-        id="actions"
-        .items=${fileActions}
-        down-arrow=""
-        vertical-offset="20"
-        @tap-item="${this._handleActionTap}"
-        link=""
-        >Actions</gr-dropdown
-      >`;
+    return html` <gr-dropdown
+      id="actions"
+      .items=${fileActions}
+      down-arrow=""
+      vertical-offset="20"
+      @tap-item=${this._handleActionTap}
+      link=""
+      >Actions</gr-dropdown
+    >`;
   }
 
   _handleActionTap(e: CustomEvent) {
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
index 049c187..bd27660 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
@@ -1,29 +1,15 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-edit-file-controls';
 import {GrEditFileControls} from './gr-edit-file-controls';
 import {GrEditConstants} from '../gr-edit-constants';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-edit-file-controls');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-edit-file-controls tests', () => {
   let element: GrEditFileControls;
@@ -31,20 +17,33 @@
   let fileActionHandler: sinon.SinonStub;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-edit-file-controls></gr-edit-file-controls>`
+    );
     fileActionHandler = sinon.stub();
     element.addEventListener('file-action-tap', fileActionHandler);
-    await flush();
+    await element.updateComplete;
   });
 
-  test('open tap emits event', () => {
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-dropdown down-arrow="" id="actions" link="" vertical-offset="20">
+          Actions
+        </gr-dropdown>
+      `
+    );
+  });
+
+  test('open tap emits event', async () => {
     const actions = queryAndAssert<GrDropdown>(element, '#actions');
     element.filePath = 'foo';
-    actions._open();
-    flush();
+    actions.open();
+    await actions.updateComplete;
 
-    const row = queryAndAssert(actions, 'li [data-id="open"]');
-    MockInteractions.tap(row);
+    const row = queryAndAssert<HTMLSpanElement>(actions, 'li [data-id="open"]');
+    row.click();
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
       action: GrEditConstants.Actions.OPEN.id,
@@ -52,14 +51,17 @@
     });
   });
 
-  test('delete tap emits event', () => {
+  test('delete tap emits event', async () => {
     const actions = queryAndAssert<GrDropdown>(element, '#actions');
     element.filePath = 'foo';
-    actions._open();
-    flush();
+    actions.open();
+    await actions.updateComplete;
 
-    const row = queryAndAssert(actions, 'li [data-id="delete"]');
-    MockInteractions.tap(row);
+    const row = queryAndAssert<HTMLSpanElement>(
+      actions,
+      'li [data-id="delete"]'
+    );
+    row.click();
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
       action: GrEditConstants.Actions.DELETE.id,
@@ -67,14 +69,17 @@
     });
   });
 
-  test('restore tap emits event', () => {
+  test('restore tap emits event', async () => {
     const actions = queryAndAssert<GrDropdown>(element, '#actions');
     element.filePath = 'foo';
-    actions._open();
-    flush();
+    actions.open();
+    await actions.updateComplete;
 
-    const row = queryAndAssert(actions, 'li [data-id="restore"]');
-    MockInteractions.tap(row);
+    const row = queryAndAssert<HTMLSpanElement>(
+      actions,
+      'li [data-id="restore"]'
+    );
+    row.click();
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
       action: GrEditConstants.Actions.RESTORE.id,
@@ -82,14 +87,17 @@
     });
   });
 
-  test('rename tap emits event', () => {
+  test('rename tap emits event', async () => {
     const actions = queryAndAssert<GrDropdown>(element, '#actions');
     element.filePath = 'foo';
-    actions._open();
-    flush();
+    actions.open();
+    await actions.updateComplete;
 
-    const row = queryAndAssert(actions, 'li [data-id="rename"]');
-    MockInteractions.tap(row);
+    const row = queryAndAssert<HTMLSpanElement>(
+      actions,
+      'li [data-id="rename"]'
+    );
+    row.click();
     assert.isTrue(fileActionHandler.called);
     assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
       action: GrEditConstants.Actions.RENAME.id,
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 a295588..acc4c9e 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
@@ -1,52 +1,44 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-editable-label/gr-editable-label';
 import '../gr-default-editor/gr-default-editor';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-editor-view_html';
-import {
-  GerritNav,
-  GenerateUrlEditViewParameters,
-} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {computeTruncatedPath} from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
-  ChangeInfo,
-  PatchSetNum,
   EditPreferencesInfo,
   Base64FileContent,
-  NumericChangeId,
-  EditPatchSetNum,
+  PatchSetNumber,
 } from '../../../types/common';
+import {ParsedChangeInfo} from '../../../types/types';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} 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';
-import {GrButton} from '../../shared/gr-button/gr-button';
-import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
-import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {addShortcut, Modifier} from '../../../utils/dom-util';
+import {Modifier} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {
+  ChangeChildView,
+  changeViewModelToken,
+  ChangeViewState,
+  createChangeUrl,
+} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -57,23 +49,8 @@
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
-// Used within the tests
-export interface GrEditorView {
-  $: {
-    close: GrButton;
-    editorEndpoint: GrEndpointDecorator;
-    file: GrDefaultEditor;
-    publish: GrButton;
-    save: GrButton;
-  };
-}
-
 @customElement('gr-editor-view')
-export class GrEditorView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEditorView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
@@ -86,198 +63,335 @@
    * @event show-alert
    */
 
-  @property({type: Object, observer: '_paramsChanged'})
-  params?: GenerateUrlEditViewParameters;
-
-  @property({type: Object, observer: '_editChange'})
-  _change?: ChangeInfo | null;
-
-  @property({type: Number})
-  _changeNum?: NumericChangeId;
-
-  @property({type: String})
-  _patchNum?: PatchSetNum;
-
-  @property({type: String})
-  _path?: string;
-
-  @property({type: String})
-  _type?: string;
-
-  @property({type: String})
-  _content?: string;
-
-  @property({type: String})
-  _newContent = '';
-
-  @property({type: Boolean})
-  _saving = false;
-
-  @property({type: Boolean})
-  _successfulSave = false;
-
-  @property({
-    type: Boolean,
-    computed: '_computeSaveDisabled(_content, _newContent, _saving)',
-  })
-  _saveDisabled = true;
-
   @property({type: Object})
-  _prefs?: EditPreferencesInfo;
+  viewState?: ChangeViewState;
 
-  @property({type: Number})
-  _lineNum?: number;
+  // private but used in test
+  @state() change?: ParsedChangeInfo;
 
-  private readonly restApiService = appContext.restApiService;
+  // private but used in test
+  @state() type?: string;
 
-  private readonly storage = appContext.storageService;
+  // private but used in test
+  @state() content?: string;
 
-  private readonly reporting = appContext.reportingService;
+  // private but used in test
+  @state() newContent = '';
+
+  // private but used in test
+  @state() saving = false;
+
+  // private but used in test
+  @state() successfulSave = false;
+
+  @state() private editPrefs?: EditPreferencesInfo;
+
+  // private but used in test
+  @state() latestPatchsetNumber?: PatchSetNumber;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getStorage = resolve(this, storageServiceToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  private readonly shortcuts = new ShortcutController(this);
 
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
-
   constructor() {
     super();
     this.addEventListener('content-change', e => {
-      this._handleContentChange(e as CustomEvent<{value: string}>);
+      this.handleContentChange(e as CustomEvent<{value: string}>);
     });
+    subscribe(
+      this,
+      () => this.getUserModel().editPreferences$,
+      editPreferences => (this.editPrefs = editPreferences)
+    );
+    subscribe(
+      this,
+      () => this.getViewModel().state$,
+      state => {
+        // TODO: Add a setter for `viewState` instead of relying on the
+        // `viewStateChanged()` call here.
+        this.viewState = state;
+        this.viewStateChanged();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestPatchNum$,
+      x => (this.latestPatchsetNumber = x)
+    );
+    this.shortcuts.addLocal({key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
+      this.handleSaveShortcut()
+    );
+    this.shortcuts.addLocal({key: 's', modifiers: [Modifier.META_KEY]}, () =>
+      this.handleSaveShortcut()
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._getEditPrefs().then(prefs => {
-      this._prefs = prefs;
-    });
-    this.cleanups.push(
-      addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, e =>
-        this._handleSaveShortcut(e)
-      )
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, e =>
-        this._handleSaveShortcut(e)
-      )
-    );
   }
 
   override disconnectedCallback() {
-    this.storeTask?.cancel();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
+    this.storeTask?.flush();
     super.disconnectedCallback();
   }
 
-  get storageKey() {
-    return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          background-color: var(--view-background-color);
+        }
+        .stickyHeader {
+          background-color: var(--edit-mode-background-color);
+          border-bottom: 1px var(--border-color) solid;
+          position: sticky;
+          top: 0;
+          z-index: 1;
+        }
+        header {
+          align-items: center;
+          display: flex;
+          flex-wrap: wrap;
+          justify-content: space-between;
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        header gr-editable-label {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+        }
+        header gr-editable-label::part(label) {
+          text-overflow: initial;
+          white-space: initial;
+          word-break: break-all;
+        }
+        header gr-editable-label::part(input-container) {
+          margin-top: var(--spacing-l);
+        }
+        .textareaWrapper {
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          margin: var(--spacing-l);
+        }
+        .textareaWrapper .editButtons {
+          display: none;
+        }
+        .controlGroup {
+          align-items: center;
+          display: flex;
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+        }
+        .rightControls {
+          justify-content: flex-end;
+        }
+        .warning {
+          color: var(--error-text-color);
+        }
+      `,
+    ];
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
+  override render() {
+    if (this.viewState?.childView !== ChangeChildView.EDIT) return nothing;
+    return html` ${this.renderHeader()} ${this.renderEndpoint()} `;
   }
 
-  _getEditPrefs() {
-    return this.restApiService.getEditPreferences();
+  private renderHeader() {
+    return html`
+      <div class="stickyHeader">
+        <header>
+          <span class="controlGroup">
+            <span>Edit mode</span>
+            ${this.renderEditingOldPatchsetWarning()}
+            <span class="separator"></span>
+            <gr-editable-label
+              labelText="File path"
+              .value=${this.viewState?.editView?.path}
+              placeholder="File path..."
+              @changed=${this.handlePathChanged}
+            ></gr-editable-label>
+          </span>
+          <span class="controlGroup rightControls">
+            <gr-button id="close" link="" @click=${this.handleCloseTap}
+              >Cancel</gr-button
+            >
+            <gr-button
+              id="save"
+              ?disabled=${this.computeSaveDisabled()}
+              primary=""
+              link=""
+              title="Save and Close the file"
+              @click=${this.handleSaveTap}
+              >Save</gr-button
+            >
+            <gr-button
+              id="publish"
+              link=""
+              primary=""
+              title="Publish your edit. A new patchset will be created."
+              @click=${this.handlePublishTap}
+              ?disabled=${this.computeSaveDisabled()}
+              >Save & Publish</gr-button
+            >
+          </span>
+        </header>
+      </div>
+    `;
   }
 
-  _paramsChanged(value: GenerateUrlEditViewParameters) {
-    if (value.view !== GerritNav.View.EDIT) {
-      return;
+  private renderEditingOldPatchsetWarning() {
+    const patchset = this.viewState?.patchNum;
+    if (patchset === this.latestPatchsetNumber) return nothing;
+    return html`<span class="warning">&nbsp;(Old Patchset)</span>`;
+  }
+
+  private renderEndpoint() {
+    return html`
+      <div class="textareaWrapper">
+        <gr-endpoint-decorator id="editorEndpoint" name="editor">
+          <gr-endpoint-param
+            name="fileContent"
+            .value=${this.newContent}
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="prefs"
+            .value=${this.editPrefs}
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="fileType"
+            .value=${this.type}
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="lineNum"
+            .value=${this.viewState?.editView?.lineNum}
+          ></gr-endpoint-param>
+          <gr-default-editor
+            id="file"
+            .fileContent=${this.newContent}
+          ></gr-default-editor>
+        </gr-endpoint-decorator>
+      </div>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('change')) {
+      this.navigateToChangeIfEdit();
     }
+    if (changedProperties.has('change') || changedProperties.has('type')) {
+      this.navigateToChangeIfEditType();
+    }
+  }
 
-    this._changeNum = value.changeNum;
-    this._path = value.path;
-    this._patchNum = value.patchNum || (EditPatchSetNum as PatchSetNum);
-    this._lineNum =
-      typeof value.lineNum === 'string' ? Number(value.lineNum) : value.lineNum;
+  get storageKey() {
+    return `c${this.viewState?.changeNum}_ps${this.viewState?.patchNum}_${this.viewState?.editView?.path}`;
+  }
+
+  // private but used in test
+  viewStateChanged() {
+    if (this.viewState?.childView !== ChangeChildView.EDIT) return;
 
     // 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.
     setTimeout(() => {
-      const title = `Editing ${computeTruncatedPath(value.path)}`;
+      if (!this.viewState) return;
+      const title = `Editing ${computeTruncatedPath(
+        this.viewState.editView?.path
+      )}`;
       fireTitleChange(this, title);
     });
 
     const promises = [];
-
-    promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(
-      this._getFileData(this._changeNum, this._path, this._patchNum)
-    );
+    promises.push(this.getChangeDetail());
+    promises.push(this.getFileData());
     return Promise.all(promises);
   }
 
-  _getChangeDetail(changeNum: NumericChangeId) {
-    return this.restApiService.getDiffChangeDetail(changeNum).then(change => {
-      this._change = change;
-    });
+  private async getChangeDetail() {
+    const changeNum = this.viewState?.changeNum;
+    assertIsDefined(changeNum, 'change number');
+    this.change = await this.restApiService.getChangeDetail(changeNum);
   }
 
-  _editChange(value?: ChangeInfo | null) {
-    if (!value) return;
-    if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
+  private navigateToChangeIfEdit() {
+    if (!this.change) return;
+    if (!changeIsMerged(this.change) && !changeIsAbandoned(this.change)) return;
     fireAlert(
       this,
       'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
     );
-    GerritNav.navigateToChange(value);
+    this.getNavigation().setUrl(createChangeUrl({change: this.change}));
   }
 
-  @observe('_change', '_type')
-  _editType(change?: ChangeInfo | null, type?: string) {
-    if (!change || !type || !type.startsWith('image/')) return;
+  private navigateToChangeIfEditType() {
+    if (!this.change || !this.type || !this.type.startsWith('image/')) return;
 
     // Prevent editing binary files
     fireAlert(this, 'You cannot edit binary files within the inline editor.');
-    GerritNav.navigateToChange(change);
+    this.getNavigation().setUrl(createChangeUrl({change: this.change}));
   }
 
-  _handlePathChanged(e: CustomEvent<string>) {
-    // 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'));
-    }
-    const path = e.detail;
-    if (path === this._path) {
-      return Promise.resolve();
-    }
-    return this.restApiService
-      .renameFileInChangeEdit(this._changeNum, this._path, path)
-      .then(res => {
-        if (!res || !res.ok) {
-          return;
-        }
+  // private but used in test
+  async handlePathChanged(e: CustomEvent<string>): Promise<void> {
+    const changeNum = this.viewState?.changeNum;
+    const currentPath = this.viewState?.editView?.path;
+    assertIsDefined(changeNum, 'change number');
+    assertIsDefined(currentPath, 'path');
 
-        this._successfulSave = true;
-        this._viewEditInChangeView();
-      });
+    const newPath = e.detail;
+    if (newPath === currentPath) return;
+    const res = await this.restApiService.renameFileInChangeEdit(
+      changeNum,
+      currentPath,
+      newPath
+    );
+    if (!res?.ok) return;
+
+    this.successfulSave = true;
+    this.viewEditInChangeView();
   }
 
-  _viewEditInChangeView() {
-    if (this._change)
-      GerritNav.navigateToChange(
-        this._change,
-        undefined,
-        undefined,
-        true,
-        undefined,
-        true
-      );
+  // private but used in test
+  viewEditInChangeView() {
+    if (!this.change) return;
+    this.getNavigation().setUrl(
+      createChangeUrl({change: this.change, edit: true, forceReload: true})
+    );
   }
 
-  _getFileData(
-    changeNum: NumericChangeId,
-    path: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (patchNum === undefined) {
-      return Promise.reject(new Error('patchNum undefined'));
-    }
-    const storedContent = this.storage.getEditableContentItem(this.storageKey);
+  // private but used in test
+  getFileData() {
+    const changeNum = this.viewState?.changeNum;
+    const patchNum = this.viewState?.patchNum;
+    const path = this.viewState?.editView?.path;
+    assertIsDefined(changeNum, 'change number');
+    assertIsDefined(patchNum, 'patchset number');
+    assertIsDefined(path, 'path');
+
+    const storedContent = this.getStorage().getEditableContentItem(
+      this.storageKey
+    );
 
     return this.restApiService
       .getFileContent(changeNum, path, patchNum)
@@ -290,89 +404,87 @@
         ) {
           fireAlert(this, RESTORED_MESSAGE);
 
-          this._newContent = storedContent.message;
+          this.newContent = storedContent.message;
         } else {
-          this._newContent = content;
+          this.newContent = content;
         }
-        this._content = content;
+        this.content = content;
 
         // A non-ok response may result if the file does not yet exist.
         // The `type` field of the response is only valid when the file
         // already exists.
         if (res && res.ok && res.type) {
-          this._type = res.type;
+          this.type = res.type;
         } else {
-          this._type = '';
+          this.type = '';
         }
       });
   }
 
-  _saveEdit() {
-    if (this._changeNum === undefined || !this._path) {
-      return Promise.reject(new Error('changeNum or path undefined'));
-    }
-    this._saving = true;
-    this._showAlert(SAVING_MESSAGE);
-    this.storage.eraseEditableContentItem(this.storageKey);
-    if (!this._newContent)
+  // private but used in test
+  saveEdit() {
+    const changeNum = this.viewState?.changeNum;
+    const path = this.viewState?.editView?.path;
+    assertIsDefined(changeNum, 'change number');
+    assertIsDefined(path, 'path');
+
+    this.saving = true;
+    this.showAlert(SAVING_MESSAGE);
+    this.getStorage().eraseEditableContentItem(this.storageKey);
+    if (!this.newContent)
       return Promise.reject(new Error('new content undefined'));
     return this.restApiService
-      .saveChangeEdit(this._changeNum, this._path, this._newContent)
+      .saveChangeEdit(changeNum, path, this.newContent)
       .then(res => {
-        this._saving = false;
-        this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
+        this.saving = false;
+        this.showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
         if (!res.ok) {
           return res;
         }
 
-        this._content = this._newContent;
-        this._successfulSave = true;
+        this.content = this.newContent;
+        this.successfulSave = true;
         return res;
       });
   }
 
-  _showAlert(message: string) {
+  // private but used in test
+  showAlert(message: string) {
     fireAlert(this, message);
   }
 
-  _computeSaveDisabled(
-    content?: string,
-    newContent?: string,
-    saving?: boolean
-  ) {
-    // Polymer 2: check for undefined
-    if ([content, newContent, saving].includes(undefined)) {
+  computeSaveDisabled() {
+    if ([this.content, this.newContent, this.saving].includes(undefined)) {
       return true;
     }
 
-    if (saving) {
-      return true;
-    }
-    return content === newContent;
+    if (this.saving) return true;
+    return this.content === this.newContent;
   }
 
-  _handleCloseTap() {
+  // private but used in test
+  handleCloseTap = () => {
     // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
-    this._viewEditInChangeView();
-  }
+    this.viewEditInChangeView();
+  };
 
-  _handleSaveTap() {
-    this._saveEdit().then(res => {
-      if (res.ok) this._viewEditInChangeView();
+  private handleSaveTap = () => {
+    this.saveEdit().then(res => {
+      if (res.ok) this.viewEditInChangeView();
     });
-  }
+  };
 
-  _handlePublishTap() {
-    assertIsDefined(this._changeNum, '_changeNum');
+  private handlePublishTap = () => {
+    const changeNum = this.viewState?.changeNum;
+    assertIsDefined(changeNum, 'change number');
 
-    const changeNum = this._changeNum;
-    this._saveEdit().then(() => {
+    this.saveEdit().then(() => {
       const handleError: ErrorCallback = response => {
-        this._showAlert(PUBLISH_FAILED_MSG);
-        this.reporting.error(new Error(response?.statusText));
+        this.showAlert(PUBLISH_FAILED_MSG);
+        this.reporting.error('/edit:publish', new Error(response?.statusText));
       };
 
-      this._showAlert(PUBLISHING_EDIT_MSG);
+      this.showAlert(PUBLISHING_EDIT_MSG);
 
       this.restApiService
         .executeChangeAction(
@@ -384,33 +496,33 @@
           handleError
         )
         .then(() => {
-          assertIsDefined(this._change, '_change');
-          GerritNav.navigateToChange(this._change);
+          assertIsDefined(this.change, 'change');
+          this.getNavigation().setUrl(
+            createChangeUrl({change: this.change, forceReload: true})
+          );
         });
     });
-  }
+  };
 
-  _handleContentChange(e: CustomEvent<{value: string}>) {
+  private handleContentChange(e: CustomEvent<{value: string}>) {
     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.newContent = e.detail.value;
+          this.getStorage().setEditableContentItem(this.storageKey, content);
         } else {
-          this.storage.eraseEditableContentItem(this.storageKey);
+          this.getStorage().eraseEditableContentItem(this.storageKey);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
     );
   }
 
-  _handleSaveShortcut(e: KeyboardEvent) {
-    e.preventDefault();
-    if (!this._saveDisabled) {
-      this._saveEdit();
-    }
+  // private but used in test
+  handleSaveShortcut() {
+    if (!this.computeSaveDisabled()) this.saveEdit();
   }
 }
 
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
deleted file mode 100644
index b577db3..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
+++ /dev/null
@@ -1,127 +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(--view-background-color);
-    }
-    .stickyHeader {
-      background-color: var(--edit-mode-background-color);
-      border-bottom: 1px var(--border-color) solid;
-      position: sticky;
-      top: 0;
-      z-index: 1;
-    }
-    header {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-      justify-content: space-between;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    header gr-editable-label {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-    }
-    header gr-editable-label::part(label) {
-      text-overflow: initial;
-      white-space: initial;
-      word-break: break-all;
-    }
-    header gr-editable-label::part(input-container) {
-      margin-top: var(--spacing-l);
-    }
-    .textareaWrapper {
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin: var(--spacing-l);
-    }
-    .textareaWrapper .editButtons {
-      display: none;
-    }
-    .controlGroup {
-      align-items: center;
-      display: flex;
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-    }
-    .rightControls {
-      justify-content: flex-end;
-    }
-  </style>
-  <div class="stickyHeader">
-    <header>
-      <span class="controlGroup">
-        <span>Edit mode</span>
-        <span class="separator"></span>
-        <gr-editable-label
-          label-text="File path"
-          value="[[_path]]"
-          placeholder="File path..."
-          on-changed="_handlePathChanged"
-        ></gr-editable-label>
-      </span>
-      <span class="controlGroup rightControls">
-        <gr-button id="close" link="" on-click="_handleCloseTap"
-          >Cancel</gr-button
-        >
-        <gr-button
-          id="save"
-          disabled$="[[_saveDisabled]]"
-          primary=""
-          link=""
-          title="Save and Close the file"
-          on-click="_handleSaveTap"
-          >Save</gr-button
-        >
-        <gr-button
-          id="publish"
-          link=""
-          primary=""
-          title="Publish your edit. A new patchset will be created."
-          on-click="_handlePublishTap"
-          disabled$="[[_saveDisabled]]"
-          >Save & Publish</gr-button
-        >
-      </span>
-    </header>
-  </div>
-  <div class="textareaWrapper">
-    <gr-endpoint-decorator id="editorEndpoint" name="editor">
-      <gr-endpoint-param
-        name="fileContent"
-        value="[[_newContent]]"
-      ></gr-endpoint-param>
-      <gr-endpoint-param name="prefs" value="[[_prefs]]"></gr-endpoint-param>
-      <gr-endpoint-param name="fileType" value="[[_type]]"></gr-endpoint-param>
-      <gr-endpoint-param
-        name="lineNum"
-        value="[[_lineNum]]"
-      ></gr-endpoint-param>
-      <gr-default-editor
-        id="file"
-        file-content="[[_newContent]]"
-      ></gr-default-editor>
-    </gr-endpoint-decorator>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index f591ab2..1a4879d 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -1,37 +1,38 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
+import './gr-editor-view';
 import {GrEditorView} from './gr-editor-view';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {HttpMethod} from '../../../constants/constants';
-import {mockPromise, stubRestApi, stubStorage} from '../../../test/test-utils';
 import {
-  EditPatchSetNum,
+  mockPromise,
+  pressKey,
+  query,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  EDIT,
   NumericChangeId,
-  PatchSetNum,
+  PatchSetNumber,
+  RevisionPatchSetNum,
 } from '../../../types/common';
 import {
   createChangeViewChange,
-  createGenerateUrlEditViewParameters,
+  createEditViewState,
 } from '../../../test/test-data-generators';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-editor-view');
+import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
+import {Modifier} from '../../../utils/dom-util';
+import {testResolver} from '../../../test/common-test-setup';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {StorageService} from '../../../services/storage/gr-storage';
 
 suite('gr-editor-view tests', () => {
   let element: GrEditorView;
@@ -40,82 +41,150 @@
   let saveFileStub: sinon.SinonStub;
   let changeDetailStub: sinon.SinonStub;
   let navigateStub: sinon.SinonStub;
+  let storageService: StorageService;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-editor-view></gr-editor-view>`);
     savePathStub = stubRestApi('renameFileInChangeEdit');
     saveFileStub = stubRestApi('saveChangeEdit');
-    changeDetailStub = stubRestApi('getDiffChangeDetail');
-    navigateStub = sinon.stub(element, '_viewEditInChangeView');
+    changeDetailStub = stubRestApi('getChangeDetail');
+    navigateStub = sinon.stub(element, 'viewEditInChangeView');
+    element.viewState = {
+      ...createEditViewState(),
+      patchNum: 1 as PatchSetNumber,
+    };
+    element.latestPatchsetNumber = 1 as PatchSetNumber;
+    await element.updateComplete;
+    storageService = testResolver(storageServiceToken);
   });
 
-  suite('_paramsChanged', () => {
-    test('good params proceed', () => {
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="stickyHeader">
+          <header>
+            <span class="controlGroup">
+              <span> Edit mode </span>
+              <span class="separator"> </span>
+              <gr-editable-label
+                id="global"
+                labeltext="File path"
+                placeholder="File path..."
+                tabindex="0"
+                title="${element.viewState?.editView?.path}"
+              >
+              </gr-editable-label>
+            </span>
+            <span class="controlGroup rightControls">
+              <gr-button
+                aria-disabled="false"
+                id="close"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                Cancel
+              </gr-button>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                id="save"
+                link=""
+                primary=""
+                role="button"
+                tabindex="-1"
+                title="Save and Close the file"
+              >
+                Save
+              </gr-button>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                id="publish"
+                link=""
+                primary=""
+                role="button"
+                tabindex="-1"
+                title="Publish your edit. A new patchset will be created."
+              >
+                Save & Publish
+              </gr-button>
+            </span>
+          </header>
+        </div>
+        <div class="textareaWrapper">
+          <gr-endpoint-decorator id="editorEndpoint" name="editor">
+            <gr-endpoint-param name="fileContent"> </gr-endpoint-param>
+            <gr-endpoint-param name="prefs"> </gr-endpoint-param>
+            <gr-endpoint-param name="fileType"> </gr-endpoint-param>
+            <gr-endpoint-param name="lineNum"> </gr-endpoint-param>
+            <gr-default-editor id="file"> </gr-default-editor>
+          </gr-endpoint-decorator>
+        </div>
+      `
+    );
+  });
+
+  suite('viewStateChanged', () => {
+    test('good view state proceed', async () => {
       changeDetailStub.returns(Promise.resolve({}));
-      const fileStub = sinon.stub(element, '_getFileData').callsFake(() => {
-        element._content = 'text';
-        element._newContent = 'text';
-        element._type = 'application/octet-stream';
+      const fileStub = sinon.stub(element, 'getFileData').callsFake(() => {
+        element.content = 'text';
+        element.newContent = 'text';
+        element.type = 'application/octet-stream';
         return Promise.resolve();
       });
 
-      const promises = element._paramsChanged({
-        ...createGenerateUrlEditViewParameters(),
-      });
+      element.viewState = {...createEditViewState()};
+      const promises = element.viewStateChanged();
 
-      flush();
+      await element.updateComplete;
+
       const changeNum = 42 as NumericChangeId;
-      assert.equal(element._changeNum, changeNum);
-      assert.equal(element._path, 'foo/bar.baz');
       assert.deepEqual(changeDetailStub.lastCall.args[0], changeNum);
-      assert.deepEqual(fileStub.lastCall.args, [
-        changeNum,
-        'foo/bar.baz',
-        EditPatchSetNum as PatchSetNum,
-      ]);
+      assert.isTrue(fileStub.called);
 
       return promises?.then(() => {
-        assert.equal(element._content, 'text');
-        assert.equal(element._newContent, 'text');
-        assert.equal(element._type, 'application/octet-stream');
+        assert.equal(element.content, 'text');
+        assert.equal(element.newContent, 'text');
+        assert.equal(element.type, 'application/octet-stream');
       });
     });
   });
 
   test('edit file path', () => {
-    element._changeNum = 42 as NumericChangeId;
-    element._path = 'foo/bar.baz';
+    element.viewState = {...createEditViewState()};
     savePathStub.onFirstCall().returns(Promise.resolve({}));
     savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
 
     // Calling with the same path should not navigate.
     return element
-      ._handlePathChanged(new CustomEvent('change', {detail: 'foo/bar.baz'}))
+      .handlePathChanged(new CustomEvent('change', {detail: 'foo/bar.baz'}))
       .then(() => {
         assert.isFalse(savePathStub.called);
         // !ok response
         element
-          ._handlePathChanged(new CustomEvent('change', {detail: 'newPath'}))
+          .handlePathChanged(new CustomEvent('change', {detail: 'newPath'}))
           .then(() => {
             assert.isTrue(savePathStub.called);
             assert.isFalse(navigateStub.called);
             // ok response
             element
-              ._handlePathChanged(
-                new CustomEvent('change', {detail: 'newPath'})
-              )
+              .handlePathChanged(new CustomEvent('change', {detail: 'newPath'}))
               .then(() => {
                 assert.isTrue(navigateStub.called);
-                assert.isTrue(element._successfulSave);
+                assert.isTrue(element.successfulSave);
               });
           });
       });
   });
 
-  test('reacts to content-change event', () => {
-    const storageStub = stubStorage('setEditableContentItem');
-    element._newContent = 'test';
-    element.$.editorEndpoint.dispatchEvent(
+  test('reacts to content-change event', async () => {
+    const storageStub = sinon.stub(storageService, 'setEditableContentItem');
+    element.newContent = 'test';
+    await element.updateComplete;
+    query<GrEndpointDecorator>(element, '#editorEndpoint')!.dispatchEvent(
       new CustomEvent('content-change', {
         bubbles: true,
         composed: true,
@@ -123,9 +192,9 @@
       })
     );
     element.storeTask?.flush();
-    flush();
+    await element.updateComplete;
 
-    assert.equal(element._newContent, 'new content value');
+    assert.equal(element.newContent, 'new content value');
     assert.isTrue(storageStub.called);
     assert.equal(storageStub.lastCall.args[1], 'new content value');
   });
@@ -134,40 +203,49 @@
     const originalText = 'file text';
     const newText = 'file text changed';
 
-    setup(() => {
-      element._changeNum = 42 as NumericChangeId;
-      element._path = 'foo/bar.baz';
-      element._content = originalText;
-      element._newContent = originalText;
-      flush();
+    setup(async () => {
+      element.viewState = {...createEditViewState()};
+      element.content = originalText;
+      element.newContent = originalText;
+      await element.updateComplete;
     });
 
     test('initial load', () => {
-      assert.equal(element.$.file.fileContent, originalText);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
+      assert.equal(
+        query<GrDefaultEditor>(element, '#file')!.fileContent,
+        originalText
+      );
+      assert.isTrue(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
     });
 
-    test('file modification and save, !ok response', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const eraseStub = stubStorage('eraseEditableContentItem');
-      const alertStub = sinon.stub(element, '_showAlert');
+    test('file modification and save, !ok response', async () => {
+      const saveSpy = sinon.spy(element, 'saveEdit');
+      const eraseStub = sinon.stub(storageService, 'eraseEditableContentItem');
+      const alertStub = sinon.stub(element, 'showAlert');
       saveFileStub.returns(Promise.resolve({ok: false}));
-      element._newContent = newText;
-      flush();
+      element.newContent = newText;
+      await element.updateComplete;
 
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-      assert.isFalse(element._saving);
+      assert.isFalse(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
+      assert.isFalse(element.saving);
 
-      MockInteractions.tap(element.$.save);
+      query<GrButton>(element, '#save')!.click();
       assert.isTrue(saveSpy.called);
       assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
+      assert.isTrue(element.saving);
+      await element.updateComplete;
+      assert.isTrue(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
       return saveSpy.lastCall.returnValue.then(() => {
         assert.isTrue(saveFileStub.called);
         assert.isTrue(eraseStub.called);
-        assert.isFalse(element._saving);
+        assert.isFalse(element.saving);
         assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
         assert.deepEqual(saveFileStub.lastCall.args, [
           42 as NumericChangeId,
@@ -175,65 +253,81 @@
           newText,
         ]);
         assert.isFalse(navigateStub.called);
-        assert.isFalse(element.$.save.hasAttribute('disabled'));
-        assert.notEqual(element._content, element._newContent);
+        assert.isFalse(
+          query<GrButton>(element, '#save')!.hasAttribute('disabled')
+        );
+        assert.notEqual(element.content, element.newContent);
       });
     });
 
-    test('file modification and save', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const alertStub = sinon.stub(element, '_showAlert');
+    test('file modification and save', async () => {
+      const saveSpy = sinon.spy(element, 'saveEdit');
+      const alertStub = sinon.stub(element, 'showAlert');
       saveFileStub.returns(Promise.resolve({ok: true}));
-      element._newContent = newText;
-      flush();
+      element.newContent = newText;
+      await element.updateComplete;
 
-      assert.isFalse(element._saving);
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(element.saving);
+      assert.isFalse(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
-      MockInteractions.tap(element.$.save);
+      query<GrButton>(element, '#save')!.click();
       assert.isTrue(saveSpy.called);
       assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
+      assert.isTrue(element.saving);
+      await element.updateComplete;
+      assert.isTrue(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
       return saveSpy.lastCall.returnValue.then(() => {
         assert.isTrue(saveFileStub.called);
-        assert.isFalse(element._saving);
+        assert.isFalse(element.saving);
         assert.equal(alertStub.lastCall.args[0], 'All changes saved');
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-        assert.equal(element._content, element._newContent);
-        assert.isTrue(element._successfulSave);
+        assert.isTrue(
+          query<GrButton>(element, '#save')!.hasAttribute('disabled')
+        );
+        assert.equal(element.content, element.newContent);
+        assert.isTrue(element.successfulSave);
         assert.isTrue(navigateStub.called);
       });
     });
 
-    test('file modification and publish', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const alertStub = sinon.stub(element, '_showAlert');
+    test('file modification and publish', async () => {
+      const saveSpy = sinon.spy(element, 'saveEdit');
+      const alertStub = sinon.stub(element, 'showAlert');
       const changeActionsStub = stubRestApi('executeChangeAction');
       saveFileStub.returns(Promise.resolve({ok: true}));
-      element._newContent = newText;
-      flush();
+      element.newContent = newText;
+      await element.updateComplete;
 
-      assert.isFalse(element._saving);
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(element.saving);
+      assert.isFalse(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
-      MockInteractions.tap(element.$.publish);
+      query<GrButton>(element, '#publish')!.click();
       assert.isTrue(saveSpy.called);
       assert.equal(alertStub.getCall(0).args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
+      assert.isTrue(element.saving);
+      await element.updateComplete;
+      assert.isTrue(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
       return saveSpy.lastCall.returnValue.then(() => {
         assert.isTrue(saveFileStub.called);
-        assert.isFalse(element._saving);
+        assert.isFalse(element.saving);
 
         assert.equal(alertStub.getCall(1).args[0], 'All changes saved');
         assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...');
 
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-        assert.equal(element._content, element._newContent);
-        assert.isTrue(element._successfulSave);
+        assert.isTrue(
+          query<GrButton>(element, '#save')!.hasAttribute('disabled')
+        );
+        assert.equal(element.content, element.newContent);
+        assert.isTrue(element.successfulSave);
         assert.isFalse(navigateStub.called);
 
         const args = changeActionsStub.lastCall.args;
@@ -243,26 +337,28 @@
       });
     });
 
-    test('file modification and close', () => {
-      const closeSpy = sinon.spy(element, '_handleCloseTap');
-      element._newContent = newText;
-      flush();
+    test('file modification and close', async () => {
+      const closeSpy = sinon.spy(element, 'handleCloseTap');
+      element.newContent = newText;
+      await element.updateComplete;
 
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(
+        query<GrButton>(element, '#save')!.hasAttribute('disabled')
+      );
 
-      MockInteractions.tap(element.$.close);
+      query<GrButton>(element, '#close')!.click();
       assert.isTrue(closeSpy.called);
       assert.isFalse(saveFileStub.called);
       assert.isTrue(navigateStub.called);
     });
   });
 
-  suite('_getFileData', () => {
+  suite('getFileData', () => {
     setup(() => {
-      element._newContent = 'initial';
-      element._content = 'initial';
-      element._type = 'initial';
-      stubStorage('getEditableContentItem').returns(null);
+      element.newContent = 'initial';
+      element.content = 'initial';
+      element.type = 'initial';
+      sinon.stub(storageService, 'getEditableContentItem').returns(null);
     });
 
     test('res.ok', () => {
@@ -273,38 +369,38 @@
           content: 'new content',
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        editView: {path: 'test/path'},
+      };
 
       // Ensure no data is set with a bad response.
-      return element
-        ._getFileData(
-          1 as NumericChangeId,
-          'test/path',
-          EditPatchSetNum as PatchSetNum
-        )
-        .then(() => {
-          assert.equal(element._newContent, 'new content');
-          assert.equal(element._content, 'new content');
-          assert.equal(element._type, 'text/javascript');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, 'new content');
+        assert.equal(element.content, 'new content');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('!res.ok', () => {
       stubRestApi('getFileContent').returns(
         Promise.resolve(new Response(null, {status: 500}))
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        editView: {path: 'test/path'},
+      };
 
       // Ensure no data is set with a bad response.
-      return element
-        ._getFileData(
-          1 as NumericChangeId,
-          'test/path',
-          EditPatchSetNum as PatchSetNum
-        )
-        .then(() => {
-          assert.equal(element._newContent, '');
-          assert.equal(element._content, '');
-          assert.equal(element._type, '');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, '');
+        assert.equal(element.content, '');
+        assert.equal(element.type, '');
+      });
     });
 
     test('content is undefined', () => {
@@ -315,97 +411,101 @@
           type: 'text/javascript' as ResponseType,
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        editView: {path: 'test/path'},
+      };
 
-      return element
-        ._getFileData(
-          1 as NumericChangeId,
-          'test/path',
-          EditPatchSetNum as PatchSetNum
-        )
-        .then(() => {
-          assert.equal(element._newContent, '');
-          assert.equal(element._content, '');
-          assert.equal(element._type, 'text/javascript');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, '');
+        assert.equal(element.content, '');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('content and type is undefined', () => {
       stubRestApi('getFileContent').returns(
         Promise.resolve({...new Response(), ok: true})
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: EDIT,
+        editView: {path: 'test/path'},
+      };
 
-      return element
-        ._getFileData(
-          1 as NumericChangeId,
-          'test/path',
-          EditPatchSetNum as PatchSetNum
-        )
-        .then(() => {
-          assert.equal(element._newContent, '');
-          assert.equal(element._content, '');
-          assert.equal(element._type, '');
-        });
+      return element.getFileData().then(() => {
+        assert.equal(element.newContent, '');
+        assert.equal(element.content, '');
+        assert.equal(element.type, '');
+      });
     });
   });
 
-  test('_showAlert', async () => {
+  test('showAlert', async () => {
     const promise = mockPromise();
-    element.addEventListener('show-alert', e => {
-      assert.deepEqual(e.detail, {message: 'test message'});
+    element.addEventListener(EventType.SHOW_ALERT, e => {
+      assert.deepEqual(e.detail, {message: 'test message', showDismiss: true});
       assert.isTrue(e.bubbles);
       promise.resolve();
     });
 
-    element._showAlert('test message');
+    element.showAlert('test message');
     await promise;
   });
 
-  test('_viewEditInChangeView', () => {
-    element._change = createChangeViewChange();
+  test('viewEditInChangeView', () => {
+    element.change = createChangeViewChange();
     navigateStub.restore();
-    const navStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._patchNum = EditPatchSetNum;
-    element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], undefined);
-    assert.equal(navStub.lastCall.args[3], true);
+    const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+    element.viewEditInChangeView();
+
+    assert.isTrue(setUrlStub.called);
+    assert.equal(
+      setUrlStub.lastCall.firstArg,
+      '/c/test-project/+/42,edit?forceReload=true'
+    );
   });
 
   suite('keyboard shortcuts', () => {
     // Used as the spy on the handler for each entry in keyBindings.
     let handleSpy: sinon.SinonSpy;
 
-    suite('_handleSaveShortcut', () => {
+    suite('handleSaveShortcut', () => {
       let saveStub: sinon.SinonStub;
       setup(() => {
-        handleSpy = sinon.spy(element, '_handleSaveShortcut');
-        saveStub = sinon.stub(element, '_saveEdit');
+        handleSpy = sinon.spy(element, 'handleSaveShortcut');
+        saveStub = sinon.stub(element, 'saveEdit');
       });
 
-      test('save enabled', () => {
-        element._content = '';
-        element._newContent = '_test';
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flush();
+      test('save enabled', async () => {
+        element.content = '';
+        element.newContent = '_test';
+        pressKey(element, 's', Modifier.CTRL_KEY);
+        await element.updateComplete;
 
         assert.isTrue(handleSpy.calledOnce);
         assert.isTrue(saveStub.calledOnce);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flush();
+        pressKey(element, 's', Modifier.META_KEY);
+        await element.updateComplete;
 
         assert.equal(handleSpy.callCount, 2);
         assert.equal(saveStub.callCount, 2);
       });
 
-      test('save disabled', () => {
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flush();
+      test('save disabled', async () => {
+        pressKey(element, 's', Modifier.CTRL_KEY);
+        await element.updateComplete;
 
         assert.isTrue(handleSpy.calledOnce);
         assert.isFalse(saveStub.called);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flush();
+        pressKey(element, 's', Modifier.META_KEY);
+        await element.updateComplete;
 
         assert.equal(handleSpy.callCount, 2);
         assert.isFalse(saveStub.called);
@@ -415,7 +515,7 @@
 
   suite('gr-storage caching', () => {
     test('local edit exists', () => {
-      stubStorage('getEditableContentItem').returns({
+      sinon.stub(storageService, 'getEditableContentItem').returns({
         message: 'pending edit',
         updated: 0,
       });
@@ -426,24 +526,28 @@
           content: 'old content',
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: 1 as RevisionPatchSetNum,
+        editView: {path: 'test'},
+      };
 
       const alertStub = sinon.stub();
-      element.addEventListener('show-alert', alertStub);
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
 
-      return element
-        ._getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
-        .then(() => {
-          flush();
+      return element.getFileData().then(async () => {
+        await element.updateComplete;
 
-          assert.isTrue(alertStub.called);
-          assert.equal(element._newContent, 'pending edit');
-          assert.equal(element._content, 'old content');
-          assert.equal(element._type, 'text/javascript');
-        });
+        assert.isTrue(alertStub.called);
+        assert.equal(element.newContent, 'pending edit');
+        assert.equal(element.content, 'old content');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('local edit exists, is same as remote edit', () => {
-      stubStorage('getEditableContentItem').returns({
+      sinon.stub(storageService, 'getEditableContentItem').returns({
         message: 'pending edit',
         updated: 0,
       });
@@ -454,26 +558,33 @@
           content: 'pending edit',
         })
       );
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: 1 as RevisionPatchSetNum,
+        editView: {path: 'test'},
+      };
 
       const alertStub = sinon.stub();
-      element.addEventListener('show-alert', alertStub);
+      element.addEventListener(EventType.SHOW_ALERT, alertStub);
 
-      return element
-        ._getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
-        .then(() => {
-          flush();
+      return element.getFileData().then(async () => {
+        await element.updateComplete;
 
-          assert.isFalse(alertStub.called);
-          assert.equal(element._newContent, 'pending edit');
-          assert.equal(element._content, 'pending edit');
-          assert.equal(element._type, 'text/javascript');
-        });
+        assert.isFalse(alertStub.called);
+        assert.equal(element.newContent, 'pending edit');
+        assert.equal(element.content, 'pending edit');
+        assert.equal(element.type, 'text/javascript');
+      });
     });
 
     test('storage key computation', () => {
-      element._changeNum = 1 as NumericChangeId;
-      element._patchNum = 1 as PatchSetNum;
-      element._path = 'test';
+      element.viewState = {
+        ...createEditViewState(),
+        changeNum: 1 as NumericChangeId,
+        patchNum: 1 as RevisionPatchSetNum,
+        editView: {path: 'test'},
+      };
       assert.equal(element.storageKey, 'c1_ps1_test');
     });
   });
diff --git a/polygerrit-ui/app/elements/font-roboto-local-loader.ts b/polygerrit-ui/app/elements/font-roboto-local-loader.ts
index 1be72d2..ebd7f6b 100644
--- a/polygerrit-ui/app/elements/font-roboto-local-loader.ts
+++ b/polygerrit-ui/app/elements/font-roboto-local-loader.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // Place all code related to font-roboto-local here
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 7f7749a..eceb05e 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -1,22 +1,14 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../styles/shared-styles';
 import '../styles/themes/app-theme';
-import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme';
+import '../styles/themes/dark-theme';
+import {
+  applyTheme as applyDarkTheme,
+  removeTheme as removeDarkTheme,
+} from '../styles/themes/dark-theme';
 import './admin/gr-admin-view/gr-admin-view';
 import './documentation/gr-documentation-search/gr-documentation-search';
 import './change-list/gr-change-list-view/gr-change-list-view';
@@ -25,41 +17,26 @@
 import './core/gr-error-manager/gr-error-manager';
 import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog';
 import './core/gr-main-header/gr-main-header';
-import './core/gr-router/gr-router';
 import './core/gr-smart-search/gr-smart-search';
 import './diff/gr-diff-view/gr-diff-view';
 import './edit/gr-editor-view/gr-editor-view';
 import './plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import './plugins/gr-endpoint-param/gr-endpoint-param';
 import './plugins/gr-endpoint-slot/gr-endpoint-slot';
-import './plugins/gr-external-style/gr-external-style';
 import './plugins/gr-plugin-host/gr-plugin-host';
 import './settings/gr-cla-view/gr-cla-view';
 import './settings/gr-registration-dialog/gr-registration-dialog';
 import './settings/gr-settings-view/gr-settings-view';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-app-element_html';
+import './core/gr-notifications-prompt/gr-notifications-prompt';
 import {getBaseUrl} from '../utils/url-util';
-import {
-  KeyboardShortcutMixin,
-  Shortcut,
-  ShortcutListener,
-} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-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 {GrRouter} from './core/gr-router/gr-router';
-import {
-  AccountDetailInfo,
-  ElementPropertyDeepChange,
-  ServerInfo,
-} from '../types/common';
+import {navigationToken} from './core/gr-navigation/gr-navigation';
+import {getAppContext} from '../services/app-context';
+import {routerToken} from './core/gr-router/gr-router';
+import {AccountDetailInfo} from '../types/common';
 import {
   constructServerErrorMsg,
   GrErrorManager,
 } from './core/gr-error-manager/gr-error-manager';
-import {GrOverlay} from './shared/gr-overlay/gr-overlay';
 import {GrRegistrationDialog} from './settings/gr-registration-dialog/gr-registration-dialog';
 import {
   AppElementJustRegisteredParams,
@@ -71,17 +48,33 @@
 import {
   DialogChangeEventDetail,
   EventType,
-  LocationChangeEvent,
   PageErrorEventDetail,
   RpcLogEvent,
   TitleChangeEventDetail,
 } from '../types/events';
-import {ViewState} from '../types/types';
-import {GerritView} from '../services/router/router-model';
+import {GerritView, routerModelToken} from '../services/router/router-model';
 import {LifeCycle} from '../constants/reporting';
 import {fireIronAnnounce} from '../utils/event-util';
+import {resolve} from '../models/dependency';
+import {browserModelToken} from '../models/browser/browser-model';
+import {sharedStyles} from '../styles/shared-styles';
+import {LitElement, PropertyValues, html, css, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {Shortcut, ShortcutController} from './lit/shortcut-controller';
+import {cache} from 'lit/directives/cache.js';
 import {assertIsDefined} from '../utils/common-util';
-import {listen} from '../services/shortcuts/shortcuts-service';
+import './gr-css-mixins';
+import {isDarkTheme, prefersDarkColorScheme} from '../utils/theme-util';
+import {AppTheme} from '../constants/constants';
+import {subscribe} from './lit/subscription-controller';
+import {PluginViewState} from '../models/views/plugin';
+import {createSearchUrl, SearchViewState} from '../models/views/search';
+import {createSettingsUrl} from '../models/views/settings';
+import {createDashboardUrl} from '../models/views/dashboard';
+import {userModelToken} from '../models/user/user-model';
+import {modalStyles} from '../styles/gr-modal-styles';
+import {AdminChildView, createAdminUrl} from '../models/views/admin';
+import {ChangeChildView, changeViewModelToken} from '../models/views/change';
 
 interface ErrorInfo {
   text: string;
@@ -89,335 +82,594 @@
   moreInfo?: string;
 }
 
-export interface GrAppElement {
-  $: {
-    router: GrRouter;
-    errorManager: GrErrorManager;
-    errorView: HTMLDivElement;
-    mainHeader: GrMainHeader;
-  };
-}
-
-type DomIf = PolymerElement & {
-  restamp: boolean;
-};
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
+/**
+ * This is simple hacky way for allowing certain plugin screens to hide the
+ * header and the footer of the Gerrit page.
+ */
+const WHITE_LISTED_FULL_SCREEN_PLUGINS = ['git_source_editor/screen/edit'];
 
 // TODO(TS): implement AppElement interface from gr-app-types.ts
 @customElement('gr-app-element')
-export class GrAppElement extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAppElement extends LitElement {
   /**
    * Fired when the URL location changes.
    *
    * @event location-change
    */
 
+  @query('#errorManager') errorManager?: GrErrorManager;
+
+  @query('#errorView') errorView?: HTMLDivElement;
+
+  @query('#mainHeader') mainHeader?: GrMainHeader;
+
+  @query('#registrationModal') registrationModal?: HTMLDialogElement;
+
+  @query('#registrationDialog') registrationDialog?: GrRegistrationDialog;
+
+  @query('#keyboardShortcuts') keyboardShortcuts?: HTMLDialogElement;
+
+  @query('gr-settings-view') settingsView?: GrSettingsView;
+
   @property({type: Object})
   params?: AppElementParams;
 
-  @property({type: Object, observer: '_accountChanged'})
-  _account?: AccountDetailInfo;
+  @state() private account?: AccountDetailInfo;
 
-  @property({type: Number})
-  _lastGKeyPressTimestamp: number | null = null;
+  @state() private version?: string;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  @state() private view?: GerritView;
 
-  @property({type: String})
-  _version?: string;
+  // TODO: Introduce a wrapper element for CHANGE, DIFF, EDIT view.
+  @state() private childView?: ChangeChildView;
 
-  @property({type: Boolean})
-  _showChangeListView?: boolean;
+  @state() private lastError?: ErrorInfo;
 
-  @property({type: Boolean})
-  _showDashboardView?: boolean;
+  // private but used in test
+  @state() lastSearchPage?: string;
 
-  @property({type: Boolean})
-  _showChangeView?: boolean;
+  @state() private settingsUrl?: string;
 
-  @property({type: Boolean})
-  _showDiffView?: boolean;
+  @state() private mobileSearch = false;
 
-  @property({type: Boolean})
-  _showSettingsView?: boolean;
+  @state() private loginUrl = '/login';
 
-  @property({type: Boolean})
-  _showAdminView?: boolean;
+  @state() private loadRegistrationDialog = false;
 
-  @property({type: Boolean})
-  _showCLAView?: boolean;
-
-  @property({type: Boolean})
-  _showEditorView?: boolean;
-
-  @property({type: Boolean})
-  _showPluginScreen?: boolean;
-
-  @property({type: Boolean})
-  _showDocumentationSearch?: boolean;
-
-  @property({type: Object})
-  _viewState?: ViewState;
-
-  @property({type: Object})
-  _lastError?: ErrorInfo;
-
-  @property({type: String})
-  _lastSearchPage?: string;
-
-  @property({type: String})
-  _path?: string;
-
-  @property({type: String, computed: '_computePluginScreenName(params)'})
-  _pluginScreenName?: string;
-
-  @property({type: String})
-  _settingsUrl?: string;
-
-  @property({type: String})
-  _feedbackUrl?: string;
-
-  @property({type: Boolean})
-  mobileSearch = false;
-
-  @property({type: String})
-  _loginUrl = '/login';
-
-  @property({type: Boolean})
-  loadRegistrationDialog = false;
-
-  @property({type: Boolean})
-  loadKeyboardShortcutsDialog = false;
+  @state() private 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;
+  @state() private 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;
+  @state() private mainAriaHidden = false;
 
-  private reporting = appContext.reportingService;
+  // Triggers dom-if unsetting/setting restamp behaviour in lit
+  @state() private invalidateChangeViewCache = false;
 
-  private readonly restApiService = appContext.restApiService;
+  // Triggers dom-if unsetting/setting restamp behaviour in lit
+  @state() private invalidateDiffViewCache = false;
 
-  private readonly browserService = appContext.browserService;
+  @state() private theme = AppTheme.AUTO;
 
-  override keyboardShortcuts(): ShortcutListener[] {
-    return [
-      listen(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, _ =>
-        this._showKeyboardShortcuts()
-      ),
-      listen(Shortcut.GO_TO_USER_DASHBOARD, _ => this._goToUserDashboard()),
-      listen(Shortcut.GO_TO_OPENED_CHANGES, _ => this._goToOpenedChanges()),
-      listen(Shortcut.GO_TO_MERGED_CHANGES, _ => this._goToMergedChanges()),
-      listen(Shortcut.GO_TO_ABANDONED_CHANGES, _ =>
-        this._goToAbandonedChanges()
-      ),
-      listen(Shortcut.GO_TO_WATCHED_CHANGES, _ => this._goToWatchedChanges()),
-    ];
-  }
+  readonly getRouter = resolve(this, routerToken);
+
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  private reporting = getAppContext().reportingService;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getBrowserModel = resolve(this, browserModelToken);
+
+  private readonly shortcuts = new ShortcutController(this);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getRouterModel = resolve(this, routerModelToken);
+
+  private readonly getChangeViewModel = resolve(this, changeViewModelToken);
 
   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;
+
     document.addEventListener(EventType.PAGE_ERROR, e => {
-      this._handlePageError(e);
+      this.handlePageError(e);
     });
     this.addEventListener(EventType.TITLE_CHANGE, e => {
-      this._handleTitleChange(e);
+      this.handleTitleChange(e);
     });
     this.addEventListener(EventType.DIALOG_CHANGE, e => {
-      this._handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
+      this.handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
     });
-    this.addEventListener(EventType.LOCATION_CHANGE, e =>
-      this._handleLocationChange(e)
+    document.addEventListener(EventType.LOCATION_CHANGE, () =>
+      this.handleLocationChange()
     );
     this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
-      this.handleRecreateView(GerritView.CHANGE)
+      this.handleRecreateView()
     );
     this.addEventListener(EventType.RECREATE_DIFF_VIEW, () =>
-      this.handleRecreateView(GerritView.DIFF)
+      this.handleRecreateView()
     );
-    document.addEventListener(EventType.GR_RPC_LOG, e => this._handleRpcLog(e));
-    const resizeObserver = this.browserService.observeWidth();
-    resizeObserver.observe(this);
+    document.addEventListener(EventType.GR_RPC_LOG, e => this.handleRpcLog(e));
+    this.shortcuts.addAbstract(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, () =>
+      this.showKeyboardShortcuts()
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_USER_DASHBOARD, () =>
+      this.getNavigation().setUrl(createDashboardUrl({user: 'self'}))
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_OPENED_CHANGES, () =>
+      this.getNavigation().setUrl(createSearchUrl({statuses: ['open']}))
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_MERGED_CHANGES, () =>
+      this.getNavigation().setUrl(createSearchUrl({statuses: ['merged']}))
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_ABANDONED_CHANGES, () =>
+      this.getNavigation().setUrl(createSearchUrl({statuses: ['abandoned']}))
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_WATCHED_CHANGES, () =>
+      this.getNavigation().setUrl(
+        createSearchUrl({query: 'is:watched is:open'})
+      )
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_REPOS, () =>
+      this.getNavigation().setUrl(
+        createAdminUrl({adminView: AdminChildView.REPOS})
+      )
+    );
+    this.shortcuts.addAbstract(Shortcut.GO_TO_GROUPS, () =>
+      this.getNavigation().setUrl(
+        createAdminUrl({adminView: AdminChildView.GROUPS})
+      )
+    );
+
+    subscribe(
+      this,
+      () => this.getUserModel().preferenceTheme$,
+      theme => {
+        this.theme = theme;
+        this.applyTheme();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getRouterModel().routerView$,
+      view => {
+        this.view = view;
+        if (view) this.errorView?.classList.remove('show');
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeViewModel().childView$,
+      childView => {
+        this.childView = childView;
+      }
+    );
+
+    prefersDarkColorScheme().addEventListener('change', () => {
+      if (this.theme === AppTheme.AUTO) {
+        this.applyTheme();
+      }
+    });
   }
 
-  override ready() {
-    super.ready();
-    this._updateLoginUrl();
+  override connectedCallback() {
+    super.connectedCallback();
+    const resizeObserver = this.getBrowserModel().observeWidth();
+    resizeObserver.observe(this);
+
+    this.updateLoginUrl();
     this.reporting.appStarted();
-    this.$.router.start();
+    this.getRouter().start();
 
     this.restApiService.getAccount().then(account => {
-      this._account = account;
+      this.account = account;
       if (account) {
         this.reporting.reportLifeCycle(LifeCycle.STARTED_AS_USER);
       } else {
         this.reporting.reportLifeCycle(LifeCycle.STARTED_AS_GUEST);
       }
     });
-    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.restApiService.getVersion().then(version => {
-      this._version = version;
-      this._logWelcome();
+      this.version = version;
+      this.logWelcome();
     });
 
-    if (window.localStorage.getItem('dark-theme')) {
-      applyDarkTheme();
-    }
-
     // Note: this is evaluated here to ensure that it only happens after the
     // router has been initialized. @see Issue 7837
-    this._settingsUrl = GerritNav.getUrlForSettings();
-
-    this._viewState = {
-      changeView: {
-        changeNum: null,
-        patchRange: null,
-        selectedFileIndex: 0,
-        showReplyDialog: false,
-        showDownloadDialog: false,
-        diffMode: null,
-        numFilesShown: null,
-      },
-      changeListView: {
-        query: null,
-        offset: 0,
-        selectedChangeIndex: 0,
-      },
-      dashboardView: {},
-    };
+    this.settingsUrl = createSettingsUrl();
   }
 
-  _accountChanged(account?: AccountDetailInfo) {
-    if (!account) return;
+  static override get styles() {
+    return [
+      sharedStyles,
+      modalStyles,
+      css`
+        :host {
+          background-color: var(--background-color-tertiary);
+          display: flex;
+          flex-direction: column;
+          min-height: 100%;
+        }
+        gr-main-header,
+        footer {
+          color: var(--primary-text-color);
+        }
+        gr-main-header {
+          background: var(
+            --header-background,
+            var(--header-background-color, #eee)
+          );
+          padding: var(--header-padding);
+          border-bottom: var(--header-border-bottom);
+          border-image: var(--header-border-image);
+          border-right: 0;
+          border-left: 0;
+          border-top: 0;
+          box-shadow: var(--header-box-shadow);
+          /* Make sure the header is above the main content, to preserve box-shadow
+            visibility. We need 2 here instead of 1, because dropdowns in the
+            header should be shown on top of the sticky diff header, which has a
+            z-index of 1. */
+          z-index: 2;
+        }
+        footer {
+          background: var(
+            --footer-background,
+            var(--footer-background-color, #eee)
+          );
+          border-top: var(--footer-border-top);
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-m) var(--spacing-l);
+          z-index: 100;
+        }
+        main {
+          flex: 1;
+          padding-bottom: var(--spacing-xxl);
+          position: relative;
+        }
+        .errorView {
+          align-items: center;
+          display: none;
+          flex-direction: column;
+          justify-content: center;
+          position: absolute;
+          top: 0;
+          right: 0;
+          bottom: 0;
+          left: 0;
+        }
+        .errorView.show {
+          display: flex;
+        }
+        .errorEmoji {
+          font-size: 2.6rem;
+        }
+        .errorText,
+        .errorMoreInfo {
+          margin-top: var(--spacing-m);
+        }
+        .errorText {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+        }
+        .errorMoreInfo {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-css-mixins></gr-css-mixins>
+      <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
+      ${this.renderHeader()}
+      <main ?aria-hidden=${this.mainAriaHidden}>
+        ${this.renderMobileSearch()} ${this.renderChangeListView()}
+        ${this.renderDashboardView()} ${this.renderChangeView()}
+        ${this.renderEditorView()} ${this.renderDiffView()}
+        ${this.renderSettingsView()} ${this.renderAdminView()}
+        ${this.renderPluginScreen()} ${this.renderCLAView()}
+        ${this.renderDocumentationSearch()}
+        <div id="errorView" class="errorView">
+          <div class="errorEmoji">${this.lastError?.emoji}</div>
+          <div class="errorText">${this.lastError?.text}</div>
+          <div class="errorMoreInfo">${this.lastError?.moreInfo}</div>
+        </div>
+      </main>
+      ${this.renderFooter()} ${this.renderKeyboardShortcutsDialog()}
+      ${this.renderRegistrationDialog()}
+      <gr-notifications-prompt></gr-notifications-prompt>
+      <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
+      <gr-error-manager
+        id="errorManager"
+        .loginUrl=${this.loginUrl}
+      ></gr-error-manager>
+      <gr-plugin-host id="plugins"></gr-plugin-host>
+    `;
+  }
+
+  private renderHeader() {
+    if (this.hideHeaderAndFooter()) return nothing;
+    return html`
+      <gr-main-header
+        id="mainHeader"
+        .searchQuery=${(this.params as SearchViewState)?.query}
+        @mobile-search=${this.mobileSearchToggle}
+        @show-keyboard-shortcuts=${this.showKeyboardShortcuts}
+        .mobileSearchHidden=${!this.mobileSearch}
+        .loginUrl=${this.loginUrl}
+        ?aria-hidden=${this.footerHeaderAriaHidden}
+      >
+      </gr-main-header>
+    `;
+  }
+
+  private renderFooter() {
+    if (this.hideHeaderAndFooter()) return nothing;
+    return html`
+      <footer ?aria-hidden=${this.footerHeaderAriaHidden}>
+        <div>
+          Powered by
+          <a
+            href="https://www.gerritcodereview.com/"
+            rel="noopener"
+            target="_blank"
+            >Gerrit Code Review</a
+          >
+          (${this.version})
+          <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
+        </div>
+        <div>
+          Press “?” for keyboard shortcuts
+          <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
+        </div>
+      </footer>
+    `;
+  }
+
+  private hideHeaderAndFooter() {
+    return (
+      this.view === GerritView.PLUGIN_SCREEN &&
+      WHITE_LISTED_FULL_SCREEN_PLUGINS.includes(this.computePluginScreenName())
+    );
+  }
+
+  private renderMobileSearch() {
+    if (!this.mobileSearch) return nothing;
+    return html`
+      <gr-smart-search
+        id="search"
+        label="Search for changes"
+        .searchQuery=${(this.params as SearchViewState)?.query}
+      >
+      </gr-smart-search>
+    `;
+  }
+
+  private renderChangeListView() {
+    return cache(
+      this.view === GerritView.SEARCH
+        ? html` <gr-change-list-view></gr-change-list-view> `
+        : nothing
+    );
+  }
+
+  private renderDashboardView() {
+    return cache(
+      this.view === GerritView.DASHBOARD
+        ? html`<gr-dashboard-view></gr-dashboard-view>`
+        : nothing
+    );
+  }
+
+  private renderChangeView() {
+    if (this.invalidateChangeViewCache) {
+      this.updateComplete.then(() => (this.invalidateChangeViewCache = false));
+      return nothing;
+    }
+    return cache(this.isChangeView() ? this.changeViewTemplate() : nothing);
+  }
+
+  // Template as not to create duplicates, for renderChangeView() only.
+  private changeViewTemplate() {
+    return html`
+      <gr-change-view .backPage=${this.lastSearchPage}></gr-change-view>
+    `;
+  }
+
+  private isChangeView() {
+    return (
+      this.view === GerritView.CHANGE &&
+      this.childView === ChangeChildView.OVERVIEW
+    );
+  }
+
+  private isDiffView() {
+    return (
+      this.view === GerritView.CHANGE && this.childView === ChangeChildView.DIFF
+    );
+  }
+
+  private isEditorView() {
+    return (
+      this.view === GerritView.CHANGE && this.childView === ChangeChildView.EDIT
+    );
+  }
+
+  private renderEditorView() {
+    if (!this.isEditorView()) return nothing;
+    return html`<gr-editor-view></gr-editor-view>`;
+  }
+
+  private renderDiffView() {
+    if (this.invalidateDiffViewCache) {
+      this.updateComplete.then(() => (this.invalidateDiffViewCache = false));
+      return nothing;
+    }
+    return cache(this.isDiffView() ? this.diffViewTemplate() : nothing);
+  }
+
+  private diffViewTemplate() {
+    return html`<gr-diff-view></gr-diff-view>`;
+  }
+
+  private renderSettingsView() {
+    if (this.view !== GerritView.SETTINGS) return nothing;
+    return html`
+      <gr-settings-view
+        @account-detail-update=${this.handleAccountDetailUpdate}
+      >
+      </gr-settings-view>
+    `;
+  }
+
+  private renderAdminView() {
+    if (
+      this.view !== GerritView.ADMIN &&
+      this.view !== GerritView.GROUP &&
+      this.view !== GerritView.REPO
+    )
+      return nothing;
+    return html`<gr-admin-view></gr-admin-view>`;
+  }
+
+  private renderPluginScreen() {
+    if (this.view !== GerritView.PLUGIN_SCREEN) return nothing;
+    const pluginViewState = this.params as PluginViewState;
+    return html`
+      <gr-endpoint-decorator .name=${this.computePluginScreenName()}>
+        <gr-endpoint-param
+          name="token"
+          .value=${pluginViewState.screen}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderCLAView() {
+    if (this.view !== GerritView.AGREEMENTS) return nothing;
+    return html`<gr-cla-view></gr-cla-view>`;
+  }
+
+  private renderDocumentationSearch() {
+    if (this.view !== GerritView.DOCUMENTATION_SEARCH) return nothing;
+    return html`<gr-documentation-search></gr-documentation-search>`;
+  }
+
+  private renderKeyboardShortcutsDialog() {
+    if (!this.loadKeyboardShortcutsDialog) return nothing;
+    return html`
+      <dialog
+        id="keyboardShortcuts"
+        tabindex="-1"
+        @close=${this.onModalCanceled}
+      >
+        <gr-keyboard-shortcuts-dialog
+          @close=${this.handleKeyboardShortcutDialogClose}
+        ></gr-keyboard-shortcuts-dialog>
+      </dialog>
+    `;
+  }
+
+  private renderRegistrationDialog() {
+    if (!this.loadRegistrationDialog) return nothing;
+    return html`
+      <dialog id="registrationModal" tabindex="-1">
+        <gr-registration-dialog
+          id="registrationDialog"
+          .settingsUrl=${this.settingsUrl}
+          @account-detail-update=${this.handleAccountDetailUpdate}
+          @close=${this.handleRegistrationDialogClose}
+        >
+        </gr-registration-dialog>
+      </dialog>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('account')) {
+      this.accountChanged();
+    }
+
+    if (changedProperties.has('params')) {
+      this.viewChanged();
+      this.paramsChanged();
+    }
+  }
+
+  private accountChanged() {
+    if (!this.account) return;
 
     // Preferences are cached when a user is logged in; warm them.
     this.restApiService.getPreferences();
     this.restApiService.getDiffPreferences();
     this.restApiService.getEditPreferences();
-    this.$.errorManager.knownAccountId =
-      (this._account && this._account._account_id) || null;
+    if (this.errorManager)
+      this.errorManager.knownAccountId =
+        (this.account && this.account._account_id) || null;
   }
 
   /**
    * Throws away the view and re-creates it. The view itself fires an event, if
    * it wants to be re-created.
    */
-  private handleRecreateView(view: GerritView.DIFF | GerritView.CHANGE) {
-    const isDiff = view === GerritView.DIFF;
-    const domId = isDiff ? '#dom-if-diff-view' : '#dom-if-change-view';
-    const domIf = this.root!.querySelector(domId) as DomIf;
-    assertIsDefined(domIf, '<dom-if> for the view');
-    // The rendering of DomIf is debounced, so just changing _show...View and
-    // restamp properties back and forth won't work. That is why we are using
-    // timeouts.
-    // The first timeout is needed, because the _viewChanged() observer also
-    // affects _show...View and would change _show...View=false directly back to
-    // _show...View=true.
-    setTimeout(() => {
-      this._showChangeView = false;
-      this._showDiffView = false;
-      domIf.restamp = true;
-      setTimeout(() => {
-        this._showChangeView = this.params?.view === GerritView.CHANGE;
-        this._showDiffView = this.params?.view === GerritView.DIFF;
-        domIf.restamp = false;
-      }, 1);
-    }, 1);
+  private handleRecreateView() {
+    this.invalidateChangeViewCache = true;
+    this.invalidateDiffViewCache = true;
   }
 
-  @observe('params.*')
-  _viewChanged() {
-    const view = this.params?.view;
-    this.$.errorView.classList.remove('show');
-    this._showChangeListView = view === GerritView.SEARCH;
-    this._showDashboardView = view === GerritView.DASHBOARD;
-    this._showChangeView = view === GerritView.CHANGE;
-    this._showDiffView = view === GerritView.DIFF;
-    this._showSettingsView = view === GerritView.SETTINGS;
-    // _showAdminView must be in sync with the gr-admin-view AdminViewParams type
-    this._showAdminView =
-      view === GerritView.ADMIN ||
-      view === GerritView.GROUP ||
-      view === GerritView.REPO;
-    this._showCLAView = view === GerritView.AGREEMENTS;
-    this._showEditorView = view === GerritView.EDIT;
-    const isPluginScreen = view === GerritView.PLUGIN_SCREEN;
-    this._showPluginScreen = false;
-    // Navigation within plugin screens does not restamp gr-endpoint-decorator
-    // because _showPluginScreen value does not change. To force restamp,
-    // change _showPluginScreen value between true and false.
-    if (isPluginScreen) {
-      setTimeout(() => (this._showPluginScreen = true), 1);
-    }
-    this._showDocumentationSearch = view === GerritView.DOCUMENTATION_SEARCH;
+  private async viewChanged() {
     if (
       this.params &&
       isAppElementJustRegisteredParams(this.params) &&
       this.params.justRegistered
     ) {
       this.loadRegistrationDialog = true;
-      flush();
-      const registrationOverlay = this.shadowRoot!.querySelector(
-        '#registrationOverlay'
-      ) as GrOverlay;
-      const registrationDialog = this.shadowRoot!.querySelector(
-        '#registrationDialog'
-      ) as GrRegistrationDialog;
-      registrationOverlay.open();
-      registrationDialog.loadData().then(() => {
-        registrationOverlay.refit();
-      });
+      await this.updateComplete;
+      assertIsDefined(this.registrationModal, 'registrationModal');
+      assertIsDefined(this.registrationDialog, 'registrationDialog');
+      this.registrationModal.showModal();
+      await this.registrationDialog.loadData();
     }
     // To fix bug announce read after each new view, we reset announce with
     // empty space
     fireIronAnnounce(this, ' ');
   }
 
-  _handlePageError(e: CustomEvent<PageErrorEventDetail>) {
-    const props = [
-      '_showChangeListView',
-      '_showDashboardView',
-      '_showChangeView',
-      '_showDiffView',
-      '_showSettingsView',
-      '_showAdminView',
-    ];
-    for (const showProp of props) {
-      this.set(showProp, false);
+  private applyTheme() {
+    const showDarkTheme = isDarkTheme(this.theme);
+    document.documentElement.classList.toggle('darkTheme', showDarkTheme);
+    document.documentElement.classList.toggle('lightTheme', !showDarkTheme);
+    // TODO: Remove this code for adding/removing dark theme style. We should
+    // be able to just always add them once we have changed its css selector
+    // from `html` to `html.darkTheme`.
+    if (showDarkTheme) {
+      applyDarkTheme();
+    } else {
+      removeDarkTheme();
     }
+  }
 
-    this.$.errorView.classList.add('show');
+  private handlePageError(e: CustomEvent<PageErrorEventDetail>) {
+    this.view = undefined;
+    this.errorView?.classList.add('show');
     const response = e.detail.response;
     const err: ErrorInfo = {
       text: [response?.status, response?.statusText].join(' '),
     };
     if (response?.status === 404) {
       err.emoji = '¯\\_(ツ)_/¯';
-      this._lastError = err;
+      this.lastError = err;
     } else {
       err.emoji = 'o_O';
       if (response) {
@@ -431,29 +683,22 @@
             errorText: text,
             trace,
           });
-          this._lastError = err;
+          this.lastError = err;
         });
       }
     }
   }
 
-  _handleLocationChange(e: LocationChangeEvent) {
-    this._updateLoginUrl();
-
-    const hash = e.detail.hash.substring(1);
-    let pathname = e.detail.pathname;
-    if (pathname.startsWith('/c/') && Number(hash) > 0) {
-      pathname += '@' + hash;
-    }
-    this._path = pathname;
+  private handleLocationChange() {
+    this.updateLoginUrl();
   }
 
-  _updateLoginUrl() {
+  private updateLoginUrl() {
     const baseUrl = getBaseUrl();
     if (baseUrl) {
       // Strip the canonical path from the path since needing canonical in
       // the path is unneeded and breaks the url.
-      this._loginUrl =
+      this.loginUrl =
         baseUrl +
         '/login/' +
         encodeURIComponent(
@@ -463,7 +708,7 @@
             window.location.hash
         );
     } else {
-      this._loginUrl =
+      this.loginUrl =
         '/login/' +
         encodeURIComponent(
           window.location.pathname +
@@ -473,18 +718,15 @@
     }
   }
 
-  @observe('params.*')
-  _paramsChanged(
-    paramsRecord: ElementPropertyDeepChange<GrAppElement, 'params'>
-  ) {
-    const params = paramsRecord.base;
+  // private but used in test
+  paramsChanged() {
     const viewsToCheck = [GerritView.SEARCH, GerritView.DASHBOARD];
-    if (params?.view && viewsToCheck.includes(params.view)) {
-      this._lastSearchPage = location.pathname;
+    if (this.params?.view && viewsToCheck.includes(this.params.view)) {
+      this.lastSearchPage = location.pathname;
     }
   }
 
-  _handleTitleChange(e: CustomEvent<TitleChangeEventDetail>) {
+  private handleTitleChange(e: CustomEvent<TitleChangeEventDetail>) {
     if (e.detail.title) {
       document.title = e.detail.title + ' · Gerrit Code Review';
     } else {
@@ -492,104 +734,66 @@
     }
   }
 
-  _handleDialogChange(e: CustomEvent<DialogChangeEventDetail>) {
+  private handleDialogChange(e: CustomEvent<DialogChangeEventDetail>) {
     if (e.detail.canceled) {
-      this._footerHeaderAriaHidden = false;
+      this.footerHeaderAriaHidden = false;
     } else if (e.detail.opened) {
-      this._footerHeaderAriaHidden = true;
+      this.footerHeaderAriaHidden = true;
     }
   }
 
-  handleShowKeyboardShortcuts() {
+  private async showKeyboardShortcuts() {
     this.loadKeyboardShortcutsDialog = true;
-    flush();
-    (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).open();
-  }
+    await this.updateComplete;
+    assertIsDefined(this.keyboardShortcuts, 'keyboardShortcuts');
 
-  _showKeyboardShortcuts() {
-    // same shortcut should close the dialog if pressed again
-    // when dialog is open
-    this.loadKeyboardShortcutsDialog = true;
-    flush();
-    const keyboardShortcuts = this.shadowRoot!.querySelector(
-      '#keyboardShortcuts'
-    ) as GrOverlay;
-    if (!keyboardShortcuts) return;
-    if (keyboardShortcuts.opened) {
-      keyboardShortcuts.cancel();
+    if (this.keyboardShortcuts.hasAttribute('open')) {
+      this.keyboardShortcuts.close();
       return;
     }
-    keyboardShortcuts.open();
-    this._footerHeaderAriaHidden = true;
-    this._mainAriaHidden = true;
+    this.footerHeaderAriaHidden = true;
+    this.mainAriaHidden = true;
+    this.keyboardShortcuts.showModal();
   }
 
-  _handleKeyboardShortcutDialogClose() {
-    (
-      this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay
-    ).cancel();
+  private handleKeyboardShortcutDialogClose() {
+    assertIsDefined(this.keyboardShortcuts, 'keyboardShortcuts');
+    this.keyboardShortcuts.close();
   }
 
-  onOverlayCanceled() {
-    this._footerHeaderAriaHidden = false;
-    this._mainAriaHidden = false;
+  onModalCanceled() {
+    this.footerHeaderAriaHidden = false;
+    this.mainAriaHidden = false;
   }
 
-  _handleAccountDetailUpdate() {
-    this.$.mainHeader.reload();
-    if (this.params?.view === GerritView.SETTINGS) {
-      (
-        this.shadowRoot!.querySelector('gr-settings-view') as GrSettingsView
-      ).reloadAccountDetail();
-    }
+  private handleAccountDetailUpdate() {
+    this.mainHeader?.reload();
+    this.settingsView?.reloadAccountDetail();
   }
 
-  _handleRegistrationDialogClose() {
+  private handleRegistrationDialogClose() {
     // The registration dialog is visible only if this.params is
     // instanceof AppElementJustRegisteredParams
     (this.params as AppElementJustRegisteredParams).justRegistered = false;
-    (
-      this.shadowRoot!.querySelector('#registrationOverlay') as GrOverlay
-    ).close();
+    assertIsDefined(this.registrationModal, 'registrationModal');
+    this.registrationModal.close();
   }
 
-  _goToOpenedChanges() {
-    GerritNav.navigateToStatusSearch('open');
+  private computePluginScreenName() {
+    if (this.view !== GerritView.PLUGIN_SCREEN) return '';
+    if (this.params === undefined) return '';
+    const pluginViewState = this.params as PluginViewState;
+    if (!pluginViewState.plugin || !pluginViewState.screen) return '';
+    return `${pluginViewState.plugin}-screen-${pluginViewState.screen}`;
   }
 
-  _goToUserDashboard() {
-    GerritNav.navigateToUserDashboard();
-  }
-
-  _goToMergedChanges() {
-    GerritNav.navigateToStatusSearch('merged');
-  }
-
-  _goToAbandonedChanges() {
-    GerritNav.navigateToStatusSearch('abandoned');
-  }
-
-  _goToWatchedChanges() {
-    // The query is hardcoded, and doesn't respect custom menu entries
-    GerritNav.navigateToSearchQuery('is:watched is:open');
-  }
-
-  _computePluginScreenName(params: AppElementParams) {
-    if (params.view !== GerritView.PLUGIN_SCREEN) return '';
-    if (!params.plugin || !params.screen) return '';
-    return `${params.plugin}-screen-${params.screen}`;
-  }
-
-  _logWelcome() {
+  private logWelcome() {
     console.group('Runtime Info');
     console.info('Gerrit UI (PolyGerrit)');
-    console.info(`Gerrit Server Version: ${this._version}`);
+    console.info(`Gerrit Server Version: ${this.version}`);
     if (window.VERSION_INFO) {
       console.info(`UI Version Info: ${window.VERSION_INFO}`);
     }
-    if (this._feedbackUrl) {
-      console.info(`Please file bugs and feedback at: ${this._feedbackUrl}`);
-    }
     console.groupEnd();
   }
 
@@ -598,20 +802,13 @@
    * Note: the REST API interface cannot use gr-reporting directly because
    * that would create a cyclic dependency.
    */
-  _handleRpcLog(e: RpcLogEvent) {
+  private handleRpcLog(e: RpcLogEvent) {
     this.reporting.reportRpcTiming(e.detail.anonymizedUrl, e.detail.elapsed);
   }
 
-  _mobileSearchToggle() {
+  private mobileSearchToggle() {
     this.mobileSearch = !this.mobileSearch;
   }
-
-  getThemeEndpoint() {
-    // For now, we only have dark mode and light mode
-    return window.localStorage.getItem('dark-theme')
-      ? 'app-theme-dark'
-      : 'app-theme-light';
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
deleted file mode 100644
index a1e6ac9..0000000
--- a/polygerrit-ui/app/elements/gr-app-element_html.ts
+++ /dev/null
@@ -1,245 +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(--background-color-tertiary);
-      display: flex;
-      flex-direction: column;
-      min-height: 100%;
-    }
-    gr-main-header,
-    footer {
-      color: var(--primary-text-color);
-    }
-    gr-main-header {
-      background: var(
-        --header-background,
-        var(--header-background-color, #eee)
-      );
-      padding: var(--header-padding);
-      border-bottom: var(--header-border-bottom);
-      border-image: var(--header-border-image);
-      border-right: 0;
-      border-left: 0;
-      border-top: 0;
-      box-shadow: var(--header-box-shadow);
-      /* Make sure the header is above the main content, to preserve box-shadow
-         visibility. We need 2 here instead of 1, because dropdowns in the
-         header should be shown on top of the sticky diff header, which has a
-         z-index of 1. */
-      z-index: 2;
-    }
-    footer {
-      background: var(
-        --footer-background,
-        var(--footer-background-color, #eee)
-      );
-      border-top: var(--footer-border-top);
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m) var(--spacing-l);
-      z-index: 100;
-    }
-    main {
-      flex: 1;
-      padding-bottom: var(--spacing-xxl);
-      position: relative;
-    }
-    .errorView {
-      align-items: center;
-      display: none;
-      flex-direction: column;
-      justify-content: center;
-      position: absolute;
-      top: 0;
-      right: 0;
-      bottom: 0;
-      left: 0;
-    }
-    .errorView.show {
-      display: flex;
-    }
-    .errorEmoji {
-      font-size: 2.6rem;
-    }
-    .errorText,
-    .errorMoreInfo {
-      margin-top: var(--spacing-m);
-    }
-    .errorText {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h3);
-      font-weight: var(--font-weight-h3);
-      line-height: var(--line-height-h3);
-    }
-    .errorMoreInfo {
-      color: var(--deemphasized-text-color);
-    }
-    .feedback {
-      color: var(--error-text-color);
-    }
-  </style>
-  <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
-  <gr-main-header
-    id="mainHeader"
-    search-query="{{params.query}}"
-    on-mobile-search="_mobileSearchToggle"
-    on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
-    mobile-search-hidden="[[!mobileSearch]]"
-    login-url="[[_loginUrl]]"
-    aria-hidden="[[_footerHeaderAriaHidden]]"
-  >
-  </gr-main-header>
-  <main aria-hidden="[[_mainAriaHidden]]">
-    <template is="dom-if" if="[[mobileSearch]]">
-      <gr-smart-search
-        id="search"
-        label="Search for changes"
-        search-query="{{params.query}}"
-        hidden="[[!mobileSearch]]"
-      >
-      </gr-smart-search>
-    </template>
-    <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
-      <gr-change-list-view
-        params="[[params]]"
-        account="[[_account]]"
-        view-state="{{_viewState.changeListView}}"
-      ></gr-change-list-view>
-    </template>
-    <template is="dom-if" if="[[_showDashboardView]]" restamp="true">
-      <gr-dashboard-view
-        account="[[_account]]"
-        params="[[params]]"
-        view-state="{{_viewState.dashboardView}}"
-      ></gr-dashboard-view>
-    </template>
-    <!-- Note that the change view does not have restamp="true" set, because we
-         want to re-use it as long as the change number does not change. -->
-    <template id="dom-if-change-view" is="dom-if" if="[[_showChangeView]]">
-      <gr-change-view
-        params="[[params]]"
-        view-state="{{_viewState.changeView}}"
-        back-page="[[_lastSearchPage]]"
-      ></gr-change-view>
-    </template>
-    <template is="dom-if" if="[[_showEditorView]]" restamp="true">
-      <gr-editor-view params="[[params]]"></gr-editor-view>
-    </template>
-    <!-- Note that the diff view does not have restamp="true" set, because we
-         want to re-use it as long as the change number does not change. -->
-    <template id="dom-if-diff-view" is="dom-if" if="[[_showDiffView]]">
-      <gr-diff-view
-        params="[[params]]"
-        change-view-state="{{_viewState.changeView}}"
-      ></gr-diff-view>
-    </template>
-    <template is="dom-if" if="[[_showSettingsView]]" restamp="true">
-      <gr-settings-view
-        params="[[params]]"
-        on-account-detail-update="_handleAccountDetailUpdate"
-      >
-      </gr-settings-view>
-    </template>
-    <template is="dom-if" if="[[_showAdminView]]" restamp="true">
-      <gr-admin-view path="[[_path]]" params="[[params]]"></gr-admin-view>
-    </template>
-    <template is="dom-if" if="[[_showPluginScreen]]" restamp="true">
-      <gr-endpoint-decorator name="[[_pluginScreenName]]">
-        <gr-endpoint-param
-          name="token"
-          value="[[params.screen]]"
-        ></gr-endpoint-param>
-      </gr-endpoint-decorator>
-    </template>
-    <template is="dom-if" if="[[_showCLAView]]" restamp="true">
-      <gr-cla-view></gr-cla-view>
-    </template>
-    <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
-      <gr-documentation-search params="[[params]]"> </gr-documentation-search>
-    </template>
-    <div id="errorView" class="errorView">
-      <div class="errorEmoji">[[_lastError.emoji]]</div>
-      <div class="errorText">[[_lastError.text]]</div>
-      <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
-    </div>
-  </main>
-  <footer r="contentinfo" aria-hidden="[[_footerHeaderAriaHidden]]">
-    <div>
-      Powered by
-      <a href="https://www.gerritcodereview.com/" rel="noopener" target="_blank"
-        >Gerrit Code Review</a
-      >
-      ([[_version]])
-      <gr-endpoint-decorator name="footer-left"></gr-endpoint-decorator>
-    </div>
-    <div>
-      <template is="dom-if" if="[[_feedbackUrl]]">
-        <a
-          class="feedback"
-          href$="[[_feedbackUrl]]"
-          rel="noopener"
-          target="_blank"
-          >Report bug</a
-        >
-        |
-      </template>
-      Press “?” for keyboard shortcuts
-      <gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
-    </div>
-  </footer>
-  <template is="dom-if" if="[[loadKeyboardShortcutsDialog]]">
-    <gr-overlay
-      id="keyboardShortcuts"
-      with-backdrop=""
-      on-iron-overlay-canceled="onOverlayCanceled"
-    >
-      <gr-keyboard-shortcuts-dialog
-        on-close="_handleKeyboardShortcutDialogClose"
-      ></gr-keyboard-shortcuts-dialog>
-    </gr-overlay>
-  </template>
-  <template is="dom-if" if="[[loadRegistrationDialog]]">
-    <gr-overlay id="registrationOverlay" with-backdrop="">
-      <gr-registration-dialog
-        id="registrationDialog"
-        settings-url="[[_settingsUrl]]"
-        on-account-detail-update="_handleAccountDetailUpdate"
-        on-close="_handleRegistrationDialogClose"
-      >
-      </gr-registration-dialog>
-    </gr-overlay>
-  </template>
-  <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
-  <gr-error-manager
-    id="errorManager"
-    login-url="[[_loginUrl]]"
-  ></gr-error-manager>
-  <gr-router id="router"></gr-router>
-  <gr-plugin-host id="plugins" config="[[_serverConfig]]"> </gr-plugin-host>
-  <gr-external-style
-    id="externalStyleForAll"
-    name="app-theme"
-  ></gr-external-style>
-  <gr-external-style
-    id="externalStyleForTheme"
-    name="[[getThemeEndpoint()]]"
-  ></gr-external-style>
-`;
diff --git a/polygerrit-ui/app/elements/gr-app-entry-point.ts b/polygerrit-ui/app/elements/gr-app-entry-point.ts
index b1f9621..0ae8942 100644
--- a/polygerrit-ui/app/elements/gr-app-entry-point.ts
+++ b/polygerrit-ui/app/elements/gr-app-entry-point.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // DO NOT EXPORT ANYTHING FROM THIS FILE!
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 de749df..da9c0c9 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /**
@@ -22,14 +11,18 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
-import {page} from '../utils/page-wrapper-utils';
+import {GrAnnotation} from '../embed/diff/gr-diff-highlight/gr-annotation';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
-import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
+import {AppContext, injectAppContext} from '../services/app-context';
+import {Finalizable} from '../services/registry';
+import {PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
 
-export function initGlobalVariables() {
+export function initGlobalVariables(appContext: AppContext & Finalizable) {
+  injectAppContext(appContext);
   window.GrAnnotation = GrAnnotation;
-  window.page = page;
   window.GrPluginActionContext = GrPluginActionContext;
-  initGerritPluginApi();
+}
+
+export function initGerrit(pluginLoader: PluginLoader) {
+  window.Gerrit = pluginLoader;
 }
diff --git a/polygerrit-ui/app/elements/gr-app-init.ts b/polygerrit-ui/app/elements/gr-app-init.ts
deleted file mode 100644
index ab38326..0000000
--- a/polygerrit-ui/app/elements/gr-app-init.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 {initAppContext} from '../services/app-context-init';
-import {
-  initVisibilityReporter,
-  initPerformanceReporter,
-  initErrorReporter,
-} from '../services/gr-reporting/gr-reporting_impl';
-import {appContext} from '../services/app-context';
-
-initAppContext();
-initVisibilityReporter(appContext);
-initPerformanceReporter(appContext);
-initErrorReporter(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 39070bd..0261992 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -1,132 +1,21 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {
-  GenerateUrlParameters,
-  GroupDetailView,
-  RepoDetailView,
-} from './core/gr-navigation/gr-navigation';
-import {
-  BasePatchSetNum,
-  DashboardId,
-  GroupId,
-  NumericChangeId,
-  RepoName,
-  RevisionPatchSetNum,
-  UrlEncodedCommentId,
-} from '../types/common';
-import {GerritView} from '../services/router/router-model';
+import {SettingsViewState} from '../models/views/settings';
+import {AdminViewState} from '../models/views/admin';
+import {GroupViewState} from '../models/views/group';
+import {RepoViewState} from '../models/views/repo';
+import {AgreementViewState} from '../models/views/agreement';
+import {DocumentationViewState} from '../models/views/documentation';
+import {PluginViewState} from '../models/views/plugin';
+import {SearchViewState} from '../models/views/search';
+import {DashboardViewState} from '../models/views/dashboard';
+import {ChangeViewState} from '../models/views/change';
 
 export interface AppElement extends HTMLElement {
-  params: AppElementParams | GenerateUrlParameters;
-}
-
-// TODO(TS): Remove unify AppElementParams with GenerateUrlParameters
-// Seems we can use GenerateUrlParameters instead of AppElementParams,
-// but it require some refactoring
-export interface AppElementDashboardParams {
-  view: GerritView.DASHBOARD;
-  project?: RepoName;
-  dashboard: DashboardId;
-  user?: string;
-  sections: Array<{name: string; query: string}>;
-  title?: string;
-}
-
-export interface AppElementGroupParams {
-  view: GerritView.GROUP;
-  detail?: GroupDetailView;
-  groupId: GroupId;
-}
-
-export interface ListViewParams {
-  filter?: string | null;
-  offset?: number | string;
-}
-
-export interface AppElementAdminParams extends ListViewParams {
-  view: GerritView.ADMIN;
-  adminView: string;
-  openCreateModal?: boolean;
-}
-
-export interface AppElementRepoParams extends ListViewParams {
-  view: GerritView.REPO;
-  detail?: RepoDetailView;
-  repo: RepoName;
-}
-
-export interface AppElementDocSearchParams {
-  view: GerritView.DOCUMENTATION_SEARCH;
-  filter: string | null;
-}
-
-export interface AppElementPluginScreenParams {
-  view: GerritView.PLUGIN_SCREEN;
-  plugin?: string;
-  screen?: string;
-}
-
-export interface AppElementSearchParam {
-  view: GerritView.SEARCH;
-  query: string;
-  offset: string;
-}
-
-export interface AppElementSettingsParam {
-  view: GerritView.SETTINGS;
-  emailToken?: string;
-}
-
-export interface AppElementAgreementParam {
-  view: GerritView.AGREEMENTS;
-}
-
-export interface AppElementDiffViewParam {
-  view: GerritView.DIFF;
-  changeNum: NumericChangeId;
-  project?: RepoName;
-  commentId?: UrlEncodedCommentId;
-  path?: string;
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-  lineNum: number;
-  leftSide?: boolean;
-  commentLink?: boolean;
-}
-
-export interface AppElementDiffEditViewParam {
-  view: GerritView.EDIT;
-  changeNum: NumericChangeId;
-  project: RepoName;
-  path: string;
-  patchNum: RevisionPatchSetNum;
-  lineNum?: number;
-}
-
-export interface AppElementChangeViewParams {
-  view: GerritView.CHANGE;
-  changeNum: NumericChangeId;
-  project: RepoName;
-  edit?: boolean;
-  patchNum?: RevisionPatchSetNum;
-  basePatchNum?: BasePatchSetNum;
-  commentId?: UrlEncodedCommentId;
-  forceReload?: boolean;
-  tab?: string;
+  params: AppElementParams;
 }
 
 export interface AppElementJustRegisteredParams {
@@ -140,18 +29,16 @@
 }
 
 export type AppElementParams =
-  | AppElementDashboardParams
-  | AppElementGroupParams
-  | AppElementAdminParams
-  | AppElementChangeViewParams
-  | AppElementRepoParams
-  | AppElementDocSearchParams
-  | AppElementPluginScreenParams
-  | AppElementSearchParam
-  | AppElementSettingsParam
-  | AppElementAgreementParam
-  | AppElementDiffViewParam
-  | AppElementDiffEditViewParam
+  | DashboardViewState
+  | GroupViewState
+  | AdminViewState
+  | ChangeViewState
+  | RepoViewState
+  | DocumentationViewState
+  | PluginViewState
+  | SearchViewState
+  | SettingsViewState
+  | AgreementViewState
   | AppElementJustRegisteredParams;
 
 export function isAppElementJustRegisteredParams(
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 463fab9..645f94a 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -1,22 +1,9 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {safeTypesBridge} from '../utils/safe-types-util';
-import './gr-app-init';
 import './font-roboto-local-loader';
 // Sets up global Polymer variable, because plugins requires it.
 import '../scripts/bundled-polymer';
@@ -34,20 +21,108 @@
 setCancelSyntheticClickEvents(false);
 setPassiveTouchGestures(true);
 
-import {initGlobalVariables} from './gr-app-global-var-init';
+import {initGerrit, initGlobalVariables} from './gr-app-global-var-init';
 import './gr-app-element';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-app_html';
-import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
-import {customElement} from '@polymer/decorators';
+import {Finalizable} from '../services/registry';
+import {
+  DependencyError,
+  DependencyToken,
+  provide,
+  Provider,
+} from '../models/dependency';
 import {installPolymerResin} from '../scripts/polymer-resin-install';
 
+import {
+  createAppContext,
+  createAppDependencies,
+  Creator,
+} from '../services/app-context-init';
+import {
+  initVisibilityReporter,
+  initPerformanceReporter,
+  initErrorReporter,
+  initWebVitals,
+} from '../services/gr-reporting/gr-reporting_impl';
+import {html, LitElement} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {
+  ServiceWorkerInstaller,
+  serviceWorkerInstallerToken,
+} from '../services/service-worker-installer';
+import {pluginLoaderToken} from './shared/gr-js-api-interface/gr-plugin-loader';
+
+const appContext = createAppContext();
+initGlobalVariables(appContext);
+const reportingService = appContext.reportingService;
+initVisibilityReporter(reportingService);
+initPerformanceReporter(reportingService);
+initWebVitals(reportingService);
+initErrorReporter(reportingService);
+
 installPolymerResin(safeTypesBridge);
 
 @customElement('gr-app')
-export class GrApp extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrApp extends LitElement {
+  private finalizables: Finalizable[] = [];
+
+  private serviceWorkerInstaller?: ServiceWorkerInstaller;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    const dependencies = new Map<DependencyToken<unknown>, Provider<unknown>>();
+
+    const injectDependency = <T>(
+      token: DependencyToken<T>,
+      creator: Creator<T>
+    ) => {
+      let service: (T & Finalizable) | undefined = undefined;
+      dependencies.set(token, () => {
+        if (service) return service;
+        service = creator();
+        this.finalizables.push(service);
+        return service;
+      });
+    };
+
+    const resolver = <T>(token: DependencyToken<T>): T => {
+      const provider = dependencies.get(token);
+      if (provider) {
+        return provider() as T;
+      } else {
+        throw new DependencyError(
+          token,
+          'Forgot to set up dependency for gr-app'
+        );
+      }
+    };
+
+    for (const [token, creator] of createAppDependencies(
+      appContext,
+      resolver
+    )) {
+      injectDependency(token, creator);
+    }
+    for (const [token, provider] of dependencies) {
+      provide(this, token, provider);
+    }
+
+    initGerrit(resolver(pluginLoaderToken));
+
+    if (!this.serviceWorkerInstaller) {
+      this.serviceWorkerInstaller = resolver(serviceWorkerInstallerToken);
+    }
+  }
+
+  override disconnectedCallback() {
+    for (const f of this.finalizables) {
+      f.finalize();
+    }
+    this.finalizables = [];
+    super.disconnectedCallback();
+  }
+
+  override render() {
+    return html`<gr-app-element id="app-element"></gr-app-element>`;
   }
 }
 
@@ -56,6 +131,3 @@
     'gr-app': GrApp;
   }
 }
-
-initGlobalVariables();
-initGerritPluginApi();
diff --git a/polygerrit-ui/app/elements/gr-app_html.ts b/polygerrit-ui/app/elements/gr-app_html.ts
deleted file mode 100644
index f6172c9..0000000
--- a/polygerrit-ui/app/elements/gr-app_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-app-element id="app-element"></gr-app-element>
-`;
diff --git a/polygerrit-ui/app/elements/gr-app_test.js b/polygerrit-ui/app/elements/gr-app_test.js
deleted file mode 100644
index 5a3b1f2..0000000
--- a/polygerrit-ui/app/elements/gr-app_test.js
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import './gr-app.js';
-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(async () => {
-    sinon.stub(appContext.reportingService, 'appStarted');
-    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,
-      },
-    }));
-    stubRestApi('getPreferences').returns(Promise.resolve({my: []}));
-    stubRestApi('getVersion').returns(Promise.resolve(42));
-    stubRestApi('probePath').returns(Promise.resolve(42));
-
-    element = basicFixture.instantiate();
-    await flush();
-  });
-
-  const appElement = () => element.$['app-element'];
-
-  test('reporting', () => {
-    assert.isTrue(appElement().reporting.appStarted.calledOnce);
-  });
-
-  test('reporting called before router start', () => {
-    const element = appElement();
-    const appStartedStub = element.reporting.appStarted;
-    const routerStartStub = element.$.router.start;
-    sinon.assert.callOrder(appStartedStub, routerStartStub);
-  });
-
-  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}});
-    assert.notOk(appElement()._lastSearchPage);
-    appElement()._paramsChanged({base: {view: GerritNav.View.SEARCH}});
-    assert.ok(appElement()._lastSearchPage);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
new file mode 100644
index 0000000..05ee9ed
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-app';
+import {getAppContext} from '../services/app-context';
+import {fixture, html, assert} from '@open-wc/testing';
+import {queryAndAssert, stubElement, stubRestApi} from '../test/test-utils';
+import {GrApp} from './gr-app';
+import {
+  createChangeViewState,
+  createAppElementSearchViewParams,
+  createPreferences,
+  createServerInfo,
+} from '../test/test-data-generators';
+import {GrAppElement} from './gr-app-element';
+import {GrRouter, routerToken} from './core/gr-router/gr-router';
+import {resolve} from '../models/dependency';
+import {removeRequestDependencyListener} from '../test/common-test-setup';
+
+suite('gr-app callback tests', () => {
+  const handleLocationChangeSpy = sinon.spy(
+    GrAppElement.prototype,
+    <any>'handleLocationChange'
+  );
+  const dispatchLocationChangeEventSpy = sinon.spy(
+    GrRouter.prototype,
+    <any>'dispatchLocationChangeEvent'
+  );
+
+  setup(async () => {
+    await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
+  });
+
+  test("handleLocationChange in gr-app-element is called after dispatching 'location-change' event in gr-router", () => {
+    dispatchLocationChangeEventSpy();
+    assert.isTrue(handleLocationChangeSpy.calledOnce);
+  });
+});
+
+suite('gr-app tests', () => {
+  let grApp: GrApp;
+  const config = createServerInfo();
+  let appStartedStub: sinon.SinonStub;
+  let routerStartStub: sinon.SinonStub;
+
+  setup(async () => {
+    appStartedStub = sinon.stub(getAppContext().reportingService, 'appStarted');
+    stubElement('gr-account-dropdown', '_getTopContent');
+    routerStartStub = sinon.stub(GrRouter.prototype, 'start');
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
+    stubRestApi('getConfig').returns(Promise.resolve(config));
+    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
+    stubRestApi('getVersion').returns(Promise.resolve('42'));
+    stubRestApi('probePath').returns(Promise.resolve(false));
+    grApp = await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
+  });
+
+  test('models resolve', () => {
+    // Verify that models resolve on grApp without falling back
+    // to the ones instantiated by the test-setup.
+    removeRequestDependencyListener();
+    assert.ok(resolve(grApp, routerToken)());
+  });
+
+  test('reporting', () => {
+    assert.isTrue(appStartedStub.calledOnce);
+  });
+
+  test('reporting called before router start', () => {
+    sinon.assert.callOrder(appStartedStub, routerStartStub);
+  });
+
+  test('_paramsChanged sets search page', () => {
+    const grAppElement = queryAndAssert<GrAppElement>(grApp, '#app-element');
+
+    grAppElement.params = createChangeViewState();
+    grAppElement.paramsChanged();
+    assert.notOk(grAppElement.lastSearchPage);
+
+    grAppElement.params = createAppElementSearchViewParams();
+    grAppElement.paramsChanged();
+    assert.ok(grAppElement.lastSearchPage);
+  });
+});
diff --git a/polygerrit-ui/app/elements/gr-css-mixins.ts b/polygerrit-ui/app/elements/gr-css-mixins.ts
new file mode 100644
index 0000000..523a056
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-css-mixins.ts
@@ -0,0 +1,94 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement} from '@polymer/decorators';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+
+@customElement('gr-css-mixins')
+export class GrCssMixins extends PolymerElement {
+  /* eslint-disable lit/prefer-static-styles */
+  static get template() {
+    return html`
+      <style>
+        /* prettier formatter removes semi-colons after css mixins. */
+        /* prettier-ignore */
+        :host {
+          /* If you want to use css-mixins in Lit elements, then you have to
+          first use them in a PolymerElement somewhere. We are collecting all
+          css- mixin usage here, but we may move them somewhere else later when
+          converting gr-app-element to Lit. In the Lit element you can then use
+          the css variables directly such as --paper-input-container_-_padding,
+          so you don't have to mess with mixins at all.
+          */
+          --paper-input-container: {
+            padding: 8px 0;
+          };
+          --paper-font-common-base: {
+            font-family: var(--header-font-family);
+            -webkit-font-smoothing: initial;
+          };
+          --paper-input-container-input: {
+            font-size: var(--font-size-normal);
+            line-height: var(--line-height-normal);
+            color: var(--primary-text-color);
+          };
+          --paper-input-container-underline: {
+            height: 0;
+            display: none;
+          };
+          --paper-input-container-underline-focus: {
+            height: 0;
+            display: none;
+          };
+          --paper-input-container-underline-disabled: {
+            height: 0;
+            display: none;
+          };
+          --paper-input-container-label: {
+            display: none;
+          };
+          --paper-tab-content: {
+            margin-bottom: var(--spacing-s);
+          };
+          --paper-tab-content-focused: {
+            /* paper-tabs uses 700 here, which can look awkward */
+            font-weight: var(--font-weight-h3);
+            background: var(--gray-background-focus);
+          };
+          --paper-tab-content-unselected: {
+            /* paper-tabs uses 0.8 here, but we want to control the color
+               directly */
+            opacity: 1;
+            color: var(--deemphasized-text-color);
+          };
+          --paper-item: {
+            min-height: 0;
+            padding: 0px 16px;
+          };
+          --paper-item-focused-before: {
+            background-color: var(--selection-background-color);
+          };
+          --paper-item-focused: {
+            background-color: var(--selection-background-color);
+          };
+          --paper-listbox: {
+            padding: 0;
+          };
+          --iron-autogrow-textarea: {
+            box-sizing: border-box;
+            padding: var(--spacing-s);
+          };
+        }
+      </style>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-css-mixins': GrCssMixins;
+  }
+}
diff --git a/polygerrit-ui/app/elements/lit/fit-controller.ts b/polygerrit-ui/app/elements/lit/fit-controller.ts
new file mode 100644
index 0000000..423a4f0
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/fit-controller.ts
@@ -0,0 +1,213 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {ReactiveController, ReactiveControllerHost} from 'lit';
+
+export interface FitControllerHost {
+  /**
+   * This offset will increase or decrease the distance to the left side
+   * of the screen, a negative offset will move the dropdown to the left
+   * a positive one, to the right.
+   *
+   */
+  horizontalOffset: number;
+
+  /**
+   * This offset will increase or decrease the distance to the top
+   * side of the screen: a negative offset will move the dropdown upwards
+   * , a positive one, downwards.
+   *
+   */
+  verticalOffset: number;
+}
+
+export interface PositionStyles {
+  top: string;
+  left: string;
+  position: string;
+  maxWidth: string;
+  maxHeight: string;
+  boxSizing: string;
+}
+
+/**
+ * `FitController` fits an element in another element using `max-height`
+ * and `max-width`.
+ *
+ * FitController overrides all properties defined in PositionStyles for the
+ * host.
+ * The element will only be sized and/or positioned if it has not already been
+ * sized and/or positioned by CSS.
+ *  CSS properties            | Action
+ * --------------------------|-------------------------------------------
+ * `position` set            | Element is not centered horizontally/vertically
+ * `top` or `bottom` set     | Element is not vertically centered
+ * `left` or `right` set     | Element is not horizontally centered
+ * `max-height` set          | Element respects `max-height`
+ * `max-width` set           | Element respects `max-width`
+ *
+ * `FitController` positions an element into another element and gives it
+ * a horizontalAlignment = left and verticalAlignment = top.
+ * This will override the element's css position.
+ *
+ * Use `horizontalOffset, verticalOffset` to offset the element from its
+ * `positionTarget`; `FitController` will collapse these in order to
+ * keep the element within `window` boundaries, while preserving the element's
+ * CSS margin values.
+ *
+ */
+export class FitController implements ReactiveController {
+  host: ReactiveControllerHost & HTMLElement & FitControllerHost;
+
+  private originalStyles?: PositionStyles;
+
+  private positionTarget?: HTMLElement;
+
+  constructor(host: ReactiveControllerHost & HTMLElement & FitControllerHost) {
+    (this.host = host).addController(this);
+  }
+
+  hostConnected() {
+    this.positionTarget = this.getPositionTarget();
+  }
+
+  hostDisconnected() {}
+
+  // private but used in tests
+  getPositionTarget() {
+    let parent = this.host.parentNode;
+
+    if (parent && parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
+      parent = (parent as ShadowRoot).host;
+    }
+
+    return parent as HTMLElement;
+  }
+
+  private saveOriginalStyles() {
+    // These properties are changed in position() hence keep the original
+    // values to reset the host styles later.
+    this.originalStyles = {
+      top: this.host.style.top || '',
+      left: this.host.style.left || '',
+      position: this.host.style.position || '',
+      maxWidth: this.host.style.maxWidth || '',
+      maxHeight: this.host.style.maxHeight || '',
+      boxSizing: this.host.style.boxSizing || '',
+    };
+  }
+
+  /**
+   * Reset the host style, and clear the memoized data.
+   */
+  private resetStyles() {
+    // It is necessary to clear the max-width:0px and max-height:0px.
+    // A component may call refit() multiple times, in which case we don't
+    // want the values assigned from the first call which may not be precisely
+    // correct to influence the second call.
+    // Hence we reset the styles here.
+    if (this.originalStyles !== undefined) {
+      Object.assign(this.host.style, this.originalStyles);
+    }
+    this.originalStyles = undefined;
+  }
+
+  setPositionTarget(target: HTMLElement) {
+    this.positionTarget = target;
+  }
+
+  /**
+   * Equivalent to calling `resetStyles()` and `position()`.
+   * Useful to call this after the element or the `window` element has
+   * been resized, or if any of the positioning properties
+   * (e.g. `horizontalOffset, verticalOffset`) are updated.
+   * It preserves the scroll position of the host.
+   */
+  refit() {
+    const scrollLeft = this.host.scrollLeft;
+    const scrollTop = this.host.scrollTop;
+    this.resetStyles();
+    this.position();
+    this.host.scrollLeft = scrollLeft;
+    this.host.scrollTop = scrollTop;
+  }
+
+  private position() {
+    this.saveOriginalStyles();
+
+    this.host.style.position = 'fixed';
+    // Need border-box for margin/padding.
+    this.host.style.boxSizing = 'border-box';
+
+    const hostRect = this.host.getBoundingClientRect();
+    const positionRect = this.getNormalizedRect(this.positionTarget!);
+    const windowRect = this.getNormalizedRect(window);
+
+    this.calculateAndSetPositions(hostRect, positionRect, windowRect);
+  }
+
+  // private but used in tests
+  calculateAndSetPositions(
+    hostRect: DOMRect,
+    positionRect: DOMRect,
+    windowRect: DOMRect
+  ) {
+    const hostStyles = (window as Window).getComputedStyle(this.host);
+    const hostMinWidth = parseInt(hostStyles.minWidth) || 0;
+    const hostMinHeight = parseInt(hostStyles.minHeight) || 0;
+
+    const hostMargin = {
+      top: parseInt(hostStyles.marginTop) || 0,
+      right: parseInt(hostStyles.marginRight) || 0,
+      bottom: parseInt(hostStyles.marginBottom) || 0,
+      left: parseInt(hostStyles.marginLeft) || 0,
+    };
+
+    let leftPosition =
+      positionRect.left + this.host.horizontalOffset + hostMargin.left;
+    let topPosition =
+      positionRect.top + this.host.verticalOffset + hostMargin.top;
+
+    // Limit right/bottom within window respecting the margin.
+    const rightPosition = Math.min(
+      windowRect.right - hostMargin.right,
+      leftPosition + hostRect.width
+    );
+    const bottomPosition = Math.min(
+      windowRect.bottom - hostMargin.bottom,
+      topPosition + hostRect.height
+    );
+
+    // Respect hostMinWidth and hostMinHeight
+    // Current width is rightPosition - leftPosition or hostRect.width
+    //    rightPosition - leftPosition >= hostMinWidth
+    // => leftPosition <= rightPosition - hostMinWidth
+    leftPosition = Math.min(leftPosition, rightPosition - hostMinWidth);
+    topPosition = Math.min(topPosition, bottomPosition - hostMinHeight);
+
+    // Limit left/top within window respecting the margin.
+    leftPosition = Math.max(windowRect.left + hostMargin.left, leftPosition);
+    topPosition = Math.max(windowRect.top + hostMargin.top, topPosition);
+
+    // Use right/bottom to set maxWidth/maxHeight and respect
+    // minWidth/minHeight.
+    const maxWidth = Math.max(rightPosition - leftPosition, hostMinWidth);
+    const maxHeight = Math.max(bottomPosition - topPosition, hostMinHeight);
+
+    this.host.style.maxWidth = `${maxWidth}px`;
+    this.host.style.maxHeight = `${maxHeight}px`;
+
+    this.host.style.left = `${leftPosition}px`;
+    this.host.style.top = `${topPosition}px`;
+  }
+
+  private getNormalizedRect(target: Window | HTMLElement): DOMRect {
+    if (target === document.documentElement || target === window) {
+      return new DOMRect(0, 0, window.innerWidth, window.innerHeight);
+    }
+    return (target as HTMLElement).getBoundingClientRect();
+  }
+}
diff --git a/polygerrit-ui/app/elements/lit/fit-controller_test.ts b/polygerrit-ui/app/elements/lit/fit-controller_test.ts
new file mode 100644
index 0000000..2a60b83
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/fit-controller_test.ts
@@ -0,0 +1,143 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {FitController} from './fit-controller';
+import {LitElement} from 'lit';
+import {customElement} from 'lit/decorators.js';
+
+@customElement('fit-element')
+class FitElement extends LitElement {
+  fitController = new FitController(this);
+
+  horizontalOffset = 0;
+
+  verticalOffset = 0;
+
+  override render() {
+    return html`<div></div>`;
+  }
+}
+
+suite('fit controller', () => {
+  let element: FitElement;
+  setup(async () => {
+    element = await fixture(html`<fit-element></fit-element>`);
+  });
+
+  test('refit positioning', async () => {
+    const hostRect = new DOMRect(0, 0, 50, 50);
+
+    const positionRect = new DOMRect(37, 37, 300, 60);
+
+    const windowRect = new DOMRect(0, 0, 600, 600);
+
+    element.fitController.calculateAndSetPositions(
+      hostRect,
+      positionRect,
+      windowRect
+    );
+
+    assert.equal(element.style.top, '37px');
+    assert.equal(element.style.left, '37px');
+  });
+
+  test('refit positioning with offset', async () => {
+    const elementWithOffset: FitElement = await fixture(
+      html`<fit-element></fit-element>`
+    );
+    elementWithOffset.verticalOffset = 10;
+    elementWithOffset.horizontalOffset = 20;
+
+    const hostRect = new DOMRect(0, 0, 50, 50);
+
+    const positionRect = new DOMRect(37, 37, 300, 60);
+
+    const windowRect = new DOMRect(0, 0, 600, 600);
+
+    elementWithOffset.fitController.calculateAndSetPositions(
+      hostRect,
+      positionRect,
+      windowRect
+    );
+
+    assert.equal(elementWithOffset.style.top, '47px');
+    assert.equal(elementWithOffset.style.left, '57px');
+  });
+
+  test('host margin updates positioning', async () => {
+    const hostRect = new DOMRect(0, 0, 50, 50);
+
+    const positionRect = new DOMRect(37, 37, 300, 60);
+
+    const windowRect = new DOMRect(0, 0, 600, 600);
+
+    element.style.marginLeft = '10px';
+    element.style.marginTop = '10px';
+    element.fitController.calculateAndSetPositions(
+      hostRect,
+      positionRect,
+      windowRect
+    );
+
+    // is 10px extra from the previous test due to host margin
+    assert.equal(element.style.top, '47px');
+    assert.equal(element.style.left, '47px');
+  });
+
+  test('host minWidth, minHeight overrides positioning', async () => {
+    const hostRect = new DOMRect(0, 0, 50, 50);
+
+    const positionRect = new DOMRect(37, 37, 300, 60);
+
+    const windowRect = new DOMRect(0, 0, 600, 600);
+
+    element.style.marginLeft = '10px';
+    element.style.marginTop = '10px';
+
+    element.style.minHeight = '50px';
+    element.style.minWidth = '60px';
+
+    element.fitController.calculateAndSetPositions(
+      hostRect,
+      positionRect,
+      windowRect
+    );
+
+    assert.equal(element.style.top, '47px');
+
+    // Should be 47 like the previous test but that would make it overall
+    // smaller in width than the minWidth defined
+    assert.equal(element.style.left, '37px');
+    assert.equal(element.style.maxWidth, '60px');
+  });
+
+  test('positioning happens within window size ', async () => {
+    const hostRect = new DOMRect(0, 0, 50, 50);
+
+    const positionRect = new DOMRect(37, 37, 300, 60);
+
+    // window size is small hence limits the position
+    const windowRect = new DOMRect(0, 0, 50, 50);
+
+    element.style.marginLeft = '10px';
+    element.style.marginTop = '10px';
+
+    element.fitController.calculateAndSetPositions(
+      hostRect,
+      positionRect,
+      windowRect
+    );
+
+    assert.equal(element.style.top, '47px');
+    assert.equal(element.style.left, '47px');
+    // With the window size being 50, the element is styled with width 3px
+    // width = windowSize - leftPosition = 50 - 47 = 3px
+    // Without the window width restriction, in previous test maxWidth is 60px
+    assert.equal(element.style.maxWidth, '3px');
+  });
+});
diff --git a/polygerrit-ui/app/elements/lit/incremental-repeat.ts b/polygerrit-ui/app/elements/lit/incremental-repeat.ts
new file mode 100644
index 0000000..695290c
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/incremental-repeat.ts
@@ -0,0 +1,123 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {directive, AsyncDirective} from 'lit/async-directive.js';
+import {DirectiveParameters, ChildPart} from 'lit/directive.js';
+import {
+  insertPart,
+  setChildPartValue,
+  removePart,
+} from 'lit/directive-helpers.js';
+
+interface RepeatOptions<T> {
+  values: T[];
+  mapFn?: (val: T, idx: number) => unknown;
+  initialCount: number;
+  targetFrameRate?: number;
+  startAt?: number;
+  // TODO: targetFramerate
+}
+
+interface RepeatState<T> {
+  values: T[];
+  mapFn?: (val: T, idx: number) => unknown;
+  startAt: number;
+  incrementAmount: number;
+  lastRenderedAt: number;
+  targetFrameRate: number;
+}
+
+class IncrementalRepeat<T> extends AsyncDirective {
+  private children: {part: ChildPart; options: RepeatOptions<T>}[] = [];
+
+  private part!: ChildPart;
+
+  private state!: RepeatState<T>;
+
+  render(options: RepeatOptions<T>) {
+    const values = options.values.slice(
+      options.startAt ?? 0,
+      (options.startAt ?? 0) + options.initialCount
+    );
+    if (options.mapFn) {
+      return values.map(options.mapFn);
+    }
+    return values;
+  }
+
+  override update(part: ChildPart, [options]: DirectiveParameters<this>) {
+    if (options.values !== this.state?.values) {
+      if (this.nextScheduledFrameWork !== undefined)
+        cancelAnimationFrame(this.nextScheduledFrameWork);
+      this.part = part;
+      this.clearParts();
+      this.state = {
+        values: options.values,
+        mapFn: options.mapFn,
+        startAt: options.initialCount,
+        incrementAmount: options.initialCount,
+        lastRenderedAt: performance.now(),
+        targetFrameRate: options.targetFrameRate ?? 30,
+      };
+      this.nextScheduledFrameWork = requestAnimationFrame(
+        this.animationFrameHandler
+      );
+    } else {
+      this.updateParts();
+    }
+    return this.render(options);
+  }
+
+  private appendPart(options: RepeatOptions<T>) {
+    const part = insertPart(this.part);
+    this.children.push({part, options});
+    setChildPartValue(part, this.render(options));
+  }
+
+  private clearParts() {
+    for (const child of this.children) {
+      removePart(child.part);
+    }
+    this.children = [];
+  }
+
+  private updateParts() {
+    for (const child of this.children) {
+      setChildPartValue(child.part, this.render(child.options));
+    }
+  }
+
+  private nextScheduledFrameWork: number | undefined;
+
+  private animationFrameHandler = () => {
+    const now = performance.now();
+    const frameRate = 1000 / (now - this.state.lastRenderedAt);
+    if (frameRate < this.state.targetFrameRate) {
+      // https://en.wikipedia.org/wiki/Additive_increase/multiplicative_decrease
+      this.state.incrementAmount = Math.max(
+        1,
+        Math.round(this.state.incrementAmount / 2)
+      );
+    } else {
+      this.state.incrementAmount++;
+    }
+    this.state.lastRenderedAt = now;
+    this.appendPart({
+      mapFn: this.state.mapFn,
+      values: this.state.values,
+      initialCount: this.state.incrementAmount,
+      startAt: this.state.startAt,
+    });
+
+    this.state.startAt += this.state.incrementAmount;
+    if (this.state.startAt < this.state.values.length) {
+      this.nextScheduledFrameWork = requestAnimationFrame(
+        this.animationFrameHandler
+      );
+    }
+  };
+}
+
+export const incrementalRepeat = directive(IncrementalRepeat);
diff --git a/polygerrit-ui/app/elements/lit/shortcut-controller.ts b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
new file mode 100644
index 0000000..5d6c536
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ReactiveController, ReactiveControllerHost} from 'lit';
+import {Binding, ShortcutOptions} from '../../utils/dom-util';
+import {shortcutsServiceToken} from '../../services/shortcuts/shortcuts-service';
+import {Shortcut} from '../../services/shortcuts/shortcuts-config';
+import {resolve} from '../../models/dependency';
+
+export {Shortcut};
+interface ShortcutListener {
+  binding: Binding;
+  listener: (e: KeyboardEvent) => void;
+  options?: ShortcutOptions;
+}
+
+interface AbstractListener {
+  shortcut: Shortcut;
+  listener: (e: KeyboardEvent) => void;
+  options?: ShortcutOptions;
+}
+
+type Cleanup = () => void;
+
+export class ShortcutController implements ReactiveController {
+  private readonly getShortcutsService = resolve(
+    this.host,
+    shortcutsServiceToken
+  );
+
+  private readonly listenersLocal: ShortcutListener[] = [];
+
+  private readonly listenersGlobal: ShortcutListener[] = [];
+
+  private readonly listenersAbstract: AbstractListener[] = [];
+
+  private cleanups: Cleanup[] = [];
+
+  constructor(private readonly host: ReactiveControllerHost & HTMLElement) {
+    host.addController(this);
+  }
+
+  // Note that local shortcuts are *not* suppressed when the user has shortcuts
+  // disabled or when the event comes from elements like <input>. So this method
+  // is intended for shortcuts like ESC and Ctrl-ENTER.
+  // Call method in constructor of the component
+  addLocal(
+    binding: Binding,
+    listener: (e: KeyboardEvent) => void,
+    options?: ShortcutOptions
+  ) {
+    this.listenersLocal.push({binding, listener, options});
+  }
+
+  // Call method in constructor of the component
+  addGlobal(binding: Binding, listener: (e: KeyboardEvent) => void) {
+    this.listenersGlobal.push({binding, listener});
+  }
+
+  /**
+   * `Shortcut` is more abstract than a concrete `Binding`. A `Shortcut` has a
+   * description text and (several) bindings configured in the file
+   * `shortcuts-config.ts`.
+   *
+   * Use this method when you are migrating from Polymer to Lit. Call it for
+   * each entry of keyboardShortcuts().
+   *
+   * Call method in constructor of the component
+   */
+  addAbstract(
+    shortcut: Shortcut,
+    listener: (e: KeyboardEvent) => void,
+    options?: ShortcutOptions
+  ) {
+    this.listenersAbstract.push({shortcut, listener, options});
+  }
+
+  hostConnected() {
+    const shortcutsService = this.getShortcutsService();
+    for (const {binding, listener, options} of this.listenersLocal) {
+      const cleanup = shortcutsService.addShortcut(
+        this.host,
+        binding,
+        listener,
+        {
+          shouldSuppress: options?.shouldSuppress ?? false,
+          preventDefault: options?.preventDefault,
+        }
+      );
+      this.cleanups.push(cleanup);
+    }
+    for (const {shortcut, listener, options} of this.listenersAbstract) {
+      const cleanup = shortcutsService.addShortcutListener(
+        shortcut,
+        listener,
+        options
+      );
+      this.cleanups.push(cleanup);
+    }
+    for (const {binding, listener} of this.listenersGlobal) {
+      const cleanup = shortcutsService.addShortcut(
+        document.body,
+        binding,
+        listener
+      );
+      this.cleanups.push(cleanup);
+    }
+  }
+
+  hostDisconnected() {
+    for (const cleanup of this.cleanups) {
+      cleanup();
+    }
+    this.cleanups = [];
+  }
+}
diff --git a/polygerrit-ui/app/elements/lit/subscription-controller.ts b/polygerrit-ui/app/elements/lit/subscription-controller.ts
index ab4ed64..fdd24cf 100644
--- a/polygerrit-ui/app/elements/lit/subscription-controller.ts
+++ b/polygerrit-ui/app/elements/lit/subscription-controller.ts
@@ -1,45 +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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {ReactiveController, ReactiveControllerHost} from 'lit';
 import {Observable, Subscription} from 'rxjs';
+import {Provider} from '../../models/dependency';
+
+export class SubscriptionError extends Error {
+  constructor(message: string) {
+    super(message);
+  }
+}
 
 /**
  * Enables components to simply hook up a property with an Observable like so:
  *
- * subscribe(this, obs$, x => (this.prop = x));
+ * subscribe(this, () => obs$, x => (this.prop = x));
  */
 export function subscribe<T>(
-  host: ReactiveControllerHost,
-  obs$: Observable<T>,
-  setProp: (t: T) => void
+  host: ReactiveControllerHost & HTMLElement,
+  provider: Provider<Observable<T>>,
+  callback: (t: T) => void
 ) {
-  host.addController(new SubscriptionController(obs$, setProp));
+  if (host.isConnected)
+    throw new Error(
+      'Subscriptions should happen before a component is connected'
+    );
+  const controller = new SubscriptionController(provider, callback);
+  host.addController(controller);
 }
 
 export class SubscriptionController<T> implements ReactiveController {
   private sub?: Subscription;
 
   constructor(
-    private readonly obs$: Observable<T>,
-    private readonly setProp: (t: T) => void
+    private readonly provider: Provider<Observable<T>>,
+    private readonly callback: (t: T) => void
   ) {}
 
   hostConnected() {
-    this.sub = this.obs$.subscribe(this.setProp);
+    this.sub = this.provider().subscribe(v => this.update(v));
+  }
+
+  update(value: T) {
+    this.callback(value);
   }
 
   hostDisconnected() {
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 7a91c68..033df49 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
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {EventType, PluginApi} from '../../../api/plugin';
 import {AdminPluginApi, MenuLink} from '../../../api/admin';
-import {appContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 /**
  * GrAdminApi class.
@@ -27,9 +16,10 @@
   // TODO(TS): maybe define as enum if its a limited set
   private menuLinks: MenuLink[] = [];
 
-  private readonly reporting = appContext.reportingService;
-
-  constructor(private readonly plugin: PluginApi) {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly plugin: PluginApi
+  ) {
     this.reporting.trackApi(this.plugin, 'admin', 'constructor');
     this.plugin.on(EventType.ADMIN_MENU_LINKS, this);
   }
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
deleted file mode 100644
index 9a8f75e..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
+++ /dev/null
@@ -1,59 +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 '../../shared/gr-js-api-interface/gr-js-api-interface.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 pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-admin-api tests', () => {
-  let adminApi;
-
-  setup(() => {
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    getPluginLoader().loadPlugins([]);
-    adminApi = plugin.admin();
-  });
-
-  teardown(() => {
-    adminApi = null;
-  });
-
-  test('exists', () => {
-    assert.isOk(adminApi);
-  });
-
-  test('addMenuLink', () => {
-    adminApi.addMenuLink('text', 'url');
-    const links = adminApi.getMenuLinks();
-    assert.equal(links.length, 1);
-    assert.deepEqual(links[0], {text: 'text', url: 'url', capability: null});
-  });
-
-  test('addMenuLinkWithCapability', () => {
-    adminApi.addMenuLink('text', 'url', 'capability');
-    const links = adminApi.getMenuLinks();
-    assert.equal(links.length, 1);
-    assert.deepEqual(links[0],
-        {text: 'text', url: 'url', capability: 'capability'});
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
new file mode 100644
index 0000000..0d041d4
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {AdminPluginApi} from '../../../api/admin';
+import {PluginApi} from '../../../api/plugin';
+import '../../../test/common-test-setup';
+import {testResolver} from '../../../test/common-test-setup';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
+suite('gr-admin-api tests', () => {
+  let adminApi: AdminPluginApi;
+  let plugin: PluginApi;
+
+  setup(() => {
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    testResolver(pluginLoaderToken).loadPlugins([]);
+    adminApi = plugin.admin();
+  });
+
+  test('exists', () => {
+    assert.isOk(adminApi);
+  });
+
+  test('addMenuLink', () => {
+    adminApi.addMenuLink('text', 'url');
+    const links = adminApi.getMenuLinks();
+    assert.equal(links.length, 1);
+    assert.deepEqual(links[0], {text: 'text', url: 'url', capability: null});
+  });
+
+  test('addMenuLinkWithCapability', () => {
+    adminApi.addMenuLink('text', 'url', 'capability');
+    const links = adminApi.getMenuLinks();
+    assert.equal(links.length, 1);
+    assert.deepEqual(links[0], {
+      text: 'text',
+      url: 'url',
+      capability: 'capability',
+    });
+  });
+});
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 c3d9e4d..bc4e701 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
@@ -1,32 +1,23 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 import {PluginApi} from '../../../api/plugin';
-import {appContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 export class GrAttributeHelper implements AttributeHelperPluginApi {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   private readonly _promises = new Map<string, Promise<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) {
+  constructor(
+    private readonly reporting: ReportingService,
+    readonly plugin: PluginApi,
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    public element: any
+  ) {
     this.reporting.trackApi(this.plugin, 'attribute', 'constructor');
   }
 
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
deleted file mode 100644
index 2d83012..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
+++ /dev/null
@@ -1,81 +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 {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-Polymer({
-  is: 'gr-attribute-helper-some-element',
-  properties: {
-    fooBar: {
-      type: Object,
-      notify: true,
-    },
-  },
-});
-
-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 = plugin.attributeHelper(element);
-  });
-
-  test('resolved on value change from undefined', () => {
-    const promise = instance.get('fooBar').then(value => {
-      assert.equal(value, 'foo! bar!');
-    });
-    element.fooBar = 'foo! bar!';
-    return promise;
-  });
-
-  test('resolves to current attribute value', () => {
-    element.fooBar = 'foo-foo-bar';
-    const promise = instance.get('fooBar').then(value => {
-      assert.equal(value, 'foo-foo-bar');
-    });
-    element.fooBar = 'no bar';
-    return promise;
-  });
-
-  test('bind', () => {
-    const stub = sinon.stub();
-    element.fooBar = 'bar foo';
-    const unbind = instance.bind('fooBar', stub);
-    element.fooBar = 'partridge in a foo tree';
-    element.fooBar = 'five gold bars';
-    assert.equal(stub.callCount, 3);
-    assert.deepEqual(stub.args[0], ['bar foo']);
-    assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
-    assert.deepEqual(stub.args[2], ['five gold bars']);
-    stub.reset();
-    unbind();
-    instance.fooBar = 'ladies dancing';
-    assert.isFalse(stub.called);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts
new file mode 100644
index 0000000..5c15816
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn';
+import {fixture, html, assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
+
+// Attribute helper only works on Polymer notify events, so we cannot use a Lit
+// element for the test.
+Polymer({
+  is: 'foo-bar',
+  properties: {
+    fooBar: {
+      type: Object,
+      notify: true,
+    },
+  },
+});
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'foo-bar': HTMLElement;
+  }
+}
+
+suite('gr-attribute-helper tests', () => {
+  let element: HTMLElement & {fooBar?: string};
+  let instance: AttributeHelperPluginApi;
+
+  setup(async () => {
+    let plugin: PluginApi;
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    element = await fixture(html`<foo-bar></foo-bar>`);
+    instance = plugin!.attributeHelper(element);
+  });
+
+  test('get resolves on value change from undefined', async () => {
+    const fooBarWatch = instance.get('fooBar');
+    element.fooBar = 'foo! bar!';
+    const value = await fooBarWatch;
+
+    assert.equal(value, 'foo! bar!');
+  });
+
+  test('get resolves to current attribute value', async () => {
+    element.fooBar = 'foo-foo-bar';
+    const fooBarWatch = instance.get('fooBar');
+    element.fooBar = 'no bar';
+    const value = await fooBarWatch;
+
+    assert.equal(value, 'foo-foo-bar');
+  });
+
+  test('bind', () => {
+    const stub = sinon.stub();
+    element.fooBar = 'bar foo';
+    const unbind = instance.bind('fooBar', stub);
+    element.fooBar = 'partridge in a foo tree';
+    element.fooBar = 'five gold bars';
+
+    assert.equal(stub.callCount, 3);
+    assert.deepEqual(stub.args[0], ['bar foo']);
+    assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
+    assert.deepEqual(stub.args[2], ['five gold bars']);
+
+    stub.reset();
+    unbind();
+    element.fooBar = 'ladies dancing';
+
+    assert.isFalse(stub.called);
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index 087779e..51cddbe 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {PluginApi} from '../../../api/plugin';
 import {
@@ -22,7 +11,8 @@
   CheckResult,
   CheckRun,
 } from '../../../api/checks';
-import {appContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
 
 const DEFAULT_CONFIG: ChecksApiConfig = {
   fetchPollingIntervalSeconds: 60,
@@ -43,24 +33,28 @@
 export class GrChecksApi implements ChecksPluginApi {
   private state = State.NOT_REGISTERED;
 
-  private readonly checksService = appContext.checksService;
-
-  private readonly reporting = appContext.reportingService;
-
-  constructor(readonly plugin: PluginApi) {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly pluginsModel: PluginsModel,
+    readonly plugin: PluginApi
+  ) {
     this.reporting.trackApi(this.plugin, 'checks', 'constructor');
   }
 
   announceUpdate() {
     this.reporting.trackApi(this.plugin, 'checks', 'announceUpdate');
-    this.checksService.reload(this.plugin.getPluginName());
+    this.pluginsModel.checksAnnounce(this.plugin.getPluginName());
   }
 
   updateResult(run: CheckRun, result: CheckResult) {
     if (result.externalId === undefined) {
       throw new Error('ChecksApi.updateResult() was called without externalId');
     }
-    this.checksService.updateResult(this.plugin.getPluginName(), run, result);
+    this.pluginsModel.checksUpdate({
+      pluginName: this.plugin.getPluginName(),
+      run,
+      result,
+    });
   }
 
   register(provider: ChecksProvider, config?: ChecksApiConfig): void {
@@ -68,10 +62,10 @@
     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(),
+    this.pluginsModel.checksRegister({
+      pluginName: this.plugin.getPluginName(),
       provider,
-      config ?? DEFAULT_CONFIG
-    );
+      config: 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
index e1ec158..54197ea 100644
--- 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
@@ -1,41 +1,28 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-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 '../../../test/common-test-setup';
 import {PluginApi} from '../../../api/plugin';
 import {ChecksPluginApi} from '../../../api/checks';
-
-const gerritPluginApi = _testOnly_initGerritPluginApi();
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 suite('gr-settings-api tests', () => {
   let checksApi: ChecksPluginApi | undefined;
 
   setup(() => {
     let pluginApi: PluginApi | undefined = undefined;
-    gerritPluginApi.install(
+    window.Gerrit.install(
       p => {
         pluginApi = p;
       },
       '0.1',
       'http://test.com/plugins/testplugin/static/test.js'
     );
-    getPluginLoader().loadPlugins([]);
+    testResolver(pluginLoaderToken).loadPlugins([]);
     assert.isOk(pluginApi);
     checksApi = pluginApi!.checks();
   });
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 6b8c4f0..1ca9918 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
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
 
@@ -81,20 +69,17 @@
   }
 
   _createPlaceholder(hookName: string) {
-    class HookPlaceholder extends PolymerElement {
-      static get is() {
-        return hookName;
-      }
+    /**
+     * See gr-endpoint-decorator.ts for how hooks are instantiated and
+     * initialized.
+     */
+    class HookPlaceholder extends HTMLElement {
+      plugin?: PluginApi;
 
-      static get properties() {
-        return {
-          plugin: Object,
-          content: Object,
-        };
-      }
+      content?: Element | null;
     }
 
-    customElements.define(HookPlaceholder.is, HookPlaceholder);
+    customElements.define(hookName, HookPlaceholder);
   }
 
   handleInstanceDetached(instance: T) {
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
deleted file mode 100644
index 883f2a6..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-dom-hooks tests', () => {
-  let instance;
-  let hook;
-
-  setup(() => {
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    instance = new GrDomHooksManager(plugin);
-  });
-
-  suite('placeholder', () => {
-    setup(()=>{
-      sinon.stub(GrDomHook.prototype, '_createPlaceholder');
-      hook = instance.getDomHook('foo-bar');
-    });
-
-    test('registers placeholder class', () => {
-      assert.isTrue(hook._createPlaceholder.calledWithExactly(
-          'testplugin-autogenerated-foo-bar'));
-    });
-
-    test('getModuleName()', () => {
-      const hookName = Object.keys(instance.hooks).pop();
-      assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
-      assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
-    });
-  });
-
-  suite('custom element', () => {
-    setup(() => {
-      hook = instance.getDomHook('foo-bar', 'my-el');
-    });
-
-    test('getModuleName()', () => {
-      const hookName = Object.keys(instance.hooks).pop();
-      assert.equal(hookName, 'foo-bar my-el');
-      assert.equal(hook.getModuleName(), 'my-el');
-    });
-
-    test('onAttached', () => {
-      const onAttachedSpy = sinon.spy();
-      hook.onAttached(onAttachedSpy);
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      hook.handleInstanceAttached(el1);
-      hook.handleInstanceAttached(el2);
-      assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
-      assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
-    });
-
-    test('onDetached', () => {
-      const onDetachedSpy = sinon.spy();
-      hook.onDetached(onDetachedSpy);
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      hook.handleInstanceDetached(el1);
-      assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
-      hook.handleInstanceDetached(el2);
-      assert.isTrue(onDetachedSpy.secondCall.calledWithExactly(el2));
-    });
-
-    test('getAllAttached', () => {
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      el1.textContent = 'one';
-      el2.textContent = 'two';
-      hook.handleInstanceAttached(el1);
-      hook.handleInstanceAttached(el2);
-      assert.deepEqual([el1, el2], hook.getAllAttached());
-      hook.handleInstanceDetached(el1);
-      assert.deepEqual([el2], hook.getAllAttached());
-    });
-
-    test('getLastAttached', () => {
-      const beforeAttachedPromise = hook.getLastAttached().then(
-          el => assert.strictEqual(el1, el));
-      const [el1, el2] = [
-        document.createElement(hook.getModuleName()),
-        document.createElement(hook.getModuleName()),
-      ];
-      el1.textContent = 'one';
-      el2.textContent = 'two';
-      hook.handleInstanceAttached(el1);
-      hook.handleInstanceAttached(el2);
-      const afterAttachedPromise = hook.getLastAttached().then(
-          el => assert.strictEqual(el2, el));
-      return Promise.all([
-        beforeAttachedPromise,
-        afterAttachedPromise,
-      ]);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts
new file mode 100644
index 0000000..cef6a8b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts
@@ -0,0 +1,120 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {HookApi, PluginElement} from '../../../api/hook';
+import {PluginApi} from '../../../api/plugin';
+import '../../../test/common-test-setup';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {GrDomHook, GrDomHooksManager} from './gr-dom-hooks';
+
+suite('gr-dom-hooks tests', () => {
+  let instance: GrDomHooksManager;
+  let hook: HookApi<PluginElement>;
+  let plugin: PluginApi;
+
+  setup(() => {
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    instance = new GrDomHooksManager(plugin);
+  });
+
+  suite('placeholder', () => {
+    let createPlaceHolderStub: sinon.SinonStub;
+
+    setup(() => {
+      createPlaceHolderStub = sinon.stub(
+        GrDomHook.prototype,
+        '_createPlaceholder'
+      );
+      hook = instance.getDomHook('foo-bar');
+    });
+
+    test('registers placeholder class', () => {
+      assert.isTrue(
+        createPlaceHolderStub.calledWithExactly(
+          'testplugin-autogenerated-foo-bar'
+        )
+      );
+    });
+
+    test('getModuleName()', () => {
+      assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
+    });
+  });
+
+  suite('custom element', () => {
+    setup(() => {
+      hook = instance.getDomHook('foo-bar', 'my-el');
+    });
+
+    test('getModuleName()', () => {
+      assert.equal(hook.getModuleName(), 'my-el');
+    });
+
+    test('onAttached', () => {
+      const onAttachedSpy = sinon.spy();
+      hook.onAttached(onAttachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hook.handleInstanceAttached(el1);
+      hook.handleInstanceAttached(el2);
+      assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
+      assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('onDetached', () => {
+      const onDetachedSpy = sinon.spy();
+      hook.onDetached(onDetachedSpy);
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      hook.handleInstanceDetached(el1);
+      assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
+      hook.handleInstanceDetached(el2);
+      assert.isTrue(onDetachedSpy.secondCall.calledWithExactly(el2));
+    });
+
+    test('getAllAttached', () => {
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      el1.textContent = 'one';
+      el2.textContent = 'two';
+      hook.handleInstanceAttached(el1);
+      hook.handleInstanceAttached(el2);
+      assert.deepEqual([el1, el2], hook.getAllAttached());
+      hook.handleInstanceDetached(el1);
+      assert.deepEqual([el2], hook.getAllAttached());
+    });
+
+    test('getLastAttached', () => {
+      const beforeAttachedPromise = hook
+        .getLastAttached()
+        .then((el: HTMLElement) => assert.strictEqual(el1, el));
+      const [el1, el2] = [
+        document.createElement(hook.getModuleName()),
+        document.createElement(hook.getModuleName()),
+      ];
+      el1.textContent = 'one';
+      el2.textContent = 'two';
+      hook.handleInstanceAttached(el1);
+      hook.handleInstanceAttached(el2);
+      const afterAttachedPromise = hook
+        .getLastAttached()
+        .then((el: HTMLElement) => assert.strictEqual(el2, el));
+      return Promise.all([beforeAttachedPromise, afterAttachedPromise]);
+    });
+  });
+});
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 00fa77d..9cacaea 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
@@ -1,38 +1,25 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-endpoint-decorator_html';
+import {html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
 import {
-  getPluginEndpoints,
+  EndpointType,
   ModuleInfo,
 } 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 {PluginApi} from '../../../api/plugin';
 import {HookApi, PluginElement} from '../../../api/hook';
+import {getAppContext} from '../../../services/app-context';
+import {assertIsDefined} from '../../../utils/common-util';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {resolve} from '../../../models/dependency';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
 @customElement('gr-endpoint-decorator')
-export class GrEndpointDecorator extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEndpointDecorator extends LitElement {
   /**
    * If set, then this endpoint only invokes callbacks registered by the target
    * plugin. For example this is used for the `check-result-expanded` endpoint.
@@ -42,37 +29,61 @@
   @property({type: String})
   targetPlugin?: string;
 
+  /** Required. */
   @property({type: String})
-  name!: string;
+  name?: string;
 
-  @property({type: Object})
-  _domHooks = new Map<PluginElement, HookApi<PluginElement>>();
+  private readonly domHooks = new Map<PluginElement, HookApi<PluginElement>>();
 
-  @property({type: Object})
-  _initializedPlugins = new Map<string, boolean>();
+  private readonly initializedPlugins = new Map<string, boolean>();
 
-  /**
-   * This is the callback that the plugin endpoint manager should be calling
-   * when a new element is registered for this endpoint. It points to
-   * _initModule().
-   */
-  _endpointCallBack: (info: ModuleInfo) => void = () => {};
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  override render() {
+    return html`<slot></slot>`;
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    assertIsDefined(this.name);
+    this.getPluginLoader().pluginEndPoints.onNewEndpoint(
+      this.name,
+      this.initModule
+    );
+    this.getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        assertIsDefined(this.name);
+        const modules = this.getPluginLoader().pluginEndPoints.getDetails(
+          this.name
+        );
+        for (const module of modules) {
+          this.initModule(module);
+        }
+      });
+  }
 
   override disconnectedCallback() {
-    for (const [el, domHook] of this._domHooks) {
+    for (const [el, domHook] of this.domHooks) {
       domHook.handleInstanceDetached(el);
     }
-    getPluginEndpoints().onDetachedEndpoint(this.name, this._endpointCallBack);
+    assertIsDefined(this.name);
+    this.getPluginLoader().pluginEndPoints.onDetachedEndpoint(
+      this.name,
+      this.initModule
+    );
     super.disconnectedCallback();
   }
 
-  _initDecoration(
+  private initDecoration(
     name: string,
     plugin: PluginApi,
     slot?: string
   ): Promise<HTMLElement> {
     const el = document.createElement(name) as PluginElement;
-    return this._initProperties(
+    return this.initProperties(
       el,
       plugin,
       // The direct children are slotted into <slot>, so this is identical to
@@ -85,15 +96,16 @@
       if (slot && slotEl?.parentNode) {
         slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
       } else {
-        this._appendChild(el);
+        this.appendChild(el);
       }
       return el;
     });
   }
 
-  // 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> {
+  private initReplacement(
+    name: string,
+    plugin: PluginApi
+  ): Promise<HTMLElement> {
     // The direct children are slotted into <slot>, so they are identical to
     // this.shadowRoot.querySelector('slot').assignedElements().
     const directChildren = [...this.childNodes];
@@ -101,22 +113,23 @@
     [...directChildren, ...shadowChildren]
       .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
       .filter(node => node.nodeName !== 'SLOT')
-      .forEach(node => (node as ChildNode).remove());
+      .forEach(node => node.remove());
     const el = document.createElement(name);
-    return this._initProperties(el, plugin).then((el: HTMLElement) =>
-      this._appendChild(el)
+    return this.initProperties(el, plugin).then((el: HTMLElement) =>
+      this.appendChild(el)
     );
   }
 
-  _getEndpointParams() {
+  private getEndpointParams() {
     return Array.from(this.querySelectorAll('gr-endpoint-param'));
   }
 
-  _initProperties(
+  private initProperties(
     el: PluginElement,
     plugin: PluginApi,
     content?: Element | null
   ) {
+    const pluginName = plugin.getPluginName();
     el.plugin = plugin;
     // The content is (only?) used in ChangeReplyPluginApi.
     // Maybe it would be better for the consumer side to figure out the content
@@ -126,11 +139,18 @@
     if (content) {
       el.content = content as HTMLElement;
     }
-    const expectProperties = this._getEndpointParams().map(paramEl => {
+    const expectProperties = this.getEndpointParams().map(paramEl => {
       const helper = plugin.attributeHelper(paramEl);
-      // TODO: this should be replaced by accessing the property directly
-      const paramName = paramEl.getAttribute('name');
-      if (!paramName) throw Error('plugin endpoint parameter missing a name');
+      const paramName = paramEl.name;
+      if (!paramName) {
+        this.reporting.error(
+          `Plugin '${pluginName}', endpoint '${this.name}'`,
+          new Error(
+            `Plugin '${pluginName}', endpoint '${this.name}': param is missing a name.`
+          )
+        );
+        return;
+      }
       return helper.get('value').then(() =>
         helper.bind('value', value =>
           // Note that despite the naming this sets the property, not the
@@ -146,9 +166,12 @@
         // 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}`
+          this.reporting.error(
+            `Plugin '${pluginName}', endpoint '${this.name}'`,
+            new Error(
+              `Plugin ${pluginName}, endpoint ${this.name}: ` +
+                'Timeout waiting for endpoint properties initialization'
+            )
           );
         }, INIT_PROPERTIES_TIMEOUT_MS))
     );
@@ -159,53 +182,40 @@
       });
   }
 
-  _appendChild(el: HTMLElement): HTMLElement {
-    if (!this.root) throw Error('plugin endpoint decorator missing root');
-    return this.root.appendChild(el);
-  }
-
-  _initModule({moduleName, plugin, type, domHook, slot}: ModuleInfo) {
+  private readonly initModule = ({
+    moduleName,
+    plugin,
+    type,
+    domHook,
+    slot,
+  }: ModuleInfo) => {
     const name = plugin.getPluginName() + '.' + moduleName;
     if (this.targetPlugin) {
       if (this.targetPlugin !== plugin.getPluginName()) return;
     }
-    if (this._initializedPlugins.get(name)) {
+    if (this.initializedPlugins.get(name)) {
       return;
     }
     let initPromise;
     switch (type) {
-      case 'decorate':
-        initPromise = this._initDecoration(moduleName, plugin, slot);
+      case EndpointType.DECORATE:
+        initPromise = this.initDecoration(moduleName, plugin, slot);
         break;
-      case 'replace':
-        initPromise = this._initReplacement(moduleName, plugin);
+      case EndpointType.REPLACE:
+        initPromise = this.initReplacement(moduleName, plugin);
         break;
     }
     if (!initPromise) {
       throw Error(`unknown endpoint type ${type} used by plugin ${name}`);
     }
-    this._initializedPlugins.set(name, true);
+    this.initializedPlugins.set(name, true);
     initPromise.then(el => {
       if (domHook) {
         domHook.handleInstanceAttached(el);
-        this._domHooks.set(el, domHook);
+        this.domHooks.set(el, domHook);
       }
     });
-  }
-
-  override ready() {
-    super.ready();
-    if (!this.name) return;
-    this._endpointCallBack = (info: ModuleInfo) => this._initModule(info);
-    getPluginEndpoints().onNewEndpoint(this.name, this._endpointCallBack);
-    getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() =>
-        getPluginEndpoints()
-          .getDetails(this.name)
-          .forEach(this._initModule, this)
-      );
-  }
+  };
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_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` <slot></slot> `;
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
deleted file mode 100644
index 1be5e82..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
+++ /dev/null
@@ -1,198 +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 './gr-endpoint-decorator.js';
-import '../gr-endpoint-param/gr-endpoint-param.js';
-import '../gr-endpoint-slot/gr-endpoint-slot.js';
-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 {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-const basicFixture = fixtureFromTemplate(
-    html`<div>
-  <gr-endpoint-decorator name="first">
-    <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
-    <p>
-      <span>test slot</span>
-      <gr-endpoint-slot name="test"></gr-endpoint-slot>
-    </p>
-  </gr-endpoint-decorator>
-  <gr-endpoint-decorator name="second">
-    <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
-  </gr-endpoint-decorator>
-  <gr-endpoint-decorator name="banana">
-    <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
-  </gr-endpoint-decorator>
-</div>`
-);
-
-suite('gr-endpoint-decorator', () => {
-  let container;
-
-  let plugin;
-  let decorationHook;
-  let decorationHookWithSlot;
-  let replacementHook;
-
-  setup(async () => {
-    resetPlugins();
-    container = basicFixture.instantiate();
-    pluginApi.install(p => plugin = p, '0.1',
-        'http://some/plugin/url.js');
-    // Decoration
-    decorationHook = plugin.registerCustomComponent('first', 'some-module');
-    decorationHookWithSlot = plugin.registerCustomComponent(
-        'first',
-        'some-module-2',
-        {slot: 'test'}
-    );
-    // Replacement
-    replacementHook = plugin.registerCustomComponent(
-        'second', 'other-module', {replace: true});
-    // Mimic all plugins loaded.
-    getPluginLoader().loadPlugins([]);
-    await flush();
-  });
-
-  teardown(() => {
-    resetPlugins();
-  });
-
-  test('imports plugin-provided modules into endpoints', () => {
-    const endpoints =
-        Array.from(container.querySelectorAll('gr-endpoint-decorator'));
-    assert.equal(endpoints.length, 3);
-  });
-
-  test('decoration', () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="first"]');
-    const modules = Array.from(element.root.children).filter(
-        element => element.nodeName === 'SOME-MODULE');
-    assert.equal(modules.length, 1);
-    const [module] = modules;
-    assert.isOk(module);
-    assert.equal(module['someparam'], 'barbar');
-    return decorationHook.getLastAttached()
-        .then(element => {
-          assert.strictEqual(element, module);
-        })
-        .then(() => {
-          element.remove();
-          assert.equal(decorationHook.getAllAttached().length, 0);
-        });
-  });
-
-  test('decoration with slot', () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="first"]');
-    const modules = [...element.querySelectorAll('some-module-2')];
-    assert.equal(modules.length, 1);
-    const [module] = modules;
-    assert.isOk(module);
-    assert.equal(module['someparam'], 'barbar');
-    return decorationHookWithSlot.getLastAttached()
-        .then(element => {
-          assert.strictEqual(element, module);
-        })
-        .then(() => {
-          element.remove();
-          assert.equal(decorationHookWithSlot.getAllAttached().length, 0);
-        });
-  });
-
-  test('replacement', () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="second"]');
-    const module = Array.from(element.root.children).find(
-        element => element.nodeName === 'OTHER-MODULE');
-    assert.isOk(module);
-    assert.equal(module['someparam'], 'foofoo');
-    return replacementHook.getLastAttached()
-        .then(element => {
-          assert.strictEqual(element, module);
-        })
-        .then(() => {
-          element.remove();
-          assert.equal(replacementHook.getAllAttached().length, 0);
-        });
-  });
-
-  test('late registration', async () => {
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="banana"]');
-    const module = Array.from(element.root.children).find(
-        element => element.nodeName === 'NOOB-NOOB');
-    assert.isOk(module);
-  });
-
-  test('two modules', async () => {
-    plugin.registerCustomComponent('banana', 'mod-one');
-    plugin.registerCustomComponent('banana', 'mod-two');
-    await flush();
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="banana"]');
-    const module1 = Array.from(element.root.children).find(
-        element => element.nodeName === 'MOD-ONE');
-    assert.isOk(module1);
-    const module2 = Array.from(element.root.children).find(
-        element => element.nodeName === 'MOD-TWO');
-    assert.isOk(module2);
-  });
-
-  test('late param setup', async () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="banana"]');
-    const param = element.querySelector('gr-endpoint-param');
-    param['value'] = undefined;
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    let module = Array.from(element.root.children).find(
-        element => element.nodeName === 'NOOB-NOOB');
-    // Module waits for param to be defined.
-    assert.isNotOk(module);
-    const value = {abc: 'def'};
-    param.value = value;
-
-    await flush();
-    module = Array.from(element.root.children).find(
-        element => element.nodeName === 'NOOB-NOOB');
-    assert.isOk(module);
-    assert.strictEqual(module['someParam'], value);
-  });
-
-  test('param is bound', async () => {
-    const element =
-        container.querySelector('gr-endpoint-decorator[name="banana"]');
-    const param = element.querySelector('gr-endpoint-param');
-    const value1 = {abc: 'def'};
-    const value2 = {def: 'abc'};
-    param.value = value1;
-    plugin.registerCustomComponent('banana', 'noob-noob');
-    await flush();
-    const module = Array.from(element.root.children).find(
-        element => element.nodeName === 'NOOB-NOOB');
-    assert.strictEqual(module['someParam'], value1);
-    param.value = value2;
-    assert.strictEqual(module['someParam'], value2);
-  });
-});
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
new file mode 100644
index 0000000..57888fa
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
@@ -0,0 +1,254 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-endpoint-decorator';
+import '../gr-endpoint-param/gr-endpoint-param';
+import '../gr-endpoint-slot/gr-endpoint-slot';
+import {fixture, html, assert} from '@open-wc/testing';
+import {mockPromise, queryAndAssert} from '../../../test/test-utils';
+import {GrEndpointDecorator} from './gr-endpoint-decorator';
+import {PluginApi} from '../../../api/plugin';
+import {GrEndpointParam} from '../gr-endpoint-param/gr-endpoint-param';
+
+suite('gr-endpoint-decorator', () => {
+  let container: HTMLElement;
+
+  let plugin: PluginApi;
+  let decorationHook: any;
+  let decorationHookWithSlot: any;
+  let replacementHook: any;
+  let first: GrEndpointDecorator;
+  let second: GrEndpointDecorator;
+  let banana: GrEndpointDecorator;
+
+  setup(async () => {
+    container = await fixture(
+      html`<div>
+        <gr-endpoint-decorator name="first">
+          <gr-endpoint-param
+            name="first-param"
+            .value=${'barbar'}
+          ></gr-endpoint-param>
+          <p>
+            <span>test slot</span>
+            <gr-endpoint-slot name="test"></gr-endpoint-slot>
+          </p>
+        </gr-endpoint-decorator>
+        <gr-endpoint-decorator name="second">
+          <gr-endpoint-param
+            name="second-param"
+            .value=${'foofoo'}
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <gr-endpoint-decorator name="banana">
+          <gr-endpoint-param
+            name="banana-param"
+            .value=${'yes'}
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>`
+    );
+    first = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="first"]'
+    );
+    second = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="second"]'
+    );
+    banana = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="banana"]'
+    );
+
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://some/plugin/url.js'
+    );
+    // Decoration
+    decorationHook = plugin.registerCustomComponent('first', 'some-module');
+    const decorationHookPromise = mockPromise();
+    decorationHook.onAttached(() => decorationHookPromise.resolve());
+
+    // Decoration with slot
+    decorationHookWithSlot = plugin.registerCustomComponent(
+      'first',
+      'some-module-2',
+      {slot: 'test'}
+    );
+    const decorationHookSlotPromise = mockPromise();
+    decorationHookWithSlot.onAttached(() =>
+      decorationHookSlotPromise.resolve()
+    );
+
+    // Replacement
+    replacementHook = plugin.registerCustomComponent('second', 'other-module', {
+      replace: true,
+    });
+    const replacementHookPromise = mockPromise();
+    replacementHook.onAttached(() => replacementHookPromise.resolve());
+
+    await decorationHookPromise;
+    await decorationHookSlotPromise;
+    await replacementHookPromise;
+  });
+
+  test('imports plugin-provided modules into endpoints', () => {
+    const endpoints = Array.from(
+      container.querySelectorAll('gr-endpoint-decorator')
+    );
+    assert.equal(endpoints.length, 3);
+  });
+
+  test('first decoration', () => {
+    const element = first;
+    const modules = Array.from(element.children).filter(
+      element => element.nodeName === 'SOME-MODULE'
+    );
+    assert.equal(modules.length, 1);
+    const [module] = modules;
+    assert.isOk(module);
+    assert.equal((module as any)['first-param'], 'barbar');
+    return decorationHook
+      .getLastAttached()
+      .then((element: any) => {
+        assert.strictEqual(element, module);
+      })
+      .then(() => {
+        element.remove();
+        assert.equal(decorationHook.getAllAttached().length, 0);
+      });
+  });
+
+  test('decoration with slot', () => {
+    const element = first;
+    const modules = [...element.querySelectorAll('some-module-2')];
+    assert.equal(modules.length, 1);
+    const [module] = modules;
+    assert.isOk(module);
+    assert.equal((module as any)['first-param'], 'barbar');
+    return decorationHookWithSlot
+      .getLastAttached()
+      .then((element: any) => {
+        assert.strictEqual(element, module);
+      })
+      .then(() => {
+        element.remove();
+        assert.equal(decorationHookWithSlot.getAllAttached().length, 0);
+      });
+  });
+
+  test('replacement', () => {
+    const element = second;
+    const module = Array.from(element.children).find(
+      element => element.nodeName === 'OTHER-MODULE'
+    );
+    assert.isOk(module);
+    assert.equal((module as any)['second-param'], 'foofoo');
+    return replacementHook
+      .getLastAttached()
+      .then((element: any) => {
+        assert.strictEqual(element, module);
+      })
+      .then(() => {
+        element.remove();
+        assert.equal(replacementHook.getAllAttached().length, 0);
+      });
+  });
+
+  test('late registration', async () => {
+    const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+    const bananaHookPromise = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise.resolve());
+    await bananaHookPromise;
+
+    const element = banana;
+    const module = Array.from(element.children).find(
+      element => element.nodeName === 'NOOB-NOOB'
+    );
+    assert.isOk(module);
+  });
+
+  test('two modules', async () => {
+    const bananaHook1 = plugin.registerCustomComponent('banana', 'mod-one');
+    const bananaHookPromise1 = mockPromise();
+    bananaHook1.onAttached(() => bananaHookPromise1.resolve());
+    await bananaHookPromise1;
+
+    const bananaHook = plugin.registerCustomComponent('banana', 'mod-two');
+    const bananaHookPromise2 = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise2.resolve());
+    await bananaHookPromise2;
+
+    const element = banana;
+    const module1 = Array.from(element.children).find(
+      element => element.nodeName === 'MOD-ONE'
+    );
+    assert.isOk(module1);
+    const module2 = Array.from(element.children).find(
+      element => element.nodeName === 'MOD-TWO'
+    );
+    assert.isOk(module2);
+  });
+
+  test('late param setup', async () => {
+    let element = banana;
+    const param = queryAndAssert<GrEndpointParam>(element, 'gr-endpoint-param');
+    param['value'] = undefined;
+    await param.updateComplete;
+
+    const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+    const bananaHookPromise = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise.resolve());
+
+    element = queryAndAssert<GrEndpointDecorator>(
+      container,
+      'gr-endpoint-decorator[name="banana"]'
+    );
+    let module = Array.from(element.children).find(
+      element => element.nodeName === 'NOOB-NOOB'
+    );
+    // Module waits for param to be defined.
+    assert.isNotOk(module);
+    const value = {abc: 'def'};
+    param.value = value;
+    await param.updateComplete;
+    await bananaHookPromise;
+
+    module = Array.from(element.children).find(
+      element => element.nodeName === 'NOOB-NOOB'
+    );
+    assert.isOk(module);
+    assert.strictEqual((module as any)['banana-param'], value);
+  });
+
+  test('param is bound', async () => {
+    const element = banana;
+    const param = queryAndAssert<GrEndpointParam>(element, 'gr-endpoint-param');
+    const value1 = {abc: 'def'};
+    const value2 = {def: 'abc'};
+    param.value = value1;
+    await param.updateComplete;
+
+    const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob');
+    const bananaHookPromise = mockPromise();
+    bananaHook.onAttached(() => bananaHookPromise.resolve());
+    await bananaHookPromise;
+
+    const module = Array.from(element.children).find(
+      element => element.nodeName === 'NOOB-NOOB'
+    );
+    assert.isOk(module);
+    assert.strictEqual((module as any)['banana-param'], value1);
+
+    param.value = value2;
+    await param.updateComplete;
+    assert.strictEqual((module as any)['banana-param'], value2);
+  });
+});
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 ee89c86..e73aad6 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
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -24,26 +13,18 @@
 }
 
 @customElement('gr-endpoint-param')
-export class GrEndpointParam extends PolymerElement {
-  @property({type: String, reflectToAttribute: true})
+export class GrEndpointParam extends LitElement {
+  @property({type: String, reflect: true})
   name = '';
 
-  @property({
-    type: Object,
-    notify: true,
-    observer: '_valueChanged',
-  })
+  @property({type: Object})
   value?: unknown;
 
-  _valueChanged(value: unknown) {
-    /* In polymer 2 the following change was made:
-    "Property change notifications (property-changed events) aren't fired when
-    the value changes as a result of a binding from the host"
-    (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
-    To workaround this problem, we fire the event from the observer.
-    In some cases this fire the event twice, but our code is
-    ready for it.
-    */
-    this.dispatchEvent(new CustomEvent('value-changed', {detail: {value}}));
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('value')) {
+      this.dispatchEvent(
+        new CustomEvent('value-changed', {detail: {value: this.value}})
+      );
+    }
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
index 4999716..ffe9400 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
@@ -1,28 +1,23 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-endpoint-slot': GrEndpointSlot;
+  }
+}
 
 /**
  * `gr-endpoint-slot` is used when need control over where
  * the registered element should appear inside of the endpoint.
  */
 @customElement('gr-endpoint-slot')
-export class GrEndpointSlot extends PolymerElement {
+export class GrEndpointSlot extends LitElement {
   @property({type: String})
   name!: string;
 }
@@ -34,6 +29,6 @@
  * This should help catch errors when you assign an element without
  * name to GrEndpointSlot type.
  */
-export interface GrEndpointSlot extends PolymerElement {
+export interface GrEndpointSlot extends LitElement {
   name: string;
 }
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 0c36cd5..641d87b 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
@@ -1,30 +1,21 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {
   EventHelperPluginApi,
   UnsubscribeCallback,
 } from '../../../api/event-helper';
 import {PluginApi} from '../../../api/plugin';
-import {appContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 export class GrEventHelper implements EventHelperPluginApi {
-  private readonly reporting = appContext.reportingService;
-
-  constructor(readonly plugin: PluginApi, readonly element: HTMLElement) {
+  constructor(
+    private readonly reporting: ReportingService,
+    readonly plugin: PluginApi,
+    readonly element: HTMLElement
+  ) {
     this.reporting.trackApi(this.plugin, 'event', 'constructor');
   }
 
@@ -42,10 +33,10 @@
    */
   onClick(callback: (event: Event) => boolean) {
     this.reporting.trackApi(this.plugin, 'event', 'onClick');
-    return this._listen(this.element, callback);
+    return this.listen(this.element, callback);
   }
 
-  _listen(
+  private listen(
     container: HTMLElement,
     callback: (event: Event) => boolean
   ): UnsubscribeCallback {
@@ -56,8 +47,12 @@
         let mayContinue = true;
         try {
           mayContinue = callback(e);
-        } catch (exception) {
-          this.reporting.error(exception);
+        } catch (exception: unknown) {
+          this.reporting.error(
+            'GrEventHelper',
+            new Error('event listener callback error'),
+            exception
+          );
         }
         if (mayContinue === false) {
           e.stopImmediatePropagation();
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
deleted file mode 100644
index 4e3d657..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
-import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-import {mockPromise} from '../../../test/test-utils.js';
-
-Polymer({
-  is: 'gr-event-helper-some-element',
-
-  properties: {
-    fooBar: {
-      type: Object,
-      notify: true,
-    },
-  },
-});
-
-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 = plugin.eventHelper(element);
-  });
-
-  test('onTap()', async () => {
-    const promise = mockPromise();
-    instance.onTap(() => {
-      promise.resolve();
-    });
-    MockInteractions.tap(element);
-    await promise;
-  });
-
-  test('onTap() cancel', () => {
-    const tapStub = sinon.stub();
-    addListener(element.parentElement, 'tap', tapStub);
-    instance.onTap(() => false);
-    MockInteractions.tap(element);
-    flush();
-    assert.isFalse(tapStub.called);
-  });
-
-  test('onClick() cancel', () => {
-    const tapStub = sinon.stub();
-    element.parentElement.addEventListener('click', tapStub);
-    instance.onTap(() => false);
-    MockInteractions.tap(element);
-    flush();
-    assert.isFalse(tapStub.called);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.ts
new file mode 100644
index 0000000..eaaab5d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.ts
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+// eslint-disable-next-line import/named
+import {fixture, html, assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {EventHelperPluginApi} from '../../../api/event-helper';
+
+suite('gr-event-helper tests', () => {
+  let element: HTMLDivElement;
+  let eventHelper: EventHelperPluginApi;
+
+  setup(async () => {
+    let plugin: PluginApi;
+    window.Gerrit.install(
+      p => (plugin = p),
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    element = await fixture(html`<div></div>`);
+    eventHelper = plugin!.eventHelper(element);
+  });
+
+  test('listens via onTap', async () => {
+    let parentReceivedClick = false;
+    element.parentElement!.addEventListener(
+      'click',
+      () => (parentReceivedClick = true)
+    );
+    let helperReceivedClick = false;
+
+    eventHelper.onTap(() => {
+      helperReceivedClick = true;
+      return true;
+    });
+    element.click();
+
+    assert.isTrue(helperReceivedClick);
+    assert.isTrue(parentReceivedClick);
+  });
+
+  test('listens via onClick', async () => {
+    let parentReceivedClick = false;
+    element.parentElement!.addEventListener(
+      'click',
+      () => (parentReceivedClick = true)
+    );
+    let helperReceivedClick = false;
+
+    eventHelper.onClick(() => {
+      helperReceivedClick = true;
+      return true;
+    });
+    element.click();
+
+    assert.isTrue(helperReceivedClick);
+    assert.isTrue(parentReceivedClick);
+  });
+
+  test('onTap false blocks event to parent', async () => {
+    let parentReceivedTap = false;
+    element.parentElement!.addEventListener(
+      'tap',
+      () => (parentReceivedTap = true)
+    );
+
+    eventHelper.onTap(() => false);
+    element.click();
+
+    assert.isFalse(parentReceivedTap);
+  });
+
+  test('onClick false blocks event to parent', async () => {
+    let parentReceivedTap = false;
+    element.parentElement!.addEventListener(
+      'tap',
+      () => (parentReceivedTap = true)
+    );
+
+    eventHelper.onClick(() => false);
+    element.click();
+
+    assert.isFalse(parentReceivedTap);
+  });
+});
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
deleted file mode 100644
index 88964bf..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {updateStyles} from '@polymer/polymer/lib/mixins/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';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {customElement, property} from '@polymer/decorators';
-
-@customElement('gr-external-style')
-export class GrExternalStyle extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  // This is a required value for this component.
-  @property({type: String})
-  name!: string;
-
-  @property({type: Array})
-  _stylesApplied: string[] = [];
-
-  _applyStyle(name: string) {
-    if (this._stylesApplied.includes(name)) {
-      return;
-    }
-    this._stylesApplied.push(name);
-
-    const s = document.createElement('style');
-    s.setAttribute('include', name);
-    const cs = document.createElement('custom-style');
-    cs.appendChild(s);
-    // When using Shadow DOM <custom-style> must be added to the <body>.
-    // Within <gr-external-style> itself the styles would have no effect.
-    const topEl = document.getElementsByTagName('body')[0];
-    topEl.insertBefore(cs, topEl.firstChild);
-    updateStyles();
-  }
-
-  _importAndApply() {
-    const moduleNames = getPluginEndpoints().getModules(this.name);
-    for (const name of moduleNames) {
-      this._applyStyle(name);
-    }
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this._importAndApply();
-  }
-
-  override ready() {
-    super.ready();
-    getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => this._importAndApply());
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-external-style': GrExternalStyle;
-  }
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_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` <slot></slot> `;
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
deleted file mode 100644
index a192f80..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../test/common-test-setup-karma.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import './gr-external-style.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 pluginApi = _testOnly_initGerritPluginApi();
-
-const basicFixture = fixtureFromTemplate(
-    html`<gr-external-style name="foo"></gr-external-style>`
-);
-
-suite('gr-external-style integration tests', () => {
-  const TEST_URL = 'http://some.com/plugins/url.js';
-
-  let element;
-  let plugin;
-
-  const installPlugin = () => {
-    if (plugin) { return; }
-    pluginApi.install(p => {
-      plugin = p;
-    }, '0.1', TEST_URL);
-  };
-
-  const createElement = () => {
-    element = basicFixture.instantiate();
-    sinon.spy(element, '_applyStyle');
-  };
-
-  /**
-   * Installs the plugin, creates the element, registers style module.
-   */
-  const lateRegister = () => {
-    installPlugin();
-    createElement();
-    plugin.registerStyleModule('foo', 'some-module');
-  };
-
-  /**
-   * Installs the plugin, registers style module, creates the element.
-   */
-  const earlyRegister = () => {
-    installPlugin();
-    plugin.registerStyleModule('foo', 'some-module');
-    createElement();
-  };
-
-  setup(() => {
-    sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
-        .returns(Promise.resolve());
-  });
-
-  teardown(() => {
-    resetPlugins();
-    document.body.querySelectorAll('custom-style')
-        .forEach(style => style.remove());
-  });
-
-  test('applies plugin-provided styles', async () => {
-    lateRegister();
-    await new Promise(flush);
-    assert.isTrue(element._applyStyle.calledWith('some-module'));
-  });
-
-  test('does not double apply', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    plugin.registerStyleModule('foo', 'some-module');
-    await new Promise(flush);
-    const stylesApplied =
-        element._stylesApplied.filter(name => name === 'some-module');
-    assert.strictEqual(stylesApplied.length, 1);
-  });
-
-  test('loads and applies preloaded modules', async () => {
-    earlyRegister();
-    await new Promise(flush);
-    assert.isTrue(element._applyStyle.calledWith('some-module'));
-  });
-});
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 7fee4a0..80765a8 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
@@ -1,46 +1,43 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {LitElement, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {LitElement} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
 import {ServerInfo} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 @customElement('gr-plugin-host')
 export class GrPluginHost extends LitElement {
-  @property({type: Object})
+  @state()
   config?: ServerInfo;
 
-  _configChanged(config: ServerInfo) {
-    const plugins = config.plugin;
-    const jsPlugins = (plugins && plugins.js_resource_paths) || [];
-    const shouldLoadTheme = !!config.default_theme;
-    // config.default_theme is defined when shouldLoadTheme is true
-    const themeToLoad: string[] = shouldLoadTheme
-      ? [config.default_theme!]
-      : [];
-    // Theme should be loaded first for better UX.
-    const pluginsPending = themeToLoad.concat(jsPlugins);
-    getPluginLoader().loadPlugins(pluginsPending);
-  }
+  private readonly getConfigModel = resolve(this, configModelToken);
 
-  override updated(changedProperties: PropertyValues<GrPluginHost>) {
-    if (changedProperties.has('config') && this.config) {
-      this._configChanged(this.config);
-    }
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        if (!config) return;
+        const jsPlugins = config?.plugin?.js_resource_paths ?? [];
+        const themes: string[] = config?.default_theme
+          ? [config.default_theme]
+          : [];
+        const instanceId = config?.gerrit?.instance_id;
+        this.getPluginLoader().loadPlugins(
+          [...themes, ...jsPlugins],
+          instanceId
+        );
+      }
+    );
   }
 }
 
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
deleted file mode 100644
index f555103..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-plugin-host.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-const basicFixture = fixtureFromElement('gr-plugin-host');
-
-suite('gr-plugin-host tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-
-    sinon.stub(document.body, 'appendChild');
-  });
-
-  test('load plugins should be called', async () => {
-    sinon.stub(getPluginLoader(), 'loadPlugins');
-    element.config = {
-      plugin: {
-        js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
-      },
-    };
-    await flush();
-    assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
-    assert.isTrue(getPluginLoader().loadPlugins.calledWith([
-      'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-    ]));
-  });
-
-  test('theme plugins should be loaded if enabled', async () => {
-    sinon.stub(getPluginLoader(), 'loadPlugins');
-    element.config = {
-      default_theme: 'gerrit-theme.js',
-      plugin: {
-        js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
-      },
-    };
-    await flush();
-    assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
-    assert.isTrue(getPluginLoader().loadPlugins.calledWith([
-      'gerrit-theme.js', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
-    ]));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
new file mode 100644
index 0000000..0699e49
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-plugin-host';
+import {GrPluginHost} from './gr-plugin-host';
+import {fixture, html, assert} from '@open-wc/testing';
+import {SinonStub} from 'sinon';
+import {createServerInfo} from '../../../test/test-data-generators';
+import {
+  ConfigModel,
+  configModelToken,
+} from '../../../models/config/config-model';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
+suite('gr-plugin-host tests', () => {
+  let element: GrPluginHost;
+  let loadPluginsStub: SinonStub;
+  let configModel: ConfigModel;
+
+  setup(async () => {
+    loadPluginsStub = sinon.stub(
+      testResolver(pluginLoaderToken),
+      'loadPlugins'
+    );
+    element = await fixture<GrPluginHost>(html`
+      <gr-plugin-host></gr-plugin-host>
+    `);
+    await element.updateComplete;
+    configModel = testResolver(configModelToken);
+
+    sinon.stub(document.body, 'appendChild');
+  });
+
+  test('load plugins should be called', async () => {
+    loadPluginsStub.reset();
+    configModel.updateServerConfig({
+      ...createServerInfo(),
+      plugin: {
+        has_avatars: false,
+        js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
+      },
+    });
+    assert.isTrue(loadPluginsStub.calledOnce);
+    assert.isTrue(
+      loadPluginsStub.calledWith([
+        'plugins/42',
+        'plugins/foo/bar',
+        'plugins/baz',
+      ])
+    );
+  });
+
+  test('theme plugins should be loaded if enabled', async () => {
+    loadPluginsStub.reset();
+    configModel.updateServerConfig({
+      ...createServerInfo(),
+      default_theme: 'gerrit-theme.js',
+      plugin: {
+        has_avatars: false,
+        js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
+      },
+    });
+    assert.isTrue(loadPluginsStub.calledOnce);
+    assert.isTrue(
+      loadPluginsStub.calledWith([
+        'gerrit-theme.js',
+        'plugins/42',
+        'plugins/foo/bar',
+        'plugins/baz',
+      ])
+    );
+  });
+
+  test('plugins loaded with instanceId ', async () => {
+    loadPluginsStub.reset();
+    const config = createServerInfo();
+    config.gerrit.instance_id = 'test-id';
+    configModel.updateServerConfig(config);
+    assert.isTrue(loadPluginsStub.calledOnce);
+    assert.isTrue(loadPluginsStub.calledWith([], 'test-id'));
+  });
+});
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 af6a159..7c99f14 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
@@ -1,24 +1,12 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../shared/gr-overlay/gr-overlay';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-plugin-popup_html';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {customElement} from '@polymer/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html} from 'lit';
+import {customElement, query} from 'lit/decorators.js';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -26,26 +14,29 @@
   }
 }
 
-export interface GrPluginPopup {
-  $: {
-    overlay: GrOverlay;
-  };
-}
 @customElement('gr-plugin-popup')
-export class GrPluginPopup extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrPluginPopup extends LitElement {
+  @query('#modal') protected modal!: HTMLDialogElement;
+
+  static override get styles() {
+    return [sharedStyles, modalStyles];
+  }
+
+  override render() {
+    return html`<dialog id="modal">
+      <slot></slot>
+    </dialog>`;
   }
 
   get opened() {
-    return this.$.overlay.opened;
+    return this.modal.hasAttribute('open');
   }
 
   open() {
-    return this.$.overlay.open();
+    this.modal.showModal();
   }
 
   close() {
-    this.$.overlay.close();
+    this.modal.close();
   }
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts
deleted file mode 100644
index aa7a92d..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_html.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-overlay id="overlay" with-backdrop="">
-    <slot></slot>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
index 342cf83..8e7605d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
@@ -1,48 +1,37 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import {stubElement} from '../../../test/test-utils';
 import './gr-plugin-popup';
 import {GrPluginPopup} from './gr-plugin-popup';
 
-const basicFixture = fixtureFromElement('gr-plugin-popup');
-
 suite('gr-plugin-popup tests', () => {
   let element: GrPluginPopup;
-  let overlayOpen: sinon.SinonStub;
-  let overlayClose: sinon.SinonStub;
+  let modalOpen: sinon.SinonStub;
+  let modalClose: sinon.SinonStub;
 
-  setup(() => {
-    element = basicFixture.instantiate();
-    overlayOpen = stub('gr-overlay', 'open').callsFake(() => Promise.resolve());
-    overlayClose = stub('gr-overlay', 'close');
+  setup(async () => {
+    element = await fixture(html`<gr-plugin-popup></gr-plugin-popup>`);
+    await element.updateComplete;
+    modalOpen = stubElement('dialog', 'showModal');
+    modalClose = stubElement('dialog', 'close');
   });
 
   test('exists', () => {
     assert.isOk(element);
   });
 
-  test('open uses open() from gr-overlay', async () => {
-    await element.open();
-    assert.isTrue(overlayOpen.called);
+  test('open uses open() from dialog', () => {
+    element.open();
+    assert.isTrue(modalOpen.called);
   });
 
-  test('close uses close() from gr-overlay', () => {
+  test('close uses close() from dialog', () => {
     element.close();
-    assert.isTrue(overlayClose.called);
+    assert.isTrue(modalClose.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
index 45a93bf..1ee5f5d 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
@@ -1,25 +1,13 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import './gr-plugin-popup';
-import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {GrPluginPopup} from './gr-plugin-popup';
 import {PluginApi} from '../../../api/plugin';
 import {PopupPluginApi} from '../../../api/popup';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 
 interface CustomPolymerPluginEl extends HTMLElement {
   plugin: PluginApi;
@@ -36,19 +24,20 @@
 
   private popup: GrPluginPopup | null = null;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(
     readonly plugin: PluginApi,
-    private moduleName: string | null = null
+    // private but used in tests
+    readonly moduleName: string | null = null
   ) {
     this.reporting.trackApi(this.plugin, 'popup', 'constructor');
   }
 
+  // TODO: This method should be removed as soon as plugins stop
+  // depending on it.
   _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 this.popup;
   }
 
   appendContent(el: HTMLElement) {
@@ -61,13 +50,13 @@
    * Creates the popup if not previously created. Creates popup content element,
    * if it was provided with constructor.
    */
-  open(): Promise<PopupPluginApi> {
+  open(): Promise<GrPopupInterface> {
     this.reporting.trackApi(this.plugin, 'popup', 'open');
     if (!this.openingPromise) {
       this.openingPromise = this.plugin
         .hook('plugin-overlay')
         .getLastAttached()
-        .then(hookEl => {
+        .then(async hookEl => {
           const popup = document.createElement('gr-plugin-popup');
           if (this.moduleName) {
             const el = popup.appendChild(
@@ -76,8 +65,9 @@
             el.plugin = this.plugin;
           }
           this.popup = hookEl.appendChild(popup);
-          flush();
-          return this.popup.open().then(() => this);
+          await this.popup.updateComplete;
+          this.popup.open();
+          return this;
         });
     }
     return this.openingPromise;
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
deleted file mode 100644
index 2889333..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
+++ /dev/null
@@ -1,97 +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 '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {GrPopupInterface} from './gr-popup-interface.js';
-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';
-
-class GrUserTestPopupElement extends PolymerElement {
-  static get is() { return 'gr-user-test-popup'; }
-
-  static get template() {
-    return html`<div id="barfoo">some test module</div>`;
-  }
-}
-
-customElements.define(GrUserTestPopupElement.is, GrUserTestPopupElement);
-
-const containerFixture = fixtureFromElement('div');
-
-const pluginApi = _testOnly_initGerritPluginApi();
-suite('gr-popup-interface tests', () => {
-  let container;
-  let instance;
-  let plugin;
-
-  setup(() => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    container = containerFixture.instantiate();
-    sinon.stub(plugin, 'hook').returns({
-      getLastAttached() {
-        return Promise.resolve(container);
-      },
-    });
-  });
-
-  suite('manual', () => {
-    setup(() => {
-      instance = new GrPopupInterface(plugin);
-    });
-
-    test('open', async () => {
-      const api = await instance.open();
-      assert.strictEqual(api, instance);
-      const manual = document.createElement('div');
-      manual.id = 'foobar';
-      manual.innerHTML = 'manual content';
-      api._getElement().appendChild(manual);
-      await flush();
-      assert.equal(
-          container.querySelector('#foobar').textContent, 'manual content');
-    });
-
-    test('close', async () => {
-      const api = await instance.open();
-      assert.isTrue(api._getElement().node.opened);
-      api.close();
-      assert.isFalse(api._getElement().node.opened);
-    });
-  });
-
-  suite('components', () => {
-    setup(() => {
-      instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
-    });
-
-    test('open', async () => {
-      await instance.open();
-      assert.isNotNull(container.querySelector('gr-user-test-popup'));
-    });
-
-    test('close', async () => {
-      const api = await instance.open();
-      assert.isTrue(api._getElement().node.opened);
-      api.close();
-      assert.isFalse(api._getElement().node.opened);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.ts
new file mode 100644
index 0000000..5354ea5
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.ts
@@ -0,0 +1,96 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {GrPopupInterface} from './gr-popup-interface';
+import {PluginApi} from '../../../api/plugin';
+import {HookApi, PluginElement} from '../../../api/hook';
+import {queryAndAssert, waitEventLoop} from '../../../test/test-utils';
+import {LitElement, html} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {fixture, assert} from '@open-wc/testing';
+
+@customElement('gr-user-test-popup')
+class GrUserTestPopupElement extends LitElement {
+  override render() {
+    return html`<div id="barfoo">some test module</div>`;
+  }
+}
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-user-test-popup': GrUserTestPopupElement;
+  }
+}
+
+suite('gr-popup-interface tests', () => {
+  let container: HTMLElement;
+  let instance: GrPopupInterface;
+  let plugin: PluginApi;
+
+  setup(async () => {
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    container = await fixture(html`<div></div>`);
+    sinon.stub(plugin, 'hook').returns({
+      getLastAttached() {
+        return Promise.resolve(container);
+      },
+    } as HookApi<PluginElement>);
+  });
+
+  suite('manual', () => {
+    setup(async () => {
+      instance = new GrPopupInterface(plugin);
+      await instance.open();
+    });
+
+    test('open', async () => {
+      const manual = document.createElement('div');
+      manual.id = 'foobar';
+      manual.innerHTML = 'manual content';
+      const popup = instance._getElement();
+      assert.isOk(popup);
+      popup!.appendChild(manual);
+      await waitEventLoop();
+      assert.equal(
+        queryAndAssert(container, '#foobar').textContent,
+        'manual content'
+      );
+    });
+
+    test('close', async () => {
+      assert.isOk(instance._getElement());
+      assert.isTrue(instance._getElement()!.opened);
+      instance.close();
+      assert.isOk(instance._getElement());
+      assert.isFalse(instance._getElement()!.opened);
+    });
+  });
+
+  suite('components', () => {
+    setup(async () => {
+      instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
+      await instance.open();
+    });
+
+    test('open', async () => {
+      assert.isNotNull(container.querySelector('gr-user-test-popup'));
+    });
+
+    test('close', async () => {
+      assert.isOk(instance._getElement());
+      assert.isTrue(instance._getElement()!.opened);
+      instance.close();
+      assert.isOk(instance._getElement());
+      assert.isFalse(instance._getElement()!.opened);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
deleted file mode 100644
index 6580ad6..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
+++ /dev/null
@@ -1,54 +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.
- */
-/* eslint-disable lit/no-legacy-template-syntax,lit/prefer-static-styles */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {customElement, property} from '@polymer/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-custom-plugin-header': GrCustomPluginHeader;
-  }
-}
-
-@customElement('gr-custom-plugin-header')
-export class GrCustomPluginHeader extends PolymerElement {
-  @property({type: String})
-  logoUrl = '';
-
-  @property({type: String})
-  override title = '';
-
-  static get template() {
-    return html`
-      <style>
-        img {
-          width: 1em;
-          height: 1em;
-          vertical-align: middle;
-        }
-        .title {
-          margin-left: var(--spacing-xs);
-        }
-      </style>
-      <span>
-        <img src="[[logoUrl]]" hidden$="[[!logoUrl]]" />
-        <span class="title">[[title]]</span>
-      </span>
-    `;
-  }
-}
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 bd6835c..0e75abb 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
@@ -1,135 +1,325 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-info_html';
-import {customElement, property, observe} from '@polymer/decorators';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-hovercard-account/gr-hovercard-account-contents';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
-import {appContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
+import {fire, fireEvent} from '../../../utils/event-util';
+import {LitElement, css, html, nothing, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {when} from 'lit/directives/when.js';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 
 @customElement('gr-account-info')
-export class GrAccountInfo extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountInfo extends LitElement {
   /**
    * Fired when account details are changed.
    *
    * @event account-detail-update
    */
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed: '_computeUsernameMutable(_serverConfig, _account.username)',
-  })
-  usernameMutable?: boolean;
+  // private but used in test
+  @state() nameMutable?: boolean;
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed: '_computeNameMutable(_serverConfig)',
-  })
-  nameMutable?: boolean;
+  @property({type: Boolean}) hasUnsavedChanges = false;
 
-  @property({
-    type: Boolean,
-    notify: true,
-    computed:
-      '_computeHasUnsavedChanges(_hasNameChange, ' +
-      '_hasUsernameChange, _hasStatusChange, _hasDisplayNameChange)',
-  })
-  hasUnsavedChanges?: boolean;
+  // private but used in test
+  @state() hasNameChange = false;
 
-  @property({type: Boolean})
-  _hasNameChange?: boolean;
+  // private but used in test
+  @state() hasUsernameChange = false;
 
-  @property({type: Boolean})
-  _hasUsernameChange?: boolean;
+  // private but used in test
+  @state() hasDisplayNameChange = false;
 
-  @property({type: Boolean})
-  _hasDisplayNameChange?: boolean;
+  // private but used in test
+  @state() hasStatusChange = false;
 
-  @property({type: Boolean})
-  _hasStatusChange?: boolean;
+  // private but used in test
+  @state() loading = false;
 
-  @property({type: Boolean})
-  _loading = false;
+  @state() private saving = false;
 
-  @property({type: Boolean})
-  _saving = false;
+  // private but used in test
+  @state() account?: AccountDetailInfo;
 
-  @property({type: Object})
-  _account?: AccountDetailInfo;
+  // private but used in test
+  @state() serverConfig?: ServerInfo;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  // private but used in test
+  @state() username?: string;
 
-  @property({type: String, observer: '_usernameChanged'})
-  _username?: string;
+  @state() private avatarChangeUrl = '';
 
-  @property({type: String})
-  _avatarChangeUrl = '';
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly restApiService = appContext.restApiService;
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      gr-avatar {
+        height: 120px;
+        width: 120px;
+        margin-right: var(--spacing-xs);
+        vertical-align: -0.25em;
+      }
+      div section.hide {
+        display: none;
+      }
+      gr-hovercard-account-contents {
+        display: block;
+        max-width: 600px;
+        margin-top: var(--spacing-m);
+        background: var(--dialog-background-color);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-5);
+      }
+      iron-autogrow-textarea {
+        background-color: var(--view-background-color);
+        color: var(--primary-text-color);
+      }
+      .lengthCounter {
+        font-weight: var(--font-weight-normal);
+      }
+      p {
+        max-width: 65ch;
+        margin-bottom: var(--spacing-m);
+      }
+    `,
+  ];
+
+  override render() {
+    if (!this.account || this.loading) return nothing;
+    return html`<div class="gr-form-styles">
+      <p>
+        All profile fields below may be publicly displayed to others, including
+        on changes you are associated with, as well as in search and
+        autocompletion.
+        <a
+          href="https://gerrit-review.googlesource.com/Documentation/user-privacy.html"
+          >Learn more</a
+        >
+      </p>
+      <gr-endpoint-decorator name="profile"></gr-endpoint-decorator>
+      <section>
+        <span class="title"></span>
+        <span class="value">
+          <gr-avatar .account=${this.account} imageSize="120"></gr-avatar>
+        </span>
+      </section>
+      ${when(
+        this.avatarChangeUrl,
+        () => html` <section>
+          <span class="title"></span>
+          <span class="value">
+            <a href=${this.avatarChangeUrl}> Change avatar </a>
+          </span>
+        </section>`
+      )}
+      <section>
+        <span class="title">ID</span>
+        <span class="value">${this.account._account_id}</span>
+      </section>
+      <section>
+        <span class="title">Email</span>
+        <span class="value">${this.account.email}</span>
+      </section>
+      <section>
+        <span class="title">Registered</span>
+        <span class="value">
+          <gr-date-formatter
+            withTooltip
+            .dateStr=${this.account.registered_on}
+          ></gr-date-formatter>
+        </span>
+      </section>
+      <section id="usernameSection">
+        <span class="title">Username</span>
+        ${when(
+          this.computeUsernameEditable(),
+          () => html`<span class="value">
+            <iron-input
+              @keydown=${this.handleKeydown}
+              .bindValue=${this.username}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                if (this.username === e.detail.value) return;
+                this.username = e.detail.value;
+                this.hasUsernameChange = true;
+              }}
+              id="usernameIronInput"
+            >
+              <input
+                id="usernameInput"
+                ?disabled=${this.saving}
+                @keydown=${this.handleKeydown}
+              />
+            </iron-input>
+          </span>`,
+          () => html`<span class="value">${this.username}</span>`
+        )}
+      </section>
+      <section id="nameSection">
+        <label class="title" for="nameInput">Full name</label>
+        ${when(
+          this.nameMutable,
+          () => html`<span class="value">
+            <iron-input
+              @keydown=${this.handleKeydown}
+              .bindValue=${this.account?.name}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                const oldAccount = this.account;
+                if (!oldAccount || oldAccount.name === e.detail.value) return;
+                this.account = {...oldAccount, name: e.detail.value};
+                this.hasNameChange = true;
+              }}
+              id="nameIronInput"
+            >
+              <input
+                id="nameInput"
+                ?disabled=${this.saving}
+                @keydown=${this.handleKeydown}
+              />
+            </iron-input>
+          </span>`,
+          () => html` <span class="value">${this.account?.name}</span>`
+        )}
+      </section>
+      <section>
+        <label class="title" for="displayNameInput">Display name</label>
+        <span class="value">
+          <iron-input
+            @keydown=${this.handleKeydown}
+            .bindValue=${this.account.display_name}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              const oldAccount = this.account;
+              if (!oldAccount || oldAccount.display_name === e.detail.value) {
+                return;
+              }
+              this.account = {...oldAccount, display_name: e.detail.value};
+              this.hasDisplayNameChange = true;
+            }}
+          >
+            <input
+              id="displayNameInput"
+              ?disabled=${this.saving}
+              @keydown=${this.handleKeydown}
+            />
+          </iron-input>
+        </span>
+      </section>
+      <section>
+        <span class="title">
+          <label for="statusInput">About me (e.g. employer)</label>
+          <div class="lengthCounter">
+            ${this.account.status?.length ?? 0}/140
+          </div>
+        </span>
+        <span class="value">
+          <iron-autogrow-textarea
+            id="statusInput"
+            .name=${'statusInput'}
+            ?disabled=${this.saving}
+            maxlength="140"
+            .value=${this.account?.status}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              const oldAccount = this.account;
+              if (!oldAccount || oldAccount.status === e.detail.value) return;
+              this.account = {...oldAccount, status: e.detail.value};
+              this.hasStatusChange = true;
+            }}
+          ></iron-autogrow-textarea>
+        </span>
+      </section>
+      <section>
+        <span class="title">
+          <gr-tooltip-content
+            title="This is how you appear to others"
+            has-tooltip
+            show-icon
+          >
+            Account preview
+          </gr-tooltip-content>
+        </span>
+        <span class="value">
+          <gr-account-chip .account=${this.account}></gr-account-chip>
+          <gr-hovercard-account-contents
+            .account=${this.account}
+          ></gr-hovercard-account-contents>
+        </span>
+      </section>
+    </div>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.nameMutable = this.computeNameMutable();
+    }
+
+    if (
+      changedProperties.has('hasNameChange') ||
+      changedProperties.has('hasUsernameChange') ||
+      changedProperties.has('hasStatusChange') ||
+      changedProperties.has('hasDisplayNameChange')
+    ) {
+      this.hasUnsavedChanges = this.computeHasUnsavedChanges();
+    }
+    if (changedProperties.has('hasUnsavedChanges')) {
+      fire(this, 'unsaved-changes-changed', {
+        value: this.hasUnsavedChanges,
+      });
+    }
+  }
 
   loadData() {
     const promises = [];
 
-    this._loading = true;
+    this.loading = true;
 
     promises.push(
       this.restApiService.getConfig().then(config => {
-        this._serverConfig = config;
+        this.serverConfig = config;
       })
     );
 
-    promises.push(this.restApiService.invalidateAccountsDetailCache());
+    this.restApiService.invalidateAccountsDetailCache();
 
     promises.push(
       this.restApiService.getAccount().then(account => {
         if (!account) return;
-        this._hasNameChange = false;
-        this._hasUsernameChange = false;
-        this._hasDisplayNameChange = false;
-        this._hasStatusChange = false;
+        this.hasNameChange = false;
+        this.hasUsernameChange = false;
+        this.hasDisplayNameChange = false;
+        this.hasStatusChange = false;
         // Provide predefined value for username to trigger computation of
         // username mutability.
         account.username = account.username || '';
-        this._account = account;
-        this._username = account.username;
+        this.account = account;
+        this.username = account.username;
       })
     );
 
     promises.push(
       this.restApiService.getAvatarChangeUrl().then(url => {
-        this._avatarChangeUrl = url || '';
+        this.avatarChangeUrl = url || '';
       })
     );
 
     return Promise.all(promises).then(() => {
-      this._loading = false;
+      this.loading = false;
     });
   }
 
@@ -138,132 +328,89 @@
       return Promise.resolve();
     }
 
-    this._saving = true;
+    this.saving = true;
     // Set only the fields that have changed.
     // Must be done in sequence to avoid race conditions (@see Issue 5721)
-    return this._maybeSetName()
-      .then(() => this._maybeSetUsername())
-      .then(() => this._maybeSetDisplayName())
-      .then(() => this._maybeSetStatus())
+    return this.maybeSetName()
+      .then(() => this.maybeSetUsername())
+      .then(() => this.maybeSetDisplayName())
+      .then(() => this.maybeSetStatus())
       .then(() => {
-        this._hasNameChange = false;
-        this._hasDisplayNameChange = false;
-        this._hasStatusChange = false;
-        this._saving = false;
+        this.hasNameChange = false;
+        this.hasUsernameChange = false;
+        this.hasDisplayNameChange = false;
+        this.hasStatusChange = false;
+        this.saving = false;
         fireEvent(this, 'account-detail-update');
       });
   }
 
-  _maybeSetName() {
+  private maybeSetName() {
     // 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.restApiService.setAccountName(this._account.name)
+    return this.hasNameChange && this.nameMutable && this.account?.name
+      ? this.restApiService.setAccountName(this.account.name)
       : Promise.resolve();
   }
 
-  _maybeSetUsername() {
+  private maybeSetUsername() {
     // 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.restApiService.setAccountUsername(this._username)
+    return this.hasUsernameChange &&
+      this.computeUsernameEditable() &&
+      this.username
+      ? this.restApiService.setAccountUsername(this.username)
       : Promise.resolve();
   }
 
-  _maybeSetDisplayName() {
-    return this._hasDisplayNameChange &&
-      this._account?.display_name !== undefined
-      ? this.restApiService.setAccountDisplayName(this._account.display_name)
+  private maybeSetDisplayName() {
+    return this.hasDisplayNameChange && this.account?.display_name !== undefined
+      ? this.restApiService.setAccountDisplayName(this.account.display_name)
       : Promise.resolve();
   }
 
-  _maybeSetStatus() {
-    return this._hasStatusChange && this._account?.status !== undefined
-      ? this.restApiService.setAccountStatus(this._account.status)
+  private maybeSetStatus() {
+    return this.hasStatusChange && this.account?.status !== undefined
+      ? this.restApiService.setAccountStatus(this.account.status)
       : Promise.resolve();
   }
 
-  _computeHasUnsavedChanges(
-    nameChanged: boolean,
-    usernameChanged: boolean,
-    statusChanged: boolean,
-    displayNameChanged: boolean
-  ) {
+  private computeHasUnsavedChanges() {
     return (
-      nameChanged || usernameChanged || statusChanged || displayNameChanged
+      this.hasNameChange ||
+      this.hasUsernameChange ||
+      this.hasStatusChange ||
+      this.hasDisplayNameChange
     );
   }
 
-  _computeUsernameMutable(config: ServerInfo, username?: string) {
-    // Polymer 2: check for undefined
-    if ([config, username].includes(undefined)) {
-      return undefined;
-    }
-
-    // Username may not be changed once it is set.
+  // private but used in test
+  computeUsernameEditable() {
     return (
-      config.auth.editable_account_fields.includes(
+      !!this.serverConfig?.auth.editable_account_fields.includes(
         EditableAccountField.USER_NAME
-      ) && !username
+      ) && !this.account?.username
     );
   }
 
-  _computeNameMutable(config: ServerInfo) {
-    return config.auth.editable_account_fields.includes(
+  private computeNameMutable() {
+    return !!this.serverConfig?.auth.editable_account_fields.includes(
       EditableAccountField.FULL_NAME
     );
   }
 
-  @observe('_account.status')
-  _statusChanged() {
-    if (this._loading) {
-      return;
-    }
-    this._hasStatusChange = true;
-  }
-
-  @observe('_account.display_name')
-  _displayNameChanged() {
-    if (this._loading) {
-      return;
-    }
-    this._hasDisplayNameChange = true;
-  }
-
-  _usernameChanged() {
-    if (this._loading || !this._account) {
-      return;
-    }
-    this._hasUsernameChange =
-      (this._account.username || '') !== (this._username || '');
-  }
-
-  @observe('_account.name')
-  _nameChanged() {
-    if (this._loading) {
-      return;
-    }
-    this._hasNameChange = true;
-  }
-
-  _handleKeydown(e: KeyboardEvent) {
-    if (e.keyCode === 13) {
-      // Enter
+  private handleKeydown(e: KeyboardEvent) {
+    if (e.key === 'Enter') {
       e.stopPropagation();
       this.save();
     }
   }
-
-  _hideAvatarChangeUrl(avatarChangeUrl: string) {
-    if (!avatarChangeUrl) {
-      return 'hide';
-    }
-
-    return '';
-  }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'unsaved-changes-changed': ValueChangedEvent<boolean>;
+  }
   interface HTMLElementTagNameMap {
     'gr-account-info': GrAccountInfo;
   }
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
deleted file mode 100644
index 51259c8..0000000
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-avatar {
-      height: 120px;
-      width: 120px;
-      margin-right: var(--spacing-xs);
-      vertical-align: -0.25em;
-    }
-    div section.hide {
-      display: none;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <section>
-      <span class="title"></span>
-      <span class="value">
-        <gr-avatar account="[[_account]]" imageSize="120"></gr-avatar>
-      </span>
-    </section>
-    <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
-      <span class="title"></span>
-      <span class="value">
-        <a href$="[[_avatarChangeUrl]]"> Change avatar </a>
-      </span>
-    </section>
-    <section>
-      <span class="title">ID</span>
-      <span class="value">[[_account._account_id]]</span>
-    </section>
-    <section>
-      <span class="title">Email</span>
-      <span class="value">[[_account.email]]</span>
-    </section>
-    <section>
-      <span class="title">Registered</span>
-      <span class="value">
-        <gr-date-formatter
-          withTooltip
-          date-str="[[_account.registered_on]]"
-        ></gr-date-formatter>
-      </span>
-    </section>
-    <section id="usernameSection">
-      <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}}"
-          id="usernameIronInput"
-        >
-          <input
-            id="usernameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section id="nameSection">
-      <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}}"
-          id="nameIronInput"
-        >
-          <input
-            id="nameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label class="title" for="displayNameInput">Display name</label>
-      <span class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_account.display_name}}"
-        >
-          <input
-            id="displayNameInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label class="title" for="statusInput">Status (e.g. "Vacation")</label>
-      <span class="value">
-        <iron-input
-          on-keydown="_handleKeydown"
-          bind-value="{{_account.status}}"
-        >
-          <input
-            id="statusInput"
-            disabled="[[_saving]]"
-            on-keydown="_handleKeydown"
-          />
-        </iron-input>
-      </span>
-    </section>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index f1813a4..f954960 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -1,36 +1,31 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-account-info';
-import {SinonSpyMember, stubRestApi} from '../../../test/test-utils';
+import {
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {GrAccountInfo} from './gr-account-info';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {
   createAccountDetailWithId,
   createAccountWithIdNameAndEmail,
+  createAuth,
   createPreferences,
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {IronInputElement} from '@polymer/iron-input';
 import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-
-const basicFixture = fixtureFromElement('gr-account-info');
+import {EditableAccountField} from '../../../api/rest-api';
+import {fixture, html, assert} from '@open-wc/testing';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 
 suite('gr-account-info tests', () => {
   let element!: GrAccountInfo;
@@ -38,13 +33,13 @@
   let config: ServerInfo;
 
   function queryIronInput(selector: string): IronInputElement {
-    const input = element.root?.querySelector<IronInputElement>(selector);
+    const input = query<IronInputElement>(element, 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') ?? [];
+    const sections = queryAll<HTMLElement>(element, 'section') ?? [];
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -60,17 +55,102 @@
     account = createAccountWithIdNameAndEmail(123) as AccountDetailInfo;
     config = createServerInfo();
 
-    stubRestApi('getAccount').returns(Promise.resolve(account));
-    stubRestApi('getConfig').returns(Promise.resolve(config));
-    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
+    stubRestApi('getAccount').resolves(account);
+    stubRestApi('getConfig').resolves(config);
+    stubRestApi('getPreferences').resolves(createPreferences());
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-account-info></gr-account-info>`);
     await element.loadData();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <p>
+            All profile fields below may be publicly displayed to others,
+            including on changes you are associated with, as well as in search
+            and autocompletion.
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-privacy.html"
+              >Learn more</a
+            >
+          </p>
+          <gr-endpoint-decorator name="profile"></gr-endpoint-decorator>
+          <section>
+            <span class="title"></span>
+            <span class="value">
+              <gr-avatar hidden="" imagesize="120"></gr-avatar>
+            </span>
+          </section>
+          <section>
+            <span class="title">ID</span>
+            <span class="value">123</span>
+          </section>
+          <section>
+            <span class="title">Email</span>
+            <span class="value">user-123@</span>
+          </section>
+          <section>
+            <span class="title">Registered</span>
+            <span class="value">
+              <gr-date-formatter withtooltip=""></gr-date-formatter>
+            </span>
+          </section>
+          <section id="usernameSection">
+            <span class="title">Username</span>
+            <span class="value"></span>
+          </section>
+          <section id="nameSection">
+            <label class="title" for="nameInput">Full name</label>
+            <span class="value">User-123</span>
+          </section>
+          <section>
+            <label class="title" for="displayNameInput">Display name</label>
+            <span class="value">
+              <iron-input>
+                <input id="displayNameInput" />
+              </iron-input>
+            </span>
+          </section>
+          <section>
+            <span class="title">
+              <label for="statusInput">About me (e.g. employer)</label>
+              <div class="lengthCounter">0/140</div>
+            </span>
+            <span class="value">
+              <iron-autogrow-textarea
+                aria-disabled="false"
+                id="statusInput"
+                maxlength="140"
+              />
+            </span>
+          </section>
+          <section>
+            <span class="title">
+              <gr-tooltip-content
+                has-tooltip=""
+                show-icon=""
+                title="This is how you appear to others"
+              >
+                Account preview
+              </gr-tooltip-content>
+            </span>
+            <span class="value"
+              ><gr-account-chip></gr-account-chip>
+              <gr-hovercard-account-contents></gr-hovercard-account-contents>
+            </span>
+          </section>
+        </div>
+      `,
+      {ignoreChildren: ['p']}
+    );
   });
 
   test('basic account info render', () => {
-    assert.isFalse(element._loading);
+    assert.isFalse(element.loading);
 
     assert.equal(valueOf('ID').textContent, `${account._account_id}`);
     assert.equal(valueOf('Email').textContent, account.email);
@@ -78,55 +158,62 @@
   });
 
   test('full name render (immutable)', () => {
-    const section = element.$.nameSection;
+    const section = query<HTMLElement>(element, '#nameSection')!;
     const displaySpan = section.querySelectorAll('.value')[0];
     const inputSpan = section.querySelectorAll('.value')[1];
 
     assert.isFalse(element.nameMutable);
     assert.isFalse(displaySpan.hasAttribute('hidden'));
     assert.equal(displaySpan.textContent, account.name);
-    assert.isTrue(inputSpan.hasAttribute('hidden'));
+    assert.isUndefined(inputSpan);
   });
 
-  test('full name render (mutable)', () => {
-    element.set('_serverConfig', {
-      auth: {editable_account_fields: ['FULL_NAME']},
-    });
+  test('full name render (mutable)', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        ...createAuth(),
+        editable_account_fields: [EditableAccountField.FULL_NAME],
+      },
+    };
 
-    const section = element.$.nameSection;
-    const displaySpan = section.querySelectorAll('.value')[0];
-    const inputSpan = section.querySelectorAll('.value')[1];
+    await element.updateComplete;
+    const section = query<HTMLElement>(element, '#nameSection')!;
+    const inputSpan = section.querySelectorAll('.value')[0];
 
     assert.isTrue(element.nameMutable);
-    assert.isTrue(displaySpan.hasAttribute('hidden'));
     assert.equal(queryIronInput('#nameIronInput').bindValue, account.name);
     assert.isFalse(inputSpan.hasAttribute('hidden'));
   });
 
   test('username render (immutable)', () => {
-    const section = element.$.usernameSection;
+    const section = query<HTMLElement>(element, '#usernameSection')!;
     const displaySpan = section.querySelectorAll('.value')[0];
     const inputSpan = section.querySelectorAll('.value')[1];
 
-    assert.isFalse(element.usernameMutable);
+    assert.isFalse(element.computeUsernameEditable());
     assert.isFalse(displaySpan.hasAttribute('hidden'));
     assert.equal(displaySpan.textContent, account.username);
-    assert.isTrue(inputSpan.hasAttribute('hidden'));
+    assert.isUndefined(inputSpan);
   });
 
-  test('username render (mutable)', () => {
-    element.set('_serverConfig', {
-      auth: {editable_account_fields: ['USER_NAME']},
-    });
-    element.set('_account.username', '');
-    element.set('_username', '');
+  test('username render (mutable)', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        ...createAuth(),
+        editable_account_fields: [EditableAccountField.USER_NAME],
+      },
+    };
+    element.account!.username = '';
+    element.username = '';
 
-    const section = element.$.usernameSection;
-    const displaySpan = section.querySelectorAll('.value')[0];
-    const inputSpan = section.querySelectorAll('.value')[1];
+    await element.updateComplete;
 
-    assert.isTrue(element.usernameMutable);
-    assert.isTrue(displaySpan.hasAttribute('hidden'));
+    const section = query<HTMLElement>(element, '#usernameSection')!;
+    const inputSpan = section.querySelectorAll('.value')[0];
+
+    assert.isTrue(element.computeUsernameEditable());
     assert.equal(
       queryIronInput('#usernameIronInput').bindValue,
       account.username
@@ -135,36 +222,37 @@
   });
 
   suite('account info edit', () => {
-    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']},
-      });
+    setup(async () => {
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [
+            EditableAccountField.FULL_NAME,
+            EditableAccountField.USER_NAME,
+          ],
+        },
+      };
 
-      nameStub = stubRestApi('setAccountName').returns(Promise.resolve());
-      usernameStub = stubRestApi('setAccountUsername').returns(
-        Promise.resolve()
-      );
-      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
+      await element.updateComplete;
+      nameStub = stubRestApi('setAccountName').resolves();
+      usernameStub = stubRestApi('setAccountUsername').resolves();
+      statusStub = stubRestApi('setAccountStatus').resolves();
     });
 
     test('name', async () => {
       assert.isTrue(element.nameMutable);
       assert.isFalse(element.hasUnsavedChanges);
 
-      element.set('_account.name', 'new name');
-
-      assert.isTrue(nameChangedSpy.called);
-      assert.isFalse(statusChangedSpy.called);
+      const statusInputEl = queryIronInput('#nameIronInput');
+      statusInputEl.bindValue = 'new name';
+      await element.updateComplete;
+      assert.isTrue(element.hasNameChange);
+      assert.isFalse(element.hasStatusChange);
       assert.isTrue(element.hasUnsavedChanges);
 
       await element.save();
@@ -176,14 +264,25 @@
     });
 
     test('username', async () => {
-      element.set('_account.username', '');
-      element._hasUsernameChange = false;
-      assert.isTrue(element.usernameMutable);
+      element.account!.username = '';
+      element.username = 't';
+      element.hasUsernameChange = false;
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [EditableAccountField.USER_NAME],
+        },
+      };
+      await element.updateComplete;
+      assert.isTrue(element.computeUsernameEditable());
 
-      element.set('_username', 'new username');
-
-      assert.isTrue(usernameChangedSpy.called);
-      assert.isFalse(statusChangedSpy.called);
+      const statusInputEl = queryIronInput('#usernameIronInput');
+      statusInputEl.bindValue = 'new username';
+      await element.updateComplete;
+      assert.isTrue(element.hasUsernameChange);
+      assert.isFalse(element.hasNameChange);
+      assert.isFalse(element.hasStatusChange);
       assert.isTrue(element.hasUnsavedChanges);
 
       await element.save();
@@ -197,10 +296,14 @@
     test('status', async () => {
       assert.isFalse(element.hasUnsavedChanges);
 
-      element.set('_account.status', 'new status');
-
-      assert.isFalse(nameChangedSpy.called);
-      assert.isTrue(statusChangedSpy.called);
+      const statusTextarea = queryAndAssert<IronAutogrowTextareaElement>(
+        element,
+        '#statusInput'
+      );
+      statusTextarea.value = 'new status';
+      await element.updateComplete;
+      assert.isFalse(element.hasNameChange);
+      assert.isTrue(element.hasStatusChange);
       assert.isTrue(element.hasUnsavedChanges);
 
       await element.save();
@@ -213,34 +316,40 @@
   });
 
   suite('edit name and status', () => {
-    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']},
-      });
+    setup(async () => {
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [EditableAccountField.FULL_NAME],
+        },
+      };
+      await element.updateComplete;
 
-      nameStub = stubRestApi('setAccountName').returns(Promise.resolve());
-      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
-      stubRestApi('setAccountUsername').returns(Promise.resolve());
+      nameStub = stubRestApi('setAccountName').resolves();
+      statusStub = stubRestApi('setAccountStatus').resolves();
+      stubRestApi('setAccountUsername').resolves();
     });
 
     test('set name and status', async () => {
       assert.isTrue(element.nameMutable);
       assert.isFalse(element.hasUnsavedChanges);
 
-      element.set('_account.name', 'new name');
+      const inputEl = queryIronInput('#nameIronInput');
+      inputEl.bindValue = 'new name';
+      await element.updateComplete;
+      assert.isTrue(element.hasNameChange);
 
-      assert.isTrue(nameChangedSpy.called);
-
-      element.set('_account.status', 'new status');
-
-      assert.isTrue(statusChangedSpy.called);
+      const statusTextarea = queryAndAssert<IronAutogrowTextareaElement>(
+        element,
+        '#statusInput'
+      );
+      statusTextarea.value = 'new status';
+      await element.updateComplete;
+      assert.isTrue(element.hasStatusChange);
 
       assert.isTrue(element.hasUnsavedChanges);
 
@@ -255,18 +364,23 @@
   });
 
   suite('set status but read name', () => {
-    let statusChangedSpy: SinonSpyMember<typeof element._statusChanged>;
     let statusStub: SinonStubbedMember<RestApiService['setAccountStatus']>;
 
-    setup(() => {
-      statusChangedSpy = sinon.spy(element, '_statusChanged');
-      element.set('_serverConfig', {auth: {editable_account_fields: []}});
+    setup(async () => {
+      element.serverConfig = {
+        ...createServerInfo(),
+        auth: {
+          ...createAuth(),
+          editable_account_fields: [],
+        },
+      };
+      await element.updateComplete;
 
-      statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
+      statusStub = stubRestApi('setAccountStatus').resolves();
     });
 
     test('read full name but set status', async () => {
-      const section = element.$.nameSection;
+      const section = query<HTMLElement>(element, '#nameSection')!;
       const displaySpan = section.querySelectorAll('.value')[0];
       const inputSpan = section.querySelectorAll('.value')[1];
 
@@ -276,11 +390,15 @@
 
       assert.isFalse(displaySpan.hasAttribute('hidden'));
       assert.equal(displaySpan.textContent, account.name);
-      assert.isTrue(inputSpan.hasAttribute('hidden'));
+      assert.isUndefined(inputSpan);
 
-      element.set('_account.status', 'new status');
-
-      assert.isTrue(statusChangedSpy.called);
+      const statusTextarea = queryAndAssert<IronAutogrowTextareaElement>(
+        element,
+        '#statusInput'
+      );
+      statusTextarea.value = 'new status';
+      await element.updateComplete;
+      assert.isTrue(element.hasStatusChange);
 
       assert.isTrue(element.hasUnsavedChanges);
 
@@ -291,27 +409,27 @@
     });
   });
 
-  test('_usernameChanged compares usernames with loose equality', () => {
-    element._account = createAccountDetailWithId();
-    element._username = '';
-    element._hasUsernameChange = false;
-    element._loading = false;
-    // _usernameChanged is an observer, but call it here after setting
-    // _hasUsernameChange in the test to force recomputation.
-    element._usernameChanged();
-    flush();
+  test('_usernameChanged compares usernames with loose equality', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        ...createAuth(),
+        editable_account_fields: [EditableAccountField.USER_NAME],
+      },
+    };
+    element.account = createAccountDetailWithId();
+    element.username = 't';
+    element.hasUsernameChange = false;
+    element.loading = false;
+    // usernameChanged is an observer, but call it here after setting
+    // hasUsernameChange in the test to force recomputation.
+    await element.updateComplete;
+    assert.isFalse(element.hasUsernameChange);
 
-    assert.isFalse(element._hasUsernameChange);
+    const inputEl = queryIronInput('#usernameIronInput');
+    inputEl.bindValue = 'test';
+    await element.updateComplete;
 
-    element.set('_username', 'test');
-    flush();
-
-    assert.isTrue(element._hasUsernameChange);
-  });
-
-  test('_hideAvatarChangeUrl', () => {
-    assert.equal(element._hideAvatarChangeUrl(''), 'hide');
-
-    assert.equal(element._hideAvatarChangeUrl('https://example.com'), '');
+    assert.isTrue(element.hasUsernameChange);
   });
 });
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 a972db3..9f4379b 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
@@ -1,34 +1,22 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {getBaseUrl} from '../../../utils/url-util';
 import {ContributorAgreementInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 @customElement('gr-agreements-list')
 export class GrAgreementsList extends LitElement {
   @property({type: Array})
   _agreements?: ContributorAgreementInfo[];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
@@ -62,7 +50,7 @@
     return html`
       <tr>
         <td class="nameColumn">
-          <a href="${this.getUrlBase(agreement.url)}" rel="external">
+          <a href=${this.getUrlBase(agreement?.url)} rel="external">
             ${agreement.name}
           </a>
         </td>
@@ -86,7 +74,7 @@
           )}
         </tbody>
       </table>
-      <a href="${this.getUrl()}">New Contributor Agreement</a>
+      <a href=${this.getUrl()}>New Contributor Agreement</a>
     </div>`;
   }
 
@@ -94,8 +82,8 @@
     return `${getBaseUrl()}/settings/new-agreement`;
   }
 
-  getUrlBase(item: string) {
-    return `${getBaseUrl()}/${item}`;
+  getUrlBase(item?: string) {
+    return item ? `${getBaseUrl()}/${item}` : '';
   }
 }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
index f3eeae8..3fe55a5 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
@@ -1,26 +1,14 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-agreements-list';
-import {queryAll, stubRestApi} from '../../../test/test-utils';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
 import {GrAgreementsList} from './gr-agreements-list';
 import {ContributorAgreementInfo} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-agreements-list');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-agreements-list tests', () => {
   let element: GrAgreementsList;
@@ -36,20 +24,36 @@
 
     stubRestApi('getAccountAgreements').returns(Promise.resolve(agreements));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-agreements-list></gr-agreements-list>`);
 
     await element.loadData();
-    await flush();
+    await waitEventLoop();
   });
 
   test('renders', () => {
-    const rows = queryAll<HTMLTableRowElement>(element, 'tbody tr') ?? [];
-    assert.equal(rows.length, 1);
-
-    const nameCells = Array.from(rows).map(row =>
-      queryAll<HTMLTableElement>(row, 'td')[0].textContent?.trim()
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <table id="agreements">
+            <thead>
+              <tr>
+                <th class="nameColumn">Name</th>
+                <th class="descriptionColumn">Description</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="nameColumn">
+                  <a href="/some url" rel="external"> Agreements 1 </a>
+                </td>
+                <td class="descriptionColumn">Agreements 1 description</td>
+              </tr>
+            </tbody>
+          </table>
+          <a href="/settings/new-agreement"> New Contributor Agreement </a>
+        </div>
+      `
     );
-
-    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 96b1ded..53548f8 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
@@ -1,107 +1,177 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-table-editor_html';
-import {customElement, property, observe} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
-import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
+import {getAppContext} from '../../../services/app-context';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {PropertyValues} from 'lit';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {ColumnNames} from '../../../constants/constants';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
 
 @customElement('gr-change-table-editor')
-export class GrChangeTableEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Array, notify: true})
+export class GrChangeTableEditor extends LitElement {
+  @property({type: Array})
   displayedColumns: string[] = [];
 
-  @property({type: Boolean, notify: true})
+  @property({type: Boolean})
   showNumber?: boolean;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
   @property({type: Array})
   defaultColumns: string[] = [];
 
-  private readonly flagsService = appContext.flagsService;
+  @state()
+  serverConfig?: ServerInfo;
 
-  @observe('serverConfig')
-  _configChanged(config: ServerInfo) {
-    this.defaultColumns = columnNames.filter(col =>
-      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
+  private readonly flagsService = getAppContext().flagsService;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      #changeCols {
+        width: auto;
+      }
+      #changeCols .visibleHeader {
+        text-align: center;
+      }
+      .checkboxContainer {
+        cursor: pointer;
+        text-align: center;
+      }
+      .checkboxContainer input {
+        cursor: pointer;
+      }
+      .checkboxContainer:hover {
+        outline: 1px solid var(--border-color);
+      }
+    `,
+  ];
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+  }
+
+  override render() {
+    return html`<div class="gr-form-styles">
+      <table id="changeCols">
+        <thead>
+          <tr>
+            <th class="nameHeader">Column</th>
+            <th class="visibleHeader">Visible</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td><label for="numberCheckbox">Number</label></td>
+            <td
+              class="checkboxContainer"
+              @click=${this.handleCheckboxContainerClick}
+            >
+              <input
+                id="numberCheckbox"
+                type="checkbox"
+                name="number"
+                @click=${this.handleNumberCheckboxClick}
+                ?checked=${this.showNumber}
+              />
+            </td>
+          </tr>
+          ${this.defaultColumns.map(column => this.renderRow(column))}
+        </tbody>
+      </table>
+    </div>`;
+  }
+
+  renderRow(column: string) {
+    return html`<tr>
+      <td><label for=${column}>${column}</label></td>
+      <td class="checkboxContainer" @click=${this.handleCheckboxContainerClick}>
+        <input
+          id=${column}
+          type="checkbox"
+          name=${column}
+          @click=${this.handleTargetClick}
+          ?checked=${!this.computeIsColumnHidden(column)}
+        />
+      </td>
+    </tr>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.configChanged();
+    }
+  }
+
+  private configChanged() {
+    this.defaultColumns = Object.values(ColumnNames).filter(column =>
+      this.isColumnEnabled(column)
     );
     if (!this.displayedColumns) return;
     this.displayedColumns = this.displayedColumns.filter(column =>
-      this._isColumnEnabled(
-        column,
-        config,
-        this.flagsService.enabledExperiments
-      )
+      this.isColumnEnabled(column)
     );
   }
 
   /**
-   * Is the column disabled by a server config or experiment? For example the
-   * assignee feature might be disabled and thus the corresponding column is
-   * also disabled.
-   *
+   * Is the column disabled by a server config or experiment?
+   * private but used in test
    */
-  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
-    if (!config || !config.change) return true;
-    if (column === 'Assignee') return !!config.change.enable_assignee;
-    if (column === 'Comments') return experiments.includes('comments-column');
+  isColumnEnabled(column: string) {
+    if (!this.serverConfig?.change) return true;
+    if (column === ColumnNames.COMMENTS)
+      return this.flagsService.isEnabled('comments-column');
+    if (column === ColumnNames.STATUS) return false;
     return true;
   }
 
   /**
    * Get the list of enabled column names from whichever checkboxes are
    * checked (excluding the number checkbox).
+   * private but used in test
    */
-  _getDisplayedColumns() {
-    if (this.root === null) return [];
-    return (
-      Array.from(
-        this.root.querySelectorAll(
-          '.checkboxContainer input:not([name=number])'
-        )
-      ) as HTMLInputElement[]
+  getDisplayedColumns() {
+    if (this.shadowRoot === null) return [];
+    return Array.from(
+      this.shadowRoot.querySelectorAll<HTMLInputElement>(
+        '.checkboxContainer input:not([name=number])'
+      )
     )
       .filter(checkbox => checkbox.checked)
       .map(checkbox => checkbox.name);
   }
 
-  _computeIsColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
-    if (!columnsToDisplay || !columnToCheck) {
+  private computeIsColumnHidden(columnToCheck?: string) {
+    if (!this.displayedColumns || !columnToCheck) {
       return false;
     }
-    return !columnsToDisplay.includes(columnToCheck);
+    return !this.displayedColumns.includes(columnToCheck);
   }
 
   /**
    * Handle a click on a checkbox container and relay the click to the checkbox it
    * contains.
    */
-  _handleCheckboxContainerClick(e: MouseEvent) {
+  private handleCheckboxContainerClick(e: MouseEvent) {
     if (e.target === null) return;
     const checkbox = (e.target as HTMLElement).querySelector('input');
     if (!checkbox) {
@@ -114,22 +184,26 @@
    * Handle a click on the number checkbox and update the showNumber property
    * accordingly.
    */
-  _handleNumberCheckboxClick(e: MouseEvent) {
-    this.showNumber = (
-      (dom(e) as EventApi).rootTarget as HTMLInputElement
-    ).checked;
+  private handleNumberCheckboxClick(e: MouseEvent) {
+    this.showNumber = (e.target as HTMLInputElement).checked;
+    fire(this, 'show-number-changed', {value: this.showNumber});
   }
 
   /**
    * Handle a click on a displayed column checkboxes (excluding number) and
    * update the displayedColumns property accordingly.
    */
-  _handleTargetClick() {
-    this.set('displayedColumns', this._getDisplayedColumns());
+  private handleTargetClick() {
+    this.displayedColumns = this.getDisplayedColumns();
+    fire(this, 'displayed-columns-changed', {value: this.displayedColumns});
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'show-number-changed': ValueChangedEvent<boolean>;
+    'displayed-columns-changed': ValueChangedEvent<string[]>;
+  }
   interface HTMLElementTagNameMap {
     'gr-change-table-editor': GrChangeTableEditor;
   }
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
deleted file mode 100644
index e756a20..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #changeCols {
-      width: auto;
-    }
-    #changeCols .visibleHeader {
-      text-align: center;
-    }
-    .checkboxContainer {
-      cursor: pointer;
-      text-align: center;
-    }
-    .checkboxContainer input {
-      cursor: pointer;
-    }
-    .checkboxContainer:hover {
-      outline: 1px solid var(--border-color);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="changeCols">
-      <thead>
-        <tr>
-          <th class="nameHeader">Column</th>
-          <th class="visibleHeader">Visible</th>
-        </tr>
-      </thead>
-      <tbody>
-        <tr>
-          <td><label for="numberCheckbox">Number</label></td>
-          <td
-            class="checkboxContainer"
-            on-click="_handleCheckboxContainerClick"
-          >
-            <input
-              id="numberCheckbox"
-              type="checkbox"
-              name="number"
-              on-click="_handleNumberCheckboxClick"
-              checked$="[[showNumber]]"
-            />
-          </td>
-        </tr>
-        <template is="dom-repeat" items="[[defaultColumns]]">
-          <tr>
-            <td><label for$="[[item]]">[[item]]</label></td>
-            <td
-              class="checkboxContainer"
-              on-click="_handleCheckboxContainerClick"
-            >
-              <input
-                id$="[[item]]"
-                type="checkbox"
-                name="[[item]]"
-                on-click="_handleTargetClick"
-                checked$="[[!_computeIsColumnHidden(item, displayedColumns)]]"
-              />
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index 4f8d0a0..4d3d3a1 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -1,52 +1,117 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 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');
+import {fixture, html, assert} from '@open-wc/testing';
+import {ColumnNames} from '../../../constants/constants';
 
 suite('gr-change-table-editor tests', () => {
   let element: GrChangeTableEditor;
   let columns: string[];
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrChangeTableEditor>(
+      html`<gr-change-table-editor></gr-change-table-editor>`
+    );
 
     columns = [
       'Subject',
       'Status',
       'Owner',
-      'Assignee',
       'Reviewers',
       'Comments',
       'Repo',
-      'Branch',
-      'Updated',
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
     ];
 
-    element.set('displayedColumns', columns);
+    element.displayedColumns = columns;
     element.showNumber = false;
     element.serverConfig = createServerInfo();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <div class="gr-form-styles">
+        <table id="changeCols">
+          <thead>
+            <tr>
+              <th class="nameHeader">Column</th>
+              <th class="visibleHeader">Visible</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              <td><label for="numberCheckbox"> Number </label></td>
+              <td class="checkboxContainer">
+                <input id="numberCheckbox" name="number" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Subject"> Subject </label></td>
+              <td class="checkboxContainer">
+                <input checked="" id="Subject" name="Subject" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Owner"> Owner </label></td>
+              <td class="checkboxContainer">
+                <input checked="" id="Owner" name="Owner" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Reviewers"> Reviewers </label></td>
+              <td class="checkboxContainer">
+                <input
+                  checked=""
+                  id="Reviewers"
+                  name="Reviewers"
+                  type="checkbox"
+                />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Repo"> Repo </label></td>
+              <td class="checkboxContainer">
+                <input checked="" id="Repo" name="Repo" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Branch"> Branch </label></td>
+              <td class="checkboxContainer">
+                <input checked="" id="Branch" name="Branch" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Updated"> Updated </label></td>
+              <td class="checkboxContainer">
+                <input checked="" id="Updated" name="Updated" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for="Size"> Size </label></td>
+              <td class="checkboxContainer">
+                <input id="Size" name="Size" type="checkbox" />
+              </td>
+            </tr>
+            <tr>
+              <td><label for=" Status "> Status </label></td>
+              <td class="checkboxContainer">
+                <input id=" Status " name=" Status " type="checkbox" />
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>`
+    );
   });
 
   test('renders', () => {
@@ -62,17 +127,7 @@
     }
   });
 
-  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', () => {
+  test('hide item', async () => {
     const checkbox = queryAndAssert<HTMLInputElement>(
       element,
       'table tr:nth-child(2) input'
@@ -81,24 +136,23 @@
     const displayedLength = element.displayedColumns.length;
     assert.isTrue(isChecked);
 
-    MockInteractions.tap(checkbox);
-    flush();
+    checkbox.click();
+    await element.updateComplete;
 
     assert.equal(element.displayedColumns.length, displayedLength - 1);
   });
 
-  test('show item', () => {
-    element.set('displayedColumns', [
-      'Status',
-      'Owner',
-      'Assignee',
-      'Repo',
-      'Branch',
-      'Updated',
-    ]);
+  test('show item', async () => {
+    element.displayedColumns = [
+      ColumnNames.STATUS,
+      ColumnNames.OWNER,
+      ColumnNames.REPO,
+      ColumnNames.BRANCH,
+      ColumnNames.UPDATED,
+    ];
     // trigger computation of enabled displayed columns
     element.serverConfig = createServerInfo();
-    flush();
+    await element.updateComplete;
     const checkbox = queryAndAssert<HTMLInputElement>(
       element,
       'table tr:nth-child(2) input'
@@ -109,74 +163,68 @@
     const table = queryAndAssert<HTMLTableElement>(element, 'table');
     assert.equal(table.style.display, '');
 
-    MockInteractions.tap(checkbox);
-    flush();
+    checkbox.click();
+    await element.updateComplete;
 
     assert.equal(element.displayedColumns.length, displayedLength + 1);
   });
 
-  test('_getDisplayedColumns', () => {
+  test('getDisplayedColumns', () => {
     const enabledColumns = columns.filter(column =>
-      element._isColumnEnabled(column, element.serverConfig!, [])
+      element.isColumnEnabled(column)
     );
-    assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
+    assert.deepEqual(element.getDisplayedColumns(), enabledColumns);
     const input = queryAndAssert<HTMLInputElement>(
       element,
       '.checkboxContainer input[name=Subject]'
     );
-    MockInteractions.tap(input);
+    input.click();
     assert.deepEqual(
-      element._getDisplayedColumns(),
+      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(
+  test('handleCheckboxContainerClick relays taps to checkboxes', async () => {
+    const firstContainer = queryAndAssert<HTMLTableRowElement>(
       element,
       'table tr:first-of-type .checkboxContainer'
     );
-    MockInteractions.tap(firstContainer);
-    assert.isTrue(checkBoxClickStub.calledOnce);
-    assert.isFalse(targetClickStub.called);
+    assert.isFalse(element.showNumber);
+    firstContainer.click();
+    assert.isTrue(element.showNumber);
 
-    const lastContainer = queryAndAssert(
+    const lastContainer = queryAndAssert<HTMLTableRowElement>(
       element,
       'table tr:last-of-type .checkboxContainer'
     );
-    MockInteractions.tap(lastContainer);
-    assert.isTrue(checkBoxClickStub.calledOnce);
-    assert.isTrue(targetClickStub.calledOnce);
+    const lastColumn =
+      element.defaultColumns[element.defaultColumns.length - 1];
+    assert.notInclude(element.displayedColumns, lastColumn);
+    lastContainer.click();
+    await element.updateComplete;
+    assert.include(element.displayedColumns, lastColumn);
   });
 
-  test('_handleNumberCheckboxClick', () => {
-    const checkBoxClickSpy = sinon.spy(element, '_handleNumberCheckboxClick');
-
-    const numberInput = queryAndAssert(
+  test('handleNumberCheckboxClick', () => {
+    const numberInput = queryAndAssert<HTMLInputElement>(
       element,
       '.checkboxContainer input[name=number]'
     );
-    MockInteractions.tap(numberInput);
-    assert.isTrue(checkBoxClickSpy.calledOnce);
+    numberInput.click();
     assert.isTrue(element.showNumber);
 
-    MockInteractions.tap(numberInput);
-    assert.isTrue(checkBoxClickSpy.calledTwice);
+    numberInput.click();
     assert.isFalse(element.showNumber);
   });
 
-  test('_handleTargetClick', () => {
-    const targetClickSpy = sinon.spy(element, '_handleTargetClick');
+  test('handleTargetClick', () => {
     assert.include(element.displayedColumns, 'Subject');
-    const subjectInput = queryAndAssert(
+    const subjectInput = queryAndAssert<HTMLInputElement>(
       element,
       '.checkboxContainer input[name=Subject]'
     );
-    MockInteractions.tap(subjectInput);
-    assert.isTrue(targetClickSpy.calledOnce);
+    subjectInput.click();
     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 5b757e6..2f835f7 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
@@ -1,36 +1,25 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-cla-view_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
 import {
   ServerInfo,
   GroupInfo,
   ContributorAgreementInfo,
 } from '../../../types/common';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {ifDefined} from 'lit/directives/if-defined.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,33 +28,26 @@
 }
 
 @customElement('gr-cla-view')
-export class GrClaView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrClaView extends LitElement {
+  // private but used in test
+  @state() groups?: GroupInfo[];
 
-  @property({type: Object})
-  _groups?: GroupInfo[];
+  // private but used in test
+  @state() serverConfig?: ServerInfo;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  @state() private agreementsText?: string;
 
-  @property({type: String})
-  _agreementsText?: string;
+  // private but used in test
+  @state() agreementName?: string;
 
-  @property({type: String})
-  _agreementName?: string;
+  // private but used in test
+  @state() signedAgreements?: ContributorAgreementInfo[];
 
-  @property({type: Array})
-  _signedAgreements?: ContributorAgreementInfo[];
+  @state() private showAgreements = false;
 
-  @property({type: Boolean})
-  _showAgreements = false;
+  @state() private agreementsUrl?: string;
 
-  @property({type: String})
-  _agreementsUrl?: string;
-
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
@@ -74,18 +56,136 @@
     fireTitleChange(this, 'New Contributor Agreement');
   }
 
+  static override get styles() {
+    return [
+      fontStyles,
+      formStyles,
+      sharedStyles,
+      css`
+        h1 {
+          margin-bottom: var(--spacing-m);
+        }
+        h3 {
+          margin-bottom: var(--spacing-m);
+        }
+        .agreementsUrl {
+          border: 1px solid var(--border-color);
+          margin-bottom: var(--spacing-xl);
+          margin-left: var(--spacing-xl);
+          margin-right: var(--spacing-xl);
+          padding: var(--spacing-s);
+        }
+        #claNewAgreementsLabel {
+          font-weight: var(--font-weight-bold);
+        }
+        .contributorAgreementButton {
+          font-weight: var(--font-weight-bold);
+        }
+        .alreadySubmittedText {
+          color: var(--error-text-color);
+          margin: 0 var(--spacing-xxl);
+          padding: var(--spacing-m);
+        }
+        main {
+          margin: var(--spacing-xxl) auto;
+          max-width: 50em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <main>
+        <h1 class="heading-1">New Contributor Agreement</h1>
+        <h3 class="heading-3">Select an agreement type:</h3>
+        ${(this.serverConfig?.auth.contributor_agreements ?? [])
+          .filter(agreement => agreement.url)
+          .map(item => this.renderAgreementsButton(item))}
+        ${this.renderNewAgreement()}
+      </main>
+    `;
+  }
+
+  private renderAgreementsButton(item: ContributorAgreementInfo) {
+    return html`
+      <span class="contributorAgreementButton">
+        <input
+          id="claNewAgreementsInput${item.name}"
+          name="claNewAgreementsRadio"
+          type="radio"
+          data-name=${ifDefined(item.name)}
+          data-url=${ifDefined(item.url)}
+          @click=${this.handleShowAgreement}
+          ?disabled=${this.disableAgreements(item)}
+        />
+        <label id="claNewAgreementsLabel">${item.name}</label>
+      </span>
+      ${this.renderAlreadySubmittedText(item)}
+      <div class="agreementsUrl">${item.description}</div>
+    `;
+  }
+
+  private renderAlreadySubmittedText(item: ContributorAgreementInfo) {
+    if (!this.disableAgreements(item)) return;
+
+    return html`
+      <div class="alreadySubmittedText">Agreement already submitted.</div>
+    `;
+  }
+
+  private renderNewAgreement() {
+    if (!this.showAgreements) return;
+    return html`
+      <div id="claNewAgreement">
+        <h3 class="heading-3">Review the agreement:</h3>
+        <div id="agreementsUrl" class="agreementsUrl">
+          <a
+            href=${ifDefined(this.agreementsUrl)}
+            target="blank"
+            rel="noopener"
+          >
+            Please review the agreement.</a
+          >
+        </div>
+        ${this.renderAgreementsTextBox()}
+      </div>
+    `;
+  }
+
+  private renderAgreementsTextBox() {
+    if (this.computeHideAgreementTextbox()) return;
+    return html`
+      <div class="agreementsTextBox">
+        <h3 class="heading-3">Complete the agreement:</h3>
+        <iron-input
+          .bindValue=${this.agreementsText}
+          @bind-value-changed=${this.handleBindValueChanged}
+        >
+          <input id="input-agreements" placeholder="Enter 'I agree' here" />
+        </iron-input>
+        <gr-button
+          @click=${this.handleSaveAgreements}
+          ?disabled=${this.agreementsText?.toLowerCase() !== 'i agree'}
+        >
+          Submit
+        </gr-button>
+      </div>
+    `;
+  }
+
   loadData() {
     const promises = [];
     promises.push(
       this.restApiService.getConfig(true).then(config => {
-        this._serverConfig = config;
+        this.serverConfig = config;
       })
     );
 
     promises.push(
       this.restApiService.getAccountGroups().then(groups => {
         if (!groups) return;
-        this._groups = groups.sort((a, b) =>
+        this.groups = groups.sort((a, b) =>
           (a.name || '').localeCompare(b.name || '')
         );
       })
@@ -95,70 +195,59 @@
       this.restApiService
         .getAccountAgreements()
         .then((agreements: ContributorAgreementInfo[] | undefined) => {
-          this._signedAgreements = agreements || [];
+          this.signedAgreements = agreements || [];
         })
     );
 
     return Promise.all(promises);
   }
 
-  _getAgreementsUrl(configUrl: string) {
-    let url;
-    if (!configUrl) {
-      return '';
-    }
+  // private but used in test
+  getAgreementsUrl(configUrl: string) {
+    if (!configUrl) return '';
+
     if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
-      url = configUrl;
-    } else {
-      url = getBaseUrl() + '/' + configUrl;
+      return configUrl;
     }
 
-    return url;
+    return `${getBaseUrl()}/${configUrl}`;
   }
 
-  _handleShowAgreement(e: Event) {
-    this._agreementName = (e.target as HTMLInputElement).getAttribute(
+  private readonly handleShowAgreement = (e: Event) => {
+    this.agreementName = (e.target as HTMLInputElement).getAttribute(
       'data-name'
     )!;
     const url = (e.target as HTMLInputElement).getAttribute('data-url')!;
-    this._agreementsUrl = this._getAgreementsUrl(url);
-    this._showAgreements = true;
-  }
+    this.agreementsUrl = this.getAgreementsUrl(url);
+    this.showAgreements = true;
+  };
 
-  _handleSaveAgreements() {
-    this._createToast('Agreement saving...');
+  private readonly handleSaveAgreements = () => {
+    this.createToast('Agreement saving...');
 
-    const name = this._agreementName;
+    const name = this.agreementName;
     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.';
       }
-      this._createToast(message);
+      this.createToast(message);
       this.loadData();
-      this._agreementsText = '';
-      this._showAgreements = false;
+      this.agreementsText = '';
+      this.showAgreements = false;
     });
-  }
+  };
 
-  _createToast(message: string) {
+  private createToast(message: string) {
     fireAlert(this, message);
   }
 
-  _computeShowAgreementsClass(showAgreements: boolean) {
-    return showAgreements ? 'show' : '';
-  }
-
-  _disableAgreements(
-    item: ContributorAgreementInfo,
-    groups?: GroupInfo[],
-    signedAgreements?: ContributorAgreementInfo[]
-  ) {
-    if (!groups) return false;
-    for (const group of groups) {
+  // private but used in test
+  disableAgreements(item: ContributorAgreementInfo) {
+    for (const group of this.groups ?? []) {
       if (
         item?.auto_verify_group?.id === group.id ||
-        signedAgreements?.find(i => i.name === item.name)
+        this.signedAgreements?.find(i => i.name === item.name)
       ) {
         return true;
       }
@@ -166,34 +255,22 @@
     return false;
   }
 
-  _hideAgreements(
-    item: ContributorAgreementInfo,
-    groups?: GroupInfo[],
-    signedAgreements?: ContributorAgreementInfo[]
-  ) {
-    return this._disableAgreements(item, groups, signedAgreements)
-      ? ''
-      : 'hide';
-  }
-
-  _disableAgreementsText(text?: string) {
-    return text?.toLowerCase() === 'i agree' ? false : true;
-  }
-
   // This checks for auto_verify_group,
-  // if specified it returns 'hideAgreementsTextBox' which
+  // if specified it returns 'true' which
   // then hides the text box and submit button.
-  _computeHideAgreementClass(
-    name?: string,
-    contributorAgreements?: ContributorAgreementInfo[]
-  ) {
-    if (!name || !contributorAgreements) return '';
+  // private but used in test
+  computeHideAgreementTextbox() {
+    const contributorAgreements =
+      this.serverConfig?.auth.contributor_agreements;
+    if (!this.agreementName || !contributorAgreements) return false;
     return contributorAgreements.some(
       (contributorAgreement: ContributorAgreementInfo) =>
-        name === contributorAgreement.name &&
+        this.agreementName === contributorAgreement.name &&
         !contributorAgreement.auto_verify_group
-    )
-      ? 'hideAgreementsTextBox'
-      : '';
+    );
   }
+
+  private readonly handleBindValueChanged = (e: BindValueChangeEvent) => {
+    this.agreementsText = e.detail.value;
+  };
 }
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
deleted file mode 100644
index ce95ccb..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    h1 {
-      margin-bottom: var(--spacing-m);
-    }
-    h3 {
-      margin-bottom: var(--spacing-m);
-    }
-    .agreementsUrl {
-      border: 1px solid var(--border-color);
-      margin-bottom: var(--spacing-xl);
-      margin-left: var(--spacing-xl);
-      margin-right: var(--spacing-xl);
-      padding: var(--spacing-s);
-    }
-    #claNewAgreementsLabel {
-      font-weight: var(--font-weight-bold);
-    }
-    #claNewAgreement {
-      display: none;
-    }
-    #claNewAgreement.show {
-      display: block;
-    }
-    .contributorAgreementButton {
-      font-weight: var(--font-weight-bold);
-    }
-    .alreadySubmittedText {
-      color: var(--error-text-color);
-      margin: 0 var(--spacing-xxl);
-      padding: var(--spacing-m);
-    }
-    .alreadySubmittedText.hide,
-    .hideAgreementsTextBox {
-      display: none;
-    }
-    main {
-      margin: var(--spacing-xxl) auto;
-      max-width: 50em;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <main>
-    <h1 class="heading-1">New Contributor Agreement</h1>
-    <h3 class="heading-3">Select an agreement type:</h3>
-    <template
-      is="dom-repeat"
-      items="[[_serverConfig.auth.contributor_agreements]]"
-    >
-      <span class="contributorAgreementButton">
-        <input
-          id$="claNewAgreementsInput[[item.name]]"
-          name="claNewAgreementsRadio"
-          type="radio"
-          data-name$="[[item.name]]"
-          data-url$="[[item.url]]"
-          on-click="_handleShowAgreement"
-          disabled$="[[_disableAgreements(item, _groups, _signedAgreements)]]"
-        />
-        <label id="claNewAgreementsLabel">[[item.name]]</label>
-      </span>
-      <div
-        class$="alreadySubmittedText [[_hideAgreements(item, _groups, _signedAgreements)]]"
-      >
-        Agreement already submitted.
-      </div>
-      <div class="agreementsUrl">[[item.description]]</div>
-    </template>
-    <div
-      id="claNewAgreement"
-      class$="[[_computeShowAgreementsClass(_showAgreements)]]"
-    >
-      <h3 class="heading-3">Review the agreement:</h3>
-      <div id="agreementsUrl" class="agreementsUrl">
-        <a href$="[[_agreementsUrl]]" target="blank" rel="noopener">
-          Please review the agreement.</a
-        >
-      </div>
-      <div
-        class$="agreementsTextBox [[_computeHideAgreementClass(_agreementName, _serverConfig.auth.contributor_agreements)]]"
-      >
-        <h3 class="heading-3">Complete the agreement:</h3>
-        <iron-input
-          bind-value="{{_agreementsText}}"
-          placeholder="Enter 'I agree' here"
-        >
-          <input id="input-agreements" placeholder="Enter 'I agree' here" />
-        </iron-input>
-        <gr-button
-          on-click="_handleSaveAgreements"
-          disabled="[[_disableAgreementsText(_agreementsText)]]"
-        >
-          Submit
-        </gr-button>
-      </div>
-    </div>
-  </main>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
index 979f859..a321610 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-cla-view';
-import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {stubRestApi} from '../../../test/test-utils';
 import {GrClaView} from './gr-cla-view';
 import {
   ContributorAgreementInfo,
@@ -27,8 +16,7 @@
 } from '../../../types/common';
 import {AuthType} from '../../../constants/constants';
 import {createServerInfo} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-cla-view');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-cla-view tests', () => {
   let element: GrClaView;
@@ -131,79 +119,74 @@
     stubRestApi('getAccountAgreements').returns(
       Promise.resolve(signedAgreements)
     );
-    element = basicFixture.instantiate();
+    element = await fixture<GrClaView>(html` <gr-cla-view></gr-cla-view> `);
     await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
-  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('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <main>
+          <h1 class="heading-1">New Contributor Agreement</h1>
+          <h3 class="heading-3">Select an agreement type:</h3>
+          <span class="contributorAgreementButton">
+            <input
+              data-name="Individual"
+              data-url="static/cla_individual.html"
+              id="claNewAgreementsInputIndividual"
+              name="claNewAgreementsRadio"
+              type="radio"
+            />
+            <label id="claNewAgreementsLabel"> Individual </label>
+          </span>
+          <div class="agreementsUrl">test-description</div>
+          <span class="contributorAgreementButton">
+            <input
+              data-name="CLA"
+              data-url="static/cla.html"
+              disabled=""
+              id="claNewAgreementsInputCLA"
+              name="claNewAgreementsRadio"
+              type="radio"
+            />
+            <label id="claNewAgreementsLabel"> CLA </label>
+          </span>
+          <div class="alreadySubmittedText">Agreement already submitted.</div>
+          <div class="agreementsUrl">Contributor License Agreement</div>
+        </main>
+      `
     );
   });
 
-  test('_disableAgreements', () => {
+  test('disableAgreements', () => {
+    element.groups = groups;
+    element.signedAgreements = signedAgreements;
     // In the auto verify group and have not yet signed agreement
-    assert.isTrue(element._disableAgreements(auth, groups, signedAgreements));
+    assert.isTrue(element.disableAgreements(auth));
     // Not in the auto verify group and have not yet signed agreement
-    assert.isFalse(element._disableAgreements(auth2, groups, signedAgreements));
+    assert.isFalse(element.disableAgreements(auth2));
     // Not in the auto verify group, have signed agreement
-    assert.isTrue(element._disableAgreements(auth3, groups, signedAgreements));
+    assert.isTrue(element.disableAgreements(auth3));
+    element.groups = undefined;
     // Make sure the undefined check works
-    assert.isFalse(
-      element._disableAgreements(auth, undefined, signedAgreements)
-    );
+    assert.isFalse(element.disableAgreements(auth));
   });
 
-  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
+  test('computeHideAgreementTextbox', () => {
+    element.agreementName = auth.name;
+    element.serverConfig = config;
+    assert.isTrue(element.computeHideAgreementTextbox());
+    element.serverConfig = config2;
+    assert.isFalse(element.computeHideAgreementTextbox());
+  });
+
+  test('getAgreementsUrl', () => {
     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'),
+      element.getAgreementsUrl('http://test.org/test.html'),
       'http://test.org/test.html'
     );
-    assert.equal(element._getAgreementsUrl('test_cla.html'), '/test_cla.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 7cfd1b3..183425d 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
@@ -1,142 +1,314 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-edit-preferences_html';
-import {customElement, property} from '@polymer/decorators';
 import {EditPreferencesInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {convertToString} from '../../../utils/string-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
 
-export interface GrEditPreferences {
-  $: {
-    editTabWidth: HTMLInputElement;
-    editColumns: HTMLInputElement;
-    editIndentUnit: HTMLInputElement;
-    editSyntaxHighlighting: HTMLInputElement;
-    showAutoCloseBrackets: HTMLInputElement;
-    showIndentWithTabs: HTMLInputElement;
-    showMatchBrackets: HTMLInputElement;
-    editShowLineWrapping: HTMLInputElement;
-    editShowTabs: HTMLInputElement;
-    editShowTrailingWhitespaceInput: HTMLInputElement;
-  };
-}
 @customElement('gr-edit-preferences')
-export class GrEditPreferences extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrEditPreferences extends LitElement {
+  @query('#editTabWidth') private editTabWidth?: HTMLInputElement;
 
-  @property({type: Boolean, notify: true})
-  hasUnsavedChanges = false;
+  @query('#editColumns') private editColumns?: HTMLInputElement;
 
-  @property({type: Object})
-  editPrefs?: EditPreferencesInfo;
+  @query('#editIndentUnit') private editIndentUnit?: HTMLInputElement;
 
-  private readonly restApiService = appContext.restApiService;
+  @query('#editSyntaxHighlighting')
+  private editSyntaxHighlighting?: HTMLInputElement;
 
-  loadData() {
-    return this.restApiService.getEditPreferences().then(prefs => {
-      this.editPrefs = prefs;
-    });
-  }
+  @query('#showAutoCloseBrackets')
+  private showAutoCloseBrackets?: HTMLInputElement;
 
-  _handleEditPrefsChanged() {
-    this.hasUnsavedChanges = true;
-  }
+  @query('#showIndentWithTabs') private showIndentWithTabs?: HTMLInputElement;
 
-  _handleEditTabWidthChanged() {
-    this.set('editPrefs.tab_size', Number(this.$.editTabWidth.value));
-    this._handleEditPrefsChanged();
-  }
+  @query('#showMatchBrackets') private showMatchBrackets?: HTMLInputElement;
 
-  _handleEditLineLengthChanged() {
-    this.set('editPrefs.line_length', Number(this.$.editColumns.value));
-    this._handleEditPrefsChanged();
-  }
+  @query('#editShowLineWrapping')
+  private editShowLineWrapping?: HTMLInputElement;
 
-  _handleEditIndentUnitChanged() {
-    this.set('editPrefs.indent_unit', Number(this.$.editIndentUnit.value));
-    this._handleEditPrefsChanged();
-  }
+  @query('#editShowTabs') private editShowTabs?: HTMLInputElement;
 
-  _handleEditSyntaxHighlightingChanged() {
-    this.set(
-      'editPrefs.syntax_highlighting',
-      this.$.editSyntaxHighlighting.checked
+  @query('#editShowTrailingWhitespaceInput')
+  private editShowTrailingWhitespaceInput?: HTMLInputElement;
+
+  @state() editPrefs?: EditPreferencesInfo;
+
+  @state() private originalEditPrefs?: EditPreferencesInfo;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().editPreferences$,
+      editPreferences => {
+        this.originalEditPrefs = editPreferences;
+        this.editPrefs = {...editPreferences};
+      }
     );
-    this._handleEditPrefsChanged();
   }
 
-  _handleEditShowTabsChanged() {
-    this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
-    this._handleEditPrefsChanged();
+  static override get styles() {
+    return [
+      sharedStyles,
+      menuPageStyles,
+      formStyles,
+      css`
+        :host {
+          border: none;
+          margin-bottom: var(--spacing-xxl);
+        }
+        h2 {
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h2);
+          font-weight: var(--font-weight-h2);
+          line-height: var(--line-height-h2);
+        }
+      `,
+    ];
   }
 
-  _handleEditShowTrailingWhitespaceTap() {
-    this.set(
-      'editPrefs.show_whitespace_errors',
-      this.$.editShowTrailingWhitespaceInput.checked
+  override render() {
+    return html`
+      <h2
+        id="EditPreferences"
+        class=${this.hasUnsavedChanges() ? 'edited' : ''}
+      >
+        Edit Preferences
+      </h2>
+      <fieldset id="editPreferences">
+        <div id="editPreferences" class="gr-form-styles">
+          <section>
+            <label for="editTabWidth" class="title">Tab width</label>
+            <span class="value">
+              <iron-input
+                .allowedPattern=${'[0-9]'}
+                .bindValue=${convertToString(this.editPrefs?.tab_size)}
+                @change=${this.handleEditTabWidthChanged}
+              >
+                <input id="editTabWidth" type="number" />
+              </iron-input>
+            </span>
+          </section>
+          <section>
+            <label for="editColumns" class="title">Columns</label>
+            <span class="value">
+              <iron-input
+                .allowedPattern=${'[0-9]'}
+                .bindValue=${convertToString(this.editPrefs?.line_length)}
+                @change=${this.handleEditLineLengthChanged}
+              >
+                <input id="editColumns" type="number" />
+              </iron-input>
+            </span>
+          </section>
+          <section>
+            <label for="editIndentUnit" class="title">Indent unit</label>
+            <span class="value">
+              <iron-input
+                .allowedPattern=${'[0-9]'}
+                .bindValue=${convertToString(this.editPrefs?.indent_unit)}
+                @change=${this.handleEditIndentUnitChanged}
+              >
+                <input id="editIndentUnit" type="number" />
+              </iron-input>
+            </span>
+          </section>
+          <section>
+            <label for="editSyntaxHighlighting" class="title"
+              >Syntax highlighting</label
+            >
+            <span class="value">
+              <input
+                id="editSyntaxHighlighting"
+                type="checkbox"
+                ?checked=${this.editPrefs?.syntax_highlighting}
+                @change=${this.handleEditSyntaxHighlightingChanged}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="editShowTabs" class="title">Show tabs</label>
+            <span class="value">
+              <input
+                id="editShowTabs"
+                type="checkbox"
+                ?checked=${this.editPrefs?.show_tabs}
+                @change=${this.handleEditShowTabsChanged}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="showTrailingWhitespaceInput" class="title"
+              >Show trailing whitespace</label
+            >
+            <span class="value">
+              <input
+                id="editShowTrailingWhitespaceInput"
+                type="checkbox"
+                ?checked=${this.editPrefs?.show_whitespace_errors}
+                @change=${this.handleEditShowTrailingWhitespaceTap}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="showMatchBrackets" class="title">Match brackets</label>
+            <span class="value">
+              <input
+                id="showMatchBrackets"
+                type="checkbox"
+                ?checked=${this.editPrefs?.match_brackets}
+                @change=${this.handleMatchBracketsChanged}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="editShowLineWrapping" class="title"
+              >Line wrapping</label
+            >
+            <span class="value">
+              <input
+                id="editShowLineWrapping"
+                type="checkbox"
+                ?checked=${this.editPrefs?.line_wrapping}
+                @change=${this.handleEditLineWrappingChanged}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="showIndentWithTabs" class="title"
+              >Indent with tabs</label
+            >
+            <span class="value">
+              <input
+                id="showIndentWithTabs"
+                type="checkbox"
+                ?checked=${this.editPrefs?.indent_with_tabs}
+                @change=${this.handleIndentWithTabsChanged}
+              />
+            </span>
+          </section>
+          <section>
+            <label for="showAutoCloseBrackets" class="title"
+              >Auto close brackets</label
+            >
+            <span class="value">
+              <input
+                id="showAutoCloseBrackets"
+                type="checkbox"
+                ?checked=${this.editPrefs?.auto_close_brackets}
+                @change=${this.handleAutoCloseBracketsChanged}
+              />
+            </span>
+          </section>
+        </div>
+        <gr-button
+          id="saveEditPrefs"
+          @click=${this.handleSaveEditPreferences}
+          ?disabled=${!this.hasUnsavedChanges()}
+          >Save changes</gr-button
+        >
+      </fieldset>
+    `;
+  }
+
+  private readonly handleEditTabWidthChanged = () => {
+    this.editPrefs!.tab_size = Number(this.editTabWidth!.value);
+    this.requestUpdate();
+  };
+
+  private readonly handleEditLineLengthChanged = () => {
+    this.editPrefs!.line_length = Number(this.editColumns!.value);
+    this.requestUpdate();
+  };
+
+  private readonly handleEditIndentUnitChanged = () => {
+    this.editPrefs!.indent_unit = Number(this.editIndentUnit!.value);
+    this.requestUpdate();
+  };
+
+  private readonly handleEditSyntaxHighlightingChanged = () => {
+    this.editPrefs!.syntax_highlighting = this.editSyntaxHighlighting!.checked;
+    this.requestUpdate();
+  };
+
+  // private but used in test
+  readonly handleEditShowTabsChanged = () => {
+    this.editPrefs!.show_tabs = this.editShowTabs!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleEditShowTrailingWhitespaceTap = () => {
+    this.editPrefs!.show_whitespace_errors =
+      this.editShowTrailingWhitespaceInput!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleMatchBracketsChanged = () => {
+    this.editPrefs!.match_brackets = this.showMatchBrackets!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleEditLineWrappingChanged = () => {
+    this.editPrefs!.line_wrapping = this.editShowLineWrapping!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleIndentWithTabsChanged = () => {
+    this.editPrefs!.indent_with_tabs = this.showIndentWithTabs!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleAutoCloseBracketsChanged = () => {
+    this.editPrefs!.auto_close_brackets = this.showAutoCloseBrackets!.checked;
+    this.requestUpdate();
+  };
+
+  private readonly handleSaveEditPreferences = () => {
+    this.save();
+  };
+
+  // private but used in test
+  hasUnsavedChanges() {
+    // We have to wrap boolean values in Boolean() to ensure undefined values
+    // use false rather than undefined.
+    return (
+      this.originalEditPrefs?.tab_size !== this.editPrefs?.tab_size ||
+      this.originalEditPrefs?.line_length !== this.editPrefs?.line_length ||
+      this.originalEditPrefs?.indent_unit !== this.editPrefs?.indent_unit ||
+      Boolean(this.originalEditPrefs?.syntax_highlighting) !==
+        Boolean(this.editPrefs?.syntax_highlighting) ||
+      Boolean(this.originalEditPrefs?.show_tabs) !==
+        Boolean(this.editPrefs?.show_tabs) ||
+      Boolean(this.originalEditPrefs?.show_whitespace_errors) !==
+        Boolean(this.editPrefs?.show_whitespace_errors) ||
+      Boolean(this.originalEditPrefs?.match_brackets) !==
+        Boolean(this.editPrefs?.match_brackets) ||
+      Boolean(this.originalEditPrefs?.line_wrapping) !==
+        Boolean(this.editPrefs?.line_wrapping) ||
+      Boolean(this.originalEditPrefs?.indent_with_tabs) !==
+        Boolean(this.editPrefs?.indent_with_tabs) ||
+      Boolean(this.originalEditPrefs?.auto_close_brackets) !==
+        Boolean(this.editPrefs?.auto_close_brackets)
     );
-    this._handleEditPrefsChanged();
   }
 
-  _handleMatchBracketsChanged() {
-    this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleEditLineWrappingChanged() {
-    this.set('editPrefs.line_wrapping', this.$.editShowLineWrapping.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleIndentWithTabsChanged() {
-    this.set('editPrefs.indent_with_tabs', this.$.showIndentWithTabs.checked);
-    this._handleEditPrefsChanged();
-  }
-
-  _handleAutoCloseBracketsChanged() {
-    this.set(
-      'editPrefs.auto_close_brackets',
-      this.$.showAutoCloseBrackets.checked
-    );
-    this._handleEditPrefsChanged();
-  }
-
-  save() {
-    if (!this.editPrefs)
-      return Promise.reject(new Error('Missing edit preferences'));
-    return this.restApiService.saveEditPreferences(this.editPrefs).then(() => {
-      this.hasUnsavedChanges = false;
-    });
-  }
-
-  /**
-   * bind-value has type string so we have to convert
-   * anything inputed to string.
-   *
-   * This is so typescript checker doesn't fail.
-   */
-  _convertToString(key?: number) {
-    return key !== undefined ? String(key) : '';
+  async save() {
+    if (!this.editPrefs) return;
+    await this.getUserModel().updateEditPreference(this.editPrefs);
   }
 }
 
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
deleted file mode 100644
index abd925f..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_html.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div id="editPreferences" class="gr-form-styles">
-    <section>
-      <label for="editTabWidth" class="title">Tab width</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(editPrefs.tab_size)]]"
-          on-change="_handleEditTabWidthChanged"
-        >
-          <input id="editTabWidth" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label for="editColumns" class="title">Columns</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(editPrefs.line_length)]]"
-          on-change="_handleEditLineLengthChanged"
-        >
-          <input id="editColumns" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label for="editIndentUnit" class="title">Indent unit</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(editPrefs.indent_unit)]]"
-          on-change="_handleEditIndentUnitChanged"
-        >
-          <input id="indentUnit" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label for="editSyntaxHighlighting" class="title"
-        >Syntax highlighting</label
-      >
-      <span class="value">
-        <input
-          id="editSyntaxHighlighting"
-          type="checkbox"
-          checked$="[[editPrefs.syntax_highlighting]]"
-          on-change="_handleEditSyntaxHighlightingChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="editShowTabs" class="title">Show tabs</label>
-      <span class="value">
-        <input
-          id="editShowTabs"
-          type="checkbox"
-          checked$="[[editPrefs.show_tabs]]"
-          on-change="_handleEditShowTabsChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="showTrailingWhitespaceInput" class="title"
-        >Show trailing whitespace</label
-      >
-      <span class="value">
-        <input
-          id="editShowTrailingWhitespaceInput"
-          type="checkbox"
-          checked$="[[editPrefs.show_whitespace_errors]]"
-          on-change="_handleEditShowTrailingWhitespaceTap"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="showMatchBrackets" class="title">Match brackets</label>
-      <span class="value">
-        <input
-          id="showMatchBrackets"
-          type="checkbox"
-          checked$="[[editPrefs.match_brackets]]"
-          on-change="_handleMatchBracketsChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="editShowLineWrapping" class="title">Line wrapping</label>
-      <span class="value">
-        <input
-          id="editShowLineWrapping"
-          type="checkbox"
-          checked$="[[editPrefs.line_wrapping]]"
-          on-change="_handleEditLineWrappingChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="showIndentWithTabs" class="title">Indent with tabs</label>
-      <span class="value">
-        <input
-          id="showIndentWithTabs"
-          type="checkbox"
-          checked$="[[editPrefs.indent_with_tabs]]"
-          on-change="_handleIndentWithTabsChanged"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="showAutoCloseBrackets" class="title"
-        >Auto close brackets</label
-      >
-      <span class="value">
-        <input
-          id="showAutoCloseBrackets"
-          type="checkbox"
-          checked$="[[editPrefs.auto_close_brackets]]"
-          on-change="_handleAutoCloseBracketsChanged"
-        />
-      </span>
-    </section>
-  </div>
-`;
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
index bfdcaa0..bd682b8 100644
--- 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
@@ -1,36 +1,23 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-edit-preferences';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAll, stubRestApi} from '../../../test/test-utils';
 import {GrEditPreferences} from './gr-edit-preferences';
-import {EditPreferencesInfo} from '../../../types/common';
+import {EditPreferencesInfo, ParsedJSON} from '../../../types/common';
 import {IronInputElement} from '@polymer/iron-input';
-
-const basicFixture = fixtureFromElement('gr-edit-preferences');
+import {createDefaultEditPrefs} from '../../../constants/constants';
+import {fixture, html, assert} from '@open-wc/testing';
 
 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`) ?? [];
+    const sections = queryAll(element, `#${id} section`) ?? [];
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -43,34 +30,120 @@
   }
 
   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',
-    };
+    editPreferences = createDefaultEditPrefs();
 
     stubRestApi('getEditPreferences').returns(Promise.resolve(editPreferences));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-edit-preferences></gr-edit-preferences>`);
 
-    await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <h2 id="EditPreferences">Edit Preferences</h2>
+        <fieldset id="editPreferences">
+          <div class="gr-form-styles" id="editPreferences">
+            <section>
+              <label class="title" for="editTabWidth"> Tab width </label>
+              <span class="value">
+                <iron-input>
+                  <input id="editTabWidth" type="number" />
+                </iron-input>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="editColumns"> Columns </label>
+              <span class="value">
+                <iron-input>
+                  <input id="editColumns" type="number" />
+                </iron-input>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="editIndentUnit"> Indent unit </label>
+              <span class="value">
+                <iron-input>
+                  <input id="editIndentUnit" type="number" />
+                </iron-input>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="editSyntaxHighlighting">
+                Syntax highlighting
+              </label>
+              <span class="value">
+                <input checked="" id="editSyntaxHighlighting" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="editShowTabs"> Show tabs </label>
+              <span class="value">
+                <input checked="" id="editShowTabs" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showTrailingWhitespaceInput">
+                Show trailing whitespace
+              </label>
+              <span class="value">
+                <input
+                  checked=""
+                  id="editShowTrailingWhitespaceInput"
+                  type="checkbox"
+                />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showMatchBrackets">
+                Match brackets
+              </label>
+              <span class="value">
+                <input checked="" id="showMatchBrackets" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="editShowLineWrapping">
+                Line wrapping
+              </label>
+              <span class="value">
+                <input id="editShowLineWrapping" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showIndentWithTabs">
+                Indent with tabs
+              </label>
+              <span class="value">
+                <input id="showIndentWithTabs" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showAutoCloseBrackets">
+                Auto close brackets
+              </label>
+              <span class="value">
+                <input id="showAutoCloseBrackets" type="checkbox" />
+              </span>
+            </section>
+          </div>
+          <gr-button
+            aria-disabled="true"
+            disabled=""
+            id="saveEditPrefs"
+            role="button"
+            tabindex="-1"
+          >
+            Save changes
+          </gr-button>
+        </fieldset>
+      `
+    );
+  });
+
+  test('input values match preferences', () => {
     // Rendered with the expected preferences selected.
     const tabWidthInput = valueOf('Tab width', 'editPreferences')
       .firstElementChild as IronInputElement;
@@ -108,18 +181,29 @@
       .firstElementChild as HTMLInputElement;
     assert.equal(autoCloseInput.checked, editPreferences.auto_close_brackets);
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(element.hasUnsavedChanges());
   });
 
   test('save changes', async () => {
+    assert.isTrue(element.editPrefs?.show_tabs);
+
     const showTabsCheckbox = valueOf('Show tabs', 'editPreferences')
       .firstElementChild as HTMLInputElement;
     showTabsCheckbox.checked = false;
-    element._handleEditShowTabsChanged();
+    element.handleEditShowTabsChanged();
 
-    assert.isTrue(element.hasUnsavedChanges);
+    assert.isTrue(element.hasUnsavedChanges());
+
+    const getResponseObjStub = stubRestApi('getResponseObject').returns(
+      Promise.resolve(element.editPrefs! as unknown as ParsedJSON)
+    );
 
     await element.save();
-    assert.isFalse(element.hasUnsavedChanges);
+
+    assert.isTrue(getResponseObjStub.called);
+
+    assert.isFalse(element.editPrefs?.show_tabs);
+
+    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 7f25a86..b9f59bf 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
@@ -1,88 +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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-email-editor_html';
-import {customElement, property} from '@polymer/decorators';
 import {EmailInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 @customElement('gr-email-editor')
-export class GrEmailEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrEmailEditor extends LitElement {
+  @property({type: Boolean}) hasUnsavedChanges = false;
+
+  /* private but used in test */
+  @state() emails: EmailInfo[] = [];
+
+  /* private but used in test */
+  @state() emailsToRemove: EmailInfo[] = [];
+
+  /* private but used in test */
+  @state() newPreferred = '';
+
+  readonly restApiService = getAppContext().restApiService;
+
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      th {
+        color: var(--deemphasized-text-color);
+        text-align: left;
+      }
+      #emailTable .emailColumn {
+        min-width: 32.5em;
+        width: auto;
+      }
+      #emailTable .preferredHeader {
+        text-align: center;
+        width: 6em;
+      }
+      #emailTable .preferredControl {
+        cursor: pointer;
+        height: auto;
+        text-align: center;
+      }
+      #emailTable .preferredControl .preferredRadio {
+        height: auto;
+      }
+      .preferredControl:hover {
+        outline: 1px solid var(--border-color);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="gr-form-styles">
+      <table id="emailTable">
+        <thead>
+          <tr>
+            <th class="emailColumn">Email</th>
+            <th class="preferredHeader">Preferred</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${this.emails.map((email, index) => this.renderEmail(email, index))}
+        </tbody>
+      </table>
+    </div>`;
   }
 
-  @property({type: Boolean, notify: true})
-  hasUnsavedChanges = false;
-
-  @property({type: Array})
-  _emails: EmailInfo[] = [];
-
-  @property({type: Array})
-  _emailsToRemove: EmailInfo[] = [];
-
-  @property({type: String})
-  _newPreferred: string | null = null;
-
-  readonly restApiService = appContext.restApiService;
+  private renderEmail(email: EmailInfo, index: number) {
+    return html`<tr>
+      <td class="emailColumn">${email.email}</td>
+      <td class="preferredControl" @click=${this.handlePreferredControlClick}>
+        <iron-input
+          class="preferredRadio"
+          @change=${this.handlePreferredChange}
+          .bindValue=${email.email}
+        >
+          <input
+            class="preferredRadio"
+            type="radio"
+            @change=${this.handlePreferredChange}
+            name="preferred"
+            ?checked=${email.preferred}
+          />
+        </iron-input>
+      </td>
+      <td>
+        <gr-button
+          data-index=${index}
+          @click=${this.handleDeleteButton}
+          ?disabled=${this.checkPreferred(email.preferred)}
+          class="remove-button"
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
 
   loadData() {
     return this.restApiService.getAccountEmails().then(emails => {
-      this._emails = emails ?? [];
+      this.emails = emails ?? [];
     });
   }
 
   save() {
     const promises: Promise<unknown>[] = [];
 
-    for (const emailObj of this._emailsToRemove) {
+    for (const emailObj of this.emailsToRemove) {
       promises.push(this.restApiService.deleteAccountEmail(emailObj.email));
     }
 
-    if (this._newPreferred) {
+    if (this.newPreferred) {
       promises.push(
-        this.restApiService.setPreferredAccountEmail(this._newPreferred)
+        this.restApiService.setPreferredAccountEmail(this.newPreferred)
       );
     }
 
     return Promise.all(promises).then(() => {
-      this._emailsToRemove = [];
-      this._newPreferred = null;
-      this.hasUnsavedChanges = false;
+      this.emailsToRemove = [];
+      this.newPreferred = '';
+      this.setHasUnsavedChanges(false);
     });
   }
 
-  _handleDeleteButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
+  private handleDeleteButton(e: Event) {
+    const target = e.target;
     if (!(target instanceof Element)) return;
     const indexStr = target.getAttribute('data-index');
     if (indexStr === null) return;
     const index = Number(indexStr);
-    const email = this._emails[index];
-    this.push('_emailsToRemove', email);
-    this.splice('_emails', index, 1);
-    this.hasUnsavedChanges = true;
+    const email = this.emails[index];
+    this.emailsToRemove = [...this.emailsToRemove, email];
+    this.emails.splice(index, 1);
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _handlePreferredControlClick(e: Event) {
+  private handlePreferredControlClick(e: Event) {
     if (
       e.target instanceof HTMLElement &&
       e.target.classList.contains('preferredControl') &&
@@ -92,26 +155,36 @@
     }
   }
 
-  _handlePreferredChange(e: Event) {
+  private handlePreferredChange(e: Event) {
     if (!(e.target instanceof HTMLInputElement)) return;
     const preferred = e.target.value;
-    for (let i = 0; i < this._emails.length; i++) {
-      if (preferred === this._emails[i].email) {
-        this.set(['_emails', i, 'preferred'], true);
-        this._newPreferred = preferred;
-        this.hasUnsavedChanges = true;
-      } else if (this._emails[i].preferred) {
-        this.set(['_emails', i, 'preferred'], false);
+    for (let i = 0; i < this.emails.length; i++) {
+      if (preferred === this.emails[i].email) {
+        this.emails[i].preferred = true;
+        this.requestUpdate();
+        this.newPreferred = preferred;
+        this.setHasUnsavedChanges(true);
+      } else if (this.emails[i].preferred) {
+        this.emails[i].preferred = false;
+        this.requestUpdate();
       }
     }
   }
 
-  _checkPreferred(preferred?: boolean) {
+  private checkPreferred(preferred?: boolean) {
     return preferred ?? false;
   }
+
+  private setHasUnsavedChanges(value: boolean) {
+    this.hasUnsavedChanges = value;
+    fire(this, 'has-unsaved-changes-changed', {value});
+  }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'has-unsaved-changes-changed': ValueChangedEvent<boolean>;
+  }
   interface HTMLElementTagNameMap {
     'gr-email-editor': GrEmailEditor;
   }
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
deleted file mode 100644
index 666afb7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    th {
-      color: var(--deemphasized-text-color);
-      text-align: left;
-    }
-    #emailTable .emailColumn {
-      min-width: 32.5em;
-      width: auto;
-    }
-    #emailTable .preferredHeader {
-      text-align: center;
-      width: 6em;
-    }
-    #emailTable .preferredControl {
-      cursor: pointer;
-      height: auto;
-      text-align: center;
-    }
-    #emailTable .preferredControl .preferredRadio {
-      height: auto;
-    }
-    .preferredControl:hover {
-      outline: 1px solid var(--border-color);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="emailTable">
-      <thead>
-        <tr>
-          <th class="emailColumn">Email</th>
-          <th class="preferredHeader">Preferred</th>
-          <th></th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[_emails]]">
-          <tr>
-            <td class="emailColumn">[[item.email]]</td>
-            <td
-              class="preferredControl"
-              on-click="_handlePreferredControlClick"
-            >
-              <iron-input
-                class="preferredRadio"
-                type="radio"
-                on-change="_handlePreferredChange"
-                name="preferred"
-                bind-value="[[item.email]]"
-                checked$="[[item.preferred]]"
-              >
-                <input
-                  class="preferredRadio"
-                  type="radio"
-                  on-change="_handlePreferredChange"
-                  name="preferred"
-                  checked$="[[item.preferred]]"
-                />
-              </iron-input>
-            </td>
-            <td>
-              <gr-button
-                data-index$="[[index]]"
-                on-click="_handleDeleteButton"
-                disabled="[[_checkPreferred(item.preferred)]]"
-                class="remove-button"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </div>
-`;
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 e4c1584..25c9b97 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
@@ -1,26 +1,13 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-email-editor';
 import {GrEmailEditor} from './gr-email-editor';
 import {spyRestApi, stubRestApi} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-email-editor');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-email-editor tests', () => {
   let element: GrEmailEditor;
@@ -34,16 +21,111 @@
 
     stubRestApi('getAccountEmails').returns(Promise.resolve(emails));
 
-    element = basicFixture.instantiate();
+    element = await fixture<GrEmailEditor>(
+      html`<gr-email-editor></gr-email-editor>`
+    );
 
     await element.loadData();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="gr-form-styles">
+        <table id="emailTable">
+          <thead>
+            <tr>
+              <th class="emailColumn">Email</th>
+              <th class="preferredHeader">Preferred</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              <td class="emailColumn">email@one.com</td>
+              <td class="preferredControl">
+                <iron-input class="preferredRadio">
+                  <input
+                    class="preferredRadio"
+                    name="preferred"
+                    type="radio"
+                    value="email@one.com"
+                  />
+                </iron-input>
+              </td>
+              <td>
+                <gr-button
+                  aria-disabled="false"
+                  class="remove-button"
+                  data-index="0"
+                  role="button"
+                  tabindex="0"
+                >
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+            <tr>
+              <td class="emailColumn">email@two.com</td>
+              <td class="preferredControl">
+                <iron-input class="preferredRadio">
+                  <input
+                    checked=""
+                    class="preferredRadio"
+                    name="preferred"
+                    type="radio"
+                    value="email@two.com"
+                  />
+                </iron-input>
+              </td>
+              <td>
+                <gr-button
+                  aria-disabled="true"
+                  class="remove-button"
+                  data-index="1"
+                  disabled=""
+                  role="button"
+                  tabindex="-1"
+                >
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+            <tr>
+              <td class="emailColumn">email@three.com</td>
+              <td class="preferredControl">
+                <iron-input class="preferredRadio">
+                  <input
+                    class="preferredRadio"
+                    name="preferred"
+                    type="radio"
+                    value="email@three.com"
+                  />
+                </iron-input>
+              </td>
+              <td>
+                <gr-button
+                  aria-disabled="false"
+                  class="remove-button"
+                  data-index="2"
+                  role="button"
+                  tabindex="0"
+                >
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>`
+    );
   });
 
   test('renders', () => {
     const rows = element
       .shadowRoot!.querySelector('table')!
-      .querySelectorAll('tbody tr') as NodeListOf<HTMLTableRowElement>;
+      .querySelectorAll('tbody tr');
 
     assert.equal(rows.length, 3);
 
@@ -66,28 +148,27 @@
   });
 
   test('edit preferred', () => {
-    const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
     const radios = element
       .shadowRoot!.querySelector('table')!
-      .querySelectorAll('input[type=radio]') as NodeListOf<HTMLInputElement>;
+      .querySelectorAll<HTMLInputElement>('input[type=radio]');
 
     assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
     assert.isNotOk(radios[0].checked);
     assert.isOk(radios[1].checked);
-    assert.isFalse(preferredChangedSpy.called);
+    assert.isUndefined(element.emails[0].preferred);
 
     radios[0].click();
 
     assert.isTrue(element.hasUnsavedChanges);
-    assert.isOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
     assert.isOk(radios[0].checked);
     assert.isNotOk(radios[1].checked);
-    assert.isTrue(preferredChangedSpy.called);
+    assert.isTrue(element.emails[0].preferred);
   });
 
   test('delete email', () => {
@@ -96,18 +177,18 @@
       .querySelectorAll('gr-button');
 
     assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
 
     buttons[2].click();
 
     assert.isTrue(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 1);
-    assert.equal(element._emails.length, 2);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 1);
+    assert.equal(element.emails.length, 2);
 
-    assert.equal(element._emailsToRemove[0].email, 'email@three.com');
+    assert.equal(element.emailsToRemove[0].email, 'email@three.com');
   });
 
   test('save changes', async () => {
@@ -119,19 +200,19 @@
       .querySelectorAll('tbody tr');
 
     assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
 
     // Delete the first email and set the last as preferred.
     rows[0].querySelector('gr-button')!.click();
-    (rows[2].querySelector('input[type=radio]')! as HTMLInputElement).click();
+    rows[2].querySelector<HTMLInputElement>('input[type=radio]')!.click();
 
     assert.isTrue(element.hasUnsavedChanges);
-    assert.equal(element._newPreferred, 'email@three.com');
-    assert.equal(element._emailsToRemove.length, 1);
-    assert.equal(element._emailsToRemove[0].email, 'email@one.com');
-    assert.equal(element._emails.length, 2);
+    assert.equal(element.newPreferred, 'email@three.com');
+    assert.equal(element.emailsToRemove.length, 1);
+    assert.equal(element.emailsToRemove[0].email, 'email@one.com');
+    assert.equal(element.emails.length, 2);
 
     await element.save();
     assert.equal(deleteEmailSpy.callCount, 1);
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 afbd67e..32b32e2 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
@@ -1,42 +1,23 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-form-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-overlay/gr-overlay';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-gpg-editor_html';
-import {customElement, property} from '@polymer/decorators';
 import {GpgKeyInfo, GpgKeyId} from '../../../types/common';
 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 {appContext} from '../../../services/app-context';
-
-export interface GrGpgEditor {
-  $: {
-    viewKeyOverlay: GrOverlay;
-    addButton: GrButton;
-    newKey: IronAutogrowTextareaElement;
-  };
-}
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
+import {getAppContext} from '../../../services/app-context';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -44,35 +25,161 @@
   }
 }
 @customElement('gr-gpg-editor')
-export class GrGpgEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrGpgEditor extends LitElement {
+  @query('#viewKeyModal') viewKeyModal?: HTMLDialogElement;
 
-  @property({type: Boolean, notify: true})
+  @query('#addButton') addButton?: GrButton;
+
+  @query('#newKey') newKeyTextarea?: IronAutogrowTextareaElement;
+
+  @property({type: Boolean})
   hasUnsavedChanges = false;
 
-  @property({type: Array})
-  _keys: GpgKeyInfo[] = [];
+  // private but used in test
+  @state() keys: GpgKeyInfo[] = [];
 
-  @property({type: Object})
-  _keyToView?: GpgKeyInfo;
+  // private but used in test
+  @state() keyToView?: GpgKeyInfo;
 
-  @property({type: String})
-  _newKey = '';
+  // private but used in test
+  @state() newKey = '';
 
-  @property({type: Array})
-  _keysToRemove: GpgKeyInfo[] = [];
+  // private but used in test
+  @state() keysToRemove: GpgKeyInfo[] = [];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
+  static override styles = [
+    formStyles,
+    sharedStyles,
+    modalStyles,
+    css`
+      .keyHeader {
+        width: 9em;
+      }
+      .userIdHeader {
+        width: 15em;
+      }
+      #viewKeyModal {
+        padding: var(--spacing-xxl);
+        width: 50em;
+      }
+      .closeButton {
+        bottom: 2em;
+        position: absolute;
+        right: 2em;
+      }
+      #existing {
+        margin-bottom: var(--spacing-l);
+      }
+      iron-autogrow-textarea {
+        background-color: var(--view-background-color);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <fieldset id="existing">
+          <table>
+            <thead>
+              <tr>
+                <th class="idColumn">ID</th>
+                <th class="fingerPrintColumn">Fingerprint</th>
+                <th class="userIdHeader">User IDs</th>
+                <th class="keyHeader">Public Key</th>
+                <th></th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.keys.map((key, index) => this.renderKey(key, index))}
+            </tbody>
+          </table>
+          <dialog id="viewKeyModal" tabindex="-1">
+            <fieldset>
+              <section>
+                <span class="title">Status</span>
+                <span class="value">${this.keyToView?.status}</span>
+              </section>
+              <section>
+                <span class="title">Key</span>
+                <span class="value">${this.keyToView?.key}</span>
+              </section>
+            </fieldset>
+            <gr-button
+              class="closeButton"
+              @click=${() => {
+                this.viewKeyModal?.close();
+              }}
+              >Close</gr-button
+            >
+          </dialog>
+          <gr-button @click=${this.save} ?disabled=${!this.hasUnsavedChanges}
+            >Save changes</gr-button
+          >
+        </fieldset>
+        <fieldset>
+          <section>
+            <span class="title">New GPG key</span>
+            <span class="value">
+              <iron-autogrow-textarea
+                id="newKey"
+                autocomplete="on"
+                .bindValue=${this.newKey}
+                @bind-value-changed=${(e: BindValueChangeEvent) =>
+                  this.handleNewKeyChanged(e)}
+                placeholder="New GPG Key"
+              ></iron-autogrow-textarea>
+            </span>
+          </section>
+          <gr-button
+            id="addButton"
+            ?disabled=${!this.newKey?.length}
+            @click=${this.handleAddKey}
+            >Add new GPG key</gr-button
+          >
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderKey(key: GpgKeyInfo, index: number) {
+    return html`<tr>
+      <td class="idColumn">${key.id}</td>
+      <td class="fingerPrintColumn">${key.fingerprint}</td>
+      <td class="userIdHeader">${key.user_ids?.map(id => html`${id}`)}</td>
+      <td class="keyHeader">
+        <gr-button @click=${() => this.showKey(key)} link=""
+          >Click to View</gr-button
+        >
+      </td>
+      <td>
+        <gr-copy-clipboard
+          hasTooltip
+          buttonTitle="Copy GPG public key to clipboard"
+          hideInput
+          .text=${key.key}
+        >
+        </gr-copy-clipboard>
+      </td>
+      <td>
+        <gr-button @click=${() => this.handleDeleteKey(index)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
+  // private but used in test
   loadData() {
-    this._keys = [];
+    this.keys = [];
     return this.restApiService.getAccountGPGKeys().then(keys => {
       if (!keys) {
         return;
       }
-      this._keys = Object.keys(keys).map(key => {
+      this.keys = Object.keys(keys).map(key => {
         const gpgKey = keys[key];
         gpgKey.id = key as GpgKeyId;
         return gpgKey;
@@ -80,53 +187,58 @@
     });
   }
 
+  // private but used in test
   save() {
-    const promises = this._keysToRemove.map(key =>
+    const promises = this.keysToRemove.map(key =>
       this.restApiService.deleteAccountGPGKey(key.id!)
     );
 
     return Promise.all(promises).then(() => {
-      this._keysToRemove = [];
-      this.hasUnsavedChanges = false;
+      this.keysToRemove = [];
+      this.setHasUnsavedChanges(false);
     });
   }
 
-  _showKey(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as Element;
-    const index = Number(el.getAttribute('data-index')!);
-    this._keyToView = this._keys[index];
-    this.$.viewKeyOverlay.open();
+  private showKey(key: GpgKeyInfo) {
+    this.keyToView = key;
+    this.viewKeyModal?.showModal();
   }
 
-  _closeOverlay() {
-    this.$.viewKeyOverlay.close();
+  private handleNewKeyChanged(e: BindValueChangeEvent) {
+    this.newKey = e.detail.value ?? '';
   }
 
-  _handleDeleteKey(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as Element;
-    const index = Number(el.getAttribute('data-index')!);
-    this.push('_keysToRemove', this._keys[index]);
-    this.splice('_keys', index, 1);
-    this.hasUnsavedChanges = true;
+  private handleDeleteKey(index: number) {
+    this.keysToRemove.push(this.keys[index]);
+    this.keys.splice(index, 1);
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _handleAddKey() {
-    this.$.addButton.disabled = true;
-    this.$.newKey.disabled = true;
+  // private but used in test
+  handleAddKey() {
+    assertIsDefined(this.newKeyTextarea);
+    assertIsDefined(this.addButton);
+    this.addButton.disabled = true;
+    this.newKeyTextarea.disabled = true;
     return this.restApiService
-      .addAccountGPGKey({add: [this._newKey.trim()]})
+      .addAccountGPGKey({add: [this.newKey.trim()]})
       .then(() => {
-        this.$.newKey.disabled = false;
-        this._newKey = '';
+        assertIsDefined(this.newKeyTextarea);
+        this.newKeyTextarea.disabled = false;
+        this.newKey = '';
         this.loadData();
       })
       .catch(() => {
-        this.$.addButton.disabled = false;
-        this.$.newKey.disabled = false;
+        assertIsDefined(this.newKeyTextarea);
+        assertIsDefined(this.addButton);
+        this.addButton.disabled = false;
+        this.newKeyTextarea.disabled = false;
       });
   }
 
-  _computeAddButtonDisabled(newKey: string) {
-    return !newKey.length;
+  private setHasUnsavedChanges(value: boolean) {
+    this.hasUnsavedChanges = value;
+    fire(this, 'has-unsaved-changes-changed', {value});
   }
 }
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
deleted file mode 100644
index f4641c2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .keyHeader {
-      width: 9em;
-    }
-    .userIdHeader {
-      width: 15em;
-    }
-    #viewKeyOverlay {
-      padding: var(--spacing-xxl);
-      width: 50em;
-    }
-    .closeButton {
-      bottom: 2em;
-      position: absolute;
-      right: 2em;
-    }
-    #existing {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset id="existing">
-      <table>
-        <thead>
-          <tr>
-            <th class="idColumn">ID</th>
-            <th class="fingerPrintColumn">Fingerprint</th>
-            <th class="userIdHeader">User IDs</th>
-            <th class="keyHeader">Public Key</th>
-            <th></th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_keys]]" as="key">
-            <tr>
-              <td class="idColumn">[[key.id]]</td>
-              <td class="fingerPrintColumn">[[key.fingerprint]]</td>
-              <td class="userIdHeader">
-                <template is="dom-repeat" items="[[key.user_ids]]">
-                  [[item]]
-                </template>
-              </td>
-              <td class="keyHeader">
-                <gr-button on-click="_showKey" data-index$="[[index]]" link=""
-                  >Click to View</gr-button
-                >
-              </td>
-              <td>
-                <gr-copy-clipboard
-                  hasTooltip=""
-                  buttonTitle="Copy GPG public key to clipboard"
-                  hideInput=""
-                  text="[[key.key]]"
-                >
-                </gr-copy-clipboard>
-              </td>
-              <td>
-                <gr-button data-index$="[[index]]" on-click="_handleDeleteKey"
-                  >Delete</gr-button
-                >
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <gr-overlay id="viewKeyOverlay" with-backdrop="">
-        <fieldset>
-          <section>
-            <span class="title">Status</span>
-            <span class="value">[[_keyToView.status]]</span>
-          </section>
-          <section>
-            <span class="title">Key</span>
-            <span class="value">[[_keyToView.key]]</span>
-          </section>
-        </fieldset>
-        <gr-button class="closeButton" on-click="_closeOverlay"
-          >Close</gr-button
-        >
-      </gr-overlay>
-      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
-        >Save changes</gr-button
-      >
-    </fieldset>
-    <fieldset>
-      <section>
-        <span class="title">New GPG key</span>
-        <span class="value">
-          <iron-autogrow-textarea
-            id="newKey"
-            autocomplete="on"
-            bind-value="{{_newKey}}"
-            placeholder="New GPG Key"
-          ></iron-autogrow-textarea>
-        </span>
-      </section>
-      <gr-button
-        id="addButton"
-        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-        on-click="_handleAddKey"
-        >Add new GPG key</gr-button
-      >
-    </fieldset>
-  </div>
-`;
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
deleted file mode 100644
index ba736a2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
+++ /dev/null
@@ -1,184 +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-gpg-editor.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-gpg-editor');
-
-suite('gr-gpg-editor tests', () => {
-  let element;
-  let keys;
-
-  setup(async () => {
-    const fingerprint1 = '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
-    const fingerprint2 = '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
-    keys = {
-      AFC8A49B: {
-        fingerprint: fingerprint1,
-        user_ids: [
-          'John Doe john.doe@example.com',
-        ],
-        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-             '\nVersion: BCPG v1.52\n\t<key 1>',
-        status: 'TRUSTED',
-        problems: [],
-      },
-      AED9B59C: {
-        fingerprint: fingerprint2,
-        user_ids: [
-          'Gerrit gerrit@example.com',
-        ],
-        key: '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-             '\nVersion: BCPG v1.52\n\t<key 2>',
-        status: 'TRUSTED',
-        problems: [],
-      },
-    };
-
-    stubRestApi('getAccountGPGKeys').returns(Promise.resolve(keys));
-
-    element = basicFixture.instantiate();
-
-    await element.loadData();
-    await flush();
-  });
-
-  test('renders', () => {
-    const rows = element.root.querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 2);
-
-    let cells = rows[0].querySelectorAll('td');
-    assert.equal(cells[0].textContent, 'AFC8A49B');
-
-    cells = rows[1].querySelectorAll('td');
-    assert.equal(cells[0].textContent, 'AED9B59C');
-  });
-
-  test('remove key', async () => {
-    const lastKey = keys[Object.keys(keys)[1]];
-
-    const saveStub = stubRestApi('deleteAccountGPGKey')
-        .callsFake(() => Promise.resolve());
-
-    assert.equal(element._keysToRemove.length, 0);
-    assert.isFalse(element.hasUnsavedChanges);
-
-    // Get the delete button for the last row.
-    const button = element.root.querySelector(
-        'tbody tr:last-of-type td:nth-child(6) gr-button');
-
-    MockInteractions.tap(button);
-
-    assert.equal(element._keys.length, 1);
-    assert.equal(element._keysToRemove.length, 1);
-    assert.equal(element._keysToRemove[0], lastKey);
-    assert.isTrue(element.hasUnsavedChanges);
-    assert.isFalse(saveStub.called);
-
-    await element.save();
-    assert.isTrue(saveStub.called);
-    assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
-    assert.equal(element._keysToRemove.length, 0);
-    assert.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('show key', () => {
-    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
-
-    // Get the show button for the last row.
-    const button = element.root.querySelector(
-        'tbody tr:last-of-type td:nth-child(4) gr-button');
-
-    MockInteractions.tap(button);
-
-    assert.equal(element._keyToView, keys[Object.keys(keys)[1]]);
-    assert.isTrue(openSpy.called);
-  });
-
-  test('add key', async () => {
-    const newKeyString =
-        '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-        '\nVersion: BCPG v1.52\n\t<key 3>';
-    const newKeyObject = {
-      ADE8A59B: {
-        fingerprint: '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B',
-        user_ids: [
-          'John john@example.com',
-        ],
-        key: newKeyString,
-        status: 'TRUSTED',
-        problems: [],
-      },
-    };
-
-    const addStub = stubRestApi(
-        'addAccountGPGKey').callsFake(
-        () => Promise.resolve(newKeyObject));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
-      promise.resolve();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
-    await promise;
-  });
-
-  test('add invalid key', async () => {
-    const newKeyString = 'not even close to valid';
-
-    const addStub = stubRestApi(
-        'addAccountGPGKey').callsFake(
-        () => Promise.reject(new Error('error')));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
-      promise.resolve();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
-    await promise;
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
new file mode 100644
index 0000000..5be5b29
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
@@ -0,0 +1,320 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-gpg-editor';
+import {
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrGpgEditor} from './gr-gpg-editor';
+import {
+  GpgKeyFingerprint,
+  GpgKeyInfo,
+  GpgKeyInfoStatus,
+  OpenPgpUserIds,
+} from '../../../api/rest-api';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-gpg-editor tests', () => {
+  let element: GrGpgEditor;
+  let keys: Record<string, GpgKeyInfo>;
+
+  setup(async () => {
+    const fingerprint1 =
+      '0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint;
+    const fingerprint2 =
+      '0196 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint;
+    keys = {
+      AFC8A49B: {
+        fingerprint: fingerprint1,
+        user_ids: ['John Doe john.doe@example.com'] as OpenPgpUserIds[],
+        key:
+          '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+          '\nVersion: BCPG v1.52\n\t<key 1>',
+        status: 'TRUSTED' as GpgKeyInfoStatus,
+        problems: [],
+      },
+      AED9B59C: {
+        fingerprint: fingerprint2,
+        user_ids: ['Gerrit gerrit@example.com'] as OpenPgpUserIds[],
+        key:
+          '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
+          '\nVersion: BCPG v1.52\n\t<key 2>',
+        status: 'TRUSTED' as GpgKeyInfoStatus,
+        problems: [],
+      },
+    };
+
+    stubRestApi('getAccountGPGKeys').returns(Promise.resolve(keys));
+
+    element = await fixture(html`<gr-gpg-editor></gr-gpg-editor>`);
+
+    await element.loadData();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="gr-form-styles">
+        <fieldset id="existing">
+          <table>
+            <thead>
+              <tr>
+                <th class="idColumn">ID</th>
+                <th class="fingerPrintColumn">Fingerprint</th>
+                <th class="userIdHeader">User IDs</th>
+                <th class="keyHeader">Public Key</th>
+                <th></th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="idColumn">AFC8A49B</td>
+                <td class="fingerPrintColumn">
+                  0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B
+                </td>
+                <td class="userIdHeader">John Doe john.doe@example.com</td>
+                <td class="keyHeader">
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Click to View
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-copy-clipboard
+                    buttontitle="Copy GPG public key to clipboard"
+                    hastooltip=""
+                    hideinput=""
+                  >
+                  </gr-copy-clipboard>
+                </td>
+                <td>
+                  <gr-button aria-disabled="false" role="button" tabindex="0">
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td class="idColumn">AED9B59C</td>
+                <td class="fingerPrintColumn">
+                  0196 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B
+                </td>
+                <td class="userIdHeader">Gerrit gerrit@example.com</td>
+                <td class="keyHeader">
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Click to View
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-copy-clipboard
+                    buttontitle="Copy GPG public key to clipboard"
+                    hastooltip=""
+                    hideinput=""
+                  >
+                  </gr-copy-clipboard>
+                </td>
+                <td>
+                  <gr-button aria-disabled="false" role="button" tabindex="0">
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+          <dialog id="viewKeyModal" tabindex="-1">
+            <fieldset>
+              <section>
+                <span class="title"> Status </span> <span class="value"> </span>
+              </section>
+              <section>
+                <span class="title"> Key </span> <span class="value"> </span>
+              </section>
+            </fieldset>
+            <gr-button
+              aria-disabled="false"
+              class="closeButton"
+              role="button"
+              tabindex="0"
+            >
+              Close
+            </gr-button>
+          </dialog>
+          <gr-button
+            aria-disabled="true"
+            disabled=""
+            role="button"
+            tabindex="-1"
+          >
+            Save changes
+          </gr-button>
+        </fieldset>
+        <fieldset>
+          <section>
+            <span class="title"> New GPG key </span>
+            <span class="value">
+              <iron-autogrow-textarea
+                aria-disabled="false"
+                autocomplete="on"
+                id="newKey"
+                placeholder="New GPG Key"
+              >
+              </iron-autogrow-textarea>
+            </span>
+          </section>
+          <gr-button
+            aria-disabled="true"
+            disabled=""
+            id="addButton"
+            role="button"
+            tabindex="-1"
+          >
+            Add new GPG key
+          </gr-button>
+        </fieldset>
+      </div> `
+    );
+  });
+
+  test('renders', () => {
+    const rows = queryAll(element, 'tbody tr');
+
+    assert.equal(rows.length, 2);
+
+    let cells = rows[0].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AFC8A49B');
+
+    cells = rows[1].querySelectorAll('td');
+    assert.equal(cells[0].textContent, 'AED9B59C');
+  });
+
+  test('remove key', async () => {
+    const lastKey = keys[Object.keys(keys)[1]];
+
+    const saveStub = stubRestApi('deleteAccountGPGKey').callsFake(() =>
+      Promise.resolve(new Response())
+    );
+
+    assert.equal(element.keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = queryAndAssert<GrButton>(
+      element,
+      'tbody tr:last-of-type td:nth-child(6) gr-button'
+    );
+
+    button.click();
+
+    assert.equal(element.keys.length, 1);
+    assert.equal(element.keysToRemove.length, 1);
+    assert.equal(element.keysToRemove[0], lastKey);
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isFalse(saveStub.called);
+
+    await element.save();
+    assert.isTrue(saveStub.called);
+    assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
+    assert.equal(element.keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('show key', () => {
+    const openSpy = sinon.spy(element.viewKeyModal!, 'showModal');
+
+    // Get the show button for the last row.
+    const button = queryAndAssert<GrButton>(
+      element,
+      'tbody tr:last-of-type td:nth-child(4) gr-button'
+    );
+
+    button.click();
+    assert.equal(element.keyToView, keys[Object.keys(keys)[1]]);
+    assert.isTrue(openSpy.called);
+  });
+
+  test('add key', async () => {
+    const newKeyString =
+      '-----BEGIN PGP PUBLIC KEY BLOCK-----' + ' Version: BCPG v1.52 \t<key 3>';
+    const newKeyObject = {
+      ADE8A59B: {
+        fingerprint:
+          '0194 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint,
+        user_ids: ['John john@example.com'] as OpenPgpUserIds[],
+        key: newKeyString,
+        status: 'TRUSTED' as GpgKeyInfoStatus,
+        problems: [],
+      },
+    };
+
+    const addStub = stubRestApi('addAccountGPGKey').callsFake(() =>
+      Promise.resolve(newKeyObject)
+    );
+
+    element.newKey = newKeyString;
+    await element.updateComplete;
+
+    assert.isFalse(element.addButton!.disabled);
+    assert.isFalse(element.newKeyTextarea!.disabled);
+
+    const promise = mockPromise();
+    element.handleAddKey().then(() => {
+      assert.isTrue(element.addButton!.disabled);
+      assert.isFalse(element.newKeyTextarea!.disabled);
+      assert.equal(element.keys.length, 2);
+      promise.resolve();
+    });
+
+    assert.isTrue(element.addButton!.disabled);
+    assert.isTrue(element.newKeyTextarea!.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+    await promise;
+  });
+
+  test('add invalid key', async () => {
+    const newKeyString = 'not even close to valid';
+
+    const addStub = stubRestApi('addAccountGPGKey').callsFake(() =>
+      Promise.reject(new Error('error'))
+    );
+
+    element.newKey = newKeyString;
+    await element.updateComplete;
+
+    assert.isFalse(element.addButton!.disabled);
+    assert.isFalse(element.newKeyTextarea!.disabled);
+
+    const promise = mockPromise();
+    element.handleAddKey().then(() => {
+      assert.isFalse(element.addButton!.disabled);
+      assert.isFalse(element.newKeyTextarea!.disabled);
+      assert.equal(element.keys.length, 2);
+      promise.resolve();
+    });
+
+    assert.isTrue(element.addButton!.disabled);
+    assert.isTrue(element.newKeyTextarea!.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+    await promise;
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
index 8f1706d..68a2293 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
@@ -1,27 +1,15 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GroupInfo, GroupId} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, state} from 'lit/decorators';
+import {customElement, state} from 'lit/decorators.js';
+import {createGroupUrl} from '../../../models/views/group';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -33,7 +21,7 @@
   @state()
   protected _groups: GroupInfo[] = [];
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   loadData() {
     return this.restApiService.getAccountGroups().then(groups => {
@@ -76,11 +64,11 @@
         </thead>
         <tbody>
           ${(this._groups ?? []).map(group => {
-            const href = this._computeGroupPath(group);
+            const href = this._computeGroupPath(group) ?? '';
             return html`
               <tr>
                 <td class="nameColumn">
-                  <a href="${href}"> ${group.name} </a>
+                  <a href=${href}> ${group.name} </a>
                 </td>
                 <td>${group.description}</td>
                 <td class="visibleCell">
@@ -94,13 +82,12 @@
     </div>`;
   }
 
-  _computeGroupPath(group: GroupInfo) {
-    if (!group || !group.id) {
-      return;
-    }
+  _computeGroupPath(group?: GroupInfo) {
+    if (!group?.id) return;
 
     // Group ID is already encoded from the API
     // Decode it here to match with our router encoding behavior
-    return GerritNav.getUrlForGroup(decodeURIComponent(group.id) as GroupId);
+    const decodedGroupId = decodeURIComponent(group.id) as GroupId;
+    return createGroupUrl({groupId: decodedGroupId});
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts
index a1534af..08ce11c 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts
@@ -1,28 +1,14 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-group-list';
 import {GrGroupList} from './gr-group-list';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {queryAll, stubRestApi} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-group-list');
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-group-list tests', () => {
   let element: GrGroupList;
@@ -56,52 +42,51 @@
 
     stubRestApi('getAccountGroups').returns(Promise.resolve(groups));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-group-list></gr-group-list>`);
 
     await element.loadData();
-    await flush();
+    await waitEventLoop();
   });
 
-  test('renders', async () => {
-    await flush();
-
-    const rows = Array.from(queryAll(element, 'tbody tr'));
-
-    assert.equal(rows.length, 3);
-
-    const nameCells = rows.map(row =>
-      queryAll(row, 'td a')[0].textContent!.trim()
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <table id="groups">
+            <thead>
+              <tr>
+                <th class="nameHeader">Name</th>
+                <th class="descriptionHeader">Description</th>
+                <th class="visibleCell">Visible to all</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="nameColumn">
+                  <a href="/admin/groups/abc"> Group 1 </a>
+                </td>
+                <td>Group 1 description</td>
+                <td class="visibleCell">No</td>
+              </tr>
+              <tr>
+                <td class="nameColumn">
+                  <a href="/admin/groups/456"> Group 2 </a>
+                </td>
+                <td></td>
+                <td class="visibleCell">Yes</td>
+              </tr>
+              <tr>
+                <td class="nameColumn">
+                  <a href="/admin/groups/789"> Group 3 </a>
+                </td>
+                <td></td>
+                <td class="visibleCell">No</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      `
     );
-
-    assert.equal(nameCells[0], 'Group 1');
-    assert.equal(nameCells[1], 'Group 2');
-    assert.equal(nameCells[2], 'Group 3');
-  });
-
-  test('_computeGroupPath', () => {
-    let urlStub = sinon
-      .stub(GerritNav, 'getUrlForGroup')
-      .callsFake(
-        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
-      );
-
-    let group = {
-      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5' as GroupId,
-    };
-    assert.equal(
-      element._computeGroupPath(group),
-      '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
-    );
-
-    urlStub.restore();
-
-    urlStub = sinon
-      .stub(GerritNav, 'getUrlForGroup')
-      .callsFake(() => '/admin/groups/user/test');
-
-    group = {
-      id: 'user%2Ftest' as GroupId,
-    };
-    assert.equal(element._computeGroupPath(group), '/admin/groups/user/test');
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index 59f6a39..16e262b 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
@@ -1,28 +1,16 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-overlay/gr-overlay';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -32,8 +20,8 @@
 
 @customElement('gr-http-password')
 export class GrHttpPassword extends LitElement {
-  @query('#generatedPasswordOverlay')
-  generatedPasswordOverlay?: GrOverlay;
+  @query('#generatedPasswordModal')
+  generatedPasswordModal?: HTMLDialogElement;
 
   @property({type: String})
   _username?: string;
@@ -44,7 +32,7 @@
   @property({type: String})
   _passwordUrl: string | null = null;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   override connectedCallback() {
     super.connectedCallback();
@@ -79,13 +67,14 @@
     return [
       sharedStyles,
       formStyles,
+      modalStyles,
       css`
         .password {
           font-family: var(--monospace-font-family);
           font-size: var(--font-size-mono);
           line-height: var(--line-height-mono);
         }
-        #generatedPasswordOverlay {
+        #generatedPasswordModal {
           padding: var(--spacing-xxl);
           width: 50em;
         }
@@ -125,16 +114,16 @@
           >
         </div>
         <span ?hidden=${!this._passwordUrl}>
-          <a href="${this._passwordUrl!}" target="_blank" rel="noopener">
+          <a href=${this._passwordUrl!} target="_blank" rel="noopener">
             Obtain password</a
           >
           (opens in a new tab)
         </span>
       </div>
-      <gr-overlay
-        id="generatedPasswordOverlay"
-        @iron-overlay-closed=${this._generatedPasswordOverlayClosed}
-        with-backdrop
+      <dialog
+        tabindex="-1"
+        id="generatedPasswordModal"
+        @closed=${this._generatedPasswordModalClosed}
       >
         <div class="gr-form-styles">
           <section id="generatedPasswordDisplay">
@@ -144,7 +133,7 @@
               hasTooltip=""
               buttonTitle="Copy password to clipboard"
               hideInput=""
-              .text="${this._generatedPassword}"
+              .text=${this._generatedPassword}
             >
             </gr-copy-clipboard>
           </section>
@@ -152,26 +141,26 @@
             This password will not be displayed again.<br />
             If you lose it, you will need to generate a new one.
           </section>
-          <gr-button link="" class="closeButton" @click=${this._closeOverlay}
+          <gr-button link="" class="closeButton" @click=${this._closeModal}
             >Close</gr-button
           >
         </div>
-      </gr-overlay>`;
+      </dialog>`;
   }
 
   _handleGenerateTap() {
     this._generatedPassword = 'Generating...';
-    this.generatedPasswordOverlay?.open();
+    this.generatedPasswordModal?.showModal();
     this.restApiService.generateAccountHttpPassword().then(newPassword => {
       this._generatedPassword = newPassword;
     });
   }
 
-  _closeOverlay() {
-    this.generatedPasswordOverlay?.close();
+  _closeModal() {
+    this.generatedPasswordModal?.close();
   }
 
-  _generatedPasswordOverlayClosed() {
+  _generatedPasswordModalClosed() {
     this._generatedPassword = '';
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
index eab8d2e..a582044 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
@@ -1,25 +1,12 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-http-password';
 import {GrHttpPassword} from './gr-http-password';
-import {stubRestApi} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {stubRestApi, waitEventLoop} from '../../../test/test-utils';
 import {
   createAccountDetailWithId,
   createServerInfo,
@@ -27,8 +14,7 @@
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../../shared/gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-http-password');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-http-password tests', () => {
   let element: GrHttpPassword;
@@ -42,9 +28,65 @@
     stubRestApi('getAccount').returns(Promise.resolve(account));
     stubRestApi('getConfig').returns(Promise.resolve(config));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-http-password></gr-http-password>`);
     await element.loadData();
-    await flush();
+    await waitEventLoop();
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <div>
+            <section>
+              <span class="title"> Username </span>
+              <span class="value"> user name </span>
+            </section>
+            <gr-button
+              aria-disabled="false"
+              id="generateButton"
+              role="button"
+              tabindex="0"
+            >
+              Generate new password
+            </gr-button>
+          </div>
+          <span hidden="">
+            <a href="" rel="noopener" target="_blank"> Obtain password </a>
+            (opens in a new tab)
+          </span>
+        </div>
+        <dialog tabindex="-1" id="generatedPasswordModal">
+          <div class="gr-form-styles">
+            <section id="generatedPasswordDisplay">
+              <span class="title"> New Password: </span>
+              <span class="value"> </span>
+              <gr-copy-clipboard
+                buttontitle="Copy password to clipboard"
+                hastooltip=""
+                hideinput=""
+              >
+              </gr-copy-clipboard>
+            </section>
+            <section id="passwordWarning">
+              This password will not be displayed again.
+              <br />
+              If you lose it, you will need to generate a new one.
+            </section>
+            <gr-button
+              aria-disabled="false"
+              class="closeButton"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Close
+            </gr-button>
+          </div>
+        </dialog>
+      `
+    );
   });
 
   test('generate password', () => {
@@ -60,7 +102,7 @@
 
     assert.isNotOk(element._generatedPassword);
 
-    MockInteractions.tap(button);
+    button.click();
 
     assert.isTrue(generateStub.called);
     assert.equal(element._generatedPassword, 'Generating...');
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 d65304c..7f67ea8 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -1,117 +1,187 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
 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 {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 {AccountExternalIdInfo, ServerInfo} from '../../../types/common';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {PolymerDomRepeatEvent} from '../../../types/types';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
+import {AuthType} from '../../../constants/constants';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {classMap} from 'lit/directives/class-map.js';
+import {when} from 'lit/directives/when.js';
+import {assertIsDefined} from '../../../utils/common-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
-const AUTH = ['OPENID', 'OAUTH'];
-
-export interface GrIdentities {
-  $: {
-    overlay: GrOverlay;
-  };
-}
+const AUTH = [AuthType.OPENID, AuthType.OAUTH];
 
 @customElement('gr-identities')
-export class GrIdentities extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrIdentities extends LitElement {
+  @query('#modal') modal?: HTMLDialogElement;
+
+  @state() private identities: AccountExternalIdInfo[] = [];
+
+  // temporary var for communicating with the confirmation dialog
+  // private but used in test
+  @state() idName?: string;
+
+  @property({type: Object}) serverConfig?: ServerInfo;
+
+  @state() showLinkAnotherIdentity = false;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    modalStyles,
+    css`
+      tr th.emailAddressHeader,
+      tr th.identityHeader {
+        width: 15em;
+        padding: 0 10px;
+      }
+      tr td.statusColumn,
+      tr td.emailAddressColumn,
+      tr td.identityColumn {
+        word-break: break-word;
+      }
+      tr td.emailAddressColumn,
+      tr td.identityColumn {
+        padding: 4px 10px;
+        width: 15em;
+      }
+      .deleteButton {
+        float: right;
+      }
+      .deleteButton:not(.show) {
+        display: none;
+      }
+      .space {
+        margin-bottom: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="gr-form-styles">
+        <fieldset class="space">
+          <table>
+            <thead>
+              <tr>
+                <th class="statusHeader">Status</th>
+                <th class="emailAddressHeader">Email Address</th>
+                <th class="identityHeader">Identity</th>
+                <th class="deleteHeader"></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.getIdentities().map((account, index) =>
+                this.renderIdentity(account, index)
+              )}
+            </tbody>
+          </table>
+        </fieldset>
+        ${when(
+          this.showLinkAnotherIdentity,
+          () => html`<fieldset>
+            <a href=${this.computeLinkAnotherIdentity()}>
+              <gr-button id="linkAnotherIdentity" link=""
+                >Link Another Identity</gr-button
+              >
+            </a>
+          </fieldset>`
+        )}
+      </div>
+      <dialog id="modal" tabindex="-1">
+        <gr-confirm-delete-item-dialog
+          class="confirmDialog"
+          @confirm=${this.handleDeleteItemConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .item=${this.idName}
+          itemtypename="ID"
+        ></gr-confirm-delete-item-dialog>
+      </dialog>`;
   }
 
-  @property({type: Array})
-  _identities: AccountExternalIdInfo[] = [];
+  private renderIdentity(account: AccountExternalIdInfo, index: number) {
+    return html`<tr>
+      <td class="statusColumn">${account.trusted ? '' : 'Untrusted'}</td>
+      <td class="emailAddressColumn">${account.email_address}</td>
+      <td class="identityColumn">
+        ${account.identity.startsWith('mailto:') ? '' : account.identity}
+      </td>
+      <td class="deleteColumn">
+        <gr-button
+          data-index=${index}
+          class=${classMap({
+            deleteButton: true,
+            show: !!account.can_delete,
+          })}
+          @click=${() => this.handleDeleteItem(account.identity)}
+        >
+          Delete
+        </gr-button>
+      </td>
+    </tr>`;
+  }
 
-  @property({type: String})
-  _idName?: string;
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.showLinkAnotherIdentity = this.computeShowLinkAnotherIdentity();
+    }
+  }
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
-  @property({
-    type: Boolean,
-    computed: '_computeShowLinkAnotherIdentity(serverConfig)',
-  })
-  _showLinkAnotherIdentity?: boolean;
-
-  private readonly restApiService = appContext.restApiService;
+  // private but used in test
+  getIdentities() {
+    return this.identities.filter(
+      account => !account.identity.startsWith('username:')
+    );
+  }
 
   loadData() {
-    return this.restApiService.getExternalIds().then(id => {
-      this._identities = id ?? [];
+    return this.restApiService.getExternalIds().then(ids => {
+      this.identities = ids ?? [];
     });
   }
 
-  _computeIdentity(id: string) {
-    return id && id.startsWith('mailto:') ? '' : id;
-  }
-
+  // private but used in test
   _computeHideDeleteClass(canDelete?: boolean) {
     return canDelete ? 'show' : '';
   }
 
-  _handleDeleteItemConfirm() {
-    this.$.overlay.close();
-    return this.restApiService
-      .deleteAccountIdentity([this._idName!])
-      .then(() => {
-        this.loadData();
-      });
+  handleDeleteItemConfirm() {
+    this.modal?.close();
+    assertIsDefined(this.idName);
+    return this.restApiService.deleteAccountIdentity([this.idName]).then(() => {
+      this.loadData();
+    });
   }
 
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
+  private handleConfirmDialogCancel() {
+    this.modal?.close();
   }
 
-  _handleDeleteItem(e: PolymerDomRepeatEvent<AccountExternalIdInfo>) {
-    const name = e.model.item.identity;
-    if (!name) {
-      return;
-    }
-    this._idName = name;
-    this.$.overlay.open();
+  private handleDeleteItem(name: string) {
+    this.idName = name;
+    this.modal?.showModal();
   }
 
-  _computeIsTrusted(item?: boolean) {
-    return item ? '' : 'Untrusted';
-  }
-
-  filterIdentities(item: AccountExternalIdInfo) {
-    return !item.identity.startsWith('username:');
-  }
-
-  _computeShowLinkAnotherIdentity(config?: ServerInfo) {
-    if (config?.auth?.git_basic_auth_policy) {
-      return AUTH.includes(config.auth.git_basic_auth_policy.toUpperCase());
+  // private but used in test
+  computeShowLinkAnotherIdentity() {
+    if (this.serverConfig?.auth?.auth_type) {
+      return AUTH.includes(this.serverConfig.auth.auth_type);
     }
 
     return false;
   }
 
-  _computeLinkAnotherIdentity() {
+  private computeLinkAnotherIdentity() {
     const baseUrl = getBaseUrl() || '';
     let pathname = window.location.pathname;
     if (baseUrl) {
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
deleted file mode 100644
index a30840c..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    tr th.emailAddressHeader,
-    tr th.identityHeader {
-      width: 15em;
-      padding: 0 10px;
-    }
-    tr td.statusColumn,
-    tr td.emailAddressColumn,
-    tr td.identityColumn {
-      word-break: break-word;
-    }
-    tr td.emailAddressColumn,
-    tr td.identityColumn {
-      padding: 4px 10px;
-      width: 15em;
-    }
-    .deleteButton {
-      float: right;
-    }
-    .deleteButton:not(.show) {
-      display: none;
-    }
-    .space {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset class="space">
-      <table>
-        <thead>
-          <tr>
-            <th class="statusHeader">Status</th>
-            <th class="emailAddressHeader">Email Address</th>
-            <th class="identityHeader">Identity</th>
-            <th class="deleteHeader"></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template
-            is="dom-repeat"
-            items="[[_identities]]"
-            filter="filterIdentities"
-          >
-            <tr>
-              <td class="statusColumn">[[_computeIsTrusted(item.trusted)]]</td>
-              <td class="emailAddressColumn">[[item.email_address]]</td>
-              <td class="identityColumn">
-                [[_computeIdentity(item.identity)]]
-              </td>
-              <td class="deleteColumn">
-                <gr-button
-                  class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
-                  on-click="_handleDeleteItem"
-                >
-                  Delete
-                </gr-button>
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </fieldset>
-    <template is="dom-if" if="[[_showLinkAnotherIdentity]]">
-      <fieldset>
-        <a href$="[[_computeLinkAnotherIdentity()]]">
-          <gr-button id="linkAnotherIdentity" link=""
-            >Link Another Identity</gr-button
-          >
-        </a>
-      </fieldset>
-    </template>
-  </div>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-delete-item-dialog
-      class="confirmDialog"
-      on-confirm="_handleDeleteItemConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      item="[[_idName]]"
-      itemTypeName="ID"
-    ></gr-confirm-delete-item-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
index 9d8dcc5..d52b423 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
@@ -1,30 +1,18 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-identities';
 import {GrIdentities} from './gr-identities';
-import {stubRestApi} from '../../../test/test-utils';
+import {AuthType} from '../../../constants/constants';
+import {stubRestApi, waitUntilVisible} from '../../../test/test-utils';
 import {ServerInfo} from '../../../types/common';
 import {createServerInfo} from '../../../test/test-data-generators';
 import {queryAll, queryAndAssert} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-identities');
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-identities tests', () => {
   let element: GrIdentities;
@@ -50,9 +38,72 @@
   setup(async () => {
     stubRestApi('getExternalIds').returns(Promise.resolve(ids));
 
-    element = basicFixture.instantiate();
+    element = await fixture<GrIdentities>(
+      html`<gr-identities></gr-identities>`
+    );
     await element.loadData();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="gr-form-styles">
+          <fieldset class="space">
+            <table>
+              <thead>
+                <tr>
+                  <th class="statusHeader">Status</th>
+                  <th class="emailAddressHeader">Email Address</th>
+                  <th class="identityHeader">Identity</th>
+                  <th class="deleteHeader"></th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <td class="statusColumn">Untrusted</td>
+                  <td class="emailAddressColumn">gerrit@example.com</td>
+                  <td class="identityColumn">gerrit:gerrit</td>
+                  <td class="deleteColumn">
+                    <gr-button
+                      aria-disabled="false"
+                      class="deleteButton"
+                      data-index="0"
+                      role="button"
+                      tabindex="0"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+                <tr>
+                  <td class="statusColumn"></td>
+                  <td class="emailAddressColumn">gerrit2@example.com</td>
+                  <td class="identityColumn"></td>
+                  <td class="deleteColumn">
+                    <gr-button
+                      aria-disabled="false"
+                      class="deleteButton show"
+                      data-index="1"
+                      role="button"
+                      tabindex="0"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </fieldset>
+        </div>
+        <dialog id="modal" tabindex="-1">
+          <gr-confirm-delete-item-dialog
+            class="confirmDialog"
+            itemtypename="ID"
+          >
+          </gr-confirm-delete-item-dialog>
+        </dialog>`
+    );
   });
 
   test('renders', () => {
@@ -77,70 +128,71 @@
     assert.equal(nameCells[1]!, 'gerrit2@example.com');
   });
 
-  test('_computeIdentity', () => {
-    assert.equal(element._computeIdentity(ids[0].identity), 'username:john');
-    assert.equal(element._computeIdentity(ids[2].identity), '');
-  });
-
   test('filterIdentities', () => {
-    assert.isFalse(element.filterIdentities(ids[0]));
-
-    assert.isTrue(element.filterIdentities(ids[1]));
+    assert.notInclude(element.getIdentities(), ids[0]);
+    assert.include(element.getIdentities(), ids[1]);
   });
 
   test('delete id', async () => {
-    element._idName = 'mailto:gerrit2@example.com';
+    element.idName = 'mailto:gerrit2@example.com';
     const loadDataStub = sinon.stub(element, 'loadData');
-    await element._handleDeleteItemConfirm();
+    await element.handleDeleteItemConfirm();
     assert.isTrue(loadDataStub.called);
   });
 
-  test('_handleDeleteItem opens modal', () => {
-    const deleteBtn = queryAndAssert(element, '.deleteButton');
-    const deleteItem = sinon.stub(element, '_handleDeleteItem');
-    MockInteractions.tap(deleteBtn);
-    assert.isTrue(deleteItem.called);
+  test('handleDeleteItem opens modal', async () => {
+    const deleteBtn = queryAndAssert<GrButton>(element, '.deleteButton');
+    deleteBtn.click();
+    await element.updateComplete;
+    await waitUntilVisible(element.modal!);
   });
 
-  test('_computeShowLinkAnotherIdentity', () => {
+  test('computeShowLinkAnotherIdentity', () => {
     const config: ServerInfo = {
       ...createServerInfo(),
     };
 
-    config.auth.git_basic_auth_policy = 'OAUTH';
-    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+    config.auth.auth_type = AuthType.OAUTH;
+    element.serverConfig = config;
+    assert.isTrue(element.computeShowLinkAnotherIdentity());
 
-    config.auth.git_basic_auth_policy = 'OpenID';
-    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+    config.auth.auth_type = AuthType.OPENID;
+    element.serverConfig = config;
+    assert.isTrue(element.computeShowLinkAnotherIdentity());
 
-    config.auth.git_basic_auth_policy = 'HTTP_LDAP';
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    config.auth.auth_type = AuthType.HTTP_LDAP;
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
-    config.auth.git_basic_auth_policy = 'LDAP';
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    config.auth.auth_type = AuthType.LDAP;
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
-    config.auth.git_basic_auth_policy = 'HTTP';
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    config.auth.auth_type = AuthType.HTTP;
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
-    assert.isFalse(element._computeShowLinkAnotherIdentity(undefined));
+    element.serverConfig = undefined;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
   });
 
-  test('_showLinkAnotherIdentity', () => {
+  test('showLinkAnotherIdentity', async () => {
     let config: ServerInfo = {
       ...createServerInfo(),
     };
-    config.auth.git_basic_auth_policy = 'OAUTH';
-
+    config.auth.auth_type = AuthType.OAUTH;
     element.serverConfig = config;
+    await element.updateComplete;
 
-    assert.isTrue(element._showLinkAnotherIdentity);
+    assert.isTrue(element.showLinkAnotherIdentity);
 
     config = {
       ...createServerInfo(),
     };
-    config.auth.git_basic_auth_policy = 'LDAP';
+    config.auth.auth_type = AuthType.LDAP;
     element.serverConfig = config;
+    await element.updateComplete;
 
-    assert.isFalse(element._showLinkAnotherIdentity);
+    assert.isFalse(element.showLinkAnotherIdentity);
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index c392a13..9c23857 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
@@ -1,98 +1,240 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-menu-editor_html';
-import {customElement, property} from '@polymer/decorators';
-import {TopMenuItemInfo} from '../../../types/common';
+import {PreferencesInfo, TopMenuItemInfo} from '../../../types/common';
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {state, customElement} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {deepEqual} from '../../../utils/deep-util';
+import {createDefaultPreferences} from '../../../constants/constants';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {classMap} from 'lit/directives/class-map.js';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
 
 @customElement('gr-menu-editor')
-export class GrMenuEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrMenuEditor extends LitElement {
+  @state()
+  menuItems: TopMenuItemInfo[] = [];
+
+  @state()
+  originalPrefs: PreferencesInfo = createDefaultPreferences();
+
+  @state()
+  newName = '';
+
+  @state()
+  newUrl = '';
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => {
+        this.originalPrefs = prefs;
+        this.menuItems = [...prefs.my];
+      }
+    );
   }
 
-  @property({type: Array})
-  menuItems!: TopMenuItemInfo[];
+  static override styles = [
+    formStyles,
+    sharedStyles,
+    fontStyles,
+    menuPageStyles,
+    css`
+      .buttonColumn {
+        width: 2em;
+      }
+      .moveUpButton,
+      .moveDownButton {
+        width: 100%;
+      }
+      tbody tr:first-of-type td .moveUpButton,
+      tbody tr:last-of-type td .moveDownButton {
+        display: none;
+      }
+      td.urlCell {
+        word-break: break-word;
+      }
+      .newUrlInput {
+        min-width: 23em;
+      }
+    `,
+  ];
 
-  @property({type: String})
-  _newName?: string;
-
-  @property({type: String})
-  _newUrl?: string;
-
-  _handleMoveUpButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    if (index === 0) {
-      return;
-    }
-    const row = this.menuItems[index];
-    const prev = this.menuItems[index - 1];
-    this.splice('menuItems', index - 1, 2, row, prev);
+  override render() {
+    const unchanged = deepEqual(this.menuItems, this.originalPrefs.my);
+    const classes = {
+      'heading-2': true,
+      edited: !unchanged,
+    };
+    return html`
+      <div class="gr-form-styles">
+        <h2 id="Menu" class=${classMap(classes)}>Menu</h2>
+        <fieldset id="menu">
+          <table>
+            <thead>
+              <tr>
+                <th>Name</th>
+                <th>URL</th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.menuItems.map((item, index) =>
+                this.renderMenuItemRow(item, index)
+              )}
+            </tbody>
+            <tfoot>
+              ${this.renderFooterRow()}
+            </tfoot>
+          </table>
+          <gr-button id="save" @click=${this.handleSave} ?disabled=${unchanged}
+            >Save changes</gr-button
+          >
+          <gr-button id="reset" link @click=${this.handleReset}
+            >Reset</gr-button
+          >
+        </fieldset>
+      </div>
+    `;
   }
 
-  _handleMoveDownButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    if (index === this.menuItems.length - 1) {
-      return;
-    }
-    const row = this.menuItems[index];
-    const next = this.menuItems[index + 1];
-    this.splice('menuItems', index, 2, next, row);
+  private renderMenuItemRow(item: TopMenuItemInfo, index: number) {
+    return html`
+      <tr>
+        <td>${item.name}</td>
+        <td class="urlCell">${item.url}</td>
+        <td class="buttonColumn">
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => this.swapItems(index, index - 1)}
+            class="moveUpButton"
+            >↑</gr-button
+          >
+        </td>
+        <td class="buttonColumn">
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => this.swapItems(index, index + 1)}
+            class="moveDownButton"
+            >↓</gr-button
+          >
+        </td>
+        <td>
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => {
+              this.menuItems.splice(index, 1);
+              this.requestUpdate('menuItems');
+            }}
+            class="remove-button"
+            >Delete</gr-button
+          >
+        </td>
+      </tr>
+    `;
   }
 
-  _handleDeleteButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    this.splice('menuItems', index, 1);
+  private renderFooterRow() {
+    return html`
+      <tr>
+        <th>
+          <iron-input
+            .bindValue=${this.newName}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newName = e.detail.value ?? '';
+            }}
+          >
+            <input
+              is="iron-input"
+              placeholder="New Title"
+              @keydown=${this.handleInputKeydown}
+            />
+          </iron-input>
+        </th>
+        <th>
+          <iron-input
+            .bindValue=${this.newUrl}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newUrl = e.detail.value ?? '';
+            }}
+          >
+            <input
+              class="newUrlInput"
+              placeholder="New URL"
+              @keydown=${this.handleInputKeydown}
+            />
+          </iron-input>
+        </th>
+        <th></th>
+        <th></th>
+        <th>
+          <gr-button
+            id="add"
+            link
+            ?disabled=${this.newName.length === 0 || this.newUrl.length === 0}
+            @click=${this.handleAddButton}
+            >Add</gr-button
+          >
+        </th>
+      </tr>
+    `;
   }
 
-  _handleAddButton() {
-    if (this._computeAddDisabled(this._newName, this._newUrl)) {
-      return;
-    }
+  private handleSave() {
+    this.getUserModel().updatePreferences({
+      ...this.originalPrefs,
+      my: this.menuItems,
+    });
+  }
 
-    this.splice('menuItems', this.menuItems.length, 0, {
-      name: this._newName,
-      url: this._newUrl,
+  private handleReset() {
+    this.menuItems = [...this.originalPrefs.my];
+  }
+
+  private swapItems(i: number, j: number) {
+    const max = this.menuItems.length - 1;
+    if (i < 0 || j < 0) return;
+    if (i > max || j > max) return;
+    const x = this.menuItems[i];
+    this.menuItems[i] = this.menuItems[j];
+    this.menuItems[j] = x;
+    this.requestUpdate('menuItems');
+  }
+
+  // visible for testing
+  handleAddButton() {
+    if (this.newName.length === 0 || this.newUrl.length === 0) return;
+
+    this.menuItems.push({
+      name: this.newName,
+      url: this.newUrl,
       target: '_blank',
     });
-
-    this._newName = '';
-    this._newUrl = '';
+    this.newName = '';
+    this.newUrl = '';
+    this.requestUpdate('menuItems');
   }
 
-  _computeAddDisabled(newName?: string, newUrl?: string) {
-    return !newName?.length || !newUrl?.length;
-  }
-
-  _handleInputKeydown(e: KeyboardEvent) {
-    if (e.keyCode === 13) {
+  private handleInputKeydown(e: KeyboardEvent) {
+    if (e.key === 'Enter') {
       e.stopPropagation();
-      this._handleAddButton();
+      this.handleAddButton();
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
deleted file mode 100644
index e4d66e2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
+++ /dev/null
@@ -1,131 +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">
-    .buttonColumn {
-      width: 2em;
-    }
-    .moveUpButton,
-    .moveDownButton {
-      width: 100%;
-    }
-    tbody tr:first-of-type td .moveUpButton,
-    tbody tr:last-of-type td .moveDownButton {
-      display: none;
-    }
-    td.urlCell {
-      word-break: break-word;
-    }
-    .newUrlInput {
-      min-width: 23em;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <table>
-      <thead>
-        <tr>
-          <th class="nameHeader">Name</th>
-          <th class="url-header">URL</th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[menuItems]]">
-          <tr>
-            <td>[[item.name]]</td>
-            <td class="urlCell">[[item.url]]</td>
-            <td class="buttonColumn">
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleMoveUpButton"
-                class="moveUpButton"
-                >↑</gr-button
-              >
-            </td>
-            <td class="buttonColumn">
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleMoveDownButton"
-                class="moveDownButton"
-                >↓</gr-button
-              >
-            </td>
-            <td>
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleDeleteButton"
-                class="remove-button"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-      <tfoot>
-        <tr>
-          <th>
-            <iron-input
-              placeholder="New Title"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newName}}"
-            >
-              <input
-                is="iron-input"
-                placeholder="New Title"
-                on-keydown="_handleInputKeydown"
-                bind-value="{{_newName}}"
-              />
-            </iron-input>
-          </th>
-          <th>
-            <iron-input
-              class="newUrlInput"
-              placeholder="New URL"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newUrl}}"
-            >
-              <input
-                class="newUrlInput"
-                is="iron-input"
-                placeholder="New URL"
-                on-keydown="_handleInputKeydown"
-                bind-value="{{_newUrl}}"
-              />
-            </iron-input>
-          </th>
-          <th></th>
-          <th></th>
-          <th>
-            <gr-button
-              link=""
-              disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
-              on-click="_handleAddButton"
-              >Add</gr-button
-            >
-          </th>
-        </tr>
-      </tfoot>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
deleted file mode 100644
index 1ca4852..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
+++ /dev/null
@@ -1,164 +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-menu-editor.js';
-import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-const basicFixture = fixtureFromElement('gr-menu-editor');
-
-suite('gr-menu-editor tests', () => {
-  let element;
-  let menu;
-
-  function assertMenuNamesEqual(element, expected) {
-    const names = element.menuItems.map(i => i.name);
-    assert.equal(names.length, expected.length);
-    for (let i = 0; i < names.length; i++) {
-      assert.equal(names[i], expected[i]);
-    }
-  }
-
-  // Click the up/down button (according to direction) for the index'th row.
-  // The index of the first row is 0, corresponding to the array.
-  function move(element, index, direction) {
-    const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
-        direction + 'Button';
-    const button =
-        element.shadowRoot
-            .querySelector('tbody').querySelector(selector)
-            .shadowRoot
-            .querySelector('paper-button');
-    MockInteractions.tap(button);
-  }
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    menu = [
-      {url: '/first/url', name: 'first name', target: '_blank'},
-      {url: '/second/url', name: 'second name', target: '_blank'},
-      {url: '/third/url', name: 'third name', target: '_blank'},
-    ];
-    element.set('menuItems', menu);
-    flush$0();
-    await flush();
-  });
-
-  test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('tbody').querySelectorAll('tr');
-    let tds;
-
-    assert.equal(rows.length, menu.length);
-    for (let i = 0; i < menu.length; i++) {
-      tds = rows[i].querySelectorAll('td');
-      assert.equal(tds[0].textContent, menu[i].name);
-      assert.equal(tds[1].textContent, menu[i].url);
-    }
-
-    assert.isTrue(element._computeAddDisabled(element._newName,
-        element._newUrl));
-  });
-
-  test('_computeAddDisabled', () => {
-    assert.isTrue(element._computeAddDisabled('', ''));
-    assert.isTrue(element._computeAddDisabled('name', ''));
-    assert.isTrue(element._computeAddDisabled('', 'url'));
-    assert.isFalse(element._computeAddDisabled('name', 'url'));
-  });
-
-  test('add a new menu item', () => {
-    const newName = 'new name';
-    const newUrl = 'new url';
-
-    element._newName = newName;
-    element._newUrl = newUrl;
-    assert.isFalse(element._computeAddDisabled(element._newName,
-        element._newUrl));
-
-    const originalMenuLength = element.menuItems.length;
-
-    element._handleAddButton();
-
-    assert.equal(element.menuItems.length, originalMenuLength + 1);
-    assert.equal(element.menuItems[element.menuItems.length - 1].name,
-        newName);
-    assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
-  });
-
-  test('move items down', () => {
-    assertMenuNamesEqual(element,
-        ['first name', 'second name', 'third name']);
-
-    // Move the middle item down
-    move(element, 1, 'Down');
-    assertMenuNamesEqual(element,
-        ['first name', 'third name', 'second name']);
-
-    // Moving the bottom item down is a no-op.
-    move(element, 2, 'Down');
-    assertMenuNamesEqual(element,
-        ['first name', 'third name', 'second name']);
-  });
-
-  test('move items up', () => {
-    assertMenuNamesEqual(element,
-        ['first name', 'second name', 'third name']);
-
-    // Move the last item up twice to be the first.
-    move(element, 2, 'Up');
-    move(element, 1, 'Up');
-    assertMenuNamesEqual(element,
-        ['third name', 'first name', 'second name']);
-
-    // Moving the top item up is a no-op.
-    move(element, 0, 'Up');
-    assertMenuNamesEqual(element,
-        ['third name', 'first name', 'second name']);
-  });
-
-  test('remove item', () => {
-    assertMenuNamesEqual(element,
-        ['first name', 'second name', 'third name']);
-
-    // Tap the delete button for the middle item.
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('tbody')
-        .querySelector('tr:nth-child(2) .remove-button')
-        .shadowRoot
-        .querySelector('paper-button'));
-
-    assertMenuNamesEqual(element, ['first name', 'third name']);
-
-    // Delete remaining items.
-    for (let i = 0; i < 2; i++) {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('tbody')
-          .querySelector('tr:first-child .remove-button')
-          .shadowRoot
-          .querySelector('paper-button'));
-    }
-    assertMenuNamesEqual(element, []);
-
-    // Add item to empty menu.
-    element._newName = 'new name';
-    element._newUrl = 'new url';
-    element._handleAddButton();
-    assertMenuNamesEqual(element, ['new name']);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
new file mode 100644
index 0000000..a8ad17c
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
@@ -0,0 +1,364 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-menu-editor';
+import {GrMenuEditor} from './gr-menu-editor';
+import {query, queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {PaperButtonElement} from '@polymer/paper-button';
+import {TopMenuItemInfo} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+import {createDefaultPreferences} from '../../../constants/constants';
+
+suite('gr-menu-editor tests', () => {
+  let element: GrMenuEditor;
+  let menu: TopMenuItemInfo[];
+
+  function assertMenuNamesEqual(
+    element: GrMenuEditor,
+    expected: Array<string>
+  ) {
+    const names = element.menuItems.map(i => i.name);
+    assert.equal(names.length, expected.length);
+    for (let i = 0; i < names.length; i++) {
+      assert.equal(names[i], expected[i]);
+    }
+  }
+
+  // Click the up/down button (according to direction) for the index'th row.
+  // The index of the first row is 0, corresponding to the array.
+  function move(element: GrMenuEditor, index: number, direction: string) {
+    const selector = `tr:nth-child(${index + 1}) .move${direction}Button`;
+
+    const button = query<PaperButtonElement>(
+      query<HTMLElement>(query<HTMLTableElement>(element, 'tbody'), selector),
+      'paper-button'
+    );
+    button!.click();
+  }
+
+  setup(async () => {
+    element = await fixture<GrMenuEditor>(
+      html`<gr-menu-editor></gr-menu-editor>`
+    );
+    menu = [
+      {url: '/first/url', name: 'first name', target: '_blank'},
+      {url: '/second/url', name: 'second name', target: '_blank'},
+      {url: '/third/url', name: 'third name', target: '_blank'},
+    ];
+    element.originalPrefs = {...createDefaultPreferences(), my: menu};
+    element.menuItems = [...menu];
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <h2 class="heading-2" id="Menu">Menu</h2>
+          <fieldset id="menu">
+            <table>
+              <thead>
+                <tr>
+                  <th>Name</th>
+                  <th>URL</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <td>first name</td>
+                  <td class="urlCell">/first/url</td>
+                  <td class="buttonColumn">
+                    <gr-button
+                      aria-disabled="false"
+                      class="moveUpButton"
+                      data-index="0"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      ↑
+                    </gr-button>
+                  </td>
+                  <td class="buttonColumn">
+                    <gr-button
+                      aria-disabled="false"
+                      class="moveDownButton"
+                      data-index="0"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      ↓
+                    </gr-button>
+                  </td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      class="remove-button"
+                      data-index="0"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+                <tr>
+                  <td>second name</td>
+                  <td class="urlCell">/second/url</td>
+                  <td class="buttonColumn">
+                    <gr-button
+                      aria-disabled="false"
+                      class="moveUpButton"
+                      data-index="1"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      ↑
+                    </gr-button>
+                  </td>
+                  <td class="buttonColumn">
+                    <gr-button
+                      aria-disabled="false"
+                      class="moveDownButton"
+                      data-index="1"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      ↓
+                    </gr-button>
+                  </td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      class="remove-button"
+                      data-index="1"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+                <tr>
+                  <td>third name</td>
+                  <td class="urlCell">/third/url</td>
+                  <td class="buttonColumn">
+                    <gr-button
+                      aria-disabled="false"
+                      class="moveUpButton"
+                      data-index="2"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      ↑
+                    </gr-button>
+                  </td>
+                  <td class="buttonColumn">
+                    <gr-button
+                      aria-disabled="false"
+                      class="moveDownButton"
+                      data-index="2"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      ↓
+                    </gr-button>
+                  </td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      class="remove-button"
+                      data-index="2"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+              </tbody>
+              <tfoot>
+                <tr>
+                  <th>
+                    <iron-input>
+                      <input is="iron-input" placeholder="New Title" />
+                    </iron-input>
+                  </th>
+                  <th>
+                    <iron-input>
+                      <input class="newUrlInput" placeholder="New URL" />
+                    </iron-input>
+                  </th>
+                  <th></th>
+                  <th></th>
+                  <th>
+                    <gr-button
+                      aria-disabled="true"
+                      disabled=""
+                      id="add"
+                      link=""
+                      role="button"
+                      tabindex="-1"
+                    >
+                      Add
+                    </gr-button>
+                  </th>
+                </tr>
+              </tfoot>
+            </table>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="save"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+            <gr-button
+              aria-disabled="false"
+              id="reset"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              Reset
+            </gr-button>
+          </fieldset>
+        </div>
+      `
+    );
+  });
+
+  test('add button disabled', async () => {
+    element.newName = 'test-name';
+    await element.updateComplete;
+    let addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isTrue(addButton.hasAttribute('disabled'));
+
+    element.newUrl = 'test-url';
+    await element.updateComplete;
+    addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isFalse(addButton.hasAttribute('disabled'));
+  });
+
+  test('add a new menu item', async () => {
+    const newName = 'new name';
+    const newUrl = 'new url';
+    const originalMenuLength = element.menuItems.length;
+
+    element.newName = newName;
+    element.newUrl = newUrl;
+    await element.updateComplete;
+
+    const addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isFalse(addButton.hasAttribute('disabled'));
+    addButton.click();
+
+    assert.equal(element.menuItems.length, originalMenuLength + 1);
+    assert.equal(element.menuItems[element.menuItems.length - 1].name, newName);
+    assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
+  });
+
+  test('move items down', () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+
+    // Move the middle item down
+    move(element, 1, 'Down');
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+
+    // Moving the bottom item down is a no-op.
+    move(element, 2, 'Down');
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+  });
+
+  test('move item down and save', async () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+    const saveButton = queryAndAssert<GrButton>(element, 'gr-button#save');
+    assert.isTrue(saveButton.hasAttribute('disabled'));
+
+    move(element, 1, 'Down');
+    await element.updateComplete;
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+    assert.isFalse(saveButton.hasAttribute('disabled'));
+
+    saveButton.click();
+    await waitUntil(() => element.originalPrefs.my[1].name === 'third name');
+    await element.updateComplete;
+
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+    assert.isTrue(saveButton.hasAttribute('disabled'));
+  });
+
+  test('move item down and reset', async () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+
+    move(element, 1, 'Down');
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+
+    const resetButton = queryAndAssert<GrButton>(element, 'gr-button#reset');
+    resetButton.click();
+    await element.updateComplete;
+
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+  });
+
+  test('move items up', () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+
+    // Move the last item up twice to be the first.
+    move(element, 2, 'Up');
+    move(element, 1, 'Up');
+    assertMenuNamesEqual(element, ['third name', 'first name', 'second name']);
+
+    // Moving the top item up is a no-op.
+    move(element, 0, 'Up');
+    assertMenuNamesEqual(element, ['third name', 'first name', 'second name']);
+  });
+
+  test('remove item', () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+
+    // Tap the delete button for the middle item.
+    query<PaperButtonElement>(
+      query<HTMLElement>(
+        query<HTMLTableElement>(element, 'tbody'),
+        'tr:nth-child(2) .remove-button'
+      ),
+      'paper-button'
+    )!.click();
+
+    assertMenuNamesEqual(element, ['first name', 'third name']);
+
+    // Delete remaining items.
+    for (let i = 0; i < 2; i++) {
+      query<PaperButtonElement>(
+        query<HTMLElement>(
+          query<HTMLTableElement>(element, 'tbody'),
+          'tr:first-child .remove-button'
+        ),
+        'paper-button'
+      )!.click();
+    }
+    assertMenuNamesEqual(element, []);
+
+    // Add item to empty menu.
+    element.newName = 'new name';
+    element.newUrl = 'new url';
+    element.handleAddButton();
+    assertMenuNamesEqual(element, ['new name']);
+  });
+});
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 aa8f62b..a20c0ee 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
@@ -1,37 +1,22 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../../styles/gr-form-styles';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-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 {EditableAccountField} from '../../../constants/constants';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
-
-export interface GrRegistrationDialog {
-  $: {
-    name: HTMLInputElement;
-    username: HTMLInputElement;
-  };
-}
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {when} from 'lit/directives/when.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -40,11 +25,7 @@
 }
 
 @customElement('gr-registration-dialog')
-export class GrRegistrationDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrRegistrationDialog extends LitElement {
   /**
    * Fired when account details are changed.
    *
@@ -56,119 +37,286 @@
    *
    * @event close
    */
-  @property({type: String})
-  settingsUrl?: string;
+  @query('#name') nameInput?: HTMLInputElement;
 
-  @property({type: Object})
-  _account: Partial<AccountDetailInfo> = {};
+  @query('#username') usernameInput?: HTMLInputElement;
 
-  @property({type: Boolean})
-  _loading = true;
+  @query('#displayName') displayName?: HTMLInputElement;
 
-  @property({type: Boolean})
-  _saving = false;
+  @property() settingsUrl?: string;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  @state() account: Partial<AccountDetailInfo> = {};
 
-  @property({
-    computed: '_computeUsernameMutable(_account.username)',
-    type: Boolean,
-  })
-  _usernameMutable = false;
+  @state() loading = true;
 
-  @property({type: Boolean})
-  _hasUsernameChange?: boolean;
+  @state() saving = false;
 
-  @property({type: String, observer: '_usernameChanged'})
-  _username?: string;
+  @state() serverConfig?: ServerInfo;
 
-  private readonly restApiService = appContext.restApiService;
+  @state() usernameMutable = false;
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
+  @state() hasUsernameChange?: boolean;
+
+  @state() username?: string;
+
+  @state() nameMutable?: boolean;
+
+  @state() hasNameChange?: boolean;
+
+  @state() hasDisplayNameChange?: boolean;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    if (!this.getAttribute('role')) {
+      this.setAttribute('role', 'dialog');
+    }
+  }
+
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      :host {
+        display: block;
+      }
+      main {
+        max-width: 46em;
+      }
+      :host(.loading) main {
+        display: none;
+      }
+      .loadingMessage {
+        display: none;
+        font-style: italic;
+      }
+      :host(.loading) .loadingMessage {
+        display: block;
+      }
+      hr {
+        margin-top: var(--spacing-l);
+        margin-bottom: var(--spacing-l);
+      }
+      header {
+        border-bottom: 1px solid var(--border-color);
+        font-weight: var(--font-weight-bold);
+        margin-bottom: var(--spacing-l);
+      }
+      .container {
+        padding: var(--spacing-m) var(--spacing-xl);
+      }
+      footer {
+        display: flex;
+        justify-content: flex-end;
+      }
+      footer gr-button {
+        margin-left: var(--spacing-l);
+      }
+      input {
+        width: 20em;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="container gr-form-styles">
+      <header>Please confirm your contact information</header>
+      <div class="loadingMessage">Loading...</div>
+      <main>
+        <p>
+          The following contact information was automatically obtained when you
+          signed in to the site. This information is used to display who you are
+          to others, and to send updates to code reviews you have either started
+          or subscribed to.
+        </p>
+        <hr />
+        <section>
+          <span class="title">Full Name</span>
+          ${when(
+            this.nameMutable,
+            () => html`<span class="value">
+              <iron-input
+                .bindValue=${this.account.name}
+                @bind-value-changed=${(e: BindValueChangeEvent) => {
+                  const oldAccount = this.account;
+                  if (!oldAccount || oldAccount.name === e.detail.value) return;
+                  this.account = {...oldAccount, name: e.detail.value};
+                  this.hasNameChange = true;
+                }}
+              >
+                <input id="name" ?disabled=${this.saving} />
+              </iron-input>
+            </span>`,
+            () => html`<span class="value">${this.account.name}</span>`
+          )}
+        </section>
+        <section>
+          <span class="title">Display Name</span>
+          <span class="value">
+            <iron-input
+              .bindValue=${this.account.display_name}
+              @bind-value-changed=${(e: BindValueChangeEvent) => {
+                const oldAccount = this.account;
+                if (!oldAccount || oldAccount.display_name === e.detail.value) {
+                  return;
+                }
+                this.account = {...oldAccount, display_name: e.detail.value};
+                this.hasDisplayNameChange = true;
+              }}
+            >
+              <input id="displayName" ?disabled=${this.saving} />
+            </iron-input>
+          </span>
+        </section>
+        ${when(
+          this.computeUsernameEditable(),
+          () => html`<section>
+            <span class="title">Username</span>
+            ${when(
+              this.usernameMutable,
+              () => html` <span class="value">
+                <iron-input
+                  .bindValue=${this.username}
+                  @bind-value-changed=${(e: BindValueChangeEvent) => {
+                    if (!this.usernameInput || this.username === e.detail.value)
+                      return;
+                    this.username = e.detail.value;
+                    this.hasUsernameChange = true;
+                  }}
+                >
+                  <input id="username" ?disabled=${this.saving} />
+                </iron-input>
+              </span>`,
+              () => html`<span class="value">${this.username}</span>`
+            )}
+          </section>`
+        )}
+        <hr />
+        <p>
+          More configuration options for Gerrit may be found in the
+          <a @click=${this.close} href=${ifDefined(this.settingsUrl)}
+            >settings</a
+          >.
+        </p>
+      </main>
+      <footer>
+        <gr-button
+          id="closeButton"
+          link
+          ?disabled=${this.saving}
+          @click=${this.handleClose}
+          >Close</gr-button
+        >
+        <gr-button
+          id="saveButton"
+          primary
+          link
+          ?disabled=${this.computeSaveDisabled()}
+          @click=${this.handleSave}
+          >Save</gr-button
+        >
+      </footer>
+    </div>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('account')) {
+      this.usernameMutable = !this.account.username;
+    }
+    if (changedProperties.has('serverConfig')) {
+      this.nameMutable = this.computeNameMutable();
+    }
+    if (changedProperties.has('loading')) {
+      this.classList.toggle('loading', this.loading);
+    }
   }
 
   loadData() {
-    this._loading = true;
+    this.loading = true;
 
     const loadAccount = this.restApiService.getAccount().then(account => {
       if (!account) return;
-      this._hasUsernameChange = false;
+      this.hasNameChange = false;
+      this.hasUsernameChange = false;
+      this.hasDisplayNameChange = false;
       // Provide predefined value for username to trigger computation of
       // username mutability.
       account.username = account.username || '';
-      this._account = account;
-      this._username = account.username;
+
+      this.account = account;
+      this.username = account.username;
     });
 
     const loadConfig = this.restApiService.getConfig().then(config => {
-      this._serverConfig = config;
+      this.serverConfig = config;
     });
 
     return Promise.all([loadAccount, loadConfig]).then(() => {
-      this._loading = false;
+      this.loading = false;
     });
   }
 
-  _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(
+  // private but used in test
+  computeUsernameEditable() {
+    return !!this.serverConfig?.auth.editable_account_fields.includes(
       EditableAccountField.USER_NAME
     );
   }
 
-  _save() {
-    this._saving = true;
+  private computeNameMutable() {
+    return !!this.serverConfig?.auth.editable_account_fields.includes(
+      EditableAccountField.FULL_NAME
+    );
+  }
 
-    const promises = [this.restApiService.setAccountName(this.$.name.value)];
+  // private but used in test
+  save() {
+    this.saving = true;
 
+    const promises = [];
     // 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));
+    if (this.hasUsernameChange && this.usernameMutable && this.username) {
+      promises.push(this.restApiService.setAccountUsername(this.username));
+    }
+
+    if (this.hasNameChange && this.nameMutable && this.account?.name) {
+      promises.push(this.restApiService.setAccountName(this.account.name));
+    }
+
+    if (this.hasDisplayNameChange && this.account?.display_name) {
+      promises.push(
+        this.restApiService.setAccountDisplayName(this.account.display_name)
+      );
     }
 
     return Promise.all(promises).then(() => {
-      this._saving = false;
+      this.saving = false;
       fireEvent(this, 'account-detail-update');
     });
   }
 
-  _handleSave(e: Event) {
+  private handleSave(e: Event) {
     e.preventDefault();
-    this._save().then(() => this.close());
+    this.save().then(() => this.close());
   }
 
-  _handleClose(e: Event) {
+  private handleClose(e: Event) {
     e.preventDefault();
     this.close();
   }
 
-  close() {
-    this._saving = true; // disable buttons indefinitely
+  private close() {
+    this.saving = true; // disable buttons indefinitely
     fireEvent(this, 'close');
   }
 
-  _computeSaveDisabled(name?: string, username?: string, saving?: boolean) {
-    return saving || (!name && !username);
-  }
-
-  @observe('_loading')
-  _loadingChanged() {
-    this.classList.toggle('loading', this._loading);
+  // private but used in test
+  computeSaveDisabled() {
+    return (
+      this.saving ||
+      (!this.account?.display_name && !this.account.name && !this.username)
+    );
   }
 }
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
deleted file mode 100644
index 6f270f5..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_html.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.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    main {
-      max-width: 46em;
-    }
-    :host(.loading) main {
-      display: none;
-    }
-    .loadingMessage {
-      display: none;
-      font-style: italic;
-    }
-    :host(.loading) .loadingMessage {
-      display: block;
-    }
-    hr {
-      margin-top: var(--spacing-l);
-      margin-bottom: var(--spacing-l);
-    }
-    header {
-      border-bottom: 1px solid var(--border-color);
-      font-weight: var(--font-weight-bold);
-      margin-bottom: var(--spacing-l);
-    }
-    .container {
-      padding: var(--spacing-m) var(--spacing-xl);
-    }
-    footer {
-      display: flex;
-      justify-content: flex-end;
-    }
-    footer gr-button {
-      margin-left: var(--spacing-l);
-    }
-    input {
-      width: 20em;
-    }
-  </style>
-  <div class="container gr-form-styles">
-    <header>Please confirm your contact information</header>
-    <div class="loadingMessage">Loading...</div>
-    <main>
-      <p>
-        The following contact information was automatically obtained when you
-        signed in to the site. This information is used to display who you are
-        to others, and to send updates to code reviews you have either started
-        or subscribed to.
-      </p>
-      <hr />
-      <section>
-        <span class="title">Full Name</span>
-        <span class="value">
-          <iron-input bind-value="{{_account.name}}">
-            <input id="name" disabled="[[_saving]]" />
-          </iron-input>
-        </span>
-      </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 id="username" disabled="[[_saving]]" />
-            </iron-input>
-          </span>
-        </section>
-      </template>
-      <hr />
-      <p>
-        More configuration options for Gerrit may be found in the
-        <a on-click="close" href$="[[settingsUrl]]">settings</a>.
-      </p>
-    </main>
-    <footer>
-      <gr-button
-        id="closeButton"
-        link=""
-        disabled="[[_saving]]"
-        on-click="_handleClose"
-        >Close</gr-button
-      >
-      <gr-button
-        id="saveButton"
-        primary=""
-        link=""
-        disabled="[[_computeSaveDisabled(_account.name, _username, _saving)]]"
-        on-click="_handleSave"
-        >Save</gr-button
-      >
-    </footer>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
index 07a2e51..7f0f85d 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
@@ -1,28 +1,20 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
+import './gr-registration-dialog';
 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');
+import {
+  createAccountWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 suite('gr-registration-dialog tests', () => {
   let element: GrRegistrationDialog;
@@ -30,15 +22,20 @@
 
   let _listeners: {[key: string]: EventListenerOrEventListenerObject};
 
-  setup(() => {
+  setup(async () => {
     _listeners = {};
 
     account = {
       name: 'name',
+      display_name: 'display name',
       registered_on: '2018-02-08 18:49:18.000000000' as Timestamp,
     };
 
-    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('getAccount').returns(
+      Promise.resolve({
+        ...account,
+      })
+    );
     stubRestApi('setAccountName').callsFake(name => {
       account.name = name;
       return Promise.resolve();
@@ -47,19 +44,29 @@
       account.username = username;
       return Promise.resolve();
     });
+    stubRestApi('setAccountDisplayName').callsFake(displayName => {
+      account.display_name = displayName;
+      return Promise.resolve();
+    });
     stubRestApi('getConfig').returns(
       Promise.resolve({
         ...createServerInfo(),
         auth: {
           auth_type: AuthType.HTTP,
-          editable_account_fields: [EditableAccountField.USER_NAME],
+          editable_account_fields: [
+            EditableAccountField.USER_NAME,
+            EditableAccountField.FULL_NAME,
+          ],
         },
       })
     );
 
-    element = basicFixture.instantiate();
+    element = await fixture<GrRegistrationDialog>(
+      html`<gr-registration-dialog></gr-registration-dialog>`
+    );
 
-    return element.loadData();
+    await element.loadData();
+    await element.updateComplete;
   });
 
   teardown(() => {
@@ -79,7 +86,7 @@
 
   function save() {
     const promise = listen('account-detail-update');
-    MockInteractions.tap(queryAndAssert(element, '#saveButton'));
+    queryAndAssert<GrButton>(element, '#saveButton').click();
     return promise;
   }
 
@@ -88,73 +95,156 @@
     if (opt_action) {
       opt_action();
     } else {
-      MockInteractions.tap(queryAndAssert(element, '#closeButton'));
+      queryAndAssert<GrButton>(element, '#closeButton').click();
     }
     return promise;
   }
 
+  test('renders', () => {
+    // cannot format with /* HTML */, because it breaks test
+    assert.shadowDom.equal(
+      element,
+      /* HTML*/ `<div
+      class="container gr-form-styles"
+    >
+      <header>Please confirm your contact information</header>
+      <div class="loadingMessage">Loading...</div>
+      <main>
+        <p>
+        The following contact information was automatically obtained when you
+          signed in to the site. This information is used to display who you are
+          to others, and to send updates to code reviews you have either started
+          or subscribed to.
+        </p>
+        <hr />
+        <section>
+          <span class="title"> Full Name </span>
+          <span class="value">
+            <iron-input>
+              <input id="name">
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <span class="title"> Display Name </span>
+          <span class="value">
+            <iron-input> <input id="displayName" /> </iron-input>
+          </span>
+        </section>
+        <section>
+          <span class="title"> Username </span>
+          <span class="value">
+            <iron-input>
+              <input id="username">
+            </iron-input>
+          </span>
+        </section>
+        <hr />
+        <p>
+          More configuration options for Gerrit may be found in the
+          <a> settings </a> .
+        </p>
+      </main>
+      <footer>
+        <gr-button
+          aria-disabled="false"
+          id="closeButton"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Close
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          id="saveButton"
+          link=""
+          primary=""
+          role="button"
+          tabindex="0"
+        >
+          Save
+        </gr-button>
+      </footer>
+    </div>`
+    );
+  });
+
   test('fires the close event on close', async () => {
     await close();
   });
 
   test('fires the close event on save', async () => {
-    await close(() =>
-      MockInteractions.tap(queryAndAssert(element, '#saveButton'))
-    );
+    await close(() => {
+      queryAndAssert<GrButton>(element, '#saveButton').click();
+    });
   });
 
   test('saves account details', async () => {
-    await flush();
-    element.$.name.value = 'new name';
+    await element.updateComplete;
 
-    element.set('_account.username', '');
-    element._hasUsernameChange = false;
-    assert.isTrue(element._usernameMutable);
+    element.account.username = '';
+    element.hasUsernameChange = false;
+    await element.updateComplete;
+    assert.isTrue(element.usernameMutable);
 
-    element.set('_username', 'new username');
+    element.username = 'new username';
+    element.hasUsernameChange = true;
+    element.account.name = 'new name';
+    element.hasNameChange = true;
+    element.account.display_name = 'new display name';
+    element.hasDisplayNameChange = true;
+    await element.updateComplete;
 
     // Nothing should be committed yet.
     assert.equal(account.name, 'name');
     assert.isNotOk(account.username);
+    assert.equal(account.display_name, 'display name');
 
     // Save and verify new values are committed.
     await save();
     assert.equal(account.name, 'new name');
     assert.equal(account.username, 'new username');
+    assert.equal(account.display_name, 'new display name');
   });
 
-  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('save btn disabled', async () => {
+    element.account = {};
+    element.saving = false;
+    await element.updateComplete;
+    assert.isTrue(element.computeSaveDisabled());
+    element.account = {
+      ...createAccountWithId(),
+      display_name: 'test',
+      name: 'test',
+    };
+    element.username = 'test';
+    element.saving = true;
+    await element.updateComplete;
+    assert.isTrue(element.computeSaveDisabled());
+    element.saving = false;
+    await element.updateComplete;
+    assert.isFalse(element.computeSaveDisabled());
   });
 
-  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: [],
-        },
-      })
-    );
+  test('_computeUsernameEditable', async () => {
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        auth_type: AuthType.HTTP,
+        editable_account_fields: [EditableAccountField.USER_NAME],
+      },
+    };
+    await element.updateComplete;
+    assert.isTrue(element.computeUsernameEditable());
+    element.serverConfig = {
+      ...createServerInfo(),
+      auth: {
+        auth_type: AuthType.HTTP,
+        editable_account_fields: [],
+      },
+    };
+    await element.updateComplete;
+    assert.isFalse(element.computeUsernameEditable());
   });
 });
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 64409d5..ea65542 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
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {formStyles} from '../../../styles/gr-form-styles';
 
 declare global {
@@ -46,6 +35,6 @@
 
   override render() {
     const anchor = this.anchor ?? '';
-    return html`<h2 id="${anchor}" class="heading-2">${this.title}</h2>`;
+    return html`<h2 id=${anchor} class="heading-2">${this.title}</h2>`;
   }
 }
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 9e4ea0a..6c83bea 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
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -40,7 +29,7 @@
   override render() {
     const href = this.href ?? '';
     return html` <div class="navStyles">
-      <li><a href="${href}">${this.title}</a></li>
+      <li><a href=${href}>${this.title}</a></li>
     </div>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 25e9de8..49f2284 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
@@ -1,34 +1,17 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-font-styles';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-menu-page-styles';
-import '../../../styles/gr-page-nav-styles';
-import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../gr-change-table-editor/gr-change-table-editor';
 import '../../shared/gr-button/gr-button';
-import {GrButton} from '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../../shared/gr-select/gr-select';
+import '../../shared/gr-icon/gr-icon';
 import '../gr-account-info/gr-account-info';
 import '../gr-agreements-list/gr-agreements-list';
 import '../gr-edit-preferences/gr-edit-preferences';
@@ -40,55 +23,48 @@
 import '../gr-menu-editor/gr-menu-editor';
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-settings-view_html';
 import {getDocsBaseUrl} from '../../../utils/url-util';
-import {customElement, property, observe} from '@polymer/decorators';
-import {AppElementParams} from '../../gr-app-types';
 import {GrAccountInfo} from '../gr-account-info/gr-account-info';
 import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
 import {GrGroupList} from '../gr-group-list/gr-group-list';
 import {GrIdentities} from '../gr-identities/gr-identities';
-import {GrEditPreferences} from '../gr-edit-preferences/gr-edit-preferences';
 import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
 import {
+  AccountDetailInfo,
   PreferencesInput,
   ServerInfo,
-  TopMenuItemInfo,
 } from '../../../types/common';
 import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
 import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
-import {appContext} from '../../../services/app-context';
-import {GerritView} from '../../../services/router/router-model';
+import {getAppContext} from '../../../services/app-context';
 import {
+  ColumnNames,
   DateFormat,
   DefaultBase,
   DiffViewMode,
   EmailFormat,
   EmailStrategy,
+  AppTheme,
   TimeFormat,
 } from '../../../constants/constants';
-import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
-import {windowLocationReload} from '../../../utils/dom-util';
-
-const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
-  'changes_per_page',
-  'date_format',
-  'time_format',
-  'email_strategy',
-  'diff_view',
-  'publish_comments_on_push',
-  'disable_keyboard_shortcuts',
-  'disable_token_highlighting',
-  'work_in_progress_by_default',
-  'default_base_for_merges',
-  'signed_off_by',
-  'email_format',
-  'size_bar_in_change_table',
-  'relative_date_in_change_table',
-];
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, query, queryAsync, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {when} from 'lit/directives/when.js';
+import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {settingsViewModelToken} from '../../../models/views/settings';
+import {areNotificationsEnabled} from '../../../utils/worker-util';
+import {userModelToken} from '../../../models/user/user-model';
 
 const GERRIT_DOCS_BASE_URL =
   'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -103,44 +79,8 @@
   LocalPrefsToPrefs,
 }
 
-type LocalMenuItemInfo = Omit<TopMenuItemInfo, 'id'>;
-
-export interface GrSettingsView {
-  $: {
-    accountInfo: GrAccountInfo;
-    watchedProjectsEditor: GrWatchedProjectsEditor;
-    groupList: GrGroupList;
-    identities: GrIdentities;
-    editPrefs: GrEditPreferences;
-    diffPrefs: GrDiffPreferences;
-    sshEditor: GrSshEditor;
-    gpgEditor: GrGpgEditor;
-    emailEditor: GrEmailEditor;
-    insertSignedOff: HTMLInputElement;
-    workInProgressByDefault: HTMLInputElement;
-    showSizeBarsInFileList: HTMLInputElement;
-    publishCommentsOnPush: HTMLInputElement;
-    disableKeyboardShortcuts: HTMLInputElement;
-    disableTokenHighlighting: HTMLInputElement;
-    relativeDateInChangeTable: HTMLInputElement;
-    changesPerPageSelect: HTMLInputElement;
-    dateTimeFormatSelect: HTMLInputElement;
-    timeFormatSelect: HTMLInputElement;
-    emailNotificationsSelect: HTMLInputElement;
-    emailFormatSelect: HTMLInputElement;
-    defaultBaseForMergesSelect: HTMLInputElement;
-    diffViewSelect: HTMLInputElement;
-    menu: HTMLFieldSetElement;
-    resetButton: GrButton;
-  };
-}
-
 @customElement('gr-settings-view')
-export class GrSettingsView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrSettingsView extends LitElement {
   /**
    * Fired when the title of the page should change.
    *
@@ -153,135 +93,206 @@
    * @event show-alert
    */
 
-  @property({type: Object})
-  prefs: PreferencesInput = {};
+  @query('#accountInfo', true) accountInfo!: GrAccountInfo;
 
-  @property({type: Object})
-  params?: AppElementParams;
+  @query('#watchedProjectsEditor', true)
+  watchedProjectsEditor!: GrWatchedProjectsEditor;
 
-  @property({type: Boolean})
-  _accountInfoChanged?: boolean;
+  @query('#groupList', true) groupList!: GrGroupList;
 
-  @property({type: Object})
-  _localPrefs: PreferencesInput = {};
+  @query('#identities', true) identities!: GrIdentities;
 
-  @property({type: Array})
-  _localChangeTableColumns: string[] = [];
+  @query('#diffPrefs') diffPrefs!: GrDiffPreferences;
 
-  @property({type: Array})
-  _localMenu: LocalMenuItemInfo[] = [];
+  @queryAsync('#sshEditor') sshEditorPromise!: Promise<GrSshEditor>;
 
-  @property({type: Boolean})
-  _loading = true;
+  @queryAsync('#gpgEditor') gpgEditorPromise!: Promise<GrGpgEditor>;
 
-  @property({type: Boolean})
-  _changeTableChanged = false;
+  @query('#emailEditor', true) emailEditor!: GrEmailEditor;
 
-  @property({type: Boolean})
-  _prefsChanged = false;
+  @query('#insertSignedOff') insertSignedOff!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _diffPrefsChanged = false;
+  @query('#workInProgressByDefault') workInProgressByDefault!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _editPrefsChanged = false;
+  @query('#showSizeBarsInFileList') showSizeBarsInFileList!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _menuChanged = false;
+  @query('#publishCommentsOnPush') publishCommentsOnPush!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _watchedProjectsChanged = false;
+  @query('#allowBrowserNotifications')
+  allowBrowserNotifications?: HTMLInputElement;
 
-  @property({type: Boolean})
-  _keysChanged = false;
+  @query('#disableKeyboardShortcuts')
+  disableKeyboardShortcuts!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _gpgKeysChanged = false;
+  @query('#disableTokenHighlighting')
+  disableTokenHighlighting!: HTMLInputElement;
 
-  @property({type: String})
-  _newEmail?: string;
+  @query('#relativeDateInChangeTable')
+  relativeDateInChangeTable!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _addingEmail = false;
+  @query('#changesPerPageSelect') changesPerPageSelect!: HTMLInputElement;
 
-  @property({type: String})
-  _lastSentVerificationEmail?: string | null = null;
+  @query('#dateTimeFormatSelect') dateTimeFormatSelect!: HTMLInputElement;
 
-  @property({type: Object})
-  _serverConfig?: ServerInfo;
+  @query('#timeFormatSelect') timeFormatSelect!: HTMLInputElement;
 
-  @property({type: String})
-  _docsBaseUrl?: string | null;
+  @query('#emailNotificationsSelect')
+  emailNotificationsSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _emailsChanged = false;
+  @query('#emailFormatSelect') emailFormatSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _showNumber?: boolean;
+  @query('#defaultBaseForMergesSelect')
+  defaultBaseForMergesSelect!: HTMLInputElement;
 
-  @property({type: Boolean})
-  _isDark = false;
+  @query('#diffViewSelect') diffViewSelect!: HTMLInputElement;
 
+  @query('#themeSelect') themeSelect!: HTMLInputElement;
+
+  @state() prefs: PreferencesInput = {};
+
+  @state() private accountInfoChanged = false;
+
+  // private but used in test
+  @state() localPrefs: PreferencesInput = {};
+
+  // private but used in test
+  @state() localChangeTableColumns: string[] = [];
+
+  @state() private loading = true;
+
+  @state() private changeTableChanged = false;
+
+  // private but used in test
+  @state() prefsChanged = false;
+
+  @state() private diffPrefsChanged = false;
+
+  @state() private watchedProjectsChanged = false;
+
+  @state() private keysChanged = false;
+
+  @state() private gpgKeysChanged = false;
+
+  // private but used in test
+  @state() newEmail?: string;
+
+  // private but used in test
+  @state() addingEmail = false;
+
+  // private but used in test
+  @state() lastSentVerificationEmail?: string | null = null;
+
+  // private but used in test
+  @state() serverConfig?: ServerInfo;
+
+  // private but used in test
+  @state() docsBaseUrl?: string | null;
+
+  @state() private emailsChanged = false;
+
+  // private but used in test
+  @state() emailToken?: string;
+
+  // private but used in test
+  @state() showNumber?: boolean;
+
+  @state() account?: AccountDetailInfo;
+
+  // private but used in test
   public _testOnly_loadingPromise?: Promise<void>;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  // private but used in test
+  readonly flagsService = getAppContext().flagsService;
+
+  private readonly getViewModel = resolve(this, settingsViewModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getViewModel().emailToken$,
+      x => {
+        this.emailToken = x;
+        this.confirmEmail();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      acc => {
+        this.account = acc;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => {
+        if (!prefs) {
+          throw new Error('getPreferences returned undefined');
+        }
+        this.prefs = prefs;
+        this.showNumber = !!prefs.legacycid_in_change_table;
+        this.copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+        this.prefsChanged = false;
+        this.localChangeTableColumns =
+          prefs.change_table.length === 0
+            ? Object.values(ColumnNames)
+            : prefs.change_table.map(column =>
+                column === 'Project' ? 'Repo' : column
+              );
+      }
+    );
+  }
+
+  // private, but used in tests
+  async confirmEmail() {
+    if (!this.emailToken) return;
+    const message = await this.restApiService.confirmEmail(this.emailToken);
+    if (message) fireAlert(this, message);
+    this.getViewModel().clearToken();
+    await this.emailEditor.loadData();
+  }
 
   override connectedCallback() {
     super.connectedCallback();
     // Polymer 2: anchor tag won't work on shadow DOM
     // we need to manually calling scrollIntoView when hash changed
-    window.addEventListener('location-change', this.handleLocationChange);
+    document.addEventListener('location-change', this.handleLocationChange);
     fireTitleChange(this, 'Settings');
+  }
 
-    this._isDark = !!window.localStorage.getItem('dark-theme');
-
+  override firstUpdated() {
     const promises: Array<Promise<unknown>> = [
-      this.$.accountInfo.loadData(),
-      this.$.watchedProjectsEditor.loadData(),
-      this.$.groupList.loadData(),
-      this.$.identities.loadData(),
-      this.$.editPrefs.loadData(),
+      this.accountInfo.loadData(),
+      this.watchedProjectsEditor.loadData(),
+      this.groupList.loadData(),
+      this.identities.loadData(),
     ];
 
-    // TODO(dhruvsri): move this to the service
-    promises.push(
-      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._localMenu = this._cloneMenu(prefs.my);
-        this._localChangeTableColumns =
-          prefs.change_table.length === 0
-            ? columnNames
-            : prefs.change_table.map(column =>
-                column === 'Project' ? 'Repo' : column
-              );
-      })
-    );
-
     promises.push(
       this.restApiService.getConfig().then(config => {
-        this._serverConfig = config;
+        this.serverConfig = config;
         const configPromises: Array<Promise<void>> = [];
 
-        if (this._serverConfig && this._serverConfig.sshd) {
-          configPromises.push(this.$.sshEditor.loadData());
+        if (this.serverConfig?.sshd) {
+          configPromises.push(
+            this.sshEditorPromise.then(sshEditor => sshEditor.loadData())
+          );
         }
 
-        if (
-          this._serverConfig &&
-          this._serverConfig.receive &&
-          this._serverConfig.receive.enable_signed_push
-        ) {
-          configPromises.push(this.$.gpgEditor.loadData());
+        if (this.serverConfig?.receive?.enable_signed_push) {
+          configPromises.push(
+            this.gpgEditorPromise.then(gpgEditor => gpgEditor.loadData())
+          );
         }
 
         configPromises.push(
           getDocsBaseUrl(config, this.restApiService).then(baseUrl => {
-            this._docsBaseUrl = baseUrl;
+            this.docsBaseUrl = baseUrl;
           })
         );
 
@@ -289,38 +300,828 @@
       })
     );
 
-    if (
-      this.params &&
-      this.params.view === GerritView.SETTINGS &&
-      this.params.emailToken
-    ) {
-      promises.push(
-        this.restApiService
-          .confirmEmail(this.params.emailToken)
-          .then(message => {
-            if (message) {
-              fireAlert(this, message);
-            }
-            this.$.emailEditor.loadData();
-          })
-      );
-    } else {
-      promises.push(this.$.emailEditor.loadData());
-    }
+    promises.push(this.emailEditor.loadData());
 
     this._testOnly_loadingPromise = Promise.all(promises).then(() => {
-      this._loading = false;
+      this.loading = false;
 
       // Handle anchor tag for initial load
       this.handleLocationChange();
     });
   }
 
+  static override styles = [
+    sharedStyles,
+    paperStyles,
+    fontStyles,
+    formStyles,
+    menuPageStyles,
+    pageNavStyles,
+    css`
+      :host {
+        color: var(--primary-text-color);
+      }
+      h2 {
+        font-family: var(--header-font-family);
+        font-size: var(--font-size-h2);
+        font-weight: var(--font-weight-h2);
+        line-height: var(--line-height-h2);
+      }
+      .newEmailInput {
+        width: 20em;
+      }
+      #email {
+        margin-bottom: var(--spacing-l);
+      }
+      .filters p {
+        margin-bottom: var(--spacing-l);
+      }
+      .queryExample em {
+        color: violet;
+      }
+      .toggle {
+        align-items: center;
+        display: flex;
+        margin-bottom: var(--spacing-l);
+        margin-right: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    const isLoading = this.loading || this.loading === undefined;
+    return html`<div class="loading" ?hidden=${!isLoading}>Loading...</div>
+      <div ?hidden=${isLoading}>
+        <gr-page-nav class="navStyles">
+          <ul>
+            <li><a href="#Profile">Profile</a></li>
+            <li><a href="#Preferences">Preferences</a></li>
+            <li><a href="#DiffPreferences">Diff Preferences</a></li>
+            <li><a href="#EditPreferences">Edit Preferences</a></li>
+            <li><a href="#Menu">Menu</a></li>
+            <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
+            <li><a href="#Notifications">Notifications</a></li>
+            <li><a href="#EmailAddresses">Email Addresses</a></li>
+            ${when(
+              this.showHttpAuth(),
+              () =>
+                html`<li><a href="#HTTPCredentials">HTTP Credentials</a></li>`
+            )}
+            ${when(
+              this.serverConfig?.sshd,
+              () => html`<li><a href="#SSHKeys"> SSH Keys </a></li>`
+            )}
+            ${when(
+              this.serverConfig?.receive?.enable_signed_push,
+              () => html`<li><a href="#GPGKeys"> GPG Keys </a></li>`
+            )}
+            <li><a href="#Groups">Groups</a></li>
+            <li><a href="#Identities">Identities</a></li>
+            ${when(
+              this.serverConfig?.auth.use_contributor_agreements,
+              () => html`<li><a href="#Agreements">Agreements</a></li>`
+            )}
+            <li><a href="#MailFilters">Mail Filters</a></li>
+            <gr-endpoint-decorator name="settings-menu-item">
+            </gr-endpoint-decorator>
+          </ul>
+        </gr-page-nav>
+        <div class="main gr-form-styles">
+          <h1 class="heading-1">User Settings</h1>
+          <h2
+            id="Profile"
+            class=${this.computeHeaderClass(this.accountInfoChanged)}
+          >
+            Profile
+          </h2>
+          <fieldset id="profile">
+            <gr-account-info
+              id="accountInfo"
+              ?hasUnsavedChanges=${this.accountInfoChanged}
+              @unsaved-changes-changed=${(e: ValueChangedEvent<boolean>) => {
+                this.accountInfoChanged = e.detail.value;
+              }}
+            ></gr-account-info>
+            <gr-button
+              @click=${() => {
+                this.accountInfo.save();
+              }}
+              ?disabled=${!this.accountInfoChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="Preferences"
+            class=${this.computeHeaderClass(this.prefsChanged)}
+          >
+            Preferences
+          </h2>
+          <fieldset id="preferences">
+            ${this.renderTheme()} ${this.renderChangesPerPages()}
+            ${this.renderDateTimeFormat()} ${this.renderEmailNotification()}
+            ${this.renderEmailFormat()} ${this.renderBrowserNotifications()}
+            ${this.renderDefaultBaseForMerges()}
+            ${this.renderRelativeDateInChangeTable()} ${this.renderDiffView()}
+            ${this.renderShowSizeBarsInFileList()}
+            ${this.renderPublishCommentsOnPush()}
+            ${this.renderWorkInProgressByDefault()}
+            ${this.renderDisableKeyboardShortcuts()}
+            ${this.renderDisableTokenHighlighting()}
+            ${this.renderInsertSignedOff()}
+            <gr-button
+              id="savePrefs"
+              @click=${this.handleSavePreferences}
+              ?disabled=${!this.prefsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="DiffPreferences"
+            class=${this.computeHeaderClass(this.diffPrefsChanged)}
+          >
+            Diff Preferences
+          </h2>
+          <fieldset id="diffPreferences">
+            <gr-diff-preferences
+              id="diffPrefs"
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.diffPrefsChanged = e.detail.value;
+              }}
+            ></gr-diff-preferences>
+            <gr-button
+              id="saveDiffPrefs"
+              @click=${() => {
+                this.diffPrefs.save();
+              }}
+              ?disabled=${!this.diffPrefsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <gr-edit-preferences id="EditPreferences"></gr-edit-preferences>
+          <gr-menu-editor id="Menu"></gr-menu-editor>
+          <h2
+            id="ChangeTableColumns"
+            class=${this.computeHeaderClass(this.changeTableChanged)}
+          >
+            Change Table Columns
+          </h2>
+          <fieldset id="changeTableColumns">
+            <gr-change-table-editor
+              .showNumber=${this.showNumber}
+              @show-number-changed=${(e: ValueChangedEvent<boolean>) => {
+                this.showNumber = e.detail.value;
+                this.changeTableChanged = true;
+              }}
+              .displayedColumns=${this.localChangeTableColumns}
+              @displayed-columns-changed=${(e: ValueChangedEvent<string[]>) => {
+                this.localChangeTableColumns = e.detail.value;
+                this.changeTableChanged = true;
+              }}
+            >
+            </gr-change-table-editor>
+            <gr-button
+              id="saveChangeTable"
+              @click=${this.handleSaveChangeTable}
+              ?disabled=${!this.changeTableChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="Notifications"
+            class=${this.computeHeaderClass(this.watchedProjectsChanged)}
+          >
+            Notifications
+          </h2>
+          <fieldset id="watchedProjects">
+            <gr-watched-projects-editor
+              ?hasUnsavedChanges=${this.watchedProjectsChanged}
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.watchedProjectsChanged = e.detail.value;
+              }}
+              id="watchedProjectsEditor"
+            ></gr-watched-projects-editor>
+            <gr-button
+              @click=${() => {
+                this.watchedProjectsEditor.save();
+              }}
+              ?disabled=${!this.watchedProjectsChanged}
+              id="_handleSaveWatchedProjects"
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <h2
+            id="EmailAddresses"
+            class=${this.computeHeaderClass(this.emailsChanged)}
+          >
+            Email Addresses
+          </h2>
+          <fieldset id="email">
+            <gr-email-editor
+              id="emailEditor"
+              ?hasUnsavedChanges=${this.emailsChanged}
+              @has-unsaved-changes-changed=${(
+                e: ValueChangedEvent<boolean>
+              ) => {
+                this.emailsChanged = e.detail.value;
+              }}
+            ></gr-email-editor>
+            <gr-button
+              @click=${() => {
+                this.emailEditor.save();
+              }}
+              ?disabled=${!this.emailsChanged}
+              >Save changes</gr-button
+            >
+          </fieldset>
+          <fieldset id="newEmail">
+            <section>
+              <span class="title">New email address</span>
+              <span class="value">
+                <iron-input
+                  class="newEmailInput"
+                  .bindValue=${this.newEmail}
+                  @bind-value-changed=${(e: BindValueChangeEvent) => {
+                    this.newEmail = e.detail.value;
+                  }}
+                  @keydown=${this.handleNewEmailKeydown}
+                >
+                  <input
+                    class="newEmailInput"
+                    type="text"
+                    ?disabled=${this.addingEmail}
+                    @keydown=${this.handleNewEmailKeydown}
+                    placeholder="email@example.com"
+                  />
+                </iron-input>
+              </span>
+            </section>
+            <section
+              id="verificationSentMessage"
+              ?hidden=${!this.lastSentVerificationEmail}
+            >
+              <p>
+                A verification email was sent to
+                <em>${this.lastSentVerificationEmail}</em>. Please check your
+                inbox.
+              </p>
+            </section>
+            <gr-button
+              ?disabled=${!this.computeAddEmailButtonEnabled()}
+              @click=${this.handleAddEmailButton}
+              >Send verification</gr-button
+            >
+          </fieldset>
+          ${when(
+            this.showHttpAuth(),
+            () => html` <div>
+              <h2 id="HTTPCredentials">HTTP Credentials</h2>
+              <fieldset>
+                <gr-http-password id="httpPass"></gr-http-password>
+              </fieldset>
+            </div>`
+          )}
+          ${when(
+            this.serverConfig?.sshd,
+            () => html`<h2
+                id="SSHKeys"
+                class=${this.computeHeaderClass(this.keysChanged)}
+              >
+                SSH keys
+              </h2>
+              <gr-ssh-editor
+                id="sshEditor"
+                ?hasUnsavedChanges=${this.keysChanged}
+                @has-unsaved-changes-changed=${(
+                  e: ValueChangedEvent<boolean>
+                ) => {
+                  this.keysChanged = e.detail.value;
+                }}
+              ></gr-ssh-editor>`
+          )}
+          ${when(
+            this.serverConfig?.receive?.enable_signed_push,
+            () => html`<div>
+              <h2
+                id="GPGKeys"
+                class=${this.computeHeaderClass(this.gpgKeysChanged)}
+              >
+                GPG keys
+              </h2>
+              <gr-gpg-editor
+                id="gpgEditor"
+                ?hasUnsavedChanges=${this.gpgKeysChanged}
+                @has-unsaved-changes-changed=${(
+                  e: ValueChangedEvent<boolean>
+                ) => {
+                  this.gpgKeysChanged = e.detail.value;
+                }}
+              ></gr-gpg-editor>
+            </div>`
+          )}
+          <h2 id="Groups">Groups</h2>
+          <fieldset>
+            <gr-group-list id="groupList"></gr-group-list>
+          </fieldset>
+          <h2 id="Identities">Identities</h2>
+          <fieldset>
+            <gr-identities
+              id="identities"
+              .serverConfig=${this.serverConfig}
+            ></gr-identities>
+          </fieldset>
+          ${when(
+            this.serverConfig?.auth.use_contributor_agreements,
+            () => html`<h2 id="Agreements">Agreements</h2>
+              <fieldset>
+                <gr-agreements-list id="agreementsList"></gr-agreements-list>
+              </fieldset>`
+          )}
+          <h2 id="MailFilters">Mail Filters</h2>
+          <fieldset class="filters">
+            <p>
+              Gerrit emails include metadata about the change to support writing
+              mail filters.
+            </p>
+            <p>
+              Here are some example Gmail queries that can be used for filters
+              or for searching through archived messages. View the
+              <a
+                href=${this.getFilterDocsLink(this.docsBaseUrl)}
+                target="_blank"
+                rel="nofollow"
+                >Gerrit documentation</a
+              >
+              for the complete set of footers.
+            </p>
+            <table>
+              <tbody>
+                <tr>
+                  <th>Name</th>
+                  <th>Query</th>
+                </tr>
+                <tr>
+                  <td>Changes requesting my review</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Reviewer: <em>Your Name</em>
+                      &lt;<em>your.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes requesting my attention</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Attention: <em>Your Name</em>
+                      &lt;<em>your.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes from a specific owner</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Owner: <em>Owner name</em>
+                      &lt;<em>owner.email@example.com</em>&gt;"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes targeting a specific branch</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Branch: <em>branch-name</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes in a specific project</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Project: <em>project-name</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific Change ID</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Id: <em>Change ID</em>"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific change number</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Number: <em>change number</em>"
+                    </code>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </fieldset>
+          <gr-endpoint-decorator name="settings-screen">
+          </gr-endpoint-decorator>
+        </div>
+      </div>`;
+  }
+
   override disconnectedCallback() {
-    window.removeEventListener('location-change', this.handleLocationChange);
+    document.removeEventListener('location-change', this.handleLocationChange);
     super.disconnectedCallback();
   }
 
+  private renderTheme() {
+    return html`
+      <section>
+        <label class="title" for="themeSelect">Theme</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.localPrefs.theme ?? AppTheme.AUTO}
+            @change=${() => {
+              this.localPrefs.theme = this.themeSelect.value as AppTheme;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="themeSelect">
+              <option value="AUTO">Auto (based on OS prefs)</option>
+              <option value="LIGHT">Light</option>
+              <option value="DARK">Dark</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderChangesPerPages() {
+    return html`
+      <section>
+        <label class="title" for="changesPerPageSelect">Changes per page</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.changes_per_page)}
+            @change=${() => {
+              this.localPrefs.changes_per_page = Number(
+                this.changesPerPageSelect.value
+              ) as 10 | 25 | 50 | 100;
+              this.prefsChanged = true;
+            }}
+          >
+            <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>
+              <option value="100">100 rows per page</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDateTimeFormat() {
+    return html`
+      <section>
+        <label class="title" for="dateTimeFormatSelect">Date/time format</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.date_format)}
+            @change=${() => {
+              this.localPrefs.date_format = this.dateTimeFormatSelect
+                .value as DateFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <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>
+              <option value="EURO">3. Jun ; 03.06.2016</option>
+              <option value="UK">03/06 ; 03/06/2016</option>
+            </select>
+          </gr-select>
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.time_format)}
+            aria-label="Time Format"
+            @change=${() => {
+              this.localPrefs.time_format = this.timeFormatSelect
+                .value as TimeFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="timeFormatSelect">
+              <option value="HHMM_12">4:10 PM</option>
+              <option value="HHMM_24">16:10</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEmailNotification() {
+    return html`
+      <section>
+        <label class="title" for="emailNotificationsSelect"
+          >Email notifications</label
+        >
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.email_strategy)}
+            @change=${() => {
+              this.localPrefs.email_strategy = this.emailNotificationsSelect
+                .value as EmailStrategy;
+              this.prefsChanged = true;
+            }}
+          >
+            <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="DISABLED">None</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderEmailFormat() {
+    if (!this.localPrefs.email_format) return nothing;
+    return html`
+      <section>
+        <label class="title" for="emailFormatSelect">Email format</label>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.email_format)}
+            @change=${() => {
+              this.localPrefs.email_format = this.emailFormatSelect
+                .value as EmailFormat;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="emailFormatSelect">
+              <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+              <option value="PLAINTEXT">Plaintext only</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderBrowserNotifications() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
+      return nothing;
+    if (
+      !this.flagsService.isEnabled(
+        KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
+      ) &&
+      !areNotificationsEnabled(this.account)
+    )
+      return nothing;
+    return html`
+      <section id="allowBrowserNotificationsSection">
+        <div class="title">
+          <label for="allowBrowserNotifications"
+            >Allow browser notifications</label
+          >
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+            target="_blank"
+          >
+            <gr-icon icon="help" title="read documentation"></gr-icon>
+          </a>
+        </div>
+        <span class="value">
+          <input
+            id="allowBrowserNotifications"
+            type="checkbox"
+            ?checked=${this.localPrefs.allow_browser_notifications}
+            @change=${() => {
+              this.localPrefs.allow_browser_notifications =
+                this.allowBrowserNotifications!.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDefaultBaseForMerges() {
+    if (!this.localPrefs.default_base_for_merges) return nothing;
+    return nothing;
+    // TODO: Re-enable respecting the default_base_for_merges preference.
+    // See corresponding TODO in change-model.
+    // return html`
+    //   <section>
+    //     <span class="title">Default Base For Merges</span>
+    //     <span class="value">
+    //       <gr-select
+    //         .bindValue=${this.convertToString(
+    //           this.localPrefs.default_base_for_merges
+    //         )}
+    //         @change=${() => {
+    //           this.localPrefs.default_base_for_merges = this
+    //             .defaultBaseForMergesSelect.value as DefaultBase;
+    //           this.prefsChanged = true;
+    //         }}
+    //       >
+    //         <select id="defaultBaseForMergesSelect">
+    //           <option value="AUTO_MERGE">Auto Merge</option>
+    //           <option value="FIRST_PARENT">First Parent</option>
+    //         </select>
+    //       </gr-select>
+    //     </span>
+    //   </section>
+    // `;
+  }
+
+  private renderRelativeDateInChangeTable() {
+    return html`
+      <section>
+        <label class="title" for="relativeDateInChangeTable"
+          >Show Relative Dates In Changes Table</label
+        >
+        <span class="value">
+          <input
+            id="relativeDateInChangeTable"
+            type="checkbox"
+            ?checked=${this.localPrefs.relative_date_in_change_table}
+            @change=${() => {
+              this.localPrefs.relative_date_in_change_table =
+                this.relativeDateInChangeTable.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDiffView() {
+    return html`
+      <section>
+        <span class="title">Diff view</span>
+        <span class="value">
+          <gr-select
+            .bindValue=${this.convertToString(this.localPrefs.diff_view)}
+            @change=${() => {
+              this.localPrefs.diff_view = this.diffViewSelect
+                .value as DiffViewMode;
+              this.prefsChanged = true;
+            }}
+          >
+            <select id="diffViewSelect">
+              <option value="SIDE_BY_SIDE">Side by side</option>
+              <option value="UNIFIED_DIFF">Unified diff</option>
+            </select>
+          </gr-select>
+        </span>
+      </section>
+    `;
+  }
+
+  private renderShowSizeBarsInFileList() {
+    return html`
+      <section>
+        <label for="showSizeBarsInFileList" class="title"
+          >Show size bars in file list</label
+        >
+        <span class="value">
+          <input
+            id="showSizeBarsInFileList"
+            type="checkbox"
+            ?checked=${this.localPrefs.size_bar_in_change_table}
+            @change=${() => {
+              this.localPrefs.size_bar_in_change_table =
+                this.showSizeBarsInFileList.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderPublishCommentsOnPush() {
+    return html`
+      <section>
+        <label for="publishCommentsOnPush" class="title"
+          >Publish comments on push</label
+        >
+        <span class="value">
+          <input
+            id="publishCommentsOnPush"
+            type="checkbox"
+            ?checked=${this.localPrefs.publish_comments_on_push}
+            @change=${() => {
+              this.localPrefs.publish_comments_on_push =
+                this.publishCommentsOnPush.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderWorkInProgressByDefault() {
+    return html`
+      <section>
+        <label for="workInProgressByDefault" class="title"
+          >Set new changes to "work in progress" by default</label
+        >
+        <span class="value">
+          <input
+            id="workInProgressByDefault"
+            type="checkbox"
+            ?checked=${this.localPrefs.work_in_progress_by_default}
+            @change=${() => {
+              this.localPrefs.work_in_progress_by_default =
+                this.workInProgressByDefault.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDisableKeyboardShortcuts() {
+    return html`
+      <section>
+        <label for="disableKeyboardShortcuts" class="title"
+          >Disable all keyboard shortcuts</label
+        >
+        <span class="value">
+          <input
+            id="disableKeyboardShortcuts"
+            type="checkbox"
+            ?checked=${this.localPrefs.disable_keyboard_shortcuts}
+            @change=${() => {
+              this.localPrefs.disable_keyboard_shortcuts =
+                this.disableKeyboardShortcuts.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderDisableTokenHighlighting() {
+    return html`
+      <section>
+        <label for="disableTokenHighlighting" class="title"
+          >Disable token highlighting on hover</label
+        >
+        <span class="value">
+          <input
+            id="disableTokenHighlighting"
+            type="checkbox"
+            ?checked=${this.localPrefs.disable_token_highlighting}
+            @change=${() => {
+              this.localPrefs.disable_token_highlighting =
+                this.disableTokenHighlighting.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
+  private renderInsertSignedOff() {
+    return html`
+      <section>
+        <label for="insertSignedOff" class="title">
+          Insert Signed-off-by Footer For Inline Edit Changes
+        </label>
+        <span class="value">
+          <input
+            id="insertSignedOff"
+            type="checkbox"
+            ?checked=${this.localPrefs.signed_off_by}
+            @change=${() => {
+              this.localPrefs.signed_off_by = this.insertSignedOff.checked;
+              this.prefsChanged = true;
+            }}
+          />
+        </span>
+      </section>
+    `;
+  }
+
   private readonly handleLocationChange = () => {
     // Handle anchor tag after dom attached
     const urlHash = window.location.hash;
@@ -334,192 +1135,77 @@
   };
 
   reloadAccountDetail() {
-    Promise.all([this.$.accountInfo.loadData(), this.$.emailEditor.loadData()]);
+    Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
   }
 
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _copyPrefs(direction: CopyPrefsDirection) {
-    let to;
-    let from;
+  private copyPrefs(direction: CopyPrefsDirection) {
     if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
-      from = this._localPrefs;
-      to = 'prefs';
+      this.prefs = {
+        ...this.localPrefs,
+      };
     } else {
-      from = this.prefs;
-      to = '_localPrefs';
-    }
-    for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
-      this.set([to, PREFS_SECTION_FIELDS[i]], from[PREFS_SECTION_FIELDS[i]]);
+      this.localPrefs = {
+        ...this.prefs,
+      };
     }
   }
 
-  _cloneMenu(prefs: TopMenuItemInfo[]) {
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    return prefs.map(({id, ...item}) => item);
+  // private but used in test
+  handleSavePreferences() {
+    return this.getUserModel().updatePreferences(this.localPrefs);
   }
 
-  @observe('_localChangeTableColumns', '_showNumber')
-  _handleChangeTableChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._changeTableChanged = true;
-  }
-
-  @observe('_localPrefs.*')
-  _handlePrefsChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._prefsChanged = true;
-  }
-
-  _handleRelativeDateInChangeTable() {
-    this.set(
-      '_localPrefs.relative_date_in_change_table',
-      this.$.relativeDateInChangeTable.checked
-    );
-  }
-
-  _handleShowSizeBarsInFileListChanged() {
-    this.set(
-      '_localPrefs.size_bar_in_change_table',
-      this.$.showSizeBarsInFileList.checked
-    );
-  }
-
-  _handlePublishCommentsOnPushChanged() {
-    this.set(
-      '_localPrefs.publish_comments_on_push',
-      this.$.publishCommentsOnPush.checked
-    );
-  }
-
-  _handleDisableKeyboardShortcutsChanged() {
-    this.set(
-      '_localPrefs.disable_keyboard_shortcuts',
-      this.$.disableKeyboardShortcuts.checked
-    );
-  }
-
-  _handleDisableTokenHighlightingChanged() {
-    this.set(
-      '_localPrefs.disable_token_highlighting',
-      this.$.disableTokenHighlighting.checked
-    );
-  }
-
-  _handleWorkInProgressByDefault() {
-    this.set(
-      '_localPrefs.work_in_progress_by_default',
-      this.$.workInProgressByDefault.checked
-    );
-  }
-
-  _handleInsertSignedOff() {
-    this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
-  }
-
-  @observe('_localMenu.splices')
-  _handleMenuChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._menuChanged = true;
-  }
-
-  _handleSaveAccountInfo() {
-    this.$.accountInfo.save();
-  }
-
-  _handleSavePreferences() {
-    this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
-
+  // private but used in test
+  handleSaveChangeTable() {
+    this.prefs.change_table = this.localChangeTableColumns;
+    this.prefs.legacycid_in_change_table = this.showNumber;
     return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._prefsChanged = false;
+      this.changeTableChanged = false;
     });
   }
 
-  _handleSaveChangeTable() {
-    this.set('prefs.change_table', this._localChangeTableColumns);
-    this.set('prefs.legacycid_in_change_table', this._showNumber);
-    return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._changeTableChanged = false;
-    });
-  }
-
-  _handleSaveDiffPreferences() {
-    this.$.diffPrefs.save();
-  }
-
-  _handleSaveEditPreferences() {
-    this.$.editPrefs.save();
-  }
-
-  _handleSaveMenu() {
-    this.set('prefs.my', this._localMenu);
-    return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._menuChanged = false;
-    });
-  }
-
-  _handleResetMenuButton() {
-    return this.restApiService.getDefaultPreferences().then(data => {
-      if (data?.my) {
-        this._localMenu = this._cloneMenu(data.my);
-      }
-    });
-  }
-
-  _handleSaveWatchedProjects() {
-    this.$.watchedProjectsEditor.save();
-  }
-
-  _computeHeaderClass(changed?: boolean) {
+  private computeHeaderClass(changed?: boolean) {
     return changed ? 'edited' : '';
   }
 
-  _handleSaveEmails() {
-    this.$.emailEditor.save();
-  }
-
-  _handleNewEmailKeydown(e: KeyboardEvent) {
-    if (e.keyCode === 13) {
-      // Enter
+  // private but used in test
+  handleNewEmailKeydown(e: KeyboardEvent) {
+    if (e.key === 'Enter') {
       e.stopPropagation();
-      this._handleAddEmailButton();
+      this.handleAddEmailButton();
     }
   }
 
-  _isNewEmailValid(newEmail?: string): newEmail is string {
+  // private but used in test
+  isNewEmailValid(newEmail?: string): newEmail is string {
     return !!newEmail && newEmail.includes('@');
   }
 
-  _computeAddEmailButtonEnabled(newEmail?: string, addingEmail?: boolean) {
-    return this._isNewEmailValid(newEmail) && !addingEmail;
+  // private but used in test
+  computeAddEmailButtonEnabled() {
+    return this.isNewEmailValid(this.newEmail) && !this.addingEmail;
   }
 
-  _handleAddEmailButton() {
-    if (!this._isNewEmailValid(this._newEmail)) return;
+  // private but used in test
+  handleAddEmailButton() {
+    if (!this.isNewEmailValid(this.newEmail)) return;
 
-    this._addingEmail = true;
-    this.restApiService.addAccountEmail(this._newEmail).then(response => {
-      this._addingEmail = false;
+    this.addingEmail = true;
+    this.restApiService.addAccountEmail(this.newEmail).then(response => {
+      this.addingEmail = false;
 
       // If it was unsuccessful.
       if (response.status < 200 || response.status >= 300) {
         return;
       }
 
-      this._lastSentVerificationEmail = this._newEmail;
-      this._newEmail = '';
+      this.lastSentVerificationEmail = this.newEmail;
+      this.newEmail = '';
     });
   }
 
-  _getFilterDocsLink(docsBaseUrl?: string | null) {
+  // private but used in test
+  getFilterDocsLink(docsBaseUrl?: string | null) {
     let base = docsBaseUrl;
     if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
       base = GERRIT_DOCS_BASE_URL;
@@ -531,23 +1217,11 @@
     return base + GERRIT_DOCS_FILTER_PATH;
   }
 
-  _handleToggleDark() {
-    if (this._isDark) {
-      window.localStorage.removeItem('dark-theme');
-    } else {
-      window.localStorage.setItem('dark-theme', 'true');
-    }
-    this.reloadPage();
-  }
-
-  reloadPage() {
-    windowLocationReload();
-  }
-
-  _showHttpAuth(config?: ServerInfo) {
-    if (config && config.auth && config.auth.git_basic_auth_policy) {
+  // private but used in test
+  showHttpAuth() {
+    if (this.serverConfig?.auth?.git_basic_auth_policy) {
       return HTTP_AUTH.includes(
-        config.auth.git_basic_auth_policy.toUpperCase()
+        this.serverConfig.auth.git_basic_auth_policy.toUpperCase()
       );
     }
 
@@ -555,59 +1229,12 @@
   }
 
   /**
-   * Work around a issue on iOS when clicking turns into double tap
-   */
-  _onTapDarkToggle(e: Event) {
-    e.preventDefault();
-  }
-
-  _handleChangesPerPage() {
-    this.set(
-      '_localPrefs.changes_per_page',
-      Number(this.$.changesPerPageSelect.value)
-    );
-  }
-
-  _handleDateFormat() {
-    this.set('_localPrefs.date_format', this.$.dateTimeFormatSelect.value);
-  }
-
-  _handleTimeFormat() {
-    this.set('_localPrefs.time_format', this.$.timeFormatSelect.value);
-  }
-
-  _handleEmailStrategy() {
-    this.set(
-      '_localPrefs.email_strategy',
-      this.$.emailNotificationsSelect.value
-    );
-  }
-
-  _handleEmailFormat() {
-    this.set('_localPrefs.email_format', this.$.emailFormatSelect.value);
-  }
-
-  _handleDefaultBaseForMerges() {
-    this.set(
-      '_localPrefs.default_base_for_merges',
-      this.$.defaultBaseForMergesSelect.value
-    );
-  }
-
-  _handleDiffView() {
-    this.set(
-      '_localPrefs.diff_view',
-      this.$.diffViewSelect.value as DiffViewMode
-    );
-  }
-
-  /**
    * bind-value has type string so we have to convert anything inputed
    * to string.
    *
    * This is so typescript template checker doesn't fail.
    */
-  _convertToString(
+  private convertToString(
     key?:
       | DateFormat
       | DefaultBase
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
deleted file mode 100644
index c1ebcac..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ /dev/null
@@ -1,614 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      color: var(--primary-text-color);
-    }
-    h2 {
-      font-family: var(--header-font-family);
-      font-size: var(--font-size-h2);
-      font-weight: var(--font-weight-h2);
-      line-height: var(--line-height-h2);
-    }
-    .newEmailInput {
-      width: 20em;
-    }
-    #email {
-      margin-bottom: var(--spacing-l);
-    }
-    .main section.darkToggle {
-      display: block;
-    }
-    .filters p,
-    .darkToggle p {
-      margin-bottom: var(--spacing-l);
-    }
-    .queryExample em {
-      color: violet;
-    }
-    .toggle {
-      align-items: center;
-      display: flex;
-      margin-bottom: var(--spacing-l);
-      margin-right: var(--spacing-l);
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-menu-page-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-page-nav-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-  <div hidden$="[[_loading]]" hidden="">
-    <gr-page-nav class="navStyles">
-      <ul>
-        <li><a href="#Profile">Profile</a></li>
-        <li><a href="#Preferences">Preferences</a></li>
-        <li><a href="#DiffPreferences">Diff Preferences</a></li>
-        <li><a href="#EditPreferences">Edit Preferences</a></li>
-        <li><a href="#Menu">Menu</a></li>
-        <li><a href="#ChangeTableColumns">Change Table Columns</a></li>
-        <li><a href="#Notifications">Notifications</a></li>
-        <li><a href="#EmailAddresses">Email Addresses</a></li>
-        <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-          <li><a href="#HTTPCredentials">HTTP Credentials</a></li>
-        </template>
-        <li hidden$="[[!_serverConfig.sshd]]">
-          <a href="#SSHKeys"> SSH Keys </a>
-        </li>
-        <li hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-          <a href="#GPGKeys"> GPG Keys </a>
-        </li>
-        <li><a href="#Groups">Groups</a></li>
-        <li><a href="#Identities">Identities</a></li>
-        <template
-          is="dom-if"
-          if="[[_serverConfig.auth.use_contributor_agreements]]"
-        >
-          <li>
-            <a href="#Agreements">Agreements</a>
-          </li>
-        </template>
-        <li><a href="#MailFilters">Mail Filters</a></li>
-        <gr-endpoint-decorator name="settings-menu-item">
-        </gr-endpoint-decorator>
-      </ul>
-    </gr-page-nav>
-    <div class="main gr-form-styles">
-      <h1 class="heading-1">User Settings</h1>
-      <h2 id="Theme">Theme</h2>
-      <section class="darkToggle">
-        <div class="toggle">
-          <paper-toggle-button
-            aria-labelledby="darkThemeToggleLabel"
-            checked="[[_isDark]]"
-            on-change="_handleToggleDark"
-            on-click="_onTapDarkToggle"
-          ></paper-toggle-button>
-          <div id="darkThemeToggleLabel">
-            Dark theme (the toggle reloads the page)
-          </div>
-        </div>
-      </section>
-      <h2 id="Profile" class$="[[_computeHeaderClass(_accountInfoChanged)]]">
-        Profile
-      </h2>
-      <fieldset id="profile">
-        <gr-account-info
-          id="accountInfo"
-          has-unsaved-changes="{{_accountInfoChanged}}"
-        ></gr-account-info>
-        <gr-button
-          on-click="_handleSaveAccountInfo"
-          disabled="[[!_accountInfoChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="Preferences" class$="[[_computeHeaderClass(_prefsChanged)]]">
-        Preferences
-      </h2>
-      <fieldset id="preferences">
-        <section>
-          <label class="title" for="changesPerPageSelect"
-            >Changes per page</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.changes_per_page)]]"
-              on-change="_handleChangesPerPage"
-            >
-              <select id="changesPerPageSelect">
-                <option value="10">10 rows per page</option>
-                <option value="25">25 rows per page</option>
-                <option value="50">50 rows per page</option>
-                <option value="100">100 rows per page</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="dateTimeFormatSelect"
-            >Date/time format</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.date_format)]]"
-              on-change="_handleDateFormat"
-            >
-              <select id="dateTimeFormatSelect">
-                <option value="STD">Jun 3 ; Jun 3, 2016</option>
-                <option value="US">06/03 ; 06/03/16</option>
-                <option value="ISO">06-03 ; 2016-06-03</option>
-                <option value="EURO">3. Jun ; 03.06.2016</option>
-                <option value="UK">03/06 ; 03/06/2016</option>
-              </select>
-            </gr-select>
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.time_format)]]"
-              aria-label="Time Format"
-              on-change="_handleTimeFormat"
-            >
-              <select id="timeFormatSelect">
-                <option value="HHMM_12">4:10 PM</option>
-                <option value="HHMM_24">16:10</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="emailNotificationsSelect"
-            >Email notifications</label
-          >
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.email_strategy)]]"
-              on-change="_handleEmailStrategy"
-            >
-              <select id="emailNotificationsSelect">
-                <option value="CC_ON_OWN_COMMENTS">Every comment</option>
-                <option value="ENABLED">Only comments left by others</option>
-                <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$="[[!_convertToString(_localPrefs.email_format)]]">
-          <label class="title" for="emailFormatSelect">Email format</label>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.email_format)]]"
-              on-change="_handleEmailFormat"
-            >
-              <select id="emailFormatSelect">
-                <option value="HTML_PLAINTEXT">HTML and plaintext</option>
-                <option value="PLAINTEXT">Plaintext only</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section hidden$="[[!_localPrefs.default_base_for_merges]]">
-          <span class="title">Default Base For Merges</span>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.default_base_for_merges)]]"
-              on-change="_handleDefaultBaseForMerges"
-            >
-              <select id="defaultBaseForMergesSelect">
-                <option value="AUTO_MERGE">Auto Merge</option>
-                <option value="FIRST_PARENT">First Parent</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label class="title" for="relativeDateInChangeTable"
-            >Show Relative Dates In Changes Table</label
-          >
-          <span class="value">
-            <input
-              id="relativeDateInChangeTable"
-              type="checkbox"
-              checked$="[[_localPrefs.relative_date_in_change_table]]"
-              on-change="_handleRelativeDateInChangeTable"
-            />
-          </span>
-        </section>
-        <section>
-          <span class="title">Diff view</span>
-          <span class="value">
-            <gr-select
-              bind-value="[[_convertToString(_localPrefs.diff_view)]]"
-              on-change="_handleDiffView"
-            >
-              <select id="diffViewSelect">
-                <option value="SIDE_BY_SIDE">Side by side</option>
-                <option value="UNIFIED_DIFF">Unified diff</option>
-              </select>
-            </gr-select>
-          </span>
-        </section>
-        <section>
-          <label for="showSizeBarsInFileList" class="title"
-            >Show size bars in file list</label
-          >
-          <span class="value">
-            <input
-              id="showSizeBarsInFileList"
-              type="checkbox"
-              checked$="[[_localPrefs.size_bar_in_change_table]]"
-              on-change="_handleShowSizeBarsInFileListChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="publishCommentsOnPush" class="title"
-            >Publish comments on push</label
-          >
-          <span class="value">
-            <input
-              id="publishCommentsOnPush"
-              type="checkbox"
-              checked$="[[_localPrefs.publish_comments_on_push]]"
-              on-change="_handlePublishCommentsOnPushChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="workInProgressByDefault" class="title"
-            >Set new changes to "work in progress" by default</label
-          >
-          <span class="value">
-            <input
-              id="workInProgressByDefault"
-              type="checkbox"
-              checked$="[[_localPrefs.work_in_progress_by_default]]"
-              on-change="_handleWorkInProgressByDefault"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="disableKeyboardShortcuts" class="title"
-            >Disable all keyboard shortcuts</label
-          >
-          <span class="value">
-            <input
-              id="disableKeyboardShortcuts"
-              type="checkbox"
-              checked$="[[_localPrefs.disable_keyboard_shortcuts]]"
-              on-change="_handleDisableKeyboardShortcutsChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="disableTokenHighlighting" class="title"
-            >Disable token highlighting on hover</label
-          >
-          <span class="value">
-            <input
-              id="disableTokenHighlighting"
-              type="checkbox"
-              checked$="[[_localPrefs.disable_token_highlighting]]"
-              on-change="_handleDisableTokenHighlightingChanged"
-            />
-          </span>
-        </section>
-        <section>
-          <label for="insertSignedOff" class="title">
-            Insert Signed-off-by Footer For Inline Edit Changes
-          </label>
-          <span class="value">
-            <input
-              id="insertSignedOff"
-              type="checkbox"
-              checked$="[[_localPrefs.signed_off_by]]"
-              on-change="_handleInsertSignedOff"
-            />
-          </span>
-        </section>
-        <gr-button
-          id="savePrefs"
-          on-click="_handleSavePreferences"
-          disabled="[[!_prefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="DiffPreferences"
-        class$="[[_computeHeaderClass(_diffPrefsChanged)]]"
-      >
-        Diff Preferences
-      </h2>
-      <fieldset id="diffPreferences">
-        <gr-diff-preferences
-          id="diffPrefs"
-          has-unsaved-changes="{{_diffPrefsChanged}}"
-        ></gr-diff-preferences>
-        <gr-button
-          id="saveDiffPrefs"
-          on-click="_handleSaveDiffPreferences"
-          disabled$="[[!_diffPrefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="EditPreferences"
-        class$="[[_computeHeaderClass(_editPrefsChanged)]]"
-      >
-        Edit Preferences
-      </h2>
-      <fieldset id="editPreferences">
-        <gr-edit-preferences
-          id="editPrefs"
-          has-unsaved-changes="{{_editPrefsChanged}}"
-        ></gr-edit-preferences>
-        <gr-button
-          id="saveEditPrefs"
-          on-click="_handleSaveEditPreferences"
-          disabled$="[[!_editPrefsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
-      <fieldset id="menu">
-        <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
-        <gr-button
-          id="saveMenu"
-          on-click="_handleSaveMenu"
-          disabled="[[!_menuChanged]]"
-          >Save changes</gr-button
-        >
-        <gr-button id="resetButton" link="" on-click="_handleResetMenuButton"
-          >Reset</gr-button
-        >
-      </fieldset>
-      <h2
-        id="ChangeTableColumns"
-        class$="[[_computeHeaderClass(_changeTableChanged)]]"
-      >
-        Change Table Columns
-      </h2>
-      <fieldset id="changeTableColumns">
-        <gr-change-table-editor
-          show-number="{{_showNumber}}"
-          server-config="[[_serverConfig]]"
-          displayed-columns="{{_localChangeTableColumns}}"
-        >
-        </gr-change-table-editor>
-        <gr-button
-          id="saveChangeTable"
-          on-click="_handleSaveChangeTable"
-          disabled="[[!_changeTableChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2
-        id="Notifications"
-        class$="[[_computeHeaderClass(_watchedProjectsChanged)]]"
-      >
-        Notifications
-      </h2>
-      <fieldset id="watchedProjects">
-        <gr-watched-projects-editor
-          has-unsaved-changes="{{_watchedProjectsChanged}}"
-          id="watchedProjectsEditor"
-        ></gr-watched-projects-editor>
-        <gr-button
-          on-click="_handleSaveWatchedProjects"
-          disabled$="[[!_watchedProjectsChanged]]"
-          id="_handleSaveWatchedProjects"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <h2 id="EmailAddresses" class$="[[_computeHeaderClass(_emailsChanged)]]">
-        Email Addresses
-      </h2>
-      <fieldset id="email">
-        <gr-email-editor
-          id="emailEditor"
-          has-unsaved-changes="{{_emailsChanged}}"
-        ></gr-email-editor>
-        <gr-button on-click="_handleSaveEmails" disabled$="[[!_emailsChanged]]"
-          >Save changes</gr-button
-        >
-      </fieldset>
-      <fieldset id="newEmail">
-        <section>
-          <span class="title">New email address</span>
-          <span class="value">
-            <iron-input
-              class="newEmailInput"
-              bind-value="{{_newEmail}}"
-              type="text"
-              on-keydown="_handleNewEmailKeydown"
-              placeholder="email@example.com"
-            >
-              <input
-                class="newEmailInput"
-                type="text"
-                disabled="[[_addingEmail]]"
-                on-keydown="_handleNewEmailKeydown"
-                placeholder="email@example.com"
-              />
-            </iron-input>
-          </span>
-        </section>
-        <section
-          id="verificationSentMessage"
-          hidden$="[[!_lastSentVerificationEmail]]"
-        >
-          <p>
-            A verification email was sent to
-            <em>[[_lastSentVerificationEmail]]</em>. Please check your inbox.
-          </p>
-        </section>
-        <gr-button
-          disabled="[[!_computeAddEmailButtonEnabled(_newEmail, _addingEmail)]]"
-          on-click="_handleAddEmailButton"
-          >Send verification</gr-button
-        >
-      </fieldset>
-      <template is="dom-if" if="[[_showHttpAuth(_serverConfig)]]">
-        <div>
-          <h2 id="HTTPCredentials">HTTP Credentials</h2>
-          <fieldset>
-            <gr-http-password id="httpPass"></gr-http-password>
-          </fieldset>
-        </div>
-      </template>
-      <div hidden$="[[!_serverConfig.sshd]]">
-        <h2 id="SSHKeys" class$="[[_computeHeaderClass(_keysChanged)]]">
-          SSH keys
-        </h2>
-        <gr-ssh-editor
-          id="sshEditor"
-          has-unsaved-changes="{{_keysChanged}}"
-        ></gr-ssh-editor>
-      </div>
-      <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
-        <h2 id="GPGKeys" class$="[[_computeHeaderClass(_gpgKeysChanged)]]">
-          GPG keys
-        </h2>
-        <gr-gpg-editor
-          id="gpgEditor"
-          has-unsaved-changes="{{_gpgKeysChanged}}"
-        ></gr-gpg-editor>
-      </div>
-      <h2 id="Groups">Groups</h2>
-      <fieldset>
-        <gr-group-list id="groupList"></gr-group-list>
-      </fieldset>
-      <h2 id="Identities">Identities</h2>
-      <fieldset>
-        <gr-identities
-          id="identities"
-          server-config="[[_serverConfig]]"
-        ></gr-identities>
-      </fieldset>
-      <template
-        is="dom-if"
-        if="[[_serverConfig.auth.use_contributor_agreements]]"
-      >
-        <h2 id="Agreements">Agreements</h2>
-        <fieldset>
-          <gr-agreements-list id="agreementsList"></gr-agreements-list>
-        </fieldset>
-      </template>
-      <h2 id="MailFilters">Mail Filters</h2>
-      <fieldset class="filters">
-        <p>
-          Gerrit emails include metadata about the change to support writing
-          mail filters.
-        </p>
-        <p>
-          Here are some example Gmail queries that can be used for filters or
-          for searching through archived messages. View the
-          <a
-            href$="[[_getFilterDocsLink(_docsBaseUrl)]]"
-            target="_blank"
-            rel="nofollow"
-            >Gerrit documentation</a
-          >
-          for the complete set of footers.
-        </p>
-        <table>
-          <tbody>
-            <tr>
-              <th>Name</th>
-              <th>Query</th>
-            </tr>
-            <tr>
-              <td>Changes requesting my review</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Reviewer: <em>Your Name</em>
-                  &lt;<em>your.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes requesting my attention</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Attention: <em>Your Name</em>
-                  &lt;<em>your.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes from a specific owner</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Owner: <em>Owner name</em>
-                  &lt;<em>owner.email@example.com</em>&gt;"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes targeting a specific branch</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Branch: <em>branch-name</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Changes in a specific project</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Project: <em>project-name</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Messages related to a specific Change ID</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Change-Id: <em>Change ID</em>"
-                </code>
-              </td>
-            </tr>
-            <tr>
-              <td>Messages related to a specific change number</td>
-              <td>
-                <code class="queryExample">
-                  "Gerrit-Change-Number: <em>change number</em>"
-                </code>
-              </td>
-            </tr>
-          </tbody>
-        </table>
-      </fieldset>
-      <gr-endpoint-decorator name="settings-screen"> </gr-endpoint-decorator>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 61876fe..a5ef86d 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -1,26 +1,18 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-settings-view';
 import {GrSettingsView} from './gr-settings-view';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {GerritView} from '../../../services/router/router-model';
-import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  queryAll,
+  queryAndAssert,
+  stubFlags,
+  stubRestApi,
+  waitEventLoop,
+} from '../../../test/test-utils';
 import {
   AuthInfo,
   AccountDetailInfo,
@@ -36,19 +28,17 @@
   DiffViewMode,
   EmailFormat,
   EmailStrategy,
+  AppTheme,
   TimeFormat,
 } from '../../../constants/constants';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {
   createAccountDetailWithId,
   createPreferences,
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {GrSelect} from '../../shared/gr-select/gr-select';
-import {AppElementSettingsParam} from '../../gr-app-types';
-
-const basicFixture = fixtureFromElement('gr-settings-view');
-const blankFixture = fixtureFromElement('div');
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
 
 suite('gr-settings-view tests', () => {
   let element: GrSettingsView;
@@ -57,7 +47,7 @@
   let config: ServerInfo;
 
   function valueOf(title: string, id: string) {
-    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    const sections = queryAll(element, `#${id} section`);
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -102,6 +92,7 @@
     preferences = {
       ...createPreferences(),
       changes_per_page: 25,
+      theme: AppTheme.LIGHT,
       date_format: DateFormat.UK,
       time_format: TimeFormat.HHMM_12,
       diff_view: DiffViewMode.UNIFIED,
@@ -122,44 +113,447 @@
     stubRestApi('getPreferences').returns(Promise.resolve(preferences));
     stubRestApi('getAccountEmails').returns(Promise.resolve(undefined));
     stubRestApi('getConfig').returns(Promise.resolve(config));
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-settings-view></gr-settings-view>`);
 
     // Allow the element to render.
     if (element._testOnly_loadingPromise)
       await element._testOnly_loadingPromise;
+    await element.updateComplete;
   });
 
-  test('theme changing', async () => {
-    const reloadStub = sinon.stub(element, 'reloadPage');
-
-    window.localStorage.removeItem('dark-theme');
-    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
-    const themeToggle = queryAndAssert(
+  test('renders', async () => {
+    sinon
+      .stub(element, 'getFilterDocsLink')
+      .returns('https://test.com/user-notify.html');
+    element.docsBaseUrl = 'https://test.com';
+    await element.updateComplete;
+    // this cannot be formatted with /* HTML */, because it breaks test
+    assert.shadowDom.equal(
       element,
-      '.darkToggle paper-toggle-button'
+      /* HTML*/ `<div
+        class="loading"
+        hidden=""
+      >
+        Loading...
+      </div>
+      <div>
+        <gr-page-nav class="navStyles">
+          <ul>
+            <li><a href="#Profile"> Profile </a></li>
+            <li><a href="#Preferences"> Preferences </a></li>
+            <li><a href="#DiffPreferences"> Diff Preferences </a></li>
+            <li><a href="#EditPreferences"> Edit Preferences </a></li>
+            <li><a href="#Menu"> Menu </a></li>
+            <li><a href="#ChangeTableColumns"> Change Table Columns </a></li>
+            <li><a href="#Notifications"> Notifications </a></li>
+            <li><a href="#EmailAddresses"> Email Addresses </a></li>
+            <li><a href="#Groups"> Groups </a></li>
+            <li><a href="#Identities"> Identities </a></li>
+            <li><a href="#MailFilters"> Mail Filters </a></li>
+            <gr-endpoint-decorator name="settings-menu-item">
+            </gr-endpoint-decorator>
+          </ul>
+        </gr-page-nav>
+        <div class="gr-form-styles main">
+          <h1 class="heading-1">User Settings</h1>
+          <h2 id="Profile">Profile</h2>
+          <fieldset id="profile">
+            <gr-account-info id="accountInfo"> </gr-account-info>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="Preferences">Preferences</h2>
+          <fieldset id="preferences">
+            <section>
+              <label class="title" for="themeSelect">
+                Theme
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="themeSelect">
+                    <option value="AUTO">Auto (based on OS prefs)</option>
+                    <option value="LIGHT">Light</option>
+                    <option value="DARK">Dark</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="changesPerPageSelect">
+                Changes per page
+              </label>
+              <span class="value">
+                <gr-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>
+                    <option value="100">100 rows per page</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="dateTimeFormatSelect">
+                Date/time format
+              </label>
+              <span class="value">
+                <gr-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>
+                    <option value="EURO">3. Jun ; 03.06.2016</option>
+                    <option value="UK">03/06 ; 03/06/2016</option>
+                  </select>
+                </gr-select>
+                <gr-select aria-label="Time Format">
+                  <select id="timeFormatSelect">
+                    <option value="HHMM_12">4:10 PM</option>
+                    <option value="HHMM_24">16:10</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailNotificationsSelect">
+                Email notifications
+              </label>
+              <span class="value">
+                <gr-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="DISABLED">None</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="emailFormatSelect">
+                Email format
+              </label>
+              <span class="value">
+                <gr-select>
+                  <select id="emailFormatSelect">
+                    <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                    <option value="PLAINTEXT">Plaintext only</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="relativeDateInChangeTable">
+                Show Relative Dates In Changes Table
+              </label>
+              <span class="value">
+                <input id="relativeDateInChangeTable" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <span class="title"> Diff view </span>
+              <span class="value">
+                <gr-select>
+                  <select id="diffViewSelect">
+                    <option value="SIDE_BY_SIDE">Side by side</option>
+                    <option value="UNIFIED_DIFF">Unified diff</option>
+                  </select>
+                </gr-select>
+              </span>
+            </section>
+            <section>
+              <label class="title" for="showSizeBarsInFileList">
+                Show size bars in file list
+              </label>
+              <span class="value">
+                <input
+                  checked=""
+                  id="showSizeBarsInFileList"
+                  type="checkbox"
+                />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="publishCommentsOnPush">
+                Publish comments on push
+              </label>
+              <span class="value">
+                <input id="publishCommentsOnPush" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="workInProgressByDefault">
+                Set new changes to "work in progress" by default
+              </label>
+              <span class="value">
+                <input id="workInProgressByDefault" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="disableKeyboardShortcuts">
+                Disable all keyboard shortcuts
+              </label>
+              <span class="value">
+                <input id="disableKeyboardShortcuts" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="disableTokenHighlighting">
+                Disable token highlighting on hover
+              </label>
+              <span class="value">
+                <input id="disableTokenHighlighting" type="checkbox" />
+              </span>
+            </section>
+            <section>
+              <label class="title" for="insertSignedOff">
+                Insert Signed-off-by Footer For Inline Edit Changes
+              </label>
+              <span class="value">
+                <input id="insertSignedOff" type="checkbox" />
+              </span>
+            </section>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="savePrefs"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="DiffPreferences">Diff Preferences</h2>
+          <fieldset id="diffPreferences">
+            <gr-diff-preferences id="diffPrefs"> </gr-diff-preferences>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="saveDiffPrefs"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <gr-edit-preferences id="EditPreferences"> </gr-edit-preferences>
+          <gr-menu-editor id="Menu"> </gr-menu-editor>
+          <h2 id="ChangeTableColumns">Change Table Columns</h2>
+          <fieldset id="changeTableColumns">
+            <gr-change-table-editor> </gr-change-table-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="saveChangeTable"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="Notifications">Notifications</h2>
+          <fieldset id="watchedProjects">
+            <gr-watched-projects-editor id="watchedProjectsEditor">
+            </gr-watched-projects-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="_handleSaveWatchedProjects"
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <h2 id="EmailAddresses">Email Addresses</h2>
+          <fieldset id="email">
+            <gr-email-editor id="emailEditor"> </gr-email-editor>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <fieldset id="newEmail">
+            <section>
+              <span class="title"> New email address </span>
+              <span class="value">
+                <iron-input class="newEmailInput">
+                  <input
+                    class="newEmailInput"
+                    placeholder="email@example.com"
+                    type="text"
+                  />
+                </iron-input>
+              </span>
+            </section>
+            <section hidden="" id="verificationSentMessage">
+              <p>
+                A verification email was sent to <em>
+                </em>
+               . Please check your
+                inbox.
+              </p>
+            </section>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Send verification
+            </gr-button>
+          </fieldset> 
+          <h2 id="Groups">Groups</h2>
+          <fieldset><gr-group-list id="groupList"> </gr-group-list></fieldset>
+          <h2 id="Identities">Identities</h2>
+          <fieldset>
+            <gr-identities id="identities"> </gr-identities>
+          </fieldset>
+          <h2 id="MailFilters">Mail Filters</h2>
+          <fieldset class="filters">
+            <p>
+              Gerrit emails include metadata about the change to support writing
+              mail filters.
+            </p>
+            <p>
+              Here are some example Gmail queries that can be used for filters
+              or for searching through archived messages. View the
+              <a
+                href="https://test.com/user-notify.html"
+                rel="nofollow"
+                target="_blank"
+              >
+                Gerrit documentation
+              </a>
+              for the complete set of footers.
+            </p>
+            <table>
+              <tbody>
+                <tr>
+                  <th>Name</th>
+                  <th>Query</th>
+                </tr>
+                <tr>
+                  <td>Changes requesting my review</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Reviewer: <em> Your Name </em> <
+                      <em> your.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes requesting my attention</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Attention: <em> Your Name </em> <
+                      <em> your.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes from a specific owner</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Owner: <em> Owner name </em> <
+                      <em> owner.email@example.com </em> >"
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes targeting a specific branch</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Branch: <em> branch-name </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Changes in a specific project</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Project: <em> project-name </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific Change ID</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Id: <em> Change ID </em> "
+                    </code>
+                  </td>
+                </tr>
+                <tr>
+                  <td>Messages related to a specific change number</td>
+                  <td>
+                    <code class="queryExample">
+                      "Gerrit-Change-Number: <em> change number </em> "
+                    </code>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </fieldset>
+          <gr-endpoint-decorator name="settings-screen">
+          </gr-endpoint-decorator>
+        </div>
+      </div>`
     );
-    MockInteractions.tap(themeToggle);
-    assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
-    assert.isTrue(reloadStub.calledOnce);
-
-    element._isDark = true;
-    await flush();
-    MockInteractions.tap(themeToggle);
-    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
-    assert.isTrue(reloadStub.calledTwice);
   });
 
-  test('calls the title-change event', () => {
+  test('allow browser notifications', async () => {
+    stubFlags('isEnabled').returns(true);
+    element = await fixture(html`<gr-settings-view></gr-settings-view>`);
+    element.account = createAccountDetailWithId();
+    await element.updateComplete;
+    assert.dom.equal(
+      queryAndAssert(element, '#allowBrowserNotificationsSection'),
+      /* HTML */ `<section id="allowBrowserNotificationsSection">
+        <div class="title">
+          <label for="allowBrowserNotifications">
+            Allow browser notifications
+          </label>
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+            target="_blank"
+          >
+            <gr-icon icon="help" title="read documentation"> </gr-icon>
+          </a>
+        </div>
+        <span class="value">
+          <input checked="" id="allowBrowserNotifications" type="checkbox" />
+        </span>
+      </section>`
+    );
+  });
+
+  test('calls the title-change event', async () => {
     const titleChangedStub = sinon.stub();
 
     // Create a new view.
     const newElement = document.createElement('gr-settings-view');
     newElement.addEventListener('title-change', titleChangedStub);
 
-    const blank = blankFixture.instantiate();
-    blank.appendChild(newElement);
+    const div = await fixture(html`<div></div>`);
+    div.appendChild(newElement);
 
-    flush();
+    await waitEventLoop();
 
     assert.isTrue(titleChangedStub.called);
     assert.equal(titleChangedStub.getCall(0).args[0].detail.title, 'Settings');
@@ -177,6 +571,10 @@
       preferences.changes_per_page
     );
     assert.equal(
+      (valueOf('Theme', 'preferences').firstElementChild as GrSelect).bindValue,
+      preferences.theme
+    );
+    assert.equal(
       (
         valueOf('Date/time format', 'preferences')!
           .firstElementChild as GrSelect
@@ -202,13 +600,6 @@
     );
     assert.equal(
       (
-        valueOf('Default Base For Merges', 'preferences')!
-          .firstElementChild as GrSelect
-      ).bindValue,
-      preferences.default_base_for_merges
-    );
-    assert.equal(
-      (
         valueOf('Show Relative Dates In Changes Table', 'preferences')!
           .firstElementChild as HTMLInputElement
       ).checked,
@@ -259,38 +650,46 @@
       false
     );
 
-    assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
+    assert.isFalse(element.prefsChanged);
+
+    const themeSelect = valueOf('Theme', 'preferences')
+      .firstElementChild as GrSelect;
+    themeSelect.bindValue = 'DARK';
+
+    themeSelect.dispatchEvent(
+      new CustomEvent('change', {
+        composed: true,
+        bubbles: true,
+      })
+    );
 
     const publishOnPush = valueOf('Publish comments on push', 'preferences')!
-      .firstElementChild!;
+      .firstElementChild! as HTMLSpanElement;
 
-    MockInteractions.tap(publishOnPush);
+    publishOnPush.click();
 
-    assert.isTrue(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assertMenusEqual(prefs.my, preferences.my);
       assert.equal(prefs.publish_comments_on_push, true);
+      assert.equal(prefs.theme, AppTheme.DARK);
       return Promise.resolve(createDefaultPreferences());
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
   test('publish comments on push', async () => {
     const publishCommentsOnPush = valueOf(
       'Publish comments on push',
       'preferences'
-    )!.firstElementChild!;
-    MockInteractions.tap(publishCommentsOnPush);
+    )!.firstElementChild! as HTMLSpanElement;
+    publishCommentsOnPush.click();
 
-    assert.isFalse(element._menuChanged);
-    assert.isTrue(element._prefsChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assert.equal(prefs.publish_comments_on_push, true);
@@ -298,20 +697,18 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
   test('set new changes work-in-progress', async () => {
     const newChangesWorkInProgress = valueOf(
       'Set new changes to "work in progress" by default',
       'preferences'
-    )!.firstElementChild!;
-    MockInteractions.tap(newChangesWorkInProgress);
+    )!.firstElementChild! as HTMLSpanElement;
+    newChangesWorkInProgress.click();
 
-    assert.isFalse(element._menuChanged);
-    assert.isTrue(element._prefsChanged);
+    assert.isTrue(element.prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assert.equal(prefs.work_in_progress_by_default, true);
@@ -319,71 +716,40 @@
     });
 
     // Save the change.
-    await element._handleSavePreferences();
-    assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
+    await element.handleSavePreferences();
+    assert.isFalse(element.prefsChanged);
   });
 
-  test('menu', async () => {
-    assert.isFalse(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
+  test('add email validation', async () => {
+    assert.isFalse(element.isNewEmailValid('invalid email'));
+    assert.isTrue(element.isNewEmailValid('vaguely@valid.email'));
 
-    assertMenusEqual(element._localMenu, preferences.my);
-
-    const menu = element.$.menu.firstElementChild!;
-    let tableRows = queryAll(menu, 'tbody tr');
-    // let tableRows = menu.root.querySelectorAll('tbody tr');
-    assert.equal(tableRows.length, preferences.my.length);
-
-    // Add a menu item:
-    element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
-    flush();
-
-    // tableRows = menu.root.querySelectorAll('tbody tr');
-    tableRows = queryAll(menu, 'tbody tr');
-    assert.equal(tableRows.length, preferences.my.length + 1);
-
-    assert.isTrue(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-
-    stubRestApi('savePreferences').callsFake(prefs => {
-      assertMenusEqual(prefs.my, element._localMenu);
-      return Promise.resolve(createDefaultPreferences());
-    });
-
-    await element._handleSaveMenu();
-    assert.isFalse(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-    assertMenusEqual(element.prefs.my, element._localMenu);
-  });
-
-  test('add email validation', () => {
-    assert.isFalse(element._isNewEmailValid('invalid email'));
-    assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
-
-    assert.isFalse(
-      element._computeAddEmailButtonEnabled('invalid email', true)
-    );
-    assert.isFalse(
-      element._computeAddEmailButtonEnabled('vaguely@valid.email', true)
-    );
-    assert.isTrue(
-      element._computeAddEmailButtonEnabled('vaguely@valid.email', false)
-    );
+    element.newEmail = 'invalid email';
+    element.addingEmail = true;
+    await element.updateComplete;
+    assert.isFalse(element.computeAddEmailButtonEnabled());
+    element.newEmail = 'vaguely@valid.email';
+    element.addingEmail = true;
+    await element.updateComplete;
+    assert.isFalse(element.computeAddEmailButtonEnabled());
+    element.newEmail = 'vaguely@valid.email';
+    element.addingEmail = false;
+    await element.updateComplete;
+    assert.isTrue(element.computeAddEmailButtonEnabled());
   });
 
   test('add email does not save invalid', () => {
     const addEmailStub = stubAddAccountEmail(201);
 
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'invalid email';
+    assert.isFalse(element.addingEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'invalid email';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
-    assert.isFalse(element._addingEmail);
+    assert.isFalse(element.addingEmail);
     assert.isFalse(addEmailStub.called);
-    assert.isNotOk(element._lastSentVerificationEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
 
     assert.isFalse(addEmailStub.called);
   });
@@ -391,95 +757,56 @@
   test('add email does save valid', async () => {
     const addEmailStub = stubAddAccountEmail(201);
 
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
+    assert.isFalse(element.addingEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'valid@email.com';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
-    assert.isTrue(element._addingEmail);
+    assert.isTrue(element.addingEmail);
     assert.isTrue(addEmailStub.called);
 
     assert.isTrue(addEmailStub.called);
     await addEmailStub.lastCall.returnValue;
-    assert.isOk(element._lastSentVerificationEmail);
+    assert.isOk(element.lastSentVerificationEmail);
   });
 
   test('add email does not set last-email if error', async () => {
     const addEmailStub = stubAddAccountEmail(500);
 
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
+    assert.isNotOk(element.lastSentVerificationEmail);
+    element.newEmail = 'valid@email.com';
 
-    element._handleAddEmailButton();
+    element.handleAddEmailButton();
 
     assert.isTrue(addEmailStub.called);
     await addEmailStub.lastCall.returnValue;
-    assert.isNotOk(element._lastSentVerificationEmail);
+    assert.isNotOk(element.lastSentVerificationEmail);
   });
 
   test('emails are loaded without emailToken', () => {
-    const emailEditorLoadDataStub = sinon.stub(
-      element.$.emailEditor,
-      'loadData'
-    );
-    element.params = {
-      view: GerritView.SETTINGS,
-    } as AppElementSettingsParam;
-    element.connectedCallback();
+    const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
+    element.firstUpdated();
     assert.isTrue(emailEditorLoadDataStub.calledOnce);
   });
 
-  test('_handleSaveChangeTable', () => {
+  test('handleSaveChangeTable', () => {
     let newColumns = ['Owner', 'Project', 'Branch'];
-    element._localChangeTableColumns = newColumns.slice(0);
-    element._showNumber = false;
-    element._handleSaveChangeTable();
+    element.localChangeTableColumns = newColumns.slice(0);
+    element.showNumber = false;
+    element.handleSaveChangeTable();
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isNotOk(element.prefs.legacycid_in_change_table);
 
     newColumns = ['Size'];
-    element._localChangeTableColumns = newColumns;
-    element._showNumber = true;
-    element._handleSaveChangeTable();
+    element.localChangeTableColumns = newColumns;
+    element.showNumber = true;
+    element.handleSaveChangeTable();
     assert.deepEqual(element.prefs.change_table, newColumns);
     assert.isTrue(element.prefs.legacycid_in_change_table);
   });
 
-  test('reset menu item back to default', async () => {
-    const originalMenu = {
-      ...createDefaultPreferences(),
-      my: [
-        {url: '/first/url', name: 'first name', target: '_blank'},
-        {url: '/second/url', name: 'second name', target: '_blank'},
-        {url: '/third/url', name: 'third name', target: '_blank'},
-      ] as TopMenuItemInfo[],
-    };
-
-    stubRestApi('getDefaultPreferences').returns(Promise.resolve(originalMenu));
-
-    const updatedMenu = [
-      {url: '/first/url', name: 'first name', target: '_blank'},
-      {url: '/second/url', name: 'second name', target: '_blank'},
-      {url: '/third/url', name: 'third name', target: '_blank'},
-      {url: '/fourth/url', name: 'fourth name', target: '_blank'},
-    ];
-
-    element.set('_localMenu', updatedMenu);
-
-    await element._handleResetMenuButton();
-    assertMenusEqual(element._localMenu, originalMenu.my);
-  });
-
-  test('test that reset button is called', () => {
-    const overlayOpen = sinon.stub(element, '_handleResetMenuButton');
-
-    MockInteractions.tap(element.$.resetButton);
-
-    assert.isTrue(overlayOpen.called);
-  });
-
-  test('_showHttpAuth', () => {
+  test('showHttpAuth', async () => {
     const serverConfig: ServerInfo = {
       ...createServerInfo(),
       auth: {
@@ -487,41 +814,48 @@
       } as AuthInfo,
     };
 
-    assert.isTrue(element._showHttpAuth(serverConfig));
+    element.serverConfig = serverConfig;
+    await element.updateComplete;
+    assert.isTrue(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
-    assert.isTrue(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
+    await element.updateComplete;
+    assert.isTrue(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'LDAP';
-    assert.isFalse(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'LDAP';
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
 
-    serverConfig.auth.git_basic_auth_policy = 'OAUTH';
-    assert.isFalse(element._showHttpAuth(serverConfig));
+    element.serverConfig.auth.git_basic_auth_policy = 'OAUTH';
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
 
-    assert.isFalse(element._showHttpAuth(undefined));
+    element.serverConfig = undefined;
+    await element.updateComplete;
+    assert.isFalse(element.showHttpAuth());
   });
 
-  suite('_getFilterDocsLink', () => {
+  suite('getFilterDocsLink', () => {
     test('with http: docs base URL', () => {
       const base = 'http://example.com/';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'http://example.com/user-notify.html');
     });
 
     test('with http: docs base URL without slash', () => {
       const base = 'http://example.com';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'http://example.com/user-notify.html');
     });
 
     test('with https: docs base URL', () => {
       const base = 'https://example.com/';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(result, 'https://example.com/user-notify.html');
     });
 
     test('without docs base URL', () => {
-      const result = element._getFilterDocsLink(null);
+      const result = element.getFilterDocsLink(null);
       assert.equal(
         result,
         'https://gerrit-review.googlesource.com/' +
@@ -531,7 +865,7 @@
 
     test('ignores non HTTP links', () => {
       const base = 'javascript://alert("evil");';
-      const result = element._getFilterDocsLink(base);
+      const result = element.getFilterDocsLink(base);
       assert.equal(
         result,
         'https://gerrit-review.googlesource.com/' +
@@ -548,15 +882,15 @@
     let emailEditorLoadDataStub: sinon.SinonStub;
 
     setup(() => {
-      emailEditorLoadDataStub = sinon.stub(element.$.emailEditor, 'loadData');
+      emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
       confirmEmailStub = stubRestApi('confirmEmail').returns(
         new Promise(resolve => {
           resolveConfirm = resolve;
         })
       );
 
-      element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
-      element.connectedCallback();
+      element.emailToken = 'foo';
+      element.confirmEmail();
     });
 
     test('it is used to confirm email via rest API', () => {
@@ -581,11 +915,11 @@
       await element._testOnly_loadingPromise;
       assert.equal(
         (dispatchEventSpy.lastCall.args[0] as CustomEvent).type,
-        'show-alert'
+        EventType.SHOW_ALERT
       );
       assert.deepEqual(
         (dispatchEventSpy.lastCall.args[0] as CustomEvent).detail,
-        {message: 'bar'}
+        {message: 'bar', showDismiss: true}
       );
     });
   });
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 c1f347f..9c323aa 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
@@ -1,42 +1,22 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import '../../../styles/gr-form-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-overlay/gr-overlay';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-ssh-editor_html';
-import {property, customElement} from '@polymer/decorators';
 import {SshKeyInfo} from '../../../types/common';
 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 {appContext} from '../../../services/app-context';
-
-export interface GrSshEditor {
-  $: {
-    addButton: GrButton;
-    newKey: IronAutogrowTextareaElement;
-    viewKeyOverlay: GrOverlay;
-  };
-}
+import {getAppContext} from '../../../services/app-context';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {fire} from '../../../utils/event-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -44,85 +24,240 @@
   }
 }
 @customElement('gr-ssh-editor')
-export class GrSshEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Boolean, notify: true})
+export class GrSshEditor extends LitElement {
+  @property({type: Boolean})
   hasUnsavedChanges = false;
 
   @property({type: Array})
-  _keys: SshKeyInfo[] = [];
+  keys: SshKeyInfo[] = [];
 
   @property({type: Object})
-  _keyToView?: SshKeyInfo;
+  keyToView?: SshKeyInfo;
 
   @property({type: String})
-  _newKey = '';
+  newKey = '';
 
   @property({type: Array})
-  _keysToRemove: SshKeyInfo[] = [];
+  keysToRemove: SshKeyInfo[] = [];
 
-  private readonly restApiService = appContext.restApiService;
+  @state() prevHasUnsavedChanges = false;
+
+  @query('#addButton') addButton!: GrButton;
+
+  @query('#newKey') newKeyEditor!: IronAutogrowTextareaElement;
+
+  @query('#viewKeyModal') viewKeyModal!: HTMLDialogElement;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  static override get styles() {
+    return [
+      formStyles,
+      sharedStyles,
+      modalStyles,
+      css`
+        .statusHeader {
+          width: 4em;
+        }
+        .keyHeader {
+          width: 7.5em;
+        }
+        #viewKeyModal {
+          padding: var(--spacing-xxl);
+          width: 50em;
+        }
+        .publicKey {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          overflow-x: scroll;
+          overflow-wrap: break-word;
+          width: 30em;
+        }
+        .closeButton {
+          bottom: 2em;
+          position: absolute;
+          right: 2em;
+        }
+        #existing {
+          margin-bottom: var(--spacing-l);
+        }
+        #existing .commentColumn {
+          min-width: 27em;
+          width: auto;
+        }
+        iron-autogrow-textarea {
+          background-color: var(--view-background-color);
+        }
+      `,
+    ];
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('hasUnsavedChanges')) {
+      if (this.prevHasUnsavedChanges === this.hasUnsavedChanges) return;
+      this.prevHasUnsavedChanges = this.hasUnsavedChanges;
+      fire(this, 'has-unsaved-changes-changed', {
+        value: this.hasUnsavedChanges,
+      });
+    }
+  }
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <fieldset id="existing">
+          <table>
+            <thead>
+              <tr>
+                <th class="commentColumn">Comment</th>
+                <th class="statusHeader">Status</th>
+                <th class="keyHeader">Public key</th>
+                <th></th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.keys.map((key, index) => this.renderKey(key, index))}
+            </tbody>
+          </table>
+          <dialog id="viewKeyModal" tabindex="-1">
+            <fieldset>
+              <section>
+                <span class="title">Algorithm</span>
+                <span class="value">${this.keyToView?.algorithm}</span>
+              </section>
+              <section>
+                <span class="title">Public key</span>
+                <span class="value publicKey"
+                  >${this.keyToView?.encoded_key}</span
+                >
+              </section>
+              <section>
+                <span class="title">Comment</span>
+                <span class="value">${this.keyToView?.comment}</span>
+              </section>
+            </fieldset>
+            <gr-button
+              class="closeButton"
+              @click=${() => this.viewKeyModal.close()}
+              >Close</gr-button
+            >
+          </dialog>
+          <gr-button
+            @click=${() => this.save()}
+            ?disabled=${!this.hasUnsavedChanges}
+            >Save changes</gr-button
+          >
+        </fieldset>
+        <fieldset>
+          <section>
+            <span class="title">New SSH key</span>
+            <span class="value">
+              <iron-autogrow-textarea
+                id="newKey"
+                autocomplete="on"
+                placeholder="New SSH Key"
+                .bindValue=${this.newKey}
+                @bind-value-changed=${(e: BindValueChangeEvent) => {
+                  this.newKey = e.detail.value ?? '';
+                }}
+              ></iron-autogrow-textarea>
+            </span>
+          </section>
+          <gr-button
+            id="addButton"
+            link=""
+            ?disabled=${!this.newKey.length}
+            @click=${() => this.handleAddKey()}
+            >Add new SSH key</gr-button
+          >
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderKey(key: SshKeyInfo, index: number) {
+    return html` <tr>
+      <td class="commentColumn">${key.comment}</td>
+      <td>${key.valid ? 'Valid' : 'Invalid'}</td>
+      <td>
+        <gr-button
+          link=""
+          @click=${(e: Event) => this.showKey(e)}
+          data-index=${index}
+          >Click to View</gr-button
+        >
+      </td>
+      <td>
+        <gr-copy-clipboard
+          hasTooltip=""
+          .buttonTitle=${'Copy SSH public key to clipboard'}
+          hideInput=""
+          .text=${key.ssh_public_key}
+        >
+        </gr-copy-clipboard>
+      </td>
+      <td>
+        <gr-button
+          link=""
+          data-index=${index}
+          @click=${(e: Event) => this.handleDeleteKey(e)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
 
   loadData() {
     return this.restApiService.getAccountSSHKeys().then(keys => {
       if (!keys) return;
-      this._keys = keys;
+      this.keys = keys;
     });
   }
 
+  // private but used in tests
   save() {
-    const promises = this._keysToRemove.map(key =>
+    const promises = this.keysToRemove.map(key =>
       this.restApiService.deleteAccountSSHKey(`${key.seq}`)
     );
     return Promise.all(promises).then(() => {
-      this._keysToRemove = [];
+      this.keysToRemove = [];
       this.hasUnsavedChanges = false;
     });
   }
 
-  _getStatusLabel(isValid: boolean) {
-    return isValid ? 'Valid' : 'Invalid';
-  }
-
-  _showKey(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as GrButton;
+  private showKey(e: Event) {
+    const el = e.target as GrButton;
     const index = Number(el.getAttribute('data-index')!);
-    this._keyToView = this._keys[index];
-    this.$.viewKeyOverlay.open();
+    this.keyToView = this.keys[index];
+    this.viewKeyModal.showModal();
   }
 
-  _closeOverlay() {
-    this.$.viewKeyOverlay.close();
-  }
-
-  _handleDeleteKey(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as GrButton;
+  private handleDeleteKey(e: Event) {
+    const el = e.target as GrButton;
     const index = Number(el.getAttribute('data-index')!);
-    this.push('_keysToRemove', this._keys[index]);
-    this.splice('_keys', index, 1);
+    this.keysToRemove.push(this.keys[index]);
+    this.keys.splice(index, 1);
+    this.requestUpdate();
     this.hasUnsavedChanges = true;
   }
 
-  _handleAddKey() {
-    this.$.addButton.disabled = true;
-    this.$.newKey.disabled = true;
+  // private but used in tests
+  handleAddKey() {
+    this.addButton.disabled = true;
+    this.newKeyEditor.disabled = true;
     return this.restApiService
-      .addAccountSSHKey(this._newKey.trim())
+      .addAccountSSHKey(this.newKey.trim())
       .then(key => {
-        this.$.newKey.disabled = false;
-        this._newKey = '';
-        this.push('_keys', key);
+        this.newKeyEditor.disabled = false;
+        this.newKey = '';
+        this.keys.push(key);
+        this.requestUpdate();
       })
       .catch(() => {
-        this.$.addButton.disabled = false;
-        this.$.newKey.disabled = false;
+        this.addButton.disabled = false;
+        this.newKeyEditor.disabled = false;
       });
   }
-
-  _computeAddButtonDisabled(newKey: string) {
-    return !newKey.length;
-  }
 }
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
deleted file mode 100644
index e853b58..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .statusHeader {
-      width: 4em;
-    }
-    .keyHeader {
-      width: 7.5em;
-    }
-    #viewKeyOverlay {
-      padding: var(--spacing-xxl);
-      width: 50em;
-    }
-    .publicKey {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      overflow-x: scroll;
-      overflow-wrap: break-word;
-      width: 30em;
-    }
-    .closeButton {
-      bottom: 2em;
-      position: absolute;
-      right: 2em;
-    }
-    #existing {
-      margin-bottom: var(--spacing-l);
-    }
-    #existing .commentColumn {
-      min-width: 27em;
-      width: auto;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset id="existing">
-      <table>
-        <thead>
-          <tr>
-            <th class="commentColumn">Comment</th>
-            <th class="statusHeader">Status</th>
-            <th class="keyHeader">Public key</th>
-            <th></th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_keys]]" as="key">
-            <tr>
-              <td class="commentColumn">[[key.comment]]</td>
-              <td>[[_getStatusLabel(key.valid)]]</td>
-              <td>
-                <gr-button link="" on-click="_showKey" data-index$="[[index]]"
-                  >Click to View</gr-button
-                >
-              </td>
-              <td>
-                <gr-copy-clipboard
-                  hasTooltip=""
-                  buttonTitle="Copy SSH public key to clipboard"
-                  hideInput=""
-                  text="[[key.ssh_public_key]]"
-                >
-                </gr-copy-clipboard>
-              </td>
-              <td>
-                <gr-button
-                  link=""
-                  data-index$="[[index]]"
-                  on-click="_handleDeleteKey"
-                  >Delete</gr-button
-                >
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <gr-overlay id="viewKeyOverlay" with-backdrop="">
-        <fieldset>
-          <section>
-            <span class="title">Algorithm</span>
-            <span class="value">[[_keyToView.algorithm]]</span>
-          </section>
-          <section>
-            <span class="title">Public key</span>
-            <span class="value publicKey">[[_keyToView.encoded_key]]</span>
-          </section>
-          <section>
-            <span class="title">Comment</span>
-            <span class="value">[[_keyToView.comment]]</span>
-          </section>
-        </fieldset>
-        <gr-button class="closeButton" on-click="_closeOverlay"
-          >Close</gr-button
-        >
-      </gr-overlay>
-      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
-        >Save changes</gr-button
-      >
-    </fieldset>
-    <fieldset>
-      <section>
-        <span class="title">New SSH key</span>
-        <span class="value">
-          <iron-autogrow-textarea
-            id="newKey"
-            autocomplete="on"
-            bind-value="{{_newKey}}"
-            placeholder="New SSH Key"
-          ></iron-autogrow-textarea>
-        </span>
-      </section>
-      <gr-button
-        id="addButton"
-        link=""
-        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-        on-click="_handleAddKey"
-        >Add new SSH key</gr-button
-      >
-    </fieldset>
-  </div>
-`;
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
deleted file mode 100644
index cd2c1df..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
+++ /dev/null
@@ -1,170 +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-ssh-editor.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-ssh-editor');
-
-suite('gr-ssh-editor tests', () => {
-  let element;
-  let keys;
-
-  setup(async () => {
-    keys = [{
-      seq: 1,
-      ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
-      encoded_key: '<key 1>',
-      algorithm: 'ssh-rsa',
-      comment: 'comment-one@machine-one',
-      valid: true,
-    }, {
-      seq: 2,
-      ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
-      encoded_key: '<key 2>',
-      algorithm: 'ssh-rsa',
-      comment: 'comment-two@machine-two',
-      valid: true,
-    }];
-
-    stubRestApi('getAccountSSHKeys').returns(Promise.resolve(keys));
-
-    element = basicFixture.instantiate();
-
-    await element.loadData();
-    await flush();
-  });
-
-  test('renders', () => {
-    const rows = element.root.querySelectorAll('tbody tr');
-
-    assert.equal(rows.length, 2);
-
-    let cells = rows[0].querySelectorAll('td');
-    assert.equal(cells[0].textContent, keys[0].comment);
-
-    cells = rows[1].querySelectorAll('td');
-    assert.equal(cells[0].textContent, keys[1].comment);
-  });
-
-  test('remove key', async () => {
-    const lastKey = keys[1];
-
-    const saveStub = stubRestApi('deleteAccountSSHKey')
-        .callsFake(() => Promise.resolve());
-
-    assert.equal(element._keysToRemove.length, 0);
-    assert.isFalse(element.hasUnsavedChanges);
-
-    // Get the delete button for the last row.
-    const button = element.root.querySelector(
-        'tbody tr:last-of-type td:nth-child(5) gr-button');
-
-    MockInteractions.tap(button);
-
-    assert.equal(element._keys.length, 1);
-    assert.equal(element._keysToRemove.length, 1);
-    assert.equal(element._keysToRemove[0], lastKey);
-    assert.isTrue(element.hasUnsavedChanges);
-    assert.isFalse(saveStub.called);
-
-    await element.save();
-    assert.isTrue(saveStub.called);
-    assert.equal(saveStub.lastCall.args[0], lastKey.seq);
-    assert.equal(element._keysToRemove.length, 0);
-    assert.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('show key', () => {
-    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
-
-    // Get the show button for the last row.
-    const button = element.root.querySelector(
-        'tbody tr:last-of-type td:nth-child(3) gr-button');
-
-    MockInteractions.tap(button);
-
-    assert.equal(element._keyToView, keys[1]);
-    assert.isTrue(openSpy.called);
-  });
-
-  test('add key', async () => {
-    const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
-    const newKeyObject = {
-      seq: 3,
-      ssh_public_key: newKeyString,
-      encoded_key: '<key 3>',
-      algorithm: 'ssh-rsa',
-      comment: 'comment-three@machine-three',
-      valid: true,
-    };
-
-    const addStub = stubRestApi(
-        'addAccountSSHKey').callsFake(
-        () => Promise.resolve(newKeyObject));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 3);
-      promise.resolve();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.equal(addStub.lastCall.args[0], newKeyString);
-    await promise;
-  });
-
-  test('add invalid key', async () => {
-    const newKeyString = 'not even close to valid';
-
-    const addStub = stubRestApi(
-        'addAccountSSHKey').callsFake(
-        () => Promise.reject(new Error('error')));
-
-    element._newKey = newKeyString;
-
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
-
-    const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
-      promise.resolve();
-    });
-
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
-
-    assert.isTrue(addStub.called);
-    assert.equal(addStub.lastCall.args[0], newKeyString);
-    await promise;
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
new file mode 100644
index 0000000..9528fb2
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
@@ -0,0 +1,301 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-ssh-editor';
+import {
+  mockPromise,
+  query,
+  stubRestApi,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {GrSshEditor} from './gr-ssh-editor';
+import {SshKeyInfo} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-ssh-editor tests', () => {
+  let element: GrSshEditor;
+  let keys: SshKeyInfo[];
+
+  setup(async () => {
+    keys = [
+      {
+        seq: 1,
+        ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
+        encoded_key: '<key 1>',
+        algorithm: 'ssh-rsa',
+        comment: 'comment-one@machine-one',
+        valid: true,
+      },
+      {
+        seq: 2,
+        ssh_public_key: 'ssh-rsa <key 2> comment-two@machine-two',
+        encoded_key: '<key 2>',
+        algorithm: 'ssh-rsa',
+        comment: 'comment-two@machine-two',
+        valid: true,
+      },
+    ];
+
+    stubRestApi('getAccountSSHKeys').returns(Promise.resolve(keys));
+
+    element = await fixture(html`<gr-ssh-editor></gr-ssh-editor>`);
+
+    await element.loadData();
+    await waitEventLoop();
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <fieldset id="existing">
+            <table>
+              <thead>
+                <tr>
+                  <th class="commentColumn">Comment</th>
+                  <th class="statusHeader">Status</th>
+                  <th class="keyHeader">Public key</th>
+                  <th></th>
+                  <th></th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <td class="commentColumn">comment-one@machine-one</td>
+                  <td>Valid</td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      data-index="0"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Click to View
+                    </gr-button>
+                  </td>
+                  <td>
+                    <gr-copy-clipboard hastooltip="" hideinput="">
+                    </gr-copy-clipboard>
+                  </td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      data-index="0"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+                <tr>
+                  <td class="commentColumn">comment-two@machine-two</td>
+                  <td>Valid</td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      data-index="1"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Click to View
+                    </gr-button>
+                  </td>
+                  <td>
+                    <gr-copy-clipboard hastooltip="" hideinput="">
+                    </gr-copy-clipboard>
+                  </td>
+                  <td>
+                    <gr-button
+                      aria-disabled="false"
+                      data-index="1"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Delete
+                    </gr-button>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+            <dialog id="viewKeyModal" tabindex="-1">
+              <fieldset>
+                <section>
+                  <span class="title"> Algorithm </span>
+                  <span class="value"> </span>
+                </section>
+                <section>
+                  <span class="title"> Public key </span>
+                  <span class="publicKey value"> </span>
+                </section>
+                <section>
+                  <span class="title"> Comment </span>
+                  <span class="value"> </span>
+                </section>
+              </fieldset>
+              <gr-button
+                aria-disabled="false"
+                class="closeButton"
+                role="button"
+                tabindex="0"
+              >
+                Close
+              </gr-button>
+            </dialog>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              role="button"
+              tabindex="-1"
+            >
+              Save changes
+            </gr-button>
+          </fieldset>
+          <fieldset>
+            <section>
+              <span class="title"> New SSH key </span>
+              <span class="value">
+                <iron-autogrow-textarea
+                  aria-disabled="false"
+                  autocomplete="on"
+                  id="newKey"
+                  placeholder="New SSH Key"
+                >
+                </iron-autogrow-textarea>
+              </span>
+            </section>
+            <gr-button
+              aria-disabled="true"
+              disabled=""
+              id="addButton"
+              link=""
+              role="button"
+              tabindex="-1"
+            >
+              Add new SSH key
+            </gr-button>
+          </fieldset>
+        </div>
+      `
+    );
+  });
+
+  test('remove key', async () => {
+    const lastKey = keys[1];
+
+    const saveStub = stubRestApi('deleteAccountSSHKey').callsFake(() =>
+      Promise.resolve()
+    );
+
+    assert.equal(element.keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+
+    // Get the delete button for the last row.
+    const button = query<GrButton>(
+      element,
+      'tbody tr:last-of-type td:nth-child(5) gr-button'
+    );
+
+    button!.click();
+
+    assert.equal(element.keys.length, 1);
+    assert.equal(element.keysToRemove.length, 1);
+    assert.equal(element.keysToRemove[0], lastKey);
+    assert.isTrue(element.hasUnsavedChanges);
+    assert.isFalse(saveStub.called);
+
+    await element.save();
+    assert.isTrue(saveStub.called);
+    assert.equal(saveStub.lastCall.args[0], `${lastKey.seq}`);
+    assert.equal(element.keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('show key', () => {
+    const openSpy = sinon.spy(element.viewKeyModal, 'showModal');
+
+    // Get the show button for the last row.
+    const button = query<GrButton>(
+      element,
+      'tbody tr:last-of-type td:nth-child(3) gr-button'
+    );
+
+    button!.click();
+
+    assert.equal(element.keyToView, keys[1]);
+    assert.isTrue(openSpy.called);
+  });
+
+  test('add key', async () => {
+    const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
+    const newKeyObject = {
+      seq: 3,
+      ssh_public_key: newKeyString,
+      encoded_key: '<key 3>',
+      algorithm: 'ssh-rsa',
+      comment: 'comment-three@machine-three',
+      valid: true,
+    };
+
+    const addStub = stubRestApi('addAccountSSHKey').resolves(newKeyObject);
+
+    element.newKey = newKeyString;
+
+    await element.updateComplete;
+
+    assert.isFalse(element.addButton.disabled);
+    assert.isFalse(element.newKeyEditor.disabled);
+
+    const promise = mockPromise();
+    element.handleAddKey().then(() => {
+      assert.isTrue(element.addButton.disabled);
+      assert.isFalse(element.newKeyEditor.disabled);
+      assert.equal(element.keys.length, 3);
+      promise.resolve();
+    });
+
+    assert.isTrue(element.addButton.disabled);
+    assert.isTrue(element.newKeyEditor.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.equal(addStub.lastCall.args[0], newKeyString);
+    await promise;
+  });
+
+  test('add invalid key', async () => {
+    const newKeyString = 'not even close to valid';
+
+    const addStub = stubRestApi('addAccountSSHKey').rejects(new Error('error'));
+
+    element.newKey = newKeyString;
+
+    await element.updateComplete;
+
+    assert.isFalse(element.addButton.disabled);
+    assert.isFalse(element.newKeyEditor.disabled);
+
+    const promise = mockPromise();
+    element.handleAddKey().then(() => {
+      assert.isFalse(element.addButton.disabled);
+      assert.isFalse(element.newKeyEditor.disabled);
+      assert.equal(element.keys.length, 2);
+      promise.resolve();
+    });
+
+    assert.isTrue(element.addButton.disabled);
+    assert.isTrue(element.newKeyEditor.disabled);
+
+    assert.isTrue(addStub.called);
+    assert.equal(addStub.lastCall.args[0], newKeyString);
+    await promise;
+  });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 4381a59..2996e50 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
@@ -1,39 +1,31 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-watched-projects-editor_html';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 import {
   AutocompleteQuery,
   GrAutocomplete,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {ProjectWatchInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
-import {IronInputElement} from '@polymer/iron-input';
+import {assertIsDefined} from '../../../utils/common-util';
+import {ProjectWatchInfo, RepoName} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {when} from 'lit/directives/when.js';
+import {fire} from '../../../utils/event-util';
+import {PropertiesOfType} from '../../../utils/type-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
-const NOTIFICATION_TYPES = [
+type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
+
+const NOTIFICATION_TYPES: Array<{name: string; key: NotificationKey}> = [
   {name: 'Changes', key: 'notify_new_changes'},
   {name: 'Patches', key: 'notify_new_patch_sets'},
   {name: 'Comments', key: 'notify_all_comments'},
@@ -41,50 +33,145 @@
   {name: 'Abandons', key: 'notify_abandoned_changes'},
 ];
 
-export interface GrWatchedProjectsEditor {
-  $: {
-    newFilter: HTMLInputElement;
-    newFilterInput: IronInputElement;
-    newProject: GrAutocomplete;
-  };
-}
-
 @customElement('gr-watched-projects-editor')
-export class GrWatchedProjectsEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrWatchedProjectsEditor extends LitElement {
+  // Private but used in tests.
+  @query('#newFilter')
+  newFilter?: HTMLInputElement;
 
-  @property({type: Boolean, notify: true})
+  // Private but used in tests.
+  @query('#newProject')
+  newProject?: GrAutocomplete;
+
+  @property({type: Boolean})
   hasUnsavedChanges = false;
 
   @property({type: Array})
-  _projects?: ProjectWatchInfo[];
+  projects?: ProjectWatchInfo[];
 
   @property({type: Array})
-  _projectsToRemove: ProjectWatchInfo[] = [];
+  projectsToRemove: ProjectWatchInfo[] = [];
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  private readonly query: AutocompleteQuery = input =>
+    this.getProjectSuggestions(input);
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  constructor() {
-    super();
-    this._query = input => this._getProjectSuggestions(input);
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        #watchedProjects .notifType {
+          text-align: center;
+          padding: 0 var(--spacing-s);
+        }
+        .notifControl {
+          cursor: pointer;
+          text-align: center;
+        }
+        .notifControl:hover {
+          outline: 1px solid var(--border-color);
+        }
+        .projectFilter {
+          color: var(--deemphasized-text-color);
+          font-style: italic;
+          margin-left: var(--spacing-l);
+        }
+        .newFilterInput {
+          width: 100%;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const types = NOTIFICATION_TYPES;
+    return html` <div class="gr-form-styles">
+      <table id="watchedProjects">
+        <thead>
+          <tr>
+            <th>Repo</th>
+            ${types.map(type => html`<th class="notifType">${type.name}</th>`)}
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${(this.projects ?? []).map(project => this.renderProject(project))}
+        </tbody>
+        <tfoot>
+          <tr>
+            <th>
+              <gr-autocomplete
+                id="newProject"
+                .query=${this.query}
+                threshold="1"
+                allow-non-suggested-values
+                tab-complete
+                placeholder="Repo"
+              ></gr-autocomplete>
+            </th>
+            <th colspan=${types.length}>
+              <iron-input id="newFilterInput" class="newFilterInput">
+                <input
+                  id="newFilter"
+                  class="newFilterInput"
+                  placeholder="branch:name, or other search expression"
+                />
+              </iron-input>
+            </th>
+            <th>
+              <gr-button link="" @click=${this.handleAddProject}>Add</gr-button>
+            </th>
+          </tr>
+        </tfoot>
+      </table>
+    </div>`;
+  }
+
+  private renderProject(project: ProjectWatchInfo) {
+    const types = NOTIFICATION_TYPES;
+    return html` <tr>
+      <td>
+        ${project.project}
+        ${when(
+          project.filter,
+          () => html`<div class="projectFilter">${project.filter}</div>`
+        )}
+      </td>
+      ${types.map(type => this.renderNotifyControl(project, type.key))}
+      <td>
+        <gr-button
+          link=""
+          @click=${(_e: Event) => this.handleRemoveProject(project)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
+  private renderNotifyControl(project: ProjectWatchInfo, key: NotificationKey) {
+    return html` <td class="notifControl" @click=${this.handleNotifCellClick}>
+      <input
+        type="checkbox"
+        data-key=${key}
+        @change=${(e: Event) => this.handleCheckboxChange(project, key, e)}
+        ?checked=${!!project[key]}
+      />
+    </td>`;
   }
 
   loadData() {
     return this.restApiService.getWatchedProjects().then(projs => {
-      this._projects = projs;
+      this.projects = projs;
     });
   }
 
   save() {
     let deletePromise: Promise<Response | undefined>;
-    if (this._projectsToRemove.length) {
+    if (this.projectsToRemove.length) {
       deletePromise = this.restApiService.deleteWatchedProjects(
-        this._projectsToRemove
+        this.projectsToRemove
       );
     } else {
       deletePromise = Promise.resolve(undefined);
@@ -92,53 +179,44 @@
 
     return deletePromise
       .then(() => {
-        if (this._projects) {
-          return this.restApiService.saveWatchedProjects(this._projects);
+        if (this.projects) {
+          return this.restApiService.saveWatchedProjects(this.projects);
         } else {
           return Promise.resolve(undefined);
         }
       })
       .then(projects => {
-        this._projects = projects;
-        this._projectsToRemove = [];
-        this.hasUnsavedChanges = false;
+        this.projects = projects;
+        this.projectsToRemove = [];
+        this.setHasUnsavedChanges(false);
       });
   }
 
-  _getTypes() {
-    return NOTIFICATION_TYPES;
+  // private but used in tests.
+  getProjectSuggestions(input: string) {
+    return this.restApiService
+      .getSuggestedRepos(input, /* n=*/ undefined, throwingErrorCallback)
+      .then(response => {
+        const repos: AutocompleteSuggestion[] = [];
+        for (const [name, repo] of Object.entries(response ?? {})) {
+          repos.push({name, value: repo.id});
+        }
+        return repos;
+      });
   }
 
-  _getTypeCount() {
-    return this._getTypes().length;
+  private handleRemoveProject(project: ProjectWatchInfo) {
+    if (!this.projects) return;
+    const index = this.projects.indexOf(project);
+    if (index < 0) return;
+    this.projects.splice(index, 1);
+    this.projectsToRemove.push(project);
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _computeCheckboxChecked(project: ProjectWatchInfo, key: string) {
-    return hasOwnProperty(project, key);
-  }
-
-  _getProjectSuggestions(input: string) {
-    return this.restApiService.getSuggestedProjects(input).then(response => {
-      const projects: AutocompleteSuggestion[] = [];
-      for (const [name, project] of Object.entries(response ?? {})) {
-        projects.push({name, value: project.id});
-      }
-      return projects;
-    });
-  }
-
-  _handleRemoveProject(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
-    const dataIndex = el.getAttribute('data-index');
-    if (dataIndex === null || !this._projects) return;
-    const index = Number(dataIndex);
-    const project = this._projects[index];
-    this.splice('_projects', index, 1);
-    this.push('_projectsToRemove', project);
-    this.hasUnsavedChanges = true;
-  }
-
-  _canAddProject(
+  // private but used in tests.
+  canAddProject(
     project: string | null,
     text: string | null,
     filter: string | null
@@ -152,12 +230,12 @@
       return true;
     }
 
-    if (!this._projects) return true;
+    if (!this.projects) return true;
     // Check if the project with filter is already in the list.
-    for (let i = 0; i < this._projects.length; i++) {
+    for (let i = 0; i < this.projects.length; i++) {
       if (
-        this._projects[i].project === project &&
-        this.areFiltersEqual(this._projects[i].filter, filter)
+        this.projects[i].project === project &&
+        this.areFiltersEqual(this.projects[i].filter, filter)
       ) {
         return false;
       }
@@ -166,14 +244,15 @@
     return true;
   }
 
-  _getNewProjectIndex(name: string, filter: string | null) {
-    if (!this._projects) return;
+  // private but used in tests.
+  getNewProjectIndex(name: string, filter: string | null) {
+    if (!this.projects) return;
     let i;
-    for (i = 0; i < this._projects.length; i++) {
-      const projectFilter = this._projects[i].filter;
+    for (i = 0; i < this.projects.length; i++) {
+      const projectFilter = this.projects[i].filter;
       if (
-        this._projects[i].project > name ||
-        (this._projects[i].project === name &&
+        this.projects[i].project > name ||
+        (this.projects[i].project === name &&
           this.isFilterDefined(projectFilter) &&
           this.isFilterDefined(filter) &&
           projectFilter! > filter!)
@@ -184,43 +263,47 @@
     return i;
   }
 
-  _handleAddProject() {
-    const newProject = this.$.newProject.value;
-    const newProjectName = this.$.newProject.text;
-    const filter = this.$.newFilter.value || null;
+  // Private but used in tests.
+  handleAddProject() {
+    assertIsDefined(this.newProject, 'newProject');
+    assertIsDefined(this.newFilter, 'newFilter');
+    const newProject = this.newProject.value;
+    const newProjectName = this.newProject.text as RepoName;
+    const filter = this.newFilter.value;
 
-    if (!this._canAddProject(newProject, newProjectName, filter)) {
+    if (!this.canAddProject(newProject, newProjectName, filter)) {
       return;
     }
 
-    const insertIndex = this._getNewProjectIndex(newProjectName, filter);
+    const insertIndex = this.getNewProjectIndex(newProjectName, filter);
 
     if (insertIndex !== undefined) {
-      this.splice('_projects', insertIndex, 0, {
+      this.projects?.splice(insertIndex, 0, {
         project: newProjectName,
         filter,
         _is_local: true,
       });
+      this.requestUpdate();
     }
 
-    this.$.newProject.clear();
-    this.$.newFilter.value = '';
-    this.hasUnsavedChanges = true;
+    this.newProject.clear();
+    this.newFilter.value = '';
+    this.setHasUnsavedChanges(true);
   }
 
-  _handleCheckboxChange(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
-    if (el === null) return;
-    const dataIndex = el.getAttribute('data-index');
-    const key = el.getAttribute('data-key');
-    if (dataIndex === null || key === null) return;
-    const index = Number(dataIndex);
+  private handleCheckboxChange(
+    project: ProjectWatchInfo,
+    key: NotificationKey,
+    e: Event
+  ) {
+    const el = e.target as HTMLInputElement;
     const checked = el.checked;
-    this.set(['_projects', index, key], !!checked);
-    this.hasUnsavedChanges = true;
+    project[key] = !!checked;
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _handleNotifCellClick(e: Event) {
+  private handleNotifCellClick(e: Event) {
     if (e.target === null) return;
     const checkbox = (e.target as HTMLElement).querySelector('input');
     if (checkbox) {
@@ -228,6 +311,11 @@
     }
   }
 
+  private setHasUnsavedChanges(value: boolean) {
+    this.hasUnsavedChanges = value;
+    fire(this, 'has-unsaved-changes-changed', {value});
+  }
+
   isFilterDefined(filter: string | null | undefined) {
     return filter !== null && filter !== undefined;
   }
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
deleted file mode 100644
index fb65a03..0000000
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #watchedProjects .notifType {
-      text-align: center;
-      padding: 0 var(--spacing-s);
-    }
-    .notifControl {
-      cursor: pointer;
-      text-align: center;
-    }
-    .notifControl:hover {
-      outline: 1px solid var(--border-color);
-    }
-    .projectFilter {
-      color: var(--deemphasized-text-color);
-      font-style: italic;
-      margin-left: var(--spacing-l);
-    }
-    .newFilterInput {
-      width: 100%;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="watchedProjects">
-      <thead>
-        <tr>
-          <th>Repo</th>
-          <template is="dom-repeat" items="[[_getTypes()]]">
-            <th class="notifType">[[item.name]]</th>
-          </template>
-          <th></th>
-        </tr>
-      </thead>
-      <tbody>
-        <template
-          is="dom-repeat"
-          items="[[_projects]]"
-          as="project"
-          index-as="projectIndex"
-        >
-          <tr>
-            <td>
-              [[project.project]]
-              <template is="dom-if" if="[[project.filter]]">
-                <div class="projectFilter">[[project.filter]]</div>
-              </template>
-            </td>
-            <template is="dom-repeat" items="[[_getTypes()]]" as="type">
-              <td class="notifControl" on-click="_handleNotifCellClick">
-                <input
-                  type="checkbox"
-                  data-index$="[[projectIndex]]"
-                  data-key$="[[type.key]]"
-                  on-change="_handleCheckboxChange"
-                  checked$="[[_computeCheckboxChecked(project, type.key)]]"
-                />
-              </td>
-            </template>
-            <td>
-              <gr-button
-                link=""
-                data-index$="[[projectIndex]]"
-                on-click="_handleRemoveProject"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-      <tfoot>
-        <tr>
-          <th>
-            <gr-autocomplete
-              id="newProject"
-              query="[[_query]]"
-              threshold="1"
-              allow-non-suggested-values=""
-              tab-complete=""
-              placeholder="Repo"
-            ></gr-autocomplete>
-          </th>
-          <th colspan$="[[_getTypeCount()]]">
-            <iron-input
-              id="newFilterInput"
-              class="newFilterInput"
-              placeholder="branch:name, or other search expression"
-            >
-              <input
-                id="newFilter"
-                class="newFilterInput"
-                placeholder="branch:name, or other search expression"
-              />
-            </iron-input>
-          </th>
-          <th>
-            <gr-button link="" on-click="_handleAddProject">Add</gr-button>
-          </th>
-        </tr>
-      </tfoot>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
index c0580f6..c608656 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
@@ -1,32 +1,23 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-watched-projects-editor';
 import {GrWatchedProjectsEditor} from './gr-watched-projects-editor';
-import {stubRestApi} from '../../../test/test-utils';
+import {stubRestApi, waitUntil} from '../../../test/test-utils';
 import {ProjectWatchInfo} from '../../../types/common';
-import {queryAll, queryAndAssert} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-watched-projects-editor');
+import {queryAndAssert} from '../../../test/test-utils';
+import {IronInputElement} from '@polymer/iron-input';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 
 suite('gr-watched-projects-editor tests', () => {
   let element: GrWatchedProjectsEditor;
+  let suggestionStub: sinon.SinonStub;
 
   setup(async () => {
     const projects = [
@@ -53,7 +44,7 @@
     ] as ProjectWatchInfo[];
 
     stubRestApi('getWatchedProjects').returns(Promise.resolve(projects));
-    stubRestApi('getSuggestedProjects').callsFake(input => {
+    suggestionStub = stubRestApi('getSuggestedRepos').callsFake(input => {
       if (input.startsWith('th')) {
         return Promise.resolve({
           'the project': {
@@ -67,103 +58,296 @@
       }
     });
 
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<gr-watched-projects-editor></gr-watched-projects-editor>`
+    );
 
     await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
-    const rows = queryAndAssert(element, 'table').querySelectorAll('tbody tr');
-    assert.equal(rows.length, 4);
-
-    function getKeysOfRow(row: number) {
-      const boxes = queryAll(rows[row], 'input[checked]');
-      return Array.prototype.map.call(boxes, e => e.getAttribute('data-key'));
-    }
-
-    let checkedKeys = getKeysOfRow(0);
-    assert.equal(checkedKeys.length, 2);
-    assert.equal(checkedKeys[0], 'notify_submitted_changes');
-    assert.equal(checkedKeys[1], 'notify_abandoned_changes');
-
-    checkedKeys = getKeysOfRow(1);
-    assert.equal(checkedKeys.length, 1);
-    assert.equal(checkedKeys[0], 'notify_new_changes');
-
-    checkedKeys = getKeysOfRow(2);
-    assert.equal(checkedKeys.length, 0);
-
-    checkedKeys = getKeysOfRow(3);
-    assert.equal(checkedKeys.length, 3);
-    assert.equal(checkedKeys[0], 'notify_new_changes');
-    assert.equal(checkedKeys[1], 'notify_new_patch_sets');
-    assert.equal(checkedKeys[2], 'notify_all_comments');
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="gr-form-styles">
+          <table id="watchedProjects">
+            <thead>
+              <tr>
+                <th>Repo</th>
+                <th class="notifType">Changes</th>
+                <th class="notifType">Patches</th>
+                <th class="notifType">Comments</th>
+                <th class="notifType">Submits</th>
+                <th class="notifType">Abandons</th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>project a</td>
+                <td class="notifControl">
+                  <input data-key="notify_new_changes" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_new_patch_sets" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_all_comments" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_submitted_changes"
+                    type="checkbox"
+                  />
+                </td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_abandoned_changes"
+                    type="checkbox"
+                  />
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  project b
+                  <div class="projectFilter">filter 1</div>
+                </td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_new_changes"
+                    type="checkbox"
+                  />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_new_patch_sets" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_all_comments" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_submitted_changes" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_abandoned_changes" type="checkbox" />
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>
+                  project b
+                  <div class="projectFilter">filter 2</div>
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_new_changes" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_new_patch_sets" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_all_comments" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_submitted_changes" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_abandoned_changes" type="checkbox" />
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>project c</td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_new_changes"
+                    type="checkbox"
+                  />
+                </td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_new_patch_sets"
+                    type="checkbox"
+                  />
+                </td>
+                <td class="notifControl">
+                  <input
+                    checked=""
+                    data-key="notify_all_comments"
+                    type="checkbox"
+                  />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_submitted_changes" type="checkbox" />
+                </td>
+                <td class="notifControl">
+                  <input data-key="notify_abandoned_changes" type="checkbox" />
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+            </tbody>
+            <tfoot>
+              <tr>
+                <th>
+                  <gr-autocomplete
+                    allow-non-suggested-values=""
+                    id="newProject"
+                    placeholder="Repo"
+                    tab-complete=""
+                    threshold="1"
+                  >
+                  </gr-autocomplete>
+                </th>
+                <th colspan="5">
+                  <iron-input class="newFilterInput" id="newFilterInput">
+                    <input
+                      class="newFilterInput"
+                      id="newFilter"
+                      placeholder="branch:name, or other search expression"
+                    />
+                  </iron-input>
+                </th>
+                <th>
+                  <gr-button
+                    aria-disabled="false"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Add
+                  </gr-button>
+                </th>
+              </tr>
+            </tfoot>
+          </table>
+        </div>
+      `
+    );
   });
 
-  test('_getProjectSuggestions empty', async () => {
-    const projects = await element._getProjectSuggestions('nonexistent');
+  test('getProjectSuggestions empty', async () => {
+    const projects = await element.getProjectSuggestions('nonexistent');
     assert.equal(projects.length, 0);
   });
 
-  test('_getProjectSuggestions non-empty', async () => {
-    const projects = await element._getProjectSuggestions('the project');
+  test('getProjectSuggestions non-empty', async () => {
+    const projects = await element.getProjectSuggestions('the project');
     assert.equal(projects.length, 1);
     assert.equal(projects[0].name, 'the project');
   });
 
-  test('_getProjectSuggestions non-empty with two letter project', async () => {
-    const projects = await element._getProjectSuggestions('th');
+  test('getProjectSuggestions non-empty with two letter project', async () => {
+    const projects = await element.getProjectSuggestions('th');
     assert.equal(projects.length, 1);
     assert.equal(projects[0].name, 'the project');
   });
 
+  test('autocompletes repo input', async () => {
+    const repoAutocomplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
+    );
+    const repoInput = queryAndAssert<HTMLInputElement>(
+      repoAutocomplete,
+      '#input'
+    );
+
+    repoInput.focus();
+    repoAutocomplete.text = 'the';
+    await waitUntil(() => suggestionStub.called);
+    await repoAutocomplete.updateComplete;
+
+    assert.isTrue(suggestionStub.calledWith('the'));
+  });
+
   test('_canAddProject', () => {
-    assert.isFalse(element._canAddProject(null, null, null));
+    assert.isFalse(element.canAddProject(null, null, null));
 
     // Can add a project that is not in the list.
-    assert.isTrue(element._canAddProject('project d', null, null));
-    assert.isTrue(element._canAddProject('project d', null, 'filter 3'));
+    assert.isTrue(element.canAddProject('project d', null, null));
+    assert.isTrue(element.canAddProject('project d', null, 'filter 3'));
 
     // Cannot add a project that is in the list with no filter.
-    assert.isFalse(element._canAddProject('project a', null, null));
+    assert.isFalse(element.canAddProject('project a', null, null));
 
     // Can add a project that is in the list if the filter differs.
-    assert.isTrue(element._canAddProject('project a', null, 'filter 4'));
+    assert.isTrue(element.canAddProject('project a', null, 'filter 4'));
 
     // Cannot add a project that is in the list with the same filter.
-    assert.isFalse(element._canAddProject('project b', null, 'filter 1'));
-    assert.isFalse(element._canAddProject('project b', null, 'filter 2'));
+    assert.isFalse(element.canAddProject('project b', null, 'filter 1'));
+    assert.isFalse(element.canAddProject('project b', null, 'filter 2'));
 
     // Can add a project that is in the list using a new filter.
-    assert.isTrue(element._canAddProject('project b', null, 'filter 3'));
+    assert.isTrue(element.canAddProject('project b', null, 'filter 3'));
 
     // Can add a project that is not added by the auto complete
-    assert.isTrue(element._canAddProject(null, 'test', null));
+    assert.isTrue(element.canAddProject(null, 'test', null));
   });
 
-  test('_getNewProjectIndex', () => {
+  test('getNewProjectIndex', () => {
     // Projects are sorted in ASCII order.
-    assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
-    assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
+    assert.equal(element.getNewProjectIndex('project A', 'filter'), 0);
+    assert.equal(element.getNewProjectIndex('project a', 'filter'), 1);
 
     // Projects are sorted by filter when the names are equal
-    assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
-    assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
-    assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
+    assert.equal(element.getNewProjectIndex('project b', 'filter 0'), 1);
+    assert.equal(element.getNewProjectIndex('project b', 'filter 1.5'), 2);
+    assert.equal(element.getNewProjectIndex('project b', 'filter 3'), 3);
 
     // Projects with filters follow those without
-    assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
+    assert.equal(element.getNewProjectIndex('project c', 'filter'), 4);
   });
 
-  test('_handleAddProject', () => {
-    element.$.newProject.value = 'project d';
-    element.$.newProject.setText('project d');
-    element.$.newFilterInput.bindValue = '';
+  test('handleAddProject', () => {
+    assertIsDefined(element.newProject, 'newProject');
+    element.newProject.value = 'project d';
+    element.newProject.setText('project d');
+    queryAndAssert<IronInputElement>(element, '#newFilterInput').bindValue = '';
 
-    element._handleAddProject();
+    element.handleAddProject();
 
-    const projects = element._projects!;
+    const projects = element.projects!;
     assert.equal(projects.length, 5);
     assert.equal(projects[4].project, 'project d');
     assert.isNotOk(projects[4].filter);
@@ -171,32 +355,35 @@
   });
 
   test('_handleAddProject with invalid inputs', () => {
-    element.$.newProject.value = 'project b';
-    element.$.newProject.setText('project b');
-    element.$.newFilterInput.bindValue = 'filter 1';
-    element.$.newFilter.value = 'filter 1';
+    assertIsDefined(element.newProject, 'newProject');
+    element.newProject.value = 'project b';
+    element.newProject.setText('project b');
+    queryAndAssert<IronInputElement>(element, '#newFilterInput').bindValue =
+      'filter 1';
+    assertIsDefined(element.newFilter, 'newFilter');
+    element.newFilter.value = 'filter 1';
 
-    element._handleAddProject();
+    element.handleAddProject();
 
-    assert.equal(element._projects!.length, 4);
+    assert.equal(element.projects!.length, 4);
   });
 
-  test('_handleRemoveProject', () => {
-    assert.deepEqual(element._projectsToRemove, []);
+  test('_handleRemoveProject', async () => {
+    assert.deepEqual(element.projectsToRemove, []);
 
-    const button = queryAndAssert(
+    const button = queryAndAssert<GrButton>(
       element,
       'table tbody tr:nth-child(2) gr-button'
     );
-    MockInteractions.tap(button);
+    button.click();
 
-    flush();
+    await element.updateComplete;
 
     const rows = queryAndAssert(element, 'table tbody').querySelectorAll('tr');
 
     assert.equal(rows.length, 3);
 
-    assert.equal(element._projectsToRemove.length, 1);
-    assert.equal(element._projectsToRemove[0].project, 'project b');
+    assert.equal(element.projectsToRemove.length, 1);
+    assert.equal(element.projectsToRemove[0].project, 'project b');
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 31c62b1..31e4f5d 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
@@ -1,27 +1,22 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../gr-account-link/gr-account-link';
+import '../gr-account-label/gr-account-label';
 import '../gr-button/gr-button';
-import '../gr-icons/gr-icons';
-import {AccountInfo, ChangeInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import '../gr-icon/gr-icon';
+import {
+  AccountInfo,
+  ApprovalInfo,
+  ChangeInfo,
+  LabelInfo,
+} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {classMap} from 'lit/directives/class-map';
+import {customElement, property} from 'lit/decorators.js';
+import {ClassInfo, classMap} from 'lit/directives/class-map.js';
+import {getLabelStatus, hasVoted, LabelStatus} from '../../../utils/label-util';
 
 @customElement('gr-account-chip')
 export class GrAccountChip extends LitElement {
@@ -56,9 +51,6 @@
   @property({type: Boolean})
   forceAttention = false;
 
-  @property({type: String})
-  voteableText?: string;
-
   @property({type: Boolean, reflect: true})
   disabled = false;
 
@@ -76,10 +68,13 @@
   @property({type: Boolean, reflect: true})
   showAvatar?: boolean;
 
-  @property({type: Boolean})
-  transparentBackground = false;
+  @property({type: Object})
+  vote?: ApprovalInfo;
 
-  private readonly restApiService = appContext.restApiService;
+  @property({type: Object})
+  label?: LabelInfo;
+
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -96,6 +91,15 @@
           border: 1px solid var(--border-color);
           display: inline-flex;
           padding: 0 1px;
+          /* Any outermost circular icon would fit neatly in the border-radius
+             and won't need padding, but the exact outermost elements will
+             depend on account state and the context gr-account-chip is used.
+             So, these values are passed down to gr-account-label and any
+             outermost elements will use the value and then override it. */
+          --account-label-padding-left: 6px;
+          --account-label-padding-right: 6px;
+          --account-label-circle-padding-left: 0;
+          --account-label-circle-padding-right: 0;
         }
         :host:focus {
           border-color: transparent;
@@ -106,33 +110,30 @@
         :host:focus gr-button {
           background: #ccc;
         }
-        .transparentBackground,
-        gr-button.transparentBackground {
-          background-color: transparent;
-        }
         :host([disabled]) {
           opacity: 0.6;
           pointer-events: none;
         }
-        iron-icon {
-          height: 1.2rem;
-          width: 1.2rem;
+        gr-icon {
+          font-size: 1.2rem;
         }
-        .container gr-account-link::part(gr-account-link-text) {
+        .container gr-account-label::part(gr-account-label-text) {
           color: var(--deemphasized-text-color);
         }
-      `,
-    ];
-  }
-
-  override render() {
-    // To pass CSS mixins for @apply to Polymer components, they need to appear
-    // in <style> inside the template.
-    /* eslint-disable lit/prefer-static-styles */
-    const customStyle = html`
-      <style>
-        .container {
-          --account-label-padding-horizontal: 6px;
+        .container.disliked {
+          border: 1px solid var(--vote-outline-disliked);
+        }
+        .container.recommended {
+          border: 1px solid var(--vote-outline-recommended);
+        }
+        .container.disliked,
+        .container.recommended {
+          --account-label-padding-right: var(--spacing-xs);
+          --account-label-circle-padding-right: var(--spacing-xs);
+        }
+        .container.closeShown {
+          --account-label-padding-right: 3px;
+          --account-label-circle-padding-right: 3px;
         }
         gr-button.remove::part(paper-button),
         gr-button.remove:hover::part(paper-button),
@@ -147,51 +148,53 @@
           line-height: 10px;
           /* This cancels most of the --account-label-padding-horizontal. */
           margin-left: -4px;
-          padding: 0 2px 0 0;
+          padding: 0 2px 0 1px;
           text-decoration: none;
         }
-      </style>
-    `;
-    return html`${customStyle}
-      <div
-        class="${classMap({
-          container: true,
-          transparentBackground: this.transparentBackground,
-        })}"
-      >
-        <gr-account-link
-          .account="${this.account}"
-          .change="${this.change}"
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div
+      class=${classMap({
+        ...this.computeVoteClasses(),
+        container: true,
+        closeShown: this.removable,
+      })}
+    >
+      <div>
+        <gr-account-label
+          .account=${this.account}
+          .change=${this.change}
           ?forceAttention=${this.forceAttention}
           ?highlightAttention=${this.highlightAttention}
-          .voteableText=${this.voteableText}
+          clickable
         >
-        </gr-account-link>
-        <slot name="vote-chip"></slot>
-        <gr-button
-          id="remove"
-          link=""
-          ?hidden=${!this.removable}
-          aria-label="Remove"
-          class="${classMap({
-            remove: true,
-            transparentBackground: this.transparentBackground,
-          })}"
-          @click=${this._handleRemoveTap}
-        >
-          <iron-icon icon="gr-icons:close"></iron-icon>
-        </gr-button>
-      </div>`;
+        </gr-account-label>
+      </div>
+      <slot name="vote-chip"></slot>
+      <gr-button
+        id="remove"
+        link=""
+        ?hidden=${!this.removable}
+        aria-label="Remove"
+        class="remove"
+        @click=${this.handleRemoveTap}
+      >
+        <gr-icon icon="close"></gr-icon>
+      </gr-button>
+    </div>`;
   }
 
   constructor() {
     super();
-    this._getHasAvatars().then(hasAvatars => {
+    this.getHasAvatars().then(hasAvatars => {
       this.showAvatar = hasAvatars;
     });
   }
 
-  _handleRemoveTap(e: MouseEvent) {
+  private handleRemoveTap(e: MouseEvent) {
     e.preventDefault();
     this.dispatchEvent(
       new CustomEvent('remove', {
@@ -202,13 +205,27 @@
     );
   }
 
-  _getHasAvatars() {
+  private getHasAvatars() {
     return this.restApiService
       .getConfig()
       .then(cfg =>
         Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars))
       );
   }
+
+  private computeVoteClasses(): ClassInfo {
+    if (!this.label || !this.account || !hasVoted(this.label, this.account)) {
+      return {};
+    }
+    const status = getLabelStatus(this.label, this.vote?.value);
+    if ([LabelStatus.APPROVED, LabelStatus.RECOMMENDED].includes(status)) {
+      return {recommended: true};
+    } else if ([LabelStatus.REJECTED, LabelStatus.DISLIKED].includes(status)) {
+      return {disliked: true};
+    } else {
+      return {};
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts
new file mode 100644
index 0000000..8f1e169
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_test.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-account-chip';
+import {GrAccountChip} from './gr-account-chip';
+import {
+  createAccountWithIdNameAndEmail,
+  createChange,
+} from '../../../test/test-data-generators';
+
+suite('gr-account-chip tests', () => {
+  let element: GrAccountChip;
+  setup(async () => {
+    const reviewer = createAccountWithIdNameAndEmail();
+    const change = createChange();
+    element = await fixture<GrAccountChip>(html`<gr-account-chip
+      .account=${reviewer}
+      .change=${change}
+    ></gr-account-chip>`);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <div>
+            <gr-account-label clickable="" deselected=""></gr-account-label>
+          </div>
+          <slot name="vote-chip"></slot>
+          <gr-button
+            aria-disabled="false"
+            aria-label="Remove"
+            class="remove"
+            hidden=""
+            id="remove"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            <gr-icon icon="close"></gr-icon>
+          </gr-button>
+        </div>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index acb8348..0509925 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
@@ -1,43 +1,27 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/shared-styles';
 import '../gr-autocomplete/gr-autocomplete';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-entry_html';
-import {customElement, property} from '@polymer/decorators';
 import {
   AutocompleteQuery,
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {SuggestedReviewerInfo} from '../../../types/common';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
 
-export interface GrAccountEntry {
-  $: {
-    input: GrAutocomplete;
-  };
-}
 /**
  * gr-account-entry is an element for entering account
  * and/or group with autocomplete support.
  */
 @customElement('gr-account-entry')
-export class GrAccountEntry extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrAccountEntry extends LitElement {
+  @query('#input') private input?: GrAutocomplete;
 
   /**
    * Fired when an account is entered.
@@ -62,33 +46,71 @@
   @property({type: String})
   placeholder = '';
 
-  @property({type: Object, notify: true})
-  querySuggestions: AutocompleteQuery = () => Promise.resolve([]);
+  @property({type: Object})
+  querySuggestions: AutocompleteQuery<SuggestedReviewerInfo> = () =>
+    Promise.resolve([]);
 
-  @property({type: String, observer: '_inputTextChanged'})
-  _inputText = '';
+  @state() private inputText = '';
 
-  get focusStart() {
-    return this.$.input.focusStart;
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        gr-autocomplete {
+          display: inline-block;
+          flex: 1;
+          overflow: hidden;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-autocomplete
+        id="input"
+        .borderless=${this.borderless}
+        .placeholder=${this.placeholder}
+        .query=${this.querySuggestions}
+        allow-non-suggested-values=${this.allowAnyInput}
+        @commit=${this.handleInputCommit}
+        clear-on-commit
+        warn-uncommitted
+        .text=${this.inputText}
+        .verticalOffset=${24}
+        @text-changed=${this.handleTextChanged}
+      >
+      </gr-autocomplete>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('inputText')) {
+      this.inputTextChanged();
+    }
+  }
+
+  get focusStart(): PaperInputElement | undefined {
+    return this.input!.focusStart;
   }
 
   override focus() {
-    this.$.input.focus();
+    this.input!.focus();
   }
 
   clear() {
-    this.$.input.clear();
+    this.input!.clear();
   }
 
   setText(text: string) {
-    this.$.input.setText(text);
+    this.input!.setText(text);
   }
 
   getText() {
-    return this.$.input.text;
+    return this.input!.text;
   }
 
-  _handleInputCommit(e: CustomEvent) {
+  private handleInputCommit(e: CustomEvent) {
     this.dispatchEvent(
       new CustomEvent('add', {
         detail: {value: e.detail.value},
@@ -96,16 +118,20 @@
         bubbles: true,
       })
     );
-    this.$.input.focus();
+    this.input!.focus();
   }
 
-  _inputTextChanged(text: string) {
-    if (text.length && this.allowAnyInput) {
+  private inputTextChanged() {
+    if (this.inputText.length && this.allowAnyInput) {
       this.dispatchEvent(
         new CustomEvent('account-text-changed', {bubbles: true, composed: true})
       );
     }
   }
+
+  private handleTextChanged(e: BindValueChangeEvent) {
+    this.inputText = e.detail.value ?? '';
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
deleted file mode 100644
index d84ef62..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_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">
-    gr-autocomplete {
-      display: inline-block;
-      flex: 1;
-      overflow: hidden;
-    }
-  </style>
-  <gr-autocomplete
-    id="input"
-    borderless="[[borderless]]"
-    placeholder="[[placeholder]]"
-    query="[[querySuggestions]]"
-    allow-non-suggested-values="[[allowAnyInput]]"
-    on-commit="_handleInputCommit"
-    clear-on-commit=""
-    warn-uncommitted=""
-    text="{{_inputText}}"
-    vertical-offset="24"
-  >
-  </gr-autocomplete>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
index 4bb2232..552e321 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
@@ -1,65 +1,79 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-account-entry';
 import {GrAccountEntry} from './gr-account-entry';
-
-const basicFixture = fixtureFromElement('gr-account-entry');
+import {fixture, html, assert} from '@open-wc/testing';
+import {queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {GrAutocomplete} from '../gr-autocomplete/gr-autocomplete';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
 
 suite('gr-account-entry tests', () => {
   let element: GrAccountEntry;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture<GrAccountEntry>(html`
+      <gr-account-entry></gr-account-entry>
+    `);
+    await element.updateComplete;
   });
 
-  test('account-text-changed fired when input text changed and allowAnyInput', () => {
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-autocomplete
+          allow-non-suggested-values="false"
+          clear-on-commit=""
+          id="input"
+          warn-uncommitted=""
+        >
+        </gr-autocomplete>
+      `
+    );
+  });
+
+  test('account-text-changed fired when input text changed and allowAnyInput', async () => {
     // Spy on query, as that is called when _updateSuggestions proceeds.
     const changeStub = sinon.stub();
     element.allowAnyInput = true;
     element.querySuggestions = () => Promise.resolve([]);
     element.addEventListener('account-text-changed', changeStub);
-    element.$.input.text = 'a';
-    assert.isTrue(changeStub.calledOnce);
-    element.$.input.text = 'ab';
-    assert.isTrue(changeStub.calledTwice);
+    queryAndAssert<GrAutocomplete>(element, '#input').text = 'a';
+    await element.updateComplete;
+    await waitUntil(() => changeStub.calledOnce);
+    queryAndAssert<GrAutocomplete>(element, '#input').text = 'ab';
+    await element.updateComplete;
+    await waitUntil(() => changeStub.calledTwice);
   });
 
-  test(
-    'account-text-changed not fired when input text changed without ' +
-      'allowAnyInput',
-    () => {
-      // Spy on query, as that is called when _updateSuggestions proceeds.
-      const changeStub = sinon.stub();
-      element.querySuggestions = () => Promise.resolve([]);
-      element.addEventListener('account-text-changed', changeStub);
-      element.$.input.text = 'a';
-      assert.isFalse(changeStub.called);
-    }
-  );
-
-  test('setText', () => {
+  test('account-text-changed not fired when input text changed without allowAnyInput', async () => {
     // Spy on query, as that is called when _updateSuggestions proceeds.
-    const suggestSpy = sinon.spy(element.$.input, 'query');
-    element.setText('test text');
-    flush();
+    const changeStub = sinon.stub();
+    element.querySuggestions = () => Promise.resolve([]);
+    element.addEventListener('account-text-changed', changeStub);
+    queryAndAssert<GrAutocomplete>(element, '#input').text = 'a';
+    await element.updateComplete;
+    assert.isFalse(changeStub.called);
+  });
 
-    assert.equal(element.$.input.$.input.value, 'test text');
-    assert.isFalse(suggestSpy.called);
+  test('setText', async () => {
+    // Stub on query, as that is called when _updateSuggestions proceeds.
+    const suggestStub = sinon.stub(
+      queryAndAssert<GrAutocomplete>(element, '#input'),
+      'query'
+    );
+    element.setText('test text');
+    await element.updateComplete;
+
+    const input = queryAndAssert<GrAutocomplete>(element, '#input');
+    assert.equal(
+      queryAndAssert<PaperInputElement>(input, '#input').value,
+      'test text'
+    );
+    assert.isFalse(suggestStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 0283ca4..bb0200a 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
@@ -1,36 +1,29 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-icon/iron-icon';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
-import {appContext} from '../../../services/app-context';
+import '../gr-icon/gr-icon';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {getAppContext} from '../../../services/app-context';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
-import {hasOwnProperty} from '../../../utils/common-util';
+import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {fireEvent} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
-import {ShowAlertEventDetail} from '../../../types/events';
-import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
-import {classMap} from 'lit/directives/class-map';
-import {modifierPressed} from '../../../utils/dom-util';
+import {EventType, ShowAlertEventDetail} from '../../../types/events';
+import {LitElement, css, html, TemplateResult} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
 import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {createSearchUrl} from '../../../models/views/search';
+import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {resolve} from '../../../models/dependency';
 
 @customElement('gr-account-label')
 export class GrAccountLabel extends LitElement {
@@ -48,9 +41,6 @@
   @property({type: Object})
   change?: ChangeInfo;
 
-  @property({type: String})
-  voteableText?: string;
-
   /**
    * Should this user be considered to be in the attention set, regardless
    * of the current state of the change object?
@@ -78,21 +68,15 @@
   @property({type: Boolean})
   hideAvatar = false;
 
-  @property({
-    type: Boolean,
-    reflect: true,
-  })
-  cancelLeftPadding = false;
-
-  @property({type: Boolean})
-  hideStatus = false;
-
   @state()
   _config?: ServerInfo;
 
   @property({type: Boolean, reflect: true})
   selectionChipStyle = false;
 
+  @property({type: Boolean, reflect: true})
+  noStatusIcons = false;
+
   @property({
     type: Boolean,
     reflect: true,
@@ -102,9 +86,20 @@
   @property({type: Boolean, reflect: true})
   deselected = false;
 
-  reporting: ReportingService;
+  @property({type: Boolean, reflect: true})
+  clickable = false;
 
-  private readonly restApiService = appContext.restApiService;
+  @property({type: Boolean, reflect: true})
+  attentionIconShown = false;
+
+  @property({type: Boolean, reflect: true})
+  avatarShown = false;
+
+  readonly reporting = getAppContext().reportingService;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getAccountsModel = resolve(this, accountsModelToken);
 
   static override get styles() {
     return [
@@ -116,14 +111,23 @@
           border-radius: var(--label-border-radius);
           box-sizing: border-box;
           white-space: nowrap;
-          padding: 0 var(--account-label-padding-horizontal, 0);
+          padding-left: var(--account-label-padding-left, 0);
         }
-        /* If the first element is the avatar, then we cancel the left padding,
-        so we can fit nicely into the gr-account-chip rounding. The obvious
-        alternative of 'chip has padding' and 'avatar gets negative margin'
-        does not work, because we need 'overflow:hidden' on the label. */
-        :host([cancelLeftPadding]) {
-          padding-left: 0;
+        :host([avatarShown]:not([attentionIconShown])) {
+          padding-left: var(--account-label-circle-padding-left, 0);
+        }
+        :host([attentionIconShown]) {
+          padding-left: var(--account-label-padding-left, 0);
+        }
+        .rightSidePadding {
+          padding-right: var(--account-label-padding-right, 0);
+          /* The existence of this element will also add 2(!) flexbox gaps */
+          margin-left: -6px;
+        }
+        .container {
+          display: flex;
+          align-items: center;
+          gap: 3px;
         }
         :host::after {
           content: var(--account-label-suffix);
@@ -140,39 +144,30 @@
           border-radius: 8px;
           color: var(--chip-selected-text-color);
         }
-        :host([selected]) iron-icon.attention {
+        :host([selected]) gr-icon.attention {
           color: var(--chip-selected-text-color);
         }
         gr-avatar {
           height: calc(var(--line-height-normal) - 2px);
           width: calc(var(--line-height-normal) - 2px);
-          vertical-align: top;
-          position: relative;
-          top: 1px;
+        }
+        .accountStatusDecorator,
+        .hovercardTargetWrapper {
+          display: contents;
         }
         #attentionButton {
           /* This negates the 4px horizontal padding, which we appreciate as a
          larger click target, but which we don't want to consume space. :-) */
           margin: 0 -4px 0 -4px;
+          --gr-button-padding: 0 var(--spacing-xs);
           vertical-align: top;
         }
-        iron-icon.attention {
+        gr-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;
+          transform: scaleX(0.8);
         }
         .name {
           display: inline-block;
-          text-decoration: inherit;
           vertical-align: top;
           overflow: hidden;
           text-overflow: ellipsis;
@@ -181,96 +176,117 @@
         .hasAttention .name {
           font-weight: var(--font-weight-bold);
         }
+        a.ownerLink {
+          text-decoration: none;
+          color: var(--primary-text-color);
+          display: flex;
+          align-items: center;
+          gap: 3px;
+        }
+        :host([clickable]) a.ownerLink:hover .name {
+          text-decoration: underline;
+        }
       `,
     ];
   }
 
+  override async updated() {
+    assertIsDefined(this.account, 'account');
+    const account = await this.getAccountsModel().fillDetails(this.account);
+    if (account) this.account = account;
+  }
+
   override render() {
     const {account, change, highlightAttention, forceAttention, _config} = this;
     if (!account) return;
-    const hasAttention =
+    this.attentionIconShown =
       forceAttention ||
-      this._hasUnforcedAttention(highlightAttention, account, change);
+      this.hasUnforcedAttention(highlightAttention, account, change);
     this.deselected = !this.selected;
     const hasAvatars = !!_config?.plugin?.has_avatars;
-    this.cancelLeftPadding = !this.hideAvatar && !hasAttention && hasAvatars;
+    this.avatarShown = !this.hideAvatar && hasAvatars;
 
-    return html`<span>
+    return html`
+      <div class="container">
         ${!this.hideHovercard
           ? html`<gr-hovercard-account
               for="hovercardTarget"
               .account=${account}
               .change=${change}
               .highlightAttention=${highlightAttention}
-              .voteableText=${this.voteableText}
             ></gr-hovercard-account>`
           : ''}
-        ${hasAttention
+        ${this.attentionIconShown
           ? html` <gr-tooltip-content
-              ?has-tooltip=${this._computeAttentionButtonEnabled(
+              ?has-tooltip=${this.computeAttentionButtonEnabled(
                 highlightAttention,
                 account,
                 change,
                 false,
                 this._selfAccount
               )}
-              title="${this._computeAttentionIconTitle(
+              title=${this.computeAttentionIconTitle(
                 highlightAttention,
                 account,
                 change,
                 forceAttention,
                 this.selected,
                 this._selfAccount
-              )}"
+              )}
             >
               <gr-button
                 id="attentionButton"
                 link=""
                 aria-label="Remove user from attention set"
-                @click=${this._handleRemoveAttentionClick}
-                ?disabled=${!this._computeAttentionButtonEnabled(
+                @click=${this.handleRemoveAttentionClick}
+                ?disabled=${!this.computeAttentionButtonEnabled(
                   highlightAttention,
                   account,
                   change,
                   this.selected,
                   this._selfAccount
                 )}
-                ><iron-icon
-                  class="attention"
-                  icon="gr-icons:attention"
-                ></iron-icon>
+              >
+                <div>
+                  <gr-icon
+                    icon="label_important"
+                    filled
+                    small
+                    class="attention"
+                  >
+                  </gr-icon>
+                </div>
               </gr-button>
             </gr-tooltip-content>`
           : ''}
-      </span>
-      <span
-        id="hovercardTarget"
-        tabindex="0"
-        @keydown="${(e: KeyboardEvent) => this.handleKeyDown(e)}"
-        class="${classMap({
-          hasAttention: !!hasAttention,
-        })}"
-      >
-        ${!this.hideAvatar
-          ? html`<gr-avatar .account="${account}" imageSize="32"></gr-avatar>`
-          : ''}
-        <span class="text" part="gr-account-label-text">
-          <span class="name"
-            >${this._computeName(account, this.firstName, this._config)}</span
+        ${this.maybeRenderLink(html`
+          <span
+            class=${classMap({
+              hovercardTargetWrapper: true,
+              hasAttention: this.attentionIconShown,
+            })}
           >
-          ${!this.hideStatus && account.status
-            ? html`<iron-icon
-                class="status"
-                icon="gr-icons:unavailable"
-              ></iron-icon>`
-            : ''}
-        </span>
-      </span>`;
+            ${this.avatarShown
+              ? html`<gr-avatar .account=${account} imageSize="32"></gr-avatar>`
+              : ''}
+            <span
+              tabindex=${this.hideHovercard ? '-1' : '0'}
+              role=${ifDefined(this.hideHovercard ? undefined : 'button')}
+              id="hovercardTarget"
+              class="name"
+              part="gr-account-label-text"
+            >
+              ${this.computeName(account, this.firstName, this._config)}
+            </span>
+            ${this.renderAccountStatusPlugins()}
+          </span>
+        `)}
+      </div>
+    `;
   }
 
   constructor() {
     super();
-    this.reporting = appContext.reportingService;
     this.restApiService.getConfig().then(config => {
       this._config = config;
     });
@@ -283,16 +299,38 @@
     });
   }
 
-  handleKeyDown(e: KeyboardEvent) {
-    if (modifierPressed(e)) return;
-    // Only react to `return` and `space`.
-    if (e.keyCode !== 13 && e.keyCode !== 32) return;
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new Event('click'));
+  private maybeRenderLink(span: TemplateResult) {
+    if (!this.clickable || !this.account) return span;
+    const url = createSearchUrl({
+      owner:
+        this.account.email ||
+        this.account.username ||
+        this.account.name ||
+        `${this.account._account_id}`,
+    });
+    if (!url) return span;
+    return html`<a class="ownerLink" href=${url} tabindex="-1">${span}</a>`;
   }
 
-  _isAttentionSetEnabled(
+  private renderAccountStatusPlugins() {
+    if (!this.account?._account_id || this.noStatusIcons) {
+      return;
+    }
+    return html`
+      <gr-endpoint-decorator
+        class="accountStatusDecorator"
+        name="account-status-icon"
+      >
+        <gr-endpoint-param
+          name="accountId"
+          .value=${this.account._account_id}
+        ></gr-endpoint-param>
+        <span class="rightSidePadding"></span>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private isAttentionSetEnabled(
     highlight: boolean,
     account: AccountInfo,
     change?: ChangeInfo
@@ -300,13 +338,13 @@
     return highlight && !!change && !!account && !isServiceUser(account);
   }
 
-  _hasUnforcedAttention(
+  private hasUnforcedAttention(
     highlight: boolean,
     account: AccountInfo,
     change?: ChangeInfo
-  ) {
-    return (
-      this._isAttentionSetEnabled(highlight, account, change) &&
+  ): boolean {
+    return !!(
+      this.isAttentionSetEnabled(highlight, account, change) &&
       change &&
       change.attention_set &&
       !!account._account_id &&
@@ -314,15 +352,12 @@
     );
   }
 
-  _computeName(
-    account?: AccountInfo,
-    firstName?: boolean,
-    config?: ServerInfo
-  ) {
+  // Private but used in tests.
+  computeName(account?: AccountInfo, firstName?: boolean, config?: ServerInfo) {
     return getDisplayName(config, account, firstName);
   }
 
-  _handleRemoveAttentionClick(e: MouseEvent) {
+  private handleRemoveAttentionClick(e: MouseEvent) {
     if (!this.account || !this.change) return;
     if (this.selected) return;
     e.preventDefault();
@@ -330,7 +365,7 @@
     if (!this.account._account_id) return;
 
     this.dispatchEvent(
-      new CustomEvent<ShowAlertEventDetail>('show-alert', {
+      new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
         detail: {
           message: 'Saving attention set update ...',
           dismissOnNavigation: true,
@@ -350,7 +385,7 @@
 
     this.reporting.reportInteraction(
       'attention-icon-remove',
-      this._reportingDetails()
+      this.reportingDetails()
     );
     this.restApiService
       .removeFromAttentionSet(
@@ -363,7 +398,7 @@
       });
   }
 
-  _reportingDetails() {
+  private reportingDetails() {
     if (!this.account) return;
     const targetId = this.account._account_id;
     const ownerId =
@@ -385,7 +420,7 @@
     };
   }
 
-  _computeAttentionButtonEnabled(
+  private computeAttentionButtonEnabled(
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo | undefined,
@@ -394,12 +429,12 @@
   ) {
     if (selected) return true;
     return (
-      !!this._hasUnforcedAttention(highlight, account, change) &&
+      !!this.hasUnforcedAttention(highlight, account, change) &&
       (isInvolved(change, selfAccount) || isSelf(account, selfAccount))
     );
   }
 
-  _computeAttentionIconTitle(
+  private computeAttentionIconTitle(
     highlight: boolean,
     account: AccountInfo,
     change: ChangeInfo | undefined,
@@ -407,15 +442,18 @@
     selected: boolean,
     selfAccount?: AccountInfo
   ) {
-    const enabled = this._computeAttentionButtonEnabled(
+    const enabled = this.computeAttentionButtonEnabled(
       highlight,
       account,
       change,
       selected,
       selfAccount
     );
+    const removeFromASTooltip = `Click to remove ${
+      account._account_id === selfAccount?._account_id ? 'yourself' : 'the user'
+    } from the attention set`;
     return enabled
-      ? 'Click to remove the user from the attention set'
+      ? removeFromASTooltip
       : 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_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index 574e450..e7c0536 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -1,66 +1,128 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-account-label';
 import {
+  query,
   queryAndAssert,
   spyRestApi,
   stubRestApi,
+  waitEventLoop,
 } from '../../../test/test-utils';
 import {GrAccountLabel} from './gr-account-label';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {
-  createAccountDetailWithId,
+  createAccountDetailWithIdNameAndEmail,
   createChange,
+  createPluginConfig,
   createServerInfo,
 } from '../../../test/test-data-generators';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-account-label');
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
 
 suite('gr-account-label tests', () => {
   let element: GrAccountLabel;
   const kermit: AccountDetailInfo = {
-    ...createAccountDetailWithId(31),
+    ...createAccountDetailWithIdNameAndEmail(31),
     name: 'kermit',
   };
 
-  setup(() => {
-    stubRestApi('getAccount').callsFake(() => Promise.resolve(kermit));
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    element = basicFixture.instantiate();
-    element._config = {
+  setup(async () => {
+    stubRestApi('getAccount').resolves(kermit);
+    stubRestApi('getLoggedIn').resolves(false);
+    stubRestApi('getConfig').resolves({
       ...createServerInfo(),
+      plugin: {
+        ...createPluginConfig(),
+        has_avatars: true,
+      },
       user: {
         anonymous_coward_name: 'Anonymous Coward',
       },
-    };
+    });
+    element = await fixture(html`<gr-account-label></gr-account-label>`);
+    await element.updateComplete;
+  });
+
+  test('renders', async () => {
+    element.account = kermit;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
+          <span class="hovercardTargetWrapper">
+            <gr-avatar hidden="" imagesize="32"> </gr-avatar>
+            <span
+              class="name"
+              id="hovercardTarget"
+              part="gr-account-label-text"
+              role="button"
+              tabindex="0"
+            >
+              kermit
+            </span>
+            <gr-endpoint-decorator
+              class="accountStatusDecorator"
+              name="account-status-icon"
+            >
+              <gr-endpoint-param name="accountId"></gr-endpoint-param>
+              <span class="rightSidePadding"></span>
+            </gr-endpoint-decorator>
+          </span>
+        </div>
+      `
+    );
+  });
+
+  test('renders clickable', async () => {
+    element.account = kermit;
+    element.clickable = true;
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="container">
+          <gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
+          <a class="ownerLink" href="/q/owner:user-31%2540" tabindex="-1">
+            <span class="hovercardTargetWrapper">
+              <gr-avatar hidden="" imagesize="32"> </gr-avatar>
+              <span
+                class="name"
+                id="hovercardTarget"
+                part="gr-account-label-text"
+                role="button"
+                tabindex="0"
+              >
+                kermit
+              </span>
+              <gr-endpoint-decorator
+                class="accountStatusDecorator"
+                name="account-status-icon"
+              >
+                <gr-endpoint-param name="accountId"></gr-endpoint-param>
+                <span class="rightSidePadding"></span>
+              </gr-endpoint-decorator>
+            </span>
+          </a>
+        </div>
+      `
+    );
   });
 
   suite('_computeName', () => {
     test('not showing anonymous', () => {
       const account = {name: 'Wyatt'};
-      assert.deepEqual(element._computeName(account, false), 'Wyatt');
+      assert.deepEqual(element.computeName(account, false), 'Wyatt');
     });
 
     test('showing anonymous but no config', () => {
       const account = {};
-      assert.deepEqual(element._computeName(account, false), 'Anonymous');
+      assert.deepEqual(element.computeName(account, false), 'Anonymous');
     });
 
     test('test for Anonymous Coward user and replace with Anonymous', () => {
@@ -72,7 +134,7 @@
       };
       const account = {};
       assert.deepEqual(
-        element._computeName(account, false, config),
+        element.computeName(account, false, config),
         'Anonymous'
       );
     });
@@ -85,10 +147,7 @@
         },
       };
       const account = {};
-      assert.deepEqual(
-        element._computeName(account, false, config),
-        'TestAnon'
-      );
+      assert.deepEqual(element.computeName(account, false, config), 'TestAnon');
     });
   });
 
@@ -101,20 +160,20 @@
       };
       element._selfAccount = kermit;
       element.account = {
-        ...createAccountDetailWithId(42),
+        ...createAccountDetailWithIdNameAndEmail(42),
         name: 'ernie',
       };
       element.change = {
         ...createChange(),
         attention_set: {
           42: {
-            account: createAccountDetailWithId(42),
+            account: createAccountDetailWithIdNameAndEmail(42),
           },
         },
         owner: kermit,
         reviewers: {},
       };
-      await flush();
+      await waitEventLoop();
     });
 
     test('show attention button', () => {
@@ -125,12 +184,26 @@
 
     test('tap attention button', async () => {
       const apiSpy = spyRestApi('removeFromAttentionSet');
-      const button = queryAndAssert(element, '#attentionButton');
+      const button = queryAndAssert<GrButton>(element, '#attentionButton');
       assert.ok(button);
       assert.isNull(button.getAttribute('disabled'));
-      MockInteractions.tap(button);
+      button.click();
       assert.isTrue(apiSpy.calledOnce);
       assert.equal(apiSpy.lastCall.args[1], 42);
     });
+
+    test('no status icons attribute', async () => {
+      queryAndAssert(
+        element,
+        'gr-endpoint-decorator[name="account-status-icon"]'
+      );
+
+      element.noStatusIcons = true;
+      await element.updateComplete;
+
+      assert.notExists(
+        query(element, 'gr-endpoint-decorator[name="account-status-icon"]')
+      );
+    });
   });
 });
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
deleted file mode 100644
index f0c9106..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
+++ /dev/null
@@ -1,123 +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 '../gr-account-label/gr-account-label';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {AccountInfo, ChangeInfo} from '../../../types/common';
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {ParsedChangeInfo} from '../../../types/types';
-
-@customElement('gr-account-link')
-export class GrAccountLink extends LitElement {
-  @property({type: String})
-  voteableText?: string;
-
-  @property({type: Object})
-  account?: AccountInfo;
-
-  /**
-   * Optional ChangeInfo object, typically comes from the change page or
-   * from a row in a list of search results. This is needed for some change
-   * related features like adding the user as a reviewer.
-   */
-  @property({type: Object})
-  change?: ChangeInfo | ParsedChangeInfo;
-
-  /**
-   * Should this user be considered to be in the attention set, regardless
-   * of the current state of the change object?
-   */
-  @property({type: Boolean})
-  forceAttention = false;
-
-  /**
-   * Should attention set related features be shown in the component? Note
-   * that the information whether the user is in the attention set or not is
-   * part of the ChangeInfo object in the change property.
-   */
-  @property({type: Boolean})
-  highlightAttention = false;
-
-  @property({type: Boolean})
-  hideAvatar = false;
-
-  @property({type: Boolean})
-  hideStatus = false;
-
-  /**
-   * Only show the first name in the account label.
-   */
-  @property({type: Boolean})
-  firstName = false;
-
-  static override get styles() {
-    return [
-      css`
-        :host {
-          display: inline-block;
-          vertical-align: top;
-        }
-        a {
-          color: var(--primary-text-color);
-          text-decoration: none;
-        }
-        gr-account-label::part(gr-account-label-text):hover {
-          text-decoration: underline !important;
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    if (!this.account) return;
-    return html`<span>
-      <a href="${this._computeOwnerLink(this.account)}">
-        <gr-account-label
-          .account="${this.account}"
-          .change="${this.change}"
-          ?forceAttention=${this.forceAttention}
-          ?highlightAttention=${this.highlightAttention}
-          ?hideAvatar=${this.hideAvatar}
-          ?hideStatus=${this.hideStatus}
-          ?firstName=${this.firstName}
-          .voteableText=${this.voteableText}
-          exportparts="gr-account-label-text: gr-account-link-text"
-        >
-        </gr-account-label>
-      </a>
-    </span>`;
-  }
-
-  _computeOwnerLink(account?: AccountInfo) {
-    if (!account) {
-      return;
-    }
-    return GerritNav.getUrlForOwner(
-      account.email ||
-        account.username ||
-        account.name ||
-        `${account._account_id}`
-    );
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-account-link': GrAccountLink;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts
deleted file mode 100644
index c754e47..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts
+++ /dev/null
@@ -1,60 +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';
-import './gr-account-link';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {GrAccountLink} from './gr-account-link';
-import {createAccountWithId} from '../../../test/test-data-generators';
-import {AccountId, AccountInfo, EmailAddress} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-account-link');
-
-suite('gr-account-link tests', () => {
-  let element: GrAccountLink;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('computed fields', () => {
-    const url = 'test/url';
-    const urlStub = sinon.stub(GerritNav, 'getUrlForOwner').returns(url);
-    const account: AccountInfo = {
-      ...createAccountWithId(),
-      email: 'email' as EmailAddress,
-      username: 'username',
-      name: 'name',
-      _account_id: 5 as AccountId,
-    };
-    assert.isNotOk(element._computeOwnerLink());
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('email'));
-
-    delete account.email;
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('username'));
-
-    delete account.username;
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('name'));
-
-    delete account.name;
-    assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('5'));
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 5449981..65d8859 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
@@ -1,132 +1,103 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-account-chip/gr-account-chip';
 import '../gr-account-entry/gr-account-entry';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-list_html';
-import {appContext} from '../../../services/app-context';
-import {customElement, property} from '@polymer/decorators';
+import {getAppContext} from '../../../services/app-context';
 import {
   ChangeInfo,
   Suggestion,
   AccountInfo,
   GroupInfo,
   EmailAddress,
+  SuggestedReviewerGroupInfo,
+  SuggestedReviewerAccountInfo,
+  SuggestedReviewerInfo,
+  isGroup,
 } from '../../../types/common';
-import {
-  ReviewerSuggestionsProvider,
-  SuggestionItem,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {ReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
 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';
-import {accountOrGroupKey} from '../../../utils/account-util';
+import {fire, fireAlert} from '../../../utils/event-util';
+import {getUserId, isAccountNewlyAdded} from '../../../utils/account-util';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {classMap} from 'lit/directives/class-map.js';
+import {
+  AutocompleteQuery,
+  AutocompleteSuggestion,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
+import {ValueChangedEvent} from '../../../types/events';
+import {difference, queryAndAssert} from '../../../utils/common-util';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {IronInputElement} from '@polymer/iron-input';
+import {ReviewerState} from '../../../api/rest-api';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
 declare global {
+  interface HTMLElementEventMap {
+    'accounts-changed': ValueChangedEvent<(AccountInfo | GroupInfo)[]>;
+    'pending-confirmation-changed': ValueChangedEvent<SuggestedReviewerGroupInfo | null>;
+  }
   interface HTMLElementTagNameMap {
     'gr-account-list': GrAccountList;
   }
+  interface HTMLElementEventMap {
+    'account-added': CustomEvent<AccountInputDetail>;
+  }
 }
-
-export interface GrAccountList {
-  $: {
-    entry: GrAccountEntry;
-  };
-}
-
-/**
- * For item added with account info
- */
-export interface AccountObjectInput {
-  account: AccountInfo;
-}
-
-/**
- * For item added with group info
- */
-export interface GroupObjectInput {
-  group: GroupInfo;
-  confirm: boolean;
+export interface AccountInputDetail {
+  account: AccountInput;
 }
 
 /** Supported input to be added */
-export type RawAccountInput = string | AccountObjectInput | GroupObjectInput;
+export type RawAccountInput =
+  | string
+  | SuggestedReviewerAccountInfo
+  | SuggestedReviewerGroupInfo;
 
-// type guards for AccountObjectInput and GroupObjectInput
-function isAccountObject(x: RawAccountInput): x is AccountObjectInput {
-  return !!(x as AccountObjectInput).account;
+// type guards for SuggestedReviewerAccountInfo and SuggestedReviewerGroupInfo
+function isAccountObject(
+  x: RawAccountInput
+): x is SuggestedReviewerAccountInfo {
+  return !!(x as SuggestedReviewerAccountInfo).account;
 }
 
-function isGroupObjectInput(x: RawAccountInput): x is GroupObjectInput {
-  return !!(x as GroupObjectInput).group;
+function isSuggestedReviewerGroupInfo(
+  x: RawAccountInput
+): x is SuggestedReviewerGroupInfo {
+  return !!(x as SuggestedReviewerGroupInfo).group;
 }
 
 // Internal input type with account info
 export interface AccountInfoInput extends AccountInfo {
-  _group?: boolean;
   _account?: boolean;
-  _pendingAdd?: boolean;
   confirmed?: boolean;
 }
 
 // Internal input type with group info
 export interface GroupInfoInput extends GroupInfo {
-  _group?: boolean;
   _account?: boolean;
-  _pendingAdd?: boolean;
   confirmed?: boolean;
 }
 
-function isAccountInfoInput(x: AccountInput): x is AccountInfoInput {
-  const input = x as AccountInfoInput;
-  return !!input._account || !!input._account_id || !!input.email;
-}
-
-function isGroupInfoInput(x: AccountInput): x is GroupInfoInput {
-  const input = x as GroupInfoInput;
-  return !!input._group || !!input.id;
-}
-
-type AccountInput = AccountInfoInput | GroupInfoInput;
-
-export interface AccountAddition {
-  account?: AccountInfoInput;
-  group?: GroupInfoInput;
-}
+export type AccountInput = AccountInfoInput | GroupInfoInput;
 
 @customElement('gr-account-list')
-export class GrAccountList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountList extends LitElement {
   /**
    * Fired when user inputs an invalid email address.
    *
    * @event show-alert
    */
+  @query('#entry') entry?: GrAccountEntry;
 
-  @property({type: Array, notify: true})
+  @property({type: Array})
   accounts: AccountInput[] = [];
 
   @property({type: Object})
@@ -135,12 +106,15 @@
   @property({type: Object})
   filter?: (input: Suggestion) => boolean;
 
-  @property({type: String})
+  @property()
   placeholder = '';
 
   @property({type: Boolean})
   disabled = false;
 
+  @property({type: String})
+  reviewerState?: ReviewerState;
+
   /**
    * Returns suggestions and convert them to list item
    */
@@ -150,8 +124,8 @@
   /**
    * Needed for template checking since value is initially set to null.
    */
-  @property({type: Object, notify: true})
-  pendingConfirmation: GroupObjectInput | null = null;
+  @property({type: Object})
+  pendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
   @property({type: Boolean})
   readonly = false;
@@ -159,7 +133,7 @@
   /**
    * When true, allows for non-suggested inputs to be added.
    */
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'allow-any-input'})
   allowAnyInput = false;
 
   /**
@@ -169,37 +143,107 @@
   @property({type: Array})
   removableValues?: AccountInput[];
 
-  @property({type: Number})
-  maxCount = 0;
-
   /**
    * Returns suggestion items
    */
-  @property({type: Object})
-  _querySuggestions: (input: string) => Promise<SuggestionItem[]>;
+  @state() private querySuggestions: AutocompleteQuery<SuggestedReviewerInfo>;
 
-  reporting: ReportingService;
-
-  private pendingRemoval: Set<AccountInput> = new Set();
+  private readonly reporting = getAppContext().reportingService;
 
   constructor() {
     super();
-    this.reporting = appContext.reportingService;
-    this._querySuggestions = input => this._getSuggestions(input);
+    this.querySuggestions = input => this.getSuggestions(input);
     this.addEventListener('remove', e =>
-      this._handleRemove(e as CustomEvent<{account: AccountInput}>)
+      this.handleRemove(e as CustomEvent<{account: AccountInput}>)
     );
   }
 
-  get accountChips() {
-    return Array.from(this.root?.querySelectorAll('gr-account-chip') || []);
+  static override styles = [
+    sharedStyles,
+    css`
+      gr-account-chip {
+        display: inline-block;
+        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+      }
+      gr-account-entry {
+        display: flex;
+        flex: 1;
+        min-width: 10em;
+        margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
+      }
+      .group {
+        --account-label-suffix: ' (group)';
+      }
+      .newlyAdded {
+        font-style: italic;
+      }
+      .list {
+        align-items: center;
+        display: flex;
+        flex-wrap: wrap;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="list">
+        ${this.accounts.map(
+          account => html`
+            <gr-account-chip
+              .account=${account}
+              .change=${this.change}
+              class=${classMap({
+                group: isGroup(account),
+                newlyAdded: isAccountNewlyAdded(
+                  account,
+                  this.reviewerState,
+                  this.change
+                ),
+              })}
+              ?removable=${this.computeRemovable(account)}
+              @keydown=${this.handleChipKeydown}
+              tabindex="-1"
+            >
+            </gr-account-chip>
+          `
+        )}
+      </div>
+      <gr-account-entry
+        borderless=""
+        ?hidden=${this.readonly}
+        id="entry"
+        .placeholder=${this.placeholder}
+        @add=${this.handleAdd}
+        @keydown=${this.handleInputKeydown}
+        .allowAnyInput=${this.allowAnyInput}
+        .querySuggestions=${this.querySuggestions}
+      >
+      </gr-account-entry>
+      <slot></slot>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('pendingConfirmation')) {
+      fire(this, 'pending-confirmation-changed', {
+        value: this.pendingConfirmation,
+      });
+    }
+  }
+
+  get accountChips(): GrAccountChip[] {
+    return Array.from(
+      this.shadowRoot?.querySelectorAll('gr-account-chip') || []
+    );
   }
 
   get focusStart() {
-    return this.$.entry.focusStart;
+    // Entry is always defined and we cannot return undefined.
+    return this.entry?.focusStart;
   }
 
-  _getSuggestions(input: string) {
+  getSuggestions(
+    input: string
+  ): Promise<AutocompleteSuggestion<SuggestedReviewerInfo>[]> {
     const provider = this.suggestionsProvider;
     if (!provider) return Promise.resolve([]);
     return provider.getSuggestions(input).then(suggestions => {
@@ -213,132 +257,122 @@
     });
   }
 
-  _handleAdd(e: CustomEvent<{value: RawAccountInput}>) {
-    this.addAccountItem(e.detail.value);
+  // private but used in test
+  handleAdd(e: ValueChangedEvent<string>) {
+    // TODO(TS) this is temporary hack to avoid cascade of ts issues
+    const item = e.detail.value as RawAccountInput;
+    this.addAccountItem(item);
   }
 
   addAccountItem(item: RawAccountInput) {
     // Append new account or group to the accounts property. We add our own
     // internal properties to the account/group here, so we clone the object
     // to avoid cluttering up the shared change object.
+    let account;
+    let group;
     let itemTypeAdded = 'unknown';
     if (isAccountObject(item)) {
-      const account = {...item.account, _pendingAdd: true};
-      this.removeFromPendingRemoval(account);
-      this.push('accounts', account);
+      account = {...item.account};
+      this.accounts.push(account);
       itemTypeAdded = 'account';
-    } else if (isGroupObjectInput(item)) {
+    } else if (isSuggestedReviewerGroupInfo(item)) {
       if (item.confirm) {
         this.pendingConfirmation = item;
         return;
       }
-      const group = {...item.group, _pendingAdd: true, _group: true};
-      this.push('accounts', group);
-      this.removeFromPendingRemoval(group);
+      group = {...item.group};
+      this.accounts.push(group);
       itemTypeAdded = 'group';
     } else if (this.allowAnyInput) {
       if (!item.includes('@')) {
         // 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.entry?.setText(item);
         fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
-        const account = {email: item as EmailAddress, _pendingAdd: true};
-        this.push('accounts', account);
-        this.removeFromPendingRemoval(account);
+        account = {email: item as EmailAddress};
+        this.accounts.push(account);
         itemTypeAdded = 'email';
       }
     }
-
+    fire(this, 'accounts-changed', {value: this.accounts.slice()});
+    fire(this, 'account-added', {account: (account ?? group)! as AccountInput});
     this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
     this.pendingConfirmation = null;
+    this.requestUpdate();
     return true;
   }
 
   confirmGroup(group: GroupInfo) {
-    this.push('accounts', {
+    this.accounts.push({
       ...group,
       confirmed: true,
-      _pendingAdd: true,
-      _group: true,
     });
     this.pendingConfirmation = null;
+    fire(this, 'accounts-changed', {value: this.accounts});
+    this.requestUpdate();
   }
 
-  _computeChipClass(account: AccountInput) {
-    const classes = [];
-    if (account._group) {
-      classes.push('group');
-    }
-    if (account._pendingAdd) {
-      classes.push('pendingAdd');
-    }
-    return classes.join(' ');
-  }
-
-  _computeRemovable(account: AccountInput, readonly: boolean) {
-    if (readonly) {
+  // private but used in test
+  computeRemovable(account: AccountInput) {
+    if (this.readonly) {
       return false;
     }
     if (this.removableValues) {
       for (let i = 0; i < this.removableValues.length; i++) {
-        if (
-          accountOrGroupKey(this.removableValues[i]) ===
-          accountOrGroupKey(account)
-        ) {
+        if (getUserId(this.removableValues[i]) === getUserId(account)) {
           return true;
         }
       }
-      return !!account._pendingAdd;
+      return isAccountNewlyAdded(account, this.reviewerState, this.change);
     }
     return true;
   }
 
-  _handleRemove(e: CustomEvent<{account: AccountInput}>) {
+  private handleRemove(e: CustomEvent<{account: AccountInput}>) {
     const toRemove = e.detail.account;
     this.removeAccount(toRemove);
-    this.$.entry.focus();
+    this.entry?.focus();
   }
 
   removeAccount(toRemove?: AccountInput) {
-    if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
-      return;
+    if (!toRemove || !this.computeRemovable(toRemove)) {
+      return false;
     }
     for (let i = 0; i < this.accounts.length; i++) {
-      if (accountOrGroupKey(toRemove) === accountOrGroupKey(this.accounts[i])) {
-        this.splice('accounts', i, 1);
-        this.pendingRemoval.add(toRemove);
+      if (getUserId(toRemove) === getUserId(this.accounts[i])) {
+        this.accounts.splice(i, 1);
         this.reporting.reportInteraction(`Remove from ${this.id}`);
-        return;
+        this.requestUpdate();
+        fire(this, 'accounts-changed', {value: this.accounts.slice()});
+        return true;
       }
     }
-    this.reporting.error(
-      new Error(`Received "remove" event for missing account: ${toRemove}`)
-    );
+    return false;
   }
 
-  _getNativeInput(paperInput: PaperInputElementExt) {
-    // In Polymer 2 inputElement isn't nativeInput anymore
-    return (paperInput.$.nativeInput ||
-      paperInput.inputElement) as HTMLTextAreaElement;
+  // private but used in test
+  getOwnNativeInput(paperInput: PaperInputElement) {
+    return (paperInput.inputElement as IronInputElement)
+      .inputElement as HTMLInputElement;
   }
 
-  _handleInputKeydown(
-    e: CustomEvent<{input: PaperInputElementExt; keyCode: number}>
-  ) {
-    const input = this._getNativeInput(e.detail.input);
+  private handleInputKeydown(e: KeyboardEvent) {
+    const target = e.target as GrAccountEntry;
+    const entryInput = queryAndAssert<GrAutocomplete>(target, '#input');
+    const input = this.getOwnNativeInput(entryInput.input!);
     if (
       input.selectionStart !== input.selectionEnd ||
       input.selectionStart !== 0
     ) {
       return;
     }
-    switch (e.detail.keyCode) {
-      case 8: // Backspace
+    switch (e.key) {
+      case 'Backspace':
         this.removeAccount(this.accounts[this.accounts.length - 1]);
         break;
-      case 37: // Left arrow
+      case 'ArrowLeft':
         if (this.accountChips[this.accountChips.length - 1]) {
           this.accountChips[this.accountChips.length - 1].focus();
         }
@@ -346,15 +380,15 @@
     }
   }
 
-  _handleChipKeydown(e: KeyboardEvent) {
+  private handleChipKeydown(e: KeyboardEvent) {
     const chip = e.target as GrAccountChip;
     const chips = this.accountChips;
     const index = chips.indexOf(chip);
-    switch (e.keyCode) {
-      case 8: // Backspace
-      case 13: // Enter
-      case 32: // Spacebar
-      case 46: // Delete
+    switch (e.key) {
+      case 'Backspace':
+      case 'Enter':
+      case ' ':
+      case 'Delete':
         this.removeAccount(chip.account);
         // Splice from this array to avoid inconsistent ordering of
         // event handling.
@@ -364,21 +398,21 @@
         } else if (index > 0) {
           chips[index - 1].focus();
         } else {
-          this.$.entry.focus();
+          this.entry?.focus();
         }
         break;
-      case 37: // Left arrow
+      case 'ArrowLeft':
         if (index > 0) {
           chip.blur();
           chips[index - 1].focus();
         }
         break;
-      case 39: // Right arrow
+      case 'ArrowRight':
         chip.blur();
         if (index < chips.length - 1) {
           chips[index + 1].focus();
         } else {
-          this.$.entry.focus();
+          this.entry?.focus();
         }
         break;
     }
@@ -393,56 +427,30 @@
    * return true.
    */
   submitEntryText() {
-    const text = this.$.entry.getText();
-    if (!text.length) {
+    const text = this.entry?.getText();
+    if (!text?.length) {
       return true;
     }
     const wasSubmitted = this.addAccountItem(text);
     if (wasSubmitted) {
-      this.$.entry.clear();
+      this.entry?.clear();
     }
     return wasSubmitted;
   }
 
-  additions(): AccountAddition[] {
-    return this.accounts
-      .filter(account => account._pendingAdd)
-      .map(account => {
-        if (isGroupInfoInput(account)) {
-          return {group: account};
-        } else if (isAccountInfoInput(account)) {
-          return {account};
-        } else {
-          throw new Error('AccountInput must be either Account or Group.');
-        }
-      });
+  additions(): (AccountInfoInput | GroupInfoInput)[] {
+    if (!this.change) return [];
+    return this.accounts.filter(account =>
+      isAccountNewlyAdded(account, this.reviewerState, this.change)
+    );
   }
 
-  removals(): AccountAddition[] {
-    return Array.from(this.pendingRemoval).map(account => {
-      if (isGroupInfoInput(account)) {
-        return {group: account};
-      } else if (isAccountInfoInput(account)) {
-        return {account};
-      } else {
-        throw new Error('AccountInput must be either Account or Group.');
-      }
-    });
-  }
-
-  removeFromPendingRemoval(account: AccountInput) {
-    this.pendingRemoval.delete(account);
-  }
-
-  clearPendingRemovals() {
-    this.pendingRemoval.clear();
-  }
-
-  _computeEntryHidden(
-    maxCount: number,
-    accountsRecord: PolymerDeepPropertyChange<AccountInput[], AccountInput[]>,
-    readonly: boolean
-  ) {
-    return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
+  removals(): AccountInfo[] {
+    if (!this.reviewerState) return [];
+    return difference(
+      this.change?.reviewers[this.reviewerState] ?? [],
+      this.accounts,
+      (a, b) => getUserId(a) === getUserId(b)
+    );
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
deleted file mode 100644
index 7a47e29..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-account-chip {
-      display: inline-block;
-      margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
-    }
-    gr-account-entry {
-      display: flex;
-      flex: 1;
-      min-width: 10em;
-      margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0;
-    }
-    .group {
-      --account-label-suffix: ' (group)';
-    }
-    .pending-add {
-      font-style: italic;
-    }
-    .list {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-    }
-  </style>
-  <!--
-      NOTE(Issue 6419): Nest the inner dom-repeat template in a div rather than
-      as a direct child of the dom-module's template.
-    -->
-  <div class="list">
-    <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
-      <gr-account-chip
-        account="[[account]]"
-        class$="[[_computeChipClass(account)]]"
-        data-account-id$="[[account._account_id]]"
-        removable="[[_computeRemovable(account, readonly)]]"
-        on-keydown="_handleChipKeydown"
-        tabindex="-1"
-      >
-      </gr-account-chip>
-    </template>
-  </div>
-  <gr-account-entry
-    borderless=""
-    hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
-    id="entry"
-    placeholder="[[placeholder]]"
-    on-add="_handleAdd"
-    on-input-keydown="_handleInputKeydown"
-    allow-any-input="[[allowAnyInput]]"
-    query-suggestions="[[_querySuggestions]]"
-  >
-  </gr-account-entry>
-  <slot></slot>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 23e5a72..6b4d670 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -1,20 +1,9 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-account-list';
 import {
   AccountInfoInput,
@@ -25,16 +14,28 @@
   AccountId,
   AccountInfo,
   EmailAddress,
+  GroupBaseInfo,
   GroupId,
-  GroupInfo,
-  SuggestedReviewerAccountInfo,
+  GroupName,
+  SuggestedReviewerInfo,
   Suggestion,
 } from '../../../types/common';
-import {queryAll} from '../../../test/test-utils';
-import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-account-list');
+import {
+  pressKey,
+  queryAll,
+  queryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
+import {ReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {
+  AutocompleteSuggestion,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
+import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
+import {createChange} from '../../../test/test-data-generators';
+import {ReviewerState} from '../../../api/rest-api';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
 
 class MockSuggestionsProvider implements ReviewerSuggestionsProvider {
   init() {}
@@ -43,7 +44,9 @@
     return Promise.resolve([]);
   }
 
-  makeSuggestionItem(_: Suggestion) {
+  makeSuggestionItem(
+    _: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo> {
     return {
       name: 'test',
       value: {
@@ -51,7 +54,7 @@
           _account_id: 1 as AccountId,
         } as AccountInfo,
         count: 1,
-      } as SuggestedReviewerAccountInfo,
+      },
     };
   }
 }
@@ -64,11 +67,11 @@
       _account_id: accountId as AccountId,
     };
   };
-  const makeGroup: () => GroupInfo = function () {
+  const makeGroup: () => GroupBaseInfo = function () {
     const groupId = `group${++_nextAccountId}`;
     return {
       id: groupId as GroupId,
-      _group: true,
+      name: 'abcd' as GroupName,
     };
   };
 
@@ -83,46 +86,71 @@
   }
 
   function handleAdd(value: RawAccountInput) {
-    element._handleAdd(
-      new CustomEvent<{value: RawAccountInput}>('add', {detail: {value}})
+    element.handleAdd(
+      new CustomEvent<{value: string}>('add', {
+        detail: {value: value as unknown as string},
+      })
     );
   }
 
-  setup(() => {
+  setup(async () => {
     existingAccount1 = makeAccount();
     existingAccount2 = makeAccount();
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-account-list></gr-account-list>`);
     element.accounts = [existingAccount1, existingAccount2];
+    element.reviewerState = ReviewerState.REVIEWER;
+    element.change = {...createChange()};
+    element.change.reviewers[ReviewerState.REVIEWER] = [...element.accounts];
     suggestionsProvider = new MockSuggestionsProvider();
     element.suggestionsProvider = suggestionsProvider;
+    await element.updateComplete;
   });
 
-  test('account entry only appears when editable', () => {
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */
+      `<div class="list">
+          <gr-account-chip removable="" tabindex="-1"> </gr-account-chip>
+          <gr-account-chip removable="" tabindex="-1"> </gr-account-chip>
+        </div>
+        <gr-account-entry borderless="" id="entry"></gr-account-entry>
+        <slot></slot>`
+    );
+  });
+
+  test('account entry only appears when editable', async () => {
     element.readonly = false;
-    assert.isFalse(element.$.entry.hasAttribute('hidden'));
+    await element.updateComplete;
+    assert.isFalse(
+      queryAndAssert<GrAccountEntry>(element, '#entry').hasAttribute('hidden')
+    );
     element.readonly = true;
-    assert.isTrue(element.$.entry.hasAttribute('hidden'));
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<GrAccountEntry>(element, '#entry').hasAttribute('hidden')
+    );
   });
 
-  test('addition and removal of account/group chips', () => {
-    flush();
-    sinon.stub(element, '_computeRemovable').returns(true);
+  test('addition and removal of account/group chips', async () => {
+    await element.updateComplete;
+    sinon.stub(element, 'computeRemovable').returns(true);
     // Existing accounts are listed.
     let chips = getChips();
     assert.equal(chips.length, 2);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isFalse(chips[1].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
+    assert.isFalse(chips[1].classList.contains('newlyAdded'));
 
-    // New accounts are added to end with pendingAdd class.
+    // New accounts are added to end with newlyAdded class.
     const newAccount = makeAccount();
-    handleAdd({account: newAccount});
-    flush();
+    handleAdd({account: newAccount, count: 1});
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 3);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isFalse(chips[1].classList.contains('pendingAdd'));
-    assert.isTrue(chips[2].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
+    assert.isFalse(chips[1].classList.contains('newlyAdded'));
+    assert.isTrue(chips[2].classList.contains('newlyAdded'));
 
     // Removed accounts are taken out of the list.
     element.dispatchEvent(
@@ -132,11 +160,11 @@
         bubbles: true,
       })
     );
-    flush();
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 2);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
-    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
+    assert.isTrue(chips[1].classList.contains('newlyAdded'));
 
     // Invalid remove is ignored.
     element.dispatchEvent(
@@ -153,19 +181,19 @@
         bubbles: true,
       })
     );
-    flush();
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 1);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
 
-    // New groups are added to end with pendingAdd and group classes.
+    // New groups are added to end with newlyAdded and group classes.
     const newGroup = makeGroup();
-    handleAdd({group: newGroup, confirm: false});
-    flush();
+    handleAdd({group: newGroup, confirm: false, count: 1});
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 2);
     assert.isTrue(chips[1].classList.contains('group'));
-    assert.isTrue(chips[1].classList.contains('pendingAdd'));
+    assert.isTrue(chips[1].classList.contains('newlyAdded'));
 
     // Removed groups are taken out of the list.
     element.dispatchEvent(
@@ -175,13 +203,13 @@
         bubbles: true,
       })
     );
-    flush();
+    await element.updateComplete;
     chips = getChips();
     assert.equal(chips.length, 1);
-    assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    assert.isFalse(chips[0].classList.contains('newlyAdded'));
   });
 
-  test('_getSuggestions uses filter correctly', () => {
+  test('getSuggestions uses filter correctly', () => {
     const originalSuggestions: Suggestion[] = [
       {
         email: 'abc@example.com' as EmailAddress,
@@ -215,7 +243,7 @@
       });
 
     return element
-      ._getSuggestions('')
+      .getSuggestions('')
       .then(suggestions => {
         // Default is no filtering.
         assert.equal(suggestions.length, 3);
@@ -226,7 +254,7 @@
           return (suggestion as AccountInfo)._account_id === accountId;
         };
 
-        return element._getSuggestions('');
+        return element.getSuggestions('');
       })
       .then(suggestions => {
         assert.deepEqual(suggestions, [
@@ -241,46 +269,43 @@
       });
   });
 
-  test('_computeChipClass', () => {
-    const account = makeAccount() as AccountInfoInput;
-    assert.equal(element._computeChipClass(account), '');
-    account._pendingAdd = true;
-    assert.equal(element._computeChipClass(account), 'pendingAdd');
-    account._group = true;
-    assert.equal(element._computeChipClass(account), 'group pendingAdd');
-    account._pendingAdd = false;
-    assert.equal(element._computeChipClass(account), 'group');
-  });
-
-  test('_computeRemovable', () => {
+  test('computeRemovable', async () => {
     const newAccount = makeAccount() as AccountInfoInput;
-    newAccount._pendingAdd = true;
     element.readonly = false;
     element.removableValues = [];
-    assert.isFalse(element._computeRemovable(existingAccount1, false));
-    assert.isTrue(element._computeRemovable(newAccount, false));
+    element.updateComplete;
+    assert.isFalse(element.computeRemovable(existingAccount1));
+    assert.isTrue(element.computeRemovable(newAccount));
 
     element.removableValues = [existingAccount1];
-    assert.isTrue(element._computeRemovable(existingAccount1, false));
-    assert.isTrue(element._computeRemovable(newAccount, false));
-    assert.isFalse(element._computeRemovable(existingAccount2, false));
+    element.updateComplete;
+    assert.isTrue(element.computeRemovable(existingAccount1));
+    assert.isTrue(element.computeRemovable(newAccount));
+    assert.isFalse(element.computeRemovable(existingAccount2));
 
     element.readonly = true;
-    assert.isFalse(element._computeRemovable(existingAccount1, true));
-    assert.isFalse(element._computeRemovable(newAccount, true));
+    element.updateComplete;
+    assert.isFalse(element.computeRemovable(existingAccount1));
+    assert.isFalse(element.computeRemovable(newAccount));
   });
 
-  test('submitEntryText', () => {
+  test('submitEntryText', async () => {
     element.allowAnyInput = true;
-    flush();
+    await element.updateComplete;
 
-    const getTextStub = sinon.stub(element.$.entry, 'getText');
+    const getTextStub = sinon.stub(
+      queryAndAssert<GrAccountEntry>(element, '#entry'),
+      'getText'
+    );
     getTextStub.onFirstCall().returns('');
     getTextStub.onSecondCall().returns('test');
     getTextStub.onThirdCall().returns('test@test');
 
     // When entry is empty, return true.
-    const clearStub = sinon.stub(element.$.entry, 'clear');
+    const clearStub = sinon.stub(
+      queryAndAssert<GrAccountEntry>(element, '#entry'),
+      'clear'
+    );
     assert.isTrue(element.submitEntryText());
     assert.isFalse(clearStub.called);
 
@@ -292,7 +317,7 @@
     assert.isTrue(element.submitEntryText());
     assert.isTrue(clearStub.called);
     assert.equal(
-      element.additions()[0].account?.email,
+      (element.additions()[0] as AccountInfo)?.email,
       'test@test' as EmailAddress
     );
   });
@@ -301,23 +326,17 @@
     assert.equal(element.additions().length, 0);
 
     const newAccount = makeAccount();
-    handleAdd({account: newAccount});
+    handleAdd({account: newAccount, count: 1});
     const newGroup = makeGroup();
-    handleAdd({group: newGroup, confirm: false});
+    handleAdd({group: newGroup, confirm: false, count: 1});
 
     assert.deepEqual(element.additions(), [
       {
-        account: {
-          _account_id: newAccount._account_id,
-          _pendingAdd: true,
-        },
+        _account_id: newAccount._account_id,
       },
       {
-        group: {
-          id: newGroup.id,
-          _group: true,
-          _pendingAdd: true,
-        },
+        id: newGroup.id,
+        name: 'abcd' as GroupName,
       },
     ]);
   });
@@ -327,7 +346,7 @@
     assert.deepEqual(element.additions(), []);
 
     const group = makeGroup();
-    const reviewer = {
+    const reviewer: RawAccountInput = {
       group,
       count: 10,
       confirm: true,
@@ -341,12 +360,9 @@
     assert.isNull(element.pendingConfirmation);
     assert.deepEqual(element.additions(), [
       {
-        group: {
-          id: group.id,
-          _group: true,
-          _pendingAdd: true,
-          confirmed: true,
-        },
+        id: group.id,
+        name: 'abcd' as GroupName,
+        confirmed: true,
       },
     ]);
   });
@@ -359,14 +375,6 @@
     assert.equal(element.accounts.length, 1);
   });
 
-  test('max-count', () => {
-    element.maxCount = 1;
-    const acct = makeAccount();
-    handleAdd({account: acct});
-    flush();
-    assert.isTrue(element.$.entry.hasAttribute('hidden'));
-  });
-
   test('enter text calls suggestions provider', async () => {
     const suggestions: Suggestion[] = [
       {
@@ -387,15 +395,17 @@
       'makeSuggestionItem'
     );
 
-    const input = element.$.entry.$.input;
-
+    const input = queryAndAssert<GrAutocomplete>(
+      queryAndAssert<GrAccountEntry>(element, '#entry'),
+      '#input'
+    );
     input.text = 'newTest';
-    MockInteractions.focus(input.$.input);
+    input.input!.focus();
     input.noDebounce = true;
-    await flush();
+    await element.updateComplete;
     assert.isTrue(getSuggestionsStub.calledOnce);
     assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
-    assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
+    await waitUntil(() => makeSuggestionItemSpy.getCalls().length === 2);
   });
 
   suite('allowAnyInput', () => {
@@ -415,7 +425,7 @@
 
     test('toasts on invalid email', () => {
       const toastHandler = sinon.stub();
-      element.addEventListener('show-alert', toastHandler);
+      element.addEventListener(EventType.SHOW_ALERT, toastHandler);
       handleAdd('test');
       assert.isTrue(toastHandler.called);
     });
@@ -423,61 +433,61 @@
 
   suite('keyboard interactions', () => {
     test('backspace at text input start removes last account', async () => {
-      const input = element.$.entry.$.input;
-      sinon.stub(input, '_updateSuggestions');
-      sinon.stub(element, '_computeRemovable').returns(true);
-      await flush();
+      const input = queryAndAssert<GrAutocomplete>(
+        queryAndAssert<GrAccountEntry>(element, '#entry'),
+        '#input'
+      );
+      sinon.stub(input, 'updateSuggestions');
+      sinon.stub(element, 'computeRemovable').returns(true);
+      await element.updateComplete;
       // Next line is a workaround for Firefox not moving cursor
       // on input field update
-      assert.equal(element._getNativeInput(input.$.input).selectionStart, 0);
+      assert.equal(element.getOwnNativeInput(input.input!).selectionStart, 0);
       input.text = 'test';
-      MockInteractions.focus(input.$.input);
-      flush();
+      input.input!.focus();
+      await element.updateComplete;
       assert.equal(element.accounts.length, 2);
-      MockInteractions.pressAndReleaseKeyOn(
-        element._getNativeInput(input.$.input),
-        8
-      ); // Backspace
-      assert.equal(element.accounts.length, 2);
+      pressKey(element.getOwnNativeInput(input.input!), 'Backspace');
+      await waitUntil(() => element.accounts.length === 2);
       input.text = '';
-      MockInteractions.pressAndReleaseKeyOn(
-        element._getNativeInput(input.$.input),
-        8
-      ); // Backspace
-      flush();
-      assert.equal(element.accounts.length, 1);
+      await input.updateComplete;
+      pressKey(element.getOwnNativeInput(input.input!), 'Backspace');
+      await waitUntil(() => element.accounts.length === 1);
     });
 
     test('arrow key navigation', async () => {
-      const input = element.$.entry.$.input;
+      const input = queryAndAssert<GrAutocomplete>(
+        queryAndAssert<GrAccountEntry>(element, '#entry'),
+        '#input'
+      );
       input.text = '';
       element.accounts = [makeAccount(), makeAccount()];
-      flush();
-      MockInteractions.focus(input.$.input);
-      await flush();
+      await element.updateComplete;
+      input.input!.focus();
+      await element.updateComplete;
       const chips = element.accountChips;
       const chipsOneSpy = sinon.spy(chips[1], 'focus');
-      MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
+      pressKey(input.input!, 'ArrowLeft');
       assert.isTrue(chipsOneSpy.called);
       const chipsZeroSpy = sinon.spy(chips[0], 'focus');
-      MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
+      pressKey(chips[1], 'ArrowLeft');
       assert.isTrue(chipsZeroSpy.called);
-      MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
+      pressKey(chips[0], 'ArrowLeft');
       assert.isTrue(chipsZeroSpy.calledOnce);
-      MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
+      pressKey(chips[0], 'ArrowRight');
       assert.isTrue(chipsOneSpy.calledTwice);
     });
 
-    test('delete', () => {
+    test('delete', async () => {
       element.accounts = [makeAccount(), makeAccount()];
-      flush();
+      await element.updateComplete;
       const focusSpy = sinon.spy(element.accountChips[1], 'focus');
       const removeSpy = sinon.spy(element, 'removeAccount');
-      MockInteractions.pressAndReleaseKeyOn(element.accountChips[0], 8); // Backspace
+      pressKey(element.accountChips[0], 'Backspace');
       assert.isTrue(focusSpy.called);
       assert.isTrue(removeSpy.calledOnce);
 
-      MockInteractions.pressAndReleaseKeyOn(element.accountChips[1], 46); // Delete
+      pressKey(element.accountChips[0], 'Delete');
       assert.isTrue(removeSpy.calledTwice);
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index fa547dc..9342715 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -1,26 +1,15 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-button/gr-button';
 import '../../../styles/shared-styles';
-import {getRootElement} from '../../../scripts/rootElement';
 import {ErrorType} from '../../../types/types';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
+import {DependencyRequestEvent} from '../../../models/dependency';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -99,7 +88,7 @@
         <gr-button
           link=""
           class="action"
-          ?hidden="${this._hideActionButton}"
+          ?hidden=${this._hideActionButton}
           @click=${this._handleActionTap}
           >${actionText}
         </gr-button>
@@ -135,6 +124,9 @@
   @property({type: Boolean})
   showDismiss = false;
 
+  @property({type: Object})
+  owner: HTMLElement | null = null;
+
   @property()
   _boundTransitionEndHandler?: (
     this: HTMLElement,
@@ -148,9 +140,11 @@
     super.connectedCallback();
     this._boundTransitionEndHandler = () => this._handleTransitionEnd();
     this.addEventListener('transitionend', this._boundTransitionEndHandler);
+    this.addEventListener('request-dependency', this.resolveDep);
   }
 
   override disconnectedCallback() {
+    this.removeEventListener('request-dependency', this.resolveDep);
     if (this._boundTransitionEndHandler) {
       this.removeEventListener(
         'transitionend',
@@ -160,19 +154,29 @@
     super.disconnectedCallback();
   }
 
+  /**
+   * Hovercards aren't children of <gr-app>. Dependencies must be resolved via
+   * their targets, so re-route 'request-dependency' events.
+   */
+  readonly resolveDep = (e: DependencyRequestEvent<unknown>) => {
+    this.owner?.dispatchEvent(
+      new DependencyRequestEvent<unknown>(e.dependency, e.callback)
+    );
+  };
+
   show(text: string, actionText?: string, actionCallback?: () => void) {
     this.text = text;
     this.actionText = actionText;
     this._hideActionButton = !actionText;
     this._actionCallback = actionCallback;
-    getRootElement().appendChild(this);
+    document.body.appendChild(this);
     this.shown = true;
   }
 
   hide() {
     this.shown = false;
     if (this._hasZeroTransitionDuration()) {
-      getRootElement().removeChild(this);
+      document.body.removeChild(this);
     }
   }
 
@@ -192,7 +196,7 @@
       return;
     }
 
-    getRootElement().removeChild(this);
+    document.body.removeChild(this);
   }
 
   _handleActionTap(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
index d0fe563..6908e95 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
@@ -1,29 +1,21 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-alert';
 import {GrAlert} from './gr-alert';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
+import {waitEventLoop} from '../../../test/test-utils';
 
 suite('gr-alert tests', () => {
   let element: GrAlert;
 
   setup(() => {
+    // The gr-alert element attaches itself to the root element on .show(),
+    // rather than existing under a fixture parent.
     element = document.createElement('gr-alert');
   });
 
@@ -33,11 +25,34 @@
     }
   });
 
+  test('render', async () => {
+    element.show('Alert text');
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="content-wrapper">
+          <span class="text"> Alert text </span>
+          <gr-button
+            aria-disabled="false"
+            class="action"
+            hidden=""
+            link=""
+            role="button"
+            tabindex="0"
+          >
+          </gr-button>
+        </div>
+      `
+    );
+  });
+
   test('show/hide', async () => {
     assert.isNull(element.parentNode);
     element.show('Alert text');
     // wait for element to be rendered after being attached to DOM
-    await flush();
+    await waitEventLoop();
     assert.equal(element.parentNode, document.body);
     element.style.setProperty('--gr-alert-transition-duration', '0ms');
     element.hide();
@@ -47,10 +62,10 @@
   test('action event', async () => {
     const spy = sinon.spy();
     element.show('Alert text');
-    await flush();
+    await waitEventLoop();
     element._actionCallback = spy;
     assert.isFalse(spy.called);
-    MockInteractions.tap(element.shadowRoot!.querySelector('.action')!);
+    element.shadowRoot!.querySelector<GrButton>('.action')!.click();
     assert.isTrue(spy.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index e7137e4..91e601c 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
@@ -1,37 +1,20 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-dropdown/iron-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../../../styles/shared-styles';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-autocomplete-dropdown_html';
-import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin';
-import {customElement, property, observe} from '@polymer/decorators';
-import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {fireEvent} from '../../../utils/event-util';
-import {addShortcut, Key} from '../../../utils/dom-util';
-
-export interface GrAutocompleteDropdown {
-  $: {
-    suggestions: Element;
-  };
-}
+import {Key} from '../../../utils/dom-util';
+import {FitController} from '../../lit/fit-controller';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
+import {repeat} from 'lit/directives/repeat.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -52,15 +35,8 @@
   selected: HTMLElement | null;
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior);
-
 @customElement('gr-autocomplete-dropdown')
-export class GrAutocompleteDropdown extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAutocompleteDropdown extends LitElement {
   /**
    * Fired when the dropdown is closed.
    *
@@ -76,135 +52,246 @@
   @property({type: Number})
   index: number | null = null;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true, attribute: 'is-hidden'})
   isHidden = true;
 
-  @property({type: Number})
-  override verticalOffset: number | null = null;
+  /** If specified a single non-interactable line is shown instead of
+   * suggestions.
+   */
+  @property({type: String})
+  errorMessage?: String;
 
   @property({type: Number})
-  override horizontalOffset: number | null = null;
+  verticalOffset = 0;
+
+  @property({type: Number})
+  horizontalOffset = 0;
 
   @property({type: Array})
   suggestions: Item[] = [];
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  @query('#suggestions') suggestionsDiv?: HTMLDivElement;
+
+  private readonly shortcuts = new ShortcutController(this);
 
   // visible for testing
   cursor = new GrCursorManager();
 
+  // visible for testing
+  fitController = new FitController(this);
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          z-index: 100;
+          box-shadow: var(--elevation-level-2);
+          overflow: auto;
+          background: var(--dropdown-background-color);
+          border-radius: var(--border-radius);
+          max-height: 50vh;
+        }
+        :host([is-hidden]) {
+          display: none;
+        }
+        ul {
+          list-style: none;
+        }
+        li {
+          border-bottom: 1px solid var(--border-color);
+          cursor: pointer;
+          display: flex;
+          justify-content: space-between;
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        li:last-of-type {
+          border: none;
+        }
+        li:focus {
+          outline: none;
+        }
+        li:hover {
+          background-color: var(--hover-background-color);
+        }
+        li.selected {
+          background-color: var(--hover-background-color);
+        }
+        li.query-error {
+          background-color: var(--disabled-background);
+          color: var(--error-foreground);
+          cursor: default;
+          white-space: pre-wrap;
+        }
+        @media only screen and (max-height: 35em) {
+          .dropdown-content {
+            max-height: 80vh;
+          }
+        }
+        .label {
+          color: var(--deemphasized-text-color);
+          padding-left: var(--spacing-l);
+        }
+        .hide {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  private isSuggestionListInteractible() {
+    return !this.isHidden && !this.errorMessage;
+  }
+
   constructor() {
     super();
     this.cursor.cursorTargetClass = 'selected';
     this.cursor.focusOnMove = true;
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+    this.shortcuts.addLocal({key: Key.UP, allowRepeat: true}, () =>
+      this.cursorUp()
     );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+    this.shortcuts.addLocal({key: Key.DOWN, allowRepeat: true}, () =>
+      this.cursorDown()
     );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, _ => this._handleEscape())
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
-    );
+    this.shortcuts.addLocal({key: Key.ENTER}, () => this.handleEnter());
+    this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEscape());
+    this.shortcuts.addLocal({key: Key.TAB}, () => this.handleTab());
   }
 
   override disconnectedCallback() {
     this.cursor.unsetCursor();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
     super.disconnectedCallback();
   }
 
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('index')) {
+      this.setIndex();
+    }
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('suggestions') ||
+      changedProperties.has('isHidden')
+    ) {
+      if (!this.isHidden) {
+        this.computeCursorStopsAndRefit();
+      }
+    }
+  }
+
+  private renderError() {
+    return html`
+      <li
+        tabindex="-1"
+        aria-label="autocomplete query error"
+        class="query-error"
+      >
+        <span>${this.errorMessage}</span>
+        <span class="label">ERROR</span>
+      </li>
+    `;
+  }
+
+  override render() {
+    return html`
+      <div class="dropdown-content" id="suggestions" role="listbox">
+        <ul>
+          ${when(
+            this.errorMessage,
+            () => this.renderError(),
+            () => html`
+              ${repeat(
+                this.suggestions,
+                (item, index) => html`
+                  <li
+                    data-index=${index}
+                    data-value=${item.dataValue ?? ''}
+                    tabindex="-1"
+                    aria-label=${item.name ?? ''}
+                    class="autocompleteOption"
+                    role="option"
+                    @click=${this.handleClickItem}
+                  >
+                    <span>${item.text}</span>
+                    <span class="label ${this.computeLabelClass(item)}"
+                      >${item.label}</span
+                    >
+                  </li>
+                `
+              )}
+            `
+          )}
+        </ul>
+      </div>
+    `;
+  }
+
   close() {
     this.isHidden = true;
   }
 
   open() {
     this.isHidden = false;
-    this._resetCursorStops();
-    // Refit should run after we call Polymer.flush inside _resetCursorStops
-    this.refit();
   }
 
   getCurrentText() {
-    return this.getCursorTarget()?.dataset['value'] || '';
+    if (!this.errorMessage) {
+      return this.getCursorTarget()?.dataset['value'] || '';
+    }
+    return '';
   }
 
-  _handleUp(e: Event) {
-    if (!this.isHidden) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.cursorUp();
-    }
-  }
-
-  _handleDown(e: Event) {
-    if (!this.isHidden) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.cursorDown();
-    }
+  setPositionTarget(target: HTMLElement) {
+    this.fitController.setPositionTarget(target);
   }
 
   cursorDown() {
-    if (!this.isHidden) {
-      this.cursor.next();
-    }
+    if (this.isSuggestionListInteractible()) this.cursor.next();
   }
 
   cursorUp() {
-    if (!this.isHidden) {
-      this.cursor.previous();
+    if (this.isSuggestionListInteractible()) this.cursor.previous();
+  }
+
+  // private but used in tests
+  handleTab() {
+    if (this.isSuggestionListInteractible()) {
+      this.dispatchEvent(
+        new CustomEvent<ItemSelectedEvent>('item-selected', {
+          detail: {
+            trigger: 'tab',
+            selected: this.cursor.target,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
     }
   }
 
-  _handleTab(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEvent>('item-selected', {
-        detail: {
-          trigger: 'tab',
-          selected: this.cursor.target,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
+  // private but used in tests
+  handleEnter() {
+    if (this.isSuggestionListInteractible()) {
+      this.dispatchEvent(
+        new CustomEvent<ItemSelectedEvent>('item-selected', {
+          detail: {
+            trigger: 'enter',
+            selected: this.cursor.target,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
   }
 
-  _handleEnter(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent<ItemSelectedEvent>('item-selected', {
-        detail: {
-          trigger: 'enter',
-          selected: this.cursor.target,
-        },
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _handleEscape() {
-    this._fireClose();
+  private handleEscape() {
+    this.fireClose();
     this.close();
   }
 
-  _handleClickItem(e: Event) {
+  private handleClickItem(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     let selected = e.target! as HTMLElement;
@@ -226,7 +313,7 @@
     );
   }
 
-  _fireClose() {
+  private fireClose() {
     fireEvent(this, 'dropdown-closed');
   }
 
@@ -234,31 +321,27 @@
     return this.cursor.target;
   }
 
-  @observe('suggestions')
-  _resetCursorStops() {
+  computeCursorStopsAndRefit() {
     if (this.suggestions.length > 0) {
-      if (!this.isHidden) {
-        flush();
-        this.cursor.stops = Array.from(
-          this.$.suggestions.querySelectorAll('li')
-        );
-        this._resetCursorIndex();
-      }
+      this.cursor.stops = Array.from(
+        this.suggestionsDiv?.querySelectorAll('li.autocompleteOption') ?? []
+      );
+      this.resetCursorIndex();
     } else {
       this.cursor.stops = [];
     }
+    this.fitController.refit();
   }
 
-  @observe('index')
-  _setIndex() {
+  private setIndex() {
     this.cursor.index = this.index || -1;
   }
 
-  _resetCursorIndex() {
+  private resetCursorIndex() {
     this.cursor.setCursorAtIndex(0);
   }
 
-  _computeLabelClass(item: Item) {
+  private computeLabelClass(item: Item) {
     return item.label ? '' : 'hide';
   }
 }
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
deleted file mode 100644
index b86e8ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_html.ts
+++ /dev/null
@@ -1,94 +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 {
-      z-index: 100;
-    }
-    :host([is-hidden]) {
-      display: none;
-    }
-    ul {
-      list-style: none;
-    }
-    li {
-      border-bottom: 1px solid var(--border-color);
-      cursor: pointer;
-      display: flex;
-      justify-content: space-between;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    li:last-of-type {
-      border: none;
-    }
-    li:focus {
-      outline: none;
-    }
-    li:hover {
-      background-color: var(--hover-background-color);
-    }
-    li.selected {
-      background-color: var(--selection-background-color);
-    }
-    .dropdown-content {
-      background: var(--dropdown-background-color);
-      box-shadow: var(--elevation-level-2);
-      border-radius: var(--border-radius);
-      max-height: 50vh;
-      overflow: auto;
-    }
-    @media only screen and (max-height: 35em) {
-      .dropdown-content {
-        max-height: 80vh;
-      }
-    }
-    .label {
-      color: var(--deemphasized-text-color);
-      padding-left: var(--spacing-l);
-    }
-    .hide {
-      display: none;
-    }
-  </style>
-  <div
-    class="dropdown-content"
-    slot="dropdown-content"
-    id="suggestions"
-    role="listbox"
-  >
-    <ul>
-      <template is="dom-repeat" items="[[suggestions]]">
-        <li
-          data-index$="[[index]]"
-          data-value$="[[item.dataValue]]"
-          tabindex="-1"
-          aria-label$="[[item.name]]"
-          class="autocompleteOption"
-          role="option"
-          on-click="_handleClickItem"
-        >
-          <span>[[item.text]]</span>
-          <span class$="label [[_computeLabelClass(item)]]"
-            >[[item.label]]</span
-          >
-        </li>
-      </template>
-    </ul>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 86de3b3..54d054b 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -1,142 +1,250 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-autocomplete-dropdown';
 import {GrAutocompleteDropdown} from './gr-autocomplete-dropdown';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {
+  pressKey,
+  queryAll,
+  queryAndAssert,
+  waitEventLoop,
+  waitUntil,
+} from '../../../test/test-utils';
 import {assertIsDefined} from '../../../utils/common-util';
-
-const basicFixture = fixtureFromElement('gr-autocomplete-dropdown');
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key} from '../../../utils/dom-util';
 
 suite('gr-autocomplete-dropdown', () => {
-  let element: GrAutocompleteDropdown;
+  suite('suggestion tests', () => {
+    let element: GrAutocompleteDropdown;
 
-  const suggestionsEl = () => queryAndAssert(element, '#suggestions');
+    const suggestionsEl = () => queryAndAssert(element, '#suggestions');
 
-  setup(async () => {
-    element = basicFixture.instantiate();
-    element.open();
-    element.suggestions = [
-      {dataValue: 'test value 1', name: 'test name 1', text: '1', label: 'hi'},
-      {dataValue: 'test value 2', name: 'test name 2', text: '2'},
-    ];
-    await flush();
-  });
+    setup(async () => {
+      element = await fixture(
+        html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+      );
+      element.open();
+      element.suggestions = [
+        {
+          dataValue: 'test value 1',
+          name: 'test name 1',
+          text: '1',
+          label: 'hi',
+        },
+        {dataValue: 'test value 2', name: 'test name 2', text: '2'},
+      ];
+      await waitEventLoop();
+    });
 
-  teardown(() => {
-    element.close();
-  });
+    teardown(() => {
+      element.close();
+    });
 
-  test('shows labels', () => {
-    const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
-    assert.equal(els[0].innerText.trim(), '1\nhi');
-    assert.equal(els[1].innerText.trim(), '2');
-  });
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="test name 1"
+                class="autocompleteOption selected"
+                data-index="0"
+                data-value="test value 1"
+                role="option"
+                tabindex="-1"
+              >
+                <span> 1 </span>
+                <span class="label"> hi </span>
+              </li>
+              <li
+                aria-label="test name 2"
+                class="autocompleteOption"
+                data-index="1"
+                data-value="test value 2"
+                role="option"
+                tabindex="-1"
+              >
+                <span> 2 </span>
+                <span class="hide label"> </span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
+    });
 
-  test('escape key', () => {
-    const closeSpy = sinon.spy(element, 'close');
-    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
-    flush();
-    assert.isTrue(closeSpy.called);
-  });
+    test('shows labels', () => {
+      const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
+      assert.equal(els[0].innerText.trim(), '1\nhi');
+      assert.equal(els[1].innerText.trim(), '2');
+    });
 
-  test('tab key', () => {
-    const handleTabSpy = sinon.spy(element, '_handleTab');
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    MockInteractions.pressAndReleaseKeyOn(element, 9, null, 'Tab');
-    assert.isTrue(handleTabSpy.called);
-    assert.equal(element.cursor.index, 0);
-    assert.isTrue(itemSelectedStub.called);
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'tab',
-      selected: element.getCursorTarget(),
+    test('escape key close suggestions', async () => {
+      const closeSpy = sinon.spy(element, 'close');
+      pressKey(element, Key.ESC);
+      await waitEventLoop();
+      assert.isTrue(closeSpy.called);
+    });
+
+    test('tab key', () => {
+      const handleTabSpy = sinon.spy(element, 'handleTab');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.TAB);
+      assert.isTrue(handleTabSpy.called);
+      assert.equal(element.cursor.index, 0);
+      assert.isTrue(itemSelectedStub.called);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'tab',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('enter key', () => {
+      const handleEnterSpy = sinon.spy(element, 'handleEnter');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.ENTER);
+      assert.isTrue(handleEnterSpy.called);
+      assert.equal(element.cursor.index, 0);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'enter',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('down key', () => {
+      element.isHidden = true;
+      const nextSpy = sinon.spy(element.cursor, 'next');
+      pressKey(element, 'ArrowDown');
+      assert.isFalse(nextSpy.called);
+      assert.equal(element.cursor.index, 0);
+      element.isHidden = false;
+      pressKey(element, 'ArrowDown');
+      assert.isTrue(nextSpy.called);
+      assert.equal(element.cursor.index, 1);
+    });
+
+    test('up key', () => {
+      element.isHidden = true;
+      const prevSpy = sinon.spy(element.cursor, 'previous');
+      pressKey(element, 'ArrowUp');
+      assert.isFalse(prevSpy.called);
+      assert.equal(element.cursor.index, 0);
+      element.isHidden = false;
+      element.cursor.setCursorAtIndex(1);
+      assert.equal(element.cursor.index, 1);
+      pressKey(element, 'ArrowUp');
+      assert.isTrue(prevSpy.called);
+      assert.equal(element.cursor.index, 0);
+    });
+
+    test('tapping selects item', async () => {
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+
+      suggestionsEl().querySelectorAll('li')[1].click();
+      await waitEventLoop();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'click',
+        selected: suggestionsEl().querySelectorAll('li')[1],
+      });
+    });
+
+    test('tapping child still selects item', async () => {
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      const lastElChild = queryAll<HTMLLIElement>(suggestionsEl(), 'li')[0]
+        ?.lastElementChild;
+      assertIsDefined(lastElChild);
+      (lastElChild as HTMLSpanElement).click();
+      await waitEventLoop();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'click',
+        selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
+      });
+    });
+
+    test('updated suggestions resets cursor stops', async () => {
+      const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
+      element.suggestions = [];
+      await waitUntil(() => resetStopsSpy.called);
     });
   });
 
-  test('enter key', () => {
-    const handleEnterSpy = sinon.spy(element, '_handleEnter');
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
-    assert.isTrue(handleEnterSpy.called);
-    assert.equal(element.cursor.index, 0);
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'enter',
-      selected: element.getCursorTarget(),
+  suite('error tests', () => {
+    let element: GrAutocompleteDropdown;
+
+    setup(async () => {
+      element = await fixture(
+        html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+      );
+      element.open();
+      element.errorMessage = 'Failed query error';
+      await waitEventLoop();
     });
-  });
 
-  test('down key', () => {
-    element.isHidden = true;
-    const nextSpy = sinon.spy(element.cursor, 'next');
-    MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
-    assert.isFalse(nextSpy.called);
-    assert.equal(element.cursor.index, 0);
-    element.isHidden = false;
-    MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
-    assert.isTrue(nextSpy.called);
-    assert.equal(element.cursor.index, 1);
-  });
-
-  test('up key', () => {
-    element.isHidden = true;
-    const prevSpy = sinon.spy(element.cursor, 'previous');
-    MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
-    assert.isFalse(prevSpy.called);
-    assert.equal(element.cursor.index, 0);
-    element.isHidden = false;
-    element.cursor.setCursorAtIndex(1);
-    assert.equal(element.cursor.index, 1);
-    MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
-    assert.isTrue(prevSpy.called);
-    assert.equal(element.cursor.index, 0);
-  });
-
-  test('tapping selects item', () => {
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-
-    MockInteractions.tap(suggestionsEl().querySelectorAll('li')[1]);
-    flush();
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'click',
-      selected: suggestionsEl().querySelectorAll('li')[1],
+    teardown(() => {
+      element.close();
     });
-  });
 
-  test('tapping child still selects item', () => {
-    const itemSelectedStub = sinon.stub();
-    element.addEventListener('item-selected', itemSelectedStub);
-    const lastElChild = queryAll<HTMLElement>(suggestionsEl(), 'li')[0]
-      ?.lastElementChild;
-    assertIsDefined(lastElChild);
-    MockInteractions.tap(lastElChild);
-    flush();
-    assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
-      trigger: 'click',
-      selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
+    test('renders error', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="dropdown-content" id="suggestions" role="listbox">
+            <ul>
+              <li
+                aria-label="autocomplete query error"
+                class="query-error"
+                tabindex="-1"
+              >
+                <span>Failed query error</span>
+                <span class="label">ERROR</span>
+              </li>
+            </ul>
+          </div>
+        `
+      );
     });
-  });
 
-  test('updated suggestions resets cursor stops', () => {
-    const resetStopsSpy = sinon.spy(element, '_resetCursorStops');
-    element.suggestions = [];
-    assert.isTrue(resetStopsSpy.called);
+    test('escape key close dropdown with error', async () => {
+      const closeSpy = sinon.spy(element, 'close');
+      pressKey(element, Key.ESC);
+      await waitEventLoop();
+      assert.isTrue(closeSpy.called);
+    });
+
+    test('tab key when error shown sends no event', () => {
+      const handleTabSpy = sinon.spy(element, 'handleTab');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.TAB);
+      assert.isTrue(handleTabSpy.called);
+      assert.isFalse(itemSelectedStub.called);
+    });
+
+    test('enter key when error shown sends no event', () => {
+      const handleEnterSpy = sinon.spy(element, 'handleEnter');
+      const itemSelectedStub = sinon.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      pressKey(element, Key.ENTER);
+      assert.isTrue(handleEnterSpy.called);
+      assert.isFalse(itemSelectedStub.called);
+    });
+
+    test('up/down disabled when error', () => {
+      const nextSpy = sinon.spy(element.cursor, 'next');
+      const prevSpy = sinon.spy(element.cursor, 'previous');
+      pressKey(element, 'ArrowUp');
+      pressKey(element, 'ArrowDown');
+      assert.isFalse(nextSpy.called);
+      assert.isFalse(prevSpy.called);
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 8e84aa2..f7f8ea5 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -1,45 +1,28 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/paper-input/paper-input';
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-icons/gr-icons';
+import '../gr-icon/gr-icon';
 import '../../../styles/shared-styles';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-autocomplete_html';
-import {property, customElement, observe} from '@polymer/decorators';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {PaperInputElementExt} from '../../../types/types';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireEvent} from '../../../utils/event-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {PropertyType} from '../../../types/common';
 import {modifierPressed} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {ValueChangedEvent} from '../../../types/events';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {IronInputElement} from '@polymer/iron-input';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 const DEBOUNCE_WAIT_MS = 200;
 
-export interface GrAutocomplete {
-  $: {
-    input: PaperInputElementExt;
-    suggestions: GrAutocompleteDropdown;
-  };
-}
-
 export type AutocompleteQuery<T = string> = (
   text: string
 ) => Promise<Array<AutocompleteSuggestion<T>>>;
@@ -48,13 +31,17 @@
   interface HTMLElementTagNameMap {
     'gr-autocomplete': GrAutocomplete;
   }
+  interface HTMLElementEventMap {
+    'text-changed': ValueChangedEvent<string>;
+    'value-changed': ValueChangedEvent<string>;
+  }
 }
 
 export interface AutocompleteSuggestion<T = string> {
   name?: string;
   label?: string;
   value?: T;
-  text?: T;
+  text?: string;
 }
 
 export interface AutocompleteCommitEventDetail {
@@ -65,10 +52,7 @@
   CustomEvent<AutocompleteCommitEventDetail>;
 
 @customElement('gr-autocomplete')
-export class GrAutocomplete extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrAutocomplete extends LitElement {
   /**
    * Fired when a value is chosen.
    *
@@ -97,10 +81,17 @@
    * next to the "name" as label text. The "value" property will be emitted
    * if that suggestion is selected.
    *
+   * If query fails, the function should return rejected promise containing
+   * an Error. The "message" property will be shown in a dropdown instead of
+   * rendering suggestions.
    */
   @property({type: Object})
   query?: AutocompleteQuery = () => Promise.resolve([]);
 
+  @query('#input') input?: PaperInputElement;
+
+  @query('#suggestions') suggestionsDropdown?: GrAutocompleteDropdown;
+
   /**
    * The number of characters that must be typed before suggestions are
    * made. If threshold is zero, default suggestions are enabled.
@@ -108,7 +99,7 @@
   @property({type: Number})
   threshold = 1;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'allow-non-suggested-values'})
   allowNonSuggestedValues = false;
 
   @property({type: Boolean})
@@ -117,7 +108,7 @@
   @property({type: Boolean})
   disabled = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-search-icon'})
   showSearchIcon = false;
 
   /**
@@ -129,13 +120,13 @@
   @property({type: Number})
   verticalOffset = 31;
 
-  @property({type: String, notify: true})
+  @property({type: String})
   text = '';
 
   @property({type: String})
   placeholder = '';
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'clear-on-commit'})
   clearOnCommit = false;
 
   /**
@@ -143,10 +134,10 @@
    * When false, tab key not caught, and focus is removed from the element.
    * See Issue 4556, Issue 6645.
    */
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'tab-complete'})
   tabComplete = false;
 
-  @property({type: String, notify: true})
+  @property({type: String})
   value = '';
 
   /**
@@ -160,29 +151,17 @@
    * When true and uncommitted text is left in the autocomplete input after
    * blurring, the text will appear red.
    */
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'warn-uncommitted'})
   warnUncommitted = false;
 
   /**
    * When true, querying for suggestions is not debounced w/r/t keypresses
    */
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'no-debounce'})
   noDebounce = false;
 
-  @property({type: Array})
-  _suggestions: AutocompleteSuggestion[] = [];
-
-  @property({type: Array})
-  _suggestionEls = [];
-
-  @property({type: Number})
-  _index: number | null = null;
-
-  @property({type: Boolean})
-  _disableSuggestions = false;
-
-  @property({type: Boolean})
-  _focused = false;
+  @property({type: Boolean, attribute: 'show-blue-focus-border'})
+  showBlueFocusBorder = false;
 
   /**
    * Invisible label for input element. This label is exposed to
@@ -191,18 +170,91 @@
   @property({type: String})
   label = '';
 
-  /** The DOM element of the selected suggestion. */
-  @property({type: Object})
-  _selected: HTMLElement | null = null;
+  @state() suggestions: AutocompleteSuggestion[] = [];
+
+  @state() queryErrorMessage?: string;
+
+  @state() index: number | null = null;
+
+  // Enabled to suppress showing/updating suggestions when changing properties
+  // that would normally trigger the update.
+  disableDisplayingSuggestions = false;
+
+  // private but used in tests
+  focused = false;
+
+  @state() selected: HTMLElement | null = null;
 
   private updateSuggestionsTask?: DelayedTask;
 
-  get _nativeInput() {
-    // In Polymer 2 inputElement isn't nativeInput anymore
-    return (this.$.input.$.nativeInput ||
-      this.$.input.inputElement) as HTMLInputElement;
+  get nativeInput() {
+    return (this.input!.inputElement as IronInputElement)
+      .inputElement as HTMLInputElement;
   }
 
+  static override styles = [
+    sharedStyles,
+    css`
+      .searchIcon {
+        display: none;
+      }
+      .searchIcon.showSearchIcon {
+        display: inline-block;
+      }
+      gr-icon {
+        margin: 0 var(--spacing-xs);
+      }
+      paper-input.borderless {
+        border: none;
+        padding: 0;
+      }
+      paper-input {
+        background-color: var(--view-background-color);
+        color: var(--primary-text-color);
+        border: 1px solid var(--prominent-border-color, var(--border-color));
+        border-radius: var(--border-radius);
+        padding: var(--spacing-s);
+        --paper-input-container_-_padding: 0;
+        --paper-input-container-input_-_font-size: var(--font-size-normal);
+        --paper-input-container-input_-_line-height: var(--line-height-normal);
+        /* This is a hack for not being able to set height:0 on the underline
+            of a paper-input 2.2.3 element. All the underline fixes below only
+            actually work in 3.x.x, so the height must be adjusted directly as
+            a workaround until we are on Polymer 3. */
+        height: var(--line-height-normal);
+        --paper-input-container-underline-height: 0;
+        --paper-input-container-underline-wrapper-height: 0;
+        --paper-input-container-underline-focus-height: 0;
+        --paper-input-container-underline-legacy-height: 0;
+        --paper-input-container-underline_-_height: 0;
+        --paper-input-container-underline_-_display: none;
+        --paper-input-container-underline-focus_-_height: 0;
+        --paper-input-container-underline-focus_-_display: none;
+        --paper-input-container-underline-disabled_-_height: 0;
+        --paper-input-container-underline-disabled_-_display: none;
+        /* Hide label for input. The label is still visible for
+           screen readers. Workaround found at:
+           https://github.com/PolymerElements/paper-input/issues/478 */
+        --paper-input-container-label_-_display: none;
+      }
+      paper-input.showBlueFocusBorder:focus {
+        border: 2px solid var(--input-focus-border-color);
+        /*
+         * The goal is to have a thicker blue border when focused and a thinner
+         * gray border when blurred. To avoid shifting neighboring elements
+         * around when the border size changes, a negative margin is added to
+         * compensate. box-sizing: border-box; will not work since there is
+         * important padding to add around the content.
+         */
+        margin: -1px;
+      }
+      paper-input.warnUncommitted {
+        --paper-input-container-input_-_color: var(--error-text-color);
+        --paper-input-container-input_-_font-size: inherit;
+      }
+    `,
+  ];
+
   override connectedCallback() {
     super.connectedCallback();
     document.addEventListener('click', this.handleBodyClick);
@@ -214,38 +266,128 @@
     super.disconnectedCallback();
   }
 
+  override willUpdate(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('text') ||
+      changedProperties.has('threshold') ||
+      changedProperties.has('noDebounce')
+    ) {
+      this.updateSuggestions();
+    }
+    if (
+      changedProperties.has('suggestions') ||
+      changedProperties.has('queryErrorMessage')
+    ) {
+      this.updateDropdownVisibility();
+    }
+    if (changedProperties.has('text')) {
+      fire(this, 'text-changed', {value: this.text});
+    }
+    if (changedProperties.has('value')) {
+      fire(this, 'value-changed', {value: this.value});
+    }
+  }
+
+  override render() {
+    return html`
+      <paper-input
+        .noLabelFloat=${true}
+        id="input"
+        class=${this.computeClass()}
+        ?disabled=${this.disabled}
+        .value=${this.text}
+        @value-changed=${(e: CustomEvent) => {
+          this.text = e.detail.value;
+        }}
+        .placeholder=${this.placeholder}
+        @keydown=${this.handleKeydown}
+        @focus=${this.onInputFocus}
+        @blur=${this.onInputBlur}
+        autocomplete="off"
+        .label=${this.label}
+      >
+        <div slot="prefix">
+          <gr-icon
+            icon="search"
+            class="searchIcon ${this.computeShowSearchIconClass(
+              this.showSearchIcon
+            )}"
+          ></gr-icon>
+        </div>
+
+        <div slot="suffix">
+          <slot name="suffix"></slot>
+        </div>
+      </paper-input>
+      <gr-autocomplete-dropdown
+        .verticalOffset=${this.verticalOffset}
+        id="suggestions"
+        @item-selected=${this.handleItemSelect}
+        @dropdown-closed=${this.focusWithoutDisplayingSuggestions}
+        .suggestions=${this.suggestions}
+        .errorMessage=${this.queryErrorMessage}
+        role="listbox"
+        .index=${this.index}
+      >
+      </gr-autocomplete-dropdown>
+    `;
+  }
+
   get focusStart() {
-    return this.$.input;
+    return this.input;
   }
 
   override focus() {
-    this._nativeInput.focus();
+    this.nativeInput.focus();
+  }
+
+  private focusWithoutDisplayingSuggestions() {
+    this.disableDisplayingSuggestions = true;
+    this.focus();
+
+    this.updateComplete.then(() => {
+      this.disableDisplayingSuggestions = false;
+    });
   }
 
   selectAll() {
-    const nativeInputElement = this._nativeInput;
-    if (!this.$.input.value) {
+    const nativeInputElement = this.nativeInput;
+    if (!this.input?.value) {
       return;
     }
-    nativeInputElement.setSelectionRange(0, this.$.input.value.length);
+    nativeInputElement.setSelectionRange(0, this.input?.value.length);
   }
 
   clear() {
     this.text = '';
   }
 
-  _handleItemSelect(e: CustomEvent) {
-    // Let _handleKeydown deal with keyboard interaction.
-    if (e.detail.trigger !== 'click') {
-      return;
-    }
-    this._selected = e.detail.selected;
-    this._commit();
+  private handleItemSelectEnter(e: CustomEvent | KeyboardEvent) {
+    this.handleInputCommit();
+    e.stopPropagation();
+    e.preventDefault();
+    this.focusWithoutDisplayingSuggestions();
   }
 
-  get _inputElement() {
-    // Polymer2: this.$ can be undefined when this is first evaluated.
-    return this.$ && this.$.input;
+  handleItemSelect(e: CustomEvent) {
+    if (e.detail.trigger === 'click') {
+      this.selected = e.detail.selected;
+      this._commit();
+      e.stopPropagation();
+      e.preventDefault();
+      this.focusWithoutDisplayingSuggestions();
+    } else if (e.detail.trigger === 'enter') {
+      this.handleItemSelectEnter(e);
+    } else if (e.detail.trigger === 'tab') {
+      if (this.tabComplete) {
+        this.handleInputCommit(true);
+        e.stopPropagation();
+        e.preventDefault();
+        this.focus();
+      } else {
+        this.setFocus(false);
+      }
+    }
   }
 
   /**
@@ -254,45 +396,48 @@
    * @param text The new text for the input.
    */
   setText(text: string) {
-    this._disableSuggestions = true;
+    this.disableDisplayingSuggestions = true;
     this.text = text;
-    this._disableSuggestions = false;
+
+    this.updateComplete.then(() => {
+      this.disableDisplayingSuggestions = false;
+    });
   }
 
-  _onInputFocus() {
-    this._focused = true;
-    this._updateSuggestions(this.text, this.threshold, this.noDebounce);
-    this.$.input.classList.remove('warnUncommitted');
+  onInputFocus() {
+    this.setFocus(true);
+    this.updateSuggestions();
+    this.input?.classList.remove('warnUncommitted');
     // Needed so that --paper-input-container-input updated style is applied.
-    this.updateStyles();
+    this.requestUpdate();
   }
 
-  _onInputBlur() {
-    this.$.input.classList.toggle(
+  onInputBlur() {
+    this.input?.classList.toggle(
       'warnUncommitted',
-      this.warnUncommitted && !!this.text.length && !this._focused
+      this.warnUncommitted && !!this.text.length && !this.focused
     );
     // Needed so that --paper-input-container-input updated style is applied.
-    this.updateStyles();
+    this.requestUpdate();
   }
 
-  @observe('text', 'threshold', 'noDebounce')
-  _updateSuggestions(text?: string, threshold?: number, noDebounce?: boolean) {
+  updateSuggestions() {
     if (
-      text === undefined ||
-      threshold === undefined ||
-      noDebounce === undefined
+      this.text === undefined ||
+      this.threshold === undefined ||
+      this.noDebounce === undefined
     )
       return;
 
-    // Reset _suggestions for every update
+    // Reset suggestions for every update
     // This will also prevent from carrying over suggestions:
     // @see Issue 12039
-    this._suggestions = [];
+    this.suggestions = [];
+    this.queryErrorMessage = undefined;
 
     // TODO(taoalpha): Also skip if text has not changed
 
-    if (this._disableSuggestions) {
+    if (this.disableDisplayingSuggestions) {
       return;
     }
 
@@ -301,33 +446,41 @@
       return;
     }
 
-    if (text.length < threshold) {
+    if (this.text.length < this.threshold) {
       this.value = '';
       return;
     }
 
-    if (!this._focused) {
+    if (!this.focused) {
       return;
     }
 
     const update = () => {
-      query(text).then(suggestions => {
-        if (text !== this.text) {
-          // Late response.
-          return;
-        }
-        for (const suggestion of suggestions) {
-          suggestion.text = suggestion.name;
-        }
-        this._suggestions = suggestions;
-        flush();
-        if (this._index === -1) {
+      query(this.text)
+        .then(suggestions => {
+          if (this.text !== this.text) {
+            // Late response.
+            return;
+          }
+          for (const suggestion of suggestions) {
+            suggestion.text = suggestion?.name ?? '';
+          }
+          this.suggestions = suggestions;
+          if (this.index === -1) {
+            this.value = '';
+          }
+        })
+        .catch(e => {
           this.value = '';
-        }
-      });
+          if (typeof e === 'string') {
+            this.queryErrorMessage = e;
+          } else if (e instanceof Error) {
+            this.queryErrorMessage = e.message;
+          }
+        });
     };
 
-    if (noDebounce) {
+    if (this.noDebounce) {
       update();
     } else {
       this.updateSuggestionsTask = debounce(
@@ -338,52 +491,69 @@
     }
   }
 
-  @observe('_suggestions', '_focused')
-  _maybeOpenDropdown(suggestions: AutocompleteSuggestion[], focused: boolean) {
-    if (suggestions.length > 0 && focused) {
-      return this.$.suggestions.open();
-    }
-    return this.$.suggestions.close();
+  setFocus(focused: boolean) {
+    if (focused === this.focused) return;
+    this.focused = focused;
+    this.updateDropdownVisibility();
   }
 
-  _computeClass(borderless?: boolean) {
-    return borderless ? 'borderless' : '';
+  updateDropdownVisibility() {
+    if (
+      (this.suggestions.length > 0 || this.queryErrorMessage) &&
+      this.focused
+    ) {
+      this.suggestionsDropdown?.open();
+      return;
+    }
+    this.suggestionsDropdown?.close();
+  }
+
+  computeClass() {
+    const classes = [];
+    if (this.borderless) classes.push('borderless');
+    if (this.showBlueFocusBorder) classes.push('showBlueFocusBorder');
+    return classes.join(' ');
   }
 
   /**
-   * _handleKeydown used for key handling in the this.$.input AND all child
-   * autocomplete options.
+   * handleKeydown used for key handling in the this.input?.
    */
-  _handleKeydown(e: KeyboardEvent) {
-    this._focused = true;
-    switch (e.keyCode) {
-      case 38: // Up
+  handleKeydown(e: KeyboardEvent) {
+    this.setFocus(true);
+    switch (e.key) {
+      case 'ArrowUp':
         e.preventDefault();
-        this.$.suggestions.cursorUp();
+        this.suggestionsDropdown?.cursorUp();
         break;
-      case 40: // Down
+      case 'ArrowDown':
         e.preventDefault();
-        this.$.suggestions.cursorDown();
+        this.suggestionsDropdown?.cursorDown();
         break;
-      case 27: // Escape
+      case 'Escape':
         e.preventDefault();
-        this._cancel();
+        this.cancel();
         break;
-      case 9: // Tab
-        if (this._suggestions.length > 0 && this.tabComplete) {
+      case 'Tab':
+        if (this.suggestions.length > 0 && this.tabComplete) {
           e.preventDefault();
-          this._handleInputCommit(true);
           this.focus();
+          this.handleInputCommit(true);
         } else {
-          this._focused = false;
+          this.setFocus(false);
         }
         break;
-      case 13: // Enter
+      case 'Enter':
         if (modifierPressed(e)) {
           break;
         }
-        e.preventDefault();
-        this._handleInputCommit();
+        if (this.suggestions.length > 0) {
+          // If suggestions are shown, act as if the keypress is in dropdown.
+          // suggestions length is 0 if error is shown.
+          this.handleItemSelectEnter(e);
+        } else {
+          e.preventDefault();
+          this.handleInputCommit();
+        }
         break;
       default:
         // For any normal keypress, return focus to the input to allow for
@@ -394,36 +564,41 @@
         // been based on a previous input. Clear them. This prevents an
         // outdated suggestion from being used if the input keystroke is
         // immediately followed by a commit keystroke. @see Issue 8655
-        this._suggestions = [];
+        this.suggestions = [];
     }
     this.dispatchEvent(
       new CustomEvent('input-keydown', {
-        detail: {keyCode: e.keyCode, input: this.$.input},
+        detail: {key: e.key, input: this.input},
         composed: true,
         bubbles: true,
       })
     );
   }
 
-  _cancel() {
-    if (this._suggestions.length) {
-      this.set('_suggestions', []);
+  cancel() {
+    if (this.suggestions.length || this.queryErrorMessage) {
+      this.suggestions = [];
+      this.queryErrorMessage = undefined;
+      this.requestUpdate();
     } else {
       fireEvent(this, 'cancel');
     }
   }
 
-  _handleInputCommit(_tabComplete?: boolean) {
-    // Nothing to do if the dropdown is not open.
-    if (!this.allowNonSuggestedValues && this.$.suggestions.isHidden) {
+  handleInputCommit(_tabComplete?: boolean) {
+    // Nothing to do if no suggestions.
+    if (
+      !this.allowNonSuggestedValues &&
+      (this.suggestionsDropdown?.isHidden || this.queryErrorMessage)
+    ) {
       return;
     }
 
-    this._selected = this.$.suggestions.getCursorTarget();
+    this.selected = this.suggestionsDropdown?.getCursorTarget() ?? null;
     this._commit(_tabComplete);
   }
 
-  _updateValue(
+  updateValue(
     suggestion: HTMLElement | null,
     suggestions: AutocompleteSuggestion[]
   ) {
@@ -455,7 +630,7 @@
         return;
       }
     }
-    this._focused = false;
+    this.setFocus(false);
   };
 
   /**
@@ -465,10 +640,10 @@
    * autocomplete suggestion in order to handle cases like tab-to-complete
    * without firing the commit event.
    */
-  _commit(silent?: boolean) {
+  async _commit(silent?: boolean) {
     // Allow values that are not in suggestion list iff suggestions are empty.
-    if (this._suggestions.length > 0) {
-      this._updateValue(this._selected, this._suggestions);
+    if (this.suggestions.length > 0) {
+      this.updateValue(this.selected, this.suggestions);
     } else {
       this.value = this.text || '';
     }
@@ -479,20 +654,24 @@
     if (this.multi) {
       this.setText(this.value);
     } else {
-      if (!this.clearOnCommit && this._selected) {
-        const dataSet = this._selected.dataset;
+      if (!this.clearOnCommit && this.selected) {
+        const dataSet = this.selected.dataset;
         // index property cannot be null for the data-set
         if (dataSet) {
           const index = Number(dataSet['index']!);
           if (isNaN(index)) return;
-          this.setText(this._suggestions[index].name || '');
+          this.setText(this.suggestions[index]?.name || '');
         }
       } else {
         this.clear();
       }
     }
 
-    this._suggestions = [];
+    this.suggestions = [];
+    this.queryErrorMessage = undefined;
+    // we need willUpdate to send text-changed event before we can send the
+    // 'commit' event
+    await this.updateComplete;
     if (!silent) {
       this.dispatchEvent(
         new CustomEvent('commit', {
@@ -504,7 +683,7 @@
     }
   }
 
-  _computeShowSearchIconClass(showSearchIcon: boolean) {
+  computeShowSearchIconClass(showSearchIcon: boolean) {
     return showSearchIcon ? 'showSearchIcon' : '';
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
deleted file mode 100644
index 62775aa..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
+++ /dev/null
@@ -1,123 +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">
-    .searchIcon {
-      display: none;
-    }
-    .searchIcon.showSearchIcon {
-      display: inline-block;
-    }
-    iron-icon {
-      margin: 0 var(--spacing-xs);
-      vertical-align: top;
-    }
-    paper-input.borderless {
-      border: none;
-      padding: 0;
-    }
-    paper-input {
-      background-color: var(--view-background-color);
-      color: var(--primary-text-color);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      padding: var(--spacing-s);
-      --paper-input-container: {
-        padding: 0;
-      }
-      --paper-input-container-input: {
-        font-size: var(--font-size-normal);
-        line-height: var(--line-height-normal);
-      }
-      /* This is a hack for not being able to set height:0 on the underline
-           of a paper-input 2.2.3 element. All the underline fixes below only
-           actually work in 3.x.x, so the height must be adjusted directly as
-           a workaround until we are on Polymer 3. */
-      height: var(--line-height-normal);
-      --paper-input-container-underline-height: 0;
-      --paper-input-container-underline-wrapper-height: 0;
-      --paper-input-container-underline-focus-height: 0;
-      --paper-input-container-underline-legacy-height: 0;
-      --paper-input-container-underline: {
-        height: 0;
-        display: none;
-      }
-      --paper-input-container-underline-focus: {
-        height: 0;
-        display: none;
-      }
-      --paper-input-container-underline-disabled: {
-        height: 0;
-        display: none;
-      }
-      /* Hide label for input. The label is still visible for
-      screen readers. Workaround found at:
-      https://github.com/PolymerElements/paper-input/issues/478 */
-      --paper-input-container-label: {
-        display: none;
-      }
-    }
-    paper-input.warnUncommitted {
-      --paper-input-container-input: {
-        color: var(--error-text-color);
-        font-size: inherit;
-      }
-    }
-  </style>
-  <paper-input
-    no-label-float=""
-    id="input"
-    class$="[[_computeClass(borderless)]]"
-    disabled$="[[disabled]]"
-    value="{{text}}"
-    placeholder="[[placeholder]]"
-    on-keydown="_handleKeydown"
-    on-focus="_onInputFocus"
-    on-blur="_onInputBlur"
-    autocomplete="off"
-    label="[[label]]"
-  >
-    <!-- prefix as attribute is required to for polymer 1 -->
-    <div slot="prefix" prefix="">
-      <iron-icon
-        icon="gr-icons:search"
-        class$="searchIcon [[_computeShowSearchIconClass(showSearchIcon)]]"
-      >
-      </iron-icon>
-    </div>
-
-    <!-- suffix as attribute is required to for polymer 1 -->
-    <div slot="suffix" suffix="">
-      <slot name="suffix"></slot>
-    </div>
-  </paper-input>
-  <gr-autocomplete-dropdown
-    vertical-align="top"
-    vertical-offset="[[verticalOffset]]"
-    horizontal-align="left"
-    id="suggestions"
-    on-item-selected="_handleItemSelect"
-    on-keydown="_handleKeydown"
-    suggestions="[[_suggestions]]"
-    role="listbox"
-    index="[[_index]]"
-    position-target="[[_inputElement]]"
-  >
-  </gr-autocomplete-dropdown>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
index 7da7ed5..e593c0d 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -1,39 +1,27 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-autocomplete';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {assertIsDefined} from '../../../utils/common-util';
-import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {
+  assertFails,
+  pressKey,
+  queryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
-
-const basicFixture = fixtureFromTemplate(
-  html`<gr-autocomplete no-debounce></gr-autocomplete>`
-);
+import {fixture, html, assert} from '@open-wc/testing';
+import {Key, Modifier} from '../../../utils/dom-util';
 
 suite('gr-autocomplete tests', () => {
   let element: GrAutocomplete;
 
   const focusOnInput = () => {
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    pressKey(inputEl(), Key.ENTER);
   };
 
   const suggestionsEl = () =>
@@ -41,60 +29,169 @@
 
   const inputEl = () => queryAndAssert<HTMLInputElement>(element, '#input');
 
-  setup(() => {
-    element = basicFixture.instantiate() as GrAutocomplete;
+  setup(async () => {
+    element = await fixture(
+      html`<gr-autocomplete no-debounce></gr-autocomplete>`
+    );
   });
 
   test('renders', () => {
-    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
-    const queryStub = sinon.spy(
-      (input: string) =>
-        (promise = Promise.resolve([
-          {name: input + ' 0', value: '0'},
-          {name: input + ' 1', value: '1'},
-          {name: input + ' 2', value: '2'},
-          {name: input + ' 3', value: '3'},
-          {name: input + ' 4', value: '4'},
-        ] as AutocompleteSuggestion[]))
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <paper-input
+          aria-disabled="false"
+          autocomplete="off"
+          id="input"
+          tabindex="0"
+        >
+          <div slot="prefix">
+            <gr-icon icon="search" class="searchIcon"></gr-icon>
+          </div>
+          <div slot="suffix">
+            <slot name="suffix"> </slot>
+          </div>
+        </paper-input>
+        <gr-autocomplete-dropdown
+          id="suggestions"
+          is-hidden=""
+          role="listbox"
+          style="position: fixed; top: 300px; left: 392.5px; box-sizing: border-box; max-height: 600px; max-width: 785px;"
+        >
+        </gr-autocomplete-dropdown>
+      `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+  });
+
+  test('renders with suggestions', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      Promise.resolve([
+        {name: input + ' 0', value: '0'},
+        {name: input + ' 1', value: '1'},
+        {name: input + ' 2', value: '2'},
+        {name: input + ' 3', value: '3'},
+        {name: input + ' 4', value: '4'},
+      ] as AutocompleteSuggestion[])
     );
     element.query = queryStub;
-    assert.isTrue(suggestionsEl().isHidden);
+
+    focusOnInput();
+    element.text = 'blah';
+    await waitUntil(() => queryStub.called);
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <paper-input
+          aria-disabled="false"
+          autocomplete="off"
+          id="input"
+          tabindex="0"
+        >
+          <div slot="prefix">
+            <gr-icon icon="search" class="searchIcon"></gr-icon>
+          </div>
+          <div slot="suffix">
+            <slot name="suffix"> </slot>
+          </div>
+        </paper-input>
+        <gr-autocomplete-dropdown id="suggestions" role="listbox">
+        </gr-autocomplete-dropdown>
+      `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+  });
+
+  test('renders with error', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      Promise.reject(new Error(`${input} not allowed`))
+    );
+    element.query = queryStub;
+
+    focusOnInput();
+    element.text = 'blah';
+    await waitUntil(() => queryStub.called);
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <paper-input
+          aria-disabled="false"
+          autocomplete="off"
+          id="input"
+          tabindex="0"
+        >
+          <div slot="prefix">
+            <gr-icon icon="search" class="searchIcon"></gr-icon>
+          </div>
+          <div slot="suffix">
+            <slot name="suffix"> </slot>
+          </div>
+        </paper-input>
+        <gr-autocomplete-dropdown id="suggestions" role="listbox">
+        </gr-autocomplete-dropdown>
+      `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+    assert.equal(element.suggestionsDropdown?.errorMessage, 'blah not allowed');
+  });
+
+  test('cursor starts on suggestions', async () => {
+    const queryStub = sinon.spy((input: string) =>
+      Promise.resolve([
+        {name: input + ' 0', value: '0'},
+        {name: input + ' 1', value: '1'},
+        {name: input + ' 2', value: '2'},
+        {name: input + ' 3', value: '3'},
+        {name: input + ' 4', value: '4'},
+      ] as AutocompleteSuggestion[])
+    );
+    element.query = queryStub;
+
     assert.equal(suggestionsEl().cursor.index, -1);
 
     focusOnInput();
     element.text = 'blah';
+    await waitUntil(() => queryStub.called);
+    await element.updateComplete;
 
-    assert.isTrue(queryStub.called);
-    element._focused = true;
-
-    assertIsDefined(promise);
-    return promise.then(() => {
-      assert.isFalse(suggestionsEl().isHidden);
-      const suggestions = queryAll<HTMLElement>(suggestionsEl(), 'li');
-      assert.equal(suggestions.length, 5);
-
-      for (let i = 0; i < 5; i++) {
-        assert.equal(suggestions[i].innerText.trim(), `blah ${i}`);
-      }
-
-      assert.notEqual(suggestionsEl().cursor.index, -1);
-    });
+    assert.notEqual(suggestionsEl().cursor.index, -1);
   });
 
   test('selectAll', async () => {
-    await flush();
-    const nativeInput = element._nativeInput;
+    await element.updateComplete;
+    const nativeInput = element.nativeInput;
     const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
 
     element.selectAll();
+    await element.updateComplete;
     assert.isFalse(selectionStub.called);
 
     inputEl().value = 'test';
+    await element.updateComplete;
     element.selectAll();
     assert.isTrue(selectionStub.called);
   });
 
-  test('esc key behavior', () => {
+  test('esc key behavior', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(
       (_: string) =>
@@ -106,26 +203,63 @@
 
     assert.isTrue(suggestionsEl().isHidden);
 
-    element._focused = true;
+    element.setFocus(true);
     element.text = 'blah';
+    await element.updateComplete;
 
-    return promise.then(() => {
-      assert.isFalse(suggestionsEl().isHidden);
+    return promise.then(async () => {
+      await waitUntil(() => !suggestionsEl().isHidden);
 
       const cancelHandler = sinon.spy();
       element.addEventListener('cancel', cancelHandler);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
-      assert.isFalse(cancelHandler.called);
-      assert.isTrue(suggestionsEl().isHidden);
-      assert.equal(element._suggestions.length, 0);
+      pressKey(inputEl(), Key.ESC);
+      await waitUntil(() => suggestionsEl().isHidden);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      assert.isFalse(cancelHandler.called);
+      assert.equal(element.suggestions.length, 0);
+
+      pressKey(inputEl(), Key.ESC);
+      await element.updateComplete;
+
       assert.isTrue(cancelHandler.called);
     });
   });
 
-  test('emits commit and handles cursor movement', () => {
+  test('esc key behavior on error', async () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (_: string) => (promise = Promise.reject(new Error('Test error')))
+    );
+    element.query = queryStub;
+
+    assert.isTrue(suggestionsEl().isHidden);
+
+    element.setFocus(true);
+    element.text = 'blah';
+    await element.updateComplete;
+
+    return assertFails(promise).then(async () => {
+      await waitUntil(() => !suggestionsEl().isHidden);
+
+      const cancelHandler = sinon.spy();
+      element.addEventListener('cancel', cancelHandler);
+      assert.equal(element.queryErrorMessage, 'Test error');
+
+      pressKey(inputEl(), Key.ESC);
+      await waitUntil(() => suggestionsEl().isHidden);
+
+      assert.isFalse(cancelHandler.called);
+      assert.isUndefined(element.queryErrorMessage);
+
+      pressKey(inputEl(), Key.ESC);
+      await element.updateComplete;
+
+      assert.isTrue(cancelHandler.called);
+    });
+  });
+
+  test('emits commit and handles cursor movement', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(
       (input: string) =>
@@ -138,43 +272,50 @@
         ] as AutocompleteSuggestion[]))
     );
     element.query = queryStub;
-
+    await element.updateComplete;
     assert.isTrue(suggestionsEl().isHidden);
     assert.equal(suggestionsEl().cursor.index, -1);
-    element._focused = true;
-    element.text = 'blah';
+    element.setFocus(true);
 
-    return promise.then(() => {
-      assert.isFalse(suggestionsEl().isHidden);
+    element.text = 'blah';
+    await element.updateComplete;
+
+    return promise.then(async () => {
+      await waitUntil(() => !suggestionsEl().isHidden);
 
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
 
       assert.equal(suggestionsEl().cursor.index, 0);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+      pressKey(inputEl(), 'ArrowDown');
+      await element.updateComplete;
 
       assert.equal(suggestionsEl().cursor.index, 1);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+      pressKey(inputEl(), 'ArrowDown');
+      await element.updateComplete;
 
       assert.equal(suggestionsEl().cursor.index, 2);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 38, null, 'up');
+      pressKey(inputEl(), 'ArrowUp');
+      await element.updateComplete;
 
       assert.equal(suggestionsEl().cursor.index, 1);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      pressKey(inputEl(), Key.ENTER);
+      await element.updateComplete;
 
       assert.equal(element.value, '1');
-      assert.isTrue(commitHandler.called);
+
+      await waitUntil(() => commitHandler.called);
       assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
       assert.isTrue(suggestionsEl().isHidden);
-      assert.isTrue(element._focused);
+      assert.isTrue(element.focused);
     });
   });
 
-  test('clear-on-commit behavior (off)', () => {
+  test('clear-on-commit behavior (off)', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(() => {
       promise = Promise.resolve([
@@ -185,19 +326,21 @@
     element.query = queryStub;
     focusOnInput();
     element.text = 'blah';
+    await waitUntil(() => element.suggestions.length > 0);
 
-    return promise.then(() => {
+    return promise.then(async () => {
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      pressKey(inputEl(), Key.ENTER);
 
-      assert.isTrue(commitHandler.called);
+      await waitUntil(() => commitHandler.called);
+
       assert.equal(element.text, 'suggestion');
     });
   });
 
-  test('clear-on-commit behavior (on)', () => {
+  test('clear-on-commit behavior (on)', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon.spy(() => {
       promise = Promise.resolve([
@@ -208,20 +351,24 @@
     element.query = queryStub;
     focusOnInput();
     element.text = 'blah';
+
+    await waitUntil(() => element.suggestions.length > 0);
+
     element.clearOnCommit = true;
 
-    return promise.then(() => {
+    return promise.then(async () => {
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      pressKey(inputEl(), Key.ENTER);
 
-      assert.isTrue(commitHandler.called);
+      await waitUntil(() => commitHandler.called);
+
       assert.equal(element.text, '');
     });
   });
 
-  test('threshold guards the query', () => {
+  test('threshold guards the query', async () => {
     const queryStub = sinon.spy(() =>
       Promise.resolve([] as AutocompleteSuggestion[])
     );
@@ -229,17 +376,21 @@
     element.threshold = 2;
     focusOnInput();
     element.text = 'a';
+    await element.updateComplete;
     assert.isFalse(queryStub.called);
+
     element.text = 'ab';
-    assert.isTrue(queryStub.called);
+    await element.updateComplete;
+    await waitUntil(() => queryStub.called);
   });
 
-  test('noDebounce=false debounces the query', () => {
-    const clock = sinon.useFakeTimers();
+  test('noDebounce=false debounces the query', async () => {
     const queryStub = sinon.spy(() =>
       Promise.resolve([] as AutocompleteSuggestion[])
     );
+
     element.query = queryStub;
+    await element.updateComplete;
     element.noDebounce = false;
     focusOnInput();
     element.text = 'a';
@@ -247,23 +398,27 @@
     // not called right away
     assert.isFalse(queryStub.called);
 
-    // but called after a while
-    clock.tick(1000);
-    assert.isTrue(queryStub.called);
+    await waitUntil(() => queryStub.called);
   });
 
-  test('_computeClass respects border property', () => {
-    assert.equal(element._computeClass(), '');
-    assert.equal(element._computeClass(false), '');
-    assert.equal(element._computeClass(true), 'borderless');
+  test('computeClass respects border property', () => {
+    element.borderless = false;
+    assert.equal(element.computeClass(), '');
+    element.borderless = true;
+    assert.equal(element.computeClass(), 'borderless');
+    element.showBlueFocusBorder = true;
+    assert.equal(element.computeClass(), 'borderless showBlueFocusBorder');
   });
 
-  test('undefined or empty text results in no suggestions', () => {
-    element._updateSuggestions(undefined, 0, undefined);
-    assert.equal(element._suggestions.length, 0);
+  test('empty text results in no suggestions', async () => {
+    element.text = '';
+    element.threshold = 0;
+    element.noDebounce = false;
+    await element.updateComplete;
+    assert.equal(element.suggestions.length, 0);
   });
 
-  test('when focused', () => {
+  test('when focused', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon
       .stub()
@@ -275,15 +430,16 @@
     element.query = queryStub;
     focusOnInput();
     element.text = 'bla';
-    assert.equal(element._focused, true);
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
+    assert.equal(element.focused, true);
+    await element.updateComplete;
+    return promise.then(async () => {
+      await waitUntil(() => element.suggestions.length > 0);
+      assert.equal(element.suggestions.length, 1);
       assert.equal(queryStub.notCalled, false);
     });
   });
 
-  test('when not focused', () => {
+  test('when not focused', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon
       .stub()
@@ -294,14 +450,14 @@
       );
     element.query = queryStub;
     element.text = 'bla';
-    assert.equal(element._focused, false);
-    flush();
+    assert.equal(element.focused, false);
+    await element.updateComplete;
     return promise.then(() => {
-      assert.equal(element._suggestions.length, 0);
+      assert.equal(element.suggestions.length, 0);
     });
   });
 
-  test('suggestions should not carry over', () => {
+  test('suggestions should not carry over', async () => {
     let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
     const queryStub = sinon
       .stub()
@@ -313,15 +469,38 @@
     element.query = queryStub;
     focusOnInput();
     element.text = 'bla';
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
-      element._updateSuggestions('', 0, false);
-      assert.equal(element._suggestions.length, 0);
+    await element.updateComplete;
+    return promise.then(async () => {
+      await waitUntil(() => element.suggestions.length > 0);
+      assert.equal(element.suggestions.length, 1);
+      element.text = '';
+      element.threshold = 0;
+      element.noDebounce = false;
+      await element.updateComplete;
+      assert.equal(element.suggestions.length, 0);
     });
   });
 
-  test('multi completes only the last part of the query', () => {
+  test('error should not carry over', async () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns((promise = Promise.reject(new Error('Test error'))));
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'bla';
+    await element.updateComplete;
+    return assertFails(promise).then(async () => {
+      await waitUntil(() => element.queryErrorMessage === 'Test error');
+      element.text = '';
+      element.threshold = 0;
+      element.noDebounce = false;
+      await element.updateComplete;
+      assert.isUndefined(element.queryErrorMessage);
+    });
+  });
+
+  test('multi completes only the last part of the query', async () => {
     let promise;
     const queryStub = sinon
       .stub()
@@ -334,139 +513,179 @@
     focusOnInput();
     element.text = 'blah blah';
     element.multi = true;
+    await element.updateComplete;
 
-    return promise.then(() => {
+    return promise.then(async () => {
       const commitHandler = sinon.spy();
       element.addEventListener('commit', commitHandler);
+      await waitUntil(() => element.suggestionsDropdown?.isHidden === false);
 
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      pressKey(inputEl(), Key.ENTER);
 
-      assert.isTrue(commitHandler.called);
+      await waitUntil(() => commitHandler.called);
       assert.equal(element.text, 'blah 0');
     });
   });
 
-  test('tabComplete flag functions', () => {
+  test('tabComplete flag functions', async () => {
     // commitHandler checks for the commit event, whereas commitSpy checks for
     // the _commit function of the element.
     const commitHandler = sinon.spy();
     element.addEventListener('commit', commitHandler);
     const commitSpy = sinon.spy(element, '_commit');
-    element._focused = true;
+    element.setFocus(true);
 
-    element._suggestions = [{text: 'tunnel snakes rule!'}];
+    element.suggestions = [{text: 'tunnel snakes rule!', name: ''}];
     element.tabComplete = false;
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    pressKey(inputEl(), Key.TAB);
+    await element.updateComplete;
+
     assert.isFalse(commitHandler.called);
     assert.isFalse(commitSpy.called);
-    assert.isFalse(element._focused);
+    assert.isFalse(element.focused);
 
     element.tabComplete = true;
-    element._focused = true;
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    await element.updateComplete;
+    element.setFocus(true);
+    await element.updateComplete;
+    pressKey(inputEl(), Key.TAB);
+
+    await waitUntil(() => commitSpy.called);
     assert.isFalse(commitHandler.called);
-    assert.isTrue(commitSpy.called);
-    assert.isTrue(element._focused);
+    assert.isTrue(element.focused);
   });
 
-  test('_focused flag properly triggered', () => {
-    flush();
-    assert.isFalse(element._focused);
-    const input = queryAndAssert<PaperInputElement>(
-      element,
-      'paper-input'
-    ).inputElement;
-    MockInteractions.focus(input);
-    assert.isTrue(element._focused);
+  test('focused flag properly triggered', async () => {
+    await element.updateComplete;
+    assert.isFalse(element.focused);
+    const input = queryAndAssert<PaperInputElement>(element, 'paper-input');
+    input.focus();
+    assert.isTrue(element.focused);
   });
 
-  test('search icon shows with showSearchIcon property', () => {
-    flush();
+  test('search icon shows with showSearchIcon property', async () => {
     assert.equal(
-      getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
+      getComputedStyle(queryAndAssert(element, 'gr-icon')).display,
       'none'
     );
     element.showSearchIcon = true;
+    await element.updateComplete;
+
     assert.notEqual(
-      getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
+      getComputedStyle(queryAndAssert(element, 'gr-icon')).display,
       'none'
     );
   });
 
-  test('vertical offset overridden by param if it exists', () => {
+  test('vertical offset overridden by param if it exists', async () => {
     assert.equal(suggestionsEl().verticalOffset, 31);
+
     element.verticalOffset = 30;
+    await element.updateComplete;
+
     assert.equal(suggestionsEl().verticalOffset, 30);
   });
 
-  test('_focused flag shows/hides the suggestions', () => {
+  test('focused flag shows/hides the suggestions', async () => {
     const openStub = sinon.stub(suggestionsEl(), 'open');
     const closedStub = sinon.stub(suggestionsEl(), 'close');
-    element._suggestions = [{text: 'hello'}, {text: 'its me'}];
+    element.suggestions = [{text: 'hello'}, {text: 'its me'}];
     assert.isFalse(openStub.called);
-    assert.isTrue(closedStub.calledOnce);
-    element._focused = true;
-    assert.isTrue(openStub.calledOnce);
-    element._suggestions = [];
-    assert.isTrue(closedStub.calledTwice);
+    await waitUntil(() => closedStub.calledOnce);
+    element.setFocus(true);
+    await waitUntil(() => openStub.calledOnce);
+    element.suggestions = [];
+    await waitUntil(() => closedStub.calledTwice);
     assert.isTrue(openStub.calledOnce);
   });
 
   test(
-    '_handleInputCommit with autocomplete hidden does nothing without' +
-      'without allowNonSuggestedValues',
+    'handleInputCommit with autocomplete hidden does nothing without' +
+      ' allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
       suggestionsEl().isHidden = true;
-      element._handleInputCommit();
+      element.handleInputCommit();
       assert.isFalse(commitStub.called);
     }
   );
 
   test(
-    '_handleInputCommit with autocomplete hidden with' +
+    'handleInputCommit with query error does nothing without' +
+      ' allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.queryErrorMessage = 'Error';
+      element.handleInputCommit();
+      assert.isFalse(commitStub.called);
+    }
+  );
+
+  test(
+    'handleInputCommit with autocomplete hidden with' +
       'allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
       element.allowNonSuggestedValues = true;
       suggestionsEl().isHidden = true;
-      element._handleInputCommit();
+      element.handleInputCommit();
       assert.isTrue(commitStub.called);
     }
   );
 
-  test('_handleInputCommit with autocomplete open calls commit', () => {
+  test(
+    'handleInputCommit with query error with' + 'allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      element.queryErrorMessage = 'Error';
+      element.handleInputCommit();
+      assert.isTrue(commitStub.called);
+    }
+  );
+
+  test('handleInputCommit with autocomplete open calls commit', () => {
     const commitStub = sinon.stub(element, '_commit');
     suggestionsEl().isHidden = false;
-    element._handleInputCommit();
+    element.handleInputCommit();
     assert.isTrue(commitStub.calledOnce);
   });
 
   test(
-    '_handleInputCommit with autocomplete open calls commit' +
+    'handleInputCommit with autocomplete open calls commit' +
       'with allowNonSuggestedValues',
     () => {
       const commitStub = sinon.stub(element, '_commit');
       element.allowNonSuggestedValues = true;
       suggestionsEl().isHidden = false;
-      element._handleInputCommit();
+      element.handleInputCommit();
       assert.isTrue(commitStub.calledOnce);
     }
   );
 
-  test('issue 8655', () => {
+  test('issue 8655', async () => {
     function makeSuggestion(s: string) {
       return {name: s, text: s, value: s};
     }
-    const keydownSpy = sinon.spy(element, '_handleKeydown');
+    const keydownSpy = sinon.spy(element, 'handleKeydown');
+    element.requestUpdate();
+    await element.updateComplete;
+
+    // const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
     element.setText('file:');
-    element._suggestions = [makeSuggestion('file:'), makeSuggestion('-file:')];
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 88, null, 'x');
+    element.suggestions = [makeSuggestion('file:'), makeSuggestion('-file:')];
+    await element.updateComplete;
+
+    pressKey(inputEl(), 'x');
     // Must set the value, because the MockInteraction does not.
     inputEl().value = 'file:x';
+
     assert.isTrue(keydownSpy.calledOnce);
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+    pressKey(inputEl(), Key.ENTER);
+    await element.updateComplete;
     assert.isTrue(keydownSpy.calledTwice);
+
     assert.equal(element.text, 'file:x');
   });
 
@@ -478,117 +697,196 @@
       commitSpy = sinon.spy(element, '_commit');
     });
 
-    test('enter does not call focus', () => {
-      element._suggestions = [{text: 'sugar bombs'}];
-      focusSpy = sinon.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
-      flush();
+    test('enter in input does not re-render suggestions', async () => {
+      element.suggestions = [{text: 'sugar bombs'}];
 
-      assert.isTrue(commitSpy.called);
-      assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 0);
+      pressKey(inputEl(), Key.ENTER);
+
+      await waitUntil(() => commitSpy.called);
+      await element.updateComplete;
+
+      assert.equal(element.suggestions.length, 0);
+      assert.isUndefined(element.queryErrorMessage);
+      assert.isTrue(suggestionsEl().isHidden);
     });
 
-    test('tab in input, tabComplete = true', () => {
+    test('enter in input does not re-render error', async () => {
+      element.allowNonSuggestedValues = true;
+      element.queryErrorMessage = 'Error message';
+
+      pressKey(inputEl(), Key.ENTER);
+
+      await waitUntil(() => commitSpy.called);
+      await element.updateComplete;
+
+      assert.equal(element.suggestions.length, 0);
+      assert.isUndefined(element.queryErrorMessage);
+      assert.isTrue(suggestionsEl().isHidden);
+    });
+
+    test('enter in suggestion does not re-render suggestions', async () => {
+      element.suggestions = [{text: 'sugar bombs'}];
+      element.setFocus(true);
+
+      await element.updateComplete;
+      assert.isFalse(suggestionsEl().isHidden);
+
+      focusSpy = sinon.spy(element, 'focus');
+      pressKey(suggestionsEl(), Key.ENTER);
+
+      await waitUntil(() => commitSpy.called);
+      await element.updateComplete;
+
+      assert.equal(element.suggestions.length, 0);
+      assert.isTrue(suggestionsEl().isHidden);
+    });
+
+    test('tab in input, tabComplete = true', async () => {
       focusSpy = sinon.spy(element, 'focus');
       const commitHandler = sinon.stub();
       element.addEventListener('commit', commitHandler);
       element.tabComplete = true;
-      element._suggestions = [{text: 'tunnel snakes drool'}];
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
-      flush();
+      element.suggestions = [{text: 'tunnel snakes drool'}];
 
-      assert.isTrue(commitSpy.called);
+      pressKey(inputEl(), Key.TAB);
+
+      await waitUntil(() => commitSpy.called);
+
       assert.isTrue(focusSpy.called);
       assert.isFalse(commitHandler.called);
-      assert.equal(element._suggestions.length, 0);
+      assert.equal(element.suggestions.length, 0);
     });
 
-    test('tab in input, tabComplete = false', () => {
-      element._suggestions = [{text: 'sugar bombs'}];
+    test('tab in input, tabComplete = false', async () => {
+      element.suggestions = [{text: 'sugar bombs'}];
       focusSpy = sinon.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
-      flush();
+      pressKey(inputEl(), Key.TAB);
+      await element.updateComplete;
 
       assert.isFalse(commitSpy.called);
       assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 1);
+      await waitUntil(() => element.suggestions.length > 0);
+      assert.equal(element.suggestions.length, 1);
     });
 
-    test('tab on suggestion, tabComplete = false', () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
+    test('tab on suggestion, tabComplete = false', async () => {
+      element.suggestions = [{name: 'sugar bombs'}];
+      element.setFocus(true);
       // When tabComplete is false, do not focus.
       element.tabComplete = false;
       focusSpy = sinon.spy(element, 'focus');
-      flush$0();
+
+      await element.updateComplete;
+
       assert.isFalse(suggestionsEl().isHidden);
 
-      MockInteractions.pressAndReleaseKeyOn(
-        queryAndAssert(suggestionsEl(), 'li:first-child'),
-        9,
-        null,
-        'tab'
-      );
-      flush();
+      pressKey(queryAndAssert(suggestionsEl(), 'li:first-child'), Key.TAB);
+      await element.updateComplete;
       assert.isFalse(commitSpy.called);
-      assert.isFalse(element._focused);
+      assert.isFalse(element.focused);
     });
 
-    test('tab on suggestion, tabComplete = true', () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
+    test('tab on suggestion, tabComplete = true', async () => {
+      element.suggestions = [{name: 'sugar bombs'}];
+      element.setFocus(true);
       // When tabComplete is true, focus.
       element.tabComplete = true;
       focusSpy = sinon.spy(element, 'focus');
-      flush$0();
+
+      await element.updateComplete;
+
       assert.isFalse(suggestionsEl().isHidden);
 
-      MockInteractions.pressAndReleaseKeyOn(
-        queryAndAssert(suggestionsEl(), 'li:first-child'),
-        9,
-        null,
-        'tab'
-      );
-      flush();
+      pressKey(queryAndAssert(suggestionsEl(), 'li:first-child'), Key.TAB);
+      await element.updateComplete;
 
       assert.isTrue(commitSpy.called);
-      assert.isTrue(element._focused);
+      assert.isTrue(element.focused);
     });
 
-    test('tap on suggestion commits, does not call focus', () => {
+    test('tap on suggestion commits, calls focus', async () => {
       focusSpy = sinon.spy(element, 'focus');
-      element._focused = true;
-      element._suggestions = [{name: 'first suggestion'}];
-      flush$0();
-      assert.isFalse(suggestionsEl().isHidden);
-      MockInteractions.tap(queryAndAssert(suggestionsEl(), 'li:first-child'));
-      flush();
+      element.setFocus(true);
+      element.suggestions = [{name: 'first suggestion'}];
 
-      assert.isFalse(focusSpy.called);
+      await element.updateComplete;
+
+      await waitUntil(() => !suggestionsEl().isHidden);
+      queryAndAssert<HTMLLIElement>(suggestionsEl(), 'li:first-child').click();
+
+      await waitUntil(() => suggestionsEl().isHidden);
+      assert.isTrue(focusSpy.called);
       assert.isTrue(commitSpy.called);
-      assert.isTrue(suggestionsEl().isHidden);
+    });
+
+    test('esc on suggestion clears suggestions, calls focus', async () => {
+      element.suggestions = [{name: 'sugar bombs'}];
+      element.setFocus(true);
+      focusSpy = sinon.spy(element, 'focus');
+
+      await element.updateComplete;
+
+      assert.isFalse(suggestionsEl().isHidden);
+
+      pressKey(queryAndAssert(suggestionsEl(), 'li:first-child'), Key.ESC);
+
+      await waitUntil(() => suggestionsEl().isHidden);
+      await element.updateComplete;
+
+      assert.isFalse(commitSpy.called);
+      assert.isTrue(focusSpy.called);
     });
   });
 
-  test('input-keydown event fired', () => {
+  test('input-keydown event fired', async () => {
     const listener = sinon.spy();
     element.addEventListener('input-keydown', listener);
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
-    flush();
+    pressKey(inputEl(), Key.TAB);
+    await element.updateComplete;
     assert.isTrue(listener.called);
   });
 
-  test('enter with modifier does not complete', () => {
-    const handleSpy = sinon.spy(element, '_handleKeydown');
-    const commitStub = sinon.stub(element, '_handleInputCommit');
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, 'ctrl', 'enter');
-    assert.isTrue(handleSpy.called);
+  test('enter with modifier does not complete', async () => {
+    const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+    const commitStub = sinon.stub(element, 'handleInputCommit');
+    pressKey(inputEl(), Key.ENTER, Modifier.CTRL_KEY);
+    await element.updateComplete;
+
+    assert.equal(dispatchEventStub.lastCall.args[0].type, 'input-keydown');
+    assert.equal(
+      (dispatchEventStub.lastCall.args[0] as CustomEvent).detail.key,
+      Key.ENTER
+    );
+
     assert.isFalse(commitStub.called);
-    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    pressKey(inputEl(), Key.ENTER);
+    await element.updateComplete;
+
     assert.isTrue(commitStub.called);
   });
 
+  test('enter with dropdown does not propagate', async () => {
+    const event = new KeyboardEvent('keydown', {key: Key.ENTER});
+    const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+
+    element.suggestions = [{name: 'first suggestion'}];
+
+    inputEl().dispatchEvent(event);
+    await element.updateComplete;
+
+    assert.isTrue(stopPropagationStub.called);
+  });
+
+  test('enter with no dropdown propagates', async () => {
+    const event = new KeyboardEvent('keydown', {key: Key.ENTER});
+    const stopPropagationStub = sinon.stub(event, 'stopPropagation');
+
+    inputEl().dispatchEvent(event);
+    await element.updateComplete;
+
+    assert.isFalse(stopPropagationStub.called);
+  });
+
   suite('warnUncommitted', () => {
     let inputClassList: DOMTokenList;
     setup(() => {
@@ -598,23 +896,23 @@
     test('enabled', () => {
       element.warnUncommitted = true;
       element.text = 'blah blah blah';
-      MockInteractions.blur(inputEl());
+      inputEl().dispatchEvent(new Event('blur'));
       assert.isTrue(inputClassList.contains('warnUncommitted'));
-      MockInteractions.focus(inputEl());
+      inputEl().focus();
       assert.isFalse(inputClassList.contains('warnUncommitted'));
     });
 
     test('disabled', () => {
       element.warnUncommitted = false;
       element.text = 'blah blah blah';
-      MockInteractions.blur(inputEl());
+      inputEl().blur();
       assert.isFalse(inputClassList.contains('warnUncommitted'));
     });
 
     test('no text', () => {
       element.warnUncommitted = true;
       element.text = '';
-      MockInteractions.blur(inputEl());
+      inputEl().blur();
       assert.isFalse(inputClassList.contains('warnUncommitted'));
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
new file mode 100644
index 0000000..1bfb55b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import './gr-avatar';
+import {AccountInfo} from '../../../types/common';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  uniqueAccountId,
+  uniqueDefinedAvatar,
+} from '../../../utils/account-util';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {subscribe} from '../../lit/subscription-controller';
+
+/**
+ * This elements draws stack of avatars overlapped with each other.
+ *
+ * If accounts is empty or contains accounts with more than MAX_STACK unique
+ * avatars the fallback slot is rendered instead.
+ *
+ * Style parameters:
+ *   --avatar-size: size of the individual avatars. (Default: 16px)
+ *   --stack-border-color: border of individual avatars in stack.
+ *       (Default: #ffffff)
+ */
+@customElement('gr-avatar-stack')
+export class GrAvatarStack extends LitElement {
+  static readonly MAX_STACK = 4;
+
+  @property({type: Array})
+  accounts: AccountInfo[] = [];
+
+  /**
+   * The size of requested image in px.
+   *
+   * By default this also controls avatarSize.
+   */
+  @property({type: Number})
+  imageSize = 16;
+
+  /**
+   * In gr-app, gr-account-chip is in charge of loading a full account, so
+   * avatars will be set. However, code-owners will create gr-avatars with a
+   * bare account-id. To enable fetching of those avatars, a flag is added to
+   * gr-avatar that will disregard the absence of avatar urls.
+   */
+  @property({type: Boolean})
+  forceFetch = false;
+
+  /**
+   * Reflects plugins.has_avatars value of server configuration.
+   */
+  @state() private hasAvatars = false;
+
+  static override get styles() {
+    return [
+      css`
+        gr-avatar {
+          box-sizing: border-box;
+          vertical-align: top;
+          height: var(--avatar-size, 16px);
+          width: var(--avatar-size, 16px);
+          border: solid 1px var(--stack-border-color, transparent);
+        }
+        gr-avatar:not(:first-child) {
+          margin-left: calc((var(--avatar-size, 16px) / -2));
+        }
+      `,
+    ];
+  }
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.hasAvatars = Boolean(config?.plugin?.has_avatars);
+      }
+    );
+  }
+
+  override render() {
+    const uniqueAvatarAccounts = this.forceFetch
+      ? this.accounts.filter(uniqueAccountId)
+      : this.accounts
+          .filter(account => !!account?.avatars?.[0]?.url)
+          .filter(uniqueDefinedAvatar);
+    if (
+      !this.hasAvatars ||
+      uniqueAvatarAccounts.length === 0 ||
+      uniqueAvatarAccounts.length > GrAvatarStack.MAX_STACK
+    ) {
+      return html`<slot name="fallback"></slot>`;
+    }
+    return uniqueAvatarAccounts.map(
+      account =>
+        html`<gr-avatar
+          .forceFetch=${this.forceFetch}
+          .account=${account}
+          .imageSize=${this.imageSize}
+        >
+        </gr-avatar>`
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-avatar-stack': GrAvatarStack;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts
new file mode 100644
index 0000000..c186a48
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack_test.ts
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-avatar-stack';
+import {
+  createAccountWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {fixture, html, assert} from '@open-wc/testing';
+import {stubRestApi} from '../../../test/test-utils';
+import {LitElement} from 'lit';
+
+suite('gr-avatar tests', () => {
+  suite('config with avatars', () => {
+    setup(() => {
+      // Set up server response, so that gr-avatar is not hidden.
+      stubRestApi('getConfig').resolves({
+        ...createServerInfo(),
+        plugin: {has_avatars: true, js_resource_paths: []},
+      });
+    });
+
+    test('renders avatars', async () => {
+      const accounts = [];
+      for (let i = 0; i < 2; ++i) {
+        accounts.push({
+          ...createAccountWithId(i),
+          avatars: [
+            {
+              url: `https://a.b.c/photo${i}.jpg`,
+              height: 32,
+              width: 32,
+            },
+          ],
+        });
+      }
+      accounts.push({
+        ...createAccountWithId(2),
+        avatars: [
+          {
+            // Account with duplicate avatar will be skipped.
+            url: 'https://a.b.c/photo1.jpg',
+            height: 32,
+            width: 32,
+          },
+        ],
+      });
+
+      const element: LitElement = await fixture(
+        html`<gr-avatar-stack
+          .accounts=${accounts}
+          .imageSize=${32}
+        ></gr-avatar-stack>`
+      );
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<gr-avatar
+            style='background-image: url("https://a.b.c/photo0.jpg");'
+          >
+          </gr-avatar>
+          <gr-avatar style='background-image: url("https://a.b.c/photo1.jpg");'>
+          </gr-avatar> `
+      );
+      // Verify that margins are set correctly.
+      const avatars = element.shadowRoot!.querySelectorAll('gr-avatar');
+      assert.strictEqual(avatars.length, 2);
+      assert.strictEqual(window.getComputedStyle(avatars[0]).marginLeft, '0px');
+      for (let i = 1; i < avatars.length; ++i) {
+        assert.strictEqual(
+          window.getComputedStyle(avatars[i]).marginLeft,
+          '-8px'
+        );
+      }
+    });
+
+    test('renders many accounts fallback', async () => {
+      const accounts = [];
+      for (let i = 0; i < 5; ++i) {
+        accounts.push({
+          ...createAccountWithId(i),
+          avatars: [
+            {
+              url: `https://a.b.c/photo${i}.jpg`,
+              height: 32,
+              width: 32,
+            },
+          ],
+        });
+      }
+
+      const element = await fixture(
+        html`<gr-avatar-stack .accounts=${accounts} .imageSize=${32}>
+          <span slot="fallback">Fall back!</span>
+        </gr-avatar-stack>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ '<slot name="fallback"></slot>'
+      );
+    });
+
+    test('renders no accounts fallback', async () => {
+      // Single account without an avatar.
+      const accounts = [createAccountWithId(1)];
+
+      const element = await fixture(
+        html`<gr-avatar-stack .accounts=${accounts} .imageSize=${32}>
+          <span slot="fallback">Fall back!</span>
+        </gr-avatar-stack>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ '<slot name="fallback"></slot>'
+      );
+    });
+  });
+
+  test('renders no avatars fallback', async () => {
+    // Set up server response, to indicate that no avatars are being served.
+    stubRestApi('getConfig').resolves({
+      ...createServerInfo(),
+      plugin: {has_avatars: false, js_resource_paths: []},
+    });
+    // Single account without an avatar.
+    const accounts = [createAccountWithId(1)];
+
+    const element = await fixture(
+      html`<gr-avatar-stack .accounts=${accounts} .imageSize=${32}>
+        <span slot="fallback">Fall back!</span>
+      </gr-avatar-stack>`
+    );
+    assert.shadowDom.equal(element, /* HTML */ '<slot name="fallback"></slot>');
+  });
+});
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 33bf6c6..8cfe2d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -1,26 +1,20 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {getBaseUrl} from '../../../utils/url-util';
-import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
 import {AccountInfo} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
+import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
+import {resolve} from '../../../models/dependency';
 
+/**
+ * The <gr-avatar> component works by updating its own background and visibility
+ * rather than conditionally rendering an image into it's shadow root.
+ */
 @customElement('gr-avatar')
 export class GrAvatar extends LitElement {
   @property({type: Object})
@@ -29,10 +23,19 @@
   @property({type: Number})
   imageSize = 16;
 
-  @property({type: Boolean})
-  _hasAvatars = false;
+  @state() private hasAvatars = false;
 
-  private readonly restApiService = appContext.restApiService;
+  // In gr-app, gr-account-chip is in charge of loading a full account, so
+  // avatars will be set. However, code-owners will create gr-avatars with a
+  // bare account-id. To enable fetching of those avatars, a flag is added to
+  // gr-avatar that will disregard the absence of avatar urls.
+
+  @property({type: Boolean})
+  forceFetch = false;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   static override get styles() {
     return [
@@ -54,53 +57,48 @@
   }
 
   override render() {
-    this._updateAvatarURL();
+    this.updateHostVisibilityAndImage();
     return html``;
   }
 
   override connectedCallback() {
     super.connectedCallback();
     Promise.all([
-      this._getConfig(),
-      getPluginLoader().awaitPluginsLoaded(),
+      this.restApiService.getConfig(),
+      this.getPluginLoader().awaitPluginsLoaded(),
     ]).then(([cfg]) => {
-      this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-
-      this._updateAvatarURL();
+      this.hasAvatars = Boolean(cfg?.plugin?.has_avatars);
+      this.updateHostVisibilityAndImage();
     });
   }
 
-  _getConfig() {
-    return this.restApiService.getConfig();
-  }
-
-  _updateAvatarURL() {
-    if (!this._hasAvatars || !this.account) {
+  private updateHostVisibilityAndImage() {
+    if (!this.hasAvatars || !this.account) {
       this.hidden = true;
       return;
     }
     this.hidden = false;
 
-    const url = this._buildAvatarURL(this.account);
+    const url = this.buildAvatarURL(this.account);
     if (url) {
-      this.style.backgroundImage = 'url("' + url + '")';
+      this.style.backgroundImage = `url("${url}")`;
     }
   }
 
-  _getAccounts(account: AccountInfo) {
+  private getAccounts(account: AccountInfo) {
     return (
       account._account_id || account.email || account.username || account.name
     );
   }
 
-  _buildAvatarURL(account?: AccountInfo) {
+  private buildAvatarURL(account?: AccountInfo) {
     if (!account) {
       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) {
+    if (avatars.length === 0 && !this.forceFetch) {
       return '';
     }
     for (let i = 0; i < avatars.length; i++) {
@@ -108,15 +106,13 @@
         return avatars[i].url;
       }
     }
-    const accountID = this._getAccounts(account);
-    if (!accountID) {
+    const accountIdentifier = this.getAccounts(account);
+    if (!accountIdentifier) {
       return '';
     }
-    return (
-      `${getBaseUrl()}/accounts/` +
-      encodeURIComponent(`${this._getAccounts(account)}`) +
-      `/avatar?s=${this.imageSize}`
-    );
+    return `${getBaseUrl()}/accounts/${encodeURIComponent(
+      accountIdentifier
+    )}/avatar?s=${this.imageSize}`;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
index b3c485a..aa341ff 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
@@ -1,33 +1,19 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-avatar';
 import {GrAvatar} from './gr-avatar';
-import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
-import {appContext} from '../../../services/app-context';
 import {AvatarInfo} from '../../../types/common';
 import {
-  createAccountWithEmail,
+  createAccountWithEmailOnly,
   createAccountWithId,
   createServerInfo,
 } from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-avatar');
+import {fixture, html, assert} from '@open-wc/testing';
+import {isVisible, stubRestApi} from '../../../test/test-utils';
 
 suite('gr-avatar tests', () => {
   let element: GrAvatar;
@@ -39,45 +25,125 @@
     },
   ];
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  test('renders hidden when no config is set', async () => {
+    stubRestApi('getConfig').resolves(undefined);
+    const accountWithId = {
+      ...createAccountWithId(123),
+      avatars: defaultAvatars,
+    };
+    element = await fixture(
+      html`<gr-avatar .account=${accountWithId}></gr-avatar>`
+    );
+
+    assert.isFalse(isVisible(element));
   });
 
-  test('account without avatar', () => {
-    assert.equal(element._buildAvatarURL(createAccountWithId(123)), '');
+  test('renders hidden when config does not use avatars', async () => {
+    stubRestApi('getConfig').resolves({
+      ...createServerInfo(),
+      plugin: {has_avatars: false, js_resource_paths: []},
+    });
+    const accountWithId = {
+      ...createAccountWithId(123),
+      avatars: defaultAvatars,
+    };
+    element = await fixture(
+      html`<gr-avatar .account=${accountWithId}></gr-avatar>`
+    );
+
+    assert.isFalse(isVisible(element));
   });
 
-  test('methods', () => {
-    assert.equal(
-      element._buildAvatarURL({
+  suite('config has avatars', () => {
+    setup(async () => {
+      stubRestApi('getConfig').resolves({
+        ...createServerInfo(),
+        plugin: {has_avatars: true, js_resource_paths: []},
+      });
+    });
+
+    test('loads correct size', async () => {
+      const accountWithId = {
         ...createAccountWithId(123),
         avatars: defaultAvatars,
-      }),
-      '/accounts/123/avatar?s=16'
-    );
-    assert.equal(
-      element._buildAvatarURL({
-        ...createAccountWithEmail('test@example.com'),
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithId} .imageSize=${64}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/123/avatar?s=64")'
+      );
+    });
+
+    test('loads using id', async () => {
+      const accountWithId = {
+        ...createAccountWithId(123),
         avatars: defaultAvatars,
-      }),
-      '/accounts/test%40example.com/avatar?s=16'
-    );
-    assert.equal(
-      element._buildAvatarURL({
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithId}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/123/avatar?s=16")'
+      );
+    });
+
+    test('loads using email', async () => {
+      const accountWithEmail = {
+        ...createAccountWithEmailOnly('foo@gmail.com'),
+        avatars: defaultAvatars,
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithEmail}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/foo%40gmail.com/avatar?s=16")'
+      );
+    });
+
+    test('loads using name', async () => {
+      const accountWithName = {
         name: 'John Doe',
         avatars: defaultAvatars,
-      }),
-      '/accounts/John%20Doe/avatar?s=16'
-    );
-    assert.equal(
-      element._buildAvatarURL({
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithName}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/John%20Doe/avatar?s=16")'
+      );
+    });
+
+    test('loads using username', async () => {
+      const accountWithUsername = {
         username: 'John_Doe',
         avatars: defaultAvatars,
-      }),
-      '/accounts/John_Doe/avatar?s=16'
-    );
-    assert.equal(
-      element._buildAvatarURL({
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithUsername}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/John_Doe/avatar?s=16")'
+      );
+    });
+
+    test('loads using custom URL from matching height', async () => {
+      const accountWithCustomAvatars = {
         ...createAccountWithId(123),
         avatars: [
           {
@@ -95,12 +161,21 @@
             height: 100,
             width: 0,
           },
-        ] as AvatarInfo[],
-      }),
-      'https://cdn.example.com/s16-p/photo.jpg'
-    );
-    assert.equal(
-      element._buildAvatarURL({
+        ],
+      };
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithCustomAvatars}></gr-avatar>`
+      );
+
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("https://cdn.example.com/s16-p/photo.jpg")'
+      );
+    });
+
+    test('loads using normal URL when no custom URL sizes match', async () => {
+      const accountWithCustomAvatars = {
         ...createAccountWithId(123),
         avatars: [
           {
@@ -108,108 +183,17 @@
             height: 95,
             width: 0,
           },
-        ] as AvatarInfo[],
-      }),
-      '/accounts/123/avatar?s=16'
-    );
-    assert.equal(element._buildAvatarURL(undefined), '');
-  });
-
-  suite('config set', () => {
-    setup(() => {
-      const config = {
-        ...createServerInfo(),
-        plugin: {has_avatars: true, js_resource_paths: []},
+        ],
       };
-      stub('gr-avatar', '_getConfig').returns(Promise.resolve(config));
-      element = basicFixture.instantiate();
-    });
+      element = await fixture(
+        html`<gr-avatar .account=${accountWithCustomAvatars}></gr-avatar>`
+      );
 
-    test('dom for existing account', () => {
-      assert.isFalse(element.hasAttribute('hidden'));
-
-      element.imageSize = 64;
-      element.account = {
-        ...createAccountWithId(123),
-        avatars: defaultAvatars,
-      };
-      flush();
-
-      assert.strictEqual(element.style.backgroundImage, '');
-
-      // Emulate plugins loaded.
-      getPluginLoader().loadPlugins([]);
-
-      return Promise.all([
-        appContext.restApiService.getConfig(),
-        getPluginLoader().awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isFalse(element.hasAttribute('hidden'));
-
-        assert.isTrue(
-          element.style.backgroundImage.includes('/accounts/123/avatar?s=64')
-        );
-      });
-    });
-  });
-
-  suite('plugin has avatars', () => {
-    let element: GrAvatar;
-
-    setup(() => {
-      const config = {
-        ...createServerInfo(),
-        plugin: {has_avatars: true, js_resource_paths: []},
-      };
-      stub('gr-avatar', '_getConfig').returns(Promise.resolve(config));
-
-      element = basicFixture.instantiate();
-    });
-
-    test('dom for non available account', () => {
-      assert.isFalse(element.hasAttribute('hidden'));
-
-      // Emulate plugins loaded.
-      getPluginLoader().loadPlugins([]);
-
-      return Promise.all([
-        appContext.restApiService.getConfig(),
-        getPluginLoader().awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isTrue(element.hasAttribute('hidden'));
-
-        assert.strictEqual(element.style.backgroundImage, '');
-      });
-    });
-  });
-
-  suite('config not set', () => {
-    let element: GrAvatar;
-
-    setup(() => {
-      stub('gr-avatar', '_getConfig').returns(Promise.resolve(undefined));
-
-      element = basicFixture.instantiate();
-    });
-
-    test('avatar hidden when account set', async () => {
-      await flush();
-      assert.isTrue(element.hasAttribute('hidden'));
-
-      element.imageSize = 64;
-      element.account = {
-        ...createAccountWithId(123),
-        avatars: defaultAvatars,
-      };
-      // Emulate plugins loaded.
-      getPluginLoader().loadPlugins([]);
-
-      return Promise.all([
-        appContext.restApiService.getConfig(),
-        getPluginLoader().awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isTrue(element.hasAttribute('hidden'));
-      });
+      assert.isTrue(isVisible(element));
+      assert.equal(
+        element.style.backgroundImage,
+        'url("/accounts/123/avatar?s=16")'
+      );
     });
   });
 });
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 ea5b5bb..75974e5 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -1,37 +1,32 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/paper-button/paper-button';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 import {votingStyles} from '../../../styles/gr-voting-styles';
-import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {getEventPath, modifierPressed} from '../../../utils/dom-util';
-import {appContext} from '../../../services/app-context';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {addShortcut, getEventPath, Key} from '../../../utils/dom-util';
+import {getAppContext} from '../../../services/app-context';
+import {classMap} from 'lit/directives/class-map.js';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-button': GrButton;
   }
 }
-
+/**
+ * @attr {Boolean} no-uppercase - text in button is not uppercased
+ * @attr {Boolean} position-below
+ * @attr {Boolean} primary - set primary button color
+ * @attr {Boolean} secondary - set secondary button color
+ */
 @customElement('gr-button')
 export class GrButton extends LitElement {
-  private readonly reporting: ReportingService = appContext.reportingService;
+  // Private but used in tests.
+  readonly reporting = getAppContext().reportingService;
 
   /**
    * Should this button be rendered as a vote chip? Then we are applying
@@ -44,12 +39,16 @@
   // after created, the initial value maybe overridden by this
   private initialTabindex?: string;
 
-  @property({type: Boolean, reflect: true, attribute: 'down-arrow'})
+  @property({type: Boolean, attribute: 'down-arrow'})
   downArrow = false;
 
   @property({type: Boolean, reflect: true})
   link = false;
 
+  // If flattened then the button will not be shown as raised.
+  @property({type: Boolean, reflect: true})
+  flatten = false;
+
   @property({type: Boolean, reflect: true})
   loading = false;
 
@@ -116,23 +115,6 @@
             var(--background-color);
         }
 
-        /* Some mobile browsers treat focused element as hovered element.
-        As a result, element remains hovered after click (has grey background in default theme).
-        Use @media (hover:none) to remove background if
-        user's primary input mechanism can't hover over elements.
-        See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover
-
-        Note 1: not all browsers support this media query
-        (see https://caniuse.com/#feat=css-media-interaction).
-        If browser doesn't support it, then the whole content of @media .. is ignored.
-        This is why the default behavior is placed outside of @media.
-        */
-        @media (hover: none) {
-          paper-button:hover {
-            background: transparent;
-          }
-        }
-
         :host([primary]) {
           --background-color: var(--primary-button-background-color);
           --text-color: var(--primary-button-text-color);
@@ -150,6 +132,11 @@
           cursor: default;
         }
 
+        :host([disabled][flatten]) {
+          --background-color: transparent;
+          --text-color: var(--disabled-foreground);
+        }
+
         /* Styles for link buttons specifically */
         :host([link]) {
           --background-color: transparent;
@@ -161,24 +148,18 @@
         :host([disabled][link]),
         :host([loading][link]) {
           --background-color: transparent;
-          --text-color: var(--deemphasized-text-color);
+          --text-color: var(--disabled-foreground);
           cursor: default;
         }
-
-        /* Styles for the optional down arrow */
-        :host(:not([down-arrow])) .downArrow {
-          display: none;
+        gr-icon.downArrow {
+          color: inherit;
         }
-        :host([down-arrow]) .downArrow {
-          border-top: 0.36em solid #ccc;
-          border-left: 0.36em solid transparent;
-          border-right: 0.36em solid transparent;
-          margin-bottom: var(--spacing-xxs);
-          margin-left: var(--spacing-m);
-          transition: border-top-color 200ms;
-        }
-        :host([down-arrow]) paper-button:hover .downArrow {
-          border-top-color: var(--deemphasized-text-color);
+        .newVoteChip {
+          border: 1px solid var(--border-color);
+          box-shadow: none;
+          box-sizing: border-box;
+          min-width: 3em;
+          color: var(--vote-text-color);
         }
       `,
     ];
@@ -186,24 +167,32 @@
 
   override render() {
     return html`<paper-button
-      ?raised="${!this.link}"
-      ?disabled="${this.disabled || this.loading}"
+      ?raised=${!this.link && !this.flatten}
+      ?disabled=${this.disabled || this.loading}
       role="button"
       tabindex="-1"
       part="paper-button"
-      class="${this.voteChip ? 'voteChip' : ''}"
+      class=${classMap({
+        newVoteChip: this.voteChip,
+      })}
     >
       ${this.loading ? html`<span class="loadingSpin"></span>` : ''}
       <slot></slot>
-      <i class="downArrow"></i>
+      ${this.renderArrowIcon()}
     </paper-button>`;
   }
 
+  renderArrowIcon() {
+    if (!this.downArrow) return nothing;
+    return html`<gr-icon icon="arrow_drop_down" class="downArrow"></gr-icon>`;
+  }
+
   constructor() {
     super();
     this.initialTabindex = this.getAttribute('tabindex') || '0';
     this.addEventListener('click', e => this._handleAction(e));
-    this.addEventListener('keydown', e => this._handleKeydown(e));
+    addShortcut(this, {key: Key.ENTER}, () => this.click());
+    addShortcut(this, {key: Key.SPACE}, () => this.click());
   }
 
   override updated(changedProperties: PropertyValues) {
@@ -241,14 +230,4 @@
 
     this.reporting.reportInteraction('button-click', {path: getEventPath(e)});
   }
-
-  _handleKeydown(e: KeyboardEvent) {
-    if (modifierPressed(e)) return;
-    // Handle `enter`, `space`.
-    if (e.keyCode === 13 || e.keyCode === 32) {
-      e.preventDefault();
-      e.stopPropagation();
-      this.click();
-    }
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
index 0149bd5..1cf05ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -1,41 +1,16 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-button';
 import {addListener} from '@polymer/polymer/lib/utils/gestures';
-import {appContext} from '../../../services/app-context';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from './gr-button';
-import {queryAndAssert} from '../../../test/test-utils';
+import {pressKey, 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>
-`);
-
-const tabindexFixture = fixtureFromTemplate(html`
-  <gr-button tabindex="3"></gr-button>
-`);
+import {Key, Modifier} from '../../../utils/dom-util';
 
 suite('gr-button tests', () => {
   let element: GrButton;
@@ -51,10 +26,40 @@
   };
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrButton>('<gr-button></gr-button>');
     await element.updateComplete;
   });
 
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <paper-button
+          animated=""
+          aria-disabled="false"
+          elevation="1"
+          part="paper-button"
+          raised=""
+          role="button"
+          tabindex="-1"
+          ><slot></slot>
+        </paper-button>
+      `
+    );
+  });
+
+  test('renders arrow icon', async () => {
+    element.downArrow = true;
+    await element.updateComplete;
+    const icon = queryAndAssert(element, 'gr-icon');
+    assert.dom.equal(
+      icon,
+      /* HTML */ `
+        <gr-icon icon="arrow_drop_down" class="downArrow"></gr-icon>
+      `
+    );
+  });
+
   test('disabled is set by disabled', async () => {
     const paperBtn = queryAndAssert<PaperButtonElement>(
       element,
@@ -81,7 +86,7 @@
       'paper-button'
     );
     assert.isFalse(paperBtn.disabled);
-    MockInteractions.tap(element);
+    element.click();
     await element.updateComplete;
     assert.isTrue(paperBtn.disabled);
     assert.isTrue(element.hasAttribute('loading'));
@@ -111,7 +116,9 @@
   });
 
   test('tabindex should be preserved', async () => {
-    const tabIndexElement = tabindexFixture.instantiate() as GrButton;
+    const tabIndexElement = await fixture<GrButton>(html`
+      <gr-button tabindex="3"></gr-button>
+    `);
     tabIndexElement.disabled = false;
     await element.updateComplete;
     assert.equal(tabIndexElement.getAttribute('tabindex'), '3');
@@ -127,39 +134,38 @@
   // plugins who didn't move to on-click which is faster and well supported.
   test('dispatches click event', () => {
     const spy = addSpyOn('click');
-    MockInteractions.click(element);
+    element.click();
     assert.isTrue(spy.calledOnce);
   });
 
   test('dispatches tap event', () => {
     const spy = addSpyOn('tap');
-    MockInteractions.tap(element);
+    element.click();
     assert.isTrue(spy.calledOnce);
   });
 
   test('dispatches click from tap event', () => {
     const spy = addSpyOn('click');
-    MockInteractions.tap(element);
+    element.click();
     assert.isTrue(spy.calledOnce);
   });
 
-  // Keycodes: 32 for Space, 13 for Enter.
-  for (const key of [32, 13]) {
-    test(`dispatches click event on keycode ${key}`, () => {
+  for (const key of [Key.ENTER, Key.SPACE]) {
+    test(`dispatches click event on key '${key}'`, () => {
       const tapSpy = sinon.spy();
       element.addEventListener('click', tapSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, key);
+      pressKey(element, key);
       assert.isTrue(tapSpy.calledOnce);
     });
 
-    test(`dispatches no click event with modifier on keycode ${key}`, () => {
+    test(`dispatches no click event with modifier on key '${key}'`, () => {
       const tapSpy = sinon.spy();
       element.addEventListener('click', tapSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'ctrl');
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'meta');
-      MockInteractions.pressAndReleaseKeyOn(element, key, 'alt');
-      assert.isFalse(tapSpy.calledOnce);
+      pressKey(element, key, Modifier.ALT_KEY);
+      pressKey(element, key, Modifier.CTRL_KEY);
+      pressKey(element, key, Modifier.META_KEY);
+      pressKey(element, key, Modifier.SHIFT_KEY);
+      assert.isFalse(tapSpy.called);
     });
   }
 
@@ -172,17 +178,16 @@
     for (const eventName of ['tap', 'click']) {
       test('stops ' + eventName + ' event', () => {
         const spy = addSpyOn(eventName);
-        MockInteractions.tap(element);
+        element.click();
         assert.isFalse(spy.called);
       });
     }
 
-    // Keycodes: 32 for Space, 13 for Enter.
-    for (const key of [32, 13]) {
-      test(`stops click event on keycode ${key}`, () => {
+    for (const key of [Key.ENTER, Key.SPACE]) {
+      test(`stops click event on key ${key}`, () => {
         const tapSpy = sinon.spy();
         element.addEventListener('click', tapSpy);
-        MockInteractions.pressAndReleaseKeyOn(element, key);
+        pressKey(element, key);
         assert.isFalse(tapSpy.called);
       });
     }
@@ -191,28 +196,30 @@
   suite('reporting', () => {
     let reportStub: sinon.SinonStub;
     setup(() => {
-      reportStub = sinon.stub(appContext.reportingService, 'reportInteraction');
+      reportStub = sinon.stub(element.reporting, 'reportInteraction');
       reportStub.reset();
     });
 
     test('report event after click', () => {
-      MockInteractions.click(element);
+      element.click();
       assert.isTrue(reportStub.calledOnce);
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
-        path: `html>body>test-fixture#${element.parentElement!.id}>gr-button`,
+        path: 'html>body>div>gr-button',
       });
     });
 
-    test('report event after click on nested', () => {
-      const nestedElement = nestedFixture.instantiate() as HTMLDivElement;
-      MockInteractions.click(queryAndAssert(nestedElement, 'gr-button'));
+    test('report event after click on nested', async () => {
+      const nestedElement = await fixture<HTMLDivElement>(html`
+        <div id="test">
+          <gr-button class="testBtn"></gr-button>
+        </div>
+      `);
+      queryAndAssert<GrButton>(nestedElement, 'gr-button').click();
       assert.isTrue(reportStub.calledOnce);
       assert.equal(reportStub.lastCall.args[0], 'button-click');
       assert.deepEqual(reportStub.lastCall.args[1], {
-        path:
-          `html>body>test-fixture#${nestedElement.parentElement!.id}` +
-          '>div#test>gr-button.testBtn',
+        path: 'html>body>div>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 c6fd01c..0bb451c 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
@@ -1,30 +1,19 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../gr-icons/gr-icons';
 import {ChangeInfo} from '../../../types/common';
-import {fireAlert} from '../../../utils/event-util';
 import {
   Shortcut,
   ShortcutSection,
 } from '../../../services/shortcuts/shortcuts-config';
-import {appContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {shortcutsServiceToken} from '../../../services/shortcuts/shortcuts-service';
+import {assertIsDefined} from '../../../utils/common-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -48,7 +37,7 @@
   @property({type: Object})
   change?: ChangeInfo;
 
-  private readonly shortcuts = appContext.shortcutsService;
+  private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   static override get styles() {
     return [
@@ -58,20 +47,6 @@
           background-color: transparent;
           cursor: pointer;
         }
-        iron-icon.active {
-          fill: var(--link-color);
-        }
-        iron-icon {
-          vertical-align: top;
-          --iron-icon-height: var(
-            --gr-change-star-size,
-            var(--line-height-normal, 20px)
-          );
-          --iron-icon-width: var(
-            --gr-change-star-size,
-            var(--line-height-normal, 20px)
-          );
-        }
         :host([hidden]) {
           visibility: hidden;
           display: block !important;
@@ -84,28 +59,32 @@
     return html`
       <button
         role="checkbox"
-        title=${this.shortcuts.createTitle(
+        title=${this.getShortcutsService().createTitle(
           Shortcut.TOGGLE_CHANGE_STAR,
           ShortcutSection.ACTIONS
         )}
         aria-label=${this.change?.starred
           ? 'Unstar this change'
           : 'Star this change'}
-        @click=${this.toggleStar}
+        @click=${this.handleClick}
       >
-        <iron-icon
+        <gr-icon
+          icon="star"
+          small
+          ?filled=${!!this.change?.starred}
           class=${this.change?.starred ? 'active' : ''}
-          .icon=${`gr-icons:star${this.change?.starred ? '' : '-border'}`}
-        ></iron-icon>
+        ></gr-icon>
       </button>
     `;
   }
 
+  handleClick(e: Event) {
+    e.stopPropagation();
+    this.toggleStar();
+  }
+
   toggleStar() {
-    // Note: change should always be defined when use gr-change-star
-    // but since we don't have a good way to enforce usage to always
-    // set the change, we still check it here.
-    if (!this.change) return;
+    assertIsDefined(this.change, 'change');
 
     const newVal = !this.change.starred;
     this.change.starred = newVal;
@@ -114,7 +93,6 @@
       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.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.ts
index 2c5d7a2..75237f4 100644
--- 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
@@ -1,34 +1,20 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {IronIconElement} from '@polymer/iron-icon';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import {queryAndAssert} from '../../../test/test-utils';
 import {GrChangeStar} from './gr-change-star';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import './gr-change-star';
 import {createChange} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-change-star');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-change-star tests', () => {
   let element: GrChangeStar;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-change-star></gr-change-star>`);
     element.change = {
       ...createChange(),
       starred: true,
@@ -36,19 +22,38 @@
     await element.updateComplete;
   });
 
-  test('star visibility states', async () => {
-    element.change!.starred = true;
-    await element.updateComplete;
-    let icon = queryAndAssert<IronIconElement>(element, 'iron-icon');
-    assert.isTrue(icon.classList.contains('active'));
-    assert.equal(icon.icon, 'gr-icons:star');
+  test('renders starred', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <button
+          aria-label="Unstar this change"
+          role="checkbox"
+          title="Star/unstar change (shortcut: s)"
+        >
+          <gr-icon icon="star" small filled class="active"></gr-icon>
+        </button>
+      `
+    );
+  });
 
+  test('renders unstarred', async () => {
     element.change!.starred = false;
     element.requestUpdate('change');
     await element.updateComplete;
-    icon = queryAndAssert<IronIconElement>(element, 'iron-icon');
-    assert.isFalse(icon.classList.contains('active'));
-    assert.equal(icon.icon, 'gr-icons:star-border');
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <button
+          aria-label="Star this change"
+          role="checkbox"
+          title="Star/unstar change (shortcut: s)"
+        >
+          <gr-icon icon="star" small></gr-icon>
+        </button>
+      `
+    );
   });
 
   test('starring', async () => {
@@ -56,7 +61,7 @@
     await element.updateComplete;
     assert.equal(element.change!.starred, false);
 
-    MockInteractions.tap(queryAndAssert<HTMLButtonElement>(element, 'button'));
+    queryAndAssert<HTMLButtonElement>(element, 'button').click();
     await element.updateComplete;
     assert.equal(element.change!.starred, true);
   });
@@ -66,7 +71,7 @@
     await element.updateComplete;
     assert.equal(element.change!.starred, true);
 
-    MockInteractions.tap(queryAndAssert<HTMLButtonElement>(element, 'button'));
+    queryAndAssert<HTMLButtonElement>(element, 'button').click();
     await element.updateComplete;
     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 0bd02d5..d2b9e2d 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
@@ -1,36 +1,24 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import '../gr-icon/gr-icon';
 import '../gr-tooltip-content/gr-tooltip-content';
-import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-status_html';
-import {customElement, property} from '@polymer/decorators';
-import {
-  GeneratedWebLink,
-  GerritNav,
-} from '../../core/gr-navigation/gr-navigation';
 import {ChangeInfo} from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {createSearchUrl} from '../../../models/views/search';
+import {GeneratedWebLink} from '../../../utils/weblink-util';
 
 export enum ChangeStates {
   ABANDONED = 'Abandoned',
   ACTIVE = 'Active',
   MERGE_CONFLICT = 'Merge Conflict',
+  GIT_CONFLICT = 'Git Conflict',
   MERGED = 'Merged',
   PRIVATE = 'Private',
   READY_TO_SUBMIT = 'Ready to submit',
@@ -39,36 +27,37 @@
   WIP = 'WIP',
 }
 
-const WIP_TOOLTIP =
+export const WIP_TOOLTIP =
   "This change isn't ready to be reviewed or submitted. " +
-  "It will not appear on dashboards unless you are CC'ed or assigned, " +
+  'It will not appear on dashboards unless you are in the attention set, ' +
   'and email notifications will be silenced until the review is started.';
 
 export const MERGE_CONFLICT_TOOLTIP =
   'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase". ' +
+  'Rebase on the upstream branch (e.g. "git pull --rebase"). ' +
   'Upload a new patchset after resolving all merge conflicts.';
 
+export const GIT_CONFLICT_TOOLTIP =
+  'A file contents of the change contain git conflict markers' +
+  'to indicate the conflicts.';
+
 const PRIVATE_TOOLTIP =
   'This change is only visible to its owner and ' +
   'current reviewers (or anyone with "View Private Changes" permission).';
 
 @customElement('gr-change-status')
-export class GrChangeStatus extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Boolean, reflectToAttribute: true})
+export class GrChangeStatus extends LitElement {
+  @property({type: Boolean, reflect: true})
   flat = false;
 
   @property({type: Object})
   change?: ChangeInfo | ParsedChangeInfo;
 
-  @property({type: String, observer: '_updateChipDetails'})
+  @property({type: String})
   status?: ChangeStates;
 
-  @property({type: String})
+  // Private but used in tests.
+  @state()
   tooltipText = '';
 
   @property({type: Object})
@@ -77,60 +66,167 @@
   @property({type: Object})
   resolveWeblinks?: GeneratedWebLink[] = [];
 
-  _computeStatusString(status?: ChangeStates) {
-    if (status === ChangeStates.WIP && !this.flat) {
-      return 'Work in Progress';
-    }
-    return status ?? '';
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .chip {
+          border-radius: var(--border-radius);
+          background-color: var(--chip-background-color);
+          padding: 0 var(--spacing-m);
+          white-space: nowrap;
+        }
+        :host(.merged) .chip {
+          background-color: var(--status-merged);
+          color: var(--status-merged);
+        }
+        :host(.abandoned) .chip {
+          background-color: var(--status-abandoned);
+          color: var(--status-abandoned);
+        }
+        :host(.wip) .chip {
+          background-color: var(--status-wip);
+          color: var(--status-wip);
+        }
+        :host(.private) .chip {
+          background-color: var(--status-private);
+          color: var(--status-private);
+        }
+        :host(.merge-conflict) .chip,
+        :host(.git-conflict) .chip {
+          background-color: var(--status-conflict);
+          color: var(--status-conflict);
+        }
+        :host(.active) .chip {
+          background-color: var(--status-active);
+          color: var(--status-active);
+        }
+        :host(.ready-to-submit) .chip {
+          background-color: var(--status-ready);
+          color: var(--status-ready);
+        }
+        :host(.revert-created) .chip {
+          background-color: var(--status-revert-created);
+          color: var(--status-revert-created);
+        }
+        :host(.revert-submitted) .chip {
+          background-color: var(--status-revert-created);
+          color: var(--status-revert-created);
+        }
+        .status-link {
+          text-decoration: none;
+        }
+        :host(.custom) .chip {
+          background-color: var(--status-custom);
+          color: var(--status-custom);
+        }
+        :host([flat]) .chip {
+          background-color: transparent;
+          padding: 0;
+        }
+        :host(:not([flat])) .chip,
+        :host(:not([flat])) .chip gr-icon {
+          color: var(--status-text-color);
+        }
+      `,
+    ];
   }
 
-  _toClassName(str?: ChangeStates) {
+  override render() {
+    return html`
+      <gr-tooltip-content
+        has-tooltip
+        position-below
+        .title=${this.tooltipText}
+        .maxWidth=${'40em'}
+      >
+        ${this.renderStatusLink()}
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderStatusLink() {
+    if (!this.hasStatusLink()) {
+      return html`
+        <div class="chip" aria-label="Label: ${this.status}">
+          ${this.computeStatusString()}
+        </div>
+      `;
+    }
+
+    return html`
+      <a class="status-link" href=${this.getStatusLink()}>
+        <div class="chip" aria-label="Label: ${this.status}">
+          ${this.computeStatusString()}
+          ${this.showResolveIcon()
+            ? html`<gr-icon icon="edit" filled small></gr-icon>`
+            : ''}
+        </div>
+      </a>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('status')) {
+      this.updateChipDetails(changedProperties.get('status') as ChangeStates);
+    }
+  }
+
+  private computeStatusString() {
+    if (this.status === ChangeStates.WIP && !this.flat) {
+      return 'Work in Progress';
+    }
+    return this.status ?? '';
+  }
+
+  private toClassName(str?: ChangeStates) {
     return str ? str.toLowerCase().replace(/\s/g, '-') : '';
   }
 
-  hasStatusLink(
-    revertedChange?: ChangeInfo,
-    resolveWeblinks?: GeneratedWebLink[],
-    status?: ChangeStates
-  ): boolean {
+  // private but used in test
+  hasStatusLink(): boolean {
     const isRevertCreatedOrSubmitted =
-      (status === ChangeStates.REVERT_SUBMITTED ||
-        status === ChangeStates.REVERT_CREATED) &&
-      revertedChange !== undefined;
+      (this.status === ChangeStates.REVERT_SUBMITTED ||
+        this.status === ChangeStates.REVERT_CREATED) &&
+      this.revertedChange !== undefined;
     return (
       isRevertCreatedOrSubmitted ||
-      !!(status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length)
+      !!(
+        this.status === ChangeStates.MERGE_CONFLICT &&
+        this.resolveWeblinks?.length
+      )
     );
   }
 
-  getStatusLink(
-    revertedChange?: ChangeInfo,
-    resolveWeblinks?: GeneratedWebLink[],
-    status?: ChangeStates
-  ): string {
-    if (revertedChange) {
-      return GerritNav.getUrlForSearchQuery(`${revertedChange._number}`);
+  // private but used in test
+  getStatusLink(): string {
+    if (this.revertedChange) {
+      return createSearchUrl({query: `${this.revertedChange._number}`});
     }
-    if (status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length) {
-      return resolveWeblinks[0].url ?? '';
+    if (
+      this.status === ChangeStates.MERGE_CONFLICT &&
+      this.resolveWeblinks?.length
+    ) {
+      return this.resolveWeblinks[0].url ?? '';
     }
     return '';
   }
 
-  showResolveIcon(
-    resolveWeblinks?: GeneratedWebLink[],
-    status?: ChangeStates
-  ): boolean {
-    return status === ChangeStates.MERGE_CONFLICT && !!resolveWeblinks?.length;
+  // private but used in test
+  showResolveIcon(): boolean {
+    return (
+      this.status === ChangeStates.MERGE_CONFLICT &&
+      !!this.resolveWeblinks?.length
+    );
   }
 
-  _updateChipDetails(status?: ChangeStates, previousStatus?: ChangeStates) {
+  private updateChipDetails(previousStatus?: ChangeStates) {
     if (previousStatus) {
-      this.classList.remove(this._toClassName(previousStatus));
+      this.classList.remove(this.toClassName(previousStatus));
     }
-    this.classList.add(this._toClassName(status));
+    this.classList.add(this.toClassName(this.status));
 
-    switch (status) {
+    switch (this.status) {
       case ChangeStates.WIP:
         this.tooltipText = WIP_TOOLTIP;
         break;
@@ -140,6 +236,9 @@
       case ChangeStates.MERGE_CONFLICT:
         this.tooltipText = MERGE_CONFLICT_TOOLTIP;
         break;
+      case ChangeStates.GIT_CONFLICT:
+        this.tooltipText = GIT_CONFLICT_TOOLTIP;
+        break;
       default:
         this.tooltipText = '';
         break;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
deleted file mode 100644
index 455bd4e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .chip {
-      border-radius: var(--border-radius);
-      background-color: var(--chip-background-color);
-      padding: 0 var(--spacing-m);
-      white-space: nowrap;
-    }
-    :host(.merged) .chip {
-      background-color: var(--status-merged);
-      color: var(--status-merged);
-    }
-    :host(.abandoned) .chip {
-      background-color: var(--status-abandoned);
-      color: var(--status-abandoned);
-    }
-    :host(.wip) .chip {
-      background-color: var(--status-wip);
-      color: var(--status-wip);
-    }
-    :host(.private) .chip {
-      background-color: var(--status-private);
-      color: var(--status-private);
-    }
-    :host(.merge-conflict) .chip {
-      background-color: var(--status-conflict);
-      color: var(--status-conflict);
-    }
-    :host(.active) .chip {
-      background-color: var(--status-active);
-      color: var(--status-active);
-    }
-    :host(.ready-to-submit) .chip {
-      background-color: var(--status-ready);
-      color: var(--status-ready);
-    }
-    :host(.revert-created) .chip {
-      background-color: var(--status-revert-created);
-      color: var(--status-revert-created);
-    }
-    :host(.revert-submitted) .chip {
-      background-color: var(--status-revert-created);
-      color: var(--status-revert-created);
-    }
-    .status-link {
-      text-decoration: none;
-    }
-    :host(.custom) .chip {
-      background-color: var(--status-custom);
-      color: var(--status-custom);
-    }
-    :host([flat]) .chip {
-      background-color: transparent;
-      padding: 0;
-    }
-    :host(:not([flat])) .chip, .icon {
-      color: var(--status-text-color);
-    }
-    .icon {
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-  </style>
-  <gr-tooltip-content
-    has-tooltip
-    position-below
-    title="[[tooltipText]]"
-    max-width="40em"
-  >
-    <template
-      is="dom-if"
-      if="[[hasStatusLink(revertedChange, resolveWeblinks, status)]]">
-      <a class="status-link"
-         href="[[getStatusLink(revertedChange, resolveWeblinks, status)]]">
-        <div class="chip" aria-label$="Label: [[status]]">
-          [[_computeStatusString(status)]]
-          <iron-icon
-            class="icon"
-            icon="gr-icons:edit"
-            hidden$="[[!showResolveIcon(resolveWeblinks, status)]]">
-          </iron-icon>
-        </div>
-      </a>
-    </template>
-    <template is="dom-if" if="[[!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
-      <div class="chip" aria-label$="Label: [[status]]"
-      >[[_computeStatusString(status)]]</div>
-    </template>
-  </gr-tooltip-content>
-</span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
index 39fc7c6..4a046e7 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
@@ -1,33 +1,18 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import {createChange} from '../../../test/test-data-generators';
+import '../../../test/common-test-setup';
+import {
+  createChange,
+  TEST_NUMERIC_CHANGE_ID,
+} from '../../../test/test-data-generators';
 import './gr-change-status';
-import {ChangeStates, GrChangeStatus} from './gr-change-status';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {ChangeStates, GrChangeStatus, WIP_TOOLTIP} from './gr-change-status';
 import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status';
-
-const basicFixture = fixtureFromElement('gr-change-status');
-
-const WIP_TOOLTIP =
-  "This change isn't ready to be reviewed or submitted. " +
-  "It will not appear on dashboards unless you are CC'ed or assigned, " +
-  'and email notifications will be silenced until the review is started.';
+import {fixture, html, assert} from '@open-wc/testing';
+import {queryAndAssert} from '../../../test/test-utils';
 
 const PRIVATE_TOOLTIP =
   'This change is only visible to its owner and ' +
@@ -36,27 +21,48 @@
 suite('gr-change-status tests', () => {
   let element: GrChangeStatus;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture<GrChangeStatus>(html`
+      <gr-change-status></gr-change-status>
+    `);
   });
 
-  test('WIP', () => {
+  test('render', async () => {
     element.status = ChangeStates.WIP;
-    flush();
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-tooltip-content
+          has-tooltip=""
+          max-width="40em"
+          position-below=""
+          title="This change isn't ready to be reviewed or submitted. It will not appear on dashboards unless you are in the attention set, and email notifications will be silenced until the review is started."
+        >
+          <div aria-label="Label: WIP" class="chip">Work in Progress</div>
+        </gr-tooltip-content>
+      `
+    );
+  });
+
+  test('WIP', async () => {
+    element.status = ChangeStates.WIP;
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Work in Progress'
     );
     assert.equal(element.tooltipText, WIP_TOOLTIP);
     assert.isTrue(element.classList.contains('wip'));
   });
 
-  test('WIP flat', () => {
+  test('WIP flat', async () => {
     element.flat = true;
     element.status = ChangeStates.WIP;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'WIP'
     );
     assert.isDefined(element.tooltipText);
@@ -64,44 +70,47 @@
     assert.isTrue(element.hasAttribute('flat'));
   });
 
-  test('merged', () => {
+  test('merged', async () => {
     element.status = ChangeStates.MERGED;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Merged'
     );
     assert.equal(element.tooltipText, '');
     assert.isTrue(element.classList.contains('merged'));
-    assert.isFalse(
-      element.showResolveIcon([{url: 'http://google.com'}], ChangeStates.MERGED)
-    );
+    element.resolveWeblinks = [{url: 'http://google.com'}];
+    element.status = ChangeStates.MERGED;
+    assert.isFalse(element.showResolveIcon());
   });
 
-  test('abandoned', () => {
+  test('abandoned', async () => {
     element.status = ChangeStates.ABANDONED;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Abandoned'
     );
     assert.equal(element.tooltipText, '');
     assert.isTrue(element.classList.contains('abandoned'));
   });
 
-  test('merge conflict', () => {
+  test('merge conflict', async () => {
     const status = ChangeStates.MERGE_CONFLICT;
     element.status = status;
-    flush();
+    await element.updateComplete;
 
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Merge Conflict'
     );
     assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
     assert.isTrue(element.classList.contains('merge-conflict'));
-    assert.isFalse(element.hasStatusLink(undefined, [], status));
-    assert.isFalse(element.showResolveIcon([], status));
+    element.revertedChange = undefined;
+    element.resolveWeblinks = [];
+    element.status = status;
+    assert.isFalse(element.hasStatusLink());
+    assert.isFalse(element.showResolveIcon());
   });
 
   test('merge conflict with resolve link', () => {
@@ -109,62 +118,66 @@
     const url = 'http://google.com';
     const weblinks = [{url}];
 
-    assert.isTrue(element.hasStatusLink(undefined, weblinks, status));
-    assert.equal(element.getStatusLink(undefined, weblinks, status), url);
-    assert.isTrue(element.showResolveIcon(weblinks, status));
+    element.revertedChange = undefined;
+    element.resolveWeblinks = weblinks;
+    element.status = status;
+    assert.isTrue(element.hasStatusLink());
+    assert.equal(element.getStatusLink(), url);
+    assert.isTrue(element.showResolveIcon());
   });
 
   test('reverted change', () => {
-    const url = 'http://google.com';
     const status = ChangeStates.REVERT_SUBMITTED;
     const revertedChange = createChange();
-    sinon.stub(GerritNav, 'getUrlForSearchQuery').returns(url);
 
-    assert.isTrue(element.hasStatusLink(revertedChange, [], status));
-    assert.equal(element.getStatusLink(revertedChange, [], status), url);
+    element.revertedChange = revertedChange;
+    element.resolveWeblinks = [];
+    element.status = status;
+    assert.isTrue(element.hasStatusLink());
+    assert.equal(element.getStatusLink(), `/q/${TEST_NUMERIC_CHANGE_ID}`);
   });
 
-  test('private', () => {
+  test('private', async () => {
     element.status = ChangeStates.PRIVATE;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Private'
     );
     assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
     assert.isTrue(element.classList.contains('private'));
   });
 
-  test('active', () => {
+  test('active', async () => {
     element.status = ChangeStates.ACTIVE;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Active'
     );
     assert.equal(element.tooltipText, '');
     assert.isTrue(element.classList.contains('active'));
   });
 
-  test('ready to submit', () => {
+  test('ready to submit', async () => {
     element.status = ChangeStates.READY_TO_SUBMIT;
-    flush();
+    await element.updateComplete;
     assert.equal(
-      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      queryAndAssert<HTMLDivElement>(element, '.chip').innerText,
       'Ready to submit'
     );
     assert.equal(element.tooltipText, '');
     assert.isTrue(element.classList.contains('ready-to-submit'));
   });
 
-  test('updating status removes the previous class', () => {
+  test('updating status removes the previous class', async () => {
     element.status = ChangeStates.PRIVATE;
-    flush();
+    await element.updateComplete;
     assert.isTrue(element.classList.contains('private'));
     assert.isFalse(element.classList.contains('wip'));
 
     element.status = ChangeStates.WIP;
-    flush();
+    await element.updateComplete;
     assert.isFalse(element.classList.contains('private'));
     assert.isTrue(element.classList.contains('wip'));
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 7ecced0..dd0fbca 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
@@ -1,365 +1,757 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../gr-comment/gr-comment';
-import '../../diff/gr-diff/gr-diff';
+import '../gr-icon/gr-icon';
+import '../../../embed/diff/gr-diff/gr-diff';
 import '../gr-copy-clipboard/gr-copy-clipboard';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment-thread_html';
+import {css, html, nothing, LitElement, PropertyValues} from 'lit';
+import {
+  customElement,
+  property,
+  query,
+  queryAll,
+  state,
+} from 'lit/decorators.js';
 import {
   computeDiffFromContext,
-  computeId,
-  DraftInfo,
   isDraft,
   isRobot,
-  sortComments,
-  UIComment,
-  UIDraft,
-  UIRobot,
+  Comment,
+  CommentThread,
+  getLastComment,
+  UnsavedInfo,
+  isDraftOrUnsaved,
+  createUnsavedComment,
+  getFirstComment,
+  createUnsavedReply,
+  isUnsaved,
+  NEWLINE_PATTERN,
 } from '../../../utils/comment-util';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {appContext} from '../../../services/app-context';
+import {ChangeMessageId} from '../../../api/rest-api';
+import {getAppContext} from '../../../services/app-context';
 import {
-  CommentSide,
   createDefaultDiffPrefs,
-  Side,
   SpecialFilePath,
 } from '../../../constants/constants';
 import {computeDisplayPath} from '../../../utils/path-list-util';
-import {customElement, observe, property} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   CommentRange,
-  ConfigInfo,
   NumericChangeId,
-  PatchSetNum,
   RepoName,
   UrlEncodedCommentId,
 } from '../../../types/common';
-import {GrComment} from '../gr-comment/gr-comment';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {FILE, LineNumber} from '../../diff/gr-diff/gr-diff-line';
+import {CommentEditingChangedDetail, GrComment} from '../gr-comment/gr-comment';
+import {FILE} from '../../../embed/diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
-import {
-  assertIsDefined,
-  check,
-  queryAndAssert,
-} from '../../../utils/common-util';
-import {fireAlert, waitForEventOnce} from '../../../utils/event-util';
-import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
-import {StorageLocation} from '../../../services/storage/gr-storage';
-import {TokenHighlightLayer} from '../../diff/gr-diff-builder/token-highlight-layer';
-import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
+import {assertIsDefined, copyToClipbard} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
+import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
+import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
 import {getUserName} from '../../../utils/display-name-util';
 import {generateAbsoluteUrl} from '../../../utils/url-util';
-import {addGlobalShortcut} from '../../../utils/dom-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {a11yStyles} from '../../../styles/gr-a11y-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {repeat} from 'lit/directives/repeat.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {ReplyToCommentEvent, ValueChangedEvent} from '../../../types/events';
+import {notDeepEqual} from '../../../utils/deep-util';
+import {resolve} from '../../../models/dependency';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {whenRendered} from '../../../utils/dom-util';
+import {Interaction} from '../../../constants/reporting';
+import {HtmlPatched} from '../../../utils/lit-util';
+import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
 
-const UNRESOLVED_EXPAND_COUNT = 5;
-const NEWLINE_PATTERN = /\n/g;
-
-export interface GrCommentThread {
-  $: {
-    replyBtn: GrButton;
-    quoteBtn: GrButton;
-  };
+declare global {
+  interface HTMLElementEventMap {
+    'comment-thread-editing-changed': ValueChangedEvent<boolean>;
+  }
 }
 
+/**
+ * gr-comment-thread exposes the following attributes that allow a
+ * diff widget like gr-diff to show the thread in the right location:
+ *
+ * line-num:
+ *     1-based line number or 'FILE' if it refers to the entire file.
+ *
+ * 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.
+ *
+ * range:
+ *     The range of text that the comment refers to (start_line,
+ *     start_character, end_line, end_character), serialized as JSON. If
+ *     set, range's end_line will have the same value as line-num. Line
+ *     numbers are 1-based, char numbers are 0-based. The start position
+ *     (start_line, start_character) is inclusive, and the end position
+ *     (end_line, end_character) is exclusive.
+ */
 @customElement('gr-comment-thread')
-export class GrCommentThread extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrCommentThread extends LitElement {
+  @query('#replyBtn')
+  replyBtn?: GrButton;
+
+  @query('#quoteBtn')
+  quoteBtn?: GrButton;
+
+  @query('.comment-box')
+  commentBox?: HTMLElement;
+
+  @queryAll('gr-comment')
+  commentElements?: NodeList;
 
   /**
-   * gr-comment-thread exposes the following attributes that allow a
-   * diff widget like gr-diff to show the thread in the right location:
+   * Required to be set by parent.
    *
-   * line-num:
-   *     1-based line number or 'FILE' if it refers to the entire file.
-   *
-   * 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.
-   *
-   * range:
-   *     The range of text that the comment refers to (start_line,
-   *     start_character, end_line, end_character), serialized as JSON. If
-   *     set, range's end_line will have the same value as line-num. Line
-   *     numbers are 1-based, char numbers are 0-based. The start position
-   *     (start_line, start_character) is inclusive, and the end position
-   *     (end_line, end_character) is exclusive.
+   * Lit's `hasChanged` change detection defaults to just checking strict
+   * equality (===). Here it makes sense to install a proper `deepEqual`
+   * check, because of how the comments-model and ChangeComments are setup:
+   * Each thread object is recreated on the slightest model change. So when you
+   * have 100 comment threads and there is an update to one thread, then you
+   * want to avoid re-rendering the other 99 threads.
    */
-  @property({type: Number})
-  changeNum?: NumericChangeId;
+  @property({hasChanged: notDeepEqual})
+  thread?: CommentThread;
 
-  @property({type: Array})
-  comments: UIComment[] = [];
-
-  @property({type: Object, reflectToAttribute: true})
-  range?: CommentRange;
-
-  @property({type: String, reflectToAttribute: true})
-  diffSide?: Side;
-
+  /**
+   * Id of the first comment and thus must not change. Will be derived from
+   * the `thread` property in the first willUpdate() cycle.
+   *
+   * The `rootId` property is also used in gr-diff for maintaining lists and
+   * maps of threads and their associated elements.
+   *
+   * Only stays `undefined` for new threads that only have an unsaved comment.
+   */
   @property({type: String})
-  patchNum?: PatchSetNum;
-
-  @property({type: String})
-  path: string | undefined;
-
-  @property({type: String, observer: '_projectNameChanged'})
-  projectName?: RepoName;
-
-  @property({type: Boolean, notify: true, reflectToAttribute: true})
-  hasDraft?: boolean;
-
-  @property({type: Boolean})
-  isOnParent = false;
-
-  @property({type: Number})
-  parentIndex: number | null = null;
-
-  @property({
-    type: String,
-    notify: true,
-    computed: '_computeRootId(comments.*)',
-  })
   rootId?: UrlEncodedCommentId;
 
-  @property({type: Boolean, observer: 'handleShouldScrollIntoViewChanged'})
+  // TODO: Is this attribute needed for querySelector() or css rules?
+  // We don't need this internally for the component.
+  @property({type: Boolean, reflect: true, attribute: 'has-draft'})
+  hasDraft?: boolean;
+
+  /** Will be inspected on firstUpdated() only. */
+  @property({type: Boolean, attribute: 'should-scroll-into-view'})
   shouldScrollIntoView = false;
 
-  @property({type: Boolean})
+  /**
+   * Should the file path and line number be rendered above the comment thread
+   * widget? Typically true in <gr-thread-list> and false in <gr-diff>.
+   */
+  @property({type: Boolean, attribute: 'show-file-path'})
   showFilePath = false;
 
-  @property({type: Object, reflectToAttribute: true})
-  lineNum?: LineNumber;
+  /**
+   * Only relevant when `showFilePath` is set.
+   * If false, then only the line number is rendered.
+   */
+  @property({type: Boolean, attribute: 'show-file-name'})
+  showFileName = false;
 
-  @property({type: Boolean, notify: true, reflectToAttribute: true})
-  unresolved?: boolean;
+  @property({type: Boolean, attribute: 'show-ported-comment'})
+  showPortedComment = false;
 
-  @property({type: Boolean})
-  _showActions?: boolean;
+  /** This is set to false by <gr-diff>. */
+  @property({type: Boolean, attribute: false})
+  showPatchset = true;
 
-  @property({type: Object})
-  _lastComment?: UIComment;
+  @property({type: Boolean, attribute: 'show-comment-context'})
+  showCommentContext = false;
 
-  @property({type: Array})
-  _orderedComments: UIComment[] = [];
+  /**
+   * Optional context information when a thread is being displayed for a
+   * specific change message. That influences which comments are expanded or
+   * collapsed by default.
+   */
+  @property({type: String, attribute: 'message-id'})
+  messageId?: ChangeMessageId;
 
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
+  /**
+   * We are reflecting the editing state of the draft comment here. This is not
+   * an input property, but can be inspected from the parent component.
+   *
+   * Changes to this property are fired as 'comment-thread-editing-changed'
+   * events.
+   */
+  @property({type: Boolean, attribute: 'false'})
+  editing = false;
 
-  @property({type: Object})
-  _prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+  /**
+   * This can either be an unsaved reply to the last comment or the unsaved
+   * content of a brand new comment thread (then `comments` is empty).
+   * If set, then `thread.comments` must not contain a draft. A thread can only
+   * contain *either* an unsaved comment *or* a draft, not both.
+   */
+  @state()
+  unsavedComment?: UnsavedInfo;
 
-  @property({type: Object})
-  _renderPrefs: RenderPreferences = {
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+  @state()
+  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;
+  @state()
+  repoName?: RepoName;
 
-  @property({type: Boolean})
-  showFileName = true;
+  @state()
+  account?: AccountDetailInfo;
 
-  @property({type: Boolean})
-  showPortedComment = false;
-
-  @property({type: Boolean})
-  showPatchset = true;
-
-  @property({type: Boolean})
-  showCommentContext = false;
-
-  @property({type: Object})
-  _selfAccount?: AccountDetailInfo;
-
-  @property({type: Array})
+  @state()
   layers: DiffLayer[] = [];
 
-  @property({type: Object, computed: 'computeDiff(comments, path)'})
-  _diff?: DiffInfo;
+  /** Computed during willUpdate(). */
+  @state()
+  diff?: DiffInfo;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  /** Computed during willUpdate(). */
+  @state()
+  highlightRange?: CommentRange;
 
-  private readonly reporting = appContext.reportingService;
+  /**
+   * Reflects the *dirty* state of whether the thread is currently unresolved.
+   * We are listening on the <gr-comment> of the draft, so we even know when the
+   * checkbox is checked, even if not yet saved.
+   */
+  @state()
+  unresolved = true;
 
-  private readonly commentsService = appContext.commentsService;
+  /**
+   * Normally drafts are saved within the <gr-comment> child component and we
+   * don't care about that. But when creating 'Done.' replies we are actually
+   * saving from this component. True while the REST API call is inflight.
+   */
+  @state()
+  saving = false;
 
-  readonly storage = appContext.storageService;
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  private readonly syntaxLayer = new GrSyntaxLayer();
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  readonly restApiService = appContext.restApiService;
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  private readonly shortcuts = appContext.shortcutsService;
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly shortcuts = new ShortcutController(this);
+
+  private readonly syntaxLayer = new GrSyntaxLayerWorker(
+    resolve(this, highlightServiceToken),
+    () => getAppContext().reportingService
+  );
+
+  // for COMMENTS_AUTOCLOSE logging purposes only
+  readonly uid = performance.now().toString(36) + Math.random().toString(36);
+
+  private readonly patched = new HtmlPatched(key => {
+    this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
+      component: this.tagName,
+      key: key.substring(0, 300),
+    });
+  });
 
   constructor() {
     super();
-    this.addEventListener('comment-update', e =>
-      this._handleCommentUpdate(e as CustomEvent)
+    this.shortcuts.addGlobal({key: 'e'}, () => this.handleExpandShortcut());
+    this.shortcuts.addGlobal({key: 'E'}, () => this.handleCollapseShortcut());
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
     );
-    appContext.restApiService.getPreferences().then(prefs => {
-      this._initLayers(!!prefs?.disable_token_highlighting);
-    });
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repoName = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      x => this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => {
+        const layers: DiffLayer[] = [this.syntaxLayer];
+        if (!prefs.disable_token_highlighting) {
+          layers.push(new TokenHighlightLayer(this));
+        }
+        this.layers = layers;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      prefs => {
+        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,
+        };
+      }
+    );
   }
 
   override disconnectedCallback() {
+    if (this.editing) {
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED
+      );
+    }
     super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.cleanups.push(
-      addGlobalShortcut({key: 'e'}, e => this.handleExpandShortcut(e))
-    );
-    this.cleanups.push(
-      addGlobalShortcut({key: 'E'}, e => this.handleCollapseShortcut(e))
-    );
-    this._getLoggedIn().then(loggedIn => {
-      this._showActions = loggedIn;
-    });
-    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.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
-    this._setInitialExpandedState();
+  static override get styles() {
+    return [
+      a11yStyles,
+      sharedStyles,
+      css`
+        :host {
+          font-family: var(--font-family);
+          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);
+        }
+        gr-comment {
+          border-bottom: 1px solid var(--comment-separator-color);
+        }
+        #actions {
+          margin-left: auto;
+          padding: var(--spacing-s) var(--spacing-m);
+        }
+        .comment-box {
+          width: 80ch;
+          max-width: 100%;
+          background-color: var(--comment-background-color);
+          color: var(--comment-text-color);
+          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;
+        }
+        .comment-box.unresolved {
+          background-color: var(--unresolved-comment-background-color);
+        }
+        .comment-box.robotComment {
+          background-color: var(--robot-comment-background-color);
+        }
+        #actionsContainer {
+          display: flex;
+        }
+        .comment-box.saving #actionsContainer {
+          opacity: 0.5;
+        }
+        #unresolvedLabel {
+          font-family: var(--font-family);
+          margin: auto 0;
+          padding: var(--spacing-m);
+        }
+        .pathInfo {
+          display: flex;
+          align-items: baseline;
+          justify-content: space-between;
+          padding: 0 var(--spacing-s) var(--spacing-s);
+        }
+        .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);
+        }
+
+        /* In saved state the "reply" and "quote" buttons are 28px height.
+         * top:4px  positions the 20px icon vertically centered.
+         * Currently in draft state the "save" and "cancel" buttons are 20px
+         * height, so the link icon does not need a top:4px in gr-comment_html.
+         */
+        .link-icon {
+          margin-left: var(--spacing-m);
+          position: relative;
+          top: 4px;
+          cursor: pointer;
+        }
+        .fileName gr-copy-clipboard {
+          display: inline-block;
+          visibility: hidden;
+          vertical-align: top;
+          --gr-button-padding: 0px;
+        }
+        .fileName:focus-within gr-copy-clipboard,
+        .fileName:hover gr-copy-clipboard {
+          visibility: visible;
+        }
+      `,
+    ];
   }
 
-  computeDiff(comments?: UIComment[], path?: string) {
-    if (comments === undefined || path === undefined) return undefined;
-    if (!comments[0]?.context_lines?.length) return undefined;
+  override render() {
+    if (!this.thread) return;
+    const dynamicBoxClasses = {
+      robotComment: this.isRobotComment(),
+      unresolved: this.unresolved,
+      saving: this.saving,
+    };
+    return html`
+      ${this.renderFilePath()}
+      <div id="container">
+        <h3 class="assistive-tech-only">${this.computeAriaHeading()}</h3>
+        <div class="comment-box ${classMap(dynamicBoxClasses)}" tabindex="0">
+          ${this.renderComments()} ${this.renderActions()}
+        </div>
+        ${this.renderContextualDiff()}
+      </div>
+    `;
+  }
+
+  renderFilePath() {
+    if (!this.showFilePath) return;
+    const href = this.getUrlForFileComment();
+    const line = this.computeDisplayLine();
+    return html`
+      ${this.renderFileName()}
+      <div class="pathInfo">
+        ${href ? html`<a href=${href}>${line}</a>` : html`<span>${line}</span>`}
+      </div>
+    `;
+  }
+
+  renderFileName() {
+    if (!this.showFileName) return;
+    if (this.isPatchsetLevel()) {
+      return html`<div class="fileName"><span>Patchset</span></div>`;
+    }
+    const href = this.getDiffUrlForPath();
+    const displayPath = this.getDisplayPath();
+    return html`
+      <div class="fileName">
+        ${href
+          ? html`<a href=${href}>${displayPath}</a>`
+          : html`<span>${displayPath}</span>`}
+        <gr-copy-clipboard hideInput .text=${displayPath}></gr-copy-clipboard>
+      </div>
+    `;
+  }
+
+  renderComments() {
+    assertIsDefined(this.thread, 'thread');
+    const publishedComments = repeat(
+      this.thread.comments.filter(c => !isDraftOrUnsaved(c)),
+      comment => comment.id,
+      comment => this.renderComment(comment)
+    );
+    // We are deliberately not including the draft in the repeat directive,
+    // because we ran into spurious issues with <gr-comment> being destroyed
+    // and re-created when an unsaved draft transitions to 'saved' state.
+    const draftComment = this.renderComment(this.getDraftOrUnsaved());
+    return html`${publishedComments}${draftComment}`;
+  }
+
+  private renderComment(comment?: Comment) {
+    if (!comment) return nothing;
+    const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
+    const initiallyCollapsed =
+      !isDraftOrUnsaved(comment) &&
+      (this.messageId
+        ? comment.change_message_id !== this.messageId
+        : !this.unresolved);
+    return this.patched.html`
+      <gr-comment
+        .comment=${comment}
+        .comments=${this.thread!.comments}
+        ?initially-collapsed=${initiallyCollapsed}
+        ?robot-button-disabled=${robotButtonDisabled}
+        ?show-patchset=${this.showPatchset}
+        ?show-ported-comment=${
+          this.showPortedComment && comment.id === this.rootId
+        }
+        @reply-to-comment=${this.handleReplyToComment}
+        @copy-comment-link=${this.handleCopyLink}
+        @comment-editing-changed=${(
+          e: CustomEvent<CommentEditingChangedDetail>
+        ) => {
+          if (isDraftOrUnsaved(comment)) this.editing = e.detail.editing;
+        }}
+        @comment-unresolved-changed=${(e: ValueChangedEvent<boolean>) => {
+          if (isDraftOrUnsaved(comment)) this.unresolved = e.detail.value;
+        }}
+      ></gr-comment>
+    `;
+  }
+
+  renderActions() {
+    if (!this.account || this.isDraftOrUnsaved() || this.isRobotComment())
+      return;
+    return html`
+      <div id="actionsContainer">
+        <span id="unresolvedLabel">${
+          this.unresolved ? 'Unresolved' : 'Resolved'
+        }</span>
+        <div id="actions">
+
+          <gr-button
+              id="replyBtn"
+              link
+              class="action reply"
+              ?disabled=${this.saving}
+              @click=${() => this.handleCommentReply(false)}
+          >Reply</gr-button
+          >
+          <gr-button
+              id="quoteBtn"
+              link
+              class="action quote"
+              ?disabled=${this.saving}
+              @click=${() => this.handleCommentReply(true)}
+          >Quote</gr-button
+          >
+          ${
+            this.unresolved
+              ? html`
+                  <gr-button
+                    id="ackBtn"
+                    link
+                    class="action ack"
+                    ?disabled=${this.saving}
+                    @click=${this.handleCommentAck}
+                    >Ack</gr-button
+                  >
+                  <gr-button
+                    id="doneBtn"
+                    link
+                    class="action done"
+                    ?disabled=${this.saving}
+                    @click=${this.handleCommentDone}
+                    >Done</gr-button
+                  >
+                `
+              : ''
+          }
+          <gr-icon
+            icon="link"
+            class="link-icon copy"
+            @click=${this.handleCopyLink}
+            title="Copy link to this comment"
+            role="button"
+            tabindex="0"
+          ></gr-icon>
+        </div>
+      </div>
+    </div>
+    `;
+  }
+
+  renderContextualDiff() {
+    if (!this.changeNum || !this.showCommentContext || !this.diff) return;
+    if (!this.thread?.path) return;
+    const href = this.getUrlForFileComment() ?? '';
+    return html`
+      <div class="diff-container">
+        <gr-diff
+          id="diff"
+          .diff=${this.diff}
+          .layers=${this.layers}
+          .path=${this.thread.path}
+          .prefs=${this.prefs}
+          .renderPrefs=${this.renderPrefs}
+          .highlightRange=${this.highlightRange}
+        >
+        </gr-diff>
+        <div class="view-diff-container">
+          <a href=${href}>
+            <gr-button link class="view-diff-button">View Diff</gr-button>
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  private firstWillUpdateDone = false;
+
+  firstWillUpdate() {
+    if (!this.thread) return;
+    if (this.firstWillUpdateDone) return;
+    this.firstWillUpdateDone = true;
+
+    if (this.getFirstComment() === undefined) {
+      this.unsavedComment = createUnsavedComment(this.thread);
+    }
+    this.unresolved = this.getLastComment()?.unresolved ?? true;
+    this.diff = this.computeDiff();
+    this.highlightRange = this.computeHighlightRange();
+  }
+
+  override willUpdate(changed: PropertyValues) {
+    this.firstWillUpdate();
+    if (changed.has('thread')) {
+      if (!this.isDraftOrUnsaved()) {
+        // We can only do this for threads without draft, because otherwise we
+        // are relying on the <gr-comment> component for the draft to fire
+        // events about the *dirty* `unresolved` state.
+        this.unresolved = this.getLastComment()?.unresolved ?? true;
+      }
+      this.hasDraft = this.isDraftOrUnsaved();
+      this.rootId = this.getFirstComment()?.id;
+      if (this.isDraft()) {
+        this.unsavedComment = undefined;
+      }
+    }
+    if (changed.has('editing')) {
+      // changed.get('editing') contains the old value. We only want to trigger
+      // when changing from editing to non-editing (user has cancelled/saved).
+      // We do *not* want to trigger on first render (old value is `null`)
+      if (!this.editing && changed.get('editing') === true) {
+        this.unsavedComment = undefined;
+        if (this.thread?.comments.length === 0) {
+          this.remove();
+        }
+      }
+      fire(this, 'comment-thread-editing-changed', {value: this.editing});
+    }
+  }
+
+  override firstUpdated() {
+    if (this.shouldScrollIntoView) {
+      whenRendered(this, () => {
+        this.expandCollapseComments(false);
+        this.commentBox?.focus();
+        // The delay is a hack because we don't know exactly when to
+        // scroll the comment into center.
+        // TODO: Find a better solution without a setTimeout
+        this.scrollIntoView({block: 'center'});
+        setTimeout(() => {
+          this.scrollIntoView({block: 'center'});
+        }, 500);
+      });
+    }
+  }
+
+  private isDraft() {
+    return isDraft(this.getLastComment());
+  }
+
+  private isDraftOrUnsaved(): boolean {
+    return this.isDraft() || this.isUnsaved();
+  }
+
+  private getDraftOrUnsaved(): Comment | undefined {
+    if (this.unsavedComment) return this.unsavedComment;
+    if (this.isDraft()) return this.getLastComment();
+    return undefined;
+  }
+
+  private isNewThread(): boolean {
+    return this.thread?.comments.length === 0;
+  }
+
+  private isUnsaved(): boolean {
+    return !!this.unsavedComment || this.thread?.comments.length === 0;
+  }
+
+  private isPatchsetLevel() {
+    return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+  }
+
+  private computeDiff() {
+    if (!this.showCommentContext) return;
+    if (!this.thread?.path) return;
+    const firstComment = this.getFirstComment();
+    if (!firstComment?.context_lines?.length) return;
     const diff = computeDiffFromContext(
-      comments[0].context_lines,
-      path,
-      comments[0].source_content_type
+      firstComment.context_lines,
+      this.thread?.path,
+      firstComment.source_content_type
     );
     // Do we really have to re-compute (and re-render) the diff?
-    if (this._diff && JSON.stringify(this._diff) === JSON.stringify(diff)) {
-      return this._diff;
+    if (this.diff && JSON.stringify(this.diff) === JSON.stringify(diff)) {
+      return this.diff;
     }
 
     if (!anyLineTooLong(diff)) {
-      this.syntaxLayer.init(diff);
-      waitForEventOnce(this, 'render').then(() => {
-        this.syntaxLayer.process();
-      });
+      this.syntaxLayer.process(diff);
     }
     return diff;
   }
 
-  handleShouldScrollIntoViewChanged(shouldScrollIntoView?: boolean) {
-    // Wait for comment to be rendered before scrolling to it
-    if (shouldScrollIntoView) {
-      const resizeObserver = new ResizeObserver(
-        (_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
-          if (this.offsetHeight > 0) {
-            queryAndAssert<HTMLDivElement>(this, '.comment-box').focus();
-            this.scrollIntoView();
-          }
-          observer.unobserve(this);
-        }
-      );
-      resizeObserver.observe(this);
+  private getDiffUrlForPath() {
+    if (!this.changeNum || !this.repoName || !this.thread?.path) {
+      return undefined;
     }
+    if (this.isNewThread()) return undefined;
+    return createDiffUrl({
+      changeNum: this.changeNum,
+      repo: this.repoName,
+      patchNum: this.thread.patchNum,
+      diffView: {path: this.thread.path},
+    });
   }
 
-  _shouldShowCommentContext(
-    changeNum?: NumericChangeId,
-    showCommentContext?: boolean,
-    diff?: DiffInfo
-  ) {
-    return changeNum && showCommentContext && !!diff;
-  }
-
-  addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
-    const lastComment = this.comments[this.comments.length - 1] || {};
-    if (isDraft(lastComment)) {
-      const commentEl = this._commentElWithDraftID(
-        lastComment.id || lastComment.__draftID
-      );
-      if (!commentEl) throw new Error('Failed to find draft.');
-      commentEl.editing = true;
-
-      // If the comment was collapsed, re-open it to make it clear which
-      // actions are available.
-      commentEl.collapsed = false;
-    } else {
-      const range = rangeParam
-        ? rangeParam
-        : lastComment
-        ? lastComment.range
-        : undefined;
-      const unresolved = lastComment ? lastComment.unresolved : undefined;
-      this.addDraft(lineNum, range, unresolved);
-    }
-  }
-
-  addDraft(lineNum?: LineNumber, range?: CommentRange, unresolved?: boolean) {
-    const draft = this._newDraft(lineNum, range);
-    draft.__editing = true;
-    draft.unresolved = unresolved === false ? unresolved : true;
-    this.commentsService.addDraft(draft);
-  }
-
-  _getDiffUrlForPath(
-    projectName?: RepoName,
-    changeNum?: NumericChangeId,
-    path?: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (!changeNum || !projectName || !path) return undefined;
-    if (isDraft(this.comments[0])) {
-      return GerritNav.getUrlForDiffById(
-        changeNum,
-        projectName,
-        path,
-        patchNum
-      );
-    }
-    const id = this.comments[0].id;
-    if (!id) throw new Error('A published comment is missing the id.');
-    return GerritNav.getUrlForComment(changeNum, projectName, id);
-  }
-
-  /** The parameter is for triggering re-computation only. */
-  getHighlightRange(_: unknown) {
-    const comment = this.comments?.[0];
+  private computeHighlightRange() {
+    const comment = this.getFirstComment();
     if (!comment) return undefined;
     if (comment.range) return comment.range;
     if (comment.line) {
@@ -373,413 +765,142 @@
     return undefined;
   }
 
-  _initLayers(disableTokenHighlighting: boolean) {
-    if (!disableTokenHighlighting) {
-      this.layers.push(new TokenHighlightLayer(this));
+  // Does not work for patchset level comments
+  private getUrlForFileComment() {
+    if (!this.repoName || !this.changeNum || this.isNewThread()) {
+      return undefined;
     }
-    this.layers.push(this.syntaxLayer);
-  }
-
-  _getUrlForViewDiff(
-    comments: UIComment[],
-    changeNum?: NumericChangeId,
-    projectName?: RepoName
-  ): string {
-    if (!changeNum) return '';
-    if (!projectName) return '';
-    check(comments.length > 0, 'comment not found');
-    return GerritNav.getUrlForComment(changeNum, projectName, comments[0].id!);
-  }
-
-  _getDiffUrlForComment(
-    projectName?: RepoName,
-    changeNum?: NumericChangeId,
-    path?: string,
-    patchNum?: PatchSetNum
-  ) {
-    if (!projectName || !changeNum || !path) return undefined;
-    if (
-      (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 === FILE ? undefined : this.lineNum
-      );
-    }
-    const id = this.comments[0].id;
-    if (!id) throw new Error('A published comment is missing the id.');
-    return GerritNav.getUrlForComment(changeNum, projectName, id);
-  }
-
-  handleCopyLink() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.projectName, 'projectName');
-    const url = generateAbsoluteUrl(
-      GerritNav.getUrlForCommentsTab(
-        this.changeNum,
-        this.projectName,
-        this.comments[0].id!
-      )
-    );
-    navigator.clipboard.writeText(url).then(() => {
-      fireAlert(this, 'Link copied to clipboard');
+    assertIsDefined(this.rootId, 'rootId of comment thread');
+    return createDiffUrl({
+      changeNum: this.changeNum,
+      repo: this.repoName,
+      commentId: this.rootId,
     });
   }
 
-  _isPatchsetLevelComment(path?: string) {
-    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) {
-      return 'Patchset';
+  private handleCopyLink() {
+    const comment = this.getFirstComment();
+    if (!comment) return;
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.repoName, 'repoName');
+    let url: string;
+    if (this.isPatchsetLevel()) {
+      url = createChangeUrl({
+        changeNum: this.changeNum,
+        repo: this.repoName,
+        commentId: comment.id,
+      });
+    } else {
+      url = createDiffUrl({
+        changeNum: this.changeNum,
+        repo: this.repoName,
+        commentId: comment.id,
+      });
     }
-    return displayPath;
+    assertIsDefined(url, 'url for comment');
+    copyToClipbard(generateAbsoluteUrl(url), 'Link');
   }
 
-  _computeDisplayLine(lineNum?: LineNumber, range?: CommentRange) {
-    if (lineNum === FILE) {
-      if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
-        return '';
-      }
-      return FILE;
-    }
-    if (lineNum) return `#${lineNum}`;
+  private getDisplayPath() {
+    if (this.isPatchsetLevel()) return 'Patchset';
+    return computeDisplayPath(this.thread?.path);
+  }
+
+  private computeDisplayLine() {
+    assertIsDefined(this.thread, 'thread');
+    if (this.thread.line === FILE) return this.isPatchsetLevel() ? '' : FILE;
+    if (this.thread.line) return `#${this.thread.line}`;
     // If range is set, then lineNum equals the end line of the range.
-    if (range) return `#${range.end_line}`;
+    if (this.thread.range) return `#${this.thread.range.end_line}`;
     return '';
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
+  private isRobotComment() {
+    return isRobot(this.getLastComment());
   }
 
-  _getUnresolvedLabel(unresolved?: boolean) {
-    return unresolved ? 'Unresolved' : 'Resolved';
+  private getFirstComment() {
+    assertIsDefined(this.thread);
+    return getFirstComment(this.thread);
   }
 
-  @observe('comments.*')
-  _commentsChanged() {
-    this._orderedComments = sortComments(this.comments);
-    this.updateThreadProperties();
+  private getLastComment() {
+    assertIsDefined(this.thread);
+    return getLastComment(this.thread);
   }
 
-  updateThreadProperties() {
-    if (this._orderedComments.length) {
-      this._lastComment = this._getLastComment();
-      this.unresolved = this._lastComment.unresolved;
-      this.hasDraft = isDraft(this._lastComment);
-      this.isRobotComment = isRobot(this._lastComment);
+  private handleExpandShortcut() {
+    this.expandCollapseComments(false);
+  }
+
+  private handleCollapseShortcut() {
+    this.expandCollapseComments(true);
+  }
+
+  private expandCollapseComments(actionIsCollapse: boolean) {
+    for (const comment of this.commentElements ?? []) {
+      (comment as GrComment).collapsed = actionIsCollapse;
     }
   }
 
-  _shouldDisableAction(_showActions?: boolean, _lastComment?: UIComment) {
-    return !_showActions || !_lastComment || isDraft(_lastComment);
-  }
-
-  _hideActions(_showActions?: boolean, _lastComment?: UIComment) {
-    return (
-      this._shouldDisableAction(_showActions, _lastComment) ||
-      isRobot(_lastComment)
-    );
-  }
-
-  _getLastComment() {
-    return this._orderedComments[this._orderedComments.length - 1] || {};
-  }
-
-  private handleExpandShortcut(e: KeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    this._expandCollapseComments(false);
-  }
-
-  private handleCollapseShortcut(e: KeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    this._expandCollapseComments(true);
-  }
-
-  _expandCollapseComments(actionIsCollapse: boolean) {
-    const comments = this.root?.querySelectorAll('gr-comment');
-    if (!comments) return;
-    for (const comment of comments) {
-      comment.collapsed = actionIsCollapse;
+  private async createReplyComment(
+    content: string,
+    userWantsToEdit: boolean,
+    unresolved: boolean
+  ) {
+    const replyingTo = this.getLastComment();
+    assertIsDefined(this.thread, 'thread');
+    assertIsDefined(replyingTo, 'the comment that the user wants to reply to');
+    if (isDraft(replyingTo)) {
+      throw new Error('cannot reply to draft');
     }
-  }
-
-  /**
-   * Sets the initial state of the comment thread.
-   * Expands the thread if one of the following is true:
-   * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
-   * thread is unresolved,
-   * - it's a robot comment.
-   * - it's a draft
-   */
-  _setInitialExpandedState() {
-    if (this._orderedComments) {
-      for (let i = 0; i < this._orderedComments.length; i++) {
-        const comment = this._orderedComments[i];
-        if (isDraft(comment)) {
-          comment.collapsed = false;
-          continue;
-        }
-        const isRobotComment = !!(comment as UIRobot).robot_id;
-        // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
-        const resolvedThread =
-          !this.unresolved ||
-          this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
-        if (comment.collapsed === undefined) {
-          comment.collapsed = !isRobotComment && resolvedThread;
-        }
+    if (isUnsaved(replyingTo)) {
+      throw new Error('cannot reply to unsaved comment');
+    }
+    const unsaved = createUnsavedReply(replyingTo, content, unresolved);
+    if (userWantsToEdit) {
+      this.unsavedComment = unsaved;
+    } else {
+      try {
+        this.saving = true;
+        await this.getCommentsModel().saveDraft(unsaved);
+      } finally {
+        this.saving = false;
       }
     }
   }
 
-  _createReplyComment(
-    content?: string,
-    isEditing?: boolean,
-    unresolved?: boolean
-  ) {
-    this.reporting.recordDraftInteraction();
-    const id = this._orderedComments[this._orderedComments.length - 1].id;
-    if (!id) throw new Error('Cannot reply to comment without id.');
-    const reply = this._newReply(id, content, unresolved);
-
-    if (isEditing) {
-      reply.__editing = true;
-      this.commentsService.addDraft(reply);
-    } else {
-      assertIsDefined(this.changeNum, 'changeNum');
-      assertIsDefined(this.patchNum, 'patchNum');
-      this.restApiService
-        .saveDiffDraft(this.changeNum, this.patchNum, reply)
-        .then(result => {
-          if (!result.ok) {
-            fireAlert(document, 'Unable to restore draft');
-            return;
-          }
-          this.restApiService.getResponseObject(result).then(obj => {
-            const resComment = obj as unknown as DraftInfo;
-            resComment.patch_set = reply.patch_set;
-            this.commentsService.addDraft(resComment);
-          });
-        });
-    }
-  }
-
-  _isDraft(comment: UIComment) {
-    return isDraft(comment);
-  }
-
-  _processCommentReply(quote?: boolean) {
-    const comment = this._lastComment;
+  private handleCommentReply(quote: boolean) {
+    const comment = this.getLastComment();
     if (!comment) throw new Error('Failed to find last comment.');
-    let content = undefined;
+    let content = '';
     if (quote) {
       const msg = comment.message;
       if (!msg) throw new Error('Quoting empty comment.');
       content = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
     }
-    this._createReplyComment(content, true, comment.unresolved);
+    this.createReplyComment(content, true, comment.unresolved ?? true);
   }
 
-  _handleCommentReply() {
-    this._processCommentReply();
+  private handleCommentAck() {
+    this.createReplyComment('Ack', false, false);
   }
 
-  _handleCommentQuote() {
-    this._processCommentReply(true);
+  private handleCommentDone() {
+    this.createReplyComment('Done', false, false);
   }
 
-  _handleCommentAck() {
-    this._createReplyComment('Ack', false, false);
+  private handleReplyToComment(e: ReplyToCommentEvent) {
+    const {content, userWantsToEdit, unresolved} = e.detail;
+    this.createReplyComment(content, userWantsToEdit, unresolved);
   }
 
-  _handleCommentDone() {
-    this._createReplyComment('Done', false, false);
-  }
-
-  _handleCommentFix(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const msg = comment.message;
-    const quoted = msg.replace(NEWLINE_PATTERN, '\n> ') as string;
-    const quoteStr = '> ' + quoted + '\n\n';
-    const response = quoteStr + 'Please fix.';
-    this._createReplyComment(response, false, true);
-  }
-
-  _commentElWithDraftID(id?: string): GrComment | null {
-    if (!id) return null;
-    const els = this.root?.querySelectorAll('gr-comment');
-    if (!els) return null;
-    for (const el of els) {
-      const c = el.comment;
-      if (isRobot(c)) continue;
-      if (c?.id === id || (isDraft(c) && c?.__draftID === id)) return el;
-    }
-    return null;
-  }
-
-  _newReply(
-    inReplyTo: UrlEncodedCommentId,
-    message?: string,
-    unresolved?: boolean
-  ) {
-    const d = this._newDraft();
-    d.in_reply_to = inReplyTo;
-    if (message !== undefined) {
-      d.message = message;
-    }
-    if (unresolved !== undefined) {
-      d.unresolved = unresolved;
-    }
-    return d;
-  }
-
-  _newDraft(lineNum?: LineNumber, range?: CommentRange) {
-    const d: UIDraft = {
-      __draft: true,
-      __draftID: 'draft__' + 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];
-      if (rootComment.path !== undefined) d.path = rootComment.path;
-      if (rootComment.patch_set !== undefined)
-        d.patch_set = rootComment.patch_set;
-      if (rootComment.side !== undefined) d.side = rootComment.side;
-      if (rootComment.line !== undefined) d.line = rootComment.line;
-      if (rootComment.range !== undefined) d.range = rootComment.range;
-      if (rootComment.parent !== undefined) d.parent = rootComment.parent;
-    } else {
-      // Set meta info for root comment.
-      d.path = this.path;
-      d.patch_set = this.patchNum;
-      d.side = this._getSide(this.isOnParent);
-
-      if (lineNum && lineNum !== FILE) {
-        d.line = lineNum;
-      }
-      if (range) {
-        d.range = range;
-      }
-      if (this.parentIndex) {
-        d.parent = this.parentIndex;
-      }
-    }
-    return d;
-  }
-
-  _getSide(isOnParent: boolean): CommentSide {
-    return isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
-  }
-
-  _computeRootId(comments: PolymerDeepPropertyChange<UIComment[], unknown>) {
-    // Keep the root ID even if the comment was removed, so that notification
-    // to sync will know which thread to remove.
-    if (!comments.base.length) {
-      return this.rootId;
-    }
-    return computeId(comments.base[0]);
-  }
-
-  _handleCommentDiscard() {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.patchNum, 'patchNum');
-    // 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 (isDraft(changeComment) && changeComment.__editing) {
-        const commentLocation: StorageLocation = {
-          changeNum: this.changeNum,
-          patchNum: this.patchNum,
-          path: changeComment.path,
-          line: changeComment.line,
-        };
-        this.storage.setDraftComment(
-          commentLocation,
-          changeComment.message ?? ''
-        );
-      }
-    }
-  }
-
-  _handleCommentUpdate(e: CustomEvent) {
-    const comment = e.detail.comment;
-    const index = this._indexOf(comment, this.comments);
-    if (index === -1) {
-      // This should never happen: comment belongs to another thread.
-      this.reporting.error(
-        new Error(`Comment update for another comment thread: ${comment}`)
-      );
-      return;
-    }
-    this.set(['comments', index], comment);
-    // Because of the way we pass these comment objects around by-ref, in
-    // combination with the fact that Polymer does dirty checking in
-    // observers, the this.set() call above will not cause a thread update in
-    // some situations.
-    this.updateThreadProperties();
-  }
-
-  _indexOf(comment: UIComment | undefined, arr: UIComment[]) {
-    if (!comment) return -1;
-    for (let i = 0; i < arr.length; i++) {
-      const c = arr[i];
-      if (
-        (isDraft(c) && isDraft(comment) && c.__draftID === comment.__draftID) ||
-        (c.id && c.id === comment.id)
-      ) {
-        return i;
-      }
-    }
-    return -1;
-  }
-
-  /** 2nd parameter is for triggering re-computation only. */
-  _computeHostClass(unresolved?: boolean, _?: unknown) {
-    if (this.isRobotComment) {
-      return 'robotComment';
-    }
-    return unresolved ? 'unresolved' : '';
-  }
-
-  /**
-   * Load the project config when a project name has been provided.
-   *
-   * @param name The project name.
-   */
-  _projectNameChanged(name?: RepoName) {
-    if (!name) {
-      return;
-    }
-    this.restApiService.getProjectConfig(name).then(config => {
-      this._projectConfig = config;
-    });
-  }
-
-  _computeAriaHeading(_orderedComments: UIComment[]) {
-    const firstComment = _orderedComments[0];
-    const author = firstComment?.author ?? this._selfAccount;
-    const lastComment = _orderedComments[_orderedComments.length - 1] || {};
-    const status = [
-      lastComment.unresolved ? 'Unresolved' : '',
-      isDraft(lastComment) ? 'Draft' : '',
-    ].join(' ');
-    return `${status} Comment thread by ${getUserName(undefined, author)}`;
+  private computeAriaHeading() {
+    const author = this.getFirstComment()?.author ?? this.account;
+    const user = getUserName(undefined, author);
+    const unresolvedStatus = this.unresolved ? 'Unresolved ' : '';
+    const draftStatus = this.isDraftOrUnsaved() ? 'Draft ' : '';
+    return `${unresolvedStatus}${draftStatus}Comment thread by ${user}`;
   }
 }
 
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
deleted file mode 100644
index c3faaa5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-a11y-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    :host {
-      font-family: var(--font-family);
-      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);
-    }
-    gr-comment {
-      border-bottom: 1px solid var(--comment-separator-color);
-    }
-    #actions {
-      margin-left: auto;
-      padding: var(--spacing-s) var(--spacing-m);
-    }
-    .comment-box {
-      width: 80ch;
-      max-width: 100%;
-      background-color: var(--comment-background-color);
-      color: var(--comment-text-color);
-      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;
-    }
-    .comment-box.unresolved {
-      background-color: var(--unresolved-comment-background-color);
-    }
-    .comment-box.robotComment {
-      background-color: var(--robot-comment-background-color);
-    }
-    #commentInfoContainer {
-      display: flex;
-    }
-    #unresolvedLabel {
-      font-family: var(--font-family);
-      margin: auto 0;
-      padding: var(--spacing-m);
-    }
-    .pathInfo {
-      display: flex;
-      align-items: baseline;
-      justify-content: space-between;
-      padding: 0 var(--spacing-s) var(--spacing-s);
-    }
-    .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);
-    }
-
-    /* In saved state the "reply" and "quote" buttons are 28px height.
-     * top:4px  positions the 20px icon vertically centered.
-     * Currently in draft state the "save" and "cancel" buttons are 20px
-     * height, so the link icon does not need a top:4px in gr-comment_html.
-     */
-    .link-icon {
-      position: relative;
-      top: 4px;
-      cursor: pointer;
-    }
-    .fileName gr-copy-clipboard {
-      display: inline-block;
-      visibility: hidden;
-      vertical-align: top;
-      --gr-button-padding: 0px;
-    }
-    .fileName:focus-within gr-copy-clipboard,
-    .fileName:hover gr-copy-clipboard {
-      visibility: visible;
-    }
-  </style>
-
-  <template is="dom-if" if="[[showFilePath]]">
-    <template is="dom-if" if="[[showFileName]]">
-      <div class="fileName">
-        <template is="dom-if" if="[[_isPatchsetLevelComment(path)]]">
-          <span> [[_computeDisplayPath(path)]] </span>
-        </template>
-        <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
-          <a
-            href$="[[_getDiffUrlForPath(projectName, changeNum, path, patchNum)]]"
-          >
-            [[_computeDisplayPath(path)]]
-          </a>
-          <gr-copy-clipboard
-            hideInput=""
-            text="[[_computeDisplayPath(path)]]"
-          ></gr-copy-clipboard>
-        </template>
-      </div>
-    </template>
-    <div class="pathInfo">
-      <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
-        <a
-          href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
-          >[[_computeDisplayLine(lineNum, range)]]</a
-        >
-      </template>
-    </div>
-  </template>
-  <div id="container">
-    <h3 class="assistive-tech-only">
-      [[_computeAriaHeading(_orderedComments)]]
-    </h3>
-    <div
-      class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box"
-      tabindex="0"
-    >
-      <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-copy-comment-link="handleCopyLink"
-        ></gr-comment>
-      </template>
-      <div
-        id="commentInfoContainer"
-        hidden$="[[_hideActions(_showActions, _lastComment)]]"
-      >
-        <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
-        <div id="actions">
-          <iron-icon
-            class="link-icon"
-            on-click="handleCopyLink"
-            class="copy"
-            title="Copy link to this comment"
-            icon="gr-icons:link"
-            role="button"
-            tabindex="0"
-          >
-          </iron-icon>
-          <gr-button
-            id="replyBtn"
-            link=""
-            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]]">
-            <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(changeNum, showCommentContext, _diff)]]"
-    >
-      <div class="diff-container">
-        <gr-diff
-          id="diff"
-          change-num="[[changeNum]]"
-          diff="[[_diff]]"
-          layers="[[layers]]"
-          path="[[path]]"
-          prefs="[[_prefs]]"
-          render-prefs="[[_renderPrefs]]"
-          highlight-range="[[getHighlightRange(comments)]]"
-        >
-        </gr-diff>
-        <div class="view-diff-container">
-          <a href="[[_getUrlForViewDiff(comments, changeNum, projectName)]]">
-            <gr-button link class="view-diff-button">View Diff</gr-button>
-          </a>
-        </div>
-      </div>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 9e0027a..97eafc9 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -1,947 +1,485 @@
 /**
  * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-comment-thread';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {SpecialFilePath, Side} from '../../../constants/constants';
-import {
-  sortComments,
-  UIComment,
-  UIRobot,
-  UIDraft,
-} from '../../../utils/comment-util';
+import {DraftInfo, sortComments} from '../../../utils/comment-util';
 import {GrCommentThread} from './gr-comment-thread';
 import {
-  PatchSetNum,
   NumericChangeId,
   UrlEncodedCommentId,
   Timestamp,
-  RobotId,
-  RobotRunId,
+  CommentInfo,
   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';
 import {
   mockPromise,
-  stubComments,
-  stubReporting,
+  queryAndAssert,
   stubRestApi,
+  waitUntilCalled,
+  MockPromise,
 } from '../../../test/test-utils';
+import {
+  createAccountDetailWithId,
+  createThread,
+} from '../../../test/test-data-generators';
 import {SinonStub} from 'sinon';
+import {fixture, html, waitUntil, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
+import {SpecialFilePath} from '../../../constants/constants';
+import {GrIcon} from '../gr-icon/gr-icon';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {testResolver} from '../../../test/common-test-setup';
 
-const basicFixture = fixtureFromElement('gr-comment-thread');
+const c1 = {
+  author: {name: 'Kermit'},
+  id: 'the-root' as UrlEncodedCommentId,
+  message: 'start the conversation',
+  updated: '2021-11-01 10:11:12.000000000' as Timestamp,
+};
 
-const withCommentFixture = fixtureFromElement('gr-comment-thread');
+const c2 = {
+  author: {name: 'Ms Piggy'},
+  id: 'the-reply' as UrlEncodedCommentId,
+  message: 'keep it going',
+  updated: '2021-11-02 10:11:12.000000000' as Timestamp,
+  in_reply_to: 'the-root' as UrlEncodedCommentId,
+};
+
+const c3 = {
+  author: {name: 'Kermit'},
+  id: 'the-draft' as UrlEncodedCommentId,
+  message: 'stop it',
+  updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+  in_reply_to: 'the-reply' as UrlEncodedCommentId,
+  __draft: true,
+};
+
+const commentWithContext = {
+  author: {name: 'Kermit'},
+  id: 'the-draft' as UrlEncodedCommentId,
+  message: 'just for context',
+  updated: '2021-11-03 10:11:12.000000000' as Timestamp,
+  line: 5,
+  context_lines: [
+    {line_number: 4, context_line: 'content of line 4'},
+    {line_number: 5, context_line: 'content of line 5'},
+    {line_number: 6, context_line: 'content of line 6'},
+  ],
+};
 
 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', async () => {
-      const projectName = 'foo/bar/baz' as RepoName;
-      const getProjectStub = stubRestApi('getProjectConfig').returns(
-        Promise.resolve({} as ConfigInfo)
-      );
-      element.projectName = projectName;
-      await flush();
-      assert.isTrue(getProjectStub.calledWithExactly(projectName as never));
-    });
-
-    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(element.lineNum, element.range),
-        '#5'
-      );
-
-      element.path = SpecialFilePath.COMMIT_MESSAGE;
-      element.lineNum = 5;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        '#5'
-      );
-
-      element.lineNum = undefined;
-      element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(
-        element._computeDisplayLine(element.lineNum, element.range),
-        ''
-      );
-    });
-  });
-});
-
-suite('comment action tests with unresolved thread', () => {
-  let element: GrCommentThread;
-  let addDraftServiceStub: SinonStub;
-  let saveDiffDraftStub: SinonStub;
-  let comment = {
-    id: '7afa4931_de3d65bd',
-    path: '/path/to/file.txt',
-    line: 5,
-    in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-    updated: '2015-12-21 02:01:10.850000000',
-    message: 'Done',
-  };
-  const peanutButterComment = {
-    author: {
-      name: 'Mr. Peanutbutter',
-      email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
-    },
-    id: 'baf0414d_60047215' as UrlEncodedCommentId,
-    line: 5,
-    in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-    message: 'is this a crossover episode!?',
-    updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-    path: '/path/to/file.txt',
-    unresolved: true,
-    patch_set: 3 as PatchSetNum,
-  };
-  const mockResponse: Response = {
-    ...new Response(),
-    headers: {} as Headers,
-    redirected: false,
-    status: 200,
-    statusText: '',
-    type: '' as ResponseType,
-    url: '',
-    ok: true,
-    text() {
-      return Promise.resolve(")]}'\n" + JSON.stringify(comment));
-    },
-  };
-  let saveDiffDraftPromiseResolver: (value?: Response) => void;
-  setup(() => {
-    addDraftServiceStub = stubComments('addDraft');
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    saveDiffDraftStub = stubRestApi('saveDiffDraft').returns(
-      new Promise<Response>(
-        resolve =>
-          (saveDiffDraftPromiseResolver = resolve as (value?: Response) => void)
-      )
-    );
-    stubRestApi('deleteDiffDraft').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    element = withCommentFixture.instantiate();
-    element.patchNum = 1 as PatchSetNum;
-    element.changeNum = 1 as NumericChangeId;
-    element.comments = [peanutButterComment];
-    flush();
-  });
-
-  test('reply', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const replyBtn = element.$.replyBtn;
-    tap(replyBtn);
-    flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.isOk(draft);
-    assert.notOk(draft.message, 'message should be empty');
-    assert.equal(
-      draft.in_reply_to,
-      'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
-    );
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.ok(commentEl);
-
-    const quoteBtn = element.$.quoteBtn;
-    tap(quoteBtn);
-    flush();
-
-    const draft = addDraftServiceStub.firstCall.args[0];
-    // the quote reply is not autmatically saved so verify that id is not set
-    assert.isNotOk(draft.id);
-    // verify that the draft returned was not saved
-    assert.isNotOk(saveDiffDraftStub.called);
-    assert.equal(draft.message, '> is this a crossover episode!?\n\n');
-    assert.equal(
-      draft.in_reply_to,
-      'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
-    );
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('quote reply multiline', () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    const reportStub = stubReporting('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 draft = addDraftServiceStub.firstCall.args[0];
-    assert.equal(
-      draft.message,
-      '> is this a crossover episode!?\n> It might be!\n\n'
-    );
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('ack', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    comment = {
-      id: '7afa4931_de3d65bd',
-      path: '/path/to/file.txt',
-      line: 5,
-      in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-      updated: '2015-12-21 02:01:10.850000000',
-      message: 'Ack',
-    };
-    const reportStub = stubReporting('recordDraftInteraction');
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
-    assert.isOk(ackBtn);
-    tap(ackBtn!);
-    await flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    assert.equal(draft.message, 'Ack');
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isNotOk(draft.unresolved);
-    assert.isTrue(reportStub.calledOnce);
-  });
-
-  test('done', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    comment = {
-      id: '7afa4931_de3d65bd',
-      path: '/path/to/file.txt',
-      line: 5,
-      in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
-      updated: '2015-12-21 02:01:10.850000000',
-      message: 'Done',
-    };
-    const reportStub = stubReporting('recordDraftInteraction');
-    assert.isFalse(saveDiffDraftStub.called);
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
-    assert.isOk(doneBtn);
-    tap(doneBtn!);
-    await flush();
-    const draft = addDraftServiceStub.firstCall.args[0];
-    // Since the reply is automatically saved, verify that draft.id is set in
-    // the model
-    assert.equal(draft.id, '7afa4931_de3d65bd');
-    assert.equal(draft.message, 'Done');
-    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
-    assert.isNotOk(draft.unresolved);
-    assert.isTrue(reportStub.calledOnce);
-    assert.isTrue(saveDiffDraftStub.called);
-  });
-
-  test('save', async () => {
-    saveDiffDraftPromiseResolver(mockResponse);
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-
-    element.shadowRoot?.querySelector('gr-comment')?._fireSave();
-
-    await flush();
-    assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
-  });
-
-  test('please fix', async () => {
-    comment = peanutButterComment;
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
-    const promise = mockPromise();
-    commentEl!.addEventListener('create-fix-comment', async () => {
-      assert.isTrue(saveDiffDraftStub.called);
-      assert.isFalse(addDraftServiceStub.called);
-      saveDiffDraftPromiseResolver(mockResponse);
-      // flushing so the saveDiffDraftStub resolves and the draft is returned
-      await flush();
-      assert.isTrue(saveDiffDraftStub.called);
-      assert.isTrue(addDraftServiceStub.called);
-      const draft = saveDiffDraftStub.firstCall.args[2];
-      assert.equal(
-        draft.message,
-        '> is this a crossover episode!?\n\nPlease fix.'
-      );
-      assert.equal(
-        draft.in_reply_to,
-        'baf0414d_60047215' as UrlEncodedCommentId
-      );
-      assert.isTrue(draft.unresolved);
-      promise.resolve();
-    });
-    assert.isFalse(saveDiffDraftStub.called);
-    assert.isFalse(addDraftServiceStub.called);
-    commentEl!.dispatchEvent(
-      new CustomEvent('create-fix-comment', {
-        detail: {comment: commentEl!.comment},
-        composed: true,
-        bubbles: false,
-      })
-    );
-    await promise;
-  });
-
-  test('discard', async () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    assert.isOk(element.comments[0]);
-    const deleteDraftStub = stubComments('deleteDraft');
-    element.push(
-      'comments',
-      element._newReply(
-        element.comments[0]!.id as UrlEncodedCommentId,
-        'it’s pronouced jiff, not giff'
-      )
-    );
-    await flush();
-
-    const draftEl = element.root?.querySelectorAll('gr-comment')[1];
-    assert.ok(draftEl);
-    draftEl?._fireSave(); // tell the model about the draft
-    const promise = mockPromise();
-    draftEl!.addEventListener('comment-discard', () => {
-      assert.isTrue(deleteDraftStub.called);
-      promise.resolve();
-    });
-    draftEl!._fireDiscard();
-    await promise;
-  });
-
-  test('discard with a single comment still fires event with previous rootId', async () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
-    element.path = '/path/to/file.txt';
-    element.comments = [];
-    element.addOrEditDraft(1 as LineNumber);
-    const draft = addDraftServiceStub.firstCall.args[0];
-    element.comments = [draft];
-    flush();
-    const rootId = element.rootId;
-    assert.isOk(rootId);
-    flush();
-    const draftEl = element.root?.querySelectorAll('gr-comment')[0];
-    assert.ok(draftEl);
-    const deleteDraftStub = stubComments('deleteDraft');
-    const promise = mockPromise();
-    draftEl!.addEventListener('comment-discard', () => {
-      assert.isTrue(deleteDraftStub.called);
-      promise.resolve();
-    });
-    draftEl!._fireDiscard();
-    await promise;
-    assert.isTrue(deleteDraftStub.called);
-  });
-
-  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,
-          path: 'abcd',
-          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,
-          path: 'abcd',
-        },
-        {
-          id: 'sally_to_dr_finklestein' as UrlEncodedCommentId,
-          in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-        {
-          id: 'sallys_defiance' as UrlEncodedCommentId,
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000' as Timestamp,
-          path: 'abcd',
-        },
-      ];
-    });
-
-    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);
-      const draft = addDraftServiceStub.firstCall.args[0];
-      element.comments = [...element.comments, draft];
-      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);
-      const draft = addDraftServiceStub.firstCall.args[0];
-      element.comments = [...element.comments, draft];
-      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;
-    let draft;
-    element.comments = [];
-    element.path = 'abcd';
-    element.addDraft(undefined, undefined, unresolved);
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, true);
-
-    unresolved = false; // comment should get added as actually resolved.
-    element.comments = [];
-    element.addDraft(undefined, undefined, unresolved);
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.unresolved, false);
-
-    element.comments = [];
-    element.addDraft();
-    draft = addDraftServiceStub.lastCall.args[0];
-    assert.equal(draft.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.path = 'abcd';
-    element.addOrEditDraft(1);
-    const draft = addDraftServiceStub.firstCall.args[0];
-    element.comments = [draft];
-    flush();
-    assert.equal(element.comments.length, 1);
-    // Mock a submitted comment.
-    element.comments[0].id = (element.comments[0] as UIDraft)
-      .__draftID as UrlEncodedCommentId;
-    delete (element.comments[0] as UIDraft).__draft;
-    element.addOrEditDraft(1);
-    assert.equal(addDraftServiceStub.callCount, 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(() => {
+  setup(async () => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('saveDiffDraft').returns(
-      Promise.resolve({
-        ...new Response(),
-        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',
-              })
-          );
-        },
-      })
-    );
-    stubRestApi('deleteDiffDraft').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
-    element = withCommentFixture.instantiate();
-    element.patchNum = 1 as PatchSetNum;
+    element = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
     element.changeNum = 1 as NumericChangeId;
-    element.comments = [
+    element.showFileName = true;
+    element.showFilePath = true;
+    element.repoName = 'test-repo-name' as RepoName;
+    await element.updateComplete;
+    element.account = {...createAccountDetailWithId(13), name: 'Yoda'};
+  });
+
+  test('renders with draft', async () => {
+    element.thread = createThread(c1, c2, c3);
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="fileName">
+          <a href="/c/test-repo-name/+/1/1/test-path-comment-thread">
+            test-path-comment-thread
+          </a>
+          <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+        </div>
+        <div class="pathInfo">
+          <a href="/c/test-repo-name/+/1/comment/the-root/"> #314 </a>
+        </div>
+        <div id="container">
+          <h3 class="assistive-tech-only">Draft Comment thread by Kermit</h3>
+          <div class="comment-box" tabindex="0">
+            <gr-comment
+              collapsed=""
+              initially-collapsed=""
+              robot-button-disabled=""
+              show-patchset=""
+            ></gr-comment>
+            <gr-comment
+              collapsed=""
+              initially-collapsed=""
+              robot-button-disabled=""
+              show-patchset=""
+            ></gr-comment>
+            <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('renders unsaved', async () => {
+    element.thread = createThread();
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="fileName">
+          <span>test-path-comment-thread</span>
+          <gr-copy-clipboard hideinput=""></gr-copy-clipboard>
+        </div>
+        <div class="pathInfo">
+          <span>#314</span>
+        </div>
+        <div id="container">
+          <h3 class="assistive-tech-only">
+            Unresolved Draft Comment thread by Yoda
+          </h3>
+          <div class="comment-box unresolved" tabindex="0">
+            <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('renders with actions resolved', async () => {
+    element.thread = createThread(c1, c2);
+    await element.updateComplete;
+    assert.dom.equal(
+      queryAndAssert(element, '#container'),
+      /* HTML */ `
+        <div id="container">
+          <h3 class="assistive-tech-only">Comment thread by Kermit</h3>
+          <div class="comment-box" tabindex="0">
+            <gr-comment
+              collapsed=""
+              initially-collapsed=""
+              show-patchset=""
+            ></gr-comment>
+            <gr-comment
+              collapsed=""
+              initially-collapsed=""
+              show-patchset=""
+            ></gr-comment>
+            <div id="actionsContainer">
+              <span id="unresolvedLabel"> Resolved </span>
+              <div id="actions">
+                <gr-button
+                  aria-disabled="false"
+                  class="action reply"
+                  id="replyBtn"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Reply
+                </gr-button>
+                <gr-button
+                  aria-disabled="false"
+                  class="action quote"
+                  id="quoteBtn"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Quote
+                </gr-button>
+                <gr-icon
+                  icon="link"
+                  class="copy link-icon"
+                  role="button"
+                  tabindex="0"
+                  title="Copy link to this comment"
+                ></gr-icon>
+              </div>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('renders with actions unresolved', async () => {
+    element.thread = createThread(c1, {...c2, unresolved: true});
+    await element.updateComplete;
+    assert.dom.equal(
+      queryAndAssert(element, '#container'),
+      /* HTML */ `
+        <div id="container">
+          <h3 class="assistive-tech-only">
+            Unresolved Comment thread by Kermit
+          </h3>
+          <div class="comment-box unresolved" tabindex="0">
+            <gr-comment show-patchset=""></gr-comment>
+            <gr-comment show-patchset=""></gr-comment>
+            <div id="actionsContainer">
+              <span id="unresolvedLabel"> Unresolved </span>
+              <div id="actions">
+                <gr-button
+                  aria-disabled="false"
+                  class="action reply"
+                  id="replyBtn"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Reply
+                </gr-button>
+                <gr-button
+                  aria-disabled="false"
+                  class="action quote"
+                  id="quoteBtn"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Quote
+                </gr-button>
+                <gr-button
+                  aria-disabled="false"
+                  class="action ack"
+                  id="ackBtn"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Ack
+                </gr-button>
+                <gr-button
+                  aria-disabled="false"
+                  class="action done"
+                  id="doneBtn"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Done
+                </gr-button>
+                <gr-icon
+                  icon="link"
+                  class="copy link-icon"
+                  role="button"
+                  tabindex="0"
+                  title="Copy link to this comment"
+                ></gr-icon>
+              </div>
+            </div>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('renders with diff', async () => {
+    element.showCommentContext = true;
+    element.thread = createThread(commentWithContext);
+    await element.updateComplete;
+    assert.dom.equal(
+      queryAndAssert(element, '.diff-container'),
+      /* HTML */ `
+        <div class="diff-container">
+          <gr-diff
+            class="disable-context-control-buttons hide-line-length-indicator no-left"
+            id="diff"
+            style="--line-limit-marker:100ch; --content-width:none; --diff-max-width:none; --font-size:12px;"
+          >
+          </gr-diff>
+          <div class="view-diff-container">
+            <a href="/c/test-repo-name/+/1/comment/the-draft/">
+              <gr-button
+                aria-disabled="false"
+                class="view-diff-button"
+                link=""
+                role="button"
+                tabindex="0"
+              >
+                View Diff
+              </gr-button>
+            </a>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  suite('action button clicks', () => {
+    let savePromise: MockPromise<DraftInfo>;
+    let stub: SinonStub;
+
+    setup(async () => {
+      savePromise = mockPromise<DraftInfo>();
+      stub = sinon
+        .stub(testResolver(commentsModelToken), 'saveDraft')
+        .returns(savePromise);
+
+      element.thread = createThread(c1, {...c2, unresolved: true});
+      await element.updateComplete;
+    });
+
+    test('handle Ack', async () => {
+      queryAndAssert<GrButton>(element, '#ackBtn').click();
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, 'Ack');
+      assert.equal(stub.lastCall.firstArg.unresolved, false);
+      assert.isTrue(element.saving);
+
+      savePromise.resolve();
+      await element.updateComplete;
+      assert.isFalse(element.saving);
+    });
+
+    test('handle Done', async () => {
+      queryAndAssert<GrButton>(element, '#doneBtn').click();
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, 'Done');
+      assert.equal(stub.lastCall.firstArg.unresolved, false);
+    });
+
+    test('handle Reply', async () => {
+      assert.isUndefined(element.unsavedComment);
+      queryAndAssert<GrButton>(element, '#replyBtn').click();
+      assert.equal(element.unsavedComment?.message, '');
+    });
+
+    test('handle Quote', async () => {
+      assert.isUndefined(element.unsavedComment);
+      queryAndAssert<GrButton>(element, '#quoteBtn').click();
+      assert.equal(element.unsavedComment?.message?.trim(), `> ${c2.message}`);
+    });
+  });
+
+  suite('self removal when empty thread changed to editing:false', () => {
+    let threadEl: GrCommentThread;
+
+    setup(async () => {
+      threadEl = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
+      threadEl.thread = createThread();
+    });
+
+    test('new thread el normally has a parent and an unsaved comment', async () => {
+      await waitUntil(() => threadEl.editing);
+      assert.isOk(threadEl.unsavedComment);
+      assert.isOk(threadEl.parentElement);
+    });
+
+    test('thread el removed after clicking CANCEL', async () => {
+      await waitUntil(() => threadEl.editing);
+
+      const commentEl = queryAndAssert(threadEl, 'gr-comment');
+      const buttonEl = queryAndAssert<GrButton>(commentEl, 'gr-button.cancel');
+      buttonEl.click();
+
+      await waitUntil(() => !threadEl.editing);
+      assert.isNotOk(threadEl.parentElement);
+    });
+  });
+
+  test('comments are sorted correctly', () => {
+    const comments: CommentInfo[] = [
       {
-        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,
+        id: 'jacks_confession' as UrlEncodedCommentId,
+        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, too',
+        updated: '2015-12-25 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: '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,
       },
     ];
-    flush();
+    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,
+      },
+      {
+        id: 'jacks_confession' as UrlEncodedCommentId,
+        in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
+        message: 'i like you, too',
+        updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+      },
+    ]);
   });
 
-  test('ack and done should be hidden', () => {
-    element.changeNum = 42 as NumericChangeId;
-    element.patchNum = 1 as PatchSetNum;
+  test('patchset comments link to /comments URL', async () => {
+    const clipboardStub = sinon.stub(navigator.clipboard, 'writeText');
+    element.thread = {
+      ...createThread(c1),
+      path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+    };
+    await element.updateComplete;
 
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
+    queryAndAssert<GrIcon>(element, 'gr-icon.copy').click();
 
-    const ackBtn = element.shadowRoot?.querySelector('#ackBtn');
-    const doneBtn = element.shadowRoot?.querySelector('#doneBtn');
-    assert.equal(ackBtn, null);
-    assert.equal(doneBtn, null);
+    assert.equal(1, clipboardStub.callCount);
+    assert.equal(
+      clipboardStub.firstCall.args[0],
+      'http://localhost:9876/c/test-repo-name/+/1/comments/the-root'
+    );
   });
 
-  test('reply and quote button should be visible', () => {
-    const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    assert.ok(commentEl);
+  test('file comments link to /comment URL', async () => {
+    const clipboardStub = sinon.stub(navigator.clipboard, 'writeText');
+    element.thread = createThread(c1);
+    await element.updateComplete;
 
-    const replyBtn = element.shadowRoot?.querySelector('#replyBtn');
-    const quoteBtn = element.shadowRoot?.querySelector('#quoteBtn');
-    assert.ok(replyBtn);
-    assert.ok(quoteBtn);
+    queryAndAssert<GrIcon>(element, 'gr-icon.copy').click();
+
+    assert.equal(1, clipboardStub.callCount);
+    assert.equal(
+      clipboardStub.firstCall.args[0],
+      'http://localhost:9876/c/test-repo-name/+/1/comment/the-root/'
+    );
   });
 });
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 4f6702d..66beaf1 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -1,149 +1,112 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../gr-button/gr-button';
 import '../gr-dialog/gr-dialog';
 import '../gr-formatted-text/gr-formatted-text';
-import '../gr-icons/gr-icons';
-import '../gr-overlay/gr-overlay';
+import '../gr-icon/gr-icon';
 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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-comment_html';
-import {getRootElement} from '../../../scripts/rootElement';
-import {appContext} from '../../../services/app-context';
-import {customElement, observe, property} from '@polymer/decorators';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getAppContext} from '../../../services/app-context';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
-import {GrOverlay} from '../gr-overlay/gr-overlay';
 import {
   AccountDetailInfo,
-  BasePatchSetNum,
-  ConfigInfo,
   NumericChangeId,
-  PatchSetNum,
   RepoName,
+  RobotCommentInfo,
 } from '../../../types/common';
-import {GrButton} from '../gr-button/gr-button';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {
-  isDraft,
+  Comment,
+  createUserFixSuggestion,
+  DraftInfo,
+  getContentInCommentRange,
+  getUserSuggestion,
+  hasUserSuggestion,
+  isDraftOrUnsaved,
   isRobot,
-  UIComment,
-  UIDraft,
-  UIRobot,
+  isUnsaved,
+  NEWLINE_PATTERN,
+  USER_SUGGESTION_START_PATTERN,
 } from '../../../utils/comment-util';
-import {OpenFixPreviewEventDetail} from '../../../types/events';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
-import {pluralize} from '../../../utils/string-util';
-import {assertIsDefined} from '../../../utils/common-util';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {StorageLocation} from '../../../services/storage/gr-storage';
-import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
+import {
+  OpenFixPreviewEventDetail,
+  ReplyToCommentEventDetail,
+  ValueChangedEvent,
+} from '../../../types/events';
+import {fire, fireEvent} from '../../../utils/event-util';
+import {assertIsDefined, assert} from '../../../utils/common-util';
+import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subscribe} from '../../lit/subscription-controller';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {classMap} from 'lit/directives/class-map.js';
+import {LineNumber} from '../../../api/diff';
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
+import {Subject} from 'rxjs';
+import {debounceTime} from 'rxjs/operators';
+import {changeModelToken} from '../../../models/change/change-model';
 import {Interaction} from '../../../constants/reporting';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {isBase64FileContent} from '../../../api/rest-api';
+import {createDiffUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
-const STORAGE_DEBOUNCE_INTERVAL = 400;
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-const SAVED_MESSAGE = 'All changes saved';
 const UNSAVED_MESSAGE = 'Unable to save draft';
 
-const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
-
 const FILE = 'FILE';
 
+// visible for testing
+export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
+
 export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
 
-/**
- * All candidates tips to show, will pick randomly.
- */
-const RESPECTFUL_REVIEW_TIPS = [
-  'Assume competence.',
-  'Provide rationale or context.',
-  'Consider how comments may be interpreted.',
-  'Avoid harsh language.',
-  'Make your comments specific and actionable.',
-  'When disagreeing, explain the advantage of your approach.',
-];
-
-interface CommentOverlays {
-  confirmDelete?: GrOverlay | null;
-  confirmDiscard?: GrOverlay | null;
+declare global {
+  interface HTMLElementEventMap {
+    'comment-editing-changed': CustomEvent<CommentEditingChangedDetail>;
+    'comment-unresolved-changed': ValueChangedEvent<boolean>;
+    'comment-text-changed': ValueChangedEvent<string>;
+    'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
+  }
 }
 
-export interface GrComment {
-  $: {
-    container: HTMLDivElement;
-    resolvedCheckbox: HTMLInputElement;
-    header: HTMLDivElement;
-  };
+export interface CommentAnchorTapEventDetail {
+  number: LineNumber;
+  side?: CommentSide;
+}
+
+export interface CommentEditingChangedDetail {
+  editing: boolean;
+  path: string;
 }
 
 @customElement('gr-comment')
-export class GrComment extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrComment extends LitElement {
   /**
-   * Fired when the create fix comment action is triggered.
+   * Fired when the parent thread component should create a reply.
    *
-   * @event create-fix-comment
+   * @event reply-to-comment
    */
 
   /**
-   * Fired when the show fix preview action is triggered.
+   * Fired when the open fix preview action is triggered.
    *
    * @event open-fix-preview
    */
 
   /**
-   * Fired when this comment is discarded.
-   *
-   * @event comment-discard
-   */
-
-  /**
-   * Fired when this comment is edited.
-   *
-   * @event comment-edit
-   */
-
-  /**
-   * Fired when this comment is saved.
-   *
-   * @event comment-save
-   */
-
-  /**
-   * Fired when this comment is updated.
-   *
-   * @event comment-update
-   */
-
-  /**
    * Fired when editing status changed.
    *
    * @event comment-editing-changed
@@ -155,192 +118,898 @@
    * @event comment-anchor-tap
    */
 
-  @property({type: Number})
-  changeNum?: NumericChangeId;
+  @query('#editTextarea')
+  textarea?: GrTextarea;
 
-  @property({type: String})
-  projectName?: RepoName;
+  @query('#container')
+  container?: HTMLElement;
 
-  @property({type: Object, notify: true, observer: '_commentChanged'})
-  comment?: UIComment;
+  @query('#resolvedCheckbox')
+  resolvedCheckbox?: HTMLInputElement;
 
+  @query('#confirmDeleteModal')
+  confirmDeleteModal?: HTMLDialogElement;
+
+  @query('#confirmDeleteCommentDialog')
+  confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
+
+  @property({type: Object})
+  comment?: Comment;
+
+  // TODO: Move this out of gr-comment. gr-comment should not have a comments
+  // property. This is only used for hasHumanReply at the moment.
   @property({type: Array})
-  comments?: UIComment[];
-
-  @property({type: Boolean, reflectToAttribute: true})
-  isRobotComment = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  disabled = false;
-
-  @property({type: Boolean, observer: '_draftChanged'})
-  draft = false;
-
-  @property({type: Boolean, observer: '_editingChanged'})
-  editing = false;
-
-  // Assigns a css property to the comment hiding the comment while it's being
-  // discarded
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-  })
-  discarding = false;
-
-  @property({type: Boolean})
-  hasChildren?: boolean;
-
-  @property({type: String})
-  patchNum?: PatchSetNum;
-
-  @property({type: Boolean})
-  showActions?: boolean;
-
-  @property({type: Boolean})
-  _showHumanActions?: boolean;
-
-  @property({type: Boolean})
-  _showRobotActions?: boolean;
-
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-    observer: '_toggleCollapseClass',
-  })
-  collapsed = true;
-
-  @property({type: Object})
-  projectConfig?: ConfigInfo;
-
-  @property({type: Boolean})
-  robotButtonDisabled = false;
-
-  @property({type: Boolean})
-  _hasHumanReply?: boolean;
-
-  @property({type: Boolean})
-  _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})
-  side?: string;
-
-  @property({type: Boolean})
-  resolved = false;
-
-  // Intentional to share the object across instances.
-  @property({type: Object})
-  _numPendingDraftRequests: {number: number} = {number: 0};
-
-  @property({type: Boolean})
-  _enableOverlay = false;
+  comments?: Comment[];
 
   /**
-   * Property for storing references to overlay elements. When the overlays
-   * are moved to getRootElement() to be shown they are no-longer
-   * children, so they can't be queried along the tree, so they are stored
-   * here.
+   * Initial collapsed state of the comment.
    */
-  @property({type: Object})
-  _overlays: CommentOverlays = {};
+  @property({type: Boolean, attribute: 'initially-collapsed'})
+  initiallyCollapsed?: boolean;
 
-  @property({type: Boolean})
-  _showRespectfulTip = false;
+  /**
+   * Hide the header for patchset level comments used in GrReplyDialog.
+   */
+  @property({type: Boolean, attribute: 'hide-header'})
+  hideHeader = false;
 
-  @property({type: Boolean})
-  showPatchset = true;
+  /**
+   * This is the *current* (internal) collapsed state of the comment. Do not set
+   * from the outside. Use `initiallyCollapsed` instead. This is just a
+   * reflected property such that css rules can be based on it.
+   */
+  @property({type: Boolean, reflect: true})
+  collapsed?: boolean;
+
+  @property({type: Boolean, attribute: 'robot-button-disabled'})
+  robotButtonDisabled = false;
 
   @property({type: String})
-  _respectfulReviewTip?: string;
+  messagePlaceholder?: string;
+
+  /* private, but used in css rules */
+  @property({type: Boolean, reflect: true})
+  saving = false;
+
+  // GrReplyDialog requires the patchset level comment to always remain
+  // editable.
+  @property({type: Boolean, attribute: 'permanent-editing-mode'})
+  permanentEditingMode = false;
+
+  /**
+   * `saving` and `autoSaving` are separate and cannot be set at the same time.
+   * `saving` affects the UI state (disabled buttons, etc.) and eventually
+   * leaves editing mode, but `autoSaving` just happens in the background
+   * without the user noticing.
+   */
+  @state()
+  autoSaving?: Promise<DraftInfo>;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  editing = false;
+
+  @state()
+  repoName?: RepoName;
+
+  /* The 'dirty' state of the comment.message, which will be saved on demand. */
+  @state()
+  messageText = '';
+
+  /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
+  @state()
+  unresolved = true;
 
   @property({type: Boolean})
-  _respectfulTipDismissed = false;
+  unableToSave = false;
 
-  @property({type: Boolean})
-  _unableToSave = false;
+  @property({type: Boolean, attribute: 'show-patchset'})
+  showPatchset = false;
 
-  @property({type: Object})
-  _selfAccount?: AccountDetailInfo;
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-ported-comment'})
   showPortedComment = false;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  @state()
+  account?: AccountDetailInfo;
 
-  private readonly restApiService = appContext.restApiService;
+  @state()
+  isAdmin = false;
 
-  private readonly storage = appContext.storageService;
+  @state()
+  isOwner = false;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly commentsService = appContext.commentsService;
+  private readonly reporting = getAppContext().reportingService;
 
-  private fireUpdateTask?: DelayedTask;
+  private readonly flagsService = getAppContext().flagsService;
 
-  private storeTask?: DelayedTask;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private draftToastTask?: DelayedTask;
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly shortcuts = new ShortcutController(this);
+
+  /**
+   * This is triggered when the user types into the editing textarea. We then
+   * debounce it and call autoSave().
+   */
+  private autoSaveTrigger$ = new Subject();
+
+  /**
+   * Set to the content of DraftInfo when entering editing mode.
+   * Only used for "Cancel".
+   */
+  private originalMessage = '';
+
+  /**
+   * Set to the content of DraftInfo when entering editing mode.
+   * Only used for "Cancel".
+   */
+  private originalUnresolved = false;
+
+  constructor() {
+    super();
+    // Allow the shortcuts to bubble up so that GrReplyDialog can respond to
+    // them as well.
+    this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc(), {
+      preventDefault: false,
     });
-    if (this.editing) {
-      this.collapsed = false;
-    } else if (this.comment) {
-      this.collapsed = !!this.comment.collapsed;
+    for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
+      this.shortcuts.addLocal(
+        {key: Key.ENTER, modifiers: [modifier]},
+        () => {
+          this.save();
+        },
+        {preventDefault: false}
+      );
     }
-    this._getIsAdmin().then(isAdmin => {
-      this._isAdmin = !!isAdmin;
-    });
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+    // For Ctrl+s add shorctut with preventDefault so that it does
+    // not bubble up to the browser
+    for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
+      this.shortcuts.addLocal({key: 's', modifiers: [modifier]}, () => {
+        this.save();
+      });
+    }
+    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+      this.messagePlaceholder = 'Mention others with @';
+    }
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.account = x)
     );
-    for (const key of ['s', Key.ENTER]) {
-      for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
-        addShortcut(this, {key, modifiers: [modifier]}, e =>
-          this._handleSaveKey(e)
-        );
+    subscribe(
+      this,
+      () => this.getUserModel().isAdmin$,
+      x => (this.isAdmin = x)
+    );
+
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repoName = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().isOwner$,
+      x => (this.isOwner = x)
+    );
+    subscribe(
+      this,
+      () =>
+        this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
+      () => {
+        this.autoSave();
       }
-    }
+    );
   }
 
   override disconnectedCallback() {
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
-    this.fireUpdateTask?.cancel();
-    this.storeTask?.cancel();
-    this.draftToastTask?.cancel();
-    if (this.textarea) {
-      this.textarea.closeDropdown();
+    // Clean up emoji dropdown.
+    if (this.textarea) this.textarea.closeDropdown();
+    if (this.editing) {
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED
+      );
     }
     super.disconnectedCallback();
   }
 
-  /** 2nd argument is for *triggering* the computation only. */
-  _getAuthor(comment?: UIComment, _?: unknown) {
-    return comment?.author || this._selfAccount;
+  static override get styles() {
+    return [
+      sharedStyles,
+      modalStyles,
+      css`
+        :host {
+          display: block;
+          font-family: var(--font-family);
+          padding: var(--spacing-m);
+        }
+        :host([collapsed]) {
+          padding: var(--spacing-s) var(--spacing-m);
+        }
+        :host([saving]) {
+          pointer-events: none;
+        }
+        :host([saving]) .actions,
+        :host([saving]) .robotActions,
+        :host([saving]) .date {
+          opacity: 0.5;
+        }
+        .header {
+          align-items: center;
+          cursor: pointer;
+          display: flex;
+          padding-bottom: var(--spacing-m);
+        }
+        :host([collapsed]) .header {
+          padding-bottom: 0px;
+        }
+        .headerLeft > span {
+          font-weight: var(--font-weight-bold);
+        }
+        .headerMiddle {
+          color: var(--deemphasized-text-color);
+          flex: 1;
+          overflow: hidden;
+        }
+        .draftTooltip {
+          font-weight: var(--font-weight-bold);
+          display: inline;
+        }
+        .draftTooltip gr-icon {
+          color: var(--info-foreground);
+        }
+        .date {
+          justify-content: flex-end;
+          text-align: right;
+          white-space: nowrap;
+        }
+        span.date {
+          color: var(--deemphasized-text-color);
+        }
+        span.date:hover {
+          text-decoration: underline;
+        }
+        .actions,
+        .robotActions {
+          display: flex;
+          justify-content: flex-end;
+          padding-top: 0;
+        }
+        .robotActions {
+          /* Better than the negative margin would be to remove the gr-button
+       * padding, but then we would also need to fix the buttons that are
+       * inserted by plugins. :-/ */
+          margin: 4px 0 -4px;
+        }
+        .action {
+          margin-left: var(--spacing-l);
+        }
+        .rightActions {
+          display: flex;
+          justify-content: flex-end;
+        }
+        .rightActions gr-button {
+          --gr-button-padding: 0 var(--spacing-s);
+        }
+        .editMessage {
+          display: block;
+          margin-bottom: var(--spacing-m);
+          width: 100%;
+        }
+        .show-hide {
+          margin-left: var(--spacing-s);
+        }
+        .robotId {
+          color: var(--deemphasized-text-color);
+          margin-bottom: var(--spacing-m);
+        }
+        .robotRun {
+          margin-left: var(--spacing-m);
+        }
+        .robotRunLink {
+          margin-left: var(--spacing-m);
+        }
+        /* just for a11y */
+        input.show-hide {
+          display: none;
+        }
+        label.show-hide {
+          cursor: pointer;
+          display: block;
+        }
+        label.show-hide gr-icon {
+          vertical-align: top;
+        }
+        :host([collapsed]) #container .body {
+          padding-top: 0;
+        }
+        #container .collapsedContent {
+          display: block;
+          overflow: hidden;
+          padding-left: var(--spacing-m);
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .resolve,
+        .unresolved {
+          align-items: center;
+          display: flex;
+          flex: 1;
+          margin: 0;
+        }
+        .resolve label {
+          color: var(--comment-text-color);
+        }
+        gr-dialog .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        #deleteBtn {
+          --gr-button-text-color: var(--deemphasized-text-color);
+          --gr-button-padding: 0;
+        }
+
+        /** Disable select for the caret and actions */
+        .actions,
+        .show-hide {
+          -webkit-user-select: none;
+          -moz-user-select: none;
+          -ms-user-select: none;
+          user-select: none;
+        }
+
+        .pointer {
+          cursor: pointer;
+        }
+        .patchset-text {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-s);
+        }
+        .headerLeft gr-account-label {
+          --account-max-length: 130px;
+          width: 150px;
+        }
+        .headerLeft gr-account-label::part(gr-account-label-text) {
+          font-weight: var(--font-weight-bold);
+        }
+        .draft gr-account-label {
+          width: unset;
+        }
+        .draft gr-formatted-text.message {
+          display: block;
+          margin-bottom: var(--spacing-m);
+        }
+        .portedMessage {
+          margin: 0 var(--spacing-m);
+        }
+        .link-icon {
+          margin-left: var(--spacing-m);
+          cursor: pointer;
+        }
+      `,
+    ];
   }
 
-  _getUrlForComment(comment?: UIComment) {
-    if (!comment || !this.changeNum || !this.projectName) return '';
+  override render() {
+    if (isUnsaved(this.comment) && !this.editing) return;
+    const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
+    return html`
+      <gr-endpoint-decorator name="comment">
+        <gr-endpoint-param name="comment" .value=${this.comment}>
+        </gr-endpoint-param>
+        <gr-endpoint-param name="editing" .value=${this.editing}>
+        </gr-endpoint-param>
+        <gr-endpoint-param name="message" .value=${this.messageText}>
+        </gr-endpoint-param>
+        <gr-endpoint-param
+          name="isDraft"
+          .value=${isDraftOrUnsaved(this.comment)}
+        >
+        </gr-endpoint-param>
+        <div id="container" class=${classMap(classes)}>
+          ${this.renderHeader()}
+          <div class="body">
+            ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
+            ${this.renderCommentMessage()}
+            <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+            ${this.renderHumanActions()} ${this.renderRobotActions()}
+            ${this.renderSuggestEditActions()}
+          </div>
+        </div>
+      </gr-endpoint-decorator>
+      ${this.renderConfirmDialog()}
+    `;
+  }
+
+  private renderHeader() {
+    if (this.hideHeader) return nothing;
+    return html`
+      <div
+        class="header"
+        id="header"
+        @click=${() => (this.collapsed = !this.collapsed)}
+      >
+        <div class="headerLeft">
+          ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
+          ${this.renderDraftLabel()}
+        </div>
+        <div class="headerMiddle">${this.renderCollapsedContent()}</div>
+        ${this.renderSuggestEditButton()} ${this.renderRunDetails()}
+        ${this.renderDeleteButton()} ${this.renderPatchset()}
+        ${this.renderDate()} ${this.renderToggle()}
+      </div>
+    `;
+  }
+
+  private renderAuthor() {
+    if (isDraftOrUnsaved(this.comment)) return;
+    if (isRobot(this.comment)) {
+      const id = this.comment.robot_id;
+      return html`<span class="robotName">${id}</span>`;
+    }
+    const classes = {draft: isDraftOrUnsaved(this.comment)};
+    return html`
+      <gr-account-label
+        .account=${this.comment?.author ?? this.account}
+        class=${classMap(classes)}
+      >
+      </gr-account-label>
+    `;
+  }
+
+  private renderPortedCommentMessage() {
+    if (!this.showPortedComment) return;
+    if (!this.comment?.patch_set) return;
+    return html`
+      <a href=${this.getUrlForComment()}>
+        <span class="portedMessage" @click=${this.handlePortedMessageClick}>
+          From patchset ${this.comment?.patch_set}
+        </span>
+      </a>
+    `;
+  }
+
+  private renderDraftLabel() {
+    if (!isDraftOrUnsaved(this.comment)) return;
+    let label = 'Draft';
+    let tooltip =
+      'This draft is only visible to you. ' +
+      "To publish drafts, click the 'Reply' or 'Start review' button " +
+      "at the top of the change or press the 'a' key.";
+    if (this.unableToSave) {
+      label += ' (Failed to save)';
+      tooltip = 'Unable to save draft. Please try to save again.';
+    }
+    return html`
+      <gr-tooltip-content
+        class="draftTooltip"
+        has-tooltip
+        title=${tooltip}
+        max-width="20em"
+      >
+        <gr-icon filled icon="rate_review"></gr-icon>
+        <span class="draftLabel">${label}</span>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderCollapsedContent() {
+    if (!this.collapsed) return;
+    return html`
+      <span class="collapsedContent">${this.comment?.message}</span>
+    `;
+  }
+
+  private renderRunDetails() {
+    if (!isRobot(this.comment)) return;
+    if (!this.comment?.url || this.collapsed) return;
+    return html`
+      <div class="runIdMessage message">
+        <div class="runIdInformation">
+          <a class="robotRunLink" href=${this.comment.url}>
+            <span class="robotRun link">Run Details</span>
+          </a>
+        </div>
+      </div>
+    `;
+  }
+
+  /**
+   * Deleting a comment is an admin feature. It means more than just discarding
+   * a draft. It is an action applied to published comments.
+   */
+  private renderDeleteButton() {
+    if (
+      !this.isAdmin ||
+      isDraftOrUnsaved(this.comment) ||
+      isRobot(this.comment)
+    )
+      return;
+    if (this.collapsed) return;
+    return html`
+      <gr-button
+        id="deleteBtn"
+        title="Delete Comment"
+        link
+        class="action delete"
+        @click=${(e: MouseEvent) => {
+          e.stopPropagation();
+          this.openDeleteCommentModal();
+        }}
+      >
+        <gr-icon id="icon" icon="delete" filled></gr-icon>
+      </gr-button>
+    `;
+  }
+
+  private renderPatchset() {
+    if (!this.showPatchset) return;
+    assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+    return html`
+      <span class="patchset-text"> Patchset ${this.comment.patch_set}</span>
+    `;
+  }
+
+  private renderDate() {
+    if (!this.comment?.updated || this.collapsed) return;
+    return html`
+      <span class="separator"></span>
+      <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
+        <gr-date-formatter
+          withTooltip
+          .dateStr=${this.comment.updated}
+        ></gr-date-formatter>
+      </span>
+    `;
+  }
+
+  private renderToggle() {
+    const icon = this.collapsed ? 'expand_more' : 'expand_less';
+    const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
+    return html`
+      <div class="show-hide" tabindex="0">
+        <label class="show-hide" aria-label=${ariaLabel}>
+          <input
+            type="checkbox"
+            class="show-hide"
+            ?checked=${this.collapsed}
+            @change=${() => (this.collapsed = !this.collapsed)}
+          />
+          <gr-icon icon=${icon} id="icon"></gr-icon>
+        </label>
+      </div>
+    `;
+  }
+
+  private renderRobotAuthor() {
+    if (!isRobot(this.comment) || this.collapsed) return;
+    return html`<div class="robotId">${this.comment.author?.name}</div>`;
+  }
+
+  private renderEditingTextarea() {
+    if (!this.editing || this.collapsed) return;
+    return html`
+      <gr-textarea
+        id="editTextarea"
+        class="editMessage"
+        autocomplete="on"
+        code=""
+        ?disabled=${this.saving}
+        rows="4"
+        .placeholder=${this.messagePlaceholder}
+        text=${this.messageText}
+        @text-changed=${(e: ValueChangedEvent) => {
+          // TODO: This is causing a re-render of <gr-comment> on every key
+          // press. Try to avoid always setting `this.messageText` or at least
+          // debounce it. Most of the code can just inspect the current value
+          // of the textare instead of needing a dedicated property.
+          this.messageText = e.detail.value;
+          this.autoSaveTrigger$.next();
+        }}
+      ></gr-textarea>
+    `;
+  }
+
+  private renderCommentMessage() {
+    if (this.collapsed || this.editing) return;
+
+    return html`
+      <!--The "message" class is needed to ensure selectability from
+          gr-diff-selection.-->
+      <gr-formatted-text
+        class="message"
+        .markdown=${true}
+        .content=${this.comment?.message ?? ''}
+      ></gr-formatted-text>
+    `;
+  }
+
+  private renderCopyLinkIcon() {
+    // Only show the icon when the thread contains a published comment.
+    if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <gr-icon
+        icon="link"
+        class="copy link-icon"
+        @click=${this.handleCopyLink}
+        title="Copy link to this comment"
+        role="button"
+        tabindex="0"
+      ></gr-icon>
+    `;
+  }
+
+  private renderHumanActions() {
+    if (!this.account || isRobot(this.comment)) return;
+    if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <div class="actions">
+        <div class="action resolve">
+          <label>
+            <input
+              type="checkbox"
+              id="resolvedCheckbox"
+              ?checked=${!this.unresolved}
+              @change=${this.handleToggleResolved}
+            />
+            Resolved
+          </label>
+        </div>
+        ${this.renderDraftActions()}
+      </div>
+    `;
+  }
+
+  private renderDraftActions() {
+    if (!isDraftOrUnsaved(this.comment)) return;
+    return html`
+      <div class="rightActions">
+        ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
+        ${this.renderDiscardButton()} ${this.renderPreviewSuggestEditButton()}
+        ${this.renderEditButton()} ${this.renderCancelButton()}
+        ${this.renderSaveButton()} ${this.renderCopyLinkIcon()}
+      </div>
+    `;
+  }
+
+  private renderPreviewSuggestEditButton() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      return nothing;
+    }
+    assertIsDefined(this.comment, 'comment');
+    if (!hasUserSuggestion(this.comment)) return nothing;
+    return html`
+      <gr-button
+        link
+        secondary
+        class="action show-fix"
+        ?disabled=${this.saving}
+        @click=${this.handleShowFix}
+      >
+        Preview Fix
+      </gr-button>
+    `;
+  }
+
+  private renderSuggestEditButton() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      return nothing;
+    }
+    if (
+      !this.editing ||
+      this.permanentEditingMode ||
+      this.comment?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
+    ) {
+      return nothing;
+    }
+    assertIsDefined(this.comment, 'comment');
+    if (hasUserSuggestion(this.comment)) return nothing;
+    // TODO(milutin): remove this check once suggesting on commit message is
+    // fixed. Currently diff line doesn't match commit message line, because
+    // of metadata in diff, which aren't in content api request.
+    if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing;
+    // TODO(milutin): disable user suggestions for owners, after user study.
+    // if (this.isOwner) return nothing;
+    return html`<gr-button
+      link
+      class="action suggestEdit"
+      @click=${this.createSuggestEdit}
+      >Suggest Fix</gr-button
+    >`;
+  }
+
+  private renderDiscardButton() {
+    if (this.editing || this.permanentEditingMode) return;
+    return html`<gr-button
+      link
+      ?disabled=${this.saving}
+      class="action discard"
+      @click=${this.discard}
+      >Discard</gr-button
+    >`;
+  }
+
+  private renderEditButton() {
+    if (this.editing) return;
+    return html`<gr-button
+      link
+      ?disabled=${this.saving}
+      class="action edit"
+      @click=${this.edit}
+      >Edit</gr-button
+    >`;
+  }
+
+  private renderCancelButton() {
+    if (!this.editing || this.permanentEditingMode) return;
+    return html`
+      <gr-button
+        link
+        ?disabled=${this.saving}
+        class="action cancel"
+        @click=${this.cancel}
+        >Cancel</gr-button
+      >
+    `;
+  }
+
+  private renderSaveButton() {
+    if (!this.editing && !this.unableToSave) return;
+    return html`
+      <gr-button
+        link
+        ?disabled=${this.isSaveDisabled()}
+        class="action save"
+        @click=${this.handleSaveButtonClicked}
+        >${this.permanentEditingMode ? 'Preview' : 'Save'}</gr-button
+      >
+    `;
+  }
+
+  private renderRobotActions() {
+    if (!this.account || !isRobot(this.comment)) return;
+    const endpoint = html`
+      <gr-endpoint-decorator name="robot-comment-controls">
+        <gr-endpoint-param name="comment" .value=${this.comment}>
+        </gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+    return html`
+      <div class="robotActions">
+        ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()}
+        ${this.renderPleaseFixButton()}
+      </div>
+    `;
+  }
+
+  private renderSuggestEditActions() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+      return nothing;
+    }
+    if (
+      !this.account ||
+      isRobot(this.comment) ||
+      isDraftOrUnsaved(this.comment)
+    ) {
+      return nothing;
+    }
+    return html`
+      <div class="robotActions">${this.renderPreviewSuggestEditButton()}</div>
+    `;
+  }
+
+  private renderShowFixButton() {
+    if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
+    return html`
+      <gr-button
+        link
+        secondary
+        class="action show-fix"
+        ?disabled=${this.saving}
+        @click=${this.handleShowFix}
+      >
+        Show Fix
+      </gr-button>
+    `;
+  }
+
+  private renderPleaseFixButton() {
+    if (this.hasHumanReply()) return;
+    return html`
+      <gr-button
+        link
+        ?disabled=${this.robotButtonDisabled}
+        class="action fix"
+        @click=${this.handlePleaseFix}
+      >
+        Please Fix
+      </gr-button>
+    `;
+  }
+
+  private renderConfirmDialog() {
+    return html`
+      <dialog id="confirmDeleteModal" tabindex="-1">
+        <gr-confirm-delete-comment-dialog
+          id="confirmDeleteCommentDialog"
+          @confirm=${this.handleConfirmDeleteComment}
+          @cancel=${this.closeDeleteCommentModal}
+        >
+        </gr-confirm-delete-comment-dialog>
+      </dialog>
+    `;
+  }
+
+  private getUrlForComment() {
+    const comment = this.comment;
+    if (!comment || !this.changeNum || !this.repoName) return '';
     if (!comment.id) throw new Error('comment must have an id');
-    return GerritNav.getUrlForComment(
-      this.changeNum as NumericChangeId,
-      this.projectName,
-      comment.id
-    );
+    return createDiffUrl({
+      changeNum: this.changeNum,
+      repo: this.repoName,
+      commentId: comment.id,
+    });
   }
 
-  _handlePortedMessageClick() {
+  private firstWillUpdateDone = false;
+
+  firstWillUpdate() {
+    if (this.firstWillUpdateDone) return;
+    this.firstWillUpdateDone = true;
+    if (this.permanentEditingMode) this.editing = true;
+    assertIsDefined(this.comment, 'comment');
+    this.unresolved = this.comment.unresolved ?? true;
+    if (isUnsaved(this.comment)) this.editing = true;
+    if (isDraftOrUnsaved(this.comment)) {
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_FIRST_UPDATE,
+        {editing: this.editing, unsaved: isUnsaved(this.comment)}
+      );
+      this.collapsed = false;
+    } else {
+      this.collapsed = !!this.initiallyCollapsed;
+    }
+  }
+
+  override updated(changed: PropertyValues) {
+    if (changed.has('editing')) {
+      if (this.editing && !this.permanentEditingMode) {
+        whenVisible(this, () => this.textarea?.putCursorAtEnd());
+      }
+    }
+  }
+
+  override willUpdate(changed: PropertyValues) {
+    this.firstWillUpdate();
+    if (changed.has('editing')) {
+      this.onEditingChanged();
+    }
+    if (changed.has('unresolved')) {
+      // The <gr-comment-thread> component wants to change its color based on
+      // the (dirty) unresolved state, so let's notify it about changes.
+      fire(this, 'comment-unresolved-changed', {value: this.unresolved});
+    }
+    if (changed.has('messageText')) {
+      // GrReplyDialog updates it's state when text inside patchset level
+      // comment changes.
+      fire(this, 'comment-text-changed', {value: this.messageText});
+    }
+  }
+
+  private handlePortedMessageClick() {
     assertIsDefined(this.comment, 'comment');
     this.reporting.reportInteraction('navigate-to-original-comment', {
       line: this.comment.line,
@@ -348,757 +1017,283 @@
     });
   }
 
-  @observe('editing')
-  _onEditingChange(editing?: boolean) {
-    this.dispatchEvent(
-      new CustomEvent('comment-editing-changed', {
-        detail: !!editing,
-        bubbles: true,
-        composed: true,
-      })
-    );
-    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();
-    if (!cachedVisibilityOfRespectfulTip) {
-      // we still want to show the tip with a probability of 30%
-      if (this.getRandomNum(0, 3) >= 1) return;
-      this._showRespectfulTip = true;
-      const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
-      this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
-      this.reporting.reportInteraction('respectful-tip-appeared', {
-        tip: this._respectfulReviewTip,
-      });
-      // update cache
-      this.storage.setRespectfulTipVisibility();
-    }
-  }
-
-  /** Set as a separate method so easy to stub. */
-  getRandomNum(min: number, max: number) {
-    return Math.floor(Math.random() * (max - min) + min);
-  }
-
-  _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
-    return showTip && !tipDismissed;
-  }
-
-  _dismissRespectfulTip() {
-    this._respectfulTipDismissed = true;
-    this.reporting.reportInteraction('respectful-tip-dismissed', {
-      tip: this._respectfulReviewTip,
-    });
-    // add a 14-day delay to the tip cache
-    this.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
-  }
-
-  _onRespectfulReadMoreClick() {
-    this.reporting.reportInteraction('respectful-read-more-clicked');
-  }
-
-  get textarea(): GrTextarea | null {
-    return this.shadowRoot?.querySelector('#editTextarea') as GrTextarea | null;
-  }
-
-  get confirmDeleteOverlay() {
-    if (!this._overlays.confirmDelete) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDelete = this.shadowRoot?.querySelector(
-        '#confirmDeleteOverlay'
-      ) as GrOverlay | null;
-    }
-    return this._overlays.confirmDelete;
-  }
-
-  get confirmDiscardOverlay() {
-    if (!this._overlays.confirmDiscard) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDiscard = this.shadowRoot?.querySelector(
-        '#confirmDiscardOverlay'
-      ) as GrOverlay | null;
-    }
-    return this._overlays.confirmDiscard;
-  }
-
-  _computeShowHideIcon(collapsed: boolean) {
-    return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
-  }
-
-  _computeShowHideAriaLabel(collapsed: boolean) {
-    return collapsed ? 'Expand' : 'Collapse';
-  }
-
-  @observe('showActions', 'isRobotComment')
-  _calculateActionstoShow(showActions?: boolean, isRobotComment?: boolean) {
-    // Polymer 2: check for undefined
-    if ([showActions, isRobotComment].includes(undefined)) {
-      return;
-    }
-
-    this._showHumanActions = showActions && !isRobotComment;
-    this._showRobotActions = showActions && isRobotComment;
-  }
-
-  hasPublishedComment(comments?: UIComment[]) {
-    if (!comments?.length) return false;
-    return comments.length > 1 || !isDraft(comments[0]);
-  }
-
-  @observe('comment')
-  _isRobotComment(comment: UIRobot) {
-    this.isRobotComment = !!comment.robot_id;
-  }
-
-  isOnParent() {
-    return this.side === 'PARENT';
-  }
-
-  _getIsAdmin() {
-    return this.restApiService.getIsAdmin();
-  }
-
-  _computeDraftTooltip(unableToSave: boolean) {
-    return unableToSave
-      ? 'Unable to save draft. Please try to save again.'
-      : "This draft is only visible to you. To publish drafts, click the 'Reply'" +
-          "or 'Start review' button at the top of the change or press the 'A' key.";
-  }
-
-  _computeDraftText(unableToSave: boolean) {
-    return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
-  }
-
-  handleCopyLink() {
+  private handleCopyLink() {
     fireEvent(this, 'copy-comment-link');
   }
 
-  save(opt_comment?: UIComment) {
-    let comment = opt_comment;
-    if (!comment) {
-      comment = this.comment;
+  /** Enter editing mode. */
+  private edit() {
+    if (!isDraftOrUnsaved(this.comment)) {
+      throw new Error('Cannot edit published comment.');
     }
-
-    this.set('comment.message', this._messageText);
-    this.editing = false;
-    this.disabled = true;
-
-    if (!this._messageText) {
-      return this._discardDraft();
-    }
-
-    const details = this.commentDetailsForReporting();
-    this.reporting.reportInteraction(Interaction.SAVE_COMMENT, details);
-    this._xhrPromise = this._saveDraft(comment)
-      .then(response => {
-        this.disabled = false;
-        if (!response.ok) {
-          return;
-        }
-
-        this._eraseDraftCommentFromStorage();
-        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;
-          // Maintain the ephemeral draft ID for identification by other
-          // elements.
-          if (this.comment?.__draftID) {
-            resComment.__draftID = this.comment.__draftID;
-          }
-          if (!resComment.patch_set) resComment.patch_set = this.patchNum;
-          this.comment = resComment;
-          const details = this.commentDetailsForReporting();
-          this.reporting.reportInteraction(Interaction.COMMENT_SAVED, details);
-          this._fireSave();
-          return obj;
-        });
-      })
-      .catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-    return this._xhrPromise;
-  }
-
-  private commentDetailsForReporting() {
-    return {
-      id: this.comment?.id,
-      message_length: this.comment?.message?.length,
-      in_reply_to: this.comment?.in_reply_to,
-      unresolved: this.comment?.unresolved,
-      path_length: this.comment?.path?.length,
-      line: this.comment?.range?.start_line ?? this.comment?.line,
-    };
-  }
-
-  _eraseDraftCommentFromStorage() {
-    // Prevents a race condition in which removing the draft comment occurs
-    // prior to it being saved.
-    this.storeTask?.cancel();
-
-    assertIsDefined(this.comment?.path, 'comment.path');
-    assertIsDefined(this.changeNum, 'changeNum');
-    this.storage.eraseDraftComment({
-      changeNum: this.changeNum,
-      patchNum: this._getPatchNum(),
-      path: this.comment.path,
-      line: this.comment.line,
-      range: this.comment.range,
-    });
-  }
-
-  _commentChanged(comment: UIComment) {
-    this.editing = isDraft(comment) && !!comment.__editing;
-    this.resolved = !comment.unresolved;
-    this.discarding = false;
-    if (this.editing) {
-      // It's a new draft/reply, notify.
-      this._fireUpdate();
-    }
-  }
-
-  @observe('comment', 'comments.*')
-  _computeHasHumanReply() {
-    const comment = this.comment;
-    if (!comment || !this.comments) return;
-    // hide please fix button for robot comment that has human reply
-    this._hasHumanReply = this.comments.some(
-      c =>
-        c.in_reply_to &&
-        c.in_reply_to === comment.id &&
-        !(c as UIRobot).robot_id
-    );
-  }
-
-  _getEventPayload(): OpenFixPreviewEventDetail {
-    return {comment: this.comment, patchNum: this.patchNum};
-  }
-
-  _fireEdit() {
-    if (this.comment) this.commentsService.editDraft(this.comment);
-    this.dispatchEvent(
-      new CustomEvent('comment-edit', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _fireSave() {
-    if (this.comment) this.commentsService.addDraft(this.comment);
-    this.dispatchEvent(
-      new CustomEvent('comment-save', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
-    );
-  }
-
-  _fireUpdate() {
-    this.fireUpdateTask = debounce(this.fireUpdateTask, () => {
-      this.dispatchEvent(
-        new CustomEvent('comment-update', {
-          detail: this._getEventPayload(),
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-  }
-
-  _computeAccountLabelClass(draft: boolean) {
-    return draft ? 'draft' : '';
-  }
-
-  _draftChanged(draft: boolean) {
-    this.$.container.classList.toggle('draft', draft);
-  }
-
-  _editingChanged(editing?: boolean, previousValue?: boolean) {
-    // Polymer 2: observer fires when at least one property is defined.
-    // Do nothing to prevent comment.__editing being overwritten
-    // if previousValue is undefined
-    if (previousValue === undefined) return;
-
-    this.$.container.classList.toggle('editing', editing);
-    if (this.comment && this.comment.id) {
-      const cancelButton = this.shadowRoot?.querySelector(
-        '.cancel'
-      ) as GrButton | null;
-      if (cancelButton) {
-        cancelButton.hidden = !editing;
-      }
-    }
-    if (isDraft(this.comment)) {
-      this.comment.__editing = this.editing;
-    }
-    if (!!editing !== !!previousValue) {
-      // To prevent event firing on comment creation.
-      this._fireUpdate();
-    }
-    if (editing) {
-      setTimeout(() => {
-        flush();
-        this.textarea && this.textarea.putCursorAtEnd();
-      }, 1);
-    }
-  }
-
-  _computeDeleteButtonClass(isAdmin: boolean, draft: boolean) {
-    return isAdmin && !draft ? 'showDeleteButtons' : '';
-  }
-
-  _computeSaveDisabled(
-    draft: string,
-    comment: UIComment | undefined,
-    resolved?: boolean
-  ) {
-    // If resolved state has changed and a msg exists, save should be enabled.
-    if (!comment || (comment.unresolved === resolved && draft)) {
-      return false;
-    }
-    return !draft || draft.trim() === '';
-  }
-
-  _handleSaveKey(e: Event) {
-    if (
-      !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
-    ) {
-      e.preventDefault();
-      this._handleSave(e);
-    }
-  }
-
-  _handleEsc(e: Event) {
-    if (!this._messageText.length) {
-      e.preventDefault();
-      this._handleCancel(e);
-    }
-  }
-
-  _handleToggleCollapsed() {
-    this.collapsed = !this.collapsed;
-  }
-
-  _toggleCollapseClass(collapsed: boolean) {
-    if (collapsed) {
-      this.$.container.classList.add('collapsed');
-    } else {
-      this.$.container.classList.remove('collapsed');
-    }
-  }
-
-  @observe('comment.message')
-  _commentMessageChanged(message: string) {
-    /*
-     * Only overwrite the message text user has typed if there is no existing
-     * text typed by the user. This prevents the bug where creating another
-     * comment triggered a recomputation of comments and the text written by
-     * the user was lost.
-     */
-    if (!this._messageText || !this.editing) this._messageText = message || '';
-  }
-
-  _messageTextChanged(_: string, oldValue: string) {
-    // Only store comments that are being edited in local storage.
-    if (
-      !this.comment ||
-      (this.comment.id && (!isDraft(this.comment) || !this.comment.__editing))
-    ) {
-      return;
-    }
-
-    const patchNum = this.comment.patch_set
-      ? this.comment.patch_set
-      : this._getPatchNum();
-    const {path, line, range} = this.comment;
-    if (!path) return;
-    this.storeTask = debounce(
-      this.storeTask,
-      () => {
-        const message = this._messageText;
-        if (this.changeNum === undefined) {
-          throw new Error('undefined changeNum');
-        }
-        const commentLocation: StorageLocation = {
-          changeNum: this.changeNum,
-          patchNum,
-          path,
-          line,
-          range,
-        };
-
-        if ((!message || !message.length) && oldValue) {
-          // If the draft has been modified to be empty, then erase the storage
-          // entry.
-          this.storage.eraseDraftComment(commentLocation);
-        } else {
-          this.storage.setDraftComment(commentLocation, message);
-        }
-      },
-      STORAGE_DEBOUNCE_INTERVAL
-    );
-  }
-
-  _handleAnchorClick(e: Event) {
-    e.preventDefault();
-    if (!this.comment) return;
-    this.dispatchEvent(
-      new CustomEvent('comment-anchor-tap', {
-        bubbles: true,
-        composed: true,
-        detail: {
-          number: this.comment.line || FILE,
-          side: this.side,
-        },
-      })
-    );
-  }
-
-  _handleEdit(e: Event) {
-    e.preventDefault();
-    if (this.comment?.message) this._messageText = this.comment.message;
+    if (this.editing) return;
     this.editing = true;
-    this._fireEdit();
-    this.reporting.recordDraftInteraction();
   }
 
-  _handleSave(e: Event) {
-    e.preventDefault();
+  // TODO: Move this out of gr-comment. gr-comment should not have a comments
+  // property.
+  private hasHumanReply() {
+    if (!this.comment || !this.comments) return false;
+    return this.comments.some(
+      c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c)
+    );
+  }
 
-    // Ignore saves started while already saving.
-    if (this.disabled) return;
-    const timingLabel = this.comment?.id
-      ? REPORT_UPDATE_DRAFT
-      : REPORT_CREATE_DRAFT;
-    const timer = this.reporting.getTimer(timingLabel);
-    this.set('comment.__editing', false);
-    return this.save().then(() => {
-      timer.end({id: this.comment?.id});
+  // private, but visible for testing
+  async createFixPreview(): Promise<OpenFixPreviewEventDetail> {
+    assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
+    assertIsDefined(this.comment?.path, 'comment.path');
+
+    if (hasUserSuggestion(this.comment)) {
+      const replacement = getUserSuggestion(this.comment);
+      assert(!!replacement, 'malformed user suggestion');
+      const line = await this.getCommentedCode();
+
+      return {
+        fixSuggestions: createUserFixSuggestion(
+          this.comment,
+          line,
+          replacement
+        ),
+        patchNum: this.comment.patch_set,
+      };
+    }
+    if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) {
+      const id = this.comment.robot_id;
+      return {
+        fixSuggestions: this.comment.fix_suggestions.map(s => {
+          return {
+            ...s,
+            description: `${id ?? ''} - ${s.description ?? ''}`,
+          };
+        }),
+        patchNum: this.comment.patch_set,
+      };
+    }
+    throw new Error('unable to create preview fix event');
+  }
+
+  private onEditingChanged() {
+    if (this.editing) {
+      this.collapsed = false;
+      this.messageText = this.comment?.message ?? '';
+      this.unresolved = this.comment?.unresolved ?? true;
+      this.originalMessage = this.messageText;
+      this.originalUnresolved = this.unresolved;
+    }
+
+    // Parent components such as the reply dialog might be interested in whether
+    // come of their child components are in editing mode.
+    fire(this, 'comment-editing-changed', {
+      editing: this.editing,
+      path: this.comment?.path ?? '',
     });
   }
 
-  _handleCancel(e: Event) {
-    e.preventDefault();
-    if (!this.comment) return;
-    if (!this.comment.id) {
-      // Ensures we update the discarded draft message before deleting the draft
-      this.set('comment.message', this._messageText);
-      this._fireDiscard();
-    } else {
-      this.set('comment.__editing', false);
-      this.commentsService.cancelDraft(this.comment);
-      this.editing = false;
+  // private, but visible for testing
+  isSaveDisabled() {
+    assertIsDefined(this.comment, 'comment');
+    if (this.saving) return true;
+    return !this.messageText?.trimEnd();
+  }
+
+  override focus() {
+    this.textarea?.focus();
+  }
+
+  private handleEsc() {
+    // vim users don't like ESC to cancel/discard, so only do this when the
+    // comment text is empty.
+    if (!this.messageText?.trimEnd()) this.cancel();
+  }
+
+  private handleAnchorClick() {
+    assertIsDefined(this.comment, 'comment');
+    fire(this, 'comment-anchor-tap', {
+      number: this.comment.line || FILE,
+      side: this.comment?.side,
+    });
+  }
+
+  private async handleSaveButtonClicked() {
+    await this.save();
+    if (this.permanentEditingMode) {
+      this.editing = !this.editing;
     }
   }
 
-  _fireDiscard() {
-    if (this.comment) this.commentsService.deleteDraft(this.comment);
-    this.fireUpdateTask?.cancel();
-    this.dispatchEvent(
-      new CustomEvent('comment-discard', {
-        detail: this._getEventPayload(),
-        composed: true,
-        bubbles: true,
-      })
+  private handlePleaseFix() {
+    const message = this.comment?.message;
+    assert(!!message, 'empty message');
+    const quoted = message.replace(NEWLINE_PATTERN, '\n> ');
+    const eventDetail: ReplyToCommentEventDetail = {
+      content: `> ${quoted}\n\nPlease fix.`,
+      userWantsToEdit: false,
+      unresolved: true,
+    };
+    // Handled by <gr-comment-thread>.
+    fire(this, 'reply-to-comment', eventDetail);
+  }
+
+  private async handleShowFix() {
+    // Handled top-level in the diff and change view components.
+    fire(this, 'open-fix-preview', await this.createFixPreview());
+  }
+
+  async createSuggestEdit(e: MouseEvent) {
+    e.stopPropagation();
+    const line = await this.getCommentedCode();
+    this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
+  }
+
+  async getCommentedCode() {
+    assertIsDefined(this.comment, 'comment');
+    assertIsDefined(this.changeNum, 'changeNum');
+    // TODO(milutin): Show a toast while the file is being loaded.
+    // TODO(milutin): This should be moved into a service/model.
+    const file = await this.restApiService.getFileContent(
+      this.changeNum,
+      this.comment.path!,
+      this.comment.patch_set!
     );
-  }
-
-  _handleFix() {
-    this.dispatchEvent(
-      new CustomEvent('create-fix-comment', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      })
+    assert(
+      !!file && isBase64FileContent(file) && !!file.content,
+      'file content for comment not found'
     );
+    const line = getContentInCommentRange(file.content, this.comment);
+    assert(!!line, 'file content for comment not found');
+    return line;
   }
 
-  _handleShowFix() {
-    this.dispatchEvent(
-      new CustomEvent('open-fix-preview', {
-        bubbles: true,
-        composed: true,
-        detail: this._getEventPayload(),
-      })
-    );
-  }
-
-  _hasNoFix(comment?: UIComment) {
-    return !comment || !(comment as UIRobot).fix_suggestions;
-  }
-
-  _handleDiscard(e: Event) {
-    e.preventDefault();
-    this.reporting.recordDraftInteraction();
-
-    this._discardDraft();
-  }
-
-  _discardDraft() {
-    if (!this.comment) return Promise.reject(new Error('undefined comment'));
-    if (!isDraft(this.comment)) {
-      return Promise.reject(new Error('Cannot discard a non-draft comment.'));
+  // private, but visible for testing
+  cancel() {
+    assertIsDefined(this.comment, 'comment');
+    if (!isDraftOrUnsaved(this.comment)) {
+      throw new Error('only unsaved and draft comments are editable');
     }
-    this.discarding = true;
-    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
-    this.editing = false;
-    this.disabled = true;
-    this._eraseDraftCommentFromStorage();
+    this.messageText = this.originalMessage;
+    this.unresolved = this.originalUnresolved;
+    this.save();
+  }
 
-    if (!this.comment.id) {
-      this.disabled = false;
-      this._fireDiscard();
-      return Promise.resolve();
+  async autoSave() {
+    if (this.saving || this.autoSaving) return;
+    if (!this.editing || !this.comment) return;
+    if (!isDraftOrUnsaved(this.comment)) return;
+    const messageToSave = this.messageText.trimEnd();
+    if (messageToSave === '') return;
+    if (messageToSave === this.comment.message) return;
+
+    try {
+      this.autoSaving = this.rawSave(messageToSave, {showToast: false});
+      await this.autoSaving;
+    } finally {
+      this.autoSaving = undefined;
     }
+  }
 
-    this._xhrPromise = this._deleteDraft(this.comment)
-      .then(response => {
-        this.disabled = false;
-        if (!response.ok) {
-          this.discarding = false;
+  async discard() {
+    this.messageText = '';
+    await this.save();
+  }
+
+  async save() {
+    if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+    // If it's an unsaved comment then it does not have a draftID yet which
+    // means sending another save() request will create a new draft
+    if (isUnsaved(this.comment) && this.saving) return;
+
+    try {
+      this.saving = true;
+      this.unableToSave = false;
+      if (this.autoSaving) {
+        this.comment = await this.autoSaving;
+      }
+      // Depending on whether `messageToSave` is empty we treat this either as
+      // a discard or a save action.
+      const messageToSave = this.messageText.trimEnd();
+      if (messageToSave === '') {
+        // Don't try to discard UnsavedInfo. Nothing to do then.
+        if (this.comment.id) {
+          await this.getCommentsModel().discardDraft(this.comment.id);
         }
-        timer.end({id: this.comment?.id});
-        this._fireDiscard();
-        return response;
-      })
-      .catch(err => {
-        this.disabled = false;
-        throw err;
-      });
-
-    return this._xhrPromise;
-  }
-
-  _getSavingMessage(numPending: number, requestFailed?: boolean) {
-    if (requestFailed) {
-      return UNSAVED_MESSAGE;
+      } else {
+        // No need to make a backend call when nothing has changed.
+        if (
+          messageToSave !== this.comment?.message ||
+          this.unresolved !== this.comment.unresolved
+        ) {
+          await this.rawSave(messageToSave, {showToast: true});
+        }
+      }
+      this.reporting.reportInteraction(
+        Interaction.COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE
+      );
+      if (!this.permanentEditingMode) {
+        this.editing = false;
+      }
+    } catch (e) {
+      this.unableToSave = true;
+      throw e;
+    } finally {
+      this.saving = false;
     }
-    if (numPending === 0) {
-      return SAVED_MESSAGE;
-    }
-    return `Saving ${pluralize(numPending, 'draft')}...`;
   }
 
-  _showStartRequest() {
-    const numPending = ++this._numPendingDraftRequests.number;
-    this._updateRequestToast(numPending);
-  }
-
-  _showEndRequest() {
-    const numPending = --this._numPendingDraftRequests.number;
-    this._updateRequestToast(numPending);
-  }
-
-  _handleFailedDraftRequest() {
-    this._numPendingDraftRequests.number--;
-
-    // Cancel the debouncer so that error toasts from the error-manager will
-    // not be overridden.
-    this.draftToastTask?.cancel();
-    this._updateRequestToast(
-      this._numPendingDraftRequests.number,
-      /* requestFailed=*/ true
-    );
-  }
-
-  _updateRequestToast(numPending: number, requestFailed?: boolean) {
-    const message = this._getSavingMessage(numPending, requestFailed);
-    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.
-        fireAlert(document.body, message);
+  /** For sharing between save() and autoSave(). */
+  private rawSave(message: string, options: {showToast: boolean}) {
+    if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+    return this.getCommentsModel().saveDraft(
+      {
+        ...this.comment,
+        message,
+        unresolved: this.unresolved,
       },
-      TOAST_DEBOUNCE_INTERVAL
+      options.showToast
     );
   }
 
-  _handleDraftFailure() {
-    this.$.container.classList.add('unableToSave');
-    this._unableToSave = true;
-    this._handleFailedDraftRequest();
-  }
-
-  _saveDraft(draft?: UIComment) {
-    if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
-      throw new Error('undefined draft or changeNum or patchNum');
-    }
-    this._showStartRequest();
-    return this.restApiService
-      .saveDiffDraft(this.changeNum, this.patchNum, draft)
-      .then(result => {
-        if (result.ok) {
-          // remove
-          this._unableToSave = false;
-          this.$.container.classList.remove('unableToSave');
-          this._showEndRequest();
-        } else {
-          this._handleDraftFailure();
-        }
-        return result;
-      })
-      .catch(err => {
-        this._handleDraftFailure();
-        throw err;
-      });
-  }
-
-  _deleteDraft(draft: UIComment) {
-    const changeNum = this.changeNum;
-    const patchNum = this.patchNum;
-    if (changeNum === undefined || patchNum === undefined) {
-      throw new Error('undefined changeNum or patchNum');
-    }
-    fireAlert(this, 'Discarding draft...');
-    const draftID = draft.id;
-    if (!draftID) throw new Error('Missing id in comment draft.');
-    return this.restApiService
-      .deleteDiffDraft(changeNum, patchNum, {id: draftID})
-      .then(result => {
-        if (result.ok) {
-          fire(this, 'show-alert', {
-            message: 'Draft Discarded',
-            action: 'Undo',
-            callback: () =>
-              this.commentsService.restoreDraft(changeNum, patchNum, draftID),
-          });
-        }
-        return result;
-      });
-  }
-
-  _getPatchNum(): PatchSetNum {
-    const patchNum = this.isOnParent()
-      ? ('PARENT' as BasePatchSetNum)
-      : this.patchNum;
-    if (patchNum === undefined) throw new Error('patchNum undefined');
-    return patchNum;
-  }
-
-  @observe('changeNum', 'patchNum', 'comment')
-  _loadLocalDraft(
-    changeNum: number,
-    patchNum?: PatchSetNum,
-    comment?: UIComment
-  ) {
-    // Polymer 2: check for undefined
-    if ([changeNum, patchNum, comment].includes(undefined)) {
-      return;
-    }
-
-    // Only apply local drafts to comments that are drafts and are currently
-    // being edited.
-    if (
-      !comment ||
-      !comment.path ||
-      comment.message ||
-      !isDraft(comment) ||
-      !comment.__editing
-    ) {
-      return;
-    }
-
-    const draft = this.storage.getDraftComment({
-      changeNum,
-      patchNum: this._getPatchNum(),
-      path: comment.path,
-      line: comment.line,
-      range: comment.range,
-    });
-
-    if (draft) {
-      this._messageText = draft.message || '';
-    }
-  }
-
-  _handleToggleResolved() {
-    this.reporting.recordDraftInteraction();
-    this.resolved = !this.resolved;
-    // Modify payload instead of this.comment, as this.comment is passed from
-    // the parent by ref.
-    const payload = this._getEventPayload();
-    if (!payload.comment) {
-      throw new Error('comment not defined in payload');
-    }
-    payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
-    this.dispatchEvent(
-      new CustomEvent('comment-update', {
-        detail: payload,
-        composed: true,
-        bubbles: true,
-      })
-    );
+  private handleToggleResolved() {
+    this.unresolved = !this.unresolved;
     if (!this.editing) {
-      // Save the resolved state immediately.
-      this.save(payload.comment);
+      // messageText is only assigned a value if the comment reaches editing
+      // state, however it is possible that the user toggles the resolved state
+      // without editing the comment in which case we assign the correct value
+      // to messageText here
+      this.messageText = this.comment?.message ?? '';
+      this.save();
     }
   }
 
-  _handleCommentDelete() {
-    this._openOverlay(this.confirmDeleteOverlay);
+  private openDeleteCommentModal() {
+    this.confirmDeleteModal?.showModal();
+    whenVisible(this.confirmDeleteDialog!, () => {
+      this.confirmDeleteDialog!.resetFocus();
+    });
   }
 
-  _handleCancelDeleteComment() {
-    this._closeOverlay(this.confirmDeleteOverlay);
+  private closeDeleteCommentModal() {
+    this.confirmDeleteModal?.close();
   }
 
-  _openOverlay(overlay?: GrOverlay | null) {
-    if (!overlay) {
-      return Promise.reject(new Error('undefined overlay'));
-    }
-    getRootElement().appendChild(overlay);
-    return overlay.open();
-  }
-
-  _computeHideRunDetails(comment: UIComment | undefined, collapsed: boolean) {
-    if (!comment) return true;
-    if (!isRobot(comment)) return true;
-    return !comment.url || collapsed;
-  }
-
-  _closeOverlay(overlay?: GrOverlay | null) {
-    if (overlay) {
-      getRootElement().removeChild(overlay);
-      overlay.close();
-    }
-  }
-
-  _handleConfirmDeleteComment() {
-    const dialog = this.confirmDeleteOverlay?.querySelector(
-      '#confirmDeleteComment'
-    ) as GrConfirmDeleteCommentDialog | null;
-    if (!dialog || !dialog.message) {
+  /**
+   * Deleting a *published* comment is an admin feature. It means more than just
+   * discarding a draft.
+   */
+  // private, but visible for testing
+  async handleConfirmDeleteComment() {
+    if (!this.confirmDeleteDialog || !this.confirmDeleteDialog.message) {
       throw new Error('missing confirm delete dialog');
     }
-    if (
-      !this.comment ||
-      !this.comment.id ||
-      this.changeNum === undefined ||
-      this.patchNum === undefined
-    ) {
-      throw new Error('undefined comment or id or changeNum or patchNum');
-    }
-    this.restApiService
-      .deleteComment(
-        this.changeNum,
-        this.patchNum,
-        this.comment.id,
-        dialog.message
-      )
-      .then(newComment => {
-        this._handleCancelDeleteComment();
-        this.comment = newComment;
-      });
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.comment, 'comment');
+
+    await this.getCommentsModel().deleteComment(
+      this.changeNum,
+      this.comment,
+      this.confirmDeleteDialog.message
+    );
+    this.closeDeleteCommentModal();
   }
 }
 
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
deleted file mode 100644
index b77c4b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ /dev/null
@@ -1,497 +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;
-      font-family: var(--font-family);
-      padding: var(--spacing-m);
-    }
-    :host([collapsed]) {
-      padding: var(--spacing-s) var(--spacing-m);
-    }
-    :host([disabled]) {
-      pointer-events: none;
-    }
-    :host([disabled]) .actions,
-    :host([disabled]) .robotActions,
-    :host([disabled]) .date {
-      opacity: 0.5;
-    }
-    :host([discarding]) {
-      display: none;
-    }
-    .body {
-      padding-top: var(--spacing-m);
-    }
-    .header {
-      align-items: center;
-      cursor: pointer;
-      display: flex;
-    }
-    .headerLeft > span {
-      font-weight: var(--font-weight-bold);
-    }
-    .headerMiddle {
-      color: var(--deemphasized-text-color);
-      flex: 1;
-      overflow: hidden;
-    }
-    .draftLabel,
-    .draftTooltip {
-      color: var(--deemphasized-text-color);
-      display: none;
-    }
-    .date {
-      justify-content: flex-end;
-      text-align: right;
-      white-space: nowrap;
-    }
-    span.date {
-      color: var(--deemphasized-text-color);
-    }
-    span.date:hover {
-      text-decoration: underline;
-    }
-    .actions,
-    .robotActions {
-      display: flex;
-      justify-content: flex-end;
-      padding-top: 0;
-    }
-    .robotActions {
-      /* Better than the negative margin would be to remove the gr-button
-       * padding, but then we would also need to fix the buttons that are
-       * inserted by plugins. :-/ */
-      margin: 4px 0 -4px;
-    }
-    .action {
-      margin-left: var(--spacing-l);
-    }
-    .rightActions {
-      display: flex;
-      justify-content: flex-end;
-    }
-    .rightActions gr-button {
-      --gr-button-padding: 0 var(--spacing-s);
-    }
-    .editMessage {
-      display: none;
-      margin: var(--spacing-m) 0;
-      width: 100%;
-    }
-    .container:not(.draft) .actions .hideOnPublished {
-      display: none;
-    }
-    .draft .reply,
-    .draft .quote,
-    .draft .ack,
-    .draft .done {
-      display: none;
-    }
-    .draft .draftLabel,
-    .draft .draftTooltip {
-      display: inline;
-    }
-    .draft:not(.editing):not(.unableToSave) .save,
-    .draft:not(.editing) .cancel {
-      display: none;
-    }
-    .editing .message,
-    .editing .reply,
-    .editing .quote,
-    .editing .ack,
-    .editing .done,
-    .editing .edit,
-    .editing .discard,
-    .editing .unresolved {
-      display: none;
-    }
-    .editing .editMessage {
-      display: block;
-    }
-    .show-hide {
-      margin-left: var(--spacing-s);
-    }
-    .robotId {
-      color: var(--deemphasized-text-color);
-      margin-bottom: var(--spacing-m);
-    }
-    .robotRun {
-      margin-left: var(--spacing-m);
-    }
-    .robotRunLink {
-      margin-left: var(--spacing-m);
-    }
-    input.show-hide {
-      display: none;
-    }
-    label.show-hide {
-      cursor: pointer;
-      display: block;
-    }
-    label.show-hide iron-icon {
-      vertical-align: top;
-    }
-    #container .collapsedContent {
-      display: none;
-    }
-    #container.collapsed .body {
-      padding-top: 0;
-    }
-    #container.collapsed .collapsedContent {
-      display: block;
-      overflow: hidden;
-      padding-left: var(--spacing-m);
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    #container.collapsed #deleteBtn,
-    #container.collapsed .date,
-    #container.collapsed .actions,
-    #container.collapsed gr-formatted-text,
-    #container.collapsed gr-textarea,
-    #container.collapsed .respectfulReviewTip {
-      display: none;
-    }
-    .resolve,
-    .unresolved {
-      align-items: center;
-      display: flex;
-      flex: 1;
-      margin: 0;
-    }
-    .resolve label {
-      color: var(--comment-text-color);
-    }
-    gr-dialog .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    #deleteBtn {
-      display: none;
-      --gr-button-text-color: var(--deemphasized-text-color);
-      --gr-button-padding: 0;
-    }
-    #deleteBtn.showDeleteButtons {
-      display: block;
-    }
-
-    /** Disable select for the caret and actions */
-    .actions,
-    .show-hide {
-      -webkit-user-select: none;
-      -moz-user-select: none;
-      -ms-user-select: none;
-      user-select: none;
-    }
-
-    .respectfulReviewTip {
-      justify-content: space-between;
-      display: flex;
-      padding: var(--spacing-m);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      margin-bottom: var(--spacing-m);
-    }
-    .respectfulReviewTip div {
-      display: flex;
-    }
-    .respectfulReviewTip div iron-icon {
-      margin-right: var(--spacing-s);
-    }
-    .respectfulReviewTip a {
-      white-space: nowrap;
-      margin-right: var(--spacing-s);
-      padding-left: var(--spacing-m);
-      text-decoration: none;
-    }
-    .pointer {
-      cursor: pointer;
-    }
-    .patchset-text {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-s);
-    }
-    .headerLeft gr-account-label {
-      --account-max-length: 130px;
-      width: 150px;
-    }
-    .headerLeft gr-account-label::part(gr-account-label-text) {
-      font-weight: var(--font-weight-bold);
-    }
-    .draft gr-account-label {
-      width: unset;
-    }
-    .portedMessage {
-      margin: 0 var(--spacing-m);
-    }
-    .link-icon {
-      cursor: pointer;
-    }
-  </style>
-  <div id="container" class="container">
-    <div class="header" id="header" on-click="_handleToggleCollapsed">
-      <div class="headerLeft">
-        <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)]]"
-            hideStatus
-          >
-          </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
-          >
-        </template>
-        <gr-tooltip-content
-          class="draftTooltip"
-          has-tooltip
-          title="[[_computeDraftTooltip(_unableToSave)]]"
-          max-width="20em"
-          show-icon
-        >
-          <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
-        </gr-tooltip-content>
-      </div>
-      <div class="headerMiddle">
-        <span class="collapsedContent">[[comment.message]]</span>
-      </div>
-      <div
-        hidden$="[[_computeHideRunDetails(comment, collapsed)]]"
-        class="runIdMessage message"
-      >
-        <div class="runIdInformation">
-          <a class="robotRunLink" href$="[[comment.url]]">
-            <span class="robotRun link">Run Details</span>
-          </a>
-        </div>
-      </div>
-      <gr-button
-        id="deleteBtn"
-        title="Delete Comment"
-        link=""
-        class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
-        hidden$="[[isRobotComment]]"
-        on-click="_handleCommentDelete"
-      >
-        <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
-      </gr-button>
-      <template is="dom-if" if="[[showPatchset]]">
-        <span class="patchset-text"> Patchset [[patchNum]]</span>
-      </template>
-      <span class="separator"></span>
-      <template is="dom-if" if="[[comment.updated]]">
-        <span class="date" tabindex="0" on-click="_handleAnchorClick">
-          <gr-date-formatter
-            withTooltip
-            date-str="[[comment.updated]]"
-          ></gr-date-formatter>
-        </span>
-      </template>
-      <div class="show-hide" tabindex="0">
-        <label
-          class="show-hide"
-          aria-label$="[[_computeShowHideAriaLabel(collapsed)]]"
-        >
-          <input
-            type="checkbox"
-            class="show-hide"
-            checked$="[[collapsed]]"
-            on-change="_handleToggleCollapsed"
-          />
-          <iron-icon id="icon" icon="[[_computeShowHideIcon(collapsed)]]">
-          </iron-icon>
-        </label>
-      </div>
-    </div>
-    <div class="body">
-      <template is="dom-if" if="[[isRobotComment]]">
-        <div class="robotId" hidden$="[[collapsed]]">
-          [[comment.author.name]]
-        </div>
-      </template>
-      <template is="dom-if" if="[[editing]]">
-        <gr-textarea
-          id="editTextarea"
-          class="editMessage"
-          autocomplete="on"
-          code=""
-          disabled="{{disabled}}"
-          rows="4"
-          text="{{_messageText}}"
-        ></gr-textarea>
-        <template
-          is="dom-if"
-          if="[[_computeVisibilityOfTip(_showRespectfulTip, _respectfulTipDismissed)]]"
-        >
-          <div class="respectfulReviewTip">
-            <div>
-              <gr-tooltip-content
-                has-tooltip
-                title="Tips for respectful code reviews."
-              >
-                <iron-icon
-                  class="pointer"
-                  icon="gr-icons:lightbulb-outline"
-                ></iron-icon>
-              </gr-tooltip-content>
-              [[_respectfulReviewTip]]
-            </div>
-            <div>
-              <a
-                tabindex="-1"
-                on-click="_onRespectfulReadMoreClick"
-                href="https://testing.googleblog.com/2019/11/code-health-respectful-reviews-useful.html"
-                target="_blank"
-              >
-                Read more
-              </a>
-              <a
-                tabindex="-1"
-                class="close pointer"
-                on-click="_dismissRespectfulTip"
-                >Not helpful</a
-              >
-            </div>
-          </div>
-        </template>
-      </template>
-      <!--The message class is needed to ensure selectability from
-        gr-diff-selection.-->
-      <gr-formatted-text
-        class="message"
-        content="[[comment.message]]"
-        no-trailing-margin="[[!comment.__draft]]"
-        config="[[projectConfig.commentlinks]]"
-      ></gr-formatted-text>
-      <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
-        <div class="action resolve hideOnPublished">
-          <label>
-            <input
-              type="checkbox"
-              id="resolvedCheckbox"
-              checked="[[resolved]]"
-              on-change="_handleToggleResolved"
-            />
-            Resolved
-          </label>
-        </div>
-        <template is="dom-if" if="[[draft]]">
-          <div class="rightActions">
-            <template is="dom-if" if="[[hasPublishedComment(comments)]]">
-              <iron-icon
-                class="link-icon"
-                on-click="handleCopyLink"
-                class="copy"
-                title="Copy link to this comment"
-                icon="gr-icons:link"
-                role="button"
-                tabindex="0"
-              >
-              </iron-icon>
-            </template>
-            <gr-button
-              link=""
-              class="action cancel hideOnPublished"
-              on-click="_handleCancel"
-              >Cancel</gr-button
-            >
-            <gr-button
-              link=""
-              class="action discard hideOnPublished"
-              on-click="_handleDiscard"
-              >Discard</gr-button
-            >
-            <gr-button
-              link=""
-              class="action edit hideOnPublished"
-              on-click="_handleEdit"
-              >Edit</gr-button
-            >
-            <gr-button
-              link=""
-              disabled$="[[_computeSaveDisabled(_messageText, comment, resolved)]]"
-              class="action save hideOnPublished"
-              on-click="_handleSave"
-              >Save</gr-button
-            >
-          </div>
-        </template>
-      </div>
-      <div class="robotActions" hidden$="[[!_showRobotActions]]">
-        <template is="dom-if" if="[[hasPublishedComment(comments)]]">
-          <iron-icon
-            class="link-icon"
-            on-click="handleCopyLink"
-            class="copy"
-            title="Copy link to this comment"
-            icon="gr-icons:link"
-            role="button"
-            tabindex="0"
-          >
-          </iron-icon>
-        </template>
-        <template is="dom-if" if="[[isRobotComment]]">
-          <gr-endpoint-decorator name="robot-comment-controls">
-            <gr-endpoint-param name="comment" value="[[comment]]">
-            </gr-endpoint-param>
-          </gr-endpoint-decorator>
-          <gr-button
-            link=""
-            secondary=""
-            class="action show-fix"
-            hidden$="[[_hasNoFix(comment)]]"
-            on-click="_handleShowFix"
-          >
-            Show Fix
-          </gr-button>
-          <template is="dom-if" if="[[!_hasHumanReply]]">
-            <gr-button
-              link=""
-              class="action fix"
-              on-click="_handleFix"
-              disabled="[[robotButtonDisabled]]"
-            >
-              Please Fix
-            </gr-button>
-          </template>
-        </template>
-      </div>
-    </div>
-  </div>
-  <template is="dom-if" if="[[_enableOverlay]]">
-    <gr-overlay id="confirmDeleteOverlay" with-backdrop="">
-      <gr-confirm-delete-comment-dialog
-        id="confirmDeleteComment"
-        on-confirm="_handleConfirmDeleteComment"
-        on-cancel="_handleCancelDeleteComment"
-      >
-      </gr-confirm-delete-comment-dialog>
-    </gr-overlay>
-  </template>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 09ac95b..3390369 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -1,1512 +1,784 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-comment';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {GrComment, __testOnly_UNSAVED_MESSAGE} from './gr-comment';
-import {SpecialFilePath, CommentSide} from '../../../constants/constants';
+import {AUTO_SAVE_DEBOUNCE_DELAY_MS, GrComment} from './gr-comment';
 import {
   queryAndAssert,
   stubRestApi,
-  stubStorage,
-  spyStorage,
   query,
-  isVisible,
-  stubReporting,
+  pressKey,
+  listenOnce,
   mockPromise,
+  waitUntilCalled,
+  dispatch,
+  MockPromise,
+  stubFlags,
 } from '../../../test/test-utils';
 import {
   AccountId,
   EmailAddress,
-  FixId,
   NumericChangeId,
-  ParsedJSON,
   PatchSetNum,
-  RobotId,
-  RobotRunId,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {
-  pressAndReleaseKeyOn,
-  tap,
-} from '@polymer/iron-test-helpers/mock-interactions';
-import {
   createComment,
   createDraft,
   createFixSuggestionInfo,
+  createRobotComment,
+  createUnsaved,
 } from '../../../test/test-data-generators';
-import {Timer} from '../../../services/gr-reporting/gr-reporting';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
-import {CreateFixCommentEvent} from '../../../types/events';
-import {DraftInfo, UIRobot} from '../../../utils/comment-util';
-import {MockTimer} from '../../../services/gr-reporting/gr-reporting_mock';
+import {
+  ReplyToCommentEvent,
+  OpenFixPreviewEventDetail,
+} from '../../../types/events';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-
-const basicFixture = fixtureFromElement('gr-comment');
-
-const draftFixture = fixtureFromTemplate(html`
-  <gr-comment draft="true"></gr-comment>
-`);
+import {
+  DraftInfo,
+  USER_SUGGESTION_START_PATTERN,
+} from '../../../utils/comment-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {Modifier} from '../../../utils/dom-util';
+import {SinonStub} from 'sinon';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../../../models/comments/comments-model';
 
 suite('gr-comment tests', () => {
-  suite('basic tests', () => {
-    let element: GrComment;
+  let element: GrComment;
+  let commentsModel: CommentsModel;
+  const account = {
+    email: 'dhruvsri@google.com' as EmailAddress,
+    name: 'Dhruv Srivastava',
+    _account_id: 1083225 as AccountId,
+    avatars: [{url: 'abc', height: 32, width: 32}],
+    registered_on: '123' as Timestamp,
+  };
+  const comment = {
+    ...createComment(),
+    author: {
+      name: 'Mr. Peanutbutter',
+      email: 'tenn1sballchaser@aol.com' as EmailAddress,
+    },
+    id: 'baf0414d_60047215' as UrlEncodedCommentId,
+    line: 5,
+    message: 'This is the test comment message.',
+    updated: '2015-12-08 19:48:33.843000000' as Timestamp,
+  };
 
-    let openOverlaySpy: sinon.SinonSpy;
+  setup(async () => {
+    element = await fixture(
+      html`<gr-comment
+        .account=${account}
+        .showPatchset=${true}
+        .comment=${comment}
+      ></gr-comment>`
+    );
+    commentsModel = testResolver(commentsModelToken);
+  });
 
-    setup(() => {
-      stubRestApi('getAccount').returns(
-        Promise.resolve({
-          email: 'dhruvsri@google.com' as EmailAddress,
-          name: 'Dhruv Srivastava',
-          _account_id: 1083225 as AccountId,
-          avatars: [{url: 'abc', height: 32, width: 32}],
-          registered_on: '123' as Timestamp,
-        })
+  suite('DOM rendering', () => {
+    test('renders collapsed', async () => {
+      const initiallyCollapsedElement = await fixture(
+        html`<gr-comment
+          .account=${account}
+          .showPatchset=${true}
+          .comment=${comment}
+          .initiallyCollapsed=${true}
+        ></gr-comment>`
       );
-      element = basicFixture.instantiate();
-      element.comment = {
-        ...createComment(),
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        id: 'baf0414d_60047215' as UrlEncodedCommentId,
-        line: 5,
-        message: 'is this a crossover episode!?',
-        updated: '2015-12-08 19:48:33.843000000' as Timestamp,
-      };
-
-      openOverlaySpy = sinon.spy(element, '_openOverlay');
-    });
-
-    teardown(() => {
-      openOverlaySpy.getCalls().forEach(call => {
-        call.args[0].remove();
-      });
-    });
-
-    test('collapsible comments', () => {
-      // When a comment (not draft) is loaded, it should be collapsed
-      assert.isTrue(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-
-      // The header middle content is only visible when comments are collapsed.
-      // It shows the message in a condensed way, and limits to a single line.
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      // When the header row is clicked, the comment should expand
-      tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is not visible'
+      assert.shadowDom.equal(
+        initiallyCollapsedElement,
+        /* HTML */ `
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <gr-endpoint-param name="message"></gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
+            <div class="container" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <gr-account-label deselected=""></gr-account-label>
+                </div>
+                <div class="headerMiddle">
+                  <span class="collapsedContent">
+                    This is the test comment message.
+                  </span>
+                </div>
+                <span class="patchset-text">Patchset 1</span>
+                <div class="show-hide" tabindex="0">
+                  <label aria-label="Expand" class="show-hide">
+                    <input checked="" class="show-hide" type="checkbox" />
+                    <gr-icon id="icon" icon="expand_more"></gr-icon>
+                  </label>
+                </div>
+              </div>
+              <div class="body">
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+              </div>
+            </div>
+          </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
+        `
       );
     });
 
-    test('clicking on date link fires event', () => {
-      element.side = 'PARENT';
-      const stub = sinon.stub();
-      element.addEventListener('comment-anchor-tap', stub);
-      flush();
-      const dateEl = queryAndAssert(element, '.date');
-      assert.ok(dateEl);
-      tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail, {
-        side: element.side,
-        number: element.comment!.line,
-      });
-    });
-
-    test('message is not retrieved from storage when missing path', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isFalse(storageStub.called);
-    });
-
-    test('message is not retrieved from storage when message present', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        message: 'This is a message',
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isFalse(storageStub.called);
-    });
-
-    test('message is retrieved from storage for drafts in edit', async () => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-      };
-      await flush();
-      assert.isTrue(loadSpy.called);
-      assert.isTrue(storageStub.called);
-    });
-
-    test('comment message sets messageText only when empty', () => {
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._messageText = '';
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: true,
-        __draft: true,
-        message: 'hello world',
-      };
-      // messageText was empty so overwrite the message now
-      assert.equal(element._messageText, 'hello world');
-
-      element.comment!.message = 'new message';
-      // messageText was already set so do not overwrite it
-      assert.equal(element._messageText, 'hello world');
-    });
-
-    test('comment message sets messageText when not edited', () => {
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._messageText = 'Some text';
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com' as EmailAddress,
-        },
-        line: 5,
-        path: 'test',
-        __editing: false,
-        __draft: true,
-        message: 'hello world',
-      };
-      // messageText was empty so overwrite the message now
-      assert.equal(element._messageText, 'hello world');
-
-      element.comment!.message = 'new message';
-      // messageText was already set so do not overwrite it
-      assert.equal(element._messageText, 'hello world');
-    });
-
-    test('_getPatchNum', () => {
-      element.side = 'PARENT';
-      element.patchNum = 1 as PatchSetNum;
-      assert.equal(element._getPatchNum(), 'PARENT' as PatchSetNum);
-      element.side = 'REVISION';
-      assert.equal(element._getPatchNum(), 1 as PatchSetNum);
-    });
-
-    test('comment expand and collapse', () => {
-      element.collapsed = true;
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      element.collapsed = false;
-      assert.isFalse(element.collapsed);
-      assert.isTrue(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is is not visible'
+    test('renders expanded', async () => {
+      element.initiallyCollapsed = false;
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <gr-endpoint-param name="message"></gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
+            <div class="container" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <gr-account-label deselected=""></gr-account-label>
+                </div>
+                <div class="headerMiddle"></div>
+                <span class="patchset-text">Patchset 1</span>
+                <span class="separator"></span>
+                <span class="date" tabindex="0">
+                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                </span>
+                <div class="show-hide" tabindex="0">
+                  <label aria-label="Collapse" class="show-hide">
+                    <input class="show-hide" type="checkbox" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                  </label>
+                </div>
+              </div>
+              <div class="body">
+                <gr-formatted-text class="message"></gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+              </div>
+            </div>
+          </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
+        `
       );
     });
 
-    suite('while editing', () => {
-      let handleCancelStub: sinon.SinonStub;
-      let handleSaveStub: sinon.SinonStub;
-      setup(() => {
-        element.editing = true;
-        element._messageText = 'test';
-        handleCancelStub = sinon.stub(element, '_handleCancel');
-        handleSaveStub = sinon.stub(element, '_handleSave');
-        flush();
-      });
-
-      suite('when text is empty', () => {
-        setup(() => {
-          element._messageText = '';
-          element.comment = {};
-        });
-
-        test('esc closes comment when text is empty', () => {
-          pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-          assert.isTrue(handleCancelStub.called);
-        });
-
-        test('ctrl+enter does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
-          assert.isFalse(handleSaveStub.called);
-        });
-
-        test('meta+enter does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
-          assert.isFalse(handleSaveStub.called);
-        });
-
-        test('ctrl+s does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
-          assert.isFalse(handleSaveStub.called);
-        });
-      });
-
-      test('esc does not close comment that has content', () => {
-        pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-        assert.isFalse(handleCancelStub.called);
-      });
-
-      test('ctrl+enter saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
-        assert.isTrue(handleSaveStub.called);
-      });
-
-      test('meta+enter saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
-        assert.isTrue(handleSaveStub.called);
-      });
-
-      test('ctrl+s saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
-        assert.isTrue(handleSaveStub.called);
-      });
-    });
-
-    test('delete comment button for non-admins is hidden', () => {
-      element._isAdmin = false;
-      assert.isFalse(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
+    test('renders expanded robot', async () => {
+      element.initiallyCollapsed = false;
+      element.comment = createRobotComment();
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <gr-endpoint-param name="message"></gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
+            <div class="container" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <span class="robotName">robot-id-123</span>
+                </div>
+                <div class="headerMiddle"></div>
+                <span class="patchset-text">Patchset 1</span>
+                <span class="separator"></span>
+                <span class="date" tabindex="0">
+                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                </span>
+                <div class="show-hide" tabindex="0">
+                  <label aria-label="Collapse" class="show-hide">
+                    <input class="show-hide" type="checkbox" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                  </label>
+                </div>
+              </div>
+              <div class="body">
+                <div class="robotId"></div>
+                <gr-formatted-text class="message"></gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <div class="robotActions">
+                  <gr-icon
+                    icon="link"
+                    class="copy link-icon"
+                    role="button"
+                    tabindex="0"
+                    title="Copy link to this comment"
+                  ></gr-icon>
+                  <gr-endpoint-decorator name="robot-comment-controls">
+                    <gr-endpoint-param name="comment"></gr-endpoint-param>
+                  </gr-endpoint-decorator>
+                  <gr-button
+                    aria-disabled="false"
+                    class="action show-fix"
+                    link=""
+                    role="button"
+                    secondary=""
+                    tabindex="0"
+                  >
+                    Show Fix
+                  </gr-button>
+                  <gr-button
+                    aria-disabled="false"
+                    class="action fix"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Please Fix
+                  </gr-button>
+                </div>
+              </div>
+            </div>
+          </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
+        `
       );
     });
 
-    test('delete comment button for admins with draft is hidden', () => {
-      element._isAdmin = false;
-      element.draft = true;
-      assert.isFalse(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
+    test('renders expanded admin', async () => {
+      element.initiallyCollapsed = false;
+      element.isAdmin = true;
+      await element.updateComplete;
+      assert.dom.equal(
+        queryAndAssert(element, 'gr-button.delete'),
+        /* HTML */ `
+          <gr-button
+            aria-disabled="false"
+            class="action delete"
+            id="deleteBtn"
+            link=""
+            role="button"
+            tabindex="0"
+            title="Delete Comment"
+          >
+            <gr-icon id="icon" icon="delete" filled></gr-icon>
+          </gr-button>
+        `
       );
     });
 
-    test('delete comment', async () => {
-      const stub = stubRestApi('deleteComment').returns(
-        Promise.resolve(createComment())
-      );
-      const openSpy = sinon.spy(element.confirmDeleteOverlay!, 'open');
-      element.changeNum = 42 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element._isAdmin = true;
-      assert.isTrue(
-        queryAndAssert(element, '.action.delete').classList.contains(
-          'showDeleteButtons'
-        )
-      );
-      tap(queryAndAssert(element, '.action.delete'));
-      await flush();
-      await openSpy.lastCall.returnValue;
-      const dialog = element.confirmDeleteOverlay?.querySelector(
-        '#confirmDeleteComment'
-      ) as GrConfirmDeleteCommentDialog;
-      dialog.message = 'removal reason';
-      element._handleConfirmDeleteComment();
-      assert.isTrue(
-        stub.calledWith(
-          42 as NumericChangeId,
-          1 as PatchSetNum,
-          'baf0414d_60047215' as UrlEncodedCommentId,
-          'removal reason'
-        )
+    test('renders draft', async () => {
+      element.initiallyCollapsed = false;
+      (element.comment as DraftInfo).__draft = true;
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <gr-endpoint-param name="message"></gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
+            <div class="container draft" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <gr-tooltip-content
+                    class="draftTooltip"
+                    has-tooltip=""
+                    max-width="20em"
+                    title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
+                  >
+                    <gr-icon filled icon="rate_review"></gr-icon>
+                    <span class="draftLabel">Draft</span>
+                  </gr-tooltip-content>
+                </div>
+                <div class="headerMiddle"></div>
+                <span class="patchset-text">Patchset 1</span>
+                <span class="separator"></span>
+                <span class="date" tabindex="0">
+                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                </span>
+                <div class="show-hide" tabindex="0">
+                  <label aria-label="Collapse" class="show-hide">
+                    <input class="show-hide" type="checkbox" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                  </label>
+                </div>
+              </div>
+              <div class="body">
+                <gr-formatted-text class="message"></gr-formatted-text>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <div class="actions">
+                  <div class="action resolve">
+                    <label>
+                      <input checked="" id="resolvedCheckbox" type="checkbox" />
+                      Resolved
+                    </label>
+                  </div>
+                  <div class="rightActions">
+                    <gr-button
+                      aria-disabled="false"
+                      class="action discard"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Discard
+                    </gr-button>
+                    <gr-button
+                      aria-disabled="false"
+                      class="action edit"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Edit
+                    </gr-button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
+        `
       );
     });
 
-    suite('draft update reporting', () => {
-      let endStub: SinonStubbedMember<() => Timer>;
-      let getTimerStub: sinon.SinonStub;
-      const mockEvent = {...new Event('click'), preventDefault() {}};
-
-      setup(() => {
-        sinon.stub(element, 'save').returns(Promise.resolve({}));
-        endStub = sinon.stub();
-        const mockTimer = new MockTimer();
-        mockTimer.end = endStub;
-        getTimerStub = stubReporting('getTimer').returns(mockTimer);
-      });
-
-      test('create', async () => {
-        element.patchNum = 1 as PatchSetNum;
-        element.comment = {};
-        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
-        await element._handleSave(mockEvent);
-        await flush();
-        const grAccountLabel = queryAndAssert(element, 'gr-account-label');
-        const spanName = queryAndAssert<HTMLSpanElement>(
-          grAccountLabel,
-          'span.name'
-        );
-        assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
-        assert.isTrue(endStub.calledOnce);
-        assert.isTrue(getTimerStub.calledOnce);
-        assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-      });
-
-      test('update', () => {
-        element.comment = {
-          ...createComment(),
-          id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
-        };
-        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
-        return element._handleSave(mockEvent)!.then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
-        });
-      });
-
-      test('discard', () => {
-        element.comment = {
-          ...createComment(),
-          id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
-        };
-        element.comment = createDraft();
-        sinon.stub(element, '_fireDiscard');
-        sinon.stub(element, '_eraseDraftCommentFromStorage');
-        sinon
-          .stub(element, '_deleteDraft')
-          .returns(Promise.resolve(new Response()));
-        return element._discardDraft().then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
-        });
-      });
+    test('renders draft in editing mode', async () => {
+      element.initiallyCollapsed = false;
+      (element.comment as DraftInfo).__draft = true;
+      element.editing = true;
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-endpoint-decorator name="comment">
+            <gr-endpoint-param name="comment"></gr-endpoint-param>
+            <gr-endpoint-param name="editing"></gr-endpoint-param>
+            <gr-endpoint-param name="message"></gr-endpoint-param>
+            <gr-endpoint-param name="isDraft"></gr-endpoint-param>
+            <div class="container draft" id="container">
+              <div class="header" id="header">
+                <div class="headerLeft">
+                  <gr-tooltip-content
+                    class="draftTooltip"
+                    has-tooltip=""
+                    max-width="20em"
+                    title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'a' key."
+                  >
+                    <gr-icon filled icon="rate_review"></gr-icon>
+                    <span class="draftLabel">Draft</span>
+                  </gr-tooltip-content>
+                </div>
+                <div class="headerMiddle"></div>
+                <span class="patchset-text">Patchset 1</span>
+                <span class="separator"></span>
+                <span class="date" tabindex="0">
+                  <gr-date-formatter withtooltip=""></gr-date-formatter>
+                </span>
+                <div class="show-hide" tabindex="0">
+                  <label aria-label="Collapse" class="show-hide">
+                    <input class="show-hide" type="checkbox" />
+                    <gr-icon id="icon" icon="expand_less"></gr-icon>
+                  </label>
+                </div>
+              </div>
+              <div class="body">
+                <gr-textarea
+                  autocomplete="on"
+                  class="code editMessage"
+                  code=""
+                  id="editTextarea"
+                  rows="4"
+                  text="This is the test comment message."
+                >
+                </gr-textarea>
+                <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+                <div class="actions">
+                  <div class="action resolve">
+                    <label>
+                      <input checked="" id="resolvedCheckbox" type="checkbox" />
+                      Resolved
+                    </label>
+                  </div>
+                  <div class="rightActions">
+                    <gr-button
+                      aria-disabled="false"
+                      class="action cancel"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Cancel
+                    </gr-button>
+                    <gr-button
+                      aria-disabled="false"
+                      class="action save"
+                      link=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      Save
+                    </gr-button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </gr-endpoint-decorator>
+          <dialog id="confirmDeleteModal" tabindex="-1">
+            <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+            </gr-confirm-delete-comment-dialog>
+          </dialog>
+        `
+      );
     });
+  });
 
-    test('edit reports interaction', () => {
-      const reportStub = stubReporting('recordDraftInteraction');
-      sinon.stub(element, '_fireEdit');
-      element.draft = true;
-      flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(reportStub.calledOnce);
-    });
+  test('clicking on date link fires event', async () => {
+    const stub = sinon.stub();
+    element.addEventListener('comment-anchor-tap', stub);
+    await element.updateComplete;
 
-    test('discard reports interaction', () => {
-      const reportStub = stubReporting('recordDraftInteraction');
-      sinon.stub(element, '_eraseDraftCommentFromStorage');
-      sinon.stub(element, '_fireDiscard');
-      sinon
-        .stub(element, '_deleteDraft')
-        .returns(Promise.resolve(new Response()));
-      element.draft = true;
-      element.comment = createDraft();
-      flush();
-      tap(queryAndAssert(element, '.discard'));
-      assert.isTrue(reportStub.calledOnce);
-    });
+    const dateEl = queryAndAssert<HTMLSpanElement>(element, '.date');
+    dateEl.click();
 
-    test('failed save draft request', async () => {
-      element.draft = true;
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
-        Promise.resolve({...new Response(), ok: false})
-      );
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      let args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0, true]);
-      assert.equal(
-        element._getSavingMessage(...args),
-        __testOnly_UNSAVED_MESSAGE
-      );
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT(Failed to save)'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0]);
-      assert.equal(element._getSavingMessage(...args), 'All changes saved');
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(element._unableToSave);
+    assert.isTrue(stub.called);
+    assert.deepEqual(stub.lastCall.args[0].detail, {
+      side: 'REVISION',
+      number: element.comment!.line,
     });
+  });
 
-    test('failed save draft request with promise failure', async () => {
-      element.draft = true;
-      element.changeNum = 1 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
-        Promise.reject(new Error())
-      );
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      let args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0, true]);
-      assert.equal(
-        element._getSavingMessage(...args),
-        __testOnly_UNSAVED_MESSAGE
-      );
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT(Failed to save)'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
-      element._saveDraft({
-        ...createComment(),
-        id: 'abc_123' as UrlEncodedCommentId,
-      });
-      await flush();
-      args = updateRequestStub.lastCall.args;
-      assert.deepEqual(args, [0]);
-      assert.equal(element._getSavingMessage(...args), 'All changes saved');
-      assert.equal(
-        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
-        'DRAFT'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(element._unableToSave);
-    });
+  test('comment message sets messageText only when empty', async () => {
+    element.changeNum = 1 as NumericChangeId;
+    element.messageText = '';
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      line: 5,
+      path: 'test',
+      __draft: true,
+      message: 'hello world',
+    };
+    element.editing = true;
+    await element.updateComplete;
+    // messageText was empty so overwrite the message now
+    assert.equal(element.messageText, 'hello world');
+
+    element.comment.message = 'new message';
+    await element.updateComplete;
+    // messageText was already set so do not overwrite it
+    assert.equal(element.messageText, 'hello world');
+  });
+
+  test('comment message sets messageText when not edited', async () => {
+    element.changeNum = 1 as NumericChangeId;
+    element.messageText = 'Some text';
+    element.comment = {
+      ...createComment(),
+      author: {
+        name: 'Mr. Peanutbutter',
+        email: 'tenn1sballchaser@aol.com' as EmailAddress,
+      },
+      line: 5,
+      path: 'test',
+      __draft: true,
+      message: 'hello world',
+    };
+    element.editing = true;
+    await element.updateComplete;
+    // messageText was empty so overwrite the message now
+    assert.equal(element.messageText, 'hello world');
+
+    element.comment.message = 'new message';
+    await element.updateComplete;
+    // messageText was already set so do not overwrite it
+    assert.equal(element.messageText, 'hello world');
+  });
+
+  test('delete comment', async () => {
+    element.changeNum = 42 as NumericChangeId;
+    element.isAdmin = true;
+    await element.updateComplete;
+
+    const deleteButton = queryAndAssert<GrButton>(element, '.action.delete');
+    deleteButton.click();
+    await element.updateComplete;
+
+    assertIsDefined(element.confirmDeleteModal, 'confirmDeleteModal');
+    const dialog = queryAndAssert<GrConfirmDeleteCommentDialog>(
+      element.confirmDeleteModal,
+      '#confirmDeleteCommentDialog'
+    );
+    dialog.message = 'removal reason';
+    await element.updateComplete;
+
+    const stub = stubRestApi('deleteComment').returns(
+      Promise.resolve(createComment())
+    );
+    element.handleConfirmDeleteComment();
+    assert.isTrue(
+      stub.calledWith(
+        42 as NumericChangeId,
+        1 as PatchSetNum,
+        'baf0414d_60047215' as UrlEncodedCommentId,
+        'removal reason'
+      )
+    );
   });
 
   suite('gr-comment draft tests', () => {
-    let element: GrComment;
-
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(undefined));
-      stubRestApi('saveDiffDraft').returns(
-        Promise.resolve({
-          ...new Response(),
-          ok: true,
-          text() {
-            return Promise.resolve(
-              ")]}'\n{" +
-                '"id": "baf0414d_40572e03",' +
-                '"path": "/path/to/file",' +
-                '"line": 5,' +
-                '"updated": "2015-12-08 21:52:36.177000000",' +
-                '"message": "saved!",' +
-                '"side": "REVISION",' +
-                '"unresolved": false,' +
-                '"patch_set": 1' +
-                '}'
-            );
-          },
-        })
-      );
-      stubRestApi('removeChangeReviewer').returns(
-        Promise.resolve({...new Response(), ok: true})
-      );
-      element = draftFixture.instantiate() as GrComment;
-      stubStorage('getDraftComment').returns(null);
+    setup(async () => {
       element.changeNum = 42 as NumericChangeId;
-      element.patchNum = 1 as PatchSetNum;
-      element.editing = false;
       element.comment = {
         ...createComment(),
         __draft: true,
-        __draftID: 'temp_draft_id',
         path: '/path/to/file',
         line: 5,
-        id: undefined,
       };
     });
 
-    test('button visibility states', async () => {
-      element.showActions = false;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+    test('isSaveDisabled', async () => {
+      element.saving = false;
+      element.unresolved = true;
+      element.comment = {...createComment(), unresolved: true};
+      element.messageText = 'asdf';
+      await element.updateComplete;
+      assert.isFalse(element.isSaveDisabled());
 
-      element.showActions = true;
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+      element.messageText = '';
+      await element.updateComplete;
+      assert.isTrue(element.isSaveDisabled());
 
-      element.draft = true;
-      await flush();
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard is visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.resolve')),
-        'resolve is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
+      // After changing the 'resolved' state of the comment the 'Save' button
+      // should stay disabled, if the message is empty.
+      element.unresolved = false;
+      await element.updateComplete;
+      assert.isTrue(element.isSaveDisabled());
 
-      element.editing = true;
-      await flush();
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.resolve')),
-        'resolve is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      element.draft = false;
-      element.editing = false;
-      await flush();
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.edit')),
-        'edit is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.discard')),
-        'discard is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.save')),
-        'save is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is not visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      element.comment!.id = 'foo' as UrlEncodedCommentId;
-      element.draft = true;
-      element.editing = true;
-      await flush();
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.cancel')),
-        'cancel is visible'
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isTrue(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // Delete button is not hidden by default
-      assert.isFalse(
-        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
-      );
-
-      element.isRobotComment = true;
-      element.draft = true;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // It is not expected to see Robot comment drafts, but if they appear,
-      // they will behave the same as non-drafts.
-      element.draft = false;
-      assert.isTrue(
-        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
-      );
-      assert.isFalse(
-        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
-      );
-
-      // A robot comment with run ID should display plain text.
-      element.set(['comment', 'robot_run_id'], 'text');
-      element.editing = false;
-      element.collapsed = false;
-      await flush();
-      assert.isTrue(
-        queryAndAssert(element, '.robotRun.link').textContent === 'Run Details'
-      );
-
-      // A robot comment with run ID and url should display a link.
-      element.set(['comment', 'url'], '/path/to/run');
-      await flush();
-      assert.notEqual(
-        getComputedStyle(queryAndAssert(element, '.robotRun.link')).display,
-        'none'
-      );
-
-      // Delete button is hidden for robot comments
-      assert.isTrue(
-        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
-      );
-    });
-
-    test('collapsible drafts', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      assert.isTrue(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is is not visible'
-      );
-
-      // When the edit button is pressed, should still see the actions
-      // and also textarea
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      await flush();
-      assert.isTrue(fireEditStub.called);
-      assert.isFalse(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is not visible'
-      );
-
-      // When toggle again, everything should be hidden except for textarea
-      // and header middle content should be visible
-      tap(element.$.header);
-      assert.isTrue(element.collapsed);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are not visible'
-      );
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-textarea')),
-        'textarea is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is visible'
-      );
-
-      // When toggle again, textarea should remain open in the state it was
-      // before
-      tap(element.$.header);
-      assert.isFalse(
-        isVisible(queryAndAssert(element, 'gr-formatted-text')),
-        'gr-formatted-text is not visible'
-      );
-      assert.isTrue(
-        isVisible(queryAndAssert(element, '.actions')),
-        'actions are visible'
-      );
-      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
-      assert.isFalse(
-        isVisible(queryAndAssert(element, '.collapsedContent')),
-        'header middle content is not visible'
-      );
-    });
-
-    test('robot comment layout', async () => {
-      const comment = {
-        robot_id: 'happy_robot_id' as RobotId,
-        url: '/robot/comment',
-        author: {
-          name: 'Happy Robot',
-          display_name: 'Display name Robot',
-        },
-        ...element.comment,
-      };
-      element.comment = comment;
-      element.collapsed = false;
-      await flush;
-      let runIdMessage;
-      runIdMessage = queryAndAssert(element, '.runIdMessage') as HTMLElement;
-      assert.isFalse((runIdMessage as HTMLElement).hidden);
-
-      const runDetailsLink = queryAndAssert(
-        element,
-        '.robotRunLink'
-      ) as HTMLAnchorElement;
-      assert.isTrue(
-        runDetailsLink.href.indexOf((element.comment as UIRobot).url!) !== -1
-      );
-
-      const robotServiceName = queryAndAssert(element, '.robotName');
-      assert.equal(robotServiceName.textContent?.trim(), 'happy_robot_id');
-
-      const authorName = queryAndAssert(element, '.robotId');
-      assert.isTrue((authorName as HTMLDivElement).innerText === 'Happy Robot');
-
-      element.collapsed = true;
-      await flush();
-      runIdMessage = queryAndAssert(element, '.runIdMessage');
-      assert.isTrue((runIdMessage as HTMLDivElement).hidden);
-    });
-
-    test('author name fallback to email', async () => {
-      const comment = {
-        url: '/robot/comment',
-        author: {
-          email: 'test@test.com' as EmailAddress,
-        },
-        ...element.comment,
-      };
-      element.comment = comment;
-      element.collapsed = false;
-      await flush();
-      const authorName = queryAndAssert(
-        queryAndAssert(element, 'gr-account-label'),
-        'span.name'
-      ) as HTMLSpanElement;
-      assert.equal(authorName.innerText.trim(), 'test@test.com');
-    });
-
-    test('patchset level comment', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      const comment = {
-        ...element.comment,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-        line: undefined,
-        range: undefined,
-      };
-      element.comment = comment;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      assert.isTrue(element.editing);
-
-      element._messageText = 'hello world';
-      const eraseMessageDraftSpy = spyStorage('eraseDraftComment');
-      const mockEvent = {...new Event('click'), preventDefault: sinon.stub()};
-      element._handleSave(mockEvent);
-      await flush();
-      assert.isTrue(eraseMessageDraftSpy.called);
-    });
-
-    test('draft creation/cancellation', async () => {
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      assert.isFalse(element.editing);
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      assert.isTrue(element.editing);
-
-      element.comment!.message = '';
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(
-        element,
-        '_eraseDraftCommentFromStorage'
-      );
-
-      // Save should be disabled on an empty message.
-      let disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-      element._messageText = '     ';
-      disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-
-      const updateStub = sinon.stub();
-      element.addEventListener('comment-update', updateStub);
-
-      let numDiscardEvents = 0;
-      const promise = mockPromise();
-      element.addEventListener('comment-discard', () => {
-        numDiscardEvents++;
-        assert.isFalse(eraseMessageDraftSpy.called);
-        if (numDiscardEvents === 2) {
-          assert.isFalse(updateStub.called);
-          promise.resolve();
-        }
-      });
-      tap(queryAndAssert(element, '.cancel'));
-      await flush();
-      element._messageText = '';
-      element.editing = true;
-      await flush();
-      pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
-      await promise;
-    });
-
-    test('draft discard removes message from storage', async () => {
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(
-        element,
-        '_eraseDraftCommentFromStorage'
-      );
-
-      const promise = mockPromise();
-      element.addEventListener('comment-discard', () => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        promise.resolve();
-      });
-      element._handleDiscard({
-        ...new Event('click'),
-        preventDefault: sinon.stub(),
-      });
-      await promise;
-    });
-
-    test('storage is cleared only after save success', () => {
-      element._messageText = 'test';
-      const eraseStub = sinon.stub(element, '_eraseDraftCommentFromStorage');
-      stubRestApi('getResponseObject').returns(
-        Promise.resolve({...(createDraft() as ParsedJSON)})
-      );
-      const saveDraftStub = sinon
-        .stub(element, '_saveDraft')
-        .returns(Promise.resolve({...new Response(), ok: false}));
-
-      const savePromise = element.save();
-      assert.isFalse(eraseStub.called);
-      return savePromise.then(() => {
-        assert.isFalse(eraseStub.called);
-
-        saveDraftStub.restore();
-        sinon
-          .stub(element, '_saveDraft')
-          .returns(Promise.resolve({...new Response(), ok: true}));
-        return element.save().then(() => {
-          assert.isTrue(eraseStub.called);
-        });
-      });
-    });
-
-    test('_computeSaveDisabled', () => {
-      const comment = {unresolved: true};
-      const msgComment = {message: 'test', unresolved: true};
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-      assert.equal(element._computeSaveDisabled('test', comment, false), false);
-      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
-      assert.equal(
-        element._computeSaveDisabled('test', msgComment, false),
-        false
-      );
-      assert.equal(
-        element._computeSaveDisabled('test2', msgComment, false),
-        false
-      );
-      assert.equal(element._computeSaveDisabled('test', comment, true), false);
-      assert.equal(element._computeSaveDisabled('', comment, true), true);
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      element.saving = true;
+      await element.updateComplete;
+      assert.isTrue(element.isSaveDisabled());
     });
 
     test('ctrl+s saves comment', async () => {
-      const promise = mockPromise();
-      const stub = sinon.stub(element, 'save').callsFake(() => {
-        assert.isTrue(stub.called);
-        stub.restore();
-        promise.resolve();
-        return Promise.resolve();
-      });
-      element._messageText = 'is that the horse from horsing around??';
+      const spy = sinon.stub(element, 'save');
+      element.messageText = 'is that the horse from horsing around??';
       element.editing = true;
-      await flush();
-      pressAndReleaseKeyOn(
-        element.textarea!.$.textarea.textarea,
-        83,
-        'ctrl',
-        's'
-      );
-      await promise;
+      await element.updateComplete;
+      pressKey(element.textarea!.textarea!.textarea, 's', Modifier.CTRL_KEY);
+      assert.isTrue(spy.called);
     });
 
-    test('draft saving/editing', async () => {
-      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      const fireEditStub = sinon.stub(element, '_fireEdit');
-      const clock: SinonFakeTimers = sinon.useFakeTimers();
-      const tickAndFlush = async (repetitions: number) => {
-        for (let i = 1; i <= repetitions; i++) {
-          clock.tick(1000);
-          await flush();
-        }
-      };
+    test('save', async () => {
+      const savePromise = mockPromise<DraftInfo>();
+      const stub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
 
-      element.draft = true;
-      await flush();
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.called);
-      tickAndFlush(1);
-      element._messageText = 'good news, everyone!';
-      tickAndFlush(1);
-      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      element._messageText = 'good news, everyone!';
-      await flush();
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      tap(queryAndAssert(element, '.save'));
-
-      assert.isTrue(
-        element.disabled,
-        'Element should be disabled when creating draft.'
-      );
-
-      let draft = await element._xhrPromise!;
-      const evt = dispatchEventStub.lastCall.args[0] as CustomEvent<{
-        comment: DraftInfo;
-      }>;
-      assert.equal(evt.type, 'comment-save');
-
-      const expectedDetail = {
-        comment: {
-          ...createComment(),
-          __draft: true,
-          __draftID: 'temp_draft_id',
-          id: 'baf0414d_40572e03' as UrlEncodedCommentId,
-          line: 5,
-          message: 'saved!',
-          path: '/path/to/file',
-          updated: '2015-12-08 21:52:36.177000000' as Timestamp,
-        },
-        patchNum: 1 as PatchSetNum,
-      };
-
-      assert.deepEqual(evt.detail, expectedDetail);
-      assert.isFalse(
-        element.disabled,
-        'Element should be enabled when done creating draft.'
-      );
-      assert.equal(draft.message, 'saved!');
-      assert.isFalse(element.editing);
-      tap(queryAndAssert(element, '.edit'));
-      assert.isTrue(fireEditStub.calledTwice);
-      element._messageText =
-        'You’ll be delivering a package to Chapek 9, ' +
-        'a world where humans are killed on sight.';
-      tap(queryAndAssert(element, '.save'));
-      assert.isTrue(
-        element.disabled,
-        'Element should be disabled when updating draft.'
-      );
-      draft = await element._xhrPromise!;
-      assert.isFalse(
-        element.disabled,
-        'Element should be enabled when done updating draft.'
-      );
-      assert.equal(draft.message, 'saved!');
-      assert.isFalse(element.editing);
-      dispatchEventStub.restore();
-    });
-
-    test('draft prevent save when disabled', async () => {
-      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
-      sinon.stub(element, '_fireEdit');
-      element.showActions = true;
-      element.draft = true;
-      await flush();
-      tap(element.$.header);
-      tap(queryAndAssert(element, '.edit'));
-      element._messageText = 'good news, everyone!';
-      await flush();
-
-      element.disabled = true;
-      tap(queryAndAssert(element, '.save'));
-      assert.isFalse(saveStub.called);
-
-      element.disabled = false;
-      tap(queryAndAssert(element, '.save'));
-      assert.isTrue(saveStub.calledOnce);
-    });
-
-    test('proper event fires on resolve, comment is not saved', async () => {
-      const save = sinon.stub(element, 'save');
-      const promise = mockPromise();
-      element.addEventListener('comment-update', e => {
-        assert.isTrue(e.detail.comment.unresolved);
-        assert.isFalse(save.called);
-        promise.resolve();
-      });
-      tap(queryAndAssert(element, '.resolve input'));
-      await promise;
-    });
-
-    test('resolved comment state indicated by checkbox', () => {
-      sinon.stub(element, 'save');
-      element.comment = {unresolved: false};
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      element.comment = {unresolved: true};
-      assert.isFalse(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-    });
-
-    test('resolved checkbox saves with tap when !editing', () => {
-      element.editing = false;
-      const save = sinon.stub(element, 'save');
-
-      element.comment = {unresolved: false};
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      element.comment = {unresolved: true};
-      assert.isFalse(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      assert.isFalse(save.called);
-      tap(element.$.resolvedCheckbox);
-      assert.isTrue(
-        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
-      );
-      assert.isTrue(save.called);
-    });
-
-    suite('draft saving messages', () => {
-      test('_getSavingMessage', () => {
-        assert.equal(element._getSavingMessage(0), 'All changes saved');
-        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
-        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
-        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
-      });
-
-      test('_show{Start,End}Request', () => {
-        const updateStub = sinon.stub(element, '_updateRequestToast');
-        element._numPendingDraftRequests.number = 1;
-
-        element._showStartRequest();
-        assert.isTrue(updateStub.calledOnce);
-        assert.equal(updateStub.lastCall.args[0], 2);
-        assert.equal(element._numPendingDraftRequests.number, 2);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledTwice);
-        assert.equal(updateStub.lastCall.args[0], 1);
-        assert.equal(element._numPendingDraftRequests.number, 1);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledThrice);
-        assert.equal(updateStub.lastCall.args[0], 0);
-        assert.equal(element._numPendingDraftRequests.number, 0);
-      });
-    });
-
-    test('cancelling an unsaved draft discards, persists in storage', async () => {
-      const clock: SinonFakeTimers = sinon.useFakeTimers();
-      const tickAndFlush = async (repetitions: number) => {
-        for (let i = 1; i <= repetitions; i++) {
-          clock.tick(1000);
-          await flush();
-        }
-      };
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      const eraseStub = stubStorage('eraseDraftComment');
-      element.comment!.id = undefined; // set id undefined for draft
-      element._messageText = 'test text';
-      tickAndFlush(1);
-
-      assert.isTrue(storeStub.called);
-      assert.equal(storeStub.lastCall.args[1], 'test text');
-      element._handleCancel({
-        ...new Event('click'),
-        preventDefault: sinon.stub(),
-      });
-      await flush();
-      assert.isTrue(discardSpy.called);
-      assert.isFalse(eraseStub.called);
-    });
-
-    test('cancelling edit on a saved draft does not store', () => {
-      element.comment!.id = 'foo' as UrlEncodedCommentId;
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      element.comment!.id = undefined; // set id undefined for draft
-      element._messageText = 'test text';
-      flush();
-
-      assert.isFalse(storeStub.called);
-      element._handleCancel({...new Event('click'), preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-    });
-
-    test('deleting text from saved draft and saving deletes the draft', () => {
-      element.comment = {
-        ...createComment(),
-        id: 'foo' as UrlEncodedCommentId,
-        message: 'test',
-      };
-      element._messageText = '';
-      const discardStub = sinon.stub(element, '_discardDraft');
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+      const textToSave = 'something, not important';
+      element.messageText = textToSave;
+      element.unresolved = true;
+      await element.updateComplete;
 
       element.save();
-      assert.isTrue(discardStub.called);
+
+      await element.updateComplete;
+      waitUntilCalled(stub, 'saveDraft()');
+      assert.equal(stub.lastCall.firstArg.message, textToSave);
+      assert.equal(stub.lastCall.firstArg.unresolved, true);
+      assert.isTrue(element.editing);
+      assert.isTrue(element.saving);
+
+      savePromise.resolve();
+      await element.updateComplete;
+
+      assert.isFalse(element.editing);
+      assert.isFalse(element.saving);
     });
 
-    test('_handleFix fires create-fix event', async () => {
-      const promise = mockPromise();
-      element.addEventListener(
-        'create-fix-comment',
-        (e: CreateFixCommentEvent) => {
-          assert.deepEqual(e.detail, element._getEventPayload());
-          promise.resolve();
-        }
-      );
-      element.isRobotComment = true;
-      element.comments = [element.comment!];
-      await flush();
+    test('previewing formatting triggers save', async () => {
+      element.permanentEditingMode = true;
 
-      tap(queryAndAssert(element, '.fix'));
-      await promise;
+      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
+
+      element.comment = createDraft();
+      element.editing = true;
+      element.messageText = 'something, not important';
+      await element.updateComplete;
+
+      assert.isFalse(saveStub.called);
+
+      queryAndAssert<GrButton>(element, '.save').click();
+
+      assert.isTrue(saveStub.called);
     });
 
-    test('do not show Please Fix button if human reply exists', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id' as RobotId,
-          robot_run_id: '5838406743490560' as RobotRunId,
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf' as FixId,
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912 as AccountId,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com' as EmailAddress,
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-                width: 32,
-              },
-            ],
-          },
-          patch_set: 1 as PatchSetNum,
-          ...createComment(),
-          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          collapsed: false,
-        },
-        {
-          __draft: true,
-          __draftID: '0.wbrfbwj89sa',
-          __date: new Date(),
-          path: 'Documentation/config-gerrit.txt',
-          side: CommentSide.REVISION,
-          line: 10,
-          in_reply_to: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          message: '> This is a robot comment with a fix.\n\nPlease fix.',
-          unresolved: true,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      assert.isNull(
-        element.shadowRoot?.querySelector('robotActions gr-button')
-      );
+    test('save failed', async () => {
+      sinon
+        .stub(commentsModel, 'saveDraft')
+        .returns(Promise.reject(new Error('saving failed')));
+
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+      element.messageText = 'something, not important';
+      await element.updateComplete;
+
+      element.save();
+      await element.updateComplete;
+
+      assert.isTrue(element.unableToSave);
+      assert.isTrue(element.editing);
+      assert.isFalse(element.saving);
     });
 
-    test('show Please Fix if no human reply', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id' as RobotId,
-          robot_run_id: '5838406743490560' as RobotRunId,
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf' as FixId,
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912 as AccountId,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com' as EmailAddress,
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-                width: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-                width: 32,
-              },
-            ],
-          },
-          patch_set: 1 as PatchSetNum,
-          ...createComment(),
-          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          collapsed: false,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      queryAndAssert(element, '.robotActions gr-button');
+    test('discard', async () => {
+      const discardPromise = mockPromise<void>();
+      const stub = sinon
+        .stub(commentsModel, 'discardDraft')
+        .returns(discardPromise);
+
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+
+      element.discard();
+
+      await element.updateComplete;
+      waitUntilCalled(stub, 'discardDraft()');
+      assert.equal(stub.lastCall.firstArg, element.comment.id);
+      assert.isTrue(element.editing);
+      assert.isTrue(element.saving);
+
+      discardPromise.resolve();
+      await element.updateComplete;
+
+      assert.isFalse(element.editing);
+      assert.isFalse(element.saving);
     });
 
-    test('_handleShowFix fires open-fix-preview event', async () => {
-      const promise = mockPromise();
-      element.addEventListener('open-fix-preview', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        promise.resolve();
-      });
+    test('resolved comment state indicated by checkbox', async () => {
+      const saveStub = sinon.stub(commentsModel, 'saveDraft');
       element.comment = {
         ...createComment(),
+        __draft: true,
+        unresolved: false,
+      };
+      await element.updateComplete;
+
+      let checkbox = queryAndAssert<HTMLInputElement>(
+        element,
+        '#resolvedCheckbox'
+      );
+      assert.isTrue(checkbox.checked);
+
+      checkbox.click();
+      await element.updateComplete;
+
+      checkbox = queryAndAssert<HTMLInputElement>(element, '#resolvedCheckbox');
+      assert.isFalse(checkbox.checked);
+
+      assert.isTrue(saveStub.called);
+    });
+
+    test('saving empty text calls discard()', async () => {
+      const saveStub = sinon.stub(commentsModel, 'saveDraft');
+      const discardStub = sinon.stub(commentsModel, 'discardDraft');
+      element.comment = createDraft();
+      element.editing = true;
+      await element.updateComplete;
+
+      element.messageText = '';
+      await element.updateComplete;
+
+      await element.save();
+      assert.isTrue(discardStub.called);
+      assert.isFalse(saveStub.called);
+    });
+
+    test('handlePleaseFix fires reply-to-comment event', async () => {
+      const listener = listenOnce<ReplyToCommentEvent>(
+        element,
+        'reply-to-comment'
+      );
+      element.comment = createRobotComment();
+      element.comments = [element.comment];
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(element, '.fix').click();
+
+      const e = await listener;
+      assert.equal(e.detail.unresolved, true);
+      assert.equal(e.detail.userWantsToEdit, false);
+      assert.isTrue(e.detail.content.includes('Please fix.'));
+    });
+
+    test('do not show Please Fix button if human reply exists', async () => {
+      element.initiallyCollapsed = false;
+      const robotComment = createRobotComment();
+      element.comment = robotComment;
+      await element.updateComplete;
+
+      let actions = query(element, '.robotActions gr-button.fix');
+      assert.isOk(actions);
+
+      element.comments = [
+        robotComment,
+        {...createComment(), in_reply_to: robotComment.id},
+      ];
+      await element.updateComplete;
+      actions = query(element, '.robotActions gr-button.fix');
+      assert.isNotOk(actions);
+    });
+
+    test('handleShowFix fires open-fix-preview event', async () => {
+      const listener = listenOnce<CustomEvent<OpenFixPreviewEventDetail>>(
+        element,
+        'open-fix-preview'
+      );
+      element.comment = {
+        ...createRobotComment(),
         fix_suggestions: [{...createFixSuggestionInfo()}],
       };
-      element.isRobotComment = true;
-      await flush();
+      await element.updateComplete;
 
-      tap(queryAndAssert(element, '.show-fix'));
-      await promise;
+      queryAndAssert<GrButton>(element, '.show-fix').click();
+
+      const e = await listener;
+      assert.deepEqual(e.detail, await element.createFixPreview());
     });
   });
 
-  suite('respectful tips', () => {
-    let element: GrComment;
-
+  suite('auto saving', () => {
     let clock: sinon.SinonFakeTimers;
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    let savePromise: MockPromise<DraftInfo>;
+    let saveStub: SinonStub;
+
+    setup(async () => {
       clock = sinon.useFakeTimers();
+      savePromise = mockPromise<DraftInfo>();
+      saveStub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
+
+      element.comment = createUnsaved();
+      element.editing = true;
+      await element.updateComplete;
     });
 
     teardown(() => {
@@ -1514,85 +786,116 @@
       sinon.restore();
     });
 
-    test('show tip when no cached record', async () => {
-      element = draftFixture.instantiate() as GrComment;
-      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
-      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
-      assert.isTrue(respectfulGetStub.called);
-      assert.isTrue(respectfulSetStub.called);
-      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+    test('basic auto saving', async () => {
+      const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
+      dispatch(textarea, 'text-changed', {value: 'some new text  '});
+
+      clock.tick(AUTO_SAVE_DEBOUNCE_DELAY_MS / 2);
+      assert.isFalse(saveStub.called);
+
+      clock.tick(AUTO_SAVE_DEBOUNCE_DELAY_MS);
+      assert.isTrue(saveStub.called);
+      assert.equal(
+        saveStub.firstCall.firstArg.message,
+        'some new text  '.trimEnd()
+      );
     });
 
-    test('add 14-day delays once dismissed', async () => {
-      element = draftFixture.instantiate() as GrComment;
-      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
-      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
-      assert.isTrue(respectfulGetStub.called);
-      assert.isTrue(respectfulSetStub.called);
-      assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
-      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+    test('saving while auto saving', async () => {
+      const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
+      dispatch(textarea, 'text-changed', {value: 'auto save text'});
 
-      tap(queryAndAssert(element, '.respectfulReviewTip .close'));
-      flush();
-      assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
+      clock.tick(2 * AUTO_SAVE_DEBOUNCE_DELAY_MS);
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.firstCall.firstArg.message, 'auto save text');
+      saveStub.reset();
+
+      element.messageText = 'actual save text';
+      const save = element.save();
+      await element.updateComplete;
+      // First wait for the auto saving to finish.
+      assert.isFalse(saveStub.called);
+
+      // Resolve auto-saving promise.
+      savePromise.resolve({
+        ...element.comment,
+        __draft: true,
+        id: 'exp123' as UrlEncodedCommentId,
+        updated: '2018-02-13 22:48:48.018000000' as Timestamp,
+      });
+      await save;
+      // Only then save.
+      assert.isTrue(saveStub.called);
+      assert.equal(saveStub.firstCall.firstArg.message, 'actual save text');
+      assert.equal(saveStub.firstCall.firstArg.id, 'exp123');
     });
+  });
 
-    test('do not show tip when fall out of probability', async () => {
-      element = draftFixture.instantiate() as GrComment;
-      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
-      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 3;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
-      assert.isTrue(respectfulGetStub.called);
-      assert.isFalse(respectfulSetStub.called);
-      assert.isNotOk(query(element, '.respectfulReviewTip'));
-    });
-
-    test('show tip when editing changed to true', async () => {
-      element = draftFixture.instantiate() as GrComment;
-      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
-      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
-      respectfulGetStub.returns(null);
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: false};
-      await flush();
-      assert.isFalse(respectfulGetStub.called);
-      assert.isFalse(respectfulSetStub.called);
-      assert.isNotOk(query(element, '.respectfulReviewTip'));
-
+  suite('suggest edit', () => {
+    let element: GrComment;
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      const comment = {
+        ...createComment(),
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        __draft: true,
+        message: 'hello world',
+      };
+      element = await fixture(
+        html`<gr-comment
+          .account=${account}
+          .showPatchset=${true}
+          .comment=${comment}
+          .initiallyCollapsed=${false}
+        ></gr-comment>`
+      );
       element.editing = true;
-      await flush();
-      assert.isTrue(respectfulGetStub.called);
-      assert.isTrue(respectfulSetStub.called);
-      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+    });
+    test('renders suggest fix button', () => {
+      assert.dom.equal(
+        queryAndAssert(element, 'gr-button.suggestEdit'),
+        /* HTML */ `<gr-button
+          class="action suggestEdit"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          Suggest Fix
+        </gr-button> `
+      );
     });
 
-    test('no tip when cached record', async () => {
-      element = draftFixture.instantiate() as GrComment;
-      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
-      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
-      respectfulGetStub.returns({updated: 0});
-      // fake random
-      element.getRandomNum = () => 0;
-      element.comment = {__editing: true, __draft: true};
-      await flush();
-      assert.isTrue(respectfulGetStub.called);
-      assert.isFalse(respectfulSetStub.called);
-      assert.isNotOk(query(element, '.respectfulReviewTip'));
+    test('renders preview suggest fix', async () => {
+      element.comment = {
+        ...createComment(),
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        message: `${USER_SUGGESTION_START_PATTERN}afterSuggestion${'\n```'}`,
+      };
+      await element.updateComplete;
+
+      assert.dom.equal(
+        queryAndAssert(element, 'gr-button.show-fix'),
+        /* HTML */ `<gr-button
+          aria-disabled="false"
+          class="action show-fix"
+          link=""
+          role="button"
+          secondary
+          tabindex="0"
+        >
+          Preview Fix
+        </gr-button> `
+      );
     });
   });
 });
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 0a9b9c3..285a41a 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
@@ -1,46 +1,25 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-delete-comment-dialog_html';
-import {property, customElement} from '@polymer/decorators';
+import {css, html, LitElement} from 'lit';
+import {property, query, customElement} from 'lit/decorators.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-confirm-delete-comment-dialog': GrConfirmDeleteCommentDialog;
   }
 }
-export interface GrConfirmDeleteCommentDialog {
-  $: {
-    messageInput: IronAutogrowTextareaElement;
-  };
-}
 
 @customElement('gr-confirm-delete-comment-dialog')
-export class GrConfirmDeleteCommentDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  static get is() {
-    return 'gr-confirm-delete-comment-dialog';
-  }
+export class GrConfirmDeleteCommentDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -53,14 +32,80 @@
    * @event cancel
    */
 
+  @query('#messageInput')
+  messageInput?: IronAutogrowTextareaElement;
+
   @property({type: String})
   message = '';
 
-  resetFocus() {
-    this.$.messageInput.textarea.focus();
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        p {
+          margin-bottom: var(--spacing-l);
+        }
+        label {
+          cursor: pointer;
+          display: block;
+          width: 100%;
+        }
+        iron-autogrow-textarea {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          width: 73ch; /* Add a char to account for the border. */
+        }
+      `,
+    ];
   }
 
-  _handleConfirmTap(e: Event) {
+  override render() {
+    return html` <gr-dialog
+      confirm-label="Delete"
+      ?disabled=${this.message === ''}
+      @confirm=${this.handleConfirmTap}
+      @cancel=${this.handleCancelTap}
+    >
+      <div class="header" slot="header">Delete Comment</div>
+      <div class="main" slot="main">
+        <p>
+          This is an admin function. Please only use in exceptional
+          circumstances.
+        </p>
+        <label for="messageInput">Enter comment delete reason</label>
+        <iron-autogrow-textarea
+          id="messageInput"
+          class="message"
+          autocomplete="on"
+          placeholder="&lt;Insert reasoning here&gt;"
+          .bindValue=${this.message}
+          @bind-value-changed=${(e: BindValueChangeEvent) => {
+            this.message = e.detail.value ?? '';
+          }}
+        ></iron-autogrow-textarea>
+      </div>
+    </gr-dialog>`;
+  }
+
+  resetFocus() {
+    assertIsDefined(this.messageInput, 'messageInput');
+    this.messageInput.textarea.focus();
+  }
+
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -72,7 +117,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts
deleted file mode 100644
index 6876c1a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts
+++ /dev/null
@@ -1,68 +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;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    p {
-      margin-bottom: var(--spacing-l);
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Delete"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Delete Comment</div>
-    <div class="main" slot="main">
-      <p>
-        This is an admin function. Please only use in exceptional circumstances.
-      </p>
-      <label for="messageInput">Enter comment delete reason</label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        placeholder="<Insert reasoning here>"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
new file mode 100644
index 0000000..b7551c1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
@@ -0,0 +1,59 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrConfirmDeleteCommentDialog} from './gr-confirm-delete-comment-dialog';
+import './gr-confirm-delete-comment-dialog';
+import {GrDialog} from '../gr-dialog/gr-dialog';
+
+suite('gr-confirm-delete-comment-dialog tests', () => {
+  let element: GrConfirmDeleteCommentDialog;
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-delete-comment-dialog></gr-confirm-delete-comment-dialog>`
+    );
+  });
+
+  test('render', async () => {
+    element.message = 'Just cause';
+    await element.updateComplete;
+
+    // prettier and shadowDom string disagree about wrapping in <p> tag.
+    assert.shadowDom.equal(
+      element,
+      /* prettier-ignore */ /* HTML */ `
+      <gr-dialog confirm-label="Delete" role="dialog">
+        <div class="header" slot="header">Delete Comment</div>
+        <div class="main" slot="main">
+          <p>
+            This is an admin function. Please only use in exceptional
+          circumstances.
+          </p>
+          <label for="messageInput"> Enter comment delete reason </label>
+          <iron-autogrow-textarea
+            aria-disabled="false"
+            autocomplete="on"
+            class="message"
+            id="messageInput"
+            placeholder="<Insert reasoning here>"
+          >
+          </iron-autogrow-textarea>
+        </div>
+      </gr-dialog>
+    `
+    );
+  });
+
+  test('dialog is disabled when message is empty', async () => {
+    element.message = '';
+    await element.updateComplete;
+
+    assert.isTrue(
+      (element.shadowRoot!.querySelector('gr-dialog') as GrDialog).disabled
+    );
+  });
+});
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 01422a9..350aa7f 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
@@ -1,29 +1,22 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../gr-button/gr-button';
-import '../gr-icons/gr-icons';
-import {IronIconElement} from '@polymer/iron-icon';
-import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
-import {classMap} from 'lit/directives/class-map';
-import {ifDefined} from 'lit/directives/if-defined';
+import '../gr-icon/gr-icon';
+import {
+  assertIsDefined,
+  copyToClipbard,
+  queryAndAssert,
+} from '../../../utils/common-util';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
+import {GrIcon} from '../gr-icon/gr-icon';
 
 const COPY_TIMEOUT_MS = 1000;
 
@@ -46,6 +39,9 @@
   @property({type: Boolean})
   hideInput = false;
 
+  @query('#icon')
+  iconEl!: GrIcon;
+
   static override get styles() {
     return [
       css`
@@ -66,27 +62,16 @@
           font-size: var(--font-size-mono);
           line-height: var(--line-height-mono);
           width: 100%;
+          background-color: var(--view-background-color);
+          color: var(--primary-text-color);
         }
-        /*
-         * Typically icons are 20px, which is the normal line-height.
-         * The copy icon is too prominent at 20px, so we choose 16px
-         * here, but add 2x2px padding below, so the entire
-         * component should still fit nicely into a normal inline
-         * layout flow.
-         */
-        #icon {
-          height: 16px;
-          width: 16px;
-        }
-        iron-icon {
+        gr-icon {
           color: var(--deemphasized-text-color);
-          vertical-align: top;
-          --iron-icon-height: 20px;
-          --iron-icon-width: 20px;
         }
         gr-button {
           display: block;
-          --gr-button-padding: 2px;
+          --gr-button-padding: var(--spacing-s);
+          margin: calc(0px - var(--spacing-s));
         }
       `,
     ];
@@ -97,15 +82,15 @@
       <div class="text">
         <iron-input
           class="copyText"
-          @click="${this._handleInputClick}"
+          @click=${this._handleInputClick}
           .bindValue=${this.text ?? ''}
         >
           <input
             id="input"
             is="iron-input"
-            class="${classMap({hideInput: this.hideInput})}"
+            class=${classMap({hideInput: this.hideInput})}
             type="text"
-            @click="${this._handleInputClick}"
+            @click=${this._handleInputClick}
             readonly=""
             .value=${this.text ?? ''}
             part="text-container-style"
@@ -113,16 +98,19 @@
         </iron-input>
         <gr-tooltip-content
           ?has-tooltip=${this.hasTooltip}
-          title="${ifDefined(this.buttonTitle)}"
+          title=${ifDefined(this.buttonTitle)}
         >
           <gr-button
             id="copy-clipboard-button"
             link=""
             class="copyToClipboard"
-            @click="${this._copyToClipboard}"
-            aria-label="Click to copy to clipboard"
+            @click=${this._copyToClipboard}
+            aria-label="copy"
+            aria-description="Click to copy to clipboard"
           >
-            <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
+            <div>
+              <gr-icon id="icon" icon="content_copy" small></gr-icon>
+            </div>
           </gr-button>
         </gr-tooltip-content>
       </div>
@@ -145,15 +133,8 @@
 
     this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
     assertIsDefined(this.text, 'text');
-    this.iconEl.icon = 'gr-icons:check';
-    navigator.clipboard.writeText(this.text);
-    setTimeout(
-      () => (this.iconEl.icon = 'gr-icons:content-copy'),
-      COPY_TIMEOUT_MS
-    );
-  }
-
-  private get iconEl(): IronIconElement {
-    return queryAndAssert<IronIconElement>(this, '#icon');
+    this.iconEl.icon = 'check';
+    copyToClipbard(this.text, 'Link');
+    setTimeout(() => (this.iconEl.icon = 'content_copy'), COPY_TIMEOUT_MS);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index ef62fe9..4c36a56 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -1,42 +1,67 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-copy-clipboard';
 import {GrCopyClipboard} from './gr-copy-clipboard';
 import {queryAndAssert} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-copy-clipboard');
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
 
 suite('gr-copy-clipboard tests', () => {
   let element: GrCopyClipboard;
+  let clipboardSpy: sinon.SinonStub;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    clipboardSpy = sinon
+      .stub(navigator.clipboard, 'writeText')
+      .returns(Promise.resolve());
+    sinon.spy(document, 'dispatchEvent');
+    element = await fixture(html`<gr-copy-clipboard></gr-copy-clipboard>`);
     element.text = `git fetch http://gerrit@localhost:8080/a/test-project
         refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="text">
+          <iron-input class="copyText">
+            <input
+              id="input"
+              is="iron-input"
+              part="text-container-style"
+              readonly=""
+              type="text"
+            />
+          </iron-input>
+          <gr-tooltip-content>
+            <gr-button
+              aria-disabled="false"
+              aria-label="copy"
+              aria-description="Click to copy to clipboard"
+              class="copyToClipboard"
+              id="copy-clipboard-button"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              <div>
+                <gr-icon icon="content_copy" id="icon" small></gr-icon>
+              </div>
+            </gr-button>
+          </gr-tooltip-content>
+        </div>
+      `
+    );
   });
 
   test('copy to clipboard', () => {
-    const clipboardSpy = sinon.spy(navigator.clipboard, 'writeText');
-    const copyBtn = queryAndAssert(element, '.copyToClipboard');
-    MockInteractions.click(copyBtn);
+    queryAndAssert<GrButton>(element, '.copyToClipboard').click();
     assert.isTrue(clipboardSpy.called);
   });
 
@@ -53,10 +78,10 @@
     const ironInputElement = queryAndAssert(element, 'iron-input');
     assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
 
-    const inputElement = queryAndAssert(element, 'input') as HTMLInputElement;
-    MockInteractions.tap(inputElement);
+    const inputElement = queryAndAssert<HTMLInputElement>(element, 'input');
+    inputElement.click();
     assert.equal(inputElement.selectionStart, 0);
-    assert.equal(inputElement.selectionEnd, element.text!.length! - 1);
+    assert.equal(inputElement.selectionEnd, element.text!.length - 1);
   });
 
   test('hideInput', async () => {
@@ -68,7 +93,7 @@
     const input = queryAndAssert(element, 'input');
     assert.notEqual(getComputedStyle(input).display, 'none');
     element.hideInput = true;
-    await flush();
+    await element.updateComplete;
     assert.equal(getComputedStyle(input).display, 'none');
   });
 
@@ -77,8 +102,7 @@
     divParent.appendChild(element);
     const clickStub = sinon.stub();
     divParent.addEventListener('click', clickStub);
-    const copyBtn = queryAndAssert(element, '.copyToClipboard');
-    MockInteractions.tap(copyBtn);
+    queryAndAssert<GrButton>(element, '.copyToClipboard').click();
     assert.isFalse(clickStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 9f65dd4..828672b 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {BehaviorSubject} from 'rxjs';
 import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
@@ -221,8 +210,10 @@
    *
    * @param noScroll prevent any potential scrolling in response
    * setting the cursor.
+   * @param applyFocus indicates if it should try to focus after move operation
+   * (e.g. focusOnMove).
    */
-  setCursor(element: HTMLElement, noScroll?: boolean) {
+  setCursor(element: HTMLElement, noScroll?: boolean, applyFocus?: boolean) {
     if (!this.targetableStops.includes(element)) {
       this.unsetCursor();
       return;
@@ -238,6 +229,9 @@
     this._updateIndex();
     this._decorateTarget();
 
+    if (applyFocus) {
+      this._focusAfterMove();
+    }
     if (noScroll && behavior) {
       this.scrollMode = behavior;
     }
@@ -341,15 +335,17 @@
       this._targetHeight = this.target.scrollHeight;
     }
 
-    if (this.focusOnMove) {
-      this.target.focus();
-    }
-
     this._decorateTarget();
-
+    this._focusAfterMove();
     return clipped ? CursorMoveResult.CLIPPED : CursorMoveResult.MOVED;
   }
 
+  _focusAfterMove() {
+    if (this.focusOnMove) {
+      this.target?.focus();
+    }
+  }
+
   _decorateTarget() {
     if (this.target && this.cursorTargetClass) {
       this.target.classList.add(this.cursorTargetClass);
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
deleted file mode 100644
index d0bd420..0000000
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ /dev/null
@@ -1,384 +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-cursor-manager.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {AbortStop, CursorMoveResult} from '../../../api/core.js';
-import {GrCursorManager} from './gr-cursor-manager.js';
-
-const basicTestFixutre = fixtureFromTemplate(html`
-    <ul>
-      <li>A</li>
-      <li>B</li>
-      <li>C</li>
-      <li>D</li>
-    </ul>
-`);
-
-suite('gr-cursor-manager tests', () => {
-  let cursor;
-  let list;
-
-  setup(() => {
-    list = basicTestFixutre.instantiate();
-    cursor = new GrCursorManager();
-    cursor.cursorTargetClass = 'targeted';
-  });
-
-  test('core cursor functionality', () => {
-    // The element is initialized into the proper state.
-    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.
-    cursor.stops = [...list.querySelectorAll('li')];
-
-    // It should have the stops but it should not be targeting any of them.
-    assert.isNotNull(cursor.stops);
-    assert.equal(cursor.stops.length, 4);
-    assert.equal(cursor.index, -1);
-    assert.isNotOk(cursor.target);
-
-    // Select the third stop.
-    cursor.setCursor(list.children[2]);
-
-    // It should update its internal state and update the element's class.
-    assert.equal(cursor.index, 2);
-    assert.equal(cursor.target, list.children[2]);
-    assert.isTrue(list.children[2].classList.contains('targeted'));
-    assert.isFalse(cursor.isAtStart());
-    assert.isFalse(cursor.isAtEnd());
-
-    // Progress the cursor.
-    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(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 = cursor.next();
-
-    // We should still be at the end.
-    assert.equal(result, CursorMoveResult.CLIPPED);
-    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 = cursor.previous();
-    assert.equal(result, CursorMoveResult.MOVED);
-    result = cursor.previous();
-    assert.equal(result, CursorMoveResult.MOVED);
-    result = cursor.previous();
-    assert.equal(result, CursorMoveResult.MOVED);
-
-    // The element state should reflect the start of the list.
-    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]);
-    cursor.stops = [...list.querySelectorAll('li')];
-
-    assert.equal(cursor.index, 1);
-
-    // De-select all targets.
-    cursor.unsetCursor();
-
-    // There should now be no cursor target.
-    assert.isFalse(list.children[1].classList.contains('targeted'));
-    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', () => {
-    cursor.stops = [...list.querySelectorAll('li')];
-    const result = cursor.next();
-
-    assert.equal(result, CursorMoveResult.MOVED);
-    assert.equal(cursor.index, 0);
-    assert.equal(cursor.target, list.children[0]);
-    assert.isTrue(list.children[0].classList.contains('targeted'));
-    assert.isTrue(cursor.isAtStart());
-    assert.isFalse(cursor.isAtEnd());
-  });
-
-  test('next() resets the cursor when there are no stops', () => {
-    cursor.stops = [];
-    const result = cursor.next();
-
-    assert.equal(result, CursorMoveResult.NO_STOPS);
-    assert.equal(cursor.index, -1);
-    assert.isNotOk(cursor.target);
-    assert.isFalse(list.children[1].classList.contains('targeted'));
-  });
-
-  test('previous() goes to last element when no cursor is set', () => {
-    cursor.stops = [...list.querySelectorAll('li')];
-    const result = cursor.previous();
-
-    assert.equal(result, CursorMoveResult.MOVED);
-    const lastIndex = list.children.length - 1;
-    assert.equal(cursor.index, lastIndex);
-    assert.equal(cursor.target, list.children[lastIndex]);
-    assert.isTrue(list.children[lastIndex].classList.contains('targeted'));
-    assert.isFalse(cursor.isAtStart());
-    assert.isTrue(cursor.isAtEnd());
-  });
-
-  test('previous() resets the cursor when there are no stops', () => {
-    cursor.stops = [];
-    const result = cursor.previous();
-
-    assert.equal(result, CursorMoveResult.NO_STOPS);
-    assert.equal(cursor.index, -1);
-    assert.isNotOk(cursor.target);
-    assert.isFalse(list.children[1].classList.contains('targeted'));
-  });
-
-  test('_moveCursor', () => {
-    // Initialize the cursor with its stops.
-    cursor.stops = [...list.querySelectorAll('li')];
-    // Select the first stop.
-    cursor.setCursor(list.children[0]);
-    const getTargetHeight = sinon.stub();
-
-    // Move the cursor without an optional get target height function.
-    cursor._moveCursor(1);
-    assert.isFalse(getTargetHeight.called);
-
-    // Move the cursor with an optional get target height function.
-    cursor._moveCursor(1, {getTargetHeight});
-    assert.isTrue(getTargetHeight.called);
-  });
-
-  test('_moveCursor from for invalid index does not check height', () => {
-    cursor.stops = [];
-    const getTargetHeight = sinon.stub();
-    cursor._moveCursor(1, () => false, {getTargetHeight});
-    assert.isFalse(getTargetHeight.called);
-  });
-
-  test('setCursorAtIndex with noScroll', () => {
-    sinon.stub(cursor, '_targetIsVisible').callsFake(() => false);
-    const scrollStub = sinon.stub(window, 'scrollTo');
-    cursor.stops = [...list.querySelectorAll('li')];
-    cursor.scrollMode = 'keep-visible';
-
-    cursor.setCursorAtIndex(1, true);
-    assert.isFalse(scrollStub.called);
-
-    cursor.setCursorAtIndex(2);
-    assert.isTrue(scrollStub.called);
-  });
-
-  test('move with filter', () => {
-    const isLetterB = function(row) {
-      return row.textContent === 'B';
-    };
-    cursor.stops = [...list.querySelectorAll('li')];
-    // Start cursor at the first stop.
-    cursor.setCursor(list.children[0]);
-
-    // Move forward to meet the next condition.
-    cursor.next({filter: isLetterB});
-    assert.equal(cursor.index, 1);
-
-    // Nothing else meets the condition, should be at last stop.
-    cursor.next({filter: isLetterB});
-    assert.equal(cursor.index, 3);
-
-    // Should stay at last stop if try to proceed.
-    cursor.next({filter: isLetterB});
-    assert.equal(cursor.index, 3);
-
-    // Go back to the previous condition met. Should be back at.
-    // stop 1.
-    cursor.previous({filter: isLetterB});
-    assert.equal(cursor.index, 1);
-
-    // Go back. No more meet the condition. Should be at stop 0.
-    cursor.previous({filter: isLetterB});
-    assert.equal(cursor.index, 0);
-  });
-
-  test('focusOnMove prop', () => {
-    const listEls = [...list.querySelectorAll('li')];
-    for (let i = 0; i < listEls.length; i++) {
-      sinon.spy(listEls[i], 'focus');
-    }
-    cursor.stops = listEls;
-    cursor.setCursor(list.children[0]);
-
-    cursor.focusOnMove = false;
-    cursor.next();
-    assert.isFalse(cursor.target.focus.called);
-
-    cursor.focusOnMove = true;
-    cursor.next();
-    assert.isTrue(cursor.target.focus.called);
-  });
-
-  suite('circular options', () => {
-    const options = {circular: true};
-    setup(() => {
-      cursor.stops = [...list.querySelectorAll('li')];
-    });
-
-    test('previous() on first element goes to last element', () => {
-      cursor.setCursor(list.children[0]);
-      cursor.previous(options);
-      assert.equal(cursor.index, list.children.length - 1);
-    });
-
-    test('next() on last element goes to first element', () => {
-      cursor.setCursor(list.children[list.children.length - 1]);
-      cursor.next(options);
-      assert.equal(cursor.index, 0);
-    });
-  });
-
-  suite('_scrollToTarget', () => {
-    let scrollStub;
-    setup(() => {
-      cursor.stops = [...list.querySelectorAll('li')];
-      cursor.scrollMode = 'keep-visible';
-
-      // There is a target which has a targetNext
-      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(cursor, '_targetIsVisible').returns(false);
-      cursor._scrollToTarget();
-      assert.isTrue(scrollStub.called);
-    });
-
-    test('Not called when top and bottom visible', () => {
-      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(cursor, '_targetIsVisible').callsFake(
-          () => visibleStub.callCount === 2);
-      window.scrollX = 123;
-      window.scrollY = 15;
-      window.innerHeight = 1000;
-      window.pageYOffset = 0;
-      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(cursor, '_targetIsVisible').callsFake(
-          () => visibleStub.callCount === 2);
-      window.scrollX = 123;
-      window.scrollY = 25;
-      window.innerHeight = 1000;
-      window.pageYOffset = 0;
-      sinon.stub(cursor, '_calculateScrollToValue').returns(20);
-      cursor._scrollToTarget();
-      assert.isFalse(scrollStub.called);
-      assert.equal(visibleStub.callCount, 2);
-    });
-
-    test('_calculateScrollToValue', () => {
-      window.scrollX = 123;
-      window.scrollY = 25;
-      window.innerHeight = 300;
-      window.pageYOffset = 0;
-      assert.equal(cursor._calculateScrollToValue(1000, {offsetHeight: 10}),
-          905);
-    });
-  });
-
-  suite('AbortStops', () => {
-    test('next() does not skip AbortStops', () => {
-      cursor.stops = [
-        document.createElement('li'),
-        new AbortStop(),
-        document.createElement('li'),
-      ];
-      cursor.setCursorAtIndex(0);
-
-      const result = cursor.next();
-
-      assert.equal(result, CursorMoveResult.ABORTED);
-      assert.equal(cursor.index, 0);
-    });
-
-    test('setCursorAtIndex() does not target AbortStops', () => {
-      cursor.stops = [
-        document.createElement('li'),
-        new AbortStop(),
-        document.createElement('li'),
-      ];
-      cursor.setCursorAtIndex(1);
-      assert.equal(cursor.index, -1);
-    });
-
-    test('moveToStart() does not target AbortStop', () => {
-      cursor.stops = [
-        new AbortStop(),
-        document.createElement('li'),
-        document.createElement('li'),
-      ];
-      cursor.moveToStart();
-      assert.equal(cursor.index, -1);
-    });
-
-    test('moveToEnd() does not target AbortStop', () => {
-      cursor.stops = [
-        document.createElement('li'),
-        document.createElement('li'),
-        new AbortStop(),
-      ];
-      cursor.moveToEnd();
-      assert.equal(cursor.index, -1);
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts
new file mode 100644
index 0000000..81d2b45
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts
@@ -0,0 +1,369 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {AbortStop, CursorMoveResult} from '../../../api/core';
+import {GrCursorManager} from './gr-cursor-manager';
+
+suite('gr-cursor-manager tests', () => {
+  let cursor: GrCursorManager;
+  let list: Element;
+
+  setup(async () => {
+    list = await fixture(html` <ul>
+      <li>A</li>
+      <li>B</li>
+      <li>C</li>
+      <li>D</li>
+    </ul>`);
+    cursor = new GrCursorManager();
+    cursor.cursorTargetClass = 'targeted';
+  });
+
+  test('core cursor functionality', () => {
+    // The element is initialized into the proper state.
+    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.
+    cursor.stops = [...list.querySelectorAll('li')];
+
+    // It should have the stops but it should not be targeting any of them.
+    assert.isNotNull(cursor.stops);
+    assert.equal(cursor.stops.length, 4);
+    assert.equal(cursor.index, -1);
+    assert.isNotOk(cursor.target);
+
+    // Select the third stop.
+    cursor.setCursor(list.children[2] as HTMLElement);
+
+    // It should update its internal state and update the element's class.
+    assert.equal(cursor.index, 2);
+    assert.equal(cursor.target, list.children[2] as HTMLElement);
+    assert.isTrue(list.children[2].classList.contains('targeted'));
+    assert.isFalse(cursor.isAtStart());
+    assert.isFalse(cursor.isAtEnd());
+
+    // Progress the cursor.
+    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(cursor.index, 3);
+    assert.equal(cursor.target, list.children[3] as HTMLElement);
+    assert.isTrue(cursor.isAtEnd());
+    assert.isFalse(list.children[2].classList.contains('targeted'));
+    assert.isTrue(list.children[3].classList.contains('targeted'));
+
+    // Progress the cursor.
+    result = cursor.next();
+
+    // We should still be at the end.
+    assert.equal(result, CursorMoveResult.CLIPPED);
+    assert.equal(cursor.index, 3);
+    assert.equal(cursor.target, list.children[3] as HTMLElement);
+    assert.isTrue(cursor.isAtEnd());
+
+    // Wind the cursor all the way back to the first stop.
+    result = cursor.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
+    result = cursor.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
+    result = cursor.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
+
+    // The element state should reflect the start of the list.
+    assert.equal(cursor.index, 0);
+    assert.equal(cursor.target, list.children[0] as HTMLElement);
+    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]);
+    cursor.stops = [...list.querySelectorAll('li')];
+
+    assert.equal(cursor.index, 1);
+
+    // De-select all targets.
+    cursor.unsetCursor();
+
+    // There should now be no cursor target.
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+    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', () => {
+    cursor.stops = [...list.querySelectorAll('li')];
+    const result = cursor.next();
+
+    assert.equal(result, CursorMoveResult.MOVED);
+    assert.equal(cursor.index, 0);
+    assert.equal(cursor.target, list.children[0] as HTMLElement);
+    assert.isTrue(list.children[0].classList.contains('targeted'));
+    assert.isTrue(cursor.isAtStart());
+    assert.isFalse(cursor.isAtEnd());
+  });
+
+  test('next() resets the cursor when there are no stops', () => {
+    cursor.stops = [];
+    const result = cursor.next();
+
+    assert.equal(result, CursorMoveResult.NO_STOPS);
+    assert.equal(cursor.index, -1);
+    assert.isNotOk(cursor.target);
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+  });
+
+  test('previous() goes to last element when no cursor is set', () => {
+    cursor.stops = [...list.querySelectorAll('li')];
+    const result = cursor.previous();
+
+    assert.equal(result, CursorMoveResult.MOVED);
+    const lastIndex = list.children.length - 1;
+    assert.equal(cursor.index, lastIndex);
+    assert.equal(cursor.target, list.children[lastIndex] as HTMLElement);
+    assert.isTrue(list.children[lastIndex].classList.contains('targeted'));
+    assert.isFalse(cursor.isAtStart());
+    assert.isTrue(cursor.isAtEnd());
+  });
+
+  test('previous() resets the cursor when there are no stops', () => {
+    cursor.stops = [];
+    const result = cursor.previous();
+
+    assert.equal(result, CursorMoveResult.NO_STOPS);
+    assert.equal(cursor.index, -1);
+    assert.isNotOk(cursor.target);
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+  });
+
+  test('_moveCursor', () => {
+    // Initialize the cursor with its stops.
+    cursor.stops = [...list.querySelectorAll('li')];
+    // Select the first stop.
+    cursor.setCursor(list.children[0] as HTMLElement);
+    const getTargetHeight = sinon.stub();
+
+    // Move the cursor without an optional get target height function.
+    cursor._moveCursor(1);
+    assert.isFalse(getTargetHeight.called);
+
+    // Move the cursor with an optional get target height function.
+    cursor._moveCursor(1, {getTargetHeight});
+    assert.isTrue(getTargetHeight.called);
+  });
+
+  test('_moveCursor from for invalid index does not check height', () => {
+    cursor.stops = [];
+    const getTargetHeight = sinon.stub();
+    cursor._moveCursor(1, {filter: () => false, getTargetHeight});
+    assert.isFalse(getTargetHeight.called);
+  });
+
+  test('setCursorAtIndex with noScroll', () => {
+    sinon.stub(cursor, '_targetIsVisible').callsFake(() => false);
+    const scrollStub = sinon.stub(window, 'scrollTo');
+    cursor.stops = [...list.querySelectorAll('li')];
+    cursor.scrollMode = 'keep-visible';
+
+    cursor.setCursorAtIndex(1, true);
+    assert.isFalse(scrollStub.called);
+
+    cursor.setCursorAtIndex(2);
+    assert.isTrue(scrollStub.called);
+  });
+
+  test('move with filter', () => {
+    const isLetterB = function (row: HTMLElement) {
+      return row.textContent === 'B';
+    };
+    cursor.stops = [...list.querySelectorAll('li')];
+    // Start cursor at the first stop.
+    cursor.setCursor(list.children[0] as HTMLElement);
+
+    // Move forward to meet the next condition.
+    cursor.next({filter: isLetterB});
+    assert.equal(cursor.index, 1);
+
+    // Nothing else meets the condition, should be at last stop.
+    cursor.next({filter: isLetterB});
+    assert.equal(cursor.index, 3);
+
+    // Should stay at last stop if try to proceed.
+    cursor.next({filter: isLetterB});
+    assert.equal(cursor.index, 3);
+
+    // Go back to the previous condition met. Should be back at.
+    // stop 1.
+    cursor.previous({filter: isLetterB});
+    assert.equal(cursor.index, 1);
+
+    // Go back. No more meet the condition. Should be at stop 0.
+    cursor.previous({filter: isLetterB});
+    assert.equal(cursor.index, 0);
+  });
+
+  test('focusOnMove prop', () => {
+    const listEls = [...list.querySelectorAll('li')];
+    const listFocusStubs = listEls.map(listEl => sinon.spy(listEl, 'focus'));
+    cursor.stops = listEls;
+    cursor.setCursor(list.children[0] as HTMLElement);
+
+    cursor.focusOnMove = false;
+    cursor.next();
+    assert.equal(listEls[1], cursor.target);
+    assert.isFalse(listFocusStubs[1].called);
+
+    cursor.focusOnMove = true;
+    cursor.next();
+    assert.equal(listEls[2], cursor.target);
+    assert.isTrue(listFocusStubs[2].called);
+  });
+
+  suite('circular options', () => {
+    const options = {circular: true};
+    setup(() => {
+      cursor.stops = [...list.querySelectorAll('li')];
+    });
+
+    test('previous() on first element goes to last element', () => {
+      cursor.setCursor(list.children[0] as HTMLElement);
+      cursor.previous(options);
+      assert.equal(cursor.index, list.children.length - 1);
+    });
+
+    test('next() on last element goes to first element', () => {
+      cursor.setCursor(list.children[list.children.length - 1] as HTMLElement);
+      cursor.next(options);
+      assert.equal(cursor.index, 0);
+    });
+  });
+
+  suite('_scrollToTarget', () => {
+    let scrollStub: sinon.SinonStub;
+    setup(() => {
+      cursor.stops = [...list.querySelectorAll('li')];
+      cursor.scrollMode = 'keep-visible';
+
+      // There is a target which has a targetNext
+      cursor.setCursor(list.children[0] as HTMLElement);
+      cursor._moveCursor(1);
+      scrollStub = sinon.stub(window, 'scrollTo');
+      window.innerHeight = 60;
+    });
+
+    test('Called when top and bottom not visible', () => {
+      sinon.stub(cursor, '_targetIsVisible').returns(false);
+      cursor._scrollToTarget();
+      assert.isTrue(scrollStub.called);
+    });
+
+    test('Not called when top and bottom visible', () => {
+      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(cursor, '_targetIsVisible')
+        .callsFake(() => visibleStub.callCount === 2);
+      window.scrollX = 123;
+      window.scrollY = 15;
+      window.innerHeight = 1000;
+      window.pageYOffset = 0;
+      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(cursor, '_targetIsVisible')
+        .callsFake(() => visibleStub.callCount === 2);
+      window.scrollX = 123;
+      window.scrollY = 25;
+      window.innerHeight = 1000;
+      window.pageYOffset = 0;
+      sinon.stub(cursor, '_calculateScrollToValue').returns(20);
+      cursor._scrollToTarget();
+      assert.isFalse(scrollStub.called);
+      assert.equal(visibleStub.callCount, 2);
+    });
+
+    test('_calculateScrollToValue', () => {
+      window.scrollX = 123;
+      window.scrollY = 25;
+      window.innerHeight = 300;
+      window.pageYOffset = 0;
+      const fakeElement = {offsetHeight: 10} as HTMLElement;
+      assert.equal(cursor._calculateScrollToValue(1000, fakeElement), 905);
+    });
+  });
+
+  suite('AbortStops', () => {
+    test('next() does not skip AbortStops', () => {
+      cursor.stops = [
+        document.createElement('li'),
+        new AbortStop(),
+        document.createElement('li'),
+      ];
+      cursor.setCursorAtIndex(0);
+
+      const result = cursor.next();
+
+      assert.equal(result, CursorMoveResult.ABORTED);
+      assert.equal(cursor.index, 0);
+    });
+
+    test('setCursorAtIndex() does not target AbortStops', () => {
+      cursor.stops = [
+        document.createElement('li'),
+        new AbortStop(),
+        document.createElement('li'),
+      ];
+      cursor.setCursorAtIndex(1);
+      assert.equal(cursor.index, -1);
+    });
+
+    test('moveToStart() does not target AbortStop', () => {
+      cursor.stops = [
+        new AbortStop(),
+        document.createElement('li'),
+        document.createElement('li'),
+      ];
+      cursor.moveToStart();
+      assert.equal(cursor.index, -1);
+    });
+
+    test('moveToEnd() does not target AbortStop', () => {
+      cursor.stops = [
+        document.createElement('li'),
+        document.createElement('li'),
+        new AbortStop(),
+      ];
+      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 99f9265..25ee130 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
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-tooltip-content/gr-tooltip-content';
 import {css, html, LitElement} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 import {
   parseDate,
   fromNow,
@@ -30,7 +19,7 @@
 import {TimeFormat, DateFormat} from '../../../constants/constants';
 import {assertNever} from '../../../utils/common-util';
 import {Timestamp} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 
 const TimeFormats = {
   TIME_12: 'h:mm A', // 2:14 PM
@@ -91,27 +80,22 @@
   @property({type: Boolean})
   showYesterday = false;
 
-  /** @type {?{short: string, full: string}} */
-  @property({type: Object})
-  private dateFormat?: DateFormatPair;
-
-  @property({type: String})
-  private timeFormat?: string;
-
-  @property({type: Boolean})
-  private relative = false;
-
   @property({type: Boolean})
   forceRelative = false;
 
   @property({type: Boolean})
   relativeOptionNoAgo = false;
 
-  private readonly restApiService = appContext.restApiService;
+  @state()
+  dateFormat?: DateFormatPair;
 
-  constructor() {
-    super();
-  }
+  @state()
+  timeFormat?: string;
+
+  @state()
+  relative = false;
+
+  private readonly restApiService = getAppContext().restApiService;
 
   static override get styles() {
     return [
@@ -141,12 +125,12 @@
   }
 
   private renderDateString() {
-    return html` <span>${this._computeDateStr()}</span>`;
+    return html` <span>${this.computeDateStr()}</span>`;
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this._loadPreferences();
+    this.loadPreferences();
   }
 
   // private but used by tests
@@ -155,27 +139,25 @@
   }
 
   // private but used by tests
-  _loadPreferences() {
-    return this._getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        this.timeFormat = TimeFormats.TIME_24;
-        this.dateFormat = DateFormats.STD;
-        this.relative = this.forceRelative;
-        return;
-      }
-      return Promise.all([this._loadTimeFormat(), this.loadRelative()]);
-    });
+  async loadPreferences() {
+    const loggedIn = await this.restApiService.getLoggedIn();
+    if (!loggedIn) {
+      this.timeFormat = TimeFormats.TIME_24;
+      this.dateFormat = DateFormats.STD;
+      this.relative = this.forceRelative;
+      return;
+    }
+    await Promise.all([this.loadTimeFormat(), this.loadRelative()]);
   }
 
-  // private but used in gr/file-list_test.js
-  _loadTimeFormat() {
-    return this.getPreferences().then(preferences => {
-      if (!preferences) {
-        throw Error('Preferences is not set');
-      }
-      this.decideTimeFormat(preferences.time_format);
-      this.decideDateFormat(preferences.date_format);
-    });
+  // private but used in gr/file-list_test.ts
+  async loadTimeFormat() {
+    const preferences = await this.restApiService.getPreferences();
+    if (!preferences) {
+      throw Error('Preferences is not set');
+    }
+    this.decideTimeFormat(preferences.time_format);
+    this.decideDateFormat(preferences.date_format);
   }
 
   private decideTimeFormat(timeFormat: TimeFormat) {
@@ -213,24 +195,13 @@
     }
   }
 
-  private loadRelative() {
-    return this.getPreferences().then(prefs => {
-      // prefs.relative_date_in_change_table is not set when false.
-      this.relative =
-        this.forceRelative || !!(prefs && prefs.relative_date_in_change_table);
-    });
+  private async loadRelative() {
+    const prefs = await this.restApiService.getPreferences();
+    this.relative =
+      this.forceRelative || Boolean(prefs?.relative_date_in_change_table);
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  private getPreferences() {
-    return this.restApiService.getPreferences();
-  }
-
-  // private but used by tests
-  _computeDateStr() {
+  private computeDateStr() {
     if (!this.dateStr || !this.timeFormat || !this.dateFormat) {
       return '';
     }
@@ -259,7 +230,6 @@
   }
 
   private computeFullDateStr() {
-    // Polymer 2: check for undefined
     if (
       [this.dateStr, this.timeFormat].includes(undefined) ||
       !this.dateFormat
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
deleted file mode 100644
index 860a7e7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
+++ /dev/null
@@ -1,463 +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-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 withTooltip dateStr="2015-09-24 23:30:17.033000000">
-</gr-date-formatter>
-`);
-
-const lightFixture = fixtureFromTemplate(html`
-<gr-date-formatter dateStr="2015-09-24 23:30:17.033000000"></gr-date-formatter>
-`);
-
-suite('gr-date-formatter tests', () => {
-  let element;
-
-  setup(() => {
-  });
-
-  /**
-   * Parse server-formatter date and normalize into current timezone.
-   */
-  function normalizedDate(dateStr) {
-    const d = parseDate(dateStr);
-    d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
-    return d;
-  }
-
-  async function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
-      expectedTooltip) {
-    // Normalize and convert the date to mimic server response.
-    dateStr = normalizedDate(dateStr)
-        .toJSON()
-        .replace('T', ' ')
-        .slice(0, -1);
-    sinon.useFakeTimers(normalizedDate(nowStr).getTime());
-    element.dateStr = dateStr;
-    await element.updateComplete;
-    const span = element.shadowRoot.querySelector('span');
-    const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
-    assert.equal(span.textContent.trim(), expected);
-    assert.equal(tooltip.title, expectedTooltip);
-    element.showDateAndTime = true;
-    await element.updateComplete;
-    assert.equal(span.textContent.trim(), expectedWithDateAndTime);
-  }
-
-  function stubRestAPI(preferences) {
-    const loggedInPromise = Promise.resolve(preferences !== null);
-    const preferencesPromise = Promise.resolve(preferences);
-    stubRestApi('getLoggedIn').returns(loggedInPromise);
-    stubRestApi('getPreferences').returns(preferencesPromise);
-    return Promise.all([loggedInPromise, preferencesPromise]);
-  }
-
-  suite('STD + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'STD',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('invalid dates are quietly rejected', () => {
-      assert.notOk((new Date('foo')).valueOf());
-      element.dateStr = 'foo';
-      element.timeFormat = 'h:mm A';
-      assert.equal(element._computeDateStr(), '');
-    });
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          'Jul 29, 2015, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          'Jul 28',
-          'Jul 28 20:25',
-          'Jul 28, 2015, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          'Jun 15',
-          'Jun 15 03:25',
-          'Jun 15, 2015, 03:25:14');
-    });
-
-    test('More than six months', async () => {
-      await testDates('2015-09-15 20:34:00.000000000',
-          '2015-01-15 03:25:00.000000000',
-          'Jan 15, 2015',
-          'Jan 15, 2015 03:25',
-          'Jan 15, 2015, 03:25:00');
-    });
-  });
-
-  suite('US + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'US',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '07/29/15, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '07/28',
-          '07/28 20:25',
-          '07/28/15, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '06/15',
-          '06/15 03:25',
-          '06/15/15, 03:25:14');
-    });
-  });
-
-  suite('ISO + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'ISO',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '2015-07-29, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '07-28',
-          '07-28 20:25',
-          '2015-07-28, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '06-15',
-          '06-15 03:25',
-          '2015-06-15, 03:25:14');
-    });
-  });
-
-  suite('EURO + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'EURO',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '29.07.2015, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '28. Jul',
-          '28. Jul 20:25',
-          '28.07.2015, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '15. Jun',
-          '15. Jun 03:25',
-          '15.06.2015, 03:25:14');
-    });
-  });
-
-  suite('UK + 24 hours time format preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_24',
-      date_format: 'UK',
-      relative_date_in_change_table: false,
-    }).then(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '15:34',
-          '15:34',
-          '29/07/2015, 15:34:14');
-    });
-
-    test('Within 24 hours on different days', async () => {
-      await testDates('2015-07-29 03:34:14.985000000',
-          '2015-07-28 20:25:14.985000000',
-          '28/07',
-          '28/07 20:25',
-          '28/07/2015, 20:25:14');
-    });
-
-    test('More than 24 hours but less than six months', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-06-15 03:25:14.985000000',
-          '15/06',
-          '15/06 03:25',
-          '15/06/2015, 03:25:14');
-    });
-  });
-
-  suite('STD + 12 hours time format preference', () => {
-    setup(() =>
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'STD'}
-      ).then(() => {
-        element = basicFixture.instantiate();
-        sinon.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          'Jul 29, 2015, 3:34:14 PM');
-    });
-  });
-
-  suite('US + 12 hours time format preference', () => {
-    setup(() =>
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'US'}
-      ).then(() => {
-        element = basicFixture.instantiate();
-        sinon.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '07/29/15, 3:34:14 PM');
-    });
-  });
-
-  suite('ISO + 12 hours time format preference', () => {
-    setup(() =>
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'ISO'}
-      ).then(() => {
-        element = basicFixture.instantiate();
-        sinon.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '2015-07-29, 3:34:14 PM');
-    });
-  });
-
-  suite('EURO + 12 hours time format preference', () => {
-    setup(() =>
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'EURO'}
-      ).then(() => {
-        element = basicFixture.instantiate();
-        sinon.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '29.07.2015, 3:34:14 PM');
-    });
-  });
-
-  suite('UK + 12 hours time format preference', () => {
-    setup(() =>
-      // relative_date_in_change_table is not set when false.
-      stubRestAPI(
-          {time_format: 'HHMM_12', date_format: 'UK'}
-      ).then(() => {
-        element = basicFixture.instantiate();
-        sinon.stub(element, '_getUtcOffsetString').returns('');
-        return element._loadPreferences();
-      })
-    );
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '3:34 PM',
-          '3:34 PM',
-          '29/07/2015, 3:34:14 PM');
-    });
-  });
-
-  suite('relative date preference', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_12',
-      date_format: 'STD',
-      relative_date_in_change_table: true,
-    }).then(() => {
-      element = basicFixture.instantiate();
-      sinon.stub(element, '_getUtcOffsetString').returns('');
-      return element._loadPreferences();
-    }));
-
-    test('Within 24 hours on same day', async () => {
-      await testDates('2015-07-29 20:34:14.985000000',
-          '2015-07-29 15:34:14.985000000',
-          '5 hours ago',
-          '5 hours ago',
-          'Jul 29, 2015, 3:34:14 PM');
-    });
-
-    test('More than six months', async () => {
-      await testDates('2015-09-15 20:34:00.000000000',
-          '2015-01-15 03:25:00.000000000',
-          '8 months ago',
-          '8 months ago',
-          'Jan 15, 2015, 3:25:00 AM');
-    });
-  });
-
-  suite('logged in', () => {
-    setup(() => stubRestAPI({
-      time_format: 'HHMM_12',
-      date_format: 'US',
-      relative_date_in_change_table: true,
-    }).then(() => {
-      element = basicFixture.instantiate();
-      return element._loadPreferences();
-    }));
-
-    test('Preferences are respected', () => {
-      assert.equal(element.timeFormat, 'h:mm A');
-      assert.equal(element.dateFormat.short, 'MM/DD');
-      assert.equal(element.dateFormat.full, 'MM/DD/YY');
-      assert.isTrue(element.relative);
-    });
-  });
-
-  suite('logged out', () => {
-    setup(() => stubRestAPI(null).then(() => {
-      element = basicFixture.instantiate();
-      return element._loadPreferences();
-    }));
-
-    test('Default preferences are respected', () => {
-      assert.equal(element.timeFormat, 'HH:mm');
-      assert.equal(element.dateFormat.short, 'MMM DD');
-      assert.equal(element.dateFormat.full, 'MMM DD, YYYY');
-      assert.isFalse(element.relative);
-    });
-  });
-
-  suite('with tooltip', () => {
-    setup(async () => {
-      await stubRestAPI(null);
-      element = basicFixture.instantiate();
-      await element._loadPreferences();
-      await element.updateComplete;
-    });
-
-    test('Tooltip is present', () => {
-      const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
-      assert.isOk(tooltip);
-    });
-  });
-
-  suite('without tooltip', () => {
-    setup(async () => {
-      await stubRestAPI(null);
-      element = lightFixture.instantiate();
-      await element._loadPreferences();
-      await element.updateComplete;
-    });
-
-    test('Tooltip is absent', () => {
-      const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
-      assert.isNotOk(tooltip);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
new file mode 100644
index 0000000..d7c38df
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
@@ -0,0 +1,529 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-date-formatter';
+import {GrDateFormatter} from './gr-date-formatter';
+import {parseDate} from '../../../utils/date-util';
+import {fixture, html, assert} from '@open-wc/testing';
+import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
+import {Timestamp} from '../../../api/rest-api';
+import {PreferencesInfo} from '../../../types/common';
+import {createPreferences} from '../../../test/test-data-generators';
+import {
+  createDefaultPreferences,
+  DateFormat,
+  TimeFormat,
+} from '../../../constants/constants';
+
+const basicTemplate = html`
+  <gr-date-formatter withTooltip dateStr="2015-09-24 23:30:17.033000000">
+  </gr-date-formatter>
+`;
+
+const lightTemplate = html`
+  <gr-date-formatter dateStr="2015-09-24 23:30:17.033000000">
+  </gr-date-formatter>
+`;
+
+suite('gr-date-formatter tests', () => {
+  let element: GrDateFormatter;
+
+  /**
+   * Parse server-formatted date and normalize into current timezone.
+   */
+  function normalizedDate(dateStr: Timestamp) {
+    const d = parseDate(dateStr);
+    d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
+    return d;
+  }
+
+  async function testDates(
+    nowStr: string,
+    dateStr: string,
+    expected: string,
+    expectedWithDateAndTime: string,
+    expectedTooltip: string
+  ) {
+    // Normalize and convert the date to mimic server response.
+    const normalizedDateStr = normalizedDate(dateStr as Timestamp)
+      .toJSON()
+      .replace('T', ' ')
+      .slice(0, -1);
+    sinon.useFakeTimers(normalizedDate(nowStr as Timestamp).getTime());
+    element.dateStr = normalizedDateStr;
+    await element.updateComplete;
+    const span = queryAndAssert<HTMLSpanElement>(element, 'span');
+    const tooltip = queryAndAssert<GrTooltipContent>(
+      element,
+      'gr-tooltip-content'
+    );
+    assert.equal(span.textContent?.trim(), expected);
+    assert.equal(tooltip.title, expectedTooltip);
+    element.showDateAndTime = true;
+    await element.updateComplete;
+    assert.equal(span.textContent?.trim(), expectedWithDateAndTime);
+  }
+
+  function stubRestAPI(preferences?: PreferencesInfo) {
+    stubRestApi('getLoggedIn').resolves(preferences !== undefined);
+    stubRestApi('getPreferences').resolves(preferences);
+  }
+
+  suite('STD + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.STD,
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        'Jul 29, 2015, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        'Jul 28',
+        'Jul 28 20:25',
+        'Jul 28, 2015, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        'Jun 15',
+        'Jun 15 03:25',
+        'Jun 15, 2015, 03:25:14'
+      );
+    });
+
+    test('More than six months', async () => {
+      await testDates(
+        '2015-09-15 20:34:00.000000000',
+        '2015-01-15 03:25:00.000000000',
+        'Jan 15, 2015',
+        'Jan 15, 2015 03:25',
+        'Jan 15, 2015, 03:25:00'
+      );
+    });
+  });
+
+  suite('US + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.US,
+        relative_date_in_change_table: false,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '07/29/15, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '07/28',
+        '07/28 20:25',
+        '07/28/15, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '06/15',
+        '06/15 03:25',
+        '06/15/15, 03:25:14'
+      );
+    });
+  });
+
+  suite('ISO + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.ISO,
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '2015-07-29, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '07-28',
+        '07-28 20:25',
+        '2015-07-28, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '06-15',
+        '06-15 03:25',
+        '2015-06-15, 03:25:14'
+      );
+    });
+  });
+
+  suite('EURO + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.EURO,
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '29.07.2015, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '28. Jul',
+        '28. Jul 20:25',
+        '28.07.2015, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '15. Jun',
+        '15. Jun 03:25',
+        '15.06.2015, 03:25:14'
+      );
+    });
+  });
+
+  suite('UK + 24 hours time format preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_24,
+        date_format: DateFormat.UK,
+        relative_date_in_change_table: false,
+      });
+
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '15:34',
+        '15:34',
+        '29/07/2015, 15:34:14'
+      );
+    });
+
+    test('Within 24 hours on different days', async () => {
+      await testDates(
+        '2015-07-29 03:34:14.985000000',
+        '2015-07-28 20:25:14.985000000',
+        '28/07',
+        '28/07 20:25',
+        '28/07/2015, 20:25:14'
+      );
+    });
+
+    test('More than 24 hours but less than six months', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-06-15 03:25:14.985000000',
+        '15/06',
+        '15/06 03:25',
+        '15/06/2015, 03:25:14'
+      );
+    });
+  });
+
+  suite('STD + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.STD,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        'Jul 29, 2015, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('US + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.US,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '07/29/15, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('ISO + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.ISO,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '2015-07-29, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('EURO + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.EURO,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '29.07.2015, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('UK + 12 hours time format preference', () => {
+    setup(async () => {
+      // relative_date_in_change_table is not set when false.
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.UK,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '3:34 PM',
+        '3:34 PM',
+        '29/07/2015, 3:34:14 PM'
+      );
+    });
+  });
+
+  suite('relative date preference', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.STD,
+        relative_date_in_change_table: true,
+      });
+      element = await fixture(basicTemplate);
+      sinon.stub(element, '_getUtcOffsetString').returns('');
+      await element.loadPreferences();
+    });
+
+    test('Within 24 hours on same day', async () => {
+      await testDates(
+        '2015-07-29 20:34:14.985000000',
+        '2015-07-29 15:34:14.985000000',
+        '5 hours ago',
+        '5 hours ago',
+        'Jul 29, 2015, 3:34:14 PM'
+      );
+    });
+
+    test('More than six months', async () => {
+      await testDates(
+        '2015-09-15 20:34:00.000000000',
+        '2015-01-15 03:25:00.000000000',
+        '8 months ago',
+        '8 months ago',
+        'Jan 15, 2015, 3:25:00 AM'
+      );
+    });
+  });
+
+  suite('logged in', () => {
+    setup(async () => {
+      stubRestAPI({
+        ...createPreferences(),
+        time_format: TimeFormat.HHMM_12,
+        date_format: DateFormat.US,
+        relative_date_in_change_table: true,
+      });
+      element = await fixture(basicTemplate);
+      await element.loadPreferences();
+    });
+
+    test('Preferences are respected', () => {
+      assert.equal(element.timeFormat, 'h:mm A');
+      assert.equal(element.dateFormat?.short, 'MM/DD');
+      assert.equal(element.dateFormat?.full, 'MM/DD/YY');
+      assert.isTrue(element.relative);
+    });
+  });
+
+  suite('logged out', () => {
+    setup(async () => {
+      stubRestAPI(undefined);
+      element = await fixture(basicTemplate);
+      await element.loadPreferences();
+    });
+
+    test('Default preferences are respected', () => {
+      assert.equal(element.timeFormat, 'HH:mm');
+      assert.equal(element.dateFormat?.short, 'MMM DD');
+      assert.equal(element.dateFormat?.full, 'MMM DD, YYYY');
+      assert.isFalse(element.relative);
+    });
+  });
+
+  suite('with tooltip', () => {
+    setup(async () => {
+      stubRestAPI(createDefaultPreferences());
+      element = await fixture(basicTemplate);
+      await element.loadPreferences();
+      await element.updateComplete;
+    });
+
+    test('Tooltip is present', () => {
+      const tooltip = queryAndAssert<GrTooltipContent>(
+        element,
+        'gr-tooltip-content'
+      );
+      assert.isOk(tooltip);
+    });
+  });
+
+  suite('without tooltip', () => {
+    setup(async () => {
+      stubRestAPI(createDefaultPreferences());
+      element = await fixture(lightTemplate);
+      await element.loadPreferences();
+      await element.updateComplete;
+    });
+
+    test('Tooltip is absent', () => {
+      const tooltip = query<GrTooltipContent>(element, 'gr-tooltip-content');
+      assert.isNotOk(tooltip);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index 97ee39e..04d5923 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -1,25 +1,15 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-button/gr-button';
-import {customElement, property, query} from 'lit/decorators';
+import {customElement, property, query} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {when} from 'lit/directives/when.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -41,6 +31,9 @@
    * @event cancel
    */
 
+  @query('#cancel')
+  cancelButton?: GrButton;
+
   @query('#confirm')
   confirmButton?: GrButton;
 
@@ -51,9 +44,19 @@
   @property({type: String, attribute: 'cancel-label'})
   cancelLabel = 'Cancel';
 
+  @property({type: Boolean, attribute: 'loading'})
+  loading = false;
+
+  @property({type: String, attribute: 'loading-label'})
+  loadingLabel = 'Loading...';
+
+  // TODO: Add consistent naming after Lit conversion of the codebase
   @property({type: Boolean})
   disabled = false;
 
+  @property({type: Boolean})
+  disableCancel = false;
+
   @property({type: Boolean, attribute: 'confirm-on-enter'})
   confirmOnEnter = false;
 
@@ -101,8 +104,11 @@
         footer {
           display: flex;
           flex-shrink: 0;
-          justify-content: flex-end;
           padding-top: var(--spacing-xl);
+          align-items: center;
+        }
+        .flex-space {
+          flex-grow: 1;
         }
         gr-button {
           margin-left: var(--spacing-l);
@@ -110,6 +116,10 @@
         .hidden {
           display: none;
         }
+        .loadingSpin {
+          width: 18px;
+          height: 18px;
+        }
       `,
     ];
   }
@@ -130,11 +140,23 @@
           </div>
         </main>
         <footer>
-          <slot name="footer"></slot>
+          ${when(
+            this.loading,
+            () => html`
+              <span
+                class="loadingSpin"
+                role="progressbar"
+                aria-label=${this.loadingLabel}
+              ></span>
+              <span class="loadingLabel"> ${this.loadingLabel} </span>
+            `
+          )}
+          <div class="flex-space"></div>
           <gr-button
             id="cancel"
-            class="${this.cancelLabel.length ? '' : 'hidden'}"
+            class=${this.cancelLabel.length ? '' : 'hidden'}
             link
+            ?disabled=${this.disableCancel}
             @click=${(e: Event) => this.handleCancelTap(e)}
           >
             ${this.cancelLabel}
@@ -143,7 +165,7 @@
             id="confirm"
             link
             primary
-            @click=${(e: Event) => this._handleConfirm(e)}
+            @click=${this._handleConfirm}
             ?disabled=${this.disabled}
             title=${this.confirmTooltip ?? ''}
           >
@@ -197,12 +219,16 @@
   }
 
   _handleKeydown(e: KeyboardEvent) {
-    if (this.confirmOnEnter && e.keyCode === 13) {
+    if (this.confirmOnEnter && e.key === 'Enter') {
       this._handleConfirm(e);
     }
   }
 
   resetFocus() {
-    this.confirmButton!.focus();
+    if (this.disabled && this.cancelLabel) {
+      this.cancelButton!.focus();
+    } else {
+      this.confirmButton!.focus();
+    }
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
index 171fc6c..d386c32 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
@@ -1,46 +1,122 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-dialog';
 import {GrDialog} from './gr-dialog';
-import {isHidden, queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-dialog');
+import {
+  isHidden,
+  pressKey,
+  queryAndAssert,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
 
 suite('gr-dialog tests', () => {
   let element: GrDialog;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrDialog>(html` <gr-dialog></gr-dialog> `);
     await element.updateComplete;
   });
 
+  test('renders', async () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="container">
+        <header class="heading-3">
+          <slot name="header"> </slot>
+        </header>
+        <main>
+          <div class="overflow-container">
+            <slot name="main"> </slot>
+          </div>
+        </main>
+        <footer>
+          <div class="flex-space"></div>
+          <gr-button
+            aria-disabled="false"
+            id="cancel"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Cancel
+          </gr-button>
+          <gr-button
+            aria-disabled="false"
+            id="confirm"
+            link=""
+            primary=""
+            role="button"
+            tabindex="0"
+            title=""
+          >
+            Confirm
+          </gr-button>
+        </footer>
+      </div> `
+    );
+  });
+
+  test('renders with loading state', async () => {
+    element.loading = true;
+    element.loadingLabel = 'Loading!!';
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="container">
+        <header class="heading-3">
+          <slot name="header"> </slot>
+        </header>
+        <main>
+          <div class="overflow-container">
+            <slot name="main"> </slot>
+          </div>
+        </main>
+        <footer>
+          <span class="loadingSpin" aria-label="Loading!!" role="progressbar">
+          </span>
+          <span class="loadingLabel"> Loading!! </span>
+          <div class="flex-space"></div>
+          <gr-button
+            aria-disabled="false"
+            id="cancel"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Cancel
+          </gr-button>
+          <gr-button
+            aria-disabled="false"
+            id="confirm"
+            link=""
+            primary=""
+            role="button"
+            tabindex="0"
+            title=""
+          >
+            Confirm
+          </gr-button>
+        </footer>
+      </div> `
+    );
+  });
+
   test('events', () => {
     const confirm = sinon.stub();
     const cancel = sinon.stub();
     element.addEventListener('confirm', confirm);
     element.addEventListener('cancel', cancel);
 
-    MockInteractions.tap(queryAndAssert(element, 'gr-button[primary]'));
+    queryAndAssert<GrButton>(element, 'gr-button[primary]').click();
     assert.equal(confirm.callCount, 1);
 
-    MockInteractions.tap(queryAndAssert(element, 'gr-button:not([primary])'));
+    queryAndAssert<GrButton>(element, 'gr-button:not([primary])').click();
     assert.equal(cancel.callCount, 1);
   });
 
@@ -49,13 +125,8 @@
     await element.updateComplete;
     const handleConfirmStub = sinon.stub(element, '_handleConfirm');
     const handleKeydownSpy = sinon.spy(element, '_handleKeydown');
-    MockInteractions.keyDownOn(
-      queryAndAssert(element, 'main'),
-      13,
-      null,
-      'enter'
-    );
-    await flush();
+    pressKey(queryAndAssert(element, 'main'), 'Enter');
+    await waitEventLoop();
 
     assert.isTrue(handleKeydownSpy.called);
     assert.isFalse(handleConfirmStub.called);
@@ -63,13 +134,8 @@
     element.confirmOnEnter = true;
     await element.updateComplete;
 
-    MockInteractions.keyDownOn(
-      queryAndAssert(element, 'main'),
-      13,
-      null,
-      'enter'
-    );
-    await flush();
+    pressKey(queryAndAssert(element, 'main'), 'Enter');
+    await waitEventLoop();
 
     assert.isTrue(handleConfirmStub.called);
   });
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 6ddaccf..019bec1 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
@@ -1,172 +1,331 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
 import '../gr-select/gr-select';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-diff-preferences_html';
-import {customElement, property} from '@polymer/decorators';
 import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
+import {subscribe} from '../../lit/subscription-controller';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {convertToString} from '../../../utils/string-util';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
 import {GrSelect} from '../gr-select/gr-select';
-import {appContext} from '../../../services/app-context';
-import {diffPreferences$} from '../../../services/user/user-model';
-import {takeUntil} from 'rxjs/operators';
-import {Subject} from 'rxjs';
-
-export interface GrDiffPreferences {
-  $: {
-    contextLineSelect: HTMLInputElement;
-    columnsInput: HTMLInputElement;
-    tabSizeInput: HTMLInputElement;
-    fontSizeInput: HTMLInputElement;
-    lineWrappingInput: HTMLInputElement;
-    showTabsInput: HTMLInputElement;
-    showTrailingWhitespaceInput: HTMLInputElement;
-    automaticReviewInput: HTMLInputElement;
-    syntaxHighlightInput: HTMLInputElement;
-    contextSelect: GrSelect;
-    ignoreWhiteSpace: HTMLInputElement;
-  };
-  save(): void;
-}
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
 
 @customElement('gr-diff-preferences')
-export class GrDiffPreferences extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrDiffPreferences extends LitElement {
+  @query('#contextLineSelect') private contextLineSelect?: HTMLInputElement;
 
-  @property({type: Boolean, notify: true})
-  hasUnsavedChanges = false;
+  @query('#columnsInput') private columnsInput?: HTMLInputElement;
 
-  @property({type: Object})
-  diffPrefs?: DiffPreferencesInfo;
+  @query('#tabSizeInput') private tabSizeInput?: HTMLInputElement;
 
-  private readonly userService = appContext.userService;
+  @query('#fontSizeInput') private fontSizeInput?: HTMLInputElement;
 
-  private readonly disconnected$ = new Subject();
+  @query('#lineWrappingInput') private lineWrappingInput?: HTMLInputElement;
 
-  override connectedCallback() {
-    super.connectedCallback();
-    diffPreferences$
-      .pipe(takeUntil(this.disconnected$))
-      .subscribe(diffPreferences => {
-        this.diffPrefs = diffPreferences;
-      });
-  }
+  @query('#showTabsInput') private showTabsInput?: HTMLInputElement;
 
-  override disconnectedCallback() {
-    this.disconnected$.next();
-    super.disconnectedCallback();
-  }
+  @query('#showTrailingWhitespaceInput')
+  private showTrailingWhitespaceInput?: HTMLInputElement;
 
-  _handleDiffPrefsChanged() {
-    this.hasUnsavedChanges = true;
-  }
+  @query('#automaticReviewInput')
+  private automaticReviewInput?: HTMLInputElement;
 
-  _handleDiffContextChanged() {
-    this.set('diffPrefs.context', Number(this.$.contextLineSelect.value));
-    this._handleDiffPrefsChanged();
-  }
+  @query('#syntaxHighlightInput')
+  private syntaxHighlightInput?: HTMLInputElement;
 
-  _handleLineWrappingTap() {
-    this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
-    this._handleDiffPrefsChanged();
-  }
+  @query('#ignoreWhiteSpace') private ignoreWhiteSpace?: HTMLInputElement;
 
-  _handleDiffLineLengthChanged() {
-    this.set('diffPrefs.line_length', Number(this.$.columnsInput.value));
-    this._handleDiffPrefsChanged();
-  }
+  // Used in gr-diff-preferences-dialog
+  @query('#contextSelect') contextSelect?: GrSelect;
 
-  _handleDiffTabSizeChanged() {
-    this.set('diffPrefs.tab_size', Number(this.$.tabSizeInput.value));
-    this._handleDiffPrefsChanged();
-  }
+  @state() diffPrefs?: DiffPreferencesInfo;
 
-  _handleDiffFontSizeChanged() {
-    this.set('diffPrefs.font_size', Number(this.$.fontSizeInput.value));
-    this._handleDiffPrefsChanged();
-  }
+  @state() private originalDiffPrefs?: DiffPreferencesInfo;
 
-  _handleShowTabsTap() {
-    this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
-    this._handleDiffPrefsChanged();
-  }
+  private readonly getUserModel = resolve(this, userModelToken);
 
-  _handleShowTrailingWhitespaceTap() {
-    this.set(
-      'diffPrefs.show_whitespace_errors',
-      this.$.showTrailingWhitespaceInput.checked
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.originalDiffPrefs = diffPreferences;
+        this.diffPrefs = {...diffPreferences};
+      }
     );
-    this._handleDiffPrefsChanged();
   }
 
-  _handleSyntaxHighlightTap() {
-    this.set(
-      'diffPrefs.syntax_highlighting',
-      this.$.syntaxHighlightInput.checked
+  static override get styles() {
+    return [sharedStyles, formStyles];
+  }
+
+  override render() {
+    return html`
+      <div id="diffPreferences" class="gr-form-styles">
+        <section>
+          <label for="contextLineSelect" class="title">Context</label>
+          <span class="value">
+            <gr-select
+              id="contextSelect"
+              .bindValue=${convertToString(this.diffPrefs?.context)}
+              @change=${this.handleDiffContextChanged}
+            >
+              <select id="contextLineSelect">
+                <option value="3">3 lines</option>
+                <option value="10">10 lines</option>
+                <option value="25">25 lines</option>
+                <option value="50">50 lines</option>
+                <option value="75">75 lines</option>
+                <option value="100">100 lines</option>
+                <option value="-1">Whole file</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <label for="lineWrappingInput" class="title">Fit to screen</label>
+          <span class="value">
+            <input
+              id="lineWrappingInput"
+              type="checkbox"
+              ?checked=${this.diffPrefs?.line_wrapping}
+              @change=${this.handleLineWrappingTap}
+            />
+          </span>
+        </section>
+        <section>
+          <label for="columnsInput" class="title">Diff width</label>
+          <span class="value">
+            <iron-input
+              .allowedPattern=${'[0-9]'}
+              .bindValue=${convertToString(this.diffPrefs?.line_length)}
+              @change=${this.handleDiffLineLengthChanged}
+            >
+              <input id="columnsInput" type="number" />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <label for="tabSizeInput" class="title">Tab width</label>
+          <span class="value">
+            <iron-input
+              .allowedPattern=${'[0-9]'}
+              .bindValue=${convertToString(this.diffPrefs?.tab_size)}
+              @change=${this.handleDiffTabSizeChanged}
+            >
+              <input id="tabSizeInput" type="number" />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <label for="fontSizeInput" class="title">Font size</label>
+          <span class="value">
+            <iron-input
+              .allowedPattern=${'[0-9]'}
+              .bindValue=${convertToString(this.diffPrefs?.font_size)}
+              @change=${this.handleDiffFontSizeChanged}
+            >
+              <input id="fontSizeInput" type="number" />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <label for="showTabsInput" class="title">Show tabs</label>
+          <span class="value">
+            <input
+              id="showTabsInput"
+              type="checkbox"
+              ?checked=${this.diffPrefs?.show_tabs}
+              @change=${this.handleShowTabsTap}
+            />
+          </span>
+        </section>
+        <section>
+          <label for="showTrailingWhitespaceInput" class="title"
+            >Show trailing whitespace</label
+          >
+          <span class="value">
+            <input
+              id="showTrailingWhitespaceInput"
+              type="checkbox"
+              ?checked=${this.diffPrefs?.show_whitespace_errors}
+              @change=${this.handleShowTrailingWhitespaceTap}
+            />
+          </span>
+        </section>
+        <section>
+          <label for="syntaxHighlightInput" class="title"
+            >Syntax highlighting</label
+          >
+          <span class="value">
+            <input
+              id="syntaxHighlightInput"
+              type="checkbox"
+              ?checked=${this.diffPrefs?.syntax_highlighting}
+              @change=${this.handleSyntaxHighlightTap}
+            />
+          </span>
+        </section>
+        <section>
+          <label for="automaticReviewInput" class="title"
+            >Automatically mark viewed files reviewed</label
+          >
+          <span class="value">
+            <input
+              id="automaticReviewInput"
+              type="checkbox"
+              ?checked=${!this.diffPrefs?.manual_review}
+              @change=${this.handleAutomaticReviewTap}
+            />
+          </span>
+        </section>
+        <section>
+          <div class="pref">
+            <label for="ignoreWhiteSpace" class="title"
+              >Ignore Whitespace</label
+            >
+            <span class="value">
+              <gr-select
+                .bindValue=${convertToString(this.diffPrefs?.ignore_whitespace)}
+                @change=${this.handleDiffIgnoreWhitespaceChanged}
+              >
+                <select id="ignoreWhiteSpace">
+                  <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_ALL">All</option>
+                </select>
+              </gr-select>
+            </span>
+          </div>
+        </section>
+      </div>
+    `;
+  }
+
+  private readonly handleDiffContextChanged = () => {
+    this.diffPrefs!.context = Number(this.contextLineSelect!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleLineWrappingTap = () => {
+    this.diffPrefs!.line_wrapping = this.lineWrappingInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleDiffLineLengthChanged = () => {
+    this.diffPrefs!.line_length = Number(this.columnsInput!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleDiffTabSizeChanged = () => {
+    this.diffPrefs!.tab_size = Number(this.tabSizeInput!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleDiffFontSizeChanged = () => {
+    this.diffPrefs!.font_size = Number(this.fontSizeInput!.value);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleShowTabsTap = () => {
+    this.diffPrefs!.show_tabs = this.showTabsInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  // private but used in test
+  readonly handleShowTrailingWhitespaceTap = () => {
+    this.diffPrefs!.show_whitespace_errors =
+      this.showTrailingWhitespaceInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleSyntaxHighlightTap = () => {
+    this.diffPrefs!.syntax_highlighting = this.syntaxHighlightInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleAutomaticReviewTap = () => {
+    this.diffPrefs!.manual_review = !this.automaticReviewInput!.checked;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  private readonly handleDiffIgnoreWhitespaceChanged = () => {
+    this.diffPrefs!.ignore_whitespace = this.ignoreWhiteSpace!
+      .value as IgnoreWhitespaceType;
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
+  };
+
+  hasUnsavedChanges() {
+    // We have to wrap boolean values in Boolean() to ensure undefined values
+    // use false rather than undefined.
+    return (
+      Boolean(this.originalDiffPrefs?.syntax_highlighting) !==
+        Boolean(this.diffPrefs?.syntax_highlighting) ||
+      this.originalDiffPrefs?.context !== this.diffPrefs?.context ||
+      Boolean(this.originalDiffPrefs?.line_wrapping) !==
+        Boolean(this.diffPrefs?.line_wrapping) ||
+      this.originalDiffPrefs?.line_length !== this.diffPrefs?.line_length ||
+      this.originalDiffPrefs?.tab_size !== this.diffPrefs?.tab_size ||
+      this.originalDiffPrefs?.font_size !== this.diffPrefs?.font_size ||
+      this.originalDiffPrefs?.ignore_whitespace !==
+        this.diffPrefs?.ignore_whitespace ||
+      Boolean(this.originalDiffPrefs?.show_tabs) !==
+        Boolean(this.diffPrefs?.show_tabs) ||
+      Boolean(this.originalDiffPrefs?.show_whitespace_errors) !==
+        Boolean(this.diffPrefs?.show_whitespace_errors) ||
+      Boolean(this.originalDiffPrefs?.manual_review) !==
+        Boolean(this.diffPrefs?.manual_review)
     );
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleAutomaticReviewTap() {
-    this.set('diffPrefs.manual_review', !this.$.automaticReviewInput.checked);
-    this._handleDiffPrefsChanged();
-  }
-
-  _handleDiffIgnoreWhitespaceChanged() {
-    this.set(
-      'diffPrefs.ignore_whitespace',
-      this.$.ignoreWhiteSpace.value as IgnoreWhitespaceType
-    );
-    this._handleDiffPrefsChanged();
   }
 
   async save() {
     if (!this.diffPrefs) return;
-    await this.userService.updateDiffPreference(this.diffPrefs);
-    this.hasUnsavedChanges = false;
-  }
-
-  /**
-   * bind-value has type string so we have to convert
-   * anything inputed to string.
-   *
-   * This is so typescript checker doesn't fail.
-   */
-  _convertToString(key?: number | IgnoreWhitespaceType) {
-    return key !== undefined ? String(key) : '';
-  }
-
-  /**
-   * input 'checked' does not allow undefined,
-   * so we make sure the value is boolean
-   * by returning false if undefined.
-   *
-   * This is so typescript checker doesn't fail.
-   */
-  _convertToBoolean(key?: boolean) {
-    return key !== undefined ? key : false;
+    await this.getUserModel().updateDiffPreference(this.diffPrefs);
+    fire(this, 'has-unsaved-changes-changed', {
+      value: this.hasUnsavedChanges(),
+    });
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'has-unsaved-changes-changed': ValueChangedEvent<boolean>;
+  }
   interface HTMLElementTagNameMap {
     'gr-diff-preferences': GrDiffPreferences;
   }
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
deleted file mode 100644
index 51867c8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div id="diffPreferences" class="gr-form-styles">
-    <section>
-      <label for="contextLineSelect" class="title">Context</label>
-      <span class="value">
-        <gr-select
-          id="contextSelect"
-          bind-value="[[_convertToString(diffPrefs.context)]]"
-          on-change="_handleDiffContextChanged"
-        >
-          <select id="contextLineSelect">
-            <option value="3">3 lines</option>
-            <option value="10">10 lines</option>
-            <option value="25">25 lines</option>
-            <option value="50">50 lines</option>
-            <option value="75">75 lines</option>
-            <option value="100">100 lines</option>
-            <option value="-1">Whole file</option>
-          </select>
-        </gr-select>
-      </span>
-    </section>
-    <section>
-      <label for="lineWrappingInput" class="title">Fit to screen</label>
-      <span class="value">
-        <input
-          id="lineWrappingInput"
-          type="checkbox"
-          checked="[[_convertToBoolean(diffPrefs.line_wrapping)]]"
-          on-change="_handleLineWrappingTap"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="columnsInput" class="title">Diff width</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(diffPrefs.line_length)]]"
-          on-change="_handleDiffLineLengthChanged"
-        >
-          <input id="columnsInput" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label for="tabSizeInput" class="title">Tab width</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(diffPrefs.tab_size)]]"
-          on-change="_handleDiffTabSizeChanged"
-        >
-          <input id="tabSizeInput" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section hidden$="[[!diffPrefs.font_size]]">
-      <label for="fontSizeInput" class="title">Font size</label>
-      <span class="value">
-        <iron-input
-          allowed-pattern="[0-9]"
-          bind-value="[[_convertToString(diffPrefs.font_size)]]"
-          on-change="_handleDiffFontSizeChanged"
-        >
-          <input id="fontSizeInput" type="number" />
-        </iron-input>
-      </span>
-    </section>
-    <section>
-      <label for="showTabsInput" class="title">Show tabs</label>
-      <span class="value">
-        <input
-          id="showTabsInput"
-          type="checkbox"
-          checked="[[_convertToBoolean(diffPrefs.show_tabs)]]"
-          on-change="_handleShowTabsTap"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="showTrailingWhitespaceInput" class="title"
-        >Show trailing whitespace</label
-      >
-      <span class="value">
-        <input
-          id="showTrailingWhitespaceInput"
-          type="checkbox"
-          checked="[[_convertToBoolean(diffPrefs.show_whitespace_errors)]]"
-          on-change="_handleShowTrailingWhitespaceTap"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="syntaxHighlightInput" class="title"
-        >Syntax highlighting</label
-      >
-      <span class="value">
-        <input
-          id="syntaxHighlightInput"
-          type="checkbox"
-          checked="[[_convertToBoolean(diffPrefs.syntax_highlighting)]]"
-          on-change="_handleSyntaxHighlightTap"
-        />
-      </span>
-    </section>
-    <section>
-      <label for="automaticReviewInput" class="title"
-        >Automatically mark viewed files reviewed</label
-      >
-      <span class="value">
-        <input
-          id="automaticReviewInput"
-          type="checkbox"
-          checked="[[!_convertToBoolean(diffPrefs.manual_review)]]"
-          on-change="_handleAutomaticReviewTap"
-        />
-      </span>
-    </section>
-    <section>
-      <div class="pref">
-        <label for="ignoreWhiteSpace" class="title">Ignore Whitespace</label>
-        <span class="value">
-          <gr-select
-            bind-value="[[_convertToString(diffPrefs.ignore_whitespace)]]"
-            on-change="_handleDiffIgnoreWhitespaceChanged"
-          >
-            <select id="ignoreWhiteSpace">
-              <option value="IGNORE_NONE">None</option>
-              <option value="IGNORE_TRAILING">Trailing</option>
-              <option value="IGNORE_LEADING_AND_TRAILING">
-                Leading &amp; trailing
-              </option>
-              <option value="IGNORE_ALL">All</option>
-            </select>
-          </gr-select>
-        </span>
-      </div>
-    </section>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
index 41ac3e3..05f5ea1 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
@@ -1,30 +1,18 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-diff-preferences';
 import {GrDiffPreferences} from './gr-diff-preferences';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAll, stubRestApi} from '../../../test/test-utils';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {createDefaultDiffPrefs} from '../../../constants/constants';
 import {IronInputElement} from '@polymer/iron-input';
 import {GrSelect} from '../gr-select/gr-select';
-
-const basicFixture = fixtureFromElement('gr-diff-preferences');
+import {ParsedJSON} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-diff-preferences tests', () => {
   let element: GrDiffPreferences;
@@ -32,7 +20,7 @@
   let diffPreferences: DiffPreferencesInfo;
 
   function valueOf(title: string, id: string) {
-    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    const sections = queryAll(element, `#${id} section`) ?? [];
     let titleEl;
     for (let i = 0; i < sections.length; i++) {
       titleEl = sections[i].querySelector('.title');
@@ -49,12 +37,119 @@
 
     stubRestApi('getDiffPreferences').returns(Promise.resolve(diffPreferences));
 
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-diff-preferences></gr-diff-preferences>`);
 
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ ` <div class="gr-form-styles" id="diffPreferences">
+        <section>
+          <label class="title" for="contextLineSelect">Context</label>
+          <span class="value">
+            <gr-select id="contextSelect">
+              <select id="contextLineSelect">
+                <option value="3">3 lines</option>
+                <option value="10">10 lines</option>
+                <option value="25">25 lines</option>
+                <option value="50">50 lines</option>
+                <option value="75">75 lines</option>
+                <option value="100">100 lines</option>
+                <option value="-1">Whole file</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <label class="title" for="lineWrappingInput">Fit to screen</label>
+          <span class="value">
+            <input id="lineWrappingInput" type="checkbox" />
+          </span>
+        </section>
+        <section>
+          <label class="title" for="columnsInput">Diff width</label>
+          <span class="value">
+            <iron-input>
+              <input id="columnsInput" type="number" />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <label class="title" for="tabSizeInput">Tab width</label>
+          <span class="value">
+            <iron-input>
+              <input id="tabSizeInput" type="number" />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <label class="title" for="fontSizeInput">Font size</label>
+          <span class="value">
+            <iron-input>
+              <input id="fontSizeInput" type="number" />
+            </iron-input>
+          </span>
+        </section>
+        <section>
+          <label class="title" for="showTabsInput">Show tabs</label>
+          <span class="value">
+            <input checked="" id="showTabsInput" type="checkbox" />
+          </span>
+        </section>
+        <section>
+          <label class="title" for="showTrailingWhitespaceInput">
+            Show trailing whitespace
+          </label>
+          <span class="value">
+            <input
+              checked=""
+              id="showTrailingWhitespaceInput"
+              type="checkbox"
+            />
+          </span>
+        </section>
+        <section>
+          <label class="title" for="syntaxHighlightInput">
+            Syntax highlighting
+          </label>
+          <span class="value">
+            <input checked="" id="syntaxHighlightInput" type="checkbox" />
+          </span>
+        </section>
+        <section>
+          <label class="title" for="automaticReviewInput">
+            Automatically mark viewed files reviewed
+          </label>
+          <span class="value">
+            <input checked="" id="automaticReviewInput" type="checkbox" />
+          </span>
+        </section>
+        <section>
+          <div class="pref">
+            <label class="title" for="ignoreWhiteSpace">
+              Ignore Whitespace
+            </label>
+            <span class="value">
+              <gr-select>
+                <select id="ignoreWhiteSpace">
+                  <option value="IGNORE_NONE">None</option>
+                  <option value="IGNORE_TRAILING">Trailing</option>
+                  <option value="IGNORE_LEADING_AND_TRAILING">
+                    Leading & trailing
+                  </option>
+                  <option value="IGNORE_ALL">All</option>
+                </select>
+              </gr-select>
+            </span>
+          </div>
+        </section>
+      </div>`
+    );
+  });
+
+  test('renders preferences', () => {
     // Rendered with the expected preferences selected.
     const contextInput = valueOf('Context', 'diffPreferences')
       .firstElementChild as IronInputElement;
@@ -113,21 +208,32 @@
       diffPreferences.ignore_whitespace
     );
 
-    assert.isFalse(element.hasUnsavedChanges);
+    assert.isFalse(element.hasUnsavedChanges());
   });
 
   test('save changes', async () => {
+    assert.isTrue(element.diffPrefs!.show_whitespace_errors);
+
     const showTrailingWhitespaceCheckbox = valueOf(
       'Show trailing whitespace',
       'diffPreferences'
     ).firstElementChild as HTMLInputElement;
     showTrailingWhitespaceCheckbox.checked = false;
-    element._handleShowTrailingWhitespaceTap();
+    element.handleShowTrailingWhitespaceTap();
 
-    assert.isTrue(element.hasUnsavedChanges);
+    assert.isTrue(element.hasUnsavedChanges());
+
+    const getResponseObjStub = stubRestApi('getResponseObject').returns(
+      Promise.resolve(element.diffPrefs! as unknown as ParsedJSON)
+    );
 
     // Save the change.
     await element.save();
-    assert.isFalse(element.hasUnsavedChanges);
+
+    assert.isTrue(getResponseObjStub.called);
+
+    assert.isFalse(element.diffPrefs!.show_whitespace_errors);
+
+    assert.isFalse(element.hasUnsavedChanges());
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index cac3d59..886894e 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
@@ -1,137 +1,220 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {Subscription} from 'rxjs';
 import '@polymer/paper-tabs/paper-tab';
 import '@polymer/paper-tabs/paper-tabs';
 import '../gr-shell-command/gr-shell-command';
-import '../../../styles/gr-paper-styles';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-download-commands_html';
-import {customElement, property} from '@polymer/decorators';
-import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {queryAndAssert} from '../../../utils/common-util';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
-import {preferences$} from '../../../services/user/user-model';
-import {takeUntil} from 'rxjs/operators';
-import {Subject} from 'rxjs';
+import {paperStyles} from '../../../styles/gr-paper-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html, css} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {fire} from '../../../utils/event-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
 
 declare global {
   interface HTMLElementEventMap {
     'selected-changed': CustomEvent<{value: number}>;
+    'selected-scheme-changed': BindValueChangeEvent;
   }
   interface HTMLElementTagNameMap {
     'gr-download-commands': GrDownloadCommands;
   }
 }
 
-export interface GrDownloadCommands {
-  $: {
-    downloadTabs: PaperTabsElement;
-  };
-}
-
 export interface Command {
   title: string;
   command: string;
 }
 
 @customElement('gr-download-commands')
-export class GrDownloadCommands extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDownloadCommands extends LitElement {
   // TODO(TS): maybe default to [] as only used in dom-repeat
   @property({type: Array})
   commands?: Command[];
 
-  @property({type: Boolean})
-  _loggedIn = false;
+  // private but used in test
+  @state() loggedIn = false;
 
   @property({type: Array})
   schemes: string[] = [];
 
-  @property({type: String, notify: true})
+  @property({type: String})
   selectedScheme?: string;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-keyboard-shortcut-tooltips'})
   showKeyboardShortcutTooltips = false;
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly userService = appContext.userService;
+  // Private but used in tests.
+  readonly getUserModel = resolve(this, userModelToken);
 
-  disconnected$ = new Subject();
+  private subscriptions: Subscription[] = [];
 
   override connectedCallback() {
     super.connectedCallback();
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
+    this.restApiService.getLoggedIn().then(loggedIn => {
+      this.loggedIn = loggedIn;
     });
-    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(prefs => {
-      if (prefs?.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this.selectedScheme = prefs.download_scheme.toLowerCase();
-      }
-    });
+    this.subscriptions.push(
+      this.getUserModel().preferences$.subscribe(prefs => {
+        if (prefs?.download_scheme) {
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this.selectedScheme = prefs.download_scheme.toLowerCase();
+          fire(this, 'selected-scheme-changed', {value: this.selectedScheme});
+        }
+      })
+    );
   }
 
   override disconnectedCallback() {
-    this.disconnected$.next();
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
     super.disconnectedCallback();
   }
 
-  focusOnCopy() {
-    queryAndAssert<GrShellCommand>(this, 'gr-shell-command').focusOnCopy();
+  static override get styles() {
+    return [
+      paperStyles,
+      sharedStyles,
+      css`
+        paper-tabs {
+          height: 3rem;
+          margin-bottom: var(--spacing-m);
+          --paper-tabs-selection-bar-color: var(--link-color);
+        }
+        paper-tab {
+          max-width: 15rem;
+          text-transform: uppercase;
+          --paper-tab-ink: var(--link-color);
+          --paper-font-common-base_-_font-family: var(--header-font-family);
+          --paper-font-common-base_-_-webkit-font-smoothing: initial;
+          --paper-tab-content_-_margin-bottom: var(--spacing-s);
+          /* paper-tabs uses 700 here, which can look awkward */
+          --paper-tab-content-focused_-_font-weight: var(--font-weight-h3);
+          --paper-tab-content-focused_-_background: var(
+            --gray-background-focus
+          );
+          --paper-tab-content-unselected_-_opacity: 1;
+          --paper-tab-content-unselected_-_color: var(
+            --deemphasized-text-color
+          );
+        }
+        label,
+        input {
+          display: block;
+        }
+        label {
+          font-weight: var(--font-weight-bold);
+        }
+        .schemes {
+          display: flex;
+          justify-content: space-between;
+        }
+        .commands {
+          display: flex;
+          flex-direction: column;
+        }
+        gr-shell-command {
+          margin-bottom: var(--spacing-m);
+        }
+        .hidden {
+          display: none;
+        }
+      `,
+    ];
   }
 
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
+  override render() {
+    return html`
+      <div class="schemes">${this.renderDownloadTabs()}</div>
+      ${this.renderCommands()}
+    `;
   }
 
-  _handleTabChange(e: CustomEvent<{value: number}>) {
+  private renderDownloadTabs() {
+    const selectedIndex =
+      this.schemes.findIndex(scheme => scheme === this.selectedScheme) || 0;
+    return html`
+      <paper-tabs
+        id="downloadTabs"
+        class=${this.computeShowTabs()}
+        .selected=${selectedIndex}
+        @selected-changed=${this.handleTabChange}
+      >
+        ${this.schemes.map(scheme => this.renderPaperTab(scheme))}
+      </paper-tabs>
+    `;
+  }
+
+  private renderPaperTab(scheme: string) {
+    return html` <paper-tab data-scheme=${scheme}>${scheme}</paper-tab> `;
+  }
+
+  private renderCommands() {
+    return html`
+      <div class="commands" ?hidden=${!this.schemes.length}></div>
+        ${this.commands?.map((command, index) =>
+          this.renderShellCommand(command, index)
+        )}
+      </div>
+    `;
+  }
+
+  private renderShellCommand(command: Command, index: number) {
+    return html`
+      <gr-shell-command
+        class=${this.computeClass(command.title)}
+        .label=${command.title}
+        .command=${command.command}
+        .tooltip=${this.computeTooltip(index)}
+      ></gr-shell-command>
+    `;
+  }
+
+  async focusOnCopy() {
+    await this.updateComplete;
+    await queryAndAssert<GrShellCommand>(
+      this,
+      'gr-shell-command'
+    ).focusOnCopy();
+  }
+
+  private handleTabChange = (e: CustomEvent<{value: number}>) => {
     const scheme = this.schemes[e.detail.value];
     if (scheme && scheme !== this.selectedScheme) {
-      this.set('selectedScheme', scheme);
-      if (this._loggedIn) {
-        this.userService.updatePreferences({
+      this.selectedScheme = scheme;
+      fire(this, 'selected-scheme-changed', {value: scheme});
+      if (this.loggedIn) {
+        this.getUserModel().updatePreferences({
           download_scheme: this.selectedScheme,
         });
       }
     }
-  }
+  };
 
-  _computeSelected(schemes: string[], selectedScheme?: string) {
-    return `${schemes.findIndex(scheme => scheme === selectedScheme) || 0}`;
-  }
-
-  _computeShowTabs(schemes: string[]) {
-    return schemes.length > 1 ? '' : 'hidden';
-  }
-
-  _computeTooltip(showKeyboardShortcutTooltips: boolean, index: number) {
-    return index <= 4 && showKeyboardShortcutTooltips
+  private computeTooltip(index: number) {
+    return index <= 4 && this.showKeyboardShortcutTooltips
       ? `Keyboard shortcut: ${index + 1}`
       : '';
   }
 
+  private computeShowTabs() {
+    return this.schemes.length > 1 ? '' : 'hidden';
+  }
+
   // TODO: maybe unify with strToClassName from dom-util
-  _computeClass(title: string) {
+  private computeClass(title: string) {
     // Only retain [a-z] chars, so "Cherry Pick" becomes "cherrypick".
     return '_label_' + title.replace(/[^a-z]+/gi, '').toLowerCase();
   }
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
deleted file mode 100644
index f9c08ba..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-paper-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    paper-tabs {
-      height: 3rem;
-      margin-bottom: var(--spacing-m);
-      --paper-tabs-selection-bar-color: var(--link-color);
-    }
-    paper-tab {
-      max-width: 15rem;
-      text-transform: uppercase;
-      --paper-tab-ink: var(--link-color);
-    }
-    label,
-    input {
-      display: block;
-    }
-    label {
-      font-weight: var(--font-weight-bold);
-    }
-    .schemes {
-      display: flex;
-      justify-content: space-between;
-    }
-    .commands {
-      display: flex;
-      flex-direction: column;
-    }
-    gr-shell-command {
-      margin-bottom: var(--spacing-m);
-    }
-    .hidden {
-      display: none;
-    }
-  </style>
-  <div class="schemes">
-    <paper-tabs
-      id="downloadTabs"
-      class$="[[_computeShowTabs(schemes)]]"
-      selected="[[_computeSelected(schemes, selectedScheme)]]"
-      on-selected-changed="_handleTabChange"
-    >
-      <template is="dom-repeat" items="[[schemes]]" as="scheme">
-        <paper-tab data-scheme$="[[scheme]]">[[scheme]]</paper-tab>
-      </template>
-    </paper-tabs>
-  </div>
-  <div class="commands" hidden$="[[!schemes.length]]" hidden="">
-    <template is="dom-repeat" items="[[commands]]" as="command" indexAs="index">
-      <gr-shell-command
-        class$="[[_computeClass(command.title)]]"
-        label="[[command.title]]"
-        command="[[command.command]]"
-        tooltip="[[_computeTooltip(showKeyboardShortcutTooltips, index)]]"
-      ></gr-shell-command>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index ef712ac..b1d4e36 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -1,31 +1,25 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-download-commands';
 import {GrDownloadCommands} from './gr-download-commands';
-import {isHidden, queryAndAssert, stubRestApi} from '../../../test/test-utils';
-import {updatePreferences} from '../../../services/user/user-model';
+import {
+  isHidden,
+  query,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {createPreferences} from '../../../test/test-data-generators';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
 import {createDefaultPreferences} from '../../../constants/constants';
-
-const basicFixture = fixtureFromElement('gr-download-commands');
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {fixture, html, assert} from '@open-wc/testing';
+import {PaperTabElement} from '@polymer/paper-tabs/paper-tab';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-download-commands', () => {
   let element: GrDownloadCommands;
@@ -55,55 +49,120 @@
   ];
   const SELECTED_SCHEME = 'http';
 
-  setup(() => {});
-
   suite('unauthenticated', () => {
     setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
+      element = await fixture(
+        html`<gr-download-commands></gr-download-commands>`
+      );
       element.schemes = SCHEMES;
       element.commands = COMMANDS;
       element.selectedScheme = SELECTED_SCHEME;
-      await flush();
+      await element.updateComplete;
     });
 
-    test('focusOnCopy', () => {
+    test('render', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="schemes">
+            <paper-tabs
+              dir="null"
+              id="downloadTabs"
+              role="tablist"
+              tabindex="0"
+            >
+              <paper-tab
+                aria-disabled="false"
+                aria-selected="true"
+                class="iron-selected"
+                data-scheme="http"
+                role="tab"
+                tabindex="0"
+              >
+                http
+              </paper-tab>
+              <paper-tab
+                aria-disabled="false"
+                aria-selected="false"
+                data-scheme="repo"
+                role="tab"
+                tabindex="-1"
+              >
+                repo
+              </paper-tab>
+              <paper-tab
+                aria-disabled="false"
+                aria-selected="false"
+                data-scheme="ssh"
+                role="tab"
+                tabindex="-1"
+              >
+                ssh
+              </paper-tab>
+            </paper-tabs>
+          </div>
+          <div class="commands"></div>
+          <gr-shell-command class="_label_checkout"> </gr-shell-command>
+          <gr-shell-command class="_label_cherrypick"> </gr-shell-command>
+          <gr-shell-command class="_label_formatpatch"> </gr-shell-command>
+          <gr-shell-command class="_label_pull"> </gr-shell-command>
+        `
+      );
+    });
+
+    test('focusOnCopy', async () => {
       const focusStub = sinon.stub(
         queryAndAssert<GrShellCommand>(element, 'gr-shell-command'),
         'focusOnCopy'
       );
-      element.focusOnCopy();
+      await element.focusOnCopy();
       assert.isTrue(focusStub.called);
     });
 
-    test('element visibility', () => {
+    test('element visibility', async () => {
       assert.isFalse(isHidden(queryAndAssert(element, 'paper-tabs')));
       assert.isFalse(isHidden(queryAndAssert(element, '.commands')));
+      assert.isTrue(Boolean(query(element, '#downloadTabs')));
 
       element.schemes = [];
+      await element.updateComplete;
       assert.isTrue(isHidden(queryAndAssert(element, 'paper-tabs')));
+      assert.isTrue(Boolean(query(element, '.commands')));
       assert.isTrue(isHidden(queryAndAssert(element, '.commands')));
+      // Should still be present but hidden
+      assert.isTrue(Boolean(query(element, '#downloadTabs')));
+      assert.isTrue(isHidden(queryAndAssert(element, '#downloadTabs')));
     });
 
-    test('tab selection', () => {
-      assert.equal(element.$.downloadTabs.selected, '0');
-      MockInteractions.tap(queryAndAssert(element, '[data-scheme="ssh"]'));
-      flush();
+    test('tab selection', async () => {
+      assert.equal(
+        queryAndAssert<PaperTabsElement>(element, '#downloadTabs').selected,
+        '0'
+      );
+      queryAndAssert<PaperTabElement>(element, '[data-scheme="ssh"]').click();
+      await element.updateComplete;
       assert.equal(element.selectedScheme, 'ssh');
-      assert.equal(element.$.downloadTabs.selected, '2');
+      assert.equal(
+        queryAndAssert<PaperTabsElement>(element, '#downloadTabs').selected,
+        '2'
+      );
     });
 
-    test('saves scheme to preferences', () => {
-      element._loggedIn = true;
+    test('saves scheme to preferences', async () => {
+      element.loggedIn = true;
       const savePrefsStub = stubRestApi('savePreferences').returns(
         Promise.resolve(createDefaultPreferences())
       );
 
-      flush();
+      await element.updateComplete;
 
-      const repoTab = queryAndAssert(element, 'paper-tab[data-scheme="repo"]');
+      const repoTab = queryAndAssert<PaperTabElement>(
+        element,
+        'paper-tab[data-scheme="repo"]'
+      );
 
-      MockInteractions.tap(repoTab);
+      repoTab.click();
 
       assert.isTrue(savePrefsStub.called);
       assert.equal(
@@ -113,23 +172,27 @@
     });
   });
   suite('authenticated', () => {
+    let element: GrDownloadCommands;
+    let userModel: UserModel;
+    setup(async () => {
+      userModel = testResolver(userModelToken);
+      element = await fixture(
+        html`<gr-download-commands></gr-download-commands>`
+      );
+    });
     test('loads scheme from preferences', async () => {
-      updatePreferences({
+      userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'repo',
       });
-      const element = basicFixture.instantiate();
-      await flush();
       assert.equal(element.selectedScheme, 'repo');
     });
 
     test('normalize scheme from preferences', async () => {
-      updatePreferences({
+      userModel.setPreferences({
         ...createPreferences(),
         download_scheme: 'REPO',
       });
-      const element = basicFixture.instantiate();
-      await flush();
       assert.equal(element.selectedScheme, 'repo');
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 6180f35..b6ca9f5 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-dropdown/iron-dropdown';
 import '@polymer/paper-item/paper-item';
@@ -21,13 +10,19 @@
 import '../gr-button/gr-button';
 import '../gr-date-formatter/gr-date-formatter';
 import '../gr-select/gr-select';
-import '../gr-file-status-chip/gr-file-status-chip';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-dropdown-list_html';
-import {customElement, property, observe} from '@polymer/decorators';
+import '../gr-file-status/gr-file-status';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query} from 'lit/decorators.js';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {Timestamp} from '../../../types/common';
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
+import {GrButton} from '../gr-button/gr-button';
+import {assertIsDefined} from '../../../utils/common-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ValueChangedEvent} from '../../../types/events';
+import {incrementalRepeat} from '../../lit/incremental-repeat';
+import {when} from 'lit/directives/when.js';
+import {isMagicPath} from '../../../utils/path-list-util';
 
 /**
  * Required values are text and value. mobileText and triggerText will
@@ -49,36 +44,31 @@
   file?: NormalizedFileInfo;
 }
 
-export interface GrDropdownList {
-  $: {
-    dropdown: IronDropdownElement;
-  };
-}
-
-export interface ValueChangeDetail {
-  value: string;
-}
-
-export type DropDownValueChangeEvent = CustomEvent<ValueChangeDetail>;
-
-@customElement('gr-dropdown-list')
-export class GrDropdownList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+declare global {
+  interface HTMLElementEventMap {
+    'value-change': ValueChangedEvent<string>;
   }
+}
+@customElement('gr-dropdown-list')
+export class GrDropdownList extends LitElement {
+  @query('#dropdown')
+  dropdown?: IronDropdownElement;
+
+  @query('#trigger')
+  trigger?: GrButton;
 
   /**
    * Fired when the selected value changes
    *
    * @event value-change
    *
-   * @property {string|number} value
+   * @property {string} value
    */
 
   @property({type: Number})
   initialCount = 75;
 
-  @property({type: Object})
+  @property({type: Array})
   items?: DropdownItem[];
 
   @property({type: String})
@@ -87,27 +77,244 @@
   @property({type: Boolean})
   disabled = false;
 
-  @property({type: String, notify: true})
-  value: string | number = '';
+  @property({type: String})
+  value = '';
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-copy-for-trigger-text'})
   showCopyForTriggerText = false;
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        #triggerText {
+          -moz-user-select: text;
+          -ms-user-select: text;
+          -webkit-user-select: text;
+          user-select: text;
+        }
+        .dropdown-trigger {
+          cursor: pointer;
+          padding: 0;
+        }
+        .dropdown-content {
+          background-color: var(--dropdown-background-color);
+          box-shadow: var(--elevation-level-2);
+          max-height: 70vh;
+          min-width: 266px;
+        }
+        paper-item:hover {
+          background-color: var(--hover-background-color);
+        }
+        paper-item:not(:last-of-type) {
+          border-bottom: 1px solid var(--border-color);
+        }
+        .bottomContent {
+          color: var(--deemphasized-text-color);
+        }
+        .bottomContent,
+        .topContent {
+          display: flex;
+          justify-content: space-between;
+          flex-direction: row;
+          width: 100%;
+        }
+        gr-button {
+          font-family: var(--trigger-style-font-family);
+          --gr-button-text-color: var(--trigger-style-text-color);
+        }
+        gr-date-formatter {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-xxl);
+          white-space: nowrap;
+        }
+        gr-select {
+          display: none;
+        }
+        /* Because the iron dropdown 'area' includes the trigger, and the entire
+          width of the dropdown, we want to treat tapping the area above the
+          dropdown content as if it is tapping whatever content is underneath
+          it. The next two styles allow this to happen. */
+        iron-dropdown {
+          max-width: none;
+          pointer-events: none;
+        }
+        paper-listbox {
+          pointer-events: auto;
+          --paper-listbox_-_padding: 0;
+        }
+        paper-item {
+          cursor: pointer;
+          flex-direction: column;
+          font-size: inherit;
+          /* This variable was introduced in Dec 2019. We keep both min-height
+            * rules around, because --paper-item-min-height is not yet
+            * upstreamed.
+            */
+          --paper-item-min-height: 0;
+          --paper-item_-_min-height: 0;
+          --paper-item_-_padding: 10px 16px;
+          --paper-item-focused-before_-_background-color: var(
+            --selection-background-color
+          );
+          --paper-item-focused_-_background-color: var(
+            --selection-background-color
+          );
+        }
+        @media only screen and (max-width: 50em) {
+          gr-select {
+            display: var(--gr-select-style-display, inline);
+            width: var(--gr-select-style-width);
+          }
+          gr-button,
+          iron-dropdown {
+            display: none;
+          }
+          select {
+            width: var(--native-select-style-width);
+          }
+        }
+      `,
+    ];
+  }
+
+  protected override willUpdate(changedProperties: PropertyValues): void {
+    if (changedProperties.has('items') || changedProperties.has('value')) {
+      this.handleValueChange();
+    }
+  }
+
+  override render() {
+    return html`
+      <gr-button
+        id="trigger"
+        ?disabled=${this.disabled}
+        down-arrow
+        link
+        class="dropdown-trigger"
+        slot="dropdown-trigger"
+        no-uppercase
+        @click=${this.showDropdownTapHandler}
+      >
+        <span id="triggerText">${this.text}</span>
+        <gr-copy-clipboard
+          ?hidden=${!this.showCopyForTriggerText}
+          hideInput
+          .text=${this.text}
+        ></gr-copy-clipboard>
+      </gr-button>
+      <iron-dropdown
+        id="dropdown"
+        .verticalAlign=${'top'}
+        .horizontalAlign=${'left'}
+        .dynamicAlign=${true}
+        .noOverlap=${true}
+        .allowOutsideScroll=${true}
+        @click=${this.handleDropdownClick}
+      >
+        <paper-listbox
+          class="dropdown-content"
+          slot="dropdown-content"
+          .attrForSelected=${'data-value'}
+          .selected=${this.value}
+          @selected-changed=${this.selectedChanged}
+        >
+          ${incrementalRepeat({
+            values: this.items ?? [],
+            initialCount: this.initialCount,
+            mapFn: item => this.renderPaperItem(item as DropdownItem),
+          })}
+        </paper-listbox>
+      </iron-dropdown>
+      <gr-select
+        .bindValue=${this.value}
+        @bind-value-changed=${this.selectedChanged}
+      >
+        <select>
+          ${this.items?.map(
+            item => html`
+              <option ?disabled=${item.disabled} value=${`${item.value}`}>
+                ${this.computeMobileText(item)}
+              </option>
+            `
+          )}
+        </select>
+      </gr-select>
+    `;
+  }
+
+  private renderPaperItem(item: DropdownItem) {
+    return html`
+      <paper-item ?disabled=${item.disabled} data-value=${item.value}>
+        <div class="topContent">
+          <div>${item.text}</div>
+          ${when(
+            item.date,
+            () => html`
+              <gr-date-formatter .dateStr=${item.date}></gr-date-formatter>
+            `
+          )}
+          ${when(
+            item.file?.status && !isMagicPath(item.file?.__path),
+            () => html`
+              <gr-file-status .status=${item.file?.status}></gr-file-status>
+            `
+          )}
+        </div>
+        ${when(
+          item.bottomText,
+          () => html`
+            <div class="bottomContent">
+              <div>${item.bottomText}</div>
+            </div>
+          `
+        )}
+      </paper-item>
+    `;
+  }
+
+  private selectedChanged(e: ValueChangedEvent<string>) {
+    this.value = e.detail.value;
+  }
+
   /**
    * Handle a click on the iron-dropdown element.
    */
-  _handleDropdownClick() {
+  private handleDropdownClick() {
     // async is needed so that that the click event is fired before the
     // dropdown closes (This was a bug for touch devices).
     setTimeout(() => {
-      this.$.dropdown.close();
+      assertIsDefined(this.dropdown);
+      this.dropdown.close();
     }, 1);
   }
 
+  private handleValueChange() {
+    if (this.value === undefined || this.items === undefined) {
+      return;
+    }
+    const selectedObj = this.items.find(item => `${item.value}` === this.value);
+    if (!selectedObj) {
+      return;
+    }
+    this.text = selectedObj.triggerText
+      ? selectedObj.triggerText
+      : selectedObj.text;
+    this.dispatchEvent(
+      new CustomEvent('value-change', {
+        detail: {value: this.value},
+        bubbles: false,
+      })
+    );
+  }
+
   /**
    * Handle a click on the button to open the dropdown.
    */
-  _showDropdownTapHandler() {
+  private showDropdownTapHandler() {
     this.open();
   }
 
@@ -115,37 +322,14 @@
    * Open the dropdown.
    */
   open() {
-    this.$.dropdown.open();
+    assertIsDefined(this.dropdown);
+    this.dropdown.open();
   }
 
-  _computeMobileText(item: DropdownItem) {
+  // Private but used in tests.
+  computeMobileText(item: DropdownItem) {
     return item.mobileText ? item.mobileText : item.text;
   }
-
-  computeStringValue(val: string | number) {
-    return String(val);
-  }
-
-  @observe('value', 'items')
-  _handleValueChange(value?: string, items?: DropdownItem[]) {
-    if (!value || !items) {
-      return;
-    }
-    const selectedObj = items.find(item => `${item.value}` === `${value}`);
-    if (!selectedObj) {
-      return;
-    }
-    this.text = selectedObj.triggerText
-      ? selectedObj.triggerText
-      : selectedObj.text;
-    const detail: ValueChangeDetail = {value};
-    this.dispatchEvent(
-      new CustomEvent('value-change', {
-        detail,
-        bubbles: false,
-      })
-    );
-  }
 }
 
 declare global {
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
deleted file mode 100644
index 3875871..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
+++ /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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: inline-block;
-    }
-    #triggerText {
-      -moz-user-select: text;
-      -ms-user-select: text;
-      -webkit-user-select: text;
-      user-select: text;
-    }
-    .dropdown-trigger {
-      cursor: pointer;
-      padding: 0;
-    }
-    .dropdown-content {
-      background-color: var(--dropdown-background-color);
-      box-shadow: var(--elevation-level-2);
-      max-height: 70vh;
-      min-width: 266px;
-    }
-    paper-listbox {
-      --paper-listbox: {
-        padding: 0;
-      }
-    }
-    paper-item {
-      cursor: pointer;
-      flex-direction: column;
-      font-size: inherit;
-      /* This variable was introduced in Dec 2019. We keep both min-height
-         * rules around, because --paper-item-min-height is not yet upstreamed.
-         */
-      --paper-item-min-height: 0;
-      --paper-item: {
-        min-height: 0;
-        padding: 10px 16px;
-      }
-      --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);
-    }
-    paper-item:not(:last-of-type) {
-      border-bottom: 1px solid var(--border-color);
-    }
-    .bottomContent {
-      color: var(--deemphasized-text-color);
-    }
-    .bottomContent,
-    .topContent {
-      display: flex;
-      justify-content: space-between;
-      flex-direction: row;
-      width: 100%;
-    }
-    gr-button {
-      font-family: var(--trigger-style-font-family);
-      --gr-button-text-color: var(--trigger-style-text-color);
-    }
-    gr-date-formatter {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-xxl);
-      white-space: nowrap;
-    }
-    gr-select {
-      display: none;
-    }
-    /* Because the iron dropdown 'area' includes the trigger, and the entire
-       width of the dropdown, we want to treat tapping the area above the
-       dropdown content as if it is tapping whatever content is underneath it.
-       The next two styles allow this to happen. */
-    iron-dropdown {
-      max-width: none;
-      pointer-events: none;
-    }
-    paper-listbox {
-      pointer-events: auto;
-    }
-    @media only screen and (max-width: 50em) {
-      gr-select {
-        display: inline;
-        @apply --gr-select-style;
-      }
-      gr-button,
-      iron-dropdown {
-        display: none;
-      }
-      select {
-        @apply --native-select-style;
-      }
-    }
-  </style>
-  <gr-button
-    disabled="[[disabled]]"
-    down-arrow=""
-    link=""
-    id="trigger"
-    class="dropdown-trigger"
-    on-click="_showDropdownTapHandler"
-    slot="dropdown-trigger"
-    no-uppercase
-  >
-    <span id="triggerText">[[text]]</span>
-    <gr-copy-clipboard
-      hidden="[[!showCopyForTriggerText]]"
-      hideInput=""
-      text="[[text]]"
-    ></gr-copy-clipboard>
-  </gr-button>
-  <iron-dropdown
-    id="dropdown"
-    vertical-align="top"
-    horizontal-align="left"
-    dynamic-align
-    no-overlap
-    allow-outside-scroll="true"
-    on-click="_handleDropdownClick"
-  >
-    <paper-listbox
-      class="dropdown-content"
-      slot="dropdown-content"
-      attr-for-selected="data-value"
-      selected="{{value}}"
-    >
-      <template
-        is="dom-repeat"
-        items="[[items]]"
-        initial-count="[[initialCount]]"
-      >
-        <paper-item disabled="[[item.disabled]]" data-value$="[[item.value]]">
-          <div class="topContent">
-            <div>[[item.text]]</div>
-            <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">
-              <div>[[item.bottomText]]</div>
-            </div>
-          </template>
-        </paper-item>
-      </template>
-    </paper-listbox>
-  </iron-dropdown>
-  <gr-select bind-value="{{value}}">
-    <select>
-      <template is="dom-repeat" items="[[items]]">
-        <option
-          disabled$="[[item.disabled]]"
-          value="[[computeStringValue(item.value)]]"
-        >
-          [[_computeMobileText(item)]]
-        </option>
-      </template>
-    </select>
-  </gr-select>
-`;
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
deleted file mode 100644
index 909a3bbf..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
+++ /dev/null
@@ -1,164 +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-dropdown-list.js';
-
-const basicFixture = fixtureFromElement('gr-dropdown-list');
-
-suite('gr-dropdown-list tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('hide copy by default', () => {
-    const copyEl = element.shadowRoot
-        .querySelector('#triggerText + gr-copy-clipboard');
-    assert.isTrue(!!copyEl);
-    assert.isTrue(copyEl.hidden);
-  });
-
-  test('show copy if enabled', () => {
-    element.showCopyForTriggerText = true;
-    flush();
-    const copyEl = element.shadowRoot.querySelector(
-        '#triggerText + gr-copy-clipboard');
-    assert.isTrue(!!copyEl);
-    assert.isFalse(copyEl.hidden);
-  });
-
-  test('tap on trigger opens menu', () => {
-    sinon.stub(element, 'open')
-        .callsFake(() => { element.$.dropdown.open(); });
-    assert.isFalse(element.$.dropdown.opened);
-    MockInteractions.tap(element.$.trigger);
-    assert.isTrue(element.$.dropdown.opened);
-  });
-
-  test('_computeMobileText', () => {
-    const item = {
-      value: 1,
-      text: 'text',
-    };
-    assert.equal(element._computeMobileText(item), item.text);
-    item.mobileText = 'mobile text';
-    assert.equal(element._computeMobileText(item), item.mobileText);
-  });
-
-  test('options are selected and laid out correctly', async () => {
-    element.value = 2;
-    element.items = [
-      {
-        value: 1,
-        text: 'Top Text 1',
-      },
-      {
-        value: 2,
-        bottomText: 'Bottom Text 2',
-        triggerText: 'Button Text 2',
-        text: 'Top Text 2',
-        mobileText: 'Mobile Text 2',
-      },
-      {
-        value: 3,
-        disabled: true,
-        bottomText: 'Bottom Text 3',
-        triggerText: 'Button Text 3',
-        date: '2017-08-18 23:11:42.569000000',
-        text: 'Top Text 3',
-        mobileText: 'Mobile Text 3',
-      },
-    ];
-    assert.equal(element.shadowRoot
-        .querySelector('paper-listbox').selected, element.value);
-    assert.equal(element.text, 'Button Text 2');
-    await flush();
-
-    const items = element.root.querySelectorAll('paper-item');
-    const mobileItems = element.root.querySelectorAll('option');
-    assert.equal(items.length, 3);
-    assert.equal(mobileItems.length, 3);
-
-    // First Item
-    // The first item should be disabled, has no bottom text, and no date.
-    assert.isFalse(!!items[0].disabled);
-    assert.isFalse(mobileItems[0].disabled);
-    assert.isFalse(items[0].classList.contains('iron-selected'));
-    assert.isFalse(mobileItems[0].selected);
-
-    assert.isNotOk(items[0].querySelector('gr-date-formatter'));
-    assert.isNotOk(items[0].querySelector('.bottomContent'));
-    assert.equal(items[0].dataset.value, element.items[0].value);
-    assert.equal(mobileItems[0].value, element.items[0].value);
-    assert.equal(items[0].querySelector('.topContent div')
-        .innerText, element.items[0].text);
-
-    // Since no mobile specific text, it should fall back to text.
-    assert.equal(mobileItems[0].text, element.items[0].text);
-
-    // Second Item
-    // The second item should have top text, bottom text, and no date.
-    assert.isFalse(!!items[1].disabled);
-    assert.isFalse(mobileItems[1].disabled);
-    assert.isTrue(items[1].classList.contains('iron-selected'));
-    assert.isTrue(mobileItems[1].selected);
-
-    assert.isNotOk(items[1].querySelector('gr-date-formatter'));
-    assert.isOk(items[1].querySelector('.bottomContent'));
-    assert.equal(items[1].dataset.value, element.items[1].value);
-    assert.equal(mobileItems[1].value, element.items[1].value);
-    assert.equal(items[1].querySelector('.topContent div')
-        .innerText, element.items[1].text);
-
-    // Since there is mobile specific text, it should that.
-    assert.equal(mobileItems[1].text, element.items[1].mobileText);
-
-    // Since this item is selected, and it has triggerText defined, that
-    // should be used.
-    assert.equal(element.text, element.items[1].triggerText);
-
-    // Third item
-    // The third item should be disabled, and have a date, and bottom content.
-    assert.isTrue(!!items[2].disabled);
-    assert.isTrue(mobileItems[2].disabled);
-    assert.isFalse(items[2].classList.contains('iron-selected'));
-    assert.isFalse(mobileItems[2].selected);
-
-    assert.isOk(items[2].querySelector('gr-date-formatter'));
-    assert.isOk(items[2].querySelector('.bottomContent'));
-    assert.equal(items[2].dataset.value, element.items[2].value);
-    assert.equal(mobileItems[2].value, element.items[2].value);
-    assert.equal(items[2].querySelector('.topContent div')
-        .innerText, element.items[2].text);
-
-    // Since there is mobile specific text, it should that.
-    assert.equal(mobileItems[2].text, element.items[2].mobileText);
-
-    // Select a new item.
-    MockInteractions.tap(items[0]);
-    flush();
-    assert.equal(element.value, 1);
-    assert.isTrue(items[0].classList.contains('iron-selected'));
-    assert.isTrue(mobileItems[0].selected);
-
-    // Since no triggerText, the fallback is used.
-    assert.equal(element.text, element.items[0].text);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
new file mode 100644
index 0000000..b9380cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
@@ -0,0 +1,293 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-dropdown-list';
+import {GrDropdownList} from './gr-dropdown-list';
+import {
+  query,
+  queryAll,
+  queryAndAssert,
+  waitEventLoop,
+} from '../../../test/test-utils';
+import {PaperListboxElement} from '@polymer/paper-listbox';
+import {Timestamp} from '../../../types/common';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-dropdown-list tests', () => {
+  let element: GrDropdownList;
+
+  setup(async () => {
+    element = await fixture<GrDropdownList>(
+      html`<gr-dropdown-list></gr-dropdown-list>`
+    );
+  });
+
+  test('render', async () => {
+    element.value = '2';
+    element.items = [
+      {
+        value: 1,
+        text: 'Top Text 1',
+      },
+      {
+        value: 2,
+        bottomText: 'Bottom Text 2',
+        triggerText: 'Button Text 2',
+        text: 'Top Text 2',
+        mobileText: 'Mobile Text 2',
+      },
+      {
+        value: 3,
+        disabled: true,
+        bottomText: 'Bottom Text 3',
+        triggerText: 'Button Text 3',
+        date: '2017-08-18 23:11:42.569000000' as Timestamp,
+        text: 'Top Text 3',
+        mobileText: 'Mobile Text 3',
+      },
+    ];
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-button
+          aria-disabled="false"
+          class="dropdown-trigger"
+          down-arrow=""
+          id="trigger"
+          link=""
+          no-uppercase=""
+          role="button"
+          slot="dropdown-trigger"
+          tabindex="0"
+        >
+          <span id="triggerText"> Button Text 2 </span>
+          <gr-copy-clipboard hidden="" hideinput=""> </gr-copy-clipboard>
+        </gr-button>
+        <iron-dropdown
+          aria-disabled="false"
+          aria-hidden="true"
+          horizontal-align="left"
+          id="dropdown"
+          style="outline: none; display: none;"
+          vertical-align="top"
+        >
+          <paper-listbox
+            class="dropdown-content"
+            role="listbox"
+            slot="dropdown-content"
+            tabindex="0"
+          >
+            <paper-item
+              aria-disabled="false"
+              aria-selected="false"
+              data-value="1"
+              role="option"
+              tabindex="-1"
+            >
+              <div class="topContent">
+                <div>Top Text 1</div>
+              </div>
+            </paper-item>
+            <paper-item
+              aria-disabled="false"
+              aria-selected="true"
+              class="iron-selected"
+              data-value="2"
+              role="option"
+              tabindex="0"
+            >
+              <div class="topContent">
+                <div>Top Text 2</div>
+              </div>
+              <div class="bottomContent">
+                <div>Bottom Text 2</div>
+              </div>
+            </paper-item>
+            <paper-item
+              aria-disabled="true"
+              aria-selected="false"
+              data-value="3"
+              disabled=""
+              role="option"
+              style="pointer-events: none;"
+              tabindex="-1"
+            >
+              <div class="topContent">
+                <div>Top Text 3</div>
+                <gr-date-formatter> </gr-date-formatter>
+              </div>
+              <div class="bottomContent">
+                <div>Bottom Text 3</div>
+              </div>
+            </paper-item>
+          </paper-listbox>
+        </iron-dropdown>
+        <gr-select>
+          <select>
+            <option value="1">Top Text 1</option>
+            <option value="2">Mobile Text 2</option>
+            <option disabled="" value="3">Mobile Text 3</option>
+          </select>
+        </gr-select>
+      `
+    );
+  });
+
+  test('hide copy by default', () => {
+    const copyEl = query<HTMLElement>(
+      element,
+      '#triggerText + gr-copy-clipboard'
+    )!;
+    assert.isOk(copyEl);
+    assert.isTrue(copyEl.hidden);
+  });
+
+  test('show copy if enabled', async () => {
+    element.showCopyForTriggerText = true;
+    await element.updateComplete;
+    const copyEl = query<HTMLElement>(
+      element,
+      '#triggerText + gr-copy-clipboard'
+    )!;
+    assert.isOk(copyEl);
+    assert.isFalse(copyEl.hidden);
+  });
+
+  test('tap on trigger opens menu', () => {
+    sinon.stub(element, 'open').callsFake(() => {
+      assertIsDefined(element.dropdown);
+      element.dropdown.open();
+    });
+    assertIsDefined(element.dropdown);
+    assert.isFalse(element.dropdown.opened);
+    assertIsDefined(element.trigger);
+    element.trigger.click();
+    assert.isTrue(element.dropdown.opened);
+  });
+
+  test('computeMobileText', () => {
+    const item: any = {
+      value: 1,
+      text: 'text',
+    };
+    assert.equal(element.computeMobileText(item), item.text);
+    item.mobileText = 'mobile text';
+    assert.equal(element.computeMobileText(item), item.mobileText);
+  });
+
+  test('options are selected and laid out correctly', async () => {
+    element.value = '2';
+    element.items = [
+      {
+        value: 1,
+        text: 'Top Text 1',
+      },
+      {
+        value: 2,
+        bottomText: 'Bottom Text 2',
+        triggerText: 'Button Text 2',
+        text: 'Top Text 2',
+        mobileText: 'Mobile Text 2',
+      },
+      {
+        value: 3,
+        disabled: true,
+        bottomText: 'Bottom Text 3',
+        triggerText: 'Button Text 3',
+        date: '2017-08-18 23:11:42.569000000' as Timestamp,
+        text: 'Top Text 3',
+        mobileText: 'Mobile Text 3',
+      },
+    ];
+    await element.updateComplete;
+    await waitEventLoop();
+
+    assert.equal(
+      queryAndAssert<PaperListboxElement>(element, 'paper-listbox').selected,
+      element.value
+    );
+    assert.equal(element.text, 'Button Text 2');
+
+    const items = queryAll<HTMLInputElement>(element, 'paper-item');
+    const mobileItems = queryAll<HTMLOptionElement>(element, 'option');
+    assert.equal(items.length, 3);
+    assert.equal(mobileItems.length, 3);
+
+    // First Item
+    // The first item should be disabled, has no bottom text, and no date.
+    assert.isFalse(!!items[0].disabled);
+    assert.isFalse(mobileItems[0].disabled);
+    assert.isFalse(items[0].classList.contains('iron-selected'));
+    assert.isFalse(mobileItems[0].selected);
+
+    assert.isNotOk(items[0].querySelector('gr-date-formatter'));
+    assert.isNotOk(items[0].querySelector('.bottomContent'));
+    assert.equal(items[0].dataset.value, element.items[0].value as any);
+    assert.equal(mobileItems[0].value, element.items[0].value);
+    assert.equal(
+      queryAndAssert<HTMLDivElement>(items[0], '.topContent div').innerText,
+      element.items[0].text
+    );
+
+    // Since no mobile specific text, it should fall back to text.
+    assert.equal(mobileItems[0].text, element.items[0].text);
+
+    // Second Item
+    // The second item should have top text, bottom text, and no date.
+    assert.isFalse(!!items[1].disabled);
+    assert.isFalse(mobileItems[1].disabled);
+    assert.isTrue(items[1].classList.contains('iron-selected'));
+    assert.isTrue(mobileItems[1].selected);
+
+    assert.isNotOk(items[1].querySelector('gr-date-formatter'));
+    assert.isOk(items[1].querySelector('.bottomContent'));
+    assert.equal(items[1].dataset.value, element.items[1].value as any);
+    assert.equal(mobileItems[1].value, element.items[1].value);
+    assert.equal(
+      queryAndAssert<HTMLDivElement>(items[1], '.topContent div').innerText,
+      element.items[1].text
+    );
+
+    // Since there is mobile specific text, it should that.
+    assert.equal(mobileItems[1].text, element.items[1].mobileText);
+
+    // Since this item is selected, and it has triggerText defined, that
+    // should be used.
+    assert.equal(element.text, element.items[1].triggerText);
+
+    // Third item
+    // The third item should be disabled, and have a date, and bottom content.
+    assert.isTrue(!!items[2].disabled);
+    assert.isTrue(mobileItems[2].disabled);
+    assert.isFalse(items[2].classList.contains('iron-selected'));
+    assert.isFalse(mobileItems[2].selected);
+
+    assert.isOk(items[2].querySelector('gr-date-formatter'));
+    assert.isOk(items[2].querySelector('.bottomContent'));
+    assert.equal(items[2].dataset.value, element.items[2].value as any);
+    assert.equal(mobileItems[2].value, element.items[2].value);
+    assert.equal(
+      queryAndAssert<HTMLDivElement>(items[2], '.topContent div').innerText,
+      element.items[2].text
+    );
+
+    // Since there is mobile specific text, it should that.
+    assert.equal(mobileItems[2].text, element.items[2].mobileText);
+
+    // Select a new item.
+    items[0].click();
+    await element.updateComplete;
+    assert.equal(element.value, '1');
+    assert.isTrue(items[0].classList.contains('iron-selected'));
+    assert.isTrue(mobileItems[0].selected);
+
+    // Since no triggerText, the fallback is used.
+    assert.equal(element.text, element.items[0].text);
+  });
+});
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 2b56de6..3a8946a 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-dropdown/iron-dropdown';
 import '../gr-button/gr-button';
@@ -20,34 +9,31 @@
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/shared-styles';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-dropdown_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
-import {property, customElement, observe} from '@polymer/decorators';
-import {addShortcut, Key} from '../../../utils/dom-util';
+import {property, customElement, query, state} from 'lit/decorators.js';
+import {Key} from '../../../utils/dom-util';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {assertIsDefined} from '../../../utils/common-util';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 const REL_NOOPENER = 'noopener';
 const REL_EXTERNAL = 'external';
 
 declare global {
   interface HTMLElementEventMap {
-    'opened-changed': CustomEvent;
+    'opened-changed': ValueChangedEvent<boolean>;
   }
   interface HTMLElementTagNameMap {
     'gr-dropdown': GrDropdown;
   }
 }
 
-export interface GrDropdown {
-  $: {
-    dropdown: IronDropdownElement;
-    trigger: GrButton;
-  };
-}
-
 export interface DropdownLink {
   url?: string;
   name?: string;
@@ -58,21 +44,102 @@
   tooltip?: string;
 }
 
-interface DisableIdsRecord {
-  base: string[];
-}
-
 export interface DropdownContent {
   text: string;
   bold?: boolean;
 }
 
 @customElement('gr-dropdown')
-export class GrDropdown extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrDropdown extends LitElement {
+  @query('#dropdown')
+  dropdown?: IronDropdownElement;
 
+  @query('#trigger')
+  trigger?: GrButton;
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: inline-block;
+        }
+        .dropdown-trigger {
+          text-decoration: none;
+          width: 100%;
+        }
+        .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;
+        }
+        gr-avatar {
+          height: 2em;
+          width: 2em;
+          vertical-align: middle;
+        }
+        gr-button[link]:focus {
+          outline: 5px auto -webkit-focus-ring-color;
+        }
+        ul {
+          list-style: none;
+        }
+        .topContent,
+        li {
+          border-bottom: 1px solid var(--border-color);
+        }
+        li:last-of-type {
+          border: none;
+        }
+        li .itemAction {
+          cursor: pointer;
+          display: block;
+          padding: var(--spacing-m) var(--spacing-l);
+        }
+        li .itemAction {
+          color: var(--gr-dropdown-item-color);
+          background-color: var(--gr-dropdown-item-background-color);
+          border: var(--gr-dropdown-item-border);
+          text-transform: var(--gr-dropdown-item-text-transform);
+        }
+        li .itemAction.disabled {
+          color: var(--deemphasized-text-color);
+          cursor: default;
+        }
+        li .itemAction:link,
+        li .itemAction:visited {
+          text-decoration: none;
+        }
+        li .itemAction:not(.disabled):hover {
+          background-color: var(--hover-background-color);
+        }
+        li:focus,
+        li.selected {
+          background-color: var(--selection-background-color);
+          outline: none;
+        }
+        li:focus .itemAction,
+        li.selected .itemAction {
+          background-color: transparent;
+        }
+        .topContent {
+          display: block;
+          padding: var(--spacing-m) var(--spacing-l);
+          color: var(--gr-dropdown-item-color);
+          background-color: var(--gr-dropdown-item-background-color);
+          border: var(--gr-dropdown-item-border);
+          text-transform: var(--gr-dropdown-item-text-transform);
+        }
+        .bold-text {
+          font-weight: var(--font-weight-bold);
+        }
+      `,
+    ];
+  }
   /**
    * Fired when a non-link dropdown item with the given ID is tapped.
    *
@@ -88,13 +155,13 @@
   @property({type: Array})
   items?: DropdownLink[];
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'down-arrow'})
   downArrow = false;
 
   @property({type: Array})
   topContent?: DropdownContent[];
 
-  @property({type: String})
+  @property({type: String, attribute: 'horizontal-align'})
   horizontalAlign = 'left';
 
   /**
@@ -104,12 +171,11 @@
   @property({type: Boolean})
   link = false;
 
-  @property({type: Number})
+  @property({type: Number, attribute: 'vertical-offset'})
   verticalOffset = 40;
 
-  /** Propagates/Reflects the `opened` property of the <iron-dropdown> */
-  @property({type: Boolean, notify: true})
-  opened = false;
+  @state()
+  private opened = false;
 
   /**
    * List the IDs of dropdown buttons to be disabled. (Note this only
@@ -118,88 +184,157 @@
   @property({type: Array})
   disabledIds: string[] = [];
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
-
   // Used within the tests so needs to be non-private.
   cursor = new GrCursorManager();
 
+  private readonly shortcuts = new ShortcutController(this);
+
   constructor() {
     super();
     this.cursor.cursorTargetClass = 'selected';
     this.cursor.focusOnMove = true;
+    this.shortcuts.addLocal({key: Key.UP}, () => this.handleUp());
+    this.shortcuts.addLocal({key: Key.DOWN}, () => this.handleDown());
+    this.shortcuts.addLocal({key: Key.ENTER}, () => this.handleEnter());
+    this.shortcuts.addLocal({key: Key.SPACE}, () => this.handleEnter());
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.SPACE}, e => this._handleEnter(e))
-    );
   }
 
   override disconnectedCallback() {
     this.cursor.unsetCursor();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
     super.disconnectedCallback();
   }
 
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('opened')) {
+      fire(this, 'opened-changed', {value: this.opened});
+    }
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('items')) {
+      this.resetCursorStops();
+    }
+    if (changedProperties.has('opened') && this.opened) {
+      this.resetCursorStops();
+      this.cursor.setCursorAtIndex(0);
+      if (this.cursor.target !== null) this.cursor.target.focus();
+    }
+  }
+
+  override render() {
+    return html` <gr-button
+        ?link=${this.link}
+        class="dropdown-trigger"
+        id="trigger"
+        ?down-arrow=${this.downArrow}
+        @click=${this.dropdownTriggerTapHandler}
+      >
+        <slot></slot>
+      </gr-button>
+      <iron-dropdown
+        id="dropdown"
+        .verticalAlign=${'top'}
+        .verticalOffset=${this.verticalOffset}
+        allowOutsideScroll
+        .horizontalAlign=${this.horizontalAlign}
+        @click=${() => this.close()}
+        @opened-changed=${(e: CustomEvent) => (this.opened = e.detail.value)}
+      >
+        ${this.renderDropdownContent()}
+      </iron-dropdown>`;
+  }
+
+  private renderDropdownContent() {
+    return html` <div class="dropdown-content" slot="dropdown-content">
+      <ul>
+        ${this.renderTopContent()}
+        ${(this.items ?? []).map(link => this.renderDropdownLink(link))}
+      </ul>
+    </div>`;
+  }
+
+  private renderTopContent() {
+    if (!this.topContent) return nothing;
+    return html`
+      <div class="topContent">
+        ${(this.topContent ?? []).map(item => this.renderTopContentItem(item))}
+      </div>
+    `;
+  }
+
+  private renderTopContentItem(item: DropdownContent) {
+    return html`
+      <div class="${this.getClassIfBold(item.bold)} top-item" tabindex="-1">
+        ${item.text}
+      </div>
+    `;
+  }
+
+  private renderDropdownLink(link: DropdownLink) {
+    const disabledClass = this.computeDisabledClass(link.id);
+    return html`
+      <li tabindex="-1">
+        <gr-tooltip-content
+          ?has-tooltip=${!!link.tooltip}
+          title=${ifDefined(link.tooltip)}
+        >
+          <span
+            class="itemAction ${disabledClass}"
+            data-id=${ifDefined(link.id)}
+            @click=${this.handleItemTap}
+            ?hidden=${!!link.url}
+            tabindex="-1"
+            >${link.name}</span
+          >
+          <a
+            class="itemAction"
+            href=${this.computeLinkURL(link)}
+            ?download=${!!link.download}
+            rel=${ifDefined(this.computeLinkRel(link) ?? undefined)}
+            target=${ifDefined(link.target ?? undefined)}
+            ?hidden=${!link.url}
+            tabindex="-1"
+            >${link.name}</a
+          >
+        </gr-tooltip-content>
+      </li>
+    `;
+  }
+
   /**
    * Handle the up key.
    */
-  _handleUp(e: Event) {
-    if (this.$.dropdown.opened) {
-      e.preventDefault();
-      e.stopPropagation();
+  private handleUp() {
+    assertIsDefined(this.dropdown);
+    if (this.dropdown.opened) {
       this.cursor.previous();
     } else {
-      this._open();
+      this.open();
     }
   }
 
   /**
    * Handle the down key.
    */
-  _handleDown(e: Event) {
-    if (this.$.dropdown.opened) {
-      e.preventDefault();
-      e.stopPropagation();
+  private handleDown() {
+    assertIsDefined(this.dropdown);
+    if (this.dropdown.opened) {
       this.cursor.next();
     } else {
-      this._open();
-    }
-  }
-
-  /**
-   * Handle the tab key.
-   */
-  _handleTab(e: Event) {
-    if (this.$.dropdown.opened) {
-      // Tab in a native select is a no-op. Emulate this.
-      e.preventDefault();
-      e.stopPropagation();
+      this.open();
     }
   }
 
   /**
    * Handle the enter key.
    */
-  _handleEnter(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    if (this.$.dropdown.opened) {
+  private handleEnter() {
+    assertIsDefined(this.dropdown);
+    if (this.dropdown.opened) {
       // 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.
@@ -210,49 +345,39 @@
         }
       }
     } else {
-      this._open();
+      this.open();
     }
   }
 
   /**
-   * Handle a click on the iron-dropdown element.
-   */
-  _handleDropdownClick() {
-    this._close();
-  }
-
-  handleOpenedChanged(e: CustomEvent) {
-    this.opened = e.detail.value;
-  }
-
-  /**
    * Handle a click on the button to open the dropdown.
    */
-  _dropdownTriggerTapHandler(e: MouseEvent) {
+  private dropdownTriggerTapHandler(e: MouseEvent) {
+    assertIsDefined(this.dropdown);
     e.preventDefault();
     e.stopPropagation();
-    if (this.$.dropdown.opened) {
-      this._close();
+    if (this.dropdown.opened) {
+      this.close();
     } else {
-      this._open();
+      this.open();
     }
   }
 
   /**
    * Open the dropdown and initialize the cursor.
+   * Private but used in tests.
    */
-  _open() {
-    this.$.dropdown.open();
-    this._resetCursorStops();
-    this.cursor.setCursorAtIndex(0);
-    if (this.cursor.target !== null) this.cursor.target.focus();
+  open() {
+    assertIsDefined(this.dropdown);
+    this.dropdown.open();
   }
 
-  _close() {
+  // Private but used in tests.
+  close() {
     // async is needed so that that the click event is fired before the
     // dropdown closes (This was a bug for touch devices).
     setTimeout(() => {
-      this.$.dropdown.close();
+      this.dropdown?.close();
     }, 1);
   }
 
@@ -261,8 +386,10 @@
    *
    * @param bold Whether the item is bold.
    * @return The class for the top-content item.
+   *
+   * Private but used in tests.
    */
-  _getClassIfBold(bold?: boolean) {
+  getClassIfBold(bold?: boolean) {
     return bold ? 'bold-text' : '';
   }
 
@@ -285,30 +412,33 @@
    * @param path The path for the URL.
    * @return The scheme-relative URL.
    */
-  _computeRelativeURL(path: string) {
+  private computeRelativeURL(path: string) {
     const host = window.location.host;
     return this._computeURLHelper(host, path);
   }
 
   /**
    * Compute the URL for a link object.
+   *
+   * Private but used in tests.
    */
-  _computeLinkURL(link: DropdownLink) {
+  computeLinkURL(link: DropdownLink) {
     if (typeof link.url === 'undefined') {
       return '';
     }
     if (link.target || !link.url.startsWith('/')) {
       return link.url;
     }
-    return this._computeRelativeURL(link.url);
+    return this.computeRelativeURL(link.url);
   }
 
   /**
    * Compute the value for the rel attribute of an anchor for the given link
    * object. If the link has a target value, then the rel must be "noopener"
    * for security reasons.
+   * Private but used in tests.
    */
-  _computeLinkRel(link: DropdownLink) {
+  computeLinkRel(link: DropdownLink) {
     // Note: noopener takes precedence over external.
     if (link.target) {
       return REL_NOOPENER;
@@ -322,7 +452,7 @@
   /**
    * Handle a click on an item of the dropdown.
    */
-  _handleItemTap(e: MouseEvent) {
+  private handleItemTap(e: MouseEvent) {
     if (e.target === null || !this.items) {
       return;
     }
@@ -343,33 +473,23 @@
   }
 
   /**
-   * If a dropdown item is shown as a button, get the class for the button.
-   *
-   * @param disabledIdsRecord The change record for the disabled IDs
-   *     list.
-   * @return The class for the item button.
-   */
-  _computeDisabledClass(disabledIdsRecord: DisableIdsRecord, id?: string) {
-    return id && disabledIdsRecord.base.includes(id) ? 'disabled' : '';
-  }
-
-  /**
    * Recompute the stops for the dropdown item cursor.
    */
-  @observe('items')
-  _resetCursorStops() {
-    if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
-      flush();
-      this.cursor.stops =
-        this.root !== null ? Array.from(this.root.querySelectorAll('li')) : [];
+  private resetCursorStops() {
+    assertIsDefined(this.dropdown);
+    if (this.items && this.items.length > 0 && this.dropdown?.opened) {
+      this.cursor.stops = Array.from(
+        this.shadowRoot?.querySelectorAll('li') ?? []
+      );
     }
   }
 
-  _computeHasTooltip(tooltip?: string) {
-    return !!tooltip;
-  }
-
-  _computeIsDownload(link: DropdownLink) {
-    return !!link.download;
+  /**
+   * If a dropdown item is shown as a button, get the class for the button.
+   *
+   * @return The class for the item button.
+   */
+  private computeDisabledClass(id?: string) {
+    return id && this.disabledIds.includes(id) ? 'disabled' : '';
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
deleted file mode 100644
index 082a10b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_html.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: inline-block;
-    }
-    .dropdown-trigger {
-      text-decoration: none;
-      width: 100%;
-    }
-    .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;
-    }
-    gr-avatar {
-      height: 2em;
-      width: 2em;
-      vertical-align: middle;
-    }
-    gr-button[link]:focus {
-      outline: 5px auto -webkit-focus-ring-color;
-    }
-    ul {
-      list-style: none;
-    }
-    .topContent,
-    li {
-      border-bottom: 1px solid var(--border-color);
-    }
-    li:last-of-type {
-      border: none;
-    }
-    li .itemAction {
-      cursor: pointer;
-      display: block;
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    li .itemAction {
-      color: var(--gr-dropdown-item-color);
-      @apply --gr-dropdown-item;
-    }
-    li .itemAction.disabled {
-      color: var(--deemphasized-text-color);
-      cursor: default;
-    }
-    li .itemAction:link,
-    li .itemAction:visited {
-      text-decoration: none;
-    }
-    li .itemAction:not(.disabled):hover {
-      background-color: var(--hover-background-color);
-    }
-    li:focus,
-    li.selected {
-      background-color: var(--selection-background-color);
-      outline: none;
-    }
-    li:focus .itemAction,
-    li.selected .itemAction {
-      background-color: transparent;
-    }
-    .topContent {
-      display: block;
-      padding: var(--spacing-m) var(--spacing-l);
-      color: var(--gr-dropdown-item-color);
-      @apply --gr-dropdown-item;
-    }
-    .bold-text {
-      font-weight: var(--font-weight-bold);
-    }
-  </style>
-  <gr-button
-    link="[[link]]"
-    class="dropdown-trigger"
-    id="trigger"
-    down-arrow="[[downArrow]]"
-    on-click="_dropdownTriggerTapHandler"
-  >
-    <slot></slot>
-  </gr-button>
-  <iron-dropdown
-    id="dropdown"
-    vertical-align="top"
-    vertical-offset="[[verticalOffset]]"
-    allow-outside-scroll="true"
-    horizontal-align="[[horizontalAlign]]"
-    on-click="_handleDropdownClick"
-    on-opened-changed="handleOpenedChanged"
-  >
-    <div class="dropdown-content" slot="dropdown-content">
-      <ul>
-        <template is="dom-if" if="[[topContent]]">
-          <div class="topContent">
-            <template
-              is="dom-repeat"
-              items="[[topContent]]"
-              as="item"
-              initial-count="75"
-            >
-              <div
-                class$="[[_getClassIfBold(item.bold)]] top-item"
-                tabindex="-1"
-              >
-                [[item.text]]
-              </div>
-            </template>
-          </div>
-        </template>
-        <template
-          is="dom-repeat"
-          items="[[items]]"
-          as="link"
-          initial-count="75"
-        >
-          <li tabindex="-1">
-            <gr-tooltip-content
-              has-tooltip="[[_computeHasTooltip(link.tooltip)]]"
-              title$="[[link.tooltip]]"
-            >
-              <span
-                class$="itemAction [[_computeDisabledClass(disabledIds.*, link.id)]]"
-                data-id$="[[link.id]]"
-                on-click="_handleItemTap"
-                hidden$="[[link.url]]"
-                tabindex="-1"
-                >[[link.name]]</span
-              >
-              <a
-                class="itemAction"
-                href$="[[_computeLinkURL(link)]]"
-                download$="[[_computeIsDownload(link)]]"
-                rel$="[[_computeLinkRel(link)]]"
-                target$="[[link.target]]"
-                hidden$="[[!link.url]]"
-                tabindex="-1"
-                >[[link.name]]</a
-              >
-            </gr-tooltip-content>
-          </li>
-        </template>
-      </ul>
-    </div>
-  </iron-dropdown>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index 393f44e..fab742f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -1,55 +1,39 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-dropdown';
 import {DropdownLink, GrDropdown} from './gr-dropdown';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {pressKey, queryAll, queryAndAssert} from '../../../test/test-utils';
 import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
-
-const basicFixture = fixtureFromElement('gr-dropdown');
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-dropdown tests', () => {
   let element: GrDropdown;
 
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_computeIsDownload', () => {
-    assert.isTrue(element._computeIsDownload({download: true} as DropdownLink));
-    assert.isFalse(
-      element._computeIsDownload({download: false} as DropdownLink)
-    );
+  setup(async () => {
+    element = await fixture(html`<gr-dropdown></gr-dropdown>`);
   });
 
   test('tap on trigger opens menu, then closes', () => {
-    sinon.stub(element, '_open').callsFake(() => {
-      element.$.dropdown.open();
+    sinon.stub(element, 'open').callsFake(() => {
+      assertIsDefined(element.dropdown);
+      element.dropdown.open();
     });
-    sinon.stub(element, '_close').callsFake(() => {
-      element.$.dropdown.close();
+    sinon.stub(element, 'close').callsFake(() => {
+      assertIsDefined(element.dropdown);
+      element.dropdown.close();
     });
-    assert.isFalse(element.$.dropdown.opened);
-    MockInteractions.tap(element.$.trigger);
-    assert.isTrue(element.$.dropdown.opened);
-    MockInteractions.tap(element.$.trigger);
-    assert.isFalse(element.$.dropdown.opened);
+    assertIsDefined(element.dropdown);
+    assertIsDefined(element.trigger);
+    assert.isFalse(element.dropdown.opened);
+    element.trigger.click();
+    assert.isTrue(element.dropdown.opened);
+    element.trigger.click();
+    assert.isFalse(element.dropdown.opened);
   });
 
   test('_computeURLHelper', () => {
@@ -61,71 +45,69 @@
 
   test('link URLs', () => {
     assert.equal(
-      element._computeLinkURL({url: 'http://example.com/test'}),
+      element.computeLinkURL({url: 'http://example.com/test'}),
       'http://example.com/test'
     );
     assert.equal(
-      element._computeLinkURL({url: 'https://example.com/test'}),
+      element.computeLinkURL({url: 'https://example.com/test'}),
       'https://example.com/test'
     );
     assert.equal(
-      element._computeLinkURL({url: '/test'}),
+      element.computeLinkURL({url: '/test'}),
       '//' + window.location.host + '/test'
     );
     assert.equal(
-      element._computeLinkURL({url: '/test', target: '_blank'}),
+      element.computeLinkURL({url: '/test', target: '_blank'}),
       '/test'
     );
   });
 
   test('link rel', () => {
     let link: DropdownLink = {url: '/test'};
-    assert.isNull(element._computeLinkRel(link));
+    assert.isNull(element.computeLinkRel(link));
 
     link = {url: '/test', target: '_blank'};
-    assert.equal(element._computeLinkRel(link), 'noopener');
+    assert.equal(element.computeLinkRel(link), 'noopener');
 
     link = {url: '/test', external: true};
-    assert.equal(element._computeLinkRel(link), 'external');
+    assert.equal(element.computeLinkRel(link), 'external');
 
     link = {url: '/test', target: '_blank', external: true};
-    assert.equal(element._computeLinkRel(link), 'noopener');
+    assert.equal(element.computeLinkRel(link), 'noopener');
   });
 
-  test('_getClassIfBold', () => {
+  test('getClassIfBold', () => {
     let bold = true;
-    assert.equal(element._getClassIfBold(bold), 'bold-text');
+    assert.equal(element.getClassIfBold(bold), 'bold-text');
 
     bold = false;
-    assert.equal(element._getClassIfBold(bold), '');
+    assert.equal(element.getClassIfBold(bold), '');
   });
 
-  test('Top text exists and is bolded correctly', () => {
+  test('Top text exists and is bolded correctly', async () => {
     element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
-    flush();
+    await element.updateComplete;
     const topItems = queryAll<HTMLDivElement>(element, '.top-item');
     assert.equal(topItems.length, 2);
     assert.isTrue(topItems[0].classList.contains('bold-text'));
     assert.isFalse(topItems[1].classList.contains('bold-text'));
   });
 
-  test('non link items', () => {
+  test('non link items', async () => {
     const item0 = {name: 'item one', id: 'foo'};
     element.items = [item0, {name: 'item two', id: 'bar'}];
     const fooTapped = sinon.stub();
     const tapped = sinon.stub();
     element.addEventListener('tap-item-foo', fooTapped);
     element.addEventListener('tap-item', tapped);
-    flush();
-    MockInteractions.tap(
-      queryAndAssert<HTMLSpanElement>(element, '.itemAction')
-    );
+    await element.updateComplete;
+    queryAndAssert<HTMLSpanElement>(element, '.itemAction').click();
     assert.isTrue(fooTapped.called);
     assert.isTrue(tapped.called);
     assert.deepEqual(tapped.lastCall.args[0].detail, item0);
   });
 
-  test('disabled non link item', () => {
+  test('disabled non link item', async () => {
     element.items = [{name: 'item one', id: 'foo'}];
     element.disabledIds = ['foo'];
 
@@ -133,21 +115,19 @@
     const tapped = sinon.stub();
     element.addEventListener('tap-item-foo', stub);
     element.addEventListener('tap-item', tapped);
-    flush();
-    MockInteractions.tap(
-      queryAndAssert<HTMLSpanElement>(element, '.itemAction')
-    );
+    await element.updateComplete;
+    queryAndAssert<HTMLSpanElement>(element, '.itemAction').click();
     assert.isFalse(stub.called);
     assert.isFalse(tapped.called);
   });
 
-  test('properly sets tooltips', () => {
+  test('properly sets tooltips', async () => {
     element.items = [
       {name: 'item one', id: 'foo', tooltip: 'hello'},
       {name: 'item two', id: 'bar'},
     ];
     element.disabledIds = [];
-    flush();
+    await element.updateComplete;
     const tooltipContents = queryAll<GrTooltipContent>(
       element,
       'iron-dropdown li gr-tooltip-content'
@@ -158,46 +138,131 @@
     assert.isFalse(tooltipContents[1].hasTooltip);
   });
 
+  test('render', async () => {
+    element.items = [
+      {name: 'item one', id: 'foo', tooltip: 'hello'},
+      {name: 'item two', id: 'bar', url: 'http://bar'},
+    ];
+    element.disabledIds = [];
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+      <gr-button
+        aria-disabled="false"
+        class="dropdown-trigger"
+        id="trigger"
+        role="button"
+        tabindex="0"
+      >
+        <slot>
+        </slot>
+      </gr-button>
+      <iron-dropdown
+        allowoutsidescroll=""
+        aria-disabled="false"
+        aria-hidden="true"
+        horizontal-align="left"
+        id="dropdown"
+        style="outline: none; display: none;"
+        vertical-align="top"
+      >
+        <div
+          class="dropdown-content"
+          slot="dropdown-content"
+        >
+          <ul>
+            <li tabindex="-1">
+              <gr-tooltip-content
+                has-tooltip=""
+                title="hello"
+              >
+                <span
+                  class="itemAction"
+                  data-id="foo"
+                  tabindex="-1"
+                >
+                  item one
+                </span>
+                <a
+                  class="itemAction"
+                  hidden=""
+                  href=""
+                  tabindex="-1"
+                >
+                  item one
+                </a>
+              </gr-tooltip-content>
+            </li>
+            <li tabindex="-1">
+              <gr-tooltip-content>
+                <span
+                  class="itemAction"
+                  data-id="bar"
+                  hidden=""
+                  tabindex="-1"
+                >
+                  item two
+                </span>
+                <a
+                  class="itemAction"
+                  href="http://bar"
+                  tabindex="-1"
+                >
+                  item two
+                </a>
+              </gr-tooltip-content>
+            </li>
+        </div>
+          </ul>
+      </iron-dropdown>`
+    );
+  });
+
   suite('keyboard navigation', () => {
-    setup(() => {
+    setup(async () => {
       element.items = [
-        {name: 'item one', id: 'foo'},
-        {name: 'item two', id: 'bar'},
+        {name: 'item one', id: 'foo', url: 'http://foo'},
+        {name: 'item two', id: 'bar', url: 'http://bar'},
       ];
-      flush();
+      await element.updateComplete;
     });
 
     test('down', () => {
       const stub = sinon.stub(element.cursor, 'next');
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
-      assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
+      assertIsDefined(element.dropdown);
+      assert.isFalse(element.dropdown.opened);
+      pressKey(element, 'ArrowDown');
+      assert.isTrue(element.dropdown.opened);
+      pressKey(element, 'ArrowDown');
       assert.isTrue(stub.called);
     });
 
     test('up', () => {
+      assertIsDefined(element.dropdown);
       const stub = sinon.stub(element.cursor, 'previous');
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
-      assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
+      assert.isFalse(element.dropdown.opened);
+      pressKey(element, 'ArrowUp');
+      assert.isTrue(element.dropdown.opened);
+      pressKey(element, 'ArrowUp');
       assert.isTrue(stub.called);
     });
 
-    test('enter/space', () => {
+    test('enter/space', async () => {
+      assertIsDefined(element.dropdown);
       // Because enter and space are handled by the same fn, we need only to
       // test one.
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 32, null, ' ');
-      assert.isTrue(element.$.dropdown.opened);
+      assert.isFalse(element.dropdown.opened);
+      pressKey(element, ' ');
+      await element.updateComplete;
+      assert.isTrue(element.dropdown.opened);
 
       const el = queryAndAssert<HTMLAnchorElement>(
         element.cursor.target as HTMLElement,
         ':not([hidden]) a'
       );
       const stub = sinon.stub(el, 'click');
-      MockInteractions.pressAndReleaseKeyOn(element, 32, null, ' ');
+      pressKey(element, ' ');
       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 866ee5a..e176598 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
@@ -1,31 +1,33 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-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 '../gr-icon/gr-icon';
+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 {fire, fireAlert, fireEvent} from '../../../utils/event-util';
+import {getAppContext} from '../../../services/app-context';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {queryAndAssert} from '../../../utils/common-util';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {Interaction} from '../../../constants/reporting';
+import {LitElement, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css} from 'lit';
+import {PropertyValues} from 'lit';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {nothing} from 'lit';
+import {classMap} from 'lit/directives/class-map.js';
+import {when} from 'lit/directives/when.js';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {resolve} from '../../../models/dependency';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -34,14 +36,14 @@
   interface HTMLElementTagNameMap {
     'gr-editable-content': GrEditableContent;
   }
+  interface HTMLElementEventMap {
+    'content-changed': ValueChangedEvent<string>;
+    'editing-changed': ValueChangedEvent<boolean>;
+  }
 }
 
 @customElement('gr-editable-content')
-export class GrEditableContent extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEditableContent extends LitElement {
   /**
    * Fired when the save button is pressed.
    *
@@ -60,72 +62,250 @@
    * @event show-alert
    */
 
-  @property({type: String, notify: true, observer: '_contentChanged'})
+  @property({type: String})
   content?: string;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
-  @property({type: Boolean, observer: '_editingChanged', notify: true})
+  @property({
+    type: Boolean,
+    reflect: true,
+  })
   editing = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'remove-zero-width-space'})
   removeZeroWidthSpace?: boolean;
 
   // If no storage key is provided, content is not stored.
-  @property({type: String})
+  @property({type: String, attribute: 'storage-key'})
   storageKey?: string;
 
-  /** If false, then the "Show more" button was used to expand. */
-  @property({type: Boolean})
-  _commitCollapsed = true;
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'commit-collapsible'})
   commitCollapsible = true;
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeHideShowAllContainer(hideEditCommitMessage, _hideShowAllButton, editing)',
-  })
-  _hideShowAllContainer = false;
-
-  @property({
-    type: Boolean,
-    computed: '_computeHideShowAllButton(commitCollapsible, editing)',
-  })
-  _hideShowAllButton = false;
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'hide-edit-commit-message'})
   hideEditCommitMessage?: boolean;
 
-  @property({
-    type: Boolean,
-    computed: '_computeSaveDisabled(disabled, content, _newContent)',
-  })
-  _saveDisabled!: boolean;
+  /** If false, then the "Show more" button was used to expand. */
+  @state() commitCollapsed = true;
 
-  @property({type: String, observer: '_newContentChanged'})
-  _newContent = '';
+  @state() newContent = '';
 
-  private readonly storage = appContext.storageService;
+  private readonly getStorage = resolve(this, storageServiceToken);
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
 
   override disconnectedCallback() {
-    this.storeTask?.cancel();
+    this.storeTask?.flush();
     super.disconnectedCallback();
   }
 
-  _contentChanged() {
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('editing')) this.editingChanged();
+    if (changedProperties.has('newContent')) this.newContentChanged();
+    if (changedProperties.has('content')) this.contentChanged();
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) iron-autogrow-textarea {
+          opacity: 0.5;
+        }
+        .viewer {
+          background-color: var(--view-background-color);
+          border: 1px solid var(--view-background-color);
+          border-radius: var(--border-radius);
+          box-shadow: var(--elevation-level-1);
+          padding: var(--spacing-m);
+        }
+        :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;
+          --iron-autogrow-textarea_-_padding: var(--spacing-m);
+        }
+        .editButtons {
+          display: flex;
+          justify-content: space-between;
+        }
+        .show-all-container {
+          background-color: var(--view-background-color);
+          display: flex;
+          justify-content: flex-end;
+          border: 1px solid transparent;
+          border-top-color: var(--border-color);
+          border-radius: 0 0 4px 4px;
+          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;
+        }
+        :host([editing]) .show-all-container {
+          box-shadow: none;
+          border: 1px solid var(--border-color);
+        }
+        .flex-space {
+          flex-grow: 1;
+        }
+        .show-all-container gr-icon {
+          color: inherit;
+        }
+        .cancel-button {
+          margin-right: var(--spacing-l);
+        }
+        .save-button {
+          margin-right: var(--spacing-xs);
+        }
+        gr-button {
+          padding: var(--spacing-xs);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-endpoint-decorator name="commit-message">
+        <gr-endpoint-param
+          name="editing"
+          .value=${this.editing}
+        ></gr-endpoint-param>
+        ${this.renderViewer()} ${this.renderEditor()} ${this.renderButtons()}
+        <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderViewer() {
+    if (this.editing) return;
+    return html`
+      <div
+        class=${classMap({
+          viewer: true,
+          collapsed: this.commitCollapsed && this.commitCollapsible,
+        })}
+      >
+        <slot></slot>
+      </div>
+    `;
+  }
+
+  private renderEditor() {
+    if (!this.editing) return;
+    return html`
+      <div class="editor">
+        <div>
+          <iron-autogrow-textarea
+            autocomplete="on"
+            .bindValue=${this.newContent}
+            ?disabled=${this.disabled}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newContent = e.detail.value ?? '';
+            }}
+          ></iron-autogrow-textarea>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderButtons() {
+    if (!this.editing && !this.commitCollapsible && this.hideEditCommitMessage)
+      return nothing;
+
+    return html`
+      <div class="show-all-container font-normal">
+        ${when(
+          this.commitCollapsible && !this.editing,
+          () => html`
+            <gr-button
+              link
+              class="show-all-button"
+              @click=${this.toggleCommitCollapsed}
+            >
+              <div>
+                ${when(
+                  !this.commitCollapsed,
+                  () => html`<gr-icon icon="expand_less" small></gr-icon>`
+                )}
+                ${when(
+                  this.commitCollapsed,
+                  () => html`<gr-icon icon="expand_more" small></gr-icon>`
+                )}
+                <span>${this.commitCollapsed ? 'Show all' : 'Show less'}</span>
+              </div>
+            </gr-button>
+            <div class="flex-space"></div>
+          `
+        )}
+        ${when(
+          !this.hideEditCommitMessage,
+          () => html`
+            <gr-button
+              link
+              class="edit-commit-message"
+              title="Edit commit message"
+              @click=${this.handleEditCommitMessage}
+              ><div>
+                <gr-icon icon="edit" filled small></gr-icon>
+                <span>Edit</span>
+              </div></gr-button
+            >
+          `
+        )}
+        ${when(
+          this.editing,
+          () => html` <div class="editButtons">
+            <gr-button
+              link
+              class="cancel-button"
+              @click=${this.handleCancel}
+              ?disabled=${this.disabled}
+              >Cancel</gr-button
+            >
+            <gr-button
+              class="save-button"
+              primary=""
+              @click=${this.handleSave}
+              ?disabled=${this.computeSaveDisabled()}
+              >Save</gr-button
+            >
+          </div>`
+        )}
+        </div>
+      </div>
+    `;
+  }
+
+  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.
      */
     this.editing = false;
-    this._newContent = '';
+    this.newContent = '';
+    fire(this, 'content-changed', {
+      value: this.content ?? '',
+    });
   }
 
   focusTextarea() {
@@ -135,30 +315,30 @@
     ).textarea.focus();
   }
 
-  _newContentChanged(newContent: string) {
+  newContentChanged() {
     if (!this.storageKey) return;
     const storageKey = this.storageKey;
 
     this.storeTask = debounce(
       this.storeTask,
       () => {
-        if (newContent.length) {
-          this.storage.setEditableContentItem(storageKey, newContent);
+        if (this.newContent.length) {
+          this.getStorage().setEditableContentItem(storageKey, this.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 GrStorage cleans
           // up itself after one day, so we are not so concerned about leaving
           // some garbage behind.
-          this.storage.eraseEditableContentItem(storageKey);
+          this.getStorage().eraseEditableContentItem(storageKey);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
     );
   }
 
-  _editingChanged(editing: boolean) {
-    // This method is for initializing _newContent when you start editing.
+  editingChanged() {
+    // This method is for initializing newContent when you start editing.
     // Restoring content from local storage is not perfect and has
     // some issues:
     //
@@ -172,11 +352,15 @@
     // content from local storage when you enter editing mode for the first
     // time. Otherwise it is better to just keep the last editing state from
     // the same session.
-    if (!editing || this._newContent) return;
+    fire(this, 'editing-changed', {
+      value: this.editing,
+    });
+
+    if (!this.editing || this.newContent) return;
 
     let content;
     if (this.storageKey) {
-      const storedContent = this.storage.getEditableContentItem(
+      const storedContent = this.getStorage().getEditableContentItem(
         this.storageKey
       );
       if (storedContent?.message) {
@@ -189,73 +373,51 @@
     }
 
     // TODO(wyatta) switch linkify sequence, see issue 5526.
-    this._newContent = this.removeZeroWidthSpace
+    this.newContent = this.removeZeroWidthSpace
       ? content.replace(/^R=\u200B/gm, 'R=')
       : content;
   }
 
-  _computeSaveDisabled(
-    disabled?: boolean,
-    content?: string,
-    newContent?: string
-  ): boolean {
-    return disabled || !newContent || content === newContent;
+  computeSaveDisabled(): boolean {
+    return (
+      this.disabled || !this.newContent || this.content === this.newContent
+    );
   }
 
-  _handleSave(e: Event) {
+  handleSave(e: Event) {
     e.preventDefault();
     this.dispatchEvent(
       new CustomEvent('editable-content-save', {
-        detail: {content: this._newContent},
+        detail: {content: this.newContent},
         composed: true,
         bubbles: true,
       })
     );
-    // It would be nice, if we would set this._newContent = undefined here,
+    // It would be nice, if we would set this.newContent = undefined here,
     // but we can only do that when we are sure that the save operation has
     // succeeded.
   }
 
-  _handleCancel(e: Event) {
+  handleCancel(e: Event) {
     e.preventDefault();
     this.editing = false;
     fireEvent(this, 'editable-content-cancel');
   }
 
-  _computeCollapseText(collapsed: boolean) {
-    return collapsed ? 'Show all' : 'Show less';
-  }
-
-  _toggleCommitCollapsed() {
-    this._commitCollapsed = !this._commitCollapsed;
+  toggleCommitCollapsed() {
+    this.commitCollapsed = !this.commitCollapsed;
     this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'Commit message',
-      toState: !this._commitCollapsed ? 'Show all' : 'Show less',
+      toState: !this.commitCollapsed ? 'Show all' : 'Show less',
     });
-    if (this._commitCollapsed) {
+    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() {
+  async handleEditCommitMessage() {
     this.editing = true;
+    await this.updateComplete;
     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
deleted file mode 100644
index 7877a1f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ /dev/null
@@ -1,153 +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;
-    }
-    :host([disabled]) iron-autogrow-textarea {
-      opacity: 0.5;
-    }
-    .viewer {
-      background-color: var(--view-background-color);
-      border: 1px solid var(--view-background-color);
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-1);
-      padding: var(--spacing-m);
-    }
-    :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. */
-      --iron-autogrow-textarea: {
-        box-sizing: border-box;
-        padding: var(--spacing-m);
-        overflow-y: hidden;
-        white-space: pre;
-      }
-    }
-    .editButtons {
-      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]]"
-    collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, commitCollapsible)]]"
-  >
-    <slot></slot>
-  </div>
-  <div class="editor" hidden$="[[!editing]]">
-    <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>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index 074678e..b4f25ce 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -1,83 +1,159 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-editable-content';
 import {GrEditableContent} from './gr-editable-content';
-import {queryAndAssert, stubStorage} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {query, queryAndAssert} from '../../../test/test-utils';
 import {GrButton} from '../gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-editable-content');
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
+import {StorageService} from '../../../services/storage/gr-storage';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-editable-content tests', () => {
   let element: GrEditableContent;
+  let storageService: StorageService;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-editable-content></gr-editable-content>`);
+    await element.updateComplete;
+    storageService = testResolver(storageServiceToken);
   });
 
-  test('save event', () => {
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<gr-endpoint-decorator name="commit-message">
+        <gr-endpoint-param name="editing"> </gr-endpoint-param>
+        <div class="collapsed viewer">
+          <slot> </slot>
+        </div>
+        <div class="show-all-container font-normal">
+          <gr-button
+            aria-disabled="false"
+            class="show-all-button"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            <div>
+              <gr-icon icon="expand_more" small></gr-icon>
+              <span>Show all</span>
+            </div>
+          </gr-button>
+          <div class="flex-space"></div>
+          <gr-button
+            aria-disabled="false"
+            class="edit-commit-message"
+            link=""
+            role="button"
+            tabindex="0"
+            title="Edit commit message"
+          >
+            <div>
+              <gr-icon icon="edit" filled small></gr-icon>
+              <span>Edit</span>
+            </div>
+          </gr-button>
+        </div>
+        <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
+      </gr-endpoint-decorator> `
+    );
+  });
+
+  test('show-all-container visibility', async () => {
+    element.editing = false;
+    element.commitCollapsible = false;
+    element.hideEditCommitMessage = true;
+    await element.updateComplete;
+    assert.isNotOk(query(element, '.show-all-container'));
+
+    element.hideEditCommitMessage = false;
+    await element.updateComplete;
+    assert.isOk(query(element, '.show-all-container'));
+
+    element.hideEditCommitMessage = true;
+    element.editing = true;
+    await element.updateComplete;
+    assert.isOk(query(element, '.show-all-container'));
+
+    element.editing = false;
+    element.commitCollapsible = true;
+    await element.updateComplete;
+    assert.isOk(query(element, '.show-all-container'));
+  });
+
+  test('save event', async () => {
     element.content = '';
-    element._newContent = 'foo';
+    // Needed because contentChanged resets newContent
+    // We want contentChanged observer to finish before newContentChanged is
+    // called
+    await element.updateComplete;
+
+    element.newContent = 'foo';
+    element.disabled = false;
+    element.editing = true;
     const handler = sinon.spy();
     element.addEventListener('editable-content-save', handler);
 
-    MockInteractions.tap(queryAndAssert(element, 'gr-button[primary]'));
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(element, 'gr-button[primary]').click();
+
+    await element.updateComplete;
 
     assert.isTrue(handler.called);
     assert.equal(handler.lastCall.args[0].detail.content, 'foo');
   });
 
-  test('cancel event', () => {
+  test('cancel event', async () => {
     const handler = sinon.spy();
+    element.editing = true;
+    await element.updateComplete;
     element.addEventListener('editable-content-cancel', handler);
 
-    MockInteractions.tap(queryAndAssert(element, 'gr-button.cancel-button'));
+    queryAndAssert<GrButton>(element, 'gr-button.cancel-button').click();
 
     assert.isTrue(handler.called);
   });
 
-  test('enabling editing keeps old content', () => {
+  test('enabling editing keeps old content', async () => {
     element.content = 'current content';
-    element._newContent = 'old content';
+
+    // Needed because contentChanged resets newContent
+    // We want contentChanged observer to finish before newContentChanged is
+    // called
+    await element.updateComplete;
+
+    element.newContent = 'old content';
     element.editing = true;
-    assert.equal(element._newContent, 'old content');
+
+    await element.updateComplete;
+
+    assert.equal(element.newContent, 'old content');
   });
 
   test('disabling editing does not update edit field contents', () => {
     element.content = 'current content';
     element.editing = true;
-    element._newContent = 'stale content';
+    element.newContent = 'stale content';
     element.editing = false;
-    assert.equal(element._newContent, 'stale content');
-  });
-
-  test('zero width spaces are removed properly', () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'R=\u200Btest@google.com';
-    element.editing = true;
-    assert.equal(element._newContent, 'R=test@google.com');
+    assert.equal(element.newContent, 'stale content');
   });
 
   suite('editing', () => {
-    setup(() => {
+    setup(async () => {
       element.content = 'current content';
+      // Needed because contentChanged resets newContent
+      // contentChanged updates newContent as well so wait for that observer
+      // to finish before setting editing=true.
+      await element.updateComplete;
       element.editing = true;
+      await element.updateComplete;
     });
 
     test('save button is disabled initially', () => {
@@ -86,8 +162,9 @@
       );
     });
 
-    test('save button is enabled when content changes', () => {
-      element._newContent = 'new content';
+    test('save button is enabled when content changes', async () => {
+      element.newContent = 'new content';
+      await element.updateComplete;
       assert.isFalse(
         queryAndAssert<GrButton>(element, 'gr-button[primary]').disabled
       );
@@ -97,49 +174,60 @@
   suite('storageKey and related behavior', () => {
     let dispatchSpy: sinon.SinonSpy;
 
-    setup(() => {
+    setup(async () => {
       element.content = 'current content';
+      await element.updateComplete;
       element.storageKey = 'test';
       dispatchSpy = sinon.spy(element, 'dispatchEvent');
     });
 
-    test('editing toggled to true, has stored data', () => {
-      stubStorage('getEditableContentItem').returns({
+    test('editing toggled to true, has stored data', async () => {
+      sinon.stub(storageService, 'getEditableContentItem').returns({
         message: 'stored content',
         updated: 0,
       });
       element.editing = true;
-
-      assert.equal(element._newContent, 'stored content');
+      await element.updateComplete;
+      assert.equal(element.newContent, 'stored content');
       assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.firstCall.args[0].type, 'show-alert');
+      assert.equal(dispatchSpy.lastCall.args[0].type, EventType.SHOW_ALERT);
     });
 
-    test('editing toggled to true, has no stored data', () => {
-      stubStorage('getEditableContentItem').returns(null);
+    test('editing toggled to true, has no stored data', async () => {
+      sinon.stub(storageService, 'getEditableContentItem').returns(null);
       element.editing = true;
 
-      assert.equal(element._newContent, 'current content');
+      await element.updateComplete;
+
+      assert.equal(element.newContent, 'current content');
       assert.equal(dispatchSpy.firstCall.args[0].type, 'editing-changed');
     });
 
-    test('edits are cached', () => {
-      const storeStub = stubStorage('setEditableContentItem');
-      const eraseStub = stubStorage('eraseEditableContentItem');
+    test('edits are cached', async () => {
+      const storeStub = sinon.stub(storageService, 'setEditableContentItem');
+      const eraseStub = sinon.stub(storageService, 'eraseEditableContentItem');
       element.editing = true;
 
-      element._newContent = 'new content';
-      flush();
+      // Needed because editingChanged resets newContent
+      // We want editingChanged() to finish before triggering newContentChanged
+      await element.updateComplete;
+
+      element.newContent = 'new content';
+
+      await element.updateComplete;
+
       element.storeTask?.flush();
 
       assert.isTrue(storeStub.called);
       assert.deepEqual(
-        [element.storageKey, element._newContent],
+        [element.storageKey, element.newContent],
         storeStub.lastCall.args
       );
 
-      element._newContent = '';
-      flush();
+      element.newContent = '';
+
+      await element.updateComplete;
+
       element.storeTask?.flush();
 
       assert.isTrue(eraseStub.called);
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 e0d1d15..bf8209b 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
@@ -1,35 +1,26 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-dropdown/iron-dropdown';
 import '@polymer/paper-input/paper-input';
-import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
+import '../gr-icon/gr-icon';
 import '../../shared/gr-autocomplete/gr-autocomplete';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
-import {htmlTemplate} from './gr-editable-label_html';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
-import {PaperInputElementExt} from '../../../types/types';
 import {
   AutocompleteQuery,
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
-import {addShortcut, Key} from '../../../utils/dom-util';
+import {Key} from '../../../utils/dom-util';
 import {queryAndAssert} from '../../../utils/common-util';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {IronInputElement} from '@polymer/iron-input';
+import {ShortcutController} from '../../lit/shortcut-controller';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -40,53 +31,43 @@
   }
 }
 
-export interface GrEditableLabel {
-  $: {
-    dropdown: IronDropdownElement;
-  };
-}
-
 @customElement('gr-editable-label')
-export class GrEditableLabel extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEditableLabel extends LitElement {
   /**
    * Fired when the value is changed.
    *
    * @event changed
    */
 
-  @property({type: String})
+  @query('#dropdown')
+  dropdown?: IronDropdownElement;
+
+  @property()
   labelText = '';
 
   @property({type: Boolean})
   editing = false;
 
-  @property({type: String, notify: true, observer: '_updateTitle'})
+  @property()
   value?: string;
 
-  @property({type: String})
+  @property()
   placeholder = '';
 
   @property({type: Boolean})
   readOnly = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   uppercase = false;
 
   @property({type: Number})
   maxLength?: number;
 
   @property({type: String})
-  _inputText = '';
+  confirmLabel = 'Save';
 
-  // This is used to push the iron-input element up on the page, so
-  // the input is placed in approximately the same position as the
-  // trigger.
-  @property({type: Number})
-  readonly _verticalOffset = -30;
+  /* private but used in test */
+  @state() inputText = '';
 
   @property({type: Boolean})
   showAsEditPencil = false;
@@ -97,64 +78,204 @@
   @property({type: Object})
   query: AutocompleteQuery = () => Promise.resolve([]);
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('tabindex', '0');
+  @query('#input')
+  input?: PaperInputElement;
+
+  @query('#autocomplete')
+  grAutocomplete?: GrAutocomplete;
+
+  private readonly shortcuts = new ShortcutController(this);
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          align-items: center;
+          display: inline-flex;
+        }
+        :host([uppercase]) label {
+          text-transform: uppercase;
+        }
+        input,
+        label {
+          width: 100%;
+        }
+        label {
+          color: var(--deemphasized-text-color);
+          display: inline-block;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        label.editable {
+          color: var(--link-color);
+          cursor: pointer;
+        }
+        #dropdown {
+          box-shadow: var(--elevation-level-2);
+        }
+        .inputContainer {
+          background-color: var(--dialog-background-color);
+          padding: var(--spacing-m);
+        }
+        /* This makes inputContainer on one line. */
+        .inputContainer gr-autocomplete,
+        .inputContainer .buttons {
+          display: inline-block;
+        }
+        .buttons gr-button {
+          margin-left: var(--spacing-m);
+        }
+        /* prettier formatter removes semi-colons after css mixins. */
+        /* prettier-ignore */
+        paper-input {
+          --paper-input-container: {
+            padding: 0;
+            min-width: 15em;
+          };
+          --paper-input-container-input: {
+            font-size: inherit;
+          };
+          --paper-input-container-focus-color: var(--link-color);
+        }
+        gr-button gr-icon {
+          color: inherit;
+        }
+        gr-button.pencil {
+          --gr-button-padding: var(--spacing-s);
+          --margin: calc(0px - var(--spacing-s));
+        }
+      `,
+    ];
   }
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  override render() {
+    this.setAttribute('title', this.computeLabel());
+    return html`${this.renderActivateButton()}
+      <iron-dropdown
+        id="dropdown"
+        .verticalAlign=${'auto'}
+        .horizontalAlign=${'auto'}
+        .allowOutsideScroll=${true}
+        .noCancelOnEscKey=${true}
+        .noCancelOnOutsideClick=${true}
+      >
+        <div class="dropdown-content" slot="dropdown-content">
+          <div class="inputContainer" part="input-container">
+            ${this.renderInputBox()}
+            <div class="buttons">
+              <gr-button primary id="saveBtn" @click=${this.save}
+                >${this.confirmLabel}</gr-button
+              >
+              <gr-button id="cancelBtn" @click=${this.cancel}>cancel</gr-button>
+            </div>
+          </div>
+        </div>
+      </iron-dropdown>`;
+  }
+
+  private renderActivateButton() {
+    if (this.showAsEditPencil) {
+      return html`<gr-button
+        link=""
+        class="pencil ${this.computeLabelClass()}"
+        @click=${this.showDropdown}
+        title=${this.computeLabel()}
+      >
+        <div>
+          <gr-icon icon="edit" filled small></gr-icon>
+        </div>
+      </gr-button>`;
+    } else {
+      return html`<label
+        class=${this.computeLabelClass()}
+        title=${this.computeLabel()}
+        aria-label=${this.computeLabel()}
+        @click=${this.showDropdown}
+        part="label"
+        >${this.computeLabel()}</label
+      >`;
+    }
+  }
+
+  private renderInputBox() {
+    if (this.autocomplete) {
+      return html`<gr-autocomplete
+        .label=${this.labelText}
+        id="autocomplete"
+        .text=${this.inputText}
+        .query=${this.query}
+        @cancel=${this.cancel}
+        @text-changed=${(e: CustomEvent) => {
+          this.inputText = e.detail.value;
+        }}
+      >
+      </gr-autocomplete>`;
+    } else {
+      return html`<paper-input
+        id="input"
+        .label=${this.labelText}
+        .maxlength=${this.maxLength}
+        .value=${this.inputText}
+      ></paper-input>`;
+    }
+  }
+
+  constructor() {
+    super();
+    this.shortcuts.addLocal({key: Key.ENTER}, e => this.handleEnter(e));
+    this.shortcuts.addLocal({key: Key.ESC}, e => this.handleEsc(e));
+  }
 
   override disconnectedCallback() {
     super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
-    );
+    if (!this.getAttribute('tabindex')) {
+      this.setAttribute('tabindex', '0');
+    }
+    if (!this.getAttribute('id')) {
+      this.setAttribute('id', 'global');
+    }
   }
 
-  _usePlaceholder(value?: string, placeholder?: string) {
+  private usePlaceholder(value?: string, placeholder?: string) {
     return (!value || !value.length) && placeholder;
   }
 
-  _computeLabel(value?: string, placeholder?: string): string {
-    if (this._usePlaceholder(value, placeholder)) {
-      return placeholder!;
+  private computeLabel(): string {
+    const {value, placeholder} = this;
+    if (this.usePlaceholder(value, placeholder)) {
+      return placeholder;
     }
     return value || '';
   }
 
-  _showDropdown() {
+  private showDropdown() {
     if (this.readOnly || this.editing) return;
-    return this._open().then(() => {
-      this._nativeInput.focus();
-      const input = this.getInput();
-      if (!input?.value) return;
-      this._nativeInput.setSelectionRange(0, input.value.length);
+    return this.openDropdown().then(() => {
+      this.nativeInput.focus();
+      if (!this.input?.value) return;
+      this.nativeInput.setSelectionRange(0, this.input.value.length);
     });
   }
 
   open() {
-    return this._open().then(() => {
-      this._nativeInput.focus();
+    return this.openDropdown().then(() => {
+      this.nativeInput.focus();
     });
   }
 
-  _open() {
-    this.$.dropdown.open();
-    this._inputText = this.value || '';
+  private openDropdown() {
+    this.dropdown?.open();
+    this.inputText = this.value || '';
     this.editing = true;
 
     return new Promise<void>(resolve => {
-      this._awaitOpen(resolve);
+      this.awaitOpen(resolve);
     });
   }
 
@@ -162,11 +283,11 @@
    * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
    * opening. Eventually replace with a direct way to listen to the overlay.
    */
-  _awaitOpen(fn: () => void) {
+  private awaitOpen(fn: () => void) {
     let iters = 0;
     const step = () => {
       setTimeout(() => {
-        if (this.$.dropdown.style.display !== 'none') {
+        if (this.dropdown?.style.display !== 'none') {
           fn.call(this);
         } else if (iters++ < AWAIT_MAX_ITERS) {
           step.call(this);
@@ -176,16 +297,16 @@
     step.call(this);
   }
 
-  _id() {
-    return this.getAttribute('id') || 'global';
-  }
-
-  _save() {
+  private save() {
     if (!this.editing) {
       return;
     }
-    this.$.dropdown.close();
-    this.value = this._inputText || '';
+    this.dropdown?.close();
+    if (this.input) {
+      this.value = this.input.value ?? undefined;
+    } else {
+      this.value = this.inputText || '';
+    }
     this.editing = false;
     this.dispatchEvent(
       new CustomEvent('changed', {
@@ -196,68 +317,57 @@
     );
   }
 
-  _cancel() {
+  private cancel() {
     if (!this.editing) {
       return;
     }
-    this.$.dropdown.close();
+    this.dropdown?.close();
     this.editing = false;
-    this._inputText = this.value || '';
+    this.inputText = this.value || '';
   }
 
-  get _nativeInput(): HTMLInputElement {
-    // In Polymer 2 inputElement isn't nativeInput anymore
-    return (this.getInput()?.$.nativeInput ||
-      this.getInput()?.inputElement ||
-      this.getGrAutocomplete()) as HTMLInputElement;
+  private get nativeInput(): HTMLInputElement {
+    if (this.autocomplete) {
+      return this.grAutocomplete!.nativeInput;
+    } else {
+      return (this.input!.inputElement as IronInputElement)
+        .inputElement as HTMLInputElement;
+    }
   }
 
-  _handleEnter(event: KeyboardEvent) {
+  private handleEnter(event: KeyboardEvent) {
     const inputContainer = queryAndAssert(this, '.inputContainer');
     const isEventFromInput = event
       .composedPath()
       .some(element => element === inputContainer);
     if (isEventFromInput) {
-      event.preventDefault();
-      this._save();
+      this.save();
     }
   }
 
-  _handleEsc(event: KeyboardEvent) {
+  private handleEsc(event: KeyboardEvent) {
+    // If autocomplete is used, it's handling the ESC instead.
+    if (this.autocomplete) {
+      return;
+    }
     const inputContainer = queryAndAssert(this, '.inputContainer');
     const isEventFromInput = event
       .composedPath()
       .some(element => element === inputContainer);
     if (isEventFromInput) {
-      event.preventDefault();
-      this._cancel();
+      this.cancel();
     }
   }
 
-  _handleCommit() {
-    this._save();
-  }
-
-  _computeLabelClass(readOnly?: boolean, value?: string, placeholder?: string) {
+  private computeLabelClass() {
+    const {readOnly, value, placeholder} = this;
     const classes = [];
     if (!readOnly) {
       classes.push('editable');
     }
-    if (this._usePlaceholder(value, placeholder)) {
+    if (this.usePlaceholder(value, placeholder)) {
       classes.push('placeholder');
     }
     return classes.join(' ');
   }
-
-  _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
deleted file mode 100644
index e711e9d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: inline-flex;
-    }
-    :host([uppercase]) label {
-      text-transform: uppercase;
-    }
-    input,
-    label {
-      width: 100%;
-    }
-    label {
-      color: var(--deemphasized-text-color);
-      display: inline-block;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    label.editable {
-      color: var(--link-color);
-      cursor: pointer;
-    }
-    #dropdown {
-      box-shadow: var(--elevation-level-2);
-    }
-    .inputContainer {
-      background-color: var(--dialog-background-color);
-      padding: var(--spacing-m);
-    }
-    .buttons {
-      display: flex;
-      justify-content: flex-end;
-      padding-top: var(--spacing-l);
-      width: 100%;
-    }
-    .buttons gr-button {
-      margin-left: var(--spacing-m);
-    }
-    paper-input {
-      --paper-input-container: {
-        padding: 0;
-        min-width: 15em;
-      }
-      --paper-input-container-input: {
-        font-size: inherit;
-      }
-      --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 {
-      --gr-button-padding: 0px 0px;
-    }
-  </style>
-  <template is="dom-if" if="[[!showAsEditPencil]]">
-    <label
-      class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
-      title$="[[_computeLabel(value, placeholder)]]"
-      aria-label$="[[_computeLabel(value, placeholder)]]"
-      on-click="_showDropdown"
-      part="label"
-      >[[_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"
-    horizontal-align="auto"
-    vertical-offset="[[_verticalOffset]]"
-    allow-outside-scroll="true"
-    on-iron-overlay-canceled="_cancel"
-  >
-    <div class="dropdown-content" slot="dropdown-content">
-      <div class="inputContainer" part="input-container">
-        <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
-          >
-          <gr-button link="" id="saveBtn" on-click="_save">save</gr-button>
-        </div>
-      </div>
-    </div>
-  </iron-dropdown>
-`;
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
deleted file mode 100644
index b6bb87b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
+++ /dev/null
@@ -1,204 +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-editable-label.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-editable-label
-        value="value text"
-        placeholder="label text"></gr-editable-label>
-`);
-
-const noPlaceholderFixture = fixtureFromTemplate(html`
-<gr-editable-label value=""></gr-editable-label>
-`);
-
-const readOnlyFixture = fixtureFromTemplate(html`
-<gr-editable-label
-        read-only
-        value="value text"
-        placeholder="label text"></gr-editable-label>
-`);
-
-suite('gr-editable-label tests', () => {
-  let element;
-  let elementNoPlaceholder;
-  let input;
-  let label;
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    elementNoPlaceholder = noPlaceholderFixture.instantiate();
-    flush();
-    label = element.shadowRoot.querySelector('label');
-
-    await flush();
-    // In Polymer 2 inputElement isn't nativeInput anymore
-    const paperInput = element.shadowRoot.querySelector('#input');
-    input = paperInput.$.nativeInput || paperInput.inputElement;
-  });
-
-  test('element render', () => {
-    // The dropdown is closed and the label is visible:
-    assert.isFalse(element.$.dropdown.opened);
-    assert.isTrue(label.classList.contains('editable'));
-    assert.equal(label.textContent, 'value text');
-    const focusSpy = sinon.spy(input, 'focus');
-    const showSpy = sinon.spy(element, '_showDropdown');
-
-    MockInteractions.tap(label);
-
-    return showSpy.lastCall.returnValue.then(() => {
-      // The dropdown is open (which covers up the label):
-      assert.isTrue(element.$.dropdown.opened);
-      assert.isTrue(focusSpy.called);
-      assert.equal(input.value, 'value text');
-    });
-  });
-
-  test('title with placeholder', () => {
-    assert.equal(element.title, 'value text');
-    element.value = '';
-
-    flush();
-    assert.equal(element.title, 'label text');
-  });
-
-  test('title without placeholder', () => {
-    assert.equal(elementNoPlaceholder.title, '');
-    element.value = 'value text';
-
-    flush();
-    assert.equal(element.title, 'value text');
-  });
-
-  test('edit value', async () => {
-    const editedSpy = sinon.spy();
-    element.addEventListener('changed', editedSpy);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-    flush();
-
-    assert.isTrue(element.editing);
-    assert.isFalse(editedSpy.called);
-
-    element._inputText = 'new text';
-    // Press enter:
-    MockInteractions.keyDownOn(input, 13, null, 'Enter');
-    flush();
-
-    assert.isTrue(editedSpy.called);
-    assert.equal(input.value, 'new text');
-    assert.isFalse(element.editing);
-  });
-
-  test('save button', () => {
-    const editedSpy = sinon.spy();
-    element.addEventListener('changed', editedSpy);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-    flush();
-
-    assert.isTrue(element.editing);
-    assert.isFalse(editedSpy.called);
-
-    element._inputText = 'new text';
-    // Press enter:
-    MockInteractions.tap(element.$.saveBtn, 13, null, 'Enter');
-    flush();
-
-    assert.isTrue(editedSpy.called);
-    assert.equal(input.value, 'new text');
-    assert.isFalse(element.editing);
-  });
-
-  test('edit and then escape key', () => {
-    const editedSpy = sinon.spy();
-    element.addEventListener('changed', editedSpy);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-    flush();
-
-    assert.isTrue(element.editing);
-    assert.isFalse(editedSpy.called);
-
-    element._inputText = 'new text';
-    // Press escape:
-    MockInteractions.keyDownOn(input, 27, null, 'Escape');
-    flush();
-
-    assert.isFalse(editedSpy.called);
-    // Text changes should be discarded.
-    assert.equal(input.value, 'value text');
-    assert.isFalse(element.editing);
-  });
-
-  test('cancel button', () => {
-    const editedSpy = sinon.spy();
-    element.addEventListener('changed', editedSpy);
-    assert.isFalse(element.editing);
-
-    MockInteractions.tap(label);
-    flush();
-
-    assert.isTrue(element.editing);
-    assert.isFalse(editedSpy.called);
-
-    element._inputText = 'new text';
-    // Press escape:
-    MockInteractions.tap(element.$.cancelBtn);
-    flush();
-
-    assert.isFalse(editedSpy.called);
-    // Text changes should be discarded.
-    assert.equal(input.value, 'value text');
-    assert.isFalse(element.editing);
-  });
-
-  suite('gr-editable-label read-only tests', () => {
-    let element;
-    let label;
-
-    setup(() => {
-      element = readOnlyFixture.instantiate();
-      flush();
-      label = element.shadowRoot
-          .querySelector('label');
-    });
-
-    test('disallows edit when read-only', () => {
-      // The dropdown is closed.
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(label);
-
-      flush();
-
-      // The dropdown is still closed.
-      assert.isFalse(element.$.dropdown.opened);
-    });
-
-    test('label is not marked as editable', () => {
-      assert.isFalse(label.classList.contains('editable'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
new file mode 100644
index 0000000..d916118
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -0,0 +1,330 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-editable-label';
+import {GrEditableLabel} from './gr-editable-label';
+import {queryAndAssert} from '../../../utils/common-util';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {GrButton} from '../gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {
+  AutocompleteSuggestion,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
+import {Key} from '../../../utils/dom-util';
+import {pressKey, waitEventLoop, waitUntil} from '../../../test/test-utils';
+import {IronInputElement} from '@polymer/iron-input';
+
+suite('gr-editable-label tests', () => {
+  let element: GrEditableLabel;
+  let elementNoPlaceholder: GrEditableLabel;
+  let input: HTMLInputElement;
+  let label: HTMLLabelElement;
+
+  setup(async () => {
+    element = await fixture<GrEditableLabel>(html`
+      <gr-editable-label
+        value="value text"
+        placeholder="label text"
+      ></gr-editable-label>
+    `);
+    label = queryAndAssert<HTMLLabelElement>(element, 'label');
+    elementNoPlaceholder = await fixture<GrEditableLabel>(html`
+      <gr-editable-label value=""></gr-editable-label>
+    `);
+
+    const paperInput = queryAndAssert<PaperInputElement>(element, '#input');
+    input = (paperInput.inputElement as IronInputElement)
+      .inputElement as HTMLInputElement;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      `<label
+      aria-label="value text"
+      class="editable"
+      part="label"
+      title="value text"
+    >
+      value text
+    </label>
+    <iron-dropdown
+      aria-disabled="false"
+      aria-hidden="true"
+      horizontal-align="auto"
+      id="dropdown"
+      style="outline: none; display: none;"
+      vertical-align="auto"
+    >
+      <div class="dropdown-content" slot="dropdown-content">
+        <div class="inputContainer" part="input-container">
+          <paper-input
+            aria-disabled="false"
+            id="input"
+            tabindex="0"
+          ></paper-input>
+          <div class="buttons">
+          <gr-button
+              aria-disabled="false"
+              id="saveBtn"
+              primary
+              role="button"
+              tabindex="0"
+            >
+              Save
+            </gr-button>
+            <gr-button
+              aria-disabled="false"
+              id="cancelBtn"
+              role="button"
+              tabindex="0"
+            >
+              cancel
+            </gr-button>
+          </div>
+        </div>
+      </div>
+    </iron-dropdown>`
+    );
+  });
+
+  test('element render', async () => {
+    // The dropdown is closed and the label is visible:
+    const dropdown = queryAndAssert<IronDropdownElement>(element, '#dropdown');
+    assert.isFalse(dropdown.opened);
+    assert.isTrue(label.classList.contains('editable'));
+    assert.equal(label.textContent, 'value text');
+
+    label.click();
+    await element.updateComplete;
+    // The dropdown is open (which covers up the label):
+    assert.isTrue(dropdown.opened);
+    assert.equal(input.value, 'value text');
+  });
+
+  test('title with placeholder', async () => {
+    assert.equal(element.title, 'value text');
+    element.value = '';
+
+    await element.updateComplete;
+    assert.equal(element.title, 'label text');
+  });
+
+  test('title without placeholder', async () => {
+    assert.equal(elementNoPlaceholder.title, '');
+    element.value = 'value text';
+
+    await waitEventLoop();
+    assert.equal(element.title, 'value text');
+  });
+
+  test('edit value', async () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    label.click();
+    await waitEventLoop();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element.inputText = 'new text';
+    pressKey(input, Key.ENTER);
+    await waitEventLoop();
+
+    assert.isTrue(editedSpy.called);
+    assert.equal(input.value, 'new text');
+    assert.isFalse(element.editing);
+  });
+
+  test('save button', async () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    label.click();
+    await waitEventLoop();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element.inputText = 'new text';
+    pressKey(queryAndAssert<GrButton>(element, '#saveBtn'), Key.ENTER);
+    await waitEventLoop();
+
+    assert.isTrue(editedSpy.called);
+    assert.equal(input.value, 'new text');
+    assert.isFalse(element.editing);
+  });
+
+  test('edit and then escape key', async () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    label.click();
+    await waitEventLoop();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element.inputText = 'new text';
+    pressKey(input, Key.ESC);
+    await waitEventLoop();
+
+    assert.isFalse(editedSpy.called);
+    // Text changes should be discarded.
+    assert.equal(input.value, 'value text');
+    assert.isFalse(element.editing);
+  });
+
+  test('cancel button', async () => {
+    const editedSpy = sinon.spy();
+    element.addEventListener('changed', editedSpy);
+    assert.isFalse(element.editing);
+
+    label.click();
+    await waitEventLoop();
+
+    assert.isTrue(element.editing);
+    assert.isFalse(editedSpy.called);
+
+    element.inputText = 'new text';
+    // Press escape:
+    queryAndAssert<GrButton>(element, '#cancelBtn').click();
+    await waitEventLoop();
+
+    assert.isFalse(editedSpy.called);
+    // Text changes should be discarded.
+    assert.equal(input.value, 'value text');
+    assert.isFalse(element.editing);
+  });
+
+  suite('gr-editable-label read-only tests', () => {
+    let element: GrEditableLabel;
+    let label: HTMLLabelElement;
+
+    setup(async () => {
+      element = await fixture<GrEditableLabel>(html`
+        <gr-editable-label
+          readOnly
+          value="value text"
+          placeholder="label text"
+        ></gr-editable-label>
+      `);
+      label = queryAndAssert(element, 'label');
+    });
+
+    test('disallows edit when read-only', async () => {
+      // The dropdown is closed.
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        '#dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+      label.click();
+
+      await element.updateComplete;
+
+      // The dropdown is still closed.
+      assert.isFalse(dropdown.opened);
+    });
+
+    test('label is not marked as editable', () => {
+      assert.isFalse(label.classList.contains('editable'));
+    });
+  });
+
+  suite('autocomplete tests', () => {
+    let element: GrEditableLabel;
+    let autocomplete: GrAutocomplete;
+    let suggestions: Array<AutocompleteSuggestion>;
+    let labelSaved = false;
+
+    setup(async () => {
+      element = await fixture<GrEditableLabel>(html`
+        <gr-editable-label
+          autocomplete
+          value="value text"
+          .query=${() => Promise.resolve(suggestions)}
+          @changed=${() => {
+            labelSaved = true;
+          }}
+        ></gr-editable-label>
+      `);
+
+      autocomplete = element.grAutocomplete!;
+    });
+
+    test('autocomplete suggestions shown esc closes suggestions', async () => {
+      suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
+      await element.open();
+
+      await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+
+      pressKey(autocomplete.input!, Key.ESC);
+
+      await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
+      assert.isTrue(element.dropdown?.opened);
+    });
+
+    test('autocomplete suggestions closed esc closes dialogue', async () => {
+      suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
+      await element.open();
+      await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+      // Press esc to close suggestions.
+      pressKey(autocomplete.input!, Key.ESC);
+      await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
+
+      pressKey(autocomplete.input!, Key.ESC);
+
+      await element.updateComplete;
+      // Dialogue is closed, save not triggered.
+      assert.isTrue(autocomplete.suggestionsDropdown?.isHidden);
+      assert.isFalse(element.dropdown?.opened);
+      assert.isFalse(labelSaved);
+    });
+
+    test('autocomplete suggestions shown enter chooses suggestions', async () => {
+      suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
+      await element.open();
+
+      await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+
+      pressKey(autocomplete.input!, Key.ENTER);
+
+      await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
+      await element.updateComplete;
+      // The value was picked from suggestions, suggestions are hidden, dialogue
+      // is shown, save has not been triggered.
+      assert.strictEqual(element.inputText, 'value text 1');
+      assert.isTrue(autocomplete.suggestionsDropdown?.isHidden);
+      assert.isTrue(element.dropdown?.opened);
+      assert.isFalse(labelSaved);
+    });
+
+    test('autocomplete suggestions closed enter saves suggestion', async () => {
+      suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
+      await element.open();
+      await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+      // Press enter to close suggestions.
+      pressKey(autocomplete.input!, Key.ENTER);
+
+      await waitUntil(() => autocomplete.suggestionsDropdown!.isHidden);
+
+      pressKey(autocomplete.input!, Key.ENTER);
+
+      await element.updateComplete;
+      // Dialogue is closed, save triggered.
+      assert.isTrue(autocomplete.suggestionsDropdown?.isHidden);
+      assert.isFalse(element.dropdown?.opened);
+      assert.isTrue(labelSaved);
+    });
+  });
+});
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
deleted file mode 100644
index 3f759b5..0000000
--- a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
+++ /dev/null
@@ -1,121 +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 {SpecialFilePath} from '../../../constants/constants';
-import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-
-const FileStatus = {
-  A: 'Added',
-  C: 'Copied',
-  D: 'Deleted',
-  M: 'Modified',
-  R: 'Renamed',
-  W: 'Rewritten',
-  U: 'Unchanged',
-};
-
-@customElement('gr-file-status-chip')
-export class GrFileStatusChip extends LitElement {
-  @property({type: Object})
-  file?: NormalizedFileInfo;
-
-  static override get styles() {
-    return [
-      sharedStyles,
-      css`
-        .status {
-          display: inline-block;
-          border-radius: var(--border-radius);
-          margin-left: var(--spacing-s);
-          padding: 0 var(--spacing-m);
-          color: var(--primary-text-color);
-          font-size: var(--font-size-small);
-          background-color: var(--file-status-added);
-        }
-        .status.invisible,
-        .status.M {
-          display: none;
-        }
-        .status.D,
-        .status.R,
-        .status.W {
-          background-color: var(--file-status-changed);
-        }
-        .status.U {
-          background-color: var(--file-status-unchanged);
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    return html` <span
-      class="${this._computeStatusClass(this.file)}"
-      tabindex="0"
-      title="${this._computeFileStatusLabel(this.file?.status)}"
-      aria-label="${this._computeFileStatusLabel(this.file?.status)}"
-    >
-      ${this._computeFileStatusLabel(this.file?.status)}
-    </span>`;
-  }
-
-  /**
-   * Get a descriptive label for use in the status indicator's tooltip and
-   * ARIA label.
-   */
-  _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_test.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_test.ts
deleted file mode 100644
index 0abc85f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../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-file-status/gr-file-status.ts b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts
new file mode 100644
index 0000000..943f4de
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts
@@ -0,0 +1,162 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {FileInfoStatus} from '../../../constants/constants';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {assertNever} from '../../../utils/common-util';
+import '../gr-tooltip-content/gr-tooltip-content';
+import '../gr-icon/gr-icon';
+
+function statusString(status?: FileInfoStatus) {
+  if (!status) return '';
+  switch (status) {
+    case FileInfoStatus.ADDED:
+      return 'Added';
+    case FileInfoStatus.COPIED:
+      return 'Copied';
+    case FileInfoStatus.DELETED:
+      return 'Deleted';
+    case FileInfoStatus.MODIFIED:
+      return 'Modified';
+    case FileInfoStatus.RENAMED:
+      return 'Renamed';
+    case FileInfoStatus.REWRITTEN:
+      return 'Rewritten';
+    case FileInfoStatus.UNMODIFIED:
+      return 'Unchanged';
+    case FileInfoStatus.REVERTED:
+      return 'Reverted';
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+/**
+ * This is a square colored box with a single letter, which can be used as a
+ * prefix column before file names.
+ *
+ * It can also show an additional "new" icon for indicating that a file was
+ * newly changed in a patchset.
+ */
+@customElement('gr-file-status')
+export class GrFileStatus extends LitElement {
+  @property({type: String})
+  status?: FileInfoStatus;
+
+  /**
+   * Show an additional "new" icon for indicating that a file was newly changed
+   * in a patchset.
+   */
+  @property({type: Boolean})
+  newlyChanged = false;
+
+  /**
+   * What postfix should the tooltip have? For example you can set
+   * ' in ps 5', such that the 'Added' tooltip becomes 'Added in ps 5'.
+   */
+  @property({type: String})
+  labelPostfix = '';
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+        }
+        div.status {
+          display: inline-block;
+          line-height: var(--line-height-normal);
+          width: var(--line-height-normal);
+          text-align: center;
+          border-radius: var(--border-radius);
+          background-color: transparent;
+          color: var(--file-status-font-color);
+        }
+        div.status gr-icon {
+          color: var(--file-status-font-color);
+        }
+        div.status.M {
+          border: 1px solid var(--border-color);
+          line-height: calc(var(--line-height-normal) - 2px);
+          width: calc(var(--line-height-normal) - 2px);
+          color: var(--deemphasized-text-color);
+        }
+        div.status.A {
+          background-color: var(--file-status-added);
+        }
+        div.status.D {
+          background-color: var(--file-status-deleted);
+        }
+        div.status.R,
+        div.status.W {
+          background-color: var(--file-status-renamed);
+        }
+        div.status.U {
+          background-color: var(--file-status-unchanged);
+        }
+        div.status.X {
+          background-color: var(--file-status-reverted);
+        }
+        .size-16 {
+          font-size: 16px;
+          position: relative;
+          top: 2px;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`${this.renderNewlyChanged()}${this.renderStatus()}`;
+  }
+
+  private renderStatus() {
+    const classes = ['status', this.status];
+    return html`
+      <gr-tooltip-content
+        title=${this.computeLabel()}
+        has-tooltip
+        aria-label=${statusString(this.status)}
+      >
+        <div class=${classes.join(' ')} aria-hidden="true">
+          ${this.renderIconOrLetter()}
+        </div>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderIconOrLetter() {
+    if (this.status === FileInfoStatus.REVERTED) {
+      return html`<gr-icon small icon="undo"></gr-icon>`;
+    }
+    return html`<span>${this.status ?? ''}</span>`;
+  }
+
+  private renderNewlyChanged() {
+    if (!this.newlyChanged) return;
+    return html`
+      <gr-tooltip-content
+        title=${this.computeLabel()}
+        has-tooltip
+        aria-label="newly"
+      >
+        <gr-icon icon="new_releases" class="size-16"></gr-icon>
+      </gr-tooltip-content>
+    `;
+  }
+
+  private computeLabel() {
+    if (!this.status) return '';
+    const prefix = this.newlyChanged ? 'Newly ' : '';
+    return `${prefix}${statusString(this.status)}${this.labelPostfix}`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-status': GrFileStatus;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts
new file mode 100644
index 0000000..555b237
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-file-status';
+import {GrFileStatus} from './gr-file-status';
+import {fixture, assert} from '@open-wc/testing';
+import {FileInfoStatus} from '../../../api/rest-api';
+
+suite('gr-file-status tests', () => {
+  let element: GrFileStatus;
+
+  setup(async () => {
+    element = await fixture<GrFileStatus>('<gr-file-status></gr-file-status>');
+    await setStatus();
+  });
+
+  const setStatus = async (status?: FileInfoStatus, newly = false) => {
+    element.status = status;
+    element.newlyChanged = newly;
+    await element.updateComplete;
+  };
+
+  suite('semantic dom diff tests', () => {
+    test('empty status', async () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-tooltip-content has-tooltip="" title="" aria-label="">
+            <div class="status" aria-hidden="true"><span></span></div>
+          </gr-tooltip-content>
+        `
+      );
+    });
+
+    test('added', async () => {
+      await setStatus(FileInfoStatus.ADDED);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-tooltip-content has-tooltip="" title="Added" aria-label="Added">
+            <div class="A status" aria-hidden="true">
+              <span>A</span>
+            </div>
+          </gr-tooltip-content>
+        `
+      );
+    });
+
+    test('newly added', async () => {
+      await setStatus(FileInfoStatus.ADDED, true);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-tooltip-content
+            has-tooltip=""
+            title="Newly Added"
+            aria-label="newly"
+          >
+            <gr-icon icon="new_releases" class="size-16"></gr-icon>
+          </gr-tooltip-content>
+          <gr-tooltip-content
+            has-tooltip=""
+            title="Newly Added"
+            aria-label="Added"
+          >
+            <div class="A status" aria-hidden="true">
+              <span>A</span>
+            </div>
+          </gr-tooltip-content>
+        `
+      );
+    });
+  });
+});
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 17621c3..627ea27 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
@@ -1,38 +1,246 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../gr-linked-text/gr-linked-text';
-import {CommentLinks} from '../../../types/common';
-import {LitElement, css, html, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  htmlEscape,
+  sanitizeHtml,
+  sanitizeHtmlToFragment,
+} from '../../../utils/inner-html-util';
+import {unescapeHTML} from '../../../utils/syntax-util';
+import '@polymer/marked-element';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {CommentLinks, EmailAddress} from '../../../api/rest-api';
+import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
+import '../gr-account-chip/gr-account-chip';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {getAppContext} from '../../../services/app-context';
 
-const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
+/**
+ * This element optionally renders markdown and also applies some regex
+ * replacements to linkify key parts of the text defined by the host's config.
+ */
+@customElement('gr-formatted-text')
+export class GrFormattedText extends LitElement {
+  @property({type: String})
+  content = '';
 
-export type Block = ListBlock | QuoteBlock | TextBlock;
-export interface ListBlock {
-  type: 'list';
-  items: string[];
-}
-export interface QuoteBlock {
-  type: 'quote';
-  blocks: Block[];
-}
-export interface TextBlock {
-  type: 'paragraph' | 'code' | 'pre';
-  text: string;
+  @property({type: Boolean})
+  markdown = false;
+
+  @state()
+  private repoCommentLinks: CommentLinks = {};
+
+  private readonly flagsService = getAppContext().flagsService;
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  // Private const but used in tests.
+  // Limit the length of markdown because otherwise the markdown lexer will
+  // run out of memory causing the tab to crash.
+  @state()
+  MARKDOWN_LIMIT = 100000;
+
+  /**
+   * Note: Do not use sharedStyles or other styles here that should not affect
+   * the generated HTML of the markdown.
+   */
+  static override styles = [
+    css`
+      a {
+        color: var(--link-color);
+      }
+      p,
+      ul,
+      code,
+      blockquote {
+        margin: 0 0 var(--spacing-m) 0;
+        max-width: var(--gr-formatted-text-prose-max-width, none);
+      }
+      p:last-child,
+      ul:last-child,
+      blockquote:last-child,
+      pre:last-child {
+        margin: 0;
+      }
+      blockquote {
+        border-left: var(--spacing-xxs) solid var(--comment-quote-marker-color);
+        padding: 0 var(--spacing-m);
+      }
+      code {
+        background-color: var(--background-color-secondary);
+        border: var(--spacing-xxs) solid var(--border-color);
+        display: block;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: var(--line-height-mono);
+        margin: var(--spacing-m) 0;
+        padding: var(--spacing-xxs) var(--spacing-s);
+        overflow-x: auto;
+        /* Pre will preserve whitespace and line breaks but not wrap */
+        white-space: pre;
+      }
+      /* Non-multiline code elements need display:inline to shrink and not take
+         a whole row */
+      :not(pre) > code {
+        display: inline;
+      }
+      li {
+        margin-left: var(--spacing-xl);
+      }
+      gr-account-chip {
+        display: inline;
+      }
+      .plaintext {
+        font: inherit;
+        white-space: var(--linked-text-white-space, pre-wrap);
+        word-wrap: var(--linked-text-word-wrap, break-word);
+      }
+      .markdown-html {
+        /* prose will automatically wrap but inline <code> blocks won't and we
+           should overflow in that case rather than wrapping or leaking out */
+        overflow-x: auto;
+      }
+    `,
+  ];
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().repoCommentLinks$,
+      repoCommentLinks => (this.repoCommentLinks = repoCommentLinks)
+    );
+  }
+
+  override render() {
+    if (this.markdown && this.content.length < this.MARKDOWN_LIMIT) {
+      return this.renderAsMarkdown();
+    } else {
+      return this.renderAsPlaintext();
+    }
+  }
+
+  private renderAsPlaintext() {
+    const linkedText = linkifyUrlsAndApplyRewrite(
+      htmlEscape(this.content).toString(),
+      this.repoCommentLinks
+    );
+
+    return html`
+      <pre class="plaintext">${sanitizeHtmlToFragment(linkedText)}</pre>
+    `;
+  }
+
+  private renderAsMarkdown() {
+    // <marked-element> internals will be in charge of calling our custom
+    // renderer so we wrap 'this.rewriteText' so that 'this' is preserved via
+    // closure.
+    const boundRewriteText = (text: string) =>
+      linkifyUrlsAndApplyRewrite(text, this.repoCommentLinks);
+
+    // We are overriding some marked-element renderers for a few reasons:
+    // 1. Disable inline images as a design/policy choice.
+    // 2. Inline code blocks ("codespan") do not unescape HTML characters when
+    //    rendering without <pre> and so we must do this manually.
+    //    <marked-element> is already escaping these internally. See test
+    //    covering this.
+    // 3. Multiline code blocks ("code") is similarly handling escaped
+    //    characters using <pre>. The convention is to only use <pre> for multi-
+    //    line code blocks so it is not used for inline code blocks. See test
+    //    for this.
+    // 4. Rewrite plain text ("text") to apply linking and other config-based
+    //    rewrites. Text within code blocks is not passed here.
+    // 5. Open links in a new tab by rendering with target="_blank" attribute.
+    function customRenderer(renderer: {[type: string]: Function}) {
+      renderer['link'] = (href: string, title: string, text: string) =>
+        /* HTML */
+        `<a
+          href="${href}"
+          target="_blank"
+          ${title ? `title="${title}"` : ''}
+          rel="noopener"
+          >${text}</a
+        >`;
+      renderer['image'] = (href: string, _title: string, text: string) =>
+        `![${text}](${href})`;
+      renderer['codespan'] = (text: string) =>
+        `<code>${unescapeHTML(text)}</code>`;
+      renderer['code'] = (text: string) => `<pre><code>${text}</code></pre>`;
+      renderer['text'] = boundRewriteText;
+    }
+
+    // The child with slot is optional but allows us control over the styling.
+    // The `callback` property lets us do a final sanitization of the output
+    // HTML string before it is rendered by `<marked-element>` in case any
+    // rewrites have been abused to attempt an XSS attack.
+    return html`
+      <marked-element
+        .markdown=${this.escapeAllButBlockQuotes(this.content)}
+        .breaks=${true}
+        .renderer=${customRenderer}
+        .callback=${(_error: string | null, contents: string) =>
+          sanitizeHtml(contents)}
+      >
+        <div class="markdown-html" slot="markdown-html"></div>
+      </marked-element>
+    `;
+  }
+
+  private escapeAllButBlockQuotes(text: string) {
+    // Escaping the message should be done first to make sure user's literal
+    // input does not get rendered without affecting html added in later steps.
+    text = htmlEscape(text).toString();
+    // Unescape block quotes '>'. This is slightly dangerous as '>' can be used
+    // in HTML fragments, but it is insufficient on it's own.
+    for (;;) {
+      const newText = text.replace(
+        /(^|\n)((?:\s{0,3}&gt;)*\s{0,3})&gt;/g,
+        '$1$2>'
+      );
+      if (newText === text) {
+        break;
+      }
+      text = newText;
+    }
+
+    return text;
+  }
+
+  override updated() {
+    // Look for @mentions and replace them with an account-label chip.
+    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+      this.convertEmailsToAccountChips();
+    }
+  }
+
+  private convertEmailsToAccountChips() {
+    for (const emailLink of this.renderRoot.querySelectorAll(
+      'a[href^="mailto"]'
+    )) {
+      const previous = emailLink.previousSibling;
+      // This Regexp matches the beginning of the MENTIONS_REGEX at the end of
+      // an element.
+      if (
+        previous?.nodeName === '#text' &&
+        previous?.textContent?.match(/(^|\s)@$/)
+      ) {
+        const accountChip = document.createElement('gr-account-chip');
+        accountChip.account = {
+          email: emailLink.textContent as EmailAddress,
+        };
+        accountChip.removable = false;
+        // Remove the trailing @ from the previous element.
+        previous.textContent = previous.textContent.slice(0, -1);
+        emailLink.parentNode?.replaceChild(accountChip, emailLink);
+      }
+    }
+  }
 }
 
 declare global {
@@ -40,268 +248,3 @@
     'gr-formatted-text': GrFormattedText;
   }
 }
-@customElement('gr-formatted-text')
-export class GrFormattedText extends LitElement {
-  @property({type: String})
-  content?: string;
-
-  @property({type: Object})
-  config?: CommentLinks;
-
-  @property({type: Boolean, reflect: true})
-  noTrailingMargin = false;
-
-  static override get styles() {
-    return [
-      css`
-        :host {
-          display: block;
-          font-family: var(--font-family);
-        }
-        p,
-        ul,
-        code,
-        blockquote,
-        gr-linked-text.pre {
-          margin: 0 0 var(--spacing-m) 0;
-        }
-        p,
-        ul,
-        code,
-        blockquote {
-          max-width: var(--gr-formatted-text-prose-max-width, none);
-        }
-        :host([noTrailingMargin]) p:last-child,
-        :host([noTrailingMargin]) ul:last-child,
-        :host([noTrailingMargin]) blockquote:last-child,
-        :host([noTrailingMargin]) gr-linked-text.pre:last-child {
-          margin: 0;
-        }
-        code,
-        blockquote {
-          border-left: 1px solid #aaa;
-          padding: 0 var(--spacing-m);
-        }
-        code {
-          display: block;
-          white-space: pre-wrap;
-          color: var(--deemphasized-text-color);
-        }
-        li {
-          list-style-type: disc;
-          margin-left: var(--spacing-xl);
-        }
-        code,
-        gr-linked-text.pre {
-          font-family: var(--monospace-font-family);
-          font-size: var(--font-size-code);
-          /* usually 16px = 12px + 4px */
-          line-height: calc(var(--font-size-code) + var(--spacing-s));
-        }
-      `,
-    ];
-  }
-
-  override render() {
-    if (!this.content) return;
-    const blocks = this._computeBlocks(this.content);
-    return html`${blocks.map(block => this.renderBlock(block))}`;
-  }
-
-  /**
-   * Given a source string, parse into an array of block objects. Each block
-   * has a `type` property which takes any of the following values.
-   * * 'paragraph'
-   * * 'quote' (Block quote.)
-   * * 'pre' (Pre-formatted text.)
-   * * 'list' (Unordered list.)
-   * * 'code' (code blocks.)
-   *
-   * For blocks of type 'paragraph', 'pre' and 'code' there is a `text`
-   * property that maps to a string of the block's content.
-   *
-   * For blocks of type 'list', there is an `items` property that maps to a
-   * list of strings representing the list items.
-   *
-   * For blocks of type 'quote', there is a `blocks` property that maps to a
-   * list of blocks contained in the quote.
-   *
-   * NOTE: Strings appearing in all block objects are NOT escaped.
-   */
-  _computeBlocks(content: string): Block[] {
-    const result: Block[] = [];
-    const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
-
-    for (let i = 0; i < lines.length; i++) {
-      if (!lines[i].length) {
-        continue;
-      }
-
-      if (this.isCodeMarkLine(lines[i])) {
-        const startOfCode = i + 1;
-        const endOfCode = this.getEndOfSection(
-          lines,
-          startOfCode,
-          line => !this.isCodeMarkLine(line)
-        );
-        // If the code extends to the end then there is no closing``` and the
-        // opening``` should not be counted as a multiline code block.
-        const lineAfterCode = lines[endOfCode];
-        if (lineAfterCode && this.isCodeMarkLine(lineAfterCode)) {
-          result.push({
-            type: 'code',
-            // Does not include either of the ``` lines
-            text: lines.slice(startOfCode, endOfCode).join('\n'),
-          });
-          i = endOfCode; // advances past the closing```
-          continue;
-        }
-      }
-      if (this.isSingleLineCode(lines[i])) {
-        // no guard check as _isSingleLineCode tested on the pattern
-        const codeContent = lines[i].match(CODE_MARKER_PATTERN)![2];
-        result.push({type: 'code', text: codeContent});
-      } else if (this.isList(lines[i])) {
-        const endOfList = this.getEndOfSection(lines, i + 1, line =>
-          this.isList(line)
-        );
-        result.push(this.makeList(lines.slice(i, endOfList)));
-        i = endOfList - 1;
-      } else if (this.isQuote(lines[i])) {
-        const endOfQuote = this.getEndOfSection(lines, i + 1, line =>
-          this.isQuote(line)
-        );
-        const blockLines = lines
-          .slice(i, endOfQuote)
-          .map(l => l.replace(/^[ ]?>[ ]?/, ''));
-        result.push({
-          type: 'quote',
-          blocks: this._computeBlocks(blockLines.join('\n')),
-        });
-        i = endOfQuote - 1;
-      } else if (this.isPreFormat(lines[i])) {
-        // include pre or all regular lines but stop at next new line
-        const predicate = (line: string) =>
-          this.isPreFormat(line) ||
-          (this.isRegularLine(line) &&
-            !this.isWhitespaceLine(line) &&
-            line.length > 0);
-        const endOfPre = this.getEndOfSection(lines, i + 1, predicate);
-        result.push({
-          type: 'pre',
-          text: lines.slice(i, endOfPre).join('\n'),
-        });
-        i = endOfPre - 1;
-      } else {
-        const endOfRegularLines = this.getEndOfSection(lines, i + 1, line =>
-          this.isRegularLine(line)
-        );
-        result.push({
-          type: 'paragraph',
-          text: lines.slice(i, endOfRegularLines).join('\n'),
-        });
-        i = endOfRegularLines - 1;
-      }
-    }
-
-    return result;
-  }
-
-  private getEndOfSection(
-    lines: string[],
-    startIndex: number,
-    sectionPredicate: (line: string) => boolean
-  ) {
-    const index = lines
-      .slice(startIndex)
-      .findIndex(line => !sectionPredicate(line));
-    return index === -1 ? lines.length : index + startIndex;
-  }
-
-  /**
-   * Take a block of comment text that contains a list, generate appropriate
-   * block objects and append them to the output list.
-   *
-   * * Item one.
-   * * Item two.
-   * * item three.
-   *
-   * TODO(taoalpha): maybe we should also support nested list
-   *
-   * @param lines The block containing the list.
-   */
-  private makeList(lines: string[]): Block {
-    const items = lines.map(line => line.substring(1).trim());
-    return {type: 'list', items};
-  }
-
-  private isRegularLine(line: string): boolean {
-    return (
-      !this.isQuote(line) &&
-      !this.isCodeMarkLine(line) &&
-      !this.isSingleLineCode(line) &&
-      !this.isList(line) &&
-      !this.isPreFormat(line)
-    );
-  }
-
-  private isQuote(line: string): boolean {
-    return line.startsWith('> ') || line.startsWith(' > ');
-  }
-
-  private isCodeMarkLine(line: string): boolean {
-    return line.trim() === '```';
-  }
-
-  private isSingleLineCode(line: string): boolean {
-    return CODE_MARKER_PATTERN.test(line);
-  }
-
-  private isPreFormat(line: string): boolean {
-    return /^[ \t]/.test(line) && !this.isWhitespaceLine(line);
-  }
-
-  private isList(line: string): boolean {
-    return /^[-*] /.test(line);
-  }
-
-  private isWhitespaceLine(line: string): boolean {
-    return /^\s+$/.test(line);
-  }
-
-  private renderLinkedText(content: string, isPre?: boolean): TemplateResult {
-    return html`
-      <gr-linked-text
-        class="${isPre ? 'pre' : ''}"
-        .config=${this.config}
-        content=${content}
-        pre
-      ></gr-linked-text>
-    `;
-  }
-
-  private renderBlock(block: Block): TemplateResult {
-    switch (block.type) {
-      case 'paragraph':
-        return html`<p>${this.renderLinkedText(block.text)}</p>`;
-      case 'quote':
-        return html`
-          <blockquote>
-            ${block.blocks.map(subBlock => this.renderBlock(subBlock))}
-          </blockquote>
-        `;
-      case 'code':
-        return html`<code>${block.text}</code>`;
-      case 'pre':
-        return this.renderLinkedText(block.text, true);
-      case 'list':
-        return html`
-          <ul>
-            ${block.items.map(
-              item => html`<li>${this.renderLinkedText(item)}</li>`
-            )}
-          </ul>
-        `;
-    }
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 8cecf71..3881c62 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -1,433 +1,619 @@
 /**
  * @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.
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
-import './gr-formatted-text';
+import '../../../test/common-test-setup';
+import {assert, fixture, html} from '@open-wc/testing';
+import {changeModelToken} from '../../../models/change/change-model';
 import {
-  GrFormattedText,
-  Block,
-  ListBlock,
-  TextBlock,
-  QuoteBlock,
-} from './gr-formatted-text';
-
-const basicFixture = fixtureFromElement('gr-formatted-text');
+  ConfigModel,
+  configModelToken,
+} from '../../../models/config/config-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import './gr-formatted-text';
+import {GrFormattedText} from './gr-formatted-text';
+import {createConfig} from '../../../test/test-data-generators';
+import {
+  queryAndAssert,
+  stubFlags,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {CommentLinks, EmailAddress} from '../../../api/rest-api';
+import {testResolver} from '../../../test/common-test-setup';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 
 suite('gr-formatted-text tests', () => {
   let element: GrFormattedText;
+  let configModel: ConfigModel;
 
-  function assertTextBlock(block: Block, type: string, text: string) {
-    assert.equal(block.type, type);
-    const textBlock = block as TextBlock;
-    assert.equal(textBlock.text, text);
+  async function setCommentLinks(commentlinks: CommentLinks) {
+    configModel.updateRepoConfig({...createConfig(), commentlinks});
+    await waitUntilObserved(
+      configModel.repoCommentLinks$,
+      links => links === commentlinks
+    );
   }
 
-  function assertListBlock(block: Block, items: string[]) {
-    assert.equal(block.type, 'list');
-    const listBlock = block as ListBlock;
-    assert.deepEqual(listBlock.items, items);
-  }
-
-  function assertQuoteBlock(block: Block): QuoteBlock {
-    assert.equal(block.type, 'quote');
-    return block as QuoteBlock;
-  }
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('parse empty', () => {
-    assert.lengthOf(element._computeBlocks(''), 0);
-  });
-
-  test('parse simple', () => {
-    const comment = 'Para1';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'paragraph', comment);
-  });
-
-  test('parse multiline para', () => {
-    const comment = 'Para 1\nStill para 1';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'paragraph', comment);
-  });
-
-  test('parse para break without special blocks', () => {
-    const comment = 'Para 1\n\nPara 2\n\nPara 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'paragraph', comment);
-  });
-
-  test('parse quote', () => {
-    const comment = '> Quote text';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(quoteBlock.blocks[0], 'paragraph', 'Quote text');
-  });
-
-  test('parse quote lead space', () => {
-    const comment = ' > Quote text';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(quoteBlock.blocks[0], 'paragraph', 'Quote text');
-  });
-
-  test('parse multiline quote', () => {
-    const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(
-      quoteBlock.blocks[0],
-      'paragraph',
-      'Quote line 1\nQuote line 2\nQuote line 3'
+  setup(async () => {
+    configModel = new ConfigModel(
+      testResolver(changeModelToken),
+      getAppContext().restApiService
     );
+    await setCommentLinks({
+      customLinkRewrite: {
+        match: '(LinkRewriteMe)',
+        link: 'http://google.com/$1',
+      },
+      complexLinkRewrite: {
+        match: '(^|\\s)A Link (\\d+)($|\\s)',
+        link: '/page?id=$2',
+        text: 'Link $2',
+        prefix: '$1A ',
+        suffix: '$3',
+      },
+    });
+    self.CANONICAL_PATH = 'http://localhost';
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-formatted-text></gr-formatted-text>`,
+          configModelToken,
+          configModel
+        )
+      )
+    ).querySelector('gr-formatted-text')!;
   });
 
-  test('parse pre', () => {
-    const comment = '    Four space indent.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'pre', comment);
+  suite('as plaintext', () => {
+    setup(async () => {
+      element.markdown = false;
+      await element.updateComplete;
+    });
+
+    test('does not apply rewrites within links', async () => {
+      element.content = 'google.com/LinkRewriteMe';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+            <a
+              href="http://google.com/LinkRewriteMe"
+              rel="noopener"
+              target="_blank"
+            >
+              google.com/LinkRewriteMe
+            </a>
+          </pre>
+        `
+      );
+    });
+
+    test('does not apply rewrites on rewritten text', async () => {
+      await setCommentLinks({
+        capitalizeFoo: {
+          match: 'foo',
+          prefix: 'FOO',
+          link: 'a.b.c',
+        },
+        lowercaseFoo: {
+          match: 'FOO',
+          prefix: 'foo',
+          link: 'c.d.e',
+        },
+      });
+      element.content = 'foo';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+          FOO<a href="a.b.c" rel="noopener" target="_blank">foo</a>
+        </pre>
+        `
+      );
+    });
+
+    test('supports overlapping rewrites', async () => {
+      await setCommentLinks({
+        bracketNum: {
+          match: '(Start:) ([0-9]+)',
+          prefix: '$1 ',
+          link: 'bug/$2',
+          text: 'bug/$2',
+        },
+        bracketNum2: {
+          match: '(Start: [0-9]+) ([0-9]+)',
+          prefix: '$1 ',
+          link: 'bug/$2',
+          text: 'bug/$2',
+        },
+      });
+      element.content = 'Start: 123 456';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+            Start:
+            <a href="bug/123" rel="noopener" target="_blank">
+              bug/123
+            </a>
+            <a href="bug/456" rel="noopener" target="_blank">
+              bug/456
+            </a>
+          </pre>
+        `
+      );
+    });
+
+    test('renders text with links and rewrites', async () => {
+      element.content = `text with plain link: google.com
+        \ntext with config link: LinkRewriteMe
+        \ntext with complex link: A Link 12`;
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+            text with plain link:
+            <a href="http://google.com" rel="noopener" target="_blank">
+              google.com
+            </a>
+            text with config link:
+            <a
+              href="http://google.com/LinkRewriteMe"
+              rel="noopener"
+              target="_blank"
+            >
+              LinkRewriteMe
+            </a>
+            text with complex link: A
+            <a
+              href="http://localhost/page?id=12"
+              rel="noopener"
+              target="_blank"
+            >
+              Link 12
+            </a>
+          </pre>
+        `
+      );
+    });
+
+    test('does not render typed html', async () => {
+      element.content = 'plain text <div>foo</div>';
+      await element.updateComplete;
+
+      const escapedDiv = '&lt;div&gt;foo&lt;/div&gt;';
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<pre class="plaintext">plain text ${escapedDiv}</pre>`
+      );
+    });
+
+    test('does not render markdown', async () => {
+      element.content = '# A Markdown Heading';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ '<pre class="plaintext"># A Markdown Heading</pre>'
+      );
+    });
   });
 
-  test('parse one space pre', () => {
-    const comment = ' One space indent.\n Another line.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'pre', comment);
-  });
+  suite('as markdown', () => {
+    setup(async () => {
+      element.markdown = true;
+      await element.updateComplete;
+    });
+    test('renders text with links and rewrites', async () => {
+      element.content = `text
+        \ntext with plain link: google.com
+        \ntext with config link: LinkRewriteMe
+        \ntext without a link: NotA Link 15 cats
+        \ntext with complex link: A Link 12`;
+      await element.updateComplete;
 
-  test('parse tab pre', () => {
-    const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'pre', comment);
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <p>text</p>
+              <p>
+                text with plain link:
+                <a href="http://google.com" rel="noopener" target="_blank">
+                  google.com
+                </a>
+              </p>
+              <p>
+                text with config link:
+                <a
+                  href="http://google.com/LinkRewriteMe"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  LinkRewriteMe
+                </a>
+              </p>
+              <p>text without a link: NotA Link 15 cats</p>
+              <p>
+                text with complex link: A
+                <a
+                  href="http://localhost/page?id=12"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  Link 12
+                </a>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('parse star list', () => {
-    const comment = '* Item 1\n* Item 2\n* Item 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result[0], ['Item 1', 'Item 2', 'Item 3']);
-  });
+    test('does not render if too long', async () => {
+      element.content = `text
+        text with plain link: google.com
+        text with config link: LinkRewriteMe
+        text without a link: NotA Link 15 cats
+        text with complex link: A Link 12`;
+      element.MARKDOWN_LIMIT = 10;
+      await element.updateComplete;
 
-  test('parse dash list', () => {
-    const comment = '- Item 1\n- Item 2\n- Item 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result[0], ['Item 1', 'Item 2', 'Item 3']);
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <pre class="plaintext">
+          text
+        text with plain link:
+          <a href="http://google.com" rel="noopener" target="_blank">google.com</a>
+          text with config link:
+          <a
+            href="http://google.com/LinkRewriteMe"
+            rel="noopener"
+            target="_blank"
+          >
+            LinkRewriteMe
+          </a>
+        text without a link: NotA Link 15 cats
+        text with complex link: A
+          <a
+            href="http://localhost/page?id=12"
+            rel="noopener"
+            target="_blank"
+          >
+            Link 12
+          </a>
+        </pre>
+        `
+      );
+    });
 
-  test('parse mixed list', () => {
-    const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result[0], ['Item 1', 'Item 2', 'Item 3', 'Item 4']);
-  });
+    test('renders headings with links and rewrites', async () => {
+      element.content = `# h1-heading
+        \n## h2-heading
+        \n### h3-heading
+        \n#### h4-heading
+        \n##### h5-heading
+        \n###### h6-heading
+        \n# heading with plain link: google.com
+        \n# heading with config link: LinkRewriteMe`;
+      await element.updateComplete;
 
-  test('parse mixed block types', () => {
-    const comment =
-      'Paragraph\nacross\na\nfew\nlines.' +
-      '\n\n' +
-      '> Quote\n> across\n> not many lines.' +
-      '\n\n' +
-      'Another paragraph' +
-      '\n\n' +
-      '* Series\n* of\n* list\n* items' +
-      '\n\n' +
-      'Yet another paragraph' +
-      '\n\n' +
-      '\tPreformatted text.' +
-      '\n\n' +
-      'Parting words.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 7);
-    assertTextBlock(
-      result[0],
-      'paragraph',
-      'Paragraph\nacross\na\nfew\nlines.\n'
-    );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <h1>h1-heading</h1>
+              <h2>h2-heading</h2>
+              <h3>h3-heading</h3>
+              <h4>h4-heading</h4>
+              <h5>h5-heading</h5>
+              <h6>h6-heading</h6>
+              <h1>
+                heading with plain link:
+                <a href="http://google.com" rel="noopener" target="_blank">
+                  google.com
+                </a>
+              </h1>
+              <h1>
+                heading with config link:
+                <a
+                  href="http://google.com/LinkRewriteMe"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  LinkRewriteMe
+                </a>
+              </h1>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-    const quoteBlock = assertQuoteBlock(result[1]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(
-      quoteBlock.blocks[0],
-      'paragraph',
-      'Quote\nacross\nnot many lines.'
-    );
+    test('renders inline-code without linking or rewriting', async () => {
+      element.content = `\`inline code\`
+        \n\`inline code with plain link: google.com\`
+        \n\`inline code with config link: LinkRewriteMe\``;
+      await element.updateComplete;
 
-    assertTextBlock(result[2], 'paragraph', 'Another paragraph\n');
-    assertListBlock(result[3], ['Series', 'of', 'list', 'items']);
-    assertTextBlock(result[4], 'paragraph', 'Yet another paragraph\n');
-    assertTextBlock(result[5], 'pre', '\tPreformatted text.');
-    assertTextBlock(result[6], 'paragraph', 'Parting words.');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <p>
+                <code>inline code</code>
+              </p>
+              <p>
+                <code>inline code with plain link: google.com</code>
+              </p>
+              <p>
+                <code>inline code with config link: LinkRewriteMe</code>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('bullet list 1', () => {
-    const comment = 'A\n\n* line 1';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'A\n');
-    assertListBlock(result[1], ['line 1']);
-  });
+    test('renders multiline-code without linking or rewriting', async () => {
+      element.content = `\`\`\`\nmultiline code\n\`\`\`
+        \n\`\`\`\nmultiline code with plain link: google.com\n\`\`\`
+        \n\`\`\`\nmultiline code with config link: LinkRewriteMe\n\`\`\``;
+      await element.updateComplete;
 
-  test('bullet list 2', () => {
-    const comment = 'A\n\n* line 1\n* 2nd line';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'A\n');
-    assertListBlock(result[1], ['line 1', '2nd line']);
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <pre>
+              <code>multiline code</code>
+            </pre>
+              <pre>
+              <code>multiline code with plain link: google.com</code>
+            </pre>
+              <pre>
+              <code>multiline code with config link: LinkRewriteMe</code>
+            </pre>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('bullet list 3', () => {
-    const comment = 'A\n* line 1\n* 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertListBlock(result[1], ['line 1', '2nd line']);
-    assertTextBlock(result[2], 'paragraph', 'B');
-  });
+    test('does not render inline images into <img> tags', async () => {
+      element.content = '![img](google.com/img.png)';
+      await element.updateComplete;
 
-  test('bullet list 4', () => {
-    const comment = '* line 1\n* 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertListBlock(result[0], ['line 1', '2nd line']);
-    assertTextBlock(result[1], 'paragraph', 'B');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <p>![img](google.com/img.png)</p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('bullet list 5', () => {
-    const comment =
-      'To see this bug, you have to:\n' +
-      '* Be on IMAP or EAS (not on POP)\n' +
-      '* Be very unlucky\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'To see this bug, you have to:');
-    assertListBlock(result[1], [
-      'Be on IMAP or EAS (not on POP)',
-      'Be very unlucky',
-    ]);
-  });
+    test('does not handle @mentions if not enabled', async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(false);
+      element.content = '@someone@google.com';
+      await element.updateComplete;
 
-  test('bullet list 6', () => {
-    const comment =
-      'To see this bug,\n' +
-      'you have to:\n' +
-      '* Be on IMAP or EAS (not on POP)\n' +
-      '* Be very unlucky\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'To see this bug,\nyou have to:');
-    assertListBlock(result[1], [
-      'Be on IMAP or EAS (not on POP)',
-      'Be very unlucky',
-    ]);
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <p>
+                @
+                <a
+                  href="mailto:someone@google.com"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  someone@google.com
+                </a>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('dash list 1', () => {
-    const comment = 'A\n- line 1\n- 2nd line';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertListBlock(result[1], ['line 1', '2nd line']);
-  });
+    test('handles @mentions if enabled', async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(true);
+      element.content = '@someone@google.com';
+      await element.updateComplete;
 
-  test('dash list 2', () => {
-    const comment = 'A\n- line 1\n- 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertListBlock(result[1], ['line 1', '2nd line']);
-    assertTextBlock(result[2], 'paragraph', 'B');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <p>
+                <gr-account-chip></gr-account-chip>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+      const accountChip = queryAndAssert<GrAccountChip>(
+        element,
+        'gr-account-chip'
+      );
+      assert.equal(
+        accountChip.account?.email,
+        'someone@google.com' as EmailAddress
+      );
+    });
 
-  test('dash list 3', () => {
-    const comment = '- line 1\n- 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertListBlock(result[0], ['line 1', '2nd line']);
-    assertTextBlock(result[1], 'paragraph', 'B');
-  });
+    test('does not handle @mentions that is part of a code block', async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(true);
+      element.content = '`@`someone@google.com';
+      await element.updateComplete;
 
-  test('nested list will NOT be recognized', () => {
-    // will be rendered as two separate lists
-    const comment = '- line 1\n  - line with indentation\n- line 2';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertListBlock(result[0], ['line 1']);
-    assertTextBlock(result[1], 'pre', '  - line with indentation');
-    assertListBlock(result[2], ['line 2']);
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <p>
+                <code>@</code>
+                <a
+                  href="mailto:someone@google.com"
+                  rel="noopener"
+                  target="_blank"
+                >
+                  someone@google.com
+                </a>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('pre format 1', () => {
-    const comment = 'A\n  This is pre\n  formatted';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertTextBlock(result[1], 'pre', '  This is pre\n  formatted');
-  });
+    test('renders inline links into <a> tags', async () => {
+      element.content = '[myLink](https://www.google.com)';
+      await element.updateComplete;
 
-  test('pre format 2', () => {
-    const comment = 'A\n  This is pre\n  formatted\n\nbut this is not';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertTextBlock(result[1], 'pre', '  This is pre\n  formatted');
-    assertTextBlock(result[2], 'paragraph', 'but this is not');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <p>
+                <a href="https://www.google.com" rel="noopener" target="_blank"
+                  >myLink</a
+                >
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('pre format 3', () => {
-    const comment = 'A\n  Q\n    <R>\n  S\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertTextBlock(result[0], 'paragraph', 'A');
-    assertTextBlock(result[1], 'pre', '  Q\n    <R>\n  S');
-    assertTextBlock(result[2], 'paragraph', 'B');
-  });
+    test('renders block quotes with links and rewrites', async () => {
+      element.content = `> block quote
+        \n> block quote with plain link: google.com
+        \n> block quote with config link: LinkRewriteMe`;
+      await element.updateComplete;
 
-  test('pre format 4', () => {
-    const comment = '  Q\n    <R>\n  S\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'pre', '  Q\n    <R>\n  S');
-    assertTextBlock(result[1], 'paragraph', 'B');
-  });
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <blockquote>
+                <p>block quote</p>
+              </blockquote>
+              <blockquote>
+                <p>
+                  block quote with plain link:
+                  <a href="http://google.com" rel="noopener" target="_blank">
+                    google.com
+                  </a>
+                </p>
+              </blockquote>
+              <blockquote>
+                <p>
+                  block quote with config link:
+                  <a
+                    href="http://google.com/LinkRewriteMe"
+                    rel="noopener"
+                    target="_blank"
+                  >
+                    LinkRewriteMe
+                  </a>
+                </p>
+              </blockquote>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('pre format 5', () => {
-    const comment = '  Q\n    <R>\n  S\n \nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'pre', '  Q\n    <R>\n  S');
-    assertTextBlock(result[1], 'paragraph', ' \nB');
-  });
+    test('never renders typed html', async () => {
+      element.content = `plain text <div>foo</div>
+        \n\`inline code <div>foo</div>\`
+        \n\`\`\`\nmultiline code <div>foo</div>\`\`\`
+        \n> block quote <div>foo</div>
+        \n[inline link <div>foo</div>](http://google.com)`;
+      await element.updateComplete;
 
-  test('quote 1', () => {
-    const comment = "> I'm happy with quotes!!";
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(
-      quoteBlock.blocks[0],
-      'paragraph',
-      "I'm happy with quotes!!"
-    );
-  });
+      const escapedDiv = '&lt;div&gt;foo&lt;/div&gt;';
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <p>plain text ${escapedDiv}</p>
+              <p>
+                <code>inline code ${escapedDiv}</code>
+              </p>
+              <pre>
+              <code>
+                multiline code ${escapedDiv}
+              </code>
+            </pre>
+              <blockquote>
+                <p>block quote ${escapedDiv}</p>
+              </blockquote>
+              <p>
+                <a href="http://google.com" rel="noopener" target="_blank"
+                  >inline link ${escapedDiv}</a
+                >
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
 
-  test('quote 2', () => {
-    const comment = "> I'm happy\n > with quotes!\n\nSee above.";
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    const quoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(
-      quoteBlock.blocks[0],
-      'paragraph',
-      "I'm happy\nwith quotes!"
-    );
-    assertTextBlock(result[1], 'paragraph', 'See above.');
-  });
+    test('renders nested block quotes', async () => {
+      element.content = '> > > block quote';
+      await element.updateComplete;
 
-  test('quote 3', () => {
-    const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertTextBlock(result[0], 'paragraph', 'See this said:');
-    const quoteBlock = assertQuoteBlock(result[1]);
-    assert.lengthOf(quoteBlock.blocks, 1);
-    assertTextBlock(
-      quoteBlock.blocks[0],
-      'paragraph',
-      'a quoted\nstring block'
-    );
-    assertTextBlock(result[2], 'paragraph', 'OK?');
-  });
-
-  test('nested quotes', () => {
-    const comment = ' > > prior\n > \n > next\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    const outerQuoteBlock = assertQuoteBlock(result[0]);
-    assert.lengthOf(outerQuoteBlock.blocks, 2);
-    const nestedQuoteBlock = assertQuoteBlock(outerQuoteBlock.blocks[0]);
-    assert.lengthOf(nestedQuoteBlock.blocks, 1);
-    assertTextBlock(nestedQuoteBlock.blocks[0], 'paragraph', 'prior');
-    assertTextBlock(outerQuoteBlock.blocks[1], 'paragraph', 'next');
-  });
-
-  test('code 1', () => {
-    const comment = '```\n// test code\n```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'code', '// test code');
-  });
-
-  test('code 2', () => {
-    const comment = 'test code\n```// test code```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'test code');
-    assertTextBlock(result[1], 'code', '// test code');
-  });
-
-  test('not a code block', () => {
-    const comment = 'test code\n```// test code';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertTextBlock(result[0], 'paragraph', 'test code\n```// test code');
-  });
-
-  test('not a code block 2', () => {
-    const comment = 'test code\n```\n// test code';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'test code');
-    assertTextBlock(result[1], 'paragraph', '```\n// test code');
-  });
-
-  test('not a code block 3', () => {
-    const comment = 'test code\n```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertTextBlock(result[0], 'paragraph', 'test code');
-    assertTextBlock(result[1], 'paragraph', '```');
-  });
-
-  test('mix all 1', () => {
-    const comment =
-      ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
-      '```// test code```\n\n> reference is here';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 5);
-    assert.equal(result[0].type, 'pre');
-    assert.equal(result[1].type, 'list');
-    assert.equal(result[2].type, 'paragraph');
-    assert.equal(result[3].type, 'code');
-    assert.equal(result[4].type, 'quote');
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html" class="markdown-html">
+              <blockquote>
+                <blockquote>
+                  <blockquote>
+                    <p>block quote</p>
+                  </blockquote>
+                </blockquote>
+              </blockquote>
+            </div>
+          </marked-element>
+        `
+      );
+    });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
new file mode 100644
index 0000000..abc7f3c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
@@ -0,0 +1,569 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-avatar/gr-avatar';
+import '../gr-button/gr-button';
+import '../gr-icon/gr-icon';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {getAppContext} from '../../../services/app-context';
+import {
+  accountKey,
+  computeVoteableText,
+  isAccountEmailOnly,
+  isSelf,
+} from '../../../utils/account-util';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  AccountInfo,
+  ChangeInfo,
+  ServerInfo,
+  ReviewInput,
+} from '../../../types/common';
+import {
+  canHaveAttention,
+  getAddedByReason,
+  getLastUpdate,
+  getReason,
+  getRemovedByReason,
+  hasAttention,
+} from '../../../utils/attention-set-util';
+import {ReviewerState} from '../../../constants/constants';
+import {CURRENT} from '../../../utils/patch-set-util';
+import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement, nothing} from 'lit';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {EventType} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {createSearchUrl} from '../../../models/views/search';
+import {createDashboardUrl} from '../../../models/views/dashboard';
+import {fire, fireEvent} from '../../../utils/event-util';
+import {userModelToken} from '../../../models/user/user-model';
+
+@customElement('gr-hovercard-account-contents')
+export class GrHovercardAccountContents extends LitElement {
+  @property({type: Object})
+  account!: AccountInfo;
+
+  @state()
+  selfAccount?: AccountInfo;
+
+  /**
+   * Optional ChangeInfo object, typically comes from the change page or
+   * from a row in a list of search results. This is needed for some change
+   * related features like adding the user as a reviewer.
+   */
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  /**
+   * Should attention set related features be shown in the component? Note
+   * that the information whether the user is in the attention set or not is
+   * part of the ChangeInfo object in the change property.
+   */
+  @property({type: Boolean})
+  highlightAttention = false;
+
+  @state()
+  serverConfig?: ServerInfo;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.selfAccount = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        .top,
+        .attention,
+        .status,
+        .voteable {
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .links {
+          padding: var(--spacing-m) 0px var(--spacing-l) var(--spacing-xxl);
+        }
+        .top {
+          display: flex;
+          padding-top: var(--spacing-xl);
+          min-width: 300px;
+        }
+        gr-avatar {
+          height: 48px;
+          width: 48px;
+          margin-right: var(--spacing-l);
+        }
+        .title,
+        .email {
+          color: var(--deemphasized-text-color);
+        }
+        .action {
+          border-top: 1px solid var(--border-color);
+          padding: var(--spacing-s) var(--spacing-l);
+          --gr-button-padding: var(--spacing-s) var(--spacing-m);
+        }
+        .attention {
+          background-color: var(--emphasis-color);
+        }
+        .attention a {
+          text-decoration: none;
+        }
+        .status gr-icon {
+          font-size: 14px;
+          position: relative;
+          top: 2px;
+        }
+        gr-icon.attentionIcon {
+          transform: scaleX(0.8);
+        }
+        gr-icon.linkIcon {
+          font-size: var(--line-height-normal, 20px);
+          color: var(--deemphasized-text-color);
+          padding-right: 12px;
+        }
+        .links a {
+          color: var(--link-color);
+          padding: 0px 4px;
+        }
+        .reason {
+          padding-top: var(--spacing-s);
+        }
+        .status .value {
+          white-space: pre-wrap;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div class="top">
+        <div class="avatar">
+          <gr-avatar .account=${this.account} .imageSize=${56}></gr-avatar>
+        </div>
+        <div class="account">
+          <h3 class="name heading-3">${this.account.name}</h3>
+          <div class="email">${this.account.email}</div>
+        </div>
+      </div>
+      ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
+      ${this.renderLinks()} ${this.renderChangeRelatedInfoAndActions()}
+    `;
+  }
+
+  private renderChangeRelatedInfoAndActions() {
+    if (this.change === undefined) {
+      return nothing;
+    }
+    const voteableText = computeVoteableText(this.change, this.account);
+    return html`
+      ${voteableText
+        ? html`
+            <div class="voteable">
+              <span class="title">Voteable:</span>
+              <span class="value">${voteableText}</span>
+            </div>
+          `
+        : ''}
+      ${this.renderNeedsAttention()} ${this.renderAddToAttention()}
+      ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()}
+    `;
+  }
+
+  private renderReviewerOrCcActions() {
+    // `selfAccount` is required so that logged out users can't perform actions.
+    if (!this.selfAccount || !isRemovableReviewer(this.change, this.account))
+      return nothing;
+    return html`
+      <div class="action">
+        <gr-button
+          class="removeReviewerOrCC"
+          link
+          no-uppercase
+          @click=${this.handleRemoveReviewerOrCC}
+        >
+          Remove ${this.computeReviewerOrCCText()}
+        </gr-button>
+      </div>
+      <div class="action">
+        <gr-button
+          class="changeReviewerOrCC"
+          link
+          no-uppercase
+          @click=${this.handleChangeReviewerOrCCStatus}
+        >
+          ${this.computeChangeReviewerOrCCText()}
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private renderAccountStatusPlugins() {
+    return html`
+      <gr-endpoint-decorator name="hovercard-status">
+        <gr-endpoint-param
+          name="account"
+          .value=${this.account}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderLinks() {
+    if (!this.account || isAccountEmailOnly(this.account)) return nothing;
+    return html` <div class="links">
+      <gr-icon icon="link" class="linkIcon"></gr-icon>
+      <a
+        href=${ifDefined(this.computeOwnerChangesLink())}
+        @click=${() => {
+          fireEvent(this, 'link-clicked');
+        }}
+        @enter=${() => {
+          fireEvent(this, 'link-clicked');
+        }}
+      >
+        Changes
+      </a>
+      ·
+      <a
+        href=${ifDefined(this.computeOwnerDashboardLink())}
+        @click=${() => {
+          fireEvent(this, 'link-clicked');
+        }}
+        @enter=${() => {
+          fireEvent(this, 'link-clicked');
+        }}
+      >
+        Dashboard
+      </a>
+    </div>`;
+  }
+
+  private renderAccountStatus() {
+    if (!this.account.status) return nothing;
+    return html`
+      <div class="status">
+        <span class="title">About me:</span>
+        <span class="value">${this.account.status}</span>
+      </div>
+    `;
+  }
+
+  private renderNeedsAttention() {
+    if (!(this.isAttentionEnabled && this.hasUserAttention)) return nothing;
+    const lastUpdate = getLastUpdate(this.account, this.change);
+    return html`
+      <div class="attention">
+        <div>
+          <gr-icon
+            icon="label_important"
+            filled
+            small
+            class="attentionIcon"
+          ></gr-icon>
+          <span> ${this.computePronoun()} turn to take action. </span>
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+            target="_blank"
+          >
+            <gr-icon icon="help" title="read documentation"></gr-icon>
+          </a>
+        </div>
+        <div class="reason">
+          <span class="title">Reason:</span>
+          <span class="value">
+            ${getReason(this.serverConfig, this.account, this.change)}
+          </span>
+          ${lastUpdate
+            ? html` (
+                <gr-date-formatter
+                  withTooltip
+                  .dateStr=${lastUpdate}
+                ></gr-date-formatter>
+                )`
+            : ''}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderAddToAttention() {
+    if (!this.computeShowActionAddToAttentionSet()) return nothing;
+    return html`
+      <div class="action">
+        <gr-button
+          class="addToAttentionSet"
+          link
+          no-uppercase
+          @click=${this.handleClickAddToAttentionSet}
+        >
+          Add to attention set
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private renderRemoveFromAttention() {
+    if (!this.computeShowActionRemoveFromAttentionSet()) return nothing;
+    return html`
+      <div class="action">
+        <gr-button
+          class="removeFromAttentionSet"
+          link
+          no-uppercase
+          @click=${this.handleClickRemoveFromAttentionSet}
+        >
+          Remove from attention set
+        </gr-button>
+      </div>
+    `;
+  }
+
+  // private but used by tests
+  computePronoun() {
+    if (!this.account || !this.selfAccount) return '';
+    return isSelf(this.account, this.selfAccount) ? 'Your' : 'Their';
+  }
+
+  computeOwnerChangesLink() {
+    if (!this.account) return undefined;
+    return createSearchUrl({
+      owner:
+        this.account.email ||
+        this.account.username ||
+        this.account.name ||
+        `${this.account._account_id}`,
+    });
+  }
+
+  computeOwnerDashboardLink() {
+    if (!this.account) return undefined;
+    if (this.account._account_id)
+      return createDashboardUrl({user: `${this.account._account_id}`});
+    if (this.account.email)
+      return createDashboardUrl({user: this.account.email});
+    return undefined;
+  }
+
+  get isAttentionEnabled() {
+    return (
+      !!this.highlightAttention &&
+      !!this.change &&
+      canHaveAttention(this.account)
+    );
+  }
+
+  get hasUserAttention() {
+    return hasAttention(this.account, this.change);
+  }
+
+  private getReviewerState(change: ChangeInfo) {
+    if (
+      change.reviewers[ReviewerState.REVIEWER]?.some(
+        (reviewer: AccountInfo) =>
+          reviewer._account_id === this.account._account_id
+      )
+    ) {
+      return ReviewerState.REVIEWER;
+    }
+    return ReviewerState.CC;
+  }
+
+  private computeReviewerOrCCText() {
+    if (!this.change || !this.account) return '';
+    return this.getReviewerState(this.change) === ReviewerState.REVIEWER
+      ? 'Reviewer'
+      : 'CC';
+  }
+
+  private computeChangeReviewerOrCCText() {
+    if (!this.change || !this.account) return '';
+    return this.getReviewerState(this.change) === ReviewerState.REVIEWER
+      ? 'Move Reviewer to CC'
+      : 'Move CC to Reviewer';
+  }
+
+  private handleChangeReviewerOrCCStatus() {
+    assertIsDefined(this.change, 'change');
+    // accountKey() throws an error if _account_id & email is not found, which
+    // we want to check before showing reloading toast
+    const _accountKey = accountKey(this.account);
+    fire(this, EventType.SHOW_ALERT, {
+      message: 'Reloading page...',
+    });
+    const reviewInput: Partial<ReviewInput> = {};
+    reviewInput.reviewers = [
+      {
+        reviewer: _accountKey,
+        state:
+          this.getReviewerState(this.change) === ReviewerState.CC
+            ? ReviewerState.REVIEWER
+            : ReviewerState.CC,
+      },
+    ];
+
+    this.restApiService
+      .saveChangeReview(this.change._number, CURRENT, reviewInput)
+      .then(response => {
+        if (!response || !response.ok) {
+          throw new Error(
+            'something went wrong when toggling' +
+              this.getReviewerState(this.change!)
+          );
+        }
+        fire(this, 'reload', {clearPatchset: true});
+      });
+  }
+
+  private handleRemoveReviewerOrCC() {
+    if (!this.change || !(this.account?._account_id || this.account?.email))
+      throw new Error('Missing change or account.');
+    fire(this, EventType.SHOW_ALERT, {
+      message: 'Reloading page...',
+    });
+    this.restApiService
+      .removeChangeReviewer(
+        this.change._number,
+        (this.account?._account_id || this.account?.email)!
+      )
+      .then((response: Response | undefined) => {
+        if (!response || !response.ok) {
+          throw new Error('something went wrong when removing user');
+        }
+        fire(this, 'reload', {clearPatchset: true});
+        return response;
+      });
+  }
+
+  private computeShowActionAddToAttentionSet() {
+    const involvedOrSelf =
+      isInvolved(this.change, this.selfAccount) ||
+      isSelf(this.account, this.selfAccount);
+    return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
+  }
+
+  private computeShowActionRemoveFromAttentionSet() {
+    const involvedOrSelf =
+      isInvolved(this.change, this.selfAccount) ||
+      isSelf(this.account, this.selfAccount);
+    return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
+  }
+
+  private handleClickAddToAttentionSet() {
+    if (!this.change || !this.account._account_id) return;
+    fire(this, EventType.SHOW_ALERT, {
+      message: 'Reloading page...',
+      dismissOnNavigation: true,
+    });
+
+    // We are deliberately updating the UI before making the API call. It is a
+    // risk that we are taking to achieve a better UX for 99.9% of the cases.
+    const reason = getAddedByReason(this.selfAccount, this.serverConfig);
+
+    if (!this.change.attention_set) this.change.attention_set = {};
+    this.change.attention_set[this.account._account_id] = {
+      account: this.account,
+      reason,
+      reason_account: this.selfAccount,
+    };
+    fireEvent(this, 'attention-set-updated');
+
+    this.reporting.reportInteraction(
+      'attention-hovercard-add',
+      this.reportingDetails()
+    );
+    this.restApiService
+      .addToAttentionSet(this.change._number, this.account._account_id, reason)
+      .then(() => {
+        fireEvent(this, 'hide-alert');
+      });
+    fireEvent(this, 'action-taken');
+  }
+
+  private handleClickRemoveFromAttentionSet() {
+    if (!this.change || !this.account._account_id) return;
+    fire(this, EventType.SHOW_ALERT, {
+      message: 'Saving attention set update ...',
+      dismissOnNavigation: true,
+    });
+
+    // We are deliberately updating the UI before making the API call. It is a
+    // risk that we are taking to achieve a better UX for 99.9% of the cases.
+
+    const reason = getRemovedByReason(this.selfAccount, this.serverConfig);
+    if (this.change.attention_set)
+      delete this.change.attention_set[this.account._account_id];
+    fireEvent(this, 'attention-set-updated');
+
+    this.reporting.reportInteraction(
+      'attention-hovercard-remove',
+      this.reportingDetails()
+    );
+    this.restApiService
+      .removeFromAttentionSet(
+        this.change._number,
+        this.account._account_id,
+        reason
+      )
+      .then(() => {
+        fireEvent(this, 'hide-alert');
+      });
+    fireEvent(this, 'action-taken');
+  }
+
+  private reportingDetails() {
+    const targetId = this.account._account_id;
+    const ownerId =
+      (this.change && this.change.owner && this.change.owner._account_id) || -1;
+    const selfId = (this.selfAccount && this.selfAccount._account_id) || -1;
+    const reviewers =
+      this.change && this.change.reviewers && this.change.reviewers.REVIEWER
+        ? [...this.change.reviewers.REVIEWER]
+        : [];
+    const reviewerIds = reviewers
+      .map(r => r._account_id)
+      .filter(rId => rId !== ownerId);
+    return {
+      actionByOwner: selfId === ownerId,
+      actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
+      targetIsOwner: targetId === ownerId,
+      targetIsReviewer: reviewerIds.includes(targetId),
+      targetIsSelf: targetId === selfId,
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-hovercard-account-contents': GrHovercardAccountContents;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
new file mode 100644
index 0000000..b217562
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
@@ -0,0 +1,386 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-hovercard-account-contents';
+import {GrHovercardAccountContents} from './gr-hovercard-account-contents';
+import {
+  mockPromise,
+  query,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {
+  AccountDetailInfo,
+  AccountId,
+  EmailAddress,
+  ReviewerState,
+} from '../../../api/rest-api';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createDetailedLabelInfo,
+} from '../../../test/test-data-generators';
+import {GrButton} from '../gr-button/gr-button';
+import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken} from '../../../models/user/user-model';
+
+suite('gr-hovercard-account-contents tests', () => {
+  let element: GrHovercardAccountContents;
+
+  const ACCOUNT: AccountDetailInfo = {
+    ...createAccountDetailWithId(31),
+    email: 'kermit@gmail.com' as EmailAddress,
+    username: 'kermit',
+    name: 'Kermit The Frog',
+    status: 'I am a frog',
+    _account_id: 31415926535 as AccountId,
+  };
+
+  setup(async () => {
+    const change = {
+      ...createChange(),
+      attention_set: {},
+      reviewers: {},
+      owner: {...ACCOUNT},
+    };
+    element = await fixture(
+      html`<gr-hovercard-account-contents .account=${ACCOUNT} .change=${change}>
+      </gr-hovercard-account-contents>`
+    );
+    testResolver(userModelToken).setAccount({...ACCOUNT});
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="top">
+          <div class="avatar">
+            <gr-avatar hidden=""></gr-avatar>
+          </div>
+          <div class="account">
+            <h3 class="heading-3 name">Kermit The Frog</h3>
+            <div class="email">kermit@gmail.com</div>
+          </div>
+        </div>
+        <gr-endpoint-decorator name="hovercard-status">
+          <gr-endpoint-param name="account"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <div class="status">
+          <span class="title">About me:</span>
+          <span class="value">I am a frog</span>
+        </div>
+        <div class="links">
+          <gr-icon icon="link" class="linkIcon"></gr-icon>
+          <a href="/q/owner:kermit%2540gmail.com">Changes</a>
+          ·
+          <a href="/dashboard/31415926535">Dashboard</a>
+        </div>
+      `
+    );
+  });
+
+  test('renders without change data', async () => {
+    const elementWithoutChange = await fixture(
+      html`<gr-hovercard-account-contents
+        .account=${ACCOUNT}
+      ></gr-hovercard-account-contents>`
+    );
+    assert.shadowDom.equal(
+      elementWithoutChange,
+      /* HTML */ `
+        <div class="top">
+          <div class="avatar">
+            <gr-avatar hidden=""></gr-avatar>
+          </div>
+          <div class="account">
+            <h3 class="heading-3 name">Kermit The Frog</h3>
+            <div class="email">kermit@gmail.com</div>
+          </div>
+        </div>
+        <gr-endpoint-decorator name="hovercard-status">
+          <gr-endpoint-param name="account"> </gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <div class="status">
+          <span class="title"> About me: </span>
+          <span class="value"> I am a frog </span>
+        </div>
+        <div class="links">
+          <gr-icon class="linkIcon" icon="link"> </gr-icon>
+          <a href="/q/owner:kermit%2540gmail.com"> Changes </a>
+          ·
+          <a href="/dashboard/31415926535"> Dashboard </a>
+        </div>
+      `
+    );
+  });
+
+  test('account name is shown', () => {
+    const name = queryAndAssert<HTMLHeadingElement>(element, '.name');
+    assert.equal(name.innerText, 'Kermit The Frog');
+  });
+
+  test('computePronoun', async () => {
+    element.account = createAccountDetailWithId(1);
+    element.selfAccount = createAccountDetailWithId(1);
+    await element.updateComplete;
+    assert.equal(element.computePronoun(), 'Your');
+    element.account = createAccountDetailWithId(2);
+    await element.updateComplete;
+    assert.equal(element.computePronoun(), 'Their');
+  });
+
+  test('account status is not shown if the property is not set', async () => {
+    element.account = {...ACCOUNT, status: undefined};
+    await element.updateComplete;
+    assert.isUndefined(query(element, '.status'));
+  });
+
+  test('account status is displayed', () => {
+    const status = queryAndAssert<HTMLSpanElement>(element, '.status .value');
+    assert.equal(status.innerText, 'I am a frog');
+  });
+
+  test('voteable div is not shown if the property is not set', () => {
+    assert.isUndefined(query(element, '.voteable'));
+  });
+
+  test('voteable div is displayed', async () => {
+    element.change = {
+      ...createChange(),
+      labels: {
+        Foo: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 2, min: 0},
+            },
+          ],
+        },
+        Bar: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createAccountDetailWithId(1),
+              permitted_voting_range: {max: 1, min: 0},
+            },
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 1, min: 0},
+            },
+          ],
+        },
+        FooBar: {
+          ...createDetailedLabelInfo(),
+          all: [{_account_id: 7 as AccountId, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    element.account = createAccountDetailWithId(1);
+
+    await element.updateComplete;
+    const voteableEl = queryAndAssert<HTMLSpanElement>(
+      element,
+      '.voteable .value'
+    );
+    assert.equal(voteableEl.innerText, 'Bar: +1');
+  });
+
+  test('remove reviewer', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    await element.updateComplete;
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Remove Reviewer');
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [ACCOUNT],
+      },
+    };
+    await element.updateComplete;
+    const saveReviewStub = stubRestApi('saveChangeReview').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
+
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move Reviewer to CC');
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('move reviewer to cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    await element.updateComplete;
+    const saveReviewStub = stubRestApi('saveChangeReview').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
+    assert.isOk(button);
+    assert.equal(button.innerText, 'Move CC to Reviewer');
+
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(saveReviewStub.called);
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('remove cc', async () => {
+    element.change = {
+      ...createChange(),
+      removable_reviewers: [ACCOUNT],
+      reviewers: {
+        [ReviewerState.REVIEWER]: [],
+      },
+    };
+    await element.updateComplete;
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+    const reloadListener = sinon.spy();
+    element.addEventListener('reload', reloadListener);
+
+    const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+
+    assert.equal(button.innerText, 'Remove CC');
+    assert.isOk(button);
+    button.click();
+    await element.updateComplete;
+    assert.isTrue(reloadListener.called);
+  });
+
+  test('add to attention set', async () => {
+    const apiPromise = mockPromise<Response>();
+    const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
+    element.highlightAttention = true;
+    await element.updateComplete;
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const updatedListener = sinon.spy();
+    element.addEventListener(EventType.SHOW_ALERT, showAlertListener);
+    element.addEventListener('hide-alert', hideAlertListener);
+    element.addEventListener('attention-set-updated', updatedListener);
+
+    const button = queryAndAssert<GrButton>(element, '.addToAttentionSet');
+    assert.isOk(button);
+    button.click();
+
+    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 1);
+    const attention_set_info = Object.values(
+      element.change?.attention_set ?? {}
+    )[0];
+    assert.equal(
+      attention_set_info.reason,
+      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.equal(
+      attention_set_info.reason_account?._account_id,
+      ACCOUNT._account_id
+    );
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isTrue(updatedListener.called, 'updatedListener was called');
+
+    apiPromise.resolve({...new Response(), ok: true});
+    await element.updateComplete;
+    assert.isTrue(apiSpy.calledOnce);
+    assert.equal(
+      apiSpy.lastCall.args[2],
+      `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+  });
+
+  test('remove from attention set', async () => {
+    const apiPromise = mockPromise<Response>();
+    const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
+    element.highlightAttention = true;
+    element.change = {
+      ...createChange(),
+      attention_set: {
+        '31415926535': {account: ACCOUNT, reason: 'a good reason'},
+      },
+      reviewers: {},
+      owner: {...ACCOUNT},
+    };
+    await element.updateComplete;
+    const showAlertListener = sinon.spy();
+    const hideAlertListener = sinon.spy();
+    const updatedListener = sinon.spy();
+    element.addEventListener(EventType.SHOW_ALERT, showAlertListener);
+    element.addEventListener('hide-alert', hideAlertListener);
+    element.addEventListener('attention-set-updated', updatedListener);
+
+    const button = queryAndAssert<GrButton>(element, '.removeFromAttentionSet');
+    assert.isOk(button);
+    button.click();
+
+    assert.isDefined(element.change?.attention_set);
+    assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 0);
+    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+    assert.isTrue(updatedListener.called, 'updatedListener was called');
+
+    apiPromise.resolve({...new Response(), ok: true});
+    await element.updateComplete;
+
+    assert.isTrue(apiSpy.calledOnce);
+    assert.equal(
+      apiSpy.lastCall.args[2],
+      `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+        ' using the hovercard menu'
+    );
+    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index e2ebb14..543f5bc 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
@@ -1,48 +1,19 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '@polymer/iron-icon/iron-icon';
 import '../gr-avatar/gr-avatar';
 import '../gr-button/gr-button';
-import {appContext} from '../../../services/app-context';
-import {accountKey, isSelf} from '../../../utils/account-util';
-import {customElement, property} from 'lit/decorators';
-import {
-  AccountInfo,
-  ChangeInfo,
-  ServerInfo,
-  ReviewInput,
-} from '../../../types/common';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {
-  canHaveAttention,
-  getAddedByReason,
-  getLastUpdate,
-  getReason,
-  getRemovedByReason,
-  hasAttention,
-} from '../../../utils/attention-set-util';
-import {ReviewerState} from '../../../constants/constants';
-import {CURRENT} from '../../../utils/patch-set-util';
-import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
-import {assertIsDefined} from '../../../utils/common-util';
-import {fontStyles} from '../../../styles/gr-font-styles';
-import {css, html, LitElement} from 'lit';
+import '../gr-icon/gr-icon';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {customElement, property} from 'lit/decorators.js';
+import {AccountInfo, ChangeInfo} from '../../../types/common';
+import {html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {when} from 'lit/directives/when.js';
+import './gr-hovercard-account-contents';
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = HovercardMixin(LitElement);
@@ -52,9 +23,6 @@
   @property({type: Object})
   account!: AccountInfo;
 
-  @property({type: Object})
-  _selfAccount?: AccountInfo;
-
   /**
    * Optional ChangeInfo object, typically comes from the change page or
    * from a row in a list of search results. This is needed for some change
@@ -64,13 +32,6 @@
   change?: ChangeInfo;
 
   /**
-   * Explains which labels the user can vote on and which score they can
-   * give.
-   */
-  @property({type: String})
-  voteableText?: string;
-
-  /**
    * Should attention set related features be shown in the component? Note
    * that the information whether the user is in the attention set or not is
    * part of the ChangeInfo object in the change property.
@@ -78,422 +39,30 @@
   @property({type: Boolean})
   highlightAttention = false;
 
-  @property({type: Object})
-  _config?: ServerInfo;
-
-  reporting: ReportingService;
-
-  private readonly restApiService = appContext.restApiService;
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  override connectedCallback() {
-    super.connectedCallback();
-    this.restApiService.getConfig().then(config => {
-      this._config = config;
-    });
-    this.restApiService.getAccount().then(account => {
-      this._selfAccount = account;
-    });
-  }
-
-  static override get styles() {
-    return [
-      fontStyles,
-      base.styles || [],
-      css`
-        .top,
-        .attention,
-        .status,
-        .voteable {
-          padding: var(--spacing-s) var(--spacing-l);
-        }
-        .top {
-          display: flex;
-          padding-top: var(--spacing-xl);
-          min-width: 300px;
-        }
-        gr-avatar {
-          height: 48px;
-          width: 48px;
-          margin-right: var(--spacing-l);
-        }
-        .title,
-        .email {
-          color: var(--deemphasized-text-color);
-        }
-        .action {
-          border-top: 1px solid var(--border-color);
-          padding: var(--spacing-s) var(--spacing-l);
-          --gr-button-padding: var(--spacing-s) var(--spacing-m);
-        }
-        .attention {
-          background-color: var(--emphasis-color);
-        }
-        .attention a {
-          text-decoration: none;
-        }
-        iron-icon {
-          vertical-align: top;
-        }
-        .status iron-icon {
-          width: 14px;
-          height: 14px;
-          position: relative;
-          top: 2px;
-        }
-        iron-icon.attentionIcon {
-          width: 14px;
-          height: 14px;
-          position: relative;
-          top: 3px;
-        }
-        .reason {
-          padding-top: var(--spacing-s);
-        }
-      `,
-    ];
-  }
-
   override render() {
     return html`
       <div id="container" role="tooltip" tabindex="-1">
-        ${this.renderContent()}
+        ${when(
+          this._isShowing,
+          () =>
+            html`<gr-hovercard-account-contents
+              .account=${this.account}
+              .change=${this.change}
+              .highlightAttention=${this.highlightAttention}
+              @link-clicked=${this.forceHide}
+              @action-taken=${this.mouseHide}
+              @attention-set-updated=${this.redirectEventToTarget}
+              @hide-alert=${this.redirectEventToTarget}
+              @show-alert=${this.redirectEventToTarget}
+              @reload=${this.redirectEventToTarget}
+            ></gr-hovercard-account-contents>`
+        )}
       </div>
     `;
   }
 
-  private renderContent() {
-    if (!this._isShowing) return;
-    return html`
-      <div class="top">
-        <div class="avatar">
-          <gr-avautar .account=${this.account} imageSize="56"></gr-avatar>
-        </div>
-        <div class="account">
-          <h3 class="name heading-3">${this.account.name}</h3>
-          <div class="email">${this.account.email}</div>
-        </div>
-      </div>
-      ${this.renderAccountStatus()}
-      ${
-        this.voteableText
-          ? html`
-              <div class="voteable">
-                <span class="title">Voteable:</span>
-                <span class="value">${this.voteableText}</span>
-              </div>
-            `
-          : ''
-      }
-      ${this.renderNeedsAttention()} ${this.renderAddToAttention()}
-      ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()}
-    `;
-  }
-
-  private renderReviewerOrCcActions() {
-    if (!this._selfAccount || !isRemovableReviewer(this.change, this.account))
-      return;
-    return html`
-      <div class="action">
-        <gr-button
-          class="removeReviewerOrCC"
-          link=""
-          no-uppercase
-          @click="${this.handleRemoveReviewerOrCC}"
-        >
-          Remove ${this.computeReviewerOrCCText()}
-        </gr-button>
-      </div>
-      <div class="action">
-        <gr-button
-          class="changeReviewerOrCC"
-          link=""
-          no-uppercase
-          @click="${this.handleChangeReviewerOrCCStatus}"
-        >
-          ${this.computeChangeReviewerOrCCText()}
-        </gr-button>
-      </div>
-    `;
-  }
-
-  private renderAccountStatus() {
-    if (!this.account.status) return;
-    return html`
-      <div class="status">
-        <span class="title">
-          <iron-icon icon="gr-icons:unavailable"></iron-icon>
-          Status:
-        </span>
-        <span class="value">${this.account.status}</span>
-      </div>
-    `;
-  }
-
-  private renderNeedsAttention() {
-    if (!(this.isAttentionEnabled && this.hasUserAttention)) return;
-    const lastUpdate = getLastUpdate(this.account, this.change);
-    return html`
-      <div class="attention">
-        <div>
-          <iron-icon
-            class="attentionIcon"
-            icon="gr-icons:attention"
-          ></iron-icon>
-          <span> ${this.computePronoun()} turn to take this action. </span>
-          <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
-            target="_blank"
-          >
-            <iron-icon
-              icon="gr-icons:help-outline"
-              title="read documentation"
-            ></iron-icon>
-          </a>
-        </div>
-        <div class="reason">
-          <span class="title">Reason:</span>
-          <span class="value">
-            ${getReason(this._config, this.account, this.change)}
-          </span>
-          ${lastUpdate
-            ? html` (<gr-date-formatter
-                  withTooltip
-                  .dateStr="${lastUpdate}"
-                ></gr-date-formatter
-                >)`
-            : ''}
-        </div>
-      </div>
-    `;
-  }
-
-  private renderAddToAttention() {
-    if (!this.computeShowActionAddToAttentionSet()) return;
-    return html`
-      <div class="action">
-        <gr-button
-          class="addToAttentionSet"
-          link=""
-          no-uppercase
-          @click="${this.handleClickAddToAttentionSet}"
-        >
-          Add to attention set
-        </gr-button>
-      </div>
-    `;
-  }
-
-  private renderRemoveFromAttention() {
-    if (!this.computeShowActionRemoveFromAttentionSet()) return;
-    return html`
-      <div class="action">
-        <gr-button
-          class="removeFromAttentionSet"
-          link=""
-          no-uppercase
-          @click="${this.handleClickRemoveFromAttentionSet}"
-        >
-          Remove from attention set
-        </gr-button>
-      </div>
-    `;
-  }
-
-  // private but used by tests
-  computePronoun() {
-    if (!this.account || !this._selfAccount) return '';
-    return isSelf(this.account, this._selfAccount) ? 'Your' : 'Their';
-  }
-
-  get isAttentionEnabled() {
-    return (
-      !!this.highlightAttention &&
-      !!this.change &&
-      canHaveAttention(this.account)
-    );
-  }
-
-  get hasUserAttention() {
-    return hasAttention(this.account, this.change);
-  }
-
-  private getReviewerState() {
-    if (
-      this.change!.reviewers[ReviewerState.REVIEWER]?.some(
-        (reviewer: AccountInfo) =>
-          reviewer._account_id === this.account._account_id
-      )
-    ) {
-      return ReviewerState.REVIEWER;
-    }
-    return ReviewerState.CC;
-  }
-
-  private computeReviewerOrCCText() {
-    if (!this.change || !this.account) return '';
-    return this.getReviewerState() === ReviewerState.REVIEWER
-      ? 'Reviewer'
-      : 'CC';
-  }
-
-  private computeChangeReviewerOrCCText() {
-    if (!this.change || !this.account) return '';
-    return this.getReviewerState() === ReviewerState.REVIEWER
-      ? 'Move Reviewer to CC'
-      : 'Move CC to Reviewer';
-  }
-
-  private handleChangeReviewerOrCCStatus() {
-    assertIsDefined(this.change, 'change');
-    // accountKey() throws an error if _account_id & email is not found, which
-    // we want to check before showing reloading toast
-    const _accountKey = accountKey(this.account);
-    this.dispatchEventThroughTarget('show-alert', {
-      message: 'Reloading page...',
-    });
-    const reviewInput: Partial<ReviewInput> = {};
-    reviewInput.reviewers = [
-      {
-        reviewer: _accountKey,
-        state:
-          this.getReviewerState() === ReviewerState.CC
-            ? ReviewerState.REVIEWER
-            : ReviewerState.CC,
-      },
-    ];
-
-    this.restApiService
-      .saveChangeReview(this.change._number, CURRENT, reviewInput)
-      .then(response => {
-        if (!response || !response.ok) {
-          throw new Error(
-            'something went wrong when toggling' + this.getReviewerState()
-          );
-        }
-        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
-      });
-  }
-
-  private handleRemoveReviewerOrCC() {
-    if (!this.change || !(this.account?._account_id || this.account?.email))
-      throw new Error('Missing change or account.');
-    this.dispatchEventThroughTarget('show-alert', {
-      message: 'Reloading page...',
-    });
-    this.restApiService
-      .removeChangeReviewer(
-        this.change._number,
-        (this.account?._account_id || this.account?.email)!
-      )
-      .then((response: Response | undefined) => {
-        if (!response || !response.ok) {
-          throw new Error('something went wrong when removing user');
-        }
-        this.dispatchEventThroughTarget('reload', {clearPatchset: true});
-        return response;
-      });
-  }
-
-  private computeShowActionAddToAttentionSet() {
-    const involvedOrSelf =
-      isInvolved(this.change, this._selfAccount) ||
-      isSelf(this.account, this._selfAccount);
-    return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
-  }
-
-  private computeShowActionRemoveFromAttentionSet() {
-    const involvedOrSelf =
-      isInvolved(this.change, this._selfAccount) ||
-      isSelf(this.account, this._selfAccount);
-    return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
-  }
-
-  private handleClickAddToAttentionSet(e: MouseEvent) {
-    if (!this.change || !this.account._account_id) return;
-    this.dispatchEventThroughTarget('show-alert', {
-      message: 'Saving attention set update ...',
-      dismissOnNavigation: true,
-    });
-
-    // We are deliberately updating the UI before making the API call. It is a
-    // risk that we are taking to achieve a better UX for 99.9% of the cases.
-    const reason = getAddedByReason(this._selfAccount, this._config);
-
-    if (!this.change.attention_set) this.change.attention_set = {};
-    this.change.attention_set[this.account._account_id] = {
-      account: this.account,
-      reason,
-      reason_account: this._selfAccount,
-    };
-    this.dispatchEventThroughTarget('attention-set-updated');
-
-    this.reporting.reportInteraction(
-      'attention-hovercard-add',
-      this.reportingDetails()
-    );
-    this.restApiService
-      .addToAttentionSet(this.change._number, this.account._account_id, reason)
-      .then(() => {
-        this.dispatchEventThroughTarget('hide-alert');
-      });
-    this.hide(e);
-  }
-
-  private handleClickRemoveFromAttentionSet(e: MouseEvent) {
-    if (!this.change || !this.account._account_id) return;
-    this.dispatchEventThroughTarget('show-alert', {
-      message: 'Saving attention set update ...',
-      dismissOnNavigation: true,
-    });
-
-    // We are deliberately updating the UI before making the API call. It is a
-    // risk that we are taking to achieve a better UX for 99.9% of the cases.
-
-    const reason = getRemovedByReason(this._selfAccount, this._config);
-    if (this.change.attention_set)
-      delete this.change.attention_set[this.account._account_id];
-    this.dispatchEventThroughTarget('attention-set-updated');
-
-    this.reporting.reportInteraction(
-      'attention-hovercard-remove',
-      this.reportingDetails()
-    );
-    this.restApiService
-      .removeFromAttentionSet(
-        this.change._number,
-        this.account._account_id,
-        reason
-      )
-      .then(() => {
-        this.dispatchEventThroughTarget('hide-alert');
-      });
-    this.hide(e);
-  }
-
-  private reportingDetails() {
-    const targetId = this.account._account_id;
-    const ownerId =
-      (this.change && this.change.owner && this.change.owner._account_id) || -1;
-    const selfId = (this._selfAccount && this._selfAccount._account_id) || -1;
-    const reviewers =
-      this.change && this.change.reviewers && this.change.reviewers.REVIEWER
-        ? [...this.change.reviewers.REVIEWER]
-        : [];
-    const reviewerIds = reviewers
-      .map(r => r._account_id)
-      .filter(rId => rId !== ownerId);
-    return {
-      actionByOwner: selfId === ownerId,
-      actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
-      targetIsOwner: targetId === ownerId,
-      targetIsReviewer: reviewerIds.includes(targetId),
-      targetIsSelf: targetId === selfId,
-    };
+  private redirectEventToTarget(e: CustomEvent<unknown>) {
+    this.dispatchEventThroughTarget(e.type, e.detail);
   }
 }
 
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
deleted file mode 100644
index 5530d7c..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ /dev/null
@@ -1,255 +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 './gr-hovercard-account.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {ReviewerState} from '../../../constants/constants.js';
-import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-hovercard-account class="hovered"></gr-hovercard-account>
-`);
-
-suite('gr-hovercard-account tests', () => {
-  let element;
-
-  const ACCOUNT = {
-    email: 'kermit@gmail.com',
-    username: 'kermit',
-    name: 'Kermit The Frog',
-    _account_id: '31415926535',
-  };
-
-  setup(async () => {
-    stubRestApi('getAccount').returns(Promise.resolve({...ACCOUNT}));
-    element = basicFixture.instantiate();
-    element.account = {...ACCOUNT};
-    element.change = {
-      attention_set: {},
-      reviewers: {},
-      owner: {...ACCOUNT},
-    };
-    element.show({});
-    await flush();
-  });
-
-  teardown(() => {
-    element.hide({});
-  });
-
-  test('account name is shown', () => {
-    assert.equal(element.shadowRoot.querySelector('.name').innerText,
-        'Kermit The Frog');
-  });
-
-  test('computePronoun', () => {
-    element.account = {_account_id: '1'};
-    element._selfAccount = {_account_id: '1'};
-    assert.equal(element.computePronoun(), 'Your');
-    element.account = {_account_id: '2'};
-    assert.equal(element.computePronoun(), 'Their');
-  });
-
-  test('account status is not shown if the property is not set', () => {
-    assert.isNull(element.shadowRoot.querySelector('.status'));
-  });
-
-  test('account status is displayed', async () => {
-    element.account = {status: 'OOO', ...ACCOUNT};
-    await element.updateComplete;
-    assert.equal(element.shadowRoot.querySelector('.status .value').innerText,
-        'OOO');
-  });
-
-  test('voteable div is not shown if the property is not set', () => {
-    assert.isNull(element.shadowRoot.querySelector('.voteable'));
-  });
-
-  test('voteable div is displayed', async () => {
-    element.voteableText = 'CodeReview: +2';
-    await element.updateComplete;
-    assert.equal(element.shadowRoot.querySelector('.voteable .value').innerText,
-        element.voteableText);
-  });
-
-  test('remove reviewer', async () => {
-    element.change = {
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [ACCOUNT],
-      },
-    };
-    await element.updateComplete;
-    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-    const reloadListener = sinon.spy();
-    element._target.addEventListener('reload', reloadListener);
-    const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Remove Reviewer');
-    MockInteractions.tap(button);
-    await element.updateComplete;
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('move reviewer to cc', async () => {
-    element.change = {
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [ACCOUNT],
-      },
-    };
-    await element.updateComplete;
-    const saveReviewStub = stubRestApi(
-        'saveChangeReview').returns(
-        Promise.resolve({ok: true}));
-    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-    const reloadListener = sinon.spy();
-    element._target.addEventListener('reload', reloadListener);
-
-    const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
-
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Move Reviewer to CC');
-    MockInteractions.tap(button);
-    await element.updateComplete;
-    assert.isTrue(saveReviewStub.called);
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('move reviewer to cc', async () => {
-    element.change = {
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [],
-      },
-    };
-    await element.updateComplete;
-    const saveReviewStub = stubRestApi(
-        'saveChangeReview').returns(Promise.resolve({ok: true}));
-    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-    const reloadListener = sinon.spy();
-    element._target.addEventListener('reload', reloadListener);
-
-    const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
-    assert.isOk(button);
-    assert.equal(button.innerText, 'Move CC to Reviewer');
-
-    MockInteractions.tap(button);
-    await element.updateComplete;
-    assert.isTrue(saveReviewStub.called);
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('remove cc', async () => {
-    element.change = {
-      removable_reviewers: [ACCOUNT],
-      reviewers: {
-        [ReviewerState.REVIEWER]: [],
-      },
-    };
-    await element.updateComplete;
-    stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
-    const reloadListener = sinon.spy();
-    element._target.addEventListener('reload', reloadListener);
-
-    const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
-
-    assert.equal(button.innerText, 'Remove CC');
-    assert.isOk(button);
-    MockInteractions.tap(button);
-    await element.updateComplete;
-    assert.isTrue(reloadListener.called);
-  });
-
-  test('add to attention set', async () => {
-    const apiPromise = mockPromise();
-    const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
-    element.highlightAttention = true;
-    element._target = document.createElement('div');
-    await element.updateComplete;
-    const showAlertListener = sinon.spy();
-    const hideAlertListener = sinon.spy();
-    const updatedListener = sinon.spy();
-    element._target.addEventListener('show-alert', showAlertListener);
-    element._target.addEventListener('hide-alert', hideAlertListener);
-    element._target.addEventListener('attention-set-updated', updatedListener);
-
-    const button = element.shadowRoot.querySelector('.addToAttentionSet');
-    assert.isOk(button);
-    assert.isTrue(element._isShowing, 'hovercard is showing');
-    MockInteractions.tap(button);
-
-    assert.equal(Object.keys(element.change.attention_set).length, 1);
-    const attention_set_info = Object.values(element.change.attention_set)[0];
-    assert.equal(attention_set_info.reason,
-        `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>`
-        + ` using the hovercard menu`);
-    assert.equal(attention_set_info.reason_account._account_id,
-        ACCOUNT._account_id);
-    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
-    assert.isTrue(updatedListener.called, 'updatedListener was called');
-    assert.isFalse(element._isShowing, 'hovercard is hidden');
-
-    apiPromise.resolve({});
-    await element.updateComplete;
-    assert.isTrue(apiSpy.calledOnce);
-    assert.equal(apiSpy.lastCall.args[2],
-        `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>`
-        + ` using the hovercard menu`);
-    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
-  });
-
-  test('remove from attention set', async () => {
-    const apiPromise = mockPromise();
-    const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
-    element.highlightAttention = true;
-    element.change = {
-      attention_set: {31415926535: {}},
-      reviewers: {},
-      owner: {...ACCOUNT},
-    };
-    element._target = document.createElement('div');
-    await element.updateComplete;
-    const showAlertListener = sinon.spy();
-    const hideAlertListener = sinon.spy();
-    const updatedListener = sinon.spy();
-    element._target.addEventListener('show-alert', showAlertListener);
-    element._target.addEventListener('hide-alert', hideAlertListener);
-    element._target.addEventListener('attention-set-updated', updatedListener);
-
-    const button = element.shadowRoot.querySelector('.removeFromAttentionSet');
-    assert.isOk(button);
-    assert.isTrue(element._isShowing, 'hovercard is showing');
-    MockInteractions.tap(button);
-
-    assert.equal(Object.keys(element.change.attention_set).length, 0);
-    assert.isTrue(showAlertListener.called, 'showAlertListener was called');
-    assert.isTrue(updatedListener.called, 'updatedListener was called');
-    assert.isFalse(element._isShowing, 'hovercard is hidden');
-
-    apiPromise.resolve({});
-    await element.updateComplete;
-
-    assert.isTrue(apiSpy.calledOnce);
-    assert.equal(apiSpy.lastCall.args[2],
-        `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>`
-        + ` using the hovercard menu`);
-    assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
new file mode 100644
index 0000000..40e4c75
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-hovercard-account';
+import {GrHovercardAccount} from './gr-hovercard-account';
+import {queryAndAssert} from '../../../test/test-utils';
+import {
+  createAccountDetailWithId,
+  createChange,
+} from '../../../test/test-data-generators';
+import {GrButton} from '../gr-button/gr-button';
+import {GrHovercardAccountContents} from './gr-hovercard-account-contents';
+import {userModelToken} from '../../../models/user/user-model';
+import {testResolver} from '../../../test/common-test-setup';
+
+suite('gr-hovercard-account tests', () => {
+  let element: GrHovercardAccount;
+  let contents: GrHovercardAccountContents;
+
+  setup(async () => {
+    const account = createAccountDetailWithId(31);
+    element = await fixture<GrHovercardAccount>(
+      html`<gr-hovercard-account
+        class="hovered"
+        .account=${account}
+        .change=${createChange()}
+        .highlightAttention=${true}
+      >
+      </gr-hovercard-account>`
+    );
+    await element.show({});
+    testResolver(userModelToken).setAccount({...account});
+    await element.updateComplete;
+    contents = queryAndAssert(element, 'gr-hovercard-account-contents');
+  });
+
+  teardown(async () => {
+    element.mouseHide(new MouseEvent('click'));
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container" role="tooltip" tabindex="-1">
+          <gr-hovercard-account-contents></gr-hovercard-account-contents>
+        </div>
+      `
+    );
+  });
+
+  test('hides when links are clicked', () => {
+    const changesLink = queryAndAssert<HTMLAnchorElement>(contents, 'a');
+    // Actually redirecting will break the test, replace URL with no-op
+    changesLink.href = 'javascript:';
+
+    assert.isTrue(element._isShowing);
+
+    changesLink.click();
+
+    assert.isFalse(element._isShowing);
+  });
+
+  test('hides when actions are performed', () => {
+    assert.isTrue(element._isShowing);
+
+    queryAndAssert<GrButton>(contents, 'gr-button.addToAttentionSet').click();
+
+    assert.isFalse(element._isShowing);
+  });
+});
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 bf35c06..b15a49e 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {customElement} from 'lit/decorators';
+import {customElement} from 'lit/decorators.js';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
 import {css, html, LitElement} from 'lit';
 
diff --git a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
new file mode 100644
index 0000000..a2d70bc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-icon': GrIcon;
+  }
+}
+
+/**
+ * A material icon.  The advantage of using gr-icon over a native span is that
+ * gr-icon uses :host::before trick to avoid that the icon name shows up in
+ * chrome search.
+ * TODO: Improve type-checking by restricting which strings can be passed into
+ * `icon`.
+ *
+ * @attr {String} icon - the icon to display
+ * @attr {Boolean} filled - whether the icon should be filled
+ * @attr {Boolean} small - whether the icon should be smaller than usual
+ */
+@customElement('gr-icon')
+export class GrIcon extends LitElement {
+  @property({type: String, reflect: true})
+  icon?: string;
+
+  @property({type: Boolean, reflect: true})
+  filled?: boolean;
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          /* Fallback rule for color */
+          color: var(--deemphasized-text-color);
+          font-family: var(--icon-font-family, 'Material Symbols Outlined');
+          font-weight: normal;
+          font-style: normal;
+          font-size: 20px;
+          line-height: 1;
+          letter-spacing: normal;
+          text-transform: none;
+          display: inline-block;
+          white-space: nowrap;
+          word-wrap: normal;
+          direction: ltr;
+          -webkit-font-feature-settings: 'liga';
+          -webkit-font-smoothing: antialiased;
+          font-variation-settings: 'FILL' 0;
+          vertical-align: top;
+        }
+        :host([small]) {
+          font-size: 16px;
+          position: relative;
+          top: 2px;
+        }
+        :host([filled]) {
+          font-variation-settings: 'FILL' 1;
+        }
+        /* This is the trick such that the name of the icon doesn't appear in
+         * search
+         */
+        :host::before {
+          content: attr(icon);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html``;
+  }
+}
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 2533e00..89c8770 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-icon/iron-icon';
 import '@polymer/iron-iconset-svg/iron-iconset-svg';
@@ -21,149 +10,12 @@
 $_documentContainer.innerHTML = `<iron-iconset-svg name="gr-icons" size="24">
   <svg>
     <defs>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#unfold_more -->
-      <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="create"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="more-horiz"><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
-      <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
-      <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/resources/icons/?icon=help_outline -->
-      <g id="help-outline"><path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="info-outline"><path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
-      <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
-      <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#calendar_today-->
-      <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <g id="lightbulb-outline"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"></path></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
-      <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 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 -->
-      <g id="abandon"><path d="M17.65675,17.65725 C14.77275,20.54125 10.23775,20.75625 7.09875,18.31525 L18.31475,7.09925 C20.75575,10.23825 20.54075,14.77325 17.65675,17.65725 M6.34275,6.34325 C9.22675,3.45925 13.76275,3.24425 16.90075,5.68525 L5.68475,16.90125 C3.24375,13.76325 3.45875,9.22725 6.34275,6.34325 M19.07075,4.92925 C15.16575,1.02425 8.83375,1.02425 4.92875,4.92925 C1.02375,8.83425 1.02375,15.16625 4.92875,19.07125 C8.83375,22.97625 15.16575,22.97625 19.07075,19.07125 C22.97575,15.16625 22.97575,8.83425 19.07075,4.92925"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="edit"><path d="M3,17.2525 L3,21.0025 L6.75,21.0025 L17.81,9.9425 L14.06,6.1925 L3,17.2525 L3,17.2525 Z M20.71,7.0425 C21.1,6.6525 21.1,6.0225 20.71,5.6325 L18.37,3.2925 C17.98,2.9025 17.35,2.9025 16.96,3.2925 L15.13,5.1225 L18.88,8.8725 L20.71,7.0425 L20.71,7.0425 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="rebase"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="rebaseEdit"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="restore"><path d="M12,8 L12,13 L16.28,15.54 L17,14.33 L13.5,12.25 L13.5,8 L12,8 Z M13,3 C8.03,3 4,7.03 4,12 L1,12 L4.89,15.89 L4.96,16.03 L9,12 L6,12 C6,8.13 9.13,5 13,5 C16.87,5 20,8.13 20,12 C20,15.87 16.87,19 13,19 C11.07,19 9.32,18.21 8.06,16.94 L6.64,18.36 C8.27,19.99 10.51,21 13,21 C17.97,21 22,16.97 22,12 C22,7.03 17.97,3 13,3 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="revert"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
-      <g id="revert_submission"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="stopEdit"><path d="M4 4 20 4 20 20 4 20z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="submit"><path d="M22.23,5 L11.65,15.58 L7.47000001,11.41 L6.06000001,12.82 L11.65,18.41 L23.649,6.41 L22.23,5 Z M16.58,5 L10.239,11.34 L11.65,12.75 L17.989,6.41 L16.58,5 Z M0.400000006,12.82 L5.99000001,18.41 L7.40000001,17 L1.82000001,11.41 L0.400000006,12.82 Z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This is a custom PolyGerrit SVG -->
-      <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
-      <!-- This SVG is an adaptation of material.io https://material.io/icons/#label_important-->
-      <g id="attention"><path d="M1 23 l13 0 c.67 0 1.27 -.33 1.63 -.84 l7.37 -10.16 l-7.37 -10.16 c-.36 -.51 -.96 -.84 -1.63 -.84 L1 1 L7 12 z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#pets-->
-      <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#visibility-->
-      <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
-      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
-      <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/#swap_horiz-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=swap_horiz-->
       <g id="swapHoriz"><path d="M0 0h24v24H0z" fill="none"/><path d="M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#link-->
-      <g id="link"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aplay_arrow-->
       <g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Apause-->
       <g id="pause"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Acode-->
-      <g id="code"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Afile_present-->
-      <g id="file-present"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V7l-5-5zM6 20V4h8v4h4v12H6zm10-10v5c0 2.21-1.79 4-4 4s-4-1.79-4-4V8.5c0-1.47 1.26-2.64 2.76-2.49 1.3.13 2.24 1.32 2.24 2.63V15h-2V8.5c0-.28-.22-.5-.5-.5s-.5.22-.5.5V15c0 1.1.9 2 2 2s2-.9 2-2v-5h2z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aarrow_forward-->
-      <g id="arrow-forward"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:feedback -->
-      <g id="feedback"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=description -->
-      <g id="description"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=settings_backup_restore and 0.65 scale and 4 translate https://fonts.google.com/icons?selected=Material+Icons&icon.query=done-->
-      <g id="overridden"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 15 zM2 4v6h6V8H5.09C6.47 5.61 9.04 4 12 4c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8H2c0 5.52 4.48 10 10.01 10C17.53 22 22 17.52 22 12S17.53 2 12.01 2C8.73 2 5.83 3.58 4 6.01V4H2z"/><path xmlns="http://www.w3.org/2000/svg" d="M9.85 14.53 7.12 11.8l-.91.91L9.85 16.35 17.65 8.55l-.91-.91L9.85 14.53z"/></g>
-      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:event_busy -->
-      <g id="unavailable"><path d="M0 0h24v24H0z" fill="none"/><path d="M9.31 17l2.44-2.44L14.19 17l1.06-1.06-2.44-2.44 2.44-2.44L14.19 10l-2.44 2.44L9.31 10l-1.06 1.06 2.44 2.44-2.44 2.44L9.31 17zM19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 82c7118..4b5913c 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
@@ -1,142 +1,27 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {DiffLayer, DiffLayerListener} from '../../../types/types';
-import {Side} from '../../../constants/constants';
-import {EventType, PluginApi} from '../../../api/plugin';
-import {appContext} from '../../../services/app-context';
 import {AnnotationPluginApi, CoverageProvider} from '../../../api/annotation';
+import {PluginApi} from '../../../api/plugin';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 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;
-
-  private readonly reporting = appContext.reportingService;
-
-  constructor(private readonly plugin: PluginApi) {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly pluginsModel: PluginsModel,
+    private readonly plugin: PluginApi
+  ) {
     this.reporting.trackApi(this.plugin, 'annotation', 'constructor');
-    plugin.on(EventType.ANNOTATE_DIFF, this);
   }
 
-  setCoverageProvider(
-    coverageProvider: CoverageProvider
-  ): GrAnnotationActionsInterface {
+  setCoverageProvider(provider: CoverageProvider) {
     this.reporting.trackApi(this.plugin, 'annotation', 'setCoverageProvider');
-    if (this.coverageProvider) {
-      this.reporting.error(
-        new Error(`Overwriting cov provider: ${this.plugin.getPluginName()}`)
-      );
-    }
-    this.coverageProvider = coverageProvider;
-    return this;
-  }
-
-  /**
-   * Used by Gerrit to look up the coverage provider. Not intended to be called
-   * by plugins.
-   */
-  getCoverageProvider() {
-    return this.coverageProvider;
-  }
-
-  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.
-      if (annotationLayer.path === path) {
-        annotationLayer.notifyListeners(start, end, side);
-      }
-    }
-  }
-
-  /**
-   * Factory method called by Gerrit for creating a DiffLayer for each diff that
-   * is rendered.
-   *
-   * Don't forget to also call disposeLayer().
-   */
-  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
-    );
-  }
-}
-
-/**
- * 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[] = [];
-
-  /**
-   * Used to create an instance of the Annotation Layer interface.
-   *
-   * @param path The file path (eg: /COMMIT_MSG').
-   */
-  constructor(readonly path: string) {
-    this.listeners = [];
-  }
-
-  /**
-   * Register a listener for layer updates.
-   * Don't forget to removeListener when you stop using layer.
-   *
-   * @param fn The update handler function.
-   * Should accept as arguments the line numbers for the start and end of
-   * the update and the side as a string.
-   */
-  addListener(listener: DiffLayerListener) {
-    this.listeners.push(listener);
-  }
-
-  removeListener(listener: DiffLayerListener) {
-    this.listeners = this.listeners.filter(f => f !== listener);
-  }
-
-  annotate() {}
-
-  /**
-   * 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.
-   * @param side The side of the update. ('left' or 'right')
-   */
-  notifyListeners(start: number, end: number, side: Side) {
-    for (const listener of this.listeners) {
-      listener(start, end, side);
-    }
+    this.pluginsModel.coverageRegister({
+      pluginName: this.plugin.getPluginName(),
+      provider,
+    });
   }
 }
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
deleted file mode 100644
index 996edf3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ /dev/null
@@ -1,95 +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 '../../change/gr-change-actions/gr-change-actions.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-annotation-actions-js-api tests', () => {
-  let annotationActions;
-
-  let plugin;
-
-  setup(() => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    annotationActions = plugin.annotationApi();
-  });
-
-  teardown(() => {
-    annotationActions = null;
-  });
-
-  test('add notifier', () => {
-    const path1 = '/dummy/path1';
-    const path2 = '/dummy/path2';
-    const annotationLayer1 = annotationActions.createLayer(path1, 1);
-    const annotationLayer2 = annotationActions.createLayer(path2, 1);
-    const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
-    const layer2Spy = sinon.spy(annotationLayer2, 'notifyListeners');
-
-    // Assert that no layers are invoked with a different path.
-    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.
-    annotationActions.notify(path1, 0, 10, 'right');
-    assert.isTrue(layer1Spy.called);
-    assert.isFalse(layer2Spy.called);
-
-    // Reset spies.
-    layer1Spy.resetHistory();
-    layer2Spy.resetHistory();
-
-    // Assert that only the 2nd layer is invoked with path2.
-    annotationActions.notify(path2, 0, 20, 'left');
-    assert.isFalse(layer1Spy.called);
-    assert.isTrue(layer2Spy.called);
-  });
-
-  test('layer notify listeners', () => {
-    const annotationLayer = annotationActions.createLayer('/dummy/path', 1);
-    let listenerCalledTimes = 0;
-    const startRange = 10;
-    const endRange = 20;
-    const side = 'right';
-    const listener = (st, end, s) => {
-      listenerCalledTimes++;
-      assert.equal(st, startRange);
-      assert.equal(end, endRange);
-      assert.equal(s, side);
-    };
-
-    // Notify with 0 listeners added.
-    annotationLayer.notifyListeners(startRange, endRange, side);
-    assert.equal(listenerCalledTimes, 0);
-
-    // Add 1 listener.
-    annotationLayer.addListener(listener);
-    annotationLayer.notifyListeners(startRange, endRange, side);
-    assert.equal(listenerCalledTimes, 1);
-
-    // Add 1 more listener. Total 2 listeners.
-    annotationLayer.addListener(listener);
-    annotationLayer.notifyListeners(startRange, endRange, side);
-    assert.equal(listenerCalledTimes, 3);
-  });
-});
-
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 36e5c25..0bd491c 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
@@ -1,27 +1,23 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {getBaseUrl} from '../../../utils/url-util';
 import {HttpMethod} from '../../../constants/constants';
 import {RequestPayload} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 
 export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
+export const THEME_JS = '/static/gerrit-theme.js';
+
+const THEME_NAME = 'gerrit-theme';
+
+export function isThemeFile(path: string) {
+  return path.endsWith(THEME_JS);
+}
+
 /**
  * Retrieves the name of the plugin base on the url.
  */
@@ -40,12 +36,10 @@
   if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
     pathname = url.href.replace(window.ASSETS_PATH, '');
   }
-  // Site theme is server from predefined path.
-  if (
-    ['/static/gerrit-theme.html', '/static/gerrit-theme.js'].includes(pathname)
-  ) {
-    return 'gerrit-theme';
-  } else if (!pathname.startsWith('/plugins')) {
+
+  if (isThemeFile(pathname)) return THEME_NAME;
+
+  if (!pathname.startsWith('/plugins')) {
     console.warn(
       'Plugin not being loaded from /plugins base path:',
       url.href,
@@ -56,20 +50,20 @@
 
   // Pathname should normally look like this:
   // /plugins/PLUGINNAME/static/SCRIPTNAME.js
-  // Or, for app/samples:
+  // Or:
   // /plugins/PLUGINNAME.js
   // TODO(taoalpha): guard with a regex
   return pathname.split('/')[2].split('.')[0];
 }
 
-// TODO(taoalpha): to be deprecated.
 export function send(
+  restApiService: RestApiService,
   method: HttpMethod,
   url: string,
   opt_callback?: (response: unknown) => void,
   opt_payload?: RequestPayload
 ) {
-  return appContext.restApiService
+  return restApiService
     .send(method, url, opt_payload)
     .then(response => {
       if (response.status < 200 || response.status >= 300) {
@@ -81,7 +75,7 @@
           }
         });
       } else {
-        return appContext.restApiService.getResponseObject(response);
+        return restApiService.getResponseObject(response);
       }
     })
     .then(response => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts
index 865aa20..9646bcd 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-js-api-interface';
 import {getPluginNameFromUrl} from './gr-api-utils';
+import {assert} from '@open-wc/testing';
 
 suite('gr-api-utils tests', () => {
   suite('test getPluginNameFromUrl', () => {
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 a33b145..f0143a6 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
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {PluginApi, TargetElement} from '../../../api/plugin';
 import {ActionInfo, RequireProperties} from '../../../types/common';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {
   ActionPriority,
   ActionType,
@@ -25,6 +14,8 @@
   PrimaryActionKey,
   RevisionActions,
 } from '../../../api/change-actions';
+import {PropertyDeclaration} from 'lit';
+import {JsApiService} from './gr-js-api-types';
 
 export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
   __key: string;
@@ -32,6 +23,7 @@
   __primary?: boolean;
   __type: ActionType;
   icon?: string;
+  filled?: boolean;
 }
 
 // This interface is required to avoid circular dependencies between files;
@@ -40,7 +32,6 @@
   ChangeActions: Record<string, string>;
   ActionType: Record<string, string>;
   primaryActionKeys: string[];
-  push(propName: 'primaryActionKeys', value: string): void;
   hideQuickApproveAction(): void;
   setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
   setActionPriority(
@@ -57,6 +48,11 @@
     value: UIActionInfo[T]
   ): void;
   getActionDetails(actionName: string): ActionInfo | undefined;
+  requestUpdate(
+    name?: PropertyKey,
+    oldValue?: unknown,
+    options?: PropertyDeclaration
+  ): void;
 }
 
 export class GrChangeActionsInterface implements ChangeActionsPluginApi {
@@ -68,9 +64,13 @@
 
   ActionType = ActionType;
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
-  constructor(public plugin: PluginApi, el?: GrChangeActionsElement) {
+  constructor(
+    public plugin: PluginApi,
+    private readonly jsApiService: JsApiService,
+    el?: GrChangeActionsElement
+  ) {
     this.reporting.trackApi(this.plugin, 'actions', 'constructor');
     this.setEl(el);
   }
@@ -80,7 +80,10 @@
    */
   private setEl(el?: GrChangeActionsElement) {
     if (!el) {
-      this.reporting.error(new Error('changeActions() API is not ready'));
+      this.reporting.error(
+        'GrChangeActionsInterface',
+        new Error('changeActions() API is not ready')
+      );
       return;
     }
     this.el = el;
@@ -92,7 +95,7 @@
    */
   ensureEl(): GrChangeActionsElement {
     if (!this.el) {
-      const sharedApiElement = appContext.jsApiService;
+      const sharedApiElement = this.jsApiService;
       this.setEl(
         sharedApiElement.getElement(
           TargetElement.CHANGE_ACTIONS
@@ -109,7 +112,8 @@
       return;
     }
 
-    el.push('primaryActionKeys', key);
+    el.primaryActionKeys.push(key);
+    el.requestUpdate();
   }
 
   removePrimaryActionKey(key: string) {
@@ -125,20 +129,17 @@
 
   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);
+    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);
+    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);
+    this.ensureEl().setActionHidden(type, key, hidden);
   }
 
   add(type: ActionType, label: string): string {
@@ -148,8 +149,7 @@
 
   remove(key: string) {
     this.reporting.trackApi(this.plugin, 'actions', 'remove');
-    // TODO(TS): remove return, unclear why it was written
-    return this.ensureEl().removeActionButton(key);
+    this.ensureEl().removeActionButton(key);
   }
 
   addTapListener(key: string, handler: EventListenerOrEventListenerObject) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
deleted file mode 100644
index 87f6052..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
+++ /dev/null
@@ -1,198 +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 '../../change/gr-change-actions/gr-change-actions.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {getPluginLoader} from './gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const basicFixture = fixtureFromElement('gr-change-actions');
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-change-actions-js-api-interface tests', () => {
-  let element;
-  let changeActions;
-  let plugin;
-
-  // Because deepEqual doesn’t behave in Safari.
-  function assertArraysEqual(actual, expected) {
-    assert.equal(actual.length, expected.length);
-    for (let i = 0; i < actual.length; i++) {
-      assert.equal(actual[i], expected[i]);
-    }
-  }
-
-  suite('early init', () => {
-    setup(() => {
-      resetPlugins();
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      // Mimic all plugins loaded.
-      getPluginLoader().loadPlugins([]);
-      changeActions = plugin.changeActions();
-      element = basicFixture.instantiate();
-    });
-
-    teardown(() => {
-      changeActions = null;
-      resetPlugins();
-    });
-
-    test('does not throw', ()=> {
-      assert.doesNotThrow(() => {
-        changeActions.add('change', 'foo');
-      });
-    });
-  });
-
-  suite('normal init', () => {
-    setup(() => {
-      resetPlugins();
-      element = basicFixture.instantiate();
-      sinon.stub(element, '_editStatusChanged');
-      element.change = {};
-      element._hasKnownChainState = false;
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeActions = plugin.changeActions();
-      // Mimic all plugins loaded.
-      getPluginLoader().loadPlugins([]);
-    });
-
-    teardown(() => {
-      changeActions = null;
-      resetPlugins();
-    });
-
-    test('property existence', () => {
-      const properties = [
-        'ActionType',
-        'ChangeActions',
-        'RevisionActions',
-      ];
-      for (const p of properties) {
-        assertArraysEqual(changeActions[p], element[p]);
-      }
-    });
-
-    test('add/remove primary action keys', () => {
-      element.primaryActionKeys = [];
-      changeActions.addPrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['foo']);
-      changeActions.addPrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['foo']);
-      changeActions.addPrimaryActionKey('bar');
-      assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
-      changeActions.removePrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['bar']);
-      changeActions.removePrimaryActionKey('baz');
-      assertArraysEqual(element.primaryActionKeys, ['bar']);
-      changeActions.removePrimaryActionKey('bar');
-      assertArraysEqual(element.primaryActionKeys, []);
-    });
-
-    test('action buttons', () => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      const handler = sinon.spy();
-      changeActions.addTapListener(key, handler);
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]'));
-      assert(handler.calledOnce);
-      changeActions.removeTapListener(key, handler);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]'));
-      assert(handler.calledOnce);
-      changeActions.remove(key);
-      flush();
-      assert.isNull(element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]'));
-    });
-
-    test('action button properties', async () => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush();
-      const button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isOk(button);
-      assert.equal(button.getAttribute('data-label'), 'Bork!');
-      assert.isNotOk(button.disabled);
-      changeActions.setLabel(key, 'Yo');
-      changeActions.setTitle(key, 'Yo hint');
-      changeActions.setEnabled(key, false);
-      changeActions.setIcon(key, 'pupper');
-      await flush();
-      assert.equal(button.getAttribute('data-label'), 'Yo');
-      assert.equal(button.parentElement.getAttribute('title'), 'Yo hint');
-      assert.isTrue(button.disabled);
-      assert.equal(button.querySelector('iron-icon').icon,
-          'gr-icons:pupper');
-    });
-
-    test('hide action buttons', async () => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      await flush();
-      let button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isOk(button);
-      assert.isFalse(button.hasAttribute('hidden'));
-      changeActions.setActionHidden(
-          changeActions.ActionType.REVISION, key, true);
-      flush();
-      button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isNotOk(button);
-    });
-
-    test('move action button to overflow', async () => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      await flush();
-      assert.isTrue(element.$.moreActions.hidden);
-      assert.isOk(element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]'));
-      changeActions.setActionOverflow(
-          changeActions.ActionType.REVISION, key, true);
-      await flush();
-      assert.isNotOk(element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]'));
-      assert.isFalse(element.$.moreActions.hidden);
-      assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
-    });
-
-    test('change actions priority', () => {
-      const key1 =
-        changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      const key2 =
-        changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
-      flush();
-      let buttons =
-        element.root.querySelectorAll('[data-action-key]');
-      assert.equal(buttons[0].getAttribute('data-action-key'), key1);
-      assert.equal(buttons[1].getAttribute('data-action-key'), key2);
-      changeActions.setActionPriority(
-          changeActions.ActionType.REVISION, key1, 10);
-      flush();
-      buttons =
-        element.root.querySelectorAll('[data-action-key]');
-      assert.equal(buttons[0].getAttribute('data-action-key'), key2);
-      assert.equal(buttons[1].getAttribute('data-action-key'), key1);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
new file mode 100644
index 0000000..ae93977
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../../change/gr-change-actions/gr-change-actions';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
+import {fixture, html, assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {
+  ActionType,
+  ChangeActionsPluginApi,
+  PrimaryActionKey,
+} from '../../../api/change-actions';
+import {GrButton} from '../gr-button/gr-button';
+import {ChangeViewChangeInfo} from '../../../types/common';
+import {GrDropdown} from '../gr-dropdown/gr-dropdown';
+import {GrIcon} from '../gr-icon/gr-icon';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from './gr-plugin-loader';
+
+suite('gr-change-actions-js-api-interface tests', () => {
+  let element: GrChangeActions;
+  let changeActions: ChangeActionsPluginApi;
+  let plugin: PluginApi;
+
+  suite('early init', () => {
+    setup(async () => {
+      window.Gerrit.install(
+        p => {
+          plugin = p;
+        },
+        '0.1',
+        'http://test.com/plugins/testplugin/static/test.js'
+      );
+      // Mimic all plugins loaded.
+      testResolver(pluginLoaderToken).loadPlugins([]);
+      changeActions = plugin.changeActions();
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
+    });
+
+    test('does not throw', () => {
+      assert.doesNotThrow(() => {
+        changeActions.add(ActionType.CHANGE, 'foo');
+      });
+    });
+  });
+
+  suite('normal init', () => {
+    setup(async () => {
+      element = await fixture<GrChangeActions>(html`
+        <gr-change-actions></gr-change-actions>
+      `);
+      element.change = {} as ChangeViewChangeInfo;
+      element._hasKnownChainState = false;
+      window.Gerrit.install(
+        p => {
+          plugin = p;
+        },
+        '0.1',
+        'http://test.com/plugins/testplugin/static/test.js'
+      );
+      changeActions = plugin.changeActions();
+      // Mimic all plugins loaded.
+      testResolver(pluginLoaderToken).loadPlugins([]);
+    });
+
+    test('property existence', () => {
+      const properties = ['ActionType', 'ChangeActions', 'RevisionActions'];
+      for (const p of properties) {
+        // Have to type as any to prevent 'has no index signature.'
+        assert.deepEqual((changeActions as any)[p], (element as any)[p]);
+      }
+    });
+
+    test('add/remove primary action keys', () => {
+      element.primaryActionKeys = [];
+      changeActions.addPrimaryActionKey('foo' as PrimaryActionKey);
+      assert.deepEqual(element.primaryActionKeys, ['foo']);
+      changeActions.addPrimaryActionKey('bar' as PrimaryActionKey);
+      assert.deepEqual(element.primaryActionKeys, ['foo', 'bar']);
+      changeActions.removePrimaryActionKey('foo');
+      assert.deepEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('baz');
+      assert.deepEqual(element.primaryActionKeys, ['bar']);
+      changeActions.removePrimaryActionKey('bar');
+      assert.deepEqual(element.primaryActionKeys, []);
+    });
+
+    test('action buttons', async () => {
+      const key = changeActions.add(ActionType.REVISION, 'Bork!');
+      const handler = sinon.spy();
+      changeActions.addTapListener(key, handler);
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`).click();
+      await element.updateComplete;
+      assert(handler.calledOnce);
+      changeActions.removeTapListener(key, handler);
+      await element.updateComplete;
+      queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`).click();
+      await element.updateComplete;
+      assert(handler.calledOnce);
+      changeActions.remove(key);
+      await element.updateComplete;
+      assert.isUndefined(
+        query<GrButton>(element, `[data-action-key="${key}"]`)
+      );
+    });
+
+    test('action button properties', async () => {
+      const key = changeActions.add(ActionType.REVISION, 'Bork!');
+      await element.updateComplete;
+      const button = queryAndAssert<GrButton>(
+        element,
+        `[data-action-key="${key}"]`
+      );
+      assert.isOk(button);
+      assert.equal(button.getAttribute('data-label'), 'Bork!');
+      assert.isNotOk(button.disabled);
+      changeActions.setLabel(key, 'Yo');
+      changeActions.setTitle(key, 'Yo hint');
+      changeActions.setEnabled(key, false);
+      changeActions.setIcon(key, 'hive');
+      await element.updateComplete;
+      assert.equal(button.getAttribute('data-label'), 'Yo');
+      assert.equal(button.parentElement!.getAttribute('title'), 'Yo hint');
+      assert.isTrue(button.disabled);
+      assert.equal(queryAndAssert<GrIcon>(button, 'gr-icon').icon, 'hive');
+    });
+
+    test('hide action buttons', async () => {
+      const key = changeActions.add(ActionType.REVISION, 'Bork!');
+      await element.updateComplete;
+      let button = query<GrButton>(element, `[data-action-key="${key}"]`);
+      assert.isOk(button);
+      assert.isFalse(button!.hasAttribute('hidden'));
+      changeActions.setActionHidden(ActionType.REVISION, key, true);
+      await element.updateComplete;
+      button = query<GrButton>(element, `[data-action-key="${key}"]`);
+      assert.isNotOk(button);
+    });
+
+    test('move action button to overflow', async () => {
+      const key = changeActions.add(ActionType.REVISION, 'Bork!');
+      await element.updateComplete;
+      assert.isTrue(queryAndAssert<GrDropdown>(element, '#moreActions').hidden);
+      assert.isOk(
+        queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`)
+      );
+      changeActions.setActionOverflow(ActionType.REVISION, key, true);
+      await element.updateComplete;
+      assert.isNotOk(query<GrButton>(element, `[data-action-key="${key}"]`));
+      assert.isFalse(
+        queryAndAssert<GrDropdown>(element, '#moreActions').hidden
+      );
+      assert.strictEqual(
+        queryAndAssert<GrDropdown>(element, '#moreActions').items![0].name,
+        'Bork!'
+      );
+    });
+
+    test('change actions priority', async () => {
+      const key1 = changeActions.add(ActionType.REVISION, 'Bork!');
+      const key2 = changeActions.add(ActionType.CHANGE, 'Squanch?');
+      await element.updateComplete;
+      let buttons = queryAll<GrButton>(element, '[data-action-key]');
+      assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+      assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+      changeActions.setActionPriority(ActionType.REVISION, key1, 10);
+      await element.updateComplete;
+      buttons = queryAll<GrButton>(element, '[data-action-key]');
+      assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+      assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+    });
+  });
+});
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 aa86de4..5277d90 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
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {GrReplyDialog} from '../../../services/gr-rest-api/gr-rest-api';
+import {GrReplyDialog} from '../../change/gr-reply-dialog/gr-reply-dialog';
 import {PluginApi, TargetElement} from '../../../api/plugin';
 import {JsApiService} from './gr-js-api-types';
 import {
@@ -25,14 +13,14 @@
   ReplyChangedCallback,
   ValueChangedDetail,
 } from '../../../api/change-reply';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {HookApi, PluginElement} from '../../../api/hook';
 
 /**
  * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
  */
 export class GrChangeReplyInterface implements ChangeReplyPluginApi {
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(
     readonly plugin: PluginApi,
@@ -47,7 +35,7 @@
     ) as unknown as GrReplyDialog;
   }
 
-  getLabelValue(label: string): string {
+  getLabelValue(label: string): string | number | undefined {
     this.reporting.trackApi(this.plugin, 'reply', 'getLabelValue');
     return this._el.getLabelValue(label);
   }
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
deleted file mode 100644
index 2324588..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.js
+++ /dev/null
@@ -1,91 +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 '../../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');
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-change-reply-js-api tests', () => {
-  let element;
-
-  let changeReply;
-  let plugin;
-
-  setup(() => {
-    stubRestApi('getAccount').returns(Promise.resolve(null));
-  });
-
-  suite('early init', () => {
-    setup(() => {
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeReply = plugin.changeReply();
-      element = basicFixture.instantiate();
-    });
-
-    teardown(() => {
-      changeReply = null;
-    });
-
-    test('works', () => {
-      sinon.stub(element, 'getLabelValue').returns('+123');
-      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
-
-      sinon.stub(element, 'setLabelValue');
-      changeReply.setLabelValue('My-Label', '+1337');
-      assert.isTrue(
-          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
-
-      sinon.stub(element, 'setPluginMessage');
-      changeReply.showMessage('foobar');
-      assert.isTrue(element.setPluginMessage.calledWithExactly('foobar'));
-    });
-  });
-
-  suite('normal init', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeReply = plugin.changeReply();
-    });
-
-    teardown(() => {
-      changeReply = null;
-    });
-
-    test('works', () => {
-      sinon.stub(element, 'getLabelValue').returns('+123');
-      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
-
-      sinon.stub(element, 'setLabelValue');
-      changeReply.setLabelValue('My-Label', '+1337');
-      assert.isTrue(
-          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
-
-      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-change-reply-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts
new file mode 100644
index 0000000..65d2687
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../../change/gr-reply-dialog/gr-reply-dialog';
+import {stubElement} from '../../../test/test-utils';
+import {assert, fixture} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {ChangeReplyPluginApi} from '../../../api/change-reply';
+import {GrReplyDialog} from '../../change/gr-reply-dialog/gr-reply-dialog';
+import {html} from 'lit';
+
+suite('gr-change-reply-js-api tests', () => {
+  let changeReply: ChangeReplyPluginApi;
+  let plugin: PluginApi;
+
+  suite('init', () => {
+    setup(async () => {
+      window.Gerrit.install(
+        p => {
+          plugin = p;
+        },
+        '0.1',
+        'http://test.com/plugins/testplugin/static/test.js'
+      );
+      changeReply = plugin.changeReply();
+      await fixture<GrReplyDialog>(html`<gr-reply-dialog></gr-reply-dialog>>`);
+      assert.ok(changeReply);
+    });
+
+    test('works', () => {
+      stubElement('gr-reply-dialog', 'getLabelValue').returns('+123');
+      assert.ok(changeReply);
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+      const setLabelValueStub = stubElement('gr-reply-dialog', 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(setLabelValueStub.calledWithExactly('My-Label', '+1337'));
+
+      const setPluginMessageStub = stubElement(
+        'gr-reply-dialog',
+        'setPluginMessage'
+      );
+      changeReply.showMessage('foobar');
+      assert.isTrue(setPluginMessageStub.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
deleted file mode 100644
index 1d4bd06..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ /dev/null
@@ -1,309 +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.
- */
-
-/**
- * This defines the Gerrit instance. All methods directly attached to Gerrit
- * should be defined or linked here.
- */
-import {getPluginLoader, PluginOptionMap} from './gr-plugin-loader';
-import {send} from './gr-api-utils';
-import {appContext} from '../../../services/app-context';
-import {PluginApi} from '../../../api/plugin';
-import {HttpMethod} from '../../../constants/constants';
-import {RequestPayload} from '../../../types/common';
-import {
-  EventCallback,
-  EventEmitterService,
-} from '../../../services/gr-event-interface/gr-event-interface';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {Gerrit} from '../../../api/gerrit';
-import {fontStyles} from '../../../styles/gr-font-styles';
-import {formStyles} from '../../../styles/gr-form-styles';
-import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
-import {spinnerStyles} from '../../../styles/gr-spinner-styles';
-import {subpageStyles} from '../../../styles/gr-subpage-styles';
-import {tableStyles} from '../../../styles/gr-table-styles';
-
-/**
- * These are the methods and properties that are exposed explicitly in the
- * public global `Gerrit` interface. In reality JavaScript plugins do depend
- * on some of this "internal" stuff. But we want to convert plugins to
- * TypeScript one by one and while doing that remove those dependencies.
- */
-export interface GerritInternal extends EventEmitterService, Gerrit {
-  css(rule: string): string;
-  install(
-    callback: (plugin: PluginApi) => void,
-    opt_version?: string,
-    src?: string
-  ): void;
-  getLoggedIn(): Promise<boolean>;
-  get(url: string, callback?: (response: unknown) => void): void;
-  post(
-    url: string,
-    payload?: RequestPayload,
-    callback?: (response: unknown) => void
-  ): void;
-  put(
-    url: string,
-    payload?: RequestPayload,
-    callback?: (response: unknown) => void
-  ): void;
-  delete(url: string, callback?: (response: unknown) => void): void;
-  isPluginLoaded(pathOrUrl: string): boolean;
-  awaitPluginsLoaded(): Promise<unknown>;
-  _loadPlugins(plugins: string[], opts: PluginOptionMap): void;
-  _arePluginsLoaded(): boolean;
-  _isPluginEnabled(pathOrUrl: string): boolean;
-  _isPluginLoaded(pathOrUrl: string): boolean;
-  _customStyleSheet?: CSSStyleSheet;
-
-  // exposed methods
-  Nav: typeof GerritNav;
-  Auth: typeof appContext.authService;
-}
-
-export function initGerritPluginApi() {
-  window.Gerrit = window.Gerrit ?? new GerritImpl();
-}
-
-export function _testOnly_initGerritPluginApi(): GerritInternal {
-  initGerritPluginApi();
-  return window.Gerrit as GerritInternal;
-}
-
-export function deprecatedDelete(
-  url: string,
-  callback?: (response: Response) => void
-) {
-  console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
-  return appContext.restApiService
-    .send(HttpMethod.DELETE, url)
-    .then(response => {
-      if (response.status !== 204) {
-        return response.text().then(text => {
-          if (text) {
-            return Promise.reject(new Error(text));
-          } else {
-            return Promise.reject(new Error(`${response.status}`));
-          }
-        });
-      }
-      if (callback) callback(response);
-      return response;
-    });
-}
-
-const fakeApi = {
-  getPluginName: () => 'global',
-};
-
-/**
- * TODO(brohlfs): Reduce this step by step until it only contains install().
- */
-class GerritImpl implements GerritInternal {
-  _customStyleSheet?: CSSStyleSheet;
-
-  public readonly Nav = GerritNav;
-
-  public readonly Auth = appContext.authService;
-
-  public readonly styles = {
-    font: fontStyles,
-    form: formStyles,
-    menuPage: menuPageStyles,
-    spinner: spinnerStyles,
-    subPage: subpageStyles,
-    table: tableStyles,
-  };
-
-  /**
-   * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
-   * the documentation how to replace it accordingly.
-   */
-  css(rulesStr: string) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'css');
-    console.warn(
-      'Gerrit.css(rulesStr) is deprecated!',
-      'Use plugin.styles().css(rulesStr)'
-    );
-    if (!this._customStyleSheet) {
-      const styleEl = document.createElement('style');
-      document.head.appendChild(styleEl);
-      this._customStyleSheet = styleEl.sheet!;
-    }
-
-    const name = `__pg_js_api_class_${this._customStyleSheet.cssRules.length}`;
-    this._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
-    return name;
-  }
-
-  install(
-    callback: (plugin: PluginApi) => void,
-    version?: string,
-    src?: string
-  ) {
-    getPluginLoader().install(callback, version, src);
-  }
-
-  getLoggedIn() {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'getLoggedIn');
-    console.warn(
-      'Gerrit.getLoggedIn() is deprecated! ' +
-        'Use plugin.restApi().getLoggedIn()'
-    );
-    return appContext.restApiService.getLoggedIn();
-  }
-
-  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);
-  }
-
-  post(
-    url: string,
-    payload?: RequestPayload,
-    callback?: (response: unknown) => void
-  ) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'post');
-    console.warn('.post() is deprecated! Use plugin.restApi().post()');
-    send(HttpMethod.POST, url, callback, payload);
-  }
-
-  put(
-    url: string,
-    payload?: RequestPayload,
-    callback?: (response: unknown) => void
-  ) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'put');
-    console.warn('.put() is deprecated! Use plugin.restApi().put()');
-    send(HttpMethod.PUT, url, callback, payload);
-  }
-
-  delete(url: string, callback?: (response: Response) => void) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'delete');
-    deprecatedDelete(url, callback);
-  }
-
-  awaitPluginsLoaded() {
-    appContext.reportingService.trackApi(
-      fakeApi,
-      'global',
-      'awaitPluginsLoaded'
-    );
-    return getPluginLoader().awaitPluginsLoaded();
-  }
-
-  // TODO(taoalpha): consider removing these proxy methods
-  // and using getPluginLoader() directly
-  _loadPlugins(plugins: string[] = []) {
-    appContext.reportingService.trackApi(fakeApi, 'global', '_loadPlugins');
-    getPluginLoader().loadPlugins(plugins);
-  }
-
-  _arePluginsLoaded() {
-    appContext.reportingService.trackApi(
-      fakeApi,
-      'global',
-      '_arePluginsLoaded'
-    );
-    return getPluginLoader().arePluginsLoaded();
-  }
-
-  _isPluginEnabled(pathOrUrl: string) {
-    appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginEnabled');
-    return getPluginLoader().isPluginEnabled(pathOrUrl);
-  }
-
-  isPluginLoaded(pathOrUrl: string) {
-    return this._isPluginLoaded(pathOrUrl);
-  }
-
-  _isPluginLoaded(pathOrUrl: string) {
-    appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginLoaded');
-    return getPluginLoader().isPluginLoaded(pathOrUrl);
-  }
-
-  /**
-   * Enabling EventEmitter interface on Gerrit.
-   *
-   * This will enable to signal across different parts of js code without relying on DOM,
-   * including core to core, plugin to plugin and also core to plugin.
-   *
-   * @example
-   *
-   * // Emit this event from pluginA
-   * Gerrit.install(pluginA => {
-   *   fetch("some-api").then(() => {
-   *     Gerrit.on("your-special-event", {plugin: pluginA});
-   *   });
-   * });
-   *
-   * // Listen on your-special-event from pluginB
-   * Gerrit.install(pluginB => {
-   *   Gerrit.on("your-special-event", ({plugin}) => {
-   *     // do something, plugin is pluginA
-   *   });
-   * });
-   */
-  addListener(eventName: string, cb: EventCallback) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'addListener');
-    return appContext.eventEmitter.addListener(eventName, cb);
-  }
-
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  dispatch(eventName: string, detail: any) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'dispatch');
-    return appContext.eventEmitter.dispatch(eventName, detail);
-  }
-
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  emit(eventName: string, detail: any) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'emit');
-    return appContext.eventEmitter.emit(eventName, detail);
-  }
-
-  off(eventName: string, cb: EventCallback) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'off');
-    return appContext.eventEmitter.off(eventName, cb);
-  }
-
-  on(eventName: string, cb: EventCallback) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'on');
-    return appContext.eventEmitter.on(eventName, cb);
-  }
-
-  once(eventName: string, cb: EventCallback) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'once');
-    return appContext.eventEmitter.once(eventName, cb);
-  }
-
-  removeAllListeners(eventName: string) {
-    appContext.reportingService.trackApi(
-      fakeApi,
-      'global',
-      'removeAllListeners'
-    );
-    return appContext.eventEmitter.removeAllListeners(eventName);
-  }
-
-  removeListener(eventName: string, cb: EventCallback) {
-    appContext.reportingService.trackApi(fakeApi, 'global', 'removeListener');
-    return appContext.eventEmitter.removeListener(eventName, cb);
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
deleted file mode 100644
index 95a67ab..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
+++ /dev/null
@@ -1,69 +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 {getPluginLoader} from './gr-plugin-loader.js';
-import {resetPlugins} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {appContext} from '../../../services/app-context.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-gerrit tests', () => {
-  let element;
-
-  let clock;
-
-  setup(() => {
-    clock = sinon.useFakeTimers();
-
-    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
-    stubRestApi('send').returns(Promise.resolve({status: 200}));
-    element = appContext.jsApiService;
-  });
-
-  teardown(() => {
-    clock.restore();
-    element._removeEventCallbacks();
-    resetPlugins();
-  });
-
-  suite('proxy methods', () => {
-    test('Gerrit._isPluginEnabled proxy to getPluginLoader()', () => {
-      const stubFn = sinon.stub();
-      sinon.stub(
-          getPluginLoader(),
-          'isPluginEnabled')
-          .callsFake((...args) => stubFn(...args)
-          );
-      pluginApi._isPluginEnabled('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
-
-    test('Gerrit._isPluginLoaded proxy to getPluginLoader()', () => {
-      const stubFn = sinon.stub();
-      sinon.stub(
-          getPluginLoader(),
-          'isPluginLoaded')
-          .callsFake((...args) => stubFn(...args));
-      pluginApi._isPluginLoaded('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 4bb5fd4..a16a1d1 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
@@ -1,28 +1,15 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {getPluginLoader} from './gr-plugin-loader';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
   ChangeInfo,
-  LabelNameToValuesMap,
+  LabelNameToValueMap,
   ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
-import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
 import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
 import {
   JsApiService,
@@ -31,49 +18,22 @@
   ShowRevisionActionsDetail,
 } from './gr-js-api-types';
 import {EventType, TargetElement} from '../../../api/plugin';
-import {DiffLayer, HighlightJS, ParsedChangeInfo} from '../../../types/types';
-import {appContext} from '../../../services/app-context';
+import {ParsedChangeInfo} from '../../../types/types';
 import {MenuLink} from '../../../api/admin';
+import {Finalizable} from '../../../services/registry';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {Provider} from '../../../models/dependency';
 
 const elements: {[key: string]: HTMLElement} = {};
 const eventCallbacks: {[key: string]: EventCallback[]} = {};
 
-export class GrJsApiInterface implements JsApiService {
-  private readonly reporting = appContext.reportingService;
+export class GrJsApiInterface implements JsApiService, Finalizable {
+  constructor(
+    private waitForPluginsToLoad: Provider<Promise<void>>,
+    readonly reporting: ReportingService
+  ) {}
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  handleEvent(type: EventType, detail: any) {
-    getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        switch (type) {
-          case EventType.HISTORY:
-            this._handleHistory(detail);
-            break;
-          case EventType.SHOW_CHANGE:
-            this._handleShowChange(detail);
-            break;
-          case EventType.COMMENT:
-            this._handleComment(detail);
-            break;
-          case EventType.LABEL_CHANGE:
-            this._handleLabelChange(detail);
-            break;
-          case EventType.SHOW_REVISION_ACTIONS:
-            this._handleShowRevisionActions(detail);
-            break;
-          case EventType.HIGHLIGHTJS_LOADED:
-            this._handleHighlightjsLoaded(detail);
-            break;
-          default:
-            console.warn(
-              'handleEvent called with unsupported event type:',
-              type
-            );
-            break;
-        }
-      });
-  }
+  finalize() {}
 
   addElement(key: TargetElement, el: HTMLElement) {
     elements[key] = el;
@@ -95,8 +55,12 @@
     const cancelSubmit = submitCallbacks.some(callback => {
       try {
         return callback(change, revision) === false;
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          'GrJsApiInterface',
+          new Error('canSubmitChange callback error'),
+          err
+        );
       }
       return false;
     });
@@ -111,18 +75,8 @@
     }
   }
 
-  // TODO(TS): The HISTORY event and its handler seem unused.
-  _handleHistory(detail: {path: string}) {
-    for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
-      try {
-        cb(detail.path);
-      } catch (err) {
-        this.reporting.error(err);
-      }
-    }
-  }
-
-  _handleShowChange(detail: ShowChangeDetail) {
+  async handleShowChange(detail: ShowChangeDetail) {
+    await this.waitForPluginsToLoad();
     // Note (issue 8221) Shallow clone the change object and add a mergeable
     // getter with deprecation warning. This makes the change detail appear as
     // though SKIP_MERGEABLE was not set, so that plugins that expect it can
@@ -157,21 +111,30 @@
     for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
       try {
         cb(change, revision, info);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          'GrJsApiInterface',
+          new Error('showChange callback error'),
+          err
+        );
       }
     }
   }
 
-  _handleShowRevisionActions(detail: ShowRevisionActionsDetail) {
+  async handleShowRevisionActions(detail: ShowRevisionActionsDetail) {
+    await this.waitForPluginsToLoad();
     const registeredCallbacks = this._getEventCallbacks(
       EventType.SHOW_REVISION_ACTIONS
     );
     for (const cb of registeredCallbacks) {
       try {
         cb(detail.revisionActions, detail.change);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          'GrJsApiInterface',
+          new Error('showRevisionActions callback error'),
+          err
+        );
       }
     }
   }
@@ -180,39 +143,27 @@
     for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
       try {
         cb(change, msg);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          'GrJsApiInterface',
+          new Error('commitMessage callback error'),
+          err
+        );
       }
     }
   }
 
-  // TODO(TS): The COMMENT event and its handler seem unused.
-  _handleComment(detail: {node: Node}) {
-    for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
-      try {
-        cb(detail.node);
-      } catch (err) {
-        this.reporting.error(err);
-      }
-    }
-  }
-
-  _handleLabelChange(detail: {change: ChangeInfo}) {
+  async handleLabelChange(detail: {change?: ParsedChangeInfo}) {
+    await this.waitForPluginsToLoad();
     for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
       try {
         cb(detail.change);
-      } catch (err) {
-        this.reporting.error(err);
-      }
-    }
-  }
-
-  _handleHighlightjsLoaded(detail: {hljs: HighlightJS}) {
-    for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
-      try {
-        cb(detail.hljs);
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          'GrJsApiInterface',
+          new Error('labelChange callback error'),
+          err
+        );
       }
     }
   }
@@ -221,8 +172,12 @@
     for (const cb of this._getEventCallbacks(EventType.REVERT)) {
       try {
         revertMsg = cb(change, revertMsg, origMsg) as string;
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          'GrJsApiInterface',
+          new Error('modifyRevertMsg callback error'),
+          err
+        );
       }
     }
     return revertMsg;
@@ -240,59 +195,17 @@
           revertSubmissionMsg,
           origMsg
         ) as string;
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          'GrJsApiInterface',
+          new Error('modifyRevertSubmissionMsg callback error'),
+          err
+        );
       }
     }
     return revertSubmissionMsg;
   }
 
-  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.createLayer(path);
-        if (layer) layers.push(layer);
-      } catch (err) {
-        this.reporting.error(err);
-      }
-    }
-    return layers;
-  }
-
-  disposeDiffLayers(path: string) {
-    for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
-      try {
-        const annotationApi = cb as unknown as GrAnnotationActionsInterface;
-        annotationApi.disposeLayer(path);
-      } catch (err) {
-        this.reporting.error(err);
-      }
-    }
-  }
-
-  /**
-   * Retrieves coverage data possibly provided by a plugin.
-   *
-   * Will wait for plugins to be loaded. If multiple plugins offer a coverage
-   * provider, the first one is returned. If no plugin offers a coverage provider,
-   * will resolve to null.
-   */
-  getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]> {
-    return getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        const providers: GrAnnotationActionsInterface[] = [];
-        this._getEventCallbacks(EventType.ANNOTATE_DIFF).forEach(cb => {
-          const annotationApi = cb as unknown as GrAnnotationActionsInterface;
-          const provider = annotationApi.getCoverageProvider();
-          if (provider) providers.push(annotationApi);
-        });
-        return providers;
-      });
-  }
-
   getAdminMenuLinks(): MenuLink[] {
     const links: MenuLink[] = [];
     for (const cb of this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
@@ -310,10 +223,14 @@
         if (hasOwnProperty(r, 'labels')) {
           review = r as ReviewInput;
         } else {
-          review = {labels: r as LabelNameToValuesMap};
+          review = {labels: r as LabelNameToValueMap};
         }
-      } catch (err) {
-        this.reporting.error(err);
+      } catch (err: unknown) {
+        this.reporting.error(
+          'GrJsApiInterface',
+          new Error('getReviewPostRevert callback error'),
+          err
+        );
       }
     }
     return review;
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 a05e7e1..2ec4f27 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
@@ -1,19 +1,7 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 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
deleted file mode 100644
index 29db685..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ /dev/null
@@ -1,360 +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-js-api-interface.js';
-import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.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';
-import {stubRestApi} from '../../../test/test-utils.js';
-import {appContext} from '../../../services/app-context.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('GrJsApiInterface tests', () => {
-  let element;
-  let plugin;
-  let errorStub;
-
-  let sendStub;
-  let clock;
-
-  const throwErrFn = function() {
-    throw Error('Unfortunately, this handler has stopped');
-  };
-
-  setup(() => {
-    clock = sinon.useFakeTimers();
-
-    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([]);
-  });
-
-  teardown(() => {
-    clock.restore();
-    element._removeEventCallbacks();
-    plugin = null;
-  });
-
-  test('url', () => {
-    assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
-    assert.equal(plugin.url('/static/test.js'),
-        'http://test.com/plugins/testplugin/static/test.js');
-  });
-
-  test('_send on failure rejects with response text', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve('text'); }}));
-    return plugin._send().catch(r => {
-      assert.equal(r.message, 'text');
-    });
-  });
-
-  test('_send on failure without text rejects with code', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve(null); }}));
-    return plugin._send().catch(r => {
-      assert.equal(r.message, '400');
-    });
-  });
-
-  test('history event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    plugin.on(EventType.HISTORY, throwErrFn);
-    plugin.on(EventType.HISTORY, resolve);
-    element.handleEvent(EventType.HISTORY, {path: '/path/to/awesomesauce'});
-    const path = await promise;
-    assert.equal(path, '/path/to/awesomesauce');
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('showchange event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testChange = {
-      _number: 42,
-      revisions: {def: {_number: 2}, abc: {_number: 1}},
-    };
-    const expectedChange = {mergeable: false, ...testChange};
-    plugin.on(EventType.SHOW_CHANGE, throwErrFn);
-    plugin.on(EventType.SHOW_CHANGE, (change, revision, info) => {
-      resolve({change, revision, info});
-    });
-    element.handleEvent(EventType.SHOW_CHANGE,
-        {change: testChange, patchNum: 1, info: {mergeable: false}});
-
-    const {change, revision, info} = await promise;
-    assert.deepEqual(change, expectedChange);
-    assert.deepEqual(revision, testChange.revisions.abc);
-    assert.deepEqual(info, {mergeable: false});
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('show-revision-actions event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testChange = {
-      _number: 42,
-      revisions: {def: {_number: 2}, abc: {_number: 1}},
-    };
-    plugin.on(EventType.SHOW_REVISION_ACTIONS, throwErrFn);
-    plugin.on(EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
-      resolve({change, actions});
-    });
-    element.handleEvent(EventType.SHOW_REVISION_ACTIONS,
-        {change: testChange, revisionActions: {test: {}}});
-
-    const {change, actions} = await promise;
-    assert.deepEqual(change, testChange);
-    assert.deepEqual(actions, {test: {}});
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('handleEvent awaits plugins load', async () => {
-    const testChange = {
-      _number: 42,
-      revisions: {def: {_number: 2}, abc: {_number: 1}},
-    };
-    const spy = sinon.spy();
-    getPluginLoader().loadPlugins(['plugins/test.js']);
-    plugin.on(EventType.SHOW_CHANGE, spy);
-    element.handleEvent(EventType.SHOW_CHANGE,
-        {change: testChange, patchNum: 1});
-    assert.isFalse(spy.called);
-
-    // Timeout on loading plugins
-    clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
-
-    await flush();
-    assert.isTrue(spy.called);
-  });
-
-  test('comment event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testCommentNode = {foo: 'bar'};
-    plugin.on(EventType.COMMENT, throwErrFn);
-    plugin.on(EventType.COMMENT, resolve);
-    element.handleEvent(EventType.COMMENT, {node: testCommentNode});
-
-    const commentNode = await promise;
-    assert.deepEqual(commentNode, testCommentNode);
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('revert event', () => {
-    function appendToRevertMsg(c, revertMsg, originalMsg) {
-      return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
-    }
-
-    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
-    assert.equal(errorStub.callCount, 0);
-
-    plugin.on(EventType.REVERT, throwErrFn);
-    plugin.on(EventType.REVERT, appendToRevertMsg);
-    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-        'test\n> origTest\ninfo');
-    assert.isTrue(errorStub.calledOnce);
-
-    plugin.on(EventType.REVERT, appendToRevertMsg);
-    assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-        'test\n> origTest\ninfo\n> origTest\ninfo');
-    assert.isTrue(errorStub.calledTwice);
-  });
-
-  test('postrevert event labels', () => {
-    function getLabels(c) {
-      return {'Code-Review': 1};
-    }
-
-    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.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);
-  });
-
-  test('commitmsgedit event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testMsg = 'Test CL commit message';
-    plugin.on(EventType.COMMIT_MSG_EDIT, throwErrFn);
-    plugin.on(EventType.COMMIT_MSG_EDIT, (change, msg) => {
-      resolve(msg);
-    });
-    element.handleCommitMessage(null, testMsg);
-
-    const msg = await promise;
-    assert.deepEqual(msg, testMsg);
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('labelchange event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testChange = {_number: 42};
-    plugin.on(EventType.LABEL_CHANGE, throwErrFn);
-    plugin.on(EventType.LABEL_CHANGE, resolve);
-    element.handleEvent(EventType.LABEL_CHANGE, {change: testChange});
-
-    const change = await promise;
-    assert.deepEqual(change, testChange);
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('submitchange', () => {
-    plugin.on(EventType.SUBMIT_CHANGE, throwErrFn);
-    plugin.on(EventType.SUBMIT_CHANGE, () => true);
-    assert.isTrue(element.canSubmitChange());
-    assert.isTrue(errorStub.calledOnce);
-    plugin.on(EventType.SUBMIT_CHANGE, () => false);
-    plugin.on(EventType.SUBMIT_CHANGE, () => true);
-    assert.isFalse(element.canSubmitChange());
-    assert.isTrue(errorStub.calledTwice);
-  });
-
-  test('highlightjs-loaded event', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    const testHljs = {_number: 42};
-    plugin.on(EventType.HIGHLIGHTJS_LOADED, throwErrFn);
-    plugin.on(EventType.HIGHLIGHTJS_LOADED, resolve);
-    element.handleEvent(EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
-
-    const hljs = await promise;
-    assert.deepEqual(hljs, testHljs);
-    assert.isTrue(errorStub.calledOnce);
-  });
-
-  test('getLoggedIn', () => {
-    // fake fetch for authCheck
-    sinon.stub(window, 'fetch').callsFake(() => Promise.resolve({status: 204}));
-    return plugin.restApi().getLoggedIn()
-        .then(loggedIn => {
-          assert.isTrue(loggedIn);
-        });
-  });
-
-  test('attributeHelper', () => {
-    assert.isOk(plugin.attributeHelper());
-  });
-
-  test('getAdminMenuLinks', () => {
-    const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
-    const getCallbacksStub = sinon.stub(element, '_getEventCallbacks')
-        .returns([
-          {getMenuLinks: () => [links[0]]},
-          {getMenuLinks: () => [links[1]]},
-        ]);
-    const result = element.getAdminMenuLinks();
-    assert.deepEqual(result, links);
-    assert.isTrue(getCallbacksStub.calledOnce);
-    assert.equal(getCallbacksStub.lastCall.args[0],
-        EventType.ADMIN_MENU_LINKS);
-  });
-
-  suite('test plugin with base url', () => {
-    let baseUrlPlugin;
-
-    setup(() => {
-      stubBaseUrl('/r');
-
-      pluginApi.install(p => { baseUrlPlugin = p; }, '0.1',
-          'http://test.com/r/plugins/baseurlplugin/static/test.js');
-    });
-
-    test('url', () => {
-      assert.notEqual(baseUrlPlugin.url(),
-          'http://test.com/plugins/baseurlplugin/');
-      assert.equal(baseUrlPlugin.url(),
-          'http://test.com/r/plugins/baseurlplugin/');
-      assert.equal(baseUrlPlugin.url('/static/test.js'),
-          'http://test.com/r/plugins/baseurlplugin/static/test.js');
-    });
-  });
-
-  suite('popup', () => {
-    test('popup(element) is deprecated', () => {
-      sinon.stub(console, 'error');
-      plugin.popup(document.createElement('div'));
-      assert.isTrue(console.error.calledOnce);
-    });
-
-    test('popup(moduleName) creates popup with component', () => {
-      const openStub = sinon.stub(GrPopupInterface.prototype, 'open').callsFake(
-          function() {
-            // Arrow function can't be used here, because we want to
-            // get properties from the instance of GrPopupInterface
-            // eslint-disable-next-line no-invalid-this
-            const grPopupInterface = this;
-            assert.equal(grPopupInterface.plugin, plugin);
-            assert.equal(grPopupInterface.moduleName, 'some-name');
-          });
-      plugin.popup('some-name');
-      assert.isTrue(openStub.calledOnce);
-    });
-  });
-
-  suite('screen', () => {
-    test('screenUrl()', () => {
-      stubBaseUrl('/base');
-      assert.equal(
-          plugin.screenUrl(),
-          `${location.origin}/base/x/testplugin`
-      );
-      assert.equal(
-          plugin.screenUrl('foo'),
-          `${location.origin}/base/x/testplugin/foo`
-      );
-    });
-
-    test('works', () => {
-      sinon.stub(plugin, 'registerCustomComponent');
-      plugin.screen('foo', 'some-module');
-      assert.isTrue(plugin.registerCustomComponent.calledWith(
-          'testplugin-screen-foo', 'some-module'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
new file mode 100644
index 0000000..a21ddc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
@@ -0,0 +1,401 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
+import {EventType} from '../../../api/plugin';
+import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
+import {
+  stubRestApi,
+  stubBaseUrl,
+  waitEventLoop,
+  waitUntilCalled,
+  assertFails,
+} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {PluginLoader, pluginLoaderToken} from './gr-plugin-loader';
+import {useFakeTimers, stub, SinonFakeTimers, SinonStub} from 'sinon';
+import {GrJsApiInterface} from './gr-js-api-interface-element';
+import {Plugin} from './gr-public-js-api';
+import {
+  ChangeInfo,
+  HttpMethod,
+  NumericChangeId,
+  PatchSetNum,
+  RevisionPatchSetNum,
+  Timestamp,
+} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+import {
+  createChange,
+  createParsedChange,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {EventCallback} from './gr-js-api-types';
+
+suite('GrJsApiInterface tests', () => {
+  let element: GrJsApiInterface;
+  let plugin: Plugin;
+  let errorStub: SinonStub;
+  let pluginLoader: PluginLoader;
+
+  let sendStub: SinonStub;
+  let clock: SinonFakeTimers;
+
+  const throwErrFn = function () {
+    throw Error('Unfortunately, this handler has stopped');
+  };
+
+  setup(() => {
+    clock = useFakeTimers();
+
+    stubRestApi('getAccount').resolves({
+      name: 'Judy Hopps',
+      registered_on: '' as Timestamp,
+    });
+    sendStub = stubRestApi('send').resolves(
+      new Response(undefined, {status: 200})
+    );
+    pluginLoader = testResolver(pluginLoaderToken);
+
+    // We are using the jsApiService as the implementation class rather than the
+    // interface to better set up tests.
+    element = pluginLoader.jsApiService as GrJsApiInterface;
+    errorStub = stub(element.reporting, 'error');
+    pluginLoader.install(
+      p => {
+        // We are using the plugin API as the implementation class rather than
+        // the interface to better set up tests.
+        plugin = p as Plugin;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    testResolver(pluginLoaderToken).loadPlugins([]);
+  });
+
+  teardown(() => {
+    clock.restore();
+    element._removeEventCallbacks();
+  });
+
+  test('url', () => {
+    assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
+    assert.equal(
+      plugin.url('/static/test.js'),
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+  });
+
+  test('_send on failure rejects with response text', async () => {
+    sendStub.resolves({
+      status: 400,
+      text() {
+        return Promise.resolve('text');
+      },
+    });
+    const error = await assertFails<Error>(plugin._send(HttpMethod.POST, ''));
+    assert.equal(error.message, 'text');
+  });
+
+  test('_send on failure without text rejects with code', async () => {
+    sendStub.resolves({
+      status: 400,
+      text() {
+        return Promise.resolve(null);
+      },
+    });
+    const error = await assertFails<Error>(plugin._send(HttpMethod.POST, ''));
+    assert.equal(error.message, '400');
+  });
+
+  test('showchange event', async () => {
+    const showChangeStub = stub();
+    const testChange: ParsedChangeInfo = {
+      ...createParsedChange(),
+      _number: 42 as NumericChangeId,
+      revisions: {
+        def: {...createRevision(), _number: 2 as RevisionPatchSetNum},
+        abc: {...createRevision(), _number: 1 as RevisionPatchSetNum},
+      },
+    };
+    const expectedChange = {mergeable: false, ...testChange};
+
+    plugin.on(EventType.SHOW_CHANGE, throwErrFn);
+    plugin.on(EventType.SHOW_CHANGE, showChangeStub);
+    element.handleShowChange({
+      change: testChange,
+      patchNum: 1 as PatchSetNum,
+      info: {mergeable: false},
+    });
+    await waitUntilCalled(showChangeStub, 'showChangeStub');
+
+    const [change, revision, info] = showChangeStub.firstCall.args;
+    assert.deepEqual(change, expectedChange);
+    assert.deepEqual(revision, testChange.revisions.abc);
+    assert.deepEqual(info, {mergeable: false});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('show-revision-actions event', async () => {
+    const showRevisionActionsStub = stub();
+    const testChange: ChangeInfo = {
+      ...createChange(),
+      _number: 42 as NumericChangeId,
+      revisions: {
+        def: {...createRevision(), _number: 2 as RevisionPatchSetNum},
+        abc: {...createRevision(), _number: 1 as RevisionPatchSetNum},
+      },
+    };
+
+    plugin.on(EventType.SHOW_REVISION_ACTIONS, throwErrFn);
+    plugin.on(EventType.SHOW_REVISION_ACTIONS, showRevisionActionsStub);
+    element.handleShowRevisionActions({
+      change: testChange,
+      revisionActions: {test: {}},
+    });
+    await waitUntilCalled(showRevisionActionsStub, 'showRevisionActionsStub');
+
+    const [actions, change] = showRevisionActionsStub.firstCall.args;
+    assert.deepEqual(change, testChange);
+    assert.deepEqual(actions, {test: {}});
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('handleShowChange awaits plugins load', async () => {
+    const testChange: ParsedChangeInfo = {
+      ...createParsedChange(),
+      _number: 42 as NumericChangeId,
+      revisions: {
+        def: {...createRevision(), _number: 2 as RevisionPatchSetNum},
+        abc: {...createRevision(), _number: 1 as RevisionPatchSetNum},
+      },
+    };
+    const showChangeStub = stub();
+    testResolver(pluginLoaderToken).loadPlugins(['plugins/test.js']);
+    plugin.on(EventType.SHOW_CHANGE, showChangeStub);
+    element.handleShowChange({
+      change: testChange,
+      patchNum: 1 as PatchSetNum,
+      info: {mergeable: null},
+    });
+    assert.isFalse(showChangeStub.called);
+
+    // Timeout on loading plugins
+    clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    await waitEventLoop();
+    assert.isTrue(showChangeStub.called);
+  });
+
+  test('revert event', () => {
+    function appendToRevertMsg(
+      _c: unknown,
+      revertMsg: string,
+      originalMsg: string
+    ) {
+      return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
+    }
+    const change = createChange();
+
+    assert.equal(element.modifyRevertMsg(change, 'test', 'origTest'), 'test');
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(EventType.REVERT, throwErrFn);
+    plugin.on(EventType.REVERT, appendToRevertMsg);
+    assert.equal(
+      element.modifyRevertMsg(change, 'test', 'origTest'),
+      'test\n> origTest\ninfo'
+    );
+    assert.isTrue(errorStub.calledOnce);
+
+    plugin.on(EventType.REVERT, appendToRevertMsg);
+    assert.equal(
+      element.modifyRevertMsg(change, 'test', 'origTest'),
+      'test\n> origTest\ninfo\n> origTest\ninfo'
+    );
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('postrevert event labels', () => {
+    function getLabels(_c: unknown) {
+      return {'Code-Review': 1};
+    }
+
+    assert.deepEqual(element.getReviewPostRevert(undefined), {});
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(EventType.POST_REVERT, throwErrFn);
+    plugin.on(EventType.POST_REVERT, getLabels);
+    assert.deepEqual(element.getReviewPostRevert(undefined), {
+      labels: {'Code-Review': 1},
+    });
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('postrevert event review', () => {
+    function getReview(_c: unknown) {
+      return {labels: {'Code-Review': 1}};
+    }
+
+    assert.deepEqual(element.getReviewPostRevert(undefined), {});
+    assert.equal(errorStub.callCount, 0);
+
+    plugin.on(EventType.POST_REVERT, throwErrFn);
+    plugin.on(EventType.POST_REVERT, getReview);
+    assert.deepEqual(element.getReviewPostRevert(undefined), {
+      labels: {'Code-Review': 1},
+    });
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('commitmsgedit event', async () => {
+    const commitMsgEditStub = stub();
+    const testMsg = 'Test CL commit message';
+
+    plugin.on(EventType.COMMIT_MSG_EDIT, throwErrFn);
+    plugin.on(EventType.COMMIT_MSG_EDIT, commitMsgEditStub);
+    element.handleCommitMessage(createChange(), testMsg);
+    await waitUntilCalled(commitMsgEditStub, 'commitMsgEditStub');
+
+    const msg = commitMsgEditStub.firstCall.args[1];
+    assert.deepEqual(msg, testMsg);
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('labelchange event', async () => {
+    const labelChangeStub = stub();
+    const testChange: ParsedChangeInfo = {
+      ...createParsedChange(),
+      _number: 42 as NumericChangeId,
+    };
+
+    plugin.on(EventType.LABEL_CHANGE, throwErrFn);
+    plugin.on(EventType.LABEL_CHANGE, labelChangeStub);
+    element.handleLabelChange({change: testChange});
+    await waitUntilCalled(labelChangeStub, 'labelChangeStub');
+
+    const [change] = labelChangeStub.firstCall.args;
+    assert.deepEqual(change, testChange);
+    assert.isTrue(errorStub.calledOnce);
+  });
+
+  test('submitchange', () => {
+    plugin.on(EventType.SUBMIT_CHANGE, throwErrFn);
+    plugin.on(EventType.SUBMIT_CHANGE, () => true);
+    assert.isTrue(element.canSubmitChange(createChange()));
+    assert.isTrue(errorStub.calledOnce);
+    plugin.on(EventType.SUBMIT_CHANGE, () => false);
+    plugin.on(EventType.SUBMIT_CHANGE, () => true);
+    assert.isFalse(element.canSubmitChange(createChange()));
+    assert.isTrue(errorStub.calledTwice);
+  });
+
+  test('getLoggedIn', async () => {
+    // fake fetch for authCheck
+    stub(window, 'fetch').resolves(new Response(undefined, {status: 204}));
+    const loggedIn = await plugin.restApi().getLoggedIn();
+    assert.isTrue(loggedIn);
+  });
+
+  test('attributeHelper', () => {
+    assert.isOk(plugin.attributeHelper(document.createElement('div')));
+  });
+
+  test('getAdminMenuLinks', () => {
+    const links = [
+      {text: 'a', url: 'b'},
+      {text: 'c', url: 'd'},
+    ];
+    // getAdminMenuLinks expects _getEventCallbacks to really return
+    // GrAdminApi[] even though _getEventCallbacks has return type
+    // EventCallback[]. Therefore this test must also return GrAdminApi[]
+    // disguised as EventCallback[].
+    const getCallbacksStub = stub(element, '_getEventCallbacks').returns([
+      {getMenuLinks: () => [links[0]]},
+      {getMenuLinks: () => [links[1]]},
+    ] as unknown as EventCallback[]);
+    const result = element.getAdminMenuLinks();
+    assert.deepEqual(result, links);
+    assert.isTrue(getCallbacksStub.calledOnce);
+    assert.equal(getCallbacksStub.lastCall.args[0], EventType.ADMIN_MENU_LINKS);
+  });
+
+  suite('test plugin with base url', () => {
+    let baseUrlPlugin: Plugin;
+
+    setup(() => {
+      stubBaseUrl('/r');
+
+      pluginLoader.install(
+        p => {
+          // We are using the plugin API as the implementation class rather than
+          // the interface to better set up tests.
+          baseUrlPlugin = p as Plugin;
+        },
+        '0.1',
+        'http://test.com/r/plugins/baseurlplugin/static/test.js'
+      );
+    });
+
+    test('url', () => {
+      assert.notEqual(
+        baseUrlPlugin.url(),
+        'http://test.com/plugins/baseurlplugin/'
+      );
+      assert.equal(
+        baseUrlPlugin.url(),
+        'http://test.com/r/plugins/baseurlplugin/'
+      );
+      assert.equal(
+        baseUrlPlugin.url('/static/test.js'),
+        'http://test.com/r/plugins/baseurlplugin/static/test.js'
+      );
+    });
+  });
+
+  suite('popup', () => {
+    test('popup(moduleName) creates popup with component', () => {
+      const openStub = stub(GrPopupInterface.prototype, 'open').callsFake(
+        async function (this: GrPopupInterface) {
+          // Arrow function can't be used here, because we want to
+          // get properties from the instance of GrPopupInterface
+          assert.equal(this.plugin, plugin);
+          assert.equal(this.moduleName, 'some-name');
+          return this;
+        }
+      );
+      plugin.popup('some-name');
+      assert.isTrue(openStub.calledOnce);
+    });
+  });
+
+  suite('screen', () => {
+    test('screenUrl()', () => {
+      stubBaseUrl('/base');
+      assert.equal(plugin.screenUrl(), `${location.origin}/base/x/testplugin`);
+      assert.equal(
+        plugin.screenUrl('foo'),
+        `${location.origin}/base/x/testplugin/foo`
+      );
+    });
+
+    test('works', () => {
+      const registerCustomComponentStub = stub(
+        plugin,
+        'registerCustomComponent'
+      );
+      plugin.screen('foo', 'some-module');
+      assert.isTrue(
+        registerCustomComponentStub.calledWith(
+          'testplugin-screen-foo',
+          'some-module'
+        )
+      );
+    });
+  });
+});
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 9644ef3..a10beab 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {
   ActionInfo,
@@ -21,26 +10,26 @@
   ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
+import {Finalizable} from '../../../services/registry';
 import {EventType, TargetElement} from '../../../api/plugin';
-import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
-import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {ParsedChangeInfo} from '../../../types/types';
 import {MenuLink} from '../../../api/admin';
 
 export interface ShowChangeDetail {
-  change: ChangeInfo;
-  patchNum: PatchSetNum;
-  info: {mergeable: boolean};
+  change?: ParsedChangeInfo;
+  patchNum?: PatchSetNum;
+  info: {mergeable: boolean | null};
 }
 
 export interface ShowRevisionActionsDetail {
   change: ChangeInfo;
-  revisionActions: {[key: string]: ActionInfo};
+  revisionActions: {[key: string]: ActionInfo | undefined};
 }
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventCallback = (...args: any[]) => any;
 
-export interface JsApiService {
+export interface JsApiService extends Finalizable {
   getElement(key: TargetElement): HTMLElement;
   addEventCallback(eventName: EventType, callback: EventCallback): void;
   modifyRevertSubmissionMsg(
@@ -48,17 +37,15 @@
     revertSubmissionMsg: string,
     origMsg: string
   ): string;
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  handleEvent(eventName: EventType, detail: any): void;
+  handleShowChange(detail: ShowChangeDetail): void;
+  handleShowRevisionActions(detail: ShowRevisionActionsDetail): void;
+  handleLabelChange(detail: {change?: ParsedChangeInfo}): void;
   modifyRevertMsg(
     change: ChangeInfo,
     revertMsg: string,
     origMsg: string
   ): string;
   addElement(key: TargetElement, el: HTMLElement): void;
-  getDiffLayers(path: string): DiffLayer[];
-  disposeDiffLayers(path: string): void;
-  getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]>;
   getAdminMenuLinks(): MenuLink[];
   handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string): void;
   canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
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 46c7ad6..0628d2f 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
@@ -1,28 +1,16 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
-import {ShowAlertEventDetail} from '../../../types/events';
+import {EventType, ShowAlertEventDetail} from '../../../types/events';
 import {PluginApi} from '../../../api/plugin';
 import {UIActionInfo} from './gr-change-actions-js-api';
 import {windowLocationReload} from '../../../utils/dom-util';
 import {PopupPluginApi} from '../../../api/popup';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
@@ -31,7 +19,7 @@
 export class GrPluginActionContext {
   private popups: PopupPluginApi[] = [];
 
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   constructor(
     public readonly plugin: PluginApi,
@@ -112,6 +100,7 @@
     if (!this.action.method) return;
     if (!this.action.__url) {
       this.reporting.error(
+        'GrPluginActionContext',
         new Error(`Unable to ${this.action.method} to ${this.action.__key}!`)
       );
       return;
@@ -122,7 +111,7 @@
       .then(onSuccess)
       .catch((error: unknown) => {
         document.dispatchEvent(
-          new CustomEvent<ShowAlertEventDetail>('show-alert', {
+          new CustomEvent<ShowAlertEventDetail>(EventType.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
deleted file mode 100644
index 34c976a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ /dev/null
@@ -1,149 +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 {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();
-
-suite('gr-plugin-action-context tests', () => {
-  let instance;
-
-  let plugin;
-
-  setup(() => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    instance = new GrPluginActionContext(plugin);
-  });
-
-  test('popup() and hide()', async () => {
-    const popupApiStub = {
-      _getElement: sinon.stub().returns(document.createElement('div')),
-      close: sinon.stub(),
-    };
-    sinon.stub(plugin, 'popup').returns(Promise.resolve(popupApiStub));
-    const el = document.createElement('span');
-    instance.popup(el);
-    await flush();
-    assert.isTrue(popupApiStub._getElement.called);
-    instance.hide();
-    assert.isTrue(popupApiStub.close.called);
-  });
-
-  test('textfield', () => {
-    assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
-  });
-
-  test('br', () => {
-    assert.equal(instance.br().tagName, 'BR');
-  });
-
-  test('msg', () => {
-    const el = instance.msg('foobar');
-    assert.equal(el.tagName, 'GR-LABEL');
-    assert.equal(el.textContent, 'foobar');
-  });
-
-  test('div', () => {
-    const el1 = document.createElement('span');
-    el1.textContent = 'foo';
-    const el2 = document.createElement('div');
-    el2.textContent = 'bar';
-    const div = instance.div(el1, el2);
-    assert.equal(div.tagName, 'DIV');
-    assert.equal(div.textContent, 'foobar');
-  });
-
-  suite('button', () => {
-    let clickStub;
-    let button;
-    setup(() => {
-      clickStub = sinon.stub();
-      button = instance.button('foo', {onclick: clickStub});
-      // If you don't attach a Polymer element to the DOM, then the ready()
-      // callback will not be called and then e.g. this.$ is undefined.
-      document.body.appendChild(button);
-    });
-
-    test('click', () => {
-      MockInteractions.tap(button);
-      flush();
-      assert.isTrue(clickStub.called);
-      assert.equal(button.textContent, 'foo');
-    });
-
-    teardown(() => {
-      button.remove();
-    });
-  });
-
-  test('checkbox', () => {
-    const el = instance.checkbox();
-    assert.equal(el.tagName, 'INPUT');
-    assert.equal(el.type, 'checkbox');
-  });
-
-  test('label', () => {
-    const fakeMsg = {};
-    const fakeCheckbox = {};
-    sinon.stub(instance, 'div');
-    sinon.stub(instance, 'msg').returns(fakeMsg);
-    instance.label(fakeCheckbox, 'foo');
-    assert.isTrue(instance.div.calledWithExactly(fakeCheckbox, fakeMsg));
-  });
-
-  test('call', () => {
-    instance.action = {
-      method: 'METHOD',
-      __key: 'key',
-      __url: '/changes/1/revisions/2/foo~bar',
-    };
-    const sendStub = sinon.stub().returns(Promise.resolve());
-    sinon.stub(plugin, 'restApi').returns({
-      send: sendStub,
-    });
-    const payload = {foo: 'foo'};
-    const successStub = sinon.stub();
-    instance.call(payload, successStub);
-    assert.isTrue(sendStub.calledWith(
-        'METHOD', '/changes/1/revisions/2/foo~bar', payload));
-  });
-
-  test('call error', async () => {
-    instance.action = {
-      method: 'METHOD',
-      __key: 'key',
-      __url: '/changes/1/revisions/2/foo~bar',
-    };
-    const sendStub = sinon.stub().returns(Promise.reject(new Error('boom')));
-    sinon.stub(plugin, 'restApi').returns({
-      send: sendStub,
-    });
-    const errorStub = sinon.stub();
-    addListenerForTest(document, 'show-alert', errorStub);
-    instance.call();
-    await flush();
-    assert.isTrue(errorStub.calledOnce);
-    assert.equal(errorStub.args[0][0].detail.message,
-        'Plugin network error: Error: boom');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts
new file mode 100644
index 0000000..eaf6612
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts
@@ -0,0 +1,161 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {GrPluginActionContext} from './gr-plugin-action-context';
+import {addListenerForTest, waitEventLoop} from '../../../test/test-utils';
+import {EventType} from '../../../types/events';
+import {assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {SinonStub, stub, spy} from 'sinon';
+import {PopupPluginApi} from '../../../api/popup';
+import {GrButton} from '../gr-button/gr-button';
+import {createChange, createRevision} from '../../../test/test-data-generators';
+import {ActionType} from '../../../api/change-actions';
+import {HttpMethod} from '../../../api/rest-api';
+import {RestPluginApi} from '../../../api/rest';
+
+suite('gr-plugin-action-context tests', () => {
+  let instance: GrPluginActionContext;
+
+  let plugin: PluginApi;
+
+  setup(() => {
+    window.Gerrit.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    instance = new GrPluginActionContext(
+      plugin,
+      {
+        label: 'MyAction',
+        method: HttpMethod.POST,
+        __key: 'key',
+        __url: '/changes/1/revisions/2/foo~bar',
+        __type: ActionType.REVISION,
+      },
+      createChange(),
+      createRevision()
+    );
+  });
+
+  test('popup() and hide()', async () => {
+    const popupApiStub = {
+      _getElement: stub().returns(document.createElement('div')),
+      close: stub(),
+    } as PopupPluginApi & {_getElement: SinonStub; close: SinonStub};
+    stub(plugin, 'popup').resolves(popupApiStub);
+    const el = document.createElement('span');
+    instance.popup(el);
+    await waitEventLoop();
+    assert.isTrue(popupApiStub._getElement.called);
+    instance.hide();
+    assert.isTrue(popupApiStub.close.called);
+  });
+
+  test('textfield', () => {
+    assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
+  });
+
+  test('br', () => {
+    assert.equal(instance.br().tagName, 'BR');
+  });
+
+  test('msg', () => {
+    const el = instance.msg('foobar');
+    assert.equal(el.tagName, 'GR-LABEL');
+    assert.equal(el.textContent, 'foobar');
+  });
+
+  test('div', () => {
+    const el1 = document.createElement('span');
+    el1.textContent = 'foo';
+    const el2 = document.createElement('div');
+    el2.textContent = 'bar';
+    const div = instance.div(el1, el2);
+    assert.equal(div.tagName, 'DIV');
+    assert.equal(div.textContent, 'foobar');
+  });
+
+  suite('button', () => {
+    let clickStub: SinonStub;
+    let button: GrButton;
+    setup(() => {
+      clickStub = stub();
+      button = instance.button('foo', {onclick: clickStub});
+      // If you don't attach a Polymer element to the DOM, then the ready()
+      // callback will not be called and then e.g. this.$ is undefined.
+      document.body.appendChild(button);
+    });
+
+    test('click', async () => {
+      button.click();
+      await waitEventLoop();
+      assert.isTrue(clickStub.called);
+      assert.equal(button.textContent, 'foo');
+    });
+
+    teardown(() => {
+      button.remove();
+    });
+  });
+
+  test('checkbox', () => {
+    const el = instance.checkbox();
+    assert.equal(el.tagName, 'INPUT');
+    assert.equal(el.type, 'checkbox');
+  });
+
+  test('label', () => {
+    const divSpy = spy(instance, 'div');
+    const fakeMsg = document.createElement('gr-label');
+    const fakeCheckbox = document.createElement('input');
+    stub(instance, 'msg').returns(fakeMsg);
+
+    instance.label(fakeCheckbox, 'foo');
+
+    assert.isTrue(divSpy.calledWithExactly(fakeCheckbox, fakeMsg));
+  });
+
+  test('call', () => {
+    const fakeRestApi = {
+      send: stub().resolves(),
+    } as RestPluginApi & {send: SinonStub};
+    stub(plugin, 'restApi').returns(fakeRestApi);
+
+    const payload = {foo: 'foo'};
+    instance.call(payload, () => {});
+
+    assert.isTrue(
+      fakeRestApi.send.calledWith(
+        HttpMethod.POST,
+        '/changes/1/revisions/2/foo~bar',
+        payload
+      )
+    );
+  });
+
+  test('call error', async () => {
+    const fakeRestApi = {
+      send: () => Promise.reject(new Error('boom')),
+    } as unknown as RestPluginApi;
+    stub(plugin, 'restApi').returns(fakeRestApi);
+    const errorStub = stub();
+    addListenerForTest(document, EventType.SHOW_ALERT, errorStub);
+
+    instance.call({}, () => {});
+    await waitEventLoop();
+
+    assert.isTrue(errorStub.calledOnce);
+    assert.equal(
+      errorStub.args[0][0].detail.message,
+      'Plugin network error: Error: boom'
+    );
+  });
+});
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 b039a7e..b1b66ad 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
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {PluginApi} from '../../../api/plugin';
-import {notUndefined} from '../../../types/types';
 import {HookApi, PluginElement} from '../../../api/hook';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -25,16 +13,29 @@
   moduleName: string;
   plugin: PluginApi;
   pluginUrl?: URL;
-  type?: string;
+  type?: EndpointType;
   domHook?: HookApi<PluginElement>;
   slot?: string;
 }
 
+/**
+ * Plugin-provided custom components can affect content in extension
+ * points using one of following methods:
+ * - DECORATE: custom component is set with `content` attribute and may
+ *   decorate (e.g. style) DOM element.
+ * - REPLACE: contents of extension point are replaced with the custom
+ *   component.
+ */
+export enum EndpointType {
+  DECORATE = 'decorate',
+  REPLACE = 'replace',
+}
+
 interface Options {
   endpoint: string;
   dynamicEndpoint?: string;
   slot?: string;
-  type?: string;
+  type?: EndpointType;
   moduleName?: string;
   domHook?: HookApi<PluginElement>;
 }
@@ -72,7 +73,7 @@
   _getOrCreateModuleInfo(plugin: PluginApi, opts: Options): ModuleInfo {
     const {endpoint, slot, type, moduleName, domHook} = opts;
     const existingModule = this._endpoints
-      .get(endpoint!)!
+      .get(endpoint)!
       .find(
         (info: ModuleInfo) =>
           info.plugin === plugin &&
@@ -91,7 +92,7 @@
         domHook,
         slot,
       };
-      this._endpoints.get(endpoint!)!.push(newModule);
+      this._endpoints.get(endpoint)!.push(newModule);
       return newModule;
     }
   }
@@ -136,54 +137,7 @@
    * Get detailed information about modules registered with an extension
    * endpoint.
    */
-  getDetails(name: string, options?: Options): ModuleInfo[] {
-    const type = options && options.type;
-    const moduleName = options && options.moduleName;
-    if (!this._endpoints.has(name)) {
-      return [];
-    } else {
-      return this._endpoints
-        .get(name)!
-        .filter(
-          (item: ModuleInfo) =>
-            (!type || item.type === type) &&
-            (!moduleName || moduleName === item.moduleName)
-        );
-    }
+  getDetails(name: string): ModuleInfo[] {
+    return this._endpoints.get(name) ?? [];
   }
-
-  /**
-   * Get detailed module names for instantiating at the endpoint.
-   */
-  getModules(name: string, options?: Options): string[] {
-    const modulesData = this.getDetails(name, options);
-    if (!modulesData.length) {
-      return [];
-    }
-    return modulesData.map(m => m.moduleName);
-  }
-
-  /**
-   * Get plugin URLs with element and module definitions.
-   */
-  getPlugins(name: string, options?: Options): URL[] {
-    const modulesData = this.getDetails(name, options);
-    if (!modulesData.length) {
-      return [];
-    }
-    return Array.from(new Set(modulesData.map(m => m.pluginUrl))).filter(
-      notUndefined
-    );
-  }
-}
-
-// TODO(dmfilippov): Convert to service and add to appContext
-let pluginEndpoints = new GrPluginEndpoints();
-
-// To avoid mutable-exports, we don't want to export above variable directly
-export function getPluginEndpoints() {
-  return pluginEndpoints;
-}
-export function _testOnly_resetEndpoints() {
-  pluginEndpoints = new GrPluginEndpoints();
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
index c7bdfb4..ddba546 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -1,28 +1,14 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../test/common-test-setup-karma';
-import {resetPlugins} from '../../../test/test-utils';
+import '../../../test/common-test-setup';
 import './gr-js-api-interface';
-import {GrPluginEndpoints} from './gr-plugin-endpoints';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit';
+import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
-
-const pluginApi = _testOnly_initGerritPluginApi();
+import {assert} from '@open-wc/testing';
 
 export class MockHook<T extends PluginElement> implements HookApi<T> {
   handleInstanceDetached(_: T) {}
@@ -53,115 +39,64 @@
 suite('gr-plugin-endpoints tests', () => {
   let instance: GrPluginEndpoints;
   let decoratePlugin: PluginApi;
-  let stylePlugin: PluginApi;
+  let replacePlugin: PluginApi;
   let domHook: HookApi<PluginElement>;
 
   setup(() => {
     domHook = new MockHook<PluginElement>();
     instance = new GrPluginEndpoints();
-    pluginApi.install(
+    window.Gerrit.install(
       plugin => (decoratePlugin = plugin),
       '0.1',
       'http://test.com/plugins/testplugin/static/decorate.js'
     );
     instance.registerModule(decoratePlugin, {
       endpoint: 'my-endpoint',
-      type: 'decorate',
+      type: EndpointType.DECORATE,
       moduleName: 'decorate-module',
       domHook,
     });
-    pluginApi.install(
-      plugin => (stylePlugin = plugin),
+    window.Gerrit.install(
+      plugin => (replacePlugin = plugin),
       '0.1',
-      'http://test.com/plugins/testplugin/static/style.js'
+      'http://test.com/plugins/testplugin/static/replace.js'
     );
-    instance.registerModule(stylePlugin, {
+    instance.registerModule(replacePlugin, {
       endpoint: 'my-endpoint',
-      type: 'style',
-      moduleName: 'style-module',
+      type: EndpointType.REPLACE,
+      moduleName: 'replace-module',
       domHook,
     });
   });
 
-  teardown(() => {
-    resetPlugins();
-  });
-
   test('getDetails all', () => {
     assert.deepEqual(instance.getDetails('my-endpoint'), [
       {
         moduleName: 'decorate-module',
         plugin: decoratePlugin,
         pluginUrl: decoratePlugin._url,
-        type: 'decorate',
+        type: EndpointType.DECORATE,
         domHook,
         slot: undefined,
       },
       {
-        moduleName: 'style-module',
-        plugin: stylePlugin,
-        pluginUrl: stylePlugin._url,
-        type: 'style',
+        moduleName: 'replace-module',
+        plugin: replacePlugin,
+        pluginUrl: replacePlugin._url,
+        type: EndpointType.REPLACE,
         domHook,
         slot: undefined,
       },
     ]);
   });
 
-  test('getDetails by type', () => {
-    assert.deepEqual(
-      instance.getDetails('my-endpoint', {endpoint: 'a-place', type: 'style'}),
-      [
-        {
-          moduleName: 'style-module',
-          plugin: stylePlugin,
-          pluginUrl: stylePlugin._url,
-          type: 'style',
-          domHook,
-          slot: undefined,
-        },
-      ]
-    );
-  });
-
-  test('getDetails by module', () => {
-    assert.deepEqual(
-      instance.getDetails('my-endpoint', {
-        endpoint: 'my-endpoint',
-        moduleName: 'decorate-module',
-      }),
-      [
-        {
-          moduleName: 'decorate-module',
-          plugin: decoratePlugin,
-          pluginUrl: decoratePlugin._url,
-          type: 'decorate',
-          domHook,
-          slot: undefined,
-        },
-      ]
-    );
-  });
-
-  test('getModules', () => {
-    assert.deepEqual(instance.getModules('my-endpoint'), [
-      'decorate-module',
-      'style-module',
-    ]);
-  });
-
-  test('getPlugins URLs are unique', () => {
-    assert.equal(decoratePlugin._url, stylePlugin._url);
-    assert.deepEqual(instance.getPlugins('my-endpoint'), [decoratePlugin._url]);
-  });
-
   test('onNewEndpoint', () => {
     const newModuleStub = sinon.stub();
     instance.setPluginsReady();
     instance.onNewEndpoint('my-endpoint', newModuleStub);
     instance.registerModule(decoratePlugin, {
       endpoint: 'my-endpoint',
-      type: 'replace',
+      type: EndpointType.REPLACE,
       moduleName: 'replace-module',
       domHook,
     });
@@ -169,7 +104,7 @@
       moduleName: 'replace-module',
       plugin: decoratePlugin,
       pluginUrl: decoratePlugin._url,
-      type: 'replace',
+      type: EndpointType.REPLACE,
       domHook,
       slot: undefined,
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 7c99480..a58c6cc 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
@@ -1,27 +1,35 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {appContext} from '../../../services/app-context';
-import {PLUGIN_LOADING_TIMEOUT_MS, getPluginNameFromUrl} from './gr-api-utils';
+import {
+  PLUGIN_LOADING_TIMEOUT_MS,
+  getPluginNameFromUrl,
+  isThemeFile,
+  THEME_JS,
+} from './gr-api-utils';
 import {Plugin} from './gr-public-js-api';
 import {getBaseUrl} from '../../../utils/url-util';
-import {getPluginEndpoints} from './gr-plugin-endpoints';
+import {GrPluginEndpoints} from './gr-plugin-endpoints';
 import {PluginApi} from '../../../api/plugin';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {ShowAlertEventDetail} from '../../../types/events';
+import {fireAlert} from '../../../utils/event-util';
+import {JsApiService} from './gr-js-api-types';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {Finalizable} from '../../../services/registry';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {Gerrit} from '../../../api/gerrit';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {iconStyles} from '../../../styles/gr-icon-styles';
+import {GrJsApiInterface} from './gr-js-api-interface-element';
+import {define} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
 
 enum PluginState {
   /** State that indicates the plugin is pending to be loaded. */
@@ -61,6 +69,8 @@
 // plugins with incompatible version will not be loaded.
 const API_VERSION = '0.1';
 
+export const pluginLoaderToken = define<PluginLoader>('plugin-loader');
+
 /**
  * PluginLoader, responsible for:
  *
@@ -70,72 +80,101 @@
  * Retrieve plugin.
  * Check plugin status and if all plugins loaded.
  */
-export class PluginLoader {
-  _pluginListLoaded = false;
+export class PluginLoader implements Gerrit, Finalizable {
+  public readonly styles = {
+    font: fontStyles,
+    form: formStyles,
+    icon: iconStyles,
+    menuPage: menuPageStyles,
+    spinner: spinnerStyles,
+    subPage: subpageStyles,
+    table: tableStyles,
+    modal: modalStyles,
+  };
 
-  _plugins = new Map<string, PluginObject>();
+  private pluginListLoaded = false;
 
-  _reporting: ReportingService | null = null;
+  private plugins = new Map<string, PluginObject>();
 
   // Promise that resolves when all plugins loaded
-  _loadingPromise: Promise<void> | null = null;
+  private loadingPromise: Promise<void> | null = null;
 
-  // Resolver to resolve _loadingPromise once all plugins loaded
-  _loadingResolver: (() => void) | null = null;
+  // Resolver to resolve loadingPromise once all plugins loaded
+  private loadingResolver: (() => void) | null = null;
 
-  _getReporting() {
-    if (!this._reporting) {
-      this._reporting = appContext.reportingService;
-    }
-    return this._reporting;
+  private instanceId?: string;
+
+  public readonly jsApiService: JsApiService;
+
+  public readonly pluginsModel: PluginsModel;
+
+  public pluginEndPoints: GrPluginEndpoints;
+
+  constructor(
+    private readonly reportingService: ReportingService,
+    private readonly restApiService: RestApiService
+  ) {
+    this.jsApiService = new GrJsApiInterface(
+      () => this.awaitPluginsLoaded(),
+      this.reportingService
+    );
+    this.pluginsModel = new PluginsModel();
+    this.pluginEndPoints = new GrPluginEndpoints();
   }
 
+  finalize() {}
+
   /**
    * Use the plugin name or use the full url if not recognized.
    */
-  _getPluginKeyFromUrl(url: string) {
+  private getPluginKeyFromUrl(url: string) {
     return getPluginNameFromUrl(url) || `${UNKNOWN_PLUGIN_PREFIX}${url}`;
   }
 
   /**
    * Load multiple plugins with certain options.
    */
-  loadPlugins(plugins: string[] = []) {
-    this._pluginListLoaded = true;
+  loadPlugins(plugins: string[] = [], instanceId?: string) {
+    this.instanceId = instanceId;
+    this.pluginListLoaded = true;
 
     plugins.forEach(path => {
-      const url = this._urlFor(path, window.ASSETS_PATH);
-      const pluginKey = this._getPluginKeyFromUrl(url);
+      const url = this.urlFor(path, window.ASSETS_PATH);
+      const pluginKey = this.getPluginKeyFromUrl(url);
       // Skip if already installed.
-      if (this._plugins.has(pluginKey)) return;
-      this._plugins.set(pluginKey, {
+      if (this.plugins.has(pluginKey)) return;
+      this.plugins.set(pluginKey, {
         name: pluginKey,
         url,
         state: PluginState.PENDING,
         plugin: null,
       });
 
-      if (this._isPathEndsWith(url, '.js')) {
-        this._loadJsPlugin(path);
+      if (this.isPathEndsWith(url, '.js')) {
+        this.loadJsPlugin(path);
       } else {
-        this._failToLoad(`Unrecognized plugin path ${path}`, path);
+        this.failToLoad(`Unrecognized plugin path ${path}`, path);
       }
     });
 
     this.awaitPluginsLoaded().then(() => {
       const loaded = this.getPluginsByState(PluginState.LOADED);
       const failed = this.getPluginsByState(PluginState.LOAD_FAILED);
-      this._getReporting().pluginsLoaded(loaded.map(p => p.name));
-      this._getReporting().pluginsFailed(failed.map(p => p.name));
+      this.reportingService.pluginsLoaded(loaded.map(p => p.name));
+      this.reportingService.pluginsFailed(failed.map(p => p.name));
     });
   }
 
-  _isPathEndsWith(url: string | URL, suffix: string) {
+  private isPathEndsWith(url: string | URL, suffix: string) {
     if (!(url instanceof URL)) {
       try {
         url = new URL(url);
-      } catch (e) {
-        this._getReporting().error(e);
+      } catch (e: unknown) {
+        this.reportingService.error(
+          'GrPluginLoader',
+          new Error('url parse error'),
+          e
+        );
         return false;
       }
     }
@@ -144,7 +183,7 @@
   }
 
   private getPluginsByState(state: PluginState) {
-    return [...this._plugins.values()].filter(p => p.state === state);
+    return [...this.plugins.values()].filter(p => p.state === state);
   }
 
   install(
@@ -162,139 +201,156 @@
       src = script && script.baseURI;
     }
     if (!src) {
-      this._failToLoad('Failed to determine src.');
+      this.failToLoad('Failed to determine src.');
       return;
     }
     if (version && version !== API_VERSION) {
-      this._failToLoad(
+      this.failToLoad(
         `Plugin ${src} install error: only version ${API_VERSION} is supported in PolyGerrit. ${version} was given.`,
         src
       );
       return;
     }
 
-    const url = this._urlFor(src);
+    const url = this.urlFor(src);
     const pluginObject = this.getPlugin(url);
     let plugin = pluginObject && pluginObject.plugin;
     if (!plugin) {
-      plugin = new Plugin(url);
+      plugin = new Plugin(
+        url,
+        this.jsApiService,
+        this.reportingService,
+        this.restApiService,
+        this.pluginsModel,
+        this.pluginEndPoints
+      );
     }
     try {
       callback(plugin);
-      this._pluginInstalled(url, plugin);
-    } catch (e) {
-      this._failToLoad(`${e.name}: ${e.message}`, src);
-    }
-  }
-
-  arePluginsLoaded() {
-    if (!this._pluginListLoaded) return false;
-    return this.getPluginsByState(PluginState.PENDING).length === 0;
-  }
-
-  _checkIfCompleted() {
-    if (this.arePluginsLoaded()) {
-      getPluginEndpoints().setPluginsReady();
-      if (this._loadingResolver) {
-        this._loadingResolver();
-        this._loadingResolver = null;
-        this._loadingPromise = null;
+      this.pluginInstalled(url, plugin);
+    } catch (e: unknown) {
+      if (e instanceof Error) {
+        this.failToLoad(`${e.name}: ${e.message}`, src);
+      } else {
+        this.reportingService.error(
+          'GrPluginLoader',
+          new Error('plugin callback error'),
+          e
+        );
       }
     }
   }
 
-  _timeout() {
+  arePluginsLoaded() {
+    if (!this.pluginListLoaded) return false;
+    return this.getPluginsByState(PluginState.PENDING).length === 0;
+  }
+
+  private checkIfCompleted() {
+    if (this.arePluginsLoaded()) {
+      this.pluginEndPoints.setPluginsReady();
+      if (this.loadingResolver) {
+        this.loadingResolver();
+        this.loadingResolver = null;
+        this.loadingPromise = null;
+      }
+    }
+  }
+
+  private timeout() {
     const pending = this.getPluginsByState(PluginState.PENDING);
     for (const plugin of pending) {
-      this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
+      this.updatePluginState(plugin.url, PluginState.LOAD_FAILED);
     }
-    this._checkIfCompleted();
-    return `Timeout when loading plugins: ${pending
+    this.checkIfCompleted();
+    const errorMessage = `Timeout when loading plugins: ${pending
       .map(p => p.name)
       .join(',')}`;
+    fireAlert(document, errorMessage);
+    return errorMessage;
   }
 
-  _failToLoad(message: string, pluginUrl?: string) {
+  // Private but mocked in tests.
+  failToLoad(message: string, pluginUrl?: string) {
     // Show an alert with the error
-    document.dispatchEvent(
-      new CustomEvent<ShowAlertEventDetail>('show-alert', {
-        detail: {
-          message: `Plugin install error: ${message} from ${pluginUrl}`,
-        },
-      })
-    );
-    if (pluginUrl) this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
-    this._checkIfCompleted();
+    fireAlert(document, `Plugin install error: ${message} from ${pluginUrl}`);
+    if (pluginUrl) this.updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
+    this.checkIfCompleted();
   }
 
-  _updatePluginState(pluginUrl: string, state: PluginState): PluginObject {
-    const key = this._getPluginKeyFromUrl(pluginUrl);
-    if (this._plugins.has(key)) {
-      this._plugins.get(key)!.state = state;
+  private updatePluginState(
+    pluginUrl: string,
+    state: PluginState
+  ): PluginObject {
+    const key = this.getPluginKeyFromUrl(pluginUrl);
+    if (this.plugins.has(key)) {
+      this.plugins.get(key)!.state = state;
     } else {
       // Plugin is not recorded for some reason.
       console.info(`Plugin loaded separately: ${pluginUrl}`);
-      this._plugins.set(key, {
+      this.plugins.set(key, {
         name: key,
         url: pluginUrl,
         state,
         plugin: null,
       });
     }
-    console.info(`Plugin ${key} ${state}`);
-    return this._plugins.get(key)!;
+    console.debug(`Plugin ${key} ${state}`);
+    return this.plugins.get(key)!;
   }
 
-  _pluginInstalled(url: string, plugin: PluginApi) {
-    const pluginObj = this._updatePluginState(url, PluginState.LOADED);
+  private pluginInstalled(url: string, plugin: PluginApi) {
+    const pluginObj = this.updatePluginState(url, PluginState.LOADED);
     pluginObj.plugin = plugin;
-    this._getReporting().pluginLoaded(plugin.getPluginName() || url);
-    this._checkIfCompleted();
+    this.reportingService.pluginLoaded(plugin.getPluginName() || url);
+    this.checkIfCompleted();
   }
 
   /**
    * Checks if given plugin path/url is enabled or not.
    */
   isPluginEnabled(pathOrUrl: string) {
-    const url = this._urlFor(pathOrUrl);
-    const key = this._getPluginKeyFromUrl(url);
-    return this._plugins.has(key);
+    const url = this.urlFor(pathOrUrl);
+    const key = this.getPluginKeyFromUrl(url);
+    return this.plugins.has(key);
   }
 
   /**
    * Returns the plugin object with a given url.
    */
   getPlugin(pathOrUrl: string) {
-    const url = this._urlFor(pathOrUrl);
-    const key = this._getPluginKeyFromUrl(url);
-    return this._plugins.get(key);
+    const url = this.urlFor(pathOrUrl);
+    const key = this.getPluginKeyFromUrl(url);
+    return this.plugins.get(key);
   }
 
   /**
    * Checks if given plugin path/url is loaded or not.
    */
   isPluginLoaded(pathOrUrl: string): boolean {
-    const url = this._urlFor(pathOrUrl);
-    const key = this._getPluginKeyFromUrl(url);
-    return this._plugins.has(key)
-      ? this._plugins.get(key)!.state === PluginState.LOADED
+    const url = this.urlFor(pathOrUrl);
+    const key = this.getPluginKeyFromUrl(url);
+    return this.plugins.has(key)
+      ? this.plugins.get(key)!.state === PluginState.LOADED
       : false;
   }
 
-  _loadJsPlugin(pluginUrl: string) {
-    const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
-    const urlWithoutAP = this._urlFor(pluginUrl);
+  // Private but mocked in tests.
+  loadJsPlugin(pluginUrl: string) {
+    const urlWithAP = this.urlFor(pluginUrl, window.ASSETS_PATH);
+    const urlWithoutAP = this.urlFor(pluginUrl);
     let onerror = undefined;
     if (urlWithAP !== urlWithoutAP) {
-      onerror = () => this._createScriptTag(urlWithoutAP);
+      onerror = () => this.createScriptTag(urlWithoutAP);
     }
 
-    this._createScriptTag(urlWithAP, onerror);
+    this.createScriptTag(urlWithAP, onerror);
   }
 
-  _createScriptTag(url: string, onerror?: OnErrorEventHandler) {
+  // Private but mocked in tests.
+  createScriptTag(url: string, onerror?: OnErrorEventHandler) {
     if (!onerror) {
-      onerror = () => this._failToLoad(`${url} load error`, url);
+      onerror = () => this.failToLoad(`${url} load error`, url);
     }
 
     const el = document.createElement('script');
@@ -308,17 +364,17 @@
     return document.body.appendChild(el);
   }
 
-  _urlFor(pathOrUrl: string, assetsPath?: string): string {
-    // theme is per host, should always load from assetsPath
-    const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.js');
-    const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
+  private urlFor(pathOrUrl: string, assetsPath?: string): string {
+    if (isThemeFile(pathOrUrl)) {
+      if (assetsPath && this.instanceId) {
+        return `${assetsPath}/hosts/${this.instanceId}${THEME_JS}`;
+      }
+      return window.location.origin + getBaseUrl() + THEME_JS;
+    }
+
     if (pathOrUrl.startsWith('http')) {
       // Plugins are loaded from another domain or preloaded.
-      if (
-        pathOrUrl.includes(location.host) &&
-        shouldTryLoadFromAssetsPathFirst &&
-        assetsPath
-      ) {
+      if (pathOrUrl.includes(location.host) && assetsPath) {
         // if is loading from host server, try replace with cdn when assetsPath provided
         return pathOrUrl.replace(location.origin, assetsPath);
       }
@@ -328,49 +384,36 @@
     if (!pathOrUrl.startsWith('/')) {
       pathOrUrl = '/' + pathOrUrl;
     }
-
-    if (shouldTryLoadFromAssetsPathFirst && assetsPath) {
+    if (assetsPath) {
       return assetsPath + pathOrUrl;
     }
-
     return window.location.origin + getBaseUrl() + pathOrUrl;
   }
 
   awaitPluginsLoaded() {
     // Resolve if completed.
-    this._checkIfCompleted();
+    this.checkIfCompleted();
 
     if (this.arePluginsLoaded()) {
       return Promise.resolve();
     }
-    if (!this._loadingPromise) {
+    if (!this.loadingPromise) {
       // 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<void>(resolve => (this._loadingResolver = resolve)),
+      this.loadingPromise = Promise.race([
+        new Promise<void>(resolve => (this.loadingResolver = resolve)),
         new Promise(
           (_, reject) =>
             (timerId = window.setTimeout(() => {
-              reject(new Error(this._timeout()));
+              reject(new Error(this.timeout()));
             }, PLUGIN_LOADING_TIMEOUT_MS))
         ),
       ]).finally(() => {
         if (timerId) clearTimeout(timerId);
       }) as Promise<void>;
     }
-    return this._loadingPromise;
+    return this.loadingPromise;
   }
 }
-
-// TODO(dmfilippov): Convert to service and add to appContext
-let pluginLoader = new PluginLoader();
-export function _testOnly_resetPluginLoader() {
-  pluginLoader = new PluginLoader();
-  return pluginLoader;
-}
-
-export function getPluginLoader() {
-  return pluginLoader;
-}
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
deleted file mode 100644
index ab69267..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ /dev/null
@@ -1,400 +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 {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
-import {_testOnly_resetPluginLoader} from './gr-plugin-loader.js';
-import {resetPlugins, stubBaseUrl} from '../../../test/test-utils.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-plugin-loader tests', () => {
-  let plugin;
-
-  let url;
-  let pluginLoader;
-  let clock;
-
-  setup(() => {
-    clock = sinon.useFakeTimers();
-
-    stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
-    stubRestApi('send').returns(Promise.resolve({status: 200}));
-    pluginLoader = _testOnly_resetPluginLoader();
-    sinon.stub(document.body, 'appendChild');
-    url = window.location.origin;
-  });
-
-  teardown(() => {
-    clock.restore();
-    resetPlugins();
-  });
-
-  test('reuse plugin for install calls', () => {
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-
-    let otherPlugin;
-    pluginApi.install(p => { otherPlugin = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/test.js');
-    assert.strictEqual(plugin, otherPlugin);
-  });
-
-  test('versioning', () => {
-    const callback = sinon.spy();
-    pluginApi.install(callback, '0.0pre-alpha');
-    assert(callback.notCalled);
-  });
-
-  test('report pluginsLoaded', async () => {
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
-    pluginsLoadedStub.reset();
-    window.Gerrit._loadPlugins([]);
-    await flush();
-    assert.isTrue(pluginsLoadedStub.called);
-  });
-
-  test('arePluginsLoaded', () => {
-    assert.isFalse(pluginLoader.arePluginsLoaded());
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-
-    pluginLoader.loadPlugins(plugins);
-    assert.isFalse(pluginLoader.arePluginsLoaded());
-    // Timeout on loading plugins
-    clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
-
-    flush();
-    assert.isTrue(pluginLoader.arePluginsLoaded());
-  });
-
-  test('plugins installed successfully', async () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => void 0, undefined, url);
-    });
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
-
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-    pluginLoader.loadPlugins(plugins);
-
-    await flush();
-    assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
-    assert.isTrue(pluginLoader.arePluginsLoaded());
-  });
-
-  test('isPluginEnabled and isPluginLoaded', () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => void 0, undefined, url);
-    });
-
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-      'bar/static/test.js',
-    ];
-    pluginLoader.loadPlugins(plugins);
-    assert.isTrue(
-        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
-    );
-
-    flush();
-    assert.isTrue(pluginLoader.arePluginsLoaded());
-    assert.isTrue(
-        plugins.every(plugin => pluginLoader.isPluginLoaded(plugin))
-    );
-  });
-
-  test('plugins installed mixed result, 1 fail 1 succeed', async () => {
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-
-    const alertStub = sinon.stub();
-    addListenerForTest(document, 'show-alert', alertStub);
-
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => {
-        if (url === plugins[0]) {
-          throw new Error('failed');
-        }
-      }, undefined, url);
-    });
-
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
-
-    pluginLoader.loadPlugins(plugins);
-
-    await flush();
-    assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
-    assert.isTrue(pluginLoader.arePluginsLoaded());
-    assert.isTrue(alertStub.calledOnce);
-  });
-
-  test('isPluginEnabled and isPluginLoaded for mixed results', async () => {
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-
-    const alertStub = sinon.stub();
-    addListenerForTest(document, 'show-alert', alertStub);
-
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => {
-        if (url === plugins[0]) {
-          throw new Error('failed');
-        }
-      }, undefined, url);
-    });
-
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
-
-    pluginLoader.loadPlugins(plugins);
-    assert.isTrue(
-        plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
-    );
-
-    await flush();
-    assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
-    assert.isTrue(pluginLoader.arePluginsLoaded());
-    assert.isTrue(alertStub.calledOnce);
-    assert.isTrue(pluginLoader.isPluginLoaded(plugins[1]));
-    assert.isFalse(pluginLoader.isPluginLoaded(plugins[0]));
-  });
-
-  test('plugins installed all failed', async () => {
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-
-    const alertStub = sinon.stub();
-    addListenerForTest(document, 'show-alert', alertStub);
-
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => {
-        throw new Error('failed');
-      }, undefined, url);
-    });
-
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
-
-    pluginLoader.loadPlugins(plugins);
-
-    await flush();
-    assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
-    assert.isTrue(pluginLoader.arePluginsLoaded());
-    assert.isTrue(alertStub.calledTwice);
-  });
-
-  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();
-    addListenerForTest(document, 'show-alert', alertStub);
-
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => {
-      }, url === plugins[0] ? '' : 'alpha', url);
-    });
-
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
-
-    pluginLoader.loadPlugins(plugins);
-
-    await flush();
-    assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
-    assert.isTrue(pluginLoader.arePluginsLoaded());
-    assert.isTrue(alertStub.calledOnce);
-  });
-
-  test('multiple assets for same plugin installed successfully', async () => {
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => void 0, undefined, url);
-    });
-    const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
-        'pluginsLoaded');
-
-    const plugins = [
-      'http://test.com/plugins/foo/static/test.js',
-      'http://test.com/plugins/foo/static/test2.js',
-      'http://test.com/plugins/bar/static/test.js',
-    ];
-    pluginLoader.loadPlugins(plugins);
-
-    await flush();
-    assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
-    assert.isTrue(pluginLoader.arePluginsLoaded());
-  });
-
-  suite('plugin path and url', () => {
-    let loadJsPluginStub;
-    setup(() => {
-      loadJsPluginStub = sinon.stub();
-      sinon.stub(pluginLoader, '_createScriptTag').callsFake( url => {
-        loadJsPluginStub(url);
-      });
-    });
-
-    test('invalid plugin path', () => {
-      const failToLoadStub = sinon.stub();
-      sinon.stub(pluginLoader, '_failToLoad').callsFake((...args) => {
-        failToLoadStub(...args);
-      });
-
-      pluginLoader.loadPlugins([
-        'foo/bar',
-      ]);
-
-      assert.isTrue(failToLoadStub.calledOnce);
-      assert.isTrue(failToLoadStub.calledWithExactly(
-          'Unrecognized plugin path foo/bar',
-          'foo/bar'
-      ));
-    });
-
-    test('relative path for plugins', () => {
-      pluginLoader.loadPlugins([
-        'foo/bar.js',
-      ]);
-
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`)
-      );
-    });
-
-    test('relative path should honor getBaseUrl', () => {
-      const testUrl = '/test';
-      stubBaseUrl(testUrl);
-
-      pluginLoader.loadPlugins([
-        'foo/bar.js',
-      ]);
-
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
-      );
-    });
-
-    test('absolute path for plugins', () => {
-      pluginLoader.loadPlugins([
-        'http://e.com/foo/bar.js',
-      ]);
-
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`)
-      );
-    });
-  });
-
-  suite('With ASSETS_PATH', () => {
-    let loadJsPluginStub;
-    setup(() => {
-      window.ASSETS_PATH = 'https://cdn.com';
-      loadJsPluginStub = sinon.stub();
-      sinon.stub(pluginLoader, '_createScriptTag').callsFake( url => {
-        loadJsPluginStub(url);
-      });
-    });
-
-    teardown(() => {
-      window.ASSETS_PATH = '';
-    });
-
-    test('Should try load plugins from assets path instead', () => {
-      pluginLoader.loadPlugins([
-        'foo/bar.js',
-      ]);
-
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
-    });
-
-    test('Should honor original path if exists', () => {
-      pluginLoader.loadPlugins([
-        'http://e.com/foo/bar.js',
-      ]);
-
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`http://e.com/foo/bar.js`));
-    });
-
-    test('Should try replace current host with assetsPath', () => {
-      const host = window.location.origin;
-      pluginLoader.loadPlugins([
-        `${host}/foo/bar.js`,
-      ]);
-
-      assert.isTrue(loadJsPluginStub.calledOnce);
-      assert.isTrue(
-          loadJsPluginStub.calledWithExactly(`https://cdn.com/foo/bar.js`));
-    });
-  });
-
-  test('adds js plugins will call the body', () => {
-    pluginLoader.loadPlugins([
-      'http://e.com/foo/bar.js',
-      'http://e.com/bar/foo.js',
-    ]);
-    assert.isTrue(document.body.appendChild.calledTwice);
-  });
-
-  test('can call awaitPluginsLoaded multiple times', async () => {
-    const plugins = [
-      'http://e.com/foo/bar.js',
-      'http://e.com/bar/foo.js',
-    ];
-
-    let installed = false;
-    function pluginCallback(url) {
-      if (url === plugins[1]) {
-        installed = true;
-      }
-    }
-    sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-      pluginApi.install(() => pluginCallback(url), undefined, url);
-    });
-
-    pluginLoader.loadPlugins(plugins);
-
-    await pluginLoader.awaitPluginsLoaded();
-    assert.isTrue(installed);
-    await pluginLoader.awaitPluginsLoaded();
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
new file mode 100644
index 0000000..acce236
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
@@ -0,0 +1,421 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
+import {PluginLoader} from './gr-plugin-loader';
+import {stubBaseUrl, waitEventLoop} from '../../../test/test-utils';
+import {addListenerForTest, stubRestApi} from '../../../test/test-utils';
+import {PluginApi} from '../../../api/plugin';
+import {SinonFakeTimers} from 'sinon';
+import {Timestamp} from '../../../api/rest-api';
+import {EventType} from '../../../types/events';
+import {assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
+
+suite('gr-plugin-loader tests', () => {
+  let plugin: PluginApi;
+
+  let url: string;
+  let pluginLoader: PluginLoader;
+  let clock: SinonFakeTimers;
+  let bodyStub: sinon.SinonStub;
+
+  setup(() => {
+    clock = sinon.useFakeTimers();
+
+    stubRestApi('getAccount').returns(
+      Promise.resolve({name: 'Judy Hopps', registered_on: '' as Timestamp})
+    );
+    stubRestApi('send').returns(
+      Promise.resolve({...new Response(), status: 200})
+    );
+    pluginLoader = new PluginLoader(
+      getAppContext().reportingService,
+      getAppContext().restApiService
+    );
+    bodyStub = sinon.stub(document.body, 'appendChild');
+    url = window.location.origin;
+  });
+
+  teardown(() => {
+    clock.restore();
+  });
+
+  test('reuse plugin for install calls', () => {
+    pluginLoader.install(
+      p => {
+        plugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+
+    let otherPlugin;
+    pluginLoader.install(
+      p => {
+        otherPlugin = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    assert.strictEqual(plugin, otherPlugin);
+  });
+
+  test('versioning', () => {
+    const callback = sinon.spy();
+    pluginLoader.install(callback, '0.0pre-alpha');
+    assert(callback.notCalled);
+  });
+
+  test('report pluginsLoaded', async () => {
+    const pluginsLoadedStub = sinon.stub(
+      getAppContext().reportingService,
+      'pluginsLoaded'
+    );
+    pluginsLoadedStub.reset();
+    pluginLoader.loadPlugins([]);
+    await waitEventLoop();
+    assert.isTrue(pluginsLoadedStub.called);
+  });
+
+  test('arePluginsLoaded', async () => {
+    assert.isFalse(pluginLoader.arePluginsLoaded());
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    pluginLoader.loadPlugins(plugins);
+    assert.isFalse(pluginLoader.arePluginsLoaded());
+    // Timeout on loading plugins
+    clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+    await waitEventLoop();
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+  });
+
+  test('plugins installed successfully', async () => {
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sinon.stub(
+      getAppContext().reportingService,
+      'pluginsLoaded'
+    );
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+    pluginLoader.loadPlugins(plugins);
+
+    await waitEventLoop();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+  });
+
+  test('isPluginEnabled and isPluginLoaded', async () => {
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(() => void 0, undefined, url);
+    });
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+      'bar/static/test.js',
+    ];
+    pluginLoader.loadPlugins(plugins);
+    assert.isTrue(
+      plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
+    );
+
+    await waitEventLoop();
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+    assert.isTrue(plugins.every(plugin => pluginLoader.isPluginLoaded(plugin)));
+  });
+
+  test('plugins installed mixed result, 1 fail 1 succeed', async () => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(
+        () => {
+          if (url === plugins[0]) {
+            throw new Error('failed');
+          }
+        },
+        undefined,
+        url
+      );
+    });
+
+    const pluginsLoadedStub = sinon.stub(
+      getAppContext().reportingService,
+      'pluginsLoaded'
+    );
+
+    pluginLoader.loadPlugins(plugins);
+
+    await waitEventLoop();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('isPluginEnabled and isPluginLoaded for mixed results', async () => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(
+        () => {
+          if (url === plugins[0]) {
+            throw new Error('failed');
+          }
+        },
+        undefined,
+        url
+      );
+    });
+
+    const pluginsLoadedStub = sinon.stub(
+      getAppContext().reportingService,
+      'pluginsLoaded'
+    );
+
+    pluginLoader.loadPlugins(plugins);
+    assert.isTrue(
+      plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
+    );
+
+    await waitEventLoop();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+    assert.isTrue(alertStub.calledOnce);
+    assert.isTrue(pluginLoader.isPluginLoaded(plugins[1]));
+    assert.isFalse(pluginLoader.isPluginLoaded(plugins[0]));
+  });
+
+  test('plugins installed all failed', async () => {
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+
+    const alertStub = sinon.stub();
+    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(
+        () => {
+          throw new Error('failed');
+        },
+        undefined,
+        url
+      );
+    });
+
+    const pluginsLoadedStub = sinon.stub(
+      getAppContext().reportingService,
+      'pluginsLoaded'
+    );
+
+    pluginLoader.loadPlugins(plugins);
+
+    await waitEventLoop();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+    assert.isTrue(alertStub.calledTwice);
+  });
+
+  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();
+    addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(() => {}, url === plugins[0] ? '' : 'alpha', url);
+    });
+
+    const pluginsLoadedStub = sinon.stub(
+      getAppContext().reportingService,
+      'pluginsLoaded'
+    );
+
+    pluginLoader.loadPlugins(plugins);
+
+    await waitEventLoop();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('multiple assets for same plugin installed successfully', async () => {
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(() => void 0, undefined, url);
+    });
+    const pluginsLoadedStub = sinon.stub(
+      getAppContext().reportingService,
+      'pluginsLoaded'
+    );
+
+    const plugins = [
+      'http://test.com/plugins/foo/static/test.js',
+      'http://test.com/plugins/foo/static/test2.js',
+      'http://test.com/plugins/bar/static/test.js',
+    ];
+    pluginLoader.loadPlugins(plugins);
+
+    await waitEventLoop();
+    assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+    assert.isTrue(pluginLoader.arePluginsLoaded());
+  });
+
+  suite('plugin path and url', () => {
+    let loadJsPluginStub: sinon.SinonStub;
+    setup(() => {
+      loadJsPluginStub = sinon.stub();
+      sinon
+        .stub(pluginLoader, 'createScriptTag')
+        .callsFake((url: string, _onerror?: OnErrorEventHandler | undefined) =>
+          loadJsPluginStub(url)
+        );
+    });
+
+    test('invalid plugin path', () => {
+      const failToLoadStub = sinon.stub();
+      sinon.stub(pluginLoader, 'failToLoad').callsFake((...args) => {
+        failToLoadStub(...args);
+      });
+
+      pluginLoader.loadPlugins(['foo/bar']);
+
+      assert.isTrue(failToLoadStub.calledOnce);
+      assert.isTrue(
+        failToLoadStub.calledWithExactly(
+          'Unrecognized plugin path foo/bar',
+          'foo/bar'
+        )
+      );
+    });
+
+    test('relative path for plugins', () => {
+      pluginLoader.loadPlugins(['foo/bar.js']);
+
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(loadJsPluginStub.calledWithExactly(`${url}/foo/bar.js`));
+    });
+
+    test('relative path should honor getBaseUrl', () => {
+      const testUrl = '/test';
+      stubBaseUrl(testUrl);
+
+      pluginLoader.loadPlugins(['foo/bar.js']);
+
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+        loadJsPluginStub.calledWithExactly(`${url}${testUrl}/foo/bar.js`)
+      );
+    });
+
+    test('absolute path for plugins', () => {
+      pluginLoader.loadPlugins(['http://e.com/foo/bar.js']);
+
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+        loadJsPluginStub.calledWithExactly('http://e.com/foo/bar.js')
+      );
+    });
+  });
+
+  suite('With ASSETS_PATH', () => {
+    let loadJsPluginStub: sinon.SinonStub;
+    setup(() => {
+      window.ASSETS_PATH = 'https://cdn.com';
+      loadJsPluginStub = sinon.stub();
+      sinon
+        .stub(pluginLoader, 'createScriptTag')
+        .callsFake((url: string, _onerror?: OnErrorEventHandler | undefined) =>
+          loadJsPluginStub(url)
+        );
+    });
+
+    teardown(() => {
+      window.ASSETS_PATH = '';
+    });
+
+    test('Should try load plugins from assets path instead', () => {
+      pluginLoader.loadPlugins(['foo/bar.js']);
+
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+        loadJsPluginStub.calledWithExactly('https://cdn.com/foo/bar.js')
+      );
+    });
+
+    test('Should honor original path if exists', () => {
+      pluginLoader.loadPlugins(['http://e.com/foo/bar.js']);
+
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+        loadJsPluginStub.calledWithExactly('http://e.com/foo/bar.js')
+      );
+    });
+
+    test('Should try replace current host with assetsPath', () => {
+      const host = window.location.origin;
+      pluginLoader.loadPlugins([`${host}/foo/bar.js`]);
+
+      assert.isTrue(loadJsPluginStub.calledOnce);
+      assert.isTrue(
+        loadJsPluginStub.calledWithExactly('https://cdn.com/foo/bar.js')
+      );
+    });
+  });
+
+  test('adds js plugins will call the body', () => {
+    pluginLoader.loadPlugins([
+      'http://e.com/foo/bar.js',
+      'http://e.com/bar/foo.js',
+    ]);
+    assert.isTrue(bodyStub.calledTwice);
+  });
+
+  test('can call awaitPluginsLoaded multiple times', async () => {
+    const plugins = ['http://e.com/foo/bar.js', 'http://e.com/bar/foo.js'];
+
+    let installed = false;
+    function pluginCallback(url: string) {
+      if (url === plugins[1]) {
+        installed = true;
+      }
+    }
+    sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+      pluginLoader.install(() => pluginCallback(url), undefined, url);
+    });
+
+    pluginLoader.loadPlugins(plugins);
+
+    await pluginLoader.awaitPluginsLoaded();
+    assert.isTrue(installed);
+    await pluginLoader.awaitPluginsLoaded();
+  });
+});
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 2b6db21..65e4960 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
@@ -1,24 +1,14 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 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';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 async function getErrorMessage(response: Response): Promise<string> {
   const text = await response.text();
@@ -35,11 +25,12 @@
 }
 
 export class GrPluginRestApi implements RestPluginApi {
-  private readonly restApi = appContext.restApiService;
-
-  private readonly reporting = appContext.reportingService;
-
-  constructor(readonly plugin: PluginApi, private readonly prefix = '') {
+  constructor(
+    private readonly restApi: RestApiService,
+    private readonly reporting: ReportingService,
+    readonly plugin: PluginApi,
+    private readonly prefix = ''
+  ) {
     this.reporting.trackApi(this.plugin, 'rest', 'constructor');
   }
 
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
deleted file mode 100644
index d2b5658..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.js
+++ /dev/null
@@ -1,134 +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 {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;
-
-  setup(() => {
-    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();
-  });
-
-  test('fetch', () => {
-    const payload = {foo: 'foo'};
-    return instance.fetch('HTTP_METHOD', '/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
-      assert.equal(r.status, 200);
-      assert.isFalse(getResponseObjectStub.called);
-    });
-  });
-
-  test('send', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.send('HTTP_METHOD', '/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('get', () => {
-    const response = {foo: 'foo'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.get('/url').then(r => {
-      assert.isTrue(sendStub.calledWith('GET', '/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('post', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.post('/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('POST', '/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('put', () => {
-    const payload = {foo: 'foo'};
-    const response = {bar: 'bar'};
-    getResponseObjectStub.returns(Promise.resolve(response));
-    return instance.put('/url', payload).then(r => {
-      assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('delete works', () => {
-    const response = {status: 204};
-    sendStub.returns(Promise.resolve(response));
-    return instance.delete('/url').then(r => {
-      assert.isTrue(sendStub.calledWith('DELETE', '/url'));
-      assert.strictEqual(r, response);
-    });
-  });
-
-  test('delete fails', () => {
-    sendStub.returns(Promise.resolve(
-        {status: 400, text() { return Promise.resolve('text'); }}));
-    return instance.delete('/url').then(r => {
-      throw new Error('Should not resolve');
-    })
-        .catch(err => {
-          assert.isTrue(sendStub.calledWith('DELETE', '/url'));
-          assert.equal('text', err.message);
-        });
-  });
-
-  test('getLoggedIn', () => {
-    const stub = stubRestApi('getLoggedIn').returns(Promise.resolve(true));
-    return instance.getLoggedIn().then(result => {
-      assert.isTrue(stub.calledOnce);
-      assert.isTrue(result);
-    });
-  });
-
-  test('getVersion', () => {
-    const stub = stubRestApi('getVersion').returns(Promise.resolve('foo bar'));
-    return instance.getVersion().then(result => {
-      assert.isTrue(stub.calledOnce);
-      assert.equal(result, 'foo bar');
-    });
-  });
-
-  test('getConfig', () => {
-    const stub = stubRestApi('getConfig').returns(Promise.resolve('foo bar'));
-    return instance.getConfig().then(result => {
-      assert.isTrue(stub.calledOnce);
-      assert.equal(result, 'foo bar');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
new file mode 100644
index 0000000..c5bef85
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
@@ -0,0 +1,127 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {GrPluginRestApi} from './gr-plugin-rest-api';
+import {assertFails, stubRestApi} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {
+  createAccountDetailWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {HttpMethod} from '../../../api/rest-api';
+import {getAppContext} from '../../../services/app-context';
+
+suite('gr-plugin-rest-api tests', () => {
+  let instance: GrPluginRestApi;
+  let getResponseObjectStub: sinon.SinonStub;
+  let sendStub: sinon.SinonStub;
+
+  setup(() => {
+    stubRestApi('getAccount').resolves(createAccountDetailWithId());
+    getResponseObjectStub = stubRestApi('getResponseObject').resolves();
+    sendStub = stubRestApi('send').resolves({...new Response(), status: 200});
+    let pluginApi: PluginApi;
+    window.Gerrit.install(
+      p => {
+        pluginApi = p;
+      },
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+    instance = new GrPluginRestApi(
+      getAppContext().restApiService,
+      getAppContext().reportingService,
+      pluginApi!
+    );
+  });
+
+  test('fetch', async () => {
+    const payload = {foo: 'foo'};
+    const r = await instance.fetch(HttpMethod.POST, '/url', payload);
+    assert.isTrue(sendStub.calledWith(HttpMethod.POST, '/url', payload));
+    assert.equal(r.status, 200);
+    assert.isFalse(getResponseObjectStub.called);
+  });
+
+  test('send', async () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.resolves(response);
+    const r = await instance.send(HttpMethod.POST, '/url', payload);
+    assert.isTrue(sendStub.calledWith(HttpMethod.POST, '/url', payload));
+    assert.strictEqual(r, response);
+  });
+
+  test('get', async () => {
+    const response = {foo: 'foo'};
+    getResponseObjectStub.resolves(response);
+    const r = await instance.get('/url');
+    assert.isTrue(sendStub.calledWith('GET', '/url'));
+    assert.strictEqual(r, response);
+  });
+
+  test('post', async () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.resolves(response);
+    const r = await instance.post('/url', payload);
+    assert.isTrue(sendStub.calledWith('POST', '/url', payload));
+    assert.strictEqual(r, response);
+  });
+
+  test('put', async () => {
+    const payload = {foo: 'foo'};
+    const response = {bar: 'bar'};
+    getResponseObjectStub.resolves(response);
+    const r = await instance.put('/url', payload);
+    assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
+    assert.strictEqual(r, response);
+  });
+
+  test('delete works', async () => {
+    const response = {status: 204};
+    sendStub.resolves(response);
+    const r = await instance.delete('/url');
+    assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+    assert.strictEqual(r, response);
+  });
+
+  test('delete fails', async () => {
+    sendStub.resolves({
+      status: 400,
+      text() {
+        return Promise.resolve('text');
+      },
+    });
+    const error = await assertFails(instance.delete('/url'));
+    assert.equal('text', (error as Error).message);
+    assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+  });
+
+  test('getLoggedIn', async () => {
+    const stub = stubRestApi('getLoggedIn').resolves(true);
+    const loggedIn = await instance.getLoggedIn();
+    assert.isTrue(stub.calledOnce);
+    assert.isTrue(loggedIn);
+  });
+
+  test('getVersion', async () => {
+    const stub = stubRestApi('getVersion').resolves('foo bar');
+    const version = await instance.getVersion();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(version, 'foo bar');
+  });
+
+  test('getConfig', async () => {
+    const info = createServerInfo();
+    const stub = stubRestApi('getConfig').resolves(info);
+    const config = await instance.getConfig();
+    assert.isTrue(stub.calledOnce);
+    assert.equal(config, info);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts
new file mode 100644
index 0000000..13eefc8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {PluginApi} from '../../../api/plugin';
+import {StylePluginApi} from '../../../api/styles';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+
+function getOrCreatePluginStyleEl(): HTMLStyleElement {
+  const el =
+    document.head.querySelector<HTMLStyleElement>('style#plugin-style');
+  if (el) return el;
+
+  const styleEl = document.createElement('style');
+  styleEl.setAttribute('id', 'plugin-style');
+  // Append at the end so that they override the default light and dark theme
+  // styles.
+  document.head.appendChild(styleEl);
+  return styleEl;
+}
+
+export class GrPluginStyleApi implements StylePluginApi {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly plugin: PluginApi
+  ) {
+    this.reporting.trackApi(this.plugin, 'style', 'constructor');
+  }
+
+  insertCSSRule(rule: string): void {
+    this.reporting.trackApi(this.plugin, 'style', 'insertCSSRule');
+
+    const styleEl = getOrCreatePluginStyleEl();
+    try {
+      styleEl.sheet?.insertRule(rule);
+    } catch (error) {
+      console.error(
+        `Failed to insert CSS rule for plugin ${this.plugin.getPluginName()}: ${error}`
+      );
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts
new file mode 100644
index 0000000..469d667
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {query, queryAndAssert} from '../../../utils/common-util';
+import {assert} from '@open-wc/testing';
+import {StylePluginApi} from '../../../api/styles';
+
+suite('gr-plugin-style-api tests', () => {
+  let styleApi: StylePluginApi;
+
+  setup(() => {
+    window.Gerrit.install(
+      p => (styleApi = p.styleApi()),
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+  });
+
+  teardown(() => {
+    const styleEl = query<HTMLStyleElement>(
+      document.head,
+      'style#plugin-style'
+    );
+    styleEl?.remove();
+  });
+
+  test('insertCSSRule adds a rule', async () => {
+    styleApi.insertCSSRule('html{color:green;}');
+    const styleEl = queryAndAssert<HTMLStyleElement>(
+      document.head,
+      'style#plugin-style'
+    );
+    const styleSheet = styleEl.sheet;
+    assert.equal(styleSheet?.cssRules.length, 1);
+  });
+
+  test('insertCSSRule re-uses the <style> element', async () => {
+    styleApi.insertCSSRule('html{color:green;}');
+    styleApi.insertCSSRule('html{margin:0px;}');
+    const styleEl = queryAndAssert<HTMLStyleElement>(
+      document.head,
+      'style#plugin-style'
+    );
+    const styleSheet = styleEl.sheet;
+    assert.equal(styleSheet?.cssRules.length, 2);
+  });
+});
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 18737a9..832b97e 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {getBaseUrl} from '../../../utils/url-util';
 import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper';
@@ -24,7 +13,7 @@
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
 import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper';
 import {GrPluginRestApi} from './gr-plugin-rest-api';
-import {getPluginEndpoints} from './gr-plugin-endpoints';
+import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints';
 import {getPluginNameFromUrl, send} from './gr-api-utils';
 import {GrReportingJsApi} from './gr-reporting-js-api';
 import {EventType, PluginApi, TargetElement} from '../../../api/plugin';
@@ -32,7 +21,6 @@
 import {HttpMethod} from '../../../constants/constants';
 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';
@@ -43,22 +31,12 @@
 import {RestPluginApi} from '../../../api/rest';
 import {HookApi, PluginElement, RegisterOptions} from '../../../api/hook';
 import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
-
-/**
- * Plugin-provided custom components can affect content in extension
- * points using one of following methods:
- * - DECORATE: custom component is set with `content` attribute and may
- *   decorate (e.g. style) DOM element.
- * - REPLACE: contents of extension point are replaced with the custom
- *   component.
- * - STYLE: custom component is a shared styles module that is inserted
- *   into the extension point.
- */
-enum EndpointType {
-  DECORATE = 'decorate',
-  REPLACE = 'replace',
-  STYLE = 'style',
-}
+import {JsApiService} from './gr-js-api-types';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {GrPluginStyleApi} from './gr-plugin-style-api';
+import {StylePluginApi} from '../../../api/styles';
 
 const PLUGIN_NAME_NOT_SET = 'NULL';
 
@@ -71,15 +49,19 @@
 
   private readonly _name: string = PLUGIN_NAME_NOT_SET;
 
-  private readonly jsApi = appContext.jsApiService;
-
-  private readonly report = appContext.reportingService;
-
-  constructor(url?: string) {
+  constructor(
+    url: string,
+    private readonly jsApi: JsApiService,
+    private readonly report: ReportingService,
+    private readonly restApiService: RestApiService,
+    private readonly pluginsModel: PluginsModel,
+    private readonly pluginEndpoints: GrPluginEndpoints
+  ) {
     this.domHooks = new GrDomHooksManager(this);
 
     if (!url) {
       this.report.error(
+        'Plugin constructor',
         new Error(
           'Plugin not being loaded from /plugins base path. Unable to determine name.'
         )
@@ -96,15 +78,6 @@
     return this._name;
   }
 
-  registerStyleModule(endpoint: string, moduleName: string) {
-    this.report.trackApi(this, 'plugin', 'registerStyleModule');
-    getPluginEndpoints().registerModule(this, {
-      endpoint,
-      type: EndpointType.STYLE,
-      moduleName,
-    });
-  }
-
   /**
    * Registers an endpoint for the plugin.
    */
@@ -150,7 +123,7 @@
     const slot = options?.slot ?? '';
     const domHook = this.domHooks.getDomHook<T>(endpoint, moduleName);
     moduleName = moduleName || domHook.getModuleName();
-    getPluginEndpoints().registerModule(this, {
+    this.pluginEndpoints.registerModule(this, {
       slot,
       endpoint,
       type,
@@ -175,7 +148,7 @@
 
   getServerInfo() {
     this.report.trackApi(this, 'plugin', 'getServerInfo');
-    return appContext.restApiService.getConfig();
+    return this.restApiService.getConfig();
   }
 
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -212,16 +185,21 @@
     callback?: SendCallback,
     payload?: RequestPayload
   ) {
-    return send(method, this.url(url), callback, payload);
+    return send(this.restApiService, method, this.url(url), callback, payload);
   }
 
   annotationApi(): AnnotationPluginApi {
-    return new GrAnnotationActionsInterface(this);
+    return new GrAnnotationActionsInterface(
+      this.report,
+      this.pluginsModel,
+      this
+    );
   }
 
   changeActions(): ChangeActionsPluginApi {
     return new GrChangeActionsInterface(
       this,
+      this.jsApi,
       this.jsApi.getElement(
         TargetElement.CHANGE_ACTIONS
       ) as unknown as GrChangeActions
@@ -233,27 +211,31 @@
   }
 
   checks(): GrChecksApi {
-    return new GrChecksApi(this);
+    return new GrChecksApi(this.report, this.pluginsModel, this);
   }
 
   reporting(): ReportingPluginApi {
-    return new GrReportingJsApi(this);
+    return new GrReportingJsApi(this.report, this);
+  }
+
+  styleApi(): StylePluginApi {
+    return new GrPluginStyleApi(this.report, this);
   }
 
   admin(): AdminPluginApi {
-    return new GrAdminApi(this);
+    return new GrAdminApi(this.report, this);
   }
 
   restApi(prefix?: string): RestPluginApi {
-    return new GrPluginRestApi(this, prefix);
+    return new GrPluginRestApi(this.restApiService, this.report, this, prefix);
   }
 
   attributeHelper(element: HTMLElement): AttributeHelperPluginApi {
-    return new GrAttributeHelper(this, element);
+    return new GrAttributeHelper(this.report, this, element);
   }
 
   eventHelper(element: HTMLElement): EventHelperPluginApi {
-    return new GrEventHelper(this, element);
+    return new GrEventHelper(this.report, this, element);
   }
 
   popup(): Promise<PopupPluginApi>;
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 0427b43..d82b68d 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
@@ -1,31 +1,20 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {appContext} from '../../../services/app-context';
 import {PluginApi} from '../../../api/plugin';
 import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 
 /**
  * Defines all methods that will be exported to plugin from reporting service.
  */
 export class GrReportingJsApi implements ReportingPluginApi {
-  private readonly reporting = appContext.reportingService;
-
-  constructor(private readonly plugin: PluginApi) {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly plugin: PluginApi
+  ) {
     this.reporting.trackApi(this.plugin, 'reporting', 'constructor');
   }
 
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
deleted file mode 100644
index 71cc565..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
+++ /dev/null
@@ -1,77 +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 '../../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();
-
-suite('gr-reporting-js-api tests', () => {
-  let reporting;
-  let plugin;
-
-  setup(() => {
-    stubRestApi('getAccount').returns(Promise.resolve(null));
-  });
-
-  suite('early init', () => {
-    setup(() => {
-      pluginApi.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      reporting = plugin.reporting();
-    });
-
-    teardown(() => {
-      reporting = null;
-    });
-
-    test('redirect reportInteraction call to reportingService', () => {
-      sinon.spy(appContext.reportingService, 'reportPluginInteractionLog');
-      reporting.reportInteraction('test', {});
-      assert.isTrue(appContext.reportingService.reportPluginInteractionLog
-          .called);
-      assert.equal(
-          appContext.reportingService.reportPluginInteractionLog.lastCall
-              .args[0],
-          'testplugin-test'
-      );
-      assert.deepEqual(
-          appContext.reportingService.reportPluginInteractionLog.lastCall
-              .args[1],
-          {}
-      );
-    });
-
-    test('redirect reportLifeCycle call to reportingService', () => {
-      sinon.spy(appContext.reportingService, 'reportPluginLifeCycleLog');
-      reporting.reportLifeCycle('test', {});
-      assert.isTrue(appContext.reportingService.reportPluginLifeCycleLog
-          .called);
-      assert.equal(
-          appContext.reportingService.reportPluginLifeCycleLog.lastCall.args[0],
-          'testplugin-test'
-      );
-      assert.deepEqual(
-          appContext.reportingService.reportPluginLifeCycleLog.lastCall.args[1],
-          {}
-      );
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
new file mode 100644
index 0000000..8e4edd6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.ts
@@ -0,0 +1,53 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../../change/gr-reply-dialog/gr-reply-dialog';
+import {getAppContext} from '../../../services/app-context';
+import {stubRestApi} from '../../../test/test-utils';
+import {PluginApi} from '../../../api/plugin';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {ReportingPluginApi} from '../../../api/reporting';
+import {assert} from '@open-wc/testing';
+
+suite('gr-reporting-js-api tests', () => {
+  let plugin: PluginApi;
+  let reportingService: ReportingService;
+
+  setup(() => {
+    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    reportingService = getAppContext().reportingService;
+  });
+
+  suite('early init', () => {
+    let reporting: ReportingPluginApi;
+    setup(() => {
+      window.Gerrit.install(
+        p => {
+          plugin = p;
+        },
+        '0.1',
+        'http://test.com/plugins/testplugin/static/test.js'
+      );
+      reporting = plugin.reporting();
+    });
+
+    test('redirect reportInteraction call to reportingService', () => {
+      const spy = sinon.spy(reportingService, 'reportPluginInteractionLog');
+      reporting.reportInteraction('test', {});
+      assert.isTrue(spy.called);
+      assert.equal(spy.lastCall.args[0], 'testplugin-test');
+      assert.deepEqual(spy.lastCall.args[1], {});
+    });
+
+    test('redirect reportLifeCycle call to reportingService', () => {
+      const spy = sinon.spy(reportingService, 'reportPluginLifeCycleLog');
+      reporting.reportLifeCycle('test', {});
+      assert.isTrue(spy.called);
+      assert.equal(spy.lastCall.args[0], 'testplugin-test');
+      assert.deepEqual(spy.lastCall.args[1], {});
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index a65bb75..47d722d 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
@@ -1,59 +1,40 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/gr-font-styles';
 import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
+import '../gr-icon/gr-icon';
 import '../gr-vote-chip/gr-vote-chip';
-import '../gr-account-label/gr-account-label';
-import '../gr-account-link/gr-account-link';
 import '../gr-account-chip/gr-account-chip';
 import '../gr-button/gr-button';
-import '../gr-icons/gr-icons';
-import '../gr-label/gr-label';
 import '../gr-tooltip-content/gr-tooltip-content';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {
   AccountInfo,
   LabelInfo,
   ApprovalInfo,
   AccountId,
-  isQuickLabelInfo,
   isDetailedLabelInfo,
-  LabelNameToInfoMap,
 } from '../../../types/common';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
 import {
   canVote,
   getApprovalInfo,
-  getVotingRangeOrDefault,
   hasNeutralStatus,
   hasVoted,
   valueString,
 } from '../../../utils/label-util';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {votingStyles} from '../../../styles/gr-voting-styles';
-import {ifDefined} from 'lit/directives/if-defined';
+import {ifDefined} from 'lit/directives/if-defined.js';
 import {fireReload} from '../../../utils/event-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {sortReviewers} from '../../../utils/attention-set-util';
 
 declare global {
@@ -62,19 +43,6 @@
   }
 }
 
-enum LabelClassName {
-  NEGATIVE = 'negative',
-  POSITIVE = 'positive',
-  MIN = 'min',
-  MAX = 'max',
-}
-
-interface FormattedLabel {
-  className?: LabelClassName;
-  account: ApprovalInfo | AccountInfo;
-  value: string;
-}
-
 @customElement('gr-label-info')
 export class GrLabelInfo extends LitElement {
   @property({type: Object})
@@ -104,13 +72,9 @@
   @property({type: Boolean})
   showAllReviewers = true;
 
-  /** temporary until submit requirements are finished */
-  @property({type: Boolean})
-  showAlwaysOldUI = false;
+  private readonly restApiService = getAppContext().restApiService;
 
-  private readonly restApiService = appContext.restApiService;
-
-  private readonly reporting = appContext.reportingService;
+  private readonly reporting = getAppContext().reportingService;
 
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
@@ -121,9 +85,6 @@
       fontStyles,
       votingStyles,
       css`
-        .placeholder {
-          color: var(--deemphasized-text-color);
-        }
         .hidden {
           display: none;
         }
@@ -135,33 +96,6 @@
           margin-right: var(--spacing-s);
           padding: 1px;
         }
-        .max {
-          background-color: var(--vote-color-approved);
-        }
-        .min {
-          background-color: var(--vote-color-rejected);
-        }
-        .positive {
-          background-color: var(--vote-color-recommended);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-recommended);
-          color: var(--chip-color);
-        }
-        .negative {
-          background-color: var(--vote-color-disliked);
-          border-radius: 12px;
-          border: 1px solid var(--vote-outline-disliked);
-          color: var(--chip-color);
-        }
-        .hidden {
-          display: none;
-        }
-        td {
-          vertical-align: top;
-        }
-        tr {
-          min-height: var(--line-height-normal);
-        }
         gr-tooltip-content {
           display: block;
         }
@@ -173,19 +107,11 @@
           width: var(--line-height-normal);
           padding: 0;
         }
-        gr-button[disabled] iron-icon {
+        gr-button[disabled] gr-icon {
           color: var(--border-color);
         }
-        gr-account-link {
-          --account-max-length: 100px;
-          margin-right: var(--spacing-xs);
-        }
-        iron-icon {
-          height: calc(var(--line-height-normal) - 2px);
-          width: calc(var(--line-height-normal) - 2px);
-        }
-        .labelValueContainer:not(:first-of-type) td {
-          padding-top: var(--spacing-s);
+        gr-icon {
+          font-size: calc(var(--line-height-normal) - 2px);
         }
         .reviewer-row {
           padding-top: var(--spacing-s);
@@ -205,69 +131,56 @@
         gr-vote-chip {
           --gr-vote-chip-width: 14px;
           --gr-vote-chip-height: 14px;
-          margin-right: var(--spacing-s);
         }
       `,
     ];
   }
 
-  private readonly flagsService = appContext.flagsService;
-
   override render() {
-    if (
-      this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
-      !this.showAlwaysOldUI
-    ) {
-      return this.renderNewSubmitRequirements();
-    } else {
-      return this.renderOldSubmitRequirements();
-    }
-  }
-
-  private renderNewSubmitRequirements() {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
     const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
-      .filter(
-        reviewer =>
-          (this.showAllReviewers && canVote(labelInfo, reviewer)) ||
-          (!this.showAllReviewers && hasVoted(labelInfo, reviewer))
-      )
+      .filter(reviewer => {
+        if (this.showAllReviewers) {
+          if (isDetailedLabelInfo(labelInfo)) {
+            return canVote(labelInfo, reviewer);
+          } else {
+            // isQuickLabelInfo
+            return hasVoted(labelInfo, reviewer);
+          }
+        } else {
+          // !showAllReviewers
+          return hasVoted(labelInfo, reviewer);
+        }
+      })
       .sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
     return html`<div>
       ${reviewers.map(reviewer => this.renderReviewerVote(reviewer))}
     </div>`;
   }
 
-  private renderOldSubmitRequirements() {
-    const labelInfo = this.labelInfo;
-    return html` <p
-        class="placeholder ${this.computeShowPlaceholder(
-          labelInfo,
-          this.change?.labels
-        )}"
-      >
-        No votes
-      </p>
-      <table>
-        ${this.mapLabelInfo(labelInfo, this.account, this.change?.labels).map(
-          mappedLabel => this.renderLabel(mappedLabel)
-        )}
-      </table>`;
-  }
-
   renderReviewerVote(reviewer: AccountInfo) {
     const labelInfo = this.labelInfo;
-    if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
-    const approvalInfo = getApprovalInfo(labelInfo, reviewer);
+    if (!labelInfo) return;
+    const approvalInfo = isDetailedLabelInfo(labelInfo)
+      ? getApprovalInfo(labelInfo, reviewer)
+      : undefined;
     const noVoteYet =
-      !approvalInfo || hasNeutralStatus(labelInfo, approvalInfo);
+      !hasVoted(labelInfo, reviewer) ||
+      (isDetailedLabelInfo(labelInfo) &&
+        hasNeutralStatus(labelInfo, approvalInfo));
     return html`<div class="reviewer-row">
-      <gr-account-chip .account="${reviewer}" .change="${this.change}">
+      <gr-account-chip
+        .account=${reviewer}
+        .change=${this.change}
+        .vote=${approvalInfo}
+        .label=${labelInfo}
+      >
         <gr-vote-chip
           slot="vote-chip"
-          .vote="${approvalInfo}"
-          .label="${labelInfo}"
+          .vote=${approvalInfo}
+          .label=${labelInfo}
+          circle-shape
         ></gr-vote-chip
       ></gr-account-chip>
       ${noVoteYet
@@ -276,34 +189,11 @@
     </div>`;
   }
 
-  renderLabel(mappedLabel: FormattedLabel) {
-    const {labelInfo, change} = this;
-    return html` <tr class="labelValueContainer">
-      <td>
-        <gr-tooltip-content
-          has-tooltip
-          title="${this._computeValueTooltip(labelInfo, mappedLabel.value)}"
-        >
-          <gr-label class="${mappedLabel.className} voteChip font-small">
-            ${mappedLabel.value}
-          </gr-label>
-        </gr-tooltip-content>
-      </td>
-      <td>
-        <gr-account-link
-          .account="${mappedLabel.account}"
-          .change="${change}"
-        ></gr-account-link>
-      </td>
-      <td>${this.renderRemoveVote(mappedLabel.account)}</td>
-    </tr>`;
-  }
-
   private renderVoteAbility(reviewer: AccountInfo) {
     if (this.labelInfo && isDetailedLabelInfo(this.labelInfo)) {
       const approvalInfo = getApprovalInfo(this.labelInfo, reviewer);
       if (approvalInfo?.permitted_voting_range) {
-        const {min, max} = approvalInfo?.permitted_voting_range;
+        const {min, max} = approvalInfo.permitted_voting_range;
         return html`<span class="no-votes"
           >Can vote ${valueString(min)}/${valueString(max)}</span
         >`;
@@ -317,97 +207,20 @@
       <gr-button
         link
         aria-label="Remove vote"
-        @click="${this.onDeleteVote}"
-        data-account-id="${ifDefined(reviewer._account_id)}"
+        @click=${this.onDeleteVote}
+        data-account-id=${ifDefined(reviewer._account_id as number | undefined)}
         class="deleteBtn ${this.computeDeleteClass(
           reviewer,
           this.mutable,
           this.change
         )}"
       >
-        <iron-icon icon="gr-icons:delete"></iron-icon>
+        <gr-icon icon="delete" filled></gr-icon>
       </gr-button>
     </gr-tooltip-content>`;
   }
 
   /**
-   * This method also listens on change.labels.*,
-   * to trigger computation when a label is removed from the change.
-   *
-   * The third parameter is just for *triggering* computation.
-   */
-  private mapLabelInfo(
-    labelInfo?: LabelInfo,
-    account?: AccountInfo,
-    _?: LabelNameToInfoMap
-  ): FormattedLabel[] {
-    const result: FormattedLabel[] = [];
-    if (!labelInfo) {
-      return result;
-    }
-    if (!isDetailedLabelInfo(labelInfo)) {
-      if (
-        isQuickLabelInfo(labelInfo) &&
-        (labelInfo.rejected || labelInfo.approved)
-      ) {
-        const ok = labelInfo.approved || !labelInfo.rejected;
-        return [
-          {
-            value: ok ? '👍️' : '👎️',
-            className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
-            // executed only if approved or rejected is not undefined
-            account: ok ? labelInfo.approved! : labelInfo.rejected!,
-          },
-        ];
-      }
-      return result;
-    }
-
-    // Sort votes by positivity.
-    // TODO(TS): maybe mark value as required if always present
-    const votes = (labelInfo.all || []).sort(
-      (a, b) => (a.value || 0) - (b.value || 0)
-    );
-    const votingRange = getVotingRangeOrDefault(labelInfo);
-    for (const label of votes) {
-      if (
-        label.value &&
-        (!isQuickLabelInfo(labelInfo) ||
-          label.value !== labelInfo.default_value)
-      ) {
-        let labelClassName;
-        let labelValPrefix = '';
-        if (label.value > 0) {
-          labelValPrefix = '+';
-          if (label.value === votingRange.max) {
-            labelClassName = LabelClassName.MAX;
-          } else {
-            labelClassName = LabelClassName.POSITIVE;
-          }
-        } else if (label.value < 0) {
-          if (label.value === votingRange.min) {
-            labelClassName = LabelClassName.MIN;
-          } else {
-            labelClassName = LabelClassName.NEGATIVE;
-          }
-        }
-        const formattedLabel: FormattedLabel = {
-          value: `${labelValPrefix}${label.value}`,
-          className: labelClassName,
-          account: label,
-        };
-        if (label._account_id === account?._account_id) {
-          // Put self-votes at the top.
-          result.unshift(formattedLabel);
-        } else {
-          result.push(formattedLabel);
-        }
-      }
-    }
-    return result;
-  }
-
-  /**
    * A user is able to delete a vote iff the mutable property is true and the
    * reviewer that left the vote exists in the list of removable_reviewers
    * received from the backend.
@@ -438,7 +251,7 @@
     if (!this.change) return;
 
     e.preventDefault();
-    let target = (dom(e) as EventApi).rootTarget as GrButton;
+    let target = e.composedPath()[0] as GrButton;
     while (!target.classList.contains('deleteBtn')) {
       if (!target.parentElement) {
         return;
@@ -462,7 +275,7 @@
         }
       })
       .catch(err => {
-        this.reporting.error(err);
+        this.reporting.error('Delete vote', err);
         target.disabled = false;
         return;
       });
@@ -478,39 +291,4 @@
     }
     return labelInfo.values[score];
   }
-
-  /**
-   * This method also listens change.labels.* in
-   * order to trigger computation when a label is removed from the change.
-   *
-   * The second parameter is just for *triggering* computation.
-   */
-  private computeShowPlaceholder(
-    labelInfo?: LabelInfo,
-    _?: LabelNameToInfoMap
-  ) {
-    if (!labelInfo) {
-      return '';
-    }
-    if (
-      !isDetailedLabelInfo(labelInfo) &&
-      isQuickLabelInfo(labelInfo) &&
-      (labelInfo.rejected || labelInfo.approved)
-    ) {
-      return 'hidden';
-    }
-
-    if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
-      for (const label of labelInfo.all) {
-        if (
-          label.value &&
-          (!isQuickLabelInfo(labelInfo) ||
-            label.value !== labelInfo.default_value)
-        ) {
-          return 'hidden';
-        }
-      }
-    }
-    return '';
-  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
index cad1f69..67af61f 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -1,52 +1,78 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-label-info';
 import {
   isHidden,
   mockPromise,
-  queryAll,
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrLabelInfo} from './gr-label-info';
 import {GrButton} from '../gr-button/gr-button';
-import {GrLabel} from '../gr-label/gr-label';
-import {GrAccountLink} from '../gr-account-link/gr-account-link';
 import {
   createAccountWithIdNameAndEmail,
+  createDetailedLabelInfo,
   createParsedChange,
 } from '../../../test/test-data-generators';
-import {LabelInfo} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-label-info');
+import {ApprovalInfo, LabelInfo} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-label-info tests', () => {
   let element: GrLabelInfo;
   const account = createAccountWithIdNameAndEmail(5);
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-label-info></gr-label-info>`);
 
     // Needed to trigger computed bindings.
     element.account = {};
-    element.change = {...createParsedChange(), labels: {}};
+    element.change = {
+      ...createParsedChange(),
+      labels: {},
+      reviewers: {
+        REVIEWER: [account],
+        CC: [],
+      },
+    };
+    const approval: ApprovalInfo = {
+      value: 2,
+      _account_id: account._account_id,
+    };
+    element.labelInfo = {
+      ...createDetailedLabelInfo(),
+      all: [approval],
+    };
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div>
+        <div class="reviewer-row">
+          <gr-account-chip>
+            <gr-vote-chip circle-shape="" slot="vote-chip"> </gr-vote-chip>
+          </gr-account-chip>
+          <gr-tooltip-content has-tooltip="" title="Remove vote">
+            <gr-button
+              aria-disabled="false"
+              aria-label="Remove vote"
+              class="deleteBtn hidden"
+              data-account-id="5"
+              link=""
+              role="button"
+              tabindex="0"
+            >
+              <gr-icon icon="delete" filled></gr-icon>
+            </gr-button>
+          </gr-tooltip-content>
+        </div>
+      </div>`
+    );
   });
 
   suite('remove reviewer votes', () => {
@@ -62,6 +88,10 @@
       element.change = {
         ...createParsedChange(),
         labels: {'Code-Review': label},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [],
+        },
       };
       element.labelInfo = label;
       element.label = 'Code-Review';
@@ -92,7 +122,7 @@
       element.mutable = true;
       const removeButton = queryAndAssert<GrButton>(element, 'gr-button');
 
-      MockInteractions.tap(removeButton);
+      removeButton.click();
       assert.isTrue(removeButton.disabled);
       mock.resolve();
       await deleteResponse;
@@ -108,101 +138,6 @@
     });
   });
 
-  suite('label color and order', () => {
-    test('valueless label rejected', async () => {
-      element.labelInfo = {rejected: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('negative'));
-    });
-
-    test('valueless label approved', async () => {
-      element.labelInfo = {approved: {name: 'someone'}};
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('positive'));
-    });
-
-    test('-2 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 2, name: 'user 2'},
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 3'},
-          {value: -2, name: 'user 4'},
-        ],
-        values: {
-          '-2': 'Awful',
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-          '+2': 'Ready to submit',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-      assert.isTrue(labels[2].classList.contains('negative'));
-      assert.isTrue(labels[3].classList.contains('min'));
-    });
-
-    test('-1 to +1', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 2'},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('min'));
-    });
-
-    test('0 to +2', async () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 2'},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': "Don't submit as-is",
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const labels = queryAll<GrLabel>(element, 'gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-    });
-
-    test('self votes at top', async () => {
-      const otherAccount = createAccountWithIdNameAndEmail(8);
-      element.account = account;
-      element.labelInfo = {
-        all: [
-          {...otherAccount, value: 1},
-          {...account, value: -1},
-        ],
-        values: {
-          '-1': "Don't submit as-is",
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      await element.updateComplete;
-      const chips = queryAll<GrAccountLink>(element, 'gr-account-link');
-      assert.equal(chips[0].account!._account_id, element.account._account_id);
-    });
-  });
-
   test('_computeValueTooltip', () => {
     // Existing label.
     let labelInfo: LabelInfo = {values: {0: 'Baz'}};
@@ -218,49 +153,4 @@
     score = '0';
     assert.equal(element._computeValueTooltip(labelInfo, score), '');
   });
-
-  test('placeholder', async () => {
-    const values = {
-      '0': 'No score',
-      '+1': 'good',
-      '+2': 'excellent',
-      '-1': 'bad',
-      '-2': 'terrible',
-    };
-    element.labelInfo = {};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [], values};
-    await element.updateComplete;
-    assert.isFalse(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {rejected: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-    element.labelInfo = {approved: account, all: [{value: 1}], values};
-    await element.updateComplete;
-    assert.isTrue(
-      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
-    );
-  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
deleted file mode 100644
index 842b35e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
+++ /dev/null
@@ -1,42 +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.
- */
-
-/**
- * @fileoverview Consider removing this element as
- * its functionality seems to be duplicated with gr-tooltip and only
- * used in gr-label-info.
- */
-
-import {html, LitElement} from 'lit';
-import {customElement} from 'lit/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-label': GrLabel;
-  }
-}
-
-@customElement('gr-label')
-export class GrLabel extends LitElement {
-  static override get styles() {
-    return [];
-  }
-
-  override render() {
-    return html` <slot></slot> `;
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label_html.ts
deleted file mode 100644
index 94196df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label_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` <slot></slot> `;
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
index 85f29cb..33dc920 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
@@ -1,39 +1,23 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-autocomplete/gr-autocomplete';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-labeled-autocomplete_html';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query} from 'lit/decorators.js';
 import {
   GrAutocomplete,
   AutocompleteQuery,
 } from '../gr-autocomplete/gr-autocomplete';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
 
-export interface GrLabeledAutocomplete {
-  $: {
-    autocomplete: GrAutocomplete;
-  };
-}
 @customElement('gr-labeled-autocomplete')
-export class GrLabeledAutocomplete extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrLabeledAutocomplete extends LitElement {
+  @query('#autocomplete')
+  autocomplete?: GrAutocomplete;
 
   /**
    * Fired when a value is chosen.
@@ -44,7 +28,7 @@
   @property({type: Object})
   query: AutocompleteQuery = () => Promise.resolve([]);
 
-  @property({type: String, notify: true})
+  @property({type: String})
   text = '';
 
   @property({type: String})
@@ -56,15 +40,73 @@
   @property({type: Boolean})
   disabled = false;
 
-  _handleTriggerClick(e: Event) {
+  static override get styles() {
+    return css`
+      :host {
+        display: block;
+        width: 12em;
+      }
+      #container {
+        background: var(--chip-background-color);
+        border-radius: 1em;
+        padding: var(--spacing-m);
+      }
+      #header {
+        color: var(--deemphasized-text-color);
+        font-weight: var(--font-weight-bold);
+        font-size: var(--font-size-small);
+      }
+      #body {
+        display: flex;
+      }
+      #trigger {
+        color: var(--deemphasized-text-color);
+        cursor: pointer;
+        padding-left: var(--spacing-s);
+      }
+      #trigger:hover {
+        color: var(--primary-text-color);
+      }
+    `;
+  }
+
+  override render() {
+    return html`
+      <div id="container">
+        <div id="header">${this.label}</div>
+        <div id="body">
+          <gr-autocomplete
+            id="autocomplete"
+            threshold="0"
+            .query=${this.query}
+            ?disabled=${this.disabled}
+            .placeholder=${this.placeholder}
+            borderless=""
+          ></gr-autocomplete>
+          <div id="trigger" @click=${this._handleTriggerClick}>▼</div>
+        </div>
+      </div>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('text')) {
+      fire(this, 'text-changed', {value: this.text});
+    }
+  }
+
+  // Private but used in tests.
+  _handleTriggerClick = (e: Event) => {
     // Stop propagation here so we don't confuse gr-autocomplete, which
     // listens for taps on body to try to determine when it's blurred.
     e.stopPropagation();
-    this.$.autocomplete.focus();
-  }
+    assertIsDefined(this.autocomplete);
+    this.autocomplete.focus();
+  };
 
   setText(text: string) {
-    this.$.autocomplete.setText(text);
+    assertIsDefined(this.autocomplete);
+    this.autocomplete.setText(text);
   }
 
   clear() {
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
deleted file mode 100644
index 934ab84..0000000
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 12em;
-    }
-    #container {
-      background: var(--chip-background-color);
-      border-radius: 1em;
-      padding: var(--spacing-m);
-    }
-    #header {
-      color: var(--deemphasized-text-color);
-      font-weight: var(--font-weight-bold);
-      font-size: var(--font-size-small);
-    }
-    #body {
-      display: flex;
-    }
-    #trigger {
-      color: var(--deemphasized-text-color);
-      cursor: pointer;
-      padding-left: var(--spacing-s);
-    }
-    #trigger:hover {
-      color: var(--primary-text-color);
-    }
-  </style>
-  <div id="container">
-    <div id="header">[[label]]</div>
-    <div id="body">
-      <gr-autocomplete
-        id="autocomplete"
-        threshold="0"
-        query="[[query]]"
-        disabled="[[disabled]]"
-        placeholder="[[placeholder]]"
-        borderless=""
-      ></gr-autocomplete>
-      <div id="trigger" on-click="_handleTriggerClick">▼</div>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
index d6fc45f..a8f6ff2 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
@@ -1,45 +1,59 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-labeled-autocomplete';
 import {GrLabeledAutocomplete} from './gr-labeled-autocomplete';
-
-const basicFixture = fixtureFromElement('gr-labeled-autocomplete');
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-labeled-autocomplete tests', () => {
   let element: GrLabeledAutocomplete;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-labeled-autocomplete></gr-labeled-autocomplete>`
+    );
   });
 
   test('tapping trigger focuses autocomplete', () => {
     const e = {stopPropagation: () => undefined};
     const stopPropagationStub = sinon.stub(e, 'stopPropagation');
-    const autocompleteStub = sinon.stub(element.$.autocomplete, 'focus');
+    assertIsDefined(element.autocomplete);
+    const autocompleteStub = sinon.stub(element.autocomplete, 'focus');
     element._handleTriggerClick(e as Event);
     assert.isTrue(stopPropagationStub.calledOnce);
     assert.isTrue(autocompleteStub.calledOnce);
   });
 
   test('setText', () => {
-    const setTextStub = sinon.stub(element.$.autocomplete, 'setText');
+    assertIsDefined(element.autocomplete);
+    const setTextStub = sinon.stub(element.autocomplete, 'setText');
     element.setText('foo-bar');
     assert.isTrue(setTextStub.calledWith('foo-bar'));
   });
+
+  test('shadowDom', async () => {
+    element.label = 'Some label';
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="container">
+          <div id="header">Some label</div>
+          <div id="body">
+            <gr-autocomplete
+              id="autocomplete"
+              threshold="0"
+              borderless=""
+            ></gr-autocomplete>
+            <div id="trigger">▼</div>
+          </div>
+        </div>
+      `
+    );
+  });
 });
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 a3f128b..583cf80 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 export interface LibraryConfig {
@@ -86,7 +75,7 @@
         reject(new Error('Unable to load blank script url.'));
         return;
       }
-
+      script.setAttribute('crossorigin', 'anonymous');
       script.setAttribute('src', src);
       script.onload = resolve;
       script.onerror = reject;
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
deleted file mode 100644
index e83698f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
+++ /dev/null
@@ -1,203 +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-lib-loader.js';
-import {GrLibLoader} from './gr-lib-loader.js';
-
-suite('gr-lib-loader tests', () => {
-  let grLibLoader;
-  let resolveLoad;
-  let rejectLoad;
-  let loadStub;
-
-  setup(() => {
-    grLibLoader = new GrLibLoader();
-
-    loadStub = sinon.stub(grLibLoader, '_loadScript').callsFake(() =>
-      new Promise((resolve, reject) => {
-        resolveLoad = resolve;
-        rejectLoad = reject;
-      })
-    );
-  });
-
-  test('notifies all callers when loaded', async () => {
-    const libraryConfig = {src: 'foo.js'};
-
-    const loaded1 = sinon.stub();
-    const loaded2 = sinon.stub();
-
-    grLibLoader.getLibrary(libraryConfig).then(loaded1);
-    grLibLoader.getLibrary(libraryConfig).then(loaded2);
-
-    resolveLoad();
-    await flush();
-
-    const lateLoaded = sinon.stub();
-    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
-
-    await flush();
-
-    assert.isTrue(loaded1.calledOnce);
-    assert.isTrue(loaded2.calledOnce);
-    assert.isTrue(lateLoaded.calledOnce);
-  });
-
-  test('notifies all callers when failed', async () => {
-    const libraryConfig = {src: 'foo.js'};
-
-    const failed1 = sinon.stub();
-    const failed2 = sinon.stub();
-
-    grLibLoader.getLibrary(libraryConfig).catch(failed1);
-    grLibLoader.getLibrary(libraryConfig).catch(failed2);
-
-    rejectLoad();
-    await flush();
-
-    const lateFailed = sinon.stub();
-    grLibLoader.getLibrary(libraryConfig).catch(lateFailed);
-
-    await flush();
-
-    assert.isTrue(failed1.calledOnce);
-    assert.isTrue(failed2.calledOnce);
-    assert.isTrue(lateFailed.calledOnce);
-  });
-
-  test('runs library configuration only once', async () => {
-    const configureCallback = sinon.stub();
-    const libraryConfig = {
-      src: 'foo.js',
-      configureCallback,
-    };
-
-    const loaded1 = sinon.stub();
-    const loaded2 = sinon.stub();
-
-    grLibLoader.getLibrary(libraryConfig).then(loaded1);
-    grLibLoader.getLibrary(libraryConfig).then(loaded2);
-
-    resolveLoad();
-    await flush();
-
-    const lateLoaded = sinon.stub();
-    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
-
-    await flush();
-
-    assert.isTrue(configureCallback.calledOnce);
-  });
-
-  test('resolves to result of configureCallback, if any', async () => {
-    const library = {someFunction: () => 'foobar'};
-
-    const libraryConfig = {
-      src: 'foo.js',
-      configureCallback: () => window.library,
-    };
-
-    const loaded1 = sinon.stub();
-    const loaded2 = sinon.stub();
-
-    grLibLoader.getLibrary(libraryConfig).then(loaded1);
-    grLibLoader.getLibrary(libraryConfig).then(loaded2);
-
-    window.library = library;
-    resolveLoad();
-    await flush();
-
-    assert.isTrue(loaded1.calledWith(library));
-    assert.isTrue(loaded2.calledWith(library));
-
-    const lateLoaded = sinon.stub();
-    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
-
-    await flush();
-
-    assert.isTrue(lateLoaded.calledWith(library));
-  });
-
-  suite('preloaded', () => {
-    setup(() => {
-      window.library = {
-        initialize: sinon.stub(),
-      };
-    });
-
-    teardown(() => {
-      delete window.library;
-    });
-
-    test('does not load library again if detected present', async () => {
-      const libraryConfig = {
-        src: 'foo.js',
-        checkPresent: () => window.library !== undefined,
-      };
-
-      const loaded1 = sinon.stub();
-      const loaded2 = sinon.stub();
-
-      grLibLoader.getLibrary(libraryConfig).then(loaded1);
-      grLibLoader.getLibrary(libraryConfig).then(loaded2);
-
-      resolveLoad();
-      await flush();
-
-      const lateLoaded = sinon.stub();
-      grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
-
-      await flush();
-
-      assert.isFalse(loadStub.called);
-      assert.isTrue(loaded1.called);
-      assert.isTrue(loaded2.called);
-      assert.isTrue(lateLoaded.called);
-    });
-
-    test('runs configuration for externally loaded library', async () => {
-      const libraryConfig = {
-        src: 'foo.js',
-        checkPresent: () => window.library !== undefined,
-        configureCallback: () => window.library.initialize(),
-      };
-
-      grLibLoader.getLibrary(libraryConfig);
-
-      resolveLoad();
-      await flush();
-
-      assert.isTrue(window.library.initialize.calledOnce);
-    });
-
-    test('loads library again if not detected present', async () => {
-      window.library = undefined;
-      const libraryConfig = {
-        src: 'foo.js',
-        checkPresent: () => window.library !== undefined,
-      };
-
-      grLibLoader.getLibrary(libraryConfig);
-
-      resolveLoad();
-      await flush();
-
-      assert.isTrue(loadStub.called);
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
new file mode 100644
index 0000000..7e353f6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
@@ -0,0 +1,194 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import {waitEventLoop} from '../../../test/test-utils';
+import './gr-lib-loader';
+import {GrLibLoader} from './gr-lib-loader';
+
+suite('gr-lib-loader tests', () => {
+  let grLibLoader: GrLibLoader;
+  let resolveLoad: any;
+  let rejectLoad: any;
+  let loadStub: sinon.SinonStub;
+
+  setup(() => {
+    grLibLoader = new GrLibLoader();
+
+    loadStub = sinon.stub(grLibLoader, '_loadScript').callsFake(
+      () =>
+        new Promise((resolve, reject) => {
+          resolveLoad = resolve;
+          rejectLoad = reject;
+        })
+    );
+  });
+
+  test('notifies all callers when loaded', async () => {
+    const libraryConfig = {src: 'foo.js'};
+
+    const loaded1 = sinon.stub();
+    const loaded2 = sinon.stub();
+
+    grLibLoader.getLibrary(libraryConfig).then(loaded1);
+    grLibLoader.getLibrary(libraryConfig).then(loaded2);
+
+    resolveLoad();
+    await waitEventLoop();
+
+    const lateLoaded = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+    await waitEventLoop();
+
+    assert.isTrue(loaded1.calledOnce);
+    assert.isTrue(loaded2.calledOnce);
+    assert.isTrue(lateLoaded.calledOnce);
+  });
+
+  test('notifies all callers when failed', async () => {
+    const libraryConfig = {src: 'foo.js'};
+
+    const failed1 = sinon.stub();
+    const failed2 = sinon.stub();
+
+    grLibLoader.getLibrary(libraryConfig).catch(failed1);
+    grLibLoader.getLibrary(libraryConfig).catch(failed2);
+
+    rejectLoad();
+    await waitEventLoop();
+
+    const lateFailed = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).catch(lateFailed);
+
+    await waitEventLoop();
+
+    assert.isTrue(failed1.calledOnce);
+    assert.isTrue(failed2.calledOnce);
+    assert.isTrue(lateFailed.calledOnce);
+  });
+
+  test('runs library configuration only once', async () => {
+    const configureCallback = sinon.stub();
+    const libraryConfig = {
+      src: 'foo.js',
+      configureCallback,
+    };
+
+    const loaded1 = sinon.stub();
+    const loaded2 = sinon.stub();
+
+    grLibLoader.getLibrary(libraryConfig).then(loaded1);
+    grLibLoader.getLibrary(libraryConfig).then(loaded2);
+
+    resolveLoad();
+    await waitEventLoop();
+
+    const lateLoaded = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+    await waitEventLoop();
+
+    assert.isTrue(configureCallback.calledOnce);
+  });
+
+  test('resolves to result of configureCallback, if any', async () => {
+    const library = {someFunction: () => 'foobar'};
+
+    const libraryConfig = {
+      src: 'foo.js',
+      configureCallback: () => (window as any).library,
+    };
+
+    const loaded1 = sinon.stub();
+    const loaded2 = sinon.stub();
+
+    grLibLoader.getLibrary(libraryConfig).then(loaded1);
+    grLibLoader.getLibrary(libraryConfig).then(loaded2);
+
+    (window as any).library = library;
+    resolveLoad();
+    await waitEventLoop();
+
+    assert.isTrue(loaded1.calledWith(library));
+    assert.isTrue(loaded2.calledWith(library));
+
+    const lateLoaded = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+    await waitEventLoop();
+
+    assert.isTrue(lateLoaded.calledWith(library));
+  });
+
+  suite('preloaded', () => {
+    setup(() => {
+      (window as any).library = {
+        initialize: sinon.stub(),
+      };
+    });
+
+    teardown(() => {
+      delete (window as any).library;
+    });
+
+    test('does not load library again if detected present', async () => {
+      const libraryConfig = {
+        src: 'foo.js',
+        checkPresent: () => (window as any).library !== undefined,
+      };
+
+      const loaded1 = sinon.stub();
+      const loaded2 = sinon.stub();
+
+      grLibLoader.getLibrary(libraryConfig).then(loaded1);
+      grLibLoader.getLibrary(libraryConfig).then(loaded2);
+
+      resolveLoad();
+      await waitEventLoop();
+
+      const lateLoaded = sinon.stub();
+      grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+      await waitEventLoop();
+
+      assert.isFalse(loadStub.called);
+      assert.isTrue(loaded1.called);
+      assert.isTrue(loaded2.called);
+      assert.isTrue(lateLoaded.called);
+    });
+
+    test('runs configuration for externally loaded library', async () => {
+      const libraryConfig = {
+        src: 'foo.js',
+        checkPresent: () => (window as any).library !== undefined,
+        configureCallback: () => (window as any).library.initialize(),
+      };
+
+      grLibLoader.getLibrary(libraryConfig);
+
+      resolveLoad();
+      await waitEventLoop();
+
+      assert.isTrue((window as any).library.initialize.calledOnce);
+    });
+
+    test('loads library again if not detected present', async () => {
+      (window as any).library = undefined;
+      const libraryConfig = {
+        src: 'foo.js',
+        checkPresent: () => (window as any).library !== undefined,
+      };
+
+      grLibLoader.getLibrary(libraryConfig);
+
+      resolveLoad();
+      await waitEventLoop();
+
+      assert.isTrue(loadStub.called);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts
deleted file mode 100644
index da13396..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../gr-js-api-interface/gr-js-api-interface';
-
-import {EventType} from '../../../api/plugin';
-import {appContext} from '../../../services/app-context';
-
-import {LibraryConfig} from './gr-lib-loader';
-
-export const HLJS_LIBRARY_CONFIG: LibraryConfig = {
-  // preloaded in PolyGerritIndexHtml.soy
-  src: 'bower_components/highlightjs/highlight.min.js',
-  checkPresent: () => window.hljs !== undefined,
-  configureCallback: () => {
-    window.hljs!.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
-    appContext.jsApiService.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
-      hljs: window.hljs,
-    });
-    return window.hljs;
-  },
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts
index 872d01c..02079cb 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {LibraryConfig} from './gr-lib-loader';
 
@@ -20,7 +9,7 @@
   src: 'bower_components/resemblejs/resemble.js',
   checkPresent: () => window.resemble !== undefined,
   configureCallback: () => {
-    window.resemble!.outputSettings({
+    window.resemble.outputSettings({
       errorColor: {red: 255, green: 0, blue: 255},
       errorType: 'flat',
       transparency: 0,
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 7008db2..96d4b92 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
@@ -1,21 +1,11 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {customElement, property} from 'lit/decorators';
-import {html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {css, html, LitElement} from 'lit';
+import '../gr-tooltip-content/gr-tooltip-content';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -37,13 +27,19 @@
 
   /** The maximum length for the text to display before truncating. */
   @property({type: Number})
-  limit = 0;
+  limit = 25;
 
   @property({type: String})
   tooltip?: string;
 
   static override get styles() {
-    return [];
+    return [
+      css`
+        :host {
+          white-space: nowrap;
+        }
+      `,
+    ];
   }
 
   override render() {
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
deleted file mode 100644
index e3e72d0..0000000
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_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-limited-text.js';
-
-const basicFixture = fixtureFromElement('gr-limited-text');
-
-suite('gr-limited-text tests', () => {
-  let element;
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    await element.updateComplete;
-  });
-
-  test('tooltip without title input', async () => {
-    element.text = 'abc 123';
-    await element.updateComplete;
-    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
-
-    element.limit = 10;
-    await element.updateComplete;
-    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
-
-    element.limit = 3;
-    await element.updateComplete;
-    assert.isOk(element.shadowRoot.querySelector('gr-tooltip-content'));
-    assert.equal(
-        element.shadowRoot.querySelector('gr-tooltip-content').title,
-        'abc 123');
-
-    element.limit = 100;
-    await element.updateComplete;
-    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
-
-    element.limit = null;
-    await element.updateComplete;
-    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
-  });
-
-  test('with tooltip input', async () => {
-    element.tooltip = 'abc 123';
-    await element.updateComplete;
-    let tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
-    assert.isOk(tooltipContent);
-    assert.equal(tooltipContent.title, 'abc 123');
-
-    element.text = 'abc';
-    await element.updateComplete;
-    tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
-    assert.isOk(tooltipContent);
-    assert.equal(tooltipContent.title, 'abc 123');
-
-    element.text = 'abcdef';
-    element.limit = 3;
-    await element.updateComplete;
-    tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
-    assert.isOk(tooltipContent);
-    assert.equal(tooltipContent.title, 'abcdef (abc 123)');
-  });
-
-  test('_computeDisplayText', () => {
-    element.text = 'foo bar';
-    element.limit = 100;
-    assert.equal(element.renderText(), 'foo bar');
-    element.limit = 4;
-    assert.equal(element.renderText(), 'foo…');
-    element.limit = 0;
-    assert.equal(element.renderText(), 'foo bar');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
new file mode 100644
index 0000000..c646525
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {query, queryAndAssert} from '../../../test/test-utils';
+import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
+import './gr-limited-text';
+import {GrLimitedText} from './gr-limited-text';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-limited-text tests', () => {
+  let element: GrLimitedText;
+
+  setup(async () => {
+    element = await fixture<GrLimitedText>(
+      html`<gr-limited-text></gr-limited-text>`
+    );
+  });
+
+  test('render', async () => {
+    element.text = 'abc 123';
+    element.limit = 5;
+    element.tooltip = 'tip';
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-tooltip-content has-tooltip="" title="abc 123 (tip)">
+          abc …
+        </gr-tooltip-content>
+      `
+    );
+  });
+
+  test('tooltip without title input', async () => {
+    element.text = 'abc 123';
+    await element.updateComplete;
+    assert.isNotOk(query(element, 'gr-tooltip-content'));
+
+    element.limit = 10;
+    await element.updateComplete;
+    assert.isNotOk(query(element, 'gr-tooltip-content'));
+
+    element.limit = 3;
+    await element.updateComplete;
+    assert.isOk(query(element, 'gr-tooltip-content'));
+    assert.equal(
+      queryAndAssert<GrTooltipContent>(element, 'gr-tooltip-content').title,
+      'abc 123'
+    );
+
+    element.limit = 100;
+    await element.updateComplete;
+    assert.isNotOk(query(element, 'gr-tooltip-content'));
+
+    element.limit = null as any;
+    await element.updateComplete;
+    assert.isNotOk(query(element, 'gr-tooltip-content'));
+  });
+
+  test('with tooltip input', async () => {
+    element.tooltip = 'abc 123';
+    await element.updateComplete;
+    let tooltipContent = queryAndAssert<GrTooltipContent>(
+      element,
+      'gr-tooltip-content'
+    );
+    assert.isOk(tooltipContent);
+    assert.equal(tooltipContent.title, 'abc 123');
+
+    element.text = 'abc';
+    await element.updateComplete;
+    tooltipContent = queryAndAssert<GrTooltipContent>(
+      element,
+      'gr-tooltip-content'
+    );
+    assert.isOk(tooltipContent);
+    assert.equal(tooltipContent.title, 'abc 123');
+
+    element.text = 'abcdef';
+    element.limit = 3;
+    await element.updateComplete;
+    tooltipContent = queryAndAssert(element, 'gr-tooltip-content');
+    assert.isOk(tooltipContent);
+    assert.equal(tooltipContent.title, 'abcdef (abc 123)');
+  });
+
+  test('_computeDisplayText', () => {
+    element.text = 'foo bar';
+    element.limit = 100;
+    assert.equal(element.renderText(), 'foo bar');
+    element.limit = 4;
+    assert.equal(element.renderText(), 'foo…');
+    element.limit = 0;
+    assert.equal(element.renderText(), 'foo bar');
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index 801b8bf..e48dcb3 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
@@ -1,27 +1,15 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../gr-button/gr-button';
-import '../gr-icons/gr-icons';
+import '../gr-icon/gr-icon';
 import '../gr-limited-text/gr-limited-text';
 import {fireEvent} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -43,9 +31,6 @@
   @property({type: String})
   text = '';
 
-  @property({type: Boolean})
-  transparentBackground = false;
-
   /**  If provided, sets the maximum length of the content. */
   @property({type: Number})
   limit?: number;
@@ -65,10 +50,6 @@
           display: inline-flex;
           padding: 0 var(--spacing-m);
         }
-        .transparentBackground,
-        gr-button.transparentBackground {
-          background-color: transparent;
-        }
         :host([disabled]) {
           opacity: 0.6;
           pointer-events: none;
@@ -76,20 +57,9 @@
         a {
           color: var(--linked-chip-text-color);
         }
-        iron-icon {
-          height: 1.2rem;
-          width: 1.2rem;
+        gr-icon {
+          font-size: 1.2rem;
         }
-      `,
-    ];
-  }
-
-  override render() {
-    // To pass CSS mixins for @apply to Polymer components, they need to appear
-    // in <style> inside the template.
-    /* eslint-disable lit/prefer-static-styles */
-    const customStyle = html`
-      <style>
         gr-button::part(paper-button),
         gr-button.remove:hover::part(paper-button),
         gr-button.remove:focus::part(paper-button) {
@@ -105,37 +75,31 @@
           padding: 0;
           text-decoration: none;
         }
-      </style>
-    `;
-    return html`${customStyle}
-      <div
-        class="container ${this._getBackgroundClass(
-          this.transparentBackground
-        )}"
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div class="container">
+      <a href=${this.href}>
+        <gr-limited-text
+          .limit=${this.limit}
+          .text=${this.text}
+        ></gr-limited-text>
+      </a>
+      <gr-button
+        id="remove"
+        link=""
+        ?hidden=${!this.removable}
+        class="remove"
+        @click=${this.handleRemoveTap}
       >
-        <a href="${this.href}">
-          <gr-limited-text
-            .limit="${this.limit}"
-            .text="${this.text}"
-          ></gr-limited-text>
-        </a>
-        <gr-button
-          id="remove"
-          link=""
-          ?hidden=${!this.removable}
-          class="remove ${this._getBackgroundClass(this.transparentBackground)}"
-          @click=${this._handleRemoveTap}
-        >
-          <iron-icon icon="gr-icons:close"></iron-icon>
-        </gr-button>
-      </div>`;
+        <gr-icon icon="close"></gr-icon>
+      </gr-button>
+    </div>`;
   }
 
-  _getBackgroundClass(transparent: boolean) {
-    return transparent ? 'transparentBackground' : '';
-  }
-
-  _handleRemoveTap(e: Event) {
+  private handleRemoveTap(e: Event) {
     e.preventDefault();
     fireEvent(this, 'remove');
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
index 1d98a0b..08572b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
@@ -1,41 +1,47 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-linked-chip';
 import {GrLinkedChip} from './gr-linked-chip';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-linked-chip');
+import {queryAndAssert, waitEventLoop} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrButton} from '../gr-button/gr-button';
 
 suite('gr-linked-chip tests', () => {
   let element: GrLinkedChip;
 
   setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
+    element = await fixture(html`<gr-linked-chip></gr-linked-chip>`);
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div class="container">
+        <a href=""> <gr-limited-text> </gr-limited-text> </a>
+        <gr-button
+          aria-disabled="false"
+          class="remove"
+          hidden=""
+          id="remove"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          <gr-icon icon="close"></gr-icon>
+        </gr-button>
+      </div>`
+    );
   });
 
   test('remove fired', async () => {
     const spy = sinon.spy();
     element.addEventListener('remove', spy);
-    await flush();
-    MockInteractions.tap(queryAndAssert(element, '#remove'));
+    await waitEventLoop();
+    queryAndAssert<GrButton>(element, '#remove').click();
     assert.isTrue(spy.called);
   });
 });
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
deleted file mode 100644
index 2812b47..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-linked-text_html';
-import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, observe} from '@polymer/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-linked-text': GrLinkedText;
-  }
-}
-
-export interface GrLinkedText {
-  $: {
-    output: HTMLSpanElement;
-  };
-}
-
-@customElement('gr-linked-text')
-export class GrLinkedText extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Boolean})
-  removeZeroWidthSpace?: boolean;
-
-  // content default is null, because this.$.output.textContent is string|null
-  @property({type: String})
-  content: string | null = null;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  pre = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  disabled = false;
-
-  @property({type: Object})
-  config?: LinkTextParserConfig;
-
-  @observe('content')
-  _contentChanged(content: string | null) {
-    // In the case where the config may not be set (perhaps due to the
-    // request for it still being in flight), set the content anyway to
-    // prevent waiting on the config to display the text.
-    if (!this.config) {
-      return;
-    }
-    this.$.output.textContent = content;
-  }
-
-  /**
-   * Because either the source text or the linkification config has changed,
-   * the content should be re-parsed.
-   *
-   * @param content The raw, un-linkified source string to parse.
-   * @param config The server config specifying commentLink patterns
-   */
-  @observe('content', 'config')
-  _contentOrConfigChanged(
-    content: string | null,
-    config?: LinkTextParserConfig
-  ) {
-    if (!config) {
-      return;
-    }
-
-    // TODO(TS): mapCommentlinks always has value, remove
-    if (!GerritNav.mapCommentlinks) return;
-    config = GerritNav.mapCommentlinks(config);
-    const output = this.$.output;
-    output.textContent = '';
-    const parser = new GrLinkTextParser(
-      config,
-      (text: string | null, href: string | null, fragment?: DocumentFragment) =>
-        this._handleParseResult(text, href, fragment),
-      this.removeZeroWidthSpace
-    );
-    parser.parse(content);
-
-    // Ensure that external links originating from HTML commentlink configs
-    // open in a new tab. @see Issue 5567
-    // Ensure links to the same host originating from commentlink configs
-    // open in the same tab. When target is not set - default is _self
-    // @see Issue 4616
-    output.querySelectorAll('a').forEach(anchor => {
-      if (anchor.hostname === window.location.hostname) {
-        anchor.removeAttribute('target');
-      } else {
-        anchor.setAttribute('target', '_blank');
-      }
-      anchor.setAttribute('rel', 'noopener');
-    });
-  }
-
-  /**
-   * This method is called when the GrLikTextParser emits a partial result
-   * (used as the "callback" parameter). It will be called in either of two
-   * ways:
-   * - To create a link: when called with `text` and `href` arguments, a link
-   *   element should be created and attached to the resulting DOM.
-   * - To attach an arbitrary fragment: when called with only the `fragment`
-   *   argument, the fragment should be attached to the resulting DOM as is.
-   */
-  private _handleParseResult(
-    text: string | null,
-    href: string | null,
-    fragment?: DocumentFragment
-  ) {
-    const output = this.$.output;
-    if (href) {
-      const a = document.createElement('a');
-      a.setAttribute('href', href);
-      // GrLinkTextParser either pass text and href together or
-      // only DocumentFragment - see LinkTextParserCallback
-      a.textContent = text!;
-      a.target = '_blank';
-      a.setAttribute('rel', 'noopener');
-      output.appendChild(a);
-    } else if (fragment) {
-      output.appendChild(fragment);
-    }
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
deleted file mode 100644
index 0d44bc8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: block;
-    }
-    :host([pre]) span {
-      white-space: var(--linked-text-white-space, pre-wrap);
-      word-wrap: var(--linked-text-word-wrap, break-word);
-    }
-    :host([disabled]) a {
-      color: inherit;
-      text-decoration: none;
-      pointer-events: none;
-    }
-    a {
-      color: var(--link-color);
-    }
-  </style>
-  <span id="output"></span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
deleted file mode 100644
index b2cdba1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
+++ /dev/null
@@ -1,414 +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';
-import './gr-linked-text';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {GrLinkedText} from './gr-linked-text';
-import {CommentLinks} from '../../../types/common';
-import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromTemplate(html`
-  <gr-linked-text>
-    <div id="output"></div>
-  </gr-linked-text>
-`);
-
-suite('gr-linked-text tests', () => {
-  let element: GrLinkedText;
-
-  let originalCanonicalPath: string | undefined;
-
-  setup(() => {
-    originalCanonicalPath = window.CANONICAL_PATH;
-    element = basicFixture.instantiate() as GrLinkedText;
-
-    sinon.stub(GerritNav, 'mapCommentlinks').value((x: CommentLinks) => x);
-    element.config = {
-      ph: {
-        match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
-        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-      },
-      prefixsameinlinkandpattern: {
-        match: '([Hh][Tt][Tt][Pp]example)\\s*#?(\\d+)',
-        link: 'https://bugs.chromium.org/p/gerrit/issues/detail?id=$2',
-      },
-      changeid: {
-        match: '(I[0-9a-f]{8,40})',
-        link: '#/q/$1',
-      },
-      changeid2: {
-        match: 'Change-Id: +(I[0-9a-f]{8,40})',
-        link: '#/q/$1',
-      },
-      googlesearch: {
-        match: 'google:(.+)',
-        link: 'https://bing.com/search?q=$1', // html should supercede link.
-        html: '<a href="https://google.com/search?q=$1">$1</a>',
-      },
-      hashedhtml: {
-        match: 'hash:(.+)',
-        html: '<a href="#/awesomesauce">$1</a>',
-      },
-      baseurl: {
-        match: 'test (.+)',
-        html: '<a href="/r/awesomesauce">$1</a>',
-      },
-      anotatstartwithbaseurl: {
-        match: 'a test (.+)',
-        html: '[Lookup: <a href="/r/awesomesauce">$1</a>]',
-      },
-      disabledconfig: {
-        match: 'foo:(.+)',
-        link: 'https://google.com/search?q=$1',
-        enabled: false,
-      },
-    };
-  });
-
-  teardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  test('URL pattern was parsed and linked.', () => {
-    // Regular inline link.
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    element.content = url;
-    const linkEl = queryAndAssert(element, '#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.rel, 'noopener');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, url);
-  });
-
-  test('Bug pattern was parsed and linked', () => {
-    // "Issue/Bug" pattern.
-    element.content = 'Issue 3650';
-
-    let linkEl = queryAndAssert(element, '#output')
-      .childNodes[0] as HTMLAnchorElement;
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'Issue 3650');
-
-    element.content = 'Bug 3650';
-    linkEl = queryAndAssert(element, '#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.rel, 'noopener');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'Bug 3650');
-  });
-
-  test('Pattern with same prefix as link was correctly parsed', () => {
-    // Pattern starts with the same prefix (`http`) as the url.
-    element.content = 'httpexample 3650';
-
-    assert.equal(queryAndAssert(element, '#output').childNodes.length, 1);
-    const linkEl = queryAndAssert(element, '#output')
-      .childNodes[0] as HTMLAnchorElement;
-    const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-    assert.equal(linkEl.target, '_blank');
-    assert.equal(linkEl.href, url);
-    assert.equal(linkEl.textContent, 'httpexample 3650');
-  });
-
-  test('Change-Id pattern was parsed and linked', () => {
-    // "Change-Id:" pattern.
-    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-    const prefix = 'Change-Id: ';
-    element.content = prefix + changeID;
-
-    const textNode = queryAndAssert(element, '#output').childNodes[0];
-    const linkEl = queryAndAssert(element, '#output')
-      .childNodes[1] as HTMLAnchorElement;
-    assert.equal(textNode.textContent, prefix);
-    const url = '/q/' + changeID;
-    assert.isFalse(linkEl.hasAttribute('target'));
-    // Since url is a path, the host is added automatically.
-    assert.isTrue(linkEl.href.endsWith(url));
-    assert.equal(linkEl.textContent, changeID);
-  });
-
-  test('Change-Id pattern was parsed and linked with base url', () => {
-    window.CANONICAL_PATH = '/r';
-
-    // "Change-Id:" pattern.
-    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-    const prefix = 'Change-Id: ';
-    element.content = prefix + changeID;
-
-    const textNode = queryAndAssert(element, '#output').childNodes[0];
-    const linkEl = queryAndAssert(element, '#output')
-      .childNodes[1] as HTMLAnchorElement;
-    assert.equal(textNode.textContent, prefix);
-    const url = '/r/q/' + changeID;
-    assert.isFalse(linkEl.hasAttribute('target'));
-    // Since url is a path, the host is added automatically.
-    assert.isTrue(linkEl.href.endsWith(url));
-    assert.equal(linkEl.textContent, changeID);
-  });
-
-  test('Multiple matches', () => {
-    element.content = 'Issue 3650\nIssue 3450';
-    const linkEl1 = queryAndAssert(element, '#output')
-      .childNodes[0] as HTMLAnchorElement;
-    const linkEl2 = queryAndAssert(element, '#output')
-      .childNodes[2] as HTMLAnchorElement;
-
-    assert.equal(linkEl1.target, '_blank');
-    assert.equal(
-      linkEl1.href,
-      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650'
-    );
-    assert.equal(linkEl1.textContent, 'Issue 3650');
-
-    assert.equal(linkEl2.target, '_blank');
-    assert.equal(
-      linkEl2.href,
-      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450'
-    );
-    assert.equal(linkEl2.textContent, 'Issue 3450');
-  });
-
-  test('Change-Id pattern parsed before bug pattern', () => {
-    // "Change-Id:" pattern.
-    const changeID = 'I11d6a37f5e9b5df0486f6c922d8836dfa780e03e';
-    const prefix = 'Change-Id: ';
-
-    // "Issue/Bug" pattern.
-    const bug = 'Issue 3650';
-
-    const changeUrl = '/q/' + changeID;
-    const bugUrl = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
-
-    element.content = prefix + changeID + bug;
-
-    const textNode = queryAndAssert(element, '#output').childNodes[0];
-    const changeLinkEl = queryAndAssert(element, '#output')
-      .childNodes[1] as HTMLAnchorElement;
-    const bugLinkEl = queryAndAssert(element, '#output')
-      .childNodes[2] as HTMLAnchorElement;
-
-    assert.equal(textNode.textContent, prefix);
-
-    assert.isFalse(changeLinkEl.hasAttribute('target'));
-    assert.isTrue(changeLinkEl.href.endsWith(changeUrl));
-    assert.equal(changeLinkEl.textContent, changeID);
-
-    assert.equal(bugLinkEl.target, '_blank');
-    assert.equal(bugLinkEl.href, bugUrl);
-    assert.equal(bugLinkEl.textContent, 'Issue 3650');
-  });
-
-  test('html field in link config', () => {
-    element.content = 'google:do a barrel roll';
-    const linkEl = queryAndAssert(element, '#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.equal(
-      linkEl.getAttribute('href'),
-      'https://google.com/search?q=do a barrel roll'
-    );
-    assert.equal(linkEl.textContent, 'do a barrel roll');
-  });
-
-  test('removing hash from links', () => {
-    element.content = 'hash:foo';
-    const linkEl = queryAndAssert(element, '#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('html with base url', () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'test foo';
-    const linkEl = queryAndAssert(element, '#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('a is not at start', () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'a test foo';
-    const linkEl = queryAndAssert(element, '#output')
-      .childNodes[1] as HTMLAnchorElement;
-    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('hash html with base url', () => {
-    window.CANONICAL_PATH = '/r';
-
-    element.content = 'hash:foo';
-    const linkEl = queryAndAssert(element, '#output')
-      .childNodes[0] as HTMLAnchorElement;
-    assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
-    assert.equal(linkEl.textContent, 'foo');
-  });
-
-  test('disabled config', () => {
-    element.content = 'foo:baz';
-    assert.equal(queryAndAssert(element, '#output').innerHTML, 'foo:baz');
-  });
-
-  test('R=email labels link correctly', () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'R=\u200Btest@google.com';
-    assert.equal(
-      queryAndAssert(element, '#output').textContent,
-      'R=test@google.com'
-    );
-    assert.equal(
-      queryAndAssert(element, '#output').innerHTML.match(/(R=<a)/g)!.length,
-      1
-    );
-  });
-
-  test('CC=email labels link correctly', () => {
-    element.removeZeroWidthSpace = true;
-    element.content = 'CC=\u200Btest@google.com';
-    assert.equal(
-      queryAndAssert(element, '#output').textContent,
-      'CC=test@google.com'
-    );
-    assert.equal(
-      queryAndAssert(element, '#output').innerHTML.match(/(CC=<a)/g)!.length,
-      1
-    );
-  });
-
-  test('only {http,https,mailto} protocols are linkified', () => {
-    element.content = 'xx mailto:test@google.com yy';
-    let links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
-    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
-    element.content = 'xx http://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'http://google.com');
-    assert.equal(links[0].innerHTML, 'http://google.com');
-
-    element.content = 'xx https://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'https://google.com');
-    assert.equal(links[0].innerHTML, 'https://google.com');
-
-    element.content = 'xx ssh://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-
-    element.content = 'xx ftp://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-  });
-
-  test('links without leading whitespace are linkified', () => {
-    element.content = 'xx abcmailto:test@google.com yy';
-    assert.equal(
-      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
-      'xx abc'
-    );
-    let links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
-    assert.equal(links[0].innerHTML, 'mailto:test@google.com');
-
-    element.content = 'xx defhttp://google.com yy';
-    assert.equal(
-      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
-      'xx def'
-    );
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'http://google.com');
-    assert.equal(links[0].innerHTML, 'http://google.com');
-
-    element.content = 'xx qwehttps://google.com yy';
-    assert.equal(
-      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
-      'xx qwe'
-    );
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'https://google.com');
-    assert.equal(links[0].innerHTML, 'https://google.com');
-
-    // Non-latin character
-    element.content = 'xx абвhttps://google.com yy';
-    assert.equal(
-      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
-      'xx абв'
-    );
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 1);
-    assert.equal(links[0].getAttribute('href'), 'https://google.com');
-    assert.equal(links[0].innerHTML, 'https://google.com');
-
-    element.content = 'xx ssh://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-
-    element.content = 'xx ftp://google.com yy';
-    links = queryAndAssert(element, '#output').querySelectorAll('a');
-    assert.equal(links.length, 0);
-  });
-
-  test('overlapping links', () => {
-    element.config = {
-      b1: {
-        match: '(B:\\s*)(\\d+)',
-        html: '$1<a href="ftp://foo/$2">$2</a>',
-      },
-      b2: {
-        match: '(B:\\s*\\d+\\s*,\\s*)(\\d+)',
-        html: '$1<a href="ftp://foo/$2">$2</a>',
-      },
-    };
-    element.content = '- B: 123, 45';
-    const links = element.root!.querySelectorAll('a');
-
-    assert.equal(links.length, 2);
-    assert.equal(
-      queryAndAssert<HTMLSpanElement>(element, 'span').textContent,
-      '- B: 123, 45'
-    );
-
-    assert.equal(links[0].href, 'ftp://foo/123');
-    assert.equal(links[0].textContent, '123');
-
-    assert.equal(links[1].href, 'ftp://foo/45');
-    assert.equal(links[1].textContent, '45');
-  });
-
-  test('_contentOrConfigChanged called with config', () => {
-    const contentStub = sinon.stub(element, '_contentChanged');
-    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
-    element.content = 'some text';
-    assert.isTrue(contentStub.called);
-    assert.isTrue(contentConfigStub.called);
-  });
-});
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
deleted file mode 100644
index 36f518b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
+++ /dev/null
@@ -1,428 +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 'ba-linkify/ba-linkify';
-import {getBaseUrl} from '../../../utils/url-util';
-import {CommentLinkInfo} from '../../../types/common';
-
-/**
- * Pattern describing URLs with supported protocols.
- */
-const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
-
-export type LinkTextParserCallback = ((text: string, href: string) => void) &
-  ((text: null, href: null, fragment: DocumentFragment) => void);
-
-export interface CommentLinkItem {
-  position: number;
-  length: number;
-  html: HTMLAnchorElement | DocumentFragment;
-}
-
-export type LinkTextParserConfig = {[name: string]: CommentLinkInfo};
-
-export class GrLinkTextParser {
-  private readonly baseUrl = getBaseUrl();
-
-  /**
-   * Construct a parser for linkifying text. Will linkify plain URLs that appear
-   * in the text as well as custom links if any are specified in the linkConfig
-   * parameter.
-   *
-   * @constructor
-   * @param linkConfig Comment links as specified by the commentlinks field on a
-   *     project config.
-   * @param callback The callback to be fired when an intermediate parse result
-   *     is emitted. The callback is passed text and href strings if a link is to
-   *     be created, or a document fragment otherwise.
-   * @param removeZeroWidthSpace If true, zero-width spaces will be removed from
-   *     R=<email> and CC=<email> expressions.
-   */
-  constructor(
-    private readonly linkConfig: LinkTextParserConfig,
-    private readonly callback: LinkTextParserCallback,
-    private readonly removeZeroWidthSpace?: boolean
-  ) {
-    Object.preventExtensions(this);
-  }
-
-  /**
-   * Emit a callback to create a link element.
-   *
-   * @param text The text of the link.
-   * @param href The URL to use as the href of the link.
-   */
-  addText(text: string, href: string) {
-    if (!text) {
-      return;
-    }
-    this.callback(text, href);
-  }
-
-  /**
-   * Given the source text and a list of CommentLinkItem objects that were
-   * generated by the commentlinks config, emit parsing callbacks.
-   *
-   * @param text The chuml of source text over which the outputArray items range.
-   * @param outputArray The list of items to add resulting from commentlink
-   *     matches.
-   */
-  processLinks(text: string, outputArray: CommentLinkItem[]) {
-    this.sortArrayReverse(outputArray);
-    const fragment = document.createDocumentFragment();
-    let cursor = text.length;
-
-    // Start inserting linkified URLs from the end of the String. That way, the
-    // string positions of the items don't change as we iterate through.
-    outputArray.forEach(item => {
-      // Add any text between the current linkified item and the item added
-      // before if it exists.
-      if (item.position + item.length !== cursor) {
-        fragment.insertBefore(
-          document.createTextNode(
-            text.slice(item.position + item.length, cursor)
-          ),
-          fragment.firstChild
-        );
-      }
-      fragment.insertBefore(item.html, fragment.firstChild);
-      cursor = item.position;
-    });
-
-    // Add the beginning portion at the end.
-    if (cursor !== 0) {
-      fragment.insertBefore(
-        document.createTextNode(text.slice(0, cursor)),
-        fragment.firstChild
-      );
-    }
-
-    this.callback(null, null, fragment);
-  }
-
-  /**
-   * Sort the given array of CommentLinkItems such that the positions are in
-   * reverse order.
-   */
-  sortArrayReverse(outputArray: CommentLinkItem[]) {
-    outputArray.sort((a, b) => b.position - a.position);
-  }
-
-  addItem(
-    text: string,
-    href: string,
-    html: null,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ): void;
-
-  addItem(
-    text: null,
-    href: null,
-    html: string,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ): void;
-
-  /**
-   * Create a CommentLinkItem and append it to the given output array. This
-   * method can be called in either of two ways:
-   * - With `text` and `href` parameters provided, and the `html` parameter
-   *   passed as `null`. In this case, the new CommentLinkItem will be a link
-   *   element with the given text and href value.
-   * - With the `html` paremeter provided, and the `text` and `href` parameters
-   *   passed as `null`. In this case, the string of HTML will be parsed and the
-   *   first resulting node will be used as the resulting content.
-   *
-   * @param text The text to use if creating a link.
-   * @param href The href to use as the URL if creating a link.
-   * @param html The html to parse and use as the result.
-   * @param  position The position inside the source text where the item
-   *     starts.
-   * @param length The number of characters in the source text
-   *     represented by the item.
-   * @param outputArray The array to which the
-   *     new item is to be appended.
-   */
-  addItem(
-    text: string | null,
-    href: string | null,
-    html: string | null,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ): void {
-    if (href) {
-      const a = document.createElement('a');
-      a.setAttribute('href', href);
-      a.textContent = text;
-      a.target = '_blank';
-      a.rel = 'noopener';
-      outputArray.push({
-        html: a,
-        position,
-        length,
-      });
-    } else if (html) {
-      // addItem has 2 overloads. If href is null, then html
-      // can't be null.
-      // TODO(TS): remove if(html) and keep else block without condition
-      const fragment = document.createDocumentFragment();
-      // Create temporary div to hold the nodes in.
-      const div = document.createElement('div');
-      div.innerHTML = html;
-      while (div.firstChild) {
-        fragment.appendChild(div.firstChild);
-      }
-      outputArray.push({
-        html: fragment,
-        position,
-        length,
-      });
-    }
-  }
-
-  /**
-   * Create a CommentLinkItem for a link and append it to the given output
-   * array.
-   *
-   * @param text The text for the link.
-   * @param href The href to use as the URL of the link.
-   * @param position The position inside the source text where the link
-   *     starts.
-   * @param length The number of characters in the source text
-   *     represented by the link.
-   * @param outputArray The array to which the
-   *     new item is to be appended.
-   */
-  addLink(
-    text: string,
-    href: string,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ) {
-    // TODO(TS): remove !test condition
-    if (!text || this.hasOverlap(position, length, outputArray)) {
-      return;
-    }
-    if (
-      !!this.baseUrl &&
-      href.startsWith('/') &&
-      !href.startsWith(this.baseUrl)
-    ) {
-      href = this.baseUrl + href;
-    }
-    this.addItem(text, href, null, position, length, outputArray);
-  }
-
-  /**
-   * Create a CommentLinkItem specified by an HTMl string and append it to the
-   * given output array.
-   *
-   * @param html The html to parse and use as the result.
-   * @param position The position inside the source text where the item
-   *     starts.
-   * @param length The number of characters in the source text
-   *     represented by the item.
-   * @param outputArray The array to which the
-   *     new item is to be appended.
-   */
-  addHTML(
-    html: string,
-    position: number,
-    length: number,
-    outputArray: CommentLinkItem[]
-  ) {
-    if (this.hasOverlap(position, length, outputArray)) {
-      return;
-    }
-    if (
-      !!this.baseUrl &&
-      html.match(/<a href="\//g) &&
-      !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)
-    ) {
-      html = html.replace(/<a href="\//g, `<a href="${this.baseUrl}/`);
-    }
-    this.addItem(null, null, html, position, length, outputArray);
-  }
-
-  /**
-   * Does the given range overlap with anything already in the item list.
-   */
-  hasOverlap(position: number, length: number, outputArray: CommentLinkItem[]) {
-    const endPosition = position + length;
-    for (let i = 0; i < outputArray.length; i++) {
-      const arrayItemStart = outputArray[i].position;
-      const arrayItemEnd = outputArray[i].position + outputArray[i].length;
-      if (
-        (position >= arrayItemStart && position < arrayItemEnd) ||
-        (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
-        (position === arrayItemStart && position === arrayItemEnd)
-      ) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  /**
-   * Parse the given source text and emit callbacks for the items that are
-   * parsed.
-   */
-  parse(text?: string | null) {
-    if (text) {
-      window.linkify(text, {
-        callback: (text: string, href?: string) => this.parseChunk(text, href),
-      });
-    }
-  }
-
-  /**
-   * Callback that is pased into the linkify function. ba-linkify will call this
-   * method in either of two ways:
-   * - With both a `text` and `href` parameter provided: this indicates that
-   *   ba-linkify has found a plain URL and wants it linkified.
-   * - With only a `text` parameter provided: this represents the non-link
-   *   content that lies between the links the library has found.
-   *
-   */
-  parseChunk(text: string, href?: string) {
-    // TODO(wyatta) switch linkify sequence, see issue 5526.
-    if (this.removeZeroWidthSpace) {
-      // Remove the zero-width space added in gr-change-view.
-      text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
-    }
-
-    // If the href is provided then ba-linkify has recognized it as a URL. If
-    // the source text does not include a protocol, the protocol will be added
-    // by ba-linkify. Create the link if the href is provided and its protocol
-    // matches the expected pattern.
-    if (href) {
-      const result = URL_PROTOCOL_PATTERN.exec(href);
-      if (result) {
-        const prefixText = result[1];
-        if (prefixText.length > 0) {
-          // Fix for simple cases from
-          // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
-          // When leading whitespace is missed before link,
-          // linkify add this text before link as a schema name to href.
-          // We suppose, that prefixText just a single word
-          // before link and add this word as is, without processing
-          // any patterns in it.
-          this.parseLinks(prefixText, {});
-          text = text.substring(prefixText.length);
-          href = href.substring(prefixText.length);
-        }
-        this.addText(text, href);
-        return;
-      }
-    }
-    // For the sections of text that lie between the links found by
-    // ba-linkify, we search for the project-config-specified link patterns.
-    this.parseLinks(text, this.linkConfig);
-  }
-
-  /**
-   * Walk over the given source text to find matches for comemntlink patterns
-   * and emit parse result callbacks.
-   *
-   * @param text The raw source text.
-   * @param config A comment links specification object.
-   */
-  parseLinks(text: string, config: LinkTextParserConfig) {
-    // The outputArray is used to store all of the matches found for all
-    // patterns.
-    const outputArray: CommentLinkItem[] = [];
-    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 (linkInfo.enabled != null && linkInfo.enabled == false) {
-        continue;
-      }
-      // PolyGerrit doesn't use hash-based navigation like the GWT UI.
-      // Account for this.
-      const html = linkInfo.html;
-      const link = linkInfo.link;
-      if (html) {
-        linkInfo.html = html.replace(/<a href="#\//g, '<a href="/');
-      } else if (link) {
-        if (link[0] === '#') {
-          linkInfo.link = link.substr(1);
-        }
-      }
-
-      const pattern = new RegExp(linkInfo.match, 'g');
-
-      let match;
-      let textToCheck = text;
-      let susbtrIndex = 0;
-
-      while ((match = pattern.exec(textToCheck))) {
-        textToCheck = textToCheck.substr(match.index + match[0].length);
-        let result = match[0].replace(
-          pattern,
-          // Either html or link has a value. Otherwise an exception is thrown
-          // in the code below.
-          (linkInfo.html || linkInfo.link)!
-        );
-
-        if (linkInfo.html) {
-          let i;
-          // Skip portion of replacement string that is equal to original to
-          // allow overlapping patterns.
-          for (i = 0; i < result.length; i++) {
-            if (result[i] !== match[0][i]) {
-              break;
-            }
-          }
-          result = result.slice(i);
-
-          this.addHTML(
-            result,
-            susbtrIndex + match.index + i,
-            match[0].length - i,
-            outputArray
-          );
-        } else if (linkInfo.link) {
-          this.addLink(
-            match[0],
-            result,
-            susbtrIndex + match.index,
-            match[0].length,
-            outputArray
-          );
-        } else {
-          throw Error(
-            'linkconfig entry ' +
-              configName +
-              ' doesn’t contain a link or html attribute.'
-          );
-        }
-
-        // Update the substring location so we know where we are in relation to
-        // the initial full text string.
-        susbtrIndex = susbtrIndex + match.index + match[0].length;
-      }
-    }
-    this.processLinks(text, outputArray);
-  }
-}
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 4f02897..449c041 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
@@ -1,30 +1,19 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
-import '@polymer/iron-icon/iron-icon';
-import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-list-view_html';
+import '../gr-icon/gr-icon';
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {page} from '../../../utils/page-wrapper-utils';
-import {property, customElement} from '@polymer/decorators';
 import {fireEvent} from '../../../utils/event-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
@@ -35,11 +24,7 @@
 }
 
 @customElement('gr-list-view')
-export class GrListView extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrListView extends LitElement {
   @property({type: Boolean})
   createNew?: boolean;
 
@@ -49,7 +34,7 @@
   @property({type: Number})
   itemsPerPage = 25;
 
-  @property({type: String, observer: '_filterChanged'})
+  @property({type: String})
   filter?: string;
 
   @property({type: Number})
@@ -68,37 +53,147 @@
     super.disconnectedCallback();
   }
 
-  _filterChanged(newFilter?: string, oldFilter?: string) {
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        #filter {
+          max-width: 25em;
+        }
+        #filter:focus {
+          outline: none;
+        }
+        #topContainer {
+          align-items: center;
+          display: flex;
+          height: 3rem;
+          justify-content: space-between;
+          margin: 0 var(--spacing-l);
+        }
+        #createNewContainer:not(.show) {
+          display: none;
+        }
+        a {
+          color: var(--primary-text-color);
+          text-decoration: none;
+        }
+        a:hover {
+          text-decoration: underline;
+        }
+        nav {
+          align-items: center;
+          display: flex;
+          height: 3rem;
+          justify-content: flex-end;
+          margin-right: 20px;
+          color: var(--deemphasized-text-color);
+        }
+        gr-icon {
+          font-size: 1.85rem;
+          margin-left: 16px;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div id="topContainer">
+        <div class="filterContainer">
+          <label>Filter:</label>
+          <iron-input
+            .bindValue=${this.filter}
+            @bind-value-changed=${this.handleFilterBindValueChanged}
+          >
+            <input type="text" id="filter" />
+          </iron-input>
+        </div>
+        <div id="createNewContainer" class=${this.createNew ? 'show' : ''}>
+          <gr-button
+            id="createNew"
+            primary
+            link
+            @click=${() => this.createNewItem()}
+          >
+            Create New
+          </gr-button>
+        </div>
+      </div>
+      <slot></slot>
+      <nav>
+        Page ${this.computePage(this.offset, this.itemsPerPage)}
+        <a
+          id="prevArrow"
+          href=${this.computeNavLink(
+            this.offset,
+            -1,
+            this.itemsPerPage,
+            this.filter,
+            this.path
+          )}
+          ?hidden=${this.loading || this.offset === 0}
+        >
+          <gr-icon icon="chevron_left"></gr-icon>
+        </a>
+        <a
+          id="nextArrow"
+          href=${this.computeNavLink(
+            this.offset,
+            1,
+            this.itemsPerPage,
+            this.filter,
+            this.path
+          )}
+          ?hidden=${this.hideNextArrow(this.loading, this.items)}
+        >
+          <gr-icon icon="chevron_right"></gr-icon>
+        </a>
+      </nav>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    // We have to do this for the tests.
+    if (changedProperties.has('filter')) {
+      this.filterChanged(
+        this.filter,
+        changedProperties.get('filter') as string
+      );
+    }
+  }
+
+  private filterChanged(newFilter?: string, oldFilter?: string) {
     // newFilter can be empty string and then !newFilter === true
     if (!newFilter && !oldFilter) {
       return;
     }
-
-    this._debounceReload(newFilter);
+    this.debounceReload(newFilter);
   }
 
-  _debounceReload(filter?: string) {
+  // private but used in test
+  debounceReload(filter?: string) {
     this.reloadTask = debounce(
       this.reloadTask,
       () => {
-        if (this.path) {
-          if (filter) {
-            return page.show(
-              `${this.path}/q/filter:` + encodeURL(filter, false)
-            );
-          }
-          return page.show(this.path);
+        if (!this.isConnected || !this.path) return;
+        if (filter) {
+          // TODO: Use navigation service instead of `page.show()` directly.
+          page.show(`${this.path}/q/filter:${encodeURL(filter, false)}`);
+          return;
         }
+        // TODO: Use navigation service instead of `page.show()` directly.
+        page.show(this.path);
       },
       REQUEST_DEBOUNCE_INTERVAL_MS
     );
   }
 
-  _createNewItem() {
+  private createNewItem() {
     fireEvent(this, 'create-clicked');
   }
 
-  _computeNavLink(
+  // private but used in test
+  computeNavLink(
     offset: number,
     direction: number,
     itemsPerPage: number,
@@ -118,15 +213,8 @@
     return href;
   }
 
-  _computeCreateClass(createNew?: boolean) {
-    return createNew ? 'show' : '';
-  }
-
-  _hidePrevArrow(loading?: boolean, offset?: number) {
-    return loading || offset === 0;
-  }
-
-  _hideNextArrow(loading?: boolean, items?: unknown[]) {
+  // private but used in test
+  hideNextArrow(loading?: boolean, items?: unknown[]) {
     if (loading || !items || !items.length) {
       return true;
     }
@@ -137,7 +225,12 @@
   // TODO: fix offset (including itemsPerPage)
   // to either support a decimal or make it go to the nearest
   // whole number (e.g 3).
-  _computePage(offset: number, itemsPerPage: number) {
+  // private but used in test
+  computePage(offset: number, itemsPerPage: number) {
     return offset / itemsPerPage + 1;
   }
+
+  private handleFilterBindValueChanged(e: BindValueChangeEvent) {
+    this.filter = e.detail.value;
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts
deleted file mode 100644
index 75ee667..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_html.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    #filter {
-      max-width: 25em;
-    }
-    #filter:focus {
-      outline: none;
-    }
-    #topContainer {
-      align-items: center;
-      display: flex;
-      height: 3rem;
-      justify-content: space-between;
-      margin: 0 var(--spacing-l);
-    }
-    #createNewContainer:not(.show) {
-      display: none;
-    }
-    a {
-      color: var(--primary-text-color);
-      text-decoration: none;
-    }
-    a:hover {
-      text-decoration: underline;
-    }
-    nav {
-      align-items: center;
-      display: flex;
-      height: 3rem;
-      justify-content: flex-end;
-      margin-right: 20px;
-    }
-    nav,
-    iron-icon {
-      color: var(--deemphasized-text-color);
-    }
-    iron-icon {
-      height: 1.85rem;
-      margin-left: 16px;
-      width: 1.85rem;
-    }
-  </style>
-  <div id="topContainer">
-    <div class="filterContainer">
-      <label>Filter:</label>
-      <iron-input type="text" bind-value="{{filter}}">
-        <input
-          is="iron-input"
-          type="text"
-          id="filter"
-          bind-value="{{filter}}"
-        />
-      </iron-input>
-    </div>
-    <div id="createNewContainer" class$="[[_computeCreateClass(createNew)]]">
-      <gr-button primary="" link="" id="createNew" on-click="_createNewItem">
-        Create New
-      </gr-button>
-    </div>
-  </div>
-  <slot></slot>
-  <nav>
-    Page [[_computePage(offset, itemsPerPage)]]
-    <a
-      id="prevArrow"
-      href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
-      hidden$="[[_hidePrevArrow(loading, offset)]]"
-      hidden=""
-    >
-      <iron-icon icon="gr-icons:chevron-left"></iron-icon>
-    </a>
-    <a
-      id="nextArrow"
-      href$="[[_computeNavLink(offset, 1, itemsPerPage, filter, path)]]"
-      hidden$="[[_hideNextArrow(loading, items)]]"
-      hidden=""
-    >
-      <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-    </a>
-  </nav>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
deleted file mode 100644
index 066c53e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
+++ /dev/null
@@ -1,147 +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-list-view.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import {stubBaseUrl} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-list-view');
-
-suite('gr-list-view tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_computeNavLink', () => {
-    const offset = 25;
-    const projectsPerPage = 25;
-    let filter = 'test';
-    const path = '/admin/projects';
-
-    stubBaseUrl('');
-
-    assert.equal(
-        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
-        '/admin/projects/q/filter:test,50');
-
-    assert.equal(
-        element._computeNavLink(offset, -1, projectsPerPage, filter, path),
-        '/admin/projects/q/filter:test');
-
-    assert.equal(
-        element._computeNavLink(offset, 1, projectsPerPage, null, path),
-        '/admin/projects,50');
-
-    assert.equal(
-        element._computeNavLink(offset, -1, projectsPerPage, null, path),
-        '/admin/projects');
-
-    filter = 'plugins/';
-    assert.equal(
-        element._computeNavLink(offset, 1, projectsPerPage, filter, path),
-        '/admin/projects/q/filter:plugins%252F,50');
-  });
-
-  test('_onValueChange', async () => {
-    let resolve;
-    const promise = new Promise(r => resolve = r);
-    element.path = '/admin/projects';
-    sinon.stub(page, 'show').callsFake(resolve);
-
-    element.filter = 'test';
-
-    const url = await promise;
-    assert.equal(url, '/admin/projects/q/filter:test');
-  });
-
-  test('_filterChanged not reload when swap between falsy values', () => {
-    sinon.stub(element, '_debounceReload');
-    element.filter = null;
-    element.filter = undefined;
-    element.filter = '';
-    assert.isFalse(element._debounceReload.called);
-  });
-
-  test('next button', () => {
-    element.itemsPerPage = 25;
-    let projects = new Array(26);
-    flush();
-
-    let loading;
-    assert.isFalse(element._hideNextArrow(loading, projects));
-    loading = true;
-    assert.isTrue(element._hideNextArrow(loading, projects));
-    loading = false;
-    assert.isFalse(element._hideNextArrow(loading, projects));
-    element._projects = [];
-    assert.isTrue(element._hideNextArrow(loading, element._projects));
-    projects = new Array(4);
-    assert.isTrue(element._hideNextArrow(loading, projects));
-  });
-
-  test('prev button', () => {
-    assert.isTrue(element._hidePrevArrow(true, 0));
-    flush(() => {
-      let offset = 0;
-      assert.isTrue(element._hidePrevArrow(false, offset));
-      offset = 5;
-      assert.isFalse(element._hidePrevArrow(false, offset));
-    });
-  });
-
-  test('createNew link appears correctly', () => {
-    assert.isFalse(element.shadowRoot
-        .querySelector('#createNewContainer').classList
-        .contains('show'));
-    element.createNew = true;
-    flush();
-    assert.isTrue(element.shadowRoot
-        .querySelector('#createNewContainer').classList
-        .contains('show'));
-  });
-
-  test('fires create clicked event when button tapped', () => {
-    const clickHandler = sinon.stub();
-    element.addEventListener('create-clicked', clickHandler);
-    element.createNew = true;
-    flush();
-    MockInteractions.tap(element.shadowRoot.querySelector('#createNew'));
-    assert.isTrue(clickHandler.called);
-  });
-
-  test('next/prev links change when path changes', () => {
-    const BRANCHES_PATH = '/path/to/branches';
-    const TAGS_PATH = '/path/to/tags';
-    sinon.stub(element, '_computeNavLink');
-    element.offset = 0;
-    element.itemsPerPage = 25;
-    element.filter = '';
-    element.path = BRANCHES_PATH;
-    assert.equal(element._computeNavLink.lastCall.args[4], BRANCHES_PATH);
-    element.path = TAGS_PATH;
-    assert.equal(element._computeNavLink.lastCall.args[4], TAGS_PATH);
-  });
-
-  test('_computePage', () => {
-    assert.equal(element._computePage(0, 25), 1);
-    assert.equal(element._computePage(50, 25), 3);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
new file mode 100644
index 0000000..bbbef72
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
@@ -0,0 +1,205 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-list-view';
+import {GrListView} from './gr-list-view';
+import {page} from '../../../utils/page-wrapper-utils';
+import {queryAndAssert, stubBaseUrl} from '../../../test/test-utils';
+import {GrButton} from '../gr-button/gr-button';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-list-view tests', () => {
+  let element: GrListView;
+
+  setup(async () => {
+    element = await fixture(html`<gr-list-view></gr-list-view>`);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div id="topContainer">
+          <div class="filterContainer">
+            <label> Filter: </label>
+            <iron-input>
+              <input id="filter" type="text" />
+            </iron-input>
+          </div>
+          <div id="createNewContainer">
+            <gr-button
+              aria-disabled="false"
+              id="createNew"
+              link=""
+              primary=""
+              role="button"
+              tabindex="0"
+            >
+              Create New
+            </gr-button>
+          </div>
+        </div>
+        <slot> </slot>
+        <nav>
+          Page 1
+          <a hidden="" href="" id="prevArrow">
+            <gr-icon icon="chevron_left"></gr-icon>
+          </a>
+          <a hidden="" href=",25" id="nextArrow">
+            <gr-icon icon="chevron_right"></gr-icon>
+          </a>
+        </nav>
+      `
+    );
+  });
+
+  test('computeNavLink', () => {
+    const offset = 25;
+    const projectsPerPage = 25;
+    let filter = 'test';
+    const path = '/admin/projects';
+
+    stubBaseUrl('');
+
+    assert.equal(
+      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
+      '/admin/projects/q/filter:test,50'
+    );
+
+    assert.equal(
+      element.computeNavLink(offset, -1, projectsPerPage, filter, path),
+      '/admin/projects/q/filter:test'
+    );
+
+    assert.equal(
+      element.computeNavLink(offset, 1, projectsPerPage, undefined, path),
+      '/admin/projects,50'
+    );
+
+    assert.equal(
+      element.computeNavLink(offset, -1, projectsPerPage, undefined, path),
+      '/admin/projects'
+    );
+
+    filter = 'plugins/';
+    assert.equal(
+      element.computeNavLink(offset, 1, projectsPerPage, filter, path),
+      '/admin/projects/q/filter:plugins%252F,50'
+    );
+  });
+
+  test('_onValueChange', async () => {
+    let resolve: (url: string) => void;
+    const promise = new Promise(r => (resolve = r));
+    element.path = '/admin/projects';
+    sinon.stub(page, 'show').callsFake(r => resolve(r));
+
+    element.filter = 'test';
+    await element.updateComplete;
+
+    const url = await promise;
+    assert.equal(url, '/admin/projects/q/filter:test');
+  });
+
+  test('_filterChanged not reload when swap between falsy values', () => {
+    const debounceReloadStub = sinon.stub(element, 'debounceReload');
+    element.filter = undefined;
+    element.filter = '';
+    assert.isFalse(debounceReloadStub.called);
+  });
+
+  test('next button', async () => {
+    element.itemsPerPage = 25;
+    let projects = new Array(26);
+    await element.updateComplete;
+
+    let loading;
+    assert.isFalse(element.hideNextArrow(loading, projects));
+    loading = true;
+    assert.isTrue(element.hideNextArrow(loading, projects));
+    loading = false;
+    assert.isFalse(element.hideNextArrow(loading, projects));
+    projects = [];
+    assert.isTrue(element.hideNextArrow(loading, projects));
+    projects = new Array(4);
+    assert.isTrue(element.hideNextArrow(loading, projects));
+  });
+
+  test('prev button', async () => {
+    element.loading = true;
+    element.offset = 0;
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<HTMLAnchorElement>(element, '#prevArrow').hasAttribute(
+        'hidden'
+      )
+    );
+
+    element.loading = false;
+    element.offset = 0;
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<HTMLAnchorElement>(element, '#prevArrow').hasAttribute(
+        'hidden'
+      )
+    );
+
+    element.loading = false;
+    element.offset = 5;
+    await element.updateComplete;
+    assert.isFalse(
+      queryAndAssert<HTMLAnchorElement>(element, '#prevArrow').hasAttribute(
+        'hidden'
+      )
+    );
+  });
+
+  test('createNew link appears correctly', async () => {
+    assert.isFalse(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#createNewContainer'
+      ).classList.contains('show')
+    );
+    element.createNew = true;
+    await element.updateComplete;
+    assert.isTrue(
+      queryAndAssert<HTMLDivElement>(
+        element,
+        '#createNewContainer'
+      ).classList.contains('show')
+    );
+  });
+
+  test('fires create clicked event when button tapped', async () => {
+    const clickHandler = sinon.stub();
+    element.addEventListener('create-clicked', clickHandler);
+    element.createNew = true;
+    await element.updateComplete;
+    queryAndAssert<GrButton>(element, '#createNew').click();
+    assert.isTrue(clickHandler.called);
+  });
+
+  test('next/prev links change when path changes', async () => {
+    const BRANCHES_PATH = '/path/to/branches';
+    const TAGS_PATH = '/path/to/tags';
+    const computeNavLinkStub = sinon.stub(element, 'computeNavLink');
+    element.offset = 0;
+    element.itemsPerPage = 25;
+    element.filter = '';
+    element.path = BRANCHES_PATH;
+    await element.updateComplete;
+    assert.equal(computeNavLinkStub.lastCall.args[4], BRANCHES_PATH);
+    element.path = TAGS_PATH;
+    await element.updateComplete;
+    assert.equal(computeNavLinkStub.lastCall.args[4], TAGS_PATH);
+  });
+
+  test('computePage', () => {
+    assert.equal(element.computePage(0, 25), 1);
+    assert.equal(element.computePage(50, 25), 3);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
deleted file mode 100644
index 3d6b5ed..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles';
-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} 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 '../../../mixins/hovercard-mixin/hovercard-mixin';
-
-const AWAIT_MAX_ITERS = 10;
-const AWAIT_STEP = 5;
-const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-overlay': GrOverlay;
-  }
-}
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = IronOverlayMixin(
-  PolymerElement,
-  IronOverlayBehavior as IronOverlayBehavior
-);
-
-/**
- * @attr {Boolean} with-backdrop - inherited from IronOverlay
- */
-@customElement('gr-overlay')
-export class GrOverlay extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when a fullscreen overlay is closed
-   *
-   * @event fullscreen-overlay-closed
-   */
-
-  /**
-   * Fired when an overlay is opened in full screen mode
-   *
-   * @event fullscreen-overlay-opened
-   */
-
-  private fullScreenOpen = false;
-
-  private _boundHandleClose: () => void = () => super.close();
-
-  private focusableNodes?: Node[];
-
-  private returnFocusTo?: HTMLElement;
-
-  override get _focusableNodes() {
-    if (this.focusableNodes) {
-      return this.focusableNodes;
-    }
-    // TODO(TS): to avoid ts error for:
-    // Only public and protected methods of the base class are accessible
-    // via the 'super' keyword.
-    // we call IronFocsablesHelper directly here
-    // Currently IronFocsablesHelper is not exported from iron-focusables-helper
-    // as it should so we use Polymer.IronFocsablesHelper here instead
-    // (can not use the IronFocsablesHelperClass
-    // in case different behavior due to singleton)
-    // once the type contains the exported member,
-    // should replace with:
-    // import {IronFocusablesHelper} from '@polymer/iron-overlay-behavior/iron-focusables-helper';
-    return window.Polymer.IronFocusablesHelper.getTabbableNodes(this);
-  }
-
-  constructor() {
-    super();
-    this.addEventListener('iron-overlay-closed', () => this._overlayClosed());
-    this.addEventListener('iron-overlay-cancelled', () =>
-      this._overlayClosed()
-    );
-  }
-
-  override open() {
-    this.returnFocusTo = findActiveElement(document, true) ?? undefined;
-    window.addEventListener('popstate', this._boundHandleClose);
-    return new Promise<void>((resolve, reject) => {
-      super.open.apply(this);
-      if (this._isMobile()) {
-        fireEvent(this, 'fullscreen-overlay-opened');
-        this.fullScreenOpen = true;
-      }
-      this._awaitOpen(resolve, reject);
-    });
-  }
-
-  _isMobile() {
-    return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
-  }
-
-  // called after iron-overlay is closed. Does not actually close the overlay
-  _overlayClosed() {
-    window.removeEventListener('popstate', this._boundHandleClose);
-    if (this.fullScreenOpen) {
-      fireEvent(this, 'fullscreen-overlay-closed');
-      this.fullScreenOpen = false;
-    }
-    if (this.returnFocusTo) {
-      this.returnFocusTo.focus();
-      this.returnFocusTo = undefined;
-    }
-  }
-
-  override _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);
-  }
-
-  /**
-   * Override the focus stops that iron-overlay-behavior tries to find.
-   */
-  setFocusStops(stops: GrOverlayStops) {
-    this.focusableNodes = [stops.start, stops.end];
-  }
-
-  /**
-   * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
-   * opening. Eventually replace with a direct way to listen to the overlay.
-   */
-  _awaitOpen(fn: (this: GrOverlay) => void, reject: (error: Error) => void) {
-    let iters = 0;
-    const step = () => {
-      setTimeout(() => {
-        if (this.style.display !== 'none') {
-          fn.call(this);
-        } else if (iters++ < AWAIT_MAX_ITERS) {
-          step.call(this);
-        } else {
-          reject(new Error('gr-overlay _awaitOpen failed to resolve'));
-        }
-      }, AWAIT_STEP);
-    };
-    step.call(this);
-  }
-
-  _id() {
-    return this.getAttribute('id') || 'global';
-  }
-}
-
-export interface GrOverlayStops {
-  start: Node;
-  end: Node;
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
deleted file mode 100644
index 730eeac..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_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">
-    :host {
-      background: var(--dialog-background-color);
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-5);
-    }
-
-    @media screen and (max-width: 50em) {
-      :host {
-        height: 100%;
-        left: 0;
-        position: fixed;
-        right: 0;
-        top: 0;
-        border-radius: 0;
-        box-shadow: none;
-      }
-    }
-  </style>
-  <slot></slot>
-`;
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
deleted file mode 100644
index 72c3399..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
+++ /dev/null
@@ -1,86 +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-overlay.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-overlay>
-      <div>content</div>
-    </gr-overlay>
-`);
-
-suite('gr-overlay tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('popstate listener is attached on open and removed on close', () => {
-    const addEventListenerStub = sinon.stub(window, 'addEventListener');
-    const removeEventListenerStub = sinon.stub(window, 'removeEventListener');
-    element.open();
-    assert.isTrue(addEventListenerStub.called);
-    assert.equal(addEventListenerStub.lastCall.args[0], 'popstate');
-    assert.equal(addEventListenerStub.lastCall.args[1],
-        element._boundHandleClose);
-    element._overlayClosed();
-    assert.isTrue(removeEventListenerStub.called);
-    assert.equal(removeEventListenerStub.lastCall.args[0], 'popstate');
-    assert.equal(removeEventListenerStub.lastCall.args[1],
-        element._boundHandleClose);
-  });
-
-  test('events are fired on fullscreen view', async () => {
-    sinon.stub(element, '_isMobile').returns(true);
-    const openHandler = sinon.stub();
-    const closeHandler = sinon.stub();
-    element.addEventListener('fullscreen-overlay-opened', openHandler);
-    element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
-    await element.open();
-
-    assert.isTrue(element._isMobile.called);
-    assert.isTrue(element.fullScreenOpen);
-    assert.isTrue(openHandler.called);
-
-    element._overlayClosed();
-    assert.isFalse(element.fullScreenOpen);
-    assert.isTrue(closeHandler.called);
-  });
-
-  test('events are not fired on desktop view', async () => {
-    sinon.stub(element, '_isMobile').returns(false);
-    const openHandler = sinon.stub();
-    const closeHandler = sinon.stub();
-    element.addEventListener('fullscreen-overlay-opened', openHandler);
-    element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
-    await element.open();
-
-    assert.isTrue(element._isMobile.called);
-    assert.isFalse(element.fullScreenOpen);
-    assert.isFalse(openHandler.called);
-
-    element._overlayClosed();
-    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 423a1a8..08ae5a3 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
@@ -1,35 +1,12 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-page-nav_html';
-import {customElement, property} from '@polymer/decorators';
-
-/**
- * Augment the interface on top of PolymerElement
- * for gr-page-nav.
- */
-export interface GrPageNav {
-  $: {
-    // Note: this is needed to access $.nav
-    // with dotted property access
-    nav: HTMLElement;
-  };
-}
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, query, state} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -38,19 +15,17 @@
 }
 
 @customElement('gr-page-nav')
-export class GrPageNav extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrPageNav extends LitElement {
+  @query('nav') private nav?: HTMLElement;
 
-  @property({type: Number})
-  _headerHeight?: number;
+  // private but used in test
+  @state() headerHeight?: number;
 
   private readonly bodyScrollHandler: () => void;
 
   constructor() {
     super();
-    this.bodyScrollHandler = () => this._handleBodyScroll();
+    this.bodyScrollHandler = () => this.handleBodyScroll();
   }
 
   override connectedCallback() {
@@ -63,40 +38,77 @@
     super.disconnectedCallback();
   }
 
-  _handleBodyScroll() {
-    if (this._headerHeight === undefined) {
-      let top = this._getOffsetTop(this);
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        nav {
+          background-color: var(--table-header-background-color);
+          border: 1px solid var(--border-color);
+          border-top: none;
+          height: 100%;
+          position: absolute;
+          top: 0;
+          width: 14em;
+        }
+        nav.pinned {
+          position: fixed;
+        }
+        @media only screen and (max-width: 53em) {
+          nav {
+            display: none;
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <nav aria-label="Sidebar">
+        <slot></slot>
+      </nav>
+    `;
+  }
+
+  // private but used in test
+  handleBodyScroll() {
+    assertIsDefined(this.nav, 'nav');
+    if (this.headerHeight === undefined) {
+      let top = this.getOffsetTop(this);
       // TODO(TS): Element doesn't have offsetParent,
       // while `offsetParent` are returning Element not HTMLElement
       for (
         let offsetParent = this.offsetParent as HTMLElement | undefined;
         offsetParent;
-        offsetParent = this._getOffsetParent(offsetParent)
+        offsetParent = this.getOffsetParent(offsetParent)
       ) {
-        top += this._getOffsetTop(offsetParent);
+        top += this.getOffsetTop(offsetParent);
       }
-      this._headerHeight = top;
+      this.headerHeight = top;
     }
 
-    this.$.nav.classList.toggle(
+    this.nav.classList.toggle(
       'pinned',
-      this._getScrollY() >= (this._headerHeight || 0)
+      this.getScrollY() >= (this.headerHeight || 0)
     );
   }
 
   /* Functions used for test purposes */
-  _getOffsetParent(element?: HTMLElement) {
+  private getOffsetParent(element?: HTMLElement) {
     if (!element || !('offsetParent' in element)) {
       return undefined;
     }
     return element.offsetParent as HTMLElement;
   }
 
-  _getOffsetTop(element: HTMLElement) {
+  // private but used in test
+  getOffsetTop(element: HTMLElement) {
     return element.offsetTop;
   }
 
-  _getScrollY() {
+  // private but used in test
+  getScrollY() {
     return window.scrollY;
   }
 }
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
deleted file mode 100644
index a9d9216..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_html.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    #nav {
-      background-color: var(--table-header-background-color);
-      border: 1px solid var(--border-color);
-      border-top: none;
-      height: 100%;
-      position: absolute;
-      top: 0;
-      width: 14em;
-    }
-    #nav.pinned {
-      position: fixed;
-    }
-    @media only screen and (max-width: 53em) {
-      #nav {
-        display: none;
-      }
-    }
-  </style>
-  <nav id="nav" aria-label="Sidebar">
-    <slot></slot>
-  </nav>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
deleted file mode 100644
index 2960a1f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
+++ /dev/null
@@ -1,71 +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-page-nav.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-page-nav>
-      <ul>
-        <li>item</li>
-      </ul>
-    </gr-page-nav>
-`);
-
-suite('gr-page-nav tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    flush();
-  });
-
-  test('header is not pinned just below top', () => {
-    sinon.stub(element, '_getOffsetParent').callsFake(() => 0);
-    sinon.stub(element, '_getOffsetTop').callsFake(() => 10);
-    sinon.stub(element, '_getScrollY').callsFake(() => 5);
-    element._handleBodyScroll();
-    assert.isFalse(element.$.nav.classList.contains('pinned'));
-  });
-
-  test('header is pinned when scroll down the page', () => {
-    sinon.stub(element, '_getOffsetParent').callsFake(() => 0);
-    sinon.stub(element, '_getOffsetTop').callsFake(() => 10);
-    sinon.stub(element, '_getScrollY').callsFake(() => 25);
-    window.scrollY = 100;
-    element._handleBodyScroll();
-    assert.isTrue(element.$.nav.classList.contains('pinned'));
-  });
-
-  test('header is not pinned just below top with header set', () => {
-    element._headerHeight = 20;
-    sinon.stub(element, '_getScrollY').callsFake(() => 15);
-    window.scrollY = 100;
-    element._handleBodyScroll();
-    assert.isFalse(element.$.nav.classList.contains('pinned'));
-  });
-
-  test('header is pinned when scroll down the page with header set', () => {
-    element._headerHeight = 20;
-    sinon.stub(element, '_getScrollY').callsFake(() => 25);
-    window.scrollY = 100;
-    element._handleBodyScroll();
-    assert.isTrue(element.$.nav.classList.contains('pinned'));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts
new file mode 100644
index 0000000..1cdb5c7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-page-nav';
+import {GrPageNav} from './gr-page-nav';
+import {fixture, html, assert} from '@open-wc/testing';
+import {queryAndAssert} from '../../../test/test-utils';
+
+suite('gr-page-nav tests', () => {
+  let element: GrPageNav;
+
+  setup(async () => {
+    element = await fixture<GrPageNav>(html`
+      <gr-page-nav>
+        <ul>
+          <li>item</li>
+        </ul>
+      </gr-page-nav>
+    `);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <nav aria-label="Sidebar">
+          <slot> </slot>
+        </nav>
+      `
+    );
+  });
+
+  test('header is not pinned just below top', () => {
+    sinon.stub(element, 'getOffsetTop').callsFake(() => 10);
+    sinon.stub(element, 'getScrollY').callsFake(() => 5);
+    element.handleBodyScroll();
+    assert.isFalse(queryAndAssert(element, 'nav').classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page', () => {
+    sinon.stub(element, 'getOffsetTop').callsFake(() => 10);
+    sinon.stub(element, 'getScrollY').callsFake(() => 25);
+    element.handleBodyScroll();
+    assert.isTrue(queryAndAssert(element, 'nav').classList.contains('pinned'));
+  });
+
+  test('header is not pinned just below top with header set', () => {
+    element.headerHeight = 20;
+    sinon.stub(element, 'getScrollY').callsFake(() => 15);
+    element.handleBodyScroll();
+    assert.isFalse(queryAndAssert(element, 'nav').classList.contains('pinned'));
+  });
+
+  test('header is pinned when scroll down the page with header set', () => {
+    element.headerHeight = 20;
+    sinon.stub(element, 'getScrollY').callsFake(() => 25);
+    element.handleBodyScroll();
+    assert.isTrue(queryAndAssert(element, 'nav').classList.contains('pinned'));
+  });
+});
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 bc60520..6d2fe20 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
@@ -1,27 +1,11 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '@polymer/iron-icon/iron-icon';
-import '../../../styles/shared-styles';
-import '../gr-icons/gr-icons';
 import '../gr-labeled-autocomplete/gr-labeled-autocomplete';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-branch-picker_html';
+import '../gr-icon/gr-icon';
 import {singleDecodeURL} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
 import {AutocompleteQuery} from '../gr-autocomplete/gr-autocomplete';
 import {
   BranchName,
@@ -30,59 +14,107 @@
   BranchInfo,
 } from '../../../types/common';
 import {GrLabeledAutocomplete} from '../gr-labeled-autocomplete/gr-labeled-autocomplete';
-import {appContext} from '../../../services/app-context';
+import {getAppContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, PropertyValues, html, css} from 'lit';
+import {customElement, property, state, query} from 'lit/decorators.js';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {throwingErrorCallback} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
 
-export interface GrRepoBranchPicker {
-  $: {
-    repoInput: GrLabeledAutocomplete;
-    branchInput: GrLabeledAutocomplete;
-  };
-}
 @customElement('gr-repo-branch-picker')
-export class GrRepoBranchPicker extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRepoBranchPicker extends LitElement {
+  @query('#repoInput') protected repoInput?: GrLabeledAutocomplete;
 
-  @property({type: String, notify: true, observer: '_repoChanged'})
+  @query('#branchInput') protected branchInput?: GrLabeledAutocomplete;
+
+  @property({type: String})
   repo?: RepoName;
 
-  @property({type: String, notify: true})
+  @property({type: String})
   branch?: BranchName;
 
-  @property({type: Boolean})
-  _branchDisabled = false;
+  @state() private branchDisabled = false;
 
-  @property({type: Object})
-  _query: AutocompleteQuery = () => Promise.resolve([]);
+  private readonly query: AutocompleteQuery = () => Promise.resolve([]);
 
-  @property({type: Object})
-  _repoQuery: AutocompleteQuery = () => Promise.resolve([]);
+  private readonly repoQuery: AutocompleteQuery = () => Promise.resolve([]);
 
-  private readonly restApiService = appContext.restApiService;
+  private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._query = input => this._getRepoBranchesSuggestions(input);
-    this._repoQuery = input => this._getRepoSuggestions(input);
+    this.query = input => this.getRepoBranchesSuggestions(input);
+    this.repoQuery = input => this.getRepoSuggestions(input);
   }
 
   override connectedCallback() {
     super.connectedCallback();
     if (this.repo) {
-      this.$.repoInput.setText(this.repo);
+      assertIsDefined(this.repoInput, 'repoInput');
+      this.repoInput.setText(this.repo);
+    }
+    this.branchDisabled = !this.repo;
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        gr-labeled-autocomplete {
+          display: inline-block;
+        }
+        gr-icon {
+          margin-bottom: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div>
+        <gr-labeled-autocomplete
+          id="repoInput"
+          label="Repository"
+          placeholder="Select repo"
+          .query=${this.repoQuery}
+          @commit=${(e: CustomEvent<{value: string}>) => {
+            this.repoCommitted(e);
+          }}
+        >
+        </gr-labeled-autocomplete>
+        <gr-icon icon="chevron_right"></gr-icon>
+        <gr-labeled-autocomplete
+          id="branchInput"
+          label="Branch"
+          placeholder="Select branch"
+          ?disabled=${this.branchDisabled}
+          .query=${this.query}
+          @commit=${(e: CustomEvent<{value: string}>) => {
+            this.branchCommitted(e);
+          }}
+        >
+        </gr-labeled-autocomplete>
+      </div>
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this.repoChanged();
     }
   }
 
-  override ready() {
-    super.ready();
-    this._branchDisabled = !this.repo;
-  }
-
-  _getRepoBranchesSuggestions(input: string) {
+  // private but used in test
+  getRepoBranchesSuggestions(input: string) {
     if (!this.repo) {
       return Promise.resolve([]);
     }
@@ -90,17 +122,44 @@
       input = input.substring(REF_PREFIX.length);
     }
     return this.restApiService
-      .getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
-      .then(res => this._branchResponseToSuggestions(res));
+      .getRepoBranches(
+        input,
+        this.repo,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
+      .then(res => this.branchResponseToSuggestions(res));
   }
 
-  _getRepoSuggestions(input: string) {
+  private branchResponseToSuggestions(res: BranchInfo[] | undefined) {
+    if (!res) return [];
+    return res
+      .filter(branchInfo => branchInfo.ref !== 'HEAD')
+      .map(branchInfo => {
+        let branch;
+        if (branchInfo.ref.startsWith(REF_PREFIX)) {
+          branch = branchInfo.ref.substring(REF_PREFIX.length);
+        } else {
+          branch = branchInfo.ref;
+        }
+        return {name: branch, value: branch};
+      });
+  }
+
+  // private but used in test
+  getRepoSuggestions(input: string) {
     return this.restApiService
-      .getRepos(input, SUGGESTIONS_LIMIT)
-      .then(res => this._repoResponseToSuggestions(res));
+      .getRepos(
+        input,
+        SUGGESTIONS_LIMIT,
+        /* offset=*/ undefined,
+        throwingErrorCallback
+      )
+      .then(res => this.repoResponseToSuggestions(res));
   }
 
-  _repoResponseToSuggestions(res: ProjectInfoWithName[] | undefined) {
+  private repoResponseToSuggestions(res: ProjectInfoWithName[] | undefined) {
     if (!res) return [];
     return res.map(repo => {
       return {
@@ -110,34 +169,28 @@
     });
   }
 
-  _branchResponseToSuggestions(res: BranchInfo[] | undefined) {
-    if (!res) return [];
-    return res.map(branchInfo => {
-      let branch;
-      if (branchInfo.ref.startsWith(REF_PREFIX)) {
-        branch = branchInfo.ref.substring(REF_PREFIX.length);
-      } else {
-        branch = branchInfo.ref;
-      }
-      return {name: branch, value: branch};
-    });
-  }
-
-  _repoCommitted(e: CustomEvent<{value: string}>) {
+  private repoCommitted(e: CustomEvent<{value: string}>) {
     this.repo = e.detail.value as RepoName;
+    fire(this, 'repo-changed', {value: e.detail.value});
   }
 
-  _branchCommitted(e: CustomEvent<{value: string}>) {
+  private branchCommitted(e: CustomEvent<{value: string}>) {
     this.branch = e.detail.value as BranchName;
+    fire(this, 'branch-changed', {value: e.detail.value});
   }
 
-  _repoChanged() {
-    this.$.branchInput.clear();
-    this._branchDisabled = !this.repo;
+  private repoChanged() {
+    assertIsDefined(this.branchInput, 'branchInput');
+    this.branchInput.clear();
+    this.branchDisabled = !this.repo;
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'branch-changed': BindValueChangeEvent;
+    'repo-changed': BindValueChangeEvent;
+  }
   interface HTMLElementTagNameMap {
     'gr-repo-branch-picker': GrRepoBranchPicker;
   }
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
deleted file mode 100644
index 3e551b6..0000000
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_html.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    gr-labeled-autocomplete,
-    iron-icon {
-      display: inline-block;
-    }
-    iron-icon {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div>
-    <gr-labeled-autocomplete
-      id="repoInput"
-      label="Repository"
-      placeholder="Select repo"
-      on-commit="_repoCommitted"
-      query="[[_repoQuery]]"
-    >
-    </gr-labeled-autocomplete>
-    <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-    <gr-labeled-autocomplete
-      id="branchInput"
-      label="Branch"
-      placeholder="Select branch"
-      disabled="[[_branchDisabled]]"
-      on-commit="_branchCommitted"
-      query="[[_query]]"
-    >
-    </gr-labeled-autocomplete>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
index 31efa73..6839431 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
@@ -1,36 +1,49 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-repo-branch-picker';
 import {GrRepoBranchPicker} from './gr-repo-branch-picker';
 import {stubRestApi} from '../../../test/test-utils';
 import {GitRef, ProjectInfoWithName, RepoName} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-repo-branch-picker');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-repo-branch-picker tests', () => {
   let element: GrRepoBranchPicker;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-repo-branch-picker></gr-repo-branch-picker>`
+    );
   });
 
-  suite('_getRepoSuggestions', () => {
+  test('render', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div>
+          <gr-labeled-autocomplete
+            id="repoInput"
+            label="Repository"
+            placeholder="Select repo"
+          >
+          </gr-labeled-autocomplete>
+          <gr-icon icon="chevron_right"></gr-icon>
+          <gr-labeled-autocomplete
+            disabled=""
+            id="branchInput"
+            label="Branch"
+            placeholder="Select branch"
+          >
+          </gr-labeled-autocomplete>
+        </div>
+      `
+    );
+  });
+
+  suite('getRepoSuggestions', () => {
     let getReposStub: sinon.SinonStub;
     setup(() => {
       getReposStub = stubRestApi('getRepos').returns(
@@ -57,7 +70,7 @@
 
     test('converts to suggestion objects', async () => {
       const input = 'plugins/avatars';
-      const suggestions = await element._getRepoSuggestions(input);
+      const suggestions = await element.getRepoSuggestions(input);
       assert.isTrue(getReposStub.calledWith(input));
       const unencodedNames = [
         'plugins/avatars-external',
@@ -76,13 +89,14 @@
     });
   });
 
-  suite('_getRepoBranchesSuggestions', () => {
+  suite('getRepoBranchesSuggestions', () => {
     let getRepoBranchesStub: sinon.SinonStub;
     setup(() => {
       getRepoBranchesStub = stubRestApi('getRepoBranches').returns(
         Promise.resolve([
-          {ref: 'refs/heads/stable-2.10' as GitRef, revision: '123'},
-          {ref: 'refs/heads/stable-2.11' as GitRef, revision: '1234'},
+          {ref: 'HEAD' as GitRef, revision: 'main'},
+          {ref: 'refs/heads/stable-2.10' as GitRef, revision: '123af'},
+          {ref: 'refs/heads/stable-2.11' as GitRef, revision: '1234b'},
           {ref: 'refs/heads/stable-2.12' as GitRef, revision: '12345'},
           {ref: 'refs/heads/stable-2.13' as GitRef, revision: '123456'},
           {ref: 'refs/heads/stable-2.14' as GitRef, revision: '1234567'},
@@ -95,9 +109,7 @@
       const repo = 'gerrit';
       const branchInput = 'stable-2.1';
       element.repo = repo as RepoName;
-      const suggestions = await element._getRepoBranchesSuggestions(
-        branchInput
-      );
+      const suggestions = await element.getRepoBranchesSuggestions(branchInput);
       assert.isTrue(getRepoBranchesStub.calledWith(branchInput, repo, 15));
       const refNames = [
         'stable-2.10',
@@ -121,16 +133,15 @@
       const repo = 'gerrit' as RepoName;
       const branchInput = 'refs/heads/stable-2.1';
       element.repo = repo;
-      return element._getRepoBranchesSuggestions(branchInput).then(() => {
-        assert.isTrue(getRepoBranchesStub.calledWith('stable-2.1', repo, 15));
-      });
+      await element.getRepoBranchesSuggestions(branchInput);
+      assert.isTrue(getRepoBranchesStub.calledWith('stable-2.1', repo, 15));
     });
 
     test('does not query when repo is unset', async () => {
-      await element._getRepoBranchesSuggestions('');
+      await element.getRepoBranchesSuggestions('');
       assert.isFalse(getRepoBranchesStub.called);
       element.repo = 'gerrit' as RepoName;
-      await element._getRepoBranchesSuggestions('');
+      await element.getRepoBranchesSuggestions('');
       assert.isTrue(getRepoBranchesStub.called);
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
index 2c1ba70..684863e 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // Limit cache size because /change/detail responses may be large.
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js
deleted file mode 100644
index f4099ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js
+++ /dev/null
@@ -1,84 +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 'lodash/lodash.js';
-import {GrEtagDecorator} from './gr-etag-decorator.js';
-
-suite('gr-etag-decorator', () => {
-  let etag;
-
-  const fakeRequest = (opt_etag, opt_status) => {
-    const headers = new Headers();
-    if (opt_etag) {
-      headers.set('etag', opt_etag);
-    }
-    const status = opt_status || 200;
-    return {ok: true, status, headers};
-  };
-
-  setup(() => {
-    etag = new GrEtagDecorator();
-  });
-
-  test('exists', () => {
-    assert.isOk(etag);
-  });
-
-  test('works', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    const options = etag.getOptions('/foo');
-    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
-  });
-
-  test('updates etags', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    etag.collect('/foo', fakeRequest('baz'));
-    const options = etag.getOptions('/foo');
-    assert.strictEqual(options.headers.get('If-None-Match'), 'baz');
-  });
-
-  test('discards empty etags', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    etag.collect('/foo', fakeRequest());
-    const options = etag.getOptions('/foo', {headers: new Headers()});
-    assert.isNull(options.headers.get('If-None-Match'));
-  });
-
-  test('discards etags in order used', () => {
-    etag.collect('/foo', fakeRequest('bar'));
-    _.times(29, i => {
-      etag.collect('/qaz/' + i, fakeRequest('qaz'));
-    });
-    let options = etag.getOptions('/foo');
-    assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
-    etag.collect('/zaq', fakeRequest('zaq'));
-    options = etag.getOptions('/foo', {headers: new Headers()});
-    assert.isNull(options.headers.get('If-None-Match'));
-  });
-
-  test('getCachedPayload', () => {
-    const payload = 'payload';
-    etag.collect('/foo', fakeRequest('bar'), payload);
-    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
-    etag.collect('/foo', fakeRequest('bar', 304), 'garbage');
-    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
-    etag.collect('/foo', fakeRequest('bar', 200), 'new payload');
-    assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
new file mode 100644
index 0000000..4f22f25
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import {GrEtagDecorator} from './gr-etag-decorator';
+
+suite('gr-etag-decorator', () => {
+  let etag: GrEtagDecorator;
+
+  const fakeRequest = (opt_etag?: string, opt_status?: number) => {
+    const headers = new Headers();
+    if (opt_etag) {
+      headers.set('etag', opt_etag);
+    }
+    const status = opt_status || 200;
+    return {...new Response(), ok: true, status, headers};
+  };
+
+  setup(() => {
+    etag = new GrEtagDecorator();
+  });
+
+  test('exists', () => {
+    assert.isOk(etag);
+  });
+
+  test('works', () => {
+    etag.collect('/foo', fakeRequest('bar'), '');
+    const options = etag.getOptions('/foo');
+    assert.strictEqual(options!.headers!.get('If-None-Match'), 'bar');
+  });
+
+  test('updates etags', () => {
+    etag.collect('/foo', fakeRequest('bar'), '');
+    etag.collect('/foo', fakeRequest('baz'), '');
+    const options = etag.getOptions('/foo');
+    assert.strictEqual(options!.headers!.get('If-None-Match'), 'baz');
+  });
+
+  test('discards empty etags', () => {
+    etag.collect('/foo', fakeRequest('bar'), '');
+    etag.collect('/foo', fakeRequest(), '');
+    const options = etag.getOptions('/foo', {headers: new Headers()});
+    assert.isNull(options!.headers!.get('If-None-Match'));
+  });
+
+  test('discards etags in order used', () => {
+    etag.collect('/foo', fakeRequest('bar'), '');
+    for (let i = 0; i < 29; i++) {
+      etag.collect(`/qaz/${i}`, fakeRequest('qaz'), '');
+    }
+    let options = etag.getOptions('/foo');
+    assert.strictEqual(options!.headers!.get('If-None-Match'), 'bar');
+    etag.collect('/zaq', fakeRequest('zaq'), '');
+    options = etag.getOptions('/foo', {headers: new Headers()});
+    assert.isNull(options!.headers!.get('If-None-Match'));
+  });
+
+  test('getCachedPayload', () => {
+    const payload = 'payload';
+    etag.collect('/foo', fakeRequest('bar'), payload);
+    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
+    etag.collect('/foo', fakeRequest('bar', 304), 'garbage');
+    assert.strictEqual(etag.getCachedPayload('/foo'), payload);
+    etag.collect('/foo', fakeRequest('bar', 200), 'new payload');
+    assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
+  });
+});
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
deleted file mode 100644
index c2b0269..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ /dev/null
@@ -1,3278 +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.
- */
-/* NB: Order is important, because of namespaced classes. */
-
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {GrEtagDecorator} from './gr-etag-decorator';
-import {
-  FetchJSONRequest,
-  FetchParams,
-  FetchPromisesCache,
-  GrRestApiHelper,
-  parsePrefixedJSON,
-  readResponsePayload,
-  SendJSONRequest,
-  SendRequest,
-  SiteBasedCache,
-} from './gr-rest-apis/gr-rest-api-helper';
-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} from '../../../utils/patch-set-util';
-import {
-  ListChangesOption,
-  listChangesOptionsToHex,
-} from '../../../utils/change-util';
-import {assertNever, hasOwnProperty} from '../../../utils/common-util';
-import {customElement} from '@polymer/decorators';
-import {AuthRequestInit, AuthService} from '../../../services/gr-auth/gr-auth';
-import {
-  AccountCapabilityInfo,
-  AccountDetailInfo,
-  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,
-  DiffPreferenceInput,
-  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,
-  SubmittedTogetherInfo,
-  SuggestedReviewerInfo,
-  TagInfo,
-  TagInput,
-  TopMenuEntryInfo,
-  UrlEncodedCommentId,
-} from '../../../types/common';
-import {
-  DiffInfo,
-  DiffPreferencesInfo,
-  IgnoreWhitespaceType,
-} from '../../../types/diff';
-import {
-  CancelConditionCallback,
-  GetDiffCommentsOutput,
-  GetDiffRobotCommentsOutput,
-  RestApiService,
-} from '../../../services/gr-rest-api/gr-rest-api';
-import {
-  CommentSide,
-  createDefaultDiffPrefs,
-  createDefaultEditPrefs,
-  createDefaultPreferences,
-  HttpMethod,
-  ReviewerState,
-} from '../../../constants/constants';
-import {firePageError, fireServerError} from '../../../utils/event-util';
-import {ParsedChangeInfo} from '../../../types/types';
-import {ErrorCallback} from '../../../api/rest';
-import {FlagsService, KnownExperimentId} from '../../../services/flags/flags';
-
-const MAX_PROJECT_RESULTS = 25;
-
-const Requests = {
-  SEND_DIFF_DRAFT: 'sendDiffDraft',
-};
-
-const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
-  'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
-const HEADER_REPORTING_BLOCK_REGEX = /^set-cookie$/i;
-
-const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
-const ANONYMIZED_REVISION_BASE_URL =
-  ANONYMIZED_CHANGE_BASE_URL + '/revisions/*';
-
-let siteBasedCache = new SiteBasedCache(); // Shared across instances.
-let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances.
-let pendingRequest: {[promiseName: string]: Array<Promise<unknown>>} = {}; // Shared across instances.
-let grEtagDecorator = new GrEtagDecorator(); // Shared across instances.
-let projectLookup: {[changeNum: string]: RepoName} = {}; // Shared across instances.
-
-interface FetchChangeJSON {
-  reportEndpointAsIs?: boolean;
-  endpoint: string;
-  anonymizedEndpoint?: string;
-  revision?: RevisionId;
-  changeNum: NumericChangeId;
-  errFn?: ErrorCallback;
-  params?: FetchParams;
-  fetchOptions?: AuthRequestInit;
-  // TODO(TS): The following properties are not used, however some methods
-  // set them to true. They should be either changed to reportEndpointAsIs: true
-  // or deleted. This should be done carefully case by case.
-  reportEndpointAsId?: true;
-}
-
-interface SendChangeRequestBase {
-  patchNum?: PatchSetNum;
-  reportEndpointAsIs?: boolean;
-  endpoint: string;
-  anonymizedEndpoint?: string;
-  changeNum: NumericChangeId;
-  method: HttpMethod | undefined;
-  errFn?: ErrorCallback;
-  headers?: Record<string, string>;
-  contentType?: string;
-  body?: string | object;
-
-  // TODO(TS): The following properties are not used, however some methods
-  // set them to true. They should be either changed to reportEndpointAsIs: true
-  // or deleted. This should be done carefully case by case.
-  reportUrlAsIs?: true;
-  reportEndpointAsId?: true;
-}
-
-interface SendRawChangeRequest extends SendChangeRequestBase {
-  parseResponse?: false | null;
-}
-
-interface SendJSONChangeRequest extends SendChangeRequestBase {
-  parseResponse: true;
-}
-
-interface QueryChangesParams {
-  [paramName: string]: string | undefined | number | string[];
-  O?: string; // options
-  S: number; // start
-  n?: number; // changes per page
-  q?: string | string[]; // query/queries
-}
-
-interface QueryAccountsParams {
-  [paramName: string]: string | undefined | null | number;
-  suggest: null;
-  q: string;
-  n?: number;
-}
-
-interface QueryGroupsParams {
-  [paramName: string]: string | undefined | null | number;
-  s: string;
-  n?: number;
-}
-
-interface QuerySuggestedReviewersParams {
-  [paramName: string]: string | undefined | null | number;
-  n: number;
-  q?: string;
-  'reviewer-state': ReviewerState;
-}
-
-interface GetDiffParams {
-  [paramName: string]: string | undefined | null | number | boolean;
-  intraline?: boolean | null;
-  whitespace?: IgnoreWhitespaceType;
-  parent?: number;
-  base?: PatchSetNum;
-}
-
-type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
-
-export function _testOnlyResetGrRestApiSharedObjects() {
-  siteBasedCache = new SiteBasedCache();
-  fetchPromisesCache = new FetchPromisesCache();
-  pendingRequest = {};
-  grEtagDecorator = new GrEtagDecorator();
-  projectLookup = {};
-  appContext.authService.clearCache();
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-rest-api-interface': GrRestApiInterface;
-  }
-}
-
-@customElement('gr-rest-api-interface')
-export class GrRestApiInterface
-  extends PolymerElement
-  implements RestApiService
-{
-  readonly _cache = siteBasedCache; // Shared across instances.
-
-  readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.
-
-  readonly _pendingRequests = pendingRequest; // Shared across instances.
-
-  readonly _etags = grEtagDecorator; // Shared across instances.
-
-  readonly _projectLookup = projectLookup; // Shared across instances.
-
-  // The value is set in created, before any other actions
-  private authService: AuthService;
-
-  private flagService: FlagsService;
-
-  // The value is set in created, before any other actions
-  private readonly _restApiHelper: GrRestApiHelper;
-
-  constructor(authService?: AuthService, flagService?: FlagsService) {
-    super();
-    // TODO: Make the authService constructor parameter required when we have
-    // changed all usages of this class to not instantiate via createElement().
-    this.authService = authService ?? appContext.authService;
-    this.flagService = flagService ?? appContext.flagsService;
-    this._restApiHelper = new GrRestApiHelper(
-      this._cache,
-      this.authService,
-      this._sharedFetchPromises
-    );
-  }
-
-  _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
-    // Cache is shared across instances
-    return this._restApiHelper.fetchCacheURL(req);
-  }
-
-  getResponseObject(response: Response): Promise<ParsedJSON> {
-    return this._restApiHelper.getResponseObject(response);
-  }
-
-  getConfig(noCache?: boolean): Promise<ServerInfo | undefined> {
-    if (!noCache) {
-      return this._fetchSharedCacheURL({
-        url: '/config/server/info',
-        reportUrlAsIs: true,
-      }) as Promise<ServerInfo | undefined>;
-    }
-
-    return this._restApiHelper.fetchJSON({
-      url: '/config/server/info',
-      reportUrlAsIs: true,
-    }) as Promise<ServerInfo | undefined>;
-  }
-
-  getRepo(
-    repo: RepoName,
-    errFn?: ErrorCallback
-  ): Promise<ProjectInfo | undefined> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
-      url: '/projects/' + encodeURIComponent(repo),
-      errFn,
-      anonymizedUrl: '/projects/*',
-    }) as Promise<ProjectInfo | undefined>;
-  }
-
-  getProjectConfig(
-    repo: RepoName,
-    errFn?: ErrorCallback
-  ): Promise<ConfigInfo | undefined> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
-      url: '/projects/' + encodeURIComponent(repo) + '/config',
-      errFn,
-      anonymizedUrl: '/projects/*/config',
-    }) as Promise<ConfigInfo | undefined>;
-  }
-
-  getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
-      url: '/access/?project=' + encodeURIComponent(repo),
-      anonymizedUrl: '/access/?project=*',
-    }) as Promise<ProjectAccessInfoMap | undefined>;
-  }
-
-  getRepoDashboards(
-    repo: RepoName,
-    errFn?: ErrorCallback
-  ): Promise<DashboardInfo[] | undefined> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
-      url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
-      errFn,
-      anonymizedUrl: '/projects/*/dashboards?inherited',
-    }) as Promise<DashboardInfo[] | 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`;
-    this._cache.delete(url);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url,
-      body: config,
-      anonymizedUrl: '/projects/*/config',
-    });
-  }
-
-  runRepoGC(repo: RepoName): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    const encodeName = encodeURIComponent(repo);
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
-      url: `/projects/${encodeName}/gc`,
-      body: '',
-      anonymizedUrl: '/projects/*/gc',
-    });
-  }
-
-  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);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/projects/${encodeName}`,
-      body: config,
-      anonymizedUrl: '/projects/*',
-    });
-  }
-
-  createGroup(config: GroupInput & {name: string}): Promise<Response> {
-    const encodeName = encodeURIComponent(config.name);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/groups/${encodeName}`,
-      body: config,
-      anonymizedUrl: '/groups/*',
-    });
-  }
-
-  getGroupConfig(
-    group: GroupId | GroupName,
-    errFn?: ErrorCallback
-  ): Promise<GroupInfo | undefined> {
-    return this._restApiHelper.fetchJSON({
-      url: `/groups/${encodeURIComponent(group)}/detail`,
-      errFn,
-      anonymizedUrl: '/groups/*/detail',
-    }) as Promise<GroupInfo | undefined>;
-  }
-
-  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    const encodeName = encodeURIComponent(repo);
-    const encodeRef = encodeURIComponent(ref);
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
-      url: `/projects/${encodeName}/branches/${encodeRef}`,
-      body: '',
-      anonymizedUrl: '/projects/*/branches/*',
-    });
-  }
-
-  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    const encodeName = encodeURIComponent(repo);
-    const encodeRef = encodeURIComponent(ref);
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
-      url: `/projects/${encodeName}/tags/${encodeRef}`,
-      body: '',
-      anonymizedUrl: '/projects/*/tags/*',
-    });
-  }
-
-  createRepoBranch(
-    name: RepoName,
-    branch: BranchName,
-    revision: BranchInput
-  ): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    const encodeName = encodeURIComponent(name);
-    const encodeBranch = encodeURIComponent(branch);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/projects/${encodeName}/branches/${encodeBranch}`,
-      body: revision,
-      anonymizedUrl: '/projects/*/branches/*',
-    });
-  }
-
-  createRepoTag(
-    name: RepoName,
-    tag: string,
-    revision: TagInput
-  ): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    const encodeName = encodeURIComponent(name);
-    const encodeTag = encodeURIComponent(tag);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/projects/${encodeName}/tags/${encodeTag}`,
-      body: revision,
-      anonymizedUrl: '/projects/*/tags/*',
-    });
-  }
-
-  getIsGroupOwner(groupName: GroupName): Promise<boolean> {
-    const encodeName = encodeURIComponent(groupName);
-    const req = {
-      url: `/groups/?owned&g=${encodeName}`,
-      anonymizedUrl: '/groups/owned&g=*',
-    };
-    return this._fetchSharedCacheURL(req).then(configs =>
-      hasOwnProperty(configs, groupName)
-    );
-  }
-
-  getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]> {
-    const encodeName = encodeURIComponent(groupName);
-    return this._restApiHelper.fetchJSON({
-      url: `/groups/${encodeName}/members/`,
-      anonymizedUrl: '/groups/*/members',
-    }) as unknown as Promise<AccountInfo[]>;
-  }
-
-  getIncludedGroup(
-    groupName: GroupId | GroupName
-  ): Promise<GroupInfo[] | undefined> {
-    return this._restApiHelper.fetchJSON({
-      url: `/groups/${encodeURIComponent(groupName)}/groups/`,
-      anonymizedUrl: '/groups/*/groups',
-    }) as Promise<GroupInfo[] | undefined>;
-  }
-
-  saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> {
-    const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/groups/${encodeId}/name`,
-      body: {name},
-      anonymizedUrl: '/groups/*/name',
-    });
-  }
-
-  saveGroupOwner(
-    groupId: GroupId | GroupName,
-    ownerId: string
-  ): Promise<Response> {
-    const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/groups/${encodeId}/owner`,
-      body: {owner: ownerId},
-      anonymizedUrl: '/groups/*/owner',
-    });
-  }
-
-  saveGroupDescription(
-    groupId: GroupId | GroupName,
-    description: string
-  ): Promise<Response> {
-    const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/groups/${encodeId}/description`,
-      body: {description},
-      anonymizedUrl: '/groups/*/description',
-    });
-  }
-
-  saveGroupOptions(
-    groupId: GroupId | GroupName,
-    options: GroupOptionsInput
-  ): Promise<Response> {
-    const encodeId = encodeURIComponent(groupId);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/groups/${encodeId}/options`,
-      body: options,
-      anonymizedUrl: '/groups/*/options',
-    });
-  }
-
-  getGroupAuditLog(
-    group: EncodedGroupId,
-    errFn?: ErrorCallback
-  ): Promise<GroupAuditEventInfo[] | undefined> {
-    return this._fetchSharedCacheURL({
-      url: `/groups/${group}/log.audit`,
-      errFn,
-      anonymizedUrl: '/groups/*/log.audit',
-    }) as Promise<GroupAuditEventInfo[] | undefined>;
-  }
-
-  saveGroupMember(
-    groupName: GroupId | GroupName,
-    groupMember: AccountId
-  ): Promise<AccountInfo> {
-    const encodeName = encodeURIComponent(groupName);
-    const encodeMember = encodeURIComponent(`${groupMember}`);
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/groups/${encodeName}/members/${encodeMember}`,
-      parseResponse: true,
-      anonymizedUrl: '/groups/*/members/*',
-    }) as unknown as Promise<AccountInfo>;
-  }
-
-  saveIncludedGroup(
-    groupName: GroupId | GroupName,
-    includedGroup: GroupId,
-    errFn?: ErrorCallback
-  ): Promise<GroupInfo | undefined> {
-    const encodeName = encodeURIComponent(groupName);
-    const encodeIncludedGroup = encodeURIComponent(includedGroup);
-    const req = {
-      method: HttpMethod.PUT,
-      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
-      errFn,
-      anonymizedUrl: '/groups/*/groups/*',
-    };
-    return this._restApiHelper.send(req).then(response => {
-      if (response?.ok) {
-        return this.getResponseObject(
-          response
-        ) as unknown as Promise<GroupInfo>;
-      }
-      return undefined;
-    });
-  }
-
-  deleteGroupMember(
-    groupName: GroupId | GroupName,
-    groupMember: AccountId
-  ): Promise<Response> {
-    const encodeName = encodeURIComponent(groupName);
-    const encodeMember = encodeURIComponent(`${groupMember}`);
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
-      url: `/groups/${encodeName}/members/${encodeMember}`,
-      anonymizedUrl: '/groups/*/members/*',
-    });
-  }
-
-  deleteIncludedGroup(
-    groupName: GroupId,
-    includedGroup: GroupId | GroupName
-  ): Promise<Response> {
-    const encodeName = encodeURIComponent(groupName);
-    const encodeIncludedGroup = encodeURIComponent(includedGroup);
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
-      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
-      anonymizedUrl: '/groups/*/groups/*',
-    });
-  }
-
-  getVersion(): Promise<string | undefined> {
-    return this._fetchSharedCacheURL({
-      url: '/config/server/version',
-      reportUrlAsIs: true,
-    }) as Promise<string | undefined>;
-  }
-
-  getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
-    return this.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        return this._fetchSharedCacheURL({
-          url: '/accounts/self/preferences.diff',
-          reportUrlAsIs: true,
-        }) as Promise<DiffPreferencesInfo | undefined>;
-      }
-      return Promise.resolve(createDefaultDiffPrefs());
-    });
-  }
-
-  getEditPreferences(): Promise<EditPreferencesInfo | undefined> {
-    return this.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        return this._fetchSharedCacheURL({
-          url: '/accounts/self/preferences.edit',
-          reportUrlAsIs: true,
-        }) as Promise<EditPreferencesInfo | undefined>;
-      }
-      return Promise.resolve(createDefaultEditPrefs());
-    });
-  }
-
-  savePreferences(
-    prefs: PreferencesInput
-  ): Promise<PreferencesInfo | undefined> {
-    // Note (Issue 5142): normalize the download scheme with lower case before
-    // saving.
-    if (prefs.download_scheme) {
-      prefs.download_scheme = prefs.download_scheme.toLowerCase();
-    }
-
-    return this._restApiHelper
-      .send({
-        method: HttpMethod.PUT,
-        url: '/accounts/self/preferences',
-        body: prefs,
-        reportUrlAsIs: true,
-      })
-      .then((response: Response) =>
-        this.getResponseObject(response).then(
-          obj => obj as unknown as PreferencesInfo
-        )
-      );
-  }
-
-  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,
-      reportUrlAsIs: true,
-    });
-  }
-
-  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,
-      reportUrlAsIs: true,
-    });
-  }
-
-  getAccount(): Promise<AccountDetailInfo | undefined> {
-    return this._fetchSharedCacheURL({
-      url: '/accounts/self/detail',
-      reportUrlAsIs: true,
-      errFn: resp => {
-        if (!resp || resp.status === 403) {
-          this._cache.delete('/accounts/self/detail');
-        }
-      },
-    }) as Promise<AccountDetailInfo | undefined>;
-  }
-
-  getAvatarChangeUrl() {
-    return this._fetchSharedCacheURL({
-      url: '/accounts/self/avatar.change.url',
-      reportUrlAsIs: true,
-      errFn: resp => {
-        if (!resp || resp.status === 403) {
-          this._cache.delete('/accounts/self/avatar.change.url');
-        }
-      },
-    }) as Promise<string | undefined>;
-  }
-
-  getExternalIds() {
-    return this._restApiHelper.fetchJSON({
-      url: '/accounts/self/external.ids',
-      reportUrlAsIs: true,
-    }) as Promise<AccountExternalIdInfo[] | undefined>;
-  }
-
-  deleteAccountIdentity(id: string[]) {
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
-      url: '/accounts/self/external.ids:delete',
-      body: id,
-      parseResponse: true,
-      reportUrlAsIs: true,
-    }) as Promise<unknown>;
-  }
-
-  getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined> {
-    return this._restApiHelper.fetchJSON({
-      url: `/accounts/${encodeURIComponent(userId)}/detail`,
-      anonymizedUrl: '/accounts/*/detail',
-    }) as Promise<AccountDetailInfo | undefined>;
-  }
-
-  getAccountEmails() {
-    return this._fetchSharedCacheURL({
-      url: '/accounts/self/emails',
-      reportUrlAsIs: true,
-    }) as Promise<EmailInfo[] | undefined>;
-  }
-
-  addAccountEmail(email: string): Promise<Response> {
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: '/accounts/self/emails/' + encodeURIComponent(email),
-      anonymizedUrl: '/account/self/emails/*',
-    });
-  }
-
-  deleteAccountEmail(email: string): Promise<Response> {
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
-      url: '/accounts/self/emails/' + encodeURIComponent(email),
-      anonymizedUrl: '/accounts/self/email/*',
-    });
-  }
-
-  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`,
-      anonymizedUrl: '/accounts/self/emails/*/preferred',
-    };
-    return this._restApiHelper.send(req).then(() => {
-      // If result of getAccountEmails is in cache, update it in the cache
-      // so we don't have to invalidate it.
-      const cachedEmails = this._cache.get('/accounts/self/emails');
-      if (cachedEmails) {
-        const emails = cachedEmails.map(entry => {
-          if (entry.email === email) {
-            return {email, preferred: true};
-          } else {
-            return {email};
-          }
-        });
-        this._cache.set('/accounts/self/emails', emails);
-      }
-    });
-  }
-
-  _updateCachedAccount(obj: Partial<AccountDetailInfo>): void {
-    // If result of getAccount is in cache, update it in the cache
-    // so we don't have to invalidate it.
-    const cachedAccount = this._cache.get('/accounts/self/detail');
-    if (cachedAccount) {
-      // Replace object in cache with new object to force UI updates.
-      this._cache.set('/accounts/self/detail', {...cachedAccount, ...obj});
-    }
-  }
-
-  setAccountName(name: string): Promise<void> {
-    // TODO(TS): add correct error handling
-    const req: SendJSONRequest = {
-      method: HttpMethod.PUT,
-      url: '/accounts/self/name',
-      body: {name},
-      parseResponse: true,
-      reportUrlAsIs: true,
-    };
-    return this._restApiHelper
-      .send(req)
-      .then(newName =>
-        this._updateCachedAccount({name: newName as unknown as string})
-      );
-  }
-
-  setAccountUsername(username: string): Promise<void> {
-    // TODO(TS): add correct error handling
-    const req: SendJSONRequest = {
-      method: HttpMethod.PUT,
-      url: '/accounts/self/username',
-      body: {username},
-      parseResponse: true,
-      reportUrlAsIs: true,
-    };
-    return this._restApiHelper
-      .send(req)
-      .then(newName =>
-        this._updateCachedAccount({username: newName as unknown as string})
-      );
-  }
-
-  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},
-      parseResponse: true,
-      reportUrlAsIs: true,
-    };
-    return this._restApiHelper.send(req).then(newName =>
-      this._updateCachedAccount({
-        display_name: newName as unknown as string,
-      })
-    );
-  }
-
-  setAccountStatus(status: string): Promise<void> {
-    // TODO(TS): add correct error handling
-    const req: SendJSONRequest = {
-      method: HttpMethod.PUT,
-      url: '/accounts/self/status',
-      body: {status},
-      parseResponse: true,
-      reportUrlAsIs: true,
-    };
-    return this._restApiHelper
-      .send(req)
-      .then(newStatus =>
-        this._updateCachedAccount({status: newStatus as unknown as string})
-      );
-  }
-
-  getAccountStatus(userId: AccountId) {
-    return this._restApiHelper.fetchJSON({
-      url: `/accounts/${encodeURIComponent(userId)}/status`,
-      anonymizedUrl: '/accounts/*/status',
-    }) as Promise<string | undefined>;
-  }
-
-  // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#list-groups
-  getAccountGroups() {
-    return this._restApiHelper.fetchJSON({
-      url: '/accounts/self/groups',
-      reportUrlAsIs: true,
-    }) as Promise<GroupInfo[] | undefined>;
-  }
-
-  getAccountAgreements() {
-    return this._restApiHelper.fetchJSON({
-      url: '/accounts/self/agreements',
-      reportUrlAsIs: true,
-    }) as Promise<ContributorAgreementInfo[] | undefined>;
-  }
-
-  saveAccountAgreement(name: ContributorAgreementInput): Promise<Response> {
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: '/accounts/self/agreements',
-      body: name,
-      reportUrlAsIs: true,
-    });
-  }
-
-  getAccountCapabilities(
-    params?: string[]
-  ): Promise<AccountCapabilityInfo | undefined> {
-    let queryString = '';
-    if (params) {
-      queryString =
-        '?q=' + params.map(param => encodeURIComponent(param)).join('&q=');
-    }
-    return this._fetchSharedCacheURL({
-      url: '/accounts/self/capabilities' + queryString,
-      anonymizedUrl: '/accounts/self/capabilities?q=*',
-    }) as Promise<AccountCapabilityInfo | undefined>;
-  }
-
-  getLoggedIn() {
-    return this.authService.authCheck();
-  }
-
-  getIsAdmin() {
-    return this.getLoggedIn()
-      .then(isLoggedIn => {
-        if (isLoggedIn) {
-          return this.getAccountCapabilities();
-        } else {
-          return;
-        }
-      })
-      .then(
-        (capabilities: AccountCapabilityInfo | undefined) =>
-          capabilities && capabilities.administrateServer
-      );
-  }
-
-  getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
-    return this._fetchSharedCacheURL({
-      url: '/config/server/preferences',
-      reportUrlAsIs: true,
-    }) as Promise<PreferencesInfo | undefined>;
-  }
-
-  getPreferences(): Promise<PreferencesInfo | undefined> {
-    return this.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
-        return this._fetchSharedCacheURL(req).then(res => {
-          if (!res) {
-            return res;
-          }
-          const prefInfo = res as unknown as PreferencesInfo;
-          return prefInfo;
-        });
-      }
-      return createDefaultPreferences();
-    });
-  }
-
-  getWatchedProjects() {
-    return this._fetchSharedCacheURL({
-      url: '/accounts/self/watched.projects',
-      reportUrlAsIs: true,
-    }) as unknown as Promise<ProjectWatchInfo[] | undefined>;
-  }
-
-  saveWatchedProjects(
-    projects: ProjectWatchInfo[]
-  ): Promise<ProjectWatchInfo[]> {
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
-      url: '/accounts/self/watched.projects',
-      body: projects,
-      parseResponse: true,
-      reportUrlAsIs: true,
-    }) as unknown as Promise<ProjectWatchInfo[]>;
-  }
-
-  deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response> {
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
-      url: '/accounts/self/watched.projects:delete',
-      body: projects,
-      reportUrlAsIs: true,
-    });
-  }
-
-  getChanges(
-    changesPerPage?: number,
-    query?: string,
-    offset?: 'n,z' | number,
-    options?: string
-  ): Promise<ChangeInfo[] | undefined>;
-
-  getChanges(
-    changesPerPage?: number,
-    query?: string[],
-    offset?: 'n,z' | number,
-    options?: string
-  ): Promise<ChangeInfo[][] | undefined>;
-
-  /**
-   * @return If opt_query is an
-   * array, _fetchJSON will return an array of arrays of changeInfos. If it
-   * is unspecified or a string, _fetchJSON will return an array of
-   * changeInfos.
-   */
-  getChanges(
-    changesPerPage?: number,
-    query?: string | string[],
-    offset?: 'n,z' | number,
-    options?: string
-  ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined> {
-    options = options || this._getChangesOptionsHex();
-    // Issue 4524: respect legacy token with max sortkey.
-    if (offset === 'n,z') {
-      offset = 0;
-    }
-    const params: QueryChangesParams = {
-      O: options,
-      S: offset || 0,
-    };
-    if (changesPerPage) {
-      params.n = changesPerPage;
-    }
-    if (query && query.length > 0) {
-      params.q = query;
-    }
-    const request = {
-      url: '/changes/',
-      params,
-      reportUrlAsIs: true,
-    };
-
-    return Promise.resolve(
-      this._restApiHelper.fetchJSON(request, true) as Promise<
-        ChangeInfo[] | ChangeInfo[][] | undefined
-      >
-    ).then(response => {
-      if (!response) {
-        return;
-      }
-      const iterateOverChanges = (arr: ChangeInfo[]) => {
-        for (const change of arr) {
-          this._maybeInsertInLookup(change);
-        }
-      };
-      // Response may be an array of changes OR an array of arrays of
-      // changes.
-      if (query instanceof Array) {
-        // Normalize the response to look like a multi-query response
-        // when there is only one query.
-        const responseArray: Array<ChangeInfo[]> =
-          query.length === 1
-            ? [response as ChangeInfo[]]
-            : (response as ChangeInfo[][]);
-        for (const arr of responseArray) {
-          iterateOverChanges(arr);
-        }
-        return responseArray;
-      } else {
-        iterateOverChanges(response as ChangeInfo[]);
-        return response as ChangeInfo[];
-      }
-    });
-  }
-
-  /**
-   * Inserts a change into _projectLookup iff it has a valid structure.
-   */
-  _maybeInsertInLookup(change: ChangeInfo): void {
-    if (change?.project && change._number) {
-      this.setInProjectLookup(change._number, change.project);
-    }
-  }
-
-  getChangeActionURL(
-    changeNum: NumericChangeId,
-    revisionId: RevisionId | undefined,
-    endpoint: string
-  ): Promise<string> {
-    return this._changeBaseURL(changeNum, revisionId).then(
-      url => url + endpoint
-    );
-  }
-
-  getChangeDetail(
-    changeNum: NumericChangeId,
-    errFn?: ErrorCallback,
-    cancelCondition?: CancelConditionCallback
-  ): Promise<ParsedChangeInfo | null | undefined> {
-    return this.getConfig(false).then(config => {
-      const optionsHex = this._getChangeOptionsHex(config);
-      return this._getChangeDetail(
-        changeNum,
-        optionsHex,
-        errFn,
-        cancelCondition
-      ).then(detail =>
-        // detail has ChangeViewChangeInfo type because the optionsHex always
-        // includes ALL_REVISIONS flag.
-        GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo)
-      );
-    });
-  }
-
-  _getChangesOptionsHex() {
-    if (
-      window.DEFAULT_DETAIL_HEXES &&
-      window.DEFAULT_DETAIL_HEXES.dashboardPage &&
-      !this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)
-    ) {
-      return window.DEFAULT_DETAIL_HEXES.dashboardPage;
-    }
-    const options = [
-      ListChangesOption.LABELS,
-      ListChangesOption.DETAILED_ACCOUNTS,
-    ];
-    if (this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      options.push(ListChangesOption.SUBMIT_REQUIREMENTS);
-    }
-
-    return listChangesOptionsToHex(...options);
-  }
-
-  _getChangeOptionsHex(config?: ServerInfo) {
-    if (
-      window.DEFAULT_DETAIL_HEXES &&
-      window.DEFAULT_DETAIL_HEXES.changePage &&
-      (!config || !(config.receive && config.receive.enable_signed_push)) &&
-      !this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)
-    ) {
-      return window.DEFAULT_DETAIL_HEXES.changePage;
-    }
-
-    // This list MUST be kept in sync with
-    // ChangeIT#changeDetailsDoesNotRequireIndex
-    const options = [
-      ListChangesOption.ALL_COMMITS,
-      ListChangesOption.ALL_REVISIONS,
-      ListChangesOption.CHANGE_ACTIONS,
-      ListChangesOption.DETAILED_LABELS,
-      ListChangesOption.DOWNLOAD_COMMANDS,
-      ListChangesOption.MESSAGES,
-      ListChangesOption.SUBMITTABLE,
-      ListChangesOption.WEB_LINKS,
-      ListChangesOption.SKIP_DIFFSTAT,
-    ];
-    if (config?.receive?.enable_signed_push) {
-      options.push(ListChangesOption.PUSH_CERTIFICATES);
-    }
-    if (this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      options.push(ListChangesOption.SUBMIT_REQUIREMENTS);
-    }
-    return listChangesOptionsToHex(...options);
-  }
-
-  getDiffChangeDetail(changeNum: NumericChangeId) {
-    let optionsHex = '';
-    if (window.DEFAULT_DETAIL_HEXES?.diffPage) {
-      optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
-    } else {
-      optionsHex = listChangesOptionsToHex(
-        ListChangesOption.ALL_COMMITS,
-        ListChangesOption.ALL_REVISIONS,
-        ListChangesOption.SKIP_DIFFSTAT
-      );
-    }
-    return this._getChangeDetail(changeNum, optionsHex);
-  }
-
-  /**
-   * @param optionsHex list changes options in hex
-   */
-  _getChangeDetail(
-    changeNum: NumericChangeId,
-    optionsHex: string,
-    errFn?: ErrorCallback,
-    cancelCondition?: CancelConditionCallback
-  ): Promise<ChangeInfo | undefined | null> {
-    return this.getChangeActionURL(changeNum, undefined, '/detail').then(
-      url => {
-        const params: FetchParams = {O: optionsHex};
-        const urlWithParams = this._restApiHelper.urlWithParams(url, params);
-        const req: FetchJSONRequest = {
-          url,
-          errFn,
-          cancelCondition,
-          params,
-          fetchOptions: this._etags.getOptions(urlWithParams),
-          anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
-        };
-        return this._restApiHelper.fetchRawJSON(req).then(response => {
-          if (response?.status === 304) {
-            return parsePrefixedJSON(
-              // urlWithParams already cached
-              this._etags.getCachedPayload(urlWithParams)!
-            ) as unknown as ChangeInfo;
-          }
-
-          if (response && !response.ok) {
-            if (errFn) {
-              errFn.call(null, response);
-            } else {
-              fireServerError(response, req);
-            }
-            return undefined;
-          }
-
-          if (!response) {
-            return Promise.resolve(null);
-          }
-
-          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;
-          });
-        });
-      }
-    );
-  }
-
-  getChangeCommitInfo(changeNum: NumericChangeId, patchNum: PatchSetNum) {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/commit?links',
-      revision: patchNum,
-      reportEndpointAsIs: true,
-    }) as Promise<CommitInfo | undefined>;
-  }
-
-  getChangeFiles(
-    changeNum: NumericChangeId,
-    patchRange: PatchRange
-  ): Promise<FileNameToFileInfoMap | undefined> {
-    let params = undefined;
-    if (isMergeParent(patchRange.basePatchNum)) {
-      params = {parent: getParentIndex(patchRange.basePatchNum)};
-    } else if (patchRange.basePatchNum !== ParentPatchSetNum) {
-      params = {base: patchRange.basePatchNum};
-    }
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/files',
-      revision: patchRange.patchNum,
-      params,
-      reportEndpointAsIs: true,
-    }) as Promise<FileNameToFileInfoMap | undefined>;
-  }
-
-  // TODO(TS): The output type is unclear
-  getChangeEditFiles(
-    changeNum: NumericChangeId,
-    patchRange: PatchRange
-  ): Promise<{files: FileNameToFileInfoMap} | undefined> {
-    let endpoint = '/edit?list';
-    let anonymizedEndpoint = endpoint;
-    if (patchRange.basePatchNum !== ParentPatchSetNum) {
-      endpoint += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`);
-      anonymizedEndpoint += '&base=*';
-    }
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint,
-      anonymizedEndpoint,
-    }) as Promise<{files: FileNameToFileInfoMap} | undefined>;
-  }
-
-  queryChangeFiles(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    query: string
-  ) {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: `/files?q=${encodeURIComponent(query)}`,
-      revision: patchNum,
-      anonymizedEndpoint: '/files?q=*',
-    }) as Promise<string[] | undefined>;
-  }
-
-  getChangeOrEditFiles(
-    changeNum: NumericChangeId,
-    patchRange: PatchRange
-  ): Promise<FileNameToFileInfoMap | undefined> {
-    if (patchRange.patchNum === EditPatchSetNum) {
-      return this.getChangeEditFiles(changeNum, patchRange).then(
-        res => res && res.files
-      );
-    }
-    return this.getChangeFiles(changeNum, patchRange);
-  }
-
-  getChangeRevisionActions(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum
-  ): Promise<ActionNameToActionInfoMap | undefined> {
-    const req: FetchChangeJSON = {
-      changeNum,
-      endpoint: '/actions',
-      revision: patchNum,
-      reportEndpointAsIs: true,
-    };
-    return this._getChangeURLAndFetch(req) as Promise<
-      ActionNameToActionInfoMap | undefined
-    >;
-  }
-
-  getChangeSuggestedReviewers(changeNum: NumericChangeId, inputVal: string) {
-    return this._getChangeSuggestedGroup(
-      ReviewerState.REVIEWER,
-      changeNum,
-      inputVal
-    );
-  }
-
-  getChangeSuggestedCCs(changeNum: NumericChangeId, inputVal: string) {
-    return this._getChangeSuggestedGroup(ReviewerState.CC, changeNum, inputVal);
-  }
-
-  _getChangeSuggestedGroup(
-    reviewerState: ReviewerState,
-    changeNum: NumericChangeId,
-    inputVal: string
-  ): Promise<SuggestedReviewerInfo[] | undefined> {
-    // More suggestions may obscure content underneath in the reply dialog,
-    // see issue 10793.
-    const params: QuerySuggestedReviewersParams = {
-      n: 6,
-      'reviewer-state': reviewerState,
-    };
-    if (inputVal) {
-      params.q = inputVal;
-    }
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/suggest_reviewers',
-      params,
-      reportEndpointAsIs: true,
-    }) as Promise<SuggestedReviewerInfo[] | undefined>;
-  }
-
-  getChangeIncludedIn(
-    changeNum: NumericChangeId
-  ): Promise<IncludedInInfo | undefined> {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/in',
-      reportEndpointAsIs: true,
-    }) as Promise<IncludedInInfo | undefined>;
-  }
-
-  _computeFilter(filter: string) {
-    if (filter?.startsWith('^')) {
-      filter = '&r=' + encodeURIComponent(filter);
-    } else if (filter) {
-      filter = '&m=' + encodeURIComponent(filter);
-    } else {
-      filter = '';
-    }
-    return filter;
-  }
-
-  _getGroupsUrl(filter: string, groupsPerPage: number, offset?: number) {
-    offset = offset || 0;
-
-    return (
-      `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
-      this._computeFilter(filter)
-    );
-  }
-
-  _getReposUrl(
-    filter: string | undefined,
-    reposPerPage: number,
-    offset?: number
-  ) {
-    const defaultFilter = 'state:active OR state:read-only';
-    const namePartDelimiters = /[@.\-\s/_]/g;
-    offset = offset || 0;
-
-    if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
-      // The query language specifies hyphens as operators. Split the string
-      // by hyphens and 'AND' the parts together as 'inname:' queries.
-      // If the filter includes a semicolon, the user is using a more complex
-      // query so we trust them and don't do any magic under the hood.
-      const originalFilter = filter;
-      filter = '';
-      originalFilter.split(namePartDelimiters).forEach(part => {
-        if (part) {
-          filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
-        }
-      });
-    }
-    // Check if filter is now empty which could be either because the user did
-    // not provide it or because the user provided only a split character.
-    if (!filter) {
-      filter = defaultFilter;
-    }
-
-    filter = filter.trim();
-    const encodedFilter = encodeURIComponent(filter);
-
-    return (
-      `/projects/?n=${reposPerPage + 1}&S=${offset}` + `&query=${encodedFilter}`
-    );
-  }
-
-  invalidateGroupsCache() {
-    this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
-  }
-
-  invalidateReposCache() {
-    this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
-  }
-
-  invalidateAccountsCache() {
-    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
-  }
-
-  invalidateAccountsDetailCache() {
-    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
-  }
-
-  getGroups(filter: string, groupsPerPage: number, offset?: number) {
-    const url = this._getGroupsUrl(filter, groupsPerPage, offset);
-
-    return this._fetchSharedCacheURL({
-      url,
-      anonymizedUrl: '/groups/?*',
-    }) as Promise<GroupNameToGroupInfoMap | undefined>;
-  }
-
-  getRepos(
-    filter: string | undefined,
-    reposPerPage: number,
-    offset?: number
-  ): Promise<ProjectInfoWithName[] | undefined> {
-    const url = this._getReposUrl(filter, reposPerPage, offset);
-
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
-      url, // The url contains query,so the response is an array, not map
-      anonymizedUrl: '/projects/?*',
-    }) as Promise<ProjectInfoWithName[] | undefined>;
-  }
-
-  setRepoHead(repo: RepoName, ref: GitRef) {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/projects/${encodeURIComponent(repo)}/HEAD`,
-      body: {ref},
-      anonymizedUrl: '/projects/*/HEAD',
-    });
-  }
-
-  getRepoBranches(
-    filter: string,
-    repo: RepoName,
-    reposBranchesPerPage: number,
-    offset?: number,
-    errFn?: ErrorCallback
-  ): Promise<BranchInfo[] | undefined> {
-    offset = offset || 0;
-    const count = reposBranchesPerPage + 1;
-    filter = this._computeFilter(filter);
-    const encodedRepo = encodeURIComponent(repo);
-    const url = `/projects/${encodedRepo}/branches?n=${count}&S=${offset}${filter}`;
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._restApiHelper.fetchJSON({
-      url,
-      errFn,
-      anonymizedUrl: '/projects/*/branches?*',
-    }) as Promise<BranchInfo[] | undefined>;
-  }
-
-  getRepoTags(
-    filter: string,
-    repo: RepoName,
-    reposTagsPerPage: number,
-    offset?: number,
-    errFn?: ErrorCallback
-  ) {
-    offset = offset || 0;
-    const encodedRepo = encodeURIComponent(repo);
-    const n = reposTagsPerPage + 1;
-    const encodedFilter = this._computeFilter(filter);
-    const url =
-      `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + encodedFilter;
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._restApiHelper.fetchJSON({
-      url,
-      errFn,
-      anonymizedUrl: '/projects/*/tags',
-    }) as unknown as Promise<TagInfo[]>;
-  }
-
-  getPlugins(
-    filter: string,
-    pluginsPerPage: number,
-    offset?: number,
-    errFn?: ErrorCallback
-  ): Promise<{[pluginName: string]: PluginInfo} | undefined> {
-    offset = offset || 0;
-    const encodedFilter = this._computeFilter(filter);
-    const n = pluginsPerPage + 1;
-    const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
-    return this._restApiHelper.fetchJSON({
-      url,
-      errFn,
-      anonymizedUrl: '/plugins/?all',
-    });
-  }
-
-  getRepoAccessRights(
-    repoName: RepoName,
-    errFn?: ErrorCallback
-  ): Promise<ProjectAccessInfo | undefined> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._restApiHelper.fetchJSON({
-      url: `/projects/${encodeURIComponent(repoName)}/access`,
-      errFn,
-      anonymizedUrl: '/projects/*/access',
-    }) as Promise<ProjectAccessInfo | undefined>;
-  }
-
-  setRepoAccessRights(
-    repoName: RepoName,
-    repoInfo: ProjectAccessInput
-  ): Promise<Response> {
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
-      url: `/projects/${encodeURIComponent(repoName)}/access`,
-      body: repoInfo,
-      anonymizedUrl: '/projects/*/access',
-    });
-  }
-
-  setRepoAccessRightsForReview(
-    projectName: RepoName,
-    projectInfo: ProjectAccessInput
-  ): Promise<ChangeInfo> {
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: `/projects/${encodeURIComponent(projectName)}/access:review`,
-      body: projectInfo,
-      parseResponse: true,
-      anonymizedUrl: '/projects/*/access:review',
-    }) as unknown as Promise<ChangeInfo>;
-  }
-
-  getSuggestedGroups(
-    inputVal: string,
-    n?: number
-  ): Promise<GroupNameToGroupInfoMap | undefined> {
-    const params: QueryGroupsParams = {s: inputVal};
-    if (n) {
-      params.n = n;
-    }
-    return this._restApiHelper.fetchJSON({
-      url: '/groups/',
-      params,
-      reportUrlAsIs: true,
-    }) as Promise<GroupNameToGroupInfoMap | undefined>;
-  }
-
-  getSuggestedProjects(
-    inputVal: string,
-    n?: number
-  ): Promise<NameToProjectInfoMap | undefined> {
-    const params = {
-      m: inputVal,
-      n: MAX_PROJECT_RESULTS,
-      type: 'ALL',
-    };
-    if (n) {
-      params.n = n;
-    }
-    return this._restApiHelper.fetchJSON({
-      url: '/projects/',
-      params,
-      reportUrlAsIs: true,
-    });
-  }
-
-  getSuggestedAccounts(
-    inputVal: string,
-    n?: number
-  ): Promise<AccountInfo[] | undefined> {
-    if (!inputVal) {
-      return Promise.resolve([]);
-    }
-    const params: QueryAccountsParams = {suggest: null, q: inputVal};
-    if (n) {
-      params.n = n;
-    }
-    return this._restApiHelper.fetchJSON({
-      url: '/accounts/',
-      params,
-      anonymizedUrl: '/accounts/?n=*',
-    }) as Promise<AccountInfo[] | undefined>;
-  }
-
-  addChangeReviewer(
-    changeNum: NumericChangeId,
-    reviewerID: AccountId | EmailAddress | GroupId
-  ) {
-    return this._sendChangeReviewerRequest(
-      HttpMethod.POST,
-      changeNum,
-      reviewerID
-    );
-  }
-
-  removeChangeReviewer(
-    changeNum: NumericChangeId,
-    reviewerID: AccountId | EmailAddress | GroupId
-  ) {
-    return this._sendChangeReviewerRequest(
-      HttpMethod.DELETE,
-      changeNum,
-      reviewerID
-    );
-  }
-
-  _sendChangeReviewerRequest(
-    method: HttpMethod.POST | HttpMethod.DELETE,
-    changeNum: NumericChangeId,
-    reviewerID: AccountId | EmailAddress | GroupId
-  ) {
-    return this.getChangeActionURL(changeNum, undefined, '/reviewers').then(
-      url => {
-        let body;
-        switch (method) {
-          case HttpMethod.POST:
-            body = {reviewer: reviewerID};
-            break;
-          case HttpMethod.DELETE:
-            url += '/' + encodeURIComponent(reviewerID);
-            break;
-          default:
-            assertNever(method, `Unsupported HTTP method: ${method}`);
-        }
-
-        return this._restApiHelper.send({method, url, body});
-      }
-    );
-  }
-
-  getRelatedChanges(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum
-  ): Promise<RelatedChangesInfo | undefined> {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/related',
-      revision: patchNum,
-      reportEndpointAsIs: true,
-    }) as Promise<RelatedChangesInfo | undefined>;
-  }
-
-  getChangesSubmittedTogether(
-    changeNum: NumericChangeId
-  ): Promise<SubmittedTogetherInfo | undefined> {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
-      reportEndpointAsIs: true,
-    }) as Promise<SubmittedTogetherInfo | undefined>;
-  }
-
-  getChangeConflicts(
-    changeNum: NumericChangeId
-  ): Promise<ChangeInfo[] | undefined> {
-    const options = listChangesOptionsToHex(
-      ListChangesOption.CURRENT_REVISION,
-      ListChangesOption.CURRENT_COMMIT
-    );
-    const params = {
-      O: options,
-      q: `status:open conflicts:${changeNum}`,
-    };
-    return this._restApiHelper.fetchJSON({
-      url: '/changes/',
-      params,
-      anonymizedUrl: '/changes/conflicts:*',
-    }) as Promise<ChangeInfo[] | undefined>;
-  }
-
-  getChangeCherryPicks(
-    project: RepoName,
-    changeID: ChangeId,
-    changeNum: NumericChangeId
-  ): Promise<ChangeInfo[] | undefined> {
-    const options = listChangesOptionsToHex(
-      ListChangesOption.CURRENT_REVISION,
-      ListChangesOption.CURRENT_COMMIT
-    );
-    const query = [
-      `project:${project}`,
-      `change:${changeID}`,
-      `-change:${changeNum}`,
-      '-is:abandoned',
-    ].join(' ');
-    const params = {
-      O: options,
-      q: query,
-    };
-    return this._restApiHelper.fetchJSON({
-      url: '/changes/',
-      params,
-      anonymizedUrl: '/changes/change:*',
-    }) as Promise<ChangeInfo[] | undefined>;
-  }
-
-  getChangesWithSameTopic(
-    topic: string,
-    changeNum: NumericChangeId
-  ): Promise<ChangeInfo[] | undefined> {
-    const options = listChangesOptionsToHex(
-      ListChangesOption.LABELS,
-      ListChangesOption.CURRENT_REVISION,
-      ListChangesOption.CURRENT_COMMIT,
-      ListChangesOption.DETAILED_LABELS
-    );
-    const query = [
-      'status:open',
-      `-change:${changeNum}`,
-      `topic:"${topic}"`,
-    ].join(' ');
-    const params = {
-      O: options,
-      q: query,
-    };
-    return this._restApiHelper.fetchJSON({
-      url: '/changes/',
-      params,
-      anonymizedUrl: '/changes/topic:*',
-    }) 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
-  ): Promise<string[] | undefined> {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/files?reviewed',
-      revision: patchNum,
-      reportEndpointAsIs: true,
-    }) as Promise<string[] | undefined>;
-  }
-
-  saveFileReviewed(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    path: string,
-    reviewed: boolean
-  ): Promise<Response> {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE,
-      patchNum,
-      endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
-      anonymizedEndpoint: '/files/*/reviewed',
-    });
-  }
-
-  saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    review: ReviewInput
-  ): Promise<Response>;
-
-  saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    review: ReviewInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    review: ReviewInput,
-    errFn?: ErrorCallback
-  ) {
-    const promises: [Promise<void>, Promise<string>] = [
-      this.awaitPendingDiffDrafts(),
-      this.getChangeActionURL(changeNum, patchNum, '/review'),
-    ];
-    return Promise.all(promises).then(([, url]) =>
-      this._restApiHelper.send({
-        method: HttpMethod.POST,
-        url,
-        body: review,
-        errFn,
-      })
-    );
-  }
-
-  getChangeEdit(
-    changeNum: NumericChangeId,
-    downloadCommands?: boolean
-  ): Promise<false | EditInfo | undefined> {
-    const params = downloadCommands ? {'download-commands': true} : undefined;
-    return this.getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        return Promise.resolve(false);
-      }
-      return this._getChangeURLAndFetch(
-        {
-          changeNum,
-          endpoint: '/edit/',
-          params,
-          reportEndpointAsIs: true,
-        },
-        true
-      ) as Promise<EditInfo | false | undefined>;
-    });
-  }
-
-  createChange(
-    project: RepoName,
-    branch: BranchName,
-    subject: string,
-    topic?: string,
-    isPrivate?: boolean,
-    workInProgress?: boolean,
-    baseChange?: ChangeId,
-    baseCommit?: string
-  ) {
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
-      url: '/changes/',
-      body: {
-        project,
-        branch,
-        subject,
-        topic,
-        is_private: isPrivate,
-        work_in_progress: workInProgress,
-        base_change: baseChange,
-        base_commit: baseCommit,
-      },
-      parseResponse: true,
-      reportUrlAsIs: true,
-    }) as unknown as Promise<ChangeInfo | undefined>;
-  }
-
-  getFileContent(
-    changeNum: NumericChangeId,
-    path: string,
-    patchNum: PatchSetNum
-  ): Promise<Response | Base64FileContent | undefined> {
-    // 404s indicate the file does not exist yet in the revision, so suppress
-    // them.
-    const suppress404s: ErrorCallback = res => {
-      if (res && res?.status !== 404) {
-        fireServerError(res);
-      }
-      return res;
-    };
-    const promise =
-      patchNum === EditPatchSetNum
-        ? this._getFileInChangeEdit(changeNum, path)
-        : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
-
-    return promise.then(res => {
-      if (!res || !res.ok) {
-        return res;
-      }
-
-      // The file type (used for syntax highlighting) is identified in the
-      // X-FYI-Content-Type header of the response.
-      const type = res.headers.get('X-FYI-Content-Type');
-      return this.getResponseObject(res).then(content => {
-        const strContent = content as unknown as string | null;
-        return {content: strContent, type, ok: true};
-      });
-    });
-  }
-
-  /**
-   * Gets a file in a specific change and revision.
-   */
-  _getFileInRevision(
-    changeNum: NumericChangeId,
-    path: string,
-    patchNum: PatchSetNum,
-    errFn?: ErrorCallback
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.GET,
-      patchNum,
-      endpoint: `/files/${encodeURIComponent(path)}/content`,
-      errFn,
-      headers: {Accept: 'application/json'},
-      anonymizedEndpoint: '/files/*/content',
-    });
-  }
-
-  /**
-   * Gets a file in a change edit.
-   */
-  _getFileInChangeEdit(changeNum: NumericChangeId, path: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.GET,
-      endpoint: '/edit/' + encodeURIComponent(path),
-      headers: {Accept: 'application/json'},
-      anonymizedEndpoint: '/edit/*',
-    });
-  }
-
-  rebaseChangeEdit(changeNum: NumericChangeId) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/edit:rebase',
-      reportEndpointAsIs: true,
-    });
-  }
-
-  deleteChangeEdit(changeNum: NumericChangeId) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: '/edit',
-      reportEndpointAsIs: true,
-    });
-  }
-
-  restoreFileInChangeEdit(changeNum: NumericChangeId, restore_path: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/edit',
-      body: {restore_path},
-      reportEndpointAsIs: true,
-    });
-  }
-
-  renameFileInChangeEdit(
-    changeNum: NumericChangeId,
-    old_path: string,
-    new_path: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/edit',
-      body: {old_path, new_path},
-      reportEndpointAsIs: true,
-    });
-  }
-
-  deleteFileInChangeEdit(changeNum: NumericChangeId, path: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: '/edit/' + encodeURIComponent(path),
-      anonymizedEndpoint: '/edit/*',
-    });
-  }
-
-  saveChangeEdit(changeNum: NumericChangeId, path: string, contents: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/edit/' + encodeURIComponent(path),
-      body: contents,
-      contentType: 'text/plain',
-      anonymizedEndpoint: '/edit/*',
-    });
-  }
-
-  saveFileUploadChangeEdit(
-    changeNum: NumericChangeId,
-    path: string,
-    content: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/edit/' + encodeURIComponent(path),
-      body: {binary_content: content},
-      anonymizedEndpoint: '/edit/*',
-    });
-  }
-
-  getRobotCommentFixPreview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    fixId: FixId
-  ): Promise<FilePathToDiffInfoMap | undefined> {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      revision: patchNum,
-      endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
-      reportEndpointAsId: true,
-    }) as Promise<FilePathToDiffInfoMap | undefined>;
-  }
-
-  applyFixSuggestion(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    fixId: string
-  ): Promise<Response> {
-    return this._getChangeURLAndSend({
-      method: HttpMethod.POST,
-      changeNum,
-      patchNum,
-      endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
-      reportEndpointAsId: true,
-    });
-  }
-
-  // Deprecated, prefer to use putChangeCommitMessage instead.
-  saveChangeCommitMessageEdit(changeNum: NumericChangeId, message: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/edit:message',
-      body: {message},
-      reportEndpointAsIs: true,
-    });
-  }
-
-  publishChangeEdit(changeNum: NumericChangeId) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/edit:publish',
-      reportEndpointAsIs: true,
-    });
-  }
-
-  putChangeCommitMessage(changeNum: NumericChangeId, message: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/message',
-      body: {message},
-      reportEndpointAsIs: true,
-    });
-  }
-
-  deleteChangeCommitMessage(
-    changeNum: NumericChangeId,
-    messageId: ChangeMessageId
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: `/messages/${messageId}`,
-      reportEndpointAsIs: true,
-    });
-  }
-
-  saveChangeStarred(
-    changeNum: NumericChangeId,
-    starred: boolean
-  ): Promise<Response> {
-    // Some servers may require the project name to be provided
-    // alongside the change number, so resolve the project name
-    // first.
-    return this.getFromProjectLookup(changeNum).then(project => {
-      const encodedRepoName = project ? encodeURIComponent(project) + '~' : '';
-      const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`;
-      return this._restApiHelper.send({
-        method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
-        url,
-        anonymizedUrl: '/accounts/self/starred.changes/*',
-      });
-    });
-  }
-
-  send(
-    method: HttpMethod,
-    url: string,
-    body?: RequestPayload,
-    errFn?: undefined,
-    contentType?: string,
-    headers?: Record<string, string>
-  ): Promise<Response>;
-
-  send(
-    method: HttpMethod,
-    url: string,
-    body: RequestPayload | undefined,
-    errFn: ErrorCallback,
-    contentType?: string,
-    headers?: Record<string, string>
-  ): Promise<Response | undefined>;
-
-  /**
-   * Public version of the _restApiHelper.send method preserved for plugins.
-   *
-   * @param body passed as null sometimes
-   * and also apparently a number. TODO (beckysiegel) remove need for
-   * number at least.
-   */
-  send(
-    method: HttpMethod,
-    url: string,
-    body?: RequestPayload,
-    errFn?: ErrorCallback,
-    contentType?: string,
-    headers?: Record<string, string>
-  ): Promise<Response | undefined> {
-    return this._restApiHelper.send({
-      method,
-      url,
-      body,
-      errFn,
-      contentType,
-      headers,
-    });
-  }
-
-  /**
-   * @param basePatchNum Negative values specify merge parent
-   * index.
-   * @param whitespace the ignore-whitespace level for the diff
-   * algorithm.
-   */
-  getDiff(
-    changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum,
-    path: string,
-    whitespace?: IgnoreWhitespaceType,
-    errFn?: ErrorCallback
-  ) {
-    const params: GetDiffParams = {
-      intraline: null,
-      whitespace: whitespace || 'IGNORE_NONE',
-    };
-    if (isMergeParent(basePatchNum)) {
-      params.parent = getParentIndex(basePatchNum);
-    } else if (basePatchNum !== ParentPatchSetNum) {
-      params.base = basePatchNum;
-    }
-    const endpoint = `/files/${encodeURIComponent(path)}/diff`;
-    const req: FetchChangeJSON = {
-      changeNum,
-      endpoint,
-      revision: patchNum,
-      errFn,
-      params,
-      anonymizedEndpoint: '/files/*/diff',
-    };
-
-    // Invalidate the cache if its edit patch to make sure we always get latest.
-    if (patchNum === EditPatchSetNum) {
-      if (!req.fetchOptions) req.fetchOptions = {};
-      if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
-      req.fetchOptions.headers.append('Cache-Control', 'no-cache');
-    }
-
-    return this._getChangeURLAndFetch(req) as Promise<DiffInfo | undefined>;
-  }
-
-  getDiffComments(
-    changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
-
-  getDiffComments(
-    changeNum: NumericChangeId,
-    basePatchNum: BasePatchSetNum,
-    patchNum: PatchSetNum,
-    path: string
-  ): Promise<GetDiffCommentsOutput>;
-
-  getDiffComments(
-    changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ) {
-    if (!basePatchNum && !patchNum && !path) {
-      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
-    );
-  }
-
-  getDiffRobotComments(
-    changeNum: NumericChangeId
-  ): Promise<PathToRobotCommentsInfoMap | undefined>;
-
-  getDiffRobotComments(
-    changeNum: NumericChangeId,
-    basePatchNum: BasePatchSetNum,
-    patchNum: PatchSetNum,
-    path: string
-  ): Promise<GetDiffRobotCommentsOutput>;
-
-  getDiffRobotComments(
-    changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ) {
-    if (!basePatchNum && !patchNum && !path) {
-      return this._getDiffComments(changeNum, '/robotcomments');
-    }
-
-    return this._getDiffComments(
-      changeNum,
-      '/robotcomments',
-      undefined,
-      basePatchNum,
-      patchNum,
-      path
-    );
-  }
-
-  /**
-   * If the user is logged in, fetch the user's draft diff comments. If there
-   * is no logged in user, the request is not made and the promise yields an
-   * empty object.
-   */
-  getDiffDrafts(
-    changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
-
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum: BasePatchSetNum,
-    patchNum: PatchSetNum,
-    path: string
-  ): Promise<GetDiffCommentsOutput>;
-
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ) {
-    return this.getLoggedIn().then(loggedIn => {
-      if (!loggedIn) {
-        return {};
-      }
-      if (!basePatchNum && !patchNum && !path) {
-        return this._getDiffComments(changeNum, '/drafts', {
-          'enable-context': true,
-          'context-padding': 3,
-        });
-      }
-      return this._getDiffComments(
-        changeNum,
-        '/drafts',
-        {
-          'enable-context': true,
-          'context-padding': 3,
-        },
-        basePatchNum,
-        patchNum,
-        path
-      );
-    });
-  }
-
-  _setRange(comments: CommentInfo[], comment: CommentInfo) {
-    if (comment.in_reply_to && !comment.range) {
-      for (let i = 0; i < comments.length; i++) {
-        if (comments[i].id === comment.in_reply_to) {
-          comment.range = comments[i].range;
-          break;
-        }
-      }
-    }
-    return comment;
-  }
-
-  _setRanges(comments?: CommentInfo[]) {
-    comments = comments || [];
-    comments.sort(
-      (a, b) => parseDate(a.updated).valueOf() - parseDate(b.updated).valueOf()
-    );
-    for (const comment of comments) {
-      this._setRange(comments, comment);
-    }
-    return comments;
-  }
-
-  _getDiffComments(
-    changeNum: NumericChangeId,
-    endpoint: '/comments' | '/drafts',
-    params?: FetchParams
-  ): Promise<PathToCommentsInfoMap | undefined>;
-
-  _getDiffComments(
-    changeNum: NumericChangeId,
-    endpoint: '/robotcomments'
-  ): Promise<PathToRobotCommentsInfoMap | undefined>;
-
-  _getDiffComments(
-    changeNum: NumericChangeId,
-    endpoint: '/comments' | '/drafts',
-    params?: FetchParams,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ): Promise<GetDiffCommentsOutput>;
-
-  _getDiffComments(
-    changeNum: NumericChangeId,
-    endpoint: '/robotcomments',
-    params?: FetchParams,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ): Promise<GetDiffRobotCommentsOutput>;
-
-  _getDiffComments(
-    changeNum: NumericChangeId,
-    endpoint: string,
-    params?: FetchParams,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ): Promise<
-    | GetDiffCommentsOutput
-    | GetDiffRobotCommentsOutput
-    | PathToCommentsInfoMap
-    | PathToRobotCommentsInfoMap
-    | undefined
-  > {
-    /**
-     * Fetches the comments for a given patchNum.
-     * Helper function to make promises more legible.
-     */
-    // We don't want to add accept header, since preloading of comments is
-    // working only without accept header.
-    const noAcceptHeader = true;
-    const fetchComments = (patchNum?: PatchSetNum) =>
-      this._getChangeURLAndFetch(
-        {
-          changeNum,
-          endpoint,
-          revision: patchNum,
-          reportEndpointAsIs: true,
-          params,
-        },
-        noAcceptHeader
-      ) as Promise<
-        PathToCommentsInfoMap | PathToRobotCommentsInfoMap | undefined
-      >;
-
-    if (!basePatchNum && !patchNum && !path) {
-      return fetchComments();
-    }
-    function onlyParent(c: CommentInfo) {
-      return c.side === CommentSide.PARENT;
-    }
-    function withoutParent(c: CommentInfo) {
-      return c.side !== CommentSide.PARENT;
-    }
-    function setPath(c: CommentInfo) {
-      c.path = path;
-    }
-
-    const promises = [];
-    let comments: CommentInfo[];
-    let baseComments: CommentInfo[];
-    let fetchPromise;
-    fetchPromise = fetchComments(patchNum).then(response => {
-      comments = (response && path && response[path]) || [];
-      // TODO(kaspern): Implement this on in the backend so this can
-      // be removed.
-      // Sort comments by date so that parent ranges can be propagated
-      // in a single pass.
-      comments = this._setRanges(comments);
-
-      if (basePatchNum === ParentPatchSetNum) {
-        baseComments = comments.filter(onlyParent);
-        baseComments.forEach(setPath);
-      }
-      comments = comments.filter(withoutParent);
-
-      comments.forEach(setPath);
-    });
-    promises.push(fetchPromise);
-
-    if (basePatchNum !== ParentPatchSetNum) {
-      fetchPromise = fetchComments(basePatchNum).then(response => {
-        baseComments = ((response && path && response[path]) || []).filter(
-          withoutParent
-        );
-        baseComments = this._setRanges(baseComments);
-        baseComments.forEach(setPath);
-      });
-      promises.push(fetchPromise);
-    }
-
-    return Promise.all(promises).then(() =>
-      Promise.resolve({
-        baseComments,
-        comments,
-      })
-    );
-  }
-
-  _getDiffCommentsFetchURL(
-    changeNum: NumericChangeId,
-    endpoint: string,
-    patchNum?: RevisionId
-  ) {
-    return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
-  }
-
-  getPortedComments(
-    changeNum: NumericChangeId,
-    revision: RevisionId
-  ): Promise<PathToCommentsInfoMap | undefined> {
-    // maintaining a custom error function so that errors do not surface in UI
-    const errFn: ErrorCallback = (response?: Response | null) => {
-      if (response)
-        console.info(`Fetching ported comments failed, ${response.status}`);
-    };
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/ported_comments/',
-      revision,
-      errFn,
-    });
-  }
-
-  getPortedDrafts(
-    changeNum: NumericChangeId,
-    revision: RevisionId
-  ): Promise<PathToCommentsInfoMap | undefined> {
-    // maintaining a custom error function so that errors do not surface in UI
-    const errFn: ErrorCallback = (response?: Response | null) => {
-      if (response)
-        console.info(`Fetching ported drafts failed, ${response.status}`);
-    };
-    return this.getLoggedIn().then(loggedIn => {
-      if (!loggedIn) return {};
-      return this._getChangeURLAndFetch({
-        changeNum,
-        endpoint: '/ported_drafts/',
-        revision,
-        errFn,
-      });
-    });
-  }
-
-  saveDiffDraft(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    draft: CommentInput
-  ) {
-    return this._sendDiffDraftRequest(
-      HttpMethod.PUT,
-      changeNum,
-      patchNum,
-      draft
-    );
-  }
-
-  deleteDiffDraft(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    draft: {id: UrlEncodedCommentId}
-  ) {
-    return this._sendDiffDraftRequest(
-      HttpMethod.DELETE,
-      changeNum,
-      patchNum,
-      draft
-    );
-  }
-
-  /**
-   * @returns Whether there are pending diff draft sends.
-   */
-  hasPendingDiffDrafts(): number {
-    const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
-    return promises && promises.length;
-  }
-
-  /**
-   * @returns A promise that resolves when all pending
-   * diff draft sends have resolved.
-   */
-  awaitPendingDiffDrafts(): Promise<void> {
-    return Promise.all(
-      this._pendingRequests[Requests.SEND_DIFF_DRAFT] || []
-    ).then(() => {
-      this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
-    });
-  }
-
-  _sendDiffDraftRequest(
-    method: HttpMethod.PUT,
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    draft: CommentInput
-  ): Promise<Response>;
-
-  _sendDiffDraftRequest(
-    method: HttpMethod.GET | HttpMethod.DELETE,
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    draft: {id?: UrlEncodedCommentId}
-  ): Promise<Response>;
-
-  _sendDiffDraftRequest(
-    method: HttpMethod,
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    draft: CommentInput | {id: UrlEncodedCommentId}
-  ): Promise<Response> {
-    const isCreate = !draft.id && method === HttpMethod.PUT;
-    let endpoint = '/drafts';
-    let anonymizedEndpoint = endpoint;
-    if (draft.id) {
-      endpoint += `/${draft.id}`;
-      anonymizedEndpoint += '/*';
-    }
-    let body;
-    if (method === HttpMethod.PUT) {
-      body = draft;
-    }
-
-    if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
-      this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
-    }
-
-    const req = {
-      changeNum,
-      method,
-      patchNum,
-      endpoint,
-      body,
-      anonymizedEndpoint,
-    };
-
-    const promise = this._getChangeURLAndSend(req);
-    this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
-
-    if (isCreate) {
-      return this._failForCreate200(promise);
-    }
-
-    return promise;
-  }
-
-  getCommitInfo(
-    project: RepoName,
-    commit: CommitId
-  ): Promise<CommitInfo | undefined> {
-    return this._restApiHelper.fetchJSON({
-      url:
-        '/projects/' +
-        encodeURIComponent(project) +
-        '/commits/' +
-        encodeURIComponent(commit),
-      anonymizedUrl: '/projects/*/comments/*',
-    }) as Promise<CommitInfo | undefined>;
-  }
-
-  _fetchB64File(url: string): Promise<Base64File> {
-    return this._restApiHelper
-      .fetch({url: getBaseUrl() + url})
-      .then(response => {
-        if (!response.ok) {
-          return Promise.reject(new Error(response.statusText));
-        }
-        const type = response.headers.get('X-FYI-Content-Type');
-        return response.text().then(text => {
-          return {body: text, type};
-        });
-      });
-  }
-
-  getB64FileContents(
-    changeId: NumericChangeId,
-    patchNum: RevisionId,
-    path: string,
-    parentIndex?: number
-  ) {
-    const parent =
-      typeof parentIndex === 'number' ? `?parent=${parentIndex}` : '';
-    return this._changeBaseURL(changeId, patchNum).then(url => {
-      url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
-      return this._fetchB64File(url);
-    });
-  }
-
-  getImagesForDiff(
-    changeNum: NumericChangeId,
-    diff: DiffInfo,
-    patchRange: PatchRange
-  ): Promise<ImagesForDiff> {
-    let promiseA;
-    let promiseB;
-
-    if (diff.meta_a?.content_type.startsWith('image/')) {
-      if (patchRange.basePatchNum === ParentPatchSetNum) {
-        // Note: we only attempt to get the image from the first parent.
-        promiseA = this.getB64FileContents(
-          changeNum,
-          patchRange.patchNum,
-          diff.meta_a.name,
-          1
-        );
-      } else {
-        promiseA = this.getB64FileContents(
-          changeNum,
-          patchRange.basePatchNum,
-          diff.meta_a.name
-        );
-      }
-    } else {
-      promiseA = Promise.resolve(null);
-    }
-
-    if (diff.meta_b?.content_type.startsWith('image/')) {
-      promiseB = this.getB64FileContents(
-        changeNum,
-        patchRange.patchNum,
-        diff.meta_b.name
-      );
-    } else {
-      promiseB = Promise.resolve(null);
-    }
-
-    return Promise.all([promiseA, promiseB]).then(results => {
-      // Sometimes the server doesn't send back the content type.
-      const baseImage: Base64ImageFile | null = results[0]
-        ? {
-            ...results[0],
-            _expectedType: diff.meta_a.content_type,
-            _name: diff.meta_a.name,
-          }
-        : null;
-      const revisionImage: Base64ImageFile | null = results[1]
-        ? {
-            ...results[1],
-            _expectedType: diff.meta_b.content_type,
-            _name: diff.meta_b.name,
-          }
-        : null;
-      const imagesForDiff: ImagesForDiff = {baseImage, revisionImage};
-      return imagesForDiff;
-    });
-  }
-
-  _changeBaseURL(
-    changeNum: NumericChangeId,
-    revisionId?: RevisionId,
-    project?: RepoName
-  ): Promise<string> {
-    // TODO(kaspern): For full slicer migration, app should warn with a call
-    // stack every time _changeBaseURL is called without a project.
-    const projectPromise = project
-      ? Promise.resolve(project)
-      : this.getFromProjectLookup(changeNum);
-    return projectPromise.then(project => {
-      // TODO(TS): unclear why project can't be null here. Fix it
-      let url = `/changes/${encodeURIComponent(
-        project as RepoName
-      )}~${changeNum}`;
-      if (revisionId) {
-        url += `/revisions/${revisionId}`;
-      }
-      return url;
-    });
-  }
-
-  addToAttentionSet(
-    changeNum: NumericChangeId,
-    user: AccountId | undefined | null,
-    reason: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/attention',
-      body: {user, reason},
-      reportUrlAsIs: true,
-    });
-  }
-
-  removeFromAttentionSet(
-    changeNum: NumericChangeId,
-    user: AccountId,
-    reason: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: `/attention/${user}`,
-      anonymizedEndpoint: '/attention/*',
-      body: {reason},
-    });
-  }
-
-  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string> {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/topic',
-      body: {topic},
-      parseResponse: true,
-      reportUrlAsIs: true,
-    }) as unknown as Promise<string>;
-  }
-
-  setChangeHashtag(
-    changeNum: NumericChangeId,
-    hashtag: HashtagsInput
-  ): Promise<Hashtag[]> {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/hashtags',
-      body: hashtag,
-      parseResponse: true,
-      reportUrlAsIs: true,
-    }) as unknown as Promise<Hashtag[]>;
-  }
-
-  deleteAccountHttpPassword() {
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
-      url: '/accounts/self/password.http',
-      reportUrlAsIs: true,
-    });
-  }
-
-  generateAccountHttpPassword(): Promise<Password> {
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: '/accounts/self/password.http',
-      body: {generate: true},
-      parseResponse: true,
-      reportUrlAsIs: true,
-    }) as Promise<unknown> as Promise<Password>;
-  }
-
-  getAccountSSHKeys() {
-    return this._fetchSharedCacheURL({
-      url: '/accounts/self/sshkeys',
-      reportUrlAsIs: true,
-    }) as Promise<unknown> as Promise<SshKeyInfo[] | undefined>;
-  }
-
-  addAccountSSHKey(key: string): Promise<SshKeyInfo> {
-    const req = {
-      method: HttpMethod.POST,
-      url: '/accounts/self/sshkeys',
-      body: key,
-      contentType: 'text/plain',
-      reportUrlAsIs: true,
-    };
-    return this._restApiHelper
-      .send(req)
-      .then((response: Response | undefined) => {
-        if (!response || (response.status < 200 && response.status >= 300)) {
-          return Promise.reject(new Error('error'));
-        }
-        return this.getResponseObject(
-          response
-        ) as unknown as Promise<SshKeyInfo>;
-      })
-      .then(obj => {
-        if (!obj || !obj.valid) {
-          return Promise.reject(new Error('error'));
-        }
-        return obj;
-      });
-  }
-
-  deleteAccountSSHKey(id: string) {
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
-      url: '/accounts/self/sshkeys/' + id,
-      anonymizedUrl: '/accounts/self/sshkeys/*',
-    });
-  }
-
-  getAccountGPGKeys() {
-    return this._restApiHelper.fetchJSON({
-      url: '/accounts/self/gpgkeys',
-      reportUrlAsIs: true,
-    }) as Promise<unknown> as Promise<Record<string, GpgKeyInfo>>;
-  }
-
-  addAccountGPGKey(key: GpgKeysInput) {
-    const req = {
-      method: HttpMethod.POST,
-      url: '/accounts/self/gpgkeys',
-      body: key,
-      reportUrlAsIs: true,
-    };
-    return this._restApiHelper
-      .send(req)
-      .then(response => {
-        if (!response || (response.status < 200 && response.status >= 300)) {
-          return Promise.reject(new Error('error'));
-        }
-        return this.getResponseObject(response);
-      })
-      .then(obj => {
-        if (!obj) {
-          return Promise.reject(new Error('error'));
-        }
-        return obj;
-      });
-  }
-
-  deleteAccountGPGKey(id: GpgKeyId) {
-    return this._restApiHelper.send({
-      method: HttpMethod.DELETE,
-      url: `/accounts/self/gpgkeys/${id}`,
-      anonymizedUrl: '/accounts/self/gpgkeys/*',
-    });
-  }
-
-  deleteVote(changeNum: NumericChangeId, account: AccountId, label: string) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
-      anonymizedEndpoint: '/reviewers/*/votes/*',
-    });
-  }
-
-  setDescription(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    desc: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      patchNum,
-      endpoint: '/description',
-      body: {description: desc},
-      reportUrlAsIs: true,
-    });
-  }
-
-  confirmEmail(token: string): Promise<string | null> {
-    const req = {
-      method: HttpMethod.PUT,
-      url: '/config/server/email.confirm',
-      body: {token},
-      reportUrlAsIs: true,
-    };
-    return this._restApiHelper.send(req).then(response => {
-      if (response?.status === 204) {
-        return 'Email confirmed successfully.';
-      }
-      return null;
-    });
-  }
-
-  getCapabilities(
-    errFn?: ErrorCallback
-  ): Promise<CapabilityInfoMap | undefined> {
-    return this._restApiHelper.fetchJSON({
-      url: '/config/server/capabilities',
-      errFn,
-      reportUrlAsIs: true,
-    }) as Promise<CapabilityInfoMap | undefined>;
-  }
-
-  getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
-    return this._fetchSharedCacheURL({
-      url: '/config/server/top-menus',
-      reportUrlAsIs: true,
-    }) as Promise<TopMenuEntryInfo[] | undefined>;
-  }
-
-  setAssignee(
-    changeNum: NumericChangeId,
-    assignee: AccountId
-  ): Promise<Response> {
-    const body: AssigneeInput = {assignee};
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.PUT,
-      endpoint: '/assignee',
-      body,
-      reportUrlAsIs: true,
-    });
-  }
-
-  deleteAssignee(changeNum: NumericChangeId): Promise<Response> {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.DELETE,
-      endpoint: '/assignee',
-      reportUrlAsIs: true,
-    });
-  }
-
-  probePath(path: string) {
-    return fetch(new Request(path, {method: HttpMethod.HEAD})).then(
-      response => response.ok
-    );
-  }
-
-  startWorkInProgress(
-    changeNum: NumericChangeId,
-    message?: string
-  ): Promise<string | undefined> {
-    const body = message ? {message} : {};
-    const req: SendRawChangeRequest = {
-      changeNum,
-      method: HttpMethod.POST,
-      endpoint: '/wip',
-      body,
-      reportUrlAsIs: true,
-    };
-    return this._getChangeURLAndSend(req).then(response => {
-      if (response?.status === 204) {
-        return 'Change marked as Work In Progress.';
-      }
-      return undefined;
-    });
-  }
-
-  deleteComment(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    commentID: UrlEncodedCommentId,
-    reason: string
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method: HttpMethod.POST,
-      patchNum,
-      endpoint: `/comments/${commentID}/delete`,
-      body: {reason},
-      parseResponse: true,
-      anonymizedEndpoint: '/comments/*/delete',
-    }) as unknown as Promise<CommentInfo>;
-  }
-
-  /**
-   * Given a changeNum, gets the change.
-   */
-  getChange(
-    changeNum: ChangeId | NumericChangeId,
-    errFn: ErrorCallback
-  ): Promise<ChangeInfo | null> {
-    // Cannot use _changeBaseURL, as this function is used by _projectLookup.
-    return this._restApiHelper
-      .fetchJSON({
-        url: `/changes/?q=change:${changeNum}`,
-        errFn,
-        anonymizedUrl: '/changes/?q=change:*',
-      })
-      .then(res => {
-        const changeInfos = res as ChangeInfo[] | undefined;
-        if (!changeInfos || !changeInfos.length) {
-          return null;
-        }
-        return changeInfos[0];
-      });
-  }
-
-  setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
-    if (
-      this._projectLookup[changeNum] &&
-      this._projectLookup[changeNum] !== project
-    ) {
-      console.warn(
-        'Change set with multiple project nums.' +
-          'One of them must be invalid.'
-      );
-    }
-    this._projectLookup[changeNum] = project;
-  }
-
-  /**
-   * Checks in _projectLookup for the changeNum. If it exists, returns the
-   * project. If not, calls the restAPI to get the change, populates
-   * _projectLookup with the project for that change, and returns the project.
-   */
-  getFromProjectLookup(
-    changeNum: NumericChangeId
-  ): Promise<RepoName | undefined> {
-    const project = this._projectLookup[`${changeNum}`];
-    if (project) {
-      return Promise.resolve(project);
-    }
-
-    const onError = (response?: Response | null) => firePageError(response);
-
-    return this.getChange(changeNum, onError).then(change => {
-      if (!change || !change.project) {
-        return;
-      }
-      this.setInProjectLookup(changeNum, change.project);
-      return change.project;
-    });
-  }
-
-  // if errFn is not set, then only Response possible
-  _getChangeURLAndSend(
-    req: SendRawChangeRequest & {errFn?: undefined}
-  ): Promise<Response>;
-
-  _getChangeURLAndSend(
-    req: SendRawChangeRequest
-  ): Promise<Response | undefined>;
-
-  _getChangeURLAndSend(req: SendJSONChangeRequest): Promise<ParsedJSON>;
-
-  /**
-   * Alias for _changeBaseURL.then(send).
-   */
-  _getChangeURLAndSend(
-    req: SendChangeRequest
-  ): Promise<ParsedJSON | Response | undefined> {
-    const anonymizedBaseUrl = req.patchNum
-      ? ANONYMIZED_REVISION_BASE_URL
-      : ANONYMIZED_CHANGE_BASE_URL;
-    const anonymizedEndpoint = req.reportEndpointAsIs
-      ? req.endpoint
-      : req.anonymizedEndpoint;
-
-    return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
-      const request: SendRequest = {
-        method: req.method,
-        url: url + req.endpoint,
-        body: req.body,
-        errFn: req.errFn,
-        contentType: req.contentType,
-        headers: req.headers,
-        parseResponse: req.parseResponse,
-        anonymizedUrl: anonymizedEndpoint
-          ? `${anonymizedBaseUrl}${anonymizedEndpoint}`
-          : undefined,
-      };
-      return this._restApiHelper.send(request);
-    });
-  }
-
-  /**
-   * Alias for _changeBaseURL.then(_fetchJSON).
-   */
-  _getChangeURLAndFetch(
-    req: FetchChangeJSON,
-    noAcceptHeader?: boolean
-  ): Promise<ParsedJSON | undefined> {
-    const anonymizedEndpoint = req.reportEndpointAsIs
-      ? req.endpoint
-      : req.anonymizedEndpoint;
-    const anonymizedBaseUrl = req.revision
-      ? ANONYMIZED_REVISION_BASE_URL
-      : ANONYMIZED_CHANGE_BASE_URL;
-    return this._changeBaseURL(req.changeNum, req.revision).then(url =>
-      this._restApiHelper.fetchJSON(
-        {
-          url: url + req.endpoint,
-          errFn: req.errFn,
-          params: req.params,
-          fetchOptions: req.fetchOptions,
-          anonymizedUrl: anonymizedEndpoint
-            ? anonymizedBaseUrl + anonymizedEndpoint
-            : undefined,
-        },
-        noAcceptHeader
-      )
-    );
-  }
-
-  executeChangeAction(
-    changeNum: NumericChangeId,
-    method: HttpMethod | undefined,
-    endpoint: string,
-    patchNum?: PatchSetNum,
-    payload?: RequestPayload
-  ): Promise<Response>;
-
-  executeChangeAction(
-    changeNum: NumericChangeId,
-    method: HttpMethod | undefined,
-    endpoint: string,
-    patchNum: PatchSetNum | undefined,
-    payload: RequestPayload | undefined,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  /**
-   * Execute a change action or revision action on a change.
-   */
-  executeChangeAction(
-    changeNum: NumericChangeId,
-    method: HttpMethod | undefined,
-    endpoint: string,
-    patchNum?: PatchSetNum,
-    payload?: RequestPayload,
-    errFn?: ErrorCallback
-  ) {
-    return this._getChangeURLAndSend({
-      changeNum,
-      method,
-      patchNum,
-      endpoint,
-      body: payload,
-      errFn,
-    });
-  }
-
-  /**
-   * Get blame information for the given diff.
-   *
-   * @param base If true, requests blame for the base of the
-   *     diff, rather than the revision.
-   */
-  getBlame(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    path: string,
-    base?: boolean
-  ) {
-    const encodedPath = encodeURIComponent(path);
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: `/files/${encodedPath}/blame`,
-      revision: patchNum,
-      params: base ? {base: 't'} : undefined,
-      anonymizedEndpoint: '/files/*/blame',
-    }) as Promise<BlameInfo[] | undefined>;
-  }
-
-  /**
-   * Modify the given create draft request promise so that it fails and throws
-   * an error if the response bears HTTP status 200 instead of HTTP 201.
-   *
-   * @see Issue 7763
-   * @param promise The original promise.
-   * @return The modified promise.
-   */
-  _failForCreate200(promise: Promise<Response>): Promise<Response> {
-    return promise.then(result => {
-      if (result.status === 200) {
-        // Read the response headers into an object representation.
-        const headers = Array.from(result.headers.entries()).reduce(
-          (obj, [key, val]) => {
-            if (!HEADER_REPORTING_BLOCK_REGEX.test(key)) {
-              obj[key] = val;
-            }
-            return obj;
-          },
-          {} as Record<string, string>
-        );
-        const err = new Error(
-          [
-            CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
-            JSON.stringify(headers),
-          ].join('\n')
-        );
-        // Throw the error so that it is caught by gr-reporting.
-        throw err;
-      }
-      return result;
-    });
-  }
-
-  /**
-   * Fetch a project dashboard definition.
-   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
-   */
-  getDashboard(
-    project: RepoName,
-    dashboard: DashboardId,
-    errFn?: ErrorCallback
-  ): Promise<DashboardInfo | undefined> {
-    const url =
-      '/projects/' +
-      encodeURIComponent(project) +
-      '/dashboards/' +
-      encodeURIComponent(dashboard);
-    return this._fetchSharedCacheURL({
-      url,
-      errFn,
-      anonymizedUrl: '/projects/*/dashboards/*',
-    }) as Promise<DashboardInfo | undefined>;
-  }
-
-  getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
-    filter = filter.trim();
-    const encodedFilter = encodeURIComponent(filter);
-
-    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
-    // supports it.
-    return this._fetchSharedCacheURL({
-      url: `/Documentation/?q=${encodedFilter}`,
-      anonymizedUrl: '/Documentation/?*',
-    }) as Promise<DocResult[] | undefined>;
-  }
-
-  getMergeable(changeNum: NumericChangeId) {
-    return this._getChangeURLAndFetch({
-      changeNum,
-      endpoint: '/revisions/current/mergeable',
-      reportEndpointAsIs: true,
-    }) as Promise<MergeableInfo | undefined>;
-  }
-
-  deleteDraftComments(query: string): Promise<Response> {
-    const body: DeleteDraftCommentsInput = {query};
-    return this._restApiHelper.send({
-      method: HttpMethod.POST,
-      url: '/accounts/self/drafts:delete',
-      body,
-    });
-  }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
deleted file mode 100644
index 6a02985..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ /dev/null
@@ -1,1356 +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 {addListenerForTest, mockPromise, stubAuth} from '../../../test/test-utils.js';
-import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
-import {ListChangesOption} from '../../../utils/change-util.js';
-import {appContext} from '../../../services/app-context.js';
-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;
-
-  let ctr = 0;
-  let originalCanonicalPath;
-
-  setup(() => {
-    // Modify CANONICAL_PATH to effectively reset cache.
-    ctr += 1;
-    originalCanonicalPath = window.CANONICAL_PATH;
-    window.CANONICAL_PATH = `test${ctr}`;
-
-    const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    sinon.stub(window, 'fetch').returns(Promise.resolve({
-      ok: true,
-      text() {
-        return Promise.resolve(testJSON);
-      },
-    }));
-    // fake auth
-    sinon.stub(appContext.authService, 'authCheck')
-        .returns(Promise.resolve(true));
-    element = new GrRestApiInterface();
-    element._projectLookup = {};
-  });
-
-  teardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  test('parent diff comments are properly grouped', () => {
-    sinon.stub(element._restApiHelper, 'fetchJSON')
-        .callsFake(() => Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              updated: '2017-02-03 22:32:28.000000000',
-              message: 'this isn’t quite right',
-            },
-            {
-              side: 'PARENT',
-              message: 'how did this work in the first place?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-          ],
-        }));
-    return element._getDiffComments('42', '', undefined, 'PARENT', 1,
-        'sieve.go').then(
-        obj => {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            side: 'PARENT',
-            message: 'how did this work in the first place?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:33:28.000000000',
-          });
-          assert.equal(obj.comments.length, 1);
-          assert.deepEqual(obj.comments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-        });
-  });
-
-  test('_setRange', () => {
-    const comments = [
-      {
-        id: 1,
-        side: 'PARENT',
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-      },
-    ];
-    const expectedResult = {
-      id: 2,
-      in_reply_to: 1,
-      message: 'this isn’t quite right',
-      updated: '2017-02-03 22:33:28.000000000',
-      range: {
-        start_line: 1,
-        start_character: 1,
-        end_line: 2,
-        end_character: 1,
-      },
-    };
-    const comment = comments[1];
-    assert.deepEqual(element._setRange(comments, comment), expectedResult);
-  });
-
-  test('_setRanges', () => {
-    const comments = [
-      {
-        id: 3,
-        in_reply_to: 2,
-        message: 'this isn’t quite right either',
-        updated: '2017-02-03 22:34:28.000000000',
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-      },
-      {
-        id: 1,
-        side: 'PARENT',
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-    ];
-    const expectedResult = [
-      {
-        id: 1,
-        side: 'PARENT',
-        message: 'how did this work in the first place?',
-        updated: '2017-02-03 22:32:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 2,
-        in_reply_to: 1,
-        message: 'this isn’t quite right',
-        updated: '2017-02-03 22:33:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-      {
-        id: 3,
-        in_reply_to: 2,
-        message: 'this isn’t quite right either',
-        updated: '2017-02-03 22:34:28.000000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 2,
-          end_character: 1,
-        },
-      },
-    ];
-    assert.deepEqual(element._setRanges(comments), expectedResult);
-  });
-
-  test('differing patch diff comments are properly grouped', () => {
-    sinon.stub(element, 'getFromProjectLookup')
-        .returns(Promise.resolve('test'));
-    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(request => {
-      const url = request.url;
-      if (url === '/changes/test~42/revisions/1') {
-        return Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              message: 'this isn’t quite right',
-              updated: '2017-02-03 22:32:28.000000000',
-            },
-            {
-              side: 'PARENT',
-              message: 'how did this work in the first place?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-          ],
-        });
-      } else if (url === '/changes/test~42/revisions/2') {
-        return Promise.resolve({
-          '/COMMIT_MSG': [],
-          'sieve.go': [
-            {
-              message: 'What on earth are you thinking, here?',
-              updated: '2017-02-03 22:32:28.000000000',
-            },
-            {
-              side: 'PARENT',
-              message: 'Yeah not sure how this worked either?',
-              updated: '2017-02-03 22:33:28.000000000',
-            },
-            {
-              message: '¯\\_(ツ)_/¯',
-              updated: '2017-02-04 22:33:28.000000000',
-            },
-          ],
-        });
-      }
-    });
-    return element._getDiffComments('42', '', undefined, 1, 2, 'sieve.go').then(
-        obj => {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          assert.equal(obj.comments.length, 2);
-          assert.deepEqual(obj.comments[0], {
-            message: 'What on earth are you thinking, here?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          assert.deepEqual(obj.comments[1], {
-            message: '¯\\_(ツ)_/¯',
-            path: 'sieve.go',
-            updated: '2017-02-04 22:33:28.000000000',
-          });
-        });
-  });
-
-  test('server error', () => {
-    const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
-    stubAuth('fetch').returns(Promise.resolve({ok: false}));
-    const serverErrorEventPromise = new Promise(resolve => {
-      addListenerForTest(document, 'server-error', resolve);
-    });
-
-    return Promise.all([element._restApiHelper.fetchJSON({}).then(response => {
-      assert.isUndefined(response);
-      assert.isTrue(getResponseObjectStub.notCalled);
-    }), serverErrorEventPromise]);
-  });
-
-  test('legacy n,z key in change url is replaced', async () => {
-    sinon.stub(element, 'getConfig').callsFake(async () => {
-      return {};
-    });
-    const stub = sinon.stub(element._restApiHelper, 'fetchJSON')
-        .returns(Promise.resolve([]));
-    await element.getChanges(1, null, 'n,z');
-    assert.equal(stub.lastCall.args[0].params.S, 0);
-  });
-
-  test('saveDiffPreferences invalidates cache line', () => {
-    const cacheKey = '/accounts/self/preferences.diff';
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
-    element._cache.set(cacheKey, {tab_size: 4});
-    element.saveDiffPreferences({tab_size: 8});
-    assert.isTrue(sendStub.called);
-    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-  });
-
-  test('getAccount when resp is null does not add to cache', async () => {
-    const cacheKey = '/accounts/self/detail';
-    const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
-        .callsFake(() => Promise.resolve());
-
-    await element.getAccount();
-    assert.isTrue(stub.called);
-    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-
-    element._restApiHelper._cache.set(cacheKey, 'fake cache');
-    stub.lastCall.args[0].errFn();
-  });
-
-  test('getAccount does not add to cache when status is 403', async () => {
-    const cacheKey = '/accounts/self/detail';
-    const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
-        .callsFake(() => Promise.resolve());
-
-    await element.getAccount();
-    assert.isTrue(stub.called);
-    assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-
-    element._cache.set(cacheKey, 'fake cache');
-    stub.lastCall.args[0].errFn({status: 403});
-  });
-
-  test('getAccount when resp is successful', async () => {
-    const cacheKey = '/accounts/self/detail';
-    const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL').callsFake(
-        () => Promise.resolve());
-
-    await element.getAccount();
-
-    element._restApiHelper._cache.set(cacheKey, 'fake cache');
-    assert.isTrue(stub.called);
-    assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
-    stub.lastCall.args[0].errFn({});
-  });
-
-  const preferenceSetup = function(testJSON, loggedIn) {
-    sinon.stub(element, 'getLoggedIn')
-        .callsFake(() => Promise.resolve(loggedIn));
-    sinon.stub(
-        element._restApiHelper,
-        'fetchCacheURL')
-        .callsFake(() => Promise.resolve(testJSON));
-  };
-
-  test('getPreferences returns correctly logged in',
-      () => {
-        const testJSON = {diff_view: 'SIDE_BY_SIDE'};
-        const loggedIn = true;
-
-        preferenceSetup(testJSON, loggedIn);
-
-        return element.getPreferences().then(obj => {
-          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        });
-      });
-
-  test('getPreferences returns correctly on larger screens logged in',
-      () => {
-        const testJSON = {diff_view: 'UNIFIED_DIFF'};
-        const loggedIn = true;
-
-        preferenceSetup(testJSON, loggedIn);
-
-        return element.getPreferences().then(obj => {
-          assert.equal(obj.diff_view, 'UNIFIED_DIFF');
-        });
-      });
-
-  test('getPreferences returns correctly on larger screens not logged in',
-      () => {
-        const testJSON = {diff_view: 'UNIFIED_DIFF'};
-        const loggedIn = false;
-
-        preferenceSetup(testJSON, loggedIn);
-
-        return element.getPreferences().then(obj => {
-          assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        });
-      });
-
-  test('savPreferences normalizes download scheme', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve(new Response()));
-    element.savePreferences({download_scheme: 'HTTP'});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
-  });
-
-  test('getDiffPreferences returns correct defaults', () => {
-    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
-
-    return element.getDiffPreferences().then(obj => {
-      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.line_length, 100);
-      assert.equal(obj.line_wrapping, false);
-      assert.equal(obj.show_line_endings, true);
-      assert.equal(obj.show_tabs, true);
-      assert.equal(obj.show_whitespace_errors, true);
-      assert.equal(obj.syntax_highlighting, true);
-      assert.equal(obj.tab_size, 8);
-    });
-  });
-
-  test('saveDiffPreferences set show_tabs to false', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
-    element.saveDiffPreferences({show_tabs: false});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-  });
-
-  test('getEditPreferences returns correct defaults', () => {
-    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
-
-    return element.getEditPreferences().then(obj => {
-      assert.equal(obj.auto_close_brackets, false);
-      assert.equal(obj.cursor_blink_rate, 0);
-      assert.equal(obj.hide_line_numbers, false);
-      assert.equal(obj.hide_top_menu, false);
-      assert.equal(obj.indent_unit, 2);
-      assert.equal(obj.indent_with_tabs, false);
-      assert.equal(obj.key_map_type, 'DEFAULT');
-      assert.equal(obj.line_length, 100);
-      assert.equal(obj.line_wrapping, false);
-      assert.equal(obj.match_brackets, true);
-      assert.equal(obj.show_base, false);
-      assert.equal(obj.show_tabs, true);
-      assert.equal(obj.show_whitespace_errors, true);
-      assert.equal(obj.syntax_highlighting, true);
-      assert.equal(obj.tab_size, 8);
-      assert.equal(obj.theme, 'DEFAULT');
-    });
-  });
-
-  test('saveEditPreferences set show_tabs to false', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
-    element.saveEditPreferences({show_tabs: false});
-    assert.isTrue(sendStub.called);
-    assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
-  });
-
-  test('confirmEmail', () => {
-    const sendStub = sinon.spy(element._restApiHelper, 'send');
-    element.confirmEmail('foo');
-    assert.isTrue(sendStub.calledOnce);
-    assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-    assert.equal(sendStub.lastCall.args[0].url,
-        '/config/server/email.confirm');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
-  });
-
-  test('setAccountStatus', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve('OOO'));
-    element._cache.set('/accounts/self/detail', {});
-    return element.setAccountStatus('OOO').then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].method, 'PUT');
-      assert.equal(sendStub.lastCall.args[0].url,
-          '/accounts/self/status');
-      assert.deepEqual(sendStub.lastCall.args[0].body,
-          {status: 'OOO'});
-      assert.deepEqual(
-          element._restApiHelper._cache.get('/accounts/self/detail'),
-          {status: 'OOO'});
-    });
-  });
-
-  suite('draft comments', () => {
-    test('_sendDiffDraftRequest pending requests tracked', () => {
-      const obj = element._pendingRequests;
-      sinon.stub(element, '_getChangeURLAndSend')
-          .callsFake(() => mockPromise());
-      assert.notOk(element.hasPendingDiffDrafts());
-
-      element._sendDiffDraftRequest(null, null, null, {});
-      assert.equal(obj.sendDiffDraft.length, 1);
-      assert.isTrue(!!element.hasPendingDiffDrafts());
-
-      element._sendDiffDraftRequest(null, null, null, {});
-      assert.equal(obj.sendDiffDraft.length, 2);
-      assert.isTrue(!!element.hasPendingDiffDrafts());
-
-      for (const promise of obj.sendDiffDraft) {
-        promise.resolve();
-      }
-
-      return element.awaitPendingDiffDrafts().then(() => {
-        assert.equal(obj.sendDiffDraft.length, 0);
-        assert.isFalse(!!element.hasPendingDiffDrafts());
-      });
-    });
-
-    suite('_failForCreate200', () => {
-      test('_sendDiffDraftRequest checks for 200 on create', () => {
-        const sendPromise = Promise.resolve();
-        sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
-        const failStub = sinon.stub(element, '_failForCreate200')
-            .returns(Promise.resolve());
-        return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
-          assert.isTrue(failStub.calledOnce);
-          assert.isTrue(failStub.calledWithExactly(sendPromise));
-        });
-      });
-
-      test('_sendDiffDraftRequest no checks for 200 on non create', () => {
-        sinon.stub(element, '_getChangeURLAndSend')
-            .returns(Promise.resolve());
-        const failStub = sinon.stub(element, '_failForCreate200')
-            .returns(Promise.resolve());
-        return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
-            .then(() => {
-              assert.isFalse(failStub.called);
-            });
-      });
-
-      test('_failForCreate200 fails on 200', () => {
-        const result = {
-          ok: true,
-          status: 200,
-          headers: {
-            entries: () => [
-              ['Set-CoOkiE', 'secret'],
-              ['Innocuous', 'hello'],
-            ],
-          },
-        };
-        return element._failForCreate200(Promise.resolve(result))
-            .then(() => {
-              assert.fail('Error expected.');
-            })
-            .catch(e => {
-              assert.isOk(e);
-              assert.include(e.message, 'Saving draft resulted in HTTP 200');
-              assert.include(e.message, 'hello');
-              assert.notInclude(e.message, 'secret');
-            });
-      });
-
-      test('_failForCreate200 does not fail on 201', () => {
-        const result = {
-          ok: true,
-          status: 201,
-          headers: {entries: () => []},
-        };
-        return element._failForCreate200(Promise.resolve(result));
-      });
-    });
-  });
-
-  test('saveChangeEdit', () => {
-    element._projectLookup = {1: 'test'};
-    const change_num = '1';
-    const file_name = 'index.php';
-    const file_contents = '<?php';
-    sinon.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve([change_num, file_name, file_contents]));
-    sinon.stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, file_name, file_contents]));
-    element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
-    return element.saveChangeEdit(change_num, file_name, file_contents)
-        .then(() => {
-          assert.isTrue(element._restApiHelper.send.calledOnce);
-          assert.equal(element._restApiHelper.send.lastCall.args[0].method,
-              'PUT');
-          assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-              '/changes/test~1/edit/' + file_name);
-          assert.equal(element._restApiHelper.send.lastCall.args[0].body,
-              file_contents);
-        });
-  });
-
-  test('putChangeCommitMessage', () => {
-    element._projectLookup = {1: 'test'};
-    const change_num = '1';
-    const message = 'this is a commit message';
-    sinon.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve([change_num, message]));
-    sinon.stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, message]));
-    element._cache.set('/changes/' + change_num + '/message', {});
-    return element.putChangeCommitMessage(change_num, message).then(() => {
-      assert.isTrue(element._restApiHelper.send.calledOnce);
-      assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
-      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-          '/changes/test~1/message');
-      assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body,
-          {message});
-    });
-  });
-
-  test('deleteChangeCommitMessage', () => {
-    element._projectLookup = {1: 'test'};
-    const change_num = '1';
-    const messageId = 'abc';
-    sinon.stub(element._restApiHelper, 'send').returns(
-        Promise.resolve([change_num, messageId]));
-    sinon.stub(element, 'getResponseObject')
-        .returns(Promise.resolve([change_num, messageId]));
-    return element.deleteChangeCommitMessage(change_num, messageId).then(() => {
-      assert.isTrue(element._restApiHelper.send.calledOnce);
-      assert.equal(
-          element._restApiHelper.send.lastCall.args[0].method,
-          'DELETE'
-      );
-      assert.equal(element._restApiHelper.send.lastCall.args[0].url,
-          '/changes/test~1/messages/abc');
-    });
-  });
-
-  test('startWorkInProgress', () => {
-    const sendStub = sinon.stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve('ok'));
-    element.startWorkInProgress('42');
-    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, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body, {});
-
-    element.startWorkInProgress('42', 'revising...');
-    assert.isTrue(sendStub.calledTwice);
-    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, '/wip');
-    assert.deepEqual(sendStub.lastCall.args[0].body,
-        {message: 'revising...'});
-  });
-
-  test('deleteComment', () => {
-    const sendStub = sinon.stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve('some response'));
-    return element.deleteComment('foo', 'bar', '01234', 'removal reason')
-        .then(response => {
-          assert.equal(response, 'some response');
-          assert.isTrue(sendStub.calledOnce);
-          assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
-          assert.equal(sendStub.lastCall.args[0].method, 'POST');
-          assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
-          assert.equal(sendStub.lastCall.args[0].endpoint,
-              '/comments/01234/delete');
-          assert.deepEqual(sendStub.lastCall.args[0].body,
-              {reason: 'removal reason'});
-        });
-  });
-
-  test('createRepo encodes name', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve());
-    return element.createRepo({name: 'x/y'}).then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
-    });
-  });
-
-  test('queryChangeFiles', () => {
-    const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
-        .returns(Promise.resolve());
-    return element.queryChangeFiles('42', 'edit', 'test/path.js').then(() => {
-      assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
-      assert.equal(fetchStub.lastCall.args[0].endpoint,
-          '/files?q=test%2Fpath.js');
-      assert.equal(fetchStub.lastCall.args[0].revision, 'edit');
-    });
-  });
-
-  test('normal use', () => {
-    const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
-
-    assert.equal(element._getReposUrl('test', 25),
-        '/projects/?n=26&S=0&query=test');
-
-    assert.equal(element._getReposUrl(null, 25),
-        `/projects/?n=26&S=0&query=${defaultQuery}`);
-
-    assert.equal(element._getReposUrl('test', 25, 25),
-        '/projects/?n=26&S=25&query=test');
-  });
-
-  test('invalidateReposCache', () => {
-    const url = '/projects/?n=26&S=0&query=test';
-
-    element._cache.set(url, {});
-
-    element.invalidateReposCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  test('invalidateAccountsCache', () => {
-    const url = '/accounts/self/detail';
-
-    element._cache.set(url, {});
-
-    element.invalidateAccountsCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  suite('getRepos', () => {
-    const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
-    let fetchCacheURLStub;
-    setup(() => {
-      fetchCacheURLStub =
-          sinon.stub(element._restApiHelper, 'fetchCacheURL');
-    });
-
-    test('normal use', () => {
-      element.getRepos('test', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=test');
-
-      element.getRepos(null, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&query=${defaultQuery}`);
-
-      element.getRepos('test', 25, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=25&query=test');
-    });
-
-    test('with blank', () => {
-      element.getRepos('test/test', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Atest%20AND%20inname%3Atest');
-    });
-
-    test('with hyphen', () => {
-      element.getRepos('foo-bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-    });
-
-    test('with leading hyphen', () => {
-      element.getRepos('-bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Abar');
-    });
-
-    test('with trailing hyphen', () => {
-      element.getRepos('foo-bar-', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-    });
-
-    test('with underscore', () => {
-      element.getRepos('foo_bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-    });
-
-    test('with underscore', () => {
-      element.getRepos('foo_bar', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/projects/?n=26&S=0&query=inname%3Afoo%20AND%20inname%3Abar');
-    });
-
-    test('hyphen only', () => {
-      element.getRepos('-', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          `/projects/?n=26&S=0&query=${defaultQuery}`);
-    });
-  });
-
-  test('_getGroupsUrl normal use', () => {
-    assert.equal(element._getGroupsUrl('test', 25),
-        '/groups/?n=26&S=0&m=test');
-
-    assert.equal(element._getGroupsUrl(null, 25),
-        '/groups/?n=26&S=0');
-
-    assert.equal(element._getGroupsUrl('test', 25, 25),
-        '/groups/?n=26&S=25&m=test');
-  });
-
-  test('invalidateGroupsCache', () => {
-    const url = '/groups/?n=26&S=0&m=test';
-
-    element._cache.set(url, {});
-
-    element.invalidateGroupsCache();
-
-    assert.isUndefined(element._sharedFetchPromises[url]);
-
-    assert.isFalse(element._cache.has(url));
-  });
-
-  suite('getGroups', () => {
-    let fetchCacheURLStub;
-    setup(() => {
-      fetchCacheURLStub =
-          sinon.stub(element._restApiHelper, 'fetchCacheURL');
-    });
-
-    test('normal use', () => {
-      element.getGroups('test', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0&m=test');
-
-      element.getGroups(null, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0');
-
-      element.getGroups('test', 25, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=25&m=test');
-    });
-
-    test('regex', () => {
-      element.getGroups('^test.*', 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=0&r=%5Etest.*');
-
-      element.getGroups('^test.*', 25, 25);
-      assert.equal(fetchCacheURLStub.lastCall.args[0].url,
-          '/groups/?n=26&S=25&r=%5Etest.*');
-    });
-  });
-
-  test('gerrit auth is used', () => {
-    stubAuth('fetch').returns(Promise.resolve());
-    element._restApiHelper.fetchJSON({url: 'foo'});
-    assert(appContext.authService.fetch.called);
-  });
-
-  test('getSuggestedAccounts does not return _fetchJSON', () => {
-    const _fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
-    return element.getSuggestedAccounts().then(accts => {
-      assert.isFalse(_fetchJSONSpy.called);
-      assert.equal(accts.length, 0);
-    });
-  });
-
-  test('_fetchJSON gets called by getSuggestedAccounts', () => {
-    const _fetchJSONStub = sinon.stub(element._restApiHelper, 'fetchJSON')
-        .callsFake(() => Promise.resolve());
-    return element.getSuggestedAccounts('own').then(() => {
-      assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
-        q: 'own',
-        suggest: null,
-      });
-    });
-  });
-
-  suite('getChangeDetail', () => {
-    suite('change detail options', () => {
-      setup(() => {
-        sinon.stub(element, '_getChangeDetail').callsFake(
-            async (changeNum, options) => {
-              return {changeNum, options};
-            });
-      });
-
-      test('signed pushes disabled', async () => {
-        sinon.stub(element, 'getConfig').callsFake(async () => {
-          return {};
-        });
-        const {changeNum, options} = await element.getChangeDetail(123);
-        assert.strictEqual(123, changeNum);
-        assert.isNotOk(
-            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES));
-      });
-
-      test('signed pushes enabled', async () => {
-        sinon.stub(element, 'getConfig').callsFake(async () => {
-          return {receive: {enable_signed_push: true}};
-        });
-        const {changeNum, options} = await element.getChangeDetail(123);
-        assert.strictEqual(123, changeNum);
-        assert.ok(
-            parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES));
-      });
-    });
-
-    test('GrReviewerUpdatesParser.parse is used', () => {
-      sinon.stub(GrReviewerUpdatesParser, 'parse').returns(
-          Promise.resolve('foo'));
-      return element.getChangeDetail(42).then(result => {
-        assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
-        assert.equal(result, 'foo');
-      });
-    });
-
-    test('_getChangeDetail passes params to ETags decorator', () => {
-      const changeNum = 4321;
-      element._projectLookup[changeNum] = 'test';
-      const expectedUrl =
-          window.CANONICAL_PATH + '/changes/test~4321/detail?O=516714';
-      sinon.stub(element._etags, 'getOptions');
-      sinon.stub(element._etags, 'collect');
-      return element._getChangeDetail(changeNum, '516714').then(() => {
-        assert.isTrue(element._etags.getOptions.calledWithExactly(
-            expectedUrl));
-        assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
-      });
-    });
-
-    test('_getChangeDetail calls errFn on 500', () => {
-      const errFn = sinon.stub();
-      sinon.stub(element, 'getChangeActionURL')
-          .returns(Promise.resolve(''));
-      sinon.stub(element._restApiHelper, 'fetchRawJSON')
-          .returns(Promise.resolve({ok: false, status: 500}));
-      return element._getChangeDetail(123, '516714', errFn).then(() => {
-        assert.isTrue(errFn.called);
-      });
-    });
-
-    test('_getChangeDetail populates _projectLookup', async () => {
-      sinon.stub(element, 'getChangeActionURL')
-          .returns(Promise.resolve(''));
-      sinon.stub(element._restApiHelper, 'fetchRawJSON')
-          .returns(Promise.resolve({
-            ok: true,
-            status: 200,
-            text: () => Promise.resolve(`)]}'{"_number":1,"project":"test"}`),
-          }));
-      await element._getChangeDetail(1, '516714');
-      assert.equal(Object.keys(element._projectLookup).length, 1);
-      assert.equal(element._projectLookup[1], 'test');
-    });
-
-    suite('_getChangeDetail ETag cache', () => {
-      let requestUrl;
-      let mockResponseSerial;
-      let collectSpy;
-
-      setup(() => {
-        requestUrl = '/foo/bar';
-        const mockResponse = {foo: 'bar', baz: 42};
-        mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
-        sinon.stub(element._restApiHelper, 'urlWithParams')
-            .returns(requestUrl);
-        sinon.stub(element, 'getChangeActionURL')
-            .returns(Promise.resolve(requestUrl));
-        collectSpy = sinon.spy(element._etags, 'collect');
-      });
-
-      test('contributes to cache', () => {
-        const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
-        sinon.stub(element._restApiHelper, 'fetchRawJSON')
-            .returns(Promise.resolve({
-              text: () => Promise.resolve(mockResponseSerial),
-              status: 200,
-              ok: true,
-            }));
-
-        return element._getChangeDetail(123, '516714').then(detail => {
-          assert.isFalse(getPayloadSpy.called);
-          assert.isTrue(collectSpy.calledOnce);
-          const cachedResponse = element._etags.getCachedPayload(requestUrl);
-          assert.equal(cachedResponse, mockResponseSerial);
-        });
-      });
-
-      test('uses cache on HTTP 304', () => {
-        const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
-        getPayloadStub.returns(mockResponseSerial);
-        sinon.stub(element._restApiHelper, 'fetchRawJSON')
-            .returns(Promise.resolve({
-              text: () => Promise.resolve(''),
-              status: 304,
-              ok: true,
-            }));
-
-        return element._getChangeDetail(123, '').then(detail => {
-          assert.isFalse(collectSpy.called);
-          assert.isTrue(getPayloadStub.calledOnce);
-        });
-      });
-    });
-  });
-
-  test('setInProjectLookup', () => {
-    element.setInProjectLookup('test', 'project');
-    assert.deepEqual(element._projectLookup, {test: 'project'});
-  });
-
-  suite('getFromProjectLookup', () => {
-    test('getChange fails', () => {
-      sinon.stub(element, 'getChange')
-          .returns(Promise.resolve(null));
-      return element.getFromProjectLookup().then(val => {
-        assert.strictEqual(val, undefined);
-        assert.deepEqual(element._projectLookup, {});
-      });
-    });
-
-    test('getChange succeeds, no project', () => {
-      sinon.stub(element, 'getChange').returns(Promise.resolve(null));
-      return element.getFromProjectLookup().then(val => {
-        assert.strictEqual(val, undefined);
-        assert.deepEqual(element._projectLookup, {});
-      });
-    });
-
-    test('getChange succeeds with project', () => {
-      sinon.stub(element, 'getChange')
-          .returns(Promise.resolve({project: 'project'}));
-      return element.getFromProjectLookup('test').then(val => {
-        assert.equal(val, 'project');
-        assert.deepEqual(element._projectLookup, {test: 'project'});
-      });
-    });
-  });
-
-  suite('getChanges populates _projectLookup', () => {
-    test('multiple queries', () => {
-      sinon.stub(element._restApiHelper, 'fetchJSON')
-          .returns(Promise.resolve([
-            [
-              {_number: 1, project: 'test'},
-              {_number: 2, project: 'test'},
-            ], [
-              {_number: 3, project: 'test/test'},
-            ],
-          ]));
-      // When opt_query instanceof Array, _fetchJSON returns
-      // Array<Array<Object>>.
-      return element.getChanges(null, []).then(() => {
-        assert.equal(Object.keys(element._projectLookup).length, 3);
-        assert.equal(element._projectLookup[1], 'test');
-        assert.equal(element._projectLookup[2], 'test');
-        assert.equal(element._projectLookup[3], 'test/test');
-      });
-    });
-
-    test('no query', () => {
-      sinon.stub(element._restApiHelper, 'fetchJSON')
-          .returns(Promise.resolve([
-            {_number: 1, project: 'test'},
-            {_number: 2, project: 'test'},
-            {_number: 3, project: 'test/test'},
-          ]));
-
-      // When opt_query !instanceof Array, _fetchJSON returns
-      // Array<Object>.
-      return element.getChanges().then(() => {
-        assert.equal(Object.keys(element._projectLookup).length, 3);
-        assert.equal(element._projectLookup[1], 'test');
-        assert.equal(element._projectLookup[2], 'test');
-        assert.equal(element._projectLookup[3], 'test/test');
-      });
-    });
-  });
-
-  test('_getChangeURLAndFetch', () => {
-    element._projectLookup = {1: 'test'};
-    const fetchStub = sinon.stub(element._restApiHelper, 'fetchJSON')
-        .returns(Promise.resolve());
-    const req = {changeNum: 1, endpoint: '/test', revision: 1};
-    return element._getChangeURLAndFetch(req).then(() => {
-      assert.equal(fetchStub.lastCall.args[0].url,
-          '/changes/test~1/revisions/1/test');
-    });
-  });
-
-  test('_getChangeURLAndSend', () => {
-    element._projectLookup = {1: 'test'};
-    const sendStub = sinon.stub(element._restApiHelper, 'send')
-        .returns(Promise.resolve());
-
-    const req = {
-      changeNum: 1,
-      method: 'POST',
-      patchNum: 1,
-      endpoint: '/test',
-    };
-    return element._getChangeURLAndSend(req).then(() => {
-      assert.isTrue(sendStub.calledOnce);
-      assert.equal(sendStub.lastCall.args[0].method, 'POST');
-      assert.equal(sendStub.lastCall.args[0].url,
-          '/changes/test~1/revisions/1/test');
-    });
-  });
-
-  suite('reading responses', () => {
-    test('_readResponsePayload', async () => {
-      const mockObject = {foo: 'bar', baz: 'foo'};
-      const serial = JSON_PREFIX + JSON.stringify(mockObject);
-      const mockResponse = {text: () => Promise.resolve(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 = JSON_PREFIX + JSON.stringify(obj);
-      const result = parsePrefixedJSON(serial);
-      assert.deepEqual(result, obj);
-    });
-  });
-
-  test('setChangeTopic', () => {
-    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
-    return element.setChangeTopic(123, 'foo-bar').then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
-    });
-  });
-
-  test('setChangeHashtag', () => {
-    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
-    return element.setChangeHashtag(123, 'foo-bar').then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
-    });
-  });
-
-  test('generateAccountHttpPassword', () => {
-    const sendSpy = sinon.spy(element._restApiHelper, 'send');
-    return element.generateAccountHttpPassword().then(() => {
-      assert.isTrue(sendSpy.calledOnce);
-      assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
-    });
-  });
-
-  suite('getChangeFiles', () => {
-    test('patch only', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: 'PARENT', patchNum: 2};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 2);
-        assert.isNotOk(fetchStub.lastCall.args[0].params);
-      });
-    });
-
-    test('simple range', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: 4, patchNum: 5};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-      });
-    });
-
-    test('parent index', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      const range = {basePatchNum: -3, patchNum: 5};
-      return element.getChangeFiles(123, range).then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-      });
-    });
-  });
-
-  suite('getDiff', () => {
-    test('patchOnly', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 2);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-      });
-    });
-
-    test('simple range', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
-        assert.equal(fetchStub.lastCall.args[0].params.base, 4);
-      });
-    });
-
-    test('parent index', () => {
-      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch')
-          .returns(Promise.resolve());
-      return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
-        assert.isTrue(fetchStub.calledOnce);
-        assert.equal(fetchStub.lastCall.args[0].revision, 5);
-        assert.isOk(fetchStub.lastCall.args[0].params);
-        assert.isNotOk(fetchStub.lastCall.args[0].params.base);
-        assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
-      });
-    });
-  });
-
-  test('getDashboard', () => {
-    const fetchCacheURLStub = sinon.stub(element._restApiHelper,
-        'fetchCacheURL');
-    element.getDashboard('gerrit/project', 'default:main');
-    assert.isTrue(fetchCacheURLStub.calledOnce);
-    assert.equal(
-        fetchCacheURLStub.lastCall.args[0].url,
-        '/projects/gerrit%2Fproject/dashboards/default%3Amain');
-  });
-
-  test('getFileContent', () => {
-    sinon.stub(element, '_getChangeURLAndSend')
-        .returns(Promise.resolve({
-          ok: 'true',
-          headers: {
-            get(header) {
-              if (header === 'X-FYI-Content-Type') {
-                return 'text/java';
-              }
-            },
-          },
-        }));
-
-    sinon.stub(element, 'getResponseObject')
-        .returns(Promise.resolve('new content'));
-
-    const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
-      assert.deepEqual(res,
-          {content: 'new content', type: 'text/java', ok: true});
-    });
-
-    const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
-      assert.deepEqual(res,
-          {content: 'new content', type: 'text/java', ok: true});
-    });
-
-    return Promise.all([edit, normal]);
-  });
-
-  test('getFileContent suppresses 404s', () => {
-    const res = {status: 404};
-    const spy = sinon.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')
-        .then(() => {
-          flush();
-          assert.isFalse(spy.called);
-
-          res.status = 500;
-          return element.getFileContent('1', 'tst/path', '1');
-        })
-        .then(() => {
-          assert.isTrue(spy.called);
-          assert.notEqual(spy.lastCall.args[0].detail.response.status, 404);
-        });
-  });
-
-  test('getChangeFilesOrEditFiles is edit-sensitive', () => {
-    const fn = element.getChangeOrEditFiles.bind(element);
-    const getChangeFilesStub = sinon.stub(element, 'getChangeFiles')
-        .returns(Promise.resolve({}));
-    const getChangeEditFilesStub = sinon.stub(element, 'getChangeEditFiles')
-        .returns(Promise.resolve({}));
-
-    return fn('1', {patchNum: 'edit'}).then(() => {
-      assert.isTrue(getChangeEditFilesStub.calledOnce);
-      assert.isFalse(getChangeFilesStub.called);
-      return fn('1', {patchNum: '1'}).then(() => {
-        assert.isTrue(getChangeEditFilesStub.calledOnce);
-        assert.isTrue(getChangeFilesStub.calledOnce);
-      });
-    });
-  });
-
-  test('_fetch forwards request and logs', () => {
-    const logStub = sinon.stub(element._restApiHelper, '_logCall');
-    const response = {status: 404, text: sinon.stub()};
-    const url = 'my url';
-    const fetchOptions = {method: 'DELETE'};
-    sinon.stub(element.authService, 'fetch').returns(Promise.resolve(response));
-    const startTime = 123;
-    sinon.stub(Date, 'now').returns(startTime);
-    const req = {url, fetchOptions};
-    return element._restApiHelper.fetch(req).then(() => {
-      assert.isTrue(logStub.calledOnce);
-      assert.isTrue(logStub.calledWith(req, startTime, response.status));
-      assert.isFalse(response.text.called);
-    });
-  });
-
-  test('_logCall only reports requests with anonymized URLss', () => {
-    sinon.stub(Date, 'now').returns(200);
-    const handler = sinon.stub();
-    addListenerForTest(document, 'gr-rpc-log', handler);
-
-    element._restApiHelper._logCall({url: 'url'}, 100, 200);
-    assert.isFalse(handler.called);
-
-    element._restApiHelper
-        ._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
-    flush();
-    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'));
-    const sendStub =
-        sinon.stub(element._restApiHelper, 'send').returns(Promise.resolve());
-
-    await element.saveChangeStarred(123, true);
-    assert.isTrue(sendStub.calledOnce);
-    assert.deepEqual(sendStub.lastCall.args[0], {
-      method: 'PUT',
-      url: '/accounts/self/starred.changes/test~123',
-      anonymizedUrl: '/accounts/self/starred.changes/*',
-    });
-
-    await element.saveChangeStarred(456, false);
-    assert.isTrue(sendStub.calledTwice);
-    assert.deepEqual(sendStub.lastCall.args[0], {
-      method: 'DELETE',
-      url: '/accounts/self/starred.changes/test~456',
-      anonymizedUrl: '/accounts/self/starred.changes/*',
-    });
-  });
-});
-
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 8db6606..8b3e2a6 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
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {getBaseUrl} from '../../../../utils/url-util';
 import {CancelConditionCallback} from '../../../../services/gr-rest-api/gr-rest-api';
@@ -31,6 +20,8 @@
 import {fireNetworkError, fireServerError} from '../../../../utils/event-util';
 import {FetchRequest} from '../../../../types/types';
 import {ErrorCallback} from '../../../../api/rest';
+import {Scheduler, Task} from '../../../../services/scheduler/scheduler';
+import {RetryError} from '../../../../services/scheduler/retry-scheduler';
 
 export const JSON_PREFIX = ")]}'";
 
@@ -111,7 +102,7 @@
 
   get(key: '/accounts/self/emails'): EmailInfo[] | null;
 
-  get(key: '/accounts/self/detail'): AccountDetailInfo[] | null;
+  get(key: '/accounts/self/detail'): AccountDetailInfo | null;
 
   get(key: string): ParsedJSON | null;
 
@@ -121,7 +112,7 @@
 
   set(key: '/accounts/self/emails', value: EmailInfo[]): void;
 
-  set(key: '/accounts/self/detail', value: AccountDetailInfo[]): void;
+  set(key: '/accounts/self/detail', value: AccountDetailInfo): void;
 
   set(key: string, value: ParsedJSON | null): void;
 
@@ -192,6 +183,35 @@
   [name: string]: string[] | string | number | boolean | undefined | null;
 };
 
+/**
+ * Error callback that throws an error.
+ *
+ * Pass into REST API methods as errFn to make the returned Promises reject on
+ * error.
+ *
+ * If error is provided, it's thrown.
+ * Otherwise if response with error is provided the promise that will throw an
+ * error is returned.
+ */
+export function throwingErrorCallback(
+  response?: Response | null,
+  err?: Error
+): void | Promise<void> {
+  if (err) throw err;
+  if (!response) return;
+
+  return response.text().then(errorText => {
+    let message = `Error ${response.status}`;
+    if (response.statusText) {
+      message += ` (${response.statusText})`;
+    }
+    if (errorText) {
+      message += `: ${errorText}`;
+    }
+    throw new Error(message);
+  });
+}
+
 interface SendRequestBase {
   method: HttpMethod | undefined;
   body?: RequestPayload;
@@ -236,20 +256,45 @@
   constructor(
     private readonly _cache: SiteBasedCache,
     private readonly _auth: AuthService,
-    private readonly _fetchPromisesCache: FetchPromisesCache
+    private readonly _fetchPromisesCache: FetchPromisesCache,
+    private readonly readScheduler: Scheduler<Response>,
+    private readonly writeScheduler: Scheduler<Response>
   ) {}
 
+  private schedule(method: string, task: Task<Response>) {
+    if (method === 'PUT' || method === 'POST' || method === 'DELETE') {
+      return this.writeScheduler.schedule(task);
+    } else {
+      return this.readScheduler.schedule(task);
+    }
+  }
+
   /**
    * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
    * with timing and logging.
 s   */
   fetch(req: FetchRequest): Promise<Response> {
+    const method =
+      req.fetchOptions && req.fetchOptions.method
+        ? req.fetchOptions.method
+        : 'GET';
     const start = Date.now();
-    const xhr = this._auth.fetch(req.url, req.fetchOptions);
+    const task = async () => {
+      const res = await this._auth.fetch(req.url, req.fetchOptions);
+      if (!res.ok && res.status === 429) throw new RetryError<Response>(res);
+      return res;
+    };
+
+    const xhr = this.schedule(method, task).catch((err: unknown) => {
+      if (err instanceof RetryError) {
+        return err.payload;
+      } else {
+        throw err;
+      }
+    });
 
     // Log the call after it completes.
     xhr.then(res => this._logCall(req, start, res ? res.status : null));
-
     // Return the XHR directly (without the log).
     return xhr;
   }
@@ -259,16 +304,14 @@
    * by this method, it should be called immediately after the request
    * finishes.
    *
+   * Private, but used in tests.
+   *
    * @param startTime the time that the request was started.
    * @param status the HTTP status of the response. The status value
    *     is used here rather than the response object so there is no way this
    *     method can read the body stream.
    */
-  private _logCall(
-    req: FetchRequest,
-    startTime: number,
-    status: number | null
-  ) {
+  _logCall(req: FetchRequest, startTime: number, status: number | null) {
     const method =
       req.fetchOptions && req.fetchOptions.method
         ? req.fetchOptions.method
@@ -277,7 +320,7 @@
     const elapsed = endTime - startTime;
     const startAt = new Date(startTime);
     const endAt = new Date(endTime);
-    console.info(
+    console.debug(
       [
         'HTTP',
         status,
@@ -347,27 +390,26 @@
    *
    * @param noAcceptHeader - don't add default accept json header
    */
-  fetchJSON(
+  async fetchJSON(
     req: FetchJSONRequest,
     noAcceptHeader?: boolean
   ): Promise<ParsedJSON | undefined> {
     if (!noAcceptHeader) {
       req = this.addAcceptJsonHeader(req);
     }
-    return this.fetchRawJSON(req).then(response => {
-      if (!response) {
+    const response = await this.fetchRawJSON(req);
+    if (!response) {
+      return;
+    }
+    if (!response.ok) {
+      if (req.errFn) {
+        await req.errFn.call(undefined, response);
         return;
       }
-      if (!response.ok) {
-        if (req.errFn) {
-          req.errFn.call(null, response);
-          return;
-        }
-        fireServerError(response, req);
-        return;
-      }
-      return this.getResponseObject(response);
-    });
+      fireServerError(response, req);
+      return;
+    }
+    return this.getResponseObject(response);
   }
 
   urlWithParams(url: string, fetchParams?: FetchParams): string {
@@ -460,7 +502,7 @@
    *     (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> {
+  async send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
     const options: AuthRequestInit = {method: req.method};
     if (req.body) {
       options.headers = new Headers();
@@ -485,37 +527,30 @@
       fetchOptions: options,
       anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
     };
-    const xhr = this.fetch(fetchReq)
-      .catch(err => {
-        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;
-      });
+    let xhr;
+    try {
+      xhr = await this.fetch(fetchReq);
+    } catch (err) {
+      fireNetworkError(err as Error);
+      if (req.errFn) {
+        await req.errFn.call(undefined, null, err as Error);
+        xhr = undefined;
+      } else {
+        throw err;
+      }
+    }
+    if (xhr && !xhr.ok) {
+      if (req.errFn) {
+        await req.errFn.call(undefined, xhr);
+      } else {
+        fireServerError(xhr, fetchReq);
+      }
+    }
 
     if (req.parseResponse) {
-      // TODO(TS): remove as Response and fix error.
-      // Javascript code allows returning of a Response object from errFn.
-      // This can be a mistake and we should add check here or it can be used
-      // somewhere - in this case we should fix it carefully (define
-      // different type of callback if parseResponse is true, etc...).
-      return xhr.then(res => this.getResponseObject(res as Response));
+      xhr = xhr && this.getResponseObject(xhr);
     }
-    // The actual xhr type is Promise<Response|undefined|void> because of the
-    // catch callback
-    return xhr as Promise<Response | undefined>;
+    return xhr;
   }
 
   invalidateFetchPromisesPrefix(prefix: string) {
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
deleted file mode 100644
index 70bd369..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
+++ /dev/null
@@ -1,155 +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 {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
-import {appContext} from '../../../../services/app-context.js';
-import {stubAuth} from '../../../../test/test-utils.js';
-
-suite('gr-rest-api-helper tests', () => {
-  let helper;
-
-  let cache;
-  let fetchPromisesCache;
-  let originalCanonicalPath;
-  let authFetchStub;
-
-  setup(() => {
-    cache = new SiteBasedCache();
-    fetchPromisesCache = new FetchPromisesCache();
-
-    originalCanonicalPath = window.CANONICAL_PATH;
-    window.CANONICAL_PATH = 'testhelper';
-
-    const mockRestApiInterface = {
-      fire: sinon.stub(),
-    };
-
-    const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    authFetchStub = stubAuth('fetch').returns(Promise.resolve({
-      ok: true,
-      text() {
-        return Promise.resolve(testJSON);
-      },
-    }));
-
-    helper = new GrRestApiHelper(cache, appContext.authService,
-        fetchPromisesCache, mockRestApiInterface);
-  });
-
-  teardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  suite('fetchJSON()', () => {
-    test('Sets header to accept application/json', () => {
-      helper.fetchJSON({url: '/dummy/url'});
-      assert.isTrue(authFetchStub.called);
-      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-          'application/json');
-    });
-
-    test('Use header option accept when provided', () => {
-      const headers = new Headers();
-      headers.append('Accept', '*/*');
-      const fetchOptions = {headers};
-      helper.fetchJSON({url: '/dummy/url', fetchOptions});
-      assert.isTrue(authFetchStub.called);
-      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-          '*/*');
-    });
-  });
-
-  test('JSON prefix is properly removed',
-      () => helper.fetchJSON({url: '/dummy/url'}).then(obj => {
-        assert.deepEqual(obj, {hello: 'bonjour'});
-      })
-  );
-
-  test('cached results', () => {
-    let n = 0;
-    sinon.stub(helper, 'fetchJSON').callsFake(() => Promise.resolve(++n));
-    const promises = [];
-    promises.push(helper.fetchCacheURL('/foo'));
-    promises.push(helper.fetchCacheURL('/foo'));
-    promises.push(helper.fetchCacheURL('/foo'));
-
-    return Promise.all(promises).then(results => {
-      assert.deepEqual(results, [1, 1, 1]);
-      return helper.fetchCacheURL('/foo').then(foo => {
-        assert.equal(foo, 1);
-      });
-    });
-  });
-
-  test('cached promise', () => {
-    const promise = Promise.reject(new Error('foo'));
-    cache.set('/foo', promise);
-    return helper.fetchCacheURL({url: '/foo'}).catch(p => {
-      assert.equal(p.message, 'foo');
-    });
-  });
-
-  test('cache invalidation', () => {
-    cache.set('/foo/bar', 1);
-    cache.set('/bar', 2);
-    fetchPromisesCache.set('/foo/bar', 3);
-    fetchPromisesCache.set('/bar', 4);
-    helper.invalidateFetchPromisesPrefix('/foo/');
-    assert.isFalse(cache.has('/foo/bar'));
-    assert.isTrue(cache.has('/bar'));
-    assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
-    assert.strictEqual(4, fetchPromisesCache.get('/bar'));
-  });
-
-  test('params are properly encoded', () => {
-    let url = helper.urlWithParams('/path/', {
-      sp: 'hola',
-      gr: 'guten tag',
-      noval: null,
-    });
-    assert.equal(url,
-        window.CANONICAL_PATH + '/path/?sp=hola&gr=guten%20tag&noval');
-
-    url = helper.urlWithParams('/path/', {
-      sp: 'hola',
-      en: ['hey', 'hi'],
-    });
-    assert.equal(url, window.CANONICAL_PATH + '/path/?sp=hola&en=hey&en=hi');
-
-    // Order must be maintained with array params.
-    url = helper.urlWithParams('/path/', {
-      l: ['c', 'b', 'a'],
-    });
-    assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
-  });
-
-  test('request callbacks can be canceled', () => {
-    let cancelCalled = false;
-    authFetchStub.returns(Promise.resolve({
-      body: {
-        cancel() { cancelCalled = true; },
-      },
-    }));
-    const cancelCondition = () => true;
-    return helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(obj => {
-      assert.isUndefined(obj);
-      assert.isTrue(cancelCalled);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
new file mode 100644
index 0000000..9f0319e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -0,0 +1,378 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../../test/common-test-setup';
+import {
+  SiteBasedCache,
+  FetchPromisesCache,
+  GrRestApiHelper,
+} from './gr-rest-api-helper';
+import {assertFails, waitEventLoop} from '../../../../test/test-utils';
+import {FakeScheduler} from '../../../../services/scheduler/fake-scheduler';
+import {RetryScheduler} from '../../../../services/scheduler/retry-scheduler';
+import {ParsedJSON} from '../../../../types/common';
+import {HttpMethod} from '../../../../api/rest-api';
+import {SinonFakeTimers} from 'sinon';
+import {assert} from '@open-wc/testing';
+import {AuthService} from '../../../../services/gr-auth/gr-auth';
+import {GrAuthMock} from '../../../../services/gr-auth/gr-auth_mock';
+
+function makeParsedJSON<T>(val: T): ParsedJSON {
+  return val as unknown as ParsedJSON;
+}
+
+suite('gr-rest-api-helper tests', () => {
+  let clock: SinonFakeTimers;
+  let helper: GrRestApiHelper;
+
+  let cache: SiteBasedCache;
+  let fetchPromisesCache: FetchPromisesCache;
+  let originalCanonicalPath: string | undefined;
+  let authFetchStub: sinon.SinonStub;
+  let readScheduler: FakeScheduler<Response>;
+  let writeScheduler: FakeScheduler<Response>;
+  let authService: AuthService;
+
+  setup(() => {
+    clock = sinon.useFakeTimers();
+    cache = new SiteBasedCache();
+    fetchPromisesCache = new FetchPromisesCache();
+
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = 'testhelper';
+
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    authService = new GrAuthMock();
+    authFetchStub = sinon.stub(authService, 'fetch').returns(
+      Promise.resolve({
+        ...new Response(),
+        ok: true,
+        text() {
+          return Promise.resolve(testJSON);
+        },
+      })
+    );
+
+    readScheduler = new FakeScheduler<Response>();
+    writeScheduler = new FakeScheduler<Response>();
+
+    helper = new GrRestApiHelper(
+      cache,
+      authService,
+      fetchPromisesCache,
+      readScheduler,
+      writeScheduler
+    );
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  async function assertReadRequest() {
+    assert.equal(readScheduler.scheduled.length, 1);
+    await readScheduler.resolve();
+    await waitEventLoop();
+  }
+
+  async function assertWriteRequest() {
+    assert.equal(writeScheduler.scheduled.length, 1);
+    await writeScheduler.resolve();
+    await waitEventLoop();
+  }
+
+  suite('send()', () => {
+    setup(() => {
+      authFetchStub.returns(
+        Promise.resolve({
+          ...new Response(),
+          ok: true,
+          text() {
+            return Promise.resolve('Yay');
+          },
+        })
+      );
+    });
+
+    test('GET are sent to readScheduler', async () => {
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+      });
+      assert.equal(writeScheduler.scheduled.length, 0);
+      await assertReadRequest();
+      const res: Response = await promise;
+      assert.equal(await res.text(), 'Yay');
+    });
+
+    test('PUT are sent to writeScheduler', async () => {
+      const promise = helper.send({
+        method: HttpMethod.PUT,
+        url: '/dummy/url',
+        parseResponse: false,
+      });
+      assert.equal(readScheduler.scheduled.length, 0);
+      await assertWriteRequest();
+      const res: Response = await promise;
+      assert.equal(await res.text(), 'Yay');
+    });
+  });
+
+  suite('fetchJSON()', () => {
+    test('Sets header to accept application/json', async () => {
+      helper.fetchJSON({url: '/dummy/url'});
+      assert.isFalse(authFetchStub.called);
+      await assertReadRequest();
+      assert.isTrue(authFetchStub.called);
+      assert.equal(
+        authFetchStub.lastCall.args[1].headers.get('Accept'),
+        'application/json'
+      );
+    });
+
+    test('Use header option accept when provided', async () => {
+      const headers = new Headers();
+      headers.append('Accept', '*/*');
+      const fetchOptions = {headers};
+      helper.fetchJSON({url: '/dummy/url', fetchOptions});
+      assert.isFalse(authFetchStub.called);
+      await assertReadRequest();
+      assert.isTrue(authFetchStub.called);
+      assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'), '*/*');
+    });
+
+    test('JSON prefix is properly removed', async () => {
+      const promise = helper.fetchJSON({url: '/dummy/url'});
+      await assertReadRequest();
+      const obj = await promise;
+      assert.deepEqual(obj, makeParsedJSON({hello: 'bonjour'}));
+    });
+  });
+
+  test('cached results', () => {
+    let n = 0;
+    sinon
+      .stub(helper, 'fetchJSON')
+      .callsFake(() => Promise.resolve(makeParsedJSON(++n)));
+    const promises = [];
+    promises.push(helper.fetchCacheURL({url: '/foo'}));
+    promises.push(helper.fetchCacheURL({url: '/foo'}));
+    promises.push(helper.fetchCacheURL({url: '/foo'}));
+
+    return Promise.all(promises).then(results => {
+      assert.deepEqual(results, [
+        makeParsedJSON(1),
+        makeParsedJSON(1),
+        makeParsedJSON(1),
+      ]);
+      return helper.fetchCacheURL({url: '/foo'}).then(foo => {
+        assert.equal(foo, makeParsedJSON(1));
+      });
+    });
+  });
+
+  test('cache invalidation', async () => {
+    cache.set('/foo/bar', makeParsedJSON(1));
+    cache.set('/bar', makeParsedJSON(2));
+    fetchPromisesCache.set('/foo/bar', Promise.resolve(makeParsedJSON(3)));
+    fetchPromisesCache.set('/bar', Promise.resolve(makeParsedJSON(4)));
+    helper.invalidateFetchPromisesPrefix('/foo/');
+    assert.isFalse(cache.has('/foo/bar'));
+    assert.isTrue(cache.has('/bar'));
+    assert.isUndefined(fetchPromisesCache.get('/foo/bar'));
+    assert.strictEqual(makeParsedJSON(4), await fetchPromisesCache.get('/bar'));
+  });
+
+  test('params are properly encoded', () => {
+    let url = helper.urlWithParams('/path/', {
+      sp: 'hola',
+      gr: 'guten tag',
+      noval: null,
+    });
+    assert.equal(
+      url,
+      `${window.CANONICAL_PATH}/path/?sp=hola&gr=guten%20tag&noval`
+    );
+
+    url = helper.urlWithParams('/path/', {
+      sp: 'hola',
+      en: ['hey', 'hi'],
+    });
+    assert.equal(url, `${window.CANONICAL_PATH}/path/?sp=hola&en=hey&en=hi`);
+
+    // Order must be maintained with array params.
+    url = helper.urlWithParams('/path/', {
+      l: ['c', 'b', 'a'],
+    });
+    assert.equal(url, `${window.CANONICAL_PATH}/path/?l=c&l=b&l=a`);
+  });
+
+  test('request callbacks can be canceled', async () => {
+    let cancelCalled = false;
+    authFetchStub.returns(
+      Promise.resolve({
+        body: {
+          cancel() {
+            cancelCalled = true;
+          },
+        },
+      })
+    );
+    const cancelCondition = () => true;
+    const promise = helper.fetchJSON({url: '/dummy/url', cancelCondition});
+    await assertReadRequest();
+    const obj = await promise;
+    assert.isUndefined(obj);
+    assert.isTrue(cancelCalled);
+  });
+
+  suite('throwing in errFn', () => {
+    function throwInPromise(response?: Response | null, _?: Error) {
+      return response?.text().then(text => {
+        throw new Error(text);
+      });
+    }
+
+    function throwImmediately(_1?: Response | null, _2?: Error) {
+      throw new Error('Error Callback error');
+    }
+
+    setup(() => {
+      authFetchStub.returns(
+        Promise.resolve({
+          ...new Response(),
+          status: 400,
+          ok: false,
+          text() {
+            return Promise.resolve('Nope');
+          },
+        })
+      );
+    });
+
+    test('errFn with Promise throw cause send to reject on error', async () => {
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+        errFn: throwInPromise,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Nope');
+    });
+
+    test('errFn with Promise throw cause fetchJSON to reject on error', async () => {
+      const promise = helper.fetchJSON({
+        url: '/dummy/url',
+        errFn: throwInPromise,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Nope');
+    });
+
+    test('errFn with immediate throw cause send to reject on error', async () => {
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+        errFn: throwImmediately,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Error Callback error');
+    });
+
+    test('errFn with immediate Promise cause fetchJSON to reject on error', async () => {
+      const promise = helper.fetchJSON({
+        url: '/dummy/url',
+        errFn: throwImmediately,
+      });
+      await assertReadRequest();
+
+      const err = await assertFails(promise);
+      assert.equal((err as Error).message, 'Error Callback error');
+    });
+  });
+
+  suite('429 errors', () => {
+    setup(() => {
+      authFetchStub.returns(
+        Promise.resolve({
+          ...new Response(),
+          status: 429,
+          ok: false,
+        })
+      );
+    });
+
+    test('still call errFn when not retried', async () => {
+      const errFn = sinon.stub();
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+        errFn,
+      });
+      await assertReadRequest();
+
+      // But we expect the result from the network to return a 429 error when
+      // it's no longer being retried.
+      await promise;
+      assert.isTrue(errFn.called);
+    });
+
+    test('still pass through correctly when not retried', async () => {
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+      });
+      await assertReadRequest();
+
+      // But we expect the result from the network to return a 429 error when
+      // it's no longer being retried.
+      const res: Response = await promise;
+      assert.equal(res.status, 429);
+    });
+
+    test('are retried', async () => {
+      helper = new GrRestApiHelper(
+        cache,
+        authService,
+        fetchPromisesCache,
+        new RetryScheduler<Response>(readScheduler, 1, 50),
+        writeScheduler
+      );
+      const promise = helper.send({
+        method: HttpMethod.GET,
+        url: '/dummy/url',
+        parseResponse: false,
+      });
+      await assertReadRequest();
+      authFetchStub.returns(
+        Promise.resolve({
+          ...new Response(),
+          ok: true,
+          text() {
+            return Promise.resolve('Yay');
+          },
+        })
+      );
+      // Flush the retry scheduler
+      clock.tick(50);
+      await waitEventLoop();
+      // We expect a retry.
+      await assertReadRequest();
+      const res: Response = await promise;
+      assert.equal(await res.text(), 'Yay');
+    });
+  });
+});
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 a603ec6..b51c4a2 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
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {parseDate} from '../../../utils/date-util';
 import {MessageTag, ReviewerState} from '../../../constants/constants';
 import {
@@ -276,8 +264,8 @@
   }
 
   static parse(
-    change: ChangeViewChangeInfo | undefined | null
-  ): ParsedChangeInfo | undefined | null {
+    change: ChangeViewChangeInfo | undefined
+  ): ParsedChangeInfo | undefined {
     // TODO(TS): The !change condition should be removed when all files are converted to TS
     if (!change || !isChangeInfoParserInput(change)) {
       return change;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
index 34fb709..9c87910 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
-import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
-import {parseDate} from '../../../utils/date-util.js';
+import '../../../test/common-test-setup';
+import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser';
+import {parseDate} from '../../../utils/date-util';
+import {assert} from '@open-wc/testing';
 
 suite('gr-reviewer-updates-parser tests', () => {
   let instance;
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 47295ab..083ec0b 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -1,51 +1,38 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {customElement, property, observe} from '@polymer/decorators';
+import {html, LitElement, PropertyValues} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-select': GrSelect;
   }
+  interface HTMLElementEventMap {
+    'bind-value-changed': BindValueChangeEvent;
+  }
 }
 
 /**
  * GrSelect `gr-select` component.
+ * TODO: Figure out if this class still has merit over native <select>
  */
 @customElement('gr-select')
-export class GrSelect extends PolymerElement {
-  static get template() {
-    return html` <slot></slot> `;
+export class GrSelect extends LitElement {
+  private _bindValue?: string | number | boolean;
+
+  get bindValue() {
+    return this._bindValue;
   }
 
-  @property({type: String, notify: true})
-  bindValue?: string | number | boolean;
-
-  get nativeSelect() {
-    // gr-select is not a shadow component
-    // TODO(taoalpha): maybe we should convert
-    // it into a shadow dom component instead
-    // TODO(TS): should warn if no `select` detected.
-    return this.querySelector('select')!;
-  }
-
-  @observe('bindValue')
-  _updateValue() {
+  set bindValue(bindValue: string | number | boolean | undefined) {
+    if (this._bindValue === bindValue) return;
+    this._bindValue = bindValue;
+    this._updateValue();
     // It's possible to have a value of 0.
     if (this.bindValue !== undefined) {
       // Set for chrome/safari so it happens instantly
@@ -57,27 +44,55 @@
         this.nativeSelect.value = String(this.bindValue);
       }, 1);
     }
+    // TODO: bind-value-changed is polymer-specific.  Move to a new event
+    // name and rely on ValueChangedEvent instead of BindValueChangeEvent.
+    fire(this, 'bind-value-changed', {value: this.convert(this._bindValue)});
   }
 
-  _valueChanged() {
-    this.bindValue = this.nativeSelect.value;
-  }
-
-  override focus() {
-    this.nativeSelect.focus();
+  get nativeSelect() {
+    return this.querySelector('select')!;
   }
 
   constructor() {
     super();
-    this.addEventListener('change', () => this._valueChanged());
-    this.addEventListener('dom-change', () => this._updateValue());
+    this.addEventListener('change', () => {
+      this.bindValue = this.nativeSelect.value;
+    });
   }
 
-  override ready() {
-    super.ready();
+  override updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
     // If not set via the property, set bind-value to the element value.
     if (this.bindValue === undefined && this.nativeSelect.options.length > 0) {
       this.bindValue = this.nativeSelect.value;
     }
   }
+
+  override render() {
+    return html`<slot></slot>`;
+  }
+
+  _updateValue() {
+    // It's possible to have a value of 0.
+    if (this.bindValue !== undefined) {
+      // Set for chrome/safari so it happens instantly
+      this.nativeSelect.value = this.convert(this.bindValue) ?? '';
+      // Async needed for firefox to populate value. It was trying to do it
+      // before options from a dom-repeat were rendered previously.
+      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
+      setTimeout(() => {
+        this.nativeSelect.value = this.convert(this.bindValue) ?? '';
+      }, 1);
+    }
+  }
+
+  private convert(value: string | boolean | number | undefined) {
+    if (value === undefined) return undefined;
+    if (typeof value === 'string') return value;
+    return String(value);
+  }
+
+  override focus() {
+    this.nativeSelect.focus();
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
index 245d7df..4bb63ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
@@ -1,46 +1,30 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-select';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {fixture, html, assert} from '@open-wc/testing';
 import {GrSelect} from './gr-select';
 
-const basicFixture = fixtureFromTemplate(html`
-  <gr-select>
-    <select>
-      <option value="1">One</option>
-      <option value="2">Two</option>
-      <option value="3">Three</option>
-    </select>
-  </gr-select>
-`);
-
-const noOptionsFixture = fixtureFromTemplate(html`
-  <gr-select>
-    <select></select>
-  </gr-select>
-`);
-
 suite('gr-select tests', () => {
   let element: GrSelect;
 
-  setup(() => {
-    element = basicFixture.instantiate() as GrSelect;
+  setup(async () => {
+    element = await fixture<GrSelect>(html`
+      <gr-select>
+        <select>
+          <option value="1">One</option>
+          <option value="2">Two</option>
+          <option value="3">Three</option>
+        </select>
+      </gr-select>
+    `);
+  });
+
+  test('render', () => {
+    assert.shadowDom.equal(element, /* HTML */ '<slot></slot>');
   });
 
   test('bindValue must be set to the first option value', () => {
@@ -98,8 +82,12 @@
   suite('gr-select no options tests', () => {
     let element: GrSelect;
 
-    setup(() => {
-      element = noOptionsFixture.instantiate() as GrSelect;
+    setup(async () => {
+      element = await fixture<GrSelect>(html`
+        <gr-select>
+          <select></select>
+        </gr-select>
+      `);
     });
 
     test('bindValue must not be changed', () => {
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 7ed000b..ef0f531 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
@@ -1,25 +1,14 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-copy-clipboard/gr-copy-clipboard';
 import {GrCopyClipboard} from '../gr-copy-clipboard/gr-copy-clipboard';
 import {queryAndAssert} from '../../../utils/common-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -81,14 +70,15 @@
     return html` <label>${label}</label>
       <div class="commandContainer">
         <gr-copy-clipboard
-          .text="${this.command}"
+          .text=${this.command}
           hasTooltip
-          buttonTitle="${this.tooltip}"
+          buttonTitle=${this.tooltip}
         ></gr-copy-clipboard>
       </div>`;
   }
 
-  focusOnCopy() {
+  async focusOnCopy() {
+    await this.updateComplete;
     const copyClipboard = queryAndAssert<GrCopyClipboard>(
       this,
       'gr-copy-clipboard'
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
index a50b60b..f489664 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
@@ -1,44 +1,46 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-shell-command';
 import {GrShellCommand} from './gr-shell-command';
 import {GrCopyClipboard} from '../gr-copy-clipboard/gr-copy-clipboard';
 import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-shell-command');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-shell-command tests', () => {
   let element: GrShellCommand;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture(html`<gr-shell-command></gr-shell-command>`);
     element.command = `git fetch http://gerrit@localhost:8080/a/test-project
         refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-    await flush();
+    await element.updateComplete;
   });
 
-  test('focusOnCopy', () => {
+  test('render', async () => {
+    element.label = 'label1';
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <label> label1 </label>
+        <div class="commandContainer">
+          <gr-copy-clipboard buttontitle="" hastooltip=""> </gr-copy-clipboard>
+        </div>
+      `
+    );
+  });
+
+  test('focusOnCopy', async () => {
     const focusStub = sinon.stub(
       queryAndAssert<GrCopyClipboard>(element, 'gr-copy-clipboard')!,
       'focusOnCopy'
     );
-    element.focusOnCopy();
+    await element.focusOnCopy();
     assert.isTrue(focusStub.called);
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index ce1b282..a1a1e84 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -1,38 +1,36 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-overlay/gr-overlay';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-textarea_html';
-import {appContext} from '../../../services/app-context';
-import {customElement, property} from '@polymer/decorators';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {getAppContext} from '../../../services/app-context';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {
   GrAutocompleteDropdown,
   Item,
   ItemSelectedEvent,
 } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {addShortcut, Key} from '../../../utils/dom-util';
-import {BindValueChangeEvent} from '../../../types/events';
+import {Key} from '../../../utils/dom-util';
+import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {PropertyValues} from 'lit';
+import {classMap} from 'lit/directives/class-map.js';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {NumericChangeId, ServerInfo} from '../../../api/rest-api';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {assert} from '../../../utils/common-util';
+import {ShortcutController} from '../../lit/shortcut-controller';
+import {getAccountDisplayName} from '../../../utils/display-name-util';
+import {configModelToken} from '../../../models/config/config-model';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -60,17 +58,12 @@
   {value: '😜', match: 'winking tongue ;)'},
 ];
 
-interface EmojiSuggestion extends Item {
+export interface EmojiSuggestion extends Item {
   match: string;
 }
 
-export interface GrTextarea {
-  $: {
-    textarea: IronAutogrowTextareaElement;
-    emojiSuggestions: GrAutocompleteDropdown;
-    caratSpan: HTMLSpanElement;
-    hiddenText: HTMLDivElement;
-  };
+function isEmojiSuggestion(x: EmojiSuggestion | Item): x is EmojiSuggestion {
+  return !!x && !!(x as EmojiSuggestion).match;
 }
 
 declare global {
@@ -80,119 +73,234 @@
 }
 
 @customElement('gr-textarea')
-export class GrTextarea extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrTextarea extends LitElement {
   /**
    * @event bind-value-changed
    */
-  @property({type: String})
-  autocomplete?: string;
+  @query('#textarea') textarea?: IronAutogrowTextareaElement;
 
-  @property({type: Boolean})
-  disabled?: boolean;
+  @query('#emojiSuggestions') emojiSuggestions?: GrAutocompleteDropdown;
 
-  @property({type: Number})
-  rows?: number;
+  @query('#mentionsSuggestions') mentionsSuggestions?: GrAutocompleteDropdown;
 
-  @property({type: Number})
-  maxRows?: number;
+  @query('#caratSpan', true) caratSpan?: HTMLSpanElement;
 
-  @property({type: String})
-  placeholder?: string;
+  @query('#hiddenText') hiddenText?: HTMLDivElement;
 
-  @property({type: String, notify: true, observer: '_handleTextChanged'})
-  text = '';
+  @property() autocomplete?: string;
 
-  @property({type: Boolean})
-  hideBorder = false;
+  @property({type: Boolean}) disabled?: boolean;
+
+  @property({type: Number}) rows?: number;
+
+  @property({type: Number}) maxRows?: number;
+
+  @property({type: String}) placeholder?: string;
+
+  @property({type: String}) text = '';
+
+  @property({type: Boolean, attribute: 'hide-border'}) hideBorder = false;
 
   /** Text input should be rendered in monospace font.  */
-  @property({type: Boolean})
-  monospace = false;
+  @property({type: Boolean}) monospace = false;
 
   /** Text input should be rendered in code font, which is smaller than the
     standard monospace font. */
-  @property({type: Boolean})
-  code = false;
+  @property({type: Boolean}) code = false;
 
-  @property({type: Number})
-  _colonIndex: number | null = null;
+  @state() suggestions: (Item | EmojiSuggestion)[] = [];
 
-  @property({type: String, observer: '_determineSuggestions'})
-  _currentSearchString?: string;
+  // Accessed in tests.
+  readonly reporting = getAppContext().reportingService;
 
-  @property({type: Boolean})
-  _hideEmojiAutocomplete = true;
+  private readonly getChangeModel = resolve(this, changeModelToken);
 
-  @property({type: Number})
-  _index: number | null = null;
+  private readonly flagsService = getAppContext().flagsService;
 
-  @property({type: Array})
-  _suggestions: EmojiSuggestion[] = [];
+  private readonly restApiService = getAppContext().restApiService;
 
-  @property({type: Number})
-  readonly _verticalOffset = 20;
-  // Offset makes dropdown appear below text.
+  private readonly getConfigModel = resolve(this, configModelToken);
 
-  reporting: ReportingService;
+  private serverConfig?: ServerInfo;
 
-  disableEnterKeyForSelectingEmoji = false;
+  private changeNum?: NumericChangeId;
 
-  /** Called in disconnectedCallback. */
-  private cleanups: (() => void)[] = [];
+  // private but used in tests
+  specialCharIndex = -1;
+
+  // private but used in tests
+  currentSearchString?: string;
+
+  private readonly shortcuts = new ShortcutController(this);
 
   constructor() {
     super();
-    this.reporting = appContext.reportingService;
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.serverConfig = config;
+      }
+    );
+    this.shortcuts.addLocal({key: Key.UP}, e => this.handleUpKey(e), {
+      preventDefault: false,
+    });
+    this.shortcuts.addLocal({key: Key.DOWN}, e => this.handleDownKey(e), {
+      preventDefault: false,
+    });
+    this.shortcuts.addLocal({key: Key.TAB}, e => this.handleTabKey(e), {
+      preventDefault: false,
+    });
+    this.shortcuts.addLocal({key: Key.ENTER}, e => this.handleEnterByKey(e), {
+      preventDefault: false,
+    });
+    this.shortcuts.addLocal({key: Key.ESC}, e => this.handleEscKey(e), {
+      preventDefault: false,
+    });
   }
 
   override disconnectedCallback() {
     super.disconnectedCallback();
-    for (const cleanup of this.cleanups) cleanup();
-    this.cleanups = [];
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.cleanups.push(
-      addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e))
-    );
-    this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e))
-    );
-  }
-
-  override ready() {
-    super.ready();
     if (this.monospace) {
       this.classList.add('monospace');
     }
     if (this.code) {
       this.classList.add('code');
     }
-    if (this.hideBorder) {
-      this.$.textarea.classList.add('noBorder');
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: flex;
+        position: relative;
+      }
+      :host(.monospace) {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
+        font-weight: var(--font-weight-normal);
+      }
+      :host(.code) {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        /* usually 16px = 12px + 4px */
+        line-height: calc(var(--font-size-code) + var(--spacing-s));
+        font-weight: var(--font-weight-normal);
+      }
+      #emojiSuggestions {
+        font-family: var(--font-family);
+      }
+      #textarea {
+        background-color: var(--view-background-color);
+        width: 100%;
+      }
+      #hiddenText #emojiSuggestions {
+        visibility: visible;
+        white-space: normal;
+      }
+      iron-autogrow-textarea {
+        position: relative;
+      }
+      #textarea.noBorder {
+        border: none;
+      }
+      #hiddenText {
+        display: block;
+        float: left;
+        position: absolute;
+        visibility: hidden;
+        width: 100%;
+        white-space: pre-wrap;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <div id="hiddenText"></div>
+      <!-- When the autocomplete is open, the span is moved at the end of
+      hiddenText in order to correctly position the dropdown. After being moved,
+      it is set as the positionTarget for the emojiSuggestions dropdown. -->
+      <span id="caratSpan"></span>
+      ${this.renderEmojiDropdown()} ${this.renderMentionsDropdown()}
+      <iron-autogrow-textarea
+        id="textarea"
+        class=${classMap({noBorder: this.hideBorder})}
+        .autocomplete=${this.autocomplete}
+        .placeholder=${this.placeholder}
+        ?disabled=${this.disabled}
+        .rows=${this.rows}
+        .maxRows=${this.maxRows}
+        .value=${this.text}
+        @value-changed=${(e: ValueChangedEvent) => {
+          this.text = e.detail.value;
+        }}
+      ></iron-autogrow-textarea>
+    `;
+  }
+
+  private renderEmojiDropdown() {
+    return html`
+      <gr-autocomplete-dropdown
+        id="emojiSuggestions"
+        .suggestions=${this.suggestions}
+        .horizontalOffset=${20}
+        .verticalOffset=${20}
+        @dropdown-closed=${this.resetDropdown}
+        @item-selected=${this.handleDropdownItemSelect}
+      >
+      </gr-autocomplete-dropdown>
+    `;
+  }
+
+  private renderMentionsDropdown() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
+      return nothing;
+    return html` <gr-autocomplete-dropdown
+      id="mentionsSuggestions"
+      .suggestions=${this.suggestions}
+      @dropdown-closed=${this.resetDropdown}
+      @item-selected=${this.handleDropdownItemSelect}
+      .horizontalOffset=${20}
+      .verticalOffset=${20}
+      role="listbox"
+    ></gr-autocomplete-dropdown>`;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('text')) {
+      this.fireChangedEvents();
+      // Add to updated because we want this.textarea.selectionStart and
+      // this.textarea is null in the willUpdate lifecycle
+      this.computeSpecialCharIndex();
+      this.computeCurrentSearchString();
+      this.handleTextChanged();
     }
   }
 
+  // private but used in test
   closeDropdown() {
-    return this.$.emojiSuggestions.close();
+    this.mentionsSuggestions?.close();
+    this.emojiSuggestions?.close();
   }
 
   getNativeTextarea() {
-    return this.$.textarea.textarea;
+    return this.textarea!.textarea;
+  }
+
+  override focus() {
+    this.textarea?.textarea.focus();
   }
 
   putCursorAtEnd() {
@@ -200,90 +308,112 @@
     // Put the cursor at the end always.
     textarea.selectionStart = textarea.value.length;
     textarea.selectionEnd = textarea.selectionStart;
-    setTimeout(() => {
-      textarea.focus();
-    });
+    textarea.focus();
   }
 
-  _handleEscKey(e: KeyboardEvent) {
-    if (this._hideEmojiAutocomplete) {
+  private getVisibleDropdown() {
+    if (this.emojiSuggestions && !this.emojiSuggestions.isHidden)
+      return this.emojiSuggestions;
+    if (this.mentionsSuggestions && !this.mentionsSuggestions.isHidden)
+      return this.mentionsSuggestions;
+    throw new Error('no dropdown visible');
+  }
+
+  private isDropdownVisible() {
+    return (
+      (this.emojiSuggestions && !this.emojiSuggestions.isHidden) ||
+      (this.mentionsSuggestions && !this.mentionsSuggestions.isHidden)
+    );
+  }
+
+  private handleEscKey(e: KeyboardEvent) {
+    if (!this.isDropdownVisible()) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this._resetEmojiDropdown();
+    this.resetDropdown();
   }
 
-  _handleUpKey(e: KeyboardEvent) {
-    if (this._hideEmojiAutocomplete) {
+  private handleUpKey(e: KeyboardEvent) {
+    if (!this.isDropdownVisible()) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this.$.emojiSuggestions.cursorUp();
-    this.$.textarea.textarea.focus();
-    this.disableEnterKeyForSelectingEmoji = false;
+    this.getVisibleDropdown().cursorUp();
+    this.focus();
   }
 
-  _handleDownKey(e: KeyboardEvent) {
-    if (this._hideEmojiAutocomplete) {
+  private handleDownKey(e: KeyboardEvent) {
+    if (!this.isDropdownVisible()) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this.$.emojiSuggestions.cursorDown();
-    this.$.textarea.textarea.focus();
-    this.disableEnterKeyForSelectingEmoji = false;
+    this.getVisibleDropdown().cursorDown();
+    this.focus();
   }
 
-  _handleTabKey(e: KeyboardEvent) {
-    // Tab should have normal behavior if the picker is closed or if the user
-    // has only typed ':'.
-    if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+  private handleTabKey(e: KeyboardEvent) {
+    // Tab should have normal behavior if the picker is closed.
+    if (!this.isDropdownVisible()) {
       return;
     }
     e.preventDefault();
     e.stopPropagation();
-    this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+    this.setValue(this.getVisibleDropdown().getCurrentText());
   }
 
-  _handleEnterByKey(e: KeyboardEvent) {
-    // Enter should have newline behavior if the picker is closed or if the user
-    // has only typed ':'. Also make sure that shortcuts aren't clobbered.
-    if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+  // private but used in test
+  handleEnterByKey(e: KeyboardEvent) {
+    // Enter should have newline behavior if the picker is closed. Also make
+    // sure that shortcuts aren't clobbered.
+    if (!this.isDropdownVisible()) {
       this.indent(e);
       return;
     }
 
     e.preventDefault();
     e.stopPropagation();
-    this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+    this.setValue(this.getVisibleDropdown().getCurrentText());
   }
 
-  _handleEmojiSelect(e: CustomEvent<ItemSelectedEvent>) {
+  // private but used in test
+  handleDropdownItemSelect(e: CustomEvent<ItemSelectedEvent>) {
     if (e.detail.selected?.dataset['value']) {
-      this._setEmoji(e.detail.selected?.dataset['value']);
+      this.setValue(e.detail.selected?.dataset['value']);
     }
   }
 
-  _setEmoji(text: string) {
-    if (this._colonIndex === null) {
+  private async setValue(text: string) {
+    if (this.specialCharIndex === -1) {
       return;
     }
-    const colonIndex = this._colonIndex;
-    this.text = this._getText(text);
-    this.$.textarea.selectionStart = colonIndex + 1;
-    this.$.textarea.selectionEnd = colonIndex + 1;
-    this.reporting.reportInteraction('select-emoji', {type: text});
-    this._resetEmojiDropdown();
+    const specialCharIndex = this.specialCharIndex;
+    if (this.isEmojiDropdownActive()) {
+      this.text = this.addValueToText(text);
+      this.reporting.reportInteraction('select-emoji', {type: text});
+    } else {
+      this.text = this.addValueToText('@' + text);
+      this.reporting.reportInteraction('select-mention', {type: text});
+    }
+    // iron-autogrow-textarea unfortunately sets the cursor at the end when
+    // it's value is changed, which means the setting of selectionStart
+    // below needs to happen after iron-autogrow-textarea has set the
+    // incorrect value.
+    await this.updateComplete;
+    this.textarea!.selectionStart = specialCharIndex + 1;
+    this.textarea!.selectionEnd = specialCharIndex + 1;
+    this.resetDropdown();
   }
 
-  _getText(value: string) {
+  private addValueToText(value: string) {
     if (!this.text) return '';
     return (
-      this.text.substr(0, this._colonIndex || 0) +
+      this.text.substr(0, this.specialCharIndex ?? 0) +
       value +
-      this.text.substr(this.$.textarea.selectionStart)
+      this.text.substr(this.textarea!.selectionStart)
     );
   }
 
@@ -292,136 +422,227 @@
    * the text up until the point of interest. Then caratSpan element is added
    * to the end and is set to be the positionTarget for the dropdown. Together
    * this allows the dropdown to appear near where the user is typing.
+   * private but used in test
    */
-  _updateCaratPosition() {
-    this._hideEmojiAutocomplete = false;
-    if (typeof this.$.textarea.value === 'string') {
-      this.$.hiddenText.textContent = this.$.textarea.value.substr(
+  updateCaratPosition() {
+    if (typeof this.textarea!.value === 'string') {
+      this.hiddenText!.textContent = this.textarea!.value.substr(
         0,
-        this.$.textarea.selectionStart
+        this.textarea!.selectionStart
       );
     }
 
-    const caratSpan = this.$.caratSpan;
-    this.$.hiddenText.appendChild(caratSpan);
-    this.$.emojiSuggestions.positionTarget = caratSpan;
-    this._openEmojiDropdown();
+    const caratSpan = this.caratSpan!;
+    this.hiddenText!.appendChild(caratSpan);
+    return caratSpan;
   }
 
-  /**
-   * _handleKeydown used for key handling in the this.$.textarea AND all child
-   * autocomplete options.
-   */
-  _onValueChanged(e: BindValueChangeEvent) {
-    // Relay the event.
-    this.dispatchEvent(
-      new CustomEvent('bind-value-changed', {
-        detail: e,
-        composed: true,
-        bubbles: true,
-      })
-    );
-
-    // If cursor is not in textarea (just opened with colon as last char),
-    // Don't do anything.
-    if (
-      e.currentTarget === null ||
-      !(e.currentTarget as IronAutogrowTextareaElement).focused
-    ) {
-      return;
-    }
-
-    const charAtCursor =
-      e.detail && e.detail.value
-        ? e.detail.value[this.$.textarea.selectionStart - 1]
-        : '';
-    if (charAtCursor !== ':' && this._colonIndex === null) {
-      return;
-    }
-
-    // When a colon is detected, set a colon index. We are interested only on
-    // colons after space or in beginning of textarea
-    if (charAtCursor === ':') {
-      if (
-        this.$.textarea.selectionStart < 2 ||
-        e.detail.value[this.$.textarea.selectionStart - 2] === ' '
-      ) {
-        this._colonIndex = this.$.textarea.selectionStart - 1;
-      }
-    }
-    if (this._colonIndex === null) {
-      return;
-    }
-
-    this._currentSearchString = e.detail.value.substr(
-      this._colonIndex + 1,
-      this.$.textarea.selectionStart - this._colonIndex - 1
-    );
-    // Under the following conditions, close and reset the dropdown:
+  private shouldResetDropdown(
+    text: string,
+    charIndex: number,
+    suggestions?: Item[],
+    char?: string
+  ) {
+    // Under any of the following conditions, close and reset the dropdown:
     // - The cursor is no longer at the end of the current search string
     // - The search string is an space or new line
     // - The colon has been removed
     // - There are no suggestions that match the search string
-    if (
-      this.$.textarea.selectionStart !==
-        this._currentSearchString.length + this._colonIndex + 1 ||
-      this._currentSearchString === ' ' ||
-      this._currentSearchString === '\n' ||
-      !(e.detail.value[this._colonIndex] === ':') ||
-      !this._suggestions ||
-      !this._suggestions.length
-    ) {
-      this._resetEmojiDropdown();
-      // Otherwise open the dropdown and set the position to be just below the
-      // cursor.
-    } else if (this.$.emojiSuggestions.isHidden) {
-      this._updateCaratPosition();
-    }
-    this.$.textarea.textarea.focus();
+    return (
+      this.textarea!.selectionStart !==
+        (this.currentSearchString ?? '').length + charIndex + 1 ||
+      this.currentSearchString === ' ' ||
+      this.currentSearchString === '\n' ||
+      !(text[charIndex] === char) ||
+      !suggestions ||
+      !suggestions.length
+    );
   }
 
-  _openEmojiDropdown() {
-    this.$.emojiSuggestions.open();
+  // When special char is detected, set index. We are interested only on
+  // special char after space or in beginning of textarea
+  // In case of mentions we are interested if previous char is '\n' as well
+  private getSpecialCharIndex(text: string) {
+    const charAtCursor = text[this.textarea!.selectionStart - 1];
+    if (
+      this.textarea!.selectionStart < 2 ||
+      text[this.textarea!.selectionStart - 2] === ' '
+    ) {
+      return this.textarea!.selectionStart - 1;
+    }
+    if (
+      charAtCursor === '@' &&
+      text[this.textarea!.selectionStart - 2] === '\n'
+    ) {
+      return this.textarea!.selectionStart - 1;
+    }
+    return -1;
+  }
+
+  private async computeSuggestions() {
+    if (this.currentSearchString === undefined) {
+      this.suggestions = [];
+      return;
+    }
+    if (this.isEmojiDropdownActive()) {
+      this.computeEmojiSuggestions(this.currentSearchString);
+    } else if (this.isMentionsDropdownActive()) {
+      await this.computeReviewerSuggestions();
+    }
+  }
+
+  private openOrResetDropdown() {
+    let activeDropdown: GrAutocompleteDropdown;
+    let activate: () => void;
+    if (this.isEmojiDropdownActive()) {
+      activeDropdown = this.emojiSuggestions!;
+      activate = () => this.openEmojiDropdown();
+    } else if (this.isMentionsDropdownActive()) {
+      activeDropdown = this.mentionsSuggestions!;
+      activate = () => this.openMentionsDropdown();
+    } else {
+      this.resetDropdown();
+      return;
+    }
+
+    if (
+      this.shouldResetDropdown(
+        this.text,
+        this.specialCharIndex,
+        this.suggestions,
+        this.text[this.specialCharIndex]
+      )
+    ) {
+      this.resetDropdown();
+    } else if (activeDropdown!.isHidden && this.textarea!.focused) {
+      // Otherwise open the dropdown and set the position to be just below the
+      // cursor.
+      // Do not open dropdown if textarea is not focused
+      activeDropdown.setPositionTarget(this.updateCaratPosition());
+      activate();
+    }
+  }
+
+  private isMentionsDropdownActive() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
+      return false;
+    return (
+      this.specialCharIndex !== -1 && this.text[this.specialCharIndex] === '@'
+    );
+  }
+
+  private isEmojiDropdownActive() {
+    return (
+      this.specialCharIndex !== -1 && this.text[this.specialCharIndex] === ':'
+    );
+  }
+
+  private computeSpecialCharIndex() {
+    const charAtCursor = this.text[this.textarea!.selectionStart - 1];
+
+    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+      if (charAtCursor === '@' && this.specialCharIndex === -1) {
+        this.specialCharIndex = this.getSpecialCharIndex(this.text);
+      }
+    }
+    if (charAtCursor === ':' && this.specialCharIndex === -1) {
+      this.specialCharIndex = this.getSpecialCharIndex(this.text);
+    }
+  }
+
+  private computeCurrentSearchString() {
+    if (this.specialCharIndex === -1) {
+      this.currentSearchString = undefined;
+      return;
+    }
+    this.currentSearchString = this.text.substr(
+      this.specialCharIndex + 1,
+      this.textarea!.selectionStart - this.specialCharIndex - 1
+    );
+  }
+
+  // Private but used in tests.
+  async handleTextChanged() {
+    await this.computeSuggestions();
+    this.openOrResetDropdown();
+    this.focus();
+  }
+
+  private openEmojiDropdown() {
+    this.emojiSuggestions!.open();
     this.reporting.reportInteraction('open-emoji-dropdown');
   }
 
-  _formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
+  private openMentionsDropdown() {
+    this.mentionsSuggestions!.open();
+    this.reporting.reportInteraction('open-mentions-dropdown');
+  }
+
+  // private but used in test
+  formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
     const suggestions = [];
     for (const suggestion of matchedSuggestions) {
+      assert(isEmojiSuggestion(suggestion), 'malformed suggestion');
       suggestion.dataValue = suggestion.value;
       suggestion.text = `${suggestion.value} ${suggestion.match}`;
       suggestions.push(suggestion);
     }
-    this.set('_suggestions', suggestions);
+    this.suggestions = suggestions;
   }
 
-  _determineSuggestions(emojiText: string) {
-    if (!emojiText.length) {
-      this._formatSuggestions(ALL_SUGGESTIONS);
-      this.disableEnterKeyForSelectingEmoji = true;
+  // private but used in test
+  computeEmojiSuggestions(suggestionsText?: string) {
+    if (suggestionsText === undefined) {
+      this.suggestions = [];
+      return;
+    }
+    if (!suggestionsText.length) {
+      this.formatSuggestions(ALL_SUGGESTIONS);
     } else {
       const matches = ALL_SUGGESTIONS.filter(suggestion =>
-        suggestion.match.includes(emojiText)
+        suggestion.match.includes(suggestionsText)
       ).slice(0, MAX_ITEMS_DROPDOWN);
-      this._formatSuggestions(matches);
-      this.disableEnterKeyForSelectingEmoji = false;
+      this.formatSuggestions(matches);
     }
   }
 
-  _resetEmojiDropdown() {
-    // hide and reset the autocomplete dropdown.
-    flush();
-    this._currentSearchString = '';
-    this._hideEmojiAutocomplete = true;
-    this.closeDropdown();
-    this._colonIndex = null;
-    this.$.textarea.textarea.focus();
+  // TODO(dhruvsri): merge with getAccountSuggestions in account-util
+  async computeReviewerSuggestions() {
+    this.suggestions = (
+      (await this.restApiService.getSuggestedAccounts(
+        this.currentSearchString ?? '',
+        /* number= */ 15,
+        this.changeNum,
+        /* filterActive= */ true
+      )) ?? []
+    )
+      .filter(account => account.email)
+      .map(account => {
+        return {
+          text: `${getAccountDisplayName(this.serverConfig, account)}`,
+          dataValue: account.email,
+        };
+      });
   }
 
-  _handleTextChanged(text: string) {
-    this.dispatchEvent(
-      new CustomEvent('value-changed', {detail: {value: text}})
-    );
+  // private but used in test
+  resetDropdown() {
+    // hide and reset the autocomplete dropdown.
+    this.requestUpdate();
+    this.currentSearchString = '';
+    this.closeDropdown();
+    this.specialCharIndex = -1;
+    this.textarea?.textarea.focus();
+  }
+
+  private fireChangedEvents() {
+    // This is a bit redundant, because the `text` property has `notify:true`,
+    // so whenever the `text` changes the component fires two identical events
+    // `text-changed` and `value-changed`.
+    fire(this, 'value-changed', {value: this.text});
+    fire(this, 'text-changed', {value: this.text});
+    // Relay the event.
+    fire(this, 'bind-value-changed', {value: this.text});
   }
 
   private indent(e: KeyboardEvent): void {
@@ -431,8 +652,10 @@
     // When nothing is selected, selectionStart is the caret position. We want
     // the indentation level of the current line, not the end of the text which
     // may be different.
-    const currentLine = this.$.textarea.textarea.value
-      .substr(0, this.$.textarea.selectionStart)
+    const currentLine = this.textarea!.textarea.value.substr(
+      0,
+      this.textarea!.selectionStart
+    )
       .split('\n')
       .pop();
     const currentLineIndentation = currentLine?.match(/^\s*/)?.[0];
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
deleted file mode 100644
index d55481b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_html.ts
+++ /dev/null
@@ -1,94 +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: flex;
-      position: relative;
-    }
-    :host(.monospace) {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      font-weight: var(--font-weight-normal);
-    }
-    :host(.code) {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-code);
-      /* usually 16px = 12px + 4px */
-      line-height: calc(var(--font-size-code) + var(--spacing-s));
-      font-weight: var(--font-weight-normal);
-    }
-    #emojiSuggestions {
-      font-family: var(--font-family);
-    }
-    gr-autocomplete {
-      display: inline-block;
-    }
-    #textarea {
-      background-color: var(--view-background-color);
-      width: 100%;
-    }
-    #hiddenText #emojiSuggestions {
-      visibility: visible;
-      white-space: normal;
-    }
-    iron-autogrow-textarea {
-      position: relative;
-    }
-    #textarea.noBorder {
-      border: none;
-    }
-    #hiddenText {
-      display: block;
-      float: left;
-      position: absolute;
-      visibility: hidden;
-      width: 100%;
-      white-space: pre-wrap;
-    }
-  </style>
-  <div id="hiddenText"></div>
-  <!-- When the autocomplete is open, the span is moved at the end of
-      hiddenText in order to correctly position the dropdown. After being moved,
-      it is set as the positionTarget for the emojiSuggestions dropdown. -->
-  <span id="caratSpan"></span>
-  <gr-autocomplete-dropdown
-    vertical-align="top"
-    horizontal-align="left"
-    dynamic-align=""
-    id="emojiSuggestions"
-    suggestions="[[_suggestions]]"
-    index="[[_index]]"
-    vertical-offset="[[_verticalOffset]]"
-    on-dropdown-closed="_resetEmojiDropdown"
-    on-item-selected="_handleEmojiSelect"
-  >
-  </gr-autocomplete-dropdown>
-  <iron-autogrow-textarea
-    id="textarea"
-    autocomplete="[[autocomplete]]"
-    placeholder="[[placeholder]]"
-    disabled="[[disabled]]"
-    rows="[[rows]]"
-    max-rows="[[maxRows]]"
-    value="{{text}}"
-    on-bind-value-changed="_onValueChanged"
-  ></iron-autogrow-textarea>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 318c720..f8ae38c 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -1,43 +1,325 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-textarea';
 import {GrTextarea} from './gr-textarea';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-
-const basicFixture = fixtureFromElement('gr-textarea');
-
-const monospaceFixture = fixtureFromTemplate(html`
-  <gr-textarea monospace="true"></gr-textarea>
-`);
-
-const hideBorderFixture = fixtureFromTemplate(html`
-  <gr-textarea hide-border="true"></gr-textarea>
-`);
+import {
+  pressKey,
+  stubFlags,
+  stubRestApi,
+  waitUntil,
+} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
+import {createAccountWithEmail} from '../../../test/test-data-generators';
+import {Key} from '../../../utils/dom-util';
 
 suite('gr-textarea tests', () => {
   let element: GrTextarea;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture<GrTextarea>(html`<gr-textarea></gr-textarea>`);
     sinon.stub(element.reporting, 'reportInteraction');
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<div id="hiddenText"></div>
+        <span id="caratSpan"> </span>
+        <gr-autocomplete-dropdown
+          id="emojiSuggestions"
+          is-hidden=""
+          style="position: fixed; top: 150px; left: 392.5px; box-sizing: border-box; max-height: 300px; max-width: 785px;"
+        >
+        </gr-autocomplete-dropdown>
+        <iron-autogrow-textarea aria-disabled="false" focused="" id="textarea">
+        </iron-autogrow-textarea> `,
+      {
+        // gr-autocomplete-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [
+          {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+        ],
+      }
+    );
+  });
+
+  suite('mention users', () => {
+    setup(async () => {
+      stubFlags('isEnabled').returns(true);
+      element.requestUpdate();
+      await element.updateComplete;
+    });
+
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div id="hiddenText"></div>
+          <span id="caratSpan"> </span>
+          <gr-autocomplete-dropdown
+            id="emojiSuggestions"
+            is-hidden=""
+            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
+          >
+          </gr-autocomplete-dropdown>
+          <gr-autocomplete-dropdown
+            id="mentionsSuggestions"
+            is-hidden=""
+            role="listbox"
+            style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
+          >
+          </gr-autocomplete-dropdown>
+          <iron-autogrow-textarea
+            focused=""
+            aria-disabled="false"
+            id="textarea"
+          >
+          </iron-autogrow-textarea>
+        `,
+        {
+          // gr-autocomplete-dropdown sizing seems to vary between local & CI
+          ignoreAttributes: [
+            {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+          ],
+        }
+      );
+    });
+
+    test('mentions selector is open when @ is typed & the textarea has focus', async () => {
+      // Needed for Safari tests. selectionStart is not updated when text is
+      // updated.
+      const listenerStub = sinon.stub();
+      element.addEventListener('bind-value-changed', listenerStub);
+      stubRestApi('getSuggestedAccounts').returns(
+        Promise.resolve([
+          createAccountWithEmail('abc@google.com'),
+          createAccountWithEmail('abcdef@google.com'),
+        ])
+      );
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '@';
+
+      await waitUntil(() => element.suggestions.length > 0);
+      await element.updateComplete;
+
+      assert.equal(listenerStub.lastCall.args[0].detail.value, '@');
+      assert.isTrue(element.textarea!.focused);
+
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      assert.equal(element.specialCharIndex, 0);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+      assert.equal(element.currentSearchString, '');
+
+      element.text = '@abc@google.com';
+      await element.updateComplete;
+
+      assert.equal(element.currentSearchString, 'abc@google.com');
+      assert.equal(element.specialCharIndex, 0);
+    });
+
+    test('mention selector opens when previous char is \n', async () => {
+      stubRestApi('getSuggestedAccounts').returns(
+        Promise.resolve([
+          {
+            ...createAccountWithEmail('abc@google.com'),
+            name: 'A',
+            display_name: 'display A',
+          },
+          {...createAccountWithEmail('abcdef@google.com'), name: 'B'},
+        ])
+      );
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '\n@';
+
+      await waitUntil(() => element.suggestions.length > 0);
+      await element.updateComplete;
+
+      assert.deepEqual(element.suggestions, [
+        {
+          dataValue: 'abc@google.com',
+          text: 'display A <abc@google.com>',
+        },
+        {
+          dataValue: 'abcdef@google.com',
+          text: 'B <abcdef@google.com>',
+        },
+      ]);
+
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+    });
+
+    test('emoji selector does not open when previous char is \n', async () => {
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '\n:';
+
+      await element.updateComplete;
+
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+    });
+
+    test('selecting mentions from dropdown', async () => {
+      stubRestApi('getSuggestedAccounts').returns(
+        Promise.resolve([
+          createAccountWithEmail('abc@google.com'),
+          createAccountWithEmail('abcdef@google.com'),
+        ])
+      );
+
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '@';
+
+      await waitUntil(() => element.suggestions.length > 0);
+      await element.updateComplete;
+
+      pressKey(element, 'ArrowDown');
+      await element.updateComplete;
+
+      pressKey(element, 'ArrowDown');
+      await element.updateComplete;
+
+      pressKey(element, Key.ENTER);
+      await element.updateComplete;
+
+      assert.equal(element.text, '@abcdef@google.com');
+    });
+
+    test('emoji dropdown does not open if mention dropdown is open', async () => {
+      const listenerStub = sinon.stub();
+      element.addEventListener('bind-value-changed', listenerStub);
+      const resetSpy = sinon.spy(element, 'resetDropdown');
+      stubRestApi('getSuggestedAccounts').returns(
+        Promise.resolve([
+          createAccountWithEmail('abc@google.com'),
+          createAccountWithEmail('abcdef@google.com'),
+        ])
+      );
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '@';
+      element.suggestions = [
+        {
+          name: 'a',
+          value: 'a',
+        },
+      ];
+      await waitUntil(() => element.suggestions.length > 0);
+      await element.updateComplete;
+
+      assert.isFalse(resetSpy.called);
+
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      element.text = '@h';
+      await element.updateComplete;
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      element.text = '@h ';
+      await element.updateComplete;
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      element.text = '@h :';
+      await element.updateComplete;
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      element.text = '@h :D';
+      await element.updateComplete;
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+    });
+
+    test('mention dropdown does not open if emoji dropdown is open', async () => {
+      const listenerStub = sinon.stub();
+      element.addEventListener('bind-value-changed', listenerStub);
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = ':';
+      element.suggestions = [
+        {
+          name: 'a',
+          value: 'a',
+        },
+      ];
+
+      await element.updateComplete;
+      assert.isFalse(element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+
+      element.text = ':D';
+      await element.updateComplete;
+      assert.isFalse(element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+
+      element.text = ':D@';
+      await element.updateComplete;
+      // emoji dropdown hidden since we have no more suggestions
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+
+      element.text = ':D@b';
+      await element.updateComplete;
+      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+    });
+
+    test('mention dropdown is cleared if @ is deleted', async () => {
+      stubRestApi('getSuggestedAccounts').returns(
+        Promise.resolve([
+          createAccountWithEmail('abc@google.com'),
+          createAccountWithEmail('abcdef@google.com'),
+        ])
+      );
+
+      element.textarea!.focus();
+      await waitUntil(() => element.textarea!.focused === true);
+
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
+      element.text = '@';
+
+      await waitUntil(() => element.suggestions.length > 0);
+      await element.updateComplete;
+
+      assert.isFalse(element.mentionsSuggestions!.isHidden);
+
+      element.text = '';
+      await element.updateComplete;
+      assert.isTrue(element.mentionsSuggestions!.isHidden);
+    });
   });
 
   test('monospace is set properly', () => {
@@ -45,141 +327,169 @@
   });
 
   test('hideBorder is set properly', () => {
-    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
+    assert.isFalse(element.textarea!.classList.contains('noBorder'));
   });
 
-  test('emoji selector is not open with the textarea lacks focus', () => {
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
+  test('emoji selector is not open when the textarea lacks focus', async () => {
+    // by default textarea has focus when rendered
+    // explicitly remove focus from the element for the test
+    element.blur();
+    element.textarea!.selectionStart = 1;
+    element.textarea!.selectionEnd = 1;
     element.text = ':';
-    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+    await element.updateComplete;
+    assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
-  test('emoji selector is not open when a general text is entered', () => {
-    MockInteractions.focus(element.$.textarea);
-    element.$.textarea.selectionStart = 9;
-    element.$.textarea.selectionEnd = 9;
+  test('emoji selector is not open when a general text is entered', async () => {
+    element.textarea!.focus();
+    await waitUntil(() => element.textarea!.focused === true);
+    element.textarea!.selectionStart = 9;
+    element.textarea!.selectionEnd = 9;
     element.text = 'some text';
-    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+    await element.updateComplete;
+    assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
-  test('emoji selector opens when a colon is typed & the textarea has focus', () => {
-    MockInteractions.focus(element.$.textarea);
+  test('emoji selector is open when a colon is typed & the textarea has focus', async () => {
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
+    const listenerStub = sinon.stub();
+    element.addEventListener('bind-value-changed', listenerStub);
+    element.textarea!.focus();
+    await waitUntil(() => element.textarea!.focused === true);
+    element.textarea!.selectionStart = 1;
+    element.textarea!.selectionEnd = 1;
     element.text = ':';
-    flush();
-    assert.isFalse(element.$.emojiSuggestions.isHidden);
-    assert.equal(element._colonIndex, 0);
-    assert.isFalse(element._hideEmojiAutocomplete);
-    assert.equal(element._currentSearchString, '');
+    await element.updateComplete;
+    assert.equal(listenerStub.lastCall.args[0].detail.value, ':');
+    assert.isTrue(element.textarea!.focused);
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+    assert.equal(element.specialCharIndex, 0);
+    assert.isTrue(!element.emojiSuggestions!.isHidden);
+    assert.equal(element.currentSearchString, '');
   });
 
-  test('emoji selector opens when a colon is typed after space', () => {
-    MockInteractions.focus(element.$.textarea);
+  test('emoji selector opens when a colon is typed after space', async () => {
+    element.textarea!.focus();
+    await waitUntil(() => element.textarea!.focused === true);
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.$.textarea.selectionStart = 2;
-    element.$.textarea.selectionEnd = 2;
+    element.textarea!.selectionStart = 2;
+    element.textarea!.selectionEnd = 2;
     element.text = ' :';
-    flush();
-    assert.isFalse(element.$.emojiSuggestions.isHidden);
-    assert.equal(element._colonIndex, 1);
-    assert.isFalse(element._hideEmojiAutocomplete);
-    assert.equal(element._currentSearchString, '');
+    await element.updateComplete;
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+    assert.equal(element.specialCharIndex, 1);
+    assert.isTrue(!element.emojiSuggestions!.isHidden);
+    assert.equal(element.currentSearchString, '');
   });
 
-  test('emoji selector doesn`t open when a colon is typed after character', () => {
-    MockInteractions.focus(element.$.textarea);
+  test('emoji selector doesn`t open when a colon is typed after character', async () => {
+    element.textarea!.focus();
+    await waitUntil(() => element.textarea!.focused === true);
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.$.textarea.selectionStart = 5;
-    element.$.textarea.selectionEnd = 5;
+    element.textarea!.selectionStart = 5;
+    element.textarea!.selectionEnd = 5;
     element.text = 'test:';
-    flush();
-    assert.isTrue(element.$.emojiSuggestions.isHidden);
-    assert.isTrue(element._hideEmojiAutocomplete);
+    await element.updateComplete;
+    assert.isTrue(element.emojiSuggestions!.isHidden);
+    assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
-  test('emoji selector opens when a colon is typed and some substring', () => {
-    MockInteractions.focus(element.$.textarea);
+  test('emoji selector opens when a colon is typed and some substring', async () => {
+    element.textarea!.focus();
+    await waitUntil(() => element.textarea!.focused === true);
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
+    element.textarea!.selectionStart = 1;
+    element.textarea!.selectionEnd = 1;
     element.text = ':';
-    element.$.textarea.selectionStart = 2;
-    element.$.textarea.selectionEnd = 2;
+    await element.updateComplete;
+    element.textarea!.selectionStart = 2;
+    element.textarea!.selectionEnd = 2;
     element.text = ':t';
-    flush();
-    assert.isFalse(element.$.emojiSuggestions.isHidden);
-    assert.equal(element._colonIndex, 0);
-    assert.isFalse(element._hideEmojiAutocomplete);
-    assert.equal(element._currentSearchString, 't');
+    await element.updateComplete;
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+    assert.equal(element.specialCharIndex, 0);
+    assert.isTrue(!element.emojiSuggestions!.isHidden);
+    assert.equal(element.currentSearchString, 't');
   });
 
-  test('emoji selector opens when a colon is typed in middle of text', () => {
-    MockInteractions.focus(element.$.textarea);
+  test('emoji selector opens when a colon is typed in middle of text', async () => {
+    element.textarea!.focus();
     // Needed for Safari tests. selectionStart is not updated when text is
     // updated.
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
+    element.textarea!.selectionStart = 1;
+    element.textarea!.selectionEnd = 1;
     // Since selectionStart is on Chrome set always on end of text, we
     // stub it to 1
     const text = ': hello';
-    sinon.stub(element.$, 'textarea').value({
+    sinon.stub(element, 'textarea').value({
       selectionStart: 1,
       value: text,
+      focused: true,
       textarea: {
         focus: () => {},
       },
     });
     element.text = text;
-    flush();
-    assert.isFalse(element.$.emojiSuggestions.isHidden);
-    assert.equal(element._colonIndex, 0);
-    assert.isFalse(element._hideEmojiAutocomplete);
-    assert.equal(element._currentSearchString, '');
+    await element.updateComplete;
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+    assert.equal(element.specialCharIndex, 0);
+    assert.isTrue(!element.emojiSuggestions!.isHidden);
+    assert.equal(element.currentSearchString, '');
   });
-  test('emoji selector closes when text changes before the colon', () => {
-    const resetStub = sinon.stub(element, '_resetEmojiDropdown');
-    MockInteractions.focus(element.$.textarea);
-    flush();
-    element.$.textarea.selectionStart = 10;
-    element.$.textarea.selectionEnd = 10;
+
+  test('emoji selector closes when text changes before the colon', async () => {
+    element.textarea!.focus();
+    await waitUntil(() => element.textarea!.focused === true);
+    await element.updateComplete;
+    element.textarea!.selectionStart = 10;
+    element.textarea!.selectionEnd = 10;
     element.text = 'test test ';
-    element.$.textarea.selectionStart = 12;
-    element.$.textarea.selectionEnd = 12;
-    element.text = 'test test :';
-    element.$.textarea.selectionStart = 15;
-    element.$.textarea.selectionEnd = 15;
-    element.text = 'test test :smi';
+    await element.updateComplete;
+    element.textarea!.selectionStart = 12;
+    element.textarea!.selectionEnd = 12;
 
-    assert.equal(element._currentSearchString, 'smi');
-    assert.isFalse(resetStub.called);
+    element.text = 'test test :';
+    await element.updateComplete;
+
+    // typing : opens the selector
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+
+    element.textarea!.selectionStart = 15;
+    element.textarea!.selectionEnd = 15;
+    element.text = 'test test :smi';
+    await element.updateComplete;
+
+    assert.equal(element.currentSearchString, 'smi');
+    assert.isFalse(element.emojiSuggestions!.isHidden);
+
     element.text = 'test test test :smi';
-    assert.isTrue(resetStub.called);
+    await element.updateComplete;
+
+    assert.isTrue(element.emojiSuggestions!.isHidden);
   });
 
-  test('_resetEmojiDropdown', () => {
+  test('resetDropdown', async () => {
     const closeSpy = sinon.spy(element, 'closeDropdown');
-    element._resetEmojiDropdown();
-    assert.equal(element._currentSearchString, '');
-    assert.isTrue(element._hideEmojiAutocomplete);
-    assert.equal(element._colonIndex, null);
+    element.resetDropdown();
+    assert.equal(element.currentSearchString, '');
+    assert.isTrue(element.emojiSuggestions!.isHidden);
+    assert.equal(element.specialCharIndex, -1);
 
-    element.$.emojiSuggestions.open();
-    flush();
-    element._resetEmojiDropdown();
+    element.emojiSuggestions!.open();
+    await element.updateComplete;
+    element.resetDropdown();
     assert.isTrue(closeSpy.called);
   });
 
-  test('_determineSuggestions', () => {
+  test('determineEmojiSuggestions', () => {
     const emojiText = 'tear';
-    const formatSpy = sinon.spy(element, '_formatSuggestions');
-    element._determineSuggestions(emojiText);
+    const formatSpy = sinon.spy(element, 'formatSuggestions');
+    element.computeEmojiSuggestions(emojiText);
     assert.isTrue(formatSpy.called);
     assert.isTrue(
       formatSpy.lastCall.calledWithExactly([
@@ -194,192 +504,129 @@
     );
   });
 
-  test('_formatSuggestions', () => {
+  test('formatSuggestions', () => {
     const matchedSuggestions = [
       {value: '😢', match: 'tear'},
       {value: '😂', match: 'tears'},
     ];
-    element._formatSuggestions(matchedSuggestions);
+    element.formatSuggestions(matchedSuggestions);
     assert.deepEqual(
       [
         {value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
         {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'},
       ],
-      element._suggestions
+      element.suggestions
     );
   });
 
-  test('_handleEmojiSelect', () => {
-    element.$.textarea.selectionStart = 16;
-    element.$.textarea.selectionEnd = 16;
+  test('handleDropdownItemSelect', async () => {
+    element.textarea!.selectionStart = 16;
+    element.textarea!.selectionEnd = 16;
     element.text = 'test test :tears';
-    element._colonIndex = 10;
+    element.specialCharIndex = 10;
+    await element.updateComplete;
     const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
     const event = new CustomEvent<ItemSelectedEvent>('item-selected', {
       detail: {trigger: 'click', selected: selectedItem},
     });
-    element._handleEmojiSelect(event);
+    element.handleDropdownItemSelect(event);
     assert.equal(element.text, 'test test 😂');
   });
 
-  test('_updateCaratPosition', () => {
-    element.$.textarea.selectionStart = 4;
-    element.$.textarea.selectionEnd = 4;
+  test('updateCaratPosition', async () => {
+    element.textarea!.selectionStart = 4;
+    element.textarea!.selectionEnd = 4;
     element.text = 'test';
-    element._updateCaratPosition();
+    await element.updateComplete;
+    element.updateCaratPosition();
     assert.deepEqual(
-      element.$.hiddenText.innerHTML,
-      element.text + element.$.caratSpan.outerHTML
+      element.hiddenText!.innerHTML,
+      element.text + element.caratSpan!.outerHTML
     );
   });
 
   test('newline receives matching indentation', async () => {
     const indentCommand = sinon.stub(document, 'execCommand');
-    element.$.textarea.value = '    a';
-    element._handleEnterByKey(
-      new KeyboardEvent('keydown', {key: 'Enter', keyCode: 13})
-    );
-    await flush();
+    element.textarea!.value = '    a';
+    element.handleEnterByKey(new KeyboardEvent('keydown', {key: 'Enter'}));
+    await element.updateComplete;
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
   });
 
-  test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
-    const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
-    element.$.emojiSuggestions.dispatchEvent(
+  test('emoji dropdown is closed when dropdown-closed is fired', async () => {
+    const resetSpy = sinon.spy(element, 'closeDropdown');
+    element.emojiSuggestions!.dispatchEvent(
       new CustomEvent('dropdown-closed', {
         composed: true,
         bubbles: true,
       })
     );
+    await element.updateComplete;
     assert.isTrue(resetSpy.called);
   });
 
-  test('_onValueChanged fires bind-value-changed', () => {
-    const listenerStub = sinon.stub();
-    const eventObject = new CustomEvent('bind-value-changed', {
-      detail: {currentTarget: {focused: false}, value: ''},
-    });
-    element.addEventListener('bind-value-changed', listenerStub);
-    element._onValueChanged(eventObject);
-    assert.isTrue(listenerStub.called);
-  });
-
-  suite('keyboard shortcuts', () => {
-    function setupDropdown() {
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
+  suite('keyboard shortcuts', async () => {
+    async function setupDropdown() {
+      element.textarea!.focus();
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 1;
       element.text = ':';
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 2;
+      await element.updateComplete;
+      element.textarea!.selectionStart = 1;
+      element.textarea!.selectionEnd = 2;
       element.text = ':1';
-      flush();
+      await element.emojiSuggestions!.updateComplete;
+      await element.updateComplete;
     }
 
-    test('escape key', () => {
-      const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
-      MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
-        27,
-        null,
-        'Escape'
-      );
+    test('escape key', async () => {
+      const resetSpy = sinon.spy(element, 'resetDropdown');
+      pressKey(element.textarea! as HTMLElement, Key.ESC);
       assert.isFalse(resetSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
-        27,
-        null,
-        'Escape'
-      );
+      await setupDropdown();
+      pressKey(element.textarea! as HTMLElement, Key.ESC);
       assert.isTrue(resetSpy.called);
-      assert.isFalse(!element.$.emojiSuggestions.isHidden);
+      assert.isTrue(element.emojiSuggestions!.isHidden);
     });
 
-    test('up key', () => {
-      const upSpy = sinon.spy(element.$.emojiSuggestions, 'cursorUp');
-      MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
-        38,
-        null,
-        'ArrowUp'
-      );
+    test('up key', async () => {
+      const upSpy = sinon.spy(element.emojiSuggestions!, 'cursorUp');
+      pressKey(element.textarea! as HTMLElement, 'ArrowUp');
       assert.isFalse(upSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
-        38,
-        null,
-        'ArrowUp'
-      );
+      await setupDropdown();
+      pressKey(element.textarea! as HTMLElement, 'ArrowUp');
       assert.isTrue(upSpy.called);
     });
 
-    test('down key', () => {
-      const downSpy = sinon.spy(element.$.emojiSuggestions, 'cursorDown');
-      MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
-        40,
-        null,
-        'ArrowDown'
-      );
+    test('down key', async () => {
+      const downSpy = sinon.spy(element.emojiSuggestions!, 'cursorDown');
+      pressKey(element.textarea! as HTMLElement, 'ArrowDown');
       assert.isFalse(downSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
-        40,
-        null,
-        'ArrowDown'
-      );
+      await setupDropdown();
+      pressKey(element.textarea! as HTMLElement, 'ArrowDown');
       assert.isTrue(downSpy.called);
     });
 
-    test('enter key', () => {
-      const enterSpy = sinon.spy(element.$.emojiSuggestions, 'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
-        13,
-        null,
-        'Enter'
-      );
+    test('enter key', async () => {
+      const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
+      pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isFalse(enterSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(
-        element.$.textarea,
-        13,
-        null,
-        'Enter'
-      );
+      await setupDropdown();
+      pressKey(element.textarea! as HTMLElement, Key.ENTER);
       assert.isTrue(enterSpy.called);
-      flush();
+      await element.updateComplete;
       assert.equal(element.text, '💯');
     });
-
-    test('enter key - ignored on just colon without more information', () => {
-      const enterSpy = sinon.spy(element.$.emojiSuggestions, 'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
-      element.text = ':';
-      flush();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-    });
   });
 
   suite('gr-textarea monospace', () => {
-    // gr-textarea set monospace class in the ready() method.
-    // In Polymer2, ready() is called from the fixture(...) method,
-    // If ready() is called again later, some nested elements doesn't
-    // handle it correctly. A separate test-fixture is used to set
-    // properties before ready() is called.
-
     let element: GrTextarea;
 
-    setup(() => {
-      element = monospaceFixture.instantiate() as GrTextarea;
+    setup(async () => {
+      element = await fixture<GrTextarea>(
+        html`<gr-textarea monospace></gr-textarea>`
+      );
+      await element.updateComplete;
     });
 
     test('monospace is set properly', () => {
@@ -388,20 +635,17 @@
   });
 
   suite('gr-textarea hideBorder', () => {
-    // gr-textarea set noBorder class in the ready() method.
-    // In Polymer2, ready() is called from the fixture(...) method,
-    // If ready() is called again later, some nested elements doesn't
-    // handle it correctly. A separate test-fixture is used to set
-    // properties before ready() is called.
-
     let element: GrTextarea;
 
-    setup(() => {
-      element = hideBorderFixture.instantiate() as GrTextarea;
+    setup(async () => {
+      element = await fixture<GrTextarea>(
+        html`<gr-textarea hide-border></gr-textarea>`
+      );
+      await element.updateComplete;
     });
 
     test('hideBorder is set properly', () => {
-      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
+      assert.isTrue(element.textarea!.classList.contains('noBorder'));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
index 4315071..4ccc635 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
@@ -1,25 +1,13 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../gr-icons/gr-icons';
+import '../gr-icon/gr-icon';
 import '../gr-tooltip/gr-tooltip';
-import {getRootElement} from '../../../scripts/rootElement';
 import {GrTooltip} from '../gr-tooltip/gr-tooltip';
 import {css, html, LitElement, PropertyValues} from 'lit';
-import {customElement, property, state} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators.js';
 
 const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
 
@@ -34,6 +22,11 @@
   @property({type: Boolean, attribute: 'has-tooltip', reflect: true})
   hasTooltip = false;
 
+  // A light tooltip will disappear immediately when the original hovered
+  // over content is no longer hovered over.
+  @property({type: Boolean, attribute: 'light-tooltip', reflect: true})
+  lightTooltip = false;
+
   @property({type: Boolean, attribute: 'position-below', reflect: true})
   positionBelow = false;
 
@@ -79,10 +72,8 @@
   static override get styles() {
     return [
       css`
-        iron-icon {
-          width: var(--line-height-normal);
-          height: var(--line-height-normal);
-          vertical-align: top;
+        gr-icon {
+          font-size: var(--line-height-normal);
         }
       `,
     ];
@@ -97,7 +88,7 @@
 
   renderIcon() {
     if (!this.showIcon) return;
-    return html`<iron-icon icon="gr-icons:info"></iron-icon>`;
+    return html`<gr-icon icon="info" filled></gr-icon>`;
   }
 
   override updated(changedProperties: PropertyValues) {
@@ -123,7 +114,7 @@
     this.addEventListener('mouseenter', this.showHandler);
   }
 
-  _handleShowTooltip() {
+  async _handleShowTooltip() {
     if (this.isTouchDevice) {
       return;
     }
@@ -145,22 +136,25 @@
     tooltip.text = this.originalTitle;
     tooltip.maxWidth = this.getAttribute('max-width') || '';
     tooltip.positionBelow = this.hasAttribute('position-below');
+    this.tooltip = tooltip;
 
     // Set visibility to hidden before appending to the DOM so that
     // calculations can be made based on the element’s size.
     tooltip.style.visibility = 'hidden';
-    getRootElement().appendChild(tooltip);
+    document.body.appendChild(tooltip);
+    await tooltip.updateComplete;
     this._positionTooltip(tooltip);
     tooltip.style.visibility = 'initial';
 
-    this.tooltip = tooltip;
     window.addEventListener('scroll', this.windowScrollHandler);
     this.addEventListener('mouseleave', this.hideHandler);
     this.addEventListener('click', this.hideHandler);
-    tooltip.addEventListener('mouseleave', this.hideHandler);
+    if (!this.lightTooltip) {
+      tooltip.addEventListener('mouseleave', this.hideHandler);
+    }
   }
 
-  _handleHideTooltip(e: Event | undefined) {
+  _handleHideTooltip(e?: Event) {
     if (this.isTouchDevice) {
       return;
     }
@@ -170,7 +164,8 @@
     // Do not hide if mouse left this or this.tooltip and came to this or
     // this.tooltip
     if (
-      (e as MouseEvent)?.relatedTarget === this.tooltip ||
+      (!this.lightTooltip &&
+        (e as MouseEvent)?.relatedTarget === this.tooltip) ||
       (e as MouseEvent)?.relatedTarget === this
     ) {
       return;
@@ -180,7 +175,9 @@
     this.removeEventListener('mouseleave', this.hideHandler);
     this.removeEventListener('click', this.hideHandler);
     this.setAttribute('title', this.originalTitle);
-    this.tooltip?.removeEventListener('mouseleave', this.hideHandler);
+    if (!this.lightTooltip) {
+      this.tooltip?.removeEventListener('mouseleave', this.hideHandler);
+    }
 
     if (this.tooltip?.parentNode) {
       this.tooltip.parentNode.removeChild(this.tooltip);
@@ -198,7 +195,7 @@
   }
 
   // private but used in tests.
-  async _positionTooltip(tooltip: GrTooltip | null) {
+  _positionTooltip(tooltip: GrTooltip | null) {
     if (tooltip === null) return;
     const rect = this.getBoundingClientRect();
     const boxRect = tooltip.getBoundingClientRect();
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
deleted file mode 100644
index 3b81f46..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
+++ /dev/null
@@ -1,174 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-tooltip-content.js';
-
-const basicFixture = fixtureFromElement('gr-tooltip-content');
-
-suite('gr-tooltip-content tests', () => {
-  let element;
-
-  function makeTooltip(tooltipRect, parentRect) {
-    return {
-      arrowCenterOffset: '0',
-      getBoundingClientRect() {
-        return tooltipRect;
-      },
-      style: {left: 0, top: 0},
-      parentElement: {
-        getBoundingClientRect() {
-          return parentRect;
-        },
-      },
-    };
-  }
-
-  setup(async () => {
-    element = basicFixture.instantiate();
-    element.title = 'title';
-    await element.updateComplete;
-  });
-
-  test('icon is not visible by default', () => {
-    assert.isNotOk(element.shadowRoot.querySelector('iron-icon'));
-  });
-
-  test('icon is visible with showIcon property', async () => {
-    element.showIcon = true;
-    await element.updateComplete;
-    assert.isOk(element.shadowRoot.querySelector('iron-icon'));
-  });
-
-  test('position-below attribute is reflected', async () => {
-    assert.isFalse(element.hasAttribute('position-below'));
-    element.positionBelow = true;
-    await element.updateComplete;
-    assert.isTrue(element.hasAttribute('position-below'));
-  });
-
-  test('normal position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 100, width: 200};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 50},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.equal(tooltip.arrowCenterOffset, '0');
-    assert.equal(tooltip.style.left, '175px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('left side position', async () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 10, width: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    await element.updateComplete;
-    assert.isBelow(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '0px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('right side position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 950, width: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isAbove(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('position to bottom', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 950, width: 50, height: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element.positionBelow = true;
-    element._positionTooltip(tooltip);
-    assert.isAbove(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '157.2px');
-  });
-
-  test('hides tooltip when detached', async () => {
-    const handleHideTooltipStub = sinon.stub(element, '_handleHideTooltip');
-    element.remove();
-    await element.updateComplete;
-    assert.isTrue(handleHideTooltipStub.called);
-  });
-
-  test('sets up listeners when has-tooltip is changed', async () => {
-    const addListenerStub = sinon.stub(element, 'addEventListener');
-    element.hasTooltip = true;
-    await element.updateComplete;
-    assert.isTrue(addListenerStub.called);
-  });
-
-  test('clean up listeners when has-tooltip changed to false', async () => {
-    const removeListenerStub = sinon.stub(element, 'removeEventListener');
-    element.hasTooltip = true;
-    await element.updateComplete;
-    element.hasTooltip = false;
-    await element.updateComplete;
-    assert.isTrue(removeListenerStub.called);
-  });
-
-  test('do not display tooltips on touch devices', async () => {
-    // On touch devices, tooltips should not be shown.
-    element.isTouchDevice = true;
-    await element.updateComplete;
-
-    // fire mouse-enter
-    element._handleShowTooltip();
-    await element.updateComplete;
-    assert.isNotOk(element.tooltip);
-
-    // fire mouse-enter
-    element._handleHideTooltip();
-    await element.updateComplete;
-    assert.isNotOk(element.tooltip);
-
-    // On other devices, tooltips should be shown.
-    element.isTouchDevice = false;
-
-    // fire mouse-enter
-    element._handleShowTooltip();
-    await element.updateComplete;
-    assert.isOk(element.tooltip);
-
-    // fire mouse-enter
-    element._handleHideTooltip();
-    await element.updateComplete;
-    assert.isNotOk(element.tooltip);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
new file mode 100644
index 0000000..f4dbc3e5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.ts
@@ -0,0 +1,184 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-tooltip-content';
+import {GrTooltipContent} from './gr-tooltip-content';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrTooltip} from '../gr-tooltip/gr-tooltip';
+import {query} from '../../../test/test-utils';
+
+suite('gr-tooltip-content tests', () => {
+  let element: GrTooltipContent;
+
+  function makeTooltip(tooltipRect: DOMRect, parentRect: DOMRect) {
+    return {
+      arrowCenterOffset: '0',
+      getBoundingClientRect() {
+        return tooltipRect;
+      },
+      style: {left: '0', top: '0'},
+      parentElement: {
+        getBoundingClientRect() {
+          return parentRect;
+        },
+      },
+    };
+  }
+
+  setup(async () => {
+    element = await fixture<GrTooltipContent>(html`
+      <gr-tooltip-content></gr-tooltip-content>
+    `);
+    element.title = 'title';
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    element.showIcon = true;
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <slot> </slot>
+        <gr-icon icon="info" filled></gr-icon>
+      `
+    );
+  });
+
+  test('icon is not visible by default', () => {
+    assert.isNotOk(query(element, 'gr-icon'));
+  });
+
+  test('icon is visible with showIcon property', async () => {
+    element.showIcon = true;
+    await element.updateComplete;
+    assert.isOk(query(element, 'gr-icon'));
+  });
+
+  test('position-below attribute is reflected', async () => {
+    assert.isFalse(element.hasAttribute('position-below'));
+    element.positionBelow = true;
+    await element.updateComplete;
+    assert.isTrue(element.hasAttribute('position-below'));
+  });
+
+  test('normal position', () => {
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(() => ({top: 100, left: 100, width: 200} as DOMRect));
+    const tooltip = makeTooltip(
+      {height: 30, width: 50} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
+
+    element._positionTooltip(tooltip);
+    assert.equal(tooltip.arrowCenterOffset, '0');
+    assert.equal(tooltip.style.left, '175px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('left side position', async () => {
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(() => ({top: 100, left: 10, width: 50} as DOMRect));
+    const tooltip = makeTooltip(
+      {height: 30, width: 120} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
+
+    element._positionTooltip(tooltip);
+    await element.updateComplete;
+    assert.isBelow(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '0px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('right side position', () => {
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(() => ({top: 100, left: 950, width: 50} as DOMRect));
+    const tooltip = makeTooltip(
+      {height: 30, width: 120} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
+
+    element._positionTooltip(tooltip);
+    assert.isAbove(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('position to bottom', () => {
+    sinon
+      .stub(element, 'getBoundingClientRect')
+      .callsFake(
+        () => ({top: 100, left: 950, width: 50, height: 50} as DOMRect)
+      );
+    const tooltip = makeTooltip(
+      {height: 30, width: 120} as DOMRect,
+      {top: 0, left: 0, width: 1000} as DOMRect
+    ) as GrTooltip;
+
+    element.positionBelow = true;
+    element._positionTooltip(tooltip);
+    assert.isAbove(parseFloat(tooltip.arrowCenterOffset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '157.2px');
+  });
+
+  test('hides tooltip when detached', async () => {
+    const handleHideTooltipStub = sinon.stub(element, '_handleHideTooltip');
+    element.remove();
+    await element.updateComplete;
+    assert.isTrue(handleHideTooltipStub.called);
+  });
+
+  test('sets up listeners when has-tooltip is changed', async () => {
+    const addListenerStub = sinon.stub(element, 'addEventListener');
+    element.hasTooltip = true;
+    await element.updateComplete;
+    assert.isTrue(addListenerStub.called);
+  });
+
+  test('clean up listeners when has-tooltip changed to false', async () => {
+    const removeListenerStub = sinon.stub(element, 'removeEventListener');
+    element.hasTooltip = true;
+    await element.updateComplete;
+    element.hasTooltip = false;
+    await element.updateComplete;
+    assert.isTrue(removeListenerStub.called);
+  });
+
+  test('do not display tooltips on touch devices', async () => {
+    // On touch devices, tooltips should not be shown.
+    element.isTouchDevice = true;
+    await element.updateComplete;
+
+    // fire mouse-enter
+    await element._handleShowTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
+
+    // fire mouse-enter
+    element._handleHideTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
+
+    // On other devices, tooltips should be shown.
+    element.isTouchDevice = false;
+
+    // fire mouse-enter
+    await element._handleShowTooltip();
+    await element.updateComplete;
+    assert.isOk(element.tooltip);
+
+    // fire mouse-enter
+    element._handleHideTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
index 0e41891..681378d 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -1,24 +1,12 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
-import {styleMap} from 'lit/directives/style-map';
+import {customElement, property} from 'lit/decorators.js';
+import {styleMap} from 'lit/directives/style-map.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -92,12 +80,12 @@
     return html` <div class="tooltip">
       <i
         class="arrowPositionBelow arrow"
-        style="${styleMap({marginLeft: this.arrowCenterOffset})}"
+        style=${styleMap({marginLeft: this.arrowCenterOffset})}
       ></i>
       ${this.text}
       <i
         class="arrowPositionAbove arrow"
-        style="${styleMap({marginLeft: this.arrowCenterOffset})}"
+        style=${styleMap({marginLeft: this.arrowCenterOffset})}
       ></i>
     </div>`;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
index b693a9e..63ed1ff 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
@@ -1,35 +1,38 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma';
+import '../../../test/common-test-setup';
 import './gr-tooltip';
 import {GrTooltip} from './gr-tooltip';
 import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-tooltip');
+import {fixture, html, assert} from '@open-wc/testing';
 
 suite('gr-tooltip tests', () => {
   let element: GrTooltip;
 
   setup(async () => {
-    element = basicFixture.instantiate() as GrTooltip;
+    element = await fixture(html`<gr-tooltip></gr-tooltip>`);
     await element.updateComplete;
   });
 
+  test('render', async () => {
+    element.text = 'tooltipText';
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="tooltip">
+          <i class="arrow arrowPositionBelow" style="margin-left:0;"> </i>
+          tooltipText
+          <i class="arrow arrowPositionAbove" style="margin-left:0;"> </i>
+        </div>
+      `
+    );
+  });
+
   test('max-width is respected if set', async () => {
     element.text =
       'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
index 1738aaa..02bde24 100644
--- a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
@@ -1,29 +1,17 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import '../gr-tooltip-content/gr-tooltip-content';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property} from 'lit/decorators.js';
 import {
   ApprovalInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
   LabelInfo,
 } from '../../../api/rest-api';
-import {appContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   classForLabelStatus,
   getLabelStatus,
@@ -35,7 +23,9 @@
     'gr-vote-chip': GrVoteChip;
   }
 }
-
+/**
+ * @attr {Boolean} circle-shape - element has shape as circle
+ */
 @customElement('gr-vote-chip')
 export class GrVoteChip extends LitElement {
   @property({type: Object})
@@ -48,16 +38,29 @@
   @property({type: Boolean})
   more = false;
 
-  private readonly flagsService = appContext.flagsService;
+  /**
+   * If defined, vote-chip is shown with this value instead of the latest vote.
+   * This is useful for change log.
+   */
+  @property()
+  displayValue?: string;
+
+  @property({type: Boolean, attribute: 'tooltip-with-who-voted'})
+  tooltipWithWhoVoted = false;
 
   static override get styles() {
     return [
       css`
+        :host([circle-shape]) .vote-chip {
+          border-radius: 50%;
+          border: none;
+          padding: 2px;
+        }
         .vote-chip.max {
           background-color: var(--vote-color-approved);
           padding: 2px;
         }
-        .vote-chip.max.more {
+        .more > .vote-chip.max {
           padding: 1px;
           border: 1px solid var(--vote-outline-recommended);
         }
@@ -65,7 +68,7 @@
           background-color: var(--vote-color-rejected);
           padding: 2px;
         }
-        .vote-chip.min.more {
+        .more > .vote-chip.min {
           padding: 1px;
           border: 1px solid var(--vote-outline-disliked);
         }
@@ -95,11 +98,11 @@
           line-height: var(--gr-vote-chip-width, 16px);
           color: var(--vote-text-color);
         }
-        .vote-chip {
+        .more > .vote-chip {
           position: relative;
           z-index: 2;
         }
-        .chip-angle {
+        .more > .chip-angle {
           position: absolute;
           top: 2px;
           left: 2px;
@@ -108,30 +111,38 @@
         .container {
           position: relative;
         }
+        /* fix for firefox only */
+        @supports (-moz-appearance: none) {
+          .container.more {
+            display: inline-block;
+          }
+        }
       `,
     ];
   }
 
   override render() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI))
-      return;
-
     const renderValue = this.renderValue();
     if (!renderValue) return;
 
-    return html`<span class="container">
-      <div class="vote-chip ${this.computeClass()} ${this.more ? 'more' : ''}">
-        ${renderValue}
-      </div>
+    return html`<gr-tooltip-content
+      class="container ${this.more ? 'more' : ''}"
+      title=${this.computeTooltip()}
+      has-tooltip
+    >
+      <div class="vote-chip ${this.computeClass()}">${renderValue}</div>
       ${this.more
         ? html`<div class="chip-angle ${this.computeClass()}">
             ${renderValue}
           </div>`
         : ''}
-    </span>`;
+    </gr-tooltip-content>`;
   }
 
   private renderValue() {
+    if (this.displayValue) {
+      return this.displayValue;
+    }
     if (!this.label) {
       return '';
     } else if (isDetailedLabelInfo(this.label)) {
@@ -140,9 +151,11 @@
       }
     } else if (isQuickLabelInfo(this.label)) {
       if (this.label.approved) {
-        return '👍️';
+        return html`&#x2713;`; // check mark
       } else if (this.label.rejected) {
-        return '👎️';
+        return html`&#x2717;`; // x mark
+      } else if (this.label.disliked || this.label.recommended) {
+        return valueString(this.label.value);
       }
     }
     return '';
@@ -151,15 +164,26 @@
   private computeClass() {
     if (!this.label) {
       return '';
-    } else if (isDetailedLabelInfo(this.label)) {
-      if (this.vote?.value) {
-        const status = getLabelStatus(this.label, this.vote.value);
-        return classForLabelStatus(status);
-      }
-    } else if (isQuickLabelInfo(this.label)) {
-      const status = getLabelStatus(this.label);
+    } else if (this.displayValue) {
+      const status = getLabelStatus(this.label, Number(this.displayValue));
+      return classForLabelStatus(status);
+    } else {
+      const status = getLabelStatus(this.label, this.vote?.value);
       return classForLabelStatus(status);
     }
-    return '';
+  }
+
+  private computeTooltip() {
+    if (!this.label || !isDetailedLabelInfo(this.label)) {
+      return '';
+    }
+    const voteDescription =
+      this.label.values?.[valueString(this.vote?.value)] ?? '';
+
+    if (this.tooltipWithWhoVoted && this.vote) {
+      return `${this.vote?.name}: ${voteDescription}`;
+    } else {
+      return voteDescription;
+    }
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip_test.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip_test.ts
new file mode 100644
index 0000000..581b577
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip_test.ts
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import {getAppContext} from '../../../services/app-context';
+import './gr-vote-chip';
+import {GrVoteChip} from './gr-vote-chip';
+import {
+  createAccountWithIdNameAndEmail,
+  createApproval,
+  createDetailedLabelInfo,
+  createQuickLabelInfo,
+} from '../../../test/test-data-generators';
+import {ApprovalInfo} from '../../../api/rest-api';
+
+suite('gr-vote-chip tests', () => {
+  setup(() => {
+    sinon.stub(getAppContext().flagsService, 'isEnabled').returns(true);
+  });
+
+  suite('with QuickLabelInfo', () => {
+    test('renders positive', async () => {
+      const labelInfo = {
+        ...createQuickLabelInfo(),
+        approved: createAccountWithIdNameAndEmail(),
+      };
+      const element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title=""
+        >
+          <div class="max vote-chip">&#x2713;</div>
+        </gr-tooltip-content>`
+      );
+    });
+
+    test('renders negative', async () => {
+      const labelInfo = {
+        ...createQuickLabelInfo(),
+        rejected: createAccountWithIdNameAndEmail(),
+      };
+      const element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title=""
+        >
+          <div class="min vote-chip">&#x2717;</div>
+        </gr-tooltip-content>`
+      );
+    });
+  });
+
+  suite('with DetailedLabelInfo', () => {
+    let element: GrVoteChip;
+    const labelInfo = createDetailedLabelInfo();
+    const vote: ApprovalInfo = {
+      ...createApproval(),
+      value: 2,
+    };
+
+    setup(async () => {
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip .label=${labelInfo} .vote=${vote}></gr-vote-chip>`
+      );
+    });
+
+    test('renders', () => {
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title=""
+        >
+          <div class="positive vote-chip">+2</div>
+        </gr-tooltip-content>`
+      );
+    });
+
+    test('renders negative vote', async () => {
+      const vote: ApprovalInfo = {
+        ...createApproval,
+        value: -1,
+      };
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip .label=${labelInfo} .vote=${vote}></gr-vote-chip>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title="Wrong Style or Formatting"
+        >
+          <div class="min vote-chip">-1</div>
+        </gr-tooltip-content>`
+      );
+    });
+
+    test('renders for more than 1 vote', async () => {
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip
+          .label=${labelInfo}
+          .vote=${vote}
+          more
+        ></gr-vote-chip>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container more"
+          has-tooltip=""
+          title=""
+        >
+          <div class="positive vote-chip">+2</div>
+          <div class="chip-angle positive">+2</div>
+        </gr-tooltip-content>`
+      );
+    });
+
+    test('renders with tooltip who voted', async () => {
+      vote.name = 'Tester';
+      const labelInfo = {
+        all: [{value: 2}, {value: 1}],
+        values: {'+2': 'Great'},
+      };
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip
+          .label=${labelInfo}
+          .vote=${vote}
+          tooltip-with-who-voted
+        ></gr-vote-chip>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title="Tester: Great"
+        >
+          <div class="max vote-chip">+2</div>
+        </gr-tooltip-content>`
+      );
+    });
+
+    test('renders with display value instead of latest vote', async () => {
+      element = await fixture<GrVoteChip>(
+        html`<gr-vote-chip
+          .displayValue=${-1}
+          .label=${labelInfo}
+          .vote=${vote}
+        ></gr-vote-chip>`
+      );
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ ` <gr-tooltip-content
+          class="container"
+          has-tooltip=""
+          title=""
+        >
+          <div class="min vote-chip">-1</div>
+        </gr-tooltip-content>`
+      );
+    });
+  });
+});
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 f533bbd..91b9910 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -1,28 +1,17 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {
+  ChangeInfo,
+  PatchSetNum,
+  RevisionPatchSetNum,
+} from '../../../types/common';
 import {ParsedChangeInfo} from '../../../types/types';
 
-type RevNumberToParentCountMap = {[revNumber: number]: number};
-
 export class RevisionInfo {
   /**
-   * @constructor
    * @param change A change object resulting from a change detail
    *     call that includes revision information.
    */
@@ -48,22 +37,25 @@
    * Get an object that maps revision numbers to the number of parents of the
    * commit of that revision.
    */
-  getParentCountMap() {
-    const result: RevNumberToParentCountMap = {};
+  getParentCountMap(): Map<RevisionPatchSetNum, number> {
+    const result: Map<RevisionPatchSetNum, number> = new Map();
     if (!this.change || !this.change.revisions) {
-      return {};
+      return result;
     }
     Object.values(this.change.revisions).forEach(rev => {
-      if (rev.commit) result[rev._number as number] = rev.commit.parents.length;
+      if (rev.commit) result.set(rev._number, rev.commit.parents.length);
     });
     return result;
   }
 
-  getParentCount(patchNum: PatchSetNum) {
-    return this.getParentCountMap()[patchNum as number];
+  getParentCount(patchNum: RevisionPatchSetNum): number {
+    // The caller should make sure to pass a known `patchNum`, but `1` seems to
+    // be a reasonable default. Normally a revision has one parent.
+    return this.getParentCountMap().get(patchNum) ?? 1;
   }
 
-  isMergeCommit(patchNum: PatchSetNum) {
+  isMergeCommit(patchNum?: RevisionPatchSetNum) {
+    if (patchNum === undefined) return false;
     return this.getParentCount(patchNum) > 1;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js
deleted file mode 100644
index 7d0dd4f..0000000
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.js
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './revision-info.js';
-import {RevisionInfo} from './revision-info.js';
-suite('revision-info tests', () => {
-  let mockChange;
-
-  setup(() => {
-    mockChange = {
-      revisions: {
-        r1: {_number: 1, commit: {parents: [
-          {commit: 'p1'},
-          {commit: 'p2'},
-          {commit: 'p3'},
-        ]}},
-        r2: {_number: 2, commit: {parents: [
-          {commit: 'p1'},
-          {commit: 'p4'},
-        ]}},
-        r3: {_number: 3, commit: {parents: [{commit: 'p5'}]}},
-        r4: {_number: 4, commit: {parents: [
-          {commit: 'p2'},
-          {commit: 'p3'},
-        ]}},
-        r5: {_number: 5, commit: {parents: [
-          {commit: 'p5'},
-          {commit: 'p2'},
-          {commit: 'p3'},
-        ]}},
-      },
-    };
-  });
-
-  test('getMaxParents', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.equal(ri.getMaxParents(), 3);
-  });
-
-  test('getParentCountMap', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCountMap(), {1: 3, 2: 2, 3: 1, 4: 2, 5: 3});
-  });
-
-  test('getParentCount', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCount(1), 3);
-    assert.deepEqual(ri.getParentCount(3), 1);
-  });
-
-  test('getParentCount', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentCount(1), 3);
-    assert.deepEqual(ri.getParentCount(3), 1);
-  });
-
-  test('getParentId', () => {
-    const ri = new RevisionInfo(mockChange);
-    assert.deepEqual(ri.getParentId(1, 2), 'p3');
-    assert.deepEqual(ri.getParentId(2, 1), 'p4');
-    assert.deepEqual(ri.getParentId(3, 0), 'p5');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.ts
new file mode 100644
index 0000000..055626f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info_test.ts
@@ -0,0 +1,114 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import {
+  createChange,
+  createCommit,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {ChangeInfo, CommitId, PatchSetNumber} from '../../../types/common';
+import './revision-info';
+import {RevisionInfo} from './revision-info';
+
+suite('revision-info tests', () => {
+  let mockChange: ChangeInfo;
+
+  setup(() => {
+    mockChange = {
+      ...createChange(),
+      revisions: {
+        r1: {
+          ...createRevision(1),
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p1' as CommitId, subject: ''},
+              {commit: 'p2' as CommitId, subject: ''},
+              {commit: 'p3' as CommitId, subject: ''},
+            ],
+          },
+        },
+        r2: {
+          ...createRevision(2),
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p1' as CommitId, subject: ''},
+              {commit: 'p4' as CommitId, subject: ''},
+            ],
+          },
+        },
+        r3: {
+          ...createRevision(3),
+          commit: {
+            ...createCommit(),
+            parents: [{commit: 'p5' as CommitId, subject: ''}],
+          },
+        },
+        r4: {
+          ...createRevision(4),
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p2' as CommitId, subject: ''},
+              {commit: 'p3' as CommitId, subject: ''},
+            ],
+          },
+        },
+        r5: {
+          ...createRevision(5),
+          commit: {
+            ...createCommit(),
+            parents: [
+              {commit: 'p5' as CommitId, subject: ''},
+              {commit: 'p2' as CommitId, subject: ''},
+              {commit: 'p3' as CommitId, subject: ''},
+            ],
+          },
+        },
+      },
+    };
+  });
+
+  test('getMaxParents', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.equal(ri.getMaxParents(), 3);
+  });
+
+  test('getParentCountMap', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(
+      ri.getParentCountMap(),
+      new Map([
+        [1 as PatchSetNumber, 3],
+        [2 as PatchSetNumber, 2],
+        [3 as PatchSetNumber, 1],
+        [4 as PatchSetNumber, 2],
+        [5 as PatchSetNumber, 3],
+      ])
+    );
+  });
+
+  test('getParentCount', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCount(1 as PatchSetNumber), 3);
+    assert.deepEqual(ri.getParentCount(3 as PatchSetNumber), 1);
+  });
+
+  test('getParentCount', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentCount(1 as PatchSetNumber), 3);
+    assert.deepEqual(ri.getParentCount(3 as PatchSetNumber), 1);
+  });
+
+  test('getParentId', () => {
+    const ri = new RevisionInfo(mockChange);
+    assert.deepEqual(ri.getParentId(1 as PatchSetNumber, 2), 'p3' as CommitId);
+    assert.deepEqual(ri.getParentId(2 as PatchSetNumber, 1), 'p4' as CommitId);
+    assert.deepEqual(ri.getParentId(3 as PatchSetNumber, 0), 'p5' as CommitId);
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
new file mode 100644
index 0000000..79c40de
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../elements/shared/gr-button/gr-button';
+import {html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {DiffInfo, DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {getShowConfig} from './gr-context-controls';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+
+@customElement('gr-context-controls-section')
+export class GrContextControlsSection extends LitElement {
+  /** Should context controls be rendered for expanding above the section? */
+  @property({type: Boolean}) showAbove = false;
+
+  /** Should context controls be rendered for expanding below the section? */
+  @property({type: Boolean}) showBelow = false;
+
+  /** Must be of type GrDiffGroupType.CONTEXT_CONTROL. */
+  @property({type: Object})
+  group?: GrDiffGroup;
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @property({type: Object})
+  renderPrefs?: RenderPreferences;
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  private renderPaddingRow(whereClass: 'above' | 'below') {
+    if (!this.showAbove && whereClass === 'above') return;
+    if (!this.showBelow && whereClass === 'below') return;
+    const modeClass = this.isSideBySide() ? 'side-by-side' : 'unified';
+    const type = this.isSideBySide()
+      ? GrDiffGroupType.CONTEXT_CONTROL
+      : undefined;
+    return html`
+      <tr
+        class=${diffClasses('contextBackground', modeClass, whereClass)}
+        left-type=${ifDefined(type)}
+        right-type=${ifDefined(type)}
+      >
+        <td class=${diffClasses('blame')} data-line-number="0"></td>
+        <td class=${diffClasses('contextLineNum')}></td>
+        ${when(
+          this.isSideBySide(),
+          () => html`
+            <td class=${diffClasses('sign')}></td>
+            <td class=${diffClasses()}></td>
+          `
+        )}
+        <td class=${diffClasses('contextLineNum')}></td>
+        ${when(
+          this.isSideBySide(),
+          () => html`<td class=${diffClasses('sign')}></td>`
+        )}
+        <td class=${diffClasses()}></td>
+      </tr>
+    `;
+  }
+
+  private isSideBySide() {
+    return this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED;
+  }
+
+  private createContextControlRow() {
+    // Note that <td> table cells that have `display: none` don't count!
+    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
+    const showConfig = getShowConfig(this.showAbove, this.showBelow);
+    return html`
+      <tr class=${diffClasses('dividerRow', `show-${showConfig}`)}>
+        <td class=${diffClasses('blame')} data-line-number="0"></td>
+        ${when(
+          this.isSideBySide(),
+          () => html`<td class=${diffClasses()}></td>`
+        )}
+        <td class=${diffClasses('dividerCell')} colspan=${colspan}>
+          <gr-context-controls
+            class=${diffClasses()}
+            .diff=${this.diff}
+            .renderPreferences=${this.renderPrefs}
+            .group=${this.group}
+            .showConfig=${showConfig}
+          >
+          </gr-context-controls>
+        </td>
+      </tr>
+    `;
+  }
+
+  override render() {
+    const rows = html`
+      ${this.renderPaddingRow('above')} ${this.createContextControlRow()}
+      ${this.renderPaddingRow('below')}
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${rows}
+      </table>`;
+    }
+    return rows;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-context-controls-section': GrContextControlsSection;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
new file mode 100644
index 0000000..6a557fc
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-context-controls-section';
+import {GrContextControlsSection} from './gr-context-controls-section';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-context-controls-section test', () => {
+  let element: GrContextControlsSection;
+
+  setup(async () => {
+    element = await fixture<GrContextControlsSection>(
+      html`<gr-context-controls-section></gr-context-controls-section>`
+    );
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  test('render: normal with showAbove and showBelow', async () => {
+    element.showAbove = true;
+    element.showBelow = true;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              class="above contextBackground gr-diff side-by-side"
+              left-type="contextControl"
+              right-type="contextControl"
+            >
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+            </tr>
+            <tr class="dividerRow gr-diff show-both">
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="gr-diff"></td>
+              <td class="dividerCell gr-diff" colspan="3">
+                <gr-context-controls class="gr-diff" showconfig="both">
+                </gr-context-controls>
+              </td>
+            </tr>
+            <tr
+              class="below contextBackground gr-diff side-by-side"
+              left-type="contextControl"
+              right-type="contextControl"
+            >
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
new file mode 100644
index 0000000..1679ee0
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -0,0 +1,514 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '@polymer/paper-button/paper-button';
+import '@polymer/paper-card/paper-card';
+import '@polymer/paper-checkbox/paper-checkbox';
+import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+import '@polymer/paper-icon-button/paper-icon-button';
+import '@polymer/paper-item/paper-item';
+import '@polymer/paper-listbox/paper-listbox';
+import '@polymer/paper-tooltip/paper-tooltip';
+import {of, EMPTY, Subject} from 'rxjs';
+import {switchMap, delay} from 'rxjs/operators';
+
+import '../../../elements/shared/gr-button/gr-button';
+import {pluralize} from '../../../utils/string-util';
+import {fire} from '../../../utils/event-util';
+import {DiffInfo} from '../../../types/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {css, html, LitElement, TemplateResult} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {subscribe} from '../../../elements/lit/subscription-controller';
+
+import {
+  ContextButtonType,
+  DiffContextButtonHoveredDetail,
+  RenderPreferences,
+  SyntaxBlock,
+} from '../../../api/diff';
+
+import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
+
+declare global {
+  interface HTMLElementEventMap {
+    'diff-context-button-hovered': CustomEvent<DiffContextButtonHoveredDetail>;
+  }
+}
+
+const PARTIAL_CONTEXT_AMOUNT = 10;
+
+/**
+ * Traverses a hierarchical structure of syntax blocks and
+ * finds the most local/nested block that can be associated line.
+ * It finds the closest block that contains the whole line and
+ * returns the whole path from the syntax layer (blocks) sent as parameter
+ * to the most nested block - the complete path from the top to bottom layer of
+ * a syntax tree. Example: [myNamespace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
+ *
+ * @param lineNum line number for the targeted line.
+ * @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
+ */
+function findBlockTreePathForLine(
+  lineNum: number,
+  blocks?: SyntaxBlock[]
+): SyntaxBlock[] {
+  const containingBlock = blocks?.find(
+    ({range}) => range.start_line < lineNum && range.end_line > lineNum
+  );
+  if (!containingBlock) return [];
+  const innerPathInChild = findBlockTreePathForLine(
+    lineNum,
+    containingBlock?.children
+  );
+  return [containingBlock].concat(innerPathInChild);
+}
+
+export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
+
+export function getShowConfig(
+  showAbove: boolean,
+  showBelow: boolean
+): GrContextControlsShowConfig {
+  if (showAbove && !showBelow) return 'above';
+  if (!showAbove && showBelow) return 'below';
+
+  // Note that !showAbove && !showBelow also intentionally returns 'both'.
+  // This means the file is completely collapsed, which is unusual, but at least
+  // happens in one test.
+  return 'both';
+}
+
+@customElement('gr-context-controls')
+export class GrContextControls extends LitElement {
+  @property({type: Object}) renderPreferences?: RenderPreferences;
+
+  @property({type: Object}) diff?: DiffInfo;
+
+  @property({type: Object}) group?: GrDiffGroup;
+
+  @property({type: String, reflect: true})
+  showConfig: GrContextControlsShowConfig = 'both';
+
+  private expandButtonsHover = new Subject<{
+    eventType: 'enter' | 'leave';
+    buttonType: ContextButtonType;
+    linesToExpand: number;
+  }>();
+
+  static override styles = css`
+    :host {
+      display: flex;
+      justify-content: center;
+      flex-direction: column;
+      position: relative;
+    }
+
+    :host([showConfig='above']) {
+      justify-content: flex-end;
+      margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
+      margin-bottom: var(--gr-context-controls-margin-bottom);
+      height: calc(var(--line-height-normal) + var(--spacing-s));
+      .horizontalFlex {
+        align-items: end;
+      }
+    }
+
+    :host([showConfig='below']) {
+      justify-content: flex-start;
+      margin-top: 1px;
+      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      .horizontalFlex {
+        align-items: start;
+      }
+    }
+
+    :host([showConfig='both']) {
+      margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      height: calc(
+        2 * var(--line-height-normal) + 2 * var(--spacing-s) +
+          var(--divider-height)
+      );
+      .horizontalFlex {
+        align-items: center;
+      }
+    }
+
+    .contextControlButton {
+      background-color: var(--default-button-background-color);
+      font: var(--context-control-button-font, inherit);
+    }
+
+    paper-button {
+      text-transform: none;
+      align-items: center;
+      background-color: var(--background-color);
+      font-family: inherit;
+      margin: var(--margin, 0);
+      min-width: var(--border, 0);
+      color: var(--diff-context-control-color);
+      border: solid var(--border-color);
+      border-width: 1px;
+      border-radius: var(--border-radius);
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+
+    paper-button:hover {
+      /* same as defined in gr-button */
+      background: rgba(0, 0, 0, 0.12);
+    }
+
+    .aboveBelowButtons {
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      margin-left: var(--spacing-m);
+      position: relative;
+    }
+    .aboveBelowButtons:first-child {
+      margin-left: 0;
+      /* Places a default background layer behind the "all button" that can have opacity */
+      background-color: var(--default-button-background-color);
+    }
+
+    .horizontalFlex {
+      display: flex;
+      justify-content: center;
+      align-items: var(--gr-context-controls-horizontal-align-items, center);
+    }
+
+    .aboveButton {
+      border-bottom-width: 0;
+      border-bottom-right-radius: 0;
+      border-bottom-left-radius: 0;
+      padding: var(--spacing-xxs) var(--spacing-l);
+    }
+    .belowButton {
+      border-top-width: 0;
+      border-top-left-radius: 0;
+      border-top-right-radius: 0;
+      padding: var(--spacing-xxs) var(--spacing-l);
+      margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
+    }
+    .belowButton:first-child {
+      margin-top: 0;
+    }
+    .breadcrumbTooltip {
+      white-space: nowrap;
+    }
+  `;
+
+  constructor() {
+    super();
+    this.setupButtonHoverHandler();
+  }
+
+  private showBoth() {
+    return this.showConfig === 'both';
+  }
+
+  private showAbove() {
+    return this.showBoth() || this.showConfig === 'above';
+  }
+
+  private showBelow() {
+    return this.showBoth() || this.showConfig === 'below';
+  }
+
+  private setupButtonHoverHandler() {
+    subscribe(
+      this,
+      () =>
+        this.expandButtonsHover.pipe(
+          switchMap(e => {
+            if (e.eventType === 'leave') {
+              // cancel any previous delay
+              // for mouse enter
+              return EMPTY;
+            }
+            return of(e).pipe(delay(500));
+          })
+        ),
+      ({buttonType, linesToExpand}) => {
+        fire(this, 'diff-context-button-hovered', {
+          buttonType,
+          linesToExpand,
+        });
+      }
+    );
+  }
+
+  private numLines() {
+    assertIsDefined(this.group);
+    // In context groups, there is the same number of lines left and right
+    const left = this.group.lineRange.left;
+    // Both start and end inclusive, so we need to add 1.
+    return left.end_line - left.start_line + 1;
+  }
+
+  private createExpandAllButtonContainer() {
+    return html` <div class="gr-diff aboveBelowButtons fullExpansion">
+      ${this.createContextButton(ContextButtonType.ALL, this.numLines())}
+    </div>`;
+  }
+
+  /**
+   * Creates a specific expansion button (e.g. +X common lines, +10, +Block).
+   */
+  private createContextButton(
+    type: ContextButtonType,
+    linesToExpand: number,
+    tooltip?: TemplateResult
+  ) {
+    if (!this.group) return;
+    let text = '';
+    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+    let ariaLabel = '';
+    let classes = 'contextControlButton showContext ';
+
+    if (type === ContextButtonType.ALL) {
+      text = `+${pluralize(linesToExpand, 'common line')}`;
+      ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+      classes += this.showBoth()
+        ? 'centeredButton'
+        : this.showAbove()
+        ? 'aboveButton'
+        : 'belowButton';
+      if (this.group?.hasSkipGroup()) {
+        // Expanding content would require load of more data
+        text += ' (too large)';
+      }
+      groups.push(...this.group.contextGroups);
+    } else if (type === ContextButtonType.ABOVE) {
+      groups = hideInContextControl(
+        this.group.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = `+${linesToExpand}`;
+      classes += 'aboveButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} above`;
+    } else if (type === ContextButtonType.BELOW) {
+      groups = hideInContextControl(
+        this.group.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = `+${linesToExpand}`;
+      classes += 'belowButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} below`;
+    } else if (type === ContextButtonType.BLOCK_ABOVE) {
+      groups = hideInContextControl(
+        this.group.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = '+Block';
+      classes += 'aboveButton';
+      ariaLabel = 'Show block above';
+    } else if (type === ContextButtonType.BLOCK_BELOW) {
+      groups = hideInContextControl(
+        this.group.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = '+Block';
+      classes += 'belowButton';
+      ariaLabel = 'Show block below';
+    }
+    const expandHandler = this.createExpansionHandler(
+      linesToExpand,
+      type,
+      groups
+    );
+
+    const mouseHandler = (eventType: 'enter' | 'leave') => {
+      this.expandButtonsHover.next({
+        eventType,
+        buttonType: type,
+        linesToExpand,
+      });
+    };
+
+    const button = html` <paper-button
+      class=${classes}
+      aria-label=${ariaLabel}
+      @click=${expandHandler}
+      @mouseenter=${() => mouseHandler('enter')}
+      @mouseleave=${() => mouseHandler('leave')}
+    >
+      <span class="showContext">${text}</span>
+      ${tooltip}
+    </paper-button>`;
+    return button;
+  }
+
+  private createExpansionHandler(
+    linesToExpand: number,
+    type: ContextButtonType,
+    groups: GrDiffGroup[]
+  ) {
+    return (e: Event) => {
+      assertIsDefined(this.group);
+      e.stopPropagation();
+      if (type === ContextButtonType.ALL && this.group?.hasSkipGroup()) {
+        fire(this, 'content-load-needed', {
+          lineRange: this.group.lineRange,
+        });
+      } else {
+        fire(this, 'diff-context-expanded', {
+          contextGroup: this.group,
+          groups,
+          numLines: this.numLines(),
+          buttonType: type,
+          expandedLines: linesToExpand,
+        });
+      }
+    };
+  }
+
+  private showPartialLinks() {
+    return this.numLines() > PARTIAL_CONTEXT_AMOUNT;
+  }
+
+  /**
+   * Creates a container div with partial (+10) expansion buttons (above and/or below).
+   */
+  private createPartialExpansionButtons() {
+    if (!this.showPartialLinks()) {
+      return undefined;
+    }
+    let aboveButton;
+    let belowButton;
+    if (this.showAbove()) {
+      aboveButton = this.createContextButton(
+        ContextButtonType.ABOVE,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    if (this.showBelow()) {
+      belowButton = this.createContextButton(
+        ContextButtonType.BELOW,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    return aboveButton || belowButton
+      ? html` <div class="aboveBelowButtons partialExpansion">
+          ${aboveButton} ${belowButton}
+        </div>`
+      : undefined;
+  }
+
+  /**
+   * Creates a container div with block expansion buttons (above and/or below).
+   */
+  private createBlockExpansionButtons() {
+    assertIsDefined(this.group, 'group');
+    if (
+      !this.showPartialLinks() ||
+      !this.renderPreferences?.use_block_expansion ||
+      this.group?.hasSkipGroup()
+    ) {
+      return undefined;
+    }
+    let aboveBlockButton;
+    let belowBlockButton;
+    if (this.showAbove()) {
+      aboveBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_ABOVE,
+        this.numLines(),
+        this.group.lineRange.right.start_line - 1
+      );
+    }
+    if (this.showBelow()) {
+      belowBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_BELOW,
+        this.numLines(),
+        this.group.lineRange.right.end_line + 1
+      );
+    }
+    if (aboveBlockButton || belowBlockButton) {
+      return html` <div class="aboveBelowButtons blockExpansion">
+        ${aboveBlockButton} ${belowBlockButton}
+      </div>`;
+    }
+    return undefined;
+  }
+
+  private createBlockButtonTooltip(
+    buttonType: ContextButtonType,
+    syntaxPath: SyntaxBlock[],
+    linesToExpand: number
+  ) {
+    // Create breadcrumb string:
+    // myNamespace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
+    const tooltipText = syntaxPath.length
+      ? syntaxPath.map(b => b.name || '(anonymous)').join(' > ')
+      : `${linesToExpand} common lines`;
+
+    const position =
+      buttonType === ContextButtonType.BLOCK_ABOVE ? 'top' : 'bottom';
+    return html`<paper-tooltip offset="10" position=${position}
+      ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
+    >`;
+  }
+
+  private createBlockButton(
+    buttonType: ContextButtonType,
+    numLines: number,
+    referenceLine: number
+  ) {
+    if (!this.diff?.meta_b) return;
+    const syntaxTree = this.diff.meta_b.syntax_tree;
+    const outlineSyntaxPath = findBlockTreePathForLine(
+      referenceLine,
+      syntaxTree
+    );
+    let linesToExpand = numLines;
+    if (outlineSyntaxPath.length) {
+      const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
+      const targetLine =
+        buttonType === ContextButtonType.BLOCK_ABOVE
+          ? range.end_line
+          : range.start_line;
+      const distanceToTargetLine = Math.abs(targetLine - referenceLine);
+      if (distanceToTargetLine < numLines) {
+        linesToExpand = distanceToTargetLine;
+      }
+    }
+    const tooltip = this.createBlockButtonTooltip(
+      buttonType,
+      outlineSyntaxPath,
+      linesToExpand
+    );
+    return this.createContextButton(buttonType, linesToExpand, tooltip);
+  }
+
+  private hasValidProperties() {
+    return !!(this.diff && this.group?.contextGroups?.length);
+  }
+
+  override render() {
+    if (!this.hasValidProperties()) {
+      console.error('Invalid properties for gr-context-controls!');
+      return html`<p>invalid properties</p>`;
+    }
+    return html`
+      <div class="horizontalFlex">
+        ${this.createExpandAllButtonContainer()}
+        ${this.createPartialExpansionButtons()}
+        ${this.createBlockExpansionButtons()}
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-context-controls': GrContextControls;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
new file mode 100644
index 0000000..8e2f432
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
@@ -0,0 +1,367 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../gr-diff/gr-diff-group';
+import './gr-context-controls';
+import {GrContextControls} from './gr-context-controls';
+
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitEventLoop} from '../../../test/test-utils';
+
+suite('gr-context-control tests', () => {
+  let element: GrContextControls;
+
+  setup(async () => {
+    element = document.createElement('gr-context-controls');
+    element.diff = {content: []} as any as DiffInfo;
+    element.renderPreferences = {};
+    const div = await fixture(html`<div></div>`);
+    div.appendChild(element);
+    await waitEventLoop();
+  });
+
+  function createContextGroup(options: {offset?: number; count?: number}) {
+    const offset = options.offset || 0;
+    const numLines = options.count || 10;
+    const lines = [];
+    for (let i = 0; i < numLines; i++) {
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = offset + i + 1;
+      line.afterNumber = offset + i + 1;
+      line.text = 'lorem upsum';
+      lines.push(line);
+    }
+    return new GrDiffGroup({
+      type: GrDiffGroupType.CONTEXT_CONTROL,
+      contextGroups: [new GrDiffGroup({type: GrDiffGroupType.BOTH, lines})],
+    });
+  }
+
+  test('no +10 buttons for 10 or less lines', async () => {
+    element.group = createContextGroup({count: 10});
+
+    await waitEventLoop();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+    assert.equal(buttons.length, 1);
+    assert.equal(buttons[0].textContent!.trim(), '+10 common lines');
+  });
+
+  test('context control at the top', async () => {
+    element.group = createContextGroup({offset: 0, count: 20});
+    element.showConfig = 'below';
+
+    await waitEventLoop();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'belowButton');
+    assert.include([...buttons[1].classList.values()], 'belowButton');
+  });
+
+  test('context control in the middle', async () => {
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await waitEventLoop();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 3);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+    assert.equal(buttons[2].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'centeredButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+    assert.include([...buttons[2].classList.values()], 'belowButton');
+  });
+
+  test('context control at the bottom', async () => {
+    element.group = createContextGroup({offset: 30, count: 20});
+    element.showConfig = 'above';
+
+    await waitEventLoop();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'aboveButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+  });
+
+  function prepareForBlockExpansion(syntaxTree: SyntaxBlock[]) {
+    element.renderPreferences!.use_block_expansion = true;
+    element.diff!.meta_b = {
+      syntax_tree: syntaxTree,
+    } as any as DiffFileMetaInfo;
+  }
+
+  test('context control with block expansion at the top', async () => {
+    prepareForBlockExpansion([]);
+    element.group = createContextGroup({offset: 0, count: 20});
+    element.showConfig = 'below';
+
+    await waitEventLoop();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion in the middle', async () => {
+    prepareForBlockExpansion([]);
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await waitEventLoop();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 2);
+    assert.equal(blockExpansionButtons.length, 2);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.equal(
+      blockExpansionButtons[1].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+    assert.include(
+      [...blockExpansionButtons[1].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion at the bottom', async () => {
+    prepareForBlockExpansion([]);
+    element.group = createContextGroup({offset: 30, count: 20});
+    element.showConfig = 'above';
+
+    await waitEventLoop();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+  });
+
+  test('+ Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificFunction',
+        range: {start_line: 1, start_column: 0, end_line: 25, end_column: 0},
+        children: [],
+      },
+      {
+        name: 'anotherFunction',
+        range: {start_line: 26, start_column: 0, end_line: 50, end_column: 0},
+        children: [],
+      },
+    ]);
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await waitEventLoop();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificFunction'
+    );
+    assert.equal(
+      blockExpansionButtons[1]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'anotherFunction'
+    );
+  });
+
+  test('+Block tooltip shows nested syntax blocks as breadcrumbs', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: 'MyClass',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await waitEventLoop();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > MyClass > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows (anonymous) for empty blocks', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: '',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+    await waitEventLoop();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > (anonymous) > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows "all common lines" for empty syntax tree', async () => {
+    prepareForBlockExpansion([]);
+
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+    await waitEventLoop();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    const tooltipAbove =
+      blockExpansionButtons[0].querySelector('paper-tooltip')!;
+    const tooltipBelow =
+      blockExpansionButtons[1].querySelector('paper-tooltip')!;
+    assert.equal(
+      tooltipAbove.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+      '20 common lines'
+    );
+    assert.equal(
+      tooltipBelow.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+      '20 common lines'
+    );
+    assert.equal(tooltipAbove.getAttribute('position'), 'top');
+    assert.equal(tooltipBelow.getAttribute('position'), 'bottom');
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
new file mode 100644
index 0000000..859a49d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Side} from '../../../api/diff';
+import {
+  CoverageRange,
+  CoverageType,
+  DiffLayer,
+  DiffLayerListener,
+} from '../../../types/types';
+
+const TOOLTIP_MAP = new Map([
+  [CoverageType.COVERED, 'Covered by tests.'],
+  [CoverageType.NOT_COVERED, 'Not covered by tests.'],
+  [CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
+  [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
+]);
+
+// Ranges are considered half-open: [start, end)
+export type Range = {start: number; end: number};
+
+export function mergeRanges(ranges: Range[]): Range[] {
+  ranges.sort((a, b) => a.start - b.start);
+
+  if (ranges.length <= 1) {
+    return ranges;
+  }
+
+  const stack: Range[] = [];
+  stack.push(ranges[0]);
+
+  for (let j = 1; j < ranges.length; j++) {
+    const interval = ranges[j];
+    const top = stack[stack.length - 1];
+    if (top.end < interval.start) {
+      stack.push(interval);
+    } else if (top.end < interval.end) {
+      top.end = interval.end;
+    }
+  }
+  return stack;
+}
+
+export class GrCoverageLayer implements DiffLayer {
+  /**
+   * Must be sorted by code_range.start_line.
+   * Must only contain ranges that match the side.
+   */
+  private coverageRanges: CoverageRange[] = [];
+
+  /**
+   * We keep track of the line number from the previous annotate() call,
+   * and also of the index of the coverage range that had matched.
+   * annotate() calls are coming in with increasing line numbers and
+   * coverage ranges are sorted by line number. So this is a very simple
+   * and efficient way for finding the coverage range that matches a given
+   * line number.
+   */
+  private lastLineNumber = 0;
+
+  /**
+   * See `lastLineNumber` comment.
+   */
+  private index = 0;
+
+  /**
+   * Has any line been annotated already in the lifetime of this layer?
+   * If not, then `setRanges()` does not have to call `notify()` and thus
+   * trigger re-rendering of the affected diff rows.
+   */
+  // visible for testing
+  annotated = false;
+
+  private listeners: DiffLayerListener[] = [];
+
+  constructor(private readonly side: Side) {}
+
+  addListener(listener: DiffLayerListener) {
+    this.listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this.listeners = this.listeners.filter(f => f !== listener);
+  }
+
+  /**
+   * Must be sorted by code_range.start_line.
+   * Must only contain ranges that match the side.
+   */
+  setRanges(ranges: CoverageRange[]) {
+    const oldRanges = this.coverageRanges;
+    if (oldRanges.length === 0 && ranges.length === 0) return;
+    this.coverageRanges = ranges;
+
+    // If ranges are set before any diff row was rendered, then great, no need
+    // to notify and re-render.
+    if (this.annotated) this.notify([...oldRanges, ...ranges]);
+  }
+
+  /**
+   * Notify listeners (should be just gr-diff triggering a re-render).
+   *
+   * We are optimizing the notification calls by converting the coverange ranges
+   * to an array of [start, end) ranges and then merging them to non-overlapping
+   * set of ranges.
+   */
+  private notify(ranges: CoverageRange[]) {
+    const notifyRanges = mergeRanges(
+      ranges.map(r => {
+        return {start: r.code_range.start_line, end: r.code_range.end_line + 1};
+      })
+    );
+    for (const r of notifyRanges) {
+      for (const l of this.listeners) l(r.start, r.end - 1, this.side);
+    }
+  }
+
+  /**
+   * Layer method to add annotations to a line.
+   *
+   * @param _el Not used for this layer. (unused parameter)
+   * @param lineNumberEl The <td> element with the line number.
+   * @param line Not used for this layer.
+   */
+  annotate(_el: HTMLElement, lineNumberEl: HTMLElement) {
+    if (
+      !this.side ||
+      !lineNumberEl ||
+      !lineNumberEl.classList.contains(this.side)
+    ) {
+      return;
+    }
+    let elementLineNumber;
+    const dataValue = lineNumberEl.getAttribute('data-value');
+    if (dataValue) {
+      elementLineNumber = Number(dataValue);
+    }
+    if (!elementLineNumber || elementLineNumber < 1) return;
+
+    // If the line number is smaller than before, then we have to reset our
+    // algorithm and start searching the coverage ranges from the beginning.
+    // That happens for example when you expand diff sections.
+    if (elementLineNumber < this.lastLineNumber) {
+      this.index = 0;
+    }
+    this.lastLineNumber = elementLineNumber;
+    this.annotated = true;
+
+    // We simply loop through all the coverage ranges until we find one that
+    // matches the line number.
+    while (this.index < this.coverageRanges.length) {
+      const coverageRange = this.coverageRanges[this.index];
+
+      // If the line number has moved past the current coverage range, then
+      // try the next coverage range.
+      if (this.lastLineNumber > coverageRange.code_range.end_line) {
+        this.index++;
+        continue;
+      }
+
+      // If the line number has not reached the next coverage range (and the
+      // range before also did not match), then this line has not been
+      // instrumented. Nothing to do for this line.
+      if (this.lastLineNumber < coverageRange.code_range.start_line) {
+        return;
+      }
+
+      // The line number is within the current coverage range. Style it!
+      lineNumberEl.classList.add(coverageRange.type);
+      lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type) || '';
+      return;
+    }
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
new file mode 100644
index 0000000..a8cdff6
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
@@ -0,0 +1,176 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {CoverageType, Side} from '../../../api/diff';
+import {GrCoverageLayer, mergeRanges} from './gr-coverage-layer';
+import {assert} from '@open-wc/testing';
+import {SinonStub} from 'sinon';
+
+const RANGES = [
+  {
+    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,
+    },
+  },
+  {
+    type: CoverageType.PARTIALLY_COVERED,
+    side: Side.RIGHT,
+    code_range: {
+      start_line: 5,
+      end_line: 6,
+    },
+  },
+  {
+    type: CoverageType.NOT_INSTRUMENTED,
+    side: Side.RIGHT,
+    code_range: {
+      start_line: 8,
+      end_line: 9,
+    },
+  },
+];
+
+suite('gr-coverage-layer', () => {
+  let layer: GrCoverageLayer;
+
+  test('mergeRanges', () => {
+    assert.deepEqual(mergeRanges([]), []);
+    assert.deepEqual(mergeRanges([{start: 1, end: 2}]), [{start: 1, end: 2}]);
+    assert.deepEqual(
+      mergeRanges([
+        {start: 1, end: 2},
+        {start: 2, end: 3},
+      ]),
+      [{start: 1, end: 3}]
+    );
+    assert.deepEqual(
+      mergeRanges([
+        {start: 2, end: 3},
+        {start: 1, end: 2},
+      ]),
+      [{start: 1, end: 3}]
+    );
+    assert.deepEqual(
+      mergeRanges([
+        {start: 1, end: 3},
+        {start: 4, end: 5},
+      ]),
+      [
+        {start: 1, end: 3},
+        {start: 4, end: 5},
+      ]
+    );
+  });
+
+  suite('setRanges and notify', () => {
+    let listener: SinonStub;
+
+    setup(() => {
+      layer = new GrCoverageLayer(Side.RIGHT);
+      listener = sinon.stub();
+      layer.addListener(listener);
+    });
+
+    test('empty ranges do not notify', () => {
+      layer.annotated = true;
+      layer.setRanges([]);
+      assert.isFalse(listener.called);
+    });
+
+    test('do not notify while annotated is false', () => {
+      layer.setRanges(RANGES);
+      assert.isFalse(listener.called);
+    });
+
+    test('RANGES', () => {
+      layer.annotated = true;
+      layer.setRanges(RANGES);
+      assert.isTrue(listener.called);
+      assert.equal(listener.callCount, 2);
+      assert.equal(listener.getCall(0).args[0], 1);
+      assert.equal(listener.getCall(0).args[1], 6);
+      assert.equal(listener.getCall(1).args[0], 8);
+      assert.equal(listener.getCall(1).args[1], 9);
+    });
+  });
+
+  suite('annotate', () => {
+    function createLine(lineNumber: number) {
+      const lineEl = document.createElement('div');
+      lineEl.setAttribute('data-side', Side.RIGHT);
+      lineEl.setAttribute('data-value', lineNumber.toString());
+      lineEl.className = Side.RIGHT;
+      return lineEl;
+    }
+
+    function checkLine(
+      lineNumber: number,
+      className: string,
+      negated?: boolean
+    ) {
+      const content = document.createElement('div');
+      const line = createLine(lineNumber);
+      layer.annotate(content, line);
+      let contains = line.classList.contains(className);
+      if (negated) contains = !contains;
+      assert.isTrue(contains);
+    }
+
+    setup(() => {
+      layer = new GrCoverageLayer(Side.RIGHT);
+      layer.setRanges(RANGES);
+    });
+
+    test('line 1-2 are covered', () => {
+      checkLine(1, 'COVERED');
+      checkLine(2, 'COVERED');
+    });
+
+    test('line 3-4 are not covered', () => {
+      checkLine(3, 'NOT_COVERED');
+      checkLine(4, 'NOT_COVERED');
+    });
+
+    test('line 5-6 are partially covered', () => {
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+    });
+
+    test('line 7 is implicitly not instrumented', () => {
+      checkLine(7, 'COVERED', true);
+      checkLine(7, 'NOT_COVERED', true);
+      checkLine(7, 'PARTIALLY_COVERED', true);
+      checkLine(7, 'NOT_INSTRUMENTED', true);
+    });
+
+    test('line 8-9 are not instrumented', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+    });
+
+    test('coverage correct, if annotate is called out of order', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(1, 'COVERED');
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(3, 'NOT_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+      checkLine(4, 'NOT_COVERED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+      checkLine(2, 'COVERED');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
new file mode 100644
index 0000000..5267f307
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType} from '../../../api/diff';
+import {queryAndAssert} from '../../../utils/common-util';
+import {html, render} from 'lit';
+
+export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement
+  ) {
+    super(diff, prefs, outputEl);
+  }
+
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
+    const section = createElementDiff('tbody', 'binary-diff');
+    // Do not create a diff row for 'LOST'.
+    if (group.lines[0].beforeNumber !== 'FILE') return section;
+
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
+    const fileRow = this.createRow(line);
+    const contentTd = queryAndAssert<HTMLTableCellElement>(
+      fileRow,
+      'td.both.file'
+    )!;
+    const div = document.createElement('div');
+    render(html`<span>Difference in binary files</span>`, div);
+    contentTd.insertBefore(div, contentTd.firstChild);
+    section.appendChild(fileRow);
+    return section;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
new file mode 100644
index 0000000..12d7ec2
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -0,0 +1,628 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-diff-processor/gr-diff-processor';
+import '../../../elements/shared/gr-hovercard/gr-hovercard';
+import './gr-diff-builder-side-by-side';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {
+  DiffBuilder,
+  ImageDiffBuilder,
+  DiffContextExpandedEventDetail,
+  isImageDiffBuilder,
+} from './gr-diff-builder';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {GrDiffBuilderImage} from './gr-diff-builder-image';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
+import {GrDiffBuilderLit} from './gr-diff-builder-lit';
+import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
+import {BlameInfo, ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {CoverageRange, DiffLayer} from '../../../types/types';
+import {
+  GrDiffProcessor,
+  GroupConsumer,
+  KeyLocations,
+} from '../gr-diff-processor/gr-diff-processor';
+import {
+  CommentRangeLayer,
+  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 {createDefaultDiffPrefs, Side} from '../../../constants/constants';
+import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from '../gr-diff/gr-diff-group';
+import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {assertIsDefined} from '../../../utils/common-util';
+
+const TRAILING_WHITESPACE_PATTERN = /\s+$/;
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+declare global {
+  interface HTMLElementEventMap {
+    /**
+     * Fired when the diff begins rendering - both for full renders and for
+     * partial rerenders.
+     */
+    'render-start': CustomEvent<{}>;
+    /**
+     * Fired when the diff finishes rendering text content - both for full
+     * renders and for partial rerenders.
+     */
+    'render-content': CustomEvent<{}>;
+  }
+}
+
+export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
+  return prefs.font_size * 4;
+}
+
+function annotateSymbols(
+  contentEl: HTMLElement,
+  line: GrDiffLine,
+  separator: string | RegExp,
+  className: string
+) {
+  const split = line.text.split(separator);
+  if (!split || split.length < 2) {
+    return;
+  }
+  for (let i = 0, pos = 0; i < split.length - 1; i++) {
+    // Skip forward by the length of the content
+    pos += split[i].length;
+
+    GrAnnotation.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
+
+    pos++;
+  }
+}
+
+// TODO: Rename the class and the file and remove "element". This is not an
+// element anymore.
+export class GrDiffBuilderElement implements GroupConsumer {
+  diff?: DiffInfo;
+
+  diffElement?: HTMLTableElement;
+
+  viewMode?: string;
+
+  isImageDiff?: boolean;
+
+  baseImage: ImageInfo | null = null;
+
+  revisionImage: ImageInfo | null = null;
+
+  path?: string;
+
+  prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+  renderPrefs?: RenderPreferences;
+
+  useNewImageDiffUi = false;
+
+  /**
+   * Layers passed in from the outside.
+   *
+   * See `layersInternal` for where these layers will end up together with the
+   * internal layers.
+   */
+  layers: DiffLayer[] = [];
+
+  // visible for testing
+  builder?: DiffBuilder | ImageDiffBuilder;
+
+  /**
+   * All layers, both from the outside and the default ones. See `layers` for
+   * the property that can be set from the outside.
+   */
+  // visible for testing
+  layersInternal: DiffLayer[] = [];
+
+  // visible for testing
+  showTabs?: boolean;
+
+  // visible for testing
+  showTrailingWhitespace?: boolean;
+
+  /**
+   * The promise last returned from `render()` while the asynchronous
+   * rendering is running - `null` otherwise. Provides a `cancel()`
+   * method that rejects it with `{isCancelled: true}`.
+   */
+  private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
+
+  private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
+
+  private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
+
+  private rangeLayer?: GrRangedCommentLayer;
+
+  // visible for testing
+  processor = new GrDiffProcessor();
+
+  /**
+   * Groups are mostly just passed on to the diff builder (this.builder). But
+   * we also keep track of them here for being able to fire a `render-content`
+   * event when .element of each group has rendered.
+   *
+   * TODO: Refactor DiffBuilderElement and DiffBuilders with a cleaner
+   * separation of responsibilities.
+   */
+  private groups: GrDiffGroup[] = [];
+
+  constructor() {
+    this.processor.consumer = this;
+  }
+
+  updateCommentRanges(ranges: CommentRangeLayer[]) {
+    this.rangeLayer?.updateRanges(ranges);
+  }
+
+  updateCoverageRanges(rs: CoverageRange[]) {
+    this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+    this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
+  }
+
+  render(keyLocations: KeyLocations): Promise<void> {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
+
+    // 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
+    // attached before plugins are installed.
+    this.setupAnnotationLayers();
+
+    this.showTabs = this.prefs.show_tabs;
+    this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
+
+    this.cleanup();
+    this.builder = this.getDiffBuilder();
+    this.init();
+
+    this.processor.context = this.prefs.context;
+    this.processor.keyLocations = keyLocations;
+
+    this.clearDiffContent();
+    this.builder.addColumns(
+      this.diffElement,
+      getLineNumberCellWidth(this.prefs)
+    );
+
+    const isBinary = !!(this.isImageDiff || this.diff.binary);
+
+    this.fireDiffEvent('render-start');
+    // TODO: processor.process() returns a cancelable promise already.
+    // Why wrap another one around it?
+    this.cancelableRenderPromise = makeCancelable(
+      this.processor.process(this.diff.content, isBinary)
+    );
+    // All then/catch/finally clauses must be outside of makeCancelable().
+    return (
+      this.cancelableRenderPromise
+        .then(async () => {
+          if (isImageDiffBuilder(this.builder)) {
+            this.builder.renderImageDiff();
+          }
+          await this.untilGroupsRendered();
+          this.fireDiffEvent('render-content');
+        })
+        // Mocha testing does not like uncaught rejections, so we catch
+        // the cancels which are expected and should not throw errors in
+        // tests.
+        .catch(e => {
+          if (!e.isCanceled) return Promise.reject(e);
+          return;
+        })
+        .finally(() => {
+          this.cancelableRenderPromise = null;
+        })
+    );
+  }
+
+  // visible for testing
+  async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
+    return Promise.all(groups.map(g => g.waitUntilRendered()));
+  }
+
+  private onDiffContextExpanded = (
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) => {
+    // Don't stop propagation. The host may listen for reporting or
+    // resizing.
+    this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+  };
+
+  private fireDiffEvent<K extends keyof HTMLElementEventMap>(type: K) {
+    assertIsDefined(this.diffElement, 'diff table');
+    fireEvent(this.diffElement, type);
+  }
+
+  // visible for testing
+  setupAnnotationLayers() {
+    this.rangeLayer = new GrRangedCommentLayer();
+
+    const layers: DiffLayer[] = [
+      this.createTrailingWhitespaceLayer(),
+      this.createIntralineLayer(),
+      this.createTabIndicatorLayer(),
+      this.createSpecialCharacterIndicatorLayer(),
+      this.rangeLayer,
+      this.coverageLayerLeft,
+      this.coverageLayerRight,
+    ];
+
+    if (this.layers) {
+      layers.push(...this.layers);
+    }
+    this.layersInternal = layers;
+  }
+
+  getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
+    if (!this.builder) return null;
+    return this.builder.getContentTdByLine(lineNumber, side, root);
+  }
+
+  private getDiffRowByChild(child: Element) {
+    while (!child.classList.contains('diff-row') && child.parentElement) {
+      child = child.parentElement;
+    }
+    return child;
+  }
+
+  getContentTdByLineEl(lineEl?: Element): Element | null {
+    if (!lineEl) return null;
+    const line = getLineNumber(lineEl);
+    if (!line) return null;
+    const side = getSideByLineEl(lineEl);
+    // Performance optimization because we already have an element in the
+    // correct row
+    const row = this.getDiffRowByChild(lineEl);
+    return this.getContentTdByLine(line, side, row);
+  }
+
+  getLineElByNumber(lineNumber: LineNumber, side?: Side) {
+    if (!this.builder) return null;
+    return this.builder.getLineElByNumber(lineNumber, side);
+  }
+
+  getLineNumberRows() {
+    if (!this.builder) return [];
+    return this.builder.getLineNumberRows();
+  }
+
+  getLineNumEls(side: Side) {
+    if (!this.builder) return [];
+    return this.builder.getLineNumEls(side);
+  }
+
+  /**
+   * When the line is hidden behind a context expander, expand it.
+   *
+   * @param lineNum A line number to expand. Using number here because other
+   *   special case line numbers are never hidden, so it does not make sense
+   *   to expand them.
+   * @param side The side the line number refer to.
+   */
+  unhideLine(lineNum: number, side: Side) {
+    if (!this.builder) return;
+    const group = this.builder.findGroup(side, lineNum);
+    // Cannot unhide a line that is not part of the diff.
+    if (!group) return;
+    // If it's already visible, great!
+    if (group.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+    const lineRange = group.lineRange[side];
+    const lineOffset = lineNum - lineRange.start_line;
+    const newGroups = [];
+    const groups = hideInContextControl(
+      group.contextGroups,
+      0,
+      lineOffset - 1 - this.prefs.context
+    );
+    // If there is a context group, it will be the first group because we
+    // start hiding from 0 offset
+    if (groups[0].type === GrDiffGroupType.CONTEXT_CONTROL) {
+      newGroups.push(groups.shift()!);
+    }
+    newGroups.push(
+      ...hideInContextControl(
+        groups,
+        lineOffset + 1 + this.prefs.context,
+        // Both ends inclusive, so difference is the offset of the last line.
+        // But we need to pass the first line not to hide, which is the element
+        // after.
+        lineRange.end_line - lineRange.start_line + 1
+      )
+    );
+    this.replaceGroup(group, newGroups);
+  }
+
+  /**
+   * Replace the group of a context control section by rendering the provided
+   * groups instead. This happens in response to expanding a context control
+   * group.
+   *
+   * @param contextGroup The context control group to replace
+   * @param newGroups The groups that are replacing the context control group
+   */
+  private replaceGroup(
+    contextGroup: GrDiffGroup,
+    newGroups: readonly GrDiffGroup[]
+  ) {
+    if (!this.builder) return;
+    this.fireDiffEvent('render-start');
+    this.builder.replaceGroup(contextGroup, newGroups);
+    this.groups = this.groups.filter(g => g !== contextGroup);
+    this.groups.push(...newGroups);
+    this.untilGroupsRendered(newGroups).then(() => {
+      this.fireDiffEvent('render-content');
+    });
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component re-connects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with cleanup(), which is called
+   * when gr-diff disconnects.
+   */
+  init() {
+    this.cleanup();
+    this.diffElement?.addEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
+    this.builder?.init();
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component disconnects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with init(), which is called when
+   * gr-diff re-connects.
+   */
+  cleanup() {
+    this.processor.cancel();
+    this.builder?.cleanup();
+    this.cancelableRenderPromise?.cancel();
+    this.cancelableRenderPromise = null;
+    this.diffElement?.removeEventListener(
+      'diff-context-expanded',
+      this.onDiffContextExpanded
+    );
+  }
+
+  // visible for testing
+  handlePreferenceError(pref: string): never {
+    const message =
+      `The value of the '${pref}' user preference is ` +
+      'invalid. Fix in diff preferences';
+    assertIsDefined(this.diffElement, 'diff table');
+    fireAlert(this.diffElement, message);
+    throw Error(`Invalid preference value: ${pref}`);
+  }
+
+  // visible for testing
+  getDiffBuilder(): DiffBuilder {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffElement, 'diff table');
+    if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
+      this.handlePreferenceError('tab size');
+    }
+
+    if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
+      this.handlePreferenceError('diff width');
+    }
+
+    const localPrefs = {...this.prefs};
+    if (this.path === COMMIT_MSG_PATH) {
+      // override line_length for commit msg the same way as
+      // in gr-diff
+      localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
+    }
+
+    let builder = null;
+    const useLit = this.renderPrefs?.use_lit_components ?? false;
+    if (this.isImageDiff) {
+      builder = new GrDiffBuilderImage(
+        this.diff,
+        localPrefs,
+        this.diffElement,
+        this.baseImage,
+        this.revisionImage,
+        this.renderPrefs,
+        this.useNewImageDiffUi
+      );
+    } else if (this.diff.binary) {
+      // If the diff is binary, but not an image.
+      return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
+    } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      this.renderPrefs = {
+        ...this.renderPrefs,
+        view_mode: DiffViewMode.SIDE_BY_SIDE,
+      };
+      if (useLit) {
+        builder = new GrDiffBuilderLit(
+          this.diff,
+          localPrefs,
+          this.diffElement,
+          this.layersInternal,
+          this.renderPrefs
+        );
+      } else {
+        builder = new GrDiffBuilderSideBySide(
+          this.diff,
+          localPrefs,
+          this.diffElement,
+          this.layersInternal,
+          this.renderPrefs
+        );
+      }
+    } else if (this.viewMode === DiffViewMode.UNIFIED) {
+      this.renderPrefs = {
+        ...this.renderPrefs,
+        view_mode: DiffViewMode.UNIFIED,
+      };
+      if (useLit) {
+        builder = new GrDiffBuilderLit(
+          this.diff,
+          localPrefs,
+          this.diffElement,
+          this.layersInternal,
+          this.renderPrefs
+        );
+      } else {
+        builder = new GrDiffBuilderUnified(
+          this.diff,
+          localPrefs,
+          this.diffElement,
+          this.layersInternal,
+          this.renderPrefs
+        );
+      }
+    }
+    if (!builder) {
+      throw Error(`Unsupported diff view mode: ${this.viewMode}`);
+    }
+    return builder;
+  }
+
+  private clearDiffContent() {
+    assertIsDefined(this.diffElement, 'diff table');
+    this.diffElement.innerHTML = '';
+  }
+
+  /**
+   * Called when the processor starts converting the diff information from the
+   * server into chunks.
+   */
+  clearGroups() {
+    if (!this.builder) return;
+    this.groups = [];
+    this.builder.clearGroups();
+  }
+
+  /**
+   * Called when the processor is done converting a chunk of the diff.
+   */
+  addGroup(group: GrDiffGroup) {
+    if (!this.builder) return;
+    this.builder.addGroups([group]);
+    this.groups.push(group);
+  }
+
+  // visible for testing
+  createIntralineLayer(): DiffLayer {
+    return {
+      // Take a DIV.contentText element and a line object with intraline
+      // differences to highlight and apply them to the element as
+      // annotations.
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        const HL_CLASS = 'gr-diff intraline';
+        for (const highlight of line.highlights) {
+          // The start and end indices could be the same if a highlight is
+          // meant to start at the end of a line and continue onto the
+          // next one. Ignore it.
+          if (highlight.startIndex === highlight.endIndex) {
+            continue;
+          }
+
+          // If endIndex isn't present, continue to the end of the line.
+          const endIndex =
+            highlight.endIndex === undefined
+              ? GrAnnotation.getStringLength(line.text)
+              : highlight.endIndex;
+
+          GrAnnotation.annotateElement(
+            contentEl,
+            highlight.startIndex,
+            endIndex - highlight.startIndex,
+            HL_CLASS
+          );
+        }
+      },
+    };
+  }
+
+  // visible for testing
+  createTabIndicatorLayer(): DiffLayer {
+    const show = () => this.showTabs;
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        // If visible tabs are disabled, do nothing.
+        if (!show()) {
+          return;
+        }
+
+        // Find and annotate the locations of tabs.
+        annotateSymbols(contentEl, line, '\t', 'tab-indicator');
+      },
+    };
+  }
+
+  private createSpecialCharacterIndicatorLayer(): DiffLayer {
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        // Find and annotate the locations of soft hyphen (\u00AD)
+        annotateSymbols(contentEl, line, '\u00AD', 'special-char-indicator');
+        // Find and annotate Stateful Unicode directional controls
+        annotateSymbols(
+          contentEl,
+          line,
+          /[\u202A-\u202E\u2066-\u2069]/,
+          'special-char-warning'
+        );
+      },
+    };
+  }
+
+  // visible for testing
+  createTrailingWhitespaceLayer(): DiffLayer {
+    const show = () => this.showTrailingWhitespace;
+
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        if (!show()) {
+          return;
+        }
+
+        const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+        if (match) {
+          // Normalize string positions in case there is unicode before or
+          // within the match.
+          const index = GrAnnotation.getStringLength(
+            line.text.substr(0, match.index)
+          );
+          const length = GrAnnotation.getStringLength(match[0]);
+          GrAnnotation.annotateElement(
+            contentEl,
+            index,
+            length,
+            'gr-diff trailing-whitespace'
+          );
+        }
+      },
+    };
+  }
+
+  setBlame(blame: BlameInfo[] | null) {
+    if (!this.builder) return;
+    this.builder.setBlame(blame ?? []);
+  }
+
+  updateRenderPrefs(renderPrefs: RenderPreferences) {
+    this.builder?.updateRenderPrefs(renderPrefs);
+    this.processor.updateRenderPrefs(renderPrefs);
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
new file mode 100644
index 0000000..0f02d71
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -0,0 +1,1171 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {
+  createConfig,
+  createDiff,
+  createEmptyDiff,
+} from '../../../test/test-data-generators';
+import './gr-diff-builder-element';
+import {queryAndAssert, stubBaseUrl, waitUntil} from '../../../test/test-utils';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffLayer,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  Side,
+} from '../../../api/diff';
+import {stubRestApi} from '../../../test/test-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiffBuilderElement} from './gr-diff-builder-element';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {BlameInfo} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
+import {EventType} from '../../../types/events';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
+
+suite('gr-diff-builder tests', () => {
+  let element: GrDiffBuilderElement;
+  let builder: GrDiffBuilderLegacy;
+  let diffTable: HTMLTableElement;
+
+  const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
+  const WBR_HTML = '<wbr class="gr-diff">';
+
+  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
+    builder = new GrDiffBuilderSideBySide(
+      createEmptyDiff(),
+      {...createDefaultDiffPrefs(), ...prefs},
+      diffTable
+    );
+  };
+
+  const line = (text: string) => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.text = text;
+    return line;
+  };
+
+  setup(async () => {
+    diffTable = await fixture(html`<table id="diffTable"></table>`);
+    element = new GrDiffBuilderElement();
+    element.diffElement = diffTable;
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+    stubBaseUrl('/r');
+    setBuilderPrefs({});
+  });
+
+  test('line_length applied with <wbr> if line_wrapping is true', () => {
+    setBuilderPrefs({line_wrapping: true, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.firstElementChild?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  test('line_length applied with line break if line_wrapping is false', () => {
+    setBuilderPrefs({line_wrapping: false, tab_size: 4, line_length: 50});
+    const text = 'a'.repeat(51);
+    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
+    const result = builder.createTextEl(null, line(text)).firstElementChild
+      ?.firstElementChild?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+    test(`line_length used for regular files under ${mode}`, () => {
+      element.path = '/a.txt';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 50);
+    });
+
+    test(`line_length ignored for commit msg under ${mode}`, () => {
+      element.path = '/COMMIT_MSG';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
+      assert.equal(builder._prefs.line_length, 72);
+    });
+  });
+
+  test('createTextEl linewrap with tabs', () => {
+    setBuilderPrefs({tab_size: 4, line_length: 10});
+    const text = '\t'.repeat(7) + '!';
+    const el = builder.createTextEl(null, line(text));
+    assert.equal(el.innerText, text);
+    // With line length 10 and tab size 4, there should be a line break
+    // after every two tabs.
+    const newlineEl = el.querySelector('.contentText .br');
+    assert.isOk(newlineEl);
+    assert.equal(
+      el.querySelector('.contentText .tab:nth-child(2)')?.nextSibling,
+      newlineEl
+    );
+  });
+
+  test('_handlePreferenceError throws with invalid preference', () => {
+    element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
+    assert.throws(() => element.getDiffBuilder());
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    diffTable.addEventListener(EventType.SHOW_ALERT, errorStub);
+    assert.throws(() => element.handlePreferenceError('tab size'));
+    assert.equal(
+      errorStub.lastCall.args[0].detail.message,
+      "The value of the 'tab size' user preference is invalid. " +
+        'Fix in diff preferences'
+    );
+  });
+
+  suite('intraline differences', () => {
+    let el: HTMLElement;
+    let str: string;
+    let annotateElementSpy: sinon.SinonSpy;
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str: string, start: number, end?: number) {
+      return Array.from(str).slice(start, end).join('');
+    }
+
+    setup(async () => {
+      el = await fixture(html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `);
+      str = el.textContent ?? '';
+      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+      layer = element.createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const l = line(str);
+      l.highlights = [
+        {contentIndex: 0, startIndex: 6, endIndex: 12},
+        {contentIndex: 0, startIndex: 18, endIndex: 22},
+      ];
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28}];
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+      const numHighlightedChars = GrAnnotation.getStringLength(str1);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.calledWith(el, 6, numHighlightedChars));
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTabs = true;
+      layer = element.createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTabs = false;
+
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let initialLayersCount = 0;
+    let withLayerCount = 0;
+    setup(() => {
+      const layers: DiffLayer[] = [];
+      element.layers = layers;
+      element.showTrailingWhitespace = true;
+      element.setupAnnotationLayers();
+      initialLayersCount = element.layersInternal.length;
+    });
+
+    test('no layers', () => {
+      element.setupAnnotationLayers();
+      assert.equal(element.layersInternal.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
+      setup(() => {
+        element.layers = layers;
+        element.showTrailingWhitespace = true;
+        element.setupAnnotationLayers();
+        withLayerCount = element.layersInternal.length;
+      });
+      test('with layers', () => {
+        element.setupAnnotationLayers();
+        assert.equal(element.layersInternal.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length, withLayerCount);
+      });
+    });
+  });
+
+  suite('trailing whitespace', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTrailingWhitespace = true;
+      layer = element.createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let processStub: sinon.SinonStub;
+    let keyLocations: KeyLocations;
+    let content: DiffContent[] = [];
+
+    setup(() => {
+      element.viewMode = 'SIDE_BY_SIDE';
+      processStub = sinon
+        .stub(element.processor, 'process')
+        .returns(Promise.resolve());
+      keyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+    });
+
+    test('text', async () => {
+      element.diff = {...createEmptyDiff(), content};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isFalse(processStub.lastCall.args[1]);
+    });
+
+    test('image', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.isImageDiff = true;
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+
+    test('binary', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.render(keyLocations);
+      await waitForEventOnce(diffTable, 'render-content');
+      assert.isTrue(processStub.calledOnce);
+      assert.isTrue(processStub.lastCall.args[1]);
+    });
+  });
+
+  suite('rendering', () => {
+    let content: DiffContent[];
+    let outputEl: HTMLTableElement;
+    let keyLocations: KeyLocations;
+    let addColumnsStub: sinon.SinonStub;
+    let dispatchStub: sinon.SinonStub;
+    let builder: GrDiffBuilderSideBySide;
+
+    setup(() => {
+      const prefs = {...DEFAULT_PREFS};
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      outputEl = element.diffElement!;
+      keyLocations = {left: {}, right: {}};
+      sinon.stub(element, 'getDiffBuilder').callsFake(() => {
+        builder = new GrDiffBuilderSideBySide(
+          {...createEmptyDiff(), content},
+          prefs,
+          outputEl
+        );
+        addColumnsStub = sinon.stub(builder, 'addColumns');
+        builder.buildSectionElement = function (group) {
+          const section = document.createElement('stub');
+          section.style.display = 'block';
+          section.textContent = group.lines.reduce(
+            (acc, line) => acc + line.text,
+            ''
+          );
+          return section;
+        };
+        return builder;
+      });
+      element.diff = {...createEmptyDiff(), content};
+      element.prefs = prefs;
+      element.render(keyLocations);
+    });
+
+    test('addColumns is called', () => {
+      assert.isTrue(addColumnsStub.called);
+    });
+
+    test('getGroupsByLineRange one line', () => {
+      const section = outputEl.querySelector<HTMLElement>(
+        'stub:nth-of-type(3)'
+      );
+      const groups = builder.getGroupsByLineRange(1, 1, Side.LEFT);
+      assert.equal(groups.length, 1);
+      assert.strictEqual(groups[0].element, section);
+    });
+
+    test('getGroupsByLineRange over diff', () => {
+      const section = [
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(3)'),
+        outputEl.querySelector<HTMLElement>('stub:nth-of-type(4)'),
+      ];
+      const groups = builder.getGroupsByLineRange(1, 2, Side.LEFT);
+      assert.equal(groups.length, 2);
+      assert.strictEqual(groups[0].element, section[0]);
+      assert.strictEqual(groups[1].element, section[1]);
+    });
+
+    test('render-start and render-content are fired', async () => {
+      await waitUntil(() => dispatchStub.callCount >= 1);
+      let firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-start');
+
+      await waitUntil(() => dispatchStub.callCount >= 2);
+      firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+
+    test('cancel cancels the processor', () => {
+      const processorCancelStub = sinon.stub(element.processor, 'cancel');
+      element.cleanup();
+      assert.isTrue(processorCancelStub.called);
+    });
+  });
+
+  suite('context hiding and expanding', () => {
+    let dispatchStub: sinon.SinonStub;
+
+    setup(async () => {
+      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
+      element.diff = {
+        ...createEmptyDiff(),
+        content: [
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
+          {a: ['before'], b: ['after']},
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
+        ],
+      };
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+      const keyLocations: KeyLocations = {left: {}, right: {}};
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: 1,
+      };
+      element.render(keyLocations);
+      // Make sure all listeners are installed.
+      await element.untilGroupsRendered();
+    });
+
+    test('hides lines behind two context controls', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      assert.equal(contextControls.length, 2);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 10');
+      assert.include(diffRows[3].textContent, 'before');
+      assert.include(diffRows[3].textContent, 'after');
+      assert.include(diffRows[4].textContent, 'unchanged 11');
+    });
+
+    test('clicking +x common lines expands those lines', () => {
+      const contextControls = diffTable.querySelectorAll('gr-context-controls');
+      const topExpandCommonButton =
+        contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+          '.showContext'
+        )[0];
+      assert.isOk(topExpandCommonButton);
+      assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+      topExpandCommonButton!.click();
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 10 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 6');
+      assert.include(diffRows[8].textContent, 'unchanged 7');
+      assert.include(diffRows[9].textContent, 'unchanged 8');
+      assert.include(diffRows[10].textContent, 'unchanged 9');
+      assert.include(diffRows[11].textContent, 'unchanged 10');
+      assert.include(diffRows[12].textContent, 'before');
+      assert.include(diffRows[12].textContent, 'after');
+      assert.include(diffRows[13].textContent, 'unchanged 11');
+    });
+
+    test('unhideLine shows the line with context', async () => {
+      dispatchStub.reset();
+      element.unhideLine(4, Side.LEFT);
+
+      const diffRows = diffTable.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
+      // Because context expanders do not hide <3 lines, lines 1-2 will also
+      // be shown.
+      // Lines 6-9 continue to be hidden
+      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 10');
+      assert.include(diffRows[8].textContent, 'before');
+      assert.include(diffRows[8].textContent, 'after');
+      assert.include(diffRows[9].textContent, 'unchanged 11');
+
+      await element.untilGroupsRendered();
+      const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+    suite(`mock-diff mode:${mode}`, () => {
+      let builder: GrDiffBuilderSideBySide;
+      let diff: DiffInfo;
+      let keyLocations: KeyLocations;
+
+      setup(() => {
+        element.viewMode = mode;
+        diff = createDiff();
+        element.diff = diff;
+
+        keyLocations = {left: {}, right: {}};
+
+        element.prefs = {
+          ...createDefaultDiffPrefs(),
+          line_length: 80,
+          show_tabs: true,
+          tab_size: 4,
+        };
+        element.render(keyLocations);
+        builder = element.builder as GrDiffBuilderSideBySide;
+      });
+
+      test('aria-labels on added line numbers', () => {
+        const deltaLineNumberButton = diffTable.querySelectorAll(
+          '.lineNumButton.right'
+        )[5];
+
+        assert.isOk(deltaLineNumberButton);
+        assert.equal(
+          deltaLineNumberButton.getAttribute('aria-label'),
+          '5 added'
+        );
+      });
+
+      test('aria-labels on removed line numbers', () => {
+        const deltaLineNumberButton = diffTable.querySelectorAll(
+          '.lineNumButton.left'
+        )[10];
+
+        assert.isOk(deltaLineNumberButton);
+        assert.equal(
+          deltaLineNumberButton.getAttribute('aria-label'),
+          '10 removed'
+        );
+      });
+
+      test('getContentByLine', () => {
+        let actual: HTMLElement | null;
+
+        actual = builder.getContentByLine(2, Side.LEFT);
+        assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+        actual = builder.getContentByLine(2, Side.RIGHT);
+        assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
+
+        actual = builder.getContentByLine(5, Side.LEFT);
+        assert.equal(actual?.textContent, diff.content[2].ab?.[0]);
+
+        actual = builder.getContentByLine(5, Side.RIGHT);
+        assert.equal(actual?.textContent, diff.content[1].b?.[0]);
+      });
+
+      test('getContentTdByLineEl works both with button and td', () => {
+        const diffRow = diffTable.querySelectorAll('tr.diff-row')[2];
+
+        const lineNumTdLeft = queryAndAssert(diffRow, 'td.lineNum.left');
+        const lineNumButtonLeft = queryAndAssert(lineNumTdLeft, 'button');
+        const contentTdLeft = diffRow.querySelectorAll('.content')[0];
+
+        const lineNumTdRight = queryAndAssert(diffRow, 'td.lineNum.right');
+        const lineNumButtonRight = queryAndAssert(lineNumTdRight, 'button');
+        const contentTdRight =
+          mode === DiffViewMode.SIDE_BY_SIDE
+            ? diffRow.querySelectorAll('.content')[1]
+            : contentTdLeft;
+
+        assert.equal(
+          element.getContentTdByLineEl(lineNumTdLeft),
+          contentTdLeft
+        );
+        assert.equal(
+          element.getContentTdByLineEl(lineNumButtonLeft),
+          contentTdLeft
+        );
+        assert.equal(
+          element.getContentTdByLineEl(lineNumTdRight),
+          contentTdRight
+        );
+        assert.equal(
+          element.getContentTdByLineEl(lineNumButtonRight),
+          contentTdRight
+        );
+      });
+
+      test('findLinesByRange LEFT', () => {
+        const lines: GrDiffLine[] = [];
+        const elems: HTMLElement[] = [];
+        const start = 1;
+        const end = 44;
+
+        // lines 26-29 are collapsed, so minus 4
+        let count = end - start + 1 - 4;
+        // Lines 14+15 are part of a 'common' chunk. And we have a bug in
+        // unified diff that results in not rendering these lines for the LEFT
+        // side. TODO: Fix that bug!
+        if (mode === DiffViewMode.UNIFIED) count -= 2;
+
+        builder.findLinesByRange(start, end, Side.LEFT, lines, elems);
+
+        assert.equal(lines.length, count);
+        assert.equal(elems.length, count);
+
+        for (let i = 0; i < count; i++) {
+          assert.instanceOf(lines[i], GrDiffLine);
+          assert.instanceOf(elems[i], HTMLElement);
+          assert.equal(lines[i].text, elems[i].textContent);
+        }
+      });
+
+      test('findLinesByRange RIGHT', () => {
+        const lines: GrDiffLine[] = [];
+        const elems: HTMLElement[] = [];
+        const start = 1;
+        const end = 48;
+
+        // lines 26-29 are collapsed, so minus 4
+        const count = end - start + 1 - 4;
+
+        builder.findLinesByRange(start, end, Side.RIGHT, lines, elems);
+
+        assert.equal(lines.length, count);
+        assert.equal(elems.length, count);
+
+        for (let i = 0; i < count; i++) {
+          assert.instanceOf(lines[i], GrDiffLine);
+          assert.instanceOf(elems[i], HTMLElement);
+          assert.equal(lines[i].text, elems[i].textContent);
+        }
+      });
+
+      test('renderContentByRange', () => {
+        const spy = sinon.spy(builder, 'createTextEl');
+        const start = 9;
+        const end = 14;
+        let count = end - start + 1;
+        // Lines 14+15 are part of a 'common' chunk. And we have a bug in
+        // unified diff that results in not rendering these lines for the LEFT
+        // side. TODO: Fix that bug!
+        if (mode === DiffViewMode.UNIFIED) count -= 1;
+
+        builder.renderContentByRange(start, end, Side.LEFT);
+
+        assert.equal(spy.callCount, count);
+        spy.getCalls().forEach((call, i: number) => {
+          assert.equal(call.args[1].beforeNumber, start + i);
+        });
+      });
+
+      test('renderContentByRange non-existent elements', () => {
+        const spy = sinon.spy(builder, 'createTextEl');
+
+        sinon
+          .stub(builder, 'getLineNumberEl')
+          .returns(document.createElement('div'));
+        sinon
+          .stub(builder, 'findLinesByRange')
+          .callsFake((_1, _2, _3, lines, elements) => {
+            // Add a line and a corresponding element.
+            lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+            const tr = document.createElement('tr');
+            const td = document.createElement('td');
+            const el = document.createElement('div');
+            tr.appendChild(td);
+            td.appendChild(el);
+            elements?.push(el);
+
+            // Add 2 lines without corresponding elements.
+            lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+            lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
+          });
+
+        builder.renderContentByRange(1, 10, Side.LEFT);
+        // Should be called only once because only one line had a corresponding
+        // element.
+        assert.equal(spy.callCount, 1);
+      });
+
+      test('getLineNumberEl side-by-side left', () => {
+        const contentEl = builder.getContentByLine(
+          5,
+          Side.LEFT,
+          element.diffElement as HTMLTableElement
+        );
+        assert.isOk(contentEl);
+        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+        assert.isOk(lineNumberEl);
+        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+      });
+
+      test('getLineNumberEl side-by-side right', () => {
+        const contentEl = builder.getContentByLine(
+          5,
+          Side.RIGHT,
+          element.diffElement as HTMLTableElement
+        );
+        assert.isOk(contentEl);
+        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+        assert.isOk(lineNumberEl);
+        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+      });
+
+      test('getLineNumberEl unified left', async () => {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render(keyLocations);
+        builder = element.builder as GrDiffBuilderSideBySide;
+
+        const contentEl = builder.getContentByLine(
+          5,
+          Side.LEFT,
+          element.diffElement as HTMLTableElement
+        );
+        assert.isOk(contentEl);
+        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
+        assert.isOk(lineNumberEl);
+        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
+      });
+
+      test('getLineNumberEl unified right', async () => {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render(keyLocations);
+        builder = element.builder as GrDiffBuilderSideBySide;
+
+        const contentEl = builder.getContentByLine(
+          5,
+          Side.RIGHT,
+          element.diffElement as HTMLTableElement
+        );
+        assert.isOk(contentEl);
+        const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
+        assert.isOk(lineNumberEl);
+        assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
+        assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
+      });
+
+      test('getNextContentOnSide side-by-side left', () => {
+        const startElem = builder.getContentByLine(
+          5,
+          Side.LEFT,
+          element.diffElement as HTMLTableElement
+        );
+        assert.isOk(startElem);
+        const expectedStartString = diff.content[2].ab?.[0];
+        const expectedNextString = diff.content[2].ab?.[1];
+        assert.equal(startElem!.textContent, expectedStartString);
+
+        const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+        assert.isOk(nextElem);
+        assert.equal(nextElem!.textContent, expectedNextString);
+      });
+
+      test('getNextContentOnSide side-by-side right', () => {
+        const startElem = builder.getContentByLine(
+          5,
+          Side.RIGHT,
+          element.diffElement as HTMLTableElement
+        );
+        const expectedStartString = diff.content[1].b?.[0];
+        const expectedNextString = diff.content[1].b?.[1];
+        assert.isOk(startElem);
+        assert.equal(startElem!.textContent, expectedStartString);
+
+        const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+        assert.isOk(nextElem);
+        assert.equal(nextElem!.textContent, expectedNextString);
+      });
+
+      test('getNextContentOnSide unified left', async () => {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render(keyLocations);
+        builder = element.builder as GrDiffBuilderSideBySide;
+
+        const startElem = builder.getContentByLine(
+          5,
+          Side.LEFT,
+          element.diffElement as HTMLTableElement
+        );
+        const expectedStartString = diff.content[2].ab?.[0];
+        const expectedNextString = diff.content[2].ab?.[1];
+        assert.isOk(startElem);
+        assert.equal(startElem!.textContent, expectedStartString);
+
+        const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
+        assert.isOk(nextElem);
+        assert.equal(nextElem!.textContent, expectedNextString);
+      });
+
+      test('getNextContentOnSide unified right', async () => {
+        // Re-render as unified:
+        element.viewMode = 'UNIFIED_DIFF';
+        element.render(keyLocations);
+        builder = element.builder as GrDiffBuilderSideBySide;
+
+        const startElem = builder.getContentByLine(
+          5,
+          Side.RIGHT,
+          element.diffElement as HTMLTableElement
+        );
+        const expectedStartString = diff.content[1].b?.[0];
+        const expectedNextString = diff.content[1].b?.[1];
+        assert.isOk(startElem);
+        assert.equal(startElem!.textContent, expectedStartString);
+
+        const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
+        assert.isOk(nextElem);
+        assert.equal(nextElem!.textContent, expectedNextString);
+      });
+    });
+  });
+
+  suite('blame', () => {
+    let mockBlame: BlameInfo[];
+
+    setup(() => {
+      mockBlame = [
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 1',
+          ranges: [
+            {start: 1, end: 2},
+            {start: 10, end: 16},
+          ],
+        },
+        {
+          author: 'test-author',
+          time: 314,
+          commit_msg: 'test-commit-message',
+          id: 'commit 2',
+          ranges: [
+            {start: 4, end: 10},
+            {start: 17, end: 32},
+          ],
+        },
+      ];
+    });
+
+    test('setBlame attempts to render each blamed line', () => {
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameTdByLine')
+        .returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.equal(getBlameStub.callCount, 32);
+    });
+
+    test('getBlameCommitForBaseLine', () => {
+      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
+      builder.setBlame(mockBlame);
+      assert.isOk(builder.getBlameCommitForBaseLine(1));
+      assert.equal(builder.getBlameCommitForBaseLine(1)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(11));
+      assert.equal(builder.getBlameCommitForBaseLine(11)?.id, 'commit 1');
+
+      assert.isOk(builder.getBlameCommitForBaseLine(32));
+      assert.equal(builder.getBlameCommitForBaseLine(32)?.id, 'commit 2');
+
+      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
+    });
+
+    test('getBlameCommitForBaseLine w/o blame returns null', () => {
+      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
+    });
+
+    test('createBlameCell', () => {
+      const mockBlameInfo = {
+        time: 1576155200,
+        id: '1234567890',
+        author: 'Clark Kent',
+        commit_msg: 'Testing Commit',
+        ranges: [{start: 4, end: 10}],
+      };
+      const getBlameStub = sinon
+        .stub(builder, 'getBlameCommitForBaseLine')
+        .returns(mockBlameInfo);
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      const result = builder.createBlameCell(line.beforeNumber);
+
+      assert.isTrue(getBlameStub.calledWithExactly(3));
+      assert.equal(result.getAttribute('data-line-number'), '3');
+      assert.dom.equal(
+        result,
+        /* HTML */ `
+          <span class="gr-diff">
+            <a class="blameDate gr-diff" href="/r/q/1234567890"> 12/12/2019 </a>
+            <span class="blameAuthor gr-diff">Clark</span>
+            <gr-hovercard class="gr-diff">
+              <span class="blameHoverCard gr-diff">
+                Commit 1234567890<br />
+                Author: Clark Kent<br />
+                Date: 12/12/2019<br />
+                <br />
+                Testing Commit
+              </span>
+            </gr-hovercard>
+          </span>
+        `
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
new file mode 100644
index 0000000..0ea904a
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -0,0 +1,267 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {RenderPreferences, Side} from '../../../api/diff';
+import '../gr-diff-image-viewer/gr-image-viewer';
+import {ImageDiffBuilder} from './gr-diff-builder';
+import {html, LitElement, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+
+// MIME types for images we allow showing. Do not include SVG, it can contain
+// arbitrary JavaScript.
+const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
+
+export class GrDiffBuilderImage
+  extends GrDiffBuilderSideBySide
+  implements ImageDiffBuilder
+{
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    private readonly baseImage: ImageInfo | null,
+    private readonly revisionImage: ImageInfo | null,
+    renderPrefs?: RenderPreferences,
+    private readonly useNewImageDiffUi: boolean = false
+  ) {
+    super(diff, prefs, outputEl, [], renderPrefs);
+  }
+
+  public renderImageDiff() {
+    const imageDiff = this.useNewImageDiffUi
+      ? this.createImageDiffNew()
+      : this.createImageDiffOld();
+    this.outputEl.appendChild(imageDiff);
+  }
+
+  private createImageDiffNew() {
+    const imageDiff = document.createElement('gr-diff-image-new');
+    imageDiff.automaticBlink = this.autoBlink();
+    imageDiff.baseImage = this.baseImage ?? undefined;
+    imageDiff.revisionImage = this.revisionImage ?? undefined;
+    return imageDiff;
+  }
+
+  private createImageDiffOld() {
+    const imageDiff = document.createElement('gr-diff-image-old');
+    imageDiff.baseImage = this.baseImage ?? undefined;
+    imageDiff.revisionImage = this.revisionImage ?? undefined;
+    return imageDiff;
+  }
+
+  private autoBlink(): boolean {
+    return !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
+  }
+
+  override updateRenderPrefs(renderPrefs: RenderPreferences) {
+    this.renderPrefs = renderPrefs;
+
+    // We have to update `imageDiff.automaticBlink` manually, because `this` is
+    // not a LitElement.
+    const imageDiff = this.outputEl.querySelector(
+      'gr-diff-image-new'
+    ) as GrDiffImageNew;
+    if (imageDiff) imageDiff.automaticBlink = this.autoBlink();
+  }
+}
+
+@customElement('gr-diff-image-new')
+class GrDiffImageNew extends LitElement {
+  @property() baseImage?: ImageInfo;
+
+  @property() revisionImage?: ImageInfo;
+
+  @property() automaticBlink = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override render() {
+    return html`
+      <tbody class="gr-diff image-diff">
+        <tr class="gr-diff">
+          <td class="gr-diff" colspan="4">
+            <gr-image-viewer
+              class="gr-diff"
+              .baseUrl=${imageSrc(this.baseImage)}
+              .revisionUrl=${imageSrc(this.revisionImage)}
+              .automaticBlink=${this.automaticBlink}
+            >
+            </gr-image-viewer>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+}
+
+@customElement('gr-diff-image-old')
+class GrDiffImageOld extends LitElement {
+  @property() baseImage?: ImageInfo;
+
+  @property() revisionImage?: ImageInfo;
+
+  @query('img.left') baseImageEl?: HTMLImageElement;
+
+  @query('img.right') revisionImageEl?: HTMLImageElement;
+
+  @state() baseError?: string;
+
+  @state() revisionError?: string;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override render() {
+    return html`
+      <tbody class="gr-diff image-diff">
+        ${this.renderImagePairRow()} ${this.renderImageLabelRow()}
+      </tbody>
+      ${this.renderEndpoint()}
+    `;
+  }
+
+  private renderEndpoint() {
+    return html`
+      <tbody class="gr-diff endpoint">
+        <tr class="gr-diff">
+          <td class="gr-diff" colspan="4">
+            <gr-endpoint-decorator class="gr-diff" name="image-diff">
+              ${this.renderEndpointParam('baseImage', this.baseImage)}
+              ${this.renderEndpointParam('revisionImage', this.revisionImage)}
+            </gr-endpoint-decorator>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+
+  private renderEndpointParam(name: string, value: unknown) {
+    if (!value) return nothing;
+    return html`
+      <gr-endpoint-param class="gr-diff" name=${name} .value=${value}>
+      </gr-endpoint-param>
+    `;
+  }
+
+  private renderImagePairRow() {
+    return html`
+      <tr class="gr-diff">
+        <td class="gr-diff left lineNum blank"></td>
+        <td class="gr-diff left">${this.renderImage(Side.LEFT)}</td>
+        <td class="gr-diff right lineNum blank"></td>
+        <td class="gr-diff right">${this.renderImage(Side.RIGHT)}</td>
+      </tr>
+    `;
+  }
+
+  private renderImage(side: Side) {
+    const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+    if (!image) return nothing;
+    const error = side === Side.LEFT ? this.baseError : this.revisionError;
+    if (error) return error;
+    const src = imageSrc(image);
+    if (!src) return nothing;
+
+    return html`
+      <img
+        class="gr-diff ${side}"
+        src=${src}
+        @load=${this.handleLoad}
+        @error=${(e: Event) => this.handleError(e, side)}
+      >
+      </img>
+    `;
+  }
+
+  private handleLoad() {
+    this.requestUpdate();
+  }
+
+  private handleError(e: Event, side: Side) {
+    const msg = `[Image failed to load] ${e.type}`;
+    if (side === Side.LEFT) this.baseError = msg;
+    if (side === Side.RIGHT) this.revisionError = msg;
+  }
+
+  private renderImageLabelRow() {
+    return html`
+      <tr class="gr-diff">
+        <td class="gr-diff left lineNum blank"></td>
+        <td class="gr-diff left">
+          <label class="gr-diff">
+            ${this.renderName(this.baseImage?._name ?? '')}
+            <span class="gr-diff label">${this.imageLabel(Side.LEFT)}</span>
+          </label>
+        </td>
+        <td class="gr-diff right lineNum blank"></td>
+        <td class="gr-diff right">
+          <label class="gr-diff">
+            ${this.renderName(this.revisionImage?._name ?? '')}
+            <span class="gr-diff label"> ${this.imageLabel(Side.RIGHT)} </span>
+          </label>
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderName(name?: string) {
+    const addNamesInLabel =
+      this.baseImage &&
+      this.revisionImage &&
+      this.baseImage._name !== this.revisionImage._name;
+    if (!addNamesInLabel) return nothing;
+    return html`
+      <span class="gr-diff name">${name}</span><br class="gr-diff" />
+    `;
+  }
+
+  private imageLabel(side: Side) {
+    const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+    const imageEl =
+      side === Side.LEFT ? this.baseImageEl : this.revisionImageEl;
+    if (image) {
+      const type = image.type ?? image._expectedType;
+      if (imageEl?.naturalWidth && imageEl.naturalHeight) {
+        return `${imageEl?.naturalWidth}×${imageEl.naturalHeight} ${type}`;
+      } else {
+        return type;
+      }
+    }
+    return 'No image';
+  }
+}
+
+function imageSrc(image?: ImageInfo): string {
+  return image && IMAGE_MIME_PATTERN.test(image.type)
+    ? `data:${image.type};base64,${image.body}`
+    : '';
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-image-new': GrDiffImageNew;
+    'gr-diff-image-old': GrDiffImageOld;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
new file mode 100644
index 0000000..5270603
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -0,0 +1,527 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  MovedLinkClickedEventDetail,
+  RenderPreferences,
+} from '../../../api/diff';
+import {fire} from '../../../utils/event-util';
+import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import '../gr-context-controls/gr-context-controls';
+import {
+  GrContextControls,
+  GrContextControlsShowConfig,
+} from '../gr-context-controls/gr-context-controls';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+import {
+  createBlameElement,
+  createElementDiff,
+  createElementDiffWithText,
+  formatText,
+  getResponsiveMode,
+} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {BlameInfo} from '../../../types/common';
+
+function lineTdSelector(lineNumber: LineNumber, side?: Side): string {
+  const sideSelector = side ? `.${side}` : '';
+  return `td.lineNum[data-value="${lineNumber}"]${sideSelector}`;
+}
+/**
+ * Base class for builders that are creating the DOM elements programmatically
+ * by calling `document.createElement()` and such. We are calling such builders
+ * "legacy", because we want to create (Lit) component based diff elements.
+ *
+ * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
+ */
+export abstract class GrDiffBuilderLegacy extends GrDiffBuilder {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    super(diff, prefs, outputEl, layers, renderPrefs);
+  }
+
+  override getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root: Element = this.outputEl
+  ): HTMLTableCellElement | null {
+    return root.querySelector<HTMLTableCellElement>(
+      `${lineTdSelector(lineNumber, side)} ~ td.content`
+    );
+  }
+
+  override getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | null {
+    return this.outputEl.querySelector<HTMLTableCellElement>(
+      lineTdSelector(lineNumber, side)
+    );
+  }
+
+  override getLineNumberRows() {
+    return Array.from(
+      this.outputEl.querySelectorAll<HTMLTableRowElement>(
+        ':not(.contextControl) > .diff-row'
+      ) ?? []
+    ).filter(tr => tr.querySelector('button'));
+  }
+
+  override getLineNumEls(side: Side): HTMLTableCellElement[] {
+    return Array.from(
+      this.outputEl.querySelectorAll<HTMLTableCellElement>(
+        `td.lineNum.${side}`
+      ) ?? []
+    );
+  }
+
+  override getBlameTdByLine(lineNum: number): Element | undefined {
+    return (
+      this.outputEl.querySelector(`td.blame[data-line-number="${lineNum}"]`) ??
+      undefined
+    );
+  }
+
+  override getContentByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root?: HTMLElement
+  ): HTMLElement | null {
+    const td = this.getContentTdByLine(lineNumber, side, root);
+    return td ? td.querySelector('.contentText') : null;
+  }
+
+  override renderContentByRange(
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) {
+    const lines: GrDiffLine[] = [];
+    const elements: HTMLElement[] = [];
+    let line;
+    let el;
+    this.findLinesByRange(start, end, side, lines, elements);
+    for (let i = 0; i < lines.length; i++) {
+      line = lines[i];
+      el = elements[i];
+      if (!el || !el.parentElement) {
+        // Cannot re-render an element if it does not exist. This can happen
+        // if lines are collapsed and not visible on the page yet.
+        continue;
+      }
+      const lineNumberEl = this.getLineNumberEl(el, side);
+      const newContent = this.createTextEl(lineNumberEl, line, side)
+        .firstChild as HTMLElement;
+      // Note that ${el.id} ${newContent.id} might actually mismatch: In unified
+      // diff we are rendering the same content twice for all the diff chunk
+      // that are unchanged from left to right. TODO: Be smarter about this.
+      el.parentElement.replaceChild(newContent, el);
+    }
+  }
+
+  override renderBlameByRange(blame: BlameInfo, start: number, end: number) {
+    for (let i = start; i <= end; i++) {
+      // TODO(wyatta): this query is expensive, but, when traversing a
+      // range, the lines are consecutive, and given the previous blame
+      // cell, the next one can be reached cheaply.
+      const blameCell = this.getBlameTdByLine(i);
+      if (!blameCell) continue;
+
+      // Remove the element's children (if any).
+      while (blameCell.hasChildNodes()) {
+        blameCell.removeChild(blameCell.lastChild!);
+      }
+      const blameEl = createBlameElement(i, blame);
+      if (blameEl) blameCell.appendChild(blameEl);
+    }
+  }
+
+  /**
+   * Finds the line number element given the content element by walking up the
+   * DOM tree to the diff row and then querying for a .lineNum element on the
+   * requested side.
+   *
+   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
+   */
+  // visible for testing
+  getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
+    let row: HTMLElement | null = content;
+    while (row && !row.classList.contains('diff-row')) row = row.parentElement;
+    return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
+  }
+
+  /**
+   * Adds <tr> table rows to a <tbody> section for allowing the user to expand
+   * collapsed of lines. Called by subclasses.
+   */
+  protected createContextControls(
+    section: HTMLElement,
+    group: GrDiffGroup,
+    viewMode: DiffViewMode
+  ) {
+    const leftStart = group.lineRange.left.start_line;
+    const leftEnd = group.lineRange.left.end_line;
+    const firstGroupIsSkipped = !!group.contextGroups[0].skip;
+    const lastGroupIsSkipped =
+      !!group.contextGroups[group.contextGroups.length - 1].skip;
+
+    const containsWholeFile = this.numLinesLeft === leftEnd - leftStart + 1;
+    const showAbove =
+      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
+    const showBelow = leftEnd < this.numLinesLeft && !lastGroupIsSkipped;
+
+    if (showAbove) {
+      const paddingRow = this.createContextControlPaddingRow(viewMode);
+      paddingRow.classList.add('above');
+      section.appendChild(paddingRow);
+    }
+    section.appendChild(
+      this.createContextControlRow(group, showAbove, showBelow, viewMode)
+    );
+    if (showBelow) {
+      const paddingRow = this.createContextControlPaddingRow(viewMode);
+      paddingRow.classList.add('below');
+      section.appendChild(paddingRow);
+    }
+  }
+
+  /**
+   * Creates a context control <tr> table row for with buttons the allow the
+   * user to expand collapsed lines. Buttons extend from the gap created by this
+   * method up or down into the area of code that they affect.
+   */
+  private createContextControlRow(
+    group: GrDiffGroup,
+    showAbove: boolean,
+    showBelow: boolean,
+    viewMode: DiffViewMode
+  ): HTMLElement {
+    const row = createElementDiff('tr', 'dividerRow');
+    let showConfig: GrContextControlsShowConfig;
+    if (showAbove && !showBelow) {
+      showConfig = 'above';
+    } else if (!showAbove && showBelow) {
+      showConfig = 'below';
+    } else {
+      // Note that !showAbove && !showBelow also intentionally creates
+      // "show-both". This means the file is completely collapsed, which is
+      // unusual, but at least happens in one test.
+      showConfig = 'both';
+    }
+    row.classList.add(`show-${showConfig}`);
+
+    row.appendChild(this.createBlameCell(0));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(createElementDiff('td'));
+    }
+
+    const cell = createElementDiff('td', 'dividerCell');
+    // Note that <td> table cells that have `display: none` don't count!
+    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
+    cell.setAttribute('colspan', colspan);
+    row.appendChild(cell);
+
+    const contextControls = createElementDiff(
+      'gr-context-controls'
+    ) as GrContextControls;
+    contextControls.diff = this._diff;
+    contextControls.renderPreferences = this.renderPrefs;
+    contextControls.group = group;
+    contextControls.showConfig = showConfig;
+    cell.appendChild(contextControls);
+    return row;
+  }
+
+  /**
+   * Creates a table row to serve as padding between code and context controls.
+   * Blame column, line gutters, and content area will continue visually, but
+   * context controls can render over this background to map more clearly to
+   * the area of code they expand.
+   */
+  private createContextControlPaddingRow(viewMode: DiffViewMode) {
+    const row = createElementDiff('tr', 'contextBackground');
+
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.classList.add('side-by-side');
+      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
+      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
+    } else {
+      row.classList.add('unified');
+    }
+
+    row.appendChild(this.createBlameCell(0));
+    row.appendChild(createElementDiff('td', 'contextLineNum'));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(createElementDiff('td', 'sign'));
+      row.appendChild(createElementDiff('td'));
+    }
+    row.appendChild(createElementDiff('td', 'contextLineNum'));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(createElementDiff('td', 'sign'));
+    }
+    row.appendChild(createElementDiff('td'));
+
+    return row;
+  }
+
+  protected createLineEl(
+    line: GrDiffLine,
+    number: LineNumber,
+    type: GrDiffLineType,
+    side: Side
+  ) {
+    const td = createElementDiff('td');
+    td.classList.add(side);
+    if (line.type === GrDiffLineType.BLANK) {
+      td.classList.add('blankLineNum');
+      return td;
+    }
+    if (line.type === GrDiffLineType.BOTH || line.type === type) {
+      td.classList.add('lineNum');
+      td.dataset['value'] = number.toString();
+
+      if (
+        ((this._prefs.show_file_comment_button === false ||
+          this.renderPrefs?.show_file_comment_button === false) &&
+          number === 'FILE') ||
+        number === 'LOST'
+      ) {
+        return td;
+      }
+
+      const button = createElementDiff('button');
+      td.appendChild(button);
+      button.tabIndex = -1;
+      button.classList.add('lineNumButton');
+      button.classList.add(side);
+      button.dataset['value'] = number.toString();
+      button.id =
+        side === Side.LEFT ? `left-button-${number}` : `right-button-${number}`;
+      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
+      // the empty line number column for added/removed lines. This should not
+      // be announced to the screenreader.
+      if (number > 0) {
+        if (line.type === GrDiffLineType.REMOVE) {
+          button.setAttribute('aria-label', `${number} removed`);
+        } else if (line.type === GrDiffLineType.ADD) {
+          button.setAttribute('aria-label', `${number} added`);
+        } else {
+          button.setAttribute('aria-label', `${number} unmodified`);
+        }
+      }
+      this.addLineNumberMouseEvents(td, number, side);
+    }
+    return td;
+  }
+
+  private addLineNumberMouseEvents(
+    el: HTMLElement,
+    number: LineNumber,
+    side: Side
+  ) {
+    el.addEventListener('mouseenter', () => {
+      fire(el, 'line-mouse-enter', {lineNum: number, side});
+    });
+    el.addEventListener('mouseleave', () => {
+      fire(el, 'line-mouse-leave', {lineNum: number, side});
+    });
+  }
+
+  // visible for testing
+  createTextEl(
+    lineNumberEl: HTMLElement | null,
+    line: GrDiffLine,
+    side?: Side,
+    twoSlots?: boolean
+  ) {
+    const td = createElementDiff('td');
+    if (line.type !== GrDiffLineType.BLANK) {
+      td.classList.add('content');
+    }
+    if (side) {
+      td.classList.add(side);
+    }
+
+    // If intraline info is not available, the entire line will be
+    // considered as changed and marked as dark red / green color
+    if (!line.hasIntralineInfo) {
+      td.classList.add('no-intraline-info');
+    }
+    td.classList.add(line.type);
+
+    const lineNumber = side ? line.lineNumber(side) : 0;
+    if (lineNumber === 'FILE') {
+      td.classList.add('file');
+    } else if (lineNumber === 'LOST') {
+      td.classList.add('lost');
+    } else {
+      const responsiveMode = getResponsiveMode(this._prefs, this.renderPrefs);
+      const contentId =
+        side && lineNumber > 0 ? `${side}-content-${lineNumber}` : '';
+      const contentText = formatText(
+        line.text,
+        responsiveMode,
+        this._prefs.tab_size,
+        this._prefs.line_length,
+        contentId
+      );
+
+      if (side) {
+        contentText.setAttribute('data-side', side);
+        this.addLineNumberMouseEvents(td, lineNumber, side);
+      }
+
+      if (lineNumberEl && side) {
+        for (const layer of this.layers) {
+          if (typeof layer.annotate === 'function') {
+            layer.annotate(contentText, lineNumberEl, line, side);
+          }
+        }
+      } else {
+        console.error('lineNumberEl or side not set, skipping layer.annotate');
+      }
+
+      td.appendChild(contentText);
+    }
+
+    if (side && lineNumber) {
+      const threadGroupEl = document.createElement('div');
+      threadGroupEl.className = 'thread-group';
+      threadGroupEl.setAttribute('data-side', side);
+
+      const slot = document.createElement('slot');
+      slot.name = `${side}-${lineNumber}`;
+      threadGroupEl.appendChild(slot);
+
+      // For line.type === BOTH in unified diff we want two slots.
+      if (twoSlots) {
+        const slot = document.createElement('slot');
+        const otherSide = side === Side.LEFT ? Side.RIGHT : Side.LEFT;
+        slot.name = `${otherSide}-${line.lineNumber(otherSide)}`;
+        threadGroupEl.appendChild(slot);
+      }
+
+      td.appendChild(threadGroupEl);
+    }
+
+    return td;
+  }
+
+  private createMovedLineAnchor(line: number, side: Side) {
+    const anchor = createElementDiffWithText('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;
+  }
+
+  private createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
+    const div = createElementDiff('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(createElementDiffWithText('span', textLabel));
+      div.appendChild(this.createMovedLineAnchor(range.start, otherSide));
+      div.appendChild(createElementDiffWithText('span', ' - '));
+      div.appendChild(this.createMovedLineAnchor(range.end, otherSide));
+    } else {
+      div.appendChild(
+        createElementDiffWithText('span', movedIn ? 'Moved in' : 'Moved out')
+      );
+    }
+    return div;
+  }
+
+  protected buildMoveControls(group: GrDiffGroup) {
+    const movedIn = group.adds.length > 0;
+    const {
+      numberOfCells,
+      movedOutIndex,
+      movedInIndex,
+      lineNumberCols,
+      signCols,
+    } = this.getMoveControlsConfig();
+
+    let controlsClass;
+    let descriptionIndex;
+    const descriptionTextDiv = this.createMoveDescriptionDiv(movedIn, group);
+    if (movedIn) {
+      controlsClass = 'movedIn';
+      descriptionIndex = movedInIndex;
+    } else {
+      controlsClass = 'movedOut';
+      descriptionIndex = movedOutIndex;
+    }
+
+    const controls = createElementDiff('tr', `moveControls ${controlsClass}`);
+    const cells = [...Array(numberOfCells).keys()].map(() =>
+      createElementDiff('td')
+    );
+    lineNumberCols.forEach(index => {
+      cells[index].classList.add('moveControlsLineNumCol');
+    });
+
+    if (signCols) {
+      cells[signCols.left].classList.add('sign', 'left');
+      cells[signCols.right].classList.add('sign', 'right');
+    }
+    const moveRangeHeader = createElementDiff('gr-range-header');
+    moveRangeHeader.setAttribute('icon', 'move_item');
+    moveRangeHeader.appendChild(descriptionTextDiv);
+    cells[descriptionIndex].classList.add('moveHeader');
+    cells[descriptionIndex].appendChild(moveRangeHeader);
+    cells.forEach(c => {
+      controls.appendChild(c);
+    });
+    return controls;
+  }
+
+  /**
+   * Create a blame cell for the given base line. Blame information will be
+   * included in the cell if available.
+   */
+  // visible for testing
+  createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
+    const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
+    blameTd.setAttribute('data-line-number', lineNumber.toString());
+    if (!lineNumber) return blameTd;
+
+    const blameInfo = this.getBlameCommitForBaseLine(lineNumber);
+    if (!blameInfo) return blameTd;
+
+    blameTd.appendChild(createBlameElement(lineNumber, blameInfo));
+    return blameTd;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts
new file mode 100644
index 0000000..2a61ef2
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts
@@ -0,0 +1,214 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {DiffLayer, isDefined} from '../../../types/types';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {BlameInfo} from '../../../types/common';
+import {html, nothing, render} from 'lit';
+import {GrDiffSection} from './gr-diff-section';
+import '../gr-context-controls/gr-context-controls';
+import './gr-diff-section';
+import {GrDiffRow} from './gr-diff-row';
+
+/**
+ * Base class for builders that are creating the diff using Lit elements.
+ */
+export class GrDiffBuilderLit extends GrDiffBuilder {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    super(diff, prefs, outputEl, layers, renderPrefs);
+  }
+
+  override getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    _root: Element = this.outputEl
+  ): HTMLTableCellElement | null {
+    if (!side) return null;
+    const row = this.findRow(lineNumber, side);
+    return row?.getContentCell(side) ?? null;
+  }
+
+  override getLineElByNumber(lineNumber: LineNumber, side: Side) {
+    const row = this.findRow(lineNumber, side);
+    return row?.getLineNumberCell(side) ?? null;
+  }
+
+  private findRow(lineNumber?: LineNumber, side?: Side): GrDiffRow | undefined {
+    if (!side || !lineNumber) return undefined;
+    const group = this.findGroup(side, lineNumber);
+    if (!group) return undefined;
+    const section = this.findSection(group);
+    if (!section) return undefined;
+    return section.findRow(side, lineNumber);
+  }
+
+  private getDiffRows() {
+    const sections = [
+      ...this.outputEl.querySelectorAll<GrDiffSection>('gr-diff-section'),
+    ];
+    return sections.map(s => s.getDiffRows()).flat();
+  }
+
+  override getLineNumberRows(): HTMLTableRowElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getTableRow()).filter(isDefined);
+  }
+
+  override getLineNumEls(side: Side): HTMLTableCellElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
+  }
+
+  override getBlameTdByLine(lineNumber: number): Element | undefined {
+    return this.findRow(lineNumber, Side.LEFT)?.getBlameCell();
+  }
+
+  override getContentByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    _root?: HTMLElement
+  ): HTMLElement | null {
+    const cell = this.getContentTdByLine(lineNumber, side);
+    return (cell?.firstChild ?? null) as HTMLElement | null;
+  }
+
+  /** This is used when layers initiate an update. */
+  override renderContentByRange(
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) {
+    const groups = this.getGroupsByLineRange(start, end, side);
+    for (const group of groups) {
+      const section = this.findSection(group);
+      for (const row of section?.getDiffRows() ?? []) {
+        row.requestUpdate();
+      }
+    }
+  }
+
+  private findSection(group?: GrDiffGroup): GrDiffSection | undefined {
+    if (!group) return undefined;
+    const leftClass = `left-${group.startLine(Side.LEFT)}`;
+    const rightClass = `right-${group.startLine(Side.RIGHT)}`;
+    return (
+      this.outputEl.querySelector<GrDiffSection>(
+        `gr-diff-section.${leftClass}.${rightClass}`
+      ) ?? undefined
+    );
+  }
+
+  override renderBlameByRange(
+    blameInfo: BlameInfo,
+    start: number,
+    end: number
+  ) {
+    for (let lineNumber = start; lineNumber <= end; lineNumber++) {
+      const row = this.findRow(lineNumber, Side.LEFT);
+      if (!row) continue;
+      row.blameInfo = blameInfo;
+    }
+  }
+
+  // TODO: Refactor this such that adding the move controls becomes part of the
+  // lit element.
+  protected override getMoveControlsConfig() {
+    return {
+      numberOfCells: 6, // How many cells does the diff table have?
+      movedOutIndex: 2, // Index of left content column in diff table.
+      movedInIndex: 5, // Index of right content column in diff table.
+      lineNumberCols: [0, 3], // Indices of line number columns in diff table.
+      signCols: {left: 1, right: 4},
+    };
+  }
+
+  protected override buildSectionElement(group: GrDiffGroup) {
+    const leftCl = `left-${group.startLine(Side.LEFT)}`;
+    const rightCl = `right-${group.startLine(Side.RIGHT)}`;
+    const section = html`
+      <gr-diff-section
+        class="${leftCl} ${rightCl}"
+        .group=${group}
+        .diff=${this._diff}
+        .layers=${this.layers}
+        .diffPrefs=${this._prefs}
+        .renderPrefs=${this.renderPrefs}
+      ></gr-diff-section>
+    `;
+    // When using Lit's `render()` method it wants to be in full control of the
+    // element that it renders into, so we let it render into a temp element.
+    // Rendering into the diff table directly would interfere with
+    // `clearDiffContent()`for example.
+    // TODO: Remove legacy diff builder, then convert <gr-diff> to be fully lit
+    // controlled, then this code will become part of the standard `render()` of
+    // <gr-diff> as a LitElement.
+    const tempEl = document.createElement('div');
+    render(section, tempEl);
+    const sectionEl = tempEl.firstElementChild as GrDiffSection;
+    return sectionEl;
+  }
+
+  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+    const colgroup = html`
+      <colgroup>
+        <col class=${diffClasses('blame')}></col>
+        ${this.renderUnifiedColumns(lineNumberWidth)}
+        ${this.renderSideBySideColumns(Side.LEFT, lineNumberWidth)}
+        ${this.renderSideBySideColumns(Side.RIGHT, lineNumberWidth)}
+      </colgroup>
+    `;
+    // When using Lit's `render()` method it wants to be in full control of the
+    // element that it renders into, so we let it render into a temp element.
+    // Rendering into the diff table directly would interfere with
+    // `clearDiffContent()`for example.
+    // TODO: Remove legacy diff builder, then convert <gr-diff> to be fully lit
+    // controlled, then this code will become part of the standard `render()` of
+    // <gr-diff> as a LitElement.
+    const tempEl = document.createElement('div');
+    render(colgroup, tempEl);
+    const colgroupEl = tempEl.firstElementChild as HTMLElement;
+    outputEl.appendChild(colgroupEl);
+  }
+
+  private renderUnifiedColumns(lineNumberWidth: number) {
+    if (this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED) return nothing;
+    return html`
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()}></col>
+    `;
+  }
+
+  private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
+    if (this.renderPrefs?.view_mode === DiffViewMode.UNIFIED) return nothing;
+    return html`
+      <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
+      <col class=${diffClasses(side, 'sign')}></col>
+      <col class=${diffClasses(side)}></col>
+    `;
+  }
+
+  protected override getNextContentOnSide(
+    _content: HTMLElement,
+    _side: Side
+  ): HTMLElement | null {
+    // TODO: getNextContentOnSide() is not required by lit based rendering.
+    // So let's refactor it to be moved into gr-diff-builder-legacy.
+    console.warn('unimplemented method getNextContentOnSide() called');
+    return null;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
new file mode 100644
index 0000000..6ae7d4cd
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -0,0 +1,174 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+import {RenderPreferences} from '../../../api/diff';
+import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
+
+export class GrDiffBuilderSideBySide extends GrDiffBuilderLegacy {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    super(diff, prefs, outputEl, layers, renderPrefs);
+  }
+
+  protected override getMoveControlsConfig() {
+    return {
+      numberOfCells: 6,
+      movedOutIndex: 2,
+      movedInIndex: 5,
+      lineNumberCols: [0, 3],
+      signCols: {left: 1, right: 4},
+    };
+  }
+
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup) {
+    const sectionEl = createElementDiff('tbody', 'section');
+    sectionEl.classList.add(group.type);
+    if (group.isTotal()) {
+      sectionEl.classList.add('total');
+    }
+    if (group.dueToRebase) {
+      sectionEl.classList.add('dueToRebase');
+    }
+    if (group.moveDetails) {
+      sectionEl.classList.add('dueToMove');
+      if (group.moveDetails.changed) {
+        sectionEl.classList.add('changed');
+      }
+      sectionEl.appendChild(this.buildMoveControls(group));
+    }
+    if (group.ignoredWhitespaceOnly) {
+      sectionEl.classList.add('ignoredWhitespaceOnly');
+    }
+    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
+      this.createContextControls(sectionEl, group, DiffViewMode.SIDE_BY_SIDE);
+      return sectionEl;
+    }
+
+    const pairs = group.getSideBySidePairs();
+    for (let i = 0; i < pairs.length; i++) {
+      sectionEl.appendChild(this.createRow(pairs[i].left, pairs[i].right));
+    }
+    return sectionEl;
+  }
+
+  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+    const colgroup = document.createElement('colgroup');
+
+    // Add the blame column.
+    let col = createElementDiff('col', 'blame');
+    colgroup.appendChild(col);
+
+    // Add left-side line number.
+    col = createElementDiff('col', 'left');
+    col.setAttribute('width', lineNumberWidth.toString());
+    colgroup.appendChild(col);
+
+    colgroup.appendChild(createElementDiff('col', 'sign left'));
+
+    // Add left-side content.
+    colgroup.appendChild(createElementDiff('col', 'left'));
+
+    // Add right-side line number.
+    col = createElementDiff('col', 'right');
+    col.setAttribute('width', lineNumberWidth.toString());
+    colgroup.appendChild(col);
+
+    colgroup.appendChild(createElementDiff('col', 'sign right'));
+
+    // Add right-side content.
+    colgroup.appendChild(createElementDiff('col', 'right'));
+
+    outputEl.appendChild(colgroup);
+  }
+
+  private createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
+    const row = createElementDiff('tr');
+    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;
+    // Before Chrome 102, Chrome was able to compute a11y label from children
+    // content. Now Chrome 102 and Firefox are not computing a11y label because
+    // tr is not expected to have aria label. Adding aria role button is
+    // pushing browser to compute aria even for tr. This can be removed, once
+    // browsers will again compute a11y label even for tr when it is focused.
+    // TODO: Remove when Chrome 102 is out of date for 1 year.
+    row.setAttribute(
+      'aria-labelledby',
+      [
+        leftLine.beforeNumber ? `left-button-${leftLine.beforeNumber}` : '',
+        leftLine.beforeNumber ? `left-content-${leftLine.beforeNumber}` : '',
+        rightLine.afterNumber ? `right-button-${rightLine.afterNumber}` : '',
+        rightLine.afterNumber ? `right-content-${rightLine.afterNumber}` : '',
+      ]
+        .join(' ')
+        .trim()
+    );
+
+    row.appendChild(this.createBlameCell(leftLine.beforeNumber));
+
+    this.appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
+    this.appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
+    return row;
+  }
+
+  private appendPair(
+    row: HTMLElement,
+    line: GrDiffLine,
+    lineNumber: LineNumber,
+    side: Side
+  ) {
+    const lineNumberEl = this.createLineEl(line, lineNumber, line.type, side);
+    row.appendChild(lineNumberEl);
+    row.appendChild(this.createSignEl(line, side));
+    row.appendChild(this.createTextEl(lineNumberEl, line, side));
+  }
+
+  private createSignEl(line: GrDiffLine, side: Side): HTMLElement {
+    const td = createElementDiff('td', 'sign');
+    td.classList.add(side);
+    if (line.type === GrDiffLineType.BLANK) {
+      td.classList.add('blank');
+    } else if (line.type === GrDiffLineType.ADD && side === Side.RIGHT) {
+      td.classList.add('add');
+      td.innerText = '+';
+    } else if (line.type === GrDiffLineType.REMOVE && side === Side.LEFT) {
+      td.classList.add('remove');
+      td.innerText = '-';
+    }
+    if (!line.hasIntralineInfo) {
+      td.classList.add('no-intraline-info');
+    }
+    return td;
+  }
+
+  // visible for testing
+  override getNextContentOnSide(
+    content: HTMLElement,
+    side: Side
+  ): HTMLElement | null {
+    let tr: HTMLElement = content.parentElement!.parentElement!;
+    while ((tr = tr.nextSibling as HTMLElement)) {
+      const nextContent = tr.querySelector(
+        'td.content .contentText[data-side="' + side + '"]'
+      );
+      if (nextContent) return nextContent as HTMLElement;
+    }
+    return null;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
new file mode 100644
index 0000000..ffc2dcd
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -0,0 +1,168 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+import {RenderPreferences} from '../../../api/diff';
+import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
+
+export class GrDiffBuilderUnified extends GrDiffBuilderLegacy {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    super(diff, prefs, outputEl, layers, renderPrefs);
+  }
+
+  protected override getMoveControlsConfig() {
+    return {
+      numberOfCells: 3,
+      movedOutIndex: 2,
+      movedInIndex: 2,
+      lineNumberCols: [0, 1],
+    };
+  }
+
+  // visible for testing
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
+    const sectionEl = createElementDiff('tbody', 'section');
+    sectionEl.classList.add(group.type);
+    if (group.isTotal()) {
+      sectionEl.classList.add('total');
+    }
+    if (group.dueToRebase) {
+      sectionEl.classList.add('dueToRebase');
+    }
+    if (group.moveDetails) {
+      sectionEl.classList.add('dueToMove');
+      if (group.moveDetails.changed) {
+        sectionEl.classList.add('changed');
+      }
+      sectionEl.appendChild(this.buildMoveControls(group));
+    }
+    if (group.ignoredWhitespaceOnly) {
+      sectionEl.classList.add('ignoredWhitespaceOnly');
+    }
+    if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
+      this.createContextControls(sectionEl, group, DiffViewMode.UNIFIED);
+      return sectionEl;
+    }
+
+    for (let i = 0; i < group.lines.length; ++i) {
+      const line = group.lines[i];
+      // If only whitespace has changed and the settings ask for whitespace to
+      // be ignored, only render the right-side line in unified diff mode.
+      if (group.ignoredWhitespaceOnly && line.type === GrDiffLineType.REMOVE) {
+        continue;
+      }
+      sectionEl.appendChild(this.createRow(line));
+    }
+    return sectionEl;
+  }
+
+  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+    const colgroup = document.createElement('colgroup');
+
+    // Add the blame column.
+    let col = createElementDiff('col', 'blame');
+    colgroup.appendChild(col);
+
+    // Add left-side line number.
+    col = createElementDiff('col');
+    col.setAttribute('width', lineNumberWidth.toString());
+    colgroup.appendChild(col);
+
+    // Add right-side line number.
+    col = createElementDiff('col');
+    col.setAttribute('width', lineNumberWidth.toString());
+    colgroup.appendChild(col);
+
+    // Add the content.
+    colgroup.appendChild(createElementDiff('col'));
+
+    outputEl.appendChild(colgroup);
+  }
+
+  protected createRow(line: GrDiffLine) {
+    const row = createElementDiff('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(
+      line,
+      line.beforeNumber,
+      GrDiffLineType.REMOVE,
+      Side.LEFT
+    );
+    row.appendChild(lineNumberEl);
+    lineNumberEl = this.createLineEl(
+      line,
+      line.afterNumber,
+      GrDiffLineType.ADD,
+      Side.RIGHT
+    );
+    row.appendChild(lineNumberEl);
+    let side = undefined;
+    if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
+      side = Side.RIGHT;
+    }
+    if (line.type === GrDiffLineType.REMOVE) {
+      side = Side.LEFT;
+    }
+
+    // Before Chrome 102, Chrome was able to compute a11y label from children
+    // content. Now Chrome 102 and Firefox are not computing a11y label because
+    // tr is not expected to have aria label. Adding aria role button is
+    // pushing browser to compute aria even for tr. This can be removed, once
+    // browsers will again compute a11y label even for tr when it is focused.
+    // TODO: Remove when Chrome 102 is out of date for 1 year.
+    row.setAttribute(
+      'aria-labelledby',
+      [
+        line.beforeNumber ? `left-button-${line.beforeNumber}` : '',
+        side === Side.LEFT && line.beforeNumber
+          ? `left-content-${line.beforeNumber}`
+          : '',
+        line.afterNumber ? `right-button-${line.afterNumber}` : '',
+        side === Side.RIGHT && line.afterNumber
+          ? `right-content-${line.afterNumber}`
+          : '',
+      ]
+        .filter(id => !!id)
+        .join(' ')
+        .trim()
+    );
+    const twoSlots = line.type === GrDiffLineType.BOTH;
+    row.appendChild(this.createTextEl(lineNumberEl, line, side, twoSlots));
+    return row;
+  }
+
+  getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
+    let tr: HTMLElement = content.parentElement!.parentElement!;
+    while ((tr = tr.nextSibling as HTMLElement)) {
+      // Note that this does not work when there is a "common" chunk in the
+      // diff (different content only because of whitespace). Such chunks are
+      // rendered with class "add", so these rows will be skipped for the
+      // 'left' side.
+      // TODO: Fix this when writing a Lit component for unified diff.
+      if (
+        tr.classList.contains('both') ||
+        (side === 'left' && tr.classList.contains('remove')) ||
+        (side === 'right' && tr.classList.contains('add'))
+      ) {
+        return tr.querySelector('.contentText');
+      }
+    }
+    return null;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
new file mode 100644
index 0000000..8c44727
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
@@ -0,0 +1,283 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../gr-diff/gr-diff-group';
+import './gr-diff-builder';
+import './gr-diff-builder-unified';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {createDiff} from '../../../test/test-data-generators';
+import {queryAndAssert} from '../../../utils/common-util';
+import {assert} from '@open-wc/testing';
+
+suite('GrDiffBuilderUnified tests', () => {
+  let prefs: DiffPreferencesInfo;
+  let outputEl: HTMLElement;
+  let diffBuilder: GrDiffBuilderUnified;
+
+  setup(() => {
+    prefs = {
+      ...createDefaultDiffPrefs(),
+      line_length: 10,
+      show_tabs: true,
+      tab_size: 4,
+    };
+    outputEl = document.createElement('div');
+    diffBuilder = new GrDiffBuilderUnified(createDiff(), prefs, outputEl, []);
+  });
+
+  suite('buildSectionElement for BOTH group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
+        new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
+        new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World";';
+      lines[2].text = '  return True';
+
+      group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+    });
+
+    test('creates the section', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('both'));
+    });
+
+    test('creates each unchanged row once', () => {
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 3);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[0].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[1].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.left').textContent,
+        lines[2].beforeNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+    });
+  });
+
+  suite('buildSectionElement for moved chunks', () => {
+    test('creates a moved out group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 15),
+        new GrDiffLine(GrDiffLineType.REMOVE, 16),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedOut'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved out');
+    });
+
+    test('creates a moved in group', () => {
+      const lines = [
+        new GrDiffLine(GrDiffLineType.ADD, 37),
+        new GrDiffLine(GrDiffLineType.ADD, 38),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        moveDetails: {changed: false},
+      });
+
+      const sectionEl = diffBuilder.buildSectionElement(group);
+
+      const rowEls = sectionEl.querySelectorAll('tr');
+      const moveControlsRow = rowEls[0];
+      const cells = moveControlsRow.querySelectorAll('td');
+      assert.isTrue(sectionEl.classList.contains('dueToMove'));
+      assert.equal(rowEls.length, 3);
+      assert.isTrue(moveControlsRow.classList.contains('movedIn'));
+      assert.equal(cells.length, 3);
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
+      assert.equal(cells[2].textContent, 'Moved in');
+    });
+  });
+
+  suite('buildSectionElement for DELTA group', () => {
+    let lines: GrDiffLine[];
+    let group: GrDiffGroup;
+
+    setup(() => {
+      lines = [
+        new GrDiffLine(GrDiffLineType.REMOVE, 1),
+        new GrDiffLine(GrDiffLineType.REMOVE, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 2),
+        new GrDiffLine(GrDiffLineType.ADD, 3),
+      ];
+      lines[0].text = 'def hello_world():';
+      lines[1].text = '  print "Hello World"';
+      lines[2].text = 'def hello_universe()';
+      lines[3].text = '  print "Hello Universe"';
+    });
+
+    test('creates the section', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('section'));
+      assert.isTrue(sectionEl.classList.contains('delta'));
+    });
+
+    test('creates the section with class if ignoredWhitespaceOnly', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
+    });
+
+    test('creates the section with class if dueToRebase', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        dueToRebase: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      assert.isTrue(sectionEl.classList.contains('dueToRebase'));
+    });
+
+    test('creates first the removed and then the added rows', () => {
+      group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 4);
+
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.left').textContent,
+        lines[0].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[0].text
+      );
+
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.left').textContent,
+        lines[1].beforeNumber.toString()
+      );
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[1].text
+      );
+
+      assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[2], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[2], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[3], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[3], '.content').textContent,
+        lines[3].text
+      );
+    });
+
+    test('creates only the added rows if only ignored whitespace', () => {
+      group = new GrDiffGroup({
+        type: GrDiffGroupType.DELTA,
+        lines,
+        ignoredWhitespaceOnly: true,
+      });
+      const sectionEl = diffBuilder.buildSectionElement(group);
+      const rowEls = sectionEl.querySelectorAll('.diff-row');
+
+      assert.equal(rowEls.length, 2);
+
+      assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[0], '.lineNum.right').textContent,
+        lines[2].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[0], '.content').textContent,
+        lines[2].text
+      );
+
+      assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
+      assert.equal(
+        queryAndAssert(rowEls[1], '.lineNum.right').textContent,
+        lines[3].afterNumber.toString()
+      );
+      assert.equal(
+        queryAndAssert(rowEls[1], '.content').textContent,
+        lines[3].text
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
new file mode 100644
index 0000000..5ca5197
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -0,0 +1,393 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  ContentLoadNeededEventDetail,
+  DiffContextExpandedExternalDetail,
+  RenderPreferences,
+} from '../../../api/diff';
+import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {assert} from '../../../utils/common-util';
+import '../gr-context-controls/gr-context-controls';
+import {BlameInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+
+export interface DiffContextExpandedEventDetail
+  extends DiffContextExpandedExternalDetail {
+  /** The context control group that should be replaced by `groups`. */
+  contextGroup: GrDiffGroup;
+  groups: GrDiffGroup[];
+  numLines: number;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
+    'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
+  }
+}
+
+/**
+ * Given that GrDiffBuilder has ~1,000 lines of code, this interface is just
+ * making refactorings easier by emphasizing what the public facing "contract"
+ * of this class is. There are no plans for adding separate implementations.
+ */
+export interface DiffBuilder {
+  init(): void;
+  cleanup(): void;
+  addGroups(groups: readonly GrDiffGroup[]): void;
+  clearGroups(): void;
+  replaceGroup(
+    contextControl: GrDiffGroup,
+    groups: readonly GrDiffGroup[]
+  ): void;
+  findGroup(side: Side, line: LineNumber): GrDiffGroup | undefined;
+  addColumns(outputEl: HTMLElement, fontSize: number): void;
+  // TODO: Change `null` to `undefined`.
+  getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root?: Element
+  ): HTMLTableCellElement | null;
+  getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | null;
+  getLineNumberRows(): HTMLTableRowElement[];
+  getLineNumEls(side: Side): HTMLTableCellElement[];
+  setBlame(blame: BlameInfo[]): void;
+  updateRenderPrefs(renderPrefs: RenderPreferences): void;
+}
+
+export interface ImageDiffBuilder extends DiffBuilder {
+  renderImageDiff(): void;
+}
+
+export function isImageDiffBuilder(
+  x: DiffBuilder | ImageDiffBuilder | undefined
+): x is ImageDiffBuilder {
+  return !!x && !!(x as ImageDiffBuilder).renderImageDiff;
+}
+
+/**
+ * Base class for different diff builders, like side-by-side, unified etc.
+ *
+ * The builder takes GrDiffGroups, and builds the corresponding DOM elements,
+ * called sections. Only the builder should add or remove sections from the
+ * DOM. Callers can use the ...group() methods to modify groups and thus cause
+ * rendering changes.
+ *
+ * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
+ */
+export abstract class GrDiffBuilder implements DiffBuilder {
+  protected readonly _diff: DiffInfo;
+
+  protected readonly numLinesLeft: number;
+
+  // visible for testing
+  readonly _prefs: DiffPreferencesInfo;
+
+  protected renderPrefs?: RenderPreferences;
+
+  protected readonly outputEl: HTMLElement;
+
+  protected groups: GrDiffGroup[];
+
+  private blameInfo: BlameInfo[] = [];
+
+  private readonly layerUpdateListener: (
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) => void;
+
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    readonly layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    this._diff = diff;
+    this.numLinesLeft = this._diff.content
+      ? this._diff.content.reduce((sum, chunk) => {
+          const left = chunk.a || chunk.ab;
+          return sum + (left?.length || chunk.skip || 0);
+        }, 0)
+      : 0;
+    this._prefs = prefs;
+    this.renderPrefs = renderPrefs;
+    this.outputEl = outputEl;
+    this.groups = [];
+
+    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+      throw Error('Invalid tab size from preferences.');
+    }
+
+    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+      throw Error('Invalid line length from preferences.');
+    }
+
+    this.layerUpdateListener = (
+      start: LineNumber,
+      end: LineNumber,
+      side: Side
+    ) => this.renderContentByRange(start, end, side);
+    this.init();
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component re-connects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with cleanup(), which is called
+   * when gr-diff disconnects.
+   */
+  init() {
+    this.cleanup();
+    for (const layer of this.layers) {
+      if (layer.addListener) {
+        layer.addListener(this.layerUpdateListener);
+      }
+    }
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component disconnects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with init(), which is called when
+   * gr-diff re-connects.
+   */
+  cleanup() {
+    for (const layer of this.layers) {
+      if (layer.removeListener) {
+        layer.removeListener(this.layerUpdateListener);
+      }
+    }
+  }
+
+  abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
+
+  protected abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
+
+  addGroups(groups: readonly GrDiffGroup[]) {
+    for (const group of groups) {
+      this.groups.push(group);
+      this.emitGroup(group);
+    }
+  }
+
+  clearGroups() {
+    for (const deletedGroup of this.groups) {
+      deletedGroup.element?.remove();
+    }
+    this.groups = [];
+  }
+
+  replaceGroup(contextControl: GrDiffGroup, groups: readonly GrDiffGroup[]) {
+    const i = this.groups.indexOf(contextControl);
+    if (i === -1) throw new Error('cannot find context control group');
+
+    const contextControlSection = this.groups[i].element;
+    if (!contextControlSection) throw new Error('diff group element not set');
+
+    this.groups.splice(i, 1, ...groups);
+    for (const group of groups) {
+      this.emitGroup(group, contextControlSection);
+    }
+    if (contextControlSection) contextControlSection.remove();
+  }
+
+  findGroup(side: Side, line: LineNumber) {
+    return this.groups.find(group => group.containsLine(side, line));
+  }
+
+  private emitGroup(group: GrDiffGroup, beforeSection?: HTMLElement) {
+    const element = this.buildSectionElement(group);
+    this.outputEl.insertBefore(element, beforeSection ?? null);
+    group.element = element;
+  }
+
+  // visible for testing
+  getGroupsByLineRange(
+    startLine: LineNumber,
+    endLine: LineNumber,
+    side: Side
+  ): GrDiffGroup[] {
+    const startIndex = this.groups.findIndex(group =>
+      group.containsLine(side, startLine)
+    );
+    if (startIndex === -1) return [];
+    let endIndex = this.groups.findIndex(group =>
+      group.containsLine(side, endLine)
+    );
+    // Not all groups may have been processed yet (i.e. this.groups is still
+    // incomplete). In that case let's just return *all* groups until the end
+    // of the array.
+    if (endIndex === -1) endIndex = this.groups.length - 1;
+    // The filter preserves the legacy behavior to only return non-context
+    // groups
+    return this.groups
+      .slice(startIndex, endIndex + 1)
+      .filter(group => group.lines.length > 0);
+  }
+
+  // TODO: Change `null` to `undefined`.
+  abstract getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root?: Element
+  ): HTMLTableCellElement | null;
+
+  // TODO: Change `null` to `undefined`.
+  abstract getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | null;
+
+  abstract getLineNumberRows(): HTMLTableRowElement[];
+
+  abstract getLineNumEls(side: Side): HTMLTableCellElement[];
+
+  protected abstract getBlameTdByLine(lineNum: number): Element | undefined;
+
+  // TODO: Change `null` to `undefined`.
+  protected abstract getContentByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root?: HTMLElement
+  ): HTMLElement | null;
+
+  /**
+   * Find line elements or line objects by a range of line numbers and a side.
+   *
+   * @param start The first line number
+   * @param end The last line number
+   * @param side The side of the range. Either 'left' or 'right'.
+   * @param out_lines The output list of line objects.
+   *        TODO: Change to camelCase.
+   * @param out_elements The output list of line elements.
+   *        TODO: Change to camelCase.
+   */
+  // visible for testing
+  findLinesByRange(
+    start: LineNumber,
+    end: LineNumber,
+    side: Side,
+    out_lines: GrDiffLine[],
+    out_elements: HTMLElement[]
+  ) {
+    const groups = this.getGroupsByLineRange(start, end, side);
+    for (const group of groups) {
+      let content: HTMLElement | null = null;
+      for (const line of group.lines) {
+        if (
+          (side === 'left' && line.type === GrDiffLineType.ADD) ||
+          (side === 'right' && line.type === GrDiffLineType.REMOVE)
+        ) {
+          continue;
+        }
+        const lineNumber =
+          side === 'left' ? line.beforeNumber : line.afterNumber;
+        if (lineNumber < start || lineNumber > end) {
+          continue;
+        }
+
+        if (content) {
+          content = this.getNextContentOnSide(content, side);
+        } else {
+          content = this.getContentByLine(lineNumber, side, group.element);
+        }
+        if (content) {
+          // out_lines and out_elements must match. So if we don't have an
+          // element to push, then also don't push a line.
+          out_lines.push(line);
+          out_elements.push(content);
+        }
+      }
+    }
+    assert(
+      out_lines.length === out_elements.length,
+      'findLinesByRange: lines and elements arrays must have same length'
+    );
+  }
+
+  protected abstract renderContentByRange(
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ): void;
+
+  protected abstract renderBlameByRange(
+    blame: BlameInfo,
+    start: number,
+    end: number
+  ): void;
+
+  /**
+   * Finds the next DIV.contentText element following the given element, and on
+   * the same side. Will only search within a group.
+   *
+   * TODO: Change `null` to `undefined`.
+   */
+  protected abstract getNextContentOnSide(
+    content: HTMLElement,
+    side: Side
+  ): HTMLElement | null;
+
+  /**
+   * Gets configuration for creating move controls for chunks marked with
+   * dueToMove
+   */
+  protected abstract getMoveControlsConfig(): {
+    numberOfCells: number;
+    movedOutIndex: number;
+    movedInIndex: number;
+    lineNumberCols: number[];
+    signCols?: {left: number; right: number};
+  };
+
+  /**
+   * Set the blame information for the diff. For any already-rendered line,
+   * re-render its blame cell content.
+   */
+  setBlame(blame: BlameInfo[]) {
+    this.blameInfo = blame;
+    for (const commit of blame) {
+      for (const range of commit.ranges) {
+        this.renderBlameByRange(commit, range.start, range.end);
+      }
+    }
+  }
+
+  /**
+   * Given a base line number, return the commit containing that line in the
+   * current set of blame information. If no blame information has been
+   * provided, null is returned.
+   *
+   * @return The commit information.
+   */
+  // visible for testing
+  getBlameCommitForBaseLine(lineNum: LineNumber): BlameInfo | undefined {
+    for (const blameCommit of this.blameInfo) {
+      for (const range of blameCommit.ranges) {
+        if (range.start <= lineNum && range.end >= lineNum) {
+          return blameCommit;
+        }
+      }
+    }
+    return undefined;
+  }
+
+  /**
+   * Only special builders need to implement this. The default is to
+   * just ignore it.
+   */
+  updateRenderPrefs(_: RenderPreferences) {}
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
new file mode 100644
index 0000000..13a6b00
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -0,0 +1,465 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement, nothing, TemplateResult} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {createRef, Ref, ref} from 'lit/directives/ref.js';
+import {
+  DiffResponsiveMode,
+  Side,
+  LineNumber,
+  DiffLayer,
+} from '../../../api/diff';
+import {BlameInfo} from '../../../types/common';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+import {getBaseUrl} from '../../../utils/url-util';
+import './gr-diff-text';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {diffClasses, isResponsive} from '../gr-diff/gr-diff-utils';
+
+@customElement('gr-diff-row')
+export class GrDiffRow extends LitElement {
+  contentLeftRef: Ref<LitElement> = createRef();
+
+  contentRightRef: Ref<LitElement> = createRef();
+
+  contentCellLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+  contentCellRightRef: Ref<HTMLTableCellElement> = createRef();
+
+  lineNumberLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+  lineNumberRightRef: Ref<HTMLTableCellElement> = createRef();
+
+  blameCellRef: Ref<HTMLTableCellElement> = createRef();
+
+  tableRowRef: Ref<HTMLTableRowElement> = createRef();
+
+  @property({type: Object})
+  left?: GrDiffLine;
+
+  @property({type: Object})
+  right?: GrDiffLine;
+
+  @property({type: Object})
+  blameInfo?: BlameInfo;
+
+  @property({type: Object})
+  responsiveMode?: DiffResponsiveMode;
+
+  /**
+   * true: side-by-side diff
+   * false: unified diff
+   */
+  @property({type: Boolean})
+  unifiedDiff = false;
+
+  @property({type: Number})
+  tabSize = 2;
+
+  @property({type: Number})
+  lineLength = 80;
+
+  @property({type: Boolean})
+  hideFileCommentButton = false;
+
+  @property({type: Object})
+  layers: DiffLayer[] = [];
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * Keeps track of whether diff layers have already been applied to the diff
+   * row. That happens after the DOM has been created in the `updated()`
+   * lifecycle callback.
+   *
+   * Once layers are applied, the diff row requires two rendering passes for an
+   * update: 1. Remove all <gr-diff-text> elements and their layer manipulated
+   * DOMs. 2. Add fresh <gr-diff-text> elements and let layers re-apply in
+   * `updated()`.
+   */
+  private layersApplied = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override updated() {
+    if (this.layersApplied) {
+      // <gr-diff-text> elements have been removed during rendering. Let's start
+      // another rendering cycle with freshly created <gr-diff-text> elements.
+      this.updateComplete.then(() => {
+        this.layersApplied = false;
+        this.requestUpdate();
+      });
+    } else {
+      this.updateLayers(Side.LEFT);
+      this.updateLayers(Side.RIGHT);
+    }
+  }
+
+  /**
+   * The diff layers API is designed to let layers manipulate the DOM. So we
+   * have to apply them after the rendering cycle is done (`updated()`). But
+   * when re-rendering a row that already has layers applied, then we have to
+   * first wipe away <gr-diff-text>. This is achieved by
+   * `this.layersApplied = true`.
+   */
+  private async updateLayers(side: Side) {
+    const line = this.line(side);
+    const contentEl = this.contentRef(side).value;
+    const lineNumberEl = this.lineNumberRef(side).value;
+    if (!line || !contentEl || !lineNumberEl) return;
+
+    // We have to wait for the <gr-diff-text> child component to finish
+    // rendering before we can apply layers, which will re-write the HTML.
+    await contentEl?.updateComplete;
+    for (const layer of this.layers) {
+      if (typeof layer.annotate === 'function') {
+        layer.annotate(contentEl, lineNumberEl, line, side);
+      }
+    }
+    // At this point we consider layers applied. So as soon as <gr-diff-row>
+    // enters a new rendering cycle <gr-diff-text> elements will be removed.
+    this.layersApplied = true;
+  }
+
+  override render() {
+    if (!this.left || !this.right) return;
+    const classes = this.unifiedDiff ? ['unified'] : ['side-by-side'];
+    const unifiedType = this.unifiedType();
+    if (this.unifiedDiff && unifiedType) classes.push(unifiedType);
+    const row = html`
+      <tr
+        ${ref(this.tableRowRef)}
+        class=${diffClasses('diff-row', ...classes)}
+        left-type=${ifDefined(this.getType(Side.LEFT))}
+        right-type=${ifDefined(this.getType(Side.RIGHT))}
+        tabindex="-1"
+        aria-labelledby=${this.ariaLabelIds()}
+      >
+        ${this.renderBlameCell()} ${this.renderLineNumberCell(Side.LEFT)}
+        ${this.renderSignCell(Side.LEFT)} ${this.renderContentCell(Side.LEFT)}
+        ${this.renderLineNumberCell(Side.RIGHT)}
+        ${this.renderSignCell(Side.RIGHT)} ${this.renderContentCell(Side.RIGHT)}
+      </tr>
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${row}
+      </table>`;
+    }
+    return row;
+  }
+
+  private ariaLabelIds() {
+    const ids: string[] = [];
+    ids.push(this.lineNumberId(Side.LEFT));
+    if (!this.unifiedDiff) ids.push(this.contentId(Side.LEFT));
+    ids.push(this.lineNumberId(Side.RIGHT));
+    if (!this.unifiedDiff) ids.push(this.contentId(Side.RIGHT));
+    if (this.unifiedDiff) ids.push(this.contentId(this.unifiedSide()));
+    return ids.filter(id => !!id).join(' ');
+  }
+
+  private lineNumberId(side: Side): string {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return '';
+    return `${side}-button-${lineNumber}`;
+  }
+
+  private unifiedSide() {
+    const isLeft = this.line(Side.RIGHT)?.type === GrDiffLineType.BLANK;
+    return isLeft ? Side.LEFT : Side.RIGHT;
+  }
+
+  private contentId(side: Side): string {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return '';
+    return `${side}-content-${lineNumber}`;
+  }
+
+  getTableRow(): HTMLTableRowElement | undefined {
+    return this.tableRowRef.value;
+  }
+
+  getLineNumberCell(side: Side): HTMLTableCellElement | undefined {
+    return this.lineNumberRef(side).value;
+  }
+
+  getContentCell(side: Side) {
+    return this.contentCellRef(side)?.value;
+  }
+
+  getBlameCell() {
+    return this.blameCellRef.value;
+  }
+
+  private renderBlameCell() {
+    // td.blame has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <td
+        ${ref(this.blameCellRef)}
+        class=${diffClasses('blame')}
+        data-line-number=${this.left?.beforeNumber ?? 0}
+      >${this.renderBlameElement()}</td>
+    `;
+  }
+
+  private renderBlameElement() {
+    const lineNum = this.left?.beforeNumber;
+    const commit = this.blameInfo;
+    if (!lineNum || !commit) return;
+
+    const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+    const extras: string[] = [];
+    if (isStartOfRange) extras.push('startOfRange');
+    const date = new Date(commit.time * 1000).toLocaleDateString();
+    const shortName = commit.author.split(' ')[0];
+    const url = `${getBaseUrl()}/q/${commit.id}`;
+
+    // td.blame has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<span class=${diffClasses(...extras)}
+        ><a href=${url} class=${diffClasses('blameDate')}>${date}</a
+        ><span class=${diffClasses('blameAuthor')}> ${shortName}</span
+        ><gr-hovercard class=${diffClasses()}>
+          <span class=${diffClasses('blameHoverCard')}>
+            Commit ${commit.id}<br />
+            Author: ${commit.author}<br />
+            Date: ${date}<br />
+            <br />
+            ${commit.commit_msg}
+          </span>
+        </gr-hovercard
+      ></span>`;
+  }
+
+  private renderLineNumberCell(side: Side): TemplateResult {
+    const line = this.line(side);
+    const lineNumber = this.lineNumber(side);
+    const isBlank = line?.type === GrDiffLineType.BLANK;
+    if (!line || !lineNumber || isBlank || this.layersApplied) {
+      const blankClass = isBlank && !this.unifiedDiff ? 'blankLineNum' : '';
+      return html`<td
+        ${ref(this.lineNumberRef(side))}
+        class=${diffClasses(side, blankClass)}
+      ></td>`;
+    }
+
+    return html`<td
+      ${ref(this.lineNumberRef(side))}
+      class=${diffClasses(side, 'lineNum')}
+      data-value=${lineNumber}
+    >
+      ${this.renderLineNumberButton(line, lineNumber, side)}
+    </td>`;
+  }
+
+  private renderLineNumberButton(
+    line: GrDiffLine,
+    lineNumber: LineNumber,
+    side: Side
+  ) {
+    if (this.hideFileCommentButton && lineNumber === 'FILE') return;
+    if (lineNumber === 'LOST') return;
+    // .lineNumButton has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <button
+        id=${this.lineNumberId(side)}
+        class=${diffClasses('lineNumButton', side)}
+        tabindex="-1"
+        data-value=${lineNumber}
+        aria-label=${ifDefined(
+          this.computeLineNumberAriaLabel(line, lineNumber)
+        )}
+        @mouseenter=${() =>
+          fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})}
+        @mouseleave=${() =>
+          fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})}
+      >${lineNumber === 'FILE' ? 'File' : lineNumber.toString()}</button>
+    `;
+  }
+
+  private computeLineNumberAriaLabel(line: GrDiffLine, lineNumber: LineNumber) {
+    if (lineNumber === 'FILE') return 'Add file comment';
+
+    // Add aria-labels for valid line numbers.
+    // For unified diff, this method will be called with number set to 0 for
+    // the empty line number column for added/removed lines. This should not
+    // be announced to the screenreader.
+    if (lineNumber <= 0) return undefined;
+
+    switch (line.type) {
+      case GrDiffLineType.REMOVE:
+        return `${lineNumber} removed`;
+      case GrDiffLineType.ADD:
+        return `${lineNumber} added`;
+      case GrDiffLineType.BOTH:
+      case GrDiffLineType.BLANK:
+        return `${lineNumber} unmodified`;
+    }
+  }
+
+  private renderContentCell(side: Side) {
+    let line = this.line(side);
+    if (this.unifiedDiff) {
+      if (side === Side.LEFT) return nothing;
+      if (line?.type === GrDiffLineType.BLANK) {
+        side = Side.LEFT;
+        line = this.line(Side.LEFT);
+      }
+    }
+    const lineNumber = this.lineNumber(side);
+    assertIsDefined(line, 'line');
+    const extras: string[] = [line.type, side];
+    if (line.type !== GrDiffLineType.BLANK) extras.push('content');
+    if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+    if (line.beforeNumber === 'FILE') extras.push('file');
+    if (line.beforeNumber === 'LOST') extras.push('lost');
+
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <td
+        ${ref(this.contentCellRef(side))}
+        class=${diffClasses(...extras)}
+        @mouseenter=${() => {
+          if (lineNumber)
+            fire(this, 'line-mouse-enter', {lineNum: lineNumber, side});
+        }}
+        @mouseleave=${() => {
+          if (lineNumber)
+            fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
+        }}
+      >${this.renderText(side)}${this.renderThreadGroup(side)}</td>
+    `;
+  }
+
+  private renderSignCell(side: Side) {
+    if (this.unifiedDiff) return nothing;
+    const line = this.line(side);
+    assertIsDefined(line, 'line');
+    const isBlank = line.type === GrDiffLineType.BLANK;
+    const isAdd = line.type === GrDiffLineType.ADD && side === Side.RIGHT;
+    const isRemove = line.type === GrDiffLineType.REMOVE && side === Side.LEFT;
+    const extras: string[] = ['sign', side];
+    if (isBlank) extras.push('blank');
+    if (isAdd) extras.push('add');
+    if (isRemove) extras.push('remove');
+    if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+
+    const sign = isAdd ? '+' : isRemove ? '-' : '';
+    return html`<td class=${diffClasses(...extras)}>${sign}</td>`;
+  }
+
+  private renderThreadGroup(side: Side) {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return nothing;
+    return html`<div class="thread-group" data-side=${side}>
+      <slot name="${side}-${lineNumber}"></slot>
+      ${this.renderSecondSlot()}
+    </div>`;
+  }
+
+  private renderSecondSlot() {
+    if (!this.unifiedDiff) return nothing;
+    if (this.line(Side.LEFT)?.type !== GrDiffLineType.BOTH) return nothing;
+    return html`<slot
+      name="${Side.LEFT}-${this.lineNumber(Side.LEFT)}"
+    ></slot>`;
+  }
+
+  private contentRef(side: Side) {
+    return side === Side.LEFT ? this.contentLeftRef : this.contentRightRef;
+  }
+
+  private contentCellRef(side: Side) {
+    return side === Side.LEFT
+      ? this.contentCellLeftRef
+      : this.contentCellRightRef;
+  }
+
+  private lineNumberRef(side: Side) {
+    return side === Side.LEFT
+      ? this.lineNumberLeftRef
+      : this.lineNumberRightRef;
+  }
+
+  private lineNumber(side: Side) {
+    return this.line(side)?.lineNumber(side);
+  }
+
+  private line(side: Side) {
+    return side === Side.LEFT ? this.left : this.right;
+  }
+
+  private getType(side?: Side): string | undefined {
+    if (this.unifiedDiff) return undefined;
+    if (side === Side.LEFT) return this.left?.type;
+    if (side === Side.RIGHT) return this.right?.type;
+    return undefined;
+  }
+
+  private unifiedType() {
+    return this.left?.type === GrDiffLineType.BLANK
+      ? this.right?.type
+      : this.left?.type;
+  }
+
+  /**
+   * Returns a 'div' element containing the supplied |text| as its innerText,
+   * with '\t' characters expanded to a width determined by |tabSize|, and the
+   * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+   * desired.
+   */
+  private renderText(side: Side) {
+    const line = this.line(side);
+    const lineNumber = this.lineNumber(side);
+    if (lineNumber === 'FILE' || lineNumber === 'LOST') return;
+
+    // Note that `this.layersApplied` will wipe away the <gr-diff-text>, and
+    // another rendering cycle will be initiated in `updated()`.
+    // prettier-ignore
+    const textElement = line?.text && !this.layersApplied
+      ? html`<gr-diff-text
+          ${ref(this.contentRef(side))}
+          .text=${line?.text}
+          .tabSize=${this.tabSize}
+          .lineLimit=${this.lineLength}
+          .isResponsive=${isResponsive(this.responsiveMode)}
+        ></gr-diff-text>` : '';
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<div
+        class=${diffClasses('contentText')}
+        data-side=${ifDefined(side)}
+        id=${this.contentId(side)}
+      >${textElement}</div>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-row': GrDiffRow;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
new file mode 100644
index 0000000..1c7b311
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
@@ -0,0 +1,265 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-row';
+import {GrDiffRow} from './gr-diff-row';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType} from '../../../api/diff';
+
+suite('gr-diff-row test', () => {
+  let element: GrDiffRow;
+
+  setup(async () => {
+    element = await fixture<GrDiffRow>(html`<gr-diff-row></gr-diff-row>`);
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  test('both', async () => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = line;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('both unified', async () => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = line;
+    element.unifiedDiff = true;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="left-button-1 right-button-1 right-content-1"
+              class="both diff-row gr-diff unified"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('add', async () => {
+    const line = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+    line.text = 'lorem ipsum';
+    element.left = new GrDiffLine(GrDiffLineType.BLANK);
+    element.right = line;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="blank"
+              right-type="add"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="blankLineNum gr-diff left"></td>
+              <td class="blank gr-diff left no-intraline-info sign"></td>
+              <td class="blank gr-diff left no-intraline-info">
+                <div class="contentText gr-diff" data-side="left"></div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 added"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="add gr-diff no-intraline-info right sign">+</td>
+              <td class="add content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('remove', async () => {
+    const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = new GrDiffLine(GrDiffLineType.BLANK);
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="left-button-1 left-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="remove"
+              right-type="blank"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 removed"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info remove sign">-</td>
+              <td class="content gr-diff left no-intraline-info remove">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="blankLineNum gr-diff right"></td>
+              <td class="blank gr-diff no-intraline-info right sign"></td>
+              <td class="blank gr-diff no-intraline-info right">
+                <div class="contentText gr-diff" data-side="right"></div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
new file mode 100644
index 0000000..b952a3d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -0,0 +1,249 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  DiffInfo,
+  DiffLayer,
+  DiffViewMode,
+  MovedLinkClickedEventDetail,
+  RenderPreferences,
+  Side,
+  LineNumber,
+  DiffPreferencesInfo,
+} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {
+  countLines,
+  diffClasses,
+  getResponsiveMode,
+} from '../gr-diff/gr-diff-utils';
+import {GrDiffRow} from './gr-diff-row';
+import '../gr-context-controls/gr-context-controls-section';
+import '../gr-context-controls/gr-context-controls';
+import '../gr-range-header/gr-range-header';
+import './gr-diff-row';
+
+@customElement('gr-diff-section')
+export class GrDiffSection extends LitElement {
+  @property({type: Object})
+  group?: GrDiffGroup;
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @property({type: Object})
+  renderPrefs?: RenderPreferences;
+
+  @property({type: Object})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  layers: DiffLayer[] = [];
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override render() {
+    if (!this.group) return;
+    const extras: string[] = [];
+    extras.push('section');
+    extras.push(this.group.type);
+    if (this.group.isTotal()) extras.push('total');
+    if (this.group.dueToRebase) extras.push('dueToRebase');
+    if (this.group.moveDetails) extras.push('dueToMove');
+    if (this.group.moveDetails?.changed) extras.push('changed');
+    if (this.group.ignoredWhitespaceOnly) extras.push('ignoredWhitespaceOnly');
+
+    const pairs = this.getLinePairs();
+    const responsiveMode = getResponsiveMode(this.diffPrefs, this.renderPrefs);
+    const hideFileCommentButton =
+      this.diffPrefs?.show_file_comment_button === false ||
+      this.renderPrefs?.show_file_comment_button === false;
+    const body = html`
+      <tbody class=${diffClasses(...extras)}>
+        ${this.renderContextControls()} ${this.renderMoveControls()}
+        ${pairs.map(pair => {
+          const leftCl = `left-${pair.left.lineNumber(Side.LEFT)}`;
+          const rightCl = `right-${pair.right.lineNumber(Side.RIGHT)}`;
+          return html`
+            <gr-diff-row
+              class="${leftCl} ${rightCl}"
+              .left=${pair.left}
+              .right=${pair.right}
+              .layers=${this.layers}
+              .lineLength=${this.diffPrefs?.line_length ?? 80}
+              .tabSize=${this.diffPrefs?.tab_size ?? 2}
+              .unifiedDiff=${this.isUnifiedDiff()}
+              .responsiveMode=${responsiveMode}
+              .hideFileCommentButton=${hideFileCommentButton}
+            >
+            </gr-diff-row>
+          `;
+        })}
+      </tbody>
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${body}
+      </table>`;
+    }
+    return body;
+  }
+
+  private isUnifiedDiff() {
+    return this.renderPrefs?.view_mode === DiffViewMode.UNIFIED;
+  }
+
+  getLinePairs() {
+    if (!this.group) return [];
+    const isControl = this.group.type === GrDiffGroupType.CONTEXT_CONTROL;
+    if (isControl) return [];
+    return this.isUnifiedDiff()
+      ? this.group.getUnifiedPairs()
+      : this.group.getSideBySidePairs();
+  }
+
+  getDiffRows(): GrDiffRow[] {
+    return [...this.querySelectorAll<GrDiffRow>('gr-diff-row')];
+  }
+
+  private renderContextControls() {
+    if (this.group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+
+    const leftStart = this.group.lineRange.left.start_line;
+    const leftEnd = this.group.lineRange.left.end_line;
+    const firstGroupIsSkipped = !!this.group.contextGroups[0].skip;
+    const lastGroupIsSkipped =
+      !!this.group.contextGroups[this.group.contextGroups.length - 1].skip;
+    const lineCountLeft = countLines(this.diff, Side.LEFT);
+    const containsWholeFile = lineCountLeft === leftEnd - leftStart + 1;
+    const showAbove =
+      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
+    const showBelow = leftEnd < lineCountLeft && !lastGroupIsSkipped;
+
+    return html`
+      <gr-context-controls-section
+        .showAbove=${showAbove}
+        .showBelow=${showBelow}
+        .group=${this.group}
+        .diff=${this.diff}
+        .renderPrefs=${this.renderPrefs}
+      >
+      </gr-context-controls-section>
+    `;
+  }
+
+  findRow(side: Side, lineNumber: LineNumber): GrDiffRow | undefined {
+    return (
+      this.querySelector<GrDiffRow>(`gr-diff-row.${side}-${lineNumber}`) ??
+      undefined
+    );
+  }
+
+  private renderMoveControls() {
+    if (!this.group?.moveDetails) return;
+    const movedIn = this.group.adds.length > 0;
+    const plainCell = html`<td class=${diffClasses()}></td>`;
+    const signCell = html`<td class=${diffClasses('sign')}></td>`;
+    const lineNumberCell = html`
+      <td class=${diffClasses('moveControlsLineNumCol')}></td>
+    `;
+    const moveCell = html`
+      <td class=${diffClasses('moveHeader')}>
+        <gr-range-header class=${diffClasses()} icon="gr-icons:move-item">
+          ${this.renderMoveDescription(movedIn)}
+        </gr-range-header>
+      </td>
+    `;
+    return html`
+      <tr
+        class=${diffClasses('moveControls', movedIn ? 'movedIn' : 'movedOut')}
+      >
+        ${lineNumberCell} ${signCell} ${movedIn ? plainCell : moveCell}
+        ${lineNumberCell} ${signCell} ${movedIn ? moveCell : plainCell}
+      </tr>
+    `;
+  }
+
+  private renderMoveDescription(movedIn: boolean) {
+    if (this.group?.moveDetails?.range) {
+      const {changed, range} = this.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 `;
+      return html`
+        <div class=${diffClasses()}>
+          <span class=${diffClasses()}>${textLabel}</span>
+          ${this.renderMovedLineAnchor(range.start, otherSide)}
+          <span class=${diffClasses()}> - </span>
+          ${this.renderMovedLineAnchor(range.end, otherSide)}
+        </div>
+      `;
+    }
+
+    return html`
+      <div class=${diffClasses()}>
+        <span class=${diffClasses()}
+          >${movedIn ? 'Moved in' : 'Moved out'}</span
+        >
+      </div>
+    `;
+  }
+
+  private renderMovedLineAnchor(line: number, side: Side) {
+    const listener = (e: MouseEvent) => {
+      e.preventDefault();
+      this.handleMovedLineAnchorClick(e.target, side, line);
+    };
+    // `href` is not actually used but important for Screen Readers
+    return html`
+      <a class=${diffClasses()} href=${`#${line}`} @click=${listener}
+        >${line}</a
+      >
+    `;
+  }
+
+  private handleMovedLineAnchorClick(
+    anchor: EventTarget | null,
+    side: Side,
+    line: number
+  ) {
+    anchor?.dispatchEvent(
+      new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
+        detail: {
+          lineNum: line,
+          side,
+        },
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-section': GrDiffSection;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
new file mode 100644
index 0000000..33b3df0
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -0,0 +1,221 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-section';
+import {GrDiffSection} from './gr-diff-section';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType} from '../../../api/diff';
+
+suite('gr-diff-section test', () => {
+  let element: GrDiffSection;
+
+  setup(async () => {
+    element = await fixture<GrDiffSection>(
+      html`<gr-diff-section></gr-diff-section>`
+    );
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  test('3 normal unchanged rows', async () => {
+    const lines = [
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+    ];
+    lines[0].text = 'asdf';
+    lines[1].text = 'qwer';
+    lines[2].text = 'zxcv';
+    const group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+    element.group = group;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <table>
+          <tbody class="both gr-diff section">
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
new file mode 100644
index 0000000..c1b13ac
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
@@ -0,0 +1,152 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, TemplateResult} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {styleMap} from 'lit/directives/style-map.js';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+
+const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+const TAB = '\t';
+
+/**
+ * Renders one line of code on one side of the diff. It takes care of:
+ * - Tabs, see `tabSize` property.
+ * - Line Breaks, see `lineLimit` property.
+ * - Surrogate Character Pairs.
+ *
+ * Note that other modifications to the code in a gr-diff is done via diff
+ * layers, which manipulate the DOM directly. So `gr-diff-text` is thrown
+ * away and re-rendered every time something changes by its parent
+ * `gr-diff-row`. So don't bother to optimize this component for re-rendering
+ * performance. And be aware that building longer lived local state is not
+ * useful here.
+ */
+@customElement('gr-diff-text')
+export class GrDiffText extends LitElement {
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  @property({type: String})
+  text = '';
+
+  @property({type: Boolean})
+  isResponsive = false;
+
+  @property({type: Number})
+  tabSize = 2;
+
+  @property({type: Number})
+  lineLimit = 80;
+
+  /** Temporary state while rendering. */
+  private textOffset = 0;
+
+  /** Temporary state while rendering. */
+  private columnPos = 0;
+
+  /** Temporary state while rendering. */
+  private pieces: (string | TemplateResult)[] = [];
+
+  /** Split up the string into tabs, surrogate pairs and regular segments. */
+  override render() {
+    this.textOffset = 0;
+    this.columnPos = 0;
+    this.pieces = [];
+    const splitByTab = this.text.split('\t');
+    for (let i = 0; i < splitByTab.length; i++) {
+      const splitBySurrogate = splitByTab[i].split(SURROGATE_PAIR);
+      for (let j = 0; j < splitBySurrogate.length; j++) {
+        this.renderSegment(splitBySurrogate[j]);
+        if (j < splitBySurrogate.length - 1) {
+          this.renderSurrogatePair();
+        }
+      }
+      if (i < splitByTab.length - 1) {
+        this.renderTab();
+      }
+    }
+    if (this.textOffset !== this.text.length) throw new Error('unfinished');
+    return this.pieces;
+  }
+
+  /** Render regular characters, but insert line breaks appropriately. */
+  private renderSegment(segment: string) {
+    let segmentOffset = 0;
+    while (segmentOffset < segment.length) {
+      const newOffset = Math.min(
+        segment.length,
+        segmentOffset + this.lineLimit - this.columnPos
+      );
+      this.renderString(segment.substring(segmentOffset, newOffset));
+      segmentOffset = newOffset;
+      if (segmentOffset < segment.length && this.columnPos === this.lineLimit) {
+        this.renderLineBreak();
+      }
+    }
+  }
+
+  /** Render regular characters. */
+  private renderString(s: string) {
+    if (s.length === 0) return;
+    this.pieces.push(s);
+    this.textOffset += s.length;
+    this.columnPos += s.length;
+    if (this.columnPos > this.lineLimit) throw new Error('over line limit');
+  }
+
+  /** Render a tab character. */
+  private renderTab() {
+    let tabSize = this.tabSize - (this.columnPos % this.tabSize);
+    if (this.columnPos + tabSize > this.lineLimit) {
+      this.renderLineBreak();
+      tabSize = this.tabSize;
+    }
+    const piece = html`<span
+      class=${diffClasses('tab')}
+      style=${styleMap({'tab-size': `${tabSize}`})}
+      >${TAB}</span
+    >`;
+    this.pieces.push(piece);
+    this.textOffset += 1;
+    this.columnPos += tabSize;
+  }
+
+  /** Render a surrogate pair: string length is 2, but is just 1 char. */
+  private renderSurrogatePair() {
+    if (this.columnPos === this.lineLimit) {
+      this.renderLineBreak();
+    }
+    this.pieces.push(this.text.substring(this.textOffset, this.textOffset + 2));
+    this.textOffset += 2;
+    this.columnPos += 1;
+  }
+
+  /** Render a line break, don't advance text offset, reset col position. */
+  private renderLineBreak() {
+    if (this.isResponsive) {
+      this.pieces.push(html`<wbr class=${diffClasses()}></wbr>`);
+    } else {
+      this.pieces.push(html`<span class=${diffClasses('br')}></span>`);
+    }
+    // this.textOffset += 0;
+    this.columnPos = 0;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-text': GrDiffText;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
new file mode 100644
index 0000000..a0e7840
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-text';
+import {GrDiffText} from './gr-diff-text';
+import {fixture, html, assert} from '@open-wc/testing';
+
+const LINE_BREAK = '<span class="gr-diff br"></span>';
+
+const TAB = '<span class="" style=""></span>';
+
+const TAB_IGNORE = ['class', 'style'];
+
+suite('gr-diff-text test', () => {
+  let element: GrDiffText;
+
+  setup(async () => {
+    element = await fixture<GrDiffText>(
+      html`<gr-diff-text tabsize="4" linelimit="10"></gr-diff-text>`
+    );
+  });
+
+  const check = async (
+    text: string,
+    html: string,
+    ignoreAttributes: string[] = []
+  ) => {
+    element.text = text;
+    await element.updateComplete;
+    assert.lightDom.equal(element, html, {ignoreAttributes});
+  };
+
+  suite('lit rendering', () => {
+    test('renderText newlines 1', async () => {
+      await check('abcdef', 'abcdef');
+      await check('a'.repeat(20), `aaaaaaaaaa${LINE_BREAK}aaaaaaaaaa`);
+    });
+
+    test('renderText newlines 2', async () => {
+      await check(
+        '<span class="thumbsup">👍</span>',
+        '&lt;span clas' +
+          LINE_BREAK +
+          's="thumbsu' +
+          LINE_BREAK +
+          'p"&gt;👍&lt;/span' +
+          LINE_BREAK +
+          '&gt;'
+      );
+    });
+
+    test('renderText newlines 3', async () => {
+      await check(
+        '01234\t56789',
+        '01234' + TAB + '56' + LINE_BREAK + '789',
+        TAB_IGNORE
+      );
+    });
+
+    test('renderText newlines 4', async () => {
+      element.lineLimit = 20;
+      await element.updateComplete;
+      await check(
+        '👍'.repeat(58),
+        '👍'.repeat(20) +
+          LINE_BREAK +
+          '👍'.repeat(20) +
+          LINE_BREAK +
+          '👍'.repeat(18)
+      );
+    });
+
+    test('tab wrapper style', async () => {
+      element.lineLimit = 100;
+      element.tabSize = 4;
+      await check(
+        '\t',
+        /* HTML */ '<span class="gr-diff tab" style="tab-size:4;"></span>'
+      );
+      await check(
+        'abc\t',
+        /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:1;"></span>'
+      );
+
+      element.tabSize = 8;
+      await check(
+        '\t',
+        /* HTML */ '<span class="gr-diff tab" style="tab-size:8;"></span>'
+      );
+      await check(
+        'abc\t',
+        /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:5;"></span>'
+      );
+    });
+
+    test('tab wrapper insertion', async () => {
+      await check('abc\tdef', 'abc' + TAB + 'def', TAB_IGNORE);
+    });
+
+    test('escaping HTML', async () => {
+      element.lineLimit = 100;
+      await element.updateComplete;
+      await check(
+        '<script>alert("XSS");<' + '/script>',
+        '&lt;script&gt;alert("XSS");&lt;/script&gt;'
+      );
+      await check('& < > " \' / `', '&amp; &lt; &gt; " \' / `');
+    });
+
+    test('text length with tabs and unicode', async () => {
+      async function expectTextLength(
+        text: string,
+        tabSize: number,
+        expected: number
+      ) {
+        element.text = text;
+        element.tabSize = tabSize;
+        element.lineLimit = expected;
+        await element.updateComplete;
+        const result = element.innerHTML;
+
+        // Must not contain a line break.
+        assert.isNotOk(element.querySelector('span.br'));
+
+        // Increasing the line limit by 1 should not change anything.
+        element.lineLimit = expected + 1;
+        await element.updateComplete;
+        const resultPlusOne = element.innerHTML;
+        assert.equal(resultPlusOne, result);
+
+        // Increasing the line limit to infinity should not change anything.
+        element.lineLimit = Infinity;
+        await element.updateComplete;
+        const resultInf = element.innerHTML;
+        assert.equal(resultInf, result);
+
+        // Decreasing the line limit by 1 should introduce a line break.
+        element.lineLimit = expected + 1;
+        await element.updateComplete;
+        assert.isNotOk(element.querySelector('span.br'));
+      }
+      expectTextLength('12345', 4, 5);
+      expectTextLength('\t\t12', 4, 10);
+      expectTextLength('abc💢123', 4, 7);
+      expectTextLength('abc\t', 8, 8);
+      expectTextLength('abc\t\t', 10, 20);
+      expectTextLength('', 10, 0);
+      // 17 Thai combining chars.
+      expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+      expectTextLength('abc\tde', 10, 12);
+      expectTextLength('abc\tde\t', 10, 20);
+      expectTextLength('\t\t\t\t\t', 20, 100);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
new file mode 100644
index 0000000..e9076aa
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -0,0 +1,352 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {DiffLayer} from '../../../types/types';
+import {GrDiffLine, Side, TokenHighlightListener} from '../../../api/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+
+import {getLineElByChild, getSideByLineEl} from '../gr-diff/gr-diff-utils';
+
+import {
+  getLineNumberByChild,
+  lineNumberToNumber,
+} from '../gr-diff/gr-diff-utils';
+import {GrDiff} from '../gr-diff/gr-diff';
+
+const tokenMatcher = new RegExp(/[\w]+/g);
+
+/** CSS class for all tokens. */
+const CSS_TOKEN = 'token';
+
+/** CSS class for the currently hovered token. */
+const CSS_HIGHLIGHT = 'token-highlight';
+
+/** CSS class marking which text value each token corresponds */
+const TOKEN_TEXT_PREFIX = 'tk-text-';
+
+/**
+ * CSS class marking which index (column) where token starts within a line of code.
+ * The assumption is that we can only have a single token per column start per line.
+ */
+const TOKEN_INDEX_PREFIX = 'tk-index-';
+
+export const HOVER_DELAY_MS = 200;
+
+const LINE_LENGTH_LIMIT = 500;
+
+const TOKEN_LENGTH_LIMIT = 100;
+
+const TOKEN_COUNT_LIMIT = 10000;
+
+const TOKEN_OCCURRENCES_LIMIT = 1000;
+
+/**
+ * When a user hovers over a token in the diff, then this layer makes sure that
+ * all occurrences of this token are annotated with the 'token-highlight' css
+ * class. And removes that class when the user moves the mouse away from the
+ * token.
+ *
+ * The layer does not react to mouse events directly by adding a css class to
+ * the appropriate elements, but instead it just sets the currently highlighted
+ * token and notifies the diff renderer that certain lines must be re-rendered.
+ * And when that re-rendering happens the appropriate css class is added.
+ */
+export class TokenHighlightLayer implements DiffLayer {
+  /** The currently highlighted token. */
+  private currentHighlight?: string;
+
+  /** Trigger when a new token starts or stoped being highlighted.*/
+  private readonly tokenHighlightListener?: TokenHighlightListener;
+
+  /**
+   * The line of the currently highlighted token. We store this in order to
+   * re-render only relevant lines of the diff. Only lines visible on the screen
+   * need a highlight. For example in a file with 10,000 lines it is sufficient
+   * to just re-render the ~100 lines that are visible to the user.
+   *
+   * It is a known issue that we are only storing the line number on the side of
+   * where the user is hovering and we use that also to determine which line
+   * numbers to re-render on the other side, but it is non-trivial to look up or
+   * store a reliable mapping of line numbers, so we just accept this
+   * shortcoming with the reasoning that the user is mostly interested in the
+   * tokens on the side where they are hovering anyway.
+   *
+   * Another known issue is that we are not able to see past collapsed lines
+   * with the current implementation.
+   */
+  private currentHighlightLineNumber = 0;
+
+  /**
+   * Keeps track of where tokens occur in a file during rendering, so that it is
+   * easy to look up when processing mouse events.
+   */
+  private tokenToElements = new Map<string, Set<HTMLElement>>();
+
+  private hoveredElement?: Element;
+
+  private updateTokenTask?: DelayedTask;
+
+  /**
+   * Container that contains all annotated tokens and contains no shadow root
+   * elements that would prevent tokens to be queryable by querySelectorAll.
+   */
+  private getTokenQueryContainer?: () => HTMLElement;
+
+  /**
+   * @param container for registering "deselect" click
+   * @param tokenHighlightListener method that is called,
+   *   when token is highlighted.
+   * @param getTokenQueryContainer if specified, list of tokens to be
+   *   highlighted are recalculated every time using querySelectorAll inside
+   *   this element. Otherwise, the pointers calculated once at annotate() time
+   *   and are reused.
+   */
+  constructor(
+    container: HTMLElement,
+    tokenHighlightListener?: TokenHighlightListener,
+    getTokenQueryContainer?: () => HTMLElement
+  ) {
+    this.tokenHighlightListener = tokenHighlightListener;
+    container.addEventListener('click', e => {
+      this.handleContainerClick(e);
+    });
+    this.getTokenQueryContainer = getTokenQueryContainer;
+  }
+
+  static createTokenHighlightContainer(
+    container: HTMLElement,
+    getGrDiff: () => GrDiff,
+    tokenHighlightListener?: TokenHighlightListener
+  ): TokenHighlightLayer {
+    return new TokenHighlightLayer(
+      container,
+      tokenHighlightListener,
+      () => getGrDiff().diffTable!
+    );
+  }
+
+  annotate(el: HTMLElement, _1: HTMLElement, _2: GrDiffLine, _3: Side): void {
+    const text = el.textContent;
+    if (!text) return;
+    // Binary files encoded as text for example can have super long lines
+    // with super long tokens. Let's guard against against this scenario.
+    if (text.length > LINE_LENGTH_LIMIT) return;
+    let match;
+    let atLeastOneTokenMatched = false;
+    while ((match = tokenMatcher.exec(text))) {
+      const token = match[0];
+
+      // Binary files encoded as text for example can have super long lines
+      // with super long tokens. Let's guard against this scenario.
+      if (token.length > TOKEN_LENGTH_LIMIT) continue;
+
+      // This is to correctly count surrogate pairs in text and token.
+      // If the index calculation becomes a hotspot, we could precompute a code
+      // unit to code point index map for text before iterating over the results
+      const index = GrAnnotation.getStringLength(text.slice(0, match.index));
+      const length = GrAnnotation.getStringLength(token);
+
+      atLeastOneTokenMatched = true;
+      const highlightTypeClass =
+        token === this.currentHighlight ? CSS_HIGHLIGHT : '';
+      const textClass = `${TOKEN_TEXT_PREFIX}${token}`;
+      const indexClass = `${TOKEN_INDEX_PREFIX}${index}`;
+      // We add the TOKEN_TEXT_PREFIX class so that we can look up the token later easily
+      // even if the token element was split up into multiple smaller nodes.
+      // All parts of a single token will share a common TOKEN_INDEX_PREFIX class within the line of code.
+      GrAnnotation.annotateElement(
+        el,
+        index,
+        length,
+        `${textClass} ${indexClass} ${CSS_TOKEN} ${highlightTypeClass}`
+      );
+      // We could try to detect whether we are re-rendering instead of initially
+      // rendering the line. Then we would not have to call storeLineForToken()
+      // again. But since the Set swallows the duplicates we don't care.
+      this.storeElementsForToken(token, el, textClass);
+    }
+    if (atLeastOneTokenMatched) {
+      // These listeners do not have to be cleaned, because listeners are
+      // garbage collected along with the element itself once it is not attached
+      // to the DOM anymore and no references exist anymore.
+      el.addEventListener('mouseover', e => {
+        this.handleTokenMouseOver(e);
+      });
+      el.addEventListener('mouseout', e => {
+        this.handleTokenMouseOut(e);
+      });
+    }
+  }
+
+  private storeElementsForToken(
+    token: string,
+    lineEl: HTMLElement,
+    cssClass: string
+  ) {
+    for (const el of lineEl.querySelectorAll(`.${cssClass}`)) {
+      let tokenEls = this.tokenToElements.get(token);
+      if (!tokenEls) {
+        // Just to make sure that we don't break down on large files.
+        if (this.tokenToElements.size > TOKEN_COUNT_LIMIT) return;
+        tokenEls = new Set<HTMLElement>();
+        this.tokenToElements.set(token, tokenEls);
+      }
+      // Just to make sure that we don't break down on large files.
+      if (tokenEls.size > TOKEN_OCCURRENCES_LIMIT) return;
+      tokenEls.add(el as HTMLElement);
+    }
+  }
+
+  private handleTokenMouseOut(e: MouseEvent) {
+    // If there's no ongoing hover-task, terminate early.
+    if (!this.updateTokenTask?.isActive()) return;
+    if (e.buttons > 0 || this.interferesWithSelection()) return;
+    const {element} = this.findTokenAncestor(e?.target);
+    if (!element) return;
+    if (element === this.hoveredElement) {
+      // If we are moving out of the currently hovered element, cancel the
+      // update task.
+      this.hoveredElement = undefined;
+      if (this.updateTokenTask) this.updateTokenTask.cancel();
+    }
+  }
+
+  private handleTokenMouseOver(e: MouseEvent) {
+    if (e.buttons > 0 || this.interferesWithSelection()) return;
+    const {
+      line,
+      token: newHighlight,
+      element,
+    } = this.findTokenAncestor(e?.target);
+    if (!newHighlight || newHighlight === this.currentHighlight) return;
+    this.hoveredElement = element;
+    this.updateTokenTask = debounce(
+      this.updateTokenTask,
+      () => {
+        this.updateTokenHighlight(newHighlight, line, element);
+      },
+      HOVER_DELAY_MS
+    );
+  }
+
+  private handleContainerClick(e: MouseEvent) {
+    if (this.interferesWithSelection()) return;
+    // Ignore the click if the click is on a token.
+    // We can't use e.target becauses it gets retargetted to the container as
+    // it's a shadow dom.
+    const {element} = this.findTokenAncestor(e.composedPath()[0]);
+    if (element) return;
+    this.hoveredElement = undefined;
+    this.updateTokenTask?.cancel();
+    this.updateTokenHighlight(undefined, 0, undefined);
+  }
+
+  private interferesWithSelection() {
+    return document.getSelection()?.type === 'Range';
+  }
+
+  findTokenAncestor(el?: EventTarget | Element | null): {
+    token?: string;
+    line: number;
+    element?: Element;
+  } {
+    if (!(el instanceof Element))
+      return {line: 0, token: undefined, element: undefined};
+    if (el.classList.contains(CSS_TOKEN)) {
+      const tkTextClass = [...el.classList].find(c =>
+        c.startsWith(TOKEN_TEXT_PREFIX)
+      );
+      const line = lineNumberToNumber(getLineNumberByChild(el));
+      if (!line || !tkTextClass)
+        return {line: 0, token: undefined, element: undefined};
+      return {
+        line,
+        token: tkTextClass.substring(TOKEN_TEXT_PREFIX.length),
+        element: el,
+      };
+    }
+    if (el.tagName === 'TD')
+      return {line: 0, token: undefined, element: undefined};
+    return this.findTokenAncestor(el.parentElement);
+  }
+
+  private updateTokenHighlight(
+    newHighlight: string | undefined,
+    newLineNumber: number,
+    newHoveredElement: Element | undefined
+  ) {
+    if (
+      this.currentHighlight === newHighlight &&
+      this.currentHighlightLineNumber === newLineNumber
+    )
+      return;
+
+    const oldHighlight = this.currentHighlight;
+    this.currentHighlight = newHighlight;
+    this.currentHighlightLineNumber = newLineNumber;
+    this.triggerTokenHighlightEvent(
+      newHighlight,
+      newLineNumber,
+      newHoveredElement
+    );
+    this.toggleTokenHighlight(oldHighlight, CSS_HIGHLIGHT);
+    this.toggleTokenHighlight(newHighlight, CSS_HIGHLIGHT);
+  }
+
+  private toggleTokenHighlight(token: string | undefined, cssClass: string) {
+    if (!token) {
+      return;
+    }
+    let tokenEls;
+    let tokenElsLength;
+    if (this.getTokenQueryContainer) {
+      tokenEls = this.getTokenQueryContainer().querySelectorAll(
+        `.${TOKEN_TEXT_PREFIX}${token}`
+      );
+      tokenElsLength = tokenEls.length;
+    } else {
+      tokenEls = this.tokenToElements.get(token);
+      tokenElsLength = tokenEls?.size;
+    }
+    if (!tokenEls || tokenElsLength === 0) {
+      console.warn(`No tokens have been found for '${token}'`);
+      return;
+    }
+    for (const el of tokenEls) {
+      el.classList.toggle(cssClass);
+    }
+  }
+
+  triggerTokenHighlightEvent(
+    token: string | undefined,
+    line: number,
+    element: Element | undefined
+  ) {
+    if (!this.tokenHighlightListener) {
+      return;
+    }
+    if (!token || !element) {
+      this.tokenHighlightListener(undefined);
+      return;
+    }
+    const lineEl = getLineElByChild(element);
+    assertIsDefined(lineEl, 'Line element should be found!');
+    const tokenIndexStr = [...element.classList]
+      .find(c => c.startsWith(TOKEN_INDEX_PREFIX))
+      ?.substring(TOKEN_INDEX_PREFIX.length);
+    assertIsDefined(tokenIndexStr, 'Index class should be found!');
+    const index = Number(tokenIndexStr);
+    const side = getSideByLineEl(lineEl);
+    const range = {
+      start_line: line,
+      start_column: index + 1, // 1-based inclusive
+      end_line: line,
+      end_column: index + GrAnnotation.getStringLength(token), // 1-based inclusive
+    };
+    this.tokenHighlightListener({token, element, side, range});
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
new file mode 100644
index 0000000..8fd03bb
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -0,0 +1,404 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {Side, TokenHighlightEventDetails} from '../../../api/diff';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {html, render} from 'lit';
+import {_testOnly_allTasks} from '../../../utils/async-util';
+import {queryAndAssert} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
+
+function dispatchMouseEvent(type: string, node: Element) {
+  const props = {
+    bubbles: true,
+    cancellable: true,
+    composed: true,
+    clientX: 100,
+    clientY: 100,
+    buttons: 0,
+  };
+  node.dispatchEvent(new MouseEvent(type, props));
+}
+
+class MockListener {
+  private results: any[][] = [];
+
+  notify(...args: any[]) {
+    this.results.push(args);
+  }
+
+  shift() {
+    return this.results.shift();
+  }
+
+  flush() {
+    this.results = [];
+  }
+
+  get pending(): number {
+    return this.results.length;
+  }
+}
+
+suite('token-highlight-layer', () => {
+  let container: HTMLElement;
+  let listener: MockListener;
+  let highlighter: TokenHighlightLayer;
+  let tokenHighlightingCalls: {details?: TokenHighlightEventDetails}[] = [];
+
+  function tokenHighlightListener(
+    highlightDetails?: TokenHighlightEventDetails
+  ) {
+    tokenHighlightingCalls.push({details: highlightDetails});
+    listener.notify({details: highlightDetails});
+  }
+
+  setup(async () => {
+    listener = new MockListener();
+    tokenHighlightingCalls = [];
+    container = document.createElement('div');
+    document.body.appendChild(container);
+    highlighter = new TokenHighlightLayer(container, tokenHighlightListener);
+  });
+
+  teardown(() => {
+    document.body.removeChild(container);
+  });
+
+  function annotate(el: HTMLElement, side: Side = Side.LEFT, line = 1) {
+    const diffLine = new GrDiffLine(GrDiffLineType.BOTH);
+    diffLine.afterNumber = line;
+    diffLine.beforeNumber = line;
+    highlighter.annotate(el, document.createElement('div'), diffLine, side);
+    return el;
+  }
+
+  let uniqueId = 0;
+  function createLineId() {
+    uniqueId++;
+    return `line-${uniqueId.toString()}`;
+  }
+
+  function createLine(text: string, line = 1): HTMLElement {
+    const lineId = createLineId();
+    const template = html`
+      <div class="line">
+        <div data-value=${line} class="lineNum right"></div>
+        <div class="content">
+          <div id=${lineId} class="contentText">${text}</div>
+        </div>
+      </div>
+    `;
+
+    const div = document.createElement('div');
+    render(template, div);
+    container.appendChild(div);
+    const el = queryAndAssert(container, `#${lineId}`);
+    return el as HTMLElement;
+  }
+
+  suite('annotate', () => {
+    function assertAnnotation(
+      args: any[],
+      expected: {
+        parent: HTMLElement;
+        offset: number;
+        length: number;
+        cssClass: string;
+      }
+    ) {
+      assert.equal(args[0], expected.parent);
+      assert.equal(args[1], expected.offset);
+      assert.equal(args[2], expected.length);
+      assert.equal(args[3], expected.cssClass);
+    }
+
+    test('annotate adds css token', () => {
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const el = createLine('these are words');
+      annotate(el);
+      assert.isTrue(annotateElementStub.calledThrice);
+      assertAnnotation(annotateElementStub.args[0], {
+        parent: el,
+        offset: 0,
+        length: 5,
+        cssClass: 'tk-text-these tk-index-0 token ',
+      });
+      assertAnnotation(annotateElementStub.args[1], {
+        parent: el,
+        offset: 6,
+        length: 3,
+        cssClass: 'tk-text-are tk-index-6 token ',
+      });
+      assertAnnotation(annotateElementStub.args[2], {
+        parent: el,
+        offset: 10,
+        length: 5,
+        cssClass: 'tk-text-words tk-index-10 token ',
+      });
+    });
+
+    test('annotate adds css tokens w/ emojis', () => {
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const el = createLine('these 💩 are 👨‍👩‍👧‍👦 words');
+
+      annotate(el);
+
+      assert.isTrue(annotateElementStub.calledThrice);
+      assertAnnotation(annotateElementStub.args[0], {
+        parent: el,
+        offset: 0,
+        length: 5,
+        cssClass: 'tk-text-these tk-index-0 token ',
+      });
+      assertAnnotation(annotateElementStub.args[1], {
+        parent: el,
+        offset: 8,
+        length: 3,
+        cssClass: 'tk-text-are tk-index-8 token ',
+      });
+      assertAnnotation(annotateElementStub.args[2], {
+        parent: el,
+        offset: 20,
+        length: 5,
+        cssClass: 'tk-text-words tk-index-20 token ',
+      });
+    });
+
+    test('annotate adds mouse handlers', () => {
+      const el = createLine('these are words');
+      const addEventListenerStub = sinon.stub(el, 'addEventListener');
+      annotate(el);
+      assert.isTrue(addEventListenerStub.calledTwice);
+      assert.equal(addEventListenerStub.args[0][0], 'mouseover');
+      assert.equal(addEventListenerStub.args[1][0], 'mouseout');
+    });
+
+    test('annotate does not add mouse handlers without words', () => {
+      const el = createLine('  ');
+      const addEventListenerStub = sinon.stub(el, 'addEventListener');
+      annotate(el);
+      assert.isFalse(addEventListenerStub.called);
+    });
+
+    test('annotate adds mouse handlers for longest word', () => {
+      const el = createLine('w'.repeat(100));
+      const addEventListenerStub = sinon.stub(el, 'addEventListener');
+      annotate(el);
+      assert.isTrue(addEventListenerStub.called);
+    });
+
+    test('annotate does not add mouse handlers for long words', () => {
+      const el = createLine('w'.repeat(101));
+      const addEventListenerStub = sinon.stub(el, 'addEventListener');
+      annotate(el);
+      assert.isFalse(addEventListenerStub.called);
+    });
+  });
+
+  suite('highlight', () => {
+    test('highlighting hover delay', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words');
+      annotate(line2, Side.RIGHT, 2);
+      const words1 = queryAndAssert(line1, '.tk-text-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent('mouseover', words1);
+
+      assert.equal(listener.pending, 0);
+      assert.equal(_testOnly_allTasks.size, 1);
+
+      // Too early for hover behavior to trigger.
+      clock.tick(100);
+      assert.equal(listener.pending, 0);
+      assert.equal(_testOnly_allTasks.size, 1);
+
+      // After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
+      clock.tick(HOVER_DELAY_MS - 100);
+      assert.equal(listener.pending, 1);
+      assert.equal(_testOnly_allTasks.size, 0);
+    });
+
+    test('highlighting spans many lines', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words');
+      annotate(line2, Side.RIGHT, 1000);
+      const words1 = queryAndAssert(line1, '.tk-text-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent('mouseover', words1);
+
+      assert.equal(listener.pending, 0);
+
+      // After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(listener.pending, 1);
+      assert.equal(_testOnly_allTasks.size, 0);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'words',
+        side: Side.RIGHT,
+        element: words1,
+        range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
+      });
+    });
+
+    test('highlighting mouse out before delay', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words', 2);
+      annotate(line2, Side.RIGHT, 2);
+      const words1 = queryAndAssert(line1, '.tk-text-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent('mouseover', words1);
+      assert.equal(listener.pending, 0);
+      clock.tick(100);
+      // Mouse out after 100ms but before hover delay.
+      dispatchMouseEvent('mouseout', words1);
+      assert.equal(listener.pending, 0);
+      clock.tick(HOVER_DELAY_MS - 100);
+      assert.equal(listener.pending, 0);
+      assert.equal(_testOnly_allTasks.size, 0);
+    });
+
+    test('triggers listener for applying and clearing highlighting', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words', 2);
+      annotate(line2, Side.RIGHT, 2);
+      const words1 = queryAndAssert(line1, '.tk-text-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent('mouseover', words1);
+      assert.equal(tokenHighlightingCalls.length, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(tokenHighlightingCalls.length, 1);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'words',
+        side: Side.RIGHT,
+        element: words1,
+        range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
+      });
+      assert.isTrue(words1.classList.contains('token-highlight'));
+
+      container.click();
+      assert.equal(tokenHighlightingCalls.length, 2);
+      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+      assert.isFalse(words1.classList.contains('token-highlight'));
+    });
+
+    test('triggers listener on token with single occurrence', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('a tokenWithSingleOccurence');
+      const line2 = createLine('can be highlighted', 2);
+      annotate(line1);
+      annotate(line2, Side.RIGHT, 2);
+      const tokenNode = queryAndAssert(
+        line1,
+        '.tk-text-tokenWithSingleOccurence'
+      );
+      assert.isTrue(tokenNode.classList.contains('token'));
+      dispatchMouseEvent('mouseover', tokenNode);
+      assert.equal(tokenHighlightingCalls.length, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(tokenHighlightingCalls.length, 1);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'tokenWithSingleOccurence',
+        side: Side.RIGHT,
+        element: tokenNode,
+        range: {start_line: 1, start_column: 3, end_line: 1, end_column: 26},
+      });
+
+      container.click();
+      assert.equal(tokenHighlightingCalls.length, 2);
+      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+    });
+
+    test('clicking clears highlight', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words', 2);
+      annotate(line2, Side.RIGHT, 2);
+      const words1 = queryAndAssert(line1, '.tk-text-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent('mouseover', words1);
+      assert.equal(listener.pending, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(listener.pending, 1);
+      listener.flush();
+      assert.equal(listener.pending, 0);
+      assert.isTrue(words1.classList.contains('token-highlight'));
+      container.click();
+      assert.equal(listener.pending, 1);
+      assert.isFalse(words1.classList.contains('token-highlight'));
+    });
+
+    test('clicking on word does not clear highlight', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words', 2);
+      annotate(line2, Side.RIGHT, 2);
+      const words1 = queryAndAssert<HTMLDivElement>(line1, '.tk-text-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent('mouseover', words1);
+      assert.equal(listener.pending, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(listener.pending, 1);
+      listener.flush();
+      assert.equal(listener.pending, 0);
+      assert.isTrue(words1.classList.contains('token-highlight'));
+      words1.click();
+      assert.equal(listener.pending, 0);
+      assert.isTrue(words1.classList.contains('token-highlight'));
+    });
+
+    test('query based highlighting', async () => {
+      highlighter = new TokenHighlightLayer(
+        container,
+        tokenHighlightListener,
+        /* getTokenQueryContainer=*/ () => container
+      );
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words', 2);
+      annotate(line2, Side.RIGHT, 2);
+      // Invalidate pointers.
+      for (const child of line1.childNodes) {
+        line1.replaceChild(child.cloneNode(), child);
+      }
+      for (const child of line2.childNodes) {
+        line2.replaceChild(child.cloneNode(), child);
+      }
+
+      const words1 = queryAndAssert(line1, '.tk-text-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent('mouseover', words1);
+      assert.equal(tokenHighlightingCalls.length, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(tokenHighlightingCalls.length, 1);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'words',
+        side: Side.RIGHT,
+        element: words1,
+        range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
+      });
+      assert.isTrue(words1.classList.contains('token-highlight'));
+
+      container.click();
+      assert.equal(tokenHighlightingCalls.length, 2);
+      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+      assert.isFalse(words1.classList.contains('token-highlight'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
new file mode 100644
index 0000000..14bb17e
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -0,0 +1,600 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Subscription} from 'rxjs';
+import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
+import {
+  DiffViewMode,
+  GrDiffCursor as GrDiffCursorApi,
+  LineNumberEventDetail,
+} from '../../../api/diff';
+import {ScrollMode, Side} from '../../../constants/constants';
+import {toggleClass} from '../../../utils/dom-util';
+import {
+  GrCursorManager,
+  isTargetable,
+} from '../../../elements/shared/gr-cursor-manager/gr-cursor-manager';
+import {GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiff} from '../gr-diff/gr-diff';
+
+type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
+
+const LEFT_SIDE_CLASS = 'target-side-left';
+const RIGHT_SIDE_CLASS = 'target-side-right';
+
+interface Address {
+  leftSide: boolean;
+  number: number;
+}
+
+/**
+ * From <tr> diff row go up to <tbody> diff chunk.
+ *
+ * In Lit based diff there is a <gr-diff-row> element in between the two.
+ */
+export function fromRowToChunk(
+  rowEl: HTMLElement
+): HTMLTableSectionElement | undefined {
+  const parent = rowEl.parentElement;
+  if (!parent) return undefined;
+  if (parent.tagName === 'TBODY') {
+    return parent as HTMLTableSectionElement;
+  }
+
+  const grandParent = parent.parentElement;
+  if (!grandParent) return undefined;
+  if (grandParent.tagName === 'TBODY') {
+    return grandParent as HTMLTableSectionElement;
+  }
+
+  return undefined;
+}
+
+/** A subset of the GrDiff API that the cursor is using. */
+export interface GrDiffCursorable extends HTMLElement {
+  isRangeSelected(): boolean;
+  createRangeComment(): void;
+  getCursorStops(): Stop[];
+  path?: string;
+}
+
+export class GrDiffCursor implements GrDiffCursorApi {
+  private preventAutoScrollOnManualScroll = false;
+
+  set side(side: Side) {
+    if (this.sideInternal === side) {
+      return;
+    }
+    if (this.sideInternal && this.diffRow) {
+      this.fireCursorMoved(
+        'line-cursor-moved-out',
+        this.diffRow,
+        this.sideInternal
+      );
+    }
+    this.sideInternal = side;
+    this.updateSideClass();
+    if (this.diffRow) {
+      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    }
+  }
+
+  get side(): Side {
+    return this.sideInternal;
+  }
+
+  private sideInternal = Side.RIGHT;
+
+  set diffRow(diffRow: HTMLElement | undefined) {
+    if (this.diffRowInternal) {
+      this.diffRowInternal.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+      this.fireCursorMoved(
+        'line-cursor-moved-out',
+        this.diffRowInternal,
+        this.side
+      );
+    }
+    this.diffRowInternal = diffRow;
+
+    this.updateSideClass();
+    if (this.diffRow) {
+      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    }
+  }
+
+  get diffRow(): HTMLElement | undefined {
+    return this.diffRowInternal;
+  }
+
+  private diffRowInternal?: HTMLElement;
+
+  private diffs: GrDiffCursorable[] = [];
+
+  /**
+   * If set, the cursor will attempt to move to the line number (instead of
+   * the first chunk) the next time the diff renders. It is set back to null
+   * when used. It should be only used if you want the line to be focused
+   * after initialization of the component and page should scroll
+   * to that position. This parameter should be set at most for one gr-diff
+   * element in the page.
+   */
+  initialLineNumber: number | null = null;
+
+  // visible for testing
+  cursorManager = new GrCursorManager();
+
+  private targetSubscription?: Subscription;
+
+  constructor() {
+    this.cursorManager.cursorTargetClass = 'target-row';
+    this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
+    this.cursorManager.focusOnMove = true;
+
+    window.addEventListener('scroll', this._boundHandleWindowScroll);
+    this.targetSubscription = this.cursorManager.target$.subscribe(target => {
+      this.diffRow = target || undefined;
+    });
+  }
+
+  dispose() {
+    this.cursorManager.unsetCursor();
+    if (this.targetSubscription) this.targetSubscription.unsubscribe();
+    window.removeEventListener('scroll', this._boundHandleWindowScroll);
+  }
+
+  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+  isAtStart() {
+    return this.cursorManager.isAtStart();
+  }
+
+  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+  isAtEnd() {
+    return this.cursorManager.isAtEnd();
+  }
+
+  moveLeft() {
+    this.side = Side.LEFT;
+    if (this._isTargetBlank()) {
+      this.moveUp();
+    }
+  }
+
+  moveRight() {
+    this.side = Side.RIGHT;
+    if (this._isTargetBlank()) {
+      this.moveUp();
+    }
+  }
+
+  moveDown() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      return this.cursorManager.next({
+        filter: (row: Element) => this._rowHasSide(row),
+      });
+    } else {
+      return this.cursorManager.next();
+    }
+  }
+
+  moveUp() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      return this.cursorManager.previous({
+        filter: (row: Element) => this._rowHasSide(row),
+      });
+    } else {
+      return this.cursorManager.previous();
+    }
+  }
+
+  moveToVisibleArea() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.cursorManager.moveToVisibleArea((row: Element) =>
+        this._rowHasSide(row)
+      );
+    } else {
+      this.cursorManager.moveToVisibleArea();
+    }
+  }
+
+  moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
+    const result = this.cursorManager.next({
+      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+      getTargetHeight: target => fromRowToChunk(target)?.scrollHeight || 0,
+      clipToTop,
+    });
+    this._fixSide();
+    return result;
+  }
+
+  moveToPreviousChunk(): CursorMoveResult {
+    const result = this.cursorManager.previous({
+      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+    });
+    this._fixSide();
+    return result;
+  }
+
+  moveToNextCommentThread(): CursorMoveResult {
+    if (this.isAtEnd()) {
+      return CursorMoveResult.CLIPPED;
+    }
+    const result = this.cursorManager.next({
+      filter: (row: HTMLElement) => this._rowHasThread(row),
+    });
+    this._fixSide();
+    return result;
+  }
+
+  moveToPreviousCommentThread(): CursorMoveResult {
+    const result = this.cursorManager.previous({
+      filter: (row: HTMLElement) => this._rowHasThread(row),
+    });
+    this._fixSide();
+    return result;
+  }
+
+  moveToLineNumber(
+    number: number,
+    side: Side,
+    path?: string,
+    intentionalMove?: boolean
+  ) {
+    const row = this._findRowByNumberAndFile(number, side, path);
+    if (row) {
+      this.side = side;
+      this.cursorManager.setCursor(row, undefined, intentionalMove);
+    }
+  }
+
+  /**
+   * Get the line number element targeted by the cursor row and side.
+   */
+  getTargetLineElement(): HTMLElement | null {
+    let lineElSelector = '.lineNum';
+
+    if (!this.diffRow) {
+      return null;
+    }
+
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      lineElSelector += this.side === Side.LEFT ? '.left' : '.right';
+    }
+
+    return this.diffRow.querySelector(lineElSelector);
+  }
+
+  getTargetDiffElement(): GrDiff | null {
+    if (!this.diffRow) return null;
+
+    const hostOwner = this.diffRow.getRootNode() as ShadowRoot;
+    if (hostOwner?.host?.tagName === 'GR-DIFF') {
+      return hostOwner.host as GrDiff;
+    }
+    return null;
+  }
+
+  moveToFirstChunk() {
+    this.cursorManager.moveToStart();
+    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+      this.moveToNextChunk(true);
+    } else {
+      this._fixSide();
+    }
+  }
+
+  moveToLastChunk() {
+    this.cursorManager.moveToEnd();
+    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+      this.moveToPreviousChunk();
+    } else {
+      this._fixSide();
+    }
+  }
+
+  /**
+   * Move the cursor either to initialLineNumber or the first chunk and
+   * reset scroll behavior.
+   *
+   * This may grab the focus from the app.
+   *
+   * If you do not want to move the cursor or grab focus, and just want to
+   * reset the scroll behavior, use reInitAndUpdateStops() instead.
+   */
+  reInitCursor() {
+    this._updateStops();
+    if (!this.diffRow) {
+      // does not scroll during init unless requested
+      this.cursorManager.scrollMode = this.initialLineNumber
+        ? ScrollMode.KEEP_VISIBLE
+        : ScrollMode.NEVER;
+      if (this.initialLineNumber) {
+        this.moveToLineNumber(this.initialLineNumber, this.side);
+        this.initialLineNumber = null;
+      } else {
+        this.moveToFirstChunk();
+      }
+    }
+    this.resetScrollMode();
+  }
+
+  resetScrollMode() {
+    this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
+  }
+
+  private _boundHandleWindowScroll = () => {
+    if (this.preventAutoScrollOnManualScroll) {
+      this.cursorManager.scrollMode = ScrollMode.NEVER;
+      this.cursorManager.focusOnMove = false;
+      this.preventAutoScrollOnManualScroll = false;
+    }
+  };
+
+  reInitAndUpdateStops() {
+    this.resetScrollMode();
+    this._updateStops();
+  }
+
+  private boundHandleDiffLoadingChanged = () => {
+    this._updateStops();
+  };
+
+  private _boundHandleDiffRenderStart = () => {
+    this.preventAutoScrollOnManualScroll = true;
+  };
+
+  private _boundHandleDiffRenderContent = () => {
+    this._updateStops();
+    // When done rendering, turn focus on move and automatic scrolling back on
+    this.cursorManager.focusOnMove = true;
+    this.preventAutoScrollOnManualScroll = false;
+  };
+
+  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 =>
+      diff.isRangeSelected()
+    );
+    if (diffWithRangeSelected) {
+      diffWithRangeSelected.createRangeComment();
+    } else {
+      const line = this.getTargetLineElement();
+      const diff = this.getTargetDiffElement();
+      if (diff && line) {
+        diff.addDraftAtLine(line);
+      }
+    }
+  }
+
+  /**
+   * Get an object describing the location of the cursor. Such as
+   * {leftSide: false, number: 123} for line 123 of the revision, or
+   * {leftSide: true, number: 321} for line 321 of the base patch.
+   * Returns null if an address is not available.
+   */
+  getAddress(): Address | null {
+    if (!this.diffRow) {
+      return null;
+    }
+    // Get the line-number cell targeted by the cursor. If the mode is unified
+    // then prefer the revision cell if available.
+    return this.getAddressFor(this.diffRow, this.side);
+  }
+
+  private getAddressFor(diffRow: HTMLElement, side: Side): Address | null {
+    let cell;
+    if (this._getViewMode() === DiffViewMode.UNIFIED) {
+      cell = diffRow.querySelector('.lineNum.right');
+      if (!cell) {
+        cell = diffRow.querySelector('.lineNum.left');
+      }
+    } else {
+      cell = diffRow.querySelector('.lineNum.' + side);
+    }
+    if (!cell) {
+      return null;
+    }
+
+    const number = cell.getAttribute('data-value');
+    if (!number || number === 'FILE') {
+      return null;
+    }
+
+    return {
+      leftSide: cell.matches('.left'),
+      number: Number(number),
+    };
+  }
+
+  _getViewMode() {
+    if (!this.diffRow) {
+      return null;
+    }
+
+    if (this.diffRow.classList.contains('side-by-side')) {
+      return DiffViewMode.SIDE_BY_SIDE;
+    } else {
+      return DiffViewMode.UNIFIED;
+    }
+  }
+
+  _rowHasSide(row: Element) {
+    const selector =
+      (this.side === Side.LEFT ? '.left' : '.right') + ' + .content';
+    return !!row.querySelector(selector);
+  }
+
+  _isFirstRowOfChunk(row: HTMLElement) {
+    const chunk = fromRowToChunk(row);
+    if (!chunk) return false;
+
+    const isInDeltaChunk = chunk.classList.contains('delta');
+    if (!isInDeltaChunk) return false;
+
+    const firstRow = chunk.querySelector('tr:not(.moveControls)');
+    return firstRow === row;
+  }
+
+  _rowHasThread(row: HTMLElement): boolean {
+    const slots = [
+      ...row.querySelectorAll<HTMLSlotElement>('.thread-group > slot'),
+    ];
+    return slots.some(slot => slot.assignedElements().length > 0);
+  }
+
+  /**
+   * If we jumped to a row where there is no content on the current side then
+   * switch to the alternate side.
+   */
+  _fixSide() {
+    if (
+      this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
+      this._isTargetBlank()
+    ) {
+      this.side = this.side === Side.LEFT ? Side.RIGHT : Side.LEFT;
+    }
+  }
+
+  _isTargetBlank() {
+    if (!this.diffRow) {
+      return false;
+    }
+
+    const actions = this._getActionsForRow();
+    return (
+      (this.side === Side.LEFT && !actions.left) ||
+      (this.side === Side.RIGHT && !actions.right)
+    );
+  }
+
+  private fireCursorMoved(
+    event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
+    row: HTMLElement,
+    side: Side
+  ) {
+    const address = this.getAddressFor(row, side);
+    if (address) {
+      const {leftSide, number} = address;
+      row.dispatchEvent(
+        new CustomEvent<LineNumberEventDetail>(event, {
+          detail: {
+            lineNum: number,
+            side: leftSide ? Side.LEFT : Side.RIGHT,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
+  }
+
+  private updateSideClass() {
+    if (!this.diffRow) {
+      return;
+    }
+    toggleClass(this.diffRow, LEFT_SIDE_CLASS, this.side === Side.LEFT);
+    toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
+  }
+
+  _isActionType(type: GrDiffRowType) {
+    return (
+      type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
+    );
+  }
+
+  _getActionsForRow() {
+    const actions = {left: false, right: false};
+    if (this.diffRow) {
+      actions.left = this._isActionType(
+        this.diffRow.getAttribute('left-type') as GrDiffRowType
+      );
+      actions.right = this._isActionType(
+        this.diffRow.getAttribute('right-type') as GrDiffRowType
+      );
+    }
+    return actions;
+  }
+
+  _updateStops() {
+    this.cursorManager.stops = this.diffs.reduce(
+      (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
+      []
+    );
+  }
+
+  replaceDiffs(diffs: GrDiffCursorable[]) {
+    for (const diff of this.diffs) {
+      this.removeEventListeners(diff);
+    }
+    this.diffs = [];
+    for (const diff of diffs) {
+      this.addEventListeners(diff);
+    }
+    this.diffs.push(...diffs);
+    this._updateStops();
+  }
+
+  unregisterDiff(diff: GrDiffCursorable) {
+    // This can happen during destruction - just don't unregister then.
+    if (!this.diffs) return;
+    const i = this.diffs.indexOf(diff);
+    if (i !== -1) {
+      this.diffs.splice(i, 1);
+    }
+  }
+
+  private removeEventListeners(diff: GrDiffCursorable) {
+    diff.removeEventListener(
+      'loading-changed',
+      this.boundHandleDiffLoadingChanged
+    );
+    diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.removeEventListener(
+      'render-content',
+      this._boundHandleDiffRenderContent
+    );
+    diff.removeEventListener(
+      'line-selected',
+      this._boundHandleDiffLineSelected
+    );
+  }
+
+  private addEventListeners(diff: GrDiffCursorable) {
+    diff.addEventListener(
+      'loading-changed',
+      this.boundHandleDiffLoadingChanged
+    );
+    diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
+    diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
+  }
+
+  _findRowByNumberAndFile(
+    targetNumber: number,
+    side: Side,
+    path?: string
+  ): HTMLElement | undefined {
+    let stops: Array<HTMLElement | AbortStop>;
+    if (path) {
+      const diff = this.diffs.filter(diff => diff.path === path)[0];
+      stops = diff.getCursorStops();
+    } else {
+      stops = this.cursorManager.stops;
+    }
+    // Sadly needed for type narrowing to understand that the result is always
+    // targetable.
+    const targetableStops: HTMLElement[] = stops.filter(isTargetable);
+    const selector = `.lineNum.${side}[data-value="${targetNumber}"]`;
+    return targetableStops.find(stop => stop.querySelector(selector));
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
new file mode 100644
index 0000000..b9db280
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -0,0 +1,694 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../gr-diff/gr-diff';
+import './gr-diff-cursor';
+import {fixture, html, assert} from '@open-wc/testing';
+import {
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
+import {createDiff} from '../../../test/test-data-generators';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffCursor} from './gr-diff-cursor';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {DiffInfo, DiffViewMode, Side} from '../../../api/diff';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {assertIsDefined} from '../../../utils/common-util';
+
+suite('gr-diff-cursor tests', () => {
+  let cursor: GrDiffCursor;
+  let diffElement: GrDiff;
+  let diff: DiffInfo;
+
+  setup(async () => {
+    diffElement = await fixture(html`<gr-diff></gr-diff>`);
+    cursor = new GrDiffCursor();
+
+    // Register the diff with the cursor.
+    cursor.replaceDiffs([diffElement]);
+
+    diffElement.loggedIn = false;
+    diffElement.path = 'some/path.ts';
+    const promise = mockPromise();
+    const setupDone = () => {
+      cursor._updateStops();
+      cursor.moveToFirstChunk();
+      diffElement.removeEventListener('render', setupDone);
+      promise.resolve();
+    };
+    diffElement.addEventListener('render', setupDone);
+
+    diff = createDiff();
+    diffElement.prefs = createDefaultDiffPrefs();
+    diffElement.renderPrefs = {use_lit_components: true};
+    diffElement.diff = diff;
+    await promise;
+  });
+
+  test('diff cursor functionality (side-by-side)', () => {
+    assert.isOk(cursor.diffRow);
+
+    const deltaRows = queryAll<HTMLTableRowElement>(
+      diffElement,
+      '.section.delta tr.diff-row'
+    );
+    assert.equal(cursor.diffRow, deltaRows[0]);
+
+    cursor.moveDown();
+
+    assert.notEqual(cursor.diffRow, deltaRows[0]);
+    assert.equal(cursor.diffRow, deltaRows[1]);
+
+    cursor.moveUp();
+
+    assert.notEqual(cursor.diffRow, deltaRows[1]);
+    assert.equal(cursor.diffRow, deltaRows[0]);
+  });
+
+  test('moveToFirstChunk', async () => {
+    const diff: DiffInfo = {
+      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 waitForEventOnce(diffElement, 'render');
+
+    cursor._updateStops();
+
+    const chunks = [
+      ...queryAll(diffElement, '.section.delta'),
+    ] as HTMLElement[];
+    assert.equal(chunks.length, 2);
+
+    const rows = [
+      ...queryAll(diffElement, '.section.delta tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(rows.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[0]);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToNextChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[1]);
+    assert.equal(cursor.side, Side.LEFT);
+
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[0]);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('moveToLastChunk', async () => {
+    const diff: DiffInfo = {
+      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 waitForEventOnce(diffElement, 'render');
+    cursor._updateStops();
+
+    const chunks = [
+      ...queryAll(diffElement, '.section.delta'),
+    ] as HTMLElement[];
+    assert.equal(chunks.length, 2);
+
+    const rows = [
+      ...queryAll(diffElement, '.section.delta tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(rows.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToLastChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[1]);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToPreviousChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[0]);
+    assert.equal(cursor.side, Side.LEFT);
+
+    cursor.moveToLastChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[1]);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('cursor scroll behavior', () => {
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+
+    diffElement.dispatchEvent(new Event('render-start'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    window.dispatchEvent(new Event('scroll'));
+    assert.equal(cursor.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursor.cursorManager.focusOnMove);
+
+    diffElement.dispatchEvent(new Event('render-content'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    cursor.reInitCursor();
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('moves to selected line', () => {
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+
+    diffElement.dispatchEvent(
+      new CustomEvent('line-selected', {
+        detail: {number: '123', side: Side.RIGHT, path: 'some/file'},
+      })
+    );
+
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 123);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
+  });
+
+  suite('unified diff', () => {
+    setup(async () => {
+      diffElement.viewMode = DiffViewMode.UNIFIED;
+      await waitForEventOnce(diffElement, 'render');
+      cursor.reInitCursor();
+    });
+
+    test('diff cursor functionality (unified)', () => {
+      assert.isOk(cursor.diffRow);
+
+      const rows = [
+        ...queryAll(diffElement, '.section.delta tr.diff-row'),
+      ] as HTMLTableRowElement[];
+      assert.equal(cursor.diffRow, rows[0]);
+
+      cursor.moveDown();
+
+      assert.notEqual(cursor.diffRow, rows[0]);
+      assert.equal(cursor.diffRow, rows[1]);
+
+      cursor.moveUp();
+
+      assert.notEqual(cursor.diffRow, rows[1]);
+      assert.equal(cursor.diffRow, rows[0]);
+    });
+  });
+
+  test('cursor side functionality', () => {
+    // The side only applies to side-by-side mode, which should be the default
+    // mode.
+    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+    const rows = [
+      ...queryAll(diffElement, '.section tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(rows.length, 50);
+    const deltaRows = [
+      ...queryAll(diffElement, '.section.delta tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(deltaRows.length, 14);
+    const indexFirstDelta = rows.indexOf(deltaRows[0]);
+    const rowBeforeFirstDelta = rows[indexFirstDelta - 1];
+
+    // Because the first delta in this diff is on the right, it should be set
+    // to the right side.
+    assert.equal(cursor.side, Side.RIGHT);
+    assert.equal(cursor.diffRow, deltaRows[0]);
+    const firstIndex = cursor.cursorManager.index;
+
+    // Move the side to the left. Because this delta only has a right side, we
+    // should be moved up to the previous line where there is content on the
+    // right. The previous row is part of the previous section.
+    cursor.moveLeft();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, rows[0]);
+    assert.equal(cursor.diffRow, rowBeforeFirstDelta);
+    assert.equal(cursor.cursorManager.index, firstIndex - 1);
+
+    // If we move down, we should skip everything in the first delta because
+    // we are on the left side and the first delta has no content on the left.
+    cursor.moveDown();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, rowBeforeFirstDelta);
+    assert.notEqual(cursor.diffRow, rows[0]);
+    assert.isTrue(cursor.cursorManager.index > firstIndex);
+  });
+
+  test('chunk skip functionality', () => {
+    const deltaChunks = [...queryAll(diffElement, 'tbody.section.delta')];
+
+    // We should be initialized to the first chunk. Since this chunk only has
+    // content on the right side, our side should be right.
+    assert.equal(cursor.diffRow, deltaChunks[0].querySelector('tr'));
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Move to the next chunk.
+    cursor.moveToNextChunk();
+
+    // Since this chunk only has content on the left side. we should have been
+    // automatically moved over.
+    assert.equal(cursor.diffRow, deltaChunks[1].querySelector('tr'));
+    assert.equal(cursor.side, Side.LEFT);
+  });
+
+  suite('moved chunks without line range)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      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},
+          },
+          {
+            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},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
+      ];
+      assert.include(movedIn.innerText, 'Moved in');
+      assert.include(movedOut.innerText, 'Moved out');
+    });
+  });
+
+  suite('moved chunks (moveDetails)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      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.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
+      ];
+      assert.include(movedIn.innerText, 'Moved from lines 4 - 6');
+      assert.include(movedOut.innerText, 'Moved to lines 2 - 4');
+    });
+
+    test('startLineAnchor of movedIn chunk fires events', async () => {
+      const [movedIn] = [...queryAll(diffElement, '.dueToMove .moveControls')];
+      const [startLineAnchor] = movedIn.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.LEFT});
+        promise.resolve();
+      };
+      assert.equal(startLineAnchor.textContent, '4');
+      startLineAnchor.addEventListener(
+        'moved-link-clicked',
+        onMovedLinkClicked
+      );
+      startLineAnchor.click();
+      await promise;
+    });
+
+    test('endLineAnchor of movedOut fires events', async () => {
+      const [, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      const [, endLineAnchor] = movedOut.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.RIGHT});
+        promise.resolve();
+      };
+      assert.equal(endLineAnchor.textContent, '4');
+      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
+      endLineAnchor.click();
+      await promise;
+    });
+  });
+
+  test('initialLineNumber not provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+    const moveToChunkStub = sinon
+      .stub(cursor, 'moveToFirstChunk')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+    diffElement.diff = createDiff();
+    await diffElement.updateComplete;
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToNumStub.called);
+    assert.isTrue(moveToChunkStub.called);
+    assert.equal(scrollBehaviorDuringMove, 'never');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('initialLineNumber provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon
+      .stub(cursor, 'moveToLineNumber')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
+    cursor.initialLineNumber = 10;
+    cursor.side = Side.RIGHT;
+
+    diffElement.diff = createDiff();
+    await diffElement.updateComplete;
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToChunkStub.called);
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 10);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('getTargetDiffElement', () => {
+    cursor.initialLineNumber = 1;
+    assert.isTrue(!!cursor.diffRow);
+    assert.equal(cursor.getTargetDiffElement(), diffElement);
+  });
+
+  suite('createCommentInPlace', () => {
+    setup(() => {
+      diffElement.loggedIn = true;
+    });
+
+    test('adds new draft for selected line on the left', async () => {
+      cursor.moveToLineNumber(2, Side.LEFT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 2);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.LEFT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('adds draft for selected line on the right', async () => {
+      cursor.moveToLineNumber(4, Side.RIGHT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 4);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('creates comment for range if selected', async () => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
+      };
+      diffElement.highlights.selectedRange = {
+        side: Side.RIGHT,
+        range: someRange,
+      };
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 6);
+        assert.equal(range, someRange);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('ignores call if nothing is selected', () => {
+      const createRangeCommentStub = sinon.stub(
+        diffElement,
+        'createRangeComment'
+      );
+      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
+      cursor.diffRow = undefined;
+      cursor.createCommentInPlace();
+      assert.isFalse(createRangeCommentStub.called);
+      assert.isFalse(addDraftAtLineStub.called);
+    });
+  });
+
+  test('getAddress', () => {
+    // It should initialize to the first chunk: line 5 of the revision.
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // Revision line 4 is up.
+    cursor.moveUp();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 4});
+
+    // Base line 4 is left.
+    cursor.moveLeft();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
+
+    // Moving to the next chunk takes it back to the start.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // The following chunk is a removal starting on line 10 of the base.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 10});
+
+    // Should be null if there is no selection.
+    cursor.cursorManager.unsetCursor();
+    assert.isNotOk(cursor.getAddress());
+  });
+
+  test('_findRowByNumberAndFile', () => {
+    // Get the first ab row after the first chunk.
+    const rows = [...queryAll<HTMLTableRowElement>(diffElement, 'tr')];
+    const row = rows[9];
+    assert.ok(row);
+
+    // It should be line 8 on the right, but line 5 on the left.
+    assert.equal(cursor._findRowByNumberAndFile(8, Side.RIGHT), row);
+    assert.equal(cursor._findRowByNumberAndFile(5, Side.LEFT), row);
+  });
+
+  test('expand context updates stops', async () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    const controls = queryAndAssert(diffElement, 'gr-context-controls');
+    const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
+    showContext.click();
+    await waitForEventOnce(diffElement, 'render');
+    await waitUntil(() => spy.called);
+    assert.isTrue(spy.called);
+  });
+
+  test('updates stops when loading changes', () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    diffElement.dispatchEvent(new Event('loading-changed'));
+    assert.isTrue(spy.called);
+  });
+
+  suite('multi diff', () => {
+    let diffElements: GrDiff[];
+
+    setup(async () => {
+      diffElements = [
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+      ];
+      cursor = new GrDiffCursor();
+
+      // Register the diff with the cursor.
+      cursor.replaceDiffs(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.
+      const target = cursor.getTargetDiffElement();
+      assertIsDefined(target);
+      return diffElements.indexOf(target);
+    }
+
+    test('do not skip loading diffs', async () => {
+      diffElements[0].diff = createDiff();
+      diffElements[2].diff = createDiff();
+      await waitForEventOnce(diffElements[0], 'render');
+      await waitForEventOnce(diffElements[2], 'render');
+
+      const lastLine = diffElements[0].diff.meta_b?.lines;
+      assertIsDefined(lastLine);
+
+      // Goto second last line of the first diff
+      cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        `${lastLine - 1}`
+      );
+
+      // Can move down until we reach the loading file
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Cannot move down while still loading the diff we would switch to
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent,
+        lastLine.toString()
+      );
+
+      // Diff 1 finishing to load
+      diffElements[1].diff = createDiff();
+      await waitForEventOnce(diffElements[1], 'render');
+
+      // Now we can go down
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 1);
+      assert.equal(cursor.getTargetLineElement()!.textContent, 'File');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
new file mode 100644
index 0000000..38bd707
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
@@ -0,0 +1,284 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings';
+
+// TODO(wyatta): refactor this to be <MARK> rather than <HL>.
+const ANNOTATION_TAG = 'HL';
+
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+export const GrAnnotation = {
+  /**
+   * The DOM API textContent.length calculation is broken when the text
+   * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+   *
+   */
+  getLength(node: Node) {
+    if (node instanceof Comment) return 0;
+    return this.getStringLength(node.textContent || '');
+  },
+
+  /**
+   * Returns the number of Unicode code points in the given string
+   *
+   * This is not necessarily the same as the number of visible symbols.
+   * See https://mathiasbynens.be/notes/javascript-unicode for more details.
+   */
+  getStringLength(str: string) {
+    return [...str].length;
+  },
+
+  /**
+   * Annotates the [offset, offset+length) text segment in the parent with the
+   * element definition provided as arguments.
+   *
+   * @param parent the node whose contents will be annotated.
+   * If parent is Text then parent.parentNode must not be null
+   * @param offset the 0-based offset from which the annotation will
+   * start.
+   * @param length of the annotated text.
+   * @param elementSpec the spec to create the
+   * annotating element.
+   */
+  annotateWithElement(
+    parent: Node,
+    offset: number,
+    length: number,
+    elSpec: ElementSpec
+  ) {
+    const tagName = elSpec.tagName;
+    const attributes = elSpec.attributes || {};
+    let childNodes: Node[];
+
+    if (parent instanceof Element) {
+      childNodes = Array.from(parent.childNodes);
+    } else if (parent instanceof Text) {
+      childNodes = [parent];
+      parent = parent.parentNode!;
+    } else {
+      return;
+    }
+
+    const nestedNodes: Node[] = [];
+    for (let node of childNodes) {
+      const initialNodeLength = this.getLength(node);
+      // If the current node is completely before the offset.
+      if (offset > 0 && initialNodeLength <= offset) {
+        offset -= initialNodeLength;
+        continue;
+      }
+
+      if (offset > 0) {
+        node = this.splitNode(node, offset);
+        offset = 0;
+      }
+      if (this.getLength(node) > length) {
+        this.splitNode(node, length);
+      }
+      nestedNodes.push(node);
+
+      length -= this.getLength(node);
+      if (!length) break;
+    }
+
+    const wrapper = document.createElement(tagName);
+    const sanitizer = getSanitizeDOMValue();
+    for (let [name, value] of Object.entries(attributes)) {
+      if (!value) continue;
+      if (sanitizer) {
+        value = sanitizer(value, name, 'attribute', wrapper) as string;
+      }
+      wrapper.setAttribute(name, value);
+    }
+    for (const inner of nestedNodes) {
+      parent.replaceChild(wrapper, inner);
+      wrapper.appendChild(inner);
+    }
+  },
+
+  /**
+   * Surrounds the element's text at specified range in an ANNOTATION_TAG
+   * element. If the element has child elements, the range is split and
+   * applied as deeply as possible.
+   */
+  annotateElement(
+    parent: HTMLElement,
+    offset: number,
+    length: number,
+    cssClass: string
+  ) {
+    const nodes: Array<HTMLElement | Text> = [].slice.apply(parent.childNodes);
+    let nodeLength;
+    let subLength;
+
+    for (const node of nodes) {
+      nodeLength = this.getLength(node);
+
+      // If the current node is completely before the offset.
+      if (nodeLength <= offset) {
+        offset -= nodeLength;
+        continue;
+      }
+
+      // Sublength is the annotation length for the current node.
+      subLength = Math.min(length, nodeLength - offset);
+
+      if (node instanceof Text) {
+        this._annotateText(node, offset, subLength, cssClass);
+      } else if (node instanceof Element) {
+        this.annotateElement(node, offset, subLength, cssClass);
+      }
+
+      // If there is still more to annotate, then shift the indices, otherwise
+      // work is done, so break the loop.
+      if (subLength < length) {
+        length -= subLength;
+        offset = 0;
+      } else {
+        break;
+      }
+    }
+  },
+
+  /**
+   * Wraps node in annotation tag with cssClass, replacing the node in DOM.
+   */
+  wrapInHighlight(node: Element | Text, cssClass: string) {
+    let hl;
+    if (!(node instanceof Text) && node.tagName === ANNOTATION_TAG) {
+      hl = node;
+      hl.classList.add(cssClass);
+    } else {
+      hl = document.createElement(ANNOTATION_TAG);
+      hl.className = cssClass;
+      if (node.parentElement) node.parentElement.replaceChild(hl, node);
+      hl.appendChild(node);
+    }
+    return hl;
+  },
+
+  /**
+   * Splits Text Node and wraps it in hl with cssClass.
+   * Wraps trailing part after split, tailing one if firstPart is true.
+   */
+  splitAndWrapInHighlight(
+    node: Text,
+    offset: number,
+    cssClass: string,
+    firstPart?: boolean
+  ) {
+    if (
+      (this.getLength(node) === offset && firstPart) ||
+      (offset === 0 && !firstPart)
+    ) {
+      return this.wrapInHighlight(node, cssClass);
+    }
+    if (firstPart) {
+      this.splitNode(node, offset);
+      // Node points to first part of the Text, second one is sibling.
+    } else {
+      // if node is Text then splitNode will return a Text
+      node = this.splitNode(node, offset) as Text;
+    }
+    return this.wrapInHighlight(node, cssClass);
+  },
+
+  /**
+   * Splits Node at offset.
+   * If Node is Element, it's cloned and the node at offset is split too.
+   */
+  splitNode(element: Node, offset: number) {
+    if (element instanceof Text) {
+      return this.splitTextNode(element, offset);
+    }
+    const tail = element.cloneNode(false);
+
+    if (element.parentElement)
+      element.parentElement.insertBefore(tail, element.nextSibling);
+    // Skip nodes before offset.
+    let node = element.firstChild;
+    while (
+      node &&
+      (this.getLength(node) <= offset || this.getLength(node) === 0)
+    ) {
+      offset -= this.getLength(node);
+      node = node.nextSibling;
+    }
+    if (node && this.getLength(node) > offset) {
+      tail.appendChild(this.splitNode(node, offset));
+    }
+    while (node && node.nextSibling) {
+      tail.appendChild(node.nextSibling);
+    }
+    return tail;
+  },
+
+  /**
+   * Node.prototype.splitText Unicode-valid alternative.
+   *
+   * DOM Api for splitText() is broken for Unicode:
+   * https://mathiasbynens.be/notes/javascript-unicode
+   *
+   * @return Trailing Text Node.
+   */
+  splitTextNode(node: Text, offset: number) {
+    if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
+      const head = Array.from(node.textContent);
+      const tail = head.splice(offset);
+      const parent = node.parentNode;
+
+      // Split the content of the original node.
+      node.textContent = head.join('');
+
+      const tailNode = document.createTextNode(tail.join(''));
+      if (parent) {
+        parent.insertBefore(tailNode, node.nextSibling);
+      }
+      return tailNode;
+    } else {
+      return node.splitText(offset);
+    }
+  },
+
+  _annotateText(node: Text, offset: number, length: number, cssClass: string) {
+    const nodeLength = this.getLength(node);
+
+    // There are four cases:
+    //  1) Entire node is highlighted.
+    //  2) Highlight is at the start.
+    //  3) Highlight is at the end.
+    //  4) Highlight is in the middle.
+
+    if (offset === 0 && nodeLength === length) {
+      // Case 1.
+      this.wrapInHighlight(node, cssClass);
+    } else if (offset === 0) {
+      // Case 2.
+      this.splitAndWrapInHighlight(node, length, cssClass, true);
+    } else if (offset + length === nodeLength) {
+      // Case 3
+      this.splitAndWrapInHighlight(node, offset, cssClass, false);
+    } else {
+      // Case 4
+      this.splitAndWrapInHighlight(
+        this.splitTextNode(node, offset),
+        length,
+        cssClass,
+        true
+      );
+    }
+  },
+};
+
+/**
+ * Data used to construct an element.
+ *
+ */
+export interface ElementSpec {
+  tagName: string;
+  attributes?: {[attributeName: string]: string | undefined};
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
new file mode 100644
index 0000000..f319a3c
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
@@ -0,0 +1,308 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../../test/common-test-setup';
+import {GrAnnotation} from './gr-annotation';
+import {
+  getSanitizeDOMValue,
+  setSanitizeDOMValue,
+} from '@polymer/polymer/lib/utils/settings';
+import {assert, fixture, html} from '@open-wc/testing';
+
+suite('annotation', () => {
+  let str: string;
+  let parent: HTMLDivElement;
+  let textNode: Text;
+
+  setup(async () => {
+    parent = await fixture(
+      html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `
+    );
+    textNode = parent.childNodes[0] as Text;
+    str = textNode.textContent!;
+  });
+
+  test('_annotateText length:0 offset:0', () => {
+    GrAnnotation._annotateText(textNode, 0, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar"></hl>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText length:0 offset:1', () => {
+    GrAnnotation._annotateText(textNode, 1, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'L<hl class="foobar"></hl>orem ipsum dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText length:0 offset:str.length', () => {
+    GrAnnotation._annotateText(textNode, str.length, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula<hl class="foobar"></hl>'
+    );
+  });
+
+  test('_annotateText Case 1', () => {
+    GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar">Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</hl>'
+    );
+  });
+
+  test('_annotateText Case 2', () => {
+    GrAnnotation._annotateText(textNode, 0, 12, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar">Lorem ipsum </hl>dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText Case 3', () => {
+    GrAnnotation._annotateText(textNode, 12, str.length - 12, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum <hl class="foobar">dolor sit amet, suspendisse inceptos vehicula</hl>'
+    );
+  });
+
+  test('_annotateText Case 4', () => {
+    const index = str.indexOf('dolor');
+    const length = 'dolor '.length;
+
+    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum <hl class="foobar">dolor </hl>sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateElement design doc example', () => {
+    const layers = ['amet, ', 'inceptos ', 'amet, ', 'et, suspendisse ince'];
+
+    // Apply the layers successively.
+    layers.forEach((layer, i) => {
+      GrAnnotation.annotateElement(
+        parent,
+        str.indexOf(layer),
+        layer.length,
+        `layer-${i + 1}`
+      );
+    });
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum dolor sit <hl class="layer-1"><hl class="layer-3">am<hl class="layer-4">et, </hl></hl></hl><hl class="layer-4">suspendisse </hl><hl class="layer-2"><hl class="layer-4">ince</hl>ptos </hl>vehicula'
+    );
+  });
+
+  test('splitTextNode', () => {
+    const helloString = 'hello';
+    const asciiString = 'ASCII';
+    const unicodeString = 'Unic💢de';
+
+    let node;
+    let tail;
+
+    // Non-unicode path:
+    node = document.createTextNode(helloString + asciiString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, asciiString);
+
+    // Unicdoe path:
+    node = document.createTextNode(helloString + unicodeString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, unicodeString);
+  });
+
+  suite('annotateWithElement', () => {
+    const fullText = '01234567890123456789';
+    let mockSanitize: sinon.SinonSpy;
+    let originalSanitizeDOMValue: (
+      p0: any,
+      p1: string,
+      p2: string,
+      p3: Node | null
+    ) => any;
+
+    setup(() => {
+      setSanitizeDOMValue(p0 => p0);
+      originalSanitizeDOMValue = getSanitizeDOMValue()!;
+      assert.isDefined(originalSanitizeDOMValue);
+      mockSanitize = sinon.spy(originalSanitizeDOMValue);
+      setSanitizeDOMValue(mockSanitize);
+    });
+
+    teardown(() => {
+      setSanitizeDOMValue(originalSanitizeDOMValue);
+    });
+
+    test('annotates when fully contained', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0<test-wrapper>1234567890</test-wrapper>123456789'
+      );
+    });
+
+    test('annotates when spanning multiple nodes', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateElement(container, 5, length, 'testclass');
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0' +
+          '<test-wrapper>' +
+          '1234' +
+          '<hl class="testclass">567890</hl>' +
+          '</test-wrapper>' +
+          '<hl class="testclass">1234</hl>' +
+          '56789'
+      );
+    });
+
+    test('annotates text node', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(container.childNodes[0], 1, length, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0<test-wrapper>1234567890</test-wrapper>123456789'
+      );
+    });
+
+    test('handles zero-length nodes', () => {
+      const container = document.createElement('div');
+      container.appendChild(document.createTextNode('0123456789'));
+      container.appendChild(document.createElement('span'));
+      container.appendChild(document.createTextNode('0123456789'));
+      GrAnnotation.annotateWithElement(container, 1, 10, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789'
+      );
+    });
+
+    test('handles comment nodes', () => {
+      const container = document.createElement('div');
+      container.appendChild(document.createComment('comment1'));
+      container.appendChild(document.createTextNode('0123456789'));
+      container.appendChild(document.createComment('comment2'));
+      container.appendChild(document.createElement('span'));
+      container.appendChild(document.createTextNode('0123456789'));
+      GrAnnotation.annotateWithElement(container, 1, 10, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '<!--comment1-->' +
+          '0<test-wrapper>123456789' +
+          '<!--comment2-->' +
+          '<span></span>0</test-wrapper>123456789'
+      );
+    });
+
+    test('sets sanitized attributes', () => {
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      const attributes = {
+        href: 'foo',
+        'data-foo': 'bar',
+        class: 'hello world',
+      };
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+        attributes,
+      });
+      assert(
+        mockSanitize.calledWith(
+          'foo',
+          'href',
+          'attribute',
+          sinon.match.instanceOf(Element)
+        )
+      );
+      assert(
+        mockSanitize.calledWith(
+          'bar',
+          'data-foo',
+          'attribute',
+          sinon.match.instanceOf(Element)
+        )
+      );
+      assert(
+        mockSanitize.calledWith(
+          'hello world',
+          'class',
+          'attribute',
+          sinon.match.instanceOf(Element)
+        )
+      );
+      const el = container.querySelector('test-wrapper')!;
+      assert.equal(el.getAttribute('href'), 'foo');
+      assert.equal(el.getAttribute('data-foo'), 'bar');
+      assert.equal(el.getAttribute('class'), 'hello world');
+    });
+  });
+
+  suite('getStringLength', () => {
+    test('ASCII characters are counted correctly', () => {
+      assert.equal(GrAnnotation.getStringLength('ASCII'), 5);
+    });
+
+    test('Unicode surrogate pairs count as one symbol', () => {
+      assert.equal(GrAnnotation.getStringLength('Unic💢de'), 7);
+      assert.equal(GrAnnotation.getStringLength('💢💢'), 2);
+    });
+
+    test('Grapheme clusters count as multiple symbols', () => {
+      assert.equal(GrAnnotation.getStringLength('man\u0303ana'), 7); // mañana
+      assert.equal(GrAnnotation.getStringLength('q\u0307\u0323'), 3); // q̣̇
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
new file mode 100644
index 0000000..0714645
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -0,0 +1,529 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import '../gr-selection-action-box/gr-selection-action-box';
+import {GrAnnotation} from './gr-annotation';
+import {normalize} from './gr-range-normalizer';
+import {strToClassName} from '../../../utils/dom-util';
+import {Side} from '../../../constants/constants';
+import {CommentRange} from '../../../types/common';
+import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+import {FILE} from '../gr-diff/gr-diff-line';
+import {
+  getLineElByChild,
+  getLineNumberByChild,
+  getSideByLineEl,
+  GrDiffThreadElement,
+} from '../gr-diff/gr-diff-utils';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+
+interface SidedRange {
+  side: Side;
+  range: CommentRange;
+}
+
+interface NormalizedPosition {
+  node: Node | null;
+  side: Side;
+  line: number;
+  column: number;
+}
+
+interface NormalizedRange {
+  start: NormalizedPosition | null;
+  end: NormalizedPosition | null;
+}
+
+/**
+ * The methods that we actually want to call on the builder. We don't want a
+ * fully blown dependency on GrDiffBuilderElement.
+ */
+export interface DiffBuilderInterface {
+  getContentTdByLineEl(lineEl?: Element): Element | null;
+}
+
+/**
+ * Handles showing, positioning and interacting with <gr-selection-action-box>.
+ *
+ * Toggles a css class for highlighting comment ranges when the mouse leaves or
+ * enters a comment thread element.
+ */
+export class GrDiffHighlight {
+  selectedRange?: SidedRange;
+
+  private diffBuilder?: DiffBuilderInterface;
+
+  private diffTable?: HTMLElement;
+
+  private selectionChangeTask?: DelayedTask;
+
+  init(diffTable: HTMLElement, diffBuilder: DiffBuilderInterface) {
+    this.cleanup();
+
+    this.diffTable = diffTable;
+    this.diffBuilder = diffBuilder;
+
+    diffTable.addEventListener(
+      'comment-thread-mouseleave',
+      this.handleCommentThreadMouseleave
+    );
+    diffTable.addEventListener(
+      'comment-thread-mouseenter',
+      this.handleCommentThreadMouseenter
+    );
+    diffTable.addEventListener(
+      'create-comment-requested',
+      this.handleRangeCommentRequest
+    );
+  }
+
+  cleanup() {
+    this.selectionChangeTask?.cancel();
+    if (this.diffTable) {
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseleave',
+        this.handleCommentThreadMouseleave
+      );
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseenter',
+        this.handleCommentThreadMouseenter
+      );
+      this.diffTable.removeEventListener(
+        'create-comment-requested',
+        this.handleRangeCommentRequest
+      );
+    }
+  }
+
+  /**
+   * Determines side/line/range for a DOM selection and shows a tooltip.
+   *
+   * With native shadow DOM, gr-diff-highlight cannot access a selection that
+   * references the DOM elements making up the diff because they are in the
+   * shadow DOM the gr-diff element. For this reason, we listen to the
+   * selectionchange event and retrieve the selection in gr-diff, and then
+   * call this method to process the Selection.
+   *
+   * @param selection A DOM Selection living in the shadow DOM of
+   * the diff element.
+   * @param isMouseUp If true, this is called due to a mouseup
+   * event, in which case we might want to immediately create a comment,
+   * because isMouseUp === true combined with an existing selection must
+   * mean that this is the end of a double-click.
+   */
+  handleSelectionChange(
+    selection: Selection | Range | null,
+    isMouseUp: boolean
+  ) {
+    if (selection === null) return;
+    // Debounce is not just nice for waiting until the selection has settled,
+    // it is also vital for being able to click on the action box before it is
+    // removed.
+    // If you wait longer than 50 ms, then you don't properly catch a very
+    // 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.selectionChangeTask = debounce(
+      this.selectionChangeTask,
+      () => this.handleSelection(selection, isMouseUp),
+      10
+    );
+  }
+
+  private getThreadEl(e: Event): GrDiffThreadElement | null {
+    for (const pathEl of e.composedPath()) {
+      if (
+        pathEl instanceof HTMLElement &&
+        pathEl.classList.contains('comment-thread')
+      ) {
+        return pathEl as GrDiffThreadElement;
+      }
+    }
+    return null;
+  }
+
+  private toggleRangeElHighlight(
+    threadEl: GrDiffThreadElement | null,
+    highlightRange = false
+  ) {
+    const rootId = threadEl?.rootId;
+    if (!rootId) return;
+    if (!this.diffTable) return;
+    if (highlightRange) {
+      const selector = `.range.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.add('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+      );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.add('rangeHoverHighlight')
+        );
+    } else {
+      const selector = `.rangeHoverHighlight.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.remove('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+      );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHoverHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.remove('rangeHoverHighlight')
+        );
+    }
+  }
+
+  private handleCommentThreadMouseenter = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+  };
+
+  private handleCommentThreadMouseleave = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+  };
+
+  /**
+   * Get current normalized selection.
+   * Merges multiple ranges, accounts for triple click, accounts for
+   * syntax highligh, convert native DOM Range objects to Gerrit concepts
+   * (line, side, etc).
+   */
+  private getNormalizedRange(selection: Selection | Range) {
+    /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
+       we can get is a single Range */
+    if (selection instanceof Range) {
+      return this.normalizeRange(selection);
+    }
+    const rangeCount = selection.rangeCount;
+    if (rangeCount === 0) {
+      return null;
+    } else if (rangeCount === 1) {
+      return this.normalizeRange(selection.getRangeAt(0));
+    } else {
+      const startRange = this.normalizeRange(selection.getRangeAt(0));
+      const endRange = this.normalizeRange(
+        selection.getRangeAt(rangeCount - 1)
+      );
+      return {
+        start: startRange.start,
+        end: endRange.end,
+      };
+    }
+  }
+
+  /**
+   * Normalize a specific DOM Range.
+   *
+   * @return fixed normalized range
+   */
+  private normalizeRange(domRange: Range): NormalizedRange {
+    const range = normalize(domRange);
+    return this.fixTripleClickSelection(
+      {
+        start: this.normalizeSelectionSide(
+          range.startContainer,
+          range.startOffset
+        ),
+        end: this.normalizeSelectionSide(range.endContainer, range.endOffset),
+      },
+      domRange
+    );
+  }
+
+  /**
+   * Adjust triple click selection for the whole line.
+   * A triple click always results in:
+   * - start.column == end.column == 0
+   * - end.line == start.line + 1
+   *
+   * @param range Normalized range, ie column/line numbers
+   * @param domRange DOM Range object
+   * @return fixed normalized range
+   */
+  private fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+    if (!range.start) {
+      // Selection outside of current diff.
+      return range;
+    }
+    const start = range.start;
+    const end = range.end;
+    // Happens when triple click in side-by-side mode with other side empty.
+    const endsAtOtherEmptySide =
+      !end &&
+      domRange.endOffset === 0 &&
+      domRange.endContainer instanceof HTMLElement &&
+      domRange.endContainer.nodeName === 'TD' &&
+      (domRange.endContainer.classList.contains('left') ||
+        domRange.endContainer.classList.contains('right'));
+    const endsAtBeginningOfNextLine =
+      end &&
+      start.column === 0 &&
+      end.column === 0 &&
+      end.line === start.line + 1;
+    const content = domRange.cloneContents().querySelector('.contentText');
+    const lineLength = (content && this.getLength(content)) || 0;
+    if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
+      // Move the selection to the end of the previous line.
+      range.end = {
+        node: start.node,
+        column: lineLength,
+        side: start.side,
+        line: start.line,
+      };
+    }
+    return range;
+  }
+
+  /**
+   * Convert DOM Range selection to concrete numbers (line, column, side).
+   * Moves range end if it's not inside td.content.
+   * Returns null if selection end is not valid (outside of diff).
+   *
+   * @param node td.content child
+   * @param offset offset within node
+   */
+  private normalizeSelectionSide(
+    node: Node | null,
+    offset: number
+  ): NormalizedPosition | null {
+    let column;
+    if (!this.diffTable) return null;
+    if (!this.diffBuilder) return null;
+    if (!node || !this.diffTable.contains(node)) return null;
+    const lineEl = getLineElByChild(node);
+    if (!lineEl) return null;
+    const side = getSideByLineEl(lineEl);
+    if (!side) return null;
+    const line = getLineNumberByChild(lineEl);
+    if (!line || line === FILE || line === 'LOST') return null;
+    const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
+    if (!contentTd) return null;
+    const contentText = contentTd.querySelector('.contentText');
+    if (!contentTd.contains(node)) {
+      node = contentText;
+      column = 0;
+    } else {
+      const thread = contentTd.querySelector('.comment-thread');
+      if (thread?.contains(node)) {
+        column = this.getLength(contentText);
+        node = contentText;
+      } else {
+        column = this.convertOffsetToColumn(node, offset);
+      }
+    }
+
+    return {
+      node,
+      side,
+      line,
+      column,
+    };
+  }
+
+  /**
+   * The only line in which add a comment tooltip is cut off is the first
+   * line. Even if there is a collapsed section, The first visible line is
+   * in the position where the second line would have been, if not for the
+   * collapsed section, so don't need to worry about this case for
+   * positioning the tooltip.
+   */
+  // visible for testing
+  positionActionBox(
+    actionBox: GrSelectionActionBox,
+    startLine: number,
+    range: Text | Element | Range
+  ) {
+    if (startLine > 1) {
+      actionBox.positionBelow = false;
+      actionBox.placeAbove(range);
+      return;
+    }
+    actionBox.positionBelow = true;
+    actionBox.placeBelow(range);
+  }
+
+  private isRangeValid(range: NormalizedRange | null) {
+    if (!range || !range.start || !range.start.node || !range.end) {
+      return false;
+    }
+    const start = range.start;
+    const end = range.end;
+    return !(
+      start.side !== end.side ||
+      end.line < start.line ||
+      (start.line === end.line && start.column === end.column)
+    );
+  }
+
+  // visible for testing
+  handleSelection(selection: Selection | Range, isMouseUp: boolean) {
+    /* On Safari, the selection events may return a null range that should
+       be ignored */
+    if (!selection) return;
+    if (!this.diffTable) return;
+
+    const normalizedRange = this.getNormalizedRange(selection);
+    if (!this.isRangeValid(normalizedRange)) {
+      this.removeActionBox();
+      return;
+    }
+    /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
+       we can get is a single Range */
+    const domRange =
+      selection instanceof Range ? selection : selection.getRangeAt(0);
+    const start = normalizedRange!.start!;
+    const end = normalizedRange!.end!;
+
+    // TODO (viktard): Drop empty first and last lines from selection.
+
+    // If the selection is from the end of one line to the start of the next
+    // line, then this must have been a double-click, or you have started
+    // dragging. Showing the action box is bad in the former case and not very
+    // useful in the latter, so never do that.
+    // If this was a mouse-up event, we create a comment immediately if
+    // the selection is from the end of a line to the start of the next line.
+    // In a perfect world we would only do this for double-click, but it is
+    // extremely rare that a user would drag from the end of one line to the
+    // start of the next and release the mouse, so we don't bother.
+    // TODO(brohlfs): This does not work, if the double-click is before a new
+    // diff chunk (start will be equal to end), and neither before an "expand
+    // the diff context" block (end line will match the first line of the new
+    // section and thus be greater than start line + 1).
+    if (start.line === end.line - 1 && end.column === 0) {
+      // Rather than trying to find the line contents (for comparing
+      // start.column with the content length), we just check if the selection
+      // is empty to see that it's at the end of a line.
+      const content = domRange.cloneContents().querySelector('.contentText');
+      if (isMouseUp && this.getLength(content) === 0) {
+        this.fireCreateRangeComment(start.side, {
+          start_line: start.line,
+          start_character: 0,
+          end_line: start.line,
+          end_character: start.column,
+        });
+      }
+      return;
+    }
+
+    let actionBox = this.diffTable.querySelector('gr-selection-action-box');
+    if (!actionBox) {
+      actionBox = document.createElement('gr-selection-action-box');
+      this.diffTable.appendChild(actionBox);
+    }
+    this.selectedRange = {
+      range: {
+        start_line: start.line,
+        start_character: start.column,
+        end_line: end.line,
+        end_character: end.column,
+      },
+      side: start.side,
+    };
+    if (start.line === end.line) {
+      this.positionActionBox(actionBox, start.line, domRange);
+    } else if (start.node instanceof Text) {
+      if (start.column) {
+        this.positionActionBox(
+          actionBox,
+          start.line,
+          start.node.splitText(start.column)
+        );
+      }
+      start.node.parentElement!.normalize(); // Undo splitText from above.
+    } else if (
+      start.node instanceof HTMLElement &&
+      start.node.classList.contains('content') &&
+      (start.node.firstChild instanceof Element ||
+        start.node.firstChild instanceof Text)
+    ) {
+      this.positionActionBox(actionBox, start.line, start.node.firstChild);
+    } else if (start.node instanceof Element || start.node instanceof Text) {
+      this.positionActionBox(actionBox, start.line, start.node);
+    } else {
+      console.warn('Failed to position comment action box.');
+      this.removeActionBox();
+    }
+  }
+
+  private fireCreateRangeComment(side: Side, range: CommentRange) {
+    this.diffTable?.dispatchEvent(
+      new CustomEvent('create-range-comment', {
+        detail: {side, range},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    this.removeActionBox();
+  }
+
+  private handleRangeCommentRequest = (e: Event) => {
+    e.stopPropagation();
+    assertIsDefined(this.selectedRange, 'selectedRange');
+    const {side, range} = this.selectedRange;
+    this.fireCreateRangeComment(side, range);
+  };
+
+  // visible for testing
+  removeActionBox() {
+    this.selectedRange = undefined;
+    const actionBox = this.diffTable?.querySelector('gr-selection-action-box');
+    if (actionBox) actionBox.remove();
+  }
+
+  private convertOffsetToColumn(el: Node, offset: number) {
+    if (el instanceof Element && el.classList.contains('content')) {
+      return offset;
+    }
+    while (
+      el.previousSibling ||
+      !el.parentElement?.classList.contains('content')
+    ) {
+      if (el.previousSibling) {
+        el = el.previousSibling;
+        offset += this.getLength(el);
+      } else {
+        el = el.parentElement!;
+      }
+    }
+    return offset;
+  }
+
+  /**
+   * Get length of a node. If the node is a content node, then only give the
+   * length of its .contentText child.
+   *
+   * @param node this is sometimes passed as null.
+   */
+  // visible for testing
+  getLength(node: Node | null): number {
+    if (node === null) return 0;
+    if (node instanceof Element && node.classList.contains('content')) {
+      return this.getLength(queryAndAssert(node, '.contentText'));
+    } else {
+      return GrAnnotation.getLength(node);
+    }
+  }
+}
+
+export interface CreateRangeCommentEventDetail {
+  side: Side;
+  range: CommentRange;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
new file mode 100644
index 0000000..f04e6a2
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -0,0 +1,717 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-highlight';
+import {getTextOffset} from './gr-range-normalizer';
+import {fixture, fixtureCleanup, html, assert} from '@open-wc/testing';
+import {
+  GrDiffHighlight,
+  DiffBuilderInterface,
+  CreateRangeCommentEventDetail,
+} from './gr-diff-highlight';
+import {Side} from '../../../api/diff';
+import {SinonStubbedMember} from 'sinon';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrDiffThreadElement} from '../gr-diff/gr-diff-utils';
+import {
+  stubElement,
+  waitQueryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
+import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len, lit/prefer-static-styles */
+/* prettier-ignore */
+const diffTable = html`
+  <table id="diffTable">
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText">na💢ti <hl class="foo range generated_id314">te, inquit</hl>, sumus<hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>udiam, <hl>quid</hl> sit,<span class="tab-indicator" style="tab-size:8;"> </span>quod<hl>Epicurum</hl></div></td>
+        <td class="right lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>otiosum,<span class="tab-indicator" style="tab-size:8;"> </span> audiam,sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="138"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="119"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="140"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText"><!-- a comment node -->na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+          [Yet another random diff thread content here]
+        </div></td>
+        <td class="right lineNum" data-value="120"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="141"></td>
+        <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>complectitur<span class="tab-indicator" style="tab-size:8;"></span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+        <td class="right lineNum" data-value="130"></td>
+        <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quodintellegam;</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section contextControl">
+      <tr
+        class="diff-row side-by-side"
+        left-type="contextControl"
+        right-type="contextControl"
+      >
+        <td class="left contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+        <td class="right contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta total">
+      <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+        <td class="left"></td>
+        <td class="blank"></td>
+        <td class="right lineNum" data-value="146"></td>
+        <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="165"></td>
+        <td class="content both"><div class="contentText"></div></td>
+        <td class="right lineNum" data-value="147"></td>
+        <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+      </tr>
+    </tbody>
+  </table>
+`;
+/* eslint-enable max-len */
+
+suite('gr-diff-highlight', () => {
+  suite('comment events', () => {
+    let threadEl: GrDiffThreadElement;
+    let hlRange: HTMLElement;
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      hlRange = queryAndAssert(diff, 'hl.range.generated_id314');
+
+      threadEl = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      threadEl.className = 'comment-thread';
+      threadEl.rootId = 'id314';
+      diff.appendChild(threadEl);
+    });
+
+    teardown(() => {
+      element.cleanup();
+      threadEl.remove();
+    });
+
+    test('comment-thread-mouseenter toggles rangeHoverHighlight class', async () => {
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseenter', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isTrue(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test('comment-thread-mouseleave toggles rangeHoverHighlight class', async () => {
+      hlRange.classList.add('rangeHoverHighlight');
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseleave', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => !hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test(`create-range-comment for range when create-comment-requested
+          is fired`, () => {
+      const removeActionBoxStub = sinon.stub(element, 'removeActionBox');
+      element.selectedRange = {
+        side: Side.LEFT,
+        range: {
+          start_line: 7,
+          start_character: 11,
+          end_line: 24,
+          end_character: 42,
+        },
+      };
+      const requestEvent = new CustomEvent('create-comment-requested');
+      let createRangeEvent: CustomEvent<CreateRangeCommentEventDetail>;
+      diff.addEventListener('create-range-comment', e => {
+        createRangeEvent = e;
+      });
+      diff.dispatchEvent(requestEvent);
+      if (!createRangeEvent!) assert.fail('event not set');
+      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
+      assert.isTrue(removeActionBoxStub.called);
+    });
+  });
+
+  suite('selection', () => {
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+    let contentStubs;
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      contentStubs = [];
+      stubElement('gr-selection-action-box', 'placeAbove');
+      stubElement('gr-selection-action-box', 'placeBelow');
+    });
+
+    teardown(() => {
+      fixtureCleanup();
+      element.cleanup();
+      contentStubs = null;
+      document.getSelection()!.removeAllRanges();
+    });
+
+    const stubContent = (line: number, side: Side) => {
+      const contentTd = diff.querySelector(
+        `.${side}.lineNum[data-value="${line}"] ~ .content`
+      );
+      if (!contentTd) assert.fail('content td not found');
+      const contentText = contentTd.querySelector('.contentText');
+      const lineEl =
+        diff.querySelector(`.${side}.lineNum[data-value="${line}"]`) ??
+        undefined;
+      contentStubs.push({
+        lineEl,
+        contentTd,
+        contentText,
+      });
+      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+      return contentText;
+    };
+
+    const emulateSelection = (
+      startNode: Node,
+      startOffset: number,
+      endNode: Node,
+      endOffset: number
+    ) => {
+      const selection = document.getSelection();
+      if (!selection) assert.fail('no selection');
+      selection.removeAllRanges();
+      const range = document.createRange();
+      range.setStart(startNode, startOffset);
+      range.setEnd(endNode, endOffset);
+      selection.addRange(range);
+      element.handleSelection(selection, false);
+    };
+
+    test('single first line', () => {
+      const content = stubContent(1, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('multiline starting on first line', () => {
+      const startContent = stubContent(1, Side.RIGHT);
+      const endContent = stubContent(2, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('single line', async () => {
+      const content = stubContent(138, Side.LEFT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = await waitQueryAndAssert<GrSelectionActionBox>(
+        diff,
+        'gr-selection-action-box'
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 138,
+        start_character: 5,
+        end_line: 138,
+        end_character: 12,
+      });
+      assert.equal(side, Side.LEFT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiline', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+      assert.equal(side, Side.RIGHT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiple ranges aka firefox implementation', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+
+      const startRange = document.createRange();
+      startRange.setStart(startContent.firstChild, 10);
+      startRange.setEnd(startContent.firstChild, 11);
+
+      const endRange = document.createRange();
+      endRange.setStart(endContent.lastChild, 6);
+      endRange.setEnd(endContent.lastChild, 7);
+
+      const getRangeAtStub = sinon.stub();
+      getRangeAtStub
+        .onFirstCall()
+        .returns(startRange)
+        .onSecondCall()
+        .returns(endRange);
+      const selection = {
+        rangeCount: 2,
+        getRangeAt: getRangeAtStub,
+        removeAllRanges: sinon.stub(),
+      } as unknown as Selection;
+      element.handleSelection(selection, false);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+    });
+
+    test('multiline grow end highlight over tabs', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 2,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('collapsed', () => {
+      const content = stubContent(138, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      emulateSelection(content.firstChild, 5, content.firstChild, 5);
+      const sel = document.getSelection();
+      if (!sel) assert.fail('no selection');
+      assert.isOk(sel.getRangeAt(0).startContainer);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) {
+        assert.fail('content not found');
+      }
+      const hl = content.querySelector('.foo');
+      if (!hl?.firstChild) {
+        assert.fail('first child of hl element not found');
+      }
+      if (!hl?.nextSibling) {
+        assert.fail('next sibling of hl element not found');
+      }
+      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 8,
+        end_line: 140,
+        end_character: 23,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      const hl = content.querySelector('.bar');
+      if (!hl) assert.fail('hl inside content not found');
+      if (!hl.previousSibling) assert.fail('previous sibling not found');
+      if (!hl.firstChild) assert.fail('first child not found');
+      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 18,
+        end_line: 140,
+        end_character: 27,
+      });
+    });
+
+    test('multiple hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('first child not found');
+      const hl = content.querySelectorAll('hl')[4];
+      if (!hl) assert.fail('hl not found');
+      if (!hl.firstChild) assert.fail('first child of hl not found');
+      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 2,
+        end_line: 140,
+        end_character: 61,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts outside of diff', () => {
+      const contentText = stubContent(140, Side.LEFT);
+      if (!contentText) assert.fail('content not found');
+      if (!contentText.firstChild) assert.fail('child not found');
+      const contentTd = contentText.parentElement;
+      if (!contentTd) assert.fail('content td not found');
+      if (!contentTd.parentElement) assert.fail('parent of td not found');
+
+      emulateSelection(contentTd.parentElement, 0, contentText.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends outside of diff', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('child not found');
+      if (!content.nextElementSibling) assert.fail('sibling not found');
+      if (!content.nextElementSibling.firstChild) {
+        assert.fail('sibling child not found');
+      }
+      emulateSelection(
+        content.nextElementSibling.firstChild,
+        2,
+        content.firstChild,
+        2
+      );
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts and ends on different sides', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      const endContent = stubContent(130, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts in comment thread element', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      if (!startContent?.parentElement) {
+        assert.fail('parent el of start content not found');
+      }
+      const comment =
+        startContent.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment not found');
+      }
+      const endContent = stubContent(141, Side.LEFT);
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 83,
+        end_line: 141,
+        end_character: 4,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends in comment thread element', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      if (!content?.parentElement) {
+        assert.fail('parent element of content not found');
+      }
+      const comment = content.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment element not found');
+      }
+      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 4,
+        end_line: 140,
+        end_character: 83,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) assert.fail('context control not found');
+      const content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('content child not found');
+      emulateSelection(contextControl, 0, content.firstChild, 7);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) {
+        assert.fail('context control element not found');
+      }
+      const content = stubContent(141, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      emulateSelection(content.firstChild, 2, contextControl, 1);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('selection containing context element', () => {
+      const startContent = stubContent(130, Side.RIGHT);
+      const endContent = stubContent(146, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 130,
+        start_character: 3,
+        end_line: 146,
+        end_character: 14,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('ends at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      const span = content.querySelector('span');
+      if (!span) assert.fail('span element not found');
+      emulateSelection(content.firstChild, 1, span, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 1,
+        end_line: 140,
+        end_character: 51,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1].nextSibling!,
+        1
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 71,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('properly accounts for syntax highlighting', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1],
+        0
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 69,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('GrRangeNormalizer.getTextOffset computes text offset', () => {
+      let content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      if (!content.lastChild) assert.fail('last child of content not found');
+      let child = content.lastChild.lastChild;
+      if (!child) assert.fail('last child of last child of content not found');
+      let result = getTextOffset(content, child);
+      assert.equal(result, 75);
+      content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content element not found');
+      child = content.lastChild;
+      if (!child) assert.fail('child element not found');
+      result = getTextOffset(content, child);
+      assert.equal(result, 0);
+    });
+
+    test('fixTripleClickSelection', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent) assert.fail('end content not found');
+      if (!endContent.firstChild) assert.fail('first child not found');
+      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 0,
+        end_line: 119,
+        end_character: element.getLength(startContent),
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
new file mode 100644
index 0000000..b177e14
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+export interface NormalizedRange {
+  endContainer: Node;
+  endOffset: number;
+  startContainer: Node;
+  startOffset: number;
+}
+
+/**
+ * Remap DOM range to whole lines of a diff if necessary. If the start or
+ * end containers are DOM elements that are singular pieces of syntax
+ * highlighting, the containers are remapped to the .contentText divs that
+ * contain the entire line of code.
+ *
+ * @param range - the standard DOM selector range.
+ * @return A modified version of the range that correctly accounts
+ *     for syntax highlighting.
+ */
+export function normalize(range: Range): NormalizedRange {
+  const startContainer = getContentTextParent(range.startContainer);
+  const startOffset =
+    range.startOffset + getTextOffset(startContainer, range.startContainer);
+  const endContainer = getContentTextParent(range.endContainer);
+  const endOffset =
+    range.endOffset + getTextOffset(endContainer, range.endContainer);
+  return {
+    startContainer,
+    startOffset,
+    endContainer,
+    endOffset,
+  };
+}
+
+function getContentTextParent(target: Node): Node {
+  if (!target.parentElement) return target;
+
+  let element: Element | null;
+  if (target instanceof Element) {
+    element = target;
+  } else {
+    element = target.parentElement;
+  }
+
+  while (element && !element.classList.contains('contentText')) {
+    if (element.parentElement === null) {
+      return target;
+    }
+    element = element.parentElement;
+  }
+  return element ? element : target;
+}
+
+/**
+ * Gets the character offset of the child within the parent.
+ * Performs a synchronous in-order traversal from top to bottom of the node
+ * element, counting the length of the syntax until child is found.
+ *
+ * @param node The root DOM element to be searched through.
+ * @param child The child element being searched for.
+ */
+// TODO(TS): Only export for test.
+export function getTextOffset(node: Node | null, child: Node): number {
+  let count = 0;
+  let stack = [node];
+  while (stack.length) {
+    const n = stack.pop();
+    if (n === child) {
+      break;
+    }
+    if (n?.childNodes && n.childNodes.length !== 0) {
+      const arr = [];
+      for (const childNode of n.childNodes) {
+        arr.push(childNode);
+      }
+      arr.reverse();
+      stack = stack.concat(arr);
+    } else {
+      count += getLength(n);
+    }
+  }
+  return count;
+}
+
+/**
+ * The DOM API textContent.length calculation is broken when the text
+ * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+ *
+ * @param node A text node.
+ * @return The length of the text.
+ */
+function getLength(node?: Node | null) {
+  return node && node.textContent && node.nodeType !== Node.COMMENT_NODE
+    ? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
+    : 0;
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
new file mode 100644
index 0000000..8a92bcc
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -0,0 +1,958 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '@polymer/paper-button/paper-button';
+import '@polymer/paper-card/paper-card';
+import '@polymer/paper-checkbox/paper-checkbox';
+import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+import '@polymer/paper-icon-button/paper-icon-button';
+import '@polymer/paper-item/paper-item';
+import '@polymer/paper-listbox/paper-listbox';
+import './gr-overview-image';
+import './gr-zoomed-image';
+import '../../../elements/shared/gr-icons/gr-icons';
+
+import {GrLibLoader} from '../../../elements/shared/gr-lib-loader/gr-lib-loader';
+import {RESEMBLEJS_LIBRARY_CONFIG} from '../../../elements/shared/gr-lib-loader/resemblejs_config';
+
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
+
+import {
+  createEvent,
+  Dimensions,
+  fitToFrame,
+  FrameConstrainer,
+  Point,
+  Rect,
+} from './util';
+
+const DRAG_DEAD_ZONE_PIXELS = 5;
+
+const DEFAULT_AUTOMATIC_BLINK_TIME_MS = 1000;
+
+const AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS = 350;
+
+/**
+ * This components allows the user to rapidly switch between two given images
+ * rendered in the same location, to make subtle differences more noticeable.
+ * 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 = '';
+
+  /**
+   * When true, cycle automatically between base and revision image, if both
+   * are available.
+   */
+  @property({type: Boolean}) automaticBlink = false;
+
+  @state() protected baseSelected = false;
+
+  @state() protected scaledSelected = true;
+
+  @state() protected followMouse = false;
+
+  @state() protected scale = 1;
+
+  @state() protected checkerboardSelected = true;
+
+  @state() protected backgroundColor = '';
+
+  @state() protected automaticBlinkShown = false;
+
+  @state() protected zoomedImageStyle: StyleInfo = {};
+
+  @query('.imageArea') protected imageArea!: HTMLDivElement;
+
+  @query('gr-zoomed-image') protected zoomedImage!: Element;
+
+  @query('#source-image') protected sourceImage!: HTMLImageElement;
+
+  @query('#automatic-blink-button') protected automaticBlinkButton?: Element;
+
+  private imageSize: Dimensions = {width: 0, height: 0};
+
+  @state()
+  protected magnifierSize: Dimensions = {width: 0, height: 0};
+
+  @state()
+  protected magnifierFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  @state()
+  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,
+  ];
+
+  @state() protected grabbing = false;
+
+  @state() protected canHighlightDiffs = false;
+
+  @state() protected diffHighlightSrc?: string;
+
+  @state() protected showHighlight = 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,
+          };
+        }
+      }
+    }
+  );
+
+  // Ensure constant function references, so that render() does not bind a new
+  // event listener on every call, as it would with lambdas.
+  private createColorPickerCallback(color: string) {
+    return {color, callback: () => this.pickColor(color)};
+  }
+
+  private readonly colorPickerCallbacks = [
+    this.createColorPickerCallback('#fff'),
+    this.createColorPickerCallback('#000'),
+    this.createColorPickerCallback('#aaa'),
+  ];
+
+  private automaticBlinkTimer?: ReturnType<typeof setInterval>;
+
+  // TODO(hermannloose): Make GrLibLoader a singleton.
+  private static readonly libLoader = new GrLibLoader();
+
+  static override styles = css`
+    :host {
+      display: grid;
+      grid-template-rows: 1fr auto;
+      grid-template-columns: 1fr auto;
+      width: 100%;
+      height: 100%;
+      box-sizing: border-box;
+      text-align: initial !important;
+      font-size: var(--font-size-normal);
+      --image-border-width: 2px;
+    }
+    .imageArea {
+      grid-row-start: 1;
+      grid-column-start: 1;
+      box-sizing: border-box;
+      flex-grow: 1;
+      overflow: hidden;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      margin: var(--spacing-m);
+      padding: var(--image-border-width);
+      max-height: 100%;
+      position: relative;
+    }
+    #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));
+    }
+    #automatic-blink-button {
+      position: absolute;
+      right: var(--spacing-xl);
+      bottom: var(--spacing-xl);
+      opacity: 0;
+      transition: opacity 200ms ease;
+      --paper-fab-background: var(--primary-button-background-color);
+      --paper-fab-keyboard-focus-background: var(
+        --primary-button-background-color
+      );
+    }
+    #automatic-blink-button.show,
+    #automatic-blink-button:focus-visible {
+      opacity: 1;
+    }
+    .checkerboard {
+      --square-size: var(--checkerboard-square-size, 10px);
+      --square-color: var(--checkerboard-square-color, #808080);
+      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;
+    }
+    .dimensions {
+      grid-row-start: 2;
+      justify-self: center;
+      align-self: center;
+      background: var(--primary-button-background-color);
+      color: var(--primary-button-text-color);
+      font-family: var(--font-family);
+      font-size: var(--font-size-small);
+      line-height: var(--line-height-small);
+      border-radius: var(--border-radius, 4px);
+      margin: var(--spacing-s);
+      padding: var(--spacing-xxs) var(--spacing-s);
+    }
+    .controls {
+      grid-column-start: 2;
+      flex-grow: 0;
+      display: flex;
+      flex-direction: column;
+      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);
+      outline: 1px solid transparent;
+      border: 1px solid var(--primary-button-background-color);
+    }
+    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);
+    }
+    #version-switcher {
+      display: flex;
+      align-items: center;
+      margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
+      /* Start a stacking context to contain FAB below. */
+      z-index: 0;
+    }
+    #version-switcher paper-button {
+      flex-grow: 1;
+      margin: 0;
+      /*
+        The floating action button below overlaps part of the version buttons.
+        This min-width ensures the button text still appears somewhat balanced.
+        */
+      min-width: 7rem;
+    }
+    #version-switcher paper-fab {
+      /* Round button overlaps Base and Revision buttons. */
+      z-index: 1;
+      margin: 0 -12px;
+      /* Styled as an outlined button. */
+      color: var(--primary-button-background-color);
+      border: 1px solid var(--primary-button-background-color);
+      --paper-fab-background: var(--primary-background-color);
+      --paper-fab-keyboard-focus-background: var(--primary-background-color);
+    }
+    #version-explanation {
+      color: var(--deemphasized-text-color);
+      text-align: center;
+      margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
+    }
+    #highlight-changes {
+      margin: var(--spacing-m) var(--spacing-xl);
+    }
+    gr-overview-image {
+      min-width: 200px;
+      min-height: 150px;
+      margin-top: var(--spacing-m);
+    }
+    #zoom-control {
+      margin: 0 var(--spacing-xl);
+    }
+    paper-item {
+      cursor: pointer;
+    }
+    paper-item:hover {
+      background-color: var(--hover-background-color);
+    }
+    #follow-mouse {
+      margin: var(--spacing-m) var(--spacing-xl);
+    }
+    .color-picker {
+      margin: var(--spacing-m) var(--spacing-xl) 0;
+    }
+    .color-picker .label {
+      margin-bottom: var(--spacing-s);
+    }
+    .color-picker .options {
+      display: flex;
+      /* Ignore selection border for alignment, for visual balance. */
+      margin-left: -3px;
+    }
+    .color-picker-button {
+      border-width: 2px;
+      border-style: solid;
+      border-color: transparent;
+      border-radius: 50%;
+      width: 24px;
+      height: 24px;
+      padding: 1px;
+    }
+    .color-picker-button.selected {
+      border-color: var(--primary-button-background-color);
+    }
+    .color-picker-button:focus-within:not(.selected) {
+      /* Not an actual outline, as those do not follow border-radius. */
+      border-color: var(--outline-color-focus);
+    }
+    .color-picker-button .color {
+      border: 1px solid var(--border-color);
+      border-radius: 50%;
+      width: 100%;
+      height: 100%;
+      box-sizing: border-box;
+    }
+    #source-plus-highlight-container {
+      position: relative;
+    }
+    #source-plus-highlight-container img {
+      position: absolute;
+      top: 0;
+      left: 0;
+    }
+  `;
+
+  private renderColorPickerButton(color: string, colorPicked: () => void) {
+    const selected =
+      color === this.backgroundColor && !this.checkerboardSelected;
+    return html`
+      <div
+        class=${classMap({
+          'color-picker-button': true,
+          selected,
+        })}
+      >
+        <paper-icon-button
+          class="color"
+          style=${styleMap({backgroundColor: color})}
+          @click=${colorPicked}
+        ></paper-icon-button>
+      </div>
+    `;
+  }
+
+  private renderCheckerboardButton() {
+    return html`
+      <div
+        class=${classMap({
+          'color-picker-button': true,
+          selected: this.checkerboardSelected,
+        })}
+      >
+        <paper-icon-button
+          class="color checkerboard"
+          @click=${this.pickCheckerboard}
+        >
+        </paper-icon-button>
+      </div>
+    `;
+  }
+
+  override render() {
+    const src = this.baseSelected ? this.baseUrl : this.revisionUrl;
+
+    const sourceImage = html`
+      <img
+        id="source-image"
+        src=${src}
+        class=${classMap({checkerboard: this.checkerboardSelected})}
+        style=${styleMap({
+          backgroundColor: this.checkerboardSelected
+            ? ''
+            : this.backgroundColor,
+        })}
+        @load=${this.updateSizes}
+      />
+    `;
+
+    const sourceImageWithHighlight = html`
+      <div id="source-plus-highlight-container">
+        ${sourceImage}
+        <img
+          id="highlight-image"
+          style=${styleMap({
+            opacity: this.showHighlight ? '1' : '0',
+            // When the highlight layer is not being shown, saving the image or
+            // opening it in a new tab from the context menu, e.g. for external
+            // comparison, should give back the source image, not the highlight
+            // layer.
+            'pointer-events': this.showHighlight ? 'auto' : 'none',
+          })}
+          src=${ifDefined(this.diffHighlightSrc)}
+        />
+      </div>
+    `;
+
+    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 leftClasses = {
+      left: true,
+      unelevated: this.baseSelected,
+      outlined: !this.baseSelected,
+    };
+    const rightClasses = {
+      right: true,
+      unelevated: !this.baseSelected,
+      outlined: this.baseSelected,
+    };
+    const versionToggle = html`
+      <div id="version-switcher">
+        <paper-button class=${classMap(leftClasses)} @click=${this.selectBase}>
+          Base
+        </paper-button>
+        <paper-fab mini icon="gr-icons:swapHoriz" @click=${this.manualBlink}>
+        </paper-fab>
+        <paper-button
+          class=${classMap(rightClasses)}
+          @click=${this.selectRevision}
+        >
+          Revision
+        </paper-button>
+      </div>
+    `;
+
+    const versionSwitcher = html`
+      ${this.baseUrl && this.revisionUrl ? versionToggle : versionExplanation}
+    `;
+
+    const highlightSwitcher = this.diffHighlightSrc
+      ? html`
+          <paper-checkbox
+            id="highlight-changes"
+            ?checked=${this.showHighlight}
+            @change=${this.showHighlightChanged}
+          >
+            Highlight differences
+          </paper-checkbox>
+        `
+      : '';
+
+    const overviewImage = html`
+      <gr-overview-image
+        .frameRect=${this.overviewFrame}
+        @center-updated=${this.onOverviewCenterUpdated}
+      >
+        <img
+          src=${src}
+          class=${classMap({checkerboard: this.checkerboardSelected})}
+          style=${styleMap({
+            backgroundColor: this.checkerboardSelected
+              ? ''
+              : this.backgroundColor,
+          })}
+        />
+      </gr-overview-image>
+    `;
+
+    const zoomControl = html`
+      <paper-dropdown-menu id="zoom-control" label="Zoom">
+        <paper-listbox
+          slot="dropdown-content"
+          selected="fit"
+          .attrForSelected=${'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>
+    `;
+
+    const backgroundPicker = html`
+      <div class="color-picker">
+        <div class="label">Background</div>
+        <div class="options">
+          ${this.renderCheckerboardButton()}
+          ${this.colorPickerCallbacks.map(({color, callback}) =>
+            this.renderColorPickerButton(color, callback)
+          )}
+        </div>
+      </div>
+    `;
+
+    /*
+     * We want the content to fill the available space until it can display
+     * without being cropped, the maximum of which will be determined by
+     * (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>
+    `;
+
+    const automaticBlink = html`
+      <paper-fab
+        id="automatic-blink-button"
+        class=${classMap({show: this.automaticBlinkShown})}
+        title="Automatic blink"
+        icon="gr-icons:${this.automaticBlink ? 'pause' : 'playArrow'}"
+        @click=${this.toggleAutomaticBlink}
+      >
+      </paper-fab>
+    `;
+
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
+    const customStyle = html`
+      <style>
+        /* prettier formatter removes semi-colons after css mixins. */
+        /* prettier-ignore */
+        paper-item {
+          --paper-item-min-height: 48;
+          --paper-item: {
+            min-height: 48px;
+            padding: 0 var(--spacing-xl);
+          };
+          --paper-item-focused-before: {
+            background-color: var(--selection-background-color);
+          };
+          --paper-item-focused: {
+            background-color: var(--selection-background-color);
+          };
+        }
+      </style>
+    `;
+
+    return html`
+      ${customStyle}
+      <div
+        class="imageArea"
+        @mousemove=${this.mousemoveImageArea}
+        @mouseleave=${this.mouseleaveImageArea}
+      >
+        <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}
+        >
+          ${sourceImageWithHighlight}
+        </gr-zoomed-image>
+        ${this.baseUrl && this.revisionUrl ? automaticBlink : ''} ${spacer}
+      </div>
+
+      <div class="dimensions">
+        ${this.imageSize.width} x ${this.imageSize.height}
+      </div>
+
+      <paper-card class="controls">
+        ${versionSwitcher} ${highlightSwitcher} ${overviewImage} ${zoomControl}
+        ${!this.scaledSelected ? followMouse : ''} ${backgroundPicker}
+      </paper-card>
+    `;
+  }
+
+  override firstUpdated() {
+    this.resizeObserver.observe(this.imageArea, {box: 'content-box'});
+    GrImageViewer.libLoader.getLibrary(RESEMBLEJS_LIBRARY_CONFIG).then(() => {
+      this.canHighlightDiffs = true;
+      this.computeDiffImage();
+    });
+  }
+
+  // We don't want property changes in updateSizes() to trigger infinite update
+  // loops, so we perform this in update() instead of updated().
+  override update(changedProperties: PropertyValues) {
+    // eslint-disable-next-line lit/no-property-change-update
+    if (!this.baseUrl) this.baseSelected = false;
+    // eslint-disable-next-line lit/no-property-change-update
+    if (!this.revisionUrl) this.baseSelected = true;
+    this.updateSizes();
+    super.update(changedProperties);
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (
+      (changedProperties.has('baseUrl') && this.baseSelected) ||
+      (changedProperties.has('revisionUrl') && !this.baseSelected)
+    ) {
+      this.frameConstrainer.requestCenter({x: 0, y: 0});
+    }
+    if (changedProperties.has('automaticBlink')) {
+      this.updateAutomaticBlink();
+    }
+    if (
+      this.canHighlightDiffs &&
+      (changedProperties.has('baseUrl') || changedProperties.has('revisionUrl'))
+    ) {
+      this.computeDiffImage();
+    }
+  }
+
+  private computeDiffImage() {
+    if (!(this.baseUrl && this.revisionUrl)) return;
+    window
+      .resemble(this.baseUrl)
+      .compareTo(this.revisionUrl)
+      // By default Resemble.js applies some color / alpha tolerance as well as
+      // min / max brightness beyond which to ignore changes. Until we have
+      // controls to let the user affect these options, always highlight all
+      // changed pixels.
+      .ignoreNothing()
+      .onComplete(result => {
+        this.diffHighlightSrc = result.getImageDataUrl();
+      });
+  }
+
+  selectBase() {
+    if (!this.baseUrl) return;
+    this.baseSelected = true;
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'base'})
+    );
+  }
+
+  selectRevision() {
+    if (!this.revisionUrl) return;
+    this.baseSelected = false;
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'revision'})
+    );
+  }
+
+  manualBlink() {
+    this.toggleImage();
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'switch'})
+    );
+  }
+
+  private toggleImage() {
+    if (this.baseUrl && this.revisionUrl) {
+      this.baseSelected = !this.baseSelected;
+    }
+  }
+
+  toggleAutomaticBlink() {
+    this.automaticBlink = !this.automaticBlink;
+    this.dispatchEvent(
+      createEvent({type: 'automatic-blink-changed', value: this.automaticBlink})
+    );
+  }
+
+  private updateAutomaticBlink() {
+    if (this.automaticBlink) {
+      this.toggleImage();
+      this.setBlinkInterval();
+    } else {
+      this.clearBlinkInterval();
+    }
+  }
+
+  private setBlinkInterval() {
+    this.clearBlinkInterval();
+    this.automaticBlinkTimer = setInterval(() => {
+      this.toggleImage();
+    }, DEFAULT_AUTOMATIC_BLINK_TIME_MS);
+  }
+
+  private clearBlinkInterval() {
+    if (this.automaticBlinkTimer) {
+      clearInterval(this.automaticBlinkTimer);
+      this.automaticBlinkTimer = undefined;
+    }
+  }
+
+  showHighlightChanged() {
+    this.toggleHighlight('controls');
+  }
+
+  private toggleHighlight(source: 'controls' | 'magnifier') {
+    this.showHighlight = !this.showHighlight;
+    this.dispatchEvent(
+      createEvent({
+        type: 'highlight-changes-changed',
+        value: this.showHighlight,
+        source,
+      })
+    );
+  }
+
+  zoomControlChanged(event: CustomEvent) {
+    const value = event.detail.value;
+    if (!value) return;
+    if (value === 'fit') {
+      this.scaledSelected = true;
+      this.dispatchEvent(
+        createEvent({type: 'zoom-level-changed', scale: 'fit'})
+      );
+    }
+    if (value > 0) {
+      this.scaledSelected = false;
+      this.scale = value;
+      this.dispatchEvent(
+        createEvent({type: 'zoom-level-changed', scale: value})
+      );
+    }
+    this.updateSizes();
+  }
+
+  followMouseChanged() {
+    this.followMouse = !this.followMouse;
+    this.dispatchEvent(
+      createEvent({type: 'follow-mouse-changed', value: this.followMouse})
+    );
+  }
+
+  pickColor(value: string) {
+    this.checkerboardSelected = false;
+    this.backgroundColor = value;
+    this.dispatchEvent(createEvent({type: 'background-color-changed', value}));
+  }
+
+  pickCheckerboard() {
+    this.checkerboardSelected = true;
+    this.dispatchEvent(
+      createEvent({type: 'background-color-changed', value: 'checkerboard'})
+    );
+  }
+
+  mousemoveImageArea(event: MouseEvent) {
+    if (this.automaticBlinkButton) {
+      this.updateAutomaticBlinkVisibility(event);
+    }
+    this.mousemoveMagnifier(event);
+  }
+
+  private updateAutomaticBlinkVisibility(event: MouseEvent) {
+    const rect = this.automaticBlinkButton!.getBoundingClientRect();
+    const centerX = rect.left + (rect.right - rect.left) / 2;
+    const centerY = rect.top + (rect.bottom - rect.top) / 2;
+    const distX = Math.abs(centerX - event.clientX);
+    const distY = Math.abs(centerY - event.clientY);
+    this.automaticBlinkShown =
+      distX < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS &&
+      distY < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS;
+  }
+
+  mouseleaveImageArea() {
+    this.automaticBlinkShown = false;
+  }
+
+  mousedownMagnifier(event: MouseEvent) {
+    if (event.buttons === 1) {
+      this.ownsMouseDown = true;
+      this.centerOnDown = this.frameConstrainer.getCenter();
+      this.pointerOnDown = {
+        x: event.clientX,
+        y: event.clientY,
+      };
+    }
+  }
+
+  mouseupMagnifier(event: MouseEvent) {
+    if (!this.ownsMouseDown) return;
+    this.grabbing = false;
+    this.ownsMouseDown = false;
+
+    if (event.shiftKey && this.diffHighlightSrc) {
+      this.toggleHighlight('magnifier');
+      return;
+    }
+
+    const offsetX = event.clientX - this.pointerOnDown.x;
+    const offsetY = event.clientY - this.pointerOnDown.y;
+    const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
+    // Consider very short drags as clicks. These tend to happen more often on
+    // external mice.
+    if (distance < DRAG_DEAD_ZONE_PIXELS) {
+      this.toggleImage();
+      this.dispatchEvent(createEvent({type: 'magnifier-clicked'}));
+    } else {
+      this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
+    }
+  }
+
+  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() {
+    if (!this.ownsMouseDown) return;
+    this.grabbing = false;
+    this.ownsMouseDown = false;
+    this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
+  }
+
+  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/embed/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
new file mode 100644
index 0000000..1bc1447
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -0,0 +1,314 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
+import {ImageDiffAction} from '../../../api/diff';
+
+import {createEvent, 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}};
+
+  @state() protected contentStyle: StyleInfo = {};
+
+  @state() protected contentTransformStyle: StyleInfo = {};
+
+  @state() protected frameStyle: StyleInfo = {};
+
+  @state() 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;
+
+  protected overlay?: 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 override 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;
+    }
+  `;
+
+  override 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>
+    `;
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    if (this.isConnected) {
+      this.overlay = document.createElement('div');
+      // The overlay is added directly to document body to ensure it fills the
+      // entire screen to capture events, without being clipped by any parent
+      // overflow properties. This means it has to be styled manually, since
+      // component styles will not affect it.
+      this.overlay.style.position = 'fixed';
+      this.overlay.style.top = '0';
+      this.overlay.style.left = '0';
+      // We subtract 20 pixels in each dimension to prevent the overlay from
+      // extending offscreen under any existing scrollbar and causing the
+      // scrollbar for the other dimension to show up unnecessarily.
+      this.overlay.style.width = 'calc(100vw - 20px)';
+      this.overlay.style.height = 'calc(100vh - 20px)';
+      this.overlay.style.zIndex = '10000';
+      this.overlay.style.display = 'none';
+
+      this.overlay.addEventListener('mousemove', (event: MouseEvent) =>
+        this.maybeDragFrame(event)
+      );
+      this.overlay.addEventListener('mouseleave', (event: MouseEvent) => {
+        // Ignore mouseleave events that are due to closeOverlay() calls.
+        if (this.overlay?.style.display !== 'none') {
+          this.releaseFrame(event);
+        }
+      });
+      this.overlay.addEventListener('mouseup', (event: MouseEvent) =>
+        this.releaseFrame(event)
+      );
+
+      document.body.appendChild(this.overlay);
+    }
+  }
+
+  override disconnectedCallback() {
+    if (this.overlay) {
+      document.body.removeChild(this.overlay);
+      this.overlay = undefined;
+    }
+    super.disconnectedCallback();
+  }
+
+  override firstUpdated() {
+    this.resizeObserver.observe(this.contentBox);
+    this.resizeObserver.observe(this.contentTransform);
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('frameRect')) {
+      this.updateFrameStyle();
+    }
+  }
+
+  clickOverview(event: MouseEvent) {
+    if (event.buttons !== 1) return;
+    event.preventDefault();
+
+    this.dragging = true;
+    this.openOverlay();
+
+    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.dragging = true;
+    this.openOverlay();
+
+    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();
+
+    const detail: ImageDiffAction = {
+      type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked',
+    };
+    this.dispatchEvent(createEvent(detail));
+
+    this.dragging = false;
+    this.closeOverlay();
+    this.grabOffset = {x: 0, y: 0};
+  }
+
+  private openOverlay() {
+    if (this.overlay) {
+      this.overlay.style.display = 'block';
+      this.overlay.style.cursor = 'grabbing';
+    }
+  }
+
+  private closeOverlay() {
+    if (this.overlay) {
+      this.overlay.style.display = 'none';
+      this.overlay.style.cursor = '';
+    }
+  }
+
+  private updateScale() {
+    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 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/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
new file mode 100644
index 0000000..7b46c51
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
+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}};
+
+  @state() protected imageStyles: StyleInfo = {};
+
+  static override 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;
+    }
+  `;
+
+  override render() {
+    return html`
+      <div id="clip">
+        <div id="transform" style=${styleMap(this.imageStyles)}>
+          <slot></slot>
+        </div>
+      </div>
+    `;
+  }
+
+  override 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/embed/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
new file mode 100644
index 0000000..38a07b7
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
@@ -0,0 +1,236 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ImageDiffAction} from '../../../api/diff';
+
+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,
+    };
+  }
+}
+
+export function createEvent(
+  detail: ImageDiffAction
+): CustomEvent<ImageDiffAction> {
+  return new CustomEvent('image-diff-action', {
+    detail,
+    bubbles: true,
+    composed: true,
+  });
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.ts
new file mode 100644
index 0000000..521b2e1
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util_test.ts
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../../test/common-test-setup';
+import {FrameConstrainer} from './util';
+
+suite('FrameConstrainer tests', () => {
+  let constrainer: FrameConstrainer;
+
+  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},
+        });
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
new file mode 100644
index 0000000..5058ce8
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -0,0 +1,164 @@
+/**
+ * @license
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Subscription} from 'rxjs';
+import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import '../../../elements/shared/gr-button/gr-button';
+import '../../../elements/shared/gr-icon/gr-icon';
+import {DiffViewMode} from '../../../constants/constants';
+import {customElement, property, state} from 'lit/decorators.js';
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import {FixIronA11yAnnouncer} from '../../../types/types';
+import {fireIronAnnounce} from '../../../utils/event-util';
+import {browserModelToken} from '../../../models/browser/browser-model';
+import {resolve} from '../../../models/dependency';
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {userModelToken} from '../../../models/user/user-model';
+
+@customElement('gr-diff-mode-selector')
+export class GrDiffModeSelector extends LitElement {
+  /**
+   * If set to true, the user's preference will be updated every time a
+   * button is tapped. Don't set to true if there is no user.
+   */
+  @property({type: Boolean}) saveOnChange = false;
+
+  @property({type: Boolean, attribute: 'show-tooltip-below'})
+  showTooltipBelow = false;
+
+  // visible for testing
+  @state() mode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  private readonly getBrowserModel = resolve(this, browserModelToken);
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private subscriptions: Subscription[] = [];
+
+  constructor() {
+    super();
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    (
+      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
+    ).requestAvailability();
+    this.subscriptions.push(
+      this.getBrowserModel().diffViewMode$.subscribe(
+        diffView => (this.mode = diffView)
+      )
+    );
+  }
+
+  override disconnectedCallback() {
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+    super.disconnectedCallback();
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        /* Used to remove horizontal whitespace between the icons. */
+        display: flex;
+      }
+      gr-button.selected gr-icon {
+        color: var(--link-color);
+      }
+      gr-icon {
+        font-size: 1.3rem;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-tooltip-content
+        has-tooltip
+        title="Side-by-side diff"
+        ?position-below=${this.showTooltipBelow}
+      >
+        <gr-button
+          id="sideBySideBtn"
+          link
+          class=${this.computeSideBySideSelected()}
+          aria-pressed=${this.isSideBySideSelected()}
+          @click=${this.handleSideBySideTap}
+        >
+          <gr-icon icon="view_column_2" filled></gr-icon>
+        </gr-button>
+      </gr-tooltip-content>
+      <gr-tooltip-content
+        has-tooltip
+        ?position-below=${this.showTooltipBelow}
+        title="Unified diff"
+      >
+        <gr-button
+          id="unifiedBtn"
+          link
+          class=${this.computeUnifiedSelected()}
+          aria-pressed=${this.isUnifiedSelected()}
+          @click=${this.handleUnifiedTap}
+        >
+          <gr-icon icon="calendar_view_day" filled></gr-icon>
+        </gr-button>
+      </gr-tooltip-content>
+    `;
+  }
+
+  /**
+   * Set the mode. If save on change is enabled also update the preference.
+   */
+  private setMode(newMode: DiffViewMode) {
+    if (this.saveOnChange && this.mode && this.mode !== newMode) {
+      this.getUserModel().updatePreferences({diff_view: newMode});
+    }
+    this.mode = newMode;
+    let announcement;
+    if (this.isUnifiedSelected()) {
+      announcement = 'Changed diff view to unified';
+    } else if (this.isSideBySideSelected()) {
+      announcement = 'Changed diff view to side by side';
+    }
+    if (announcement) {
+      fireIronAnnounce(this, announcement);
+    }
+  }
+
+  private computeSideBySideSelected() {
+    return this.mode === DiffViewMode.SIDE_BY_SIDE ? 'selected' : '';
+  }
+
+  private computeUnifiedSelected() {
+    return this.mode === DiffViewMode.UNIFIED ? 'selected' : '';
+  }
+
+  private isSideBySideSelected() {
+    return this.mode === DiffViewMode.SIDE_BY_SIDE;
+  }
+
+  private isUnifiedSelected() {
+    return this.mode === DiffViewMode.UNIFIED;
+  }
+
+  private handleSideBySideTap() {
+    this.setMode(DiffViewMode.SIDE_BY_SIDE);
+  }
+
+  private handleUnifiedTap() {
+    this.setMode(DiffViewMode.UNIFIED);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-mode-selector': GrDiffModeSelector;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
new file mode 100644
index 0000000..d646988
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -0,0 +1,159 @@
+/**
+ * @license
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-mode-selector';
+import {GrDiffModeSelector} from './gr-diff-mode-selector';
+import {DiffViewMode} from '../../../constants/constants';
+import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {
+  BrowserModel,
+  browserModelToken,
+} from '../../../models/browser/browser-model';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {createPreferences} from '../../../test/test-data-generators';
+import {GrButton} from '../../../elements/shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+
+suite('gr-diff-mode-selector tests', () => {
+  let element: GrDiffModeSelector;
+  let browserModel: BrowserModel;
+  let userModel: UserModel;
+
+  setup(async () => {
+    userModel = testResolver(userModelToken);
+    browserModel = new BrowserModel(userModel);
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-diff-mode-selector></gr-diff-mode-selector>`,
+          browserModelToken,
+          browserModel
+        )
+      )
+    ).querySelector('gr-diff-mode-selector')!;
+  });
+
+  test('renders side-by-side selected', async () => {
+    userModel.setPreferences({
+      ...createPreferences(),
+      diff_view: DiffViewMode.SIDE_BY_SIDE,
+    });
+    await waitUntilObserved(
+      browserModel.diffViewMode$,
+      mode => mode === DiffViewMode.SIDE_BY_SIDE
+    );
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-tooltip-content has-tooltip="" title="Side-by-side diff">
+          <gr-button
+            id="sideBySideBtn"
+            link=""
+            class="selected"
+            aria-disabled="false"
+            aria-pressed="true"
+            role="button"
+            tabindex="0"
+          >
+            <gr-icon icon="view_column_2" filled></gr-icon>
+          </gr-button>
+        </gr-tooltip-content>
+        <gr-tooltip-content has-tooltip title="Unified diff">
+          <gr-button
+            id="unifiedBtn"
+            link=""
+            role="button"
+            aria-disabled="false"
+            aria-pressed="false"
+            tabindex="0"
+          >
+            <gr-icon filled icon="calendar_view_day"></gr-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      `
+    );
+  });
+
+  test('renders unified selected', async () => {
+    userModel.setPreferences({
+      ...createPreferences(),
+      diff_view: DiffViewMode.UNIFIED,
+    });
+    await waitUntilObserved(
+      browserModel.diffViewMode$,
+      mode => mode === DiffViewMode.UNIFIED
+    );
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-tooltip-content has-tooltip="" title="Side-by-side diff">
+          <gr-button
+            id="sideBySideBtn"
+            link=""
+            class=""
+            aria-disabled="false"
+            aria-pressed="false"
+            role="button"
+            tabindex="0"
+          >
+            <gr-icon icon="view_column_2" filled></gr-icon>
+          </gr-button>
+        </gr-tooltip-content>
+        <gr-tooltip-content has-tooltip title="Unified diff">
+          <gr-button
+            id="unifiedBtn"
+            link=""
+            class="selected"
+            role="button"
+            aria-disabled="false"
+            aria-pressed="true"
+            tabindex="0"
+          >
+            <gr-icon icon="calendar_view_day" filled></gr-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      `
+    );
+  });
+
+  test('set mode', async () => {
+    browserModel.setScreenWidth(0);
+    const saveStub = sinon.stub(userModel, 'updatePreferences');
+
+    // Setting the mode initially does not save prefs.
+    element.saveOnChange = true;
+    queryAndAssert<GrButton>(element, 'gr-button#sideBySideBtn').click();
+    await element.updateComplete;
+
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to itself does not save prefs.
+    queryAndAssert<GrButton>(element, 'gr-button#sideBySideBtn').click();
+    await element.updateComplete;
+
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to something else does not save prefs if saveOnChange
+    // is false.
+    element.saveOnChange = false;
+    queryAndAssert<GrButton>(element, 'gr-button#unifiedBtn').click();
+    await element.updateComplete;
+
+    assert.isFalse(saveStub.called);
+
+    // Setting the mode to something else does not save prefs if saveOnChange
+    // is false.
+    element.saveOnChange = true;
+    queryAndAssert<GrButton>(element, 'gr-button#sideBySideBtn').click();
+    await element.updateComplete;
+
+    assert.isTrue(saveStub.calledOnce);
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
new file mode 100644
index 0000000..22a71a5
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -0,0 +1,748 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  GrDiffLine,
+  GrDiffLineType,
+  FILE,
+  Highlights,
+  LineNumber,
+} from '../gr-diff/gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from '../gr-diff/gr-diff-group';
+import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
+import {DiffContent} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {RenderPreferences} from '../../../api/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+
+const WHOLE_FILE = -1;
+
+// visible for testing
+export interface State {
+  lineNums: {
+    left: number;
+    right: number;
+  };
+  chunkIndex: number;
+}
+
+interface ChunkEnd {
+  offset: number;
+  keyLocation: boolean;
+}
+
+export interface KeyLocations {
+  left: {[key: string]: boolean};
+  right: {[key: string]: boolean};
+}
+
+/**
+ * The maximum size for an addition or removal chunk before it is broken down
+ * into a series of chunks that are this size at most.
+ *
+ * Note: The value of 120 is chosen so that it is larger than the default
+ * asyncThreshold of 64, but feel free to tune this constant to your
+ * performance needs.
+ */
+function calcMaxGroupSize(asyncThreshold?: number): number {
+  if (!asyncThreshold) return 120;
+  return asyncThreshold * 2;
+}
+
+/** Interface for listening to the output of the processor. */
+export interface GroupConsumer {
+  addGroup(group: GrDiffGroup): void;
+  clearGroups(): void;
+}
+
+/**
+ * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
+ *
+ * Glossary:
+ * - "chunk": A single `DiffContent` as returned by the API.
+ * - "group": A single `GrDiffGroup` as used for rendering.
+ * - "common" chunk/group: A chunk/group that should be considered unchanged
+ *   for diffing purposes. This can mean its either actually unchanged, or it
+ *   has only whitespace changes.
+ * - "key location": A line number and side of the diff that should not be
+ *   collapsed e.g. because a comment is attached to it, or because it was
+ *   provided in the URL and thus should be visible
+ * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
+ *   or cannot be collapsed because it contains a key location
+ *
+ * Here a a number of tasks this processor performs:
+ *  - splitting large chunks to allow more granular async rendering
+ *  - adding a group for the "File" pseudo line that file-level comments can
+ *    be attached to
+ *  - replacing common parts of the diff that are outside the user's
+ *    context setting and do not have comments with a group representing the
+ *    "expand context" widget. This may require splitting a chunk/group so
+ *    that the part that is within the context or has comments is shown, while
+ *    the rest is not.
+ */
+export class GrDiffProcessor {
+  context = 3;
+
+  consumer?: GroupConsumer;
+
+  keyLocations: KeyLocations = {left: {}, right: {}};
+
+  private asyncThreshold = 64;
+
+  private nextStepHandle: number | null = null;
+
+  private processPromise: CancelablePromise<void> | null = null;
+
+  // visible for testing
+  isScrolling?: boolean;
+
+  private resetIsScrollingTask?: DelayedTask;
+
+  private readonly handleWindowScroll = () => {
+    this.isScrolling = true;
+    this.resetIsScrollingTask = debounce(
+      this.resetIsScrollingTask,
+      () => (this.isScrolling = false),
+      50
+    );
+  };
+
+  /**
+   * Asynchronously process the diff chunks into groups. As it processes, it
+   * will splice groups into the `groups` property of the component.
+   *
+   * @return A promise that resolves with an
+   * array of GrDiffGroups when the diff is completely processed.
+   */
+  process(chunks: DiffContent[], isBinary: boolean) {
+    // Cancel any still running process() calls, because they append to the
+    // same groups field.
+    this.cancel();
+    window.addEventListener('scroll', this.handleWindowScroll);
+
+    assertIsDefined(this.consumer, 'consumer');
+    this.consumer.clearGroups();
+    this.consumer.addGroup(this.makeGroup('LOST'));
+    this.consumer.addGroup(this.makeGroup(FILE));
+
+    // If it's a binary diff, we won't be rendering hunks of text differences
+    // so finish processing.
+    if (isBinary) {
+      return Promise.resolve();
+    }
+
+    // TODO: Canceling this promise does not help much. `nextStep` will continue
+    // to be scheduled anyway. So either just remove the cancelable promise, so
+    // future programmers are not fooled about this promise can do. Or fix the
+    // scheduling of `nextStep` such that cancellation is taken into account.
+    // The easiest approach is likely to just not re-use the processor for
+    // multiple processing passes. There is no benefit from doing so.
+    this.processPromise = makeCancelable(
+      new Promise(resolve => {
+        const state = {
+          lineNums: {left: 0, right: 0},
+          chunkIndex: 0,
+        };
+
+        chunks = this.splitLargeChunks(chunks);
+        chunks = this.splitCommonChunksWithKeyLocations(chunks);
+
+        let currentBatch = 0;
+        const nextStep = () => {
+          if (this.isScrolling) {
+            this.nextStepHandle = window.setTimeout(nextStep, 100);
+            return;
+          }
+          // If we are done, resolve the promise.
+          if (state.chunkIndex >= chunks.length) {
+            resolve();
+            this.nextStepHandle = null;
+            return;
+          }
+
+          // Process the next chunk and incorporate the result.
+          const stateUpdate = this.processNext(state, chunks);
+          for (const group of stateUpdate.groups) {
+            assertIsDefined(this.consumer, 'consumer');
+            this.consumer.addGroup(group);
+            currentBatch += group.lines.length;
+          }
+          state.lineNums.left += stateUpdate.lineDelta.left;
+          state.lineNums.right += stateUpdate.lineDelta.right;
+
+          // Increment the index and recurse.
+          state.chunkIndex = stateUpdate.newChunkIndex;
+          if (currentBatch >= this.asyncThreshold) {
+            currentBatch = 0;
+            this.nextStepHandle = window.setTimeout(nextStep, 1);
+          } else {
+            nextStep.call(this);
+          }
+        };
+
+        nextStep.call(this);
+      })
+    );
+    return this.processPromise.finally(() => {
+      this.processPromise = null;
+      window.removeEventListener('scroll', this.handleWindowScroll);
+    });
+  }
+
+  /**
+   * Cancel any jobs that are running.
+   */
+  cancel() {
+    if (this.nextStepHandle !== null) {
+      window.clearTimeout(this.nextStepHandle);
+      this.nextStepHandle = null;
+    }
+    if (this.processPromise) {
+      this.processPromise.cancel();
+    }
+    window.removeEventListener('scroll', this.handleWindowScroll);
+  }
+
+  /**
+   * Process the next uncollapsible chunk, or the next collapsible chunks.
+   */
+  // visible for testing
+  processNext(state: State, chunks: DiffContent[]) {
+    const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
+      chunks,
+      state.chunkIndex
+    );
+    if (firstUncollapsibleChunkIndex === state.chunkIndex) {
+      const chunk = chunks[state.chunkIndex];
+      return {
+        lineDelta: {
+          left: this.linesLeft(chunk).length,
+          right: this.linesRight(chunk).length,
+        },
+        groups: [
+          this.chunkToGroup(
+            chunk,
+            state.lineNums.left + 1,
+            state.lineNums.right + 1
+          ),
+        ],
+        newChunkIndex: state.chunkIndex + 1,
+      };
+    }
+
+    return this.processCollapsibleChunks(
+      state,
+      chunks,
+      firstUncollapsibleChunkIndex
+    );
+  }
+
+  private linesLeft(chunk: DiffContent) {
+    return chunk.ab || chunk.a || [];
+  }
+
+  private linesRight(chunk: DiffContent) {
+    return chunk.ab || chunk.b || [];
+  }
+
+  private firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
+    let chunkIndex = offset;
+    while (
+      chunkIndex < chunks.length &&
+      this.isCollapsibleChunk(chunks[chunkIndex])
+    ) {
+      chunkIndex++;
+    }
+    return chunkIndex;
+  }
+
+  private isCollapsibleChunk(chunk: DiffContent) {
+    return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
+  }
+
+  /**
+   * Process a stretch of collapsible chunks.
+   *
+   * Outputs up to three groups:
+   * 1) Visible context before the hidden common code, unless it's the
+   * very beginning of the file.
+   * 2) Context hidden behind a context bar, unless empty.
+   * 3) Visible context after the hidden common code, unless it's the very
+   * end of the file.
+   */
+  private processCollapsibleChunks(
+    state: State,
+    chunks: DiffContent[],
+    firstUncollapsibleChunkIndex: number
+  ) {
+    const collapsibleChunks = chunks.slice(
+      state.chunkIndex,
+      firstUncollapsibleChunkIndex
+    );
+    const lineCount = collapsibleChunks.reduce(
+      (sum, chunk) => sum + this.commonChunkLength(chunk),
+      0
+    );
+
+    let groups = this.chunksToGroups(
+      collapsibleChunks,
+      state.lineNums.left + 1,
+      state.lineNums.right + 1
+    );
+
+    const hasSkippedGroup = !!groups.find(g => g.skip);
+    if (this.context !== WHOLE_FILE || hasSkippedGroup) {
+      const contextNumLines = this.context > 0 ? this.context : 0;
+      const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
+      const hiddenEnd =
+        lineCount -
+        (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
+      groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
+    }
+
+    return {
+      lineDelta: {
+        left: lineCount,
+        right: lineCount,
+      },
+      groups,
+      newChunkIndex: firstUncollapsibleChunkIndex,
+    };
+  }
+
+  private commonChunkLength(chunk: DiffContent) {
+    if (chunk.skip) {
+      return chunk.skip;
+    }
+    console.assert(!!chunk.ab || !!chunk.common);
+
+    console.assert(
+      !chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
+      'common chunk needs same number of a and b lines: ',
+      chunk
+    );
+    return this.linesLeft(chunk).length;
+  }
+
+  private chunksToGroups(
+    chunks: DiffContent[],
+    offsetLeft: number,
+    offsetRight: number
+  ): GrDiffGroup[] {
+    return chunks.map(chunk => {
+      const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
+      const chunkLength = this.commonChunkLength(chunk);
+      offsetLeft += chunkLength;
+      offsetRight += chunkLength;
+      return group;
+    });
+  }
+
+  private chunkToGroup(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ): GrDiffGroup {
+    const type =
+      chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
+    const lines = this.linesFromChunk(chunk, offsetLeft, offsetRight);
+    const options = {
+      moveDetails: chunk.move_details,
+      dueToRebase: !!chunk.due_to_rebase,
+      ignoredWhitespaceOnly: !!chunk.common,
+      keyLocation: !!chunk.keyLocation,
+    };
+    if (chunk.skip !== undefined) {
+      return new GrDiffGroup({
+        type,
+        skip: chunk.skip,
+        offsetLeft,
+        offsetRight,
+        ...options,
+      });
+    } else {
+      return new GrDiffGroup({
+        type,
+        lines,
+        ...options,
+      });
+    }
+  }
+
+  private linesFromChunk(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ) {
+    if (chunk.ab) {
+      return chunk.ab.map((row, i) =>
+        this.lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
+      );
+    }
+    let lines: GrDiffLine[] = [];
+    if (chunk.a) {
+      // Avoiding a.push(...b) because that causes callstack overflows for
+      // large b, which can occur when large files are added removed.
+      lines = lines.concat(
+        this.linesFromRows(
+          GrDiffLineType.REMOVE,
+          chunk.a,
+          offsetLeft,
+          chunk.edit_a
+        )
+      );
+    }
+    if (chunk.b) {
+      // Avoiding a.push(...b) because that causes callstack overflows for
+      // large b, which can occur when large files are added removed.
+      lines = lines.concat(
+        this.linesFromRows(
+          GrDiffLineType.ADD,
+          chunk.b,
+          offsetRight,
+          chunk.edit_b
+        )
+      );
+    }
+    return lines;
+  }
+
+  // visible for testing
+  linesFromRows(
+    lineType: GrDiffLineType,
+    rows: string[],
+    offset: number,
+    intralineInfos?: number[][]
+  ): GrDiffLine[] {
+    const grDiffHighlights = intralineInfos
+      ? this.convertIntralineInfos(rows, intralineInfos)
+      : undefined;
+    return rows.map((row, i) =>
+      this.lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
+    );
+  }
+
+  private lineFromRow(
+    type: GrDiffLineType,
+    offsetLeft: number,
+    offsetRight: number,
+    row: string,
+    i: number,
+    highlights?: Highlights[]
+  ): GrDiffLine {
+    const line = new GrDiffLine(type);
+    line.text = row;
+    if (type !== GrDiffLineType.ADD) line.beforeNumber = offsetLeft + i;
+    if (type !== GrDiffLineType.REMOVE) line.afterNumber = offsetRight + i;
+    if (highlights) {
+      line.hasIntralineInfo = true;
+      line.highlights = highlights.filter(hl => hl.contentIndex === i);
+    } else {
+      line.hasIntralineInfo = false;
+    }
+    return line;
+  }
+
+  private makeGroup(number: LineNumber) {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.beforeNumber = number;
+    line.afterNumber = number;
+    return new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [line]});
+  }
+
+  /**
+   * Split chunks into smaller chunks of the same kind.
+   *
+   * This is done to prevent doing too much work on the main thread in one
+   * uninterrupted rendering step, which would make the browser unresponsive.
+   *
+   * Note that in the case of unmodified chunks, we only split chunks if the
+   * context is set to file (because otherwise they are split up further down
+   * the processing into the visible and hidden context), and only split it
+   * into 2 chunks, one max sized one and the rest (for reasons that are
+   * unclear to me).
+   *
+   * @param chunks Chunks as returned from the server
+   * @return Finer grained chunks.
+   */
+  // visible for testing
+  splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
+    const newChunks = [];
+
+    for (const chunk of chunks) {
+      if (!chunk.ab) {
+        for (const subChunk of this.breakdownChunk(chunk)) {
+          newChunks.push(subChunk);
+        }
+        continue;
+      }
+
+      // If the context is set to "whole file", then break down the shared
+      // chunks so they can be rendered incrementally. Note: this is not
+      // enabled for any other context preference because manipulating the
+      // chunks in this way violates assumptions by the context grouper logic.
+      const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
+      if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
+        // Split large shared chunks in two, where the first is the maximum
+        // group size.
+        newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
+        newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
+      } else {
+        newChunks.push(chunk);
+      }
+    }
+    return newChunks;
+  }
+
+  /**
+   * In order to show key locations, such as comments, out of the bounds of
+   * the selected context, treat them as separate chunks within the model so
+   * that the content (and context surrounding it) renders correctly.
+   *
+   * @param chunks DiffContents as returned from server.
+   * @return Finer grained DiffContents.
+   */
+  // visible for testing
+  splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
+    const result = [];
+    let leftLineNum = 1;
+    let rightLineNum = 1;
+
+    for (const chunk of chunks) {
+      // If it isn't a common chunk, append it as-is and update line numbers.
+      if (!chunk.ab && !chunk.skip && !chunk.common) {
+        if (chunk.a) {
+          leftLineNum += chunk.a.length;
+        }
+        if (chunk.b) {
+          rightLineNum += chunk.b.length;
+        }
+        result.push(chunk);
+        continue;
+      }
+
+      if (chunk.common && chunk.a!.length !== chunk.b!.length) {
+        throw new Error(
+          'DiffContent with common=true must always have equal length'
+        );
+      }
+      const numLines = this.commonChunkLength(chunk);
+      const chunkEnds = this.findChunkEndsAtKeyLocations(
+        numLines,
+        leftLineNum,
+        rightLineNum
+      );
+      leftLineNum += numLines;
+      rightLineNum += numLines;
+
+      if (chunk.skip) {
+        result.push({
+          ...chunk,
+          skip: chunk.skip,
+          keyLocation: false,
+        });
+      } else if (chunk.ab) {
+        result.push(
+          ...this.splitAtChunkEnds(chunk.ab, chunkEnds).map(
+            ({lines, keyLocation}) => {
+              return {
+                ...chunk,
+                ab: lines,
+                keyLocation,
+              };
+            }
+          )
+        );
+      } else if (chunk.common) {
+        const aChunks = this.splitAtChunkEnds(chunk.a!, chunkEnds);
+        const bChunks = this.splitAtChunkEnds(chunk.b!, chunkEnds);
+        result.push(
+          ...aChunks.map(({lines, keyLocation}, i) => {
+            return {
+              ...chunk,
+              a: lines,
+              b: bChunks[i].lines,
+              keyLocation,
+            };
+          })
+        );
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * @return Offsets of the new chunk ends, including whether it's a key
+   * location.
+   */
+  private findChunkEndsAtKeyLocations(
+    numLines: number,
+    leftOffset: number,
+    rightOffset: number
+  ): ChunkEnd[] {
+    const result = [];
+    let lastChunkEnd = 0;
+    for (let i = 0; i < numLines; i++) {
+      // If this line should not be collapsed.
+      if (
+        this.keyLocations[Side.LEFT][leftOffset + i] ||
+        this.keyLocations[Side.RIGHT][rightOffset + i]
+      ) {
+        // If any lines have been accumulated into the chunk leading up to
+        // this non-collapse line, then add them as a chunk and start a new
+        // one.
+        if (i > lastChunkEnd) {
+          result.push({offset: i, keyLocation: false});
+          lastChunkEnd = i;
+        }
+
+        // Add the non-collapse line as its own chunk.
+        result.push({offset: i + 1, keyLocation: true});
+      }
+    }
+
+    if (numLines > lastChunkEnd) {
+      result.push({offset: numLines, keyLocation: false});
+    }
+
+    return result;
+  }
+
+  private splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
+    const result = [];
+    let lastChunkEndOffset = 0;
+    for (const {offset, keyLocation} of chunkEnds) {
+      if (lastChunkEndOffset === offset) continue;
+      result.push({
+        lines: lines.slice(lastChunkEndOffset, offset),
+        keyLocation,
+      });
+      lastChunkEndOffset = offset;
+    }
+    return result;
+  }
+
+  /**
+   * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
+   * for rendering.
+   */
+  // visible for testing
+  convertIntralineInfos(
+    rows: string[],
+    intralineInfos: number[][]
+  ): Highlights[] {
+    // +1 to account for the \n that is not part of the rows passed here
+    const lineLengths = rows.map(r => GrAnnotation.getStringLength(r) + 1);
+
+    let rowIndex = 0;
+    let idx = 0;
+    const normalized = [];
+    for (const [skipLength, markLength] of intralineInfos) {
+      let lineLength = lineLengths[rowIndex];
+      let j = 0;
+      while (j < skipLength) {
+        if (idx === lineLength) {
+          idx = 0;
+          lineLength = lineLengths[++rowIndex];
+          continue;
+        }
+        idx++;
+        j++;
+      }
+      let lineHighlight: Highlights = {
+        contentIndex: rowIndex,
+        startIndex: idx,
+      };
+
+      j = 0;
+      while (lineLength && j < markLength) {
+        if (idx === lineLength) {
+          idx = 0;
+          lineLength = lineLengths[++rowIndex];
+          normalized.push(lineHighlight);
+          lineHighlight = {
+            contentIndex: rowIndex,
+            startIndex: idx,
+          };
+          continue;
+        }
+        idx++;
+        j++;
+      }
+      lineHighlight.endIndex = idx;
+      normalized.push(lineHighlight);
+    }
+    return normalized;
+  }
+
+  /**
+   * If a group is an addition or a removal, break it down into smaller groups
+   * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
+   * or a delta it is returned as the single element of the result array.
+   */
+  // visible for testing
+  breakdownChunk(chunk: DiffContent): DiffContent[] {
+    let key: 'a' | 'b' | 'ab' | null = null;
+    const {a, b, ab, move_details} = chunk;
+    if (a?.length && !b?.length) {
+      key = 'a';
+    } else if (b?.length && !a?.length) {
+      key = 'b';
+    } else if (ab?.length) {
+      key = 'ab';
+    }
+
+    // Move chunks should not be divided because of move label
+    // positioned in the top of the chunk
+    if (!key || move_details) {
+      return [chunk];
+    }
+
+    const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
+    return this.breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
+      const subChunk: DiffContent = {};
+      subChunk[key!] = subChunkLines;
+      if (chunk.due_to_rebase) {
+        subChunk.due_to_rebase = true;
+      }
+      if (chunk.move_details) {
+        subChunk.move_details = chunk.move_details;
+      }
+      return subChunk;
+    });
+  }
+
+  /**
+   * Given an array and a size, return an array of arrays where no inner array
+   * is larger than that size, preserving the original order.
+   */
+  // visible for testing
+  breakdown<T>(array: T[], size: number): T[][] {
+    if (!array.length) {
+      return [];
+    }
+    if (array.length < size) {
+      return [array];
+    }
+
+    const head = array.slice(0, array.length - size);
+    const tail = array.slice(array.length - size);
+
+    return this.breakdown(head, size).concat([tail]);
+  }
+
+  updateRenderPrefs(renderPrefs: RenderPreferences) {
+    if (renderPrefs.num_lines_rendered_at_once) {
+      this.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
+    }
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
new file mode 100644
index 0000000..f6f7052
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -0,0 +1,1092 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-processor';
+import {GrDiffLineType, FILE, GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffProcessor, State} from './gr-diff-processor';
+import {DiffContent} from '../../../types/diff';
+import {assert} from '@open-wc/testing';
+
+suite('gr-diff-processor tests', () => {
+  const WHOLE_FILE = -1;
+  const loremIpsum =
+    'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+    'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
+    'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+    'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+    'fugit assum per.';
+
+  let element: GrDiffProcessor;
+  let groups: GrDiffGroup[];
+
+  setup(() => {});
+
+  suite('not logged in', () => {
+    setup(() => {
+      groups = [];
+      element = new GrDiffProcessor();
+      element.consumer = {
+        addGroup(group: GrDiffGroup) {
+          groups.push(group);
+        },
+        clearGroups() {
+          groups = [];
+        },
+      };
+      element.context = 4;
+    });
+
+    test('process loaded content', () => {
+      const content: DiffContent[] = [
+        {
+          ab: ['<!DOCTYPE html>', '<meta charset="utf-8">'],
+        },
+        {
+          a: ['  Welcome ', '  to the wooorld of tomorrow!'],
+          b: ['  Hello, world!'],
+        },
+        {
+          ab: [
+            'Leela: This is the only place the ship can’t hear us, so ',
+            'everyone pretend to shower.',
+            'Fry: Same as every day. Got it.',
+          ],
+        },
+      ];
+
+      return element.process(content, false).then(() => {
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+        assert.equal(groups.length, 4);
+
+        let group = groups[0];
+        assert.equal(group.type, GrDiffGroupType.BOTH);
+        assert.equal(group.lines.length, 1);
+        assert.equal(group.lines[0].text, '');
+        assert.equal(group.lines[0].beforeNumber, FILE);
+        assert.equal(group.lines[0].afterNumber, FILE);
+
+        group = groups[1];
+        assert.equal(group.type, GrDiffGroupType.BOTH);
+        assert.equal(group.lines.length, 2);
+
+        function beforeNumberFn(l: GrDiffLine) {
+          return l.beforeNumber;
+        }
+        function afterNumberFn(l: GrDiffLine) {
+          return l.afterNumber;
+        }
+        function textFn(l: GrDiffLine) {
+          return l.text;
+        }
+
+        assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+        assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+        assert.deepEqual(group.lines.map(textFn), [
+          '<!DOCTYPE html>',
+          '<meta charset="utf-8">',
+        ]);
+
+        group = groups[2];
+        assert.equal(group.type, GrDiffGroupType.DELTA);
+        assert.equal(group.lines.length, 3);
+        assert.equal(group.adds.length, 1);
+        assert.equal(group.removes.length, 2);
+        assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+        assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+        assert.deepEqual(group.removes.map(textFn), [
+          '  Welcome ',
+          '  to the wooorld of tomorrow!',
+        ]);
+        assert.deepEqual(group.adds.map(textFn), ['  Hello, world!']);
+
+        group = groups[3];
+        assert.equal(group.type, GrDiffGroupType.BOTH);
+        assert.equal(group.lines.length, 3);
+        assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+        assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+        assert.deepEqual(group.lines.map(textFn), [
+          'Leela: This is the only place the ship can’t hear us, so ',
+          'everyone pretend to shower.',
+          'Fry: Same as every day. Got it.',
+        ]);
+      });
+    });
+
+    test('first group is for file', () => {
+      const content = [{b: ['foo']}];
+
+      return element.process(content, false).then(() => {
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        assert.equal(groups[0].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[0].lines.length, 1);
+        assert.equal(groups[0].lines[0].text, '');
+        assert.equal(groups[0].lines[0].beforeNumber, FILE);
+        assert.equal(groups[0].lines[0].afterNumber, FILE);
+      });
+    });
+
+    suite('context groups', () => {
+      test('at the beginning, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {
+            ab: new Array(100).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+
+          assert.equal(groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.instanceOf(groups[1].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[1].contextGroups[0].lines.length, 90);
+          for (const l of groups[1].contextGroups[0].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+        });
+      });
+
+      test('at the beginning with skip chunks', async () => {
+        element.context = 10;
+        const content = [
+          {ab: new Array(20).fill('all work and no play make jack a dull boy')},
+          {skip: 43900},
+          {ab: new Array(30).fill('some other content')},
+          {a: ['some other content']},
+        ];
+
+        await element.process(content, false);
+
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        // group[0] is the file group
+
+        const commonGroup = groups[1];
+
+        // Hidden context before
+        assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+        assert.equal(commonGroup.contextGroups[0].lines.length, 20);
+        for (const l of commonGroup.contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
+
+        // Skipped group
+        const skipGroup = commonGroup.contextGroups[1];
+        assert.equal(skipGroup.skip, 43900);
+        const expectedRange = {
+          left: {start_line: 21, end_line: 43920},
+          right: {start_line: 21, end_line: 43920},
+        };
+        assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+        // Hidden context after
+        assert.equal(commonGroup.contextGroups[2].lines.length, 20);
+        for (const l of commonGroup.contextGroups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+
+        // Displayed lines
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 10);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+      });
+
+      test('at the beginning, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {ab: new Array(5).fill('all work and no play make jack a dull boy')},
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+
+          assert.equal(groups[1].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[1].lines.length, 5);
+          for (const l of groups[1].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+        });
+      });
+
+      test('at the end, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: new Array(100).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].contextGroups[0].lines.length, 90);
+          for (const l of groups[3].contextGroups[0].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('at the end, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(5).fill('all work and no play make jill a dull girl')},
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 5);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('for interleaved ab and common: true chunks', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
+          {
+            a: new Array(3).fill('all work and no play make jill a dull girl'),
+            b: new Array(3).fill(
+              '  all work and no play make jill a dull girl'
+            ),
+            common: true,
+          },
+          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
+          {
+            a: new Array(3).fill('all work and no play make jill a dull girl'),
+            b: new Array(3).fill(
+              '  all work and no play make jill a dull girl'
+            ),
+            common: true,
+          },
+          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          // The first three interleaved chunks are completely shown because
+          // they are part of the context (3 * 3 <= 10)
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 3);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroupType.DELTA);
+          assert.equal(groups[3].lines.length, 6);
+          assert.equal(groups[3].adds.length, 3);
+          assert.equal(groups[3].removes.length, 3);
+          for (const l of groups[3].removes) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[3].adds) {
+            assert.equal(
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
+          }
+
+          assert.equal(groups[4].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[4].lines.length, 3);
+          for (const l of groups[4].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+
+          // The next chunk is partially shown, so it results in two groups
+
+          assert.equal(groups[5].type, GrDiffGroupType.DELTA);
+          assert.equal(groups[5].lines.length, 2);
+          assert.equal(groups[5].adds.length, 1);
+          assert.equal(groups[5].removes.length, 1);
+          for (const l of groups[5].removes) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[5].adds) {
+            assert.equal(
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
+          }
+
+          assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.equal(groups[6].contextGroups.length, 2);
+
+          assert.equal(groups[6].contextGroups[0].lines.length, 4);
+          assert.equal(groups[6].contextGroups[0].removes.length, 2);
+          assert.equal(groups[6].contextGroups[0].adds.length, 2);
+          for (const l of groups[6].contextGroups[0].removes) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[6].contextGroups[0].adds) {
+            assert.equal(
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
+          }
+
+          // The final chunk is completely hidden
+          assert.equal(groups[6].contextGroups[1].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[6].contextGroups[1].lines.length, 3);
+          for (const l of groups[6].contextGroups[1].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('in the middle, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: new Array(100).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].contextGroups[0].lines.length, 80);
+          for (const l of groups[3].contextGroups[0].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[4].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[4].lines.length, 10);
+          for (const l of groups[4].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('in the middle, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: new Array(5).fill('all work and no play make jill a dull girl')},
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 5);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+    });
+
+    test('in the middle with skip chunks', async () => {
+      element.context = 10;
+      const content = [
+        {a: ['all work and no play make andybons a dull boy']},
+        {ab: new Array(20).fill('all work and no play make jill a dull girl')},
+        {skip: 60},
+        {ab: new Array(20).fill('all work and no play make jill a dull girl')},
+        {a: ['all work and no play make andybons a dull boy']},
+      ];
+
+      await element.process(content, false);
+
+      groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+      // group[0] is the file group
+      // group[1] is the chunk with a
+      // group[2] is the displayed part of ab before
+
+      const commonGroup = groups[3];
+
+      // Hidden context before
+      assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+      assert.equal(commonGroup.contextGroups[0].lines.length, 10);
+      for (const l of commonGroup.contextGroups[0].lines) {
+        assert.equal(l.text, 'all work and no play make jill a dull girl');
+      }
+
+      // Skipped group
+      const skipGroup = commonGroup.contextGroups[1];
+      assert.equal(skipGroup.skip, 60);
+      const expectedRange = {
+        left: {start_line: 22, end_line: 81},
+        right: {start_line: 21, end_line: 80},
+      };
+      assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+      // Hidden context after
+      assert.equal(commonGroup.contextGroups[2].lines.length, 10);
+      for (const l of commonGroup.contextGroups[2].lines) {
+        assert.equal(l.text, 'all work and no play make jill a dull girl');
+      }
+      // group[4] is the displayed part of the second ab
+    });
+
+    test('works with skip === 0', async () => {
+      element.context = 3;
+      const content = [
+        {
+          skip: 0,
+        },
+        {
+          b: [
+            '/**',
+            ' * @license',
+            ' * Copyright 2015 Google LLC',
+            ' * SPDX-License-Identifier: Apache-2.0',
+            ' */',
+            "import '../../../test/common-test-setup';",
+          ],
+        },
+      ];
+      await element.process(content, false);
+    });
+
+    test('break up common diff chunks', () => {
+      element.keyLocations = {
+        left: {1: true},
+        right: {10: true},
+      };
+
+      const content = [
+        {
+          ab: [
+            'copy',
+            '',
+            'asdf',
+            'qwer',
+            'zxcv',
+            '',
+            'http',
+            '',
+            'vbnm',
+            'dfgh',
+            'yuio',
+            'sdfg',
+            '1234',
+          ],
+        },
+      ];
+      const result = element.splitCommonChunksWithKeyLocations(content);
+      assert.deepEqual(result, [
+        {
+          ab: ['copy'],
+          keyLocation: true,
+        },
+        {
+          ab: ['', 'asdf', 'qwer', 'zxcv', '', 'http', '', 'vbnm'],
+          keyLocation: false,
+        },
+        {
+          ab: ['dfgh'],
+          keyLocation: true,
+        },
+        {
+          ab: ['yuio', 'sdfg', '1234'],
+          keyLocation: false,
+        },
+      ]);
+    });
+
+    test('breaks down shared chunks w/ whole-file', () => {
+      const maxGroupSize = 128;
+      const size = maxGroupSize * 2 + 5;
+      const ab = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      const content = [{ab}];
+      element.context = -1;
+      const result = element.splitLargeChunks(content);
+      assert.equal(result.length, 2);
+      assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
+      assert.deepEqual(result[1].ab, content[0].ab.slice(maxGroupSize));
+    });
+
+    test('breaks down added chunks', () => {
+      const maxGroupSize = 128;
+      const size = maxGroupSize * 2 + 5;
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${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, maxGroupSize + 5));
+      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
+    });
+
+    test('breaks down removed chunks', () => {
+      const maxGroupSize = 128;
+      const size = maxGroupSize * 2 + 5;
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${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, maxGroupSize + 5));
+      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
+    });
+
+    test('does not break down moved chunks', () => {
+      const size = 120 * 2 + 5;
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      element.context = 5;
+      const splitContent = element
+        .splitLargeChunks([
+          {
+            a: content,
+            b: [],
+            move_details: {changed: false, range: {start: 1, end: 1}},
+          },
+        ])
+        .map(r => r.a);
+      assert.equal(splitContent.length, 1);
+      assert.deepEqual(splitContent[0], content);
+    });
+
+    test('does not break-down common chunks w/ context', () => {
+      const ab = Array(75)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      const content = [{ab}];
+      element.context = 4;
+      const result = element.splitCommonChunksWithKeyLocations(content);
+      assert.equal(result.length, 1);
+      assert.deepEqual(result[0].ab, content[0].ab);
+      assert.isFalse(result[0].keyLocation);
+    });
+
+    test('intraline normalization', () => {
+      // The content and highlights are in the format returned by the Gerrit
+      // REST API.
+      let content = [
+        '      <section class="summary">',
+        '        <gr-formatted-text content="' +
+          '[[_computeCurrentRevisionMessage(change)]]"></gr-formatted-text>',
+        '      </section>',
+      ];
+      let highlights = [
+        [31, 34],
+        [42, 26],
+      ];
+
+      let results = element.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 31,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+          endIndex: 33,
+        },
+        {
+          contentIndex: 1,
+          endIndex: 101,
+          startIndex: 75,
+        },
+      ]);
+      const lines = element.linesFromRows(
+        GrDiffLineType.BOTH,
+        content,
+        0,
+        highlights
+      );
+      assert.equal(lines.length, 3);
+      assert.isTrue(lines[0].hasIntralineInfo);
+      assert.equal(lines[0].highlights.length, 1);
+      assert.isTrue(lines[1].hasIntralineInfo);
+      assert.equal(lines[1].highlights.length, 2);
+      assert.isTrue(lines[2].hasIntralineInfo);
+      assert.equal(lines[2].highlights.length, 0);
+
+      content = [
+        '        this._path = value.path;',
+        '',
+        '        // When navigating away from the page, there is a ' +
+          'possibility that the',
+        '        // patch number is no longer a part of the URL ' +
+          '(say when navigating to',
+        '        // the top-level change info view) and therefore ' +
+          'undefined in `params`.',
+        '        if (!this._patchRange.patchNum) {',
+      ];
+      highlights = [
+        [14, 17],
+        [11, 70],
+        [12, 67],
+        [12, 67],
+        [14, 29],
+      ];
+      results = element.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 14,
+          endIndex: 31,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 8,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 3,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 4,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 5,
+          startIndex: 12,
+          endIndex: 41,
+        },
+      ]);
+
+      content = ['🙈 a', '🙉 b', '🙊 c'];
+      highlights = [[2, 7]];
+      results = element.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 2,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 1,
+        },
+      ]);
+    });
+
+    test('scrolling pauses rendering', () => {
+      const content = Array(200).fill({ab: ['', '']});
+      element.isScrolling = true;
+      element.process(content, false);
+      // Just the files group - no more processing during scrolling.
+      assert.equal(groups.length, 2);
+
+      element.isScrolling = false;
+      element.process(content, false);
+      // More groups have been processed. How many does not matter here.
+      assert.isAtLeast(groups.length, 3);
+    });
+
+    test('image diffs', () => {
+      const content = Array(200).fill({ab: ['', '']});
+      element.process(content, true);
+      assert.equal(groups.length, 2);
+
+      // Image diffs don't process content, just the 'FILE' line.
+      assert.equal(groups[0].lines.length, 1);
+    });
+
+    suite('processNext', () => {
+      let rows: string[];
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
+      });
+
+      test('WHOLE_FILE', () => {
+        element.context = WHOLE_FILE;
+        const state: State = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+
+        // Results in one, uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1);
+        assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+        assert.equal(result.groups[0].lines.length, rows.length);
+
+        // Line numbers are set correctly.
+        assert.equal(
+          result.groups[0].lines[0].beforeNumber,
+          state.lineNums.left + 1
+        );
+        assert.equal(
+          result.groups[0].lines[0].afterNumber,
+          state.lineNums.right + 1
+        );
+
+        assert.equal(
+          result.groups[0].lines[rows.length - 1].beforeNumber,
+          state.lineNums.left + rows.length
+        );
+        assert.equal(
+          result.groups[0].lines[rows.length - 1].afterNumber,
+          state.lineNums.right + rows.length
+        );
+      });
+
+      test('WHOLE_FILE with skip chunks still get collapsed', () => {
+        element.context = WHOLE_FILE;
+        const lineNums = {left: 10, right: 100};
+        const state = {
+          lineNums,
+          chunkIndex: 1,
+        };
+        const skip = 10000;
+        const chunks = [{a: ['foo']}, {skip}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+        // Results in one, uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1);
+        assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
+
+        // Skip and ab group are hidden in the same context control
+        assert.equal(result.groups[0].contextGroups.length, 2);
+        const [skippedGroup, abGroup] = result.groups[0].contextGroups;
+
+        // Line numbers are set correctly.
+        assert.deepEqual(skippedGroup.lineRange, {
+          left: {
+            start_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_line: lineNums.left + skip + 1,
+            end_line: lineNums.left + skip + rows.length,
+          },
+          right: {
+            start_line: lineNums.right + skip + 1,
+            end_line: lineNums.right + skip + rows.length,
+          },
+        });
+      });
+
+      test('with context', () => {
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+        const expectedCollapseSize = rows.length - 2 * element.context;
+
+        assert.equal(result.groups.length, 3, 'Results in three groups');
+
+        // The first and last are uncollapsed context, whereas the middle has
+        // a single context-control line.
+        assert.equal(result.groups[0].lines.length, element.context);
+        assert.equal(result.groups[2].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(
+          result.groups[1].contextGroups[0].lines.length,
+          expectedCollapseSize
+        );
+      });
+
+      test('first', () => {
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+        const expectedCollapseSize = rows.length - element.context;
+
+        assert.equal(result.groups.length, 2, 'Results in two groups');
+
+        // Only the first group is collapsed.
+        assert.equal(result.groups[1].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(
+          result.groups[0].contextGroups[0].lines.length,
+          expectedCollapseSize
+        );
+      });
+
+      test('few-rows', () => {
+        // Only ten rows.
+        rows = rows.slice(0, 10);
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      test('no single line collapse', () => {
+        rows = rows.slice(0, 7);
+        element.context = 3;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      suite('with key location', () => {
+        let state: State;
+        let chunks: DiffContent[];
+
+        setup(() => {
+          state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 0,
+          };
+          element.context = 10;
+          chunks = [{ab: rows}, {ab: ['foo'], keyLocation: true}, {ab: rows}];
+        });
+
+        test('context before', () => {
+          state.chunkIndex = 0;
+          const result = element.processNext(state, chunks);
+
+          // The first chunk is split into two groups:
+          // 1) A context-control, hiding everything but the context before
+          //    the key location.
+          // 2) The context before the key location.
+          // The key location is not processed in this call to processNext
+          assert.equal(result.groups.length, 2);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(
+            result.groups[0].contextGroups[0].lines.length,
+            rows.length - element.context
+          );
+          assert.equal(result.groups[1].lines.length, element.context);
+        });
+
+        test('key location itself', () => {
+          state.chunkIndex = 1;
+          const result = element.processNext(state, chunks);
+
+          // The second chunk results in a single group, that is just the
+          // line with the key location
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].lines.length, 1);
+          assert.equal(result.lineDelta.left, 1);
+          assert.equal(result.lineDelta.right, 1);
+        });
+
+        test('context after', () => {
+          state.chunkIndex = 2;
+          const result = element.processNext(state, chunks);
+
+          // The last chunk is split into two groups:
+          // 1) The context after the key location.
+          // 1) A context-control, hiding everything but the context after the
+          //    key location.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].lines.length, element.context);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(
+            result.groups[1].contextGroups[0].lines.length,
+            rows.length - element.context
+          );
+        });
+      });
+    });
+
+    suite('gr-diff-processor helpers', () => {
+      let rows: string[];
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
+      });
+
+      test('linesFromRows', () => {
+        const startLineNum = 10;
+        let result = element.linesFromRows(
+          GrDiffLineType.ADD,
+          rows,
+          startLineNum + 1
+        );
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLineType.ADD);
+        assert.notOk(result[0].hasIntralineInfo);
+        assert.equal(result[0].afterNumber, startLineNum + 1);
+        assert.notOk(result[0].beforeNumber);
+        assert.equal(
+          result[result.length - 1].afterNumber,
+          startLineNum + rows.length
+        );
+        assert.notOk(result[result.length - 1].beforeNumber);
+
+        result = element.linesFromRows(
+          GrDiffLineType.REMOVE,
+          rows,
+          startLineNum + 1
+        );
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLineType.REMOVE);
+        assert.notOk(result[0].hasIntralineInfo);
+        assert.equal(result[0].beforeNumber, startLineNum + 1);
+        assert.notOk(result[0].afterNumber);
+        assert.equal(
+          result[result.length - 1].beforeNumber,
+          startLineNum + rows.length
+        );
+        assert.notOk(result[result.length - 1].afterNumber);
+      });
+    });
+
+    suite('breakdown*', () => {
+      test('breakdownChunk breaks down additions', () => {
+        const breakdownSpy = sinon.spy(element, 'breakdown');
+        const chunk = {b: ['blah', 'blah', 'blah']};
+        const result = element.breakdownChunk(chunk);
+        assert.deepEqual(result, [chunk]);
+        assert.isTrue(breakdownSpy.called);
+      });
+
+      test('breakdownChunk keeps due_to_rebase for broken down additions', () => {
+        sinon.spy(element, 'breakdown');
+        const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
+        const result = element.breakdownChunk(chunk);
+        for (const subResult of result) {
+          assert.isTrue(subResult.due_to_rebase);
+        }
+      });
+
+      test('breakdown common case', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
+          ' '
+        );
+        const size = 3;
+
+        const result = element.breakdown(array, size);
+
+        for (const subResult of result) {
+          assert.isAtMost(subResult.length, size);
+        }
+        const flattened = result.reduce((a, b) => a.concat(b), []);
+        assert.deepEqual(flattened, array);
+      });
+
+      test('breakdown smaller than size', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
+          ' '
+        );
+        const size = 10;
+        const expected = [array];
+
+        const result = element.breakdown(array, size);
+
+        assert.deepEqual(result, expected);
+      });
+
+      test('breakdown empty', () => {
+        const array: string[] = [];
+        const size = 10;
+        const expected: string[][] = [];
+
+        const result = element.breakdown(array, size);
+
+        assert.deepEqual(result, expected);
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
new file mode 100644
index 0000000..a790736
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
@@ -0,0 +1,247 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import {normalize} from '../gr-diff-highlight/gr-range-normalizer';
+import {
+  descendedFromClass,
+  parentWithClass,
+  querySelectorAll,
+} from '../../../utils/dom-util';
+import {DiffInfo} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {
+  getLineElByChild,
+  getSide,
+  getSideByLineEl,
+  isThreadEl,
+} from '../gr-diff/gr-diff-utils';
+
+/**
+ * Possible CSS classes indicating the state of selection. Dynamically added/
+ * removed based on where the user clicks within the diff.
+ */
+const SelectionClass = {
+  COMMENT: 'selected-comment',
+  LEFT: 'selected-left',
+  RIGHT: 'selected-right',
+  BLAME: 'selected-blame',
+};
+
+function selectionClassForSide(side?: Side) {
+  return side === Side.LEFT ? SelectionClass.LEFT : SelectionClass.RIGHT;
+}
+
+interface LinesCache {
+  left: string[] | null;
+  right: string[] | null;
+}
+
+function getNewCache(): LinesCache {
+  return {left: null, right: null};
+}
+
+export class GrDiffSelection {
+  // visible for testing
+  diff?: DiffInfo;
+
+  // visible for testing
+  diffTable?: HTMLElement;
+
+  // visible for testing
+  linesCache: LinesCache = getNewCache();
+
+  init(diff: DiffInfo, diffTable: HTMLElement) {
+    this.cleanup();
+    this.diff = diff;
+    this.diffTable = diffTable;
+    this.diffTable.classList.add(SelectionClass.RIGHT);
+    this.diffTable.addEventListener('copy', this.handleCopy);
+    this.diffTable.addEventListener('mousedown', this.handleDown);
+    this.linesCache = getNewCache();
+  }
+
+  cleanup() {
+    if (!this.diffTable) return;
+    this.diffTable.removeEventListener('copy', this.handleCopy);
+    this.diffTable.removeEventListener('mousedown', this.handleDown);
+  }
+
+  handleDown = (e: Event) => {
+    const target = e.target;
+    if (!(target instanceof Element)) return;
+
+    const commentEl = parentWithClass(target, 'comment-thread', this.diffTable);
+    if (commentEl && isThreadEl(commentEl)) {
+      this.setClasses([
+        SelectionClass.COMMENT,
+        selectionClassForSide(getSide(commentEl)),
+      ]);
+      return;
+    }
+
+    const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
+    if (blameSelected) {
+      this.setClasses([SelectionClass.BLAME]);
+      return;
+    }
+
+    // This works for both, the content and the line number cells.
+    const lineEl = getLineElByChild(target);
+    if (lineEl) {
+      this.setClasses([selectionClassForSide(getSideByLineEl(lineEl))]);
+      return;
+    }
+  };
+
+  /**
+   * Set the provided list of classes on the element, to the exclusion of all
+   * other SelectionClass values.
+   */
+  setClasses(targetClasses: string[]) {
+    if (!this.diffTable) return;
+    // Remove any selection classes that do not belong.
+    for (const className of Object.values(SelectionClass)) {
+      if (!targetClasses.includes(className)) {
+        this.diffTable.classList.remove(className);
+      }
+    }
+    // Add new selection classes iff they are not already present.
+    for (const targetClass of targetClasses) {
+      if (!this.diffTable.classList.contains(targetClass)) {
+        this.diffTable.classList.add(targetClass);
+      }
+    }
+  }
+
+  handleCopy = (e: ClipboardEvent) => {
+    const target = e.composedPath()[0];
+    if (!(target instanceof Element)) return;
+    if (target instanceof HTMLTextAreaElement) return;
+    if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
+    if (!this.diffTable) return;
+    if (this.diffTable.classList.contains(SelectionClass.COMMENT)) return;
+
+    const lineEl = getLineElByChild(target);
+    if (!lineEl) return;
+    const side = getSideByLineEl(lineEl);
+    const text = this.getSelectedText(side);
+    if (text && e.clipboardData) {
+      e.clipboardData.setData('Text', text);
+      e.preventDefault();
+    }
+  };
+
+  getSelection() {
+    const diffHosts = querySelectorAll(document.body, 'gr-diff');
+    if (!diffHosts.length) return document.getSelection();
+
+    const curDiffHost = diffHosts.find(diffHost => {
+      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
+      return selection && selection.type !== 'None';
+    });
+
+    return curDiffHost?.shadowRoot?.getSelection
+      ? curDiffHost.shadowRoot.getSelection()
+      : document.getSelection();
+  }
+
+  /**
+   * Get the text of the current selection. If commentSelected is
+   * true, it returns only the text of comments within the selection.
+   * Otherwise it returns the text of the selected diff region.
+   *
+   * @param side The side that is selected.
+   * @param commentSelected Whether or not a comment is selected.
+   * @return The selected text.
+   */
+  getSelectedText(side: Side) {
+    const sel = this.getSelection();
+    if (!sel || sel.rangeCount !== 1) {
+      return ''; // No multi-select support yet.
+    }
+    const range = normalize(sel.getRangeAt(0));
+    const startLineEl = getLineElByChild(range.startContainer);
+    if (!startLineEl) return;
+    const endLineEl = getLineElByChild(range.endContainer);
+    // Happens when triple click in side-by-side mode with other side empty.
+    const endsAtOtherEmptySide =
+      !endLineEl &&
+      range.endOffset === 0 &&
+      range.endContainer.nodeName === 'TD' &&
+      range.endContainer instanceof HTMLTableCellElement &&
+      (range.endContainer.classList.contains('left') ||
+        range.endContainer.classList.contains('right'));
+    const startLineDataValue = startLineEl.getAttribute('data-value');
+    if (!startLineDataValue) return;
+    const startLineNum = Number(startLineDataValue);
+    let endLineNum;
+    if (endsAtOtherEmptySide) {
+      endLineNum = startLineNum + 1;
+    } else if (endLineEl) {
+      const endLineDataValue = endLineEl.getAttribute('data-value');
+      if (endLineDataValue) endLineNum = Number(endLineDataValue);
+    }
+
+    return this.getRangeFromDiff(
+      startLineNum,
+      range.startOffset,
+      endLineNum,
+      range.endOffset,
+      side
+    );
+  }
+
+  /**
+   * Query the diff object for the selected lines.
+   */
+  getRangeFromDiff(
+    startLineNum: number,
+    startOffset: number,
+    endLineNum: number | undefined,
+    endOffset: number,
+    side: Side
+  ) {
+    const skipChunk = this.diff?.content.find(chunk => chunk.skip);
+    if (skipChunk) {
+      startLineNum -= skipChunk.skip!;
+      if (endLineNum) endLineNum -= skipChunk.skip!;
+    }
+    const lines = this.getDiffLines(side).slice(startLineNum - 1, endLineNum);
+    if (lines.length) {
+      lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
+      lines[0] = lines[0].substring(startOffset);
+    }
+    return lines.join('\n');
+  }
+
+  /**
+   * Query the diff object for the lines from a particular side.
+   *
+   * @param side The side that is currently selected.
+   * @return An array of strings indexed by line number.
+   */
+  getDiffLines(side: Side): string[] {
+    if (this.linesCache[side]) {
+      return this.linesCache[side]!;
+    }
+    if (!this.diff) return [];
+    let lines: string[] = [];
+    for (const chunk of this.diff.content) {
+      if (chunk.ab) {
+        lines = lines.concat(chunk.ab);
+      } else if (side === Side.LEFT && chunk.a) {
+        lines = lines.concat(chunk.a);
+      } else if (side === Side.RIGHT && chunk.b) {
+        lines = lines.concat(chunk.b);
+      }
+    }
+    this.linesCache[side] = lines;
+    return lines;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
new file mode 100644
index 0000000..9e3d288
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -0,0 +1,220 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-selection';
+import '../gr-diff/gr-diff';
+import '../../../elements/shared/gr-comment-thread/gr-comment-thread';
+import {GrDiffSelection} from './gr-diff-selection';
+import {createDiff} from '../../../test/test-data-generators';
+import {DiffInfo, Side} from '../../../api/diff';
+import {fixture, html, assert} from '@open-wc/testing';
+import {mouseDown} from '../../../test/test-utils';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+
+function firstTextNode(el: HTMLElement) {
+  return [...el.childNodes].filter(node => node.nodeType === Node.TEXT_NODE)[0];
+}
+
+suite('gr-diff-selection', () => {
+  let element: GrDiffSelection;
+  let diffTable: HTMLElement;
+  let grDiff: GrDiff;
+
+  const emulateCopyOn = function (target: HTMLElement | null) {
+    const fakeEvent = {
+      target,
+      preventDefault: sinon.stub(),
+      composedPath() {
+        return [target];
+      },
+      clipboardData: {
+        setData: sinon.stub(),
+      },
+    };
+    element.handleCopy(fakeEvent as unknown as ClipboardEvent);
+    return fakeEvent;
+  };
+
+  setup(async () => {
+    grDiff = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+    element = grDiff.diffSelection;
+
+    const diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {
+          a: ['ba ba'],
+          b: ['some other text'],
+        },
+        {
+          a: ['zin'],
+          b: ['more more more'],
+        },
+        {
+          a: ['ga ga'],
+          b: ['some other text'],
+        },
+      ],
+    };
+    grDiff.prefs = createDefaultDiffPrefs();
+    grDiff.renderPrefs = {use_lit_components: true};
+    grDiff.diff = diff;
+    await waitForEventOnce(grDiff, 'render');
+    assert.isOk(element.diffTable);
+    diffTable = element.diffTable!;
+  });
+
+  test('applies selected-left on left side click', () => {
+    diffTable.classList.add('selected-right');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.left');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
+    assert.isTrue(
+      diffTable.classList.contains('selected-left'),
+      'adds selected-left'
+    );
+    assert.isFalse(
+      diffTable.classList.contains('selected-right'),
+      'removes selected-right'
+    );
+  });
+
+  test('applies selected-right on right side click', () => {
+    diffTable.classList.add('selected-left');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.right');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
+    assert.isTrue(
+      diffTable.classList.contains('selected-right'),
+      'adds selected-right'
+    );
+    assert.isFalse(
+      diffTable.classList.contains('selected-left'),
+      'removes selected-left'
+    );
+  });
+
+  test('applies selected-blame on blame click', () => {
+    diffTable.classList.add('selected-left');
+    const blameDiv = document.createElement('div');
+    blameDiv.classList.add('blame');
+    diffTable.appendChild(blameDiv);
+    mouseDown(blameDiv);
+    assert.isTrue(
+      diffTable.classList.contains('selected-blame'),
+      'adds selected-right'
+    );
+    assert.isFalse(
+      diffTable.classList.contains('selected-left'),
+      'removes selected-left'
+    );
+  });
+
+  test('ignores copy for non-content Element', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('.not-diff-row'));
+    assert.isFalse(getSelectedTextStub.called);
+  });
+
+  test('asks for text for left side Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.deepEqual([Side.LEFT], getSelectedTextStub.lastCall.args);
+  });
+
+  test('reacts to copy for content Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.isTrue(getSelectedTextStub.called);
+  });
+
+  test('copy event is prevented for content Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    getSelectedTextStub.returns('test');
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.isTrue(event.preventDefault.called);
+  });
+
+  test('inserts text into clipboard on copy', () => {
+    sinon.stub(element, 'getSelectedText').returns('the text');
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.deepEqual(
+      ['Text', 'the text'],
+      event.clipboardData.setData.lastCall.args
+    );
+  });
+
+  test('setClasses adds given SelectionClass values, removes others', () => {
+    diffTable.classList.add('selected-right');
+    element.setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(diffTable.classList.contains('selected-comment'));
+    assert.isTrue(diffTable.classList.contains('selected-left'));
+    assert.isFalse(diffTable.classList.contains('selected-right'));
+    assert.isFalse(diffTable.classList.contains('selected-blame'));
+
+    element.setClasses(['selected-blame']);
+    assert.isFalse(diffTable.classList.contains('selected-comment'));
+    assert.isFalse(diffTable.classList.contains('selected-left'));
+    assert.isFalse(diffTable.classList.contains('selected-right'));
+    assert.isTrue(diffTable.classList.contains('selected-blame'));
+  });
+
+  test('setClasses removes before it ads', () => {
+    diffTable.classList.add('selected-right');
+    const addStub = sinon.stub(diffTable.classList, 'add');
+    const removeStub = sinon
+      .stub(diffTable.classList, 'remove')
+      .callsFake(() => {
+        assert.isFalse(addStub.called);
+      });
+    element.setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(addStub.called);
+    assert.isTrue(removeStub.called);
+  });
+
+  test('copies content correctly', () => {
+    diffTable.classList.add('selected-left');
+    diffTable.classList.remove('selected-right');
+
+    const selection = document.getSelection();
+    if (selection === null) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text');
+    range.setStart(firstTextNode(texts[0]), 3);
+    range.setEnd(firstTextNode(texts[4]), 2);
+    selection.addRange(range);
+
+    assert.equal(element.getSelectedText(Side.LEFT), 'ba\nzin\nga');
+  });
+
+  test('defers to default behavior for textarea', () => {
+    diffTable.classList.add('selected-left');
+    diffTable.classList.remove('selected-right');
+    const selectedTextSpy = sinon.spy(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('textarea'));
+
+    assert.isFalse(selectedTextSpy.called);
+  });
+
+  test('regression test for 4794', () => {
+    diffTable.classList.add('selected-right');
+    diffTable.classList.remove('selected-left');
+
+    const selection = document.getSelection();
+    if (!selection) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text');
+    range.setStart(firstTextNode(texts[1]), 4);
+    range.setEnd(firstTextNode(texts[1]), 10);
+    selection.addRange(range);
+
+    assert.equal(element.getSelectedText(Side.RIGHT), ' other');
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
new file mode 100644
index 0000000..59da20d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -0,0 +1,520 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
+import {LineRange, Side} from '../../../api/diff';
+import {LineNumber} from './gr-diff-line';
+import {assertIsDefined, assert} from '../../../utils/common-util';
+import {untilRendered} from '../../../utils/dom-util';
+import {isDefined} from '../../../types/types';
+import {LitElement} from 'lit';
+
+export enum GrDiffGroupType {
+  /** Unchanged context. */
+  BOTH = 'both',
+
+  /** A widget used to show more context. */
+  CONTEXT_CONTROL = 'contextControl',
+
+  /** Added, removed or modified chunk. */
+  DELTA = 'delta',
+}
+
+export interface GrDiffLinePair {
+  left: GrDiffLine;
+  right: GrDiffLine;
+}
+
+/**
+ * Hides lines in the given range behind a context control group.
+ *
+ * Groups that would be partially visible are split into their visible and
+ * hidden parts, respectively.
+ * The groups need to be "common groups", meaning they have to have either
+ * originated from an `ab` chunk, or from an `a`+`b` chunk with
+ * `common: true`.
+ *
+ * If the hidden range is 3 lines or less, nothing is hidden and no context
+ * control group is created.
+ *
+ * @param groups Common groups, ordered by their line ranges.
+ * @param hiddenStart The first element to be hidden, as a
+ *     non-negative line number offset relative to the first group's start
+ *     line, left and right respectively.
+ * @param hiddenEnd The first visible element after the hidden range,
+ *     as a non-negative line number offset relative to the first group's
+ *     start line, left and right respectively.
+ */
+export function hideInContextControl(
+  groups: readonly GrDiffGroup[],
+  hiddenStart: number,
+  hiddenEnd: number
+): GrDiffGroup[] {
+  if (groups.length === 0) return [];
+  // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
+  hiddenStart = Math.max(hiddenStart, 0);
+  hiddenEnd = Math.max(hiddenEnd, hiddenStart);
+
+  let before: GrDiffGroup[] = [];
+  let hidden = groups;
+  let after: readonly GrDiffGroup[] = [];
+
+  const numHidden = hiddenEnd - hiddenStart;
+
+  // 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_line;
+        const beforeEnd = before[before.length - 1].lineRange.left.end_line;
+        beforeLength = beforeEnd - beforeStart + 1;
+      }
+      [hidden, after] = splitCommonGroups(hidden, hiddenEnd - beforeLength);
+    }
+  } else {
+    [hidden, after] = [[], hidden];
+  }
+
+  const result = [...before];
+  if (hidden.length) {
+    result.push(
+      new GrDiffGroup({
+        type: GrDiffGroupType.CONTEXT_CONTROL,
+        contextGroups: [...hidden],
+      })
+    );
+  }
+  result.push(...after);
+  return result;
+}
+
+/**
+ * Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
+ * used in function splitCommonGroups
+ * Groups with some lines before and some lines after the split will be split
+ * into two groups, which will be put into the first and second list.
+ *
+ * @param group The group to be split in two
+ * @param leftSplit The line number relative to the split on the left side
+ * @param rightSplit The line number relative to the split on the right side
+ * @return two new groups, one before the split and another after it
+ */
+function splitGroupInTwo(
+  group: GrDiffGroup,
+  leftSplit: number,
+  rightSplit: number
+) {
+  let beforeSplit: GrDiffGroup | undefined;
+  let afterSplit: GrDiffGroup | undefined;
+  // split line is in the middle of a group, we need to break the group
+  // in lines before and after the split.
+  if (group.skip) {
+    // Currently we assume skip chunks "refuse" to be split. Expanding this
+    // group will in the future mean load more data - and therefore we want to
+    // fire an event when user wants to do it.
+    const closerToStartThanEnd =
+      leftSplit - group.lineRange.left.start_line <
+      group.lineRange.right.end_line - leftSplit;
+    if (closerToStartThanEnd) {
+      afterSplit = group;
+    } else {
+      beforeSplit = group;
+    }
+  } else {
+    const before = [];
+    const after = [];
+    for (const line of group.lines) {
+      if (
+        (line.beforeNumber && line.beforeNumber < leftSplit) ||
+        (line.afterNumber && line.afterNumber < rightSplit)
+      ) {
+        before.push(line);
+      } else {
+        after.push(line);
+      }
+    }
+    if (before.length) {
+      beforeSplit =
+        before.length === group.lines.length
+          ? group
+          : group.cloneWithLines(before);
+    }
+    if (after.length) {
+      afterSplit =
+        after.length === group.lines.length
+          ? group
+          : group.cloneWithLines(after);
+    }
+  }
+  return {beforeSplit, afterSplit};
+}
+
+/**
+ * Splits a list of common groups into two lists of groups.
+ *
+ * Groups where all lines are before or all lines are after the split will be
+ * retained as is and put into the first or second list respectively. Groups
+ * with some lines before and some lines after the split will be split into
+ * two groups, which will be put into the first and second list.
+ *
+ * @param split A line number offset relative to the first group's
+ *     start line at which the groups should be split.
+ * @return The outer array has 2 elements, the
+ *   list of groups before and the list of groups after the split.
+ */
+function splitCommonGroups(
+  groups: readonly GrDiffGroup[],
+  split: number
+): GrDiffGroup[][] {
+  if (groups.length === 0) return [[], []];
+  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_line < leftSplit ||
+      group.lineRange.right.end_line < rightSplit;
+    const isCompletelyAfter =
+      leftSplit <= group.lineRange.left.start_line ||
+      rightSplit <= group.lineRange.right.start_line;
+    if (isCompletelyBefore) {
+      beforeGroups.push(group);
+    } else if (isCompletelyAfter) {
+      afterGroups.push(group);
+    } else {
+      const {beforeSplit, afterSplit} = splitGroupInTwo(
+        group,
+        leftSplit,
+        rightSplit
+      );
+      if (beforeSplit) {
+        beforeGroups.push(beforeSplit);
+      }
+      if (afterSplit) {
+        afterGroups.push(afterSplit);
+      }
+    }
+  }
+  return [beforeGroups, afterGroups];
+}
+
+export interface GrMoveDetails {
+  changed: boolean;
+  range?: {
+    start: number;
+    end: number;
+  };
+}
+
+/** A chunk of the diff that should be rendered together. */
+export class GrDiffGroup {
+  constructor(
+    options:
+      | {
+          type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA;
+          lines?: GrDiffLine[];
+          skip?: undefined;
+          moveDetails?: GrMoveDetails;
+          dueToRebase?: boolean;
+          ignoredWhitespaceOnly?: boolean;
+          keyLocation?: boolean;
+        }
+      | {
+          type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA;
+          lines?: undefined;
+          skip: number;
+          offsetLeft: number;
+          offsetRight: number;
+          moveDetails?: GrMoveDetails;
+          dueToRebase?: boolean;
+          ignoredWhitespaceOnly?: boolean;
+          keyLocation?: boolean;
+        }
+      | {
+          type: GrDiffGroupType.CONTEXT_CONTROL;
+          contextGroups: GrDiffGroup[];
+        }
+  ) {
+    this.type = options.type;
+    switch (options.type) {
+      case GrDiffGroupType.BOTH:
+      case GrDiffGroupType.DELTA: {
+        this.moveDetails = options.moveDetails;
+        this.dueToRebase = options.dueToRebase ?? false;
+        this.ignoredWhitespaceOnly = options.ignoredWhitespaceOnly ?? false;
+        this.keyLocation = options.keyLocation ?? false;
+        if (options.skip && options.lines) {
+          throw new Error('Cannot set skip and lines');
+        }
+        this.skip = options.skip;
+        if (options.skip !== undefined) {
+          this.lineRange = {
+            left: {
+              start_line: options.offsetLeft,
+              end_line: options.offsetLeft + options.skip - 1,
+            },
+            right: {
+              start_line: options.offsetRight,
+              end_line: options.offsetRight + options.skip - 1,
+            },
+          };
+        } else {
+          assertIsDefined(options.lines);
+          assert(options.lines.length > 0, 'diff group must have lines');
+          for (const line of options.lines) {
+            this.addLine(line);
+          }
+        }
+        break;
+      }
+      case GrDiffGroupType.CONTEXT_CONTROL: {
+        this.contextGroups = options.contextGroups;
+        if (this.contextGroups.length > 0) {
+          const firstGroup = this.contextGroups[0];
+          const lastGroup = this.contextGroups[this.contextGroups.length - 1];
+          this.lineRange = {
+            left: {
+              start_line: firstGroup.lineRange.left.start_line,
+              end_line: lastGroup.lineRange.left.end_line,
+            },
+            right: {
+              start_line: firstGroup.lineRange.right.start_line,
+              end_line: lastGroup.lineRange.right.end_line,
+            },
+          };
+        }
+        break;
+      }
+      default:
+        throw new Error(`Unknown group type: ${this.type}`);
+    }
+  }
+
+  readonly type: GrDiffGroupType;
+
+  readonly dueToRebase: boolean = false;
+
+  /**
+   * True means all changes in this line are whitespace changes that should
+   * not be highlighted as changed as per the user settings.
+   */
+  readonly ignoredWhitespaceOnly: boolean = false;
+
+  /**
+   * True means it should not be collapsed (because it was in the URL, or
+   * there is a comment on that line)
+   */
+  readonly keyLocation: boolean = false;
+
+  /**
+   * Once rendered the diff builder sets this to the diff section element.
+   */
+  element?: HTMLElement;
+
+  readonly lines: GrDiffLine[] = [];
+
+  readonly adds: GrDiffLine[] = [];
+
+  readonly removes: GrDiffLine[] = [];
+
+  readonly contextGroups: GrDiffGroup[] = [];
+
+  readonly skip?: number;
+
+  /** Both start and end line are inclusive. */
+  readonly lineRange: {[side in Side]: LineRange} = {
+    [Side.LEFT]: {start_line: 0, end_line: 0},
+    [Side.RIGHT]: {start_line: 0, end_line: 0},
+  };
+
+  readonly moveDetails?: GrMoveDetails;
+
+  /**
+   * Creates a new group with the same properties but different lines.
+   *
+   * The element property is not copied, because the original element is still a
+   * rendering of the old lines, so that would not make sense.
+   */
+  cloneWithLines(lines: GrDiffLine[]): GrDiffGroup {
+    if (
+      this.type !== GrDiffGroupType.BOTH &&
+      this.type !== GrDiffGroupType.DELTA
+    ) {
+      throw new Error('Cannot clone context group with lines');
+    }
+    const group = new GrDiffGroup({
+      type: this.type,
+      lines,
+      dueToRebase: this.dueToRebase,
+      ignoredWhitespaceOnly: this.ignoredWhitespaceOnly,
+    });
+    return group;
+  }
+
+  private addLine(line: GrDiffLine) {
+    this.lines.push(line);
+
+    const notDelta =
+      this.type === GrDiffGroupType.BOTH ||
+      this.type === GrDiffGroupType.CONTEXT_CONTROL;
+    if (
+      notDelta &&
+      (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.REMOVE)
+    ) {
+      throw Error('Cannot add delta line to a non-delta group.');
+    }
+
+    if (line.type === GrDiffLineType.ADD) {
+      this.adds.push(line);
+    } else if (line.type === GrDiffLineType.REMOVE) {
+      this.removes.push(line);
+    }
+    this._updateRangeWithNewLine(line);
+  }
+
+  getSideBySidePairs(): GrDiffLinePair[] {
+    if (
+      this.type === GrDiffGroupType.BOTH ||
+      this.type === GrDiffGroupType.CONTEXT_CONTROL
+    ) {
+      return this.lines.map(line => {
+        return {left: line, right: line};
+      });
+    }
+
+    const pairs: GrDiffLinePair[] = [];
+    let i = 0;
+    let j = 0;
+    while (i < this.removes.length || j < this.adds.length) {
+      pairs.push({
+        left: this.removes[i] || BLANK_LINE,
+        right: this.adds[j] || BLANK_LINE,
+      });
+      i++;
+      j++;
+    }
+    return pairs;
+  }
+
+  getUnifiedPairs(): GrDiffLinePair[] {
+    return this.lines
+      .map(line => {
+        if (line.type === GrDiffLineType.ADD) {
+          return {left: BLANK_LINE, right: line};
+        }
+        if (line.type === GrDiffLineType.REMOVE) {
+          if (this.ignoredWhitespaceOnly) return undefined;
+          return {left: line, right: BLANK_LINE};
+        }
+        return {left: line, right: line};
+      })
+      .filter(isDefined);
+  }
+
+  /** Returns true if it is, or contains, a skip group. */
+  hasSkipGroup() {
+    return !!this.skip || this.contextGroups?.some(g => !!g.skip);
+  }
+
+  containsLine(side: Side, line: LineNumber) {
+    if (line === 'FILE' || line === 'LOST') {
+      // For FILE and LOST, beforeNumber and afterNumber are the same
+      return this.lines[0]?.beforeNumber === line;
+    }
+    const lineRange = this.lineRange[side];
+    return lineRange.start_line <= line && line <= lineRange.end_line;
+  }
+
+  startLine(side: Side): LineNumber {
+    if (this.type === GrDiffGroupType.CONTEXT_CONTROL) {
+      return side === Side.LEFT
+        ? this.lineRange.left.start_line
+        : this.lineRange.right.start_line;
+    }
+    return this.lines[0].lineNumber(side);
+  }
+
+  private _updateRangeWithNewLine(line: GrDiffLine) {
+    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_line === 0 ||
+        line.afterNumber < this.lineRange.right.start_line
+      ) {
+        this.lineRange.right.start_line = line.afterNumber;
+      }
+      if (line.afterNumber > this.lineRange.right.end_line) {
+        this.lineRange.right.end_line = line.afterNumber;
+      }
+    }
+
+    if (
+      line.type === GrDiffLineType.REMOVE ||
+      line.type === GrDiffLineType.BOTH
+    ) {
+      if (
+        this.lineRange.left.start_line === 0 ||
+        line.beforeNumber < this.lineRange.left.start_line
+      ) {
+        this.lineRange.left.start_line = line.beforeNumber;
+      }
+      if (line.beforeNumber > this.lineRange.left.end_line) {
+        this.lineRange.left.end_line = line.beforeNumber;
+      }
+    }
+  }
+
+  async waitUntilRendered() {
+    const lineNumber = this.lines[0]?.beforeNumber;
+    // The LOST or FILE lines may be hidden and thus never resolve an
+    // untilRendered() promise.
+    if (
+      this.skip ||
+      lineNumber === 'LOST' ||
+      lineNumber === 'FILE' ||
+      this.type === GrDiffGroupType.CONTEXT_CONTROL
+    ) {
+      return Promise.resolve();
+    }
+    assertIsDefined(this.element);
+    // This is a temporary hack while migration to lit based diff rendering:
+    // Elements with 'display: contents;' do not have a height, so they
+    // won't work as intended with `untilRendered()`.
+    const isLitDiff = this.element.tagName === 'GR-DIFF-SECTION';
+    if (isLitDiff) {
+      await (this.element as LitElement).updateComplete;
+      await untilRendered(this.element.firstElementChild as HTMLElement);
+    } else {
+      await untilRendered(this.element);
+    }
+  }
+
+  /**
+   * Determines whether the group is either totally an addition or totally
+   * a removal.
+   */
+  isTotal(): boolean {
+    return (
+      this.type === GrDiffGroupType.DELTA &&
+      (!this.adds.length || !this.removes.length) &&
+      !(!this.adds.length && !this.removes.length)
+    );
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
new file mode 100644
index 0000000..4a19282
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -0,0 +1,294 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from './gr-diff-group';
+import {assert} from '@open-wc/testing';
+import {Side} from '../../../api/diff';
+
+suite('gr-diff-group tests', () => {
+  test('delta line pairs', () => {
+    const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
+    const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
+    const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
+    let group = new GrDiffGroup({
+      type: GrDiffGroupType.DELTA,
+      lines: [l1, l2, l3],
+    });
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, [l1, l2]);
+    assert.deepEqual(group.removes, [l3]);
+    assert.deepEqual(group.lineRange, {
+      left: {start_line: 64, end_line: 64},
+      right: {start_line: 128, end_line: 129},
+    });
+
+    let pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: BLANK_LINE, right: l2},
+    ]);
+
+    group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [l1, l2, l3]});
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, [l1, l2]);
+    assert.deepEqual(group.removes, [l3]);
+
+    pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: BLANK_LINE, right: l2},
+    ]);
+  });
+
+  test('group must have lines', () => {
+    try {
+      new GrDiffGroup({type: GrDiffGroupType.BOTH});
+    } catch (e) {
+      // expected
+      return;
+    }
+    assert.fail('a standard diff group cannot be empty');
+  });
+
+  test('group/header line pairs', () => {
+    const l1 = new GrDiffLine(GrDiffLineType.BOTH, 64, 128);
+    const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
+    const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
+
+    const group = new GrDiffGroup({
+      type: GrDiffGroupType.BOTH,
+      lines: [l1, l2, l3],
+    });
+
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, []);
+    assert.deepEqual(group.removes, []);
+
+    assert.deepEqual(group.lineRange, {
+      left: {start_line: 64, end_line: 66},
+      right: {start_line: 128, end_line: 130},
+    });
+
+    const pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l1, right: l1},
+      {left: l2, right: l2},
+      {left: l3, right: l3},
+    ]);
+  });
+
+  test('adding delta lines to non-delta group', () => {
+    const l1 = new GrDiffLine(GrDiffLineType.ADD);
+    const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
+    const l3 = new GrDiffLine(GrDiffLineType.BOTH);
+
+    assert.throws(
+      () => new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]})
+    );
+  });
+
+  suite('hideInContextControl', () => {
+    let groups: GrDiffGroup[];
+    setup(() => {
+      groups = [
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.DELTA,
+          lines: [
+            new GrDiffLine(GrDiffLineType.REMOVE, 8),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+            new GrDiffLine(GrDiffLineType.REMOVE, 9),
+            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({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            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, 7);
+      assert.equal(collapsedGroups.length, 3);
+
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[1].contextGroups.length, 1);
+      assert.equal(collapsedGroups[1].contextGroups[0], groups[1]);
+
+      assert.equal(collapsedGroups[2], groups[2]);
+    });
+
+    test('splits partially hidden groups', () => {
+      const collapsedGroups = hideInContextControl(groups, 4, 8);
+      assert.equal(collapsedGroups.length, 4);
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroupType.DELTA);
+      assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
+      assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
+
+      assert.equal(collapsedGroups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[2].contextGroups.length, 2);
+
+      assert.equal(
+        collapsedGroups[2].contextGroups[0].type,
+        GrDiffGroupType.DELTA
+      );
+      assert.deepEqual(
+        collapsedGroups[2].contextGroups[0].adds,
+        groups[1].adds.slice(1)
+      );
+      assert.deepEqual(
+        collapsedGroups[2].contextGroups[0].removes,
+        groups[1].removes.slice(1)
+      );
+
+      assert.equal(
+        collapsedGroups[2].contextGroups[1].type,
+        GrDiffGroupType.BOTH
+      );
+      assert.deepEqual(collapsedGroups[2].contextGroups[1].lines, [
+        groups[2].lines[0],
+      ]);
+
+      assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
+      assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
+    });
+
+    suite('with skip chunks', () => {
+      setup(() => {
+        const skipGroup = new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          skip: 60,
+          offsetLeft: 8,
+          offsetRight: 10,
+        });
+        groups = [
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+              new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+              new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+            ],
+          }),
+          skipGroup,
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+              new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+              new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+            ],
+          }),
+        ];
+      });
+
+      test('refuses to split skip group when closer to before', () => {
+        const collapsedGroups = hideInContextControl(groups, 4, 10);
+        assert.deepEqual(groups, collapsedGroups);
+      });
+    });
+
+    test('groups unchanged if the hidden range is empty', () => {
+      assert.deepEqual(hideInContextControl(groups, 0, 0), groups);
+    });
+
+    test('groups unchanged if there is only 1 line to hide', () => {
+      assert.deepEqual(hideInContextControl(groups, 3, 4), groups);
+    });
+  });
+
+  suite('isTotal', () => {
+    test('is total for add', () => {
+      const lines = [];
+      for (let idx = 0; idx < 10; idx++) {
+        lines.push(new GrDiffLine(GrDiffLineType.ADD));
+      }
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.isTrue(group.isTotal());
+    });
+
+    test('is total for remove', () => {
+      const lines = [];
+      for (let idx = 0; idx < 10; idx++) {
+        lines.push(new GrDiffLine(GrDiffLineType.REMOVE));
+      }
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.isTrue(group.isTotal());
+    });
+
+    test('not total for non-delta', () => {
+      const lines = [];
+      for (let idx = 0; idx < 10; idx++) {
+        lines.push(new GrDiffLine(GrDiffLineType.BOTH));
+      }
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.isFalse(group.isTotal());
+    });
+  });
+
+  suite('startLine', () => {
+    test('DELTA', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 3, 4));
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.equal(group.startLine(Side.LEFT), 3);
+      assert.equal(group.startLine(Side.RIGHT), 4);
+    });
+
+    test('CONTEXT CONTROL', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 3, 4));
+      const delta = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.CONTEXT_CONTROL,
+        contextGroups: [delta],
+      });
+      assert.equal(group.startLine(Side.LEFT), 3);
+      assert.equal(group.startLine(Side.RIGHT), 4);
+    });
+
+    test('FILE', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE'));
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.equal(group.startLine(Side.LEFT), 'FILE');
+      assert.equal(group.startLine(Side.RIGHT), 'FILE');
+    });
+
+    test('LOST', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'LOST', 'LOST'));
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.equal(group.startLine(Side.LEFT), 'LOST');
+      assert.equal(group.startLine(Side.RIGHT), 'LOST');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
new file mode 100644
index 0000000..338a275
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  GrDiffLine as GrDiffLineApi,
+  GrDiffLineType,
+  LineNumber,
+  Side,
+} from '../../../api/diff';
+
+export {GrDiffLineType};
+export type {LineNumber};
+
+export const FILE = 'FILE';
+
+export class GrDiffLine implements GrDiffLineApi {
+  constructor(
+    readonly type: GrDiffLineType,
+    public beforeNumber: LineNumber = 0,
+    public afterNumber: LineNumber = 0
+  ) {}
+
+  hasIntralineInfo = false;
+
+  highlights: Highlights[] = [];
+
+  text = '';
+
+  lineNumber(side: Side) {
+    return side === Side.LEFT ? this.beforeNumber : this.afterNumber;
+  }
+
+  // TODO(TS): remove this properties
+  static readonly Type = GrDiffLineType;
+
+  static readonly File = FILE;
+}
+
+/**
+ * A line highlight object consists of three fields:
+ * - contentIndex: The index of the chunk `content` field (the line
+ *   being referred to).
+ * - startIndex: Index of the character where the highlight should begin.
+ * - endIndex: (optional) Index of the character where the highlight should
+ *   end. If omitted, the highlight is meant to be a continuation onto the
+ *   next line.
+ */
+export interface Highlights {
+  contentIndex: number;
+  startIndex: number;
+  endIndex?: number;
+}
+
+export const BLANK_LINE = new GrDiffLine(GrDiffLineType.BLANK);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
new file mode 100644
index 0000000..87fd5ca
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -0,0 +1,397 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {BlameInfo, CommentRange} from '../../../types/common';
+import {FILE, LineNumber} from './gr-diff-line';
+import {Side} from '../../../constants/constants';
+import {DiffInfo} from '../../../types/diff';
+import {
+  DiffPreferencesInfo,
+  DiffResponsiveMode,
+  RenderPreferences,
+} from '../../../api/diff';
+import {getBaseUrl} from '../../../utils/url-util';
+
+/**
+ * In JS, unicode code points above 0xFFFF occupy two elements of a string.
+ * 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
+ * these are two cases where '1 char' does not automatically imply '1 column'.
+ *
+ * TODO: For human languages whose orthographies use combining marks, this
+ * approach won't correctly identify the grapheme boundaries. In those cases,
+ * a grapheme consists of multiple code points that should count as only one
+ * character against the column limit. Getting that correct (if it's desired)
+ * is probably beyond the limits of a regex, but there are nonstandard APIs to
+ * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
+ *
+ * Further reading:
+ *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
+ *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
+ *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
+ */
+export const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+// If any line of the diff is more than the character limit, then disable
+// syntax highlighting for the entire file.
+export const SYNTAX_MAX_LINE_LENGTH = 500;
+
+export function countLines(diff?: DiffInfo, side?: Side) {
+  if (!diff?.content || !side) return 0;
+  return diff.content.reduce((sum, chunk) => {
+    const sideChunk = side === Side.LEFT ? chunk.a : chunk.b;
+    return sum + (sideChunk?.length ?? chunk.ab?.length ?? chunk.skip ?? 0);
+  }, 0);
+}
+
+export function isFileUnchanged(diff: DiffInfo) {
+  return !diff.content.some(
+    content => (content.a && !content.common) || (content.b && !content.common)
+  );
+}
+
+export function getResponsiveMode(
+  prefs?: DiffPreferencesInfo,
+  renderPrefs?: RenderPreferences
+): DiffResponsiveMode {
+  if (renderPrefs?.responsive_mode) {
+    return renderPrefs.responsive_mode;
+  }
+  // Backwards compatibility to the line_wrapping param.
+  if (prefs?.line_wrapping) {
+    return 'FULL_RESPONSIVE';
+  }
+  return 'NONE';
+}
+
+export function isResponsive(responsiveMode?: DiffResponsiveMode) {
+  return (
+    responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY'
+  );
+}
+
+/**
+ * Compare two ranges. Either argument may be falsy, but will only return
+ * true if both are falsy or if neither are falsy and have the same position
+ * values.
+ */
+export function rangesEqual(a?: CommentRange, b?: CommentRange): boolean {
+  if (!a && !b) {
+    return true;
+  }
+  if (!a || !b) {
+    return false;
+  }
+  return (
+    a.start_line === b.start_line &&
+    a.start_character === b.start_character &&
+    a.end_line === b.end_line &&
+    a.end_character === b.end_character
+  );
+}
+
+export function isLongCommentRange(range: CommentRange): boolean {
+  return range.end_line - range.start_line > 10;
+}
+
+export function getLineNumberByChild(node?: Node) {
+  return getLineNumber(getLineElByChild(node));
+}
+
+export function lineNumberToNumber(lineNumber?: LineNumber | null): number {
+  if (!lineNumber) return 0;
+  if (lineNumber === 'LOST') return 0;
+  if (lineNumber === 'FILE') return 0;
+  return lineNumber;
+}
+
+export function getLineElByChild(node?: Node): HTMLElement | null {
+  while (node) {
+    if (node instanceof Element) {
+      if (node.classList.contains('lineNum')) {
+        return node as HTMLElement;
+      }
+      if (node.classList.contains('section')) {
+        return null;
+      }
+    }
+    node =
+      (node as Element).assignedSlot ??
+      (node as ShadowRoot).host ??
+      node.previousSibling ??
+      node.parentNode ??
+      undefined;
+  }
+  return null;
+}
+
+export function getSideByLineEl(lineEl: Element) {
+  return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
+}
+
+export function getLineNumber(lineEl?: Element | null): LineNumber | null {
+  if (!lineEl) return null;
+  const lineNumberStr = lineEl.getAttribute('data-value');
+  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 {
+  const sideAtt = threadEl.getAttribute('diff-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'.
+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')
+  );
+}
+
+/**
+ * @return whether any of the lines in diff are longer
+ * than SYNTAX_MAX_LINE_LENGTH.
+ */
+export function anyLineTooLong(diff?: DiffInfo) {
+  if (!diff) return false;
+  return diff.content.some(section => {
+    const lines = section.ab
+      ? section.ab
+      : (section.a || []).concat(section.b || []);
+    return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+  });
+}
+
+/**
+ * Simple helper method for creating element classes in the context of
+ * gr-diff. This is just a super simple convenience function.
+ */
+export function diffClasses(...additionalClasses: string[]) {
+  return ['gr-diff', ...additionalClasses].join(' ');
+}
+
+/**
+ * Simple helper method for creating elements in the context of gr-diff.
+ * This is just a super simple convenience function.
+ */
+export function createElementDiff(
+  tagName: string,
+  classStr?: string
+): HTMLElement {
+  const el = document.createElement(tagName);
+
+  el.classList.add('gr-diff');
+  if (classStr) {
+    for (const className of classStr.split(' ')) {
+      el.classList.add(className);
+    }
+  }
+  return el;
+}
+
+export function createElementDiffWithText(
+  tagName: string,
+  textContent: string
+) {
+  const element = createElementDiff(tagName);
+  element.textContent = textContent;
+  return element;
+}
+
+export function createLineBreak(mode: DiffResponsiveMode) {
+  return isResponsive(mode)
+    ? createElementDiff('wbr')
+    : createElementDiff('span', 'br');
+}
+
+/**
+ * Returns a <span> element holding a '\t' character, that will visually
+ * occupy |tabSize| many columns.
+ *
+ * @param tabSize The effective size of this tab stop.
+ */
+export function createTabWrapper(tabSize: number): HTMLElement {
+  // Force this to be a number to prevent arbitrary injection.
+  const result = createElementDiff('span', 'tab');
+  result.setAttribute(
+    'style',
+    `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};`
+  );
+  result.innerText = '\t';
+  return result;
+}
+
+/**
+ * Returns a 'div' element containing the supplied |text| as its innerText,
+ * with '\t' characters expanded to a width determined by |tabSize|, and the
+ * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+ * desired.
+ *
+ * @param text The text to be formatted.
+ * @param responsiveMode The responsive mode of the diff.
+ * @param tabSize The width of each tab stop.
+ * @param lineLimit The column after which to wrap lines.
+ */
+export function formatText(
+  text: string,
+  responsiveMode: DiffResponsiveMode,
+  tabSize: number,
+  lineLimit: number,
+  elementId: string
+): HTMLElement {
+  const contentText = createElementDiff('div', 'contentText');
+  // <gr-legacy-text> is not defined anywhere, so this behave just as a <div>
+  // would. We use this during the migration to lit based diff elements to
+  // match <gr-diff-text>. We define a css rule with `display:contents` making
+  // sure that this extra element is basically a no-op.
+  const legacyText = document.createElement('gr-legacy-text');
+  contentText.appendChild(legacyText);
+  contentText.id = elementId;
+  let columnPos = 0;
+  let textOffset = 0;
+  for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
+    if (segment) {
+      // |segment| contains only normal characters. If |segment| doesn't fit
+      // entirely on the current line, append chunks of |segment| followed by
+      // line breaks.
+      let rowStart = 0;
+      let rowEnd = lineLimit - columnPos;
+      while (rowEnd < segment.length) {
+        legacyText.appendChild(
+          document.createTextNode(segment.substring(rowStart, rowEnd))
+        );
+        legacyText.appendChild(createLineBreak(responsiveMode));
+        columnPos = 0;
+        rowStart = rowEnd;
+        rowEnd += lineLimit;
+      }
+      // Append the last part of |segment|, which fits on the current line.
+      legacyText.appendChild(
+        document.createTextNode(segment.substring(rowStart))
+      );
+      columnPos += segment.length - rowStart;
+      textOffset += segment.length;
+    }
+    if (textOffset < text.length) {
+      // Handle the special character at |textOffset|.
+      if (text.startsWith('\t', textOffset)) {
+        // Append a single '\t' character.
+        let effectiveTabSize = tabSize - (columnPos % tabSize);
+        if (columnPos + effectiveTabSize > lineLimit) {
+          legacyText.appendChild(createLineBreak(responsiveMode));
+          columnPos = 0;
+          effectiveTabSize = tabSize;
+        }
+        legacyText.appendChild(createTabWrapper(effectiveTabSize));
+        columnPos += effectiveTabSize;
+        textOffset++;
+      } else {
+        // Append a single surrogate pair.
+        if (columnPos >= lineLimit) {
+          legacyText.appendChild(createLineBreak(responsiveMode));
+          columnPos = 0;
+        }
+        legacyText.appendChild(
+          document.createTextNode(text.substring(textOffset, textOffset + 2))
+        );
+        textOffset += 2;
+        columnPos += 1;
+      }
+    }
+  }
+  return contentText;
+}
+
+/**
+ * Given the number of a base line and the BlameInfo create a <span> element
+ * with a hovercard. This is supposed to be put into a <td> cell of the diff.
+ */
+export function createBlameElement(
+  lineNum: LineNumber,
+  commit: BlameInfo
+): HTMLElement {
+  const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+
+  const date = new Date(commit.time * 1000).toLocaleDateString();
+  const blameNode = createElementDiff(
+    'span',
+    isStartOfRange ? 'startOfRange' : ''
+  );
+
+  const shaNode = createElementDiff('a', 'blameDate');
+  shaNode.innerText = `${date}`;
+  shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`);
+  blameNode.appendChild(shaNode);
+
+  const shortName = commit.author.split(' ')[0];
+  const authorNode = createElementDiff('span', 'blameAuthor');
+  authorNode.innerText = ` ${shortName}`;
+  blameNode.appendChild(authorNode);
+
+  const hoverCardFragment = createElementDiff('span', 'blameHoverCard');
+  hoverCardFragment.innerText = `Commit ${commit.id}
+Author: ${commit.author}
+Date: ${date}
+
+${commit.commit_msg}`;
+  const hovercard = createElementDiff('gr-hovercard');
+  hovercard.appendChild(hoverCardFragment);
+  blameNode.appendChild(hovercard);
+
+  return blameNode;
+}
+
+/**
+ * Get the approximate length of the diff as the sum of the maximum
+ * length of the chunks.
+ */
+export function getDiffLength(diff?: DiffInfo) {
+  if (!diff) return 0;
+  return diff.content.reduce((sum, sec) => {
+    if (sec.ab) {
+      return sum + sec.ab.length;
+    } else {
+      return sum + Math.max(sec.a?.length ?? 0, sec.b?.length ?? 0);
+    }
+  }, 0);
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
new file mode 100644
index 0000000..25dc768
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -0,0 +1,198 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {DiffInfo} from '../../../api/diff';
+import '../../../test/common-test-setup';
+import {createDiff} from '../../../test/test-data-generators';
+import {
+  createElementDiff,
+  formatText,
+  createTabWrapper,
+  isFileUnchanged,
+} from './gr-diff-utils';
+
+const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
+
+suite('gr-diff-utils tests', () => {
+  test('createElementDiff classStr applies all classes', () => {
+    const node = createElementDiff('div', 'test classes');
+    assert.isTrue(node.classList.contains('gr-diff'));
+    assert.isTrue(node.classList.contains('test'));
+    assert.isTrue(node.classList.contains('classes'));
+  });
+
+  test('formatText newlines 1', () => {
+    let text = 'abcdef';
+
+    assert.equal(
+      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
+      text
+    );
+    text = 'a'.repeat(20);
+    assert.equal(
+      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
+      'a'.repeat(10) + LINE_BREAK_HTML + 'a'.repeat(10)
+    );
+  });
+
+  test('formatText newlines 2', () => {
+    const text = '<span class="thumbsup">👍</span>';
+    assert.equal(
+      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
+      '&lt;span clas' +
+        LINE_BREAK_HTML +
+        's="thumbsu' +
+        LINE_BREAK_HTML +
+        'p"&gt;👍&lt;/span' +
+        LINE_BREAK_HTML +
+        '&gt;'
+    );
+  });
+
+  test('formatText newlines 3', () => {
+    const text = '01234\t56789';
+    assert.equal(
+      formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
+      '01234' + createTabWrapper(3).outerHTML + '56' + LINE_BREAK_HTML + '789'
+    );
+  });
+
+  test('formatText newlines 4', () => {
+    const text = '👍'.repeat(58);
+    assert.equal(
+      formatText(text, 'NONE', 4, 20, '').firstElementChild?.innerHTML,
+      '👍'.repeat(20) +
+        LINE_BREAK_HTML +
+        '👍'.repeat(20) +
+        LINE_BREAK_HTML +
+        '👍'.repeat(18)
+    );
+  });
+
+  test('tab wrapper style', () => {
+    const pattern = new RegExp(
+      '^<span class="gr-diff tab" ' +
+        'style="((?:-moz-)?tab-size: (\\d+);.?)+">\\t<\\/span>$'
+    );
+
+    for (const size of [1, 3, 8, 55]) {
+      const html = createTabWrapper(size).outerHTML;
+      assert.match(html, pattern);
+      assert.equal(html.match(pattern)?.[2], size.toString());
+    }
+  });
+
+  test('tab wrapper insertion', () => {
+    const html = 'abc\tdef';
+    const tabSize = 8;
+    const wrapper = createTabWrapper(tabSize - 3);
+    assert.ok(wrapper);
+    assert.equal(wrapper.innerText, '\t');
+    assert.equal(
+      formatText(html, 'NONE', tabSize, Infinity, '').firstElementChild
+        ?.innerHTML,
+      'abc' + wrapper.outerHTML + 'def'
+    );
+  });
+
+  test('escaping HTML', () => {
+    let input = '<script>alert("XSS");<' + '/script>';
+    let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
+
+    let result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY, '')
+      .firstElementChild?.innerHTML;
+    assert.equal(result, expected);
+
+    input = '& < > " \' / `';
+    expected = '&amp; &lt; &gt; " \' / `';
+    result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY, '')
+      .firstElementChild?.innerHTML;
+    assert.equal(result, expected);
+  });
+
+  test('text length with tabs and unicode', () => {
+    function expectTextLength(text: string, tabSize: number, expected: number) {
+      // Formatting to |expected| columns should not introduce line breaks.
+      const result = formatText(text, 'NONE', tabSize, expected, '')
+        .firstElementChild!;
+      assert.isNotOk(
+        result.querySelector('.contentText > .br'),
+        '  Expected the result of: \n' +
+          `      _formatText(${text}', 'NONE',  ${tabSize}, ${expected})\n` +
+          '  to not contain a br. But the actual result HTML was:\n' +
+          `      '${result.innerHTML}'\nwhereupon`
+      );
+
+      // Increasing the line limit should produce the same markup.
+      assert.equal(
+        formatText(text, 'NONE', tabSize, Infinity, '').firstElementChild
+          ?.innerHTML,
+        result.innerHTML
+      );
+      assert.equal(
+        formatText(text, 'NONE', tabSize, expected + 1, '').firstElementChild
+          ?.innerHTML,
+        result.innerHTML
+      );
+
+      // Decreasing the line limit should introduce line breaks.
+      if (expected > 0) {
+        const tooSmall = formatText(text, 'NONE', tabSize, expected - 1, '')
+          .firstElementChild!;
+        assert.isOk(
+          tooSmall.querySelector('.contentText .br'),
+          '  Expected the result of: \n' +
+            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
+            '  to contain a br. But the actual result HTML was:\n' +
+            `      '${tooSmall.innerHTML}'\nwhereupon`
+        );
+      }
+    }
+    expectTextLength('12345', 4, 5);
+    expectTextLength('\t\t12', 4, 10);
+    expectTextLength('abc💢123', 4, 7);
+    expectTextLength('abc\t', 8, 8);
+    expectTextLength('abc\t\t', 10, 20);
+    expectTextLength('', 10, 0);
+    // 17 Thai combining chars.
+    expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+    expectTextLength('abc\tde', 10, 12);
+    expectTextLength('abc\tde\t', 10, 20);
+    expectTextLength('\t\t\t\t\t', 20, 100);
+  });
+
+  test('isFileUnchanged', () => {
+    let diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef']},
+        {b: ['ancd'], a: ['xx']},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), false);
+    diff = {
+      ...createDiff(),
+      content: [{ab: ['abcd']}, {ab: ['ancd']}],
+    };
+    assert.equal(isFileUnchanged(diff), true);
+    diff = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef'], common: true},
+        {b: ['ancd'], ab: ['xx']},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), false);
+    diff = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef'], common: true},
+        {b: ['ancd'], ab: ['xx'], common: true},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), true);
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
new file mode 100644
index 0000000..a0526ed
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -0,0 +1,1827 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import '../../../elements/shared/gr-button/gr-button';
+import '../../../elements/shared/gr-icon/gr-icon';
+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 {LineNumber} from './gr-diff-line';
+import {
+  getLine,
+  getLineElByChild,
+  getLineNumber,
+  getRange,
+  getSide,
+  GrDiffThreadElement,
+  isLongCommentRange,
+  isThreadEl,
+  rangesEqual,
+  getResponsiveMode,
+  isResponsive,
+  getDiffLength,
+} from './gr-diff-utils';
+import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
+import {
+  GrDiffBuilderElement,
+  getLineNumberCellWidth,
+} from '../gr-diff-builder/gr-diff-builder-element';
+import {CoverageRange, DiffLayer} from '../../../types/types';
+import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {
+  createDefaultDiffPrefs,
+  DiffViewMode,
+  Side,
+} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
+import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
+import {getContentEditableRange} from '../../../utils/safari-selection-util';
+import {AbortStop} from '../../../api/core';
+import {
+  CreateCommentEventDetail as CreateCommentEventDetailApi,
+  RenderPreferences,
+  GrDiff as GrDiffApi,
+  DisplayLine,
+} from '../../../api/diff';
+import {isSafari, toggleClass} from '../../../utils/dom-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {
+  debounceP,
+  DelayedPromise,
+  DELAYED_CANCELLATION,
+} from '../../../utils/async-util';
+import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {when} from 'lit/directives/when.js';
+import {grSyntaxTheme} from '../gr-syntax-themes/gr-syntax-theme';
+import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
+import {classMap} from 'lit/directives/class-map.js';
+import {iconStyles} from '../../../styles/gr-icon-styles';
+import {expandFileMode} from '../../../utils/file-util';
+
+const NO_NEWLINE_LEFT = 'No newline at end of left file.';
+const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
+
+const LARGE_DIFF_THRESHOLD_LINES = 10000;
+const FULL_CONTEXT = -1;
+
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+/**
+ * 72 is the unofficial length standard for git commit messages.
+ * Derived from the fact that git log/show appends 4 ws in the beginning of
+ * each line when displaying commit messages. To center the commit message
+ * in an 80 char terminal a 4 ws border is added to the rightmost side:
+ * 4 + 72 + 4
+ */
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+export interface CreateCommentEventDetail extends CreateCommentEventDetailApi {
+  path: string;
+}
+
+@customElement('gr-diff')
+export class GrDiff extends LitElement implements GrDiffApi {
+  /**
+   * Fired when the user selects a line.
+   *
+   * @event line-selected
+   */
+
+  /**
+   * Fired if being logged in is required.
+   *
+   * @event show-auth-required
+   */
+
+  /**
+   * Fired when a comment is created
+   *
+   * @event create-comment
+   */
+
+  /**
+   * Fired when rendering, including syntax highlighting, is done. Also fired
+   * when no rendering can be done because required preferences are not set.
+   *
+   * @event render
+   */
+
+  /**
+   * Fired for interaction reporting when a diff context is expanded.
+   * Contains an event.detail with numLines about the number of lines that
+   * were expanded.
+   *
+   * @event diff-context-expanded
+   */
+
+  @query('#diffTable')
+  diffTable?: HTMLTableElement;
+
+  @property({type: Boolean})
+  noAutoRender = false;
+
+  @property({type: String})
+  path?: string;
+
+  @property({type: Object})
+  prefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  renderPrefs?: RenderPreferences;
+
+  @property({type: Boolean})
+  displayLine = false;
+
+  @property({type: Boolean})
+  isImageDiff?: boolean;
+
+  @property({type: Boolean, reflect: true})
+  override hidden = false;
+
+  @property({type: Boolean})
+  noRenderOnPrefsChange?: boolean;
+
+  // Private but used in tests.
+  @state()
+  commentRanges: CommentRangeLayer[] = [];
+
+  // explicitly highlight a range if it is not associated with any comment
+  @property({type: Object})
+  highlightRange?: CommentRange;
+
+  @property({type: Array})
+  coverageRanges: CoverageRange[] = [];
+
+  @property({type: Boolean})
+  lineWrapping = false;
+
+  @property({type: String})
+  viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  @property({type: Object})
+  lineOfInterest?: DisplayLine;
+
+  /**
+   * True when diff is changed, until the content is done rendering.
+   * Use getter/setter loading instead of this.
+   */
+  private _loading = true;
+
+  get loading() {
+    return this._loading;
+  }
+
+  set loading(loading: boolean) {
+    if (this._loading === loading) return;
+    const oldLoading = this._loading;
+    this._loading = loading;
+    fire(this, 'loading-changed', {value: this._loading});
+    this.requestUpdate('loading', oldLoading);
+  }
+
+  @property({type: Boolean})
+  loggedIn = false;
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @state()
+  private diffTableClass = '';
+
+  @property({type: Object})
+  baseImage?: ImageInfo;
+
+  @property({type: Object})
+  revisionImage?: ImageInfo;
+
+  /**
+   * In order to allow multi-select in Safari browsers, a workaround is required
+   * to trigger 'beforeinput' events to get a list of static ranges. This is
+   * obtained by making the content of the diff table "contentEditable".
+   */
+  @property({type: Boolean})
+  override isContentEditable = isSafari();
+
+  /**
+   * Whether the safety check for large diffs when whole-file is set has
+   * been bypassed. If the value is null, then the safety has not been
+   * bypassed. If the value is a number, then that number represents the
+   * context preference to use when rendering the bypassed diff.
+   *
+   * Private but used in tests.
+   */
+  @state()
+  safetyBypass: number | null = null;
+
+  // Private but used in tests.
+  @state()
+  showWarning?: boolean;
+
+  @property({type: String})
+  errorMessage: string | null = null;
+
+  @property({type: Array})
+  blame: BlameInfo[] | null = null;
+
+  @property({type: Boolean})
+  showNewlineWarningLeft = false;
+
+  @property({type: Boolean})
+  showNewlineWarningRight = false;
+
+  @property({type: Boolean})
+  useNewImageDiffUi = false;
+
+  // Private but used in tests.
+  @state()
+  diffLength?: number;
+
+  /**
+   * Observes comment nodes added or removed at any point.
+   * Can be used to unregister upon detachment.
+   */
+  private nodeObserver?: MutationObserver;
+
+  @property({type: Array})
+  layers?: DiffLayer[];
+
+  // Private but used in tests.
+  renderDiffTableTask?: DelayedPromise<void>;
+
+  // Private but used in tests.
+  diffSelection = new GrDiffSelection();
+
+  // Private but used in tests.
+  highlights = new GrDiffHighlight();
+
+  // Private but used in tests.
+  diffBuilder = new GrDiffBuilderElement();
+
+  static override get styles() {
+    return [
+      iconStyles,
+      sharedStyles,
+      grSyntaxTheme,
+      grRangedCommentTheme,
+      css`
+        /**
+          This is used to hide all left side of the diff (e.g. diffs besides
+          comments in the change log). Since we want to remove the first 4
+          cells consistently in all rows except context buttons (.dividerRow).
+        */
+        :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
+        :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
+          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));
+          /* usually 16px = 12px + 4px */
+          line-height: calc(
+            var(--font-size, var(--font-size-code, 12px)) +
+              var(--spacing-s, 4px)
+          );
+        }
+
+        .thread-group {
+          display: block;
+          max-width: var(--content-width, 80ch);
+          white-space: normal;
+          background-color: var(--diff-blank-background-color);
+        }
+        .diffContainer {
+          max-width: var(--diff-max-width, none);
+          display: flex;
+          font-family: var(--monospace-font-family);
+        }
+        table {
+          border-collapse: collapse;
+          table-layout: fixed;
+        }
+        td.lineNum {
+          /* Enforces background whenever lines wrap */
+          background-color: var(--diff-blank-background-color);
+        }
+
+        /**
+          Provides the option to add side borders (left and right) to the line
+          number column.
+        */
+        td.lineNum,
+        td.blankLineNum,
+        td.moveControlsLineNumCol,
+        td.contextLineNum {
+          box-shadow: var(--line-number-box-shadow, unset);
+        }
+
+        /**
+          Context controls break up the table visually, so we set the right
+          border on individual sections to leave a gap for the divider.
+
+          Also taken into account for max-width calculations in SHRINK_ONLY mode
+          (check GrDiff.updatePreferenceStyles).
+        */
+        .section {
+          border-right: 1px solid var(--border-color);
+        }
+        .section.contextControl {
+          /**
+            Divider inside this section must not have border; we set borders on
+            the padding rows below.
+          */
+          border-right-width: 0;
+        }
+        /**
+          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);
+        }
+        .contextBackground.above {
+          border-bottom: 1px solid var(--border-color);
+        }
+        .contextBackground.below {
+          border-top: 1px solid var(--border-color);
+        }
+
+        .lineNumButton {
+          display: block;
+          width: 100%;
+          height: 100%;
+          background-color: var(--diff-blank-background-color);
+          box-shadow: var(--line-number-box-shadow, unset);
+        }
+        td.lineNum {
+          vertical-align: top;
+        }
+
+        /**
+          The only way to focus this (clicking) will apply our own focus
+          styling, so this default styling is not needed and distracting.
+        */
+        .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);
+          /**
+            Defined by paper-styles default-theme and used in various
+            components. background-color-secondary is a compromise between
+            fairly light in light theme (where we ideally would want
+            background-color-primary) yet slightly offset against the app
+            background in dark mode, where drop shadows e.g. around paper-card
+            are almost invisible.
+          */
+          --primary-background-color: var(--background-color-secondary);
+        }
+        .image-diff .gr-diff {
+          text-align: center;
+        }
+        .image-diff img {
+          box-shadow: var(--elevation-level-1);
+          max-width: 50em;
+        }
+        .image-diff .right.lineNumButton {
+          border-left: 1px solid var(--border-color);
+        }
+        .image-diff label,
+        .binary-diff label {
+          font-family: var(--font-family);
+          font-style: italic;
+        }
+        .diff-row {
+          outline: none;
+          user-select: none;
+        }
+        .diff-row.target-row.target-side-left .lineNumButton.left,
+        .diff-row.target-row.target-side-right .lineNumButton.right,
+        .diff-row.target-row.unified .lineNumButton {
+          color: var(--primary-text-color);
+        }
+
+        /**
+          Preparing selected line cells with position relative so it allows a
+          positioned overlay with 'position: absolute'.
+        */
+        .target-row td {
+          position: relative;
+        }
+
+        /**
+          Defines an overlay to the selected line for drawing an outline without
+          blocking user interaction (e.g. text selection).
+        */
+        .target-row td::before {
+          border-width: 0;
+          border-style: solid;
+          border-color: var(--focused-line-outline-color);
+          position: absolute;
+          top: 0;
+          left: 0;
+          width: 100%;
+          height: 100%;
+          pointer-events: none;
+          user-select: none;
+          content: ' ';
+        }
+
+        /**
+          the outline for the selected content cell should be the same in all
+          cases.
+        */
+        .target-row.target-side-left td.left.content::before,
+        .target-row.target-side-right td.right.content::before,
+        .unified.target-row td.content::before {
+          border-width: 1px 1px 1px 0;
+        }
+
+        /**
+          the outline for the sign cell should be always be contiguous
+          top/bottom.
+        */
+        .target-row.target-side-left td.left.sign::before,
+        .target-row.target-side-right td.right.sign::before {
+          border-width: 1px 0;
+        }
+
+        /**
+          For side-by-side we need to select the correct line number to
+          "visually close" the outline.
+        */
+        .side-by-side.target-row.target-side-left td.left.lineNum::before,
+        .side-by-side.target-row.target-side-right td.right.lineNum::before {
+          border-width: 1px 0 1px 1px;
+        }
+
+        /**
+          For unified diff we always start the overlay from the left cell
+        */
+        .unified.target-row td.left:not(.content)::before {
+          border-width: 1px 0 1px 1px;
+        }
+
+        /**
+          For unified diff we should continue the top/bottom border in right
+          line number column.
+        */
+        .unified.target-row td.right:not(.content)::before {
+          border-width: 1px 0;
+        }
+
+        .content {
+          background-color: var(--diff-blank-background-color);
+        }
+
+        /**
+          Describes two states of semantic tokens: whenever a token has a
+          definition that can be navigated to (navigable) and whenever
+          the token is actually clickable to perform this navigation.
+        */
+        .semantic-token.navigable {
+          text-decoration-style: dotted;
+          text-decoration-line: underline;
+        }
+        .semantic-token.navigable.clickable {
+          text-decoration-style: solid;
+          cursor: pointer;
+        }
+
+        /*
+          The file line, which has no contentText, add some margin before the
+          first comment. We cannot add padding the container because we only
+          want it if there is at least one comment thread, and the slotting
+          makes :empty not work as expected.
+        */
+        .content.file slot:first-child::slotted(.comment-thread) {
+          display: block;
+          margin-top: var(--spacing-xs);
+        }
+        .contentText {
+          background-color: var(--view-background-color);
+        }
+        .blank {
+          background-color: var(--diff-blank-background-color);
+        }
+        .image-diff .content {
+          background-color: var(--diff-blank-background-color);
+        }
+        .responsive {
+          width: 100%;
+        }
+        .responsive .contentText {
+          white-space: break-spaces;
+          word-break: break-all;
+        }
+        .lineNumButton,
+        .content {
+          vertical-align: top;
+          white-space: pre;
+        }
+        .contextLineNum,
+        .lineNumButton {
+          -webkit-user-select: none;
+          -moz-user-select: none;
+          -ms-user-select: none;
+          user-select: none;
+
+          color: var(--deemphasized-text-color);
+          padding: 0 var(--spacing-m);
+          text-align: right;
+        }
+        .canComment .lineNumButton {
+          cursor: pointer;
+        }
+        .sign {
+          min-width: 1ch;
+          width: 1ch;
+          background-color: var(--view-background-color);
+        }
+        .sign.blank {
+          background-color: var(--diff-blank-background-color);
+        }
+        .content {
+          /*
+            Set min width since setting width on table cells still allows them
+            to shrink. Do not set max width because CJK
+            (Chinese-Japanese-Korean) glyphs have variable width
+          */
+          min-width: var(--content-width, 80ch);
+          width: var(--content-width, 80ch);
+        }
+        .content.add .contentText .intraline,
+          /* If there are no intraline info, consider everything changed */
+          .content.add.no-intraline-info .contentText,
+          .sign.add.no-intraline-info,
+          .delta.total .content.add .contentText {
+          background-color: var(--dark-add-highlight-color);
+        }
+        .content.add .contentText,
+        .sign.add {
+          background-color: var(--light-add-highlight-color);
+        }
+        .content.remove .contentText .intraline,
+          /* If there are no intraline info, consider everything changed */
+          .content.remove.no-intraline-info .contentText,
+          .delta.total .content.remove .contentText,
+          .sign.remove.no-intraline-info {
+          background-color: var(--dark-remove-highlight-color);
+        }
+        .content.remove .contentText,
+        .sign.remove {
+          background-color: var(--light-remove-highlight-color);
+        }
+
+        .ignoredWhitespaceOnly .sign.no-intraline-info {
+          background-color: var(--view-background-color);
+        }
+
+        /* dueToRebase */
+        .dueToRebase .content.add .contentText .intraline,
+        .delta.total.dueToRebase .content.add .contentText {
+          background-color: var(--dark-rebased-add-highlight-color);
+        }
+        .dueToRebase .content.add .contentText {
+          background-color: var(--light-rebased-add-highlight-color);
+        }
+        .dueToRebase .content.remove .contentText .intraline,
+        .delta.total.dueToRebase .content.remove .contentText {
+          background-color: var(--dark-rebased-remove-highlight-color);
+        }
+        .dueToRebase .content.remove .contentText {
+          background-color: var(--light-rebased-remove-highlight-color);
+        }
+
+        /* dueToMove */
+        .dueToMove .sign.add,
+        .dueToMove .content.add .contentText,
+        .dueToMove .moveControls.movedIn .sign.right,
+        .dueToMove .moveControls.movedIn .moveHeader,
+        .delta.total.dueToMove .content.add .contentText {
+          background-color: var(--diff-moved-in-background);
+        }
+
+        .dueToMove.changed .sign.add,
+        .dueToMove.changed .content.add .contentText,
+        .dueToMove.changed .moveControls.movedIn .sign.right,
+        .dueToMove.changed .moveControls.movedIn .moveHeader,
+        .delta.total.dueToMove.changed .content.add .contentText {
+          background-color: var(--diff-moved-in-changed-background);
+        }
+
+        .dueToMove .sign.remove,
+        .dueToMove .content.remove .contentText,
+        .dueToMove .moveControls.movedOut .moveHeader,
+        .dueToMove .moveControls.movedOut .sign.left,
+        .delta.total.dueToMove .content.remove .contentText {
+          background-color: var(--diff-moved-out-background);
+        }
+
+        .delta.dueToMove .movedIn .moveHeader {
+          --gr-range-header-color: var(--diff-moved-in-label-color);
+        }
+        .delta.dueToMove.changed .movedIn .moveHeader {
+          --gr-range-header-color: var(--diff-moved-in-changed-label-color);
+        }
+        .delta.dueToMove .movedOut .moveHeader {
+          --gr-range-header-color: var(--diff-moved-out-label-color);
+        }
+
+        .moveHeader a {
+          color: inherit;
+        }
+
+        /* ignoredWhitespaceOnly */
+        .ignoredWhitespaceOnly .content.add .contentText .intraline,
+        .delta.total.ignoredWhitespaceOnly .content.add .contentText,
+        .ignoredWhitespaceOnly .content.add .contentText,
+        .ignoredWhitespaceOnly .content.remove .contentText .intraline,
+        .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
+        .ignoredWhitespaceOnly .content.remove .contentText {
+          background-color: var(--view-background-color);
+        }
+
+        .content .contentText gr-diff-text:empty:after,
+        .content .contentText gr-legacy-text:empty:after,
+        .content .contentText:empty:after {
+          /* Newline, to ensure empty lines are one line-height tall. */
+          content: '\\A';
+        }
+
+        /* Context controls */
+        .contextControl {
+          display: var(--context-control-display, table-row-group);
+          background-color: transparent;
+          border: none;
+          --divider-height: var(--spacing-s);
+          --divider-border: 1px;
+        }
+        /* TODO: Is this still used? */
+        .contextControl gr-button gr-icon {
+          /* should match line-height of gr-button */
+          font-size: var(--line-height-mono, 18px);
+        }
+        .contextControl td:not(.lineNumButton) {
+          text-align: center;
+        }
+
+        /**
+          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);
+        }
+        .contextBackground > td:not(.contextLineNum) {
+          background-color: var(--view-background-color);
+        }
+        .contextBackground {
+          /**
+            One line of background behind the context expanders which they can
+            render on top of, plus some padding.
+          */
+          height: calc(var(--line-height-normal) + var(--spacing-s));
+        }
+
+        .dividerCell {
+          vertical-align: top;
+        }
+        .dividerRow.show-both .dividerCell {
+          height: var(--divider-height);
+        }
+        .dividerRow.show-above .dividerCell,
+        .dividerRow.show-above .dividerCell {
+          height: 0;
+        }
+
+        .br:after {
+          /* Line feed */
+          content: '\\A';
+        }
+        .tab {
+          display: inline-block;
+        }
+        .tab-indicator:before {
+          color: var(--diff-tab-indicator-color);
+          /* >> character */
+          content: '\\00BB';
+          position: absolute;
+        }
+        .special-char-indicator {
+          /* spacing so elements don't collide */
+          padding-right: var(--spacing-m);
+        }
+        .special-char-indicator:before {
+          color: var(--diff-tab-indicator-color);
+          content: '•';
+          position: absolute;
+        }
+        .special-char-warning {
+          /* spacing so elements don't collide */
+          padding-right: var(--spacing-m);
+        }
+        .special-char-warning:before {
+          color: var(--warning-foreground);
+          content: '!';
+          position: absolute;
+        }
+        /**
+          Is defined after other background-colors, such that this
+          rule wins in case of same specificity.
+        */
+        .trailing-whitespace,
+        .content .contentText .trailing-whitespace,
+        .trailing-whitespace .intraline,
+        .content .contentText .trailing-whitespace .intraline {
+          border-radius: var(--border-radius, 4px);
+          background-color: var(--diff-trailing-whitespace-indicator);
+        }
+        #diffHeader {
+          background-color: var(--table-header-background-color);
+          border-bottom: 1px solid var(--border-color);
+          color: var(--link-color);
+          padding: var(--spacing-m) 0 var(--spacing-m) 48px;
+        }
+        #diffTable {
+          /* for gr-selection-action-box positioning */
+          position: relative;
+        }
+        #diffTable:focus {
+          outline: none;
+        }
+        #loadingError,
+        #sizeWarning {
+          display: block;
+          margin: var(--spacing-l) auto;
+          max-width: 60em;
+          text-align: center;
+        }
+        #loadingError {
+          color: var(--error-text-color);
+        }
+        #sizeWarning gr-button {
+          margin: var(--spacing-l);
+        }
+        .target-row td.blame {
+          background: var(--diff-selection-background-color);
+        }
+        td.lost div {
+          background-color: var(--info-background);
+        }
+        td.lost div.lost-message {
+          font-family: var(--font-family, 'Roboto');
+          font-size: var(--font-size-normal, 14px);
+          line-height: var(--line-height-normal);
+          padding: var(--spacing-s) 0;
+        }
+        td.lost div.lost-message gr-icon {
+          padding: 0 var(--spacing-s) 0 var(--spacing-m);
+          color: var(--blue-700);
+        }
+
+        col.sign,
+        td.sign {
+          display: none;
+        }
+
+        /* Sign column should only be shown in high-contrast mode. */
+        :host(.with-sign-col) col.sign {
+          display: table-column;
+        }
+        :host(.with-sign-col) td.sign {
+          display: table-cell;
+        }
+        col.blame {
+          display: none;
+        }
+        td.blame {
+          display: none;
+          padding: 0 var(--spacing-m);
+          white-space: pre;
+        }
+        :host(.showBlame) col.blame {
+          display: table-column;
+        }
+        :host(.showBlame) td.blame {
+          display: table-cell;
+        }
+        td.blame > span {
+          opacity: 0.6;
+        }
+        td.blame > span.startOfRange {
+          opacity: 1;
+        }
+        td.blame .blameDate {
+          font-family: var(--monospace-font-family);
+          color: var(--link-color);
+          text-decoration: none;
+        }
+        .responsive td.blame {
+          overflow: hidden;
+          width: 200px;
+        }
+        /** Support the line length indicator **/
+        .responsive td.content .contentText {
+          /**
+            Same strategy as in
+            https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+          */
+          background-image: linear-gradient(
+            var(--line-length-indicator-color),
+            var(--line-length-indicator-color)
+          );
+          background-size: 1px 100%;
+          background-position: var(--line-limit-marker) 0;
+          background-repeat: no-repeat;
+        }
+        .newlineWarning {
+          color: var(--deemphasized-text-color);
+          text-align: center;
+        }
+        .newlineWarning.hidden {
+          display: none;
+        }
+        .lineNum.COVERED .lineNumButton {
+          color: var(
+            --coverage-covered-line-num-color,
+            var(--deemphasized-text-color)
+          );
+          background-color: var(--coverage-covered, #e0f2f1);
+        }
+        .lineNum.NOT_COVERED .lineNumButton {
+          color: var(
+            --coverage-covered-line-num-color,
+            var(--deemphasized-text-color)
+          );
+          background-color: var(--coverage-not-covered, #ffd1a4);
+        }
+        .lineNum.PARTIALLY_COVERED .lineNumButton {
+          color: var(
+            --coverage-covered-line-num-color,
+            var(--deemphasized-text-color)
+          );
+          background: linear-gradient(
+            to right bottom,
+            var(--coverage-not-covered, #ffd1a4) 0%,
+            var(--coverage-not-covered, #ffd1a4) 50%,
+            var(--coverage-covered, #e0f2f1) 50%,
+            var(--coverage-covered, #e0f2f1) 100%
+          );
+        }
+
+        // TODO: Investigate whether this CSS is still necessary.
+        /** BEGIN: Select and copy for Polymer 2 */
+        /**
+          Below was copied and modified from the original css in
+          gr-diff-selection.html
+        */
+        .content,
+        .contextControl,
+        .blame {
+          -webkit-user-select: none;
+          -moz-user-select: none;
+          -ms-user-select: none;
+          user-select: none;
+        }
+
+        .selected-left:not(.selected-comment)
+          .side-by-side
+          .left
+          + .content
+          .contentText,
+        .selected-right:not(.selected-comment)
+          .side-by-side
+          .right
+          + .content
+          .contentText,
+        .selected-left:not(.selected-comment)
+          .unified
+          .left.lineNum
+          ~ .content:not(.both)
+          .contentText,
+        .selected-right:not(.selected-comment)
+          .unified
+          .right.lineNum
+          ~ .content
+          .contentText,
+        .selected-left.selected-comment .side-by-side .left + .content .message,
+        .selected-right.selected-comment
+          .side-by-side
+          .right
+          + .content
+          .message
+          :not(.collapsedContent),
+        .selected-comment .unified .message :not(.collapsedContent),
+        .selected-blame .blame {
+          -webkit-user-select: text;
+          -moz-user-select: text;
+          -ms-user-select: text;
+          user-select: text;
+        }
+
+        /** Make comments and check results selectable when selected */
+        .selected-left.selected-comment
+          ::slotted(.comment-thread[diff-side='left']),
+        .selected-right.selected-comment
+          ::slotted(.comment-thread[diff-side='right']) {
+          -webkit-user-select: text;
+          -moz-user-select: text;
+          -ms-user-select: text;
+          user-select: text;
+        }
+        /** END: Select and copy for Polymer 2 */
+
+        .whitespace-change-only-message {
+          background-color: var(--diff-context-control-background-color);
+          border: 1px solid var(--diff-context-control-border-color);
+          text-align: center;
+        }
+
+        .token-highlight {
+          background-color: var(--token-highlighting-color, #fffd54);
+        }
+
+        gr-selection-action-box {
+          /**
+          * Needs z-index to appear above wrapped content, since it's inserted
+          * into DOM before it.
+          */
+          z-index: 10;
+        }
+
+        gr-diff-image-new,
+        gr-diff-image-old,
+        gr-diff-section,
+        gr-context-controls-section,
+        gr-diff-row {
+          display: contents;
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    this.addEventListener('create-range-comment', (e: Event) =>
+      this.handleCreateRangeComment(e as CustomEvent)
+    );
+    this.addEventListener('render-content', () => this.handleRenderContent());
+    this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
+      this.dispatchSelectedLine(e.detail.lineNum, e.detail.side);
+    });
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    if (this.loggedIn) {
+      this.addSelectionListeners();
+    }
+    if (this.diff && this.diffTable) {
+      this.diffSelection.init(this.diff, this.diffTable);
+    }
+    if (this.diffTable && this.diffBuilder) {
+      this.highlights.init(this.diffTable, this.diffBuilder);
+    }
+    this.diffBuilder.init();
+  }
+
+  override disconnectedCallback() {
+    this.removeSelectionListeners();
+    this.renderDiffTableTask?.cancel();
+    this.diffSelection.cleanup();
+    this.highlights.cleanup();
+    this.diffBuilder.cleanup();
+    super.disconnectedCallback();
+  }
+
+  protected override willUpdate(changedProperties: PropertyValues<this>): void {
+    if (
+      changedProperties.has('path') ||
+      changedProperties.has('lineWrapping') ||
+      changedProperties.has('viewMode') ||
+      changedProperties.has('useNewImageDiffUi') ||
+      changedProperties.has('prefs')
+    ) {
+      this.prefsChanged();
+    }
+    if (changedProperties.has('blame')) {
+      this.blameChanged();
+    }
+    if (changedProperties.has('renderPrefs')) {
+      this.renderPrefsChanged();
+    }
+    if (changedProperties.has('loggedIn')) {
+      if (this.loggedIn && this.isConnected) {
+        this.addSelectionListeners();
+      } else {
+        this.removeSelectionListeners();
+      }
+    }
+    if (changedProperties.has('coverageRanges')) {
+      this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+    }
+    if (changedProperties.has('lineOfInterest')) {
+      this.lineOfInterestChanged();
+    }
+  }
+
+  protected override updated(changedProperties: PropertyValues<this>): void {
+    if (changedProperties.has('diff')) {
+      // diffChanged relies on diffTable ahving been rendered.
+      this.diffChanged();
+    }
+  }
+
+  override render() {
+    return html`
+      ${this.renderHeader()} ${this.renderContainer()}
+      ${this.renderNewlineWarning()} ${this.renderLoadingError()}
+      ${this.renderSizeWarning()}
+    `;
+  }
+
+  private renderHeader() {
+    const diffheaderItems = this.computeDiffHeaderItems();
+    if (diffheaderItems.length === 0) return nothing;
+    return html`
+      <div id="diffHeader">
+        ${diffheaderItems.map(item => html`<div>${item}</div>`)}
+      </div>
+    `;
+  }
+
+  private renderContainer() {
+    const cssClasses = {
+      diffContainer: true,
+      unified: this.viewMode === DiffViewMode.UNIFIED,
+      sideBySide: this.viewMode === DiffViewMode.SIDE_BY_SIDE,
+      canComment: this.loggedIn,
+      displayLine: this.displayLine,
+    };
+    return html`
+      <div class=${classMap(cssClasses)} @click=${this.handleTap}>
+        <table
+          id="diffTable"
+          class=${this.diffTableClass}
+          ?contenteditable=${this.isContentEditable}
+        ></table>
+        ${when(
+          this.showNoChangeMessage(),
+          () => html`
+            <div class="whitespace-change-only-message">
+              This file only contains whitespace changes. Modify the whitespace
+              setting to see the changes.
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  private renderNewlineWarning() {
+    const newlineWarning = this.computeNewlineWarning();
+    if (!newlineWarning) return nothing;
+    return html`<div class="newlineWarning">${newlineWarning}</div>`;
+  }
+
+  private renderLoadingError() {
+    if (!this.errorMessage) return nothing;
+    return html`<div id="loadingError">${this.errorMessage}</div>`;
+  }
+
+  private renderSizeWarning() {
+    if (!this.showWarning) return nothing;
+    // TODO: Update comment about 'Whole file' as it's not in settings.
+    return html`
+      <div id="sizeWarning">
+        <p>
+          Prevented render because "Whole file" is enabled and this diff is very
+          large (about ${this.diffLength} lines).
+        </p>
+        <gr-button @click=${this.collapseContext}>
+          Render with limited context
+        </gr-button>
+        <gr-button @click=${this.handleFullBypass}>
+          Render anyway (may be slow)
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private addSelectionListeners() {
+    document.addEventListener('selectionchange', this.handleSelectionChange);
+    document.addEventListener('mouseup', this.handleMouseUp);
+  }
+
+  private removeSelectionListeners() {
+    document.removeEventListener('selectionchange', this.handleSelectionChange);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+  }
+
+  getLineNumEls(side: Side): HTMLElement[] {
+    return this.diffBuilder.getLineNumEls(side);
+  }
+
+  // Private but used in tests.
+  showNoChangeMessage() {
+    return (
+      !this.loading &&
+      this.diff &&
+      !this.diff.binary &&
+      this.prefs &&
+      this.prefs.ignore_whitespace !== 'IGNORE_NONE' &&
+      this.diffLength === 0
+    );
+  }
+
+  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);
+  };
+
+  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. */
+  private getShadowOrDocumentSelection() {
+    // When using native shadow DOM, the selection returned by
+    // document.getSelection() cannot reference the actual DOM elements making
+    // up the diff in Safari because they are in the shadow DOM of the gr-diff
+    // element. This takes the shadow DOM selection if one exists.
+    return this.shadowRoot?.getSelection
+      ? this.shadowRoot.getSelection()
+      : isSafari()
+      ? getContentEditableRange()
+      : document.getSelection();
+  }
+
+  private updateRanges(
+    addedThreadEls: GrDiffThreadElement[],
+    removedThreadEls: GrDiffThreadElement[]
+  ) {
+    function commentRangeFromThreadEl(
+      threadEl: GrDiffThreadElement
+    ): CommentRangeLayer | undefined {
+      const side = getSide(threadEl);
+      if (!side) return undefined;
+      const range = getRange(threadEl);
+      if (!range) return undefined;
+
+      return {side, range, rootId: threadEl.rootId};
+    }
+
+    // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
+    const addedCommentRanges = addedThreadEls
+      .map(commentRangeFromThreadEl)
+      .filter(range => !!range) as CommentRangeLayer[];
+    const removedCommentRanges = removedThreadEls
+      .map(commentRangeFromThreadEl)
+      .filter(range => !!range) as CommentRangeLayer[];
+    for (const removedCommentRange of removedCommentRanges) {
+      const i = this.commentRanges.findIndex(
+        cr =>
+          cr.side === removedCommentRange.side &&
+          rangesEqual(cr.range, removedCommentRange.range)
+      );
+      this.commentRanges.splice(i, 1);
+    }
+
+    if (addedCommentRanges?.length) {
+      this.commentRanges.push(...addedCommentRanges);
+    }
+    if (this.highlightRange) {
+      this.commentRanges.push({
+        side: Side.RIGHT,
+        range: this.highlightRange,
+        rootId: '',
+      });
+    }
+
+    this.diffBuilder.updateCommentRanges(this.commentRanges);
+  }
+
+  /**
+   * The key locations based on the comments and line of interests,
+   * where lines should not be collapsed.
+   *
+   */
+  private computeKeyLocations() {
+    const keyLocations: KeyLocations = {left: {}, right: {}};
+    if (this.lineOfInterest) {
+      const side = this.lineOfInterest.side;
+      keyLocations[side][this.lineOfInterest.lineNum] = true;
+    }
+    const threadEls = [...this.childNodes].filter(isThreadEl);
+
+    for (const threadEl of threadEls) {
+      const side = getSide(threadEl);
+      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) {
+        keyLocations[side][commentRange.start_line] = true;
+      }
+    }
+    return keyLocations;
+  }
+
+  // Dispatch events that are handled by the gr-diff-highlight.
+  private 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.cleanup();
+    this.renderDiffTableTask?.cancel();
+  }
+
+  getCursorStops(): Array<HTMLElement | AbortStop> {
+    if (this.hidden && this.noAutoRender) return [];
+
+    // Get rendered stops.
+    const stops: Array<HTMLElement | AbortStop> =
+      this.diffBuilder.getLineNumberRows();
+
+    // If we are still loading this diff, abort after the rendered stops to
+    // avoid skipping over to e.g. the next file.
+    if (this.loading) {
+      stops.push(new AbortStop());
+    }
+    return stops;
+  }
+
+  isRangeSelected() {
+    return !!this.highlights.selectedRange;
+  }
+
+  toggleLeftDiff() {
+    toggleClass(this, 'no-left');
+  }
+
+  private blameChanged() {
+    this.diffBuilder.setBlame(this.blame);
+    if (this.blame) {
+      this.classList.add('showBlame');
+    } else {
+      this.classList.remove('showBlame');
+    }
+  }
+
+  // Private but used in tests.
+  handleTap(e: Event) {
+    const el = e.target as Element;
+
+    if (
+      el.getAttribute('data-value') !== 'LOST' &&
+      (el.classList.contains('lineNum') ||
+        el.classList.contains('lineNumButton'))
+    ) {
+      this.addDraftAtLine(el);
+    } else if (
+      el.tagName === 'HL' ||
+      el.classList.contains('content') ||
+      el.classList.contains('contentText')
+    ) {
+      const target = getLineElByChild(el);
+      if (target) {
+        this.selectLine(target);
+      }
+    }
+  }
+
+  // Private but used in tests.
+  selectLine(el: Element) {
+    const lineNumber = Number(el.getAttribute('data-value'));
+    const side = el.classList.contains('left') ? Side.LEFT : Side.RIGHT;
+    this.dispatchSelectedLine(lineNumber, side);
+  }
+
+  private dispatchSelectedLine(number: LineNumber, side: Side) {
+    this.dispatchEvent(
+      new CustomEvent('line-selected', {
+        detail: {
+          number,
+          side,
+          path: this.path,
+        },
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  addDraftAtLine(el: Element) {
+    this.selectLine(el);
+
+    const lineNum = getLineNumber(el);
+    if (lineNum === null) {
+      fireAlert(this, 'Invalid line number');
+      return;
+    }
+
+    this.createComment(el, lineNum);
+  }
+
+  createRangeComment() {
+    if (!this.isRangeSelected()) {
+      throw Error('Selection is needed for new range comment');
+    }
+    const selectedRange = this.highlights.selectedRange;
+    if (!selectedRange) throw Error('selected range not set');
+    const {side, range} = selectedRange;
+    this.createCommentForSelection(side, range);
+  }
+
+  createCommentForSelection(side: Side, range: CommentRange) {
+    const lineNum = range.end_line;
+    const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
+    if (lineEl) {
+      this.createComment(lineEl, lineNum, side, range);
+    }
+  }
+
+  private handleCreateRangeComment(e: CustomEvent) {
+    const range = e.detail.range;
+    const side = e.detail.side;
+    this.createCommentForSelection(side, range);
+  }
+
+  // Private but used in tests.
+  createComment(
+    lineEl: Element,
+    lineNum: LineNumber,
+    side?: Side,
+    range?: CommentRange
+  ) {
+    const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+    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<CreateCommentEventDetail>('create-comment', {
+        bubbles: true,
+        composed: true,
+        detail: {
+          path: this.path,
+          side,
+          lineNum,
+          range,
+        },
+      })
+    );
+  }
+
+  private getCommentSideByLineAndContent(
+    lineEl: Element,
+    contentEl: Element
+  ): Side {
+    return lineEl.classList.contains(Side.LEFT) ||
+      contentEl.classList.contains('remove')
+      ? Side.LEFT
+      : Side.RIGHT;
+  }
+
+  private lineOfInterestChanged() {
+    if (this.loading) return;
+    if (!this.lineOfInterest) return;
+    const lineNum = this.lineOfInterest.lineNum;
+    if (typeof lineNum !== 'number') return;
+    this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+  }
+
+  private cleanup() {
+    this.cancel();
+    this.blame = null;
+    this.safetyBypass = null;
+    this.showWarning = false;
+    this.clearDiffContent();
+  }
+
+  private prefsChanged() {
+    if (!this.prefs) return;
+
+    this.blame = null;
+    this.updatePreferenceStyles();
+
+    if (this.diff && !this.noRenderOnPrefsChange) {
+      this.debounceRenderDiffTable();
+    }
+  }
+
+  private updatePreferenceStyles() {
+    assertIsDefined(this.prefs, 'prefs');
+    const lineLength =
+      this.path === COMMIT_MSG_PATH
+        ? COMMIT_MSG_LINE_LENGTH
+        : this.prefs.line_length;
+    const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
+
+    const responsiveMode = getResponsiveMode(this.prefs, this.renderPrefs);
+    const responsive = isResponsive(responsiveMode);
+    this.diffTableClass = responsive ? 'responsive' : '';
+    const lineLimit = `${lineLength}ch`;
+    this.style.setProperty(
+      '--line-limit-marker',
+      responsiveMode === 'FULL_RESPONSIVE' ? lineLimit : '-1px'
+    );
+    this.style.setProperty('--content-width', responsive ? 'none' : lineLimit);
+    if (responsiveMode === 'SHRINK_ONLY') {
+      // Calculating ideal (initial) width for the whole table including
+      // width of each table column (content and line number columns) and
+      // border. We also add a 1px correction as some values are calculated
+      // in 'ch'.
+
+      // We might have 1 to 2 columns for content depending if side-by-side
+      // or unified mode
+      const contentWidth = `${sideBySide ? 2 : 1} * ${lineLimit}`;
+
+      // We always have 2 columns for line number
+      const lineNumberWidth = `2 * ${getLineNumberCellWidth(this.prefs)}px`;
+
+      // border-right in ".section" css definition (in gr-diff_html.ts)
+      const sectionRightBorder = '1px';
+
+      // each sign col has 1ch width.
+      const signColsWidth =
+        sideBySide && this.renderPrefs?.show_sign_col ? '2ch' : '0ch';
+
+      // As some of these calculations are done using 'ch' we end up having <1px
+      // difference between ideal and calculated size for each side leading to
+      // lines using the max columns (e.g. 80) to wrap (decided exclusively by
+      // the browser).This happens even in monospace fonts. Empirically adding
+      // 2px as correction to be sure wrapping won't happen in these cases so it
+      // doesn't block further experimentation with the SHRINK_MODE. This was
+      // previously set to 1px but due to to a more aggressive text wrapping
+      // (via word-break: break-all; - check .contextText) we need to be even
+      // more lenient in some cases. If we find another way to avoid this
+      // correction we will change it.
+      const dontWrapCorrection = '2px';
+      this.style.setProperty(
+        '--diff-max-width',
+        `calc(${contentWidth} + ${lineNumberWidth} + ${signColsWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`
+      );
+    } else {
+      this.style.setProperty('--diff-max-width', 'none');
+    }
+    if (this.prefs.font_size) {
+      this.style.setProperty('--font-size', `${this.prefs.font_size}px`);
+    }
+  }
+
+  private renderPrefsChanged() {
+    if (!this.renderPrefs) return;
+    if (this.renderPrefs.hide_left_side) {
+      this.classList.add('no-left');
+    }
+    if (this.renderPrefs.disable_context_control_buttons) {
+      this.classList.add('disable-context-control-buttons');
+    }
+    if (this.renderPrefs.hide_line_length_indicator) {
+      this.classList.add('hide-line-length-indicator');
+    }
+    if (this.renderPrefs.show_sign_col) {
+      this.classList.add('with-sign-col');
+    }
+    if (this.prefs) {
+      this.updatePreferenceStyles();
+    }
+    this.diffBuilder.updateRenderPrefs(this.renderPrefs);
+  }
+
+  private diffChanged() {
+    this.loading = true;
+    this.cleanup();
+    if (this.diff) {
+      this.diffLength = this.getDiffLength(this.diff);
+      this.debounceRenderDiffTable();
+      assertIsDefined(this.diffTable, 'diffTable');
+      this.diffSelection.init(this.diff, this.diffTable);
+      this.highlights.init(this.diffTable, this.diffBuilder);
+    }
+  }
+
+  // Implemented so the test can stub it.
+  getDiffLength(diff?: DiffInfo) {
+    return getDiffLength(diff);
+  }
+
+  /**
+   * When called multiple times from the same task, will call
+   * _renderDiffTable only once, in the next task (scheduled via `setTimeout`).
+   *
+   * This should be used instead of calling _renderDiffTable directly to
+   * render the diff in response to an input change, because there may be
+   * multiple inputs changing in the same microtask, but we only want to
+   * render once.
+   */
+  private debounceRenderDiffTable() {
+    // at this point gr-diff might be considered as rendered from the outside
+    // (client), although it was not actually rendered. Clients need to know
+    // when it is safe to perform operations like cursor moves, for example,
+    // and if changing an input actually requires a reload of the diff table.
+    // Since `fireEvent` is synchronous it allows clients to be aware when an
+    // async render is needed and that they can wait for a further `render`
+    // event to actually take further action.
+    fireEvent(this, 'render-required');
+    this.renderDiffTableTask = debounceP(
+      this.renderDiffTableTask,
+      async () => await this.renderDiffTable()
+    );
+    this.renderDiffTableTask.catch((e: unknown) => {
+      if (e === DELAYED_CANCELLATION) return;
+      throw e;
+    });
+  }
+
+  // Private but used in tests.
+  async renderDiffTable() {
+    this.unobserveNodes();
+    if (!this.prefs) {
+      fireEvent(this, 'render');
+      return;
+    }
+    if (
+      this.prefs.context === -1 &&
+      this.diffLength &&
+      this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
+      this.safetyBypass === null
+    ) {
+      this.showWarning = true;
+      fireEvent(this, 'render');
+      return;
+    }
+
+    this.showWarning = false;
+
+    const keyLocations = this.computeKeyLocations();
+
+    // TODO: Setting tons of public properties like this is obviously a code
+    // smell. We are planning to introduce a diff model for managing all this
+    // data. Then diff builder will only need access to that model.
+    this.diffBuilder.prefs = this.getBypassPrefs();
+    this.diffBuilder.renderPrefs = this.renderPrefs;
+    this.diffBuilder.diff = this.diff;
+    this.diffBuilder.path = this.path;
+    this.diffBuilder.viewMode = this.viewMode;
+    this.diffBuilder.layers = this.layers ?? [];
+    this.diffBuilder.isImageDiff = this.isImageDiff;
+    this.diffBuilder.baseImage = this.baseImage ?? null;
+    this.diffBuilder.revisionImage = this.revisionImage ?? null;
+    this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
+    this.diffBuilder.diffElement = this.diffTable;
+    // `this.commentRanges` are probably empty here, because they will only be
+    // populated by the node observer, which starts observing *after* rendering.
+    this.diffBuilder.updateCommentRanges(this.commentRanges);
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+    await this.diffBuilder.render(keyLocations);
+  }
+
+  private handleRenderContent() {
+    this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
+      element.remove()
+    );
+    this.loading = false;
+    this.observeNodes();
+    // We are just converting 'render-content' into 'render' here. Maybe we
+    // should retire the 'render' event in favor of 'render-content'?
+    fireEvent(this, 'render');
+  }
+
+  private observeNodes() {
+    // First stop observing old nodes.
+    this.unobserveNodes();
+    // Then introduce a Mutation observer that watches for children being added
+    // to gr-diff. If those children are `isThreadEl`, namely then they are
+    // processed.
+    this.nodeObserver = new MutationObserver(mutations => {
+      const addedThreadEls = extractAddedNodes(mutations).filter(isThreadEl);
+      const removedThreadEls =
+        extractRemovedNodes(mutations).filter(isThreadEl);
+      this.processNodes(addedThreadEls, removedThreadEls);
+    });
+    this.nodeObserver.observe(this, {childList: true});
+    // Make sure to process existing gr-comment-threads that already exist.
+    this.processNodes([...this.childNodes].filter(isThreadEl), []);
+  }
+
+  private processNodes(
+    addedThreadEls: GrDiffThreadElement[],
+    removedThreadEls: GrDiffThreadElement[]
+  ) {
+    this.updateRanges(addedThreadEls, removedThreadEls);
+    addedThreadEls.forEach(threadEl =>
+      this.redispatchHoverEvents(threadEl, threadEl)
+    );
+    // Removed nodes do not need to be handled because all this code does is
+    // adding a slot for the added thread elements, and the extra slots do
+    // not hurt. It's probably a bigger performance cost to remove them than
+    // to keep them around. Medium term we can even consider to add one slot
+    // for each line from the start.
+    for (const threadEl of addedThreadEls) {
+      const lineNum = getLine(threadEl);
+      const commentSide = getSide(threadEl);
+      const range = getRange(threadEl);
+      if (!commentSide) continue;
+      const lineEl = this.diffBuilder.getLineElByNumber(lineNum, commentSide);
+      // When the line the comment refers to does not exist, log an error
+      // but don't crash. This can happen e.g. if the API does not fully
+      // validate e.g. (robot) comments
+      if (!lineEl) {
+        console.error(
+          'thread attached to line ',
+          commentSide,
+          lineNum,
+          ' which does not exist.'
+        );
+        continue;
+      }
+      const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+      if (!contentEl) continue;
+      if (lineNum === 'LOST') {
+        this.insertPortedCommentsWithoutRangeMessage(contentEl);
+      }
+
+      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);
+      }
+    }
+
+    for (const threadEl of removedThreadEls) {
+      this.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
+      )?.remove();
+    }
+  }
+
+  private unobserveNodes() {
+    if (this.nodeObserver) {
+      this.nodeObserver.disconnect();
+      this.nodeObserver = undefined;
+    }
+    // You only stop observing for comment thread elements when the diff is
+    // completely rendered from scratch. And then comment thread elements
+    // will be (re-)added *after* rendering is done. That is also when we
+    // re-start observing. So it is appropriate to thoroughly clean up
+    // everything that the observer is managing.
+    this.commentRanges = [];
+  }
+
+  private insertPortedCommentsWithoutRangeMessage(lostCell: Element) {
+    const existingMessage = lostCell.querySelector('div.lost-message');
+    if (existingMessage) return;
+
+    const div = document.createElement('div');
+    div.className = 'lost-message';
+    const icon = document.createElement('gr-icon');
+    icon.setAttribute('icon', 'info');
+    div.appendChild(icon);
+    const span = document.createElement('span');
+    span.innerText = 'Original comment position not found in this patchset';
+    div.appendChild(span);
+    lostCell.insertBefore(div, lostCell.firstChild);
+  }
+
+  /**
+   * Get the preferences object including the safety bypass context (if any).
+   */
+  private getBypassPrefs() {
+    assertIsDefined(this.prefs, 'prefs');
+    if (this.safetyBypass !== null) {
+      return {...this.prefs, context: this.safetyBypass};
+    }
+    return this.prefs;
+  }
+
+  clearDiffContent() {
+    this.unobserveNodes();
+    if (!this.diffTable) return;
+    while (this.diffTable.hasChildNodes()) {
+      this.diffTable.removeChild(this.diffTable.lastChild!);
+    }
+  }
+
+  // Private but used in tests.
+  computeDiffHeaderItems() {
+    return (this.diff?.diff_header ?? [])
+      .filter(
+        item =>
+          !(
+            item.startsWith('diff --git ') ||
+            item.startsWith('index ') ||
+            item.startsWith('+++ ') ||
+            item.startsWith('--- ') ||
+            item === 'Binary files differ'
+          )
+      )
+      .map(expandFileMode);
+  }
+
+  private handleFullBypass() {
+    this.safetyBypass = FULL_CONTEXT;
+    this.debounceRenderDiffTable();
+  }
+
+  private 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();
+  }
+
+  toggleAllContext() {
+    if (!this.prefs) {
+      return;
+    }
+    if (this.getBypassPrefs().context < 0) {
+      this.collapseContext();
+    } else {
+      this.handleFullBypass();
+    }
+  }
+
+  private computeNewlineWarning(): string | undefined {
+    const messages = [];
+    if (this.showNewlineWarningLeft) {
+      messages.push(NO_NEWLINE_LEFT);
+    }
+    if (this.showNewlineWarningRight) {
+      messages.push(NO_NEWLINE_RIGHT);
+    }
+    if (!messages.length) {
+      return undefined;
+    }
+    return messages.join(' \u2014 '); // \u2014 - '—'
+  }
+}
+
+function extractAddedNodes(mutations: MutationRecord[]) {
+  return mutations.flatMap(mutation => [...mutation.addedNodes]);
+}
+
+function extractRemovedNodes(mutations: MutationRecord[]) {
+  return mutations.flatMap(mutation => [...mutation.removedNodes]);
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff': GrDiff;
+  }
+  interface HTMLElementEventMap {
+    'loading-changed': ValueChangedEvent<boolean>;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
new file mode 100644
index 0000000..ce1393d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -0,0 +1,4190 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {createDiff} from '../../../test/test-data-generators';
+import './gr-diff';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import '@polymer/paper-button/paper-button';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  IgnoreWhitespaceType,
+  Side,
+} from '../../../api/diff';
+import {
+  mockPromise,
+  mouseDown,
+  query,
+  queryAll,
+  queryAndAssert,
+  waitEventLoop,
+  waitQueryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
+import {AbortStop} from '../../../api/core';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiff} from './gr-diff';
+import {ImageInfo} from '../../../types/common';
+import {GrRangedCommentHint} from '../gr-ranged-comment-hint/gr-ranged-comment-hint';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    assert.isAccessible(await fixture(html`<gr-diff></gr-diff>`));
+  });
+});
+
+suite('gr-diff tests', () => {
+  let element: GrDiff;
+
+  const MINIMAL_PREFS: DiffPreferencesInfo = {
+    tab_size: 2,
+    line_length: 80,
+    font_size: 12,
+    context: 3,
+    ignore_whitespace: 'IGNORE_NONE',
+  };
+
+  setup(async () => {
+    element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+  });
+
+  suite('rendering', () => {
+    test('empty diff', async () => {
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer sideBySide">
+            <table id="diffTable"></table>
+          </div>
+        `
+      );
+    });
+
+    test('a unified diff legacy', async () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      await testUnified();
+    });
+
+    test('a unified diff lit', async () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      element.renderPrefs = {...element.renderPrefs, use_lit_components: true};
+      await testUnified();
+    });
+
+    const testUnified = async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.diff = createDiff();
+      await element.updateComplete;
+      await waitForEventOnce(element, 'render');
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer unified">
+            <table class="selected-right" id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff" width="48" />
+                <col class="gr-diff" width="48" />
+                <col class="gr-diff" />
+              </colgroup>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-LOST right-button-LOST right-content-LOST"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="LOST"></td>
+                  <td class="gr-diff left lineNum" data-value="LOST"></td>
+                  <td class="gr-diff lineNum right" data-value="LOST"></td>
+                  <td class="both content gr-diff lost no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-FILE right-button-FILE right-content-FILE"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="FILE"></td>
+                  <td class="gr-diff left lineNum" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff left lineNumButton"
+                      data-value="FILE"
+                      id="left-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff lineNumButton right"
+                      data-value="FILE"
+                      id="right-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="both content file gr-diff no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-1 right-button-1 right-content-1"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="1"></td>
+                  <td class="gr-diff left lineNum" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="1"
+                      id="left-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="1"
+                      id="right-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-1"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-2 right-button-2 right-content-2"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="2"></td>
+                  <td class="gr-diff left lineNum" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="2"
+                      id="left-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="2"
+                      id="right-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-2"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-3 right-button-3 right-content-3"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="3"></td>
+                  <td class="gr-diff left lineNum" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="3"
+                      id="left-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="3"
+                      id="right-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-3"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-4 right-button-4 right-content-4"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="4"></td>
+                  <td class="gr-diff left lineNum" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="4"
+                      id="left-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="4"
+                      id="right-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-4"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-5 right-content-5"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="5">
+                    <button
+                      aria-label="5 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="5"
+                      id="right-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-5"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-6 right-content-6"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="6">
+                    <button
+                      aria-label="6 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="6"
+                      id="right-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-6"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-7 right-content-7"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="7">
+                    <button
+                      aria-label="7 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="7"
+                      id="right-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-7"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-5 right-button-8 right-content-8"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="5"></td>
+                  <td class="gr-diff left lineNum" data-value="5">
+                    <button
+                      aria-label="5 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="5"
+                      id="left-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="8"
+                      id="right-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-8"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-6 right-button-9 right-content-9"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="6"></td>
+                  <td class="gr-diff left lineNum" data-value="6">
+                    <button
+                      aria-label="6 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="6"
+                      id="left-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="9"
+                      id="right-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-9"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-7 right-button-10 right-content-10"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="7"></td>
+                  <td class="gr-diff left lineNum" data-value="7">
+                    <button
+                      aria-label="7 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="7"
+                      id="left-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="10">
+                    <button
+                      aria-label="10 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="10"
+                      id="right-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-8 right-button-11 right-content-11"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="8"></td>
+                  <td class="gr-diff left lineNum" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="8"
+                      id="left-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="11">
+                    <button
+                      aria-label="11 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="11"
+                      id="right-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-9 right-button-12 right-content-12"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="9"></td>
+                  <td class="gr-diff left lineNum" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="9"
+                      id="left-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="12">
+                    <button
+                      aria-label="12 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="12"
+                      id="right-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="left-button-10 left-content-10"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="10"></td>
+                  <td class="gr-diff left lineNum" data-value="10">
+                    <button
+                      aria-label="10 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="10"
+                      id="left-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-11 left-content-11"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="11"></td>
+                  <td class="gr-diff left lineNum" data-value="11">
+                    <button
+                      aria-label="11 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="11"
+                      id="left-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-12 left-content-12"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="12"></td>
+                  <td class="gr-diff left lineNum" data-value="12">
+                    <button
+                      aria-label="12 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="12"
+                      id="left-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-13 left-content-13"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="13"></td>
+                  <td class="gr-diff left lineNum" data-value="13">
+                    <button
+                      aria-label="13 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="13"
+                      id="left-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+                <tr
+                  aria-labelledby="right-button-13 right-content-13"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="13">
+                    <button
+                      aria-label="13 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="13"
+                      id="right-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-14 right-content-14"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="14">
+                    <button
+                      aria-label="14 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="14"
+                      id="right-button-14"
+                      tabindex="-1"
+                    >
+                      14
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-14"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section">
+                <tr
+                  aria-labelledby="left-button-16 left-content-16"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="16"></td>
+                  <td class="gr-diff left lineNum" data-value="16">
+                    <button
+                      aria-label="16 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="16"
+                      id="left-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-15 right-content-15"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="15">
+                    <button
+                      aria-label="15 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="15"
+                      id="right-button-15"
+                      tabindex="-1"
+                    >
+                      15
+                    </button>
+                  </td>
+                  <td class="add content gr-diff right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-15"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-17 right-button-16 right-content-16"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="17"></td>
+                  <td class="gr-diff left lineNum" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="17"
+                      id="left-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="16">
+                    <button
+                      aria-label="16 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="16"
+                      id="right-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-18 right-button-17 right-content-17"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="18"></td>
+                  <td class="gr-diff left lineNum" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="18"
+                      id="left-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="17"
+                      id="right-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-17"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-19 right-button-18 right-content-18"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="19"></td>
+                  <td class="gr-diff left lineNum" data-value="19">
+                    <button
+                      aria-label="19 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="19"
+                      id="left-button-19"
+                      tabindex="-1"
+                    >
+                      19
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="18"
+                      id="right-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-18"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="contextControl gr-diff section">
+                <tr class="above contextBackground gr-diff unified">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+                <tr class="dividerRow gr-diff show-both">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="dividerCell gr-diff" colspan="3">
+                    <gr-context-controls class="gr-diff" showconfig="both">
+                    </gr-context-controls>
+                  </td>
+                </tr>
+                <tr class="below contextBackground gr-diff unified">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-38 right-button-37 right-content-37"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="38"></td>
+                  <td class="gr-diff left lineNum" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="38"
+                      id="left-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="37">
+                    <button
+                      aria-label="37 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="37"
+                      id="right-button-37"
+                      tabindex="-1"
+                    >
+                      37
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-37"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-39 right-button-38 right-content-38"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="39"></td>
+                  <td class="gr-diff left lineNum" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="39"
+                      id="left-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="38"
+                      id="right-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-38"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-40 right-button-39 right-content-39"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="40"></td>
+                  <td class="gr-diff left lineNum" data-value="40">
+                    <button
+                      aria-label="40 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="40"
+                      id="left-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="39"
+                      id="right-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-39"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-40 right-content-40"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="40">
+                    <button
+                      aria-label="40 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="40"
+                      id="right-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-40"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-41 right-content-41"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="41">
+                    <button
+                      aria-label="41 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="41"
+                      id="right-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-41"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-42 right-content-42"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="42">
+                    <button
+                      aria-label="42 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="42"
+                      id="right-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-42"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-43 right-content-43"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="43">
+                    <button
+                      aria-label="43 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="43"
+                      id="right-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-43"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-41 right-button-44 right-content-44"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="41"></td>
+                  <td class="gr-diff left lineNum" data-value="41">
+                    <button
+                      aria-label="41 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="41"
+                      id="left-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="44"
+                      id="right-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-44"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-42 right-button-45 right-content-45"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="42"></td>
+                  <td class="gr-diff left lineNum" data-value="42">
+                    <button
+                      aria-label="42 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="42"
+                      id="left-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="45"
+                      id="right-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-45"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-43 right-button-46 right-content-46"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="43"></td>
+                  <td class="gr-diff left lineNum" data-value="43">
+                    <button
+                      aria-label="43 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="43"
+                      id="left-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="46">
+                    <button
+                      aria-label="46 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="46"
+                      id="right-button-46"
+                      tabindex="-1"
+                    >
+                      46
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-46"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-44 right-button-47 right-content-47"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="44"></td>
+                  <td class="gr-diff left lineNum" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="44"
+                      id="left-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="47">
+                    <button
+                      aria-label="47 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="47"
+                      id="right-button-47"
+                      tabindex="-1"
+                    >
+                      47
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-47"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-45 right-button-48 right-content-48"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="45"></td>
+                  <td class="gr-diff left lineNum" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="45"
+                      id="left-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="48">
+                    <button
+                      aria-label="48 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="48"
+                      id="right-button-48"
+                      tabindex="-1"
+                    >
+                      48
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-48"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        `,
+        {
+          ignoreTags: [
+            'gr-context-controls-section',
+            'gr-diff-section',
+            'gr-diff-row',
+            'gr-diff-text',
+            'gr-legacy-text',
+            'slot',
+          ],
+        }
+      );
+    };
+
+    test('a normal diff legacy', async () => {
+      await testNormal();
+    });
+
+    test('a normal diff lit', async () => {
+      element.renderPrefs = {...element.renderPrefs, use_lit_components: true};
+      await testNormal();
+    });
+
+    const testNormal = async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.diff = createDiff();
+      await element.updateComplete;
+      await waitForEventOnce(element, 'render');
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer sideBySide">
+            <table class="selected-right" id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff left" width="48" />
+                <col class="gr-diff left sign" />
+                <col class="gr-diff left" />
+                <col class="gr-diff right" width="48" />
+                <col class="gr-diff right sign" />
+                <col class="gr-diff right" />
+              </colgroup>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-LOST left-content-LOST right-button-LOST right-content-LOST"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="LOST"></td>
+                  <td class="gr-diff left lineNum" data-value="LOST"></td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left lost no-intraline-info">
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="LOST"></td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff lost no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="FILE"></td>
+                  <td class="gr-diff left lineNum" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff left lineNumButton"
+                      data-value="FILE"
+                      id="left-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content file gr-diff left no-intraline-info">
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff lineNumButton right"
+                      data-value="FILE"
+                      id="right-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content file gr-diff no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="1"></td>
+                  <td class="gr-diff left lineNum" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="1"
+                      id="left-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-1"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="1"
+                      id="right-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-1"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-2 left-content-2 right-button-2 right-content-2"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="2"></td>
+                  <td class="gr-diff left lineNum" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="2"
+                      id="left-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-2"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="2"
+                      id="right-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-2"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-3 left-content-3 right-button-3 right-content-3"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="3"></td>
+                  <td class="gr-diff left lineNum" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="3"
+                      id="left-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-3"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="3"
+                      id="right-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-3"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-4 left-content-4 right-button-4 right-content-4"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="4"></td>
+                  <td class="gr-diff left lineNum" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="4"
+                      id="left-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-4"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="4"
+                      id="right-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-4"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-5 right-content-5"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="5">
+                    <button
+                      aria-label="5 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="5"
+                      id="right-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-5"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-6 right-content-6"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="6">
+                    <button
+                      aria-label="6 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="6"
+                      id="right-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-6"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-7 right-content-7"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="7">
+                    <button
+                      aria-label="7 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="7"
+                      id="right-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-7"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-5 left-content-5 right-button-8 right-content-8"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="5"></td>
+                  <td class="gr-diff left lineNum" data-value="5">
+                    <button
+                      aria-label="5 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="5"
+                      id="left-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-5"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="8"
+                      id="right-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-8"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-6 left-content-6 right-button-9 right-content-9"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="6"></td>
+                  <td class="gr-diff left lineNum" data-value="6">
+                    <button
+                      aria-label="6 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="6"
+                      id="left-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-6"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="9"
+                      id="right-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-9"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-7 left-content-7 right-button-10 right-content-10"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="7"></td>
+                  <td class="gr-diff left lineNum" data-value="7">
+                    <button
+                      aria-label="7 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="7"
+                      id="left-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-7"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="10">
+                    <button
+                      aria-label="10 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="10"
+                      id="right-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-8 left-content-8 right-button-11 right-content-11"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="8"></td>
+                  <td class="gr-diff left lineNum" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="8"
+                      id="left-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-8"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="11">
+                    <button
+                      aria-label="11 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="11"
+                      id="right-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-9 left-content-9 right-button-12 right-content-12"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="9"></td>
+                  <td class="gr-diff left lineNum" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="9"
+                      id="left-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-9"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="12">
+                    <button
+                      aria-label="12 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="12"
+                      id="right-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="left-button-10 left-content-10"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="10"></td>
+                  <td class="gr-diff left lineNum" data-value="10">
+                    <button
+                      aria-label="10 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="10"
+                      id="left-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-11 left-content-11"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="11"></td>
+                  <td class="gr-diff left lineNum" data-value="11">
+                    <button
+                      aria-label="11 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="11"
+                      id="left-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-12 left-content-12"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="12"></td>
+                  <td class="gr-diff left lineNum" data-value="12">
+                    <button
+                      aria-label="12 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="12"
+                      id="left-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-13 left-content-13"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="13"></td>
+                  <td class="gr-diff left lineNum" data-value="13">
+                    <button
+                      aria-label="13 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="13"
+                      id="left-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+                <tr
+                  aria-labelledby="left-button-14 left-content-14 right-button-13 right-content-13"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="14"></td>
+                  <td class="gr-diff left lineNum" data-value="14">
+                    <button
+                      aria-label="14 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="14"
+                      id="left-button-14"
+                      tabindex="-1"
+                    >
+                      14
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-14"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="13">
+                    <button
+                      aria-label="13 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="13"
+                      id="right-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-15 left-content-15 right-button-14 right-content-14"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="15"></td>
+                  <td class="gr-diff left lineNum" data-value="15">
+                    <button
+                      aria-label="15 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="15"
+                      id="left-button-15"
+                      tabindex="-1"
+                    >
+                      15
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-15"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="14">
+                    <button
+                      aria-label="14 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="14"
+                      id="right-button-14"
+                      tabindex="-1"
+                    >
+                      14
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-14"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section">
+                <tr
+                  aria-labelledby="left-button-16 left-content-16 right-button-15 right-content-15"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="16"></td>
+                  <td class="gr-diff left lineNum" data-value="16">
+                    <button
+                      aria-label="16 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="16"
+                      id="left-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="gr-diff left remove sign">-</td>
+                  <td class="content gr-diff left remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="15">
+                    <button
+                      aria-label="15 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="15"
+                      id="right-button-15"
+                      tabindex="-1"
+                    >
+                      15
+                    </button>
+                  </td>
+                  <td class="add gr-diff right sign">+</td>
+                  <td class="add content gr-diff right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-15"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-17 left-content-17 right-button-16 right-content-16"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="17"></td>
+                  <td class="gr-diff left lineNum" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="17"
+                      id="left-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-17"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="16">
+                    <button
+                      aria-label="16 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="16"
+                      id="right-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-18 left-content-18 right-button-17 right-content-17"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="18"></td>
+                  <td class="gr-diff left lineNum" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="18"
+                      id="left-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-18"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="17"
+                      id="right-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-17"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-19 left-content-19 right-button-18 right-content-18"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="19"></td>
+                  <td class="gr-diff left lineNum" data-value="19">
+                    <button
+                      aria-label="19 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="19"
+                      id="left-button-19"
+                      tabindex="-1"
+                    >
+                      19
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-19"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="18"
+                      id="right-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-18"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="contextControl gr-diff section">
+                <tr
+                  class="above contextBackground gr-diff side-by-side"
+                  left-type="contextControl"
+                  right-type="contextControl"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+                <tr class="dividerRow gr-diff show-both">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff"></td>
+                  <td class="dividerCell gr-diff" colspan="3">
+                    <gr-context-controls
+                      class="gr-diff"
+                      showconfig="both"
+                    ></gr-context-controls>
+                  </td>
+                </tr>
+                <tr
+                  class="below contextBackground gr-diff side-by-side"
+                  left-type="contextControl"
+                  right-type="contextControl"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-38 left-content-38 right-button-37 right-content-37"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="38"></td>
+                  <td class="gr-diff left lineNum" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="38"
+                      id="left-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-38"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="37">
+                    <button
+                      aria-label="37 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="37"
+                      id="right-button-37"
+                      tabindex="-1"
+                    >
+                      37
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-37"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-39 left-content-39 right-button-38 right-content-38"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="39"></td>
+                  <td class="gr-diff left lineNum" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="39"
+                      id="left-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-39"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="38"
+                      id="right-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-38"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-40 left-content-40 right-button-39 right-content-39"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="40"></td>
+                  <td class="gr-diff left lineNum" data-value="40">
+                    <button
+                      aria-label="40 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="40"
+                      id="left-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-40"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="39"
+                      id="right-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-39"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-40 right-content-40"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="40">
+                    <button
+                      aria-label="40 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="40"
+                      id="right-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-40"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-41 right-content-41"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="41">
+                    <button
+                      aria-label="41 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="41"
+                      id="right-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-41"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-42 right-content-42"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="42">
+                    <button
+                      aria-label="42 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="42"
+                      id="right-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-42"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-43 right-content-43"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="43">
+                    <button
+                      aria-label="43 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="43"
+                      id="right-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-43"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-41 left-content-41 right-button-44 right-content-44"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="41"></td>
+                  <td class="gr-diff left lineNum" data-value="41">
+                    <button
+                      aria-label="41 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="41"
+                      id="left-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-41"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="44"
+                      id="right-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-44"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-42 left-content-42 right-button-45 right-content-45"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="42"></td>
+                  <td class="gr-diff left lineNum" data-value="42">
+                    <button
+                      aria-label="42 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="42"
+                      id="left-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-42"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="45"
+                      id="right-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-45"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-43 left-content-43 right-button-46 right-content-46"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="43"></td>
+                  <td class="gr-diff left lineNum" data-value="43">
+                    <button
+                      aria-label="43 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="43"
+                      id="left-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-43"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="46">
+                    <button
+                      aria-label="46 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="46"
+                      id="right-button-46"
+                      tabindex="-1"
+                    >
+                      46
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-46"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-44 left-content-44 right-button-47 right-content-47"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="44"></td>
+                  <td class="gr-diff left lineNum" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="44"
+                      id="left-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-44"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="47">
+                    <button
+                      aria-label="47 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="47"
+                      id="right-button-47"
+                      tabindex="-1"
+                    >
+                      47
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-47"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-45 left-content-45 right-button-48 right-content-48"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="45"></td>
+                  <td class="gr-diff left lineNum" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="45"
+                      id="left-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-45"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="48">
+                    <button
+                      aria-label="48 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="48"
+                      id="right-button-48"
+                      tabindex="-1"
+                    >
+                      48
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-48"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        `,
+        {
+          ignoreTags: [
+            'gr-context-controls-section',
+            'gr-diff-section',
+            'gr-diff-row',
+            'gr-diff-text',
+            'gr-legacy-text',
+            'slot',
+          ],
+        }
+      );
+    };
+  });
+
+  suite('selectionchange event handling', () => {
+    let handleSelectionChangeStub: sinon.SinonSpy;
+
+    const emulateSelection = function () {
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+    };
+
+    setup(async () => {
+      handleSelectionChangeStub = sinon.spy(
+        element.highlights,
+        'handleSelectionChange'
+      );
+    });
+
+    test('enabled if logged in', async () => {
+      element.loggedIn = true;
+      await element.updateComplete;
+      emulateSelection();
+      assert.isTrue(handleSelectionChangeStub.called);
+    });
+
+    test('ignored if logged out', async () => {
+      element.loggedIn = false;
+      await element.updateComplete;
+      emulateSelection();
+      assert.isFalse(handleSelectionChangeStub.called);
+    });
+  });
+
+  test('cancel', () => {
+    const cleanupStub = sinon.stub(element.diffBuilder, 'cleanup');
+    element.cancel();
+    assert.isTrue(cleanupStub.calledOnce);
+  });
+
+  test('line limit with line_wrapping', async () => {
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
+    await element.updateComplete;
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
+  });
+
+  test('line limit without line_wrapping', async () => {
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
+    await element.updateComplete;
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
+  });
+
+  suite('FULL_RESPONSIVE mode', () => {
+    setup(async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
+      await element.updateComplete;
+    });
+
+    test('line limit is based on line_length', async () => {
+      element.prefs = {...element.prefs!, line_length: 100};
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--line-limit-marker', element),
+        '100ch'
+      );
+    });
+
+    test('content-width should not be defined', () => {
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+  });
+
+  suite('SHRINK_ONLY mode', () => {
+    setup(async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
+      await element.updateComplete;
+    });
+
+    test('content-width should not be defined', () => {
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+
+    test('max-width considers two content columns in side-by-side', async () => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('max-width considers one content column in unified', async () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('max-width considers font-size', async () => {
+      element.prefs = {...element.prefs!, font_size: 13};
+      await element.updateComplete;
+      // Each line number column: 4 * 13 = 52px
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('sign cols are considered if show_sign_col is true', async () => {
+      element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)'
+      );
+    });
+  });
+
+  suite('not logged in', () => {
+    setup(async () => {
+      element.loggedIn = false;
+      await element.updateComplete;
+    });
+
+    test('toggleLeftDiff', () => {
+      element.toggleLeftDiff();
+      assert.isTrue(element.classList.contains('no-left'));
+      element.toggleLeftDiff();
+      assert.isFalse(element.classList.contains('no-left'));
+    });
+
+    test('view does not start with displayLine classList', () => {
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.isFalse(container.classList.contains('displayLine'));
+    });
+
+    test('displayLine class added when displayLine is true', async () => {
+      element.displayLine = true;
+      await element.updateComplete;
+      const container = queryAndAssert(element, '.diffContainer');
+      assert.isTrue(container.classList.contains('displayLine'));
+    });
+
+    suite('binary diffs', () => {
+      test('render binary diff', async () => {
+        element.prefs = {
+          ...MINIMAL_PREFS,
+        };
+        element.diff = {
+          meta_a: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+          meta_b: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+          change_type: 'MODIFIED',
+          intraline_status: 'OK',
+          diff_header: [],
+          content: [],
+          binary: true,
+        };
+        await waitForEventOnce(element, 'render');
+
+        assert.shadowDom.equal(
+          element,
+          /* HTML */ `
+            <div class="diffContainer sideBySide">
+              <table class="selected-right" id="diffTable">
+                <colgroup>
+                  <col class="blame gr-diff" />
+                  <col class="gr-diff" width="48" />
+                  <col class="gr-diff" width="48" />
+                  <col class="gr-diff" />
+                </colgroup>
+                <tbody class="binary-diff gr-diff"></tbody>
+                <tbody class="binary-diff gr-diff">
+                  <tr
+                    aria-labelledby="left-button-FILE right-button-FILE right-content-FILE"
+                    class="both diff-row gr-diff unified"
+                    tabindex="-1"
+                  >
+                    <td class="blame gr-diff" data-line-number="FILE"></td>
+                    <td class="gr-diff left lineNum" data-value="FILE">
+                      <button
+                        aria-label="Add file comment"
+                        class="gr-diff left lineNumButton"
+                        data-value="FILE"
+                        id="left-button-FILE"
+                        tabindex="-1"
+                      >
+                        File
+                      </button>
+                    </td>
+                    <td class="gr-diff lineNum right" data-value="FILE">
+                      <button
+                        aria-label="Add file comment"
+                        class="gr-diff lineNumButton right"
+                        data-value="FILE"
+                        id="right-button-FILE"
+                        tabindex="-1"
+                      >
+                        File
+                      </button>
+                    </td>
+                    <td
+                      class="both content file gr-diff no-intraline-info right"
+                    >
+                      <div>
+                        <span> Difference in binary files </span>
+                      </div>
+                      <div class="thread-group" data-side="right">
+                        <slot name="right-FILE"> </slot>
+                        <slot name="left-FILE"> </slot>
+                      </div>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          `
+        );
+      });
+    });
+
+    suite('image diffs', () => {
+      let mockFile1: ImageInfo;
+      let mockFile2: ImageInfo;
+      setup(() => {
+        mockFile1 = {
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
+        };
+
+        element.isImageDiff = true;
+        element.prefs = {
+          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,
+        };
+      });
+
+      test('render image diff', async () => {
+        element.baseImage = mockFile1;
+        element.revisionImage = mockFile2;
+        element.diff = {
+          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,
+        };
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        assert.lightDom.equal(
+          imageDiffSection,
+          /* HTML */ `
+            <tbody class="gr-diff image-diff">
+              <tr class="gr-diff">
+                <td class="blank gr-diff left lineNum"></td>
+                <td class="gr-diff left">
+                  <img
+                    class="gr-diff left"
+                    src="data:image/bmp;base64,${mockFile1.body}"
+                  />
+                </td>
+                <td class="blank gr-diff lineNum right"></td>
+                <td class="gr-diff right">
+                  <img
+                    class="gr-diff right"
+                    src="data:image/bmp;base64,${mockFile2.body}"
+                  />
+                </td>
+              </tr>
+              <tr class="gr-diff">
+                <td class="blank gr-diff left lineNum"></td>
+                <td class="gr-diff left">
+                  <label class="gr-diff">
+                    <span class="gr-diff label"> image/bmp </span>
+                  </label>
+                </td>
+                <td class="blank gr-diff lineNum right"></td>
+                <td class="gr-diff right">
+                  <label class="gr-diff">
+                    <span class="gr-diff label"> image/bmp </span>
+                  </label>
+                </td>
+              </tr>
+            </tbody>
+          `
+        );
+        const endpoint = queryAndAssert(element, 'tbody.endpoint');
+        assert.dom.equal(
+          endpoint,
+          /* HTML */ `
+            <tbody class="gr-diff endpoint">
+              <tr class="gr-diff">
+                <gr-endpoint-decorator class="gr-diff" name="image-diff">
+                  <gr-endpoint-param class="gr-diff" name="baseImage">
+                  </gr-endpoint-param>
+                  <gr-endpoint-param class="gr-diff" name="revisionImage">
+                  </gr-endpoint-param>
+                </gr-endpoint-decorator>
+              </tr>
+            </tbody>
+          `
+        );
+      });
+
+      test('renders image diffs with a different file name', async () => {
+        const mockDiff: DiffInfo = {
+          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,
+        };
+
+        element.baseImage = mockFile1;
+        element.baseImage._name = mockDiff.meta_a!.name;
+        element.revisionImage = mockFile2;
+        element.revisionImage._name = mockDiff.meta_b!.name;
+        element.diff = mockDiff;
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftLabel = queryAndAssert(imageDiffSection, 'td.left label');
+        const rightLabel = queryAndAssert(imageDiffSection, 'td.right label');
+        assert.dom.equal(
+          leftLabel,
+          /* HTML */ `
+            <label class="gr-diff">
+              <span class="gr-diff name"> carrot.jpg </span>
+              <br class="gr-diff" />
+              <span class="gr-diff label"> image/bmp </span>
+            </label>
+          `
+        );
+        assert.dom.equal(
+          rightLabel,
+          /* HTML */ `
+            <label class="gr-diff">
+              <span class="gr-diff name"> carrot2.jpg </span>
+              <br class="gr-diff" />
+              <span class="gr-diff label"> image/bmp </span>
+            </label>
+          `
+        );
+      });
+
+      test('renders added image', async () => {
+        const mockDiff: DiffInfo = {
+          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,
+        };
+        element.revisionImage = mockFile2;
+        element.diff = mockDiff;
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftImage = query(imageDiffSection, 'td.left img');
+        const rightImage = queryAndAssert(imageDiffSection, 'td.right img');
+        assert.isNotOk(leftImage);
+        assert.dom.equal(
+          rightImage,
+          /* HTML */ `
+            <img
+              class="gr-diff right"
+              src="data:image/bmp;base64,${mockFile2.body}"
+            />
+          `
+        );
+      });
+
+      test('renders removed image', async () => {
+        const mockDiff: DiffInfo = {
+          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,
+        };
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftImage = queryAndAssert(imageDiffSection, 'td.left img');
+        const rightImage = query(imageDiffSection, 'td.right img');
+        assert.isNotOk(rightImage);
+        assert.dom.equal(
+          leftImage,
+          /* HTML */ `
+            <img
+              class="gr-diff left"
+              src="data:image/bmp;base64,${mockFile1.body}"
+            />
+          `
+        );
+      });
+
+      test('does not render disallowed image type', async () => {
+        const mockDiff: DiffInfo = {
+          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';
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftImage = query(imageDiffSection, 'td.left img');
+        assert.isNotOk(leftImage);
+      });
+    });
+
+    test('handleTap lineNum', async () => {
+      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
+      const el = document.createElement('div');
+      el.className = 'lineNum';
+      const promise = mockPromise();
+      el.addEventListener('click', e => {
+        element.handleTap(e);
+        assert.isTrue(addDraftStub.called);
+        assert.equal(addDraftStub.lastCall.args[0], el);
+        promise.resolve();
+      });
+      el.click();
+      await promise;
+    });
+
+    test('handleTap content', async () => {
+      const content = document.createElement('div');
+      const lineEl = document.createElement('div');
+      lineEl.className = 'lineNum';
+      const row = document.createElement('div');
+      row.appendChild(lineEl);
+      row.appendChild(content);
+
+      const selectStub = sinon.stub(element, 'selectLine');
+
+      content.className = 'content';
+      const promise = mockPromise();
+      content.addEventListener('click', e => {
+        element.handleTap(e);
+        assert.isTrue(selectStub.called);
+        assert.equal(selectStub.lastCall.args[0], lineEl);
+        promise.resolve();
+      });
+      content.click();
+      await promise;
+    });
+
+    suite('getCursorStops', () => {
+      async function setupDiff() {
+        element.diff = createDiff();
+        element.prefs = {
+          context: 10,
+          tab_size: 8,
+          font_size: 12,
+          line_length: 100,
+          cursor_blink_rate: 0,
+          line_wrapping: false,
+
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          ignore_whitespace: 'IGNORE_NONE',
+        };
+        await element.updateComplete;
+        element.renderDiffTable();
+      }
+
+      test('returns [] when hidden and noAutoRender', async () => {
+        element.noAutoRender = true;
+        await setupDiff();
+        element.loading = false;
+        await element.updateComplete;
+        element.hidden = true;
+        await element.updateComplete;
+        assert.equal(element.getCursorStops().length, 0);
+      });
+
+      test('returns one stop per line and one for the file row', async () => {
+        await setupDiff();
+        element.loading = false;
+        await element.updateComplete;
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
+      });
+
+      test('returns an additional AbortStop when still loading', async () => {
+        await setupDiff();
+        element.loading = true;
+        await element.updateComplete;
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        const actual = element.getCursorStops();
+        assert.equal(actual.length, ROWS + FILE_ROW + 1);
+        assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
+      });
+    });
+  });
+
+  suite('logged in', async () => {
+    let fakeLineEl: HTMLElement;
+    setup(async () => {
+      element.loggedIn = true;
+
+      fakeLineEl = {
+        getAttribute: sinon.stub().returns(42),
+        classList: {
+          contains: sinon.stub().returns(true),
+        },
+      } as unknown as HTMLElement;
+      await element.updateComplete;
+    });
+
+    test('addDraftAtLine', () => {
+      sinon.stub(element, 'selectLine');
+      const createCommentStub = sinon.stub(element, 'createComment');
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(createCommentStub.calledWithExactly(fakeLineEl, 42));
+    });
+
+    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: ['asdf'],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
+      await setupSampleDiff({content});
+
+      element.appendChild(threadEl);
+
+      const hint = await waitQueryAndAssert<GrRangedCommentHint>(
+        element,
+        'gr-ranged-comment-hint'
+      );
+      assert.deepEqual(hint.range, range);
+    });
+
+    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('slot', 'right-1');
+      const content = [
+        {
+          a: ['asdf'],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
+      await setupSampleDiff({content});
+
+      element.appendChild(firstHint);
+      element.appendChild(threadEl);
+
+      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 = [
+        {
+          ab: Array(8).fill('text'),
+        },
+      ];
+      await setupSampleDiff({content});
+
+      element.appendChild(threadEl);
+      await waitUntil(() => element.commentRanges.length === 1);
+
+      threadEl.remove();
+      await waitUntil(() => element.commentRanges.length === 0);
+
+      assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
+    });
+
+    suite('change in preferences', () => {
+      setup(async () => {
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          diff_header: [],
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          content: [{skip: 66}],
+        };
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+      });
+
+      test('change in preferences re-renders diff', async () => {
+        const stub = sinon.stub(element, 'renderDiffTable');
+        element.prefs = {
+          ...MINIMAL_PREFS,
+        };
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+      });
+
+      test('adding/removing property in preferences re-renders diff', async () => {
+        const stub = sinon.stub(element, 'renderDiffTable');
+        const newPrefs1: DiffPreferencesInfo = {
+          ...MINIMAL_PREFS,
+          line_wrapping: true,
+        };
+        element.prefs = newPrefs1;
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+        stub.reset();
+
+        const newPrefs2 = {...newPrefs1};
+        delete newPrefs2.line_wrapping;
+        element.prefs = newPrefs2;
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+      });
+
+      test(
+        'change in preferences does not re-renders diff with ' +
+          'noRenderOnPrefsChange',
+        async () => {
+          const stub = sinon.stub(element, 'renderDiffTable');
+          element.noRenderOnPrefsChange = true;
+          element.prefs = {
+            ...MINIMAL_PREFS,
+            context: 12,
+          };
+          await element.updateComplete;
+          await element.renderDiffTableTask?.flush();
+          assert.isFalse(stub.called);
+        }
+      );
+    });
+  });
+
+  suite('diff header', () => {
+    setup(async () => {
+      element.diff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        diff_header: [],
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        content: [{skip: 66}],
+      };
+      await element.updateComplete;
+    });
+
+    test('hidden', async () => {
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('diff --git a/test.jpg b/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('index 2adc47d..f9c2f2c 100644');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('--- a/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('+++ b/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('test');
+      assert.equal(element.computeDiffHeaderItems().length, 1);
+      element.requestUpdate('diff');
+      await element.updateComplete;
+
+      const header = queryAndAssert(element, '#diffHeader');
+      assert.equal(header.textContent?.trim(), 'test');
+    });
+
+    test('binary files', () => {
+      element.diff!.binary = true;
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('diff --git a/test.jpg b/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('test');
+      assert.equal(element.computeDiffHeaderItems().length, 1);
+      element.diff?.diff_header?.push('Binary files differ');
+      assert.equal(element.computeDiffHeaderItems().length, 1);
+    });
+  });
+
+  suite('safety and bypass', () => {
+    let renderStub: sinon.SinonStub;
+
+    setup(async () => {
+      renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+        assertIsDefined(element.diffTable);
+        const diffTable = element.diffTable;
+        diffTable.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true})
+        );
+        return Promise.resolve();
+      });
+      sinon.stub(element, 'getDiffLength').returns(10000);
+      element.diff = createDiff();
+      element.noRenderOnPrefsChange = true;
+      await element.updateComplete;
+    });
+
+    test('large render w/ context = 10', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 10};
+      element.renderDiffTable();
+      await waitForEventOnce(element, 'render');
+
+      assert.isTrue(renderStub.called);
+      assert.isFalse(element.showWarning);
+    });
+
+    test('large render w/ whole file and bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      element.safetyBypass = 10;
+      element.renderDiffTable();
+      await waitForEventOnce(element, 'render');
+
+      assert.isTrue(renderStub.called);
+      assert.isFalse(element.showWarning);
+    });
+
+    test('large render w/ whole file and no bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      element.renderDiffTable();
+      await waitForEventOnce(element, 'render');
+
+      assert.isFalse(renderStub.called);
+      assert.isTrue(element.showWarning);
+    });
+
+    test('toggles expand context using bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+
+      element.toggleAllContext();
+      element.renderDiffTable();
+      await element.updateComplete;
+
+      assert.equal(element.prefs.context, 3);
+      assert.equal(element.safetyBypass, -1);
+      assert.equal(element.diffBuilder.prefs.context, -1);
+    });
+
+    test('toggles collapse context from bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+      element.safetyBypass = -1;
+
+      element.toggleAllContext();
+      element.renderDiffTable();
+      await element.updateComplete;
+
+      assert.equal(element.prefs.context, 3);
+      assert.isNull(element.safetyBypass);
+      assert.equal(element.diffBuilder.prefs.context, 3);
+    });
+
+    test('toggles collapse context from pref using default', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+
+      element.toggleAllContext();
+      element.renderDiffTable();
+      await element.updateComplete;
+
+      assert.equal(element.prefs.context, -1);
+      assert.equal(element.safetyBypass, 10);
+      assert.equal(element.diffBuilder.prefs.context, 10);
+    });
+  });
+
+  suite('blame', () => {
+    test('unsetting', async () => {
+      element.blame = [];
+      const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
+      element.classList.add('showBlame');
+      element.blame = null;
+      await element.updateComplete;
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isFalse(element.classList.contains('showBlame'));
+    });
+
+    test('setting', async () => {
+      element.blame = [
+        {
+          author: 'test-author',
+          time: 12345,
+          commit_msg: '',
+          id: 'commit id',
+          ranges: [{start: 1, end: 2}],
+        },
+      ];
+      await element.updateComplete;
+      assert.isTrue(element.classList.contains('showBlame'));
+    });
+  });
+
+  suite('trailing newline warnings', () => {
+    const NO_NEWLINE_LEFT = 'No newline at end of left file.';
+    const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
+
+    const getWarning = (element: GrDiff) => {
+      const warningElement = query(element, '.newlineWarning');
+      return warningElement?.textContent ?? '';
+    };
+
+    setup(async () => {
+      element.showNewlineWarningLeft = false;
+      element.showNewlineWarningRight = false;
+      await element.updateComplete;
+    });
+
+    test('shows combined warning if both sides set to warn', async () => {
+      element.showNewlineWarningLeft = true;
+      element.showNewlineWarningRight = true;
+      await element.updateComplete;
+      assert.include(
+        getWarning(element),
+        NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT
+      ); // \u2014 - '—'
+    });
+
+    suite('showNewlineWarningLeft', () => {
+      test('show warning if true', async () => {
+        element.showNewlineWarningLeft = true;
+        await element.updateComplete;
+        assert.include(getWarning(element), NO_NEWLINE_LEFT);
+      });
+
+      test('hide warning if false', async () => {
+        element.showNewlineWarningLeft = false;
+        await element.updateComplete;
+        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
+      });
+    });
+
+    suite('showNewlineWarningRight', () => {
+      test('show warning if true', async () => {
+        element.showNewlineWarningRight = true;
+        await element.updateComplete;
+        assert.include(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+
+      test('hide warning if false', async () => {
+        element.showNewlineWarningRight = false;
+        await element.updateComplete;
+        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+    });
+  });
+
+  suite('key locations', () => {
+    let renderStub: sinon.SinonStub;
+
+    setup(async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      renderStub = sinon.stub(element.diffBuilder, 'render');
+      await element.updateComplete;
+    });
+
+    test('lineOfInterest is a key location', () => {
+      element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
+      element.renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {789: true},
+        right: {},
+      });
+    });
+
+    test('line comments are key locations', async () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '3');
+      element.appendChild(threadEl);
+      await element.updateComplete;
+
+      element.renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {},
+        right: {3: true},
+      });
+    });
+
+    test('file comments are key locations', async () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'left');
+      element.appendChild(threadEl);
+      await element.updateComplete;
+
+      element.renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {FILE: true},
+        right: {},
+      });
+    });
+  });
+  const setupSampleDiff = async function (params: {
+    content: DiffContent[];
+    ignore_whitespace?: IgnoreWhitespaceType;
+    binary?: boolean;
+  }) {
+    const {ignore_whitespace, content} = params;
+    // binary can't be undefined, use false if not set
+    const binary = params.binary || false;
+    element.prefs = {
+      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
+      context: 10,
+      cursor_blink_rate: 0,
+      font_size: 12,
+
+      line_length: 100,
+      line_wrapping: false,
+      show_line_endings: true,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+    };
+    element.diff = {
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/carrot.js b/carrot.js',
+        'index 2adc47d..f9c2f2c 100644',
+        '--- a/carrot.js',
+        '+++ b/carrot.jjs',
+        'file differ',
+      ],
+      content,
+      binary,
+    };
+    await element.updateComplete;
+    await element.renderDiffTableTask;
+  };
+
+  test('clear diff table content as soon as diff changes', async () => {
+    const content = [
+      {
+        a: ['all work and no play make andybons a dull boy'],
+      },
+      {
+        b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
+      },
+    ];
+    function assertDiffTableWithContent() {
+      assertIsDefined(element.diffTable);
+      const diffTable = element.diffTable;
+      assert.isTrue(diffTable.innerText.includes(content[0].a?.[0] ?? ''));
+    }
+    await setupSampleDiff({content});
+    assertDiffTableWithContent();
+    element.diff = {...element.diff!};
+    await element.updateComplete;
+    // immediately cleaned up
+    assertIsDefined(element.diffTable);
+    const diffTable = element.diffTable;
+    assert.equal(diffTable.innerHTML, '');
+    element.renderDiffTable();
+    await element.updateComplete;
+    // rendered again
+    assertDiffTableWithContent();
+  });
+
+  suite('selection test', () => {
+    test('user-select set correctly on side-by-side view', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({content});
+      await waitEventLoop();
+
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      mouseDown(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+
+    test('user-select set correctly on unified view', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({content});
+      element.viewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      mouseDown(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+  });
+
+  suite('whitespace changes only message', () => {
+    test('show the message if ignore_whitespace is criteria matches', async () => {
+      await setupSampleDiff({content: [{skip: 100}]});
+      element.loading = false;
+      assert.isTrue(element.showNoChangeMessage());
+    });
+
+    test('do not show the message for binary files', async () => {
+      await setupSampleDiff({content: [{skip: 100}], binary: true});
+      element.loading = false;
+      assert.isFalse(element.showNoChangeMessage());
+    });
+
+    test('do not show the message if still loading', async () => {
+      await setupSampleDiff({content: [{skip: 100}]});
+      element.loading = true;
+      assert.isFalse(element.showNoChangeMessage());
+    });
+
+    test('do not show the message if contains valid changes', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({content});
+      element.loading = false;
+      assert.equal(element.diffLength, 3);
+      assert.isFalse(element.showNoChangeMessage());
+    });
+
+    test('do not show message if ignore whitespace is disabled', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
+      element.loading = false;
+      assert.isFalse(element.showNoChangeMessage());
+    });
+  });
+
+  test('getDiffLength', () => {
+    const diff = createDiff();
+    assert.equal(element.getDiffLength(diff), 52);
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
new file mode 100644
index 0000000..5d1eaa6
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header.ts
@@ -0,0 +1,64 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../elements/shared/gr-icon/gr-icon';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+
+/**
+ * Represents a header (label) for a code chunk whenever showing
+ * diffs.
+ * Used as a labeled header to describe selections in code for cases
+ * like long comments and moved in/out chunks.
+ */
+@customElement('gr-range-header')
+export class GrRangeHeader extends LitElement {
+  @property({type: String})
+  icon?: string;
+
+  @property({type: Boolean})
+  filled?: boolean;
+
+  static override get styles() {
+    return [
+      css`
+        .row {
+          color: var(--gr-range-header-color);
+          display: flex;
+          font-family: var(--font-family, ''), 'Roboto Mono';
+          font-size: var(--font-size-small, 12px);
+          font-weight: var(--code-hint-font-weight, 500);
+          line-height: var(--line-height-small, 16px);
+          justify-content: flex-end;
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .icon {
+          color: var(--gr-range-header-color);
+          font-size: var(--line-height-small, 16px);
+          margin-right: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const icon = this.icon ?? '';
+    return html` <div class="row">
+      <gr-icon
+        class="icon"
+        icon=${icon}
+        ?filled=${this.filled}
+        aria-hidden="true"
+      ></gr-icon>
+      <slot></slot>
+    </div>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-range-header': GrRangeHeader;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
new file mode 100644
index 0000000..d7883a0
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-range-header/gr-range-header';
+import {CommentRange} from '../../../types/common';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
+
+@customElement('gr-ranged-comment-hint')
+export class GrRangedCommentHint extends LitElement {
+  @property({type: Object})
+  range?: CommentRange;
+
+  static override get styles() {
+    return [
+      grRangedCommentTheme,
+      sharedStyles,
+      css`
+        .row {
+          display: flex;
+          --gr-range-header-color: var(--ranged-comment-hint-text-color);
+        }
+        gr-range-header {
+          flex-grow: 1;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div class="rangeHighlight row">
+      <gr-range-header icon="mode_comment" filled
+        >${this._computeRangeLabel(this.range)}</gr-range-header
+      >
+    </div>`;
+  }
+
+  _computeRangeLabel(range?: CommentRange): string {
+    if (!range) return '';
+    return `Long comment range ${range.start_line} - ${range.end_line}`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-ranged-comment-hint': GrRangedCommentHint;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
new file mode 100644
index 0000000..87ef3f8
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-ranged-comment-hint';
+import {CommentRange} from '../../../types/common';
+import {GrRangedCommentHint} from './gr-ranged-comment-hint';
+import {queryAndAssert, waitEventLoop} from '../../../test/test-utils';
+import {GrRangeHeader} from '../gr-range-header/gr-range-header';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-ranged-comment-hint tests', () => {
+  let element: GrRangedCommentHint;
+
+  setup(async () => {
+    element = await fixture(
+      html`<gr-ranged-comment-hint></gr-ranged-comment-hint>`
+    );
+    await waitEventLoop();
+  });
+
+  test('shows line range', async () => {
+    element.range = {
+      start_line: 2,
+      start_character: 1,
+      end_line: 5,
+      end_character: 3,
+    } as CommentRange;
+    await waitEventLoop();
+    const textDiv = queryAndAssert<GrRangeHeader>(element, 'gr-range-header');
+    assert.equal(textDiv?.innerText.trim(), 'Long comment range 2 - 5');
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
new file mode 100644
index 0000000..38eecfa
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -0,0 +1,213 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {strToClassName} from '../../../utils/dom-util';
+import {Side} from '../../../constants/constants';
+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
+ * outside.
+ *
+ * TODO(TS): Unify with what is used in gr-diff when these objects are created.
+ */
+export interface CommentRangeLayer {
+  side: Side;
+  range: CommentRange;
+  // New drafts don't have a rootId.
+  rootId?: string;
+}
+
+/** Can be used for array functions like `some()`. */
+function equals(a: CommentRangeLayer) {
+  return (b: CommentRangeLayer) => id(a) === id(b);
+}
+
+function id(r: CommentRangeLayer): string {
+  if (r.rootId) return r.rootId;
+  return `${r.side}-${r.range.start_line}-${r.range.start_character}-${r.range.end_line}-${r.range.end_character}`;
+}
+
+/**
+ * This class breaks down all comment ranges into individual line segment
+ * highlights.
+ */
+interface CommentRangeLineLayer {
+  longRange: boolean;
+  id: string;
+  // start char (0-based)
+  start: number;
+  // end char (0-based)
+  end: number;
+}
+
+type LinesMap = {
+  [line in number]: CommentRangeLineLayer[];
+};
+
+type RangesMap = {
+  [side in Side]: LinesMap;
+};
+
+const RANGE_BASE_ONLY = 'gr-diff range';
+const RANGE_HIGHLIGHT = 'gr-diff range rangeHighlight';
+// Note that there is also `rangeHoverHighlight` being set by GrDiffHighlight.
+
+/**
+ * This layer does not have a `reset` or `cleanup` method, so don't re-use it
+ * for rendering another diff. You should create a new layer then.
+ */
+export class GrRangedCommentLayer implements DiffLayer {
+  private knownRanges: CommentRangeLayer[] = [];
+
+  private listeners: DiffLayerListener[] = [];
+
+  private rangesMap: RangesMap = {left: {}, right: {}};
+
+  /**
+   * Layer method to add annotations to a line.
+   *
+   * @param el The DIV.contentText element to apply the annotation to.
+   */
+  annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+    let ranges: CommentRangeLineLayer[] = [];
+    if (
+      line.type === GrDiffLineType.REMOVE ||
+      (line.type === GrDiffLineType.BOTH &&
+        el.getAttribute('data-side') !== Side.RIGHT)
+    ) {
+      ranges = this.getRangesForLine(line, Side.LEFT);
+    }
+    if (
+      line.type === GrDiffLineType.ADD ||
+      (line.type === GrDiffLineType.BOTH &&
+        el.getAttribute('data-side') !== Side.LEFT)
+    ) {
+      ranges = this.getRangesForLine(line, Side.RIGHT);
+    }
+
+    for (const range of ranges) {
+      GrAnnotation.annotateElement(
+        el,
+        range.start,
+        range.end - range.start,
+        (range.longRange ? RANGE_BASE_ONLY : RANGE_HIGHLIGHT) +
+          ` ${strToClassName(range.id)}`
+      );
+    }
+  }
+
+  /**
+   * Register a listener for layer updates.
+   */
+  addListener(listener: DiffLayerListener) {
+    this.listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this.listeners = this.listeners.filter(f => f !== listener);
+  }
+
+  /**
+   * Notify Layer listeners of changes to annotations.
+   */
+  private notifyUpdateRange(start: number, end: number, side: Side) {
+    for (const listener of this.listeners) {
+      listener(start, end, side);
+    }
+  }
+
+  updateRanges(newRanges: CommentRangeLayer[]) {
+    for (const newRange of newRanges) {
+      if (this.knownRanges.some(equals(newRange))) continue;
+      this.addRange(newRange);
+    }
+
+    for (const knownRange of this.knownRanges) {
+      if (newRanges.some(equals(knownRange))) continue;
+      this.removeRange(knownRange);
+    }
+
+    this.knownRanges = [...newRanges];
+  }
+
+  private addRange(commentRange: CommentRangeLayer) {
+    const {side, range} = commentRange;
+    const longRange = isLongCommentRange(range);
+    this.updateRangesMap({
+      side,
+      range,
+      operation: (forLine, startChar, endChar) => {
+        if (startChar !== endChar)
+          forLine.push({
+            start: startChar,
+            end: endChar,
+            id: id(commentRange),
+            longRange,
+          });
+      },
+    });
+  }
+
+  private removeRange(commentRange: CommentRangeLayer) {
+    const {side, range} = commentRange;
+    this.updateRangesMap({
+      side,
+      range,
+      operation: forLine => {
+        const index = forLine.findIndex(
+          lineRange => id(commentRange) === lineRange.id
+        );
+        if (index > -1) forLine.splice(index, 1);
+      },
+    });
+  }
+
+  private updateRangesMap(options: {
+    side: Side;
+    range: CommentRange;
+    operation: (
+      forLine: CommentRangeLineLayer[],
+      start: number,
+      end: number
+    ) => void;
+  }) {
+    const {side, range, operation} = options;
+    const forSide = this.rangesMap[side] || (this.rangesMap[side] = {});
+    for (let line = range.start_line; line <= range.end_line; line++) {
+      const forLine = forSide[line] || (forSide[line] = []);
+      const start = line === range.start_line ? range.start_character : 0;
+      const end = line === range.end_line ? range.end_character : -1;
+      operation(forLine, start, end);
+    }
+    this.notifyUpdateRange(range.start_line, range.end_line, side);
+  }
+
+  // visible for testing
+  getRangesForLine(line: GrDiffLine, side: Side): CommentRangeLineLayer[] {
+    const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
+    if (lineNum === 'FILE' || lineNum === 'LOST') return [];
+    const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
+    return ranges.map(range => {
+      // Make a copy, so that the normalization below does not mess with
+      // our map.
+      range = {...range};
+      range.end = range.end === -1 ? line.text.length : range.end;
+
+      // Normalize invalid ranges where the start is after the end but the
+      // start still makes sense. Set the end to the end of the line.
+      // @see Issue 5744
+      if (range.start > range.end && range.start < line.text.length) {
+        range.end = line.text.length;
+      }
+
+      return range;
+    });
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
new file mode 100644
index 0000000..7feda47
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -0,0 +1,318 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../gr-diff/gr-diff-line';
+import './gr-ranged-comment-layer';
+import {
+  CommentRangeLayer,
+  GrRangedCommentLayer,
+} from './gr-ranged-comment-layer';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {Side} from '../../../api/diff';
+import {SinonStub} from 'sinon';
+import {assert} from '@open-wc/testing';
+
+const rangeA: CommentRangeLayer = {
+  side: Side.LEFT,
+  range: {
+    end_character: 9,
+    end_line: 39,
+    start_character: 6,
+    start_line: 36,
+  },
+  rootId: 'a',
+};
+
+const rangeB: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 22,
+    end_line: 12,
+    start_character: 10,
+    start_line: 10,
+  },
+  rootId: 'b',
+};
+
+const rangeC: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 15,
+    end_line: 100,
+    start_character: 5,
+    start_line: 100,
+  },
+};
+
+const rangeD: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 2,
+    end_line: 55,
+    start_character: 32,
+    start_line: 55,
+  },
+  rootId: 'd',
+};
+
+const rangeE: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 1,
+    end_line: 71,
+    start_character: 1,
+    start_line: 60,
+  },
+};
+
+const rangeF: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 0,
+    end_line: 24,
+    start_character: 0,
+    start_line: 23,
+  },
+};
+
+suite('gr-ranged-comment-layer', () => {
+  let element: GrRangedCommentLayer;
+
+  setup(() => {
+    const initialCommentRanges: CommentRangeLayer[] = [
+      rangeA,
+      rangeB,
+      rangeC,
+      rangeD,
+      rangeE,
+      rangeF,
+    ];
+
+    element = new GrRangedCommentLayer();
+    element.updateRanges(initialCommentRanges);
+  });
+
+  suite('annotate', () => {
+    let el: HTMLDivElement;
+    let line: GrDiffLine;
+    let annotateElementStub: SinonStub;
+    const lineNumberEl = document.createElement('td');
+
+    function assertHasRange(
+      commentRange: CommentRangeLayer,
+      hasRange: boolean
+    ) {
+      assertHasRangeOn(
+        commentRange.side,
+        commentRange.range.start_line,
+        hasRange
+      );
+    }
+
+    function assertHasRangeOn(
+      side: Side,
+      lineNumber: number,
+      hasRange: boolean
+    ) {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      if (side === Side.LEFT) line.beforeNumber = lineNumber;
+      if (side === Side.RIGHT) line.afterNumber = lineNumber;
+      el.setAttribute('data-side', side);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.called, hasRange);
+      annotateElementStub.reset();
+    }
+
+    setup(() => {
+      annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      el = document.createElement('div');
+      el.setAttribute('data-side', Side.LEFT);
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
+    });
+
+    test('type=Remove no-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.REMOVE);
+      line.beforeNumber = 40;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('type=Remove has-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.REMOVE);
+      line.beforeNumber = 36;
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(
+        lastCall.args[3],
+        'gr-diff range rangeHighlight generated_a'
+      );
+    });
+
+    test('type=Both has-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 36;
+
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(
+        lastCall.args[3],
+        'gr-diff range rangeHighlight generated_a'
+      );
+    });
+
+    test('type=Both has-comment off side', () => {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 36;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('type=Add has-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.ADD);
+      line.afterNumber = 12;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      const expectedStart = 0;
+      const expectedLength = 22;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(
+        lastCall.args[3],
+        'gr-diff range rangeHighlight generated_b'
+      );
+    });
+
+    test('long range comment', () => {
+      line = new GrDiffLine(GrDiffLineType.ADD);
+      line.afterNumber = 65;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(
+        annotateElementStub.lastCall.args[3],
+        'gr-diff range generated_right-60-1-71-1'
+      );
+    });
+
+    test('do not annotate lines with end_character 0', () => {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.afterNumber = 24;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('updateRanges remove all', () => {
+      assertHasRange(rangeA, true);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, true);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, true);
+
+      element.updateRanges([]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, false);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, false);
+      assertHasRange(rangeE, false);
+    });
+
+    test('updateRanges remove A and C', () => {
+      assertHasRange(rangeA, true);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, true);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, true);
+
+      element.updateRanges([rangeB, rangeD, rangeE]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, true);
+    });
+
+    test('updateRanges add B and D', () => {
+      element.updateRanges([]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, false);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, false);
+      assertHasRange(rangeE, false);
+
+      element.updateRanges([rangeB, rangeD]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, false);
+    });
+
+    test('updateRanges add A, remove B', () => {
+      element.updateRanges([rangeB, rangeC]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, true);
+
+      element.updateRanges([rangeA, rangeC]);
+
+      assertHasRange(rangeA, true);
+      assertHasRange(rangeB, false);
+      assertHasRange(rangeC, true);
+    });
+
+    test('_getRangesForLine normalizes invalid ranges', () => {
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.afterNumber = 55;
+      line.text = 'getRangesForLine normalizes invalid ranges';
+      const ranges = element.getRangesForLine(line, Side.RIGHT);
+      assert.equal(ranges.length, 1);
+      const range = ranges[0];
+      assert.isTrue(range.start < range.end, 'start and end are normalized');
+      assert.equal(range.end, line.text.length);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
new file mode 100644
index 0000000..38a9533
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+const $_documentContainer = document.createElement('template');
+
+export const grRangedCommentTheme = css`
+  .rangeHighlight {
+    background-color: var(--diff-highlight-range-color);
+  }
+  .rangeHoverHighlight {
+    background-color: var(--diff-highlight-range-hover-color);
+  }
+`;
+
+$_documentContainer.innerHTML = `<dom-module id="gr-ranged-comment-theme">
+  <template>
+    <style>
+    ${grRangedCommentTheme.cssText}
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
new file mode 100644
index 0000000..cb08e55
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../elements/shared/gr-tooltip/gr-tooltip';
+import {GrTooltip} from '../../../elements/shared/gr-tooltip/gr-tooltip';
+import {fireEvent} from '../../../utils/event-util';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-selection-action-box': GrSelectionActionBox;
+  }
+}
+
+@customElement('gr-selection-action-box')
+export class GrSelectionActionBox extends LitElement {
+  /**
+   * Fired when the comment creation action was taken (click).
+   *
+   * @event create-comment-requested
+   */
+
+  @query('#tooltip')
+  tooltip?: GrTooltip;
+
+  @property({type: Boolean})
+  positionBelow = false;
+
+  /**
+   * We need to absolutely position the element before we can show it. So
+   * initially the tooltip must be invisible.
+   */
+  @state() private invisible = true;
+
+  constructor() {
+    super();
+    // See https://crbug.com/gerrit/4767
+    this.addEventListener('mousedown', e => this.handleMouseDown(e));
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        cursor: pointer;
+        font-family: var(--font-family);
+        position: absolute;
+        white-space: nowrap;
+      }
+      gr-tooltip[invisible] {
+        visibility: hidden;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-tooltip
+        id="tooltip"
+        ?invisible=${this.invisible}
+        text="Press c to comment"
+        ?position-below=${this.positionBelow}
+      ></gr-tooltip>
+    `;
+  }
+
+  async placeAbove(el: Text | Element | Range) {
+    if (!this.tooltip) return;
+    await this.tooltip.updateComplete;
+    const rect = this.getTargetBoundingRect(el);
+    const boxRect = this.tooltip.getBoundingClientRect();
+    const parentRect = this.getParentBoundingClientRect();
+    if (parentRect === null) {
+      return;
+    }
+    this.style.top = `${rect.top - parentRect.top - boxRect.height - 6}px`;
+    this.style.left = `${
+      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
+    }px`;
+    this.invisible = false;
+  }
+
+  async placeBelow(el: Text | Element | Range) {
+    if (!this.tooltip) return;
+    await this.tooltip.updateComplete;
+    const rect = this.getTargetBoundingRect(el);
+    const boxRect = this.tooltip.getBoundingClientRect();
+    const parentRect = this.getParentBoundingClientRect();
+    if (parentRect === null) {
+      return;
+    }
+    this.style.top = `${rect.top - parentRect.top + boxRect.height - 6}px`;
+    this.style.left = `${
+      rect.left - parentRect.left + (rect.width - boxRect.width) / 2
+    }px`;
+    this.invisible = false;
+  }
+
+  private getParentBoundingClientRect() {
+    // With native shadow DOM, the parent is the shadow root, not the gr-diff
+    // element
+    if (this.parentElement) {
+      return this.parentElement.getBoundingClientRect();
+    }
+    if (this.parentNode !== null) {
+      return (this.parentNode as ShadowRoot).host.getBoundingClientRect();
+    }
+    return null;
+  }
+
+  // visible for testing
+  getTargetBoundingRect(el: Text | Element | Range) {
+    let rect;
+    if (el instanceof Text) {
+      const range = document.createRange();
+      range.selectNode(el);
+      rect = range.getBoundingClientRect();
+      range.detach();
+    } else {
+      rect = el.getBoundingClientRect();
+    }
+    return rect;
+  }
+
+  // visible for testing
+  handleMouseDown(e: MouseEvent) {
+    if (e.button !== 0) {
+      return;
+    } // 0 = main button
+    e.preventDefault();
+    e.stopPropagation();
+    fireEvent(this, 'create-comment-requested');
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
new file mode 100644
index 0000000..67836a4
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
@@ -0,0 +1,152 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-selection-action-box';
+import {GrSelectionActionBox} from './gr-selection-action-box';
+import {queryAndAssert} from '../../../test/test-utils';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-selection-action-box', () => {
+  let container: HTMLDivElement;
+  let element: GrSelectionActionBox;
+  let dispatchEventStub: sinon.SinonStub;
+
+  setup(async () => {
+    container = await fixture<HTMLDivElement>(html`
+      <div>
+        <gr-selection-action-box></gr-selection-action-box>
+        <div class="target">some text</div>
+      </div>
+    `);
+    element = queryAndAssert<GrSelectionActionBox>(
+      container,
+      'gr-selection-action-box'
+    );
+    await element.updateComplete;
+
+    dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-tooltip
+          invisible
+          id="tooltip"
+          text="Press c to comment"
+        ></gr-tooltip>
+      `
+    );
+  });
+
+  test('ignores regular keys', () => {
+    const event = new KeyboardEvent('keydown', {key: 'a'});
+    document.body.dispatchEvent(event);
+    assert.isFalse(dispatchEventStub.called);
+  });
+
+  suite('mousedown reacts only to main button', () => {
+    let e: any;
+
+    setup(() => {
+      e = {
+        button: 0,
+        preventDefault: sinon.stub(),
+        stopPropagation: sinon.stub(),
+      };
+    });
+
+    test('event handled if main button', () => {
+      element.handleMouseDown(e);
+      assert.isTrue(e.preventDefault.called);
+      assert.equal(
+        dispatchEventStub.lastCall.args[0].type,
+        'create-comment-requested'
+      );
+    });
+
+    test('event ignored if not main button', () => {
+      e.button = 1;
+      element.handleMouseDown(e);
+      assert.isFalse(e.preventDefault.called);
+      assert.isFalse(dispatchEventStub.called);
+    });
+  });
+
+  suite('placeAbove', () => {
+    let target: HTMLDivElement;
+    let getTargetBoundingRectStub: sinon.SinonStub;
+
+    setup(() => {
+      target = queryAndAssert<HTMLDivElement>(container, '.target');
+      sinon.stub(container, 'getBoundingClientRect').returns({
+        top: 1,
+        bottom: 2,
+        left: 3,
+        right: 4,
+        width: 50,
+        height: 6,
+      } as DOMRect);
+      getTargetBoundingRectStub = sinon
+        .stub(element, 'getTargetBoundingRect')
+        .returns({
+          top: 42,
+          bottom: 20,
+          left: 30,
+          right: 40,
+          width: 100,
+          height: 60,
+        } as DOMRect);
+      assert.isOk(element.tooltip);
+      sinon
+        .stub(element.tooltip!, 'getBoundingClientRect')
+        .returns({width: 10, height: 10} as DOMRect);
+    });
+
+    test('renders visible', async () => {
+      await element.placeAbove(target);
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <gr-tooltip id="tooltip" text="Press c to comment"></gr-tooltip>
+        `
+      );
+    });
+
+    test('placeAbove for Element argument', async () => {
+      await element.placeAbove(target);
+      assert.equal(element.style.top, '25px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeAbove for Text Node argument', async () => {
+      await element.placeAbove(target.firstElementChild!);
+      assert.equal(element.style.top, '25px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeBelow for Element argument', async () => {
+      await element.placeBelow(target);
+      assert.equal(element.style.top, '45px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('placeBelow for Text Node argument', async () => {
+      await element.placeBelow(target.firstElementChild!);
+      assert.equal(element.style.top, '45px');
+      assert.equal(element.style.left, '72px');
+    });
+
+    test('uses document.createRange', async () => {
+      const createRangeSpy = sinon.spy(document, 'createRange');
+      getTargetBoundingRectStub.restore();
+      await element.placeAbove(target.firstChild as HTMLElement);
+      assert.isTrue(createRangeSpy.called);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
new file mode 100644
index 0000000..da08a1f
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -0,0 +1,322 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
+import {DiffLayer, DiffLayerListener} from '../../../types/types';
+import {Side} from '../../../constants/constants';
+import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
+import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
+import {HighlightService} from '../../../services/highlight/highlight-service';
+import {Provider} from '../../../models/dependency';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+
+const LANGUAGE_MAP = new Map<string, string>([
+  ['application/dart', 'dart'],
+  ['application/json', 'json'],
+  ['application/x-powershell', 'powershell'],
+  ['application/typescript', 'typescript'],
+  ['application/xml', 'xml'],
+  ['application/xquery', 'xquery'],
+  ['application/x-erb', 'erb'],
+  ['text/css', 'css'],
+  ['text/html', 'html'],
+  ['text/javascript', 'js'],
+  ['text/jsx', 'jsx'],
+  ['text/tsx', 'jsx'],
+  ['text/x-c', 'cpp'],
+  ['text/x-c++src', 'cpp'],
+  ['text/x-clojure', 'clojure'],
+  ['text/x-cmake', 'cmake'],
+  ['text/x-coffeescript', 'coffeescript'],
+  ['text/x-common-lisp', 'lisp'],
+  ['text/x-crystal', 'crystal'],
+  ['text/x-csharp', 'csharp'],
+  ['text/x-csrc', 'cpp'],
+  ['text/x-d', 'd'],
+  ['text/x-diff', 'diff'],
+  ['text/x-django', 'django'],
+  ['text/x-dockerfile', 'dockerfile'],
+  ['text/x-ebnf', 'ebnf'],
+  ['text/x-elm', 'elm'],
+  ['text/x-erlang', 'erlang'],
+  ['text/x-fortran', 'fortran'],
+  ['text/x-fsharp', 'fsharp'],
+  ['text/x-gfm', 'markdown'],
+  ['text/x-gherkin', 'gherkin'],
+  ['text/x-go', 'go'],
+  ['text/x-groovy', 'groovy'],
+  ['text/x-haml', 'haml'],
+  ['text/x-handlebars', 'handlebars'],
+  ['text/x-haskell', 'haskell'],
+  ['text/x-haxe', 'haxe'],
+  ['text/x-iecst', 'iecst'],
+  ['text/x-ini', 'ini'],
+  ['text/x-java', 'java'],
+  ['text/x-julia', 'julia'],
+  ['text/x-kotlin', 'kotlin'],
+  ['text/x-latex', 'latex'],
+  ['text/x-less', 'less'],
+  ['text/x-lua', 'lua'],
+  ['text/x-markdown', 'markdown'],
+  ['text/x-mathematica', 'mathematica'],
+  ['text/x-nginx-conf', 'nginx'],
+  ['text/x-nsis', 'nsis'],
+  ['text/x-objectivec', 'objectivec'],
+  ['text/x-ocaml', 'ocaml'],
+  ['text/x-perl', 'perl'],
+  ['text/x-pgsql', 'pgsql'], // postgresql
+  ['text/x-php', 'php'],
+  ['text/x-properties', 'properties'],
+  ['text/x-protobuf', 'protobuf'],
+  ['text/x-puppet', 'puppet'],
+  ['text/x-python', 'python'],
+  ['text/x-q', 'q'],
+  ['text/x-ruby', 'ruby'],
+  ['text/x-rustsrc', 'rust'],
+  ['text/x-scala', 'scala'],
+  ['text/x-scss', 'scss'],
+  ['text/x-scheme', 'scheme'],
+  ['text/x-shell', 'shell'],
+  ['text/x-soy', 'soy'],
+  ['text/x-spreadsheet', 'excel'],
+  ['text/x-sh', 'bash'],
+  ['text/x-sql', 'sql'],
+  ['text/x-swift', 'swift'],
+  ['text/x-systemverilog', 'sv'],
+  ['text/x-tcl', 'tcl'],
+  ['text/x-torque', 'torque'],
+  ['text/x-twig', 'twig'],
+  ['text/x-vb', 'vb'],
+  ['text/x-verilog', 'v'],
+  ['text/x-vhdl', 'vhdl'],
+  ['text/x-yaml', 'yaml'],
+  ['text/vbscript', 'vbscript'],
+]);
+
+const CLASS_PREFIX = 'gr-diff gr-syntax gr-syntax-';
+
+const CLASS_SAFELIST = new Set<string>([
+  'attr',
+  'attribute',
+  'built_in',
+  'bullet',
+  'code',
+  'comment',
+  'doctag',
+  'emphasis',
+  'formula',
+  'function',
+  'keyword',
+  'link',
+  'literal',
+  'meta',
+  'meta-keyword',
+  'name',
+  'number',
+  'params',
+  'property',
+  'quote',
+  'regexp',
+  'section',
+  'selector-attr',
+  'selector-class',
+  'selector-id',
+  'selector-pseudo',
+  'selector-tag',
+  'string',
+  'strong',
+  'tag',
+  'template-tag',
+  'template-variable',
+  'title',
+  'title function_',
+  'type',
+  'variable',
+  'variable language_',
+]);
+
+export class GrSyntaxLayerWorker implements DiffLayer {
+  diff?: DiffInfo;
+
+  enabled = true;
+
+  // private, but visible for testing
+  leftRanges: SyntaxLayerLine[] = [];
+
+  // private, but visible for testing
+  rightRanges: SyntaxLayerLine[] = [];
+
+  /**
+   * We are keeping a reference around to the async computation, such that we
+   * can cancel it, if needed.
+   */
+  private leftPromise?: CancelablePromise<SyntaxLayerLine[]>;
+
+  /**
+   * We are keeping a reference around to the async computation, such that we
+   * can cancel it, if needed.
+   */
+  private rightPromise?: CancelablePromise<SyntaxLayerLine[]>;
+
+  private listeners: DiffLayerListener[] = [];
+
+  constructor(
+    private readonly getHighlightService: Provider<HighlightService>,
+    private readonly getReportingService: Provider<ReportingService>
+  ) {}
+
+  setEnabled(enabled: boolean) {
+    this.enabled = enabled;
+  }
+
+  addListener(listener: DiffLayerListener) {
+    this.listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this.listeners = this.listeners.filter(f => f !== listener);
+  }
+
+  annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+    if (!this.enabled) return;
+    if (line.beforeNumber === FILE || line.afterNumber === FILE) return;
+    if (line.beforeNumber === 'LOST' || line.afterNumber === 'LOST') return;
+
+    let side: Side | undefined;
+    if (
+      line.type === GrDiffLineType.REMOVE ||
+      (line.type === GrDiffLineType.BOTH &&
+        el.getAttribute('data-side') !== Side.RIGHT)
+    ) {
+      side = Side.LEFT;
+    } else if (
+      line.type === GrDiffLineType.ADD ||
+      el.getAttribute('data-side') !== Side.LEFT
+    ) {
+      side = Side.RIGHT;
+    }
+    if (!side) return;
+
+    const isLeft = side === Side.LEFT;
+    const lineNumber = isLeft ? line.beforeNumber : line.afterNumber;
+    const rangesPerLine = isLeft ? this.leftRanges : this.rightRanges;
+    const ranges = rangesPerLine[lineNumber - 1]?.ranges ?? [];
+
+    for (const range of ranges) {
+      if (!CLASS_SAFELIST.has(range.className)) continue;
+      if (range.length === 0) continue;
+      GrAnnotation.annotateElement(
+        el,
+        range.start,
+        range.length,
+        CLASS_PREFIX + range.className
+      );
+    }
+  }
+
+  _getLanguage(metaInfo?: DiffFileMetaInfo) {
+    if (!metaInfo) return undefined;
+    // The Gerrit API provides only content-type, but for other users of
+    // gr-diff it may be more convenient to specify the language directly.
+    return metaInfo.language ?? LANGUAGE_MAP.get(metaInfo.content_type);
+  }
+
+  /**
+   * Computes SyntaxLayerLines asynchronously, which can then later be applied,
+   * when the annotate() method of the layer API is called.
+   *
+   * For larger files this is an expensive operation, but is offloaded to a web
+   * worker. We are using the HighlightJS lib for doing the actual highlighting.
+   *
+   * annotate() will only be able to apply highlighting after process() has
+   * completed, but that will likely happen later. That is why layer can have
+   * listeners. When process() completes, the listeners will be notified, which
+   * tells the diff renderer that another call to annotate() is needed.
+   */
+  async process(diff: DiffInfo) {
+    this.diff = diff;
+    this.leftRanges = [];
+    this.rightRanges = [];
+    if (this.leftPromise) this.leftPromise.cancel();
+    if (this.rightPromise) this.rightPromise.cancel();
+    this.leftPromise = undefined;
+    this.rightPromise = undefined;
+    if (!this.enabled || !this.diff) return;
+
+    const leftLanguage = this._getLanguage(this.diff.meta_a);
+    const rightLanguage = this._getLanguage(this.diff.meta_b);
+
+    let leftContent = '';
+    let rightContent = '';
+    for (const chunk of this.diff.content) {
+      const a = [...(chunk.a ?? []), ...(chunk.ab ?? [])];
+      for (const line of a) {
+        leftContent += line + '\n';
+      }
+      const b = [...(chunk.b ?? []), ...(chunk.ab ?? [])];
+      for (const line of b) {
+        rightContent += line + '\n';
+      }
+      const skip = chunk.skip ?? 0;
+      if (skip > 0) {
+        leftContent += '\n'.repeat(skip);
+        rightContent += '\n'.repeat(skip);
+      }
+    }
+    leftContent = leftContent.trimEnd();
+    rightContent = rightContent.trimEnd();
+
+    try {
+      this.leftPromise = this.highlight(leftLanguage, leftContent);
+      this.rightPromise = this.highlight(rightLanguage, rightContent);
+      this.leftRanges = await this.leftPromise;
+      this.rightRanges = await this.rightPromise;
+      this.notify();
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    } catch (err: any) {
+      if (!err.isCanceled)
+        this.getReportingService().error('Diff Syntax Layer', err as Error);
+      // One source of "error" can promise cancelation.
+      this.leftRanges = [];
+      this.rightRanges = [];
+    }
+  }
+
+  highlight(
+    language?: string,
+    code?: string
+  ): CancelablePromise<SyntaxLayerLine[]> {
+    const hlPromise = this.getHighlightService().highlight(language, code);
+    return makeCancelable(hlPromise);
+  }
+
+  notify() {
+    // We don't want to notify for lines that don't have any SyntaxLayerRange.
+    // So for both sides we are looking for the first and the last occurrence
+    // of a line with at least one SyntaxLayerRange.
+    const leftRangesReversed = [...this.leftRanges].reverse();
+    const leftStart = this.leftRanges.findIndex(line => line.ranges.length > 0);
+    const leftEnd =
+      this.leftRanges.length -
+      1 -
+      leftRangesReversed.findIndex(line => line.ranges.length > 0);
+
+    const rightRangesReversed = [...this.rightRanges].reverse();
+    const rightStart = this.rightRanges.findIndex(
+      line => line.ranges.length > 0
+    );
+    const rightEnd =
+      this.rightRanges.length -
+      1 -
+      rightRangesReversed.findIndex(line => line.ranges.length > 0);
+
+    for (const listener of this.listeners) {
+      if (leftStart > -1) listener(leftStart + 1, leftEnd + 1, Side.LEFT);
+      if (rightStart > -1) listener(rightStart + 1, rightEnd + 1, Side.RIGHT);
+    }
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
new file mode 100644
index 0000000..c6c46f9
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
@@ -0,0 +1,165 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {DiffInfo, GrDiffLineType, Side} from '../../../api/diff';
+import {getAppContext} from '../../../services/app-context';
+import {
+  HighlightService,
+  highlightServiceToken,
+} from '../../../services/highlight/highlight-service';
+import '../../../test/common-test-setup';
+import {testResolver} from '../../../test/common-test-setup';
+import {mockPromise} from '../../../test/test-utils';
+import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrSyntaxLayerWorker} from './gr-syntax-layer-worker';
+
+const diff: DiffInfo = {
+  meta_a: {
+    name: 'somepath/somefile.js',
+    content_type: 'text/javascript',
+    lines: 3,
+    language: 'lang-left',
+  },
+  meta_b: {
+    name: 'somepath/somefile.js',
+    content_type: 'text/javascript',
+    lines: 4,
+    language: 'lang-right',
+  },
+  change_type: 'MODIFIED',
+  intraline_status: 'OK',
+  content: [
+    {
+      ab: ['import it;'],
+    },
+    {
+      b: ['b only'],
+    },
+    {
+      ab: ['  public static final {', 'ab3'],
+    },
+  ],
+};
+
+const leftRanges: SyntaxLayerLine[] = [
+  {ranges: [{start: 0, length: 6, className: 'literal'}]},
+  {ranges: []},
+  {ranges: []},
+];
+
+const rightRanges: SyntaxLayerLine[] = [
+  {ranges: []},
+  {ranges: []},
+  {
+    ranges: [
+      {start: 0, length: 2, className: 'not-safe'},
+      {start: 2, length: 6, className: 'literal'},
+      {start: 9, length: 6, className: 'keyword'},
+      {start: 16, length: 5, className: 'name'},
+    ],
+  },
+  {ranges: []},
+];
+
+suite('gr-syntax-layer-worker tests', () => {
+  let layer: GrSyntaxLayerWorker;
+  let listener: sinon.SinonStub;
+  let highlightService: HighlightService;
+
+  const annotate = (side: Side, lineNumber: number, text: string) => {
+    const el = document.createElement('div');
+    const lineNumberEl = document.createElement('td');
+    el.setAttribute('data-side', side);
+    el.innerText = text;
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    if (side === Side.LEFT) line.beforeNumber = lineNumber;
+    if (side === Side.RIGHT) line.afterNumber = lineNumber;
+    layer.annotate(el, lineNumberEl, line);
+    return el;
+  };
+
+  setup(() => {
+    highlightService = testResolver(highlightServiceToken);
+    layer = new GrSyntaxLayerWorker(
+      () => highlightService,
+      () => getAppContext().reportingService
+    );
+  });
+
+  test('cancel processing', async () => {
+    const mockPromise1 = mockPromise<SyntaxLayerLine[]>();
+    const mockPromise2 = mockPromise<SyntaxLayerLine[]>();
+    const mockPromise3 = mockPromise<SyntaxLayerLine[]>();
+    const mockPromise4 = mockPromise<SyntaxLayerLine[]>();
+    const stub = sinon.stub(highlightService, 'highlight');
+    stub.onCall(0).returns(mockPromise1);
+    stub.onCall(1).returns(mockPromise2);
+    stub.onCall(2).returns(mockPromise3);
+    stub.onCall(3).returns(mockPromise4);
+
+    const processPromise1 = layer.process(diff);
+    // Calling the process() a second time means that the promises created
+    // during the first call are cancelled.
+    const processPromise2 = layer.process(diff);
+    // We can await the outer promise even before the inner promises resolve,
+    // because cancelling rejects the inner promises.
+    await processPromise1;
+    // It does not matter actually, whether these two inner promises are
+    // resolved or not.
+    mockPromise1.resolve(leftRanges);
+    mockPromise2.resolve(rightRanges);
+    // Both ranges must still be empty, because the promise of the first call
+    // must have been cancelled and the returned ranges ignored.
+    assert.isEmpty(layer.leftRanges);
+    assert.isEmpty(layer.rightRanges);
+    // Lets' resolve and await the promises of the second as normal.
+    mockPromise3.resolve(leftRanges);
+    mockPromise4.resolve(rightRanges);
+    await processPromise2;
+    assert.equal(layer.leftRanges, leftRanges);
+  });
+
+  suite('annotate and listen', () => {
+    setup(() => {
+      listener = sinon.stub();
+      layer.addListener(listener);
+      sinon.stub(highlightService, 'highlight').callsFake((lang?: string) => {
+        if (lang === 'lang-left') return Promise.resolve(leftRanges);
+        if (lang === 'lang-right') return Promise.resolve(rightRanges);
+        return Promise.resolve([]);
+      });
+    });
+
+    test('process and annotate line 2 LEFT', async () => {
+      await layer.process(diff);
+      const el = annotate(Side.LEFT, 1, 'import it;');
+      assert.equal(
+        el.innerHTML,
+        '<hl class="gr-diff gr-syntax gr-syntax-literal">import</hl> it;'
+      );
+      assert.equal(listener.callCount, 2);
+      assert.equal(listener.getCall(0).args[0], 1);
+      assert.equal(listener.getCall(0).args[1], 1);
+      assert.equal(listener.getCall(0).args[2], Side.LEFT);
+      assert.equal(listener.getCall(1).args[0], 3);
+      assert.equal(listener.getCall(1).args[1], 3);
+      assert.equal(listener.getCall(1).args[2], Side.RIGHT);
+    });
+
+    test('process and annotate line 3 RIGHT', async () => {
+      await layer.process(diff);
+      const el = annotate(Side.RIGHT, 3, '  public static final {');
+      assert.equal(
+        el.innerHTML,
+        '  <hl class="gr-diff gr-syntax gr-syntax-literal">public</hl> ' +
+          '<hl class="gr-diff gr-syntax gr-syntax-keyword">static</hl> ' +
+          '<hl class="gr-diff gr-syntax gr-syntax-name">final</hl> {'
+      );
+      assert.equal(listener.callCount, 2);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
new file mode 100644
index 0000000..042215f
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -0,0 +1,136 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+const $_documentContainer = document.createElement('template');
+
+/**
+ * HighlightJS emits the following classes that do not have styles here:
+ *    subst, symbol, class, function, doctag, meta-string, section, name,
+ *    builtin-name, bulletm, code, formula, quote, addition, deletion,
+ *    attribute
+ *
+ * @see {@link http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html}
+ */
+export const grSyntaxTheme = css`
+  .contentText {
+    color: var(--syntax-default-color);
+  }
+  .gr-syntax-attr {
+    color: var(--syntax-attr-color);
+  }
+  .gr-syntax-attribute {
+    color: var(--syntax-attribute-color);
+  }
+  .gr-syntax-built_in {
+    color: var(--syntax-built_in-color);
+  }
+  .gr-syntax-bullet {
+    color: var(--syntax-bullet-color);
+  }
+  .gr-syntax-code {
+    color: var(--syntax-code-color);
+  }
+  .gr-syntax-comment {
+    color: var(--syntax-comment-color);
+  }
+  .gr-syntax-doctag {
+    font-weight: var(--syntax-doctag-weight);
+  }
+  .gr-syntax-formula {
+    color: var(--syntax-formula-color);
+  }
+  .gr-syntax-function {
+    color: var(--syntax-function-color);
+  }
+  .gr-syntax-link {
+    color: var(--syntax-link-color);
+  }
+  .gr-syntax-literal {
+    /* XML/HTML Attribute */
+    color: var(--syntax-literal-color);
+  }
+  .gr-syntax-meta {
+    color: var(--syntax-meta-color);
+  }
+  .gr-syntax-meta-keyword {
+    color: var(--syntax-meta-keyword-color);
+  }
+  .gr-syntax-keyword,
+  .gr-syntax-name {
+    color: var(--syntax-keyword-color);
+  }
+  .gr-syntax-number {
+    color: var(--syntax-number-color);
+  }
+  .gr-syntax-params {
+    color: var(--syntax-params-color);
+  }
+  .gr-syntax-property {
+    color: var(--syntax-property-color);
+  }
+  .gr-syntax-quote {
+    color: var(--syntax-quote-color);
+  }
+  .gr-syntax-regexp {
+    color: var(--syntax-regexp-color);
+  }
+  .gr-syntax-section {
+    color: var(--syntax-section-color);
+  }
+  .gr-syntax-selector-attr {
+    color: var(--syntax-selector-attr-color);
+  }
+  .gr-syntax-selector-class {
+    color: var(--syntax-selector-class-color);
+  }
+  .gr-syntax-selector-id {
+    color: var(--syntax-selector-id-color);
+  }
+  .gr-syntax-selector-pseudo {
+    color: var(--syntax-selector-pseudo-color);
+  }
+  .gr-syntax-string {
+    color: var(--syntax-string-color);
+  }
+  .gr-syntax-strong {
+    color: var(--syntax-strong-color);
+  }
+  .gr-syntax-tag {
+    color: var(--syntax-tag-color);
+  }
+  .gr-syntax-template-tag {
+    color: var(--syntax-template-tag-color);
+  }
+  .gr-syntax-template-variable {
+    color: var(--syntax-template-variable-color);
+  }
+  .gr-syntax-title {
+    color: var(--syntax-title-color);
+  }
+  .gr-syntax-title.function_ {
+    color: var(--syntax-title-function-color);
+  }
+  .gr-syntax-type {
+    color: var(--syntax-type-color);
+  }
+  .gr-syntax-variable {
+    color: var(--syntax-variable-color);
+  }
+  .gr-syntax-variable.language_ {
+    color: var(--syntax-variable-language-color);
+  }
+`;
+
+$_documentContainer.innerHTML = `<dom-module id="gr-syntax-theme">
+  <template>
+    <style>
+    ${grSyntaxTheme.cssText}
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index 6a30477..36ebb9f 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -1,32 +1,23 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {appContext} from '../services/app-context';
+import {create, Registry, Finalizable} from '../services/registry';
+import {AppContext} from '../services/app-context';
+import {AuthService} from '../services/gr-auth/gr-auth';
 import {FlagsService} from '../services/flags/flags';
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
-import {AuthService} from '../services/gr-auth/gr-auth';
 
 class MockFlagsService implements FlagsService {
   isEnabled() {
     return false;
   }
 
+  finalize() {}
+
   /**
-   * @returns array of all enabled experiments.
+   * @return array of all enabled experiments.
    */
   get enabledExperiments() {
     return [];
@@ -48,6 +39,8 @@
 
   setup() {}
 
+  finalize() {}
+
   fetch() {
     const blob = new Blob();
     const init = {status: 200, statusText: 'Ack'};
@@ -59,15 +52,17 @@
 // Setup mocks for appContext.
 // This is a temporary solution
 // TODO(dmfilippov): find a better solution for gr-diff
-export function initDiffAppContext() {
-  function setMock(serviceName: string, setupMock: unknown) {
-    Object.defineProperty(appContext, serviceName, {
-      get() {
-        return setupMock;
-      },
-    });
-  }
-  setMock('flagsService', new MockFlagsService());
-  setMock('reportingService', grReportingMock);
-  setMock('authService', new MockAuthService());
+export function createDiffAppContext(): AppContext & Finalizable {
+  const appRegistry: Registry<AppContext> = {
+    flagsService: (_ctx: Partial<AppContext>) => new MockFlagsService(),
+    authService: (_ctx: Partial<AppContext>) => new MockAuthService(),
+    reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
+    eventEmitter: (_ctx: Partial<AppContext>) => {
+      throw new Error('eventEmitter is not implemented');
+    },
+    restApiService: (_ctx: Partial<AppContext>) => {
+      throw new Error('restApiService is not implemented');
+    },
+  };
+  return create<AppContext>(appRegistry);
 }
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
deleted file mode 100644
index 832c931..0000000
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.js
+++ /dev/null
@@ -1,35 +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 {appContext} from '../services/app-context.js';
-import {initDiffAppContext} from './gr-diff-app-context-init.js';
-suite('gr diff app context initializer tests', () => {
-  setup(() => {
-    initDiffAppContext();
-  });
-
-  test('all services initialized and are singletons', () => {
-    Object.keys(appContext).forEach(serviceName => {
-      const service = appContext[serviceName];
-      assert.isNotNull(service);
-      const service2 = appContext[serviceName];
-      assert.strictEqual(service, service2);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
new file mode 100644
index 0000000..baa0a34
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init_test.ts
@@ -0,0 +1,23 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {AppContext} from '../services/app-context';
+import '../test/common-test-setup';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+
+suite('gr diff app context initializer tests', () => {
+  test('all services initialized and are singletons', () => {
+    const appContext: AppContext = createDiffAppContext();
+    for (const serviceName of Object.keys(appContext) as Array<
+      keyof AppContext
+    >) {
+      const service = appContext[serviceName];
+      assert.isNotNull(service);
+      const service2 = appContext[serviceName];
+      assert.strictEqual(service, service2);
+    }
+  });
+});
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 422667a4..977f8a9 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
@@ -23,16 +12,17 @@
 // exposed by shared gr-diff component.
 import '../api/embed';
 import '../scripts/bundled-polymer';
-import '../elements/diff/gr-diff/gr-diff';
-import '../elements/diff/gr-diff-cursor/gr-diff-cursor';
-import {TokenHighlightLayer} from '../elements/diff/gr-diff-builder/token-highlight-layer';
-import {GrDiffCursor} from '../elements/diff/gr-diff-cursor/gr-diff-cursor';
-import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
-import {initDiffAppContext} from './gr-diff-app-context-init';
+import './diff/gr-diff/gr-diff';
+import './diff/gr-diff-cursor/gr-diff-cursor';
+import {TokenHighlightLayer} from './diff/gr-diff-builder/token-highlight-layer';
+import {GrDiffCursor} from './diff/gr-diff-cursor/gr-diff-cursor';
+import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
+import {createDiffAppContext} from './gr-diff-app-context-init';
+import {injectAppContext} from '../services/app-context';
 
 // Setup appContext for diff.
 // TODO (dmfilippov): find a better solution
-initDiffAppContext();
+injectAppContext(createDiffAppContext());
 // Setup global variables for existing usages of this component
 window.grdiff = {
   GrAnnotation,
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.ts b/polygerrit-ui/app/gr-diff/gr-diff-root.ts
deleted file mode 100644
index fbe81fb..0000000
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.ts
+++ /dev/null
@@ -1,18 +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 '../elements/diff/gr-diff/gr-diff';
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
index fd4da4f..b383fd7 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -1,27 +1,33 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {getRootElement} from '../../scripts/rootElement';
 import {Constructor} from '../../utils/common-util';
 import {LitElement, PropertyValues} from 'lit';
-import {property, query} from 'lit/decorators';
-import {ShowAlertEventDetail} from '../../types/events';
+import {property, query} from 'lit/decorators.js';
+import {EventType, ShowAlertEventDetail} from '../../types/events';
 import {debounce, DelayedTask} from '../../utils/async-util';
 import {hovercardStyles} from '../../styles/gr-hovercard-styles';
 import {sharedStyles} from '../../styles/shared-styles';
+import {DependencyRequestEvent} from '../../models/dependency';
+import {
+  addShortcut,
+  findActiveElement,
+  isElementTarget,
+  Key,
+  Modifier,
+} from '../../utils/dom-util';
+import {ShortcutController} from '../../elements/lit/shortcut-controller';
+import {
+  getFocusableElements,
+  getFocusableElementsReverse,
+} from '../../utils/focusable';
+import {getAppContext} from '../../services/app-context';
+import {
+  ReportingService,
+  Timer,
+} from '../../services/gr-reporting/gr-reporting';
 
 interface ReloadEventDetail {
   clearPatchset?: boolean;
@@ -35,19 +41,10 @@
  */
 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;
+export interface MouseKeyboardOrFocusEvent {
+  keyboardEvent?: KeyboardEvent;
+  mouseEvent?: MouseEvent;
+  focusEvent?: FocusEvent;
 }
 
 /**
@@ -126,6 +123,17 @@
 
     isScheduledToHide?: boolean;
 
+    openedByKeyboard = false;
+
+    reporting: ReportingService = getAppContext().reportingService;
+
+    reportingTimer?: Timer;
+
+    private targetCleanups: Array<() => void> = [];
+
+    /** Called in disconnectedCallback. */
+    private cleanups: (() => void)[] = [];
+
     static get styles() {
       return [sharedStyles, hovercardStyles];
     }
@@ -135,9 +143,13 @@
       super(...args);
       // show the hovercard if mouse moves to hovercard
       // this will cancel pending hide as well
-      this.addEventListener('mouseenter', this.show);
+      this.addEventListener('mouseenter', this.mouseShow);
       // when leave hovercard, hide it immediately
-      this.addEventListener('mouseleave', this.hide);
+      this.addEventListener('mouseleave', this.mouseHide);
+      const keyboardController = new ShortcutController(this);
+      keyboardController.addGlobal({key: Key.ESC}, (e: KeyboardEvent) =>
+        this.hide({keyboardEvent: e})
+      );
     }
 
     override connectedCallback() {
@@ -149,12 +161,38 @@
         this.addTargetEventListeners();
       }
 
-      this.container = getHovercardContainer({createIfNotExists: true});
+      this.container = this.getContainer();
+      this.cleanups.push(
+        addShortcut(
+          this,
+          {key: Key.TAB},
+          (e: KeyboardEvent) => {
+            this.pressTab(e);
+          },
+          {
+            preventDefault: false,
+          }
+        )
+      );
+      this.cleanups.push(
+        addShortcut(
+          this,
+          {key: Key.TAB, modifiers: [Modifier.SHIFT_KEY]},
+          (e: KeyboardEvent) => {
+            this.pressShiftTab(e);
+          },
+          {
+            preventDefault: false,
+          }
+        )
+      );
     }
 
     override disconnectedCallback() {
       this.cancelShowTask();
       this.cancelHideTask();
+      for (const cleanup of this.cleanups) cleanup();
+      this.cleanups = [];
       super.disconnectedCallback();
     }
 
@@ -164,19 +202,37 @@
       // trigger the hovercard, which can annoying for the user, for example
       // when added reviewer chips appear in the reply dialog via keyboard
       // interaction.
-      this._target?.addEventListener('mousemove', this.debounceShow);
-      this._target?.addEventListener('focus', this.debounceShow);
-      this._target?.addEventListener('mouseleave', this.debounceHide);
-      this._target?.addEventListener('blur', this.debounceHide);
-      this._target?.addEventListener('click', this.hide);
+      this._target?.addEventListener('mousemove', this.mouseDebounceShow);
+      this._target?.addEventListener('mouseleave', this.mouseDebounceHide);
+      this._target?.addEventListener('blur', this.focusDebounceHide);
+      this._target?.addEventListener('click', this.mouseHide);
+      if (this._target) {
+        this.targetCleanups.push(
+          addShortcut(this._target, {key: Key.ENTER}, (e: KeyboardEvent) => {
+            this.show({keyboardEvent: e});
+          })
+        );
+        this.targetCleanups.push(
+          addShortcut(this._target, {key: Key.SPACE}, (e: KeyboardEvent) => {
+            this.show({keyboardEvent: e});
+          })
+        );
+      }
+      this.addEventListener('request-dependency', this.resolveDep);
+      this.addEventListener('reload', this.reload);
     }
 
     private removeTargetEventListeners() {
-      this._target?.removeEventListener('mousemove', this.debounceShow);
-      this._target?.removeEventListener('focus', this.debounceShow);
-      this._target?.removeEventListener('mouseleave', this.debounceHide);
-      this._target?.removeEventListener('blur', this.debounceHide);
-      this._target?.removeEventListener('click', this.hide);
+      this._target?.removeEventListener('mousemove', this.mouseDebounceShow);
+      this._target?.removeEventListener('mouseleave', this.mouseDebounceHide);
+      this._target?.removeEventListener('blur', this.focusDebounceHide);
+      this._target?.removeEventListener('click', this.mouseHide);
+      for (const cleanup of this.targetCleanups) {
+        cleanup();
+      }
+      this.targetCleanups = [];
+      this.removeEventListener('request-dependency', this.resolveDep);
+      this.removeEventListener('reload', this.reload);
     }
 
     /**
@@ -192,7 +248,31 @@
       }
     }
 
-    readonly debounceHide = () => {
+    readonly reload = () => {
+      this.dispatchEventThroughTarget('reload');
+    };
+
+    readonly mouseDebounceHide = (e: MouseEvent) => {
+      this.debounceHide({mouseEvent: e});
+    };
+
+    readonly mouseDebounceShow = (e: MouseEvent) => {
+      this.debounceShow({mouseEvent: e});
+    };
+
+    readonly mouseHide = (e: MouseEvent) => {
+      this.hide({mouseEvent: e});
+    };
+
+    readonly mouseShow = (e: MouseEvent) => {
+      this.show({mouseEvent: e});
+    };
+
+    readonly focusDebounceHide = (e: FocusEvent) => {
+      this.debounceHide({focusEvent: e});
+    };
+
+    readonly debounceHide = (props: MouseKeyboardOrFocusEvent) => {
       this.cancelShowTask();
       if (!this._isShowing || this.isScheduledToHide) return;
       this.isScheduledToHide = true;
@@ -202,7 +282,7 @@
           // This happens when hide immediately through click or mouse leave
           // on the hovercard
           if (!this.isScheduledToHide) return;
-          this.hide();
+          this.hide(props);
         },
         HIDE_DELAY_MS
       );
@@ -223,7 +303,7 @@
     dispatchEventThroughTarget(eventName: string): void;
 
     dispatchEventThroughTarget(
-      eventName: 'show-alert',
+      eventName: EventType.SHOW_ALERT,
       detail: ShowAlertEventDetail
     ): void;
 
@@ -244,6 +324,29 @@
         );
     }
 
+    getHost(): HTMLElement {
+      let el = this._target as Node;
+      while (el) {
+        if ((el as HTMLElement).tagName === 'DIALOG') {
+          return el as HTMLElement;
+        }
+        el = el.parentNode || (el as ShadowRoot).host;
+      }
+      return document.body;
+    }
+
+    getContainer(): HTMLElement | null {
+      const host = this.getHost();
+      let container = host.querySelector<HTMLElement>(`#${containerId}`);
+      if (!container) {
+        // If it does not exist, create and initialize the hovercard container.
+        container = document.createElement('div');
+        container.setAttribute('id', containerId);
+        host.appendChild(container);
+      }
+      return container;
+    }
+
     /**
      * Returns the target element that the hovercard is anchored to (the `id` of
      * the `for` property).
@@ -264,23 +367,48 @@
       return target as HTMLElement;
     }
 
+    private readonly documentClickListener = (e: MouseEvent) => {
+      if (!e.target || !isElementTarget(e.target)) return;
+      if (this.contains(e.target)) return;
+      this.forceHide();
+    };
+
+    private containerClickListener = (e: MouseEvent) => {
+      e.stopPropagation();
+    };
+
+    /**
+     * Hovercards aren't children of <gr-app>. Dependencies must be resolved via
+     * their targets, so re-route 'request-dependency' events.
+     */
+    readonly resolveDep = (e: DependencyRequestEvent<unknown>) => {
+      this._target?.dispatchEvent(
+        new DependencyRequestEvent<unknown>(e.dependency, e.callback)
+      );
+    };
+
+    readonly forceHide = () => {
+      this.hide({keyboardEvent: new KeyboardEvent('enter')});
+    };
+
     /**
      * Hides/closes the hovercard. This occurs when the user triggers the
      * `mouseleave` event on the hovercard's `target` element (as long as the
-     * user is not hovering over the hovercard).
-     *
+     * user is not hovering over the hovercard). If event is not specified
+     * in props, code assumes mouseEvent
      */
-    readonly hide = (e?: MouseEvent) => {
+    readonly hide = (props: MouseKeyboardOrFocusEvent) => {
       this.cancelHideTask();
       this.cancelShowTask();
       if (!this._isShowing) {
         return;
       }
-
-      // If the user is now hovering over the hovercard or the user is returning
-      // from the hovercard but now hovering over the target (to stop an annoying
-      // flicker effect), just return.
-      if (e) {
+      if (!props?.keyboardEvent && this.openedByKeyboard) return;
+      // If the user is clicking on a link and still hovering over the hovercard
+      // or the user is returning from the hovercard but now hovering over the
+      // target (to stop an annoying flicker effect), just return.
+      if (props?.mouseEvent) {
+        const e = props.mouseEvent;
         if (
           e.relatedTarget === this ||
           (e.target === this && e.relatedTarget === this._target)
@@ -288,6 +416,14 @@
           return;
         }
       }
+      if (this.openedByKeyboard) {
+        if (this._target) {
+          this._target.focus();
+        }
+      }
+      // Make sure to reset the keyboard variable so new shows will not
+      // assume keyboard is the reason for opening the hovercard.
+      this.openedByKeyboard = false;
 
       // Mark that the hovercard is not visible and do not allow focusing
       this._isShowing = false;
@@ -304,19 +440,25 @@
       if (this.container?.contains(this)) {
         this.container.removeChild(this);
       }
+      document.removeEventListener('click', this.documentClickListener);
+      this.container?.removeEventListener('click', this.containerClickListener);
+      this.reportingTimer?.end({
+        targetId: this._target?.id,
+        tagName: this.tagName,
+      });
     };
 
     /**
      * Shows/opens the hovercard with a fixed delay.
      */
-    readonly debounceShow = () => {
-      this.debounceShowBy(SHOW_DELAY_MS);
+    readonly debounceShow = (props: MouseKeyboardOrFocusEvent) => {
+      this.debounceShowBy(SHOW_DELAY_MS, props);
     };
 
     /**
      * Shows/opens the hovercard with the given delay.
      */
-    debounceShowBy(delayMs: number) {
+    debounceShowBy(delayMs: number, props: MouseKeyboardOrFocusEvent) {
       this.cancelHideTask();
       if (this._isShowing || this.isScheduledToShow) return;
       this.isScheduledToShow = true;
@@ -325,12 +467,35 @@
         () => {
           // This happens when the mouse leaves the target before the delay is over.
           if (!this.isScheduledToShow) return;
-          this.show();
+          this.show(props);
         },
         delayMs
       );
     }
 
+    override focus(options?: FocusOptions): void {
+      const a = getFocusableElements(this).next();
+      if (!a.done) a.value.focus(options);
+    }
+
+    pressTab(e: KeyboardEvent) {
+      const activeElement = findActiveElement(document);
+      const lastFocusable = getFocusableElementsReverse(this).next();
+      if (!lastFocusable.done && activeElement === lastFocusable.value) {
+        e.preventDefault();
+        this.forceHide();
+      }
+    }
+
+    pressShiftTab(e: KeyboardEvent) {
+      const activeElement = findActiveElement(document);
+      const firstFocusable = getFocusableElements(this).next();
+      if (!firstFocusable.done && activeElement === firstFocusable.value) {
+        e.preventDefault();
+        this.forceHide();
+      }
+    }
+
     cancelShowTask() {
       if (!this.showTask) return;
       this.showTask.cancel();
@@ -340,18 +505,22 @@
 
     /**
      * Shows/opens the hovercard. This occurs when the user triggers the
-     * `mousenter` event on the hovercard's `target` element.
+     * `mousenter` event on the hovercard's `target` element or when a user
+     * presses enter/space on the hovercard's `target` element. If event is not
+     * specified in props, code assumes mouseEvent
      */
-    readonly show = async () => {
+    readonly show = async (props: MouseKeyboardOrFocusEvent) => {
       this.cancelHideTask();
       this.cancelShowTask();
+      // If we are calling show again because of a mouse reason, then keep
+      // the keyboard valuable set.
+      this.openedByKeyboard = this.openedByKeyboard || !!props?.keyboardEvent;
       if (this._isShowing || !this.container) {
         return;
       }
 
       // Mark that the hovercard is now visible
       this._isShowing = true;
-      this.setAttribute('tabindex', '0');
 
       // Add it to the DOM and calculate its position
       this.container.appendChild(this);
@@ -367,6 +536,12 @@
       });
       this.updatePosition();
       this.classList.remove(HIDE_CLASS);
+      if (props?.keyboardEvent) {
+        this.focus();
+      }
+      this.container.addEventListener('click', this.containerClickListener);
+      document.addEventListener('click', this.documentClickListener);
+      this.reportingTimer = this.reporting.getTimer('Show Hovercard');
     };
 
     updatePosition() {
@@ -385,16 +560,16 @@
         this.updatePositionTo(position);
         if (this._isInsideViewport()) return;
       }
-      console.warn('Could not find a visible position for the hovercard.');
+      this.updatePositionTo(this.position);
     }
 
     _isInsideViewport() {
       const thisRect = this.getBoundingClientRect();
-      if (thisRect.top < 0) return false;
-      if (thisRect.left < 0) return false;
-      const docuRect = document.documentElement.getBoundingClientRect();
-      if (thisRect.bottom > docuRect.height) return false;
-      if (thisRect.right > docuRect.width) return false;
+      const hostRect = this.getHost().getBoundingClientRect();
+      if (thisRect.top < hostRect.top) return false;
+      if (thisRect.left < hostRect.left) return false;
+      if (thisRect.bottom > hostRect.bottom) return false;
+      if (thisRect.right > hostRect.right) return false;
       return true;
     }
 
@@ -419,12 +594,12 @@
       // in the width and height of the bounding client rect.
       this.style.cssText = '';
 
-      const docuRect = document.documentElement.getBoundingClientRect();
+      const hostRect = this.getHost().getBoundingClientRect();
       const targetRect = this._target.getBoundingClientRect();
       const thisRect = this.getBoundingClientRect();
 
-      const targetLeft = targetRect.left - docuRect.left;
-      const targetTop = targetRect.top - docuRect.top;
+      const targetLeft = targetRect.left - hostRect.left;
+      const targetTop = targetRect.top - hostRect.top;
 
       let hovercardLeft;
       let hovercardTop;
@@ -478,15 +653,18 @@
   _target: HTMLElement | null;
   _isShowing: boolean;
   dispatchEventThroughTarget(eventName: string, detail?: unknown): void;
-  show(): void;
+  show(props: MouseKeyboardOrFocusEvent): Promise<void>;
+  forceHide(): void;
 
   // Used for tests
-  hide(e: MouseEvent): void;
+  mouseHide(e: MouseEvent): void;
+  getHost(): HTMLElement;
+  hide(props: MouseKeyboardOrFocusEvent): void;
   container: HTMLElement | null;
   hideTask?: DelayedTask;
   showTask?: DelayedTask;
   position: string;
-  debounceShowBy(delayMs: number): void;
+  debounceShowBy(delayMs: number, props: MouseKeyboardOrFocusEvent): void;
   updatePosition(): void;
   isScheduledToShow?: boolean;
   isScheduledToHide?: boolean;
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
index e6b63e6..ffae9e5 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -1,25 +1,20 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma.js';
-import {HovercardMixin} from './hovercard-mixin.js';
-import {html, LitElement} from 'lit';
-import {customElement} from 'lit/decorators';
-import {MockPromise, mockPromise} from '../../test/test-utils.js';
+import '../../test/common-test-setup';
+import {HovercardMixin} from './hovercard-mixin';
+import {LitElement} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {
+  MockPromise,
+  mockPromise,
+  pressKey,
+  waitEventLoop,
+} from '../../test/test-utils';
+import {findActiveElement, Key} from '../../utils/dom-util';
+import {fixture, html, assert} from '@open-wc/testing';
 
 const base = HovercardMixin(LitElement);
 
@@ -37,31 +32,35 @@
   }
 
   override render() {
-    return html`<div id="container"><slot></slot></div>`;
+    return html` <div id="container">
+      <span tabindex="0" id="focusable"></span>
+      <slot></slot>
+    </div>`;
   }
 }
 
-const basicFixture = fixtureFromElement('hovercard-mixin-test');
-
 suite('gr-hovercard tests', () => {
   let element: HovercardMixinTest;
 
   let button: HTMLElement;
   let testPromise: MockPromise<void>;
 
-  setup(() => {
+  setup(async () => {
     testPromise = mockPromise();
     button = document.createElement('button');
     button.innerHTML = 'Hello';
     button.setAttribute('id', 'foo');
     document.body.appendChild(button);
 
-    element = basicFixture.instantiate();
+    element = await fixture(
+      html`<hovercard-mixin-test></hovercard-mixin-test>`
+    );
   });
 
   teardown(() => {
-    element.hide(new MouseEvent('click'));
-    button?.remove();
+    pressKey(element, Key.ESC);
+    element.mouseHide(new MouseEvent('click'));
+    if (button) button.remove();
   });
 
   test('updatePosition', async () => {
@@ -74,8 +73,8 @@
     assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
     assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
 
-    const parentRect = document.documentElement.getBoundingClientRect();
-    const targetRect = element!._target!.getBoundingClientRect();
+    const parentRect = element.getHost().getBoundingClientRect();
+    const targetRect = element._target!.getBoundingClientRect();
     const thisRect = element.getBoundingClientRect();
 
     const targetLeft = targetRect.left - parentRect.left;
@@ -94,8 +93,18 @@
     );
   });
 
+  test('getHost', () => {
+    element._target = document.createElement('span');
+
+    const dialog = document.createElement('dialog');
+
+    assert.deepEqual(element.getHost(), document.body);
+    dialog.appendChild(element._target);
+    assert.deepEqual(element.getHost(), dialog);
+  });
+
   test('hide', () => {
-    element.hide(new MouseEvent('click'));
+    element.mouseHide(new MouseEvent('click'));
     const style = getComputedStyle(element);
     assert.isFalse(element._isShowing);
     assert.isFalse(element.classList.contains('hovered'));
@@ -104,7 +113,7 @@
   });
 
   test('show', async () => {
-    await element.show();
+    await element.show({});
     await element.updateComplete;
     const style = getComputedStyle(element);
     assert.isTrue(element._isShowing);
@@ -114,14 +123,14 @@
   });
 
   test('debounceShow does not show immediately', async () => {
-    element.debounceShowBy(100);
+    element.debounceShowBy(100, {});
     setTimeout(() => testPromise.resolve(), 0);
     await testPromise;
     assert.isFalse(element._isShowing);
   });
 
   test('debounceShow shows after delay', async () => {
-    element.debounceShowBy(1);
+    element.debounceShowBy(1, {});
     setTimeout(() => testPromise.resolve(), 10);
     await testPromise;
     assert.isTrue(element._isShowing);
@@ -138,9 +147,9 @@
     button!.dispatchEvent(new CustomEvent('mousemove'));
 
     await enterPromise;
-    await flush();
+    await waitEventLoop();
     assert.isTrue(element.isScheduledToShow);
-    element!.showTask!.flush();
+    element.showTask!.flush();
     assert.isTrue(element._isShowing);
     assert.isFalse(element.isScheduledToShow);
 
@@ -149,7 +158,7 @@
     await leavePromise;
     assert.isTrue(element.isScheduledToHide);
     assert.isTrue(element._isShowing);
-    element!.hideTask!.flush();
+    element.hideTask!.flush();
     assert.isFalse(element.isScheduledToShow);
     assert.isFalse(element._isShowing);
   });
@@ -166,7 +175,7 @@
     button!.dispatchEvent(new CustomEvent('mousemove'));
 
     await enterPromise;
-    await flush();
+    await waitEventLoop();
     assert.isTrue(element.isScheduledToShow);
     button!.click();
 
@@ -174,4 +183,52 @@
     assert.isFalse(element.isScheduledToShow);
     assert.isFalse(element._isShowing);
   });
+
+  test('do not show on focus', async () => {
+    const button = document.querySelector('button');
+    button?.focus();
+    await element.updateComplete;
+    assert.isNotTrue(element.isScheduledToShow);
+    assert.isFalse(element._isShowing);
+  });
+
+  test('show on pressing enter when focused', async () => {
+    const button = document.querySelector('button')!;
+    button.focus();
+    await element.updateComplete;
+    pressKey(button, Key.ENTER);
+    await element.updateComplete;
+    assert.isTrue(element._isShowing);
+  });
+
+  test('show on pressing space when focused', async () => {
+    const button = document.querySelector('button')!;
+    button.focus();
+    await element.updateComplete;
+    pressKey(button, Key.SPACE);
+    await element.updateComplete;
+    assert.isTrue(element._isShowing);
+  });
+
+  test('when on pressing enter, focus is moved to hovercard', async () => {
+    const button = document.querySelector('button')!;
+    button.focus();
+    await element.updateComplete;
+    await element.show({keyboardEvent: new KeyboardEvent('enter')});
+    await element.updateComplete;
+    assert.isTrue(element._isShowing);
+    const activeElement = findActiveElement(document);
+    assert.equal(activeElement?.id, 'focusable');
+  });
+
+  test('when on mouseEvent, focus is not moved to hovercard', async () => {
+    const button = document.querySelector('button')!;
+    button.focus();
+    await element.updateComplete;
+    await element.show({mouseEvent: new MouseEvent('enter')});
+    await element.updateComplete;
+    assert.isTrue(element._isShowing);
+    const activeElement = findActiveElement(document);
+    assert.notEqual(activeElement?.id, 'focusable');
+  });
 });
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
deleted file mode 100644
index 57e034f..0000000
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {Constructor} from '../../utils/common-util';
-
-// The mixinBehaviors clears all type information about superClass.
-// As a workaround, we define IronFitMixin with correct type.
-// Due to the following issues:
-// https://github.com/microsoft/TypeScript/issues/15870
-// https://github.com/microsoft/TypeScript/issues/9944
-// we have to import IronFitBehavior in the same file where IronFitMixin
-// is used. To ensure that this import can't be avoided, the second parameter
-// is added. Usage example:
-// class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
-// The code 'IronFitBehavior as IronFitBehavior' required, because IronFitBehavior
-// defined as an object, not as IronFitBehavior instance.
-
-export const IronFitMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T,
-  _: IronFitBehavior
-): T & Constructor<IronFitBehavior> =>
-  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
-  // which will fail the type check due to missing IronFitBehavior interface
-  // 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
deleted file mode 100644
index 8429e38..0000000
--- a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {Constructor} from '../../utils/common-util';
-
-// The mixinBehaviors clears all type information about superClass.
-// As a workaround, we define IronOverlayMixin with correct type.
-// Due to the following issues:
-// https://github.com/microsoft/TypeScript/issues/15870
-// https://github.com/microsoft/TypeScript/issues/9944
-// we have to import IronOverlayBehavior in the same file where IronOverlayMixin
-// is used. To ensure that this import can't be avoided, the second parameter
-// is added. Usage example:
-// class Element extends IronOverlayMixin(PolymerElement, IronOverlayBehavior as IronOverlayBehavior)
-// The code 'IronOverlayBehavior as IronOverlayBehavior' required, because
-// IronOverlayBehavior defined as an object, not as IronOverlayBehavior instance.
-export const IronOverlayMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T,
-  _: IronOverlayBehavior
-): T & Constructor<IronOverlayBehavior> =>
-  // TODO(TS): mixinBehaviors in some lib is returning: `new () => T`
-  // instead which will fail the type check due to missing
-  // IronOverlayBehavior interface
-  // 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
deleted file mode 100644
index f133c116..0000000
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ /dev/null
@@ -1,146 +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 {property} from '@polymer/decorators';
-import {PolymerElement} from '@polymer/polymer';
-import {check, Constructor} from '../../utils/common-util';
-import {appContext} from '../../services/app-context';
-import {
-  Shortcut,
-  ShortcutSection,
-  SPECIAL_SHORTCUT,
-} from '../../services/shortcuts/shortcuts-config';
-import {
-  SectionView,
-  ShortcutListener,
-} from '../../services/shortcuts/shortcuts-service';
-
-export {
-  Shortcut,
-  ShortcutSection,
-  SPECIAL_SHORTCUT,
-  ShortcutListener,
-  SectionView,
-};
-
-export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T
-) => {
-  /**
-   * @polymer
-   * @mixinClass
-   */
-  class Mixin extends superClass {
-    // This enables `Shortcut` to be used in the html template.
-    Shortcut = Shortcut;
-
-    // This enables `ShortcutSection` to be used in the html template.
-    ShortcutSection = ShortcutSection;
-
-    private readonly shortcuts = appContext.shortcutsService;
-
-    /** Used to disable shortcuts when the element is not visible. */
-    private observer?: IntersectionObserver;
-
-    /**
-     * Enabling shortcuts only when the element is visible (see `observer`
-     * above) is a great feature, but often what you want is for the *page* to
-     * be visible, not the specific child element that registers keyboard
-     * shortcuts. An example is the FileList in the ChangeView. So we allow
-     * a broader observer target to be specified here, and fall back to
-     * `this` as the default.
-     */
-    @property({type: Object})
-    observerTarget: Element = this;
-
-    /** Are shortcuts currently enabled? True only when element is visible. */
-    private bindingsEnabled = false;
-
-    override connectedCallback() {
-      super.connectedCallback();
-      this.createVisibilityObserver();
-      this.enableBindings();
-    }
-
-    override disconnectedCallback() {
-      this.destroyVisibilityObserver();
-      this.disableBindings();
-      super.disconnectedCallback();
-    }
-
-    /**
-     * Creates an intersection observer that enables bindings when the
-     * element is visible and disables them when the element is hidden.
-     */
-    private createVisibilityObserver() {
-      if (!this.hasKeyboardShortcuts()) return;
-      if (this.observer) return;
-      this.observer = new IntersectionObserver(entries => {
-        check(entries.length === 1, 'Expected one observer entry.');
-        const isVisible = entries[0].isIntersecting;
-        if (isVisible) {
-          this.enableBindings();
-        } else {
-          this.disableBindings();
-        }
-      });
-      this.observer.observe(this.observerTarget);
-    }
-
-    private destroyVisibilityObserver() {
-      if (this.observer) this.observer.unobserve(this.observerTarget);
-    }
-
-    /**
-     * Enables all the shortcuts returned by keyboardShortcuts().
-     * This is a private method being called when the element becomes
-     * connected or visible.
-     */
-    private enableBindings() {
-      if (!this.hasKeyboardShortcuts()) return;
-      if (this.bindingsEnabled) return;
-      this.bindingsEnabled = true;
-
-      this.shortcuts.attachHost(this, this.keyboardShortcuts());
-    }
-
-    /**
-     * Disables all the shortcuts returned by keyboardShortcuts().
-     * This is a private method being called when the element becomes
-     * disconnected or invisible.
-     */
-    private disableBindings() {
-      if (!this.bindingsEnabled) return;
-      this.bindingsEnabled = false;
-      this.shortcuts.detachHost(this);
-    }
-
-    private hasKeyboardShortcuts() {
-      return this.keyboardShortcuts().length > 0;
-    }
-
-    keyboardShortcuts(): ShortcutListener[] {
-      return [];
-    }
-  }
-
-  return Mixin as T & Constructor<KeyboardShortcutMixinInterface>;
-};
-
-/** The interface corresponding to KeyboardShortcutMixin */
-export interface KeyboardShortcutMixinInterface {
-  keyboardShortcuts(): ShortcutListener[];
-}
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
new file mode 100644
index 0000000..2bf6068
--- /dev/null
+++ b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
@@ -0,0 +1,57 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {AccountDetailInfo, AccountInfo} from '../../api/rest-api';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {UserId} from '../../types/common';
+import {getUserId, isDetailedAccount} from '../../utils/account-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+
+export interface AccountsState {
+  accounts: {[id: UserId]: AccountDetailInfo};
+}
+
+export const accountsModelToken = define<AccountsModel>('accounts-model');
+
+export class AccountsModel extends Model<AccountsState> {
+  constructor(readonly restApiService: RestApiService) {
+    super({
+      accounts: {},
+    });
+  }
+
+  private updateStateAccount(id: UserId, account?: AccountDetailInfo) {
+    if (!account) return;
+    const current = {...this.getState()};
+    current.accounts = {...current.accounts, [id]: account};
+    this.setState(current);
+  }
+
+  async getAccount(partialAccount: AccountInfo) {
+    const current = this.getState();
+    const id = getUserId(partialAccount);
+    if (current.accounts[id]) return current.accounts[id];
+    // It is possible to add emails to CC when they don't have a Gerrit
+    // account. In this case getAccountDetails will return a 404 error hence
+    // pass an empty error function to handle that.
+    const account = await this.restApiService.getAccountDetails(id, () => {
+      this.updateStateAccount(id, partialAccount as AccountDetailInfo);
+      return;
+    });
+    if (account) this.updateStateAccount(id, account);
+    return account;
+  }
+
+  async fillDetails(account: AccountInfo) {
+    if (!isDetailedAccount(account)) {
+      if (account.email) return await this.getAccount({email: account.email});
+      else if (account._account_id)
+        return await this.getAccount({_account_id: account._account_id});
+    }
+    return account;
+  }
+}
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts b/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
new file mode 100644
index 0000000..53c90a6
--- /dev/null
+++ b/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../test/common-test-setup';
+import {EmailAddress} from '../../api/rest-api';
+import {getAppContext} from '../../services/app-context';
+import {stubRestApi} from '../../test/test-utils';
+import {AccountsModel} from './accounts-model';
+import {assert} from '@open-wc/testing';
+
+suite('accounts-model tests', () => {
+  let model: AccountsModel;
+
+  setup(() => {
+    model = new AccountsModel(getAppContext().restApiService);
+  });
+
+  teardown(() => {
+    model.finalize();
+  });
+
+  test('invalid account makes only one request', () => {
+    const response = {...new Response(), status: 404};
+    const getAccountDetails = stubRestApi('getAccountDetails').callsFake(
+      (_, errFn) => {
+        if (errFn !== undefined) {
+          errFn(response);
+        }
+        return Promise.resolve(undefined);
+      }
+    );
+
+    model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
+    assert.equal(getAccountDetails.callCount, 1);
+
+    model.fillDetails({email: 'Invalid_email@def.com' as EmailAddress});
+    assert.equal(getAccountDetails.callCount, 1);
+  });
+});
diff --git a/polygerrit-ui/app/models/browser/browser-model.ts b/polygerrit-ui/app/models/browser/browser-model.ts
new file mode 100644
index 0000000..50b6325
--- /dev/null
+++ b/polygerrit-ui/app/models/browser/browser-model.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Observable, combineLatest} from 'rxjs';
+import {define} from '../dependency';
+import {DiffViewMode} from '../../api/diff';
+import {UserModel} from '../user/user-model';
+import {Model} from '../model';
+import {select} from '../../utils/observable-util';
+
+// This value is somewhat arbitrary and not based on research or calculations.
+const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
+
+export interface BrowserState {
+  /**
+   * We maintain the screen width in the state so that the app can react to
+   * changes in the width such as automatically changing to unified diff view
+   */
+  screenWidth?: number;
+}
+
+const initialState: BrowserState = {};
+
+export const browserModelToken = define<BrowserModel>('browser-model');
+
+export class BrowserModel extends Model<BrowserState> {
+  private readonly isScreenTooSmall$ = select(
+    this.state$,
+    state =>
+      !!state.screenWidth &&
+      state.screenWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX
+  );
+
+  readonly diffViewMode$: Observable<DiffViewMode> = select(
+    combineLatest([
+      this.isScreenTooSmall$,
+      this.userModel.preferenceDiffViewMode$,
+    ]),
+    ([isScreenTooSmall, preferenceDiffViewMode]) =>
+      isScreenTooSmall ? DiffViewMode.UNIFIED : preferenceDiffViewMode
+  );
+
+  constructor(readonly userModel: UserModel) {
+    super(initialState);
+  }
+
+  /* Observe the screen width so that the app can react to changes to it */
+  observeWidth() {
+    return new ResizeObserver(entries => {
+      entries.forEach(entry => {
+        this.setScreenWidth(entry.contentRect.width);
+      });
+    });
+  }
+
+  // Private but used in tests.
+  setScreenWidth(screenWidth: number) {
+    this.updateState({screenWidth});
+  }
+}
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
new file mode 100644
index 0000000..b13a16f
--- /dev/null
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -0,0 +1,286 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  ChangeInfo,
+  NumericChangeId,
+  ChangeStatus,
+  ReviewerState,
+  AccountId,
+  AccountInfo,
+  GroupInfo,
+  Hashtag,
+} from '../../api/rest-api';
+import {Model} from '../model';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {define} from '../dependency';
+import {select} from '../../utils/observable-util';
+import {
+  ReviewInput,
+  ReviewerInput,
+  AttentionSetInput,
+  RelatedChangeAndCommitInfo,
+} from '../../types/common';
+import {getUserId} from '../../utils/account-util';
+import {getChangeNumber} from '../../utils/change-util';
+import {deepEqual} from '../../utils/deep-util';
+
+export const bulkActionsModelToken =
+  define<BulkActionsModel>('bulk-actions-model');
+
+export enum LoadingState {
+  NOT_SYNCED = 'NOT_SYNCED',
+  LOADING = 'LOADING',
+  LOADED = 'LOADED',
+}
+export interface BulkActionsState {
+  loadingState: LoadingState;
+  selectableChangeNums: NumericChangeId[];
+  selectedChangeNums: NumericChangeId[];
+  allChanges: Map<NumericChangeId, ChangeInfo>;
+}
+
+const initialState: BulkActionsState = {
+  loadingState: LoadingState.NOT_SYNCED,
+  selectedChangeNums: [],
+  selectableChangeNums: [],
+  allChanges: new Map(),
+};
+
+export class BulkActionsModel extends Model<BulkActionsState> {
+  constructor(private readonly restApiService: RestApiService) {
+    super(initialState);
+  }
+
+  public readonly selectedChangeNums$ = select(
+    this.state$,
+    bulkActionsState => bulkActionsState.selectedChangeNums
+  );
+
+  public readonly totalChangeCount$ = select(
+    this.state$,
+    bulkActionsState => bulkActionsState.allChanges.size
+  );
+
+  public readonly loadingState$ = select(
+    this.state$,
+    bulkActionsState => bulkActionsState.loadingState
+  );
+
+  public readonly selectedChanges$ = select(this.state$, bulkActionsState => {
+    const result = [];
+    for (const changeNum of bulkActionsState.selectedChangeNums) {
+      const change = bulkActionsState.allChanges.get(changeNum);
+      if (change) result.push(change);
+    }
+    return result;
+  });
+
+  toggleSelectedChangeNum(changeNum: NumericChangeId) {
+    this.getState().selectedChangeNums.includes(changeNum)
+      ? this.removeSelectedChangeNum(changeNum)
+      : this.addSelectedChangeNum(changeNum);
+  }
+
+  addSelectedChangeNum(changeNum: NumericChangeId) {
+    const current = this.getState();
+    if (!current.selectableChangeNums.includes(changeNum)) {
+      throw new Error(
+        `Trying to add change ${changeNum} that is not part of bulk-actions model`
+      );
+    }
+    const selectedChangeNums = [...current.selectedChangeNums];
+    selectedChangeNums.push(changeNum);
+    this.setState({...current, selectedChangeNums});
+  }
+
+  removeSelectedChangeNum(changeNum: NumericChangeId) {
+    const current = this.getState();
+    if (!current.selectableChangeNums.includes(changeNum)) {
+      throw new Error(
+        `Trying to remove change ${changeNum} that is not part of bulk-actions model`
+      );
+    }
+    const selectedChangeNums = [...current.selectedChangeNums];
+    const index = selectedChangeNums.findIndex(item => item === changeNum);
+    if (index === -1) return;
+    selectedChangeNums.splice(index, 1);
+    this.updateState({selectedChangeNums});
+  }
+
+  clearSelectedChangeNums() {
+    this.updateState({selectedChangeNums: []});
+  }
+
+  selectAll() {
+    const current = this.getState();
+    this.updateState({
+      selectedChangeNums: Array.from(current.allChanges.keys()),
+    });
+  }
+
+  abandonChanges(
+    reason?: string,
+    // errorFn is needed to avoid showing an error dialog
+    errFn?: (changeNum: NumericChangeId) => void
+  ): Promise<Response | undefined>[] {
+    const current = this.getState();
+    return current.selectedChangeNums.map(changeNum => {
+      if (!current.allChanges.get(changeNum))
+        throw new Error('invalid change id');
+      const change = current.allChanges.get(changeNum)!;
+      if (change.status === ChangeStatus.ABANDONED) {
+        return Promise.resolve(new Response());
+      }
+      return this.restApiService.executeChangeAction(
+        getChangeNumber(change),
+        change.actions!.abandon!.method,
+        '/abandon',
+        undefined,
+        {message: reason ?? ''},
+        () => errFn && errFn(getChangeNumber(change))
+      );
+    });
+  }
+
+  voteChanges(reviewInput: ReviewInput) {
+    const current = this.getState();
+    return current.selectedChangeNums.map(changeNum => {
+      const change = current.allChanges.get(changeNum)!;
+      if (!change) throw new Error('invalid change id');
+      return this.restApiService.saveChangeReview(
+        getChangeNumber(change),
+        'current',
+        reviewInput,
+        () => {
+          throw new Error();
+        }
+      );
+    });
+  }
+
+  addReviewers(
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>,
+    reason: string
+  ): Promise<Response>[] {
+    const current = this.getState();
+    const changes = current.selectedChangeNums.map(
+      changeNum => current.allChanges.get(changeNum)!
+    );
+    return changes.map(change => {
+      const reviewersNewToChange: ReviewerInput[] = [
+        ReviewerState.REVIEWER,
+        ReviewerState.CC,
+      ].flatMap(state =>
+        this.getNewReviewersToChange(change, state, changedReviewers)
+      );
+      if (reviewersNewToChange.length === 0) {
+        return Promise.resolve(new Response());
+      }
+      const attentionSetUpdates: AttentionSetInput[] = reviewersNewToChange
+        .filter(reviewerInput => reviewerInput.state === ReviewerState.REVIEWER)
+        .map(reviewerInput => {
+          return {
+            // TODO: Once Groups are supported, filter them out and only add
+            // Accounts to the attention set, just like gr-reply-dialog.
+            user: reviewerInput.reviewer as AccountId,
+            reason,
+          };
+        });
+      const reviewInput: ReviewInput = {
+        reviewers: reviewersNewToChange,
+        ignore_automatic_attention_set_rules: true,
+        add_to_attention_set: attentionSetUpdates,
+      };
+      return this.restApiService.saveChangeReview(
+        getChangeNumber(change),
+        'current',
+        reviewInput
+      );
+    });
+  }
+
+  addHashtags(hashtags: Hashtag[]): Promise<Hashtag[]>[] {
+    const current = this.getState();
+    return current.selectedChangeNums.map(changeNum =>
+      this.restApiService
+        .setChangeHashtag(changeNum, {
+          add: hashtags,
+        })
+        .then(responseHashtags => {
+          // Once we get server confirmation that the hashtags were added to the
+          // change, we are updating the model's ChangeInfo. This way we can
+          // keep the page state (dialog status) but use the updated change info
+          // naturally.
+
+          // refetch the current state since other changes may have been updated
+          // since the promises were launched.
+          const current = this.getState();
+          const nextState = {
+            ...current,
+            allChanges: new Map(current.allChanges),
+          };
+          nextState.allChanges.set(changeNum, {
+            ...nextState.allChanges.get(changeNum)!,
+            hashtags: responseHashtags,
+          });
+          this.setState(nextState);
+          return responseHashtags;
+        })
+    );
+  }
+
+  async sync(changes: (ChangeInfo | RelatedChangeAndCommitInfo)[]) {
+    const basicChanges = new Map(changes.map(c => [getChangeNumber(c), c]));
+    let currentState = this.getState();
+    const selectedChangeNums = currentState.selectedChangeNums.filter(
+      changeNum => basicChanges.has(changeNum)
+    );
+    const selectableChangeNums = changes.map(c => getChangeNumber(c));
+    this.updateState({
+      loadingState: LoadingState.LOADING,
+      selectedChangeNums,
+      selectableChangeNums,
+      allChanges: new Map(),
+    });
+
+    if (changes.length === 0) {
+      return;
+    }
+    const changeDetails =
+      await this.restApiService.getDetailedChangesWithActions(
+        changes.map(c => getChangeNumber(c))
+      );
+    currentState = this.getState();
+    // Return early if sync has been called again since starting the load.
+    if (!deepEqual(selectableChangeNums, currentState.selectableChangeNums)) {
+      return;
+    }
+    const allDetailedChanges: Map<NumericChangeId, ChangeInfo> = new Map();
+    for (const detailedChange of changeDetails ?? []) {
+      allDetailedChanges.set(detailedChange._number, detailedChange);
+    }
+    this.setState({
+      ...currentState,
+      loadingState: LoadingState.LOADED,
+      allChanges: allDetailedChanges,
+    });
+  }
+
+  private getNewReviewersToChange(
+    change: ChangeInfo,
+    state: ReviewerState,
+    changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>
+  ): ReviewerInput[] {
+    return (
+      changedReviewers
+        .get(state)
+        ?.filter(account => !change.reviewers[state]?.includes(account))
+        .map(account => {
+          return {state, reviewer: getUserId(account)};
+        }) ?? []
+    );
+  }
+}
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
new file mode 100644
index 0000000..e2f75f7
--- /dev/null
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -0,0 +1,551 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  createAccountWithIdNameAndEmail,
+  createChange,
+  createGroupInfo,
+  createRevisions,
+} from '../../test/test-data-generators';
+import {
+  ChangeInfo,
+  NumericChangeId,
+  ChangeStatus,
+  HttpMethod,
+  AccountInfo,
+  ReviewerState,
+  GroupInfo,
+  Hashtag,
+} from '../../api/rest-api';
+import {BulkActionsModel, LoadingState} from './bulk-actions-model';
+import {getAppContext} from '../../services/app-context';
+import '../../test/common-test-setup';
+import {
+  stubRestApi,
+  waitEventLoop,
+  waitUntilObserved,
+} from '../../test/test-utils';
+import {mockPromise} from '../../test/test-utils';
+import {SinonStubbedMember} from 'sinon';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {ReviewInput} from '../../types/common';
+import {assert} from '@open-wc/testing';
+
+suite('bulk actions model test', () => {
+  let bulkActionsModel: BulkActionsModel;
+  setup(() => {
+    bulkActionsModel = new BulkActionsModel(getAppContext().restApiService);
+  });
+
+  test('does not request detailed changes when no changes are synced', () => {
+    const detailedActionsStub = stubRestApi('getDetailedChangesWithActions');
+
+    bulkActionsModel.sync([]);
+
+    assert.isTrue(detailedActionsStub.notCalled);
+  });
+
+  test('add changes before sync does not add them', () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+
+    assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+
+    assert.throws(() => bulkActionsModel.addSelectedChangeNum(c1._number));
+    assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+
+    bulkActionsModel.sync([c1, c2]);
+
+    bulkActionsModel.addSelectedChangeNum(c2._number);
+    assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [
+      c2._number,
+    ]);
+
+    bulkActionsModel.removeSelectedChangeNum(c2._number);
+    assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+  });
+
+  test('add and remove selected changes', () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([c1, c2]);
+
+    assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+    assert.deepEqual(bulkActionsModel.getState().selectableChangeNums, [1, 2]);
+
+    bulkActionsModel.addSelectedChangeNum(c1._number);
+    assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [
+      c1._number,
+    ]);
+
+    bulkActionsModel.addSelectedChangeNum(c2._number);
+    assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [
+      c1._number,
+      c2._number,
+    ]);
+
+    bulkActionsModel.removeSelectedChangeNum(c1._number);
+    assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [
+      c2._number,
+    ]);
+
+    bulkActionsModel.removeSelectedChangeNum(c2._number);
+    assert.isEmpty(bulkActionsModel.getState().selectedChangeNums);
+  });
+
+  test('toggle selected changes', async () => {
+    const change1 = createChange();
+    change1._number = 1 as NumericChangeId;
+    const change2 = createChange();
+    change2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([change1, change2]);
+
+    // toggle first change on
+    bulkActionsModel.toggleSelectedChangeNum(change1._number);
+
+    let selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      selectedChangeNums => selectedChangeNums.includes(change1._number)
+    );
+    assert.sameMembers(selectedChangeNums, [change1._number]);
+
+    // toggle second change on
+    bulkActionsModel.toggleSelectedChangeNum(change2._number);
+
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      selectedChangeNums => selectedChangeNums.includes(change2._number)
+    );
+    assert.sameMembers(selectedChangeNums, [change1._number, change2._number]);
+
+    // toggle first change off
+    bulkActionsModel.toggleSelectedChangeNum(change1._number);
+
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      selectedChangeNums => !selectedChangeNums.includes(change1._number)
+    );
+    assert.sameMembers(selectedChangeNums, [change2._number]);
+  });
+
+  test('clears selected change numbers', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([c1, c2]);
+    bulkActionsModel.addSelectedChangeNum(c1._number);
+    bulkActionsModel.addSelectedChangeNum(c2._number);
+    let selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      s => s.length === 2
+    );
+    let totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+    assert.sameMembers(selectedChangeNums, [c1._number, c2._number]);
+    assert.equal(totalChangeCount, 2);
+
+    bulkActionsModel.clearSelectedChangeNums();
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      s => s.length === 0
+    );
+    totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+
+    assert.isEmpty(selectedChangeNums);
+    assert.equal(totalChangeCount, 2);
+  });
+
+  test('selects all changes', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([c1, c2]);
+    let selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      s => s.length === 0
+    );
+    let totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+    assert.isEmpty(selectedChangeNums);
+    assert.equal(totalChangeCount, 2);
+
+    bulkActionsModel.selectAll();
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      s => s.length === 2
+    );
+    totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+
+    assert.sameMembers(selectedChangeNums, [c1._number, c2._number]);
+    assert.equal(totalChangeCount, 2);
+  });
+
+  suite('abandon changes', () => {
+    let detailedActionsStub: SinonStubbedMember<
+      RestApiService['getDetailedChangesWithActions']
+    >;
+    setup(async () => {
+      detailedActionsStub = stubRestApi('getDetailedChangesWithActions');
+      const c1 = createChange();
+      c1._number = 1 as NumericChangeId;
+      const c2 = createChange();
+      c2._number = 2 as NumericChangeId;
+
+      detailedActionsStub.returns(
+        Promise.resolve([
+          {...c1, actions: {abandon: {method: HttpMethod.POST}}},
+          {...c2, status: ChangeStatus.ABANDONED},
+        ])
+      );
+
+      bulkActionsModel.sync([c1, c2]);
+
+      bulkActionsModel.addSelectedChangeNum(c1._number);
+      bulkActionsModel.addSelectedChangeNum(c2._number);
+    });
+
+    test('already abandoned change does not call executeChangeAction', () => {
+      const actionStub = stubRestApi('executeChangeAction');
+      bulkActionsModel.abandonChanges();
+      assert.equal(actionStub.callCount, 1);
+      assert.deepEqual(actionStub.lastCall.args.slice(0, 5), [
+        1 as NumericChangeId,
+        HttpMethod.POST,
+        '/abandon',
+        undefined,
+        {message: ''},
+      ]);
+    });
+  });
+
+  suite('add reviewers', () => {
+    const accounts: AccountInfo[] = [
+      createAccountWithIdNameAndEmail(0),
+      createAccountWithIdNameAndEmail(1),
+    ];
+    const groups: GroupInfo[] = [createGroupInfo('groupId')];
+    const changes: ChangeInfo[] = [
+      {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        subject: 'Subject 1',
+        reviewers: {
+          REVIEWER: [accounts[0]],
+          CC: [accounts[1]],
+        },
+      },
+      {
+        ...createChange(),
+        _number: 2 as NumericChangeId,
+        subject: 'Subject 2',
+      },
+    ];
+    let saveChangeReviewStub: sinon.SinonStub;
+
+    setup(async () => {
+      saveChangeReviewStub = stubRestApi('saveChangeReview').resolves(
+        new Response()
+      );
+      stubRestApi('getDetailedChangesWithActions').resolves([
+        {...changes[0], actions: {abandon: {method: HttpMethod.POST}}},
+        {...changes[1], status: ChangeStatus.ABANDONED},
+      ]);
+      bulkActionsModel.sync(changes);
+      bulkActionsModel.addSelectedChangeNum(changes[0]._number);
+      bulkActionsModel.addSelectedChangeNum(changes[1]._number);
+    });
+
+    test('adds reviewers/cc only to changes that need it', async () => {
+      bulkActionsModel.addReviewers(
+        new Map([
+          [ReviewerState.REVIEWER, [accounts[0], groups[0]]],
+          [ReviewerState.CC, [accounts[1]]],
+        ]),
+        '<GERRIT_ACCOUNT_12345> replied on the change'
+      );
+
+      assert.isTrue(saveChangeReviewStub.calledTwice);
+      // changes[0] only adds the group since it already has the other
+      // reviewer/CCs
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [
+        changes[0]._number,
+        'current',
+        {
+          reviewers: [{reviewer: groups[0].id, state: ReviewerState.REVIEWER}],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+      assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [
+        changes[1]._number,
+        'current',
+        {
+          reviewers: [
+            {reviewer: accounts[0]._account_id, state: ReviewerState.REVIEWER},
+            {reviewer: groups[0].id, state: ReviewerState.REVIEWER},
+            {reviewer: accounts[1]._account_id, state: ReviewerState.CC},
+          ],
+          ignore_automatic_attention_set_rules: true,
+          add_to_attention_set: [
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: accounts[0]._account_id,
+            },
+            {
+              reason: '<GERRIT_ACCOUNT_12345> replied on the change',
+              user: groups[0].id,
+            },
+          ],
+        },
+      ]);
+    });
+  });
+
+  suite('voteChanges', () => {
+    let detailedActionsStub: SinonStubbedMember<
+      RestApiService['getDetailedChangesWithActions']
+    >;
+    setup(async () => {
+      const c1 = {...createChange(), revisions: createRevisions(10)};
+      c1._number = 1 as NumericChangeId;
+      const c2 = {...createChange(), revisions: createRevisions(4)};
+      c2._number = 2 as NumericChangeId;
+
+      detailedActionsStub = stubRestApi('getDetailedChangesWithActions');
+      detailedActionsStub.returns(
+        Promise.resolve([
+          {...c1, actions: {abandon: {method: HttpMethod.POST}}},
+          {...c2, status: ChangeStatus.ABANDONED},
+        ])
+      );
+
+      await bulkActionsModel.sync([c1, c2]);
+
+      bulkActionsModel.addSelectedChangeNum(c1._number);
+      bulkActionsModel.addSelectedChangeNum(c2._number);
+    });
+
+    test('vote changes', () => {
+      const reviewStub = stubRestApi('saveChangeReview');
+      const reviewInput: ReviewInput = {
+        labels: {
+          a: 1,
+        },
+      };
+      bulkActionsModel.voteChanges(reviewInput);
+      assert.equal(reviewStub.callCount, 2);
+      assert.deepEqual(reviewStub.firstCall.args.slice(0, 3), [
+        1 as NumericChangeId,
+        'current',
+        {
+          labels: {
+            a: 1,
+          },
+        },
+      ]);
+
+      assert.deepEqual(reviewStub.secondCall.args.slice(0, 3), [
+        2 as NumericChangeId,
+        'current',
+        {
+          labels: {
+            a: 1,
+          },
+        },
+      ]);
+    });
+  });
+
+  suite('add hashtags', () => {
+    const change1: ChangeInfo = {
+      ...createChange(),
+      _number: 1 as NumericChangeId,
+      hashtags: ['existingHashtag' as Hashtag],
+    };
+    const change2: ChangeInfo = {
+      ...createChange(),
+      _number: 2 as NumericChangeId,
+      hashtags: ['existingHashtag' as Hashtag],
+    };
+    const existingHashtag = 'existingHashtag' as Hashtag;
+    const newHashtag = 'newHashtag' as Hashtag;
+    let detailedActionsStub: SinonStubbedMember<
+      RestApiService['getDetailedChangesWithActions']
+    >;
+    setup(async () => {
+      detailedActionsStub = stubRestApi('getDetailedChangesWithActions');
+      detailedActionsStub.returns(Promise.resolve([change1, change2]));
+
+      await bulkActionsModel.sync([change1, change2]);
+      bulkActionsModel.addSelectedChangeNum(change1._number);
+      bulkActionsModel.addSelectedChangeNum(change2._number);
+      stubRestApi('setChangeHashtag').resolves([existingHashtag, newHashtag]);
+    });
+
+    test('server-acked hashtags are added to the model', async () => {
+      await Promise.all(bulkActionsModel.addHashtags([newHashtag]));
+
+      const updatedChanges = await waitUntilObserved(
+        bulkActionsModel.selectedChanges$,
+        changes => changes.some(change => change.hashtags?.includes(newHashtag))
+      );
+
+      assert.deepEqual(updatedChanges, [
+        {...change1, hashtags: [existingHashtag, newHashtag]},
+        {...change2, hashtags: [existingHashtag, newHashtag]},
+      ]);
+    });
+  });
+
+  test('stale changes are removed from the model', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    bulkActionsModel.sync([c1, c2]);
+
+    bulkActionsModel.addSelectedChangeNum(c1._number);
+    bulkActionsModel.addSelectedChangeNum(c2._number);
+
+    let selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      s => s.length === 2
+    );
+    let totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 2
+    );
+
+    assert.sameMembers(selectedChangeNums, [c1._number, c2._number]);
+    assert.equal(totalChangeCount, 2);
+
+    bulkActionsModel.sync([c1]);
+    selectedChangeNums = await waitUntilObserved(
+      bulkActionsModel.selectedChangeNums$,
+      s => s.length === 1
+    );
+    totalChangeCount = await waitUntilObserved(
+      bulkActionsModel.totalChangeCount$,
+      totalChangeCount => totalChangeCount === 1
+    );
+
+    assert.sameMembers(selectedChangeNums, [c1._number]);
+    assert.equal(totalChangeCount, 1);
+  });
+
+  test('sync fetches new changes', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+
+    assert.equal(
+      bulkActionsModel.getState().loadingState,
+      LoadingState.NOT_SYNCED
+    );
+
+    bulkActionsModel.sync([c1, c2]);
+    await waitUntilObserved(
+      bulkActionsModel.loadingState$,
+      s => s === LoadingState.LOADING
+    );
+
+    await waitUntilObserved(
+      bulkActionsModel.loadingState$,
+      s => s === LoadingState.LOADED
+    );
+    const model = bulkActionsModel.getState();
+
+    assert.strictEqual(
+      model.allChanges.get(1 as NumericChangeId)?.subject,
+      'Subject 1'
+    );
+    assert.strictEqual(
+      model.allChanges.get(2 as NumericChangeId)?.subject,
+      'Subject 2'
+    );
+  });
+
+  test('sync ignores outdated fetch responses', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+
+    const responsePromise1 = mockPromise<ChangeInfo[]>();
+    let promise = responsePromise1;
+    const getChangesStub = stubRestApi(
+      'getDetailedChangesWithActions'
+    ).callsFake(() => promise);
+    bulkActionsModel.sync([c1]);
+    assert.strictEqual(getChangesStub.callCount, 1);
+    await waitUntilObserved(
+      bulkActionsModel.loadingState$,
+      s => s === LoadingState.LOADING
+    );
+    const responsePromise2 = mockPromise<ChangeInfo[]>();
+
+    promise = responsePromise2;
+    bulkActionsModel.sync([c1, c2]);
+    assert.strictEqual(getChangesStub.callCount, 2);
+
+    responsePromise2.resolve([
+      {...createChange(), _number: 1, subject: 'Subject 1'},
+      {...createChange(), _number: 2, subject: 'Subject 2'},
+    ] as ChangeInfo[]);
+
+    await waitUntilObserved(
+      bulkActionsModel.loadingState$,
+      s => s === LoadingState.LOADED
+    );
+    const model = bulkActionsModel.getState();
+    assert.strictEqual(
+      model.allChanges.get(1 as NumericChangeId)?.subject,
+      'Subject 1'
+    );
+    assert.strictEqual(
+      model.allChanges.get(2 as NumericChangeId)?.subject,
+      'Subject 2'
+    );
+
+    // Resolve the old promise.
+    responsePromise1.resolve([
+      {...createChange(), _number: 1, subject: 'Subject 1-old'},
+    ] as ChangeInfo[]);
+    await waitEventLoop();
+    const model2 = bulkActionsModel.getState();
+
+    // No change should happen.
+    assert.strictEqual(
+      model2.allChanges.get(1 as NumericChangeId)?.subject,
+      'Subject 1'
+    );
+    assert.strictEqual(
+      model2.allChanges.get(2 as NumericChangeId)?.subject,
+      'Subject 2'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
new file mode 100644
index 0000000..446822f
--- /dev/null
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -0,0 +1,504 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  BasePatchSetNum,
+  EditInfo,
+  EDIT,
+  PARENT,
+  NumericChangeId,
+  PatchSetNum,
+  PreferencesInfo,
+  RevisionPatchSetNum,
+  PatchSetNumber,
+} from '../../types/common';
+import {DefaultBase} from '../../constants/constants';
+import {combineLatest, from, fromEvent, Observable, forkJoin, of} from 'rxjs';
+import {
+  map,
+  filter,
+  withLatestFrom,
+  startWith,
+  switchMap,
+} from 'rxjs/operators';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+} from '../../utils/patch-set-util';
+import {ParsedChangeInfo} from '../../types/types';
+import {fireAlert} from '../../utils/event-util';
+
+import {ChangeInfo} from '../../types/common';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {select} from '../../utils/observable-util';
+import {assertIsDefined} from '../../utils/common-util';
+import {Model} from '../model';
+import {UserModel} from '../user/user-model';
+import {define} from '../dependency';
+import {isOwner} from '../../utils/change-util';
+import {
+  ChangeViewModel,
+  createChangeUrl,
+  createDiffUrl,
+  createEditUrl,
+} from '../views/change';
+import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
+
+export enum LoadingStatus {
+  NOT_LOADED = 'NOT_LOADED',
+  LOADING = 'LOADING',
+  RELOADING = 'RELOADING',
+  LOADED = 'LOADED',
+}
+
+const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
+
+export interface ChangeState {
+  /**
+   * If `change` is undefined, this must be either NOT_LOADED or LOADING.
+   * If `change` is defined, this must be either LOADED or RELOADING.
+   */
+  loadingStatus: LoadingStatus;
+  change?: ParsedChangeInfo;
+  /**
+   * The list of reviewed files, kept in the model because we want changes made
+   * in one view to reflect on other views without re-rendering the other views.
+   * Undefined means it's still loading and empty set means no files reviewed.
+   */
+  reviewedFiles?: string[];
+}
+
+/**
+ * Updates the change object with information from the saved `edit` patchset.
+ */
+// visible for testing
+export function updateChangeWithEdit(
+  change?: ParsedChangeInfo,
+  edit?: EditInfo,
+  viewModelPatchNum?: PatchSetNum
+): ParsedChangeInfo | undefined {
+  if (!change || !edit) return change;
+  assertIsDefined(edit.commit.commit, 'edit.commit.commit');
+  if (!change.revisions) change.revisions = {};
+  change.revisions[edit.commit.commit] = {
+    _number: EDIT,
+    basePatchNum: edit.base_patch_set_number,
+    commit: edit.commit,
+    fetch: edit.fetch,
+  };
+  // If the change was loaded without a specific patchset, then this normally
+  // means that the *latest* patchset should be loaded. But if there is an
+  // active edit, then automatically switch to that edit as the current
+  // patchset.
+  // TODO: This goes together with `_patchRange.patchNum' being set to `edit`,
+  // which is still done in change-view. `_patchRange.patchNum` should
+  // eventually also be model managed, so we can reconcile these two code
+  // snippets into one location.
+  if (viewModelPatchNum === undefined) {
+    change.current_revision = edit.commit.commit;
+  }
+  return change;
+}
+
+/**
+ * Derives the base patchset number from all the data that can potentially
+ * influence it. Mostly just returns `viewModelBasePatchNum` or PARENT, but has
+ * some special logic when looking at merge commits.
+ *
+ * NOTE: At the moment this returns just `viewModelBasePatchNum ?? PARENT`, see
+ * TODO below.
+ */
+function computeBase(
+  viewModelBasePatchNum: BasePatchSetNum | undefined,
+  patchNum: RevisionPatchSetNum | undefined,
+  change: ParsedChangeInfo | undefined,
+  preferences: PreferencesInfo
+): BasePatchSetNum {
+  if (viewModelBasePatchNum && viewModelBasePatchNum !== PARENT) {
+    return viewModelBasePatchNum;
+  }
+  if (!change || !patchNum) return PARENT;
+
+  const preferFirst =
+    preferences.default_base_for_merges === DefaultBase.FIRST_PARENT;
+  if (!preferFirst) return PARENT;
+
+  // TODO: Re-enable respecting the default_base_for_merges preference.
+  // For the Polygerrit UI this was originally implemented in change 214432,
+  // but we are not sure whether this was ever 100% working correctly. A
+  // major challenge is being able to select PARENT explicitly even if your
+  // preference for the default choice is FIRST_PARENT. <gr-file-list-header>
+  // just uses `navigation.setUrl()` and the view model does not have any
+  // way of forcing the basePatchSetNum to stick to PARENT without being
+  // altered back to FIRST_PARENT here.
+  // See also corresponding TODO in gr-settings-view.
+  return PARENT;
+  // const revisionInfo = new RevisionInfo(change);
+  // const isMergeCommit = revisionInfo.isMergeCommit(patchNum);
+  // return isMergeCommit ? (-1 as PatchSetNumber) : PARENT;
+}
+
+// TODO: Figure out how to best enforce immutability of all states. Use Immer?
+// Use DeepReadOnly?
+const initialState: ChangeState = {
+  loadingStatus: LoadingStatus.NOT_LOADED,
+};
+
+export const changeModelToken = define<ChangeModel>('change-model');
+
+export class ChangeModel extends Model<ChangeState> {
+  private change?: ParsedChangeInfo;
+
+  private patchNum?: RevisionPatchSetNum;
+
+  private basePatchNum?: BasePatchSetNum;
+
+  private latestPatchNum?: PatchSetNumber;
+
+  public readonly change$ = select(
+    this.state$,
+    changeState => changeState.change
+  );
+
+  public readonly changeLoadingStatus$ = select(
+    this.state$,
+    changeState => changeState.loadingStatus
+  );
+
+  public readonly reviewedFiles$ = select(
+    this.state$,
+    changeState => changeState?.reviewedFiles
+  );
+
+  public readonly changeNum$ = select(this.change$, change => change?._number);
+
+  public readonly repo$ = select(this.change$, change => change?.project);
+
+  public readonly labels$ = select(this.change$, change => change?.labels);
+
+  public readonly revisions$ = select(
+    this.change$,
+    change => change?.revisions
+  );
+
+  public readonly patchsets$ = select(this.change$, change =>
+    computeAllPatchSets(change)
+  );
+
+  public readonly latestPatchNum$ = select(this.patchsets$, patchsets =>
+    computeLatestPatchNum(patchsets)
+  );
+
+  /**
+   * 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 without the change being available!
+   */
+  public readonly patchNum$: Observable<RevisionPatchSetNum | undefined> =
+    select(
+      combineLatest([
+        this.viewModel.state$,
+        this.state$,
+        this.latestPatchNum$,
+      ]).pipe(
+        /**
+         * If you depend on both, view model and change state, then you want to
+         * filter out inconsistent state, e.g. view model changeNum already
+         * updated, change not yet reset to undefined.
+         */
+        filter(([viewModelState, changeState, _latestPatchN]) => {
+          const changeNum = changeState.change?._number;
+          const viewModelChangeNum = viewModelState?.changeNum;
+          return changeNum === undefined || changeNum === viewModelChangeNum;
+        })
+      ),
+      ([viewModelState, _changeState, latestPatchN]) =>
+        viewModelState?.patchNum || latestPatchN
+    );
+
+  /**
+   * Emits the base patchset number. This is identical to the
+   * `viewModel.basePatchNum$`, but has some special logic for merges.
+   *
+   * Note that this selector can emit without the change being available!
+   */
+  public readonly basePatchNum$: Observable<BasePatchSetNum> =
+    /**
+     * If you depend on both, view model and change state, then you want to
+     * filter out inconsistent state, e.g. view model changeNum already
+     * updated, change not yet reset to undefined.
+     */
+    select(
+      combineLatest([
+        this.viewModel.state$,
+        this.state$,
+        this.userModel.state$,
+      ]).pipe(
+        filter(([viewModelState, changeState, _]) => {
+          const changeNum = changeState.change?._number;
+          const viewModelChangeNum = viewModelState?.changeNum;
+          return changeNum === undefined || changeNum === viewModelChangeNum;
+        }),
+        withLatestFrom(
+          this.viewModel.basePatchNum$,
+          this.patchNum$,
+          this.change$,
+          this.userModel.preferences$
+        )
+      ),
+      ([_, viewModelBasePatchNum, patchNum, change, preferences]) =>
+        computeBase(viewModelBasePatchNum, patchNum, change, preferences)
+    );
+
+  public readonly isOwner$: Observable<boolean> = select(
+    combineLatest([this.change$, this.userModel.account$]),
+    ([change, account]) => isOwner(change, account)
+  );
+
+  // For usage in `combineLatest` we need `startWith` such that reload$ has an
+  // initial value.
+  readonly reload$: Observable<unknown> = fromEvent(document, 'reload').pipe(
+    startWith(undefined)
+  );
+
+  constructor(
+    private readonly navigation: NavigationService,
+    private readonly viewModel: ChangeViewModel,
+    private readonly restApiService: RestApiService,
+    private readonly userModel: UserModel
+  ) {
+    super(initialState);
+    this.subscriptions = [
+      combineLatest([this.viewModel.changeNum$, this.reload$])
+        .pipe(
+          map(([changeNum, _]) => changeNum),
+          switchMap(changeNum => {
+            if (changeNum !== undefined) this.updateStateLoading(changeNum);
+            const change = from(this.restApiService.getChangeDetail(changeNum));
+            const edit = from(this.restApiService.getChangeEdit(changeNum));
+            return forkJoin([change, edit]);
+          }),
+          withLatestFrom(this.viewModel.patchNum$),
+          map(([[change, edit], patchNum]) =>
+            updateChangeWithEdit(change, edit, patchNum)
+          )
+        )
+        .subscribe(change => {
+          // The change service is currently a singleton, so we have to be
+          // careful to avoid situations where the application state is
+          // partially set for the old change where the user is coming from,
+          // and partially for the new change where the user is navigating to.
+          // So setting the change explicitly to undefined when the user
+          // moves away from diff and change pages (changeNum === undefined)
+          // helps with that.
+          this.updateStateChange(change ?? undefined);
+        }),
+      this.change$.subscribe(change => (this.change = change)),
+      this.patchNum$.subscribe(patchNum => (this.patchNum = patchNum)),
+      this.basePatchNum$.subscribe(
+        basePatchNum => (this.basePatchNum = basePatchNum)
+      ),
+      this.latestPatchNum$.subscribe(
+        latestPatchNum => (this.latestPatchNum = latestPatchNum)
+      ),
+      combineLatest([this.patchNum$, this.changeNum$, this.userModel.loggedIn$])
+        .pipe(
+          switchMap(([patchNum, changeNum, loggedIn]) => {
+            if (!changeNum || !patchNum || !loggedIn) {
+              this.updateStateReviewedFiles([]);
+              return of(undefined);
+            }
+            return from(this.fetchReviewedFiles(patchNum, changeNum));
+          })
+        )
+        .subscribe(),
+    ];
+  }
+
+  updateStateReviewedFiles(reviewedFiles: string[]) {
+    this.updateState({reviewedFiles});
+  }
+
+  updateStateFileReviewed(file: string, reviewed: boolean) {
+    const current = this.getState();
+    if (current.reviewedFiles === undefined) {
+      // Reviewed files haven't loaded yet.
+      // TODO(dhruvsri): disable updating status if reviewed files are not loaded.
+      fireAlert(
+        document,
+        'Updating status failed. Reviewed files not loaded yet.'
+      );
+      return;
+    }
+    const reviewedFiles = [...current.reviewedFiles];
+
+    // File is already reviewed and is being marked reviewed
+    if (reviewedFiles.includes(file) && reviewed) return;
+    // File is not reviewed and is being marked not reviewed
+    if (!reviewedFiles.includes(file) && !reviewed) return;
+
+    if (reviewed) reviewedFiles.push(file);
+    else reviewedFiles.splice(reviewedFiles.indexOf(file), 1);
+    this.updateState({reviewedFiles});
+  }
+
+  fetchReviewedFiles(patchNum: PatchSetNum, changeNum: NumericChangeId) {
+    return this.restApiService
+      .getReviewedFiles(changeNum, patchNum)
+      .then(files => {
+        if (changeNum !== this.change?._number || patchNum !== this.patchNum)
+          return;
+        this.updateStateReviewedFiles(files ?? []);
+      });
+  }
+
+  setReviewedFilesStatus(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    file: string,
+    reviewed: boolean
+  ) {
+    return this.restApiService
+      .saveFileReviewed(changeNum, patchNum, file, reviewed)
+      .then(() => {
+        if (changeNum !== this.change?._number || patchNum !== this.patchNum)
+          return;
+        this.updateStateFileReviewed(file, reviewed);
+      })
+      .catch(() => {
+        fireAlert(document, ERR_REVIEW_STATUS);
+      });
+  }
+
+  /**
+   * Typically you would just subscribe to change$ yourself to get updates. But
+   * sometimes it is nice to also be able to get the current ChangeInfo on
+   * demand. So here it is for your convenience.
+   */
+  getChange() {
+    return this.getState().change;
+  }
+
+  diffUrl(
+    diffView: {path: string; lineNum?: number},
+    patchNum = this.patchNum,
+    basePatchNum = this.basePatchNum
+  ) {
+    if (!this.change) return;
+    if (!this.patchNum) return;
+    return createDiffUrl({
+      change: this.change,
+      patchNum,
+      basePatchNum,
+      diffView,
+    });
+  }
+
+  navigateToDiff(
+    diffView: {path: string; lineNum?: number},
+    patchNum = this.patchNum,
+    basePatchNum = this.basePatchNum
+  ) {
+    const url = this.diffUrl(diffView, patchNum, basePatchNum);
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
+  changeUrl(openReplyDialog = false) {
+    if (!this.change) return;
+    const isLatest = this.latestPatchNum === this.patchNum;
+    return createChangeUrl({
+      change: this.change,
+      patchNum:
+        isLatest && this.basePatchNum === PARENT ? undefined : this.patchNum,
+      basePatchNum: this.basePatchNum,
+      openReplyDialog,
+    });
+  }
+
+  navigateToChange(openReplyDialog = false) {
+    const url = this.changeUrl(openReplyDialog);
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
+  editUrl(editView: {path: string; lineNum?: number}) {
+    if (!this.change) return;
+    return createEditUrl({
+      changeNum: this.change._number,
+      repo: this.change.project,
+      patchNum: this.patchNum,
+      editView,
+    });
+  }
+
+  navigateToEdit(editView: {path: string; lineNum?: number}) {
+    const url = this.editUrl(editView);
+    if (!url) return;
+    this.navigation.setUrl(url);
+  }
+
+  /**
+   * Check whether there is no newer patch than the latest patch that was
+   * available when this change was loaded.
+   *
+   * @return A promise that yields true if the latest patch
+   *     has been loaded, and false if a newer patch has been uploaded in the
+   *     meantime. The promise is rejected on network error.
+   */
+  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+    return this.restApiService.getChangeDetail(change._number).then(detail => {
+      if (!detail) {
+        const error = new Error('Change detail not found.');
+        return Promise.reject(error);
+      }
+      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+      if (!actualLatest || !knownLatest) {
+        const error = new Error('Unable to check for latest patchset.');
+        return Promise.reject(error);
+      }
+      return {
+        isLatest: actualLatest <= knownLatest,
+        newStatus: change.status !== detail.status ? detail.status : null,
+        newMessages:
+          (change.messages || []).length < (detail.messages || []).length
+            ? detail.messages![detail.messages!.length - 1]
+            : undefined,
+      };
+    });
+  }
+
+  /**
+   * Called when change detail loading is initiated.
+   *
+   * If the change number matches the current change in the state, then
+   * this is a reload. If not, then we not just want to set the state to
+   * LOADING instead of RELOADING, but we also want to set the change to
+   * undefined right away. Otherwise components could see inconsistent state:
+   * a new change number, but an old change.
+   */
+  private updateStateLoading(changeNum: NumericChangeId) {
+    const current = this.getState();
+    const reloading = current.change?._number === changeNum;
+    this.updateState({
+      change: reloading ? current.change : undefined,
+      loadingStatus: reloading
+        ? LoadingStatus.RELOADING
+        : LoadingStatus.LOADING,
+    });
+  }
+
+  // Private but used in tests.
+  updateStateChange(change?: ParsedChangeInfo) {
+    this.updateState({
+      change,
+      loadingStatus:
+        change === undefined ? LoadingStatus.NOT_LOADED : LoadingStatus.LOADED,
+    });
+  }
+}
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
new file mode 100644
index 0000000..c11c15b
--- /dev/null
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -0,0 +1,297 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Subject} from 'rxjs';
+import {ChangeStatus} from '../../constants/constants';
+import '../../test/common-test-setup';
+import {
+  createChange,
+  createChangeMessageInfo,
+  createChangeViewState,
+  createEditInfo,
+  createParsedChange,
+  createRevision,
+} from '../../test/test-data-generators';
+import {
+  mockPromise,
+  stubRestApi,
+  waitUntilObserved,
+} from '../../test/test-utils';
+import {
+  CommitId,
+  EDIT,
+  NumericChangeId,
+  PARENT,
+  PatchSetNum,
+  PatchSetNumber,
+} from '../../types/common';
+import {ParsedChangeInfo} from '../../types/types';
+import {getAppContext} from '../../services/app-context';
+import {ChangeState, LoadingStatus, updateChangeWithEdit} from './change-model';
+import {ChangeModel} from './change-model';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {userModelToken} from '../user/user-model';
+import {changeViewModelToken} from '../views/change';
+import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
+
+suite('updateChangeWithEdit() tests', () => {
+  test('undefined change', async () => {
+    assert.isUndefined(updateChangeWithEdit());
+  });
+
+  test('undefined edit', async () => {
+    const change = createParsedChange();
+    assert.equal(updateChangeWithEdit(change), change);
+  });
+
+  test('set edit rev and current rev', async () => {
+    let change: ParsedChangeInfo | undefined = createParsedChange();
+    const edit = createEditInfo();
+    change = updateChangeWithEdit(change, edit);
+    const editRev = change?.revisions[`${edit.commit.commit}`];
+    assert.isDefined(editRev);
+    assert.equal(editRev?._number, EDIT);
+    assert.equal(editRev?.basePatchNum, edit.base_patch_set_number);
+    assert.equal(change?.current_revision, edit.commit.commit);
+  });
+
+  test('do not set current rev when patchNum already set', async () => {
+    let change: ParsedChangeInfo | undefined = createParsedChange();
+    const edit = createEditInfo();
+    change = updateChangeWithEdit(change, edit, 1 as PatchSetNum);
+    const editRev = change?.revisions[`${edit.commit.commit}`];
+    assert.isDefined(editRev);
+    assert.equal(change?.current_revision, 'abc' as CommitId);
+  });
+});
+
+suite('change model tests', () => {
+  let changeModel: ChangeModel;
+  let knownChange: ParsedChangeInfo;
+  const testCompleted = new Subject<void>();
+
+  async function waitForLoadingStatus(
+    loadingStatus: LoadingStatus
+  ): Promise<ChangeState> {
+    return await waitUntilObserved(
+      changeModel.state$,
+      state => state.loadingStatus === loadingStatus,
+      `LoadingStatus was never ${loadingStatus}`
+    );
+  }
+
+  setup(() => {
+    changeModel = new ChangeModel(
+      testResolver(navigationToken),
+      testResolver(changeViewModelToken),
+      getAppContext().restApiService,
+      testResolver(userModelToken)
+    );
+    knownChange = {
+      ...createChange(),
+      revisions: {
+        sha1: {
+          ...createRevision(1),
+          description: 'patch 1',
+          _number: 1 as PatchSetNumber,
+        },
+        sha2: {
+          ...createRevision(2),
+          description: 'patch 2',
+          _number: 2 as PatchSetNumber,
+        },
+      },
+      status: ChangeStatus.NEW,
+      current_revision: 'abc' as CommitId,
+      messages: [],
+    };
+  });
+
+  teardown(() => {
+    testCompleted.next();
+    changeModel.finalize();
+  });
+
+  test('load a change', async () => {
+    const promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState;
+
+    state = await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
+    assert.equal(stub.callCount, 0);
+    assert.isUndefined(state?.change);
+
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    state = await waitForLoadingStatus(LoadingStatus.LOADING);
+    assert.equal(stub.callCount, 1);
+    assert.isUndefined(state?.change);
+
+    promise.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 1);
+    assert.equal(state?.change, knownChange);
+  });
+
+  test('reload a change', async () => {
+    // setting up a loaded change
+    const promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState;
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    promise.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+
+    // Reloading same change
+    document.dispatchEvent(new CustomEvent('reload'));
+    state = await waitForLoadingStatus(LoadingStatus.RELOADING);
+    assert.equal(stub.callCount, 2);
+    assert.equal(state?.change, knownChange);
+
+    promise.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 2);
+    assert.equal(state?.change, knownChange);
+  });
+
+  test('navigating to another change', async () => {
+    // setting up a loaded change
+    let promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState;
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    promise.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+
+    // Navigating to other change
+
+    const otherChange: ParsedChangeInfo = {
+      ...knownChange,
+      _number: 123 as NumericChangeId,
+    };
+    promise = mockPromise<ParsedChangeInfo | undefined>();
+    testResolver(changeViewModelToken).setState({
+      ...createChangeViewState(),
+      changeNum: otherChange._number,
+    });
+    state = await waitForLoadingStatus(LoadingStatus.LOADING);
+    assert.equal(stub.callCount, 2);
+    assert.isUndefined(state?.change);
+
+    promise.resolve(otherChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 2);
+    assert.equal(state?.change, otherChange);
+  });
+
+  test('navigating to dashboard', async () => {
+    // setting up a loaded change
+    let promise = mockPromise<ParsedChangeInfo | undefined>();
+    const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
+    let state: ChangeState;
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    promise.resolve(knownChange);
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+
+    // Navigating to dashboard
+
+    promise = mockPromise<ParsedChangeInfo | undefined>();
+    promise.resolve(undefined);
+    testResolver(changeViewModelToken).setState(undefined);
+    state = await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
+    assert.equal(stub.callCount, 2);
+    assert.isUndefined(state?.change);
+
+    // Navigating back from dashboard to change page
+
+    promise = mockPromise<ParsedChangeInfo | undefined>();
+    promise.resolve(knownChange);
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    state = await waitForLoadingStatus(LoadingStatus.LOADED);
+    assert.equal(stub.callCount, 3);
+    assert.equal(state?.change, knownChange);
+  });
+
+  test('changeModel.fetchChangeUpdates on latest', async () => {
+    stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeModel.fetchChangeUpdates not on latest', async () => {
+    const actualChange = {
+      ...knownChange,
+      revisions: {
+        ...knownChange.revisions,
+        sha3: {
+          ...createRevision(3),
+          description: 'patch 3',
+          _number: 3 as PatchSetNumber,
+        },
+      },
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isFalse(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeModel.fetchChangeUpdates new status', async () => {
+    const actualChange = {
+      ...knownChange,
+      status: ChangeStatus.MERGED,
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.equal(result.newStatus, ChangeStatus.MERGED);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeModel.fetchChangeUpdates new messages', async () => {
+    const actualChange = {
+      ...knownChange,
+      messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeModel.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.deepEqual(result.newMessages, {
+      ...createChangeMessageInfo(),
+      message: 'blah blah',
+    });
+  });
+
+  // At some point we had forgotten the `select()` wrapper for this selector.
+  // And the missing `replay` led to a bug that was hard to find. That is why
+  // we are testing this explicitly here.
+  test('basePatchNum$ selector', async () => {
+    // Let's first wait for the selector to emit. Then we can test the replay
+    // below.
+    await waitUntilObserved(changeModel.basePatchNum$, x => x === PARENT);
+
+    const spy = sinon.spy();
+    changeModel.basePatchNum$.subscribe(spy);
+
+    // test replay
+    assert.equal(spy.callCount, 1);
+    assert.equal(spy.lastCall.firstArg, PARENT);
+
+    // test update
+    testResolver(changeViewModelToken).updateState({
+      basePatchNum: 1 as PatchSetNumber,
+    });
+    assert.equal(spy.callCount, 2);
+    assert.equal(spy.lastCall.firstArg, 1 as PatchSetNumber);
+
+    // test distinctUntilChanged
+    changeModel.updateStateChange(createParsedChange());
+    assert.equal(spy.callCount, 2);
+  });
+});
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
new file mode 100644
index 0000000..0683af0
--- /dev/null
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -0,0 +1,206 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  BasePatchSetNum,
+  FileInfo,
+  FileNameToFileInfoMap,
+  PARENT,
+  PatchRange,
+  PatchSetNumber,
+  RevisionPatchSetNum,
+} from '../../types/common';
+import {combineLatest, of, from} from 'rxjs';
+import {switchMap, map} from 'rxjs/operators';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {select} from '../../utils/observable-util';
+import {FileInfoStatus, SpecialFilePath} from '../../constants/constants';
+import {specialFilePathCompare} from '../../utils/path-list-util';
+import {Model} from '../model';
+import {define} from '../dependency';
+import {ChangeModel} from './change-model';
+import {CommentsModel} from '../comments/comments-model';
+
+export type FileNameToNormalizedFileInfoMap = {
+  [name: string]: NormalizedFileInfo;
+};
+export interface NormalizedFileInfo extends FileInfo {
+  __path: string;
+  // Compared to `FileInfo` these four props are required here.
+  lines_inserted: number;
+  lines_deleted: number;
+  size_delta: number; // in bytes
+  size: number; // in bytes
+}
+
+export function normalize(file: FileInfo, path: string): NormalizedFileInfo {
+  return {
+    __path: path,
+    // These 4 props are required in NormalizedFileInfo, but optional in
+    // FileInfo. So let's set a default value, if not already set.
+    lines_inserted: 0,
+    lines_deleted: 0,
+    size_delta: 0,
+    size: 0,
+    ...file,
+  };
+}
+
+function mapToList(map?: FileNameToFileInfoMap): NormalizedFileInfo[] {
+  const list: NormalizedFileInfo[] = [];
+  for (const [key, value] of Object.entries(map ?? {})) {
+    list.push(normalize(value, key));
+  }
+  list.sort((f1, f2) => specialFilePathCompare(f1.__path, f2.__path));
+  return list;
+}
+
+export function addUnmodified(
+  files: NormalizedFileInfo[],
+  commentedPaths: string[]
+) {
+  const combined = [...files];
+  for (const commentedPath of commentedPaths) {
+    if (commentedPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) continue;
+    if (files.some(f => f.__path === commentedPath)) continue;
+    if (
+      files.some(
+        f => f.status === FileInfoStatus.RENAMED && f.old_path === commentedPath
+      )
+    ) {
+      continue;
+    }
+    combined.push(
+      normalize({status: FileInfoStatus.UNMODIFIED}, commentedPath)
+    );
+  }
+  combined.sort((f1, f2) => specialFilePathCompare(f1.__path, f2.__path));
+  return combined;
+}
+
+export interface FilesState {
+  // TODO: Move reviewed files from change model into here. Change model is
+  // already large and complex, so the files model is a better fit.
+
+  /**
+   * Basic file and diff information of all files for the currently chosen
+   * patch range.
+   */
+  files: NormalizedFileInfo[];
+
+  /**
+   * Basic file and diff information of all files for the left chosen patchset
+   * compared against its base (aka parent).
+   *
+   * Empty if the left chosen patchset is PARENT.
+   */
+  filesLeftBase: NormalizedFileInfo[];
+
+  /**
+   * Basic file and diff information of all files for the right chosen patchset
+   * compared against its base (aka parent).
+   *
+   * Empty if the left chosen patchset is PARENT.
+   */
+  filesRightBase: NormalizedFileInfo[];
+}
+
+const initialState: FilesState = {
+  files: [],
+  filesLeftBase: [],
+  filesRightBase: [],
+};
+
+export const filesModelToken = define<FilesModel>('files-model');
+
+export class FilesModel extends Model<FilesState> {
+  public readonly files$ = select(this.state$, state => state.files);
+
+  /**
+   * `files$` only includes the files that were modified. Here we also include
+   * all unmodified files that have comments with
+   * `status: FileInfoStatus.UNMODIFIED`.
+   */
+  public readonly filesIncludingUnmodified$ = select(
+    combineLatest([this.files$, this.commentsModel.commentedPaths$]),
+    ([files, commentedPaths]) => addUnmodified(files, commentedPaths)
+  );
+
+  public readonly filesLeftBase$ = select(
+    this.state$,
+    state => state.filesLeftBase
+  );
+
+  public readonly filesRightBase$ = select(
+    this.state$,
+    state => state.filesRightBase
+  );
+
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly commentsModel: CommentsModel,
+    readonly restApiService: RestApiService
+  ) {
+    super(initialState);
+    this.subscriptions = [
+      this.subscribeToFiles(
+        (psLeft, psRight) => {
+          return {basePatchNum: psLeft, patchNum: psRight};
+        },
+        files => {
+          return {files: [...files]};
+        }
+      ),
+      this.subscribeToFiles(
+        (psLeft, _) => {
+          if (psLeft === PARENT || psLeft <= 0) return undefined;
+          return {basePatchNum: PARENT, patchNum: psLeft as PatchSetNumber};
+        },
+        files => {
+          return {filesLeftBase: [...files]};
+        }
+      ),
+      this.subscribeToFiles(
+        (psLeft, psRight) => {
+          if (psLeft === PARENT || psLeft <= 0) return undefined;
+          return {basePatchNum: PARENT, patchNum: psRight as PatchSetNumber};
+        },
+        files => {
+          return {filesRightBase: [...files]};
+        }
+      ),
+    ];
+  }
+
+  private subscribeToFiles(
+    rangeChooser: (
+      basePatchNum: BasePatchSetNum,
+      patchNum: RevisionPatchSetNum
+    ) => PatchRange | undefined,
+    filesToState: (files: NormalizedFileInfo[]) => Partial<FilesState>
+  ) {
+    return combineLatest([
+      this.changeModel.reload$,
+      this.changeModel.changeNum$,
+      this.changeModel.basePatchNum$,
+      this.changeModel.patchNum$,
+    ])
+      .pipe(
+        switchMap(([_, changeNum, basePatchNum, patchNum]) => {
+          if (!changeNum || !patchNum) return of({});
+          const range = rangeChooser(basePatchNum, patchNum);
+          if (!range) return of({});
+          return from(
+            this.restApiService.getChangeOrEditFiles(changeNum, range)
+          );
+        }),
+        map(mapToList),
+        map(filesToState)
+      )
+      .subscribe(state => {
+        this.updateState(state);
+      });
+  }
+}
diff --git a/polygerrit-ui/app/models/checks/checks-fakes.ts b/polygerrit-ui/app/models/checks/checks-fakes.ts
new file mode 100644
index 0000000..d50ddba
--- /dev/null
+++ b/polygerrit-ui/app/models/checks/checks-fakes.ts
@@ -0,0 +1,596 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  Action,
+  Category,
+  Link,
+  LinkIcon,
+  RunStatus,
+  TagColor,
+} from '../../api/checks';
+import {CheckRun, ChecksModel, ChecksPatchset} from './checks-model';
+
+// TODO(brohlfs): Eventually these fakes should be removed. But they have proven
+// to be super convenient for testing, debugging and demoing, so I would like to
+// keep them around for a few quarters. Maybe remove by EOY 2022?
+
+export const fakeRun0: CheckRun = {
+  pluginName: 'f0',
+  internalRunId: 'f0',
+  patchset: 1,
+  checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder 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.',
+      message: 'Btw, 1 is also not equal to 3. Did you know?',
+      actions: [
+        {
+          name: 'Ignore',
+          tooltip: 'Ignore this result',
+          primary: true,
+          callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
+        },
+        {
+          name: 'Flag',
+          tooltip: 'Flag this result as totally absolutely really not useful',
+          primary: true,
+          disabled: true,
+          callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
+        },
+        {
+          name: 'Upload',
+          tooltip: 'Upload the result to the super cloud.',
+          primary: false,
+          callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
+        },
+      ],
+      tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
+      links: [
+        {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+        {
+          primary: true,
+          url: 'https://google.com',
+          icon: LinkIcon.DOWNLOAD_MOBILE,
+        },
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
+      ],
+    },
+  ],
+  status: RunStatus.COMPLETED,
+};
+
+export const fakeRun1: CheckRun = {
+  pluginName: 'f1',
+  internalRunId: 'f1',
+  checkName: 'FAKE Super Check',
+  startedTimestamp: new Date(new Date().getTime() - 5 * 60 * 1000),
+  finishedTimestamp: new Date(new Date().getTime() + 5 * 60 * 1000),
+  patchset: 1,
+  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.
+                So please keep reading.`,
+      tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
+      codePointers: [
+        {
+          path: '/COMMIT_MSG',
+          range: {
+            start_line: 7,
+            start_character: 5,
+            end_line: 9,
+            end_character: 20,
+          },
+        },
+      ],
+      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},
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'look at this',
+          icon: LinkIcon.IMAGE,
+        },
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'not at this',
+          icon: LinkIcon.IMAGE,
+        },
+      ],
+    },
+    {
+      internalResultId: 'f1r1',
+      category: Category.INFO,
+      summary: 'Suspicious Author',
+      message: 'Do you personally know this person?',
+      codePointers: [
+        {
+          path: '/COMMIT_MSG',
+          range: {
+            start_line: 2,
+            start_character: 0,
+            end_line: 2,
+            end_character: 0,
+          },
+        },
+      ],
+      links: [],
+    },
+    {
+      internalResultId: 'f1r2',
+      category: Category.ERROR,
+      summary: 'Suspicious Date',
+      message: 'That was a holiday, you know.',
+      codePointers: [
+        {
+          path: '/COMMIT_MSG',
+          range: {
+            start_line: 3,
+            start_character: 0,
+            end_line: 3,
+            end_character: 0,
+          },
+        },
+      ],
+      fixes: [
+        {
+          description: 'This is the way to do it.',
+          replacements: [
+            {
+              path: 'BUILD',
+              range: {
+                start_line: 1,
+                start_character: 0,
+                end_line: 1,
+                end_character: 0,
+              },
+              replacement: '# This is now fixed.\n',
+            },
+          ],
+        },
+      ],
+      links: [],
+    },
+  ],
+  status: RunStatus.RUNNING,
+};
+
+export const fakeRun2: CheckRun = {
+  pluginName: 'f2',
+  internalRunId: 'f2',
+  patchset: 1,
+  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: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+    },
+    {
+      name: 'Monetize',
+      primary: true,
+      disabled: true,
+      callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
+    },
+    {
+      name: 'Delete',
+      primary: true,
+      callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
+    },
+  ],
+  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 = {
+  pluginName: 'f3',
+  internalRunId: 'f3',
+  checkName: 'FAKE Critical Observations',
+  status: RunStatus.RUNNABLE,
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+};
+
+export const fakeRun4_1: CheckRun = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  status: RunStatus.RUNNABLE,
+  attempt: 1,
+  isSingleAttempt: false,
+  isLatestAttempt: false,
+  attemptDetails: [],
+};
+
+export const fakeRun4_2: CheckRun = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  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 = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  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 = {
+  pluginName: 'f4',
+  internalRunId: 'f4',
+  patchset: 1,
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  checkDescription: 'Shows you the possible eliminations.',
+  checkLink: 'https://www.google.com',
+  status: RunStatus.COMPLETED,
+  statusDescription: 'Everything was eliminated already.',
+  statusLink: 'https://www.google.com',
+  attempt: 40,
+  scheduledTimestamp: new Date('2021-04-02T03:14:15'),
+  startedTimestamp: new Date('2021-04-02T04:24:25'),
+  finishedTimestamp: new Date('2021-04-02T04:25:44'),
+  isSingleAttempt: false,
+  isLatestAttempt: true,
+  attemptDetails: [],
+  results: [
+    {
+      internalResultId: 'f44r0',
+      category: Category.INFO,
+      summary: 'Dont be afraid. All TODOs will be eliminated.',
+      fixes: [
+        {
+          description: 'This is the way to do it.',
+          replacements: [
+            {
+              path: 'BUILD',
+              range: {
+                start_line: 1,
+                start_character: 0,
+                end_line: 1,
+                end_character: 0,
+              },
+              replacement: '# This is now fixed.\n',
+            },
+          ],
+        },
+      ],
+      actions: [
+        {
+          name: 'Re-Run',
+          tooltip: 'More powerful run than before with a long tooltip, really.',
+          primary: true,
+          callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+        },
+      ],
+    },
+  ],
+  actions: [
+    {
+      name: 'Re-Run',
+      tooltip: 'small',
+      primary: true,
+      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+    },
+  ],
+};
+
+export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
+  const runs: CheckRun[] = [];
+  for (let i = from; i < to; i++) {
+    runs.push(fakeRun4CreateAttempt(i));
+  }
+  return runs;
+}
+
+export function fakeRun4CreateAttempt(attempt: number): CheckRun {
+  return {
+    pluginName: 'f4',
+    internalRunId: 'f4',
+    checkName: 'FAKE Elimination Long Long Long Long Long',
+    status: RunStatus.COMPLETED,
+    attempt,
+    isSingleAttempt: false,
+    isLatestAttempt: false,
+    attemptDetails: [],
+    results:
+      attempt % 2 === 0
+        ? [
+            {
+              internalResultId: 'f43r0',
+              category: Category.ERROR,
+              summary:
+                'Without eliminating all the TODOs your change will break!',
+            },
+          ]
+        : [],
+  };
+}
+
+export const fakeRun4Att = [
+  fakeRun4_1,
+  fakeRun4_2,
+  fakeRun4_3,
+  ...fakeRun4CreateAttempts(5, 40),
+  fakeRun4_4,
+];
+
+export const fakeActions: Action[] = [
+  {
+    name: 'Fake Action 1',
+    primary: true,
+    disabled: true,
+    tooltip: 'Tooltip for Fake Action 1',
+    callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
+  },
+  {
+    name: 'Fake Action 2',
+    primary: false,
+    disabled: true,
+    tooltip: 'Tooltip for Fake Action 2',
+    callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
+  },
+  {
+    name: 'Fake Action 3',
+    summary: true,
+    primary: false,
+    tooltip: 'Tooltip for Fake Action 3',
+    callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
+  },
+];
+
+export const fakeLinks: Link[] = [
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Bug Report 1',
+    icon: LinkIcon.REPORT_BUG,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Bug Report 2',
+    icon: LinkIcon.REPORT_BUG,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Link 1',
+    icon: LinkIcon.EXTERNAL,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: false,
+    tooltip: 'Fake Link 2',
+    icon: LinkIcon.EXTERNAL,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Code Link',
+    icon: LinkIcon.CODE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Image Link',
+    icon: LinkIcon.IMAGE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Help Link',
+    icon: LinkIcon.HELP_PAGE,
+  },
+];
+
+export const fakeRun5: CheckRun = {
+  pluginName: 'f5',
+  internalRunId: 'f5',
+  checkName: 'FAKE Of Tomorrow',
+  status: RunStatus.SCHEDULED,
+  isSingleAttempt: true,
+  isLatestAttempt: true,
+  attemptDetails: [],
+};
+
+export function clearAllFakeRuns(model: ChecksModel) {
+  model.updateStateSetProvider('f0', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f1', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f2', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f3', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f4', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f5', ChecksPatchset.LATEST);
+  model.updateStateSetResults(
+    'f0',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f1',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f2',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f3',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f4',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f5',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+}
+
+export function setAllFakeRuns(model: ChecksModel) {
+  model.updateStateSetProvider('f0', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f1', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f2', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f3', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f4', ChecksPatchset.LATEST);
+  model.updateStateSetProvider('f5', ChecksPatchset.LATEST);
+  model.updateStateSetResults(
+    'f0',
+    [fakeRun0],
+    fakeActions,
+    fakeLinks,
+    'ETA: 1 min',
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f1',
+    [fakeRun1],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f2',
+    [fakeRun2],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f3',
+    [fakeRun3],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f4',
+    fakeRun4Att,
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f5',
+    [fakeRun5],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+}
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
new file mode 100644
index 0000000..4715abf
--- /dev/null
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -0,0 +1,870 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  AttemptChoice,
+  AttemptDetail,
+  createAttemptMap,
+  LATEST_ATTEMPT,
+  sortAttemptDetails,
+} from './checks-util';
+import {assertIsDefined} from '../../utils/common-util';
+import {select} from '../../utils/observable-util';
+import {
+  BehaviorSubject,
+  combineLatest,
+  from,
+  Observable,
+  of,
+  Subject,
+  timer,
+} from 'rxjs';
+import {
+  catchError,
+  filter,
+  switchMap,
+  take,
+  takeUntil,
+  takeWhile,
+  timeout,
+  throttleTime,
+  withLatestFrom,
+} from 'rxjs/operators';
+import {
+  Action,
+  CheckResult as CheckResultApi,
+  CheckRun as CheckRunApi,
+  Link,
+  ChangeData,
+  ChecksApiConfig,
+  ChecksProvider,
+  FetchResponse,
+  ResponseCode,
+  Category,
+  RunStatus,
+} from '../../api/checks';
+import {ChangeModel} from '../change/change-model';
+import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
+import {getCurrentRevision} from '../../utils/change-util';
+import {getShaByPatchNum} from '../../utils/patch-set-util';
+import {ReportingService} from '../../services/gr-reporting/gr-reporting';
+import {Execution, Interaction, Timing} from '../../constants/reporting';
+import {fireAlert, fireEvent} from '../../utils/event-util';
+import {Model} from '../model';
+import {define} from '../dependency';
+import {
+  ChecksPlugin,
+  ChecksUpdate,
+  PluginsModel,
+} from '../plugins/plugins-model';
+import {ChangeViewModel} from '../views/change';
+
+/**
+ * The checks model maintains the state of checks for two patchsets: the latest
+ * and (if different) also for the one selected in the checks tab. So we need
+ * the distinction in a lot of places for checks about whether the code affects
+ * the checks data of the LATEST or the SELECTED patchset.
+ */
+export enum ChecksPatchset {
+  LATEST = 'LATEST',
+  SELECTED = 'SELECTED',
+}
+
+export interface CheckResult extends CheckResultApi {
+  /**
+   * 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 {
+  /**
+   * For convenience we attach the name of the plugin to each run.
+   */
+  pluginName: string;
+  /**
+   * Internally we want to uniquely identify a result with an id, for example
+   * when efficiently re-rendering lists of results in the UI.
+   */
+  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;
+
+export const checksModelToken = define<ChecksModel>('checks-model');
+
+export interface ChecksProviderState {
+  pluginName: string;
+  loading: boolean;
+  /**
+   * Allows to distinguish whether loading:true is the *first* time of loading
+   * something for this provider. Or just a subsequent background update.
+   * Note that this is initially true even before loading is being set to true,
+   * so you may want to check loading && firstTimeLoad.
+   */
+  firstTimeLoad: boolean;
+  /** Presence of errorMessage implicitly means that the provider is in ERROR state. */
+  errorMessage?: string;
+  /** Presence of loginCallback implicitly means that the provider is in NOT_LOGGED_IN state. */
+  loginCallback?: () => void;
+  summaryMessage?: string;
+  runs: CheckRun[];
+  actions: Action[];
+  links: Link[];
+}
+
+interface ChecksState {
+  /** Checks data for the latest patchset. */
+  pluginStateLatest: {
+    [name: string]: ChecksProviderState;
+  };
+  /**
+   * Checks data for the selected patchset. Note that `checksSelected$` below
+   * falls back to the data for the latest patchset, if no patchset is selected.
+   */
+  pluginStateSelected: {
+    [name: string]: ChecksProviderState;
+  };
+}
+
+/**
+ * Android's Checks Plugin has a 15s timeout internally. So we are using
+ * something slightly larger, so that we get a proper error from the plugin,
+ * if they run into timeout issues.
+ */
+const FETCH_RESULT_TIMEOUT_MS = 16000;
+
+/**
+ * Can be used in `reduce()` to collect all results from all runs from all
+ * providers into one array.
+ */
+function collectRunResults(
+  allResults: RunResult[],
+  providerState: ChecksProviderState
+) {
+  return [
+    ...allResults,
+    ...providerState.runs.reduce((results: RunResult[], run: CheckRun) => {
+      const runResults: RunResult[] =
+        run.results?.map(r => {
+          return {...run, ...r};
+        }) ?? [];
+      return results.concat(runResults ?? []);
+    }, []),
+  ];
+}
+
+export interface ErrorMessages {
+  /* Maps plugin name to error message. */
+  [name: string]: string;
+}
+
+export class ChecksModel extends Model<ChecksState> {
+  private readonly providers: {[name: string]: ChecksProvider} = {};
+
+  private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
+
+  private checkToPluginMap = new Map<string, string>();
+
+  // visible for testing
+  changeNum?: NumericChangeId;
+
+  // visible for testing
+  latestPatchNum?: PatchSetNumber;
+
+  private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
+
+  private readonly reloadListener: () => void;
+
+  private readonly visibilityChangeListener: () => void;
+
+  public checksSelectedPatchsetNumber$ = select(
+    this.changeViewModel.checksPatchset$,
+    ps => ps
+  );
+
+  public checksSelectedAttemptNumber$ = select(
+    this.changeViewModel.attempt$,
+    attempt => attempt ?? LATEST_ATTEMPT
+  );
+
+  public runFilterRegexp$ = select(
+    this.changeViewModel.filter$,
+    filter => filter ?? ''
+  );
+
+  public checksLatest$ = select(this.state$, state => state.pluginStateLatest);
+
+  public checksSelected$ = select(
+    combineLatest([this.state$, this.changeViewModel.checksPatchset$]),
+    ([state, ps]) => {
+      const checksPs = ps ? ChecksPatchset.SELECTED : ChecksPatchset.LATEST;
+      return this.getPluginState(state, checksPs);
+    }
+  );
+
+  public aPluginHasRegistered$ = select(
+    this.checksLatest$,
+    state => Object.keys(state).length > 0
+  );
+
+  private firstLoadCompleted$ = select(this.checksLatest$, state => {
+    const providers = Object.values(state);
+    if (providers.length === 0) return false;
+    if (providers.some(p => p.loading || p.firstTimeLoad)) return false;
+    return true;
+  });
+
+  public someProvidersAreLoadingFirstTime$ = select(this.checksLatest$, state =>
+    Object.values(state).some(
+      provider => provider.loading && provider.firstTimeLoad
+    )
+  );
+
+  public someProvidersAreLoadingLatest$ = select(this.checksLatest$, state =>
+    Object.values(state).some(providerState => providerState.loading)
+  );
+
+  public someProvidersAreLoadingSelected$ = select(
+    this.checksSelected$,
+    state => Object.values(state).some(providerState => providerState.loading)
+  );
+
+  public errorMessageLatest$ = select(
+    this.checksLatest$,
+
+    state =>
+      Object.values(state).find(
+        providerState => providerState.errorMessage !== undefined
+      )?.errorMessage
+  );
+
+  public errorMessagesLatest$ = select(this.checksLatest$, state => {
+    const errorMessages: ErrorMessages = {};
+    for (const providerState of Object.values(state)) {
+      if (providerState.errorMessage === undefined) continue;
+      errorMessages[providerState.pluginName] = providerState.errorMessage;
+    }
+    return errorMessages;
+  });
+
+  public loginCallbackLatest$ = select(
+    this.checksLatest$,
+    state =>
+      Object.values(state).find(
+        providerState => providerState.loginCallback !== undefined
+      )?.loginCallback
+  );
+
+  public topLevelActionsLatest$ = select(this.checksLatest$, state =>
+    Object.values(state).reduce(
+      (allActions: Action[], providerState: ChecksProviderState) => [
+        ...allActions,
+        ...providerState.actions,
+      ],
+      []
+    )
+  );
+
+  public topLevelMessagesLatest$ = select(this.checksLatest$, state => {
+    const messages = Object.values(state).map(
+      providerState => providerState.summaryMessage
+    );
+    return messages.filter(m => !!m) as string[];
+  });
+
+  public topLevelActionsSelected$ = select(this.checksSelected$, state =>
+    Object.values(state).reduce(
+      (allActions: Action[], providerState: ChecksProviderState) => [
+        ...allActions,
+        ...providerState.actions,
+      ],
+      []
+    )
+  );
+
+  public topLevelLinksSelected$ = select(this.checksSelected$, state =>
+    Object.values(state).reduce(
+      (allLinks: Link[], providerState: ChecksProviderState) => [
+        ...allLinks,
+        ...providerState.links,
+      ],
+      []
+    )
+  );
+
+  public allRunsLatestPatchset$ = select(this.checksLatest$, state =>
+    Object.values(state).reduce(
+      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
+        ...allRuns,
+        ...providerState.runs,
+      ],
+      []
+    )
+  );
+
+  public allRunsSelectedPatchset$ = select(this.checksSelected$, state =>
+    Object.values(state).reduce(
+      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
+        ...allRuns,
+        ...providerState.runs,
+      ],
+      []
+    )
+  );
+
+  public allRunsLatestPatchsetLatestAttempt$ = select(
+    this.allRunsLatestPatchset$,
+    runs => runs.filter(run => run.isLatestAttempt)
+  );
+
+  public checkToPluginMap$ = select(this.checksLatest$, 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;
+  });
+
+  public allResultsSelected$ = select(this.checksSelected$, state =>
+    Object.values(state)
+      .reduce(collectRunResults, [])
+      .filter(r => r !== undefined)
+  );
+
+  public allResultsLatest$ = select(this.checksLatest$, state =>
+    Object.values(state)
+      .reduce(collectRunResults, [])
+      .filter(r => r !== undefined)
+  );
+
+  public allResults$ = select(
+    combineLatest([
+      this.checksSelectedPatchsetNumber$,
+      this.allResultsSelected$,
+      this.allResultsLatest$,
+    ]),
+    ([selectedPs, selected, latest]) =>
+      selectedPs ? [...selected, ...latest] : latest
+  );
+
+  constructor(
+    private readonly changeViewModel: ChangeViewModel,
+    private readonly changeModel: ChangeModel,
+    private readonly reporting: ReportingService,
+    private readonly pluginsModel: PluginsModel
+  ) {
+    super({
+      pluginStateLatest: {},
+      pluginStateSelected: {},
+    });
+    this.reporting.time(Timing.CHECKS_LOAD);
+    this.subscriptions = [
+      this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)),
+      this.changeModel.latestPatchNum$.subscribe(
+        x => (this.latestPatchNum = x)
+      ),
+      this.pluginsModel.checksPlugins$.subscribe(plugins => {
+        for (const plugin of plugins) {
+          this.register(plugin);
+        }
+      }),
+      this.pluginsModel.checksAnnounce$.subscribe(a =>
+        this.reload(a.pluginName)
+      ),
+      this.pluginsModel.checksUpdate$.subscribe(u => this.updateResult(u)),
+      this.checkToPluginMap$.subscribe(map => {
+        this.checkToPluginMap = map;
+      }),
+      this.firstLoadCompleted$
+        .pipe(
+          filter(completed => !!completed),
+          take(1),
+          withLatestFrom(this.checksLatest$)
+        )
+        .subscribe(([_, state]) => this.reportStats(state)),
+    ];
+    this.visibilityChangeListener = () => {
+      this.documentVisibilityChange$.next(undefined);
+    };
+    document.addEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+    this.reloadListener = () => this.reloadAll();
+    document.addEventListener('reload', this.reloadListener);
+  }
+
+  private reportStats(state: {[name: string]: ChecksProviderState}) {
+    const stats = {
+      providerCount: 0,
+      providerErrorCount: 0,
+      providerLoginCount: 0,
+      providerActionCount: 0,
+      providerLinkCount: 0,
+      errorCount: 0,
+      warningCount: 0,
+      infoCount: 0,
+      successCount: 0,
+      runnableCount: 0,
+      scheduledCount: 0,
+      runningCount: 0,
+      completedCount: 0,
+    };
+    const providers = Object.values(state);
+    for (const provider of providers) {
+      stats.providerCount++;
+      if (provider.errorMessage) stats.providerErrorCount++;
+      if (provider.loginCallback) stats.providerLoginCount++;
+      if (provider.actions?.length) stats.providerActionCount++;
+      if (provider.links?.length) stats.providerLinkCount++;
+      for (const run of provider.runs) {
+        if (run.status === RunStatus.RUNNABLE) stats.runnableCount++;
+        if (run.status === RunStatus.SCHEDULED) stats.scheduledCount++;
+        if (run.status === RunStatus.RUNNING) stats.runningCount++;
+        if (run.status === RunStatus.COMPLETED) stats.completedCount++;
+        for (const result of run.results ?? []) {
+          if (result.category === Category.ERROR) stats.errorCount++;
+          if (result.category === Category.WARNING) stats.warningCount++;
+          if (result.category === Category.INFO) stats.infoCount++;
+          if (result.category === Category.SUCCESS) stats.successCount++;
+        }
+      }
+    }
+    this.reporting.timeEnd(Timing.CHECKS_LOAD, stats);
+    this.reporting.reportInteraction(Interaction.CHECKS_STATS, stats);
+  }
+
+  override finalize() {
+    document.removeEventListener('reload', this.reloadListener);
+    document.removeEventListener(
+      'visibilitychange',
+      this.visibilityChangeListener
+    );
+    super.finalize();
+  }
+
+  // Must only be used by the checks service or whatever is in control of this
+  // model.
+  updateStateSetProvider(pluginName: string, patchset: ChecksPatchset) {
+    const nextState = {...this.getState()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      pluginName,
+      loading: false,
+      firstTimeLoad: true,
+      runs: [],
+      actions: [],
+      links: [],
+    };
+    this.setState(nextState);
+  }
+
+  getPluginState(
+    state: ChecksState,
+    patchset: ChecksPatchset = ChecksPatchset.LATEST
+  ) {
+    if (patchset === ChecksPatchset.LATEST) {
+      state.pluginStateLatest = {...state.pluginStateLatest};
+      return state.pluginStateLatest;
+    } else {
+      state.pluginStateSelected = {...state.pluginStateSelected};
+      return state.pluginStateSelected;
+    }
+  }
+
+  updateStateSetLoading(pluginName: string, patchset: ChecksPatchset) {
+    const nextState = {...this.getState()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      loading: true,
+    };
+    this.setState(nextState);
+  }
+
+  updateStateSetError(
+    pluginName: string,
+    errorMessage: string,
+    patchset: ChecksPatchset
+  ) {
+    const nextState = {...this.getState()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      loading: false,
+      firstTimeLoad: false,
+      errorMessage,
+      loginCallback: undefined,
+      runs: [],
+      actions: [],
+    };
+    this.setState(nextState);
+  }
+
+  updateStateSetNotLoggedIn(
+    pluginName: string,
+    loginCallback: () => void,
+    patchset: ChecksPatchset
+  ) {
+    const nextState = {...this.getState()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      loading: false,
+      firstTimeLoad: false,
+      errorMessage: undefined,
+      loginCallback,
+      runs: [],
+      actions: [],
+    };
+    this.setState(nextState);
+  }
+
+  updateStateSetResults(
+    pluginName: string,
+    runs: CheckRunApi[],
+    actions: Action[] = [],
+    links: Link[] = [],
+    summaryMessage: string | undefined,
+    patchset: ChecksPatchset
+  ) {
+    // Protect against plugins not respecting required fields.
+    runs = runs.filter(run => !!run.checkName && !!run.status);
+    const attemptMap = createAttemptMap(runs);
+    for (const attemptInfo of attemptMap.values()) {
+      attemptInfo.attempts.sort(sortAttemptDetails);
+    }
+    const nextState = {...this.getState()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    const oldState = pluginState[pluginName];
+    pluginState[pluginName] = {
+      ...oldState,
+      loading: false,
+      firstTimeLoad: oldState.loading ? false : oldState.firstTimeLoad,
+      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,
+          attempt: run.attempt ?? 0,
+          pluginName,
+          internalRunId: runId,
+          isLatestAttempt: attemptInfo.latestAttempt === (run.attempt ?? 0),
+          isSingleAttempt: attemptInfo.isSingleAttempt,
+          attemptDetails: attemptInfo.attempts,
+          results: (run.results ?? []).map((result, i) => {
+            return {
+              ...result,
+              internalResultId: `${runId}-${i}`,
+            };
+          }),
+        };
+      }),
+      actions: [...actions],
+      links: [...links],
+      summaryMessage,
+    };
+    this.setState(nextState);
+  }
+
+  updateStateUpdateResult(
+    pluginName: string,
+    updatedRun: CheckRunApi,
+    updatedResult: CheckResultApi,
+    patchset: ChecksPatchset
+  ) {
+    const nextState = {...this.getState()};
+    const pluginState = this.getPluginState(nextState, patchset);
+    let runUpdated = false;
+    const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
+      if (run.change !== updatedRun.change) return run;
+      if (run.patchset !== updatedRun.patchset) return run;
+      if (run.attempt !== updatedRun.attempt) return run;
+      if (run.checkName !== updatedRun.checkName) return run;
+      let resultUpdated = false;
+      const results: CheckResult[] = (run.results ?? []).map(result => {
+        if (
+          result.externalId &&
+          result.externalId === updatedResult.externalId
+        ) {
+          runUpdated = true;
+          resultUpdated = true;
+          return {
+            ...updatedResult,
+            internalResultId: result.internalResultId,
+          };
+        }
+        return result;
+      });
+      return resultUpdated ? {...run, results} : run;
+    });
+    if (!runUpdated) return;
+    pluginState[pluginName] = {
+      ...pluginState[pluginName],
+      runs,
+    };
+    this.setState(nextState);
+  }
+
+  updateStateSetPatchset(num?: PatchSetNumber) {
+    const newPatchset = num === this.latestPatchNum ? undefined : num;
+    const oldPatchset = this.changeViewModel.getState()?.checksPatchset;
+    // For `checksPatchset` itself we could just let updateState() do the
+    // standard old===new comparison. But we have to make sure here that
+    // the attempt reset only actually happens when a new patchset is chosen.
+    if (newPatchset === oldPatchset) return;
+    this.changeViewModel.updateState({
+      checksPatchset: newPatchset,
+      attempt: LATEST_ATTEMPT,
+    });
+  }
+
+  updateStateSetAttempt(attemptNumberSelected: AttemptChoice) {
+    this.changeViewModel.updateState({attempt: attemptNumberSelected});
+  }
+
+  updateStateSetRunFilter(runFilterRegexp: string) {
+    this.changeViewModel.updateState({filter: runFilterRegexp});
+  }
+
+  reload(pluginName: string) {
+    this.reloadSubjects[pluginName].next();
+  }
+
+  reloadAll() {
+    for (const key of Object.keys(this.providers)) {
+      this.reload(key);
+    }
+  }
+
+  reloadForCheck(checkName?: string) {
+    if (!checkName) return;
+    const plugin = this.checkToPluginMap.get(checkName);
+    if (plugin) this.reload(plugin);
+  }
+
+  updateResult(update: ChecksUpdate) {
+    const {pluginName, run, result} = update;
+    this.updateStateUpdateResult(
+      pluginName,
+      run,
+      result,
+      ChecksPatchset.LATEST
+    );
+    this.updateStateUpdateResult(
+      pluginName,
+      run,
+      result,
+      ChecksPatchset.SELECTED
+    );
+  }
+
+  triggerAction(action: Action, run: CheckRun | undefined, context: string) {
+    if (!action?.callback) return;
+    if (!this.changeNum) return;
+    const patchSet = run?.patchset ?? this.latestPatchNum;
+    if (!patchSet) return;
+    this.reporting.reportInteraction(Interaction.CHECKS_ACTION_TRIGGERED, {
+      checkName: run?.checkName,
+      actionName: action.name,
+      context,
+    });
+    const promise = action.callback(
+      this.changeNum,
+      patchSet,
+      run?.attempt,
+      run?.externalId,
+      run?.checkName,
+      action.name
+    );
+    // If plugins return undefined or not a promise, then show no toast.
+    if (!promise?.then) return;
+
+    fireAlert(document, `Triggering action '${action.name}' ...`);
+    from(promise)
+      // If the action takes longer than 5 seconds, then most likely the
+      // user is either not interested or the result not relevant anymore.
+      .pipe(takeUntil(timer(5000)))
+      .subscribe(result => {
+        if (result.errorMessage || result.message) {
+          fireAlert(document, `${result.message ?? result.errorMessage}`);
+        } else {
+          fireEvent(document, 'hide-alert');
+        }
+        if (result.shouldReload) {
+          this.reloadForCheck(run?.checkName);
+        }
+      });
+  }
+
+  register(checksPlugin: ChecksPlugin) {
+    const {pluginName, provider, config} = checksPlugin;
+    if (this.providers[pluginName]) {
+      console.warn(
+        `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
+      );
+      return;
+    }
+    this.providers[pluginName] = provider;
+    this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
+    this.updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
+    this.updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
+  }
+
+  initFetchingOfData(
+    pluginName: string,
+    config: ChecksApiConfig,
+    patchset: ChecksPatchset
+  ) {
+    const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
+    // Various events should trigger fetching checks from the provider:
+    // 1. Change number and patchset number changes.
+    // 2. Specific reload requests.
+    // 3. Regular polling starting with an initial fetch right now.
+    // 4. A hidden Gerrit tab becoming visible.
+    this.subscriptions.push(
+      combineLatest([
+        this.changeModel.change$,
+        patchset === ChecksPatchset.LATEST
+          ? this.changeModel.latestPatchNum$
+          : this.checksSelectedPatchsetNumber$,
+        this.reloadSubjects[pluginName],
+        pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
+        this.documentVisibilityChange$,
+      ])
+        .pipe(
+          takeWhile(_ => !!this.providers[pluginName]),
+          filter(_ => document.visibilityState !== 'hidden'),
+          throttleTime(500, undefined, {leading: true, trailing: true}),
+          switchMap(([change, patchNum]): Observable<FetchResponse> => {
+            if (!change || !patchNum) return of(this.empty());
+            if (typeof patchNum !== 'number') return of(this.empty());
+            assertIsDefined(change.revisions, 'change.revisions');
+            const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
+            // Sometimes patchNum is updated earlier than change, so change
+            // revisions don't have patchNum yet
+            if (!patchsetSha) return of(this.empty());
+            const data: ChangeData = {
+              changeNumber: change?._number,
+              patchsetNumber: patchNum,
+              patchsetSha,
+              repo: change.project,
+              commitMessage: getCurrentRevision(change)?.commit?.message,
+              changeInfo: change as ChangeInfo,
+            };
+            return this.fetchResults(pluginName, data, patchset);
+          }),
+          catchError(e => {
+            // This should not happen and is really severe, because it means that
+            // the Observable has terminated and we won't recover from that. No
+            // further attempts to fetch results for this plugin will be made.
+            this.reporting.error(`checks-model crash for ${pluginName}`, e);
+            return of(this.createErrorResponse(pluginName, e));
+          })
+        )
+        .subscribe(response => {
+          switch (response.responseCode) {
+            case ResponseCode.ERROR: {
+              const message = response.errorMessage ?? '-';
+              this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
+                plugin: pluginName,
+                message,
+              });
+              this.updateStateSetError(pluginName, message, patchset);
+              break;
+            }
+            case ResponseCode.NOT_LOGGED_IN: {
+              assertIsDefined(response.loginCallback, 'loginCallback');
+              this.reporting.reportExecution(
+                Execution.CHECKS_API_NOT_LOGGED_IN,
+                {
+                  plugin: pluginName,
+                }
+              );
+              this.updateStateSetNotLoggedIn(
+                pluginName,
+                response.loginCallback,
+                patchset
+              );
+              break;
+            }
+            case ResponseCode.OK: {
+              this.updateStateSetResults(
+                pluginName,
+                response.runs ?? [],
+                response.actions ?? [],
+                response.links ?? [],
+                response.summaryMessage,
+                patchset
+              );
+              break;
+            }
+          }
+        })
+    );
+  }
+
+  private empty(): FetchResponse {
+    return {
+      responseCode: ResponseCode.OK,
+      runs: [],
+    };
+  }
+
+  private createErrorResponse(pluginName: string, error: Error): FetchResponse {
+    return {
+      responseCode: ResponseCode.ERROR,
+      errorMessage:
+        `Error message from plugin '${pluginName}':` +
+        ` ${JSON.stringify(error)}`,
+    };
+  }
+
+  private fetchResults(
+    pluginName: string,
+    data: ChangeData,
+    patchset: ChecksPatchset
+  ): Observable<FetchResponse> {
+    this.updateStateSetLoading(pluginName, patchset);
+    const timer = this.reporting.getTimer('ChecksPluginFetch');
+    const fetchPromise = this.providers[pluginName]
+      .fetch(data)
+      .then(response => {
+        timer.end({pluginName});
+        return response;
+      });
+
+    return from(fetchPromise)
+      .pipe(timeout(FETCH_RESULT_TIMEOUT_MS))
+      .pipe(catchError(e => of(this.createErrorResponse(pluginName, e))));
+  }
+}
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
new file mode 100644
index 0000000..da4e3f1
--- /dev/null
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -0,0 +1,337 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import './checks-model';
+import {ChecksModel, ChecksPatchset, ChecksProviderState} from './checks-model';
+import {
+  Action,
+  Category,
+  CheckRun,
+  ChecksApiConfig,
+  ChecksProvider,
+  ResponseCode,
+  RunStatus,
+} from '../../api/checks';
+import {getAppContext} from '../../services/app-context';
+import {createParsedChange} from '../../test/test-data-generators';
+import {waitUntil, waitUntilCalled} from '../../test/test-utils';
+import {ParsedChangeInfo} from '../../types/types';
+import {changeModelToken} from '../change/change-model';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {changeViewModelToken} from '../views/change';
+import {NumericChangeId, PatchSetNumber} from '../../api/rest-api';
+import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
+
+const PLUGIN_NAME = 'test-plugin';
+
+const RUNS: CheckRun[] = [
+  {
+    checkName: 'MacCheck',
+    change: 123,
+    patchset: 1,
+    attempt: 1,
+    status: RunStatus.COMPLETED,
+    results: [
+      {
+        externalId: 'id-314',
+        category: Category.WARNING,
+        summary: 'Meddle cheddle check and you are weg.',
+      },
+    ],
+  },
+];
+
+const CONFIG_POLLING_5S: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 5,
+};
+
+const CONFIG_POLLING_NONE: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 0,
+};
+
+function createProvider(): ChecksProvider {
+  return {
+    fetch: () =>
+      Promise.resolve({
+        responseCode: ResponseCode.OK,
+        runs: [],
+      }),
+  };
+}
+
+suite('checks-model tests', () => {
+  let model: ChecksModel;
+
+  let current: ChecksProviderState;
+
+  setup(() => {
+    model = new ChecksModel(
+      testResolver(changeViewModelToken),
+      testResolver(changeModelToken),
+      getAppContext().reportingService,
+      testResolver(pluginLoaderToken).pluginsModel
+    );
+    model.checksLatest$.subscribe(c => (current = c[PLUGIN_NAME]));
+  });
+
+  teardown(() => {
+    model.finalize();
+  });
+
+  test('register and fetch', async () => {
+    let change: ParsedChangeInfo | undefined = undefined;
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
+    const provider = createProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({
+      pluginName: 'test-plugin',
+      provider,
+      config: CONFIG_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+
+    const testChange = createParsedChange();
+    testResolver(changeModelToken).updateStateChange(testChange);
+    await waitUntil(() => change === testChange);
+    await waitUntilCalled(fetchSpy, 'fetch');
+
+    assert.equal(
+      model.latestPatchNum,
+      testChange.revisions[testChange.current_revision]
+        ._number as PatchSetNumber
+    );
+    assert.equal(model.changeNum, testChange._number);
+  });
+
+  test('fetch throttle', async () => {
+    const clock = sinon.useFakeTimers();
+    let change: ParsedChangeInfo | undefined = undefined;
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
+    const provider = createProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({
+      pluginName: 'test-plugin',
+      provider,
+      config: CONFIG_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+
+    const testChange = createParsedChange();
+    testResolver(changeModelToken).updateStateChange(testChange);
+    await waitUntil(() => change === testChange);
+
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+
+    // Does not emit at 'leading' of throttle interval,
+    // because fetch() is not called when change is undefined.
+    assert.equal(fetchSpy.callCount, 0);
+
+    // 600 ms is greater than the 500 ms throttle time.
+    clock.tick(600);
+    // emits at 'trailing' of throttle interval
+    assert.equal(fetchSpy.callCount, 1);
+
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    model.reload('test-plugin');
+    // emits at 'leading' of throttle interval
+    assert.equal(fetchSpy.callCount, 2);
+
+    // 600 ms is greater than the 500 ms throttle time.
+    clock.tick(600);
+    // emits at 'trailing' of throttle interval
+    assert.equal(fetchSpy.callCount, 3);
+  });
+
+  test('triggerAction', async () => {
+    model.changeNum = 314 as NumericChangeId;
+    model.latestPatchNum = 13 as PatchSetNumber;
+    const action: Action = {
+      name: 'test action',
+      callback: () => undefined,
+    };
+    const spy = sinon.spy(action, 'callback');
+    model.triggerAction(action, undefined, 'none');
+    assert.isTrue(spy.calledOnce);
+    assert.equal(spy.lastCall.args[0], 314);
+    assert.equal(spy.lastCall.args[1], 13);
+  });
+
+  test('model.updateStateSetProvider', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.deepEqual(current, {
+      pluginName: PLUGIN_NAME,
+      loading: false,
+      firstTimeLoad: true,
+      runs: [],
+      actions: [],
+      links: [],
+    });
+  });
+
+  test('loading and first time load', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isFalse(current.loading);
+    assert.isTrue(current.firstTimeLoad);
+    model.updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isTrue(current.loading);
+    assert.isTrue(current.firstTimeLoad);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    assert.isFalse(current.loading);
+    assert.isFalse(current.firstTimeLoad);
+    model.updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isTrue(current.loading);
+    assert.isFalse(current.firstTimeLoad);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    assert.isFalse(current.loading);
+    assert.isFalse(current.firstTimeLoad);
+  });
+
+  test('model.updateStateSetResults', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    assert.lengthOf(current.runs, 1);
+    assert.lengthOf(current.runs[0].results!, 1);
+  });
+
+  test('model.updateStateSetResults ignore empty name or status', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      [
+        {
+          checkName: 'test-check-name',
+          status: RunStatus.COMPLETED,
+        },
+        // Will be ignored, because the checkName is empty.
+        {
+          checkName: undefined as unknown as string,
+          status: RunStatus.COMPLETED,
+        },
+        // Will be ignored, because the status is empty.
+        {
+          checkName: 'test-check-name',
+          status: undefined as unknown as RunStatus,
+        },
+      ],
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    // 2 out of 3 runs are ignored.
+    assert.lengthOf(current.runs, 1);
+  });
+
+  test('model.updateStateUpdateResult', () => {
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    assert.equal(
+      current.runs[0].results![0].summary,
+      RUNS[0]!.results![0].summary
+    );
+    const result = RUNS[0].results![0];
+    const updatedResult = {...result, summary: 'new'};
+    model.updateStateUpdateResult(
+      PLUGIN_NAME,
+      RUNS[0],
+      updatedResult,
+      ChecksPatchset.LATEST
+    );
+    assert.lengthOf(current.runs, 1);
+    assert.lengthOf(current.runs[0].results!, 1);
+    assert.equal(current.runs[0].results![0].summary, 'new');
+  });
+
+  test('polls for changes', async () => {
+    const clock = sinon.useFakeTimers();
+    let change: ParsedChangeInfo | undefined = undefined;
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
+    const provider = createProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({
+      pluginName: 'test-plugin',
+      provider,
+      config: CONFIG_POLLING_5S,
+    });
+    await waitUntil(() => change === undefined);
+    clock.tick(1);
+    const testChange = createParsedChange();
+    testResolver(changeModelToken).updateStateChange(testChange);
+    await waitUntil(() => change === testChange);
+    clock.tick(600); // need to wait for 500ms throttle
+    await waitUntilCalled(fetchSpy, 'fetch');
+    const pollCount = fetchSpy.callCount;
+
+    // polling should continue while we wait
+    clock.tick(CONFIG_POLLING_5S.fetchPollingIntervalSeconds * 1000 * 2);
+
+    assert.isTrue(fetchSpy.callCount > pollCount);
+  });
+
+  test('does not poll when config specifies 0 seconds', async () => {
+    const clock = sinon.useFakeTimers();
+    let change: ParsedChangeInfo | undefined = undefined;
+    testResolver(changeModelToken).change$.subscribe(c => (change = c));
+    const provider = createProvider();
+    const fetchSpy = sinon.spy(provider, 'fetch');
+
+    model.register({
+      pluginName: 'test-plugin',
+      provider,
+      config: CONFIG_POLLING_NONE,
+    });
+    await waitUntil(() => change === undefined);
+    clock.tick(1);
+    const testChange = createParsedChange();
+    testResolver(changeModelToken).updateStateChange(testChange);
+    await waitUntil(() => change === testChange);
+    clock.tick(600); // need to wait for 500ms throttle
+    await waitUntilCalled(fetchSpy, 'fetch');
+    clock.tick(1);
+    const pollCount = fetchSpy.callCount;
+
+    // polling should not happen
+    clock.tick(60 * 1000);
+
+    assert.equal(fetchSpy.callCount, pollCount);
+  });
+});
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
new file mode 100644
index 0000000..ba43eb4
--- /dev/null
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -0,0 +1,512 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  Action,
+  Category,
+  CheckResult as CheckResultApi,
+  CheckRun as CheckRunApi,
+  Fix,
+  Link,
+  LinkIcon,
+  Replacement,
+  RunStatus,
+} from '../../api/checks';
+import {PatchSetNumber, RevisionPatchSetNum} from '../../api/rest-api';
+import {CommentSide} from '../../constants/constants';
+import {FixSuggestionInfo, FixReplacementInfo} from '../../types/common';
+import {OpenFixPreviewEventDetail} from '../../types/events';
+import {isDefined} from '../../types/types';
+import {PROVIDED_FIX_ID, UnsavedInfo} from '../../utils/comment-util';
+import {assert, assertIsDefined, assertNever} from '../../utils/common-util';
+import {fire} from '../../utils/event-util';
+import {CheckResult, CheckRun, RunResult} from './checks-model';
+
+export interface ChecksIcon {
+  name: string;
+  filled?: boolean;
+}
+
+export function iconForLink(linkIcon?: LinkIcon): ChecksIcon {
+  if (linkIcon === undefined) return {name: 'open_in_new'};
+  switch (linkIcon) {
+    case LinkIcon.EXTERNAL:
+      return {name: 'open_in_new'};
+    case LinkIcon.IMAGE:
+      return {name: 'image', filled: true};
+    case LinkIcon.HISTORY:
+      return {name: 'history'};
+    case LinkIcon.DOWNLOAD:
+      return {name: 'download'};
+    case LinkIcon.DOWNLOAD_MOBILE:
+      return {name: 'system_update'};
+    case LinkIcon.HELP_PAGE:
+      return {name: 'help'};
+    case LinkIcon.REPORT_BUG:
+      return {name: 'bug_report', filled: true};
+    case LinkIcon.CODE:
+      return {name: 'code'};
+    case LinkIcon.FILE_PRESENT:
+      return {name: 'file_present'};
+    default:
+      // We don't throw an assertion error here, because plugins don't have to
+      // be written in TypeScript, so we may encounter arbitrary strings for
+      // linkIcon.
+      return {name: 'open_in_new'};
+  }
+}
+
+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';
+    case LinkIcon.CODE:
+      return 'Link to code';
+    case LinkIcon.FILE_PRESENT:
+      return 'Link to file';
+    default:
+      // We don't throw an assertion error here, because plugins don't have to
+      // be written in TypeScript, so we may encounter arbitrary strings for
+      // linkIcon.
+      return 'Link to details';
+  }
+}
+
+function pleaseFixMessage(result: RunResult) {
+  return `Please fix this ${result.category} reported by ${result.checkName}: ${result.summary}
+
+${result.message}`;
+}
+
+export function createPleaseFixComment(result: RunResult): UnsavedInfo {
+  const pointer = result.codePointers?.[0];
+  assertIsDefined(pointer, 'codePointer');
+  return {
+    __unsaved: true,
+    path: pointer.path,
+    patch_set: result.patchset as RevisionPatchSetNum,
+    side: CommentSide.REVISION,
+    line: pointer.range.end_line ?? pointer.range.start_line,
+    range: pointer.range,
+    message: pleaseFixMessage(result),
+    unresolved: true,
+  };
+}
+
+export function createFixAction(
+  target: EventTarget,
+  result?: RunResult
+): Action | undefined {
+  if (!result?.patchset) return;
+  if (!result?.fixes) return;
+  const fixSuggestions = result.fixes
+    .map(f => rectifyFix(f, result?.checkName))
+    .filter(isDefined);
+  if (fixSuggestions.length === 0) return;
+  const eventDetail: OpenFixPreviewEventDetail = {
+    patchNum: result.patchset as PatchSetNumber,
+    fixSuggestions,
+  };
+  return {
+    name: 'Show Fix',
+    callback: () => {
+      fire(target, 'open-fix-preview', eventDetail);
+      return undefined;
+    },
+  };
+}
+
+export function rectifyFix(
+  fix: Fix | undefined,
+  checkName: string
+): FixSuggestionInfo | undefined {
+  if (!fix?.replacements) return undefined;
+  const replacements = fix.replacements
+    .map(rectifyReplacement)
+    .filter(isDefined);
+  if (replacements.length === 0) return undefined;
+
+  return {
+    description: fix.description ?? `Fix provided by ${checkName}`,
+    fix_id: PROVIDED_FIX_ID,
+    replacements,
+  };
+}
+
+export function rectifyReplacement(
+  r: Replacement | undefined
+): FixReplacementInfo | undefined {
+  if (!r?.path) return undefined;
+  if (!r?.range) return undefined;
+  if (r?.replacement === undefined) return undefined;
+  if (!Number.isInteger(r.range.start_line)) return undefined;
+  if (!Number.isInteger(r.range.end_line)) return undefined;
+  if (!Number.isInteger(r.range.start_character)) return undefined;
+  if (!Number.isInteger(r.range.end_character)) return undefined;
+  return r;
+}
+
+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;
+  if (hasResultsOf(run, Category.SUCCESS)) return Category.SUCCESS;
+  return undefined;
+}
+
+export function isCategory(
+  catStat?: Category | RunStatus
+): catStat is Category {
+  return (
+    catStat === Category.ERROR ||
+    catStat === Category.WARNING ||
+    catStat === Category.INFO ||
+    catStat === Category.SUCCESS
+  );
+}
+
+export function isStatus(catStat?: Category | RunStatus): catStat is RunStatus {
+  return (
+    catStat === RunStatus.COMPLETED ||
+    catStat === RunStatus.RUNNABLE ||
+    catStat === RunStatus.SCHEDULED ||
+    catStat === RunStatus.RUNNING
+  );
+}
+
+export function labelFor(catStat: Category | RunStatus) {
+  switch (catStat) {
+    case Category.ERROR:
+      return 'error';
+    case Category.INFO:
+      return 'info';
+    case Category.WARNING:
+      return 'warning';
+    case Category.SUCCESS:
+      return 'success';
+    case RunStatus.COMPLETED:
+      return 'completed';
+    case RunStatus.RUNNABLE:
+      return 'runnable';
+    case RunStatus.RUNNING:
+      return 'running';
+    case RunStatus.SCHEDULED:
+      return 'scheduled';
+    default:
+      assertNever(catStat, `Unsupported category/status: ${catStat}`);
+  }
+}
+
+export function iconFor(catStat: Category | RunStatus): ChecksIcon {
+  switch (catStat) {
+    case Category.ERROR:
+      return {name: 'error', filled: true};
+    case Category.INFO:
+      return {name: 'info'};
+    case Category.WARNING:
+      return {name: 'warning', filled: true};
+    case Category.SUCCESS:
+      return {name: 'check_circle'};
+    // Note that this is only for COMPLETED without results!
+    case RunStatus.COMPLETED:
+      return {name: 'check_circle'};
+    case RunStatus.RUNNABLE:
+      return {name: ''};
+    case RunStatus.RUNNING:
+      return {name: 'timelapse'};
+    case RunStatus.SCHEDULED:
+      return {name: 'pending_actions'};
+    default:
+      assertNever(catStat, `Unsupported category/status: ${catStat}`);
+  }
+}
+
+export enum PRIMARY_STATUS_ACTIONS {
+  RERUN = 'rerun',
+  RUN = 'run',
+}
+
+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;
+  }
+  return {...action, name};
+}
+
+export function headerForStatus(status: RunStatus) {
+  switch (status) {
+    case RunStatus.COMPLETED:
+      return 'Completed';
+    case RunStatus.RUNNABLE:
+      return 'Not run';
+    case RunStatus.RUNNING:
+      return 'Running';
+    case RunStatus.SCHEDULED:
+      return 'Scheduled';
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+function primaryActionName(status: RunStatus) {
+  switch (status) {
+    case RunStatus.COMPLETED:
+      return PRIMARY_STATUS_ACTIONS.RERUN;
+    case RunStatus.RUNNABLE:
+      return PRIMARY_STATUS_ACTIONS.RUN;
+    case RunStatus.RUNNING:
+    case RunStatus.SCHEDULED:
+      return undefined;
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+export function primaryRunAction(run?: CheckRun): Action | undefined {
+  if (!run) return undefined;
+  return runActions(run).filter(
+    action => !action.disabled && 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 iconFor(run.status);
+  } else {
+    const category = worstCategory(run);
+    return category ? iconFor(category) : iconFor(run.status);
+  }
+}
+
+export function hasCompleted(run: CheckRun) {
+  return run.status === RunStatus.COMPLETED;
+}
+
+export function isRunningOrScheduled(run: CheckRun) {
+  return run.status === RunStatus.RUNNING || run.status === RunStatus.SCHEDULED;
+}
+
+export function isRunningScheduledOrCompleted(run: CheckRun) {
+  return (
+    run.status === RunStatus.COMPLETED ||
+    run.status === RunStatus.RUNNING ||
+    run.status === RunStatus.SCHEDULED
+  );
+}
+
+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) {
+  const catComp = catLevel(worstCategory(b)) - catLevel(worstCategory(a));
+  if (catComp !== 0) return catComp;
+  const statusComp = runLevel(b.status) - runLevel(a.status);
+  return statusComp;
+}
+
+function catLevel(cat?: Category) {
+  if (!cat) return -1;
+  switch (cat) {
+    case Category.SUCCESS:
+      return 0;
+    case Category.INFO:
+      return 1;
+    case Category.WARNING:
+      return 2;
+    case Category.ERROR:
+      return 3;
+  }
+}
+
+function runLevel(status: RunStatus) {
+  switch (status) {
+    case RunStatus.COMPLETED:
+      return 0;
+    case RunStatus.RUNNABLE:
+      return 1;
+    case RunStatus.RUNNING:
+      return 2;
+    case RunStatus.SCHEDULED:
+      return 3;
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+export interface AttemptDetail {
+  attempt?: AttemptChoice;
+  icon?: ChecksIcon;
+}
+
+export interface AttemptInfo {
+  latestAttempt: AttemptChoice;
+  isSingleAttempt: boolean;
+  attempts: AttemptDetail[];
+}
+
+export type AttemptChoice = number | 'latest' | 'all';
+export const ALL_ATTEMPTS = 'all' as AttemptChoice;
+export const LATEST_ATTEMPT = 'latest' as AttemptChoice;
+
+export function isAttemptChoice(x: number | string): x is AttemptChoice {
+  if (typeof x === 'string') {
+    return x === ALL_ATTEMPTS || x === LATEST_ATTEMPT;
+  }
+  if (typeof x === 'number') {
+    return x >= 0;
+  }
+  return false;
+}
+
+export function stringToAttemptChoice(
+  s?: string | null
+): AttemptChoice | undefined {
+  if (s === undefined) return undefined;
+  if (s === null) return undefined;
+  if (s === '') return undefined;
+  if (isAttemptChoice(s)) return s;
+  const n = Number(s);
+  if (isAttemptChoice(n)) return n;
+  return undefined;
+}
+
+export function attemptChoiceLabel(attempt: AttemptChoice): string {
+  if (attempt === LATEST_ATTEMPT) return 'Latest Attempt';
+  if (attempt === ALL_ATTEMPTS) return 'All Attempts';
+  return `Attempt ${attempt}`;
+}
+
+export function sortAttemptDetails(a: AttemptDetail, b: AttemptDetail): number {
+  return sortAttemptChoices(a.attempt, b.attempt);
+}
+
+export function sortAttemptChoices(
+  a?: AttemptChoice,
+  b?: AttemptChoice
+): number {
+  if (a === b) return 0;
+  if (a === undefined) return -1;
+  if (b === undefined) return 1;
+  if (a === LATEST_ATTEMPT) return -1;
+  if (b === LATEST_ATTEMPT) return 1;
+  if (a === ALL_ATTEMPTS) return -1;
+  if (b === ALL_ATTEMPTS) return 1;
+  assert(typeof a === 'number', `unexpected attempt ${a}`);
+  assert(typeof b === 'number', `unexpected attempt ${b}`);
+  return a - b;
+}
+
+export function createAttemptMap(runs: CheckRunApi[]) {
+  const map = new Map<string, AttemptInfo>();
+  for (const run of runs) {
+    const value = map.get(run.checkName);
+    const detail: AttemptDetail = {
+      attempt: run.attempt ?? 0,
+      icon: iconForRun(fromApiToInternalRun(run)),
+    };
+    if (value === undefined) {
+      map.set(run.checkName, {
+        latestAttempt: run.attempt ?? 0,
+        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,
+    pluginName: 'fake',
+    internalRunId: 'fake',
+    isSingleAttempt: false,
+    isLatestAttempt: false,
+    attemptDetails: [],
+    results: (run.results ?? []).map(fromApiToInternalResult),
+  };
+}
+
+export function fromApiToInternalResult(result: CheckResultApi): CheckResult {
+  return {
+    ...result,
+    internalResultId: 'fake',
+  };
+}
+
+function allPrimaryLinks(result?: CheckResultApi): Link[] {
+  return (result?.links ?? []).filter(link => link.primary);
+}
+
+export function firstPrimaryLink(result?: CheckResultApi): Link | undefined {
+  return allPrimaryLinks(result).find(link => link.icon === LinkIcon.EXTERNAL);
+}
+
+export function otherPrimaryLinks(result?: CheckResultApi): Link[] {
+  const first = firstPrimaryLink(result);
+  return allPrimaryLinks(result).filter(link => link !== first);
+}
+
+export function secondaryLinks(result?: CheckResultApi): Link[] {
+  return (result?.links ?? []).filter(link => !link.primary);
+}
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
new file mode 100644
index 0000000..c237c59
--- /dev/null
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import './checks-model';
+import {assert} from '@open-wc/testing';
+import {
+  ALL_ATTEMPTS,
+  AttemptChoice,
+  LATEST_ATTEMPT,
+  rectifyFix,
+  sortAttemptChoices,
+  stringToAttemptChoice,
+} from './checks-util';
+import {Fix, Replacement} from '../../api/checks';
+import {CommentRange} from '../../api/core';
+import {PROVIDED_FIX_ID} from '../../utils/comment-util';
+
+suite('checks-util tests', () => {
+  setup(() => {});
+
+  teardown(() => {});
+
+  test('stringToAttemptChoice', () => {
+    assert.equal(stringToAttemptChoice('0'), 0);
+    assert.equal(stringToAttemptChoice('1'), 1);
+    assert.equal(stringToAttemptChoice('999'), 999);
+    assert.equal(stringToAttemptChoice('latest'), 'latest');
+    assert.equal(stringToAttemptChoice('all'), 'all');
+
+    assert.equal(stringToAttemptChoice(undefined), undefined);
+    assert.equal(stringToAttemptChoice(''), undefined);
+    assert.equal(stringToAttemptChoice('asdf'), undefined);
+    assert.equal(stringToAttemptChoice('-1'), undefined);
+    assert.equal(stringToAttemptChoice('1x'), undefined);
+  });
+
+  test('rectifyFix', () => {
+    assert.isUndefined(rectifyFix(undefined, 'name'));
+    assert.isUndefined(rectifyFix({} as Fix, 'name'));
+    assert.isUndefined(
+      rectifyFix({description: 'asdf', replacements: []}, 'name')
+    );
+    assert.isUndefined(
+      rectifyFix(
+        {description: 'asdf', replacements: [{} as Replacement]},
+        'test-check-name'
+      )
+    );
+    assert.isUndefined(
+      rectifyFix(
+        {
+          description: 'asdf',
+          replacements: [
+            {
+              path: 'test-path',
+              range: {} as CommentRange,
+              replacement: 'test-replacement-string',
+            },
+          ],
+        },
+        'test-check-name'
+      )
+    );
+    const rectified = rectifyFix(
+      {
+        replacements: [
+          {
+            path: 'test-path',
+            range: {
+              start_line: 1,
+              end_line: 1,
+              start_character: 0,
+              end_character: 1,
+            } as CommentRange,
+            replacement: 'test-replacement-string',
+          },
+        ],
+      },
+      'test-check-name'
+    );
+    assert.isDefined(rectified);
+    assert.equal(rectified?.description, 'Fix provided by test-check-name');
+    assert.equal(rectified?.fix_id, PROVIDED_FIX_ID);
+  });
+
+  test('sortAttemptChoices', () => {
+    const unsorted: (AttemptChoice | undefined)[] = [
+      3,
+      1,
+      LATEST_ATTEMPT,
+      ALL_ATTEMPTS,
+      undefined,
+      0,
+      999,
+    ];
+    const sortedExpected: (AttemptChoice | undefined)[] = [
+      LATEST_ATTEMPT,
+      ALL_ATTEMPTS,
+      0,
+      1,
+      3,
+      999,
+      undefined,
+    ];
+    assert.deepEqual(unsorted.sort(sortAttemptChoices), sortedExpected);
+  });
+});
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
new file mode 100644
index 0000000..1fdf342
--- /dev/null
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -0,0 +1,720 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
+import {
+  CommentBasics,
+  CommentInfo,
+  NumericChangeId,
+  PatchSetNum,
+  RevisionId,
+  UrlEncodedCommentId,
+  PathToCommentsInfoMap,
+  RobotCommentInfo,
+  PathToRobotCommentsInfoMap,
+  AccountInfo,
+} from '../../types/common';
+import {
+  addPath,
+  Comment,
+  DraftInfo,
+  isDraft,
+  isDraftOrUnsaved,
+  isDraftThread,
+  isUnsaved,
+  reportingDetails,
+  UnsavedInfo,
+} from '../../utils/comment-util';
+import {deepEqual} from '../../utils/deep-util';
+import {select} from '../../utils/observable-util';
+import {define} from '../dependency';
+import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
+import {fire, fireAlert, fireEvent} from '../../utils/event-util';
+import {CURRENT} from '../../utils/patch-set-util';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {ChangeModel} from '../change/change-model';
+import {Interaction, Timing} from '../../constants/reporting';
+import {assertIsDefined} from '../../utils/common-util';
+import {debounce, DelayedTask} from '../../utils/async-util';
+import {pluralize} from '../../utils/string-util';
+import {ReportingService} from '../../services/gr-reporting/gr-reporting';
+import {Model} from '../model';
+import {Deduping} from '../../api/reporting';
+import {extractMentionedUsers, getUserId} from '../../utils/account-util';
+import {EventType} from '../../types/events';
+import {SpecialFilePath} from '../../constants/constants';
+import {AccountsModel} from '../accounts-model/accounts-model';
+import {
+  distinctUntilChanged,
+  map,
+  shareReplay,
+  switchMap,
+} from 'rxjs/operators';
+import {isDefined} from '../../types/types';
+import {ChangeViewModel} from '../views/change';
+
+export interface CommentState {
+  /** undefined means 'still loading' */
+  comments?: PathToCommentsInfoMap;
+  /** undefined means 'still loading' */
+  robotComments?: {[path: string]: RobotCommentInfo[]};
+  // All drafts are DraftInfo objects and have __draft = true set.
+  // Drafts have an id and are known to the backend. Unsaved drafts
+  // (see UnsavedInfo) do NOT belong in the application model.
+  /** undefined means 'still loading' */
+  drafts?: {[path: string]: DraftInfo[]};
+  // Ported comments only affect `CommentThread` properties, not individual
+  // comments.
+  /** undefined means 'still loading' */
+  portedComments?: PathToCommentsInfoMap;
+  /** undefined means 'still loading' */
+  portedDrafts?: PathToCommentsInfoMap;
+  /**
+   * If a draft is discarded by the user, then we temporarily keep it in this
+   * array in case the user decides to Undo the discard operation and bring the
+   * draft back. Once restored, the draft is removed from this array.
+   */
+  discardedDrafts: DraftInfo[];
+}
+
+const initialState: CommentState = {
+  comments: undefined,
+  robotComments: undefined,
+  drafts: undefined,
+  portedComments: undefined,
+  portedDrafts: undefined,
+  discardedDrafts: [],
+};
+
+const TOAST_DEBOUNCE_INTERVAL = 200;
+
+function getSavingMessage(numPending: number, requestFailed?: boolean) {
+  if (requestFailed) {
+    return 'Unable to save draft';
+  }
+  if (numPending === 0) {
+    return 'All changes saved';
+  }
+  return `Saving ${pluralize(numPending, 'draft')}...`;
+}
+
+// Private but used in tests.
+export function setComments(
+  state: CommentState,
+  comments?: {
+    [path: string]: CommentInfo[];
+  }
+): CommentState {
+  const nextState = {...state};
+  if (deepEqual(comments, nextState.comments)) return state;
+  nextState.comments = addPath(comments) || {};
+  return nextState;
+}
+
+/** Updates a single comment in a state. */
+export function updateComment(
+  state: CommentState,
+  comment: CommentInfo
+): CommentState {
+  if (!comment.path || !state.comments) {
+    return state;
+  }
+  const newCommentsAtPath = [...state.comments[comment.path]];
+  for (let i = 0; i < newCommentsAtPath.length; ++i) {
+    if (newCommentsAtPath[i].id === comment.id) {
+      // TODO: In "delete comment" the returned comment is missing some of the
+      // fields (for example patch_set), which would throw errors when
+      // rendering. Remove merging with the old comment, once that is fixed in
+      // server code.
+      newCommentsAtPath[i] = {...newCommentsAtPath[i], ...comment};
+
+      return {
+        ...state,
+        comments: {
+          ...state.comments,
+          [comment.path]: newCommentsAtPath,
+        },
+      };
+    }
+  }
+  throw new Error('Comment to be updated does not exist');
+}
+
+// Private but used in tests.
+export function setRobotComments(
+  state: CommentState,
+  robotComments?: {
+    [path: string]: RobotCommentInfo[];
+  }
+): CommentState {
+  if (deepEqual(robotComments, state.robotComments)) return state;
+  const nextState = {...state};
+  nextState.robotComments = addPath(robotComments) || {};
+  return nextState;
+}
+
+// Private but used in tests.
+export function setDrafts(
+  state: CommentState,
+  drafts?: {[path: string]: DraftInfo[]}
+): CommentState {
+  if (deepEqual(drafts, state.drafts)) return state;
+  const nextState = {...state};
+  nextState.drafts = addPath(drafts);
+  return nextState;
+}
+
+// Private but used in tests.
+export function setPortedComments(
+  state: CommentState,
+  portedComments?: PathToCommentsInfoMap
+): CommentState {
+  if (deepEqual(portedComments, state.portedComments)) return state;
+  const nextState = {...state};
+  nextState.portedComments = portedComments || {};
+  return nextState;
+}
+
+// Private but used in tests.
+export function setPortedDrafts(
+  state: CommentState,
+  portedDrafts?: PathToCommentsInfoMap
+): CommentState {
+  if (deepEqual(portedDrafts, state.portedDrafts)) return state;
+  const nextState = {...state};
+  nextState.portedDrafts = portedDrafts || {};
+  return nextState;
+}
+
+// Private but used in tests.
+export function setDiscardedDraft(
+  state: CommentState,
+  draft: DraftInfo
+): CommentState {
+  const nextState = {...state};
+  nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
+  return nextState;
+}
+
+// Private but used in tests.
+export function deleteDiscardedDraft(
+  state: CommentState,
+  draftID?: string
+): CommentState {
+  const nextState = {...state};
+  const drafts = [...nextState.discardedDrafts];
+  const index = drafts.findIndex(d => d.id === draftID);
+  if (index === -1) {
+    throw new Error('discarded draft not found');
+  }
+  drafts.splice(index, 1);
+  nextState.discardedDrafts = drafts;
+  return nextState;
+}
+
+/** Adds or updates a draft. */
+export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
+  const nextState = {...state};
+  if (!draft.path) throw new Error('draft path undefined');
+  if (!isDraft(draft)) throw new Error('draft is not a draft');
+  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
+  else drafts[draft.path] = [...drafts[draft.path]];
+  const index = drafts[draft.path].findIndex(d => d.id && d.id === draft.id);
+  if (index !== -1) {
+    drafts[draft.path][index] = draft;
+  } else {
+    drafts[draft.path].push(draft);
+  }
+  return nextState;
+}
+
+export function deleteDraft(
+  state: CommentState,
+  draft: DraftInfo
+): CommentState {
+  const nextState = {...state};
+  if (!draft.path) throw new Error('draft path undefined');
+  if (!isDraft(draft)) throw new Error('draft is not a draft');
+  if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  const index = (drafts[draft.path] || []).findIndex(
+    d => d.id && d.id === draft.id
+  );
+  if (index === -1) return state;
+  const discardedDraft = drafts[draft.path][index];
+  drafts[draft.path] = [...drafts[draft.path]];
+  drafts[draft.path].splice(index, 1);
+  return setDiscardedDraft(nextState, discardedDraft);
+}
+
+export const commentsModelToken = define<CommentsModel>('comments-model');
+export class CommentsModel extends Model<CommentState> {
+  public readonly commentsLoading$ = select(
+    this.state$,
+    commentState =>
+      commentState.comments === undefined ||
+      commentState.robotComments === undefined ||
+      commentState.drafts === undefined
+  );
+
+  public readonly comments$ = select(
+    this.state$,
+    commentState => commentState.comments
+  );
+
+  public readonly robotComments$ = select(
+    this.state$,
+    commentState => commentState.robotComments
+  );
+
+  public readonly robotCommentCount$ = select(
+    this.robotComments$,
+    robotComments => Object.values(robotComments ?? {}).flat().length
+  );
+
+  public readonly drafts$ = select(
+    this.state$,
+    commentState => commentState.drafts
+  );
+
+  public readonly draftsCount$ = select(
+    this.drafts$,
+    drafts => Object.values(drafts ?? {}).flat().length
+  );
+
+  public readonly portedComments$ = select(
+    this.state$,
+    commentState => commentState.portedComments
+  );
+
+  public readonly discardedDrafts$ = select(
+    this.state$,
+    commentState => commentState.discardedDrafts
+  );
+
+  public readonly patchsetLevelDrafts$ = select(this.drafts$, drafts =>
+    Object.values(drafts ?? {})
+      .flat()
+      .filter(
+        draft =>
+          draft.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
+          !draft.in_reply_to
+      )
+  );
+
+  public readonly mentionedUsersInDrafts$: Observable<AccountInfo[]> =
+    this.drafts$.pipe(
+      switchMap(drafts => {
+        const users: AccountInfo[] = [];
+        const comments = Object.values(drafts ?? {}).flat();
+        for (const comment of comments) {
+          users.push(...extractMentionedUsers(comment.message));
+        }
+        const uniqueUsers = users.filter(
+          (user, index) =>
+            index === users.findIndex(u => getUserId(u) === getUserId(user))
+        );
+        // forkJoin only emits value when the array is non-empty
+        if (uniqueUsers.length === 0) {
+          return of(uniqueUsers);
+        }
+        const filledUsers$: Observable<AccountInfo | undefined>[] =
+          uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
+        return forkJoin(filledUsers$);
+      }),
+      map(users => users.filter(isDefined)),
+      distinctUntilChanged(deepEqual),
+      shareReplay(1)
+    );
+
+  public readonly mentionedUsersInUnresolvedDrafts$: Observable<AccountInfo[]> =
+    this.drafts$.pipe(
+      switchMap(drafts => {
+        const users: AccountInfo[] = [];
+        const comments = Object.values(drafts ?? {})
+          .flat()
+          .filter(c => c.unresolved);
+        for (const comment of comments) {
+          users.push(...extractMentionedUsers(comment.message));
+        }
+        const uniqueUsers = users.filter(
+          (user, index) =>
+            index === users.findIndex(u => getUserId(u) === getUserId(user))
+        );
+        // forkJoin only emits value when the array is non-empty
+        if (uniqueUsers.length === 0) {
+          return of(uniqueUsers);
+        }
+        const filledUsers$: Observable<AccountInfo | undefined>[] =
+          uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
+        return forkJoin(filledUsers$);
+      }),
+      map(users => users.filter(isDefined)),
+      distinctUntilChanged(deepEqual),
+      shareReplay(1)
+    );
+
+  // Emits a new value even if only a single draft is changed. Components should
+  // aim to subsribe to something more specific.
+  public readonly changeComments$ = select(
+    this.state$,
+    commentState =>
+      new ChangeComments(
+        commentState.comments,
+        commentState.robotComments,
+        commentState.drafts,
+        commentState.portedComments,
+        commentState.portedDrafts
+      )
+  );
+
+  public readonly threads$ = select(this.changeComments$, changeComments =>
+    changeComments.getAllThreadsForChange()
+  );
+
+  public readonly draftThreads$ = select(this.threads$, threads =>
+    threads.filter(isDraftThread)
+  );
+
+  public readonly commentedPaths$ = select(
+    combineLatest([
+      this.changeComments$,
+      this.changeModel.basePatchNum$,
+      this.changeModel.patchNum$,
+    ]),
+    ([changeComments, basePatchNum, patchNum]) => {
+      if (!patchNum) return [];
+      const pathsMap = changeComments.getPaths({basePatchNum, patchNum});
+      return Object.keys(pathsMap);
+    }
+  );
+
+  public thread$(id: UrlEncodedCommentId) {
+    return select(this.threads$, threads => threads.find(t => t.rootId === id));
+  }
+
+  private numPendingDraftRequests = 0;
+
+  private changeNum?: NumericChangeId;
+
+  private patchNum?: PatchSetNum;
+
+  private readonly reloadListener: () => void;
+
+  private drafts: {[path: string]: DraftInfo[]} = {};
+
+  private draftToastTask?: DelayedTask;
+
+  private discardedDrafts: DraftInfo[] = [];
+
+  constructor(
+    private readonly changeViewModel: ChangeViewModel,
+    private readonly changeModel: ChangeModel,
+    private readonly accountsModel: AccountsModel,
+    private readonly restApiService: RestApiService,
+    private readonly reporting: ReportingService
+  ) {
+    super(initialState);
+    this.subscriptions.push(
+      this.discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
+    );
+    this.subscriptions.push(
+      this.drafts$.subscribe(x => (this.drafts = x ?? {}))
+    );
+    this.subscriptions.push(
+      this.changeModel.patchNum$.subscribe(x => (this.patchNum = x))
+    );
+    this.subscriptions.push(
+      this.changeViewModel.changeNum$.subscribe(changeNum => {
+        this.changeNum = changeNum;
+        this.setState({...initialState});
+        this.reloadAllComments();
+      })
+    );
+    this.subscriptions.push(
+      combineLatest([
+        this.changeModel.changeNum$,
+        this.changeModel.patchNum$,
+      ]).subscribe(([changeNum, patchNum]) => {
+        this.changeNum = changeNum;
+        this.patchNum = patchNum;
+        this.reloadAllPortedComments();
+      })
+    );
+    this.reloadListener = () => {
+      this.reloadAllComments();
+      this.reloadAllPortedComments();
+    };
+    document.addEventListener('reload', this.reloadListener);
+  }
+
+  override finalize() {
+    document.removeEventListener('reload', this.reloadListener);
+    super.finalize();
+  }
+
+  // Note that this does *not* reload ported comments.
+  async reloadAllComments() {
+    if (!this.changeNum) return;
+    await Promise.all([
+      this.reloadComments(this.changeNum),
+      this.reloadRobotComments(this.changeNum),
+      this.reloadDrafts(this.changeNum),
+    ]);
+  }
+
+  async reloadAllPortedComments() {
+    if (!this.changeNum) return;
+    if (!this.patchNum) return;
+    await Promise.all([
+      this.reloadPortedComments(this.changeNum, this.patchNum),
+      this.reloadPortedDrafts(this.changeNum, this.patchNum),
+    ]);
+  }
+
+  // visible for testing
+  modifyState(reducer: (state: CommentState) => CommentState) {
+    this.setState(reducer({...this.getState()}));
+  }
+
+  async reloadComments(changeNum: NumericChangeId): Promise<void> {
+    const comments = await this.restApiService.getDiffComments(changeNum);
+    this.modifyState(s => setComments(s, comments));
+  }
+
+  async reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
+    const robotComments = await this.restApiService.getDiffRobotComments(
+      changeNum
+    );
+    this.reportRobotCommentStats(robotComments);
+    this.modifyState(s => setRobotComments(s, robotComments));
+  }
+
+  private reportRobotCommentStats(obj?: PathToRobotCommentsInfoMap) {
+    if (!obj) return;
+    const comments = Object.values(obj).flat();
+    if (comments.length === 0) return;
+    const ids = comments.map(c => c.robot_id);
+    const latestPatchset = comments.reduce(
+      (latestPs, comment) =>
+        Math.max(latestPs, (comment?.patch_set as number) ?? 0),
+      0
+    );
+    const commentsLatest = comments.filter(c => c.patch_set === latestPatchset);
+    const commentsFixes = comments
+      .map(c => c.fix_suggestions?.length ?? 0)
+      .filter(l => l > 0);
+    const details = {
+      firstId: ids[0],
+      ids: [...new Set(ids)],
+      count: comments.length,
+      countLatest: commentsLatest.length,
+      countFixes: commentsFixes.length,
+    };
+    this.reporting.reportInteraction(
+      Interaction.ROBOT_COMMENTS_STATS,
+      details,
+      {deduping: Deduping.EVENT_ONCE_PER_CHANGE}
+    );
+  }
+
+  async reloadDrafts(changeNum: NumericChangeId): Promise<void> {
+    const drafts = await this.restApiService.getDiffDrafts(changeNum);
+    this.modifyState(s => setDrafts(s, drafts));
+  }
+
+  async reloadPortedComments(
+    changeNum: NumericChangeId,
+    patchNum = CURRENT as RevisionId
+  ): Promise<void> {
+    const portedComments = await this.restApiService.getPortedComments(
+      changeNum,
+      patchNum
+    );
+    this.modifyState(s => setPortedComments(s, portedComments));
+  }
+
+  async reloadPortedDrafts(
+    changeNum: NumericChangeId,
+    patchNum = CURRENT as RevisionId
+  ): Promise<void> {
+    const portedDrafts = await this.restApiService.getPortedDrafts(
+      changeNum,
+      patchNum
+    );
+    this.modifyState(s => setPortedDrafts(s, portedDrafts));
+  }
+
+  async restoreDraft(id: UrlEncodedCommentId) {
+    const found = this.discardedDrafts?.find(d => d.id === id);
+    if (!found) throw new Error('discarded draft not found');
+    const newDraft = {
+      ...found,
+      id: undefined,
+      updated: undefined,
+      __draft: undefined,
+      __unsaved: true,
+    };
+    await this.saveDraft(newDraft);
+    this.modifyState(s => deleteDiscardedDraft(s, id));
+  }
+
+  /**
+   * Saves a new or updates an existing draft.
+   * The model will only be updated when a successful response comes back.
+   */
+  async saveDraft(
+    draft: DraftInfo | UnsavedInfo,
+    showToast = true
+  ): Promise<DraftInfo> {
+    assertIsDefined(this.changeNum, 'change number');
+    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+    if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
+
+    // Saving the change number as to make sure that the response is still
+    // relevant when it comes back. The user maybe have navigated away.
+    const changeNum = this.changeNum;
+    this.report(Interaction.SAVE_COMMENT, draft);
+    if (showToast) this.showStartRequest();
+    const timing = isUnsaved(draft) ? Timing.DRAFT_CREATE : Timing.DRAFT_UPDATE;
+    const timer = this.reporting.getTimer(timing);
+    const result = await this.restApiService.saveDiffDraft(
+      changeNum,
+      draft.patch_set,
+      draft
+    );
+    if (changeNum !== this.changeNum) throw new Error('change changed');
+    if (!result.ok) {
+      if (showToast) this.handleFailedDraftRequest();
+      throw new Error(
+        `Failed to save draft comment: ${JSON.stringify(result)}`
+      );
+    }
+    const obj = await this.restApiService.getResponseObject(result);
+    const savedComment = obj as unknown as CommentInfo;
+    const updatedDraft = {
+      ...draft,
+      id: savedComment.id,
+      updated: savedComment.updated,
+      __draft: true,
+      __unsaved: undefined,
+    };
+    timer.end({id: updatedDraft.id});
+    if (showToast) this.showEndRequest();
+    this.modifyState(s => setDraft(s, updatedDraft));
+    this.report(Interaction.COMMENT_SAVED, updatedDraft);
+    return updatedDraft;
+  }
+
+  async discardDraft(draftId: UrlEncodedCommentId) {
+    const draft = this.lookupDraft(draftId);
+    assertIsDefined(this.changeNum, 'change number');
+    assertIsDefined(draft, `draft not found by id ${draftId}`);
+    assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+
+    if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
+    // Saving the change number as to make sure that the response is still
+    // relevant when it comes back. The user maybe have navigated away.
+    const changeNum = this.changeNum;
+    this.report(Interaction.DISCARD_COMMENT, draft);
+    this.showStartRequest();
+    const timer = this.reporting.getTimer(Timing.DRAFT_DISCARD);
+    const result = await this.restApiService.deleteDiffDraft(
+      changeNum,
+      draft.patch_set,
+      {id: draft.id}
+    );
+    timer.end({id: draft.id});
+    if (changeNum !== this.changeNum) throw new Error('change changed');
+    if (!result.ok) {
+      this.handleFailedDraftRequest();
+      throw new Error(
+        `Failed to discard draft comment: ${JSON.stringify(result)}`
+      );
+    }
+    this.showEndRequest();
+    this.modifyState(s => deleteDraft(s, draft));
+    // We don't store empty discarded drafts and don't need an UNDO then.
+    if (draft.message?.trim()) {
+      fire(document, EventType.SHOW_ALERT, {
+        message: 'Draft Discarded',
+        action: 'Undo',
+        callback: () => this.restoreDraft(draft.id),
+      });
+    }
+    this.report(Interaction.COMMENT_DISCARDED, draft);
+  }
+
+  async deleteComment(
+    changeNum: NumericChangeId,
+    comment: Comment,
+    reason: string
+  ) {
+    assertIsDefined(comment.patch_set, 'comment.patch_set');
+    if (isDraftOrUnsaved(comment)) {
+      throw new Error('Admin deletion is only for published comments.');
+    }
+
+    const newComment = await this.restApiService.deleteComment(
+      changeNum,
+      comment.patch_set,
+      comment.id,
+      reason
+    );
+    this.modifyState(s => updateComment(s, newComment));
+  }
+
+  private report(interaction: Interaction, comment: CommentBasics) {
+    const details = reportingDetails(comment);
+    this.reporting.reportInteraction(interaction, details);
+  }
+
+  private showStartRequest() {
+    this.numPendingDraftRequests += 1;
+    this.updateRequestToast();
+  }
+
+  private showEndRequest() {
+    this.numPendingDraftRequests -= 1;
+    this.updateRequestToast();
+  }
+
+  private handleFailedDraftRequest() {
+    this.numPendingDraftRequests -= 1;
+    this.updateRequestToast(/* requestFailed=*/ true);
+  }
+
+  private updateRequestToast(requestFailed?: boolean) {
+    if (this.numPendingDraftRequests === 0 && !requestFailed) {
+      fireEvent(document, 'hide-alert');
+      return;
+    }
+    const message = getSavingMessage(
+      this.numPendingDraftRequests,
+      requestFailed
+    );
+    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.
+        fireAlert(document.body, message);
+      },
+      TOAST_DEBOUNCE_INTERVAL
+    );
+  }
+
+  private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
+    return Object.values(this.drafts)
+      .flat()
+      .find(d => d.id === id);
+  }
+}
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
new file mode 100644
index 0000000..a689e42
--- /dev/null
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -0,0 +1,221 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {
+  createAccountWithEmail,
+  createChangeViewState,
+  createDraft,
+} from '../../test/test-data-generators';
+import {
+  AccountInfo,
+  EmailAddress,
+  NumericChangeId,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {CommentsModel, deleteDraft} from './comments-model';
+import {Subscription} from 'rxjs';
+import {
+  createComment,
+  createParsedChange,
+} from '../../test/test-data-generators';
+import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
+import {getAppContext} from '../../services/app-context';
+import {PathToCommentsInfoMap} from '../../types/common';
+import {changeModelToken} from '../change/change-model';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {accountsModelToken} from '../accounts-model/accounts-model';
+import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
+import {changeViewModelToken} from '../views/change';
+
+suite('comments model tests', () => {
+  test('updateStateDeleteDraft', () => {
+    const draft = createDraft();
+    draft.id = '1' as UrlEncodedCommentId;
+    const state = {
+      comments: {},
+      robotComments: {},
+      drafts: {
+        [draft.path!]: [draft],
+      },
+      portedComments: {},
+      portedDrafts: {},
+      discardedDrafts: [],
+    };
+    const output = deleteDraft(state, draft);
+    assert.deepEqual(output, {
+      comments: {},
+      robotComments: {},
+      drafts: {
+        'abc.txt': [],
+      },
+      portedComments: {},
+      portedDrafts: {},
+      discardedDrafts: [{...draft}],
+    });
+  });
+});
+
+suite('change service tests', () => {
+  let subscriptions: Subscription[] = [];
+
+  teardown(() => {
+    for (const s of subscriptions) {
+      s.unsubscribe();
+    }
+    subscriptions = [];
+  });
+
+  test('loads comments', async () => {
+    const model = new CommentsModel(
+      testResolver(changeViewModelToken),
+      testResolver(changeModelToken),
+      testResolver(accountsModelToken),
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({'foo.c': [createComment()]})
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({})
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+    const portedCommentsSpy = stubRestApi('getPortedComments').returns(
+      Promise.resolve({'foo.c': [createComment()]})
+    );
+    const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
+      Promise.resolve({})
+    );
+    let comments: PathToCommentsInfoMap = {};
+    subscriptions.push(model.comments$.subscribe(c => (comments = c ?? {})));
+    let portedComments: PathToCommentsInfoMap = {};
+    subscriptions.push(
+      model.portedComments$.subscribe(c => (portedComments = c ?? {}))
+    );
+
+    testResolver(changeViewModelToken).setState(createChangeViewState());
+    testResolver(changeModelToken).updateStateChange(createParsedChange());
+
+    await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
+    await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
+    await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
+    await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
+    await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
+    await waitUntil(
+      () => Object.keys(comments).length > 0,
+      'comment in model not set'
+    );
+    await waitUntil(
+      () => Object.keys(portedComments).length > 0,
+      'ported comment in model not set'
+    );
+
+    assert.equal(comments['foo.c'].length, 1);
+    assert.equal(comments['foo.c'][0].id, '12345');
+    assert.equal(portedComments['foo.c'].length, 1);
+    assert.equal(portedComments['foo.c'][0].id, '12345');
+  });
+
+  test('duplicate mentions are filtered out', async () => {
+    const account = {
+      ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    };
+    stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+    const model = new CommentsModel(
+      testResolver(changeViewModelToken),
+      testResolver(changeModelToken),
+      testResolver(accountsModelToken),
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+    let mentionedUsers: AccountInfo[] = [];
+    const draft = {...createDraft(), message: 'hey @abc@def.com'};
+    model.mentionedUsersInDrafts$.subscribe(x => (mentionedUsers = x));
+    model.setState({
+      drafts: {
+        'abc.txt': [draft, draft],
+      },
+      discardedDrafts: [],
+    });
+
+    await waitUntil(() => mentionedUsers.length > 0);
+
+    assert.deepEqual(mentionedUsers, [account]);
+  });
+
+  test('empty mentions are emitted', async () => {
+    const account = {
+      ...createAccountWithEmail('abcd@def.com' as EmailAddress),
+      registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+    };
+    stubRestApi('getAccountDetails').returns(Promise.resolve(account));
+    const model = new CommentsModel(
+      testResolver(changeViewModelToken),
+      testResolver(changeModelToken),
+      testResolver(accountsModelToken),
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+    let mentionedUsers: AccountInfo[] = [];
+    const draft = {...createDraft(), message: 'hey @abc@def.com'};
+    model.mentionedUsersInDrafts$.subscribe(x => (mentionedUsers = x));
+    model.setState({
+      drafts: {
+        'abc.txt': [draft],
+      },
+      discardedDrafts: [],
+    });
+
+    await waitUntil(() => mentionedUsers.length > 0);
+
+    assert.deepEqual(mentionedUsers, [account]);
+
+    model.setState({
+      drafts: {
+        'abc.txt': [],
+      },
+      discardedDrafts: [],
+    });
+    await waitUntil(() => mentionedUsers.length === 0);
+  });
+
+  test('delete comment change is emitted', async () => {
+    const comment = createComment();
+    stubRestApi('deleteComment').returns(
+      Promise.resolve({
+        ...comment,
+        message: 'Comment is deleted',
+      })
+    );
+    const model = new CommentsModel(
+      testResolver(changeViewModelToken),
+      testResolver(changeModelToken),
+      testResolver(accountsModelToken),
+      getAppContext().restApiService,
+      getAppContext().reportingService
+    );
+
+    let changeComments: ChangeComments | undefined = undefined;
+    model.changeComments$.subscribe(x => (changeComments = x));
+    model.setState({
+      comments: {[comment.path!]: [comment]},
+      discardedDrafts: [],
+    });
+
+    model.deleteComment(123 as NumericChangeId, comment, 'Comment is deleted');
+
+    await waitUntil(
+      () =>
+        changeComments?.getAllCommentsForPath(comment.path!)[0].message ===
+        'Comment is deleted'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
new file mode 100644
index 0000000..4c9bb35c
--- /dev/null
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
+import {from, of} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {ChangeModel} from '../change/change-model';
+import {select} from '../../utils/observable-util';
+import {Model} from '../model';
+import {define} from '../dependency';
+import {getDocsBaseUrl} from '../../utils/url-util';
+
+export interface ConfigState {
+  repoConfig?: ConfigInfo;
+  serverConfig?: ServerInfo;
+}
+
+export const configModelToken = define<ConfigModel>('config-model');
+export class ConfigModel extends Model<ConfigState> {
+  public repoConfig$ = select(
+    this.state$,
+    configState => configState.repoConfig
+  );
+
+  public repoCommentLinks$ = select(
+    this.repoConfig$,
+    repoConfig => repoConfig?.commentlinks ?? {}
+  );
+
+  public serverConfig$ = select(
+    this.state$,
+    configState => configState.serverConfig
+  );
+
+  public mergeabilityComputationBehavior$ = select(
+    this.serverConfig$,
+    serverConfig => serverConfig?.change?.mergeability_computation_behavior
+  );
+
+  public docsBaseUrl$ = select(
+    this.serverConfig$.pipe(
+      switchMap(serverConfig =>
+        from(getDocsBaseUrl(serverConfig, this.restApiService))
+      )
+    ),
+    url => url
+  );
+
+  constructor(
+    readonly changeModel: ChangeModel,
+    readonly restApiService: RestApiService
+  ) {
+    super({});
+    this.subscriptions = [
+      from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
+        this.updateServerConfig(config);
+      }),
+      this.changeModel.repo$
+        .pipe(
+          switchMap((repo?: RepoName) => {
+            if (repo === undefined) return of(undefined);
+            return from(this.restApiService.getProjectConfig(repo));
+          })
+        )
+        .subscribe((repoConfig?: ConfigInfo) => {
+          this.updateRepoConfig(repoConfig);
+        }),
+    ];
+  }
+
+  // visible for testing
+  updateRepoConfig(repoConfig?: ConfigInfo) {
+    this.updateState({repoConfig});
+  }
+
+  // visible for testing
+  updateServerConfig(serverConfig?: ServerInfo) {
+    this.updateState({serverConfig});
+  }
+}
diff --git a/polygerrit-ui/app/models/dependency.ts b/polygerrit-ui/app/models/dependency.ts
new file mode 100644
index 0000000..3b5081a
--- /dev/null
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -0,0 +1,304 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ReactiveController, ReactiveControllerHost} from 'lit';
+
+/**
+ * This module provides the ability to do dependency injection in components.
+ * It provides 3 functions that are for the purpose of dependency injection.
+ *
+ * Definitions
+ * ---
+ * A component's "connected lifetime" consists of the span between
+ * `super.connectedCallback` and `super.disconnectedCallback`.
+ *
+ * Dependency Definition
+ * ---
+ *
+ * A token for a dependency of type FooService is defined as follows:
+ *
+ *   const fooToken = define<FooService>('some name');
+ *
+ * Dependency Resolution
+ * ---
+ *
+ * To get the value of a dependency, a component requests a resolved dependency
+ *
+ * ```
+ *   private readonly serviceRef = resolve(this, fooToken);
+ * ```
+ *
+ * A resolved dependency is a function that when called will return the actual
+ * value for that dependency.
+ *
+ * A resolved dependency is guaranteed to be resolved during a components
+ * connected lifetime. If no ancestor provided a value for the dependency, then
+ * the resolved dependency will throw an error if the value is accessed.
+ * Therefore, the following is safe-by-construction as long as it happens
+ * within a components connected lifetime:
+ *
+ * ```
+ *    serviceRef().fooServiceMethod()
+ * ```
+ *
+ * Dependency Injection
+ * ---
+ *
+ * Ancestor components will inject the dependencies that a child component
+ * requires by providing providers for those values.
+ *
+ *
+ * To provide a dependency, a component needs to specify the following prior
+ * to finishing its connectedCallback:
+ *
+ * ```
+ *   const fooImpl = new FooImpl();
+ *   provide(this, fooToken, () => fooImpl);
+ * ```
+ * Dependencies are injected as factories in case the construction of them
+ * depends on other dependencies further up the component chain.  For instance,
+ * if the construction of FooImpl needed a BarService, then it could look
+ * something like this:
+ *
+ * ```
+ *   const barRef = resolve(this, barToken);
+ *   const fooImpl = new FooImpl(barRef());
+ *   provide(this, fooToken, () => fooImpl);
+ * ```
+ *
+ * Lifetime guarantees
+ * ---
+ * A resolved dependency is valid for the duration of its component's connected
+ * lifetime.
+ *
+ * Internally, this is guaranteed by the following:
+ *
+ *   - Dependency injection relies on using dom-events which work synchronously.
+ *   - Dependency injection leverages ReactiveControllers whose lifetime
+ *     mirror that of the component
+ *   - Parent components' connected lifetime is guaranteed to include the
+ *     connected lifetime of child components.
+ *   - Dependency provider factories are only called during the lifetime of the
+ *     component that provides the value.
+ *
+ * Best practices
+ * ===
+ *  - Provide dependencies in or before connectedCallback
+ *  - Verify that isConnected is true when accessing a dependency after an
+ *    await.
+ *
+ * Type Safety
+ * ---
+ *
+ * Dependency injection is guaranteed type-safe by construction due to the
+ * typing of the token used to tie together dependency providers and dependency
+ * consumers.
+ *
+ * Two tokens can never be equal because of how they are created. And both the
+ * consumer and provider logic of dependencies relies on the type of dependency
+ * token.
+ */
+
+/**
+ * A dependency-token is a unique key. It's typed by the type of the value the
+ * dependency needs.
+ */
+export type DependencyToken<ValueType> = symbol & {__type__: ValueType};
+
+/**
+ * Defines a unique dependency token for a given type.  The string provided
+ * is purely for debugging and does not need to be unique.
+ *
+ * Example usage:
+ *   const token = define<FooService>('foo-service');
+ */
+export function define<ValueType>(name: string) {
+  return Symbol(name) as unknown as DependencyToken<ValueType>;
+}
+
+/**
+ * A provider for a value.
+ */
+export type Provider<T> = () => T;
+
+// Symbols to cache the providers and resolvers to avoid duplicate registration.
+const PROVIDERS_SYMBOL = Symbol('providers');
+const RESOLVERS_SYMBOL = Symbol('resolvers');
+
+interface Registrations {
+  [PROVIDERS_SYMBOL]?: Map<
+    DependencyToken<unknown>,
+    DependencyProvider<unknown>
+  >;
+  [RESOLVERS_SYMBOL]?: Map<DependencyToken<unknown>, Provider<unknown>>;
+}
+/**
+ * A producer of a dependency expresses this as a need that results in a promise
+ * for the given dependency.
+ */
+export function provide<T>(
+  host: ReactiveControllerHost & HTMLElement & Registrations,
+  dependency: DependencyToken<T>,
+  provider: Provider<T>
+) {
+  const hostProviders = (host[PROVIDERS_SYMBOL] ||= new Map<
+    DependencyToken<unknown>,
+    DependencyProvider<unknown>
+  >());
+  const oldController = hostProviders.get(dependency);
+  if (oldController) {
+    host.removeController(oldController);
+    oldController.hostDisconnected();
+  }
+  const controller = new DependencyProvider<T>(host, dependency, provider);
+  hostProviders.set(dependency, controller);
+  host.addController(controller);
+}
+
+/**
+ * A consumer of a service will resolve a given dependency token. The resolved
+ * dependency is returned as a simple function that can be called to access
+ * the injected value.
+ */
+export function resolve<T>(
+  host: ReactiveControllerHost & HTMLElement & Registrations,
+  dependency: DependencyToken<T>
+): Provider<T> {
+  const hostResolvers = (host[RESOLVERS_SYMBOL] ||= new Map<
+    DependencyToken<unknown>,
+    Provider<unknown>
+  >());
+  let resolver = hostResolvers.get(dependency);
+  if (!resolver) {
+    const controller = new DependencySubscriber(host, dependency);
+    host.addController(controller);
+    resolver = () => controller.get();
+    hostResolvers.set(dependency, resolver);
+  }
+  return resolver as Provider<T>;
+}
+
+/**
+ * A callback for a value.
+ */
+type Callback<T> = (value: T) => void;
+
+/**
+ * A Dependency Request gets sent by an element to ask for a dependency.
+ */
+export interface DependencyRequest<T> {
+  readonly dependency: DependencyToken<T>;
+  readonly callback: Callback<Provider<T>>;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    /**
+     * An 'request-dependency' can be emitted by any element which desires a
+     * dependency to be injected by an external provider.
+     */
+    'request-dependency': DependencyRequestEvent<unknown>;
+  }
+  interface DocumentEventMap {
+    /**
+     * An 'request-dependency' can be emitted by any element which desires a
+     * dependency to be injected by an external provider.
+     */
+    'request-dependency': DependencyRequestEvent<unknown>;
+  }
+}
+
+/**
+ * Dependency Consumers fire DependencyRequests in the form of
+ * DependencyRequestEvent
+ */
+export class DependencyRequestEvent<T>
+  extends Event
+  implements DependencyRequest<T>
+{
+  public constructor(
+    public readonly dependency: DependencyToken<T>,
+    public readonly callback: Callback<Provider<T>>
+  ) {
+    super('request-dependency', {bubbles: true, composed: true});
+  }
+}
+
+/**
+ * A resolved dependency is valid within the connected lifetime of a component,
+ * namely between connectedCallback and disconnectedCallback.
+ */
+interface ResolvedDependency<T> {
+  get(): T;
+}
+
+export class DependencyError<T> extends Error {
+  constructor(public readonly dependency: DependencyToken<T>, message: string) {
+    super(message);
+  }
+}
+
+function makeDependencyError<T>(
+  host: HTMLElement,
+  dependency: DependencyToken<T>
+): DependencyError<T> {
+  const dep = dependency.description;
+  const tag = host.tagName;
+  const msg = `Could not resolve dependency '${dep}' in '${tag}'`;
+  return new DependencyError(dependency, msg);
+}
+
+class DependencySubscriber<T>
+  implements ReactiveController, ResolvedDependency<T>
+{
+  private provider?: Provider<T>;
+
+  constructor(
+    private readonly host: ReactiveControllerHost & HTMLElement,
+    private readonly dependency: DependencyToken<T>
+  ) {}
+
+  get() {
+    if (!this.provider) {
+      throw makeDependencyError(this.host, this.dependency);
+    }
+    return this.provider();
+  }
+
+  hostConnected() {
+    this.provider = undefined;
+    this.host.dispatchEvent(
+      new DependencyRequestEvent(this.dependency, (provider: Provider<T>) => {
+        this.provider = provider;
+      })
+    );
+    if (!this.provider) {
+      throw makeDependencyError(this.host, this.dependency);
+    }
+  }
+}
+
+class DependencyProvider<T> implements ReactiveController {
+  constructor(
+    private readonly host: ReactiveControllerHost & HTMLElement,
+    private readonly dependency: DependencyToken<T>,
+    private readonly provider: Provider<T>
+  ) {}
+
+  hostConnected() {
+    this.host.addEventListener('request-dependency', this.fullfill);
+  }
+
+  hostDisconnected() {
+    this.host.removeEventListener('request-dependency', this.fullfill);
+  }
+
+  private readonly fullfill = (ev: DependencyRequestEvent<unknown>) => {
+    if (ev.dependency !== this.dependency) return;
+    ev.stopPropagation();
+    ev.preventDefault();
+    ev.callback(this.provider);
+  };
+}
diff --git a/polygerrit-ui/app/models/dependency_test.ts b/polygerrit-ui/app/models/dependency_test.ts
new file mode 100644
index 0000000..c52765d
--- /dev/null
+++ b/polygerrit-ui/app/models/dependency_test.ts
@@ -0,0 +1,122 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {define, provide, resolve} from './dependency';
+import {html, LitElement} from 'lit';
+import {customElement, property, query} from 'lit/decorators.js';
+import '../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+
+interface FooService {
+  value: string;
+}
+const fooToken = define<FooService>('foo');
+
+interface BarService {
+  value: string;
+}
+
+const barToken = define<BarService>('bar');
+
+class FooImpl implements FooService {
+  constructor(public readonly value: string) {}
+}
+
+class BarImpl implements BarService {
+  constructor(private readonly foo: FooService) {}
+
+  get value() {
+    return this.foo.value;
+  }
+}
+
+@customElement('lit-foo-provider')
+export class LitFooProviderElement extends LitElement {
+  @query('bar-provider')
+  bar?: BarProviderElement;
+
+  @property({type: Boolean})
+  public showBarProvider = true;
+
+  constructor() {
+    super();
+    provide(this, fooToken, () => new FooImpl('foo'));
+  }
+
+  override render() {
+    if (this.showBarProvider) {
+      return html`<bar-provider></bar-provider>`;
+    } else {
+      return undefined;
+    }
+  }
+}
+
+@customElement('bar-provider')
+export class BarProviderElement extends LitElement {
+  @query('leaf-lit-element')
+  litChild?: LeafLitElement;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    provide(this, barToken, () => this.create());
+  }
+
+  private create() {
+    const fooRef = resolve(this, fooToken);
+    assert.isDefined(fooRef());
+    return new BarImpl(fooRef());
+  }
+
+  override render() {
+    return html`<leaf-lit-element></leaf-lit-element>`;
+  }
+}
+
+@customElement('leaf-lit-element')
+export class LeafLitElement extends LitElement {
+  readonly barRef = resolve(this, barToken);
+
+  override connectedCallback() {
+    super.connectedCallback();
+    assert.isDefined(this.barRef());
+  }
+
+  override render() {
+    return html`${this.barRef().value}`;
+  }
+}
+
+suite('Dependency', () => {
+  let element: LitFooProviderElement;
+
+  setup(async () => {
+    element = await fixture(html`<lit-foo-provider></lit-foo-provider>`);
+  });
+
+  test('It instantiates', () => {
+    assert.isDefined(element.bar?.litChild?.barRef());
+  });
+
+  test('It works by connecting and reconnecting', async () => {
+    assert.isDefined(element.bar?.litChild?.barRef());
+
+    element.showBarProvider = false;
+    await element.updateComplete;
+    assert.isNull(element.bar);
+
+    element.showBarProvider = true;
+    await element.updateComplete;
+    assert.isDefined(element.bar?.litChild?.barRef());
+  });
+});
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'lit-foo-provider': LitFooProviderElement;
+    'bar-provider': BarProviderElement;
+    'leaf-lit-element': LeafLitElement;
+  }
+}
diff --git a/polygerrit-ui/app/models/di-provider-element.ts b/polygerrit-ui/app/models/di-provider-element.ts
new file mode 100644
index 0000000..5e01373
--- /dev/null
+++ b/polygerrit-ui/app/models/di-provider-element.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, TemplateResult} from 'lit';
+import {customElement, property, query} from 'lit/decorators.js';
+import {DependencyToken, provide} from './dependency';
+
+/**
+ * Example usage:
+ *
+ *   const providerElement = await fixture<DIProviderElement>(
+ *     wrapInProvider(
+ *       html`<my-element-to-test></my-element-to-test>`,
+ *       myModelToken,
+ *       myModel,
+ *     )
+ *   );
+ *   const element = providerElement.element as MyElementToTest;
+ *
+ * For injecting multiple tokens, make nested calls to `wrapInProvider` such as:
+ *
+ *   wrapInProvider(wrapInProvider(html`...`, token1, value1), token2, value2);
+ */
+export function wrapInProvider<T>(
+  template: TemplateResult,
+  token: DependencyToken<T>,
+  value: T
+) {
+  return html`
+    <di-provider-element .token=${token} .value=${value}
+      >${template}</di-provider-element
+    >
+  `;
+}
+
+/**
+ * Use `.element` to get the wrapped element for assertions.
+ */
+@customElement('di-provider-element')
+export class DIProviderElement extends LitElement {
+  @property({type: Object})
+  token?: DependencyToken<unknown>;
+
+  @property({type: Object})
+  value?: unknown;
+
+  get element() {
+    return this.slotElement.assignedElements()[0];
+  }
+
+  private isProvided = false;
+
+  @query('slot')
+  private slotElement!: HTMLSlotElement;
+
+  static override get styles() {
+    return css`
+      :host() {
+        display: contents;
+      }
+    `;
+  }
+
+  /** Only calls `provide` even after reconnection. */
+  override connectedCallback(): void {
+    super.connectedCallback();
+    if (!this.token || this.isProvided) return;
+    this.isProvided = true;
+    provide(this, this.token, () => this.value);
+  }
+
+  override render() {
+    return html`<slot></slot>`;
+  }
+}
diff --git a/polygerrit-ui/app/models/di-provider-element_test.ts b/polygerrit-ui/app/models/di-provider-element_test.ts
new file mode 100644
index 0000000..6f0ac4a
--- /dev/null
+++ b/polygerrit-ui/app/models/di-provider-element_test.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {define, resolve} from './dependency';
+import '../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {DIProviderElement, wrapInProvider} from './di-provider-element';
+import {BehaviorSubject} from 'rxjs';
+import {waitUntilObserved} from '../test/test-utils';
+import {subscribe} from '../elements/lit/subscription-controller';
+
+const modelToken = define<BehaviorSubject<string>>('token');
+
+/**
+ * This is an example element-under-test. It is expecting a model injected
+ * using a token, and then always displaying the value from that model.
+ */
+@customElement('consumer-element')
+class ConsumerElement extends LitElement {
+  readonly getModel = resolve(this, modelToken);
+
+  @state()
+  private injectedValue = '';
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getModel(),
+      value => (this.injectedValue = value)
+    );
+  }
+
+  override render() {
+    return html`<div>${this.injectedValue}</div>`;
+  }
+}
+
+suite('di-provider-element', () => {
+  let injectedModel: BehaviorSubject<string>;
+  let element: ConsumerElement;
+
+  setup(async () => {
+    // The injected value and fixture are created inside `setup` to prevent
+    // tests from leaking into each other.
+    injectedModel = new BehaviorSubject('foo');
+    element = (
+      await fixture<DIProviderElement>(
+        wrapInProvider(
+          html`<consumer-element></consumer-element>`,
+          modelToken,
+          injectedModel
+        )
+      )
+    ).element as ConsumerElement;
+  });
+
+  test('provides values to the wrapped element', () => {
+    assert.shadowDom.equal(element, '<div>foo</div>');
+  });
+
+  test('enables the test to control the injected dependency', async () => {
+    injectedModel.next('bar');
+    await waitUntilObserved(injectedModel, value => value === 'bar');
+
+    assert.shadowDom.equal(element, '<div>bar</div>');
+  });
+});
diff --git a/polygerrit-ui/app/models/model.ts b/polygerrit-ui/app/models/model.ts
new file mode 100644
index 0000000..19b52fc
--- /dev/null
+++ b/polygerrit-ui/app/models/model.ts
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {BehaviorSubject, Observable, Subscription} from 'rxjs';
+import {Finalizable} from '../services/registry';
+import {deepEqual} from '../utils/deep-util';
+
+/**
+ * A Model stores a value <T> and controls changes to that value via `subject$`
+ * while allowing others to subscribe to value updates via the `state$`
+ * Observable.
+ *
+ * Typically a given Model subclass will provide:
+ *   1. An initial value. If there is no good default to start with, then
+ *      include `undefined` in the type `T`.
+ *   2. "reducers": functions for users to request changes to the value
+ *   3. "selectors": convenient sub-Observables that only contain updates for a
+ *      nested property from the value
+ *
+ *  Any new subscriber will immediately receive the current value.
+ */
+export abstract class Model<T> implements Finalizable {
+  /**
+   * rxjs does not like `next()` being called on a subject during processing of
+   * another `next()` call. So make sure that state updates complete before
+   * starting another one.
+   */
+  private stateUpdateInProgress = false;
+
+  private subject$: BehaviorSubject<T>;
+
+  public state$: Observable<T>;
+
+  protected subscriptions: Subscription[] = [];
+
+  constructor(initialState: T) {
+    this.subject$ = new BehaviorSubject(initialState);
+    this.state$ = this.subject$.asObservable();
+  }
+
+  getState() {
+    return this.subject$.getValue();
+  }
+
+  setState(state: T) {
+    if (this.stateUpdateInProgress) {
+      setTimeout(() => this.setState(state));
+      return;
+    }
+    if (deepEqual(state, this.getState())) return;
+    try {
+      this.stateUpdateInProgress = true;
+      this.subject$.next(state);
+    } finally {
+      this.stateUpdateInProgress = false;
+    }
+  }
+
+  updateState(state: Partial<T>) {
+    if (this.stateUpdateInProgress) {
+      setTimeout(() => this.updateState(state));
+      return;
+    }
+    this.setState({...this.getState(), ...state});
+  }
+
+  finalize() {
+    this.subject$.complete();
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
+    this.subscriptions = [];
+  }
+}
diff --git a/polygerrit-ui/app/models/model_test.ts b/polygerrit-ui/app/models/model_test.ts
new file mode 100644
index 0000000..3fa88e7
--- /dev/null
+++ b/polygerrit-ui/app/models/model_test.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert, waitUntil} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {Model} from './model';
+
+interface TestModelState {
+  prop1?: string;
+  prop2?: string;
+  prop3?: string;
+}
+
+export class TestModel extends Model<TestModelState> {
+  constructor() {
+    super({});
+  }
+}
+
+suite('model tests', () => {
+  test('setState update in progress', async () => {
+    const model = new TestModel();
+    let firstUpdateCompleted = false;
+    let secondUpdateCompleted = false;
+    model.state$.subscribe(s => {
+      if (s.prop2 === 'set') {
+        // Otherwise this would be a clear indication of a nested `setState()`
+        // call, which `stateUpdateInProgress` is supposed to avoid.
+        assert.isTrue(firstUpdateCompleted);
+        secondUpdateCompleted = true;
+      }
+      if (s.prop1 === 'set' && s.prop2 !== 'set')
+        model.setState({prop2: 'set'});
+    });
+
+    // This call should return before the subscriber calls `setState()` again.
+    model.setState({prop1: 'set'});
+    firstUpdateCompleted = true;
+
+    await waitUntil(() => secondUpdateCompleted);
+  });
+
+  test('updateState update in progress', async () => {
+    const model = new TestModel();
+    let completed = false;
+    model.state$.subscribe(s => {
+      if (s.prop1 !== 'go') return;
+      if (s.prop2 !== 'set' && s.prop3 !== 'set')
+        model.updateState({prop2: 'set'});
+      if (s.prop2 === 'set' && s.prop3 === 'set') completed = true;
+    });
+    model.state$.subscribe(s => {
+      if (s.prop1 !== 'go') return;
+      if (s.prop2 !== 'set' && s.prop3 !== 'set')
+        model.updateState({prop3: 'set'});
+      if (s.prop2 === 'set' && s.prop3 === 'set') completed = true;
+    });
+
+    model.updateState({prop1: 'go'});
+
+    await waitUntil(() => completed);
+  });
+});
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
new file mode 100644
index 0000000..83235b17
--- /dev/null
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -0,0 +1,127 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Observable, Subject} from 'rxjs';
+import {
+  CheckResult,
+  CheckRun,
+  ChecksApiConfig,
+  ChecksProvider,
+} from '../../api/checks';
+import {Model} from '../model';
+import {select} from '../../utils/observable-util';
+import {CoverageProvider} from '../../api/annotation';
+
+export interface CoveragePlugin {
+  pluginName: string;
+  provider: CoverageProvider;
+}
+
+export interface ChecksPlugin {
+  pluginName: string;
+  provider: ChecksProvider;
+  config: ChecksApiConfig;
+}
+
+export interface ChecksUpdate {
+  pluginName: string;
+  run: CheckRun;
+  result: CheckResult;
+}
+
+/** Application wide state of plugins. */
+interface PluginsState {
+  /**
+   * List of plugins that have called annotationApi().setCoverageProvider().
+   */
+  coveragePlugins: CoveragePlugin[];
+  /**
+   * List of plugins that have called checks().register().
+   */
+  checksPlugins: ChecksPlugin[];
+}
+
+export class PluginsModel extends Model<PluginsState> {
+  /** Private version of the event bus below. */
+  private checksAnnounceSubject$ = new Subject<ChecksPlugin>();
+
+  /** Event bus for telling the checks models that announce() was called. */
+  public checksAnnounce$: Observable<ChecksPlugin> =
+    this.checksAnnounceSubject$.asObservable();
+
+  /** Private version of the event bus below. */
+  private checksUpdateSubject$ = new Subject<ChecksUpdate>();
+
+  /** Event bus for telling the checks models that updateResult() was called. */
+  public checksUpdate$: Observable<ChecksUpdate> =
+    this.checksUpdateSubject$.asObservable();
+
+  public checksPlugins$ = select(this.state$, state => state.checksPlugins);
+
+  public coveragePlugins$ = select(this.state$, state => state.coveragePlugins);
+
+  constructor() {
+    super({
+      coveragePlugins: [],
+      checksPlugins: [],
+    });
+  }
+
+  coverageRegister(plugin: CoveragePlugin) {
+    const nextState = {...this.getState()};
+    nextState.coveragePlugins = [...nextState.coveragePlugins];
+    const alreadyRegistered = nextState.coveragePlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadyRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a coverage provider. Ignored.`
+      );
+      return;
+    }
+    nextState.coveragePlugins.push(plugin);
+    this.setState(nextState);
+  }
+
+  checksRegister(plugin: ChecksPlugin) {
+    const nextState = {...this.getState()};
+    nextState.checksPlugins = [...nextState.checksPlugins];
+    const alreadyRegistered = nextState.checksPlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadyRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a checks provider. Ignored.`
+      );
+      return;
+    }
+    nextState.checksPlugins.push(plugin);
+    this.setState(nextState);
+  }
+
+  checksUpdate(update: ChecksUpdate) {
+    const plugins = this.getState().checksPlugins;
+    const plugin = plugins.find(p => p.pluginName === update.pluginName);
+    if (!plugin) {
+      console.warn(
+        `Plugin '${update.pluginName}' not found. checksUpdate() ignored.`
+      );
+      return;
+    }
+    this.checksUpdateSubject$.next(update);
+  }
+
+  checksAnnounce(pluginName: string) {
+    const plugins = this.getState().checksPlugins;
+    const plugin = plugins.find(p => p.pluginName === pluginName);
+    if (!plugin) {
+      console.warn(
+        `Plugin '${pluginName}' not found. checksAnnounce() ignored.`
+      );
+      return;
+    }
+    this.checksAnnounceSubject$.next(plugin);
+  }
+}
diff --git a/polygerrit-ui/app/models/plugins/plugins-model_test.ts b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
new file mode 100644
index 0000000..639afc69
--- /dev/null
+++ b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import './plugins-model';
+import {ChecksApiConfig, ChecksProvider, ResponseCode} from '../../api/checks';
+import {ChecksPlugin, ChecksUpdate, PluginsModel} from './plugins-model';
+import {createRunResult} from '../../test/test-data-generators';
+import {assert} from '@open-wc/testing';
+
+const PLUGIN_NAME = 'test-plugin';
+
+const CONFIG: ChecksApiConfig = {
+  fetchPollingIntervalSeconds: 1000,
+};
+
+function createProvider(): ChecksProvider {
+  return {
+    fetch: () =>
+      Promise.resolve({
+        responseCode: ResponseCode.OK,
+        runs: [],
+      }),
+  };
+}
+
+suite('plugins-model tests', () => {
+  let model: PluginsModel;
+  let checksPlugins: ChecksPlugin[] = [];
+  const register = function () {
+    model.checksRegister({
+      pluginName: PLUGIN_NAME,
+      provider: createProvider(),
+      config: CONFIG,
+    });
+  };
+
+  setup(() => {
+    model = new PluginsModel();
+    model.state$.subscribe(s => {
+      checksPlugins = s.checksPlugins;
+    });
+  });
+
+  teardown(() => {
+    model.finalize();
+  });
+
+  test('checksRegister', async () => {
+    assert.isFalse(checksPlugins.some(p => p.pluginName === PLUGIN_NAME));
+
+    register();
+
+    assert.isTrue(checksPlugins.some(p => p.pluginName === PLUGIN_NAME));
+  });
+
+  test('checksAnnounce', async () => {
+    let announcement: ChecksPlugin | undefined;
+    model.checksAnnounce$.subscribe(a => (announcement = a));
+    assert.isUndefined(announcement?.pluginName);
+
+    register();
+    model.checksAnnounce(PLUGIN_NAME);
+
+    assert.equal(announcement?.pluginName, PLUGIN_NAME);
+  });
+
+  test('checksUpdate', async () => {
+    let update: ChecksUpdate | undefined;
+    model.checksUpdate$.subscribe(u => (update = u));
+    assert.isUndefined(update?.pluginName);
+
+    register();
+    model.checksUpdate({
+      pluginName: PLUGIN_NAME,
+      run: createRunResult(),
+      result: createRunResult(),
+    });
+
+    assert.equal(update?.pluginName, PLUGIN_NAME);
+  });
+});
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
new file mode 100644
index 0000000..97f90fa
--- /dev/null
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -0,0 +1,244 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {from, of, Observable} from 'rxjs';
+import {filter, switchMap} from 'rxjs/operators';
+import {
+  DiffPreferencesInfo as DiffPreferencesInfoAPI,
+  DiffViewMode,
+} from '../../api/diff';
+import {
+  AccountCapabilityInfo,
+  AccountDetailInfo,
+  EditPreferencesInfo,
+  PreferencesInfo,
+} from '../../types/common';
+import {
+  createDefaultPreferences,
+  createDefaultDiffPrefs,
+  createDefaultEditPrefs,
+  AppTheme,
+} from '../../constants/constants';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {DiffPreferencesInfo} from '../../types/diff';
+import {select} from '../../utils/observable-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {isDefined} from '../../types/types';
+
+export interface UserState {
+  /**
+   * Keeps being defined even when credentials have expired.
+   *
+   * `undefined` can mean that the app is still starting up and we have not
+   * tried loading an account object yet. If you want to wait until the
+   * `account` is known, then use `accountLoaded` below.
+   */
+  account?: AccountDetailInfo;
+  /**
+   * Starts as `false` and switches to `true` after the first `getAccount` call.
+   * A common use case for this is to wait with loading or doing something until
+   * we know whether the user is logged in or not, see `loadedAccount$` below.
+   *
+   * This value cannot change back to `false` once it has become `true`.
+   *
+   * This value does *not* indicate whether the user is logged in or whether an
+   * `account` object is available. If the first `getAccount()` call returns
+   * `undefined`, then `accountLoaded` still becomes true, even if `account`
+   * stays `undefined`.
+   */
+  accountLoaded: boolean;
+  preferences?: PreferencesInfo;
+  diffPreferences?: DiffPreferencesInfo;
+  editPreferences?: EditPreferencesInfo;
+  capabilities?: AccountCapabilityInfo;
+}
+
+export const userModelToken = define<UserModel>('user-model');
+
+export class UserModel extends Model<UserState> {
+  /**
+   * Note that the initially emitted `undefined` value can mean "not loaded
+   * the account into object yet" or "user is not logged in". Consider using
+   * `loadedAccount$` below.
+   *
+   * TODO: Maybe consider changing all usages to `loadedAccount$`.
+   */
+  readonly account$: Observable<AccountDetailInfo | undefined> = select(
+    this.state$,
+    userState => userState.account
+  );
+
+  /**
+   * Only emits once we have tried to actually load the account. Note that
+   * this does not initially emit a value.
+   *
+   * So if this emits `undefined`, then you actually know that the user is not
+   * logged in. And for logged in users you will never get an initial
+   * `undefined` emission.
+   */
+  readonly loadedAccount$: Observable<AccountDetailInfo | undefined> = select(
+    this.state$.pipe(filter(s => s.accountLoaded)),
+    userState => userState.account
+  );
+
+  /** Note that this may still be true, even if credentials have expired. */
+  readonly loggedIn$: Observable<boolean> = select(
+    this.account$,
+    account => !!account
+  );
+
+  readonly capabilities$: Observable<AccountCapabilityInfo | undefined> =
+    select(this.state$, userState => userState.capabilities);
+
+  readonly isAdmin$: Observable<boolean> = select(
+    this.capabilities$,
+    capabilities => capabilities?.administrateServer ?? false
+  );
+
+  readonly preferences$: Observable<PreferencesInfo> = select(
+    this.state$,
+    userState => userState.preferences
+  ).pipe(filter(isDefined));
+
+  readonly diffPreferences$: Observable<DiffPreferencesInfo> = select(
+    this.state$,
+    userState => userState.diffPreferences
+  ).pipe(filter(isDefined));
+
+  readonly editPreferences$: Observable<EditPreferencesInfo> = select(
+    this.state$,
+    userState => userState.editPreferences
+  ).pipe(filter(isDefined));
+
+  readonly preferenceDiffViewMode$: Observable<DiffViewMode> = select(
+    this.preferences$,
+    preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE
+  );
+
+  readonly preferenceTheme$: Observable<AppTheme> = select(
+    this.preferences$,
+    preference => preference.theme
+  );
+
+  readonly preferenceChangesPerPage$: Observable<number> = select(
+    this.preferences$,
+    preference => preference.changes_per_page
+  );
+
+  constructor(readonly restApiService: RestApiService) {
+    super({
+      accountLoaded: false,
+    });
+    this.subscriptions = [
+      from(this.restApiService.getAccount()).subscribe(
+        (account?: AccountDetailInfo) => {
+          this.setAccount(account);
+        }
+      ),
+      this.loadedAccount$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(createDefaultPreferences());
+            return from(this.restApiService.getPreferences());
+          })
+        )
+        .subscribe((preferences?: PreferencesInfo) => {
+          this.setPreferences(preferences ?? createDefaultPreferences());
+        }),
+      this.loadedAccount$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(createDefaultDiffPrefs());
+            return from(this.restApiService.getDiffPreferences());
+          })
+        )
+        .subscribe((diffPrefs?: DiffPreferencesInfoAPI) => {
+          this.setDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
+        }),
+      this.loadedAccount$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(createDefaultEditPrefs());
+            return from(this.restApiService.getEditPreferences());
+          })
+        )
+        .subscribe((editPrefs?: EditPreferencesInfo) => {
+          this.setEditPreferences(editPrefs ?? createDefaultEditPrefs());
+        }),
+      this.loadedAccount$
+        .pipe(
+          switchMap(account => {
+            if (!account) return of(undefined);
+            return from(this.restApiService.getAccountCapabilities());
+          })
+        )
+        .subscribe((capabilities?: AccountCapabilityInfo) => {
+          this.setCapabilities(capabilities);
+        }),
+    ];
+  }
+
+  updatePreferences(prefs: Partial<PreferencesInfo>) {
+    return this.restApiService
+      .savePreferences(prefs)
+      .then((newPrefs: PreferencesInfo | undefined) => {
+        if (!newPrefs) return;
+        this.setPreferences(newPrefs);
+        return newPrefs;
+      });
+  }
+
+  updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
+    return this.restApiService
+      .saveDiffPreferences(diffPrefs)
+      .then((response: Response) =>
+        this.restApiService.getResponseObject(response).then(obj => {
+          const newPrefs = obj as unknown as DiffPreferencesInfo;
+          if (!newPrefs) return;
+          this.setDiffPreferences(newPrefs);
+        })
+      );
+  }
+
+  updateEditPreference(editPrefs: EditPreferencesInfo) {
+    return this.restApiService
+      .saveEditPreferences(editPrefs)
+      .then((response: Response) => {
+        this.restApiService.getResponseObject(response).then(obj => {
+          const newPrefs = obj as unknown as EditPreferencesInfo;
+          if (!newPrefs) return;
+          this.setEditPreferences(newPrefs);
+        });
+      });
+  }
+
+  getDiffPreferences() {
+    return this.restApiService.getDiffPreferences().then(prefs => {
+      if (!prefs) return;
+      this.setDiffPreferences(prefs);
+    });
+  }
+
+  setPreferences(preferences: PreferencesInfo) {
+    this.updateState({preferences});
+  }
+
+  setDiffPreferences(diffPreferences: DiffPreferencesInfo) {
+    this.updateState({diffPreferences});
+  }
+
+  setEditPreferences(editPreferences: EditPreferencesInfo) {
+    this.updateState({editPreferences});
+  }
+
+  setCapabilities(capabilities?: AccountCapabilityInfo) {
+    this.updateState({capabilities});
+  }
+
+  setAccount(account?: AccountDetailInfo) {
+    this.updateState({account, accountLoaded: true});
+  }
+}
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
new file mode 100644
index 0000000..3380637
--- /dev/null
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export enum AdminChildView {
+  REPOS = 'gr-repo-list',
+  GROUPS = 'gr-admin-group-list',
+  PLUGINS = 'gr-plugin-list',
+}
+export interface AdminViewState extends ViewState {
+  view: GerritView.ADMIN;
+  adminView: AdminChildView;
+  openCreateModal?: boolean;
+  filter?: string | null;
+  offset?: number | string;
+}
+
+export function createAdminUrl(state: Omit<AdminViewState, 'view'>) {
+  switch (state.adminView) {
+    case AdminChildView.REPOS:
+      return `${getBaseUrl()}/admin/repos`;
+    case AdminChildView.GROUPS:
+      return `${getBaseUrl()}/admin/groups`;
+    case AdminChildView.PLUGINS:
+      return `${getBaseUrl()}/admin/plugins`;
+  }
+}
+
+export const adminViewModelToken = define<AdminViewModel>('admin-view-model');
+
+export class AdminViewModel extends Model<AdminViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/agreement.ts b/polygerrit-ui/app/models/views/agreement.ts
new file mode 100644
index 0000000..839699c
--- /dev/null
+++ b/polygerrit-ui/app/models/views/agreement.ts
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface AgreementViewState extends ViewState {
+  view: GerritView.AGREEMENTS;
+}
+
+const DEFAULT_STATE: AgreementViewState = {view: GerritView.AGREEMENTS};
+
+export const agreementViewModelToken = define<AgreementViewModel>(
+  'agreement-view-model'
+);
+
+export class AgreementViewModel extends Model<AgreementViewState> {
+  constructor() {
+    super(DEFAULT_STATE);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/base.ts b/polygerrit-ui/app/models/views/base.ts
new file mode 100644
index 0000000..065495d
--- /dev/null
+++ b/polygerrit-ui/app/models/views/base.ts
@@ -0,0 +1,10 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+
+export interface ViewState {
+  view: GerritView;
+}
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
new file mode 100644
index 0000000..a206037
--- /dev/null
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -0,0 +1,320 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  NumericChangeId,
+  RepoName,
+  RevisionPatchSetNum,
+  BasePatchSetNum,
+  ChangeInfo,
+  PatchSetNumber,
+  EDIT,
+} from '../../api/rest-api';
+import {Tab} from '../../constants/constants';
+import {GerritView} from '../../services/router/router-model';
+import {UrlEncodedCommentId} from '../../types/common';
+import {toggleSet} from '../../utils/common-util';
+import {select} from '../../utils/observable-util';
+import {
+  encodeURL,
+  getBaseUrl,
+  getPatchRangeExpression,
+} from '../../utils/url-util';
+import {AttemptChoice} from '../checks/checks-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export enum ChangeChildView {
+  OVERVIEW = 'OVERVIEW',
+  DIFF = 'DIFF',
+  EDIT = 'EDIT',
+}
+
+export interface ChangeViewState extends ViewState {
+  view: GerritView.CHANGE;
+  childView: ChangeChildView;
+
+  changeNum: NumericChangeId;
+  repo: RepoName;
+  patchNum?: RevisionPatchSetNum;
+  basePatchNum?: BasePatchSetNum;
+  /** Refers to comment on COMMENTS tab in OVERVIEW. */
+  commentId?: UrlEncodedCommentId;
+
+  // TODO: Move properties that only apply to OVERVIEW into a submessage.
+
+  edit?: boolean;
+  /** This can be a string only for plugin provided tabs. */
+  tab?: Tab | string;
+
+  // TODO: Move properties that only apply to CHECKS tab into a submessage.
+
+  /** Checks related view state */
+
+  /** selected patchset for check runs (undefined=latest) */
+  checksPatchset?: PatchSetNumber;
+  /** regular expression for filtering check runs */
+  filter?: string;
+  /** selected attempt for check runs (undefined=latest) */
+  attempt?: AttemptChoice;
+  /** selected check runs identified by `checkName` */
+  checksRunsSelected?: Set<string>;
+  /** regular expression for filtering check results */
+  checksResultsFilter?: string;
+
+  /** State properties that trigger one-time actions */
+
+  /** for scrolling a Change Log message into view in gr-change-view */
+  messageHash?: string;
+  /** for logging where the user came from */
+  usp?: string;
+  /** triggers all change related data to be reloaded */
+  forceReload?: boolean;
+  /** triggers opening the reply dialog */
+  openReplyDialog?: boolean;
+
+  /** These properties apply to the DIFF child view only. */
+  diffView?: {
+    path?: string;
+    lineNum?: number;
+    leftSide?: boolean;
+  };
+
+  /** These properties apply to the EDIT child view only. */
+  editView?: {
+    path?: string;
+    lineNum?: number;
+  };
+}
+
+/**
+ * This is a convenience type such that you can pass a `ChangeInfo` object
+ * as the `change` property instead of having to set both the `changeNum` and
+ * `project` properties explicitly.
+ */
+export type CreateChangeUrlObject = Omit<
+  ChangeViewState,
+  'view' | 'childView' | 'changeNum' | 'repo'
+> & {
+  change: Pick<ChangeInfo, '_number' | 'project'>;
+};
+
+export function isCreateChangeUrlObject(
+  state: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
+): state is CreateChangeUrlObject {
+  return !!(state as CreateChangeUrlObject).change;
+}
+
+export function objToState(
+  obj:
+    | (CreateChangeUrlObject & {childView: ChangeChildView})
+    | Omit<ChangeViewState, 'view'>
+): ChangeViewState {
+  if (isCreateChangeUrlObject(obj)) {
+    return {
+      ...obj,
+      view: GerritView.CHANGE,
+      changeNum: obj.change._number,
+      repo: obj.change.project,
+    };
+  }
+  return {...obj, view: GerritView.CHANGE};
+}
+
+export function createChangeViewUrl(state: ChangeViewState): string {
+  switch (state.childView) {
+    case ChangeChildView.OVERVIEW:
+      return createChangeUrl(state);
+    case ChangeChildView.DIFF:
+      return createDiffUrl(state);
+    case ChangeChildView.EDIT:
+      return createEditUrl(state);
+  }
+}
+
+export function createChangeUrl(
+  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
+) {
+  const state: ChangeViewState = objToState({
+    ...obj,
+    childView: ChangeChildView.OVERVIEW,
+  });
+
+  let suffix = '';
+  const queries = [];
+  if (state.checksPatchset && state.checksPatchset > 0) {
+    queries.push(`checksPatchset=${state.checksPatchset}`);
+  }
+  if (state.attempt) {
+    if (state.attempt !== 'latest') queries.push(`attempt=${state.attempt}`);
+  }
+  if (state.filter) {
+    queries.push(`filter=${state.filter}`);
+  }
+  if (state.checksResultsFilter) {
+    queries.push(`checksResultsFilter=${state.checksResultsFilter}`);
+  }
+  if (state.checksRunsSelected && state.checksRunsSelected.size > 0) {
+    queries.push(`checksRunsSelected=${[...state.checksRunsSelected].sort()}`);
+  }
+  if (state.tab && state.tab !== Tab.FILES) {
+    queries.push(`tab=${state.tab}`);
+  }
+  if (state.forceReload) {
+    queries.push('forceReload=true');
+  }
+  if (state.openReplyDialog) {
+    queries.push('openReplyDialog=true');
+  }
+  if (state.usp) {
+    queries.push(`usp=${state.usp}`);
+  }
+  if (state.edit) {
+    suffix += ',edit';
+  }
+  if (state.commentId) {
+    suffix += `/comments/${state.commentId}`;
+  }
+  if (queries.length > 0) {
+    suffix += '?' + queries.join('&');
+  }
+  if (state.messageHash) {
+    suffix += state.messageHash;
+  }
+
+  return `${createChangeUrlCommon(state)}${suffix}`;
+}
+
+export function createDiffUrl(
+  obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
+) {
+  const state: ChangeViewState = objToState({
+    ...obj,
+    childView: ChangeChildView.DIFF,
+  });
+
+  const path = `/${encodeURL(state.diffView?.path ?? '', true)}`;
+
+  let suffix = '';
+  // TODO: Move creating of comment URLs to a separate function. We are
+  // "abusing" the `commentId` property, which should only be used for pointing
+  // to comment in the COMMENTS tab of the OVERVIEW page.
+  if (state.commentId) {
+    suffix += `comment/${state.commentId}/`;
+  }
+
+  if (state.diffView?.lineNum) {
+    suffix += '#';
+    if (state.diffView?.leftSide) {
+      suffix += 'b';
+    }
+    suffix += state.diffView.lineNum;
+  }
+
+  return `${createChangeUrlCommon(state)}${path}${suffix}`;
+}
+
+export function createEditUrl(
+  obj: Omit<ChangeViewState, 'view' | 'childView'>
+): string {
+  const state: ChangeViewState = objToState({
+    ...obj,
+    childView: ChangeChildView.DIFF,
+    patchNum: obj.patchNum ?? EDIT,
+  });
+
+  const path = `/${encodeURL(state.editView?.path ?? '', true)}`;
+  const line = state.editView?.lineNum;
+  const suffix = line ? `#${line}` : '';
+
+  return `${createChangeUrlCommon(state)}${path},edit${suffix}`;
+}
+
+/**
+ * The shared part of creating a change URL between OVERVIEW, DIFF and EDIT
+ * child views.
+ */
+function createChangeUrlCommon(state: ChangeViewState) {
+  let range = getPatchRangeExpression(state);
+  if (range.length) range = '/' + range;
+
+  let repo = '';
+  if (state.repo) repo = `${encodeURL(state.repo, true)}/+/`;
+
+  return `${getBaseUrl()}/c/${repo}${state.changeNum}${range}`;
+}
+
+export const changeViewModelToken =
+  define<ChangeViewModel>('change-view-model');
+
+export class ChangeViewModel extends Model<ChangeViewState | undefined> {
+  public readonly changeNum$ = select(this.state$, state => state?.changeNum);
+
+  public readonly patchNum$ = select(this.state$, state => state?.patchNum);
+
+  public readonly basePatchNum$ = select(
+    this.state$,
+    state => state?.basePatchNum
+  );
+
+  public readonly diffPath$ = select(
+    this.state$,
+    state => state?.diffView?.path
+  );
+
+  public readonly diffLine$ = select(
+    this.state$,
+    state => state?.diffView?.lineNum
+  );
+
+  public readonly diffLeftSide$ = select(
+    this.state$,
+    state => state?.diffView?.leftSide ?? false
+  );
+
+  public readonly childView$ = select(this.state$, state => state?.childView);
+
+  public readonly tab$ = select(this.state$, state => state?.tab);
+
+  public readonly checksPatchset$ = select(
+    this.state$,
+    state => state?.checksPatchset
+  );
+
+  public readonly attempt$ = select(this.state$, state => state?.attempt);
+
+  public readonly filter$ = select(this.state$, state => state?.filter);
+
+  public readonly checksResultsFilter$ = select(
+    this.state$,
+    state => state?.checksResultsFilter ?? ''
+  );
+
+  public readonly checksRunsSelected$ = select(
+    this.state$,
+    state => state?.checksRunsSelected ?? new Set<string>()
+  );
+
+  constructor() {
+    super(undefined);
+    this.state$.subscribe(s => {
+      if (s?.usp || s?.forceReload || s?.openReplyDialog) {
+        this.updateState({
+          usp: undefined,
+          forceReload: undefined,
+          openReplyDialog: undefined,
+        });
+      }
+    });
+  }
+
+  toggleSelectedCheckRun(checkName: string) {
+    const current = this.getState()?.checksRunsSelected ?? new Set();
+    const next = new Set(current);
+    toggleSet(next, checkName);
+    this.updateState({checksRunsSelected: next});
+  }
+}
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
new file mode 100644
index 0000000..837e362
--- /dev/null
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -0,0 +1,150 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {
+  BasePatchSetNum,
+  RepoName,
+  RevisionPatchSetNum,
+} from '../../api/rest-api';
+import '../../test/common-test-setup';
+import {
+  createChangeViewState,
+  createDiffViewState,
+  createEditViewState,
+} from '../../test/test-data-generators';
+import {
+  createChangeUrl,
+  createDiffUrl,
+  createEditUrl,
+  ChangeViewState,
+} from './change';
+
+suite('change view state tests', () => {
+  test('createChangeUrl()', () => {
+    const state: ChangeViewState = createChangeViewState();
+
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42');
+
+    state.patchNum = 10 as RevisionPatchSetNum;
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42/10');
+
+    state.basePatchNum = 5 as BasePatchSetNum;
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42/5..10');
+
+    state.messageHash = '#123';
+    assert.equal(createChangeUrl(state), '/c/test-project/+/42/5..10#123');
+  });
+
+  test('createChangeUrl() baseUrl', () => {
+    window.CANONICAL_PATH = '/base';
+    const state: ChangeViewState = createChangeViewState();
+    assert.equal(createChangeUrl(state).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
+  });
+
+  test('createChangeUrl() checksRunsSelected', () => {
+    const state: ChangeViewState = {
+      ...createChangeViewState(),
+      checksRunsSelected: new Set(['asdf']),
+    };
+
+    assert.equal(
+      createChangeUrl(state),
+      '/c/test-project/+/42?checksRunsSelected=asdf'
+    );
+  });
+
+  test('createChangeUrl() checksResultsFilter', () => {
+    const state: ChangeViewState = {
+      ...createChangeViewState(),
+      checksResultsFilter: 'asdf.*qwer',
+    };
+
+    assert.equal(
+      createChangeUrl(state),
+      '/c/test-project/+/42?checksResultsFilter=asdf.*qwer'
+    );
+  });
+
+  test('createChangeUrl() with repo name encoding', () => {
+    const state: ChangeViewState = {
+      ...createChangeViewState(),
+      repo: 'x+/y+/z+/w' as RepoName,
+    };
+    assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/42');
+  });
+
+  test('createDiffUrl', () => {
+    const params: ChangeViewState = {
+      ...createDiffViewState(),
+      patchNum: 12 as RevisionPatchSetNum,
+      diffView: {path: 'x+y/path.cpp'},
+    };
+    assert.equal(
+      createDiffUrl(params),
+      '/c/test-project/+/42/12/x%252By/path.cpp'
+    );
+
+    window.CANONICAL_PATH = '/base';
+    assert.equal(createDiffUrl(params).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
+
+    params.repo = 'test' as RepoName;
+    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
+
+    params.basePatchNum = 6 as BasePatchSetNum;
+    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
+
+    params.diffView = {
+      path: 'foo bar/my+file.txt%',
+    };
+    params.patchNum = 2 as RevisionPatchSetNum;
+    delete params.basePatchNum;
+    assert.equal(
+      createDiffUrl(params),
+      '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
+    );
+
+    params.diffView = {
+      path: 'file.cpp',
+      lineNum: 123,
+    };
+    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#123');
+
+    params.diffView = {
+      path: 'file.cpp',
+      lineNum: 123,
+      leftSide: true,
+    };
+    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#b123');
+  });
+
+  test('diff with repo name encoding', () => {
+    const params: ChangeViewState = {
+      ...createDiffViewState(),
+      patchNum: 12 as RevisionPatchSetNum,
+      repo: 'x+/y' as RepoName,
+      diffView: {path: 'x+y/path.cpp'},
+    };
+    assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+  });
+
+  test('createEditUrl', () => {
+    const params: ChangeViewState = {
+      ...createEditViewState(),
+      patchNum: 12 as RevisionPatchSetNum,
+      editView: {path: 'x+y/path.cpp' as RepoName, lineNum: 31},
+    };
+    assert.equal(
+      createEditUrl(params),
+      '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
+    );
+
+    window.CANONICAL_PATH = '/base';
+    assert.equal(createEditUrl(params).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
+  });
+});
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
new file mode 100644
index 0000000..d9ff2d2
--- /dev/null
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {RepoName} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
+import {DashboardId} from '../../types/common';
+import {DashboardSection} from '../../utils/dashboard-util';
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface DashboardViewState extends ViewState {
+  view: GerritView.DASHBOARD;
+  project?: RepoName;
+  dashboard?: DashboardId;
+  user?: string;
+  sections?: DashboardSection[];
+  title?: string;
+}
+
+const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
+
+function sectionsToEncodedParams(
+  sections: DashboardSection[],
+  repoName?: RepoName
+) {
+  return sections.map(section => {
+    // If there is a repo name provided, make sure to substitute it into the
+    // ${repo} (or legacy ${project}) query tokens.
+    const query = repoName
+      ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
+      : section.query;
+    return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
+  });
+}
+
+export function createDashboardUrl(state: Omit<DashboardViewState, 'view'>) {
+  const repoName = state.project || undefined;
+  if (state.sections) {
+    // Custom dashboard.
+    const queryParams = sectionsToEncodedParams(state.sections, repoName);
+    if (state.title) {
+      queryParams.push('title=' + encodeURIComponent(state.title));
+    }
+    const user = state.user ? state.user : '';
+    return `${getBaseUrl()}/dashboard/${user}?${queryParams.join('&')}`;
+  } else if (repoName) {
+    // Project dashboard.
+    const encodedRepo = encodeURL(repoName, true);
+    return `${getBaseUrl()}/p/${encodedRepo}/+/dashboard/${state.dashboard}`;
+  } else {
+    // User dashboard.
+    return `${getBaseUrl()}/dashboard/${state.user || 'self'}`;
+  }
+}
+
+export const dashboardViewModelToken = define<DashboardViewModel>(
+  'dashboard-view-model'
+);
+
+export class DashboardViewModel extends Model<DashboardViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
new file mode 100644
index 0000000..86bb5c0
--- /dev/null
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {RepoName} from '../../api/rest-api';
+import '../../test/common-test-setup';
+import {DashboardId} from '../../types/common';
+import {createDashboardUrl} from './dashboard';
+
+suite('dashboard view state tests', () => {
+  suite('createDashboardUrl()', () => {
+    test('self dashboard', () => {
+      assert.equal(createDashboardUrl({}), '/dashboard/self');
+    });
+
+    test('baseUrl', () => {
+      window.CANONICAL_PATH = '/base';
+      assert.equal(createDashboardUrl({}).substring(0, 5), '/base');
+      window.CANONICAL_PATH = undefined;
+    });
+
+    test('user dashboard', () => {
+      assert.equal(createDashboardUrl({user: 'user'}), '/dashboard/user');
+    });
+
+    test('custom self dashboard, no title', () => {
+      const state = {
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: 'query 2'},
+        ],
+      };
+      assert.equal(
+        createDashboardUrl(state),
+        '/dashboard/?section%201=query%201&section%202=query%202'
+      );
+    });
+
+    test('custom repo dashboard', () => {
+      const state = {
+        sections: [
+          {name: 'section 1', query: 'query 1 ${project}'},
+          {name: 'section 2', query: 'query 2 ${repo}'},
+        ],
+        project: 'repo-name' as RepoName,
+      };
+      assert.equal(
+        createDashboardUrl(state),
+        '/dashboard/?section%201=query%201%20repo-name&' +
+          'section%202=query%202%20repo-name'
+      );
+    });
+
+    test('custom user dashboard, with title', () => {
+      const state = {
+        user: 'user',
+        sections: [{name: 'name', query: 'query'}],
+        title: 'custom dashboard',
+      };
+      assert.equal(
+        createDashboardUrl(state),
+        '/dashboard/user?name=query&title=custom%20dashboard'
+      );
+    });
+
+    test('repo dashboard', () => {
+      const state = {
+        project: 'gerrit/repo' as RepoName,
+        dashboard: 'default:main' as DashboardId,
+      };
+      assert.equal(
+        createDashboardUrl(state),
+        '/p/gerrit/repo/+/dashboard/default:main'
+      );
+    });
+
+    test('project dashboard (legacy)', () => {
+      const state = {
+        project: 'gerrit/project' as RepoName,
+        dashboard: 'default:main' as DashboardId,
+      };
+      assert.equal(
+        createDashboardUrl(state),
+        '/p/gerrit/project/+/dashboard/default:main'
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/models/views/documentation.ts b/polygerrit-ui/app/models/views/documentation.ts
new file mode 100644
index 0000000..b564d64
--- /dev/null
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface DocumentationViewState extends ViewState {
+  view: GerritView.DOCUMENTATION_SEARCH;
+  filter?: string | null;
+}
+
+export const documentationViewModelToken = define<DocumentationViewModel>(
+  'documentation-view-model'
+);
+
+export class DocumentationViewModel extends Model<
+  DocumentationViewState | undefined
+> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/group.ts b/polygerrit-ui/app/models/views/group.ts
new file mode 100644
index 0000000..277bcff
--- /dev/null
+++ b/polygerrit-ui/app/models/views/group.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {GroupId} from '../../types/common';
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export enum GroupDetailView {
+  MEMBERS = 'members',
+  LOG = 'log',
+}
+
+export interface GroupViewState extends ViewState {
+  view: GerritView.GROUP;
+  groupId: GroupId;
+  detail?: GroupDetailView;
+}
+
+export function createGroupUrl(state: Omit<GroupViewState, 'view'>) {
+  let url = `/admin/groups/${encodeURL(`${state.groupId}`, true)}`;
+  if (state.detail === GroupDetailView.MEMBERS) {
+    url += ',members';
+  } else if (state.detail === GroupDetailView.LOG) {
+    url += ',audit-log';
+  }
+  return getBaseUrl() + url;
+}
+
+export const groupViewModelToken = define<GroupViewModel>('group-view-model');
+
+export class GroupViewModel extends Model<GroupViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/group_test.ts b/polygerrit-ui/app/models/views/group_test.ts
new file mode 100644
index 0000000..e1fbe66
--- /dev/null
+++ b/polygerrit-ui/app/models/views/group_test.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {GroupId} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
+import '../../test/common-test-setup';
+import {createGroupUrl, GroupDetailView, GroupViewState} from './group';
+
+suite('group view state tests', () => {
+  test('createGroupUrl() info', () => {
+    const params: GroupViewState = {
+      view: GerritView.GROUP,
+      groupId: '1234' as GroupId,
+    };
+    assert.equal(createGroupUrl(params), '/admin/groups/1234');
+  });
+
+  test('createGroupUrl() members', () => {
+    const params: GroupViewState = {
+      view: GerritView.GROUP,
+      groupId: '1234' as GroupId,
+      detail: 'members' as GroupDetailView,
+    };
+    assert.equal(createGroupUrl(params), '/admin/groups/1234,members');
+  });
+
+  test('createGroupUrl() audit log', () => {
+    const params: GroupViewState = {
+      view: GerritView.GROUP,
+      groupId: '1234' as GroupId,
+      detail: 'log' as GroupDetailView,
+    };
+    assert.equal(createGroupUrl(params), '/admin/groups/1234,audit-log');
+  });
+});
diff --git a/polygerrit-ui/app/models/views/plugin.ts b/polygerrit-ui/app/models/views/plugin.ts
new file mode 100644
index 0000000..ac7e925
--- /dev/null
+++ b/polygerrit-ui/app/models/views/plugin.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface PluginViewState extends ViewState {
+  view: GerritView.PLUGIN_SCREEN;
+  plugin?: string;
+  screen?: string;
+}
+
+const DEFAULT_STATE: PluginViewState = {view: GerritView.PLUGIN_SCREEN};
+
+export const pluginViewModelToken =
+  define<PluginViewModel>('plugin-view-model');
+
+export class PluginViewModel extends Model<PluginViewState> {
+  constructor() {
+    super(DEFAULT_STATE);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
new file mode 100644
index 0000000..ec65ca1
--- /dev/null
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {BranchName, RepoName} from '../../types/common';
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export enum RepoDetailView {
+  GENERAL = 'general',
+  ACCESS = 'access',
+  BRANCHES = 'branches',
+  COMMANDS = 'commands',
+  DASHBOARDS = 'dashboards',
+  TAGS = 'tags',
+}
+
+export interface RepoViewState extends ViewState {
+  view: GerritView.REPO;
+  detail?: RepoDetailView;
+  repo?: RepoName;
+  filter?: string | null;
+  offset?: number | string;
+  /**
+   * This is for creating a change from the URL and then redirecting to a file
+   * editing page.
+   */
+  createEdit?: {
+    branch: BranchName;
+    path: string;
+  };
+}
+
+export function createRepoUrl(state: Omit<RepoViewState, 'view'>) {
+  let url = `/admin/repos/${encodeURL(`${state.repo}`, true)}`;
+  if (state.detail === RepoDetailView.GENERAL) {
+    url += ',general';
+  } else if (state.detail === RepoDetailView.ACCESS) {
+    url += ',access';
+  } else if (state.detail === RepoDetailView.BRANCHES) {
+    url += ',branches';
+  } else if (state.detail === RepoDetailView.TAGS) {
+    url += ',tags';
+  } else if (state.detail === RepoDetailView.COMMANDS) {
+    url += ',commands';
+  } else if (state.detail === RepoDetailView.DASHBOARDS) {
+    url += ',dashboards';
+  }
+  return getBaseUrl() + url;
+}
+
+export const repoViewModelToken = define<RepoViewModel>('repo-view-model');
+
+export class RepoViewModel extends Model<RepoViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+}
diff --git a/polygerrit-ui/app/models/views/repo_test.ts b/polygerrit-ui/app/models/views/repo_test.ts
new file mode 100644
index 0000000..2875ea7
--- /dev/null
+++ b/polygerrit-ui/app/models/views/repo_test.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {RepoName} from '../../api/rest-api';
+import '../../test/common-test-setup';
+import {createRepoUrl, RepoDetailView} from './repo';
+
+suite('repo view state tests', () => {
+  test('createRepoUrl', () => {
+    assert.equal(createRepoUrl({}), '/admin/repos/undefined');
+    assert.equal(
+      createRepoUrl({repo: 'asdf' as RepoName}),
+      '/admin/repos/asdf'
+    );
+    assert.equal(
+      createRepoUrl({
+        repo: 'asdf' as RepoName,
+        detail: RepoDetailView.ACCESS,
+      }),
+      '/admin/repos/asdf,access'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/models/views/search.ts b/polygerrit-ui/app/models/views/search.ts
new file mode 100644
index 0000000..c5d394d
--- /dev/null
+++ b/polygerrit-ui/app/models/views/search.ts
@@ -0,0 +1,259 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {combineLatest, fromEvent, Observable} from 'rxjs';
+import {
+  filter,
+  map,
+  startWith,
+  switchMap,
+  tap,
+  withLatestFrom,
+} from 'rxjs/operators';
+import {RepoName, BranchName, TopicName, ChangeInfo} from '../../api/rest-api';
+import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {GerritView} from '../../services/router/router-model';
+import {select} from '../../utils/observable-util';
+import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util';
+import {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {define, Provider} from '../dependency';
+import {Model} from '../model';
+import {UserModel} from '../user/user-model';
+import {ViewState} from './base';
+import {createChangeUrl} from './change';
+
+const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
+
+const REPO_QUERY_PATTERN =
+  /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+
+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
+];
+
+export interface SearchViewState extends ViewState {
+  view: GerritView.SEARCH;
+
+  /**
+   * The query for searching changes.
+   *
+   * Changing this to something non-empty will trigger search.
+   */
+  query: string;
+
+  /**
+   * How many initial search results should be skipped? This is for showing
+   * more than one search result page. This must be a non-negative number.
+   * If the string is not provided or cannot be parsed as expected, then the
+   * offset falls back to 0.
+   *
+   * TODO: Consider converting from string to number before writing to the
+   * state object.
+   */
+  offset?: string;
+
+  /**
+   * Is a search API call currrently in progress?
+   */
+  loading: boolean;
+
+  /**
+   * The search results for the current query.
+   */
+  changes: ChangeInfo[];
+}
+
+export interface SearchUrlOptions {
+  query?: string;
+  offset?: number;
+  repo?: RepoName;
+  branch?: BranchName;
+  topic?: TopicName;
+  statuses?: string[];
+  hashtag?: string;
+  owner?: string;
+}
+
+export function createSearchUrl(params: SearchUrlOptions): string {
+  let offsetExpr = '';
+  if (params.offset && params.offset > 0) {
+    offsetExpr = `,${params.offset}`;
+  }
+
+  if (params.query) {
+    return `${getBaseUrl()}/q/${encodeURL(params.query, true)}${offsetExpr}`;
+  }
+
+  const operators: string[] = [];
+  if (params.owner) {
+    operators.push('owner:' + encodeURL(params.owner, false));
+  }
+  if (params.repo) {
+    operators.push('project:' + encodeURL(params.repo, false));
+  }
+  if (params.branch) {
+    operators.push('branch:' + encodeURL(params.branch, false));
+  }
+  if (params.topic) {
+    operators.push(
+      'topic:' +
+        escapeAndWrapSearchOperatorValue(encodeURL(params.topic, false))
+    );
+  }
+  if (params.hashtag) {
+    operators.push(
+      'hashtag:' +
+        escapeAndWrapSearchOperatorValue(
+          encodeURL(params.hashtag.toLowerCase(), false)
+        )
+    );
+  }
+  if (params.statuses) {
+    if (params.statuses.length === 1) {
+      operators.push('status:' + encodeURL(params.statuses[0], false));
+    } else if (params.statuses.length > 1) {
+      operators.push(
+        '(' +
+          params.statuses
+            .map(s => `status:${encodeURL(s, false)}`)
+            .join(' OR ') +
+          ')'
+      );
+    }
+  }
+
+  return `${getBaseUrl()}/q/${operators.join('+')}${offsetExpr}`;
+}
+
+export const searchViewModelToken =
+  define<SearchViewModel>('search-view-model');
+
+/**
+ * This is the view model for the search page.
+ *
+ * It keeps track of the overall search view state and provides selectors for
+ * subscribing to certain slices of the state.
+ *
+ * It manages loading the changes to be shown on the search page by providing
+ * `changes` in its state. Changes to the view state or certain user preferences
+ * will automatically trigger reloading the changes.
+ */
+export class SearchViewModel extends Model<SearchViewState | undefined> {
+  public readonly query$ = select(this.state$, s => s?.query ?? '');
+
+  private readonly offset$ = select(this.state$, s => s?.offset ?? '0');
+
+  /**
+   * Convenience selector for getting the `offset` as a number.
+   *
+   * TODO: Consider changing the type of `offset$` and `state.offset` to
+   * `number`.
+   */
+  public readonly offsetNumber$ = select(this.offset$, offset => {
+    const offsetNumber = Number(offset);
+    return Number.isFinite(offsetNumber) ? offsetNumber : 0;
+  });
+
+  public readonly changes$ = select(this.state$, s => s?.changes ?? []);
+
+  public readonly userId$ = select(
+    combineLatest([this.query$, this.changes$]),
+    ([query, changes]) => {
+      if (changes.length === 0) return undefined;
+      if (!USER_QUERY_PATTERN.test(query)) return undefined;
+      const owner = changes[0].owner;
+      return owner?._account_id ?? owner?.email;
+    }
+  );
+
+  public readonly repo$ = select(
+    combineLatest([this.query$, this.changes$]),
+    ([query, changes]) => {
+      if (changes.length === 0) return undefined;
+      if (!REPO_QUERY_PATTERN.test(query)) return undefined;
+      return changes[0].project;
+    }
+  );
+
+  public readonly loading$ = select(this.state$, s => s?.loading ?? false);
+
+  // For usage in `combineLatest` we need `startWith` such that reload$ has an
+  // initial value.
+  private readonly reload$: Observable<unknown> = fromEvent(
+    document,
+    'reload'
+  ).pipe(startWith(undefined));
+
+  private readonly reloadChangesTrigger$ = combineLatest([
+    this.reload$,
+    this.query$,
+    this.offsetNumber$,
+    this.userModel.preferenceChangesPerPage$,
+  ]).pipe(
+    map(([_reload, query, offsetNumber, changesPerPage]) => {
+      const params: [string, number, number] = [
+        query,
+        offsetNumber,
+        changesPerPage,
+      ];
+      return params;
+    })
+  );
+
+  constructor(
+    private readonly restApiService: RestApiService,
+    private readonly userModel: UserModel,
+    private readonly getNavigation: Provider<NavigationService>
+  ) {
+    super(undefined);
+    this.subscriptions = [
+      this.reloadChangesTrigger$
+        .pipe(
+          switchMap(a => this.reloadChanges(a)),
+          tap(changes => this.updateState({changes, loading: false}))
+        )
+        .subscribe(),
+      this.changes$
+        .pipe(
+          filter(changes => changes.length === 1),
+          withLatestFrom(this.query$)
+        )
+        .subscribe(([changes, query]) =>
+          this.redirectSingleResult(query, changes)
+        ),
+    ];
+  }
+
+  private async reloadChanges([query, offset, changesPerPage]: [
+    string,
+    number,
+    number
+  ]): Promise<ChangeInfo[]> {
+    if (this.getState() === undefined) return [];
+    if (query.trim().length === 0) return [];
+    this.updateState({loading: true});
+    const changes = await this.restApiService.getChanges(
+      changesPerPage,
+      query,
+      offset
+    );
+    return changes ?? [];
+  }
+
+  // visible for testing
+  redirectSingleResult(query: string, changes: ChangeInfo[]): void {
+    if (changes.length !== 1) return;
+    for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
+      if (query.match(queryPattern)) {
+        // "Back"/"Forward" buttons work correctly only with replaceUrl()
+        this.getNavigation().replaceUrl(createChangeUrl({change: changes[0]}));
+        return;
+      }
+    }
+  }
+}
diff --git a/polygerrit-ui/app/models/views/search_test.ts b/polygerrit-ui/app/models/views/search_test.ts
new file mode 100644
index 0000000..6809225
--- /dev/null
+++ b/polygerrit-ui/app/models/views/search_test.ts
@@ -0,0 +1,205 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {SinonStub} from 'sinon';
+import {
+  AccountId,
+  BranchName,
+  EmailAddress,
+  NumericChangeId,
+  RepoName,
+  TopicName,
+} from '../../api/rest-api';
+import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
+import '../../test/common-test-setup';
+import {testResolver} from '../../test/common-test-setup';
+import {createChange} from '../../test/test-data-generators';
+import {
+  createSearchUrl,
+  SearchUrlOptions,
+  SearchViewModel,
+  searchViewModelToken,
+} from './search';
+
+const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
+const COMMIT_HASH = '12345678';
+
+suite('search view state tests', () => {
+  test('createSearchUrl', () => {
+    let options: SearchUrlOptions = {
+      owner: 'a%b',
+      repo: 'c%d' as RepoName,
+      branch: 'e%f' as BranchName,
+      topic: 'g%h' as TopicName,
+      statuses: ['op%en'],
+    };
+    assert.equal(
+      createSearchUrl(options),
+      '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+        'topic:"g%2525h"+status:op%2525en'
+    );
+
+    window.CANONICAL_PATH = '/base';
+    assert.equal(createSearchUrl(options).substring(0, 5), '/base');
+    window.CANONICAL_PATH = undefined;
+
+    options.offset = 100;
+    assert.equal(
+      createSearchUrl(options),
+      '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+        'topic:"g%2525h"+status:op%2525en,100'
+    );
+    delete options.offset;
+
+    // The presence of the query param overrides other options.
+    options.query = 'foo$bar';
+    assert.equal(createSearchUrl(options), '/q/foo%2524bar');
+
+    options.offset = 100;
+    assert.equal(createSearchUrl(options), '/q/foo%2524bar,100');
+
+    options = {statuses: ['a', 'b', 'c']};
+    assert.equal(
+      createSearchUrl(options),
+      '/q/(status:a OR status:b OR status:c)'
+    );
+
+    options = {topic: 'test' as TopicName};
+    assert.equal(createSearchUrl(options), '/q/topic:"test"');
+
+    options = {topic: 'test test' as TopicName};
+    assert.equal(createSearchUrl(options), '/q/topic:"test+test"');
+
+    options = {topic: 'test:test' as TopicName};
+    assert.equal(createSearchUrl(options), '/q/topic:"test:test"');
+  });
+
+  suite('query based navigation', () => {
+    let replaceUrlStub: SinonStub;
+    let model: SearchViewModel;
+
+    setup(() => {
+      model = testResolver(searchViewModelToken);
+      replaceUrlStub = sinon.stub(testResolver(navigationToken), 'replaceUrl');
+    });
+
+    teardown(() => {
+      model.finalize();
+    });
+
+    test('Searching for a change ID redirects to change', async () => {
+      const change = {...createChange(), _number: 1 as NumericChangeId};
+
+      model.redirectSingleResult(CHANGE_ID, [change]);
+
+      assert.isTrue(replaceUrlStub.called);
+      assert.equal(replaceUrlStub.lastCall.firstArg, '/c/test-project/+/1');
+    });
+
+    test('Searching for a change num redirects to change', async () => {
+      const change = {...createChange(), _number: 1 as NumericChangeId};
+
+      model.redirectSingleResult('1', [change]);
+
+      assert.isTrue(replaceUrlStub.called);
+      assert.equal(replaceUrlStub.lastCall.firstArg, '/c/test-project/+/1');
+    });
+
+    test('Commit hash redirects to change', async () => {
+      const change = {...createChange(), _number: 1 as NumericChangeId};
+
+      model.redirectSingleResult(COMMIT_HASH, [change]);
+
+      assert.isTrue(replaceUrlStub.called);
+      assert.equal(replaceUrlStub.lastCall.firstArg, '/c/test-project/+/1');
+    });
+
+    test('No results: no redirect', async () => {
+      model.redirectSingleResult(CHANGE_ID, []);
+
+      assert.isFalse(replaceUrlStub.called);
+    });
+
+    test('More than 1 result: no redirect', async () => {
+      const change1 = {...createChange(), _number: 1 as NumericChangeId};
+      const change2 = {...createChange(), _number: 2 as NumericChangeId};
+
+      model.redirectSingleResult(CHANGE_ID, [change1, change2]);
+
+      assert.isFalse(replaceUrlStub.called);
+    });
+  });
+
+  suite('selectors', () => {
+    let model: SearchViewModel;
+    let userId: AccountId | EmailAddress | undefined;
+    let repo: RepoName | undefined;
+
+    setup(() => {
+      model = testResolver(searchViewModelToken);
+      model.userId$.subscribe(x => (userId = x));
+      model.repo$.subscribe(x => (repo = x));
+    });
+
+    teardown(() => {
+      model.finalize();
+    });
+
+    test('userId', async () => {
+      assert.isUndefined(userId);
+
+      model.updateState({
+        query: 'owner: foo@bar',
+        changes: [
+          {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+        ],
+      });
+      assert.equal(userId, 'foo@bar' as EmailAddress);
+
+      model.updateState({
+        query: 'foo bar baz',
+        changes: [
+          {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+        ],
+      });
+      assert.isUndefined(userId);
+
+      model.updateState({
+        query: 'owner: foo@bar',
+        changes: [{...createChange(), owner: {}}],
+      });
+      assert.isUndefined(userId);
+    });
+
+    test('repo', async () => {
+      assert.isUndefined(repo);
+
+      model.updateState({
+        query: 'foo bar baz',
+        changes: [{...createChange(), project: 'test-repo' as RepoName}],
+      });
+      assert.isUndefined(repo);
+
+      model.updateState({
+        query: 'foo bar baz',
+        changes: [{...createChange()}],
+      });
+      assert.isUndefined(repo);
+
+      model.updateState({
+        query: 'project: test-repo',
+        changes: [{...createChange(), project: 'test-repo' as RepoName}],
+      });
+      assert.equal(repo, 'test-repo' as RepoName);
+
+      model.updateState({
+        query: 'project:test-repo status:open',
+        changes: [{...createChange(), project: 'test-repo' as RepoName}],
+      });
+      assert.equal(repo, 'test-repo' as RepoName);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/models/views/settings.ts b/polygerrit-ui/app/models/views/settings.ts
new file mode 100644
index 0000000..c1a8c08
--- /dev/null
+++ b/polygerrit-ui/app/models/views/settings.ts
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GerritView} from '../../services/router/router-model';
+import {select} from '../../utils/observable-util';
+import {getBaseUrl} from '../../utils/url-util';
+import {define} from '../dependency';
+import {Model} from '../model';
+import {ViewState} from './base';
+
+export interface SettingsViewState extends ViewState {
+  view: GerritView.SETTINGS;
+  emailToken?: string;
+}
+
+export function createSettingsUrl() {
+  return getBaseUrl() + '/settings';
+}
+
+export const settingsViewModelToken = define<SettingsViewModel>(
+  'settings-view-model'
+);
+
+export class SettingsViewModel extends Model<SettingsViewState | undefined> {
+  constructor() {
+    super(undefined);
+  }
+
+  public emailToken$ = select(this.state$, state => state?.emailToken);
+
+  clearToken() {
+    this.updateState({emailToken: undefined});
+  }
+}
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
index 44b9811..77400c6 100644
--- a/polygerrit-ui/app/node_modules_licenses/BUILD
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -1,4 +1,4 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//@bazel/concatjs:index.bzl", "ts_library")
 load("//tools/node_tools/node_modules_licenses:node_modules_licenses.bzl", "node_modules_licenses")
 
 filegroup(
@@ -6,6 +6,8 @@
     srcs = glob(["licenses/*.txt"]),
 )
 
+# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
+# We would prefer to not depend on @bazel/concatjs ...
 ts_library(
     name = "licenses-config",
     srcs = [
@@ -15,8 +17,7 @@
     tsconfig = "tsconfig.json",
     deps = [
         "//tools/node_tools/node_modules_licenses:licenses-map",
-        "@tools_npm//@bazel/typescript",
-        "@tools_npm//@types/node",
+        "@tools_npm//:node_modules",
     ],
 )
 
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index ede84ff..b5b313e 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -1,44 +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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // Ugly import path due to the following bugs:
 // https://github.com/bazelbuild/rules_nodejs/issues/1522
 // https://github.com/bazelbuild/rules_nodejs/issues/1380
-import {PackageInfo, LicenseType, LicenseInfo} from "../../../tools/node_tools/node_modules_licenses/package-license-info";
-import * as path from "path";
+import {
+  PackageInfo,
+  LicenseType,
+  LicenseInfo,
+} from '../../../tools/node_tools/node_modules_licenses/package-license-info';
+import * as path from 'path';
 
 class LicenseTypes {
   public static Mit: LicenseType = {
-    name: "MIT",
-    allowed: true
+    name: 'MIT',
+    allowed: true,
   };
   public static Apache2_0: LicenseType = {
-    name: "Apache 2.0",
-    allowed: true
+    name: 'Apache 2.0',
+    allowed: true,
   };
 
   public static Bsd3: LicenseType = {
-    name: "BSD-3-Clause",
-    allowed: true
+    name: 'BSD-3-Clause',
+    allowed: true,
   };
 
   public static BsdZeroClause: LicenseType = {
-    name: "BSD-Zero-Clause",
-    allowed: true
+    name: 'BSD-Zero-Clause',
+    allowed: true,
   };
 }
 
@@ -46,387 +39,452 @@
  * in package. For details - see comments for {@link LicenseInfo} and {@link PackageInfo} */
 class SharedLicenses {
   public static Lit: LicenseInfo = {
-    name: "Lit",
+    name: 'Lit',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "lit.txt",
+    sharedLicenseFile: 'lit.txt',
   };
 
   public static Polymer2014: LicenseInfo = {
-    name: "Polymer-2014",
+    name: 'Polymer-2014',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "polymer-2014.txt",
+    sharedLicenseFile: 'polymer-2014.txt',
   };
 
   public static Polymer2015: LicenseInfo = {
-    name: "Polymer-2015",
+    name: 'Polymer-2015',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "polymer-2015.txt",
+    sharedLicenseFile: 'polymer-2015.txt',
   };
 
   public static Polymer2016: LicenseInfo = {
-    name: "Polymer-2016",
+    name: 'Polymer-2016',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "polymer-2016.txt",
+    sharedLicenseFile: 'polymer-2016.txt',
   };
 
   public static Polymer2017: LicenseInfo = {
-    name: "Polymer-2017",
+    name: 'Polymer-2017',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "polymer-2017.txt",
+    sharedLicenseFile: 'polymer-2017.txt',
   };
 
   public static Polymer2018: LicenseInfo = {
-    name: "Polymer-2018",
+    name: 'Polymer-2018',
     type: LicenseTypes.Bsd3,
-    sharedLicenseFile: "polymer-2018.txt",
+    sharedLicenseFile: 'polymer-2018.txt',
   };
 
   public static IsArray: LicenseInfo = {
-    name: "isarray",
+    name: 'isarray',
     type: LicenseTypes.Mit,
-    sharedLicenseFile: "isarray.txt"
+    sharedLicenseFile: 'isarray.txt',
   };
 
   public static Page: LicenseInfo = {
-    name: "page",
+    name: 'page',
     type: LicenseTypes.Mit,
-    sharedLicenseFile: "page.txt"
-  }
+    sharedLicenseFile: 'page.txt',
+  };
 }
 
 const fontsRobotoFilter = (fileName: string) =>
-    fileName.startsWith("fonts/roboto/") && path.basename(fileName) !== "DESCRIPTION.en_us.html";
+  fileName.startsWith('fonts/roboto/') &&
+  path.basename(fileName) !== 'DESCRIPTION.en_us.html';
 
 const fontsRobotomonoFilter = (fileName: string) =>
-    fileName.startsWith("fonts/robotomono/") && path.basename(fileName) !== "DESCRIPTION.en_us.html";
-
+  fileName.startsWith('fonts/robotomono/') &&
+  path.basename(fileName) !== 'DESCRIPTION.en_us.html';
 
 const packages: PackageInfo[] = [
   {
-    name: "@lit/reactive-element",
+    name: '@lit/reactive-element',
     license: SharedLicenses.Lit,
   },
   {
-    name: "@polymer/decorators",
+    name: '@polymer/decorators',
     license: SharedLicenses.Polymer2017,
   },
   {
-    name: "@polymer/font-roboto",
+    name: '@polymer/font-roboto',
     license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/font-roboto-local",
+    name: '@polymer/font-roboto-local',
     license: SharedLicenses.Polymer2015,
-    filesFilter: fileName => !fontsRobotoFilter(fileName) && !fontsRobotomonoFilter(fileName)
+    filesFilter: fileName =>
+      !fontsRobotoFilter(fileName) && !fontsRobotomonoFilter(fileName),
   },
   {
-    name: "@polymer/font-roboto-local",
+    name: '@polymer/font-roboto-local',
     license: {
-      name: "font-roboto-local-fonts-roboto",
+      name: 'font-roboto-local-fonts-roboto',
       type: LicenseTypes.Apache2_0,
-      packageLicenseFile: "fonts/roboto/LICENSE.txt"
+      packageLicenseFile: 'fonts/roboto/LICENSE.txt',
     },
-    filesFilter: fontsRobotoFilter
+    filesFilter: fontsRobotoFilter,
   },
   {
-    name: "@polymer/font-roboto-local",
+    name: '@polymer/font-roboto-local',
     license: {
-      name: "font-roboto-local-fonts-robotomono",
+      name: 'font-roboto-local-fonts-robotomono',
       type: LicenseTypes.Apache2_0,
-      packageLicenseFile: "fonts/robotomono/LICENSE.txt"
+      packageLicenseFile: 'fonts/robotomono/LICENSE.txt',
     },
-    filesFilter: fontsRobotomonoFilter
+    filesFilter: fontsRobotomonoFilter,
   },
   {
-    name: "@polymer/iron-a11y-announcer",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-a11y-announcer',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-a11y-keys-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-a11y-keys-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-autogrow-textarea",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-autogrow-textarea',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-behaviors",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-behaviors',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-checked-element-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-checked-element-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-dropdown",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-dropdown',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-fit-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-fit-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-flex-layout",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-flex-layout',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-form-element-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-form-element-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-icon",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-icon',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-iconset-svg",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-iconset-svg',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-image",
-    license: SharedLicenses.Polymer2016
+    name: '@polymer/iron-image',
+    license: SharedLicenses.Polymer2016,
   },
   {
-    name: "@polymer/iron-input",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-input',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-menu-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-menu-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-meta",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-meta',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-overlay-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-overlay-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-resizable-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-resizable-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-selector",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-selector',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/iron-validatable-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/iron-validatable-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/neon-animation",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/marked-element',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-behaviors",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/neon-animation',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-button",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-behaviors',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-card",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-button',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-checkbox",
-    license: SharedLicenses.Polymer2016
+    name: '@polymer/paper-card',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-dialog",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-checkbox',
+    license: SharedLicenses.Polymer2016,
   },
   {
-    name: "@polymer/paper-dialog-behavior",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-dialog',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-dialog-scrollable",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-dialog-behavior',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-dropdown-menu",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-dialog-scrollable',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-fab",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-dropdown-menu',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-icon-button",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-fab',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-input",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-icon-button',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-item",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-input',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-listbox",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-item',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-menu-button",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-listbox',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-ripple",
-    license: SharedLicenses.Polymer2014
+    name: '@polymer/paper-menu-button',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-styles",
-    license: SharedLicenses.Polymer2014
+    name: '@polymer/paper-ripple',
+    license: SharedLicenses.Polymer2014,
   },
   {
-    name: "@polymer/paper-tabs",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-styles',
+    license: SharedLicenses.Polymer2014,
   },
   {
-    name: "@polymer/paper-toggle-button",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-tabs',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/paper-tooltip",
-    license: SharedLicenses.Polymer2015
+    name: '@polymer/paper-toggle-button',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@polymer/polymer",
-    license: SharedLicenses.Polymer2017
+    name: '@polymer/paper-tooltip',
+    license: SharedLicenses.Polymer2015,
   },
   {
-    name: "@types/resemblejs",
+    name: '@polymer/polymer',
+    license: SharedLicenses.Polymer2017,
+  },
+  {
+    name: '@types/resemblejs',
     license: {
       name: 'DefinitelyTyped',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "@types/resize-observer-browser",
+    name: '@types/resize-observer-browser',
     license: {
       name: 'DefinitelyTyped',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "@types/trusted-types",
+    name: '@types/trusted-types',
     license: {
       name: 'DefinitelyTyped',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "@webcomponents/shadycss",
-    license: SharedLicenses.Polymer2017
+    name: '@webcomponents/shadycss',
+    license: SharedLicenses.Polymer2017,
   },
   {
-    name: "@webcomponents/webcomponentsjs",
-    license: SharedLicenses.Polymer2018
+    name: '@webcomponents/webcomponentsjs',
+    license: SharedLicenses.Polymer2018,
   },
   {
-    name: "ba-linkify",
+    name: 'ba-linkify',
     license: {
-      name: "ba-linkify",
+      name: 'ba-linkify',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE-MIT",
-    }
+      packageLicenseFile: 'LICENSE-MIT',
+    },
   },
   {
-    name: "codemirror-minified",
+    name: 'codemirror-minified',
     license: {
-      name: "codemirror-minified",
+      name: 'codemirror-minified',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE",
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "isarray",
-    license: SharedLicenses.IsArray
+    name: 'isarray',
+    license: SharedLicenses.IsArray,
   },
   {
-    name: "page",
-    license: SharedLicenses.Page
+    name: 'page',
+    license: SharedLicenses.Page,
   },
   {
-    name: "shadow-selection-polyfill",
+    name: 'shadow-selection-polyfill',
     license: {
-      name: "shadow-selection-polyfill",
+      name: 'shadow-selection-polyfill',
       type: LicenseTypes.Apache2_0,
-      packageLicenseFile: "LICENSE"
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "path-to-regexp",
+    name: 'path-to-regexp',
     license: {
-      name: "path-to-regexp",
+      name: 'path-to-regexp',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE"
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "polymer-resin",
-    license: SharedLicenses.Polymer2018
+    name: 'polymer-resin',
+    license: SharedLicenses.Polymer2018,
   },
   {
-    name: "polymer-bridges",
-    license: SharedLicenses.Polymer2018
+    name: 'polymer-bridges',
+    license: SharedLicenses.Polymer2018,
   },
   {
-    name: "rxjs",
+    name: 'web-vitals',
     license: {
-      name: "rxjs",
+      name: 'web-vitals',
       type: LicenseTypes.Apache2_0,
-      packageLicenseFile: "LICENSE.txt"
+      packageLicenseFile: 'LICENSE',
+    },
+  },
+  {
+    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"],
+      'ajax',
+      'fetch',
+      'internal-compatibility',
+      'operators',
+      'testing',
+      'webSocket',
+      'src/ajax',
+      'src/fetch',
+      'src/internal-compatibility',
+      'src/operators',
+      'src/testing',
+      'src/webSocket',
+    ],
   },
   {
-    name: "lit",
+    name: 'lit',
     license: SharedLicenses.Lit,
   },
   {
-    name: "lit-element",
+    name: 'lit-element',
     license: SharedLicenses.Lit,
   },
   {
-    name: "lit-html",
+    name: 'lit-html',
     license: SharedLicenses.Lit,
   },
   {
-    name: "tslib",
+    name: 'tslib',
     license: {
-      name: "tslib",
+      name: 'tslib',
       type: LicenseTypes.BsdZeroClause,
-      packageLicenseFile: "LICENSE.txt"
+      packageLicenseFile: 'LICENSE.txt',
     },
-    nonPackages: ["modules", "test/validateModuleExportsMatchCommonJS"],
+    nonPackages: ['modules', 'test/validateModuleExportsMatchCommonJS'],
   },
   {
-    name: "resemblejs",
+    name: 'resemblejs',
     license: {
-      name: "resemblejs",
+      name: 'resemblejs',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE",
-    }
+      packageLicenseFile: 'LICENSE',
+    },
   },
   {
-    name: "immer",
+    name: 'immer',
     license: {
-      name: "immer",
+      name: 'immer',
       type: LicenseTypes.Mit,
-      packageLicenseFile: "LICENSE",
-    }
-  }
+      packageLicenseFile: 'LICENSE',
+    },
+  },
+  {
+    name: 'highlight.js',
+    license: {
+      name: 'highlight.js',
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: 'LICENSE',
+    },
+    nonPackages: ['es'],
+  },
+  {
+    name: 'highlightjs-closure-templates',
+    license: {
+      name: 'highlightjs-closure-templates',
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: 'LICENSE',
+    },
+  },
+  {
+    name: 'highlightjs-structured-text',
+    license: {
+      name: 'highlightjs-structured-text',
+      type: LicenseTypes.Bsd3,
+      packageLicenseFile: 'LICENSE',
+    },
+  },
+  {
+    name: 'marked',
+    license: {
+      name: 'marked',
+      type: LicenseTypes.Mit,
+      packageLicenseFile: 'LICENSE.md',
+    },
+  },
+  {
+    name: 'safevalues',
+    license: {
+      name: 'safevalues',
+      type: LicenseTypes.Apache2_0,
+      packageLicenseFile: 'LICENSE',
+    },
+  },
 ];
 
 export default packages;
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 281a1eb..392b5a8 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -12,8 +12,8 @@
     "@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/marked-element": "^3.0.1",
     "@polymer/paper-button": "^3.0.1",
     "@polymer/paper-card": "^3.0.1",
     "@polymer/paper-checkbox": "^3.1.0",
@@ -35,14 +35,19 @@
     "@webcomponents/shadycss": "^1.10.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "^1.0.1",
-    "codemirror-minified": "^5.62.2",
+    "codemirror-minified": "^5.65.0",
+    "highlight.js": "^11.5.0",
+    "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates",
+    "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text",
     "immer": "^9.0.5",
-    "lit": "2.0.2",
+    "lit": "^2.2.3",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
     "resemblejs": "^4.0.0",
-    "rxjs": "^6.6.7"
+    "rxjs": "^6.6.7",
+    "safevalues": "^0.3.1",
+    "web-vitals": "^2.1.4"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/app/plugins/README.md b/polygerrit-ui/app/plugins/README.md
new file mode 100644
index 0000000..6de8ee3
--- /dev/null
+++ b/polygerrit-ui/app/plugins/README.md
@@ -0,0 +1,3 @@
+This directory exists for loading plugins from.
+
+It should not contain actual code as it's .gitignore'd.
diff --git a/polygerrit-ui/app/polylint_test.sh b/polygerrit-ui/app/polylint_test.sh
deleted file mode 100755
index 0c7118d..0000000
--- a/polygerrit-ui/app/polylint_test.sh
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/bash
-
-set -ex
-
-DIR=$(pwd)
-ln -s $RUNFILES_DIR/ui_npm/node_modules $TEST_TMPDIR/node_modules
-cp $2 $TEST_TMPDIR/polymer.json
-cp -R -L polygerrit-ui/app/_pg_ts_out/* $TEST_TMPDIR
-
-#Can't use --root with polymer.json - see https://github.com/Polymer/tools/issues/2616
-#Change current directory to the root folder
-cd $TEST_TMPDIR/
-$DIR/$1 lint --verbose
diff --git a/polygerrit-ui/app/polymer.json b/polygerrit-ui/app/polymer.json
deleted file mode 100644
index 4348ba8..0000000
--- a/polygerrit-ui/app/polymer.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
-  "shell": "elements/gr-app.js",
-  "sources": [
-    "elements/**/*",
-    "mixins/**/*",
-    "scripts/**/*",
-    "styles/*",
-    "types/**/*"
-  ],
-  "lint": {
-    "rules": ["polymer-3"],
-    "ignoreWarnings": [
-      "deprecated-dom-call",
-      "multiple-global-declarations"
-    ],
-    "filesToIgnore": [
-      "**/gr-plugin-rest-api.js",
-      "**/.cache/**/gr-plugin-rest-api.js"
-    ]
-  }
-}
diff --git a/polygerrit-ui/app/rollup.config.js b/polygerrit-ui/app/rollup.config.js
index d93b5ea..be60a63 100644
--- a/polygerrit-ui/app/rollup.config.js
+++ b/polygerrit-ui/app/rollup.config.js
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 const path = require('path');
@@ -35,12 +24,13 @@
 // file as rollup node module.
 function requirePlugin(id) {
   const rollupBinDir = path.dirname(process.argv[1]);
-  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir] });
+  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir]});
   return require(pluginPath);
 }
 
 const resolve = requirePlugin('rollup-plugin-node-resolve');
 const {terser} = requirePlugin('rollup-plugin-terser');
+const define = requirePlugin('rollup-plugin-define');
 
 // @polymer/font-roboto-local uses import.meta.url value
 // as a base path to fonts. We should substitute a correct javascript
@@ -48,13 +38,13 @@
 const importLocalFontMetaUrlResolver = function() {
   return {
     name: 'import-meta-url-resolver',
-    resolveImportMeta: function (property, data) {
-      if(property === 'url' && data.moduleId.endsWith('/@polymer/font-roboto-local/roboto.js')) {
+    resolveImportMeta(property, data) {
+      if (property === 'url' && data.moduleId.endsWith('/@polymer/font-roboto-local/roboto.js')) {
         return 'new URL("..", document.baseURI).href';
       }
       return null;
-    }
-  }
+    },
+  };
 };
 
 export default {
@@ -72,12 +62,12 @@
     plugins: [
       terser({
         output: {
-          comments: false
-        }
-      })
-    ]
+          comments: false,
+        },
+      }),
+    ],
   },
-  //Context must be set to window to correctly processing global variables
+  // Context must be set to window to correctly processing global variables
   context: 'window',
   plugins: [resolve({
     customResolveOptions: {
@@ -85,6 +75,12 @@
       // when importing 'page/page'.
       extensions: ['.js'],
       moduleDirectory: 'external/ui_npm/node_modules',
-    }
-  }), importLocalFontMetaUrlResolver()],
+    },
+  }),
+  define({
+     replacements: {
+       'process.env.NODE_ENV': JSON.stringify('production'),
+     },
+  }),
+  importLocalFontMetaUrlResolver()],
 };
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 401c0c3..9ab0f64 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -26,8 +26,38 @@
         config_file = ":rollup.config.js",
         entry_point = entry_point,
         rollup_bin = "//tools/node_tools:rollup-bin",
+        silent = True,
         sourcemap = "hidden",
         deps = [
+            "@tools_npm//rollup-plugin-define",
+            "@tools_npm//rollup-plugin-node-resolve",
+        ],
+    )
+
+    rollup_bundle(
+        name = "syntax-worker",
+        srcs = [app_name + "-full-src"],
+        config_file = ":rollup.config.js",
+        entry_point = "_pg_ts_out/workers/syntax-worker.js",
+        rollup_bin = "//tools/node_tools:rollup-bin",
+        silent = True,
+        sourcemap = "hidden",
+        deps = [
+            "@tools_npm//rollup-plugin-define",
+            "@tools_npm//rollup-plugin-node-resolve",
+        ],
+    )
+
+    rollup_bundle(
+        name = "service-worker",
+        srcs = [app_name + "-full-src"],
+        config_file = ":rollup.config.js",
+        entry_point = "_pg_ts_out/workers/service-worker.js",
+        rollup_bin = "//tools/node_tools:rollup-bin",
+        silent = True,
+        sourcemap = "hidden",
+        deps = [
+            "@tools_npm//rollup-plugin-define",
             "@tools_npm//rollup-plugin-node-resolve",
         ],
     )
@@ -45,6 +75,14 @@
     )
 
     native.filegroup(
+        name = name + "_worker_sources",
+        srcs = [
+            "syntax-worker.js",
+            "service-worker.js",
+        ],
+    )
+
+    native.filegroup(
         name = name + "_top_sources",
         srcs = [
             "favicon.ico",
@@ -59,6 +97,8 @@
             name + "_app_sources",
             name + "_css_sources",
             name + "_top_sources",
+            name + "_worker_sources",
+            "//lib/fonts:material-icons",
             "//lib/fonts:robotofonts",
             "//lib/js:highlightjs__files",
             "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js",
@@ -70,11 +110,13 @@
         outs = outs,
         cmd = " && ".join([
             "FONT_DIR=$$(dirname $(location @ui_npm//:node_modules/@polymer/font-roboto-local/package.json))/fonts",
-            "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts/{roboto,robotomono},bower_components/{highlightjs,webcomponentsjs,resemblejs},elements}",
+            "mkdir -p $$TMP/polygerrit_ui/{workers,styles/themes,fonts/{roboto,robotomono},bower_components/{highlightjs,webcomponentsjs,resemblejs},elements}",
             "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + app_name + ".$$ext; done",
             "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
+            "cp $(locations //lib/fonts:material-icons) $$TMP/polygerrit_ui/fonts/",
             "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
             "for f in $(locations " + name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
+            "for f in $(locations " + name + "_worker_sources); do cp $$f $$TMP/polygerrit_ui/workers; done",
             "for f in $(locations //lib/js:highlightjs__files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
             "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js",
             "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js.map) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js.map",
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index 0ceca3c..bd87000 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -9,8 +9,10 @@
 # At least temporarily we want to know what is going on even when all tests are
 # passing, so we have a better chance of debugging what happens in CI test runs
 # that were supposed to catch test failures, but did not.
+# Run type checker before testing
+${bazel_bin} build //polygerrit-ui/app:compile_pg_with_tests && \
 ${bazel_bin} test \
       "$@" \
       --test_verbose_timeout_warnings \
       --test_output=all \
-      //polygerrit-ui:karma_test
+      //polygerrit-ui:web-test-runner
diff --git a/polygerrit-ui/app/samples/add-from-favorite-reviewers.js b/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
deleted file mode 100644
index 1616ef3..0000000
--- a/polygerrit-ui/app/samples/add-from-favorite-reviewers.js
+++ /dev/null
@@ -1,146 +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 plugin will a button to quickly add favorite reviewers to
- * reviewers in reply dialog.
- */
-
-const onToggleButtonClicks = [];
-function toggleButtonClicked(expanded) {
-  onToggleButtonClicks.forEach(cb => {
-    cb(expanded);
-  });
-}
-
-class ReviewerShortcut extends Polymer.Element {
-  static get is() { return 'reviewer-shortcut'; }
-
-  static get properties() {
-    return {
-      change: Object,
-      expanded: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-      <button on-click="toggleControlContent">
-        [[computeButtonText(expanded)]]
-      </button>
-    `;
-  }
-
-  toggleControlContent() {
-    this.expanded = !this.expanded;
-    toggleButtonClicked(this.expanded);
-  }
-
-  computeButtonText(expanded) {
-    return expanded ? 'Collapse' : 'Add favorite reviewers';
-  }
-}
-
-customElements.define(ReviewerShortcut.is, ReviewerShortcut);
-
-class ReviewerShortcutContent extends Polymer.Element {
-  static get is() { return 'reviewer-shortcut-content'; }
-
-  static get properties() {
-    return {
-      change: Object,
-      hidden: {
-        type: Boolean,
-        value: true,
-        reflectToAttribute: true,
-      },
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-      <style>
-      :host([hidden]) {
-        display: none;
-      }
-      :host {
-        display: block;
-      }
-      </style>
-      <ul>
-        <li><button on-click="addApple">Apple</button></li>
-        <li><button on-click="addBanana">Banana</button></li>
-        <li><button on-click="addCherry">Cherry</button></li>
-      </ul>
-    `;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    onToggleButtonClicks.push(expanded => {
-      this.hidden = !expanded;
-    });
-  }
-
-  addApple() {
-    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
-      reviewer: {
-        display_name: 'Apple',
-        email: 'apple@gmail.com',
-        name: 'Apple',
-        _account_id: 0,
-      },
-    },
-    composed: true, bubbles: true}));
-  }
-
-  addBanana() {
-    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
-      reviewer: {
-        display_name: 'Banana',
-        email: 'banana@gmail.com',
-        name: 'B',
-        _account_id: 1,
-      },
-    },
-    composed: true, bubbles: true}));
-  }
-
-  addCherry() {
-    this.dispatchEvent(new CustomEvent('add-reviewer', {detail: {
-      reviewer: {
-        display_name: 'Cherry',
-        email: 'cherry@gmail.com',
-        name: 'C',
-        _account_id: 2,
-      },
-    },
-    composed: true, bubbles: true}));
-  }
-}
-
-customElements.define(ReviewerShortcutContent.is, ReviewerShortcutContent);
-
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent(
-      'reply-reviewers', ReviewerShortcut.is, {slot: 'right'});
-  plugin.registerCustomComponent(
-      'reply-reviewers', ReviewerShortcutContent.is, {slot: 'below'});
-});
diff --git a/polygerrit-ui/app/samples/bind-parameters.js b/polygerrit-ui/app/samples/bind-parameters.js
deleted file mode 100644
index 4527a80..0000000
--- a/polygerrit-ui/app/samples/bind-parameters.js
+++ /dev/null
@@ -1,63 +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.
- */
-class MyBindSample extends Polymer.Element {
-  static get is() { return 'my-bind-sample'; }
-
-  static get properties() {
-    return {
-      computedExample: {
-        type: String,
-        computed: '_computeExample(revision._number)',
-      },
-      revision: {
-        type: Object,
-        observer: '_onRevisionChanged',
-      },
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-    Template example: Patchset number [[revision._number]]. <br/>
-    Computed example: [[computedExample]].
-    `;
-  }
-
-  _computeExample(value) {
-    if (!value) { return '(empty)'; }
-    return `(patchset ${value} selected)`;
-  }
-
-  _onRevisionChanged(value) {
-    console.info(`(attributeHelper.bind) revision number: ${value._number}`);
-  }
-}
-
-// register the custom component
-customElements.define(MyBindSample.is, MyBindSample);
-
-/**
- * This plugin will add a new section
- * between the file list and change log with the
- * `my-bind-sample` component.
- */
-Gerrit.install(plugin => {
-  // You should see the above text with the right revision number shown
-  // between the file list and the change log
-  plugin.registerCustomComponent(
-      'change-view-integration', 'my-bind-sample');
-});
diff --git a/polygerrit-ui/app/samples/custom-wip-requirement.js b/polygerrit-ui/app/samples/custom-wip-requirement.js
deleted file mode 100644
index 1d2663c..0000000
--- a/polygerrit-ui/app/samples/custom-wip-requirement.js
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * This plugin will add a text next to WIP requirement if shown.
- */
-class WipRequirementValue extends Polymer.Element {
-  static get is() {
-    return 'wip-requirement-value';
-  }
-
-  static get template() {
-    return Polymer.html`
-        <style include="shared-styles">
-        :host {
-          color: var(--deemphasized-text-color);
-        }
-        </style>
-        <span>Will be removed once active.</span>
-      `;
-  }
-
-  static get properties() {
-    return {
-      change: Object,
-      requirement: Object,
-    };
-  }
-}
-
-customElements.define(WipRequirementValue.is, WipRequirementValue);
-
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent(
-      'submit-requirement-item-wip', WipRequirementValue.is, {slot: 'value'});
-});
\ 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
deleted file mode 100644
index c64bcd4..0000000
--- a/polygerrit-ui/app/samples/extra-column-on-file-list.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-/**
- * This plugin will an extra column to file list on change page to show
- * the first character of the path.
- */
-
-// Header of this extra column
-class ColumnHeader extends Polymer.Element {
-  static get is() { return 'column-header'; }
-
-  static get template() {
-    return Polymer.html`
-      <style>
-      :host {
-        display: block;
-        padding-right: var(--spacing-m);
-        min-width: 5em;
-      }
-      </style>
-      <div>First Char</div>
-    `;
-  }
-}
-
-customElements.define(ColumnHeader.is, ColumnHeader);
-
-// Content of this extra column
-class ColumnContent extends Polymer.Element {
-  static get is() { return 'column-content'; }
-
-  static get properties() {
-    return {
-      path: String,
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-      <style>
-      :host {
-        display:block;
-        padding-right: var(--spacing-m);
-        min-width: 5em;
-      }
-      </style>
-      <div>[[getStatus(path)]]</div>
-    `;
-  }
-
-  getStatus(path) {
-    return path.charAt(0);
-  }
-}
-
-customElements.define(ColumnContent.is, ColumnContent);
-
-Gerrit.install(plugin => {
-  plugin.registerDynamicCustomComponent(
-      'change-view-file-list-header-prepend', ColumnHeader.is);
-  plugin.registerDynamicCustomComponent(
-      'change-view-file-list-content-prepend', ColumnContent.is);
-});
diff --git a/polygerrit-ui/app/samples/lgtm-plugin.js b/polygerrit-ui/app/samples/lgtm-plugin.js
deleted file mode 100644
index 537b1fa..0000000
--- a/polygerrit-ui/app/samples/lgtm-plugin.js
+++ /dev/null
@@ -1,33 +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 plugin will +1 on Code-Review label if it detects that you have
- * LGTM as start of your reply.
- */
-Gerrit.install(plugin => {
-  const replyApi = plugin.changeReply();
-  replyApi.addReplyTextChangedCallback(text => {
-    const label = 'Code-Review';
-    const labelValue = replyApi.getLabelValue(label);
-    if (labelValue &&
-      labelValue === ' 0' &&
-      text.indexOf('LGTM') === 0) {
-      replyApi.setLabelValue(label, '+1');
-    }
-  });
-});
diff --git a/polygerrit-ui/app/samples/repo-command.js b/polygerrit-ui/app/samples/repo-command.js
deleted file mode 100644
index 6abb120..0000000
--- a/polygerrit-ui/app/samples/repo-command.js
+++ /dev/null
@@ -1,60 +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.
- */
-
-class RepoCommandLow extends Polymer.Element {
-  static get is() { return 'repo-command-low'; }
-
-  static get properties() {
-    return {
-      rootUrl: String,
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-      <style include="shared-styles">
-      :host {
-        display: block;
-        margin-bottom: var(--spacing-xxl);
-      }
-      </style>
-      <h3 class="heading-3">Plugin Bork</h3>
-      <gr-button on-click="_handleCommandTap">Bork</gr-button>
-    `;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    console.info(this.repoName);
-    console.info(this.config);
-    this.hidden = this.repoName !== 'All-Projects';
-  }
-
-  _handleCommandTap() {
-    alert('bork');
-  }
-}
-
-customElements.define(RepoCommandLow.is, RepoCommandLow);
-
-/**
- * This plugin adds a new command to the command page of the repo All-Projects.
- */
-Gerrit.install(plugin => {
-  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
deleted file mode 100644
index 8edaaa9..0000000
--- a/polygerrit-ui/app/samples/some-screen.js
+++ /dev/null
@@ -1,65 +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.
- */
-
-class SomeScreenMain extends Polymer.Element {
-  static get is() { return 'some-screen-main'; }
-
-  static get properties() {
-    return {
-      rootUrl: String,
-    };
-  }
-
-  static get template() {
-    return Polymer.html`
-      This is the <b>main</b> plugin screen at [[token]]
-      <ul>
-        <li><a href$="[[rootUrl]]/bar">without component</a></li>
-      </ul>
-    `;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this.rootUrl = `${this.plugin.screenUrl()}`;
-  }
-}
-
-customElements.define(SomeScreenMain.is, SomeScreenMain);
-
-/**
- * This plugin will add several things to gerrit:
- * 1. two screens added by this plugin in two different ways
- * 2. a link in change page under meta info to the added main screen
- */
-Gerrit.install(plugin => {
-  // Recommended approach for screen() API.
-  plugin.screen('main', 'some-screen-main');
-
-  const mainUrl = plugin.screenUrl('main');
-
-  // Quick and dirty way to get something on screen.
-  plugin.screen('bar').onAttached(el => {
-    el.innerHTML = `This is a plugin screen at ${el.token}<br/>` +
-    `<a href="${mainUrl}">Go to main plugin screen</a>`;
-  });
-
-  // Add a "Plugin screen" link to the change view screen.
-  plugin.hook('change-metadata-item').onAttached(el => {
-    el.innerHTML = `<a href="${mainUrl}">Plugin screen</a>`;
-  });
-});
diff --git a/polygerrit-ui/app/samples/suggest-vote.js b/polygerrit-ui/app/samples/suggest-vote.js
deleted file mode 100644
index 10d0d4a..0000000
--- a/polygerrit-ui/app/samples/suggest-vote.js
+++ /dev/null
@@ -1,39 +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 plugin will upgrade your +1 on Code-Review label
- * to +2 and show a message below the voting labels.
- */
-Gerrit.install(plugin => {
-  const replyApi = plugin.changeReply();
-  let wasSuggested = false;
-  plugin.on('showchange', () => {
-    wasSuggested = false;
-  });
-  const CODE_REVIEW = 'Code-Review';
-  replyApi.addLabelValuesChangedCallback(({name, value}) => {
-    if (wasSuggested && name === CODE_REVIEW) {
-      replyApi.showMessage('');
-      wasSuggested = false;
-    } 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/samples/theme-plugin.js b/polygerrit-ui/app/samples/theme-plugin.js
deleted file mode 100644
index b3d4033..0000000
--- a/polygerrit-ui/app/samples/theme-plugin.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-const customTheme = document.createElement('dom-module');
-customTheme.innerHTML = `
-  <template>
-    <style>
-    html {
-      --primary-text-color: red;
-    }
-    </style>
-  </template>
-`;
-customTheme.register('theme-plugin');
-
-const darkCustomTheme = document.createElement('dom-module');
-darkCustomTheme.innerHTML = `
-  <template>
-    <style>
-    html {
-      --background-color-primary: yellow;
-    }
-    </style>
-  </template>
-`;
-darkCustomTheme.register('dark-theme-plugin');
-
-/**
- * This plugin will change the primary text color to red.
- *
- * Also change the primary background color to yellow for dark theme.
- */
-Gerrit.install(plugin => {
-  plugin.registerStyleModule('app-theme', 'theme-plugin');
-  plugin.registerStyleModule('app-theme-dark', 'dark-theme-plugin');
-});
\ No newline at end of file
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.ts b/polygerrit-ui/app/scripts/bundled-polymer.ts
index 75c99d5..ad183c1 100644
--- a/polygerrit-ui/app/scripts/bundled-polymer.ts
+++ b/polygerrit-ui/app/scripts/bundled-polymer.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // This file is a replacement for the
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
deleted file mode 100644
index 5818003..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
+++ /dev/null
@@ -1,39 +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 {getAccountDisplayName} from '../../utils/display-name-util';
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {AccountInfo} from '../../types/common';
-
-export class GrEmailSuggestionsProvider {
-  constructor(private restAPI: RestApiService) {}
-
-  getSuggestions(input: string) {
-    return this.restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
-      if (!accounts) {
-        return [];
-      }
-      return accounts;
-    });
-  }
-
-  makeSuggestionItem(account: AccountInfo) {
-    return {
-      name: getAccountDisplayName(undefined, account),
-      value: {account, count: 1},
-    };
-  }
-}
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
deleted file mode 100644
index 989bafb..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider_test.js
+++ /dev/null
@@ -1,67 +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 {GrEmailSuggestionsProvider} from './gr-email-suggestions-provider.js';
-import {appContext} from '../../services/app-context.js';
-import {stubRestApi} from '../../test/test-utils.js';
-
-suite('GrEmailSuggestionsProvider tests', () => {
-  let provider;
-  const account1 = {
-    name: 'Some name',
-    email: 'some@example.com',
-  };
-  const account2 = {
-    email: 'other@example.com',
-    _account_id: 3,
-  };
-
-  setup(() => {
-    provider = new GrEmailSuggestionsProvider(appContext.restApiService);
-  });
-
-  test('getSuggestions', async () => {
-    const getSuggestedAccountsStub =
-        stubRestApi('getSuggestedAccounts').returns(
-            Promise.resolve([account1, account2]));
-
-    const res = await provider.getSuggestions('Some input');
-    assert.deepEqual(res, [account1, account2]);
-    assert.isTrue(getSuggestedAccountsStub.calledOnce);
-    assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-  });
-
-  test('makeSuggestionItem', () => {
-    assert.deepEqual(provider.makeSuggestionItem(account1), {
-      name: 'Some name <some@example.com>',
-      value: {
-        account: account1,
-        count: 1,
-      },
-    });
-
-    assert.deepEqual(provider.makeSuggestionItem(account2), {
-      name: 'other@example.com <other@example.com>',
-      value: {
-        account: account2,
-        count: 1,
-      },
-    });
-  });
-});
-
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
deleted file mode 100644
index ff113fb..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
+++ /dev/null
@@ -1,42 +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 {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {GroupBaseInfo} from '../../types/common';
-
-export class GrGroupSuggestionsProvider {
-  constructor(private restAPI: RestApiService) {}
-
-  getSuggestions(input: string) {
-    return this.restAPI.getSuggestedGroups(`${input}`).then(groups => {
-      if (!groups) {
-        return [];
-      }
-      const keys = Object.keys(groups);
-      return keys.map(key => {
-        return {...groups[key], name: key};
-      });
-    });
-  }
-
-  makeSuggestionItem(suggestion: GroupBaseInfo) {
-    return {
-      name: suggestion.name,
-      value: {group: {name: suggestion.name, id: suggestion.id}},
-    };
-  }
-}
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
deleted file mode 100644
index 67f9433..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider_test.js
+++ /dev/null
@@ -1,75 +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 {GrGroupSuggestionsProvider} from './gr-group-suggestions-provider.js';
-import {appContext} from '../../services/app-context.js';
-import {stubRestApi} from '../../test/test-utils.js';
-
-suite('GrGroupSuggestionsProvider tests', () => {
-  let provider;
-  const group1 = {
-    name: 'Some name',
-    id: 1,
-  };
-  const group2 = {
-    name: 'Other name',
-    id: 3,
-    url: 'abcd',
-  };
-
-  setup(() => {
-    provider = new GrGroupSuggestionsProvider(appContext.restApiService);
-  });
-
-  test('getSuggestions', async () => {
-    const getSuggestedAccountsStub =
-        stubRestApi('getSuggestedGroups')
-            .returns(Promise.resolve({
-              'Some name': {id: 1},
-              'Other name': {id: 3, url: 'abcd'},
-            }));
-
-    const res = await provider.getSuggestions('Some input');
-    assert.deepEqual(res, [group1, group2]);
-    assert.isTrue(getSuggestedAccountsStub.calledOnce);
-    assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-  });
-
-  test('makeSuggestionItem', () => {
-    assert.deepEqual(provider.makeSuggestionItem(group1), {
-      name: 'Some name',
-      value: {
-        group: {
-          name: 'Some name',
-          id: 1,
-        },
-      },
-    });
-
-    assert.deepEqual(provider.makeSuggestionItem(group2), {
-      name: 'Other name',
-      value: {
-        group: {
-          name: 'Other name',
-          id: 3,
-        },
-      },
-    });
-  });
-});
-
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
deleted file mode 100644
index a74adf6..0000000
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ /dev/null
@@ -1,149 +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 {
-  getAccountDisplayName,
-  getGroupDisplayName,
-} from '../../utils/display-name-util';
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {
-  AccountInfo,
-  isReviewerAccountSuggestion,
-  isReviewerGroupSuggestion,
-  NumericChangeId,
-  ServerInfo,
-  SuggestedReviewerInfo,
-  Suggestion,
-} from '../../types/common';
-import {assertNever} from '../../utils/common-util';
-
-// TODO(TS): enum name doesn't follow typescript style guid rules
-// Rename it
-export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
-  REVIEWER = 'reviewers',
-  CC = 'ccs',
-  ANY = 'any',
-}
-
-export function isAccountSuggestions(s: Suggestion): s is AccountInfo {
-  return (s as AccountInfo)._account_id !== undefined;
-}
-
-type ApiCallCallback = (input: string) => Promise<Suggestion[] | void>;
-
-export interface SuggestionItem {
-  name: string;
-  value: SuggestedReviewerInfo;
-}
-
-export interface ReviewerSuggestionsProvider {
-  init(): void;
-  getSuggestions(input: string): Promise<Suggestion[]>;
-  makeSuggestionItem(suggestion: Suggestion): SuggestionItem;
-}
-
-export class GrReviewerSuggestionsProvider
-  implements ReviewerSuggestionsProvider
-{
-  static create(
-    restApi: RestApiService,
-    changeNumber: NumericChangeId,
-    userType: SUGGESTIONS_PROVIDERS_USERS_TYPES
-  ) {
-    switch (userType) {
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getChangeSuggestedReviewers(changeNumber, input)
-        );
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getChangeSuggestedCCs(changeNumber, input)
-        );
-      case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
-        return new GrReviewerSuggestionsProvider(restApi, input =>
-          restApi.getSuggestedAccounts(`cansee:${changeNumber} ${input}`)
-        );
-      default:
-        throw new Error(`Unknown users type: ${userType}`);
-    }
-  }
-
-  private initPromise?: Promise<void>;
-
-  private config?: ServerInfo;
-
-  private loggedIn = false;
-
-  private initialized = false;
-
-  private constructor(
-    private readonly _restAPI: RestApiService,
-    private readonly _apiCall: ApiCallCallback
-  ) {}
-
-  init() {
-    if (this.initPromise) {
-      return this.initPromise;
-    }
-    const getConfigPromise = this._restAPI.getConfig().then(cfg => {
-      this.config = cfg;
-    });
-    const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
-      this.loggedIn = loggedIn;
-    });
-    this.initPromise = Promise.all([getConfigPromise, getLoggedInPromise]).then(
-      () => {
-        this.initialized = true;
-      }
-    );
-    return this.initPromise;
-  }
-
-  getSuggestions(input: string): Promise<Suggestion[]> {
-    if (!this.initialized || !this.loggedIn) {
-      return Promise.resolve([]);
-    }
-
-    return this._apiCall(input).then(reviewers => reviewers || []);
-  }
-
-  makeSuggestionItem(suggestion: Suggestion): SuggestionItem {
-    if (isReviewerAccountSuggestion(suggestion)) {
-      // Reviewer is an account suggestion from getChangeSuggestedReviewers.
-      return {
-        name: getAccountDisplayName(this.config, suggestion.account),
-        value: suggestion,
-      };
-    }
-
-    if (isReviewerGroupSuggestion(suggestion)) {
-      // Reviewer is a group suggestion from getChangeSuggestedReviewers.
-      return {
-        name: getGroupDisplayName(suggestion.group),
-        value: suggestion,
-      };
-    }
-
-    if (isAccountSuggestions(suggestion)) {
-      // Reviewer is an account suggestion from getSuggestedAccounts.
-      return {
-        name: getAccountDisplayName(this.config, suggestion),
-        value: {account: suggestion, count: 1},
-      };
-    }
-    assertNever(suggestion, 'Received an incorrect suggestion');
-  }
-}
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
deleted file mode 100644
index 762d36c..0000000
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.js
+++ /dev/null
@@ -1,225 +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 {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from './gr-reviewer-suggestions-provider.js';
-import {appContext} from '../../services/app-context.js';
-import {stubRestApi} from '../../test/test-utils.js';
-
-suite('GrReviewerSuggestionsProvider tests', () => {
-  let _nextAccountId = 0;
-  const makeAccount = function(opt_status) {
-    const accountId = ++_nextAccountId;
-    return {
-      _account_id: accountId,
-      name: 'name ' + accountId,
-      email: 'email ' + accountId,
-      status: opt_status,
-    };
-  };
-  let _nextAccountId2 = 0;
-  const makeAccount2 = function(opt_status) {
-    const accountId2 = ++_nextAccountId2;
-    return {
-      _account_id: accountId2,
-      name: 'name ' + accountId2,
-      status: opt_status,
-    };
-  };
-
-  let owner;
-  let existingReviewer1;
-  let existingReviewer2;
-  let suggestion1;
-  let suggestion2;
-  let suggestion3;
-  let provider;
-
-  let redundantSuggestion1;
-  let redundantSuggestion2;
-  let redundantSuggestion3;
-  let change;
-
-  setup(async () => {
-    owner = makeAccount();
-    existingReviewer1 = makeAccount();
-    existingReviewer2 = makeAccount();
-    suggestion1 = {account: makeAccount()};
-    suggestion2 = {account: makeAccount()};
-    suggestion3 = {
-      group: {
-        id: 'suggested group id',
-        name: 'suggested group',
-      },
-    };
-
-    stubRestApi('getConfig').returns(Promise.resolve({}));
-
-    change = {
-      _number: 42,
-      owner,
-      reviewers: {
-        CC: [existingReviewer1],
-        REVIEWER: [existingReviewer2],
-      },
-    };
-
-    await flush();
-  });
-
-  suite('allowAnyUser set to false', () => {
-    setup(async () => {
-      provider = GrReviewerSuggestionsProvider.create(
-          appContext.restApiService, change._number,
-          SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
-      await provider.init();
-    });
-    suite('stubbed values for _getReviewerSuggestions', () => {
-      let getChangeSuggestedReviewersStub;
-      setup(() => {
-        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', () => {
-        let account = makeAccount();
-        const account3 = makeAccount2();
-        let suggestion = provider.makeSuggestionItem({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account},
-        });
-
-        const group = {name: 'test'};
-        suggestion = provider.makeSuggestionItem({group});
-        assert.deepEqual(suggestion, {
-          name: group.name + ' (group)',
-          value: {group},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '>',
-          value: {account, count: 1},
-        });
-
-        suggestion = provider.makeSuggestionItem({account: {}});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous',
-          value: {account: {}},
-        });
-
-        provider.config = {
-          user: {
-            anonymous_coward_name: 'Anonymous Coward Name',
-          },
-        };
-
-        suggestion = provider.makeSuggestionItem({account: {}});
-        assert.deepEqual(suggestion, {
-          name: 'Anonymous Coward Name',
-          value: {account: {}},
-        });
-
-        account = makeAccount('OOO');
-
-        suggestion = provider.makeSuggestionItem({account});
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account},
-        });
-
-        suggestion = provider.makeSuggestionItem(account);
-        assert.deepEqual(suggestion, {
-          name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account, count: 1},
-        });
-
-        account3.email = undefined;
-
-        suggestion = provider.makeSuggestionItem(account3);
-        assert.deepEqual(suggestion, {
-          name: account3.name,
-          value: {account: account3, count: 1},
-        });
-      });
-
-      test('getSuggestions', async () => {
-        const reviewers = await provider.getSuggestions();
-
-        // Default is no filtering.
-        assert.equal(reviewers.length, 6);
-        assert.deepEqual(reviewers,
-            [redundantSuggestion1, redundantSuggestion2,
-              redundantSuggestion3, suggestion1,
-              suggestion2, suggestion3]);
-      });
-
-      test('getSuggestions short circuits when logged out', () => {
-        provider.loggedIn = false;
-        return provider.getSuggestions('').then(() => {
-          assert.isFalse(getChangeSuggestedReviewersStub.called);
-          provider.loggedIn = true;
-          return provider.getSuggestions('').then(() => {
-            assert.isTrue(getChangeSuggestedReviewersStub.called);
-          });
-        });
-      });
-    });
-
-    test('getChangeSuggestedReviewers is used', async () => {
-      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
-          .returns(Promise.resolve([]));
-      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
-          .returns(Promise.resolve([]));
-
-      await provider.getSuggestions('');
-      assert.isTrue(suggestReviewerStub.calledOnce);
-      assert.isTrue(suggestReviewerStub.calledWith(42, ''));
-      assert.isFalse(suggestAccountStub.called);
-    });
-  });
-
-  suite('allowAnyUser set to true', () => {
-    setup(async () => {
-      provider = GrReviewerSuggestionsProvider.create(
-          appContext.restApiService, change._number,
-          SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-      await provider.init();
-    });
-
-    test('getSuggestedAccounts is used', async () => {
-      const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
-          .returns(Promise.resolve([]));
-      const suggestAccountStub = stubRestApi('getSuggestedAccounts')
-          .returns(Promise.resolve([]));
-
-      await provider.getSuggestions('');
-      assert.isFalse(suggestReviewerStub.called);
-      assert.isTrue(suggestAccountStub.calledOnce);
-      assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.ts b/polygerrit-ui/app/scripts/hiddenscroll.ts
deleted file mode 100644
index b4364be..0000000
--- a/polygerrit-ui/app/scripts/hiddenscroll.ts
+++ /dev/null
@@ -1,34 +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.
- */
-
-let hiddenscroll: boolean | undefined = undefined;
-
-window.addEventListener('WebComponentsReady', () => {
-  const elem = document.createElement('div');
-  elem.setAttribute('style', 'width:100px;height:100px;overflow:scroll');
-  document.body.appendChild(elem);
-  hiddenscroll = elem.offsetWidth === elem.clientWidth;
-  elem.remove();
-});
-
-export function _setHiddenScroll(value: boolean) {
-  hiddenscroll = value;
-}
-
-export function getHiddenScroll() {
-  return hiddenscroll;
-}
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
index 7041300..01ecf50 100644
--- a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // We can't convert bundled-polymer.js to ts. To allow import
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
index d04b533..494acd9 100644
--- a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // This file can't be converted to TS - it imports some .js file which
@@ -69,7 +58,3 @@
 import 'polymer-bridges/polymer/lib/elements/custom-style_bridge.js';
 import 'polymer-bridges/polymer/lib/legacy/mutable-data-behavior_bridge.js';
 import 'polymer-bridges/polymer/polymer-legacy_bridge.js';
-
-// This is needed due to the Polymer.IronFocusablesHelper in gr-overlay.ts
-import 'polymer-bridges/iron-overlay-behavior/iron-focusables-helper_bridge.js';
-
diff --git a/polygerrit-ui/app/scripts/polymer-resin-install.ts b/polygerrit-ui/app/scripts/polymer-resin-install.ts
index ee03171..527df12 100644
--- a/polygerrit-ui/app/scripts/polymer-resin-install.ts
+++ b/polygerrit-ui/app/scripts/polymer-resin-install.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import 'polymer-resin/standalone/polymer-resin';
 
 export type SafeTypeBridge = (
diff --git a/polygerrit-ui/app/scripts/rootElement.ts b/polygerrit-ui/app/scripts/rootElement.ts
deleted file mode 100644
index 2217bf9..0000000
--- a/polygerrit-ui/app/scripts/rootElement.ts
+++ /dev/null
@@ -1,21 +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.
- */
-
-/**
- * Returns the root element of the dom: body.
- */
-export const getRootElement = () => document.body;
diff --git a/polygerrit-ui/app/scripts/util.ts b/polygerrit-ui/app/scripts/util.ts
deleted file mode 100644
index bf7120f..0000000
--- a/polygerrit-ui/app/scripts/util.ts
+++ /dev/null
@@ -1,77 +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.
- */
-
-export interface CancelablePromise<T> extends Promise<T> {
-  cancel(): void;
-}
-
-// TODO (dmfilippov): Each function must be exported separately. According to
-// the code style guide, a namespacing is not allowed.
-export const util = {
-  getCookie(name: string) {
-    const key = name + '=';
-    const cookies = document.cookie.split(';');
-    for (let i = 0; i < cookies.length; i++) {
-      let c = cookies[i];
-      while (c.charAt(0) === ' ') {
-        c = c.substring(1);
-      }
-      if (c.startsWith(key)) {
-        return c.substring(key.length, c.length);
-      }
-    }
-    return '';
-  },
-
-  /**
-   * Make the promise cancelable.
-   *
-   * Returns a promise with a `cancel()` method wrapped around `promise`.
-   * Calling `cancel()` will reject the returned promise with
-   * {isCancelled: true} synchronously. If the inner promise for a cancelled
-   * promise resolves or rejects this is ignored.
-   */
-  makeCancelable<T>(promise: Promise<T>) {
-    // True if the promise is either resolved or reject (possibly cancelled)
-    let isDone = false;
-
-    let rejectPromise: (reason?: unknown) => void;
-
-    const wrappedPromise: CancelablePromise<T> = new Promise(
-      (resolve, reject) => {
-        rejectPromise = reject;
-        promise.then(
-          val => {
-            if (!isDone) resolve(val);
-            isDone = true;
-          },
-          error => {
-            if (!isDone) reject(error);
-            isDone = true;
-          }
-        );
-      }
-    ) as CancelablePromise<T>;
-
-    wrappedPromise.cancel = () => {
-      if (isDone) return;
-      rejectPromise({isCanceled: true});
-      isDone = true;
-    };
-    return wrappedPromise;
-  },
-};
diff --git a/polygerrit-ui/app/services/README.md b/polygerrit-ui/app/services/README.md
index b88532b..126db83 100644
--- a/polygerrit-ui/app/services/README.md
+++ b/polygerrit-ui/app/services/README.md
@@ -9,20 +9,17 @@
 Regarding all stateful should be considered as services or not, it's still TBD. Will update as soon
 as it's finalized.
 
-## How to access service
+## How to access a service
 
 We use AppContext to access instance of service. It helps in mocking service in tests as well.
 We prefer setting instance of service in constructor and then accessing it from variable. We also
-allow access straight from appContext especially in static methods.
+allow access straight from getAppContext() especially in static methods.
 
 ```
-import {appContext} from '../../../services/app-context.js';
+import {getAppContext()} from '../../../services/app-context.js';
 
 class T {
-  constructor() {
-    super();
-    this.flagsService = appContext.flagsService;
-  }
+  private readonly flagsService = getAppContext().flagsService;
 
   action1() {
     if (this.flagsService.isEnabled('test)) {
@@ -45,10 +42,10 @@
 'flags' is a service to provide easy access to all enabled experiments.
 
 ```
-import {appContext} from '../../../services/app-context.js';
+import {getAppContext} from '../../../services/app-context.js';
 
 // check if an experiment is enabled or not
-if (appContext.flagsService.isEnabled('test')) {
+if (getAppContext().flagsService.isEnabled('test')) {
   // do something
 }
-```
\ No newline at end of file
+```
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index b9c4f49..14fb253 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -1,90 +1,223 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {appContext, AppContext} from './app-context';
+import {AppContext} from './app-context';
+import {create, Finalizable, Registry} from './registry';
+import {DependencyToken} from '../models/dependency';
 import {FlagsServiceImplementation} from './flags/flags_impl';
 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';
-import {UserService} from './user/user-service';
-import {CommentsService} from './comments/comments-service';
-import {ShortcutsService} from './shortcuts/shortcuts-service';
-import {BrowserService} from './browser/browser-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<ServiceName, any>();
-
-function getService<K extends ServiceName>(
-  serviceName: K,
-  serviceCreator: ServiceCreator<AppContext[K]>
-): AppContext[K] {
-  if (!initializedServices.has(serviceName)) {
-    initializedServices.set(serviceName, serviceCreator());
-  }
-  return initializedServices.get(serviceName);
-}
+import {GrRestApiServiceImpl} from './gr-rest-api/gr-rest-api-impl';
+import {ChangeModel, changeModelToken} from '../models/change/change-model';
+import {FilesModel, filesModelToken} from '../models/change/files-model';
+import {ChecksModel, checksModelToken} from '../models/checks/checks-model';
+import {GrStorageService, storageServiceToken} from './storage/gr-storage_impl';
+import {UserModel, userModelToken} from '../models/user/user-model';
+import {
+  CommentsModel,
+  commentsModelToken,
+} from '../models/comments/comments-model';
+import {RouterModel, routerModelToken} from './router/router-model';
+import {
+  ShortcutsService,
+  shortcutsServiceToken,
+} from './shortcuts/shortcuts-service';
+import {assertIsDefined} from '../utils/common-util';
+import {ConfigModel, configModelToken} from '../models/config/config-model';
+import {BrowserModel, browserModelToken} from '../models/browser/browser-model';
+import {
+  HighlightService,
+  highlightServiceToken,
+} from './highlight/highlight-service';
+import {
+  AccountsModel,
+  accountsModelToken,
+} from '../models/accounts-model/accounts-model';
+import {
+  DashboardViewModel,
+  dashboardViewModelToken,
+} from '../models/views/dashboard';
+import {
+  SettingsViewModel,
+  settingsViewModelToken,
+} from '../models/views/settings';
+import {GrRouter, routerToken} from '../elements/core/gr-router/gr-router';
+import {AdminViewModel, adminViewModelToken} from '../models/views/admin';
+import {
+  AgreementViewModel,
+  agreementViewModelToken,
+} from '../models/views/agreement';
+import {ChangeViewModel, changeViewModelToken} from '../models/views/change';
+import {
+  DocumentationViewModel,
+  documentationViewModelToken,
+} from '../models/views/documentation';
+import {GroupViewModel, groupViewModelToken} from '../models/views/group';
+import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
+import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
+import {SearchViewModel, searchViewModelToken} from '../models/views/search';
+import {navigationToken} from '../elements/core/gr-navigation/gr-navigation';
+import {
+  PluginLoader,
+  pluginLoaderToken,
+} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {authServiceToken} from './gr-auth/gr-auth';
+import {
+  ServiceWorkerInstaller,
+  serviceWorkerInstallerToken,
+} from './service-worker-installer';
 
 /**
  * The AppContext lazy initializator for all services
  */
-export function initAppContext() {
-  function populateAppContext(
-    serviceCreators: {[P in ServiceName]: ServiceCreator<AppContext[P]>}
-  ) {
-    const registeredServices = Object.keys(serviceCreators).reduce(
-      (registeredServices, key) => {
-        const serviceName = key as ServiceName;
-        const serviceCreator = serviceCreators[serviceName];
-        registeredServices[serviceName] = {
-          configurable: true, // Tests can mock properties
-          get() {
-            return getService(serviceName, serviceCreator);
-          },
-        };
-        return registeredServices;
-      },
-      {} as PropertyDescriptorMap
-    );
-    Object.defineProperties(appContext, registeredServices);
-  }
+export function createAppContext(): AppContext & Finalizable {
+  const appRegistry: Registry<AppContext> = {
+    flagsService: (_ctx: Partial<AppContext>) =>
+      new FlagsServiceImplementation(),
+    reportingService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.flagsService, 'flagsService)');
+      return new GrReporting(ctx.flagsService);
+    },
+    authService: (_ctx: Partial<AppContext>) => new Auth(),
+    restApiService: (ctx: Partial<AppContext>) => {
+      assertIsDefined(ctx.authService, 'authService');
+      return new GrRestApiServiceImpl(ctx.authService);
+    },
+  };
+  return create<AppContext>(appRegistry);
+}
 
-  populateAppContext({
-    flagsService: () => new FlagsServiceImplementation(),
-    reportingService: () => new GrReporting(appContext.flagsService),
-    eventEmitter: () => new EventEmitter(),
-    authService: () => new Auth(appContext.eventEmitter),
-    restApiService: () =>
-      new GrRestApiInterface(appContext.authService, appContext.flagsService),
-    changeService: () => new ChangeService(),
-    commentsService: () => new CommentsService(appContext.restApiService),
-    checksService: () => new ChecksService(appContext.reportingService),
-    jsApiService: () => new GrJsApiInterface(),
-    storageService: () => new GrStorageService(),
-    configService: () => new ConfigService(),
-    userService: () => new UserService(appContext.restApiService),
-    shortcutsService: () => new ShortcutsService(appContext.reportingService),
-    browserService: () => new BrowserService(),
-  });
+export type Creator<T> = () => T & Finalizable;
+
+// Dependencies are provided as creator functions to ensure that they are
+// not created until they are utilized.
+// This is mainly useful in tests: E.g. don't create a
+// change-model in change-model_test.ts because it creates one in the test
+// after setting up stubs.
+export function createAppDependencies(
+  appContext: AppContext,
+  resolver: <T>(token: DependencyToken<T>) => T
+): Map<DependencyToken<unknown>, Creator<unknown>> {
+  return new Map<DependencyToken<unknown>, Creator<unknown>>([
+    [authServiceToken, () => appContext.authService],
+    [routerModelToken, () => new RouterModel()],
+    [userModelToken, () => new UserModel(appContext.restApiService)],
+    [browserModelToken, () => new BrowserModel(resolver(userModelToken))],
+    [accountsModelToken, () => new AccountsModel(appContext.restApiService)],
+    [adminViewModelToken, () => new AdminViewModel()],
+    [agreementViewModelToken, () => new AgreementViewModel()],
+    [changeViewModelToken, () => new ChangeViewModel()],
+    [dashboardViewModelToken, () => new DashboardViewModel()],
+    [documentationViewModelToken, () => new DocumentationViewModel()],
+    [groupViewModelToken, () => new GroupViewModel()],
+    [pluginViewModelToken, () => new PluginViewModel()],
+    [repoViewModelToken, () => new RepoViewModel()],
+    [
+      searchViewModelToken,
+      () =>
+        new SearchViewModel(
+          appContext.restApiService,
+          resolver(userModelToken),
+          () => resolver(navigationToken)
+        ),
+    ],
+    [settingsViewModelToken, () => new SettingsViewModel()],
+    [
+      routerToken,
+      () =>
+        new GrRouter(
+          appContext.reportingService,
+          resolver(routerModelToken),
+          appContext.restApiService,
+          resolver(adminViewModelToken),
+          resolver(agreementViewModelToken),
+          resolver(changeViewModelToken),
+          resolver(dashboardViewModelToken),
+          resolver(documentationViewModelToken),
+          resolver(groupViewModelToken),
+          resolver(pluginViewModelToken),
+          resolver(repoViewModelToken),
+          resolver(searchViewModelToken),
+          resolver(settingsViewModelToken)
+        ),
+    ],
+    [navigationToken, () => resolver(routerToken)],
+    [
+      changeModelToken,
+      () =>
+        new ChangeModel(
+          resolver(navigationToken),
+          resolver(changeViewModelToken),
+          appContext.restApiService,
+          resolver(userModelToken)
+        ),
+    ],
+    [
+      commentsModelToken,
+      () =>
+        new CommentsModel(
+          resolver(changeViewModelToken),
+          resolver(changeModelToken),
+          resolver(accountsModelToken),
+          appContext.restApiService,
+          appContext.reportingService
+        ),
+    ],
+    [
+      filesModelToken,
+      () =>
+        new FilesModel(
+          resolver(changeModelToken),
+          resolver(commentsModelToken),
+          appContext.restApiService
+        ),
+    ],
+    [
+      configModelToken,
+      () =>
+        new ConfigModel(resolver(changeModelToken), appContext.restApiService),
+    ],
+    [
+      pluginLoaderToken,
+      () =>
+        new PluginLoader(
+          appContext.reportingService,
+          appContext.restApiService
+        ),
+    ],
+    [
+      checksModelToken,
+      () =>
+        new ChecksModel(
+          resolver(changeViewModelToken),
+          resolver(changeModelToken),
+          appContext.reportingService,
+          resolver(pluginLoaderToken).pluginsModel
+        ),
+    ],
+    [
+      shortcutsServiceToken,
+      () =>
+        new ShortcutsService(
+          resolver(userModelToken),
+          appContext.reportingService
+        ),
+    ],
+    [storageServiceToken, () => new GrStorageService()],
+    [
+      highlightServiceToken,
+      () => new HighlightService(appContext.reportingService),
+    ],
+    [
+      serviceWorkerInstallerToken,
+      () =>
+        new ServiceWorkerInstaller(
+          appContext.flagsService,
+          appContext.reportingService,
+          resolver(userModelToken)
+        ),
+    ],
+  ]);
 }
diff --git a/polygerrit-ui/app/services/app-context-init_test.js b/polygerrit-ui/app/services/app-context-init_test.js
deleted file mode 100644
index 9d22ec2..0000000
--- a/polygerrit-ui/app/services/app-context-init_test.js
+++ /dev/null
@@ -1,35 +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 {appContext} from './app-context.js';
-import {initAppContext} from './app-context-init.js';
-suite('app context initializer tests', () => {
-  setup(() => {
-    initAppContext();
-  });
-
-  test('all services initialized and are singletons', () => {
-    Object.keys(appContext).forEach(serviceName => {
-      const service = appContext[serviceName];
-      assert.isNotNull(service);
-      const service2 = appContext[serviceName];
-      assert.strictEqual(service, service2);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/services/app-context-init_test.ts b/polygerrit-ui/app/services/app-context-init_test.ts
new file mode 100644
index 0000000..7834e53
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context-init_test.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import {AppContext} from './app-context';
+import {Finalizable} from './registry';
+import {createTestAppContext} from '../test/test-app-context-init';
+import {assert} from '@open-wc/testing';
+
+suite('app context initializer tests', () => {
+  let appContext: AppContext & Finalizable;
+  setup(() => {
+    appContext = createTestAppContext();
+  });
+
+  teardown(() => {
+    appContext.finalize();
+  });
+
+  test('all services initialized and are singletons', () => {
+    Object.keys(appContext).forEach(serviceName => {
+      const service = appContext[serviceName as keyof AppContext];
+      assert.isDefined(service);
+      assert.isNotNull(service);
+      const service2 = appContext[serviceName as keyof AppContext];
+      assert.strictEqual(service, service2);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 47da722..aa2c032 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -1,58 +1,38 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {Finalizable} from './registry';
 import {FlagsService} from './flags/flags';
-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';
-import {UserService} from './user/user-service';
-import {CommentsService} from './comments/comments-service';
-import {ShortcutsService} from './shortcuts/shortcuts-service';
-import {BrowserService} from './browser/browser-service';
 
 export interface AppContext {
   flagsService: FlagsService;
   reportingService: ReportingService;
-  eventEmitter: EventEmitterService;
   authService: AuthService;
   restApiService: RestApiService;
-  changeService: ChangeService;
-  commentsService: CommentsService;
-  checksService: ChecksService;
-  jsApiService: JsApiService;
-  storageService: StorageService;
-  configService: ConfigService;
-  userService: UserService;
-  browserService: BrowserService;
-  shortcutsService: ShortcutsService;
 }
 
 /**
- * The AppContext holds immortal singleton instances of services. It's a
- * convenient way to provide singletons that can be swapped out for testing.
+ * The AppContext holds instances of services. It's a convenient way to provide
+ * singletons that can be swapped out for testing.
  *
  * AppContext is initialized in ./app-context-init.js
  *
  * It is guaranteed that all fields in appContext are always initialized
  * (except for shared gr-diff)
  */
-export const appContext: AppContext = {} as AppContext;
+let appContext: (AppContext & Finalizable) | undefined = undefined;
+
+export function injectAppContext(ctx: AppContext & Finalizable) {
+  appContext?.finalize();
+  appContext = ctx;
+}
+
+export function getAppContext() {
+  if (!appContext) throw new Error('App context has not been injected');
+  return appContext;
+}
diff --git a/polygerrit-ui/app/services/browser/browser-model.ts b/polygerrit-ui/app/services/browser/browser-model.ts
deleted file mode 100644
index 8cb6575..0000000
--- a/polygerrit-ui/app/services/browser/browser-model.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {BehaviorSubject, Observable, combineLatest} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
-import {preferenceDiffViewMode$} from '../user/user-model';
-import {DiffViewMode} from '../../api/diff';
-
-// This value is somewhat arbitrary and not based on research or calculations.
-const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
-
-interface BrowserState {
-  /**
-   * We maintain the screen width in the state so that the app can react to
-   * changes in the width such as automatically changing to unified diff view
-   */
-  screenWidth?: number;
-}
-
-const initialState: BrowserState = {};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
-}
-
-export function _testOnly_setState(state: BrowserState) {
-  privateState$.next(state);
-}
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-export const viewState$: Observable<BrowserState> = privateState$;
-
-export function updateStateScreenWidth(screenWidth: number) {
-  privateState$.next({...privateState$.getValue(), screenWidth});
-}
-
-export const isScreenTooSmall$ = viewState$.pipe(
-  map(
-    state =>
-      !!state.screenWidth &&
-      state.screenWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX
-  ),
-  distinctUntilChanged()
-);
-
-export const diffViewMode$: Observable<DiffViewMode> = combineLatest([
-  isScreenTooSmall$,
-  preferenceDiffViewMode$,
-]).pipe(
-  map(([isScreenTooSmall, preferenceDiffViewMode]) => {
-    if (isScreenTooSmall) return DiffViewMode.UNIFIED;
-    else return preferenceDiffViewMode;
-  }, distinctUntilChanged())
-);
diff --git a/polygerrit-ui/app/services/browser/browser-service.ts b/polygerrit-ui/app/services/browser/browser-service.ts
deleted file mode 100644
index d98f8f7..0000000
--- a/polygerrit-ui/app/services/browser/browser-service.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {updateStateScreenWidth} from './browser-model';
-
-/**
- * @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 class BrowserService {
-  /* Observer the screen width so that the app can react to changes to it */
-  observeWidth() {
-    return new ResizeObserver(entries => {
-      entries.forEach(entry => {
-        updateStateScreenWidth(entry.contentRect.width);
-      });
-    });
-  }
-}
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
deleted file mode 100644
index 7df0c22..0000000
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ /dev/null
@@ -1,127 +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 {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);
-
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...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 an additional `undefined` when the change number
-  // changes. So if you are subscribed to the latestPatchsetNumber for example,
-  // then you can rely on emissions even if the old and the new change have the
-  // same latestPatchsetNumber.
-  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 labels$ = change$.pipe(
-  map(change => change?.labels),
-  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
deleted file mode 100644
index 0b9a1f2..0000000
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ /dev/null
@@ -1,94 +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 {routerChangeNum$} from '../router/router-model';
-import {change$, updateState} from './change-model';
-import {ParsedChangeInfo} from '../../types/types';
-import {appContext} from '../app-context';
-import {ChangeInfo} from '../../types/common';
-import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-} from '../../utils/patch-set-util';
-
-export class ChangeService {
-  private change?: ParsedChangeInfo;
-
-  private readonly restApiService = appContext.restApiService;
-
-  constructor() {
-    // TODO: In the future we will want to make restApiService.getChangeDetail()
-    // calls from a switchMap() here. For now just make sure to invalidate the
-    // change when no changeNum is set.
-    routerChangeNum$.subscribe(changeNum => {
-      if (!changeNum) updateState(undefined);
-    });
-    change$.subscribe(change => {
-      this.change = change;
-    });
-  }
-
-  /**
-   * 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);
-  }
-
-  /**
-   * Typically you would just subscribe to change$ yourself to get updates. But
-   * sometimes it is nice to also be able to get the current ChangeInfo on
-   * demand. So here it is for your convenience.
-   */
-  getChange() {
-    return this.change;
-  }
-
-  /**
-   * Check whether there is no newer patch than the latest patch that was
-   * available when this change was loaded.
-   *
-   * @return A promise that yields true if the latest patch
-   *     has been loaded, and false if a newer patch has been uploaded in the
-   *     meantime. The promise is rejected on network error.
-   */
-  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
-    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
-    return this.restApiService.getChangeDetail(change._number).then(detail => {
-      if (!detail) {
-        const error = new Error('Change detail not found.');
-        return Promise.reject(error);
-      }
-      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
-      if (!actualLatest || !knownLatest) {
-        const error = new Error('Unable to check for latest patchset.');
-        return Promise.reject(error);
-      }
-      return {
-        isLatest: actualLatest <= knownLatest,
-        newStatus: change.status !== detail.status ? detail.status : null,
-        newMessages:
-          (change.messages || []).length < (detail.messages || []).length
-            ? detail.messages![detail.messages!.length - 1]
-            : undefined,
-      };
-    });
-  }
-}
diff --git a/polygerrit-ui/app/services/change/change-services_test.ts b/polygerrit-ui/app/services/change/change-services_test.ts
deleted file mode 100644
index 3e427ff..0000000
--- a/polygerrit-ui/app/services/change/change-services_test.ts
+++ /dev/null
@@ -1,108 +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 {ChangeStatus} from '../../constants/constants';
-import '../../test/common-test-setup-karma';
-import {
-  createChange,
-  createChangeMessageInfo,
-  createRevision,
-} from '../../test/test-data-generators';
-import {stubRestApi} from '../../test/test-utils';
-import {CommitId, PatchSetNum} from '../../types/common';
-import {ParsedChangeInfo} from '../../types/types';
-import {ChangeService} from './change-service';
-
-suite('change service tests', () => {
-  let changeService: ChangeService;
-  let knownChange: ParsedChangeInfo;
-  setup(() => {
-    changeService = new ChangeService();
-    knownChange = {
-      ...createChange(),
-      revisions: {
-        sha1: {
-          ...createRevision(1),
-          description: 'patch 1',
-          _number: 1 as PatchSetNum,
-        },
-        sha2: {
-          ...createRevision(2),
-          description: 'patch 2',
-          _number: 2 as PatchSetNum,
-        },
-      },
-      status: ChangeStatus.NEW,
-      current_revision: 'abc' as CommitId,
-      messages: [],
-    };
-  });
-
-  test('changeService.fetchChangeUpdates on latest', async () => {
-    stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isTrue(result.isLatest);
-    assert.isNotOk(result.newStatus);
-    assert.isNotOk(result.newMessages);
-  });
-
-  test('changeService.fetchChangeUpdates not on latest', async () => {
-    const actualChange = {
-      ...knownChange,
-      revisions: {
-        ...knownChange.revisions,
-        sha3: {
-          ...createRevision(3),
-          description: 'patch 3',
-          _number: 3 as PatchSetNum,
-        },
-      },
-    };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isFalse(result.isLatest);
-    assert.isNotOk(result.newStatus);
-    assert.isNotOk(result.newMessages);
-  });
-
-  test('changeService.fetchChangeUpdates new status', async () => {
-    const actualChange = {
-      ...knownChange,
-      status: ChangeStatus.MERGED,
-    };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isTrue(result.isLatest);
-    assert.equal(result.newStatus, ChangeStatus.MERGED);
-    assert.isNotOk(result.newMessages);
-  });
-
-  test('changeService.fetchChangeUpdates new messages', async () => {
-    const actualChange = {
-      ...knownChange,
-      messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
-    };
-    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
-    const result = await changeService.fetchChangeUpdates(knownChange);
-    assert.isTrue(result.isLatest);
-    assert.isNotOk(result.newStatus);
-    assert.deepEqual(result.newMessages, {
-      ...createChangeMessageInfo(),
-      message: 'blah blah',
-    });
-  });
-});
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
deleted file mode 100644
index 6435252..0000000
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ /dev/null
@@ -1,894 +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 {BehaviorSubject, Observable} from 'rxjs';
-import {
-  Action,
-  Category,
-  CheckResult as CheckResultApi,
-  CheckRun as CheckRunApi,
-  Link,
-  LinkIcon,
-  RunStatus,
-  TagColor,
-} from '../../api/checks';
-import {distinctUntilChanged, map} from 'rxjs/operators';
-import {PatchSetNumber} from '../../types/common';
-import {AttemptDetail, createAttemptMap} from './checks-util';
-import {assertIsDefined} from '../../utils/common-util';
-import {deepEqualStringDict, equalArray} from '../../utils/compare-util';
-
-/**
- * The checks model maintains the state of checks for two patchsets: the latest
- * and (if different) also for the one selected in the checks tab. So we need
- * the distinction in a lot of places for checks about whether the code affects
- * the checks data of the LATEST or the SELECTED patchset.
- */
-export enum ChecksPatchset {
-  LATEST = 'LATEST',
-  SELECTED = 'SELECTED',
-}
-
-export interface CheckResult extends CheckResultApi {
-  /**
-   * 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 {
-  /**
-   * For convenience we attach the name of the plugin to each run.
-   */
-  pluginName: string;
-  /**
-   * Internally we want to uniquely identify a result with an id, for example
-   * when efficiently re-rendering lists of results in the UI.
-   */
-  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;
-  /**
-   * Allows to distinguish whether loading:true is the *first* time of loading
-   * something for this provider. Or just a subsequent background update.
-   * Note that this is initially true even before loading is being set to true,
-   * so you may want to check loading && firstTimeLoad.
-   */
-  firstTimeLoad: boolean;
-  /** Presence of errorMessage implicitly means that the provider is in ERROR state. */
-  errorMessage?: string;
-  /** Presence of loginCallback implicitly means that the provider is in NOT_LOGGED_IN state. */
-  loginCallback?: () => void;
-  runs: CheckRun[];
-  actions: Action[];
-  links: Link[];
-}
-
-interface ChecksState {
-  /**
-   * This is the patchset number selected by the user. The *latest* patchset
-   * can be picked up from the change model.
-   */
-  patchsetNumberSelected?: PatchSetNumber;
-  /** Checks data for the latest patchset. */
-  pluginStateLatest: {
-    [name: string]: ChecksProviderState;
-  };
-  /**
-   * Checks data for the selected patchset. Note that `checksSelected$` below
-   * falls back to the data for the latest patchset, if no patchset is selected.
-   */
-  pluginStateSelected: {
-    [name: string]: ChecksProviderState;
-  };
-}
-
-const initialState: ChecksState = {
-  pluginStateLatest: {},
-  pluginStateSelected: {},
-};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
-}
-
-export function _testOnly_setState(state: ChecksState) {
-  privateState$.next(state);
-}
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const checksState$: Observable<ChecksState> = privateState$;
-
-export const checksSelectedPatchsetNumber$ = checksState$.pipe(
-  map(state => state.patchsetNumberSelected),
-  distinctUntilChanged()
-);
-
-export const checksLatest$ = checksState$.pipe(
-  map(state => state.pluginStateLatest),
-  distinctUntilChanged()
-);
-
-export const checksSelected$ = checksState$.pipe(
-  map(state =>
-    state.patchsetNumberSelected
-      ? state.pluginStateSelected
-      : state.pluginStateLatest
-  ),
-  distinctUntilChanged()
-);
-
-export const aPluginHasRegistered$ = checksLatest$.pipe(
-  map(state => Object.keys(state).length > 0),
-  distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingFirstTime$ = checksLatest$.pipe(
-  map(state =>
-    Object.values(state).some(
-      provider => provider.loading && provider.firstTimeLoad
-    )
-  ),
-  distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingLatest$ = checksLatest$.pipe(
-  map(state =>
-    Object.values(state).some(providerState => providerState.loading)
-  ),
-  distinctUntilChanged()
-);
-
-export const someProvidersAreLoadingSelected$ = checksSelected$.pipe(
-  map(state =>
-    Object.values(state).some(providerState => providerState.loading)
-  ),
-  distinctUntilChanged()
-);
-
-export const errorMessageLatest$ = checksLatest$.pipe(
-  map(
-    state =>
-      Object.values(state).find(
-        providerState => providerState.errorMessage !== undefined
-      )?.errorMessage
-  ),
-  distinctUntilChanged()
-);
-
-export interface ErrorMessages {
-  /* Maps plugin name to error message. */
-  [name: string]: string;
-}
-
-export const errorMessagesLatest$ = checksLatest$.pipe(
-  map(state => {
-    const errorMessages: ErrorMessages = {};
-    for (const providerState of Object.values(state)) {
-      if (providerState.errorMessage === undefined) continue;
-      errorMessages[providerState.pluginName] = providerState.errorMessage;
-    }
-    return errorMessages;
-  }),
-  distinctUntilChanged(deepEqualStringDict)
-);
-
-export const loginCallbackLatest$ = checksLatest$.pipe(
-  map(
-    state =>
-      Object.values(state).find(
-        providerState => providerState.loginCallback !== undefined
-      )?.loginCallback
-  ),
-  distinctUntilChanged()
-);
-
-export const topLevelActionsLatest$ = checksLatest$.pipe(
-  map(state =>
-    Object.values(state).reduce(
-      (allActions: Action[], providerState: ChecksProviderState) => [
-        ...allActions,
-        ...providerState.actions,
-      ],
-      []
-    )
-  ),
-  distinctUntilChanged<Action[]>(equalArray)
-);
-
-export const topLevelActionsSelected$ = checksSelected$.pipe(
-  map(state =>
-    Object.values(state).reduce(
-      (allActions: Action[], providerState: ChecksProviderState) => [
-        ...allActions,
-        ...providerState.actions,
-      ],
-      []
-    )
-  ),
-  distinctUntilChanged<Action[]>(equalArray)
-);
-
-export const topLevelLinksSelected$ = checksSelected$.pipe(
-  map(state =>
-    Object.values(state).reduce(
-      (allLinks: Link[], providerState: ChecksProviderState) => [
-        ...allLinks,
-        ...providerState.links,
-      ],
-      []
-    )
-  ),
-  distinctUntilChanged<Link[]>(equalArray)
-);
-
-export const allRunsLatestPatchset$ = checksLatest$.pipe(
-  map(state =>
-    Object.values(state).reduce(
-      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
-        ...allRuns,
-        ...providerState.runs,
-      ],
-      []
-    )
-  ),
-  distinctUntilChanged<CheckRun[]>(equalArray)
-);
-
-export const allRunsSelectedPatchset$ = checksSelected$.pipe(
-  map(state =>
-    Object.values(state).reduce(
-      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
-        ...allRuns,
-        ...providerState.runs,
-      ],
-      []
-    )
-  ),
-  distinctUntilChanged<CheckRun[]>(equalArray)
-);
-
-export const allRunsLatestPatchsetLatestAttempt$ = allRunsLatestPatchset$.pipe(
-  map(runs => runs.filter(run => run.isLatestAttempt))
-);
-
-export const checkToPluginMap$ = checksLatest$.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 allResultsSelected$ = checksSelected$.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,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    pluginName,
-    loading: false,
-    firstTimeLoad: true,
-    runs: [],
-    actions: [],
-    links: [],
-  };
-  privateState$.next(nextState);
-}
-
-// TODO(brohlfs): Remove all fake runs once the Checks UI is fully launched.
-//  They are just making it easier to develop the UI and always see all the
-//  different types/states of runs and results.
-
-export const fakeRun0: CheckRun = {
-  pluginName: 'f0',
-  internalRunId: 'f0',
-  checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder 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.',
-      message: 'Btw, 1 is also not equal to 3. Did you know?',
-      actions: [
-        {
-          name: 'Ignore',
-          tooltip: 'Ignore this result',
-          primary: true,
-          callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
-        },
-        {
-          name: 'Flag',
-          tooltip: 'Flag this result as totally absolutely really not useful',
-          primary: true,
-          disabled: true,
-          callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
-        },
-        {
-          name: 'Upload',
-          tooltip: 'Upload the result to the super cloud.',
-          primary: false,
-          callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
-        },
-      ],
-      tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
-      links: [
-        {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
-        {
-          primary: true,
-          url: 'https://google.com',
-          icon: LinkIcon.DOWNLOAD_MOBILE,
-        },
-        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
-      ],
-    },
-  ],
-  status: RunStatus.COMPLETED,
-};
-
-export const fakeRun1: CheckRun = {
-  pluginName: 'f1',
-  internalRunId: 'f1',
-  checkName: 'FAKE Super Check',
-  statusLink: 'https://www.google.com/',
-  patchset: 1,
-  labelName: 'Verified',
-  isSingleAttempt: true,
-  isLatestAttempt: true,
-  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', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
-      codePointers: [
-        {
-          path: '/COMMIT_MSG',
-          range: {
-            start_line: 10,
-            start_character: 0,
-            end_line: 10,
-            end_character: 0,
-          },
-        },
-        {
-          path: 'polygerrit-ui/app/api/checks.ts',
-          range: {
-            start_line: 5,
-            start_character: 0,
-            end_line: 7,
-            end_character: 0,
-          },
-        },
-      ],
-      links: [
-        {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
-        {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
-        {
-          primary: true,
-          url: 'https://google.com',
-          icon: LinkIcon.DOWNLOAD_MOBILE,
-        },
-        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
-        {
-          primary: false,
-          url: 'https://google.com',
-          tooltip: 'look at this',
-          icon: LinkIcon.IMAGE,
-        },
-        {
-          primary: false,
-          url: 'https://google.com',
-          tooltip: 'not at this',
-          icon: LinkIcon.IMAGE,
-        },
-      ],
-    },
-  ],
-  status: RunStatus.RUNNING,
-};
-
-export const fakeRun2: CheckRun = {
-  pluginName: 'f2',
-  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: () => Promise.resolve({message: 'fake "re-run" triggered'}),
-    },
-    {
-      name: 'Monetize',
-      primary: true,
-      disabled: true,
-      callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
-    },
-    {
-      name: 'Delete',
-      primary: true,
-      callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
-    },
-  ],
-  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 = {
-  pluginName: 'f3',
-  internalRunId: 'f3',
-  checkName: 'FAKE Critical Observations',
-  status: RunStatus.RUNNABLE,
-  isSingleAttempt: true,
-  isLatestAttempt: true,
-  attemptDetails: [],
-};
-
-export const fakeRun4_1: CheckRun = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  status: RunStatus.RUNNABLE,
-  attempt: 1,
-  isSingleAttempt: false,
-  isLatestAttempt: false,
-  attemptDetails: [],
-};
-
-export const fakeRun4_2: CheckRun = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  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 = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  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 = {
-  pluginName: 'f4',
-  internalRunId: 'f4',
-  checkName: 'FAKE Elimination Long Long Long Long Long',
-  checkDescription: 'Shows you the possible eliminations.',
-  checkLink: 'https://www.google.com',
-  status: RunStatus.COMPLETED,
-  statusDescription: 'Everything was eliminated already.',
-  statusLink: 'https://www.google.com',
-  attempt: 40,
-  scheduledTimestamp: new Date('2021-04-02T03:14:15'),
-  startedTimestamp: new Date('2021-04-02T04:24:25'),
-  finishedTimestamp: new Date('2021-04-02T04:25:44'),
-  isSingleAttempt: false,
-  isLatestAttempt: true,
-  attemptDetails: [],
-  results: [
-    {
-      internalResultId: 'f44r0',
-      category: Category.INFO,
-      summary: 'Dont be afraid. All TODOs will be eliminated.',
-      actions: [
-        {
-          name: 'Re-Run',
-          tooltip: 'More powerful run than before with a long tooltip, really.',
-          primary: true,
-          callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
-        },
-      ],
-    },
-  ],
-  actions: [
-    {
-      name: 'Re-Run',
-      tooltip: 'small',
-      primary: true,
-      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
-    },
-  ],
-};
-
-export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
-  const runs: CheckRun[] = [];
-  for (let i = from; i < to; i++) {
-    runs.push(fakeRun4CreateAttempt(i));
-  }
-  return runs;
-}
-
-export function fakeRun4CreateAttempt(attempt: number): CheckRun {
-  return {
-    pluginName: 'f4',
-    internalRunId: 'f4',
-    checkName: 'FAKE Elimination Long Long Long Long Long',
-    status: RunStatus.COMPLETED,
-    attempt,
-    isSingleAttempt: false,
-    isLatestAttempt: false,
-    attemptDetails: [],
-    results:
-      attempt % 2 === 0
-        ? [
-            {
-              internalResultId: 'f43r0',
-              category: Category.ERROR,
-              summary:
-                'Without eliminating all the TODOs your change will break!',
-            },
-          ]
-        : [],
-  };
-}
-
-export const fakeRun4Att = [
-  fakeRun4_1,
-  fakeRun4_2,
-  fakeRun4_3,
-  ...fakeRun4CreateAttempts(5, 40),
-  fakeRun4_4,
-];
-
-export const fakeActions: Action[] = [
-  {
-    name: 'Fake Action 1',
-    primary: true,
-    disabled: true,
-    tooltip: 'Tooltip for Fake Action 1',
-    callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
-  },
-  {
-    name: 'Fake Action 2',
-    primary: false,
-    disabled: true,
-    tooltip: 'Tooltip for Fake Action 2',
-    callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
-  },
-  {
-    name: 'Fake Action 3',
-    summary: true,
-    primary: false,
-    tooltip: 'Tooltip for Fake Action 3',
-    callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
-  },
-];
-
-export const fakeLinks: Link[] = [
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Bug Report 1',
-    icon: LinkIcon.REPORT_BUG,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Bug Report 2',
-    icon: LinkIcon.REPORT_BUG,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Link 1',
-    icon: LinkIcon.EXTERNAL,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: false,
-    tooltip: 'Fake Link 2',
-    icon: LinkIcon.EXTERNAL,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Code Link',
-    icon: LinkIcon.CODE,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Image Link',
-    icon: LinkIcon.IMAGE,
-  },
-  {
-    url: 'https://www.google.com',
-    primary: true,
-    tooltip: 'Fake Help Link',
-    icon: LinkIcon.HELP_PAGE,
-  },
-];
-
-export function getPluginState(
-  state: ChecksState,
-  patchset: ChecksPatchset = ChecksPatchset.LATEST
-) {
-  if (patchset === ChecksPatchset.LATEST) {
-    state.pluginStateLatest = {...state.pluginStateLatest};
-    return state.pluginStateLatest;
-  } else {
-    state.pluginStateSelected = {...state.pluginStateSelected};
-    return state.pluginStateSelected;
-  }
-}
-
-export function updateStateSetLoading(
-  pluginName: string,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: true,
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetError(
-  pluginName: string,
-  errorMessage: string,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: false,
-    firstTimeLoad: false,
-    errorMessage,
-    loginCallback: undefined,
-    runs: [],
-    actions: [],
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetNotLoggedIn(
-  pluginName: string,
-  loginCallback: () => void,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: false,
-    firstTimeLoad: false,
-    errorMessage: undefined,
-    loginCallback,
-    runs: [],
-    actions: [],
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetResults(
-  pluginName: string,
-  runs: CheckRunApi[],
-  actions: Action[] = [],
-  links: Link[] = [],
-  patchset: ChecksPatchset
-) {
-  const attemptMap = createAttemptMap(runs);
-  for (const attemptInfo of attemptMap.values()) {
-    // Per run only one attempt can be undefined, so the '?? -1' is not really
-    // relevant for sorting.
-    attemptInfo.attempts.sort((a, b) => (a.attempt ?? -1) - (b.attempt ?? -1));
-  }
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    loading: false,
-    firstTimeLoad: 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,
-        pluginName,
-        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 updateStateUpdateResult(
-  pluginName: string,
-  updatedRun: CheckRunApi,
-  updatedResult: CheckResultApi,
-  patchset: ChecksPatchset
-) {
-  const nextState = {...privateState$.getValue()};
-  const pluginState = getPluginState(nextState, patchset);
-  let runUpdated = false;
-  const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
-    if (run.change !== updatedRun.change) return run;
-    if (run.patchset !== updatedRun.patchset) return run;
-    if (run.attempt !== updatedRun.attempt) return run;
-    if (run.checkName !== updatedRun.checkName) return run;
-    let resultUpdated = false;
-    const results: CheckResult[] = (run.results ?? []).map(result => {
-      if (result.externalId && result.externalId === updatedResult.externalId) {
-        runUpdated = true;
-        resultUpdated = true;
-        return {
-          ...updatedResult,
-          internalResultId: result.internalResultId,
-        };
-      }
-      return result;
-    });
-    return resultUpdated ? {...run, results} : run;
-  });
-  if (!runUpdated) return;
-  pluginState[pluginName] = {
-    ...pluginState[pluginName],
-    runs,
-  };
-  privateState$.next(nextState);
-}
-
-export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
-  const nextState = {...privateState$.getValue()};
-  nextState.patchsetNumberSelected = patchsetNumber;
-  privateState$.next(nextState);
-}
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
deleted file mode 100644
index 0be0451..0000000
--- a/polygerrit-ui/app/services/checks/checks-model_test.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../test/common-test-setup-karma';
-import './checks-model';
-import {
-  _testOnly_getState,
-  ChecksPatchset,
-  updateStateSetLoading,
-  updateStateSetProvider,
-  updateStateSetResults,
-  updateStateUpdateResult,
-} from './checks-model';
-import {Category, CheckRun, RunStatus} from '../../api/checks';
-
-const PLUGIN_NAME = 'test-plugin';
-
-const RUNS: CheckRun[] = [
-  {
-    checkName: 'MacCheck',
-    change: 123,
-    patchset: 1,
-    attempt: 1,
-    status: RunStatus.COMPLETED,
-    results: [
-      {
-        externalId: 'id-314',
-        category: Category.WARNING,
-        summary: 'Meddle cheddle check and you are weg.',
-      },
-    ],
-  },
-];
-
-function current() {
-  return _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
-}
-
-suite('checks-model tests', () => {
-  test('updateStateSetProvider', () => {
-    updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.deepEqual(current(), {
-      pluginName: PLUGIN_NAME,
-      loading: false,
-      firstTimeLoad: true,
-      runs: [],
-      actions: [],
-      links: [],
-    });
-  });
-
-  test('loading and first time load', () => {
-    updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.isFalse(current().loading);
-    assert.isTrue(current().firstTimeLoad);
-    updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.isTrue(current().loading);
-    assert.isTrue(current().firstTimeLoad);
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.isFalse(current().loading);
-    assert.isFalse(current().firstTimeLoad);
-    updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
-    assert.isTrue(current().loading);
-    assert.isFalse(current().firstTimeLoad);
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.isFalse(current().loading);
-    assert.isFalse(current().firstTimeLoad);
-  });
-
-  test('updateStateSetResults', () => {
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.lengthOf(current().runs, 1);
-    assert.lengthOf(current().runs[0].results!, 1);
-  });
-
-  test('updateStateUpdateResult', () => {
-    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
-    assert.equal(
-      current().runs[0].results![0].summary,
-      RUNS[0]!.results![0].summary
-    );
-    const result = RUNS[0].results![0];
-    const updatedResult = {...result, summary: 'new'};
-    updateStateUpdateResult(
-      PLUGIN_NAME,
-      RUNS[0],
-      updatedResult,
-      ChecksPatchset.LATEST
-    );
-    assert.lengthOf(current().runs, 1);
-    assert.lengthOf(current().runs[0].results!, 1);
-    assert.equal(current().runs[0].results![0].summary, 'new');
-  });
-});
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
deleted file mode 100644
index 5ebc13c..0000000
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ /dev/null
@@ -1,305 +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 {
-  catchError,
-  filter,
-  switchMap,
-  takeUntil,
-  takeWhile,
-  throttleTime,
-  withLatestFrom,
-} from 'rxjs/operators';
-import {
-  Action,
-  ChangeData,
-  CheckResult,
-  CheckRun,
-  ChecksApiConfig,
-  ChecksProvider,
-  FetchResponse,
-  ResponseCode,
-} from '../../api/checks';
-import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
-import {
-  ChecksPatchset,
-  checksSelectedPatchsetNumber$,
-  checkToPluginMap$,
-  updateStateSetError,
-  updateStateSetLoading,
-  updateStateSetNotLoggedIn,
-  updateStateSetPatchset,
-  updateStateSetProvider,
-  updateStateSetResults,
-  updateStateUpdateResult,
-} from './checks-model';
-import {
-  BehaviorSubject,
-  combineLatest,
-  from,
-  Observable,
-  of,
-  Subject,
-  timer,
-} from 'rxjs';
-import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
-import {getCurrentRevision} from '../../utils/change-util';
-import {getShaByPatchNum} from '../../utils/patch-set-util';
-import {assertIsDefined} from '../../utils/common-util';
-import {ReportingService} from '../gr-reporting/gr-reporting';
-import {routerPatchNum$} from '../router/router-model';
-import {Execution} from '../../constants/reporting';
-import {fireAlert, fireEvent} from '../../utils/event-util';
-
-export class ChecksService {
-  private readonly providers: {[name: string]: ChecksProvider} = {};
-
-  private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
-
-  private checkToPluginMap = new Map<string, string>();
-
-  private changeNum?: NumericChangeId;
-
-  private latestPatchNum?: PatchSetNumber;
-
-  private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
-
-  constructor(readonly reporting: ReportingService) {
-    changeNum$.subscribe(x => (this.changeNum = x));
-    checkToPluginMap$.subscribe(map => {
-      this.checkToPluginMap = map;
-    });
-    combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
-      ([routerPs, latestPs]) => {
-        this.latestPatchNum = latestPs;
-        if (latestPs === undefined) {
-          this.setPatchset(undefined);
-        } else if (typeof routerPs === 'number') {
-          this.setPatchset(routerPs);
-        } else {
-          this.setPatchset(latestPs);
-        }
-      }
-    );
-    document.addEventListener('visibilitychange', () => {
-      this.documentVisibilityChange$.next(undefined);
-    });
-    document.addEventListener('reload', () => {
-      this.reloadAll();
-    });
-  }
-
-  setPatchset(num?: PatchSetNumber) {
-    updateStateSetPatchset(num === this.latestPatchNum ? undefined : 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);
-  }
-
-  updateResult(pluginName: string, run: CheckRun, result: CheckResult) {
-    updateStateUpdateResult(pluginName, run, result, ChecksPatchset.LATEST);
-    updateStateUpdateResult(pluginName, run, result, ChecksPatchset.SELECTED);
-  }
-
-  triggerAction(action?: Action, run?: CheckRun) {
-    if (!action?.callback) return;
-    if (!this.changeNum) return;
-    const patchSet = run?.patchset ?? this.latestPatchNum;
-    if (!patchSet) return;
-    const promise = action.callback(
-      this.changeNum,
-      patchSet,
-      run?.attempt,
-      run?.externalId,
-      run?.checkName,
-      action.name
-    );
-    // If plugins return undefined or not a promise, then show no toast.
-    if (!promise?.then) return;
-
-    fireAlert(document, `Triggering action '${action.name}' ...`);
-    from(promise)
-      // If the action takes longer than 5 seconds, then most likely the
-      // user is either not interested or the result not relevant anymore.
-      .pipe(takeUntil(timer(5000)))
-      .subscribe(result => {
-        if (result.errorMessage || result.message) {
-          fireAlert(document, `${result.message ?? result.errorMessage}`);
-        } else {
-          fireEvent(document, 'hide-alert');
-        }
-        if (result.shouldReload) {
-          this.reloadForCheck(run?.checkName);
-        }
-      });
-  }
-
-  register(
-    pluginName: string,
-    provider: ChecksProvider,
-    config: ChecksApiConfig
-  ) {
-    if (this.providers[pluginName]) {
-      console.warn(
-        `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
-      );
-      return;
-    }
-    this.providers[pluginName] = provider;
-    this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
-    updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
-    updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
-    this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
-    this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
-  }
-
-  initFetchingOfData(
-    pluginName: string,
-    config: ChecksApiConfig,
-    patchset: ChecksPatchset
-  ) {
-    const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
-    // Various events should trigger fetching checks from the provider:
-    // 1. Change number and patchset number changes.
-    // 2. Specific reload requests.
-    // 3. Regular polling starting with an initial fetch right now.
-    // 4. A hidden Gerrit tab becoming visible.
-    combineLatest([
-      changeNum$,
-      patchset === ChecksPatchset.LATEST
-        ? latestPatchNum$
-        : checksSelectedPatchsetNumber$,
-      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) return of(this.empty());
-            if (typeof patchNum !== 'number') return of(this.empty());
-            assertIsDefined(change.revisions, 'change.revisions');
-            const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
-            // Sometimes patchNum is updated earlier than change, so change
-            // revisions don't have patchNum yet
-            if (!patchsetSha) return of(this.empty());
-            const data: ChangeData = {
-              changeNumber: changeNum,
-              patchsetNumber: patchNum,
-              patchsetSha,
-              repo: change.project,
-              commitMessage: getCurrentRevision(change)?.commit?.message,
-              changeInfo: change as ChangeInfo,
-            };
-            return this.fetchResults(pluginName, data, patchset);
-          }
-        ),
-        catchError(e => {
-          // This should not happen and is really severe, because it means that
-          // the Observable has terminated and we won't recover from that. No
-          // further attempts to fetch results for this plugin will be made.
-          this.reporting.error(e, `checks-service crash for ${pluginName}`);
-          return of(this.createErrorResponse(pluginName, e));
-        })
-      )
-      .subscribe(response => {
-        switch (response.responseCode) {
-          case ResponseCode.ERROR: {
-            const message = response.errorMessage ?? '-';
-            this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
-              plugin: pluginName,
-              message,
-            });
-            updateStateSetError(pluginName, message, patchset);
-            break;
-          }
-          case ResponseCode.NOT_LOGGED_IN: {
-            assertIsDefined(response.loginCallback, 'loginCallback');
-            this.reporting.reportExecution(Execution.CHECKS_API_NOT_LOGGED_IN, {
-              plugin: pluginName,
-            });
-            updateStateSetNotLoggedIn(
-              pluginName,
-              response.loginCallback,
-              patchset
-            );
-            break;
-          }
-          case ResponseCode.OK: {
-            updateStateSetResults(
-              pluginName,
-              response.runs ?? [],
-              response.actions ?? [],
-              response.links ?? [],
-              patchset
-            );
-            break;
-          }
-        }
-      });
-  }
-
-  private empty(): FetchResponse {
-    return {
-      responseCode: ResponseCode.OK,
-      runs: [],
-    };
-  }
-
-  private createErrorResponse(
-    pluginName: string,
-    message: object
-  ): FetchResponse {
-    return {
-      responseCode: ResponseCode.ERROR,
-      errorMessage:
-        `Error message from plugin '${pluginName}':` +
-        ` ${JSON.stringify(message)}`,
-    };
-  }
-
-  private fetchResults(
-    pluginName: string,
-    data: ChangeData,
-    patchset: ChecksPatchset
-  ): Observable<FetchResponse> {
-    updateStateSetLoading(pluginName, patchset);
-    const timer = this.reporting.getTimer('ChecksPluginFetch');
-    const fetchPromise = this.providers[pluginName]
-      .fetch(data)
-      .then(response => {
-        timer.end({pluginName});
-        return response;
-      });
-    return from(fetchPromise).pipe(
-      catchError(e => of(this.createErrorResponse(pluginName, e)))
-    );
-  }
-}
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
deleted file mode 100644
index 18cc076..0000000
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ /dev/null
@@ -1,366 +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 {
-  Action,
-  Category,
-  CheckResult as CheckResultApi,
-  CheckRun as CheckRunApi,
-  Link,
-  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';
-    case LinkIcon.CODE:
-      return 'code';
-    case LinkIcon.FILE_PRESENT:
-      return 'file-present';
-    default:
-      // We don't throw an assertion error here, because plugins don't have to
-      // be written in TypeScript, so we may encounter arbitrary strings for
-      // 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';
-    case LinkIcon.CODE:
-      return 'Link to code';
-    case LinkIcon.FILE_PRESENT:
-      return 'Link to file';
-    default:
-      // We don't throw an assertion error here, because plugins don't have to
-      // be written in TypeScript, so we may encounter arbitrary strings for
-      // 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;
-  if (hasResultsOf(run, Category.SUCCESS)) return Category.SUCCESS;
-  return undefined;
-}
-
-export function isCategory(
-  catStat?: Category | RunStatus
-): catStat is Category {
-  return (
-    catStat === Category.ERROR ||
-    catStat === Category.WARNING ||
-    catStat === Category.INFO ||
-    catStat === Category.SUCCESS
-  );
-}
-
-export function isStatus(catStat?: Category | RunStatus): catStat is RunStatus {
-  return (
-    catStat === RunStatus.COMPLETED ||
-    catStat === RunStatus.RUNNABLE ||
-    catStat === RunStatus.RUNNING
-  );
-}
-
-export function labelFor(catStat: Category | RunStatus) {
-  switch (catStat) {
-    case Category.ERROR:
-      return 'error';
-    case Category.INFO:
-      return 'info';
-    case Category.WARNING:
-      return 'warning';
-    case Category.SUCCESS:
-      return 'success';
-    case RunStatus.COMPLETED:
-      return 'completed';
-    case RunStatus.RUNNABLE:
-      return 'runnable';
-    case RunStatus.RUNNING:
-      return 'running';
-    default:
-      assertNever(catStat, `Unsupported category/status: ${catStat}`);
-  }
-}
-
-export function iconFor(catStat: Category | RunStatus) {
-  switch (catStat) {
-    case Category.ERROR:
-      return 'error';
-    case Category.INFO:
-      return 'info-outline';
-    case Category.WARNING:
-      return 'warning';
-    case Category.SUCCESS:
-      return 'check-circle-outline';
-    // Note that this is only for COMPLETED without results!
-    case RunStatus.COMPLETED:
-      return 'check-circle-outline';
-    case RunStatus.RUNNABLE:
-      return 'placeholder';
-    case RunStatus.RUNNING:
-      return 'timelapse';
-    default:
-      assertNever(catStat, `Unsupported category/status: ${catStat}`);
-  }
-}
-
-export enum PRIMARY_STATUS_ACTIONS {
-  RERUN = 'rerun',
-  RUN = 'run',
-}
-
-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;
-  }
-  return {...action, name};
-}
-
-export function headerForStatus(status: RunStatus) {
-  switch (status) {
-    case RunStatus.COMPLETED:
-      return 'Completed';
-    case RunStatus.RUNNABLE:
-      return 'Not run';
-    case RunStatus.RUNNING:
-      return 'Running';
-    default:
-      assertNever(status, `Unsupported status: ${status}`);
-  }
-}
-
-function primaryActionName(status: RunStatus) {
-  switch (status) {
-    case RunStatus.COMPLETED:
-      return PRIMARY_STATUS_ACTIONS.RERUN;
-    case RunStatus.RUNNABLE:
-      return PRIMARY_STATUS_ACTIONS.RUN;
-    case RunStatus.RUNNING:
-      return undefined;
-    default:
-      assertNever(status, `Unsupported status: ${status}`);
-  }
-}
-
-export function primaryRunAction(run?: CheckRun): Action | undefined {
-  if (!run) return undefined;
-  return runActions(run).filter(
-    action => !action.disabled && 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 iconFor(run.status);
-  } else {
-    const category = worstCategory(run);
-    return category ? iconFor(category) : iconFor(run.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.SUCCESS:
-      return 0;
-    case Category.INFO:
-      return 1;
-    case Category.WARNING:
-      return 2;
-    case Category.ERROR:
-      return 3;
-  }
-}
-
-export interface ActionTriggeredEventDetail {
-  action: Action;
-  run?: CheckRun;
-}
-
-export type ActionTriggeredEvent = CustomEvent<ActionTriggeredEventDetail>;
-
-declare global {
-  interface HTMLElementEventMap {
-    'action-triggered': ActionTriggeredEvent;
-  }
-}
-
-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,
-    pluginName: 'fake',
-    internalRunId: 'fake',
-    isSingleAttempt: false,
-    isLatestAttempt: false,
-    attemptDetails: [],
-    results: (run.results ?? []).map(fromApiToInternalResult),
-  };
-}
-
-export function fromApiToInternalResult(result: CheckResultApi): CheckResult {
-  return {
-    ...result,
-    internalResultId: 'fake',
-  };
-}
-
-function allPrimaryLinks(result?: CheckResultApi): Link[] {
-  return (result?.links ?? []).filter(link => link.primary);
-}
-
-export function firstPrimaryLink(result?: CheckResultApi): Link | undefined {
-  return allPrimaryLinks(result).find(link => link.icon === LinkIcon.EXTERNAL);
-}
-
-export function otherPrimaryLinks(result?: CheckResultApi): Link[] {
-  const first = firstPrimaryLink(result);
-  return allPrimaryLinks(result).filter(link => link !== first);
-}
-
-export function secondaryLinks(result?: CheckResultApi): Link[] {
-  return (result?.links ?? []).filter(link => !link.primary);
-}
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
deleted file mode 100644
index 5b32465..0000000
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {BehaviorSubject, Observable} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
-import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
-import {
-  CommentInfo,
-  PathToCommentsInfoMap,
-  RobotCommentInfo,
-} from '../../types/common';
-import {addPath, DraftInfo} from '../../utils/comment-util';
-
-interface CommentState {
-  comments: PathToCommentsInfoMap;
-  robotComments: {[path: string]: RobotCommentInfo[]};
-  drafts: {[path: string]: DraftInfo[]};
-  portedComments: PathToCommentsInfoMap;
-  portedDrafts: PathToCommentsInfoMap;
-  /**
-   * If a draft is discarded by the user, then we temporarily keep it in this
-   * array in case the user decides to Undo the discard operation and bring the
-   * draft back. Once restored, the draft is removed from this array.
-   */
-  discardedDrafts: DraftInfo[];
-}
-
-const initialState: CommentState = {
-  comments: {},
-  robotComments: {},
-  drafts: {},
-  portedComments: {},
-  portedDrafts: {},
-  discardedDrafts: [],
-};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const commentState$: Observable<CommentState> = privateState$;
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-export function _testOnly_setState(state: CommentState) {
-  privateState$.next(state);
-}
-
-export const comments$ = commentState$.pipe(
-  map(commentState => commentState.comments),
-  distinctUntilChanged()
-);
-
-export const drafts$ = commentState$.pipe(
-  map(commentState => commentState.drafts),
-  distinctUntilChanged()
-);
-
-export const portedComments$ = commentState$.pipe(
-  map(commentState => commentState.portedComments),
-  distinctUntilChanged()
-);
-
-export const discardedDrafts$ = commentState$.pipe(
-  map(commentState => commentState.discardedDrafts),
-  distinctUntilChanged()
-);
-
-// Emits a new value even if only a single draft is changed. Components should
-// aim to subsribe to something more specific.
-export const changeComments$ = commentState$.pipe(
-  map(
-    commentState =>
-      new ChangeComments(
-        commentState.comments,
-        commentState.robotComments,
-        commentState.drafts,
-        commentState.portedComments,
-        commentState.portedDrafts
-      )
-  )
-);
-
-export const threads$ = changeComments$.pipe(
-  map(changeComments => changeComments.getAllThreadsForChange())
-);
-
-function publishState(state: CommentState) {
-  privateState$.next(state);
-}
-
-/** Called when the change number changes. Wipes out all data from the state. */
-export function updateStateReset() {
-  publishState({...initialState});
-}
-
-export function updateStateComments(comments?: {
-  [path: string]: CommentInfo[];
-}) {
-  const nextState = {...privateState$.getValue()};
-  nextState.comments = addPath(comments) || {};
-  publishState(nextState);
-}
-
-export function updateStateRobotComments(robotComments?: {
-  [path: string]: RobotCommentInfo[];
-}) {
-  const nextState = {...privateState$.getValue()};
-  nextState.robotComments = addPath(robotComments) || {};
-  publishState(nextState);
-}
-
-export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
-  const nextState = {...privateState$.getValue()};
-  nextState.drafts = addPath(drafts) || {};
-  publishState(nextState);
-}
-
-export function updateStatePortedComments(
-  portedComments?: PathToCommentsInfoMap
-) {
-  const nextState = {...privateState$.getValue()};
-  nextState.portedComments = portedComments || {};
-  publishState(nextState);
-}
-
-export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
-  const nextState = {...privateState$.getValue()};
-  nextState.portedDrafts = portedDrafts || {};
-  publishState(nextState);
-}
-
-export function updateStateAddDiscardedDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
-  publishState(nextState);
-}
-
-export function updateStateUndoDiscardedDraft(draftID?: string) {
-  const nextState = {...privateState$.getValue()};
-  const drafts = [...nextState.discardedDrafts];
-  const index = drafts.findIndex(d => d.id === draftID);
-  if (index === -1) {
-    throw new Error('discarded draft not found');
-  }
-  drafts.splice(index, 1);
-  nextState.discardedDrafts = drafts;
-  publishState(nextState);
-}
-
-export function updateStateAddDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  if (!draft.path) throw new Error('draft path undefined');
-  nextState.drafts = {...nextState.drafts};
-  const drafts = nextState.drafts;
-  if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
-  else drafts[draft.path] = [...drafts[draft.path]];
-  const index = drafts[draft.path].findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
-  if (index !== -1) {
-    drafts[draft.path][index] = draft;
-  } else {
-    drafts[draft.path].push(draft);
-  }
-  publishState(nextState);
-}
-
-export function updateStateUpdateDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  if (!draft.path) throw new Error('draft path undefined');
-  nextState.drafts = {...nextState.drafts};
-  const drafts = nextState.drafts;
-  if (!drafts[draft.path])
-    throw new Error('draft: trying to edit non-existent draft');
-  drafts[draft.path] = [...drafts[draft.path]];
-  const index = drafts[draft.path].findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
-  if (index === -1) return;
-  drafts[draft.path][index] = draft;
-  publishState(nextState);
-}
-
-export function updateStateDeleteDraft(draft: DraftInfo) {
-  const nextState = {...privateState$.getValue()};
-  if (!draft.path) throw new Error('draft path undefined');
-  nextState.drafts = {...nextState.drafts};
-  const drafts = nextState.drafts;
-  const index = (drafts[draft.path] || []).findIndex(
-    d =>
-      (d.__draftID && d.__draftID === draft.__draftID) ||
-      (d.id && d.id === draft.id)
-  );
-  if (index === -1) return;
-  const discardedDraft = drafts[draft.path][index];
-  drafts[draft.path] = [...drafts[draft.path]];
-  drafts[draft.path].splice(index, 1);
-  publishState(nextState);
-  updateStateAddDiscardedDraft(discardedDraft);
-}
diff --git a/polygerrit-ui/app/services/comments/comments-model_test.ts b/polygerrit-ui/app/services/comments/comments-model_test.ts
deleted file mode 100644
index 30fc7cf..0000000
--- a/polygerrit-ui/app/services/comments/comments-model_test.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../test/common-test-setup-karma';
-import {createDraft} from '../../test/test-data-generators';
-import {UrlEncodedCommentId} from '../../types/common';
-import {DraftInfo} from '../../utils/comment-util';
-import './comments-model';
-import {
-  updateStateDeleteDraft,
-  _testOnly_getState,
-  _testOnly_setState,
-} from './comments-model';
-
-suite('comments model tests', () => {
-  test('updateStateDeleteDraft', () => {
-    const draft = createDraft();
-    draft.id = '1' as UrlEncodedCommentId;
-    _testOnly_setState({
-      comments: {},
-      robotComments: {},
-      drafts: {
-        [draft.path!]: [draft as DraftInfo],
-      },
-      portedComments: {},
-      portedDrafts: {},
-      discardedDrafts: [],
-    });
-    updateStateDeleteDraft(draft);
-    assert.deepEqual(_testOnly_getState(), {
-      comments: {},
-      robotComments: {},
-      drafts: {
-        'abc.txt': [],
-      },
-      portedComments: {},
-      portedDrafts: {},
-      discardedDrafts: [{...draft}],
-    });
-  });
-});
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
deleted file mode 100644
index 5896b52..0000000
--- a/polygerrit-ui/app/services/comments/comments-service.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {NumericChangeId, PatchSetNum, RevisionId} from '../../types/common';
-import {DraftInfo, UIDraft} from '../../utils/comment-util';
-import {fireAlert} from '../../utils/event-util';
-import {CURRENT} from '../../utils/patch-set-util';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {
-  updateStateAddDraft,
-  updateStateDeleteDraft,
-  updateStateUpdateDraft,
-  updateStateComments,
-  updateStateRobotComments,
-  updateStateDrafts,
-  updateStatePortedComments,
-  updateStatePortedDrafts,
-  updateStateUndoDiscardedDraft,
-  discardedDrafts$,
-  updateStateReset,
-} from './comments-model';
-import {changeNum$, currentPatchNum$} from '../change/change-model';
-import {combineLatest} from 'rxjs';
-
-export class CommentsService {
-  private discardedDrafts?: UIDraft[] = [];
-
-  constructor(readonly restApiService: RestApiService) {
-    discardedDrafts$.subscribe(
-      discardedDrafts => (this.discardedDrafts = discardedDrafts)
-    );
-    changeNum$.subscribe(changeNum => {
-      updateStateReset();
-      if (!changeNum) return;
-      this.reloadComments(changeNum);
-      this.reloadRobotComments(changeNum);
-      this.reloadDrafts(changeNum);
-    });
-    combineLatest([changeNum$, currentPatchNum$]).subscribe(
-      ([changeNum, currentPatchNum]) => {
-        if (!changeNum || !currentPatchNum) return;
-        this.reloadPortedComments(changeNum, currentPatchNum);
-        this.reloadPortedDrafts(changeNum, currentPatchNum);
-      }
-    );
-  }
-
-  reloadComments(changeNum: NumericChangeId): Promise<void> {
-    return this.restApiService
-      .getDiffComments(changeNum)
-      .then(comments => updateStateComments(comments));
-  }
-
-  reloadRobotComments(changeNum: NumericChangeId): Promise<void> {
-    return this.restApiService
-      .getDiffRobotComments(changeNum)
-      .then(robotComments => updateStateRobotComments(robotComments));
-  }
-
-  reloadDrafts(changeNum: NumericChangeId): Promise<void> {
-    return this.restApiService
-      .getDiffDrafts(changeNum)
-      .then(drafts => updateStateDrafts(drafts));
-  }
-
-  reloadPortedComments(
-    changeNum: NumericChangeId,
-    patchNum = CURRENT as RevisionId
-  ): Promise<void> {
-    return this.restApiService
-      .getPortedComments(changeNum, patchNum)
-      .then(portedComments => updateStatePortedComments(portedComments));
-  }
-
-  reloadPortedDrafts(
-    changeNum: NumericChangeId,
-    patchNum = CURRENT as RevisionId
-  ): Promise<void> {
-    return this.restApiService
-      .getPortedDrafts(changeNum, patchNum)
-      .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
-  }
-
-  restoreDraft(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    draftID: string
-  ) {
-    const draft = {...this.discardedDrafts?.find(d => d.id === draftID)};
-    if (!draft) throw new Error('discarded draft not found');
-    // delete draft ID since we want to treat this as a new draft creation
-    delete draft.id;
-    this.restApiService
-      .saveDiffDraft(changeNum, patchNum, draft)
-      .then(result => {
-        if (!result.ok) {
-          fireAlert(document, 'Unable to restore draft');
-          return;
-        }
-        this.restApiService.getResponseObject(result).then(obj => {
-          const resComment = obj as unknown as DraftInfo;
-          resComment.patch_set = draft.patch_set;
-          updateStateAddDraft(resComment);
-          updateStateUndoDiscardedDraft(draftID);
-        });
-      });
-  }
-
-  addDraft(draft: DraftInfo) {
-    updateStateAddDraft(draft);
-  }
-
-  cancelDraft(draft: DraftInfo) {
-    updateStateUpdateDraft(draft);
-  }
-
-  editDraft(draft: DraftInfo) {
-    updateStateUpdateDraft(draft);
-  }
-
-  deleteDraft(draft: DraftInfo) {
-    updateStateDeleteDraft(draft);
-  }
-}
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
deleted file mode 100644
index a35768e..0000000
--- a/polygerrit-ui/app/services/comments/comments-service_test.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma';
-import {
-  createComment,
-  createParsedChange,
-  TEST_NUMERIC_CHANGE_ID,
-} from '../../test/test-data-generators';
-import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
-import {appContext} from '../app-context';
-import {CommentsService} from './comments-service';
-import {updateState as updateChangeState} from '../change/change-model';
-import {
-  GerritView,
-  updateState as updateRouterState,
-} from '../router/router-model';
-import {comments$, portedComments$} from './comments-model';
-import {PathToCommentsInfoMap} from '../../types/common';
-
-suite('change service tests', () => {
-  test('loads comments', async () => {
-    new CommentsService(appContext.restApiService);
-    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-      Promise.resolve({'foo.c': [createComment()]})
-    );
-    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
-      Promise.resolve({})
-    );
-    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
-      Promise.resolve({})
-    );
-    const portedCommentsSpy = stubRestApi('getPortedComments').returns(
-      Promise.resolve({'foo.c': [createComment()]})
-    );
-    const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
-      Promise.resolve({})
-    );
-    let comments: PathToCommentsInfoMap = {};
-    comments$.subscribe(c => (comments = c));
-    let portedComments: PathToCommentsInfoMap = {};
-    portedComments$.subscribe(c => (portedComments = c));
-
-    updateRouterState(GerritView.CHANGE, TEST_NUMERIC_CHANGE_ID);
-    updateChangeState(createParsedChange());
-
-    await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
-    await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
-    await waitUntilCalled(diffDraftsSpy, 'diffDraftsSpy');
-    await waitUntilCalled(portedCommentsSpy, 'portedCommentsSpy');
-    await waitUntilCalled(portedDraftsSpy, 'portedDraftsSpy');
-    await waitUntil(
-      () => Object.keys(comments).length > 0,
-      'comment in model not set'
-    );
-    await waitUntil(
-      () => Object.keys(portedComments).length > 0,
-      'ported comment in model not set'
-    );
-
-    assert.equal(comments['foo.c'].length, 1);
-    assert.equal(comments['foo.c'][0].id, '12345');
-    assert.equal(portedComments['foo.c'].length, 1);
-    assert.equal(portedComments['foo.c'][0].id, '12345');
-  });
-});
diff --git a/polygerrit-ui/app/services/config/config-model.ts b/polygerrit-ui/app/services/config/config-model.ts
deleted file mode 100644
index f5e10c5..0000000
--- a/polygerrit-ui/app/services/config/config-model.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {ConfigInfo, ServerInfo} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
-import {map, distinctUntilChanged} from 'rxjs/operators';
-
-interface ConfigState {
-  repoConfig?: ConfigInfo;
-  serverConfig?: ServerInfo;
-}
-
-// TODO: Figure out how to best enforce immutability of all states. Use Immer?
-// 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$;
-
-export function updateRepoConfig(repoConfig?: ConfigInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, repoConfig});
-}
-
-export function updateServerConfig(serverConfig?: ServerInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, serverConfig});
-}
-
-export const repoConfig$ = configState$.pipe(
-  map(configState => configState.repoConfig),
-  distinctUntilChanged()
-);
-
-export const serverConfig$ = configState$.pipe(
-  map(configState => configState.serverConfig),
-  distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/config/config-service.ts b/polygerrit-ui/app/services/config/config-service.ts
deleted file mode 100644
index 7cd1538..0000000
--- a/polygerrit-ui/app/services/config/config-service.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {updateRepoConfig, updateServerConfig} from './config-model';
-import {repo$} from '../change/change-model';
-import {appContext} from '../app-context';
-import {switchMap} from 'rxjs/operators';
-import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
-import {from, of} from 'rxjs';
-
-export class ConfigService {
-  private readonly restApiService = appContext.restApiService;
-
-  constructor() {
-    from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
-      updateServerConfig(config);
-    });
-    repo$
-      .pipe(
-        switchMap((repo?: RepoName) => {
-          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 863f95f..2a5dff2 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -1,30 +1,26 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {Finalizable} from '../registry';
 
-export interface FlagsService {
+export interface FlagsService extends Finalizable {
   isEnabled(experimentId: string): boolean;
   enabledExperiments: string[];
 }
 
 /**
- * @desc Experiment ids used in Gerrit.
+ * Experiment ids used in Gerrit.
  */
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
-  SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
+  PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
+  DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
+  PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
+  SUGGEST_EDIT = 'UiFeature__suggest_edit',
+  MENTION_USERS = 'UiFeature__mention_users',
+  RENDER_MARKDOWN = 'UiFeature__render_markdown',
+  REBASE_CHAIN = 'UiFeature__rebase_chain',
 }
diff --git a/polygerrit-ui/app/services/flags/flags_impl.ts b/polygerrit-ui/app/services/flags/flags_impl.ts
index 18e225b..4ef55a2 100644
--- a/polygerrit-ui/app/services/flags/flags_impl.ts
+++ b/polygerrit-ui/app/services/flags/flags_impl.ts
@@ -1,20 +1,10 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {FlagsService} from './flags';
+import {Finalizable} from '../registry';
 
 declare global {
   interface Window {
@@ -27,7 +17,7 @@
  *
  * Provides all related methods / properties regarding on feature flags.
  */
-export class FlagsServiceImplementation implements FlagsService {
+export class FlagsServiceImplementation implements FlagsService, Finalizable {
   private readonly _experiments: Set<string>;
 
   constructor() {
@@ -35,6 +25,8 @@
     this._experiments = this._loadExperiments();
   }
 
+  finalize() {}
+
   isEnabled(experimentId: string): boolean {
     return this._experiments.has(experimentId);
   }
diff --git a/polygerrit-ui/app/services/flags/flags_test.ts b/polygerrit-ui/app/services/flags/flags_test.ts
index 4ae11bf..341d49d 100644
--- a/polygerrit-ui/app/services/flags/flags_test.ts
+++ b/polygerrit-ui/app/services/flags/flags_test.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
 import {FlagsServiceImplementation} from './flags_impl';
 
 suite('flags tests', () => {
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth.ts b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
index f7fdadf..168fb26 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
@@ -1,20 +1,10 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
+import {define} from '../../models/dependency';
+import {Finalizable} from '../registry';
 export enum AuthType {
   XSRF_TOKEN = 'xsrf_token',
   ACCESS_TOKEN = 'access_token',
@@ -44,8 +34,9 @@
   // Auth class supports only Headers in options
   headers?: Headers;
 }
+export const authServiceToken = define<AuthService>('auth-service');
 
-export interface AuthService {
+export interface AuthService extends Finalizable {
   baseUrl: string;
   isAuthed: boolean;
 
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 c254284..4195666 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -1,21 +1,11 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {fire} from '../../utils/event-util';
 import {getBaseUrl} from '../../utils/url-util';
-import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
+import {Finalizable} from '../registry';
 import {
   AuthRequestInit,
   AuthService,
@@ -44,7 +34,7 @@
 /**
  * Auth class.
  */
-export class Auth implements AuthService {
+export class Auth implements AuthService, Finalizable {
   // TODO(dmfilippov): Remove Type and Status properties, expose AuthType and
   // AuthStatus to API
   static TYPE = {
@@ -61,7 +51,7 @@
 
   static CREDS_EXPIRED_MSG = 'Credentials expired.';
 
-  private authCheckPromise?: Promise<Response>;
+  private authCheckPromise?: Promise<boolean>;
 
   private _last_auth_check_time: number = Date.now();
 
@@ -77,17 +67,16 @@
 
   private getToken: GetTokenCallback;
 
-  public eventEmitter: EventEmitterService;
-
-  constructor(eventEmitter: EventEmitterService) {
+  constructor() {
     this.getToken = () => Promise.resolve(this.cachedTokenPromise);
-    this.eventEmitter = eventEmitter;
   }
 
   get baseUrl() {
     return getBaseUrl();
   }
 
+  finalize() {}
+
   /**
    * Returns if user is authed or not.
    */
@@ -97,37 +86,37 @@
       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`)
+        .then(res => {
+          // Make a call that requires loading the body of the request. This makes it so that the browser
+          // can close the request even though callers of this method might only ever read headers.
+          // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
+          try {
+            res.clone().text();
+          } catch {
+            // Ignore error
+          }
+
+          // auth-check will return 204 if authed
+          // treat the rest as unauthed
+          if (res.status === 204) {
+            this._setStatus(Auth.STATUS.AUTHED);
+            return true;
+          } else {
+            this._setStatus(Auth.STATUS.NOT_AUTHED);
+            return false;
+          }
+        })
+        .catch(() => {
+          this._setStatus(AuthStatus.ERROR);
+          // Reset authCheckPromise to avoid caching the failed promise
+          this.authCheckPromise = undefined;
+          return false;
+        });
       this._last_auth_check_time = Date.now();
     }
 
-    return this.authCheckPromise
-      .then(res => {
-        // Make a call that requires loading the body of the request. This makes it so that the browser
-        // can close the request even though callers of this method might only ever read headers.
-        // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
-        try {
-          res.clone().text();
-        } catch {
-          // Ignore error
-        }
-
-        // auth-check will return 204 if authed
-        // treat the rest as unauthed
-        if (res.status === 204) {
-          this._setStatus(Auth.STATUS.AUTHED);
-          return true;
-        } else {
-          this._setStatus(Auth.STATUS.NOT_AUTHED);
-          return false;
-        }
-      })
-      .catch(() => {
-        this._setStatus(AuthStatus.ERROR);
-        // Reset authCheckPromise to avoid caching the failed promise
-        this.authCheckPromise = undefined;
-        return false;
-      });
+    return this.authCheckPromise;
   }
 
   clearCache() {
@@ -138,7 +127,7 @@
     if (this._status === status) return;
 
     if (this._status === AuthStatus.AUTHED) {
-      this.eventEmitter.emit('auth-error', {
+      fire(document, 'auth-error', {
         message: Auth.CREDS_EXPIRED_MSG,
         action: 'Refresh credentials',
       });
@@ -188,7 +177,8 @@
     }
   }
 
-  private _getCookie(name: string): string {
+  // private but used in test
+  _getCookie(name: string): string {
     const key = name + '=';
     let result = '';
     document.cookie.split(';').some(c => {
@@ -202,7 +192,8 @@
     return result;
   }
 
-  private _isTokenValid(token: Token | null): token is ValidToken {
+  // private but used in test
+  _isTokenValid(token: Token | null): token is ValidToken {
     if (!token) {
       return false;
     }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
index 3dbb4c3..480484e 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
@@ -1,21 +1,8 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
 import {
   AuthRequestInit,
   AuthService,
@@ -30,23 +17,25 @@
 
   private _status = AuthStatus.UNDETERMINED;
 
-  public eventEmitter: EventEmitterService;
-
-  constructor(eventEmitter: EventEmitterService) {
-    this.eventEmitter = eventEmitter;
-  }
+  constructor() {}
 
   get isAuthed() {
     return this._status === Auth.STATUS.AUTHED;
   }
 
+  finalize() {}
+
   private _setStatus(status: AuthStatus) {
     if (this._status === status) return;
     if (this._status === AuthStatus.AUTHED) {
-      this.eventEmitter.emit('auth-error', {
-        message: Auth.CREDS_EXPIRED_MSG,
-        action: 'Refresh credentials',
-      });
+      document.dispatchEvent(
+        new CustomEvent('auth-error', {
+          detail: {
+            message: Auth.CREDS_EXPIRED_MSG,
+            action: 'Refresh credentials',
+          },
+        })
+      );
     }
     this._status = status;
   }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
deleted file mode 100644
index debba6d..0000000
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
+++ /dev/null
@@ -1,319 +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 {Auth} from './gr-auth_impl.js';
-import {appContext} from '../app-context.js';
-import {stubBaseUrl} from '../../test/test-utils.js';
-
-suite('gr-auth', () => {
-  let auth;
-
-  setup(() => {
-    auth = new Auth(appContext.eventEmitter);
-  });
-
-  suite('Auth class methods', () => {
-    let fakeFetch;
-    setup(() => {
-      auth = new Auth(appContext.eventEmitter);
-      fakeFetch = sinon.stub(window, 'fetch');
-    });
-
-    test('auth-check returns 403', async () => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      const authed = await auth.authCheck();
-      assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-    });
-
-    test('auth-check returns 204', async () => {
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      const authed = await auth.authCheck();
-      assert.isTrue(authed);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
-    });
-
-    test('auth-check returns 502', async () => {
-      fakeFetch.returns(Promise.resolve({status: 502}));
-      const authed = await auth.authCheck();
-      assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-    });
-
-    test('auth-check failed', async () => {
-      fakeFetch.returns(Promise.reject(new Error('random error')));
-      const authed = await auth.authCheck();
-      assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.ERROR);
-    });
-  });
-
-  suite('cache and events behavior', () => {
-    let fakeFetch;
-    let clock;
-    setup(() => {
-      auth = new Auth(appContext.eventEmitter);
-      clock = sinon.useFakeTimers();
-      fakeFetch = sinon.stub(window, 'fetch');
-    });
-
-    test('cache auth-check result', async () => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      const authed = await auth.authCheck();
-      assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      const authed2 = await auth.authCheck();
-      assert.isFalse(authed2);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-    });
-
-    test('clearCache should refetch auth-check result', async () => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      const authed = await auth.authCheck();
-      assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      auth.clearCache();
-      const authed2 = await auth.authCheck();
-      assert.isTrue(authed2);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
-    });
-
-    test('cache expired on auth-check after certain time', async () => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      const authed = await auth.authCheck();
-      assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-      clock.tick(1000 * 10000);
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      const authed2 = await auth.authCheck();
-      assert.isTrue(authed2);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
-    });
-
-    test('no cache if auth-check failed', async () => {
-      fakeFetch.returns(Promise.reject(new Error('random error')));
-      const authed = await auth.authCheck();
-      assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.ERROR);
-      assert.equal(fakeFetch.callCount, 1);
-      await auth.authCheck();
-      assert.equal(fakeFetch.callCount, 2);
-    });
-
-    test('fire event when switch from authed to unauthed', async () => {
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      const authed = await auth.authCheck();
-      assert.isTrue(authed);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
-      clock.tick(1000 * 10000);
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
-      const authed2 = await auth.authCheck();
-      assert.isFalse(authed2);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-      assert.isTrue(emitStub.called);
-    });
-
-    test('fire event when switch from authed to error', async () => {
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      const authed = await auth.authCheck();
-      assert.isTrue(authed);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
-      clock.tick(1000 * 10000);
-      fakeFetch.returns(Promise.reject(new Error('random error')));
-      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
-      const authed2 = await auth.authCheck();
-      assert.isFalse(authed2);
-      assert.isTrue(emitStub.called);
-      assert.equal(auth.status, Auth.STATUS.ERROR);
-    });
-
-    test('no event from non-authed to other status', async () => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      const authed = await auth.authCheck();
-      assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-      clock.tick(1000 * 10000);
-      fakeFetch.returns(Promise.resolve({status: 204}));
-      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
-      const authed2 = await auth.authCheck();
-      assert.isTrue(authed2);
-      assert.isFalse(emitStub.called);
-      assert.equal(auth.status, Auth.STATUS.AUTHED);
-    });
-
-    test('no event from non-authed to other status', async () => {
-      fakeFetch.returns(Promise.resolve({status: 403}));
-      const authed = await auth.authCheck();
-      assert.isFalse(authed);
-      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-      clock.tick(1000 * 10000);
-      fakeFetch.returns(Promise.reject(new Error('random error')));
-      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
-      const authed2 = await auth.authCheck();
-      assert.isFalse(authed2);
-      assert.isFalse(emitStub.called);
-      assert.equal(auth.status, Auth.STATUS.ERROR);
-    });
-  });
-
-  suite('default (xsrf token header)', () => {
-    setup(() => {
-      sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
-    });
-
-    test('GET', async () => {
-      await auth.fetch('/url', {bar: 'bar'});
-      const [url, options] = fetch.lastCall.args;
-      assert.equal(url, '/url');
-      assert.equal(options.credentials, 'same-origin');
-    });
-
-    test('POST', async () => {
-      sinon.stub(auth, '_getCookie')
-          .withArgs('XSRF_TOKEN')
-          .returns('foobar');
-      await auth.fetch('/url', {method: 'POST'});
-      const [url, options] = fetch.lastCall.args;
-      assert.equal(url, '/url');
-      assert.equal(options.credentials, 'same-origin');
-      assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
-    });
-  });
-
-  suite('cors (access token)', () => {
-    setup(() => {
-      sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
-    });
-
-    let getToken;
-
-    const makeToken = opt_accessToken => {
-      return {
-        access_token: opt_accessToken || 'zbaz',
-        expires_at: new Date(Date.now() + 10e8).getTime(),
-      };
-    };
-
-    setup(() => {
-      getToken = sinon.stub();
-      getToken.returns(Promise.resolve(makeToken()));
-      auth.setup(getToken);
-    });
-
-    test('base url support', async () => {
-      const baseUrl = 'http://foo';
-      stubBaseUrl(baseUrl);
-      await auth.fetch(baseUrl + '/url', {bar: 'bar'});
-      const [url] = fetch.lastCall.args;
-      assert.equal(url, 'http://foo/a/url?access_token=zbaz');
-    });
-
-    test('fetch not signed in', async () => {
-      getToken.returns(Promise.resolve());
-      await auth.fetch('/url', {bar: 'bar'});
-      const [url, options] = fetch.lastCall.args;
-      assert.equal(url, '/url');
-      assert.equal(options.bar, 'bar');
-      assert.equal(Object.keys(options.headers).length, 0);
-    });
-
-    test('fetch signed in', async () => {
-      await auth.fetch('/url', {bar: 'bar'});
-      const [url, options] = fetch.lastCall.args;
-      assert.equal(url, '/a/url?access_token=zbaz');
-      assert.equal(options.bar, 'bar');
-    });
-
-    test('getToken calls are cached', async () => {
-      await Promise.all([auth.fetch('/url-one'), auth.fetch('/url-two')]);
-      assert.equal(getToken.callCount, 1);
-    });
-
-    test('getToken refreshes token', async () => {
-      sinon.stub(auth, '_isTokenValid');
-      auth._isTokenValid
-          .onFirstCall().returns(true)
-          .onSecondCall()
-          .returns(false)
-          .onThirdCall()
-          .returns(true);
-      await auth.fetch('/url-one');
-      getToken.returns(Promise.resolve(makeToken('bzzbb')));
-      await auth.fetch('/url-two');
-
-      const [[firstUrl], [secondUrl]] = fetch.args;
-      assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
-      assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
-    });
-
-    test('signed in token error falls back to anonymous', async () => {
-      getToken.returns(Promise.resolve('rubbish'));
-      await auth.fetch('/url', {bar: 'bar'});
-      const [url, options] = fetch.lastCall.args;
-      assert.equal(url, '/url');
-      assert.equal(options.bar, 'bar');
-    });
-
-    test('_isTokenValid', () => {
-      assert.isFalse(auth._isTokenValid());
-      assert.isFalse(auth._isTokenValid({}));
-      assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
-      assert.isFalse(auth._isTokenValid({
-        access_token: 'foo',
-        expires_at: Date.now()/1000 - 1,
-      }));
-      assert.isTrue(auth._isTokenValid({
-        access_token: 'foo',
-        expires_at: Date.now()/1000 + 1,
-      }));
-    });
-
-    test('HTTP PUT with content type', async () => {
-      const originalOptions = {
-        method: 'PUT',
-        headers: new Headers({'Content-Type': 'mail/pigeon'}),
-      };
-      await auth.fetch('/url', originalOptions);
-      assert.isTrue(getToken.called);
-      const [url, options] = fetch.lastCall.args;
-      assert.include(url, '$ct=mail%2Fpigeon');
-      assert.include(url, '$m=PUT');
-      assert.include(url, 'access_token=zbaz');
-      assert.equal(options.method, 'POST');
-      assert.equal(options.headers.get('Content-Type'), 'text/plain');
-    });
-
-    test('HTTP PUT without content type', async () => {
-      const originalOptions = {
-        method: 'PUT',
-      };
-      await auth.fetch('/url', originalOptions);
-      assert.isTrue(getToken.called);
-      const [url, options] = fetch.lastCall.args;
-      assert.include(url, '$ct=text%2Fplain');
-      assert.include(url, '$m=PUT');
-      assert.include(url, 'access_token=zbaz');
-      assert.equal(options.method, 'POST');
-      assert.equal(options.headers.get('Content-Type'), 'text/plain');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
new file mode 100644
index 0000000..5d6056d
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
@@ -0,0 +1,328 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {Auth} from './gr-auth_impl';
+import {stubBaseUrl} from '../../test/test-utils';
+import {SinonFakeTimers} from 'sinon';
+import {AuthRequestInit, DefaultAuthOptions} from './gr-auth';
+import {assert} from '@open-wc/testing';
+
+suite('gr-auth', () => {
+  let auth: Auth;
+
+  setup(() => {
+    auth = new Auth();
+  });
+
+  suite('Auth class methods', () => {
+    let fakeFetch: sinon.SinonStub;
+    setup(() => {
+      fakeFetch = sinon.stub(window, 'fetch');
+    });
+
+    test('auth-check returns 403', async () => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+    });
+
+    test('auth-check returns 204', async () => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      const authed = await auth.authCheck();
+      assert.isTrue(authed);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
+    });
+
+    test('auth-check returns 502', async () => {
+      fakeFetch.returns(Promise.resolve({status: 502}));
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+    });
+
+    test('auth-check failed', async () => {
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.ERROR);
+    });
+  });
+
+  suite('cache and events behavior', () => {
+    let fakeFetch: sinon.SinonStub;
+    let clock: SinonFakeTimers;
+    setup(() => {
+      clock = sinon.useFakeTimers();
+      fakeFetch = sinon.stub(window, 'fetch');
+    });
+
+    test('cache auth-check result', async () => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      const authed2 = await auth.authCheck();
+      assert.isFalse(authed2);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+    });
+
+    test('clearCache should refetch auth-check result', async () => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      auth.clearCache();
+      const authed2 = await auth.authCheck();
+      assert.isTrue(authed2);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
+    });
+
+    test('cache expired on auth-check after certain time', async () => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      clock.tick(1000 * 10000);
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      const authed2 = await auth.authCheck();
+      assert.isTrue(authed2);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
+    });
+
+    test('no cache if auth-check failed', async () => {
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.ERROR);
+      assert.equal(fakeFetch.callCount, 1);
+      await auth.authCheck();
+      assert.equal(fakeFetch.callCount, 2);
+    });
+
+    test('fire event when switch from authed to unauthed', async () => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      const authed = await auth.authCheck();
+      assert.isTrue(authed);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      clock.tick(1000 * 10000);
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      const emitStub = sinon.stub();
+      document.addEventListener('auth-error', emitStub);
+      const authed2 = await auth.authCheck();
+      assert.isFalse(authed2);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.isTrue(emitStub.called);
+      document.removeEventListener('auth-error', emitStub);
+    });
+
+    test('fire event when switch from authed to error', async () => {
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      const authed = await auth.authCheck();
+      assert.isTrue(authed);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      clock.tick(1000 * 10000);
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      const emitStub = sinon.stub();
+      document.addEventListener('auth-error', emitStub);
+      const authed2 = await auth.authCheck();
+      assert.isFalse(authed2);
+      assert.isTrue(emitStub.called);
+      assert.equal(auth.status, Auth.STATUS.ERROR);
+      document.removeEventListener('auth-error', emitStub);
+    });
+
+    test('no event from non-authed to other status', async () => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      clock.tick(1000 * 10000);
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      const emitStub = sinon.stub();
+      document.addEventListener('auth-error', emitStub);
+      const authed2 = await auth.authCheck();
+      assert.isTrue(authed2);
+      assert.isFalse(emitStub.called);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      document.removeEventListener('auth-error', emitStub);
+    });
+
+    test('no event from non-authed to other status', async () => {
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      clock.tick(1000 * 10000);
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      const emitStub = sinon.stub();
+      document.addEventListener('auth-error', emitStub);
+      const authed2 = await auth.authCheck();
+      assert.isFalse(authed2);
+      assert.isFalse(emitStub.called);
+      assert.equal(auth.status, Auth.STATUS.ERROR);
+      document.removeEventListener('auth-error', emitStub);
+    });
+  });
+
+  suite('default (xsrf token header)', () => {
+    let fakeFetch: sinon.SinonStub;
+
+    setup(() => {
+      fakeFetch = sinon
+        .stub(window, 'fetch')
+        .returns(Promise.resolve({...new Response(), ok: true}));
+    });
+
+    test('GET', async () => {
+      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
+      const [url, options] = fakeFetch.lastCall.args;
+      assert.equal(url, '/url');
+      assert.equal(options.credentials, 'same-origin');
+    });
+
+    test('POST', async () => {
+      sinon.stub(auth, '_getCookie').withArgs('XSRF_TOKEN').returns('foobar');
+      await auth.fetch('/url', {method: 'POST'});
+      const [url, options] = fakeFetch.lastCall.args;
+      assert.equal(url, '/url');
+      assert.equal(options.credentials, 'same-origin');
+      assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
+    });
+  });
+
+  suite('cors (access token)', () => {
+    let fakeFetch: sinon.SinonStub;
+
+    setup(() => {
+      fakeFetch = sinon
+        .stub(window, 'fetch')
+        .returns(Promise.resolve({...new Response(), ok: true}));
+    });
+
+    let getToken: sinon.SinonStub;
+
+    const makeToken = (opt_accessToken?: string) => {
+      return {
+        access_token: opt_accessToken || 'zbaz',
+        expires_at: new Date(Date.now() + 10e8).getTime(),
+      };
+    };
+
+    setup(() => {
+      getToken = sinon.stub();
+      getToken.returns(Promise.resolve(makeToken()));
+      const defaultOptions: DefaultAuthOptions = {
+        credentials: 'include',
+      };
+      auth.setup(getToken, defaultOptions);
+    });
+
+    test('base url support', async () => {
+      const baseUrl = 'http://foo';
+      stubBaseUrl(baseUrl);
+      await auth.fetch(baseUrl + '/url', {bar: 'bar'} as AuthRequestInit);
+      const [url] = fakeFetch.lastCall.args;
+      assert.equal(url, 'http://foo/a/url?access_token=zbaz');
+    });
+
+    test('fetch not signed in', async () => {
+      getToken.returns(Promise.resolve());
+      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
+      const [url, options] = fakeFetch.lastCall.args;
+      assert.equal(url, '/url');
+      assert.equal(options.bar, 'bar');
+      assert.equal(Object.keys(options.headers).length, 0);
+    });
+
+    test('fetch signed in', async () => {
+      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
+      const [url, options] = fakeFetch.lastCall.args;
+      assert.equal(url, '/a/url?access_token=zbaz');
+      assert.equal(options.bar, 'bar');
+    });
+
+    test('getToken calls are cached', async () => {
+      await Promise.all([auth.fetch('/url-one'), auth.fetch('/url-two')]);
+      assert.equal(getToken.callCount, 1);
+    });
+
+    test('getToken refreshes token', async () => {
+      const isTokenValidStub = sinon.stub(auth, '_isTokenValid');
+      isTokenValidStub
+        .onFirstCall()
+        .returns(true)
+        .onSecondCall()
+        .returns(false)
+        .onThirdCall()
+        .returns(true);
+      await auth.fetch('/url-one');
+      getToken.returns(Promise.resolve(makeToken('bzzbb')));
+      await auth.fetch('/url-two');
+
+      const [[firstUrl], [secondUrl]] = fakeFetch.args;
+      assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
+      assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
+    });
+
+    test('signed in token error falls back to anonymous', async () => {
+      getToken.returns(Promise.resolve('rubbish'));
+      await auth.fetch('/url', {bar: 'bar'} as AuthRequestInit);
+      const [url, options] = fakeFetch.lastCall.args;
+      assert.equal(url, '/url');
+      assert.equal(options.bar, 'bar');
+    });
+
+    test('_isTokenValid', () => {
+      assert.isFalse(auth._isTokenValid(null));
+      assert.isFalse(auth._isTokenValid({}));
+      assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
+      assert.isFalse(
+        auth._isTokenValid({
+          access_token: 'foo',
+          expires_at: `${Date.now() / 1000 - 1}`,
+        })
+      );
+      assert.isTrue(
+        auth._isTokenValid({
+          access_token: 'foo',
+          expires_at: `${Date.now() / 1000 + 1}`,
+        })
+      );
+    });
+
+    test('HTTP PUT with content type', async () => {
+      const originalOptions = {
+        method: 'PUT',
+        headers: new Headers({'Content-Type': 'mail/pigeon'}),
+      };
+      await auth.fetch('/url', originalOptions);
+      assert.isTrue(getToken.called);
+      const [url, options] = fakeFetch.lastCall.args;
+      assert.include(url, '$ct=mail%2Fpigeon');
+      assert.include(url, '$m=PUT');
+      assert.include(url, 'access_token=zbaz');
+      assert.equal(options.method, 'POST');
+      assert.equal(options.headers.get('Content-Type'), 'text/plain');
+    });
+
+    test('HTTP PUT without content type', async () => {
+      const originalOptions = {
+        method: 'PUT',
+      };
+      await auth.fetch('/url', originalOptions);
+      assert.isTrue(getToken.called);
+      const [url, options] = fakeFetch.lastCall.args;
+      assert.include(url, '$ct=text%2Fplain');
+      assert.include(url, '$m=PUT');
+      assert.include(url, 'access_token=zbaz');
+      assert.equal(options.method, 'POST');
+      assert.equal(options.headers.get('Content-Type'), 'text/plain');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
deleted file mode 100644
index e540029..0000000
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.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.
- */
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type EventCallback = (...args: any) => void;
-export type UnsubscribeMethod = () => void;
-
-export interface EventEmitterService {
-  /**
-   * Register an event listener to an event.
-   */
-  addListener(eventName: string, cb: EventCallback): UnsubscribeMethod;
-
-  /**
-   * Alias for addListener.
-   */
-  on(eventName: string, cb: EventCallback): UnsubscribeMethod;
-
-  /**
-   * Attach event handler only once. Automatically removed.
-   */
-  once(eventName: string, cb: EventCallback): UnsubscribeMethod;
-
-  /**
-   * De-register an event listener to an event.
-   */
-  removeListener(eventName: string, cb: EventCallback): void;
-
-  /**
-   * Alias to removeListener
-   */
-  off(eventName: string, cb: EventCallback): void;
-
-  /**
-   * Synchronously calls each of the listeners registered for
-   * the event named eventName, in the order they were registered,
-   * passing the supplied detail to each.
-   *
-   * @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;
-
-  /**
-   * Remove listeners for a specific event or all.
-   *
-   * @param eventName if not provided, will remove all
-   */
-  removeAllListeners(eventName: string): void;
-}
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
deleted file mode 100644
index d8c5d77..0000000
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
+++ /dev/null
@@ -1,133 +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 {
-  EventCallback,
-  EventEmitterService,
-  UnsubscribeMethod,
-} from './gr-event-interface';
-/**
- * An lite implementation of
- * https://nodejs.org/api/events.html#events_class_eventemitter.
- *
- * This is unrelated to the native DOM events, you should use it when you want
- * to enable EventEmitter interface on any class.
- *
- * @example
- *
- * class YourClass extends EventEmitter {
- *   // now all instance of YourClass will have this EventEmitter interface
- * }
- *
- */
-export class EventEmitter implements EventEmitterService {
-  private _listenersMap = new Map<string, EventCallback[]>();
-
-  /**
-   * Register an event listener to an event.
-   */
-  addListener(eventName: string, cb: EventCallback): UnsubscribeMethod {
-    if (!eventName || !cb) {
-      console.warn('A valid eventname and callback is required!');
-      return () => {};
-    }
-
-    const listeners = this._listenersMap.get(eventName) || [];
-    listeners.push(cb);
-    this._listenersMap.set(eventName, listeners);
-
-    return () => {
-      this.off(eventName, cb);
-    };
-  }
-
-  /**
-   * Alias for addListener.
-   */
-  on(eventName: string, cb: EventCallback): UnsubscribeMethod {
-    return this.addListener(eventName, cb);
-  }
-
-  /**
-   * 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);
-    };
-    return this.on(eventName, onceWrapper);
-  }
-
-  /**
-   * De-register an event listener to an event.
-   */
-  removeListener(eventName: string, cb: EventCallback): void {
-    let listeners = this._listenersMap.get(eventName) || [];
-    listeners = listeners.filter(listener => listener !== cb);
-    this._listenersMap.set(eventName, listeners);
-  }
-
-  /**
-   * Alias to removeListener
-   */
-  off(eventName: string, cb: EventCallback): void {
-    this.removeListener(eventName, cb);
-  }
-
-  /**
-   * Synchronously calls each of the listeners registered for
-   * the event named eventName, in the order they were registered,
-   * passing the supplied detail to each.
-   *
-   * @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) {
-      try {
-        listener(detail);
-      } catch (e) {
-        console.error(e);
-      }
-    }
-    return listeners.length !== 0;
-  }
-
-  /**
-   * Alias to emit.
-   */
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  dispatch(eventName: string, detail: any): boolean {
-    return this.emit(eventName, detail);
-  }
-
-  /**
-   * Remove listeners for a specific event or all.
-   *
-   * @param eventName if not provided, will remove all
-   */
-  removeAllListeners(eventName: string): void {
-    if (eventName) {
-      this._listenersMap.set(eventName, []);
-    } else {
-      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
deleted file mode 100644
index 54a0f72e..0000000
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
+++ /dev/null
@@ -1,134 +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 {mockPromise} from '../../test/test-utils.js';
-import {EventEmitter} from './gr-event-interface_impl.js';
-
-suite('gr-event-interface tests', () => {
-  let gerrit;
-  setup(() => {
-    gerrit = window.Gerrit;
-  });
-
-  suite('test on Gerrit', () => {
-    setup(() => {
-      gerrit.removeAllListeners();
-    });
-
-    test('communicate between plugin and Gerrit', async () => {
-      const eventName = 'test-plugin-event';
-      let p;
-      const promise = mockPromise();
-      gerrit.on(eventName, e => {
-        assert.equal(e.value, 'test');
-        assert.equal(e.plugin, p);
-        promise.resolve();
-      });
-      gerrit.install(plugin => {
-        p = plugin;
-        gerrit.emit(eventName, {value: 'test', plugin});
-      }, '0.1',
-      'http://test.com/plugins/testplugin/static/test.js');
-      await promise;
-    });
-
-    test('listen on events from core', async () => {
-      const eventName = 'test-plugin-event';
-      const promise = mockPromise();
-      gerrit.on(eventName, e => {
-        assert.equal(e.value, 'test');
-        promise.resolve();
-      });
-
-      gerrit.emit(eventName, {value: 'test'});
-      await promise;
-    });
-
-    test('communicate across plugins', async () => {
-      const eventName = 'test-plugin-event';
-      const promise = mockPromise();
-      gerrit.install(plugin => {
-        gerrit.on(eventName, e => {
-          assert.equal(e.plugin.getPluginName(), 'testB');
-          promise.resolve();
-        });
-      }, '0.1',
-      'http://test.com/plugins/testA/static/testA.js');
-
-      gerrit.install(plugin => {
-        gerrit.emit(eventName, {plugin});
-      }, '0.1',
-      'http://test.com/plugins/testB/static/testB.js');
-      await promise;
-    });
-  });
-
-  suite('test on interfaces', () => {
-    let testObj;
-
-    class TestClass extends EventEmitter {
-    }
-
-    setup(() => {
-      testObj = new TestClass();
-    });
-
-    test('on', () => {
-      const cbStub = sinon.stub();
-      testObj.on('test', cbStub);
-      testObj.emit('test');
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledTwice);
-    });
-
-    test('once', () => {
-      const cbStub = sinon.stub();
-      testObj.once('test', cbStub);
-      testObj.emit('test');
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledOnce);
-    });
-
-    test('unsubscribe', () => {
-      const cbStub = sinon.stub();
-      const unsubscribe = testObj.on('test', cbStub);
-      testObj.emit('test');
-      unsubscribe();
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledOnce);
-    });
-
-    test('off', () => {
-      const cbStub = sinon.stub();
-      testObj.on('test', cbStub);
-      testObj.emit('test');
-      testObj.off('test', cbStub);
-      testObj.emit('test');
-      assert.isTrue(cbStub.calledOnce);
-    });
-
-    test('removeAllListeners', () => {
-      const cbStub = sinon.stub();
-      testObj.on('test', cbStub);
-      testObj.removeAllListeners('test');
-      testObj.emit('test');
-      assert.isTrue(cbStub.notCalled);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 06f1a0c..f552762 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -1,22 +1,11 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
+import {Finalizable} from '../registry';
 import {NumericChangeId} from '../../types/common';
-import {EventDetails} from '../../api/reporting';
+import {EventDetails, ReportingOptions} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
 import {
   Execution,
@@ -33,7 +22,7 @@
   withMaximum(maximum: number): this;
 }
 
-export interface ReportingService {
+export interface ReportingService extends Finalizable {
   reporter(
     type: string,
     category: string,
@@ -51,14 +40,13 @@
   changeDisplayed(eventDetails?: EventDetails): void;
   changeFullyLoaded(): void;
   diffViewDisplayed(): void;
-  diffViewFullyLoaded(): void;
   diffViewContentDisplayed(): void;
   fileListDisplayed(): void;
   reportExtension(name: string): void;
   pluginLoaded(name: string): void;
   pluginsLoaded(pluginsList?: string[]): void;
   pluginsFailed(pluginsList?: string[]): void;
-  error(err: Error, reporter?: string, details?: EventDetails): void;
+  error(errorSource: string, error: Error, details?: EventDetails): void;
   /**
    * Reset named timer.
    */
@@ -68,20 +56,6 @@
    */
   timeEnd(name: Timing, eventDetails?: EventDetails): void;
   /**
-   * Reports just line timeEnd, but additionally reports an average given a
-   * 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: Timing,
-    averageName: Timing,
-    denominator: number
-  ): void;
-  /**
    * 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.
@@ -113,13 +87,9 @@
   ): void;
   reportInteraction(
     eventName: string | Interaction,
-    details?: EventDetails
+    details?: EventDetails,
+    options?: ReportingOptions
   ): void;
-  /**
-   * 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 65a5784..dadf9e4 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -1,32 +1,22 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {AppContext} from '../app-context';
 import {FlagsService} from '../flags/flags';
 import {EventValue, ReportingService, Timer} from './gr-reporting';
 import {hasOwnProperty} from '../../utils/common-util';
 import {NumericChangeId} from '../../types/common';
-import {EventDetails} from '../../api/reporting';
+import {Deduping, EventDetails, ReportingOptions} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
+import {Finalizable} from '../registry';
 import {
   Execution,
   Interaction,
   LifeCycle,
   Timing,
 } from '../../constants/reporting';
+import {getCLS, getFID, getLCP, Metric} from 'web-vitals';
 
 // Latency reporting constants.
 
@@ -91,20 +81,15 @@
   [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.
   [Timing.WEB_COMPONENTS_READY]: 0,
 };
 
-const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
 const SLOW_RPC_THRESHOLD = 500;
 
-export function initErrorReporter(appContext: AppContext) {
-  const reportingService = appContext.reportingService;
-
+export function initErrorReporter(reportingService: ReportingService) {
   const normalizeError = (err: Error | unknown) => {
     if (err instanceof Error) {
       return err;
@@ -135,7 +120,7 @@
       line = line ?? error.lineNumber;
       column = column ?? error.columnNumber;
     }
-    reportingService.error(normalizeError(error), 'onError', {
+    reportingService.error('onError', normalizeError(error), {
       line,
       column,
       url,
@@ -158,7 +143,7 @@
     context.addEventListener(
       'unhandledrejection',
       (e: PromiseRejectionEvent) => {
-        reportingService.error(normalizeError(e.reason), 'unhandledrejection');
+        reportingService.error('unhandledrejection', normalizeError(e.reason));
       }
     );
   };
@@ -169,8 +154,7 @@
   return {catchErrors};
 }
 
-export function initPerformanceReporter(appContext: AppContext) {
-  const reportingService = appContext.reportingService;
+export function initPerformanceReporter(reportingService: ReportingService) {
   // PerformanceObserver interface is a browser API.
   if (window.PerformanceObserver) {
     const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
@@ -178,8 +162,8 @@
     if (supportedEntryTypes.includes('longtask')) {
       const catchLongJsTasks = new PerformanceObserver(list => {
         for (const task of list.getEntries()) {
-          // We are interested in longtask longer than 200 ms (default is 50 ms)
-          if (task.duration > 200) {
+          // We are interested in longtask longer than 400 ms (default is 50 ms)
+          if (task.duration > 400) {
             reportingService.reporter(
               TIMING.TYPE,
               TIMING.CATEGORY.UI_LATENCY,
@@ -196,13 +180,29 @@
   }
 }
 
-export function initVisibilityReporter(appContext: AppContext) {
-  const reportingService = appContext.reportingService;
+export function initVisibilityReporter(reportingService: ReportingService) {
   document.addEventListener('visibilitychange', () => {
     reportingService.onVisibilityChange();
   });
 }
 
+export function initWebVitals(reportingService: ReportingService) {
+  function reportWebVitalMetric(name: Timing, metric: Metric) {
+    reportingService.reporter(
+      TIMING.TYPE,
+      TIMING.CATEGORY.UI_LATENCY,
+      name,
+      metric.value,
+      JSON.stringify(metric),
+      false
+    );
+  }
+
+  getCLS(metric => reportWebVitalMetric(Timing.CLS, metric));
+  getFID(metric => reportWebVitalMetric(Timing.FID, metric));
+  getLCP(metric => reportWebVitalMetric(Timing.LCP, metric));
+}
+
 // Calculates the time of Gerrit being in a background tab. When Gerrit reports
 // a pageLoad metric it’s attached to its details for latency analysis.
 // It resets on locationChange.
@@ -277,7 +277,7 @@
 
 type PendingReportInfo = [EventInfo, boolean | undefined];
 
-export class GrReporting implements ReportingService {
+export class GrReporting implements ReportingService, Finalizable {
   private readonly _flagsService: FlagsService;
 
   private readonly _baselines = STARTUP_TIMERS;
@@ -286,19 +286,15 @@
 
   private reportChangeId: NumericChangeId | undefined;
 
-  private timers: {timeBetweenDraftActions: Timer | null} = {
-    timeBetweenDraftActions: null,
-  };
-
   private pending: PendingReportInfo[] = [];
 
   private slowRpcList: SlowRpcCall[] = [];
 
   /**
-   * Keeps track of which ids were already reported to have been executed.
-   * Execution ids should only be reported once per session.
+   * Keeps track of which ids were already reported for events that should only
+   * be reported once per session.
    */
-  private executionReported = new Set<string>();
+  private reportedIds = new Set<string>();
 
   public readonly hiddenDurationTimer = new HiddenDurationTimer();
 
@@ -328,6 +324,8 @@
     );
   }
 
+  finalize() {}
+
   /**
    * Reporter reports events. Events will be queued if metrics plugin is not
    * yet installed.
@@ -371,16 +369,22 @@
   }
 
   private _reportEvent(eventInfo: EventInfo, opt_noLog?: boolean) {
-    const {type, value, name} = eventInfo;
+    const {type, value, name, eventDetails} = eventInfo;
     document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
     if (opt_noLog) {
       return;
     }
     if (type !== ERROR.TYPE) {
       if (value !== undefined) {
-        console.info(`Reporting: ${name}: ${value}`);
+        console.debug(
+          `Reporting(${new Date().toISOString()}): ${name}: ${value}`
+        );
+      } else if (eventDetails !== undefined) {
+        console.debug(
+          `Reporting(${new Date().toISOString()}): ${name}: ${eventDetails}`
+        );
       } else {
-        console.info(`Reporting: ${name}`);
+        console.debug(`Reporting(${new Date().toISOString()}): ${name}`);
       }
     }
   }
@@ -498,7 +502,6 @@
     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;
@@ -549,14 +552,6 @@
     }
   }
 
-  diffViewFullyLoaded() {
-    if (hasOwnProperty(this._baselines, Timing.STARTUP_DIFF_VIEW_LOAD_FULL)) {
-      this.timeEnd(Timing.STARTUP_DIFF_VIEW_LOAD_FULL);
-    } else {
-      this.timeEnd(Timing.DIFF_VIEW_LOAD_FULL);
-    }
-  }
-
   diffViewContentDisplayed() {
     if (
       hasOwnProperty(
@@ -632,7 +627,7 @@
       LifeCycle.PLUGINS_INSTALLED,
       undefined,
       {pluginsList: pluginsList || []},
-      true
+      false
     );
   }
 
@@ -644,7 +639,7 @@
       LifeCycle.PLUGINS_FAILED,
       undefined,
       {pluginsList: pluginsList || []},
-      true
+      false
     );
   }
 
@@ -679,30 +674,6 @@
   }
 
   /**
-   * Reports just line timeEnd, but additionally reports an average given a
-   * 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: Timing, averageName: Timing, denominator: number) {
-    if (!hasOwnProperty(this._baselines, name)) {
-      return;
-    }
-    const baseTime = this._baselines[name];
-    this.timeEnd(name);
-
-    // Guard against division by zero.
-    if (!denominator) {
-      return;
-    }
-    const time = now() - baseTime;
-    this._reportTiming(averageName, time / denominator);
-  }
-
-  /**
    * Send a timing report with an arbitrary time value.
    *
    * @param name Timing name.
@@ -797,7 +768,7 @@
       eventName,
       undefined,
       details,
-      true
+      false
     );
   }
 
@@ -808,7 +779,7 @@
       eventName,
       undefined,
       details,
-      true
+      false
     );
   }
 
@@ -823,21 +794,55 @@
     );
   }
 
-  reportInteraction(eventName: string | Interaction, details: EventDetails) {
+  /**
+   * Returns true when the event was deduped and thus should not be reported.
+   */
+  _dedup(
+    eventName: string | Interaction,
+    details: EventDetails,
+    deduping?: Deduping
+  ): boolean {
+    if (!deduping) return false;
+    let id = '';
+    switch (deduping) {
+      case Deduping.DETAILS_ONCE_PER_CHANGE:
+        id = `${eventName}-${this.reportChangeId}-${JSON.stringify(details)}`;
+        break;
+      case Deduping.DETAILS_ONCE_PER_SESSION:
+        id = `${eventName}-${JSON.stringify(details)}`;
+        break;
+      case Deduping.EVENT_ONCE_PER_CHANGE:
+        id = `${eventName}-${this.reportChangeId}`;
+        break;
+      case Deduping.EVENT_ONCE_PER_SESSION:
+        id = `${eventName}`;
+        break;
+      default:
+        throw new Error(`Invalid 'deduping' option '${deduping}'.`);
+    }
+    if (this.reportedIds.has(id)) return true;
+    this.reportedIds.add(id);
+    return false;
+  }
+
+  reportInteraction(
+    eventName: string | Interaction,
+    details: EventDetails,
+    options?: ReportingOptions
+  ) {
+    if (this._dedup(eventName, details, options?.deduping)) return;
     this.reporter(
       INTERACTION.TYPE,
       INTERACTION.CATEGORY.DEFAULT,
       eventName,
       undefined,
       details,
-      true
+      false
     );
   }
 
   reportExecution(name: Execution, details?: EventDetails) {
-    const id = `${name}${JSON.stringify(details)}`;
-    if (this.executionReported.has(id)) return;
-    this.executionReported.add(id);
+    if (this._dedup(name, details, Deduping.DETAILS_ONCE_PER_SESSION)) return;
     this.reporter(
       LIFECYCLE.TYPE,
       LIFECYCLE.CATEGORY.EXECUTION,
@@ -857,37 +862,20 @@
     this.reportExecution(Execution.PLUGIN_API, {plugin, object, method});
   }
 
-  /**
-   * 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;
-    if (!timer) {
-      // Create a timer with a maximum length.
-      this.timers.timeBetweenDraftActions = this.getTimer(
-        DRAFT_ACTION_TIMER
-      ).withMaximum(DRAFT_ACTION_TIMER_MAX);
-      return;
-    }
-
-    // Mark the time and reinitialize the timer.
-    timer.end().reset();
-  }
-
-  error(error: Error, errorSource?: string, details?: EventDetails) {
-    const eventDetails = details ?? {};
-    const message = `${errorSource ? errorSource + ': ' : ''}${error.message}`;
+  error(errorSource: string, error: Error, details?: EventDetails) {
+    const message = `${errorSource}: ${error.message}`;
+    const eventDetails = {
+      errorMessage: message,
+      ...details,
+      stack: error.stack,
+    };
 
     this.reporter(
       ERROR.TYPE,
       ERROR.CATEGORY.EXCEPTION,
-      message,
+      errorSource,
       {error},
-      {...eventDetails, stack: error.stack}
+      eventDetails
     );
   }
 
@@ -895,8 +883,9 @@
     this.reporter(
       ERROR.TYPE,
       ERROR.CATEGORY.ERROR_DIALOG,
-      'ErrorDialog: ' + message,
-      {error: new Error(message)}
+      'ErrorDialog',
+      {error: new Error(message)},
+      {errorMessage: message}
     );
   }
 
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 337cf2f..d4efbcc 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -1,23 +1,13 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {ReportingService, Timer} from './gr-reporting';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
 import {Execution, Interaction} from '../../constants/reporting';
+import {Finalizable} from '../registry';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -33,11 +23,11 @@
   }
 }
 
-const log = function (msg: string) {
-  console.info(`ReportingMock.${msg}`);
+const log = function (msg: string, e?: unknown) {
+  console.info(`ReportingMock.${msg} ${e}`);
 };
 
-export const grReportingMock: ReportingService = {
+export const grReportingMock: ReportingService & Finalizable = {
   appStarted: () => {},
   beforeLocationChanged: () => {},
   changeDisplayed: () => {},
@@ -45,8 +35,8 @@
   dashboardDisplayed: () => {},
   diffViewContentDisplayed: () => {},
   diffViewDisplayed: () => {},
-  diffViewFullyLoaded: () => {},
   fileListDisplayed: () => {},
+  finalize: () => {},
   getTimer: () => new MockTimer(),
   locationChanged: (page: string) => {
     log(`locationChanged: ${page}`);
@@ -57,28 +47,20 @@
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   pluginsFailed: () => {},
-  recordDraftInteraction: () => {},
   reporter: () => {},
   reportErrorDialog: (message: string) => {
     log(`reportErrorDialog: ${message}`);
   },
-  error: () => {
-    log('error');
+  error: (label, e) => {
+    log(`error ${label}:`, e);
   },
-  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}`);
-  },
+  reportExecution: (_id: Execution, _details?: EventDetails) => {},
+  trackApi: (_pluginApi: PluginApi, _object: string, _method: string) => {},
   reportExtension: () => {},
   reportInteraction: (
-    eventName: string | Interaction,
-    details?: EventDetails
-  ) => {
-    log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
-  },
+    _eventName: string | Interaction,
+    _details?: EventDetails
+  ) => {},
   reportLifeCycle: () => {},
   reportPluginLifeCycleLog: () => {},
   reportPluginInteractionLog: () => {},
@@ -87,5 +69,4 @@
   setChangeId: () => {},
   time: () => {},
   timeEnd: () => {},
-  timeEndWithAverage: () => {},
 };
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js
deleted file mode 100644
index 73f8580..0000000
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js
+++ /dev/null
@@ -1,32 +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 {GrReporting} from './gr-reporting_impl.js';
-import {grReportingMock} from './gr-reporting_mock.js';
-suite('gr-reporting_mock tests', () => {
-  test('mocks all public methods', () => {
-    const methods = Object.getOwnPropertyNames(GrReporting.prototype)
-        .filter(name => typeof GrReporting.prototype[name] === 'function')
-        .filter(name => !name.startsWith('_') && name !== 'constructor')
-        .sort();
-    const mockMethods = Object.getOwnPropertyNames(grReportingMock)
-        .sort();
-    assert.deepEqual(methods, mockMethods);
-  });
-});
-
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts
new file mode 100644
index 0000000..2b34627
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
+import {GrReporting} from './gr-reporting_impl';
+import {grReportingMock} from './gr-reporting_mock';
+
+suite('gr-reporting_mock tests', () => {
+  test('mocks all public methods', () => {
+    const methods = Object.getOwnPropertyNames(GrReporting.prototype)
+      .filter(
+        name => typeof (GrReporting as any).prototype[name] === 'function'
+      )
+      .filter(name => !name.startsWith('_') && name !== 'constructor')
+      .sort();
+    const mockMethods = Object.getOwnPropertyNames(grReportingMock).sort();
+    assert.deepEqual(methods, mockMethods);
+  });
+});
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
deleted file mode 100644
index 9b71908..0000000
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ /dev/null
@@ -1,499 +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 {GrReporting, DEFAULT_STARTUP_TIMERS, initErrorReporter} from './gr-reporting_impl.js';
-import {appContext} from '../app-context.js';
-suite('gr-reporting tests', () => {
-  let service;
-
-  let clock;
-  let fakePerformance;
-
-  const NOW_TIME = 100;
-
-  setup(() => {
-    clock = sinon.useFakeTimers(NOW_TIME);
-    service = new GrReporting(appContext.flagsService);
-    service._baselines = {...DEFAULT_STARTUP_TIMERS};
-    sinon.stub(service, 'reporter');
-  });
-
-  teardown(() => {
-    clock.restore();
-  });
-
-  test('appStarted', () => {
-    fakePerformance = {
-      navigationStart: 1,
-      loadEventEnd: 2,
-    };
-    fakePerformance.toJSON = () => fakePerformance;
-    sinon.stub(service, 'performanceTiming').get(() => fakePerformance);
-    sinon.stub(window.performance, 'now').returns(42);
-    service.appStarted();
-    assert.isTrue(
-        service.reporter.calledWithMatch(
-            'timing-report', 'UI Latency', 'App Started', 42
-        ));
-    assert.isTrue(
-        service.reporter.calledWithExactly(
-            'timing-report', 'UI Latency', 'NavResTime - loadEventEnd',
-            fakePerformance.loadEventEnd - fakePerformance.navigationStart,
-            undefined, true)
-    );
-  });
-
-  test('WebComponentsReady', () => {
-    sinon.stub(window.performance, 'now').returns(42);
-    service.timeEnd('WebComponentsReady');
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'WebComponentsReady', 42
-    ));
-  });
-
-  test('beforeLocationChanged', () => {
-    service._baselines['garbage'] = 'monster';
-    sinon.stub(service, 'time');
-    service.beforeLocationChanged();
-    assert.isTrue(service.time.calledWithExactly('DashboardDisplayed'));
-    assert.isTrue(service.time.calledWithExactly('ChangeDisplayed'));
-    assert.isTrue(service.time.calledWithExactly('ChangeFullyLoaded'));
-    assert.isTrue(service.time.calledWithExactly('DiffViewDisplayed'));
-    assert.isTrue(service.time.calledWithExactly('FileListDisplayed'));
-    assert.isFalse(service._baselines.hasOwnProperty('garbage'));
-  });
-
-  test('changeDisplayed', () => {
-    sinon.spy(service, 'timeEnd');
-    service.changeDisplayed();
-    assert.isFalse(service.timeEnd.calledWith('ChangeDisplayed'));
-    assert.isTrue(service.timeEnd.calledWith('StartupChangeDisplayed'));
-    service.changeDisplayed();
-    assert.isTrue(service.timeEnd.calledWith('ChangeDisplayed'));
-  });
-
-  test('changeFullyLoaded', () => {
-    sinon.spy(service, 'timeEnd');
-    service.changeFullyLoaded();
-    assert.isFalse(
-        service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
-    assert.isTrue(
-        service.timeEnd.calledWithExactly('StartupChangeFullyLoaded'));
-    service.changeFullyLoaded();
-    assert.isTrue(service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
-  });
-
-  test('diffViewDisplayed', () => {
-    sinon.spy(service, 'timeEnd');
-    service.diffViewDisplayed();
-    assert.isFalse(service.timeEnd.calledWith('DiffViewDisplayed'));
-    assert.isTrue(service.timeEnd.calledWith('StartupDiffViewDisplayed'));
-    service.diffViewDisplayed();
-    assert.isTrue(service.timeEnd.calledWith('DiffViewDisplayed'));
-  });
-
-  test('fileListDisplayed', () => {
-    sinon.spy(service, 'timeEnd');
-    service.fileListDisplayed();
-    assert.isFalse(
-        service.timeEnd.calledWithExactly('FileListDisplayed'));
-    assert.isTrue(
-        service.timeEnd.calledWithExactly('StartupFileListDisplayed'));
-    service.fileListDisplayed();
-    assert.isTrue(service.timeEnd.calledWithExactly('FileListDisplayed'));
-  });
-
-  test('dashboardDisplayed', () => {
-    sinon.spy(service, 'timeEnd');
-    service.dashboardDisplayed();
-    assert.isFalse(service.timeEnd.calledWith('DashboardDisplayed'));
-    assert.isTrue(service.timeEnd.calledWith('StartupDashboardDisplayed'));
-    service.dashboardDisplayed();
-    assert.isTrue(service.timeEnd.calledWith('DashboardDisplayed'));
-  });
-
-  test('dashboardDisplayed details', () => {
-    sinon.spy(service, 'timeEnd');
-    sinon.stub(window, 'performance').value( {
-      memory: {
-        usedJSHeapSize: 1024 * 1024,
-      },
-      measure: () => {},
-      now: () => { 42; },
-    });
-    service.reportRpcTiming('/changes/*~*/comments', 500);
-    service.dashboardDisplayed();
-    assert.isTrue(
-        service.timeEnd.calledWithExactly('StartupDashboardDisplayed',
-            {rpcList: [
-              {
-                anonymizedUrl: '/changes/*~*/comments',
-                elapsed: 500,
-              },
-            ],
-            screenSize: {
-              width: window.screen.width,
-              height: window.screen.height,
-            },
-            viewport: {
-              width: document.documentElement.clientWidth,
-              height: document.documentElement.clientHeight,
-            },
-            usedJSHeapSizeMb: 1,
-            hiddenDurationMs: 0,
-            }
-        ));
-  });
-
-  suite('hidden duration', () => {
-    let nowStub;
-    let visibilityStateStub;
-    const assertHiddenDurationsMs = hiddenDurationMs => {
-      service.dashboardDisplayed();
-      assert.isTrue(
-          service.timeEnd.calledWithMatch('StartupDashboardDisplayed',
-              {hiddenDurationMs}
-          ));
-    };
-
-    setup(() => {
-      sinon.spy(service, 'timeEnd');
-      nowStub = sinon.stub(window.performance, 'now');
-      visibilityStateStub = {
-        value: value => {
-          Object.defineProperty(document, 'visibilityState',
-              {value, configurable: true});
-        },
-      };
-    });
-
-    test('starts in hidden', () => {
-      nowStub.returns(10);
-      visibilityStateStub.value('hidden');
-      service.onVisibilityChange();
-      nowStub.returns(15);
-      visibilityStateStub.value('visible');
-      service.onVisibilityChange();
-      assertHiddenDurationsMs(5);
-    });
-
-    test('full in hidden', () => {
-      nowStub.returns(10);
-      visibilityStateStub.value('hidden');
-      assertHiddenDurationsMs(10);
-    });
-
-    test('full in visible', () => {
-      nowStub.returns(10);
-      visibilityStateStub.value('visible');
-      assertHiddenDurationsMs(0);
-    });
-
-    test('accumulated', () => {
-      nowStub.returns(10);
-      visibilityStateStub.value('hidden');
-      service.onVisibilityChange();
-      nowStub.returns(15);
-      visibilityStateStub.value('visible');
-      service.onVisibilityChange();
-      nowStub.returns(20);
-      visibilityStateStub.value('hidden');
-      service.onVisibilityChange();
-      nowStub.returns(25);
-      assertHiddenDurationsMs(10);
-    });
-
-    test('reset after location change', () => {
-      nowStub.returns(10);
-      visibilityStateStub.value('hidden');
-      assertHiddenDurationsMs(10);
-      visibilityStateStub.value('visible');
-      nowStub.returns(15);
-      service.beforeLocationChanged();
-      service.timeEnd.resetHistory();
-      service.dashboardDisplayed();
-      assert.isTrue(
-          service.timeEnd.calledWithMatch('DashboardDisplayed',
-              {hiddenDurationMs: 0}
-          ));
-    });
-  });
-
-  test('time and timeEnd', () => {
-    const nowStub = sinon.stub(window.performance, 'now').returns(0);
-    service.time('foo');
-    nowStub.returns(1);
-    service.time('bar');
-    nowStub.returns(2);
-    service.timeEnd('bar');
-    nowStub.returns(3);
-    service.timeEnd('foo');
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'foo', 3
-    ));
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'bar', 1
-    ));
-  });
-
-  test('timer object', () => {
-    const nowStub = sinon.stub(window.performance, 'now').returns(100);
-    const timer = service.getTimer('foo-bar');
-    nowStub.returns(150);
-    timer.end();
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'foo-bar', 50));
-  });
-
-  test('timer object double call', () => {
-    const timer = service.getTimer('foo-bar');
-    timer.end();
-    assert.isTrue(service.reporter.calledOnce);
-    assert.throws(() => {
-      timer.end();
-    }, 'Timer for "foo-bar" already ended.');
-  });
-
-  test('timer object maximum', () => {
-    const nowStub = sinon.stub(window.performance, 'now').returns(100);
-    const timer = service.getTimer('foo-bar').withMaximum(100);
-    nowStub.returns(150);
-    timer.end();
-    assert.isTrue(service.reporter.calledOnce);
-
-    timer.reset();
-    nowStub.returns(260);
-    timer.end();
-    assert.isTrue(service.reporter.calledOnce);
-  });
-
-  test('recordDraftInteraction', () => {
-    const key = 'TimeBetweenDraftActions';
-    const nowStub = sinon.stub(window.performance, 'now').returns(100);
-    const timingStub = sinon.stub(service, '_reportTiming');
-    service.recordDraftInteraction();
-    assert.isFalse(timingStub.called);
-
-    nowStub.returns(200);
-    service.recordDraftInteraction();
-    assert.isTrue(timingStub.calledOnce);
-    assert.equal(timingStub.lastCall.args[0], key);
-    assert.equal(timingStub.lastCall.args[1], 100);
-
-    nowStub.returns(350);
-    service.recordDraftInteraction();
-    assert.isTrue(timingStub.calledTwice);
-    assert.equal(timingStub.lastCall.args[0], key);
-    assert.equal(timingStub.lastCall.args[1], 150);
-
-    nowStub.returns(370 + 2 * 60 * 1000);
-    service.recordDraftInteraction();
-    assert.isFalse(timingStub.calledThrice);
-  });
-
-  test('timeEndWithAverage', () => {
-    const nowStub = sinon.stub(window.performance, 'now').returns(0);
-    nowStub.returns(1000);
-    service.time('foo');
-    nowStub.returns(1100);
-    service.timeEndWithAverage('foo', 'bar', 10);
-    assert.isTrue(service.reporter.calledTwice);
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'foo', 100));
-    assert.isTrue(service.reporter.calledWithMatch(
-        'timing-report', 'UI Latency', 'bar', 10));
-  });
-
-  test('reportExtension', () => {
-    service.reportExtension('foo');
-    assert.isTrue(service.reporter.calledWithExactly(
-        'lifecycle', 'Extension detected', 'Extension detected', undefined,
-        {name: 'foo'}
-    ));
-  });
-
-  test('reportInteraction', () => {
-    service.reporter.restore();
-    sinon.spy(service, '_reportEvent');
-    service.pluginsLoaded(); // so we don't cache
-    service.reportInteraction('button-click', {name: 'sendReply'});
-    assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
-        {
-          type: 'interaction',
-          name: 'button-click',
-          eventDetails: JSON.stringify({name: 'sendReply'}),
-        }
-    ));
-  });
-
-  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);
-    sinon.spy(service, '_reportEvent');
-    const dispatchStub = sinon.spy(document, 'dispatchEvent');
-    service.pluginsLoaded();
-    service.time('timeAction');
-    service.timeEnd('timeAction');
-    assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
-        {
-          type: 'timing-report',
-          category: 'UI Latency',
-          name: 'timeAction',
-          value: 0,
-          eventStart: 42,
-        }
-    ));
-    assert.equal(dispatchStub.getCall(2).args[0].detail.eventStart, 42);
-  });
-
-  suite('plugins', () => {
-    setup(() => {
-      service.reporter.restore();
-      sinon.stub(service, '_reportEvent');
-    });
-
-    test('pluginsLoaded reports time', () => {
-      sinon.stub(window.performance, 'now').returns(42);
-      service.pluginsLoaded();
-      assert.isTrue(service._reportEvent.calledWithMatch(
-          {
-            type: 'timing-report',
-            category: 'UI Latency',
-            name: 'PluginsLoaded',
-            value: 42,
-          }
-      ));
-    });
-
-    test('pluginsLoaded reports plugins', () => {
-      service.pluginsLoaded(['foo', 'bar']);
-      assert.isTrue(service._reportEvent.calledWithMatch(
-          {
-            type: 'lifecycle',
-            category: 'Plugins installed',
-            eventDetails: JSON.stringify({pluginsList: ['foo', 'bar']}),
-          }
-      ));
-    });
-
-    test('caches reports if plugins are not loaded', () => {
-      service.timeEnd('foo');
-      assert.isFalse(service._reportEvent.called);
-    });
-
-    test('reports if plugins are loaded', () => {
-      service.pluginsLoaded();
-      assert.isTrue(service._reportEvent.called);
-    });
-
-    test('reports if metrics plugin xyz is loaded', () => {
-      service.pluginLoaded('metrics-xyz');
-      assert.isTrue(service._reportEvent.called);
-    });
-
-    test('reports cached events preserving order', () => {
-      service.time('foo');
-      service.time('bar');
-      service.timeEnd('foo');
-      service.pluginsLoaded();
-      service.timeEnd('bar');
-      assert.isTrue(service._reportEvent.getCall(0).calledWithMatch(
-          {type: 'timing-report', category: 'UI Latency', name: 'foo'}
-      ));
-      assert.isTrue(service._reportEvent.getCall(1).calledWithMatch(
-          {type: 'timing-report', category: 'UI Latency',
-            name: 'PluginsLoaded'}
-      ));
-      assert.isTrue(service._reportEvent.getCall(2).calledWithMatch(
-          {type: 'lifecycle', category: 'Plugins installed'}
-      ));
-      assert.isTrue(service._reportEvent.getCall(3).calledWithMatch(
-          {type: 'timing-report', category: 'UI Latency', name: 'bar'}
-      ));
-    });
-  });
-
-  test('search', () => {
-    service.locationChanged('_handleSomeRoute');
-    assert.isTrue(service.reporter.calledWithExactly(
-        'nav-report', 'Location Changed', 'Page', '_handleSomeRoute'));
-  });
-
-  suite('exception logging', () => {
-    let fakeWindow;
-    let reporter;
-
-    const emulateThrow = function(msg, url, line, column, error) {
-      return fakeWindow.onerror(msg, url, line, column, error);
-    };
-
-    setup(() => {
-      reporter = service.reporter;
-      fakeWindow = {
-        handlers: {},
-        addEventListener(type, handler) {
-          this.handlers[type] = handler;
-        },
-      };
-      sinon.stub(console, 'error');
-      Object.defineProperty(appContext, 'reportingService', {
-        get() {
-          return service;
-        },
-      });
-      const errorReporter = initErrorReporter(appContext);
-      errorReporter.catchErrors(fakeWindow);
-    });
-
-    test('is reported', () => {
-      const error = new Error('bar');
-      error.stack = undefined;
-      emulateThrow('bar', 'http://url', 4, 2, error);
-      assert.isTrue(reporter.calledWith('error', 'exception', 'onError: bar'));
-    });
-
-    test('is reported with stack', () => {
-      const error = new Error('bar');
-      emulateThrow('bar', 'http://url', 4, 2, error);
-      const eventDetails = reporter.lastCall.args[4];
-      assert.equal(error.stack, eventDetails.stack);
-    });
-
-    test('prevent default event handler', () => {
-      assert.isTrue(emulateThrow());
-    });
-
-    test('unhandled rejection', () => {
-      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/gr-reporting/gr-reporting_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
new file mode 100644
index 0000000..9c5e20d
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
@@ -0,0 +1,565 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {
+  GrReporting,
+  DEFAULT_STARTUP_TIMERS,
+  initErrorReporter,
+} from './gr-reporting_impl';
+import {getAppContext} from '../app-context';
+import {Deduping} from '../../api/reporting';
+import {SinonFakeTimers} from 'sinon';
+import {assert} from '@open-wc/testing';
+
+suite('gr-reporting tests', () => {
+  // We have to type as any because we access
+  // private properties for testing.
+  let service: any;
+
+  let clock: SinonFakeTimers;
+  let fakePerformance: any;
+
+  const NOW_TIME = 100;
+
+  setup(() => {
+    clock = sinon.useFakeTimers(NOW_TIME);
+    service = new GrReporting(getAppContext().flagsService);
+    service._baselines = {...DEFAULT_STARTUP_TIMERS};
+    sinon.stub(service, 'reporter');
+  });
+
+  teardown(() => {
+    clock.restore();
+  });
+
+  test('appStarted', () => {
+    fakePerformance = {
+      navigationStart: 1,
+      loadEventEnd: 2,
+    };
+    fakePerformance.toJSON = () => fakePerformance;
+    sinon.stub(service, 'performanceTiming').get(() => fakePerformance);
+    sinon.stub(window.performance, 'now').returns(42);
+    service.appStarted();
+    assert.isTrue(
+      service.reporter.calledWithMatch(
+        'timing-report',
+        'UI Latency',
+        'App Started',
+        42
+      )
+    );
+    assert.isTrue(
+      service.reporter.calledWithExactly(
+        'timing-report',
+        'UI Latency',
+        'NavResTime - loadEventEnd',
+        fakePerformance.loadEventEnd - fakePerformance.navigationStart,
+        undefined,
+        true
+      )
+    );
+  });
+
+  test('WebComponentsReady', () => {
+    sinon.stub(window.performance, 'now').returns(42);
+    service.timeEnd('WebComponentsReady');
+    assert.isTrue(
+      service.reporter.calledWithMatch(
+        'timing-report',
+        'UI Latency',
+        'WebComponentsReady',
+        42
+      )
+    );
+  });
+
+  test('beforeLocationChanged', () => {
+    service._baselines['garbage'] = 'monster';
+    sinon.stub(service, 'time');
+    service.beforeLocationChanged();
+    assert.isTrue(service.time.calledWithExactly('DashboardDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('ChangeDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(service.time.calledWithExactly('DiffViewDisplayed'));
+    assert.isTrue(service.time.calledWithExactly('FileListDisplayed'));
+    assert.isFalse(
+      Object.prototype.hasOwnProperty.call(service._baselines, 'garbage')
+    );
+  });
+
+  test('changeDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.changeDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('ChangeDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupChangeDisplayed'));
+    service.changeDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('ChangeDisplayed'));
+  });
+
+  test('changeFullyLoaded', () => {
+    sinon.spy(service, 'timeEnd');
+    service.changeFullyLoaded();
+    assert.isFalse(service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+    assert.isTrue(
+      service.timeEnd.calledWithExactly('StartupChangeFullyLoaded')
+    );
+    service.changeFullyLoaded();
+    assert.isTrue(service.timeEnd.calledWithExactly('ChangeFullyLoaded'));
+  });
+
+  test('diffViewDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.diffViewDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('DiffViewDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupDiffViewDisplayed'));
+    service.diffViewDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('DiffViewDisplayed'));
+  });
+
+  test('fileListDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.fileListDisplayed();
+    assert.isFalse(service.timeEnd.calledWithExactly('FileListDisplayed'));
+    assert.isTrue(
+      service.timeEnd.calledWithExactly('StartupFileListDisplayed')
+    );
+    service.fileListDisplayed();
+    assert.isTrue(service.timeEnd.calledWithExactly('FileListDisplayed'));
+  });
+
+  test('dashboardDisplayed', () => {
+    sinon.spy(service, 'timeEnd');
+    service.dashboardDisplayed();
+    assert.isFalse(service.timeEnd.calledWith('DashboardDisplayed'));
+    assert.isTrue(service.timeEnd.calledWith('StartupDashboardDisplayed'));
+    service.dashboardDisplayed();
+    assert.isTrue(service.timeEnd.calledWith('DashboardDisplayed'));
+  });
+
+  test('dashboardDisplayed details', () => {
+    sinon.spy(service, 'timeEnd');
+    sinon.stub(window, 'performance').value({
+      memory: {
+        usedJSHeapSize: 1024 * 1024,
+      },
+      measure: () => {},
+      now: () => {
+        42;
+      },
+    });
+    service.reportRpcTiming('/changes/*~*/comments', 500);
+    service.dashboardDisplayed();
+    assert.isTrue(
+      service.timeEnd.calledWithExactly('StartupDashboardDisplayed', {
+        rpcList: [
+          {
+            anonymizedUrl: '/changes/*~*/comments',
+            elapsed: 500,
+          },
+        ],
+        screenSize: {
+          width: window.screen.width,
+          height: window.screen.height,
+        },
+        viewport: {
+          width: document.documentElement.clientWidth,
+          height: document.documentElement.clientHeight,
+        },
+        usedJSHeapSizeMb: 1,
+        hiddenDurationMs: 0,
+      })
+    );
+  });
+
+  suite('hidden duration', () => {
+    let nowStub: sinon.SinonStub;
+    let visibilityStateStub: sinon.SinonStub;
+    const assertHiddenDurationsMs = (hiddenDurationMs: number) => {
+      service.dashboardDisplayed();
+      assert.isTrue(
+        service.timeEnd.calledWithMatch('StartupDashboardDisplayed', {
+          hiddenDurationMs,
+        })
+      );
+    };
+
+    setup(() => {
+      sinon.spy(service, 'timeEnd');
+      nowStub = sinon.stub(window.performance, 'now');
+      visibilityStateStub = {
+        value: value => {
+          Object.defineProperty(document, 'visibilityState', {
+            value,
+            configurable: true,
+          });
+        },
+      } as sinon.SinonStub;
+    });
+
+    test('starts in hidden', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(15);
+      visibilityStateStub.value('visible');
+      service.onVisibilityChange();
+      assertHiddenDurationsMs(5);
+    });
+
+    test('full in hidden', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      assertHiddenDurationsMs(10);
+    });
+
+    test('full in visible', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('visible');
+      assertHiddenDurationsMs(0);
+    });
+
+    test('accumulated', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(15);
+      visibilityStateStub.value('visible');
+      service.onVisibilityChange();
+      nowStub.returns(20);
+      visibilityStateStub.value('hidden');
+      service.onVisibilityChange();
+      nowStub.returns(25);
+      assertHiddenDurationsMs(10);
+    });
+
+    test('reset after location change', () => {
+      nowStub.returns(10);
+      visibilityStateStub.value('hidden');
+      assertHiddenDurationsMs(10);
+      visibilityStateStub.value('visible');
+      nowStub.returns(15);
+      service.beforeLocationChanged();
+      service.timeEnd.resetHistory();
+      service.dashboardDisplayed();
+      assert.isTrue(
+        service.timeEnd.calledWithMatch('DashboardDisplayed', {
+          hiddenDurationMs: 0,
+        })
+      );
+    });
+  });
+
+  test('time and timeEnd', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(0);
+    service.time('foo');
+    nowStub.returns(1);
+    service.time('bar');
+    nowStub.returns(2);
+    service.timeEnd('bar');
+    nowStub.returns(3);
+    service.timeEnd('foo');
+    assert.isTrue(
+      service.reporter.calledWithMatch('timing-report', 'UI Latency', 'foo', 3)
+    );
+    assert.isTrue(
+      service.reporter.calledWithMatch('timing-report', 'UI Latency', 'bar', 1)
+    );
+  });
+
+  test('timer object', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(100);
+    const timer = service.getTimer('foo-bar');
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(
+      service.reporter.calledWithMatch(
+        'timing-report',
+        'UI Latency',
+        'foo-bar',
+        50
+      )
+    );
+  });
+
+  test('timer object double call', () => {
+    const timer = service.getTimer('foo-bar');
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+    assert.throws(() => {
+      timer.end();
+    }, 'Timer for "foo-bar" already ended.');
+  });
+
+  test('timer object maximum', () => {
+    const nowStub = sinon.stub(window.performance, 'now').returns(100);
+    const timer = service.getTimer('foo-bar').withMaximum(100);
+    nowStub.returns(150);
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+
+    timer.reset();
+    nowStub.returns(260);
+    timer.end();
+    assert.isTrue(service.reporter.calledOnce);
+  });
+
+  test('reportExtension', () => {
+    service.reportExtension('foo');
+    assert.isTrue(
+      service.reporter.calledWithExactly(
+        'lifecycle',
+        'Extension detected',
+        'Extension detected',
+        undefined,
+        {name: 'foo'}
+      )
+    );
+  });
+
+  test('reportInteraction', () => {
+    service.reporter.restore();
+    sinon.spy(service, '_reportEvent');
+    service.pluginsLoaded(); // so we don't cache
+    service.reportInteraction('button-click', {name: 'sendReply'});
+    assert.isTrue(
+      service._reportEvent.getCall(2).calledWithMatch({
+        type: 'interaction',
+        name: 'button-click',
+        eventDetails: JSON.stringify({name: 'sendReply'}),
+      })
+    );
+  });
+
+  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);
+    sinon.spy(service, '_reportEvent');
+    const dispatchStub = sinon.spy(document, 'dispatchEvent');
+    service.pluginsLoaded();
+    service.time('timeAction');
+    service.timeEnd('timeAction');
+    assert.isTrue(
+      service._reportEvent.getCall(2).calledWithMatch({
+        type: 'timing-report',
+        category: 'UI Latency',
+        name: 'timeAction',
+        value: 0,
+        eventStart: 42,
+      })
+    );
+    assert.equal(
+      (dispatchStub.getCall(2).args[0] as CustomEvent).detail.eventStart,
+      42
+    );
+  });
+
+  test('dedup', () => {
+    assert.isFalse(service._dedup('a', undefined, undefined));
+    assert.isFalse(service._dedup('a', undefined, undefined));
+
+    let deduping = Deduping.EVENT_ONCE_PER_SESSION;
+    assert.isFalse(service._dedup('b', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('b', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('b', {x: 'bar'}, deduping));
+
+    deduping = Deduping.DETAILS_ONCE_PER_SESSION;
+    assert.isFalse(service._dedup('c', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('c', {x: 'foo'}, deduping));
+    assert.isFalse(service._dedup('c', {x: 'bar'}, deduping));
+    assert.isTrue(service._dedup('c', {x: 'bar'}, deduping));
+
+    deduping = Deduping.EVENT_ONCE_PER_CHANGE;
+    service.setChangeId(1);
+    assert.isFalse(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'bar'}, deduping));
+    service.setChangeId(2);
+    assert.isFalse(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('d', {x: 'bar'}, deduping));
+
+    deduping = Deduping.DETAILS_ONCE_PER_CHANGE;
+    service.setChangeId(1);
+    assert.isFalse(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isFalse(service._dedup('e', {x: 'bar'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'bar'}, deduping));
+    service.setChangeId(2);
+    assert.isFalse(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'foo'}, deduping));
+    assert.isFalse(service._dedup('e', {x: 'bar'}, deduping));
+    assert.isTrue(service._dedup('e', {x: 'bar'}, deduping));
+  });
+
+  suite('plugins', () => {
+    setup(() => {
+      service.reporter.restore();
+      sinon.stub(service, '_reportEvent');
+    });
+
+    test('pluginsLoaded reports time', () => {
+      sinon.stub(window.performance, 'now').returns(42);
+      service.pluginsLoaded();
+      assert.isTrue(
+        service._reportEvent.calledWithMatch({
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'PluginsLoaded',
+          value: 42,
+        })
+      );
+    });
+
+    test('pluginsLoaded reports plugins', () => {
+      service.pluginsLoaded(['foo', 'bar']);
+      assert.isTrue(
+        service._reportEvent.calledWithMatch({
+          type: 'lifecycle',
+          category: 'Plugins installed',
+          eventDetails: JSON.stringify({pluginsList: ['foo', 'bar']}),
+        })
+      );
+    });
+
+    test('caches reports if plugins are not loaded', () => {
+      service.timeEnd('foo');
+      assert.isFalse(service._reportEvent.called);
+    });
+
+    test('reports if plugins are loaded', () => {
+      service.pluginsLoaded();
+      assert.isTrue(service._reportEvent.called);
+    });
+
+    test('reports if metrics plugin xyz is loaded', () => {
+      service.pluginLoaded('metrics-xyz');
+      assert.isTrue(service._reportEvent.called);
+    });
+
+    test('reports cached events preserving order', () => {
+      service.time('foo');
+      service.time('bar');
+      service.timeEnd('foo');
+      service.pluginsLoaded();
+      service.timeEnd('bar');
+      assert.isTrue(
+        service._reportEvent.getCall(0).calledWithMatch({
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'foo',
+        })
+      );
+      assert.isTrue(
+        service._reportEvent.getCall(1).calledWithMatch({
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'PluginsLoaded',
+        })
+      );
+      assert.isTrue(
+        service._reportEvent
+          .getCall(2)
+          .calledWithMatch({type: 'lifecycle', category: 'Plugins installed'})
+      );
+      assert.isTrue(
+        service._reportEvent.getCall(3).calledWithMatch({
+          type: 'timing-report',
+          category: 'UI Latency',
+          name: 'bar',
+        })
+      );
+    });
+  });
+
+  test('search', () => {
+    service.locationChanged('_handleSomeRoute');
+    assert.isTrue(
+      service.reporter.calledWithExactly(
+        'nav-report',
+        'Location Changed',
+        'Page',
+        '_handleSomeRoute'
+      )
+    );
+  });
+
+  suite('exception logging', () => {
+    let fakeWindow: any;
+    let reporter: sinon.SinonStub;
+
+    const emulateThrow = function (
+      msg?: string,
+      url?: string,
+      line?: number,
+      column?: number,
+      error?: Error
+    ) {
+      return fakeWindow.onerror(msg, url, line, column, error);
+    };
+
+    setup(() => {
+      reporter = service.reporter;
+      fakeWindow = {
+        handlers: {},
+        addEventListener(type: string, handler: object) {
+          this.handlers[type] = handler;
+        },
+      };
+      sinon.stub(console, 'error');
+      Object.defineProperty(getAppContext(), 'reportingService', {
+        get() {
+          return service;
+        },
+      });
+      const errorReporter = initErrorReporter(getAppContext().reportingService);
+      errorReporter.catchErrors(fakeWindow);
+    });
+
+    test('is reported', () => {
+      const error = new Error('bar');
+      error.stack = undefined;
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      assert.isTrue(reporter.calledWith('error', 'exception', 'onError'));
+    });
+
+    test('is reported with message', () => {
+      const error = new Error('bar');
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      const eventDetails = reporter.lastCall.args[4];
+      assert.equal(eventDetails.errorMessage, 'onError: bar');
+    });
+
+    test('is reported with stack', () => {
+      const error = new Error('bar');
+      emulateThrow('bar', 'http://url', 4, 2, error);
+      const eventDetails = reporter.lastCall.args[4];
+      assert.equal(error.stack, eventDetails.stack);
+    });
+
+    test('prevent default event handler', () => {
+      assert.isTrue(emulateThrow());
+    });
+
+    test('unhandled rejection', () => {
+      const newError = new Error('bar');
+      fakeWindow.handlers['unhandledrejection']({reason: newError});
+      assert.isTrue(
+        reporter.calledWith('error', 'exception', 'unhandledrejection')
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
new file mode 100644
index 0000000..0d0c88f
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -0,0 +1,3309 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+/* NB: Order is important, because of namespaced classes. */
+
+import {GrEtagDecorator} from '../../elements/shared/gr-rest-api-interface/gr-etag-decorator';
+import {
+  FetchJSONRequest,
+  FetchParams,
+  FetchPromisesCache,
+  GrRestApiHelper,
+  parsePrefixedJSON,
+  readResponsePayload,
+  SendJSONRequest,
+  SendRequest,
+  SiteBasedCache,
+} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {parseDate} from '../../utils/date-util';
+import {getBaseUrl} from '../../utils/url-util';
+import {Finalizable} from '../registry';
+import {getParentIndex, isMergeParent} from '../../utils/patch-set-util';
+import {
+  ListChangesOption,
+  listChangesOptionsToHex,
+} from '../../utils/change-util';
+import {assertNever, hasOwnProperty} from '../../utils/common-util';
+import {AuthRequestInit, AuthService} from '../gr-auth/gr-auth';
+import {
+  AccountCapabilityInfo,
+  AccountDetailInfo,
+  AccountExternalIdInfo,
+  AccountId,
+  AccountInfo,
+  ActionNameToActionInfoMap,
+  Base64File,
+  Base64FileContent,
+  Base64ImageFile,
+  BasePatchSetNum,
+  BlameInfo,
+  BranchInfo,
+  BranchInput,
+  BranchName,
+  CapabilityInfoMap,
+  ChangeId,
+  ChangeInfo,
+  ChangeMessageId,
+  ChangeViewChangeInfo,
+  CommentInfo,
+  CommentInput,
+  CommitId,
+  CommitInfo,
+  ConfigInfo,
+  ConfigInput,
+  ContributorAgreementInfo,
+  ContributorAgreementInput,
+  DashboardId,
+  DashboardInfo,
+  DeleteDraftCommentsInput,
+  DiffPreferenceInput,
+  DocResult,
+  EditInfo,
+  EDIT,
+  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,
+  PARENT,
+  ParsedJSON,
+  Password,
+  PatchRange,
+  PatchSetNum,
+  PathToCommentsInfoMap,
+  PathToRobotCommentsInfoMap,
+  PluginInfo,
+  PreferencesInfo,
+  PreferencesInput,
+  ProjectAccessInfo,
+  RepoAccessInfoMap,
+  ProjectAccessInput,
+  ProjectInfo,
+  ProjectInfoWithName,
+  ProjectInput,
+  ProjectWatchInfo,
+  RelatedChangesInfo,
+  RepoName,
+  RequestPayload,
+  ReviewInput,
+  RevisionId,
+  ServerInfo,
+  SshKeyInfo,
+  SubmittedTogetherInfo,
+  SuggestedReviewerInfo,
+  TagInfo,
+  TagInput,
+  TopMenuEntryInfo,
+  UrlEncodedCommentId,
+  FixReplacementInfo,
+} from '../../types/common';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  IgnoreWhitespaceType,
+} from '../../types/diff';
+import {
+  CancelConditionCallback,
+  GetDiffCommentsOutput,
+  GetDiffRobotCommentsOutput,
+  RestApiService,
+} from './gr-rest-api';
+import {
+  CommentSide,
+  createDefaultDiffPrefs,
+  createDefaultEditPrefs,
+  createDefaultPreferences,
+  HttpMethod,
+  ReviewerState,
+} from '../../constants/constants';
+import {firePageError, fireServerError} from '../../utils/event-util';
+import {ParsedChangeInfo} from '../../types/types';
+import {ErrorCallback} from '../../api/rest';
+import {addDraftProp, DraftInfo} from '../../utils/comment-util';
+import {BaseScheduler, Scheduler} from '../scheduler/scheduler';
+import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler';
+import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util';
+
+const MAX_PROJECT_RESULTS = 25;
+
+const Requests = {
+  SEND_DIFF_DRAFT: 'sendDiffDraft',
+};
+
+const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
+  'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
+const HEADER_REPORTING_BLOCK_REGEX = /^set-cookie$/i;
+
+const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
+const ANONYMIZED_REVISION_BASE_URL =
+  ANONYMIZED_CHANGE_BASE_URL + '/revisions/*';
+
+let siteBasedCache = new SiteBasedCache(); // Shared across instances.
+let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances.
+let pendingRequest: {[promiseName: string]: Array<Promise<unknown>>} = {}; // Shared across instances.
+let grEtagDecorator = new GrEtagDecorator(); // Shared across instances.
+let projectLookup: {[changeNum: string]: Promise<RepoName | undefined>} = {}; // Shared across instances.
+
+function suppress404s(res?: Response | null) {
+  if (!res || res.status === 404) return;
+  // This is the default error handling behavior of the rest-api-helper.
+  fireServerError(res);
+}
+
+interface FetchChangeJSON {
+  reportEndpointAsIs?: boolean;
+  endpoint: string;
+  anonymizedEndpoint?: string;
+  revision?: RevisionId;
+  changeNum: NumericChangeId;
+  errFn?: ErrorCallback;
+  params?: FetchParams;
+  fetchOptions?: AuthRequestInit;
+  // TODO(TS): The following properties are not used, however some methods
+  // set them to true. They should be either changed to reportEndpointAsIs: true
+  // or deleted. This should be done carefully case by case.
+  reportEndpointAsId?: true;
+}
+
+interface SendChangeRequestBase {
+  patchNum?: PatchSetNum;
+  reportEndpointAsIs?: boolean;
+  endpoint: string;
+  anonymizedEndpoint?: string;
+  changeNum: NumericChangeId;
+  method: HttpMethod | undefined;
+  errFn?: ErrorCallback;
+  headers?: Record<string, string>;
+  contentType?: string;
+  body?: string | object;
+
+  // TODO(TS): The following properties are not used, however some methods
+  // set them to true. They should be either changed to reportEndpointAsIs: true
+  // or deleted. This should be done carefully case by case.
+  reportUrlAsIs?: true;
+  reportEndpointAsId?: true;
+}
+
+interface SendRawChangeRequest extends SendChangeRequestBase {
+  parseResponse?: false | null;
+}
+
+interface SendJSONChangeRequest extends SendChangeRequestBase {
+  parseResponse: true;
+}
+
+interface QueryChangesParams {
+  [paramName: string]: string | undefined | number | string[];
+  O?: string; // options
+  S: number; // start
+  n?: number; // changes per page
+  q?: string | string[]; // query/queries
+}
+
+interface QueryAccountsParams {
+  [paramName: string]: string | undefined | null | number;
+  q: string;
+  n?: number;
+  o?: string;
+}
+
+interface QueryGroupsParams {
+  [paramName: string]: string | undefined | null | number;
+  s: string;
+  n?: number;
+  p?: string;
+}
+
+interface QuerySuggestedReviewersParams {
+  [paramName: string]: string | undefined | null | number;
+  n: number;
+  q?: string;
+  'reviewer-state': ReviewerState;
+}
+
+interface GetDiffParams {
+  [paramName: string]: string | undefined | null | number | boolean;
+  intraline?: boolean | null;
+  whitespace?: IgnoreWhitespaceType;
+  parent?: number;
+  base?: PatchSetNum;
+}
+
+type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
+
+export function testOnlyResetGrRestApiSharedObjects(authService: AuthService) {
+  siteBasedCache = new SiteBasedCache();
+  fetchPromisesCache = new FetchPromisesCache();
+  pendingRequest = {};
+  grEtagDecorator = new GrEtagDecorator();
+  projectLookup = {};
+  authService.clearCache();
+}
+
+function createReadScheduler() {
+  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10);
+}
+
+function createWriteScheduler() {
+  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5);
+}
+
+function createSerializingScheduler() {
+  return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 1);
+}
+
+export class GrRestApiServiceImpl implements RestApiService, Finalizable {
+  readonly _cache = siteBasedCache; // Shared across instances.
+
+  readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.
+
+  readonly _pendingRequests = pendingRequest; // Shared across instances.
+
+  readonly _etags = grEtagDecorator; // Shared across instances.
+
+  // readonly, but set in tests.
+  _projectLookup = projectLookup; // Shared across instances.
+
+  // The value is set in created, before any other actions
+  // Private, but used in tests.
+  readonly _restApiHelper: GrRestApiHelper;
+
+  // Used to serialize requests for certain RPCs
+  readonly _serialScheduler: Scheduler<Response>;
+
+  constructor(private readonly authService: AuthService) {
+    this._restApiHelper = new GrRestApiHelper(
+      this._cache,
+      this.authService,
+      this._sharedFetchPromises,
+      createReadScheduler(),
+      createWriteScheduler()
+    );
+    this._serialScheduler = createSerializingScheduler();
+  }
+
+  finalize() {}
+
+  _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
+    // Cache is shared across instances
+    return this._restApiHelper.fetchCacheURL(req);
+  }
+
+  getResponseObject(response: Response): Promise<ParsedJSON> {
+    return this._restApiHelper.getResponseObject(response);
+  }
+
+  getConfig(noCache?: boolean): Promise<ServerInfo | undefined> {
+    if (!noCache) {
+      return this._fetchSharedCacheURL({
+        url: '/config/server/info',
+        reportUrlAsIs: true,
+      }) as Promise<ServerInfo | undefined>;
+    }
+
+    return this._restApiHelper.fetchJSON({
+      url: '/config/server/info',
+      reportUrlAsIs: true,
+    }) as Promise<ServerInfo | undefined>;
+  }
+
+  getRepo(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<ProjectInfo | undefined> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/projects/' + encodeURIComponent(repo),
+      errFn,
+      anonymizedUrl: '/projects/*',
+    }) as Promise<ProjectInfo | undefined>;
+  }
+
+  getProjectConfig(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<ConfigInfo | undefined> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/projects/' + encodeURIComponent(repo) + '/config',
+      errFn,
+      anonymizedUrl: '/projects/*/config',
+    }) as Promise<ConfigInfo | undefined>;
+  }
+
+  getRepoAccess(repo: RepoName): Promise<RepoAccessInfoMap | undefined> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: '/access/?project=' + encodeURIComponent(repo),
+      anonymizedUrl: '/access/?project=*',
+    }) as Promise<RepoAccessInfoMap | undefined>;
+  }
+
+  getRepoDashboards(
+    repo: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<DashboardInfo[] | undefined> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
+      errFn,
+      anonymizedUrl: '/projects/*/dashboards?inherited',
+    }) as Promise<DashboardInfo[] | 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`;
+    this._cache.delete(url);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url,
+      body: config,
+      anonymizedUrl: '/projects/*/config',
+    });
+  }
+
+  runRepoGC(repo: RepoName): Promise<Response> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(repo);
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: `/projects/${encodeName}/gc`,
+      body: '',
+      anonymizedUrl: '/projects/*/gc',
+    });
+  }
+
+  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);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/projects/${encodeName}`,
+      body: config,
+      anonymizedUrl: '/projects/*',
+    });
+  }
+
+  createGroup(config: GroupInput & {name: string}): Promise<Response> {
+    const encodeName = encodeURIComponent(config.name);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeName}`,
+      body: config,
+      anonymizedUrl: '/groups/*',
+    });
+  }
+
+  getGroupConfig(
+    group: GroupId | GroupName,
+    errFn?: ErrorCallback
+  ): Promise<GroupInfo | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeURIComponent(group)}/detail`,
+      errFn,
+      anonymizedUrl: '/groups/*/detail',
+    }) as Promise<GroupInfo | undefined>;
+  }
+
+  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(repo);
+    const encodeRef = encodeURIComponent(ref);
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: `/projects/${encodeName}/branches/${encodeRef}`,
+      body: '',
+      anonymizedUrl: '/projects/*/branches/*',
+    });
+  }
+
+  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(repo);
+    const encodeRef = encodeURIComponent(ref);
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: `/projects/${encodeName}/tags/${encodeRef}`,
+      body: '',
+      anonymizedUrl: '/projects/*/tags/*',
+    });
+  }
+
+  createRepoBranch(
+    name: RepoName,
+    branch: BranchName,
+    revision: BranchInput
+  ): Promise<Response> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(name);
+    const encodeBranch = encodeURIComponent(branch);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/projects/${encodeName}/branches/${encodeBranch}`,
+      body: revision,
+      anonymizedUrl: '/projects/*/branches/*',
+    });
+  }
+
+  createRepoTag(
+    name: RepoName,
+    tag: string,
+    revision: TagInput
+  ): Promise<Response> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    const encodeName = encodeURIComponent(name);
+    const encodeTag = encodeURIComponent(tag);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/projects/${encodeName}/tags/${encodeTag}`,
+      body: revision,
+      anonymizedUrl: '/projects/*/tags/*',
+    });
+  }
+
+  getIsGroupOwner(groupName?: GroupName): Promise<boolean> {
+    if (!groupName) return Promise.resolve(false);
+    const encodeName = encodeURIComponent(groupName);
+    const req = {
+      url: `/groups/?owned&g=${encodeName}`,
+      anonymizedUrl: '/groups/owned&g=*',
+    };
+    return this._fetchSharedCacheURL(req).then(configs =>
+      hasOwnProperty(configs, groupName)
+    );
+  }
+
+  getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]> {
+    const encodeName = encodeURIComponent(groupName);
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeName}/members/`,
+      anonymizedUrl: '/groups/*/members',
+    }) as unknown as Promise<AccountInfo[]>;
+  }
+
+  getIncludedGroup(
+    groupName: GroupId | GroupName
+  ): Promise<GroupInfo[] | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: `/groups/${encodeURIComponent(groupName)}/groups/`,
+      anonymizedUrl: '/groups/*/groups',
+    }) as Promise<GroupInfo[] | undefined>;
+  }
+
+  saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeId}/name`,
+      body: {name},
+      anonymizedUrl: '/groups/*/name',
+    });
+  }
+
+  saveGroupOwner(
+    groupId: GroupId | GroupName,
+    ownerId: string
+  ): Promise<Response> {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeId}/owner`,
+      body: {owner: ownerId},
+      anonymizedUrl: '/groups/*/owner',
+    });
+  }
+
+  saveGroupDescription(
+    groupId: GroupId | GroupName,
+    description: string
+  ): Promise<Response> {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeId}/description`,
+      body: {description},
+      anonymizedUrl: '/groups/*/description',
+    });
+  }
+
+  saveGroupOptions(
+    groupId: GroupId | GroupName,
+    options: GroupOptionsInput
+  ): Promise<Response> {
+    const encodeId = encodeURIComponent(groupId);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeId}/options`,
+      body: options,
+      anonymizedUrl: '/groups/*/options',
+    });
+  }
+
+  getGroupAuditLog(
+    group: EncodedGroupId,
+    errFn?: ErrorCallback
+  ): Promise<GroupAuditEventInfo[] | undefined> {
+    return this._fetchSharedCacheURL({
+      url: `/groups/${group}/log.audit`,
+      errFn,
+      anonymizedUrl: '/groups/*/log.audit',
+    }) as Promise<GroupAuditEventInfo[] | undefined>;
+  }
+
+  saveGroupMember(
+    groupName: GroupId | GroupName,
+    groupMember: AccountId
+  ): Promise<AccountInfo> {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeMember = encodeURIComponent(`${groupMember}`);
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeName}/members/${encodeMember}`,
+      parseResponse: true,
+      anonymizedUrl: '/groups/*/members/*',
+    }) as unknown as Promise<AccountInfo>;
+  }
+
+  saveIncludedGroup(
+    groupName: GroupId | GroupName,
+    includedGroup: GroupId,
+    errFn?: ErrorCallback
+  ): Promise<GroupInfo | undefined> {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeIncludedGroup = encodeURIComponent(includedGroup);
+    const req = {
+      method: HttpMethod.PUT,
+      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+      errFn,
+      anonymizedUrl: '/groups/*/groups/*',
+    };
+    return this._restApiHelper.send(req).then(response => {
+      if (response?.ok) {
+        return this.getResponseObject(
+          response
+        ) as unknown as Promise<GroupInfo>;
+      }
+      return undefined;
+    });
+  }
+
+  deleteGroupMember(
+    groupName: GroupId | GroupName,
+    groupMember: AccountId
+  ): Promise<Response> {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeMember = encodeURIComponent(`${groupMember}`);
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: `/groups/${encodeName}/members/${encodeMember}`,
+      anonymizedUrl: '/groups/*/members/*',
+    });
+  }
+
+  deleteIncludedGroup(
+    groupName: GroupId,
+    includedGroup: GroupId | GroupName
+  ): Promise<Response> {
+    const encodeName = encodeURIComponent(groupName);
+    const encodeIncludedGroup = encodeURIComponent(includedGroup);
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+      anonymizedUrl: '/groups/*/groups/*',
+    });
+  }
+
+  getVersion(): Promise<string | undefined> {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/version',
+      reportUrlAsIs: true,
+    }) as Promise<string | undefined>;
+  }
+
+  getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        return this._fetchSharedCacheURL({
+          url: '/accounts/self/preferences.diff',
+          reportUrlAsIs: true,
+        }) as Promise<DiffPreferencesInfo | undefined>;
+      }
+      return Promise.resolve(createDefaultDiffPrefs());
+    });
+  }
+
+  getEditPreferences(): Promise<EditPreferencesInfo | undefined> {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        return this._fetchSharedCacheURL({
+          url: '/accounts/self/preferences.edit',
+          reportUrlAsIs: true,
+        }) as Promise<EditPreferencesInfo | undefined>;
+      }
+      return Promise.resolve(createDefaultEditPrefs());
+    });
+  }
+
+  savePreferences(
+    prefs: PreferencesInput
+  ): Promise<PreferencesInfo | undefined> {
+    // Note (Issue 5142): normalize the download scheme with lower case before
+    // saving.
+    if (prefs.download_scheme) {
+      prefs.download_scheme = prefs.download_scheme.toLowerCase();
+    }
+
+    return this._restApiHelper
+      .send({
+        method: HttpMethod.PUT,
+        url: '/accounts/self/preferences',
+        body: prefs,
+        reportUrlAsIs: true,
+      })
+      .then((response: Response) =>
+        this.getResponseObject(response).then(
+          obj => obj as unknown as PreferencesInfo
+        )
+      );
+  }
+
+  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,
+      reportUrlAsIs: true,
+    });
+  }
+
+  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,
+      reportUrlAsIs: true,
+    });
+  }
+
+  getAccount(): Promise<AccountDetailInfo | undefined> {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/detail',
+      reportUrlAsIs: true,
+      errFn: resp => {
+        if (!resp || resp.status === 403) {
+          this._cache.delete('/accounts/self/detail');
+        }
+      },
+    }) as Promise<AccountDetailInfo | undefined>;
+  }
+
+  getAvatarChangeUrl() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/avatar.change.url',
+      reportUrlAsIs: true,
+      errFn: resp => {
+        if (!resp || resp.status === 403) {
+          this._cache.delete('/accounts/self/avatar.change.url');
+        }
+      },
+    }) as Promise<string | undefined>;
+  }
+
+  getExternalIds() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/external.ids',
+      reportUrlAsIs: true,
+    }) as Promise<AccountExternalIdInfo[] | undefined>;
+  }
+
+  deleteAccountIdentity(id: string[]) {
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: '/accounts/self/external.ids:delete',
+      body: id,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as Promise<unknown>;
+  }
+
+  getAccountDetails(
+    userId: AccountId | EmailAddress,
+    errFn?: ErrorCallback
+  ): Promise<AccountDetailInfo | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: `/accounts/${encodeURIComponent(userId)}/detail`,
+      anonymizedUrl: '/accounts/*/detail',
+      errFn,
+    }) as Promise<AccountDetailInfo | undefined>;
+  }
+
+  getAccountEmails() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/emails',
+      reportUrlAsIs: true,
+    }) as Promise<EmailInfo[] | undefined>;
+  }
+
+  addAccountEmail(email: string): Promise<Response> {
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: '/accounts/self/emails/' + encodeURIComponent(email),
+      anonymizedUrl: '/account/self/emails/*',
+    });
+  }
+
+  deleteAccountEmail(email: string): Promise<Response> {
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: '/accounts/self/emails/' + encodeURIComponent(email),
+      anonymizedUrl: '/accounts/self/email/*',
+    });
+  }
+
+  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`,
+      anonymizedUrl: '/accounts/self/emails/*/preferred',
+    };
+    return this._restApiHelper.send(req).then(() => {
+      // If result of getAccountEmails is in cache, update it in the cache
+      // so we don't have to invalidate it.
+      const cachedEmails = this._cache.get('/accounts/self/emails');
+      if (cachedEmails) {
+        const emails = cachedEmails.map(entry => {
+          if (entry.email === email) {
+            return {email, preferred: true};
+          } else {
+            return {email};
+          }
+        });
+        this._cache.set('/accounts/self/emails', emails);
+      }
+    });
+  }
+
+  _updateCachedAccount(obj: Partial<AccountDetailInfo>): void {
+    // If result of getAccount is in cache, update it in the cache
+    // so we don't have to invalidate it.
+    const cachedAccount = this._cache.get('/accounts/self/detail');
+    if (cachedAccount) {
+      // Replace object in cache with new object to force UI updates.
+      this._cache.set('/accounts/self/detail', {...cachedAccount, ...obj});
+    }
+  }
+
+  setAccountName(name: string): Promise<void> {
+    // TODO(TS): add correct error handling
+    const req: SendJSONRequest = {
+      method: HttpMethod.PUT,
+      url: '/accounts/self/name',
+      body: {name},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper
+      .send(req)
+      .then(newName =>
+        this._updateCachedAccount({name: newName as unknown as string})
+      );
+  }
+
+  setAccountUsername(username: string): Promise<void> {
+    // TODO(TS): add correct error handling
+    const req: SendJSONRequest = {
+      method: HttpMethod.PUT,
+      url: '/accounts/self/username',
+      body: {username},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper
+      .send(req)
+      .then(newName =>
+        this._updateCachedAccount({username: newName as unknown as string})
+      );
+  }
+
+  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},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req).then(newName =>
+      this._updateCachedAccount({
+        display_name: newName as unknown as string,
+      })
+    );
+  }
+
+  setAccountStatus(status: string): Promise<void> {
+    // TODO(TS): add correct error handling
+    const req: SendJSONRequest = {
+      method: HttpMethod.PUT,
+      url: '/accounts/self/status',
+      body: {status},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper
+      .send(req)
+      .then(newStatus =>
+        this._updateCachedAccount({status: newStatus as unknown as string})
+      );
+  }
+
+  getAccountStatus(userId: AccountId) {
+    return this._restApiHelper.fetchJSON({
+      url: `/accounts/${encodeURIComponent(userId)}/status`,
+      anonymizedUrl: '/accounts/*/status',
+    }) as Promise<string | undefined>;
+  }
+
+  getAccountGroups() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/groups',
+      reportUrlAsIs: true,
+    }) as Promise<GroupInfo[] | undefined>;
+  }
+
+  getAccountAgreements() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/agreements',
+      reportUrlAsIs: true,
+    }) as Promise<ContributorAgreementInfo[] | undefined>;
+  }
+
+  saveAccountAgreement(name: ContributorAgreementInput): Promise<Response> {
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: '/accounts/self/agreements',
+      body: name,
+      reportUrlAsIs: true,
+    });
+  }
+
+  getAccountCapabilities(
+    params?: string[]
+  ): Promise<AccountCapabilityInfo | undefined> {
+    let queryString = '';
+    if (params) {
+      queryString =
+        '?q=' + params.map(param => encodeURIComponent(param)).join('&q=');
+    }
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/capabilities' + queryString,
+      anonymizedUrl: '/accounts/self/capabilities?q=*',
+    }) as Promise<AccountCapabilityInfo | undefined>;
+  }
+
+  getLoggedIn() {
+    return this.authService.authCheck();
+  }
+
+  getIsAdmin() {
+    return this.getLoggedIn()
+      .then(isLoggedIn => {
+        if (isLoggedIn) {
+          return this.getAccountCapabilities();
+        } else {
+          return;
+        }
+      })
+      .then(
+        (capabilities: AccountCapabilityInfo | undefined) =>
+          capabilities && capabilities.administrateServer
+      );
+  }
+
+  getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/preferences',
+      reportUrlAsIs: true,
+    }) as Promise<PreferencesInfo | undefined>;
+  }
+
+  getPreferences(): Promise<PreferencesInfo | undefined> {
+    return this.getLoggedIn().then(loggedIn => {
+      if (loggedIn) {
+        const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
+        return this._fetchSharedCacheURL(req).then(res => {
+          if (!res) {
+            return res;
+          }
+          const prefInfo = res as unknown as PreferencesInfo;
+          return prefInfo;
+        });
+      }
+      return createDefaultPreferences();
+    });
+  }
+
+  getWatchedProjects() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/watched.projects',
+      reportUrlAsIs: true,
+    }) as unknown as Promise<ProjectWatchInfo[] | undefined>;
+  }
+
+  saveWatchedProjects(
+    projects: ProjectWatchInfo[]
+  ): Promise<ProjectWatchInfo[]> {
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: '/accounts/self/watched.projects',
+      body: projects,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as unknown as Promise<ProjectWatchInfo[]>;
+  }
+
+  deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response> {
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: '/accounts/self/watched.projects:delete',
+      body: projects,
+      reportUrlAsIs: true,
+    });
+  }
+
+  getRequestForGetChanges(
+    changesPerPage?: number,
+    query?: string[] | string,
+    offset?: 'n,z' | number,
+    options?: string
+  ) {
+    options = options || this._getChangesOptionsHex();
+    if (offset === 'n,z') {
+      offset = 0;
+    }
+    const params: QueryChangesParams = {
+      O: options,
+      S: offset || 0,
+    };
+    if (changesPerPage) {
+      params.n = changesPerPage;
+    }
+    if (query && query.length > 0) {
+      params.q = query;
+    }
+    const request = {
+      url: '/changes/',
+      params,
+      reportUrlAsIs: true,
+    };
+    return request;
+  }
+
+  getChangesForMultipleQueries(
+    changesPerPage?: number,
+    query?: string[],
+    offset?: 'n,z' | number,
+    options?: string
+  ): Promise<ChangeInfo[][] | undefined> {
+    if (!query) return Promise.resolve(undefined);
+
+    const request = this.getRequestForGetChanges(
+      changesPerPage,
+      query,
+      offset,
+      options
+    );
+
+    return Promise.resolve(
+      this._restApiHelper.fetchJSON(request, true) as Promise<
+        ChangeInfo[] | ChangeInfo[][] | undefined
+      >
+    ).then(response => {
+      if (!response) {
+        return;
+      }
+      const iterateOverChanges = (arr: ChangeInfo[]) => {
+        for (const change of arr) {
+          this._maybeInsertInLookup(change);
+        }
+      };
+      // Normalize the response to look like a multi-query response
+      // when there is only one query.
+      const responseArray: Array<ChangeInfo[]> =
+        query.length === 1
+          ? [response as ChangeInfo[]]
+          : (response as ChangeInfo[][]);
+      for (const arr of responseArray) {
+        iterateOverChanges(arr);
+      }
+      return responseArray;
+    });
+  }
+
+  getChanges(
+    changesPerPage?: number,
+    query?: string,
+    offset?: 'n,z' | number,
+    options?: string,
+    errFn?: ErrorCallback
+  ): Promise<ChangeInfo[] | undefined> {
+    const request = this.getRequestForGetChanges(
+      changesPerPage,
+      query,
+      offset,
+      options
+    );
+
+    return Promise.resolve(
+      this._restApiHelper.fetchJSON(
+        {
+          ...request,
+          errFn,
+        },
+        true
+      ) as Promise<ChangeInfo[] | undefined>
+    ).then(response => {
+      if (!response) {
+        return;
+      }
+      const iterateOverChanges = (arr: ChangeInfo[]) => {
+        for (const change of arr) {
+          this._maybeInsertInLookup(change);
+        }
+      };
+      iterateOverChanges(response);
+      return response;
+    });
+  }
+
+  async getDetailedChangesWithActions(changeNums: NumericChangeId[]) {
+    const query = changeNums.map(num => `change:${num}`).join(' OR ');
+    const changeDetails = await this.getChanges(
+      undefined,
+      query,
+      undefined,
+      listChangesOptionsToHex(
+        ListChangesOption.CHANGE_ACTIONS,
+        ListChangesOption.CURRENT_ACTIONS,
+        ListChangesOption.CURRENT_REVISION,
+        ListChangesOption.DETAILED_LABELS,
+        // TODO: remove this option and merge requirements from dashboard req
+        ListChangesOption.SUBMIT_REQUIREMENTS
+      )
+    );
+    return changeDetails;
+  }
+
+  /**
+   * Inserts a change into _projectLookup iff it has a valid structure.
+   */
+  _maybeInsertInLookup(change: ChangeInfo): void {
+    if (change?.project && change._number) {
+      this.setInProjectLookup(change._number, change.project);
+    }
+  }
+
+  getChangeActionURL(
+    changeNum: NumericChangeId,
+    revisionId: RevisionId | undefined,
+    endpoint: string
+  ): Promise<string> {
+    return this._changeBaseURL(changeNum, revisionId).then(
+      url => url + endpoint
+    );
+  }
+
+  getChangeDetail(
+    changeNum?: NumericChangeId,
+    errFn?: ErrorCallback,
+    cancelCondition?: CancelConditionCallback
+  ): Promise<ParsedChangeInfo | undefined> {
+    if (!changeNum) return Promise.resolve(undefined);
+    return this.getConfig(false).then(config => {
+      const optionsHex = this._getChangeOptionsHex(config);
+      return this._getChangeDetail(
+        changeNum,
+        optionsHex,
+        errFn,
+        cancelCondition
+      ).then(detail =>
+        // detail has ChangeViewChangeInfo type because the optionsHex always
+        // includes ALL_REVISIONS flag.
+        GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo)
+      );
+    });
+  }
+
+  _getChangesOptionsHex() {
+    if (
+      window.DEFAULT_DETAIL_HEXES &&
+      window.DEFAULT_DETAIL_HEXES.dashboardPage
+    ) {
+      return window.DEFAULT_DETAIL_HEXES.dashboardPage;
+    }
+    const options = [
+      ListChangesOption.LABELS,
+      ListChangesOption.DETAILED_ACCOUNTS,
+      ListChangesOption.SUBMIT_REQUIREMENTS,
+    ];
+
+    return listChangesOptionsToHex(...options);
+  }
+
+  _getChangeOptionsHex(config?: ServerInfo) {
+    if (
+      window.DEFAULT_DETAIL_HEXES &&
+      window.DEFAULT_DETAIL_HEXES.changePage &&
+      (!config || !(config.receive && config.receive.enable_signed_push))
+    ) {
+      return window.DEFAULT_DETAIL_HEXES.changePage;
+    }
+
+    // This list MUST be kept in sync with
+    // ChangeIT#changeDetailsDoesNotRequireIndex
+    const options = [
+      ListChangesOption.ALL_COMMITS,
+      ListChangesOption.ALL_REVISIONS,
+      ListChangesOption.CHANGE_ACTIONS,
+      ListChangesOption.DETAILED_LABELS,
+      ListChangesOption.DOWNLOAD_COMMANDS,
+      ListChangesOption.MESSAGES,
+      ListChangesOption.SUBMITTABLE,
+      ListChangesOption.WEB_LINKS,
+      ListChangesOption.SKIP_DIFFSTAT,
+      ListChangesOption.SUBMIT_REQUIREMENTS,
+    ];
+    if (config?.receive?.enable_signed_push) {
+      options.push(ListChangesOption.PUSH_CERTIFICATES);
+    }
+    return listChangesOptionsToHex(...options);
+  }
+
+  /**
+   * @param optionsHex list changes options in hex
+   */
+  _getChangeDetail(
+    changeNum: NumericChangeId,
+    optionsHex: string,
+    errFn?: ErrorCallback,
+    cancelCondition?: CancelConditionCallback
+  ): Promise<ChangeInfo | undefined> {
+    return this.getChangeActionURL(changeNum, undefined, '/detail').then(
+      url => {
+        const params: FetchParams = {O: optionsHex};
+        const urlWithParams = this._restApiHelper.urlWithParams(url, params);
+        const req: FetchJSONRequest = {
+          url,
+          errFn,
+          cancelCondition,
+          params,
+          fetchOptions: this._etags.getOptions(urlWithParams),
+          anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
+        };
+        return this._restApiHelper.fetchRawJSON(req).then(response => {
+          if (response?.status === 304) {
+            return parsePrefixedJSON(
+              // urlWithParams already cached
+              this._etags.getCachedPayload(urlWithParams)!
+            ) as unknown as ChangeInfo;
+          }
+
+          if (response && !response.ok) {
+            if (errFn) {
+              errFn.call(null, response);
+            } else {
+              fireServerError(response, req);
+            }
+            return undefined;
+          }
+
+          if (!response) {
+            return Promise.resolve(undefined);
+          }
+
+          return readResponsePayload(response).then(payload => {
+            if (!payload) {
+              return undefined;
+            }
+            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;
+          });
+        });
+      }
+    );
+  }
+
+  getChangeCommitInfo(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/commit?links',
+      revision: patchNum,
+      reportEndpointAsIs: true,
+      errFn: suppress404s,
+    }) as Promise<CommitInfo | undefined>;
+  }
+
+  getChangeFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined> {
+    let params = undefined;
+    if (isMergeParent(patchRange.basePatchNum)) {
+      params = {parent: getParentIndex(patchRange.basePatchNum)};
+    } else if (patchRange.basePatchNum !== PARENT) {
+      params = {base: patchRange.basePatchNum};
+    }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/files',
+      revision: patchRange.patchNum,
+      params,
+      reportEndpointAsIs: true,
+    }) as Promise<FileNameToFileInfoMap | undefined>;
+  }
+
+  // TODO(TS): The output type is unclear
+  getChangeEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<{files: FileNameToFileInfoMap} | undefined> {
+    let endpoint = '/edit?list';
+    let anonymizedEndpoint = endpoint;
+    if (patchRange.basePatchNum !== PARENT) {
+      endpoint += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`);
+      anonymizedEndpoint += '&base=*';
+    }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint,
+      anonymizedEndpoint,
+    }) as Promise<{files: FileNameToFileInfoMap} | undefined>;
+  }
+
+  queryChangeFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    query: string,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: `/files?q=${encodeURIComponent(query)}`,
+      revision: patchNum,
+      anonymizedEndpoint: '/files?q=*',
+      errFn,
+    }) as Promise<string[] | undefined>;
+  }
+
+  getChangeOrEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined> {
+    if (patchRange.patchNum === EDIT) {
+      return this.getChangeEditFiles(changeNum, patchRange).then(
+        res => res && res.files
+      );
+    }
+    return this.getChangeFiles(changeNum, patchRange);
+  }
+
+  getChangeRevisionActions(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<ActionNameToActionInfoMap | undefined> {
+    const req: FetchChangeJSON = {
+      changeNum,
+      endpoint: '/actions',
+      revision: patchNum,
+      reportEndpointAsIs: true,
+    };
+    return this._getChangeURLAndFetch(req) as Promise<
+      ActionNameToActionInfoMap | undefined
+    >;
+  }
+
+  getChangeSuggestedReviewers(
+    changeNum: NumericChangeId,
+    inputVal: string,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeSuggestedGroup(
+      ReviewerState.REVIEWER,
+      changeNum,
+      inputVal,
+      errFn
+    );
+  }
+
+  getChangeSuggestedCCs(
+    changeNum: NumericChangeId,
+    inputVal: string,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeSuggestedGroup(
+      ReviewerState.CC,
+      changeNum,
+      inputVal,
+      errFn
+    );
+  }
+
+  _getChangeSuggestedGroup(
+    reviewerState: ReviewerState,
+    changeNum: NumericChangeId,
+    inputVal: string,
+    errFn?: ErrorCallback
+  ): Promise<SuggestedReviewerInfo[] | undefined> {
+    // More suggestions may obscure content underneath in the reply dialog,
+    // see issue 10793.
+    const params: QuerySuggestedReviewersParams = {
+      n: 6,
+      'reviewer-state': reviewerState,
+    };
+    if (inputVal) {
+      params.q = inputVal;
+    }
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/suggest_reviewers',
+      params,
+      reportEndpointAsIs: true,
+      errFn,
+    }) as Promise<SuggestedReviewerInfo[] | undefined>;
+  }
+
+  getChangeIncludedIn(
+    changeNum: NumericChangeId
+  ): Promise<IncludedInInfo | undefined> {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/in',
+      reportEndpointAsIs: true,
+    }) as Promise<IncludedInInfo | undefined>;
+  }
+
+  _computeFilter(filter: string) {
+    if (filter?.startsWith('^')) {
+      filter = '&r=' + encodeURIComponent(filter);
+    } else if (filter) {
+      filter = '&m=' + encodeURIComponent(filter);
+    } else {
+      filter = '';
+    }
+    return filter;
+  }
+
+  _getGroupsUrl(filter: string, groupsPerPage: number, offset?: number) {
+    offset = offset || 0;
+
+    return (
+      `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
+      this._computeFilter(filter)
+    );
+  }
+
+  _getReposUrl(
+    filter: string | undefined,
+    reposPerPage: number,
+    offset?: number
+  ): [boolean, string] {
+    const defaultFilter = '';
+    offset = offset || 0;
+    filter ??= defaultFilter;
+    const encodedFilter = encodeURIComponent(filter);
+
+    if (filter.includes(':')) {
+      // If the filter includes a semicolon, the user is using a more complex
+      // query so we trust them and don't do any magic under the hood.
+      return [
+        true,
+        `/projects/?n=${reposPerPage + 1}&S=${offset}` +
+          `&query=${encodedFilter}`,
+      ];
+    }
+
+    return [
+      false,
+      `/projects/?n=${reposPerPage + 1}&S=${offset}` + `&d=&m=${encodedFilter}`,
+    ];
+  }
+
+  invalidateGroupsCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
+  }
+
+  invalidateReposCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
+  }
+
+  invalidateAccountsCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
+  }
+
+  invalidateAccountsDetailCache() {
+    this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
+  }
+
+  getGroups(filter: string, groupsPerPage: number, offset?: number) {
+    const url = this._getGroupsUrl(filter, groupsPerPage, offset);
+
+    return this._fetchSharedCacheURL({
+      url,
+      anonymizedUrl: '/groups/?*',
+    }) as Promise<GroupNameToGroupInfoMap | undefined>;
+  }
+
+  async getRepos(
+    filter: string | undefined,
+    reposPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ): Promise<ProjectInfoWithName[] | undefined> {
+    const [isQuery, url] = this._getReposUrl(filter, reposPerPage, offset);
+
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+
+    // If the request is a query then return the response directly as the result
+    // will already be the expected array. If it is not a query, transform the
+    // map to an array.
+    if (isQuery) {
+      return this._fetchSharedCacheURL({
+        url,
+        anonymizedUrl: '/projects/?*',
+        errFn,
+      }) as Promise<ProjectInfoWithName[] | undefined>;
+    } else {
+      const result = await (this._fetchSharedCacheURL({
+        url,
+        anonymizedUrl: '/projects/?*',
+        errFn,
+      }) as Promise<NameToProjectInfoMap | undefined>);
+      if (result === undefined) return [];
+      return Object.entries(result).map(([name, project]) => {
+        return {
+          ...project,
+          name: name as RepoName,
+        };
+      });
+    }
+  }
+
+  setRepoHead(repo: RepoName, ref: GitRef) {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/projects/${encodeURIComponent(repo)}/HEAD`,
+      body: {ref},
+      anonymizedUrl: '/projects/*/HEAD',
+    });
+  }
+
+  getRepoBranches(
+    filter: string,
+    repo: RepoName,
+    reposBranchesPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ): Promise<BranchInfo[] | undefined> {
+    offset = offset || 0;
+    const count = reposBranchesPerPage + 1;
+    filter = this._computeFilter(filter);
+    const encodedRepo = encodeURIComponent(repo);
+    const url = `/projects/${encodedRepo}/branches?n=${count}&S=${offset}${filter}`;
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.fetchJSON({
+      url,
+      errFn,
+      anonymizedUrl: '/projects/*/branches?*',
+    }) as Promise<BranchInfo[] | undefined>;
+  }
+
+  getRepoTags(
+    filter: string,
+    repo: RepoName,
+    reposTagsPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ) {
+    offset = offset || 0;
+    const encodedRepo = encodeURIComponent(repo);
+    const n = reposTagsPerPage + 1;
+    const encodedFilter = this._computeFilter(filter);
+    const url =
+      `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + encodedFilter;
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.fetchJSON({
+      url,
+      errFn,
+      anonymizedUrl: '/projects/*/tags',
+    }) as unknown as Promise<TagInfo[]>;
+  }
+
+  getPlugins(
+    filter: string,
+    pluginsPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ): Promise<{[pluginName: string]: PluginInfo} | undefined> {
+    offset = offset || 0;
+    const encodedFilter = this._computeFilter(filter);
+    const n = pluginsPerPage + 1;
+    const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
+    return this._restApiHelper.fetchJSON({
+      url,
+      errFn,
+      anonymizedUrl: '/plugins/?all',
+    });
+  }
+
+  getRepoAccessRights(
+    repoName: RepoName,
+    errFn?: ErrorCallback
+  ): Promise<ProjectAccessInfo | undefined> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.fetchJSON({
+      url: `/projects/${encodeURIComponent(repoName)}/access`,
+      errFn,
+      anonymizedUrl: '/projects/*/access',
+    }) as Promise<ProjectAccessInfo | undefined>;
+  }
+
+  setRepoAccessRights(
+    repoName: RepoName,
+    repoInfo: ProjectAccessInput
+  ): Promise<Response> {
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: `/projects/${encodeURIComponent(repoName)}/access`,
+      body: repoInfo,
+      anonymizedUrl: '/projects/*/access',
+    });
+  }
+
+  setRepoAccessRightsForReview(
+    projectName: RepoName,
+    projectInfo: ProjectAccessInput
+  ): Promise<ChangeInfo> {
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: `/projects/${encodeURIComponent(projectName)}/access:review`,
+      body: projectInfo,
+      parseResponse: true,
+      anonymizedUrl: '/projects/*/access:review',
+    }) as unknown as Promise<ChangeInfo>;
+  }
+
+  getSuggestedGroups(
+    inputVal: string,
+    project?: RepoName,
+    n?: number,
+    errFn?: ErrorCallback
+  ): Promise<GroupNameToGroupInfoMap | undefined> {
+    const params: QueryGroupsParams = {s: inputVal};
+    if (n) {
+      params.n = n;
+    }
+    if (project) {
+      params.p = project;
+    }
+    return this._restApiHelper.fetchJSON({
+      url: '/groups/',
+      params,
+      reportUrlAsIs: true,
+      errFn,
+    }) as Promise<GroupNameToGroupInfoMap | undefined>;
+  }
+
+  getSuggestedRepos(
+    inputVal: string,
+    n?: number,
+    errFn?: ErrorCallback
+  ): Promise<NameToProjectInfoMap | undefined> {
+    const params = {
+      m: inputVal,
+      n: MAX_PROJECT_RESULTS,
+      type: 'ALL',
+    };
+    if (n) {
+      params.n = n;
+    }
+    return this._restApiHelper.fetchJSON({
+      url: '/projects/',
+      params,
+      reportUrlAsIs: true,
+      errFn,
+    });
+  }
+
+  getSuggestedAccounts(
+    inputVal: string,
+    n?: number,
+    canSee?: NumericChangeId,
+    filterActive?: boolean,
+    errFn?: ErrorCallback
+  ): Promise<AccountInfo[] | undefined> {
+    const params: QueryAccountsParams = {o: 'DETAILS', q: ''};
+    const queryParams = [];
+    inputVal = inputVal?.trim() ?? '';
+    if (inputVal.length > 0) {
+      // Wrap in quotes so that reserved keywords do not throw an error such
+      // as typing "and"
+      // Espace quotes in user input since we are wrapping input in quotes
+      // explicitly
+      queryParams.push(`${escapeAndWrapSearchOperatorValue(inputVal)}`);
+    }
+    if (canSee) {
+      queryParams.push(`cansee:${canSee}`);
+    }
+    if (filterActive) {
+      queryParams.push('is:active');
+    }
+    params.q = queryParams.join(' and ');
+    if (!params.q) return Promise.resolve([]);
+    if (n) {
+      params.n = n;
+    }
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/',
+      params,
+      anonymizedUrl: '/accounts/?n=*',
+      errFn,
+    }) as Promise<AccountInfo[] | undefined>;
+  }
+
+  addChangeReviewer(
+    changeNum: NumericChangeId,
+    reviewerID: AccountId | EmailAddress | GroupId
+  ) {
+    return this._sendChangeReviewerRequest(
+      HttpMethod.POST,
+      changeNum,
+      reviewerID
+    );
+  }
+
+  removeChangeReviewer(
+    changeNum: NumericChangeId,
+    reviewerID: AccountId | EmailAddress | GroupId
+  ) {
+    return this._sendChangeReviewerRequest(
+      HttpMethod.DELETE,
+      changeNum,
+      reviewerID
+    );
+  }
+
+  _sendChangeReviewerRequest(
+    method: HttpMethod.POST | HttpMethod.DELETE,
+    changeNum: NumericChangeId,
+    reviewerID: AccountId | EmailAddress | GroupId
+  ) {
+    return this.getChangeActionURL(changeNum, undefined, '/reviewers').then(
+      url => {
+        let body;
+        switch (method) {
+          case HttpMethod.POST:
+            body = {reviewer: reviewerID};
+            break;
+          case HttpMethod.DELETE:
+            url += '/' + encodeURIComponent(reviewerID);
+            break;
+          default:
+            assertNever(method, `Unsupported HTTP method: ${method}`);
+        }
+
+        return this._restApiHelper.send({method, url, body});
+      }
+    );
+  }
+
+  getRelatedChanges(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<RelatedChangesInfo | undefined> {
+    const options = '?o=SUBMITTABLE';
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: `/related${options}`,
+      revision: patchNum,
+      reportEndpointAsIs: true,
+    }) as Promise<RelatedChangesInfo | undefined>;
+  }
+
+  getChangesSubmittedTogether(
+    changeNum: NumericChangeId,
+    options: string[] = ['NON_VISIBLE_CHANGES']
+  ): Promise<SubmittedTogetherInfo | undefined> {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: `/submitted_together?o=${options.join('&o=')}`,
+      reportEndpointAsIs: true,
+    }) as Promise<SubmittedTogetherInfo | undefined>;
+  }
+
+  async getChangeConflicts(
+    changeNum: NumericChangeId
+  ): Promise<ChangeInfo[] | undefined> {
+    const config = await this.getConfig(false);
+    if (!config?.change?.conflicts_predicate_enabled) {
+      return [];
+    }
+    const options = listChangesOptionsToHex(
+      ListChangesOption.CURRENT_REVISION,
+      ListChangesOption.CURRENT_COMMIT
+    );
+    const params = {
+      O: options,
+      q: `status:open conflicts:${changeNum}`,
+    };
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params,
+      anonymizedUrl: '/changes/conflicts:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
+  getChangeCherryPicks(
+    repo: RepoName,
+    changeID: ChangeId,
+    changeNum: NumericChangeId
+  ): Promise<ChangeInfo[] | undefined> {
+    const options = listChangesOptionsToHex(
+      ListChangesOption.CURRENT_REVISION,
+      ListChangesOption.CURRENT_COMMIT
+    );
+    const query = [
+      `project:${repo}`,
+      `change:${changeID}`,
+      `-change:${changeNum}`,
+      '-is:abandoned',
+    ].join(' ');
+    const params = {
+      O: options,
+      q: query,
+    };
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params,
+      anonymizedUrl: '/changes/change:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
+  getChangesWithSameTopic(
+    topic: string,
+    options?: {
+      openChangesOnly?: boolean;
+      changeToExclude?: NumericChangeId;
+    }
+  ): Promise<ChangeInfo[] | undefined> {
+    const requestOptions = listChangesOptionsToHex(
+      ListChangesOption.LABELS,
+      ListChangesOption.CURRENT_REVISION,
+      ListChangesOption.CURRENT_COMMIT,
+      ListChangesOption.DETAILED_LABELS
+    );
+    const queryTerms = [`topic:${escapeAndWrapSearchOperatorValue(topic)}`];
+    if (options?.openChangesOnly) {
+      queryTerms.push('status:open');
+    }
+    if (options?.changeToExclude !== undefined) {
+      queryTerms.push(`-change:${options.changeToExclude}`);
+    }
+    const params = {
+      O: requestOptions,
+      q: queryTerms.join(' '),
+    };
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params,
+      anonymizedUrl: '/changes/topic:*',
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
+  getChangesWithSimilarTopic(
+    topic: string,
+    errFn?: ErrorCallback
+  ): Promise<ChangeInfo[] | undefined> {
+    const query = `intopic:${escapeAndWrapSearchOperatorValue(topic)}`;
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params: {q: query},
+      anonymizedUrl: '/changes/intopic:*',
+      errFn,
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
+  getChangesWithSimilarHashtag(
+    hashtag: string,
+    errFn?: ErrorCallback
+  ): Promise<ChangeInfo[] | undefined> {
+    const query = `inhashtag:${escapeAndWrapSearchOperatorValue(hashtag)}`;
+    return this._restApiHelper.fetchJSON({
+      url: '/changes/',
+      params: {q: query},
+      anonymizedUrl: '/changes/inhashtag:*',
+      errFn,
+    }) as Promise<ChangeInfo[] | undefined>;
+  }
+
+  getReviewedFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<string[] | undefined> {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/files?reviewed',
+      revision: patchNum,
+      reportEndpointAsIs: true,
+    }) as Promise<string[] | undefined>;
+  }
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean
+  ): Promise<Response> {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE,
+      patchNum,
+      endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
+      anonymizedEndpoint: '/files/*/reviewed',
+    });
+  }
+
+  saveChangeReview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    review: ReviewInput
+  ): Promise<Response>;
+
+  saveChangeReview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    review: ReviewInput,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  saveChangeReview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    review: ReviewInput,
+    errFn?: ErrorCallback
+  ) {
+    const promises: [Promise<void>, Promise<string>] = [
+      this.awaitPendingDiffDrafts(),
+      this.getChangeActionURL(changeNum, patchNum, '/review'),
+    ];
+    return Promise.all(promises).then(([, url]) =>
+      this._restApiHelper.send({
+        method: HttpMethod.POST,
+        url,
+        body: review,
+        errFn,
+      })
+    );
+  }
+
+  getChangeEdit(changeNum?: NumericChangeId): Promise<EditInfo | undefined> {
+    if (!changeNum) return Promise.resolve(undefined);
+    const params = {'download-commands': true};
+    return this.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) {
+        return Promise.resolve(undefined);
+      }
+      return this._getChangeURLAndFetch(
+        {
+          changeNum,
+          endpoint: '/edit/',
+          params,
+          reportEndpointAsIs: true,
+        },
+        true
+      ) as Promise<EditInfo | undefined>;
+    });
+  }
+
+  createChange(
+    repo: RepoName,
+    branch: BranchName,
+    subject: string,
+    topic?: string,
+    isPrivate?: boolean,
+    workInProgress?: boolean,
+    baseChange?: ChangeId,
+    baseCommit?: string
+  ) {
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: '/changes/',
+      body: {
+        project: repo,
+        branch,
+        subject,
+        topic,
+        is_private: isPrivate,
+        work_in_progress: workInProgress,
+        base_change: baseChange,
+        base_commit: baseCommit,
+      },
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as unknown as Promise<ChangeInfo | undefined>;
+  }
+
+  getFileContent(
+    changeNum: NumericChangeId,
+    path: string,
+    patchNum: PatchSetNum
+  ): Promise<Response | Base64FileContent | undefined> {
+    // 404s indicate the file does not exist yet in the revision, so suppress
+    // them.
+    const promise =
+      patchNum === EDIT
+        ? this._getFileInChangeEdit(changeNum, path)
+        : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
+
+    return promise.then(res => {
+      if (!res || !res.ok) {
+        return res;
+      }
+
+      // The file type (used for syntax highlighting) is identified in the
+      // X-FYI-Content-Type header of the response.
+      const type = res.headers.get('X-FYI-Content-Type');
+      return this.getResponseObject(res).then(content => {
+        const strContent = content as unknown as string | null;
+        return {content: strContent, type, ok: true};
+      });
+    });
+  }
+
+  /**
+   * Gets a file in a specific change and revision.
+   */
+  _getFileInRevision(
+    changeNum: NumericChangeId,
+    path: string,
+    patchNum: PatchSetNum,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.GET,
+      patchNum,
+      endpoint: `/files/${encodeURIComponent(path)}/content`,
+      errFn,
+      headers: {Accept: 'application/json'},
+      anonymizedEndpoint: '/files/*/content',
+    });
+  }
+
+  /**
+   * Gets a file in a change edit.
+   */
+  _getFileInChangeEdit(changeNum: NumericChangeId, path: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.GET,
+      endpoint: '/edit/' + encodeURIComponent(path),
+      headers: {Accept: 'application/json'},
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  rebaseChangeEdit(changeNum: NumericChangeId) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/edit:rebase',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteChangeEdit(changeNum: NumericChangeId) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: '/edit',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  restoreFileInChangeEdit(changeNum: NumericChangeId, restore_path: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/edit',
+      body: {restore_path},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  renameFileInChangeEdit(
+    changeNum: NumericChangeId,
+    old_path: string,
+    new_path: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/edit',
+      body: {old_path, new_path},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteFileInChangeEdit(changeNum: NumericChangeId, path: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: '/edit/' + encodeURIComponent(path),
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  saveChangeEdit(changeNum: NumericChangeId, path: string, contents: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/edit/' + encodeURIComponent(path),
+      body: contents,
+      contentType: 'text/plain',
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  saveFileUploadChangeEdit(
+    changeNum: NumericChangeId,
+    path: string,
+    content: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/edit/' + encodeURIComponent(path),
+      body: {binary_content: content},
+      anonymizedEndpoint: '/edit/*',
+    });
+  }
+
+  getFixPreview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixReplacementInfos: FixReplacementInfo[]
+  ): Promise<FilePathToDiffInfoMap | undefined> {
+    return this._getChangeURLAndSend({
+      method: HttpMethod.POST,
+      changeNum,
+      patchNum,
+      endpoint: '/fix:preview',
+      reportEndpointAsId: true,
+      headers: {Accept: 'application/json'},
+      parseResponse: true,
+      body: {fix_replacement_infos: fixReplacementInfos},
+    }) as Promise<FilePathToDiffInfoMap | undefined>;
+  }
+
+  getRobotCommentFixPreview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixId: FixId
+  ): Promise<FilePathToDiffInfoMap | undefined> {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      revision: patchNum,
+      endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
+      reportEndpointAsId: true,
+    }) as Promise<FilePathToDiffInfoMap | undefined>;
+  }
+
+  applyFixSuggestion(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixReplacementInfos: FixReplacementInfo[]
+  ): Promise<Response> {
+    return this._getChangeURLAndSend({
+      method: HttpMethod.POST,
+      changeNum,
+      patchNum,
+      endpoint: '/fix:apply',
+      reportEndpointAsId: true,
+      headers: {Accept: 'application/json'},
+      body: {fix_replacement_infos: fixReplacementInfos},
+    });
+  }
+
+  applyRobotFixSuggestion(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixId: string
+  ): Promise<Response> {
+    return this._getChangeURLAndSend({
+      method: HttpMethod.POST,
+      changeNum,
+      patchNum,
+      endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
+      reportEndpointAsId: true,
+    });
+  }
+
+  publishChangeEdit(changeNum: NumericChangeId) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/edit:publish',
+      reportEndpointAsIs: true,
+    });
+  }
+
+  putChangeCommitMessage(changeNum: NumericChangeId, message: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/message',
+      body: {message},
+      reportEndpointAsIs: true,
+    });
+  }
+
+  deleteChangeCommitMessage(
+    changeNum: NumericChangeId,
+    messageId: ChangeMessageId
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: `/messages/${messageId}`,
+      reportEndpointAsIs: true,
+    });
+  }
+
+  saveChangeStarred(
+    changeNum: NumericChangeId,
+    starred: boolean
+  ): Promise<Response> {
+    // Some servers may require the project name to be provided
+    // alongside the change number, so resolve the project name
+    // first.
+    return this.getFromProjectLookup(changeNum).then(project => {
+      const encodedRepoName = project ? encodeURIComponent(project) + '~' : '';
+      const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`;
+      return this._serialScheduler.schedule(() =>
+        this._restApiHelper.send({
+          method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
+          url,
+          anonymizedUrl: '/accounts/self/starred.changes/*',
+        })
+      );
+    });
+  }
+
+  send(
+    method: HttpMethod,
+    url: string,
+    body?: RequestPayload,
+    errFn?: undefined,
+    contentType?: string,
+    headers?: Record<string, string>
+  ): Promise<Response>;
+
+  send(
+    method: HttpMethod,
+    url: string,
+    body: RequestPayload | undefined,
+    errFn: ErrorCallback,
+    contentType?: string,
+    headers?: Record<string, string>
+  ): Promise<Response | undefined>;
+
+  /**
+   * Public version of the _restApiHelper.send method preserved for plugins.
+   *
+   * @param body passed as null sometimes
+   * and also apparently a number. TODO (beckysiegel) remove need for
+   * number at least.
+   */
+  send(
+    method: HttpMethod,
+    url: string,
+    body?: RequestPayload,
+    errFn?: ErrorCallback,
+    contentType?: string,
+    headers?: Record<string, string>
+  ): Promise<Response | undefined> {
+    return this._restApiHelper.send({
+      method,
+      url,
+      body,
+      errFn,
+      contentType,
+      headers,
+    });
+  }
+
+  getDiff(
+    changeNum: NumericChangeId,
+    basePatchNum: PatchSetNum,
+    patchNum: PatchSetNum,
+    path: string,
+    whitespace?: IgnoreWhitespaceType,
+    errFn?: ErrorCallback
+  ) {
+    const params: GetDiffParams = {
+      intraline: null,
+      whitespace: whitespace || 'IGNORE_NONE',
+    };
+    if (isMergeParent(basePatchNum)) {
+      params.parent = getParentIndex(basePatchNum);
+    } else if (basePatchNum !== PARENT) {
+      params.base = basePatchNum;
+    }
+    const endpoint = `/files/${encodeURIComponent(path)}/diff`;
+    const req: FetchChangeJSON = {
+      changeNum,
+      endpoint,
+      revision: patchNum,
+      errFn,
+      params,
+      anonymizedEndpoint: '/files/*/diff',
+    };
+
+    // Invalidate the cache if its edit patch to make sure we always get latest.
+    if (patchNum === EDIT) {
+      if (!req.fetchOptions) req.fetchOptions = {};
+      if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
+      req.fetchOptions.headers.append('Cache-Control', 'no-cache');
+    }
+
+    return this._getChangeURLAndFetch(req) as Promise<DiffInfo | undefined>;
+  }
+
+  getDiffComments(
+    changeNum: NumericChangeId
+  ): Promise<PathToCommentsInfoMap | undefined>;
+
+  getDiffComments(
+    changeNum: NumericChangeId,
+    basePatchNum: BasePatchSetNum,
+    patchNum: PatchSetNum,
+    path: string
+  ): Promise<GetDiffCommentsOutput>;
+
+  getDiffComments(
+    changeNum: NumericChangeId,
+    basePatchNum?: BasePatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ) {
+    if (!basePatchNum && !patchNum && !path) {
+      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
+    );
+  }
+
+  getDiffRobotComments(
+    changeNum: NumericChangeId
+  ): Promise<PathToRobotCommentsInfoMap | undefined>;
+
+  getDiffRobotComments(
+    changeNum: NumericChangeId,
+    basePatchNum: BasePatchSetNum,
+    patchNum: PatchSetNum,
+    path: string
+  ): Promise<GetDiffRobotCommentsOutput>;
+
+  getDiffRobotComments(
+    changeNum: NumericChangeId,
+    basePatchNum?: BasePatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ) {
+    if (!basePatchNum && !patchNum && !path) {
+      return this._getDiffComments(changeNum, '/robotcomments');
+    }
+
+    return this._getDiffComments(
+      changeNum,
+      '/robotcomments',
+      undefined,
+      basePatchNum,
+      patchNum,
+      path
+    );
+  }
+
+  async getDiffDrafts(
+    changeNum: NumericChangeId
+  ): Promise<{[path: string]: DraftInfo[]} | undefined> {
+    const loggedIn = await this.getLoggedIn();
+    if (!loggedIn) return {};
+    const comments = await this._getDiffComments(changeNum, '/drafts', {
+      'enable-context': true,
+      'context-padding': 3,
+    });
+    return addDraftProp(comments);
+  }
+
+  _setRange(comments: CommentInfo[], comment: CommentInfo) {
+    if (comment.in_reply_to && !comment.range) {
+      for (let i = 0; i < comments.length; i++) {
+        if (comments[i].id === comment.in_reply_to) {
+          comment.range = comments[i].range;
+          break;
+        }
+      }
+    }
+    return comment;
+  }
+
+  _setRanges(comments?: CommentInfo[]) {
+    comments = comments || [];
+    comments.sort(
+      (a, b) => parseDate(a.updated).valueOf() - parseDate(b.updated).valueOf()
+    );
+    for (const comment of comments) {
+      this._setRange(comments, comment);
+    }
+    return comments;
+  }
+
+  _getDiffComments(
+    changeNum: NumericChangeId,
+    endpoint: '/comments' | '/drafts',
+    params?: FetchParams
+  ): Promise<PathToCommentsInfoMap | undefined>;
+
+  _getDiffComments(
+    changeNum: NumericChangeId,
+    endpoint: '/robotcomments'
+  ): Promise<PathToRobotCommentsInfoMap | undefined>;
+
+  _getDiffComments(
+    changeNum: NumericChangeId,
+    endpoint: '/comments' | '/drafts',
+    params?: FetchParams,
+    basePatchNum?: BasePatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ): Promise<GetDiffCommentsOutput>;
+
+  _getDiffComments(
+    changeNum: NumericChangeId,
+    endpoint: '/robotcomments',
+    params?: FetchParams,
+    basePatchNum?: BasePatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ): Promise<GetDiffRobotCommentsOutput>;
+
+  _getDiffComments(
+    changeNum: NumericChangeId,
+    endpoint: string,
+    params?: FetchParams,
+    basePatchNum?: BasePatchSetNum,
+    patchNum?: PatchSetNum,
+    path?: string
+  ): Promise<
+    | GetDiffCommentsOutput
+    | GetDiffRobotCommentsOutput
+    | PathToCommentsInfoMap
+    | PathToRobotCommentsInfoMap
+    | undefined
+  > {
+    /**
+     * Fetches the comments for a given patchNum.
+     * Helper function to make promises more legible.
+     */
+    // We don't want to add accept header, since preloading of comments is
+    // working only without accept header.
+    const noAcceptHeader = true;
+    const fetchComments = (patchNum?: PatchSetNum) =>
+      this._getChangeURLAndFetch(
+        {
+          changeNum,
+          endpoint,
+          revision: patchNum,
+          reportEndpointAsIs: true,
+          params,
+        },
+        noAcceptHeader
+      ) as Promise<
+        PathToCommentsInfoMap | PathToRobotCommentsInfoMap | undefined
+      >;
+
+    if (!basePatchNum && !patchNum && !path) {
+      return fetchComments();
+    }
+    function onlyParent(c: CommentInfo) {
+      return c.side === CommentSide.PARENT;
+    }
+    function withoutParent(c: CommentInfo) {
+      return c.side !== CommentSide.PARENT;
+    }
+    function setPath(c: CommentInfo) {
+      c.path = path;
+    }
+
+    const promises = [];
+    let comments: CommentInfo[];
+    let baseComments: CommentInfo[];
+    let fetchPromise;
+    fetchPromise = fetchComments(patchNum).then(response => {
+      comments = (response && path && response[path]) || [];
+      // TODO(kaspern): Implement this on in the backend so this can
+      // be removed.
+      // Sort comments by date so that parent ranges can be propagated
+      // in a single pass.
+      comments = this._setRanges(comments);
+
+      if (basePatchNum === PARENT) {
+        baseComments = comments.filter(onlyParent);
+        baseComments.forEach(setPath);
+      }
+      comments = comments.filter(withoutParent);
+
+      comments.forEach(setPath);
+    });
+    promises.push(fetchPromise);
+
+    if (basePatchNum !== PARENT) {
+      fetchPromise = fetchComments(basePatchNum).then(response => {
+        baseComments = ((response && path && response[path]) || []).filter(
+          withoutParent
+        );
+        baseComments = this._setRanges(baseComments);
+        baseComments.forEach(setPath);
+      });
+      promises.push(fetchPromise);
+    }
+
+    return Promise.all(promises).then(() =>
+      Promise.resolve({
+        baseComments,
+        comments,
+      })
+    );
+  }
+
+  _getDiffCommentsFetchURL(
+    changeNum: NumericChangeId,
+    endpoint: string,
+    patchNum?: RevisionId
+  ) {
+    return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
+  }
+
+  getPortedComments(
+    changeNum: NumericChangeId,
+    revision: RevisionId
+  ): Promise<PathToCommentsInfoMap | undefined> {
+    // maintaining a custom error function so that errors do not surface in UI
+    const errFn: ErrorCallback = (response?: Response | null) => {
+      if (response)
+        console.info(`Fetching ported comments failed, ${response.status}`);
+    };
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/ported_comments/',
+      revision,
+      errFn,
+    });
+  }
+
+  getPortedDrafts(
+    changeNum: NumericChangeId,
+    revision: RevisionId
+  ): Promise<PathToCommentsInfoMap | undefined> {
+    // maintaining a custom error function so that errors do not surface in UI
+    const errFn: ErrorCallback = (response?: Response | null) => {
+      if (response)
+        console.info(`Fetching ported drafts failed, ${response.status}`);
+    };
+    return this.getLoggedIn().then(loggedIn => {
+      if (!loggedIn) return {};
+      return this._getChangeURLAndFetch({
+        changeNum,
+        endpoint: '/ported_drafts/',
+        revision,
+        errFn,
+      });
+    });
+  }
+
+  saveDiffDraft(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: CommentInput
+  ) {
+    return this._sendDiffDraftRequest(
+      HttpMethod.PUT,
+      changeNum,
+      patchNum,
+      draft
+    );
+  }
+
+  deleteDiffDraft(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: {id: UrlEncodedCommentId}
+  ) {
+    return this._sendDiffDraftRequest(
+      HttpMethod.DELETE,
+      changeNum,
+      patchNum,
+      draft
+    );
+  }
+
+  hasPendingDiffDrafts(): number {
+    const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
+    return promises && promises.length;
+  }
+
+  awaitPendingDiffDrafts(): Promise<void> {
+    return Promise.all(
+      this._pendingRequests[Requests.SEND_DIFF_DRAFT] || []
+    ).then(() => {
+      this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
+    });
+  }
+
+  _sendDiffDraftRequest(
+    method: HttpMethod.PUT,
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: CommentInput
+  ): Promise<Response>;
+
+  _sendDiffDraftRequest(
+    method: HttpMethod.GET | HttpMethod.DELETE,
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: {id?: UrlEncodedCommentId}
+  ): Promise<Response>;
+
+  _sendDiffDraftRequest(
+    method: HttpMethod,
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draft: CommentInput | {id: UrlEncodedCommentId}
+  ): Promise<Response> {
+    const isCreate = !draft.id && method === HttpMethod.PUT;
+    let endpoint = '/drafts';
+    let anonymizedEndpoint = endpoint;
+    if (draft.id) {
+      endpoint += `/${draft.id}`;
+      anonymizedEndpoint += '/*';
+    }
+    let body;
+    if (method === HttpMethod.PUT) {
+      body = draft;
+    }
+
+    if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
+      this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
+    }
+
+    const req = {
+      changeNum,
+      method,
+      patchNum,
+      endpoint,
+      body,
+      anonymizedEndpoint,
+    };
+
+    const promise = this._getChangeURLAndSend(req);
+    this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
+
+    if (isCreate) {
+      return this._failForCreate200(promise);
+    }
+
+    return promise;
+  }
+
+  getCommitInfo(
+    repo: RepoName,
+    commit: CommitId
+  ): Promise<CommitInfo | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url:
+        '/projects/' +
+        encodeURIComponent(repo) +
+        '/commits/' +
+        encodeURIComponent(commit),
+      anonymizedUrl: '/projects/*/commits/*',
+    }) as Promise<CommitInfo | undefined>;
+  }
+
+  _fetchB64File(url: string): Promise<Base64File> {
+    return this._restApiHelper
+      .fetch({url: getBaseUrl() + url})
+      .then(response => {
+        if (!response.ok) {
+          return Promise.reject(new Error(response.statusText));
+        }
+        const type = response.headers.get('X-FYI-Content-Type');
+        return response.text().then(text => {
+          return {body: text, type};
+        });
+      });
+  }
+
+  getB64FileContents(
+    changeId: NumericChangeId,
+    patchNum: RevisionId,
+    path: string,
+    parentIndex?: number
+  ) {
+    const parent =
+      typeof parentIndex === 'number' ? `?parent=${parentIndex}` : '';
+    return this._changeBaseURL(changeId, patchNum).then(url => {
+      url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
+      return this._fetchB64File(url);
+    });
+  }
+
+  getImagesForDiff(
+    changeNum: NumericChangeId,
+    diff: DiffInfo,
+    patchRange: PatchRange
+  ): Promise<ImagesForDiff> {
+    let promiseA;
+    let promiseB;
+
+    if (diff.meta_a?.content_type.startsWith('image/')) {
+      if (patchRange.basePatchNum === PARENT) {
+        // Note: we only attempt to get the image from the first parent.
+        promiseA = this.getB64FileContents(
+          changeNum,
+          patchRange.patchNum,
+          diff.meta_a.name,
+          1
+        );
+      } else {
+        promiseA = this.getB64FileContents(
+          changeNum,
+          patchRange.basePatchNum,
+          diff.meta_a.name
+        );
+      }
+    } else {
+      promiseA = Promise.resolve(null);
+    }
+
+    if (diff.meta_b?.content_type.startsWith('image/')) {
+      promiseB = this.getB64FileContents(
+        changeNum,
+        patchRange.patchNum,
+        diff.meta_b.name
+      );
+    } else {
+      promiseB = Promise.resolve(null);
+    }
+
+    return Promise.all([promiseA, promiseB]).then(results => {
+      // Sometimes the server doesn't send back the content type.
+      const baseImage: Base64ImageFile | null =
+        results[0] && diff.meta_a
+          ? {
+              ...results[0],
+              _expectedType: diff.meta_a.content_type,
+              _name: diff.meta_a.name,
+            }
+          : null;
+      const revisionImage: Base64ImageFile | null =
+        results[1] && diff.meta_b
+          ? {
+              ...results[1],
+              _expectedType: diff.meta_b.content_type,
+              _name: diff.meta_b.name,
+            }
+          : null;
+      const imagesForDiff: ImagesForDiff = {baseImage, revisionImage};
+      return imagesForDiff;
+    });
+  }
+
+  _changeBaseURL(
+    changeNum: NumericChangeId,
+    revisionId?: RevisionId
+  ): Promise<string> {
+    return this.getFromProjectLookup(changeNum).then(project => {
+      // TODO(TS): unclear why project can't be null here. Fix it
+      let url = `/changes/${encodeURIComponent(
+        project as RepoName
+      )}~${changeNum}`;
+      if (revisionId) {
+        url += `/revisions/${revisionId}`;
+      }
+      return url;
+    });
+  }
+
+  addToAttentionSet(
+    changeNum: NumericChangeId,
+    user: AccountId | undefined | null,
+    reason: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/attention',
+      body: {user, reason},
+      reportUrlAsIs: true,
+    });
+  }
+
+  removeFromAttentionSet(
+    changeNum: NumericChangeId,
+    user: AccountId,
+    reason: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: `/attention/${user}`,
+      anonymizedEndpoint: '/attention/*',
+      body: {reason},
+    });
+  }
+
+  setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string> {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      endpoint: '/topic',
+      body: {topic},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as unknown as Promise<string>;
+  }
+
+  setChangeHashtag(
+    changeNum: NumericChangeId,
+    hashtag: HashtagsInput
+  ): Promise<Hashtag[]> {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/hashtags',
+      body: hashtag,
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as unknown as Promise<Hashtag[]>;
+  }
+
+  deleteAccountHttpPassword() {
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: '/accounts/self/password.http',
+      reportUrlAsIs: true,
+    });
+  }
+
+  generateAccountHttpPassword(): Promise<Password> {
+    return this._restApiHelper.send({
+      method: HttpMethod.PUT,
+      url: '/accounts/self/password.http',
+      body: {generate: true},
+      parseResponse: true,
+      reportUrlAsIs: true,
+    }) as Promise<unknown> as Promise<Password>;
+  }
+
+  getAccountSSHKeys() {
+    return this._fetchSharedCacheURL({
+      url: '/accounts/self/sshkeys',
+      reportUrlAsIs: true,
+    }) as Promise<unknown> as Promise<SshKeyInfo[] | undefined>;
+  }
+
+  addAccountSSHKey(key: string): Promise<SshKeyInfo> {
+    const req = {
+      method: HttpMethod.POST,
+      url: '/accounts/self/sshkeys',
+      body: key,
+      contentType: 'text/plain',
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper
+      .send(req)
+      .then((response: Response | undefined) => {
+        if (!response || (response.status < 200 && response.status >= 300)) {
+          return Promise.reject(new Error('error'));
+        }
+        return this.getResponseObject(
+          response
+        ) as unknown as Promise<SshKeyInfo>;
+      })
+      .then(obj => {
+        if (!obj || !obj.valid) {
+          return Promise.reject(new Error('error'));
+        }
+        return obj;
+      });
+  }
+
+  deleteAccountSSHKey(id: string) {
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: '/accounts/self/sshkeys/' + id,
+      anonymizedUrl: '/accounts/self/sshkeys/*',
+    });
+  }
+
+  getAccountGPGKeys() {
+    return this._restApiHelper.fetchJSON({
+      url: '/accounts/self/gpgkeys',
+      reportUrlAsIs: true,
+    }) as Promise<unknown> as Promise<Record<string, GpgKeyInfo>>;
+  }
+
+  addAccountGPGKey(key: GpgKeysInput) {
+    const req = {
+      method: HttpMethod.POST,
+      url: '/accounts/self/gpgkeys',
+      body: key,
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper
+      .send(req)
+      .then(response => {
+        if (!response || (response.status < 200 && response.status >= 300)) {
+          return Promise.reject(new Error('error'));
+        }
+        return this.getResponseObject(response);
+      })
+      .then(obj => {
+        if (!obj) {
+          return Promise.reject(new Error('error'));
+        }
+        return obj;
+      });
+  }
+
+  deleteAccountGPGKey(id: GpgKeyId) {
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: `/accounts/self/gpgkeys/${id}`,
+      anonymizedUrl: '/accounts/self/gpgkeys/*',
+    });
+  }
+
+  deleteVote(changeNum: NumericChangeId, account: AccountId, label: string) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.DELETE,
+      endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
+      anonymizedEndpoint: '/reviewers/*/votes/*',
+    });
+  }
+
+  setDescription(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    desc: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.PUT,
+      patchNum,
+      endpoint: '/description',
+      body: {description: desc},
+      reportUrlAsIs: true,
+    });
+  }
+
+  confirmEmail(token: string): Promise<string | null> {
+    const req = {
+      method: HttpMethod.PUT,
+      url: '/config/server/email.confirm',
+      body: {token},
+      reportUrlAsIs: true,
+    };
+    return this._restApiHelper.send(req).then(response => {
+      if (response?.status === 204) {
+        return 'Email confirmed successfully.';
+      }
+      return null;
+    });
+  }
+
+  getCapabilities(
+    errFn?: ErrorCallback
+  ): Promise<CapabilityInfoMap | undefined> {
+    return this._restApiHelper.fetchJSON({
+      url: '/config/server/capabilities',
+      errFn,
+      reportUrlAsIs: true,
+    }) as Promise<CapabilityInfoMap | undefined>;
+  }
+
+  getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
+    return this._fetchSharedCacheURL({
+      url: '/config/server/top-menus',
+      reportUrlAsIs: true,
+    }) as Promise<TopMenuEntryInfo[] | undefined>;
+  }
+
+  probePath(path: string) {
+    return fetch(new Request(path, {method: HttpMethod.HEAD})).then(
+      response => response.ok
+    );
+  }
+
+  startWorkInProgress(
+    changeNum: NumericChangeId,
+    message?: string
+  ): Promise<string | undefined> {
+    const body = message ? {message} : {};
+    const req: SendRawChangeRequest = {
+      changeNum,
+      method: HttpMethod.POST,
+      endpoint: '/wip',
+      body,
+      reportUrlAsIs: true,
+    };
+    return this._getChangeURLAndSend(req).then(response => {
+      if (response?.status === 204) {
+        return 'Change marked as Work In Progress.';
+      }
+      return undefined;
+    });
+  }
+
+  deleteComment(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    commentID: UrlEncodedCommentId,
+    reason: string
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method: HttpMethod.POST,
+      patchNum,
+      endpoint: `/comments/${commentID}/delete`,
+      body: {reason},
+      parseResponse: true,
+      anonymizedEndpoint: '/comments/*/delete',
+    }) as unknown as Promise<CommentInfo>;
+  }
+
+  getChange(
+    changeNum: ChangeId | NumericChangeId,
+    errFn: ErrorCallback
+  ): Promise<ChangeInfo | null> {
+    // Cannot use _changeBaseURL, as this function is used by _projectLookup.
+    return this._restApiHelper
+      .fetchJSON(
+        {
+          url: `/changes/?q=change:${changeNum}`,
+          errFn,
+          anonymizedUrl: '/changes/?q=change:*',
+        },
+        /* noAcceptHeader */ true
+      )
+      .then(res => {
+        const changeInfos = res as ChangeInfo[] | undefined;
+        if (!changeInfos || !changeInfos.length) {
+          return null;
+        }
+        return changeInfos[0];
+      });
+  }
+
+  async setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
+    const lookupProject = await this._projectLookup[changeNum];
+    if (lookupProject && lookupProject !== project) {
+      console.warn(
+        'Change set with multiple project nums.' +
+          'One of them must be invalid.'
+      );
+    }
+    this._projectLookup[changeNum] = Promise.resolve(project);
+  }
+
+  getFromProjectLookup(
+    changeNum: NumericChangeId
+  ): Promise<RepoName | undefined> {
+    const project = this._projectLookup[`${changeNum}`];
+    if (project) {
+      return project;
+    }
+
+    const onError = (response?: Response | null) => firePageError(response);
+
+    const projectPromise = this.getChange(changeNum, onError).then(change => {
+      if (!change || !change.project) {
+        return;
+      }
+      this.setInProjectLookup(changeNum, change.project);
+      return change.project;
+    });
+
+    this._projectLookup[changeNum] = projectPromise;
+
+    return projectPromise;
+  }
+
+  // if errFn is not set, then only Response possible
+  _getChangeURLAndSend(
+    req: SendRawChangeRequest & {errFn?: undefined}
+  ): Promise<Response>;
+
+  _getChangeURLAndSend(
+    req: SendRawChangeRequest
+  ): Promise<Response | undefined>;
+
+  _getChangeURLAndSend(req: SendJSONChangeRequest): Promise<ParsedJSON>;
+
+  _getChangeURLAndSend(
+    req: SendChangeRequest
+  ): Promise<ParsedJSON | Response | undefined> {
+    const anonymizedBaseUrl = req.patchNum
+      ? ANONYMIZED_REVISION_BASE_URL
+      : ANONYMIZED_CHANGE_BASE_URL;
+    const anonymizedEndpoint = req.reportEndpointAsIs
+      ? req.endpoint
+      : req.anonymizedEndpoint;
+
+    return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
+      const request: SendRequest = {
+        method: req.method,
+        url: url + req.endpoint,
+        body: req.body,
+        errFn: req.errFn,
+        contentType: req.contentType,
+        headers: req.headers,
+        parseResponse: req.parseResponse,
+        anonymizedUrl: anonymizedEndpoint
+          ? `${anonymizedBaseUrl}${anonymizedEndpoint}`
+          : undefined,
+      };
+      return this._restApiHelper.send(request);
+    });
+  }
+
+  _getChangeURLAndFetch(
+    req: FetchChangeJSON,
+    noAcceptHeader?: boolean
+  ): Promise<ParsedJSON | undefined> {
+    const anonymizedEndpoint = req.reportEndpointAsIs
+      ? req.endpoint
+      : req.anonymizedEndpoint;
+    const anonymizedBaseUrl = req.revision
+      ? ANONYMIZED_REVISION_BASE_URL
+      : ANONYMIZED_CHANGE_BASE_URL;
+    return this._changeBaseURL(req.changeNum, req.revision).then(url =>
+      this._restApiHelper.fetchJSON(
+        {
+          url: url + req.endpoint,
+          errFn: req.errFn,
+          params: req.params,
+          fetchOptions: req.fetchOptions,
+          anonymizedUrl: anonymizedEndpoint
+            ? anonymizedBaseUrl + anonymizedEndpoint
+            : undefined,
+        },
+        noAcceptHeader
+      )
+    );
+  }
+
+  executeChangeAction(
+    changeNum: NumericChangeId,
+    method: HttpMethod | undefined,
+    endpoint: string,
+    patchNum?: PatchSetNum,
+    payload?: RequestPayload
+  ): Promise<Response>;
+
+  executeChangeAction(
+    changeNum: NumericChangeId,
+    method: HttpMethod | undefined,
+    endpoint: string,
+    patchNum: PatchSetNum | undefined,
+    payload: RequestPayload | undefined,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  executeChangeAction(
+    changeNum: NumericChangeId,
+    method: HttpMethod | undefined,
+    endpoint: string,
+    patchNum?: PatchSetNum,
+    payload?: RequestPayload,
+    errFn?: ErrorCallback
+  ) {
+    return this._getChangeURLAndSend({
+      changeNum,
+      method,
+      patchNum,
+      endpoint,
+      body: payload,
+      errFn,
+    });
+  }
+
+  getBlame(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    base?: boolean
+  ) {
+    const encodedPath = encodeURIComponent(path);
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: `/files/${encodedPath}/blame`,
+      revision: patchNum,
+      params: base ? {base: 't'} : undefined,
+      anonymizedEndpoint: '/files/*/blame',
+    }) as Promise<BlameInfo[] | undefined>;
+  }
+
+  /**
+   * Modify the given create draft request promise so that it fails and throws
+   * an error if the response bears HTTP status 200 instead of HTTP 201.
+   *
+   * @see Issue 7763
+   * @param promise The original promise.
+   * @return The modified promise.
+   */
+  _failForCreate200(promise: Promise<Response>): Promise<Response> {
+    return promise.then(result => {
+      if (result.status === 200) {
+        // Read the response headers into an object representation.
+        const headers = Array.from(result.headers.entries()).reduce(
+          (obj, [key, val]) => {
+            if (!HEADER_REPORTING_BLOCK_REGEX.test(key)) {
+              obj[key] = val;
+            }
+            return obj;
+          },
+          {} as Record<string, string>
+        );
+        const err = new Error(
+          [
+            CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
+            JSON.stringify(headers),
+          ].join('\n')
+        );
+        // Throw the error so that it is caught by gr-reporting.
+        throw err;
+      }
+      return result;
+    });
+  }
+
+  getDashboard(
+    repo: RepoName,
+    dashboard: DashboardId,
+    errFn?: ErrorCallback
+  ): Promise<DashboardInfo | undefined> {
+    const url =
+      '/projects/' +
+      encodeURIComponent(repo) +
+      '/dashboards/' +
+      encodeURIComponent(dashboard);
+    return this._fetchSharedCacheURL({
+      url,
+      errFn,
+      anonymizedUrl: '/projects/*/dashboards/*',
+    }) as Promise<DashboardInfo | undefined>;
+  }
+
+  getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
+    filter = filter.trim();
+    const encodedFilter = encodeURIComponent(filter);
+
+    // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+    // supports it.
+    return this._fetchSharedCacheURL({
+      url: `/Documentation/?q=${encodedFilter}`,
+      anonymizedUrl: '/Documentation/?*',
+    }) as Promise<DocResult[] | undefined>;
+  }
+
+  getMergeable(changeNum: NumericChangeId) {
+    return this._getChangeURLAndFetch({
+      changeNum,
+      endpoint: '/revisions/current/mergeable',
+      reportEndpointAsIs: true,
+    }) as Promise<MergeableInfo | undefined>;
+  }
+
+  deleteDraftComments(query: string): Promise<Response> {
+    const body: DeleteDraftCommentsInput = {query};
+    return this._restApiHelper.send({
+      method: HttpMethod.POST,
+      url: '/accounts/self/drafts:delete',
+      body,
+    });
+  }
+}
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
new file mode 100644
index 0000000..9cf0b8e
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -0,0 +1,1588 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {
+  addListenerForTest,
+  assertFails,
+  MockPromise,
+  mockPromise,
+  waitEventLoop,
+} from '../../test/test-utils';
+import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  ListChangesOption,
+  listChangesOptionsToHex,
+} from '../../utils/change-util';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createComment,
+  createParsedChange,
+  createServerInfo,
+} from '../../test/test-data-generators';
+import {CURRENT} from '../../utils/patch-set-util';
+import {
+  parsePrefixedJSON,
+  readResponsePayload,
+  JSON_PREFIX,
+} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrRestApiServiceImpl} from './gr-rest-api-impl';
+import {
+  CommentSide,
+  createDefaultEditPrefs,
+  HttpMethod,
+} from '../../constants/constants';
+import {
+  BasePatchSetNum,
+  ChangeMessageId,
+  CommentInfo,
+  DashboardId,
+  DiffPreferenceInput,
+  EDIT,
+  EditPreferencesInfo,
+  Hashtag,
+  HashtagsInput,
+  NumericChangeId,
+  PARENT,
+  ParsedJSON,
+  PatchSetNum,
+  PreferencesInfo,
+  RepoName,
+  RevisionId,
+  RevisionPatchSetNum,
+  RobotCommentInfo,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {assert} from '@open-wc/testing';
+import {AuthService} from '../gr-auth/gr-auth';
+import {GrAuthMock} from '../gr-auth/gr-auth_mock';
+
+const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
+  ListChangesOption.CHANGE_ACTIONS,
+  ListChangesOption.CURRENT_ACTIONS,
+  ListChangesOption.CURRENT_REVISION,
+  ListChangesOption.DETAILED_LABELS,
+  ListChangesOption.SUBMIT_REQUIREMENTS
+);
+
+suite('gr-rest-api-service-impl tests', () => {
+  let element: GrRestApiServiceImpl;
+  let authService: AuthService;
+
+  let ctr = 0;
+  let originalCanonicalPath: string | undefined;
+
+  setup(() => {
+    // Modify CANONICAL_PATH to effectively reset cache.
+    ctr += 1;
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = `test${ctr}`;
+
+    const testJSON = ')]}\'\n{"hello": "bonjour"}';
+    sinon.stub(window, 'fetch').resolves(new Response(testJSON));
+    // fake auth
+    authService = new GrAuthMock();
+    sinon.stub(authService, 'authCheck').resolves(true);
+    element = new GrRestApiServiceImpl(authService);
+
+    element._projectLookup = {};
+  });
+
+  teardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('parent diff comments are properly grouped', async () => {
+    sinon.stub(element._restApiHelper, 'fetchJSON').resolves({
+      '/COMMIT_MSG': [],
+      'sieve.go': [
+        {
+          updated: '2017-02-03 22:32:28.000000000',
+          message: 'this isn’t quite right',
+        },
+        {
+          side: CommentSide.PARENT,
+          message: 'how did this work in the first place?',
+          updated: '2017-02-03 22:33:28.000000000',
+        },
+      ],
+    } as unknown as ParsedJSON);
+    const obj = await element._getDiffComments(
+      42 as NumericChangeId,
+      '/comments',
+      undefined,
+      PARENT,
+      1 as PatchSetNum,
+      'sieve.go'
+    );
+    assert.equal(obj.baseComments.length, 1);
+    assert.deepEqual(obj.baseComments[0], {
+      side: CommentSide.PARENT,
+      message: 'how did this work in the first place?',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+    assert.equal(obj.comments.length, 1);
+    assert.deepEqual(obj.comments[0], {
+      message: 'this isn’t quite right',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+  });
+
+  test('_setRange', () => {
+    const comments: CommentInfo[] = [
+      {
+        id: '1' as UrlEncodedCommentId,
+        side: CommentSide.PARENT,
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: '2' as UrlEncodedCommentId,
+        in_reply_to: '1' as UrlEncodedCommentId,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+      },
+    ];
+    const expectedResult: CommentInfo = {
+      id: '2' as UrlEncodedCommentId,
+      in_reply_to: '1' as UrlEncodedCommentId,
+      message: 'this isn’t quite right',
+      updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+      range: {
+        start_line: 1,
+        start_character: 1,
+        end_line: 2,
+        end_character: 1,
+      },
+    };
+    const comment = comments[1];
+    assert.deepEqual(element._setRange(comments, comment), expectedResult);
+  });
+
+  test('_setRanges', () => {
+    const comments: CommentInfo[] = [
+      {
+        id: '3' as UrlEncodedCommentId,
+        in_reply_to: '2' as UrlEncodedCommentId,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000' as Timestamp,
+      },
+      {
+        id: '2' as UrlEncodedCommentId,
+        in_reply_to: '1' as UrlEncodedCommentId,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+      },
+      {
+        id: '1' as UrlEncodedCommentId,
+        side: CommentSide.PARENT,
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    const expectedResult: CommentInfo[] = [
+      {
+        id: '1' as UrlEncodedCommentId,
+        side: CommentSide.PARENT,
+        message: 'how did this work in the first place?',
+        updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: '2' as UrlEncodedCommentId,
+        in_reply_to: '1' as UrlEncodedCommentId,
+        message: 'this isn’t quite right',
+        updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+      {
+        id: '3' as UrlEncodedCommentId,
+        in_reply_to: '2' as UrlEncodedCommentId,
+        message: 'this isn’t quite right either',
+        updated: '2017-02-03 22:34:28.000000000' as Timestamp,
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 2,
+          end_character: 1,
+        },
+      },
+    ];
+    assert.deepEqual(element._setRanges(comments), expectedResult);
+  });
+
+  test('differing patch diff comments are properly grouped', async () => {
+    sinon.stub(element, 'getFromProjectLookup').resolves('test' as RepoName);
+    sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(async request => {
+      const url = request.url;
+      if (url === '/changes/test~42/revisions/1/comments') {
+        return {
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
+              message: 'this isn’t quite right',
+              updated: '2017-02-03 22:32:28.000000000',
+            },
+            {
+              side: CommentSide.PARENT,
+              message: 'how did this work in the first place?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+          ],
+        } as unknown as ParsedJSON;
+      } else if (url === '/changes/test~42/revisions/2/comments') {
+        return {
+          '/COMMIT_MSG': [],
+          'sieve.go': [
+            {
+              message: 'What on earth are you thinking, here?',
+              updated: '2017-02-03 22:32:28.000000000',
+            },
+            {
+              side: CommentSide.PARENT,
+              message: 'Yeah not sure how this worked either?',
+              updated: '2017-02-03 22:33:28.000000000',
+            },
+            {
+              message: '¯\\_(ツ)_/¯',
+              updated: '2017-02-04 22:33:28.000000000',
+            },
+          ],
+        } as unknown as ParsedJSON;
+      }
+      return undefined;
+    });
+    const obj = await element._getDiffComments(
+      42 as NumericChangeId,
+      '/comments',
+      undefined,
+      1 as BasePatchSetNum,
+      2 as PatchSetNum,
+      'sieve.go'
+    );
+    assert.equal(obj.baseComments.length, 1);
+    assert.deepEqual(obj.baseComments[0], {
+      message: 'this isn’t quite right',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+    assert.equal(obj.comments.length, 2);
+    assert.deepEqual(obj.comments[0], {
+      message: 'What on earth are you thinking, here?',
+      path: 'sieve.go',
+      updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+    assert.deepEqual(obj.comments[1], {
+      message: '¯\\_(ツ)_/¯',
+      path: 'sieve.go',
+      updated: '2017-02-04 22:33:28.000000000' as Timestamp,
+    } as RobotCommentInfo);
+  });
+
+  test('server error', async () => {
+    const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
+    sinon
+      .stub(authService, 'fetch')
+      .resolves(new Response(undefined, {status: 502}));
+    const serverErrorEventPromise = new Promise(resolve => {
+      addListenerForTest(document, 'server-error', resolve);
+    });
+    const response = await element._restApiHelper.fetchJSON({url: ''});
+    assert.isUndefined(response);
+    assert.isTrue(getResponseObjectStub.notCalled);
+    await serverErrorEventPromise;
+  });
+
+  test('legacy n,z key in change url is replaced', async () => {
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves([] as unknown as ParsedJSON);
+    await element.getChanges(1, undefined, 'n,z');
+    assert.equal(stub.lastCall.args[0].params!.S, 0);
+  });
+
+  test('saveDiffPreferences invalidates cache line', () => {
+    const cacheKey = '/accounts/self/preferences.diff';
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element._cache.set(cacheKey, {tab_size: 4} as unknown as ParsedJSON);
+    element.saveDiffPreferences({
+      tab_size: 8,
+      ignore_whitespace: 'IGNORE_NONE',
+    });
+    assert.isTrue(sendStub.called);
+    assert.isFalse(element._cache.has(cacheKey));
+  });
+
+  suite('getAccountSuggestions', () => {
+    let fetchStub: sinon.SinonStub;
+    setup(() => {
+      fetchStub = sinon
+        .stub(element._restApiHelper, 'fetch')
+        .resolves(new Response());
+    });
+
+    test('url with just email', () => {
+      element.getSuggestedAccounts('bro');
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.firstCall.args[0].url,
+        'test52/accounts/?o=DETAILS&q=%22bro%22'
+      );
+    });
+
+    test('url with email and canSee changeId', () => {
+      element.getSuggestedAccounts('bro', undefined, 341682 as NumericChangeId);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.firstCall.args[0].url,
+        'test53/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A341682'
+      );
+    });
+
+    test('url with email and canSee changeId and isActive', () => {
+      element.getSuggestedAccounts(
+        'bro',
+        undefined,
+        341682 as NumericChangeId,
+        true
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.firstCall.args[0].url,
+        'test54/accounts/?o=DETAILS&q=%22bro%22%20and%20' +
+          'cansee%3A341682%20and%20is%3Aactive'
+      );
+    });
+  });
+
+  test('getAccount when resp is undefined clears cache', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const account = createAccountDetailWithId();
+    element._cache.set(cacheKey, account);
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(async req => {
+        req.errFn!(undefined);
+        return undefined;
+      });
+    assert.isTrue(element._cache.has(cacheKey));
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.isFalse(element._cache.has(cacheKey));
+  });
+
+  test('getAccount when status is 403 clears cache', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const account = createAccountDetailWithId();
+    element._cache.set(cacheKey, account);
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(async req => {
+        req.errFn!(new Response(undefined, {status: 403}));
+        return undefined;
+      });
+    assert.isTrue(element._cache.has(cacheKey));
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.isFalse(element._cache.has(cacheKey));
+  });
+
+  test('getAccount when resp is successful updates cache', async () => {
+    const cacheKey = '/accounts/self/detail';
+    const account = createAccountDetailWithId();
+    const stub = sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(async () => {
+        element._cache.set(cacheKey, account);
+        return undefined;
+      });
+    assert.isFalse(element._cache.has(cacheKey));
+
+    await element.getAccount();
+    assert.isTrue(stub.called);
+    assert.equal(element._cache.get(cacheKey), account);
+  });
+
+  const preferenceSetup = function (testJSON: unknown, loggedIn: boolean) {
+    sinon
+      .stub(element, 'getLoggedIn')
+      .callsFake(() => Promise.resolve(loggedIn));
+    sinon
+      .stub(element._restApiHelper, 'fetchCacheURL')
+      .callsFake(() => Promise.resolve(testJSON as ParsedJSON));
+  };
+
+  test('getPreferences returns correctly logged in', async () => {
+    const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+    const loggedIn = true;
+
+    preferenceSetup(testJSON, loggedIn);
+
+    const obj = await element.getPreferences();
+    assert.equal(obj!.diff_view, 'SIDE_BY_SIDE');
+  });
+
+  test('getPreferences returns correctly on larger screens logged in', async () => {
+    const testJSON = {diff_view: 'UNIFIED_DIFF'};
+    const loggedIn = true;
+
+    preferenceSetup(testJSON, loggedIn);
+
+    const obj = await element.getPreferences();
+    assert.equal(obj!.diff_view, 'UNIFIED_DIFF');
+  });
+
+  test('getPreferences returns correctly on larger screens no login', async () => {
+    const testJSON = {diff_view: 'UNIFIED_DIFF'};
+    const loggedIn = false;
+
+    preferenceSetup(testJSON, loggedIn);
+
+    const obj = await element.getPreferences();
+    assert.equal(obj!.diff_view, 'SIDE_BY_SIDE');
+  });
+
+  test('savPreferences normalizes download scheme', () => {
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves(new Response());
+    element.savePreferences({download_scheme: 'HTTP'});
+    assert.isTrue(sendStub.called);
+    assert.equal(
+      (sendStub.lastCall.args[0].body as Partial<PreferencesInfo>)
+        .download_scheme,
+      'http'
+    );
+  });
+
+  test('getDiffPreferences returns correct defaults', async () => {
+    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+    const obj = (await element.getDiffPreferences())!;
+    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.line_length, 100);
+    assert.equal(obj.line_wrapping, false);
+    assert.equal(obj.show_line_endings, true);
+    assert.equal(obj.show_tabs, true);
+    assert.equal(obj.show_whitespace_errors, true);
+    assert.equal(obj.syntax_highlighting, true);
+    assert.equal(obj.tab_size, 8);
+  });
+
+  test('saveDiffPreferences set show_tabs to false', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element.saveDiffPreferences({
+      show_tabs: false,
+      ignore_whitespace: 'IGNORE_NONE',
+    });
+    assert.isTrue(sendStub.called);
+    assert.equal(
+      (sendStub.lastCall.args[0].body as Partial<DiffPreferenceInput>)
+        .show_tabs,
+      false
+    );
+  });
+
+  test('getEditPreferences returns correct defaults', async () => {
+    sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+    const obj = (await element.getEditPreferences())!;
+    assert.equal(obj.auto_close_brackets, false);
+    assert.equal(obj.cursor_blink_rate, 0);
+    assert.equal(obj.hide_line_numbers, false);
+    assert.equal(obj.hide_top_menu, false);
+    assert.equal(obj.indent_unit, 2);
+    assert.equal(obj.indent_with_tabs, false);
+    assert.equal(obj.key_map_type, 'DEFAULT');
+    assert.equal(obj.line_length, 100);
+    assert.equal(obj.line_wrapping, false);
+    assert.equal(obj.match_brackets, true);
+    assert.equal(obj.show_base, false);
+    assert.equal(obj.show_tabs, true);
+    assert.equal(obj.show_whitespace_errors, true);
+    assert.equal(obj.syntax_highlighting, true);
+    assert.equal(obj.tab_size, 8);
+    assert.equal(obj.theme, 'DEFAULT');
+  });
+
+  test('saveEditPreferences set show_tabs to false', () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    element.saveEditPreferences({
+      ...createDefaultEditPrefs(),
+      show_tabs: false,
+    });
+    assert.isTrue(sendStub.called);
+    assert.equal(
+      (sendStub.lastCall.args[0].body as EditPreferencesInfo).show_tabs,
+      false
+    );
+  });
+
+  test('confirmEmail', () => {
+    const sendStub = sinon.spy(element._restApiHelper, 'send');
+    element.confirmEmail('foo');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(sendStub.lastCall.args[0].url, '/config/server/email.confirm');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
+  });
+
+  test('setAccountStatus', async () => {
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves('OOO' as unknown as ParsedJSON);
+    element._cache.set('/accounts/self/detail', createAccountDetailWithId());
+    await element.setAccountStatus('OOO');
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(sendStub.lastCall.args[0].url, '/accounts/self/status');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {status: 'OOO'});
+    assert.deepEqual(
+      element._cache.get('/accounts/self/detail')!.status,
+      'OOO'
+    );
+  });
+
+  suite('draft comments', () => {
+    test('_sendDiffDraftRequest pending requests tracked', async () => {
+      const obj = element._pendingRequests;
+      sinon
+        .stub(element, '_getChangeURLAndSend')
+        .callsFake(() => mockPromise());
+      assert.notOk(element.hasPendingDiffDrafts());
+
+      element._sendDiffDraftRequest(
+        HttpMethod.PUT,
+        123 as NumericChangeId,
+        1 as PatchSetNum,
+        {}
+      );
+      assert.equal(obj.sendDiffDraft.length, 1);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
+
+      element._sendDiffDraftRequest(
+        HttpMethod.PUT,
+        123 as NumericChangeId,
+        1 as PatchSetNum,
+        {}
+      );
+      assert.equal(obj.sendDiffDraft.length, 2);
+      assert.isTrue(!!element.hasPendingDiffDrafts());
+
+      for (const promise of obj.sendDiffDraft) {
+        (promise as MockPromise<void>).resolve();
+      }
+
+      await element.awaitPendingDiffDrafts();
+      assert.equal(obj.sendDiffDraft.length, 0);
+      assert.isFalse(!!element.hasPendingDiffDrafts());
+    });
+
+    suite('_failForCreate200', () => {
+      test('_sendDiffDraftRequest checks for 200 on create', async () => {
+        const sendPromise = Promise.resolve({} as unknown as ParsedJSON);
+        sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
+        const failStub = sinon.stub(element, '_failForCreate200').resolves();
+        await element._sendDiffDraftRequest(
+          HttpMethod.PUT,
+          123 as NumericChangeId,
+          4 as PatchSetNum,
+          {}
+        );
+        assert.isTrue(failStub.calledOnce);
+        assert.isTrue(failStub.calledWithExactly(sendPromise));
+      });
+
+      test('_sendDiffDraftRequest no checks for 200 on non create', async () => {
+        sinon.stub(element, '_getChangeURLAndSend').resolves();
+        const failStub = sinon.stub(element, '_failForCreate200').resolves();
+        await element._sendDiffDraftRequest(
+          HttpMethod.PUT,
+          123 as NumericChangeId,
+          4 as PatchSetNum,
+          {
+            id: '123' as UrlEncodedCommentId,
+          }
+        );
+        assert.isFalse(failStub.called);
+      });
+
+      test('_failForCreate200 fails on 200', async () => {
+        const result = new Response(undefined, {
+          status: 200,
+          headers: {
+            'Set-CoOkiE': 'secret',
+            Innocuous: 'hello',
+          },
+        });
+        const error = await assertFails<Error>(
+          element._failForCreate200(Promise.resolve(result))
+        );
+        assert.isOk(error);
+        assert.include(error.message, 'Saving draft resulted in HTTP 200');
+        assert.include(error.message, 'hello');
+        assert.notInclude(error.message, 'secret');
+      });
+
+      test('_failForCreate200 does not fail on 201', () => {
+        const result = new Response(undefined, {status: 201});
+        return element._failForCreate200(Promise.resolve(result));
+      });
+    });
+  });
+
+  test('saveChangeEdit', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const file_name = 'index.php';
+    const file_contents = '<?php';
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves([
+        change_num,
+        file_name,
+        file_contents,
+      ] as unknown as ParsedJSON);
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves([
+        change_num,
+        file_name,
+        file_contents,
+      ] as unknown as ParsedJSON);
+    element._cache.set(
+      `/changes/${change_num}/edit/${file_name}`,
+      {} as unknown as ParsedJSON
+    );
+    await element.saveChangeEdit(change_num, file_name, file_contents);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(
+      sendStub.lastCall.args[0].url,
+      '/changes/test~1/edit/' + file_name
+    );
+    assert.equal(sendStub.lastCall.args[0].body, file_contents);
+  });
+
+  test('putChangeCommitMessage', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const message = 'this is a commit message';
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves([change_num, message] as unknown as ParsedJSON);
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves([change_num, message] as unknown as ParsedJSON);
+    element._cache.set(
+      `/changes/${change_num}/message`,
+      {} as unknown as ParsedJSON
+    );
+    await element.putChangeCommitMessage(change_num, message);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+    assert.equal(sendStub.lastCall.args[0].url, '/changes/test~1/message');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      message,
+    });
+  });
+
+  test('deleteChangeCommitMessage', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const change_num = 1 as NumericChangeId;
+    const messageId = 'abc' as ChangeMessageId;
+    const sendStub = sinon
+      .stub(element._restApiHelper, 'send')
+      .resolves([change_num, messageId] as unknown as ParsedJSON);
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves([change_num, messageId] as unknown as ParsedJSON);
+    await element.deleteChangeCommitMessage(change_num, messageId);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.DELETE);
+    assert.equal(sendStub.lastCall.args[0].url, '/changes/test~1/messages/abc');
+  });
+
+  test('startWorkInProgress', () => {
+    const sendStub = sinon
+      .stub(element, '_getChangeURLAndSend')
+      .resolves('ok' as unknown as ParsedJSON);
+    element.startWorkInProgress(42 as NumericChangeId);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {});
+
+    element.startWorkInProgress(42 as NumericChangeId, 'revising...');
+    assert.isTrue(sendStub.calledTwice);
+    assert.equal(sendStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      message: 'revising...',
+    });
+  });
+
+  test('deleteComment', async () => {
+    const comment = createComment();
+    const sendStub = sinon
+      .stub(element, '_getChangeURLAndSend')
+      .resolves(comment as unknown as ParsedJSON);
+    const response = await element.deleteComment(
+      123 as NumericChangeId,
+      1 as PatchSetNum,
+      '01234' as UrlEncodedCommentId,
+      'removal reason'
+    );
+    assert.equal(response, comment);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].changeNum, 123 as NumericChangeId);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.equal(sendStub.lastCall.args[0].patchNum, 1 as PatchSetNum);
+    assert.equal(sendStub.lastCall.args[0].endpoint, '/comments/01234/delete');
+    assert.deepEqual(sendStub.lastCall.args[0].body, {
+      reason: 'removal reason',
+    });
+  });
+
+  test('createRepo encodes name', async () => {
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+    await element.createRepo({name: 'x/y' as RepoName});
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
+  });
+
+  test('queryChangeFiles', async () => {
+    const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+    await element.queryChangeFiles(42 as NumericChangeId, EDIT, 'test/path.js');
+    assert.equal(fetchStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+    assert.equal(
+      fetchStub.lastCall.args[0].endpoint,
+      '/files?q=test%2Fpath.js'
+    );
+    assert.equal(fetchStub.lastCall.args[0].revision, EDIT);
+  });
+
+  test('normal use', () => {
+    const defaultQuery = '';
+
+    assert.equal(
+      element._getReposUrl('test', 25).toString(),
+      [false, '/projects/?n=26&S=0&d=&m=test'].toString()
+    );
+
+    assert.equal(
+      element._getReposUrl(undefined, 25).toString(),
+      [false, `/projects/?n=26&S=0&d=&m=${defaultQuery}`].toString()
+    );
+
+    assert.equal(
+      element._getReposUrl('test', 25, 25).toString(),
+      [false, '/projects/?n=26&S=25&d=&m=test'].toString()
+    );
+
+    assert.equal(
+      element._getReposUrl('inname:test', 25, 25).toString(),
+      [true, '/projects/?n=26&S=25&query=inname%3Atest'].toString()
+    );
+  });
+
+  test('invalidateReposCache', () => {
+    const url = '/projects/?n=26&S=0&query=test';
+
+    element._cache.set(url, {} as unknown as ParsedJSON);
+
+    element.invalidateReposCache();
+
+    assert.isUndefined(element._sharedFetchPromises.get(url));
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  test('invalidateAccountsCache', () => {
+    const url = '/accounts/self/detail';
+
+    element._cache.set(url, {} as unknown as ParsedJSON);
+
+    element.invalidateAccountsCache();
+
+    assert.isUndefined(element._sharedFetchPromises.get(url));
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getRepos', () => {
+    const defaultQuery = '';
+    let fetchCacheURLStub: sinon.SinonStub;
+    setup(() => {
+      fetchCacheURLStub = sinon
+        .stub(element._restApiHelper, 'fetchCacheURL')
+        .resolves([] as unknown as ParsedJSON);
+    });
+
+    test('normal use', () => {
+      element.getRepos('test', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=test'
+      );
+
+      element.getRepos(undefined, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        `/projects/?n=26&S=0&d=&m=${defaultQuery}`
+      );
+
+      element.getRepos('test', 25, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=25&d=&m=test'
+      );
+    });
+
+    test('with blank', () => {
+      element.getRepos('test/test', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=test%2Ftest'
+      );
+    });
+
+    test('with hyphen', () => {
+      element.getRepos('foo-bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo-bar'
+      );
+    });
+
+    test('with leading hyphen', () => {
+      element.getRepos('-bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=-bar'
+      );
+    });
+
+    test('with trailing hyphen', () => {
+      element.getRepos('foo-bar-', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo-bar-'
+      );
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo_bar'
+      );
+    });
+
+    test('with underscore', () => {
+      element.getRepos('foo_bar', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=foo_bar'
+      );
+    });
+
+    test('hyphen only', () => {
+      element.getRepos('-', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&d=&m=-'
+      );
+    });
+
+    test('using query', () => {
+      element.getRepos('description:project', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/projects/?n=26&S=0&query=description%3Aproject'
+      );
+    });
+  });
+
+  test('_getGroupsUrl normal use', () => {
+    assert.equal(element._getGroupsUrl('test', 25), '/groups/?n=26&S=0&m=test');
+
+    assert.equal(element._getGroupsUrl('', 25), '/groups/?n=26&S=0');
+
+    assert.equal(
+      element._getGroupsUrl('test', 25, 25),
+      '/groups/?n=26&S=25&m=test'
+    );
+  });
+
+  test('invalidateGroupsCache', () => {
+    const url = '/groups/?n=26&S=0&m=test';
+
+    element._cache.set(url, {} as unknown as ParsedJSON);
+
+    element.invalidateGroupsCache();
+
+    assert.isUndefined(element._sharedFetchPromises.get(url));
+
+    assert.isFalse(element._cache.has(url));
+  });
+
+  suite('getGroups', () => {
+    let fetchCacheURLStub: sinon.SinonStub;
+    setup(() => {
+      fetchCacheURLStub = sinon.stub(element._restApiHelper, 'fetchCacheURL');
+    });
+
+    test('normal use', () => {
+      element.getGroups('test', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=0&m=test'
+      );
+
+      element.getGroups('', 25);
+      assert.equal(fetchCacheURLStub.lastCall.args[0].url, '/groups/?n=26&S=0');
+
+      element.getGroups('test', 25, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=25&m=test'
+      );
+    });
+
+    test('regex', () => {
+      element.getGroups('^test.*', 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=0&r=%5Etest.*'
+      );
+
+      element.getGroups('^test.*', 25, 25);
+      assert.equal(
+        fetchCacheURLStub.lastCall.args[0].url,
+        '/groups/?n=26&S=25&r=%5Etest.*'
+      );
+    });
+  });
+
+  test('gerrit auth is used', () => {
+    const fetchStub = sinon.stub(authService, 'fetch').resolves();
+    element._restApiHelper.fetchJSON({url: 'foo'});
+    assert(fetchStub.called);
+  });
+
+  test('getSuggestedAccounts does not return fetchJSON', async () => {
+    const fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
+    const accts = await element.getSuggestedAccounts('');
+    assert.isFalse(fetchJSONSpy.called);
+    assert.equal(accts!.length, 0);
+  });
+
+  test('fetchJSON gets called by getSuggestedAccounts', async () => {
+    const fetchJSONStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves();
+    await element.getSuggestedAccounts('own');
+    assert.deepEqual(fetchJSONStub.lastCall.args[0].params, {
+      q: '"own"',
+      o: 'DETAILS',
+    });
+  });
+
+  suite('getChangeDetail', () => {
+    suite('change detail options', () => {
+      let changeDetailStub: sinon.SinonStub;
+      setup(() => {
+        changeDetailStub = sinon
+          .stub(element, '_getChangeDetail')
+          .resolves({...createChange(), _number: 123 as NumericChangeId});
+      });
+
+      test('signed pushes disabled', async () => {
+        sinon.stub(element, 'getConfig').resolves({
+          ...createServerInfo(),
+          receive: {enable_signed_push: undefined},
+        });
+        const change = await element.getChangeDetail(123 as NumericChangeId);
+        assert.strictEqual(123, change!._number);
+        const options = changeDetailStub.firstCall.args[1];
+        assert.isNotOk(
+          parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
+        );
+      });
+
+      test('signed pushes enabled', async () => {
+        sinon.stub(element, 'getConfig').resolves({
+          ...createServerInfo(),
+          receive: {enable_signed_push: 'true'},
+        });
+        const change = await element.getChangeDetail(123 as NumericChangeId);
+        assert.strictEqual(123, change!._number);
+        const options = changeDetailStub.firstCall.args[1];
+        assert.ok(
+          parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
+        );
+      });
+    });
+
+    test('GrReviewerUpdatesParser.parse is used', async () => {
+      const changeInfo = createParsedChange();
+      const parseStub = sinon
+        .stub(GrReviewerUpdatesParser, 'parse')
+        .resolves(changeInfo);
+      const result = await element.getChangeDetail(42 as NumericChangeId);
+      assert.isTrue(parseStub.calledOnce);
+      assert.equal(result, changeInfo);
+    });
+
+    test('_getChangeDetail passes params to ETags decorator', async () => {
+      const changeNum = 4321 as NumericChangeId;
+      element._projectLookup[changeNum] = Promise.resolve('test' as RepoName);
+      const expectedUrl = `${window.CANONICAL_PATH}/changes/test~4321/detail?O=516714`;
+      const optionsStub = sinon.stub(element._etags, 'getOptions');
+      const collectStub = sinon.stub(element._etags, 'collect');
+      await element._getChangeDetail(changeNum, '516714');
+      assert.isTrue(optionsStub.calledWithExactly(expectedUrl));
+      assert.equal(collectStub.lastCall.args[0], expectedUrl);
+    });
+
+    test('_getChangeDetail calls errFn on 500', async () => {
+      const errFn = sinon.stub();
+      sinon.stub(element, 'getChangeActionURL').resolves('');
+      sinon
+        .stub(element._restApiHelper, 'fetchRawJSON')
+        .resolves(new Response(undefined, {status: 500}));
+      await element._getChangeDetail(123 as NumericChangeId, '516714', errFn);
+      assert.isTrue(errFn.called);
+    });
+
+    test('_getChangeDetail populates _projectLookup', async () => {
+      sinon.stub(element, 'getChangeActionURL').resolves('');
+      sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+        new Response(')]}\'{"_number":1,"project":"test"}', {
+          status: 200,
+        })
+      );
+      await element._getChangeDetail(1 as NumericChangeId, '516714');
+      assert.equal(Object.keys(element._projectLookup).length, 1);
+      const project = await element._projectLookup[1];
+      assert.equal(project, 'test' as RepoName);
+    });
+
+    suite('_getChangeDetail ETag cache', () => {
+      let requestUrl: string;
+      let mockResponseSerial: string;
+      let collectSpy: sinon.SinonSpy;
+
+      setup(() => {
+        requestUrl = '/foo/bar';
+        const mockResponse = {foo: 'bar', baz: 42};
+        mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
+        sinon.stub(element._restApiHelper, 'urlWithParams').returns(requestUrl);
+        sinon.stub(element, 'getChangeActionURL').resolves(requestUrl);
+        collectSpy = sinon.spy(element._etags, 'collect');
+      });
+
+      test('contributes to cache', async () => {
+        const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
+        sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+          new Response(mockResponseSerial, {
+            status: 200,
+          })
+        );
+
+        await element._getChangeDetail(123 as NumericChangeId, '516714');
+        assert.isFalse(getPayloadSpy.called);
+        assert.isTrue(collectSpy.calledOnce);
+        const cachedResponse = element._etags.getCachedPayload(requestUrl);
+        assert.equal(cachedResponse, mockResponseSerial);
+      });
+
+      test('uses cache on HTTP 304', async () => {
+        const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
+        getPayloadStub.returns(mockResponseSerial);
+        sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+          new Response(undefined, {
+            status: 304,
+          })
+        );
+
+        await element._getChangeDetail(123 as NumericChangeId, '');
+        assert.isFalse(collectSpy.called);
+        assert.isTrue(getPayloadStub.calledOnce);
+      });
+    });
+  });
+
+  test('setInProjectLookup', async () => {
+    await element.setInProjectLookup(
+      555 as NumericChangeId,
+      'project' as RepoName
+    );
+    const project = await element.getFromProjectLookup(555 as NumericChangeId);
+    assert.deepEqual(project, 'project' as RepoName);
+  });
+
+  suite('getFromProjectLookup', () => {
+    test('getChange succeeds, no project', async () => {
+      sinon.stub(element, 'getChange').resolves(null);
+      const val = await element.getFromProjectLookup(555 as NumericChangeId);
+      assert.strictEqual(val, undefined);
+    });
+
+    test('getChange succeeds with project', async () => {
+      sinon
+        .stub(element, 'getChange')
+        .resolves({...createChange(), project: 'project' as RepoName});
+      const projectLookup = element.getFromProjectLookup(
+        555 as NumericChangeId
+      );
+      const val = await projectLookup;
+      assert.equal(val, 'project' as RepoName);
+      assert.deepEqual(element._projectLookup, {'555': projectLookup});
+    });
+  });
+
+  suite('getChanges populates _projectLookup', () => {
+    test('multiple queries', async () => {
+      sinon.stub(element._restApiHelper, 'fetchJSON').resolves([
+        [
+          {_number: 1, project: 'test'},
+          {_number: 2, project: 'test'},
+        ],
+        [{_number: 3, project: 'test/test'}],
+      ] as unknown as ParsedJSON);
+      // When opt_query instanceof Array, fetchJSON returns
+      // Array<Array<Object>>.
+      await element.getChangesForMultipleQueries(undefined, []);
+      assert.equal(Object.keys(element._projectLookup).length, 3);
+      const project1 = await element.getFromProjectLookup(1 as NumericChangeId);
+      assert.equal(project1, 'test' as RepoName);
+      const project2 = await element.getFromProjectLookup(2 as NumericChangeId);
+      assert.equal(project2, 'test' as RepoName);
+      const project3 = await element.getFromProjectLookup(3 as NumericChangeId);
+      assert.equal(project3, 'test/test' as RepoName);
+    });
+
+    test('no query', async () => {
+      sinon.stub(element._restApiHelper, 'fetchJSON').resolves([
+        {_number: 1, project: 'test'},
+        {_number: 2, project: 'test'},
+        {_number: 3, project: 'test/test'},
+      ] as unknown as ParsedJSON);
+
+      // When opt_query !instanceof Array, fetchJSON returns Array<Object>.
+      await element.getChanges();
+      assert.equal(Object.keys(element._projectLookup).length, 3);
+      const project1 = await element.getFromProjectLookup(1 as NumericChangeId);
+      assert.equal(project1, 'test' as RepoName);
+      const project2 = await element.getFromProjectLookup(2 as NumericChangeId);
+      assert.equal(project2, 'test' as RepoName);
+      const project3 = await element.getFromProjectLookup(3 as NumericChangeId);
+      assert.equal(project3, 'test/test' as RepoName);
+    });
+  });
+
+  test('getDetailedChangesWithActions', async () => {
+    const c1 = createChange();
+    c1._number = 1 as NumericChangeId;
+    const c2 = createChange();
+    c2._number = 2 as NumericChangeId;
+    const getChangesStub = sinon
+      .stub(element, 'getChanges')
+      .callsFake((changesPerPage, query, offset, options) => {
+        assert.isUndefined(changesPerPage);
+        assert.strictEqual(query, 'change:1 OR change:2');
+        assert.isUndefined(offset);
+        assert.strictEqual(options, EXPECTED_QUERY_OPTIONS);
+        return Promise.resolve([]);
+      });
+    await element.getDetailedChangesWithActions([c1._number, c2._number]);
+    assert.isTrue(getChangesStub.calledOnce);
+  });
+
+  test('_getChangeURLAndFetch', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const fetchStub = sinon
+      .stub(element._restApiHelper, 'fetchJSON')
+      .resolves();
+    const req = {
+      changeNum: 1 as NumericChangeId,
+      endpoint: '/test',
+      revision: 1 as RevisionId,
+    };
+    await element._getChangeURLAndFetch(req);
+    assert.equal(
+      fetchStub.lastCall.args[0].url,
+      '/changes/test~1/revisions/1/test'
+    );
+  });
+
+  test('_getChangeURLAndSend', async () => {
+    element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+
+    const req = {
+      changeNum: 1 as NumericChangeId,
+      method: HttpMethod.POST,
+      patchNum: 1 as PatchSetNum,
+      endpoint: '/test',
+    };
+    await element._getChangeURLAndSend(req);
+    assert.isTrue(sendStub.calledOnce);
+    assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+    assert.equal(
+      sendStub.lastCall.args[0].url,
+      '/changes/test~1/revisions/1/test'
+    );
+  });
+
+  suite('reading responses', () => {
+    test('_readResponsePayload', async () => {
+      const mockObject = {foo: 'bar', baz: 'foo'} as unknown as ParsedJSON;
+      const serial = JSON_PREFIX + JSON.stringify(mockObject);
+      const response = new Response(serial);
+      const payload = await readResponsePayload(response);
+      assert.deepEqual(payload.parsed, mockObject);
+      assert.equal(payload.raw, serial);
+    });
+
+    test('_parsePrefixedJSON', () => {
+      const obj = {x: 3, y: {z: 4}, w: 23} as unknown as ParsedJSON;
+      const serial = JSON_PREFIX + JSON.stringify(obj);
+      const result = parsePrefixedJSON(serial);
+      assert.deepEqual(result, obj);
+    });
+  });
+
+  test('setChangeTopic', async () => {
+    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+    await element.setChangeTopic(123 as NumericChangeId, 'foo-bar');
+    assert.isTrue(sendSpy.calledOnce);
+    assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
+  });
+
+  test('setChangeHashtag', async () => {
+    const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+    await element.setChangeHashtag(123 as NumericChangeId, {
+      add: ['foo-bar' as Hashtag],
+    });
+    assert.isTrue(sendSpy.calledOnce);
+    assert.sameDeepMembers(
+      (sendSpy.lastCall.args[0].body! as HashtagsInput).add!,
+      ['foo-bar']
+    );
+  });
+
+  test('generateAccountHttpPassword', async () => {
+    const sendSpy = sinon.spy(element._restApiHelper, 'send');
+    await element.generateAccountHttpPassword();
+    assert.isTrue(sendSpy.calledOnce);
+    assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
+  });
+
+  suite('getChangeFiles', () => {
+    test('patch only', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      const range = {basePatchNum: PARENT, patchNum: 2 as RevisionPatchSetNum};
+      await element.getChangeFiles(123 as NumericChangeId, range);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(
+        fetchStub.lastCall.args[0].revision,
+        2 as RevisionPatchSetNum
+      );
+      assert.isNotOk(fetchStub.lastCall.args[0].params);
+    });
+
+    test('simple range', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      const range = {
+        basePatchNum: 4 as BasePatchSetNum,
+        patchNum: 5 as RevisionPatchSetNum,
+      };
+      await element.getChangeFiles(123 as NumericChangeId, range);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.equal(fetchStub.lastCall.args[0].params!.base, 4);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+    });
+
+    test('parent index', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      const range = {
+        basePatchNum: -3 as BasePatchSetNum,
+        patchNum: 5 as RevisionPatchSetNum,
+      };
+      await element.getChangeFiles(123 as NumericChangeId, range);
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+      assert.equal(fetchStub.lastCall.args[0].params!.parent, 3);
+    });
+  });
+
+  suite('getDiff', () => {
+    test('patchOnly', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      await element.getDiff(
+        123 as NumericChangeId,
+        PARENT,
+        2 as PatchSetNum,
+        'foo/bar.baz'
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 2 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+    });
+
+    test('simple range', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      await element.getDiff(
+        123 as NumericChangeId,
+        4 as PatchSetNum,
+        5 as PatchSetNum,
+        'foo/bar.baz'
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+      assert.equal(fetchStub.lastCall.args[0].params!.base, 4);
+    });
+
+    test('parent index', async () => {
+      const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+      await element.getDiff(
+        123 as NumericChangeId,
+        -3 as PatchSetNum,
+        5 as PatchSetNum,
+        'foo/bar.baz'
+      );
+      assert.isTrue(fetchStub.calledOnce);
+      assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+      assert.isOk(fetchStub.lastCall.args[0].params);
+      assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+      assert.equal(fetchStub.lastCall.args[0].params!.parent, 3);
+    });
+  });
+
+  test('getDashboard', () => {
+    const fetchCacheURLStub = sinon.stub(
+      element._restApiHelper,
+      'fetchCacheURL'
+    );
+    element.getDashboard(
+      'gerrit/project' as RepoName,
+      'default:main' as DashboardId
+    );
+    assert.isTrue(fetchCacheURLStub.calledOnce);
+    assert.equal(
+      fetchCacheURLStub.lastCall.args[0].url,
+      '/projects/gerrit%2Fproject/dashboards/default%3Amain'
+    );
+  });
+
+  test('getFileContent', async () => {
+    sinon.stub(element, '_getChangeURLAndSend').resolves(
+      new Response(undefined, {
+        status: 200,
+        headers: {
+          'X-FYI-Content-Type': 'text/java',
+        },
+      }) as unknown as ParsedJSON
+    );
+
+    sinon
+      .stub(element, 'getResponseObject')
+      .resolves('new content' as unknown as ParsedJSON);
+
+    const edit = await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      'EDIT' as PatchSetNum
+    );
+
+    assert.deepEqual(edit, {
+      content: 'new content',
+      type: 'text/java',
+      ok: true,
+    });
+
+    const normal = await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      '3' as PatchSetNum
+    );
+    assert.deepEqual(normal, {
+      content: 'new content',
+      type: 'text/java',
+      ok: true,
+    });
+  });
+
+  test('getFileContent suppresses 404s', async () => {
+    const res404 = new Response(undefined, {status: 404});
+    const res500 = new Response(undefined, {status: 500});
+    const spy = sinon.spy();
+    addListenerForTest(document, 'server-error', spy);
+    const authStub = sinon.stub(authService, 'fetch').resolves(res404);
+    sinon.stub(element, '_changeBaseURL').resolves('');
+    await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      1 as PatchSetNum
+    );
+    await waitEventLoop();
+    assert.isFalse(spy.called);
+    authStub.reset();
+    authStub.resolves(res500);
+    await element.getFileContent(
+      1 as NumericChangeId,
+      'tst/path',
+      1 as PatchSetNum
+    );
+    assert.isTrue(spy.called);
+    assert.notEqual(spy.lastCall.args[0].detail.response.status, 404);
+  });
+
+  test('getChangeFilesOrEditFiles is edit-sensitive', async () => {
+    const getChangeFilesStub = sinon
+      .stub(element, 'getChangeFiles')
+      .resolves({});
+    const getChangeEditFilesStub = sinon
+      .stub(element, 'getChangeEditFiles')
+      .resolves({files: {}});
+
+    await element.getChangeOrEditFiles(1 as NumericChangeId, {
+      basePatchNum: PARENT,
+      patchNum: EDIT,
+    });
+    assert.isTrue(getChangeEditFilesStub.calledOnce);
+    assert.isFalse(getChangeFilesStub.called);
+    await element.getChangeOrEditFiles(1 as NumericChangeId, {
+      basePatchNum: PARENT,
+      patchNum: 1 as RevisionPatchSetNum,
+    });
+    assert.isTrue(getChangeEditFilesStub.calledOnce);
+    assert.isTrue(getChangeFilesStub.calledOnce);
+  });
+
+  test('_fetch forwards request and logs', async () => {
+    const logStub = sinon.stub(element._restApiHelper, '_logCall');
+    const response = new Response(undefined, {status: 404});
+    const url = 'my url';
+    const fetchOptions = {method: 'DELETE'};
+    sinon.stub(authService, 'fetch').resolves(response);
+    const startTime = 123;
+    sinon.stub(Date, 'now').returns(startTime);
+    const req = {url, fetchOptions};
+    await element._restApiHelper.fetch(req);
+    assert.isTrue(logStub.calledOnce);
+    assert.isTrue(logStub.calledWith(req, startTime, response.status));
+  });
+
+  test('_logCall only reports requests with anonymized URLss', async () => {
+    sinon.stub(Date, 'now').returns(200);
+    const handler = sinon.stub();
+    addListenerForTest(document, 'gr-rpc-log', handler);
+
+    element._restApiHelper._logCall({url: 'url'}, 100, 200);
+    assert.isFalse(handler.called);
+
+    element._restApiHelper._logCall(
+      {url: 'url', anonymizedUrl: 'not url'},
+      100,
+      200
+    );
+    await waitEventLoop();
+    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').resolves({
+      ok: false,
+    } as unknown as ParsedJSON);
+
+    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').resolves(false);
+    const getChangeURLAndFetchStub = sinon.stub(
+      element,
+      '_getChangeURLAndFetch'
+    );
+
+    element.getPortedDrafts(change._number, CURRENT);
+
+    assert.isFalse(getChangeURLAndFetchStub.called);
+  });
+
+  test('saveChangeStarred', async () => {
+    sinon.stub(element, 'getFromProjectLookup').resolves('test' as RepoName);
+    const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+
+    await element.saveChangeStarred(123 as NumericChangeId, true);
+    assert.isTrue(sendStub.calledOnce);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: HttpMethod.PUT,
+      url: '/accounts/self/starred.changes/test~123',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+
+    await element.saveChangeStarred(456 as NumericChangeId, false);
+    assert.isTrue(sendStub.calledTwice);
+    assert.deepEqual(sendStub.lastCall.args[0], {
+      method: HttpMethod.DELETE,
+      url: '/accounts/self/starred.changes/test~456',
+      anonymizedUrl: '/accounts/self/starred.changes/*',
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 1378211..b4b1afb 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {HttpMethod} from '../../constants/constants';
+import {Finalizable} from '../registry';
 import {
   AccountCapabilityInfo,
   AccountDetailInfo,
@@ -52,6 +41,7 @@
   FileNameToFileInfoMap,
   FilePathToDiffInfoMap,
   FixId,
+  FixReplacementInfo,
   GitRef,
   GpgKeyId,
   GpgKeyInfo,
@@ -80,7 +70,7 @@
   PreferencesInfo,
   PreferencesInput,
   ProjectAccessInfo,
-  ProjectAccessInfoMap,
+  RepoAccessInfoMap,
   ProjectAccessInput,
   ProjectInfo,
   ProjectInfoWithName,
@@ -100,6 +90,7 @@
   TagInput,
   TopMenuEntryInfo,
   UrlEncodedCommentId,
+  UserId,
 } from '../../types/common';
 import {
   DiffInfo,
@@ -108,17 +99,10 @@
 } from '../../types/diff';
 import {ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
+import {DraftInfo} from '../../utils/comment-util';
 
 export type CancelConditionCallback = () => boolean;
 
-// TODO(TS): remove when GrReplyDialog converted to typescript
-export interface GrReplyDialog {
-  getLabelValue(label: string): string;
-  setLabelValue(label: string, value: string): void;
-  send(includeComments?: boolean, startReview?: boolean): Promise<unknown>;
-  setPluginMessage(message: string): void;
-}
-
 export interface GetDiffCommentsOutput {
   baseComments: CommentInfo[];
   comments: CommentInfo[];
@@ -129,7 +113,7 @@
   comments: RobotCommentInfo[];
 }
 
-export interface RestApiService {
+export interface RestApiService extends Finalizable {
   getConfig(noCache?: boolean): Promise<ServerInfo | undefined>;
   getLoggedIn(): Promise<boolean>;
   getPreferences(): Promise<PreferencesInfo | undefined>;
@@ -143,7 +127,8 @@
   getRepos(
     filter: string | undefined,
     reposPerPage: number,
-    offset?: number
+    offset?: number,
+    errFn?: ErrorCallback
   ): Promise<ProjectInfoWithName[] | undefined>;
 
   send(
@@ -168,20 +153,34 @@
 
   getChangeSuggestedReviewers(
     changeNum: NumericChangeId,
-    input: string
+    input: string,
+    errFn?: ErrorCallback
   ): Promise<SuggestedReviewerInfo[] | undefined>;
   getChangeSuggestedCCs(
     changeNum: NumericChangeId,
-    input: string
+    input: string,
+    errFn?: ErrorCallback
   ): Promise<SuggestedReviewerInfo[] | undefined>;
+  /**
+   * Request list of accounts via https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-account
+   * Operators defined here https://gerrit-review.googlesource.com/Documentation/user-search-accounts.html#_search_operators
+   */
   getSuggestedAccounts(
     input: string,
-    n?: number
+    n?: number,
+    canSee?: NumericChangeId,
+    filterActive?: boolean,
+    errFn?: ErrorCallback
   ): Promise<AccountInfo[] | undefined>;
   getSuggestedGroups(
     input: string,
-    n?: number
+    project?: RepoName,
+    n?: number,
+    errFn?: ErrorCallback
   ): Promise<GroupNameToGroupInfoMap | undefined>;
+  /**
+   * Execute a change action or revision action on a change.
+   */
   executeChangeAction(
     changeNum: NumericChangeId,
     method: HttpMethod | undefined,
@@ -199,11 +198,14 @@
   ): Promise<BranchInfo[] | undefined>;
 
   getChangeDetail(
-    changeNum: number | string,
+    changeNum?: number | string,
     opt_errFn?: ErrorCallback,
     opt_cancelCondition?: Function
-  ): Promise<ParsedChangeInfo | null | undefined>;
+  ): Promise<ParsedChangeInfo | undefined>;
 
+  /**
+   * Given a changeNum, gets the change.
+   */
   getChange(
     changeNum: ChangeId | NumericChangeId,
     errFn?: ErrorCallback
@@ -270,7 +272,8 @@
   queryChangeFiles(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
-    query: string
+    query: string,
+    errFn?: ErrorCallback
   ): Promise<string[] | undefined>;
 
   getRepoAccessRights(
@@ -290,7 +293,7 @@
     errFn?: ErrorCallback
   ): Promise<DashboardInfo[] | undefined>;
 
-  getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined>;
+  getRepoAccess(repo: RepoName): Promise<RepoAccessInfoMap | undefined>;
 
   getProjectConfig(
     repo: RepoName,
@@ -324,7 +327,7 @@
 
   getIsAdmin(): Promise<boolean | undefined>;
 
-  getIsGroupOwner(groupName: GroupName): Promise<boolean>;
+  getIsGroupOwner(groupName?: GroupName): Promise<boolean>;
 
   saveGroupName(
     groupId: GroupId | GroupName,
@@ -364,10 +367,7 @@
     errFn?: ErrorCallback
   ): Promise<Response>;
 
-  getChangeEdit(
-    changeNum: NumericChangeId,
-    downloadCommands?: boolean
-  ): Promise<false | EditInfo | undefined>;
+  getChangeEdit(changeNum?: NumericChangeId): Promise<EditInfo | undefined>;
 
   getChangeActionURL(
     changeNum: NumericChangeId,
@@ -376,7 +376,7 @@
   ): Promise<string>;
 
   createChange(
-    project: RepoName,
+    repo: RepoName,
     branch: BranchName,
     subject: string,
     topic?: string,
@@ -390,6 +390,12 @@
     changeNum: NumericChangeId
   ): Promise<IncludedInInfo | undefined>;
 
+  /**
+   * Checks in projectLookup map shared across instances for the changeNum.
+   * If it exists, returns the project. If not, calls the restAPI to get the
+   * change, populates projectLookup with the project for that change, and
+   * returns the project.
+   */
   getFromProjectLookup(
     changeNum: NumericChangeId
   ): Promise<RepoName | undefined>;
@@ -400,10 +406,6 @@
     draft: CommentInput
   ): Promise<Response>;
 
-  getDiffChangeDetail(
-    changeNum: NumericChangeId
-  ): Promise<ChangeInfo | undefined | null>;
-
   getPortedComments(
     changeNum: NumericChangeId,
     revision: RevisionId
@@ -450,23 +452,14 @@
     | Promise<GetDiffRobotCommentsOutput>
     | Promise<PathToRobotCommentsInfoMap | undefined>;
 
+  /**
+   * If the user is logged in, fetch the user's draft diff comments. If there
+   * is no logged in user, the request is not made and the promise yields an
+   * empty object.
+   */
   getDiffDrafts(
     changeNum: NumericChangeId
-  ): Promise<PathToCommentsInfoMap | undefined>;
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum: PatchSetNum,
-    patchNum: PatchSetNum,
-    path: string
-  ): Promise<GetDiffCommentsOutput>;
-  getDiffDrafts(
-    changeNum: NumericChangeId,
-    basePatchNum?: BasePatchSetNum,
-    patchNum?: PatchSetNum,
-    path?: string
-  ):
-    | Promise<GetDiffCommentsOutput>
-    | Promise<PathToCommentsInfoMap | undefined>;
+  ): Promise<{[path: string]: DraftInfo[]} | undefined>;
 
   createGroup(config: GroupInput & {name: string}): Promise<Response>;
 
@@ -477,38 +470,37 @@
     errFn?: ErrorCallback
   ): Promise<{[pluginName: string]: PluginInfo} | undefined>;
 
+  getDetailedChangesWithActions(
+    changeNums: NumericChangeId[]
+  ): Promise<ChangeInfo[] | undefined>;
+
   getChanges(
     changesPerPage?: number,
     query?: string,
     offset?: 'n,z' | number,
-    options?: string
+    options?: string,
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo[] | undefined>;
-  getChanges(
+  getChangesForMultipleQueries(
     changesPerPage?: number,
     query?: string[],
     offset?: 'n,z' | number,
     options?: string
   ): Promise<ChangeInfo[][] | undefined>;
-  /**
-   * @return If opt_query is an
-   * array, _fetchJSON will return an array of arrays of changeInfos. If it
-   * is unspecified or a string, _fetchJSON will return an array of
-   * changeInfos.
-   */
-  getChanges(
-    changesPerPage?: number,
-    query?: string | string[],
-    offset?: 'n,z' | number,
-    options?: string
-  ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined>;
 
   getDocumentationSearches(filter: string): Promise<DocResult[] | undefined>;
 
   getAccountAgreements(): Promise<ContributorAgreementInfo[] | undefined>;
 
+  /**
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#list-groups
+   */
   getAccountGroups(): Promise<GroupInfo[] | undefined>;
 
-  getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined>;
+  getAccountDetails(
+    userId: UserId,
+    errFn?: ErrorCallback
+  ): Promise<AccountDetailInfo | undefined>;
 
   getAccountStatus(userId: AccountId): Promise<string | undefined>;
 
@@ -528,9 +520,10 @@
 
   deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response>;
 
-  getSuggestedProjects(
+  getSuggestedRepos(
     inputVal: string,
-    n?: number
+    n?: number,
+    errFn?: ErrorCallback
   ): Promise<NameToProjectInfoMap | undefined>;
 
   invalidateGroupsCache(): void;
@@ -646,7 +639,8 @@
   ): Promise<RelatedChangesInfo | undefined>;
 
   getChangesSubmittedTogether(
-    changeNum: NumericChangeId
+    changeNum: NumericChangeId,
+    options?: string[]
   ): Promise<SubmittedTogetherInfo | undefined>;
 
   getChangeConflicts(
@@ -654,32 +648,85 @@
   ): Promise<ChangeInfo[] | undefined>;
 
   getChangeCherryPicks(
-    project: RepoName,
+    repo: RepoName,
     changeID: ChangeId,
     changeNum: NumericChangeId
   ): Promise<ChangeInfo[] | undefined>;
 
   getChangesWithSameTopic(
     topic: string,
-    changeNum: NumericChangeId
+    options?: {
+      openChangesOnly?: boolean;
+      changeToExclude?: NumericChangeId;
+    }
   ): Promise<ChangeInfo[] | undefined>;
-  getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarTopic(
+    topic: string,
+    errFn?: ErrorCallback
+  ): Promise<ChangeInfo[] | undefined>;
+  getChangesWithSimilarHashtag(
+    hashtag: string,
+    errFn?: ErrorCallback
+  ): Promise<ChangeInfo[] | undefined>;
 
+  /**
+   * @return Whether there are pending diff draft sends.
+   */
   hasPendingDiffDrafts(): number;
+  /**
+   * @return A promise that resolves when all pending
+   * diff draft sends have resolved.
+   */
   awaitPendingDiffDrafts(): Promise<void>;
 
+  /**
+   * Preview Stored Fix
+   * Gets the diffs of all files for a certain {fix-id} associated with apply fix.
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#preview-stored-fix
+   */
   getRobotCommentFixPreview(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     fixId: FixId
   ): Promise<FilePathToDiffInfoMap | undefined>;
 
+  /**
+   * Preview Provided fix
+   * Gets the diffs of all files for a provided fix replacements infos
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#preview-provided-fix
+   */
+  getFixPreview(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    fixReplacementInfos: FixReplacementInfo[]
+  ): Promise<FilePathToDiffInfoMap | undefined>;
+
+  /**
+   * Apply Provided Fix
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#apply-provided-fix
+   */
   applyFixSuggestion(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
+    fixReplacementInfos: FixReplacementInfo[]
+  ): Promise<Response>;
+
+  /**
+   * Apply Stored Fix
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#apply-stored-fix
+   */
+  applyRobotFixSuggestion(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
     fixId: string
   ): Promise<Response>;
 
+  /**
+   * @param basePatchNum Negative values specify merge parent
+   * index.
+   * @param whitespace the ignore-whitespace level for the diff
+   * algorithm.
+   */
   getDiff(
     changeNum: NumericChangeId,
     basePatchNum: PatchSetNum,
@@ -689,6 +736,12 @@
     errFn?: ErrorCallback
   ): Promise<DiffInfo | undefined>;
 
+  /**
+   * Get blame information for the given diff.
+   *
+   * @param base If true, requests blame for the base of the
+   *     diff, rather than the revision.
+   */
   getBlame(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
@@ -718,21 +771,18 @@
     starred: boolean
   ): Promise<Response>;
 
+  /**
+   * Fetch a project dashboard definition.
+   * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
+   */
   getDashboard(
-    project: RepoName,
+    repo: RepoName,
     dashboard: DashboardId,
     errFn?: ErrorCallback
   ): Promise<DashboardInfo | undefined>;
 
   deleteDraftComments(query: string): Promise<Response>;
 
-  setAssignee(
-    changeNum: NumericChangeId,
-    assignee: AccountId
-  ): Promise<Response>;
-
-  deleteAssignee(changeNum: NumericChangeId): Promise<Response>;
-
   setChangeHashtag(
     changeNum: NumericChangeId,
     hashtag: HashtagsInput
@@ -764,7 +814,7 @@
 
   getTopMenus(): Promise<TopMenuEntryInfo[] | undefined>;
 
-  setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
+  setInProjectLookup(changeNum: NumericChangeId, repo: RepoName): void;
   getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
 
   putChangeCommitMessage(
diff --git a/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
new file mode 100644
index 0000000..842dace
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -0,0 +1,166 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  getAccountDisplayName,
+  getGroupDisplayName,
+} from '../../utils/display-name-util';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {
+  AccountInfo,
+  isReviewerAccountSuggestion,
+  isReviewerGroupSuggestion,
+  NumericChangeId,
+  ServerInfo,
+  SuggestedReviewerInfo,
+  Suggestion,
+} from '../../types/common';
+import {assertNever} from '../../utils/common-util';
+import {AutocompleteSuggestion} from '../../elements/shared/gr-autocomplete/gr-autocomplete';
+import {allSettled, isFulfilled} from '../../utils/async-util';
+import {isDefined, ParsedChangeInfo} from '../../types/types';
+import {accountKey} from '../../utils/account-util';
+import {
+  AccountId,
+  ChangeInfo,
+  EmailAddress,
+  GroupId,
+  ReviewerState,
+} from '../../api/rest-api';
+import {throwingErrorCallback} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+
+export interface ReviewerSuggestionsProvider {
+  getSuggestions(input: string): Promise<Suggestion[]>;
+  makeSuggestionItem(
+    suggestion: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo>;
+}
+
+export class GrReviewerSuggestionsProvider
+  implements ReviewerSuggestionsProvider
+{
+  private changes: (ChangeInfo | ParsedChangeInfo)[];
+
+  constructor(
+    private restApi: RestApiService,
+    private type: ReviewerState.REVIEWER | ReviewerState.CC,
+    private config: ServerInfo | undefined,
+    private loggedIn: boolean,
+    ...changes: (ChangeInfo | ParsedChangeInfo)[]
+  ) {
+    this.changes = changes;
+  }
+
+  /**
+   * Requests related suggestions.
+   *
+   * If the request fails the returned promise is rejected.
+   */
+  async getSuggestions(input: string): Promise<Suggestion[]> {
+    if (!this.loggedIn) return [];
+
+    const resultsByChangeIndex = await allSettled(
+      this.changes.map(change =>
+        this.getSuggestionsForChange(change._number, input)
+      )
+    );
+    const suggestionsByChangeIndex = resultsByChangeIndex
+      .filter(isFulfilled)
+      .map(result => result.value)
+      .filter(isDefined);
+    if (suggestionsByChangeIndex.length !== resultsByChangeIndex.length) {
+      // one of the requests failed, so don't allow any suggestions.
+      return [];
+    }
+
+    // Pass the union of all the suggestions through each change, keeping only
+    // suggestions where either:
+    //   A) the change had the suggestion too, or
+    //   B) the suggestion is already a reviewer/CC on the change (depending on
+    //      this.type).
+    return this.changes.reduce((suggestions, change, changeIndex) => {
+      const reviewerAndSuggestionKeys = new Set<
+        AccountId | EmailAddress | GroupId | undefined
+      >([
+        ...(change.reviewers[this.type]?.map(accountKey) ?? []),
+        ...suggestionsByChangeIndex[changeIndex].map(suggestionKey),
+      ]);
+      return suggestions.filter(suggestion =>
+        reviewerAndSuggestionKeys.has(suggestionKey(suggestion))
+      );
+    }, uniqueSuggestions(suggestionsByChangeIndex.flat()));
+  }
+
+  makeSuggestionItem(
+    suggestion: Suggestion
+  ): AutocompleteSuggestion<SuggestedReviewerInfo> {
+    if (isReviewerAccountSuggestion(suggestion)) {
+      // Reviewer is an account suggestion from getChangeSuggestedReviewers.
+      return {
+        name: getAccountDisplayName(this.config, suggestion.account),
+        value: suggestion,
+      };
+    }
+
+    if (isReviewerGroupSuggestion(suggestion)) {
+      // Reviewer is a group suggestion from getChangeSuggestedReviewers.
+      return {
+        name: getGroupDisplayName(suggestion.group),
+        value: suggestion,
+      };
+    }
+
+    if (isAccountSuggestion(suggestion)) {
+      // Reviewer is an account suggestion from getSuggestedAccounts.
+      return {
+        name: getAccountDisplayName(this.config, suggestion),
+        value: {account: suggestion, count: 1},
+      };
+    }
+    assertNever(suggestion, 'Received an incorrect suggestion');
+  }
+
+  private getSuggestionsForChange(
+    changeNumber: NumericChangeId,
+    input: string
+  ): Promise<SuggestedReviewerInfo[] | undefined> {
+    return this.type === ReviewerState.REVIEWER
+      ? this.restApi.getChangeSuggestedReviewers(
+          changeNumber,
+          input,
+          throwingErrorCallback
+        )
+      : this.restApi.getChangeSuggestedCCs(
+          changeNumber,
+          input,
+          throwingErrorCallback
+        );
+  }
+}
+
+function uniqueSuggestions(suggestions: Suggestion[]): Suggestion[] {
+  return suggestions.filter(
+    (suggestion, index) =>
+      index ===
+      suggestions.findIndex(
+        other => suggestionKey(suggestion) === suggestionKey(other)
+      )
+  );
+}
+
+function suggestionKey(suggestion: Suggestion) {
+  if (isReviewerAccountSuggestion(suggestion)) {
+    return accountKey(suggestion.account);
+  } else if (isReviewerGroupSuggestion(suggestion)) {
+    return suggestion.group.id;
+  } else if (isAccountSuggestion(suggestion)) {
+    return accountKey(suggestion);
+  }
+  return undefined;
+}
+
+function isAccountSuggestion(s: Suggestion): s is AccountInfo {
+  return (s as AccountInfo)._account_id !== undefined;
+}
diff --git a/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
new file mode 100644
index 0000000..e96a2ad
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
@@ -0,0 +1,231 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {GrReviewerSuggestionsProvider} from './gr-reviewer-suggestions-provider';
+import {getAppContext} from '../app-context';
+import {stubRestApi} from '../../test/test-utils';
+import {
+  AccountDetailInfo,
+  ChangeInfo,
+  GroupId,
+  GroupName,
+  NumericChangeId,
+  ReviewerState,
+} from '../../api/rest-api';
+import {Suggestion} from '../../types/common';
+import {
+  createAccountDetailWithIdNameAndEmail,
+  createChange,
+  createServerInfo,
+} from '../../test/test-data-generators';
+import {assert} from '@open-wc/testing';
+
+const accounts: AccountDetailInfo[] = [
+  createAccountDetailWithIdNameAndEmail(1),
+  createAccountDetailWithIdNameAndEmail(2),
+  createAccountDetailWithIdNameAndEmail(3),
+  createAccountDetailWithIdNameAndEmail(4),
+  createAccountDetailWithIdNameAndEmail(5),
+];
+const suggestions: Suggestion[] = [
+  {account: accounts[0], count: 1},
+  {account: accounts[1], count: 1},
+  {
+    group: {
+      id: 'suggested group id' as GroupId,
+      name: 'suggested group' as GroupName,
+    },
+    count: 4,
+  },
+  {account: accounts[2], count: 1},
+];
+const changes: ChangeInfo[] = [
+  {...createChange(), reviewers: {REVIEWER: [accounts[2]], CC: [accounts[2]]}},
+  {...createChange(), _number: 43 as NumericChangeId},
+];
+
+suite('GrReviewerSuggestionsProvider tests', () => {
+  let getChangeSuggestedReviewersStub: sinon.SinonStub;
+  let getChangeSuggestedCCsStub: sinon.SinonStub;
+  let provider: GrReviewerSuggestionsProvider;
+
+  setup(() => {
+    getChangeSuggestedReviewersStub = stubRestApi(
+      'getChangeSuggestedReviewers'
+    );
+    getChangeSuggestedCCsStub = stubRestApi('getChangeSuggestedCCs');
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      true,
+      ...changes
+    );
+  });
+
+  test('getSuggestions', async () => {
+    getChangeSuggestedReviewersStub.resolves([
+      suggestions[0],
+      suggestions[1],
+      suggestions[2],
+    ]);
+    const reviewers = await provider.getSuggestions('');
+
+    assert.sameDeepMembers(reviewers, [
+      suggestions[0],
+      suggestions[1],
+      suggestions[2],
+    ]);
+  });
+
+  test('getSuggestions short circuits when logged out', async () => {
+    // logged in
+    getChangeSuggestedReviewersStub.resolves([]);
+    await provider.getSuggestions('');
+    assert.isTrue(getChangeSuggestedReviewersStub.calledTwice);
+
+    // not logged in
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      false,
+      ...changes
+    );
+
+    await provider.getSuggestions('');
+
+    // no additional calls are made
+    assert.isTrue(getChangeSuggestedReviewersStub.calledTwice);
+  });
+
+  test('only returns REVIEWER suggestions shared by all changes', async () => {
+    getChangeSuggestedReviewersStub
+      .onFirstCall()
+      .resolves([suggestions[0], suggestions[1], suggestions[2]])
+      .onSecondCall()
+      .resolves([suggestions[1], suggestions[2], suggestions[3]]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      createServerInfo(),
+      true,
+      ...changes
+    );
+
+    // suggestions[0] is excluded because it is not returned for the second
+    // change.
+    // suggestions[3] is included because the first change has the suggestion
+    // as a reviewer already.
+    assert.sameDeepMembers(await provider.getSuggestions('s'), [
+      suggestions[1],
+      suggestions[2],
+      suggestions[3],
+    ]);
+  });
+
+  test('only returns CC suggestions shared by all changes', async () => {
+    getChangeSuggestedCCsStub
+      .onFirstCall()
+      .resolves([suggestions[0], suggestions[1], suggestions[2]])
+      .onSecondCall()
+      .resolves([suggestions[1], suggestions[2], suggestions[3]]);
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.CC,
+      createServerInfo(),
+      true,
+      ...changes
+    );
+
+    // suggestions[0] is excluded because it is not returned for the second
+    // change.
+    // suggestions[3] is included because the first change has the suggestion
+    // as a CC already.
+    assert.sameDeepMembers(await provider.getSuggestions('s'), [
+      suggestions[1],
+      suggestions[2],
+      suggestions[3],
+    ]);
+  });
+
+  test('makeSuggestionItem formats account or group accordingly', () => {
+    let suggestion = provider.makeSuggestionItem({
+      account: accounts[0],
+      count: 1,
+    });
+    assert.deepEqual(suggestion, {
+      name: `${accounts[0].name} <${accounts[0].email}>`,
+      value: {account: accounts[0], count: 1},
+    });
+
+    const group = {name: 'test' as GroupName, id: '5' as GroupId};
+    suggestion = provider.makeSuggestionItem({group, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${group.name} (group)`,
+      value: {group, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem(accounts[0]);
+    assert.deepEqual(suggestion, {
+      name: `${accounts[0].name} <${accounts[0].email}>`,
+      value: {account: accounts[0], count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+    assert.deepEqual(suggestion, {
+      name: 'Name of user not set',
+      value: {account: {}, count: 1},
+    });
+
+    provider = new GrReviewerSuggestionsProvider(
+      getAppContext().restApiService,
+      ReviewerState.REVIEWER,
+      {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'Anonymous Coward Name',
+        },
+      },
+      true,
+      ...changes
+    );
+
+    suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+    assert.deepEqual(suggestion, {
+      name: 'Anonymous Coward Name',
+      value: {account: {}, count: 1},
+    });
+
+    const oooAccount = {
+      ...createAccountDetailWithIdNameAndEmail(3),
+      status: 'OOO',
+    };
+
+    suggestion = provider.makeSuggestionItem({account: oooAccount, count: 1});
+    assert.deepEqual(suggestion, {
+      name: `${oooAccount.name} <${oooAccount.email}> (OOO)`,
+      value: {account: oooAccount, count: 1},
+    });
+
+    suggestion = provider.makeSuggestionItem(oooAccount);
+    assert.deepEqual(suggestion, {
+      name: `${oooAccount.name} <${oooAccount.email}> (OOO)`,
+      value: {account: oooAccount, count: 1},
+    });
+
+    const accountWithoutEmail = {
+      ...createAccountDetailWithIdNameAndEmail(3),
+      email: undefined,
+    };
+
+    suggestion = provider.makeSuggestionItem(accountWithoutEmail);
+    assert.deepEqual(suggestion, {
+      name: accountWithoutEmail.name,
+      value: {account: accountWithoutEmail, count: 1},
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/highlight/highlight-service-mock.ts b/polygerrit-ui/app/services/highlight/highlight-service-mock.ts
new file mode 100644
index 0000000..f65fbf5
--- /dev/null
+++ b/polygerrit-ui/app/services/highlight/highlight-service-mock.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  SyntaxWorkerMessage,
+  SyntaxWorkerResult,
+} from '../../types/syntax-worker-api';
+import {HighlightService} from './highlight-service';
+
+class FakeWorker implements Worker {
+  messages: SyntaxWorkerMessage[] = [];
+
+  constructor(private readonly autoRespond = true) {}
+
+  postMessage(message: SyntaxWorkerMessage) {
+    this.messages.push(message);
+    if (this.autoRespond) this.sendResult({ranges: []});
+  }
+
+  sendResult(result: SyntaxWorkerResult) {
+    if (this.onmessage)
+      this.onmessage({data: result} as MessageEvent<SyntaxWorkerResult>);
+  }
+
+  onmessage: ((e: MessageEvent<SyntaxWorkerResult>) => void) | null = null;
+
+  onmessageerror = null;
+
+  onerror = null;
+
+  terminate(): void {}
+
+  addEventListener(): void {}
+
+  removeEventListener(): void {}
+
+  dispatchEvent(): boolean {
+    return true;
+  }
+}
+
+export class MockHighlightService extends HighlightService {
+  idle = this.poolIdle as Set<FakeWorker>;
+
+  busy = this.poolBusy as Set<FakeWorker>;
+
+  override createWorker(): Worker {
+    return new FakeWorker();
+  }
+
+  countAllMessages() {
+    let count = 0;
+    for (const worker of [...this.idle, ...this.busy]) {
+      count += worker.messages.length;
+    }
+    return count;
+  }
+
+  sendToAll(result: SyntaxWorkerResult) {
+    for (const worker of this.busy) {
+      worker.sendResult(result);
+    }
+  }
+}
+
+export class MockHighlightServiceManual extends MockHighlightService {
+  override createWorker(): Worker {
+    return new FakeWorker(false);
+  }
+}
diff --git a/polygerrit-ui/app/services/highlight/highlight-service.ts b/polygerrit-ui/app/services/highlight/highlight-service.ts
new file mode 100644
index 0000000..d10d875
--- /dev/null
+++ b/polygerrit-ui/app/services/highlight/highlight-service.ts
@@ -0,0 +1,165 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {define} from '../../models/dependency';
+import {
+  SyntaxWorkerRequest,
+  SyntaxWorkerInit,
+  SyntaxWorkerResult,
+  SyntaxWorkerMessageType,
+  SyntaxLayerLine,
+} from '../../types/syntax-worker-api';
+import {prependOrigin} from '../../utils/url-util';
+import {createWorker} from '../../utils/worker-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
+import {Finalizable} from '../registry';
+
+const hljsLibUrl = `${
+  window.STATIC_RESOURCE_PATH ?? ''
+}/bower_components/highlightjs/highlight.min.js`;
+
+const syntaxWorkerUrl = `${
+  window.STATIC_RESOURCE_PATH ?? ''
+}/workers/syntax-worker.js`;
+
+/**
+ * It is unlikely that a pool size greater than 3 will gain anything, because
+ * the app also needs the resources to process the results.
+ */
+const WORKER_POOL_SIZE = 3;
+
+/**
+ * Safe guard for not killing the browser.
+ */
+export const CODE_MAX_LINES = 20 * 1000;
+
+/**
+ * Safe guard for not killing the browser. Maximum in number of chars.
+ */
+const CODE_MAX_LENGTH = 25 * CODE_MAX_LINES;
+
+export const highlightServiceToken =
+  define<HighlightService>('highlight-service');
+/**
+ * Service for syntax highlighting. Maintains some HighlightJS workers doing
+ * their job in the background.
+ */
+export class HighlightService implements Finalizable {
+  // visible for testing
+  poolIdle: Set<Worker> = new Set();
+
+  // visible for testing
+  poolBusy: Set<Worker> = new Set();
+
+  // visible for testing
+  /** Queue for waiting that a worker becomes available. */
+  queueForWorker: Array<() => void> = [];
+
+  // visible for testing
+  /** Queue for waiting on the results of a worker. */
+  queueForResult: Map<Worker, (r: SyntaxLayerLine[]) => void> = new Map();
+
+  constructor(readonly reporting: ReportingService) {
+    for (let i = 0; i < WORKER_POOL_SIZE; i++) {
+      this.addWorker();
+    }
+  }
+
+  /** Allows tests to produce fake workers. */
+  protected createWorker() {
+    return createWorker(prependOrigin(syntaxWorkerUrl));
+  }
+
+  /** Creates, initializes and then moves a worker to the idle pool. */
+  private addWorker() {
+    const worker = this.createWorker();
+    // Will move to the idle pool after being initialized.
+    this.poolBusy.add(worker);
+    worker.onmessage = (e: MessageEvent<SyntaxWorkerResult>) => {
+      this.handleResult(worker, e.data);
+    };
+    const initMsg: SyntaxWorkerInit = {
+      type: SyntaxWorkerMessageType.INIT,
+      url: prependOrigin(hljsLibUrl),
+    };
+    worker.postMessage(initMsg);
+  }
+
+  private moveIdleToBusy() {
+    const worker = this.poolIdle.values().next().value;
+    this.poolIdle.delete(worker);
+    this.poolBusy.add(worker);
+    return worker;
+  }
+
+  private moveBusyToIdle(worker: Worker) {
+    this.poolBusy.delete(worker);
+    this.poolIdle.add(worker);
+    const resolver = this.queueForWorker.shift();
+    if (resolver) resolver();
+  }
+
+  /**
+   * If there is worker in the idle pool, then return it. Otherwise wait for a
+   * worker to become a available.
+   */
+  private async requestWorker(): Promise<Worker> {
+    if (this.poolIdle.size > 0) {
+      const worker = this.moveIdleToBusy();
+      return Promise.resolve(worker);
+    }
+    await new Promise<void>(r => this.queueForWorker.push(r));
+    return this.requestWorker();
+  }
+
+  /**
+   * A worker is done with its job. Move it back to the idle pool and notify the
+   * resolver that is waiting for the results.
+   */
+  private handleResult(worker: Worker, result: SyntaxWorkerResult) {
+    this.moveBusyToIdle(worker);
+    if (result.error) {
+      this.reporting.error(
+        'Diff Syntax Layer',
+        new Error(`syntax worker failed: ${result.error}`)
+      );
+    }
+    const resolver = this.queueForResult.get(worker);
+    this.queueForResult.delete(worker);
+    if (resolver) resolver(result.ranges ?? []);
+  }
+
+  async highlight(
+    language?: string,
+    code?: string
+  ): Promise<SyntaxLayerLine[]> {
+    if (!language || !code) return [];
+    if (code.length > CODE_MAX_LENGTH) return [];
+    const worker = await this.requestWorker();
+    const message: SyntaxWorkerRequest = {
+      type: SyntaxWorkerMessageType.REQUEST,
+      language,
+      code,
+    };
+    const promise = new Promise<SyntaxLayerLine[]>(r => {
+      this.queueForResult.set(worker, r);
+    });
+    worker.postMessage(message);
+    return await promise;
+  }
+
+  finalize() {
+    for (const worker of this.poolIdle) {
+      worker.terminate();
+    }
+    this.poolIdle.clear();
+    for (const worker of this.poolBusy) {
+      worker.terminate();
+    }
+    this.poolBusy.clear();
+    this.queueForResult.clear();
+    this.queueForWorker.length = 0;
+  }
+}
diff --git a/polygerrit-ui/app/services/highlight/highlight-service_test.ts b/polygerrit-ui/app/services/highlight/highlight-service_test.ts
new file mode 100644
index 0000000..61d5fb1
--- /dev/null
+++ b/polygerrit-ui/app/services/highlight/highlight-service_test.ts
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
+import {waitUntil} from '../../test/test-utils';
+import {grReportingMock} from '../gr-reporting/gr-reporting_mock';
+import {MockHighlightServiceManual} from './highlight-service-mock';
+
+suite('highlight-service tests', () => {
+  let service: MockHighlightServiceManual;
+
+  setup(() => {
+    service = new MockHighlightServiceManual(grReportingMock);
+  });
+
+  test('initial state', () => {
+    assert.equal(service.poolBusy.size, 3);
+    assert.equal(service.poolIdle.size, 0);
+    assert.equal(service.queueForResult.size, 0);
+    assert.equal(service.queueForWorker.length, 0);
+  });
+
+  test('initialized workers move to idle pool', () => {
+    service.sendToAll({});
+    assert.equal(service.countAllMessages(), 3);
+
+    assert.equal(service.poolBusy.size, 0);
+    assert.equal(service.poolIdle.size, 3);
+  });
+
+  test('highlight 1', async () => {
+    service.sendToAll({});
+    assert.equal(service.countAllMessages(), 3);
+
+    const p = service.highlight('asdf', 'qwer');
+    assert.equal(service.poolBusy.size, 1);
+    assert.equal(service.poolIdle.size, 2);
+    await waitUntil(() => service.queueForResult.size > 0);
+    assert.equal(service.queueForResult.size, 1);
+    assert.equal(service.queueForWorker.length, 0);
+
+    service.sendToAll({ranges: []});
+    assert.equal(service.countAllMessages(), 4);
+    const ranges = await p;
+    assert.equal(ranges.length, 0);
+
+    await waitUntil(() => service.queueForResult.size === 0);
+    assert.equal(service.poolBusy.size, 0);
+    assert.equal(service.poolIdle.size, 3);
+    assert.equal(service.queueForResult.size, 0);
+    assert.equal(service.queueForWorker.length, 0);
+  });
+
+  test('highlight 5', async () => {
+    service.sendToAll({});
+    assert.equal(service.countAllMessages(), 3);
+
+    const p1 = service.highlight('asdf1', 'qwer1');
+    const p2 = service.highlight('asdf2', 'qwer2');
+    const p3 = service.highlight('asdf3', 'qwer3');
+    const p4 = service.highlight('asdf4', 'qwer4');
+    const p5 = service.highlight('asdf5', 'qwer5');
+
+    assert.equal(service.poolBusy.size, 3);
+    assert.equal(service.poolIdle.size, 0);
+    await waitUntil(() => service.queueForResult.size > 0);
+    assert.equal(service.queueForResult.size, 3);
+    assert.equal(service.queueForWorker.length, 2);
+
+    service.sendToAll({ranges: []});
+    assert.equal(service.countAllMessages(), 6);
+    const ranges1 = await p1;
+    const ranges2 = await p2;
+    const ranges3 = await p3;
+    assert.equal(ranges1.length, 0);
+    assert.equal(ranges2.length, 0);
+    assert.equal(ranges3.length, 0);
+
+    await waitUntil(() => service.queueForResult.size === 2);
+    assert.equal(service.poolBusy.size, 2);
+    assert.equal(service.poolIdle.size, 1);
+    assert.equal(service.queueForResult.size, 2);
+    assert.equal(service.queueForWorker.length, 0);
+
+    service.sendToAll({ranges: []});
+    assert.equal(service.countAllMessages(), 8);
+    const ranges4 = await p4;
+    const ranges5 = await p5;
+    assert.equal(ranges4.length, 0);
+    assert.equal(ranges5.length, 0);
+
+    await waitUntil(() => service.queueForResult.size === 0);
+    assert.equal(service.poolBusy.size, 0);
+    assert.equal(service.poolIdle.size, 3);
+    assert.equal(service.queueForResult.size, 0);
+    assert.equal(service.queueForWorker.length, 0);
+  });
+});
diff --git a/polygerrit-ui/app/services/registry.ts b/polygerrit-ui/app/services/registry.ts
new file mode 100644
index 0000000..48a5241
--- /dev/null
+++ b/polygerrit-ui/app/services/registry.ts
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// A finalizable object has a single method `finalize` that is called when
+// the object is no longer needed and should clean itself up.
+export interface Finalizable {
+  finalize(): void;
+}
+
+// A factory can take a partially created TContext and generate a property
+// for a given key on that TContext.
+export type Factory<TContext, K extends keyof TContext> = (
+  ctx: Partial<TContext>
+) => TContext[K] & Finalizable;
+
+// A registry contains a factory for each key in TContext.
+export type Registry<TContext> = {
+  [P in keyof TContext]: Factory<TContext, P>;
+} & Record<string, (_: TContext) => Finalizable>;
+
+// Creates a context given a registry.
+export function create<TContext>(
+  registry: Registry<TContext>
+): TContext & Finalizable {
+  const context: Partial<TContext> & Finalizable = {
+    finalize() {
+      for (const key of Object.getOwnPropertyNames(registry)) {
+        const name = key as keyof TContext;
+        try {
+          if (this[name]) {
+            (this[name] as unknown as Finalizable).finalize();
+          }
+        } catch (e) {
+          console.info(`Failed to finalize ${String(name)}`);
+          throw e;
+        }
+      }
+    },
+  } as Partial<TContext> & Finalizable;
+
+  const initialized: Map<keyof TContext, Finalizable> = new Map<
+    keyof TContext,
+    Finalizable
+  >();
+  for (const key of Object.keys(registry)) {
+    const name = key as keyof TContext;
+    const factory = registry[name];
+    let initializing = false;
+    Object.defineProperty(context, name, {
+      configurable: true, // Tests can mock properties
+      get() {
+        if (!initialized.has(name)) {
+          // Notice that this is the getter for the property in question.
+          // It is possible that during the initialization of one property,
+          // another property is required. This extra check ensures that
+          // the construction of propertiers on Context are not circularly
+          // dependent.
+          if (initializing) throw new Error(`Circular dependency for ${key}`);
+          try {
+            initializing = true;
+            initialized.set(name, factory(context));
+          } catch (e) {
+            console.error(`Failed to initialize ${String(name)}`, e);
+          } finally {
+            initializing = false;
+          }
+        }
+        return initialized.get(name);
+      },
+    });
+  }
+  return context as TContext & Finalizable;
+}
diff --git a/polygerrit-ui/app/services/registry_test.ts b/polygerrit-ui/app/services/registry_test.ts
new file mode 100644
index 0000000..639cd64
--- /dev/null
+++ b/polygerrit-ui/app/services/registry_test.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {create, Finalizable, Registry} from './registry';
+import '../test/common-test-setup';
+import {assert} from '@open-wc/testing';
+
+class Foo implements Finalizable {
+  constructor(private readonly final: string[]) {}
+
+  finalize() {
+    this.final.push('Foo');
+  }
+}
+
+class Bar implements Finalizable {
+  constructor(private readonly final: string[], _foo?: Foo) {}
+
+  finalize() {
+    this.final.push('Bar');
+  }
+}
+
+interface DemoContext {
+  foo: Foo;
+  bar: Bar;
+}
+
+suite('Registry', () => {
+  setup(() => {});
+
+  test('It finalizes correctly', () => {
+    const final: string[] = [];
+    const demoRegistry: Registry<DemoContext> = {
+      foo: (_ctx: Partial<DemoContext>) => new Foo(final),
+      bar: (ctx: Partial<DemoContext>) => new Bar(final, ctx.foo),
+    };
+    const demoContext: DemoContext & Finalizable =
+      create<DemoContext>(demoRegistry);
+    demoContext.finalize();
+    assert.deepEqual(final, ['Foo', 'Bar']);
+  });
+});
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index ae3d848..c3c1cb6 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -1,88 +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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {NumericChangeId, PatchSetNum} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
-import {distinctUntilChanged, map} from 'rxjs/operators';
+import {Observable} from 'rxjs';
+import {Model} from '../../models/model';
+import {select} from '../../utils/observable-util';
+import {define} from '../../models/dependency';
 
 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 {
+  // Note that this router model view must be updated before view model state.
   view?: GerritView;
-  changeNum?: NumericChangeId;
-  patchNum?: PatchSetNum;
 }
 
-// TODO: Figure out how to best enforce immutability of all states. Use Immer?
-// Use DeepReadOnly?
-const initialState: RouterState = {};
+export const routerModelToken = define<RouterModel>('router-model');
+export class RouterModel extends Model<RouterState> {
+  readonly routerView$: Observable<GerritView | undefined> = select(
+    this.state$,
+    state => state.view
+  );
 
-const privateState$ = new BehaviorSubject<RouterState>(initialState);
-
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
+  constructor() {
+    super({});
+  }
 }
-
-// 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 routerView$ = routerState$.pipe(
-  map(state => state.view),
-  distinctUntilChanged()
-);
-
-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/scheduler/fake-scheduler.ts b/polygerrit-ui/app/services/scheduler/fake-scheduler.ts
new file mode 100644
index 0000000..d4df3ce
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/fake-scheduler.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Scheduler, Task} from './scheduler';
+
+type FakeTask = (error?: unknown) => Promise<void>;
+export class FakeScheduler<T> implements Scheduler<T> {
+  readonly scheduled: Array<FakeTask> = [];
+
+  schedule(task: Task<T>) {
+    return new Promise<T>((resolve, reject) => {
+      this.scheduled.push(async (error?: unknown) => {
+        if (error) {
+          reject(error);
+        } else {
+          try {
+            resolve(await task());
+          } catch (e: unknown) {
+            reject(e);
+          }
+        }
+      });
+    });
+  }
+
+  async resolve(): Promise<void> {
+    if (this.scheduled.length === 0) return;
+    const fakeTask = this.scheduled.shift() as FakeTask;
+    await fakeTask();
+  }
+
+  async reject(error: unknown): Promise<void> {
+    if (this.scheduled.length === 0) return;
+    const fakeTask = this.scheduled.shift() as FakeTask;
+    await fakeTask(error);
+  }
+}
diff --git a/polygerrit-ui/app/services/scheduler/fake-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/fake-scheduler_test.ts
new file mode 100644
index 0000000..b31b194
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/fake-scheduler_test.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
+import {assertFails} from '../../test/test-utils';
+import {FakeScheduler} from './fake-scheduler';
+
+suite('fake scheduler', () => {
+  let scheduler: FakeScheduler<number>;
+  setup(() => {
+    scheduler = new FakeScheduler<number>();
+  });
+  test('schedules tasks', () => {
+    scheduler.schedule(async () => 1);
+    assert.equal(scheduler.scheduled.length, 1);
+  });
+
+  test('resolves tasks', async () => {
+    const promise = scheduler.schedule(async () => 1);
+    await scheduler.resolve();
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('rejects tasks', async () => {
+    const promise = scheduler.schedule(async () => 1);
+    assertFails(promise);
+    await scheduler.reject(new Error('Fake Error'));
+  });
+
+  test('propagates errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => {
+      throw error;
+    });
+    assertFails(promise, error);
+    await scheduler.resolve();
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler.ts b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler.ts
new file mode 100644
index 0000000..1febcb6
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Scheduler, Task} from './scheduler';
+
+export class MaxInFlightScheduler<T> implements Scheduler<T> {
+  private inflight = 0;
+
+  private waiting: Array<Task<void>> = [];
+
+  constructor(
+    private readonly base: Scheduler<T>,
+    private maxInflight: number = 10
+  ) {}
+
+  async schedule(task: Task<T>): Promise<T> {
+    return new Promise<T>((resolve, reject) => {
+      this.waiting.push(async () => {
+        try {
+          const result = await this.base.schedule(task);
+          resolve(result);
+        } catch (e: unknown) {
+          reject(e);
+        }
+      });
+      this.next();
+    });
+  }
+
+  private next() {
+    if (this.inflight >= this.maxInflight) return;
+    if (this.waiting.length === 0) return;
+    const task = this.waiting.shift() as Task<void>;
+    ++this.inflight;
+    task().finally(() => {
+      --this.inflight;
+      this.next();
+    });
+  }
+}
diff --git a/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts
new file mode 100644
index 0000000..3fbc4e9
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {assertFails, waitEventLoop} from '../../test/test-utils';
+import {Scheduler} from './scheduler';
+import {MaxInFlightScheduler} from './max-in-flight-scheduler';
+import {FakeScheduler} from './fake-scheduler';
+import {assert} from '@open-wc/testing';
+
+suite('max-in-flight scheduler', () => {
+  let fakeScheduler: FakeScheduler<number>;
+  let scheduler: Scheduler<number>;
+  setup(() => {
+    fakeScheduler = new FakeScheduler<number>();
+    scheduler = new MaxInFlightScheduler<number>(fakeScheduler, 2);
+  });
+
+  test('executes tasks', async () => {
+    const promise = scheduler.schedule(async () => 1);
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('propagates errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => {
+      throw error;
+    });
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    assertFails(promise, error);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+
+  test('propagates subscheduler errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => 1);
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    assertFails(promise, error);
+    fakeScheduler.reject(error);
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+
+  test('allows up to 2 in flight', async () => {
+    for (let i = 0; i < 3; ++i) {
+      scheduler.schedule(async () => i);
+    }
+    assert.equal(fakeScheduler.scheduled.length, 2);
+  });
+
+  test('resumes when promise resolves', async () => {
+    for (let i = 0; i < 3; ++i) {
+      scheduler.schedule(async () => i);
+    }
+    assert.equal(fakeScheduler.scheduled.length, 2);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    await waitEventLoop();
+    assert.equal(fakeScheduler.scheduled.length, 2);
+  });
+
+  test('resumes when promise fails', async () => {
+    for (let i = 0; i < 3; ++i) {
+      scheduler.schedule(async () => i);
+    }
+    assert.equal(fakeScheduler.scheduled.length, 2);
+    fakeScheduler.reject(new Error('Fake Error'));
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    await waitEventLoop();
+    assert.equal(fakeScheduler.scheduled.length, 2);
+  });
+
+  test('eventually resumes all', async () => {
+    const promises = [];
+    for (let i = 0; i < 3; ++i) {
+      promises.push(scheduler.schedule(async () => i));
+    }
+    for (let i = 0; i < 3; ++i) {
+      fakeScheduler.resolve();
+      await waitEventLoop();
+    }
+    const res = await Promise.all(promises);
+    assert.deepEqual(res, [0, 1, 2]);
+  });
+});
diff --git a/polygerrit-ui/app/services/scheduler/retry-scheduler.ts b/polygerrit-ui/app/services/scheduler/retry-scheduler.ts
new file mode 100644
index 0000000..04ced2f
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/retry-scheduler.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Scheduler, Task} from './scheduler';
+
+export class RetryError<T> extends Error {
+  constructor(readonly payload: T, message = 'Retry Error') {
+    super(message);
+  }
+}
+
+function untilTimeout(ms: number) {
+  return new Promise(resolve => window.setTimeout(resolve, ms));
+}
+
+export class RetryScheduler<T> implements Scheduler<T> {
+  constructor(
+    private readonly base: Scheduler<T>,
+    private maxRetry: number,
+    private backoffIntervalMs: number,
+    private backoffFactor: number = 1.618
+  ) {}
+
+  async schedule(task: Task<T>): Promise<T> {
+    let tries = 0;
+    let timeout = this.backoffIntervalMs;
+
+    const worker: Task<T> = async () => {
+      try {
+        return await this.base.schedule(task);
+      } catch (e: unknown) {
+        if (e instanceof RetryError && tries++ < this.maxRetry) {
+          await untilTimeout(timeout);
+          timeout = timeout * this.backoffFactor;
+          return await worker();
+        } else {
+          throw e;
+        }
+      }
+    };
+    return worker();
+  }
+}
diff --git a/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
new file mode 100644
index 0000000..041aed2
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
@@ -0,0 +1,123 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {assertFails, waitEventLoop} from '../../test/test-utils';
+import {Scheduler} from './scheduler';
+import {RetryScheduler, RetryError} from './retry-scheduler';
+import {FakeScheduler} from './fake-scheduler';
+import {SinonFakeTimers} from 'sinon';
+import {assert} from '@open-wc/testing';
+
+suite('retry scheduler', () => {
+  let clock: SinonFakeTimers;
+  let fakeScheduler: FakeScheduler<number>;
+  let scheduler: Scheduler<number>;
+  setup(() => {
+    clock = sinon.useFakeTimers();
+    fakeScheduler = new FakeScheduler<number>();
+    scheduler = new RetryScheduler<number>(fakeScheduler, 3, 50, 1);
+  });
+
+  async function waitForRetry(ms: number) {
+    // Flush the promise so that we can reach untilTimeout
+    await waitEventLoop();
+    // Advance the clock.
+    clock.tick(ms);
+    // Flush the promise that waits for the clock.
+    await waitEventLoop();
+  }
+
+  test('executes tasks', async () => {
+    const promise = scheduler.schedule(async () => 1);
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('propagates task errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => {
+      throw error;
+    });
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    assertFails(promise, error);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+
+  test('propagates subscheduler errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => 1);
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    assertFails(promise, error);
+    fakeScheduler.reject(error);
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+
+  test('retries on retryable error', async () => {
+    let retries = 1;
+    const promise = scheduler.schedule(async () => {
+      if (retries-- > 0) throw new RetryError('Retrying');
+      return 1;
+    });
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    fakeScheduler.resolve();
+    assert.equal(fakeScheduler.scheduled.length, 0);
+    await waitForRetry(50);
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    fakeScheduler.resolve();
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('retries up to 3 times', async () => {
+    let retries = 3;
+    const promise = scheduler.schedule(async () => {
+      if (retries-- > 0) throw new RetryError('Retrying');
+      return 1;
+    });
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    for (let i = 0; i < 3; i++) {
+      fakeScheduler.resolve();
+      assert.equal(fakeScheduler.scheduled.length, 0);
+      await waitForRetry(50);
+      assert.equal(fakeScheduler.scheduled.length, 1);
+    }
+    fakeScheduler.resolve();
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('fails after more than 3 times', async () => {
+    let retries = 4;
+    const promise = scheduler.schedule(async () => {
+      if (retries-- > 0) throw new RetryError(retries, `Retrying ${retries}`);
+      return 1;
+    });
+    assert.equal(fakeScheduler.scheduled.length, 1);
+    for (let i = 0; i < 3; i++) {
+      fakeScheduler.resolve();
+      assert.equal(fakeScheduler.scheduled.length, 0);
+      await waitForRetry(50);
+      assert.equal(fakeScheduler.scheduled.length, 1);
+    }
+    fakeScheduler.resolve();
+    assertFails(promise);
+    // The error we get back should be the last error.
+    await promise.catch((reason: RetryError<number>) => {
+      assert.equal(reason.payload, 0);
+      assert.equal(reason.message, 'Retrying 0');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/scheduler/scheduler.ts b/polygerrit-ui/app/services/scheduler/scheduler.ts
new file mode 100644
index 0000000..b834ab3
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/scheduler.ts
@@ -0,0 +1,14 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+export type Task<T> = () => Promise<T>;
+export interface Scheduler<T> {
+  schedule(task: Task<T>): Promise<T>;
+}
+export class BaseScheduler<T> implements Scheduler<T> {
+  schedule(task: Task<T>) {
+    return task();
+  }
+}
diff --git a/polygerrit-ui/app/services/scheduler/scheduler_test.ts b/polygerrit-ui/app/services/scheduler/scheduler_test.ts
new file mode 100644
index 0000000..a59b30f
--- /dev/null
+++ b/polygerrit-ui/app/services/scheduler/scheduler_test.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../../test/common-test-setup';
+import {assertFails} from '../../test/test-utils';
+import {BaseScheduler} from './scheduler';
+
+suite('naive scheduler', () => {
+  let scheduler: BaseScheduler<number>;
+  setup(() => {
+    scheduler = new BaseScheduler<number>();
+  });
+
+  test('executes tasks', async () => {
+    const promise = scheduler.schedule(async () => 1);
+    const val = await promise;
+    assert.equal(val, 1);
+  });
+
+  test('propagates errors', async () => {
+    const error = new Error('This is an error');
+    const promise = scheduler.schedule(async () => {
+      throw error;
+    });
+    assertFails(promise, error);
+    await promise.catch((reason: Error) => {
+      assert.equal(reason, error);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/service-worker-installer.ts b/polygerrit-ui/app/services/service-worker-installer.ts
new file mode 100644
index 0000000..b83713c
--- /dev/null
+++ b/polygerrit-ui/app/services/service-worker-installer.ts
@@ -0,0 +1,183 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {FlagsService, KnownExperimentId} from './flags/flags';
+import {
+  areNotificationsEnabled,
+  registerServiceWorker,
+} from '../utils/worker-util';
+import {UserModel} from '../models/user/user-model';
+import {AccountDetailInfo} from '../api/rest-api';
+import {until} from '../utils/async-util';
+import {LifeCycle} from '../constants/reporting';
+import {ReportingService} from './gr-reporting/gr-reporting';
+import {define} from '../models/dependency';
+import {Model} from '../models/model';
+import {Observable} from 'rxjs';
+import {select} from '../utils/observable-util';
+
+/** Type of incoming messages for ServiceWorker. */
+export enum ServiceWorkerMessageType {
+  TRIGGER_NOTIFICATIONS = 'TRIGGER_NOTIFICATIONS',
+  USER_PREFERENCE_CHANGE = 'USER_PREFERENCE_CHANGE',
+  REPORTING = 'REPORTING',
+}
+
+export const TRIGGER_NOTIFICATION_UPDATES_MS = 5 * 60 * 1000;
+
+export const serviceWorkerInstallerToken = define<ServiceWorkerInstaller>(
+  'service-worker-installer'
+);
+
+/**
+ * Service worker state:
+ * initialized - True when service worker registered and event listeners added.
+ *             - False otherwise
+ * shouldShowPrompt - True when user didn't make decision about notifications
+ *                  - False otherwise
+ */
+export interface ServiceWorkerInstallerState {
+  initialized: boolean;
+  shouldShowPrompt: boolean;
+}
+
+export class ServiceWorkerInstaller extends Model<ServiceWorkerInstallerState> {
+  readonly initialized$: Observable<Boolean | undefined> = select(
+    this.state$,
+    state => state.initialized
+  );
+
+  readonly shouldShowPrompt$: Observable<Boolean | undefined> = select(
+    this.initialized$,
+    _ => this.shouldShowPrompt()
+  );
+
+  // Internal state, it's exposed in initialized$
+  private initialized = false;
+
+  account?: AccountDetailInfo;
+
+  allowBrowserNotificationsPreference?: boolean;
+
+  constructor(
+    private readonly flagsService: FlagsService,
+    private readonly reportingService: ReportingService,
+    private readonly userModel: UserModel
+  ) {
+    super({initialized: false, shouldShowPrompt: false});
+    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
+      return;
+    }
+    this.userModel.account$.subscribe(acc => (this.account = acc));
+    this.userModel.preferences$.subscribe(prefs => {
+      if (
+        this.allowBrowserNotificationsPreference !==
+        prefs.allow_browser_notifications
+      ) {
+        this.allowBrowserNotificationsPreference =
+          prefs.allow_browser_notifications;
+        // flag can disable notifications similar to user setting
+        navigator.serviceWorker.controller?.postMessage({
+          type: ServiceWorkerMessageType.USER_PREFERENCE_CHANGE,
+          allowBrowserNotificationsPreference:
+            this.allowBrowserNotificationsPreference &&
+            this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS),
+        });
+      }
+    });
+    Promise.all([
+      until(this.userModel.account$, account => !!account),
+      until(
+        this.userModel.preferences$,
+        prefs => !!prefs.allow_browser_notifications
+      ),
+    ]).then(() => {
+      this.init();
+    });
+  }
+
+  private async init() {
+    if (this.initialized) return;
+    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
+      return;
+    }
+    if (!this.areNotificationsEnabled()) return;
+
+    if (!('serviceWorker' in navigator)) {
+      console.error('Service worker API not available');
+      return;
+    }
+    await registerServiceWorker('/service-worker.js');
+    const permission = Notification.permission;
+    this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
+      permission,
+    });
+    if (this.isPermitted(permission)) this.startTriggerTimer();
+    this.initialized = true;
+    this.updateState({initialized: true});
+    // Assumption: service worker will send event only to 1 client.
+    navigator.serviceWorker.onmessage = event => {
+      if (event.data?.type === ServiceWorkerMessageType.REPORTING) {
+        this.reportingService.reportLifeCycle(LifeCycle.SERVICE_WORKER_UPDATE, {
+          eventName: event.data.eventName as string | undefined,
+        });
+      }
+    };
+  }
+
+  // private, used in test
+  shouldShowPrompt(): boolean {
+    if (!this.initialized) return false;
+    if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
+      return false;
+    }
+    if (!this.areNotificationsEnabled()) return false;
+    return Notification.permission === 'default';
+  }
+
+  public async requestPermission() {
+    const permission = await Notification.requestPermission();
+    this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
+      requested: true,
+      permission,
+    });
+    if (this.isPermitted(permission)) this.startTriggerTimer();
+  }
+
+  areNotificationsEnabled() {
+    // Push Notification developer can have notification enabled even if they
+    // are disabled for this.account.
+    if (
+      !this.flagsService.isEnabled(
+        KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
+      ) &&
+      !areNotificationsEnabled(this.account)
+    ) {
+      return false;
+    }
+
+    return this.allowBrowserNotificationsPreference;
+  }
+
+  /**
+   * Every 5 minutes, we trigger service-worker to get
+   * latest updates in attention set and service-worker will create
+   * notifications.
+   */
+  startTriggerTimer() {
+    setTimeout(() => {
+      this.startTriggerTimer();
+      navigator.serviceWorker.controller?.postMessage({
+        type: ServiceWorkerMessageType.TRIGGER_NOTIFICATIONS,
+        account: this.account,
+      });
+    }, TRIGGER_NOTIFICATION_UPDATES_MS);
+  }
+
+  isPermitted(permission: NotificationPermission) {
+    return permission === 'granted';
+  }
+}
diff --git a/polygerrit-ui/app/services/service-worker-installer_test.ts b/polygerrit-ui/app/services/service-worker-installer_test.ts
new file mode 100644
index 0000000..a036289
--- /dev/null
+++ b/polygerrit-ui/app/services/service-worker-installer_test.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {getAppContext} from './app-context';
+import '../test/common-test-setup';
+import {ServiceWorkerInstaller} from './service-worker-installer';
+import {assert} from '@open-wc/testing';
+import {createDefaultPreferences} from '../constants/constants';
+import {waitUntilObserved} from '../test/test-utils';
+import {testResolver} from '../test/common-test-setup';
+import {userModelToken} from '../models/user/user-model';
+
+suite('service worker installer tests', () => {
+  test('init', async () => {
+    const registerStub = sinon.stub(window.navigator.serviceWorker, 'register');
+    const flagsService = getAppContext().flagsService;
+    const reportingService = getAppContext().reportingService;
+    const userModel = testResolver(userModelToken);
+    sinon.stub(flagsService, 'isEnabled').returns(true);
+    new ServiceWorkerInstaller(flagsService, reportingService, userModel);
+    const prefs = {
+      ...createDefaultPreferences(),
+      allow_browser_notifications: true,
+    };
+    userModel.setPreferences(prefs);
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === true
+    );
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === true
+    );
+    assert.isTrue(registerStub.called);
+  });
+});
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index 3c9e058..9ca2213 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 /** Enum for all special shortcuts */
@@ -46,13 +35,14 @@
   GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
   GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
   GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
+  GO_TO_REPOS = 'GO_TO_REPOS',
+  GO_TO_GROUPS = 'GO_TO_GROUPS',
 
   CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
   CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
   OPEN_CHANGE = 'OPEN_CHANGE',
   NEXT_PAGE = 'NEXT_PAGE',
   PREV_PAGE = 'PREV_PAGE',
-  TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
   TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
   REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
   OPEN_SUBMIT_DIALOG = 'OPEN_SUBMIT_DIALOG',
@@ -60,6 +50,7 @@
 
   OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
   OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
+  OPEN_COPY_LINKS_DROPDOWN = 'OPEN_COPY_LINKS_DROPDOWN',
   EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
   COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
   UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
@@ -109,9 +100,11 @@
   OPEN_LAST_FILE = 'OPEN_LAST_FILE',
 
   SEARCH = 'SEARCH',
-  SEND_REPLY = 'SEND_REPLY',
   EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
+  MENTIONS_DROPDOWN = 'MENTIONS_DROPDOWN',
   TOGGLE_BLAME = 'TOGGLE_BLAME',
+
+  TOGGLE_CHECKBOX = 'TOGGLE_CHECKBOX',
 }
 
 export interface ShortcutHelpItem {
@@ -120,406 +113,426 @@
   bindings: Binding[];
 }
 
-export const config = new Map<ShortcutSection, ShortcutHelpItem[]>();
+export function createShortcutConfig() {
+  const config = new Map<ShortcutSection, ShortcutHelpItem[]>();
+  function describe(
+    shortcut: Shortcut,
+    section: ShortcutSection,
+    text: string,
+    binding: Binding,
+    ...moreBindings: Binding[]
+  ) {
+    if (!config.has(section)) {
+      config.set(section, []);
+    }
+    const shortcuts = config.get(section);
+    if (shortcuts) {
+      shortcuts.push({shortcut, text, bindings: [binding, ...moreBindings]});
+    }
+  }
 
-function describe(
-  shortcut: Shortcut,
-  section: ShortcutSection,
-  text: string,
-  binding: Binding,
-  ...moreBindings: Binding[]
-) {
-  if (!config.has(section)) {
-    config.set(section, []);
-  }
-  const shortcuts = config.get(section);
-  if (shortcuts) {
-    shortcuts.push({shortcut, text, bindings: [binding, ...moreBindings]});
-  }
+  describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', {key: '/'});
+  describe(
+    Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
+    ShortcutSection.EVERYWHERE,
+    'Show this dialog',
+    {key: '?'}
+  );
+  describe(
+    Shortcut.GO_TO_USER_DASHBOARD,
+    ShortcutSection.EVERYWHERE,
+    'Go to User Dashboard',
+    {key: 'i', combo: ComboKey.G}
+  );
+  describe(
+    Shortcut.GO_TO_OPENED_CHANGES,
+    ShortcutSection.EVERYWHERE,
+    'Go to Opened Changes',
+    {key: 'o', combo: ComboKey.G}
+  );
+  describe(
+    Shortcut.GO_TO_MERGED_CHANGES,
+    ShortcutSection.EVERYWHERE,
+    'Go to Merged Changes',
+    {key: 'm', combo: ComboKey.G}
+  );
+  describe(
+    Shortcut.GO_TO_ABANDONED_CHANGES,
+    ShortcutSection.EVERYWHERE,
+    'Go to Abandoned Changes',
+    {key: 'a', combo: ComboKey.G}
+  );
+  describe(
+    Shortcut.GO_TO_WATCHED_CHANGES,
+    ShortcutSection.EVERYWHERE,
+    'Go to Watched Changes',
+    {key: 'w', combo: ComboKey.G}
+  );
+  describe(
+    Shortcut.GO_TO_REPOS,
+    ShortcutSection.EVERYWHERE,
+    'Go to Repositories',
+    {key: 'r', combo: ComboKey.G}
+  );
+  describe(Shortcut.GO_TO_GROUPS, ShortcutSection.EVERYWHERE, 'Go to Groups', {
+    key: 'g',
+    combo: ComboKey.G,
+  });
+  describe(
+    Shortcut.TOGGLE_CHECKBOX,
+    ShortcutSection.ACTIONS,
+    'Toggle checkbox',
+    {key: 'x'}
+  );
+  describe(
+    Shortcut.CURSOR_NEXT_CHANGE,
+    ShortcutSection.ACTIONS,
+    'Select next change',
+    {key: 'j', allowRepeat: true}
+  );
+  describe(
+    Shortcut.CURSOR_PREV_CHANGE,
+    ShortcutSection.ACTIONS,
+    'Select previous change',
+    {key: 'k', allowRepeat: true}
+  );
+  describe(
+    Shortcut.OPEN_CHANGE,
+    ShortcutSection.ACTIONS,
+    'Show selected change',
+    {key: 'o'}
+  );
+  describe(
+    Shortcut.NEXT_PAGE,
+    ShortcutSection.ACTIONS,
+    'Go to next page',
+    {key: 'n'},
+    {key: ']'}
+  );
+  describe(
+    Shortcut.PREV_PAGE,
+    ShortcutSection.ACTIONS,
+    'Go to previous page',
+    {key: 'p'},
+    {key: '['}
+  );
+  describe(
+    Shortcut.OPEN_REPLY_DIALOG,
+    ShortcutSection.ACTIONS,
+    'Open reply dialog to publish comments and add reviewers',
+    {key: 'a'}
+  );
+  describe(
+    Shortcut.OPEN_DOWNLOAD_DIALOG,
+    ShortcutSection.ACTIONS,
+    'Open download overlay',
+    {key: 'd'}
+  );
+  describe(
+    Shortcut.OPEN_COPY_LINKS_DROPDOWN,
+    ShortcutSection.ACTIONS,
+    'Open link dialog',
+    {key: 'l'}
+  );
+  describe(
+    Shortcut.EXPAND_ALL_MESSAGES,
+    ShortcutSection.ACTIONS,
+    'Expand all messages',
+    {key: 'x'}
+  );
+  describe(
+    Shortcut.COLLAPSE_ALL_MESSAGES,
+    ShortcutSection.ACTIONS,
+    'Collapse all messages',
+    {key: 'z'}
+  );
+  describe(
+    Shortcut.REFRESH_CHANGE,
+    ShortcutSection.ACTIONS,
+    'Reload the change at the latest patch',
+    {key: 'R'}
+  );
+  describe(
+    Shortcut.TOGGLE_FILE_REVIEWED,
+    ShortcutSection.ACTIONS,
+    'Toggle review flag on selected file',
+    {key: 'r'}
+  );
+  describe(
+    Shortcut.REFRESH_CHANGE_LIST,
+    ShortcutSection.ACTIONS,
+    'Refresh list of changes',
+    {key: 'R'}
+  );
+  describe(
+    Shortcut.TOGGLE_CHANGE_STAR,
+    ShortcutSection.ACTIONS,
+    'Star/unstar change',
+    {key: 's'}
+  );
+  describe(
+    Shortcut.OPEN_SUBMIT_DIALOG,
+    ShortcutSection.ACTIONS,
+    'Open submit dialog',
+    {key: 'S'}
+  );
+  describe(
+    Shortcut.TOGGLE_ATTENTION_SET,
+    ShortcutSection.ACTIONS,
+    'Toggle attention set status',
+    {key: 'T'}
+  );
+  describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic', {
+    key: 't',
+  });
+  describe(
+    Shortcut.DIFF_AGAINST_BASE,
+    ShortcutSection.DIFFS,
+    'Diff against base',
+    {key: Key.DOWN, combo: ComboKey.V},
+    {key: 's', combo: ComboKey.V}
+  );
+  describe(
+    Shortcut.DIFF_AGAINST_LATEST,
+    ShortcutSection.DIFFS,
+    'Diff against latest patchset',
+    {key: Key.UP, combo: ComboKey.V},
+    {key: 'w', combo: ComboKey.V}
+  );
+  describe(
+    Shortcut.DIFF_BASE_AGAINST_LEFT,
+    ShortcutSection.DIFFS,
+    'Diff base against left',
+    {key: Key.LEFT, combo: ComboKey.V},
+    {key: 'a', combo: ComboKey.V}
+  );
+  describe(
+    Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+    ShortcutSection.DIFFS,
+    'Diff right against latest',
+    {key: Key.RIGHT, combo: ComboKey.V},
+    {key: 'd', combo: ComboKey.V}
+  );
+  describe(
+    Shortcut.DIFF_BASE_AGAINST_LATEST,
+    ShortcutSection.DIFFS,
+    'Diff base against latest',
+    {key: 'b', combo: ComboKey.V}
+  );
+
+  describe(
+    Shortcut.NEXT_LINE,
+    ShortcutSection.DIFFS,
+    'Go to next line',
+    {key: 'j', allowRepeat: true},
+    {key: Key.DOWN, allowRepeat: true}
+  );
+  describe(
+    Shortcut.PREV_LINE,
+    ShortcutSection.DIFFS,
+    'Go to previous line',
+    {key: 'k', allowRepeat: true},
+    {key: Key.UP, allowRepeat: true}
+  );
+  describe(
+    Shortcut.VISIBLE_LINE,
+    ShortcutSection.DIFFS,
+    'Move cursor to currently visible code',
+    {key: '.'}
+  );
+  describe(
+    Shortcut.NEXT_CHUNK,
+    ShortcutSection.DIFFS,
+    'Go to next diff chunk',
+    {
+      key: 'n',
+    }
+  );
+  describe(
+    Shortcut.PREV_CHUNK,
+    ShortcutSection.DIFFS,
+    'Go to previous diff chunk',
+    {key: 'p'}
+  );
+  describe(
+    Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
+    ShortcutSection.DIFFS,
+    'Toggle all diff context',
+    {key: 'X'}
+  );
+  describe(
+    Shortcut.NEXT_COMMENT_THREAD,
+    ShortcutSection.DIFFS,
+    'Go to next comment thread',
+    {key: 'N'}
+  );
+  describe(
+    Shortcut.PREV_COMMENT_THREAD,
+    ShortcutSection.DIFFS,
+    'Go to previous comment thread',
+    {key: 'P'}
+  );
+  describe(
+    Shortcut.EXPAND_ALL_COMMENT_THREADS,
+    ShortcutSection.DIFFS,
+    'Expand all comment threads',
+    {key: 'e', docOnly: true}
+  );
+  describe(
+    Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+    ShortcutSection.DIFFS,
+    'Collapse all comment threads',
+    {key: 'E', docOnly: true}
+  );
+  describe(
+    Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+    ShortcutSection.DIFFS,
+    'Hide/Display all comment threads',
+    {key: 'h'}
+  );
+  describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane', {
+    key: Key.LEFT,
+    modifiers: [Modifier.SHIFT_KEY],
+  });
+  describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane', {
+    key: Key.RIGHT,
+    modifiers: [Modifier.SHIFT_KEY],
+  });
+  describe(
+    Shortcut.TOGGLE_LEFT_PANE,
+    ShortcutSection.DIFFS,
+    'Hide/show left diff',
+    {key: 'A'}
+  );
+  describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', {
+    key: 'c',
+  });
+  describe(
+    Shortcut.SAVE_COMMENT,
+    ShortcutSection.DIFFS,
+    'Save comment',
+    {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+    {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+    {key: 's', modifiers: [Modifier.CTRL_KEY]},
+    {key: 's', modifiers: [Modifier.META_KEY]}
+  );
+  describe(
+    Shortcut.OPEN_DIFF_PREFS,
+    ShortcutSection.DIFFS,
+    'Show diff preferences',
+    {key: ','}
+  );
+  describe(
+    Shortcut.TOGGLE_DIFF_REVIEWED,
+    ShortcutSection.DIFFS,
+    'Mark/unmark file as reviewed',
+    {key: 'r'}
+  );
+  describe(
+    Shortcut.TOGGLE_DIFF_MODE,
+    ShortcutSection.DIFFS,
+    'Toggle unified/side-by-side diff',
+    {key: 'm'}
+  );
+  describe(
+    Shortcut.NEXT_UNREVIEWED_FILE,
+    ShortcutSection.DIFFS,
+    'Mark file as reviewed and go to next unreviewed file',
+    {key: 'M'}
+  );
+  describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame', {
+    key: 'b',
+  });
+  describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', {
+    key: 'f',
+  });
+  describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file', {
+    key: ']',
+  });
+  describe(
+    Shortcut.PREV_FILE,
+    ShortcutSection.NAVIGATION,
+    'Go to previous file',
+    {key: '['}
+  );
+  describe(
+    Shortcut.NEXT_FILE_WITH_COMMENTS,
+    ShortcutSection.NAVIGATION,
+    'Go to next file that has comments',
+    {key: 'J'}
+  );
+  describe(
+    Shortcut.PREV_FILE_WITH_COMMENTS,
+    ShortcutSection.NAVIGATION,
+    'Go to previous file that has comments',
+    {key: 'K'}
+  );
+  describe(
+    Shortcut.OPEN_FIRST_FILE,
+    ShortcutSection.NAVIGATION,
+    'Go to first file',
+    {key: ']'}
+  );
+  describe(
+    Shortcut.OPEN_LAST_FILE,
+    ShortcutSection.NAVIGATION,
+    'Go to last file',
+    {key: '['}
+  );
+  describe(
+    Shortcut.UP_TO_DASHBOARD,
+    ShortcutSection.NAVIGATION,
+    'Up to dashboard',
+    {key: 'u'}
+  );
+  describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change', {
+    key: 'u',
+  });
+
+  describe(
+    Shortcut.CURSOR_NEXT_FILE,
+    ShortcutSection.FILE_LIST,
+    'Select next file',
+    {key: 'j', allowRepeat: true},
+    {key: Key.DOWN, allowRepeat: true}
+  );
+  describe(
+    Shortcut.CURSOR_PREV_FILE,
+    ShortcutSection.FILE_LIST,
+    'Select previous file',
+    {key: 'k', allowRepeat: true},
+    {key: Key.UP, allowRepeat: true}
+  );
+  describe(
+    Shortcut.OPEN_FILE,
+    ShortcutSection.FILE_LIST,
+    'Go to selected file',
+    {key: 'o'},
+    {key: Key.ENTER}
+  );
+  describe(
+    Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+    ShortcutSection.FILE_LIST,
+    'Show/hide all inline diffs',
+    {key: 'I'}
+  );
+  describe(
+    Shortcut.TOGGLE_INLINE_DIFF,
+    ShortcutSection.FILE_LIST,
+    'Show/hide selected inline diff',
+    {key: 'i'}
+  );
+  describe(
+    Shortcut.EMOJI_DROPDOWN,
+    ShortcutSection.REPLY_DIALOG,
+    'Emoji dropdown',
+    {key: ':', docOnly: true}
+  );
+  describe(
+    Shortcut.MENTIONS_DROPDOWN,
+    ShortcutSection.REPLY_DIALOG,
+    'Mentions dropdown',
+    {key: '@', docOnly: true}
+  );
+  return config;
 }
-
-describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', {key: '/'});
-describe(
-  Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
-  ShortcutSection.EVERYWHERE,
-  'Show this dialog',
-  {key: '?'}
-);
-describe(
-  Shortcut.GO_TO_USER_DASHBOARD,
-  ShortcutSection.EVERYWHERE,
-  'Go to User Dashboard',
-  {key: 'i', combo: ComboKey.G}
-);
-describe(
-  Shortcut.GO_TO_OPENED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Opened Changes',
-  {key: 'o', combo: ComboKey.G}
-);
-describe(
-  Shortcut.GO_TO_MERGED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Merged Changes',
-  {key: 'm', combo: ComboKey.G}
-);
-describe(
-  Shortcut.GO_TO_ABANDONED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Abandoned Changes',
-  {key: 'a', combo: ComboKey.G}
-);
-describe(
-  Shortcut.GO_TO_WATCHED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Watched Changes',
-  {key: 'w', combo: ComboKey.G}
-);
-
-describe(
-  Shortcut.CURSOR_NEXT_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Select next change',
-  {key: 'j'}
-);
-describe(
-  Shortcut.CURSOR_PREV_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Select previous change',
-  {key: 'k'}
-);
-describe(
-  Shortcut.OPEN_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Show selected change',
-  {key: 'o'}
-);
-describe(
-  Shortcut.NEXT_PAGE,
-  ShortcutSection.ACTIONS,
-  'Go to next page',
-  {key: 'n'},
-  {key: ']'}
-);
-describe(
-  Shortcut.PREV_PAGE,
-  ShortcutSection.ACTIONS,
-  'Go to previous page',
-  {key: 'p'},
-  {key: '['}
-);
-describe(
-  Shortcut.OPEN_REPLY_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open reply dialog to publish comments and add reviewers',
-  {key: 'a'}
-);
-describe(
-  Shortcut.OPEN_DOWNLOAD_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open download overlay',
-  {key: 'd'}
-);
-describe(
-  Shortcut.EXPAND_ALL_MESSAGES,
-  ShortcutSection.ACTIONS,
-  'Expand all messages',
-  {key: 'x'}
-);
-describe(
-  Shortcut.COLLAPSE_ALL_MESSAGES,
-  ShortcutSection.ACTIONS,
-  'Collapse all messages',
-  {key: 'z'}
-);
-describe(
-  Shortcut.REFRESH_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Reload the change at the latest patch',
-  {key: 'R'}
-);
-describe(
-  Shortcut.TOGGLE_CHANGE_REVIEWED,
-  ShortcutSection.ACTIONS,
-  'Mark/unmark change as reviewed',
-  {key: 'r'}
-);
-describe(
-  Shortcut.TOGGLE_FILE_REVIEWED,
-  ShortcutSection.ACTIONS,
-  'Toggle review flag on selected file',
-  {key: 'r'}
-);
-describe(
-  Shortcut.REFRESH_CHANGE_LIST,
-  ShortcutSection.ACTIONS,
-  'Refresh list of changes',
-  {key: 'R'}
-);
-describe(
-  Shortcut.TOGGLE_CHANGE_STAR,
-  ShortcutSection.ACTIONS,
-  'Star/unstar change',
-  {key: 's'}
-);
-describe(
-  Shortcut.OPEN_SUBMIT_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open submit dialog',
-  {key: 'S'}
-);
-describe(
-  Shortcut.TOGGLE_ATTENTION_SET,
-  ShortcutSection.ACTIONS,
-  'Toggle attention set status',
-  {key: 'T'}
-);
-describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic', {
-  key: 't',
-});
-describe(
-  Shortcut.DIFF_AGAINST_BASE,
-  ShortcutSection.DIFFS,
-  'Diff against base',
-  {key: Key.DOWN, combo: ComboKey.V},
-  {key: 's', combo: ComboKey.V}
-);
-describe(
-  Shortcut.DIFF_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff against latest patchset',
-  {key: Key.UP, combo: ComboKey.V},
-  {key: 'w', combo: ComboKey.V}
-);
-describe(
-  Shortcut.DIFF_BASE_AGAINST_LEFT,
-  ShortcutSection.DIFFS,
-  'Diff base against left',
-  {key: Key.LEFT, combo: ComboKey.V},
-  {key: 'a', combo: ComboKey.V}
-);
-describe(
-  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff right against latest',
-  {key: Key.RIGHT, combo: ComboKey.V},
-  {key: 'd', combo: ComboKey.V}
-);
-describe(
-  Shortcut.DIFF_BASE_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff base against latest',
-  {key: 'b', combo: ComboKey.V}
-);
-
-describe(
-  Shortcut.NEXT_LINE,
-  ShortcutSection.DIFFS,
-  'Go to next line',
-  {key: 'j'},
-  {key: Key.DOWN}
-);
-describe(
-  Shortcut.PREV_LINE,
-  ShortcutSection.DIFFS,
-  'Go to previous line',
-  {key: 'k'},
-  {key: Key.UP}
-);
-describe(
-  Shortcut.VISIBLE_LINE,
-  ShortcutSection.DIFFS,
-  'Move cursor to currently visible code',
-  {key: '.'}
-);
-describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk', {
-  key: 'n',
-});
-describe(
-  Shortcut.PREV_CHUNK,
-  ShortcutSection.DIFFS,
-  'Go to previous diff chunk',
-  {key: 'p'}
-);
-describe(
-  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
-  ShortcutSection.DIFFS,
-  'Toggle all diff context',
-  {key: 'X'}
-);
-describe(
-  Shortcut.NEXT_COMMENT_THREAD,
-  ShortcutSection.DIFFS,
-  'Go to next comment thread',
-  {key: 'N'}
-);
-describe(
-  Shortcut.PREV_COMMENT_THREAD,
-  ShortcutSection.DIFFS,
-  'Go to previous comment thread',
-  {key: 'P'}
-);
-describe(
-  Shortcut.EXPAND_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Expand all comment threads',
-  {key: 'e', docOnly: true}
-);
-describe(
-  Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Collapse all comment threads',
-  {key: 'E', docOnly: true}
-);
-describe(
-  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Hide/Display all comment threads',
-  {key: 'h'}
-);
-describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane', {
-  key: Key.LEFT,
-  modifiers: [Modifier.SHIFT_KEY],
-});
-describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane', {
-  key: Key.RIGHT,
-  modifiers: [Modifier.SHIFT_KEY],
-});
-describe(
-  Shortcut.TOGGLE_LEFT_PANE,
-  ShortcutSection.DIFFS,
-  'Hide/show left diff',
-  {key: 'A'}
-);
-describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', {
-  key: 'c',
-});
-describe(
-  Shortcut.SAVE_COMMENT,
-  ShortcutSection.DIFFS,
-  'Save comment',
-  {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
-  {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
-  {key: 's', modifiers: [Modifier.CTRL_KEY]},
-  {key: 's', modifiers: [Modifier.META_KEY]}
-);
-describe(
-  Shortcut.OPEN_DIFF_PREFS,
-  ShortcutSection.DIFFS,
-  'Show diff preferences',
-  {key: ','}
-);
-describe(
-  Shortcut.TOGGLE_DIFF_REVIEWED,
-  ShortcutSection.DIFFS,
-  'Mark/unmark file as reviewed',
-  {key: 'r'}
-);
-describe(
-  Shortcut.TOGGLE_DIFF_MODE,
-  ShortcutSection.DIFFS,
-  'Toggle unified/side-by-side diff',
-  {key: 'm'}
-);
-describe(
-  Shortcut.NEXT_UNREVIEWED_FILE,
-  ShortcutSection.DIFFS,
-  'Mark file as reviewed and go to next unreviewed file',
-  {key: 'M'}
-);
-describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame', {
-  key: 'b',
-});
-describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', {
-  key: 'f',
-});
-describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file', {
-  key: ']',
-});
-describe(
-  Shortcut.PREV_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to previous file',
-  {key: '['}
-);
-describe(
-  Shortcut.NEXT_FILE_WITH_COMMENTS,
-  ShortcutSection.NAVIGATION,
-  'Go to next file that has comments',
-  {key: 'J'}
-);
-describe(
-  Shortcut.PREV_FILE_WITH_COMMENTS,
-  ShortcutSection.NAVIGATION,
-  'Go to previous file that has comments',
-  {key: 'K'}
-);
-describe(
-  Shortcut.OPEN_FIRST_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to first file',
-  {key: ']'}
-);
-describe(
-  Shortcut.OPEN_LAST_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to last file',
-  {key: '['}
-);
-describe(
-  Shortcut.UP_TO_DASHBOARD,
-  ShortcutSection.NAVIGATION,
-  'Up to dashboard',
-  {key: 'u'}
-);
-describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change', {
-  key: 'u',
-});
-
-describe(
-  Shortcut.CURSOR_NEXT_FILE,
-  ShortcutSection.FILE_LIST,
-  'Select next file',
-  {key: 'j'},
-  {key: Key.DOWN}
-);
-describe(
-  Shortcut.CURSOR_PREV_FILE,
-  ShortcutSection.FILE_LIST,
-  'Select previous file',
-  {key: 'k'},
-  {key: Key.UP}
-);
-describe(
-  Shortcut.OPEN_FILE,
-  ShortcutSection.FILE_LIST,
-  'Go to selected file',
-  {key: 'o'},
-  {key: Key.ENTER}
-);
-describe(
-  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-  ShortcutSection.FILE_LIST,
-  'Show/hide all inline diffs',
-  {key: 'I'}
-);
-describe(
-  Shortcut.TOGGLE_INLINE_DIFF,
-  ShortcutSection.FILE_LIST,
-  'Show/hide selected inline diff',
-  {key: 'i'}
-);
-
-describe(
-  Shortcut.SEND_REPLY,
-  ShortcutSection.REPLY_DIALOG,
-  'Send reply',
-  {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY], docOnly: true},
-  {key: Key.ENTER, modifiers: [Modifier.META_KEY], docOnly: true}
-);
-describe(
-  Shortcut.EMOJI_DROPDOWN,
-  ShortcutSection.REPLY_DIALOG,
-  'Emoji dropdown',
-  {key: ':', docOnly: true}
-);
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index a26fa08..756c209 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -1,26 +1,16 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {Subscription} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
 import {
-  config,
+  createShortcutConfig,
   Shortcut,
   ShortcutHelpItem,
   ShortcutSection,
 } from './shortcuts-config';
-import {disableShortcuts$} from '../user/user-model';
 import {
   ComboKey,
   eventMatchesShortcut,
@@ -29,8 +19,15 @@
   Modifier,
   Binding,
   shouldSuppress,
+  ShortcutOptions,
 } from '../../utils/dom-util';
 import {ReportingService} from '../gr-reporting/gr-reporting';
+import {Finalizable} from '../registry';
+import {UserModel} from '../../models/user/user-model';
+import {define} from '../../models/dependency';
+import {isCharacterLetter, isUpperCase} from '../../utils/string-util';
+
+export {Shortcut, ShortcutSection};
 
 export type SectionView = Array<{binding: string[][]; text: string}>;
 
@@ -59,22 +56,19 @@
 
 export const COMBO_TIMEOUT_MS = 1000;
 
+export const shortcutsServiceToken =
+  define<ShortcutsService>('shortcuts-service');
+
 /**
  * Shortcuts service, holds all hosts, bindings and listeners.
  */
-export class ShortcutsService {
+export class ShortcutsService implements Finalizable {
   /**
    * Keeps track of the components that are currently active such that we can
    * show a shortcut help dialog that only shows the shortcuts that are
    * currently relevant.
    */
-  private readonly activeShortcuts = new Map<HTMLElement, Shortcut[]>();
-
-  /**
-   * Keeps track of cleanup callbacks (which remove keyboard listeners) that
-   * have to be invoked when a component unregisters itself.
-   */
-  private readonly cleanupsPerHost = new Map<HTMLElement, (() => void)[]>();
+  private readonly activeShortcuts = new Set<Shortcut>();
 
   /** Static map built in the constructor by iterating over the config. */
   private readonly bindings = new Map<Shortcut, Binding[]>();
@@ -90,21 +84,47 @@
   private comboKeyLastPressed: {key?: ComboKey; timestampMs?: number} = {};
 
   /** Keeps track of the corresponding user preference. */
-  private shortcutsDisabled = false;
+  // visible for testing
+  shortcutsDisabled = false;
 
-  constructor(readonly reporting?: ReportingService) {
-    for (const section of config.keys()) {
-      const items = config.get(section) ?? [];
+  private readonly keydownListener: (e: KeyboardEvent) => void;
+
+  private readonly subscriptions: Subscription[] = [];
+
+  private readonly config: Map<ShortcutSection, ShortcutHelpItem[]>;
+
+  constructor(
+    readonly userModel: UserModel,
+    readonly reporting?: ReportingService
+  ) {
+    this.config = createShortcutConfig();
+    for (const section of this.config.keys()) {
+      const items = this.config.get(section) ?? [];
       for (const item of items) {
         this.bindings.set(item.shortcut, item.bindings);
       }
     }
-    disableShortcuts$.subscribe(x => (this.shortcutsDisabled = x));
-    document.addEventListener('keydown', (e: KeyboardEvent) => {
+    this.subscriptions.push(
+      this.userModel.preferences$
+        .pipe(
+          map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
+          distinctUntilChanged()
+        )
+        .subscribe(x => (this.shortcutsDisabled = x))
+    );
+    this.keydownListener = (e: KeyboardEvent) => {
       if (!isComboKey(e.key)) return;
-      if (this.shouldSuppress(e)) return;
+      if (this.shortcutsDisabled || shouldSuppress(e)) return;
       this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()};
-    });
+    };
+    document.addEventListener('keydown', this.keydownListener);
+  }
+
+  finalize() {
+    document.removeEventListener('keydown', this.keydownListener);
+    for (const s of this.subscriptions) {
+      s.unsubscribe();
+    }
   }
 
   public _testOnly_isEmpty() {
@@ -134,29 +154,34 @@
   addShortcut(
     element: HTMLElement,
     shortcut: Binding,
-    listener: (e: KeyboardEvent) => void
+    listener: (e: KeyboardEvent) => void,
+    options?: ShortcutOptions
   ) {
+    const optShouldSuppress = options?.shouldSuppress ?? true;
+    const optPreventDefault = options?.preventDefault ?? true;
     const wrappedListener = (e: KeyboardEvent) => {
-      if (e.repeat) return;
+      if (e.repeat && !shortcut.allowRepeat) return;
       if (!eventMatchesShortcut(e, shortcut)) return;
       if (shortcut.combo) {
         if (!this.isInSpecificComboKeyMode(shortcut.combo)) return;
       } else {
         if (this.isInComboKeyMode()) return;
       }
-      if (this.shouldSuppress(e)) return;
-      e.preventDefault();
-      e.stopPropagation();
+      if (optShouldSuppress && shouldSuppress(e)) return;
+      // `shortcutsDisabled` refers to disabling global shortcuts like 'n'. If
+      // `shouldSuppress` is false (e.g.for Ctrl - ENTER), then don't disable
+      // the shortcut.
+      if (optShouldSuppress && this.shortcutsDisabled) return;
+      if (optPreventDefault) e.preventDefault();
+      if (optPreventDefault) e.stopPropagation();
+      this.reportTriggered(e);
       listener(e);
     };
     element.addEventListener('keydown', wrappedListener);
     return () => element.removeEventListener('keydown', wrappedListener);
   }
 
-  shouldSuppress(e: KeyboardEvent) {
-    if (this.shortcutsDisabled) return true;
-    if (shouldSuppress(e)) return true;
-
+  private reportTriggered(e: KeyboardEvent) {
     // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
     let key = `${e.key}:${e.type}`;
     if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
@@ -170,7 +195,6 @@
       from = e.currentTarget.tagName;
     }
     this.reporting?.reportInteraction('shortcut-triggered', {key, from});
-    return false;
   }
 
   createTitle(shortcutName: Shortcut, section: ShortcutSection) {
@@ -183,29 +207,34 @@
     return this.bindings.get(shortcut);
   }
 
-  attachHost(host: HTMLElement, shortcuts: ShortcutListener[]) {
-    this.activeShortcuts.set(
-      host,
-      shortcuts.map(s => s.shortcut)
-    );
+  /**
+   * Looks up bindings for the given shortcut and calls addShortcut() for each
+   * of them. Also adds the shortcut to `activeShortcuts` and thus to the
+   * help page about active shortcuts. Returns a cleanup function for removing
+   * the bindings and the help page entry.
+   */
+  addShortcutListener(
+    shortcut: Shortcut,
+    listener: (e: KeyboardEvent) => void,
+    options?: ShortcutOptions
+  ) {
     const cleanups: (() => void)[] = [];
-    for (const s of shortcuts) {
-      const bindings = this.getBindingsForShortcut(s.shortcut);
-      for (const binding of bindings ?? []) {
-        if (binding.docOnly) continue;
-        cleanups.push(this.addShortcut(document.body, binding, s.listener));
-      }
+    this.activeShortcuts.add(shortcut);
+    cleanups.push(() => {
+      this.activeShortcuts.delete(shortcut);
+      this.notifyViewListeners();
+    });
+    const bindings = this.getBindingsForShortcut(shortcut);
+    for (const binding of bindings ?? []) {
+      if (binding.docOnly) continue;
+      cleanups.push(
+        this.addShortcut(document.body, binding, listener, options)
+      );
     }
-    this.cleanupsPerHost.set(host, cleanups);
     this.notifyViewListeners();
-  }
-
-  detachHost(host: HTMLElement) {
-    this.activeShortcuts.delete(host);
-    const cleanups = this.cleanupsPerHost.get(host);
-    for (const cleanup of cleanups ?? []) cleanup();
-    this.notifyViewListeners();
-    return true;
+    return () => {
+      for (const cleanup of cleanups ?? []) cleanup();
+    };
   }
 
   addListener(listener: ShortcutViewListener) {
@@ -218,7 +247,7 @@
   }
 
   getDescription(section: ShortcutSection, shortcutName: Shortcut) {
-    const bindings = config.get(section);
+    const bindings = this.config.get(section);
     if (!bindings) return '';
     const binding = bindings.find(binding => binding.shortcut === shortcutName);
     return binding?.text ?? '';
@@ -233,20 +262,13 @@
   }
 
   activeShortcutsBySection() {
-    const activeShortcuts = new Set<Shortcut>();
-    for (const shortcuts of this.activeShortcuts.values()) {
-      for (const shortcut of shortcuts) {
-        activeShortcuts.add(shortcut);
-      }
-    }
-
     const activeShortcutsBySection = new Map<
       ShortcutSection,
       ShortcutHelpItem[]
     >();
-    config.forEach((shortcutList, section) => {
+    this.config.forEach((shortcutList, section) => {
       shortcutList.forEach(shortcutHelp => {
-        if (activeShortcuts.has(shortcutHelp.shortcut)) {
+        if (this.activeShortcuts.has(shortcutHelp.shortcut)) {
           if (!activeShortcutsBySection.has(section)) {
             activeShortcutsBySection.set(section, []);
           }
@@ -312,9 +334,7 @@
   describeBindings(shortcut: Shortcut): string[][] | null {
     const bindings = this.bindings.get(shortcut);
     if (!bindings) return null;
-    return bindings
-      .filter(binding => !binding.docOnly)
-      .map(binding => describeBinding(binding));
+    return bindings.map(binding => describeBinding(binding));
   }
 
   notifyViewListeners() {
@@ -346,7 +366,10 @@
   if (binding.combo === ComboKey.V) {
     description.push('v');
   }
-  if (binding.modifiers?.includes(Modifier.SHIFT_KEY)) {
+  if (
+    binding.modifiers?.includes(Modifier.SHIFT_KEY) ||
+    (isCharacterLetter(binding.key) && isUpperCase(binding.key))
+  ) {
     description.push('Shift');
   }
   if (binding.modifiers?.includes(Modifier.ALT_KEY)) {
@@ -358,6 +381,12 @@
   if (binding.modifiers?.includes(Modifier.META_KEY)) {
     description.push('Meta/Cmd');
   }
-  description.push(describeKey(binding.key));
+
+  let key = describeKey(binding.key);
+  if (isCharacterLetter(key)) {
+    key = key.toLowerCase();
+  }
+  description.push(key);
+
   return description;
 }
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 05c4f53..164000a 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -1,109 +1,97 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../test/common-test-setup-karma';
+import '../../test/common-test-setup';
 import {
   COMBO_TIMEOUT_MS,
   describeBinding,
   ShortcutsService,
 } from '../../services/shortcuts/shortcuts-service';
 import {Shortcut, ShortcutSection} from './shortcuts-config';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {SinonFakeTimers} from 'sinon';
-import {Key, Modifier} from '../../utils/dom-util';
+import {SinonFakeTimers, SinonSpy} from 'sinon';
+import {Binding, Key, Modifier} from '../../utils/dom-util';
+import {getAppContext} from '../app-context';
+import {pressKey} from '../../test/test-utils';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {userModelToken} from '../../models/user/user-model';
 
-async function keyEventOn(
-  el: HTMLElement,
-  callback: (e: KeyboardEvent) => void,
-  keyCode = 75,
-  key = 'k'
-): Promise<KeyboardEvent> {
-  let resolve: (e: KeyboardEvent) => void;
-  const promise = new Promise<KeyboardEvent>(r => (resolve = r));
-  el.addEventListener('keydown', (e: KeyboardEvent) => {
-    callback(e);
-    resolve(e);
-  });
-  MockInteractions.keyDownOn(el, keyCode, null, key);
-  return await promise;
-}
+const KEY_A: Binding = {key: 'a'};
 
 suite('shortcuts-service tests', () => {
   let service: ShortcutsService;
 
   setup(() => {
-    service = new ShortcutsService();
-  });
-
-  suite('shouldSuppress', () => {
-    test('do not suppress shortcut event from <div>', async () => {
-      await keyEventOn(document.createElement('div'), e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from <input>', async () => {
-      await keyEventOn(document.createElement('input'), e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from <textarea>', async () => {
-      await keyEventOn(document.createElement('textarea'), e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('do not suppress shortcut event from checkbox <input>', async () => {
-      const inputEl = document.createElement('input');
-      inputEl.setAttribute('type', 'checkbox');
-      await keyEventOn(inputEl, e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress shortcut event from children of <gr-overlay>', async () => {
-      const overlay = document.createElement('gr-overlay');
-      const div = document.createElement('div');
-      overlay.appendChild(div);
-      await keyEventOn(div, e => {
-        assert.isTrue(service.shouldSuppress(e));
-      });
-    });
-
-    test('suppress "enter" shortcut event from <a>', async () => {
-      await keyEventOn(document.createElement('a'), e => {
-        assert.isFalse(service.shouldSuppress(e));
-      });
-      await keyEventOn(
-        document.createElement('a'),
-        e => assert.isTrue(service.shouldSuppress(e)),
-        13,
-        'enter'
-      );
-    });
+    service = new ShortcutsService(
+      testResolver(userModelToken),
+      getAppContext().reportingService
+    );
   });
 
   test('getShortcut', () => {
     assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
-    assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
-    assert.equal(
-      service.getShortcut(Shortcut.SEND_REPLY),
-      'Ctrl+Enter,Meta/Cmd+Enter'
-    );
+    assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'Shift+a');
+  });
+
+  suite('addShortcut()', () => {
+    let el: HTMLElement;
+    let listener: SinonSpy<[KeyboardEvent], void>;
+
+    setup(() => {
+      el = document.createElement('div');
+      listener = sinon.spy() as SinonSpy<[KeyboardEvent], void>;
+    });
+
+    test('standard call', () => {
+      service.addShortcut(el, KEY_A, listener);
+      assert.isTrue(listener.notCalled);
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.calledOnce);
+    });
+
+    test('preventDefault option default false', () => {
+      service.addShortcut(el, KEY_A, listener);
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.calledOnce);
+      assert.isTrue(listener.lastCall.firstArg?.defaultPrevented);
+    });
+
+    test('preventDefault option force false', () => {
+      service.addShortcut(el, KEY_A, listener, {preventDefault: false});
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.calledOnce);
+      assert.isFalse(listener.lastCall.firstArg?.defaultPrevented);
+    });
+
+    test('preventDefault option force true', () => {
+      service.addShortcut(el, KEY_A, listener, {preventDefault: true});
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.calledOnce);
+      assert.isTrue(listener.lastCall.firstArg?.defaultPrevented);
+    });
+
+    test('shouldSuppress option default true', () => {
+      service.shortcutsDisabled = true;
+      service.addShortcut(el, KEY_A, listener);
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.notCalled);
+    });
+
+    test('shouldSuppress option force true', () => {
+      service.shortcutsDisabled = true;
+      service.addShortcut(el, KEY_A, listener, {shouldSuppress: true});
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.notCalled);
+    });
+
+    test('shouldSuppress option force false', () => {
+      service.shortcutsDisabled = true;
+      service.addShortcut(el, KEY_A, listener, {shouldSuppress: false});
+      pressKey(el, KEY_A.key);
+      assert.isTrue(listener.calledOnce);
+    });
   });
 
   suite('binding descriptions', () => {
@@ -192,9 +180,7 @@
     test('active shortcuts by section', () => {
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {});
 
-      service.attachHost(document.createElement('div'), [
-        {shortcut: Shortcut.NEXT_FILE, listener: _ => {}},
-      ]);
+      service.addShortcutListener(Shortcut.NEXT_FILE, _ => {});
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
         [ShortcutSection.NAVIGATION]: [
           {
@@ -204,16 +190,16 @@
           },
         ],
       });
-
-      service.attachHost(document.createElement('div'), [
-        {shortcut: Shortcut.NEXT_LINE, listener: _ => {}},
-      ]);
+      service.addShortcutListener(Shortcut.NEXT_LINE, _ => {});
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
         [ShortcutSection.DIFFS]: [
           {
             shortcut: Shortcut.NEXT_LINE,
             text: 'Go to next line',
-            bindings: [{key: 'j'}, {key: 'ArrowDown'}],
+            bindings: [
+              {allowRepeat: true, key: 'j'},
+              {allowRepeat: true, key: 'ArrowDown'},
+            ],
           },
         ],
         [ShortcutSection.NAVIGATION]: [
@@ -225,16 +211,17 @@
         ],
       });
 
-      service.attachHost(document.createElement('div'), [
-        {shortcut: Shortcut.SEARCH, listener: _ => {}},
-        {shortcut: Shortcut.GO_TO_OPENED_CHANGES, listener: _ => {}},
-      ]);
+      service.addShortcutListener(Shortcut.SEARCH, _ => {});
+      service.addShortcutListener(Shortcut.GO_TO_OPENED_CHANGES, _ => {});
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
         [ShortcutSection.DIFFS]: [
           {
             shortcut: Shortcut.NEXT_LINE,
             text: 'Go to next line',
-            bindings: [{key: 'j'}, {key: 'ArrowDown'}],
+            bindings: [
+              {allowRepeat: true, key: 'j'},
+              {allowRepeat: true, key: 'ArrowDown'},
+            ],
           },
         ],
         [ShortcutSection.EVERYWHERE]: [
@@ -262,13 +249,11 @@
     test('directory view', () => {
       assert.deepEqual(mapToObject(service.directoryView()), {});
 
-      service.attachHost(document.createElement('div'), [
-        {shortcut: Shortcut.GO_TO_OPENED_CHANGES, listener: _ => {}},
-        {shortcut: Shortcut.NEXT_FILE, listener: _ => {}},
-        {shortcut: Shortcut.NEXT_LINE, listener: _ => {}},
-        {shortcut: Shortcut.SAVE_COMMENT, listener: _ => {}},
-        {shortcut: Shortcut.SEARCH, listener: _ => {}},
-      ]);
+      service.addShortcutListener(Shortcut.GO_TO_OPENED_CHANGES, _ => {});
+      service.addShortcutListener(Shortcut.NEXT_FILE, _ => {});
+      service.addShortcutListener(Shortcut.NEXT_LINE, _ => {});
+      service.addShortcutListener(Shortcut.SAVE_COMMENT, _ => {});
+      service.addShortcutListener(Shortcut.SEARCH, _ => {});
       assert.deepEqual(mapToObject(service.directoryView()), {
         [ShortcutSection.DIFFS]: [
           {binding: [['j'], ['↓']], text: 'Go to next line'},
diff --git a/polygerrit-ui/app/services/storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage.ts
index 08a3387..d7eb09a 100644
--- a/polygerrit-ui/app/services/storage/gr-storage.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage.ts
@@ -1,49 +1,22 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {CommentRange, PatchSetNum} from '../../types/common';
-
-export interface StorageLocation {
-  changeNum: number;
-  patchNum: PatchSetNum | '@change';
-  path?: string;
-  line?: number;
-  range?: CommentRange;
-}
+import {NumericChangeId} from '../../types/common';
+import {Finalizable} from '../registry';
 
 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;
-
+export interface StorageService extends Finalizable {
   getEditableContentItem(key: string): StorageObject | null;
 
   setEditableContentItem(key: string, message: string): void;
 
-  getRespectfulTipVisibility(): StorageObject | null;
-
-  setRespectfulTipVisibility(delayDays?: number): void;
-
   eraseEditableContentItem(key: string): void;
+
+  eraseEditableContentItemsForChangeEdit(changeNum?: NumericChangeId): void;
 }
diff --git a/polygerrit-ui/app/services/storage/gr-storage_impl.ts b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
index 0c0d151..7a47e0e 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_impl.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
@@ -1,21 +1,12 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {StorageLocation, StorageObject, StorageService} from './gr-storage';
+import {StorageObject, StorageService} from './gr-storage';
+import {Finalizable} from '../registry';
+import {NumericChangeId} from '../../types/common';
+import {define} from '../../models/dependency';
 
 export const DURATION_DAY = 24 * 60 * 60 * 1000;
 
@@ -23,31 +14,21 @@
 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 {
+export const storageServiceToken = define<StorageService>('storage-service');
+
+export class GrStorageService implements StorageService, Finalizable {
   private lastCleanup = 0;
 
-  private readonly storage = window.localStorage;
+  // visible for testing
+  storage = window.localStorage;
 
-  private exceededQuota = false;
+  // visible for testing
+  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);
-  }
+  finalize() {}
 
   getEditableContentItem(key: string): StorageObject | null {
     this.cleanupItems();
@@ -61,45 +42,37 @@
     });
   }
 
-  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;
+  /**
+   * Deletes all keys for cached edits.
+   *
+   * @param changeNum
+   */
+  eraseEditableContentItemsForChangeEdit(changeNum?: NumericChangeId) {
+    if (!changeNum) return;
+
+    // Fetch all keys and then match them up to the keys we want.
+    for (const key of Object.keys(this.storage)) {
+      // Only delete the value that starts with editablecontent:c${changeNum}_ps
+      // to prevent deleting unrelated keys.
+      if (key.startsWith(`editablecontent:c${changeNum}_ps`)) {
+        // We have to remove editablecontent: from the string as it is
+        // automatically added to the string within the storage.
+        this.eraseEditableContentItem(key.replace('editablecontent:', ''));
+      }
     }
-    return key;
   }
 
-  private getEditableContentKey(key: string): string {
+  // visible for testing
+  getEditableContentKey(key: string): string {
     return `editablecontent:${key}`;
   }
 
-  private cleanupItems() {
+  // visible for testing
+  cleanupItems() {
     // Throttle cleanup to the throttle interval.
     if (
       this.lastCleanup &&
@@ -136,16 +109,17 @@
     }
     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;
+    } catch (exc: unknown) {
+      if (exc instanceof DOMException) {
+        // 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;
+        }
       }
+      throw exc;
     }
   }
 }
diff --git a/polygerrit-ui/app/services/storage/gr-storage_mock.ts b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
index 399ffe4..822fef2 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_mock.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
@@ -1,43 +1,13 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {StorageLocation, StorageObject, StorageService} from './gr-storage';
-import {DURATION_DAY} from './gr-storage_impl';
+import {NumericChangeId} from '../../types/common';
+import {StorageObject, StorageService} from './gr-storage';
 
 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() {
@@ -45,19 +15,7 @@
 }
 
 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);
-  },
+  finalize(): void {},
 
   getEditableContentItem(key: string): StorageObject | null {
     return storage.get(getEditableContentKey(key)) ?? null;
@@ -70,17 +28,15 @@
     });
   },
 
-  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));
   },
+
+  eraseEditableContentItemsForChangeEdit(changeNum?: NumericChangeId): void {
+    for (const key of Array.from(storage.keys())) {
+      if (key.startsWith(`editablecontent:c${changeNum}_ps`)) {
+        this.eraseEditableContentItem(key.replace('editablecontent:', ''));
+      }
+    }
+  },
 };
diff --git a/polygerrit-ui/app/services/storage/gr-storage_test.js b/polygerrit-ui/app/services/storage/gr-storage_test.js
deleted file mode 100644
index 6cbfacf..0000000
--- a/polygerrit-ui/app/services/storage/gr-storage_test.js
+++ /dev/null
@@ -1,176 +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 {GrStorageService} from './gr-storage_impl.js';
-
-suite('gr-storage tests', () => {
-  let grStorage;
-
-  function mockStorage(opt_quotaExceeded) {
-    return {
-      getItem(key) { return this[key]; },
-      removeItem(key) { delete this[key]; },
-      setItem(key, value) {
-        // eslint-disable-next-line no-throw-literal
-        if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
-        this[key] = value;
-      },
-    };
-  }
-
-  setup(() => {
-    grStorage = new GrStorageService();
-    grStorage.storage = mockStorage();
-  });
-
-  test('storing, retrieving and erasing drafts', () => {
-    const changeNum = 1234;
-    const patchNum = 5;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-
-    // The key is in the expected format.
-    const key = grStorage.getDraftKey(location);
-    assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
-
-    // There should be no draft initially.
-    const draft = grStorage.getDraftComment(location);
-    assert.isNotOk(draft);
-
-    // Setting the draft stores it under the expected key.
-    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(grStorage.storage.getItem(key)).updated);
-
-    // Erasing the draft removes the key.
-    grStorage.eraseDraftComment(location);
-    assert.isNotOk(grStorage.storage.getItem(key));
-  });
-
-  test('automatically removes old drafts', () => {
-    const changeNum = 1234;
-    const patchNum = 5;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-
-    const key = grStorage.getDraftKey(location);
-
-    // Make sure that the call to cleanup doesn't get throttled.
-    grStorage.lastCleanup = 0;
-
-    const cleanupSpy = sinon.spy(grStorage, 'cleanupItems');
-
-    // Create a message with a timestamp that is a second behind the max age.
-    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 = grStorage.getDraftComment(location);
-
-    assert.isTrue(cleanupSpy.called);
-    assert.isNotOk(draft);
-    assert.isNotOk(grStorage.storage.getItem(key));
-  });
-
-  test('getDraftKey', () => {
-    const changeNum = 1234;
-    const patchNum = 5;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-    let expectedResult = 'draft:1234:5:my_source_file.js:123';
-    assert.equal(grStorage.getDraftKey(location), expectedResult);
-    location.range = {
-      start_character: 1,
-      start_line: 1,
-      end_character: 1,
-      end_line: 2,
-    };
-    expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
-    assert.equal(grStorage.getDraftKey(location), expectedResult);
-  });
-
-  test('exceeded quota disables storage', () => {
-    grStorage.storage = mockStorage(true);
-    assert.isFalse(grStorage.exceededQuota);
-
-    const changeNum = 1234;
-    const patchNum = 5;
-    const path = 'my_source_file.js';
-    const line = 123;
-    const location = {
-      changeNum,
-      patchNum,
-      path,
-      line,
-    };
-    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(grStorage, 'cleanupItems');
-    const key = 'testKey';
-    const computedKey = grStorage.getEditableContentKey(key);
-    // Key correctly computed.
-    assert.equal(computedKey, 'editablecontent:testKey');
-
-    grStorage.setEditableContentItem(key, 'my content');
-
-    // Setting the draft stores it under the expected key.
-    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 = grStorage.getEditableContentItem(key);
-    assert.isOk(item);
-    assert.equal(item.message, 'my content');
-    assert.isOk(item.updated);
-    assert.isTrue(cleanupStub.called);
-
-    // eraseEditableContentItem performs as expected.
-    grStorage.eraseEditableContentItem(key);
-    assert.isNotOk(grStorage.storage.getItem(computedKey));
-  });
-});
-
diff --git a/polygerrit-ui/app/services/storage/gr-storage_test.ts b/polygerrit-ui/app/services/storage/gr-storage_test.ts
new file mode 100644
index 0000000..b79a8d7
--- /dev/null
+++ b/polygerrit-ui/app/services/storage/gr-storage_test.ts
@@ -0,0 +1,114 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {NumericChangeId} from '../../api/rest-api';
+import '../../test/common-test-setup';
+import {GrStorageService} from './gr-storage_impl';
+
+suite('gr-storage tests', () => {
+  let grStorage: GrStorageService;
+
+  function mockStorage(opt_quotaExceeded: boolean): Storage {
+    return {
+      getItem(key: string) {
+        return (this as any)[key];
+      },
+      removeItem(key: string) {
+        delete (this as any)[key];
+      },
+      setItem(key: string, value: string) {
+        if (opt_quotaExceeded) {
+          throw new DOMException('error', 'QuotaExceededError');
+        }
+        (this as any)[key] = value;
+      },
+    } as Storage;
+  }
+
+  setup(() => {
+    grStorage = new GrStorageService();
+    grStorage.storage = mockStorage(false);
+  });
+
+  test('exceeded quota disables storage', () => {
+    grStorage.storage = mockStorage(true);
+    assert.isFalse(grStorage.exceededQuota);
+
+    const key = grStorage.getEditableContentKey('test-key');
+    grStorage.setEditableContentItem(key, 'test message');
+    assert.isTrue(grStorage.exceededQuota);
+    assert.isNotOk(grStorage.storage.getItem(key));
+  });
+
+  test('editable content items', () => {
+    const cleanupStub = sinon.stub(grStorage, 'cleanupItems');
+    const key = 'testKey';
+    const computedKey = grStorage.getEditableContentKey(key);
+    // Key correctly computed.
+    assert.equal(computedKey, 'editablecontent:testKey');
+
+    grStorage.setEditableContentItem(key, 'my content');
+
+    // Setting the draft stores it under the expected key.
+    const 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.
+    const obj = grStorage.getEditableContentItem(key);
+    assert.isOk(obj);
+    assert.equal(obj!.message, 'my content');
+    assert.isOk(obj!.updated);
+    assert.isTrue(cleanupStub.called);
+
+    // eraseEditableContentItem performs as expected.
+    grStorage.eraseEditableContentItem(key);
+    assert.isNotOk(grStorage.storage.getItem(computedKey));
+  });
+
+  test('editable content items eraseEditableContentItemsForChangeEdit', () => {
+    grStorage.setEditableContentItem('testKey', 'my content');
+    grStorage.setEditableContentItem(
+      'c50_psedit_index.php',
+      'my content test 1'
+    );
+    grStorage.setEditableContentItem('c50_ps3_index.php', 'my content test 2');
+
+    const item = grStorage.storage.getItem(
+      'editablecontent:c50_psedit_index.php'
+    );
+    assert.isOk(item);
+    assert.equal(JSON.parse(item!).message, 'my content test 1');
+    assert.isOk(JSON.parse(item!).updated);
+
+    // We have to add getItem, removeItem and setItem to the array.
+    // Typically these functions don't get outputed in .storage,
+    // but we're mocking the storage so they are being outputed.
+    // This doesn't invalidate the test.
+    assert.deepEqual(Object.keys(grStorage.storage), [
+      'getItem',
+      'removeItem',
+      'setItem',
+      'editablecontent:testKey',
+      'editablecontent:c50_psedit_index.php',
+      'editablecontent:c50_ps3_index.php',
+    ]);
+
+    grStorage.eraseEditableContentItemsForChangeEdit(50 as NumericChangeId);
+
+    // We have to add getItem, removeItem and setItem to the array.
+    // Typically these functions don't get outputed in .storage,
+    // but we're mocking the storage so they are being outputed.
+    // This doesn't invalidate the test.
+    assert.deepEqual(Object.keys(grStorage.storage), [
+      'getItem',
+      'removeItem',
+      'setItem',
+      'editablecontent:testKey',
+    ]);
+  });
+});
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
deleted file mode 100644
index df307d6..0000000
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
-import {BehaviorSubject, Observable} from 'rxjs';
-import {map, distinctUntilChanged} from 'rxjs/operators';
-import {
-  createDefaultPreferences,
-  createDefaultDiffPrefs,
-} from '../../constants/constants';
-import {DiffPreferencesInfo, DiffViewMode} from '../../api/diff';
-
-interface UserState {
-  /**
-   * Keeps being defined even when credentials have expired.
-   */
-  account?: AccountDetailInfo;
-  preferences: PreferencesInfo;
-  diffPreferences: DiffPreferencesInfo;
-}
-
-const initialState: UserState = {
-  preferences: createDefaultPreferences(),
-  diffPreferences: createDefaultDiffPrefs(),
-};
-
-const privateState$ = new BehaviorSubject(initialState);
-
-export function _testOnly_resetState() {
-  // We cannot assign a new subject to privateState$, because all the selectors
-  // have already subscribed to the original subject. So we have to emit the
-  // initial state on the existing subject.
-  privateState$.next({...initialState});
-}
-
-export function _testOnly_setState(state: UserState) {
-  privateState$.next(state);
-}
-
-export function _testOnly_getState() {
-  return privateState$.getValue();
-}
-
-// Re-exporting as Observable so that you can only subscribe, but not emit.
-export const userState$: Observable<UserState> = privateState$;
-
-export function updateAccount(account?: AccountDetailInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, account});
-}
-
-export function updatePreferences(preferences: PreferencesInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, preferences});
-}
-
-export function updateDiffPreferences(diffPreferences: DiffPreferencesInfo) {
-  const current = privateState$.getValue();
-  privateState$.next({...current, diffPreferences});
-}
-
-export const account$ = userState$.pipe(
-  map(userState => userState.account),
-  distinctUntilChanged()
-);
-
-export const preferences$ = userState$.pipe(
-  map(userState => userState.preferences),
-  distinctUntilChanged()
-);
-
-export const diffPreferences$ = userState$.pipe(
-  map(userState => userState.diffPreferences),
-  distinctUntilChanged()
-);
-
-export const preferenceDiffViewMode$ = preferences$.pipe(
-  map(preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE),
-  distinctUntilChanged()
-);
-
-export const myTopMenuItems$ = preferences$.pipe(
-  map(preferences => preferences?.my ?? []),
-  distinctUntilChanged()
-);
-
-export const sizeBarInChangeTable$ = preferences$.pipe(
-  map(prefs => !!prefs?.size_bar_in_change_table),
-  distinctUntilChanged()
-);
-
-export const disableShortcuts$ = preferences$.pipe(
-  map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
-  distinctUntilChanged()
-);
diff --git a/polygerrit-ui/app/services/user/user-service.ts b/polygerrit-ui/app/services/user/user-service.ts
deleted file mode 100644
index d08da8b..0000000
--- a/polygerrit-ui/app/services/user/user-service.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
-import {from, of} from 'rxjs';
-import {
-  account$,
-  updateAccount,
-  updatePreferences,
-  updateDiffPreferences,
-} from './user-model';
-import {switchMap} from 'rxjs/operators';
-import {
-  createDefaultPreferences,
-  createDefaultDiffPrefs,
-} from '../../constants/constants';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {DiffPreferencesInfo} from '../../types/diff';
-
-export class UserService {
-  constructor(readonly restApiService: RestApiService) {
-    from(this.restApiService.getAccount()).subscribe(
-      (account?: AccountDetailInfo) => {
-        updateAccount(account);
-      }
-    );
-    account$
-      .pipe(
-        switchMap(account => {
-          if (!account) return of(createDefaultPreferences());
-          return from(this.restApiService.getPreferences());
-        })
-      )
-      .subscribe((preferences?: PreferencesInfo) => {
-        updatePreferences(preferences ?? createDefaultPreferences());
-      });
-    account$
-      .pipe(
-        switchMap(account => {
-          if (!account) return of(createDefaultDiffPrefs());
-          return from(this.restApiService.getDiffPreferences());
-        })
-      )
-      .subscribe((diffPrefs?: DiffPreferencesInfo) => {
-        updateDiffPreferences(diffPrefs ?? createDefaultDiffPrefs());
-      });
-  }
-
-  updatePreferences(prefs: Partial<PreferencesInfo>) {
-    this.restApiService
-      .savePreferences(prefs)
-      .then((newPrefs: PreferencesInfo | undefined) => {
-        if (!newPrefs) return;
-        updatePreferences(newPrefs);
-      });
-  }
-
-  updateDiffPreference(diffPrefs: DiffPreferencesInfo) {
-    return this.restApiService
-      .saveDiffPreferences(diffPrefs)
-      .then((response: Response) => {
-        this.restApiService.getResponseObject(response).then(obj => {
-          const newPrefs = obj as unknown as DiffPreferencesInfo;
-          if (!newPrefs) return;
-          updateDiffPreferences(newPrefs);
-        });
-      });
-  }
-
-  getDiffPreferences() {
-    return this.restApiService.getDiffPreferences().then(prefs => {
-      if (!prefs) return;
-      updateDiffPreferences(prefs);
-    });
-  }
-}
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts
index 643a76a..d9edb99 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.ts
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -1,27 +1,10 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {css} from 'lit';
 
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
-
 const $_documentContainer = document.createElement('template');
 
 export const dashboardHeaderStyles = css`
@@ -47,8 +30,7 @@
   .info > div > span {
     display: inline-block;
     font-weight: var(--font-weight-bold);
-    text-align: right;
-    width: 4em;
+    width: 3.5em;
   }
 `;
 
diff --git a/polygerrit-ui/app/styles/gr-a11y-styles.ts b/polygerrit-ui/app/styles/gr-a11y-styles.ts
index a1fa62b..ccf8b50 100644
--- a/polygerrit-ui/app/styles/gr-a11y-styles.ts
+++ b/polygerrit-ui/app/styles/gr-a11y-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index e0a7a28..3763213 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -1,204 +1,179 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {css} from 'lit';
 
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+export const changeListStyles = css`
+  gr-change-list-item {
+    border-top: 1px solid var(--border-color);
+  }
+  gr-change-list-item[selected],
+  gr-change-list-item:focus {
+    background-color: var(--selection-background-color);
+  }
+  gr-change-list-item[highlight] {
+    background-color: var(--assignee-highlight-color);
+  }
+  gr-change-list-item[highlight][selected],
+  gr-change-list-item[highlight]:focus {
+    background-color: var(--assignee-highlight-selection-color);
+  }
+  .groupTitle td,
+  .cell {
+    vertical-align: middle;
+  }
+  .groupTitle td:not(.label):not(.endpoint):not(.star),
+  .cell:not(.label):not(.endpoint):not(.star) {
+    padding-right: 8px;
+  }
+  .groupTitle td {
+    color: var(--deemphasized-text-color);
+    text-align: left;
+  }
+  .groupHeader {
+    background-color: transparent;
+    font-size: var(--font-size-h3);
+    font-weight: var(--font-weight-h3);
+    line-height: var(--line-height-h3);
+  }
+  .groupContent {
+    background-color: var(--background-color-primary);
+    box-shadow: var(--elevation-level-1);
+  }
+  .groupHeader a {
+    color: var(--primary-text-color);
+    text-decoration: none;
+  }
+  .groupHeader a:hover {
+    text-decoration: underline;
+  }
+  .groupTitle td,
+  .cell {
+    padding: var(--spacing-s) 0;
+  }
+  .groupHeader .cell {
+    padding-top: var(--spacing-l);
+  }
+  .star {
+    padding: 0 var(--spacing-s) 0 0;
+  }
+  .owner {
+    --account-max-length: 100px;
+  }
+  .branch,
+  .star,
+  .label,
+  .number,
+  .owner,
+  .updated,
+  .submitted,
+  .waiting,
+  .size,
+  .status,
+  .repo {
+    white-space: nowrap;
+  }
+  .leftPadding {
+    width: var(--spacing-l);
+  }
+  .reviewers div {
+    overflow: hidden;
+  }
+  .label,
+  .endpoint {
+    border-left: 1px solid var(--border-color);
+  }
+  .groupTitle td.label,
+  .label {
+    text-align: center;
+    width: 3rem;
+  }
+  .truncatedRepo {
+    display: none;
+  }
+  @media only screen and (max-width: 150em) {
+    .branch {
+      overflow: hidden;
+      max-width: 18rem;
+      text-overflow: ellipsis;
+    }
+    .truncatedRepo {
+      display: inline-block;
+    }
+    .fullRepo {
+      display: none;
+    }
+  }
+  @media only screen and (max-width: 100em) {
+    .branch {
+      max-width: 10rem;
+    }
+  }
+  @media only screen and (max-width: 50em) {
+    :host {
+      font-family: var(--header-font-family);
+      font-size: var(--font-size-h3);
+      font-weight: var(--font-weight-h3);
+      line-height: var(--line-height-h3);
+    }
+    gr-change-list-item {
+      flex-wrap: wrap;
+      justify-content: space-between;
+      padding: var(--spacing-xs) var(--spacing-m);
+    }
+    gr-change-list-item[selected],
+    gr-change-list-item:focus {
+      background-color: var(--view-background-color);
+      border: none;
+      border-top: 1px solid var(--border-color);
+    }
+    gr-change-list-item:hover {
+      background-color: var(--view-background-color);
+    }
+    .cell {
+      align-items: center;
+      display: flex;
+    }
+    .groupTitle,
+    .leftPadding,
+    .status,
+    .repo,
+    .branch,
+    .updated,
+    .submitted,
+    .waiting,
+    .label,
+    .groupHeader .star,
+    .noChanges .star {
+      display: none;
+    }
+    .groupHeader .cell,
+    .noChanges .cell {
+      padding-left: var(--spacing-m);
+    }
+    .subject {
+      margin-bottom: var(--spacing-xs);
+      width: calc(100% - 2em);
+    }
+    .owner,
+    .size {
+      max-width: none;
+    }
+    .noChanges .cell {
+      display: block;
+      height: auto;
+    }
+  }
+`;
 
 const $_documentContainer = document.createElement('template');
-
 $_documentContainer.innerHTML = `<dom-module id="gr-change-list-styles">
   <template>
     <style>
-      gr-change-list-item {
-        border-top: 1px solid var(--border-color);
-      }
-      gr-change-list-item[selected],
-      gr-change-list-item:focus {
-        background-color: var(--selection-background-color);
-      }
-      gr-change-list-item[highlight] {
-        background-color: var(--assignee-highlight-color);
-      }
-      gr-change-list-item[highlight][selected],
-      gr-change-list-item[highlight]:focus {
-        background-color: var(--assignee-highlight-selection-color);
-      }
-      .groupTitle td,
-      .cell {
-        vertical-align: middle;
-      }
-      .groupTitle td:not(.label):not(.endpoint),
-      .cell:not(.label):not(.endpoint) {
-        padding-right: 8px;
-      }
-      .groupTitle td {
-        color: var(--deemphasized-text-color);
-        text-align: left;
-      }
-      .groupHeader {
-        background-color: transparent;
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      .groupContent {
-        background-color: var(--background-color-primary);
-        box-shadow: var(--elevation-level-1);
-      }
-      .groupHeader a {
-        color: var(--primary-text-color);
-        text-decoration: none;
-      }
-      .groupHeader a:hover {
-        text-decoration: underline;
-      }
-      .groupTitle td,
-      .cell {
-        padding: var(--spacing-s) 0;
-      }
-      .groupHeader .cell {
-        padding-top: var(--spacing-l);
-      }
-      .star {
-        padding: 0;
-      }
-      gr-change-star {
-        vertical-align: middle;
-      }
-      .owner {
-        --account-max-length: 100px;
-      }
-      .branch,
-      .star,
-      .label,
-      .number,
-      .owner,
-      .assignee,
-      .updated,
-      .submitted,
-      .waiting,
-      .size,
-      .status,
-      .repo {
-        white-space: nowrap;
-      }
-      .star {
-        vertical-align: middle;
-      }
-      .leftPadding {
-        width: var(--spacing-l);
-      }
-      .star {
-        width: 30px;
-      }
-      .reviewers div {
-        overflow: hidden;
-      }
-      .label, .endpoint {
-        border-left: 1px solid var(--border-color);
-      }
-      .groupTitle td.label,
-      .label {
-        text-align: center;
-        width: 3rem;
-      }
-      .truncatedRepo {
-        display: none;
-      }
-      @media only screen and (max-width: 150em) {
-        .assignee,
-        .branch {
-          overflow: hidden;
-          max-width: 18rem;
-          text-overflow: ellipsis;
-        }
-        .truncatedRepo {
-          display: inline-block;
-        }
-        .fullRepo {
-          display: none;
-        }
-      }
-      @media only screen and (max-width: 100em) {
-        .assignee,
-        .branch {
-          max-width: 10rem;
-        }
-      }
-      @media only screen and (max-width: 50em) {
-        :host {
-          font-family: var(--header-font-family);
-          font-size: var(--font-size-h3);
-          font-weight: var(--font-weight-h3);
-          line-height: var(--line-height-h3);
-        }
-        gr-change-list-item {
-          flex-wrap: wrap;
-          justify-content: space-between;
-          padding: var(--spacing-xs) var(--spacing-m);
-        }
-        gr-change-list-item[selected],
-        gr-change-list-item:focus {
-          background-color: var(--view-background-color);
-          border: none;
-          border-top: 1px solid var(--border-color);
-        }
-        gr-change-list-item:hover {
-          background-color: var(--view-background-color);
-        }
-        .cell {
-          align-items: center;
-          display: flex;
-        }
-        .groupTitle,
-        .leftPadding,
-        .status,
-        .repo,
-        .branch,
-        .updated,
-        .submitted,
-        .waiting,
-        .label,
-        .assignee,
-        .groupHeader .star,
-        .noChanges .star {
-          display: none;
-        }
-        .groupHeader .cell,
-        .noChanges .cell {
-          padding-left: var(--spacing-m);
-        }
-        .subject {
-          margin-bottom: var(--spacing-xs);
-          width: calc(100% - 2em);
-        }
-        .owner,
-        .size {
-          max-width: none;
-        }
-        .noChanges .cell {
-          display: block;
-          height: auto;
-        }
-      }
+    ${changeListStyles.cssText}
     </style>
   </template>
 </dom-module>`;
-
 document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
index 67a6963..33aa2d7 100644
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
@@ -1,57 +1,45 @@
 /**
  * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // Mark the file as a module. Otherwise typescript assumes this is a script
 // and $_documentContainer is a global variable.
 // See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+import {css} from 'lit';
+
+export const changeMetadataStyles = css`
+  section {
+    display: table-row;
+  }
+
+  section:not(:first-of-type) .title,
+  section:not(:first-of-type) .value {
+    padding-top: var(--spacing-s);
+  }
+
+  .title,
+  .value {
+    display: table-cell;
+    vertical-align: top;
+  }
+
+  .title {
+    color: var(--deemphasized-text-color);
+    max-width: 20em;
+    padding-left: var(--metadata-horizontal-padding);
+    padding-right: var(--metadata-horizontal-padding);
+    word-break: break-word;
+  }
+`;
 
 const $_documentContainer = document.createElement('template');
-
 $_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-shared-styles">
   <template>
-    <style include="shared-styles">
-      /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-    </style>
     <style>
-      section {
-        display: table-row;
-      }
-
-      section:not(:first-of-type) .title,
-      section:not(:first-of-type) .value {
-        padding-top: var(--spacing-s);
-      }
-
-      .title,
-      .value {
-        display: table-cell;
-        vertical-align: top;
-      }
-
-      .title {
-        color: var(--deemphasized-text-color);
-        max-width: 20em;
-        padding-left: var(--metadata-horizontal-padding);
-        padding-right: var(--metadata-horizontal-padding);
-        word-break: break-word;
-      }
+    ${changeMetadataStyles.cssText}
     </style>
   </template>
 </dom-module>`;
-
 document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
index 145f0d5..67ee146 100644
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // Mark the file as a module. Otherwise typescript assumes this is a script
diff --git a/polygerrit-ui/app/styles/gr-font-styles.ts b/polygerrit-ui/app/styles/gr-font-styles.ts
index 422a7c5..a816f96 100644
--- a/polygerrit-ui/app/styles/gr-font-styles.ts
+++ b/polygerrit-ui/app/styles/gr-font-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
@@ -45,6 +34,12 @@
     font-weight: var(--font-weight-h3);
     line-height: var(--line-height-h3);
   }
+  .heading-4 {
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-normal);
+    font-weight: var(--font-weight-h4);
+    line-height: var(--line-height-normal);
+  }
   strong {
     font-weight: var(--font-weight-bold);
   }
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
index 34a6936..120b0bd 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.ts
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
@@ -20,6 +9,7 @@
   .gr-form-styles input {
     background-color: var(--view-background-color);
     color: var(--primary-text-color);
+    font: inherit;
   }
   .gr-form-styles select {
     background-color: var(--select-background-color);
diff --git a/polygerrit-ui/app/styles/gr-hovercard-styles.ts b/polygerrit-ui/app/styles/gr-hovercard-styles.ts
index f214a9c..78eb3e6 100644
--- a/polygerrit-ui/app/styles/gr-hovercard-styles.ts
+++ b/polygerrit-ui/app/styles/gr-hovercard-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-icon-styles.ts b/polygerrit-ui/app/styles/gr-icon-styles.ts
new file mode 100644
index 0000000..9865825
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-icon-styles.ts
@@ -0,0 +1,15 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const iconStyles = css`
+  iron-icon {
+    display: inline-block;
+    vertical-align: top;
+    width: 20px;
+    height: 20px;
+  }
+`;
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
index 5f58571..17b7461 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
@@ -43,7 +32,7 @@
     color: var(--deemphasized-text-color);
     padding: var(--spacing-l);
   }
-  @media only screen and (max-width: 67em) {
+  @media only screen and (max-width: 70em) {
     .main {
       margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
     }
diff --git a/polygerrit-ui/app/styles/gr-modal-styles.ts b/polygerrit-ui/app/styles/gr-modal-styles.ts
new file mode 100644
index 0000000..b1bcf51
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-modal-styles.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const modalStyles = css`
+  dialog {
+    padding: 0;
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    background: var(--dialog-background-color);
+    box-shadow: var(--elevation-level-5);
+    /*
+     * These styles are taken from main.css
+     * Dialog exists in the top-layer outside the body hence the styles
+     * in main.css were not being applied.
+     */
+    font-family: var(--font-family, ''), 'Roboto', Arial, sans-serif;
+    font-size: var(--font-size-normal, 1rem);
+    line-height: var(--line-height-normal, 1.4);
+    color: var(--primary-text-color, black);
+  }
+
+  dialog::backdrop {
+    background-color: black;
+    opacity: var(--modal-opacity, 0.6);
+  }
+`;
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index f928848..b6e8f60 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -1,27 +1,10 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {css} from 'lit';
 
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
-
 const $_documentContainer = document.createElement('template');
 
 export const pageNavStyles = css`
diff --git a/polygerrit-ui/app/styles/gr-paper-styles.ts b/polygerrit-ui/app/styles/gr-paper-styles.ts
index 1ef7124..301c02d 100644
--- a/polygerrit-ui/app/styles/gr-paper-styles.ts
+++ b/polygerrit-ui/app/styles/gr-paper-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
@@ -21,6 +10,8 @@
     --paper-toggle-button-checked-bar-color: var(--link-color);
     --paper-toggle-button-checked-button-color: var(--link-color);
   }
+  /* prettier formatter removes semi-colons after css mixins. */
+  /* prettier-ignore */
   paper-tabs {
     font-size: var(--font-size-h3);
     font-weight: var(--font-weight-h3);
@@ -28,20 +19,20 @@
     --paper-font-common-base: {
       font-family: var(--header-font-family);
       -webkit-font-smoothing: initial;
-    }
+    };
     --paper-tab-content: {
       margin-bottom: var(--spacing-s);
-    }
+    };
     --paper-tab-content-focused: {
       /* paper-tabs uses 700 here, which can look awkward */
       font-weight: var(--font-weight-h3);
       background: var(--gray-background-focus);
-    }
+    };
     --paper-tab-content-unselected: {
       /* paper-tabs uses 0.8 here, but we want to control the color directly */
       opacity: 1;
       color: var(--deemphasized-text-color);
-    }
+    };
   }
   paper-tab:focus {
     padding-left: 0px;
diff --git a/polygerrit-ui/app/styles/gr-spinner-styles.ts b/polygerrit-ui/app/styles/gr-spinner-styles.ts
index 6015be4..1c7adc7 100644
--- a/polygerrit-ui/app/styles/gr-spinner-styles.ts
+++ b/polygerrit-ui/app/styles/gr-spinner-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts b/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
new file mode 100644
index 0000000..948e9b0
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-submit-requirements-styles.ts
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const submitRequirementsStyles = css`
+  gr-icon.check_circle,
+  gr-icon.published_with_changes {
+    color: var(--success-foreground);
+  }
+  gr-icon.block,
+  gr-icon.error {
+    color: var(--deemphasized-text-color);
+  }
+  gr-icon.cancel {
+    color: var(--error-foreground);
+  }
+`;
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.ts b/polygerrit-ui/app/styles/gr-subpage-styles.ts
index e426a7d..4408900 100644
--- a/polygerrit-ui/app/styles/gr-subpage-styles.ts
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
diff --git a/polygerrit-ui/app/styles/gr-table-styles.ts b/polygerrit-ui/app/styles/gr-table-styles.ts
index 6871499..ff757cf 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.ts
+++ b/polygerrit-ui/app/styles/gr-table-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
@@ -93,7 +82,7 @@
     text-decoration: underline;
   }
   .genericList .description {
-    width: 99%;
+    width: var(--generic-list-description-width, 99%);
   }
   .genericList .loadingMsg {
     color: var(--deemphasized-text-color);
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
index a623d99..e905941 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.ts
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // Mark the file as a module. Otherwise typescript assumes this is a script
diff --git a/polygerrit-ui/app/styles/material-icons.css b/polygerrit-ui/app/styles/material-icons.css
new file mode 100644
index 0000000..4c0313c
--- /dev/null
+++ b/polygerrit-ui/app/styles/material-icons.css
@@ -0,0 +1,12 @@
+/**
+ * This file has been produced by downloading this file on Sep 6, 2022:
+ * https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0
+ * The corresponding ttf file was downloaded on Sep 6, 2022 from:
+ * https://fonts.gstatic.com/s/materialsymbolsoutlined/v51/kJF4BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJU22ZZLsYEpzC_1ver5Y0J1Llf.woff2
+ */
+@font-face {
+  font-family: 'Material Symbols Outlined';
+  font-style: normal;
+  font-weight: 100 700;
+  src: url(../fonts/material-icons.woff2) format('woff2');
+}
\ No newline at end of file
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index e99cf27..5a7ca48 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {css} from 'lit';
 
@@ -123,6 +112,8 @@
     margin: 0;
     padding: var(--spacing-s);
   }
+  /* prettier formatter removes semi-colons after css mixins. */
+  /* prettier-ignore */
   iron-autogrow-textarea {
     background-color: inherit;
     color: var(--primary-text-color);
@@ -133,11 +124,8 @@
     /* 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);
-    }
+    --iron-autogrow-textarea_-_box-sizing: border-box;
+    --iron-autogrow-textarea_-_padding: var(--spacing-s);
   }
   a {
     color: var(--link-color);
@@ -189,10 +177,6 @@
   .separator.transparent {
     border-color: transparent;
   }
-  iron-autogrow-textarea {
-    /** This is needed for firefox */
-    --iron-autogrow-textarea_-_white-space: pre-wrap;
-  }
 
   /**
    * TODO: Remove these rules and change (plugin) users to rely on
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 8b00a79..6dbda3e 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -1,43 +1,21 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {safeStyleSheet, safeStyleEl} from '../../utils/inner-html-util';
 
-// 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
-import {
-  createStyle,
-  safeStyleSheet,
-  setInnerHtml,
-} from '../../utils/inner-html-util';
-
-const customStyle = document.createElement('custom-style');
-customStyle.setAttribute('id', 'light-theme');
-
-const styleSheet = safeStyleSheet`
+const appThemeCss = safeStyleSheet`
   html {
     /**
-     * When adding a new color variable make sure to also add it to the other
-     * theme files in the same directory.
-     *
-     * For colors prefer lower case hex colors.
-     *
-     * Note that plugins might be using these variables, so removing a variable
-     * can be a breaking change that should go into the release notes.
-     */
+       * When adding a new color variable make sure to also add it to the other
+       * theme files in the same directory.
+       *
+       * For colors prefer lower case hex colors.
+       *
+       * Note that plugins might be using these variables, so removing a variable
+       * can be a breaking change that should go into the release notes.
+       */
 
     /* color palette */
     --gerrit-blue-light: #1565c0;
@@ -61,6 +39,8 @@
     --blue-700-16: #1967d229;
     --blue-700-24: #1967d23d;
     --blue-400: #669df6;
+    --blue-300: #8ab4f8;
+    --blue-300-24: #8ab4f83D;
     --blue-200: #aecbfa;
     --blue-200-16: #aecbfa29;
     --blue-200-24: #aecbfa3d;
@@ -68,10 +48,13 @@
     --blue-50: #e8f0fe;
     --blue-tonal: #314972;
     --orange-900: #b06000;
+    --orange-800: #c26401;
     --orange-700: #d56e0c;
     --orange-700-04: #d56e0c0a;
     --orange-700-10: #d56e0c1a;
     --orange-700-12: #d56e0c1f;
+    --orange-400: #fa903e;
+    --orange-300: #fcad70;
     --orange-200: #fdc69c;
     --orange-50: #feefe3;
     --orange-tonal: #714625;
@@ -100,6 +83,7 @@
     --gray-700-10: #5f63681a;
     --gray-700-12: #5f63681f;
     --gray-500: #9aa0a6;
+    --gray-400: #bdc1c6;
     --gray-300: #dadce0;
     --gray-200: #e8eaed;
     --gray-200-12: #e8eaed1f;
@@ -112,8 +96,11 @@
     --purple-500: #a142f4;
     --purple-400: #af5cf7;
     --purple-200: #d7aefb;
+    --purple-100: #e9d2fd;
     --purple-50: #f3e8fd;
     --purple-tonal: #523272;
+    --deep-purple-800: #4527a0;
+    --deep-purple-600: #5e35b1;
     --pink-800: #b80672;
     --pink-500: #f538a0;
     --pink-50: #fde7f3;
@@ -126,22 +113,50 @@
     --white-10: #ffffff1a;
     --white-12: #ffffff1f;
 
+    --modal-opacity: 0.32;
+
     --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-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);
 
+    --code-review-warning-background: var(--blue-50);
+
     --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-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-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;
@@ -151,17 +166,34 @@
 
     --selected-foreground: var(--blue-800);
     --selected-background: var(--blue-50);
+    --selected-chip-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-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-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);
@@ -172,6 +204,12 @@
     --tag-background: var(--cyan-100);
     --label-background: var(--red-50);
 
+    --not-working-hours-icon-background-color: var(--purple-50);
+    --not-working-hours-icon-color: var(--purple-700);
+    --unavailability-icon-color: var(--gray-700);
+    --unavailability-chip-icon-color: var(--orange-900);
+    --unavailability-chip-background-color: var(--yellow-50);
+
     /* text colors */
     --primary-text-color: var(--gray-900);
     --link-color: var(--gerrit-blue-light);
@@ -188,7 +226,7 @@
     --tooltip-button-text-color: var(--gerrit-blue-dark);
     --negative-red-text-color: var(--red-600);
     --positive-green-text-color: var(--green-700);
-    --indirect-ancestor-text-color: var(--green-700);
+    --indirect-relation-text-color: var(--green-700);
 
     /* background colors */
     /* primary background colors */
@@ -203,7 +241,9 @@
     --expanded-background-color: var(--background-color-tertiary);
     --select-background-color: var(--background-color-secondary);
     --shell-command-background-color: var(--background-color-secondary);
-    --shell-command-decoration-background-color: var(--background-color-tertiary);
+    --shell-command-decoration-background-color: var(
+      --background-color-tertiary
+    );
     --table-header-background-color: var(--background-color-secondary);
     --table-subheader-background-color: var(--background-color-tertiary);
     --view-background-color: var(--background-color-primary);
@@ -220,6 +260,16 @@
     --selection-background-color: rgba(161, 194, 250, 0.1);
     --tooltip-background-color: var(--gray-900);
 
+    /* dashboard size background colors */
+    --dashboard-size-xs: var(--gray-200);
+    --dashboard-size-s: var(--gray-300);
+    --dashboard-size-m: var(--gray-400);
+    --dashboard-size-l: var(--gray-500);
+    --dashboard-size-xl: var(--gray-700);
+    --dashboard-size-text: black;
+    --dashboard-size-xs-text: black;
+    --dashboard-size-xl-text: white;
+
     /* comment background colors */
     --comment-background-color: var(--gray-200);
     --robot-comment-background-color: var(--blue-50);
@@ -234,11 +284,22 @@
     --vote-outline-recommended: var(--green-700);
     --vote-color-rejected: var(--red-300);
 
+    /* vote chip background colors */
+    --vote-chip-unselected-outline-color: var(--gray-500);
+    --vote-chip-unselected-color: white;
+    --vote-chip-selected-positive-color: var(--green-300);
+    --vote-chip-selected-neutral-color: var(--gray-300);
+    --vote-chip-selected-negative-color: var(--red-300);
+    --vote-chip-unselected-text-color: black;
+    --vote-chip-selected-text-color: black;
+
     --outline-color-focus: var(--gray-900);
 
     /* misc colors */
     --border-color: var(--gray-300);
+    --input-focus-border-color: var(--blue-800);
     --comment-separator-color: var(--gray-300);
+    --comment-quote-marker-color: var(--gray-500);
 
     /* checks tag colors */
     --tag-gray: var(--gray-200);
@@ -260,35 +321,47 @@
     --status-custom: var(--purple-900);
 
     /* file status colors */
+    --file-status-font-color: black;
     --file-status-added: var(--green-300);
-    --file-status-changed: var(--red-200);
-    --file-status-unchanged: var(--grey-300);
+    --file-status-deleted: var(--red-200);
+    --file-status-modified: var(--gray-300);
+    --file-status-renamed: var(--orange-300);
+    --file-status-unchanged: var(--gray-300);
+    --file-status-reverted: var(--gray-300);
 
     /* fonts */
-    --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-    --header-font-family: 'Open Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-    --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, monospace;
-    --font-size-code: 12px;     /* 12px mono */
-    --font-size-mono: .929rem;  /* 13px mono */
-    --font-size-small: .857rem; /* 12px */
-    --font-size-normal: 1rem;   /* 14px */
-    --font-size-h3: 1.143rem;   /* 16px */
-    --font-size-h2: 1.429rem;   /* 20px */
-    --font-size-h1: 1.714rem;   /* 24px */
-    --line-height-mono: 1.286rem;   /* 18px */
-    --line-height-small: 1.143rem;  /* 16px */
+    --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI',
+      Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
+      'Segoe UI Symbol';
+    --header-font-family: 'Open Sans', 'Roboto', -apple-system,
+      BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif,
+      'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+    --monospace-font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco,
+      monospace;
+    --font-size-code: 12px; /* 12px mono */
+    --font-size-mono: 0.929rem; /* 13px mono */
+    --font-size-small: 0.857rem; /* 12px */
+    --font-size-normal: 1rem; /* 14px */
+    --font-size-h3: 1.143rem; /* 16px */
+    --font-size-h2: 1.429rem; /* 20px */
+    --font-size-h1: 1.714rem; /* 24px */
+    --line-height-mono: 1.286rem; /* 18px */
+    --line-height-small: 1.143rem; /* 16px */
     --line-height-normal: 1.429rem; /* 20px */
-    --line-height-h3: 1.715rem;     /* 24px */
-    --line-height-h2: 2rem;         /* 28px */
-    --line-height-h1: 2.286rem;     /* 32px */
+    --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' */
     --font-weight-bold: 500;
     --font-weight-h1: 400;
     --font-weight-h2: 400;
-    --font-weight-h3: var(--font-weight-bold, 500);
-    --context-control-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
+    --font-weight-h3: 400;
+    --font-weight-h4: 600;
+    --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);
+    --image-diff-button-font: var(--font-weight-normal) var(--font-size-normal)
+      var(--font-family);
 
     /* spacing */
     --spacing-xxs: 1px;
@@ -315,9 +388,22 @@
 
     /* diff colors */
     --dark-add-highlight-color: #aaf2aa;
-    --dark-rebased-add-highlight-color: #d7d7f9;
-    --dark-rebased-remove-highlight-color: #f7e8b7;
+    --light-add-highlight-color: #d8fed8;
     --dark-remove-highlight-color: #ffcdd2;
+    --light-remove-highlight-color: #ffebee;
+
+    --dark-rebased-add-highlight-color: #d7d7f9;
+    --light-rebased-add-highlight-color: #eef;
+    --dark-rebased-remove-highlight-color: #f7e8b7;
+    --light-rebased-remove-highlight-color: #fff8dc;
+
+    --diff-moved-in-background: var(--cyan-50);
+    --diff-moved-in-label-color: var(--cyan-900);
+    --diff-moved-in-changed-background: var(--cyan-50);
+    --diff-moved-in-changed-label-color: var(--cyan-900);
+    --diff-moved-out-background: var(--purple-50);
+    --diff-moved-out-label-color: var(--purple-900);
+
     --diff-blank-background-color: var(--background-color-secondary);
     --diff-context-control-background-color: #fff7d4;
     --diff-context-control-border-color: #f6e6a5;
@@ -327,14 +413,8 @@
     --diff-selection-background-color: #c7dbf9;
     --diff-tab-indicator-color: var(--deemphasized-text-color);
     --diff-trailing-whitespace-indicator: #ff9ad2;
-    --light-add-highlight-color: #d8fed8;
-    --light-rebased-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;
+    --focused-line-outline-color: var(--blue-700);
+    --coverage-covered-line-num-color: var(--deemphasized-text-color);
     --coverage-covered: #e0f2f1;
     --coverage-not-covered: #ffd1a4;
     --ranged-comment-hint-text-color: var(--orange-900);
@@ -344,9 +424,15 @@
     --syntax-attr-color: #219;
     --syntax-attribute-color: var(--primary-text-color);
     --syntax-built_in-color: #30a;
+    --syntax-bullet-color: var(--syntax-keyword-color);
+    --syntax-code-color: var(--syntax-literal-color);
     --syntax-comment-color: #3f7f5f;
     --syntax-default-color: var(--primary-text-color);
     --syntax-doctag-weight: bold;
+    --syntax-emphasis-color: var(--primary-text-color);
+    --syntax-emphasis-style: italic;
+    --syntax-emphasis-weight: normal;
+    --syntax-formula-color: var(--syntax-regexp-color);
     --syntax-function-color: var(--primary-text-color);
     --syntax-keyword-color: #9e0069;
     --syntax-link-color: #219;
@@ -355,48 +441,53 @@
     --syntax-meta-keyword-color: #219;
     --syntax-number-color: #164;
     --syntax-params-color: var(--primary-text-color);
+    --syntax-property-color: var(--primary-text-color);
+    --syntax-quote-color: var(--primary-text-color);
     --syntax-regexp-color: #fa8602;
+    --syntax-section-color: var(--syntax-keyword-color);
+    --syntax-section-style: normal;
+    --syntax-section-weight: bold;
     --syntax-selector-attr-color: #fa8602;
     --syntax-selector-class-color: #164;
     --syntax-selector-id-color: #2a00ff;
-    --syntax-property-color: #fa8602;
     --syntax-selector-pseudo-color: #fa8602;
     --syntax-string-color: #2a00ff;
+    --syntax-strong-color: var(--primary-text-color);
+    --syntax-strong-style: normal;
+    --syntax-strong-weight: bold;
     --syntax-tag-color: #170;
     --syntax-template-tag-color: #fa8602;
     --syntax-template-variable-color: #0000c0;
     --syntax-title-color: #0000c0;
+    --syntax-title-function-color: var(--syntax-title-color);
     --syntax-type-color: var(--blue-700);
     --syntax-variable-color: var(--primary-text-color);
+    --syntax-variable-language-color: var(--syntax-built_in-color);
 
     /* elevation */
-    --elevation-level-1: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 1px 3px 1px rgba(60, 64, 67, .15);
-    --elevation-level-2: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 2px 6px 2px rgba(60, 64, 67, .15);
-    --elevation-level-3: 0px 1px 3px 0px rgba(60, 64, 67, .30), 0px 4px 8px 3px rgba(60, 64, 67, .15);
-    --elevation-level-4: 0px 2px 3px 0px rgba(60, 64, 67, .30), 0px 6px 10px 4px rgba(60, 64, 67, .15);
-    --elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, .30), 0px 8px 12px 6px rgba(60, 64, 67, .15);
+    --elevation-level-1: 0px 1px 2px 0px rgba(60, 64, 67, 0.3),
+      0px 1px 3px 1px rgba(60, 64, 67, 0.15);
+    --elevation-level-2: 0px 1px 2px 0px rgba(60, 64, 67, 0.3),
+      0px 2px 6px 2px rgba(60, 64, 67, 0.15);
+    --elevation-level-3: 0px 1px 3px 0px rgba(60, 64, 67, 0.3),
+      0px 4px 8px 3px rgba(60, 64, 67, 0.15);
+    --elevation-level-4: 0px 2px 3px 0px rgba(60, 64, 67, 0.3),
+      0px 6px 10px 4px rgba(60, 64, 67, 0.15);
+    --elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, 0.3),
+      0px 8px 12px 6px rgba(60, 64, 67, 0.15);
 
     /* misc */
     --border-radius: 4px;
-    --reply-overlay-z-index: 1000;
     --line-length-indicator-color: #681da8;
 
-    /* paper and iron component overrides */
-    --iron-overlay-backdrop-background-color: black;
-    --iron-overlay-backdrop-opacity: 0.32;
-    --iron-overlay-backdrop: {
-      transition: none;
-    };
+    /* paper component overrides */
     --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-opacity: 1;
     --paper-tooltip-text-color: var(--tooltip-text-color);
-    --paper-tooltip: {
-      font-size: var(--font-size-small);
-    }
   }
   @media screen and (max-width: 50em) {
     html {
@@ -408,8 +499,29 @@
       --spacing-xl: 12px;
       --spacing-xxl: 16px;
     }
-  }`;
+  }
+`;
 
-setInnerHtml(customStyle, createStyle(styleSheet));
+const styleEl = document.createElement('style');
+styleEl.setAttribute('id', 'light-theme');
+safeStyleEl.setTextContent(styleEl, appThemeCss);
+document.head.appendChild(styleEl);
 
-document.head.appendChild(customStyle);
+// TODO: The following can be removed when Paper and Iron components have been
+// removed from Gerrit.
+
+const appThemeCssPolymerLegacy = safeStyleSheet`
+  /* prettier formatter removes semi-colons after css mixins. */
+  /* prettier-ignore */
+  html {
+    --paper-tooltip: {
+      font-size: var(--font-size-small);
+    };
+  }
+`;
+
+const customStyleEl = document.createElement('custom-style');
+const innerStyleEl = document.createElement('style');
+safeStyleEl.setTextContent(innerStyleEl, appThemeCssPolymerLegacy);
+customStyleEl.appendChild(innerStyleEl);
+document.head.appendChild(customStyleEl);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 05c6b7d..c6884a6 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -1,33 +1,17 @@
 /**
  * @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.
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {safeStyleSheet, safeStyleEl} from '../../utils/inner-html-util';
 
-import {
-  createStyle,
-  safeStyleSheet,
-  setInnerHtml,
-} from '../../utils/inner-html-util';
-
-function getStyleEl() {
-  const customStyle = document.createElement('custom-style');
-  customStyle.setAttribute('id', 'dark-theme');
-
-  const styleSheet = safeStyleSheet`
-    html {
-      /**
+// TODO: Replace `html` with `html.darkTheme`. But before we can do that we have
+// to ensure that all plugins also use `.darkTheme`, otherwise we would trump
+// their sepcificity here. When we do that we can also always execute
+// applyTheme() below (similar to app-theme).
+const darkThemeCss = safeStyleSheet`
+  html {
+    /**
        * Sections and variables must stay consistent with app-theme.js.
        *
        * Only modify color variables in this theme file. dark-theme extends
@@ -36,216 +20,287 @@
        * you probably want to override all.
        */
 
-      --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);
+    --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);
+    --code-review-warning-background: var(--blue-tonal);
 
-      --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);
+    --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);
 
-      --primary-button-text-color: black;
-      --primary-button-background-color: var(--gerrit-blue-dark);
-      --primary-button-background-hover: var(--blue-200-16);
-      --primary-button-background-focus: var(--blue-200-24);
+    --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);
 
-      --selected-foreground: var(--blue-200);
-      --selected-background: var(--blue-900);
+    --primary-button-text-color: black;
+    --primary-button-background-color: var(--gerrit-blue-dark);
+    --primary-button-background-hover: var(--blue-200-16);
+    --primary-button-background-focus: var(--blue-200-24);
 
-      --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);
+    --selected-foreground: var(--blue-200);
+    --selected-background: var(--blue-900);
+    --selected-chip-background: var(--blue-300-24);
 
-      --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);
+    --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);
 
-      --disabled-foreground: var(--gray-200-38);
-      --disabled-background: var(--gray-200-12);
+    --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);
 
-      --chip-color: var(--gray-100);
-      --error-color: var(--red-200);
-      --tag-background: var(--cyan-900);
-      --label-background: var(--red-900);
+    --disabled-foreground: var(--gray-200-38);
+    --disabled-background: var(--gray-200-12);
 
-      /* 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: var(--gray-900);
-      --tooltip-button-text-color: var(--gerrit-blue-light);
-      --negative-red-text-color: var(--red-200);
-      --positive-green-text-color: var(--green-200);
-      --indirect-ancestor-text-color: var(--green-200);
+    --chip-color: var(--gray-100);
+    --error-color: var(--red-200);
+    --tag-background: var(--cyan-900);
+    --label-background: var(--red-900);
 
-      /* background colors */
-      /* primary background colors */
-      --background-color-primary: var(--gray-900);
-      --background-color-secondary: #2f3034;
-      --background-color-tertiary: var(--gray-800);
-      /* directly derived from primary background colors */
-      /*   empty, because inheriting from app-theme is just fine
+    --not-working-hours-icon-background-color: var(--purple-tonal);
+    --not-working-hours-icon-color: var(--purple-100);
+    --unavailability-icon-color: var(--gray-500);
+    --unavailability-chip-icon-color: var(--orange-700);
+    --unavailability-chip-background-color: var(--orange-tonal);
+
+    /* 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-400);
+    --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: var(--gray-900);
+    --tooltip-button-text-color: var(--gerrit-blue-light);
+    --negative-red-text-color: var(--red-200);
+    --positive-green-text-color: var(--green-200);
+    --indirect-relation-text-color: var(--green-200);
+
+    /* background colors */
+    /* primary background colors */
+    --background-color-primary: var(--gray-900);
+    --background-color-secondary: #2f3034;
+    --background-color-tertiary: var(--gray-800);
+    /* directly derived from primary background colors */
+    /*   empty, because inheriting from app-theme is just fine
       /* unique background colors */
-      --assignee-highlight-color: #3a361c;
-      --assignee-highlight-selection-color: #423e24;
-      --chip-selected-background-color: #3c4455;
-      --edit-mode-background-color: #5c0a36;
-      --emphasis-color: #383f4a;
-      --hover-background-color: rgba(161, 194, 250, 0.2);
-      --disabled-button-background-color: #484a4d;
-      --selection-background-color: rgba(161, 194, 250, 0.1);
-      --tooltip-background-color: var(--gray-200);
+    --assignee-highlight-color: #3a361c;
+    --assignee-highlight-selection-color: #423e24;
+    --chip-selected-background-color: #3c4455;
+    --edit-mode-background-color: #5c0a36;
+    --emphasis-color: #383f4a;
+    --hover-background-color: rgba(161, 194, 250, 0.2);
+    --disabled-button-background-color: #484a4d;
+    --selection-background-color: rgba(161, 194, 250, 0.1);
+    --tooltip-background-color: var(--gray-200);
 
-      /* comment background colors */
-      --comment-background-color: #3c3f43;
-      --robot-comment-background-color: #1e3a5f;
-      --unresolved-comment-background-color: #614a19;
+    /* comment background colors */
+    --comment-background-color: #3c3f43;
+    --robot-comment-background-color: #1e3a5f;
+    --unresolved-comment-background-color: #614a19;
 
-      /* vote background colors */
-      --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);
+    /* vote background colors */
+    --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);
+    /* vote chip background colors */
+    --vote-chip-unselected-outline-color: var(--gray-500);
+    --vote-chip-unselected-color: var(--grey-800);
+    --vote-chip-selected-positive-color: var(--green-200);
+    --vote-chip-selected-neutral-color: var(--gray-300);
+    --vote-chip-selected-negative-color: var(--red-200);
+    --vote-chip-unselected-text-color: white;
+    --vote-chip-selected-text-color: black;
 
-      /* misc colors */
-      --border-color: var(--gray-700);
-      --comment-separator-color: var(--border-color);
+    --outline-color-focus: var(--gray-100);
 
-      /* 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);
+    /* misc colors */
+    --border-color: var(--gray-700);
+    --input-focus-border-color: var(--blue-200);
+    --comment-separator-color: var(--border-color);
 
-      /* status colors */
-      --status-merged: var(--green-400);
-      --status-abandoned: var(--gray-300);
-      --status-wip: #bcaaa4;
-      --status-private: var(--purple-200);
-      --status-conflict: var(--red-300);
-      --status-revert-created: #ff8a65;
-      --status-active: var(--blue-400);
-      --status-ready: var(--pink-500);
-      --status-custom: var(--purple-400);
+    /* 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);
 
-      /* file status colors */
-      --file-status-added: var(--green-tonal);
-      --file-status-changed: var(--red-tonal);
-      --file-status-unchanged: var(--grey-700);
+    /* status colors */
+    --status-merged: var(--green-400);
+    --status-abandoned: var(--gray-300);
+    --status-wip: #bcaaa4;
+    --status-private: var(--purple-200);
+    --status-conflict: var(--red-300);
+    --status-revert-created: #ff8a65;
+    --status-active: var(--blue-400);
+    --status-ready: var(--pink-500);
+    --status-custom: var(--purple-400);
 
-      /* fonts */
-      --font-weight-bold: 700; /* 700 is the same as 'bold' */
+    /* file status colors */
+    --file-status-added: var(--green-400);
+    --file-status-deleted: var(--red-300);
+    --file-status-modified: var(--gray-500);
+    --file-status-renamed: var(--orange-400);
+    --file-status-unchanged: var(--gray-500);
+    --file-status-reverted: var(--gray-500);
 
-      /* spacing */
+    /* fonts */
+    --font-weight-bold: 700; /* 700 is the same as 'bold' */
 
-      /* header and footer */
-      --footer-background-color: var(--background-color-tertiary);
-      --footer-border-top: 1px solid var(--border-color);
-      --header-background-color: var(--background-color-tertiary);
-      --header-border-bottom: 1px solid var(--border-color);
-      --header-padding: 0 var(--spacing-l);
-      --header-text-color: var(--primary-text-color);
+    /* spacing */
 
-      /* diff colors */
-      --dark-add-highlight-color: var(--green-tonal); 
-      --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
-      --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
-      --dark-remove-highlight-color: #62110f;
-      --diff-blank-background-color: var(--background-color-secondary);
-      --diff-context-control-background-color: #333311;
-      --diff-context-control-border-color: var(--border-color);
-      --diff-context-control-color: var(--deemphasized-text-color);
-      --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
-      --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
-      --diff-selection-background-color: #3a71d8;
-      --diff-tab-indicator-color: var(--deemphasized-text-color);
-      --diff-trailing-whitespace-indicator: #ff9ad2;
-      --light-add-highlight-color: #182b1f;
-      --light-rebased-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);
-      --token-highlighting-color: var(--yellow-tonal);
+    /* header and footer */
+    --footer-background-color: var(--background-color-tertiary);
+    --footer-border-top: 1px solid var(--border-color);
+    --header-background-color: var(--background-color-tertiary);
+    --header-border-bottom: 1px solid var(--border-color);
+    --header-padding: 0 var(--spacing-l);
+    --header-text-color: var(--primary-text-color);
 
-      /* syntax colors */
-      --syntax-attr-color: #80cbbf;
-      --syntax-attribute-color: var(--primary-text-color);
-      --syntax-built_in-color: #f7c369;
-      --syntax-comment-color: var(--deemphasized-text-color);
-      --syntax-default-color: var(--primary-text-color);
-      --syntax-doctag-weight: bold;
-      --syntax-function-color: var(--primary-text-color);
-      --syntax-keyword-color: #cd4cf0;
-      --syntax-link-color: #c792ea;
-      --syntax-literal-color: #eefff7;
-      --syntax-meta-color: #6d7eee;
-      --syntax-meta-keyword-color: #eefff7;
-      --syntax-number-color: #00998a;
-      --syntax-params-color: var(--primary-text-color);
-      --syntax-regexp-color: #f77669;
-      --syntax-selector-attr-color: #80cbbf;
-      --syntax-selector-class-color: #ffcb68;
-      --syntax-selector-id-color: #f77669;
-      --syntax-selector-pseudo-color: #c792ea;
-      --syntax-property-color: #c792ea;
-      --syntax-string-color: #c3e88d;
-      --syntax-tag-color: #f77669;
-      --syntax-template-tag-color: #c792ea;
-      --syntax-template-variable-color: #f77669;
-      --syntax-title-color: #75a5ff;
-      --syntax-type-color: #dd5f5f;
-      --syntax-variable-color: #f77669;
+    /* dashboard size background colors */
+    --dashboard-size-xs: var(--gray-700);
+    --dashboard-size-s: var(--gray-500);
+    --dashboard-size-m: var(--gray-400);
+    --dashboard-size-l: var(--gray-300);
+    --dashboard-size-xl: var(--gray-200);
+    --dashboard-size-text: black;
+    --dashboard-size-xs-text: white;
+    --dashboard-size-xl-text: black;
 
-      /* misc */
-      --line-length-indicator-color: #d7aefb;
+    /* diff colors */
+    --dark-add-highlight-color: var(--green-tonal);
+    --light-add-highlight-color: #182b1f;
+    --dark-remove-highlight-color: #62110f;
+    --light-remove-highlight-color: #320404;
 
-      /* paper and iron component overrides */
-      --iron-overlay-backdrop-background-color: white;
+    --dark-rebased-add-highlight-color: var(--deep-purple-800);
+    --light-rebased-add-highlight-color: var(--deep-purple-600);
+    --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
+    --light-rebased-remove-highlight-color: #2f3f2f;
 
-      /* rules applied to html */
-      background-color: var(--view-background-color);
-    }
-  `;
+    --diff-moved-in-background: #1d4042;
+    --diff-moved-in-label-color: var(--cyan-50);
+    --diff-moved-in-changed-background: #1d4042;
+    --diff-moved-in-changed-label-color: var(--cyan-50);
+    --diff-moved-out-background: #230e34;
+    --diff-moved-out-label-color: var(--purple-50);
 
-  setInnerHtml(customStyle, createStyle(styleSheet));
-  return customStyle;
-}
+    --diff-blank-background-color: var(--background-color-secondary);
+    --diff-context-control-background-color: #333311;
+    --diff-context-control-border-color: var(--border-color);
+    --diff-context-control-color: var(--deemphasized-text-color);
+    --diff-highlight-range-color: rgba(0, 100, 200, 0.5);
+    --diff-highlight-range-hover-color: rgba(0, 150, 255, 0.5);
+    --diff-selection-background-color: #3a71d8;
+    --diff-tab-indicator-color: var(--deemphasized-text-color);
+    --diff-trailing-whitespace-indicator: #ff9ad2;
+    --focused-line-outline-color: var(--blue-200);
+    --coverage-covered: #37674a;
+    --coverage-covered-line-num-color: var(--gray-200);
+    --coverage-not-covered: #6b3600;
+    --ranged-comment-hint-text-color: var(--blue-50);
+    --token-highlighting-color: var(--yellow-tonal);
+
+    /* syntax colors */
+    --syntax-attr-color: #80cbbf;
+    --syntax-attribute-color: var(--primary-text-color);
+    --syntax-built_in-color: #f7c369;
+    --syntax-comment-color: var(--deemphasized-text-color);
+    --syntax-default-color: var(--primary-text-color);
+    --syntax-doctag-weight: bold;
+    --syntax-function-color: var(--primary-text-color);
+    --syntax-keyword-color: #cd4cf0;
+    --syntax-link-color: #c792ea;
+    --syntax-literal-color: #eefff7;
+    --syntax-meta-color: #6d7eee;
+    --syntax-meta-keyword-color: #eefff7;
+    --syntax-number-color: #00998a;
+    --syntax-params-color: var(--primary-text-color);
+    --syntax-property-color: #c792ea;
+    --syntax-regexp-color: #f77669;
+    --syntax-selector-attr-color: #80cbbf;
+    --syntax-selector-class-color: #ffcb68;
+    --syntax-selector-id-color: #f77669;
+    --syntax-selector-pseudo-color: #c792ea;
+    --syntax-string-color: #c3e88d;
+    --syntax-tag-color: #f77669;
+    --syntax-template-tag-color: #c792ea;
+    --syntax-template-variable-color: #f77669;
+    --syntax-title-color: #75a5ff;
+    --syntax-title-function-color: var(--syntax-title-color);
+    --syntax-type-color: #dd5f5f;
+    --syntax-variable-color: #f77669;
+    --syntax-variable-language-color: var(--syntax-built_in-color);
+
+    /* misc */
+    --line-length-indicator-color: #d7aefb;
+
+    /* rules applied to html */
+    background-color: var(--view-background-color);
+  }
+`;
 
 export function applyTheme() {
-  document.head.appendChild(getStyleEl());
+  if (document.head.querySelector('#dark-theme')) return;
+  const styleEl = document.createElement('style');
+  styleEl.setAttribute('id', 'dark-theme');
+  safeStyleEl.setTextContent(styleEl, darkThemeCss);
+
+  // We would like to insert the dark theme styles after the light theme such
+  // that the dark theme values override the defaults in the light theme. But
+  // OTOH we want to insert before any plugin provided styles, because we do NOT
+  // want to override those.
+  const pluginStyleEl = document.head.querySelector('style#plugin-style');
+  document.head.insertBefore(styleEl, pluginStyleEl);
+}
+
+export function removeTheme() {
+  const styleEl = document.head.querySelector('#dark-theme');
+  styleEl?.remove();
 }
diff --git a/polygerrit-ui/app/test/a11y-test-utils.js b/polygerrit-ui/app/test/a11y-test-utils.js
deleted file mode 100644
index a687e07..0000000
--- a/polygerrit-ui/app/test/a11y-test-utils.js
+++ /dev/null
@@ -1,44 +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 './common-test-setup-karma.js';
-
-// Run a11y audit on test fixture
-// The code is inspired by the
-// https://github.com/Polymer/web-component-tester/blob/master/data/a11ySuite.js
-export async function runA11yAudit(fixture, ignoredRules) {
-  fixture.instantiate();
-  await flush();
-  const axsConfig = new axs.AuditConfiguration();
-  axsConfig.scope = document.body;
-  axsConfig.showUnsupportedRulesWarning = false;
-  axsConfig.auditRulesToIgnore = ignoredRules;
-
-  const auditResults = axs.Audit.run(axsConfig);
-  const errors = [];
-  auditResults.forEach((result, index) => {
-    // only show applicable tests
-    if (result.result === 'FAIL') {
-      const title = result.rule.heading;
-      // fail test if audit result is FAIL
-      const error = axs.Audit.accessibilityErrorMessage(result);
-      errors.push(`${title}: ${error}`);
-    }
-  });
-  if (errors.length > 0) {
-    assert.fail(errors.join('\n') + '\n');
-  }
-}
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.ts b/polygerrit-ui/app/test/common-test-setup-karma.ts
deleted file mode 100644
index 39c79d1..0000000
--- a/polygerrit-ui/app/test/common-test-setup-karma.ts
+++ /dev/null
@@ -1,208 +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 './common-test-setup';
-import '@polymer/test-fixture/test-fixture';
-import 'chai/chai';
-
-declare global {
-  interface Window {
-    flush: typeof flushImpl;
-    fixtureFromTemplate: typeof fixtureFromTemplateImpl;
-    fixtureFromElement: typeof fixtureFromElementImpl;
-  }
-  let flush: typeof flushImpl;
-  let fixtureFromTemplate: typeof fixtureFromTemplateImpl;
-  let fixtureFromElement: typeof fixtureFromElementImpl;
-}
-
-// Workaround for https://github.com/karma-runner/karma-mocha/issues/227
-let unhandledError: ErrorEvent;
-
-window.addEventListener('error', e => {
-  // For uncaught error mochajs doesn't print the full stack trace.
-  // We should print it ourselves.
-  console.error('Uncaught error:');
-  console.error(e.error.stack.toString());
-  unhandledError = e;
-});
-
-let originalOnBeforeUnload: typeof window.onbeforeunload;
-
-suiteSetup(() => {
-  // This suiteSetup() method is called only once before all tests
-
-  // Can't use window.addEventListener("beforeunload",...) here,
-  // the handler is raised too late.
-  originalOnBeforeUnload = window.onbeforeunload;
-  window.onbeforeunload = function (e: BeforeUnloadEvent) {
-    // If a test reloads a page, we can't prevent it.
-    // However we can print an error and the stack trace with assert.fail
-    try {
-      throw new Error();
-    } catch (e) {
-      console.error('Page reloading attempt detected.');
-      console.error(e.stack.toString());
-    }
-    if (originalOnBeforeUnload) {
-      originalOnBeforeUnload.call(window, e);
-    }
-  };
-});
-
-suiteTeardown(() => {
-  // This suiteTeardown() method is called only once after all tests
-  window.onbeforeunload = originalOnBeforeUnload;
-  if (unhandledError) {
-    throw unhandledError;
-  }
-});
-
-// Tests can use fake timers (sandbox.useFakeTimers)
-// Keep the original one for use in test utils methods.
-const nativeSetTimeout = window.setTimeout;
-
-function flushImpl(): Promise<void>;
-function flushImpl(callback: () => void): void;
-/**
- * Triggers a flush of any pending events, observations, etc and calls you back
- * after they have been processed if callback is passed; otherwise returns
- * promise.
- */
-function flushImpl(callback?: () => void): Promise<void> | void {
-  // Ideally, this function would be a call to Polymer.dom.flush, but that
-  // doesn't support a callback yet
-  // (https://github.com/Polymer/polymer-dev/issues/851)
-  // The type is used only in one place, disable eslint warning instead of
-  // creating an interface
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  (window as any).Polymer.dom.flush();
-  if (callback) {
-    nativeSetTimeout(callback, 0);
-  } else {
-    return new Promise(resolve => {
-      nativeSetTimeout(resolve, 0);
-    });
-  }
-}
-
-self.flush = flushImpl;
-
-class TestFixtureIdProvider {
-  public static readonly instance: TestFixtureIdProvider =
-    new TestFixtureIdProvider();
-
-  private fixturesCount = 1;
-
-  generateNewFixtureId() {
-    this.fixturesCount++;
-    return `fixture-${this.fixturesCount}`;
-  }
-}
-
-interface TagTestFixture<T extends Element> {
-  instantiate(model?: unknown): T;
-}
-
-class TestFixture {
-  constructor(readonly fixtureId: string) {}
-
-  /**
-   * Create an instance of a fixture's template.
-   *
-   * @param model - see Data-bound sections at
-   *   https://www.webcomponents.org/element/@polymer/test-fixture
-   * @return - if the fixture's template contains
-   *   a single element, returns the appropriated instantiated element.
-   *   Otherwise, it return an array of all instantiated elements from the
-   *   template.
-   */
-  instantiate(model?: unknown): HTMLElement | HTMLElement[] {
-    // The window.fixture method is defined in common-test-setup.js
-    return window.fixture(this.fixtureId, model);
-  }
-}
-
-/**
- * Wraps provided template to a test-fixture tag and adds test-fixture to
- * the document. You can use the html function to create a template.
- *
- * Example:
- * import {html} from '@polymer/polymer/lib/utils/html-tag.js';
- *
- * // Create fixture at the root level of a test file
- * const basicTestFixture = fixtureFromTemplate(html`
- *   <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
- *   <ul>
- *    <li>A</li>
- *    <li>B</li>
- *    <li>C</li>
- *    <li>D</li>
- *   </ul>
- * `);
- * ...
- * // Instantiate fixture when needed:
- *
- * suite('example') {
- *   let elements;
- *   setup(() => {
- *     elements = basicTestFixture.instantiate();
- *   });
- * }
- *
- * @param template - a template for a fixture
- */
-function fixtureFromTemplateImpl(template: HTMLTemplateElement): TestFixture {
-  const fixtureId = TestFixtureIdProvider.instance.generateNewFixtureId();
-  const testFixture = document.createElement('test-fixture');
-  testFixture.setAttribute('id', fixtureId);
-  testFixture.appendChild(template);
-  document.body.appendChild(testFixture);
-  return new TestFixture(fixtureId);
-}
-
-/**
- * Wraps provided tag to a test-fixture/template tags and adds test-fixture
- * to the document.
- *
- * Example:
- *
- * // Create fixture at the root level of a test file
- * const basicTestFixture = fixtureFromElement('gr-diff-view');
- * ...
- * // Instantiate fixture when needed:
- *
- * suite('example') {
- *   let element;
- *   setup(() => {
- *     element = basicTestFixture.instantiate();
- *   });
- * }
- *
- * @param tagName - a template for a fixture is <tagName></tagName>
- */
-function fixtureFromElementImpl<T extends keyof HTMLElementTagNameMap>(
-  tagName: T
-): TagTestFixture<HTMLElementTagNameMap[T]> {
-  const template = document.createElement('template');
-  template.innerHTML = `<${tagName}></${tagName}>`;
-  return fixtureFromTemplate(template) as unknown as TagTestFixture<
-    HTMLElementTagNameMap[T]
-  >;
-}
-
-window.fixtureFromTemplate = fixtureFromTemplateImpl;
-window.fixtureFromElement = fixtureFromElementImpl;
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 05adb41..aed58d8 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -1,72 +1,52 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-// This should be the first import to install handler before any other code
-import './source-map-support-install';
 // TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
 // https://github.com/Polymer/polymer-resin/issues/9 is resolved.
 import '../scripts/bundled-polymer';
-import '@polymer/iron-test-helpers/iron-test-helpers';
-import './test-router';
-import {_testOnlyInitAppContext} from './test-app-context-init';
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {AppContext} from '../services/app-context';
+import {Finalizable} from '../services/registry';
+import {
+  createTestAppContext,
+  createTestDependencies,
+} from './test-app-context-init';
+import {testOnlyResetGrRestApiSharedObjects} from '../services/gr-rest-api/gr-rest-api-impl';
 import {
   cleanupTestUtils,
   getCleanupsCount,
-  registerTestCleanup,
-  addIronOverlayBackdropStyleEl,
-  removeIronOverlayBackdropStyleEl,
   removeThemeStyles,
 } from './test-utils';
 import {safeTypesBridge} from '../utils/safe-types-util';
-import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
-import {initGlobalVariables} from '../elements/gr-app-global-var-init';
-import 'chai/chai';
+import {
+  initGerrit,
+  initGlobalVariables,
+} from '../elements/gr-app-global-var-init';
+import {assert, fixtureCleanup} from '@open-wc/testing';
 import {
   _testOnly_defaultResinReportHandler,
   installPolymerResin,
 } from '../scripts/polymer-resin-install';
 import {_testOnly_allTasks} from '../utils/async-util';
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
-import {updatePreferences} from '../services/user/user-model';
-import {createDefaultPreferences} from '../constants/constants';
-import {appContext} from '../services/app-context';
-import {_testOnly_resetState as resetBrowserState} from '../services/browser/browser-model';
-import {_testOnly_resetState as resetChangeState} from '../services/change/change-model';
-import {_testOnly_resetState as resetChecksState} from '../services/checks/checks-model';
-import {_testOnly_resetState as resetCommentsState} from '../services/comments/comments-model';
-import {_testOnly_resetState as resetRouterState} from '../services/router/router-model';
-import {_testOnly_resetState as resetUserState} from '../services/user/user-model';
+import {
+  DependencyRequestEvent,
+  DependencyError,
+  DependencyToken,
+  Provider,
+} from '../models/dependency';
+import * as sinon from 'sinon';
+import '../styles/themes/app-theme.ts';
+import {Creator} from '../services/app-context-init';
+import {pluginLoaderToken} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
 
 declare global {
   interface Window {
-    assert: typeof chai.assert;
-    expect: typeof chai.expect;
-    fixture: typeof fixtureImpl;
-    stub: typeof stubImpl;
     sinon: typeof sinon;
   }
-  let assert: typeof chai.assert;
-  let expect: typeof chai.expect;
-  let stub: typeof stubImpl;
   let sinon: typeof sinon;
 }
-window.assert = chai.assert;
-window.expect = chai.expect;
 
 window.sinon = sinon;
 
@@ -74,112 +54,90 @@
   const log = _testOnly_defaultResinReportHandler;
   log(isViolation, fmt, ...args);
   if (isViolation) {
-    // This will cause the test to fail if there is a data binding
-    // violation.
+    // This will cause the test to fail if there is a data binding violation.
     throw new Error('polymer-resin violation: ' + fmt + JSON.stringify(args));
   }
 });
 
-interface TestFixtureElement extends HTMLElement {
-  restore(): void;
-  create(model?: unknown): HTMLElement | HTMLElement[];
-}
-
-function getFixtureElementById(fixtureId: string) {
-  return document.getElementById(fixtureId) as TestFixtureElement;
-}
-
-// For karma always set our implementation
-// (karma doesn't provide the fixture method)
-function fixtureImpl(fixtureId: string, model: unknown) {
-  // This method is inspired by web-component-tester method
-  registerTestCleanup(() => getFixtureElementById(fixtureId).restore());
-  return getFixtureElementById(fixtureId).create(model);
-}
-
-window.fixture = fixtureImpl;
 let testSetupTimestampMs = 0;
+let appContext: AppContext & Finalizable;
+
+const injectedDependencies: Map<
+  DependencyToken<unknown>,
+  Provider<unknown>
+> = new Map();
+
+const finalizers: Finalizable[] = [];
+
+function injectDependency<T>(
+  dependency: DependencyToken<T>,
+  creator: Creator<T>
+) {
+  let service: (T & Finalizable) | undefined = undefined;
+  injectedDependencies.set(dependency, () => {
+    if (service) return service;
+    service = creator();
+    finalizers.push(service);
+    return service;
+  });
+}
+
+export function testResolver<T>(token: DependencyToken<T>): T {
+  const provider = injectedDependencies.get(token);
+  if (provider) {
+    return provider() as T;
+  } else {
+    throw new DependencyError(token, 'Forgot to set up dependency for tests');
+  }
+}
+
+function resolveDependency(evt: DependencyRequestEvent<unknown>) {
+  evt.callback(() => testResolver(evt.dependency));
+}
 
 setup(() => {
   testSetupTimestampMs = new Date().getTime();
-  addIronOverlayBackdropStyleEl();
 
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(getCleanupsCount(), 0);
-  _testOnlyInitAppContext();
-  // The following calls is nessecary to avoid influence of previously executed
+  appContext = createTestAppContext();
+  initGlobalVariables(appContext);
+
+  finalizers.push(appContext);
+  const dependencies = createTestDependencies(appContext, testResolver);
+  for (const [token, provider] of dependencies) {
+    injectDependency(token, provider);
+  }
+  document.addEventListener('request-dependency', resolveDependency);
+  initGerrit(testResolver(pluginLoaderToken));
+
+  // The following calls is necessary to avoid influence of previously executed
   // tests.
-  initGlobalVariables();
-  _testOnly_initGerritPluginApi();
-
-  resetBrowserState();
-  resetChangeState();
-  resetChecksState();
-  resetCommentsState();
-  resetRouterState();
-  resetUserState();
-
-  const shortcuts = appContext.shortcutsService;
-  assert.isTrue(shortcuts._testOnly_isEmpty());
   const selection = document.getSelection();
   if (selection) {
     selection.removeAllRanges();
   }
-  const pl = _testOnly_resetPluginLoader();
   // For testing, always init with empty plugin list
   // Since when serve in gr-app, we always retrieve the list
   // from project config and init loading after that, all
   // `awaitPluginsLoaded` will rely on that to kick off,
   // in testing, we want to kick start this earlier.
-  // You still can manually call _testOnly_resetPluginLoader
-  // to reset this behavior if you need to test something specific.
-  pl.loadPlugins([]);
-  _testOnlyResetGrRestApiSharedObjects();
+  testResolver(pluginLoaderToken).loadPlugins([]);
+  testOnlyResetGrRestApiSharedObjects(appContext.authService);
 });
 
-// For karma always set our implementation
-// (karma doesn't provide the stub method)
-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];
-  const stub = sinon.stub(proto, method);
-  registerTestCleanup(() => {
-    stub.restore();
-  });
-  return stub;
+export function removeRequestDependencyListener() {
+  document.removeEventListener('request-dependency', resolveDependency);
 }
 
-window.stub = stubImpl;
-
 // Very simple function to catch unexpected elements in documents body.
 // It can't catch everything, but in most cases it is enough.
 function checkChildAllowed(element: Element) {
-  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER'];
+  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER', 'LINK'];
   if (allowedTags.includes(element.tagName)) {
     return;
   }
-  if (element.tagName === 'TEST-FIXTURE') {
-    if (
-      element.children.length === 0 ||
-      (element.children.length === 1 &&
-        element.children[0].tagName === 'TEMPLATE')
-    ) {
-      return;
-    }
-    assert.fail(
-      `Test fixture
-        ${element.outerHTML}` +
-        "isn't resotred after the test is finished. Please ensure that " +
-        'restore() method is called for this test-fixture. Usually the call' +
-        'happens automatically.'
-    );
-    return;
-  }
   if (
     element.tagName === 'DIV' &&
     element.id === 'gr-hovercard-container' &&
@@ -209,14 +167,18 @@
 
 teardown(() => {
   sinon.restore();
+  fixtureCleanup();
   cleanupTestUtils();
   checkGlobalSpace();
-  removeIronOverlayBackdropStyleEl();
   removeThemeStyles();
   cancelAllTasks();
   cleanUpStorage();
+  removeRequestDependencyListener();
+  injectedDependencies.clear();
   // Reset state
-  updatePreferences(createDefaultPreferences());
+  for (const f of finalizers) {
+    f.finalize();
+  }
   const testTeardownTimestampMs = new Date().getTime();
   const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
   if (elapsedMs > 1000) {
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
deleted file mode 100644
index fc4599d..0000000
--- a/polygerrit-ui/app/test/mocks/comment-api.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 {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-
-/**
- * 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 LegacyElementMixin(PolymerElement) {
-  static get properties() {
-    return {
-      _changeComments: Object,
-    };
-  }
-}
-
-/**
- * Creates a new element which is descendant of CommentApiMock with specified
- * template. Additionally, the method registers a tagName for this element.
- *
- * Each tagName must be a unique accross all tests.
- */
-export function createCommentApiMockWithTemplateElement(tagName, template) {
-  const elementClass = class extends CommentApiMock {
-    static get is() { return tagName; }
-
-    static get template() { return template; }
-  };
-  customElements.define(tagName, elementClass);
-  return elementClass;
-}
diff --git a/polygerrit-ui/app/test/mocks/diff-response.js b/polygerrit-ui/app/test/mocks/diff-response.js
deleted file mode 100644
index 8ca44c2..0000000
--- a/polygerrit-ui/app/test/mocks/diff-response.js
+++ /dev/null
@@ -1,149 +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.
- */
-
-export function getMockDiffResponse() {
-  // Return new response, so tests can't affect each other - if a test somehow
-  // modifies it, the future calls return original value
-  // Do not put it to a const outside of a method
-  return {
-    meta_a: {
-      name: 'lorem-ipsum.txt',
-      content_type: 'text/plain',
-      lines: 45,
-    },
-    meta_b: {
-      name: 'lorem-ipsum.txt',
-      content_type: 'text/plain',
-      lines: 48,
-    },
-    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: [
-          'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
-          'nulla phasellus.',
-          'Mattis lectus.',
-          'Sodales duis.',
-          'Orci a faucibus.',
-        ],
-      },
-      {
-        b: [
-          'Nullam neque, ligula ac, id blandit.',
-          'Sagittis tincidunt torquent, tempor nunc amet.',
-          'At rhoncus id.',
-        ],
-      },
-      {
-        ab: [
-          'Sem nascetur, erat ut, non in.',
-          'A donec, venenatis pellentesque dis.',
-          'Mauris mauris.',
-          'Quisque nisl duis, facilisis viverra.',
-          'Justo purus, semper eget et.',
-        ],
-      },
-      {
-        a: [
-          'Est amet, vestibulum pellentesque.',
-          'Erat ligula.',
-          'Justo eros.',
-          'Fringilla quisque.',
-        ],
-      },
-      {
-        ab: [
-          'Arcu eget, rhoncus amet cursus, ipsum elementum.',
-          'Eros suspendisse.',
-        ],
-      },
-      {
-        a: [
-          'Rhoncus tempor, ultricies aliquam ipsum.',
-        ],
-        b: [
-          'Rhoncus tempor, ultricies praesent ipsum.',
-        ],
-        edit_a: [
-          [
-            26,
-            7,
-          ],
-        ],
-        edit_b: [
-          [
-            26,
-            8,
-          ],
-        ],
-      },
-      {
-        ab: [
-          'Sollicitudin duis.',
-          'Blandit blandit, ante nisl fusce.',
-          'Felis ac at, tellus consectetuer.',
-          'Sociis ligula sapien, egestas leo.',
-          'Cum pulvinar, sed mauris, cursus neque velit.',
-          'Augue porta lobortis.',
-          'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
-          'Id quam ipsum, id urna et, massa suspendisse.',
-          'Ac nec, nibh praesent.',
-          'Rutrum vestibulum.',
-          'Est tellus, bibendum habitasse.',
-          'Justo facilisis, vel nulla.',
-          'Donec eu, vulputate neque aliquam, nulla dui.',
-          'Risus adipiscing in.',
-          'Lacus arcu arcu.',
-          'Urna velit.',
-          'Urna a dolor.',
-          'Lectus magna augue, convallis mattis tortor, sed tellus ' +
-          'consequat.',
-          'Etiam dui, blandit wisi.',
-          'Mi nec.',
-          'Vitae eget vestibulum.',
-          'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
-          'Ac eget.',
-          'Vel fringilla, interdum pellentesque placerat, proin ante.',
-        ],
-      },
-      {
-        b: [
-          'Eu congue risus.',
-          'Enim ac, quis elementum.',
-          'Non et elit.',
-          'Etiam aliquam, diam vel nunc.',
-        ],
-      },
-      {
-        ab: [
-          'Nec at.',
-          'Arcu mauris, venenatis lacus fermentum, praesent duis.',
-          'Pellentesque amet et, tellus duis.',
-          'Ipsum arcu vitae, justo elit, sed libero tellus.',
-          'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
-        ],
-      },
-    ],
-  };
-}
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 3bb0c34..a5bc4bf 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -1,19 +1,8 @@
 /* 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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {
@@ -41,7 +30,7 @@
   ConfigInfo,
   EditInfo,
   DashboardInfo,
-  ProjectAccessInfoMap,
+  RepoAccessInfoMap,
   IncludedInInfo,
   CommentInfo,
   PathToCommentsInfoMap,
@@ -68,6 +57,8 @@
   GroupId,
   GroupName,
   UrlEncodedRepoName,
+  NumericChangeId,
+  PreferencesInput,
 } 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';
@@ -83,7 +74,6 @@
 import {
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
-  createDefaultPreferences,
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
 
@@ -103,6 +93,9 @@
   applyFixSuggestion(): Promise<Response> {
     return Promise.resolve(new Response());
   },
+  applyRobotFixSuggestion(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
   awaitPendingDiffDrafts(): Promise<void> {
     return Promise.resolve();
   },
@@ -134,9 +127,6 @@
     return Promise.resolve(new Response());
   },
   deleteAccountSSHKey(): void {},
-  deleteAssignee(): Promise<Response> {
-    return Promise.resolve(new Response());
-  },
   deleteChangeCommitMessage(): Promise<Response> {
     return Promise.resolve(new Response());
   },
@@ -173,6 +163,7 @@
   executeChangeAction(): Promise<Response | undefined> {
     return Promise.resolve(new Response());
   },
+  finalize(): void {},
   generateAccountHttpPassword(): Promise<Password> {
     return Promise.resolve('asdf');
   },
@@ -227,11 +218,14 @@
   getChangeConflicts(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
-  getChangeDetail(): Promise<ParsedChangeInfo | null | undefined> {
+  getChangeDetail(
+    changeNum?: number | string
+  ): Promise<ParsedChangeInfo | undefined> {
+    if (changeNum === undefined) return Promise.resolve(undefined);
     return Promise.resolve(createChange() as ParsedChangeInfo);
   },
-  getChangeEdit(): Promise<false | EditInfo | undefined> {
-    return Promise.resolve(false);
+  getChangeEdit(): Promise<EditInfo | undefined> {
+    return Promise.resolve(undefined);
   },
   getChangeFiles(): Promise<FileNameToFileInfoMap | undefined> {
     return Promise.resolve({});
@@ -254,15 +248,33 @@
   getChanges() {
     return Promise.resolve([]);
   },
+  getChangesForMultipleQueries() {
+    return Promise.resolve([]);
+  },
   getChangesSubmittedTogether(): Promise<SubmittedTogetherInfo | undefined> {
     return Promise.resolve(createSubmittedTogetherInfo());
   },
+  getDetailedChangesWithActions(changeNums: NumericChangeId[]) {
+    return Promise.resolve(
+      changeNums.map(changeNum => {
+        return {
+          ...createChange(),
+          actions: {},
+          _number: changeNum,
+          subject: `Subject ${changeNum}`,
+        };
+      })
+    );
+  },
   getChangesWithSameTopic(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
   getChangesWithSimilarTopic(): Promise<ChangeInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getChangesWithSimilarHashtag(): Promise<ChangeInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
   getConfig(): Promise<ServerInfo | undefined> {
     return Promise.resolve(createServerInfo());
   },
@@ -275,20 +287,23 @@
   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.');
+    // NOTE: This method can not be typed properly due to overloads.
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    return Promise.resolve({}) as any;
   },
   getDiffDrafts() {
-    throw new Error('getDiffDrafts() not implemented by RestApiMock.');
+    // NOTE: This method can not be typed properly due to overloads.
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    return Promise.resolve({}) as any;
   },
   getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
     return Promise.resolve(createDefaultDiffPrefs());
   },
   getDiffRobotComments() {
-    throw new Error('getDiffRobotComments() not implemented by RestApiMock.');
+    // NOTE: This method can not be typed properly due to overloads.
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    return Promise.resolve({}) as any;
   },
   getDocumentationSearches(): Promise<DocResult[] | undefined> {
     return Promise.resolve([]);
@@ -361,7 +376,7 @@
       name: repo,
     });
   },
-  getRepoAccess(): Promise<ProjectAccessInfoMap | undefined> {
+  getRepoAccess(): Promise<RepoAccessInfoMap | undefined> {
     return Promise.resolve({});
   },
   getRepoAccessRights(): Promise<ProjectAccessInfo | undefined> {
@@ -385,6 +400,9 @@
   getReviewedFiles(): Promise<string[] | undefined> {
     return Promise.resolve([]);
   },
+  getFixPreview(): Promise<FilePathToDiffInfoMap | undefined> {
+    return Promise.resolve({});
+  },
   getRobotCommentFixPreview(): Promise<FilePathToDiffInfoMap | undefined> {
     return Promise.resolve({});
   },
@@ -394,7 +412,7 @@
   getSuggestedGroups(): Promise<GroupNameToGroupInfoMap | undefined> {
     return Promise.resolve({});
   },
-  getSuggestedProjects(): Promise<NameToProjectInfoMap | undefined> {
+  getSuggestedRepos(): Promise<NameToProjectInfoMap | undefined> {
     return Promise.resolve({});
   },
   getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
@@ -482,8 +500,9 @@
   saveIncludedGroup(): Promise<GroupInfo | undefined> {
     throw new Error('saveIncludedGroup() not implemented by RestApiMock.');
   },
-  savePreferences(): Promise<PreferencesInfo> {
-    return Promise.resolve(createDefaultPreferences());
+  savePreferences(input: PreferencesInput): Promise<PreferencesInfo> {
+    const info = input as PreferencesInfo;
+    return Promise.resolve({...info});
   },
   saveRepoConfig(): Promise<Response> {
     return Promise.resolve(new Response());
@@ -506,9 +525,6 @@
   setAccountUsername(): Promise<void> {
     return Promise.resolve();
   },
-  setAssignee(): Promise<Response> {
-    return Promise.resolve(new Response());
-  },
   setChangeHashtag(): Promise<Hashtag[]> {
     return Promise.resolve([]);
   },
diff --git a/polygerrit-ui/app/test/source-map-support-install.ts b/polygerrit-ui/app/test/source-map-support-install.ts
deleted file mode 100644
index b8798e2..0000000
--- a/polygerrit-ui/app/test/source-map-support-install.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and doesn't allow "declare global".
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
-
-declare global {
-  interface Window {
-    sourceMapSupport: {
-      install(): void;
-    };
-  }
-}
-
-// The karma.conf.js file loads required module before any other modules
-// The source-map-support.js can't be imported with import ... statement
-window.sourceMapSupport.install();
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 483baa6..6bee4a6 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -1,43 +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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // Init app context before any other imports
-import {initAppContext} from '../services/app-context-init';
+import {create, Registry, Finalizable} from '../services/registry';
+import {AppContext} from '../services/app-context';
 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';
 import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
+import {FlagsServiceImplementation} from '../services/flags/flags_impl';
+import {MockHighlightService} from '../services/highlight/highlight-service-mock';
+import {createAppDependencies, Creator} from '../services/app-context-init';
+import {navigationToken} from '../elements/core/gr-navigation/gr-navigation';
+import {DependencyToken} from '../models/dependency';
+import {storageServiceToken} from '../services/storage/gr-storage_impl';
+import {highlightServiceToken} from '../services/highlight/highlight-service';
 
-export function _testOnlyInitAppContext() {
-  initAppContext();
+export function createTestAppContext(): AppContext & Finalizable {
+  const appRegistry: Registry<AppContext> = {
+    flagsService: (_ctx: Partial<AppContext>) =>
+      new FlagsServiceImplementation(),
+    reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
+    authService: (_ctx: Partial<AppContext>) => new GrAuthMock(),
+    restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
+  };
+  return create<AppContext>(appRegistry);
+}
 
-  function setMock<T extends keyof AppContext>(
-    serviceName: T,
-    setupMock: AppContext[T]
-  ) {
-    Object.defineProperty(appContext, serviceName, {
-      get() {
-        return setupMock;
-      },
-    });
-  }
-  setMock('reportingService', grReportingMock);
-  setMock('restApiService', grRestApiMock);
-  setMock('storageService', grStorageMock);
-  setMock('authService', new GrAuthMock(appContext.eventEmitter));
+export function createTestDependencies(
+  appContext: AppContext,
+  resolver: <T>(token: DependencyToken<T>) => T
+): Map<DependencyToken<unknown>, Creator<unknown>> {
+  const dependencies = createAppDependencies(appContext, resolver);
+  dependencies.set(storageServiceToken, () => grStorageMock);
+  dependencies.set(navigationToken, () => {
+    return {
+      setUrl: () => {},
+      replaceUrl: () => {},
+      finalize: () => {},
+    };
+  });
+  dependencies.set(
+    highlightServiceToken,
+    () => new MockHighlightService(appContext.reportingService)
+  );
+  return dependencies;
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 91cd2f3..f0a4cbe 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {
   AccountDetailInfo,
   AccountId,
@@ -23,6 +11,7 @@
   ApprovalInfo,
   AuthInfo,
   BasePatchSetNum,
+  BlameInfo,
   BranchName,
   ChangeConfigInfo,
   ChangeId,
@@ -31,13 +20,16 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeViewChangeInfo,
+  CommentInfo,
   CommentLinkInfo,
   CommentLinks,
+  CommentRange,
   CommitId,
   CommitInfo,
   ConfigInfo,
   DownloadInfo,
-  EditPatchSetNum,
+  EditInfo,
+  EDIT,
   EmailAddress,
   FixId,
   FixSuggestionInfo,
@@ -49,10 +41,12 @@
   GroupId,
   GroupInfo,
   InheritedBooleanInfo,
+  LabelInfo,
   MaxObjectSizeLimitInfo,
   MergeableInfo,
   NumericChangeId,
-  PatchSetNum,
+  PARENT,
+  PatchRange,
   PluginConfigInfo,
   PreferencesInfo,
   RelatedChangeAndCommitInfo,
@@ -62,6 +56,10 @@
   RequirementType,
   Reviewers,
   RevisionInfo,
+  RevisionPatchSetNum,
+  RobotCommentInfo,
+  RobotId,
+  RobotRunId,
   SchemesInfoMap,
   ServerInfo,
   SubmittedTogetherInfo,
@@ -74,6 +72,7 @@
 } from '../types/common';
 import {
   AccountsVisibility,
+  AccountTag,
   AppTheme,
   AuthType,
   ChangeStatus,
@@ -92,28 +91,43 @@
 } from '../constants/constants';
 import {formatDate} from '../utils/date-util';
 import {GetDiffCommentsOutput} from '../services/gr-rest-api/gr-rest-api';
-import {AppElementChangeViewParams} from '../elements/gr-app-types';
 import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
 import {WebLinkInfo} from '../types/diff';
 import {
+  ChangeMessage,
+  CommentThread,
   createCommentThreads,
-  UIComment,
-  UIDraft,
-  UIHuman,
+  DraftInfo,
+  UnsavedInfo,
 } from '../utils/comment-util';
 import {GerritView} from '../services/router/router-model';
 import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
-import {ChangeMessage} from '../elements/change/gr-message/gr-message';
-import {GenerateUrlEditViewParameters} from '../elements/core/gr-navigation/gr-navigation';
 import {
   DetailedLabelInfo,
+  QuickLabelInfo,
   SubmitRequirementExpressionInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
 } from '../api/rest-api';
-import {RunResult} from '../services/checks/checks-model';
+import {CheckResult, RunResult} from '../models/checks/checks-model';
 import {Category, RunStatus} from '../api/checks';
+import {DiffInfo} from '../api/diff';
+import {SearchViewState} from '../models/views/search';
+import {ChangeChildView, ChangeViewState} from '../models/views/change';
+import {NormalizedFileInfo} from '../models/change/files-model';
+
+const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
+export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
+export const TEST_BRANCH_ID: BranchName = 'test-branch' as BranchName;
+export const TEST_CHANGE_ID: ChangeId = 'TestChangeId' as ChangeId;
+export const TEST_CHANGE_INFO_ID: ChangeInfoId =
+  `${TEST_PROJECT_NAME}~${TEST_BRANCH_ID}~${TEST_CHANGE_ID}` as ChangeInfoId;
+export const TEST_SUBJECT = 'Test subject';
+export const TEST_NUMERIC_CHANGE_ID = 42 as NumericChangeId;
+
+export const TEST_CHANGE_CREATED = new Date(2020, 1, 1, 1, 2, 3);
+export const TEST_CHANGE_UPDATED = new Date(2020, 10, 6, 5, 12, 34);
 
 export function dateToTimestamp(date: Date): Timestamp {
   const nanosecondSuffix = '.000000000';
@@ -121,9 +135,13 @@
     nanosecondSuffix) as Timestamp;
 }
 
-export function createCommentLink(match = 'test'): CommentLinkInfo {
+export function createCommentLink(
+  match = 'test',
+  link = 'http://test.com'
+): CommentLinkInfo {
   return {
     match,
+    link,
   };
 }
 
@@ -160,6 +178,7 @@
     work_in_progress_by_default: createInheritedBoolean(),
     max_object_size_limit: createMaxObjectSizeLimit(),
     default_submit_type: createSubmitType(),
+    enable_reviewer_by_email: createInheritedBoolean(),
     submit_type: SubmitType.INHERIT,
     commentlinks: createCommentLinks(),
   };
@@ -168,6 +187,14 @@
 export function createAccountWithId(id = 5): AccountInfo {
   return {
     _account_id: id as AccountId,
+    email: `${id}` as EmailAddress,
+  };
+}
+
+export function createServiceUserWithId(id = 5): AccountInfo {
+  return {
+    ...createAccountWithId(id),
+    tags: [AccountTag.SERVICE_USER],
   };
 }
 
@@ -181,6 +208,13 @@
 export function createAccountWithEmail(email = 'test@'): AccountInfo {
   return {
     email: email as EmailAddress,
+    _account_id: 1 as AccountId,
+  };
+}
+
+export function createAccountWithEmailOnly(email = 'test@'): AccountInfo {
+  return {
+    email: email as EmailAddress,
   };
 }
 
@@ -192,21 +226,21 @@
   };
 }
 
+export function createAccountDetailWithIdNameAndEmail(
+  id = 5
+): AccountDetailInfo {
+  return {
+    _account_id: id as AccountId,
+    email: `user-${id}@` as EmailAddress,
+    name: `User-${id}`,
+    registered_on: dateToTimestamp(new Date(2020, 10, 15, 14, 5, 8)),
+  };
+}
+
 export function createReviewers(): Reviewers {
   return {};
 }
 
-export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
-export const TEST_BRANCH_ID: BranchName = 'test-branch' as BranchName;
-export const TEST_CHANGE_ID: ChangeId = 'TestChangeId' as ChangeId;
-export const TEST_CHANGE_INFO_ID: ChangeInfoId =
-  `${TEST_PROJECT_NAME}~${TEST_BRANCH_ID}~${TEST_CHANGE_ID}` as ChangeInfoId;
-export const TEST_SUBJECT = 'Test subject';
-export const TEST_NUMERIC_CHANGE_ID = 42 as NumericChangeId;
-
-export const TEST_CHANGE_CREATED = new Date(2020, 1, 1, 1, 2, 3);
-export const TEST_CHANGE_UPDATED = new Date(2020, 10, 6, 5, 12, 34);
-
 export function createGitPerson(name = 'Test name'): GitPersonInfo {
   return {
     name,
@@ -216,6 +250,30 @@
   };
 }
 
+export function createLabelInfo(score = 1): LabelInfo {
+  return {
+    all: [
+      {
+        value: score,
+        permitted_voting_range: {
+          min: -1,
+          max: 1,
+        },
+        _account_id: 1000 as AccountId,
+        name: 'Foo',
+        email: 'foo@example.com' as EmailAddress,
+        username: 'foo',
+      },
+    ],
+    values: {
+      '-1': 'Fail',
+      ' 0': 'No score',
+      '+1': 'Pass',
+    },
+    default_value: 0,
+  };
+}
+
 export function createCommit(): CommitInfo {
   return {
     parents: [],
@@ -235,12 +293,22 @@
   };
 }
 
+export function createPatchRange(
+  basePatchNum?: number,
+  patchNum?: number
+): PatchRange {
+  return {
+    basePatchNum: (basePatchNum ?? PARENT) as BasePatchSetNum,
+    patchNum: (patchNum ?? 1) as RevisionPatchSetNum,
+  };
+}
+
 export function createRevision(
-  patchSetNum = 1,
+  patchSetNum: number | RevisionPatchSetNum = 1,
   description = ''
 ): RevisionInfo {
   return {
-    _number: patchSetNum as PatchSetNum,
+    _number: patchSetNum as RevisionPatchSetNum,
     commit: createCommit(),
     created: dateToTimestamp(TEST_CHANGE_CREATED),
     kind: RevisionKind.REWORK,
@@ -250,11 +318,25 @@
   };
 }
 
+export function createEditInfo(): EditInfo {
+  return {
+    commit: {...createCommit(), commit: 'commit-id-of-edit-ps' as CommitId},
+    base_patch_set_number: 1 as BasePatchSetNum,
+    base_revision: 'base-revision-of-edit',
+    ref: 'refs/changes/5/6/1' as GitRef,
+    fetch: {},
+    files: {},
+  };
+}
+
 export function createEditRevision(basePatchNum = 1): EditRevisionInfo {
   return {
-    _number: EditPatchSetNum,
+    _number: EDIT,
     basePatchNum: basePatchNum as BasePatchSetNum,
-    commit: createCommit(),
+    commit: {
+      ...createCommit(),
+      commit: 'test-commit-id-of-edit-rev' as CommitId,
+    },
   };
 }
 
@@ -309,6 +391,7 @@
     messages.push({
       ...createChangeMessageInfo((i + messageIdStart).toString(16)),
       date: dateToTimestamp(messageDate),
+      author: createAccountDetailWithId(i),
     });
     messageDate = new Date(messageDate);
     messageDate.setDate(messageDate.getDate() + 1);
@@ -316,6 +399,18 @@
   return messages;
 }
 
+export function createFileInfo(
+  path = 'test-path/test-file.txt'
+): NormalizedFileInfo {
+  return {
+    size: 314,
+    size_delta: 7,
+    lines_deleted: 0,
+    lines_inserted: 0,
+    __path: path,
+  };
+}
+
 export function createChange(): ChangeInfo {
   return {
     id: TEST_CHANGE_INFO_ID,
@@ -371,7 +466,6 @@
     update_delay: 0,
     mergeability_computation_behavior:
       MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX,
-    enable_assignee: false,
   };
 }
 
@@ -433,6 +527,154 @@
   };
 }
 
+export function createEmptyDiff(): DiffInfo {
+  return {
+    meta_a: {
+      name: 'empty-left.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    meta_b: {
+      name: 'empty-right.txt',
+      content_type: 'text/plain',
+      lines: 1,
+    },
+    intraline_status: 'OK',
+    change_type: 'MODIFIED',
+    content: [],
+  };
+}
+
+export function createDiff(): DiffInfo {
+  return {
+    meta_a: {
+      name: 'lorem-ipsum.txt',
+      content_type: 'text/plain',
+      lines: 45,
+    },
+    meta_b: {
+      name: 'lorem-ipsum.txt',
+      content_type: 'text/plain',
+      lines: 48,
+    },
+    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: [
+          'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula.',
+          'Mattis lectus.',
+          'Sodales duis.',
+          'Orci a faucibus.',
+        ],
+      },
+      {
+        b: [
+          'Nullam neque, ligula ac, id blandit.',
+          'Sagittis tincidunt torquent, tempor nunc amet.',
+          'At rhoncus id.',
+        ],
+      },
+      {
+        ab: [
+          'Sem nascetur, erat ut, non in.',
+          'A donec, venenatis pellentesque dis.',
+          'Mauris mauris.',
+          'Quisque nisl duis, facilisis viverra.',
+          'Justo purus, semper eget et.',
+        ],
+      },
+      {
+        a: [
+          'Est amet, vestibulum pellentesque.',
+          'Erat ligula.',
+          'Justo eros.',
+          'Fringilla quisque.',
+        ],
+      },
+      {
+        a: [
+          'Arcu eget, rhoncus amet cursus, ipsum elementum.  ',
+          'Eros suspendisse.  ',
+        ],
+        b: [
+          'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+          'Eros suspendisse.',
+        ],
+        common: true,
+      },
+      {
+        a: ['Rhoncus tempor, ultricies aliquam ipsum.'],
+        b: ['Rhoncus tempor, ultricies praesent ipsum.'],
+        edit_a: [[26, 7]],
+        edit_b: [[26, 8]],
+      },
+      {
+        ab: [
+          'Sollicitudin duis.',
+          'Blandit blandit, ante nisl fusce.',
+          'Felis ac at, tellus consectetuer.',
+          'Sociis ligula sapien, egestas leo.',
+          'Cum pulvinar, sed mauris, cursus neque velit.',
+          'Augue porta lobortis.',
+          'Nibh lorem, amet fermentum turpis, vel pulvinar diam.',
+          'Id quam ipsum, id urna et, massa suspendisse.',
+          'Ac nec, nibh praesent.',
+          'Rutrum vestibulum.',
+          'Est tellus, bibendum habitasse.',
+          'Justo facilisis, vel nulla.',
+          'Donec eu, vulputate neque aliquam, nulla dui.',
+          'Risus adipiscing in.',
+          'Lacus arcu arcu.',
+          'Urna velit.',
+          'Urna a dolor.',
+          'Lectus magna augue, convallis mattis tortor, sed tellus ' +
+            'consequat.',
+          'Etiam dui, blandit wisi.',
+          'Mi nec.',
+          'Vitae eget vestibulum.',
+          'Ullamcorper nunc ante, nec imperdiet felis, consectetur.',
+          'Ac eget.',
+          'Vel fringilla, interdum pellentesque placerat, proin ante.',
+        ],
+      },
+      {
+        b: [
+          'Eu congue risus.',
+          'Enim ac, quis elementum.',
+          'Non et elit.',
+          'Etiam aliquam, diam vel nunc.',
+        ],
+      },
+      {
+        ab: [
+          'Nec at.',
+          'Arcu mauris, venenatis lacus fermentum, praesent duis.',
+          'Pellentesque amet et, tellus duis.',
+          'Ipsum arcu vitae, justo elit, sed libero tellus.',
+          'Metus rutrum euismod, vivamus sodales, vel arcu nisl.',
+        ],
+      },
+    ],
+  };
+}
+
+export function createBlame(): BlameInfo {
+  return {
+    author: 'test-author',
+    id: 'test-id',
+    time: 123,
+    commit_msg: 'test-commit-message',
+    ranges: [],
+  };
+}
+
 export function createMergeable(): MergeableInfo {
   return {
     submit_type: SubmitType.MERGE_IF_NECESSARY,
@@ -444,7 +686,7 @@
 export function createPreferences(): PreferencesInfo {
   return {
     changes_per_page: 10,
-    theme: AppTheme.LIGHT,
+    theme: AppTheme.AUTO,
     date_format: DateFormat.ISO,
     time_format: TimeFormat.HHMM_24,
     diff_view: DiffViewMode.SIDE_BY_SIDE,
@@ -452,28 +694,50 @@
     change_table: [],
     email_strategy: EmailStrategy.ENABLED,
     default_base_for_merges: DefaultBase.AUTO_MERGE,
+    allow_browser_notifications: true,
   };
 }
 
-export function createApproval(): ApprovalInfo {
-  return createAccountWithId();
+export function createApproval(account?: AccountInfo): ApprovalInfo {
+  return account ?? createAccountWithId();
 }
 
-export function createAppElementChangeViewParams(): AppElementChangeViewParams {
+export function createChangeViewState(): ChangeViewState {
   return {
     view: GerritView.CHANGE,
+    childView: ChangeChildView.OVERVIEW,
     changeNum: TEST_NUMERIC_CHANGE_ID,
-    project: TEST_PROJECT_NAME,
+    repo: TEST_PROJECT_NAME,
   };
 }
 
-export function createGenerateUrlEditViewParameters(): GenerateUrlEditViewParameters {
+export function createAppElementSearchViewParams(): SearchViewState {
   return {
-    view: GerritView.EDIT,
+    view: GerritView.SEARCH,
+    query: TEST_NUMERIC_CHANGE_ID.toString(),
+    offset: '0',
+    changes: [],
+    loading: false,
+  };
+}
+
+export function createEditViewState(): ChangeViewState {
+  return {
+    view: GerritView.CHANGE,
+    childView: ChangeChildView.EDIT,
     changeNum: TEST_NUMERIC_CHANGE_ID,
-    patchNum: EditPatchSetNum as PatchSetNum,
-    path: 'foo/bar.baz',
-    project: TEST_PROJECT_NAME,
+    patchNum: EDIT,
+    repo: TEST_PROJECT_NAME,
+    editView: {path: 'foo/bar.baz'},
+  };
+}
+
+export function createDiffViewState(): ChangeViewState {
+  return {
+    view: GerritView.CHANGE,
+    childView: ChangeChildView.DIFF,
+    changeNum: TEST_NUMERIC_CHANGE_ID,
+    repo: TEST_PROJECT_NAME,
   };
 }
 
@@ -493,9 +757,20 @@
   };
 }
 
-export function createComment(): UIHuman {
+export function createRange(): CommentRange {
   return {
-    patch_set: 1 as PatchSetNum,
+    start_line: 1,
+    start_character: 0,
+    end_line: 1,
+    end_character: 1,
+  };
+}
+
+export function createComment(
+  extra: Partial<CommentInfo | DraftInfo> = {}
+): CommentInfo {
+  return {
+    patch_set: 1 as RevisionPatchSetNum,
     id: '12345' as UrlEncodedCommentId,
     side: CommentSide.REVISION,
     line: 1,
@@ -503,15 +778,38 @@
     updated: '2018-02-13 22:48:48.018000000' as Timestamp,
     unresolved: false,
     path: 'abc.txt',
+    ...extra,
   };
 }
 
-export function createDraft(): UIDraft {
+export function createDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
   return {
     ...createComment(),
-    collapsed: false,
     __draft: true,
-    __editing: false,
+    ...extra,
+  };
+}
+
+export function createUnsaved(extra: Partial<CommentInfo> = {}): UnsavedInfo {
+  return {
+    ...createComment(),
+    __unsaved: true,
+    id: undefined,
+    updated: undefined,
+    ...extra,
+  };
+}
+
+export function createRobotComment(
+  extra: Partial<CommentInfo> = {}
+): RobotCommentInfo {
+  return {
+    ...createComment(),
+    robot_id: 'robot-id-123' as RobotId,
+    robot_run_id: 'robot-run-id-456' as RobotRunId,
+    properties: {},
+    fix_suggestions: [],
+    ...extra,
   };
 }
 
@@ -532,7 +830,7 @@
       },
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'hello',
         updated: '2017-02-10 16:40:49' as Timestamp,
         id: '3' as UrlEncodedCommentId,
@@ -547,14 +845,14 @@
       },
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'wat!?',
         updated: '2017-02-09 16:40:49' as Timestamp,
         id: '5' as UrlEncodedCommentId,
       },
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'hi',
         updated: '2017-02-10 16:40:49' as Timestamp,
         id: '6' as UrlEncodedCommentId,
@@ -563,7 +861,7 @@
     'unresolved.file': [
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'wat!?',
         updated: '2017-02-09 16:40:49' as Timestamp,
         id: '7' as UrlEncodedCommentId,
@@ -571,7 +869,7 @@
       },
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'hi',
         updated: '2017-02-10 16:40:49' as Timestamp,
         id: '8' as UrlEncodedCommentId,
@@ -580,7 +878,7 @@
       },
       {
         ...createComment(),
-        patch_set: 2 as PatchSetNum,
+        patch_set: 2 as RevisionPatchSetNum,
         message: 'good news!',
         updated: '2017-02-08 16:40:49' as Timestamp,
         id: '9' as UrlEncodedCommentId,
@@ -618,14 +916,29 @@
   return new ChangeComments(comments, {}, drafts, {}, {});
 }
 
-export function createCommentThread(comments: UIComment[]) {
+export function createThread(
+  ...comments: Partial<CommentInfo | DraftInfo>[]
+): CommentThread {
+  return {
+    comments: comments.map(c => createComment(c)),
+    rootId: 'test-root-id-comment-thread' as UrlEncodedCommentId,
+    path: 'test-path-comment-thread',
+    commentSide: CommentSide.REVISION,
+    patchNum: 1 as RevisionPatchSetNum,
+    line: 314,
+  };
+}
+
+export function createCommentThread(
+  comments: Array<Partial<CommentInfo | DraftInfo>>
+) {
   if (!comments.length) {
     throw new Error('comment is required to create a thread');
   }
-  comments = comments.map(comment => {
+  const filledComments = comments.map(comment => {
     return {...createComment(), ...comment};
   });
-  const threads = createCommentThreads(comments);
+  const threads = createCommentThreads(filledComments);
   return threads[0];
 }
 
@@ -686,20 +999,36 @@
   }
 }
 
-export function createSubmitRequirementExpressionInfo(): SubmitRequirementExpressionInfo {
+export function createSubmitRequirementExpressionInfo(
+  expression = TEST_DEFAULT_EXPRESSION
+): SubmitRequirementExpressionInfo {
   return {
-    expression: 'label:Verified=MAX -label:Verified=MIN',
+    expression,
     fulfilled: true,
-    passing_atoms: ['label2:verified=MAX'],
-    failing_atoms: ['label2:verified=MIN'],
+    passing_atoms: ['label:Verified=MAX'],
+    failing_atoms: ['label:Verified=MIN'],
   };
 }
 
-export function createSubmitRequirementResultInfo(): SubmitRequirementResultInfo {
+export function createSubmitRequirementResultInfo(
+  expression = TEST_DEFAULT_EXPRESSION
+): SubmitRequirementResultInfo {
   return {
     name: 'Verified',
     status: SubmitRequirementStatus.SATISFIED,
+    submittability_expression_result:
+      createSubmitRequirementExpressionInfo(expression),
+    is_legacy: false,
+  };
+}
+
+export function createNonApplicableSubmitRequirementResultInfo(): SubmitRequirementResultInfo {
+  return {
+    name: 'Verified',
+    status: SubmitRequirementStatus.NOT_APPLICABLE,
+    applicability_expression_result: createSubmitRequirementExpressionInfo(),
     submittability_expression_result: createSubmitRequirementExpressionInfo(),
+    is_legacy: false,
   };
 }
 
@@ -719,6 +1048,14 @@
   };
 }
 
+export function createCheckResult(): CheckResult {
+  return {
+    category: Category.ERROR,
+    summary: 'error',
+    internalResultId: 'test-internal-result-id',
+  };
+}
+
 export function createDetailedLabelInfo(): DetailedLabelInfo {
   return {
     values: {
@@ -728,3 +1065,7 @@
     },
   };
 }
+
+export function createQuickLabelInfo(): QuickLabelInfo {
+  return {};
+}
diff --git a/polygerrit-ui/app/test/test-router.ts b/polygerrit-ui/app/test/test-router.ts
deleted file mode 100644
index a378e2d..0000000
--- a/polygerrit-ui/app/test/test-router.ts
+++ /dev/null
@@ -1,28 +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 {GerritNav} from '../elements/core/gr-navigation/gr-navigation';
-
-GerritNav.setup(
-  () => {
-    /* noop */
-  },
-  () => '',
-  () => [],
-  () => {
-    return {};
-  }
-);
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 63c125e..c400d9c 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -1,44 +1,37 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '../types/globals';
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
-import {appContext} from '../services/app-context';
+import {getAppContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {SinonSpy, SinonStub} from 'sinon';
-import {StorageService} from '../services/storage/gr-storage';
-import {AuthService} from '../services/gr-auth/gr-auth';
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
-import {CommentsService} from '../services/comments/comments-service';
-import {UserService} from '../services/user/user-service';
-import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 import {queryAndAssert, query} from '../utils/common-util';
+import {FlagsService} from '../services/flags/flags';
+import {Key, Modifier, whenVisible} from '../utils/dom-util';
+import {Observable} from 'rxjs';
+import {filter, take, timeout} from 'rxjs/operators';
+import {assert} from '@open-wc/testing';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
 export interface MockPromise<T> extends Promise<T> {
   resolve: (value?: T) => void;
+  reject: (reason?: any) => void;
 }
 
 export function mockPromise<T = unknown>(): MockPromise<T> {
   let res: (value?: T) => void;
-  const promise: MockPromise<T> = new Promise<T | undefined>(resolve => {
-    res = resolve;
-  }) as MockPromise<T>;
+  let rej: (reason?: any) => void;
+  const promise: MockPromise<T> = new Promise<T | undefined>(
+    (resolve, reject) => {
+      res = resolve;
+      rej = reject;
+    }
+  ) as MockPromise<T>;
   promise.resolve = res!;
+  promise.reject = rej!;
   return promise;
 }
 
@@ -52,14 +45,6 @@
   return getComputedStyle(el).getPropertyValue('display') !== 'none';
 }
 
-// Provide reset plugins function to clear installed plugins between tests.
-// No gr-app found (running tests)
-export const resetPlugins = () => {
-  _testOnly_resetEndpoints();
-  const pl = _testOnly_resetPluginLoader();
-  pl.loadPlugins([]);
-};
-
 export type CleanupCallback = () => void;
 
 const cleanups: CleanupCallback[] = [];
@@ -103,39 +88,33 @@
 }
 
 export function stubRestApi<K extends keyof RestApiService>(method: K) {
-  return sinon.stub(appContext.restApiService, method);
+  return sinon.stub(getAppContext().restApiService, method);
 }
 
 export function spyRestApi<K extends keyof RestApiService>(method: K) {
-  return sinon.spy(appContext.restApiService, method);
-}
-
-export function stubComments<K extends keyof CommentsService>(method: K) {
-  return sinon.stub(appContext.commentsService, method);
-}
-
-export function stubUsers<K extends keyof UserService>(method: K) {
-  return sinon.stub(appContext.userService, method);
-}
-
-export function stubShortcuts<K extends keyof ShortcutsService>(method: K) {
-  return sinon.stub(appContext.shortcutsService, method);
-}
-
-export function stubStorage<K extends keyof StorageService>(method: K) {
-  return sinon.stub(appContext.storageService, method);
-}
-
-export function spyStorage<K extends keyof StorageService>(method: K) {
-  return sinon.spy(appContext.storageService, method);
-}
-
-export function stubAuth<K extends keyof AuthService>(method: K) {
-  return sinon.stub(appContext.authService, method);
+  return sinon.spy(getAppContext().restApiService, method);
 }
 
 export function stubReporting<K extends keyof ReportingService>(method: K) {
-  return sinon.stub(appContext.reportingService, method);
+  return sinon.stub(getAppContext().reportingService, method);
+}
+
+export function stubFlags<K extends keyof FlagsService>(method: K) {
+  return sinon.stub(getAppContext().flagsService, method);
+}
+
+export function stubElement<
+  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];
+  const stub = sinon.stub(proto, method);
+  registerTestCleanup(() => {
+    stub.restore();
+  });
+  return stub;
 }
 
 export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
@@ -143,24 +122,6 @@
   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.
- */
-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);
-}
-
 export function removeThemeStyles() {
   // Do not remove the light theme, because it is only added once statically,
   // not once per gr-app instantiation.
@@ -168,6 +129,30 @@
   document.head.querySelector('#dark-theme')?.remove();
 }
 
+function getActiveElement() {
+  return document.activeElement;
+}
+
+export function isFocusInsideElement(element: Element) {
+  // In Polymer 2 focused element either <paper-input> or nested
+  // native input <input> element depending on the current focus
+  // in browser window.
+  // For example, the focus is changed if the developer console
+  // get a focus.
+  let activeElement = getActiveElement();
+  while (activeElement) {
+    if (activeElement === element) {
+      return true;
+    }
+    if (activeElement.parentElement) {
+      activeElement = activeElement.parentElement;
+    } else {
+      activeElement = (activeElement.getRootNode() as ShadowRoot).host;
+    }
+  }
+  return false;
+}
+
 export async function waitQueryAndAssert<E extends Element = Element>(
   el: Element | null | undefined,
   selector: string
@@ -179,20 +164,24 @@
   return queryAndAssert<E>(el, selector);
 }
 
-export function waitUntil(
-  predicate: () => boolean,
-  message = 'The waitUntil() predicate is still false after 1000 ms.'
+export async function waitUntil(
+  predicate: (() => boolean) | (() => Promise<boolean>),
+  message = 'The waitUntil() predicate is still false after 1000 ms.',
+  timeout_ms = 1000
 ): Promise<void> {
   const start = Date.now();
   let sleep = 0;
-  if (predicate()) return Promise.resolve();
+  if (await predicate()) return Promise.resolve();
+  const error = new Error(message);
   return new Promise((resolve, reject) => {
-    const waiter = () => {
-      if (predicate()) {
-        return resolve();
+    const waiter = async () => {
+      if (await predicate()) {
+        resolve();
+        return;
       }
-      if (Date.now() - start >= 1000) {
-        return reject(new Error(message));
+      if (Date.now() - start >= timeout_ms) {
+        reject(error);
+        return;
       }
       setTimeout(waiter, sleep);
       sleep = sleep === 0 ? 1 : sleep * 4;
@@ -201,28 +190,141 @@
   });
 }
 
-export function waitUntilCalled(stub: SinonStub, name: string) {
+export async function waitUntilVisible(element: Element): Promise<void> {
+  return new Promise(resolve => {
+    whenVisible(element, () => resolve());
+  });
+}
+
+export function waitUntilCalled(stub: SinonStub | SinonSpy, name: string) {
   return waitUntil(() => stub.called, `${name} was not called`);
 }
 
 /**
+ * Subscribes to the observable and resolves once it emits a matching value.
+ * Usage:
+ *   await waitUntilObserved(
+ *     myTestModel.state$,
+ *     state => state.prop === expectedValue
+ *   );
+ */
+export async function waitUntilObserved<T>(
+  observable$: Observable<T>,
+  predicate: (t: T) => boolean,
+  message = 'The waitUntilObserved() predicate did not match after 1000 ms.'
+): Promise<T> {
+  return new Promise((resolve, reject) => {
+    observable$.pipe(filter(predicate), take(1), timeout(1000)).subscribe({
+      next: t => resolve(t),
+      error: () => reject(new Error(message)),
+    });
+  });
+}
+
+/**
+ * sinon.useFakeTimers() overwrites window.setTimeout with a controlled,
+ * synchronous version for tests to use. Keep the original one for use in
+ * waitEventLoop
+ */
+const nativeSetTimeout = window.setTimeout;
+/**
+ * Wait for the current event loop's tasks to complete by scheduling a promise
+ * to resolve during the next loop. Prefer other wait methods over this one to
+ * wait for specific work to be done or for specific states to exist.
+ */
+export function waitEventLoop(): Promise<void> {
+  return new Promise(resolve => nativeSetTimeout(resolve, 0));
+}
+/**
  * Promisify an event callback to simplify async...await tests.
  *
  * Use like this:
  *   await listenOnce(el, 'render');
  *   ...
  */
-export function listenOnce(el: EventTarget, eventType: string) {
-  return new Promise<void>(resolve => {
-    const listener = () => {
+export function listenOnce<T extends Event>(
+  el: EventTarget,
+  eventType: string
+) {
+  return new Promise<T>(resolve => {
+    const listener = (e: Event) => {
       removeEventListener();
-      resolve();
+      resolve(e as T);
     };
-    el.addEventListener(eventType, listener);
     let removeEventListener = () => {
       el.removeEventListener(eventType, listener);
       removeEventListener = () => {};
     };
+    el.addEventListener(eventType, listener);
     registerTestCleanup(removeEventListener);
   });
 }
+
+export function dispatch<T>(element: HTMLElement, type: string, detail: T) {
+  const eventOptions = {
+    detail,
+    bubbles: true,
+    composed: true,
+  };
+  element.dispatchEvent(new CustomEvent<T>(type, eventOptions));
+}
+
+export function pressKey(
+  element: HTMLElement,
+  key: string | Key,
+  ...modifiers: Modifier[]
+) {
+  const eventOptions = {
+    key,
+    bubbles: true,
+    cancelable: true,
+    composed: true,
+    altKey: modifiers.includes(Modifier.ALT_KEY),
+    ctrlKey: modifiers.includes(Modifier.CTRL_KEY),
+    metaKey: modifiers.includes(Modifier.META_KEY),
+    shiftKey: modifiers.includes(Modifier.SHIFT_KEY),
+  };
+  element.dispatchEvent(new KeyboardEvent('keydown', eventOptions));
+}
+
+export function mouseDown(element: HTMLElement) {
+  const rect = element.getBoundingClientRect();
+  const eventOptions = {
+    bubbles: true,
+    composed: true,
+    clientX: (rect.left + rect.right) / 2,
+    clientY: (rect.top + rect.bottom) / 2,
+    screenX: (rect.left + rect.right) / 2,
+    screenY: (rect.top + rect.bottom) / 2,
+  };
+  element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
+}
+
+export function assertFails<T = unknown>(promise: Promise<unknown>, error?: T) {
+  return promise
+    .then((_v: unknown) => {
+      assert.fail('Promise resolved but should have failed');
+    })
+    .catch((e: T) => {
+      if (error) {
+        assert.equal(e, error);
+      }
+      return e;
+    });
+}
+
+export function logProxy<T extends object>(obj: T, name?: string): T {
+  const handler = {
+    get(target: object, prop: PropertyKey, receiver: any) {
+      const result = Reflect.get(target, prop, receiver);
+      if (result instanceof Function) {
+        return (...rest: unknown[]) => {
+          console.error(`${name}.${String(prop)}(${rest})`);
+          return result.apply(target, rest);
+        };
+      }
+      return result;
+    },
+  };
+  return new Proxy(obj, handler) as unknown as T;
+}
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 5040496..98aaf0f 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -20,6 +20,7 @@
     "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
     "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
     "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+    "isolatedModules": true, /* Require re-exports of types to use "export type {...}" syntax, for dev server compatibility (esbuild) */
 
     /* Additional Checks */
     "noUnusedLocals": true, /* Report errors on unused locals. */
@@ -42,6 +43,14 @@
 
     "allowUmdGlobalAccess": true,
 
+    /* We cannot just use the defaults, because of "webworker". */
+    "lib": [
+      "dom",
+      "dom.iterable",
+      "es2021",
+      "webworker"
+    ],
+
     /* typeRoots for IDE (see tsconfig_bazel.json for Bazel) */
     "typeRoots": [
       "node_modules/@types",
@@ -87,13 +96,13 @@
     "embed/**/*",
     "gr-diff/**/*",
     "mixins/**/*",
-    "samples/**/*",
+    "models/**/*",
     "scripts/**/*",
     "services/**/*",
     "styles/**/*",
     "types/**/*",
     "utils/**/*",
     "test/**/*",
-    "tmpl_out/**/*" //Created by template checker in dev-mode
+    "workers/**/*"
   ]
 }
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
index dfd2078..730fc4d 100644
--- a/polygerrit-ui/app/tsconfig_bazel.json
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -16,12 +16,13 @@
     "embed/**/*",
     "gr-diff/**/*",
     "mixins/**/*",
-    "samples/**/*",
+    "models/**/*",
     "scripts/**/*",
     "services/**/*",
     "styles/**/*",
     "types/**/*",
-    "utils/**/*"
+    "utils/**/*",
+    "workers/**/*"
   ],
   "exclude": [
     "**/*_test.ts",
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index 7137e23..c6a940b 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -2,13 +2,9 @@
   "extends": "./tsconfig_bazel.json",
   "compilerOptions": {
     "typeRoots": [
-      "../../external/ui_dev_npm/node_modules/@polymer/iron-test-helpers",
       "../../external/ui_npm/node_modules/@types",
       "../../external/ui_dev_npm/node_modules/@types"
-    ],
-    "paths": {
-      "@polymer/iron-test-helpers/*": ["../../ui_dev_npm/node_modules/@polymer/iron-test-helpers/*"]
-    }
+    ]
   },
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
@@ -20,13 +16,14 @@
     "embed/**/*",
     "gr-diff/**/*",
     "mixins/**/*",
-    "samples/**/*",
+    "models/**/*",
     "scripts/**/*",
     "services/**/*",
     "styles/**/*",
+    "test/**/*",
     "types/**/*",
     "utils/**/*",
-    "test/**/*"
+    "workers/**/*"
   ],
   "exclude": []
 }
diff --git a/polygerrit-ui/app/types/aria-mixin.ts b/polygerrit-ui/app/types/aria-mixin.ts
index 6ae8c2a..eb9f417 100644
--- a/polygerrit-ui/app/types/aria-mixin.ts
+++ b/polygerrit-ui/app/types/aria-mixin.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 export {};
 
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 4406a73..22bee0c 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1,23 +1,12 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {CommentRange} from '../api/core';
 import {
   ChangeStatus,
-  ProjectState,
+  RepoState,
   SubmitType,
   InheritedBooleanInfoConfiguredValue,
   PermissionAction,
@@ -68,6 +57,8 @@
   DetailedLabelInfo,
   DownloadInfo,
   DownloadSchemeInfo,
+  EDIT,
+  EditPatchSet,
   EmailAddress,
   FetchInfo,
   FileInfo,
@@ -85,14 +76,16 @@
   LabelInfo,
   LabelNameToInfoMap,
   LabelNameToLabelTypeInfoMap,
-  LabelNameToValueMap,
+  LabelNameToValuesMap,
   LabelTypeInfo,
   LabelTypeInfoValues,
   LabelValueToDescriptionMap,
   MaxObjectSizeLimitInfo,
   NumericChangeId,
   ParentCommitInfo,
+  PARENT,
   PatchSetNum,
+  PatchSetNumber,
   PluginConfigInfo,
   PluginNameToPluginParametersMap,
   PluginParameterToConfigParameterInfoMap,
@@ -108,6 +101,7 @@
   ReviewerUpdateInfo,
   Reviewers,
   RevisionInfo,
+  RevisionPatchSetNum,
   SchemesInfoMap,
   ServerInfo,
   SubmitTypeInfo,
@@ -121,10 +115,11 @@
   WebLinkInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
+  Base64FileContent,
 } from '../api/rest-api';
 import {DiffInfo, IgnoreWhitespaceType} from './diff';
 
-export {
+export type {
   AccountId,
   AccountDetailInfo,
   AccountInfo,
@@ -134,6 +129,7 @@
   ApprovalInfo,
   AuthInfo,
   AvatarInfo,
+  Base64FileContent,
   BasePatchSetNum,
   BranchName,
   BrandType,
@@ -158,6 +154,7 @@
   DetailedLabelInfo,
   DownloadInfo,
   DownloadSchemeInfo,
+  EditPatchSet,
   EmailAddress,
   FileInfo,
   GerritInfo,
@@ -174,7 +171,7 @@
   LabelInfo,
   LabelNameToInfoMap,
   LabelNameToLabelTypeInfoMap,
-  LabelNameToValueMap,
+  LabelNameToValuesMap,
   LabelTypeInfo,
   LabelTypeInfoValues,
   LabelValueToDescriptionMap,
@@ -182,6 +179,7 @@
   NumericChangeId,
   ParentCommitInfo,
   PatchSetNum,
+  PatchSetNumber,
   PluginConfigInfo,
   PluginNameToPluginParametersMap,
   PluginParameterToConfigParameterInfoMap,
@@ -196,6 +194,7 @@
   ReviewerUpdateInfo,
   Reviewers,
   RevisionInfo,
+  RevisionPatchSetNum,
   SchemesInfoMap,
   ServerInfo,
   SubmitTypeInfo,
@@ -207,9 +206,8 @@
   UserConfigInfo,
   VotingRangeInfo,
   WebLinkInfo,
-  isDetailedLabelInfo,
-  isQuickLabelInfo,
 };
+export {EDIT, PARENT, isDetailedLabelInfo, isQuickLabelInfo};
 
 /*
  * In T, make a set of properties whose keys are in the union K required
@@ -229,16 +227,6 @@
  */
 export type ParsedJSON = BrandType<unknown, '_parsedJSON'>;
 
-export type RevisionPatchSetNum = BrandType<'edit' | number, '_patchSet'>;
-
-export type PatchSetNumber = BrandType<number, '_patchSet'>;
-
-export const EditPatchSetNum = 'edit' as RevisionPatchSetNum;
-
-// TODO(TS): This is not correct, it is better to have a separate ApiPatchSetNum
-// without 'parent'.
-export const ParentPatchSetNum = 'PARENT' as BasePatchSetNum;
-
 export type RobotId = BrandType<string, '_robotId'>;
 
 export type RobotRunId = BrandType<string, '_robotRunId'>;
@@ -264,6 +252,8 @@
 // The Encoded UUID of the group
 export type EncodedGroupId = BrandType<string, '_encodedGroupId'>;
 
+export type UserId = AccountId | GroupId | EmailAddress;
+
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
 export interface ContributorAgreementInput {
   name?: string;
@@ -278,7 +268,9 @@
   'current_revision' | 'revisions'
 >;
 
-export function isAccount(x: AccountInfo | GroupInfo): x is AccountInfo {
+export function isAccount(
+  x: AccountInfo | GroupInfo | GitPersonInfo
+): x is AccountInfo {
   const account = x as AccountInfo;
   return account._account_id !== undefined || account.email !== undefined;
 }
@@ -692,9 +684,10 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
 export interface CommentInfo {
-  // TODO(TS): Make this required.
-  patch_set?: PatchSetNum;
   id: UrlEncodedCommentId;
+  updated: Timestamp;
+  // TODO(TS): Make this required. Every comment must have patch_set set.
+  patch_set?: RevisionPatchSetNum;
   path?: string;
   side?: CommentSide;
   parent?: number;
@@ -702,7 +695,6 @@
   range?: CommentRange;
   in_reply_to?: UrlEncodedCommentId;
   message?: string;
-  updated: Timestamp;
   author?: AccountInfo;
   tag?: string;
   unresolved?: boolean;
@@ -758,8 +750,6 @@
   type: string;
   _name?: string;
   _expectedType?: string;
-  _width?: number;
-  _height?: number;
 }
 
 /**
@@ -777,13 +767,13 @@
   can_add?: boolean;
   can_add_tags?: boolean;
   config_visible?: boolean;
-  groups: ProjectAccessGroups;
+  groups: RepoAccessGroups;
   config_web_links: WebLinkInfo[];
 }
 
-export type ProjectAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
+export type RepoAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
 export type LocalAccessSectionInfo = {[ref: string]: AccessSectionInfo};
-export type ProjectAccessGroups = {[uuid: string]: GroupInfo};
+export type RepoAccessGroups = {[uuid: string]: GroupInfo};
 
 /**
  * The AccessSectionInfo describes the access rights that are assigned on a ref.
@@ -866,7 +856,7 @@
   reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
   max_object_size_limit?: MaxObjectSizeLimitInfo;
   submit_type?: SubmitType;
-  state?: ProjectState;
+  state?: RepoState;
   plugin_config_values?: PluginNameToPluginParametersMap;
   commentlinks?: ConfigInfoCommentLinks;
 }
@@ -915,13 +905,13 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-access-input
  */
 export interface ProjectAccessInput {
-  remove?: RefToProjectAccessInfoMap;
-  add?: RefToProjectAccessInfoMap;
+  remove?: RefToRepoAccessInfoMap;
+  add?: RefToRepoAccessInfoMap;
   message?: string;
   parent?: string;
 }
 
-export type RefToProjectAccessInfoMap = {[refName: string]: ProjectAccessInfo};
+export type RefToRepoAccessInfoMap = {[refName: string]: ProjectAccessInfo};
 
 /**
  * Represent a file in a base64 encoding
@@ -932,16 +922,6 @@
 }
 
 /**
- * Represent a file in a base64 encoding; GrRestApiInterface returns it from some
- * methods
- */
-export interface Base64FileContent {
-  content: string | null;
-  type: string | null;
-  ok: true;
-}
-
-/**
  * The WatchedProjectsInfo entity contains information about a project watch for a user
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#project-watch-info
  */
@@ -964,14 +944,6 @@
 }
 
 /**
- * The AssigneeInput entity contains the identity of the user to be set as assignee
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#assignee-input
- */
-export interface AssigneeInput {
-  assignee: AccountId;
-}
-
-/**
  * The SshKeyInfo entity contains information about an SSH key of a user
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#ssh-key-info
  */
@@ -1148,6 +1120,7 @@
   work_in_progress_by_default?: boolean;
   // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
   email_format?: EmailFormat;
+  allow_browser_notifications?: boolean;
 }
 
 /**
@@ -1175,7 +1148,7 @@
 export interface ReviewInput {
   message?: string;
   tag?: ReviewInputTag;
-  labels?: LabelNameToValuesMap;
+  labels?: LabelNameToValueMap;
   comments?: PathToCommentsInputMap;
   robot_comments?: PathToRobotCommentsMap;
   drafts?: DraftsAction;
@@ -1198,8 +1171,7 @@
  */
 export interface ReviewResult {
   labels?: unknown;
-  // type of key is (AccountId | GroupId | EmailAddress)
-  reviewers?: {[key: string]: AddReviewerResult};
+  reviewers?: {[key: UserId]: AddReviewerResult};
   ready?: boolean;
 }
 
@@ -1210,14 +1182,14 @@
  * TODO(paiking): update this to ReviewerResult while considering removals.
  */
 export interface AddReviewerResult {
-  input: AccountId | GroupId | EmailAddress;
+  input: UserId;
   reviewers?: AccountInfo[];
   ccs?: AccountInfo[];
   error?: string;
   confirm?: boolean;
 }
 
-export type LabelNameToValuesMap = {[labelName: string]: number};
+export type LabelNameToValueMap = {[labelName: string]: number};
 export type PathToCommentsInputMap = {[path: string]: CommentInput[]};
 export type PathToRobotCommentsMap = {[path: string]: RobotCommentInput[]};
 export type RecipientTypeToNotifyInfoMap = {
@@ -1271,6 +1243,15 @@
 }
 
 /**
+ * The ApplyProvidedFixInput entity contains information for applying fixes, provided in the
+ * request body, to a revision.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#apply-provided-fix
+ */
+export interface ApplyProvidedFixInput {
+  fix_replacement_infos: FixReplacementInfo[];
+}
+
+/**
  * The FixReplacementInfo entity describes how the content of a file should be replaced by another content
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-replacement-info
  */
@@ -1293,7 +1274,7 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#reviewer-input
  */
 export interface ReviewerInput {
-  reviewer: AccountId | GroupId | EmailAddress;
+  reviewer: UserId;
   state?: ReviewerState;
   confirmed?: boolean;
   notify?: NotifyType;
@@ -1305,7 +1286,7 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-input
  */
 export interface AttentionSetInput {
-  user: AccountId;
+  user: UserId;
   reason: string;
   notify?: NotifyType;
   notify_details?: RecipientTypeToNotifyInfoMap;
@@ -1530,3 +1511,8 @@
   conflicts?: string[];
   mergeable_into?: string[];
 }
+
+export interface ChangeActionDialog extends HTMLElement {
+  resetFocus?(): void;
+  init?(): void;
+}
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 8146eb3..c03a167 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -6,19 +6,8 @@
  * 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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {WebLinkInfo} from '../api/rest-api';
 import {
@@ -35,7 +24,7 @@
   SkipLength,
 } from '../api/diff';
 
-export {
+export type {
   ChangeType,
   DiffIntralineInfo,
   DiffResponsiveMode,
@@ -48,12 +37,9 @@
 
 export interface DiffInfo extends DiffInfoApi {
   /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
-  meta_a: DiffFileMetaInfo;
+  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[];
+  meta_b?: DiffFileMetaInfo;
 
   /**
    * Links to the file diff in external sites as a list of DiffWebLinkInfo
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index f467cf6..f642af7 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -1,25 +1,13 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PatchSetNum} from './common';
-import {UIComment} from '../utils/comment-util';
+import {FixSuggestionInfo, PatchSetNum} from './common';
+import {ChangeMessage} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
-import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export enum EventType {
   BIND_VALUE_CHANGED = 'bind-value-changed',
@@ -48,7 +36,7 @@
   SHORTCUT_TRIGGERERD = 'shortcut-triggered',
   SHOW_ALERT = 'show-alert',
   SHOW_ERROR = 'show-error',
-  SHOW_PRIMARY_TAB = 'show-primary-tab',
+  SHOW_TAB = 'show-tab',
   SHOW_SECONDARY_TAB = 'show-secondary-tab',
   TAP_ITEM = 'tap-item',
   TITLE_CHANGE = 'title-change',
@@ -78,14 +66,14 @@
     'moved-link-clicked': MovedLinkClickedEvent;
     'open-fix-preview': OpenFixPreviewEvent;
     'close-fix-preview': CloseFixPreviewEvent;
-    'create-fix-comment': CreateFixCommentEvent;
+    'reply-to-comment': ReplyToCommentEvent;
     /* prettier-ignore */
     'reload': ReloadEvent;
     /* prettier-ignore */
     'reply': ReplyEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
-    'show-primary-tab': SwitchTabEvent;
+    'show-tab': SwitchTabEvent;
     'show-secondary-tab': SwitchTabEvent;
     'tap-item': TapItemEvent;
     'title-change': TitleChangeEvent;
@@ -102,11 +90,12 @@
     'server-error': ServerErrorEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
+    'auth-error': AuthErrorEvent;
   }
 }
 
 export interface BindValueChangeEventDetail {
-  value: string;
+  value: string | undefined;
 }
 export type BindValueChangeEvent = CustomEvent<BindValueChangeEventDetail>;
 
@@ -167,8 +156,8 @@
 export type NetworkErrorEvent = CustomEvent<NetworkErrorEventDetail>;
 
 export interface OpenFixPreviewEventDetail {
-  patchNum?: PatchSetNum;
-  comment?: UIComment;
+  patchNum: PatchSetNum;
+  fixSuggestions: FixSuggestionInfo[];
 }
 export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
 
@@ -176,11 +165,12 @@
   fixApplied: boolean;
 }
 export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
-export interface CreateFixCommentEventDetail {
-  patchNum?: PatchSetNum;
-  comment?: UIComment;
+export interface ReplyToCommentEventDetail {
+  content: string;
+  userWantsToEdit: boolean;
+  unresolved: boolean;
 }
-export type CreateFixCommentEvent = CustomEvent<CreateFixCommentEventDetail>;
+export type ReplyToCommentEvent = CustomEvent<ReplyToCommentEventDetail>;
 
 export interface PageErrorEventDetail {
   response?: Response;
@@ -217,12 +207,16 @@
 }
 export type ShowErrorEvent = CustomEvent<ShowErrorEventDetail>;
 
+export interface AuthErrorEventDetail {
+  message: string;
+  action: string;
+}
+export type AuthErrorEvent = CustomEvent<AuthErrorEventDetail>;
+
 // Type for the custom event to switch tab.
 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;
+  tab: string;
   // scroll into the tab afterwards, from custom event
   scrollIntoView?: boolean;
   // define state of tab after opening
@@ -236,6 +230,7 @@
   UNRESOLVED = 'unresolved',
   DRAFTS = 'drafts',
   SHOW_ALL = 'show all',
+  MENTIONS = 'mentions',
 }
 export interface ChecksTabState {
   statusOrCategory?: RunStatus | Category;
@@ -249,3 +244,12 @@
   title: string;
 }
 export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
+
+/**
+ * This event can be used for Polymer properties that have `notify: true` set.
+ * But it is also generally recommended when you want to notify your parent
+ * elements about a property update, also for Lit elements.
+ *
+ * The name of the event should be `prop-name-changed`.
+ */
+export type ValueChangedEvent<T = string> = CustomEvent<{value: T}>;
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index b5bd2aa..554fa23 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {ParsedJSON} from './common';
 import {HighlightJS} from './types';
@@ -29,12 +18,6 @@
       options: {callback: (text: string, href?: string) => void}
     ): void;
     ASSETS_PATH?: string;
-    // TODO(TS): define polymer type
-    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
diff --git a/polygerrit-ui/app/types/syntax-worker-api.ts b/polygerrit-ui/app/types/syntax-worker-api.ts
new file mode 100644
index 0000000..5dd8a36
--- /dev/null
+++ b/polygerrit-ui/app/types/syntax-worker-api.ts
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * This file defines the API of syntax-worker, which is a web worker for syntax
+ * highlighting based on the HighlightJS library.
+ *
+ * Workers communicate via `postMessage(e)` and `onMessage(e)` where `e` is a
+ * MessageEvent.
+ *
+ * SyntaxWorker expects incoming messages to be of type
+ * `MessageEvent<SyntaxWorkerMessage>`. And outgoing messages will be of type
+ * `MessageEvent<SyntaxWorkerResult>`.
+ */
+
+/** Type of incoming messages for SyntaxWorker. */
+export enum SyntaxWorkerMessageType {
+  INIT,
+  REQUEST,
+}
+
+/** Incoming message for SyntaxWorker. */
+export interface SyntaxWorkerMessage {
+  type: SyntaxWorkerMessageType;
+}
+
+/**
+ * Requests the worker to import the HighlightJS lib from the given URL and
+ * initializes and configures it. Has to be called once before you can send
+ * a SyntaxWorkerRequest message.
+ */
+export interface SyntaxWorkerInit extends SyntaxWorkerMessage {
+  type: SyntaxWorkerMessageType.INIT;
+  url: string;
+}
+
+export function isInit(
+  x: SyntaxWorkerMessage | undefined
+): x is SyntaxWorkerInit {
+  return !!x && x.type === SyntaxWorkerMessageType.INIT;
+}
+
+/**
+ * Requests the worker to highlight the given code. The worker must have been
+ * initialized before.
+ */
+export interface SyntaxWorkerRequest extends SyntaxWorkerMessage {
+  type: SyntaxWorkerMessageType.REQUEST;
+  language: string;
+  code: string;
+}
+
+export function isRequest(
+  x: SyntaxWorkerMessage | undefined
+): x is SyntaxWorkerRequest {
+  return !!x && x.type === SyntaxWorkerMessageType.REQUEST;
+}
+
+/** Type of outgoing messages of SyntaxWorker. */
+export interface SyntaxWorkerResult {
+  /** Unset or undefined means "success". */
+  error?: string;
+  /**
+   * Returned by SyntaxWorkerRequest calls. Every line gets its own array of
+   * ranges. `ranges[0]` are the ranges for line 1. Every line has an array,
+   * which may be empty. All ranges are guaranteed to be closed.
+   */
+  ranges?: SyntaxLayerLine[];
+}
+
+/** Ranges for one line. */
+export interface SyntaxLayerLine {
+  ranges: SyntaxLayerRange[];
+}
+
+/** Can be used for `length` in SyntaxLayerRange. */
+export const UNCLOSED = -1;
+
+/** Range of characters in a line to be syntax highlighted. */
+export interface SyntaxLayerRange {
+  /** 0-based inclusive. */
+  start: number;
+  /** Can only be UNCLOSED during processing. */
+  length: number;
+  /** HighlightJS specific names, e.g. 'literal'. */
+  className: string;
+}
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index b90b12f..557e3a0 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -1,55 +1,34 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {DiffLayer as DiffLayerApi} from '../api/diff';
-import {DiffViewMode, MessageTag, Side} from '../constants/constants';
+import {MessageTag, Side} from '../constants/constants';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-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,
+  EditPatchSet,
   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;
+export function isDefined<T>(x: T): x is NonNullable<T> {
+  return x !== undefined && x !== null;
 }
 
 export interface FixIronA11yAnnouncer extends IronA11yAnnouncer {
   requestAvailability(): void;
 }
 
-export interface CommitRange {
-  baseCommit: CommitId;
-  commit: CommitId;
-}
-
-export {CoverageRange, CoverageType} from '../api/diff';
+export type {CoverageRange} from '../api/diff';
+export {CoverageType} from '../api/diff';
 
 export enum ErrorType {
   AUTH = 'AUTH',
@@ -57,101 +36,11 @@
   GENERIC = 'GENERIC',
 }
 
-/**
- * We would like to access the the typed `nativeInput` of PaperInputElement, so
- * we are creating this wrapper.
- */
-export type PaperInputElementExt = PaperInputElement & {
-  $: {nativeInput?: Element};
-};
-
-/**
- * If Polymer would have exported DomApiNative from its dom.js utility, then we
- * would probably not need this type. We just use it for casting the return
- * value of dom(element).
- */
-export interface PolymerDomWrapper {
-  getOwnerRoot(): Node & OwnerRoot;
-  getEffectiveChildNodes(): Node[];
-  observeNodes(
-    callback: (p0: {
-      target: HTMLElement;
-      addedNodes: Element[];
-      removedNodes: Element[];
-    }) => void
-  ): FlattenedNodesObserver;
-  unobserveNodes(observerHandle: FlattenedNodesObserver): void;
-}
-
+/*
 export interface OwnerRoot {
   host?: HTMLElement;
 }
 
-/**
- * Event type for an event fired by Polymer for an element generated from a
- * dom-repeat template.
- */
-export interface PolymerDomRepeatEvent<TModel = unknown> extends Event {
-  model: PolymerDomRepeatEventModel<TModel>;
-}
-
-/**
- * Event type for an event fired by Polymer for an element generated from a
- * dom-repeat template.
- */
-export interface PolymerDomRepeatCustomEvent<
-  TModel = unknown,
-  TDetail = unknown
-> extends CustomEvent<TDetail> {
-  model: PolymerDomRepeatEventModel<TModel>;
-}
-
-/**
- * Model containing additional information about the dom-repeat element
- * that fired an event.
- *
- * Note: This interface is valid only if both dom-repeat properties 'as' and
- * 'indexAs' have default values ('item' and 'index' correspondingly)
- */
-export interface PolymerDomRepeatEventModel<T> {
-  /**
-   * The item corresponding to the element in the dom-repeat.
-   */
-  item: T;
-
-  /**
-   * The index of the element in the dom-repeat.
-   */
-  index: number;
-  get(name: 'item'): T;
-  // Typed get for item.prop_name
-  get<K extends keyof T>(name: `item.${K extends string ? K : never}`): T[K];
-  // Typed get for item.prop_name.nested_prop_name
-  get<K1 extends keyof T, K2 extends keyof T[K1]>(
-    name: `item.${K1 extends string ? K1 : never}.${K2 extends string
-      ? K2
-      : never}`
-  ): T[K1][K2];
-  // Untyped get for other cases
-  get(name: string): unknown; // force get(...) as Type for nested properties
-
-  set(name: 'item', val: T): void;
-  // Typed set for item.prop_name
-  set<K extends keyof T>(
-    name: `item.${K extends string ? K : never}`,
-    val: T[K]
-  ): void;
-  // Typed get for item.prop_name.nested_prop_name
-  set<K1 extends keyof T, K2 extends keyof T[K1]>(
-    name: `item.${K1 extends string ? K1 : never}.${K2 extends string
-      ? K2
-      : never}`,
-    val: T[K1][K2]
-  ): void;
-  // Untyped set for other cases
-  set(name: string, val: unknown): void;
-}
-
 /** https://highlightjs.readthedocs.io/en/latest/api.html */
 export interface HighlightJSResult {
   value: string;
@@ -166,12 +55,14 @@
     languageName: string,
     code: string,
     ignore_illegals: boolean,
-    continuation: unknown
+    continuation?: unknown
   ): HighlightJSResult;
 }
 
 export type DiffLayerListener = (
+  /** 1-based inclusive */
   start: number,
+  /** 1-based inclusive */
   end: number,
   side: Side
 ) => void;
@@ -181,41 +72,6 @@
   removeListener?(listener: DiffLayerListener): void;
 }
 
-export interface ChangeViewState {
-  changeNum: NumericChangeId | null;
-  patchRange: PatchRange | null;
-  selectedFileIndex: number;
-  showReplyDialog: boolean;
-  showDownloadDialog: boolean;
-  diffMode: DiffViewMode | null;
-  numFilesShown: number | null;
-}
-
-export interface ChangeListViewState {
-  changeNum?: ChangeId;
-  patchRange?: PatchRange;
-  // TODO(TS): seems only one of 2 selected... is required
-  selectedFileIndex?: number;
-  selectedChangeIndex?: number;
-  showReplyDialog?: boolean;
-  showDownloadDialog?: boolean;
-  diffMode?: DiffViewMode;
-  numFilesShown?: number;
-  scrollTop?: number;
-  query?: string | null;
-  offset?: number;
-}
-
-export interface DashboardViewState {
-  [key: string]: number;
-}
-
-export interface ViewState {
-  changeView: ChangeViewState;
-  changeListView: ChangeListViewState;
-  dashboardView: DashboardViewState;
-}
-
 export interface PatchSetFile {
   path: string;
   basePath?: string;
@@ -237,13 +93,6 @@
   path: string;
 }
 
-export function isPolymerSpliceChange<
-  T,
-  U extends Array<{} | null | undefined>
->(x: T | PolymerSpliceChange<U>): x is PolymerSpliceChange<U> {
-  return (x as PolymerSpliceChange<U>).indexSplices !== undefined;
-}
-
 export interface FetchRequest {
   url: string;
   fetchOptions?: AuthRequestInit;
@@ -260,7 +109,7 @@
 
 export interface EditRevisionInfo extends Partial<RevisionInfo> {
   // EditRevisionInfo has less required properties then RevisionInfo
-  _number: PatchSetNum;
+  _number: EditPatchSet;
   basePatchNum: BasePatchSetNum;
   commit: CommitInfo;
 }
diff --git a/polygerrit-ui/app/utils/access-util.ts b/polygerrit-ui/app/utils/access-util.ts
index 165eacf..a567dd9 100644
--- a/polygerrit-ui/app/utils/access-util.ts
+++ b/polygerrit-ui/app/utils/access-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {GitRef, LabelName} from '../types/common';
 
@@ -25,7 +14,6 @@
   DELETE = 'delete',
   DELETE_CHANGES = 'deleteChanges',
   DELETE_OWN_CHANGES = 'deleteOwnChanges',
-  EDIT_ASSIGNEE = 'editAssignee',
   EDIT_HASHTAGS = 'editHashtags',
   EDIT_TOPIC_NAME = 'editTopicName',
   FORGE_AUTHOR = 'forgeAuthor',
@@ -79,10 +67,6 @@
     id: AccessPermissionId.DELETE_OWN_CHANGES,
     name: 'Delete Own Changes',
   },
-  [AccessPermissionId.EDIT_ASSIGNEE]: {
-    id: AccessPermissionId.EDIT_ASSIGNEE,
-    name: 'Edit Assignee',
-  },
   [AccessPermissionId.EDIT_HASHTAGS]: {
     id: AccessPermissionId.EDIT_HASHTAGS,
     name: 'Edit Hashtags',
diff --git a/polygerrit-ui/app/utils/access-util_test.ts b/polygerrit-ui/app/utils/access-util_test.ts
index f098d89..be429bd 100644
--- a/polygerrit-ui/app/utils/access-util_test.ts
+++ b/polygerrit-ui/app/utils/access-util_test.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
 import {toSortedPermissionsArray} from './access-util';
 
 suite('access-util tests', () => {
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 08a625c..ebf9e7a 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {
   AccountId,
   AccountInfo,
@@ -23,47 +11,33 @@
   GroupId,
   GroupInfo,
   isAccount,
+  isDetailedLabelInfo,
   isGroup,
+  NumericChangeId,
   ReviewerInput,
   ServerInfo,
+  UserId,
 } from '../types/common';
 import {AccountTag, ReviewerState} from '../constants/constants';
-import {assertNever} from './common-util';
-import {AccountAddition} from '../elements/shared/gr-account-list/gr-account-list';
-import {getDisplayName} from './display-name-util';
+import {assertNever, hasOwnProperty} from './common-util';
+import {getAccountDisplayName, getDisplayName} from './display-name-util';
+import {getApprovalInfo} from './label-util';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+import {ParsedChangeInfo} from '../types/types';
+import {throwingErrorCallback} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 
 export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
+const SUGGESTIONS_LIMIT = 15;
+// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
+export const MENTIONS_REGEX =
+  /(?:^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?=\s+|$)/g;
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
-  if (account._account_id) return account._account_id;
+  if (account._account_id !== undefined) return account._account_id;
   if (account.email) return account.email;
   throw new Error('Account has neither _account_id nor email.');
 }
 
-export function mapReviewer(addition: AccountAddition): ReviewerInput {
-  if (addition.account) {
-    return {reviewer: accountKey(addition.account)};
-  }
-  if (addition.group) {
-    const reviewer = decodeURIComponent(addition.group.id) as GroupId;
-    const confirmed = addition.group.confirmed;
-    return {reviewer, confirmed};
-  }
-  throw new Error('Reviewer must be either an account or a group.');
-}
-
-export function isReviewerOrCC(
-  change: ChangeInfo,
-  reviewerAddition: AccountAddition
-): boolean {
-  const reviewers = [
-    ...(change.reviewers[ReviewerState.CC] ?? []),
-    ...(change.reviewers[ReviewerState.REVIEWER] ?? []),
-  ];
-  const reviewer = mapReviewer(reviewerAddition);
-  return reviewers.some(r => accountOrGroupKey(r) === reviewer.reviewer);
-}
-
 export function isServiceUser(account?: AccountInfo): boolean {
   return !!account?.tags?.includes(AccountTag.SERVICE_USER);
 }
@@ -80,12 +54,27 @@
   return account?.avatars?.[0]?.url === other?.avatars?.[0]?.url;
 }
 
-export function accountOrGroupKey(entry: AccountInfo | GroupInfo) {
+export function getUserId(entry: AccountInfo | GroupInfo): UserId {
   if (isAccount(entry)) return accountKey(entry);
   if (isGroup(entry)) return entry.id;
   assertNever(entry, 'entry must be account or group');
 }
 
+export function isAccountEmailOnly(entry: AccountInfo | GroupInfo) {
+  if (isGroup(entry)) return false;
+  return !entry._account_id;
+}
+
+export function isAccountNewlyAdded(
+  account: AccountInfo | GroupInfo,
+  state?: ReviewerState,
+  change?: ChangeInfo | ParsedChangeInfo
+) {
+  if (!change || !state) return false;
+  const accounts = [...(change.reviewers[state] ?? [])];
+  return !accounts.some(a => getUserId(a) === getUserId(account));
+}
+
 export function uniqueDefinedAvatar(
   account: AccountInfo,
   index: number,
@@ -96,8 +85,27 @@
   );
 }
 
+export function uniqueAccountId(
+  account: AccountInfo,
+  index: number,
+  accountArray: AccountInfo[]
+) {
+  return (
+    index ===
+    accountArray.findIndex(other => account._account_id === other._account_id)
+  );
+}
+
+export function isDetailedAccount(account?: AccountInfo) {
+  // In case ChangeInfo is requested without DetailedAccount option, the
+  // reviewer entry is returned as just {_account_id: 123}
+  // This object should also be treated as not detailed account if they have
+  // an AccountId and no email
+  return !!account?.email && !!account?._account_id;
+}
+
 /**
- * @desc Get account in pseudonymized form, that can be send to the backend.
+ * Get account in pseudonymized form, that can be send to the backend.
  *
  * If account is not present, returns anonymous user name according to config.
  */
@@ -108,7 +116,7 @@
 }
 
 /**
- * @desc Replace account templates with user display names in text, received from the backend.
+ * Replace account templates with user display names in text, received from the backend.
  */
 export function replaceTemplates(
   text: string,
@@ -129,3 +137,118 @@
     }
   );
 }
+
+/**
+ * Returns max permitted score for reviewer.
+ */
+const getReviewerPermittedScore = (
+  change: ChangeInfo,
+  reviewer: AccountInfo,
+  label: string
+) => {
+  // Note (issue 7874): sometimes the "all" list is not included in change
+  // detail responses, even when DETAILED_LABELS is included in options.
+  if (!change?.labels) {
+    return NaN;
+  }
+  const detailedLabel = change.labels[label];
+  if (!isDetailedLabelInfo(detailedLabel) || !detailedLabel.all) {
+    return NaN;
+  }
+  const approvalInfo = getApprovalInfo(detailedLabel, reviewer);
+  if (!approvalInfo) {
+    return NaN;
+  }
+  if (hasOwnProperty(approvalInfo, 'permitted_voting_range')) {
+    if (!approvalInfo.permitted_voting_range) return NaN;
+    return approvalInfo.permitted_voting_range.max;
+  } else if (hasOwnProperty(approvalInfo, 'value')) {
+    // If present, user can vote on the label.
+    return 0;
+  }
+  return NaN;
+};
+
+/**
+ * Explains which labels the user can vote on and which score they can
+ * give.
+ */
+export function computeVoteableText(change: ChangeInfo, reviewer: AccountInfo) {
+  if (!change || !change.labels) {
+    return '';
+  }
+  const maxScores = [];
+  for (const label of Object.keys(change.labels)) {
+    const maxScore = getReviewerPermittedScore(change, reviewer, label);
+    if (isNaN(maxScore) || maxScore < 0) {
+      continue;
+    }
+    const scoreLabel = maxScore > 0 ? `+${maxScore}` : `${maxScore}`;
+    maxScores.push(`${label}: ${scoreLabel}`);
+  }
+  return maxScores.join(', ');
+}
+
+export function getAccountSuggestions(
+  input: string,
+  restApiService: RestApiService,
+  config?: ServerInfo,
+  canSee?: NumericChangeId,
+  filterActive = false
+) {
+  return restApiService
+    .getSuggestedAccounts(
+      input,
+      SUGGESTIONS_LIMIT,
+      canSee,
+      filterActive,
+      throwingErrorCallback
+    )
+    .then(accounts => {
+      if (!accounts) return [];
+      const accountSuggestions = [];
+      for (const account of accounts) {
+        accountSuggestions.push({
+          name: getAccountDisplayName(config, account),
+          value: account._account_id?.toString(),
+        });
+      }
+      return accountSuggestions;
+    });
+}
+
+/**
+ * Extracts mentioned users from a given text.
+ * A user can be mentioned by triggering the mentions dropdown in a comment
+ * by typing @ at the start of the comment or after a space.
+ * The Mentions Regex first looks start of sentence or whitespace (?:^|\s) then
+ * @ token which would have triggered the mentions dropdown and then looks
+ * for the email token ending with a whitespace or end of string.
+ */
+export function extractMentionedUsers(text?: string): AccountInfo[] {
+  if (!text) return [];
+  let match;
+  const users = [];
+  while ((match = MENTIONS_REGEX.exec(text))) {
+    users.push({
+      email: match[1] as EmailAddress,
+    });
+  }
+  return users;
+}
+
+export function toReviewInput(
+  account: AccountInfo | GroupInfo,
+  state: ReviewerState
+): ReviewerInput {
+  if (isAccount(account)) {
+    return {
+      reviewer: accountKey(account),
+      state,
+    };
+  } else if (isGroup(account)) {
+    const reviewer = decodeURIComponent(account.id) as GroupId;
+    return {reviewer, state};
+  }
+  throw new Error('Must be either an account or a group.');
+}
diff --git a/polygerrit-ui/app/utils/account-util_test.ts b/polygerrit-ui/app/utils/account-util_test.ts
index 8ec9181..3e61255 100644
--- a/polygerrit-ui/app/utils/account-util_test.ts
+++ b/polygerrit-ui/app/utils/account-util_test.ts
@@ -1,23 +1,15 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
+  computeVoteableText,
+  extractMentionedUsers,
   getAccountTemplate,
+  isAccountEmailOnly,
+  isDetailedAccount,
   isServiceUser,
   removeServiceUsers,
   replaceTemplates,
@@ -27,8 +19,22 @@
   AccountTag,
   DefaultDisplayNameConfig,
 } from '../constants/constants';
-import {AccountId, AccountInfo, ServerInfo} from '../api/rest-api';
-import {createServerInfo} from '../test/test-data-generators';
+import {
+  AccountId,
+  AccountInfo,
+  EmailAddress,
+  ServerInfo,
+} from '../api/rest-api';
+import {
+  createAccountDetailWithId,
+  createAccountWithEmailOnly,
+  createAccountWithId,
+  createChange,
+  createDetailedLabelInfo,
+  createGroupInfo,
+  createServerInfo,
+} from '../test/test-data-generators';
+import {assert} from '@open-wc/testing';
 
 const EMPTY = {};
 const ERNIE = {name: 'Ernie'};
@@ -57,7 +63,7 @@
   },
 ];
 
-suite('account-util tests 3', () => {
+suite('account-util tests', () => {
   test('isServiceUser', () => {
     assert.isFalse(isServiceUser());
     assert.isFalse(isServiceUser(EMPTY));
@@ -66,6 +72,50 @@
     assert.isTrue(isServiceUser(BOTTY));
   });
 
+  test('extractMentionedUsers', () => {
+    let text =
+      'Hi @kamilm@google.com and @brohlfs@google.com can you take a look at this?';
+    assert.deepEqual(extractMentionedUsers(text), [
+      {email: 'kamilm@google.com' as EmailAddress},
+      {email: 'brohlfs@google.com' as EmailAddress},
+    ]);
+
+    // with extra @
+    text = '@@abc@google.com';
+    assert.deepEqual(extractMentionedUsers(text), []);
+
+    // with spaces in email
+    text = '@a bc@google.com';
+    assert.deepEqual(extractMentionedUsers(text), []);
+
+    // with invalid email
+    text = '@abcgoogle.com';
+    assert.deepEqual(extractMentionedUsers(text), []);
+
+    text = '@abc@googlecom';
+    assert.deepEqual(extractMentionedUsers(text), [
+      {email: 'abc@googlecom' as EmailAddress},
+    ]);
+
+    // with newline before email
+    text = '\n\n\n random text  \n\n@abc@google.com';
+    assert.deepEqual(extractMentionedUsers(text), [
+      {email: 'abc@google.com' as EmailAddress},
+    ]);
+
+    text = '@abc@google.com please take a look at this';
+    assert.deepEqual(extractMentionedUsers(text), [
+      {email: 'abc@google.com' as EmailAddress},
+    ]);
+
+    text = '@a@google.com @b@google.com @c@google.com';
+    assert.deepEqual(extractMentionedUsers(text), [
+      {email: 'a@google.com' as EmailAddress},
+      {email: 'b@google.com' as EmailAddress},
+      {email: 'c@google.com' as EmailAddress},
+    ]);
+  });
+
   test('removeServiceUsers', () => {
     assert.sameMembers(removeServiceUsers([]), []);
     assert.sameMembers(removeServiceUsers([EMPTY, ERNIE]), [EMPTY, ERNIE]);
@@ -76,6 +126,14 @@
     ]);
   });
 
+  test('isAccountEmailOnly', () => {
+    assert.isFalse(isAccountEmailOnly(createAccountWithId(1)));
+    assert.isTrue(
+      isAccountEmailOnly(createAccountWithEmailOnly('a' as EmailAddress))
+    );
+    assert.isFalse(isAccountEmailOnly(createGroupInfo()));
+  });
+
   test('replaceTemplates with display config', () => {
     assert.equal(
       replaceTemplates(
@@ -146,4 +204,81 @@
     assert.equal(getAccountTemplate({}, config), 'Unidentified User');
     assert.equal(getAccountTemplate(), 'Anonymous');
   });
+
+  test('votable labels', async () => {
+    const change = {
+      ...createChange(),
+      labels: {
+        Foo: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 2, min: 0},
+            },
+          ],
+        },
+        Bar: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createAccountDetailWithId(1),
+              permitted_voting_range: {max: 1, min: 0},
+            },
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 1, min: 0},
+            },
+          ],
+        },
+        FooBar: {
+          ...createDetailedLabelInfo(),
+          all: [{_account_id: 7 as AccountId, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+
+    assert.strictEqual(
+      computeVoteableText(change, {...createAccountDetailWithId(1)}),
+      'Bar: +1'
+    );
+    assert.strictEqual(
+      computeVoteableText(change, {...createAccountDetailWithId(7)}),
+      'Foo: +2, Bar: +1, FooBar: 0'
+    );
+    assert.strictEqual(
+      computeVoteableText(change, {...createAccountDetailWithId(2)}),
+      ''
+    );
+  });
+
+  test('isDetailedAccount', () => {
+    assert.isFalse(isDetailedAccount({_account_id: 12345 as AccountId}));
+    assert.isFalse(isDetailedAccount({email: 'abcd' as EmailAddress}));
+
+    assert.isTrue(
+      isDetailedAccount({
+        _account_id: 12345 as AccountId,
+        email: 'abcd' as EmailAddress,
+      })
+    );
+  });
+
+  test('fails gracefully when all is not included', async () => {
+    const change = {
+      ...createChange(),
+      labels: {Foo: {}},
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+      },
+    };
+    assert.strictEqual(
+      computeVoteableText(change, {...createAccountDetailWithId(1)}),
+      ''
+    );
+  });
 });
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index 8bd22ef..7916799 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -1,25 +1,9 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {
-  GerritNav,
-  RepoDetailView,
-  GroupDetailView,
-} from '../elements/core/gr-navigation/gr-navigation';
-import {
   RepoName,
   GroupId,
   AccountDetailInfo,
@@ -28,29 +12,32 @@
 import {hasOwnProperty} from './common-util';
 import {GerritView} from '../services/router/router-model';
 import {MenuLink} from '../api/admin';
+import {AdminChildView, createAdminUrl} from '../models/views/admin';
+import {createGroupUrl, GroupDetailView} from '../models/views/group';
+import {createRepoUrl, RepoDetailView} from '../models/views/repo';
 
 const ADMIN_LINKS: NavLink[] = [
   {
     name: 'Repositories',
     noBaseUrl: true,
-    url: '/admin/repos',
-    view: 'gr-repo-list',
+    url: createAdminUrl({adminView: AdminChildView.REPOS}),
+    view: 'gr-repo-list' as GerritView,
     viewableToAll: true,
   },
   {
     name: 'Groups',
     section: 'Groups',
     noBaseUrl: true,
-    url: '/admin/groups',
-    view: 'gr-admin-group-list',
+    url: createAdminUrl({adminView: AdminChildView.GROUPS}),
+    view: 'gr-admin-group-list' as GerritView,
   },
   {
     name: 'Plugins',
     capability: 'viewPlugins',
     section: 'Plugins',
     noBaseUrl: true,
-    url: '/admin/plugins',
-    view: 'gr-plugin-list',
+    url: createAdminUrl({adminView: AdminChildView.PLUGINS}),
+    view: 'gr-plugin-list' as GerritView,
   },
 ];
 
@@ -77,11 +64,11 @@
 ): Promise<AdminLinks> {
   if (!account) {
     return Promise.resolve(
-      _filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
+      filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
     );
   }
   return getAccountCapabilities().then(capabilities =>
-    _filterLinks(
+    filterLinks(
       link => !link.capability || hasOwnProperty(capabilities, link.capability),
       getAdminMenuLinks,
       options
@@ -89,7 +76,7 @@
   );
 }
 
-function _filterLinks(
+function filterLinks(
   filterFn: (link: NavLink) => boolean,
   getAdminMenuLinks: () => MenuLink[],
   options?: AdminNavLinksOption
@@ -107,7 +94,7 @@
         name: link.text,
         capability: link.capability || undefined,
         noBaseUrl: !isExternalLink(link),
-        view: null,
+        view: undefined,
         viewableToAll: !link.capability,
         target: isExternalLink(link) ? '_blank' : null,
       };
@@ -163,69 +150,69 @@
   const children: SubsectionInterface[] = [];
   const subsection: SubsectionInterface = {
     name: groupName,
-    view: GerritNav.View.GROUP,
-    url: GerritNav.getUrlForGroup(groupId),
+    view: GerritView.GROUP,
+    url: createGroupUrl({groupId}),
     children,
   };
   if (groupIsInternal) {
     children.push({
       name: 'Members',
-      detailType: GerritNav.GroupDetailView.MEMBERS,
-      view: GerritNav.View.GROUP,
-      url: GerritNav.getUrlForGroupMembers(groupId),
+      detailType: GroupDetailView.MEMBERS,
+      view: GerritView.GROUP,
+      url: createGroupUrl({groupId, detail: GroupDetailView.MEMBERS}),
     });
   }
   if (groupIsInternal && (isAdmin || groupOwner)) {
     children.push({
       name: 'Audit Log',
-      detailType: GerritNav.GroupDetailView.LOG,
-      view: GerritNav.View.GROUP,
-      url: GerritNav.getUrlForGroupLog(groupId),
+      detailType: GroupDetailView.LOG,
+      view: GerritView.GROUP,
+      url: createGroupUrl({groupId, detail: GroupDetailView.LOG}),
     });
   }
   return subsection;
 }
 
-export function getRepoSubsections(repoName: RepoName) {
+export function getRepoSubsections(repo: RepoName) {
   return {
-    name: repoName,
-    view: GerritNav.View.REPO,
+    name: repo,
+    view: GerritView.REPO,
     children: [
       {
         name: 'General',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.GENERAL,
-        url: GerritNav.getUrlForRepo(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.GENERAL,
+        url: createRepoUrl({repo, detail: RepoDetailView.GENERAL}),
       },
       {
         name: 'Access',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.ACCESS,
-        url: GerritNav.getUrlForRepoAccess(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.ACCESS,
+        url: createRepoUrl({repo, detail: RepoDetailView.ACCESS}),
       },
       {
         name: 'Commands',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.COMMANDS,
-        url: GerritNav.getUrlForRepoCommands(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.COMMANDS,
+        url: createRepoUrl({repo, detail: RepoDetailView.COMMANDS}),
       },
       {
         name: 'Branches',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.BRANCHES,
-        url: GerritNav.getUrlForRepoBranches(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.BRANCHES,
+        url: createRepoUrl({repo, detail: RepoDetailView.BRANCHES}),
       },
       {
         name: 'Tags',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.TAGS,
-        url: GerritNav.getUrlForRepoTags(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.TAGS,
+        url: createRepoUrl({repo, detail: RepoDetailView.TAGS}),
       },
       {
         name: 'Dashboards',
-        view: GerritNav.View.REPO,
-        detailType: GerritNav.RepoDetailView.DASHBOARDS,
-        url: GerritNav.getUrlForRepoDashboards(repoName),
+        view: GerritView.REPO,
+        detailType: RepoDetailView.DASHBOARDS,
+        url: createRepoUrl({repo, detail: RepoDetailView.DASHBOARDS}),
       },
     ],
   };
@@ -252,10 +239,11 @@
   name: string;
   noBaseUrl: boolean;
   url: string;
-  view: string | null;
+  view?: GerritView | AdminChildView;
   viewableToAll?: boolean;
   section?: string;
   capability?: string;
   target?: string | null;
   subsection?: SubsectionInterface;
+  children?: SubsectionInterface[];
 }
diff --git a/polygerrit-ui/app/utils/admin-nav-util_test.js b/polygerrit-ui/app/utils/admin-nav-util_test.js
deleted file mode 100644
index 2a13904..0000000
--- a/polygerrit-ui/app/utils/admin-nav-util_test.js
+++ /dev/null
@@ -1,333 +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 {getAdminLinks} from './admin-nav-util.js';
-
-suite('gr-admin-nav-behavior tests', () => {
-  let capabilityStub;
-  let menuLinkStub;
-
-  setup(() => {
-    capabilityStub = sinon.stub();
-    menuLinkStub = sinon.stub().returns([]);
-  });
-
-  const testAdminLinks = async (account, options, expected) => {
-    const res = await getAdminLinks(account,
-        capabilityStub,
-        menuLinkStub,
-        options);
-
-    assert.equal(expected.totalLength, res.links.length);
-    assert.equal(res.links[0].name, 'Repositories');
-    // Repos
-    if (expected.groupListShown) {
-      assert.equal(res.links[1].name, 'Groups');
-    }
-
-    if (expected.pluginListShown) {
-      assert.equal(res.links[2].name, 'Plugins');
-      assert.isNotOk(res.links[2].subsection);
-    }
-
-    if (expected.projectPageShown) {
-      assert.isOk(res.links[0].subsection);
-      assert.equal(res.links[0].subsection.children.length, 6);
-    } else {
-      assert.isNotOk(res.links[0].subsection);
-    }
-    // Groups
-    if (expected.groupPageShown) {
-      assert.isOk(res.links[1].subsection);
-      assert.equal(res.links[1].subsection.children.length,
-          expected.groupSubpageLength);
-    } else if ( expected.totalLength > 1) {
-      assert.isNotOk(res.links[1].subsection);
-    }
-
-    if (expected.pluginGeneratedLinks) {
-      for (const link of expected.pluginGeneratedLinks) {
-        const linkMatch = res.links
-            .find(l => (l.url === link.url && l.name === link.text));
-        assert.isTrue(!!linkMatch);
-
-        // External links should open in new tab.
-        if (link.url[0] !== '/') {
-          assert.equal(linkMatch.target, '_blank');
-        } else {
-          assert.isNotOk(linkMatch.target);
-        }
-      }
-    }
-
-    // Current section
-    if (expected.projectPageShown || expected.groupPageShown) {
-      assert.isOk(res.expandedSection);
-      assert.isOk(res.expandedSection.children);
-    } else {
-      assert.isNotOk(res.expandedSection);
-    }
-    if (expected.projectPageShown) {
-      assert.equal(res.expandedSection.name, 'my-repo');
-      assert.equal(res.expandedSection.children.length, 6);
-    } else if (expected.groupPageShown) {
-      assert.equal(res.expandedSection.name, 'my-group');
-      assert.equal(res.expandedSection.children.length,
-          expected.groupSubpageLength);
-    }
-  };
-
-  suite('logged out', () => {
-    let account;
-    let expected;
-
-    setup(() => {
-      expected = {
-        groupListShown: false,
-        groupPageShown: false,
-        pluginListShown: false,
-      };
-    });
-
-    test('without a specific repo or group', async () => {
-      let options;
-      expected = Object.assign(expected, {
-        totalLength: 1,
-        projectPageShown: false,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-
-    test('with a repo', async () => {
-      const options = {repoName: 'my-repo'};
-      expected = Object.assign(expected, {
-        totalLength: 1,
-        projectPageShown: true,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-
-    test('with plugin generated links', async () => {
-      let options;
-      const generatedLinks = [
-        {text: 'internal link text', url: '/internal/link/url'},
-        {text: 'external link text', url: 'http://external/link/url'},
-      ];
-      menuLinkStub.returns(generatedLinks);
-      expected = Object.assign(expected, {
-        totalLength: 3,
-        projectPageShown: false,
-        pluginGeneratedLinks: generatedLinks,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-  });
-
-  suite('no plugin capability logged in', () => {
-    const account = {
-      name: 'test-user',
-    };
-    let expected;
-
-    setup(() => {
-      expected = {
-        totalLength: 2,
-        pluginListShown: false,
-      };
-      capabilityStub.returns(Promise.resolve({}));
-    });
-
-    test('without a specific project or group', async () => {
-      let options;
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupListShown: true,
-        groupPageShown: false,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-
-    test('with a repo', async () => {
-      const account = {
-        name: 'test-user',
-      };
-      const options = {repoName: 'my-repo'};
-      expected = Object.assign(expected, {
-        projectPageShown: true,
-        groupListShown: true,
-        groupPageShown: false,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-  });
-
-  suite('view plugin capability logged in', () => {
-    const account = {
-      name: 'test-user',
-    };
-    let expected;
-
-    setup(() => {
-      capabilityStub.returns(Promise.resolve({viewPlugins: true}));
-      expected = {
-        totalLength: 3,
-        groupListShown: true,
-        pluginListShown: true,
-      };
-    });
-
-    test('without a specific repo or group', async () => {
-      let options;
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupPageShown: false,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-
-    test('with a repo', async () => {
-      const options = {repoName: 'my-repo'};
-      expected = Object.assign(expected, {
-        projectPageShown: true,
-        groupPageShown: false,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-
-    test('admin with internal group', async () => {
-      const options = {
-        groupId: 'a15262',
-        groupName: 'my-group',
-        groupIsInternal: true,
-        isAdmin: true,
-        groupOwner: false,
-      };
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupPageShown: true,
-        groupSubpageLength: 2,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-
-    test('group owner with internal group', async () => {
-      const options = {
-        groupId: 'a15262',
-        groupName: 'my-group',
-        groupIsInternal: true,
-        isAdmin: false,
-        groupOwner: true,
-      };
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupPageShown: true,
-        groupSubpageLength: 2,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-
-    test('non owner or admin with internal group', async () => {
-      const options = {
-        groupId: 'a15262',
-        groupName: 'my-group',
-        groupIsInternal: true,
-        isAdmin: false,
-        groupOwner: false,
-      };
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupPageShown: true,
-        groupSubpageLength: 1,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-
-    test('admin with external group', async () => {
-      const options = {
-        groupId: 'a15262',
-        groupName: 'my-group',
-        groupIsInternal: false,
-        isAdmin: true,
-        groupOwner: true,
-      };
-      expected = Object.assign(expected, {
-        projectPageShown: false,
-        groupPageShown: true,
-        groupSubpageLength: 0,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-  });
-
-  suite('view plugin screen with plugin capability', () => {
-    const account = {
-      name: 'test-user',
-    };
-    let expected;
-
-    setup(() => {
-      capabilityStub.returns(Promise.resolve({pluginCapability: true}));
-      expected = {};
-    });
-
-    test('with plugin with capabilities', async () => {
-      let options;
-      const generatedLinks = [
-        {text: 'without capability', url: '/without'},
-        {text: 'with capability',
-          url: '/with',
-          capability: 'pluginCapability'},
-      ];
-      menuLinkStub.returns(generatedLinks);
-      expected = Object.assign(expected, {
-        totalLength: 4,
-        pluginGeneratedLinks: generatedLinks,
-      });
-      await testAdminLinks(account, options, expected);
-    });
-  });
-
-  suite('view plugin screen without plugin capability', () => {
-    const account = {
-      name: 'test-user',
-    };
-    let expected;
-
-    setup(() => {
-      capabilityStub.returns(Promise.resolve({}));
-      expected = {};
-    });
-
-    test('with plugin with capabilities', async () => {
-      let options;
-      const generatedLinks = [
-        {text: 'without capability', url: '/without'},
-        {text: 'with capability',
-          url: '/with',
-          capability: 'pluginCapability'},
-      ];
-      menuLinkStub.returns(generatedLinks);
-      expected = Object.assign(expected, {
-        totalLength: 3,
-        pluginGeneratedLinks: [generatedLinks[0]],
-      });
-      await testAdminLinks(account, options, expected);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/admin-nav-util_test.ts b/polygerrit-ui/app/utils/admin-nav-util_test.ts
new file mode 100644
index 0000000..a8600c7
--- /dev/null
+++ b/polygerrit-ui/app/utils/admin-nav-util_test.ts
@@ -0,0 +1,334 @@
+/**
+ * @license
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {AccountDetailInfo, GroupId, RepoName, Timestamp} from '../api/rest-api';
+import '../test/common-test-setup';
+import {AdminNavLinksOption, getAdminLinks} from './admin-nav-util';
+
+suite('gr-admin-nav-behavior tests', () => {
+  let capabilityStub: sinon.SinonStub;
+  let menuLinkStub: sinon.SinonStub;
+
+  setup(() => {
+    capabilityStub = sinon.stub();
+    menuLinkStub = sinon.stub().returns([]);
+  });
+
+  const testAdminLinks = async (
+    account: AccountDetailInfo | undefined,
+    options: AdminNavLinksOption | undefined,
+    expected: any
+  ) => {
+    const res = await getAdminLinks(
+      account,
+      capabilityStub,
+      menuLinkStub,
+      options
+    );
+
+    assert.equal(expected.totalLength, res.links.length);
+    assert.equal(res.links[0].name, 'Repositories');
+    // Repos
+    if (expected.groupListShown) {
+      assert.equal(res.links[1].name, 'Groups');
+    }
+
+    if (expected.pluginListShown) {
+      assert.equal(res.links[2].name, 'Plugins');
+      assert.isNotOk(res.links[2].subsection);
+    }
+
+    if (expected.projectPageShown) {
+      assert.isOk(res.links[0].subsection);
+      assert.equal(res.links[0].subsection!.children!.length, 6);
+    } else {
+      assert.isNotOk(res.links[0].subsection);
+    }
+    // Groups
+    if (expected.groupPageShown) {
+      assert.isOk(res.links[1].subsection);
+      assert.equal(
+        res.links[1].subsection!.children!.length,
+        expected.groupSubpageLength
+      );
+    } else if (expected.totalLength > 1) {
+      assert.isNotOk(res.links[1].subsection);
+    }
+
+    if (expected.pluginGeneratedLinks) {
+      for (const link of expected.pluginGeneratedLinks) {
+        const linkMatch = res.links.find(
+          l => l.url === link.url && l.name === link.text
+        );
+        assert.isTrue(!!linkMatch);
+
+        // External links should open in new tab.
+        if (link.url[0] !== '/') {
+          assert.equal(linkMatch!.target, '_blank');
+        } else {
+          assert.isNotOk(linkMatch!.target);
+        }
+      }
+    }
+
+    // Current section
+    if (expected.projectPageShown || expected.groupPageShown) {
+      assert.isOk(res.expandedSection);
+      assert.isOk(res.expandedSection!.children);
+    } else {
+      assert.isNotOk(res.expandedSection);
+    }
+    if (expected.projectPageShown) {
+      assert.equal(res.expandedSection!.name, 'my-repo');
+      assert.equal(res.expandedSection!.children!.length, 6);
+    } else if (expected.groupPageShown) {
+      assert.equal(res.expandedSection!.name, 'my-group');
+      assert.equal(
+        res.expandedSection!.children!.length,
+        expected.groupSubpageLength
+      );
+    }
+  };
+
+  suite('logged out', () => {
+    let account: AccountDetailInfo;
+    let expected: any;
+
+    setup(() => {
+      expected = {
+        groupListShown: false,
+        groupPageShown: false,
+        pluginListShown: false,
+      };
+    });
+
+    test('without a specific repo or group', async () => {
+      let options;
+      expected = Object.assign(expected, {
+        totalLength: 1,
+        projectPageShown: false,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+
+    test('with a repo', async () => {
+      const options = {repoName: 'my-repo' as RepoName};
+      expected = Object.assign(expected, {
+        totalLength: 1,
+        projectPageShown: true,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+
+    test('with plugin generated links', async () => {
+      let options;
+      const generatedLinks = [
+        {text: 'internal link text', url: '/internal/link/url'},
+        {text: 'external link text', url: 'http://external/link/url'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 3,
+        projectPageShown: false,
+        pluginGeneratedLinks: generatedLinks,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+  });
+
+  suite('no plugin capability logged in', () => {
+    const account = {
+      name: 'test-user',
+      registered_on: '' as Timestamp,
+    };
+    let expected: any;
+
+    setup(() => {
+      expected = {
+        totalLength: 2,
+        pluginListShown: false,
+      };
+      capabilityStub.returns(Promise.resolve({}));
+    });
+
+    test('without a specific project or group', async () => {
+      let options;
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupListShown: true,
+        groupPageShown: false,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+
+    test('with a repo', async () => {
+      const account = {
+        name: 'test-user',
+        registered_on: '' as Timestamp,
+      };
+      const options = {repoName: 'my-repo' as RepoName};
+      expected = Object.assign(expected, {
+        projectPageShown: true,
+        groupListShown: true,
+        groupPageShown: false,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+  });
+
+  suite('view plugin capability logged in', () => {
+    const account = {
+      name: 'test-user',
+      registered_on: '' as Timestamp,
+    };
+    let expected: any;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({viewPlugins: true}));
+      expected = {
+        totalLength: 3,
+        groupListShown: true,
+        pluginListShown: true,
+      };
+    });
+
+    test('without a specific repo or group', async () => {
+      let options;
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: false,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+
+    test('with a repo', async () => {
+      const options = {repoName: 'my-repo' as RepoName};
+      expected = Object.assign(expected, {
+        projectPageShown: true,
+        groupPageShown: false,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+
+    test('admin with internal group', async () => {
+      const options = {
+        groupId: 'a15262' as GroupId,
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: true,
+        groupOwner: false,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 2,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+
+    test('group owner with internal group', async () => {
+      const options = {
+        groupId: 'a15262' as GroupId,
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: false,
+        groupOwner: true,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 2,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+
+    test('non owner or admin with internal group', async () => {
+      const options = {
+        groupId: 'a15262' as GroupId,
+        groupName: 'my-group',
+        groupIsInternal: true,
+        isAdmin: false,
+        groupOwner: false,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 1,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+
+    test('admin with external group', async () => {
+      const options = {
+        groupId: 'a15262' as GroupId,
+        groupName: 'my-group',
+        groupIsInternal: false,
+        isAdmin: true,
+        groupOwner: true,
+      };
+      expected = Object.assign(expected, {
+        projectPageShown: false,
+        groupPageShown: true,
+        groupSubpageLength: 0,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+  });
+
+  suite('view plugin screen with plugin capability', () => {
+    const account = {
+      name: 'test-user',
+      registered_on: '' as Timestamp,
+    };
+    let expected: any;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({pluginCapability: true}));
+      expected = {};
+    });
+
+    test('with plugin with capabilities', async () => {
+      let options;
+      const generatedLinks = [
+        {text: 'without capability', url: '/without'},
+        {text: 'with capability', url: '/with', capability: 'pluginCapability'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 4,
+        pluginGeneratedLinks: generatedLinks,
+      });
+      await testAdminLinks(account, options, expected);
+    });
+  });
+
+  suite('view plugin screen without plugin capability', () => {
+    const account = {
+      name: 'test-user',
+      registered_on: '' as Timestamp,
+    };
+    let expected: any;
+
+    setup(() => {
+      capabilityStub.returns(Promise.resolve({}));
+      expected = {};
+    });
+
+    test('with plugin with capabilities', async () => {
+      let options;
+      const generatedLinks = [
+        {text: 'without capability', url: '/without'},
+        {text: 'with capability', url: '/with', capability: 'pluginCapability'},
+      ];
+      menuLinkStub.returns(generatedLinks);
+      expected = Object.assign(expected, {
+        totalLength: 3,
+        pluginGeneratedLinks: [generatedLinks[0]],
+      });
+      await testAdminLinks(account, options, expected);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 90ee5a5..cae6319 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -1,19 +1,11 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {Observable} from 'rxjs';
+import {filter, take} from 'rxjs/operators';
+import {assertIsDefined} from './common-util';
 
 /**
  * @param fn An iteratee function to be passed each element of
@@ -111,6 +103,92 @@
   return new DelayedTask(callback, waitMs);
 }
 
+export const DELAYED_CANCELLATION = Symbol('Delayed Cancellation');
+
+export class DelayedPromise<T> extends Promise<T> {
+  private resolve: (value: PromiseLike<T> | T) => void;
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  private reject: (reason?: any) => void;
+
+  private timer: number | undefined;
+
+  constructor(private readonly callback: () => Promise<T>, waitMs = 0) {
+    let resolve: ((value: PromiseLike<T> | T) => void) | undefined;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    let reject: ((reason?: any) => void) | undefined;
+    super((res, rej) => {
+      resolve = res;
+      reject = rej;
+    });
+    assertIsDefined(resolve);
+    assertIsDefined(reject);
+    this.resolve = resolve;
+    this.reject = reject;
+    this.timer = window.setTimeout(async () => {
+      await this.flush();
+    }, waitMs);
+  }
+
+  private stop() {
+    if (this.timer === undefined) return false;
+    window.clearTimeout(this.timer);
+    this.timer = undefined;
+    return true;
+  }
+
+  async flush() {
+    if (!this.stop()) return;
+    try {
+      this.resolve(await this.callback());
+    } catch (e) {
+      this.reject(e);
+    }
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  cancel(reason?: any) {
+    if (!this.stop()) return;
+    this.reject(reason ?? DELAYED_CANCELLATION);
+  }
+
+  delegate(other: Promise<T>) {
+    if (!this.stop()) return;
+    other
+      .then((value: T) => this.resolve(value))
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      .catch((reason?: any) => this.reject(reason));
+  }
+
+  // From ECMAScript specification:
+  // https://tc39.es/ecma262/#sec-get-promise-@@species
+  //    Promise prototype methods normally use their this value's constructor to
+  //    create a derived object. However, a subclass constructor may over-ride
+  //    that default behaviour by redefining its @@species property.
+  // NOTE: This is required otherwise .then and .catch on a DelayedPromise
+  // will try to instantiate a DelayedPromise with 'resolve, reject' arguments.
+  static override get [Symbol.species]() {
+    return Promise;
+  }
+
+  override get [Symbol.toStringTag]() {
+    return 'DelayedPromise';
+  }
+}
+
+/**
+ * The usage pattern is
+ * this.aDebouncedPromise = debounceP(this.aDebouncedPromise, () => {...}, 123)
+ */
+export function debounceP<T>(
+  existingPromise: DelayedPromise<T> | undefined,
+  callback: () => Promise<T>,
+  waitMs = 0
+): DelayedPromise<T> {
+  const promise = new DelayedPromise<T>(callback, waitMs);
+  if (existingPromise) existingPromise.delegate(promise);
+  return promise;
+}
 const THROTTLE_INTERVAL_MS = 500;
 
 /**
@@ -130,3 +208,93 @@
     fn(e);
   };
 }
+
+/**
+ * Let's you wait for an Observable to become true.
+ */
+export function until<T>(obs$: Observable<T>, predicate: (t: T) => boolean) {
+  return new Promise<void>(resolve => {
+    obs$.pipe(filter(predicate), take(1)).subscribe(() => {
+      resolve();
+    });
+  });
+}
+
+export const isFalse = (b: boolean) => b === false;
+
+export type PromiseResult<T> =
+  | {status: 'fulfilled'; value: T}
+  | {status: 'rejected'; reason: string};
+export function isFulfilled<T>(
+  promiseResult?: PromiseResult<T>
+): promiseResult is PromiseResult<T> & {status: 'fulfilled'} {
+  return promiseResult?.status === 'fulfilled';
+}
+
+// An equivalent to Promise.allSettled from ES2020.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
+// TODO: Migrate our tooling to ES2020 and remove this method.
+export function allSettled<T>(
+  promises: Promise<T>[]
+): Promise<PromiseResult<T>[]> {
+  return Promise.all(
+    promises.map(promise =>
+      promise
+        .then(value => ({status: 'fulfilled', value} as const))
+        .catch(reason => ({status: 'rejected', reason} as const))
+    )
+  );
+}
+
+/**
+ * Noop function that can be used to suppress the tsetse must-use-promises rule.
+ *
+ * Example Usage:
+ *   async function x() {
+ *     await doA();
+ *     noAwait(doB());
+ *   }
+ */
+export function noAwait(_: {then: Function} | null | undefined) {}
+
+export interface CancelablePromise<T> extends Promise<T> {
+  cancel(): void;
+}
+
+/**
+ * Make the promise cancelable.
+ *
+ * Returns a promise with a `cancel()` method wrapped around `promise`.
+ * Calling `cancel()` will reject the returned promise with
+ * {isCancelled: true} synchronously. If the inner promise for a cancelled
+ * promise resolves or rejects this is ignored.
+ */
+export function makeCancelable<T>(promise: Promise<T>) {
+  // True if the promise is either resolved or reject (possibly cancelled)
+  let isDone = false;
+
+  let rejectPromise: (reason?: unknown) => void;
+
+  const wrappedPromise: CancelablePromise<T> = new Promise(
+    (resolve, reject) => {
+      rejectPromise = reject;
+      promise.then(
+        val => {
+          if (!isDone) resolve(val);
+          isDone = true;
+        },
+        error => {
+          if (!isDone) reject(error);
+          isDone = true;
+        }
+      );
+    }
+  ) as CancelablePromise<T>;
+
+  wrappedPromise.cancel = () => {
+    if (isDone) return;
+    rejectPromise({isCanceled: true});
+    isDone = true;
+  };
+  return wrappedPromise;
+}
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
index 5c8f610..9f029b8 100644
--- a/polygerrit-ui/app/utils/async-util_test.ts
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -1,46 +1,208 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
-import {asyncForeach} from './async-util';
+import {assert} from '@open-wc/testing';
+import {SinonFakeTimers} from 'sinon';
+import '../test/common-test-setup';
+import {waitEventLoop} from '../test/test-utils';
+import {asyncForeach, debounceP} from './async-util';
 
 suite('async-util tests', () => {
-  test('loops over each item', async () => {
-    const fn = sinon.stub().resolves();
+  suite('asyncForeach', () => {
+    test('loops over each item', async () => {
+      const fn = sinon.stub().resolves();
 
-    await asyncForeach([1, 2, 3], fn);
+      await asyncForeach([1, 2, 3], fn);
 
-    assert.isTrue(fn.calledThrice);
-    assert.equal(fn.firstCall.firstArg, 1);
-    assert.equal(fn.secondCall.firstArg, 2);
-    assert.equal(fn.thirdCall.firstArg, 3);
+      assert.isTrue(fn.calledThrice);
+      assert.equal(fn.firstCall.firstArg, 1);
+      assert.equal(fn.secondCall.firstArg, 2);
+      assert.equal(fn.thirdCall.firstArg, 3);
+    });
+
+    test('halts on stop condition', async () => {
+      const stub = sinon.stub();
+      const fn = (item: number, stopCallback: () => void) => {
+        stub(item);
+        stopCallback();
+        return Promise.resolve();
+      };
+
+      await asyncForeach([1, 2, 3], fn);
+
+      assert.isTrue(stub.calledOnce);
+      assert.equal(stub.lastCall.firstArg, 1);
+    });
   });
 
-  test('halts on stop condition', async () => {
-    const stub = sinon.stub();
-    const fn = (item: number, stopCallback: () => void) => {
-      stub(item);
-      stopCallback();
-      return Promise.resolve();
-    };
+  suite('DelayedPromise', () => {
+    let clock: SinonFakeTimers;
+    setup(() => {
+      clock = sinon.useFakeTimers();
+    });
 
-    await asyncForeach([1, 2, 3], fn);
+    test('It resolves after timeout', async () => {
+      const promise = debounceP<number>(
+        undefined,
+        () => Promise.resolve(5),
+        100
+      );
+      let hasResolved = false;
+      promise.then((value: number) => {
+        hasResolved = true;
+        assert.equal(value, 5);
+      });
+      promise.catch((_reason?: any) => {
+        assert.fail();
+      });
+      await waitEventLoop();
+      assert.isFalse(hasResolved);
+      clock.tick(99);
+      await waitEventLoop();
+      assert.isFalse(hasResolved);
+      clock.tick(1);
+      await waitEventLoop();
+      assert.isTrue(hasResolved);
+      await promise;
+      // Shouldn't do anything.
+      promise.cancel();
+      await waitEventLoop();
+    });
 
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.firstArg, 1);
+    test('It resolves immediately on flush and finalizes', async () => {
+      const promise = debounceP<number>(
+        undefined,
+        () => Promise.resolve(5),
+        100
+      );
+      let hasResolved = false;
+      promise.then((value: number) => {
+        hasResolved = true;
+        assert.equal(value, 5);
+      });
+      promise.catch((_reason?: any) => {
+        assert.fail();
+      });
+      promise.flush();
+      await waitEventLoop();
+      assert.isTrue(hasResolved);
+      // Shouldn't do anything.
+      promise.cancel();
+      await waitEventLoop();
+    });
+
+    test('It rejects on cancel', async () => {
+      const promise = debounceP<number>(
+        undefined,
+        () => Promise.resolve(5),
+        100
+      );
+      let hasCanceled = false;
+      promise.then((_value: number) => {
+        assert.fail();
+      });
+      promise.catch((reason?: any) => {
+        hasCanceled = true;
+        assert.strictEqual(reason, 'because');
+      });
+      await waitEventLoop();
+      assert.isFalse(hasCanceled);
+      promise.cancel('because');
+      await waitEventLoop();
+      assert.isTrue(hasCanceled);
+      // Shouldn't do anything.
+      promise.flush();
+      await waitEventLoop();
+    });
+
+    test('It delegates correctly', async () => {
+      const promise1 = debounceP<number>(
+        undefined,
+        () => Promise.resolve(5),
+        100
+      );
+      let hasResolved1 = false;
+      promise1.then((value: number) => {
+        hasResolved1 = true;
+        assert.equal(value, 6);
+      });
+      promise1.catch((_reason?: any) => {
+        assert.fail();
+      });
+      await waitEventLoop();
+      assert.isFalse(hasResolved1);
+      clock.tick(99);
+      await waitEventLoop();
+      const promise2 = debounceP<number>(
+        promise1,
+        () => Promise.resolve(6),
+        100
+      );
+      let hasResolved2 = false;
+      promise2.then((value: number) => {
+        hasResolved2 = true;
+        assert.equal(value, 6);
+      });
+      promise2.catch((_reason?: any) => {
+        assert.fail();
+      });
+      clock.tick(99);
+      await waitEventLoop();
+      assert.isFalse(hasResolved1);
+      assert.isFalse(hasResolved2);
+      clock.tick(2);
+      await waitEventLoop();
+      assert.isTrue(hasResolved1);
+      assert.isTrue(hasResolved2);
+      // Shouldn't do anything.
+      promise1.cancel();
+      await waitEventLoop();
+    });
+
+    test('It does not delegate after timeout', async () => {
+      const promise1 = debounceP<number>(
+        undefined,
+        () => Promise.resolve(5),
+        100
+      );
+      let hasResolved1 = false;
+      promise1.then((value: number) => {
+        hasResolved1 = true;
+        assert.equal(value, 5);
+      });
+      promise1.catch((_reason?: any) => {
+        assert.fail();
+      });
+      await waitEventLoop();
+      assert.isFalse(hasResolved1);
+      clock.tick(100);
+      await waitEventLoop();
+      assert.isTrue(hasResolved1);
+
+      const promise2 = debounceP<number>(
+        promise1,
+        () => Promise.resolve(6),
+        100
+      );
+      let hasResolved2 = false;
+      promise2.then((value: number) => {
+        hasResolved2 = true;
+        assert.equal(value, 6);
+      });
+      promise2.catch((_reason?: any) => {
+        assert.fail();
+      });
+      clock.tick(99);
+      await waitEventLoop();
+      assert.isFalse(hasResolved2);
+      clock.tick(1);
+      await waitEventLoop();
+      assert.isTrue(hasResolved2);
+      // Shouldn't do anything.
+      promise1.cancel();
+      await waitEventLoop();
+    });
   });
 });
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index b0b7ef8..4404e59 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -1,21 +1,14 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {AccountInfo, ChangeInfo, ServerInfo} from '../types/common';
+import {
+  AccountInfo,
+  ChangeInfo,
+  DetailedLabelInfo,
+  ServerInfo,
+} from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
 import {
   getAccountTemplate,
@@ -23,7 +16,9 @@
   isServiceUser,
   replaceTemplates,
 } from './account-util';
+import {CommentThread, isMentionedThread, isUnresolved} from './comment-util';
 import {hasOwnProperty} from './common-util';
+import {getCodeReviewLabel} from './label-util';
 
 export function canHaveAttention(account?: AccountInfo): boolean {
   return !!account?._account_id && !isServiceUser(account);
@@ -49,7 +44,7 @@
   if (change?.attention_set === undefined) return '';
   if (account?._account_id === undefined) return '';
 
-  const attentionSetInfo = change.attention_set[account._account_id!];
+  const attentionSetInfo = change.attention_set[account._account_id];
 
   if (attentionSetInfo?.reason === undefined) return '';
 
@@ -60,6 +55,21 @@
   );
 }
 
+export function getMentionedReason(
+  threads: CommentThread[],
+  account?: AccountInfo,
+  mentionedAccount?: AccountInfo,
+  config?: ServerInfo
+) {
+  const mentionedThreads = threads
+    .filter(isUnresolved)
+    .filter(t => isMentionedThread(t, mentionedAccount));
+  if (mentionedThreads.length > 0) {
+    return `${getAccountTemplate(account, config)} mentioned you in a comment`;
+  }
+  return getReplyByReason(account, config);
+}
+
 export function getAddedByReason(account?: AccountInfo, config?: ServerInfo) {
   return `Added by ${getAccountTemplate(
     account,
@@ -97,9 +107,10 @@
 /**
  *  Sort order:
  * 1. The user themselves
- * 2. Human users in the attention set.
- * 3. Other human users.
- * 4. Service users.
+ * 2. Users in the attention set first.
+ * 3. Human users first.
+ * 4. Users that have voted first in this order of vote values:
+ *    -2, -1, +2, +1, 0 or no vote.
  */
 export function sortReviewers(
   r1: AccountInfo,
@@ -113,7 +124,22 @@
   }
   const a1 = hasAttention(r1, change) ? 1 : 0;
   const a2 = hasAttention(r2, change) ? 1 : 0;
-  const s1 = isServiceUser(r1) ? -2 : 0;
-  const s2 = isServiceUser(r2) ? -2 : 0;
-  return a2 - a1 + s2 - s1;
+  if (a2 - a1 !== 0) return a2 - a1;
+
+  const s1 = isServiceUser(r1) ? -1 : 0;
+  const s2 = isServiceUser(r2) ? -1 : 0;
+  if (s2 - s1 !== 0) return s2 - s1;
+
+  const crLabel = getCodeReviewLabel(change?.labels ?? {}) as DetailedLabelInfo;
+  let v1 =
+    crLabel?.all?.find(vote => vote._account_id === r1._account_id)?.value ?? 0;
+  let v2 =
+    crLabel?.all?.find(vote => vote._account_id === r2._account_id)?.value ?? 0;
+  // We want negative votes getting a higher score than positive votes, so
+  // we choose 10 as a random number that is higher than all positive votes that
+  // are in use, and then add the absolute value of the vote to that.
+  // So -2 becomes 12.
+  if (v1 < 0) v1 = 10 - v1;
+  if (v2 < 0) v2 = 10 - v2;
+  return v2 - v1;
 }
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.ts b/polygerrit-ui/app/utils/attention-set-util_test.ts
index 14832c0..5bd1924 100644
--- a/polygerrit-ui/app/utils/attention-set-util_test.ts
+++ b/polygerrit-ui/app/utils/attention-set-util_test.ts
@@ -1,22 +1,18 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
-import {createChange, createServerInfo} from '../test/test-data-generators';
+import '../test/common-test-setup';
+import {
+  createAccountDetailWithIdNameAndEmail,
+  createAccountWithId,
+  createChange,
+  createComment,
+  createCommentThread,
+  createParsedChange,
+  createServerInfo,
+} from '../test/test-data-generators';
 import {
   AccountId,
   AccountInfo,
@@ -24,9 +20,15 @@
   EmailAddress,
   ServerInfo,
 } from '../types/common';
-import {getReason, hasAttention} from './attention-set-util';
+import {
+  getMentionedReason,
+  getReason,
+  hasAttention,
+  sortReviewers,
+} from './attention-set-util';
 import {DefaultDisplayNameConfig} from '../api/rest-api';
-import {AccountsVisibility} from '../constants/constants';
+import {AccountsVisibility, AccountTag} from '../constants/constants';
+import {assert} from '@open-wc/testing';
 
 const KERMIT: AccountInfo = {
   email: 'kermit@gmail.com' as EmailAddress,
@@ -42,6 +44,20 @@
   _account_id: 31415926536 as AccountId,
 };
 
+const MENTION_ACCOUNT: AccountInfo = {
+  email: 'mention@gmail.com' as EmailAddress,
+  username: 'mention',
+  name: 'Mention User',
+  _account_id: 31415926537 as AccountId,
+};
+
+const MENTION_ACCOUNT_2: AccountInfo = {
+  email: 'mention2@gmail.com' as EmailAddress,
+  username: 'mention2',
+  name: 'Mention2 User',
+  _account_id: 31415926538 as AccountId,
+};
+
 const change: ChangeInfo = {
   ...createChange(),
   attention_set: {
@@ -54,6 +70,16 @@
       reason: 'Added by <GERRIT_ACCOUNT_31415926535>',
       reason_account: KERMIT,
     },
+    '31415926537': {
+      account: MENTION_ACCOUNT,
+      reason: '<GERRIT_ACCOUNT_31415926535> replied on the change',
+      reason_account: KERMIT,
+    },
+    '31415926538': {
+      account: MENTION_ACCOUNT_2,
+      reason: 'Bot voted negatively on the change',
+      reason_account: KERMIT,
+    },
   },
 };
 
@@ -77,4 +103,93 @@
     assert.equal(getReason(config, KERMIT, change), 'a good reason');
     assert.equal(getReason(config, OTHER_ACCOUNT, change), 'Added by kermit');
   });
+
+  test('sortReviewers', () => {
+    const a1 = createAccountWithId(1);
+    a1.tags = [AccountTag.SERVICE_USER];
+    const a2 = createAccountWithId(2);
+    a2.tags = [AccountTag.SERVICE_USER];
+    const a3 = createAccountWithId(3);
+    const a4 = createAccountWithId(4);
+    const a5 = createAccountWithId(5);
+    const a6 = createAccountWithId(6);
+    const a7 = createAccountWithId(7);
+
+    const reviewers = [a1, a2, a3, a4, a5, a6, a7];
+    const change = {
+      ...createParsedChange(),
+      attention_set: {'6': {account: a6}},
+      labels: {
+        'Code-Review': {
+          all: [
+            {...a2, value: 1},
+            {...a4, value: 1},
+            {...a5, value: -1},
+          ],
+        },
+      },
+    };
+    assert.sameOrderedMembers(
+      reviewers.sort((r1, r2) => sortReviewers(r1, r2, change, a7)),
+      [
+        a7, // self
+        a6, // is in the attention set
+        a5, // human user, has voted -1
+        a4, // human user, has voted +1
+        a3, // human user, has not voted
+        a2, // service user, has voted
+        a1, // service user, has not voted
+      ]
+    );
+  });
+
+  test('getMentionReason', () => {
+    let comment = {
+      ...createComment(),
+      message: `hey @${MENTION_ACCOUNT.email} take a look at this`,
+      unresolved: true,
+      author: {
+        ...createAccountDetailWithIdNameAndEmail(1),
+      },
+    };
+
+    assert.equal(
+      getMentionedReason(
+        [createCommentThread([comment])],
+        KERMIT,
+        KERMIT,
+        config
+      ),
+      '<GERRIT_ACCOUNT_31415926535> replied on the change'
+    );
+
+    assert.equal(
+      getMentionedReason(
+        [createCommentThread([comment])],
+        KERMIT,
+        MENTION_ACCOUNT,
+        config
+      ),
+      '<GERRIT_ACCOUNT_31415926535> mentioned you in a comment'
+    );
+
+    // resolved mention hence does not change reason
+    comment = {
+      ...createComment(),
+      message: `hey @${MENTION_ACCOUNT.email} take a look at this`,
+      unresolved: false,
+      author: {
+        ...createAccountDetailWithIdNameAndEmail(1),
+      },
+    };
+    assert.equal(
+      getMentionedReason(
+        [createCommentThread([comment])],
+        KERMIT,
+        MENTION_ACCOUNT,
+        config
+      ),
+      '<GERRIT_ACCOUNT_31415926535> replied on the change'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/utils/bulk-flow-util.ts b/polygerrit-ui/app/utils/bulk-flow-util.ts
new file mode 100644
index 0000000..8dac305
--- /dev/null
+++ b/polygerrit-ui/app/utils/bulk-flow-util.ts
@@ -0,0 +1,23 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ProgressStatus} from '../constants/constants';
+import {NumericChangeId} from '../api/rest-api';
+
+export function getOverallStatus(
+  progressByChangeNum: Map<NumericChangeId, ProgressStatus>
+) {
+  const statuses = Array.from(progressByChangeNum.values());
+  if (statuses.every(s => s === ProgressStatus.NOT_STARTED)) {
+    return ProgressStatus.NOT_STARTED;
+  }
+  if (statuses.some(s => s === ProgressStatus.RUNNING)) {
+    return ProgressStatus.RUNNING;
+  }
+  if (statuses.some(s => s === ProgressStatus.FAILED)) {
+    return ProgressStatus.FAILED;
+  }
+  return ProgressStatus.SUCCESSFUL;
+}
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
index 9692ab31..9d3106d 100644
--- a/polygerrit-ui/app/utils/change-metadata-util.ts
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {ParsedChangeInfo} from '../types/types';
 
 export enum Metadata {
@@ -33,7 +21,6 @@
   UPLOADER = 'Uploader',
   AUTHOR = 'Author',
   COMMITTER = 'Committer',
-  ASSIGNEE = 'Assignee',
   CHERRY_PICK_OF = 'Cherry pick of',
 }
 
@@ -51,7 +38,6 @@
     Metadata.UPLOADER,
     Metadata.AUTHOR,
     Metadata.COMMITTER,
-    Metadata.ASSIGNEE,
     Metadata.CHERRY_PICK_OF,
   ],
   ALWAYS_HIDE: [
@@ -74,7 +60,6 @@
     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;
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 278e7f3..aa54318 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {getBaseUrl} from './url-util';
 import {ChangeStatus} from '../constants/constants';
@@ -25,6 +14,7 @@
 } from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
 import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
+import {getUserId, isServiceUser} from './account-util';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
@@ -119,11 +109,11 @@
 }
 
 export function changeBaseURL(
-  project: string,
+  repo: string,
   changeNum: NumericChangeId,
   patchNum: PatchSetNum
 ): string {
-  let v = `${getBaseUrl()}/changes/${encodeURIComponent(project)}~${changeNum}`;
+  let v = `${getBaseUrl()}/changes/${encodeURIComponent(repo)}~${changeNum}`;
   if (patchNum) {
     v += `/revisions/${patchNum}`;
   }
@@ -147,6 +137,20 @@
 ) {
   return change?.status === ChangeStatus.ABANDONED;
 }
+/**
+ * Get the change number from either a ChangeInfo (such as those included in
+ * SubmittedTogetherInfo responses) or get the change number from a
+ * RelatedChangeAndCommitInfo (such as those included in a
+ * RelatedChangesInfo response).
+ */
+export function getChangeNumber(
+  change: ChangeInfo | ParsedChangeInfo | RelatedChangeAndCommitInfo
+): NumericChangeId {
+  if (isChangeInfo(change)) {
+    return change._number;
+  }
+  return change._change_number!;
+}
 
 export function changeStatuses(
   change: ChangeInfo,
@@ -154,15 +158,19 @@
 ): ChangeStates[] {
   const states = [];
   if (change.status === ChangeStatus.MERGED) {
-    states.push(ChangeStates.MERGED);
-  } else if (change.status === ChangeStatus.ABANDONED) {
-    states.push(ChangeStates.ABANDONED);
-  } else if (
+    return [ChangeStates.MERGED];
+  }
+  if (change.status === ChangeStatus.ABANDONED) {
+    return [ChangeStates.ABANDONED];
+  }
+  if (
     change.mergeable === false ||
     (opt_options && opt_options.mergeable === false)
   ) {
     // 'mergeable' prop may not always exist (@see Issue 6819)
     states.push(ChangeStates.MERGE_CONFLICT);
+  } else if (change.contains_git_conflicts) {
+    states.push(ChangeStates.GIT_CONFLICT);
   }
   if (change.work_in_progress) {
     states.push(ChangeStates.WIP);
@@ -234,6 +242,18 @@
   return owner || uploader || reviewer || cc;
 }
 
+export function roleDetails(
+  change?: ChangeInfo | ParsedChangeInfo,
+  account?: AccountInfo
+) {
+  return {
+    isOwner: isOwner(change, account),
+    isUploader: isUploader(change, account),
+    isReviewer: isReviewer(change, account),
+    isCc: isCc(change, account),
+  };
+}
+
 export function getCurrentRevision(change?: ChangeInfo | ParsedChangeInfo) {
   if (!change?.revisions || !change?.current_revision) return undefined;
   return change.revisions[change.current_revision];
@@ -248,11 +268,23 @@
   );
 }
 
+export function hasHumanReviewer(
+  change?: ChangeInfo | ParsedChangeInfo
+): boolean {
+  if (!change) return false;
+  const reviewers = change.reviewers.REVIEWER ?? [];
+  return reviewers
+    .filter(r => getUserId(r) !== getUserId(change.owner))
+    .some(r => !isServiceUser(r));
+}
+
 export function isRemovableReviewer(
   change?: ChangeInfo,
   reviewer?: AccountInfo
 ): boolean {
-  if (!change?.removable_reviewers || !reviewer) return false;
+  if (!reviewer || !change) return false;
+  if (isCc(change, reviewer)) return true;
+  if (!change.removable_reviewers) return false;
   return change.removable_reviewers.some(
     account =>
       account._account_id === reviewer._account_id ||
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index ccec27e..70e6fd6 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -1,24 +1,18 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
+import {assert} from '@open-wc/testing';
 import {ChangeStatus} from '../constants/constants';
 import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
-import '../test/common-test-setup-karma';
-import {createChange, createRevisions} from '../test/test-data-generators';
+import '../test/common-test-setup';
+import {
+  createAccountWithId,
+  createChange,
+  createRevisions,
+  createServiceUserWithId,
+} from '../test/test-data-generators';
 import {
   AccountId,
   CommitId,
@@ -33,6 +27,9 @@
   changePath,
   changeStatuses,
   isRemovableReviewer,
+  ListChangesOption,
+  listChangesOptionsToHex,
+  hasHumanReviewer,
 } from './change-util';
 
 suite('change-util tests', () => {
@@ -125,8 +122,11 @@
       current_revision: 'rev1' as CommitId,
       status: ChangeStatus.MERGED,
     };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, [ChangeStates.MERGED]);
+    assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
+    change.is_private = true;
+    assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
+    change.work_in_progress = true;
+    assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
   });
 
   test('Abandoned status', () => {
@@ -137,8 +137,11 @@
       status: ChangeStatus.ABANDONED,
       mergeable: false,
     };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, [ChangeStates.ABANDONED]);
+    assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]);
+    change.is_private = true;
+    assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]);
+    change.work_in_progress = true;
+    assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]);
   });
 
   test('Open status with private and wip', () => {
@@ -175,6 +178,26 @@
     ]);
   });
 
+  test('hasHumanReviewer', () => {
+    const owner = createAccountWithId(1);
+    const change = {
+      ...createChange(),
+      _number: 1 as NumericChangeId,
+      subject: 'Subject 1',
+      owner,
+      reviewers: {
+        REVIEWER: [owner],
+      },
+    };
+    assert.isFalse(hasHumanReviewer(change));
+
+    change.reviewers.REVIEWER.push(createServiceUserWithId(2));
+    assert.isFalse(hasHumanReviewer(change));
+
+    change.reviewers.REVIEWER.push(createAccountWithId(3));
+    assert.isTrue(hasHumanReviewer(change));
+  });
+
   test('isRemovableReviewer', () => {
     let change = {
       ...createChange(),
@@ -237,4 +260,18 @@
     change.status = ChangeStatus.NEW;
     assert.isFalse(changeIsAbandoned(change));
   });
+
+  test('listChangesOptionsToHex', () => {
+    const changeActionsHex = listChangesOptionsToHex(
+      ListChangesOption.MESSAGES,
+      ListChangesOption.ALL_REVISIONS
+    );
+    assert.equal(changeActionsHex, '204');
+    const dashboardHex = listChangesOptionsToHex(
+      ListChangesOption.LABELS,
+      ListChangesOption.DETAILED_ACCOUNTS,
+      ListChangesOption.SUBMIT_REQUIREMENTS
+    );
+    assert.equal(dashboardHex, '1000081');
+  });
 });
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 5b08fab..a92f0f8 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {
   CommentBasics,
@@ -23,91 +12,165 @@
   UrlEncodedCommentId,
   CommentRange,
   PatchRange,
-  ParentPatchSetNum,
+  PARENT,
   ContextLine,
   BasePatchSetNum,
   RevisionPatchSetNum,
   AccountInfo,
   AccountDetailInfo,
+  ChangeMessageInfo,
+  VotingRangeInfo,
+  FixSuggestionInfo,
+  FixId,
+  PatchSetNumber,
 } from '../types/common';
-import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
+import {CommentSide, 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';
+import {LineNumber} from '../api/diff';
+import {FormattedReviewerUpdateInfo} from '../types/types';
+import {extractMentionedUsers} from './account-util';
 
 export interface DraftCommentProps {
-  __draft?: boolean;
-  __draftID?: string;
-  __date?: Date;
+  // This must be true for all drafts. Drafts received from the backend will be
+  // modified immediately with __draft:true before allowing them to get into
+  // the application state.
+  __draft: boolean;
 }
 
-export type DraftInfo = CommentBasics & DraftCommentProps;
-
-/**
- * Each of the type implements or extends CommentBasics.
- */
-export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
-
-export interface UIStateCommentProps {
-  collapsed?: boolean;
+export interface UnsavedCommentProps {
+  // This must be true for all unsaved comment drafts. An unsaved draft is
+  // always just local to a comment component like <gr-comment> or
+  // <gr-comment-thread>. Unsaved drafts will never appear in the application
+  // state.
+  __unsaved: boolean;
 }
 
-export interface UIStateDraftProps {
-  __editing?: boolean;
-}
+export type DraftInfo = CommentInfo & DraftCommentProps;
 
-export type UIDraft = DraftInfo & UIStateCommentProps & UIStateDraftProps;
+export type UnsavedInfo = CommentBasics & UnsavedCommentProps;
 
-export type UIHuman = CommentInfo & UIStateCommentProps;
+export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
 
-export type UIRobot = RobotCommentInfo & UIStateCommentProps;
-
-export type UIComment = UIHuman | UIRobot | UIDraft;
-
+// TODO: Replace the CommentMap type with just an array of paths.
 export type CommentMap = {[path: string]: boolean};
 
-export function isRobot<T extends CommentInfo>(
+export function isRobot<T extends CommentBasics>(
   x: T | DraftInfo | RobotCommentInfo | undefined
 ): x is RobotCommentInfo {
   return !!x && !!(x as RobotCommentInfo).robot_id;
 }
 
-export function isDraft<T extends CommentInfo>(
-  x: T | UIDraft | undefined
-): x is UIDraft {
-  return !!x && !!(x as UIDraft).__draft;
+export function isDraft<T extends CommentBasics>(
+  x: T | DraftInfo | undefined
+): x is DraftInfo {
+  return !!x && !!(x as DraftInfo).__draft;
+}
+
+export function isUnsaved<T extends CommentBasics>(
+  x: T | UnsavedInfo | undefined
+): x is UnsavedInfo {
+  return !!x && !!(x as UnsavedInfo).__unsaved;
+}
+
+export function isDraftOrUnsaved<T extends CommentBasics>(
+  x: T | DraftInfo | UnsavedInfo | undefined
+): x is UnsavedInfo | DraftInfo {
+  return isDraft(x) || isUnsaved(x);
 }
 
 interface SortableComment {
-  __draft?: boolean;
-  __date?: Date;
-  updated?: Timestamp;
-  id?: UrlEncodedCommentId;
+  updated: Timestamp;
+  id: UrlEncodedCommentId;
 }
 
+export interface ChangeMessage extends ChangeMessageInfo {
+  // TODO(TS): maybe should be an enum instead
+  type: string;
+  expanded: boolean;
+  commentThreads: CommentThread[];
+}
+
+export function isFormattedReviewerUpdate(
+  message: ChangeMessage
+): message is ChangeMessage & FormattedReviewerUpdateInfo {
+  return message.type === 'REVIEWER_UPDATE';
+}
+
+export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
+
+export const NEWLINE_PATTERN = /\n/g;
+
+export const PATCH_SET_PREFIX_PATTERN =
+  /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
+
 export function sortComments<T extends SortableComment>(comments: T[]): T[] {
   return comments.slice(0).sort((c1, c2) => {
-    const d1 = !!c1.__draft;
-    const d2 = !!c2.__draft;
+    const d1 = isDraft(c1);
+    const d2 = isDraft(c2);
     if (d1 !== d2) return d1 ? 1 : -1;
 
-    const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
-    const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
-    const dateDiff = date1!.valueOf() - date2!.valueOf();
+    const date1 = parseDate(c1.updated);
+    const date2 = parseDate(c2.updated);
+    const dateDiff = date1.valueOf() - date2.valueOf();
     if (dateDiff !== 0) return dateDiff;
 
-    const id1 = c1.id ?? '';
-    const id2 = c2.id ?? '';
+    const id1 = c1.id;
+    const id2 = c2.id;
     return id1.localeCompare(id2);
   });
 }
 
-export function createCommentThreads(
-  comments: UIComment[],
-  patchRange?: PatchRange
-) {
+export function createUnsavedComment(thread: CommentThread): UnsavedInfo {
+  return {
+    path: thread.path,
+    patch_set: thread.patchNum,
+    side: thread.commentSide ?? CommentSide.REVISION,
+    line: typeof thread.line === 'number' ? thread.line : undefined,
+    range: thread.range,
+    parent: thread.mergeParentNum,
+    message: '',
+    unresolved: true,
+    __unsaved: true,
+  };
+}
+
+export function createPatchsetLevelUnsavedDraft(
+  patchNum?: PatchSetNumber,
+  message?: string,
+  unresolved?: boolean
+): UnsavedInfo {
+  return {
+    patch_set: patchNum,
+    message,
+    unresolved,
+    path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+    __unsaved: true,
+  };
+}
+
+export function createUnsavedReply(
+  replyingTo: CommentInfo,
+  message: string,
+  unresolved: boolean
+): UnsavedInfo {
+  return {
+    path: replyingTo.path,
+    patch_set: replyingTo.patch_set,
+    side: replyingTo.side,
+    line: replyingTo.line,
+    range: replyingTo.range,
+    parent: replyingTo.parent,
+    in_reply_to: replyingTo.id,
+    message,
+    unresolved,
+    __unsaved: true,
+  };
+}
+
+export function createCommentThreads(comments: CommentInfo[]) {
   const sortedComments = sortComments(comments);
   const threads: CommentThread[] = [];
   const idThreadMap: CommentIdToCommentThreadMap = {};
@@ -129,7 +192,6 @@
     const newThread: CommentThread = {
       comments: [comment],
       patchNum: comment.patch_set,
-      diffSide: Side.LEFT,
       commentSide: comment.side ?? CommentSide.REVISION,
       mergeParentNum: comment.parent,
       path: comment.path,
@@ -137,13 +199,6 @@
       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';
     }
@@ -154,68 +209,136 @@
 }
 
 export interface CommentThread {
-  comments: UIComment[];
+  /**
+   * This can only contain at most one draft. And if so, then it is the last
+   * comment in this list. This must not contain unsaved drafts.
+   */
+  comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>;
+  /**
+   * Identical to the id of the first comment. If this is undefined, then the
+   * thread only contains an unsaved draft.
+   */
+  rootId?: UrlEncodedCommentId;
+  /**
+   * Note that all location information is typically identical to that of the
+   * first comment, but not for ported comments!
+   */
   path: string;
   commentSide: CommentSide;
   /* mergeParentNum is the merge parent number only valid for merge commits
      when commentSide is PARENT.
      mergeParentNum is undefined for auto merge commits
+     Same as `parent` in CommentInfo.
   */
   mergeParentNum?: number;
-  patchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
+  /* Different from CommentInfo, which just keeps the line undefined for
+     FILE comments. */
   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
+  /**
+   * Was the thread ported over from its original location to a newer patchset?
+   * If yes, then the location information above contains the ported location,
+   * but the comments still have the original location set.
+   */
+  ported?: boolean;
+  /**
+   * Only relevant when ported:true. Means that no ported range could be
+   * computed. `line` and `range` can be undefined then.
+   */
+  rangeInfoLost?: boolean;
 }
 
-export function getLastComment(thread?: CommentThread): UIComment | undefined {
-  const len = thread?.comments.length;
-  return thread && len ? thread.comments[len - 1] : undefined;
+export function equalLocation(t1?: CommentThread, t2?: CommentThread) {
+  if (t1 === t2) return true;
+  if (t1 === undefined || t2 === undefined) return false;
+  return (
+    t1.path === t2.path &&
+    t1.patchNum === t2.patchNum &&
+    t1.commentSide === t2.commentSide &&
+    t1.line === t2.line &&
+    t1.range?.start_line === t2.range?.start_line &&
+    t1.range?.start_character === t2.range?.start_character &&
+    t1.range?.end_line === t2.range?.end_line &&
+    t1.range?.end_character === t2.range?.end_character
+  );
 }
 
-export function getFirstComment(thread?: CommentThread): UIComment | undefined {
-  return thread?.comments?.[0];
+export function getLastComment(thread: CommentThread): CommentInfo | undefined {
+  const len = thread.comments.length;
+  return thread.comments[len - 1];
 }
 
-export function countComments(thread?: CommentThread) {
-  return thread?.comments?.length ?? 0;
+export function getLastPublishedComment(
+  thread: CommentThread
+): CommentInfo | undefined {
+  const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
+  const len = publishedComments.length;
+  return publishedComments[len - 1];
 }
 
-export function isPatchsetLevel(thread?: CommentThread): boolean {
-  return thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+export function getFirstComment(
+  thread: CommentThread
+): CommentInfo | undefined {
+  return thread.comments[0];
 }
 
-export function isUnresolved(thread?: CommentThread): boolean {
+export function countComments(thread: CommentThread) {
+  return thread.comments.length;
+}
+
+export function isPatchsetLevel(thread: CommentThread): boolean {
+  return thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+}
+
+export function isUnresolved(thread: CommentThread): boolean {
   return !isResolved(thread);
 }
 
-export function isResolved(thread?: CommentThread): boolean {
-  return !getLastComment(thread)?.unresolved;
+export function isResolved(thread: CommentThread): boolean {
+  const lastUnresolved = getLastComment(thread)?.unresolved;
+  return !lastUnresolved ?? false;
 }
 
-export function isDraftThread(thread?: CommentThread): boolean {
+export function isDraftThread(thread: CommentThread): boolean {
   return isDraft(getLastComment(thread));
 }
 
-export function isRobotThread(thread?: CommentThread): boolean {
+export function isMentionedThread(
+  thread: CommentThread,
+  account?: AccountInfo
+) {
+  if (!account?.email) return false;
+  return getMentionedUsers(thread)
+    .map(v => v.email)
+    .includes(account.email);
+}
+
+export function isRobotThread(thread: CommentThread): boolean {
   return isRobot(getFirstComment(thread));
 }
 
-export function hasHumanReply(thread?: CommentThread): boolean {
+export function hasHumanReply(thread: CommentThread): boolean {
   return countComments(thread) > 1 && !isRobot(getLastComment(thread));
 }
 
+export function lastUpdated(thread: CommentThread): Date | undefined {
+  // We don't want to re-sort comments when you save a draft reply, so
+  // we stick to the timestampe of the last *published* comment.
+  const lastUpdated =
+    getLastPublishedComment(thread)?.updated ?? getLastComment(thread)?.updated;
+  return lastUpdated !== undefined ? parseDate(lastUpdated) : undefined;
+}
 /**
  * Whether the given comment should be included in the base side of the
  * given patch range.
  */
 export function isInBaseOfPatchRange(
-  comment: CommentBasics,
+  comment: {
+    patch_set?: PatchSetNum;
+    side?: CommentSide;
+    parent?: number;
+  },
   range: PatchRange
 ) {
   // If the base of the patch range is a parent of a merge, and the comment
@@ -230,7 +353,7 @@
 
   // If the base of the range is the parent of the patch:
   if (
-    range.basePatchNum === ParentPatchSetNum &&
+    range.basePatchNum === PARENT &&
     comment.side === CommentSide.PARENT &&
     comment.patch_set === range.patchNum
   ) {
@@ -238,7 +361,7 @@
   }
   // If the base of the range is not the parent of the patch:
   return (
-    range.basePatchNum !== ParentPatchSetNum &&
+    range.basePatchNum !== PARENT &&
     comment.side !== CommentSide.PARENT &&
     comment.patch_set === range.basePatchNum
   );
@@ -249,7 +372,10 @@
  * given patch range.
  */
 export function isInRevisionOfPatchRange(
-  comment: CommentBasics,
+  comment: {
+    patch_set?: PatchSetNum;
+    side?: CommentSide;
+  },
   range: PatchRange
 ) {
   return (
@@ -271,27 +397,25 @@
 }
 
 export function getPatchRangeForCommentUrl(
-  comment: UIComment,
+  comment: Comment,
   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,
+      patchNum: comment.patch_set,
+      basePatchNum: PARENT,
     };
   } else if (latestPatchNum === comment.patch_set) {
     return {
       patchNum: latestPatchNum,
-      basePatchNum: ParentPatchSetNum,
+      basePatchNum: PARENT,
     };
   } else {
     return {
-      patchNum: latestPatchNum as RevisionPatchSetNum,
+      patchNum: latestPatchNum,
       basePatchNum: comment.patch_set as BasePatchSetNum,
     };
   }
@@ -355,30 +479,136 @@
   return authors;
 }
 
-export function computeId(comment: UIComment) {
-  if (comment.id) return comment.id;
-  if (isDraft(comment)) return comment.__draftID;
-  throw new Error('Missing id in root comment.');
-}
-
 /**
- * Add path info to every comment as CommentInfo returned
- * from server does not have that.
- *
- * TODO(taoalpha): should consider changing BE to send path
- * back within CommentInfo
+ * Add path info to every comment as CommentInfo returned from server does not
+ * have that.
  */
 export function addPath<T>(comments: {[path: string]: T[]} = {}): {
   [path: string]: Array<T & {path: string}>;
 } {
   const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
   for (const filePath of Object.keys(comments)) {
-    const allCommentsForPath = comments[filePath] || [];
-    if (allCommentsForPath.length) {
-      updatedComments[filePath] = allCommentsForPath.map(comment => {
-        return {...comment, path: filePath};
-      });
-    }
+    updatedComments[filePath] = (comments[filePath] || []).map(comment => {
+      return {...comment, path: filePath};
+    });
   }
   return updatedComments;
 }
+
+/**
+ * Add __draft:true to all drafts returned from server so that they can be told
+ * apart from published comments easily.
+ */
+export function addDraftProp(
+  draftsByPath: {[path: string]: CommentInfo[]} = {}
+) {
+  const updated: {[path: string]: DraftInfo[]} = {};
+  for (const filePath of Object.keys(draftsByPath)) {
+    updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
+      return {...draft, __draft: true};
+    });
+  }
+  return updated;
+}
+
+export function reportingDetails(comment: CommentBasics) {
+  return {
+    id: comment?.id,
+    message_length: comment?.message?.trim().length,
+    in_reply_to: comment?.in_reply_to,
+    unresolved: comment?.unresolved,
+    path_length: comment?.path?.length,
+    line: comment?.range?.start_line ?? comment?.line,
+    unsaved: isUnsaved(comment),
+  };
+}
+
+export const USER_SUGGESTION_START_PATTERN = '```suggestion\n';
+
+// This can either mean a user or a checks provided fix.
+// "Provided" means that the fix is sent along with the request
+// when previewing and applying the fix. This is in contrast to
+// robot comment fixes, which are stored in the backend, and they
+// are referenced by a unique `FixId`;
+export const PROVIDED_FIX_ID = 'provided_fix' as FixId;
+
+export function hasUserSuggestion(comment: Comment) {
+  return comment.message?.includes(USER_SUGGESTION_START_PATTERN) ?? false;
+}
+
+export function getUserSuggestion(comment: Comment) {
+  if (!comment.message) return;
+  const start =
+    comment.message.indexOf(USER_SUGGESTION_START_PATTERN) +
+    USER_SUGGESTION_START_PATTERN.length;
+  const end = comment.message.indexOf('\n```', start);
+  return comment.message.substring(start, end);
+}
+
+export function getContentInCommentRange(
+  fileContent: string,
+  comment: Comment
+) {
+  const lines = fileContent.split('\n');
+  if (comment.range) {
+    const range = comment.range;
+    return lines.slice(range.start_line - 1, range.end_line).join('\n');
+  }
+  return lines[comment.line! - 1];
+}
+
+export function createUserFixSuggestion(
+  comment: Comment,
+  line: string,
+  replacement: string
+): FixSuggestionInfo[] {
+  const lastLine = line.split('\n').pop();
+  return [
+    {
+      fix_id: PROVIDED_FIX_ID,
+      description: 'User suggestion',
+      replacements: [
+        {
+          path: comment.path!,
+          range: {
+            start_line: comment.range?.start_line ?? comment.line!,
+            start_character: 0,
+            end_line: comment.range?.end_line ?? comment.line!,
+            end_character: lastLine!.length,
+          },
+          replacement,
+        },
+      ],
+    },
+  ];
+}
+
+function getMentionedUsers(thread: CommentThread) {
+  return thread.comments.map(c => extractMentionedUsers(c.message)).flat();
+}
+
+export function getMentionedThreads(
+  threads: CommentThread[],
+  account: AccountInfo
+) {
+  if (!account.email) return [];
+  return threads.filter(t =>
+    getMentionedUsers(t)
+      .map(v => v.email)
+      .includes(account.email)
+  );
+}
+
+export function findComment(
+  comments: {
+    [path: string]: (CommentInfo | DraftInfo)[];
+  },
+  commentId: UrlEncodedCommentId
+) {
+  if (!commentId) return undefined;
+  let comment;
+  for (const path of Object.keys(comments)) {
+    comment = comment || comments[path].find(c => c.id === commentId);
+  }
+  return comment;
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 3c8f26d..0a8aa82 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -1,43 +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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
   isUnresolved,
   getPatchRangeForCommentUrl,
   createCommentThreads,
   sortComments,
+  USER_SUGGESTION_START_PATTERN,
+  hasUserSuggestion,
+  getUserSuggestion,
+  getContentInCommentRange,
+  createUserFixSuggestion,
+  PROVIDED_FIX_ID,
+  getMentionedThreads,
 } from './comment-util';
-import {createComment, createCommentThread} from '../test/test-data-generators';
-import {CommentSide, Side} from '../constants/constants';
 import {
-  BasePatchSetNum,
-  ParentPatchSetNum,
-  PatchSetNum,
+  createAccountWithEmail,
+  createComment,
+  createCommentThread,
+} from '../test/test-data-generators';
+import {CommentSide} from '../constants/constants';
+import {
+  PARENT,
   RevisionPatchSetNum,
   Timestamp,
   UrlEncodedCommentId,
 } from '../types/common';
+import {assert} from '@open-wc/testing';
 
 suite('comment-util', () => {
   test('isUnresolved', () => {
     const thread = createCommentThread([createComment()]);
 
-    assert.isFalse(isUnresolved(undefined));
     assert.isFalse(isUnresolved(thread));
 
     assert.isTrue(
@@ -72,32 +69,68 @@
     );
   });
 
-  test('getPatchRangeForCommentUrl', () => {
+  suite('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,
+        patch_set: 4 as RevisionPatchSetNum,
         side: CommentSide.PARENT,
         path: '/COMMIT_MSG',
       };
       assert.deepEqual(
         getPatchRangeForCommentUrl(comment, 11 as RevisionPatchSetNum),
         {
-          basePatchNum: ParentPatchSetNum,
-          patchNum: 4 as PatchSetNum,
+          basePatchNum: PARENT,
+          patchNum: 4 as RevisionPatchSetNum,
         }
       );
     });
   });
 
+  test('getMentionedThreads', () => {
+    const account = createAccountWithEmail('abcd@def.com');
+    const threads = [
+      createCommentThread([
+        {
+          ...createComment(),
+          message: 'random text with no emails',
+        },
+      ]),
+      createCommentThread([
+        {
+          ...createComment(),
+          message: '@abcd@def.com please take a look',
+        },
+        {
+          ...createComment(),
+          message: '@abcd@def.com please take a look again at this',
+        },
+      ]),
+      createCommentThread([
+        {
+          ...createComment(),
+          message: '@abcd@def.com this is important',
+        },
+      ]),
+    ];
+    assert.deepEqual(getMentionedThreads(threads, account), [
+      threads[1],
+      threads[2],
+    ]);
+
+    assert.deepEqual(
+      getMentionedThreads(threads, createAccountWithEmail('xyz@def.com')),
+      []
+    );
+  });
+
   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,
       },
@@ -106,13 +139,11 @@
         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',
       },
@@ -131,7 +162,7 @@
           message: 'i like you, jack',
           updated: '2015-12-23 15:00:20.396000000' as Timestamp,
           line: 1,
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           path: 'some/path',
         },
         {
@@ -140,7 +171,7 @@
           updated: '2015-12-24 15:01:20.396000000' as Timestamp,
           line: 1,
           in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
-          patch_set: 1 as PatchSetNum,
+          patch_set: 1 as RevisionPatchSetNum,
           path: 'some/path',
         },
         {
@@ -148,29 +179,24 @@
           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,
+          patch_set: 1 as RevisionPatchSetNum,
           path: 'some/path',
         },
       ];
 
-      const actualThreads = createCommentThreads(comments, {
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: 4 as RevisionPatchSetNum,
-      });
+      const actualThreads = createCommentThreads(comments);
 
       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].patchNum, 1 as RevisionPatchSetNum);
       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].patchNum, 1 as RevisionPatchSetNum);
       assert.equal(actualThreads[1].line, 'FILE');
     });
 
@@ -186,7 +212,7 @@
             end_line: 1,
             end_character: 2,
           },
-          patch_set: 5 as PatchSetNum,
+          patch_set: 5 as RevisionPatchSetNum,
           path: '/p',
           line: 1,
         },
@@ -194,7 +220,6 @@
 
       const expectedThreads = [
         {
-          diffSide: Side.LEFT,
           commentSide: CommentSide.REVISION,
           path: '/p',
           rootId: 'betsys_confession' as UrlEncodedCommentId,
@@ -211,11 +236,11 @@
                 end_line: 1,
                 end_character: 2,
               },
-              patch_set: 5 as PatchSetNum,
+              patch_set: 5 as RevisionPatchSetNum,
               line: 1,
             },
           ],
-          patchNum: 5 as PatchSetNum,
+          patchNum: 5 as RevisionPatchSetNum,
           range: {
             start_line: 1,
             start_character: 1,
@@ -226,13 +251,7 @@
         },
       ];
 
-      assert.deepEqual(
-        createCommentThreads(comments, {
-          basePatchNum: 5 as BasePatchSetNum,
-          patchNum: 10 as RevisionPatchSetNum,
-        }),
-        expectedThreads
-      );
+      assert.deepEqual(createCommentThreads(comments), expectedThreads);
     });
 
     test('does not thread unrelated comments at same location', () => {
@@ -241,18 +260,133 @@
           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);
     });
   });
+
+  test('hasUserSuggestion', () => {
+    const comment = {
+      ...createComment(),
+      message: `${USER_SUGGESTION_START_PATTERN}${'test'}${'\n```'}`,
+    };
+    assert.isTrue(hasUserSuggestion(comment));
+  });
+
+  test('getUserSuggestion', () => {
+    const suggestion = 'test';
+    const comment = {
+      ...createComment(),
+      message: `${USER_SUGGESTION_START_PATTERN}${suggestion}${'\n```'}`,
+    };
+    assert.equal(getUserSuggestion(comment), suggestion);
+  });
+
+  suite('getContentInCommentRange', () => {
+    test('one line', () => {
+      const comment = {
+        ...createComment(),
+        line: 1,
+      };
+      const content = 'line1\nline2\nline3';
+      assert.equal(getContentInCommentRange(content, comment), 'line1');
+    });
+
+    test('multi line', () => {
+      const comment = {
+        ...createComment(),
+        line: 3,
+        range: {
+          start_line: 1,
+          start_character: 5,
+          end_line: 3,
+          end_character: 39,
+        },
+      };
+      const selectedText =
+        '   * Examples:\n' +
+        '      * Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,\n' +
+        '      * Make blocking, Downgrade severity.';
+      const content = `${selectedText}\n`;
+      assert.equal(getContentInCommentRange(content, comment), selectedText);
+    });
+  });
+
+  suite('createUserFixSuggestion', () => {
+    test('one line', () => {
+      const comment = {
+        ...createComment(),
+        line: 1,
+        path: 'abc.txt',
+      };
+      const line = 'lane1';
+      const replacement = 'line1';
+      assert.deepEqual(createUserFixSuggestion(comment, line, replacement), [
+        {
+          fix_id: PROVIDED_FIX_ID,
+          description: 'User suggestion',
+          replacements: [
+            {
+              path: 'abc.txt',
+              range: {
+                start_line: 1,
+                start_character: 0,
+                end_line: 1,
+                end_character: line.length,
+              },
+              replacement,
+            },
+          ],
+        },
+      ]);
+    });
+
+    test('multiline', () => {
+      const comment = {
+        ...createComment(),
+        line: 3,
+        range: {
+          start_line: 1,
+          start_character: 5,
+          end_line: 3,
+          end_character: 39,
+        },
+        path: 'abc.txt',
+      };
+      const line =
+        '   * Examples:\n' +
+        '      * Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,\n' +
+        '      * Make blocking, Downgrade severity.';
+      const replacement =
+        '   - Examples:\n' +
+        '      - Acknowledge/Dismiss, Delete, Report a bug, Report as not useful,\n' +
+        '      - Make blocking, Downgrade severity.';
+      assert.deepEqual(createUserFixSuggestion(comment, line, replacement), [
+        {
+          fix_id: PROVIDED_FIX_ID,
+          description: 'User suggestion',
+          replacements: [
+            {
+              path: 'abc.txt',
+              range: {
+                start_line: 1,
+                start_character: 0,
+                end_line: 3,
+                end_character: 42,
+              },
+              replacement,
+            },
+          ],
+        },
+      ]);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 9e3bc74..183d167 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -1,20 +1,11 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
+import {fireAlert} from './event-util';
+
 /**
  * @fileoverview Functions in this file contains some widely used
  * code patterns. If you noticed a repeated code and none of the existing util
@@ -49,7 +40,7 @@
 /**
  * Throws an error with the provided error message if the condition is false.
  */
-export function check(
+export function assert(
   condition: boolean,
   errorMessage: string
 ): asserts condition {
@@ -59,28 +50,6 @@
 /**
  * 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'
@@ -95,8 +64,11 @@
   selector: string
 ): NodeListOf<E> {
   if (!el) throw new Error('element not defined');
-  const root = el.shadowRoot ?? el;
-  return root.querySelectorAll<E>(selector);
+  if (el.shadowRoot) {
+    const r = el.shadowRoot.querySelectorAll<E>(selector);
+    if (r.length > 0) return r;
+  }
+  return el.querySelectorAll<E>(selector);
 }
 
 export function query<E extends Element = Element>(
@@ -145,7 +117,7 @@
 /**
  * Add value, if the set does not contain it. Otherwise remove it.
  */
-export function toggleSetMembership<T>(set: Set<T>, value: T): void {
+export function toggleSet<T>(set: Set<T>, value: T): void {
   if (set.has(value)) {
     set.delete(value);
   } else {
@@ -153,6 +125,53 @@
   }
 }
 
+export function toggle<T>(array: T[], item: T): T[] {
+  if (array.includes(item)) {
+    return array.filter(r => r !== item);
+  } else {
+    return array.concat([item]);
+  }
+}
+
 export function unique<T>(item: T, index: number, array: T[]) {
   return array.indexOf(item) === index;
 }
+
+/**
+ * Returns the elements that are present in every sub-array. If a compareBy
+ * predicate is passed in, it will be used instead of strict equality. A new
+ * array is always returned even if there is already just a single array.
+ */
+export function intersection<T>(
+  arrays: T[][],
+  compareBy: (t: T, u: T) => boolean = (t, u) => t === u
+): T[] {
+  // Array.prototype.reduce needs either an initialValue or a non-empty array.
+  // Since there is no good initialValue for intersecting (∅ ∩ X = ∅), the
+  // empty array must be checked separately.
+  if (arrays.length === 0) {
+    return [];
+  }
+  if (arrays.length === 1) {
+    return [...arrays[0]];
+  }
+  return arrays.reduce((result, array) =>
+    result.filter(t => array.find(u => compareBy(t, u)))
+  );
+}
+
+/**
+ * Returns the elements that are present in A but not present in B.
+ */
+export function difference<T>(
+  a: T[],
+  b: T[],
+  compareBy: (t: T, u: T) => boolean = (t, u) => t === u
+): T[] {
+  return a.filter(aVal => !b.some(bVal => compareBy(aVal, bVal)));
+}
+
+export async function copyToClipbard(text: string, copyTargetName?: string) {
+  await navigator.clipboard.writeText(text);
+  fireAlert(document, `${copyTargetName ?? text} was copied to clipboard`);
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index 4156729..76c8a6c 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -1,22 +1,18 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
-import {hasOwnProperty, areSetsEqual, containsAll} from './common-util';
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {
+  hasOwnProperty,
+  areSetsEqual,
+  containsAll,
+  intersection,
+  difference,
+  toggle,
+} from './common-util';
 
 suite('common-util tests', () => {
   suite('hasOwnProperty', () => {
@@ -68,4 +64,45 @@
     assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
     assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
   });
+
+  test('intersections', () => {
+    const arrayWithValues = [1, 2, 3];
+    assert.sameDeepMembers(intersection([]), []);
+    assert.sameDeepMembers(intersection([arrayWithValues]), arrayWithValues);
+    // a new array is returned even if a single array is provided.
+    assert.notStrictEqual(intersection([arrayWithValues]), arrayWithValues);
+    assert.sameDeepMembers(
+      intersection([
+        [1, 2, 3],
+        [2, 3, 4],
+        [5, 3, 2],
+      ]),
+      [2, 3]
+    );
+
+    const foo1 = {value: 5};
+    const foo2 = {value: 5};
+
+    // these foo's will fail strict equality with each other, but a comparator
+    // can make them intersect.
+    assert.sameDeepMembers(intersection([[foo1], [foo2]]), []);
+    assert.sameDeepMembers(
+      intersection([[foo1], [foo2]], (a, b) => a.value === b.value),
+      [foo1]
+    );
+  });
+
+  test('difference', () => {
+    assert.deepEqual(difference([1, 2, 3], []), [1, 2, 3]);
+    assert.deepEqual(difference([1, 2, 3], [2, 3, 4]), [1]);
+    assert.deepEqual(difference([1, 2, 3], [1, 2, 3]), []);
+    assert.deepEqual(difference([1, 2, 3], [4, 5, 6]), [1, 2, 3]);
+  });
+
+  test('toggle', () => {
+    assert.deepEqual(toggle([], 1), [1]);
+    assert.deepEqual(toggle([1], 1), []);
+    assert.deepEqual(toggle([1, 2, 3], 1), [2, 3]);
+    assert.deepEqual(toggle([2, 3], 1), [2, 3, 1]);
+  });
 });
diff --git a/polygerrit-ui/app/utils/compare-util.ts b/polygerrit-ui/app/utils/compare-util.ts
deleted file mode 100644
index dd20915..0000000
--- a/polygerrit-ui/app/utils/compare-util.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-export function deepEqualStringDict(
-  a: {[name: string]: string},
-  b: {[name: string]: string}
-): boolean {
-  const aKeys = Object.keys(a);
-  const bKeys = Object.keys(b);
-  if (aKeys.length !== bKeys.length) return false;
-  for (const key of aKeys) {
-    if (a[key] !== b[key]) return false;
-  }
-  return true;
-}
-
-export function equalArray(a?: unknown[], b?: unknown[]): boolean {
-  if (a === b) return true;
-  if (a === undefined) return b === undefined;
-  if (b === undefined) return a === undefined;
-  if (a.length !== b.length) return false;
-  for (let i = 0; i < a.length; i++) {
-    if (a[i] !== b[i]) return false;
-  }
-  return true;
-}
diff --git a/polygerrit-ui/app/utils/compare-util_test.ts b/polygerrit-ui/app/utils/compare-util_test.ts
deleted file mode 100644
index 7cd71bf..0000000
--- a/polygerrit-ui/app/utils/compare-util_test.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../test/common-test-setup-karma';
-import {deepEqualStringDict, equalArray} from './compare-util';
-
-suite('compare-utils tests', () => {
-  test('deepEqual', () => {
-    assert.isTrue(deepEqualStringDict({}, {}));
-    assert.isTrue(deepEqualStringDict({x: 'y'}, {x: 'y'}));
-    assert.isTrue(deepEqualStringDict({x: 'y', p: 'q'}, {p: 'q', x: 'y'}));
-
-    assert.isFalse(deepEqualStringDict({}, {x: 'y'}));
-    assert.isFalse(deepEqualStringDict({x: 'y'}, {x: 'z'}));
-    assert.isFalse(deepEqualStringDict({x: 'y'}, {z: 'y'}));
-  });
-
-  test('equalArray', () => {
-    assert.isTrue(equalArray(undefined, undefined));
-    assert.isTrue(equalArray([], []));
-    assert.isTrue(equalArray([1], [1]));
-    assert.isTrue(equalArray(['a', 'b'], ['a', 'b']));
-
-    assert.isFalse(equalArray(undefined, []));
-    assert.isFalse(equalArray([], undefined));
-    assert.isFalse(equalArray([], [1]));
-    assert.isFalse(equalArray([1], [2]));
-    assert.isFalse(equalArray([1, 2], [1]));
-  });
-});
diff --git a/polygerrit-ui/app/utils/dashboard-util.ts b/polygerrit-ui/app/utils/dashboard-util.ts
new file mode 100644
index 0000000..caff603
--- /dev/null
+++ b/polygerrit-ui/app/utils/dashboard-util.ts
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ChangeConfigInfo, ChangeInfo} from '../api/rest-api';
+
+export interface DashboardSection {
+  name: string;
+  query: string;
+  suffixForDashboard?: string;
+  selfOnly?: boolean;
+  hideIfEmpty?: boolean;
+  results?: ChangeInfo[];
+}
+
+const USER_PLACEHOLDER_PATTERN = /\${user}/g;
+
+export interface UserDashboardConfig {
+  change?: ChangeConfigInfo;
+}
+
+export interface UserDashboard {
+  title?: string;
+  sections: DashboardSection[];
+}
+
+// NOTE: These queries are tested in Java. Any changes made to definitions
+// here require corresponding changes to:
+// java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+const HAS_DRAFTS: DashboardSection = {
+  // Changes with unpublished draft comments. This section is omitted when
+  // viewing other users, so we don't need to filter anything out.
+  name: 'Has draft comments',
+  query: 'has:draft',
+  selfOnly: true,
+  hideIfEmpty: true,
+  suffixForDashboard: 'limit:10',
+};
+
+export const YOUR_TURN: DashboardSection = {
+  // Changes where the user is in the attention set.
+  name: 'Your Turn',
+  query: 'attention:${user}',
+  hideIfEmpty: false,
+  suffixForDashboard: 'limit:25',
+};
+
+const WIP: DashboardSection = {
+  // WIP open changes owned by viewing user. This section is omitted when
+  // viewing other users, so we don't need to filter anything out.
+  name: 'Work in progress',
+  query: 'is:open owner:${user} is:wip',
+  selfOnly: true,
+  hideIfEmpty: true,
+  suffixForDashboard: 'limit:25',
+};
+
+export const OUTGOING: DashboardSection = {
+  // Non-WIP open changes owned by viewed user.
+  name: 'Outgoing reviews',
+  query: 'is:open owner:${user} -is:wip',
+  suffixForDashboard: 'limit:25',
+};
+
+const INCOMING: DashboardSection = {
+  // Non-WIP open changes not owned by the viewed user, that the viewed user
+  // is associated with as a reviewer.
+  name: 'Incoming reviews',
+  query: 'is:open -owner:${user} -is:wip reviewer:${user}',
+  suffixForDashboard: 'limit:25',
+};
+
+const CCED: DashboardSection = {
+  // Open changes the viewed user is CCed on.
+  name: 'CCed on',
+  query: 'is:open -is:wip cc:${user}',
+  suffixForDashboard: 'limit:10',
+};
+
+export const CLOSED: DashboardSection = {
+  name: 'Recently closed',
+  // Closed changes where viewed user is owner or reviewer.
+  // WIP changes not owned by the viewing user (the one instance of
+  // 'owner:self' is intentional and implements this logic) are filtered out.
+  query:
+    'is:closed (-is:wip OR owner:self) ' +
+    '(owner:${user} OR reviewer:${user} OR cc:${user})',
+  suffixForDashboard: '-age:4w limit:10',
+};
+
+const DEFAULT_SECTIONS: DashboardSection[] = [
+  HAS_DRAFTS,
+  YOUR_TURN,
+  WIP,
+  OUTGOING,
+  INCOMING,
+  CCED,
+  CLOSED,
+];
+
+export function getUserDashboard(
+  user = 'self',
+  sections = DEFAULT_SECTIONS,
+  title = ''
+): UserDashboard {
+  sections = sections
+    .filter(section => user === 'self' || !section.selfOnly)
+    .map(section => {
+      return {
+        ...section,
+        name: section.name,
+        query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+      };
+    });
+  return {title, sections};
+}
diff --git a/polygerrit-ui/app/utils/dashboard-util_test.ts b/polygerrit-ui/app/utils/dashboard-util_test.ts
new file mode 100644
index 0000000..40e4ad9
--- /dev/null
+++ b/polygerrit-ui/app/utils/dashboard-util_test.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {getUserDashboard} from './dashboard-util';
+
+suite('gr-navigation tests', () => {
+  suite('_getUserDashboard', () => {
+    const sections = [
+      {name: 'section 1', query: 'query 1'},
+      {name: 'section 2', query: 'query 2 for ${user}'},
+      {name: 'section 3', query: 'self only query', selfOnly: true},
+      {name: 'section 4', query: 'query 4', suffixForDashboard: 'suffix'},
+    ];
+
+    test('dashboard for self', () => {
+      const dashboard = getUserDashboard('self', sections, 'title');
+      assert.deepEqual(dashboard, {
+        title: 'title',
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: 'query 2 for self'},
+          {
+            name: 'section 3',
+            query: 'self only query',
+            selfOnly: true,
+          },
+          {
+            name: 'section 4',
+            query: 'query 4',
+            suffixForDashboard: 'suffix',
+          },
+        ],
+      });
+    });
+
+    test('dashboard for other user', () => {
+      const dashboard = getUserDashboard('user', sections, 'title');
+      assert.deepEqual(dashboard, {
+        title: 'title',
+        sections: [
+          {name: 'section 1', query: 'query 1'},
+          {name: 'section 2', query: 'query 2 for user'},
+          {
+            name: 'section 4',
+            query: 'query 4',
+            suffixForDashboard: 'suffix',
+          },
+        ],
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index a780af5..72e6cb7 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -2,19 +2,8 @@
 
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 const Duration = {
@@ -44,7 +33,10 @@
 export function durationString(from: Date, to: Date, noAgo = false) {
   const ago = noAgo ? '' : ' ago';
   const secondsAgo = Math.floor((to.valueOf() - from.valueOf()) / 1000);
-  if (secondsAgo <= 59) return 'just now';
+  if (secondsAgo <= 59) {
+    if (noAgo) return `${secondsAgo} seconds`;
+    return 'just now';
+  }
   if (secondsAgo <= 119) return `1 minute${ago}`;
   const minutesAgo = Math.floor(secondsAgo / 60);
   if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
diff --git a/polygerrit-ui/app/utils/date-util_test.ts b/polygerrit-ui/app/utils/date-util_test.ts
index f17ced3..8e802b7 100644
--- a/polygerrit-ui/app/utils/date-util_test.ts
+++ b/polygerrit-ui/app/utils/date-util_test.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {Timestamp} from '../types/common';
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
   isValidDate,
   parseDate,
@@ -25,6 +14,7 @@
   formatDate,
   wasYesterday,
 } from './date-util';
+import {assert} from '@open-wc/testing';
 
 suite('date-util tests', () => {
   suite('parseDate', () => {
diff --git a/polygerrit-ui/app/utils/deep-util.ts b/polygerrit-ui/app/utils/deep-util.ts
new file mode 100644
index 0000000..7ed7dd4
--- /dev/null
+++ b/polygerrit-ui/app/utils/deep-util.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+export function deepEqual<T>(a: T, b: T): boolean {
+  if (a === b) return true;
+  if (a === undefined || b === undefined) return false;
+  if (a === null || b === null) return false;
+  if (a instanceof Date || b instanceof Date) {
+    if (!(a instanceof Date && b instanceof Date)) return false;
+    return a.getTime() === b.getTime();
+  }
+
+  if (a instanceof Set || b instanceof Set) {
+    if (!(a instanceof Set && b instanceof Set)) return false;
+    if (a.size !== b.size) return false;
+    for (const ai of a) if (!b.has(ai)) return false;
+    return true;
+  }
+  if (a instanceof Map || b instanceof Map) {
+    if (!(a instanceof Map && b instanceof Map)) return false;
+    if (a.size !== b.size) return false;
+    for (const [aKey, aValue] of a.entries()) {
+      if (!b.has(aKey) || !deepEqual(aValue, b.get(aKey))) return false;
+    }
+    return true;
+  }
+
+  if (typeof a === 'object') {
+    if (typeof b !== 'object') return false;
+    const aObj = a as Record<string, unknown>;
+    const bObj = b as Record<string, unknown>;
+    const aKeys = Object.keys(aObj);
+    const bKeys = Object.keys(bObj);
+    if (aKeys.length !== bKeys.length) return false;
+    for (const key of aKeys) {
+      if (!deepEqual(aObj[key], bObj[key])) return false;
+    }
+    return true;
+  }
+
+  return false;
+}
+
+export function notDeepEqual<T>(a: T, b: T): boolean {
+  return !deepEqual(a, b);
+}
+
+/**
+ * @param obj Object
+ */
+export function deepClone(obj?: object) {
+  if (!obj) return undefined;
+  return JSON.parse(JSON.stringify(obj));
+}
diff --git a/polygerrit-ui/app/utils/deep-util_test.ts b/polygerrit-ui/app/utils/deep-util_test.ts
new file mode 100644
index 0000000..c671c53
--- /dev/null
+++ b/polygerrit-ui/app/utils/deep-util_test.ts
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {deepEqual} from './deep-util';
+
+suite('compare-util tests', () => {
+  test('deepEqual primitives', () => {
+    assert.isTrue(deepEqual(undefined, undefined));
+    assert.isTrue(deepEqual(null, null));
+    assert.isTrue(deepEqual(0, 0));
+    assert.isTrue(deepEqual('', ''));
+
+    assert.isFalse(deepEqual(1, 2));
+    assert.isFalse(deepEqual('a', 'b'));
+  });
+
+  test('deepEqual Dates', () => {
+    const a = new Date();
+    const b = new Date(a.getTime());
+    assert.isTrue(deepEqual(a, b));
+    assert.isFalse(deepEqual(a, undefined));
+    assert.isFalse(deepEqual(undefined, b));
+    assert.isFalse(deepEqual(a, new Date(a.getTime() + 1)));
+  });
+
+  test('deepEqual objects', () => {
+    assert.isTrue(deepEqual({}, {}));
+    assert.isTrue(deepEqual({x: 'y'}, {x: 'y'}));
+    assert.isTrue(deepEqual({x: 'y', p: 'q'}, {p: 'q', x: 'y'}));
+    assert.isTrue(deepEqual({x: {y: 'y'}}, {x: {y: 'y'}}));
+
+    assert.isFalse(deepEqual(undefined, {}));
+    assert.isFalse(deepEqual(null, {}));
+    assert.isFalse(deepEqual({}, undefined));
+    assert.isFalse(deepEqual({}, null));
+    assert.isFalse(deepEqual({}, {x: 'y'}));
+    assert.isFalse(deepEqual({x: 'y'}, {x: 'z'}));
+    assert.isFalse(deepEqual({x: 'y'}, {z: 'y'}));
+    assert.isFalse(deepEqual({x: {y: 'y'}}, {x: {y: 'z'}}));
+  });
+
+  test('deepEqual arrays', () => {
+    assert.isTrue(deepEqual([], []));
+    assert.isTrue(deepEqual([1], [1]));
+    assert.isTrue(deepEqual(['a', 'b'], ['a', 'b']));
+    assert.isTrue(deepEqual(['a', ['b']], ['a', ['b']]));
+
+    assert.isFalse(deepEqual(undefined, []));
+    assert.isFalse(deepEqual(null, []));
+    assert.isFalse(deepEqual([], undefined));
+    assert.isFalse(deepEqual([], null));
+    assert.isFalse(deepEqual([], [1]));
+    assert.isFalse(deepEqual([1], [2]));
+    assert.isFalse(deepEqual([1, 2], [1]));
+    assert.isFalse(deepEqual(['a', ['b']], ['a', ['c']]));
+  });
+
+  test('deepEqual sets', () => {
+    assert.isTrue(deepEqual(new Set([]), new Set([])));
+    assert.isTrue(deepEqual(new Set([1]), new Set([1])));
+    assert.isTrue(deepEqual(new Set(['a', 'b']), new Set(['a', 'b'])));
+
+    assert.isFalse(deepEqual(undefined, new Set([])));
+    assert.isFalse(deepEqual(null, new Set([])));
+    assert.isFalse(deepEqual(new Set([]), undefined));
+    assert.isFalse(deepEqual(new Set([]), null));
+    assert.isFalse(deepEqual(new Set([]), new Set([1])));
+    assert.isFalse(deepEqual(new Set([1]), new Set([2])));
+    assert.isFalse(deepEqual(new Set([1, 2]), new Set([1])));
+  });
+
+  test('deepEqual maps', () => {
+    assert.isTrue(deepEqual(new Map([]), new Map([])));
+    assert.isTrue(deepEqual(new Map([[1, 'b']]), new Map([[1, 'b']])));
+    assert.isTrue(deepEqual(new Map([['a', 'b']]), new Map([['a', 'b']])));
+
+    assert.isFalse(deepEqual(undefined, new Map([])));
+    assert.isFalse(deepEqual(null, new Map([])));
+    assert.isFalse(deepEqual(new Map([]), undefined));
+    assert.isFalse(deepEqual(new Map([]), null));
+    assert.isFalse(deepEqual(new Map([]), new Map([[1, 'b']])));
+    assert.isFalse(deepEqual(new Map([[1, 'a']]), new Map([[1, 'b']])));
+    assert.isFalse(
+      deepEqual(
+        new Map([[1, 'a']]),
+        new Map([
+          [1, 'a'],
+          [2, 'b'],
+        ])
+      )
+    );
+  });
+
+  test('deepEqual nested', () => {
+    assert.isFalse(deepEqual({foo: new Set([])}, {foo: new Map([])}));
+  });
+});
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
index 7114f98..850509f 100644
--- a/polygerrit-ui/app/utils/display-name-util.ts
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {AccountInfo, GroupInfo, ServerInfo} from '../types/common';
 import {DefaultDisplayNameConfig} from '../constants/constants';
@@ -66,22 +55,22 @@
   config: ServerInfo | undefined,
   account: AccountInfo
 ) {
-  const reviewerName = getUserName(config, account);
-  const reviewerEmail = _accountEmail(account.email);
+  const reviewerName = getDisplayName(config, account);
+  const reviewerEmail = accountEmail(account.email);
   const reviewerStatus = account.status ? '(' + account.status + ')' : '';
   return [reviewerName, reviewerEmail, reviewerStatus]
     .filter(p => p.length > 0)
     .join(' ');
 }
 
-function _accountEmail(email?: string) {
+function accountEmail(email?: string) {
   if (typeof email !== 'undefined') {
     return '<' + email + '>';
   }
   return '';
 }
 
-export const _testOnly_accountEmail = _accountEmail;
+export const _testOnly_accountEmail = accountEmail;
 
 export function getGroupDisplayName(group: GroupInfo) {
   return `${group.name || ''} (group)`;
diff --git a/polygerrit-ui/app/utils/display-name-util_test.ts b/polygerrit-ui/app/utils/display-name-util_test.ts
index e6d4704..2f938a5 100644
--- a/polygerrit-ui/app/utils/display-name-util_test.ts
+++ b/polygerrit-ui/app/utils/display-name-util_test.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {
   AccountInfo,
   DefaultDisplayNameConfig,
@@ -22,7 +10,7 @@
   GroupName,
   ServerInfo,
 } from '../api/rest-api';
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
   getDisplayName,
   getUserName,
@@ -35,6 +23,7 @@
   createGroupInfo,
   createServerInfo,
 } from '../test/test-data-generators';
+import {assert} from '@open-wc/testing';
 
 suite('display-name-utils tests', () => {
   const config: ServerInfo = {
@@ -205,6 +194,18 @@
     );
   });
 
+  test('getAccountDisplayName - account with display name', () => {
+    assert.equal(
+      getAccountDisplayName(config, {
+        display_name: 'Display Name',
+        name: 'Some name',
+        email: 'my@example.com' as EmailAddress,
+        status: 'OOO',
+      }),
+      'Display Name <my@example.com> (OOO)'
+    );
+  });
+
   test('getGroupDisplayName', () => {
     assert.equal(
       getGroupDisplayName({
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index e2fa8fe..056238a 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -1,27 +1,8 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {check} from './common-util';
-
-/**
- * Event emitted from polymer elements.
- */
-export interface PolymerEvent extends EventApi, Event {}
-
 interface ElementWithShadowRoot extends Element {
   shadowRoot: ShadowRoot;
 }
@@ -201,25 +182,37 @@
 }
 
 /**
- * Are any ancestors of the element (or the element itself) members of the
- * given class.
+ * Are any ancestors of the element (or the element itself) tagged with the
+ * given css class?
  *
+ * We are walking up the DOM using `element.parentElement`, but are not crossing
+ * Shadow DOM boundaries, if there are any.
  */
 export function descendedFromClass(
-  element: Element,
+  element: Element | undefined,
   className: string,
   stopElement?: Element
 ) {
-  let isDescendant = element.classList.contains(className);
-  while (
-    !isDescendant &&
-    element.parentElement &&
-    (!stopElement || element.parentElement !== stopElement)
-  ) {
-    isDescendant = element.classList.contains(className);
-    element = element.parentElement;
+  return parentWithClass(element, className, stopElement) !== undefined;
+}
+
+/**
+ * Returns an ancestor of the element (or the element itself) tagged with the
+ * given css class - or undefined.
+ *
+ * We are walking up the DOM using `element.parentElement`, but are not crossing
+ * Shadow DOM boundaries, if there are any.
+ */
+export function parentWithClass(
+  element: Element | undefined,
+  className: string,
+  stopElement?: Element
+) {
+  while (element && (!stopElement || element !== stopElement)) {
+    if (element.classList.contains(className)) return element;
+    element = element.parentElement ?? undefined;
   }
-  return isDescendant;
+  return undefined;
 }
 
 /**
@@ -277,11 +270,12 @@
 ) {
   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();
+      for (const entry of entries) {
+        if (entry.isIntersecting) {
+          observer.unobserve(entry.target);
+          callback();
+          return;
+        }
       }
     },
     {rootMargin: `${marginPx}px`}
@@ -338,6 +332,8 @@
   combo?: ComboKey;
   /** Defaults to no modifiers. */
   modifiers?: Modifier[];
+  /** Defaults to false. If true, then `event.repeat === true` is allowed. */
+  allowRepeat?: boolean;
 }
 
 const ALPHA_NUM = new RegExp(/^[A-Za-z0-9]$/);
@@ -388,29 +384,38 @@
   return true;
 }
 
-export function addGlobalShortcut(
-  shortcut: Binding,
-  listener: (e: KeyboardEvent) => void
-) {
-  return addShortcut(document.body, shortcut, listener);
+export interface ShortcutOptions {
+  /**
+   * Do you want to suppress events from <input> elements and such?
+   */
+  shouldSuppress?: boolean;
+  /**
+   * Do you want to take care of calling preventDefault() and
+   * stopPropagation() yourself? Then set this option to `false`.
+   */
+  preventDefault?: boolean;
 }
 
+/**
+ * @deprecated
+ *
+ * For LitElement use the shortcut-controller.
+ */
 export function addShortcut(
   element: HTMLElement,
   shortcut: Binding,
   listener: (e: KeyboardEvent) => void,
-  options: {
-    shouldSuppress: boolean;
-  } = {
-    shouldSuppress: false,
-  }
+  options?: ShortcutOptions
 ) {
+  const optShouldSuppress = options?.shouldSuppress ?? false;
+  const optPreventDefault = options?.preventDefault ?? true;
   const wrappedListener = (e: KeyboardEvent) => {
-    if (e.repeat) return;
-    if (options.shouldSuppress && shouldSuppress(e)) return;
-    if (eventMatchesShortcut(e, shortcut)) {
-      listener(e);
-    }
+    if (e.repeat && !shortcut.allowRepeat) return;
+    if (optShouldSuppress && shouldSuppress(e)) return;
+    if (!eventMatchesShortcut(e, shortcut)) return;
+    if (optPreventDefault) e.preventDefault();
+    if (optPreventDefault) e.stopPropagation();
+    listener(e);
   };
   element.addEventListener('keydown', wrappedListener);
   return () => element.removeEventListener('keydown', wrappedListener);
@@ -449,17 +454,43 @@
     // mark-reviewed and then press ] to go to the next file'.
     (tagName === 'INPUT' && type !== 'checkbox') ||
     tagName === 'TEXTAREA' ||
-    // Suppress shortcuts if the key is 'enter'
-    // and target is an anchor or button or paper-tab.
-    (e.keyCode === 13 &&
-      (tagName === 'A' || tagName === 'BUTTON' || tagName === 'PAPER-TAB'))
+    (e.key === 'Enter' &&
+      (tagName === 'A' ||
+        tagName === 'BUTTON' ||
+        tagName === 'GR-BUTTON' ||
+        tagName === 'PAPER-TAB'))
   ) {
     return true;
   }
   const path: EventTarget[] = e.composedPath() ?? [];
   for (const el of path) {
     if (!isElementTarget(el)) continue;
-    if (el.tagName === 'GR-OVERLAY') return true;
+    if (el.tagName === 'DIALOG') return true;
   }
   return false;
 }
+
+/** Returns a promise that waits for the element's height to become > 0. */
+export function untilRendered(el: HTMLElement) {
+  return new Promise(resolve => {
+    whenRendered(el, resolve);
+  });
+}
+
+/** Executes the given callback when the element's height is > 0. */
+export function whenRendered(
+  el: HTMLElement,
+  callback: (value?: unknown) => void
+) {
+  if (el.clientHeight > 0) {
+    callback();
+    return;
+  }
+  const obs = new ResizeObserver(() => {
+    if (el.clientHeight > 0) {
+      callback();
+      obs.unobserve(el);
+    }
+  });
+  obs.observe(el);
+}
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index e139805..fe185be 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -1,34 +1,24 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
   descendedFromClass,
   eventMatchesShortcut,
   getComputedStyleValue,
   getEventPath,
+  Key,
   Modifier,
   querySelectorAll,
   shouldSuppress,
   strToClassName,
 } from './dom-util';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {mockPromise, queryAndAssert} from '../test/test-utils';
+import {mockPromise, pressKey, queryAndAssert} from '../test/test-utils';
+import {fixture, assert} from '@open-wc/testing';
+import {LitElement, html} from 'lit';
+import {customElement} from 'lit/decorators.js';
 
 /**
  * You might think that instead of passing in the callback with assertions as a
@@ -40,7 +30,6 @@
 function keyEventOn(
   el: HTMLElement,
   callback: (e: KeyboardEvent) => void,
-  keyCode = 75,
   key = 'k'
 ): Promise<KeyboardEvent> {
   const promise = mockPromise<KeyboardEvent>();
@@ -48,16 +37,13 @@
     callback(e);
     promise.resolve(e);
   });
-  MockInteractions.keyDownOn(el, keyCode, null, key);
+  pressKey(el, key);
   return promise;
 }
 
-class TestEle extends PolymerElement {
-  static get is() {
-    return 'dom-util-test-element';
-  }
-
-  static get template() {
+@customElement('dom-util-test-element')
+export class TestElement extends LitElement {
+  override render() {
     return html`
       <div>
         <div class="a">
@@ -72,15 +58,15 @@
   }
 }
 
-customElements.define(TestEle.is, TestEle);
-
-const basicFixture = fixtureFromTemplate(html`
-  <div id="test" class="a b c">
-    <a class="testBtn" style="color:red;"></a>
-    <dom-util-test-element></dom-util-test-element>
-    <span class="ss"></span>
-  </div>
-`);
+async function createFixture() {
+  return await fixture<HTMLElement>(html`
+    <div id="test" class="a b c d">
+      <a class="testBtn" style="color:red;"></a>
+      <dom-util-test-element></dom-util-test-element>
+      <span class="ss"></span>
+    </div>
+  `);
+}
 
 suite('dom-util tests', () => {
   suite('getEventPath', () => {
@@ -135,25 +121,21 @@
       );
     });
 
-    test('event with real click', () => {
-      const element = basicFixture.instantiate() as HTMLElement;
-      const aLink = queryAndAssert(element, 'a');
+    test('event with real click', async () => {
+      const element = await createFixture();
+      const aLink = queryAndAssert<HTMLAnchorElement>(element, 'a');
       let path;
       aLink.addEventListener('click', (e: Event) => {
         path = getEventPath(e as MouseEvent);
       });
-      MockInteractions.click(aLink);
-      assert.equal(
-        path,
-        `html>body>test-fixture#${basicFixture.fixtureId}>` +
-          'div#test.a.b.c>a.testBtn'
-      );
+      aLink.click();
+      assert.equal(path, 'html>body>div>div#test.a.b.c.d>a.testBtn');
     });
   });
 
   suite('querySelector and querySelectorAll', () => {
-    test('query cross shadow dom', () => {
-      const element = basicFixture.instantiate() as HTMLElement;
+    test('query cross shadow dom', async () => {
+      const element = await createFixture();
       const theFirstEl = queryAndAssert(element, '.ss');
       const allEls = querySelectorAll(element, '.ss');
       assert.equal(allEls.length, 3);
@@ -162,22 +144,52 @@
   });
 
   suite('getComputedStyleValue', () => {
-    test('color style', () => {
-      const element = basicFixture.instantiate() as HTMLElement;
+    test('color style', async () => {
+      const element = await createFixture();
       const testBtn = queryAndAssert(element, '.testBtn');
       assert.equal(getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)');
     });
   });
 
   suite('descendedFromClass', () => {
-    test('basic tests', () => {
-      const element = basicFixture.instantiate() as HTMLElement;
+    test('descends from itself', async () => {
+      const element = await createFixture();
       const testEl = queryAndAssert(element, 'dom-util-test-element');
-      // .c is a child of .a and not vice versa.
-      assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.c'), 'a'));
-      assert.isFalse(descendedFromClass(queryAndAssert(testEl, '.a'), 'c'));
+      assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.c'), 'c'));
+      assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.b'), 'b'));
+      assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.a'), 'a'));
+    });
 
-      // Stops at stop element.
+    test('.c in .b in .a', async () => {
+      const element = await createFixture();
+      const testEl = queryAndAssert(element, 'dom-util-test-element');
+      const a = queryAndAssert(testEl, '.a');
+      const b = queryAndAssert(testEl, '.b');
+      const c = queryAndAssert(testEl, '.c');
+      assert.isTrue(descendedFromClass(a, 'a'));
+      assert.isTrue(descendedFromClass(b, 'a'));
+      assert.isTrue(descendedFromClass(c, 'a'));
+      assert.isFalse(descendedFromClass(a, 'b'));
+      assert.isTrue(descendedFromClass(b, 'b'));
+      assert.isTrue(descendedFromClass(c, 'b'));
+      assert.isFalse(descendedFromClass(a, 'c'));
+      assert.isFalse(descendedFromClass(b, 'c'));
+      assert.isTrue(descendedFromClass(c, 'c'));
+    });
+
+    test('stops at shadow root', async () => {
+      const element = await createFixture();
+      const testEl = queryAndAssert(element, 'dom-util-test-element');
+      const a = queryAndAssert(testEl, '.a');
+      // div.d is a parent of testEl, but `descendedFromClass` does not cross
+      // the shadow root boundary of <dom-util-test-element>. So div.a inside
+      // the shadow root is not considered to descend from div.d outside of it.
+      assert.isFalse(descendedFromClass(a, 'd'));
+    });
+
+    test('stops at stop element', async () => {
+      const element = await createFixture();
+      const testEl = queryAndAssert(element, 'dom-util-test-element');
       assert.isFalse(
         descendedFromClass(
           queryAndAssert(testEl, '.c'),
@@ -317,13 +329,12 @@
       });
     });
 
-    test('suppress shortcut event from children of <gr-overlay>', async () => {
-      const overlay = document.createElement('gr-overlay');
-      const div = document.createElement('div');
-      overlay.appendChild(div);
-      await keyEventOn(div, e => {
-        assert.isTrue(shouldSuppress(e));
-      });
+    test('suppress "enter" shortcut event from <gr-button>', async () => {
+      await keyEventOn(
+        document.createElement('gr-button'),
+        e => assert.isTrue(shouldSuppress(e)),
+        Key.ENTER
+      );
     });
 
     test('suppress "enter" shortcut event from <a>', async () => {
@@ -333,8 +344,7 @@
       await keyEventOn(
         document.createElement('a'),
         e => assert.isTrue(shouldSuppress(e)),
-        13,
-        'enter'
+        Key.ENTER
       );
     });
   });
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 2018eeb..714955b 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {FetchRequest} from '../types/types';
 import {
   DialogChangeEventDetail,
@@ -32,7 +20,7 @@
   );
 }
 
-type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
+export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
   HTMLElementEventMap[K] extends CustomEvent<infer DT>
     ? unknown extends DT
       ? never
@@ -69,7 +57,7 @@
 }
 
 export function fireAlert(target: EventTarget, message: string) {
-  fire(target, EventType.SHOW_ALERT, {message});
+  fire(target, EventType.SHOW_ALERT, {message, showDismiss: true});
 }
 
 export function firePageError(response?: Response | null) {
@@ -105,14 +93,14 @@
   fire(target, EventType.IRON_ANNOUNCE, {text});
 }
 
-export function fireShowPrimaryTab(
+export function fireShowTab(
   target: EventTarget,
   tab: string,
   scrollIntoView?: boolean,
   tabState?: TabState
 ) {
   const detail: SwitchTabEventDetail = {tab, scrollIntoView, tabState};
-  fire(target, EventType.SHOW_PRIMARY_TAB, detail);
+  fire(target, EventType.SHOW_TAB, detail);
 }
 
 export function fireCloseFixPreview(target: EventTarget, fixApplied: boolean) {
diff --git a/polygerrit-ui/app/utils/file-util.ts b/polygerrit-ui/app/utils/file-util.ts
new file mode 100644
index 0000000..246ac20
--- /dev/null
+++ b/polygerrit-ui/app/utils/file-util.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/** See also Patch.java for the backend equivalent. */
+export enum FileMode {
+  /** Mode indicating an entry is a symbolic link. */
+  SYMLINK = 0o120000,
+
+  /** Mode indicating an entry is a non-executable file. */
+  REGULAR_FILE = 0o100644,
+
+  /** Mode indicating an entry is an executable file. */
+  EXECUTABLE_FILE = 0o100755,
+
+  /** Mode indicating an entry is a submodule commit in another repository. */
+  GITLINK = 0o160000,
+}
+
+export function fileModeToString(mode?: number, includeNumber = true): string {
+  const str = fileModeStr(mode);
+  const num = mode?.toString(8);
+  return `${str}${includeNumber && str ? ` (${num})` : ''}`;
+}
+
+function fileModeStr(mode?: number): string {
+  if (mode === FileMode.SYMLINK) return 'symlink';
+  if (mode === FileMode.REGULAR_FILE) return 'regular';
+  if (mode === FileMode.EXECUTABLE_FILE) return 'executable';
+  if (mode === FileMode.GITLINK) return 'gitlink';
+  return '';
+}
+
+export function expandFileMode(input?: string) {
+  if (!input) return input;
+  for (const modeNum of Object.values(FileMode) as FileMode[]) {
+    const modeStr = modeNum?.toString(8);
+    if (input.includes(modeStr)) {
+      return input.replace(modeStr, `${fileModeToString(modeNum)}`);
+    }
+  }
+  return input;
+}
diff --git a/polygerrit-ui/app/utils/file-util_test.ts b/polygerrit-ui/app/utils/file-util_test.ts
new file mode 100644
index 0000000..aeab026
--- /dev/null
+++ b/polygerrit-ui/app/utils/file-util_test.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {expandFileMode, FileMode, fileModeToString} from './file-util';
+
+suite('file-util tests', () => {
+  test('fileModeToString', () => {
+    const check = (
+      mode: number | undefined,
+      str: string,
+      includeNumber = true
+    ) => assert.equal(fileModeToString(mode, includeNumber), str);
+
+    check(undefined, '');
+    check(0, '');
+    check(1, '');
+    check(FileMode.REGULAR_FILE, 'regular', false);
+    check(FileMode.EXECUTABLE_FILE, 'executable', false);
+    check(FileMode.SYMLINK, 'symlink', false);
+    check(FileMode.GITLINK, 'gitlink', false);
+    check(FileMode.REGULAR_FILE, 'regular (100644)');
+    check(FileMode.EXECUTABLE_FILE, 'executable (100755)');
+    check(FileMode.SYMLINK, 'symlink (120000)');
+    check(FileMode.GITLINK, 'gitlink (160000)');
+  });
+
+  test('expandFileMode', () => {
+    assert.deepEqual(['asdf'].map(expandFileMode), ['asdf']);
+    assert.deepEqual(
+      ['old mode 100644', 'new mode 100755'].map(expandFileMode),
+      ['old mode regular (100644)', 'new mode executable (100755)']
+    );
+  });
+});
diff --git a/polygerrit-ui/app/utils/focusable.ts b/polygerrit-ui/app/utils/focusable.ts
new file mode 100644
index 0000000..d5bed09
--- /dev/null
+++ b/polygerrit-ui/app/utils/focusable.ts
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const FOCUSABLE_QUERY =
+  'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
+
+/**
+ * Gets an ordered list of focusable elements nested within a containing
+ * element that may contain shadow DOMs.
+ *
+ * This goes depth-first, so that the order of elements follows the a11y tree.
+ */
+export function* getFocusableElements(
+  el: HTMLElement | SVGElement
+): Generator<HTMLElement | SVGElement> {
+  const style = window.getComputedStyle(el);
+  if (style.display === 'none' || style.visibility === 'hidden') return;
+  if (el.matches(FOCUSABLE_QUERY)) {
+    yield el;
+  }
+
+  let children = [];
+  if (el.localName === 'slot') {
+    children = (el as HTMLSlotElement).assignedNodes({flatten: true});
+  } else {
+    children = [...(el.shadowRoot || el).children];
+  }
+
+  for (const node of children.filter(
+    node => node instanceof HTMLElement || node instanceof SVGElement
+  )) {
+    yield* getFocusableElements(node as HTMLElement | SVGElement);
+  }
+}
+
+/**
+ * Gets an ordered list of focusable elements nested within a containing
+ * element that may contain shadow DOMs.
+ *
+ * This returns in reverse a11 order.
+ */
+export function* getFocusableElementsReverse(
+  el: HTMLElement | SVGElement
+): Generator<HTMLElement | SVGElement> {
+  const style = window.getComputedStyle(el);
+  if (style.display === 'none' || style.visibility === 'hidden') return;
+
+  let children = [];
+  if (el.localName === 'slot') {
+    children = (el as HTMLSlotElement).assignedNodes({flatten: true});
+  } else {
+    children = [...(el.shadowRoot || el).children];
+  }
+
+  for (const node of children
+    .filter(node => node instanceof HTMLElement || node instanceof SVGElement)
+    .reverse()) {
+    yield* getFocusableElementsReverse(node as HTMLElement | SVGElement);
+  }
+
+  if (el.matches(FOCUSABLE_QUERY)) {
+    yield el;
+  }
+}
diff --git a/polygerrit-ui/app/utils/focusable_test.ts b/polygerrit-ui/app/utils/focusable_test.ts
new file mode 100644
index 0000000..6ae40ec
--- /dev/null
+++ b/polygerrit-ui/app/utils/focusable_test.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import {getFocusableElements, getFocusableElementsReverse} from './focusable';
+import {html, render} from 'lit';
+import {fixture, assert} from '@open-wc/testing';
+
+async function createDom() {
+  const container = await fixture<HTMLDivElement>(html`<div></div>`);
+  const shadow = container.attachShadow({mode: 'open'});
+  render(
+    html`
+      <a href="" id="first">A link</a>
+      <slot></slot>
+      <button id="third">A button</button>
+      <button class="not" style="display: none">No Display Button</button>
+      <button class="not" style="visibility: hidden">Hidden Button</button>
+      <span id="fourth" tabindex="0">Focusable Span</span>
+      <textarea id="fifth">TextArea</textarea>
+      <div id="moreshadow">
+        <button class="not in shadow"></button>
+      </div>
+    `,
+    shadow
+  );
+  const slottedContent = document.createElement('div');
+  render(
+    html` <textarea id="second">Slotted TextArea</textarea> `,
+    slottedContent
+  );
+  container.appendChild(slottedContent);
+  const slot: HTMLSlotElement | null = shadow.querySelector('slot');
+  // For some reason Typescript doesn't know about the `assign` method on
+  // HTMLSlotElement.
+  //
+  (slot! as any).assign(slottedContent);
+  const moreShadow = shadow
+    .querySelector('#moreshadow')!
+    .attachShadow({mode: 'open'});
+  render(
+    html`
+      <form>
+        <input id="sixth" type="submit" value="Submit" />
+      </form>
+    `,
+    moreShadow
+  );
+  return container;
+}
+
+suite('focusable', () => {
+  test('Finds all focusables in-order', async () => {
+    const container = await createDom();
+    const results = [...getFocusableElements(container)];
+    assert.includeOrderedMembers(
+      results.map(e => e.id),
+      ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
+    );
+  });
+
+  test('Finds all focusables in reverse order', async () => {
+    const container = await createDom();
+    const results = [...getFocusableElementsReverse(container)];
+    assert.includeOrderedMembers(
+      results.map(e => e.id),
+      ['sixth', 'fifth', 'fourth', 'third', 'second', 'first']
+    );
+  });
+});
diff --git a/polygerrit-ui/app/utils/inner-html-util.ts b/polygerrit-ui/app/utils/inner-html-util.ts
index 549f493..ed9bfac 100644
--- a/polygerrit-ui/app/utils/inner-html-util.ts
+++ b/polygerrit-ui/app/utils/inner-html-util.ts
@@ -1,36 +1,17 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
-// This file adds some simple checks to match internal google rules.
-// Internally in google it has different implementation
+// This file adds some simple checks to match internal Google rules.
+// Internally at Google it has different a implementation.
 
 import {BrandType} from '../types/common';
+export {sanitizeHtml, htmlEscape, sanitizeHtmlToFragment} from 'safevalues';
 
-export type SafeHtml = BrandType<string, '_safeHtml'>;
 export type SafeStyleSheet = BrandType<string, '_safeHtml'>;
 
-export function setInnerHtml(el: HTMLElement, innerHTML: SafeHtml) {
-  el.innerHTML = innerHTML;
-}
-
-export function createStyle(styleSheet: SafeStyleSheet): SafeHtml {
-  return `<style>${styleSheet}</style>` as SafeHtml;
-}
-
 export function safeStyleSheet(
   templateObj: TemplateStringsArray
 ): SafeStyleSheet {
@@ -40,3 +21,9 @@
   }
   return styleSheet as SafeStyleSheet;
 }
+
+export const safeStyleEl = {
+  setTextContent: (elem: HTMLStyleElement, safeStyleSheet: SafeStyleSheet) => {
+    elem.textContent = safeStyleSheet;
+  },
+};
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 1a48a7b..aaa35a4 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -1,24 +1,14 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {
   ChangeInfo,
   isQuickLabelInfo,
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
+  LabelNameToValuesMap,
 } from '../api/rest-api';
 import {
   AccountInfo,
@@ -30,7 +20,12 @@
   VotingRangeInfo,
 } from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
-import {assertNever, unique} from './common-util';
+import {assertNever, unique, hasOwnProperty} from './common-util';
+
+export interface Label {
+  name: string;
+  value: string | null;
+}
 
 // Name of the standard Code-Review label.
 export enum StandardLabels {
@@ -90,8 +85,10 @@
         : LabelStatus.RECOMMENDED;
     }
   } else if (isQuickLabelInfo(label)) {
-    if (label.approved) return LabelStatus.RECOMMENDED;
-    if (label.rejected) return LabelStatus.DISLIKED;
+    if (label.approved) return LabelStatus.APPROVED;
+    if (label.rejected) return LabelStatus.REJECTED;
+    if (label.disliked) return LabelStatus.DISLIKED;
+    if (label.recommended) return LabelStatus.RECOMMENDED;
   }
   return LabelStatus.NEUTRAL;
 }
@@ -121,7 +118,10 @@
   }
 }
 
-export function valueString(value?: number) {
+/**
+ * Returns string representation of QuickLabelInfo value or ApprovalInfo value
+ */
+export function valueString(value?: number): string {
   if (!value) return ' 0';
   let s = `${value}`;
   if (value > 0) s = `+${s}`;
@@ -145,7 +145,10 @@
   if (isDetailedLabelInfo(label)) {
     return !hasNeutralStatus(label, getApprovalInfo(label, account));
   } else if (isQuickLabelInfo(label)) {
-    return label.approved === account || label.rejected === account;
+    return (
+      label.approved?._account_id === account._account_id ||
+      label.rejected?._account_id === account._account_id
+    );
   }
   return false;
 }
@@ -178,7 +181,12 @@
     );
   }
   if (isQuickLabelInfo(labelInfo)) {
-    return !!labelInfo.rejected || !!labelInfo.approved;
+    return (
+      !!labelInfo.rejected ||
+      !!labelInfo.approved ||
+      !!labelInfo.recommended ||
+      !!labelInfo.disliked
+    );
   }
   return false;
 }
@@ -217,29 +225,54 @@
 }
 
 export function extractAssociatedLabels(
-  requirement: SubmitRequirementResultInfo
+  requirement: SubmitRequirementResultInfo,
+  type: 'all' | 'onlyOverride' | 'onlySubmittability' = 'all'
 ): string[] {
-  let labels = extractLabelsFrom(
-    requirement.submittability_expression_result.expression
-  );
-  if (requirement.override_expression_result) {
+  let labels: string[] = [];
+  if (requirement.submittability_expression_result && type !== 'onlyOverride') {
+    labels = labels.concat(
+      extractLabelsFrom(requirement.submittability_expression_result.expression)
+    );
+  }
+  if (requirement.override_expression_result && type !== 'onlySubmittability') {
     labels = labels.concat(
       extractLabelsFrom(requirement.override_expression_result.expression)
     );
   }
   return labels.filter(unique);
 }
+export interface SubmitRequirementsIcon {
+  // The material icon name.
+  icon: string;
+  // Whether the gr-icon need to be filled.
+  filled?: boolean;
+}
 
-export function iconForStatus(status: SubmitRequirementStatus) {
+export function iconForRequirement(
+  requirement: SubmitRequirementResultInfo
+): SubmitRequirementsIcon {
+  if (isBlockingCondition(requirement)) {
+    return {icon: 'cancel', filled: true};
+  }
+  return iconForStatus(requirement.status);
+}
+
+export function iconForStatus(
+  status: SubmitRequirementStatus
+): SubmitRequirementsIcon {
   switch (status) {
     case SubmitRequirementStatus.SATISFIED:
-      return 'check';
+      return {icon: 'check_circle', filled: true};
     case SubmitRequirementStatus.UNSATISFIED:
-      return 'close';
+      return {icon: 'block'};
     case SubmitRequirementStatus.OVERRIDDEN:
-      return 'overridden';
+      return {icon: 'published_with_changes'};
     case SubmitRequirementStatus.NOT_APPLICABLE:
-      return 'info';
+      return {icon: 'info', filled: true};
+    case SubmitRequirementStatus.ERROR:
+      return {icon: 'error', filled: true};
+    case SubmitRequirementStatus.FORCED:
+      return {icon: 'check_circle', filled: true};
     default:
       assertNever(status, `Unsupported status: ${status}`);
   }
@@ -247,24 +280,11 @@
 
 /**
  * Show only applicable.
- * If there are only legacy requirements, show all legacy requirements.
- * If there is at least one non-legacy requirement, filter legacy requirements.
  */
 export function getRequirements(change?: ParsedChangeInfo | ChangeInfo) {
-  let submit_requirements = (change?.submit_requirements ?? []).filter(
+  return (change?.submit_requirements ?? []).filter(
     req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
   );
-
-  const hasNonLegacyRequirements = submit_requirements.some(
-    req => req.is_legacy === false
-  );
-  if (hasNonLegacyRequirements) {
-    submit_requirements = submit_requirements.filter(
-      req => req.is_legacy === false
-    );
-  }
-
-  return submit_requirements;
 }
 
 // TODO(milutin): This may be temporary for demo purposes
@@ -288,9 +308,120 @@
   return priorityRequirementList.concat(nonPriorityRequirements);
 }
 
+function getStringLabelValue(
+  labels: LabelNameToInfoMap,
+  labelName: string,
+  numberValue?: number
+): string {
+  const detailedInfo = labels[labelName] as DetailedLabelInfo;
+  if (detailedInfo.values) {
+    for (const labelValue of Object.keys(detailedInfo.values)) {
+      if (Number(labelValue) === numberValue) {
+        return labelValue;
+      }
+    }
+  }
+  // TODO: This code is sometimes executed with numberValue taking the
+  // values 0 and undefined.
+  // For now it is unclear how this is happening, ideally this code should
+  // never be executed.
+  return `${numberValue}`;
+}
+
+export function getDefaultValue(
+  labels?: LabelNameToInfoMap,
+  labelName?: string
+) {
+  if (!labelName || !labels?.[labelName]) return undefined;
+  const labelInfo = labels[labelName] as DetailedLabelInfo;
+  return labelInfo.default_value;
+}
+
+export function getVoteForAccount(
+  labelName: string,
+  account?: AccountInfo,
+  change?: ParsedChangeInfo | ChangeInfo
+): string | null {
+  const labels = change?.labels;
+  if (!account || !labels) return null;
+  const votes = labels[labelName] as DetailedLabelInfo;
+  if (!votes.all?.length) return null;
+  for (let i = 0; i < votes.all.length; i++) {
+    if (votes.all[i]._account_id === account._account_id) {
+      return getStringLabelValue(labels, labelName, votes.all[i].value);
+    }
+  }
+  return null;
+}
+
+export function computeOrderedLabelValues(
+  permittedLabels?: LabelNameToValuesMap
+) {
+  if (!permittedLabels) return [];
+  const labels = Object.keys(permittedLabels);
+  const values: Set<number> = new Set();
+  for (const label of labels) {
+    for (const value of permittedLabels[label]) {
+      values.add(Number(value));
+    }
+  }
+
+  return Array.from(values.values()).sort((a, b) => a - b);
+}
+
+export function mergeLabelInfoMaps(
+  a?: LabelNameToInfoMap,
+  b?: LabelNameToInfoMap
+): LabelNameToInfoMap {
+  if (!a || !b) return {};
+  const mergedMap: LabelNameToInfoMap = {};
+  for (const key of Object.keys(a)) {
+    if (!hasOwnProperty(b, key)) continue;
+    mergedMap[key] = a[key];
+  }
+  return mergedMap;
+}
+
+export function mergeLabelMaps(
+  a?: LabelNameToValuesMap,
+  b?: LabelNameToValuesMap
+): LabelNameToValuesMap {
+  if (!a || !b) return {};
+  const mergedMap: LabelNameToValuesMap = {};
+  for (const key of Object.keys(a)) {
+    if (!hasOwnProperty(b, key)) continue;
+    mergedMap[key] = mergeLabelValues(a[key], b[key]);
+  }
+  return mergedMap;
+}
+
+export function mergeLabelValues(a: string[], b: string[]) {
+  return a.filter(value => b.includes(value));
+}
+
+export function computeLabels(
+  account?: AccountInfo,
+  change?: ParsedChangeInfo | ChangeInfo
+): Label[] {
+  if (!account) return [];
+  const labelsObj = change?.labels;
+  if (!labelsObj) return [];
+  return Object.keys(labelsObj)
+    .sort(labelCompare)
+    .map(key => {
+      return {
+        name: key,
+        value: getVoteForAccount(key, account, change),
+      };
+    });
+}
+
 export function getTriggerVotes(change?: ParsedChangeInfo | ChangeInfo) {
   const allLabels = Object.keys(change?.labels ?? {});
-  const submitReqs = getRequirements(change);
+  // Normally there is utility method getRequirements, which filter out
+  // not_applicable requirements. In this case we don't want to filter out them,
+  // because trigger votes are labels not associated with any requirement.
+  const submitReqs = change?.submit_requirements ?? [];
   const labelAssociatedWithSubmitReqs = submitReqs
     .flatMap(req => extractAssociatedLabels(req))
     .filter(unique);
@@ -298,3 +429,34 @@
     label => !labelAssociatedWithSubmitReqs.includes(label)
   );
 }
+
+export function getApplicableLabels(change?: ParsedChangeInfo | ChangeInfo) {
+  const submitReqs = change?.submit_requirements ?? [];
+  const notApplicableLabels = submitReqs
+    .filter(sr => sr.status === SubmitRequirementStatus.NOT_APPLICABLE)
+    .flatMap(req => extractAssociatedLabels(req))
+    .filter(unique);
+
+  const applicableLabels = submitReqs
+    .filter(sr => sr.status !== SubmitRequirementStatus.NOT_APPLICABLE)
+    .flatMap(req => extractAssociatedLabels(req))
+    .filter(unique);
+
+  const onlyInNotApplicableLabels = notApplicableLabels.filter(
+    label => !applicableLabels.includes(label)
+  );
+
+  return applicableLabels.filter(
+    label => !onlyInNotApplicableLabels.includes(label)
+  );
+}
+
+export function isBlockingCondition(
+  requirement: SubmitRequirementResultInfo
+): boolean {
+  if (requirement.status !== SubmitRequirementStatus.UNSATISFIED) return false;
+
+  return !!requirement.submittability_expression_result.passing_atoms?.some(
+    atom => atom.match(/^label[0-9]*:[\w-]+=MIN$/)
+  );
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index 142c607..c86bda9 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {
   extractAssociatedLabels,
   getApprovalInfo,
@@ -29,6 +17,15 @@
   hasNeutralStatus,
   labelCompare,
   LabelStatus,
+  computeLabels,
+  mergeLabelMaps,
+  computeOrderedLabelValues,
+  mergeLabelInfoMaps,
+  getApplicableLabels,
+  isBlockingCondition,
+  valueString,
+  hasVotes,
+  hasVoted,
 } from './label-util';
 import {
   AccountId,
@@ -43,12 +40,19 @@
   createChange,
   createSubmitRequirementExpressionInfo,
   createSubmitRequirementResultInfo,
+  createNonApplicableSubmitRequirementResultInfo,
   createDetailedLabelInfo,
+  createAccountWithId,
+  createQuickLabelInfo,
+  createApproval,
 } from '../test/test-data-generators';
 import {
   SubmitRequirementResultInfo,
   SubmitRequirementStatus,
+  LabelNameToInfoMap,
+  SubmitRequirementExpressionInfoStatus,
 } from '../api/rest-api';
+import {assert} from '@open-wc/testing';
 
 const VALUES_0 = {
   '0': 'neutral',
@@ -187,8 +191,12 @@
     let labelInfo: QuickLabelInfo = {};
     assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
     labelInfo = {approved: createAccountWithEmail()};
-    assert.equal(getLabelStatus(labelInfo), LabelStatus.RECOMMENDED);
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.APPROVED);
     labelInfo = {rejected: createAccountWithEmail()};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.REJECTED);
+    labelInfo = {recommended: createAccountWithEmail()};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.RECOMMENDED);
+    labelInfo = {disliked: createAccountWithEmail()};
     assert.equal(getLabelStatus(labelInfo), LabelStatus.DISLIKED);
   });
 
@@ -232,33 +240,254 @@
     assert.equal(getRepresentativeValue(labelInfo), -2);
   });
 
-  suite('extractAssociatedLabels()', () => {
-    function createSubmitRequirementExpressionInfoWith(expression: string) {
-      return {
-        ...createSubmitRequirementResultInfo(),
-        submittability_expression_result: {
-          ...createSubmitRequirementExpressionInfo(),
-          expression,
-        },
-      };
-    }
+  test('computeOrderedLabelValues', () => {
+    const labelValues = computeOrderedLabelValues({
+      'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+      Verified: ['-1', ' 0', '+1'],
+    });
+    assert.deepEqual(labelValues, [-2, -1, 0, 1, 2]);
+  });
 
+  test('computeLabels', async () => {
+    const accountId = 123 as AccountId;
+    const account = createAccountWithId(accountId);
+    const change = {
+      ...createChange(),
+      labels: {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        } as DetailedLabelInfo,
+        Verified: {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        } as DetailedLabelInfo,
+      } as LabelNameToInfoMap,
+    };
+    let labels = computeLabels(account, change);
+    assert.deepEqual(labels, [
+      {name: 'Code-Review', value: null},
+      {name: 'Verified', value: null},
+    ]);
+    change.labels = {
+      ...change.labels,
+      Verified: {
+        ...change.labels.Verified,
+        all: [
+          {
+            _account_id: accountId,
+            value: 1,
+          },
+        ],
+      } as DetailedLabelInfo,
+    } as LabelNameToInfoMap;
+    labels = computeLabels(account, change);
+    assert.deepEqual(labels, [
+      {name: 'Code-Review', value: null},
+      {name: 'Verified', value: '+1'},
+    ]);
+  });
+
+  test('mergeLabelInfoMaps', () => {
+    assert.deepEqual(
+      mergeLabelInfoMaps(
+        {
+          A: createDetailedLabelInfo(),
+          B: createDetailedLabelInfo(),
+        },
+        undefined
+      ),
+      {}
+    );
+    assert.deepEqual(
+      mergeLabelInfoMaps(undefined, {
+        A: createDetailedLabelInfo(),
+        B: createDetailedLabelInfo(),
+      }),
+      {}
+    );
+
+    assert.deepEqual(
+      mergeLabelInfoMaps(
+        {
+          A: createDetailedLabelInfo(),
+          B: createDetailedLabelInfo(),
+        },
+        {
+          A: createDetailedLabelInfo(),
+          B: createDetailedLabelInfo(),
+        }
+      ),
+      {
+        A: createDetailedLabelInfo(),
+        B: createDetailedLabelInfo(),
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelInfoMaps(
+        {
+          A: createDetailedLabelInfo(),
+          B: createDetailedLabelInfo(),
+        },
+        {
+          B: createDetailedLabelInfo(),
+          C: createDetailedLabelInfo(),
+        }
+      ),
+      {
+        B: createDetailedLabelInfo(),
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelInfoMaps(
+        {
+          A: createDetailedLabelInfo(),
+          B: createDetailedLabelInfo(),
+        },
+        {
+          X: createDetailedLabelInfo(),
+          Y: createDetailedLabelInfo(),
+        }
+      ),
+      {}
+    );
+  });
+
+  test('mergeLabelMaps', () => {
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        },
+        undefined
+      ),
+      {}
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(undefined, {
+        A: ['-1', '0', '+1', '+2'],
+        B: ['-1', '0'],
+        C: ['-1', '0'],
+        D: ['0'],
+      }),
+      {}
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        },
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        }
+      ),
+      {
+        A: ['-1', '0', '+1', '+2'],
+        B: ['-1', '0'],
+        C: ['-1', '0'],
+        D: ['0'],
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+        },
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          D: ['0'],
+        }
+      ),
+      {
+        A: ['-1', '0', '+1', '+2'],
+        B: ['-1', '0'],
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+          D: ['0'],
+        },
+        {
+          A: [],
+          B: ['-1', '0'],
+          C: ['0', '+1'],
+          D: ['0'],
+        }
+      ),
+      {
+        A: [],
+        B: ['-1', '0'],
+        C: ['0'],
+        D: ['0'],
+      }
+    );
+
+    assert.deepEqual(
+      mergeLabelMaps(
+        {
+          A: ['-1', '0', '+1', '+2'],
+          B: ['-1', '0'],
+          C: ['-1', '0'],
+        },
+        {
+          X: ['-1', '0', '+1', '+2'],
+          Y: ['-1', '0'],
+          Z: ['0'],
+        }
+      ),
+      {}
+    );
+  });
+
+  suite('extractAssociatedLabels()', () => {
     test('1 label', () => {
-      const submitRequirement = createSubmitRequirementExpressionInfoWith(
-        'label:Verified=MAX -label:Verified=MIN'
-      );
+      const submitRequirement = createSubmitRequirementResultInfo();
       const labels = extractAssociatedLabels(submitRequirement);
       assert.deepEqual(labels, ['Verified']);
     });
     test('label with number', () => {
-      const submitRequirement = createSubmitRequirementExpressionInfoWith(
+      const submitRequirement = createSubmitRequirementResultInfo(
         'label2:verified=MAX'
       );
       const labels = extractAssociatedLabels(submitRequirement);
       assert.deepEqual(labels, ['verified']);
     });
     test('2 labels', () => {
-      const submitRequirement = createSubmitRequirementExpressionInfoWith(
+      const submitRequirement = createSubmitRequirementResultInfo(
         'label:Verified=MAX -label:Code-Review=MIN'
       );
       const labels = extractAssociatedLabels(submitRequirement);
@@ -266,13 +495,10 @@
     });
     test('overridden label', () => {
       const submitRequirement = {
-        ...createSubmitRequirementExpressionInfoWith(
-          'label:Verified=MAX -label:Verified=MIN'
+        ...createSubmitRequirementResultInfo(),
+        override_expression_result: createSubmitRequirementExpressionInfo(
+          'label:Build-cop-override'
         ),
-        override_expression_result: {
-          ...createSubmitRequirementExpressionInfo(),
-          expression: 'label:Build-cop-override',
-        },
       };
       const labels = extractAssociatedLabels(submitRequirement);
       assert.deepEqual(labels, ['Verified', 'Build-cop-override']);
@@ -296,7 +522,7 @@
       const change = createChangeInfoWith([requirement]);
       assert.deepEqual(getRequirements(change), [requirement]);
     });
-    test('legacy and non-legacy - filter legacy', () => {
+    test('legacy and non-legacy - show all', () => {
       const requirement = {
         ...createSubmitRequirementResultInfo(),
         is_legacy: true,
@@ -306,17 +532,11 @@
         is_legacy: false,
       };
       const change = createChangeInfoWith([requirement, requirement2]);
-      assert.deepEqual(getRequirements(change), [requirement2]);
+      assert.deepEqual(getRequirements(change), [requirement, requirement2]);
     });
     test('filter not applicable', () => {
-      const requirement = {
-        ...createSubmitRequirementResultInfo(),
-        is_legacy: true,
-      };
-      const requirement2 = {
-        ...createSubmitRequirementResultInfo(),
-        status: SubmitRequirementStatus.NOT_APPLICABLE,
-      };
+      const requirement = createSubmitRequirementResultInfo();
+      const requirement2 = createNonApplicableSubmitRequirementResultInfo();
       const change = createChangeInfoWith([requirement, requirement2]);
       assert.deepEqual(getRequirements(change), [requirement]);
     });
@@ -344,6 +564,7 @@
               ...createSubmitRequirementExpressionInfo(),
               expression: `label:${triggerVote}=MAX`,
             },
+            is_legacy: false,
           },
         ],
         labels: {
@@ -352,5 +573,306 @@
       };
       assert.deepEqual(getTriggerVotes(change), []);
     });
+
+    test('labels in not-applicable requirement are not trigger vote', () => {
+      const triggerVote = 'Trigger-Vote';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${triggerVote}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [triggerVote]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getTriggerVotes(change), []);
+    });
+  });
+
+  suite('getApplicableLabels()', () => {
+    test('1 not applicable', () => {
+      const notApplicableLabel = 'Not-Applicable-Label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${notApplicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [notApplicableLabel]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), []);
+    });
+    test('1 applicable, 1 not applicable', () => {
+      const applicableLabel = 'Applicable-Label';
+      const notApplicableLabel = 'Not-Applicable-Label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${notApplicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.UNSATISFIED,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${applicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [notApplicableLabel]: createDetailedLabelInfo(),
+          [applicableLabel]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), [applicableLabel]);
+    });
+
+    test('same label in applicable and not applicable requirement', () => {
+      const label = 'label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${label}=MAX`,
+            },
+            is_legacy: false,
+          },
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.UNSATISFIED,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${label}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [label]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), [label]);
+    });
+  });
+
+  suite('getApplicableLabels()', () => {
+    test('1 not applicable', () => {
+      const notApplicableLabel = 'Not-Applicable-Label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${notApplicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [notApplicableLabel]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), []);
+    });
+    test('1 applicable, 1 not applicable', () => {
+      const applicableLabel = 'Applicable-Label';
+      const notApplicableLabel = 'Not-Applicable-Label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${notApplicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.UNSATISFIED,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${applicableLabel}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [notApplicableLabel]: createDetailedLabelInfo(),
+          [applicableLabel]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), [applicableLabel]);
+    });
+
+    test('same label in applicable and not applicable requirement', () => {
+      const label = 'label';
+      const change = {
+        ...createChange(),
+        submit_requirements: [
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.NOT_APPLICABLE,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${label}=MAX`,
+            },
+            is_legacy: false,
+          },
+          {
+            ...createSubmitRequirementResultInfo(),
+            status: SubmitRequirementStatus.UNSATISFIED,
+            submittability_expression_result: {
+              ...createSubmitRequirementExpressionInfo(),
+              expression: `label:${label}=MAX`,
+            },
+            is_legacy: false,
+          },
+        ],
+        labels: {
+          [label]: createDetailedLabelInfo(),
+        },
+      };
+      assert.deepEqual(getApplicableLabels(change), [label]);
+    });
+  });
+
+  suite('isBlockingCondition', () => {
+    test('true', () => {
+      const requirement: SubmitRequirementResultInfo = {
+        name: 'Code-Review',
+        description:
+          "At least one maximum vote for label 'Code-Review' is required",
+        status: SubmitRequirementStatus.UNSATISFIED,
+        is_legacy: false,
+        submittability_expression_result: {
+          expression:
+            'label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN',
+          fulfilled: false,
+          status: SubmitRequirementExpressionInfoStatus.FAIL,
+          passing_atoms: ['label:Code-Review=MIN'],
+          failing_atoms: ['label:Code-Review=MAX,user=non_uploader'],
+        },
+      };
+      assert.isTrue(isBlockingCondition(requirement));
+    });
+
+    test('false', () => {
+      const requirement: SubmitRequirementResultInfo = {
+        name: 'Code-Review',
+        description:
+          "At least one maximum vote for label 'Code-Review' is required",
+        status: SubmitRequirementStatus.UNSATISFIED,
+        is_legacy: false,
+        submittability_expression_result: {
+          expression:
+            'label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN',
+          fulfilled: false,
+          status: SubmitRequirementExpressionInfoStatus.FAIL,
+          passing_atoms: [],
+          failing_atoms: [
+            'label:Code-Review=MAX,user=non_uploader',
+            'label:Code-Review=MIN',
+          ],
+        },
+      };
+      assert.isFalse(isBlockingCondition(requirement));
+    });
+  });
+
+  suite('valueString', () => {
+    const approvalInfo = createApproval();
+    test('0', () => {
+      approvalInfo.value = 0;
+      assert.equal(valueString(approvalInfo.value), ' 0');
+    });
+    test('-1', () => {
+      approvalInfo.value = -1;
+      assert.equal(valueString(approvalInfo.value), '-1');
+    });
+    test('2', () => {
+      approvalInfo.value = 2;
+      assert.equal(valueString(approvalInfo.value), '+2');
+    });
+  });
+
+  suite('hasVotes', () => {
+    const detailedLabelInfo = createDetailedLabelInfo();
+    const quickLabelInfo = createQuickLabelInfo();
+    test('detailedLabelInfo - neutral vote => false', () => {
+      const neutralApproval = createApproval();
+      neutralApproval.value = 0;
+      detailedLabelInfo.all = [neutralApproval];
+      assert.isFalse(hasVotes(detailedLabelInfo));
+    });
+    test('detailedLabelInfo - positive vote => true', () => {
+      const positiveApproval = createApproval();
+      positiveApproval.value = 2;
+      detailedLabelInfo.all = [positiveApproval];
+      assert.isTrue(hasVotes(detailedLabelInfo));
+    });
+    test('quickLabelInfo - neutral => false', () => {
+      assert.isFalse(hasVotes(quickLabelInfo));
+    });
+    test('quickLabelInfo - negative => false', () => {
+      quickLabelInfo.rejected = createAccountWithId();
+      assert.isTrue(hasVotes(quickLabelInfo));
+    });
+  });
+
+  suite('hasVoted', () => {
+    const detailedLabelInfo = createDetailedLabelInfo();
+    const quickLabelInfo = createQuickLabelInfo();
+    const account = createAccountWithId(23);
+    test('detailedLabelInfo - positive vote => true', () => {
+      const positiveApproval = createApproval(account);
+      positiveApproval.value = 2;
+      detailedLabelInfo.all = [positiveApproval];
+      assert.isTrue(hasVoted(detailedLabelInfo, account));
+    });
+    test('detailedLabelInfo - different account vote => true', () => {
+      const differentPositiveApproval = createApproval();
+      differentPositiveApproval.value = 2;
+      detailedLabelInfo.all = [differentPositiveApproval];
+      assert.isFalse(hasVoted(detailedLabelInfo, account));
+    });
+    test('quickLabelInfo - negative => false', () => {
+      quickLabelInfo.rejected = account;
+      assert.isTrue(hasVoted(quickLabelInfo, account));
+    });
   });
 });
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
new file mode 100644
index 0000000..ec1e7e7
--- /dev/null
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -0,0 +1,209 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import 'ba-linkify/ba-linkify';
+import {CommentLinkInfo, CommentLinks} from '../types/common';
+import {getBaseUrl} from './url-util';
+
+/**
+ * Finds links within the base string and convert them to HTML. Config-based
+ * rewrites are only applied on text that is not linked by the default linking
+ * library.
+ */
+export function linkifyUrlsAndApplyRewrite(
+  base: string,
+  repoCommentLinks: CommentLinks
+): string {
+  const parts: string[] = [];
+  window.linkify(insertZeroWidthSpace(base), {
+    callback: (text, href) => {
+      if (href) {
+        parts.push(removeZeroWidthSpace(createLinkTemplate(href, text)));
+      } else {
+        const rewriteResults = getRewriteResultsFromConfig(
+          text,
+          repoCommentLinks
+        );
+        parts.push(removeZeroWidthSpace(applyRewrites(text, rewriteResults)));
+      }
+    },
+  });
+  return parts.join('');
+}
+
+/**
+ * Generates a list of rewrites that would be applied to a base string. They are
+ * not applied immediately to the base text because one rewrite may interfere or
+ * overlap with a later rewrite. Only after all rewrites are known they are
+ * carefully merged with `applyRewrites`.
+ */
+function getRewriteResultsFromConfig(
+  base: string,
+  repoCommentLinks: CommentLinks
+): RewriteResult[] {
+  const enabledRewrites = Object.values(repoCommentLinks).filter(
+    commentLinkInfo =>
+      commentLinkInfo.enabled !== false && commentLinkInfo.link !== undefined
+  );
+  return enabledRewrites.flatMap(rewrite => {
+    const regexp = new RegExp(rewrite.match, 'g');
+    const partialResults: RewriteResult[] = [];
+    let match: RegExpExecArray | null;
+
+    while ((match = regexp.exec(base)) !== null) {
+      const fullReplacementText = getReplacementText(match[0], rewrite);
+      // The replacement may not be changing the entire matched substring so we
+      // "trim" the replacement position and text to the part that is actually
+      // different. This makes sure that unchanged portions are still eligible
+      // for other rewrites without being rejected as overlaps during
+      // `applyRewrites`. The new `replacementText` is not eligible for other
+      // rewrites since it would introduce unexpected interactions between
+      // rewrites depending on their order of definition/execution.
+      const sharedPrefixLength = getSharedPrefixLength(
+        match[0],
+        fullReplacementText
+      );
+      const sharedSuffixLength = getSharedSuffixLength(
+        match[0],
+        fullReplacementText
+      );
+      const prefixIndex = sharedPrefixLength;
+      const matchSuffixIndex = match[0].length - sharedSuffixLength;
+      const fullReplacementSuffixIndex =
+        fullReplacementText.length - sharedSuffixLength;
+      partialResults.push({
+        replacedTextStartPosition: match.index + prefixIndex,
+        replacedTextEndPosition: match.index + matchSuffixIndex,
+        replacementText: fullReplacementText.substring(
+          prefixIndex,
+          fullReplacementSuffixIndex
+        ),
+      });
+    }
+    return partialResults;
+  });
+}
+
+/**
+ * Applies all the rewrites to the given base string. To resolve cases where
+ * multiple rewrites target overlapping pieces of the base string, the rewrite
+ * that ends latest is kept and the rest are not applied and discarded.
+ */
+function applyRewrites(base: string, rewriteResults: RewriteResult[]): string {
+  const rewritesByEndPosition = [...rewriteResults].sort((a, b) => {
+    if (b.replacedTextEndPosition !== a.replacedTextEndPosition) {
+      return b.replacedTextEndPosition - a.replacedTextEndPosition;
+    }
+    return a.replacedTextStartPosition - b.replacedTextStartPosition;
+  });
+  const filteredSortedRewrites: RewriteResult[] = [];
+  let latestReplace = base.length;
+  for (const rewrite of rewritesByEndPosition) {
+    // Only accept rewrites that do not overlap with any previously accepted
+    // rewrites.
+    if (rewrite.replacedTextEndPosition <= latestReplace) {
+      filteredSortedRewrites.push(rewrite);
+      latestReplace = rewrite.replacedTextStartPosition;
+    }
+  }
+  return filteredSortedRewrites.reduce(
+    (text, rewrite) =>
+      text
+        .substring(0, rewrite.replacedTextStartPosition)
+        .concat(rewrite.replacementText)
+        .concat(text.substring(rewrite.replacedTextEndPosition)),
+    base
+  );
+}
+
+/**
+ * For a given regexp match, apply the rewrite based on the rewrite's type and
+ * return the resulting string.
+ */
+function getReplacementText(
+  matchedText: string,
+  rewrite: CommentLinkInfo
+): string {
+  const replacementHref = rewrite.link.startsWith('/')
+    ? `${getBaseUrl()}${rewrite.link}`
+    : rewrite.link;
+  const regexp = new RegExp(rewrite.match, 'g');
+  return matchedText.replace(
+    regexp,
+    createLinkTemplate(
+      replacementHref,
+      rewrite.text ?? '$&',
+      rewrite.prefix,
+      rewrite.suffix
+    )
+  );
+}
+
+/**
+ * Some tools are known to look for reviewers/CCs by finding lines such as
+ * "R=foo@gmail.com, bar@gmail.com". However, "=" is technically a valid email
+ * character, so ba-linkify interprets the entire string "R=foo@gmail.com" as an
+ * email address. To fix this, we insert a zero width space character \u200B
+ * before linking that prevents ba-linkify from associating the prefix with the
+ * email. After linking we remove the zero width space.
+ */
+function insertZeroWidthSpace(base: string) {
+  return base.replace(/^(R=|CC=)/g, '$&\u200B');
+}
+
+function removeZeroWidthSpace(base: string) {
+  return base.replace(/\u200B/g, '');
+}
+
+function createLinkTemplate(
+  href: string,
+  displayText: string,
+  prefix?: string,
+  suffix?: string
+) {
+  return `${
+    prefix ?? ''
+  }<a href="${href}" rel="noopener" target="_blank">${displayText}</a>${
+    suffix ?? ''
+  }`;
+}
+
+/**
+ * Returns the number of characters that are identical at the start of both
+ * strings.
+ *
+ * For example, `getSharedPrefixLength('12345678', '1234zz78')` would return 4
+ */
+function getSharedPrefixLength(a: string, b: string) {
+  let i = 0;
+  for (; i < a.length && i < b.length; ++i) {
+    if (a[i] !== b[i]) {
+      return i;
+    }
+  }
+  return i;
+}
+
+/**
+ * Returns the number of characters that are identical at the end of both
+ * strings.
+ *
+ * For example, `getSharedSuffixLength('12345678', '1234zz78')` would return 2
+ */
+function getSharedSuffixLength(a: string, b: string) {
+  let i = a.length;
+  for (let j = b.length; i !== 0 && j !== 0; --i, --j) {
+    if (a[i] !== b[j]) {
+      return a.length - 1 - i;
+    }
+  }
+  return a.length - i;
+}
+
+interface RewriteResult {
+  replacedTextStartPosition: number;
+  replacedTextEndPosition: number;
+  replacementText: string;
+}
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
new file mode 100644
index 0000000..f5c13e8
--- /dev/null
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -0,0 +1,209 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {linkifyUrlsAndApplyRewrite} from './link-util';
+import {assert} from '@open-wc/testing';
+
+suite('link-util tests', () => {
+  function link(text: string, href: string) {
+    return `<a href="${href}" rel="noopener" target="_blank">${text}</a>`;
+  }
+
+  suite('link rewrites', () => {
+    test('without text', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('foo', {
+          fooLinkWithoutText: {
+            match: 'foo',
+            link: 'foo.gov',
+          },
+        }),
+        link('foo', 'foo.gov')
+      );
+    });
+
+    test('with text', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('foo', {
+          fooLinkWithText: {
+            match: 'foo',
+            link: 'foo.gov',
+            text: 'foo site',
+          },
+        }),
+        link('foo site', 'foo.gov')
+      );
+    });
+
+    test('with prefix and suffix', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('there are 12 foos here', {
+          fooLinkWithText: {
+            match: '(.*)(bug|foo)s(.*)',
+            link: '$2.gov',
+            text: '$2 list',
+            prefix: '$1on the ',
+            suffix: '$3',
+          },
+        }),
+        `there are 12 on the ${link('foo list', 'foo.gov')} here`
+      );
+    });
+
+    test('multiple matches', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('foo foo', {
+          foo: {
+            match: 'foo',
+            link: 'foo.gov',
+          },
+        }),
+        `${link('foo', 'foo.gov')} ${link('foo', 'foo.gov')}`
+      );
+    });
+
+    test('does not apply within normal links', () => {
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('google.com', {
+          ogle: {
+            match: 'ogle',
+            link: 'gerritcodereview.com',
+          },
+        }),
+        link('google.com', 'http://google.com')
+      );
+    });
+  });
+
+  test('for overlapping rewrites prefer the latest ending', () => {
+    assert.equal(
+      linkifyUrlsAndApplyRewrite('foobarbaz', {
+        foo: {
+          match: 'foo',
+          link: 'foo.gov',
+        },
+        foobarbaz: {
+          match: 'foobarbaz',
+          link: 'foobarbaz.gov',
+        },
+        foobar: {
+          match: 'foobar',
+          link: 'foobar.gov',
+        },
+      }),
+      link('foobarbaz', 'foobarbaz.gov')
+    );
+  });
+
+  test('overlapping rewrites with same ending prefers earliest start', () => {
+    assert.equal(
+      linkifyUrlsAndApplyRewrite('foobarbaz', {
+        foo: {
+          match: 'baz',
+          link: 'Baz.gov',
+        },
+        foobarbaz: {
+          match: 'foobarbaz',
+          link: 'FooBarBaz.gov',
+        },
+        foobar: {
+          match: 'barbaz',
+          link: 'BarBaz.gov',
+        },
+      }),
+      link('foobarbaz', 'FooBarBaz.gov')
+    );
+  });
+
+  test('removed overlapping rewrites do not prevent other rewrites', () => {
+    assert.equal(
+      linkifyUrlsAndApplyRewrite('foobarbaz', {
+        foo: {
+          match: 'foo',
+          link: 'FOO',
+        },
+        oobarba: {
+          match: 'oobarba',
+          link: 'OOBARBA',
+        },
+        baz: {
+          match: 'baz',
+          link: 'BAZ',
+        },
+      }),
+      `${link('foo', 'FOO')}bar${link('baz', 'BAZ')}`
+    );
+  });
+
+  test('rewrites do not interfere with each other matching', () => {
+    assert.equal(
+      linkifyUrlsAndApplyRewrite('bugs: 123 234 345', {
+        bug1: {
+          match: '(bugs:) (\\d+)',
+          prefix: '$1 ',
+          link: 'bug/$2',
+          text: 'bug/$2',
+        },
+        bug2: {
+          match: '(bugs:) (\\d+) (\\d+)',
+          prefix: '$1 $2 ',
+          link: 'bug/$3',
+          text: 'bug/$3',
+        },
+        bug3: {
+          match: '(bugs:) (\\d+) (\\d+) (\\d+)',
+          prefix: '$1 $2 $3 ',
+          link: 'bug/$4',
+          text: 'bug/$4',
+        },
+      }),
+      `bugs: ${link('bug/123', 'bug/123')} ${link('bug/234', 'bug/234')} ${link(
+        'bug/345',
+        'bug/345'
+      )}`
+    );
+  });
+
+  suite('normal links', () => {
+    test('links urls', () => {
+      const googleLink = link('google.com', 'http://google.com');
+      const mapsLink = link('maps.google.com', 'http://maps.google.com');
+
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('google.com, maps.google.com', {}),
+        `${googleLink}, ${mapsLink}`
+      );
+    });
+
+    test('links emails without including R= prefix', () => {
+      const fooEmail = link('foo@gmail.com', 'mailto:foo@gmail.com');
+      const barEmail = link('bar@gmail.com', 'mailto:bar@gmail.com');
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('R=foo@gmail.com, bar@gmail.com', {}),
+        `R=${fooEmail}, ${barEmail}`
+      );
+    });
+
+    test('links emails without including CC= prefix', () => {
+      const fooEmail = link('foo@gmail.com', 'mailto:foo@gmail.com');
+      const barEmail = link('bar@gmail.com', 'mailto:bar@gmail.com');
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('CC=foo@gmail.com, bar@gmail.com', {}),
+        `CC=${fooEmail}, ${barEmail}`
+      );
+    });
+
+    test('links emails maintains R= and CC= within addresses', () => {
+      const fooBarBazEmail = link(
+        'fooR=barCC=baz@gmail.com',
+        'mailto:fooR=barCC=baz@gmail.com'
+      );
+      assert.equal(
+        linkifyUrlsAndApplyRewrite('fooR=barCC=baz@gmail.com', {}),
+        fooBarBazEmail
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/lit-util.ts b/polygerrit-ui/app/utils/lit-util.ts
new file mode 100644
index 0000000..7ffab89
--- /dev/null
+++ b/polygerrit-ui/app/utils/lit-util.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html} from 'lit';
+
+/**
+ * This is a patched version of html`` to work around this Chrome bug:
+ * https://bugs.chromium.org/p/v8/issues/detail?id=13190.
+ *
+ * The problem is that Chrome should guarantee that the TemplateStringsArray
+ * is always the same instance, if the strings themselves are equal, but that
+ * guarantee seems to be broken. So we are maintaining a map from
+ * "concatenated strings" to TemplateStringsArray. If "concatenated strings"
+ * are equal, then return the already known instance of TemplateStringsArray,
+ * so html`` can use its strict equality check on it.
+ */
+export class HtmlPatched {
+  constructor(private readonly reporter?: (key: string) => void) {}
+
+  /**
+   * If `strings` are in this set, then we are sure that they are also in the
+   * map, and that we will not run into the issue of "same key, but different
+   * strings array". So this set allows us to optimize performance a bit, and
+   * call the native html`` function early.
+   */
+  private readonly lookupSet = new Set<TemplateStringsArray>();
+
+  private readonly lookupMap = new Map<string, TemplateStringsArray>();
+
+  /**
+   * Proxies lit's html`` tagges template literal. See
+   * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
+   * https://lit.dev/docs/libraries/standalone-templates/
+   *
+   * Example: If you call html`a${1}b${2}c`, then
+   * ['a', 'b', 'c'] are the "strings", and 1, 2 are the "values".
+   */
+  html(strings: TemplateStringsArray, ...values: unknown[]) {
+    if (this.lookupSet.has(strings)) {
+      return this.nativeHtml(strings, ...values);
+    }
+
+    const key = strings.join('\0');
+    const oldStrings = this.lookupMap.get(key);
+
+    if (oldStrings === undefined) {
+      this.lookupSet.add(strings);
+      this.lookupMap.set(key, strings);
+      return this.nativeHtml(strings, ...values);
+    }
+
+    if (oldStrings === strings) {
+      return this.nativeHtml(strings, ...values);
+    }
+
+    // Without using HtmlPatcher html`` would be called with `strings`,
+    // which will be considered different, although actually being equal.
+    console.warn(`HtmlPatcher was required for '${key.substring(0, 100)}'.`);
+    this.reporter?.(key);
+    return this.nativeHtml(oldStrings, ...values);
+  }
+
+  // Allows spying on calls in tests.
+  nativeHtml(strings: TemplateStringsArray, ...values: unknown[]) {
+    return html(strings, ...values);
+  }
+}
diff --git a/polygerrit-ui/app/utils/lit-util_test.ts b/polygerrit-ui/app/utils/lit-util_test.ts
new file mode 100644
index 0000000..17197f0
--- /dev/null
+++ b/polygerrit-ui/app/utils/lit-util_test.ts
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {HtmlPatched} from './lit-util';
+
+function tsa(strings: string[]): TemplateStringsArray {
+  return strings as unknown as TemplateStringsArray;
+}
+
+suite('lit-util HtmlPatched tests', () => {
+  let patched: HtmlPatched;
+  let nativeHtmlSpy: sinon.SinonSpy;
+  let reporterSpy: sinon.SinonSpy;
+
+  setup(async () => {
+    reporterSpy = sinon.spy();
+    patched = new HtmlPatched(reporterSpy);
+    nativeHtmlSpy = sinon.spy(patched, 'nativeHtml');
+  });
+
+  test('simple call', () => {
+    const instance1 = tsa(['1']);
+    patched.html(instance1, 'a value');
+    assert.equal(nativeHtmlSpy.callCount, 1);
+    assert.equal(reporterSpy.callCount, 0);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].args[0], instance1);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].args[1], 'a value');
+  });
+
+  test('two calls, same instance', () => {
+    const instance1 = tsa(['1']);
+    patched.html(instance1, 'a value');
+    patched.html(instance1, 'a value');
+    assert.equal(nativeHtmlSpy.callCount, 2);
+    assert.equal(reporterSpy.callCount, 0);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1);
+  });
+
+  test('two calls, different strings', () => {
+    const instance1 = tsa(['1']);
+    const instance2 = tsa(['2']);
+    patched.html(instance1, 'a value');
+    patched.html(instance2, 'a value');
+    assert.equal(nativeHtmlSpy.callCount, 2);
+    assert.equal(reporterSpy.callCount, 0);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance2);
+  });
+
+  test('two calls, same strings, different instances', () => {
+    const instance1 = tsa(['1']);
+    const instance2 = tsa(['1']);
+    patched.html(instance1, 'a value');
+    patched.html(instance2, 'a value');
+    assert.equal(nativeHtmlSpy.callCount, 2);
+    assert.equal(reporterSpy.callCount, 1);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1);
+  });
+
+  test('many calls', () => {
+    const instance1a = tsa(['1']);
+    const instance1b = tsa(['1']);
+    const instance1c = tsa(['1']);
+    const instance2a = tsa(['asdf', 'qwer']);
+    const instance2b = tsa(['asdf', 'qwer']);
+    const instance2c = tsa(['asdf', 'qwer']);
+    const instance3a = tsa(['asd', 'fqwer']);
+    const instance3b = tsa(['asd', 'fqwer']);
+    const instance3c = tsa(['asd', 'fqwer']);
+
+    patched.html(instance1a, 'a value');
+    patched.html(instance1a, 'a value');
+    patched.html(instance1b, 'a value');
+    patched.html(instance1b, 'a value');
+    patched.html(instance1c, 'a value');
+    patched.html(instance1c, 'a value');
+    patched.html(instance2a, 'a value');
+    patched.html(instance2a, 'a value');
+    patched.html(instance2b, 'a value');
+    patched.html(instance2b, 'a value');
+    patched.html(instance2c, 'a value');
+    patched.html(instance2c, 'a value');
+    patched.html(instance3a, 'a value');
+    patched.html(instance3a, 'a value');
+    patched.html(instance3b, 'a value');
+    patched.html(instance3b, 'a value');
+    patched.html(instance3c, 'a value');
+    patched.html(instance3c, 'a value');
+
+    assert.equal(nativeHtmlSpy.callCount, 18);
+    assert.equal(reporterSpy.callCount, 12);
+
+    assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[2].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[3].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[4].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[5].firstArg, instance1a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[6].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[7].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[8].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[9].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[10].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[11].firstArg, instance2a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[12].firstArg, instance3a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[13].firstArg, instance3a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[14].firstArg, instance3a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[15].firstArg, instance3a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[16].firstArg, instance3a);
+    assert.strictEqual(nativeHtmlSpy.getCalls()[17].firstArg, instance3a);
+  });
+});
diff --git a/polygerrit-ui/app/utils/message-util.ts b/polygerrit-ui/app/utils/message-util.ts
index 70dd286..5acdf33 100644
--- a/polygerrit-ui/app/utils/message-util.ts
+++ b/polygerrit-ui/app/utils/message-util.ts
@@ -1,20 +1,8 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {MessageTag} from '../constants/constants';
 import {ChangeId, ChangeMessageInfo} from '../types/common';
 
diff --git a/polygerrit-ui/app/utils/observable-util.ts b/polygerrit-ui/app/utils/observable-util.ts
new file mode 100644
index 0000000..7687fd2
--- /dev/null
+++ b/polygerrit-ui/app/utils/observable-util.ts
@@ -0,0 +1,16 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Observable} from 'rxjs';
+import {distinctUntilChanged, map, shareReplay} from 'rxjs/operators';
+import {deepEqual} from './deep-util';
+
+export function select<A, B>(obs$: Observable<A>, mapper: (_: A) => B) {
+  return obs$.pipe(
+    map(mapper),
+    distinctUntilChanged(deepEqual),
+    shareReplay(1)
+  );
+}
diff --git a/polygerrit-ui/app/utils/page-wrapper-utils.ts b/polygerrit-ui/app/utils/page-wrapper-utils.ts
index 19cfe48..78e78ed 100644
--- a/polygerrit-ui/app/utils/page-wrapper-utils.ts
+++ b/polygerrit-ui/app/utils/page-wrapper-utils.ts
@@ -1,30 +1,20 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import 'page/page';
+// @ts-ignore: Bazel is not yet configured to download the types
+import pagejs from 'page';
 
-// Reexport page.js. To make it work, karma, server.go and rollup patch
-// page.js and replace "this" to "window". Otherwise, it can't assign global
-// property. We can't import page.mjs because typescript doesn't support mjs
-// extensions
+// Reexport page.js. To make it work rollup patches page.js and replace "this"
+// to "window". Otherwise, it can't assign global property. We can't import
+// page.mjs because typescript doesn't support mjs extensions
 export interface Page {
   (pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
   (pageCallback: PageCallback): void;
   show(url: string): void;
   redirect(url: string): void;
+  replace(path: string, state: null, init: boolean, dispatch: boolean): void;
   base(url: string): void;
   start(): void;
   exit(pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
@@ -32,14 +22,10 @@
 
 // See https://visionmedia.github.io/page.js/ for details
 export interface PageContext {
-  save(): void;
-  handled: boolean;
   canonicalPath: string;
   path: string;
   querystring: string;
   pathname: string;
-  state: unknown;
-  title: string;
   hash: string;
   params: {[paramIndex: string]: string};
 }
@@ -51,4 +37,6 @@
   next: PageNextCallback
 ) => void;
 
-export const page = window['page'] as Page;
+// TODO: Convert page usages to the real types and remove this file of wrapper
+// types. Also remove workarounds in rollup config.
+export const page = pagejs as unknown as Page;
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 921850a..355e54b 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -2,30 +2,19 @@
   RevisionInfo,
   ChangeInfo,
   PatchSetNum,
-  EditPatchSetNum,
-  ParentPatchSetNum,
+  EDIT,
+  PARENT,
   PatchSetNumber,
   BasePatchSetNum,
   RevisionPatchSetNum,
 } from '../types/common';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
-import {check} from './common-util';
+import {assert} from './common-util';
 
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // Tags identifying ChangeMessages that move change into WIP state.
@@ -40,14 +29,14 @@
 export const CURRENT = 'current';
 
 export interface PatchSet {
-  num: PatchSetNum;
+  num: RevisionPatchSetNum;
   desc: string | undefined;
   sha: string;
   wip?: boolean;
 }
 
 interface PatchRange {
-  patchNum?: PatchSetNum;
+  patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
 }
 
@@ -64,12 +53,12 @@
  * parent.
  */
 export function isAParent(n: PatchSetNum) {
-  return n === ParentPatchSetNum || isMergeParent(n);
+  return n === PARENT || isMergeParent(n);
 }
 
 export function isPatchSetNum(patchset: string) {
   if (!isNaN(Number(patchset))) return true;
-  return patchset === EditPatchSetNum || patchset === ParentPatchSetNum;
+  return patchset === EDIT || patchset === PARENT;
 }
 
 export function convertToPatchSetNum(
@@ -118,20 +107,26 @@
 }
 
 /**
+ * Find change edit revision if change edit exists.
+ */
+export function findEdit(
+  revisions: Array<RevisionInfo | EditRevisionInfo>
+): EditRevisionInfo | undefined {
+  const editRev = revisions.find(info => info._number === EDIT);
+  return editRev as EditRevisionInfo | undefined;
+}
+
+/**
  * Find change edit base revision if change edit exists.
  *
  * @return change edit parent revision or null if change edit
  *     doesn't exist.
- *
  */
 export function findEditParentRevision(
   revisions: Array<RevisionInfo | EditRevisionInfo>
 ) {
-  const editInfo = revisions.find(info => info._number === EditPatchSetNum);
-
-  if (!editInfo) {
-    return null;
-  }
+  const editInfo = findEdit(revisions);
+  if (!editInfo) return null;
 
   return revisions.find(info => info._number === editInfo.basePatchNum) || null;
 }
@@ -146,8 +141,8 @@
   revisions: Array<RevisionInfo | EditRevisionInfo>
 ) {
   const revisionInfo = findEditParentRevision(revisions);
-  // finding parent of 'edit' patchset, hence revisionInfo._number cannot be
-  // 'edit' and must be a number
+  // finding parent of EDIT patchset, hence revisionInfo._number cannot be
+  // EDIT and must be a number
   // TODO(TS): find a way to avoid 'as'
   return revisionInfo ? (revisionInfo._number as number) : -1;
 }
@@ -156,7 +151,7 @@
  * Sort given revisions array according to the patch set number, in
  * descending order.
  * The sort algorithm is change edit aware. Change edit has patch set number
- * equals 'edit', but must appear after the patch set it was based on.
+ * equals EDIT, but must appear after the patch set it was based on.
  * Example: change edit is based on patch set 2, and another patch set was
  * uploaded after change edit creation, the sorted order should be:
  * 3, edit, 2, 1.
@@ -171,9 +166,7 @@
   // Map an edit to the patchNum of parent*2... I.e. edit on 2 -> 4.
   // TODO(TS): find a way to avoid 'as'
   const num = (r: T) =>
-    r._number === EditPatchSetNum
-      ? 2 * editParent
-      : 2 * ((r._number as number) - 1) + 1;
+    r._number === EDIT ? 2 * editParent : 2 * ((r._number as number) - 1) + 1;
   return revisions.sort((a, b) => num(b) - num(a));
 }
 
@@ -213,7 +206,7 @@
       };
     });
   }
-  return _computeWipForPatchSets(change, patchNums);
+  return computeWipForPatchSets(change, patchNums);
 }
 
 /**
@@ -225,7 +218,7 @@
  * @return The given list of patch set objects, with the
  *     wip property set on each of them
  */
-function _computeWipForPatchSets(
+function computeWipForPatchSets(
   change: ChangeInfo | ParsedChangeInfo,
   patchNums: PatchSet[]
 ) {
@@ -256,7 +249,7 @@
   return patchNums;
 }
 
-export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
+export const _testOnly_computeWipForPatchSets = computeWipForPatchSets;
 
 export function computeLatestPatchNum(
   allPatchSets?: PatchSet[]
@@ -265,24 +258,20 @@
     return undefined;
   }
   let latest = allPatchSets[0].num;
-  if (latest === EditPatchSetNum) {
+  if (latest === EDIT) {
     latest = allPatchSets[1].num;
   }
-  check(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
+  assert(isNumber(latest), 'Latest patchset cannot be EDIT or PARENT.');
   return latest;
 }
 
 export function computePredecessor(
   patchset?: PatchSetNum
 ): BasePatchSetNum | undefined {
-  if (
-    !patchset ||
-    patchset === ParentPatchSetNum ||
-    patchset === EditPatchSetNum
-  ) {
+  if (!patchset || patchset === PARENT || patchset === EDIT) {
     return undefined;
   }
-  if (patchset === 1) return ParentPatchSetNum;
+  if (patchset === 1) return PARENT;
   return (Number(patchset) - 1) as BasePatchSetNum;
 }
 
@@ -292,14 +281,11 @@
   if (!allPatchSets || allPatchSets.length < 2) {
     return false;
   }
-  return allPatchSets[0].num === EditPatchSetNum;
+  return allPatchSets[0].num === EDIT;
 }
 
 export function hasEditPatchsetLoaded(patchRange: PatchRange) {
-  return (
-    patchRange.patchNum === EditPatchSetNum ||
-    patchRange.basePatchNum === EditPatchSetNum
-  );
+  return patchRange.patchNum === EDIT;
 }
 
 /**
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.ts b/polygerrit-ui/app/utils/patch-set-util_test.ts
index a9d9549..b67db9b 100644
--- a/polygerrit-ui/app/utils/patch-set-util_test.ts
+++ b/polygerrit-ui/app/utils/patch-set-util_test.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
 import {
   createChange,
   createChangeMessageInfo,
@@ -24,9 +13,11 @@
 import {
   BasePatchSetNum,
   ChangeInfo,
-  EditPatchSetNum,
+  EDIT,
   PatchSetNum,
+  PatchSetNumber,
   ReviewInputTag,
+  PARENT,
 } from '../types/common';
 import {
   _testOnly_computeWipForPatchSets,
@@ -65,10 +56,7 @@
     // to compare against an expected value for a particular patch set.
     const compute = (
       initialWip: boolean,
-      tagsByRevision: Map<
-        number | 'edit' | 'PARENT',
-        (ReviewInputTag | undefined)[]
-      >
+      tagsByRevision: Map<PatchSetNumber, (ReviewInputTag | undefined)[]>
     ) => {
       const change: ChangeInfo = {
         ...createChange(),
@@ -80,12 +68,12 @@
           change.messages!.push({
             ...createChangeMessageInfo(),
             tag,
-            _revision_number: rev as PatchSetNum,
+            _revision_number: rev,
           });
         }
       }
       const patchSets = Array.from(tagsByRevision.keys()).map(rev => {
-        return {num: rev as PatchSetNum, desc: 'test', sha: `rev${rev}`};
+        return {num: rev, desc: 'test', sha: `rev${rev}`};
       });
       const patchNums = _testOnly_computeWipForPatchSets(change, patchSets);
       const verifier = {
@@ -110,8 +98,14 @@
 
     const upload = 'upload' as ReviewInputTag;
 
-    compute(false, new Map([[1, [upload]]])).assertWip(1, false);
-    compute(true, new Map([[1, [upload]]])).assertWip(1, true);
+    compute(false, new Map([[1 as PatchSetNumber, [upload]]])).assertWip(
+      1,
+      false
+    );
+    compute(true, new Map([[1 as PatchSetNumber, [upload]]])).assertWip(
+      1,
+      true
+    );
 
     const setWip = 'autogenerated:gerrit:setWorkInProgress' as ReviewInputTag;
     const uploadInWip = 'autogenerated:gerrit:newWipPatchSet' as ReviewInputTag;
@@ -120,10 +114,10 @@
     compute(
       false,
       new Map([
-        [1, [upload, setWip]],
-        [2, [upload]],
-        [3, [upload, clearWip]],
-        [4, [upload, setWip]],
+        [1 as PatchSetNumber, [upload, setWip]],
+        [2 as PatchSetNumber, [upload]],
+        [3 as PatchSetNumber, [upload, clearWip]],
+        [4 as PatchSetNumber, [upload, setWip]],
       ])
     )
       .assertWip(1, false) // Change was created with PS1 ready for review
@@ -134,12 +128,15 @@
     compute(
       false,
       new Map([
-        [1, [uploadInWip, undefined, 'addReviewer' as ReviewInputTag]],
-        [2, [upload]],
-        [3, [upload, clearWip, setWip]],
-        [4, [upload]],
-        [5, [upload, clearWip]],
-        [6, [uploadInWip]],
+        [
+          1 as PatchSetNumber,
+          [uploadInWip, undefined, 'addReviewer' as ReviewInputTag],
+        ],
+        [2 as PatchSetNumber, [upload]],
+        [3 as PatchSetNumber, [upload, clearWip, setWip]],
+        [4 as PatchSetNumber, [upload]],
+        [5 as PatchSetNumber, [upload, clearWip]],
+        [6 as PatchSetNumber, [uploadInWip]],
       ])
     )
       .assertWip(1, true) // Change was created in WIP
@@ -153,8 +150,8 @@
   test('isMergeParent', () => {
     assert.isFalse(isMergeParent(1 as PatchSetNum));
     assert.isFalse(isMergeParent(4321 as PatchSetNum));
-    assert.isFalse(isMergeParent('edit' as PatchSetNum));
-    assert.isFalse(isMergeParent('PARENT' as PatchSetNum));
+    assert.isFalse(isMergeParent(EDIT as PatchSetNum));
+    assert.isFalse(isMergeParent(PARENT as PatchSetNum));
     assert.isFalse(isMergeParent(0 as PatchSetNum));
 
     assert.isTrue(isMergeParent(-23 as PatchSetNum));
@@ -166,8 +163,7 @@
     assert.strictEqual(findEditParentRevision(revisions), null);
 
     revisions.push({
-      ...createRevision(),
-      _number: EditPatchSetNum,
+      ...createRevision(EDIT),
       basePatchNum: 3 as BasePatchSetNum,
     });
     assert.strictEqual(findEditParentRevision(revisions), null);
@@ -182,8 +178,7 @@
 
     revisions.push(
       {
-        ...createRevision(),
-        _number: EditPatchSetNum,
+        ...createRevision(EDIT),
         basePatchNum: 3 as BasePatchSetNum,
       },
       createRevision(3)
@@ -199,13 +194,11 @@
 
     // Edit patchset should follow directly after its basePatchNum.
     revisions.push({
-      ...createRevision(),
-      _number: EditPatchSetNum,
+      ...createRevision(EDIT),
       basePatchNum: 2 as BasePatchSetNum,
     });
     sorted.unshift({
-      ...createRevision(),
-      _number: EditPatchSetNum,
+      ...createRevision(EDIT),
       basePatchNum: 2 as BasePatchSetNum,
     });
     assert.deepEqual(sortRevisions(revisions), sorted);
@@ -224,10 +217,10 @@
 
   test('computeAllPatchSets', () => {
     const expected = [
-      {num: 4 as PatchSetNum, desc: 'test', sha: 'rev4'},
-      {num: 3 as PatchSetNum, desc: 'test', sha: 'rev3'},
-      {num: 2 as PatchSetNum, desc: 'test', sha: 'rev2'},
-      {num: 1 as PatchSetNum, desc: 'test', sha: 'rev1'},
+      {num: 4 as PatchSetNumber, desc: 'test', sha: 'rev4'},
+      {num: 3 as PatchSetNumber, desc: 'test', sha: 'rev3'},
+      {num: 2 as PatchSetNumber, desc: 'test', sha: 'rev2'},
+      {num: 1 as PatchSetNumber, desc: 'test', sha: 'rev1'},
     ];
     const patchNums = computeAllPatchSets({
       ...createChange(),
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 411421e..b007d47 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -1,22 +1,10 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {SpecialFilePath, FileInfoStatus} from '../constants/constants';
-import {FileInfo} from '../types/common';
+import {FileInfo, FileNameToFileInfoMap} from '../types/common';
 import {hasOwnProperty} from './common-util';
 
 export function specialFilePathCompare(a: string, b: string) {
@@ -45,7 +33,7 @@
   const bFile = b.substr(0, bLastDotIndex) || b;
 
   // Sort header files above others with the same base name.
-  const headerExts = ['h', 'hxx', 'hpp'];
+  const headerExts = ['h', 'hh', 'hxx', 'hpp'];
   if (aFile.length > 0 && aFile === bFile) {
     if (headerExts.includes(aExt) && headerExts.includes(bExt)) {
       return a.localeCompare(b);
@@ -67,7 +55,7 @@
 // In case there are files with comments on them but they are unchanged, then
 // we explicitly displays the file to render the comments with Unchanged status
 export function addUnmodifiedFiles(
-  files: {[filename: string]: FileInfo},
+  files: FileNameToFileInfoMap,
   commentedPaths: {[fileName: string]: boolean}
 ) {
   if (!commentedPaths) return;
@@ -112,7 +100,7 @@
   );
 }
 
-export function computeTruncatedPath(path: string) {
+export function computeTruncatedPath(path?: string) {
   return truncatePath(computeDisplayPath(path));
 }
 
diff --git a/polygerrit-ui/app/utils/path-list-util_test.ts b/polygerrit-ui/app/utils/path-list-util_test.ts
index 79b5f09..3c9e0d3 100644
--- a/polygerrit-ui/app/utils/path-list-util_test.ts
+++ b/polygerrit-ui/app/utils/path-list-util_test.ts
@@ -1,21 +1,9 @@
 /**
  * @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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import '../test/common-test-setup';
 import {FileInfoStatus, SpecialFilePath} from '../constants/constants';
 import {
   addUnmodifiedFiles,
@@ -24,8 +12,9 @@
   specialFilePathCompare,
   truncatePath,
 } from './path-list-util';
-import {FileInfo} from '../api/rest-api';
 import {hasOwnProperty} from './common-util';
+import {assert} from '@open-wc/testing';
+import {FileNameToFileInfoMap} from '../types/common';
 
 suite('path-list-utl tests', () => {
   test('special sort', () => {
@@ -79,6 +68,12 @@
       ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']
     );
 
+    // Regression test for Issue 15635
+    assert.deepEqual(
+      ['manager.cc', 'manager.hh'].sort(specialFilePathCompare),
+      ['manager.hh', 'manager.cc']
+    );
+
     // Regression test for Issue 4448.
     assert.deepEqual(
       [
@@ -122,7 +117,7 @@
       'file1.txt': true,
     };
 
-    const files: {[filename: string]: FileInfo} = {
+    const files: FileNameToFileInfoMap = {
       'file2.txt': {
         status: FileInfoStatus.REWRITTEN,
         size_delta: 10,
diff --git a/polygerrit-ui/app/utils/safari-selection-util.ts b/polygerrit-ui/app/utils/safari-selection-util.ts
index e38111f..a24ac3e 100644
--- a/polygerrit-ui/app/utils/safari-selection-util.ts
+++ b/polygerrit-ui/app/utils/safari-selection-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {isSafari, findActiveElement} from './dom-util';
 
diff --git a/polygerrit-ui/app/utils/safe-types-util.ts b/polygerrit-ui/app/utils/safe-types-util.ts
index 18641de..e5c33bb 100644
--- a/polygerrit-ui/app/utils/safe-types-util.ts
+++ b/polygerrit-ui/app/utils/safe-types-util.ts
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|[^:/?#]*(?:[/?#]|$))/i;
diff --git a/polygerrit-ui/app/utils/safe-types-util_test.ts b/polygerrit-ui/app/utils/safe-types-util_test.ts
index 03253e0..dbeaa4f 100644
--- a/polygerrit-ui/app/utils/safe-types-util_test.ts
+++ b/polygerrit-ui/app/utils/safe-types-util_test.ts
@@ -1,21 +1,10 @@
 /**
  * @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.
+ * Copyright 2018 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../test/common-test-setup-karma';
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
 import {safeTypesBridge, _testOnly_SafeUrl} from './safe-types-util';
 
 suite('safe-types-util tests', () => {
diff --git a/polygerrit-ui/app/utils/service-worker-util.ts b/polygerrit-ui/app/utils/service-worker-util.ts
new file mode 100644
index 0000000..eb547ea
--- /dev/null
+++ b/polygerrit-ui/app/utils/service-worker-util.ts
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {AccountDetailInfo} from '../api/rest-api';
+import {ParsedChangeInfo} from '../types/types';
+import {parseDate} from './date-util';
+
+/**
+ * Filter changes that had change in attention set after last round
+ * of notifications. Filter out changes we already notified about.
+ */
+export function filterAttentionChangesAfter(
+  changes: ParsedChangeInfo[],
+  account: AccountDetailInfo,
+  latestUpdateTimestampMs: number
+) {
+  return changes.filter(change => {
+    const attention_set = change.attention_set![account._account_id!];
+    if (!attention_set.last_update) return false;
+    const lastUpdateTimestampMs = parseDate(
+      attention_set.last_update
+    ).valueOf();
+    return latestUpdateTimestampMs < lastUpdateTimestampMs;
+  });
+}
diff --git a/polygerrit-ui/app/utils/service-worker-util_test.ts b/polygerrit-ui/app/utils/service-worker-util_test.ts
new file mode 100644
index 0000000..6259288
--- /dev/null
+++ b/polygerrit-ui/app/utils/service-worker-util_test.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {Timestamp} from '../api/rest-api';
+import '../test/common-test-setup';
+import {
+  createAccountDetailWithId,
+  createParsedChange,
+} from '../test/test-data-generators';
+import {ParsedChangeInfo} from '../types/types';
+import {parseDate} from './date-util';
+import {filterAttentionChangesAfter} from './service-worker-util';
+
+suite('service worker util tests', () => {
+  test('filterAttentionChangesAfter', () => {
+    const account = createAccountDetailWithId();
+    const changeBefore: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: '2016-01-12 20:24:49.000000000' as Timestamp,
+        },
+      },
+    };
+    const changeAfter: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: '2016-01-12 20:24:51.000000000' as Timestamp,
+        },
+      },
+    };
+    const changes = [changeBefore, changeAfter];
+
+    const filteredChanges = filterAttentionChangesAfter(
+      changes,
+      account,
+      parseDate('2016-01-12 20:24:50.000000000' as Timestamp).valueOf()
+    );
+
+    assert.equal(filteredChanges.length, 1);
+    assert.equal(filteredChanges[0], changeAfter);
+  });
+});
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 0b217ec..81dcde1 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -1,20 +1,11 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
+import {computeDisplayPath} from './path-list-util';
+
 /**
  * Returns a count plus string that is pluralized when necessary.
  */
@@ -23,14 +14,18 @@
   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, '');
 }
 
+export function isCharacterLetter(ch: string): boolean {
+  return ch.length === 1 && ch.toLowerCase() !== ch.toUpperCase();
+}
+
+export function isUpperCase(ch: string): boolean {
+  return ch === ch.toUpperCase();
+}
+
 export function ordinal(n?: number): string {
   if (n === undefined) return '';
   if (n % 10 === 1 && n % 100 !== 11) return `${n}st`;
@@ -39,6 +34,15 @@
   return `${n}th`;
 }
 
+/** Escape operator value to avoid affecting overall query.
+ *
+ * Escapes quotes (") and backslashes (\). Wraps in quotes so the value can
+ * contain spaces and colons.
+ */
+export function escapeAndWrapSearchOperatorValue(value: string): string {
+  return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
+}
+
 /**
  * This converts any inputed value into string.
  *
@@ -47,3 +51,67 @@
 export function convertToString(key?: unknown) {
   return key !== undefined ? String(key) : '';
 }
+
+export function capitalizeFirstLetter(str: string) {
+  return str.charAt(0).toUpperCase() + str.slice(1);
+}
+
+/**
+ * Converts the items into a sentence-friendly format. Examples:
+ * listForSentence(["Foo", "Bar", "Baz"])
+ * => 'Foo, Bar, and Baz'
+ * listForSentence(["Foo", "Bar"])
+ * => 'Foo and Bar'
+ * listForSentence(["Foo"])
+ * => 'Foo'
+ */
+export function listForSentence(items: string[]): string {
+  if (items.length < 2) return items.join('');
+  if (items.length === 2) return items.join(' and ');
+
+  const firstItems = items.slice(0, items.length - 1);
+  const lastItem = items[items.length - 1];
+  return `${firstItems.join(', ')}, and ${lastItem}`;
+}
+
+/**
+ *  Separates a path into:
+ *  - The part that matches another path,
+ *  - The part that does not match the other path,
+ *  - The file name
+ *
+ *  For example:
+ *    diffFilePaths('same/part/new/part/foo.js', 'same/part/different/foo.js');
+ *  yields: {
+ *      matchingFolders: 'same/part/',
+ *      newFolders: 'new/part/',
+ *      fileName: 'foo.js',
+ *    }
+ */
+export function diffFilePaths(filePath: string, otherFilePath?: string) {
+  // Separate each string into an array of folder names + file name.
+  const displayPath = computeDisplayPath(filePath);
+  const previousFileDisplayPath = computeDisplayPath(otherFilePath);
+  const displayPathParts = displayPath.split('/');
+  const previousFileDisplayPathParts = previousFileDisplayPath.split('/');
+
+  // Construct separate strings for matching folders, new folders, and file
+  // name.
+  const firstDifferencePartIndex = displayPathParts.findIndex(
+    (part, index) => previousFileDisplayPathParts[index] !== part
+  );
+  const matchingSection = displayPathParts
+    .slice(0, firstDifferencePartIndex)
+    .join('/');
+  const newFolderSection = displayPathParts
+    .slice(firstDifferencePartIndex, -1)
+    .join('/');
+  const fileNameSection = displayPathParts[displayPathParts.length - 1];
+
+  // Note: folder sections need '/' appended back.
+  return {
+    matchingFolders: matchingSection.length > 0 ? `${matchingSection}/` : '',
+    newFolders: newFolderSection.length > 0 ? `${newFolderSection}/` : '',
+    fileName: fileNameSection,
+  };
+}
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
index 8de6ac2..d6c4187 100644
--- a/polygerrit-ui/app/utils/string-util_test.ts
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -1,24 +1,19 @@
 /**
  * @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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {
+  pluralize,
+  ordinal,
+  listForSentence,
+  diffFilePaths,
+  escapeAndWrapSearchOperatorValue,
+} from './string-util';
 
-import '../test/common-test-setup-karma';
-import {pluralize, ordinal} from './string-util';
-
-suite('formatter util tests', () => {
+suite('string-util tests', () => {
   test('pluralize', () => {
     const noun = 'comment';
     assert.equal(pluralize(0, noun), '');
@@ -39,4 +34,62 @@
     assert.equal(ordinal(44413), '44413th');
     assert.equal(ordinal(44451), '44451st');
   });
+
+  test('listForSentence', () => {
+    assert.equal(listForSentence(['Foo', 'Bar', 'Baz']), 'Foo, Bar, and Baz');
+    assert.equal(listForSentence(['Foo', 'Bar']), 'Foo and Bar');
+    assert.equal(listForSentence(['Foo']), 'Foo');
+    assert.equal(listForSentence([]), '');
+  });
+
+  test('diffFilePaths', () => {
+    const path = 'some/new/path/to/foo.js';
+
+    // no other path
+    assert.deepStrictEqual(diffFilePaths(path, undefined), {
+      matchingFolders: '',
+      newFolders: 'some/new/path/to/',
+      fileName: 'foo.js',
+    });
+    // no new folders
+    assert.deepStrictEqual(diffFilePaths(path, 'some/new/path/to/bar.js'), {
+      matchingFolders: 'some/new/path/to/',
+      newFolders: '',
+      fileName: 'foo.js',
+    });
+    // folder partially matches
+    assert.deepStrictEqual(diffFilePaths(path, 'some/ne/foo.js'), {
+      matchingFolders: 'some/',
+      newFolders: 'new/path/to/',
+      fileName: 'foo.js',
+    });
+    // no matching folders
+    assert.deepStrictEqual(
+      diffFilePaths(path, 'another/path/entirely/foo.js'),
+      {
+        matchingFolders: '',
+        newFolders: 'some/new/path/to/',
+        fileName: 'foo.js',
+      }
+    );
+    // some folders match
+    assert.deepStrictEqual(diffFilePaths(path, 'some/other/path/to/bar.js'), {
+      matchingFolders: 'some/',
+      newFolders: 'new/path/to/',
+      fileName: 'foo.js',
+    });
+    // no folders
+    assert.deepStrictEqual(diffFilePaths('COMMIT_MSG', 'some/other/foo.js'), {
+      matchingFolders: '',
+      newFolders: '',
+      fileName: 'COMMIT_MSG',
+    });
+  });
+
+  test('escapeAndWrapSearchOperatorValue', () => {
+    assert.equal(
+      escapeAndWrapSearchOperatorValue('"value of \\: \\"something"'),
+      '"\\"value of \\\\: \\\\\\"something\\""'
+    );
+  });
 });
diff --git a/polygerrit-ui/app/utils/submit-requirement-util.ts b/polygerrit-ui/app/utils/submit-requirement-util.ts
new file mode 100644
index 0000000..6672712
--- /dev/null
+++ b/polygerrit-ui/app/utils/submit-requirement-util.ts
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {SubmitRequirementExpressionInfo} from '../api/rest-api';
+import {Execution} from '../constants/reporting';
+import {getAppContext} from '../services/app-context';
+
+export enum SubmitRequirementExpressionAtomStatus {
+  UNKNOWN = 'UNKNOWN',
+  PASSING = 'PASSING',
+  FAILING = 'FAILING',
+}
+
+export interface SubmitRequirementExpressionPart {
+  value: string;
+  isAtom: boolean;
+  // Defined iff isAtom is true.
+  atomStatus?: SubmitRequirementExpressionAtomStatus;
+}
+
+interface AtomMatch {
+  start: number;
+  end: number;
+  isPassing: boolean;
+}
+
+function appendAllOccurrences(
+  text: string,
+  match: string,
+  isPassing: boolean,
+  matchedAtoms: AtomMatch[]
+) {
+  for (let searchStartIndex = 0; ; ) {
+    let index = text.indexOf(match, searchStartIndex);
+    if (index === -1) {
+      break;
+    }
+    searchStartIndex = index + match.length;
+    // Include unary minus.
+    if (index !== 0 && text[index - 1] === '-') {
+      --index;
+      isPassing = !isPassing;
+    }
+    matchedAtoms.push({start: index, end: searchStartIndex, isPassing});
+  }
+}
+
+function splitExpressionIntoParts(
+  expression: string,
+  matchedAtoms: AtomMatch[]
+): SubmitRequirementExpressionPart[] {
+  const result: SubmitRequirementExpressionPart[] = [];
+  let currentIndex = 0;
+  for (const {start, end, isPassing} of matchedAtoms) {
+    if (start < currentIndex) {
+      getAppContext().reportingService.reportExecution(
+        Execution.REACHABLE_CODE,
+        'Overlapping atom matches in submit requirement expression.'
+      );
+      continue;
+    }
+    if (start > currentIndex) {
+      result.push({
+        value: expression.slice(currentIndex, start),
+        isAtom: false,
+      });
+    }
+    result.push({
+      value: expression.slice(start, end),
+      isAtom: true,
+      atomStatus: isPassing
+        ? SubmitRequirementExpressionAtomStatus.PASSING
+        : SubmitRequirementExpressionAtomStatus.FAILING,
+    });
+    currentIndex = end;
+  }
+  if (currentIndex < expression.length) {
+    result.push({
+      value: expression.slice(currentIndex),
+      isAtom: false,
+    });
+  }
+  return result;
+}
+
+/**
+ * Returns expression string split into ExpressionPart.
+ *
+ * Concatenation result of all parts is equal to original expression string.
+ *
+ * Unary minus is included in the atom and is accounted in the status.
+ */
+export function atomizeExpression(
+  expression: SubmitRequirementExpressionInfo
+): SubmitRequirementExpressionPart[] {
+  const matchedAtoms: AtomMatch[] = [];
+  expression.passing_atoms?.forEach(atom =>
+    appendAllOccurrences(
+      expression.expression,
+      atom,
+      /* isPassing=*/ true,
+      matchedAtoms
+    )
+  );
+  expression.failing_atoms?.forEach(atom =>
+    appendAllOccurrences(
+      expression.expression,
+      atom,
+      /* isPassing=*/ false,
+      matchedAtoms
+    )
+  );
+  matchedAtoms.sort((a, b) => a.start - b.start);
+
+  return splitExpressionIntoParts(expression.expression, matchedAtoms);
+}
diff --git a/polygerrit-ui/app/utils/submit-requirement-util_test.ts b/polygerrit-ui/app/utils/submit-requirement-util_test.ts
new file mode 100644
index 0000000..a35a121
--- /dev/null
+++ b/polygerrit-ui/app/utils/submit-requirement-util_test.ts
@@ -0,0 +1,116 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {assert} from '@open-wc/testing';
+import {SubmitRequirementExpressionInfo} from '../api/rest-api';
+import '../test/common-test-setup';
+import {
+  atomizeExpression,
+  SubmitRequirementExpressionAtomStatus,
+} from './submit-requirement-util';
+
+suite('submit-requirement-util', () => {
+  test('atomizeExpression no evaluted atoms', () => {
+    const expression: SubmitRequirementExpressionInfo = {
+      expression:
+        'label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN',
+    };
+
+    assert.deepStrictEqual(atomizeExpression(expression), [
+      {
+        value:
+          'label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN',
+        isAtom: false,
+      },
+    ]);
+  });
+
+  test('atomizeExpression normal', () => {
+    const expression: SubmitRequirementExpressionInfo = {
+      expression: 'has:unresolved AND hashtag:allow-unresolved-comments',
+      passing_atoms: ['has:unresolved'],
+      failing_atoms: ['hashtag:allow-unresolved-comments'],
+    };
+
+    assert.deepStrictEqual(atomizeExpression(expression), [
+      {
+        value: 'has:unresolved',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.PASSING,
+      },
+      {
+        value: ' AND ',
+        isAtom: false,
+      },
+      {
+        value: 'hashtag:allow-unresolved-comments',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+      },
+    ]);
+  });
+
+  test('atomizeExpression unary negation', () => {
+    const expression: SubmitRequirementExpressionInfo = {
+      expression: '-has:unresolved AND hashtag:allow-unresolved-comments',
+      passing_atoms: ['has:unresolved'],
+      failing_atoms: ['hashtag:allow-unresolved-comments'],
+    };
+
+    assert.deepStrictEqual(atomizeExpression(expression), [
+      {
+        value: '-has:unresolved',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+      },
+      {
+        value: ' AND ',
+        isAtom: false,
+      },
+      {
+        value: 'hashtag:allow-unresolved-comments',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+      },
+    ]);
+  });
+
+  test('atomizeExpression partially unmatched', () => {
+    const expression: SubmitRequirementExpressionInfo = {
+      expression:
+        'NOT (-has:unresolved AND hashtag:allow-unresolved-comments) OR tested:no',
+      passing_atoms: ['has:unresolved'],
+      failing_atoms: ['hashtag:allow-unresolved-comments'],
+    };
+
+    // All that is not part of passing or failing atoms is considered
+    // "not an atom".
+    assert.deepStrictEqual(atomizeExpression(expression), [
+      {
+        value: 'NOT (',
+        isAtom: false,
+      },
+      {
+        value: '-has:unresolved',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+      },
+      {
+        value: ' AND ',
+        isAtom: false,
+      },
+      {
+        value: 'hashtag:allow-unresolved-comments',
+        isAtom: true,
+        atomStatus: SubmitRequirementExpressionAtomStatus.FAILING,
+      },
+      {
+        value: ') OR tested:no',
+        isAtom: false,
+      },
+    ]);
+  });
+});
diff --git a/polygerrit-ui/app/utils/syntax-util.ts b/polygerrit-ui/app/utils/syntax-util.ts
new file mode 100644
index 0000000..1b6a572
--- /dev/null
+++ b/polygerrit-ui/app/utils/syntax-util.ts
@@ -0,0 +1,165 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  SyntaxLayerLine,
+  SyntaxLayerRange,
+  UNCLOSED,
+} from '../types/syntax-worker-api';
+
+/**
+ * Utilities related to working with the HighlightJS syntax highlighting lib.
+ *
+ * Note that this utility is mostly used by the syntax-worker, which is a Web
+ * Worker and can thus not depend on document, the DOM or any related
+ * functionality.
+ */
+
+/**
+ * With these expressions you can match exactly what HighlightJS produces. It
+ * is really that simple:
+ * https://github.com/highlightjs/highlight.js/blob/main/src/lib/html_renderer.js
+ */
+const openingSpan = new RegExp('<span class="([^"]*?)">');
+const closingSpan = new RegExp('</span>');
+
+/**
+ * Reverse what HighlightJS does in `escapeHTML()`, see:
+ * https://github.com/highlightjs/highlight.js/blob/main/src/lib/utils.js
+ */
+export function unescapeHTML(value: string) {
+  return value
+    .replace(/&#x27;/g, "'")
+    .replace(/&quot;/g, '"')
+    .replace(/&gt;/g, '>')
+    .replace(/&lt;/g, '<')
+    .replace(/&amp;/g, '&');
+}
+
+function equal(r: SyntaxLayerRange) {
+  return (s: SyntaxLayerRange) =>
+    r.start === s.start && r.length === s.length && r.className === s.className;
+}
+
+function unique(r: SyntaxLayerRange, index: number, array: SyntaxLayerRange[]) {
+  return index === array.findIndex(equal(r));
+}
+
+/**
+ * HighlightJS produces one long HTML string with HTML elements spanning
+ * multiple lines. <gr-diff> is line based, needs all elements closed at the end
+ * of the line, and is not interested in the HTML that HighlightJS produces.
+ *
+ * So we are splitting the HTML string up into lines and process them one by
+ * one. Each <span> is detected, converted into a SyntaxLayerRange and removed.
+ * Unclosed spans will be carried over to the next line.
+ */
+export function highlightedStringToRanges(
+  highlightedCode: string
+): SyntaxLayerLine[] {
+  // What the function eventually returns.
+  const rangesPerLine: SyntaxLayerLine[] = [];
+  // The unclosed ranges that are carried over from one line to the next.
+  let carryOverRanges: SyntaxLayerRange[] = [];
+
+  for (let line of highlightedCode.split('\n')) {
+    const ranges: SyntaxLayerRange[] = [...carryOverRanges];
+    carryOverRanges = [];
+
+    // Remove all span tags one after another from left to right.
+    // For each opening <span ...> push a new (unclosed) range.
+    // For each closing </span> close the latest unclosed range.
+    let removal: SpanRemoval | undefined;
+    while ((removal = removeFirstSpan(line)) !== undefined) {
+      if (removal.type === SpanType.OPENING) {
+        ranges.push({
+          start: removal.offset,
+          length: UNCLOSED,
+          className: removal.class ?? '',
+        });
+      } else {
+        const unclosed = lastUnclosed(ranges);
+        unclosed.length = removal.offset - unclosed.start;
+      }
+      line = removal.lineAfter;
+    }
+
+    // All unclosed spans need to have the length set such that they extend to
+    // the end of the line. And they have to be carried over to the next line
+    // as cloned objects with start:0.
+    const lineLength = line.length;
+    for (const range of ranges) {
+      if (isUnclosed(range)) {
+        carryOverRanges.push({...range, start: 0});
+        range.length = lineLength - range.start;
+      }
+    }
+    rangesPerLine.push({
+      ranges: ranges.filter(r => r.length > 0).filter(unique),
+    });
+  }
+  if (carryOverRanges.length > 0) {
+    throw new Error('unclosed <span>s in highlighted code');
+  }
+  return rangesPerLine;
+}
+
+function isUnclosed(range: SyntaxLayerRange) {
+  return range.length === UNCLOSED;
+}
+
+function lastUnclosed(ranges: SyntaxLayerRange[]) {
+  const unclosed = [...ranges].reverse().find(isUnclosed);
+  if (!unclosed) throw new Error(`no unclosed range found ${ranges.length}`);
+  return unclosed;
+}
+
+/** Used for `type` in SpanRemoval. */
+export enum SpanType {
+  OPENING,
+  CLOSING,
+}
+
+/** Return type for removeFirstSpan(). */
+export interface SpanRemoval {
+  type: SpanType;
+  /** The line string after removing the matched span tag. */
+  lineAfter: string;
+  /** The matched css class for OPENING spans. undefined for CLOSING. */
+  class?: string;
+  /** At which char in the line did the removed span tag start? */
+  offset: number;
+}
+
+/**
+ * Finds the first <span ...> or </span>, removes it from the line and returns
+ * details about the removal. Returns `undefined`, if neither is found.
+ */
+export function removeFirstSpan(line: string): SpanRemoval | undefined {
+  const openingMatch = openingSpan.exec(line);
+  const openingIndex = openingMatch?.index ?? Number.MAX_VALUE;
+  const closingMatch = closingSpan.exec(line);
+  const closingIndex = closingMatch?.index ?? Number.MAX_VALUE;
+  if (openingIndex === Number.MAX_VALUE && closingIndex === Number.MAX_VALUE) {
+    return undefined;
+  }
+  const type =
+    openingIndex < closingIndex ? SpanType.OPENING : SpanType.CLOSING;
+  const match = type === SpanType.OPENING ? openingMatch : closingMatch;
+  if (match === null) return undefined;
+  const length = match[0].length;
+  const offsetEscaped = type === SpanType.OPENING ? openingIndex : closingIndex;
+  const lineUpToMatch = line.slice(0, offsetEscaped);
+  const lineAfterMatch = line.slice(offsetEscaped + length);
+  // We are parsing HTML, so escaped characters must only count as one char.
+  const offsetUnescaped = unescapeHTML(lineUpToMatch).length;
+  const removal: SpanRemoval = {
+    type,
+    lineAfter: lineUpToMatch + lineAfterMatch,
+    offset: offsetUnescaped,
+    class: type === SpanType.OPENING ? match[1] : undefined,
+  };
+  return removal;
+}
diff --git a/polygerrit-ui/app/utils/syntax-util_test.ts b/polygerrit-ui/app/utils/syntax-util_test.ts
new file mode 100644
index 0000000..4bf5823
--- /dev/null
+++ b/polygerrit-ui/app/utils/syntax-util_test.ts
@@ -0,0 +1,243 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import './syntax-util';
+import {
+  highlightedStringToRanges,
+  removeFirstSpan,
+  SpanType,
+} from './syntax-util';
+
+suite('file syntax-util', () => {
+  suite('function removeFirstSpan()', () => {
+    test('no matches', async () => {
+      assert.isUndefined(removeFirstSpan(''));
+      assert.isUndefined(removeFirstSpan('span'));
+      assert.isUndefined(removeFirstSpan('<span>'));
+      assert.isUndefined(removeFirstSpan('</span'));
+      assert.isUndefined(removeFirstSpan('asdf'));
+    });
+
+    test('simple opening match', async () => {
+      const removal = removeFirstSpan('asdf<span class="c">asdf');
+      assert.deepEqual(removal, {
+        type: SpanType.OPENING,
+        lineAfter: 'asdfasdf',
+        class: 'c',
+        offset: 4,
+      });
+    });
+
+    test('simple closing match', async () => {
+      const removal = removeFirstSpan('asdf</span>asdf');
+      assert.deepEqual(removal, {
+        type: SpanType.CLOSING,
+        lineAfter: 'asdfasdf',
+        class: undefined,
+        offset: 4,
+      });
+    });
+  });
+
+  suite('function highlightedStringToRanges()', () => {
+    test('no ranges', async () => {
+      assert.deepEqual(highlightedStringToRanges(''), [{ranges: []}]);
+      assert.deepEqual(highlightedStringToRanges('\n'), [
+        {ranges: []},
+        {ranges: []},
+      ]);
+      assert.deepEqual(highlightedStringToRanges('asdf\nasdf\nasdf'), [
+        {ranges: []},
+        {ranges: []},
+        {ranges: []},
+      ]);
+    });
+
+    test('one line, one span', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges('asdf<span class="c">qwer</span>asdf'),
+        [{ranges: [{start: 4, length: 4, className: 'c'}]}]
+      );
+      assert.deepEqual(
+        highlightedStringToRanges('<span class="d">asdfqwer</span>'),
+        [{ranges: [{start: 0, length: 8, className: 'd'}]}]
+      );
+    });
+
+    test('removal of empty spans', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges('asdf<span class="c"></span>asdf'),
+        [{ranges: []}]
+      );
+      assert.deepEqual(
+        highlightedStringToRanges(
+          '<span class="d"></span>\n<span class="d"></span>'
+        ),
+        [{ranges: []}, {ranges: []}]
+      );
+    });
+
+    test('removal of duplicate spans', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          '<span class="d"><span class="d">asdfqwer</span></span>'
+        ),
+        [{ranges: [{start: 0, length: 8, className: 'd'}]}]
+      );
+    });
+
+    test('one line, two spans one after another', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          'asdf<span class="c">qwer</span>zxcv<span class="d">qwer</span>asdf'
+        ),
+        [
+          {
+            ranges: [
+              {start: 4, length: 4, className: 'c'},
+              {start: 12, length: 4, className: 'd'},
+            ],
+          },
+        ]
+      );
+    });
+
+    test('one line, two nested spans', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          'asdf<span class="c">qwer<span class="d">zxcv</span>qwer</span>asdf'
+        ),
+        [
+          {
+            ranges: [
+              {start: 4, length: 12, className: 'c'},
+              {start: 8, length: 4, className: 'd'},
+            ],
+          },
+        ]
+      );
+    });
+
+    test('<span> quoted in a string', async () => {
+      const s = `
+<span class="keyword">const</span> x = <span class="string">&#x27;&lt;span class=&quot;c&quot;&gt;&#x27;</span>;
+<span class="keyword">const</span> y = <span class="string">&#x27;&lt;/span&gt;&#x27;</span>;`;
+
+      assert.deepEqual(highlightedStringToRanges(s), [
+        {ranges: []},
+        {
+          ranges: [
+            {start: 0, length: 5, className: 'keyword'},
+            {start: 10, length: 18, className: 'string'},
+          ],
+        },
+        {
+          ranges: [
+            {start: 0, length: 5, className: 'keyword'},
+            {start: 10, length: 9, className: 'string'},
+          ],
+        },
+      ]);
+    });
+
+    test('one complex line with escaped HTML', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          '  <span class="tag">&lt;<span class="name">span</span> <span class="attr">class</span>=<span class="string">&quot;title&quot;</span>&gt;</span>[[name]]<span class="tag">&lt;/<span class="name">span</span>&gt;</span>'
+        ),
+        [
+          {
+            ranges: [
+              // '  <span class="title">[[name]]</span>'
+              {start: 2, length: 20, className: 'tag'},
+              {start: 3, length: 4, className: 'name'},
+              {start: 8, length: 5, className: 'attr'},
+              {start: 14, length: 7, className: 'string'},
+              {start: 30, length: 7, className: 'tag'},
+              {start: 32, length: 4, className: 'name'},
+            ],
+          },
+        ]
+      );
+    });
+
+    test('two lines, one span each', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          'asdf<span class="c">qwer</span>asdf\n' +
+            'asd<span class="d">qwe</span>asd'
+        ),
+        [
+          {ranges: [{start: 4, length: 4, className: 'c'}]},
+          {ranges: [{start: 3, length: 3, className: 'd'}]},
+        ]
+      );
+    });
+
+    test('one span over two lines', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          'asdf<span class="c">qwer\n' + 'asdf</span>qwer'
+        ),
+        [
+          {ranges: [{start: 4, length: 4, className: 'c'}]},
+          {ranges: [{start: 0, length: 4, className: 'c'}]},
+        ]
+      );
+    });
+
+    test('two spans over two lines', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          'asdf<span class="c">qwer<span class="d">zxcv\n' +
+            'asdf</span>qwer</span>zxcv'
+        ),
+        [
+          {
+            ranges: [
+              {start: 4, length: 8, className: 'c'},
+              {start: 8, length: 4, className: 'd'},
+            ],
+          },
+          {
+            ranges: [
+              {start: 0, length: 8, className: 'c'},
+              {start: 0, length: 4, className: 'd'},
+            ],
+          },
+        ]
+      );
+    });
+
+    test('two spans over four lines', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          'asdf<span class="c">qwer\n' +
+            'asdf<span class="d">qwer\n' +
+            'asdf</span>qwer\n' +
+            'asdf</span>qwer'
+        ),
+        [
+          {ranges: [{start: 4, length: 4, className: 'c'}]},
+          {
+            ranges: [
+              {start: 0, length: 8, className: 'c'},
+              {start: 4, length: 4, className: 'd'},
+            ],
+          },
+          {
+            ranges: [
+              {start: 0, length: 8, className: 'c'},
+              {start: 0, length: 4, className: 'd'},
+            ],
+          },
+          {ranges: [{start: 0, length: 4, className: 'c'}]},
+        ]
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/theme-util.ts b/polygerrit-ui/app/utils/theme-util.ts
new file mode 100644
index 0000000..412e738
--- /dev/null
+++ b/polygerrit-ui/app/utils/theme-util.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {AppTheme} from '../constants/constants';
+
+// https://css-tricks.com/a-complete-guide-to-dark-mode-on-the-web/#aa-javascript
+function isDarkThemeInOs() {
+  const prefersDarkScheme = prefersDarkColorScheme();
+  return prefersDarkScheme.matches;
+}
+
+export function prefersDarkColorScheme() {
+  return window.matchMedia('(prefers-color-scheme: dark)');
+}
+
+export function isDarkTheme(theme: AppTheme) {
+  if (theme === AppTheme.AUTO) return isDarkThemeInOs();
+  return theme === AppTheme.DARK;
+}
diff --git a/polygerrit-ui/app/utils/type-util.ts b/polygerrit-ui/app/utils/type-util.ts
new file mode 100644
index 0000000..e91fefc
--- /dev/null
+++ b/polygerrit-ui/app/utils/type-util.ts
@@ -0,0 +1,16 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Gets all properties of a Source that match a given Type. For example:
+ *
+ *   type BooleansOfHTMLElement = PropertiesOfType<HTMLElement, boolean>;
+ *
+ * will be 'draggable' | 'autofocus' | etc.
+ */
+export type PropertiesOfType<Source, Type> = {
+  [K in keyof Source]: Source[K] extends Type ? K : never;
+}[keyof Source];
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index de6462f..8564c3f 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -1,27 +1,54 @@
-import {ServerInfo} from '../types/common';
-import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
-
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
+import {
+  BasePatchSetNum,
+  PARENT,
+  RevisionPatchSetNum,
+  ServerInfo,
+} from '../types/common';
+import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
+
 const PROBE_PATH = '/Documentation/index.html';
 const DOCS_BASE_PATH = '/Documentation';
 
 export function getBaseUrl(): string {
-  return window.CANONICAL_PATH || '';
+  // window is not defined in service worker, therefore no CANONICAL_PATH
+  if (typeof window === 'undefined') return '';
+  return self.CANONICAL_PATH || '';
+}
+
+export interface PatchRangeParams {
+  patchNum?: RevisionPatchSetNum;
+  basePatchNum?: BasePatchSetNum;
+}
+
+export function rootUrl() {
+  return `${getBaseUrl()}/`;
+}
+
+/**
+ * Given an object of parameters, potentially including a `patchNum` or a
+ * `basePatchNum` or both, return a string representation of that range. If
+ * no range is indicated in the params, the empty string is returned.
+ */
+export function getPatchRangeExpression(params: PatchRangeParams) {
+  let range = '';
+  if (params.patchNum) {
+    range = `${params.patchNum}`;
+  }
+  if (params.basePatchNum && params.basePatchNum !== PARENT) {
+    range = `${params.basePatchNum}..${range}`;
+  }
+  return range;
+}
+
+export function prependOrigin(path: string): string {
+  if (path.startsWith('http')) return path;
+  if (path.startsWith('/')) return window.location.origin + path;
+  throw new Error(`Cannot prepend origin to relative path '${path}'.`);
 }
 
 let getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
@@ -49,7 +76,7 @@
   return getDocsBaseUrlCachedPromise;
 }
 
-export function _testOnly_clearDocsBaseUrlCache() {
+export function testOnly_clearDocsBaseUrlCache() {
   getDocsBaseUrlCachedPromise = undefined;
 }
 
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index 63dc81d..a014dc2 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -1,37 +1,33 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import {ServerInfo} from '../api/rest-api';
-import '../test/common-test-setup-karma';
+import {
+  BasePatchSetNum,
+  RevisionPatchSetNum,
+  ServerInfo,
+} from '../api/rest-api';
+import '../test/common-test-setup';
 import {createGerritInfo, createServerInfo} from '../test/test-data-generators';
 import {
   getBaseUrl,
   getDocsBaseUrl,
-  _testOnly_clearDocsBaseUrlCache,
+  testOnly_clearDocsBaseUrlCache,
   encodeURL,
   singleDecodeURL,
   toPath,
   toPathname,
   toSearchParams,
+  getPatchRangeExpression,
+  PatchRangeParams,
 } from './url-util';
-import {appContext} from '../services/app-context';
+import {getAppContext, AppContext} from '../services/app-context';
 import {stubRestApi} from '../test/test-utils';
+import {assert} from '@open-wc/testing';
 
 suite('url-util tests', () => {
+  let appContext: AppContext;
   suite('getBaseUrl tests', () => {
     let originalCanonicalPath: string | undefined;
 
@@ -51,7 +47,8 @@
 
   suite('getDocsBaseUrl tests', () => {
     setup(() => {
-      _testOnly_clearDocsBaseUrlCache();
+      testOnly_clearDocsBaseUrlCache();
+      appContext = getAppContext();
     });
 
     test('null config', async () => {
@@ -160,4 +157,22 @@
       'asdf?qwer=zxcv'
     );
   });
+
+  test('getPatchRangeExpression', () => {
+    const params: PatchRangeParams = {};
+    let actual = getPatchRangeExpression(params);
+    assert.equal(actual, '');
+
+    params.patchNum = 4 as RevisionPatchSetNum;
+    actual = getPatchRangeExpression(params);
+    assert.equal(actual, '4');
+
+    params.basePatchNum = 2 as BasePatchSetNum;
+    actual = getPatchRangeExpression(params);
+    assert.equal(actual, '2..4');
+
+    delete params.patchNum;
+    actual = getPatchRangeExpression(params);
+    assert.equal(actual, '2..');
+  });
 });
diff --git a/polygerrit-ui/app/utils/weblink-util.ts b/polygerrit-ui/app/utils/weblink-util.ts
new file mode 100644
index 0000000..1e9315c
--- /dev/null
+++ b/polygerrit-ui/app/utils/weblink-util.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {CommitId, ServerInfo} from '../api/rest-api';
+
+export interface WebLink {
+  name?: string;
+  label: string;
+  url: string;
+}
+
+export interface GeneratedWebLink {
+  name?: string;
+  label?: string;
+  url?: string;
+}
+
+export function getPatchSetWeblink(
+  commit?: CommitId,
+  weblinks?: GeneratedWebLink[],
+  config?: ServerInfo
+): GeneratedWebLink | undefined {
+  if (!commit) return undefined;
+  const name = commit.slice(0, 7);
+  const weblink = getBrowseCommitWeblink(weblinks, config);
+  if (!weblink?.url) return {name};
+  return {name, url: weblink.url};
+}
+
+// visible for testing
+export function getCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
+  // is an ordered allowed list of web link types that provide direct
+  // links to the commit in the url property.
+  const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
+  for (let i = 0; i < codeBrowserLinks.length; i++) {
+    const weblink = weblinks.find(
+      weblink => weblink.name === codeBrowserLinks[i]
+    );
+    if (weblink) return weblink;
+  }
+  return undefined;
+}
+
+// visible for testing
+export function getBrowseCommitWeblink(
+  weblinks?: GeneratedWebLink[],
+  config?: ServerInfo
+): GeneratedWebLink | undefined {
+  if (!weblinks) return undefined;
+
+  // Use primary weblink if configured and exists.
+  const primaryWeblinkName = config?.gerrit?.primary_weblink_name;
+  if (primaryWeblinkName) {
+    const weblink = weblinks.find(link => link.name === primaryWeblinkName);
+    if (weblink) return weblink;
+  }
+
+  return getCodeBrowserWeblink(weblinks);
+}
+
+export function getChangeWeblinks(
+  weblinks?: GeneratedWebLink[],
+  config?: ServerInfo
+): GeneratedWebLink[] {
+  if (!weblinks?.length) return [];
+  const commitWeblink = getBrowseCommitWeblink(weblinks, config);
+  return weblinks.filter(
+    weblink => !commitWeblink?.name || weblink.name !== commitWeblink.name
+  );
+}
diff --git a/polygerrit-ui/app/utils/weblink-util_test.ts b/polygerrit-ui/app/utils/weblink-util_test.ts
new file mode 100644
index 0000000..be97cfd
--- /dev/null
+++ b/polygerrit-ui/app/utils/weblink-util_test.ts
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {createServerInfo, createGerritInfo} from '../test/test-data-generators';
+import {
+  getCodeBrowserWeblink,
+  getBrowseCommitWeblink,
+  getChangeWeblinks,
+} from './weblink-util';
+
+suite('weblink util tests', () => {
+  test('getCodeBrowserWeblink', () => {
+    assert.deepEqual(
+      getCodeBrowserWeblink([
+        {name: 'gitweb'},
+        {name: 'gitiles'},
+        {name: 'browse'},
+        {name: 'test'},
+      ]),
+      {name: 'gitiles'}
+    );
+
+    assert.deepEqual(
+      getCodeBrowserWeblink([{name: 'gitweb'}, {name: 'test'}]),
+      {name: 'gitweb'}
+    );
+  });
+
+  test('getBrowseCommitWeblink', () => {
+    const browserLink = {name: 'browser', url: 'browser/url'};
+    const link = {name: 'gitiles', url: 'test/url'};
+    const weblinks = [browserLink, link];
+    const config = {
+      ...createServerInfo(),
+      gerrit: {...createGerritInfo(), primary_weblink_name: browserLink.name},
+    };
+
+    assert.deepEqual(getBrowseCommitWeblink(weblinks, config), browserLink);
+    assert.deepEqual(getBrowseCommitWeblink(weblinks), link);
+  });
+
+  test('getChangeWeblinks', () => {
+    const link = {name: 'test', url: 'test/url'};
+    const browserLink = {name: 'browser', url: 'browser/url'};
+
+    assert.deepEqual(getChangeWeblinks([link, browserLink])[0], {
+      name: 'test',
+      url: 'test/url',
+    });
+
+    assert.deepEqual(getChangeWeblinks([link])[0], {
+      name: 'test',
+      url: 'test/url',
+    });
+
+    link.url = `https://${link.url}`;
+    assert.deepEqual(getChangeWeblinks([link])[0], {
+      name: 'test',
+      url: 'https://test/url',
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/worker-util.ts b/polygerrit-ui/app/utils/worker-util.ts
new file mode 100644
index 0000000..aeee537
--- /dev/null
+++ b/polygerrit-ui/app/utils/worker-util.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// This file adds some simple checks to match internal Google rules.
+// Internally at Google it has different a implementation.
+
+import {AccountDetailInfo} from '../api/rest-api';
+
+/**
+ * We cannot import the worker script from cdn directly, because that is
+ * creating cross-origin issues. Instead we have to create a worker script on
+ * the fly and pull the actual worker via `importScripts()`. Apparently that
+ * is a well established pattern.
+ */
+function wrapUrl(url: string) {
+  const content = `importScripts("${url}");`;
+  return URL.createObjectURL(new Blob([content], {type: 'text/javascript'}));
+}
+
+export function createWorker(workerUrl: string): Worker {
+  if (!workerUrl.startsWith('http'))
+    throw new Error(`Worker URL '${workerUrl}' does not start with 'http'.`);
+  return new Worker(wrapUrl(workerUrl));
+}
+
+export function registerServiceWorker(workerUrl: string) {
+  return window.navigator.serviceWorker.register(workerUrl);
+}
+
+export function areNotificationsEnabled(account?: AccountDetailInfo): boolean {
+  return !!account?._account_id;
+}
+
+export function importScript(scope: WorkerGlobalScope, url: string): void {
+  scope.importScripts(url);
+}
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
new file mode 100644
index 0000000..03b6b902
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -0,0 +1,199 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ParsedChangeInfo} from '../types/types';
+import {getReason} from '../utils/attention-set-util';
+import {readResponsePayload} from '../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {filterAttentionChangesAfter} from '../utils/service-worker-util';
+import {AccountDetailInfo} from '../api/rest-api';
+import {
+  ServiceWorkerMessageType,
+  TRIGGER_NOTIFICATION_UPDATES_MS,
+} from '../services/service-worker-installer';
+import {
+  getServiceWorkerState,
+  putServiceWorkerState,
+} from './service-worker-indexdb';
+import {createDashboardUrl} from '../models/views/dashboard';
+import {createChangeUrl} from '../models/views/change';
+import {noAwait} from '../utils/async-util';
+
+export class ServiceWorker {
+  constructor(
+    /* private but used in test */ public ctx: ServiceWorkerGlobalScope
+  ) {}
+
+  // private but used in test
+  latestUpdateTimestampMs = Date.now();
+
+  allowBrowserNotificationsPreference = false;
+
+  /**
+   * We cannot rely on a state in a service worker, because every time
+   * service worker starts or stops, new instance is created. So every time
+   * there is new instance we load state from indexdb.
+   */
+  async init() {
+    await this.loadState();
+    this.ctx.addEventListener('message', e => this.onMessage(e));
+    this.ctx.addEventListener('notificationclick', e =>
+      this.onNotificationClick(e)
+    );
+  }
+
+  // private but used in test
+  saveState() {
+    return putServiceWorkerState({
+      latestUpdateTimestampMs: this.latestUpdateTimestampMs,
+      allowBrowserNotificationsPreference:
+        this.allowBrowserNotificationsPreference,
+    });
+  }
+
+  private async loadState() {
+    const state = await getServiceWorkerState();
+    if (state) {
+      this.latestUpdateTimestampMs = state.latestUpdateTimestampMs;
+      this.allowBrowserNotificationsPreference =
+        state.allowBrowserNotificationsPreference;
+    }
+  }
+
+  private onMessage(e: ExtendableMessageEvent) {
+    if (e.data?.type === ServiceWorkerMessageType.TRIGGER_NOTIFICATIONS) {
+      e.waitUntil(
+        this.showLatestAttentionChangeNotification(
+          e.data?.account as AccountDetailInfo | undefined
+        )
+      );
+    } else if (
+      e.data?.type === ServiceWorkerMessageType.USER_PREFERENCE_CHANGE
+    ) {
+      e.waitUntil(
+        this.allowBrowserNotificationsPreferenceChanged(
+          e.data?.allowBrowserNotificationsPreference as boolean
+        )
+      );
+    }
+  }
+
+  private onNotificationClick(e: NotificationEvent) {
+    e.notification.close();
+    e.waitUntil(this.openWindow(e.notification.data.url));
+  }
+
+  async allowBrowserNotificationsPreferenceChanged(preference: boolean) {
+    this.allowBrowserNotificationsPreference = preference;
+    await this.saveState();
+  }
+
+  // private but used in test
+  async showLatestAttentionChangeNotification(account?: AccountDetailInfo) {
+    // Message always contains account, but we do not throw error.
+    if (!account?._account_id) return;
+    if (!this.allowBrowserNotificationsPreference) return;
+    const latestAttentionChanges = await this.getChangesToNotify(account);
+    const numOfChangesToNotifyAbout = latestAttentionChanges.length;
+    if (numOfChangesToNotifyAbout === 1) {
+      this.showNotificationForChange(latestAttentionChanges[0], account);
+    } else if (numOfChangesToNotifyAbout > 1) {
+      this.showNotificationForDashboard(numOfChangesToNotifyAbout);
+    }
+  }
+
+  // Code based on code sample from
+  // https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow
+  private async openWindow(url?: string) {
+    if (!url) return;
+    const clientsArr = await this.ctx.clients.matchAll({type: 'window'});
+    try {
+      let client = clientsArr.find(c => c.url === url);
+      if (!client)
+        client = (await this.ctx.clients.openWindow(url)) ?? undefined;
+      await client?.focus();
+    } catch (e) {
+      console.error(`Cannot open window about notified change - ${e}`);
+    }
+  }
+
+  private showNotificationForChange(
+    change: ParsedChangeInfo,
+    account: AccountDetailInfo
+  ) {
+    const body = getReason(undefined, account, change);
+    const changeUrl = createChangeUrl({
+      change,
+      usp: 'service-worker-notification',
+    });
+    // We are adding origin because each notification can have different origin
+    // User can have different service workers for different origins/hosts.
+    // TODO(milutin): Check if this works properly with getBaseUrl()
+    const data = {url: `${self.location.origin}${changeUrl}`};
+    const icon = `${self.location.origin}/favicon.ico`;
+    this.ctx.registration.showNotification(change.subject, {
+      body,
+      data,
+      icon,
+    });
+    this.sendReport('notify about 1 change');
+  }
+
+  private showNotificationForDashboard(numOfChangesToNotifyAbout: number) {
+    const title = `You are in the attention set for ${numOfChangesToNotifyAbout} new changes.`;
+    const dashboardUrl = createDashboardUrl({});
+    const data = {url: `${self.location.origin}${dashboardUrl}`};
+    const icon = `${self.location.origin}/favicon.ico`;
+    this.ctx.registration.showNotification(title, {data, icon});
+    this.sendReport(`notify about ${numOfChangesToNotifyAbout} changes`);
+  }
+
+  // private but used in test
+  async getChangesToNotify(account: AccountDetailInfo) {
+    // We throttle polling, since there can be many clients triggerring
+    // always only one service worker.
+    const durationFromLatestUpdateMS =
+      Date.now() - this.latestUpdateTimestampMs;
+    if (durationFromLatestUpdateMS < TRIGGER_NOTIFICATION_UPDATES_MS) {
+      return [];
+    }
+    const prevLatestUpdateTimestampMs = this.latestUpdateTimestampMs;
+    this.latestUpdateTimestampMs = Date.now();
+    await this.saveState();
+    noAwait(this.sendReport('polling'));
+    const changes = await this.getLatestAttentionSetChanges();
+    const latestAttentionChanges = filterAttentionChangesAfter(
+      changes,
+      account,
+      prevLatestUpdateTimestampMs
+    );
+    return latestAttentionChanges;
+  }
+
+  // private but used in test
+  async getLatestAttentionSetChanges(): Promise<ParsedChangeInfo[]> {
+    // TODO(milutin): Implement more generic query builder
+    const response = await fetch(
+      '/changes/?O=1000081&S=0&n=25&q=attention%3Aself'
+    );
+    const payload = await readResponsePayload(response);
+    const changes = payload.parsed as unknown as ParsedChangeInfo[] | undefined;
+    return changes ?? [];
+  }
+
+  /**
+   * Send report event to 1 client (last focused one). The client will use
+   * gr-reporting service to send event to metric event collectors.
+   */
+  async sendReport(eventName: string) {
+    const clientsArr = await this.ctx.clients.matchAll({type: 'window'});
+    const lastFocusedClient = clientsArr?.[0];
+    if (!lastFocusedClient) return;
+
+    lastFocusedClient.postMessage({
+      type: ServiceWorkerMessageType.REPORTING,
+      eventName,
+    });
+  }
+}
diff --git a/polygerrit-ui/app/workers/service-worker-class_test.ts b/polygerrit-ui/app/workers/service-worker-class_test.ts
new file mode 100644
index 0000000..33a19d9
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker-class_test.ts
@@ -0,0 +1,158 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {Timestamp} from '../api/rest-api';
+import '../test/common-test-setup';
+import {
+  createAccountDetailWithId,
+  createParsedChange,
+} from '../test/test-data-generators';
+import {mockPromise} from '../test/test-utils';
+import {ParsedChangeInfo} from '../types/types';
+import {parseDate} from '../utils/date-util';
+import {ServiceWorker} from './service-worker-class';
+
+suite('service worker class tests', () => {
+  let serviceWorker: ServiceWorker;
+
+  setup(() => {
+    const moctCtx = {
+      registration: {
+        showNotification: () => {},
+      },
+    } as {} as ServiceWorkerGlobalScope;
+    serviceWorker = new ServiceWorker(moctCtx);
+    serviceWorker.allowBrowserNotificationsPreference = true;
+  });
+
+  test('notify about attention in t2 when time is t3', async () => {
+    const t1 = parseDate('2016-01-12 20:20:00' as Timestamp).getTime();
+    const t2 = '2016-01-12 20:30:00' as Timestamp;
+    const t3 = parseDate('2016-01-12 20:40:00' as Timestamp).getTime();
+    serviceWorker.latestUpdateTimestampMs = t1;
+    const account = createAccountDetailWithId();
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: t2,
+        },
+      },
+    };
+    sinon.useFakeTimers(t3);
+    sinon
+      .stub(serviceWorker, 'getLatestAttentionSetChanges')
+      .returns(Promise.resolve([change]));
+    const changes = await serviceWorker.getChangesToNotify(account);
+    assert.equal(changes[0], change);
+  });
+
+  test('check race condition', async () => {
+    const promise = mockPromise<ParsedChangeInfo[]>();
+    sinon.stub(serviceWorker, 'saveState').returns(Promise.resolve());
+    const getLatestAttentionSetChangesStub = sinon
+      .stub(serviceWorker, 'getLatestAttentionSetChanges')
+      .returns(promise);
+    const account = createAccountDetailWithId();
+    const t1 = parseDate('2016-01-12 20:20:00' as Timestamp).getTime();
+    const t2 = '2016-01-12 20:30:00' as Timestamp;
+    serviceWorker.latestUpdateTimestampMs = t1;
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: t2,
+        },
+      },
+    };
+    serviceWorker.getChangesToNotify(account);
+    serviceWorker.getChangesToNotify(account);
+    promise.resolve([change]);
+    await serviceWorker.getChangesToNotify(account);
+    assert.isTrue(getLatestAttentionSetChangesStub.calledOnce);
+  });
+
+  test('when 2 or more changes, link to dashboard', async () => {
+    const t1 = parseDate('2016-01-12 20:20:00' as Timestamp).getTime();
+    const t2 = '2016-01-12 20:30:00' as Timestamp;
+    serviceWorker.latestUpdateTimestampMs = t1;
+    const account = createAccountDetailWithId();
+    const change: ParsedChangeInfo = {
+      ...createParsedChange(),
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: t2,
+        },
+      },
+    };
+    sinon
+      .stub(serviceWorker, 'getLatestAttentionSetChanges')
+      .returns(Promise.resolve([change, change]));
+    sinon.stub(serviceWorker, 'saveState').returns(Promise.resolve());
+    const showNotificationMock = sinon.stub(
+      serviceWorker.ctx.registration,
+      'showNotification'
+    );
+
+    await serviceWorker.showLatestAttentionChangeNotification(account);
+
+    assert.isTrue(showNotificationMock.calledOnce);
+    assert.isTrue(
+      showNotificationMock.calledWithMatch(
+        'You are in the attention set for 2 new changes.'
+      )
+    );
+  });
+
+  test('show notification for 1 change', async () => {
+    const t1 = parseDate('2016-01-12 20:20:00' as Timestamp).getTime();
+    const t2 = '2016-01-12 20:30:00' as Timestamp;
+    serviceWorker.latestUpdateTimestampMs = t1;
+    const account = createAccountDetailWithId();
+    const subject = 'New change';
+    const reason = 'Reason';
+    const change = {
+      ...createParsedChange(),
+      subject,
+      attention_set: {
+        [`${account._account_id}`]: {
+          account,
+          last_update: t2,
+          reason,
+        },
+      },
+    };
+    const showNotificationMock = sinon.stub(
+      serviceWorker.ctx.registration,
+      'showNotification'
+    );
+    sinon
+      .stub(serviceWorker, 'getLatestAttentionSetChanges')
+      .returns(Promise.resolve([change]));
+    sinon.stub(serviceWorker, 'saveState').returns(Promise.resolve());
+
+    await serviceWorker.showLatestAttentionChangeNotification(account);
+
+    assert.isTrue(showNotificationMock.calledOnce);
+    assert.isTrue(
+      showNotificationMock.calledWithMatch(subject, {
+        body: reason,
+        data: {
+          url: 'http://localhost:9876/c/test-project/+/42?usp=service-worker-notification',
+        },
+      })
+    );
+    assert.equal(showNotificationMock.firstCall.args?.[1]?.['body'], reason);
+    assert.isTrue(
+      showNotificationMock.firstCall.args?.[1]?.['data']?.['url'].endsWith(
+        'c/test-project/+/42?usp=service-worker-notification'
+      )
+    );
+  });
+});
diff --git a/polygerrit-ui/app/workers/service-worker-indexdb.ts b/polygerrit-ui/app/workers/service-worker-indexdb.ts
new file mode 100644
index 0000000..6d5ab40
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker-indexdb.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export interface GerritServiceWorkerState {
+  latestUpdateTimestampMs: number;
+  allowBrowserNotificationsPreference: boolean;
+}
+
+const SERVICE_WORKER_DB = 'service-worker-db-1';
+// Object store - kind of table that holds objects
+// https://developer.mozilla.org/en-US/docs/Web/API/IDBObjectStore
+const SERVICE_WORKER_STORE = 'states';
+// Service Worker State needs just 1 entry in object store which is rewritten
+// every time state is saved. This entry has SERVICE_WORKER_STATE_ID.
+const SERVICE_WORKER_STATE_ID = 1;
+
+function getServiceWorkerDB(): Promise<IDBDatabase> {
+  return new Promise((resolve, reject) => {
+    const request = indexedDB.open(SERVICE_WORKER_DB);
+    request.onsuccess = () => resolve(request.result);
+    request.onerror = reject;
+    request.onblocked = reject;
+    // Event is fired when an attempt was made to open a database with a version
+    // higher than its current version.
+    // https://developer.mozilla.org/en-US/docs/Web/API/IDBOpenDBRequest/upgradeneeded_event
+    // It's mainly used to create object stores.
+    // https://web.dev/indexeddb/#creating-object-stores
+    request.onupgradeneeded = () => {
+      const db = request.result;
+      if (db.objectStoreNames.contains(SERVICE_WORKER_STORE)) return;
+      const states = db.createObjectStore(SERVICE_WORKER_STORE, {
+        keyPath: 'id',
+      });
+      states.createIndex('states_id_unique', 'id', {unique: true});
+    };
+  });
+}
+
+export async function putServiceWorkerState(state: GerritServiceWorkerState) {
+  const db = await getServiceWorkerDB();
+  const tx = db.transaction(SERVICE_WORKER_STORE, 'readwrite');
+  const store = tx.objectStore(SERVICE_WORKER_STORE);
+  store.put({...state, id: SERVICE_WORKER_STATE_ID});
+
+  return new Promise<void>(resolve => {
+    tx.oncomplete = () => resolve();
+  });
+}
+
+export async function getServiceWorkerState(): Promise<GerritServiceWorkerState> {
+  const db = await getServiceWorkerDB();
+  const tx = db.transaction(SERVICE_WORKER_STORE, 'readonly');
+  const store = tx.objectStore(SERVICE_WORKER_STORE);
+
+  return new Promise((resolve, reject) => {
+    const request = store.get(SERVICE_WORKER_STATE_ID);
+    request.onsuccess = () => resolve(request.result);
+    request.onerror = reject;
+  });
+}
diff --git a/polygerrit-ui/app/workers/service-worker.ts b/polygerrit-ui/app/workers/service-worker.ts
new file mode 100644
index 0000000..86edecf
--- /dev/null
+++ b/polygerrit-ui/app/workers/service-worker.ts
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {ServiceWorker} from './service-worker-class';
+
+/**
+ * `self` is for a worker what `window` is for the web app. It is called
+ * the `ServiceWorkerGlobalScope`, see
+ * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope
+ */
+const ctx = self as {} as ServiceWorkerGlobalScope;
+
+/** Singleton instance */
+const serviceWorker = new ServiceWorker(ctx);
+serviceWorker.init();
diff --git a/polygerrit-ui/app/workers/syntax-worker.ts b/polygerrit-ui/app/workers/syntax-worker.ts
new file mode 100644
index 0000000..1ddb9c6
--- /dev/null
+++ b/polygerrit-ui/app/workers/syntax-worker.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {HighlightJS} from '../types/types';
+import {
+  SyntaxWorkerMessage,
+  SyntaxWorkerResult,
+  isRequest,
+  isInit,
+} from '../types/syntax-worker-api';
+import {highlightedStringToRanges} from '../utils/syntax-util';
+import {importScript} from '../utils/worker-util';
+
+// This is an entry point file of a bundle. Keep free of exports!
+
+/**
+ * This is a web worker for calling the HighlightJS library for syntax
+ * highlighting. Files can be large and highlighting does not require
+ * the `document` or the `DOM`, so it is a perfect fit for a web worker.
+ *
+ * This file is a just a hub hooking into the web worker API. The message
+ * events for communicating with the main app are defined in the file
+ * `types/worker-api.ts`. And the `meat` of the computation is done in the
+ * file `syntax-util.ts`.
+ */
+
+/**
+ * `self` is for a worker what `window` is for the web app. It is called
+ * the `DedicatedWorkerGlobalScope`, see
+ * https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope
+ *
+ * Once imported the HighlightJS lib exposes its functionality via the global
+ * `hljs` variable.
+ */
+const ctx = self as DedicatedWorkerGlobalScope & {hljs?: HighlightJS};
+
+/**
+ * We are encapsulating the web worker API here, so this is the only place
+ * where you need to know about it and the MessageEvents in this file.
+ */
+ctx.onmessage = function (e: MessageEvent<SyntaxWorkerMessage>) {
+  try {
+    const message = e.data;
+    if (isInit(message)) {
+      worker.init(message.url);
+      const result: SyntaxWorkerResult = {ranges: []};
+      ctx.postMessage(result);
+    }
+    if (isRequest(message)) {
+      const ranges = worker.highlightCode(message.language, message.code);
+      const result: SyntaxWorkerResult = {ranges};
+      ctx.postMessage(result);
+    }
+  } catch (err) {
+    let error = 'syntax worker error';
+    if (err instanceof Error) error = err.message;
+    const result: SyntaxWorkerResult = {error, ranges: []};
+    ctx.postMessage(result);
+  }
+};
+
+class SyntaxWorker {
+  private highlightJsLib?: HighlightJS;
+
+  init(highlightJsLibUrl: string) {
+    importScript(ctx, highlightJsLibUrl);
+    if (!ctx.hljs) {
+      throw new Error('HighlightJS lib not available after import');
+    }
+    this.highlightJsLib = ctx.hljs;
+    this.highlightJsLib.configure({classPrefix: ''});
+  }
+
+  highlightCode(language: string, code: string) {
+    if (!this.highlightJsLib) throw new Error('worker not initialized');
+    const highlighted = this.highlightJsLib.highlight(language, code, true);
+    return highlightedStringToRanges(highlighted.value);
+  }
+}
+
+/** Singleton instance being referenced in `onmessage` function above. */
+const worker = new SyntaxWorker();
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index d3b22eb..5a91fc0 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,10 +2,10 @@
 # yarn lockfile v1
 
 
-"@lit/reactive-element@^1.0.0":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.1.tgz#853cacd4d78d79059f33f66f8e7b0e5c34bee294"
-  integrity sha512-nSD5AA2AZkKuXuvGs8IK7K5ZczLAogfDd26zT9l6S7WzvqALdVWcW5vMUiTnZyj5SPcNwNNANj0koeV1ieqTFQ==
+"@lit/reactive-element@^1.3.0":
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.3.2.tgz#43e470537b6ec2c23510c07812616d5aa27a17cd"
+  integrity sha512-A2e18XzPMrIh35nhIdE4uoqRzoIpEU5vZYuQN4S3Ee1zkGdYC27DP12pewbw/RLgPHzaE4kx/YqxMzebOpm0dA==
 
 "@mapbox/node-pre-gyp@^1.0.0":
   version "1.0.5"
@@ -161,7 +161,7 @@
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.3":
+"@polymer/iron-overlay-behavior@^3.0.0-pre.27":
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/@polymer/iron-overlay-behavior/-/iron-overlay-behavior-3.0.3.tgz#29c198e19e05bb2bcf7d86d3c11848cb93301d00"
   integrity sha512-Q/Fp0+uOQQ145ebZ7T8Cxl4m1tUKYjyymkjcL2rXUm+aDQGb1wA1M1LYxUF5YBqd+9lipE0PTIiYwA2ZL/sznA==
@@ -193,6 +193,14 @@
     "@polymer/iron-meta" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
+"@polymer/marked-element@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/marked-element/-/marked-element-3.0.1.tgz#56add62080404dea142c055977807ae9ca773a89"
+  integrity sha512-WJQzQetxdStVGQbyTBUBgd+hSI0Rl39uJg7b2zL3r6EfMnibzmA/YNT06M8jVZdxPF+B4SumrFWRtasVtGQRUQ==
+  dependencies:
+    "@polymer/polymer" "^3.0.0"
+    marked "~0.3.9"
+
 "@polymer/neon-animation@^3.0.0-pre.26":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@polymer/neon-animation/-/neon-animation-3.0.1.tgz#6658e4b524abc057477772a7473292493d366c24"
@@ -517,10 +525,10 @@
   resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
   integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
 
-codemirror-minified@^5.62.2:
-  version "5.63.0"
-  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.63.0.tgz#29d1a78713a633c933a27853679afdc0bfea49cc"
-  integrity sha512-dMN2w0Qg5Zwn2p7UW3sYAoyrJ+QRBkiF5bfbQAvQ1bfqhEjGnZ++/zvOG7NivfnUbYRhSULz8lsFtzt4ldBNyQ==
+codemirror-minified@^5.65.0:
+  version "5.65.0"
+  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.65.0.tgz#283f21655d6fc3477e64532c86a657bbc2063c19"
+  integrity sha512-AxpxR5XolsvgAjwE1BspomW6fhj541BxMyj0HT5TmeketKJ/kPSEiTZes/cQgHvHOmGB4clbR67Mz/ORrjYkMQ==
 
 concat-map@0.0.1:
   version "0.0.1"
@@ -604,6 +612,33 @@
   resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
   integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
 
+highlight.js@^10.4.1:
+  version "10.7.3"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
+  integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
+
+"highlight.js@^11.3.1 || ^10.4.1":
+  version "11.3.1"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.3.1.tgz#813078ef3aa519c61700f84fe9047231c5dc3291"
+  integrity sha512-PUhCRnPjLtiLHZAQ5A/Dt5F8cWZeMyj9KRsACsWT+OD6OP0x6dp5OmT5jdx0JgEyPxPZZIPQpRN2TciUT7occw==
+
+highlight.js@^11.5.0:
+  version "11.5.0"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.5.0.tgz#00abb7ed926491adbdabc93a4f3fd2b88b451b4a"
+  integrity sha512-SM6WDj5/C+VfIY8pZ6yW6Xa0Fm1tniYVYWYW1Q/DcMnISZFrC3aQAZZZFAAZtybKNrGId3p/DNbFTtcTXXgYBw==
+
+"highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates":
+  version "0.0.1"
+  resolved "https://github.com/highlightjs/highlightjs-closure-templates#2ac39a4a0ddf08dc826e341ababf3d00fd69878a"
+  dependencies:
+    highlight.js "^11.3.1 || ^10.4.1"
+
+"highlightjs-structured-text@https://github.com/highlightjs/highlightjs-structured-text":
+  version "1.4.2"
+  resolved "https://github.com/highlightjs/highlightjs-structured-text#4879fbc15ee4a62a08b45fe785b6a6249cbcf62a"
+  dependencies:
+    highlight.js "^10.4.1"
+
 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"
@@ -652,29 +687,29 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
   integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
 
-lit-element@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.1.tgz#3c545af17d8a46268bc1dd5623a47486e6ff76f4"
-  integrity sha512-vs9uybH9ORyK49CFjoNGN85HM9h5bmisU4TQ63phe/+GYlwvY/3SIFYKdjV6xNvzz8v2MnVC+9+QOkPqh+Q3Ew==
+lit-element@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.0.tgz#9c981c55dfd9a8f124dc863edb62cc529d434db7"
+  integrity sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==
   dependencies:
-    "@lit/reactive-element" "^1.0.0"
-    lit-html "^2.0.0"
+    "@lit/reactive-element" "^1.3.0"
+    lit-html "^2.2.0"
 
-lit-html@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.1.tgz#63241015efa07bc9259b6f96f04abd052d2a1f95"
-  integrity sha512-KF5znvFdXbxTYM/GjpdOOnMsjgRcFGusTnB54ixnCTya5zUR0XqrDRj29ybuLS+jLXv1jji6Y8+g4W7WP8uL4w==
+lit-html@^2.2.0:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.2.3.tgz#dcb2744d0f0c1800b2eb2de37bc42384434a74f7"
+  integrity sha512-vI4j3eWwtQaR8q/O63juZVliBIFMio716X719/lSsGH4UWPy2/7Qf377jsNs4cx3gCHgIbx8yxFgXFQ/igZyXQ==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-lit@2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
-  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
+lit@^2.2.3:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.2.3.tgz#77203d8f247de7c0d4955817f89e40c927349b9c"
+  integrity sha512-5/v+r9dH3Pw/o0rhp/qYk3ERvOUclNF31bWb0FiW6MPgwdQIr+/KCt/p3zcd8aPl8lIGnxdGrVcZA+gWS6oFOQ==
   dependencies:
-    "@lit/reactive-element" "^1.0.0"
-    lit-element "^3.0.0"
-    lit-html "^2.0.0"
+    "@lit/reactive-element" "^1.3.0"
+    lit-element "^3.2.0"
+    lit-html "^2.2.0"
 
 lru-cache@^6.0.0:
   version "6.0.0"
@@ -690,6 +725,11 @@
   dependencies:
     semver "^6.0.0"
 
+marked@~0.3.9:
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.19.tgz#5d47f709c4c9fc3c216b6d46127280f40b39d790"
+  integrity sha512-ea2eGWOqNxPcXv8dyERdSr/6FmzvWwzjMxpfGB/sbMccXoct+xY+YukPD+QTUZwyvK7BZwcr4m21WBOW41pAkg==
+
 mimic-response@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43"
@@ -847,6 +887,11 @@
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
+safevalues@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/safevalues/-/safevalues-0.3.1.tgz#610a910290930ac5f25ba77055cb8a819b0a15a9"
+  integrity sha512-sp++LhKx0CiDw9QGrYSavXCxQRIoZUBsupt2NbucztV5cLpO3zzAwww+LZS8L3dgGU0f5/zw3hymq3ltrVebNA==
+
 semver@^6.0.0:
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
@@ -948,6 +993,11 @@
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
 
+web-vitals@^2.1.4:
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.4.tgz#76563175a475a5e835264d373704f9dde718290c"
+  integrity sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==
+
 webidl-conversions@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
diff --git a/polygerrit-ui/grep-patch-karma.js b/polygerrit-ui/grep-patch-karma.js
index adf5171..ae0ff7f 100644
--- a/polygerrit-ui/grep-patch-karma.js
+++ b/polygerrit-ui/grep-patch-karma.js
@@ -1,18 +1,7 @@
 /**
  * @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.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 // The IntelliJ (and probably other IDEs) passes test names as a regexp in
@@ -23,21 +12,26 @@
 function installPatch(karma) {
   const originalKarmaStart = karma.start;
 
-  karma.start = function(config, ...args) {
-    const regexpGrepPrefix = '--grep=/';
-    const regexpGrepSuffix = '/';
+  karma.start = function (config, ...args) {
+    const regexpGrepPrefix = "--grep=/";
+    const regexpGrepSuffix = "/";
     if (config && config.args) {
       for (let i = 0; i < config.args.length; i++) {
         const arg = config.args[i];
-        if (arg.startsWith(regexpGrepPrefix) && arg.endsWith(regexpGrepSuffix)) {
-          const regexpText = arg.slice(regexpGrepPrefix.length, -regexpGrepPrefix.length);
-          config.args[i] = '--grep=' + regexpText;
+        if (
+          arg.startsWith(regexpGrepPrefix) &&
+          arg.endsWith(regexpGrepSuffix)
+        ) {
+          const regexpText = arg.slice(
+            regexpGrepPrefix.length,
+            -regexpGrepPrefix.length
+          );
+          config.args[i] = "--grep=" + regexpText;
         }
       }
     }
     originalKarmaStart.apply(this, [config, ...args]);
-  }
-
+  };
 }
 
 const karma = window.__karma__;
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index a3b694f..60fcea3 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -1,46 +1,34 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 
 const runUnderBazel = !!process.env["RUNFILES_DIR"];
-const path = require('path');
+const path = require("path");
 
 function getModulesDir() {
-  if(runUnderBazel) {
+  if (runUnderBazel) {
     // Run under bazel
     return [
       `external/plugins_npm/node_modules`,
       `external/ui_npm/node_modules`,
-      `external/ui_dev_npm/node_modules`
+      `external/ui_dev_npm/node_modules`,
     ];
   }
 
   // Run from intellij or npm run test:kdebug
   return [
-    path.join(__dirname, 'app/node_modules'),
-    path.join(__dirname, 'node_modules'),
+    path.join(__dirname, "app/node_modules"),
+    path.join(__dirname, "node_modules"),
   ];
 }
 
 function getUiDevNpmFilePath(importPath) {
-  if(runUnderBazel) {
+  if (runUnderBazel) {
     return `external/ui_dev_npm/node_modules/${importPath}`;
-  }
-  else {
-    return `polygerrit-ui/node_modules/${importPath}`
+  } else {
+    return `polygerrit-ui/node_modules/${importPath}`;
   }
 }
 
@@ -54,15 +42,17 @@
   // We want to increase browserNoActivityTimeout when tests run in IDE.
   // Wd don't want to increase it in other cases, oterhise hanging tests
   // can slow down CI.
-  return !runUnderBazel &&
-      process.argv.some(arg => arg.toLowerCase().contains('intellij'));
+  return (
+    !runUnderBazel &&
+    process.argv.some((arg) => arg.toLowerCase().contains("intellij"))
+  );
 }
 
-module.exports = function(config) {
+module.exports = function (config) {
   let root = config.root;
   if (!root) {
-    console.warn(`--root argument not set. Falling back to __dirname.`)
-    root = path.resolve(__dirname) + '/';
+    console.warn(`--root argument not set. Falling back to __dirname.`);
+    root = path.resolve(__dirname) + "/";
   }
   // Use --test-files to specify pattern for a test files.
   // It can be just a file name, without a path:
@@ -74,45 +64,58 @@
   // given.
   let filePattern;
   if (typeof config.testFiles === "string") {
-    if (config.testFiles.endsWith('.ts')) {
-      filePattern = config.testFiles.substr(0, config.testFiles.lastIndexOf(".")) + ".js";
-    } else if (config.testFiles.endsWith('.js')) {
+    if (config.testFiles.endsWith(".ts")) {
+      filePattern =
+        config.testFiles.substr(0, config.testFiles.lastIndexOf(".")) + ".js";
+    } else if (config.testFiles.endsWith(".js")) {
       filePattern = config.testFiles;
     } else {
-      filePattern = config.testFiles + '.js';
+      filePattern = config.testFiles + ".js";
     }
   } else {
-    filePattern = '*_test.js';
+    filePattern = "*_test.js";
   }
-  const testFilesPattern = root + '**/' + filePattern;
+  const testFilesPattern = root + "**/" + filePattern;
 
-  console.info(`Karma test file pattern: ${testFilesPattern}`)
+  console.info(`Karma test file pattern: ${testFilesPattern}`);
   // Special patch for grep parameters (see details in the grep-patch-karam.js)
-  const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
+  const additionalFiles = runUnderBazel
+    ? []
+    : ["polygerrit-ui/grep-patch-karma.js"];
   config.set({
     browserNoActivityTimeout: runInIde ? 60 * 60 * 1000 : 30 * 1000,
     // base path that will be used to resolve all patterns (eg. files, exclude)
-    basePath: '../',
+    basePath: "../",
     plugins: [
       // Do not use karma-* to load all installed plugin
       // This can lead to unexpected behavior under bazel
       // if you forget to add a plugin in a bazel rule.
-      require.resolve('@open-wc/karma-esm'),
-      'karma-mocha',
-      'karma-chrome-launcher',
-      'karma-mocha-reporter',
+      require.resolve("@open-wc/karma-esm"),
+      "karma-mocha",
+      "karma-chrome-launcher",
+      "karma-mocha-reporter",
     ],
     // frameworks to use
     // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
-    frameworks: ['mocha', 'esm'],
+    frameworks: ["mocha", "esm"],
 
     // list of files / patterns to load in the browser
     files: [
       ...additionalFiles,
-      getUiDevNpmFilePath('source-map-support/browser-source-map-support.js'),
-      getUiDevNpmFilePath('accessibility-developer-tools/dist/js/axs_testing.js'),
-      getUiDevNpmFilePath('sinon/pkg/sinon.js'),
-      { pattern: testFilesPattern, type: 'module' },
+      getUiDevNpmFilePath("source-map-support/browser-source-map-support.js"),
+      getUiDevNpmFilePath(
+        "accessibility-developer-tools/dist/js/axs_testing.js"
+      ),
+      {
+        pattern: getUiDevNpmFilePath("@open-wc/semantic-dom-diff/index.js"),
+        type: "module",
+      },
+      {
+        pattern: getUiDevNpmFilePath("@open-wc/testing-helpers/index.js"),
+        type: "module",
+      },
+      getUiDevNpmFilePath("sinon/pkg/sinon.js"),
+      { pattern: testFilesPattern, type: "module" },
     ],
     esm: {
       nodeResolve: {
@@ -121,7 +124,7 @@
         // in node resolve.
         // The .ts extension is required to display source code in browser
         // (otherwise esm plugin crashes)
-        extensions: ['.js', '.ts'],
+        extensions: [".js", ".ts"],
       },
       moduleDirs: getModulesDir(),
       // Bazel and yarn uses symlinks for files.
@@ -131,7 +134,7 @@
       // In the 'auto' mode it incorrectly applies polyfills and
       // breaks tests in some browser versions
       // (for example, Chrome 69 on gerrit-ci).
-      compatibility: 'none',
+      compatibility: "none",
       plugins: [
         {
           resolveImport(importSpecifier) {
@@ -149,62 +152,69 @@
               return importSpecifier.source;
             }
             return undefined;
-          }
+          },
         },
         {
           transform(context) {
-            if (context.path.endsWith('/node_modules/page/page.js')) {
+            if (context.path.endsWith("/node_modules/page/page.js")) {
               const orignalBody = context.body;
               // Can't import page.js directly, because this is undefined.
               // Replace it with window
               // The same replace exists in server.go
               // Rollup makes this replacement automatically
               const transformedBody = orignalBody.replace(
-                  '}(this, (function () { \'use strict\';',
-                  '}(window, (function () { \'use strict\';'
+                "}(this, (function () { 'use strict';",
+                "}(window, (function () { 'use strict';"
               );
-              if(orignalBody.length === transformedBody.length) {
-                console.error('The page.js was updated. Please update transform accordingly');
+              if (orignalBody.length === transformedBody.length) {
+                console.error(
+                  "The page.js was updated. Please update transform accordingly"
+                );
                 process.exit(1);
               }
-              return {body: transformedBody};
+              return { body: transformedBody };
             }
           },
-        }
-      ]
+        },
+      ],
     },
     // test results reporter to use
     // possible values: 'dots', 'progress'
     // available reporters: https://npmjs.org/browse/keyword/karma-reporter
-    reporters: ['mocha'],
+    reporters: ["mocha"],
 
     mochaReporter: {
-      showDiff: true
+      showDiff: true,
     },
 
+    // Listen on localhost so it either listens to ipv4
+    // or ipv6. Some OS's default to ipv6 for localhost
+    // and others ipv4.
+    // Nodejs 17 changed the behaviour from prefering ipv4 to
+    // using the OS settings.
+    // The default is 127.0.0.1 thus if localhost is on ipv6 only
+    // it'll fail to connect to the karma server.
+    // See https://github.com/karma-runner/karma/blob/e17698f950af83bf2b3edc540d2a3e1fb73cba59/lib/utils/dns-utils.js#L3
+    listenAddress: "localhost",
+
     // web server port
     port: 9876,
 
-
     // enable / disable colors in the output (reporters and logs)
     colors: true,
 
-
     // level of logging
     // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
     logLevel: config.LOG_INFO,
 
-
     // enable / disable watching file and executing tests whenever any file changes
     autoWatch: false,
 
-
     // start these browsers
     // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
     browsers: ["CustomChromeHeadless"],
     browserForDebugging: "CustomChromeHeadlessWithDebugPort",
 
-
     // Continuous Integration mode
     // if true, Karma captures browsers, runs the tests and exits
     singleRun: true,
@@ -215,25 +225,25 @@
 
     client: {
       mocha: {
-        ui: 'tdd',
+        ui: "tdd",
         timeout: 5000,
-      }
+      },
     },
 
     customLaunchers: {
       // Based on https://developers.google.com/web/updates/2017/06/headless-karma-mocha-chai
-      "CustomChromeHeadless": {
-        base: 'ChromeHeadless',
-        flags: ['--disable-translate', '--disable-extensions'],
+      CustomChromeHeadless: {
+        base: "ChromeHeadless",
+        flags: ["--disable-translate", "--disable-extensions"],
       },
-      "ChromeDev": {
-        base: 'Chrome',
-        flags: ['--disable-extensions', ' --auto-open-devtools-for-tabs'],
+      ChromeDev: {
+        base: "Chrome",
+        flags: ["--disable-extensions", " --auto-open-devtools-for-tabs"],
       },
-      "CustomChromeHeadlessWithDebugPort": {
-        base: 'CustomChromeHeadless',
-        flags: ['--remote-debugging-port=9222'],
-      }
-    }
+      CustomChromeHeadlessWithDebugPort: {
+        base: "CustomChromeHeadless",
+        flags: ["--remote-debugging-port=9222"],
+      },
+    },
   });
 };
diff --git a/polygerrit-ui/karma_test.sh b/polygerrit-ui/karma_test.sh
deleted file mode 100755
index 940b969..0000000
--- a/polygerrit-ui/karma_test.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/bin/bash
-
-set -euo pipefail
-./$1 start $2 \
-  --root 'polygerrit-ui/app/_pg_with_tests_out/**/' \
-  --test-files '*_test.js'
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 25561bb..6fa4d0f 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -3,26 +3,35 @@
   "description": "Gerrit Code Review - Polygerrit dev dependencies",
   "browser": true,
   "dependencies": {
-    "@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",
+    "@open-wc/karma-esm": "^3.0.9",
+    "@open-wc/semantic-dom-diff": "^0.19.5",
+    "@open-wc/testing": "^3.1.6",
+    "@web/dev-server-esbuild": "^0.3.2",
+    "@web/test-runner": "^0.14.0",
+    "@web/test-runner-playwright": "^0.9.0",
+    "@web/test-runner-visual-regression": "^0.6.6",
     "accessibility-developer-tools": "^2.12.0",
-    "chai": "^4.3.4",
-    "karma": "^6.3.6",
-    "karma-chrome-launcher": "^3.1.0",
+    "karma": "^6.3.20",
+    "karma-chrome-launcher": "^3.1.1",
     "karma-mocha": "^2.0.1",
     "karma-mocha-reporter": "^2.2.5",
-    "lodash": "^4.17.21",
-    "mocha": "8.3.2",
-    "sinon": "^10.0.0",
+    "mocha": "9.2.2",
+    "sinon": "^13.0.0",
     "source-map-support": "^0.5.19"
   },
+  "scripts": {
+    "test": "web-test-runner",
+    "test:screenshot": "web-test-runner --run-screenshots",
+    "test:screenshot-update": "web-test-runner --update-screenshots --files",
+    "test:browsers": "web-test-runner --playwright --browsers webkit firefox chromium",
+    "test:coverage": "web-test-runner --coverage",
+    "test:watch": "web-test-runner --watch",
+    "test:single": "web-test-runner --watch --files",
+    "test:single:coverage": "web-test-runner --watch --coverage --files"
+  },
   "license": "Apache-2.0",
   "private": true
 }
diff --git a/polygerrit-ui/run-server.sh b/polygerrit-ui/run-server.sh
deleted file mode 100755
index 62e1453..0000000
--- a/polygerrit-ui/run-server.sh
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/usr/bin/env bash
-# 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.
-
-bazel_bin=$(which bazelisk 2>/dev/null)
-if [[ -z "$bazel_bin" ]]; then
-    echo "Warning: bazelisk is not installed; falling back to bazel."
-    bazel_bin=bazel
-fi
-
-set -eu
-SCRIPTNAME=$(mktemp)
-trap "{ rm -f $SCRIPTNAME; }" EXIT
-${bazel_bin} run --script_path="$SCRIPTNAME" //polygerrit-ui:devserver
-"$SCRIPTNAME" "$@"
diff --git a/polygerrit-ui/screenshots/Chrome/baseline/gr-file-list.png b/polygerrit-ui/screenshots/Chrome/baseline/gr-file-list.png
new file mode 100644
index 0000000..4d0cbed
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chrome/baseline/gr-file-list.png
Binary files differ
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
deleted file mode 100644
index 2a433fb..0000000
--- a/polygerrit-ui/server.go
+++ /dev/null
@@ -1,646 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package main
-
-import (
-	"archive/zip"
-	"bufio"
-	"bytes"
-	"compress/gzip"
-	"encoding/json"
-	"errors"
-	"flag"
-	"io"
-	"io/ioutil"
-	"log"
-	"net"
-	"net/http"
-	"net/url"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"regexp"
-	"strings"
-	"sync"
-	"time"
-
-	"golang.org/x/tools/godoc/vfs/httpfs"
-	"golang.org/x/tools/godoc/vfs/zipfs"
-)
-
-var (
-	plugins    = flag.String("plugins", "", "comma seperated plugin paths to serve")
-	port       = flag.String("port", "localhost:8081", "address to serve HTTP requests on")
-	host       = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
-	scheme     = flag.String("scheme", "https", "URL scheme")
-	cdnPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
-)
-
-func main() {
-	flag.Parse()
-
-	fontsArchive, err := openDataArchive("fonts.zip")
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	workspace := os.Getenv("BUILD_WORKSPACE_DIRECTORY")
-	if err := os.Chdir(filepath.Join(workspace, "polygerrit-ui")); err != nil {
-		log.Fatal(err)
-	}
-
-	compiledSrcPath := filepath.Join(workspace, "./.ts-out/server-go")
-
-	tsInstance := newTypescriptInstance(
-		filepath.Join(workspace, "./node_modules/.bin/tsc"),
-		filepath.Join(workspace, "./polygerrit-ui/app/tsconfig.json"),
-		compiledSrcPath,
-	)
-
-	if err := tsInstance.StartWatch(); err != nil {
-		log.Fatal(err)
-	}
-
-	dirListingMux := http.NewServeMux()
-	dirListingMux.Handle("/styles/", http.StripPrefix("/styles/", http.FileServer(http.Dir("app/styles"))))
-	dirListingMux.Handle("/samples/", http.StripPrefix("/samples/", http.FileServer(http.Dir("app/samples"))))
-	dirListingMux.Handle("/elements/", http.StripPrefix("/elements/", http.FileServer(http.Dir("app/elements"))))
-	dirListingMux.Handle("/behaviors/", http.StripPrefix("/behaviors/", http.FileServer(http.Dir("app/behaviors"))))
-
-	http.HandleFunc("/",
-		func(w http.ResponseWriter, req *http.Request) {
-			// If typescript compiler hasn't finished yet, wait for it
-			tsInstance.WaitForCompilationComplete()
-			handleSrcRequest(compiledSrcPath, dirListingMux, w, req)
-		})
-
-	http.Handle("/fonts/",
-		addDevHeadersMiddleware(http.FileServer(httpfs.New(zipfs.New(fontsArchive, "fonts")))))
-
-	http.HandleFunc("/index.html", handleIndex)
-	http.HandleFunc("/changes/", handleProxy)
-	http.HandleFunc("/accounts/", handleProxy)
-	http.HandleFunc("/config/", handleProxy)
-	http.HandleFunc("/projects/", handleProxy)
-	http.HandleFunc("/static/", handleProxy)
-	http.HandleFunc("/accounts/self/detail", handleAccountDetail)
-
-	if len(*plugins) > 0 {
-		http.Handle("/plugins/", http.StripPrefix("/plugins/",
-			http.FileServer(http.Dir("../plugins"))))
-		log.Println("Local plugins from", "../plugins")
-	} else {
-		http.HandleFunc("/plugins/", handleProxy)
-		// Serve local plugins from `plugins_`
-		http.Handle("/plugins_/", http.StripPrefix("/plugins_/",
-			http.FileServer(http.Dir("../plugins"))))
-	}
-	log.Println("Serving on port", *port)
-	log.Fatal(http.ListenAndServe(*port, &server{}))
-}
-
-func addDevHeadersMiddleware(h http.Handler) http.Handler {
-	return http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) {
-		addDevHeaders(writer)
-		h.ServeHTTP(writer, req)
-	})
-}
-
-func addDevHeaders(writer http.ResponseWriter) {
-	writer.Header().Set("Access-Control-Allow-Origin", "*")
-	writer.Header().Set("Access-Control-Allow-Headers", "cache-control,x-test-origin")
-	writer.Header().Set("Cache-Control", "public, max-age=10, must-revalidate")
-}
-
-func handleSrcRequest(compiledSrcPath string, dirListingMux *http.ServeMux, writer http.ResponseWriter, originalRequest *http.Request) {
-	parsedUrl, err := url.Parse(originalRequest.RequestURI)
-	if err != nil {
-		writer.WriteHeader(500)
-		return
-	}
-	if parsedUrl.Path != "/" && strings.HasSuffix(parsedUrl.Path, "/") {
-		dirListingMux.ServeHTTP(writer, originalRequest)
-		return
-	}
-
-	normalizedContentPath := parsedUrl.Path
-
-	if !strings.HasPrefix(normalizedContentPath, "/") {
-		normalizedContentPath = "/" + normalizedContentPath
-	}
-
-	isJsFile := strings.HasSuffix(normalizedContentPath, ".js") || strings.HasSuffix(normalizedContentPath, ".mjs")
-	isTsFile := strings.HasSuffix(normalizedContentPath, ".ts")
-
-	// Source map in a compiled js file point to a file inside /app/... directory
-	// Browser tries to load original file from the directory when debugger is
-	// activated. In this case we return original content without any processing
-	isOriginalFileRequest := strings.HasPrefix(normalizedContentPath, "/polygerrit-ui/app/") && (isTsFile || isJsFile)
-
-	data, err := getContent(compiledSrcPath, normalizedContentPath, isOriginalFileRequest)
-	if err != nil {
-		if !isOriginalFileRequest {
-			data, err = getContent(compiledSrcPath, normalizedContentPath+".js", false)
-		}
-		if err != nil {
-			writer.WriteHeader(404)
-			return
-		}
-		isJsFile = true
-	}
-	if isOriginalFileRequest {
-		// Explicitly set text/html Content-Type. If live code tries
-		// to import javascript from the /app/ folder accidentally, browser fails
-		// with the import error, so we can catch this problem easily.
-		writer.Header().Set("Content-Type", "text/html")
-	} else if isJsFile {
-		// import ... from '@polymer/decorators'
-		// must be transformed into
-		// import ... from '@polymer/decorators/lib/decorators.js'
-		// The correct way to do it is to use value of the "main" property
-		// from the @polymer/decorators/package.json. However, parsing package.json
-		// is overcomplicated right now, hard-code exact path here.
-		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'@polymer/decorators';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '@polymer/decorators/lib/decorators.js';"))
-
-		// The following code updates import statements.
-		// 1. if an in imported file has .js or .mjs extension, the code keeps
-		//	  the file extension unchanged. Otherwise, it adds .js extension
-		// 2. For module imports it adds '/node_modules/' prefix.
-		//   Examples:
-		//   '@polymer/polymer.js' -> '/node_modules/@polymer/polymer.js'
-		//   'page/page.mjs' -> '/node_modules/page.mjs'
-		//   '@polymer/iron-icon' -> '/node_modules/@polymer/iron-icon.js'
-		//   './element/file' -> './element/file.js'
-		moduleImportRegexp = regexp.MustCompile(`(import[^'";]*|export[^'";]*from ?)['"]([^;\s]*?)(\.(m?)js)?['"];`)
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'$2.${4}js';"))
-
-		moduleImportRegexp = regexp.MustCompile(`(import[^'";]*|export[^'";]*from ?)['"]([^/.;\s][^;\s]*)['"];`)
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'/node_modules/$2';"))
-
-		// The es module version of rxjs can be found in the _esm2015/ directory.
-		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.js' has to be resolved as 'lit/index.js'.
-		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)lit.js';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}lit/index.js';"))
-		// Some lit imports 'a.js' have to be resolved as 'a/a.js'.
-		moduleImportRegexp = regexp.MustCompile(`((import|export)[^'";]*'/node_modules/(@lit/)?)(lit-element|lit-html|reactive-element).js';`)
-		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}${4}/${4}.js';"))
-
-		// 'immer' imports and exports have to be resolved to 'immer/dist/immer.esm.js'.
-		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)immer.js';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}/immer/dist/immer.esm.js';"))
-
-		if strings.HasSuffix(normalizedContentPath, "/node_modules/page/page.js") {
-			// Can't import page.js directly, because this is undefined.
-			// Replace it with window
-			// The same replace exists in karma.conf.js
-			// Rollup makes this replacement automatically
-			pageJsRegexp := regexp.MustCompile(`(?m)^}\(this, \(function \(\) { 'use strict';$`)
-			newData := pageJsRegexp.ReplaceAll(data, []byte("}(window, (function () { 'use strict';"))
-			if len(newData) == len(data) {
-				log.Fatal("The page.js was updated. Please update regexp/replace accordingly")
-			}
-			data = newData
-		}
-
-		writer.Header().Set("Content-Type", "application/javascript")
-	} else if strings.HasSuffix(normalizedContentPath, ".css") {
-		writer.Header().Set("Content-Type", "text/css")
-	} else if strings.HasSuffix(normalizedContentPath, "_test.html") {
-		moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'([^/.].*)';$")
-		data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2';"))
-		writer.Header().Set("Content-Type", "text/html")
-	} else if strings.HasSuffix(normalizedContentPath, ".html") {
-		writer.Header().Set("Content-Type", "text/html")
-	}
-	writer.WriteHeader(200)
-	addDevHeaders(writer)
-	writer.Write(data)
-}
-
-func getContent(compiledSrcPath string, normalizedContentPath string, isOriginalFileRequest bool) ([]byte, error) {
-	// normalizedContentPath must always starts with '/'
-
-	if isOriginalFileRequest {
-		data, err := ioutil.ReadFile(normalizedContentPath[len("/polygerrit-ui/"):])
-		if err != nil {
-			return nil, errors.New("File not found")
-		}
-		return data, nil
-	}
-
-	// gerrit loads gr-app.js as an ordinary script, without type="module" attribute.
-	// If server.go serves this file as is, browser shows the error:
-	// Uncaught SyntaxError: Cannot use import statement outside a module
-	//
-	// To load non-bundled gr-app.js as a module, we "virtually" renames original
-	// gr-app.js to gr-app.mjs and load it with dynamic import.
-	//
-	// Another option is to patch rewriteHostPage function and add type="module" attribute
-	// to <script src=".../elements/gr-app.js"> tag, but this solution is incompatible
-	// with --dev-cdn options. If you run local gerrit instance with --dev-cdn parameter,
-	// the server.go is used as cdn and it doesn't handle host page (i.e. rewriteHostPage
-	// method is not called).
-	if normalizedContentPath == "/elements/gr-app.js" {
-		return []byte("import('./gr-app.mjs')"), nil
-	}
-
-	if normalizedContentPath == "/elements/gr-app.mjs" {
-		normalizedContentPath = "/elements/gr-app.js"
-	}
-
-	pathsToTry := []string{compiledSrcPath + normalizedContentPath, "app" + normalizedContentPath}
-	bowerComponentsSuffix := "/bower_components/"
-	nodeModulesPrefix := "/node_modules/"
-	testComponentsPrefix := "/components/"
-
-	if strings.HasPrefix(normalizedContentPath, testComponentsPrefix) {
-		pathsToTry = append(pathsToTry, "node_modules/wct-browser-legacy/node_modules/"+normalizedContentPath[len(testComponentsPrefix):])
-		pathsToTry = append(pathsToTry, "node_modules/"+normalizedContentPath[len(testComponentsPrefix):])
-	}
-
-	if strings.HasPrefix(normalizedContentPath, bowerComponentsSuffix) {
-		pathsToTry = append(pathsToTry, "node_modules/@webcomponents/"+normalizedContentPath[len(bowerComponentsSuffix):])
-	}
-
-	if strings.HasPrefix(normalizedContentPath, nodeModulesPrefix) {
-		pathsToTry = append(pathsToTry, "node_modules/"+normalizedContentPath[len(nodeModulesPrefix):])
-	}
-
-	for _, path := range pathsToTry {
-		data, err := ioutil.ReadFile(path)
-		if err == nil {
-			return data, nil
-		}
-	}
-
-	return nil, errors.New("File not found")
-}
-
-func openDataArchive(path string) (*zip.ReadCloser, error) {
-	absBinPath, err := resourceBasePath()
-	if err != nil {
-		return nil, err
-	}
-	return zip.OpenReader(absBinPath + ".runfiles/gerrit/polygerrit-ui/" + path)
-}
-
-func resourceBasePath() (string, error) {
-	return filepath.Abs(os.Args[0])
-}
-
-func handleIndex(writer http.ResponseWriter, originalRequest *http.Request) {
-	fakeRequest := &http.Request{
-		URL: &url.URL{
-			Path:     "/",
-			RawQuery: originalRequest.URL.RawQuery,
-		},
-	}
-	handleProxy(writer, fakeRequest)
-}
-
-func handleProxy(writer http.ResponseWriter, originalRequest *http.Request) {
-	patchedRequest := &http.Request{
-		Method: "GET",
-		URL: &url.URL{
-			Scheme:   *scheme,
-			Host:     *host,
-			Opaque:   originalRequest.URL.EscapedPath(),
-			RawQuery: originalRequest.URL.RawQuery,
-		},
-	}
-	response, err := http.DefaultClient.Do(patchedRequest)
-	if err != nil {
-		http.Error(writer, err.Error(), http.StatusInternalServerError)
-		return
-	}
-	defer response.Body.Close()
-	for name, values := range response.Header {
-		for _, value := range values {
-			if name != "Content-Length" {
-				writer.Header().Add(name, value)
-			}
-		}
-	}
-	writer.WriteHeader(response.StatusCode)
-	if _, err := io.Copy(writer, patchResponse(originalRequest, response)); err != nil {
-		log.Println("Error copying response to ResponseWriter:", err)
-		return
-	}
-}
-
-func getJsonPropByPath(json map[string]interface{}, path []string) interface{} {
-	prop, path := path[0], path[1:]
-	if json[prop] == nil {
-		return nil
-	}
-	switch json[prop].(type) {
-	case map[string]interface{}: // map
-		return getJsonPropByPath(json[prop].(map[string]interface{}), path)
-	case []interface{}: // array
-		return json[prop].([]interface{})
-	default:
-		return json[prop].(interface{})
-	}
-}
-
-func setJsonPropByPath(json map[string]interface{}, path []string, value interface{}) {
-	prop, path := path[0], path[1:]
-	if json[prop] == nil {
-		return // path not found
-	}
-	if len(path) > 0 {
-		setJsonPropByPath(json[prop].(map[string]interface{}), path, value)
-	} else {
-		json[prop] = value
-	}
-}
-
-func patchResponse(req *http.Request, res *http.Response) io.Reader {
-	switch req.URL.EscapedPath() {
-	case "/":
-		return rewriteHostPage(res.Body)
-	case "/config/server/info":
-		return injectLocalPlugins(res.Body)
-	default:
-		return res.Body
-	}
-}
-
-func rewriteHostPage(reader io.Reader) io.Reader {
-	buf := new(bytes.Buffer)
-	buf.ReadFrom(reader)
-	original := buf.String()
-
-	// Simply remove all CDN references, so files are loaded from the local file system  or the proxy
-	// server instead.
-	replaced := cdnPattern.ReplaceAllString(original, "")
-
-	// Modify window.INITIAL_DATA so that it has the same effect as injectLocalPlugins. To achieve
-	// this let's add JavaScript lines at the end of the <script>...</script> snippet that also
-	// contains window.INITIAL_DATA=...
-	// Here we rely on the fact that the <script> snippet that we want to append to is the first one.
-	if len(*plugins) > 0 {
-		insertionPoint := strings.Index(replaced, "</script>")
-		builder := new(strings.Builder)
-		builder.WriteString(
-			"window.INITIAL_DATA['/config/server/info'].plugin.js_resource_paths = []; ")
-		for _, p := range strings.Split(*plugins, ",") {
-			if filepath.Ext(p) == ".js" {
-				builder.WriteString(
-					"window.INITIAL_DATA['/config/server/info'].plugin.js_resource_paths.push('" + p + "'); ")
-			}
-		}
-		replaced = replaced[:insertionPoint] + builder.String() + replaced[insertionPoint:]
-	}
-
-	return strings.NewReader(replaced)
-}
-
-func injectLocalPlugins(reader io.Reader) io.Reader {
-	if len(*plugins) == 0 {
-		return reader
-	}
-	// Skip escape prefix
-	io.CopyN(ioutil.Discard, reader, 5)
-	dec := json.NewDecoder(reader)
-
-	var response map[string]interface{}
-	err := dec.Decode(&response)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	// Configuration path in the JSON server response
-	jsPluginsPath := []string{"plugin", "js_resource_paths"}
-	jsResources := getJsonPropByPath(response, jsPluginsPath).([]interface{})
-
-	for _, p := range strings.Split(*plugins, ",") {
-		if filepath.Ext(p) == ".js" {
-			jsResources = append(jsResources, p)
-		}
-	}
-
-	setJsonPropByPath(response, jsPluginsPath, jsResources)
-
-	reader, writer := io.Pipe()
-	go func() {
-		defer writer.Close()
-		io.WriteString(writer, ")]}'") // Write escape prefix
-		err := json.NewEncoder(writer).Encode(&response)
-		if err != nil {
-			log.Fatal(err)
-		}
-	}()
-	return reader
-}
-
-func handleAccountDetail(w http.ResponseWriter, r *http.Request) {
-	http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-}
-
-type gzipResponseWriter struct {
-	io.WriteCloser
-	http.ResponseWriter
-}
-
-func newGzipResponseWriter(w http.ResponseWriter) *gzipResponseWriter {
-	gz := gzip.NewWriter(w)
-	return &gzipResponseWriter{WriteCloser: gz, ResponseWriter: w}
-}
-
-func (w gzipResponseWriter) Write(b []byte) (int, error) {
-	return w.WriteCloser.Write(b)
-}
-
-func (w gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
-	h, ok := w.ResponseWriter.(http.Hijacker)
-	if !ok {
-		return nil, nil, errors.New("gzipResponseWriter: ResponseWriter does not satisfy http.Hijacker interface")
-	}
-	return h.Hijack()
-}
-
-type server struct{}
-
-// Any path prefixes that should resolve to index.html.
-var (
-	fePaths    = []string{"/q/", "/c/", "/id/", "/p/", "/x/", "/dashboard/", "/admin/", "/settings/"}
-	issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
-)
-
-func (_ *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	log.Printf("%s %s %s %s\n", r.Proto, r.Method, r.RemoteAddr, r.URL)
-	for _, prefix := range fePaths {
-		if strings.HasPrefix(r.URL.Path, prefix) || r.URL.Path == "/" {
-			r.URL.Path = "/index.html"
-			log.Println("Redirecting to /index.html")
-			break
-		} else if match := issueNumRE.Find([]byte(r.URL.Path)); match != nil {
-			r.URL.Path = "/index.html"
-			log.Println("Redirecting to /index.html")
-			break
-		}
-	}
-	if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
-		http.DefaultServeMux.ServeHTTP(w, r)
-		return
-	}
-	w.Header().Set("Content-Encoding", "gzip")
-	addDevHeaders(w)
-	gzw := newGzipResponseWriter(w)
-	defer gzw.Close()
-	http.DefaultServeMux.ServeHTTP(gzw, r)
-}
-
-// Typescript compiler support
-// The code below runs typescript compiler in watch mode and redirect
-// all output from the compiler to the standard logger with the prefix "TSC -"
-// Additionally, the code analyzes messages produced by the typescript compiler
-// and allows to wait until compilation is finished.
-var (
-	tsStartingCompilation   = "- Starting compilation in watch mode..."
-	tsFileChangeDetectedMsg = "- File change detected. Starting incremental compilation..."
-	// If there is only one error typescript outputs:
-	// Found 1 error
-	// In all other cases it outputs
-	// Found X errors
-	tsStartWatchingMsg        = regexp.MustCompile(`^.* - Found \d+ error(s)?\. Watching for file changes\.$`)
-	waitForNextChangeInterval = 1 * time.Second
-)
-
-// typescriptLogWriter implements Writer interface and receives output
-// (stdout and stderr) from the typescript compiler. It reads incoming
-// data line-by-line, analyzes each line and updates compilationDoneWaiter
-// according to the current compiler state. Additionally, the
-// typescriptLogWriter passes all incoming lines to the underlying logger.
-type typescriptLogWriter struct {
-	// unfinishedLine stores the portion of line which was partially received
-	// (i.e. all text received after the last EOL (\n) mark.
-	unfinishedLine string
-	// logger is used to pass-through all received strings
-	logger *log.Logger
-	// when WaitGroup counter is 0 the compilation is complete
-	compilationDoneWaiter *sync.WaitGroup
-}
-
-func newTypescriptLogWriter(compilationCompleteWaiter *sync.WaitGroup) *typescriptLogWriter {
-	return &typescriptLogWriter{
-		logger:                log.New(log.Writer(), "TSC - ", log.Flags()),
-		compilationDoneWaiter: compilationCompleteWaiter,
-	}
-}
-
-func (lw typescriptLogWriter) Write(p []byte) (n int, err error) {
-	// The input p can contain several lines and/or the partial line
-	// Code splits the input by EOL marker (\n) and stores the unfinished line
-	// for the next call to Write.
-	partialText := lw.unfinishedLine + string(p)
-	lines := strings.Split(partialText, "\n")
-	fullLines := lines
-	if strings.HasSuffix(partialText, "\n") {
-		lw.unfinishedLine = ""
-	} else {
-		fullLines = lines[:len(lines)-1]
-		lw.unfinishedLine = lines[len(lines)-1]
-	}
-	for _, fullLine := range fullLines {
-		text := strings.TrimSpace(fullLine)
-		if text == "" {
-			continue
-		}
-		if strings.HasSuffix(text, tsFileChangeDetectedMsg) ||
-			strings.HasSuffix(text, tsStartingCompilation) {
-			lw.compilationDoneWaiter.Add(1)
-		}
-		if tsStartWatchingMsg.MatchString(text) {
-			// A source code can be changed while previous compiler run is in progress.
-			// In this case typescript reruns compilation again almost immediately
-			// after the previous run finishes. To detect this situation, we are
-			// waiting waitForNextChangeInterval before decreasing the counter.
-			// If another compiler run is started in this interval, we will wait
-			// again until it finishes.
-			go func() {
-				time.Sleep(waitForNextChangeInterval)
-				lw.compilationDoneWaiter.Done()
-			}()
-		}
-		lw.logger.Print(text)
-	}
-	return len(p), nil
-}
-
-type typescriptInstance struct {
-	cmd                       *exec.Cmd
-	compilationCompleteWaiter *sync.WaitGroup
-}
-
-func newTypescriptInstance(tscBinaryPath string, projectPath string, outdir string) *typescriptInstance {
-	cmd := exec.Command(tscBinaryPath,
-		"--watch",
-		"--preserveWatchOutput",
-		"--project",
-		projectPath,
-		"--outDir",
-		outdir)
-
-	compilationCompleteWaiter := &sync.WaitGroup{}
-	logWriter := newTypescriptLogWriter(compilationCompleteWaiter)
-	// Note 1: (from https://golang.org/pkg/os/exec/#Cmd)
-	// If Stdout and Stderr are the same writer, and have a type that can
-	// be compared with ==, at most one goroutine at a time will call Write.
-	//
-	// Note 2: The typescript compiler reports all compilation errors to
-	// stdout by design (see https://github.com/microsoft/TypeScript/issues/615)
-	// It writes to stderr only when something unexpected happens (like internal
-	// exceptions). To print such errors in the same way as standard typescript
-	// error, the same logWriter is used both for Stdout and Stderr.
-	//
-	// If Stderr arrives in the middle of ordinary typescript output (i.e.
-	// something unexpected happens), the server.go can stop respond to http
-	// requests. However, this is not a problem for us: typescript compiler and
-	// server.go must be restarted anyway.
-	cmd.Stdout = logWriter
-	cmd.Stderr = logWriter
-
-	return &typescriptInstance{
-		cmd:                       cmd,
-		compilationCompleteWaiter: compilationCompleteWaiter,
-	}
-}
-
-func (ts *typescriptInstance) StartWatch() error {
-	err := ts.cmd.Start()
-	if err != nil {
-		return err
-	}
-	go func() {
-		ts.cmd.Wait()
-		log.Fatal("Typescript exits unexpected")
-	}()
-
-	return nil
-}
-
-func (ts *typescriptInstance) WaitForCompilationComplete() {
-	ts.compilationCompleteWaiter.Wait()
-}
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs
new file mode 100644
index 0000000..552e609
--- /dev/null
+++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -0,0 +1,61 @@
+import { esbuildPlugin } from "@web/dev-server-esbuild";
+import { defaultReporter, summaryReporter } from "@web/test-runner";
+import { visualRegressionPlugin } from "@web/test-runner-visual-regression/plugin";
+
+/** @type {import('@web/test-runner').TestRunnerConfig} */
+const config = {
+  files: [
+    "app/**/*_test.{ts,js}",
+    "!**/node_modules/**/*",
+    ...(process.argv.includes("--run-screenshots")
+      ? []
+      : ["!app/**/*_screenshot_test.{ts,js}"]),
+  ],
+  port: 9876,
+  nodeResolve: true,
+  testFramework: { config: { ui: "tdd", timeout: 5000 } },
+  plugins: [
+    esbuildPlugin({
+      ts: true,
+      target: "es2020",
+      tsconfig: "app/tsconfig.json",
+    }),
+    visualRegressionPlugin({
+      diffOptions: {
+        threshold: 0.8,
+      },
+      update: process.argv.includes("--update-screenshots"),
+    }),
+  ],
+  // serve from gerrit root directory so that we can serve fonts from
+  // /lib/fonts/, see middleware.
+  rootDir: "..",
+  reporters: [defaultReporter(), summaryReporter()],
+  middleware: [
+    // Fonts are in /lib/fonts/, but css tries to load from
+    // /polygerrit-ui/app/fonts/. In production this works because our build
+    // copies them over, see /polygerrit-ui/BUILD
+    async (context, next) => {
+      if (context.url.startsWith("/polygerrit-ui/app/fonts/")) {
+        context.url = context.url.replace("/polygerrit-ui/app/", "/lib/");
+      }
+      await next();
+    },
+  ],
+  testRunnerHtml: (testFramework) => `
+    <!DOCTYPE html>
+    <html>
+      <head>
+        <link rel="stylesheet" href="polygerrit-ui/app/styles/main.css">
+        <link rel="stylesheet" href="polygerrit-ui/app/styles/fonts.css">
+        <link
+          rel="stylesheet"
+          href="polygerrit-ui/app/styles/material-icons.css">
+      </head>
+      <body>
+        <script type="module" src="${testFramework}"></script>
+      </body>
+    </html>
+  `,
+};
+export default config;
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index e9c54e9..35409a8 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -2,396 +2,436 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
-  integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==
+"@ampproject/remapping@^2.1.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d"
+  integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==
   dependencies:
-    "@babel/highlight" "^7.14.5"
+    "@jridgewell/gen-mapping" "^0.1.0"
+    "@jridgewell/trace-mapping" "^0.3.9"
 
-"@babel/compat-data@^7.13.11", "@babel/compat-data@^7.14.7", "@babel/compat-data@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.15.0.tgz#2dbaf8b85334796cafbb0f5793a90a2fc010b176"
-  integrity sha512-0NqAC1IJE0S0+lL1SWFMxMkz1pKCNCjI4tr2Zx4LJSXxCLAdr6KyArnY+sno5m3yH9g737ygOyPABDsnXkpxiA==
+"@babel/code-frame@^7.12.11":
+  version "7.16.0"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.0.tgz#0dfc80309beec8411e65e706461c408b0bb9b431"
+  integrity sha512-IF4EOMEV+bfYwOmNxGzSnjR2EmQod7f1UXOpZM3l4i4o4QNwzjtJAu/HxdjHq0aYBvdqMuQEY1eg0nqW9ZPORA==
+  dependencies:
+    "@babel/highlight" "^7.16.0"
+
+"@babel/code-frame@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
+  integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
+  dependencies:
+    "@babel/highlight" "^7.18.6"
+
+"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8", "@babel/compat-data@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.0.tgz#2a592fd89bacb1fcde68de31bee4f2f2dacb0e86"
+  integrity sha512-y5rqgTTPTmaF5e2nVhOxw+Ur9HDJLsWb6U/KpgUzRZEdPfE6VOubXBKLdbcUTijzRptednSBDQbYZBOSqJxpJw==
 
 "@babel/core@^7.11.1":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.15.0.tgz#749e57c68778b73ad8082775561f67f5196aafa8"
-  integrity sha512-tXtmTminrze5HEUPn/a0JtOzzfp0nk+UEXQ/tqIJo3WDGypl/2OFQEMll/zSFU8f/lfmfLXvTaORHF3cfXIQMw==
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.0.tgz#d2f5f4f2033c00de8096be3c9f45772563e150c3"
+  integrity sha512-reM4+U7B9ss148rh2n1Qs9ASS+w94irYXga7c2jaQv9RVzpS7Mv1a9rnYYwuDa45G+DkORt9g6An2k/V4d9LbQ==
   dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/generator" "^7.15.0"
-    "@babel/helper-compilation-targets" "^7.15.0"
-    "@babel/helper-module-transforms" "^7.15.0"
-    "@babel/helpers" "^7.14.8"
-    "@babel/parser" "^7.15.0"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@ampproject/remapping" "^2.1.0"
+    "@babel/code-frame" "^7.18.6"
+    "@babel/generator" "^7.19.0"
+    "@babel/helper-compilation-targets" "^7.19.0"
+    "@babel/helper-module-transforms" "^7.19.0"
+    "@babel/helpers" "^7.19.0"
+    "@babel/parser" "^7.19.0"
+    "@babel/template" "^7.18.10"
+    "@babel/traverse" "^7.19.0"
+    "@babel/types" "^7.19.0"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.2"
-    json5 "^2.1.2"
+    json5 "^2.2.1"
     semver "^6.3.0"
-    source-map "^0.5.0"
 
-"@babel/generator@^7.15.0", "@babel/generator@^7.4.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.15.0.tgz#a7d0c172e0d814974bad5aa77ace543b97917f15"
-  integrity sha512-eKl4XdMrbpYvuB505KTta4AV9g+wWzmVBW69tX0H2NwKVKd2YJbKgyK6M8j/rgLbmHOYJn6rUklV677nOyJrEQ==
+"@babel/generator@^7.19.0", "@babel/generator@^7.4.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.0.tgz#785596c06425e59334df2ccee63ab166b738419a"
+  integrity sha512-S1ahxf1gZ2dpoiFgA+ohK9DIpz50bJ0CWs7Zlzb54Z4sG8qmdIrGrVqmy1sAtTVRb+9CU6U8VqT9L0Zj7hxHVg==
   dependencies:
-    "@babel/types" "^7.15.0"
+    "@babel/types" "^7.19.0"
+    "@jridgewell/gen-mapping" "^0.3.2"
     jsesc "^2.5.1"
-    source-map "^0.5.0"
 
-"@babel/helper-annotate-as-pure@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz#7bf478ec3b71726d56a8ca5775b046fc29879e61"
-  integrity sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==
+"@babel/helper-annotate-as-pure@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb"
+  integrity sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.14.5.tgz#b939b43f8c37765443a19ae74ad8b15978e0a191"
-  integrity sha512-YTA/Twn0vBXDVGJuAX6PwW7x5zQei1luDDo2Pl6q1qZ7hVNl0RZrhHCQG/ArGpR29Vl7ETiB8eJyrvpuRp300w==
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.18.6":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz#acd4edfd7a566d1d51ea975dff38fd52906981bb"
+  integrity sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==
   dependencies:
-    "@babel/helper-explode-assignable-expression" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/helper-explode-assignable-expression" "^7.18.6"
+    "@babel/types" "^7.18.9"
 
-"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.14.5", "@babel/helper-compilation-targets@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.15.0.tgz#973df8cbd025515f3ff25db0c05efc704fa79818"
-  integrity sha512-h+/9t0ncd4jfZ8wsdAsoIxSa61qhBYlycXiHWqJaQBCXAhDCMbPRSMTGnZIkkmt1u4ag+UQmuqcILwqKzZ4N2A==
+"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.0.tgz#537ec8339d53e806ed422f1e06c8f17d55b96bb0"
+  integrity sha512-Ai5bNWXIvwDvWM7njqsG3feMlL9hCVQsPYXodsZyLwshYkZVJt59Gftau4VrE8S9IT9asd2uSP1hG6wCNw+sXA==
   dependencies:
-    "@babel/compat-data" "^7.15.0"
-    "@babel/helper-validator-option" "^7.14.5"
-    browserslist "^4.16.6"
+    "@babel/compat-data" "^7.19.0"
+    "@babel/helper-validator-option" "^7.18.6"
+    browserslist "^4.20.2"
     semver "^6.3.0"
 
-"@babel/helper-create-class-features-plugin@^7.14.5":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.15.0.tgz#c9a137a4d137b2d0e2c649acf536d7ba1a76c0f7"
-  integrity sha512-MdmDXgvTIi4heDVX/e9EFfeGpugqm9fobBVg/iioE8kueXrOHdRDe36FAY7SnE9xXLVeYCoJR/gdrBEIHRC83Q==
+"@babel/helper-create-class-features-plugin@^7.18.6":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz#bfd6904620df4e46470bae4850d66be1054c404b"
+  integrity sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-member-expression-to-functions" "^7.15.0"
-    "@babel/helper-optimise-call-expression" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.15.0"
-    "@babel/helper-split-export-declaration" "^7.14.5"
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-function-name" "^7.19.0"
+    "@babel/helper-member-expression-to-functions" "^7.18.9"
+    "@babel/helper-optimise-call-expression" "^7.18.6"
+    "@babel/helper-replace-supers" "^7.18.9"
+    "@babel/helper-split-export-declaration" "^7.18.6"
 
-"@babel/helper-create-regexp-features-plugin@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.14.5.tgz#c7d5ac5e9cf621c26057722fb7a8a4c5889358c4"
-  integrity sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A==
+"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz#7976aca61c0984202baca73d84e2337a5424a41b"
+  integrity sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    regexpu-core "^4.7.1"
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    regexpu-core "^5.1.0"
 
-"@babel/helper-define-polyfill-provider@^0.2.2":
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.2.3.tgz#0525edec5094653a282688d34d846e4c75e9c0b6"
-  integrity sha512-RH3QDAfRMzj7+0Nqu5oqgO5q9mFtQEVvCRsi8qCEfzLR9p2BHfn5FzhSB2oj1fF7I2+DcTORkYaQ6aTR9Cofew==
+"@babel/helper-define-polyfill-provider@^0.3.2", "@babel/helper-define-polyfill-provider@^0.3.3":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz#8612e55be5d51f0cd1f36b4a5a83924e89884b7a"
+  integrity sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==
   dependencies:
-    "@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"
+    "@babel/helper-compilation-targets" "^7.17.7"
+    "@babel/helper-plugin-utils" "^7.16.7"
     debug "^4.1.1"
     lodash.debounce "^4.0.8"
     resolve "^1.14.2"
     semver "^6.1.2"
 
-"@babel/helper-explode-assignable-expression@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.14.5.tgz#8aa72e708205c7bb643e45c73b4386cdf2a1f645"
-  integrity sha512-Htb24gnGJdIGT4vnRKMdoXiOIlqOLmdiUYpAQ0mYfgVT/GDm8GOYhgi4GL+hMKrkiPRohO4ts34ELFsGAPQLDQ==
+"@babel/helper-environment-visitor@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be"
+  integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==
+
+"@babel/helper-explode-assignable-expression@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz#41f8228ef0a6f1a036b8dfdfec7ce94f9a6bc096"
+  integrity sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-function-name@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz#89e2c474972f15d8e233b52ee8c480e2cfcd50c4"
-  integrity sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==
+"@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c"
+  integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==
   dependencies:
-    "@babel/helper-get-function-arity" "^7.14.5"
-    "@babel/template" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/template" "^7.18.10"
+    "@babel/types" "^7.19.0"
 
-"@babel/helper-get-function-arity@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815"
-  integrity sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==
+"@babel/helper-hoist-variables@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678"
+  integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-hoist-variables@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d"
-  integrity sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==
+"@babel/helper-member-expression-to-functions@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815"
+  integrity sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.18.9"
 
-"@babel/helper-member-expression-to-functions@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.15.0.tgz#0ddaf5299c8179f27f37327936553e9bba60990b"
-  integrity sha512-Jq8H8U2kYiafuj2xMTPQwkTBnEEdGKpT35lJEQsRRjnG0LW3neucsaMWLgKcwu3OHKNeYugfw+Z20BXBSEs2Lg==
+"@babel/helper-module-imports@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e"
+  integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==
   dependencies:
-    "@babel/types" "^7.15.0"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3"
-  integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==
+"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz#309b230f04e22c58c6a2c0c0c7e50b216d350c30"
+  integrity sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-module-imports" "^7.18.6"
+    "@babel/helper-simple-access" "^7.18.6"
+    "@babel/helper-split-export-declaration" "^7.18.6"
+    "@babel/helper-validator-identifier" "^7.18.6"
+    "@babel/template" "^7.18.10"
+    "@babel/traverse" "^7.19.0"
+    "@babel/types" "^7.19.0"
 
-"@babel/helper-module-transforms@^7.14.5", "@babel/helper-module-transforms@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.15.0.tgz#679275581ea056373eddbe360e1419ef23783b08"
-  integrity sha512-RkGiW5Rer7fpXv9m1B3iHIFDZdItnO2/BLfWVW/9q7+KqQSDY5kUfQEbzdXM1MVhJGcugKV7kRrNVzNxmk7NBg==
+"@babel/helper-optimise-call-expression@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe"
+  integrity sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==
   dependencies:
-    "@babel/helper-module-imports" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.15.0"
-    "@babel/helper-simple-access" "^7.14.8"
-    "@babel/helper-split-export-declaration" "^7.14.5"
-    "@babel/helper-validator-identifier" "^7.14.9"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-optimise-call-expression@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz#f27395a8619e0665b3f0364cddb41c25d71b499c"
-  integrity sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==
+"@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.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz#4796bb14961521f0f8715990bee2fb6e51ce21bf"
+  integrity sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==
+
+"@babel/helper-remap-async-to-generator@^7.18.6", "@babel/helper-remap-async-to-generator@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz#997458a0e3357080e54e1d79ec347f8a8cd28519"
+  integrity sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-wrap-function" "^7.18.9"
+    "@babel/types" "^7.18.9"
 
-"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9"
-  integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==
-
-"@babel/helper-remap-async-to-generator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.14.5.tgz#51439c913612958f54a987a4ffc9ee587a2045d6"
-  integrity sha512-rLQKdQU+HYlxBwQIj8dk4/0ENOUEhA/Z0l4hN8BexpvmSMN9oA9EagjnhnDpNsRdWCfjwa4mn/HyBXO9yhQP6A==
+"@babel/helper-replace-supers@^7.18.6", "@babel/helper-replace-supers@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.9.tgz#1092e002feca980fbbb0bd4d51b74a65c6a500e6"
+  integrity sha512-dNsWibVI4lNT6HiuOIBr1oyxo40HvIVmbwPUm3XZ7wMh4k2WxrxTqZwSqw/eEmXDS9np0ey5M2bz9tBmO9c+YQ==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-wrap-function" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-member-expression-to-functions" "^7.18.9"
+    "@babel/helper-optimise-call-expression" "^7.18.6"
+    "@babel/traverse" "^7.18.9"
+    "@babel/types" "^7.18.9"
 
-"@babel/helper-replace-supers@^7.14.5", "@babel/helper-replace-supers@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.15.0.tgz#ace07708f5bf746bf2e6ba99572cce79b5d4e7f4"
-  integrity sha512-6O+eWrhx+HEra/uJnifCwhwMd6Bp5+ZfZeJwbqUTuqkhIT6YcRhiZCOOFChRypOIe0cV46kFrRBlm+t5vHCEaA==
+"@babel/helper-simple-access@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea"
+  integrity sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==
   dependencies:
-    "@babel/helper-member-expression-to-functions" "^7.15.0"
-    "@babel/helper-optimise-call-expression" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-simple-access@^7.14.8":
-  version "7.14.8"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.14.8.tgz#82e1fec0644a7e775c74d305f212c39f8fe73924"
-  integrity sha512-TrFN4RHh9gnWEU+s7JloIho2T76GPwRHhdzOWLqTrMnlas8T9O7ec+oEDNsRXndOmru9ymH9DFrEOxpzPoSbdg==
+"@babel/helper-skip-transparent-expression-wrappers@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz#778d87b3a758d90b471e7b9918f34a9a02eb5818"
+  integrity sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==
   dependencies:
-    "@babel/types" "^7.14.8"
+    "@babel/types" "^7.18.9"
 
-"@babel/helper-skip-transparent-expression-wrappers@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.14.5.tgz#96f486ac050ca9f44b009fbe5b7d394cab3a0ee4"
-  integrity sha512-dmqZB7mrb94PZSAOYtr+ZN5qt5owZIAgqtoTuqiFbHFtxgEcmQlRJVI+bO++fciBunXtB6MK7HrzrfcAzIz2NQ==
+"@babel/helper-split-export-declaration@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075"
+  integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.18.6"
 
-"@babel/helper-split-export-declaration@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz#22b23a54ef51c2b7605d851930c1976dd0bc693a"
-  integrity sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==
+"@babel/helper-string-parser@^7.18.10":
+  version "7.18.10"
+  resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56"
+  integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==
+
+"@babel/helper-validator-identifier@^7.15.7":
+  version "7.15.7"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz#220df993bfe904a4a6b02ab4f3385a5ebf6e2389"
+  integrity sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w==
+
+"@babel/helper-validator-identifier@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
+  integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
+
+"@babel/helper-validator-option@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8"
+  integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==
+
+"@babel/helper-wrap-function@^7.18.9":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz#89f18335cff1152373222f76a4b37799636ae8b1"
+  integrity sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/helper-function-name" "^7.19.0"
+    "@babel/template" "^7.18.10"
+    "@babel/traverse" "^7.19.0"
+    "@babel/types" "^7.19.0"
 
-"@babel/helper-validator-identifier@^7.14.5", "@babel/helper-validator-identifier@^7.14.9":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48"
-  integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==
-
-"@babel/helper-validator-option@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3"
-  integrity sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==
-
-"@babel/helper-wrap-function@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.14.5.tgz#5919d115bf0fe328b8a5d63bcb610f51601f2bff"
-  integrity sha512-YEdjTCq+LNuNS1WfxsDCNpgXkJaIyqco6DAelTUjT4f2KIWC1nBcaCaSdHTBqQVLnTBexBcVcFhLSU1KnYuePQ==
+"@babel/helpers@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.19.0.tgz#f30534657faf246ae96551d88dd31e9d1fa1fc18"
+  integrity sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg==
   dependencies:
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/template" "^7.18.10"
+    "@babel/traverse" "^7.19.0"
+    "@babel/types" "^7.19.0"
 
-"@babel/helpers@^7.14.8":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.15.3.tgz#c96838b752b95dcd525b4e741ed40bb1dc2a1357"
-  integrity sha512-HwJiz52XaS96lX+28Tnbu31VeFSQJGOeKHJeaEPQlTl7PnlhFElWPj8tUXtqFIzeN86XxXoBr+WFAyK2PPVz6g==
+"@babel/highlight@^7.16.0":
+  version "7.16.0"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.16.0.tgz#6ceb32b2ca4b8f5f361fb7fd821e3fddf4a1725a"
+  integrity sha512-t8MH41kUQylBtu2+4IQA3atqevA2lRgqA2wyVB/YiWmsDSuylZZuXOUy9ric30hfzauEFfdsuk/eXTRrGrfd0g==
   dependencies:
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
-
-"@babel/highlight@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9"
-  integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==
-  dependencies:
-    "@babel/helper-validator-identifier" "^7.14.5"
+    "@babel/helper-validator-identifier" "^7.15.7"
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.1.0", "@babel/parser@^7.14.5", "@babel/parser@^7.15.0", "@babel/parser@^7.4.3":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.3.tgz#3416d9bea748052cfcb63dbcc27368105b1ed862"
-  integrity sha512-O0L6v/HvqbdJawj0iBEfVQMc3/6WP+AeOsovsIgBFyJaG+W2w7eqvZB7puddATmWuARlm1SX7DwxJ/JJUnDpEA==
-
-"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.14.5.tgz#4b467302e1548ed3b1be43beae2cc9cf45e0bb7e"
-  integrity sha512-ZoJS2XCKPBfTmL122iP6NM9dOg+d4lc9fFk3zxc8iDjvt8Pk4+TlsHSKhIPf6X+L5ORCdBzqMZDjL/WHj7WknQ==
+"@babel/highlight@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf"
+  integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
-    "@babel/plugin-proposal-optional-chaining" "^7.14.5"
+    "@babel/helper-validator-identifier" "^7.18.6"
+    chalk "^2.0.0"
+    js-tokens "^4.0.0"
 
-"@babel/plugin-proposal-async-generator-functions@^7.14.9":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.14.9.tgz#7028dc4fa21dc199bbacf98b39bab1267d0eaf9a"
-  integrity sha512-d1lnh+ZnKrFKwtTYdw320+sQWCTwgkB9fmUhNXRADA4akR6wLjaruSGnIEUjpt9HCOwTr4ynFTKu19b7rFRpmw==
+"@babel/parser@^7.1.0", "@babel/parser@^7.18.10", "@babel/parser@^7.19.0", "@babel/parser@^7.4.3":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.0.tgz#497fcafb1d5b61376959c1c338745ef0577aa02c"
+  integrity sha512-74bEXKX2h+8rrfQUfsBfuZZHzsEs6Eql4pqy/T4Nn6Y9wNPggQOqD6z6pn5Bl8ZfysKouFZT/UXEH94ummEeQw==
+
+"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2"
+  integrity sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-remap-async-to-generator" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
+
+"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz#a11af19aa373d68d561f08e0a57242350ed0ec50"
+  integrity sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9"
+    "@babel/plugin-proposal-optional-chaining" "^7.18.9"
+
+"@babel/plugin-proposal-async-generator-functions@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.0.tgz#cf5740194f170467df20581712400487efc79ff1"
+  integrity sha512-nhEByMUTx3uZueJ/QkJuSlCfN4FGg+xy+vRsfGQGzSauq5ks2Deid2+05Q3KhfaUjvec1IGhw/Zm3cFm8JigTQ==
+  dependencies:
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-remap-async-to-generator" "^7.18.9"
     "@babel/plugin-syntax-async-generators" "^7.8.4"
 
-"@babel/plugin-proposal-class-properties@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.14.5.tgz#40d1ee140c5b1e31a350f4f5eed945096559b42e"
-  integrity sha512-q/PLpv5Ko4dVc1LYMpCY7RVAAO4uk55qPwrIuJ5QJ8c6cVuAmhu7I/49JOppXL6gXf7ZHzpRVEUZdYoPLM04Gg==
+"@babel/plugin-proposal-class-properties@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3"
+  integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-class-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-proposal-class-static-block@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.14.5.tgz#158e9e10d449c3849ef3ecde94a03d9f1841b681"
-  integrity sha512-KBAH5ksEnYHCegqseI5N9skTdxgJdmDoAOc0uXa+4QMYKeZD0w5IARh4FMlTNtaHhbB8v+KzMdTgxMMzsIy6Yg==
+"@babel/plugin-proposal-class-static-block@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz#8aa81d403ab72d3962fc06c26e222dacfc9b9020"
+  integrity sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-class-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-class-static-block" "^7.14.5"
 
-"@babel/plugin-proposal-dynamic-import@^7.10.4", "@babel/plugin-proposal-dynamic-import@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.14.5.tgz#0c6617df461c0c1f8fff3b47cd59772360101d2c"
-  integrity sha512-ExjiNYc3HDN5PXJx+bwC50GIx/KKanX2HiggnIUAYedbARdImiCU4RhhHfdf0Kd7JNXGpsBBBCOm+bBVy3Gb0g==
+"@babel/plugin-proposal-dynamic-import@^7.10.4", "@babel/plugin-proposal-dynamic-import@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz#72bcf8d408799f547d759298c3c27c7e7faa4d94"
+  integrity sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
 
-"@babel/plugin-proposal-export-namespace-from@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.14.5.tgz#dbad244310ce6ccd083072167d8cea83a52faf76"
-  integrity sha512-g5POA32bXPMmSBu5Dx/iZGLGnKmKPc5AiY7qfZgurzrCYgIztDlHFbznSNCoQuv57YQLnQfaDi7dxCtLDIdXdA==
+"@babel/plugin-proposal-export-namespace-from@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz#5f7313ab348cdb19d590145f9247540e94761203"
+  integrity sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
     "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
 
-"@babel/plugin-proposal-json-strings@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.14.5.tgz#38de60db362e83a3d8c944ac858ddf9f0c2239eb"
-  integrity sha512-NSq2fczJYKVRIsUJyNxrVUMhB27zb7N7pOFGQOhBKJrChbGcgEAqyZrmZswkPk18VMurEeJAaICbfm57vUeTbQ==
+"@babel/plugin-proposal-json-strings@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz#7e8788c1811c393aff762817e7dbf1ebd0c05f0b"
+  integrity sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-json-strings" "^7.8.3"
 
-"@babel/plugin-proposal-logical-assignment-operators@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.14.5.tgz#6e6229c2a99b02ab2915f82571e0cc646a40c738"
-  integrity sha512-YGn2AvZAo9TwyhlLvCCWxD90Xq8xJ4aSgaX3G5D/8DW94L8aaT+dS5cSP+Z06+rCJERGSr9GxMBZ601xoc2taw==
+"@babel/plugin-proposal-logical-assignment-operators@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz#8148cbb350483bf6220af06fa6db3690e14b2e23"
+  integrity sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
     "@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.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.14.5.tgz#ee38589ce00e2cc59b299ec3ea406fcd3a0fdaf6"
-  integrity sha512-gun/SOnMqjSb98Nkaq2rTKMwervfdAoz6NphdY0vTfuzMfryj+tDGb2n6UkDKwez+Y8PZDhE3D143v6Gepp4Hg==
+"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz#fdd940a99a740e577d6c753ab6fbb43fdb9467e1"
+  integrity sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
 
-"@babel/plugin-proposal-numeric-separator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.14.5.tgz#83631bf33d9a51df184c2102a069ac0c58c05f18"
-  integrity sha512-yiclALKe0vyZRZE0pS6RXgjUOt87GWv6FYa5zqj15PvhOGFO69R5DusPlgK/1K5dVnCtegTiWu9UaBSrLLJJBg==
+"@babel/plugin-proposal-numeric-separator@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz#899b14fbafe87f053d2c5ff05b36029c62e13c75"
+  integrity sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-numeric-separator" "^7.10.4"
 
-"@babel/plugin-proposal-object-rest-spread@^7.14.7":
-  version "7.14.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.14.7.tgz#5920a2b3df7f7901df0205974c0641b13fd9d363"
-  integrity sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==
+"@babel/plugin-proposal-object-rest-spread@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz#f9434f6beb2c8cae9dfcf97d2a5941bbbf9ad4e7"
+  integrity sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q==
   dependencies:
-    "@babel/compat-data" "^7.14.7"
-    "@babel/helper-compilation-targets" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/compat-data" "^7.18.8"
+    "@babel/helper-compilation-targets" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.18.9"
     "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
-    "@babel/plugin-transform-parameters" "^7.14.5"
+    "@babel/plugin-transform-parameters" "^7.18.8"
 
-"@babel/plugin-proposal-optional-catch-binding@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.14.5.tgz#939dd6eddeff3a67fdf7b3f044b5347262598c3c"
-  integrity sha512-3Oyiixm0ur7bzO5ybNcZFlmVsygSIQgdOa7cTfOYCMY+wEPAYhZAJxi3mixKFCTCKUhQXuCTtQ1MzrpL3WT8ZQ==
+"@babel/plugin-proposal-optional-catch-binding@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz#f9400d0e6a3ea93ba9ef70b09e72dd6da638a2cb"
+  integrity sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
 
-"@babel/plugin-proposal-optional-chaining@^7.11.0", "@babel/plugin-proposal-optional-chaining@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.14.5.tgz#fa83651e60a360e3f13797eef00b8d519695b603"
-  integrity sha512-ycz+VOzo2UbWNI1rQXxIuMOzrDdHGrI23fRiz/Si2R4kv2XZQ1BK8ccdHwehMKBlcH/joGW/tzrUmo67gbJHlQ==
+"@babel/plugin-proposal-optional-chaining@^7.11.0", "@babel/plugin-proposal-optional-chaining@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz#e8e8fe0723f2563960e4bf5e9690933691915993"
+  integrity sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9"
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
 
-"@babel/plugin-proposal-private-methods@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.14.5.tgz#37446495996b2945f30f5be5b60d5e2aa4f5792d"
-  integrity sha512-838DkdUA1u+QTCplatfq4B7+1lnDa/+QMI89x5WZHBcnNv+47N8QEj2k9I2MUU9xIv8XJ4XvPCviM/Dj7Uwt9g==
+"@babel/plugin-proposal-private-methods@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz#5209de7d213457548a98436fa2882f52f4be6bea"
+  integrity sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-class-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-proposal-private-property-in-object@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.14.5.tgz#9f65a4d0493a940b4c01f8aa9d3f1894a587f636"
-  integrity sha512-62EyfyA3WA0mZiF2e2IV9mc9Ghwxcg8YTu8BS4Wss4Y3PY725OmS9M0qLORbJwLqFtGh+jiE4wAmocK2CTUK2Q==
+"@babel/plugin-proposal-private-property-in-object@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz#a64137b232f0aca3733a67eb1a144c192389c503"
+  integrity sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-create-class-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    "@babel/helper-create-class-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
 
-"@babel/plugin-proposal-unicode-property-regex@^7.14.5", "@babel/plugin-proposal-unicode-property-regex@^7.4.4":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.14.5.tgz#0f95ee0e757a5d647f378daa0eca7e93faa8bbe8"
-  integrity sha512-6axIeOU5LnY471KenAB9vI8I5j7NQ2d652hIYwVyRfgaZT5UpiqFKCuVXCDMSrU+3VFafnu2c5m3lrWIlr6A5Q==
+"@babel/plugin-proposal-unicode-property-regex@^7.18.6", "@babel/plugin-proposal-unicode-property-regex@^7.4.4":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz#af613d2cd5e643643b65cded64207b15c85cb78e"
+  integrity sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
 "@babel/plugin-syntax-async-generators@^7.8.4":
   version "7.8.4"
@@ -428,6 +468,13 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.3"
 
+"@babel/plugin-syntax-import-assertions@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz#cd6190500a4fa2fe31990a963ffab4b63e4505e4"
+  integrity sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.18.6"
+
 "@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"
@@ -498,284 +545,291 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-arrow-functions@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.14.5.tgz#f7187d9588a768dd080bf4c9ffe117ea62f7862a"
-  integrity sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A==
+"@babel/plugin-transform-arrow-functions@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz#19063fcf8771ec7b31d742339dac62433d0611fe"
+  integrity sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-async-to-generator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.14.5.tgz#72c789084d8f2094acb945633943ef8443d39e67"
-  integrity sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA==
+"@babel/plugin-transform-async-to-generator@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz#ccda3d1ab9d5ced5265fdb13f1882d5476c71615"
+  integrity sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==
   dependencies:
-    "@babel/helper-module-imports" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-remap-async-to-generator" "^7.14.5"
+    "@babel/helper-module-imports" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-remap-async-to-generator" "^7.18.6"
 
-"@babel/plugin-transform-block-scoped-functions@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.14.5.tgz#e48641d999d4bc157a67ef336aeb54bc44fd3ad4"
-  integrity sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ==
+"@babel/plugin-transform-block-scoped-functions@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz#9187bf4ba302635b9d70d986ad70f038726216a8"
+  integrity sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-block-scoping@^7.14.5":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.15.3.tgz#94c81a6e2fc230bcce6ef537ac96a1e4d2b3afaf"
-  integrity sha512-nBAzfZwZb4DkaGtOes1Up1nOAp9TDRRFw4XBzBBSG9QK7KVFmYzgj9o9sbPv7TX5ofL4Auq4wZnxCoPnI/lz2Q==
+"@babel/plugin-transform-block-scoping@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz#f9b7e018ac3f373c81452d6ada8bd5a18928926d"
+  integrity sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-classes@^7.14.9":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.14.9.tgz#2a391ffb1e5292710b00f2e2c210e1435e7d449f"
-  integrity sha512-NfZpTcxU3foGWbl4wxmZ35mTsYJy8oQocbeIMoDAGGFarAmSQlL+LWMkDx/tj6pNotpbX3rltIA4dprgAPOq5A==
+"@babel/plugin-transform-classes@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz#0e61ec257fba409c41372175e7c1e606dc79bb20"
+  integrity sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-optimise-call-expression" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.14.5"
-    "@babel/helper-split-export-declaration" "^7.14.5"
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    "@babel/helper-compilation-targets" "^7.19.0"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-function-name" "^7.19.0"
+    "@babel/helper-optimise-call-expression" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-replace-supers" "^7.18.9"
+    "@babel/helper-split-export-declaration" "^7.18.6"
     globals "^11.1.0"
 
-"@babel/plugin-transform-computed-properties@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.14.5.tgz#1b9d78987420d11223d41195461cc43b974b204f"
-  integrity sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg==
+"@babel/plugin-transform-computed-properties@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz#2357a8224d402dad623caf6259b611e56aec746e"
+  integrity sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-destructuring@^7.14.7":
-  version "7.14.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.14.7.tgz#0ad58ed37e23e22084d109f185260835e5557576"
-  integrity sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw==
+"@babel/plugin-transform-destructuring@^7.18.13":
+  version "7.18.13"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz#9e03bc4a94475d62b7f4114938e6c5c33372cbf5"
+  integrity sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-dotall-regex@^7.14.5", "@babel/plugin-transform-dotall-regex@^7.4.4":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.14.5.tgz#2f6bf76e46bdf8043b4e7e16cf24532629ba0c7a"
-  integrity sha512-loGlnBdj02MDsFaHhAIJzh7euK89lBrGIdM9EAtHFo6xKygCUGuuWe07o1oZVk287amtW1n0808sQM99aZt3gw==
+"@babel/plugin-transform-dotall-regex@^7.18.6", "@babel/plugin-transform-dotall-regex@^7.4.4":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz#b286b3e7aae6c7b861e45bed0a2fafd6b1a4fef8"
+  integrity sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-duplicate-keys@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.14.5.tgz#365a4844881bdf1501e3a9f0270e7f0f91177954"
-  integrity sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A==
+"@babel/plugin-transform-duplicate-keys@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz#687f15ee3cdad6d85191eb2a372c4528eaa0ae0e"
+  integrity sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-exponentiation-operator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.14.5.tgz#5154b8dd6a3dfe6d90923d61724bd3deeb90b493"
-  integrity sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==
+"@babel/plugin-transform-exponentiation-operator@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz#421c705f4521888c65e91fdd1af951bfefd4dacd"
+  integrity sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==
   dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-for-of@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.14.5.tgz#dae384613de8f77c196a8869cbf602a44f7fc0eb"
-  integrity sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA==
+"@babel/plugin-transform-for-of@^7.18.8":
+  version "7.18.8"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz#6ef8a50b244eb6a0bdbad0c7c61877e4e30097c1"
+  integrity sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-function-name@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.14.5.tgz#e81c65ecb900746d7f31802f6bed1f52d915d6f2"
-  integrity sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ==
+"@babel/plugin-transform-function-name@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz#cc354f8234e62968946c61a46d6365440fc764e0"
+  integrity sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==
   dependencies:
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-compilation-targets" "^7.18.9"
+    "@babel/helper-function-name" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-literals@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.14.5.tgz#41d06c7ff5d4d09e3cf4587bd3ecf3930c730f78"
-  integrity sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A==
+"@babel/plugin-transform-literals@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz#72796fdbef80e56fba3c6a699d54f0de557444bc"
+  integrity sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-member-expression-literals@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.14.5.tgz#b39cd5212a2bf235a617d320ec2b48bcc091b8a7"
-  integrity sha512-WkNXxH1VXVTKarWFqmso83xl+2V3Eo28YY5utIkbsmXoItO8Q3aZxN4BTS2k0hz9dGUloHK26mJMyQEYfkn/+Q==
+"@babel/plugin-transform-member-expression-literals@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz#ac9fdc1a118620ac49b7e7a5d2dc177a1bfee88e"
+  integrity sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-modules-amd@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.14.5.tgz#4fd9ce7e3411cb8b83848480b7041d83004858f7"
-  integrity sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g==
+"@babel/plugin-transform-modules-amd@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz#8c91f8c5115d2202f277549848874027d7172d21"
+  integrity sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg==
   dependencies:
-    "@babel/helper-module-transforms" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-module-transforms" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
     babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-commonjs@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.15.0.tgz#3305896e5835f953b5cdb363acd9e8c2219a5281"
-  integrity sha512-3H/R9s8cXcOGE8kgMlmjYYC9nqr5ELiPkJn4q0mypBrjhYQoc+5/Maq69vV4xRPWnkzZuwJPf5rArxpB/35Cig==
+"@babel/plugin-transform-modules-commonjs@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz#afd243afba166cca69892e24a8fd8c9f2ca87883"
+  integrity sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q==
   dependencies:
-    "@babel/helper-module-transforms" "^7.15.0"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-simple-access" "^7.14.8"
+    "@babel/helper-module-transforms" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-simple-access" "^7.18.6"
     babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-systemjs@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.14.5.tgz#c75342ef8b30dcde4295d3401aae24e65638ed29"
-  integrity sha512-mNMQdvBEE5DcMQaL5LbzXFMANrQjd2W7FPzg34Y4yEz7dBgdaC+9B84dSO+/1Wba98zoDbInctCDo4JGxz1VYA==
+"@babel/plugin-transform-modules-systemjs@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz#5f20b471284430f02d9c5059d9b9a16d4b085a1f"
+  integrity sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A==
   dependencies:
-    "@babel/helper-hoist-variables" "^7.14.5"
-    "@babel/helper-module-transforms" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-validator-identifier" "^7.14.5"
+    "@babel/helper-hoist-variables" "^7.18.6"
+    "@babel/helper-module-transforms" "^7.19.0"
+    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-validator-identifier" "^7.18.6"
     babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-umd@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.14.5.tgz#fb662dfee697cce274a7cda525190a79096aa6e0"
-  integrity sha512-RfPGoagSngC06LsGUYyM9QWSXZ8MysEjDJTAea1lqRjNECE3y0qIJF/qbvJxc4oA4s99HumIMdXOrd+TdKaAAA==
+"@babel/plugin-transform-modules-umd@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz#81d3832d6034b75b54e62821ba58f28ed0aab4b9"
+  integrity sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==
   dependencies:
-    "@babel/helper-module-transforms" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-module-transforms" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-named-capturing-groups-regex@^7.14.9":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.14.9.tgz#c68f5c5d12d2ebaba3762e57c2c4f6347a46e7b2"
-  integrity sha512-l666wCVYO75mlAtGFfyFwnWmIXQm3kSH0C3IRnJqWcZbWkoihyAdDhFm2ZWaxWTqvBvhVFfJjMRQ0ez4oN1yYA==
+"@babel/plugin-transform-named-capturing-groups-regex@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.0.tgz#58c52422e4f91a381727faed7d513c89d7f41ada"
+  integrity sha512-HDSuqOQzkU//kfGdiHBt71/hkDTApw4U/cMVgKgX7PqfB3LOaK+2GtCEsBu1dL9CkswDm0Gwehht1dCr421ULQ==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.19.0"
+    "@babel/helper-plugin-utils" "^7.19.0"
 
-"@babel/plugin-transform-new-target@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.14.5.tgz#31bdae8b925dc84076ebfcd2a9940143aed7dbf8"
-  integrity sha512-Nx054zovz6IIRWEB49RDRuXGI4Gy0GMgqG0cII9L3MxqgXz/+rgII+RU58qpo4g7tNEx1jG7rRVH4ihZoP4esQ==
+"@babel/plugin-transform-new-target@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz#d128f376ae200477f37c4ddfcc722a8a1b3246a8"
+  integrity sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-object-super@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.14.5.tgz#d0b5faeac9e98597a161a9cf78c527ed934cdc45"
-  integrity sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg==
+"@babel/plugin-transform-object-super@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz#fb3c6ccdd15939b6ff7939944b51971ddc35912c"
+  integrity sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-replace-supers" "^7.18.6"
 
-"@babel/plugin-transform-parameters@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.14.5.tgz#49662e86a1f3ddccac6363a7dfb1ff0a158afeb3"
-  integrity sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==
+"@babel/plugin-transform-parameters@^7.18.8":
+  version "7.18.8"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz#ee9f1a0ce6d78af58d0956a9378ea3427cccb48a"
+  integrity sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-property-literals@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.14.5.tgz#0ddbaa1f83db3606f1cdf4846fa1dfb473458b34"
-  integrity sha512-r1uilDthkgXW8Z1vJz2dKYLV1tuw2xsbrp3MrZmD99Wh9vsfKoob+JTgri5VUb/JqyKRXotlOtwgu4stIYCmnw==
+"@babel/plugin-transform-property-literals@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz#e22498903a483448e94e032e9bbb9c5ccbfc93a3"
+  integrity sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-regenerator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.14.5.tgz#9676fd5707ed28f522727c5b3c0aa8544440b04f"
-  integrity sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg==
+"@babel/plugin-transform-regenerator@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz#585c66cb84d4b4bf72519a34cfce761b8676ca73"
+  integrity sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==
   dependencies:
-    regenerator-transform "^0.14.2"
+    "@babel/helper-plugin-utils" "^7.18.6"
+    regenerator-transform "^0.15.0"
 
-"@babel/plugin-transform-reserved-words@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.14.5.tgz#c44589b661cfdbef8d4300dcc7469dffa92f8304"
-  integrity sha512-cv4F2rv1nD4qdexOGsRQXJrOcyb5CrgjUH9PKrrtyhSDBNWGxd0UIitjyJiWagS+EbUGjG++22mGH1Pub8D6Vg==
+"@babel/plugin-transform-reserved-words@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz#b1abd8ebf8edaa5f7fe6bbb8d2133d23b6a6f76a"
+  integrity sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-shorthand-properties@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.14.5.tgz#97f13855f1409338d8cadcbaca670ad79e091a58"
-  integrity sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g==
+"@babel/plugin-transform-shorthand-properties@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz#6d6df7983d67b195289be24909e3f12a8f664dc9"
+  integrity sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-spread@^7.14.6":
-  version "7.14.6"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.14.6.tgz#6bd40e57fe7de94aa904851963b5616652f73144"
-  integrity sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag==
+"@babel/plugin-transform-spread@^7.19.0":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz#dd60b4620c2fec806d60cfaae364ec2188d593b6"
+  integrity sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9"
 
-"@babel/plugin-transform-sticky-regex@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.14.5.tgz#5b617542675e8b7761294381f3c28c633f40aeb9"
-  integrity sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A==
+"@babel/plugin-transform-sticky-regex@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz#c6706eb2b1524028e317720339583ad0f444adcc"
+  integrity sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-template-literals@^7.14.5", "@babel/plugin-transform-template-literals@^7.8.3":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.14.5.tgz#a5f2bc233937d8453885dc736bdd8d9ffabf3d93"
-  integrity sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg==
+"@babel/plugin-transform-template-literals@^7.18.9", "@babel/plugin-transform-template-literals@^7.8.3":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz#04ec6f10acdaa81846689d63fae117dd9c243a5e"
+  integrity sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-typeof-symbol@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.14.5.tgz#39af2739e989a2bd291bf6b53f16981423d457d4"
-  integrity sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw==
+"@babel/plugin-transform-typeof-symbol@^7.18.9":
+  version "7.18.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz#c8cea68263e45addcd6afc9091429f80925762c0"
+  integrity sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-unicode-escapes@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.14.5.tgz#9d4bd2a681e3c5d7acf4f57fa9e51175d91d0c6b"
-  integrity sha512-crTo4jATEOjxj7bt9lbYXcBAM3LZaUrbP2uUdxb6WIorLmjNKSpHfIybgY4B8SRpbf8tEVIWH3Vtm7ayCrKocA==
+"@babel/plugin-transform-unicode-escapes@^7.18.10":
+  version "7.18.10"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz#1ecfb0eda83d09bbcb77c09970c2dd55832aa246"
+  integrity sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.18.9"
 
-"@babel/plugin-transform-unicode-regex@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.14.5.tgz#4cd09b6c8425dd81255c7ceb3fb1836e7414382e"
-  integrity sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw==
+"@babel/plugin-transform-unicode-regex@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz#194317225d8c201bbae103364ffe9e2cea36cdca"
+  integrity sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-create-regexp-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
 
 "@babel/preset-env@^7.9.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.15.0.tgz#e2165bf16594c9c05e52517a194bf6187d6fe464"
-  integrity sha512-FhEpCNFCcWW3iZLg0L2NPE9UerdtsCR6ZcsGHUX6Om6kbCQeL5QZDqFDmeNHC6/fy6UH3jEge7K4qG5uC9In0Q==
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.19.0.tgz#fd18caf499a67d6411b9ded68dc70d01ed1e5da7"
+  integrity sha512-1YUju1TAFuzjIQqNM9WsF4U6VbD/8t3wEAlw3LFYuuEr+ywqLRcSXxFKz4DCEj+sN94l/XTDiUXYRrsvMpz9WQ==
   dependencies:
-    "@babel/compat-data" "^7.15.0"
-    "@babel/helper-compilation-targets" "^7.15.0"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-validator-option" "^7.14.5"
-    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.14.5"
-    "@babel/plugin-proposal-async-generator-functions" "^7.14.9"
-    "@babel/plugin-proposal-class-properties" "^7.14.5"
-    "@babel/plugin-proposal-class-static-block" "^7.14.5"
-    "@babel/plugin-proposal-dynamic-import" "^7.14.5"
-    "@babel/plugin-proposal-export-namespace-from" "^7.14.5"
-    "@babel/plugin-proposal-json-strings" "^7.14.5"
-    "@babel/plugin-proposal-logical-assignment-operators" "^7.14.5"
-    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.14.5"
-    "@babel/plugin-proposal-numeric-separator" "^7.14.5"
-    "@babel/plugin-proposal-object-rest-spread" "^7.14.7"
-    "@babel/plugin-proposal-optional-catch-binding" "^7.14.5"
-    "@babel/plugin-proposal-optional-chaining" "^7.14.5"
-    "@babel/plugin-proposal-private-methods" "^7.14.5"
-    "@babel/plugin-proposal-private-property-in-object" "^7.14.5"
-    "@babel/plugin-proposal-unicode-property-regex" "^7.14.5"
+    "@babel/compat-data" "^7.19.0"
+    "@babel/helper-compilation-targets" "^7.19.0"
+    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-validator-option" "^7.18.6"
+    "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6"
+    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.18.9"
+    "@babel/plugin-proposal-async-generator-functions" "^7.19.0"
+    "@babel/plugin-proposal-class-properties" "^7.18.6"
+    "@babel/plugin-proposal-class-static-block" "^7.18.6"
+    "@babel/plugin-proposal-dynamic-import" "^7.18.6"
+    "@babel/plugin-proposal-export-namespace-from" "^7.18.9"
+    "@babel/plugin-proposal-json-strings" "^7.18.6"
+    "@babel/plugin-proposal-logical-assignment-operators" "^7.18.9"
+    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.18.6"
+    "@babel/plugin-proposal-numeric-separator" "^7.18.6"
+    "@babel/plugin-proposal-object-rest-spread" "^7.18.9"
+    "@babel/plugin-proposal-optional-catch-binding" "^7.18.6"
+    "@babel/plugin-proposal-optional-chaining" "^7.18.9"
+    "@babel/plugin-proposal-private-methods" "^7.18.6"
+    "@babel/plugin-proposal-private-property-in-object" "^7.18.6"
+    "@babel/plugin-proposal-unicode-property-regex" "^7.18.6"
     "@babel/plugin-syntax-async-generators" "^7.8.4"
     "@babel/plugin-syntax-class-properties" "^7.12.13"
     "@babel/plugin-syntax-class-static-block" "^7.14.5"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
     "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
+    "@babel/plugin-syntax-import-assertions" "^7.18.6"
     "@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"
@@ -785,50 +839,50 @@
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
     "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
     "@babel/plugin-syntax-top-level-await" "^7.14.5"
-    "@babel/plugin-transform-arrow-functions" "^7.14.5"
-    "@babel/plugin-transform-async-to-generator" "^7.14.5"
-    "@babel/plugin-transform-block-scoped-functions" "^7.14.5"
-    "@babel/plugin-transform-block-scoping" "^7.14.5"
-    "@babel/plugin-transform-classes" "^7.14.9"
-    "@babel/plugin-transform-computed-properties" "^7.14.5"
-    "@babel/plugin-transform-destructuring" "^7.14.7"
-    "@babel/plugin-transform-dotall-regex" "^7.14.5"
-    "@babel/plugin-transform-duplicate-keys" "^7.14.5"
-    "@babel/plugin-transform-exponentiation-operator" "^7.14.5"
-    "@babel/plugin-transform-for-of" "^7.14.5"
-    "@babel/plugin-transform-function-name" "^7.14.5"
-    "@babel/plugin-transform-literals" "^7.14.5"
-    "@babel/plugin-transform-member-expression-literals" "^7.14.5"
-    "@babel/plugin-transform-modules-amd" "^7.14.5"
-    "@babel/plugin-transform-modules-commonjs" "^7.15.0"
-    "@babel/plugin-transform-modules-systemjs" "^7.14.5"
-    "@babel/plugin-transform-modules-umd" "^7.14.5"
-    "@babel/plugin-transform-named-capturing-groups-regex" "^7.14.9"
-    "@babel/plugin-transform-new-target" "^7.14.5"
-    "@babel/plugin-transform-object-super" "^7.14.5"
-    "@babel/plugin-transform-parameters" "^7.14.5"
-    "@babel/plugin-transform-property-literals" "^7.14.5"
-    "@babel/plugin-transform-regenerator" "^7.14.5"
-    "@babel/plugin-transform-reserved-words" "^7.14.5"
-    "@babel/plugin-transform-shorthand-properties" "^7.14.5"
-    "@babel/plugin-transform-spread" "^7.14.6"
-    "@babel/plugin-transform-sticky-regex" "^7.14.5"
-    "@babel/plugin-transform-template-literals" "^7.14.5"
-    "@babel/plugin-transform-typeof-symbol" "^7.14.5"
-    "@babel/plugin-transform-unicode-escapes" "^7.14.5"
-    "@babel/plugin-transform-unicode-regex" "^7.14.5"
-    "@babel/preset-modules" "^0.1.4"
-    "@babel/types" "^7.15.0"
-    babel-plugin-polyfill-corejs2 "^0.2.2"
-    babel-plugin-polyfill-corejs3 "^0.2.2"
-    babel-plugin-polyfill-regenerator "^0.2.2"
-    core-js-compat "^3.16.0"
+    "@babel/plugin-transform-arrow-functions" "^7.18.6"
+    "@babel/plugin-transform-async-to-generator" "^7.18.6"
+    "@babel/plugin-transform-block-scoped-functions" "^7.18.6"
+    "@babel/plugin-transform-block-scoping" "^7.18.9"
+    "@babel/plugin-transform-classes" "^7.19.0"
+    "@babel/plugin-transform-computed-properties" "^7.18.9"
+    "@babel/plugin-transform-destructuring" "^7.18.13"
+    "@babel/plugin-transform-dotall-regex" "^7.18.6"
+    "@babel/plugin-transform-duplicate-keys" "^7.18.9"
+    "@babel/plugin-transform-exponentiation-operator" "^7.18.6"
+    "@babel/plugin-transform-for-of" "^7.18.8"
+    "@babel/plugin-transform-function-name" "^7.18.9"
+    "@babel/plugin-transform-literals" "^7.18.9"
+    "@babel/plugin-transform-member-expression-literals" "^7.18.6"
+    "@babel/plugin-transform-modules-amd" "^7.18.6"
+    "@babel/plugin-transform-modules-commonjs" "^7.18.6"
+    "@babel/plugin-transform-modules-systemjs" "^7.19.0"
+    "@babel/plugin-transform-modules-umd" "^7.18.6"
+    "@babel/plugin-transform-named-capturing-groups-regex" "^7.19.0"
+    "@babel/plugin-transform-new-target" "^7.18.6"
+    "@babel/plugin-transform-object-super" "^7.18.6"
+    "@babel/plugin-transform-parameters" "^7.18.8"
+    "@babel/plugin-transform-property-literals" "^7.18.6"
+    "@babel/plugin-transform-regenerator" "^7.18.6"
+    "@babel/plugin-transform-reserved-words" "^7.18.6"
+    "@babel/plugin-transform-shorthand-properties" "^7.18.6"
+    "@babel/plugin-transform-spread" "^7.19.0"
+    "@babel/plugin-transform-sticky-regex" "^7.18.6"
+    "@babel/plugin-transform-template-literals" "^7.18.9"
+    "@babel/plugin-transform-typeof-symbol" "^7.18.9"
+    "@babel/plugin-transform-unicode-escapes" "^7.18.10"
+    "@babel/plugin-transform-unicode-regex" "^7.18.6"
+    "@babel/preset-modules" "^0.1.5"
+    "@babel/types" "^7.19.0"
+    babel-plugin-polyfill-corejs2 "^0.3.2"
+    babel-plugin-polyfill-corejs3 "^0.5.3"
+    babel-plugin-polyfill-regenerator "^0.4.0"
+    core-js-compat "^3.22.1"
     semver "^6.3.0"
 
-"@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==
+"@babel/preset-modules@^0.1.5":
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.5.tgz#ef939d6e7f268827e1841638dc6ff95515e115d9"
+  integrity sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.0.0"
     "@babel/plugin-proposal-unicode-property-regex" "^7.4.4"
@@ -837,62 +891,160 @@
     esutils "^2.0.2"
 
 "@babel/runtime@^7.8.4":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.3.tgz#2e1c2880ca118e5b2f9988322bd8a7656a32502b"
-  integrity sha512-OvwMLqNXkCXSz1kSm58sEsNuhqOx/fKpnUnKnFB5v8uDda5bLNEHNgKPvhDN6IU0LDcnHQ90LlJ0Q6jnyBSIBA==
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
+  integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/template@^7.14.5", "@babel/template@^7.4.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4"
-  integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==
+"@babel/template@^7.18.10", "@babel/template@^7.4.0":
+  version "7.18.10"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71"
+  integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==
   dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/parser" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/code-frame" "^7.18.6"
+    "@babel/parser" "^7.18.10"
+    "@babel/types" "^7.18.10"
 
-"@babel/traverse@^7.13.0", "@babel/traverse@^7.14.5", "@babel/traverse@^7.15.0", "@babel/traverse@^7.4.3":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.15.0.tgz#4cca838fd1b2a03283c1f38e141f639d60b3fc98"
-  integrity sha512-392d8BN0C9eVxVWd8H6x9WfipgVH5IaIoLp23334Sc1vbKKWINnvwRpb4us0xtPaCumlwbTtIYNA0Dv/32sVFw==
+"@babel/traverse@^7.18.9", "@babel/traverse@^7.19.0", "@babel/traverse@^7.4.3":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.0.tgz#eb9c561c7360005c592cc645abafe0c3c4548eed"
+  integrity sha512-4pKpFRDh+utd2mbRC8JLnlsMUii3PMHjpL6a0SZ4NMZy7YFP9aXORxEhdMVOc9CpWtDF09IkciQLEhK7Ml7gRA==
   dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/generator" "^7.15.0"
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-hoist-variables" "^7.14.5"
-    "@babel/helper-split-export-declaration" "^7.14.5"
-    "@babel/parser" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@babel/code-frame" "^7.18.6"
+    "@babel/generator" "^7.19.0"
+    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/helper-function-name" "^7.19.0"
+    "@babel/helper-hoist-variables" "^7.18.6"
+    "@babel/helper-split-export-declaration" "^7.18.6"
+    "@babel/parser" "^7.19.0"
+    "@babel/types" "^7.19.0"
     debug "^4.1.0"
     globals "^11.1.0"
 
-"@babel/types@^7.0.0", "@babel/types@^7.14.5", "@babel/types@^7.14.8", "@babel/types@^7.15.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.0.tgz#61af11f2286c4e9c69ca8deb5f4375a73c72dcbd"
-  integrity sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ==
+"@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
+  version "7.19.0"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.0.tgz#75f21d73d73dc0351f3368d28db73465f4814600"
+  integrity sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.14.9"
+    "@babel/helper-string-parser" "^7.18.10"
+    "@babel/helper-validator-identifier" "^7.18.6"
     to-fast-properties "^2.0.0"
 
-"@koa/cors@^3.1.0":
+"@colors/colors@1.5.0":
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
+  integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==
+
+"@esbuild/linux-loong64@0.14.54":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
+  integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
+
+"@esm-bundle/chai@^4.3.4-fix.0":
+  version "4.3.4-fix.0"
+  resolved "https://registry.yarnpkg.com/@esm-bundle/chai/-/chai-4.3.4-fix.0.tgz#3084cff7eb46d741749f47f3a48dbbdcbaf30a92"
+  integrity sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==
+  dependencies:
+    "@types/chai" "^4.2.12"
+
+"@jridgewell/gen-mapping@^0.1.0":
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996"
+  integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==
+  dependencies:
+    "@jridgewell/set-array" "^1.0.0"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
+
+"@jridgewell/gen-mapping@^0.3.2":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9"
+  integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==
+  dependencies:
+    "@jridgewell/set-array" "^1.0.1"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
+    "@jridgewell/trace-mapping" "^0.3.9"
+
+"@jridgewell/resolve-uri@3.1.0", "@jridgewell/resolve-uri@^3.0.3":
   version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.1.0.tgz#618bb073438cfdbd3ebd0e648a76e33b84f3a3b2"
-  integrity sha512-7ulRC1da/rBa6kj6P4g2aJfnET3z8Uf3SWu60cjbtxTA5g8lxRdX/Bd2P92EagGwwAhANeNw8T8if99rJliR6Q==
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
+  integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
+
+"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
+  integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
+
+"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10":
+  version "1.4.14"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
+  integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
+
+"@jridgewell/trace-mapping@^0.3.12":
+  version "0.3.17"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985"
+  integrity sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==
+  dependencies:
+    "@jridgewell/resolve-uri" "3.1.0"
+    "@jridgewell/sourcemap-codec" "1.4.14"
+
+"@jridgewell/trace-mapping@^0.3.9":
+  version "0.3.15"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774"
+  integrity sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==
+  dependencies:
+    "@jridgewell/resolve-uri" "^3.0.3"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
+
+"@koa/cors@^3.1.0":
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.4.1.tgz#ddd5c6ff07a1e60831e1281411a3b9fdb95a5b26"
+  integrity sha512-/sG9NlpGZ/aBpnRamIlGs+wX+C/IJ5DodNK7iPQIVCG4eUQdGeshGhWQ6JCi7tpnD9sCtFXcS04iTimuaJfh4Q==
   dependencies:
     vary "^1.1.2"
 
-"@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==
+"@lit/reactive-element@^1.0.0":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.2.tgz#daa7a7c7a6c63d735f0c9634de6b7dbd70a702ab"
+  integrity sha512-oz3d3MKjQ2tXynQgyaQaMpGTDNyNDeBdo6dXf1AbjTwhA1IRINHmA7kSaVYv9ttKweNkEoNqp9DqteDdgWzPEg==
+
+"@mdn/browser-compat-data@^4.0.0":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8"
+  integrity sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==
+
+"@nodelib/fs.scandir@2.1.5":
+  version "2.1.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
+  integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
+  dependencies:
+    "@nodelib/fs.stat" "2.0.5"
+    run-parallel "^1.1.9"
+
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
+  integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
+
+"@nodelib/fs.walk@^1.2.3":
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
+  integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
+  dependencies:
+    "@nodelib/fs.scandir" "2.1.5"
+    fastq "^1.6.0"
+
+"@open-wc/building-utils@^2.18.3":
+  version "2.18.5"
+  resolved "https://registry.yarnpkg.com/@open-wc/building-utils/-/building-utils-2.18.5.tgz#2e491d48f5be8f64f4d7968fa5eb0780c9d2f574"
+  integrity sha512-hNUQcowXGc6pxUDec57ZBl712XhYh09xuCkaac4jfDbLm1tc4o9DuLxsmS+MkVQbdfsWB/t+rUXJof1i1jO6kQ==
   dependencies:
     "@babel/core" "^7.11.1"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
     "@webcomponents/shadycss" "^1.10.2"
     "@webcomponents/webcomponentsjs" "^2.5.0"
     arrify "^2.0.1"
-    browserslist "^4.16.0"
+    browserslist "^4.16.5"
     chokidar "^3.4.3"
     clean-css "^4.2.3"
     clone "^2.1.2"
@@ -914,40 +1066,98 @@
     whatwg-fetch "^3.5.0"
     whatwg-url "^7.1.0"
 
-"@open-wc/karma-esm@^2.16.16":
-  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==
+"@open-wc/chai-dom-equals@^0.12.36":
+  version "0.12.36"
+  resolved "https://registry.yarnpkg.com/@open-wc/chai-dom-equals/-/chai-dom-equals-0.12.36.tgz#ed0eb56b9e98c4d7f7280facce6215654aae9f4c"
+  integrity sha512-Gt1fa37h4rtWPQGETSU4n1L678NmMi9KwHM1sH+JCGcz45rs8DBPx7MUVeGZ+HxRlbEI5t9LU2RGGv6xT2OlyA==
   dependencies:
-    "@open-wc/building-utils" "^2.18.0"
+    "@open-wc/semantic-dom-diff" "^0.13.16"
+    "@types/chai" "^4.1.7"
+
+"@open-wc/dedupe-mixin@^1.3.0":
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/dedupe-mixin/-/dedupe-mixin-1.3.0.tgz#0df5d438285fc3482838786ee81895318f0ff778"
+  integrity sha512-UfdK1MPnR6T7f3svzzYBfu3qBkkZ/KsPhcpc3JYhsUY4hbpwNF9wEQtD4Z+/mRqMTJrKg++YSxIxE0FBhY3RIw==
+
+"@open-wc/karma-esm@^3.0.9":
+  version "3.0.9"
+  resolved "https://registry.yarnpkg.com/@open-wc/karma-esm/-/karma-esm-3.0.9.tgz#f2bb5c78f69685289718097b8d9cd742acfb07fe"
+  integrity sha512-GzpL/iHVBskZDmKC7cLYLBYzuSoIng7CxyMxp5Ai4VxMSwCVFG+6j/PKLOXVma+EAwwvmq2L5B9tWVbXR34Peg==
+  dependencies:
+    "@open-wc/building-utils" "^2.18.3"
     babel-plugin-istanbul "^5.1.4"
     chokidar "^3.0.0"
     deepmerge "^4.2.2"
-    es-dev-server "^1.57.0"
+    es-dev-server "^1.57.8"
     minimatch "^3.0.4"
     node-fetch "^2.6.0"
-    polyfills-loader "^1.6.1"
+    polyfills-loader "^1.7.4"
     portfinder "^1.0.21"
     request "^2.88.0"
 
-"@polymer/iron-test-helpers@^3.0.1":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@polymer/iron-test-helpers/-/iron-test-helpers-3.0.1.tgz#ec2b9c6567e2967a191b3d800a04b1167b2d1394"
-  integrity sha512-2R7dnGcW2eg95i7LhYWWUO4AlAk6qXsPnKoyeN2R1t0km0ECMx0jjwqeLwCo8/7LwuVPZSiarI4DK7jyU7fJLQ==
+"@open-wc/scoped-elements@^2.1.3":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.1.3.tgz#c4f06fa16091c6ebf2a69b3f40afc03821f42535"
+  integrity sha512-WoQD5T8Me9obek+iyjgrAMw9wxZZg4ytIteIN1i9LXW2KohezUp0LTOlWgBajWJo0/bpjUKiODX73cMYL2i3hw==
   dependencies:
-    "@polymer/polymer" "^3.0.0"
+    "@lit/reactive-element" "^1.0.0"
+    "@open-wc/dedupe-mixin" "^1.3.0"
 
-"@polymer/polymer@^3.0.0":
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
-  integrity sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==
+"@open-wc/semantic-dom-diff@^0.13.16":
+  version "0.13.21"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.13.21.tgz#718b9ec5f9a98935fc775e577ad094ae8d8b7dea"
+  integrity sha512-BONpjHcGX2zFa9mfnwBCLEmlDsOHzT+j6Qt1yfK3MzFXFtAykfzFjAgaxPetu0YbBlCfXuMlfxI4vlRGCGMvFg==
+
+"@open-wc/semantic-dom-diff@^0.19.5":
+  version "0.19.5"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.19.5.tgz#8d3d7f69140b9ba477a4adf8099c79e0efe18955"
+  integrity sha512-Wi0Fuj3dzqlWClU0y+J4k/nqTcH0uwgOWxZXPyeyG3DdvuyyjgiT4L4I/s6iVShWQvvEsyXnj7yVvixAo3CZvg==
   dependencies:
-    "@webcomponents/shadycss" "^1.9.1"
+    "@types/chai" "^4.2.11"
+    "@web/test-runner-commands" "^0.5.7"
 
-"@polymer/test-fixture@^4.0.2":
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-4.0.2.tgz#2f4777ecdcfb22ee000db35a05e0edf27c722c19"
-  integrity sha512-tLX8tFE4mkc4p84YG5239G0hbgTVv2irZYrSyO0OblUqIRbRoCPmbydm3HRFQkJeAB3rPCtyeZ2roJULsmTG3A==
+"@open-wc/semantic-dom-diff@^0.19.7":
+  version "0.19.7"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.19.7.tgz#92361f0d2dcb54a8d5cf11d5ea40b8e7ffa58eb4"
+  integrity sha512-ahwHb7arQXXnkIGCrOsM895FJQrU47VWZryCsSSzl5nB3tJKcJ8yjzQ3D/yqZn6v8atqOz61vaY05aNsqoz3oA==
+  dependencies:
+    "@types/chai" "^4.3.1"
+    "@web/test-runner-commands" "^0.6.1"
+
+"@open-wc/testing-helpers@^2.1.2":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.3.tgz#85a133ac8637ed1d880d523b07650788eab4a128"
+  integrity sha512-hQujGaWncmWLx/974jq5yf2jydBNNTwnkISw2wLGiYgX34+3R6/ns301Oi9S3Il96Kzd8B7avdExp/gDgqcF5w==
+  dependencies:
+    "@open-wc/scoped-elements" "^2.1.3"
+    lit "^2.0.0"
+    lit-html "^2.0.0"
+
+"@open-wc/testing@^3.1.6":
+  version "3.1.6"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.6.tgz#89f71710e5530d74f0c478b0a9239d68dcdb9f5e"
+  integrity sha512-MIf9cBtac4/UBE5a+R5cXiRhOGfzetsV+ZPFc188AfkPDPbmffHqjrRoCyk4B/qS6fLEulSBMLSaQ+6ze971gQ==
+  dependencies:
+    "@esm-bundle/chai" "^4.3.4-fix.0"
+    "@open-wc/chai-dom-equals" "^0.12.36"
+    "@open-wc/semantic-dom-diff" "^0.19.7"
+    "@open-wc/testing-helpers" "^2.1.2"
+    "@types/chai" "^4.2.11"
+    "@types/chai-dom" "^0.0.12"
+    "@types/sinon-chai" "^3.2.3"
+    chai-a11y-axe "^1.3.2"
+
+"@rollup/plugin-node-resolve@^13.0.4":
+  version "13.3.0"
+  resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c"
+  integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw==
+  dependencies:
+    "@rollup/pluginutils" "^3.1.0"
+    "@types/resolve" "1.17.1"
+    deepmerge "^4.2.2"
+    is-builtin-module "^3.1.0"
+    is-module "^1.0.0"
+    resolve "^1.19.0"
 
 "@rollup/plugin-node-resolve@^7.1.1":
   version "7.1.3"
@@ -960,7 +1170,7 @@
     is-module "^1.0.0"
     resolve "^1.14.2"
 
-"@rollup/pluginutils@^3.0.0", "@rollup/pluginutils@^3.0.8":
+"@rollup/pluginutils@^3.0.0", "@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0":
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
   integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
@@ -969,24 +1179,31 @@
     estree-walker "^1.0.1"
     picomatch "^2.2.2"
 
-"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.1":
+"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
   version "1.8.3"
   resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
   integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==
   dependencies:
     type-detect "4.0.8"
 
-"@sinonjs/fake-timers@^7.0.4", "@sinonjs/fake-timers@^7.1.0":
+"@sinonjs/fake-timers@>=5", "@sinonjs/fake-timers@^9.1.2":
+  version "9.1.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c"
+  integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==
+  dependencies:
+    "@sinonjs/commons" "^1.7.0"
+
+"@sinonjs/fake-timers@^7.1.0":
   version "7.1.2"
   resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz#2524eae70c4910edccf99b2f4e6efc5894aff7b5"
   integrity sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==
   dependencies:
     "@sinonjs/commons" "^1.7.0"
 
-"@sinonjs/samsam@^6.0.1":
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.0.2.tgz#a0117d823260f282c04bff5f8704bdc2ac6910bb"
-  integrity sha512-jxPRPp9n93ci7b8hMfJOFDPRLFYadN6FSpeROFTR4UNF4i5b+EK6m4QXPO46BDhFgRy1JuS87zAnFOzCUwMJcQ==
+"@sinonjs/samsam@^6.1.1":
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.1.tgz#627f7f4cbdb56e6419fa2c1a3e4751ce4f6a00b1"
+  integrity sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==
   dependencies:
     "@sinonjs/commons" "^1.6.0"
     lodash.get "^4.4.2"
@@ -997,6 +1214,11 @@
   resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5"
   integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==
 
+"@socket.io/component-emitter@~3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
+  integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
+
 "@types/accepts@*":
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
@@ -1004,10 +1226,15 @@
   dependencies:
     "@types/node" "*"
 
+"@types/babel__code-frame@^7.0.2":
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.3.tgz#eda94e1b7c9326700a4b69c485ebbc9498a0b63f"
+  integrity sha512-2TN6oiwtNjOezilFVl77zwdNPwQWaDBBCCWWxyo1ctiO3vAtd7H/aB/CBJdw9+kqq3+latD0SXoedIuHySSZWw==
+
 "@types/babel__core@^7.1.3":
-  version "7.1.15"
-  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.15.tgz#2ccfb1ad55a02c83f8e0ad327cbc332f55eb1024"
-  integrity sha512-bxlMKPDbY8x5h6HBwVzEOk2C8fb6SLfYQ5Jw3uBYuYF1lfWk/kbLd81la82vrIkBb0l+JdmrZaDikPrNxpS/Ew==
+  version "7.1.19"
+  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460"
+  integrity sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==
   dependencies:
     "@babel/parser" "^7.1.0"
     "@babel/types" "^7.0.0"
@@ -1016,9 +1243,9 @@
     "@types/babel__traverse" "*"
 
 "@types/babel__generator@*":
-  version "7.6.3"
-  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.3.tgz#f456b4b2ce79137f768aa130d2423d2f0ccfaba5"
-  integrity sha512-/GWCmzJWqV7diQW54smJZzWbSFf4QYtF71WCKhcx6Ru/tFyQIY2eiiITcCAeuPbNSvT9YCGkVMqqvSk2Z0mXiA==
+  version "7.6.4"
+  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.4.tgz#1f20ce4c5b1990b37900b63f050182d28c2439b7"
+  integrity sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==
   dependencies:
     "@babel/types" "^7.0.0"
 
@@ -1031,9 +1258,9 @@
     "@babel/types" "^7.0.0"
 
 "@types/babel__traverse@*":
-  version "7.14.2"
-  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.14.2.tgz#ffcd470bbb3f8bf30481678fb5502278ca833a43"
-  integrity sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==
+  version "7.18.1"
+  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.18.1.tgz#ce5e2c8c272b99b7a9fd69fa39f0b4cd85028bd9"
+  integrity sha512-FSdLaZh2UxaMuLp9lixWaHq/golWTRWOnRsAXzDTDSDOQLuZb1nsdCt6pJSPWSEQt2eFZ2YVk3oYhn+1kLMeMA==
   dependencies:
     "@babel/types" "^7.3.0"
 
@@ -1062,10 +1289,35 @@
   resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.2.tgz#684ba0c284b2a58346abf0000bd0a735ad072d75"
   integrity sha512-YfCDMn7R59n7GFFfwjPAM0zLJQy4UvveC32rOJBmTqJJY8uSRqM4Dc7IJj8V9unA48Qy4nj5Bj3jD6Q8VZ1Seg==
 
-"@types/chai@^4.2.16":
-  version "4.2.21"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.21.tgz#9f35a5643129df132cf3b5c1ec64046ea1af0650"
-  integrity sha512-yd+9qKmJxm496BOV9CMNaey8TWsikaZOwMRwPHQIjcOJM9oV+fi9ZMNw3JsVnbEEbo2gRTDnGEBv8pjyn67hNg==
+"@types/chai-dom@^0.0.12":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-0.0.12.tgz#fdd7a52bed4dd235ed1c94d3d2d31d4e7db1d03a"
+  integrity sha512-4rE7sDw713cV61TYzQbMrPjC4DjNk3x4vk9nAVRNXcSD4p0/5lEEfm0OgoCz5eNuWUXNKA0YiKiH/JDTuKivkA==
+  dependencies:
+    "@types/chai" "*"
+
+"@types/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.12":
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
+  integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
+
+"@types/chai@^4.2.11":
+  version "4.2.22"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.22.tgz#47020d7e4cf19194d43b5202f35f75bd2ad35ce7"
+  integrity sha512-tFfcE+DSTzWAgifkjik9AySNqIyNoYwmR+uecPwwD/XRNfvOjmC/FjCxpiUGDkDVDphPfCUecSQVFw+lN3M3kQ==
+
+"@types/chai@^4.3.1":
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.1.tgz#e2c6e73e0bdeb2521d00756d099218e9f5d90a04"
+  integrity sha512-/zPMqDkzSZ8t3VtxOa4KPq7uzzW978M9Tvh+j7GHKuo6k6GTLxPJ4J5gE5cjfJ26pnXst0N5Hax8Sr0T2Mi9zQ==
+
+"@types/co-body@^6.1.0":
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.0.tgz#b52625390eb0d113c9b697ea92c3ffae7740cdb9"
+  integrity sha512-3e0q2jyDAnx/DSZi0z2H0yoZ2wt5yRDZ+P7ymcMObvq0ufWRT4tsajyO+Q1VwVWiv9PRR4W3YEjEzBjeZlhF+w==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
 
 "@types/command-line-args@^5.0.0":
   version "5.2.0"
@@ -1077,11 +1329,6 @@
   resolved "https://registry.yarnpkg.com/@types/command-line-usage/-/command-line-usage-5.0.2.tgz#ba5e3f6ae5a2009d466679cc431b50635bf1a064"
   integrity sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==
 
-"@types/component-emitter@^1.2.10":
-  version "1.2.10"
-  resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.10.tgz#ef5b1589b9f16544642e473db5ea5639107ef3ea"
-  integrity sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg==
-
 "@types/connect@*":
   version "3.4.35"
   resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
@@ -1094,6 +1341,11 @@
   resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.4.tgz#de48cf01c79c9f1560bcfd8ae43217ab028657f8"
   integrity sha512-0mPF08jn9zYI0n0Q/Pnz7C4kThdSt+6LD4amsrYDDpgBfrVWa3TcCOxKX1zkGgYniGagRv8heN2cbh+CAn+uuQ==
 
+"@types/convert-source-map@^1.5.1":
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-1.5.2.tgz#318dc22d476632a4855594c16970c6dc3ed086e7"
+  integrity sha512-tHs++ZeXer40kCF2JpE51Hg7t4HPa18B1b1Dzy96S0eCw8QKECNMYMfwa1edK/x8yCN0r4e6ewvLcc5CsVGkdg==
+
 "@types/cookie@^0.4.1":
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
@@ -1160,6 +1412,30 @@
   resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.1.tgz#e81ad28a60bee0328c6d2384e029aec626f1ae67"
   integrity sha512-e+2rjEwK6KDaNOm5Aa9wNGgyS9oSZU/4pfSMMPYNOfjvFI0WVXm29+ITRFr6aKDvvKo7uU1jV68MW4ScsfDi7Q==
 
+"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.3":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
+  integrity sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==
+
+"@types/istanbul-lib-coverage@^2.0.1":
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
+  integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
+
+"@types/istanbul-lib-report@*":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686"
+  integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==
+  dependencies:
+    "@types/istanbul-lib-coverage" "*"
+
+"@types/istanbul-reports@^3.0.0":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff"
+  integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==
+  dependencies:
+    "@types/istanbul-lib-report" "*"
+
 "@types/keygrip@*":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
@@ -1203,7 +1479,7 @@
     "@types/koa" "*"
     "@types/koa-send" "*"
 
-"@types/koa@*", "@types/koa@^2.0.48":
+"@types/koa@*", "@types/koa@^2.11.6":
   version "2.13.4"
   resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.4.tgz#10620b3f24a8027ef5cbae88b393d1b31205726b"
   integrity sha512-dfHYMfU+z/vKtQB7NUrthdAEiSvnLebvBjwHtfFmpZmB7em2N3WVQdHgnFq+xvyVgxW5jKDmjWfLD3lw4g4uTw==
@@ -1217,27 +1493,36 @@
     "@types/koa-compose" "*"
     "@types/node" "*"
 
+"@types/koa@^2.0.48":
+  version "2.13.5"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.5.tgz#64b3ca4d54e08c0062e89ec666c9f45443b21a61"
+  integrity sha512-HSUOdzKz3by4fnqagwthW/1w/yJspTgppyyalPVbgZf8jQWvdIXcVW5h2DGtw4zYntOaeRGx49r1hxoPWrD4aA==
+  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.3"
-  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.0.3.tgz#49d75813b443ba3d4da28ea6cf6244b7e99a3b23"
-  integrity sha512-74Xb4hJOPGKlrQ4PRBk1A/p0gfLpgbnpT0o67OMVbwyeMXvlBN+ZCRztAAmkKZs+8hKbgMutUlZVbA52Hr/0IA==
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.3.0.tgz#2986b320d3d7ddf05c4e2e472b25a321cb16bd3b"
+  integrity sha512-FUN8YxcBakIs+walVe3+HcNP+Bxd0SB8BJHBWkglZ5C1XQWljlKcEFDG/dPiCIqwVCUbc5X0nYDlH62uEhdHMA==
   dependencies:
     "@types/koa" "*"
 
-"@types/lodash@^4.14.168":
-  version "4.14.172"
-  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.172.tgz#aad774c28e7bfd7a67de25408e03ee5a8c3d028a"
-  integrity sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw==
-
 "@types/lru-cache@^5.1.0":
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.1.tgz#c48c2e27b65d2a153b19bfc1a317e30872e01eef"
   integrity sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==
 
 "@types/mime-types@^2.1.0":
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.0.tgz#9ca52cda363f699c69466c2a6ccdaad913ea7a73"
-  integrity sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM=
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.1.tgz#d9ba43490fa3a3df958759adf69396c3532cf2c1"
+  integrity sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==
 
 "@types/mime@^1":
   version "1.3.2"
@@ -1249,21 +1534,52 @@
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
   integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
 
-"@types/mocha@^8.2.2":
+"@types/mkdirp@^1.0.1":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-1.0.2.tgz#8d0bad7aa793abe551860be1f7ae7f3198c16666"
+  integrity sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/mocha@^8.2.0":
   version "8.2.3"
   resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323"
   integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
 
-"@types/node@*", "@types/node@>=10.0.0":
+"@types/node@*":
   version "16.6.1"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-16.6.1.tgz#aee62c7b966f55fc66c7b6dfa1d58db2a616da61"
   integrity sha512-Sr7BhXEAer9xyGuCN3Ek9eg9xPviCF2gfu9kTfuU2HkTVAMYSDeX40fvpmo72n5nansg3nsBjuQBrsS28r+NUw==
 
+"@types/node@>=10.0.0":
+  version "18.7.18"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.18.tgz#633184f55c322e4fb08612307c274ee6d5ed3154"
+  integrity sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==
+
+"@types/parse5@^6.0.1":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.2.tgz#99f6b72d82e34cea03a4d8f2ed72114d909c1c61"
+  integrity sha512-+hQX+WyJAOne7Fh3zF5CxPemILIbuhNcqHHodzK9caYOLnC8pD5efmPleRnw0z++LfKUC/sVNMwk0Gap+B0baA==
+
 "@types/path-is-inside@^1.0.0":
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
   integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
 
+"@types/pixelmatch@^5.2.2":
+  version "5.2.4"
+  resolved "https://registry.yarnpkg.com/@types/pixelmatch/-/pixelmatch-5.2.4.tgz#ca145cc5ede1388c71c68edf2d1f5190e5ddd0f6"
+  integrity sha512-HDaSHIAv9kwpMN7zlmwfTv6gax0PiporJOipcrGsVNF3Ba+kryOZc0Pio5pn6NhisgWr7TaajlPEKTbTAypIBQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/pngjs@^6.0.0":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-6.0.1.tgz#c711ec3fbbf077fed274ecccaf85dd4673130072"
+  integrity sha512-J39njbdW1U/6YyVXvC9+1iflZghP8jgRf2ndYghdJb5xL49LYDB+1EuAxfbuJ2IBbWIL3AjHPQhgaTxT3YaYeg==
+  dependencies:
+    "@types/node" "*"
+
 "@types/qs@*":
   version "6.9.7"
   resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
@@ -1281,6 +1597,13 @@
   dependencies:
     "@types/node" "*"
 
+"@types/resolve@1.17.1":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
+  integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
+  dependencies:
+    "@types/node" "*"
+
 "@types/serve-static@*":
   version "1.13.10"
   resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
@@ -1289,6 +1612,21 @@
     "@types/mime" "^1"
     "@types/node" "*"
 
+"@types/sinon-chai@^3.2.3":
+  version "3.2.8"
+  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc"
+  integrity sha512-d4ImIQbT/rKMG8+AXpmcan5T2/PNeSjrYhvkwet6z0p8kzYtfgA32xzOBlbU0yqJfq+/0Ml805iFoODO0LP5/g==
+  dependencies:
+    "@types/chai" "*"
+    "@types/sinon" "*"
+
+"@types/sinon@*":
+  version "10.0.13"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.13.tgz#60a7a87a70d9372d0b7b38cc03e825f46981fb83"
+  integrity sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==
+  dependencies:
+    "@types/sinonjs__fake-timers" "*"
+
 "@types/sinon@^10.0.0":
   version "10.0.2"
   resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.2.tgz#f360d2f189c0fd433d14aeb97b9d705d7e4cc0e4"
@@ -1296,6 +1634,16 @@
   dependencies:
     "@sinonjs/fake-timers" "^7.1.0"
 
+"@types/sinonjs__fake-timers@*":
+  version "8.1.2"
+  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e"
+  integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
+
+"@types/trusted-types@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
+  integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
+
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
@@ -1303,12 +1651,310 @@
   dependencies:
     "@types/node" "*"
 
+"@types/ws@^7.4.0":
+  version "7.4.7"
+  resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702"
+  integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==
+  dependencies:
+    "@types/node" "*"
+
+"@types/yauzl@^2.9.1":
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
+  integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==
+  dependencies:
+    "@types/node" "*"
+
 "@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.10.2", "@webcomponents/shadycss@^1.9.1":
+"@web/browser-logs@^0.2.1", "@web/browser-logs@^0.2.2":
+  version "0.2.5"
+  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.2.5.tgz#0895efb641eacb0fbc1138c6092bd18c01df2734"
+  integrity sha512-Qxo1wY/L7yILQqg0jjAaueh+tzdORXnZtxQgWH23SsTCunz9iq9FvsZa8Q5XlpjnZ3vLIsFEuEsCMqFeohJnEg==
+  dependencies:
+    errorstacks "^2.2.0"
+
+"@web/config-loader@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@web/config-loader/-/config-loader-0.1.3.tgz#8325ea54f75ef2ee7166783e64e66936db25bff7"
+  integrity sha512-XVKH79pk4d3EHRhofete8eAnqto1e8mCRAqPV00KLNFzCWSe8sWmLnqKCqkPNARC6nksMaGrATnA5sPDRllMpQ==
+  dependencies:
+    semver "^7.3.4"
+
+"@web/dev-server-core@^0.3.16":
+  version "0.3.17"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.17.tgz#95e87681b63644a955e29e13ffc6b48fd2c51264"
+  integrity sha512-vN1dwQ8yDHGiAvCeUo9xFfjo+pFl8TW+pON7k9kfhbegrrB8CKhJDUxmHbZsyQUmjf/iX57/LhuWj1xGhRL8AA==
+  dependencies:
+    "@types/koa" "^2.11.6"
+    "@types/ws" "^7.4.0"
+    "@web/parse5-utils" "^1.2.0"
+    chokidar "^3.4.3"
+    clone "^2.1.2"
+    es-module-lexer "^0.9.0"
+    get-stream "^6.0.0"
+    is-stream "^2.0.0"
+    isbinaryfile "^4.0.6"
+    koa "^2.13.0"
+    koa-etag "^4.0.0"
+    koa-send "^5.0.1"
+    koa-static "^5.0.0"
+    lru-cache "^6.0.0"
+    mime-types "^2.1.27"
+    parse5 "^6.0.1"
+    picomatch "^2.2.2"
+    ws "^7.4.2"
+
+"@web/dev-server-core@^0.3.18", "@web/dev-server-core@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.19.tgz#b61f9a0b92351371347a758b30ba19e683c72e94"
+  integrity sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==
+  dependencies:
+    "@types/koa" "^2.11.6"
+    "@types/ws" "^7.4.0"
+    "@web/parse5-utils" "^1.2.0"
+    chokidar "^3.4.3"
+    clone "^2.1.2"
+    es-module-lexer "^1.0.0"
+    get-stream "^6.0.0"
+    is-stream "^2.0.0"
+    isbinaryfile "^4.0.6"
+    koa "^2.13.0"
+    koa-etag "^4.0.0"
+    koa-send "^5.0.1"
+    koa-static "^5.0.0"
+    lru-cache "^6.0.0"
+    mime-types "^2.1.27"
+    parse5 "^6.0.1"
+    picomatch "^2.2.2"
+    ws "^7.4.2"
+
+"@web/dev-server-esbuild@^0.3.2":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.2.tgz#d4f43c1677123021f6c5805beaac902318f7e083"
+  integrity sha512-Jn9b+Rs1ck4QN+ksue6qFdvUc2r/+NHpMW0R86W4Kqw5WjE7dT44pCGkKNfB8Fph4dNi0MgDaMhIkW2fcSpogA==
+  dependencies:
+    "@mdn/browser-compat-data" "^4.0.0"
+    "@web/dev-server-core" "^0.3.19"
+    esbuild "^0.12 || ^0.13 || ^0.14"
+    parse5 "^6.0.1"
+    ua-parser-js "^1.0.2"
+
+"@web/dev-server-rollup@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.3.19.tgz#188f3a37bcc38f4dc1b208663b14ab2d17321a57"
+  integrity sha512-IwiwI+fyX0YuvAOldStlYJ+Zm/JfSCk9OSGIs7+fWbOYysEHwkEVvBwoPowaclSZA44Tobvqt+6ej9udbbZ/WQ==
+  dependencies:
+    "@rollup/plugin-node-resolve" "^13.0.4"
+    "@web/dev-server-core" "^0.3.19"
+    nanocolors "^0.2.1"
+    parse5 "^6.0.1"
+    rollup "^2.67.0"
+    whatwg-url "^11.0.0"
+
+"@web/dev-server@^0.1.33":
+  version "0.1.34"
+  resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.34.tgz#4a94ea6dcf1c8081b97f5dd6d9790dc7e5c5039d"
+  integrity sha512-+te6iwxAQign1KyhHpkR/a3+5qw/Obg/XWCES2So6G5LcZ86zIKXbUpWAJuNOqiBV6eGwqEB1AozKr2Jj7gj/Q==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/command-line-args" "^5.0.0"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server-core" "^0.3.19"
+    "@web/dev-server-rollup" "^0.3.19"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    debounce "^1.2.0"
+    deepmerge "^4.2.2"
+    ip "^1.1.5"
+    nanocolors "^0.2.1"
+    open "^8.0.2"
+    portfinder "^1.0.28"
+
+"@web/parse5-utils@^1.2.0":
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.0.tgz#e2e9e98b31a4ca948309f74891bda8d77399f6bd"
+  integrity sha512-Pgkx3ECc8EgXSlS5EyrgzSOoUbM6P8OKS471HLAyvOBcP1NCBn0to4RN/OaKASGq8qa3j+lPX9H14uA5AHEnQg==
+  dependencies:
+    "@types/parse5" "^6.0.1"
+    parse5 "^6.0.1"
+
+"@web/test-runner-chrome@^0.10.7":
+  version "0.10.7"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-chrome/-/test-runner-chrome-0.10.7.tgz#2dc35da47aa8b98c59f9e229a70ea3f443303e0c"
+  integrity sha512-DKJVHhHh3e/b6/erfKOy0a4kGfZ47qMoQRgROxi9T4F9lavEY3E5/MQ7hapHFM2lBF4vDrm+EWjtBdOL8o42tw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    "@web/test-runner-coverage-v8" "^0.4.8"
+    chrome-launcher "^0.15.0"
+    puppeteer-core "^13.1.3"
+
+"@web/test-runner-commands@^0.5.7":
+  version "0.5.13"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.5.13.tgz#57ea472c00ee2ada99eb9bb5a0371200922707c2"
+  integrity sha512-FXnpUU89ALbRlh9mgBd7CbSn5uzNtr8gvnQZPOvGLDAJ7twGvZdUJEAisPygYx2BLPSFl3/Mre8pH8zshJb8UQ==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    mkdirp "^1.0.4"
+
+"@web/test-runner-commands@^0.6.1", "@web/test-runner-commands@^0.6.3":
+  version "0.6.4"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.4.tgz#61c8e1d71d30567b8e2845274426d209dbe77c7e"
+  integrity sha512-opSfIVHj4PsIA/Ah582DKgnmdfY+Xn35FnnYeJ+aBYrM+setOP63McvrY4PuwasictwswHVSzq86qZzmxvXkHw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.27"
+    mkdirp "^1.0.4"
+
+"@web/test-runner-commands@^0.6.4":
+  version "0.6.5"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.5.tgz#69a2a06b52fd9d329f9cf1e172cd8fb1d5ffc521"
+  integrity sha512-W+wLg10jEAJY9N6tNWqG1daKmAzxGmTbO/H9fFfcgOgdxdn+hHiR4r2/x1iylKbFLujHUQlnjNQeu2d6eDPFqg==
+  dependencies:
+    "@web/test-runner-core" "^0.10.27"
+    mkdirp "^1.0.4"
+
+"@web/test-runner-core@^0.10.20":
+  version "0.10.22"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.22.tgz#34bb67d12a79b01dc79c816f3d76f3419ef50eaf"
+  integrity sha512-0jzJIl/PTZa6PCG/noHAFZT2DTcp+OYGmYOnZ2wcHAO3KwtJKnBVSuxgdOzFdmfvoO7TYAXo5AH+MvTZXMWsZw==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/babel__code-frame" "^7.0.2"
+    "@types/co-body" "^6.1.0"
+    "@types/convert-source-map" "^1.5.1"
+    "@types/debounce" "^1.2.0"
+    "@types/istanbul-lib-coverage" "^2.0.3"
+    "@types/istanbul-reports" "^3.0.0"
+    "@web/browser-logs" "^0.2.1"
+    "@web/dev-server-core" "^0.3.16"
+    chokidar "^3.4.3"
+    cli-cursor "^3.1.0"
+    co-body "^6.1.0"
+    convert-source-map "^1.7.0"
+    debounce "^1.2.0"
+    dependency-graph "^0.11.0"
+    globby "^11.0.1"
+    ip "^1.1.5"
+    istanbul-lib-coverage "^3.0.0"
+    istanbul-lib-report "^3.0.0"
+    istanbul-reports "^3.0.2"
+    log-update "^4.0.0"
+    nanocolors "^0.2.1"
+    nanoid "^3.1.25"
+    open "^8.0.2"
+    picomatch "^2.2.2"
+    source-map "^0.7.3"
+
+"@web/test-runner-core@^0.10.27":
+  version "0.10.27"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.27.tgz#8d1430f2364fb36b3ac15b9b43034fae9d94e177"
+  integrity sha512-ClV/hSxs4wDm/ANFfQOdRRFb/c0sYywC1QfUXG/nS4vTp3nnt7x7mjydtMGGLmvK9f6Zkubkc1aa+7ryfmVwNA==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/babel__code-frame" "^7.0.2"
+    "@types/co-body" "^6.1.0"
+    "@types/convert-source-map" "^1.5.1"
+    "@types/debounce" "^1.2.0"
+    "@types/istanbul-lib-coverage" "^2.0.3"
+    "@types/istanbul-reports" "^3.0.0"
+    "@web/browser-logs" "^0.2.1"
+    "@web/dev-server-core" "^0.3.18"
+    chokidar "^3.4.3"
+    cli-cursor "^3.1.0"
+    co-body "^6.1.0"
+    convert-source-map "^1.7.0"
+    debounce "^1.2.0"
+    dependency-graph "^0.11.0"
+    globby "^11.0.1"
+    ip "^1.1.5"
+    istanbul-lib-coverage "^3.0.0"
+    istanbul-lib-report "^3.0.0"
+    istanbul-reports "^3.0.2"
+    log-update "^4.0.0"
+    nanocolors "^0.2.1"
+    nanoid "^3.1.25"
+    open "^8.0.2"
+    picomatch "^2.2.2"
+    source-map "^0.7.3"
+
+"@web/test-runner-coverage-v8@^0.4.8":
+  version "0.4.9"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.4.9.tgz#334d80cd19fc68c08ec3339b1b1d2725078b51a2"
+  integrity sha512-y9LWL4uY25+fKQTljwr0XTYjeWIwU4h8eYidVuLoW3n1CdFkaddv+smrGzzF5j8XY+Mp6TmV9NdxjvMWqVkDdw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    istanbul-lib-coverage "^3.0.0"
+    picomatch "^2.2.2"
+    v8-to-istanbul "^8.0.0"
+
+"@web/test-runner-coverage-v8@^0.5.0":
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.5.0.tgz#d1b033fd4baddaf5636a41cd017e321a338727a6"
+  integrity sha512-4eZs5K4JG7zqWEhVSO8utlscjbVScV7K6JVwoWWcObFTGAaBMbDVzwGRimyNSzvmfTdIO/Arze4CeUUfCl4iLQ==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    istanbul-lib-coverage "^3.0.0"
+    picomatch "^2.2.2"
+    v8-to-istanbul "^9.0.1"
+
+"@web/test-runner-mocha@^0.7.5":
+  version "0.7.5"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-mocha/-/test-runner-mocha-0.7.5.tgz#696f8cb7f5118a72bd7ac5778367ae3bd3fb92cd"
+  integrity sha512-12/OBq6efPCAvJpcz3XJs2OO5nHe7GtBibZ8Il1a0QtsGpRmuJ4/m1EF0Fj9f6KHg7JdpGo18A37oE+5hXjHwg==
+  dependencies:
+    "@types/mocha" "^8.2.0"
+    "@web/test-runner-core" "^0.10.20"
+
+"@web/test-runner-playwright@^0.9.0":
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-playwright/-/test-runner-playwright-0.9.0.tgz#c13b71ecfe763ae5d15dff586a35a9840c238b1f"
+  integrity sha512-RhWkz1CY3KThHoX89yZ/gz9wDSPujxd2wMWNxqhov4y/XDI+0TS44TWKBfWXnuvlQFZPi8JFT7KibCo3pb/Mcg==
+  dependencies:
+    "@web/test-runner-core" "^0.10.20"
+    "@web/test-runner-coverage-v8" "^0.5.0"
+    playwright "^1.22.2"
+
+"@web/test-runner-visual-regression@^0.6.6":
+  version "0.6.6"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-visual-regression/-/test-runner-visual-regression-0.6.6.tgz#4a4dc734f360cba66a005e07b4a1c0a9ef956444"
+  integrity sha512-010J3zE6z2v7eLLey/l5cYa9/WhPsgzZb3Z6K5nn4Mn5W5LGPs/f+XG3N6+Tx8Q1/RvDqLdFvRs/T6c4ul4dgQ==
+  dependencies:
+    "@types/mkdirp" "^1.0.1"
+    "@types/pixelmatch" "^5.2.2"
+    "@types/pngjs" "^6.0.0"
+    "@web/test-runner-commands" "^0.6.4"
+    "@web/test-runner-core" "^0.10.20"
+    mkdirp "^1.0.4"
+    pixelmatch "^5.2.1"
+    pngjs "^6.0.0"
+
+"@web/test-runner@^0.14.0":
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner/-/test-runner-0.14.0.tgz#fc7b206f3fdc5a1d774cfc8f60159a574d30b185"
+  integrity sha512-9xVKnsviCqXL/xi48l0GpDDfvdczZsKHfEhmZglGMTL+I5viDMAj0GGe7fD9ygJ6UT2+056a3RzyIW5x9lZTDQ==
+  dependencies:
+    "@web/browser-logs" "^0.2.2"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server" "^0.1.33"
+    "@web/test-runner-chrome" "^0.10.7"
+    "@web/test-runner-commands" "^0.6.3"
+    "@web/test-runner-core" "^0.10.27"
+    "@web/test-runner-mocha" "^0.7.5"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    convert-source-map "^1.7.0"
+    diff "^5.0.0"
+    globby "^11.0.1"
+    nanocolors "^0.2.1"
+    portfinder "^1.0.28"
+    source-map "^0.7.3"
+
+"@webcomponents/shadycss@^1.10.2":
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
   integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
@@ -1323,7 +1969,7 @@
   resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.3.tgz#1b5b487bd6436b5b764fd52a612509702c3144b5"
   integrity sha512-zetDJxd89y3X99Kvo4qFx8GKlt6GsvN3UcRZHwU6iFA/0KiOmhkTVhe8oRoTBiTVPZu09x3vCra47+w8Yz1+2Q==
 
-accepts@^1.3.5, accepts@~1.3.4:
+accepts@^1.3.5:
   version "1.3.7"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
   integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
@@ -1331,11 +1977,26 @@
     mime-types "~2.1.24"
     negotiator "0.6.2"
 
+accepts@~1.3.4:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
+  dependencies:
+    mime-types "~2.1.34"
+    negotiator "0.6.3"
+
 accessibility-developer-tools@^2.12.0:
   version "2.12.0"
   resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514"
   integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ=
 
+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"
+
 ajv@^6.12.3:
   version "6.12.6"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
@@ -1351,21 +2012,33 @@
   resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
   integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
 
+ansi-escapes@^4.3.0:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
+  integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
+  dependencies:
+    type-fest "^0.21.3"
+
 ansi-regex@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
-  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1"
+  integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==
 
 ansi-regex@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
-  integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.1.tgz#164daac87ab2d6f6db3a29875e2d1766582dabed"
+  integrity sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==
 
 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-regex@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
 ansi-styles@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@@ -1380,12 +2053,12 @@
   dependencies:
     color-convert "^2.0.1"
 
-any-promise@^1.0.0, any-promise@^1.1.0:
+any-promise@^1.0.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
-  integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
+  integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
 
-anymatch@~3.1.1, anymatch@~3.1.2:
+anymatch@~3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
   integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
@@ -1403,55 +2076,65 @@
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
   integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
 
-array-back@^4.0.1:
+array-back@^4.0.1, array-back@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
   integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
 
+array-union@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
+  integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
+
 arrify@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
   integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
 
 asn1@~0.2.3:
-  version "0.2.4"
-  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
-  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
+  version "0.2.6"
+  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d"
+  integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==
   dependencies:
     safer-buffer "~2.1.0"
 
 assert-plus@1.0.0, assert-plus@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
-  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
+  integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==
 
-assertion-error@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
-  integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
+astral-regex@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
+  integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
 
-async@^2.6.2:
-  version "2.6.3"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
-  integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
+async@^2.6.4:
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+  integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
   dependencies:
     lodash "^4.17.14"
 
 asynckit@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
-  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
+  integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
 
 aws-sign2@~0.7.0:
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
-  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
+  integrity sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==
 
 aws4@^1.8.0:
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
   integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
 
+axe-core@^4.3.3:
+  version "4.4.3"
+  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.3.tgz#11c74d23d5013c0fa5d183796729bc3482bd2f6f"
+  integrity sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w==
+
 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"
@@ -1469,39 +2152,39 @@
     istanbul-lib-instrument "^3.3.0"
     test-exclude "^5.2.3"
 
-babel-plugin-polyfill-corejs2@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.2.2.tgz#e9124785e6fd94f94b618a7954e5693053bf5327"
-  integrity sha512-kISrENsJ0z5dNPq5eRvcctITNHYXWOA4DUZRFYCz3jYCcvTb/A546LIddmoGNMVYg2U38OyFeNosQwI9ENTqIQ==
+babel-plugin-polyfill-corejs2@^0.3.2:
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz#5d1bd3836d0a19e1b84bbf2d9640ccb6f951c122"
+  integrity sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==
   dependencies:
-    "@babel/compat-data" "^7.13.11"
-    "@babel/helper-define-polyfill-provider" "^0.2.2"
+    "@babel/compat-data" "^7.17.7"
+    "@babel/helper-define-polyfill-provider" "^0.3.3"
     semver "^6.1.1"
 
-babel-plugin-polyfill-corejs3@^0.2.2:
-  version "0.2.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.4.tgz#68cb81316b0e8d9d721a92e0009ec6ecd4cd2ca9"
-  integrity sha512-z3HnJE5TY/j4EFEa/qpQMSbcUJZ5JQi+3UFjXzn6pQCmIKc5Ug5j98SuYyH+m4xQnvKlMDIW4plLfgyVnd0IcQ==
+babel-plugin-polyfill-corejs3@^0.5.3:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.3.tgz#d7e09c9a899079d71a8b670c6181af56ec19c5c7"
+  integrity sha512-zKsXDh0XjnrUEW0mxIHLfjBfnXSMr5Q/goMe/fxpQnLm07mcOZiIZHBNWCMx60HmdvjxfXcalac0tfFg0wqxyw==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.2.2"
-    core-js-compat "^3.14.0"
+    "@babel/helper-define-polyfill-provider" "^0.3.2"
+    core-js-compat "^3.21.0"
 
-babel-plugin-polyfill-regenerator@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.2.2.tgz#b310c8d642acada348c1fa3b3e6ce0e851bee077"
-  integrity sha512-Goy5ghsc21HgPDFtzRkSirpZVW35meGoTmTOb2bxqdl60ghub4xOidgNTHaZfQ2FaxQsKmwvXtOAkcIS4SMBWg==
+babel-plugin-polyfill-regenerator@^0.4.0:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz#390f91c38d90473592ed43351e801a9d3e0fd747"
+  integrity sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.2.2"
+    "@babel/helper-define-polyfill-provider" "^0.3.3"
 
 balanced-match@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
-base64-arraybuffer@~1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.1.tgz#87bd13525626db4a9838e00a508c2b73efcf348c"
-  integrity sha512-vFIUq7FdLtjZMhATwDul5RZWv2jpXQ09Pd6jcVEOvIsqCWTRFD/ONHNfyOS8dA/Ippi5dsIgpyKWKZaAKZltbA==
+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, base64id@~2.0.0:
   version "2.0.0"
@@ -1511,7 +2194,7 @@
 bcrypt-pbkdf@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
-  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
+  integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==
   dependencies:
     tweetnacl "^0.14.3"
 
@@ -1520,21 +2203,32 @@
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
-body-parser@^1.19.0:
-  version "1.19.0"
-  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
-  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
+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:
-    bytes "3.1.0"
+    buffer "^5.5.0"
+    inherits "^2.0.4"
+    readable-stream "^3.4.0"
+
+body-parser@^1.19.0:
+  version "1.20.0"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5"
+  integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==
+  dependencies:
+    bytes "3.1.2"
     content-type "~1.0.4"
     debug "2.6.9"
-    depd "~1.1.2"
-    http-errors "1.7.2"
+    depd "2.0.0"
+    destroy "1.2.0"
+    http-errors "2.0.0"
     iconv-lite "0.4.24"
-    on-finished "~2.3.0"
-    qs "6.7.0"
-    raw-body "2.4.0"
-    type-is "~1.6.17"
+    on-finished "2.4.1"
+    qs "6.10.3"
+    raw-body "2.5.1"
+    type-is "~1.6.18"
+    unpipe "1.0.0"
 
 brace-expansion@^1.1.7:
   version "1.1.11"
@@ -1544,7 +2238,7 @@
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-braces@^3.0.2, braces@~3.0.2:
+braces@^3.0.1, braces@^3.0.2, braces@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
   integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@@ -1557,40 +2251,59 @@
   integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==
 
 browserslist-useragent@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/browserslist-useragent/-/browserslist-useragent-3.0.3.tgz#d06c062a4e444ad5e1a80323131d4508450c9af5"
-  integrity sha512-8KKO6kOXu/93IkMi8zVqzU72BgpoxcITIHtkM1qmlnxJtIMF9Y+2uWL9JS2uUbzj/PaS3kaA6LcICBThMojGjA==
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/browserslist-useragent/-/browserslist-useragent-3.1.4.tgz#b2cb15a3a46d5c535f96335c4b8d97f94c3657a9"
+  integrity sha512-o9V55790uae98Kwn+vwyO+ww07OreiH1BUc9bjjlUbIL3Fh43fyoasZxZ2EiI4ErfEIKwbycQ1pvwOBlySJ7ow==
   dependencies:
-    browserslist "^4.12.0"
-    semver "^7.3.2"
+    browserslist "^4.19.1"
+    electron-to-chromium "^1.4.67"
+    semver "^7.3.5"
     useragent "^2.3.0"
+    yamlparser "^0.0.2"
 
-browserslist@*, browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.16.0, browserslist@^4.16.6, browserslist@^4.16.7, browserslist@^4.9.1:
-  version "4.16.7"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.7.tgz#108b0d1ef33c4af1b587c54f390e7041178e4335"
-  integrity sha512-7I4qVwqZltJ7j37wObBe3SoTz+nS8APaNcrBOlgoirb6/HbEU2XxW/LpUDTCngM6iauwFqmRTuOMfyKnFGY5JA==
+browserslist@*, browserslist@^4.0.0, browserslist@^4.16.5, browserslist@^4.19.1, browserslist@^4.20.2, browserslist@^4.21.3, browserslist@^4.9.1:
+  version "4.21.3"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a"
+  integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ==
   dependencies:
-    caniuse-lite "^1.0.30001248"
-    colorette "^1.2.2"
-    electron-to-chromium "^1.3.793"
-    escalade "^3.1.1"
-    node-releases "^1.1.73"
+    caniuse-lite "^1.0.30001370"
+    electron-to-chromium "^1.4.202"
+    node-releases "^2.0.6"
+    update-browserslist-db "^1.0.5"
+
+buffer-crc32@~0.2.3:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+  integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
 
 buffer-from@^1.0.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
   integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
-builtin-modules@^3.1.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887"
-  integrity sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==
+buffer@^5.2.1, buffer@^5.5.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
+  integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
+  dependencies:
+    base64-js "^1.3.1"
+    ieee754 "^1.1.13"
 
-bytes@3.1.0, bytes@^3.0.0:
+builtin-modules@^3.1.0, builtin-modules@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
+  integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
+
+bytes@3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
   integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
 
+bytes@3.1.2, bytes@^3.0.0:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
+  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
+
 cache-content-type@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
@@ -1599,7 +2312,7 @@
     mime-types "^2.1.18"
     ylru "^1.2.0"
 
-call-bind@^1.0.0:
+call-bind@^1.0.0, call-bind@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
   integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
@@ -1620,10 +2333,10 @@
   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==
+camelcase@^6.0.0, camelcase@^6.2.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+  integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
 
 caniuse-api@^3.0.0:
   version "3.0.0"
@@ -1635,27 +2348,22 @@
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001248:
-  version "1.0.30001251"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001251.tgz#6853a606ec50893115db660f82c094d18f096d85"
-  integrity sha512-HOe1r+9VkU4TFmnU70z+r7OLmtR+/chB1rdcJUeQlAinjEeb0cKL20tlAtOagNZhbrtLnCvV19B4FmF1rgzl6A==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001370:
+  version "1.0.30001399"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001399.tgz#1bf994ca375d7f33f8d01ce03b7d5139e8587873"
+  integrity sha512-4vQ90tMKS+FkvuVWS5/QY1+d805ODxZiKFzsU8o/RsVJz49ZSRR8EjykLJbqhzdPgadbX6wB538wOzle3JniRA==
 
 caseless@~0.12.0:
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
-  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
+  integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
 
-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==
+chai-a11y-axe@^1.3.2:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.4.0.tgz#e584af967727a8656e27c32e845f5db21f2bf2e0"
+  integrity sha512-m7J6DVAl1ePL2ifPKHmwQyHXdCZ+Qfv+qduh6ScqcDfBnJEzpV1K49TblujM45j1XciZOFeFNqMb2sShXMg/mw==
   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.1"
-    type-detect "^4.0.5"
+    axe-core "^4.3.3"
 
 chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.2:
   version "2.4.2"
@@ -1666,7 +2374,7 @@
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@^4.0.0:
+chalk@^4.1.0:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
   integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -1674,27 +2382,22 @@
     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.5.1:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a"
-  integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==
+chokidar@3.5.3, chokidar@^3.0.0, chokidar@^3.5.1:
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
+  integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
   dependencies:
-    anymatch "~3.1.1"
+    anymatch "~3.1.2"
     braces "~3.0.2"
-    glob-parent "~5.1.0"
+    glob-parent "~5.1.2"
     is-binary-path "~2.1.0"
     is-glob "~4.0.1"
     normalize-path "~3.0.0"
-    readdirp "~3.5.0"
+    readdirp "~3.6.0"
   optionalDependencies:
-    fsevents "~2.3.1"
+    fsevents "~2.3.2"
 
-chokidar@^3.0.0, chokidar@^3.4.3, chokidar@^3.5.1:
+chokidar@^3.4.3:
   version "3.5.2"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75"
   integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==
@@ -1709,13 +2412,35 @@
   optionalDependencies:
     fsevents "~2.3.2"
 
+chownr@^1.1.1:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
+  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+
+chrome-launcher@^0.15.0:
+  version "0.15.1"
+  resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.1.tgz#0a0208037063641e2b3613b7e42b0fcb3fa2d399"
+  integrity sha512-UugC8u59/w2AyX5sHLZUHoxBAiSiunUhZa3zZwMH6zPVis0C3dDKiRWyUGIo14tTbZHGVviWxv3PQWZ7taZ4fg==
+  dependencies:
+    "@types/node" "*"
+    escape-string-regexp "^4.0.0"
+    is-wsl "^2.2.0"
+    lighthouse-logger "^1.0.0"
+
 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==
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178"
+  integrity sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==
   dependencies:
     source-map "~0.6.0"
 
+cli-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
+  integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
+  dependencies:
+    restore-cursor "^3.1.0"
+
 cliui@^7.0.2:
   version "7.0.4"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
@@ -1730,6 +2455,16 @@
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
   integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
 
+co-body@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/co-body/-/co-body-6.1.0.tgz#d87a8efc3564f9bfe3aced8ef5cd04c7a8766547"
+  integrity sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==
+  dependencies:
+    inflation "^2.0.0"
+    qs "^6.5.2"
+    raw-body "^2.3.3"
+    type-is "^1.6.16"
+
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -1759,16 +2494,6 @@
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
-colorette@^1.2.2:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.3.0.tgz#ff45d2f0edb244069d3b772adeb04fed38d0a0af"
-  integrity sha512-ecORCqbSFP7Wm8Y6lyqMJjexBQqXSF7SSeaTyGGphogUjBlFP9m9o08wy86HL2uB7fMTxtOUzLMk7ogKcxMg1w==
-
-colors@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
-  integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
-
 combined-stream@^1.0.6, combined-stream@~1.0.6:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@@ -1776,24 +2501,24 @@
   dependencies:
     delayed-stream "~1.0.0"
 
-command-line-args@^5.0.2:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.0.tgz#087b02748272169741f1fd7c785b295df079b9be"
-  integrity sha512-4zqtU1hYsSJzcJBOcNZIbW5Fbk9BkjCp1pZVhQKoRaWL5J7N4XphDLwo8aWwdQpTugxwu+jf9u2ZhkXiqp5Z6A==
+command-line-args@^5.0.2, command-line-args@^5.1.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+  integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
   dependencies:
     array-back "^3.1.0"
     find-replace "^3.0.0"
     lodash.camelcase "^4.3.0"
     typical "^4.0.0"
 
-command-line-usage@^6.1.0:
-  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==
+command-line-usage@^6.1.0, command-line-usage@^6.1.1:
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.3.tgz#428fa5acde6a838779dfa30e44686f4b6761d957"
+  integrity sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==
   dependencies:
-    array-back "^4.0.1"
+    array-back "^4.0.2"
     chalk "^2.4.2"
-    table-layout "^1.0.1"
+    table-layout "^1.0.2"
     typical "^5.2.0"
 
 commander@^2.20.0:
@@ -1806,11 +2531,6 @@
   resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
   integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
 
-component-emitter@~1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
-  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
-
 compressible@^2.0.0:
   version "2.0.18"
   resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
@@ -1845,7 +2565,7 @@
   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.7.0:
+convert-source-map@^1.6.0, convert-source-map@^1.7.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
   integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
@@ -1853,9 +2573,9 @@
     safe-buffer "~5.1.1"
 
 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==
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
+  integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
 
 cookies@~0.8.0:
   version "0.8.0"
@@ -1866,22 +2586,21 @@
     keygrip "~1.1.0"
 
 core-js-bundle@^3.6.0, core-js-bundle@^3.8.1:
-  version "3.16.1"
-  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.16.1.tgz#410c73317f7154dc4aac0674556b7003a7f4c47f"
-  integrity sha512-pPavAOLKXD2YXNBhS3jq4WMGJPeqgo4W9WZ7GebxXTZY/jvnD5ID+J3nUOCS7UXwCNsQKbbUg1+hp/4rmvzNeg==
+  version "3.25.1"
+  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.25.1.tgz#72e3b3a11d2d9f671e5ff740135223394c5fcb85"
+  integrity sha512-f1FcTJFuKTJNSfpKAOJY/ehGLIPhQMUlQwJHGmIdEHROgcntQqRU6LGpyuIfJVjHlypY9A2DhG9qkT+uRwvwGQ==
 
-core-js-compat@^3.14.0, core-js-compat@^3.16.0:
-  version "3.16.1"
-  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.16.1.tgz#c44b7caa2dcb94b673a98f27eee1c8312f55bc2d"
-  integrity sha512-NHXQXvRbd4nxp9TEmooTJLUf94ySUG6+DSsscBpTftN1lQLQ4LjnWvc7AoIo4UjDsFF3hB8Uh5LLCRRdaiT5MQ==
+core-js-compat@^3.21.0, core-js-compat@^3.22.1:
+  version "3.25.1"
+  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.25.1.tgz#6f13a90de52f89bbe6267e5620a412c7f7ff7e42"
+  integrity sha512-pOHS7O0i8Qt4zlPW/eIFjwp+NrTPx+wTL0ctgI2fHn31sZOq89rDsmtc/A2vAX7r6shl+bmVI+678He46jgBlw==
   dependencies:
-    browserslist "^4.16.7"
-    semver "7.0.0"
+    browserslist "^4.21.3"
 
 core-util-is@1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
-  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
+  integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==
 
 cors@~2.8.5:
   version "2.8.5"
@@ -1891,80 +2610,75 @@
     object-assign "^4"
     vary "^1"
 
+cross-fetch@3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
+  integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
+  dependencies:
+    node-fetch "2.6.7"
+
 custom-event@~1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
-  integrity sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU=
+  integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==
 
 dashdash@^1.12.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
-  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
+  integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==
   dependencies:
     assert-plus "^1.0.0"
 
-date-format@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/date-format/-/date-format-2.1.0.tgz#31d5b5ea211cf5fd764cd38baf9d033df7e125cf"
-  integrity sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==
-
-date-format@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/date-format/-/date-format-3.0.0.tgz#eb8780365c7d2b1511078fb491e6479780f3ad95"
-  integrity sha512-eyTcpKOcamdhWJXj56DpQMo1ylSQpcGtGKXcU0Tb97+K56/CF5amAqqqNj0+KvA0iw2ynxtHWFsPDSClCxe48w==
+date-format@^4.0.13:
+  version "4.0.13"
+  resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.13.tgz#87c3aab3a4f6f37582c5f5f63692d2956fa67890"
+  integrity sha512-bnYCwf8Emc3pTD8pXnre+wfnjGtfi5ncMDKy7+cWZXbmRAsdWkOQHrfC1yz/KiwP5thDp2kCHWYWKBX4HP1hoQ==
 
 debounce@^1.2.0:
   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:
+debug@2.6.9, debug@^2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
   dependencies:
     ms "2.0.0"
 
-debug@4.3.1:
-  version "4.3.1"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
-  integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
+debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
   dependencies:
     ms "2.1.2"
 
-debug@^3.1.0, debug@^3.1.1:
+debug@4.3.3:
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664"
+  integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==
+  dependencies:
+    ms "2.1.2"
+
+debug@^3.1.0, debug@^3.2.7:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
   integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
   dependencies:
     ms "^2.1.1"
 
-debug@^4.1.0, debug@^4.1.1, debug@~4.3.1, debug@~4.3.2:
+debug@^4.1.1, debug@^4.3.2:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
   integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
   dependencies:
     ms "2.1.2"
 
-debug@~3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
-  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
-  dependencies:
-    ms "2.0.0"
-
 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"
-  resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
-  integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==
-  dependencies:
-    type-detect "^4.0.0"
-
 deep-equal@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
@@ -1980,24 +2694,30 @@
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
   integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
 
-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==
+define-lazy-prop@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
+  integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
+
+define-properties@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1"
+  integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==
   dependencies:
-    object-keys "^1.0.12"
+    has-property-descriptors "^1.0.0"
+    object-keys "^1.1.1"
 
 delayed-stream@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
-  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
+  integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
 
 delegates@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
   integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
 
-depd@^2.0.0, depd@~2.0.0:
+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==
@@ -2007,30 +2727,52 @@
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
   integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
 
+dependency-graph@^0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27"
+  integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==
+
+destroy@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
 destroy@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
   integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
 
+devtools-protocol@0.0.981744:
+  version "0.0.981744"
+  resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf"
+  integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==
+
 di@^0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c"
-  integrity sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=
+  integrity sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==
 
 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"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
-  integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
+diff@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
+  integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
+
+dir-glob@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
+  integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==
+  dependencies:
+    path-type "^4.0.0"
 
 dom-serialize@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
-  integrity sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=
+  integrity sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==
   dependencies:
     custom-event "~1.0.0"
     ent "~2.2.0"
@@ -2053,7 +2795,7 @@
 ecc-jsbn@~0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
-  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
+  integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==
   dependencies:
     jsbn "~0.1.0"
     safer-buffer "^2.1.0"
@@ -2063,10 +2805,10 @@
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
-electron-to-chromium@^1.3.793:
-  version "1.3.806"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.806.tgz#21502100f11aead6c501d1cd7f2504f16c936642"
-  integrity sha512-AH/otJLAAecgyrYp0XK1DPiGVWcOgwPeJBOLeuFQ5l//vhQhwC9u6d+GijClqJAmsHG4XDue81ndSQPohUu0xA==
+electron-to-chromium@^1.4.202, electron-to-chromium@^1.4.67:
+  version "1.4.249"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.249.tgz#49c34336c742ee65453dbddf4c84355e59b96e2c"
+  integrity sha512-GMCxR3p2HQvIw47A599crTKYZprqihoBL4lDSAUmr7IYekXFK5t/WgEBrGJDCa2HWIZFQEkGuMqPCi05ceYqPQ==
 
 emoji-regex@^8.0.0:
   version "8.0.0"
@@ -2078,24 +2820,22 @@
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
   integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
 
-end-of-stream@^1.1.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"
   integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
   dependencies:
     once "^1.4.0"
 
-engine.io-parser@~5.0.0:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.1.tgz#6695fc0f1e6d76ad4a48300ff80db5f6b3654939"
-  integrity sha512-j4p3WwJrG2k92VISM0op7wiq60vO92MlF3CRGxhKHy9ywG1/Dkc72g0dXeDQ+//hrcDn8gqQzoEkdO9FN0d9AA==
-  dependencies:
-    base64-arraybuffer "~1.0.1"
+engine.io-parser@~5.0.3:
+  version "5.0.4"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.4.tgz#0b13f704fa9271b3ec4f33112410d8f3f41d0fc0"
+  integrity sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==
 
-engine.io@~6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.0.0.tgz#2b993fcd73e6b3a6abb52b40b803651cd5747cf0"
-  integrity sha512-Ui7yl3JajEIaACg8MOUwWvuuwU7jepZqX3BKs1ho7NQRuP4LhN4XIykXhp8bEy+x/DhA0LBZZXYSCkZDqrwMMg==
+engine.io@~6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.2.0.tgz#003bec48f6815926f2b1b17873e576acd54f41d0"
+  integrity sha512-4KzwW3F3bk+KlzSOY57fj/Jx6LyRQ1nbcyIadehl+AnXjKT7gDO0ORdRi/84ixvMKTym6ZKuxvbzN62HDDU1Lg==
   dependencies:
     "@types/cookie" "^0.4.1"
     "@types/cors" "^2.8.12"
@@ -2105,13 +2845,13 @@
     cookie "~0.4.1"
     cors "~2.8.5"
     debug "~4.3.1"
-    engine.io-parser "~5.0.0"
+    engine.io-parser "~5.0.3"
     ws "~8.2.3"
 
 ent@~2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
-  integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0=
+  integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==
 
 error-ex@^1.3.1:
   version "1.3.2"
@@ -2120,7 +2860,12 @@
   dependencies:
     is-arrayish "^0.2.1"
 
-es-dev-server@^1.57.0:
+errorstacks@^2.2.0:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.3.2.tgz#cab2c7c83e199a2b2862de3fea46f68372094166"
+  integrity sha512-cJp8qf5t2cXmVZJjZVrcU4ODFJeQOcUyjJEtPFtWO+3N6JPM6vCe4Sfv3cwIs/qS7gnUo/fvKX/mDCVQZq+P7A==
+
+es-dev-server@^1.57.8:
   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==
@@ -2196,11 +2941,148 @@
   resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.3.26.tgz#7b507044e97d5b03b01d4392c74ffeb9c177a83b"
   integrity sha512-Va0Q/xqtrss45hWzP8CZJwzGSZJjDM5/MJRE3IXXnUCcVLElR9BRaE9F62BopysASyc4nM3uwhSW7FFB9nlWAA==
 
+es-module-lexer@^0.9.0:
+  version "0.9.3"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19"
+  integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==
+
+es-module-lexer@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.0.3.tgz#f0d8d35b36d13024110000d5e6fadc8eeaeb66b8"
+  integrity sha512-iC67eXHToclrlVhQfpRawDiF8D8sQxNxmbqw5oebegOaJkyx/w9C/k57/5e6yJR2zIByRt9OXdqX50DV2t6ZKw==
+
 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==
 
+esbuild-android-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
+  integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
+
+esbuild-android-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
+  integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
+
+esbuild-darwin-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
+  integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
+
+esbuild-darwin-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
+  integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
+
+esbuild-freebsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
+  integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
+
+esbuild-freebsd-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
+  integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
+
+esbuild-linux-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
+  integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
+
+esbuild-linux-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652"
+  integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
+
+esbuild-linux-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
+  integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
+
+esbuild-linux-arm@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
+  integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
+
+esbuild-linux-mips64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
+  integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
+
+esbuild-linux-ppc64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
+  integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
+
+esbuild-linux-riscv64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
+  integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
+
+esbuild-linux-s390x@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
+  integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
+
+esbuild-netbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
+  integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
+
+esbuild-openbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
+  integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
+
+esbuild-sunos-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
+  integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
+
+esbuild-windows-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
+  integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
+
+esbuild-windows-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
+  integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
+
+esbuild-windows-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
+  integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
+
+"esbuild@^0.12 || ^0.13 || ^0.14":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
+  integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
+  optionalDependencies:
+    "@esbuild/linux-loong64" "0.14.54"
+    esbuild-android-64 "0.14.54"
+    esbuild-android-arm64 "0.14.54"
+    esbuild-darwin-64 "0.14.54"
+    esbuild-darwin-arm64 "0.14.54"
+    esbuild-freebsd-64 "0.14.54"
+    esbuild-freebsd-arm64 "0.14.54"
+    esbuild-linux-32 "0.14.54"
+    esbuild-linux-64 "0.14.54"
+    esbuild-linux-arm "0.14.54"
+    esbuild-linux-arm64 "0.14.54"
+    esbuild-linux-mips64le "0.14.54"
+    esbuild-linux-ppc64le "0.14.54"
+    esbuild-linux-riscv64 "0.14.54"
+    esbuild-linux-s390x "0.14.54"
+    esbuild-netbsd-64 "0.14.54"
+    esbuild-openbsd-64 "0.14.54"
+    esbuild-sunos-64 "0.14.54"
+    esbuild-windows-32 "0.14.54"
+    esbuild-windows-64 "0.14.54"
+    esbuild-windows-arm64 "0.14.54"
+
 escalade@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
@@ -2211,7 +3093,7 @@
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
   integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
 
-escape-string-regexp@4.0.0:
+escape-string-regexp@4.0.0, 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==
@@ -2231,10 +3113,10 @@
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
-etag@^1.3.0:
+etag@^1.3.0, etag@^1.8.1:
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
-  integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
+  integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
 
 eventemitter3@^4.0.0:
   version "4.0.7"
@@ -2246,26 +3128,62 @@
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
   integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
 
+extract-zip@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
+  integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
+  dependencies:
+    debug "^4.1.1"
+    get-stream "^5.1.0"
+    yauzl "^2.10.0"
+  optionalDependencies:
+    "@types/yauzl" "^2.9.1"
+
 extsprintf@1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
-  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
+  integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==
 
 extsprintf@^1.2.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
-  integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07"
+  integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==
 
 fast-deep-equal@^3.1.1:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
+fast-glob@^3.1.1:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1"
+  integrity sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.2"
+    merge2 "^1.3.0"
+    micromatch "^4.0.4"
+
 fast-json-stable-stringify@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
   integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
 
+fastq@^1.6.0:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
+  integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==
+  dependencies:
+    reusify "^1.0.4"
+
+fd-slicer@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+  integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==
+  dependencies:
+    pend "~1.2.0"
+
 fill-range@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
@@ -2308,25 +3226,33 @@
   dependencies:
     locate-path "^3.0.0"
 
+find-up@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
+
 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.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138"
-  integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==
+flatted@^3.2.6:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787"
+  integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==
 
 follow-redirects@^1.0.0:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
-  integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==
+  version "1.15.2"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
+  integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
 
 forever-agent@~0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
-  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
+  integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==
 
 form-data@~2.3.2:
   version "2.3.3"
@@ -2342,6 +3268,11 @@
   resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
   integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
 
+fs-constants@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
+  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+
 fs-extra@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
@@ -2356,7 +3287,7 @@
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
-fsevents@~2.3.1, fsevents@~2.3.2:
+fsevents@~2.3.2:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
   integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
@@ -2376,11 +3307,6 @@
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
-get-func-name@^2.0.0:
-  version "2.0.0"
-  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"
@@ -2390,6 +3316,15 @@
     has "^1.0.3"
     has-symbols "^1.0.1"
 
+get-intrinsic@^1.1.1:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385"
+  integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==
+  dependencies:
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.3"
+
 get-stream@^5.1.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
@@ -2397,24 +3332,29 @@
   dependencies:
     pump "^3.0.0"
 
+get-stream@^6.0.0:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
+  integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
+
 getpass@^0.1.1:
   version "0.1.7"
   resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
-  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
+  integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==
   dependencies:
     assert-plus "^1.0.0"
 
-glob-parent@~5.1.0, glob-parent@~5.1.2:
+glob-parent@^5.1.2, glob-parent@~5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
   integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
   dependencies:
     is-glob "^4.0.1"
 
-glob@7.1.6:
-  version "7.1.6"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
-  integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
+glob@7.2.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
+  integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
   dependencies:
     fs.realpath "^1.0.0"
     inflight "^1.0.4"
@@ -2423,7 +3363,7 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@^7.1.3, glob@^7.1.7:
+glob@^7.1.3:
   version "7.1.7"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
   integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
@@ -2435,15 +3375,39 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+glob@^7.1.7:
+  version "7.2.3"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+  integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.1.1"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 globals@^11.1.0:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
   integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
 
+globby@^11.0.1:
+  version "11.0.4"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5"
+  integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==
+  dependencies:
+    array-union "^2.1.0"
+    dir-glob "^3.0.1"
+    fast-glob "^3.1.1"
+    ignore "^5.1.4"
+    merge2 "^1.3.0"
+    slash "^3.0.0"
+
 graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.6:
-  version "4.2.8"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
-  integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
+  version "4.2.10"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
+  integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
 
 growl@1.10.5:
   version "1.10.5"
@@ -2453,7 +3417,7 @@
 har-schema@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
-  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
+  integrity sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==
 
 har-validator@~5.1.3:
   version "5.1.5"
@@ -2473,11 +3437,23 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
+has-property-descriptors@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861"
+  integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==
+  dependencies:
+    get-intrinsic "^1.1.1"
+
 has-symbols@^1.0.1, has-symbols@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
   integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
 
+has-symbols@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
+  integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+
 has-tostringtag@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
@@ -2502,6 +3478,11 @@
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
   integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
+html-escaper@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
+  integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
+
 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"
@@ -2523,17 +3504,28 @@
     deep-equal "~1.0.1"
     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==
+http-errors@1.7.3, 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.3"
+    inherits "2.0.4"
     setprototypeof "1.1.1"
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
+http-errors@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+  integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
+  dependencies:
+    depd "2.0.0"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses "2.0.1"
+    toidentifier "1.0.1"
+
 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"
@@ -2555,17 +3547,6 @@
     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@^1.18.1:
   version "1.18.1"
   resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
@@ -2578,12 +3559,20 @@
 http-signature@~1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
-  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
+  integrity sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==
   dependencies:
     assert-plus "^1.0.0"
     jsprim "^1.2.2"
     sshpk "^1.7.0"
 
+https-proxy-agent@5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
+  integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
+  dependencies:
+    agent-base "6"
+    debug "4"
+
 iconv-lite@0.4.24:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -2591,6 +3580,21 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
+ieee754@^1.1.13:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
+  integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
+
+ignore@^5.1.4:
+  version "5.1.9"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.9.tgz#9ec1a5cbe8e1446ec60d4420060d43aa6e7382fb"
+  integrity sha512-2zeMQpbKz5dhZ9IwL0gbxSW5w0NK/MSAMtNuhgIHEPmaU3vPdKPL0UdvUCXs5SS4JAwsBxysK5sFMW8ocFiVjQ==
+
+inflation@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
+  integrity sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8=
+
 inflight@^1.0.4:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@@ -2599,7 +3603,7 @@
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4:
+inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -2614,10 +3618,15 @@
   resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.7.0.tgz#ee16bee978db53516ead2f0a8154b09b400bbdc9"
   integrity sha512-Id0Fij0HsB/vKWGeBe9PxeY45ttRiBmhFyyt/geBdDHBYNctMRTE3dC1U3ujzz3lap+hVXlEcVaB56kZP/eEUg==
 
+ip@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
+  integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
+
 is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
-  integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
+  integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
 
 is-binary-path@~2.1.0:
   version "2.1.0"
@@ -2626,6 +3635,13 @@
   dependencies:
     binary-extensions "^2.0.0"
 
+is-builtin-module@^3.1.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.0.tgz#bb0310dfe881f144ca83f30100ceb10cf58835e0"
+  integrity sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==
+  dependencies:
+    builtin-modules "^3.3.0"
+
 is-core-module@^2.2.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.5.0.tgz#f754843617c70bfd29b7bd87327400cda5c18491"
@@ -2633,7 +3649,14 @@
   dependencies:
     has "^1.0.3"
 
-is-docker@^2.0.0:
+is-core-module@^2.9.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
+  integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
+  dependencies:
+    has "^1.0.3"
+
+is-docker@^2.0.0, is-docker@^2.1.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
   integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
@@ -2643,11 +3666,6 @@
   resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
-is-fullwidth-code-point@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
-  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
-
 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"
@@ -2690,9 +3708,14 @@
 is-typedarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
-  integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
+  integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==
 
-is-wsl@^2.1.1:
+is-unicode-supported@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7"
+  integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
+
+is-wsl@^2.1.1, is-wsl@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
   integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
@@ -2705,6 +3728,11 @@
   integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
 
 isbinaryfile@^4.0.2, isbinaryfile@^4.0.8:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
+  integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==
+
+isbinaryfile@^4.0.6:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.8.tgz#5d34b94865bd4946633ecc78a026fc76c5b11fcf"
   integrity sha512-53h6XFniq77YdW+spoRrebh0mnmTxRPTlcuIArO57lmMdq4uBKFKaeTjnb92oYWrSn/LVL+LT+Hap2tFQj8V+w==
@@ -2712,18 +3740,23 @@
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
-  integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
+  integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
 
 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=
+  integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==
 
 istanbul-lib-coverage@^2.0.5:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz#675f0ab69503fad4b1d849f736baaca803344f49"
   integrity sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==
 
+istanbul-lib-coverage@^3.0.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3"
+  integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==
+
 istanbul-lib-instrument@^3.3.0:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz#a5f63d91f0bbc0c3e479ef4c5de027335ec6d630"
@@ -2737,22 +3770,39 @@
     istanbul-lib-coverage "^2.0.5"
     semver "^6.0.0"
 
+istanbul-lib-report@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6"
+  integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==
+  dependencies:
+    istanbul-lib-coverage "^3.0.0"
+    make-dir "^3.0.0"
+    supports-color "^7.1.0"
+
+istanbul-reports@^3.0.2:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.5.tgz#a2580107e71279ea6d661ddede929ffc6d693384"
+  integrity sha512-5+19PlhnGabNWB7kOFnuxT8H3T/iIyQzIbQMxXsURmmvKg86P2sbkrGOT77VnHw0Qr0gc2XzRaRfMZYYbSQCJQ==
+  dependencies:
+    html-escaper "^2.0.0"
+    istanbul-lib-report "^3.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@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==
+js-yaml@4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
+  integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
   dependencies:
     argparse "^2.0.1"
 
 jsbn@~0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
-  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
+  integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==
 
 jsesc@^2.5.1:
   version "2.5.2"
@@ -2762,7 +3812,7 @@
 jsesc@~0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
-  integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
+  integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==
 
 json-parse-better-errors@^1.0.1:
   version "1.0.2"
@@ -2774,38 +3824,36 @@
   resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
   integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
 
-json-schema@0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
-  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
+json-schema@0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
+  integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
 
 json-stringify-safe@~5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
-  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
+  integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==
 
-json5@^2.1.2:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
-  integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
-  dependencies:
-    minimist "^1.2.5"
+json5@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
+  integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
 
 jsonfile@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
-  integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=
+  integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==
   optionalDependencies:
     graceful-fs "^4.1.6"
 
 jsprim@^1.2.2:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
-  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb"
+  integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==
   dependencies:
     assert-plus "1.0.0"
     extsprintf "1.3.0"
-    json-schema "0.2.3"
+    json-schema "0.4.0"
     verror "1.10.0"
 
 just-extend@^4.0.2:
@@ -2813,17 +3861,17 @@
   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"
-  resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.1.0.tgz#805a586799a4d05f4e54f72a204979f3f3066738"
-  integrity sha512-3dPs/n7vgz1rxxtynpzZTvb9y/GIaW8xjAwcIGttLbycqoFtI7yo1NGnQi6oFTherRE+GIhCAHZC4vEqWGhNvg==
+karma-chrome-launcher@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.1.1.tgz#baca9cc071b1562a1db241827257bfe5cab597ea"
+  integrity sha512-hsIglcq1vtboGPAN+DGCISCFOxW+ZVnIqhDQcCMqqCp+4dmJ0Qpq5QAjkbA0X2L9Mi6OBkHi2Srrbmm7pUKkzQ==
   dependencies:
     which "^1.2.1"
 
 karma-mocha-reporter@^2.2.5:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/karma-mocha-reporter/-/karma-mocha-reporter-2.2.5.tgz#15120095e8ed819186e47a0b012f3cd741895560"
-  integrity sha1-FRIAlejtgZGG5HoLAS8810GJVWA=
+  integrity sha512-Hr6nhkIp0GIJJrvzY8JFeHpQZNseuIakGac4bpw8K1+5F0tLb6l7uvXRa8mt2Z+NVwYgCct4QAfp2R2QP6o00w==
   dependencies:
     chalk "^2.1.0"
     log-symbols "^2.1.0"
@@ -2836,15 +3884,15 @@
   dependencies:
     minimist "^1.2.3"
 
-karma@^6.3.6:
-  version "6.3.6"
-  resolved "https://registry.yarnpkg.com/karma/-/karma-6.3.6.tgz#6f64cdd558c7d0c9da6fcdece156089582694611"
-  integrity sha512-xsiu3D6AjCv6Uq0YKXJgC6TvXX2WloQ5+XtHXmC1lwiLVG617DDV3W2DdM4BxCMKHlmz6l3qESZHFQGHAKvrew==
+karma@^6.3.20:
+  version "6.4.0"
+  resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.0.tgz#82652dfecdd853ec227b74ed718a997028a99508"
+  integrity sha512-s8m7z0IF5g/bS5ONT7wsOavhW4i4aFkzD4u4wgzAQWT4HGUeWI3i21cK2Yz6jndMAeHETp5XuNsRoyGJZXVd4w==
   dependencies:
+    "@colors/colors" "1.5.0"
     body-parser "^1.19.0"
     braces "^3.0.2"
     chokidar "^3.5.1"
-    colors "^1.4.0"
     connect "^3.7.0"
     di "^0.0.1"
     dom-serialize "^2.2.1"
@@ -2853,13 +3901,14 @@
     http-proxy "^1.18.1"
     isbinaryfile "^4.0.8"
     lodash "^4.17.21"
-    log4js "^6.3.0"
+    log4js "^6.4.1"
     mime "^2.5.2"
     minimatch "^3.0.4"
+    mkdirp "^0.5.5"
     qjobs "^1.2.0"
     range-parser "^1.2.1"
     rimraf "^3.0.2"
-    socket.io "^4.2.0"
+    socket.io "^4.4.1"
     source-map "^0.6.1"
     tmp "^0.2.1"
     ua-parser-js "^0.7.30"
@@ -2872,13 +3921,6 @@
   dependencies:
     tsscmp "1.0.6"
 
-koa-compose@^3.0.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-3.2.1.tgz#a85ccb40b7d986d8e5a345b3a1ace8eabcf54de7"
-  integrity sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=
-  dependencies:
-    any-promise "^1.1.0"
-
 koa-compose@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
@@ -2894,28 +3936,35 @@
     koa-is-json "^1.0.0"
     statuses "^1.0.0"
 
-koa-convert@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-1.2.0.tgz#da40875df49de0539098d1700b50820cebcd21d0"
-  integrity sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=
+koa-convert@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5"
+  integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==
   dependencies:
     co "^4.6.0"
-    koa-compose "^3.0.0"
+    koa-compose "^4.1.0"
 
 koa-etag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/koa-etag/-/koa-etag-3.0.0.tgz#9ef7382ddd5a82ab0deb153415c915836f771d3f"
-  integrity sha1-nvc4Ld1agqsN6xU0FckVg293HT8=
+  integrity sha512-HYU1zIsH4S9xOlUZGuZIP1PIiJ0EkBXgwL8PjFECb/pUYmAee8gfcvIovregBMYxECDhLulEWT2+ZRsA/lczCQ==
   dependencies:
     etag "^1.3.0"
     mz "^2.1.0"
 
+koa-etag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/koa-etag/-/koa-etag-4.0.0.tgz#2c2bb7ae69ca1ac6ced09ba28dcb78523c810414"
+  integrity sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==
+  dependencies:
+    etag "^1.8.1"
+
 koa-is-json@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/koa-is-json/-/koa-is-json-1.0.0.tgz#273c07edcdcb8df6a2c1ab7d59ee76491451ec14"
-  integrity sha1-JzwH7c3Ljfaiwat9We52SRRR7BQ=
+  integrity sha512-+97CtHAlWDx0ndt0J8y3P12EWLwTLMXIfMnYDev3wOTwH/RpBGMlfn4bDXlMEg1u73K6XRE9BbUp+5ZAYoRYWw==
 
-koa-send@^5.0.0:
+koa-send@^5.0.0, koa-send@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79"
   integrity sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==
@@ -2932,17 +3981,17 @@
     debug "^3.1.0"
     koa-send "^5.0.0"
 
-koa@^2.7.0:
-  version "2.13.1"
-  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.1.tgz#6275172875b27bcfe1d454356a5b6b9f5a9b1051"
-  integrity sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w==
+koa@^2.13.0, koa@^2.7.0:
+  version "2.13.4"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e"
+  integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==
   dependencies:
     accepts "^1.3.5"
     cache-content-type "^1.0.0"
     content-disposition "~0.5.2"
     content-type "^1.0.4"
     cookies "~0.8.0"
-    debug "~3.1.0"
+    debug "^4.3.2"
     delegates "^1.0.0"
     depd "^2.0.0"
     destroy "^1.0.4"
@@ -2953,7 +4002,7 @@
     http-errors "^1.6.3"
     is-generator-function "^1.0.7"
     koa-compose "^4.1.0"
-    koa-convert "^1.2.0"
+    koa-convert "^2.0.0"
     on-finished "^2.3.0"
     only "~0.0.2"
     parseurl "^1.3.2"
@@ -2961,10 +4010,42 @@
     type-is "^1.6.16"
     vary "^1.1.2"
 
+lighthouse-logger@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.3.0.tgz#ba6303e739307c4eee18f08249524e7dafd510db"
+  integrity sha512-BbqAKApLb9ywUli+0a+PcV04SyJ/N1q/8qgCNe6U97KbPCS1BTksEuHFLYdvc8DltuhfxIUBqDZsC0bBGtl3lA==
+  dependencies:
+    debug "^2.6.9"
+    marky "^1.2.2"
+
+lit-element@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.2.tgz#6422b68ba166a32695f524d6f3eb41712610bf50"
+  integrity sha512-9vTJ47D2DSE4Jwhle7aMzEwO2ZcOPRikqfT3CVG7Qol2c9/I4KZwinZNW5Xv8hNm+G/enSSfIwqQhIXi6ioAUg==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    lit-html "^2.0.0"
+
+lit-html@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.2.tgz#6a17caac4135757710c5fb3e4becc622c476e431"
+  integrity sha512-dON7Zg8btb14/fWohQLQBdSgkoiQA4mIUy87evmyJHtxRq7zS6LlC32bT5EPWiof5PUQaDpF45v2OlrxHA5Clg==
+  dependencies:
+    "@types/trusted-types" "^2.0.2"
+
+lit@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
+  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    lit-element "^3.0.0"
+    lit-html "^2.0.0"
+
 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"
-  integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs=
+  integrity sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==
   dependencies:
     graceful-fs "^4.1.2"
     parse-json "^4.0.0"
@@ -2979,6 +4060,13 @@
     p-locate "^3.0.0"
     path-exists "^3.0.0"
 
+locate-path@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
+  dependencies:
+    p-locate "^4.1.0"
+
 locate-path@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
@@ -2994,7 +4082,7 @@
 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=
+  integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
 
 lodash.get@^4.4.2:
   version "4.4.2"
@@ -3004,29 +4092,30 @@
 lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
-  integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
+  integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==
 
 lodash.sortby@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
-  integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
+  integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
 
 lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
-  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
+  integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
 
 lodash@^4.17.14, lodash@^4.17.21:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
-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==
+log-symbols@4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503"
+  integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==
   dependencies:
-    chalk "^4.0.0"
+    chalk "^4.1.0"
+    is-unicode-supported "^0.1.0"
 
 log-symbols@^2.1.0:
   version "2.2.0"
@@ -3035,16 +4124,26 @@
   dependencies:
     chalk "^2.0.1"
 
-log4js@^6.3.0:
-  version "6.3.0"
-  resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.3.0.tgz#10dfafbb434351a3e30277a00b9879446f715bcb"
-  integrity sha512-Mc8jNuSFImQUIateBFwdOQcmC6Q5maU0VVvdC2R6XMb66/VnT+7WS4D/0EeNMZu1YODmJe5NIn2XftCzEocUgw==
+log-update@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1"
+  integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==
   dependencies:
-    date-format "^3.0.0"
-    debug "^4.1.1"
-    flatted "^2.0.1"
-    rfdc "^1.1.4"
-    streamroller "^2.2.4"
+    ansi-escapes "^4.3.0"
+    cli-cursor "^3.1.0"
+    slice-ansi "^4.0.0"
+    wrap-ansi "^6.2.0"
+
+log4js@^6.4.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.6.1.tgz#48f23de8a87d2f5ffd3d913f24ca9ce77895272f"
+  integrity sha512-J8VYFH2UQq/xucdNu71io4Fo+purYYudyErgBbswWKO0MC6QVOERRomt5su/z6d3RJSmLyTGmXl3Q/XjKCf+/A==
+  dependencies:
+    date-format "^4.0.13"
+    debug "^4.3.4"
+    flatted "^3.2.6"
+    rfdc "^1.3.0"
+    streamroller "^3.1.2"
 
 lower-case@^2.0.2:
   version "2.0.2"
@@ -3075,17 +4174,54 @@
   dependencies:
     yallist "^4.0.0"
 
+make-dir@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.0.0"
+
+marky@^1.2.2:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
+  integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==
+
 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.49.0, "mime-db@>= 1.43.0 < 2":
+merge2@^1.3.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
+micromatch@^4.0.4:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9"
+  integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==
+  dependencies:
+    braces "^3.0.1"
+    picomatch "^2.2.3"
+
+mime-db@1.49.0:
   version "1.49.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed"
   integrity sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==
 
-mime-types@^2.1.12, mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.19, mime-types@~2.1.24:
+mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
+  version "1.52.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.34:
+  version "2.1.35"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
+mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.24:
   version "2.1.32"
   resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5"
   integrity sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==
@@ -3093,56 +4229,84 @@
     mime-db "1.49.0"
 
 mime@^2.5.2:
-  version "2.5.2"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe"
-  integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
+  integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
 
-minimatch@3.0.4, minimatch@^3.0.4:
+mimic-fn@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
+  integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
+
+minimatch@4.2.1:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-4.2.1.tgz#40d9d511a46bdc4e563c22c3080cde9c0d8299b4"
+  integrity sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
   dependencies:
     brace-expansion "^1.1.7"
 
-minimist@^1.2.3, minimist@^1.2.5:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
-  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
-
-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==
+minimatch@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
   dependencies:
-    minimist "^1.2.5"
+    brace-expansion "^1.1.7"
 
-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==
+minimist@^1.2.3, minimist@^1.2.6:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
+  integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
+
+mkdirp-classic@^0.5.2:
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
+  integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
+
+mkdirp@^0.5.5, mkdirp@^0.5.6:
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+  dependencies:
+    minimist "^1.2.6"
+
+mkdirp@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+
+mocha@9.2.2:
+  version "9.2.2"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-9.2.2.tgz#d70db46bdb93ca57402c809333e5a84977a88fb9"
+  integrity sha512-L6XC3EdwT6YrIk0yXpavvLkn8h+EU+Y5UcCHKECyMbdUIxyMuZj4bX4U9e1nvnvUUvQVsV2VHQr5zLdcUkhW/g==
   dependencies:
     "@ungap/promise-all-settled" "1.1.2"
     ansi-colors "4.1.1"
     browser-stdout "1.3.1"
-    chokidar "3.5.1"
-    debug "4.3.1"
+    chokidar "3.5.3"
+    debug "4.3.3"
     diff "5.0.0"
     escape-string-regexp "4.0.0"
     find-up "5.0.0"
-    glob "7.1.6"
+    glob "7.2.0"
     growl "1.10.5"
     he "1.2.0"
-    js-yaml "4.0.0"
-    log-symbols "4.0.0"
-    minimatch "3.0.4"
+    js-yaml "4.1.0"
+    log-symbols "4.1.0"
+    minimatch "4.2.1"
     ms "2.1.3"
-    nanoid "3.1.20"
-    serialize-javascript "5.0.1"
+    nanoid "3.3.1"
+    serialize-javascript "6.0.0"
     strip-json-comments "3.1.1"
     supports-color "8.1.1"
     which "2.0.2"
-    wide-align "1.1.3"
-    workerpool "6.1.0"
+    workerpool "6.2.0"
     yargs "16.2.0"
     yargs-parser "20.2.4"
     yargs-unparser "2.0.0"
@@ -3171,23 +4335,38 @@
     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==
+nanocolors@^0.2.1:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/nanocolors/-/nanocolors-0.2.13.tgz#dfd1ed0bfab05e9fe540eb6874525f0a1684099b"
+  integrity sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==
+
+nanoid@3.3.1:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35"
+  integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==
+
+nanoid@^3.1.25:
+  version "3.1.30"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.30.tgz#63f93cc548d2a113dc5dfbc63bfa09e2b9b64362"
+  integrity sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==
 
 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@^5.0.1:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.0.tgz#713ef3ed138252daef20ec035ab62b7a28be645c"
-  integrity sha512-W5WlHu+wvo3PaKLsJJkgPup2LrsXCcm7AWwyNZkUnn5rwPkuPBi3Iwk5SQtN0mv+K65k7nKKjwNQ30wg3wLAQQ==
+negotiator@0.6.3:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
+  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+
+nise@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.1.tgz#ac4237e0d785ecfcb83e20f389185975da5c31f3"
+  integrity sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==
   dependencies:
-    "@sinonjs/commons" "^1.7.0"
-    "@sinonjs/fake-timers" "^7.0.4"
+    "@sinonjs/commons" "^1.8.3"
+    "@sinonjs/fake-timers" ">=5"
     "@sinonjs/text-encoding" "^0.7.1"
     just-extend "^4.0.2"
     path-to-regexp "^1.7.0"
@@ -3200,15 +4379,17 @@
     lower-case "^2.0.2"
     tslib "^2.0.3"
 
-node-fetch@^2.6.0:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
-  integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
+node-fetch@2.6.7, node-fetch@^2.6.0:
+  version "2.6.7"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
+  integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
+  dependencies:
+    whatwg-url "^5.0.0"
 
-node-releases@^1.1.73:
-  version "1.1.74"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.74.tgz#e5866488080ebaa70a93b91144ccde06f3c3463e"
-  integrity sha512-caJBVempXZPepZoZAPCWRTNxYQ+xtG/KAi4ozTA5A+nJ7IU+kLQCbqaUjb5Rwy14M9upBWiQ4NutcmW04LJSRw==
+node-releases@^2.0.6:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
+  integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==
 
 normalize-package-data@^2.3.2:
   version "2.5.0"
@@ -3233,23 +4414,35 @@
 object-assign@^4, object-assign@^4.0.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
-  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+  integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
 
-object-keys@^1.0.12, object-keys@^1.1.1:
+object-inspect@^1.9.0:
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1"
+  integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==
+
+object-keys@^1.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:
-  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==
+  version "4.1.4"
+  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f"
+  integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==
   dependencies:
-    call-bind "^1.0.0"
-    define-properties "^1.1.3"
-    has-symbols "^1.0.1"
+    call-bind "^1.0.2"
+    define-properties "^1.1.4"
+    has-symbols "^1.0.3"
     object-keys "^1.1.1"
 
+on-finished@2.4.1:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+  dependencies:
+    ee-first "1.1.1"
+
 on-finished@^2.3.0, on-finished@~2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -3264,6 +4457,13 @@
   dependencies:
     wrappy "1"
 
+onetime@^5.1.0:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
+  integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
+  dependencies:
+    mimic-fn "^2.1.0"
+
 only@~0.0.2:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
@@ -3277,12 +4477,21 @@
     is-docker "^2.0.0"
     is-wsl "^2.1.1"
 
+open@^8.0.2:
+  version "8.4.0"
+  resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8"
+  integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==
+  dependencies:
+    define-lazy-prop "^2.0.0"
+    is-docker "^2.1.1"
+    is-wsl "^2.2.0"
+
 os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
-  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
+  integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
 
-p-limit@^2.0.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"
   integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
@@ -3303,6 +4512,13 @@
   dependencies:
     p-limit "^2.0.0"
 
+p-locate@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+  dependencies:
+    p-limit "^2.2.0"
+
 p-locate@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
@@ -3326,7 +4542,7 @@
 parse-json@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
-  integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=
+  integrity sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==
   dependencies:
     error-ex "^1.3.1"
     json-parse-better-errors "^1.0.1"
@@ -3336,6 +4552,11 @@
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
   integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
 
+parse5@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
+  integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
+
 parseurl@^1.3.2, parseurl@~1.3.3:
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@@ -3352,7 +4573,7 @@
 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=
+  integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==
 
 path-exists@^4.0.0:
   version "4.0.0"
@@ -3367,9 +4588,9 @@
 path-is-inside@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
-  integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
+  integrity sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==
 
-path-parse@^1.0.6:
+path-parse@^1.0.6, path-parse@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
@@ -3388,17 +4609,27 @@
   dependencies:
     pify "^3.0.0"
 
-pathval@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d"
-  integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==
+path-type@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
+  integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
+
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+  integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
 
 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=
+  integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==
 
-picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2:
+picocolors@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
+  integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
+
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
   integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
@@ -3406,9 +4637,40 @@
 pify@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
-  integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
+  integrity sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==
 
-polyfills-loader@^1.6.1, polyfills-loader@^1.7.4:
+pixelmatch@^5.2.1:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-5.3.0.tgz#5e5321a7abedfb7962d60dbf345deda87cb9560a"
+  integrity sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==
+  dependencies:
+    pngjs "^6.0.0"
+
+pkg-dir@4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
+  integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
+  dependencies:
+    find-up "^4.0.0"
+
+playwright-core@1.27.1:
+  version "1.27.1"
+  resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.27.1.tgz#840ef662e55a3ed759d8b5d3d00a5f885a7184f4"
+  integrity sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==
+
+playwright@^1.22.2:
+  version "1.27.1"
+  resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.27.1.tgz#4eecac5899566c589d4220ca8acc16abe8a67450"
+  integrity sha512-xXYZ7m36yTtC+oFgqH0eTgullGztKSRMb4yuwLPl8IYSmgBM88QiB+3IWb1mRIC9/NNwcgbG0RwtFlg+EAFQHQ==
+  dependencies:
+    playwright-core "1.27.1"
+
+pngjs@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821"
+  integrity sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==
+
+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==
@@ -3429,24 +4691,34 @@
     terser "^4.6.7"
     whatwg-fetch "^3.0.0"
 
-portfinder@^1.0.21:
-  version "1.0.28"
-  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.28.tgz#67c4622852bd5374dd1dd900f779f53462fac778"
-  integrity sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA==
+portfinder@^1.0.21, portfinder@^1.0.28:
+  version "1.0.32"
+  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.32.tgz#2fe1b9e58389712429dc2bea5beb2146146c7f81"
+  integrity sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==
   dependencies:
-    async "^2.6.2"
-    debug "^3.1.1"
-    mkdirp "^0.5.5"
+    async "^2.6.4"
+    debug "^3.2.7"
+    mkdirp "^0.5.6"
+
+progress@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
+proxy-from-env@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+  integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
 
 pseudomap@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
-  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
+  integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==
 
 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==
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
+  integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
 
 pump@^3.0.0:
   version "3.0.0"
@@ -3461,20 +4733,52 @@
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
+puppeteer-core@^13.1.3:
+  version "13.7.0"
+  resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-13.7.0.tgz#3344bee3994163f49120a55ddcd144a40575ba5b"
+  integrity sha512-rXja4vcnAzFAP1OVLq/5dWNfwBGuzcOARJ6qGV7oAZhnLmVRU8G5MsdeQEAOy332ZhkIOnn9jp15R89LKHyp2Q==
+  dependencies:
+    cross-fetch "3.1.5"
+    debug "4.3.4"
+    devtools-protocol "0.0.981744"
+    extract-zip "2.0.1"
+    https-proxy-agent "5.0.1"
+    pkg-dir "4.2.0"
+    progress "2.0.3"
+    proxy-from-env "1.1.0"
+    rimraf "3.0.2"
+    tar-fs "2.1.1"
+    unbzip2-stream "1.4.3"
+    ws "8.5.0"
+
 qjobs@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071"
   integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==
 
-qs@6.7.0:
-  version "6.7.0"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
-  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
+qs@6.10.3:
+  version "6.10.3"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e"
+  integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==
+  dependencies:
+    side-channel "^1.0.4"
+
+qs@^6.5.2:
+  version "6.10.1"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a"
+  integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==
+  dependencies:
+    side-channel "^1.0.4"
 
 qs@~6.5.2:
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
-  integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
+  version "6.5.3"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad"
+  integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==
+
+queue-microtask@^1.2.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
+  integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
 
 randombytes@^2.1.0:
   version "2.1.0"
@@ -3488,13 +4792,23 @@
   resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
   integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
 
-raw-body@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
-  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
+raw-body@2.5.1:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
+  integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
+  dependencies:
+    bytes "3.1.2"
+    http-errors "2.0.0"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
+raw-body@^2.3.3:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
+  integrity sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==
   dependencies:
     bytes "3.1.0"
-    http-errors "1.7.2"
+    http-errors "1.7.3"
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
@@ -3509,18 +4823,20 @@
 read-pkg@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389"
-  integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=
+  integrity sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==
   dependencies:
     load-json-file "^4.0.0"
     normalize-package-data "^2.3.2"
     path-type "^3.0.0"
 
-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==
+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:
-    picomatch "^2.2.1"
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
 
 readdirp@~3.6.0:
   version "3.6.0"
@@ -3534,14 +4850,14 @@
   resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
   integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
 
-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==
+regenerate-unicode-properties@^10.0.1:
+  version "10.0.1"
+  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz#7f442732aa7934a3740c779bb9b3340dccc1fb56"
+  integrity sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw==
   dependencies:
-    regenerate "^1.4.0"
+    regenerate "^1.4.2"
 
-regenerate@^1.4.0:
+regenerate@^1.4.2:
   version "1.4.2"
   resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
   integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
@@ -3551,41 +4867,41 @@
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
   integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
 
-regenerator-transform@^0.14.2:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4"
-  integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==
+regenerator-transform@^0.15.0:
+  version "0.15.0"
+  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537"
+  integrity sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==
   dependencies:
     "@babel/runtime" "^7.8.4"
 
-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==
+regexpu-core@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.1.0.tgz#2f8504c3fd0ebe11215783a41541e21c79942c6d"
+  integrity sha512-bb6hk+xWd2PEOkj5It46A16zFMs2mv86Iwpdu94la4S3sJ7C973h2dHpYKwIBGaWSO7cIRJ+UX0IeMaWcO4qwA==
   dependencies:
-    regenerate "^1.4.0"
-    regenerate-unicode-properties "^8.2.0"
-    regjsgen "^0.5.1"
-    regjsparser "^0.6.4"
-    unicode-match-property-ecmascript "^1.0.4"
-    unicode-match-property-value-ecmascript "^1.2.0"
+    regenerate "^1.4.2"
+    regenerate-unicode-properties "^10.0.1"
+    regjsgen "^0.6.0"
+    regjsparser "^0.8.2"
+    unicode-match-property-ecmascript "^2.0.0"
+    unicode-match-property-value-ecmascript "^2.0.0"
 
-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==
+regjsgen@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.6.0.tgz#83414c5354afd7d6627b16af5f10f41c4e71808d"
+  integrity sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA==
 
-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==
+regjsparser@^0.8.2:
+  version "0.8.4"
+  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.8.4.tgz#8a14285ffcc5de78c5b95d62bbf413b6bc132d5f"
+  integrity sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA==
   dependencies:
     jsesc "~0.5.0"
 
 relateurl@^0.2.7:
   version "0.2.7"
   resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
-  integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
+  integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==
 
 request@^2.88.0:
   version "2.88.2"
@@ -3616,7 +4932,7 @@
 require-directory@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
-  integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
+  integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
 
 require-main-filename@^2.0.0:
   version "2.0.0"
@@ -3626,7 +4942,7 @@
 requires-port@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
-  integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
+  integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==
 
 resize-observer-polyfill@^1.5.1:
   version "1.5.1"
@@ -3641,7 +4957,16 @@
     http-errors "~1.6.2"
     path-is-absolute "1.0.1"
 
-resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.0:
+resolve@^1.10.0, resolve@^1.14.2:
+  version "1.22.1"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
+  integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
+  dependencies:
+    is-core-module "^2.9.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
+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==
@@ -3649,31 +4974,51 @@
     is-core-module "^2.2.0"
     path-parse "^1.0.6"
 
-rfdc@^1.1.4:
+restore-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
+  integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
+  dependencies:
+    onetime "^5.1.0"
+    signal-exit "^3.0.2"
+
+reusify@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
+  integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
+
+rfdc@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
   integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==
 
-rimraf@^3.0.0, rimraf@^3.0.2:
+rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
   integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
   dependencies:
     glob "^7.1.3"
 
-rollup@^2.7.2:
-  version "2.56.2"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.56.2.tgz#a045ff3f6af53ee009b5f5016ca3da0329e5470f"
-  integrity sha512-s8H00ZsRi29M2/lGdm1u8DJpJ9ML8SUOpVVBd33XNeEeL3NVaTiUcSBHzBdF3eAyR0l7VSpsuoVUGrRHq7aPwQ==
+rollup@^2.67.0, rollup@^2.7.2:
+  version "2.79.0"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.0.tgz#9177992c9f09eb58c5e56cbfa641607a12b57ce2"
+  integrity sha512-x4KsrCgwQ7ZJPcFA/SUu6QVcYlO7uRLfLAy0DSA4NS2eG8japdbpM50ToH7z4iObodRYOJ0soneF0iaQRJ6zhA==
   optionalDependencies:
     fsevents "~2.3.2"
 
+run-parallel@^1.1.9:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
+  integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
+  dependencies:
+    queue-microtask "^1.2.2"
+
 safe-buffer@5.1.2, safe-buffer@~5.1.1:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
-safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2:
+safe-buffer@^5.0.1, safe-buffer@^5.1.0, 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==
@@ -3688,27 +5033,22 @@
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
 
-semver@7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
-  integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
-
 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.5"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
-  integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
+semver@^7.3.4, semver@^7.3.5:
+  version "7.3.7"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
+  integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
   dependencies:
     lru-cache "^6.0.0"
 
-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==
+serialize-javascript@6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8"
+  integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==
   dependencies:
     randombytes "^2.1.0"
 
@@ -3732,62 +5072,89 @@
   resolved "https://registry.yarnpkg.com/shady-css-scoped-element/-/shady-css-scoped-element-0.0.2.tgz#c538fcfe2317e979cd02dfec533898b95b4ea8fe"
   integrity sha512-Dqfl70x6JiwYDujd33ZTbtCK0t52E7+H2swdWQNSTzfsolSa6LJHnTpN4T9OpJJEq4bxuzHRLFO9RBcy/UfrMQ==
 
-sinon@^10.0.0:
-  version "10.0.1"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-10.0.1.tgz#0d1a13ecb86f658d15984f84273e57745b1f4c57"
-  integrity sha512-1rf86mvW4Mt7JitEIgmNaLXaWnrWd/UrVKZZlL+kbeOujXVf9fmC4kQEQ/YeHoiIA23PLNngYWK+dngIx/AumA==
+side-channel@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
+  integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
   dependencies:
-    "@sinonjs/commons" "^1.8.1"
-    "@sinonjs/fake-timers" "^7.0.4"
-    "@sinonjs/samsam" "^6.0.1"
-    diff "^4.0.2"
-    nise "^5.0.1"
-    supports-color "^7.1.0"
+    call-bind "^1.0.0"
+    get-intrinsic "^1.0.2"
+    object-inspect "^1.9.0"
 
-socket.io-adapter@~2.3.2:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.3.2.tgz#039cd7c71a52abad984a6d57da2c0b7ecdd3c289"
-  integrity sha512-PBZpxUPYjmoogY0aoaTmo1643JelsaS1CiAwNjRVdrI0X9Seuc19Y2Wife8k88avW6haG8cznvwbubAZwH4Mtg==
+signal-exit@^3.0.2:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.5.tgz#9e3e8cc0c75a99472b44321033a7702e7738252f"
+  integrity sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ==
 
-socket.io-parser@~4.0.4:
-  version "4.0.4"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.4.tgz#9ea21b0d61508d18196ef04a2c6b9ab630f4c2b0"
-  integrity sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g==
+sinon@^13.0.0:
+  version "13.0.2"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.2.tgz#c6a8ddd655dc1415bbdc5ebf0e5b287806850c3a"
+  integrity sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA==
   dependencies:
-    "@types/component-emitter" "^1.2.10"
-    component-emitter "~1.3.0"
+    "@sinonjs/commons" "^1.8.3"
+    "@sinonjs/fake-timers" "^9.1.2"
+    "@sinonjs/samsam" "^6.1.1"
+    diff "^5.0.0"
+    nise "^5.1.1"
+    supports-color "^7.2.0"
+
+slash@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
+  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
+
+slice-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b"
+  integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==
+  dependencies:
+    ansi-styles "^4.0.0"
+    astral-regex "^2.0.0"
+    is-fullwidth-code-point "^3.0.0"
+
+socket.io-adapter@~2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.4.0.tgz#b50a4a9ecdd00c34d4c8c808224daa1a786152a6"
+  integrity sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg==
+
+socket.io-parser@~4.2.0:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.1.tgz#01c96efa11ded938dcb21cbe590c26af5eff65e5"
+  integrity sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==
+  dependencies:
+    "@socket.io/component-emitter" "~3.1.0"
     debug "~4.3.1"
 
-socket.io@^4.2.0:
-  version "4.3.1"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.3.1.tgz#c0aa14f3f916a8ab713e83a5bd20c16600245763"
-  integrity sha512-HC5w5Olv2XZ0XJ4gOLGzzHEuOCfj3G0SmoW3jLHYYh34EVsIr3EkW9h6kgfW+K3TFEcmYy8JcPWe//KUkBp5jA==
+socket.io@^4.4.1:
+  version "4.5.2"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.5.2.tgz#1eb25fd380ab3d63470aa8279f8e48d922d443ac"
+  integrity sha512-6fCnk4ARMPZN448+SQcnn1u8OHUC72puJcNtSgg2xS34Cu7br1gQ09YKkO1PFfDn/wyUE9ZgMAwosJed003+NQ==
   dependencies:
     accepts "~1.3.4"
     base64id "~2.0.0"
     debug "~4.3.2"
-    engine.io "~6.0.0"
-    socket.io-adapter "~2.3.2"
-    socket.io-parser "~4.0.4"
+    engine.io "~6.2.0"
+    socket.io-adapter "~2.4.0"
+    socket.io-parser "~4.2.0"
 
 source-map-support@^0.5.19, source-map-support@~0.5.12:
-  version "0.5.19"
-  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
-  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
+  version "0.5.21"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
+  integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
   dependencies:
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
-source-map@^0.5.0:
-  version "0.5.7"
-  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
-  integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
-
 source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
+source-map@^0.7.3:
+  version "0.7.3"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
+  integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
+
 spdx-correct@^3.0.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
@@ -3810,14 +5177,14 @@
     spdx-license-ids "^3.0.0"
 
 spdx-license-ids@^3.0.0:
-  version "3.0.10"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz#0d9becccde7003d6c658d487dd48a32f0bf3014b"
-  integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==
+  version "3.0.12"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz#69077835abe2710b65f03969898b6637b505a779"
+  integrity sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==
 
 sshpk@^1.7.0:
-  version "1.16.1"
-  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
-  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
+  version "1.17.0"
+  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5"
+  integrity sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==
   dependencies:
     asn1 "~0.2.3"
     assert-plus "^1.0.0"
@@ -3829,29 +5196,26 @@
     safer-buffer "^2.0.2"
     tweetnacl "~0.14.0"
 
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
 "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.0.0, statuses@^1.5.0, statuses@~1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
 
-streamroller@^2.2.4:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-2.2.4.tgz#c198ced42db94086a6193608187ce80a5f2b0e53"
-  integrity sha512-OG79qm3AujAM9ImoqgWEY1xG4HX+Lw+yY6qZj9R1K2mhF5bEmQ849wvrb+4vt4jLMLzwXttJlQbOdPOQVRv7DQ==
+streamroller@^3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.2.tgz#abd444560768b340f696307cf84d3f46e86c0e63"
+  integrity sha512-wZswqzbgGGsXYIrBYhOE0yP+nQ6XRk7xDcYwuQAGTYXdyAUmvgVFE0YU1g5pvQT0m7GBaQfYcSnlHbapuK0H0A==
   dependencies:
-    date-format "^2.1.0"
-    debug "^4.1.1"
+    date-format "^4.0.13"
+    debug "^4.3.4"
     fs-extra "^8.1.0"
 
-"string-width@^1.0.2 || 2":
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
-  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
-  dependencies:
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^4.0.0"
-
-string-width@^4.1.0, string-width@^4.2.0:
+string-width@^4.1.0:
   version "4.2.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
   integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==
@@ -3860,10 +5224,26 @@
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
+string-width@^4.2.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+string_decoder@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+  dependencies:
+    safe-buffer "~5.2.0"
+
 strip-ansi@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
-  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
+  integrity sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==
   dependencies:
     ansi-regex "^3.0.0"
 
@@ -3881,10 +5261,17 @@
   dependencies:
     ansi-regex "^5.0.0"
 
+strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
 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=
+  integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==
 
 strip-json-comments@3.1.1:
   version "3.1.1"
@@ -3905,19 +5292,24 @@
   dependencies:
     has-flag "^3.0.0"
 
-supports-color@^7.1.0:
+supports-color@^7.1.0, supports-color@^7.2.0:
   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, systemjs@^6.8.3:
-  version "6.10.2"
-  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.10.2.tgz#c9870217bddf9cfd25d12d4fcd1989541ef1207c"
-  integrity sha512-PwaC0Z6Y1E6gFekY2u38EC5+5w2M65jYVrD1aAcOptpHVhCwPIwPFJvYJyryQKUyeuQ5bKKI3PBHWNjdE9aizg==
+supports-preserve-symlinks-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
-table-layout@^1.0.1:
+systemjs@^6.3.1, systemjs@^6.8.3:
+  version "6.12.6"
+  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.12.6.tgz#147a2a9137b8f3fddaafac1d5060adf3d11212a6"
+  integrity sha512-SawLiWya8/uNR4p12OggSYZ35tP4U4QTpfV57DdZEOPr6+J6zlLSeeEpMmzYTEoBAsMhctdEE+SWJUDYX4EaKw==
+
+table-layout@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04"
   integrity sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==
@@ -3927,10 +5319,31 @@
     typical "^5.2.0"
     wordwrapjs "^4.0.0"
 
+tar-fs@2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
+  integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==
+  dependencies:
+    chownr "^1.1.1"
+    mkdirp-classic "^0.5.2"
+    pump "^3.0.0"
+    tar-stream "^2.1.4"
+
+tar-stream@^2.1.4:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
+  integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
+  dependencies:
+    bl "^4.0.3"
+    end-of-stream "^1.4.1"
+    fs-constants "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^3.1.1"
+
 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==
+  version "4.8.1"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.1.tgz#a00e5634562de2239fd404c649051bf6fc21144f"
+  integrity sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==
   dependencies:
     commander "^2.20.0"
     source-map "~0.6.1"
@@ -3949,7 +5362,7 @@
 thenify-all@^1.0.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
-  integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=
+  integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
   dependencies:
     thenify ">= 3.1.0 < 4"
 
@@ -3960,6 +5373,11 @@
   dependencies:
     any-promise "^1.0.0"
 
+through@^2.3.8:
+  version "2.3.8"
+  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+  integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
+
 tmp@0.0.x:
   version "0.0.33"
   resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
@@ -3977,7 +5395,7 @@
 to-fast-properties@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
-  integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
+  integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==
 
 to-regex-range@^5.0.1:
   version "5.0.1"
@@ -3991,6 +5409,11 @@
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
   integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
 
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
 tough-cookie@~2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
@@ -4002,19 +5425,31 @@
 tr46@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
-  integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=
+  integrity sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==
   dependencies:
     punycode "^2.1.0"
 
+tr46@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9"
+  integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==
+  dependencies:
+    punycode "^2.1.1"
+
+tr46@~0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+  integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+
 tslib@^1.11.1:
   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.3.1"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
-  integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
+  integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
 
 tsscmp@1.0.6:
   version "1.0.6"
@@ -4024,21 +5459,26 @@
 tunnel-agent@^0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
-  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
+  integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==
   dependencies:
     safe-buffer "^5.0.1"
 
 tweetnacl@^0.14.3, tweetnacl@~0.14.0:
   version "0.14.5"
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
-  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+  integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==
 
-type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8:
+type-detect@4.0.8, type-detect@^4.0.8:
   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-is@^1.6.16, type-is@~1.6.17:
+type-fest@^0.21.3:
+  version "0.21.3"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
+  integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
+
+type-is@^1.6.16, type-is@~1.6.18:
   version "1.6.18"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
   integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
@@ -4057,32 +5497,45 @@
   integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
 
 ua-parser-js@^0.7.30:
-  version "0.7.30"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.30.tgz#4cf5170e8b55ac553fe8b38df3a82f0669671f0b"
-  integrity sha512-uXEtSresNUlXQ1QL4/3dQORcGv7+J2ookOG2ybA/ga9+HYEXueT2o+8dUJQkpedsyTyCJ6jCCirRcKtdtx1kbg==
+  version "0.7.31"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.31.tgz#649a656b191dffab4f21d5e053e27ca17cbff5c6"
+  integrity sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==
 
-unicode-canonical-property-names-ecmascript@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
-  integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==
+ua-parser-js@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775"
+  integrity sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==
 
-unicode-match-property-ecmascript@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"
-  integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==
+unbzip2-stream@1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7"
+  integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==
   dependencies:
-    unicode-canonical-property-names-ecmascript "^1.0.4"
-    unicode-property-aliases-ecmascript "^1.0.4"
+    buffer "^5.2.1"
+    through "^2.3.8"
 
-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-canonical-property-names-ecmascript@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"
+  integrity sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==
 
-unicode-property-aliases-ecmascript@^1.0.4:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
-  integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
+unicode-match-property-ecmascript@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz#54fd16e0ecb167cf04cf1f756bdcc92eba7976c3"
+  integrity sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==
+  dependencies:
+    unicode-canonical-property-names-ecmascript "^2.0.0"
+    unicode-property-aliases-ecmascript "^2.0.0"
+
+unicode-match-property-value-ecmascript@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz#1a01aa57247c14c568b89775a54938788189a714"
+  integrity sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==
+
+unicode-property-aliases-ecmascript@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8"
+  integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==
 
 universalify@^0.1.0:
   version "0.1.2"
@@ -4094,6 +5547,14 @@
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
   integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
 
+update-browserslist-db@^1.0.5:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz#2924d3927367a38d5c555413a7ce138fc95fcb18"
+  integrity sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg==
+  dependencies:
+    escalade "^3.1.1"
+    picocolors "^1.0.0"
+
 uri-js@^4.2.2:
   version "4.4.1"
   resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
@@ -4109,20 +5570,43 @@
     lru-cache "4.1.x"
     tmp "0.0.x"
 
+util-deprecate@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
 utils-merge@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
-  integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
+  integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
 
 uuid@^3.3.2:
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
 
+v8-to-istanbul@^8.0.0:
+  version "8.1.1"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed"
+  integrity sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==
+  dependencies:
+    "@types/istanbul-lib-coverage" "^2.0.1"
+    convert-source-map "^1.6.0"
+    source-map "^0.7.3"
+
+v8-to-istanbul@^9.0.1:
+  version "9.0.1"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz#b6f994b0b5d4ef255e17a0d17dc444a9f5132fa4"
+  integrity sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==
+  dependencies:
+    "@jridgewell/trace-mapping" "^0.3.12"
+    "@types/istanbul-lib-coverage" "^2.0.1"
+    convert-source-map "^1.6.0"
+
 valid-url@^1.0.9:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200"
-  integrity sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA=
+  integrity sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==
 
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
@@ -4140,7 +5624,7 @@
 verror@1.10.0:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
-  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
+  integrity sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==
   dependencies:
     assert-plus "^1.0.0"
     core-util-is "1.0.2"
@@ -4149,18 +5633,44 @@
 void-elements@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
-  integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
+  integrity sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==
+
+webidl-conversions@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+  integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
 
 webidl-conversions@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
   integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
 
+webidl-conversions@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
+  integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
+
 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@^11.0.0:
+  version "11.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018"
+  integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==
+  dependencies:
+    tr46 "^3.0.0"
+    webidl-conversions "^7.0.0"
+
+whatwg-url@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+  integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
+  dependencies:
+    tr46 "~0.0.3"
+    webidl-conversions "^3.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"
@@ -4184,13 +5694,6 @@
   dependencies:
     isexe "^2.0.0"
 
-wide-align@1.1.3:
-  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"
-
 wordwrapjs@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz#d9790bccfb110a0fc7836b5ebce0937b37a8b98f"
@@ -4199,10 +5702,19 @@
     reduce-flatten "^2.0.0"
     typical "^5.2.0"
 
-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==
+workerpool@6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.0.tgz#827d93c9ba23ee2019c3ffaff5c27fccea289e8b"
+  integrity sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A==
+
+wrap-ansi@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
+  integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
 
 wrap-ansi@^7.0.0:
   version "7.0.0"
@@ -4218,6 +5730,16 @@
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
+ws@8.5.0:
+  version "8.5.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f"
+  integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==
+
+ws@^7.4.2:
+  version "7.5.5"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881"
+  integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==
+
 ws@~8.2.3:
   version "8.2.3"
   resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba"
@@ -4231,7 +5753,7 @@
 yallist@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
-  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
+  integrity sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==
 
 yallist@^3.0.2:
   version "3.1.1"
@@ -4243,6 +5765,11 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
+yamlparser@^0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/yamlparser/-/yamlparser-0.0.2.tgz#32393e6afc70c8ca066b6650ac6738b481678ebc"
+  integrity sha512-Cou9FCGblEENtn1/8La5wkDM/ISMh2bzu5Wh7dYzCzA0o9jD4YGyLkUJxe84oPBGoB92f+Oy4ZjVhA8S0C2wlQ==
+
 yargs-parser@20.2.4:
   version "20.2.4"
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54"
@@ -4276,6 +5803,14 @@
     y18n "^5.0.5"
     yargs-parser "^20.2.2"
 
+yauzl@^2.10.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+  integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    fd-slicer "~1.1.0"
+
 ylru@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
diff --git a/proto/cache.proto b/proto/cache.proto
index 16e5e95..0cecdea 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -448,18 +448,18 @@
 }
 
 // Serialized form of com.google.gerrit.common.data.LabelType.
-// Next ID: 21
+// Next ID: 22
 message LabelTypeProto {
   string name = 1;
   string function = 2; // ENUM as String
-  bool copy_any_score = 3;
-  bool copy_min_score = 4;
-  bool copy_max_score = 5;
-  bool copy_all_scores_on_merge_first_parent_update = 6;
-  bool copy_all_scores_on_trivial_rebase = 7;
-  bool copy_all_scores_if_no_code_change = 8;
-  bool copy_all_scores_if_no_change = 9;
-  repeated int32 copy_values = 10;
+  reserved 3; // copy_any_score
+  reserved 4; // copy_min_score
+  reserved 5; // copy_max_score
+  reserved 6; // copy_all_scores_on_merge_first_parent_update
+  reserved 7; // copy_all_scores_on_trivial_rebase
+  reserved 8; // copy_all_scores_if_no_code_change
+  reserved 9; // copy_all_scores_if_no_change
+  reserved 10; // copy_values
   bool allow_post_submit = 11;
   bool ignore_self_approval = 12;
   int32 default_value = 13;
@@ -468,8 +468,9 @@
   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;
+  reserved 19; // copy_all_scores_if_list_of_files_did_not_change
   string copy_condition = 20;
+  string description = 21;
 }
 
 // Serialized form of com.google.gerrit.entities.SubmitRequirement.
@@ -484,7 +485,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.SubmitRequirementResult.
-// Next ID: 7
+// Next ID: 9
 message SubmitRequirementResultProto {
   SubmitRequirementProto submit_requirement = 1;
   SubmitRequirementExpressionResultProto applicability_expression_result = 2;
@@ -496,6 +497,13 @@
 
   // Whether this result was created from a legacy submit record.
   bool legacy = 6;
+
+  // Whether the submit requirement was bypassed during submission (i.e. by
+  // performing a push with the %submit option).
+  bool forced = 7;
+  // Whether this submit requirement result should be filtered out when returned
+  // from REST API.
+  bool hidden = 8;
 }
 
 // Serialized form of com.google.gerrit.entities.SubmitRequirementExpressionResult.
@@ -525,14 +533,18 @@
 }
 
 // Serialized form of com.google.gerrit.entities.StoredCommentLinkInfo.
-// Next ID: 7
+// Next ID: 10
 message StoredCommentLinkInfoProto {
+  reserved 4;  // html
+
   string name = 1;
   string match = 2;
-  string link = 3;
-  string html = 4;
   bool enabled = 5;
   bool override_only = 6;
+  string link = 3;
+  string prefix = 7;
+  string suffix = 8;
+  string text = 9;
 }
 
 // Serialized form of com.google.gerrit.entities.CachedProjectConfigProto.
@@ -685,7 +697,7 @@
 
 // Serialized form of
 // com.google.gerrit.server.patch.filediff.FileDiffOutput
-// Next ID: 13
+// Next ID: 15
 message FileDiffOutputProto {
   // Next ID: 5
   message Edit {
@@ -717,19 +729,6 @@
   bytes new_commit = 10;
   ComparisonType comparison_type = 11;
   bool negative = 12;
-}
-
-// Serialized form of com.google.gerrit.server.approval.ApprovalCacheImpl.Key.
-// Next ID: 5
-message PatchSetApprovalsKeyProto {
-  string project = 1;
-  int32 change_id = 2;
-  int32 patch_set_id = 3;
-  bytes id = 4;
-}
-
-// Repeated version of PatchSetApprovalProto
-// Next ID: 2
-message AllPatchSetApprovalsProto {
-  repeated devtools.gerritcodereview.PatchSetApproval approval = 1;
+  string old_mode = 13; // ENUM as string
+  string new_mode = 14; // ENUM as string
 }
diff --git a/proto/entities.proto b/proto/entities.proto
index de8f647..191cca7 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -125,7 +125,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.PatchSetApproval.
-// Next ID: 9
+// Next ID: 11
 message PatchSetApproval {
   required PatchSetApproval_Key key = 1;
   optional int32 value = 2;
@@ -134,6 +134,7 @@
   optional Account_Id real_account_id = 7;
   optional bool post_submit = 8;
   optional bool copied = 9;
+  optional string uuid = 10;
 
   // Deleted fields, should not be reused:
   reserved 4;  // changeOpen
diff --git a/resources/BUILD b/resources/BUILD
index b53ae4c..d4d0df3 100644
--- a/resources/BUILD
+++ b/resources/BUILD
@@ -11,5 +11,5 @@
     name = "log4j-config__jar",
     srcs = ["log4j.properties"],
     outs = ["log4j-config.jar"],
-    cmd = "cd resources && zip -9Dqr $$ROOT/$@ .",
+    cmd = "cd $$(dirname $(location log4j.properties)) && zip -9Dqr $$ROOT/$@ .",
 )
diff --git a/resources/com/google/gerrit/httpd/auth/container/LoginRedirect.html b/resources/com/google/gerrit/httpd/auth/container/LoginRedirect.html
index 0567468..54c3661 100644
--- a/resources/com/google/gerrit/httpd/auth/container/LoginRedirect.html
+++ b/resources/com/google/gerrit/httpd/auth/container/LoginRedirect.html
@@ -4,6 +4,12 @@
     <title>Gerrit Code Review</title>
     <script type="text/javascript">
       var href = window.location.href;
+      var query = "";
+      var q = href.indexOf('?');
+      if (q >= 0) {
+        query = href.substring(q);
+        href = href.substring(0,q);
+      }
       var p = href.indexOf('#');
       var token;
       if (p >= 0) {
@@ -12,7 +18,7 @@
       } else {
         token = '';
       }
-      window.location.replace(href + 'login/' + token);
+      window.location.replace(href + 'login/' + token + query);
     </script>
   </head>
   <body>
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 11717fb..dbfef44 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -27,9 +27,9 @@
   {@param? versionInfo: ?}
   {@param? polyfillCE: ?}
   {@param? useGoogleFonts: ?}
+  {@param? changeNum: ?}
   {@param? changeRequestsPath: ?}
   {@param? defaultChangeDetailHex: ?}
-  {@param? defaultDiffDetailHex: ?}
   {@param? defaultDashboardHex: ?}
   {@param? dashboardQuery: ?}
   {@param? userIsAuthenticated: ?}
@@ -52,9 +52,6 @@
       {if $defaultChangeDetailHex}
         changePage: '{$defaultChangeDetailHex}',
       {/if}
-      {if $defaultDiffDetailHex}
-        diffPage: '{$defaultDiffDetailHex}',
-      {/if}
       {if $defaultDashboardHex}
         dashboardPage: '{$defaultDashboardHex}',
       {/if}
@@ -99,18 +96,11 @@
         <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/?download-commands=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
       {/if}
     {/if}
-    {if $defaultDiffDetailHex}
-      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultDiffDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
-      {if $userIsAuthenticated}
-        <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
-      {/if}
-      <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}
+    <link rel="preload" href="{$canonicalPath}/changes/?q=change:{$changeNum}" 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}
+      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts?enable-context=true&context-padding=3" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
     {/if}
   {/if}
   {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
@@ -119,6 +109,7 @@
 
   {if $useGoogleFonts}
     <link rel="preload" as="style" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,600,700&display=swap">
+    <link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0" />{\n}
   {else}
     // $useGoogleFonts only exists so that hosts can opt-out of loading fonts from fonts.googleapis.com.
     // fonts.css and the woff2 files in the fonts/ directory are only relevant, if $useGoogleFonts is false.
@@ -142,8 +133,9 @@
     <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-ext-400.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
     <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-ext-500.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
     <link rel="preload" href="{$staticResourcePath}/fonts/roboto-mono-latin-ext-700.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n}
-
+    <link rel="preload" href="{$staticResourcePath}/fonts/material-icons.woff2"            as="font" type="font/woff2" crossorigin="anonymous">{\n}
     <link rel="preload" as="style" href="{$staticResourcePath}/styles/fonts.css">{\n}
+    <link rel="preload" as="style" href="{$staticResourcePath}/styles/material-icons.css">{\n}
   {/if}
   <link rel="preload" as="style" href="{$staticResourcePath}/styles/main.css">{\n}
 
@@ -162,13 +154,15 @@
   // Now use preloaded resources
   {if $useGoogleFonts}
     <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,600,700&display=swap">{\n}
+    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0" />{\n}
   {else}
     <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
+    <link rel="stylesheet" href="{$staticResourcePath}/styles/material-icons.css">{\n}
   {/if}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
 
   <body unresolved>{\n}
-  <gr-app id="app"></gr-app>{\n}
+  <gr-app id="pg-app"></gr-app>{\n}
 
   // Load gr-app.js after <gr-app ...> tag because gr-router expects that
   // <gr-app ...> already exists in the document when script is executed.
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh
index e7fda5a..1399b15 100755
--- a/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -51,7 +51,7 @@
 
 usage() {
     me=`basename "$0"`
-    echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site]"
+    echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site] [--debug [--debug_port|--debug_address ...] [--suspend]]"
     exit 1
 }
 
@@ -148,6 +148,27 @@
     GERRIT_SITE=${1##--site-path=}
     shift
     ;;
+  --debug)
+    JVM_DEBUG=true
+    shift
+    ;;
+  --suspend)
+    JVM_DEBUG_SUSPEND=true
+    shift
+    ;;
+  --debug-port=*)
+    DEBUG_ADDRESS=${1##--debug-port=}
+    shift
+    ;;
+  --debug-address=*)
+    DEBUG_ADDRESS=${1##--debug-address=}
+    shift
+    ;;
+  --debug-port|--debug-address)
+    shift
+    DEBUG_ADDRESS=$1
+    shift
+    ;;
 
   *)
     usage
@@ -317,6 +338,20 @@
   JAVA_OPTIONS="$JAVA_OPTIONS -Xmx$GERRIT_MEMORY"
 fi
 
+if test -n "$JVM_DEBUG" ; then
+  if test -z "$DEBUG_ADDRESS" ; then
+    DEBUG_ADDRESS=8000
+  fi
+  echo "Put JVM in debug mode, debugger listens to: $DEBUG_ADDRESS"
+  if test -n "$JVM_DEBUG_SUSPEND" ; then
+    SUSPEND=y
+    echo "JVM will await for a debugger to attach"
+  else
+    SUSPEND=n
+  fi
+  JAVA_OPTIONS="$JAVA_OPTIONS -agentlib:jdwp=transport=dt_socket,server=y,suspend=$SUSPEND,address=$DEBUG_ADDRESS"
+fi
+
 GERRIT_FDS=`get_config --int core.packedGitOpenFiles`
 test -z "$GERRIT_FDS" && GERRIT_FDS=128
 FDS_MULTIPLIER=2
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index 4f1a3f7..2c256ff 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -85,11 +85,11 @@
 
   ${hook} input || fail "failed hook execution"
 
-  found=$(grep -c '^Change-Id' input)
+  found=$(grep -c '^Change-Id' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Ids, want 1"
   fi
-  found=$(grep -c '^Change-Id: I123' input)
+  found=$(grep -c '^Change-Id: I123' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Id: I123, want 1"
   fi
@@ -104,6 +104,18 @@
   git config gerrit.createChangeId false
   ${hook} input || fail "failed hook execution"
   git config --unset gerrit.createChangeId
+  found=$(grep -c '^Change-Id' input) || :
+  if [[ "${found}" != "0" ]]; then
+    fail "got ${found} Change-Ids, want 0"
+  fi
+}
+
+function test_suppress_squash {
+  cat << EOF > input
+squash! bla bla
+EOF
+
+  ${hook} input || fail "failed hook execution"
   found=$(grep -c '^Change-Id' input || true)
   if [[ "${found}" != "0" ]]; then
     fail "got ${found} Change-Ids, want 0"
@@ -119,11 +131,11 @@
   git config gerrit.reviewUrl https://myhost/
   ${hook} input || fail "failed hook execution"
   git config --unset gerrit.reviewUrl
-  found=$(grep -c '^Change-Id' input || true)
+  found=$(grep -c '^Change-Id' input) || :
   if [[ "${found}" != "0" ]]; then
     fail "got ${found} Change-Ids, want 0"
   fi
-  found=$(grep -c '^Link: https://myhost/id/I' input || true)
+  found=$(grep -c '^Link: https://myhost/id/I' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Link footers, want 1"
   fi
@@ -138,7 +150,7 @@
 EOF
 
   ${hook} input || fail "failed hook execution"
-  result=$(tail -1 input | grep ^Change-Id)
+  result=$(tail -1 input | grep ^Change-Id) || :
   if [[ -z "${result}" ]] ; then
     echo "after: "
     cat input
@@ -147,6 +159,25 @@
   fi
 }
 
+# Change-Id goes before Signed-off-by trailers.
+function test_before_signed_off_by {
+  cat << EOF > input
+bla bla
+
+Bug: #123
+Signed-off-by: Joe User
+EOF
+
+  ${hook} input || fail "failed hook execution"
+  result=$(tail -2 input | head -1 | grep ^Change-Id) || :
+  if [[ -z "${result}" ]] ; then
+    echo "after: "
+    cat input
+
+    fail "did not find Change-Id before Signed-off-by"
+  fi
+}
+
 function test_dash_at_end {
   if [[ ! -x /bin/dash ]] ; then
     echo "/bin/dash not installed; skipping dash test."
@@ -161,7 +192,7 @@
 
   /bin/dash ${hook} input || fail "failed hook execution"
 
-  result=$(tail -1 input | grep ^Change-Id)
+  result=$(tail -1 input | grep ^Change-Id) || :
   if [[ -z "${result}" ]] ; then
     echo "after: "
     cat input
@@ -184,11 +215,11 @@
 
   /bin/dash ${hook} input || fail "failed hook execution"
 
-  found=$(grep -c '^Change-Id' input)
+  found=$(grep -c '^Change-Id' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Ids, want 1"
   fi
-  found=$(grep -c '^Change-Id: I123' input)
+  found=$(grep -c '^Change-Id: I123' input) || :
   if [[ "${found}" != "1" ]]; then
     fail "got ${found} Change-Id: I123, want 1"
   fi
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeader.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
index 3d0edab..027d78b 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeader.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -28,5 +28,6 @@
       {/if}
     {/for}
     {\n}
+    {\n}
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/ChangeSubject.soy b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
index 28d04d0..ba422a4 100644
--- a/resources/com/google/gerrit/server/mail/ChangeSubject.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeSubject.soy
@@ -25,10 +25,13 @@
   {@param change: ?}
   {@param shortProjectName: ?}
   {@param instanceAndProjectName: ?}
-  {@param addInstanceNameInSubject: ?}  /** boolean */
+  {@param addInstanceNameInSubject: ?}    /** boolean */
+
   {if not $addInstanceNameInSubject}
-    Change in {$shortProjectName}[{$branch.shortName}]: {$change.shortSubject}
+    [{$change.sizeBucket}] Change in {$shortProjectName}[{$branch.shortName}]: {$change
+    .shortSubject}
   {else}
-    Change in {$instanceAndProjectName}[{$branch.shortName}]: {$change.shortSubject}
+    [{$change.sizeBucket}] Change in {$instanceAndProjectName}[{$branch.shortName}]: {$change
+    .shortSubject}
   {/if}
 {/template}
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 4b66401..4b621b5 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -26,8 +26,34 @@
   {@param email: ?}
   {@param fromName: ?}
   {@param commentFiles: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {$fromName} has posted comments on this change.
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+  {if $unsatisfiedSubmitRequirements}
+    {\n}
+    The change is no longer submittable:{sp}
+    {if length($unsatisfiedSubmitRequirements) > 0}
+      {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+        {if $index > 0}
+          {if $index == length($unsatisfiedSubmitRequirements) - 1}
+            {sp}and{sp}
+          {else}
+            ,{sp}
+          {/if}
+        {/if}
+        {$unsatisfiedSubmitRequirement}
+      {/for}
+      {sp}
+      {if length($unsatisfiedSubmitRequirements) == 1}
+        is
+      {else}
+        are
+      {/if}
+      {sp}unsatisfied now.{\n}
+    {/if}
+  {/if}
   {\n}
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
@@ -51,13 +77,8 @@
       {for $line, $index in $comment.lines}
         {if $index == 0}
           {if $comment.startLine != 0}
-            {$comment.link}
+            {$comment.link}{sp}:{\n}
           {/if}
-
-          // Insert a space before the newline so that Gmail does not mistakenly
-          // link the following line with the file link. See issue 9201.
-          {sp}{\n}
-
           {$comment.linePrefix}
         {else}
           {$comment.linePrefixEmpty}
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index a120cea..320122e 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -25,6 +25,9 @@
   {@param labels: ?}
   {@param patchSet: ?}
   {@param patchSetCommentBlocks: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {let $commentHeaderStyle kind="css"}
     margin-bottom: 4px;
   {/let}
@@ -99,6 +102,31 @@
     </p>
   {/if}
 
+  {if $unsatisfiedSubmitRequirements}
+    <p>
+      The change is no longer submittable:{sp}
+      {if length($unsatisfiedSubmitRequirements) > 0}
+        {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+          {if $index > 0}
+            {if $index == length($unsatisfiedSubmitRequirements) - 1}
+              {sp}and{sp}
+            {else}
+              ,{sp}
+            {/if}
+          {/if}
+          {$unsatisfiedSubmitRequirement}
+        {/for}
+        {sp}
+        {if length($unsatisfiedSubmitRequirements) == 1}
+          is
+        {else}
+          are
+        {/if}
+        {sp}unsatisfied now.
+      {/if}
+    </p>
+  {/if}
+
   {if $email.changeUrl}
     <p>
       {call mailTemplate.ViewChangeButton data="all" /}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
index 2647572..6ae8625 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSet.soy
@@ -27,6 +27,9 @@
   {@param fromName: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   {if $email.reviewerNames and $fromEmail == $change.ownerEmail}
     Hello{sp}
     {for $reviewerName in $email.reviewerNames}
@@ -50,6 +53,40 @@
     {/if}.
     {if $email.changeUrl} ( {$email.changeUrl} ){/if}
   {/if}{\n}
+  {if $email.outdatedApprovals and length($email.outdatedApprovals) > 0}
+    {\n}
+    The following approvals got outdated and were removed:{\n}
+    {for $outdatedApproval, $index in $email.outdatedApprovals}
+      {if $index > 0}
+        ,{sp}
+      {/if}
+      {$outdatedApproval}
+    {/for}{\n}
+  {/if}
+  {if $unsatisfiedSubmitRequirements}
+    {\n}
+    The change is no longer submittable:{sp}
+    {if length($unsatisfiedSubmitRequirements) > 0}
+      {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+        {if $index > 0}
+          {if $index == length($unsatisfiedSubmitRequirements) - 1}
+            {sp}and{sp}
+          {else}
+            ,{sp}
+          {/if}
+        {/if}
+        {$unsatisfiedSubmitRequirement}
+      {/for}
+      {sp}
+      {if length($unsatisfiedSubmitRequirements) == 1}
+        is
+      {else}
+        are
+      {/if}
+      {sp}unsatisfied now.{\n}
+    {/if}
+  {/if}
+  {\n}
   {\n}
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
diff --git a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 4916a4a..1d99591 100644
--- a/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -25,6 +25,9 @@
   {@param fromEmail: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
+  {@param unsatisfiedSubmitRequirements: ?}
+  {@param oldSubmitRequirements: ?}
+  {@param newSubmitRequirements: ?}
   <p>
     {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
     to{sp}
@@ -41,6 +44,43 @@
     </p>
   {/if}
 
+  {if $email.outdatedApprovals and length($email.outdatedApprovals) > 0}
+    <p>
+      The following approvals got outdated and were removed:{\n}
+      {for $outdatedApproval, $index in $email.outdatedApprovals}
+        {if $index > 0}
+          ,{sp}
+        {/if}
+        {$outdatedApproval}
+      {/for}
+    </p>
+  {/if}
+
+  {if $unsatisfiedSubmitRequirements}
+    <p>
+      The change is no longer submittable:{sp}
+      {if length($unsatisfiedSubmitRequirements) > 0}
+        {for $unsatisfiedSubmitRequirement, $index in $unsatisfiedSubmitRequirements}
+          {if $index > 0}
+            {if $index == length($unsatisfiedSubmitRequirements) - 1}
+              {sp}and{sp}
+            {else}
+              ,{sp}
+            {/if}
+          {/if}
+          {$unsatisfiedSubmitRequirement}
+        {/for}
+        {sp}
+        {if length($unsatisfiedSubmitRequirements) == 1}
+          is
+        {else}
+          are
+        {/if}
+        {sp}unsatisfied now.
+      {/if}
+    </p>
+  {/if}
+
   {call mailTemplate.Pre}
     {param content: $email.changeDetail /}
   {/call}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 01ca71c..8e97ba7 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -5,6 +5,7 @@
 asp = application/x-aspx
 aspx = application/x-aspx
 asterisk = text/x-asterisk
+awl = text/x-iecst
 b = text/x-brainfuck
 bash = text/x-sh
 bf = text/x-brainfuck
@@ -38,6 +39,7 @@
 cpp = text/x-c++src
 cql = text/x-cassandra
 cxx = text/x-c++src
+cu = text/x-c++src
 cyp = application/x-cypher-query
 cypher = application/x-cypher-query
 c++ = text/x-c++src
@@ -203,6 +205,7 @@
 sas = text/x-sas
 sass = text/x-sass
 scala = text/x-scala
+scl = text/x-iecst
 scm = text/x-scheme
 scss = text/x-scss
 sh = text/x-sh
@@ -220,6 +223,7 @@
 st = text/x-stsrc
 star = text/x-python
 stex = text/x-stex
+stl = text/x-iecst
 sv = text/x-systemverilog
 svg = application/xml
 svh = text/x-systemverilog
@@ -228,6 +232,9 @@
 tex = text/x-latex
 text = text/plain
 textile = text/x-textile
+textpb = text/x-text-proto
+textproto = text/x-text-proto
+text_proto = text/x-text-proto
 tiddly = text/x-tiddlywiki
 tiddlywiki = text/x-tiddlywiki
 tiki = text/tiki
@@ -240,6 +247,7 @@
 ttcn3 = text/x-ttcn
 ttl = text/turtle
 txt = text/plain
+txtpb = text/x-text-proto
 twig = text/x-twig
 v = text/x-verilog
 vb = text/x-vb
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index e1d6f22..d9fd1f1 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -34,6 +34,11 @@
   exit 0
 fi
 
+# Do not create a change id for squash commits.
+if head -n1 "$1" | grep -q '^squash! '; then
+  exit 0
+fi
+
 if git rev-parse --verify HEAD >/dev/null 2>&1; then
   refhash="$(git rev-parse HEAD)"
 else
@@ -43,7 +48,7 @@
 random=$({ git var GIT_COMMITTER_IDENT ; echo "$refhash" ; cat "$1"; } | git hash-object --stdin)
 dest="$1.tmp.${random}"
 
-trap 'rm -f "${dest}"' EXIT
+trap 'rm -f "$dest" "$dest-2"' EXIT
 
 if ! git stripspace --strip-comments < "$1" > "${dest}" ; then
    echo "cannot strip comments from $1"
@@ -57,21 +62,39 @@
 
 reviewurl="$(git config --get gerrit.reviewUrl)"
 if test -n "${reviewurl}" ; then
-  if ! git interpret-trailers --parse < "$1" | grep -q '^Link:.*/id/I[0-9a-f]\{40\}$' ; then
-    if ! git interpret-trailers \
-          --trailer "Link: ${reviewurl%/}/id/I${random}" < "$1" > "${dest}" ; then
-      echo "cannot insert link footer in $1"
-      exit 1
-    fi
-  fi
+  token="Link"
+  value="${reviewurl%/}/id/I$random"
+  pattern=".*/id/I[0-9a-f]\{40\}$"
 else
-  # Avoid the --in-place option which only appeared in Git 2.8
-  # Avoid the --if-exists option which only appeared in Git 2.15
-  if ! git -c trailer.ifexists=doNothing interpret-trailers \
-        --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then
-    echo "cannot insert change-id line in $1"
-    exit 1
-  fi
+  token="Change-Id"
+  value="I$random"
+  pattern=".*"
+fi
+
+if git interpret-trailers --parse < "$1" | grep -q "^$token: $pattern$" ; then
+  exit 0
+fi
+
+# There must be a Signed-off-by trailer for the code below to work. Insert a
+# sentinel at the end to make sure there is one.
+# Avoid the --in-place option which only appeared in Git 2.8
+if ! git interpret-trailers \
+         --trailer "Signed-off-by: SENTINEL" < "$1" > "$dest-2" ; then
+  echo "cannot insert Signed-off-by sentinel line in $1"
+  exit 1
+fi
+
+# Make sure the trailer appears before any Signed-off-by trailers by inserting
+# it as if it was a Signed-off-by trailer and then use sed to remove the
+# Signed-off-by prefix and the Signed-off-by sentinel line.
+# Avoid the --in-place option which only appeared in Git 2.8
+# Avoid the --where option which only appeared in Git 2.15
+if ! git -c trailer.where=before interpret-trailers \
+         --trailer "Signed-off-by: $token: $value" < "$dest-2" |
+     sed -e "s/^Signed-off-by: \($token: \)/\1/" \
+         -e "/^Signed-off-by: SENTINEL/d" > "$dest" ; then
+  echo "cannot insert $token line in $1"
+  exit 1
 fi
 
 if ! mv "${dest}" "$1" ; then
diff --git a/tools/BUILD b/tools/BUILD
index 3fd2a0f..e25dcc5 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -1,6 +1,6 @@
 load(
     "@bazel_tools//tools/jdk:default_java_toolchain.bzl",
-    "JDK9_JVM_OPTS",
+    "NONPREBUILT_TOOLCHAIN_CONFIGURATION",
     "default_java_toolchain",
 )
 load("@rules_java//java:defs.bzl", "java_package_configuration")
@@ -8,60 +8,25 @@
 exports_files(["nongoogle.bzl"])
 
 default_java_toolchain(
-    name = "error_prone_warnings_toolchain",
-    bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
-    jvm_opts = JDK9_JVM_OPTS,
+    name = "error_prone_warnings_toolchain_java11",
+    configuration = NONPREBUILT_TOOLCHAIN_CONFIGURATION,
     package_configuration = [
         ":error_prone",
     ],
+    source_version = "11",
+    target_version = "11",
     visibility = ["//visibility:public"],
 )
 
-JDK11_JVM_OPTS = select({
-    "@bazel_tools//src/conditions:openbsd": ["-Xbootclasspath/p:$(location @bazel_tools//tools/jdk:javac_jar)"],
-    "//conditions:default": [
-        "--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
-        "--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
-        "--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
-        "--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
-        "--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
-        "--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
-        "--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
-        "--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
-        "--patch-module=java.compiler=$(location @bazel_tools//tools/jdk:java_compiler_jar)",
-        "--patch-module=jdk.compiler=$(location @bazel_tools//tools/jdk:jdk_compiler_jar)",
-        "--add-opens=java.base/java.nio=ALL-UNNAMED",
-        "--add-opens=java.base/java.lang=ALL-UNNAMED",
-    ],
-})
-
 default_java_toolchain(
-    name = "error_prone_warnings_toolchain_java11",
-    bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
-    forcibly_disable_header_compilation = False,
-    genclass = ["@bazel_tools//tools/jdk:genclass"],
-    header_compiler = ["@bazel_tools//tools/jdk:turbine"],
-    header_compiler_direct = ["@bazel_tools//tools/jdk:turbine_direct"],
-    ijar = ["@bazel_tools//tools/jdk:ijar"],
-    javabuilder = ["@bazel_tools//tools/jdk:javabuilder"],
-    javac_supports_workers = True,
-    jvm_opts = JDK11_JVM_OPTS,
-    misc = [
-        "-XDskipDuplicateBridges=true",
-        "-g",
-        "-parameters",
-    ],
+    name = "error_prone_warnings_toolchain_java17",
+    configuration = dict(),
+    java_runtime = "@bazel_tools//tools/jdk:remotejdk_17",
     package_configuration = [
         ":error_prone",
     ],
-    singlejar = ["@bazel_tools//tools/jdk:singlejar"],
-    source_version = "11",
-    target_version = "11",
-    tools = [
-        "@bazel_tools//tools/jdk:java_compiler_jar",
-        "@bazel_tools//tools/jdk:javac_jar",
-        "@bazel_tools//tools/jdk:jdk_compiler_jar",
-    ],
+    source_version = "17",
+    target_version = "17",
     visibility = ["//visibility:public"],
 )
 
@@ -81,11 +46,13 @@
         "-XepDisableWarningsInGeneratedCode",
         # The XepDisableWarningsInGeneratedCode disables only warnings, but
         # not errors. We should manually exclude all files generated by
-        # AutoValue; such files always start $AutoValue_.....
+        # AutoValue; such files always start AutoValue_..., $AutoValue_...,
+        # $$AutoValue_... or AutoValueGson_...
         # XepExcludedPaths is a regexp. If you need more paths - use | as
         # separator.
-        "-XepExcludedPaths:.*/\\\\$$AutoValue_.*\\.java",
+        "-XepExcludedPaths:.*/\\\\$$?\\\\$$?AutoValue(Gson)?_.*\\.java",
         "-Xep:AlmostJavadoc:ERROR",
+        "-Xep:AlreadyChecked:ERROR",
         "-Xep:AlwaysThrows:ERROR",
         "-Xep:AmbiguousMethodReference:ERROR",
         "-Xep:AnnotateFormatMethod:ERROR",
@@ -102,8 +69,8 @@
         "-Xep:AsyncFunctionReturnsNull:ERROR",
         "-Xep:AutoValueConstructorOrderChecker:ERROR",
         "-Xep:AutoValueFinalMethods:ERROR",
-        # "-Xep:AutoValueImmutableFields:WARN",
-        # "-Xep:AutoValueSubclassLeaked:WARN",
+        "-Xep:AutoValueImmutableFields:ERROR",
+        "-Xep:AutoValueSubclassLeaked:ERROR",
         "-Xep:BadAnnotationImplementation:ERROR",
         "-Xep:BadComparable:ERROR",
         "-Xep:BadImport:ERROR",
@@ -119,7 +86,7 @@
         "-Xep:CacheLoaderNull:ERROR",
         "-Xep:CannotMockFinalClass:ERROR",
         "-Xep:CanonicalDuration:ERROR",
-        # "-Xep:CatchAndPrintStackTrace:WARN",
+        "-Xep:CatchAndPrintStackTrace:ERROR",
         "-Xep:CatchFail:ERROR",
         "-Xep:ChainedAssertionLosesContext:ERROR",
         "-Xep:ChainingConstructorIgnoresParameter:ERROR",
@@ -151,7 +118,7 @@
         "-Xep:DeadException:ERROR",
         "-Xep:DeadThread:ERROR",
         "-Xep:DefaultCharset:ERROR",
-        # "-Xep:DefaultPackage:WARN",
+        "-Xep:DefaultPackage:ERROR",
         "-Xep:DepAnn:ERROR",
         "-Xep:DeprecatedVariable:ERROR",
         "-Xep:DiscardedPostfixExpression:ERROR",
@@ -168,9 +135,9 @@
         "-Xep:DurationTemporalUnit:ERROR",
         "-Xep:DurationToLongTimeUnit:ERROR",
         "-Xep:EmptyBlockTag:ERROR",
-        # "-Xep:EmptyCatch:WARN",
+        "-Xep:EmptyCatch:ERROR",
         "-Xep:EmptySetMultibindingContributions:ERROR",
-        # "-Xep:EqualsGetClass:WARN",
+        "-Xep:EqualsGetClass:ERROR",
         "-Xep:EqualsHashCode:ERROR",
         "-Xep:EqualsIncompatibleType:ERROR",
         "-Xep:EqualsNaN:ERROR",
@@ -180,7 +147,7 @@
         "-Xep:EqualsUsingHashCode:ERROR",
         "-Xep:EqualsWrongThing:ERROR",
         "-Xep:ErroneousThreadPoolConstructorChecker:ERROR",
-        "-Xep:EscapedEntity:WARN",
+        "-Xep:EscapedEntity:ERROR",
         "-Xep:ExpectedExceptionChecker:ERROR",
         "-Xep:ExtendingJUnitAssert:ERROR",
         "-Xep:ExtendsAutoValue:ERROR",
@@ -202,7 +169,7 @@
         "-Xep:FromTemporalAccessor:ERROR",
         "-Xep:FunctionalInterfaceClash:ERROR",
         "-Xep:FunctionalInterfaceMethodChanged:ERROR",
-        # "-Xep:FutureReturnValueIgnored:ERROR", // this check has a bug.
+        "-Xep:FutureReturnValueIgnored:ERROR",
         "-Xep:FuturesGetCheckedIllegalExceptionType:ERROR",
         "-Xep:GetClassOnAnnotation:ERROR",
         "-Xep:GetClassOnClass:ERROR",
@@ -231,7 +198,7 @@
         "-Xep:InfiniteRecursion:ERROR",
         "-Xep:InjectOnConstructorOfAbstractClass:ERROR",
         "-Xep:InheritDoc:ERROR",
-        # "-Xep:InlineFormatString:WARN",
+        "-Xep:InlineFormatString:ERROR",
         "-Xep:InlineMeInliner:ERROR",
         "-Xep:InlineMeSuggester:ERROR",
         "-Xep:InlineMeValidator:ERROR",
@@ -276,7 +243,7 @@
         "-Xep:JavaPeriodGetDays:ERROR",
         "-Xep:JavaTimeDefaultTimeZone:ERROR",
         "-Xep:JavaUtilDate:ERROR",
-        # "-Xep:JdkObsolete:WARN",
+        "-Xep:JdkObsolete:ERROR",
         "-Xep:JodaConstructors:ERROR",
         "-Xep:JodaDateTimeConstants:ERROR",
         "-Xep:JodaDurationWithMillis:ERROR",
@@ -311,7 +278,7 @@
         "-Xep:MisusedDayOfYear:ERROR",
         "-Xep:MisusedWeekYear:ERROR",
         "-Xep:MixedDescriptors:ERROR",
-        # "-Xep:MixedMutabilityReturnType:WARN",
+        "-Xep:MixedMutabilityReturnType:ERROR",
         "-Xep:MockitoUsage:ERROR",
         "-Xep:ModifiedButNotUsed:ERROR",
         "-Xep:ModifyCollectionInEnhancedForLoop:ERROR",
@@ -321,13 +288,13 @@
         "-Xep:MultipleUnaryOperatorsInMethodCall:ERROR",
         "-Xep:MustBeClosedChecker:ERROR",
         "-Xep:MutableConstantField:ERROR",
-        # "-Xep:MutablePublicArray:WARN",
+        "-Xep:MutablePublicArray:ERROR",
         "-Xep:NCopiesOfChar:ERROR",
         "-Xep:NarrowingCompoundAssignment:ERROR",
         "-Xep:NestedInstanceOfConditions:ERROR",
         "-Xep:NonAtomicVolatileUpdate:ERROR",
         "-Xep:NonCanonicalStaticImport:ERROR",
-        # "-Xep:NonCanonicalType:WARN",
+        "-Xep:NonCanonicalType:ERROR",
         "-Xep:NonFinalCompileTimeConstant:ERROR",
         "-Xep:NonOverridingEquals:ERROR",
         "-Xep:NonRuntimeAnnotation:ERROR",
@@ -364,7 +331,7 @@
         "-Xep:PreconditionsInvalidPlaceholder:ERROR",
         "-Xep:PrimitiveAtomicReference:ERROR",
         "-Xep:PrivateSecurityContractProtoAccess:ERROR",
-        # "-Xep:ProtectedMembersInFinalClass:WARN",
+        "-Xep:ProtectedMembersInFinalClass:ERROR",
         "-Xep:ProtoBuilderReturnValueIgnored:ERROR",
         "-Xep:ProtoDurationGetSecondsGetNano:ERROR",
         "-Xep:ProtoFieldNullComparison:ERROR",
@@ -385,9 +352,10 @@
         "-Xep:RestrictedApiChecker:ERROR",
         "-Xep:RethrowReflectiveOperationExceptionAsLinkageError:ERROR",
         "-Xep:ReturnFromVoid:ERROR",
+        "-Xep:ReturnMissingNullable:ERROR",
         "-Xep:ReturnValueIgnored:ERROR",
         "-Xep:RxReturnValueIgnored:ERROR",
-        # "-Xep:SameNameButDifferent:WARN",
+        "-Xep:SameNameButDifferent:ERROR",
         "-Xep:SelfAssignment:ERROR",
         "-Xep:SelfComparison:ERROR",
         "-Xep:SelfEquals:ERROR",
@@ -401,7 +369,7 @@
         "-Xep:StreamToString:ERROR",
         "-Xep:StringBuilderInitWithChar:ERROR",
         "-Xep:StringEquality:ERROR",
-        # "-Xep:StringSplitter:WARN",
+        "-Xep:StringSplitter:ERROR",
         "-Xep:SubstringOfZero:ERROR",
         "-Xep:SuppressWarningsDeprecated:ERROR",
         "-Xep:SwigMemoryLeak:ERROR",
@@ -409,9 +377,9 @@
         "-Xep:TemporalAccessorGetChronoField:ERROR",
         "-Xep:TestParametersNotInitialized:ERROR",
         "-Xep:TheoryButNoTheories:ERROR",
-        # "-Xep:ThreadJoinLoop:WARN",
+        "-Xep:ThreadJoinLoop:ERROR",
         "-Xep:ThreadLocalUsage:ERROR",
-        # "-Xep:ThreadPriorityCheck:WARN",
+        "-Xep:ThreadPriorityCheck:ERROR",
         "-Xep:ThreeLetterTimeZoneID:ERROR",
         "-Xep:ThrowIfUncheckedKnownChecked:ERROR",
         "-Xep:ThrowNull:ERROR",
@@ -430,14 +398,14 @@
         "-Xep:TypeParameterShadowing:ERROR",
         "-Xep:TypeParameterUnusedInFormals:ERROR",
         "-Xep:URLEqualsHashCode:ERROR",
-        # "-Xep:UndefinedEquals:WARN",
+        "-Xep:UndefinedEquals:ERROR",
         "-Xep:UnescapedEntity:ERROR",
         "-Xep:UnnecessaryAssignment:ERROR",
         "-Xep:UnnecessaryCheckNotNull:ERROR",
-        # "-Xep:UnnecessaryLambda:WARN",
+        "-Xep:UnnecessaryLambda:ERROR",
         "-Xep:UnnecessaryMethodInvocationMatcher:ERROR",
         "-Xep:UnnecessaryMethodReference:ERROR",
-        # "-Xep:UnnecessaryParentheses:WARN",
+        "-Xep:UnnecessaryParentheses:ERROR",
         "-Xep:UnnecessaryTypeArgument:ERROR",
         "-Xep:UnrecognisedJavadocTag:ERROR",
         "-Xep:UnsafeFinalization:ERROR",
@@ -460,6 +428,7 @@
         "-Xep:WrongOneof:ERROR",
         "-Xep:XorPower:ERROR",
         "-Xep:ZoneIdOfZ:ERROR",
+        "-Xlint:unchecked",
     ],
     packages = ["error_prone_packages"],
 )
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/asciidoc.bzl b/tools/bzl/asciidoc.bzl
index 7977cf0..0858d60 100644
--- a/tools/bzl/asciidoc.bzl
+++ b/tools/bzl/asciidoc.bzl
@@ -122,7 +122,7 @@
     ),
     "_exe": attr.label(
         default = Label("//java/com/google/gerrit/asciidoctor:asciidoc"),
-        cfg = "host",
+        cfg = "exec",
         allow_files = True,
         executable = True,
     ),
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index 62b4010..3add025 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -61,14 +61,14 @@
         command = " && ".join(cmd),
     )
 
-_java_doc = rule(
+java_doc = rule(
     attrs = {
         "external_docs": attr.string_list(),
         "libs": attr.label_list(allow_files = False),
         "pkgs": attr.string_list(),
         "title": attr.string(),
         "_jdk": attr.label(
-            default = Label("@bazel_tools//tools/jdk:current_host_java_runtime"),
+            default = Label("@bazel_tools//tools/jdk:current_java_runtime"),
             allow_files = True,
             providers = [java_common.JavaRuntimeInfo],
         ),
@@ -76,16 +76,3 @@
     outputs = {"zip": "%{name}.zip"},
     implementation = _impl,
 )
-
-def java_doc(**kwargs):
-    libs = kwargs.get("libs", [])
-    libs = libs + select({
-        "//:java11": [],
-        "//:java_next": [],
-        # TODO(davido): Remove this dependency, when Java 8 support is removed.
-        # auto-value generates @javax.annotation.Generated annotation on generated
-        # classes when Java 8 source compatibility level is used, but Java 11 and
-        # later don't have this class any more.
-        "//conditions:default": ["//lib:javax-annotation"],
-    })
-    _java_doc(**dict(kwargs, libs = libs))
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index c8d6e4b..8025dab 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -110,6 +110,7 @@
         entry_point = entry_point,
         format = "iife",
         rollup_bin = "//tools/node_tools:rollup-bin",
+        silent = True,
         sourcemap = "hidden",
         config_file = "//plugins:rollup.config.js",
         deps = [
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 3695e16..4659c48 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -25,6 +25,7 @@
 
 @RunWith(Suite.class)
 @Suite.SuiteClasses({%s})
+@SuppressWarnings("DefaultPackage")
 public class %s {}
 """
 
@@ -72,6 +73,11 @@
     "-Djava.locale.providers=COMPAT,CLDR,SPI",
 ]
 
+POST_JDK17_OPTS = [
+    # https://github.com/bazelbuild/bazel/issues/14502
+    "-Djava.security.manager=allow",
+]
+
 def junit_tests(name, srcs, **kwargs):
     s_name = name.replace("-", "_") + "TestSuite"
     _gen_suite(
@@ -80,6 +86,10 @@
         outname = s_name,
     )
     jvm_flags = kwargs.get("jvm_flags", []) + POST_JDK8_OPTS
+    jvm_flags = jvm_flags + select({
+        "//:java17": POST_JDK8_OPTS + POST_JDK17_OPTS,
+        "//conditions:default": POST_JDK8_OPTS,
+    })
     java_test(
         name = name,
         test_class = s_name,
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 43b172c..79285e6 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -170,7 +170,7 @@
 Gerrit includes an SSH daemon (Apache SSHD), to support authenticated
 uploads of changes directly from `git push` command line clients.
 
-Gerrit includes an SSH client (JSch), to support authenticated
+Gerrit includes an SSH client (Apache SSHD), to support authenticated
 replication of changes to remote systems, such as for automatic
 updates of mirror servers, or realtime backups.
 
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 2b473bc..e2be145 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -14,6 +14,8 @@
 
 # War packaging.
 
+load("//tools:deps.bzl", "AUTO_FACTORY_VERSION", "AUTO_VALUE_GSON_VERSION", "AUTO_VALUE_VERSION")
+
 jar_filetype = [".jar"]
 
 LIBS = [
@@ -23,7 +25,6 @@
     "//lib/bouncycastle:bcprov",
     "//lib/bouncycastle:bcpg",
     "//lib/log:impl-log4j",
-    "//lib:jgit-ssh-jsch",
     "//prolog:gerrit-prolog-common",
     "//resources:log4j-config",
 ]
@@ -32,6 +33,13 @@
     "//java/com/google/gerrit/pgm",
 ]
 
+SKIP_DEPS = [
+    "auto-factory-%s.jar" % AUTO_FACTORY_VERSION,
+    "auto-value-%s.jar" % AUTO_VALUE_VERSION,
+    "auto-value-annotations-%s.jar" % AUTO_VALUE_VERSION,
+    "auto-value-gson-runtime-%s.jar" % AUTO_VALUE_GSON_VERSION,
+]
+
 def _add_context(in_file, output):
     input_path = in_file.path
     return [
@@ -86,6 +94,8 @@
 
     transitive_lib_deps = depset(transitive = transitive_libs)
     for dep in transitive_lib_deps.to_list():
+        if dep.basename in SKIP_DEPS:
+            continue
         cmd += _add_file(dep, build_output + "/WEB-INF/lib/")
         inputs.append(dep)
 
@@ -96,6 +106,8 @@
 
     transitive_pgmlib_deps = depset(transitive = transitive_pgmlibs)
     for dep in transitive_pgmlib_deps.to_list():
+        if dep.basename in SKIP_DEPS:
+            continue
         if dep not in inputs:
             cmd += _add_file(dep, build_output + "/WEB-INF/pgm-lib/")
             inputs.append(dep)
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index d445be2..9e515e5 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -21,6 +21,7 @@
         provided_deps = [],
         srcs = [],
         resources = [],
+        resource_jars = [],
         manifest_entries = [],
         dir_name = None,
         target_suffix = "",
@@ -35,8 +36,6 @@
         **kwargs
     )
 
-    static_jars = []
-
     if not dir_name:
         dir_name = name
 
@@ -49,7 +48,7 @@
         main_class = "Dummy",
         runtime_deps = [
             ":%s__plugin" % name,
-        ] + static_jars,
+        ] + resource_jars,
         deploy_env = deploy_env,
         visibility = ["//visibility:public"],
         **kwargs
diff --git a/tools/deps.bzl b/tools/deps.bzl
new file mode 100644
index 0000000..ed8d65f5
--- /dev/null
+++ b/tools/deps.bzl
@@ -0,0 +1,703 @@
+load("//tools/bzl:maven_jar.bzl", "GERRIT", "maven_jar")
+
+CAFFEINE_VERS = "2.9.2"
+ANTLR_VERS = "3.5.2"
+COMMONMARK_VERS = "0.10.0"
+FLEXMARK_VERS = "0.50.50"
+GREENMAIL_VERS = "1.5.5"
+MAIL_VERS = "1.6.0"
+MIME4J_VERS = "0.8.1"
+OW2_VERS = "9.2"
+AUTO_COMMON_VERSION = "1.2.1"
+AUTO_FACTORY_VERSION = "1.0.1"
+AUTO_VALUE_VERSION = "1.7.4"
+AUTO_VALUE_GSON_VERSION = "1.3.1"
+PROLOG_VERS = "1.4.4"
+PROLOG_REPO = GERRIT
+GITILES_VERS = "1.0.0"
+GITILES_REPO = GERRIT
+
+# When updating Bouncy Castle, also update it in bazlets.
+BC_VERS = "1.64"
+HTTPCOMP_VERS = "4.5.2"
+JETTY_VERS = "9.4.49.v20220914"
+BYTE_BUDDY_VERSION = "1.10.7"
+
+def java_dependencies():
+    maven_jar(
+        name = "java-runtime",
+        artifact = "org.antlr:antlr-runtime:" + ANTLR_VERS,
+        sha1 = "cd9cd41361c155f3af0f653009dcecb08d8b4afd",
+    )
+
+    maven_jar(
+        name = "stringtemplate",
+        artifact = "org.antlr:stringtemplate:4.0.2",
+        sha1 = "e28e09e2d44d60506a7bcb004d6c23ff35c6ac08",
+    )
+
+    maven_jar(
+        name = "org-antlr",
+        artifact = "org.antlr:antlr:" + ANTLR_VERS,
+        sha1 = "c4a65c950bfc3e7d04309c515b2177c00baf7764",
+    )
+
+    maven_jar(
+        name = "antlr27",
+        artifact = "antlr:antlr:2.7.7",
+        attach_source = False,
+        sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0",
+    )
+
+    maven_jar(
+        name = "aopalliance",
+        artifact = "aopalliance:aopalliance:1.0",
+        sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8",
+    )
+
+    maven_jar(
+        name = "javax_inject",
+        artifact = "javax.inject:javax.inject:1",
+        sha1 = "6975da39a7040257bd51d21a231b76c915872d38",
+    )
+
+    maven_jar(
+        name = "servlet-api",
+        artifact = "javax.servlet:javax.servlet-api:3.1.0",
+        sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
+    )
+
+    # JGit's transitive dependencies
+
+    maven_jar(
+        name = "javaewah",
+        artifact = "com.googlecode.javaewah:JavaEWAH:1.1.12",
+        attach_source = False,
+        sha1 = "9feecc2b24d6bc9ff865af8d082f192238a293eb",
+    )
+
+    maven_jar(
+        name = "gson",
+        artifact = "com.google.code.gson:gson:2.9.0",
+        sha1 = "8a1167e089096758b49f9b34066ef98b2f4b37aa",
+    )
+
+    maven_jar(
+        name = "caffeine",
+        artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
+        sha1 = "0a17ed335e0ce2d337750772c0709b79af35a842",
+    )
+
+    maven_jar(
+        name = "guava-failureaccess",
+        artifact = "com.google.guava:failureaccess:1.0.1",
+        sha1 = "1dcf1de382a0bf95a3d8b0849546c88bac1292c9",
+    )
+
+    maven_jar(
+        name = "juniversalchardet",
+        artifact = "com.github.albfernandez:juniversalchardet:2.0.0",
+        sha1 = "28c59f58f5adcc307604602e2aa89e2aca14c554",
+    )
+
+    maven_jar(
+        name = "json-smart",
+        artifact = "net.minidev:json-smart:1.1.1",
+        sha1 = "24a2f903d25e004de30ac602c5b47f2d4e420a59",
+    )
+
+    maven_jar(
+        name = "args4j",
+        artifact = "args4j:args4j:2.33",
+        sha1 = "bd87a75374a6d6523de82fef51fc3cfe9baf9fc9",
+    )
+
+    maven_jar(
+        name = "commons-codec",
+        artifact = "commons-codec:commons-codec:1.15",
+        sha1 = "49d94806b6e3dc933dacbd8acb0fdbab8ebd1e5d",
+    )
+
+    # When upgrading commons-compress, also upgrade tukaani-xz
+    maven_jar(
+        name = "commons-compress",
+        artifact = "org.apache.commons:commons-compress:1.20",
+        sha1 = "b8df472b31e1f17c232d2ad78ceb1c84e00c641b",
+    )
+
+    maven_jar(
+        name = "commons-lang3",
+        artifact = "org.apache.commons:commons-lang3:3.8.1",
+        sha1 = "6505a72a097d9270f7a9e7bf42c4238283247755",
+    )
+
+    maven_jar(
+        name = "commons-text",
+        artifact = "org.apache.commons:commons-text:1.2",
+        sha1 = "74acdec7237f576c4803fff0c1008ab8a3808b2b",
+    )
+
+    maven_jar(
+        name = "commons-dbcp",
+        artifact = "commons-dbcp:commons-dbcp:1.4",
+        sha1 = "30be73c965cc990b153a100aaaaafcf239f82d39",
+    )
+
+    # Transitive dependency of commons-dbcp, do not update without
+    # also updating commons-dbcp
+    maven_jar(
+        name = "commons-pool",
+        artifact = "commons-pool:commons-pool:1.5.5",
+        sha1 = "7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b",
+    )
+
+    maven_jar(
+        name = "commons-net",
+        artifact = "commons-net:commons-net:3.6",
+        sha1 = "b71de00508dcb078d2b24b5fa7e538636de9b3da",
+    )
+
+    maven_jar(
+        name = "commons-validator",
+        artifact = "commons-validator:commons-validator:1.6",
+        sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908",
+    )
+
+    maven_jar(
+        name = "automaton",
+        artifact = "dk.brics:automaton:1.12-1",
+        sha1 = "959a0c62f9a5c2309e0ad0b0589c74d69e101241",
+    )
+
+    # commonmark must match the version used in Gitiles
+    maven_jar(
+        name = "commonmark",
+        artifact = "com.atlassian.commonmark:commonmark:" + COMMONMARK_VERS,
+        sha1 = "119cb7bedc3570d9ecb64ec69ab7686b5c20559b",
+    )
+
+    maven_jar(
+        name = "cm-autolink",
+        artifact = "com.atlassian.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERS,
+        sha1 = "a6056a5efbd68f57d420bc51bbc54b28a5d3c56b",
+    )
+
+    maven_jar(
+        name = "gfm-strikethrough",
+        artifact = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERS,
+        sha1 = "40837da951b421b545edddac57012e15fcc9e63c",
+    )
+
+    maven_jar(
+        name = "gfm-tables",
+        artifact = "com.atlassian.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERS,
+        sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
+    )
+
+    maven_jar(
+        name = "flexmark",
+        artifact = "com.vladsch.flexmark:flexmark:" + FLEXMARK_VERS,
+        sha1 = "7f61e190cff7e1bea64906408ccfa583b5ac9fc2",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-abbreviation",
+        artifact = "com.vladsch.flexmark:flexmark-ext-abbreviation:" + FLEXMARK_VERS,
+        sha1 = "8f62f49cfaf8d33391e48a3b79e941d94e444e50",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-anchorlink",
+        artifact = "com.vladsch.flexmark:flexmark-ext-anchorlink:" + FLEXMARK_VERS,
+        sha1 = "1d530e44b4439abf53ce9dcc784ffa5b9fd6ce89",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-autolink",
+        artifact = "com.vladsch.flexmark:flexmark-ext-autolink:" + FLEXMARK_VERS,
+        sha1 = "4026aa60fd6e146c2d4931acb19b2409c6a79b8a",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-definition",
+        artifact = "com.vladsch.flexmark:flexmark-ext-definition:" + FLEXMARK_VERS,
+        sha1 = "bb74d36459e8e34653761aaa0095220b8b7b6cb6",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-emoji",
+        artifact = "com.vladsch.flexmark:flexmark-ext-emoji:" + FLEXMARK_VERS,
+        sha1 = "36d36cb227f831b81b636d3f901f07db06b8d84d",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-escaped-character",
+        artifact = "com.vladsch.flexmark:flexmark-ext-escaped-character:" + FLEXMARK_VERS,
+        sha1 = "09f0cef3260b6f6371d6066cf32ce6a7dc2a7922",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-footnotes",
+        artifact = "com.vladsch.flexmark:flexmark-ext-footnotes:" + FLEXMARK_VERS,
+        sha1 = "cdf0b79f026b09c6b87d91e61eb29ada1577aa5c",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-gfm-issues",
+        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-issues:" + FLEXMARK_VERS,
+        sha1 = "e40d347e242e35d4344553120cb9e69c1c975f41",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-gfm-strikethrough",
+        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough:" + FLEXMARK_VERS,
+        sha1 = "a6948f6e4fc2ec1059d6b53e73d4a30c24f6e05d",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-gfm-tables",
+        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tables:" + FLEXMARK_VERS,
+        sha1 = "92d3eb0c5dc79924448c186d89717f1df853aaa0",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-gfm-tasklist",
+        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-tasklist:" + FLEXMARK_VERS,
+        sha1 = "e6fb4d9e46c96e61a07fbb40cf653db58ed95a95",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-gfm-users",
+        artifact = "com.vladsch.flexmark:flexmark-ext-gfm-users:" + FLEXMARK_VERS,
+        sha1 = "44c83bdccfd41a399f3f7b11ba72728382dd3f5a",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-ins",
+        artifact = "com.vladsch.flexmark:flexmark-ext-ins:" + FLEXMARK_VERS,
+        sha1 = "b0d174d86ac2348420993dc2c997fad5f02c68fa",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-jekyll-front-matter",
+        artifact = "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter:" + FLEXMARK_VERS,
+        sha1 = "2bfb31c67fd10af058b90f1b364f881f6276de80",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-superscript",
+        artifact = "com.vladsch.flexmark:flexmark-ext-superscript:" + FLEXMARK_VERS,
+        sha1 = "d210b007b46339082f79b6caf632b19ac8efc341",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-tables",
+        artifact = "com.vladsch.flexmark:flexmark-ext-tables:" + FLEXMARK_VERS,
+        sha1 = "be77790470aa9bd90011067221504162a1d7b083",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-toc",
+        artifact = "com.vladsch.flexmark:flexmark-ext-toc:" + FLEXMARK_VERS,
+        sha1 = "91d6d63ff5b70c3ebae98867bd7a346d71cb0ce1",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-typographic",
+        artifact = "com.vladsch.flexmark:flexmark-ext-typographic:" + FLEXMARK_VERS,
+        sha1 = "f51e247df80628df509a3af92a1efa1efb83d746",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-wikilink",
+        artifact = "com.vladsch.flexmark:flexmark-ext-wikilink:" + FLEXMARK_VERS,
+        sha1 = "d14aeb078dbaf3e166621ae9499595a4a57b22ab",
+    )
+
+    maven_jar(
+        name = "flexmark-ext-yaml-front-matter",
+        artifact = "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:" + FLEXMARK_VERS,
+        sha1 = "aba328b70e15f2553aac0a74e391edab37bf7b30",
+    )
+
+    maven_jar(
+        name = "flexmark-formatter",
+        artifact = "com.vladsch.flexmark:flexmark-formatter:" + FLEXMARK_VERS,
+        sha1 = "3e4ad3b1be29d41e8c35cadb70761768505f2164",
+    )
+
+    maven_jar(
+        name = "flexmark-html-parser",
+        artifact = "com.vladsch.flexmark:flexmark-html-parser:" + FLEXMARK_VERS,
+        sha1 = "3c59b0061c50c9a8a48c46d4f4498d8eba249921",
+    )
+
+    maven_jar(
+        name = "flexmark-profile-pegdown",
+        artifact = "com.vladsch.flexmark:flexmark-profile-pegdown:" + FLEXMARK_VERS,
+        sha1 = "c01a2c2eebe06230858956d45847cee233790d7c",
+    )
+
+    maven_jar(
+        name = "flexmark-util",
+        artifact = "com.vladsch.flexmark:flexmark-util:" + FLEXMARK_VERS,
+        sha1 = "fdfce72f5eb9ec53085804fd0c8d15f3680ae359",
+    )
+
+    # Transitive dependency of flexmark and gitiles
+    maven_jar(
+        name = "autolink",
+        artifact = "org.nibor.autolink:autolink:0.7.0",
+        sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
+    )
+
+    maven_jar(
+        name = "greenmail",
+        artifact = "com.icegreen:greenmail:" + GREENMAIL_VERS,
+        sha1 = "9ea96384ad2cb8118c22f493b529eb72c212691c",
+    )
+
+    maven_jar(
+        name = "mail",
+        artifact = "com.sun.mail:javax.mail:" + MAIL_VERS,
+        sha1 = "a055c648842c4954c1f7db7254f45d9ad565e278",
+    )
+
+    maven_jar(
+        name = "mime4j-core",
+        artifact = "org.apache.james:apache-mime4j-core:" + MIME4J_VERS,
+        sha1 = "c62dfe18a3b827a2c626ade0ffba44562ddf3f61",
+    )
+
+    maven_jar(
+        name = "mime4j-dom",
+        artifact = "org.apache.james:apache-mime4j-dom:" + MIME4J_VERS,
+        sha1 = "f2d653c617004193f3350330d907f77b60c88c56",
+    )
+
+    maven_jar(
+        name = "jsoup",
+        artifact = "org.jsoup:jsoup:1.14.3",
+        sha1 = "c43a81e18e6d0eb71951aa031d55d5c293c531a6",
+    )
+
+    maven_jar(
+        name = "ow2-asm",
+        artifact = "org.ow2.asm:asm:" + OW2_VERS,
+        sha1 = "81a03f76019c67362299c40e0ba13405f5467bff",
+    )
+
+    maven_jar(
+        name = "ow2-asm-analysis",
+        artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS,
+        sha1 = "7487dd756daf96cab9986e44b9d7bcb796a61c10",
+    )
+
+    maven_jar(
+        name = "ow2-asm-commons",
+        artifact = "org.ow2.asm:asm-commons:" + OW2_VERS,
+        sha1 = "f4d7f0fc9054386f2893b602454d48e07d4fbead",
+    )
+
+    maven_jar(
+        name = "ow2-asm-tree",
+        artifact = "org.ow2.asm:asm-tree:" + OW2_VERS,
+        sha1 = "d96c99a30f5e1a19b0e609dbb19a44d8518ac01e",
+    )
+
+    maven_jar(
+        name = "ow2-asm-util",
+        artifact = "org.ow2.asm:asm-util:" + OW2_VERS,
+        sha1 = "fbc178fc5ba3dab50fd7e8a5317b8b647c8e8946",
+    )
+
+    maven_jar(
+        name = "auto-common",
+        artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
+        sha1 = "f6da26895f759010f5f170c8044e84c1b17ef83e",
+    )
+
+    maven_jar(
+        name = "auto-factory",
+        artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
+        sha1 = "f81ece06b6525085da217cd900116f44caafe877",
+    )
+
+    maven_jar(
+        name = "auto-service-annotations",
+        artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
+        sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
+    )
+
+    maven_jar(
+        name = "auto-value",
+        artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
+        sha1 = "6b126cb218af768339e4d6e95a9b0ae41f74e73d",
+    )
+
+    maven_jar(
+        name = "auto-value-annotations",
+        artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
+        sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
+    )
+
+    maven_jar(
+        name = "auto-value-gson-runtime",
+        artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
+        sha1 = "addda2ae6cce9f855788274df5de55dde4de7b71",
+    )
+
+    maven_jar(
+        name = "auto-value-gson-extension",
+        artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
+        sha1 = "0c4c01a3e10e5b10df2e5f5697efa4bb3f453ac1",
+    )
+
+    maven_jar(
+        name = "auto-value-gson-factory",
+        artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
+        sha1 = "9ed8d79144ee8d60cc94cc11f847b5ed8ee9f19c",
+    )
+
+    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",
+    )
+
+    maven_jar(
+        name = "mime-util",
+        artifact = "eu.medsea.mimeutil:mime-util:2.1.3",
+        attach_source = False,
+        sha1 = "0c9cfae15c74f62491d4f28def0dff1dabe52a47",
+    )
+
+    maven_jar(
+        name = "prolog-runtime",
+        artifact = "com.googlecode.prolog-cafe:prolog-runtime:" + PROLOG_VERS,
+        attach_source = False,
+        repository = PROLOG_REPO,
+        sha1 = "e9a364f4233481cce63239e8e68a6190c8f58acd",
+    )
+
+    maven_jar(
+        name = "prolog-compiler",
+        artifact = "com.googlecode.prolog-cafe:prolog-compiler:" + PROLOG_VERS,
+        attach_source = False,
+        repository = PROLOG_REPO,
+        sha1 = "570295026f6aa7b905e423d107cb2e081eecdc04",
+    )
+
+    maven_jar(
+        name = "prolog-io",
+        artifact = "com.googlecode.prolog-cafe:prolog-io:" + PROLOG_VERS,
+        attach_source = False,
+        repository = PROLOG_REPO,
+        sha1 = "1f25c4e27d22bdbc31481ee0c962a2a2853e4428",
+    )
+
+    maven_jar(
+        name = "cafeteria",
+        artifact = "com.googlecode.prolog-cafe:prolog-cafeteria:" + PROLOG_VERS,
+        attach_source = False,
+        repository = PROLOG_REPO,
+        sha1 = "0e6c2deeaf5054815a561cbd663566fd59b56c6c",
+    )
+
+    maven_jar(
+        name = "guava-retrying",
+        artifact = "com.github.rholder:guava-retrying:2.0.0",
+        sha1 = "974bc0a04a11cc4806f7c20a34703bd23c34e7f4",
+    )
+
+    maven_jar(
+        name = "jsr305",
+        artifact = "com.google.code.findbugs:jsr305:3.0.1",
+        sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
+    )
+
+    maven_jar(
+        name = "blame-cache",
+        artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
+        attach_source = False,
+        repository = GITILES_REPO,
+        sha1 = "f46833f8aa6f33ce3e443c8a414c295559eaf43e",
+    )
+
+    maven_jar(
+        name = "gitiles-servlet",
+        artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
+        repository = GITILES_REPO,
+        sha1 = "90e107da00c2cd32490dd9ae8e3fb1ee095ea675",
+    )
+
+    # prettify must match the version used in Gitiles
+    maven_jar(
+        name = "prettify",
+        artifact = "com.github.twalcari:java-prettify:1.2.2",
+        sha1 = "b8ba1c1eb8b2e45cfd465d01218c6060e887572e",
+    )
+
+    maven_jar(
+        name = "html-types",
+        artifact = "com.google.common.html.types:types:1.0.8",
+        sha1 = "9e9cf7bc4b2a60efeb5f5581fe46d17c068e0777",
+    )
+
+    maven_jar(
+        name = "icu4j",
+        artifact = "com.ibm.icu:icu4j:57.1",
+        sha1 = "198ea005f41219f038f4291f0b0e9f3259730e92",
+    )
+
+    maven_jar(
+        name = "bcprov",
+        artifact = "org.bouncycastle:bcprov-jdk15on:" + BC_VERS,
+        sha1 = "1467dac1b787b5ad2a18201c0c281df69882259e",
+    )
+
+    maven_jar(
+        name = "bcpg",
+        artifact = "org.bouncycastle:bcpg-jdk15on:" + BC_VERS,
+        sha1 = "56956a8c63ccadf62e7c678571cf86f30bd84441",
+    )
+
+    maven_jar(
+        name = "bcpkix",
+        artifact = "org.bouncycastle:bcpkix-jdk15on:" + BC_VERS,
+        sha1 = "3dac163e20110817d850d17e0444852a6d7d0bd7",
+    )
+
+    maven_jar(
+        name = "h2",
+        artifact = "com.h2database:h2:1.3.176",
+        sha1 = "fd369423346b2f1525c413e33f8cf95b09c92cbd",
+    )
+
+    maven_jar(
+        name = "fluent-hc",
+        artifact = "org.apache.httpcomponents:fluent-hc:" + HTTPCOMP_VERS,
+        sha1 = "7bfdfa49de6d720ad3c8cedb6a5238eec564dfed",
+    )
+
+    maven_jar(
+        name = "httpclient",
+        artifact = "org.apache.httpcomponents:httpclient:" + HTTPCOMP_VERS,
+        sha1 = "733db77aa8d9b2d68015189df76ab06304406e50",
+    )
+
+    maven_jar(
+        name = "httpcore",
+        artifact = "org.apache.httpcomponents:httpcore:4.4.4",
+        sha1 = "b31526a230871fbe285fbcbe2813f9c0839ae9b0",
+    )
+
+    # Test-only dependencies below.
+    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",
+    )
+
+    maven_jar(
+        name = "diffutils",
+        artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0",
+        sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
+    )
+
+    maven_jar(
+        name = "jetty-servlet",
+        artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
+        sha1 = "53ca0898f02e72b6830551031ee0062430134a05",
+    )
+
+    maven_jar(
+        name = "jetty-security",
+        artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
+        sha1 = "057a67eeb12078b620131664b3b7a37ea4c5aefe",
+    )
+
+    maven_jar(
+        name = "jetty-server",
+        artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
+        sha1 = "502f99eed028139e71a4afebefa291ace12b9c1c",
+    )
+
+    maven_jar(
+        name = "jetty-jmx",
+        artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
+        sha1 = "5e24afaedcc746f03fb0f60e6c0bdb2af6e6c9e8",
+    )
+
+    maven_jar(
+        name = "jetty-http",
+        artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
+        sha1 = "ef1e3bde212115eb4bb0740aaf79029b624d4e30",
+    )
+
+    maven_jar(
+        name = "jetty-io",
+        artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
+        sha1 = "cb33d9a3bdb6e2173b9b9cfc94c0b45f9a21a1af",
+    )
+
+    maven_jar(
+        name = "jetty-util",
+        artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
+        sha1 = "29008dbc6dfac553d209f54193b505d73c253a41",
+    )
+
+    maven_jar(
+        name = "jetty-util-ajax",
+        artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS,
+        sha1 = "3b267b5ae59b7b826d5b579f2ee8b8914b286547",
+        src_sha1 = "adba851ccfbf5b2bece305d0f0bb9179852fbffb",
+    )
+
+    maven_jar(
+        name = "asciidoctor",
+        artifact = "org.asciidoctor:asciidoctorj:1.5.7",
+        sha1 = "8e8c1d8fc6144405700dd8df3b177f2801ac5987",
+    )
+
+    maven_jar(
+        name = "javax-activation",
+        artifact = "javax.activation:activation:1.1.1",
+        sha1 = "485de3a253e23f645037828c07f1d7f1af40763a",
+    )
+
+    maven_jar(
+        name = "mockito",
+        artifact = "org.mockito:mockito-core:3.3.3",
+        sha1 = "4878395d4e63173f3825e17e5e0690e8054445f1",
+    )
+
+    maven_jar(
+        name = "bytebuddy",
+        artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
+        sha1 = "1eefb7dd1b032b33c773ca0a17d5cc9e6b56ea1a",
+    )
+
+    maven_jar(
+        name = "bytebuddy-agent",
+        artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
+        sha1 = "c472fad33f617228601172682aa64f8b78508045",
+    )
+
+    maven_jar(
+        name = "objenesis",
+        artifact = "org.objenesis:objenesis:3.0.1",
+        sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808",
+    )
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index 61ea4fe..c1d8095 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -9,7 +9,6 @@
 )
 
 TEST_DEPS = [
-    "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
     "//javatests/com/google/gerrit/server:server_tests",
 ]
 
@@ -45,12 +44,17 @@
     name = "autovalue_classpath_collect",
     deps = [
         "//lib/auto:auto-value",
+        "@auto-common//jar",
+        "@auto-factory//jar",
+        "@auto-service-annotations//jar",
         "@auto-value-annotations//jar",
         "@auto-value-gson-extension//jar",
         "@auto-value-gson-factory//jar",
         "@auto-value-gson-runtime//jar",
         "@autotransient//jar",
         "@gson//jar",
+        "@guava//jar",
         "@javapoet//jar",
+        "@javax_inject//jar",
     ],
 )
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index ef20ace..d574ecf 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -49,9 +49,7 @@
 opts.add_argument('-b', '--batch', action='store_true',
                   dest='batch', help='Bazel batch option')
 opts.add_argument('-j', '--java', action='store',
-                  dest='java', help='Legacy Java 1.8 or post Java 11')
-opts.add_argument('-e', '--edge_java', action='store',
-                  dest='edge_java', help='Post Java 11 support (14|...)')
+                  dest='java', help='Post Java 11')
 opts.add_argument('--bazel',
                   help=('name of the bazel executable. Defaults to using'
                         ' bazelisk if found, or bazel if bazelisk is not'
@@ -85,7 +83,6 @@
 
 batch_option = '--batch' if args.batch else None
 custom_java = args.java
-edge_java = args.edge_java
 bazel_exe = find_bazel()
 
 
@@ -98,13 +95,9 @@
         if arg == "build":
             build = True
         cmd.append(arg)
-    if custom_java == '1.8':
-        cmd.append('--java_toolchain=//tools:error_prone_warnings_toolchain')
-    elif custom_java and not edge_java:
+    if custom_java:
         cmd.append('--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
         cmd.append('--java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
-        if edge_java and build:
-            cmd.append(edge_java)
     return cmd
 
 
@@ -186,8 +179,6 @@
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/src')
         classpathentry('src', 'modules/jgit/org.eclipse.jgit.http.server/resources')
         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')
 
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index eb4d37a..320f8da 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -31,6 +31,7 @@
             "//plugins:.eslintrc.js",
             "//plugins:.prettierrc.js",
             "//plugins:tsconfig-plugins-base.json",
+            "@npm//typescript",
         ],
         extensions = [".ts"],
         ignore = "//plugins:.eslintignore",
@@ -39,12 +40,14 @@
             "@npm//eslint-plugin-html",
             "@npm//eslint-plugin-import",
             "@npm//eslint-plugin-jsdoc",
+            "@npm//eslint-plugin-lit",
             "@npm//eslint-plugin-prettier",
+            "@npm//eslint-plugin-regex",
             "@npm//gts",
         ],
     )
 
-def eslint(name, plugins, srcs, config, ignore, extensions = [".js"], data = []):
+def eslint(name, plugins, srcs, config, ignore, size = "large", extensions = [".js"], data = []):
     """ Macro to define eslint rules for files.
 
     Args:
@@ -53,6 +56,8 @@
         srcs: list of files to be checked (ignored in {name}_bin rule)
         config: eslint config file
         ignore: eslint ignore file
+        size: eslint test size, supported values are: small, medium, large and enormous,
+            with implied timeout labels: short, moderate, long, and eternal
         extensions: list of file extensions to be checked. This is an additional filter for
             srcs list. Each extension must start with '.' character.
             Default: [".js"].
@@ -122,6 +127,7 @@
             "local",
             "manual",
         ],
+        size = size,
     )
 
     nodejs_binary(
diff --git a/tools/js/template_checker.bzl b/tools/js/template_checker.bzl
deleted file mode 100644
index da77234..0000000
--- a/tools/js/template_checker.bzl
+++ /dev/null
@@ -1,136 +0,0 @@
-# Copyright (C) 2021 The Android Open Source Project
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-"""This file contains macro to run polymer templates check."""
-
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test", "npm_package_bin", "params_file")
-load("@rules_pkg//:pkg.bzl", "pkg_tar")
-
-def _get_generated_files(outdir, srcs):
-    result = []
-    for f in srcs:
-        result.append(outdir + "/" + f)
-    return result
-
-def _generate_transformed_templates(name, srcs, tsconfig, deps, out_tsconfig, outdir, dev_run):
-    """Generates typescript code from polymer templates. It uses twinkie package
-    for generation.
-
-    Args:
-      name: rule name
-      srcs: all files in a project project
-      tsconfig: the original typescript project file
-      deps: dependencies
-      out_tsconfig: where to store the generated TS project.
-      outdir: where to store generated .ts files
-      dev_run: if True, the generator uses different file paths in generated
-        import statements. Later, generated files can be copied into workspace
-        for future debugging\\investigation templates issues.
-
-    Returns:
-      The list of generated files
-    """
-    generated_files = _get_generated_files(outdir, srcs)
-
-    # There is a limitation on the command-line length. Put all source files
-    # into a .params file (this is a text file, where each argument is placed
-    # on a new line)
-    params_file(
-        name = name + "_params",
-        out = name + ".params",
-        args = ["$(execpath {})".format(src) for src in srcs],
-        data = srcs,
-    )
-
-    # Arguments for twinkie
-    args = [
-        "$(location //tools/node_tools:twinkie-bin)",
-        "--tsconfig $(location {})".format(tsconfig),
-        "--out-dir $(RULEDIR)/{} ".format(outdir),
-        "--files $(location {})".format(name + ".params"),
-    ]
-    if dev_run:
-        args.append("--dev-run")
-    if out_tsconfig:
-        args.append("--out-ts-config $(location {})".format(out_tsconfig))
-
-    # Execute twinkie.
-    native.genrule(
-        name = name + "_npm_bin",
-        srcs = srcs + deps + [name + ".params"],
-        outs = generated_files + ([out_tsconfig] if out_tsconfig else []),
-        cmd = " ".join(args),
-        tools = ["//tools/node_tools:twinkie-bin"],
-        # Should not run sandboxed.
-        tags = [
-            "local",
-            "manual",
-        ],
-    )
-    return generated_files
-
-def transform_polymer_templates(name, srcs, tsconfig, deps, out_tsconfig):
-    """Transforms polymer templates into typescript code.
-    Additionally, the macro defines name+"_tar" package that contains
-    generated code with slightly different import paths.
-    Note, that polygerrit template tests don't depend on the tar package, so
-    bazel doesn't generate the tar package with the bazel test command.
-    The tar package must be build explicitly with the bazel build command.
-
-    Args:
-      name: rule name
-      srcs: all files in a project project
-      tsconfig: the original typescript project file
-      deps: dependencies
-      out_tsconfig: where to store the generated TS project.
-
-    Returns:
-      list of generated files
-    """
-
-    # Transformed templates for tests
-    generated_files = _generate_transformed_templates(
-        name = name,
-        srcs = srcs,
-        tsconfig = tsconfig,
-        deps = deps,
-        out_tsconfig = out_tsconfig,
-        dev_run = False,
-        outdir = name + "_out",
-    )
-
-    # Transformed templates for developers. Only the tar package depends
-    # on it and it never runs during tests.
-    generated_dev_files = _generate_transformed_templates(
-        name = name + "_dev",
-        srcs = srcs,
-        tsconfig = tsconfig,
-        deps = deps,
-        dev_run = True,
-        outdir = name + "_dev_out",
-        out_tsconfig = None,
-    )
-
-    # Pack all transformed files. Later files can be materialized in the
-    # WORKSPACE/polygerrit-ui/app/tmpl_out dir. The following command do it
-    # automatically
-    # npm run polytest:dev
-    pkg_tar(
-        name = name + "_tar",
-        srcs = generated_dev_files,
-        # Set strip_prefix to keep directory hierarchy in the .tar
-        # https://github.com/bazelbuild/rules_pkg/issues/82
-        strip_prefix = name + "_dev_out",
-    )
-    return generated_files
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 3705407..1ebf3d0 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.5.0-SNAPSHOT</version>
+  <version>3.7.1-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
@@ -77,6 +77,9 @@
       <name>Patrick Hiesel</name>
     </developer>
     <developer>
+      <name>Patrick Mulhall</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
     <developer>
@@ -85,6 +88,9 @@
     <developer>
       <name>Tao Zhou</name>
     </developer>
+    <developer>
+      <name>Thomas Dräbing</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index ba35ca2..5d817af 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.5.0-SNAPSHOT</version>
+  <version>3.7.1-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
@@ -77,6 +77,9 @@
       <name>Patrick Hiesel</name>
     </developer>
     <developer>
+      <name>Patrick Mulhall</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
     <developer>
@@ -85,6 +88,9 @@
     <developer>
       <name>Tao Zhou</name>
     </developer>
+    <developer>
+      <name>Thomas Dräbing</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index b7954c7..bc61907c 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.5.0-SNAPSHOT</version>
+  <version>3.7.1-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
@@ -77,6 +77,9 @@
       <name>Patrick Hiesel</name>
     </developer>
     <developer>
+      <name>Patrick Mulhall</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
     <developer>
@@ -85,6 +88,9 @@
     <developer>
       <name>Tao Zhou</name>
     </developer>
+    <developer>
+      <name>Thomas Dräbing</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 118cf39..77c1134 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.5.0-SNAPSHOT</version>
+  <version>3.7.1-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
@@ -77,6 +77,9 @@
       <name>Patrick Hiesel</name>
     </developer>
     <developer>
+      <name>Patrick Mulhall</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
     <developer>
@@ -85,6 +88,9 @@
     <developer>
       <name>Tao Zhou</name>
     </developer>
+    <developer>
+      <name>Thomas Dräbing</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/package.bzl b/tools/maven/package.bzl
index b25656d..eacb02b 100644
--- a/tools/maven/package.bzl
+++ b/tools/maven/package.bzl
@@ -40,7 +40,7 @@
         src = {},
         doc = {},
         war = {}):
-    build_cmd = ["bazel_cmd", "build"]
+    build_cmd = ["bazel_cmd", "build", "--java_toolchain=//tools:error_prone_warnings_toolchain_java11"]
     mvn_cmd = ["python", "tools/maven/mvn.py", "-v", version]
     api_cmd = mvn_cmd[:]
     api_targets = []
diff --git a/tools/migration/html_to_link_commentlink.md b/tools/migration/html_to_link_commentlink.md
new file mode 100644
index 0000000..45570ac
--- /dev/null
+++ b/tools/migration/html_to_link_commentlink.md
@@ -0,0 +1,47 @@
+# Overview
+
+**Raw html substitution will no longer be an option for comment links.**
+
+The raw-html option for commentlink sections is deprecated and removed.
+Example:
+
+```
+[commentlink "issue b/"]
+  match = (^|\\s)b/(\\d+)
+  html = $1<a href=\"http://b/issue?id=$2&query=$2\" target=\"_blank\">b/$2</a>
+```
+
+Before it allowed to find and replace text matches in commit messages and
+comments with arbitrary html. When misconfigured this has in the past enabled
+injecting undesired html code and XSS attacks by writing a comment.
+
+Even though the sanitization of the resulting html has improved. This feature is
+more powerful than needed. In almost all cases across host configurations html
+is only used to either configure text of the link, or limit the link to wrap
+only a portion of the matched text.
+
+To fill the gap in functionality from deprecating the option additional optional
+parameters (prefix, suffix and text) have been added. They allow to generate
+links that look like:
+```
+  PREFIX<a href="LINK">TEXT</a>SUFFIX
+```
+With substitution being strictly plaintext and all html escaped.
+
+The comment link section in project configs (in refs/meta/config) never
+supported the raw-html option and don't need to be updated.
+
+# Config migration command
+
+```
+CONFIG_FILE=<path to gerrit.config file>
+perl -0pe 's/([ \t]*)html\s*=\s*\"(.*)<a.* href=(?:\\\"(\S+)\\\"|(\S+)(?=\s|>))(?: .*)?>(.*)<\/a>(.*)(?<!\\)\"/$1link = \"$3$4\"\n$1prefix = \"$2\"\n$1text = \"$5\"\n$1suffix = \"$6\"/g' $CONFIG_FILE |
+perl -0pe 's/([ \t]*)html\s*=\s*(\S.*)?<a.* href=(?:\\\"(\S+)\\\"|(\S+)(?=\s|>))(?: .*)?>(.*)<\/a>(.*\S)?/$1link = \"$3$4\"\n$1prefix = \"$2\"\n$1text = \"$5\"\n$1suffix = \"$6\"/g' |
+perl -ne 'print if !/\s*(prefix|suffix|text)\s*=\s*\"\"/'
+```
+
+The command does 3 simple string replace passes:
+
+1. Replace `html=<value>` with quote-escaped value.
+2. Replace `html=<value>` with value without quotes.
+3. Remove empty `prefix`, `suffix`, `text` fields.
diff --git a/tools/node_tools/BUILD b/tools/node_tools/BUILD
index 11f5d6c..b14c0c6 100644
--- a/tools/node_tools/BUILD
+++ b/tools/node_tools/BUILD
@@ -28,16 +28,18 @@
 
 # Create a tsc_wrapped compiler rule to use in the ts_library
 # compiler attribute when using self-managed dependencies
+# TODO: Would be nice to just use `tsc-bin` below instead.
+# We would prefer to not depend on @bazel/concatjs ...
 nodejs_binary(
     name = "tsc_wrapped-bin",
     # Point bazel to your node_modules to find the entry point
     data = [
-        "@tools_npm//@bazel/typescript",
+        "@tools_npm//@bazel/concatjs",
         "@tools_npm//typescript",
     ],
     # It seems, bazel uses different approaches to compile ts files (it runs some
     # ts service in background). It works without any workaround.
-    entry_point = "@tools_npm//:node_modules/@bazel/typescript/internal/tsc_wrapped/tsc_wrapped.js",
+    entry_point = "@tools_npm//:node_modules/@bazel/concatjs/internal/tsc_wrapped/tsc_wrapped.js",
 )
 
 # Wrap a typescript into a tsc-bin binary.
@@ -48,16 +50,3 @@
     data = ["@tools_npm//typescript"],
     entry_point = "@tools_npm//:node_modules/typescript/lib/tsc.js",
 )
-
-# Wrap twinkie into a twinkie-bin binary.
-nodejs_binary(
-    name = "twinkie-bin",
-    # Point bazel to your node_modules to find the entry point
-    data = ["@npm//:node_modules"],
-    entry_point = "@npm//:node_modules/twinkie/src/app/index.js",
-    # Should not run sandboxed.
-    tags = [
-        "local",
-        "manual",
-    ],
-)
diff --git a/tools/node_tools/launchpad.patch b/tools/node_tools/launchpad.patch
deleted file mode 100644
index 565494b..0000000
--- a/tools/node_tools/launchpad.patch
+++ /dev/null
@@ -1,240 +0,0 @@
-From d430b5d912bebe87529b887f408ee55c82a0e003 Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Fri, 26 Jun 2020 20:16:47 +0200
-Subject: [PATCH 1/7] Update version.js
-
----
- lib/local/version.js | 15 ++++++++++++---
- 1 file changed, 12 insertions(+), 3 deletions(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
-index 0110a74..2c02bef 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
-@@ -6,6 +6,15 @@ var plist = require('plist');
- var utils = require('./utils');
- var debug = require('debug')('launchpad:local:version');
- 
-+var validPath = function (filename){
-+  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,<>\/?~]/;
-+  if (filter.test(filename)){
-+    console.log('\nInvalid characters inside the path to the browser\n');
-+    return
-+  }
-+  return filename;
-+}
-+
- module.exports = function(browser) {
-   if (!browser || !browser.path) {
-     return Q(null);
-@@ -18,7 +27,7 @@ module.exports = function(browser) {
- 
-     debug('Retrieving version for windows executable', command);
-     // Can't use Q.nfcall here unfortunately because of non 0 exit code
--    exec(command, function(error, stdout) {
-+    exec(command.split(' ')[0], command.split(' ').slice(1), function(error, stdout) {
-       var regex = /ProductVersion:\s*(.*)/;
-       // ShowVer.exe returns a non zero status code even if it works
-       if (typeof stdout === 'string' && regex.test(stdout)) {
-@@ -47,8 +56,8 @@ module.exports = function(browser) {
-   }
- 
-   // Try executing <browser> --version (everything else)
--  return Q.nfcall(exec, browser.path + ' --version').then(function(stdout) {
--    debug('Ran ' + browser.path + ' --version', stdout);
-+  return Q.nfcall(exec, validPath(browser.path) + ' --version').then(function(stdout) {
-+    debug('Ran ' + validPath(browser.path) + ' --version', stdout);
-     var version = utils.getStdout(stdout);
-     if (version) {
-       browser.version = version;
-
-From 09ce4fab2fd53cab893ceaa3b4d7f997af9b41d8 Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Fri, 26 Jun 2020 20:18:35 +0200
-Subject: [PATCH 2/7] Update instance.js
-
----
- lib/local/instance.js | 11 +++++++++--
- 1 file changed, 9 insertions(+), 2 deletions(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/instance.js b/tools/node_tools/node_modules/launchpad/lib/g/local/instance.js
-index 484a866..b49990f 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-@@ -5,8 +5,15 @@ var EventEmitter = require('events').EventEmitter;
- var debug = require('debug')('launchpad:local:instance');
- var rimraf = require('rimraf');
- 
-+var safe = function (str) {
-+   // Avoid quotes makes impossible escape the `multi command` scenario
-+   return str.replace(/['"]+/g, '');
-+}
-+
- var getProcessId = function (name, callback) {
- 
-+  name = safe(name);
-+
-   var commands = {
-     darwin: "ps -clx | grep '" + name + "$' | awk '{print $2}' | head -1",
-     linux: "ps -ax | grep '" + name + "$' | awk '{print $2}' | head -1",
-@@ -90,11 +97,11 @@ Instance.prototype.stop = function (callback) {
-     } catch (error) {}
-   } else {
-     if (this.options.command.indexOf('open') === 0) {
--      command = 'osascript -e \'tell application "' + self.options.process + '" to quit\'';
-+      command = 'osascript -e \'tell application "' + safe(self.options.process) + '" to quit\'';
-       debug('Executing shutdown AppleScript', command);
-       exec(command);
-     } else if (process.platform === 'win32') {
--      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd));
-+      command = 'taskkill /IM "' + safe(this.options.imageName || path.basename(this.cmd)) + '"';
-       debug('Executing shutdown taskkil', command);
-       exec(command).once('exit', function(data) {
-         self.emit('stop', data);
-
-From d3993fce090ed6ef378c1f0594eff18d125dad1e Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Fri, 26 Jun 2020 20:19:17 +0200
-Subject: [PATCH 3/7] Update version.js
-
----
- lib/local/version.js | 1 +
- 1 file changed, 1 insertion(+)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
-index 2c02bef..5eac082 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
-@@ -6,6 +6,7 @@ var plist = require('plist');
- var utils = require('./utils');
- var debug = require('debug')('launchpad:local:version');
- 
-+// Validate paths supplied by the user in order to avoid "arbitrary command execution"
- var validPath = function (filename){
-   var filter = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,<>\/?~]/;
-   if (filter.test(filename)){
-
-From abf3dbcc79e6b338338594ab2dbef834550e8f65 Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Mon, 29 Jun 2020 13:32:50 +0200
-Subject: [PATCH 4/7] Update instance.js
-
----
- lib/local/instance.js | 10 +++++++---
- 1 file changed, 7 insertions(+), 3 deletions(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/instance.js b/tools/node_tools/node_modules/launchpad/lib/g/local/instance.js
-index b49990f..9375d1f 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-@@ -1,6 +1,7 @@
- var path = require('path');
- var spawn = require("child_process").spawn;
- var exec = require("child_process").exec;
-+var execFile = require("child_process").execFile;
- var EventEmitter = require('events').EventEmitter;
- var debug = require('debug')('launchpad:local:instance');
- var rimraf = require('rimraf');
-@@ -99,11 +100,14 @@ Instance.prototype.stop = function (callback) {
-     if (this.options.command.indexOf('open') === 0) {
-       command = 'osascript -e \'tell application "' + safe(self.options.process) + '" to quit\'';
-       debug('Executing shutdown AppleScript', command);
--      exec(command);
-+      command = command.split(' ');
-+      execFile(command[0], command.slice(1));
-     } else if (process.platform === 'win32') {
--      command = 'taskkill /IM "' + safe(this.options.imageName || path.basename(this.cmd)) + '"';
-+      //Adding `"` wasn't safe/functional on Win systems
-+      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd); 
-       debug('Executing shutdown taskkil', command);
--      exec(command).once('exit', function(data) {
-+      command = command.split(' ');
-+      execFile(command[0], command.slice(1)).once('exit', function(data) {
-         self.emit('stop', data);
-       });
-     } else {
-
-From 68518b274c9351f799d41ce85f23499ca4a785e9 Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Tue, 30 Jun 2020 00:01:31 +0200
-Subject: [PATCH 5/7] Update instance.js
-
----
- lib/local/instance.js | 2 +-
- 1 file changed, 1 insertion(+), 1 deletion(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/instance.js b/tools/node_tools/node_modules/launchpad/lib/g/local/instance.js
-index 9375d1f..f157dd4 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/instance.js
-@@ -104,7 +104,7 @@ Instance.prototype.stop = function (callback) {
-       execFile(command[0], command.slice(1));
-     } else if (process.platform === 'win32') {
-       //Adding `"` wasn't safe/functional on Win systems
--      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd); 
-+      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd)); 
-       debug('Executing shutdown taskkil', command);
-       command = command.split(' ');
-       execFile(command[0], command.slice(1)).once('exit', function(data) {
-
-From e711d07d40d39162ea4bdb1ed344c58f92bfa10b Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Fri, 3 Jul 2020 12:30:31 +0200
-Subject: [PATCH 6/7] Update version.js
-
----
- lib/local/version.js | 5 +++--
- 1 file changed, 3 insertions(+), 2 deletions(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
-index 5eac082..d1403a0 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
-@@ -1,5 +1,6 @@
- var fs = require('fs');
- var exec = require('child_process').exec;
-+var execFile = require('child_process').execFile;
- var Q = require('q');
- var path = require('path');
- var plist = require('plist');
-@@ -8,7 +9,7 @@ var debug = require('debug')('launchpad:local:version');
- 
- // Validate paths supplied by the user in order to avoid "arbitrary command execution"
- var validPath = function (filename){
--  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,<>\/?~]/;
-+  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"|,<>?~]/;
-   if (filter.test(filename)){
-     console.log('\nInvalid characters inside the path to the browser\n');
-     return
-@@ -28,7 +29,7 @@ module.exports = function(browser) {
- 
-     debug('Retrieving version for windows executable', command);
-     // Can't use Q.nfcall here unfortunately because of non 0 exit code
--    exec(command.split(' ')[0], command.split(' ').slice(1), function(error, stdout) {
-+    execFile(command.split(' ')[0], command.split(' ').slice(1), function(error, stdout) {
-       var regex = /ProductVersion:\s*(.*)/;
-       // ShowVer.exe returns a non zero status code even if it works
-       if (typeof stdout === 'string' && regex.test(stdout)) {
-
-From a3ff1804f0aacfb4fa20dad1312427b81280bb3e Mon Sep 17 00:00:00 2001
-From: Michele Romano <33063403+Mik317@users.noreply.github.com>
-Date: Fri, 3 Jul 2020 12:31:31 +0200
-Subject: [PATCH 7/7] Update version.js
-
----
- lib/local/version.js | 2 +-
- 1 file changed, 1 insertion(+), 1 deletion(-)
-
-diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
-index d1403a0..d937be4 100644
---- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
-+++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
-@@ -9,7 +9,7 @@ var debug = require('debug')('launchpad:local:version');
- 
- // Validate paths supplied by the user in order to avoid "arbitrary command execution"
- var validPath = function (filename){
--  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"|,<>?~]/;
-+  var filter = /[`!@#$%^&*()_+\-=\[\]{};'"|,<>?~]/;
-   if (filter.test(filename)){
-     console.log('\nInvalid characters inside the path to the browser\n');
-     return
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
index b88ec24..03e9c2b5 100644
--- a/tools/node_tools/node_modules_licenses/BUILD
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -1,17 +1,18 @@
 load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//@bazel/concatjs:index.bzl", "ts_library")
 load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 
 package(default_visibility = ["//visibility:public"])
 
+# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
+# We would prefer to not depend on @bazel/concatjs ...
 ts_library(
     name = "licenses-map",
     srcs = glob(["*.ts"]),
     compiler = "//tools/node_tools:tsc_wrapped-bin",
     tsconfig = "tsconfig.json",
     deps = [
-        "@tools_npm//@bazel/typescript",
-        "@tools_npm//@types/node",
+        "@tools_npm//:node_modules",
     ],
 )
 
@@ -27,6 +28,7 @@
     entry_point = "license-map-generator.ts",
     format = "cjs",
     rollup_bin = "//tools/node_tools:rollup-bin",
+    silent = True,
     deps = [
         ":licenses-map",
         "@tools_npm//rollup",
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 7ee64df..3765575 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,8 +3,9 @@
   "description": "Gerrit Build Tools",
   "browser": false,
   "dependencies": {
-    "@bazel/rollup": "^3.5.0",
-    "@bazel/typescript": "^3.5.0",
+    "@bazel/rollup": "^5.5.0",
+    "@bazel/typescript": "^5.5.0",
+    "@bazel/concatjs": "^5.5.0",
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
@@ -12,19 +13,19 @@
     "dom5": "^3.0.1",
     "parse5-html-rewriting-stream": "^5.1.1",
     "polymer-bundler": "^4.0.10",
-    "polymer-cli": "^1.9.11",
     "rollup": "^2.3.4",
+    "rollup-plugin-commonjs": "^10.1.0",
+    "rollup-plugin-define": "^1.0.1",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
-    "typescript": "4.3.2"
+    "typescript": "^4.7.2"
   },
   "devDependencies": {},
-  "scripts": {
-    "postinstall": "(git apply --reverse --ignore-whitespace launchpad.patch || true) && git apply --ignore-whitespace launchpad.patch"
-  },
   "license": "Apache-2.0",
   "private": true,
   "resolutions": {
-    "lodash": "4.17.21"
+    "lodash": "4.17.21",
+    "wct-local": "2.1.6",
+    "launchpad": "git+https://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be"
   }
 }
diff --git a/tools/node_tools/polygerrit_app_preprocessor/BUILD b/tools/node_tools/polygerrit_app_preprocessor/BUILD
index fa3ce56..8d39d2b 100644
--- a/tools/node_tools/polygerrit_app_preprocessor/BUILD
+++ b/tools/node_tools/polygerrit_app_preprocessor/BUILD
@@ -1,9 +1,11 @@
 load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
 load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//@bazel/concatjs:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
+# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
+# We would prefer to not depend on @bazel/concatjs ...
 ts_library(
     name = "preprocessor",
     srcs = glob(["*.ts"]),
@@ -21,6 +23,7 @@
     entry_point = "preprocessor.ts",
     format = "cjs",
     rollup_bin = "//tools/node_tools:rollup-bin",
+    silent = True,
     deps = [
         ":preprocessor",
         "@tools_npm//rollup-plugin-node-resolve",
@@ -33,6 +36,7 @@
     entry_point = "links-updater.ts",
     format = "cjs",
     rollup_bin = "//tools/node_tools:rollup-bin",
+    silent = True,
     deps = [
         ":preprocessor",
         "@tools_npm//rollup-plugin-node-resolve",
diff --git a/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts b/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts
index 24e445d..9f872cc 100644
--- a/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts
+++ b/tools/node_tools/polygerrit_app_preprocessor/links-updater.ts
@@ -39,7 +39,7 @@
     process.exit(1);
   }
 
-  const jsonRedirects: JSONRedirects = JSON.parse(fs.readFileSync(process.argv[3], {encoding: "utf-8"}));
+  const jsonRedirects: JSONRedirects = JSON.parse(fs.readFileSync(process.argv[3], {encoding: "utf-8"})) as JSONRedirects;
   const redirectsResolver = new RedirectsResolver(jsonRedirects.redirects);
 
   const input = readMultilineParamFile(process.argv[2]);
diff --git a/tools/node_tools/utils/BUILD b/tools/node_tools/utils/BUILD
index 2196012..0a6e768 100644
--- a/tools/node_tools/utils/BUILD
+++ b/tools/node_tools/utils/BUILD
@@ -1,7 +1,9 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//@bazel/concatjs:index.bzl", "ts_library")
 
 package(default_visibility = ["//visibility:public"])
 
+# TODO: Would be nice to use `ts_project` from @bazel/typescript instead.
+# We would prefer to not depend on @bazel/concatjs ...
 ts_library(
     name = "utils",
     srcs = glob(["*.ts"]),
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 6525c41..c6b3eab 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -2,667 +2,171 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.14.5", "@babel/code-frame@^7.5.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
-  integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==
+"@babel/code-frame@^7.16.7", "@babel/code-frame@^7.5.5":
+  version "7.16.7"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.7.tgz#44416b6bd7624b998f5b1af5d470856c40138789"
+  integrity sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==
   dependencies:
-    "@babel/highlight" "^7.14.5"
+    "@babel/highlight" "^7.16.7"
 
-"@babel/compat-data@^7.14.7", "@babel/compat-data@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.15.0.tgz#2dbaf8b85334796cafbb0f5793a90a2fc010b176"
-  integrity sha512-0NqAC1IJE0S0+lL1SWFMxMkz1pKCNCjI4tr2Zx4LJSXxCLAdr6KyArnY+sno5m3yH9g737ygOyPABDsnXkpxiA==
-
-"@babel/core@^7.0.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.15.0.tgz#749e57c68778b73ad8082775561f67f5196aafa8"
-  integrity sha512-tXtmTminrze5HEUPn/a0JtOzzfp0nk+UEXQ/tqIJo3WDGypl/2OFQEMll/zSFU8f/lfmfLXvTaORHF3cfXIQMw==
+"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.18.2":
+  version "7.18.2"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.2.tgz#33873d6f89b21efe2da63fe554460f3df1c5880d"
+  integrity sha512-W1lG5vUwFvfMd8HVXqdfbuG7RuaSrTCCD8cl8fP8wOivdbtbIg2Db3IWUcgvfxKbbn6ZBGYRW/Zk1MIwK49mgw==
   dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/generator" "^7.15.0"
-    "@babel/helper-compilation-targets" "^7.15.0"
-    "@babel/helper-module-transforms" "^7.15.0"
-    "@babel/helpers" "^7.14.8"
-    "@babel/parser" "^7.15.0"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
-    convert-source-map "^1.7.0"
-    debug "^4.1.0"
-    gensync "^1.0.0-beta.2"
-    json5 "^2.1.2"
-    semver "^6.3.0"
-    source-map "^0.5.0"
-
-"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.15.0.tgz#a7d0c172e0d814974bad5aa77ace543b97917f15"
-  integrity sha512-eKl4XdMrbpYvuB505KTta4AV9g+wWzmVBW69tX0H2NwKVKd2YJbKgyK6M8j/rgLbmHOYJn6rUklV677nOyJrEQ==
-  dependencies:
-    "@babel/types" "^7.15.0"
+    "@babel/types" "^7.18.2"
+    "@jridgewell/gen-mapping" "^0.3.0"
     jsesc "^2.5.1"
-    source-map "^0.5.0"
 
-"@babel/helper-annotate-as-pure@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz#7bf478ec3b71726d56a8ca5775b046fc29879e61"
-  integrity sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==
+"@babel/helper-environment-visitor@^7.18.2":
+  version "7.18.2"
+  resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.2.tgz#8a6d2dedb53f6bf248e31b4baf38739ee4a637bd"
+  integrity sha512-14GQKWkX9oJzPiQQ7/J36FTXcD4kSp8egKjO9nINlSKiHITRA9q/R74qu8S9xlc/b/yjsJItQUeeh3xnGN0voQ==
+
+"@babel/helper-function-name@^7.17.9":
+  version "7.17.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz#136fcd54bc1da82fcb47565cf16fd8e444b1ff12"
+  integrity sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==
   dependencies:
-    "@babel/types" "^7.14.5"
+    "@babel/template" "^7.16.7"
+    "@babel/types" "^7.17.0"
 
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.14.5.tgz#b939b43f8c37765443a19ae74ad8b15978e0a191"
-  integrity sha512-YTA/Twn0vBXDVGJuAX6PwW7x5zQei1luDDo2Pl6q1qZ7hVNl0RZrhHCQG/ArGpR29Vl7ETiB8eJyrvpuRp300w==
+"@babel/helper-hoist-variables@^7.16.7":
+  version "7.16.7"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz#86bcb19a77a509c7b77d0e22323ef588fa58c246"
+  integrity sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==
   dependencies:
-    "@babel/helper-explode-assignable-expression" "^7.14.5"
-    "@babel/types" "^7.14.5"
+    "@babel/types" "^7.16.7"
 
-"@babel/helper-compilation-targets@^7.14.5", "@babel/helper-compilation-targets@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.15.0.tgz#973df8cbd025515f3ff25db0c05efc704fa79818"
-  integrity sha512-h+/9t0ncd4jfZ8wsdAsoIxSa61qhBYlycXiHWqJaQBCXAhDCMbPRSMTGnZIkkmt1u4ag+UQmuqcILwqKzZ4N2A==
+"@babel/helper-split-export-declaration@^7.16.7":
+  version "7.16.7"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz#0b648c0c42da9d3920d85ad585f2778620b8726b"
+  integrity sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==
   dependencies:
-    "@babel/compat-data" "^7.15.0"
-    "@babel/helper-validator-option" "^7.14.5"
-    browserslist "^4.16.6"
-    semver "^6.3.0"
+    "@babel/types" "^7.16.7"
 
-"@babel/helper-create-regexp-features-plugin@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.14.5.tgz#c7d5ac5e9cf621c26057722fb7a8a4c5889358c4"
-  integrity sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A==
+"@babel/helper-validator-identifier@^7.16.7":
+  version "7.16.7"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad"
+  integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==
+
+"@babel/highlight@^7.16.7":
+  version "7.17.12"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.17.12.tgz#257de56ee5afbd20451ac0a75686b6b404257351"
+  integrity sha512-7yykMVF3hfZY2jsHZEEgLc+3x4o1O+fYyULu11GynEUQNwB6lua+IIQn1FiJxNucd5UlyJryrwsOh8PL9Sn8Qg==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    regexpu-core "^4.7.1"
-
-"@babel/helper-explode-assignable-expression@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.14.5.tgz#8aa72e708205c7bb643e45c73b4386cdf2a1f645"
-  integrity sha512-Htb24gnGJdIGT4vnRKMdoXiOIlqOLmdiUYpAQ0mYfgVT/GDm8GOYhgi4GL+hMKrkiPRohO4ts34ELFsGAPQLDQ==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-function-name@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz#89e2c474972f15d8e233b52ee8c480e2cfcd50c4"
-  integrity sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==
-  dependencies:
-    "@babel/helper-get-function-arity" "^7.14.5"
-    "@babel/template" "^7.14.5"
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-get-function-arity@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815"
-  integrity sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-hoist-variables@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d"
-  integrity sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-member-expression-to-functions@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.15.0.tgz#0ddaf5299c8179f27f37327936553e9bba60990b"
-  integrity sha512-Jq8H8U2kYiafuj2xMTPQwkTBnEEdGKpT35lJEQsRRjnG0LW3neucsaMWLgKcwu3OHKNeYugfw+Z20BXBSEs2Lg==
-  dependencies:
-    "@babel/types" "^7.15.0"
-
-"@babel/helper-module-imports@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3"
-  integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-module-transforms@^7.14.5", "@babel/helper-module-transforms@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.15.0.tgz#679275581ea056373eddbe360e1419ef23783b08"
-  integrity sha512-RkGiW5Rer7fpXv9m1B3iHIFDZdItnO2/BLfWVW/9q7+KqQSDY5kUfQEbzdXM1MVhJGcugKV7kRrNVzNxmk7NBg==
-  dependencies:
-    "@babel/helper-module-imports" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.15.0"
-    "@babel/helper-simple-access" "^7.14.8"
-    "@babel/helper-split-export-declaration" "^7.14.5"
-    "@babel/helper-validator-identifier" "^7.14.9"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
-
-"@babel/helper-optimise-call-expression@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz#f27395a8619e0665b3f0364cddb41c25d71b499c"
-  integrity sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.8.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9"
-  integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==
-
-"@babel/helper-remap-async-to-generator@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.14.5.tgz#51439c913612958f54a987a4ffc9ee587a2045d6"
-  integrity sha512-rLQKdQU+HYlxBwQIj8dk4/0ENOUEhA/Z0l4hN8BexpvmSMN9oA9EagjnhnDpNsRdWCfjwa4mn/HyBXO9yhQP6A==
-  dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-wrap-function" "^7.14.5"
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-replace-supers@^7.14.5", "@babel/helper-replace-supers@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.15.0.tgz#ace07708f5bf746bf2e6ba99572cce79b5d4e7f4"
-  integrity sha512-6O+eWrhx+HEra/uJnifCwhwMd6Bp5+ZfZeJwbqUTuqkhIT6YcRhiZCOOFChRypOIe0cV46kFrRBlm+t5vHCEaA==
-  dependencies:
-    "@babel/helper-member-expression-to-functions" "^7.15.0"
-    "@babel/helper-optimise-call-expression" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
-
-"@babel/helper-simple-access@^7.14.8":
-  version "7.14.8"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.14.8.tgz#82e1fec0644a7e775c74d305f212c39f8fe73924"
-  integrity sha512-TrFN4RHh9gnWEU+s7JloIho2T76GPwRHhdzOWLqTrMnlas8T9O7ec+oEDNsRXndOmru9ymH9DFrEOxpzPoSbdg==
-  dependencies:
-    "@babel/types" "^7.14.8"
-
-"@babel/helper-skip-transparent-expression-wrappers@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.14.5.tgz#96f486ac050ca9f44b009fbe5b7d394cab3a0ee4"
-  integrity sha512-dmqZB7mrb94PZSAOYtr+ZN5qt5owZIAgqtoTuqiFbHFtxgEcmQlRJVI+bO++fciBunXtB6MK7HrzrfcAzIz2NQ==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-split-export-declaration@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz#22b23a54ef51c2b7605d851930c1976dd0bc693a"
-  integrity sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==
-  dependencies:
-    "@babel/types" "^7.14.5"
-
-"@babel/helper-validator-identifier@^7.14.5", "@babel/helper-validator-identifier@^7.14.9":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48"
-  integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==
-
-"@babel/helper-validator-option@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3"
-  integrity sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==
-
-"@babel/helper-wrap-function@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.14.5.tgz#5919d115bf0fe328b8a5d63bcb610f51601f2bff"
-  integrity sha512-YEdjTCq+LNuNS1WfxsDCNpgXkJaIyqco6DAelTUjT4f2KIWC1nBcaCaSdHTBqQVLnTBexBcVcFhLSU1KnYuePQ==
-  dependencies:
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.14.5"
-    "@babel/types" "^7.14.5"
-
-"@babel/helpers@^7.14.8":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.15.3.tgz#c96838b752b95dcd525b4e741ed40bb1dc2a1357"
-  integrity sha512-HwJiz52XaS96lX+28Tnbu31VeFSQJGOeKHJeaEPQlTl7PnlhFElWPj8tUXtqFIzeN86XxXoBr+WFAyK2PPVz6g==
-  dependencies:
-    "@babel/template" "^7.14.5"
-    "@babel/traverse" "^7.15.0"
-    "@babel/types" "^7.15.0"
-
-"@babel/highlight@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9"
-  integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==
-  dependencies:
-    "@babel/helper-validator-identifier" "^7.14.5"
+    "@babel/helper-validator-identifier" "^7.16.7"
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.14.5", "@babel/parser@^7.15.0":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.3.tgz#3416d9bea748052cfcb63dbcc27368105b1ed862"
-  integrity sha512-O0L6v/HvqbdJawj0iBEfVQMc3/6WP+AeOsovsIgBFyJaG+W2w7eqvZB7puddATmWuARlm1SX7DwxJ/JJUnDpEA==
+"@babel/parser@^7.16.7", "@babel/parser@^7.18.0":
+  version "7.18.4"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.4.tgz#6774231779dd700e0af29f6ad8d479582d7ce5ef"
+  integrity sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow==
 
-"@babel/plugin-external-helpers@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-external-helpers/-/plugin-external-helpers-7.14.5.tgz#920baa1569a8df5d5710abc342c7b1ac8968ed76"
-  integrity sha512-q/B/hLX+nDGk73Xn529d7Ar4ih17J8pNBbsXafq8oXij0XfFEA/bks+u+6q5q04zO5o/qivjzui6BqzPfYShEg==
+"@babel/template@^7.16.7":
+  version "7.16.7"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155"
+  integrity sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/code-frame" "^7.16.7"
+    "@babel/parser" "^7.16.7"
+    "@babel/types" "^7.16.7"
 
-"@babel/plugin-proposal-async-generator-functions@^7.0.0":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.14.9.tgz#7028dc4fa21dc199bbacf98b39bab1267d0eaf9a"
-  integrity sha512-d1lnh+ZnKrFKwtTYdw320+sQWCTwgkB9fmUhNXRADA4akR6wLjaruSGnIEUjpt9HCOwTr4ynFTKu19b7rFRpmw==
+"@babel/traverse@^7.0.0-beta.42":
+  version "7.18.2"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.2.tgz#b77a52604b5cc836a9e1e08dca01cba67a12d2e8"
+  integrity sha512-9eNwoeovJ6KH9zcCNnENY7DMFwTU9JdGCFtqNLfUAqtUHRCOsTOqWoffosP8vKmNYeSBUv3yVJXjfd8ucwOjUA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-remap-async-to-generator" "^7.14.5"
-    "@babel/plugin-syntax-async-generators" "^7.8.4"
-
-"@babel/plugin-proposal-object-rest-spread@^7.0.0":
-  version "7.14.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.14.7.tgz#5920a2b3df7f7901df0205974c0641b13fd9d363"
-  integrity sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==
-  dependencies:
-    "@babel/compat-data" "^7.14.7"
-    "@babel/helper-compilation-targets" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
-    "@babel/plugin-transform-parameters" "^7.14.5"
-
-"@babel/plugin-syntax-async-generators@^7.0.0", "@babel/plugin-syntax-async-generators@^7.8.4":
-  version "7.8.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d"
-  integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.8.0"
-
-"@babel/plugin-syntax-dynamic-import@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3"
-  integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.8.0"
-
-"@babel/plugin-syntax-import-meta@^7.0.0":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51"
-  integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-
-"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871"
-  integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.8.0"
-
-"@babel/plugin-transform-arrow-functions@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.14.5.tgz#f7187d9588a768dd080bf4c9ffe117ea62f7862a"
-  integrity sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-async-to-generator@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.14.5.tgz#72c789084d8f2094acb945633943ef8443d39e67"
-  integrity sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA==
-  dependencies:
-    "@babel/helper-module-imports" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-remap-async-to-generator" "^7.14.5"
-
-"@babel/plugin-transform-block-scoped-functions@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.14.5.tgz#e48641d999d4bc157a67ef336aeb54bc44fd3ad4"
-  integrity sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-block-scoping@^7.0.0":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.15.3.tgz#94c81a6e2fc230bcce6ef537ac96a1e4d2b3afaf"
-  integrity sha512-nBAzfZwZb4DkaGtOes1Up1nOAp9TDRRFw4XBzBBSG9QK7KVFmYzgj9o9sbPv7TX5ofL4Auq4wZnxCoPnI/lz2Q==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-classes@^7.0.0":
-  version "7.14.9"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.14.9.tgz#2a391ffb1e5292710b00f2e2c210e1435e7d449f"
-  integrity sha512-NfZpTcxU3foGWbl4wxmZ35mTsYJy8oQocbeIMoDAGGFarAmSQlL+LWMkDx/tj6pNotpbX3rltIA4dprgAPOq5A==
-  dependencies:
-    "@babel/helper-annotate-as-pure" "^7.14.5"
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-optimise-call-expression" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.14.5"
-    "@babel/helper-split-export-declaration" "^7.14.5"
-    globals "^11.1.0"
-
-"@babel/plugin-transform-computed-properties@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.14.5.tgz#1b9d78987420d11223d41195461cc43b974b204f"
-  integrity sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-destructuring@^7.0.0":
-  version "7.14.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.14.7.tgz#0ad58ed37e23e22084d109f185260835e5557576"
-  integrity sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-duplicate-keys@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.14.5.tgz#365a4844881bdf1501e3a9f0270e7f0f91177954"
-  integrity sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-exponentiation-operator@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.14.5.tgz#5154b8dd6a3dfe6d90923d61724bd3deeb90b493"
-  integrity sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==
-  dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-for-of@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.14.5.tgz#dae384613de8f77c196a8869cbf602a44f7fc0eb"
-  integrity sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-function-name@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.14.5.tgz#e81c65ecb900746d7f31802f6bed1f52d915d6f2"
-  integrity sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ==
-  dependencies:
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-instanceof@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-instanceof/-/plugin-transform-instanceof-7.14.5.tgz#8568277fbcfd7a3e4f3e6c8b7aa8ce4f60cba6e7"
-  integrity sha512-3CIpRzBLk5tEwIzjjD86KR8oMYrp1fl9q7kbdJa6O6Lcmkcee9DXfeO6zRXis//5gWRf63o5oDlNBh0VAlmtgw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-literals@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.14.5.tgz#41d06c7ff5d4d09e3cf4587bd3ecf3930c730f78"
-  integrity sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-modules-amd@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.14.5.tgz#4fd9ce7e3411cb8b83848480b7041d83004858f7"
-  integrity sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g==
-  dependencies:
-    "@babel/helper-module-transforms" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-    babel-plugin-dynamic-import-node "^2.3.3"
-
-"@babel/plugin-transform-object-super@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.14.5.tgz#d0b5faeac9e98597a161a9cf78c527ed934cdc45"
-  integrity sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-replace-supers" "^7.14.5"
-
-"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.14.5.tgz#49662e86a1f3ddccac6363a7dfb1ff0a158afeb3"
-  integrity sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-regenerator@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.14.5.tgz#9676fd5707ed28f522727c5b3c0aa8544440b04f"
-  integrity sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg==
-  dependencies:
-    regenerator-transform "^0.14.2"
-
-"@babel/plugin-transform-shorthand-properties@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.14.5.tgz#97f13855f1409338d8cadcbaca670ad79e091a58"
-  integrity sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-spread@^7.0.0":
-  version "7.14.6"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.14.6.tgz#6bd40e57fe7de94aa904851963b5616652f73144"
-  integrity sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
-
-"@babel/plugin-transform-sticky-regex@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.14.5.tgz#5b617542675e8b7761294381f3c28c633f40aeb9"
-  integrity sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-template-literals@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.14.5.tgz#a5f2bc233937d8453885dc736bdd8d9ffabf3d93"
-  integrity sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-typeof-symbol@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.14.5.tgz#39af2739e989a2bd291bf6b53f16981423d457d4"
-  integrity sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/plugin-transform-unicode-regex@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.14.5.tgz#4cd09b6c8425dd81255c7ceb3fb1836e7414382e"
-  integrity sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw==
-  dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
-    "@babel/helper-plugin-utils" "^7.14.5"
-
-"@babel/runtime@^7.8.4":
-  version "7.15.3"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.3.tgz#2e1c2880ca118e5b2f9988322bd8a7656a32502b"
-  integrity sha512-OvwMLqNXkCXSz1kSm58sEsNuhqOx/fKpnUnKnFB5v8uDda5bLNEHNgKPvhDN6IU0LDcnHQ90LlJ0Q6jnyBSIBA==
-  dependencies:
-    regenerator-runtime "^0.13.4"
-
-"@babel/template@^7.14.5":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4"
-  integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==
-  dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/parser" "^7.14.5"
-    "@babel/types" "^7.14.5"
-
-"@babel/traverse@^7.0.0", "@babel/traverse@^7.0.0-beta.42", "@babel/traverse@^7.14.5", "@babel/traverse@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.15.0.tgz#4cca838fd1b2a03283c1f38e141f639d60b3fc98"
-  integrity sha512-392d8BN0C9eVxVWd8H6x9WfipgVH5IaIoLp23334Sc1vbKKWINnvwRpb4us0xtPaCumlwbTtIYNA0Dv/32sVFw==
-  dependencies:
-    "@babel/code-frame" "^7.14.5"
-    "@babel/generator" "^7.15.0"
-    "@babel/helper-function-name" "^7.14.5"
-    "@babel/helper-hoist-variables" "^7.14.5"
-    "@babel/helper-split-export-declaration" "^7.14.5"
-    "@babel/parser" "^7.15.0"
-    "@babel/types" "^7.15.0"
+    "@babel/code-frame" "^7.16.7"
+    "@babel/generator" "^7.18.2"
+    "@babel/helper-environment-visitor" "^7.18.2"
+    "@babel/helper-function-name" "^7.17.9"
+    "@babel/helper-hoist-variables" "^7.16.7"
+    "@babel/helper-split-export-declaration" "^7.16.7"
+    "@babel/parser" "^7.18.0"
+    "@babel/types" "^7.18.2"
     debug "^4.1.0"
     globals "^11.1.0"
 
-"@babel/types@^7.0.0-beta.42", "@babel/types@^7.14.5", "@babel/types@^7.14.8", "@babel/types@^7.15.0":
-  version "7.15.0"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.0.tgz#61af11f2286c4e9c69ca8deb5f4375a73c72dcbd"
-  integrity sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ==
+"@babel/types@^7.0.0-beta.42", "@babel/types@^7.16.7", "@babel/types@^7.17.0", "@babel/types@^7.18.2":
+  version "7.18.4"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.4.tgz#27eae9b9fd18e9dccc3f9d6ad051336f307be354"
+  integrity sha512-ThN1mBcMq5pG/Vm2IcBmPPfyPXbd8S02rS+OBIDENdufvqC7Z/jHPCv9IcP01277aKtDI8g/2XysBN4hA8niiw==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.14.9"
+    "@babel/helper-validator-identifier" "^7.16.7"
     to-fast-properties "^2.0.0"
 
-"@bazel/rollup@^3.5.0":
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.8.0.tgz#850f56176d73e3b7d99a43c7e33df21ecc6ac161"
-  integrity sha512-u63ubqYtfQhOu8Km3uYdhKa6qiLSlOKYsWwMP1xGkkXzu1hOiUznN1N7q8gCF1BV2DMy1D5IYkv+Xg4a+LEiBA==
-
-"@bazel/typescript@^3.5.0":
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.8.0.tgz#725d51a1c25e314a1d8cddb8b880ac05ba97acd4"
-  integrity sha512-4C1pLe4V7aidWqcPsWNqXFS7uHAB1nH5SUKG5uWoVv4JT9XhkNSvzzQIycMwXs2tZeCylX4KYNeNvfKrmkyFlw==
+"@bazel/concatjs@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.5.0.tgz#e6104ed70595cae59463ae6b0b5389252566221e"
+  integrity sha512-hwG+ahivR20Z3iTOlkUz3OdwnW/PUaZyyz8BIX+GNUTg6U3rPHK51CavUirMupOU/LRJ5GyCwBNAAtjCyquo2g==
   dependencies:
     protobufjs "6.8.8"
+    source-map-support "0.5.9"
+    tsutils "3.21.0"
+
+"@bazel/rollup@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.5.0.tgz#1e152d6147ef5583ec9fd872756c9d0635db73c7"
+  integrity sha512-8SRbgVfaYdNb6PyIypj8jzzJHhlIRyMH3s5KpXODsjD+mXECH4jQxJ8VcRkt0f0exsgB12gK5dmoUK/F2PDKCw==
+  dependencies:
+    "@bazel/worker" "5.5.0"
+
+"@bazel/typescript@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.5.0.tgz#053c255acb1b3cac23d24984cd8d5d5542fe1f7c"
+  integrity sha512-Ord0+nCj+B1M4NDbe0uqZf2FyOCzaDAlc4DAsr5UKJrArCipIbMTEAxlsEk+WAYBNAFGO/FS9/zlDtLceqpHqw==
+  dependencies:
+    "@bazel/worker" "5.5.0"
+    protobufjs "6.8.8"
     semver "5.6.0"
     source-map-support "0.5.9"
-    tsutils "2.27.2"
+    tsutils "3.21.0"
 
-"@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==
+"@bazel/worker@5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.5.0.tgz#d30b75e46f2052d33bcda251b328d36655a5636f"
+  integrity sha512-pYfjJKg4D1CQ/AJ1UGC5ySyH09gDqNiBrQJ0uMYVewIBW24uOAkKsJfTE2y4ES0UL1Ik758WO0la0mJeFOKScg==
   dependencies:
-    colorspace "1.1.x"
-    enabled "2.0.x"
-    kuler "^2.0.0"
+    google-protobuf "^3.6.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"
-  integrity sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==
+"@jridgewell/gen-mapping@^0.3.0":
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.1.tgz#cf92a983c83466b8c0ce9124fadeaf09f7c66ea9"
+  integrity sha512-GcHwniMlA2z+WFPWuY8lp3fsza0I8xPFMWL5+n8LYyP6PSvPrXf4+n8stDHZY2DM0zy9sVkRDy1jDI4XGzYVqg==
   dependencies:
-    call-me-maybe "^1.0.1"
-    glob-to-regexp "^0.3.0"
+    "@jridgewell/set-array" "^1.0.0"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
+    "@jridgewell/trace-mapping" "^0.3.9"
 
-"@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==
+"@jridgewell/resolve-uri@^3.0.3":
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.7.tgz#30cd49820a962aff48c8fffc5cd760151fca61fe"
+  integrity sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA==
 
-"@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==
+"@jridgewell/set-array@^1.0.0":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.1.tgz#36a6acc93987adcf0ba50c66908bd0b70de8afea"
+  integrity sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ==
+
+"@jridgewell/sourcemap-codec@^1.4.10":
+  version "1.4.13"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz#b6461fb0c2964356c469e115f504c95ad97ab88c"
+  integrity sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==
+
+"@jridgewell/trace-mapping@^0.3.9":
+  version "0.3.13"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz#dcfe3e95f224c8fe97a87a5235defec999aa92ea"
+  integrity sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w==
   dependencies:
-    "@octokit/types" "^6.0.3"
-
-"@octokit/endpoint@^6.0.1":
-  version "6.0.12"
-  resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.12.tgz#3b4d47a4b0e79b1027fb8d75d4221928b2d05658"
-  integrity sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==
-  dependencies:
-    "@octokit/types" "^6.0.3"
-    is-plain-object "^5.0.0"
-    universal-user-agent "^6.0.0"
-
-"@octokit/openapi-types@^10.0.0":
-  version "10.0.0"
-  resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-10.0.0.tgz#db4335de99509021f501fc4e026e6ff495fe1e62"
-  integrity sha512-k1iO2zKuEjjRS1EJb4FwSLk+iF6EGp+ZV0OMRViQoWhQ1fZTk9hg1xccZII5uyYoiqcbC73MRBmT45y1vp2PPg==
-
-"@octokit/plugin-paginate-rest@^1.1.1":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-1.1.2.tgz#004170acf8c2be535aba26727867d692f7b488fc"
-  integrity sha512-jbsSoi5Q1pj63sC16XIUboklNw+8tL9VOnJsWycWYR78TKss5PVpIPb1TUUcMQ+bBh7cY579cVAWmf5qG+dw+Q==
-  dependencies:
-    "@octokit/types" "^2.0.1"
-
-"@octokit/plugin-request-log@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85"
-  integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==
-
-"@octokit/plugin-rest-endpoint-methods@2.4.0":
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-2.4.0.tgz#3288ecf5481f68c494dd0602fc15407a59faf61e"
-  integrity sha512-EZi/AWhtkdfAYi01obpX0DF7U6b1VRr30QNQ5xSFPITMdLSfhcBqjamE3F+sKcxPbD7eZuMHu3Qkk2V+JGxBDQ==
-  dependencies:
-    "@octokit/types" "^2.0.1"
-    deprecation "^2.3.1"
-
-"@octokit/request-error@^1.0.2":
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-1.2.1.tgz#ede0714c773f32347576c25649dc013ae6b31801"
-  integrity sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA==
-  dependencies:
-    "@octokit/types" "^2.0.0"
-    deprecation "^2.0.0"
-    once "^1.4.0"
-
-"@octokit/request-error@^2.1.0":
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.1.0.tgz#9e150357831bfc788d13a4fd4b1913d60c74d677"
-  integrity sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==
-  dependencies:
-    "@octokit/types" "^6.0.3"
-    deprecation "^2.0.0"
-    once "^1.4.0"
-
-"@octokit/request@^5.2.0":
-  version "5.6.1"
-  resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.6.1.tgz#f97aff075c37ab1d427c49082fefeef0dba2d8ce"
-  integrity sha512-Ls2cfs1OfXaOKzkcxnqw5MR6drMA/zWX/LIS/p8Yjdz7QKTPQLMsB3R+OvoxE6XnXeXEE2X7xe4G4l4X0gRiKQ==
-  dependencies:
-    "@octokit/endpoint" "^6.0.1"
-    "@octokit/request-error" "^2.1.0"
-    "@octokit/types" "^6.16.1"
-    is-plain-object "^5.0.0"
-    node-fetch "^2.6.1"
-    universal-user-agent "^6.0.0"
-
-"@octokit/rest@^16.2.0":
-  version "16.43.2"
-  resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.43.2.tgz#c53426f1e1d1044dee967023e3279c50993dd91b"
-  integrity sha512-ngDBevLbBTFfrHZeiS7SAMAZ6ssuVmXuya+F/7RaVvlysgGa1JKJkKWY+jV6TCJYcW0OALfJ7nTIGXcBXzycfQ==
-  dependencies:
-    "@octokit/auth-token" "^2.4.0"
-    "@octokit/plugin-paginate-rest" "^1.1.1"
-    "@octokit/plugin-request-log" "^1.0.0"
-    "@octokit/plugin-rest-endpoint-methods" "2.4.0"
-    "@octokit/request" "^5.2.0"
-    "@octokit/request-error" "^1.0.2"
-    atob-lite "^2.0.0"
-    before-after-hook "^2.0.0"
-    btoa-lite "^1.0.0"
-    deprecation "^2.0.0"
-    lodash.get "^4.4.2"
-    lodash.set "^4.3.2"
-    lodash.uniq "^4.5.0"
-    octokit-pagination-methods "^1.1.0"
-    once "^1.4.0"
-    universal-user-agent "^4.0.0"
-
-"@octokit/types@^2.0.0", "@octokit/types@^2.0.1":
-  version "2.16.2"
-  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-2.16.2.tgz#4c5f8da3c6fecf3da1811aef678fda03edac35d2"
-  integrity sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==
-  dependencies:
-    "@types/node" ">= 8"
-
-"@octokit/types@^6.0.3", "@octokit/types@^6.16.1":
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.26.0.tgz#b8af298485d064ad9424cb41520541c1bf820346"
-  integrity sha512-RDxZBAFMtqs1ZPnbUu1e7ohPNfoNhTiep4fErY7tZs995BeHu369Vsh5woMIaFbllRWEZBfvTCS4hvDnMPiHrA==
-  dependencies:
-    "@octokit/openapi-types" "^10.0.0"
-
-"@polymer/esm-amd-loader@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@polymer/esm-amd-loader/-/esm-amd-loader-1.0.4.tgz#4e77f2f59b29b01e0ad02aa83d33716cddc5f9f9"
-  integrity sha512-h+hqYkL+tQV/y2ESD5gFXMl5z4cC+XY1jTlBeGSBaTcj3VbB5OBEScbvRXm63NcEbBneQQYbHfBAXAkF9i9wIA==
-
-"@polymer/sinonjs@^1.14.1":
-  version "1.17.1"
-  resolved "https://registry.yarnpkg.com/@polymer/sinonjs/-/sinonjs-1.17.1.tgz#e47d3785b7d0e8c29feb97f7e924b0fc597e2e9b"
-  integrity sha512-/U8F/cOTrbF2iVVYgINYmvKbtbexs+89Q3v8AaHADRYabTg7aOZGOb0RyWpOI+sUJt04kj63U4FwMhzW5r4wZA==
-
-"@polymer/test-fixture@^0.0.3":
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-0.0.3.tgz#4443752697d4d9293bbc412ea0b5e4d341f149d9"
-  integrity sha1-REN1JpfU2Sk7vEEuoLXk00HxSdk=
+    "@jridgewell/resolve-uri" "^3.0.3"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
 
 "@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"
-  integrity sha1-m4sMxmPWaafY9vXQiToU00jzD78=
+  integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==
 
 "@protobufjs/base64@^1.1.2":
   version "1.1.2"
@@ -677,12 +181,12 @@
 "@protobufjs/eventemitter@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70"
-  integrity sha1-NVy8mLr61ZePntCV85diHx0Ga3A=
+  integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==
 
 "@protobufjs/fetch@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45"
-  integrity sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=
+  integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==
   dependencies:
     "@protobufjs/aspromise" "^1.1.1"
     "@protobufjs/inquire" "^1.1.0"
@@ -690,32 +194,40 @@
 "@protobufjs/float@^1.0.2":
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1"
-  integrity sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=
+  integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==
 
 "@protobufjs/inquire@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089"
-  integrity sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=
+  integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==
 
 "@protobufjs/path@^1.1.2":
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d"
-  integrity sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=
+  integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==
 
 "@protobufjs/pool@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54"
-  integrity sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=
+  integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==
 
 "@protobufjs/utf8@^1.1.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
-  integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
+  integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
+
+"@rollup/pluginutils@^4.0.0":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d"
+  integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==
+  dependencies:
+    estree-walker "^2.0.1"
+    picomatch "^2.2.2"
 
 "@sindresorhus/is@^4.0.0":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.0.1.tgz#d26729db850fa327b7cacc5522252194404226f5"
-  integrity sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f"
+  integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==
 
 "@szmarczak/http-timer@^4.0.5":
   version "4.0.6"
@@ -725,9 +237,9 @@
     defer-to-connect "^2.0.0"
 
 "@types/babel-generator@^6.25.1":
-  version "6.25.4"
-  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.4.tgz#74eacdaa4822c4c6923e68c541144a04415ad8a1"
-  integrity sha512-Rnsen+ckop5mbl9d43bempS7i9wdTN1vytiTlmQla/YiNm6kH8kEVABVSXmp1UbnpkUV44nUCPeDQoa+Mu7ALA==
+  version "6.25.5"
+  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.5.tgz#b02723fd589349b05524376e5530228d3675d878"
+  integrity sha512-lhbwMlAy5rfWG+R6l8aPtJdEFX/kcv6LMFIuvUb0i89ehqgD24je9YcB+0fRspQhgJGlEsUImxpw4pQeKS/+8Q==
   dependencies:
     "@types/babel-types" "*"
 
@@ -755,15 +267,10 @@
   dependencies:
     "@types/babel-types" "*"
 
-"@types/bluebird@*":
-  version "3.5.36"
-  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.36.tgz#00d9301d4dc35c2f6465a8aec634bb533674c652"
-  integrity sha512-HBNx4lhkxN7bx6P0++W8E289foSu8kO8GCk2unhuVggO+cE7rh9DhZUyPhUxNRG9m+5B5BTKxZQ5ZP92x/mx9Q==
-
 "@types/body-parser@*":
-  version "1.19.1"
-  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.1.tgz#0c0174c42a7d017b818303d4b5d969cb0b75929c"
-  integrity sha512-a6bTJ21vFOGIkwM0kzh9Yr89ziVxq4vYH2fQ6N8AeipEzai/cFK6aGMArIkUeIdRIgpwQa+2bXiLuUJCpSf2Cg==
+  version "1.19.2"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
+  integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==
   dependencies:
     "@types/connect" "*"
     "@types/node" "*"
@@ -786,41 +293,19 @@
     "@types/chai" "*"
 
 "@types/chai@*":
-  version "4.2.21"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.21.tgz#9f35a5643129df132cf3b5c1ec64046ea1af0650"
-  integrity sha512-yd+9qKmJxm496BOV9CMNaey8TWsikaZOwMRwPHQIjcOJM9oV+fi9ZMNw3JsVnbEEbo2gRTDnGEBv8pjyn67hNg==
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.1.tgz#e2c6e73e0bdeb2521d00756d099218e9f5d90a04"
+  integrity sha512-/zPMqDkzSZ8t3VtxOa4KPq7uzzW978M9Tvh+j7GHKuo6k6GTLxPJ4J5gE5cjfJ26pnXst0N5Hax8Sr0T2Mi9zQ==
 
 "@types/chalk@^0.4.30":
   version "0.4.31"
   resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-0.4.31.tgz#a31d74241a6b1edbb973cf36d97a2896834a51f9"
-  integrity sha1-ox10JBprHtu5c8822XooloNKUfk=
-
-"@types/chalk@^2.2.0":
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-2.2.0.tgz#b7f6e446f4511029ee8e3f43075fb5b73fbaa0ba"
-  integrity sha512-1zzPV9FDe1I/WHhRkf9SNgqtRJWZqrBWgu7JGveuHmmyR9CnAPCie2N/x+iHrgnpYBIcCJWHBoMRv2TRWktsvw==
-  dependencies:
-    chalk "*"
-
-"@types/clean-css@*":
-  version "4.2.5"
-  resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.5.tgz#69ce62cc13557c90ca40460133f672dc52ceaf89"
-  integrity sha512-NEzjkGGpbs9S9fgC4abuBvTpVwE3i+Acu9BBod3PUyjDVZcNsGx61b8r2PphR61QGPnn0JHVs5ey6/I4eTrkxw==
-  dependencies:
-    "@types/node" "*"
-    source-map "^0.6.0"
+  integrity sha512-nF0fisEPYMIyfrFgabFimsz9Lnuu9MwkNrrlATm2E4E46afKDyeelT+8bXfw1VSc7sLBxMxRgT7PxTC2JcqN4Q==
 
 "@types/clone@^0.1.29", "@types/clone@^0.1.30":
   version "0.1.30"
   resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
-  integrity sha1-5zZWSMG0ITalnH1QQGN7O1yDthQ=
-
-"@types/compression@^0.0.33":
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/@types/compression/-/compression-0.0.33.tgz#95dc733a2339aa846381d7f1377792d2553dc27d"
-  integrity sha1-ldxzOiM5qoRjgdfxN3eS0lU9wn0=
-  dependencies:
-    "@types/express" "*"
+  integrity sha512-vcxBr+ybljeSiasmdke1cQ9ICxoEwaBgM1OQ/P5h4MPj/kRyLcDl5L8PrftlbyV1kBbJIs3M3x1A1+rcWd4mEA==
 
 "@types/connect@*":
   version "3.4.35"
@@ -829,58 +314,31 @@
   dependencies:
     "@types/node" "*"
 
-"@types/content-type@^1.1.0":
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.5.tgz#aa02dca40864749a9e2bf0161a6216da57e3ede5"
-  integrity sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==
-
 "@types/cssbeautify@^0.3.1":
   version "0.3.2"
   resolved "https://registry.yarnpkg.com/@types/cssbeautify/-/cssbeautify-0.3.2.tgz#8a76207cd980d3e7b29b4b6dea1f4ed861285615"
   integrity sha512-b3PXlFAcS4gvGr2pDz0NoZEBo3MMQe8Ozy6+Mvm3XIEcHS4oQstvCnnCofBZD/0tQgxSzkYbW+cD3yD4yaKTxQ==
 
-"@types/del@^3.0.0":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/del/-/del-3.0.1.tgz#4712da8c119873cbbf533ad8dbf1baac5940ac5d"
-  integrity sha512-y6qRq6raBuu965clKgx6FHuiPu3oHdtmzMPXi8Uahsjdq1L6DL5fS/aY5/s71YwM7k6K1QIWvem5vNwlnNGIkQ==
-  dependencies:
-    "@types/glob" "*"
-
 "@types/doctrine@^0.0.1":
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.1.tgz#b999f2d9f7b43cabe0a1a2f39bc203bc7dcada9d"
-  integrity sha1-uZny2fe0PKvgoaLzm8IDvH3K2p0=
-
-"@types/escape-html@0.0.20":
-  version "0.0.20"
-  resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
-  integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
+  integrity sha512-iN9ewNbXmuWLOAB3wk/YpCqIBWK3wBNE1D/4u+jA/GyrqsE4r3ozbpS5F0fr0tIYmmnqhbVvT9OOXzt+vw+LDg==
 
 "@types/estree@*":
-  version "0.0.50"
-  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
-  integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
-
-"@types/events@*":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
-  integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
-
-"@types/expect@^1.20.4":
-  version "1.20.4"
-  resolved "https://registry.yarnpkg.com/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5"
-  integrity sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==
+  version "0.0.51"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40"
+  integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==
 
 "@types/express-serve-static-core@^4.17.18":
-  version "4.17.24"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.24.tgz#ea41f93bf7e0d59cd5a76665068ed6aab6815c07"
-  integrity sha512-3UJuW+Qxhzwjq3xhwXm2onQcFHn76frIYVbTu+kn24LFxI+dEhdfISDFovPB8VpEgW8oQCTpRuCe+0zJxB7NEA==
+  version "4.17.28"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz#c47def9f34ec81dc6328d0b1b5303d1ec98d86b8"
+  integrity sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
     "@types/range-parser" "*"
 
-"@types/express@*", "@types/express@^4.0.30", "@types/express@^4.0.36":
+"@types/express@^4.0.30":
   version "4.17.13"
   resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
   integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
@@ -890,100 +348,30 @@
     "@types/qs" "*"
     "@types/serve-static" "*"
 
-"@types/fast-levenshtein@0.0.1":
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/@types/fast-levenshtein/-/fast-levenshtein-0.0.1.tgz#3a3615cf173645c8fca58d051e4e32824e4bd286"
-  integrity sha1-OjYVzxc2Rcj8pY0FHk4ygk5L0oY=
-
-"@types/findup-sync@^0.3.29":
-  version "0.3.30"
-  resolved "https://registry.yarnpkg.com/@types/findup-sync/-/findup-sync-0.3.30.tgz#8ab7bdbd6ba7cbf4f33b6596fde6fff1129c738d"
-  integrity sha512-Dpt1x3rhz6t8BMTS4vziTVos8VLkF4RngIxMBCSE6w0STmnVEEaoe3w+BG5xHyZXshye9lyZE99lpBDoLGY8eA==
-  dependencies:
-    "@types/minimatch" "*"
-
-"@types/form-data@*":
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.5.0.tgz#5025f7433016f923348434c40006d9a797c1b0e8"
-  integrity sha512-23/wYiuckYYtFpL+4RPWiWmRQH2BjFuqCUi2+N3amB1a1Drv+i/byTrGvlLwRVLFNAZbwpbQ7JvTK+VCAPMbcg==
-  dependencies:
-    form-data "*"
-
 "@types/freeport@^1.0.19":
   version "1.0.22"
   resolved "https://registry.yarnpkg.com/@types/freeport/-/freeport-1.0.22.tgz#dbe627a20cb30c17c8aaaba09332e1d14cc2281f"
   integrity sha512-UGg4s5PDPXZXkkrHarU1l6WDbULxN3g7xUEtdbNf9HQhU/JnCj1G1/xZHZmQjC0uWqN1LlB0R0xOlk3k5svgTQ==
 
-"@types/glob-stream@*":
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.1.tgz#c792d8d1514278ff03cad5689aba4c4ab4fbc805"
-  integrity sha512-AGOUTsTdbPkRS0qDeyeS+6KypmfVpbT5j23SN8UPG63qjKXNKjXn6V9wZUr8Fin0m9l8oGYaPK8b2WUMF8xI1A==
-  dependencies:
-    "@types/glob" "*"
-    "@types/node" "*"
-
-"@types/glob@*", "@types/glob@^7.1.1":
-  version "7.1.4"
-  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.4.tgz#ea59e21d2ee5c517914cb4bc8e4153b99e566672"
-  integrity sha512-w+LsMxKyYQm347Otw+IfBXOv9UWVjpHpCDdbBMt8Kz/xbvCYNjP+0qPh91Km3iKfSRLBB0P7fAMf0KHrPu+MyA==
-  dependencies:
-    "@types/minimatch" "*"
-    "@types/node" "*"
-
-"@types/globby@^6.1.0":
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/@types/globby/-/globby-6.1.0.tgz#7c25b975512a89effea2a656ca8cf6db7fb29d11"
-  integrity sha512-j3XSDNoK4LO5T+ZviQD6PqfEjm07QFEacOTbJR3hnLWuWX0ZMLJl9oRPgj1PyrfGbXhfHFkksC9QZ9HFltJyrw==
-  dependencies:
-    "@types/glob" "*"
-
-"@types/gulp-if@0.0.33":
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/@types/gulp-if/-/gulp-if-0.0.33.tgz#edece22b7925d9a6db5f9c8c0d7882aa776fb678"
-  integrity sha512-J5lzff21X7r1x/4hSzn02GgIUEyjCqYIXZ9GgGBLhbsD3RiBdqwnkFWgF16/0jO5rcVZ52Zp+6MQMQdvIsWuKg==
-  dependencies:
-    "@types/node" "*"
-    "@types/vinyl" "*"
-
-"@types/html-minifier@^3.5.1":
-  version "3.5.3"
-  resolved "https://registry.yarnpkg.com/@types/html-minifier/-/html-minifier-3.5.3.tgz#5276845138db2cebc54c789e0aaf87621a21e84f"
-  integrity sha512-j1P/4PcWVVCPEy5lofcHnQ6BtXz9tHGiFPWzqm7TtGuWZEfCHEP446HlkSNc9fQgNJaJZ6ewPtp2aaFla/Uerg==
-  dependencies:
-    "@types/clean-css" "*"
-    "@types/relateurl" "*"
-    "@types/uglify-js" "*"
-
 "@types/http-cache-semantics@*":
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812"
   integrity sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==
 
-"@types/inquirer@*":
-  version "7.3.3"
-  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-7.3.3.tgz#92e6676efb67fa6925c69a2ee638f67a822952ac"
-  integrity sha512-HhxyLejTHMfohAuhRun4csWigAMjXTmRyiJTU1Y/I1xmggikFMkOUoMQRlFm+zQcPEGHSs3io/0FAmNZf8EymQ==
-  dependencies:
-    "@types/through" "*"
-    rxjs "^6.4.0"
-
-"@types/inquirer@0.0.32":
-  version "0.0.32"
-  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-0.0.32.tgz#a4a08e83741c500a7c3c8e7776014f7f8a65870d"
-  integrity sha1-pKCOg3QcUAp8PI53dgFPf4plhw0=
-  dependencies:
-    "@types/rx" "*"
-    "@types/through" "*"
-
 "@types/is-windows@^0.2.0":
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/@types/is-windows/-/is-windows-0.2.0.tgz#6f24ee48731d31168ea510610d6dd15e5fc9c6ff"
-  integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
+  integrity sha512-xuK4kuYgV6/auME6nVp78i9B22jBUYZUCTl64fpJ3O7qWRxK5uRya5yrkBAlSU17k3EVf0DwT7NUjCo5wZD8OA==
+
+"@types/json-buffer@~3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@types/json-buffer/-/json-buffer-3.0.0.tgz#85c1ff0f0948fc159810d4b5be35bf8c20875f64"
+  integrity sha512-3YP80IxxFJB4b5tYC2SUPwkg0XQLiu0nWvhRgEatgjf+29IcWO9X1k8xRv5DGssJ/lCrjYTjQPcobJr2yWIVuQ==
 
 "@types/keyv@*":
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.2.tgz#5d97bb65526c20b6e0845f6b0d2ade4f28604ee5"
-  integrity sha512-/FvAK2p4jQOaJ6CGDHJTqZcUtbZe820qIeTg7o0Shg7drB4JHeL+V/dhSaly7NXx6u8eSee+r7coT+yuJEvDLg==
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6"
+  integrity sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==
   dependencies:
     "@types/node" "*"
 
@@ -993,51 +381,24 @@
   integrity sha512-kQ1a7PwzJelwwOIw1SABmW5OsbCRPvdjps0J84MahGsEKzN89StrPyrWCMWfwpONR3ZqSxDeblxS+8WznIBEGw==
 
 "@types/long@^4.0.0":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
-  integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
-
-"@types/merge-stream@^1.0.28":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@types/merge-stream/-/merge-stream-1.1.2.tgz#a880ff66b1fbbb5eef4958d015c5947a9334dbb1"
-  integrity sha512-7faLmaE99g/yX0Y9pF1neh2IUqOf/fXMOWCVzsXjqI1EJ91lrgXmaBKf6bRWM164lLyiHxHt6t/ZO/cIzq61XA==
-  dependencies:
-    "@types/node" "*"
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a"
+  integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==
 
 "@types/mime@^1":
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
   integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
 
-"@types/mime@^2.0.0":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"
-  integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==
-
-"@types/minimatch@*", "@types/minimatch@^3.0.1", "@types/minimatch@^3.0.3":
+"@types/minimatch@^3.0.1":
   version "3.0.5"
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
   integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
 
-"@types/mz@0.0.29":
-  version "0.0.29"
-  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.29.tgz#bc24728c649973f1c7851e9033f9ce525668c27b"
-  integrity sha1-vCRyjGSZc/HHhR6QM/nOUlZowns=
-  dependencies:
-    "@types/bluebird" "*"
-    "@types/node" "*"
-
-"@types/mz@0.0.31", "@types/mz@^0.0.31":
-  version "0.0.31"
-  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.31.tgz#a4d80c082fefe71e40a7c0f07d1e6555bbbc7b52"
-  integrity sha1-pNgMCC/v5x5Ap8DwfR5lVbu8e1I=
-  dependencies:
-    "@types/node" "*"
-
-"@types/node@*", "@types/node@>= 8":
-  version "16.7.10"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-16.7.10.tgz#7aa732cc47341c12a16b7d562f519c2383b6d4fc"
-  integrity sha512-S63Dlv4zIPb8x6MMTgDq5WWRJQe56iBEY0O3SOFA9JrRienkOVDXSXBjjJw6HTNQYSE2JI6GMCR6LVbIMHJVvA==
+"@types/node@*":
+  version "17.0.38"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947"
+  integrity sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g==
 
 "@types/node@6.0.*":
   version "6.0.118"
@@ -1054,18 +415,6 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.5.tgz#a3785db96b07a4b56466cc99fd624838746f2e25"
   integrity sha512-+8fpgbXsbATKRF2ayAlYhPl2E9MPdLjrnK/79ZEpyPJ+k7dZwJm9YM8FK+l4rqL//xHk7PgQhGwz6aA2ckxbCQ==
 
-"@types/normalize-package-data@^2.4.0":
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
-  integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
-
-"@types/opn@^3.0.28":
-  version "3.0.28"
-  resolved "https://registry.yarnpkg.com/@types/opn/-/opn-3.0.28.tgz#097d0d1c9b5749573a5d96df132387bb6d02118a"
-  integrity sha1-CX0NHJtXSVc6XZbfEyOHu20CEYo=
-  dependencies:
-    "@types/node" "*"
-
 "@types/parse5-html-rewriting-stream@^5.1.2":
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/@types/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.2.tgz#919d5bbf69ef61e11d873e7195891c3811491a03"
@@ -1082,21 +431,21 @@
     "@types/parse5" "*"
 
 "@types/parse5@*":
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.1.tgz#f8ae4fbcd2b9ba4ff934698e28778961f9cb22ca"
-  integrity sha512-ARATsLdrGPUnaBvxLhUlnltcMgn7pQG312S8ccdYlnyijabrX9RN/KN/iGj9Am96CoW8e/K9628BA7Bv4XHdrA==
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb"
+  integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
 
 "@types/parse5@^0.0.31":
   version "0.0.31"
   resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-0.0.31.tgz#e827a493a443b156e1b582a2e4c3bdc0040f2ee7"
-  integrity sha1-6Cekk6RDsVbhtYKi5MO9wAQPLuc=
+  integrity sha512-W9yKi+ZkSypS/6SXd0ebArnPxg5mwSAdmLqlJX+boeu845j3WVaYSJjqIg0i8Rh5btq7KytgIcta2KJB1aS4Mw==
   dependencies:
     "@types/node" "6.0.*"
 
 "@types/parse5@^2.2.34":
   version "2.2.34"
   resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-2.2.34.tgz#e3870a10e82735a720f62d71dcd183ba78ef3a9d"
-  integrity sha1-44cKEOgnNacg9i1x3NGDunjvOp0=
+  integrity sha512-p3qOvaRsRpFyEmaS36RtLzpdxZZnmxGuT1GMgzkTtTJVFuEw7KFjGK83MFODpJExgX1bEzy9r0NYjMC3IMfi7w==
   dependencies:
     "@types/node" "*"
 
@@ -1112,13 +461,6 @@
   resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
   integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
 
-"@types/pem@^1.8.1":
-  version "1.9.6"
-  resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.6.tgz#c3686832e935947fdd9d848dec3b8fe830068de7"
-  integrity sha512-IC67SxacM9fxEi/w7hf98dTun83OwUMeLMo1NS2gE0wdM9MHeg73iH/Pp9nB02OUCQ7Zb2UuKE/IpFCmQw9jxw==
-  dependencies:
-    "@types/node" "*"
-
 "@types/qs@*":
   version "6.9.7"
   resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
@@ -1129,26 +471,6 @@
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
   integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
-"@types/relateurl@*":
-  version "0.2.29"
-  resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.29.tgz#68ccecec3d4ffdafb9c577fe764f912afc050fe6"
-  integrity sha512-QSvevZ+IRww2ldtfv1QskYsqVVVwCKQf1XbwtcyyoRvLIQzfyPhj/C+3+PKzSDRdiyejaiLgnq//XTkleorpLg==
-
-"@types/request@2.0.3":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/request/-/request-2.0.3.tgz#bdf0fba9488c822f77e97de3dd8fe357b2fb8c06"
-  integrity sha512-cIvnyFRARxwE4OHpCyYue7H+SxaKFPpeleRCHJicft8QhyTNbVYsMwjvEzEPqG06D2LGHZ+sN5lXc8+bTu6D8A==
-  dependencies:
-    "@types/form-data" "*"
-    "@types/node" "*"
-
-"@types/resolve@0.0.4":
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.4.tgz#9b586d65a947dea88c4bc24da0b905fe9520a0d5"
-  integrity sha1-m1htZalH3qiMS8JNoLkF/pUgoNU=
-  dependencies:
-    "@types/node" "*"
-
 "@types/resolve@0.0.6":
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.6.tgz#0bd2f236c2e1cebb98b79885df57edd71a8d770e"
@@ -1156,13 +478,6 @@
   dependencies:
     "@types/node" "*"
 
-"@types/resolve@0.0.7":
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.7.tgz#b299c13be8d712b1b502fb14a084252acef84f4d"
-  integrity sha512-GPewdjkb0Q76o459qgp6pBLzJj/bD3oveS2kfLhIkZ9U3t3AFKtl5DlFB6lGTw0iZmcmxoGC8lpLW3NNJKrN9A==
-  dependencies:
-    "@types/node" "*"
-
 "@types/resolve@0.0.8":
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194"
@@ -1177,118 +492,7 @@
   dependencies:
     "@types/node" "*"
 
-"@types/rimraf@^0.0.28":
-  version "0.0.28"
-  resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-0.0.28.tgz#5562519bc7963caca8abf7f128cae3b594d41d06"
-  integrity sha1-VWJRm8eWPKyoq/fxKMrjtZTUHQY=
-
-"@types/rx-core-binding@*":
-  version "4.0.4"
-  resolved "https://registry.yarnpkg.com/@types/rx-core-binding/-/rx-core-binding-4.0.4.tgz#d969d32f15a62b89e2862c17b3ee78fe329818d3"
-  integrity sha512-5pkfxnC4w810LqBPUwP5bg7SFR/USwhMSaAeZQQbEHeBp57pjKXRlXmqpMrLJB4y1oglR/c2502853uN0I+DAQ==
-  dependencies:
-    "@types/rx-core" "*"
-
-"@types/rx-core@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-core/-/rx-core-4.0.3.tgz#0b3354b1238cedbe2b74f6326f139dbc7a591d60"
-  integrity sha1-CzNUsSOM7b4rdPYybxOdvHpZHWA=
-
-"@types/rx-lite-aggregates@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-aggregates/-/rx-lite-aggregates-4.0.3.tgz#6efb2b7f3d5f07183a1cb2bd4b1371d7073384c2"
-  integrity sha512-MAGDAHy8cRatm94FDduhJF+iNS5//jrZ/PIfm+QYw9OCeDgbymFHChM8YVIvN2zArwsRftKgE33QfRWvQk4DPg==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-async@*":
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-async/-/rx-lite-async-4.0.2.tgz#27fbf0caeff029f41e2d2aae638b05e91ceb600c"
-  integrity sha512-vTEv5o8l6702ZwfAM5aOeVDfUwBSDOs+ARoGmWAKQ6LOInQ8J4/zjM7ov12fuTpktUKdMQjkeCp07Vd73mPkxw==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-backpressure@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-backpressure/-/rx-lite-backpressure-4.0.3.tgz#05abb19bdf87cc740196c355e5d0b37bb50b5d56"
-  integrity sha512-Y6aIeQCtNban5XSAF4B8dffhIKu6aAy/TXFlScHzSxh6ivfQBQw6UjxyEJxIOt3IT49YkS+siuayM2H/Q0cmgA==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-coincidence@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-coincidence/-/rx-lite-coincidence-4.0.3.tgz#80bd69acc4054a15cdc1638e2dc8843498cd85c0"
-  integrity sha512-1VNJqzE9gALUyMGypDXZZXzR0Tt7LC9DdAZQ3Ou/Q0MubNU35agVUNXKGHKpNTba+fr8GdIdkC26bRDqtCQBeQ==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-experimental@*":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-experimental/-/rx-lite-experimental-4.0.1.tgz#c532f5cbdf3f2c15da16ded8930d1b2984023cbd"
-  integrity sha1-xTL1y98/LBXaFt7Ykw0bKYQCPL0=
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-joinpatterns@*":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-joinpatterns/-/rx-lite-joinpatterns-4.0.1.tgz#f70fe370518a8432f29158cc92ffb56b4e4afc3e"
-  integrity sha1-9w/jcFGKhDLykVjMkv+1a05K/D4=
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-testing@*":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-testing/-/rx-lite-testing-4.0.1.tgz#21b19d11f4dfd6ffef5a9d1648e9c8879bfe21e9"
-  integrity sha1-IbGdEfTf1v/vWp0WSOnIh5v+Iek=
-  dependencies:
-    "@types/rx-lite-virtualtime" "*"
-
-"@types/rx-lite-time@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-time/-/rx-lite-time-4.0.3.tgz#0eda65474570237598f3448b845d2696f2dbb1c4"
-  integrity sha512-ukO5sPKDRwCGWRZRqPlaAU0SKVxmWwSjiOrLhoQDoWxZWg6vyB9XLEZViKOzIO6LnTIQBlk4UylYV0rnhJLxQw==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-virtualtime@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-virtualtime/-/rx-lite-virtualtime-4.0.3.tgz#4b30cacd0fe2e53af29f04f7438584c7d3959537"
-  integrity sha512-3uC6sGmjpOKatZSVHI2xB1+dedgml669ZRvqxy+WqmGJDVusOdyxcKfyzjW0P3/GrCiN4nmRkLVMhPwHCc5QLg==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite@*":
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite/-/rx-lite-4.0.6.tgz#3c02921c4244074234f26b772241bcc20c18c253"
-  integrity sha512-oYiDrFIcor9zDm0VDUca1UbROiMYBxMLMaM6qzz4ADAfOmA9r1dYEcAFH+2fsPI5BCCjPvV9pWC3X3flbrvs7w==
-  dependencies:
-    "@types/rx-core" "*"
-    "@types/rx-core-binding" "*"
-
-"@types/rx@*":
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/@types/rx/-/rx-4.1.2.tgz#a4061b3d72b03cf11a38d69e2022a17334c54dc0"
-  integrity sha512-1r8ZaT26Nigq7o4UBGl+aXB2UMFUIdLPP/8bLIP0x3d0pZL46ybKKjhWKaJQWIkLl5QCLD0nK3qTOO1QkwdFaA==
-  dependencies:
-    "@types/rx-core" "*"
-    "@types/rx-core-binding" "*"
-    "@types/rx-lite" "*"
-    "@types/rx-lite-aggregates" "*"
-    "@types/rx-lite-async" "*"
-    "@types/rx-lite-backpressure" "*"
-    "@types/rx-lite-coincidence" "*"
-    "@types/rx-lite-experimental" "*"
-    "@types/rx-lite-joinpatterns" "*"
-    "@types/rx-lite-testing" "*"
-    "@types/rx-lite-time" "*"
-    "@types/rx-lite-virtualtime" "*"
-
-"@types/semver@^5.3.30":
-  version "5.5.0"
-  resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
-  integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
-
-"@types/serve-static@*", "@types/serve-static@^1.7.31":
+"@types/serve-static@*":
   version "1.13.10"
   resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
   integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==
@@ -1296,75 +500,6 @@
     "@types/mime" "^1"
     "@types/node" "*"
 
-"@types/spdy@^3.4.1":
-  version "3.4.5"
-  resolved "https://registry.yarnpkg.com/@types/spdy/-/spdy-3.4.5.tgz#194dc132312ddcd31e8053789ae83a7bb32a8aaf"
-  integrity sha512-/33fIRK/aqkKNxg9BSjpzt1ucmvPremgeDywm9z2C2mOlIh5Ljjvgc3UhQHqwXsSLDLHPT9jlsnrjKQ1XiVJzA==
-  dependencies:
-    "@types/node" "*"
-
-"@types/temp@^0.8.28":
-  version "0.8.34"
-  resolved "https://registry.yarnpkg.com/@types/temp/-/temp-0.8.34.tgz#03e4b3cb67cbb48c425bbf54b12230fef85540ac"
-  integrity sha512-oLa9c5LHXgS6UimpEVp08De7QvZ+Dfu5bMQuWyMhf92Z26Q10ubEMOWy9OEfUdzW7Y/sDWVHmUaLFtmnX/2j0w==
-  dependencies:
-    "@types/node" "*"
-
-"@types/through@*":
-  version "0.0.30"
-  resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895"
-  integrity sha512-FvnCJljyxhPM3gkRgWmxmDZyAQSiBQQWLI0A0VFL0K7W1oRUrPJSqNO0NvTnLkBcotdlp3lKvaT0JrnyRDkzOg==
-  dependencies:
-    "@types/node" "*"
-
-"@types/ua-parser-js@^0.7.31":
-  version "0.7.36"
-  resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190"
-  integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==
-
-"@types/uglify-js@*":
-  version "3.13.1"
-  resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.13.1.tgz#5e889e9e81e94245c75b6450600e1c5ea2878aea"
-  integrity sha512-O3MmRAk6ZuAKa9CHgg0Pr0+lUOqoMLpc9AS4R8ano2auvsg7IE8syF3Xh/NPr26TWklxYcqoEEFdzLLs1fV9PQ==
-  dependencies:
-    source-map "^0.6.1"
-
-"@types/update-notifier@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@types/update-notifier/-/update-notifier-1.0.4.tgz#ce73d597bd399d5df4544fe136a79c2b9fe41958"
-  integrity sha512-smyU9GTDitojg87woCcLNCdPnUfNx4LHRBWf+aWmHsAgE1kaCDhhcu84W+dFymAKL1yKDsq2JFWKkR2K6WjJfw==
-
-"@types/uuid@^3.4.3":
-  version "3.4.10"
-  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.10.tgz#637d3c8431f112edf6728ac9bdfadfe029540f48"
-  integrity sha512-BgeaZuElf7DEYZhWYDTc/XcLZXdVgFkVSTa13BqKvbnmUrxr3TJFKofUxCtDO9UQOdhnV+HPOESdHiHKZOJV1A==
-
-"@types/vinyl-fs@0.0.28":
-  version "0.0.28"
-  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-0.0.28.tgz#4663017bc802c6570eae4f3409fd5cabf97cbfde"
-  integrity sha1-RmMBe8gCxlcOrk80Cf1cq/l8v94=
-  dependencies:
-    "@types/glob-stream" "*"
-    "@types/node" "*"
-    "@types/vinyl" "*"
-
-"@types/vinyl-fs@^2.4.8":
-  version "2.4.12"
-  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-2.4.12.tgz#7b4673d9b4d5a874c8652d10f0f0265479014c8e"
-  integrity sha512-LgBpYIWuuGsihnlF+OOWWz4ovwCYlT03gd3DuLwex50cYZLmX3yrW+sFF9ndtmh7zcZpS6Ri47PrIu+fV+sbXw==
-  dependencies:
-    "@types/glob-stream" "*"
-    "@types/node" "*"
-    "@types/vinyl" "*"
-
-"@types/vinyl@*", "@types/vinyl@^2.0.0":
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.5.tgz#52d3b850a4ed494aaad51e96708834c500c8d5cd"
-  integrity sha512-1m6uReH8R/RuLVQGvTT/4LlWq67jZEUxp+FBHt0hYv2BT7TUwFbKI0wa7JZVEU/XtlcnX1QcTuZ36es4rGj7jg==
-  dependencies:
-    "@types/expect" "^1.20.4"
-    "@types/node" "*"
-
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
@@ -1377,51 +512,17 @@
   resolved "https://registry.yarnpkg.com/@types/which/-/which-1.3.2.tgz#9c246fc0c93ded311c8512df2891fb41f6227fdf"
   integrity sha512-8oDqyLC7eD4HM307boe2QWKyuzdzWBj56xI/imSl2cpL+U3tCMaTAkMJ4ee5JBZ/FsOJlvRGeIShiZDAl1qERA==
 
-"@types/yeoman-generator@^2.0.3":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/yeoman-generator/-/yeoman-generator-2.0.3.tgz#f4b161ee354078b526e0901a5a5f87d4f8e085f6"
-  integrity sha512-vch2UFd6k7DdfWEv/alRwZIRXQoxZNUDpfLOK24+005dzE1HVnwSWfETF3WxJnWlsOcH87wU4uzldAE/7F/6Lw==
-  dependencies:
-    "@types/events" "*"
-    "@types/inquirer" "*"
-
-"@webcomponents/webcomponentsjs@^1.0.7":
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
-  integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
-
-JSONStream@^1.2.1, JSONStream@^1.3.5:
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
-  integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
-  dependencies:
-    jsonparse "^1.2.0"
-    through ">=2.2.7 <3"
-
-accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
-  version "1.3.7"
-  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
-  integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
-  dependencies:
-    mime-types "~2.1.24"
-    negotiator "0.6.2"
-
-accessibility-developer-tools@^2.12.0:
-  version "2.12.0"
-  resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514"
-  integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ=
-
 acorn-jsx@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b"
-  integrity sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=
+  integrity sha512-AU7pnZkguthwBjKgCg6998ByQNIMjbuDQZ8bb78QAFZwPfmKia8AIzgY/gWgqCjnht8JLdXmB4YxA0KaV60ncQ==
   dependencies:
     acorn "^3.0.4"
 
 acorn@^3.0.4:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
-  integrity sha1-ReN/s56No/JbruP/U2niu18iAXo=
+  integrity sha512-OLUyIIZ7mF5oaAUT1w0TFqQS81q3saT46x8t7ukpPjMNk+nbs4ZHhs7ToV8EWnLYLepjETXd4XaCE4uxkMeqUw==
 
 acorn@^5.5.0:
   version "5.7.4"
@@ -1433,23 +534,6 @@
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
   integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
 
-adm-zip@~0.4.3:
-  version "0.4.16"
-  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.16.tgz#cf4c508fdffab02c269cbc7f471a875f05570365"
-  integrity sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==
-
-after@0.8.2:
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
-  integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
-
-agent-base@6:
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
-  integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
-  dependencies:
-    debug "4"
-
 agent-base@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
@@ -1457,68 +541,22 @@
   dependencies:
     es6-promisify "^5.0.0"
 
-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-align@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-1.1.0.tgz#2f0c1658829739add5ebb15e6b0c6e3423f016ba"
-  integrity sha1-LwwWWIKXOa3V67FeawxuNCPwFro=
-  dependencies:
-    string-width "^1.0.1"
-
-ansi-align@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
-  integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=
-  dependencies:
-    string-width "^2.0.0"
-
 ansi-escape-sequences@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-escape-sequences/-/ansi-escape-sequences-3.0.0.tgz#1c18394b6af9b76ff9a63509fa497669fd2ce53e"
-  integrity sha1-HBg5S2r5t2/5pjUJ+kl2af0s5T4=
+  integrity sha512-nOj2mwGB2lJzx9YDqaiI77vYh4SWcOCTday6kdtx6ojUk1s1HqSiK604UIq8jlBVC0UBsX7Bph3SfOf9QsJerA==
   dependencies:
     array-back "^1.0.3"
 
-ansi-escapes@^1.1.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
-  integrity sha1-06ioOzGapneTZisT52HHkRQiMG4=
-
-ansi-escapes@^4.2.1:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
-  integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
-  dependencies:
-    type-fest "^0.21.3"
-
 ansi-regex@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
-  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
-
-ansi-regex@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
-  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
-
-ansi-regex@^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==
+  integrity sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==
 
 ansi-styles@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
-  integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
+  integrity sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==
 
 ansi-styles@^3.2.1:
   version "3.2.1"
@@ -1527,91 +565,10 @@
   dependencies:
     color-convert "^1.9.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"
-
-ansi-styles@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178"
-  integrity sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=
-
-any-promise@^1.0.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
-  integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
-
-anymatch@^1.3.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
-  integrity sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==
-  dependencies:
-    micromatch "^2.1.5"
-    normalize-path "^2.0.0"
-
-append-field@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
-  integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=
-
-archiver-utils@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2"
-  integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==
-  dependencies:
-    glob "^7.1.4"
-    graceful-fs "^4.2.0"
-    lazystream "^1.0.0"
-    lodash.defaults "^4.2.0"
-    lodash.difference "^4.5.0"
-    lodash.flatten "^4.4.0"
-    lodash.isplainobject "^4.0.6"
-    lodash.union "^4.6.0"
-    normalize-path "^3.0.0"
-    readable-stream "^2.0.0"
-
-archiver@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
-  integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==
-  dependencies:
-    archiver-utils "^2.1.0"
-    async "^2.6.3"
-    buffer-crc32 "^0.2.1"
-    glob "^7.1.4"
-    readable-stream "^3.4.0"
-    tar-stream "^2.1.0"
-    zip-stream "^2.1.2"
-
-arr-diff@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
-  integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=
-  dependencies:
-    arr-flatten "^1.0.1"
-
-arr-diff@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
-  integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=
-
-arr-flatten@^1.0.1, arr-flatten@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
-  integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==
-
-arr-union@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
-  integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
-
 array-back@^1.0.3, array-back@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-1.0.4.tgz#644ba7f095f7ffcf7c43b5f0dc39d3c1f03c063b"
-  integrity sha1-ZEun8JX3/898Q7Xw3DnTwfA8Bjs=
+  integrity sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw==
   dependencies:
     typical "^2.6.0"
 
@@ -1627,148 +584,22 @@
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
   integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
 
-array-differ@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031"
-  integrity sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=
-
-array-differ@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-3.0.0.tgz#3cbb3d0f316810eafcc47624734237d6aee4ae6b"
-  integrity sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==
-
-array-find-index@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
-  integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=
-
-array-flatten@1.1.1:
+ast-matcher@^1.1.1:
   version "1.1.1"
-  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
-  integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
+  resolved "https://registry.yarnpkg.com/ast-matcher/-/ast-matcher-1.1.1.tgz#95a6dc72318319507024fff438b7839e4e280813"
+  integrity sha512-wQPAp09kPFRQsOijM2Blfg4lH6B9MIhIUrhFtDdhD/1JFhPmfg2/+WAjViVYl3N7EwleHI+q/enTHjaDrv+wEw==
 
-array-union@^1.0.1, array-union@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
-  integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=
-  dependencies:
-    array-uniq "^1.0.1"
-
-array-union@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
-  integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
-
-array-uniq@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6"
-  integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=
-
-array-unique@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
-  integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=
-
-array-unique@^0.3.2:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
-  integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
-
-arraybuffer.slice@~0.0.7:
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
-  integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
-
-arrify@^1.0.0, arrify@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
-  integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
-
-arrify@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
-  integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
-
-asn1@~0.2.3:
-  version "0.2.4"
-  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
-  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
-  dependencies:
-    safer-buffer "~2.1.0"
-
-assert-plus@1.0.0, assert-plus@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
-  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
-
-assign-symbols@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
-  integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
-
-async-each@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
-  integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
-
-async@0.9.x:
-  version "0.9.2"
-  resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
-  integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=
-
-async@^2.0.0, async@^2.0.1, async@^2.1.2, async@^2.4.1, async@^2.6.0, async@^2.6.2, async@^2.6.3:
-  version "2.6.3"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
-  integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
+async@^2.0.1:
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+  integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
   dependencies:
     lodash "^4.17.14"
 
-async@^3.1.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/async/-/async-3.2.1.tgz#d3274ec66d107a47476a4c49136aacdb00665fc8"
-  integrity sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg==
-
-async@~0.2.9:
-  version "0.2.10"
-  resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
-  integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E=
-
-asynckit@^0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
-  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
-
-atob-lite@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/atob-lite/-/atob-lite-2.0.0.tgz#0fef5ad46f1bd7a8502c65727f0367d5ee43d696"
-  integrity sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY=
-
-atob@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
-  integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
-
-aws-sign2@~0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
-  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
-
-aws4@^1.8.0:
-  version "1.11.0"
-  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
-  integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
-
-axios@^0.21.1:
-  version "0.21.1"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
-  integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
-  dependencies:
-    follow-redirects "^1.10.0"
-
 babel-code-frame@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
-  integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=
+  integrity sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==
   dependencies:
     chalk "^1.1.3"
     esutils "^2.0.2"
@@ -1788,223 +619,17 @@
     source-map "^0.5.7"
     trim-right "^1.0.1"
 
-babel-helper-evaluate-path@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-evaluate-path/-/babel-helper-evaluate-path-0.5.0.tgz#a62fa9c4e64ff7ea5cea9353174ef023a900a67c"
-  integrity sha512-mUh0UhS607bGh5wUMAQfOpt2JX2ThXMtppHRdRU1kL7ZLRWIXxoV2UIV1r2cAeeNeU1M5SB5/RSUgUxrK8yOkA==
-
-babel-helper-flip-expressions@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-flip-expressions/-/babel-helper-flip-expressions-0.4.3.tgz#3696736a128ac18bc25254b5f40a22ceb3c1d3fd"
-  integrity sha1-NpZzahKKwYvCUlS19AoizrPB0/0=
-
-babel-helper-is-nodes-equiv@^0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-is-nodes-equiv/-/babel-helper-is-nodes-equiv-0.0.1.tgz#34e9b300b1479ddd98ec77ea0bbe9342dfe39684"
-  integrity sha1-NOmzALFHnd2Y7HfqC76TQt/jloQ=
-
-babel-helper-is-void-0@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-is-void-0/-/babel-helper-is-void-0-0.4.3.tgz#7d9c01b4561e7b95dbda0f6eee48f5b60e67313e"
-  integrity sha1-fZwBtFYee5Xb2g9u7kj1tg5nMT4=
-
-babel-helper-mark-eval-scopes@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-mark-eval-scopes/-/babel-helper-mark-eval-scopes-0.4.3.tgz#d244a3bef9844872603ffb46e22ce8acdf551562"
-  integrity sha1-0kSjvvmESHJgP/tG4izorN9VFWI=
-
-babel-helper-remove-or-void@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-remove-or-void/-/babel-helper-remove-or-void-0.4.3.tgz#a4f03b40077a0ffe88e45d07010dee241ff5ae60"
-  integrity sha1-pPA7QAd6D/6I5F0HAQ3uJB/1rmA=
-
-babel-helper-to-multiple-sequence-expressions@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d"
-  integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA==
-
 babel-messages@^6.23.0:
   version "6.23.0"
   resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
-  integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=
+  integrity sha512-Bl3ZiA+LjqaMtNYopA9TYE9HP1tQ+E5dLxE0XrAzcIJeK2UqF0/EaqXwBn9esd4UmTfEab+P+UYQ1GnioFIb/w==
   dependencies:
     babel-runtime "^6.22.0"
 
-babel-plugin-dynamic-import-node@^2.3.3:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"
-  integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==
-  dependencies:
-    object.assign "^4.1.0"
-
-babel-plugin-minify-builtins@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-builtins/-/babel-plugin-minify-builtins-0.5.0.tgz#31eb82ed1a0d0efdc31312f93b6e4741ce82c36b"
-  integrity sha512-wpqbN7Ov5hsNwGdzuzvFcjgRlzbIeVv1gMIlICbPj0xkexnfoIDe7q+AZHMkQmAE/F9R5jkrB6TLfTegImlXag==
-
-babel-plugin-minify-constant-folding@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-constant-folding/-/babel-plugin-minify-constant-folding-0.5.0.tgz#f84bc8dbf6a561e5e350ff95ae216b0ad5515b6e"
-  integrity sha512-Vj97CTn/lE9hR1D+jKUeHfNy+m1baNiJ1wJvoGyOBUx7F7kJqDZxr9nCHjO/Ad+irbR3HzR6jABpSSA29QsrXQ==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-
-babel-plugin-minify-dead-code-elimination@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-dead-code-elimination/-/babel-plugin-minify-dead-code-elimination-0.5.1.tgz#1a0c68e44be30de4976ca69ffc535e08be13683f"
-  integrity sha512-x8OJOZIrRmQBcSqxBcLbMIK8uPmTvNWPXH2bh5MDCW1latEqYiRMuUkPImKcfpo59pTUB2FT7HfcgtG8ZlR5Qg==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-mark-eval-scopes "^0.4.3"
-    babel-helper-remove-or-void "^0.4.3"
-    lodash "^4.17.11"
-
-babel-plugin-minify-flip-comparisons@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-flip-comparisons/-/babel-plugin-minify-flip-comparisons-0.4.3.tgz#00ca870cb8f13b45c038b3c1ebc0f227293c965a"
-  integrity sha1-AMqHDLjxO0XAOLPB68DyJyk8llo=
-  dependencies:
-    babel-helper-is-void-0 "^0.4.3"
-
-babel-plugin-minify-guarded-expressions@^0.4.3, babel-plugin-minify-guarded-expressions@^0.4.4:
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-guarded-expressions/-/babel-plugin-minify-guarded-expressions-0.4.4.tgz#818960f64cc08aee9d6c75bec6da974c4d621135"
-  integrity sha512-RMv0tM72YuPPfLT9QLr3ix9nwUIq+sHT6z8Iu3sLbqldzC1Dls8DPCywzUIzkTx9Zh1hWX4q/m9BPoPed9GOfA==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-flip-expressions "^0.4.3"
-
-babel-plugin-minify-infinity@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-infinity/-/babel-plugin-minify-infinity-0.4.3.tgz#dfb876a1b08a06576384ef3f92e653ba607b39ca"
-  integrity sha1-37h2obCKBldjhO8/kuZTumB7Oco=
-
-babel-plugin-minify-mangle-names@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-mangle-names/-/babel-plugin-minify-mangle-names-0.5.0.tgz#bcddb507c91d2c99e138bd6b17a19c3c271e3fd3"
-  integrity sha512-3jdNv6hCAw6fsX1p2wBGPfWuK69sfOjfd3zjUXkbq8McbohWy23tpXfy5RnToYWggvqzuMOwlId1PhyHOfgnGw==
-  dependencies:
-    babel-helper-mark-eval-scopes "^0.4.3"
-
-babel-plugin-minify-numeric-literals@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-numeric-literals/-/babel-plugin-minify-numeric-literals-0.4.3.tgz#8e4fd561c79f7801286ff60e8c5fd9deee93c0bc"
-  integrity sha1-jk/VYcefeAEob/YOjF/Z3u6TwLw=
-
-babel-plugin-minify-replace@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-replace/-/babel-plugin-minify-replace-0.5.0.tgz#d3e2c9946c9096c070efc96761ce288ec5c3f71c"
-  integrity sha512-aXZiaqWDNUbyNNNpWs/8NyST+oU7QTpK7J9zFEFSA0eOmtUNMU3fczlTTTlnCxHmq/jYNFEmkkSG3DDBtW3Y4Q==
-
-babel-plugin-minify-simplify@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-simplify/-/babel-plugin-minify-simplify-0.5.1.tgz#f21613c8b95af3450a2ca71502fdbd91793c8d6a"
-  integrity sha512-OSYDSnoCxP2cYDMk9gxNAed6uJDiDz65zgL6h8d3tm8qXIagWGMLWhqysT6DY3Vs7Fgq7YUDcjOomhVUb+xX6A==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-flip-expressions "^0.4.3"
-    babel-helper-is-nodes-equiv "^0.0.1"
-    babel-helper-to-multiple-sequence-expressions "^0.5.0"
-
-babel-plugin-minify-type-constructors@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-type-constructors/-/babel-plugin-minify-type-constructors-0.4.3.tgz#1bc6f15b87f7ab1085d42b330b717657a2156500"
-  integrity sha1-G8bxW4f3qxCF1CszC3F2V6IVZQA=
-  dependencies:
-    babel-helper-is-void-0 "^0.4.3"
-
-babel-plugin-transform-inline-consecutive-adds@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.4.3.tgz#323d47a3ea63a83a7ac3c811ae8e6941faf2b0d1"
-  integrity sha1-Mj1Ho+pjqDp6w8gRro5pQfrysNE=
-
-babel-plugin-transform-member-expression-literals@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-member-expression-literals/-/babel-plugin-transform-member-expression-literals-6.9.4.tgz#37039c9a0c3313a39495faac2ff3a6b5b9d038bf"
-  integrity sha1-NwOcmgwzE6OUlfqsL/OmtbnQOL8=
-
-babel-plugin-transform-merge-sibling-variables@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-merge-sibling-variables/-/babel-plugin-transform-merge-sibling-variables-6.9.4.tgz#85b422fc3377b449c9d1cde44087203532401dae"
-  integrity sha1-hbQi/DN3tEnJ0c3kQIcgNTJAHa4=
-
-babel-plugin-transform-minify-booleans@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-minify-booleans/-/babel-plugin-transform-minify-booleans-6.9.4.tgz#acbb3e56a3555dd23928e4b582d285162dd2b198"
-  integrity sha1-rLs+VqNVXdI5KOS1gtKFFi3SsZg=
-
-babel-plugin-transform-property-literals@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-property-literals/-/babel-plugin-transform-property-literals-6.9.4.tgz#98c1d21e255736573f93ece54459f6ce24985d39"
-  integrity sha1-mMHSHiVXNlc/k+zlRFn2ziSYXTk=
-  dependencies:
-    esutils "^2.0.2"
-
-babel-plugin-transform-regexp-constructors@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-regexp-constructors/-/babel-plugin-transform-regexp-constructors-0.4.3.tgz#58b7775b63afcf33328fae9a5f88fbd4fb0b4965"
-  integrity sha1-WLd3W2OvzzMyj66aX4j71PsLSWU=
-
-babel-plugin-transform-remove-console@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
-  integrity sha1-uYA2DAZzhOJLNXpYjYB9PINSd4A=
-
-babel-plugin-transform-remove-debugger@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-debugger/-/babel-plugin-transform-remove-debugger-6.9.4.tgz#42b727631c97978e1eb2d199a7aec84a18339ef2"
-  integrity sha1-QrcnYxyXl44estGZp67IShgznvI=
-
-babel-plugin-transform-remove-undefined@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-undefined/-/babel-plugin-transform-remove-undefined-0.5.0.tgz#80208b31225766c630c97fa2d288952056ea22dd"
-  integrity sha512-+M7fJYFaEE/M9CXa0/IRkDbiV3wRELzA1kKQFCJ4ifhrzLKn/9VCCgj9OFmYWwBd8IB48YdgPkHYtbYq+4vtHQ==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-
-babel-plugin-transform-simplify-comparison-operators@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-simplify-comparison-operators/-/babel-plugin-transform-simplify-comparison-operators-6.9.4.tgz#f62afe096cab0e1f68a2d753fdf283888471ceb9"
-  integrity sha1-9ir+CWyrDh9ootdT/fKDiIRxzrk=
-
-babel-plugin-transform-undefined-to-void@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280"
-  integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA=
-
-babel-preset-minify@^0.5.0:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-preset-minify/-/babel-preset-minify-0.5.1.tgz#25f5d0bce36ec818be80338d0e594106e21eaa9f"
-  integrity sha512-1IajDumYOAPYImkHbrKeiN5AKKP9iOmRoO2IPbIuVp0j2iuCcj0n7P260z38siKMZZ+85d3mJZdtW8IgOv+Tzg==
-  dependencies:
-    babel-plugin-minify-builtins "^0.5.0"
-    babel-plugin-minify-constant-folding "^0.5.0"
-    babel-plugin-minify-dead-code-elimination "^0.5.1"
-    babel-plugin-minify-flip-comparisons "^0.4.3"
-    babel-plugin-minify-guarded-expressions "^0.4.4"
-    babel-plugin-minify-infinity "^0.4.3"
-    babel-plugin-minify-mangle-names "^0.5.0"
-    babel-plugin-minify-numeric-literals "^0.4.3"
-    babel-plugin-minify-replace "^0.5.0"
-    babel-plugin-minify-simplify "^0.5.1"
-    babel-plugin-minify-type-constructors "^0.4.3"
-    babel-plugin-transform-inline-consecutive-adds "^0.4.3"
-    babel-plugin-transform-member-expression-literals "^6.9.4"
-    babel-plugin-transform-merge-sibling-variables "^6.9.4"
-    babel-plugin-transform-minify-booleans "^6.9.4"
-    babel-plugin-transform-property-literals "^6.9.4"
-    babel-plugin-transform-regexp-constructors "^0.4.3"
-    babel-plugin-transform-remove-console "^6.9.4"
-    babel-plugin-transform-remove-debugger "^6.9.4"
-    babel-plugin-transform-remove-undefined "^0.5.0"
-    babel-plugin-transform-simplify-comparison-operators "^6.9.4"
-    babel-plugin-transform-undefined-to-void "^6.9.4"
-    lodash "^4.17.11"
-
 babel-runtime@^6.22.0, babel-runtime@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
-  integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
+  integrity sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==
   dependencies:
     core-js "^2.4.0"
     regenerator-runtime "^0.11.0"
@@ -2012,7 +637,7 @@
 babel-traverse@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
-  integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=
+  integrity sha512-iSxeXx7apsjCHe9c7n8VtRXGzI2Bk1rBSOJgCCjfyXb6v1aCqE1KSEpq/8SXuVN8Ka/Rh1WDTF0MDzkvTA4MIA==
   dependencies:
     babel-code-frame "^6.26.0"
     babel-messages "^6.23.0"
@@ -2027,7 +652,7 @@
 babel-types@^6.26.0:
   version "6.26.0"
   resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
-  integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=
+  integrity sha512-zhe3V/26rCWsEZK8kZN+HaQj5yQ1CilTObixFzKW1UWjqG7618Twz6YEsCnjfg5gBcJh02DrpCkS9h98ZqDY+g==
   dependencies:
     babel-runtime "^6.26.0"
     esutils "^2.0.2"
@@ -2044,86 +669,21 @@
   resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.47.tgz#6d1fa44f0abec41ab7c780481e62fd9aafbdea80"
   integrity sha512-+rq2cr4GDhtToEzKFD6KZZMDBXhjFAr9JjPw9pAppZACeEWqNM294j+NdBzkSHYXwzzBmVjZ3nEVJlOhbR2gOQ==
 
-backo2@1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
-  integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
-
 balanced-match@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
-base64-arraybuffer@0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812"
-  integrity sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=
-
 base64-js@1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1"
-  integrity sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=
+  integrity sha512-hURVuTTGLOppKhjSe9lZy4NCjnvaIAF/juwazv4WtHwsk5rxKrU1WbxN+XtwKDSvkrNbIIaTBQd9wUsSwruZUg==
 
 base64-js@^1.3.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
   integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
 
-base64id@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
-  integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
-
-base@^0.11.1:
-  version "0.11.2"
-  resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
-  integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==
-  dependencies:
-    cache-base "^1.0.1"
-    class-utils "^0.3.5"
-    component-emitter "^1.2.1"
-    define-property "^1.0.0"
-    isobject "^3.0.1"
-    mixin-deep "^1.2.0"
-    pascalcase "^0.1.1"
-
-bcrypt-pbkdf@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
-  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
-  dependencies:
-    tweetnacl "^0.14.3"
-
-before-after-hook@^2.0.0:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e"
-  integrity sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==
-
-binary-extensions@^1.0.0:
-  version "1.13.1"
-  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"
-  integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==
-
-binaryextensions@^2.1.2:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.3.0.tgz#1d269cbf7e6243ea886aa41453c3651ccbe13c22"
-  integrity sha512-nAihlQsYGyc5Bwq6+EsubvANYGExeJKHDO3RjnvwU042fawQTQfM3Kxn7IHUXQOz4bzfwsGYYHGSvXyW4zOGLg==
-
-bindings@^1.5.0:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df"
-  integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==
-  dependencies:
-    file-uri-to-path "1.0.0"
-
-bl@^1.0.0:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7"
-  integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==
-  dependencies:
-    readable-stream "^2.3.5"
-    safe-buffer "^5.1.1"
-
 bl@^4.0.3:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
@@ -2133,89 +693,6 @@
     inherits "^2.0.4"
     readable-stream "^3.4.0"
 
-blob@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
-  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
-
-body-parser@1.19.0, body-parser@^1.17.2:
-  version "1.19.0"
-  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
-  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
-  dependencies:
-    bytes "3.1.0"
-    content-type "~1.0.4"
-    debug "2.6.9"
-    depd "~1.1.2"
-    http-errors "1.7.2"
-    iconv-lite "0.4.24"
-    on-finished "~2.3.0"
-    qs "6.7.0"
-    raw-body "2.4.0"
-    type-is "~1.6.17"
-
-bower-config@^1.4.0, bower-config@^1.4.1:
-  version "1.4.3"
-  resolved "https://registry.yarnpkg.com/bower-config/-/bower-config-1.4.3.tgz#3454fecdc5f08e7aa9cc6d556e492be0669689ae"
-  integrity sha512-MVyyUk3d1S7d2cl6YISViwJBc2VXCkxF5AUFykvN0PQj5FsUiMNSgAYTso18oRFfyZ6XEtjrgg9MAaufHbOwNw==
-  dependencies:
-    graceful-fs "^4.1.3"
-    minimist "^0.2.1"
-    mout "^1.0.0"
-    osenv "^0.1.3"
-    untildify "^2.1.0"
-    wordwrap "^0.0.3"
-
-bower-json@^0.8.1:
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/bower-json/-/bower-json-0.8.4.tgz#9c3b375870dcd9581350c1f403f6383dbf6a18b1"
-  integrity sha512-mMKghvq9ivbuzSsY5nrOLnDtZIJMUCpysqbGaGW3mj88JAcuSi8ZAzIt34vNZjohy0aR9VXLwgPTZGnBX2Vpjg==
-  dependencies:
-    deep-extend "^0.5.1"
-    ends-with "^0.2.0"
-    ext-list "^2.0.0"
-    graceful-fs "^4.1.3"
-    intersect "^1.0.1"
-    sort-keys-length "^1.0.0"
-
-bower-logger@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/bower-logger/-/bower-logger-0.2.2.tgz#39be07e979b2fc8e03a94634205ed9422373d381"
-  integrity sha1-Ob4H6Xmy/I4DqUY0IF7ZQiNz04E=
-
-bower@^1.8.8:
-  version "1.8.12"
-  resolved "https://registry.yarnpkg.com/bower/-/bower-1.8.12.tgz#44cfca2a5e04b8d9a066621e24c8b179d8ac321e"
-  integrity sha512-u1xy9SrwwoPlgjuHNjhV+YUPVdqyBj2ALBxuzeIUKXaPI2i2xypGgxqXkuHcITGdi5yBj5JuXgyMvgiWiS1S3Q==
-
-boxen@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-0.6.0.tgz#8364d4248ac34ff0ef1b2f2bf49a6c60ce0d81b6"
-  integrity sha1-g2TUJIrDT/DvGy8r9JpsYM4NgbY=
-  dependencies:
-    ansi-align "^1.1.0"
-    camelcase "^2.1.0"
-    chalk "^1.1.1"
-    cli-boxes "^1.0.0"
-    filled-array "^1.0.0"
-    object-assign "^4.0.1"
-    repeating "^2.0.0"
-    string-width "^1.0.1"
-    widest-line "^1.0.0"
-
-boxen@^1.2.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
-  integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==
-  dependencies:
-    ansi-align "^2.0.0"
-    camelcase "^4.0.0"
-    chalk "^2.0.1"
-    cli-boxes "^1.0.0"
-    string-width "^2.0.0"
-    term-size "^1.2.0"
-    widest-line "^2.0.0"
-
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -2224,57 +701,6 @@
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-braces@^1.8.2:
-  version "1.8.5"
-  resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
-  integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=
-  dependencies:
-    expand-range "^1.8.1"
-    preserve "^0.2.0"
-    repeat-element "^1.1.2"
-
-braces@^2.3.1:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
-  integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==
-  dependencies:
-    arr-flatten "^1.1.0"
-    array-unique "^0.3.2"
-    extend-shallow "^2.0.1"
-    fill-range "^4.0.0"
-    isobject "^3.0.1"
-    repeat-element "^1.1.2"
-    snapdragon "^0.8.1"
-    snapdragon-node "^2.0.1"
-    split-string "^3.0.2"
-    to-regex "^3.0.1"
-
-browser-capabilities@^1.0.0:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/browser-capabilities/-/browser-capabilities-1.1.4.tgz#a6bd657a07a134532ad66c722b8949904478b973"
-  integrity sha512-BezMQhbQklxjRQpZZQ8tnbzEo6AldUwMh8/PeWt5/CTBSwByQRXZEAK2fbnEahQ4poeeaI0suAYRq25A1YGOmw==
-  dependencies:
-    "@types/ua-parser-js" "^0.7.31"
-    ua-parser-js "^0.7.15"
-
-browserify-zlib@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d"
-  integrity sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=
-  dependencies:
-    pako "~0.2.0"
-
-browserslist@^4.16.6:
-  version "4.16.8"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.8.tgz#cb868b0b554f137ba6e33de0ecff2eda403c4fb0"
-  integrity sha512-sc2m9ohR/49sWEbPj14ZSSZqp+kbi16aLao42Hmn3Z8FpjuMaq2xCA2l4zl9ITfyzvnvyE0hcg62YkIGKxgaNQ==
-  dependencies:
-    caniuse-lite "^1.0.30001251"
-    colorette "^1.3.0"
-    electron-to-chromium "^1.3.811"
-    escalade "^3.1.1"
-    node-releases "^1.1.75"
-
 browserstack@^1.2.0:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.6.1.tgz#e051f9733ec3b507659f395c7a4765a1b1e358b3"
@@ -2282,40 +708,17 @@
   dependencies:
     https-proxy-agent "^2.2.1"
 
-btoa-lite@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
-  integrity sha1-M3dm2hWAEhD92VbCLpxokaudAzc=
-
-buffer-alloc-unsafe@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
-  integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
-
-buffer-alloc@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
-  integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
-  dependencies:
-    buffer-alloc-unsafe "^1.1.0"
-    buffer-fill "^1.0.0"
-
-buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
+buffer-crc32@~0.2.3:
   version "0.2.13"
   resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
-  integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
-
-buffer-fill@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
-  integrity sha1-+PeLdniYiO858gXNY39o5wISKyw=
+  integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
 
 buffer-from@^1.0.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
   integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
-buffer@^5.1.0, buffer@^5.5.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==
@@ -2324,49 +727,16 @@
     ieee754 "^1.1.13"
 
 builtin-modules@^3.1.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887"
-  integrity sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==
-
-busboy@^0.2.11:
-  version "0.2.14"
-  resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453"
-  integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=
-  dependencies:
-    dicer "0.2.5"
-    readable-stream "1.1.x"
-
-bytes@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
-  integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
-
-bytes@3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
-  integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
-
-cache-base@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
-  integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==
-  dependencies:
-    collection-visit "^1.0.0"
-    component-emitter "^1.2.1"
-    get-value "^2.0.6"
-    has-value "^1.0.0"
-    isobject "^3.0.1"
-    set-value "^2.0.0"
-    to-object-path "^0.3.0"
-    union-value "^1.0.0"
-    unset-value "^1.0.0"
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
+  integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
 
 cacheable-lookup@^5.0.3:
   version "5.0.4"
   resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005"
   integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==
 
-cacheable-request@^7.0.1:
+cacheable-request@^7.0.2:
   version "7.0.2"
   resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27"
   integrity sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==
@@ -2379,79 +749,17 @@
     normalize-url "^6.0.1"
     responselike "^2.0.0"
 
-call-bind@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
-  integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
-  dependencies:
-    function-bind "^1.1.1"
-    get-intrinsic "^1.0.2"
-
-call-me-maybe@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
-  integrity sha1-JtII6onje1y95gJQoV8DHBak1ms=
-
-camel-case@3.0.x:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
-  integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=
-  dependencies:
-    no-case "^2.2.0"
-    upper-case "^1.1.1"
-
-camelcase-keys@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
-  integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc=
-  dependencies:
-    camelcase "^2.0.0"
-    map-obj "^1.0.0"
-
-camelcase@^2.0.0, camelcase@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
-  integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
-
-camelcase@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
-  integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
-
 cancel-token@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
-  integrity sha1-wYGXZ0uxyEwdaTPr8V2NWlznm08=
+  integrity sha512-22DV8aB/ovjL6l9S+QLwFzyP5+azENgfNywoJffIE8ZNx2Nnz7UlJ0mEULTtaeuf+tmnvaUdN6WKtV1LTBlbuA==
   dependencies:
     "@types/node" "^4.0.30"
 
-caniuse-lite@^1.0.30001251:
-  version "1.0.30001252"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001252.tgz#cb16e4e3dafe948fc4a9bb3307aea054b912019a"
-  integrity sha512-I56jhWDGMtdILQORdusxBOH+Nl/KgQSdDmpJezYddnAkVOmnoU8zwjTV9xAjMIYxr0iPreEAVylCGcmHCjfaOw==
-
-capture-stack-trace@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d"
-  integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==
-
-caseless@~0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
-  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
-
-chalk@*, chalk@^4.1.0:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
-  integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
-  dependencies:
-    ansi-styles "^4.1.0"
-    supports-color "^7.1.0"
-
-chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
+chalk@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
-  integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=
+  integrity sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==
   dependencies:
     ansi-styles "^2.2.1"
     escape-string-regexp "^1.0.2"
@@ -2459,7 +767,7 @@
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
-chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2:
+chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -2468,246 +776,44 @@
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@~0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f"
-  integrity sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=
-  dependencies:
-    ansi-styles "~1.0.0"
-    has-color "~0.1.0"
-    strip-ansi "~0.1.0"
-
-chardet@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
-  integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
-
-charenc@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
-  integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=
-
-chokidar@^1.7.0:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
-  integrity sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=
-  dependencies:
-    anymatch "^1.3.0"
-    async-each "^1.0.0"
-    glob-parent "^2.0.0"
-    inherits "^2.0.1"
-    is-binary-path "^1.0.0"
-    is-glob "^2.0.0"
-    path-is-absolute "^1.0.0"
-    readdirp "^2.0.0"
-  optionalDependencies:
-    fsevents "^1.0.0"
-
-chownr@^1.0.1:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
-  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
-
-ci-info@^1.5.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
-  integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==
-
-class-utils@^0.3.5:
-  version "0.3.6"
-  resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
-  integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==
-  dependencies:
-    arr-union "^3.1.0"
-    define-property "^0.2.5"
-    isobject "^3.0.0"
-    static-extend "^0.1.1"
-
-clean-css@4.2.x:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
-  integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==
-  dependencies:
-    source-map "~0.6.0"
-
 cleankill@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/cleankill/-/cleankill-2.0.0.tgz#59830dfc8b411d53dc72ad09d45a78ea33161a91"
-  integrity sha1-WYMN/ItBHVPccq0J1Fp46jMWGpE=
-
-cli-boxes@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
-  integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
-
-cli-cursor@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
-  integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=
-  dependencies:
-    restore-cursor "^1.0.1"
-
-cli-cursor@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
-  integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
-  dependencies:
-    restore-cursor "^3.1.0"
-
-cli-table@^0.3.1:
-  version "0.3.6"
-  resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.6.tgz#e9d6aa859c7fe636981fd3787378c2a20bce92fc"
-  integrity sha512-ZkNZbnZjKERTY5NwC2SeMeLeifSPq/pubeRoTpdr3WchLlnZg6hEgvHkK5zL7KNFdd9PmHN8lxrENUwI3cE8vQ==
-  dependencies:
-    colors "1.0.3"
-
-cli-width@^2.0.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48"
-  integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==
-
-cli-width@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6"
-  integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==
-
-clone-buffer@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
-  integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
-
-clone-deep@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
-  integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==
-  dependencies:
-    is-plain-object "^2.0.4"
-    kind-of "^6.0.2"
-    shallow-clone "^3.0.0"
+  integrity sha512-qj/ZY1wjON/36bsk3cF5WtXnrxUgWqc5PCN78LsOpjIk0Dka0lPqbhu9FVk4Yy4N3VuDA8VhlcgBLWC5L+tGHg==
 
 clone-response@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
-  integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=
+  integrity sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==
   dependencies:
     mimic-response "^1.0.0"
 
-clone-stats@^0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
-  integrity sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE=
-
-clone-stats@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680"
-  integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA=
-
-clone@^1.0.0, clone@^1.0.2:
+clone@^1.0.2:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
-  integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
+  integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
 
-clone@^2.0.0, clone@^2.1.0, clone@^2.1.1:
+clone@^2.0.0, clone@^2.1.0:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
-  integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
+  integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
 
-cloneable-readable@^1.0.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec"
-  integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==
-  dependencies:
-    inherits "^2.0.1"
-    process-nextick-args "^2.0.0"
-    readable-stream "^2.3.5"
-
-code-point-at@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
-  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
-
-collection-visit@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
-  integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=
-  dependencies:
-    map-visit "^1.0.0"
-    object-visit "^1.0.0"
-
-color-convert@^1.9.0, color-convert@^1.9.1:
+color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
   integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
   dependencies:
     color-name "1.1.3"
 
-color-convert@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.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.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"
-  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
-
-color-string@^1.5.2:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312"
-  integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==
-  dependencies:
-    color-name "^1.0.0"
-    simple-swizzle "^0.2.2"
-
-color@3.0.x:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a"
-  integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==
-  dependencies:
-    color-convert "^1.9.1"
-    color-string "^1.5.2"
-
-colorette@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.3.0.tgz#ff45d2f0edb244069d3b772adeb04fed38d0a0af"
-  integrity sha512-ecORCqbSFP7Wm8Y6lyqMJjexBQqXSF7SSeaTyGGphogUjBlFP9m9o08wy86HL2uB7fMTxtOUzLMk7ogKcxMg1w==
-
-colors@1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
-  integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=
-
-colors@^1.2.1:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
-  integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
-
-colorspace@1.1.x:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5"
-  integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==
-  dependencies:
-    color "3.0.x"
-    text-hex "1.0.x"
-
-combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
-  version "1.0.8"
-  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
-  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
-  dependencies:
-    delayed-stream "~1.0.0"
+  integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
 
 command-line-args@^3.0.1:
   version "3.0.5"
   resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-3.0.5.tgz#5bd4ad45e7983e5c1344918e40280ee2693c5ac0"
-  integrity sha1-W9StReeYPlwTRJGOQCgO4mk8WsA=
+  integrity sha512-M29kjOI24VF4HqatnqVyDqyeq3SYYZbq6LWv/AdVZ5LvrcqVNSN2XeYPrBxcO19T8YkGmyCqTUqYR07DFjVhyg==
   dependencies:
     array-back "^1.0.4"
     feature-detect-es6 "^1.3.1"
@@ -2715,26 +821,19 @@
     typical "^2.6.0"
 
 command-line-args@^5.0.2:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.0.tgz#087b02748272169741f1fd7c785b295df079b9be"
-  integrity sha512-4zqtU1hYsSJzcJBOcNZIbW5Fbk9BkjCp1pZVhQKoRaWL5J7N4XphDLwo8aWwdQpTugxwu+jf9u2ZhkXiqp5Z6A==
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+  integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
   dependencies:
     array-back "^3.1.0"
     find-replace "^3.0.0"
     lodash.camelcase "^4.3.0"
     typical "^4.0.0"
 
-command-line-commands@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/command-line-commands/-/command-line-commands-2.0.1.tgz#c58aa13dc78c06038ed67077e57ad09a6f858f46"
-  integrity sha512-m8c2p1DrNd2ruIAggxd/y6DgygQayf6r8RHwchhXryaLF8I6koYjoYroVP+emeROE9DXN5b9sP1Gh+WtvTTdtQ==
-  dependencies:
-    array-back "^2.0.0"
-
 command-line-usage@^3.0.8:
   version "3.0.8"
   resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-3.0.8.tgz#b6a20978c1b383477f5c11a529428b880bfe0f4d"
-  integrity sha1-tqIJeMGzg0d/XBGlKUKLiAv+D00=
+  integrity sha512-KMWPF8wNWa+wzffE9247hlDB1c9DMMxhwIFzwRn7oNv5CU7auuJ3zKWv756F/9qqlEucC5jI8/3S8qdGKdVelw==
   dependencies:
     ansi-escape-sequences "^3.0.0"
     array-back "^1.0.3"
@@ -2752,202 +851,29 @@
     table-layout "^0.4.3"
     typical "^2.6.1"
 
-commander@2.17.x:
-  version "2.17.1"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
-  integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
-
 commander@^2.20.0, commander@^2.20.3:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
 
-commander@~2.19.0:
-  version "2.19.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
-  integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==
-
-commondir@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
-  integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
-
-component-bind@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
-  integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=
-
-component-emitter@1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
-  integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
-
-component-emitter@^1.2.1, component-emitter@~1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
-  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
-
-component-inherit@0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
-  integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
-
-compress-commons@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-2.1.1.tgz#9410d9a534cf8435e3fbbb7c6ce48de2dc2f0610"
-  integrity sha512-eVw6n7CnEMFzc3duyFVrQEuY1BlHR3rYsSztyG32ibGMW722i3C6IizEGMFmfMU+A+fALvBIwxN3czffTcdA+Q==
+compress-brotli@^1.3.8:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/compress-brotli/-/compress-brotli-1.3.8.tgz#0c0a60c97a989145314ec381e84e26682e7b38db"
+  integrity sha512-lVcQsjhxhIXsuupfy9fmZUFtAIdBmXA7EGY6GBdgZ++qkM9zG4YFT8iU7FoBxzryNDMOpD1HIFHUSX4D87oqhQ==
   dependencies:
-    buffer-crc32 "^0.2.13"
-    crc32-stream "^3.0.1"
-    normalize-path "^3.0.0"
-    readable-stream "^2.3.6"
-
-compressible@~2.0.16:
-  version "2.0.18"
-  resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
-  integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
-  dependencies:
-    mime-db ">= 1.43.0 < 2"
-
-compression@^1.6.2:
-  version "1.7.4"
-  resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f"
-  integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==
-  dependencies:
-    accepts "~1.3.5"
-    bytes "3.0.0"
-    compressible "~2.0.16"
-    debug "2.6.9"
-    on-headers "~1.0.2"
-    safe-buffer "5.1.2"
-    vary "~1.1.2"
+    "@types/json-buffer" "~3.0.0"
+    json-buffer "~3.0.1"
 
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
-  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
-
-concat-stream@^1.4.7, concat-stream@^1.5.2:
-  version "1.6.2"
-  resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
-  integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
-  dependencies:
-    buffer-from "^1.0.0"
-    inherits "^2.0.3"
-    readable-stream "^2.2.2"
-    typedarray "^0.0.6"
-
-configstore@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-2.1.0.tgz#737a3a7036e9886102aa6099e47bb33ab1aba1a1"
-  integrity sha1-c3o6cDbpiGECqmCZ5HuzOrGroaE=
-  dependencies:
-    dot-prop "^3.0.0"
-    graceful-fs "^4.1.2"
-    mkdirp "^0.5.0"
-    object-assign "^4.0.1"
-    os-tmpdir "^1.0.0"
-    osenv "^0.1.0"
-    uuid "^2.0.1"
-    write-file-atomic "^1.1.2"
-    xdg-basedir "^2.0.0"
-
-configstore@^3.0.0:
-  version "3.1.5"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.5.tgz#e9af331fadc14dabd544d3e7e76dc446a09a530f"
-  integrity sha512-nlOhI4+fdzoK5xmJ+NY+1gZK56bwEaWZr8fYuXohZ9Vkc1o3a4T/R3M+yE/w7x/ZVJ1zF8c+oaOvF0dztdUgmA==
-  dependencies:
-    dot-prop "^4.2.1"
-    graceful-fs "^4.1.2"
-    make-dir "^1.0.0"
-    unique-string "^1.0.0"
-    write-file-atomic "^2.0.0"
-    xdg-basedir "^3.0.0"
-
-content-disposition@0.5.3:
-  version "0.5.3"
-  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
-  integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
-  dependencies:
-    safe-buffer "5.1.2"
-
-content-type@^1.0.2, content-type@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
-  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
-
-convert-source-map@^1.1.1, convert-source-map@^1.7.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
-  integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
-  dependencies:
-    safe-buffer "~5.1.1"
-
-cookie-signature@1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
-  integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
-
-cookie@0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
-  integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
-
-cookie@~0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
-  integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
-
-copy-descriptor@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
-  integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
+  integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
 
 core-js@^2.4.0, core-js@^2.4.1:
   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:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
-  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
-
-core-util-is@~1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
-  integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
-
-cors@^2.8.4:
-  version "2.8.5"
-  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
-  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
-  dependencies:
-    object-assign "^4"
-    vary "^1"
-
-crc32-stream@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-3.0.1.tgz#cae6eeed003b0e44d739d279de5ae63b171b4e85"
-  integrity sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==
-  dependencies:
-    crc "^3.4.4"
-    readable-stream "^3.4.0"
-
-crc@^3.4.4:
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6"
-  integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==
-  dependencies:
-    buffer "^5.1.0"
-
-create-error-class@^3.0.0, create-error-class@^3.0.1:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6"
-  integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=
-  dependencies:
-    capture-stack-trace "^1.0.0"
-
 crisper@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/crisper/-/crisper-2.1.1.tgz#4cc7321c3e90f3c5cbdc3503217f118fd7d5c51c"
@@ -2957,27 +883,7 @@
     command-line-usage "^3.0.8"
     dom5 "^1.0.1"
 
-cross-spawn@^5.0.1:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
-  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
-  dependencies:
-    lru-cache "^4.0.1"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
-
-cross-spawn@^6.0.0, cross-spawn@^6.0.5:
-  version "6.0.5"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
-  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
-  dependencies:
-    nice-try "^1.0.4"
-    path-key "^2.0.1"
-    semver "^5.5.0"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
-
-cross-spawn@^7.0.0, cross-spawn@^7.0.3:
+cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
   integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
@@ -2986,75 +892,18 @@
     shebang-command "^2.0.0"
     which "^2.0.1"
 
-crypt@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
-  integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=
-
-crypto-random-string@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
-  integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
-
-css-slam@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/css-slam/-/css-slam-2.1.2.tgz#3d35b1922cb3e0002a45c89ab189492508c493e5"
-  integrity sha512-cObrY+mhFEmepWpua6EpMrgRNTQ0eeym+kvR0lukI6hDEzK7F8himEDS4cJ9+fPHCoArTzVrrR0d+oAUbTR1NA==
-  dependencies:
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    dom5 "^3.0.0"
-    parse5 "^4.0.0"
-    shady-css-parser "^0.1.0"
-
-css-what@^2.1.0:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
-  integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
-
 cssbeautify@^0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/cssbeautify/-/cssbeautify-0.3.1.tgz#12dd1f734035c2e6faca67dcbdcef74e42811397"
-  integrity sha1-Et0fc0A1wub6ymfcvc73TkKBE5c=
+  integrity sha512-ljnSOCOiMbklF+dwPbpooyB78foId02vUrTDogWzu6ca2DCNB7Kc/BHEGBnYOlUYtwXvSW0mWTwaiO2pwFIoRg==
 
-currently-unhandled@^0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
-  integrity sha1-mI3zP+qxke95mmE2nddsF635V+o=
-  dependencies:
-    array-find-index "^1.0.1"
-
-dargs@^6.0.0, dargs@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/dargs/-/dargs-6.1.0.tgz#1f3b9b56393ecf8caa7cbfd6c31496ffcfb9b272"
-  integrity sha512-5dVBvpBLBnPwSsYXqfybFyehMmC/EenKEcf23AhCTgTf48JFBbmJKqoZBsERDnjL0FyiVTYWdFsRfTLHxLyKdQ==
-
-dashdash@^1.12.0:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
-  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
-  dependencies:
-    assert-plus "^1.0.0"
-
-dateformat@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
-  integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
-
-debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8:
+debug@^2.2.0, debug@^2.6.8:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
   dependencies:
     ms "2.0.0"
 
-debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
-  integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
-  dependencies:
-    ms "2.1.2"
-
 debug@^3.1.0:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
@@ -3062,36 +911,12 @@
   dependencies:
     ms "^2.1.1"
 
-debug@~3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
-  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
+debug@^4.1.0, debug@^4.3.1:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
   dependencies:
-    ms "2.0.0"
-
-debug@~4.1.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
-  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
-  dependencies:
-    ms "^2.1.1"
-
-decamelize@^1.1.2:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
-  integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
-
-decode-uri-component@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
-  integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
-
-decompress-response@^3.2.0:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
-  integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=
-  dependencies:
-    mimic-response "^1.0.0"
+    ms "2.1.2"
 
 decompress-response@^6.0.0:
   version "6.0.0"
@@ -3100,154 +925,28 @@
   dependencies:
     mimic-response "^3.1.0"
 
-deep-extend@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.5.1.tgz#b894a9dd90d3023fbf1c55a394fb858eb2066f1f"
-  integrity sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==
-
-deep-extend@^0.6.0, deep-extend@~0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
-  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
-
 deep-extend@~0.4.1:
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
-  integrity sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=
+  integrity sha512-cQ0iXSEKi3JRNhjUsLWvQ+MVPxLVqpwCd0cFsWbJxlCim2TlCo1JvN5WaPdPvSpUdEnkJ/X+mPGcq5RJ68EK8g==
+
+deep-extend@~0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
 
 defer-to-connect@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587"
   integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==
 
-define-properties@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
-  integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
-  dependencies:
-    object-keys "^1.0.12"
-
-define-property@^0.2.5:
-  version "0.2.5"
-  resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
-  integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=
-  dependencies:
-    is-descriptor "^0.1.0"
-
-define-property@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6"
-  integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY=
-  dependencies:
-    is-descriptor "^1.0.0"
-
-define-property@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d"
-  integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==
-  dependencies:
-    is-descriptor "^1.0.2"
-    isobject "^3.0.1"
-
-del@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5"
-  integrity sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=
-  dependencies:
-    globby "^6.1.0"
-    is-path-cwd "^1.0.0"
-    is-path-in-cwd "^1.0.0"
-    p-map "^1.1.1"
-    pify "^3.0.0"
-    rimraf "^2.2.8"
-
-delayed-stream@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
-  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
-
-depd@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
-  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
-
-deprecation@^2.0.0, deprecation@^2.3.1:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
-  integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
-
-destroy@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
-  integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
-
-detect-conflict@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/detect-conflict/-/detect-conflict-1.0.1.tgz#088657a66a961c05019db7c4230883b1c6b4176e"
-  integrity sha1-CIZXpmqWHAUBnbfEIwiDsca0F24=
-
-detect-file@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-0.1.0.tgz#4935dedfd9488648e006b0129566e9386711ea63"
-  integrity sha1-STXe39lIhkjgBrASlWbpOGcR6mM=
-  dependencies:
-    fs-exists-sync "^0.1.0"
-
-detect-file@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
-  integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
-
 detect-indent@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
-  integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg=
+  integrity sha512-BDKtmHlOzwI7iRuEkhzsnPoi5ypEhWAJB5RvHWe1kMr06js3uK5B3734i3ui5Yd+wOJV1cpE4JnivPD283GU/A==
   dependencies:
     repeating "^2.0.0"
 
-detect-node@^2.0.3:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
-  integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
-
-dicer@0.2.5:
-  version "0.2.5"
-  resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f"
-  integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=
-  dependencies:
-    readable-stream "1.1.x"
-    streamsearch "0.1.2"
-
-diff@^2.1.2:
-  version "2.2.3"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-2.2.3.tgz#60eafd0d28ee906e4e8ff0a52c1229521033bf99"
-  integrity sha1-YOr9DSjukG5Oj/ClLBIpUhAzv5k=
-
-diff@^3.1.0, diff@^3.5.0:
-  version "3.5.0"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
-  integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
-
-diff@^4.0.1:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
-  integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
-
-dir-glob@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034"
-  integrity sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag==
-  dependencies:
-    arrify "^1.0.1"
-    path-type "^3.0.0"
-
-dir-glob@^2.2.2:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.2.2.tgz#fa09f0694153c8918b18ba0deafae94769fc50c4"
-  integrity sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==
-  dependencies:
-    path-type "^3.0.0"
-
 doctrine@^2.0.2:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
@@ -3255,17 +954,10 @@
   dependencies:
     esutils "^2.0.2"
 
-dom-urls@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/dom-urls/-/dom-urls-1.1.0.tgz#001ddf81628cd1e706125c7176f53ccec55d918e"
-  integrity sha1-AB3fgWKM0ecGElxxdvU8zsVdkY4=
-  dependencies:
-    urijs "^1.16.1"
-
 dom5@^1.0.1:
   version "1.3.6"
   resolved "https://registry.yarnpkg.com/dom5/-/dom5-1.3.6.tgz#a7088a9fc5f3b08dc9f6eda4c7abaeb241945e0d"
-  integrity sha1-pwiKn8XzsI3J9u2kx6uuskGUXg0=
+  integrity sha512-mcW8C3hP6NR7PD2mpa6cLihu0ToVrsloG69a/4vZ8lbKrAApEVJi99O2vqd5G1gfnvmLHbGSo/LdHbWBwdF4Rw==
   dependencies:
     "@types/clone" "^0.1.29"
     "@types/node" "^4.0.30"
@@ -3282,181 +974,14 @@
     clone "^2.1.0"
     parse5 "^4.0.0"
 
-dot-prop@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-3.0.0.tgz#1b708af094a49c9a0e7dbcad790aba539dac1177"
-  integrity sha1-G3CK8JSknJoOfbyteQq6U52sEXc=
-  dependencies:
-    is-obj "^1.0.0"
-
-dot-prop@^4.2.1:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.1.tgz#45884194a71fc2cda71cbb4bceb3a4dd2f433ba4"
-  integrity sha512-l0p4+mIuJIua0mhxGoh4a+iNL9bmeK5DvnSVQa6T0OhrVmaEa1XScX5Etc673FePCJOArq/4Pa2cLGODUWTPOQ==
-  dependencies:
-    is-obj "^1.0.0"
-
-download-stats@^0.3.4:
-  version "0.3.4"
-  resolved "https://registry.yarnpkg.com/download-stats/-/download-stats-0.3.4.tgz#67ea0c32f14acd9f639da704eef509684ba2dae7"
-  integrity sha512-ic2BigbyUWx7/CBbsfGjf71zUNZB4edBGC3oRliSzsoNmvyVx3Ycfp1w3vp2Y78Ee0eIIkjIEO5KzW0zThDGaA==
-  dependencies:
-    JSONStream "^1.2.1"
-    lazy-cache "^2.0.1"
-    moment "^2.15.1"
-
-duplexer2@^0.1.2, duplexer2@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
-  integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
-  dependencies:
-    readable-stream "^2.0.2"
-
-duplexer3@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
-  integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
-
-duplexify@^3.2.0, duplexify@^3.5.0, duplexify@^3.6.0:
-  version "3.7.1"
-  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
-  integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==
-  dependencies:
-    end-of-stream "^1.0.0"
-    inherits "^2.0.1"
-    readable-stream "^2.0.0"
-    stream-shift "^1.0.0"
-
-ecc-jsbn@~0.1.1:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
-  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
-  dependencies:
-    jsbn "~0.1.0"
-    safer-buffer "^2.1.0"
-
-editions@^2.2.0:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/editions/-/editions-2.3.1.tgz#3bc9962f1978e801312fbd0aebfed63b49bfe698"
-  integrity sha512-ptGvkwTvGdGfC0hfhKg0MT+TRLRKGtUiWGBInxOm5pz7ssADezahjCUaYuZ8Dr+C05FW0AECIIPt4WBxVINEhA==
-  dependencies:
-    errlop "^2.0.0"
-    semver "^6.3.0"
-
-ee-first@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
-  integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
-
-ejs@^2.5.9, ejs@^2.6.1:
-  version "2.7.4"
-  resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba"
-  integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==
-
-ejs@^3.1.5:
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a"
-  integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==
-  dependencies:
-    jake "^10.6.1"
-
-electron-to-chromium@^1.3.811:
-  version "1.3.826"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.826.tgz#dbe356b1546b39d83bcd47e675a9c5f61dadaed2"
-  integrity sha512-bpLc4QU4B8PYmdO4MSu2ZBTMD8lAaEXRS43C09lB31BvYwuk9UxgBRXbY5OJBw7VuMGcg2MZG5FyTaP9u4PQnw==
-
-emitter-component@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6"
-  integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=
-
-emoji-regex@^8.0.0:
-  version "8.0.0"
-  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
-  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
-
-enabled@2.0.x:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2"
-  integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==
-
-encodeurl@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
-  integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
-
-end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1:
+end-of-stream@^1.1.0, 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"
   integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
   dependencies:
     once "^1.4.0"
 
-ends-with@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a"
-  integrity sha1-L52pjVelDP2kVxzkM5AAUA9Oa4o=
-
-engine.io-client@~3.5.0:
-  version "3.5.2"
-  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.5.2.tgz#0ef473621294004e9ceebe73cef0af9e36f2f5fa"
-  integrity sha512-QEqIp+gJ/kMHeUun7f5Vv3bteRHppHH/FMBQX/esFj/fuYfjyUKWGMo3VCvIP/V8bE9KcjHmRZrhIz2Z9oNsDA==
-  dependencies:
-    component-emitter "~1.3.0"
-    component-inherit "0.0.3"
-    debug "~3.1.0"
-    engine.io-parser "~2.2.0"
-    has-cors "1.1.0"
-    indexof "0.0.1"
-    parseqs "0.0.6"
-    parseuri "0.0.6"
-    ws "~7.4.2"
-    xmlhttprequest-ssl "~1.6.2"
-    yeast "0.1.2"
-
-engine.io-parser@~2.2.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.1.tgz#57ce5611d9370ee94f99641b589f94c97e4f5da7"
-  integrity sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==
-  dependencies:
-    after "0.8.2"
-    arraybuffer.slice "~0.0.7"
-    base64-arraybuffer "0.1.4"
-    blob "0.0.5"
-    has-binary2 "~1.0.2"
-
-engine.io@~3.5.0:
-  version "3.5.0"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.5.0.tgz#9d6b985c8a39b1fe87cd91eb014de0552259821b"
-  integrity sha512-21HlvPUKaitDGE4GXNtQ7PLP0Sz4aWLddMPw2VTyFz1FVZqu/kZsJUO8WNpKuE/OCL7nkfRaOui2ZCJloGznGA==
-  dependencies:
-    accepts "~1.3.4"
-    base64id "2.0.0"
-    cookie "~0.4.1"
-    debug "~4.1.0"
-    engine.io-parser "~2.2.0"
-    ws "~7.4.2"
-
-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"
-  integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
-  dependencies:
-    is-arrayish "^0.2.1"
-
-error@^7.0.2:
-  version "7.2.1"
-  resolved "https://registry.yarnpkg.com/error/-/error-7.2.1.tgz#eab21a4689b5f684fc83da84a0e390de82d94894"
-  integrity sha512-fo9HBvWnx3NGUKMvMwB/CBCMMrfEJgbDTVDEkPygA3Bdd3lM1OyCd+rbQ8BwnpF6GdVeOLDNmyL4N5Bg80ZvdA==
-  dependencies:
-    string-template "~0.2.1"
-
-es6-promise@^4.0.3, es6-promise@^4.0.5:
+es6-promise@^4.0.3:
   version "4.2.8"
   resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
   integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
@@ -3464,29 +989,19 @@
 es6-promisify@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
-  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
+  integrity sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==
   dependencies:
     es6-promise "^4.0.3"
 
-es6-promisify@^6.0.0:
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.1.1.tgz#46837651b7b06bf6fff893d03f29393668d01621"
-  integrity sha512-HBL8I3mIki5C1Cc9QjKUenHtnG0A5/xA8Q/AllRcfiwl2CZFXGK7ddBiCoRwAix4i2KxcQfjtIVcrVbB3vbmwg==
-
-escalade@^3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
-  integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
-
-escape-html@^1.0.3, escape-html@~1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
-  integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
-
-escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.4, escape-string-regexp@^1.0.5:
+escape-string-regexp@^1.0.2, 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=
+  integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
+
+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==
 
 espree@^3.5.2:
   version "3.5.4"
@@ -3501,256 +1016,20 @@
   resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362"
   integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==
 
+estree-walker@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
+  integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
+
 esutils@^2.0.2:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
-etag@~1.8.1:
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
-  integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
-
-eventemitter3@^4.0.0:
-  version "4.0.7"
-  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
-  integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
-
-execa@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
-  integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=
-  dependencies:
-    cross-spawn "^5.0.1"
-    get-stream "^3.0.0"
-    is-stream "^1.1.0"
-    npm-run-path "^2.0.0"
-    p-finally "^1.0.0"
-    signal-exit "^3.0.0"
-    strip-eof "^1.0.0"
-
-execa@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8"
-  integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==
-  dependencies:
-    cross-spawn "^6.0.0"
-    get-stream "^4.0.0"
-    is-stream "^1.1.0"
-    npm-run-path "^2.0.0"
-    p-finally "^1.0.0"
-    signal-exit "^3.0.0"
-    strip-eof "^1.0.0"
-
-execa@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a"
-  integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==
-  dependencies:
-    cross-spawn "^7.0.0"
-    get-stream "^5.0.0"
-    human-signals "^1.1.1"
-    is-stream "^2.0.0"
-    merge-stream "^2.0.0"
-    npm-run-path "^4.0.0"
-    onetime "^5.1.0"
-    signal-exit "^3.0.2"
-    strip-final-newline "^2.0.0"
-
-exit-hook@^1.0.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
-  integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=
-
-expand-brackets@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
-  integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=
-  dependencies:
-    is-posix-bracket "^0.1.0"
-
-expand-brackets@^2.1.4:
-  version "2.1.4"
-  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
-  integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI=
-  dependencies:
-    debug "^2.3.3"
-    define-property "^0.2.5"
-    extend-shallow "^2.0.1"
-    posix-character-classes "^0.1.0"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.1"
-
-expand-range@^1.8.1:
-  version "1.8.2"
-  resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
-  integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=
-  dependencies:
-    fill-range "^2.1.0"
-
-expand-tilde@^1.2.2:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449"
-  integrity sha1-C4HrqJflo9MdHD0QL48BRB5VlEk=
-  dependencies:
-    os-homedir "^1.0.1"
-
-expand-tilde@^2.0.0, expand-tilde@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
-  integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=
-  dependencies:
-    homedir-polyfill "^1.0.1"
-
-express@^4.15.3, express@^4.8.5:
-  version "4.17.1"
-  resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
-  integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
-  dependencies:
-    accepts "~1.3.7"
-    array-flatten "1.1.1"
-    body-parser "1.19.0"
-    content-disposition "0.5.3"
-    content-type "~1.0.4"
-    cookie "0.4.0"
-    cookie-signature "1.0.6"
-    debug "2.6.9"
-    depd "~1.1.2"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    finalhandler "~1.1.2"
-    fresh "0.5.2"
-    merge-descriptors "1.0.1"
-    methods "~1.1.2"
-    on-finished "~2.3.0"
-    parseurl "~1.3.3"
-    path-to-regexp "0.1.7"
-    proxy-addr "~2.0.5"
-    qs "6.7.0"
-    range-parser "~1.2.1"
-    safe-buffer "5.1.2"
-    send "0.17.1"
-    serve-static "1.14.1"
-    setprototypeof "1.1.1"
-    statuses "~1.5.0"
-    type-is "~1.6.18"
-    utils-merge "1.0.1"
-    vary "~1.1.2"
-
-ext-list@^2.0.0:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/ext-list/-/ext-list-2.2.2.tgz#0b98e64ed82f5acf0f2931babf69212ef52ddd37"
-  integrity sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==
-  dependencies:
-    mime-db "^1.28.0"
-
-extend-shallow@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
-  integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=
-  dependencies:
-    is-extendable "^0.1.0"
-
-extend-shallow@^3.0.0, extend-shallow@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8"
-  integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=
-  dependencies:
-    assign-symbols "^1.0.0"
-    is-extendable "^1.0.1"
-
-extend@^3.0.0, extend@~3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
-  integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
-
-external-editor@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-1.1.1.tgz#12d7b0db850f7ff7e7081baf4005700060c4600b"
-  integrity sha1-Etew24UPf/fnCBuvQAVwAGDEYAs=
-  dependencies:
-    extend "^3.0.0"
-    spawn-sync "^1.0.15"
-    tmp "^0.0.29"
-
-external-editor@^3.0.3:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495"
-  integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==
-  dependencies:
-    chardet "^0.7.0"
-    iconv-lite "^0.4.24"
-    tmp "^0.0.33"
-
-extglob@^0.3.1:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
-  integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=
-  dependencies:
-    is-extglob "^1.0.0"
-
-extglob@^2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
-  integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==
-  dependencies:
-    array-unique "^0.3.2"
-    define-property "^1.0.0"
-    expand-brackets "^2.1.4"
-    extend-shallow "^2.0.1"
-    fragment-cache "^0.2.1"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.1"
-
-extsprintf@1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
-  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
-
-extsprintf@^1.2.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
-  integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
-
-fast-deep-equal@^3.1.1:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
-  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
-
-fast-glob@^2.0.2, fast-glob@^2.2.6:
-  version "2.2.7"
-  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d"
-  integrity sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==
-  dependencies:
-    "@mrmlnc/readdir-enhanced" "^2.2.1"
-    "@nodelib/fs.stat" "^1.1.2"
-    glob-parent "^3.1.0"
-    is-glob "^4.0.0"
-    merge2 "^1.2.3"
-    micromatch "^3.1.10"
-
-fast-json-stable-stringify@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
-  integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
-
-fast-levenshtein@^2.0.6:
-  version "2.0.6"
-  resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
-  integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
-
-fast-safe-stringify@^2.0.4:
-  version "2.0.8"
-  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.8.tgz#dc2af48c46cf712b683e849b2bbd446b32de936f"
-  integrity sha512-lXatBjf3WPjmWD6DpIZxkeSsCOwqI0maYMpgDlx8g4U2qi4lbjA9oH/HD2a87G+KfsUmo5WbJFmqBZlPxtptag==
-
 fd-slicer@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
-  integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=
+  integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==
   dependencies:
     pend "~1.2.0"
 
@@ -3761,98 +1040,10 @@
   dependencies:
     array-back "^1.0.4"
 
-fecha@^2.3.3:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd"
-  integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==
-
-fecha@^4.2.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.1.tgz#0a83ad8f86ef62a091e22bb5a039cd03d23eecce"
-  integrity sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q==
-
-figures@^1.3.5:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
-  integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=
-  dependencies:
-    escape-string-regexp "^1.0.5"
-    object-assign "^4.1.0"
-
-figures@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"
-  integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==
-  dependencies:
-    escape-string-regexp "^1.0.5"
-
-file-uri-to-path@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
-  integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
-
-filelist@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b"
-  integrity sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==
-  dependencies:
-    minimatch "^3.0.4"
-
-filename-regex@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
-  integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=
-
-fill-range@^2.1.0:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
-  integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==
-  dependencies:
-    is-number "^2.1.0"
-    isobject "^2.0.0"
-    randomatic "^3.0.0"
-    repeat-element "^1.1.2"
-    repeat-string "^1.5.2"
-
-fill-range@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
-  integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=
-  dependencies:
-    extend-shallow "^2.0.1"
-    is-number "^3.0.0"
-    repeat-string "^1.6.1"
-    to-regex-range "^2.1.0"
-
-filled-array@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/filled-array/-/filled-array-1.1.0.tgz#c3c4f6c663b923459a9aa29912d2d031f1507f84"
-  integrity sha1-w8T2xmO5I0WamqKZEtLQMfFQf4Q=
-
-finalhandler@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
-  integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
-  dependencies:
-    debug "2.6.9"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    on-finished "~2.3.0"
-    parseurl "~1.3.3"
-    statuses "~1.5.0"
-    unpipe "~1.0.0"
-
-find-port@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/find-port/-/find-port-1.0.1.tgz#db084a6cbf99564d99869ae79fbdecf66e8a185c"
-  integrity sha1-2whKbL+ZVk2Zhprnn73s9m6KGFw=
-  dependencies:
-    async "~0.2.9"
-
 find-replace@^1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-1.0.3.tgz#b88e7364d2d9c959559f388c66670d6130441fa0"
-  integrity sha1-uI5zZNLZyVlVnziMZmcNYTBEH6A=
+  integrity sha512-KrUnjzDCD9426YnCP56zGYy/eieTnhtK6Vn++j+JJzmlsWWwEkDnsyVF575spT6HJ6Ow9tlbT3TQTDsa+O4UWA==
   dependencies:
     array-back "^1.0.4"
     test-value "^2.1.0"
@@ -3864,154 +1055,20 @@
   dependencies:
     array-back "^3.0.1"
 
-find-up@^1.0.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
-  integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=
-  dependencies:
-    path-exists "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-find-up@^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"
-
-findup-sync@^0.4.2:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.4.3.tgz#40043929e7bc60adf0b7f4827c4c6e75a0deca12"
-  integrity sha1-QAQ5Kee8YK3wt/SCfExudaDeyhI=
-  dependencies:
-    detect-file "^0.1.0"
-    is-glob "^2.0.1"
-    micromatch "^2.3.7"
-    resolve-dir "^0.1.0"
-
-findup-sync@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc"
-  integrity sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=
-  dependencies:
-    detect-file "^1.0.0"
-    is-glob "^3.1.0"
-    micromatch "^3.0.4"
-    resolve-dir "^1.0.1"
-
-first-chunk-stream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e"
-  integrity sha1-Wb+1DNkF9g18OUzT2ayqtOatk04=
-
-first-chunk-stream@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz#1bdecdb8e083c0664b91945581577a43a9f31d70"
-  integrity sha1-G97NuOCDwGZLkZRVgVd6Q6nzHXA=
-  dependencies:
-    readable-stream "^2.0.2"
-
-fn.name@1.x.x:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc"
-  integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==
-
-follow-redirects@^1.0.0, follow-redirects@^1.10.0:
-  version "1.14.2"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.2.tgz#cecb825047c00f5e66b142f90fed4f515dec789b"
-  integrity sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA==
-
-for-in@^1.0.1, for-in@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
-  integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
-
-for-own@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
-  integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=
-  dependencies:
-    for-in "^1.0.1"
-
-forever-agent@~0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
-  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
-
-fork-stream@^0.0.4:
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/fork-stream/-/fork-stream-0.0.4.tgz#db849fce77f6708a5f8f386ae533a0907b54ae70"
-  integrity sha1-24Sfznf2cIpfjzhq5TOgkHtUrnA=
-
-form-data@*:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
-  integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
-  dependencies:
-    asynckit "^0.4.0"
-    combined-stream "^1.0.8"
-    mime-types "^2.1.12"
-
-form-data@~2.3.2:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
-  integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
-  dependencies:
-    asynckit "^0.4.0"
-    combined-stream "^1.0.6"
-    mime-types "^2.1.12"
-
-formatio@1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb"
-  integrity sha1-87IWfZBoxGmKjVH092CjmlTYGOs=
-  dependencies:
-    samsam "1.x"
-
-forwarded@0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
-  integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
-
-fragment-cache@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
-  integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=
-  dependencies:
-    map-cache "^0.2.2"
-
 freeport@^1.0.4:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/freeport/-/freeport-1.0.5.tgz#255e8ab84170c33ba85d990e821ae5f4a1a9bc5d"
-  integrity sha1-JV6KuEFwwzuoXZkOghrl9KGpvF0=
-
-fresh@0.5.2:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
-  integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
+  integrity sha512-1+iRfba5tXzQAF83Tvvw5ZuhqDzyACfM+v13SZkdq8xKdaj/WR0Bke4sw9HsO1nU143+Hn0JxIleHEct+xbz9A==
 
 fs-constants@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
   integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
 
-fs-exists-sync@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add"
-  integrity sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=
-
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
-  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
-
-fsevents@^1.0.0:
-  version "1.2.13"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38"
-  integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==
-  dependencies:
-    bindings "^1.5.0"
-    nan "^2.12.1"
+  integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
 
 fsevents@~2.3.2:
   version "2.3.2"
@@ -4023,207 +1080,25 @@
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
   integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
 
-gensync@^1.0.0-beta.2:
-  version "1.0.0-beta.2"
-  resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
-  integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
-
-get-intrinsic@^1.0.2:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"
-  integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==
-  dependencies:
-    function-bind "^1.1.1"
-    has "^1.0.3"
-    has-symbols "^1.0.1"
-
-get-stdin@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
-  integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
-
-get-stream@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
-  integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
-
-get-stream@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
-  integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==
-  dependencies:
-    pump "^3.0.0"
-
-get-stream@^5.0.0, get-stream@^5.1.0:
+get-stream@^5.1.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
   integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==
   dependencies:
     pump "^3.0.0"
 
-get-value@^2.0.3, get-value@^2.0.6:
-  version "2.0.6"
-  resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
-  integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
-
-getpass@^0.1.1:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
-  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
-  dependencies:
-    assert-plus "^1.0.0"
-
-gh-got@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-5.0.0.tgz#ee95be37106fd8748a96f8d1db4baea89e1bfa8a"
-  integrity sha1-7pW+NxBv2HSKlvjR20uuqJ4b+oo=
-  dependencies:
-    got "^6.2.0"
-    is-plain-obj "^1.1.0"
-
-gh-got@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-6.0.0.tgz#d74353004c6ec466647520a10bd46f7299d268d0"
-  integrity sha512-F/mS+fsWQMo1zfgG9MD8KWvTWPPzzhuVwY++fhQ5Ggd+0P+CAMHtzMZhNxG+TqGfHDChJKsbh6otfMGqO2AKBw==
-  dependencies:
-    got "^7.0.0"
-    is-plain-obj "^1.1.0"
-
-github-username@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/github-username/-/github-username-3.0.0.tgz#0a772219b3130743429f2456d0bdd3db55dce7b1"
-  integrity sha1-CnciGbMTB0NCnyRW0L3T21Xc57E=
-  dependencies:
-    gh-got "^5.0.0"
-
-github-username@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/github-username/-/github-username-4.1.0.tgz#cbe280041883206da4212ae9e4b5f169c30bf417"
-  integrity sha1-y+KABBiDIG2kISrp5LXxacML9Bc=
-  dependencies:
-    gh-got "^6.0.0"
-
-glob-base@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
-  integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=
-  dependencies:
-    glob-parent "^2.0.0"
-    is-glob "^2.0.0"
-
-glob-parent@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
-  integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=
-  dependencies:
-    is-glob "^2.0.0"
-
-glob-parent@^3.0.0, glob-parent@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
-  integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=
-  dependencies:
-    is-glob "^3.1.0"
-    path-dirname "^1.0.0"
-
-glob-stream@^5.3.2:
-  version "5.3.5"
-  resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-5.3.5.tgz#a55665a9a8ccdc41915a87c701e32d4e016fad22"
-  integrity sha1-pVZlqajM3EGRWofHAeMtTgFvrSI=
-  dependencies:
-    extend "^3.0.0"
-    glob "^5.0.3"
-    glob-parent "^3.0.0"
-    micromatch "^2.3.7"
-    ordered-read-streams "^0.3.0"
-    through2 "^0.6.0"
-    to-absolute-glob "^0.1.1"
-    unique-stream "^2.0.2"
-
-glob-to-regexp@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
-  integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=
-
-glob@^5.0.3:
-  version "5.0.15"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
-  integrity sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=
-  dependencies:
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "2 || 3"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
-
-glob@^6.0.1:
-  version "6.0.4"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
-  integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=
-  dependencies:
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "2 || 3"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
-
-glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4:
-  version "7.1.7"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
-  integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
+glob@^7.1.3:
+  version "7.2.3"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+  integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
   dependencies:
     fs.realpath "^1.0.0"
     inflight "^1.0.4"
     inherits "2"
-    minimatch "^3.0.4"
+    minimatch "^3.1.1"
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-global-dirs@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
-  integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=
-  dependencies:
-    ini "^1.3.4"
-
-global-modules@^0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d"
-  integrity sha1-6lo77ULG1s6ZWk+KEmm12uIjgo0=
-  dependencies:
-    global-prefix "^0.1.4"
-    is-windows "^0.2.0"
-
-global-modules@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
-  integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==
-  dependencies:
-    global-prefix "^1.0.1"
-    is-windows "^1.0.1"
-    resolve-dir "^1.0.0"
-
-global-prefix@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.5.tgz#8d3bc6b8da3ca8112a160d8d496ff0462bfef78f"
-  integrity sha1-jTvGuNo8qBEqFg2NSW/wRiv+948=
-  dependencies:
-    homedir-polyfill "^1.0.0"
-    ini "^1.3.4"
-    is-windows "^0.2.0"
-    which "^1.2.12"
-
-global-prefix@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
-  integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=
-  dependencies:
-    expand-tilde "^2.0.2"
-    homedir-polyfill "^1.0.1"
-    ini "^1.3.4"
-    is-windows "^1.0.1"
-    which "^1.2.14"
-
 globals@^11.1.0:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@@ -4234,288 +1109,39 @@
   resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
   integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==
 
-globby@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-4.1.0.tgz#080f54549ec1b82a6c60e631fc82e1211dbe95f8"
-  integrity sha1-CA9UVJ7BuCpsYOYx/ILhIR2+lfg=
-  dependencies:
-    array-union "^1.0.1"
-    arrify "^1.0.0"
-    glob "^6.0.1"
-    object-assign "^4.0.1"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-globby@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c"
-  integrity sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=
-  dependencies:
-    array-union "^1.0.1"
-    glob "^7.0.3"
-    object-assign "^4.0.1"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-globby@^8.0.1:
-  version "8.0.2"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d"
-  integrity sha512-yTzMmKygLp8RUpG1Ymu2VXPSJQZjNAZPD4ywgYEaG7e4tBJeUQBO8OpXrf1RCNcEs5alsoJYPAMiIHP0cmeC7w==
-  dependencies:
-    array-union "^1.0.1"
-    dir-glob "2.0.0"
-    fast-glob "^2.0.2"
-    glob "^7.1.2"
-    ignore "^3.3.5"
-    pify "^3.0.0"
-    slash "^1.0.0"
-
-globby@^9.2.0:
-  version "9.2.0"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-9.2.0.tgz#fd029a706c703d29bdd170f4b6db3a3f7a7cb63d"
-  integrity sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==
-  dependencies:
-    "@types/glob" "^7.1.1"
-    array-union "^1.0.2"
-    dir-glob "^2.2.2"
-    fast-glob "^2.2.6"
-    glob "^7.1.3"
-    ignore "^4.0.3"
-    pify "^4.0.1"
-    slash "^2.0.0"
+google-protobuf@^3.6.1:
+  version "3.20.1"
+  resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.20.1.tgz#1b255c2b59bcda7c399df46c65206aa3c7a0ce8b"
+  integrity sha512-XMf1+O32FjYIV3CYu6Tuh5PNbfNEU5Xu22X+Xkdb/DUexFlCzhvv7d5Iirm4AOwn8lv4al1YvIhzGrg2j9Zfzw==
 
 got@^11.8.2:
-  version "11.8.2"
-  resolved "https://registry.yarnpkg.com/got/-/got-11.8.2.tgz#7abb3959ea28c31f3576f1576c1effce23f33599"
-  integrity sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==
+  version "11.8.5"
+  resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046"
+  integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==
   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"
+    cacheable-request "^7.0.2"
     decompress-response "^6.0.0"
     http2-wrapper "^1.0.0-beta.5.2"
     lowercase-keys "^2.0.0"
     p-cancelable "^2.0.0"
     responselike "^2.0.0"
 
-got@^5.0.0:
-  version "5.7.1"
-  resolved "https://registry.yarnpkg.com/got/-/got-5.7.1.tgz#5f81635a61e4a6589f180569ea4e381680a51f35"
-  integrity sha1-X4FjWmHkplifGAVp6k44FoClHzU=
-  dependencies:
-    create-error-class "^3.0.1"
-    duplexer2 "^0.1.4"
-    is-redirect "^1.0.0"
-    is-retry-allowed "^1.0.0"
-    is-stream "^1.0.0"
-    lowercase-keys "^1.0.0"
-    node-status-codes "^1.0.0"
-    object-assign "^4.0.1"
-    parse-json "^2.1.0"
-    pinkie-promise "^2.0.0"
-    read-all-stream "^3.0.0"
-    readable-stream "^2.0.5"
-    timed-out "^3.0.0"
-    unzip-response "^1.0.2"
-    url-parse-lax "^1.0.0"
-
-got@^6.2.0, got@^6.7.1:
-  version "6.7.1"
-  resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
-  integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=
-  dependencies:
-    create-error-class "^3.0.0"
-    duplexer3 "^0.1.4"
-    get-stream "^3.0.0"
-    is-redirect "^1.0.0"
-    is-retry-allowed "^1.0.0"
-    is-stream "^1.0.0"
-    lowercase-keys "^1.0.0"
-    safe-buffer "^5.0.1"
-    timed-out "^4.0.0"
-    unzip-response "^2.0.1"
-    url-parse-lax "^1.0.0"
-
-got@^7.0.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a"
-  integrity sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw==
-  dependencies:
-    decompress-response "^3.2.0"
-    duplexer3 "^0.1.4"
-    get-stream "^3.0.0"
-    is-plain-obj "^1.1.0"
-    is-retry-allowed "^1.0.0"
-    is-stream "^1.0.0"
-    isurl "^1.0.0-alpha5"
-    lowercase-keys "^1.0.0"
-    p-cancelable "^0.3.0"
-    p-timeout "^1.1.1"
-    safe-buffer "^5.0.1"
-    timed-out "^4.0.0"
-    url-parse-lax "^1.0.0"
-    url-to-options "^1.0.1"
-
-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.8"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
-  integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
-
-grouped-queue@^0.3.0:
-  version "0.3.3"
-  resolved "https://registry.yarnpkg.com/grouped-queue/-/grouped-queue-0.3.3.tgz#c167d2a5319c5a0e0964ef6a25b7c2df8996c85c"
-  integrity sha1-wWfSpTGcWg4JZO9qJbfC34mWyFw=
-  dependencies:
-    lodash "^4.17.2"
-
-grouped-queue@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/grouped-queue/-/grouped-queue-1.1.0.tgz#63e3f9ca90af952269d1d40879e41221eacc74cb"
-  integrity sha512-rZOFKfCqLhsu5VqjBjEWiwrYqJR07KxIkH4mLZlNlGDfntbb4FbMyGFP14TlvRPrU9S3Hnn/sgxbC5ZeN0no3Q==
-  dependencies:
-    lodash "^4.17.15"
-
-gulp-if@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-2.0.2.tgz#a497b7e7573005041caa2bc8b7dda3c80444d629"
-  integrity sha1-pJe351cwBQQcqivIt92jyARE1ik=
-  dependencies:
-    gulp-match "^1.0.3"
-    ternary-stream "^2.0.1"
-    through2 "^2.0.1"
-
-gulp-match@^1.0.3:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/gulp-match/-/gulp-match-1.1.0.tgz#552b7080fc006ee752c90563f9fec9d61aafdf4f"
-  integrity sha512-DlyVxa1Gj24DitY2OjEsS+X6tDpretuxD6wTfhXE/Rw2hweqc1f6D/XtsJmoiCwLWfXgR87W9ozEityPCVzGtQ==
-  dependencies:
-    minimatch "^3.0.3"
-
-gulp-sourcemaps@1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-1.6.0.tgz#b86ff349d801ceb56e1d9e7dc7bbcb4b7dee600c"
-  integrity sha1-uG/zSdgBzrVuHZ59x7vLS33uYAw=
-  dependencies:
-    convert-source-map "^1.1.1"
-    graceful-fs "^4.1.2"
-    strip-bom "^2.0.0"
-    through2 "^2.0.0"
-    vinyl "^1.0.0"
-
-gunzip-maybe@^1.3.1:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz#b913564ae3be0eda6f3de36464837a9cd94b98ac"
-  integrity sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==
-  dependencies:
-    browserify-zlib "^0.1.4"
-    is-deflate "^1.0.0"
-    is-gzip "^1.0.0"
-    peek-stream "^1.1.0"
-    pumpify "^1.3.3"
-    through2 "^2.0.3"
-
-handle-thing@^1.2.5:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"
-  integrity sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ=
-
-har-schema@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
-  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
-
-har-validator@~5.1.0, har-validator@~5.1.3:
-  version "5.1.5"
-  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd"
-  integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==
-  dependencies:
-    ajv "^6.12.3"
-    har-schema "^2.0.0"
-
 has-ansi@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
-  integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=
+  integrity sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==
   dependencies:
     ansi-regex "^2.0.0"
 
-has-binary2@~1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
-  integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
-  dependencies:
-    isarray "2.0.1"
-
-has-color@~0.1.0:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f"
-  integrity sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=
-
-has-cors@1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
-  integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
-
 has-flag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
-  integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
-
-has-flag@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
-  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
-
-has-symbol-support-x@^1.4.1:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455"
-  integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==
-
-has-symbols@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
-  integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
-
-has-to-string-tag-x@^1.2.0:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz#a045ab383d7b4b2012a00148ab0aa5f290044d4d"
-  integrity sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==
-  dependencies:
-    has-symbol-support-x "^1.4.1"
-
-has-value@^0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
-  integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=
-  dependencies:
-    get-value "^2.0.3"
-    has-values "^0.1.4"
-    isobject "^2.0.0"
-
-has-value@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177"
-  integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=
-  dependencies:
-    get-value "^2.0.6"
-    has-values "^1.0.0"
-    isobject "^3.0.0"
-
-has-values@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771"
-  integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E=
-
-has-values@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f"
-  integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=
-  dependencies:
-    is-number "^3.0.0"
-    kind-of "^4.0.0"
+  integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
 
 has@^1.0.3:
   version "1.0.3"
@@ -4524,116 +1150,11 @@
   dependencies:
     function-bind "^1.1.1"
 
-he@1.2.x:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
-  integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
-
-homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
-  integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==
-  dependencies:
-    parse-passwd "^1.0.0"
-
-hosted-git-info@^2.1.4:
-  version "2.8.9"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
-  integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
-
-hpack.js@^2.1.6:
-  version "2.1.6"
-  resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2"
-  integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=
-  dependencies:
-    inherits "^2.0.1"
-    obuf "^1.0.0"
-    readable-stream "^2.0.1"
-    wbuf "^1.1.0"
-
-html-minifier@^3.5.10:
-  version "3.5.21"
-  resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.21.tgz#d0040e054730e354db008463593194015212d20c"
-  integrity sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==
-  dependencies:
-    camel-case "3.0.x"
-    clean-css "4.2.x"
-    commander "2.17.x"
-    he "1.2.x"
-    param-case "2.1.x"
-    relateurl "0.2.x"
-    uglify-js "3.4.x"
-
 http-cache-semantics@^4.0.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
   integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
 
-http-deceiver@^1.2.7:
-  version "1.2.7"
-  resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
-  integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=
-
-http-errors@1.7.2:
-  version "1.7.2"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
-  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    setprototypeof "1.1.1"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
-
-http-errors@~1.6.2:
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
-  integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    setprototypeof "1.1.0"
-    statuses ">= 1.4.0 < 2"
-
-http-errors@~1.7.2:
-  version "1.7.3"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
-  integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.4"
-    setprototypeof "1.1.1"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
-
-http-proxy-middleware@^0.17.2:
-  version "0.17.4"
-  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833"
-  integrity sha1-ZC6ISIUdZvCdTxJJEoRtuutBuDM=
-  dependencies:
-    http-proxy "^1.16.2"
-    is-glob "^3.1.0"
-    lodash "^4.17.2"
-    micromatch "^2.3.11"
-
-http-proxy@^1.16.2:
-  version "1.18.1"
-  resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
-  integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==
-  dependencies:
-    eventemitter3 "^4.0.0"
-    follow-redirects "^1.0.0"
-    requires-port "^1.0.0"
-
-http-signature@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
-  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
-  dependencies:
-    assert-plus "^1.0.0"
-    jsprim "^1.2.2"
-    sshpk "^1.7.0"
-
 http2-wrapper@^1.0.0-beta.5.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d"
@@ -4650,140 +1171,29 @@
     agent-base "^4.3.0"
     debug "^3.1.0"
 
-https-proxy-agent@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2"
-  integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==
-  dependencies:
-    agent-base "6"
-    debug "4"
-
-human-signals@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
-  integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
-
-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"
-  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
-  dependencies:
-    safer-buffer ">= 2.1.2 < 3"
-
 ieee754@^1.1.13:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
-ignore@^3.3.5:
-  version "3.3.10"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
-  integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==
-
-ignore@^4.0.3:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
-  integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
-
-import-lazy@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
-  integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=
-
-imurmurhash@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
-  integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
-
-indent-string@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
-  integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=
-  dependencies:
-    repeating "^2.0.0"
-
 indent@0.0.2:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/indent/-/indent-0.0.2.tgz#8c79f080190559b687034b84c7aefa97d5a911d9"
-  integrity sha1-jHnwgBkFWbaHA0uEx676l9WpEdk=
-
-indexof@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
-  integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
+  integrity sha512-/F1w9/msSQCfXDTvEU8rKBObcv4cBN6m8hujC/zwVc8vOuf4b76AwBVGChbg+3o0M3kp1XDjoMDQR5Nh6SAHfA==
 
 inflight@^1.0.4:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
-  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
   dependencies:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3:
+inherits@2, inherits@^2.0.3, inherits@^2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
-inherits@2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
-  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
-
-ini@^1.3.4, ini@~1.3.0:
-  version "1.3.8"
-  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
-  integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
-
-inquirer@^1.0.2:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-1.2.3.tgz#4dec6f32f37ef7bb0b2ed3f1d1a5c3f545074918"
-  integrity sha1-TexvMvN+97sLLtPx0aXD9UUHSRg=
-  dependencies:
-    ansi-escapes "^1.1.0"
-    chalk "^1.0.0"
-    cli-cursor "^1.0.1"
-    cli-width "^2.0.0"
-    external-editor "^1.1.0"
-    figures "^1.3.5"
-    lodash "^4.3.0"
-    mute-stream "0.0.6"
-    pinkie-promise "^2.0.0"
-    run-async "^2.2.0"
-    rx "^4.1.0"
-    string-width "^1.0.1"
-    strip-ansi "^3.0.0"
-    through "^2.3.6"
-
-inquirer@^7.1.0:
-  version "7.3.3"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003"
-  integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==
-  dependencies:
-    ansi-escapes "^4.2.1"
-    chalk "^4.1.0"
-    cli-cursor "^3.1.0"
-    cli-width "^3.0.0"
-    external-editor "^3.0.3"
-    figures "^3.0.0"
-    lodash "^4.17.19"
-    mute-stream "0.0.8"
-    run-async "^2.4.0"
-    rxjs "^6.6.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-    through "^2.3.6"
-
-interpret@^1.0.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
-  integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
-
-intersect@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/intersect/-/intersect-1.0.1.tgz#332650e10854d8c0ac58c192bdc27a8bf7e7a30c"
-  integrity sha1-MyZQ4QhU2MCsWMGSvcJ6i/fnoww=
-
 invariant@^2.2.2:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@@ -4791,405 +1201,39 @@
   dependencies:
     loose-envify "^1.0.0"
 
-ipaddr.js@1.9.1:
-  version "1.9.1"
-  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
-  integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
-
-is-accessor-descriptor@^0.1.6:
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
-  integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=
-  dependencies:
-    kind-of "^3.0.2"
-
-is-accessor-descriptor@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656"
-  integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==
-  dependencies:
-    kind-of "^6.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"
-  integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
-
-is-arrayish@^0.3.1:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
-  integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
-
-is-binary-path@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
-  integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=
-  dependencies:
-    binary-extensions "^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-ci@^1.0.10:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c"
-  integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==
-  dependencies:
-    ci-info "^1.5.0"
-
-is-core-module@^2.2.0:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.6.0.tgz#d7553b2526fe59b92ba3e40c8df757ec8a709e19"
-  integrity sha512-wShG8vs60jKfPWpF2KZRaAtvt3a20OAn7+IJ6hLPECpSABLcKtFKTTI4ZtH5QcBruBHlq+WsdHWyz0BCZW7svQ==
+is-core-module@^2.8.1:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"
+  integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==
   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"
-  integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=
-  dependencies:
-    kind-of "^3.0.2"
-
-is-data-descriptor@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7"
-  integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==
-  dependencies:
-    kind-of "^6.0.0"
-
-is-deflate@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-deflate/-/is-deflate-1.0.0.tgz#c862901c3c161fb09dac7cdc7e784f80e98f2f14"
-  integrity sha1-yGKQHDwWH7CdrHzcfnhPgOmPLxQ=
-
-is-descriptor@^0.1.0:
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca"
-  integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==
-  dependencies:
-    is-accessor-descriptor "^0.1.6"
-    is-data-descriptor "^0.1.4"
-    kind-of "^5.0.0"
-
-is-descriptor@^1.0.0, is-descriptor@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec"
-  integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==
-  dependencies:
-    is-accessor-descriptor "^1.0.0"
-    is-data-descriptor "^1.0.0"
-    kind-of "^6.0.2"
-
-is-dotfile@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
-  integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=
-
-is-equal-shallow@^0.1.3:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
-  integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=
-  dependencies:
-    is-primitive "^2.0.0"
-
-is-extendable@^0.1.0, is-extendable@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
-  integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=
-
-is-extendable@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
-  integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==
-  dependencies:
-    is-plain-object "^2.0.4"
-
-is-extglob@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
-  integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=
-
-is-extglob@^2.1.0, is-extglob@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
-  integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
-
 is-finite@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3"
   integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==
 
-is-fullwidth-code-point@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
-  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
-  dependencies:
-    number-is-nan "^1.0.0"
-
-is-fullwidth-code-point@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
-  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
-
-is-fullwidth-code-point@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
-  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
-
-is-glob@^2.0.0, is-glob@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
-  integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=
-  dependencies:
-    is-extglob "^1.0.0"
-
-is-glob@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
-  integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=
-  dependencies:
-    is-extglob "^2.1.0"
-
-is-glob@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
-  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
-  dependencies:
-    is-extglob "^2.1.1"
-
-is-gzip@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-gzip/-/is-gzip-1.0.0.tgz#6ca8b07b99c77998025900e555ced8ed80879a83"
-  integrity sha1-bKiwe5nHeZgCWQDlVc7Y7YCHmoM=
-
-is-installed-globally@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
-  integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=
-  dependencies:
-    global-dirs "^0.1.0"
-    is-path-inside "^1.0.0"
-
 is-module@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
-  integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=
+  integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
 
-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-number@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
-  integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=
+is-reference@^1.1.2:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7"
+  integrity sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==
   dependencies:
-    kind-of "^3.0.2"
+    "@types/estree" "*"
 
-is-number@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
-  integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=
-  dependencies:
-    kind-of "^3.0.2"
-
-is-number@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
-  integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==
-
-is-obj@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
-  integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
-
-is-object@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.2.tgz#a56552e1c665c9e950b4a025461da87e72f86fcf"
-  integrity sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==
-
-is-path-cwd@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d"
-  integrity sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=
-
-is-path-in-cwd@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52"
-  integrity sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==
-  dependencies:
-    is-path-inside "^1.0.0"
-
-is-path-inside@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036"
-  integrity sha1-jvW33lBDej/cprToZe96pVy0gDY=
-  dependencies:
-    path-is-inside "^1.0.1"
-
-is-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"
-  integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4=
-
-is-plain-object@^2.0.3, is-plain-object@^2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
-  integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
-  dependencies:
-    isobject "^3.0.1"
-
-is-plain-object@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
-  integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
-
-is-posix-bracket@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
-  integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=
-
-is-potential-custom-element-name@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
-  integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
-
-is-primitive@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
-  integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU=
-
-is-redirect@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
-  integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
-
-is-retry-allowed@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4"
-  integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==
-
-is-scoped@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-scoped/-/is-scoped-1.0.0.tgz#449ca98299e713038256289ecb2b540dc437cb30"
-  integrity sha1-RJypgpnnEwOCViieyytUDcQ3yzA=
-  dependencies:
-    scoped-regex "^1.0.0"
-
-is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
-  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
-
-is-stream@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
-  integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
-
-is-typedarray@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
-  integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
-
-is-utf8@^0.2.0, is-utf8@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
-  integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
-
-is-valid-glob@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-0.3.0.tgz#d4b55c69f51886f9b65c70d6c2622d37e29f48fe"
-  integrity sha1-1LVcafUYhvm2XHDWwmItN+KfSP4=
-
-is-windows@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c"
-  integrity sha1-3hqm1j6indJIc3tp8f+LgALSEIw=
-
-is-windows@^1.0.1, is-windows@^1.0.2:
+is-windows@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
   integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
 
-isarray@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
-  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
-
-isarray@1.0.0, isarray@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
-  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
-
-isarray@2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
-  integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
-
-isbinaryfile@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.3.tgz#5d6def3edebf6e8ca8cae9c30183a804b5f8be80"
-  integrity sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==
-  dependencies:
-    buffer-alloc "^1.2.0"
-
-isbinaryfile@^4.0.0:
-  version "4.0.8"
-  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.8.tgz#5d34b94865bd4946633ecc78a026fc76c5b11fcf"
-  integrity sha512-53h6XFniq77YdW+spoRrebh0mnmTxRPTlcuIArO57lmMdq4uBKFKaeTjnb92oYWrSn/LVL+LT+Hap2tFQj8V+w==
-
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
-  integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
-
-isobject@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
-  integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=
-  dependencies:
-    isarray "1.0.0"
-
-isobject@^3.0.0, isobject@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
-  integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
-
-isstream@~0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
-  integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
-
-istextorbinary@^2.2.1, istextorbinary@^2.5.1:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.6.0.tgz#60776315fb0fa3999add276c02c69557b9ca28ab"
-  integrity sha512-+XRlFseT8B3L9KyjxxLjfXSLMuErKDsd8DBNrsaxoViABMEZlOSCstwmw0qpoFX3+U6yWU1yhLudAe6/lETGGA==
-  dependencies:
-    binaryextensions "^2.1.2"
-    editions "^2.2.0"
-    textextensions "^2.5.0"
-
-isurl@^1.0.0-alpha5:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
-  integrity sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==
-  dependencies:
-    has-to-string-tag-x "^1.2.0"
-    is-object "^1.0.1"
-
-jake@^10.6.1:
-  version "10.8.2"
-  resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b"
-  integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==
-  dependencies:
-    async "0.9.x"
-    chalk "^2.4.2"
-    filelist "^1.0.1"
-    minimatch "^3.0.4"
+  integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
 
 jest-worker@^24.9.0:
   version "24.9.0"
@@ -5207,144 +1251,39 @@
 js-tokens@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
-  integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
-
-jsbn@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
-  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
+  integrity sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg==
 
 jsesc@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
-  integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s=
+  integrity sha512-Mke0DA0QjUWuJlhsE0ZPPhYiJkRap642SmI/4ztCFaUs6V2AiH1sfecc+57NgaryfAA2VR3v6O+CSjC1jZJKOA==
 
 jsesc@^2.5.1:
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
   integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
 
-jsesc@~0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
-  integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
-
-json-buffer@3.0.1:
+json-buffer@3.0.1, json-buffer@~3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
   integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
 
-json-parse-better-errors@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
-  integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
-
-json-parse-even-better-errors@^2.3.0:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
-  integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
-
-json-schema-traverse@^0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
-  integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
-
-json-schema@0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
-  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
-
-json-stable-stringify-without-jsonify@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
-  integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
-
-json-stringify-safe@~5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
-  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
-
-json5@^2.1.2:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
-  integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
-  dependencies:
-    minimist "^1.2.5"
-
-jsonparse@^1.2.0:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
-  integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
-
-jsonschema@^1.1.0, jsonschema@^1.1.1:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.0.tgz#1afa34c4bc22190d8e42271ec17ac8b3404f87b2"
-  integrity sha512-/YgW6pRMr6M7C+4o8kS+B/2myEpHCrxO4PEWnqJNBFMjn7EWXqlQ4tGwL6xTHeRplwuZmcAncdvfOad1nT2yMw==
-
-jsprim@^1.2.2:
+jsonschema@^1.1.0:
   version "1.4.1"
-  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
-  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
-  dependencies:
-    assert-plus "1.0.0"
-    extsprintf "1.3.0"
-    json-schema "0.2.3"
-    verror "1.10.0"
+  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.1.tgz#cc4c3f0077fb4542982973d8a083b6b34f482dab"
+  integrity sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==
 
 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==
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.3.0.tgz#b4352e0e4fe7c94111947d6738a6d3fe7903027c"
+  integrity sha512-C30Un9+63J0CsR7Wka5quXKqYZsT6dcRQ2aOwGcSc3RiQ4HGWpTAHlCA+puNfw2jA/s11EsxA1nCXgZRuRKMQQ==
   dependencies:
+    compress-brotli "^1.3.8"
     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"
-  integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=
-  dependencies:
-    is-buffer "^1.1.5"
-
-kind-of@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57"
-  integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc=
-  dependencies:
-    is-buffer "^1.1.5"
-
-kind-of@^5.0.0:
-  version "5.1.0"
-  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.3"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
-  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
-
-kuler@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"
-  integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==
-
-latest-version@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-2.0.0.tgz#56f8d6139620847b8017f8f1f4d78e211324168b"
-  integrity sha1-VvjWE5YghHuAF/jx9NeOIRMkFos=
-  dependencies:
-    package-json "^2.0.0"
-
-latest-version@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
-  integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=
-  dependencies:
-    package-json "^4.0.0"
-
-launchpad@^0.7.0:
+"launchpad@git+https://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be", "launchpad@git://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be":
   version "0.7.5"
-  resolved "https://registry.yarnpkg.com/launchpad/-/launchpad-0.7.5.tgz#a16950c937572f10ef01c9be945a96f7aef8e427"
-  integrity sha512-gsYFgT8XKL3X2XZHPPPrgwM0JqeQwGpSWnzg7EYadBY3MirbQrTVq6L4fm6l7UE2T+7gnfuhiGkKr/xxuU/fdw==
+  resolved "git+https://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be"
   dependencies:
     async "^2.0.1"
     browserstack "^1.2.0"
@@ -5355,103 +1294,15 @@
     rimraf "^3.0.0"
     underscore "^1.8.3"
 
-lazy-cache@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-2.0.2.tgz#b9190a4f913354694840859f8a8f7084d8822264"
-  integrity sha1-uRkKT5EzVGlIQIWfio9whNiCImQ=
-  dependencies:
-    set-getter "^0.1.0"
-
-lazy-req@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/lazy-req/-/lazy-req-1.1.0.tgz#bdaebead30f8d824039ce0ce149d4daa07ba1fac"
-  integrity sha1-va6+rTD42CQDnODOFJ1Nqge6H6w=
-
-lazystream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
-  integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=
-  dependencies:
-    readable-stream "^2.0.5"
-
-lines-and-columns@^1.1.6:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
-  integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
-
-load-json-file@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
-  integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=
-  dependencies:
-    graceful-fs "^4.1.2"
-    parse-json "^2.2.0"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-    strip-bom "^2.0.0"
-
-load-json-file@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b"
-  integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs=
-  dependencies:
-    graceful-fs "^4.1.2"
-    parse-json "^4.0.0"
-    pify "^3.0.0"
-    strip-bom "^3.0.0"
-
-locate-path@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
-  integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
-  dependencies:
-    p-locate "^3.0.0"
-    path-exists "^3.0.0"
-
-lodash._reinterpolate@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
-  integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
-
 lodash.camelcase@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
-  integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
-
-lodash.defaults@^4.2.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
-  integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
-
-lodash.difference@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c"
-  integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=
-
-lodash.flatten@^4.4.0:
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
-  integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
-
-lodash.get@^4.4.2:
-  version "4.4.2"
-  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
-  integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
-
-lodash.isequal@^4.0.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
-  integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
-
-lodash.isplainobject@^4.0.6:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
-  integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
+  integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
 
 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=
+  integrity sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==
 
 lodash.merge@^4.6.2:
   version "4.6.2"
@@ -5461,89 +1312,18 @@
 lodash.padend@^4.6.1:
   version "4.6.1"
   resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e"
-  integrity sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=
-
-lodash.set@^4.3.2:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
-  integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=
+  integrity sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw==
 
 lodash.sortby@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
-  integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
+  integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
 
-lodash.template@^4.4.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
-  integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
-  dependencies:
-    lodash._reinterpolate "^3.0.0"
-    lodash.templatesettings "^4.0.0"
-
-lodash.templatesettings@^4.0.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33"
-  integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==
-  dependencies:
-    lodash._reinterpolate "^3.0.0"
-
-lodash.union@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
-  integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=
-
-lodash.uniq@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
-  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
-
-lodash@4.17.21, lodash@^3.0.0, lodash@^3.10.1, lodash@^4.0.0, lodash@^4.11.1, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.3.0:
+lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.4:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
-log-symbols@^1.0.0, log-symbols@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
-  integrity sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=
-  dependencies:
-    chalk "^1.0.0"
-
-log-symbols@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
-  integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==
-  dependencies:
-    chalk "^2.0.1"
-
-logform@^1.9.1:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/logform/-/logform-1.10.0.tgz#c9d5598714c92b546e23f4e78147c40f1e02012e"
-  integrity sha512-em5ojIhU18fIMOw/333mD+ZLE2fis0EzXl1ZwHx4iQzmpQi6odNiY/t+ITNr33JZhT9/KEaH+UPIipr6a9EjWg==
-  dependencies:
-    colors "^1.2.1"
-    fast-safe-stringify "^2.0.4"
-    fecha "^2.3.3"
-    ms "^2.1.1"
-    triple-beam "^1.2.0"
-
-logform@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/logform/-/logform-2.2.0.tgz#40f036d19161fc76b68ab50fdc7fe495544492f2"
-  integrity sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg==
-  dependencies:
-    colors "^1.2.1"
-    fast-safe-stringify "^2.0.4"
-    fecha "^4.2.0"
-    ms "^2.1.1"
-    triple-beam "^1.3.0"
-
-lolex@^1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6"
-  integrity sha1-OpoCg0UqR9dDnnJzG54H1zhuSfY=
-
 long@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
@@ -5556,49 +1336,11 @@
   dependencies:
     js-tokens "^3.0.0 || ^4.0.0"
 
-loud-rejection@^1.0.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
-  integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=
-  dependencies:
-    currently-unhandled "^0.4.1"
-    signal-exit "^3.0.0"
-
-lower-case@^1.1.1:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac"
-  integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw=
-
-lowercase-keys@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
-  integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
-
 lowercase-keys@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
   integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
 
-lru-cache@^4.0.1, lru-cache@^4.0.2:
-  version "4.1.5"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
-  integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
-  dependencies:
-    pseudomap "^1.0.2"
-    yallist "^2.1.2"
-
-lru-cache@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
-  integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
-  dependencies:
-    yallist "^4.0.0"
-
-macos-release@^2.2.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.5.0.tgz#067c2c88b5f3fb3c56a375b2ec93826220fa1ff2"
-  integrity sha512-EIgv+QZ9r+814gjJj0Bt5vSLJLzswGmSUbUpbi9AIr/fsN2IWFBl2NucV9PAiek+U1STK468tEkxmVYUtuAN3g==
-
 magic-string@^0.22.4:
   version "0.22.5"
   resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e"
@@ -5606,236 +1348,18 @@
   dependencies:
     vlq "^0.2.2"
 
-make-dir@^1.0.0, make-dir@^1.1.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
-  integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==
+magic-string@^0.25.2, magic-string@^0.25.7:
+  version "0.25.9"
+  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
+  integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==
   dependencies:
-    pify "^3.0.0"
-
-make-dir@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
-  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
-  dependencies:
-    semver "^6.0.0"
-
-map-cache@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
-  integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=
-
-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"
-  integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
-
-map-visit@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
-  integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=
-  dependencies:
-    object-visit "^1.0.0"
-
-matcher@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/matcher/-/matcher-1.1.1.tgz#51d8301e138f840982b338b116bb0c09af62c1c2"
-  integrity sha512-+BmqxWIubKTRKNWx/ahnCkk3mG8m7OturVlqq6HiojGJTd5hVYbgZm6WzcYPCoB+KBT4Vd6R7WSRG2OADNaCjg==
-  dependencies:
-    escape-string-regexp "^1.0.4"
-
-math-random@^1.0.1:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
-  integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
-
-md5@^2.2.1:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f"
-  integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==
-  dependencies:
-    charenc "0.0.2"
-    crypt "0.0.2"
-    is-buffer "~1.1.6"
-
-media-typer@0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
-  integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
-
-mem-fs-editor@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-5.1.0.tgz#51972241640be8567680a04f7adaffe5fc603667"
-  integrity sha512-2Yt2GCYEbcotYbIJagmow4gEtHDqzpq5XN94+yAx/NT5+bGqIjkXnm3KCUQfE6kRfScGp9IZknScoGRKu8L78w==
-  dependencies:
-    commondir "^1.0.1"
-    deep-extend "^0.6.0"
-    ejs "^2.5.9"
-    glob "^7.0.3"
-    globby "^8.0.1"
-    isbinaryfile "^3.0.2"
-    mkdirp "^0.5.0"
-    multimatch "^2.0.0"
-    rimraf "^2.2.8"
-    through2 "^2.0.0"
-    vinyl "^2.0.1"
-
-mem-fs-editor@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-6.0.0.tgz#d63607cf0a52fe6963fc376c6a7aa52db3edabab"
-  integrity sha512-e0WfJAMm8Gv1mP5fEq/Blzy6Lt1VbLg7gNnZmZak7nhrBTibs+c6nQ4SKs/ZyJYHS1mFgDJeopsLAv7Ow0FMFg==
-  dependencies:
-    commondir "^1.0.1"
-    deep-extend "^0.6.0"
-    ejs "^2.6.1"
-    glob "^7.1.4"
-    globby "^9.2.0"
-    isbinaryfile "^4.0.0"
-    mkdirp "^0.5.0"
-    multimatch "^4.0.0"
-    rimraf "^2.6.3"
-    through2 "^3.0.1"
-    vinyl "^2.2.0"
-
-mem-fs-editor@^7.0.1:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-7.1.0.tgz#2a16f143228df87bf918874556723a7ee73bfe88"
-  integrity sha512-BH6QEqCXSqGeX48V7zu+e3cMwHU7x640NB8Zk8VNvVZniz+p4FK60pMx/3yfkzo6miI6G3a8pH6z7FeuIzqrzA==
-  dependencies:
-    commondir "^1.0.1"
-    deep-extend "^0.6.0"
-    ejs "^3.1.5"
-    glob "^7.1.4"
-    globby "^9.2.0"
-    isbinaryfile "^4.0.0"
-    mkdirp "^1.0.0"
-    multimatch "^4.0.0"
-    rimraf "^3.0.0"
-    through2 "^3.0.2"
-    vinyl "^2.2.1"
-
-mem-fs@^1.1.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/mem-fs/-/mem-fs-1.2.0.tgz#5f29b2d02a5875cd14cd836c388385892d556cde"
-  integrity sha512-b8g0jWKdl8pM0LqAPdK9i8ERL7nYrzmJfRhxMiWH2uYdfYnb7uXnmwVb0ZGe7xyEl4lj+nLIU3yf4zPUT+XsVQ==
-  dependencies:
-    through2 "^3.0.0"
-    vinyl "^2.0.1"
-    vinyl-file "^3.0.0"
-
-meow@^3.7.0:
-  version "3.7.0"
-  resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
-  integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
-  dependencies:
-    camelcase-keys "^2.0.0"
-    decamelize "^1.1.2"
-    loud-rejection "^1.0.0"
-    map-obj "^1.0.1"
-    minimist "^1.1.3"
-    normalize-package-data "^2.3.4"
-    object-assign "^4.0.1"
-    read-pkg-up "^1.0.1"
-    redent "^1.0.0"
-    trim-newlines "^1.0.0"
-
-merge-descriptors@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
-  integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
-
-merge-stream@^1.0.0, merge-stream@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1"
-  integrity sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE=
-  dependencies:
-    readable-stream "^2.0.1"
+    sourcemap-codec "^1.4.8"
 
 merge-stream@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
   integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
 
-merge2@^1.2.3:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
-  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
-
-methods@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
-  integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
-
-micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7:
-  version "2.3.11"
-  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
-  integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=
-  dependencies:
-    arr-diff "^2.0.0"
-    array-unique "^0.2.1"
-    braces "^1.8.2"
-    expand-brackets "^0.1.4"
-    extglob "^0.3.1"
-    filename-regex "^2.0.0"
-    is-extglob "^1.0.0"
-    is-glob "^2.0.1"
-    kind-of "^3.0.2"
-    normalize-path "^2.0.1"
-    object.omit "^2.0.0"
-    parse-glob "^3.0.4"
-    regex-cache "^0.4.2"
-
-micromatch@^3.0.4, micromatch@^3.1.10:
-  version "3.1.10"
-  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
-  integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==
-  dependencies:
-    arr-diff "^4.0.0"
-    array-unique "^0.3.2"
-    braces "^2.3.1"
-    define-property "^2.0.2"
-    extend-shallow "^3.0.2"
-    extglob "^2.0.4"
-    fragment-cache "^0.2.1"
-    kind-of "^6.0.2"
-    nanomatch "^1.2.9"
-    object.pick "^1.3.0"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.2"
-
-mime-db@1.49.0, "mime-db@>= 1.43.0 < 2", mime-db@^1.28.0:
-  version "1.49.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed"
-  integrity sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==
-
-mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
-  version "2.1.32"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5"
-  integrity sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==
-  dependencies:
-    mime-db "1.49.0"
-
-mime@1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
-  integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==
-
-mime@1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
-  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
-
-mime@^2.3.1:
-  version "2.5.2"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe"
-  integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==
-
-mimic-fn@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
-  integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
-
 mimic-response@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
@@ -5846,74 +1370,34 @@
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
   integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
 
-minimalistic-assert@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
-  integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
-
-minimatch-all@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/minimatch-all/-/minimatch-all-1.1.0.tgz#40c496a27a2e128d19bf758e76bb01a0c7145787"
-  integrity sha1-QMSWonouEo0Zv3WOdrsBoMcUV4c=
-  dependencies:
-    minimatch "^3.0.2"
-
-"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"
-  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+minimatch@^3.0.4, minimatch@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
   dependencies:
     brace-expansion "^1.1.7"
 
-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.2.5, minimist@^1.2.6:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
+  integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
 
-minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
-  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
-
-mixin-deep@^1.2.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
-  integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
+mkdirp@^0.5.1:
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
   dependencies:
-    for-in "^1.0.2"
-    is-extendable "^1.0.1"
+    minimist "^1.2.6"
 
-mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.4:
-  version "0.5.5"
-  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
-  integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
-  dependencies:
-    minimist "^1.2.5"
-
-mkdirp@^1.0.0, mkdirp@^1.0.4:
+mkdirp@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
   integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
 
-moment@^2.15.1, moment@^2.24.0:
-  version "2.29.1"
-  resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
-  integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
-
-mout@^1.0.0:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/mout/-/mout-1.2.2.tgz#c9b718a499806a0632cede178e80f436259e777d"
-  integrity sha512-w0OUxFEla6z3d7sVpMZGBCpQvYh8PHS1wZ6Wu9GNKHMpAHWJ0if0LsQZh3DlOqw55HlhJEOMLpFnwtxp99Y5GA==
-
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.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==
+  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
 
 ms@2.1.2:
   version "2.1.2"
@@ -5925,468 +1409,23 @@
   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.3"
-  resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.3.tgz#4db352d6992e028ac0eacf7be45c6efd0264297b"
-  integrity sha512-np0YLKncuZoTzufbkM6wEKp68EhWJXcU6fq6QqrSwkckd2LlMgd1UqhUJLj6NS/5sZ8dE8LYDWslsltJznnXlg==
-  dependencies:
-    append-field "^1.0.0"
-    busboy "^0.2.11"
-    concat-stream "^1.5.2"
-    mkdirp "^0.5.4"
-    object-assign "^4.1.1"
-    on-finished "^2.3.0"
-    type-is "^1.6.4"
-    xtend "^4.0.0"
-
-multimatch@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-2.1.0.tgz#9c7906a22fb4c02919e2f5f75161b4cdbd4b2a2b"
-  integrity sha1-nHkGoi+0wCkZ4vX3UWG0zb1LKis=
-  dependencies:
-    array-differ "^1.0.0"
-    array-union "^1.0.1"
-    arrify "^1.0.0"
-    minimatch "^3.0.0"
-
-multimatch@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-4.0.0.tgz#8c3c0f6e3e8449ada0af3dd29efb491a375191b3"
-  integrity sha512-lDmx79y1z6i7RNx0ZGCPq1bzJ6ZoDDKbvh7jxr9SJcWLkShMzXrHbYVpTdnhNM5MXpDUxCQ4DgqVttVXlBgiBQ==
-  dependencies:
-    "@types/minimatch" "^3.0.3"
-    array-differ "^3.0.0"
-    array-union "^2.1.0"
-    arrify "^2.0.1"
-    minimatch "^3.0.4"
-
-multipipe@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-1.0.2.tgz#cc13efd833c9cda99f224f868461b8e1a3fd939d"
-  integrity sha1-zBPv2DPJzamfIk+GhGG44aP9k50=
-  dependencies:
-    duplexer2 "^0.1.2"
-    object-assign "^4.1.0"
-
-mute-stream@0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.6.tgz#48962b19e169fd1dfc240b3f1e7317627bbc47db"
-  integrity sha1-SJYrGeFp/R38JAs/HnMXYnu8R9s=
-
-mute-stream@0.0.8:
-  version "0.0.8"
-  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
-  integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
-
-mz@^2.4.0, mz@^2.6.0:
-  version "2.7.0"
-  resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
-  integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
-  dependencies:
-    any-promise "^1.0.0"
-    object-assign "^4.0.1"
-    thenify-all "^1.0.0"
-
-nan@^2.12.1:
-  version "2.15.0"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee"
-  integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==
-
-nanomatch@^1.2.9:
-  version "1.2.13"
-  resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
-  integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==
-  dependencies:
-    arr-diff "^4.0.0"
-    array-unique "^0.3.2"
-    define-property "^2.0.2"
-    extend-shallow "^3.0.2"
-    fragment-cache "^0.2.1"
-    is-windows "^1.0.2"
-    kind-of "^6.0.2"
-    object.pick "^1.3.0"
-    regex-not "^1.0.0"
-    snapdragon "^0.8.1"
-    to-regex "^3.0.1"
-
-native-promise-only@^0.8.1:
-  version "0.8.1"
-  resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11"
-  integrity sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=
-
-negotiator@0.6.2:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
-  integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
-
-nice-try@^1.0.4:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
-  integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
-
-no-case@^2.2.0:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac"
-  integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==
-  dependencies:
-    lower-case "^1.1.1"
-
-node-fetch@^2.6.0, node-fetch@^2.6.1:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
-  integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
-
-node-releases@^1.1.75:
-  version "1.1.75"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.75.tgz#6dd8c876b9897a1b8e5a02de26afa79bb54ebbfe"
-  integrity sha512-Qe5OUajvqrqDSy6wrWFmMwfJ0jVgwiw4T3KqmbTcZ62qW0gQkheXYhcFM1+lOVcGUoRxcEcfyvFMAnDgaF1VWw==
-
-node-status-codes@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f"
-  integrity sha1-WuVUHQJGRdMqWPzdyc7s6nrjrC8=
-
-nomnom@^1.8.1:
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7"
-  integrity sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=
-  dependencies:
-    chalk "~0.4.0"
-    underscore "~1.6.0"
-
-normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
-  integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
-  dependencies:
-    hosted-git-info "^2.1.4"
-    resolve "^1.10.0"
-    semver "2 || 3 || 4 || 5"
-    validate-npm-package-license "^3.0.1"
-
-normalize-path@^2.0.0, normalize-path@^2.0.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
-  integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=
-  dependencies:
-    remove-trailing-separator "^1.0.1"
-
-normalize-path@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
-  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
-
 normalize-url@^6.0.1:
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
   integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
 
-npm-api@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/npm-api/-/npm-api-1.0.1.tgz#3def9b51afedca57db14ca0c970d92442d21c9c5"
-  integrity sha512-4sITrrzEbPcr0aNV28QyOmgn6C9yKiF8k92jn4buYAK8wmA5xo1qL3II5/gT1r7wxbXBflSduZ2K3FbtOrtGkA==
-  dependencies:
-    JSONStream "^1.3.5"
-    clone-deep "^4.0.1"
-    download-stats "^0.3.4"
-    moment "^2.24.0"
-    node-fetch "^2.6.0"
-    paged-request "^2.0.1"
-
-npm-run-path@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
-  integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=
-  dependencies:
-    path-key "^2.0.0"
-
-npm-run-path@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
-  integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
-  dependencies:
-    path-key "^3.0.0"
-
-number-is-nan@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
-  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
-
-oauth-sign@~0.9.0:
-  version "0.9.0"
-  resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
-  integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
-
-object-assign@^4, object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
-  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
-
-object-copy@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
-  integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw=
-  dependencies:
-    copy-descriptor "^0.1.0"
-    define-property "^0.2.5"
-    kind-of "^3.0.3"
-
-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-visit@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
-  integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=
-  dependencies:
-    isobject "^3.0.0"
-
-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:
-    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"
-  resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
-  integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=
-  dependencies:
-    for-own "^0.1.4"
-    is-extendable "^0.1.1"
-
-object.pick@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
-  integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=
-  dependencies:
-    isobject "^3.0.1"
-
-obuf@^1.0.0, obuf@^1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
-  integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
-
-octokit-pagination-methods@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
-  integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
-
-on-finished@^2.3.0, on-finished@~2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
-  integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
-  dependencies:
-    ee-first "1.1.1"
-
-on-headers@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
-  integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
-
 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"
-  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
   dependencies:
     wrappy "1"
 
-one-time@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45"
-  integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==
-  dependencies:
-    fn.name "1.x.x"
-
-onetime@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
-  integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=
-
-onetime@^5.1.0:
-  version "5.1.2"
-  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
-  integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
-  dependencies:
-    mimic-fn "^2.1.0"
-
-opn@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/opn/-/opn-3.0.3.tgz#b6d99e7399f78d65c3baaffef1fb288e9b85243a"
-  integrity sha1-ttmec5n3jWXDuq/+8fsojpuFJDo=
-  dependencies:
-    object-assign "^4.0.1"
-
-ordered-read-streams@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.3.0.tgz#7137e69b3298bb342247a1bbee3881c80e2fd78b"
-  integrity sha1-cTfmmzKYuzQiR6G77jiByA4v14s=
-  dependencies:
-    is-stream "^1.0.1"
-    readable-stream "^2.0.1"
-
-os-homedir@^1.0.0, os-homedir@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
-  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
-
-os-name@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801"
-  integrity sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==
-  dependencies:
-    macos-release "^2.2.0"
-    windows-release "^3.1.0"
-
-os-shim@^0.1.2:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/os-shim/-/os-shim-0.1.3.tgz#6b62c3791cf7909ea35ed46e17658bb417cb3917"
-  integrity sha1-a2LDeRz3kJ6jXtRuF2WLtBfLORc=
-
-os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
-  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
-
-osenv@^0.1.0, osenv@^0.1.3:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
-  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
-  dependencies:
-    os-homedir "^1.0.0"
-    os-tmpdir "^1.0.0"
-
-p-cancelable@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
-  integrity sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==
-
 p-cancelable@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"
   integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==
 
-p-finally@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
-  integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
-
-p-limit@^2.0.0:
-  version "2.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-locate@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
-  integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
-  dependencies:
-    p-limit "^2.0.0"
-
-p-map@^1.1.1:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b"
-  integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==
-
-p-timeout@^1.1.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-1.2.1.tgz#5eb3b353b7fce99f101a1038880bb054ebbea386"
-  integrity sha1-XrOzU7f86Z8QGhA4iAuwVOu+o4Y=
-  dependencies:
-    p-finally "^1.0.0"
-
-p-try@^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"
-  integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
-
-package-json@^2.0.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/package-json/-/package-json-2.4.0.tgz#0d15bd67d1cbbddbb2ca222ff2edb86bcb31a8bb"
-  integrity sha1-DRW9Z9HLvduyyiIv8u24a8sxqLs=
-  dependencies:
-    got "^5.0.0"
-    registry-auth-token "^3.0.1"
-    registry-url "^3.0.3"
-    semver "^5.1.0"
-
-package-json@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed"
-  integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=
-  dependencies:
-    got "^6.7.1"
-    registry-auth-token "^3.0.1"
-    registry-url "^3.0.3"
-    semver "^5.1.0"
-
-paged-request@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/paged-request/-/paged-request-2.0.2.tgz#4d621a08b8d6bee4440a0a92112354eeece5b5b0"
-  integrity sha512-NWrGqneZImDdcMU/7vMcAOo1bIi5h/pmpJqe7/jdsy85BA/s5MSaU/KlpxwW/IVPmIwBcq2uKPrBWWhEWhtxag==
-  dependencies:
-    axios "^0.21.1"
-
-pako@~0.2.0:
-  version "0.2.9"
-  resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
-  integrity sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=
-
-param-case@2.1.x:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247"
-  integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc=
-  dependencies:
-    no-case "^2.2.0"
-
-parse-glob@^3.0.4:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
-  integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw=
-  dependencies:
-    glob-base "^0.3.0"
-    is-dotfile "^1.0.0"
-    is-extglob "^1.0.0"
-    is-glob "^2.0.0"
-
-parse-json@^2.1.0, parse-json@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
-  integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=
-  dependencies:
-    error-ex "^1.2.0"
-
-parse-json@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
-  integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=
-  dependencies:
-    error-ex "^1.3.1"
-    json-parse-better-errors "^1.0.1"
-
-parse-json@^5.0.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
-  integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==
-  dependencies:
-    "@babel/code-frame" "^7.0.0"
-    error-ex "^1.3.1"
-    json-parse-even-better-errors "^2.3.0"
-    lines-and-columns "^1.1.6"
-
-parse-passwd@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
-  integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=
-
 parse5-html-rewriting-stream@^5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.1.tgz#fc18570ba0d09b5091250956d1c3f716ef0a07b7"
@@ -6405,7 +1444,7 @@
 parse5@^1.4.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94"
-  integrity sha1-m387DeMr543CQBsXVzzK8Pb1nZQ=
+  integrity sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA==
 
 parse5@^4.0.0:
   version "4.0.0"
@@ -6417,171 +1456,46 @@
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
   integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
 
-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.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.6.tgz#e1496e829e3ac2ff47f39a4dd044b32823c4a25a"
-  integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==
-
-parseurl@~1.3.3:
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
-  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
-
-pascalcase@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
-  integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
-
-path-dirname@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
-  integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=
-
-path-exists@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
-  integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=
-  dependencies:
-    pinkie-promise "^2.0.0"
-
-path-exists@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
-  integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
-
 path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
-  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+  integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
 
-path-is-inside@^1.0.1, path-is-inside@^1.0.2:
+path-is-inside@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
-  integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
+  integrity sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==
 
-path-key@^2.0.0, path-key@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
-  integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
-
-path-key@^3.0.0, path-key@^3.1.0:
+path-key@^3.1.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
   integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
 
-path-parse@^1.0.6:
+path-parse@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
 
-path-to-regexp@0.1.7:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
-  integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
-
-path-to-regexp@^1.0.1, path-to-regexp@^1.7.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
-  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
-  dependencies:
-    isarray "0.0.1"
-
-path-type@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
-  integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=
-  dependencies:
-    graceful-fs "^4.1.2"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-path-type@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
-  integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==
-  dependencies:
-    pify "^3.0.0"
-
-peek-stream@^1.1.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/peek-stream/-/peek-stream-1.1.3.tgz#3b35d84b7ccbbd262fff31dc10da56856ead6d67"
-  integrity sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==
-  dependencies:
-    buffer-from "^1.0.0"
-    duplexify "^3.5.0"
-    through2 "^2.0.3"
-
-pem@^1.8.3:
-  version "1.14.4"
-  resolved "https://registry.yarnpkg.com/pem/-/pem-1.14.4.tgz#a68c70c6e751ccc5b3b5bcd7af78b0aec1177ff9"
-  integrity sha512-v8lH3NpirgiEmbOqhx0vwQTxwi0ExsiWBGYh0jYNq7K6mQuO4gI6UEFlr6fLAdv9TPXRt6GqiwE37puQdIDS8g==
-  dependencies:
-    es6-promisify "^6.0.0"
-    md5 "^2.2.1"
-    os-tmpdir "^1.0.1"
-    which "^2.0.2"
-
 pend@~1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
-  integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA=
+  integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
 
-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=
-
-pify@^2.0.0, pify@^2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
-  integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
-
-pify@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
-  integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
-
-pify@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
-  integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
-
-pinkie-promise@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
-  integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o=
-  dependencies:
-    pinkie "^2.0.0"
-
-pinkie@^2.0.0:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
-  integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA=
+picomatch@^2.2.2:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+  integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
 
 plist@^2.0.1:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/plist/-/plist-2.1.0.tgz#57ccdb7a0821df21831217a3cad54e3e146a1025"
-  integrity sha1-V8zbeggh3yGDEhejytVOPhRqECU=
+  integrity sha512-yirJ+8SSb8o7pkfyNv+fTzUP0GbK52HMvh0MjMycCxvpL8rHiAfKhXU/3R5znSJnrGakV0WNZhr8yTR4//PjyA==
   dependencies:
     base64-js "1.2.0"
     xmlbuilder "8.2.2"
     xmldom "0.1.x"
 
-plylog@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/plylog/-/plylog-1.1.0.tgz#f6f354e2ae0b01f6db4ed111f4b3855da9c37417"
-  integrity sha512-/QnY5aSVaP54va6hruzNtAj02HpsLlAt7V5EndMrtq6ZUTZJKUja43rgiUtGXqm95yrSJjbZoPW0yQQQwLpoJA==
-  dependencies:
-    logform "^1.9.1"
-    winston "^3.0.0"
-    winston-transport "^4.2.0"
-
-polymer-analyzer@^3.0.0, polymer-analyzer@^3.1.3, polymer-analyzer@^3.2.2:
+polymer-analyzer@^3.2.2:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/polymer-analyzer/-/polymer-analyzer-3.2.4.tgz#7d76356620a2328e8bc9e30e47069f9729260ca1"
   integrity sha512-JmxUhMajTuC18tLXbTtu2+aN2x9bTX+4MvCD4IZKJ0rtAL8jWi1iRLfogpHJB4Ig9Dc8EEEuEYipLuzPFl3vqA==
@@ -6624,78 +1538,7 @@
     vscode-uri "=1.0.6"
     whatwg-url "^6.4.0"
 
-polymer-build@^3.1.0, polymer-build@^3.1.4:
-  version "3.1.4"
-  resolved "https://registry.yarnpkg.com/polymer-build/-/polymer-build-3.1.4.tgz#ab539f1a13d803518b13b73ffd09198431d98142"
-  integrity sha512-OhTOPG5Y/tK2HqGZ5XA/CVDh+TuOaDv7wTZWXDCg6hxeMgNKuljDMn2coyGU5NLM0pLbS+gwFAc2ZJ5cWHCHNg==
-  dependencies:
-    "@babel/core" "^7.0.0"
-    "@babel/plugin-external-helpers" "^7.0.0"
-    "@babel/plugin-proposal-async-generator-functions" "^7.0.0"
-    "@babel/plugin-proposal-object-rest-spread" "^7.0.0"
-    "@babel/plugin-syntax-async-generators" "^7.0.0"
-    "@babel/plugin-syntax-dynamic-import" "^7.0.0"
-    "@babel/plugin-syntax-import-meta" "^7.0.0"
-    "@babel/plugin-syntax-object-rest-spread" "^7.0.0"
-    "@babel/plugin-transform-arrow-functions" "^7.0.0"
-    "@babel/plugin-transform-async-to-generator" "^7.0.0"
-    "@babel/plugin-transform-block-scoped-functions" "^7.0.0"
-    "@babel/plugin-transform-block-scoping" "^7.0.0"
-    "@babel/plugin-transform-classes" "^7.0.0"
-    "@babel/plugin-transform-computed-properties" "^7.0.0"
-    "@babel/plugin-transform-destructuring" "^7.0.0"
-    "@babel/plugin-transform-duplicate-keys" "^7.0.0"
-    "@babel/plugin-transform-exponentiation-operator" "^7.0.0"
-    "@babel/plugin-transform-for-of" "^7.0.0"
-    "@babel/plugin-transform-function-name" "^7.0.0"
-    "@babel/plugin-transform-instanceof" "^7.0.0"
-    "@babel/plugin-transform-literals" "^7.0.0"
-    "@babel/plugin-transform-modules-amd" "^7.0.0"
-    "@babel/plugin-transform-object-super" "^7.0.0"
-    "@babel/plugin-transform-parameters" "^7.0.0"
-    "@babel/plugin-transform-regenerator" "^7.0.0"
-    "@babel/plugin-transform-shorthand-properties" "^7.0.0"
-    "@babel/plugin-transform-spread" "^7.0.0"
-    "@babel/plugin-transform-sticky-regex" "^7.0.0"
-    "@babel/plugin-transform-template-literals" "^7.0.0"
-    "@babel/plugin-transform-typeof-symbol" "^7.0.0"
-    "@babel/plugin-transform-unicode-regex" "^7.0.0"
-    "@babel/traverse" "^7.0.0"
-    "@polymer/esm-amd-loader" "^1.0.0"
-    "@types/babel-types" "^6.25.1"
-    "@types/babylon" "^6.16.2"
-    "@types/gulp-if" "0.0.33"
-    "@types/html-minifier" "^3.5.1"
-    "@types/is-windows" "^0.2.0"
-    "@types/mz" "0.0.31"
-    "@types/parse5" "^2.2.34"
-    "@types/resolve" "0.0.7"
-    "@types/uuid" "^3.4.3"
-    "@types/vinyl" "^2.0.0"
-    "@types/vinyl-fs" "^2.4.8"
-    babel-plugin-minify-guarded-expressions "^0.4.3"
-    babel-preset-minify "^0.5.0"
-    babylon "^7.0.0-beta.42"
-    css-slam "^2.1.2"
-    dom5 "^3.0.0"
-    gulp-if "^2.0.2"
-    html-minifier "^3.5.10"
-    matcher "^1.1.0"
-    multipipe "^1.0.2"
-    mz "^2.6.0"
-    parse5 "^4.0.0"
-    plylog "^1.0.0"
-    polymer-analyzer "^3.1.3"
-    polymer-bundler "^4.0.9"
-    polymer-project-config "^4.0.3"
-    regenerator-runtime "^0.11.1"
-    stream "0.0.2"
-    sw-precache "^5.1.1"
-    uuid "^3.2.1"
-    vinyl "^1.2.0"
-    vinyl-fs "^2.4.4"
-
-polymer-bundler@^4.0.10, polymer-bundler@^4.0.9:
+polymer-bundler@^4.0.10:
   version "4.0.10"
   resolved "https://registry.yarnpkg.com/polymer-bundler/-/polymer-bundler-4.0.10.tgz#abc8d33977652f031068d034c8104841e80d4cbb"
   integrity sha512-nwlN3LQlQDqbZ2sUH3394C/dHZUDHq8tpdS5HARvPDb0Q9IXWD+znOR1cr7wSjF0EZN4LiUH5hWyUoV4QSjhpQ==
@@ -6718,165 +1561,6 @@
     source-map "^0.5.6"
     vscode-uri "=1.0.6"
 
-polymer-cli@^1.9.11:
-  version "1.9.11"
-  resolved "https://registry.yarnpkg.com/polymer-cli/-/polymer-cli-1.9.11.tgz#0b5310732b787e07b811af96627ef0fd1263f5da"
-  integrity sha512-tiURjHDCOUUtDVPuVYvrfFI9PXe4OOUmBbn6Sg5GJNQ2POtP7r7hv+I5yI8P9qsxmalHTa19chVtf5/t9IBXDg==
-  dependencies:
-    "@octokit/rest" "^16.2.0"
-    "@types/chalk" "^2.2.0"
-    "@types/del" "^3.0.0"
-    "@types/findup-sync" "^0.3.29"
-    "@types/globby" "^6.1.0"
-    "@types/inquirer" "0.0.32"
-    "@types/merge-stream" "^1.0.28"
-    "@types/mz" "^0.0.31"
-    "@types/request" "2.0.3"
-    "@types/resolve" "0.0.4"
-    "@types/rimraf" "^0.0.28"
-    "@types/semver" "^5.3.30"
-    "@types/temp" "^0.8.28"
-    "@types/update-notifier" "^1.0.0"
-    "@types/vinyl" "^2.0.0"
-    "@types/vinyl-fs" "0.0.28"
-    "@types/yeoman-generator" "^2.0.3"
-    bower "^1.8.8"
-    bower-json "^0.8.1"
-    bower-logger "^0.2.2"
-    chalk "^2.4.2"
-    chokidar "^1.7.0"
-    command-line-args "^5.0.2"
-    command-line-commands "^2.0.1"
-    command-line-usage "^5.0.5"
-    del "^3.0.0"
-    findup-sync "^0.4.2"
-    globby "^8.0.1"
-    gunzip-maybe "^1.3.1"
-    inquirer "^1.0.2"
-    merge-stream "^1.0.1"
-    mz "^2.6.0"
-    plylog "^1.0.0"
-    polymer-analyzer "^3.2.2"
-    polymer-build "^3.1.4"
-    polymer-bundler "^4.0.9"
-    polymer-linter "^3.0.0"
-    polymer-project-config "^4.0.3"
-    polyserve "^0.27.15"
-    request "^2.72.0"
-    rimraf "^2.6.1"
-    semver "^5.3.0"
-    tar-fs "^1.12.0"
-    temp "^0.8.3"
-    update-notifier "^1.0.0"
-    validate-element-name "^2.1.1"
-    vinyl "^1.1.1"
-    vinyl-fs "^2.4.3"
-    web-component-tester "^6.9.0"
-    yeoman-environment "^1.5.2"
-    yeoman-generator "^3.1.1"
-
-polymer-linter@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/polymer-linter/-/polymer-linter-3.0.1.tgz#8804e1705fa2a7c263467b8a22da11bb764ee26b"
-  integrity sha512-eDh2CeswZz4Rwf8gfYXpMN66pieq4qJvP9bH3m39LLGm81hRePo4N5OHoQzR5unen1PUdmtjDv0Iicz3dTYEZQ==
-  dependencies:
-    "@types/fast-levenshtein" "0.0.1"
-    "@types/parse5" "^2.2.34"
-    babel-traverse "^6.26.0"
-    babel-types "^6.26.0"
-    cancel-token "^0.1.1"
-    css-what "^2.1.0"
-    dom5 "^3.0.0"
-    fast-levenshtein "^2.0.6"
-    parse5 "^4.0.0"
-    polymer-analyzer "^3.0.0"
-    shady-css-parser "^0.1.0"
-    stable "^0.1.6"
-    strip-indent "^2.0.0"
-    validate-element-name "^2.1.1"
-
-polymer-project-config@^4.0.0, polymer-project-config@^4.0.3:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/polymer-project-config/-/polymer-project-config-4.0.3.tgz#ef0c1a676ce4809907986c8e910745660de8024f"
-  integrity sha512-Drr+Imq+znhBC8XSt9pMlmPixoGnIOmleV5SD6mto1zOGC5oCDbSNsQL2v89DWOk+9aSUO79vnWwOmEPDSvYfw==
-  dependencies:
-    "@types/parse5" "^2.2.34"
-    browser-capabilities "^1.0.0"
-    jsonschema "^1.1.1"
-    minimatch-all "^1.1.0"
-    plylog "^1.0.0"
-    winston "^3.0.0"
-
-polyserve@^0.27.13, polyserve@^0.27.15:
-  version "0.27.15"
-  resolved "https://registry.yarnpkg.com/polyserve/-/polyserve-0.27.15.tgz#261fa5a0873c8d95fd7068598f44c9dac20cf9c4"
-  integrity sha512-AaFgANt+tUUVgHLw+BnaVYcn649JiwL1ru0TOZUKj1gGGn/Bq2S16gxql+1muGpRaAsgFu13Zu7k5XkwatwwSg==
-  dependencies:
-    "@types/compression" "^0.0.33"
-    "@types/content-type" "^1.1.0"
-    "@types/escape-html" "0.0.20"
-    "@types/express" "^4.0.36"
-    "@types/mime" "^2.0.0"
-    "@types/mz" "0.0.29"
-    "@types/opn" "^3.0.28"
-    "@types/parse5" "^2.2.34"
-    "@types/pem" "^1.8.1"
-    "@types/resolve" "0.0.6"
-    "@types/serve-static" "^1.7.31"
-    "@types/spdy" "^3.4.1"
-    bower-config "^1.4.1"
-    browser-capabilities "^1.0.0"
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    compression "^1.6.2"
-    content-type "^1.0.2"
-    cors "^2.8.4"
-    escape-html "^1.0.3"
-    express "^4.8.5"
-    find-port "^1.0.1"
-    http-proxy-middleware "^0.17.2"
-    lru-cache "^4.0.2"
-    mime "^2.3.1"
-    mz "^2.4.0"
-    opn "^3.0.2"
-    pem "^1.8.3"
-    polymer-build "^3.1.0"
-    polymer-project-config "^4.0.0"
-    requirejs "^2.3.4"
-    resolve "^1.5.0"
-    send "^0.16.2"
-    spdy "^3.3.3"
-
-posix-character-classes@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
-  integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
-
-prepend-http@^1.0.1:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
-  integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
-
-preserve@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
-  integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=
-
-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, pretty-bytes@^5.2.0:
-  version "5.6.0"
-  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
-  integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
-
-process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
-  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
-
 progress@2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
@@ -6901,40 +1585,6 @@
     "@types/node" "^10.1.0"
     long "^4.0.0"
 
-proxy-addr@~2.0.5:
-  version "2.0.7"
-  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
-  integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
-  dependencies:
-    forwarded "0.2.0"
-    ipaddr.js "1.9.1"
-
-pseudomap@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
-  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
-
-psl@^1.1.24, psl@^1.1.28:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
-  integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
-
-pump@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954"
-  integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw==
-  dependencies:
-    end-of-stream "^1.1.0"
-    once "^1.3.1"
-
-pump@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
-  integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==
-  dependencies:
-    end-of-stream "^1.1.0"
-    once "^1.3.1"
-
 pump@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
@@ -6943,54 +1593,21 @@
     end-of-stream "^1.1.0"
     once "^1.3.1"
 
-pumpify@^1.3.3:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce"
-  integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==
-  dependencies:
-    duplexify "^3.6.0"
-    inherits "^2.0.3"
-    pump "^2.0.0"
-
-punycode@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
-  integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
-
-punycode@^2.1.0, punycode@^2.1.1:
+punycode@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
-q@^1.4.1, q@^1.5.1:
+q@^1.4.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
-  integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
-
-qs@6.7.0:
-  version "6.7.0"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
-  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
-
-qs@~6.5.2:
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
-  integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
+  integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==
 
 quick-lru@^5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
   integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
 
-randomatic@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
-  integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==
-  dependencies:
-    is-number "^4.0.0"
-    kind-of "^6.0.0"
-    math-random "^1.0.1"
-
 randombytes@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@@ -6998,110 +1615,7 @@
   dependencies:
     safe-buffer "^5.1.0"
 
-range-parser@~1.2.0, range-parser@~1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
-  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
-
-raw-body@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
-  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
-  dependencies:
-    bytes "3.1.0"
-    http-errors "1.7.2"
-    iconv-lite "0.4.24"
-    unpipe "1.0.0"
-
-rc@^1.0.1, rc@^1.1.6:
-  version "1.2.8"
-  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
-  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
-  dependencies:
-    deep-extend "^0.6.0"
-    ini "~1.3.0"
-    minimist "^1.2.0"
-    strip-json-comments "~2.0.1"
-
-read-all-stream@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa"
-  integrity sha1-NcPhd/IHjveJ7kv6+kNzB06u9Po=
-  dependencies:
-    pinkie-promise "^2.0.0"
-    readable-stream "^2.0.0"
-
-read-chunk@^3.0.0, read-chunk@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/read-chunk/-/read-chunk-3.2.0.tgz#2984afe78ca9bfbbdb74b19387bf9e86289c16ca"
-  integrity sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==
-  dependencies:
-    pify "^4.0.1"
-    with-open-file "^0.1.6"
-
-read-pkg-up@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
-  integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=
-  dependencies:
-    find-up "^1.0.0"
-    read-pkg "^1.0.0"
-
-read-pkg-up@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978"
-  integrity sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==
-  dependencies:
-    find-up "^3.0.0"
-    read-pkg "^3.0.0"
-
-read-pkg-up@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-5.0.0.tgz#b6a6741cb144ed3610554f40162aa07a6db621b8"
-  integrity sha512-XBQjqOBtTzyol2CpsQOw8LHV0XbDZVG7xMMjmXAJomlVY03WOBRmYgDJETlvcg0H63AJvPRwT7GFi5rvOzUOKg==
-  dependencies:
-    find-up "^3.0.0"
-    read-pkg "^5.0.0"
-
-read-pkg@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
-  integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=
-  dependencies:
-    load-json-file "^1.0.0"
-    normalize-package-data "^2.3.2"
-    path-type "^1.0.0"
-
-read-pkg@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389"
-  integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=
-  dependencies:
-    load-json-file "^4.0.0"
-    normalize-package-data "^2.3.2"
-    path-type "^3.0.0"
-
-read-pkg@^5.0.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc"
-  integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==
-  dependencies:
-    "@types/normalize-package-data" "^2.4.0"
-    normalize-package-data "^2.5.0"
-    parse-json "^5.0.0"
-    type-fest "^0.6.0"
-
-readable-stream@1.1.x:
-  version "1.1.14"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
-  integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
-
-"readable-stream@2 || 3", readable-stream@^3.1.1, readable-stream@^3.4.0:
+readable-stream@^3.1.1, 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==
@@ -7110,161 +1624,16 @@
     string_decoder "^1.1.1"
     util-deprecate "^1.0.1"
 
-"readable-stream@>=1.0.33-1 <1.1.0-0":
-  version "1.0.34"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
-  integrity sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
-
-readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@^2.3.7, readable-stream@~2.3.6:
-  version "2.3.7"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
-  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.3"
-    isarray "~1.0.0"
-    process-nextick-args "~2.0.0"
-    safe-buffer "~5.1.1"
-    string_decoder "~1.1.1"
-    util-deprecate "~1.0.1"
-
-readdirp@^2.0.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
-  integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==
-  dependencies:
-    graceful-fs "^4.1.11"
-    micromatch "^3.1.10"
-    readable-stream "^2.0.2"
-
-rechoir@^0.6.2:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
-  integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=
-  dependencies:
-    resolve "^1.1.6"
-
-redent@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
-  integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=
-  dependencies:
-    indent-string "^2.1.0"
-    strip-indent "^1.0.1"
-
 reduce-flatten@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
   integrity sha1-JYx479FT3fk8tWEjf2EYTzaW4yc=
 
-regenerate-unicode-properties@^8.2.0:
-  version "8.2.0"
-  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
-  integrity sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==
-  dependencies:
-    regenerate "^1.4.0"
-
-regenerate@^1.4.0:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
-  integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
-
-regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
+regenerator-runtime@^0.11.0:
   version "0.11.1"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
   integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
 
-regenerator-runtime@^0.13.4:
-  version "0.13.9"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
-  integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
-
-regenerator-transform@^0.14.2:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4"
-  integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==
-  dependencies:
-    "@babel/runtime" "^7.8.4"
-
-regex-cache@^0.4.2:
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
-  integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==
-  dependencies:
-    is-equal-shallow "^0.1.3"
-
-regex-not@^1.0.0, regex-not@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"
-  integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==
-  dependencies:
-    extend-shallow "^3.0.2"
-    safe-regex "^1.1.0"
-
-regexpu-core@^4.7.1:
-  version "4.7.1"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6"
-  integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==
-  dependencies:
-    regenerate "^1.4.0"
-    regenerate-unicode-properties "^8.2.0"
-    regjsgen "^0.5.1"
-    regjsparser "^0.6.4"
-    unicode-match-property-ecmascript "^1.0.4"
-    unicode-match-property-value-ecmascript "^1.2.0"
-
-registry-auth-token@^3.0.1:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.4.0.tgz#d7446815433f5d5ed6431cd5dca21048f66b397e"
-  integrity sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==
-  dependencies:
-    rc "^1.1.6"
-    safe-buffer "^5.0.1"
-
-registry-url@^3.0.3:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
-  integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI=
-  dependencies:
-    rc "^1.0.1"
-
-regjsgen@^0.5.1:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733"
-  integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==
-
-regjsparser@^0.6.4:
-  version "0.6.9"
-  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.9.tgz#b489eef7c9a2ce43727627011429cf833a7183e6"
-  integrity sha512-ZqbNRz1SNjLAiYuwY0zoXW8Ne675IX5q+YHioAGbCw4X96Mjl2+dcX9B2ciaeyYjViDAfvIjFpQjJgLttTEERQ==
-  dependencies:
-    jsesc "~0.5.0"
-
-relateurl@0.2.x:
-  version "0.2.7"
-  resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
-  integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
-
-remove-trailing-separator@^1.0.1:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
-  integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8=
-
-repeat-element@^1.1.2:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9"
-  integrity sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==
-
-repeat-string@^1.5.2, repeat-string@^1.6.1:
-  version "1.6.1"
-  resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
-  integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
-
 repeating@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
@@ -7272,111 +1641,19 @@
   dependencies:
     is-finite "^1.0.0"
 
-replace-ext@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924"
-  integrity sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=
-
-replace-ext@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a"
-  integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==
-
-request@2.88.0:
-  version "2.88.0"
-  resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
-  integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
-  dependencies:
-    aws-sign2 "~0.7.0"
-    aws4 "^1.8.0"
-    caseless "~0.12.0"
-    combined-stream "~1.0.6"
-    extend "~3.0.2"
-    forever-agent "~0.6.1"
-    form-data "~2.3.2"
-    har-validator "~5.1.0"
-    http-signature "~1.2.0"
-    is-typedarray "~1.0.0"
-    isstream "~0.1.2"
-    json-stringify-safe "~5.0.1"
-    mime-types "~2.1.19"
-    oauth-sign "~0.9.0"
-    performance-now "^2.1.0"
-    qs "~6.5.2"
-    safe-buffer "^5.1.2"
-    tough-cookie "~2.4.3"
-    tunnel-agent "^0.6.0"
-    uuid "^3.3.2"
-
-request@^2.72.0, request@^2.85.0:
-  version "2.88.2"
-  resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
-  integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
-  dependencies:
-    aws-sign2 "~0.7.0"
-    aws4 "^1.8.0"
-    caseless "~0.12.0"
-    combined-stream "~1.0.6"
-    extend "~3.0.2"
-    forever-agent "~0.6.1"
-    form-data "~2.3.2"
-    har-validator "~5.1.3"
-    http-signature "~1.2.0"
-    is-typedarray "~1.0.0"
-    isstream "~0.1.2"
-    json-stringify-safe "~5.0.1"
-    mime-types "~2.1.19"
-    oauth-sign "~0.9.0"
-    performance-now "^2.1.0"
-    qs "~6.5.2"
-    safe-buffer "^5.1.2"
-    tough-cookie "~2.5.0"
-    tunnel-agent "^0.6.0"
-    uuid "^3.3.2"
-
-requirejs@^2.3.4:
-  version "2.3.6"
-  resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.6.tgz#e5093d9601c2829251258c0b9445d4d19fa9e7c9"
-  integrity sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==
-
-requires-port@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
-  integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
-
 resolve-alpn@^1.0.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9"
   integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==
 
-resolve-dir@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e"
-  integrity sha1-shklmlYC+sXFxJatiUpujMQwJh4=
+resolve@^1.11.0, resolve@^1.11.1, resolve@^1.5.0:
+  version "1.22.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
+  integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
   dependencies:
-    expand-tilde "^1.2.2"
-    global-modules "^0.2.3"
-
-resolve-dir@^1.0.0, resolve-dir@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
-  integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=
-  dependencies:
-    expand-tilde "^2.0.0"
-    global-modules "^1.0.0"
-
-resolve-url@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
-  integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
-
-resolve@^1.1.6, resolve@^1.10.0, resolve@^1.11.1, resolve@^1.5.0:
-  version "1.20.0"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
-  integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
-  dependencies:
-    is-core-module "^2.2.0"
-    path-parse "^1.0.6"
+    is-core-module "^2.8.1"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
 
 responselike@^2.0.0:
   version "2.0.0"
@@ -7385,34 +1662,6 @@
   dependencies:
     lowercase-keys "^2.0.0"
 
-restore-cursor@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
-  integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=
-  dependencies:
-    exit-hook "^1.0.0"
-    onetime "^1.0.0"
-
-restore-cursor@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
-  integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
-  dependencies:
-    onetime "^5.1.0"
-    signal-exit "^3.0.2"
-
-ret@~0.1.10:
-  version "0.1.15"
-  resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
-  integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
-
-rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.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:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
@@ -7420,12 +1669,26 @@
   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==
+rollup-plugin-commonjs@^10.1.0:
+  version "10.1.0"
+  resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-10.1.0.tgz#417af3b54503878e084d127adf4d1caf8beb86fb"
+  integrity sha512-jlXbjZSQg8EIeAAvepNwhJj++qJWNJw1Cl0YnOqKtP5Djx+fFGkp3WRh+W0ASCaFG5w1jhmzDxgu3SJuVxPF4Q==
   dependencies:
-    glob "^7.1.3"
+    estree-walker "^0.6.1"
+    is-reference "^1.1.2"
+    magic-string "^0.25.2"
+    resolve "^1.11.0"
+    rollup-pluginutils "^2.8.1"
+
+rollup-plugin-define@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/rollup-plugin-define/-/rollup-plugin-define-1.0.1.tgz#45b027cec9d2e30df71118efa156170e3846fd3d"
+  integrity sha512-SM/CKFpLvWq5xBEf84ff/ooT3KodXPVITCkRliyNccuq8SZMpzthN/Bp7JkWScbGTX5lo1SF3cjxKKDjnnFCuA==
+  dependencies:
+    "@rollup/pluginutils" "^4.0.0"
+    ast-matcher "^1.1.1"
+    escape-string-regexp "^4.0.0"
+    magic-string "^0.25.7"
 
 rollup-plugin-node-resolve@^5.2.0:
   version "5.2.0"
@@ -7466,77 +1729,17 @@
     acorn "^7.1.0"
 
 rollup@^2.3.4:
-  version "2.56.3"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.56.3.tgz#b63edadd9851b0d618a6d0e6af8201955a77aeff"
-  integrity sha512-Au92NuznFklgQCUcV96iXlxUbHuB1vQMaH76DHl5M11TotjOHwqk9CwcrT78+Tnv4FN9uTBxq6p4EJoYkpyekg==
+  version "2.75.5"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.75.5.tgz#7985c1962483235dd07966f09fdad5c5f89f16d0"
+  integrity sha512-JzNlJZDison3o2mOxVmb44Oz7t74EfSd1SQrplQk0wSaXV7uLQXtVdHbxlcT3w+8tZ1TL4r/eLfc7nAbz38BBA==
   optionalDependencies:
     fsevents "~2.3.2"
 
-run-async@^2.0.0, run-async@^2.2.0, run-async@^2.4.0:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
-  integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==
-
-rx@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
-  integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=
-
-rxjs@^6.4.0, rxjs@^6.6.0:
-  version "6.6.7"
-  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
-  integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==
-  dependencies:
-    tslib "^1.9.0"
-
-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.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
+safe-buffer@^5.1.0, safe-buffer@~5.2.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
 
-safe-regex@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e"
-  integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4=
-  dependencies:
-    ret "~0.1.10"
-
-"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
-  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
-
-samsam@1.x, samsam@^1.1.3:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
-  integrity sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==
-
-sauce-connect-launcher@^1.0.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.2.tgz#dfc675a258550809a8eaf457eb9162b943ddbaf0"
-  integrity sha512-wf0coUlidJ7rmeClgVVBh6Kw55/yalZCY/Un5RgjSnTXRAeGqagnTsTYpZaqC4dCtrY4myuYpOAZXCdbO7lHfQ==
-  dependencies:
-    adm-zip "~0.4.3"
-    async "^2.1.2"
-    https-proxy-agent "^5.0.0"
-    lodash "^4.16.6"
-    rimraf "^2.5.4"
-
-scoped-regex@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-1.0.0.tgz#a346bb1acd4207ae70bd7c0c7ca9e566b6baddb8"
-  integrity sha1-o0a7Gs1CB65wvXwMfKnlZra63bg=
-
-select-hose@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
-  integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=
-
 selenium-standalone@^6.7.0:
   version "6.24.0"
   resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.24.0.tgz#cca7c1c36bfa3429078a8e6a1a4fd373f641a7c8"
@@ -7555,73 +1758,11 @@
     which "^2.0.2"
     yauzl "^2.10.0"
 
-semver-diff@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
-  integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=
-  dependencies:
-    semver "^5.0.3"
-
-"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0:
-  version "5.7.1"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
-  integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
-
 semver@5.6.0:
   version "5.6.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
   integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
 
-semver@^6.0.0, semver@^6.3.0:
-  version "6.3.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
-  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
-
-semver@^7.1.3, semver@^7.2.1:
-  version "7.3.5"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
-  integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
-  dependencies:
-    lru-cache "^6.0.0"
-
-send@0.17.1:
-  version "0.17.1"
-  resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
-  integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
-  dependencies:
-    debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    fresh "0.5.2"
-    http-errors "~1.7.2"
-    mime "1.6.0"
-    ms "2.1.1"
-    on-finished "~2.3.0"
-    range-parser "~1.2.1"
-    statuses "~1.5.0"
-
-send@^0.16.1, send@^0.16.2:
-  version "0.16.2"
-  resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1"
-  integrity sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==
-  dependencies:
-    debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    fresh "0.5.2"
-    http-errors "~1.6.2"
-    mime "1.4.1"
-    ms "2.0.0"
-    on-finished "~2.3.0"
-    range-parser "~1.2.0"
-    statuses "~1.4.0"
-
 serialize-javascript@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
@@ -7629,72 +1770,11 @@
   dependencies:
     randombytes "^2.1.0"
 
-serve-static@1.14.1:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
-  integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
-  dependencies:
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    parseurl "~1.3.3"
-    send "0.17.1"
-
-server-destroy@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd"
-  integrity sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0=
-
-serviceworker-cache-polyfill@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/serviceworker-cache-polyfill/-/serviceworker-cache-polyfill-4.0.0.tgz#de19ee73bef21ab3c0740a37b33db62464babdeb"
-  integrity sha1-3hnuc77yGrPAdAo3sz22JGS6ves=
-
-set-getter@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/set-getter/-/set-getter-0.1.1.tgz#a3110e1b461d31a9cfc8c5c9ee2e9737ad447102"
-  integrity sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw==
-  dependencies:
-    to-object-path "^0.3.0"
-
-set-value@^2.0.0, set-value@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
-  integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==
-  dependencies:
-    extend-shallow "^2.0.1"
-    is-extendable "^0.1.1"
-    is-plain-object "^2.0.3"
-    split-string "^3.0.1"
-
-setprototypeof@1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
-  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
-
-setprototypeof@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
-  integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
-
 shady-css-parser@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/shady-css-parser/-/shady-css-parser-0.1.0.tgz#534dc79c8ca5884c5ed92a4e5a13d6d863bca428"
   integrity sha512-irfJUUkEuDlNHKZNAp2r7zOyMlmbfVJ+kWSfjlCYYUx/7dJnANLCyTzQZsuxy5NJkvtNwSxY5Gj8MOlqXUQPyA==
 
-shallow-clone@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3"
-  integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==
-  dependencies:
-    kind-of "^6.0.2"
-
-shebang-command@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
-  integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=
-  dependencies:
-    shebang-regex "^1.0.0"
-
 shebang-command@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@@ -7702,178 +1782,11 @@
   dependencies:
     shebang-regex "^3.0.0"
 
-shebang-regex@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
-  integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
-
 shebang-regex@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
   integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 
-shelljs@^0.8.0, shelljs@^0.8.4:
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2"
-  integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==
-  dependencies:
-    glob "^7.0.0"
-    interpret "^1.0.0"
-    rechoir "^0.6.2"
-
-signal-exit@^3.0.0, signal-exit@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
-  integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
-
-simple-swizzle@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
-  integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
-  dependencies:
-    is-arrayish "^0.3.1"
-
-sinon-chai@^2.10.0:
-  version "2.14.0"
-  resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-2.14.0.tgz#da7dd4cc83cd6a260b67cca0f7a9fdae26a1205d"
-  integrity sha512-9stIF1utB0ywNHNT7RgiXbdmen8QDCRsrTjw+G9TgKt1Yexjiv8TOWZ6WHsTPz57Yky3DIswZvEqX8fpuHNDtQ==
-
-sinon@^2.3.5:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.4.1.tgz#021fd64b54cb77d9d2fb0d43cdedfae7629c3a36"
-  integrity sha512-vFTrO9Wt0ECffDYIPSP/E5bBugt0UjcBQOfQUMh66xzkyPEnhl/vM2LRZi2ajuTdkH07sA6DzrM6KvdvGIH8xw==
-  dependencies:
-    diff "^3.1.0"
-    formatio "1.2.0"
-    lolex "^1.6.0"
-    native-promise-only "^0.8.1"
-    path-to-regexp "^1.7.0"
-    samsam "^1.1.3"
-    text-encoding "0.6.4"
-    type-detect "^4.0.0"
-
-slash@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
-  integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=
-
-slash@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
-  integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==
-
-slide@^1.1.5:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
-  integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=
-
-snapdragon-node@^2.0.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
-  integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==
-  dependencies:
-    define-property "^1.0.0"
-    isobject "^3.0.0"
-    snapdragon-util "^3.0.1"
-
-snapdragon-util@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2"
-  integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==
-  dependencies:
-    kind-of "^3.2.0"
-
-snapdragon@^0.8.1:
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d"
-  integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==
-  dependencies:
-    base "^0.11.1"
-    debug "^2.2.0"
-    define-property "^0.2.5"
-    extend-shallow "^2.0.1"
-    map-cache "^0.2.2"
-    source-map "^0.5.6"
-    source-map-resolve "^0.5.0"
-    use "^3.1.0"
-
-socket.io-adapter@~1.1.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9"
-  integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==
-
-socket.io-client@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.4.0.tgz#aafb5d594a3c55a34355562fc8aea22ed9119a35"
-  integrity sha512-M6xhnKQHuuZd4Ba9vltCLT9oa+YvTsP8j9NcEiLElfIg8KeYPyhWOes6x4t+LTAC8enQbE/995AdTem2uNyKKQ==
-  dependencies:
-    backo2 "1.0.2"
-    component-bind "1.0.0"
-    component-emitter "~1.3.0"
-    debug "~3.1.0"
-    engine.io-client "~3.5.0"
-    has-binary2 "~1.0.2"
-    indexof "0.0.1"
-    parseqs "0.0.6"
-    parseuri "0.0.6"
-    socket.io-parser "~3.3.0"
-    to-array "0.1.4"
-
-socket.io-parser@~3.3.0:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.2.tgz#ef872009d0adcf704f2fbe830191a14752ad50b6"
-  integrity sha512-FJvDBuOALxdCI9qwRrO/Rfp9yfndRtc1jSgVgV8FDraihmSP/MLGD5PEuJrNfjALvcQ+vMDM/33AWOYP/JSjDg==
-  dependencies:
-    component-emitter "~1.3.0"
-    debug "~3.1.0"
-    isarray "2.0.1"
-
-socket.io-parser@~3.4.0:
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.1.tgz#b06af838302975837eab2dc980037da24054d64a"
-  integrity sha512-11hMgzL+WCLWf1uFtHSNvliI++tcRUWdoeYuwIl+Axvwy9z2gQM+7nJyN3STj1tLj5JyIUH8/gpDGxzAlDdi0A==
-  dependencies:
-    component-emitter "1.2.1"
-    debug "~4.1.0"
-    isarray "2.0.1"
-
-socket.io@^2.0.3:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.4.1.tgz#95ad861c9a52369d7f1a68acf0d4a1b16da451d2"
-  integrity sha512-Si18v0mMXGAqLqCVpTxBa8MGqriHGQh8ccEOhmsmNS3thNCGBwO8WGrwMibANsWtQQ5NStdZwHqZR3naJVFc3w==
-  dependencies:
-    debug "~4.1.0"
-    engine.io "~3.5.0"
-    has-binary2 "~1.0.2"
-    socket.io-adapter "~1.1.0"
-    socket.io-client "2.4.0"
-    socket.io-parser "~3.4.0"
-
-sort-keys-length@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188"
-  integrity sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=
-  dependencies:
-    sort-keys "^1.0.0"
-
-sort-keys@^1.0.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
-  integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0=
-  dependencies:
-    is-plain-obj "^1.0.0"
-
-source-map-resolve@^0.5.0:
-  version "0.5.3"
-  resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
-  integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==
-  dependencies:
-    atob "^2.1.2"
-    decode-uri-component "^0.2.0"
-    resolve-url "^0.2.1"
-    source-map-url "^0.4.0"
-    urix "^0.1.0"
-
 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"
@@ -7883,193 +1796,33 @@
     source-map "^0.6.0"
 
 source-map-support@~0.5.12:
-  version "0.5.19"
-  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
-  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
+  version "0.5.21"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
+  integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
   dependencies:
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
-source-map-url@^0.4.0:
-  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:
+source-map@^0.5.6, source-map@^0.5.7:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
   integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
 
-source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
+source-map@^0.6.0, source-map@~0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
-spawn-sync@^1.0.15:
-  version "1.0.15"
-  resolved "https://registry.yarnpkg.com/spawn-sync/-/spawn-sync-1.0.15.tgz#b00799557eb7fb0c8376c29d44e8a1ea67e57476"
-  integrity sha1-sAeZVX63+wyDdsKdROih6mfldHY=
-  dependencies:
-    concat-stream "^1.4.7"
-    os-shim "^0.1.2"
-
-spdx-correct@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
-  integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==
-  dependencies:
-    spdx-expression-parse "^3.0.0"
-    spdx-license-ids "^3.0.0"
-
-spdx-exceptions@^2.1.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d"
-  integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
-
-spdx-expression-parse@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
-  integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
-  dependencies:
-    spdx-exceptions "^2.1.0"
-    spdx-license-ids "^3.0.0"
-
-spdx-license-ids@^3.0.0:
-  version "3.0.10"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz#0d9becccde7003d6c658d487dd48a32f0bf3014b"
-  integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==
-
-spdy-transport@^2.0.18:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.1.1.tgz#c54815d73858aadd06ce63001e7d25fa6441623b"
-  integrity sha512-q7D8c148escoB3Z7ySCASadkegMmUZW8Wb/Q1u0/XBgDKMO880rLQDj8Twiew/tYi7ghemKUi/whSYOwE17f5Q==
-  dependencies:
-    debug "^2.6.8"
-    detect-node "^2.0.3"
-    hpack.js "^2.1.6"
-    obuf "^1.1.1"
-    readable-stream "^2.2.9"
-    safe-buffer "^5.0.1"
-    wbuf "^1.7.2"
-
-spdy@^3.3.3:
-  version "3.4.7"
-  resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.7.tgz#42ff41ece5cc0f99a3a6c28aabb73f5c3b03acbc"
-  integrity sha1-Qv9B7OXMD5mjpsKKq7c/XDsDrLw=
-  dependencies:
-    debug "^2.6.8"
-    handle-thing "^1.2.5"
-    http-deceiver "^1.2.7"
-    safe-buffer "^5.0.1"
-    select-hose "^2.0.0"
-    spdy-transport "^2.0.18"
-
-split-string@^3.0.1, split-string@^3.0.2:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
-  integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==
-  dependencies:
-    extend-shallow "^3.0.0"
-
-sshpk@^1.7.0:
-  version "1.16.1"
-  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
-  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
-  dependencies:
-    asn1 "~0.2.3"
-    assert-plus "^1.0.0"
-    bcrypt-pbkdf "^1.0.0"
-    dashdash "^1.12.0"
-    ecc-jsbn "~0.1.1"
-    getpass "^0.1.1"
-    jsbn "~0.1.0"
-    safer-buffer "^2.0.2"
-    tweetnacl "~0.14.0"
+sourcemap-codec@^1.4.8:
+  version "1.4.8"
+  resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
+  integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
 
 stable@^0.1.6:
   version "0.1.8"
   resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
   integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
 
-stack-trace@0.0.x:
-  version "0.0.10"
-  resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
-  integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
-
-stacky@^1.3.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/stacky/-/stacky-1.3.1.tgz#3f117e5187b9a73d23f876d69f05c85b11804a12"
-  integrity sha1-PxF+UYe5pz0j+HbWnwXIWxGAShI=
-  dependencies:
-    chalk "^1.1.1"
-    lodash "^3.0.0"
-
-static-extend@^0.1.1:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
-  integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=
-  dependencies:
-    define-property "^0.2.5"
-    object-copy "^0.1.0"
-
-"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
-  integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
-
-statuses@~1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
-  integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==
-
-stream-shift@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
-  integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
-
-stream@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.2.tgz#7f5363f057f6592c5595f00bc80a27f5cec1f0ef"
-  integrity sha1-f1Nj8Ff2WSxVlfALyAon9c7B8O8=
-  dependencies:
-    emitter-component "^1.1.1"
-
-streamsearch@0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
-  integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
-
-string-template@~0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
-  integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0=
-
-string-width@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
-  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
-  dependencies:
-    code-point-at "^1.0.0"
-    is-fullwidth-code-point "^1.0.0"
-    strip-ansi "^3.0.0"
-
-string-width@^2.0.0, string-width@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
-  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
-  dependencies:
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^4.0.0"
-
-string-width@^4.1.0:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
-  integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.0"
-
 string_decoder@^1.1.1:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
@@ -8077,18 +1830,6 @@
   dependencies:
     safe-buffer "~5.2.0"
 
-string_decoder@~0.10.x:
-  version "0.10.31"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
-  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
-
-string_decoder@~1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
-  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
-  dependencies:
-    safe-buffer "~5.1.0"
-
 strip-ansi@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
@@ -8096,87 +1837,11 @@
   dependencies:
     ansi-regex "^2.0.0"
 
-strip-ansi@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
-  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
-  dependencies:
-    ansi-regex "^3.0.0"
-
-strip-ansi@^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-ansi@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.1.1.tgz#39e8a98d044d150660abe4a6808acf70bb7bc991"
-  integrity sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=
-
-strip-bom-buf@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom-buf/-/strip-bom-buf-1.0.0.tgz#1cb45aaf57530f4caf86c7f75179d2c9a51dd572"
-  integrity sha1-HLRar1dTD0yvhsf3UXnSyaUd1XI=
-  dependencies:
-    is-utf8 "^0.2.1"
-
-strip-bom-stream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz#e7144398577d51a6bed0fa1994fa05f43fd988ee"
-  integrity sha1-5xRDmFd9Uaa+0PoZlPoF9D/ZiO4=
-  dependencies:
-    first-chunk-stream "^1.0.0"
-    strip-bom "^2.0.0"
-
-strip-bom-stream@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz#f87db5ef2613f6968aa545abfe1ec728b6a829ca"
-  integrity sha1-+H217yYT9paKpUWr/h7HKLaoKco=
-  dependencies:
-    first-chunk-stream "^2.0.0"
-    strip-bom "^2.0.0"
-
-strip-bom@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
-  integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=
-  dependencies:
-    is-utf8 "^0.2.0"
-
-strip-bom@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
-  integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
-
-strip-eof@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
-  integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
-
-strip-final-newline@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
-  integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
-
-strip-indent@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
-  integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=
-  dependencies:
-    get-stdin "^4.0.1"
-
 strip-indent@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
   integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
 
-strip-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=
-
 supports-color@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
@@ -8196,36 +1861,10 @@
   dependencies:
     has-flag "^3.0.0"
 
-supports-color@^7.1.0:
-  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"
-
-sw-precache@^5.1.1:
-  version "5.2.1"
-  resolved "https://registry.yarnpkg.com/sw-precache/-/sw-precache-5.2.1.tgz#06134f319eec68f3b9583ce9a7036b1c119f7179"
-  integrity sha512-8FAy+BP/FXE+ILfiVTt+GQJ6UEf4CVHD9OfhzH0JX+3zoy2uFk7Vn9EfXASOtVmmIVbL3jE/W8Z66VgPSZcMhw==
-  dependencies:
-    dom-urls "^1.1.0"
-    es6-promise "^4.0.5"
-    glob "^7.1.1"
-    lodash.defaults "^4.2.0"
-    lodash.template "^4.4.0"
-    meow "^3.7.0"
-    mkdirp "^0.5.1"
-    pretty-bytes "^4.0.2"
-    sw-toolbox "^3.4.0"
-    update-notifier "^2.3.0"
-
-sw-toolbox@^3.4.0:
-  version "3.6.0"
-  resolved "https://registry.yarnpkg.com/sw-toolbox/-/sw-toolbox-3.6.0.tgz#26df1d1c70348658e4dea2884319149b7b3183b5"
-  integrity sha1-Jt8dHHA0hljk3qKIQxkUm3sxg7U=
-  dependencies:
-    path-to-regexp "^1.0.1"
-    serviceworker-cache-polyfill "^4.0.0"
+supports-preserve-symlinks-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
 table-layout@^0.3.0:
   version "0.3.0"
@@ -8250,17 +1889,7 @@
     typical "^2.6.1"
     wordwrapjs "^3.0.0"
 
-tar-fs@^1.12.0:
-  version "1.16.3"
-  resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-1.16.3.tgz#966a628841da2c4010406a82167cbd5e0c72d509"
-  integrity sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw==
-  dependencies:
-    chownr "^1.0.1"
-    mkdirp "^0.5.1"
-    pump "^1.0.0"
-    tar-stream "^1.1.2"
-
-tar-stream@2.2.0, tar-stream@^2.1.0:
+tar-stream@2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
   integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
@@ -8271,43 +1900,6 @@
     inherits "^2.0.3"
     readable-stream "^3.1.1"
 
-tar-stream@^1.1.2:
-  version "1.6.2"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555"
-  integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==
-  dependencies:
-    bl "^1.0.0"
-    buffer-alloc "^1.2.0"
-    end-of-stream "^1.0.0"
-    fs-constants "^1.0.0"
-    readable-stream "^2.3.0"
-    to-buffer "^1.1.1"
-    xtend "^4.0.0"
-
-temp@^0.8.1, temp@^0.8.3:
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.4.tgz#8c97a33a4770072e0a05f919396c7665a7dd59f2"
-  integrity sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==
-  dependencies:
-    rimraf "~2.6.2"
-
-term-size@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
-  integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=
-  dependencies:
-    execa "^0.7.0"
-
-ternary-stream@^2.0.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-2.1.1.tgz#4ad64b98668d796a085af2c493885a435a8a8bfc"
-  integrity sha512-j6ei9hxSoyGlqTmoMjOm+QNvUKDOIY6bNl4Uh1lhBvl6yjPW2iLqxDUYyfDPZknQ4KdRziFl+ec99iT4l7g0cw==
-  dependencies:
-    duplexify "^3.5.0"
-    fork-stream "^0.0.4"
-    merge-stream "^1.0.0"
-    through2 "^2.0.1"
-
 terser@^4.6.2:
   version "4.8.0"
   resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
@@ -8325,126 +1917,6 @@
     array-back "^1.0.3"
     typical "^2.6.0"
 
-text-encoding@0.6.4:
-  version "0.6.4"
-  resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
-  integrity sha1-45mpgiV6J22uQou5KEXLcb3CbRk=
-
-text-hex@1.0.x:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
-  integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
-
-text-table@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
-  integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
-
-textextensions@^2.5.0:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.6.0.tgz#d7e4ab13fe54e32e08873be40d51b74229b00fc4"
-  integrity sha512-49WtAWS+tcsy93dRt6P0P3AMD2m5PvXRhuEA0kaXos5ZLlujtYmpmFsB+QvWUSxE1ZsstmYXfQ7L40+EcQgpAQ==
-
-thenify-all@^1.0.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
-  integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=
-  dependencies:
-    thenify ">= 3.1.0 < 4"
-
-"thenify@>= 3.1.0 < 4":
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
-  integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
-  dependencies:
-    any-promise "^1.0.0"
-
-through2-filter@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-2.0.0.tgz#60bc55a0dacb76085db1f9dae99ab43f83d622ec"
-  integrity sha1-YLxVoNrLdghdsfna6Zq0P4PWIuw=
-  dependencies:
-    through2 "~2.0.0"
-    xtend "~4.0.0"
-
-through2-filter@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"
-  integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==
-  dependencies:
-    through2 "~2.0.0"
-    xtend "~4.0.0"
-
-through2@^0.6.0:
-  version "0.6.5"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48"
-  integrity sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=
-  dependencies:
-    readable-stream ">=1.0.33-1 <1.1.0-0"
-    xtend ">=4.0.0 <4.1.0-0"
-
-through2@^2.0.0, through2@^2.0.1, through2@^2.0.3, through2@~2.0.0:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
-  integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
-  dependencies:
-    readable-stream "~2.3.6"
-    xtend "~4.0.1"
-
-through2@^3.0.0, through2@^3.0.1, through2@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.2.tgz#99f88931cfc761ec7678b41d5d7336b5b6a07bf4"
-  integrity sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==
-  dependencies:
-    inherits "^2.0.4"
-    readable-stream "2 || 3"
-
-"through@>=2.2.7 <3", through@^2.3.6:
-  version "2.3.8"
-  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
-  integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
-
-timed-out@^3.0.0:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-3.1.3.tgz#95860bfcc5c76c277f8f8326fd0f5b2e20eba217"
-  integrity sha1-lYYL/MXHbCd/j4Mm/Q9bLiDrohc=
-
-timed-out@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
-  integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=
-
-tmp@^0.0.29:
-  version "0.0.29"
-  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.29.tgz#f25125ff0dd9da3ccb0c2dd371ee1288bb9128c0"
-  integrity sha1-8lEl/w3Z2jzLDC3Tce4SiLuRKMA=
-  dependencies:
-    os-tmpdir "~1.0.1"
-
-tmp@^0.0.33:
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
-  integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
-  dependencies:
-    os-tmpdir "~1.0.2"
-
-to-absolute-glob@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-0.1.1.tgz#1cdfa472a9ef50c239ee66999b662ca0eb39937f"
-  integrity sha1-HN+kcqnvUMI57maZm2YsoOs5k38=
-  dependencies:
-    extend-shallow "^2.0.1"
-
-to-array@0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
-  integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
-
-to-buffer@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80"
-  integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==
-
 to-fast-properties@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
@@ -8455,52 +1927,6 @@
   resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
   integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
 
-to-object-path@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
-  integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=
-  dependencies:
-    kind-of "^3.0.2"
-
-to-regex-range@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
-  integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=
-  dependencies:
-    is-number "^3.0.0"
-    repeat-string "^1.6.1"
-
-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"
-  integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==
-  dependencies:
-    define-property "^2.0.2"
-    extend-shallow "^3.0.2"
-    regex-not "^1.0.2"
-    safe-regex "^1.1.0"
-
-toidentifier@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
-  integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
-
-tough-cookie@~2.4.3:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
-  integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
-  dependencies:
-    psl "^1.1.24"
-    punycode "^1.4.1"
-
-tough-cookie@~2.5.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
-  integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
-  dependencies:
-    psl "^1.1.28"
-    punycode "^2.1.1"
-
 tr46@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
@@ -8508,77 +1934,27 @@
   dependencies:
     punycode "^2.1.0"
 
-trim-newlines@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
-  integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
-
 trim-right@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
   integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
 
-triple-beam@^1.2.0, triple-beam@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
-  integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
-
-tslib@^1.8.1, tslib@^1.9.0:
+tslib@^1.8.1:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
-tsutils@2.27.2:
-  version "2.27.2"
-  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.27.2.tgz#60ba88a23d6f785ec4b89c6e8179cac9b431f1c7"
-  integrity sha512-qf6rmT84TFMuxAKez2pIfR8UCai49iQsfB7YWVjV1bKpy/d0PWT5rEOSM6La9PiHZ0k1RRZQiwVdVJfQ3BPHgg==
+tsutils@3.21.0:
+  version "3.21.0"
+  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
+  integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==
   dependencies:
     tslib "^1.8.1"
 
-tunnel-agent@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
-  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
-  dependencies:
-    safe-buffer "^5.0.1"
-
-tweetnacl@^0.14.3, tweetnacl@~0.14.0:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
-  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
-
-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.21.3:
-  version "0.21.3"
-  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
-  integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
-
-type-fest@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
-  integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
-
-type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
-  version "1.6.18"
-  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
-  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
-  dependencies:
-    media-typer "0.3.0"
-    mime-types "~2.1.24"
-
-typedarray@^0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
-  integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
-
-typescript@4.3.2:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805"
-  integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==
+typescript@^4.7.2:
+  version "4.7.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.2.tgz#1f9aa2ceb9af87cca227813b4310fff0b51593c4"
+  integrity sha512-Mamb1iX2FDUpcTRzltPxgWMKy3fhg0TN378ylbktPGPK/99KbDtMQ4W1hwgsbPAsG3a0xKa1vmw4VKZQbkvz5A==
 
 typical@^2.6.0, typical@^2.6.1:
   version "2.6.1"
@@ -8590,309 +1966,16 @@
   resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
   integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
 
-ua-parser-js@^0.7.15:
-  version "0.7.28"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
-  integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
-
-uglify-js@3.4.x:
-  version "3.4.10"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f"
-  integrity sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==
-  dependencies:
-    commander "~2.19.0"
-    source-map "~0.6.1"
-
 underscore@^1.8.3:
-  version "1.13.1"
-  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1"
-  integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==
+  version "1.13.4"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.4.tgz#7886b46bbdf07f768e0052f1828e1dcab40c0dee"
+  integrity sha512-BQFnUDuAQ4Yf/cYY5LNrK9NCJFKriaRbD9uR1fTeXnBeoa97W0i41qkZfGO9pSo8I5KzjAcSY2XYtdf0oKd7KQ==
 
-underscore@~1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8"
-  integrity sha1-izixDKze9jM3uLJOT/htRa6lKag=
-
-unicode-canonical-property-names-ecmascript@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
-  integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==
-
-unicode-match-property-ecmascript@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"
-  integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==
-  dependencies:
-    unicode-canonical-property-names-ecmascript "^1.0.4"
-    unicode-property-aliases-ecmascript "^1.0.4"
-
-unicode-match-property-value-ecmascript@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531"
-  integrity sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==
-
-unicode-property-aliases-ecmascript@^1.0.4:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
-  integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
-
-union-value@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
-  integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==
-  dependencies:
-    arr-union "^3.1.0"
-    get-value "^2.0.6"
-    is-extendable "^0.1.1"
-    set-value "^2.0.1"
-
-unique-stream@^2.0.2:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac"
-  integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==
-  dependencies:
-    json-stable-stringify-without-jsonify "^1.0.1"
-    through2-filter "^3.0.0"
-
-unique-string@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a"
-  integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=
-  dependencies:
-    crypto-random-string "^1.0.0"
-
-universal-user-agent@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-4.0.1.tgz#fd8d6cb773a679a709e967ef8288a31fcc03e557"
-  integrity sha512-LnST3ebHwVL2aNe4mejI9IQh2HfZ1RLo8Io2HugSif8ekzD1TlWpHpColOB/eh8JHMLkGH3Akqf040I+4ylNxg==
-  dependencies:
-    os-name "^3.1.0"
-
-universal-user-agent@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee"
-  integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==
-
-unpipe@1.0.0, unpipe@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
-  integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
-
-unset-value@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
-  integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=
-  dependencies:
-    has-value "^0.3.1"
-    isobject "^3.0.0"
-
-untildify@^2.0.0, untildify@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/untildify/-/untildify-2.1.0.tgz#17eb2807987f76952e9c0485fc311d06a826a2e0"
-  integrity sha1-F+soB5h/dpUunASF/DEdBqgmouA=
-  dependencies:
-    os-homedir "^1.0.0"
-
-untildify@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9"
-  integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==
-
-unzip-response@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-1.0.2.tgz#b984f0877fc0a89c2c773cc1ef7b5b232b5b06fe"
-  integrity sha1-uYTwh3/AqJwsdzzB73tbIytbBv4=
-
-unzip-response@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
-  integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=
-
-update-notifier@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-1.0.3.tgz#8f92c515482bd6831b7c93013e70f87552c7cf5a"
-  integrity sha1-j5LFFUgr1oMbfJMBPnD4dVLHz1o=
-  dependencies:
-    boxen "^0.6.0"
-    chalk "^1.0.0"
-    configstore "^2.0.0"
-    is-npm "^1.0.0"
-    latest-version "^2.0.0"
-    lazy-req "^1.1.0"
-    semver-diff "^2.0.0"
-    xdg-basedir "^2.0.0"
-
-update-notifier@^2.2.0, update-notifier@^2.3.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6"
-  integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==
-  dependencies:
-    boxen "^1.2.1"
-    chalk "^2.0.1"
-    configstore "^3.0.0"
-    import-lazy "^2.1.0"
-    is-ci "^1.0.10"
-    is-installed-globally "^0.1.0"
-    is-npm "^1.0.0"
-    latest-version "^3.0.0"
-    semver-diff "^2.0.0"
-    xdg-basedir "^3.0.0"
-
-upper-case@^1.1.1:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598"
-  integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=
-
-uri-js@^4.2.2:
-  version "4.4.1"
-  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
-  integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
-  dependencies:
-    punycode "^2.1.0"
-
-urijs@^1.16.1:
-  version "1.19.7"
-  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.7.tgz#4f594e59113928fea63c00ce688fb395b1168ab9"
-  integrity sha512-Id+IKjdU0Hx+7Zx717jwLPsPeUqz7rAtuVBRLLs+qn+J2nf9NGITWVCxcijgYxBqe83C7sqsQPs6H1pyz3x9gA==
-
-urix@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
-  integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
-
-url-parse-lax@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
-  integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=
-  dependencies:
-    prepend-http "^1.0.1"
-
-url-to-options@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"
-  integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=
-
-use@^3.1.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
-  integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
-
-util-deprecate@^1.0.1, util-deprecate@~1.0.1:
+util-deprecate@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
 
-utils-merge@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
-  integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
-
-uuid@^2.0.1:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a"
-  integrity sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=
-
-uuid@^3.2.1, uuid@^3.3.2:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
-  integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
-
-vali-date@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/vali-date/-/vali-date-1.0.0.tgz#1b904a59609fb328ef078138420934f6b86709a6"
-  integrity sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=
-
-validate-element-name@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/validate-element-name/-/validate-element-name-2.1.1.tgz#8ff75f7da69f73e7c510588362130508b7ac644e"
-  integrity sha1-j/dffaafc+fFEFiDYhMFCLesZE4=
-  dependencies:
-    is-potential-custom-element-name "^1.0.0"
-    log-symbols "^1.0.0"
-    meow "^3.7.0"
-
-validate-npm-package-license@^3.0.1:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
-  integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
-  dependencies:
-    spdx-correct "^3.0.0"
-    spdx-expression-parse "^3.0.0"
-
-vargs@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/vargs/-/vargs-0.1.0.tgz#6b6184da6520cc3204ce1b407cac26d92609ebff"
-  integrity sha1-a2GE2mUgzDIEzhtAfKwm2SYJ6/8=
-
-vary@^1, vary@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
-  integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
-
-verror@1.10.0:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
-  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
-  dependencies:
-    assert-plus "^1.0.0"
-    core-util-is "1.0.2"
-    extsprintf "^1.2.0"
-
-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"
-    strip-bom-buf "^1.0.0"
-    strip-bom-stream "^2.0.0"
-    vinyl "^2.0.1"
-
-vinyl-fs@^2.4.3, vinyl-fs@^2.4.4:
-  version "2.4.4"
-  resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-2.4.4.tgz#be6ff3270cb55dfd7d3063640de81f25d7532239"
-  integrity sha1-vm/zJwy1Xf19MGNkDegfJddTIjk=
-  dependencies:
-    duplexify "^3.2.0"
-    glob-stream "^5.3.2"
-    graceful-fs "^4.0.0"
-    gulp-sourcemaps "1.6.0"
-    is-valid-glob "^0.3.0"
-    lazystream "^1.0.0"
-    lodash.isequal "^4.0.0"
-    merge-stream "^1.0.0"
-    mkdirp "^0.5.0"
-    object-assign "^4.0.0"
-    readable-stream "^2.0.4"
-    strip-bom "^2.0.0"
-    strip-bom-stream "^1.0.0"
-    through2 "^2.0.0"
-    through2-filter "^2.0.0"
-    vali-date "^1.0.0"
-    vinyl "^1.0.0"
-
-vinyl@^1.0.0, vinyl@^1.1.1, vinyl@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884"
-  integrity sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=
-  dependencies:
-    clone "^1.0.0"
-    clone-stats "^0.0.1"
-    replace-ext "0.0.1"
-
-vinyl@^2.0.1, vinyl@^2.2.0, vinyl@^2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.1.tgz#23cfb8bbab5ece3803aa2c0a1eb28af7cbba1974"
-  integrity sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==
-  dependencies:
-    clone "^2.1.1"
-    clone-buffer "^1.0.0"
-    clone-stats "^1.0.0"
-    cloneable-readable "^1.0.0"
-    remove-trailing-separator "^1.0.1"
-    replace-ext "^1.0.0"
-
 vlq@^0.2.2:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
@@ -8903,17 +1986,10 @@
   resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.6.tgz#6b8f141b0bbc44ad7b07e94f82f168ac7608ad4d"
   integrity sha512-sLI2L0uGov3wKVb9EB+vIQBl9tVP90nqRvxSoJ35vI3NjxE8jfsE5DSOhWgSunHSZmKS4OCi2jrtfxK7uyp2ww==
 
-wbuf@^1.1.0, wbuf@^1.7.2:
-  version "1.7.3"
-  resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
-  integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==
-  dependencies:
-    minimalistic-assert "^1.0.0"
-
-wct-local@^2.1.1:
-  version "2.1.5"
-  resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.5.tgz#f7986753e3ad9a35d39178a9989350523561fff1"
-  integrity sha512-eqoZhjGy4Xq2tY0uB46Grkw/ztq+/rC0ImbYKl62unFHXtOgal+kkvnxR3SLRFNM8ty9+ItgycPeH0IpTqVL+w==
+wct-local@2.1.6:
+  version "2.1.6"
+  resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.6.tgz#2d099c52996e77265d16e03a5d6d897b77ea9967"
+  integrity sha512-jvTzgOIIfJ43H3DXUfruHPTQ/TJ269SDk4R2CfCpU13EYbwxn3U1B6L5NHYRFu/cgdJmOHraGrn/wREHH6xeXQ==
   dependencies:
     "@types/express" "^4.0.30"
     "@types/freeport" "^1.0.19"
@@ -8922,71 +1998,10 @@
     chalk "^2.3.0"
     cleankill "^2.0.0"
     freeport "^1.0.4"
-    launchpad "^0.7.0"
+    launchpad "git://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be"
     selenium-standalone "^6.7.0"
     which "^1.0.8"
 
-wct-sauce@^2.0.2:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/wct-sauce/-/wct-sauce-2.1.0.tgz#67d0be346aabbbc28384e8d143b8d3ca7ba774c0"
-  integrity sha512-c3R4PJcbpS7Gxv2vZ4HDAqpXV6cT9peslAWMU7hHH9PMhKDPbn8RNa6E4DVL0tOmZznB+3cRmtZ6+vJ/aDwu1A==
-  dependencies:
-    chalk "^2.4.1"
-    cleankill "^2.0.0"
-    lodash "^4.17.10"
-    request "^2.85.0"
-    sauce-connect-launcher "^1.0.0"
-    temp "^0.8.1"
-    uuid "^3.2.1"
-
-wd@^1.2.0:
-  version "1.14.0"
-  resolved "https://registry.yarnpkg.com/wd/-/wd-1.14.0.tgz#1fe6450b5baef37caa135e7755292c6998ca8a90"
-  integrity sha512-X7ZfGHHYlQ5zYpRlgP16LUsvYti+Al/6fz3T/ClVyivVCpCZQpESTDdz6zbK910O5OIvujO23Ym2DBBo3XsQlA==
-  dependencies:
-    archiver "^3.0.0"
-    async "^2.0.0"
-    lodash "^4.0.0"
-    mkdirp "^0.5.1"
-    q "^1.5.1"
-    request "2.88.0"
-    vargs "^0.1.0"
-
-web-component-tester@^6.9.0:
-  version "6.9.2"
-  resolved "https://registry.yarnpkg.com/web-component-tester/-/web-component-tester-6.9.2.tgz#40a7b824f2cf3cbc4305552bdfc3357977ded48a"
-  integrity sha512-s2kB/+IE8XWcnxY1fqSpqTiiHEGHWgUWariAbiRlxmAvWSuvaCVNALHYebsZrLCNCLHKcJR8/sGv/bw0MWMvjw==
-  dependencies:
-    "@polymer/sinonjs" "^1.14.1"
-    "@polymer/test-fixture" "^0.0.3"
-    "@webcomponents/webcomponentsjs" "^1.0.7"
-    accessibility-developer-tools "^2.12.0"
-    async "^2.4.1"
-    body-parser "^1.17.2"
-    bower-config "^1.4.0"
-    chalk "^1.1.3"
-    cleankill "^2.0.0"
-    express "^4.15.3"
-    findup-sync "^2.0.0"
-    glob "^7.1.2"
-    lodash "^3.10.1"
-    multer "^1.3.0"
-    nomnom "^1.8.1"
-    polyserve "^0.27.13"
-    resolve "^1.5.0"
-    semver "^5.3.0"
-    send "^0.16.1"
-    server-destroy "^1.0.1"
-    sinon "^2.3.5"
-    sinon-chai "^2.10.0"
-    socket.io "^2.0.3"
-    stacky "^1.3.1"
-    wd "^1.2.0"
-  optionalDependencies:
-    update-notifier "^2.2.0"
-    wct-local "^2.1.1"
-    wct-sauce "^2.0.2"
-
 webidl-conversions@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
@@ -9001,7 +2016,7 @@
     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.0.8:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
   integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
@@ -9015,64 +2030,6 @@
   dependencies:
     isexe "^2.0.0"
 
-widest-line@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-1.0.0.tgz#0c09c85c2a94683d0d7eaf8ee097d564bf0e105c"
-  integrity sha1-DAnIXCqUaD0Nfq+O4JfVZL8OEFw=
-  dependencies:
-    string-width "^1.0.1"
-
-widest-line@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
-  integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==
-  dependencies:
-    string-width "^2.1.1"
-
-windows-release@^3.1.0:
-  version "3.3.3"
-  resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.3.3.tgz#1c10027c7225743eec6b89df160d64c2e0293999"
-  integrity sha512-OSOGH1QYiW5yVor9TtmXKQvt2vjQqbYS+DqmsZw+r7xDwLXEeT3JGW0ZppFmHx4diyXmxt238KFR3N9jzevBRg==
-  dependencies:
-    execa "^1.0.0"
-
-winston-transport@^4.2.0, winston-transport@^4.4.0:
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59"
-  integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw==
-  dependencies:
-    readable-stream "^2.3.7"
-    triple-beam "^1.2.0"
-
-winston@^3.0.0:
-  version "3.3.3"
-  resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170"
-  integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw==
-  dependencies:
-    "@dabh/diagnostics" "^2.0.2"
-    async "^3.1.0"
-    is-stream "^2.0.0"
-    logform "^2.2.0"
-    one-time "^1.0.0"
-    readable-stream "^3.4.0"
-    stack-trace "0.0.x"
-    triple-beam "^1.3.0"
-    winston-transport "^4.4.0"
-
-with-open-file@^0.1.6:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/with-open-file/-/with-open-file-0.1.7.tgz#e2de8d974e8a8ae6e58886be4fe8e7465b58a729"
-  integrity sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==
-  dependencies:
-    p-finally "^1.0.0"
-    p-try "^2.1.0"
-    pify "^4.0.1"
-
-wordwrap@^0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
-  integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
-
 wordwrapjs@^2.0.0-0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-2.0.0.tgz#ab55f695e6118da93858fdd70c053d1c5e01ac20"
@@ -9096,41 +2053,6 @@
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
-write-file-atomic@^1.1.2:
-  version "1.3.4"
-  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.3.4.tgz#f807a4f0b1d9e913ae7a48112e6cc3af1991b45f"
-  integrity sha1-+Aek8LHZ6ROuekgRLmzDrxmRtF8=
-  dependencies:
-    graceful-fs "^4.1.11"
-    imurmurhash "^0.1.4"
-    slide "^1.1.5"
-
-write-file-atomic@^2.0.0:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481"
-  integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==
-  dependencies:
-    graceful-fs "^4.1.11"
-    imurmurhash "^0.1.4"
-    signal-exit "^3.0.2"
-
-ws@~7.4.2:
-  version "7.4.6"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
-  integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
-
-xdg-basedir@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"
-  integrity sha1-7byQPMOF/ARSPZZqM1UEtVBNG9I=
-  dependencies:
-    os-homedir "^1.0.0"
-
-xdg-basedir@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
-  integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=
-
 xmlbuilder@8.2.2:
   version "8.2.2"
   resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773"
@@ -9141,26 +2063,6 @@
   resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
   integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
 
-xmlhttprequest-ssl@~1.6.2:
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6"
-  integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==
-
-"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
-  integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
-
-yallist@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
-  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
-
-yallist@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
-  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
-
 yauzl@^2.10.0:
   version "2.10.0"
   resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
@@ -9168,125 +2070,3 @@
   dependencies:
     buffer-crc32 "~0.2.3"
     fd-slicer "~1.1.0"
-
-yeast@0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
-  integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
-
-yeoman-environment@^1.5.2:
-  version "1.6.6"
-  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-1.6.6.tgz#cd85fa67d156060e440d7807d7ef7cf0d2d1d671"
-  integrity sha1-zYX6Z9FWBg5EDXgH1+988NLR1nE=
-  dependencies:
-    chalk "^1.0.0"
-    debug "^2.0.0"
-    diff "^2.1.2"
-    escape-string-regexp "^1.0.2"
-    globby "^4.0.0"
-    grouped-queue "^0.3.0"
-    inquirer "^1.0.2"
-    lodash "^4.11.1"
-    log-symbols "^1.0.1"
-    mem-fs "^1.1.0"
-    text-table "^0.2.0"
-    untildify "^2.0.0"
-
-yeoman-environment@^2.0.5, yeoman-environment@^2.9.5:
-  version "2.10.3"
-  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-2.10.3.tgz#9d8f42b77317414434cc0e51fb006a4bdd54688e"
-  integrity sha512-pLIhhU9z/G+kjOXmJ2bPFm3nejfbH+f1fjYRSOteEXDBrv1EoJE/e+kuHixSXfCYfTkxjYsvRaDX+1QykLCnpQ==
-  dependencies:
-    chalk "^2.4.1"
-    debug "^3.1.0"
-    diff "^3.5.0"
-    escape-string-regexp "^1.0.2"
-    execa "^4.0.0"
-    globby "^8.0.1"
-    grouped-queue "^1.1.0"
-    inquirer "^7.1.0"
-    is-scoped "^1.0.0"
-    lodash "^4.17.10"
-    log-symbols "^2.2.0"
-    mem-fs "^1.1.0"
-    mem-fs-editor "^6.0.0"
-    npm-api "^1.0.0"
-    semver "^7.1.3"
-    strip-ansi "^4.0.0"
-    text-table "^0.2.0"
-    untildify "^3.0.3"
-    yeoman-generator "^4.8.2"
-
-yeoman-generator@^3.1.1:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/yeoman-generator/-/yeoman-generator-3.2.0.tgz#02077d2d7ff28fedc1ed7dad7f9967fd7c3604cc"
-  integrity sha512-iR/qb2je3GdXtSfxgvOXxUW0Cp8+C6LaZaNlK2BAICzFNzwHtM10t/QBwz5Ea9nk6xVDQNj4Q889TjCXGuIv8w==
-  dependencies:
-    async "^2.6.0"
-    chalk "^2.3.0"
-    cli-table "^0.3.1"
-    cross-spawn "^6.0.5"
-    dargs "^6.0.0"
-    dateformat "^3.0.3"
-    debug "^4.1.0"
-    detect-conflict "^1.0.0"
-    error "^7.0.2"
-    find-up "^3.0.0"
-    github-username "^4.0.0"
-    istextorbinary "^2.2.1"
-    lodash "^4.17.10"
-    make-dir "^1.1.0"
-    mem-fs-editor "^5.0.0"
-    minimist "^1.2.0"
-    pretty-bytes "^5.1.0"
-    read-chunk "^3.0.0"
-    read-pkg-up "^4.0.0"
-    rimraf "^2.6.2"
-    run-async "^2.0.0"
-    shelljs "^0.8.0"
-    text-table "^0.2.0"
-    through2 "^3.0.0"
-    yeoman-environment "^2.0.5"
-
-yeoman-generator@^4.8.2:
-  version "4.13.0"
-  resolved "https://registry.yarnpkg.com/yeoman-generator/-/yeoman-generator-4.13.0.tgz#a6caeed8491fceea1f84f53e31795f25888b4672"
-  integrity sha512-f2/5N5IR3M2Ozm+QocvZQudlQITv2DwI6Mcxfy7R7gTTzaKgvUpgo/pQMJ+WQKm0KN0YMWCFOZpj0xFGxevc1w==
-  dependencies:
-    async "^2.6.2"
-    chalk "^2.4.2"
-    cli-table "^0.3.1"
-    cross-spawn "^6.0.5"
-    dargs "^6.1.0"
-    dateformat "^3.0.3"
-    debug "^4.1.1"
-    diff "^4.0.1"
-    error "^7.0.2"
-    find-up "^3.0.0"
-    github-username "^3.0.0"
-    istextorbinary "^2.5.1"
-    lodash "^4.17.11"
-    make-dir "^3.0.0"
-    mem-fs-editor "^7.0.1"
-    minimist "^1.2.5"
-    pretty-bytes "^5.2.0"
-    read-chunk "^3.2.0"
-    read-pkg-up "^5.0.0"
-    rimraf "^2.6.3"
-    run-async "^2.0.0"
-    semver "^7.2.1"
-    shelljs "^0.8.4"
-    text-table "^0.2.0"
-    through2 "^3.0.1"
-  optionalDependencies:
-    grouped-queue "^1.1.0"
-    yeoman-environment "^2.9.5"
-
-zip-stream@^2.1.2:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-2.1.3.tgz#26cc4bdb93641a8590dd07112e1f77af1758865b"
-  integrity sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==
-  dependencies:
-    archiver-utils "^2.1.0"
-    compress-commons "^2.1.1"
-    readable-stream "^3.4.0"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 51be39f..7f26ef3 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -8,8 +8,6 @@
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
 
-TESTCONTAINERS_VERSION = "1.15.3"
-
 def declare_nongoogle_deps():
     """loads dependencies that are not used at Google.
 
@@ -19,6 +17,38 @@
     """
 
     maven_jar(
+        name = "log4j",
+        artifact = "ch.qos.reload4j:reload4j:1.2.19",
+        sha1 = "4eae9978468c5e885a6fb44df7e2bbc07a20e6ce",
+    )
+
+    SLF4J_VERS = "1.7.36"
+
+    maven_jar(
+        name = "log-api",
+        artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS,
+        sha1 = "6c62681a2f655b49963a5983b8b0950a6120ae14",
+    )
+
+    maven_jar(
+        name = "log-ext",
+        artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS,
+        sha1 = "99f282aea4b6dbca04d00f0ade6e5ed61ee7091a",
+    )
+
+    maven_jar(
+        name = "impl-log4j",
+        artifact = "org.slf4j:slf4j-reload4j:" + SLF4J_VERS,
+        sha1 = "db708f7d959dee1857ac524636e85ecf2e1781c1",
+    )
+
+    maven_jar(
+        name = "jcl-over-slf4j",
+        artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS,
+        sha1 = "d877e195a05aca4a2f1ad2ff14bfec1393af4b5e",
+    )
+
+    maven_jar(
         name = "j2objc",
         artifact = "com.google.j2objc:j2objc-annotations:1.1",
         sha1 = "ed28ded51a8b1c6b112568def5f4b455e6809019",
@@ -27,8 +57,8 @@
     # Transitive dependency of commons-compress
     maven_jar(
         name = "tukaani-xz",
-        artifact = "org.tukaani:xz:1.8",
-        sha1 = "c4f7d054303948eb6a4066194253886c8af07128",
+        artifact = "org.tukaani:xz:1.9",
+        sha1 = "1ea4bec1a921180164852c65006d928617bd2caf",
     )
 
     maven_jar(
@@ -37,18 +67,18 @@
         sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
     )
 
-    SSHD_VERS = "2.6.0"
+    SSHD_VERS = "2.9.2"
 
     maven_jar(
         name = "sshd-osgi",
         artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS,
-        sha1 = "40e365bb799e1bff3d31dc858b1e59a93c123f29",
+        sha1 = "bac0415734519b2fe433fea196017acf7ed32660",
     )
 
     maven_jar(
         name = "sshd-sftp",
         artifact = "org.apache.sshd:sshd-sftp:" + SSHD_VERS,
-        sha1 = "6eddfe8fdf59a3d9a49151e4177f8c1bebeb30c9",
+        sha1 = "7f9089c87b3b44f19998252fd3b68637e3322920",
     )
 
     maven_jar(
@@ -59,28 +89,14 @@
 
     maven_jar(
         name = "mina-core",
-        artifact = "org.apache.mina:mina-core:2.0.21",
-        sha1 = "e1a317689ecd438f54e863747e832f741ef8e092",
+        artifact = "org.apache.mina:mina-core:2.0.23",
+        sha1 = "391228b25d3a24434b205444cd262780a9ea61e7",
     )
 
     maven_jar(
         name = "sshd-mina",
         artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
-        sha1 = "d22138ba75dee95e2123f0e53a9c514b2a766da9",
-    )
-
-    # elasticsearch-rest-client explicitly depends on this version
-    maven_jar(
-        name = "httpasyncclient",
-        artifact = "org.apache.httpcomponents:httpasyncclient:4.1.4",
-        sha1 = "f3a3240681faae3fa46b573a4c7e50cec9db0d86",
-    )
-
-    # elasticsearch-rest-client explicitly depends on this version
-    maven_jar(
-        name = "httpcore-nio",
-        artifact = "org.apache.httpcomponents:httpcore-nio:4.4.12",
-        sha1 = "84cd29eca842f31db02987cfedea245af020198b",
+        sha1 = "765dced3a2b4069bb0c550e18bda057bad8de26f",
     )
 
     maven_jar(
@@ -109,12 +125,6 @@
     )
 
     maven_jar(
-        name = "jackson-core",
-        artifact = "com.fasterxml.jackson.core:jackson-core:2.12.0",
-        sha1 = "afe52c6947d9939170da7989612cef544115511a",
-    )
-
-    maven_jar(
         name = "commons-io",
         artifact = "commons-io:commons-io:2.4",
         sha1 = "b1b6ea3b7e4aa4f492509a4952029cd8e48019ad",
@@ -123,24 +133,30 @@
     # Google internal dependencies: these are developed at Google, so there is
     # no concern about version skew.
 
-    FLOGGER_VERS = "0.6"
+    maven_jar(
+        name = "error-prone-annotations",
+        artifact = "com.google.errorprone:error_prone_annotations:2.15.0",
+        sha1 = "38c8485a652f808c8c149150da4e5c2b0bd17f9a",
+    )
+
+    FLOGGER_VERS = "0.7.4"
 
     maven_jar(
         name = "flogger",
         artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
-        sha1 = "155dc6e303a58f7bbff5d2cd1a259de86827f4fe",
+        sha1 = "cec29ed8b58413c2e935d86b12d6b696dc285419",
     )
 
     maven_jar(
         name = "flogger-log4j-backend",
         artifact = "com.google.flogger:flogger-log4j-backend:" + FLOGGER_VERS,
-        sha1 = "9743841bf10309163effd8ddf882b5d5190cc9d9",
+        sha1 = "7486b1c0138647cd7714eccb8ce37b5f2ae20a76",
     )
 
     maven_jar(
         name = "flogger-system-backend",
         artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
-        sha1 = "0f0ccf8923c6c315f2f57b108bcc6e46ccd88777",
+        sha1 = "4bee7ebbd97c63ca7fb17529aeb49a57b670d061",
     )
 
     maven_jar(
@@ -195,52 +211,6 @@
         sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
     )
 
-    DOCKER_JAVA_VERS = "3.2.8"
-
-    maven_jar(
-        name = "docker-java-api",
-        artifact = "com.github.docker-java:docker-java-api:" + DOCKER_JAVA_VERS,
-        sha1 = "4ac22a72d546a9f3523cd4b5fabffa77c4a6ec7c",
-    )
-
-    maven_jar(
-        name = "docker-java-transport",
-        artifact = "com.github.docker-java:docker-java-transport:" + DOCKER_JAVA_VERS,
-        sha1 = "c3b5598c67d0a5e2e780bf48f520da26b9915eab",
-    )
-
-    # https://github.com/docker-java/docker-java/blob/3.2.8/pom.xml#L61
-    # <=> DOCKER_JAVA_VERS
-    maven_jar(
-        name = "jackson-annotations",
-        artifact = "com.fasterxml.jackson.core:jackson-annotations:2.10.3",
-        sha1 = "0f63b3b1da563767d04d2e4d3fc1ae0cdeffebe7",
-    )
-
-    maven_jar(
-        name = "testcontainers",
-        artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-        sha1 = "95c6cfde71c2209f0c29cb14e432471e0b111880",
-    )
-
-    maven_jar(
-        name = "duct-tape",
-        artifact = "org.rnorth.duct-tape:duct-tape:1.0.8",
-        sha1 = "92edc22a9ab2f3e17c9bf700aaee377d50e8b530",
-    )
-
-    maven_jar(
-        name = "visible-assertions",
-        artifact = "org.rnorth.visible-assertions:visible-assertions:2.1.2",
-        sha1 = "20d31a578030ec8e941888537267d3123c2ad1c1",
-    )
-
-    maven_jar(
-        name = "jna",
-        artifact = "net.java.dev.jna:jna:5.5.0",
-        sha1 = "0e0845217c4907822403912ad6828d8e0b256208",
-    )
-
     maven_jar(
         name = "jimfs",
         artifact = "com.google.jimfs:jimfs:1.2",
@@ -273,34 +243,41 @@
         sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
     )
 
-    LUCENE_VERS = "6.6.5"
+    LUCENE_VERS = "7.7.3"
 
     maven_jar(
         name = "lucene-core",
         artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-        sha1 = "2983f80b1037e098209657b0ca9176827892d0c0",
+        sha1 = "5faa5ae56f7599019fce6184accc6c968b7519e7",
     )
 
     maven_jar(
         name = "lucene-analyzers-common",
         artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-        sha1 = "6094f91071d90570b7f5f8ce481d5de7d2d2e9d5",
+        sha1 = "0a76cbf5e21bbbb0c2d6288b042450236248214e",
     )
 
     maven_jar(
         name = "backward-codecs",
         artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-        sha1 = "460a19e8d1aa7d31e9614cf528a6cb508c9e823d",
+        sha1 = "40207d0dd023a0e2868a23dd87d72f1a3cdbb893",
     )
 
     maven_jar(
         name = "lucene-misc",
         artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-        sha1 = "ce3a1b7b6a92b9af30791356a4bd46d1cea6cc1e",
+        sha1 = "3aca078edf983059722fe61a81b7b7bd5ecdb222",
     )
 
     maven_jar(
         name = "lucene-queryparser",
         artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-        sha1 = "2db9ca0086a4b8e0b9bc9f08a9b420303168e37c",
+        sha1 = "685fc6166d29eb3e3441ae066873bb442aa02df1",
+    )
+
+    # 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/polygerrit-updater/.gitignore b/tools/polygerrit-updater/.gitignore
deleted file mode 100644
index 8619a37..0000000
--- a/tools/polygerrit-updater/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-/.idea/
-/node_modules/
-/js/
\ No newline at end of file
diff --git a/tools/polygerrit-updater/package-lock.json b/tools/polygerrit-updater/package-lock.json
deleted file mode 100644
index 9256997..0000000
--- a/tools/polygerrit-updater/package-lock.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
-  "name": "polygerrit-updater",
-  "version": "1.0.0",
-  "lockfileVersion": 1,
-  "requires": true,
-  "dependencies": {
-    "@types/node": {
-      "version": "12.7.12",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.12.tgz",
-      "integrity": "sha512-KPYGmfD0/b1eXurQ59fXD1GBzhSQfz6/lKBxkaHX9dKTzjXbK68Zt7yGUxUsCS1jeTy/8aL+d9JEr+S54mpkWQ=="
-    },
-    "typescript": {
-      "version": "3.6.4",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz",
-      "integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg=="
-    }
-  }
-}
diff --git a/tools/polygerrit-updater/package.json b/tools/polygerrit-updater/package.json
deleted file mode 100644
index 3609dad..0000000
--- a/tools/polygerrit-updater/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
-  "name": "polygerrit-updater",
-  "version": "1.0.0",
-  "description": "Polygerrit source code updater",
-  "scripts": {
-    "compile": "tsc",
-    "convert": "npm run compile && node js/src/index.js"
-  },
-  "author": "",
-  "license": "Apache-2.0",
-  "dependencies": {
-    "@types/node": "^12.7.12",
-    "typescript": "^3.6.4"
-  }
-}
diff --git a/tools/polygerrit-updater/readme.txt b/tools/polygerrit-updater/readme.txt
deleted file mode 100644
index 2b2cea8..0000000
--- a/tools/polygerrit-updater/readme.txt
+++ /dev/null
@@ -1,56 +0,0 @@
-This folder contains tool to update Polymer components to class based components.
-This is a temporary tools, it will be removed in a few weeks.
-
-How to use this tool: initial steps
-1) Important - Commit and push all your changes. Otherwise, you can loose you work.
-
-2) Ensure, that tools/polygerrit-updater is your current directory
-
-3) Run
-npm install
-
-4) If you want to convert the whole project, run
-npm run convert -- --i \
-  --root ../../polygerrit-ui --src app/elements --r \
-  --exclude app/elements/core/gr-reporting/gr-reporting.js \
-     app/elements/diff/gr-comment-api/gr-comment-api-mock.js \
-     app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
-
-You can convert only specific files (can be useful if you want to convert some files in your change)
-npm run convert -- --i \
-  --root ../../polygerrit-ui
-  --src app/elements/file1.js \
-      app/elements/folder/file2.js
-
-4) Search for the following string in all .js files:
-//This file has the following problems with comments:
-
-If you find such string in a .js file - you must manually fix comments in this file.
-(It is expected that you shouldn't have such problems)
-
-5) Go to the gerrit root folder and run
-npm run eslintfix
-
-(If you are doing it for the first time, run the following command before in gerrit root folder:
-npm run install)
-
-Fix error after eslintfix (if exists)
-
-6) If you are doing conversion for the whole project, make the followin changes:
-
-a) Add
-<link rel="import" href="../../../types/polymer-behaviors.js">
-to
-polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
-
-b) Update polymer.json with the following rules:
-  "lint": {
-    "rules": ["polymer-2"],
-    "ignoreWarnings": ["deprecated-dom-call"]
-  }
-
-
-
-5) Commit changed files.
-
-6) You can update excluded files later.
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/funcToClassBasedElementConverter.ts b/tools/polygerrit-updater/src/funcToClassConversion/funcToClassBasedElementConverter.ts
deleted file mode 100644
index b92a6e9..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/funcToClassBasedElementConverter.ts
+++ /dev/null
@@ -1,131 +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.
-
-import {LegacyLifecycleMethodsArray, LegacyPolymerComponent} from './polymerComponentParser';
-import {LifecycleMethodsBuilder} from './lifecycleMethodsBuilder';
-import {ClassBasedPolymerElement, PolymerElementBuilder} from './polymerElementBuilder';
-import * as codeUtils from '../utils/codeUtils';
-import * as ts from 'typescript';
-
-export class PolymerFuncToClassBasedConverter {
-  public static convert(component: LegacyPolymerComponent): ClassBasedPolymerElement {
-    const legacySettings = component.componentSettings;
-    const reservedDeclarations = legacySettings.reservedDeclarations;
-
-    if(!reservedDeclarations.is) {
-      throw new Error("Legacy component doesn't have 'is' property");
-    }
-    const className = this.generateClassNameFromTagName(reservedDeclarations.is.data);
-    const updater = new PolymerElementBuilder(component, className);
-    updater.addIsAccessor(reservedDeclarations.is.data);
-
-    if(reservedDeclarations.properties) {
-      updater.addPolymerPropertiesAccessor(reservedDeclarations.properties);
-    }
-
-    updater.addMixin("Polymer.Element");
-    updater.addMixin("Polymer.LegacyElementMixin");
-    updater.addMixin("Polymer.GestureEventListeners");
-
-    if(reservedDeclarations._legacyUndefinedCheck) {
-      updater.addMixin("Polymer.LegacyDataMixin");
-    }
-
-    if(reservedDeclarations.behaviors) {
-      updater.addMixin("Polymer.mixinBehaviors", [reservedDeclarations.behaviors.data]);
-      const mixinNames = this.getMixinNamesFromBehaviors(reservedDeclarations.behaviors.data);
-      const jsDocLines = mixinNames.map(mixinName => {
-        return `@appliesMixin ${mixinName}`;
-      });
-      updater.addClassJSDocComments(jsDocLines);
-    }
-
-    if(reservedDeclarations.observers) {
-      updater.addPolymerPropertiesObservers(reservedDeclarations.observers.data);
-    }
-
-    if(reservedDeclarations.keyBindings) {
-      updater.addKeyBindings(reservedDeclarations.keyBindings.data);
-    }
-
-
-    const lifecycleBuilder = new LifecycleMethodsBuilder();
-    if (reservedDeclarations.listeners) {
-      lifecycleBuilder.addListeners(reservedDeclarations.listeners.data, legacySettings.ordinaryMethods);
-    }
-
-    if (reservedDeclarations.hostAttributes) {
-      lifecycleBuilder.addHostAttributes(reservedDeclarations.hostAttributes.data);
-    }
-
-    for(const name of LegacyLifecycleMethodsArray) {
-      const existingMethod = legacySettings.lifecycleMethods.get(name);
-      if(existingMethod) {
-        lifecycleBuilder.addLegacyLifecycleMethod(name, existingMethod)
-      }
-    }
-
-    const newLifecycleMethods = lifecycleBuilder.buildNewMethods();
-    updater.addLifecycleMethods(newLifecycleMethods);
-
-
-    updater.addOrdinaryMethods(legacySettings.ordinaryMethods);
-    updater.addOrdinaryGetAccessors(legacySettings.ordinaryGetAccessors);
-    updater.addOrdinaryShorthandProperties(legacySettings.ordinaryShorthandProperties);
-    updater.addOrdinaryPropertyAssignments(legacySettings.ordinaryPropertyAssignments);
-
-    return updater.build();
-  }
-
-  private static generateClassNameFromTagName(tagName: string) {
-    let result = "";
-    let nextUppercase = true;
-    for(const ch of tagName) {
-      if (ch === '-') {
-        nextUppercase = true;
-        continue;
-      }
-      result += nextUppercase ? ch.toUpperCase() : ch;
-      nextUppercase = false;
-    }
-    return result;
-  }
-
-  private static getMixinNamesFromBehaviors(behaviors: ts.ArrayLiteralExpression): string[] {
-    return behaviors.elements.map((expression) => {
-      const propertyAccessExpression = codeUtils.assertNodeKind(expression, ts.SyntaxKind.PropertyAccessExpression) as ts.PropertyAccessExpression;
-      const namespaceName = codeUtils.assertNodeKind(propertyAccessExpression.expression, ts.SyntaxKind.Identifier) as ts.Identifier;
-      const behaviorName = propertyAccessExpression.name;
-      if(namespaceName.text === 'Gerrit') {
-        let behaviorNameText = behaviorName.text;
-        const suffix = 'Behavior';
-        if(behaviorNameText.endsWith(suffix)) {
-          behaviorNameText =
-              behaviorNameText.substr(0, behaviorNameText.length - suffix.length);
-        }
-        const mixinName = behaviorNameText + 'Mixin';
-        return `${namespaceName.text}.${mixinName}`
-      } else if(namespaceName.text === 'Polymer') {
-        let behaviorNameText = behaviorName.text;
-        if(behaviorNameText === "IronFitBehavior") {
-          return "Polymer.IronFitMixin";
-        } else if(behaviorNameText === "IronOverlayBehavior") {
-          return "";
-        }
-        throw new Error(`Unsupported behavior: ${propertyAccessExpression.getText()}`);
-      }
-      throw new Error(`Unsupported behavior name ${expression.getFullText()}`)
-    }).filter(name => name.length > 0);
-  }
-}
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/legacyPolymerFuncReplacer.ts b/tools/polygerrit-updater/src/funcToClassConversion/legacyPolymerFuncReplacer.ts
deleted file mode 100644
index 57b7b8d..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/legacyPolymerFuncReplacer.ts
+++ /dev/null
@@ -1,74 +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.
-
-import * as ts from 'typescript';
-import * as codeUtils from '../utils/codeUtils'
-import {LegacyPolymerComponent} from './polymerComponentParser';
-import {ClassBasedPolymerElement} from './polymerElementBuilder';
-
-export class LegacyPolymerFuncReplaceResult {
-  public constructor(
-      private readonly transformationResult: ts.TransformationResult<ts.SourceFile>,
-      public readonly leadingComments: string[]) {
-  }
-  public get file(): ts.SourceFile {
-    return this.transformationResult.transformed[0];
-  }
-  public dispose() {
-    this.transformationResult.dispose();
-  }
-
-}
-
-export class LegacyPolymerFuncReplacer {
-  private readonly callStatement: ts.ExpressionStatement;
-  private readonly parentBlock: ts.Block;
-  private readonly callStatementIndexInBlock: number;
-  public constructor(private readonly legacyComponent: LegacyPolymerComponent) {
-    this.callStatement = codeUtils.assertNodeKind(legacyComponent.polymerFuncCallExpr.parent, ts.SyntaxKind.ExpressionStatement);
-    this.parentBlock = codeUtils.assertNodeKind(this.callStatement.parent, ts.SyntaxKind.Block);
-    this.callStatementIndexInBlock = this.parentBlock.statements.indexOf(this.callStatement);
-    if(this.callStatementIndexInBlock < 0) {
-      throw new Error("Internal error! Couldn't find statement in its own parent");
-    }
-  }
-  public replace(classBasedElement: ClassBasedPolymerElement): LegacyPolymerFuncReplaceResult {
-    const classDeclarationWithComments = this.appendLeadingCommentToClassDeclaration(classBasedElement.classDeclaration);
-    return new LegacyPolymerFuncReplaceResult(
-        this.replaceLegacyPolymerFunction(classDeclarationWithComments.classDeclarationWithCommentsPlaceholder, classBasedElement.componentRegistration),
-        classDeclarationWithComments.leadingComments);
-  }
-  private appendLeadingCommentToClassDeclaration(classDeclaration: ts.ClassDeclaration): {classDeclarationWithCommentsPlaceholder: ts.ClassDeclaration, leadingComments: string[]} {
-    const text = this.callStatement.getFullText();
-    let classDeclarationWithCommentsPlaceholder = classDeclaration;
-    const leadingComments: string[] = [];
-    ts.forEachLeadingCommentRange(text, 0, (pos, end, kind, hasTrailingNewLine) => {
-      classDeclarationWithCommentsPlaceholder = codeUtils.addReplacableCommentBeforeNode(classDeclarationWithCommentsPlaceholder, String(leadingComments.length));
-      leadingComments.push(text.substring(pos, end));
-    });
-    return {
-      classDeclarationWithCommentsPlaceholder: classDeclarationWithCommentsPlaceholder,
-      leadingComments: leadingComments
-    }
-  }
-  private replaceLegacyPolymerFunction(classDeclaration: ts.ClassDeclaration, componentRegistration: ts.ExpressionStatement): ts.TransformationResult<ts.SourceFile> {
-    const newStatements = Array.from(this.parentBlock.statements);
-    newStatements.splice(this.callStatementIndexInBlock, 1, classDeclaration, componentRegistration);
-
-    const updatedBlock = ts.getMutableClone(this.parentBlock);
-    updatedBlock.statements = ts.createNodeArray(newStatements);
-    return codeUtils.replaceNode(this.legacyComponent.parsedFile, this.parentBlock, updatedBlock);
-
-  }
-}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/lifecycleMethodsBuilder.ts b/tools/polygerrit-updater/src/funcToClassConversion/lifecycleMethodsBuilder.ts
deleted file mode 100644
index e9e13f5..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/lifecycleMethodsBuilder.ts
+++ /dev/null
@@ -1,140 +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.
-
-import * as ts from 'typescript';
-import * as codeUtils from '../utils/codeUtils';
-import {LegacyLifecycleMethodName, OrdinaryMethods} from './polymerComponentParser';
-
-interface LegacyLifecycleMethodContent {
-  codeAtMethodStart: ts.Statement[];
-  existingMethod?: ts.MethodDeclaration;
-  codeAtMethodEnd: ts.Statement[];
-}
-
-export interface LifecycleMethod {
-  originalPos: number;//-1 - no original method exists
-  method: ts.MethodDeclaration;
-  name: LegacyLifecycleMethodName;
-}
-
-export class LifecycleMethodsBuilder {
-  private readonly methods: Map<LegacyLifecycleMethodName, LegacyLifecycleMethodContent> = new Map();
-
-  private getMethodContent(name: LegacyLifecycleMethodName): LegacyLifecycleMethodContent {
-    if(!this.methods.has(name)) {
-      this.methods.set(name, {
-        codeAtMethodStart: [],
-        codeAtMethodEnd: []
-      });
-    }
-    return this.methods.get(name)!;
-  }
-
-  public addListeners(legacyListeners: ts.ObjectLiteralExpression, legacyOrdinaryMethods: OrdinaryMethods) {
-    for(const listener of legacyListeners.properties) {
-      const propertyAssignment = codeUtils.assertNodeKind(listener, ts.SyntaxKind.PropertyAssignment) as ts.PropertyAssignment;
-      if(!propertyAssignment.name) {
-        throw new Error("Listener must have event name");
-      }
-      let eventNameLiteral: ts.StringLiteral;
-      let commentsToRestore: string[] = [];
-      if(propertyAssignment.name.kind === ts.SyntaxKind.StringLiteral) {
-        //We don't loose comment in this case, because we keep literal as is
-        eventNameLiteral = propertyAssignment.name;
-      } else if(propertyAssignment.name.kind === ts.SyntaxKind.Identifier) {
-        eventNameLiteral = ts.createStringLiteral(propertyAssignment.name.text);
-        commentsToRestore = codeUtils.getLeadingComments(propertyAssignment);
-      } else {
-        throw new Error(`Unsupported type ${ts.SyntaxKind[propertyAssignment.name.kind]}`);
-      }
-
-      const handlerLiteral = codeUtils.assertNodeKind(propertyAssignment.initializer, ts.SyntaxKind.StringLiteral) as ts.StringLiteral;
-      const handlerImpl = legacyOrdinaryMethods.get(handlerLiteral.text);
-      if(!handlerImpl) {
-        throw new Error(`Can't find event handler '${handlerLiteral.text}'`);
-      }
-      const eventHandlerAccess = ts.createPropertyAccess(ts.createThis(), handlerLiteral.text);
-      //ts.forEachChild(handler)
-      const args: ts.Identifier[] = handlerImpl.parameters.map((arg) => codeUtils.assertNodeKind(arg.name, ts.SyntaxKind.Identifier));
-      const eventHandlerCall = ts.createCall(eventHandlerAccess, [], args);
-      let arrowFunc = ts.createArrowFunction([], [], handlerImpl.parameters, undefined, undefined, eventHandlerCall);
-      arrowFunc = codeUtils.addNewLineBeforeNode(arrowFunc);
-
-      const methodContent = this.getMethodContent("created");
-      //See https://polymer-library.polymer-project.org/3.0/docs/devguide/gesture-events for a list of events
-      if(["down", "up", "tap", "track"].indexOf(eventNameLiteral.text) >= 0) {
-        const methodCall = ts.createCall(codeUtils.createNameExpression("Polymer.Gestures.addListener"), [], [ts.createThis(), eventNameLiteral, arrowFunc]);
-        methodContent.codeAtMethodEnd.push(ts.createExpressionStatement(methodCall));
-      }
-      else {
-        let methodCall = ts.createCall(ts.createPropertyAccess(ts.createThis(), "addEventListener"), [], [eventNameLiteral, arrowFunc]);
-        methodCall = codeUtils.restoreLeadingComments(methodCall, commentsToRestore);
-        methodContent.codeAtMethodEnd.push(ts.createExpressionStatement(methodCall));
-      }
-    }
-  }
-
-  public addHostAttributes(legacyHostAttributes: ts.ObjectLiteralExpression) {
-    for(const listener of legacyHostAttributes.properties) {
-      const propertyAssignment = codeUtils.assertNodeKind(listener, ts.SyntaxKind.PropertyAssignment) as ts.PropertyAssignment;
-      if(!propertyAssignment.name) {
-        throw new Error("Listener must have event name");
-      }
-      let attributeNameLiteral: ts.StringLiteral;
-      if(propertyAssignment.name.kind === ts.SyntaxKind.StringLiteral) {
-        attributeNameLiteral = propertyAssignment.name;
-      } else if(propertyAssignment.name.kind === ts.SyntaxKind.Identifier) {
-        attributeNameLiteral = ts.createStringLiteral(propertyAssignment.name.text);
-      } else {
-        throw new Error(`Unsupported type ${ts.SyntaxKind[propertyAssignment.name.kind]}`);
-      }
-      let attributeValueLiteral: ts.StringLiteral | ts.NumericLiteral;
-      if(propertyAssignment.initializer.kind === ts.SyntaxKind.StringLiteral) {
-        attributeValueLiteral = propertyAssignment.initializer as ts.StringLiteral;
-      } else if(propertyAssignment.initializer.kind === ts.SyntaxKind.NumericLiteral) {
-        attributeValueLiteral = propertyAssignment.initializer as ts.NumericLiteral;
-      } else {
-        throw new Error(`Unsupported type ${ts.SyntaxKind[propertyAssignment.initializer.kind]}`);
-      }
-      const methodCall = ts.createCall(ts.createPropertyAccess(ts.createThis(), "_ensureAttribute"), [], [attributeNameLiteral, attributeValueLiteral]);
-      this.getMethodContent("ready").codeAtMethodEnd.push(ts.createExpressionStatement(methodCall));
-    }
-  }
-
-  public addLegacyLifecycleMethod(name: LegacyLifecycleMethodName, method: ts.MethodDeclaration) {
-    const content = this.getMethodContent(name);
-    if(content.existingMethod) {
-      throw new Error(`Legacy lifecycle method ${name} already added`);
-    }
-    content.existingMethod = method;
-  }
-
-  public buildNewMethods(): LifecycleMethod[] {
-    const result = [];
-    for(const [name, content] of this.methods) {
-      const newMethod = this.createLifecycleMethod(name, content.existingMethod, content.codeAtMethodStart, content.codeAtMethodEnd);
-      if(!newMethod) continue;
-      result.push({
-        name,
-        originalPos: content.existingMethod ? content.existingMethod.pos : -1,
-        method: newMethod
-      })
-    }
-    return result;
-  }
-
-  private createLifecycleMethod(name: string, methodDecl: ts.MethodDeclaration | undefined, codeAtStart: ts.Statement[], codeAtEnd: ts.Statement[]): ts.MethodDeclaration | undefined {
-    return codeUtils.createMethod(name, methodDecl, codeAtStart, codeAtEnd, true);
-  }
-}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/polymerComponentParser.ts b/tools/polygerrit-updater/src/funcToClassConversion/polymerComponentParser.ts
deleted file mode 100644
index 6006608..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/polymerComponentParser.ts
+++ /dev/null
@@ -1,301 +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.
-
-import * as ts from "typescript";
-import * as fs from "fs";
-import * as path from "path";
-import { unexpectedValue } from "../utils/unexpectedValue";
-import * as codeUtils from "../utils/codeUtils";
-import {CommentsParser} from '../utils/commentsParser';
-
-export class LegacyPolymerComponentParser {
-  public constructor(private readonly rootDir: string, private readonly htmlFiles: Set<string>) {
-  }
-  public async parse(jsFile: string): Promise<ParsedPolymerComponent | null> {
-    const sourceFile: ts.SourceFile  = this.parseJsFile(jsFile);
-    const legacyComponent = this.tryParseLegacyComponent(sourceFile);
-    if (legacyComponent) {
-      return legacyComponent;
-    }
-    return null;
-  }
-  private parseJsFile(jsFile: string): ts.SourceFile {
-    return ts.createSourceFile(jsFile, fs.readFileSync(path.resolve(this.rootDir, jsFile)).toString(), ts.ScriptTarget.ES2015, true);
-  }
-
-  private tryParseLegacyComponent(sourceFile: ts.SourceFile): ParsedPolymerComponent | null {
-    const polymerFuncCalls: ts.CallExpression[] = [];
-
-    function addPolymerFuncCall(node: ts.Node) {
-      if(node.kind === ts.SyntaxKind.CallExpression) {
-        const callExpression: ts.CallExpression = node as ts.CallExpression;
-        if(callExpression.expression.kind === ts.SyntaxKind.Identifier) {
-          const identifier = callExpression.expression as ts.Identifier;
-          if(identifier.text === "Polymer") {
-            polymerFuncCalls.push(callExpression);
-          }
-        }
-      }
-      ts.forEachChild(node, addPolymerFuncCall);
-    }
-
-    addPolymerFuncCall(sourceFile);
-
-
-    if (polymerFuncCalls.length === 0) {
-      return null;
-    }
-    if (polymerFuncCalls.length > 1) {
-      throw new Error("Each .js file must contain only one Polymer component");
-    }
-    const parsedPath = path.parse(sourceFile.fileName);
-    const htmlFullPath = path.format({
-      dir: parsedPath.dir,
-      name: parsedPath.name,
-      ext: ".html"
-    });
-    if (!this.htmlFiles.has(htmlFullPath)) {
-      throw new Error("Legacy .js component dosn't have associated .html file");
-    }
-
-    const polymerFuncCall = polymerFuncCalls[0];
-    if(polymerFuncCall.arguments.length !== 1) {
-      throw new Error("The Polymer function must be called with exactly one parameter");
-    }
-    const argument = polymerFuncCall.arguments[0];
-    if(argument.kind !== ts.SyntaxKind.ObjectLiteralExpression) {
-      throw new Error("The parameter for Polymer function must be ObjectLiteralExpression (i.e. '{...}')");
-    }
-    const infoArg = argument as ts.ObjectLiteralExpression;
-
-    return {
-      jsFile: sourceFile.fileName,
-      htmlFile: htmlFullPath,
-      parsedFile: sourceFile,
-      polymerFuncCallExpr: polymerFuncCalls[0],
-      componentSettings: this.parseLegacyComponentSettings(infoArg),
-    };
-  }
-
-  private parseLegacyComponentSettings(info: ts.ObjectLiteralExpression): LegacyPolymerComponentSettings {
-    const props: Map<string, ts.ObjectLiteralElementLike> = new Map();
-    for(const property of info.properties) {
-      const name = property.name;
-      if (name === undefined) {
-        throw new Error("Property name is not defined");
-      }
-      switch(name.kind) {
-        case ts.SyntaxKind.Identifier:
-        case ts.SyntaxKind.StringLiteral:
-          if (props.has(name.text)) {
-            throw new Error(`Property ${name.text} appears more than once`);
-          }
-          props.set(name.text, property);
-          break;
-        case ts.SyntaxKind.ComputedPropertyName:
-          continue;
-        default:
-          unexpectedValue(ts.SyntaxKind[name.kind]);
-      }
-    }
-
-    if(props.has("_noAccessors")) {
-      throw new Error("_noAccessors is not supported");
-    }
-
-    const legacyLifecycleMethods: LegacyLifecycleMethods = new Map();
-    for(const name of LegacyLifecycleMethodsArray) {
-      const methodDecl = this.getLegacyMethodDeclaration(props, name);
-      if(methodDecl) {
-        legacyLifecycleMethods.set(name, methodDecl);
-      }
-    }
-
-    const ordinaryMethods: OrdinaryMethods = new Map();
-    const ordinaryShorthandProperties: OrdinaryShorthandProperties = new Map();
-    const ordinaryGetAccessors: OrdinaryGetAccessors = new Map();
-    const ordinaryPropertyAssignments: OrdinaryPropertyAssignments = new Map();
-    for(const [name, val] of props) {
-      if(RESERVED_NAMES.hasOwnProperty(name)) continue;
-      switch(val.kind) {
-        case ts.SyntaxKind.MethodDeclaration:
-          ordinaryMethods.set(name, val as ts.MethodDeclaration);
-          break;
-        case ts.SyntaxKind.ShorthandPropertyAssignment:
-          ordinaryShorthandProperties.set(name, val as ts.ShorthandPropertyAssignment);
-          break;
-        case ts.SyntaxKind.GetAccessor:
-          ordinaryGetAccessors.set(name, val as ts.GetAccessorDeclaration);
-          break;
-        case ts.SyntaxKind.PropertyAssignment:
-          ordinaryPropertyAssignments.set(name, val as ts.PropertyAssignment);
-          break;
-        default:
-          throw new Error(`Unsupported element kind: ${ts.SyntaxKind[val.kind]}`);
-      }
-      //ordinaryMethods.set(name, tsUtils.assertNodeKind(val, ts.SyntaxKind.MethodDeclaration) as ts.MethodDeclaration);
-    }
-
-    const eventsComments: string[] = this.getEventsComments(info.getFullText());
-
-    return {
-      reservedDeclarations: {
-        is: this.getStringLiteralValueWithComments(this.getLegacyPropertyInitializer(props, "is")),
-        _legacyUndefinedCheck: this.getBooleanLiteralValueWithComments(this.getLegacyPropertyInitializer(props, "_legacyUndefinedCheck")),
-        properties: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "properties")),
-        behaviors: this.getArrayLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "behaviors")),
-        observers: this.getArrayLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "observers")),
-        listeners: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "listeners")),
-        hostAttributes: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "hostAttributes")),
-        keyBindings: this.getObjectLiteralExpressionWithComments(this.getLegacyPropertyInitializer(props, "keyBindings")),
-      },
-      eventsComments: eventsComments,
-      lifecycleMethods: legacyLifecycleMethods,
-      ordinaryMethods: ordinaryMethods,
-      ordinaryShorthandProperties: ordinaryShorthandProperties,
-      ordinaryGetAccessors: ordinaryGetAccessors,
-      ordinaryPropertyAssignments: ordinaryPropertyAssignments,
-    };
-  }
-
-  private convertLegacyProeprtyInitializer<T>(initializer: LegacyPropertyInitializer | undefined, converter: (exp: ts.Expression) => T): DataWithComments<T> | undefined {
-    if(!initializer) {
-      return undefined;
-    }
-    return {
-      data: converter(initializer.data),
-      leadingComments: initializer.leadingComments,
-    }
-  }
-
-  private getObjectLiteralExpressionWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<ts.ObjectLiteralExpression> | undefined {
-    return this.convertLegacyProeprtyInitializer(initializer,
-        expr => codeUtils.getObjectLiteralExpression(expr));
-  }
-
-  private getStringLiteralValueWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<string> | undefined {
-    return this.convertLegacyProeprtyInitializer(initializer,
-        expr => codeUtils.getStringLiteralValue(expr));
-  }
-
-  private getBooleanLiteralValueWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<boolean> | undefined {
-    return this.convertLegacyProeprtyInitializer(initializer,
-        expr => codeUtils.getBooleanLiteralValue(expr));
-  }
-
-
-  private getArrayLiteralExpressionWithComments(initializer: LegacyPropertyInitializer | undefined): DataWithComments<ts.ArrayLiteralExpression> | undefined {
-    return this.convertLegacyProeprtyInitializer(initializer,
-        expr => codeUtils.getArrayLiteralExpression(expr));
-  }
-
-  private getLegacyPropertyInitializer(props: Map<String, ts.ObjectLiteralElementLike>, propName: string): LegacyPropertyInitializer | undefined {
-    const property = props.get(propName);
-    if (!property) {
-      return undefined;
-    }
-    const assignment = codeUtils.getPropertyAssignment(property);
-    if (!assignment) {
-      return undefined;
-    }
-    const comments: string[] = codeUtils.getLeadingComments(property)
-          .filter(c => !this.isEventComment(c));
-    return {
-      data: assignment.initializer,
-      leadingComments: comments,
-    };
-  }
-
-  private isEventComment(comment: string): boolean {
-    return comment.indexOf('@event') >= 0;
-  }
-
-  private getEventsComments(polymerComponentSource: string): string[] {
-    return CommentsParser.collectAllComments(polymerComponentSource)
-        .filter(c => this.isEventComment(c));
-  }
-
-  private getLegacyMethodDeclaration(props: Map<String, ts.ObjectLiteralElementLike>, propName: string): ts.MethodDeclaration | undefined {
-    const property = props.get(propName);
-    if (!property) {
-      return undefined;
-    }
-    return codeUtils.assertNodeKind(property, ts.SyntaxKind.MethodDeclaration) as ts.MethodDeclaration;
-  }
-
-}
-
-export type ParsedPolymerComponent = LegacyPolymerComponent;
-
-export interface LegacyPolymerComponent {
-  jsFile: string;
-  htmlFile: string;
-  parsedFile: ts.SourceFile;
-  polymerFuncCallExpr: ts.CallExpression;
-  componentSettings: LegacyPolymerComponentSettings;
-}
-
-export interface LegacyReservedDeclarations {
-  is?: DataWithComments<string>;
-  _legacyUndefinedCheck?: DataWithComments<boolean>;
-  properties?: DataWithComments<ts.ObjectLiteralExpression>;
-  behaviors?: DataWithComments<ts.ArrayLiteralExpression>,
-  observers? :DataWithComments<ts.ArrayLiteralExpression>,
-  listeners? :DataWithComments<ts.ObjectLiteralExpression>,
-  hostAttributes?: DataWithComments<ts.ObjectLiteralExpression>,
-  keyBindings?: DataWithComments<ts.ObjectLiteralExpression>,
-}
-
-export const LegacyLifecycleMethodsArray = <const>["beforeRegister", "registered", "created", "ready", "attached" , "detached", "attributeChanged"];
-export type LegacyLifecycleMethodName = typeof LegacyLifecycleMethodsArray[number];
-export type LegacyLifecycleMethods = Map<LegacyLifecycleMethodName, ts.MethodDeclaration>;
-export type OrdinaryMethods = Map<string, ts.MethodDeclaration>;
-export type OrdinaryShorthandProperties = Map<string, ts.ShorthandPropertyAssignment>;
-export type OrdinaryGetAccessors = Map<string, ts.GetAccessorDeclaration>;
-export type OrdinaryPropertyAssignments = Map<string, ts.PropertyAssignment>;
-export type ReservedName = LegacyLifecycleMethodName | keyof LegacyReservedDeclarations;
-export const RESERVED_NAMES: {[x in ReservedName]: boolean} = {
-  attached: true,
-  detached: true,
-  ready: true,
-  created: true,
-  beforeRegister: true,
-  registered: true,
-  attributeChanged: true,
-  is: true,
-  _legacyUndefinedCheck: true,
-  properties: true,
-  behaviors: true,
-  observers: true,
-  listeners: true,
-  hostAttributes: true,
-  keyBindings: true,
-};
-
-export interface LegacyPolymerComponentSettings {
-  reservedDeclarations: LegacyReservedDeclarations;
-  lifecycleMethods: LegacyLifecycleMethods,
-  ordinaryMethods: OrdinaryMethods,
-  ordinaryShorthandProperties: OrdinaryShorthandProperties,
-  ordinaryGetAccessors: OrdinaryGetAccessors,
-  ordinaryPropertyAssignments: OrdinaryPropertyAssignments,
-  eventsComments: string[];
-}
-
-export interface DataWithComments<T> {
-  data: T;
-  leadingComments: string[];
-}
-
-type LegacyPropertyInitializer = DataWithComments<ts.Expression>;
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/polymerElementBuilder.ts b/tools/polygerrit-updater/src/funcToClassConversion/polymerElementBuilder.ts
deleted file mode 100644
index d6e113c..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/polymerElementBuilder.ts
+++ /dev/null
@@ -1,142 +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.
-
-import {DataWithComments, LegacyPolymerComponent, LegacyReservedDeclarations, OrdinaryGetAccessors, OrdinaryMethods, OrdinaryPropertyAssignments, OrdinaryShorthandProperties} from './polymerComponentParser';
-import * as ts from 'typescript';
-import * as codeUtils from '../utils/codeUtils';
-import {LifecycleMethod} from './lifecycleMethodsBuilder';
-import {PolymerClassBuilder} from '../utils/polymerClassBuilder';
-import {SyntaxKind} from 'typescript';
-
-export interface ClassBasedPolymerElement {
-  classDeclaration: ts.ClassDeclaration;
-  componentRegistration: ts.ExpressionStatement;
-  eventsComments: string[];
-  generatedComments: string[];
-}
-
-export class PolymerElementBuilder {
-  private readonly reservedDeclarations: LegacyReservedDeclarations;
-  private readonly classBuilder: PolymerClassBuilder;
-  private mixins: ts.ExpressionWithTypeArguments | null;
-
-  public constructor(private readonly legacyComponent: LegacyPolymerComponent, className: string) {
-    this.reservedDeclarations = legacyComponent.componentSettings.reservedDeclarations;
-    this.classBuilder = new PolymerClassBuilder(className);
-    this.mixins = null;
-  }
-
-  public addIsAccessor(tagName: string) {
-    this.classBuilder.addIsAccessor(this.createIsAccessor(tagName));
-  }
-
-  public addPolymerPropertiesAccessor(legacyProperties: DataWithComments<ts.ObjectLiteralExpression>) {
-    const returnStatement = ts.createReturn(legacyProperties.data);
-    const block = ts.createBlock([returnStatement]);
-    let propertiesAccessor = ts.createGetAccessor(undefined, [ts.createModifier(ts.SyntaxKind.StaticKeyword)], "properties", [], undefined, block);
-    if(legacyProperties.leadingComments.length > 0) {
-      propertiesAccessor = codeUtils.restoreLeadingComments(propertiesAccessor, legacyProperties.leadingComments);
-    }
-    this.classBuilder.addPolymerPropertiesAccessor(legacyProperties.data.pos, propertiesAccessor);
-  }
-
-  public addPolymerPropertiesObservers(legacyObservers: ts.ArrayLiteralExpression) {
-    const returnStatement = ts.createReturn(legacyObservers);
-    const block = ts.createBlock([returnStatement]);
-    const propertiesAccessor = ts.createGetAccessor(undefined, [ts.createModifier(ts.SyntaxKind.StaticKeyword)], "observers", [], undefined, block);
-
-    this.classBuilder.addPolymerObserversAccessor(legacyObservers.pos, propertiesAccessor);
-  }
-
-  public addKeyBindings(keyBindings: ts.ObjectLiteralExpression) {
-    //In Polymer 2 keyBindings must be a property with get accessor
-    const returnStatement = ts.createReturn(keyBindings);
-    const block = ts.createBlock([returnStatement]);
-    const keyBindingsAccessor = ts.createGetAccessor(undefined, [], "keyBindings", [], undefined, block);
-
-    this.classBuilder.addGetAccessor(keyBindings.pos, keyBindingsAccessor);
-  }
-  public addOrdinaryMethods(ordinaryMethods: OrdinaryMethods) {
-    for(const [name, method] of ordinaryMethods) {
-      this.classBuilder.addMethod(method.pos, method);
-    }
-  }
-
-  public addOrdinaryGetAccessors(ordinaryGetAccessors: OrdinaryGetAccessors) {
-    for(const [name, accessor] of ordinaryGetAccessors) {
-      this.classBuilder.addGetAccessor(accessor.pos, accessor);
-    }
-  }
-
-  public addOrdinaryShorthandProperties(ordinaryShorthandProperties: OrdinaryShorthandProperties) {
-    for (const [name, property] of ordinaryShorthandProperties) {
-      this.classBuilder.addClassFieldInitializer(property.name, property.name);
-    }
-  }
-
-  public addOrdinaryPropertyAssignments(ordinaryPropertyAssignments: OrdinaryPropertyAssignments) {
-    for (const [name, property] of ordinaryPropertyAssignments) {
-      const propertyName = codeUtils.assertNodeKind(property.name, ts.SyntaxKind.Identifier) as ts.Identifier;
-      this.classBuilder.addClassFieldInitializer(propertyName, property.initializer);
-    }
-  }
-
-  public addMixin(name: string, mixinArguments?: ts.Expression[]) {
-    let fullMixinArguments: ts.Expression[] = [];
-    if(mixinArguments) {
-      fullMixinArguments.push(...mixinArguments);
-    }
-    if(this.mixins) {
-      fullMixinArguments.push(this.mixins.expression);
-    }
-    if(fullMixinArguments.length > 0) {
-      this.mixins = ts.createExpressionWithTypeArguments([], ts.createCall(codeUtils.createNameExpression(name), [], fullMixinArguments.length > 0 ? fullMixinArguments : undefined));
-    }
-    else {
-      this.mixins = ts.createExpressionWithTypeArguments([], codeUtils.createNameExpression(name));
-    }
-  }
-
-  public addClassJSDocComments(lines: string[]) {
-    this.classBuilder.addClassJSDocComments(lines);
-  }
-
-  public build(): ClassBasedPolymerElement {
-    if(this.mixins) {
-      this.classBuilder.setBaseType(this.mixins);
-    }
-    const className = this.classBuilder.className;
-    const callExpression = ts.createCall(ts.createPropertyAccess(ts.createIdentifier("customElements"), "define"), undefined, [ts.createPropertyAccess(ts.createIdentifier(className), "is"), ts.createIdentifier(className)]);
-    const classBuilderResult = this.classBuilder.build();
-    return {
-      classDeclaration: classBuilderResult.classDeclaration,
-      generatedComments: classBuilderResult.generatedComments,
-      componentRegistration: ts.createExpressionStatement(callExpression),
-      eventsComments: this.legacyComponent.componentSettings.eventsComments,
-    };
-  }
-
-  private createIsAccessor(tagName: string): ts.GetAccessorDeclaration {
-    const returnStatement = ts.createReturn(ts.createStringLiteral(tagName));
-    const block = ts.createBlock([returnStatement]);
-    const accessor = ts.createGetAccessor([], [ts.createModifier(ts.SyntaxKind.StaticKeyword)], "is", [], undefined, block);
-    return codeUtils.addReplacableCommentAfterNode(accessor, "eventsComments");
-  }
-
-  public addLifecycleMethods(newLifecycleMethods: LifecycleMethod[]) {
-    for(const lifecycleMethod of newLifecycleMethods) {
-      this.classBuilder.addLifecycleMethod(lifecycleMethod.name, lifecycleMethod.originalPos, lifecycleMethod.method);
-    }
-  }
-}
diff --git a/tools/polygerrit-updater/src/funcToClassConversion/updatedFileWriter.ts b/tools/polygerrit-updater/src/funcToClassConversion/updatedFileWriter.ts
deleted file mode 100644
index a147f50..0000000
--- a/tools/polygerrit-updater/src/funcToClassConversion/updatedFileWriter.ts
+++ /dev/null
@@ -1,248 +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.
-
-import {LegacyPolymerComponent} from './polymerComponentParser';
-import * as ts from 'typescript';
-import * as codeUtils from '../utils/codeUtils';
-import * as path from "path";
-import * as fs from "fs";
-import {LegacyPolymerFuncReplaceResult} from './legacyPolymerFuncReplacer';
-import {CommentsParser} from '../utils/commentsParser';
-
-export interface UpdatedFileWriterParameters {
-  out: string;
-  inplace: boolean;
-  writeOutput: boolean;
-  rootDir: string;
-}
-
-interface Replacement {
-  start: number;
-  length: number;
-  newText: string;
-}
-
-const elementRegistrationRegex = /^(\s*)customElements.define\((\w+).is, \w+\);$/m;
-const maxLineLength = 80;
-
-export class UpdatedFileWriter {
-  public constructor(private readonly component: LegacyPolymerComponent, private readonly params: UpdatedFileWriterParameters) {
-  }
-
-  public write(replaceResult: LegacyPolymerFuncReplaceResult, eventsComments: string[], generatedComments: string[]) {
-    const options: ts.PrinterOptions = {
-      removeComments: false,
-      newLine: ts.NewLineKind.LineFeed,
-    };
-    const printer = ts.createPrinter(options);
-    let newContent = codeUtils.applyNewLines(printer.printFile(replaceResult.file));
-    //ts printer doesn't keep original formatting of the file (spacing, new lines, comments, etc...).
-    //The following code tries restore original formatting
-
-    const existingComments = this.collectAllComments(newContent, []);
-
-    newContent = this.restoreEventsComments(newContent, eventsComments, existingComments);
-    newContent = this.restoreLeadingComments(newContent, replaceResult.leadingComments);
-    newContent = this.restoreFormating(printer, newContent);
-    newContent = this.splitLongLines(newContent);
-    newContent = this.addCommentsWarnings(newContent, generatedComments);
-
-    if (this.params.writeOutput) {
-      const outDir = this.params.inplace ? this.params.rootDir : this.params.out;
-      const fullOutPath = path.resolve(outDir, this.component.jsFile);
-      const fullOutDir = path.dirname(fullOutPath);
-      if (!fs.existsSync(fullOutDir)) {
-        fs.mkdirSync(fullOutDir, {
-          recursive: true,
-          mode: fs.lstatSync(this.params.rootDir).mode
-        });
-      }
-      fs.writeFileSync(fullOutPath, newContent);
-    }
-  }
-
-  private restoreEventsComments(content: string, eventsComments: string[], existingComments: Map<string, number>): string {
-    //In some cases Typescript compiler keep existing comments. These comments
-    // must not be restored here
-    eventsComments = eventsComments.filter(c => !existingComments.has(this.getNormalizedComment(c)));
-    return codeUtils.replaceComment(content, "eventsComments", "\n" + eventsComments.join("\n\n") + "\n");
-  }
-
-  private restoreLeadingComments(content: string, leadingComments: string[]): string {
-    return leadingComments.reduce(
-        (newContent, comment, commentIndex) =>
-            codeUtils.replaceComment(newContent, String(commentIndex), comment),
-        content);
-  }
-
-  private restoreFormating(printer: ts.Printer, newContent: string): string {
-    const originalFile = this.component.parsedFile;
-    const newFile = ts.createSourceFile(originalFile.fileName, newContent, originalFile.languageVersion, true, ts.ScriptKind.JS);
-    const textMap = new Map<ts.SyntaxKind, Map<string, Set<string>>>();
-    const comments = new Set<string>();
-    this.collectAllStrings(printer, originalFile, textMap);
-
-    const replacements: Replacement[] = [];
-    this.collectReplacements(printer, newFile, textMap, replacements);
-    replacements.sort((a, b) => b.start - a.start);
-    let result = newFile.getFullText();
-    let prevReplacement: Replacement | null = null;
-    for (const replacement of replacements) {
-      if (prevReplacement) {
-        if (replacement.start + replacement.length > prevReplacement.start) {
-          throw new Error('Internal error! Replacements must not intersect');
-        }
-      }
-      result = result.substring(0, replacement.start) + replacement.newText + result.substring(replacement.start + replacement.length);
-      prevReplacement = replacement;
-    }
-    return result;
-  }
-
-  private splitLongLines(content: string): string {
-    content = content.replace(elementRegistrationRegex, (match, indent, className) => {
-      if (match.length > maxLineLength) {
-        return `${indent}customElements.define(${className}.is,\n` +
-            `${indent}  ${className});`;
-      }
-      else {
-        return match;
-      }
-    });
-
-    return content
-        .replace(
-            "Polymer.LegacyDataMixin(Polymer.GestureEventListeners(Polymer.LegacyElementMixin(Polymer.Element)))",
-            "Polymer.LegacyDataMixin(\nPolymer.GestureEventListeners(\nPolymer.LegacyElementMixin(\nPolymer.Element)))")
-        .replace(
-            "Polymer.GestureEventListeners(Polymer.LegacyElementMixin(Polymer.Element))",
-            "Polymer.GestureEventListeners(\nPolymer.LegacyElementMixin(\nPolymer.Element))");
-
-  }
-
-  private addCommentsWarnings(newContent: string, generatedComments: string[]): string {
-    const expectedComments = this.collectAllComments(this.component.parsedFile.getFullText(), generatedComments);
-    const newComments = this.collectAllComments(newContent, []);
-    const commentsWarnings = [];
-    for (const [text, count] of expectedComments) {
-      const newCount = newComments.get(text);
-      if (!newCount) {
-        commentsWarnings.push(`Comment '${text}' is missing in the new content.`);
-      }
-      else if (newCount != count) {
-        commentsWarnings.push(`Comment '${text}' appears ${newCount} times in the new file and ${count} times in the old file.`);
-      }
-    }
-
-    for (const [text, newCount] of newComments) {
-      if (!expectedComments.has(text)) {
-        commentsWarnings.push(`Comment '${text}' appears only in the new content`);
-      }
-    }
-    if (commentsWarnings.length === 0) {
-      return newContent;
-    }
-    let commentsProblemStr = "";
-    if (commentsWarnings.length > 0) {
-      commentsProblemStr = commentsWarnings.join("-----------------------------\n");
-      console.log(commentsProblemStr);
-    }
-
-    return "//This file has the following problems with comments:\n" + commentsProblemStr + "\n" + newContent;
-
-  }
-
-  private collectAllComments(content: string, additionalComments: string[]): Map<string, number> {
-    const comments = CommentsParser.collectAllComments(content);
-    comments.push(...additionalComments);
-    const result = new Map<string, number>();
-    for (const comment of comments) {
-      let normalizedComment = this.getNormalizedComment(comment);
-      const count = result.get(normalizedComment);
-      if (count) {
-        result.set(normalizedComment, count + 1);
-      } else {
-        result.set(normalizedComment, 1);
-      }
-    }
-    return result;
-  }
-
-  private getNormalizedComment(comment: string): string {
-    if(comment.startsWith('/**')) {
-      comment = comment.replace(/^\s+\*/gm, "*");
-    }
-    return comment;
-  }
-
-  private collectAllStrings(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>) {
-    const formattedText = printer.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile())
-    const originalText = node.getFullText();
-    this.addIfNotExists(map, node.kind, formattedText, originalText);
-    ts.forEachChild(node, child => this.collectAllStrings(printer, child, map));
-  }
-
-  private collectReplacements(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>, replacements: Replacement[]) {
-    if(node.kind === ts.SyntaxKind.ThisKeyword || node.kind === ts.SyntaxKind.Identifier || node.kind === ts.SyntaxKind.StringLiteral || node.kind === ts.SyntaxKind.NumericLiteral) {
-      return;
-    }
-    const replacement = this.getReplacement(printer, node, map);
-    if(replacement) {
-      replacements.push(replacement);
-      return;
-    }
-    ts.forEachChild(node, child => this.collectReplacements(printer, child, map, replacements));
-  }
-
-  private addIfNotExists(map: Map<ts.SyntaxKind, Map<string, Set<string>>>, kind: ts.SyntaxKind, formattedText: string, originalText: string) {
-    let mapForKind = map.get(kind);
-    if(!mapForKind) {
-      mapForKind = new Map();
-      map.set(kind, mapForKind);
-    }
-
-    let existingOriginalText = mapForKind.get(formattedText);
-    if(!existingOriginalText) {
-      existingOriginalText = new Set<string>();
-      mapForKind.set(formattedText, existingOriginalText);
-      //throw new Error(`Different formatting of the same string exists. Kind: ${ts.SyntaxKind[kind]}.\nFormatting 1:\n${originalText}\nFormatting2:\n${existingOriginalText}\n `);
-    }
-    existingOriginalText.add(originalText);
-  }
-
-  private getReplacement(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>): Replacement | undefined {
-    const replacementsForKind = map.get(node.kind);
-    if(!replacementsForKind) {
-      return;
-    }
-    // Use printer instead of getFullText to "isolate" node content.
-    // node.getFullText returns text with indents from the original file.
-    const newText = printer.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile());
-    const originalSet = replacementsForKind.get(newText);
-    if(!originalSet || originalSet.size === 0) {
-      return;
-    }
-    if(originalSet.size >= 2) {
-      console.log(`Multiple replacements possible. Formatting of some lines can be changed`);
-    }
-    const replacementText: string = originalSet.values().next().value;
-    const nodeText = node.getFullText();
-    return {
-      start: node.pos,
-      length: nodeText.length,//Do not use newText here!
-      newText: replacementText,
-    }
-  }
-
-}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/index.ts b/tools/polygerrit-updater/src/index.ts
deleted file mode 100644
index 1b7c315..0000000
--- a/tools/polygerrit-updater/src/index.ts
+++ /dev/null
@@ -1,168 +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.
-
-import * as fs from "fs";
-import * as path from "path";
-import {LegacyPolymerComponent, LegacyPolymerComponentParser} from './funcToClassConversion/polymerComponentParser';
-import {ClassBasedPolymerElement} from './funcToClassConversion/polymerElementBuilder';
-import {PolymerFuncToClassBasedConverter} from './funcToClassConversion/funcToClassBasedElementConverter';
-import {LegacyPolymerFuncReplacer} from './funcToClassConversion/legacyPolymerFuncReplacer';
-import {UpdatedFileWriter} from './funcToClassConversion/updatedFileWriter';
-import {CommandLineParser} from './utils/commandLineParser';
-
-interface UpdaterParameters {
-  htmlFiles: Set<string>;
-  jsFiles: Set<string>;
-  out: string;
-  inplace: boolean;
-  writeOutput: boolean;
-  rootDir: string;
-}
-
-interface InputFilesFilter {
-  includeDir(path: string): boolean;
-  includeFile(path: string): boolean;
-}
-
-function addFile(filePath: string, params: UpdaterParameters, filter: InputFilesFilter) {
-  const parsedPath = path.parse(filePath);
-  const ext = parsedPath.ext.toLowerCase();
-  const relativePath = path.relative(params.rootDir, filePath);
-  if(!filter.includeFile(relativePath)) return;
-  if(relativePath.startsWith("../")) {
-    throw new Error(`${filePath} is not in rootDir ${params.rootDir}`);
-  }
-  if(ext === ".html") {
-    params.htmlFiles.add(relativePath);
-  } if(ext === ".js") {
-    params.jsFiles.add(relativePath);
-  }
-}
-
-function addDirectory(dirPath: string, params: UpdaterParameters, recursive: boolean, filter: InputFilesFilter): void {
-  const entries = fs.readdirSync(dirPath, {withFileTypes: true});
-  for(const entry of entries) {
-    const dirEnt = entry as fs.Dirent;
-    const fullPath = path.join(dirPath, dirEnt.name);
-    const relativePath = path.relative(params.rootDir, fullPath);
-    if(dirEnt.isDirectory()) {
-      if (!filter.includeDir(relativePath)) continue;
-      if(recursive) {
-        addDirectory(fullPath, params, recursive, filter);
-      }
-    }
-    else if(dirEnt.isFile()) {
-      addFile(fullPath, params, filter);
-    } else {
-      throw Error(`Unsupported dir entry '${entry.name}' in '${fullPath}'`);
-    }
-  }
-}
-
-async function updateLegacyComponent(component: LegacyPolymerComponent, params: UpdaterParameters) {
-  const classBasedElement: ClassBasedPolymerElement = PolymerFuncToClassBasedConverter.convert(component);
-
-  const replacer = new LegacyPolymerFuncReplacer(component);
-  const replaceResult = replacer.replace(classBasedElement);
-  try {
-    const writer = new UpdatedFileWriter(component, params);
-    writer.write(replaceResult, classBasedElement.eventsComments, classBasedElement.generatedComments);
-  }
-  finally {
-    replaceResult.dispose();
-  }
-}
-
-async function main() {
-  const params: UpdaterParameters = await getParams();
-  if(params.jsFiles.size === 0) {
-    console.log("No files found");
-    return;
-  }
-  const legacyPolymerComponentParser = new LegacyPolymerComponentParser(params.rootDir, params.htmlFiles)
-  for(const jsFile of params.jsFiles) {
-    console.log(`Processing ${jsFile}`);
-    const legacyComponent = await legacyPolymerComponentParser.parse(jsFile);
-    if(legacyComponent) {
-      await updateLegacyComponent(legacyComponent, params);
-      continue;
-    }
-  }
-}
-
-interface CommandLineParameters {
-  src: string[];
-  recursive: boolean;
-  excludes: string[];
-  out: string;
-  inplace: boolean;
-  noOutput: boolean;
-  rootDir: string;
-}
-
-async function getParams(): Promise<UpdaterParameters> {
-  const parser = new CommandLineParser({
-    src: CommandLineParser.createStringArrayOption("src", ".js file or folder to process", []),
-    recursive: CommandLineParser.createBooleanOption("r", "process folder recursive", false),
-    excludes: CommandLineParser.createStringArrayOption("exclude", "List of file prefixes to exclude. If relative file path starts with one of the prefixes, it will be excluded", []),
-    out: CommandLineParser.createStringOption("out", "Output folder.", null),
-    rootDir: CommandLineParser.createStringOption("root", "Root directory for src files", "/"),
-    inplace: CommandLineParser.createBooleanOption("i", "Update files inplace", false),
-    noOutput: CommandLineParser.createBooleanOption("noout", "Do everything, but do not write anything to files", false),
-  });
-  const commandLineParams: CommandLineParameters = parser.parse(process.argv) as CommandLineParameters;
-
-  const params: UpdaterParameters = {
-    htmlFiles: new Set(),
-    jsFiles: new Set(),
-    writeOutput: !commandLineParams.noOutput,
-    inplace: commandLineParams.inplace,
-    out: commandLineParams.out,
-    rootDir: path.resolve(commandLineParams.rootDir)
-  };
-
-  if(params.writeOutput && !params.inplace && !params.out) {
-    throw new Error("You should specify output directory (--out directory_name)");
-  }
-
-  const filter = new ExcludeFilesFilter(commandLineParams.excludes);
-  for(const srcPath of commandLineParams.src) {
-    const resolvedPath = path.resolve(params.rootDir, srcPath);
-    if(fs.lstatSync(resolvedPath).isFile()) {
-      addFile(resolvedPath, params, filter);
-    } else {
-      addDirectory(resolvedPath, params, commandLineParams.recursive, filter);
-    }
-  }
-  return params;
-}
-
-class ExcludeFilesFilter implements InputFilesFilter {
-  public constructor(private readonly excludes: string[]) {
-  }
-  includeDir(path: string): boolean {
-    return this.excludes.every(exclude => !path.startsWith(exclude));
-  }
-
-  includeFile(path: string): boolean {
-    return this.excludes.every(exclude => !path.startsWith(exclude));
-  }
-}
-
-main().then(() => {
-  process.exit(0);
-}).catch(e => {
-  console.error(e);
-  process.exit(1);
-});
diff --git a/tools/polygerrit-updater/src/utils/codeUtils.ts b/tools/polygerrit-updater/src/utils/codeUtils.ts
deleted file mode 100644
index 53a7f0d..0000000
--- a/tools/polygerrit-updater/src/utils/codeUtils.ts
+++ /dev/null
@@ -1,183 +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.
-
-import * as ts from 'typescript';
-import {SyntaxKind} from 'typescript';
-import {Node} from 'typescript';
-
-export function assertNodeKind<T extends U, U extends ts.Node>(node: U, expectedKind: ts.SyntaxKind): T {
-  if (node.kind !== expectedKind) {
-    throw new Error(`Invlid node kind. Expected: ${ts.SyntaxKind[expectedKind]}, actual: ${ts.SyntaxKind[node.kind]}`);
-  }
-  return node as T;
-}
-
-export function assertNodeKindOrUndefined<T extends U, U extends ts.Node>(node: U | undefined, expectedKind: ts.SyntaxKind): T | undefined {
-  if (!node) {
-    return undefined;
-  }
-  return assertNodeKind<T, U>(node, expectedKind);
-}
-
-export function getPropertyAssignment(expression?: ts.ObjectLiteralElementLike): ts.PropertyAssignment | undefined {
-  return assertNodeKindOrUndefined(expression, ts.SyntaxKind.PropertyAssignment);
-}
-
-export function getStringLiteralValue(expression: ts.Expression): string {
-  const literal: ts.StringLiteral = assertNodeKind(expression, ts.SyntaxKind.StringLiteral);
-  return literal.text;
-}
-
-export function getBooleanLiteralValue(expression: ts.Expression): boolean {
-  if (expression.kind === ts.SyntaxKind.TrueKeyword) {
-    return true;
-  }
-  if (expression.kind === ts.SyntaxKind.FalseKeyword) {
-    return false;
-  }
-  throw new Error(`Invalid expression kind - ${expression.kind}`);
-}
-
-export function getObjectLiteralExpression(expression: ts.Expression): ts.ObjectLiteralExpression {
-  return assertNodeKind(expression, ts.SyntaxKind.ObjectLiteralExpression);
-}
-
-export function getArrayLiteralExpression(expression: ts.Expression): ts.ArrayLiteralExpression {
-  return assertNodeKind(expression, ts.SyntaxKind.ArrayLiteralExpression);
-}
-
-export function replaceNode(file: ts.SourceFile, originalNode: ts.Node, newNode: ts.Node): ts.TransformationResult<ts.SourceFile> {
-  const nodeReplacerTransformer: ts.TransformerFactory<ts.SourceFile> = (context: ts.TransformationContext) => {
-    const visitor: ts.Visitor = (node) => {
-      if(node === originalNode) {
-        return newNode;
-      }
-      return ts.visitEachChild(node, visitor, context);
-    };
-
-
-    return source => ts.visitNode(source, visitor);
-  };
-  return ts.transform(file, [nodeReplacerTransformer]);
-}
-
-export type NameExpression = ts.Identifier | ts.ThisExpression | ts.PropertyAccessExpression;
-export function createNameExpression(fullPath: string): NameExpression {
-  const parts = fullPath.split(".");
-  let result: NameExpression = parts[0] === "this" ? ts.createThis() : ts.createIdentifier(parts[0]);
-  for(let i = 1; i < parts.length; i++) {
-    result = ts.createPropertyAccess(result, parts[i]);
-  }
-  return result;
-}
-
-const generatedCommentNewLineAfterText = "-Generated code - new line after - 9cb292bc-5d88-4c5e-88f4-49535c93beb9 -";
-const generatedCommentNewLineBeforeText = "-Generated code - new line-before - 9cb292bc-5d88-4c5e-88f4-49535c93beb9 -";
-const generatedCommentNewLineAfterRegExp = new RegExp("//" + generatedCommentNewLineAfterText, 'g');
-const generatedCommentNewLineBeforeRegExp = new RegExp("//" + generatedCommentNewLineBeforeText + "\n", 'g');
-const replacableCommentText = "- Replacepoint - 9cb292bc-5d88-4c5e-88f4-49535c93beb9 -";
-
-export function addNewLineAfterNode<T extends ts.Node>(node: T): T {
-  const comment = ts.getSyntheticTrailingComments(node);
-  if(comment && comment.some(c => c.text === generatedCommentNewLineAfterText)) {
-    return node;
-  }
-  return ts.addSyntheticTrailingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, generatedCommentNewLineAfterText, true);
-}
-
-export function addNewLineBeforeNode<T extends ts.Node>(node: T): T {
-  const comment = ts.getSyntheticLeadingComments(node);
-  if(comment && comment.some(c => c.text === generatedCommentNewLineBeforeText)) {
-    return node;
-  }
-  return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, generatedCommentNewLineBeforeText, true);
-}
-
-
-export function applyNewLines(text: string): string {
-  return text.replace(generatedCommentNewLineAfterRegExp, "").replace(generatedCommentNewLineBeforeRegExp, "");
-
-}
-export function addReplacableCommentAfterNode<T extends ts.Node>(node: T, name: string): T {
-  return ts.addSyntheticTrailingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, replacableCommentText + name, true);
-}
-
-export function addReplacableCommentBeforeNode<T extends ts.Node>(node: T, name: string): T {
-  return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.SingleLineCommentTrivia, replacableCommentText + name, true);
-}
-
-export function replaceComment(text: string, commentName: string, newContent: string): string {
-  return text.replace("//" + replacableCommentText + commentName, newContent);
-}
-
-export function createMethod(name: string, methodDecl: ts.MethodDeclaration | undefined, codeAtStart: ts.Statement[], codeAtEnd: ts.Statement[], callSuperMethod: boolean): ts.MethodDeclaration | undefined {
-  if(!methodDecl && (codeAtEnd.length > 0 || codeAtEnd.length > 0)) {
-    methodDecl = ts.createMethod([], [], undefined, name, undefined, [], [],undefined, ts.createBlock([]));
-  }
-  if(!methodDecl) {
-    return;
-  }
-  if (!methodDecl.body) {
-    throw new Error("Method must have a body");
-  }
-  if(methodDecl.parameters.length > 0) {
-    throw new Error("Methods with parameters are not supported");
-  }
-  let newStatements = [...codeAtStart];
-  if(callSuperMethod) {
-    const superCall: ts.CallExpression = ts.createCall(ts.createPropertyAccess(ts.createSuper(), assertNodeKind(methodDecl.name, ts.SyntaxKind.Identifier) as ts.Identifier), [], []);
-    const superCallExpression = ts.createExpressionStatement(superCall);
-    newStatements.push(superCallExpression);
-  }
-  newStatements.push(...codeAtEnd);
-  const newBody = ts.getMutableClone(methodDecl.body);
-
-  newStatements = newStatements.map(m => addNewLineAfterNode(m));
-  newStatements.splice(codeAtStart.length + 1, 0, ...newBody.statements);
-
-  newBody.statements = ts.createNodeArray(newStatements);
-
-  const newMethod = ts.getMutableClone(methodDecl);
-  newMethod.body = newBody;
-
-  return newMethod;
-}
-
-export function restoreLeadingComments<T extends Node>(node: T, originalComments: string[]): T {
-  if(originalComments.length === 0) {
-    return node;
-  }
-  for(const comment of originalComments) {
-    if(comment.startsWith("//")) {
-      node = ts.addSyntheticLeadingComment(node, SyntaxKind.SingleLineCommentTrivia, comment.substr(2), true);
-    } else if(comment.startsWith("/*")) {
-      if(!comment.endsWith("*/")) {
-        throw new Error(`Not support comment: ${comment}`);
-      }
-      node = ts.addSyntheticLeadingComment(node, SyntaxKind.MultiLineCommentTrivia, comment.substr(2, comment.length - 4), true);
-    } else {
-      throw new Error(`Not supported comment: ${comment}`);
-    }
-  }
-  return node;
-}
-
-export function getLeadingComments(node: ts.Node): string[] {
-  const nodeText = node.getFullText();
-  const commentRanges = ts.getLeadingCommentRanges(nodeText, 0);
-  if(!commentRanges) {
-    return [];
-  }
-  return commentRanges.map(range => nodeText.substring(range.pos, range.end))
-}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/utils/commandLineParser.ts b/tools/polygerrit-updater/src/utils/commandLineParser.ts
deleted file mode 100644
index 658b7ff..0000000
--- a/tools/polygerrit-updater/src/utils/commandLineParser.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed un  der the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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 class CommandLineParser {
-  public static createStringArrayOption(optionName: string, help: string, defaultValue: string[]): CommandLineArgument {
-    return new StringArrayOption(optionName, help, defaultValue);
-  }
-  public static createBooleanOption(optionName: string, help: string, defaultValue: boolean): CommandLineArgument {
-    return new BooleanOption(optionName, help, defaultValue);
-  }
-  public static createStringOption(optionName: string, help: string, defaultValue: string | null): CommandLineArgument {
-    return new StringOption(optionName, help, defaultValue);
-  }
-
-  public constructor(private readonly argumentTypes: {[name: string]: CommandLineArgument}) {
-  }
-  public parse(argv: string[]): object {
-    const result = Object.assign({});
-    let index = 2; //argv[0] - node interpreter, argv[1] - index.js
-    for(const argumentField in this.argumentTypes) {
-      result[argumentField] = this.argumentTypes[argumentField].getDefaultValue();
-    }
-    while(index < argv.length) {
-      let knownArgument = false;
-      for(const argumentField in this.argumentTypes) {
-        const argumentType = this.argumentTypes[argumentField];
-        const argumentValue = argumentType.tryRead(argv, index);
-        if(argumentValue) {
-          knownArgument = true;
-          index += argumentValue.consumed;
-          result[argumentField] = argumentValue.value;
-          break;
-        }
-      }
-      if(!knownArgument) {
-        throw new Error(`Unknown argument ${argv[index]}`);
-      }
-    }
-    return result;
-  }
-}
-
-interface CommandLineArgumentReadResult {
-  consumed: number;
-  value: any;
-}
-
-export interface CommandLineArgument {
-  getDefaultValue(): any;
-  tryRead(argv: string[], startIndex: number): CommandLineArgumentReadResult | null;
-}
-
-abstract class CommandLineOption implements CommandLineArgument {
-  protected constructor(protected readonly optionName: string, protected readonly help: string, private readonly defaultValue: any) {
-  }
-  public tryRead(argv: string[], startIndex: number): CommandLineArgumentReadResult | null  {
-    if(argv[startIndex] !== "--" + this.optionName) {
-      return null;
-    }
-    const readArgumentsResult = this.readArguments(argv, startIndex + 1);
-    if(!readArgumentsResult) {
-      return null;
-    }
-    readArgumentsResult.consumed++; // Add option name
-    return readArgumentsResult;
-  }
-  public getDefaultValue(): any {
-    return this.defaultValue;
-  }
-
-  protected abstract readArguments(argv: string[], startIndex: number) : CommandLineArgumentReadResult | null;
-}
-
-class StringArrayOption extends CommandLineOption {
-  public constructor(optionName: string, help: string, defaultValue: string[]) {
-    super(optionName, help, defaultValue);
-  }
-
-  protected readArguments(argv: string[], startIndex: number): CommandLineArgumentReadResult {
-    const result = [];
-    let index = startIndex;
-    while(index < argv.length) {
-      if(argv[index].startsWith("--")) {
-        break;
-      }
-      result.push(argv[index]);
-      index++;
-    }
-    return {
-      consumed: index - startIndex,
-      value: result
-    }
-  }
-}
-
-class BooleanOption extends CommandLineOption {
-  public constructor(optionName: string, help: string, defaultValue: boolean) {
-    super(optionName, help, defaultValue);
-  }
-
-  protected readArguments(argv: string[], startIndex: number): CommandLineArgumentReadResult {
-    return {
-      consumed: 0,
-      value: true
-    }
-  }
-}
-
-class StringOption extends CommandLineOption {
-  public constructor(optionName: string, help: string, defaultValue: string | null) {
-    super(optionName, help, defaultValue);
-  }
-
-  protected readArguments(argv: string[], startIndex: number): CommandLineArgumentReadResult | null {
-    if(startIndex >= argv.length) {
-      return null;
-    }
-    return {
-      consumed: 1,
-      value: argv[startIndex]
-    }
-  }
-}
diff --git a/tools/polygerrit-updater/src/utils/commentsParser.ts b/tools/polygerrit-updater/src/utils/commentsParser.ts
deleted file mode 100644
index b849829..0000000
--- a/tools/polygerrit-updater/src/utils/commentsParser.ts
+++ /dev/null
@@ -1,79 +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.
-
-enum CommentScannerState {
-  Text,
-  SingleLineComment,
-  MultLineComment
-}
-export class CommentsParser {
-  public static collectAllComments(text: string): string[] {
-    const result: string[] = [];
-    let state = CommentScannerState.Text;
-    let pos = 0;
-    function readSingleLineComment() {
-      const startPos = pos;
-      while(pos < text.length && text[pos] !== '\n') {
-        pos++;
-      }
-      return text.substring(startPos, pos);
-    }
-    function readMultiLineComment() {
-      const startPos = pos;
-      while(pos < text.length) {
-        if(pos < text.length - 1 && text[pos] === '*' && text[pos + 1] === '/') {
-          pos += 2;
-          break;
-        }
-        pos++;
-      }
-      return text.substring(startPos, pos);
-    }
-
-    function skipString(lastChar: string) {
-      pos++;
-      while(pos < text.length) {
-        if(text[pos] === lastChar) {
-          pos++;
-          return;
-        } else if(text[pos] === '\\') {
-          pos+=2;
-          continue;
-        }
-        pos++;
-      }
-    }
-
-
-    while(pos < text.length - 1) {
-      if(text[pos] === '/' && text[pos + 1] === '/') {
-        result.push(readSingleLineComment());
-      } else if(text[pos] === '/' && text[pos + 1] === '*') {
-        result.push(readMultiLineComment());
-      } else if(text[pos] === "'") {
-        skipString("'");
-      } else if(text[pos] === '"') {
-        skipString('"');
-      } else if(text[pos] === '`') {
-        skipString('`');
-      } else if(text[pos] == '/') {
-        skipString('/');
-      } {
-        pos++;
-      }
-
-    }
-    return result;
-  }
-}
diff --git a/tools/polygerrit-updater/src/utils/polymerClassBuilder.ts b/tools/polygerrit-updater/src/utils/polymerClassBuilder.ts
deleted file mode 100644
index b1a4320..0000000
--- a/tools/polygerrit-updater/src/utils/polymerClassBuilder.ts
+++ /dev/null
@@ -1,270 +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.
-
-import * as ts from 'typescript';
-import * as codeUtils from './codeUtils';
-import {LegacyLifecycleMethodName, LegacyLifecycleMethodsArray} from '../funcToClassConversion/polymerComponentParser';
-import {SyntaxKind} from 'typescript';
-
-enum PolymerClassMemberType {
-  IsAccessor,
-  Constructor,
-  PolymerPropertiesAccessor,
-  PolymerObserversAccessor,
-  Method,
-  ExistingLifecycleMethod,
-  NewLifecycleMethod,
-  GetAccessor,
-}
-
-type PolymerClassMember = PolymerClassIsAccessor | PolymerClassConstructor | PolymerClassExistingLifecycleMethod | PolymerClassNewLifecycleMethod | PolymerClassSimpleMember;
-
-interface PolymerClassExistingLifecycleMethod {
-  member: ts.MethodDeclaration;
-  memberType: PolymerClassMemberType.ExistingLifecycleMethod;
-  name: string;
-  lifecycleOrder: number;
-  originalPos: number;
-}
-
-interface PolymerClassNewLifecycleMethod {
-  member: ts.MethodDeclaration;
-  memberType: PolymerClassMemberType.NewLifecycleMethod;
-  name: string;
-  lifecycleOrder: number;
-  originalPos: -1
-}
-
-interface PolymerClassIsAccessor {
-  member: ts.GetAccessorDeclaration;
-  memberType: PolymerClassMemberType.IsAccessor;
-  originalPos: -1
-}
-
-interface PolymerClassConstructor {
-  member: ts.ConstructorDeclaration;
-  memberType: PolymerClassMemberType.Constructor;
-  originalPos: -1
-}
-
-interface PolymerClassSimpleMember {
-  memberType: PolymerClassMemberType.PolymerPropertiesAccessor | PolymerClassMemberType.PolymerObserversAccessor | PolymerClassMemberType.Method | PolymerClassMemberType.GetAccessor;
-  member: ts.ClassElement;
-  originalPos: number;
-}
-
-export interface PolymerClassBuilderResult {
-  classDeclaration: ts.ClassDeclaration;
-  generatedComments: string[];
-}
-
-export class PolymerClassBuilder {
-  private readonly members: PolymerClassMember[] = [];
-  public readonly constructorStatements: ts.Statement[] = [];
-  private baseType: ts.ExpressionWithTypeArguments | undefined;
-  private classJsDocComments: string[];
-
-  public constructor(public readonly className: string) {
-    this.classJsDocComments = [];
-  }
-
-  public addIsAccessor(accessor: ts.GetAccessorDeclaration) {
-    this.members.push({
-      member: accessor,
-      memberType: PolymerClassMemberType.IsAccessor,
-      originalPos: -1
-    });
-  }
-
-  public addPolymerPropertiesAccessor(originalPos: number, accessor: ts.GetAccessorDeclaration) {
-    this.members.push({
-      member: accessor,
-      memberType: PolymerClassMemberType.PolymerPropertiesAccessor,
-      originalPos: originalPos
-    });
-  }
-
-  public addPolymerObserversAccessor(originalPos: number, accessor: ts.GetAccessorDeclaration) {
-    this.members.push({
-      member: accessor,
-      memberType: PolymerClassMemberType.PolymerObserversAccessor,
-      originalPos: originalPos
-    });
-  }
-
-
-  public addClassFieldInitializer(name: string | ts.Identifier, initializer: ts.Expression) {
-    const assignment = ts.createAssignment(ts.createPropertyAccess(ts.createThis(), name), initializer);
-    this.constructorStatements.push(codeUtils.addNewLineAfterNode(ts.createExpressionStatement(assignment)));
-  }
-  public addMethod(originalPos: number, method: ts.MethodDeclaration) {
-    this.members.push({
-      member: method,
-      memberType: PolymerClassMemberType.Method,
-      originalPos: originalPos
-    });
-  }
-
-  public addGetAccessor(originalPos: number, accessor: ts.GetAccessorDeclaration) {
-    this.members.push({
-      member: accessor,
-      memberType: PolymerClassMemberType.GetAccessor,
-      originalPos: originalPos
-    });
-  }
-
-  public addLifecycleMethod(name: LegacyLifecycleMethodName, originalPos: number, method: ts.MethodDeclaration) {
-    const lifecycleOrder = LegacyLifecycleMethodsArray.indexOf(name);
-    if(lifecycleOrder < 0) {
-      throw new Error(`Invalid lifecycle name`);
-    }
-    if(originalPos >= 0) {
-      this.members.push({
-        member: method,
-        memberType: PolymerClassMemberType.ExistingLifecycleMethod,
-        originalPos: originalPos,
-        name: name,
-        lifecycleOrder: lifecycleOrder
-      })
-    } else {
-      this.members.push({
-        member: method,
-        memberType: PolymerClassMemberType.NewLifecycleMethod,
-        name: name,
-        lifecycleOrder: lifecycleOrder,
-        originalPos: -1
-      })
-    }
-  }
-
-  public setBaseType(type: ts.ExpressionWithTypeArguments) {
-    if(this.baseType) {
-      throw new Error("Class can have only one base type");
-    }
-    this.baseType = type;
-  }
-
-  public build(): PolymerClassBuilderResult {
-    let heritageClauses: ts.HeritageClause[] = [];
-    if (this.baseType) {
-      const extendClause = ts.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [this.baseType]);
-      heritageClauses.push(extendClause);
-    }
-    const finalMembers: PolymerClassMember[] = [];
-    const isAccessors = this.members.filter(member => member.memberType === PolymerClassMemberType.IsAccessor);
-    if(isAccessors.length !== 1) {
-      throw new Error("Class must have exactly one 'is'");
-    }
-    finalMembers.push(isAccessors[0]);
-    const constructorMember = this.createConstructor();
-    if(constructorMember) {
-      finalMembers.push(constructorMember);
-    }
-
-    const newLifecycleMethods: PolymerClassNewLifecycleMethod[] = [];
-    this.members.forEach(member => {
-      if(member.memberType === PolymerClassMemberType.NewLifecycleMethod) {
-        newLifecycleMethods.push(member);
-      }
-    });
-
-    const methodsWithKnownPosition = this.members.filter(member => member.originalPos >= 0);
-    methodsWithKnownPosition.sort((a, b) => a.originalPos - b.originalPos);
-
-    finalMembers.push(...methodsWithKnownPosition);
-
-
-    for(const newLifecycleMethod of newLifecycleMethods) {
-      //Number of methods is small - use brute force solution
-      let closestNextIndex = -1;
-      let closestNextOrderDiff: number = LegacyLifecycleMethodsArray.length;
-      let closestPrevIndex = -1;
-      let closestPrevOrderDiff: number = LegacyLifecycleMethodsArray.length;
-      for (let i = 0; i < finalMembers.length; i++) {
-        const member = finalMembers[i];
-        if (member.memberType !== PolymerClassMemberType.NewLifecycleMethod && member.memberType !== PolymerClassMemberType.ExistingLifecycleMethod) {
-          continue;
-        }
-        const orderDiff = member.lifecycleOrder - newLifecycleMethod.lifecycleOrder;
-        if (orderDiff > 0) {
-          if (orderDiff < closestNextOrderDiff) {
-            closestNextIndex = i;
-            closestNextOrderDiff = orderDiff;
-          }
-        } else if (orderDiff < 0) {
-          if (orderDiff < closestPrevOrderDiff) {
-            closestPrevIndex = i;
-            closestPrevOrderDiff = orderDiff;
-          }
-        }
-      }
-      let insertIndex;
-      if (closestNextIndex !== -1 || closestPrevIndex !== -1) {
-        insertIndex = closestNextOrderDiff < closestPrevOrderDiff ?
-            closestNextIndex : closestPrevIndex + 1;
-      } else {
-        insertIndex = Math.max(
-            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.Constructor),
-            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.IsAccessor),
-            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.PolymerPropertiesAccessor),
-            finalMembers.findIndex(m => m.memberType === PolymerClassMemberType.PolymerObserversAccessor),
-        );
-        if(insertIndex < 0) {
-          insertIndex = finalMembers.length;
-        } else {
-          insertIndex++;//Insert after
-        }
-      }
-      finalMembers.splice(insertIndex, 0, newLifecycleMethod);
-    }
-    //Asserts about finalMembers
-    const nonConstructorMembers = finalMembers.filter(m => m.memberType !== PolymerClassMemberType.Constructor);
-
-    if(nonConstructorMembers.length !== this.members.length) {
-      throw new Error(`Internal error! Some methods are missed`);
-    }
-    let classDeclaration = ts.createClassDeclaration(undefined, undefined, this.className, undefined, heritageClauses, finalMembers.map(m => m.member))
-    const generatedComments: string[] = [];
-    if(this.classJsDocComments.length > 0) {
-      const commentContent = '*\n' + this.classJsDocComments.map(line => `* ${line}`).join('\n') + '\n';
-      classDeclaration = ts.addSyntheticLeadingComment(classDeclaration, ts.SyntaxKind.MultiLineCommentTrivia, commentContent, true);
-      generatedComments.push(`/*${commentContent}*/`);
-    }
-    return {
-      classDeclaration,
-      generatedComments,
-    };
-
-  }
-
-  private createConstructor(): PolymerClassConstructor | null {
-    if(this.constructorStatements.length === 0) {
-      return null;
-    }
-    let superCall: ts.CallExpression = ts.createCall(ts.createSuper(), [], []);
-    const superCallExpression = ts.createExpressionStatement(superCall);
-    const statements = [superCallExpression, ...this.constructorStatements];
-    const constructorDeclaration = ts.createConstructor([], [], [], ts.createBlock(statements, true));
-
-    return {
-      memberType: PolymerClassMemberType.Constructor,
-      member: constructorDeclaration,
-      originalPos: -1
-    };
-  }
-
-  public addClassJSDocComments(lines: string[]) {
-    this.classJsDocComments.push(...lines);
-  }
-}
\ No newline at end of file
diff --git a/tools/polygerrit-updater/src/utils/unexpectedValue.ts b/tools/polygerrit-updater/src/utils/unexpectedValue.ts
deleted file mode 100644
index 690c283..0000000
--- a/tools/polygerrit-updater/src/utils/unexpectedValue.ts
+++ /dev/null
@@ -1,17 +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.
-
-export function unexpectedValue<T>(x: T): never {
-  throw new Error(`Unexpected value '${x}'`);
-}
diff --git a/tools/polygerrit-updater/tsconfig.json b/tools/polygerrit-updater/tsconfig.json
deleted file mode 100644
index 80f60c1..0000000
--- a/tools/polygerrit-updater/tsconfig.json
+++ /dev/null
@@ -1,67 +0,0 @@
-{
-  "compilerOptions": {
-    /* Basic Options */
-    // "incremental": true,                   /* Enable incremental compilation */
-    "target": "es2019", 		      /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
-    "module": "es2015", 		      /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
-    // "lib": [],                             /* Specify library files to be included in the compilation. */
-    // "allowJs": true,                       /* Allow javascript files to be compiled. */
-    // "checkJs": true,                       /* Report errors in .js files. */
-    // "jsx": "preserve",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
-    // "declaration": true,                   /* Generates corresponding '.d.ts' file. */
-    // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
-    "sourceMap": true,                     /* Generates corresponding '.map' file. */
-    // "outFile": "./",                       /* Concatenate and emit output to single file. */
-    "outDir": "./js",                        /* Redirect output structure to the directory. */
-    "rootDir": ".",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
-    // "composite": true,                     /* Enable project compilation */
-    // "tsBuildInfoFile": "./",               /* Specify file to store incremental compilation information */
-    // "removeComments": true,                /* Do not emit comments to output. */
-    // "noEmit": true,                        /* Do not emit outputs. */
-    // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
-    // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
-    // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
-
-    /* Strict Type-Checking Options */
-    "strict": true,                           /* Enable all strict type-checking options. */
-    "noImplicitAny": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */
-    // "strictNullChecks": true,              /* Enable strict null checks. */
-    // "strictFunctionTypes": true,           /* Enable strict checking of function types. */
-    // "strictBindCallApply": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
-    // "strictPropertyInitialization": true,  /* Enable strict checking of property initialization in classes. */
-    // "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */
-    // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */
-
-    /* Additional Checks */
-    // "noUnusedLocals": true,                /* Report errors on unused locals. */
-    // "noUnusedParameters": true,            /* Report errors on unused parameters. */
-    // "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
-    // "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */
-
-    /* Module Resolution Options */
-    "moduleResolution": "node",            /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
-    "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
-    "paths": {
-      "*": [ "node_modules/*" ]
-    },                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
-    // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
-    // "typeRoots": [],                       /* List of folders to include type definitions from. */
-    // "types": [],                           /* Type declaration files to be included in compilation. */
-    "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
-    "esModuleInterop": true                   /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
-    // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */
-    // "allowUmdGlobalAccess": true,          /* Allow accessing UMD globals from modules. */
-
-    /* Source Map Options */
-    // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
-    // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
-    // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
-    // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
-
-    /* Experimental Options */
-    // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
-    // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
-
-  },
-  "include": ["./src/**/*"]
-}
diff --git a/tools/release_noter/release_noter.py b/tools/release_noter/release_noter.py
old mode 100644
new mode 100755
index 73c1a05..167f68a
--- a/tools/release_noter/release_noter.py
+++ b/tools/release_noter/release_noter.py
@@ -172,7 +172,6 @@
     )
     doc = Component("Documentation", {"document"})
     jgit = Component("JGit", {"jgit"})
-    elastic = Component("Elasticsearch", {"elastic"})
     deps = Component("Other dependency", {"upgrade", "dependenc"})
     otherwise = Component("Other core", {})
 
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
index 78b86d2..c9a83e4 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -30,8 +30,6 @@
 
 # Set several flags related to specifying the platform, toolchain and java
 # properties.
-build:remote --host_javabase=@rbe_jdk11//java:jdk
-build:remote --javabase=@rbe_jdk11//java:jdk
 build:remote --crosstool_top=@rbe_jdk11//cc:toolchain
 build:remote --extra_toolchains=@rbe_jdk11//config:cc-toolchain
 build:remote --extra_execution_platforms=@rbe_jdk11//config:platform
diff --git a/version.bzl b/version.bzl
index f2e0d0c..569943f 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.5.0-SNAPSHOT"
+GERRIT_VERSION = "3.7.1-SNAPSHOT"
diff --git a/web-dev-server.config.mjs b/web-dev-server.config.mjs
new file mode 100644
index 0000000..2a7dca4
--- /dev/null
+++ b/web-dev-server.config.mjs
@@ -0,0 +1,47 @@
+import { esbuildPlugin } from "@web/dev-server-esbuild";
+import cors from "@koa/cors";
+
+/** @type {import('@web/dev-server').DevServerConfig} */
+export default {
+  port: 8081,
+  plugins: [
+    esbuildPlugin({
+      ts: true,
+      target: "es2020",
+      tsconfig: "polygerrit-ui/app/tsconfig.json",
+    }),
+  ],
+  nodeResolve: true,
+  rootDir: "polygerrit-ui/app",
+  middleware: [
+    // Allow files served from the localhost domain to be used on any domain
+    // (ex: gerrit-review.googlesource.com), which happens during local
+    // development with Gerrit FE Helper extension.
+    cors({ origin: "*" }),
+    // The issue solved here is that our production index.html does not load
+    // 'gr-app.js' as an ESM module due to our build process, but in development
+    // all our source code is written as ESM modules. When using the Gerrit FE
+    // Helper extension to see our local changes on a production site we see a
+    // syntax error due to this mismatch. The trick used to fix this is to
+    // rewrite the response for 'gr-app.js' to be a dynamic import() statement
+    // for a fake file 'gr-app.mjs'. This fake file will be loaded as an ESM
+    // module and when the server receives the request it returns the real
+    // contents of 'gr-app.js'.
+    async (context, next) => {
+      const isGrAppMjs = context.url.includes("gr-app.mjs");
+      if (isGrAppMjs) {
+        // Load the .ts file of the entrypoint instead of .js to trigger esbuild
+        // which will convert every .ts file to .js on request.
+        context.url = context.url.replace("gr-app.mjs", "gr-app.ts");
+      }
+
+      // Pass control to the next middleware which eventually loads the file.
+      // see https://koajs.com/#cascading
+      await next();
+
+      if (!isGrAppMjs && context.url.includes("gr-app.js")) {
+        context.body = "import('./gr-app.mjs')";
+      }
+    },
+  ],
+};
diff --git a/yarn.lock b/yarn.lock
index 3ddfac6..d66270b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,13 +2,6 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@7.12.11":
-  version "7.12.11"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
-  integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==
-  dependencies:
-    "@babel/highlight" "^7.10.4"
-
 "@babel/code-frame@^7.0.0":
   version "7.14.5"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
@@ -16,12 +9,24 @@
   dependencies:
     "@babel/highlight" "^7.14.5"
 
+"@babel/code-frame@^7.12.11":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
+  integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
+  dependencies:
+    "@babel/highlight" "^7.18.6"
+
 "@babel/helper-validator-identifier@^7.14.5":
   version "7.14.9"
   resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48"
   integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==
 
-"@babel/highlight@^7.10.4", "@babel/highlight@^7.14.5":
+"@babel/helper-validator-identifier@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
+  integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
+
+"@babel/highlight@^7.14.5":
   version "7.14.5"
   resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9"
   integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==
@@ -30,6 +35,15 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
+"@babel/highlight@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf"
+  integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.18.6"
+    chalk "^2.0.0"
+    js-tokens "^4.0.0"
+
 "@babel/runtime@^7.10.2":
   version "7.15.4"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a"
@@ -37,54 +51,99 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@bazel/rollup@^3.5.0":
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-3.8.0.tgz#850f56176d73e3b7d99a43c7e33df21ecc6ac161"
-  integrity sha512-u63ubqYtfQhOu8Km3uYdhKa6qiLSlOKYsWwMP1xGkkXzu1hOiUznN1N7q8gCF1BV2DMy1D5IYkv+Xg4a+LEiBA==
-
-"@bazel/terser@^3.5.0":
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-3.8.0.tgz#96d337b62b2ba18e2fe00984cca950cda899d165"
-  integrity sha512-c7cGIltFUI7prRocMDZ3qZVERnew81SFheuI5B9RQ3qeqTlJmlV8B8GI9FPG+7Ut69bmwn8es6UyPaH0iBnsQw==
-
-"@bazel/typescript@^3.5.0":
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-3.8.0.tgz#725d51a1c25e314a1d8cddb8b880ac05ba97acd4"
-  integrity sha512-4C1pLe4V7aidWqcPsWNqXFS7uHAB1nH5SUKG5uWoVv4JT9XhkNSvzzQIycMwXs2tZeCylX4KYNeNvfKrmkyFlw==
+"@bazel/concatjs@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.5.0.tgz#e6104ed70595cae59463ae6b0b5389252566221e"
+  integrity sha512-hwG+ahivR20Z3iTOlkUz3OdwnW/PUaZyyz8BIX+GNUTg6U3rPHK51CavUirMupOU/LRJ5GyCwBNAAtjCyquo2g==
   dependencies:
     protobufjs "6.8.8"
+    source-map-support "0.5.9"
+    tsutils "3.21.0"
+
+"@bazel/rollup@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.5.0.tgz#1e152d6147ef5583ec9fd872756c9d0635db73c7"
+  integrity sha512-8SRbgVfaYdNb6PyIypj8jzzJHhlIRyMH3s5KpXODsjD+mXECH4jQxJ8VcRkt0f0exsgB12gK5dmoUK/F2PDKCw==
+  dependencies:
+    "@bazel/worker" "5.5.0"
+
+"@bazel/terser@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-5.5.0.tgz#3b2b582a417d99d59ae99b50d74576ca0719c03a"
+  integrity sha512-aBjNmJ7TbcD7cKAdFErYQYXn4OqTvrmqrtN6Z6Wnv82d+23kbEsF427ixgdCO3GTQJDw7+x7K9TP2CGogaGtcg==
+
+"@bazel/typescript@^5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.5.0.tgz#053c255acb1b3cac23d24984cd8d5d5542fe1f7c"
+  integrity sha512-Ord0+nCj+B1M4NDbe0uqZf2FyOCzaDAlc4DAsr5UKJrArCipIbMTEAxlsEk+WAYBNAFGO/FS9/zlDtLceqpHqw==
+  dependencies:
+    "@bazel/worker" "5.5.0"
+    protobufjs "6.8.8"
     semver "5.6.0"
     source-map-support "0.5.9"
-    tsutils "2.27.2"
+    tsutils "3.21.0"
 
-"@eslint/eslintrc@^0.4.3":
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c"
-  integrity sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==
+"@bazel/worker@5.5.0":
+  version "5.5.0"
+  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.5.0.tgz#d30b75e46f2052d33bcda251b328d36655a5636f"
+  integrity sha512-pYfjJKg4D1CQ/AJ1UGC5ySyH09gDqNiBrQJ0uMYVewIBW24uOAkKsJfTE2y4ES0UL1Ik758WO0la0mJeFOKScg==
+  dependencies:
+    google-protobuf "^3.6.1"
+
+"@es-joy/jsdoccomment@~0.36.1":
+  version "0.36.1"
+  resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.36.1.tgz#c37db40da36e4b848da5fd427a74bae3b004a30f"
+  integrity sha512-922xqFsTpHs6D0BUiG4toiyPOMc8/jafnWKxz1KWgS4XzKPy2qXf1Pe6UFuNSCQqt6tOuhAWXBNuuyUhJmw9Vg==
+  dependencies:
+    comment-parser "1.3.1"
+    esquery "^1.4.0"
+    jsdoc-type-pratt-parser "~3.1.0"
+
+"@esbuild/linux-loong64@0.14.54":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
+  integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
+
+"@eslint/eslintrc@^1.3.0":
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f"
+  integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw==
   dependencies:
     ajv "^6.12.4"
-    debug "^4.1.1"
-    espree "^7.3.0"
-    globals "^13.9.0"
-    ignore "^4.0.6"
+    debug "^4.3.2"
+    espree "^9.3.2"
+    globals "^13.15.0"
+    ignore "^5.2.0"
     import-fresh "^3.2.1"
-    js-yaml "^3.13.1"
-    minimatch "^3.0.4"
+    js-yaml "^4.1.0"
+    minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
-"@humanwhocodes/config-array@^0.5.0":
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"
-  integrity sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==
+"@humanwhocodes/config-array@^0.9.2":
+  version "0.9.5"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7"
+  integrity sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==
   dependencies:
-    "@humanwhocodes/object-schema" "^1.2.0"
+    "@humanwhocodes/object-schema" "^1.2.1"
     debug "^4.1.1"
     minimatch "^3.0.4"
 
-"@humanwhocodes/object-schema@^1.2.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf"
-  integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==
+"@humanwhocodes/object-schema@^1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
+  integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
+
+"@koa/cors@^3.3.0":
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.3.0.tgz#b4c1c7ee303b7c968c8727f2a638a74675b50bb2"
+  integrity sha512-lzlkqLlL5Ond8jb6JLnVVDmD2OPym0r5kvZlMgAWiS9xle+Q5ulw1T358oW+RVguxUkANquZQz82i/STIRmsqQ==
+  dependencies:
+    vary "^1.1.2"
+
+"@mdn/browser-compat-data@^4.0.0":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8"
+  integrity sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==
 
 "@mrmlnc/readdir-enhanced@^2.2.1":
   version "2.2.1"
@@ -173,6 +232,27 @@
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
 
+"@rollup/plugin-node-resolve@^13.0.4":
+  version "13.3.0"
+  resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c"
+  integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw==
+  dependencies:
+    "@rollup/pluginutils" "^3.1.0"
+    "@types/resolve" "1.17.1"
+    deepmerge "^4.2.2"
+    is-builtin-module "^3.1.0"
+    is-module "^1.0.0"
+    resolve "^1.19.0"
+
+"@rollup/pluginutils@^3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
+  integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
+  dependencies:
+    "@types/estree" "0.0.39"
+    estree-walker "^1.0.1"
+    picomatch "^2.2.2"
+
 "@sindresorhus/is@^0.14.0":
   version "0.14.0"
   resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
@@ -185,25 +265,127 @@
   dependencies:
     defer-to-connect "^1.0.1"
 
-"@types/json-schema@^7.0.7":
-  version "7.0.9"
-  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
-  integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==
+"@types/accepts@*":
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
+  integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/body-parser@*":
+  version "1.19.2"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
+  integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==
+  dependencies:
+    "@types/connect" "*"
+    "@types/node" "*"
+
+"@types/command-line-args@^5.0.0":
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.0.tgz#adbb77980a1cc376bb208e3f4142e907410430f6"
+  integrity sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==
+
+"@types/connect@*":
+  version "3.4.35"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
+  integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/content-disposition@*":
+  version "0.5.5"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.5.tgz#650820e95de346e1f84e30667d168c8fd25aa6e3"
+  integrity sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==
+
+"@types/cookies@*":
+  version "0.7.7"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81"
+  integrity sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==
+  dependencies:
+    "@types/connect" "*"
+    "@types/express" "*"
+    "@types/keygrip" "*"
+    "@types/node" "*"
+
+"@types/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/express-serve-static-core@^4.17.18":
+  version "4.17.30"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.30.tgz#0f2f99617fa8f9696170c46152ccf7500b34ac04"
+  integrity sha512-gstzbTWro2/nFed1WXtf+TtrpwxH7Ggs4RLYTLbeVgIkUQOI3WG/JKjgeOU1zXDvezllupjrf8OPIdvTbIaVOQ==
+  dependencies:
+    "@types/node" "*"
+    "@types/qs" "*"
+    "@types/range-parser" "*"
+
+"@types/express@*":
+  version "4.17.13"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
+  integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
+  dependencies:
+    "@types/body-parser" "*"
+    "@types/express-serve-static-core" "^4.17.18"
+    "@types/qs" "*"
+    "@types/serve-static" "*"
+
+"@types/http-assert@*":
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661"
+  integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
+
+"@types/http-errors@*":
+  version "1.8.2"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1"
+  integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==
+
+"@types/json-schema@^7.0.9":
+  version "7.0.11"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
+  integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
 
 "@types/json5@^0.0.29":
   version "0.0.29"
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
 
+"@types/keygrip@*":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
+  integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==
+
+"@types/koa-compose@*":
+  version "3.2.5"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d"
+  integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==
+  dependencies:
+    "@types/koa" "*"
+
+"@types/koa@*", "@types/koa@^2.11.6":
+  version "2.13.5"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.5.tgz#64b3ca4d54e08c0062e89ec666c9f45443b21a61"
+  integrity sha512-HSUOdzKz3by4fnqagwthW/1w/yJspTgppyyalPVbgZf8jQWvdIXcVW5h2DGtw4zYntOaeRGx49r1hxoPWrD4aA==
+  dependencies:
+    "@types/accepts" "*"
+    "@types/content-disposition" "*"
+    "@types/cookies" "*"
+    "@types/http-assert" "*"
+    "@types/http-errors" "*"
+    "@types/keygrip" "*"
+    "@types/koa-compose" "*"
+    "@types/node" "*"
+
 "@types/long@^4.0.0":
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
   integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
 
-"@types/minimatch@3.0.3":
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
-  integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
+"@types/mime@*":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
+  integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
 
 "@types/minimist@^1.2.0":
   version "1.2.2"
@@ -211,9 +393,9 @@
   integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
 
 "@types/node@*":
-  version "16.9.6"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.6.tgz#040a64d7faf9e5d9e940357125f0963012e66f04"
-  integrity sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ==
+  version "18.7.2"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.2.tgz#22306626110c459aedd2cdf131c749ec781e3b34"
+  integrity sha512-ce7MIiaYWCFv6A83oEultwhBXb22fxwNOQf5DIxWA4WXvDQ7K+L0fbWl/YOfCzlR5B/uFkSnVBhPcOfOECcWvA==
 
 "@types/node@^10.1.0":
   version "10.17.60"
@@ -225,84 +407,227 @@
   resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
   integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
 
-"@typescript-eslint/eslint-plugin@^4.2.0", "@typescript-eslint/eslint-plugin@^4.29.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.30.0.tgz#4a0c1ae96b953f4e67435e20248d812bfa55e4fb"
-  integrity sha512-NgAnqk55RQ/SD+tZFD9aPwNSeHmDHHe5rtUyhIq0ZeCWZEvo4DK9rYz7v9HDuQZFvn320Ot+AikaCKMFKLlD0g==
+"@types/page@^1.11.5":
+  version "1.11.5"
+  resolved "https://registry.yarnpkg.com/@types/page/-/page-1.11.5.tgz#7e60f8c78a05f5b0c26a0b4334647490e074de68"
+  integrity sha512-v0uoRBrJOnYWI/HcqmhLFbkWPW6tF183FdorVLYsep+HKxW1vFT/G+yaUymvS26uL8NPKj8hit4QEtphGDTwxA==
+
+"@types/parse5@^6.0.1":
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb"
+  integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
+
+"@types/qs@*":
+  version "6.9.7"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
+  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
+
+"@types/range-parser@*":
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
+  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
+
+"@types/resolve@1.17.1":
+  version "1.17.1"
+  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
+  integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
   dependencies:
-    "@typescript-eslint/experimental-utils" "4.30.0"
-    "@typescript-eslint/scope-manager" "4.30.0"
-    debug "^4.3.1"
+    "@types/node" "*"
+
+"@types/serve-static@*":
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155"
+  integrity sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==
+  dependencies:
+    "@types/mime" "*"
+    "@types/node" "*"
+
+"@types/ws@^7.4.0":
+  version "7.4.7"
+  resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702"
+  integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==
+  dependencies:
+    "@types/node" "*"
+
+"@typescript-eslint/eslint-plugin@^4.2.0", "@typescript-eslint/eslint-plugin@^5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.27.0.tgz#23d82a4f21aaafd8f69dbab7e716323bb6695cc8"
+  integrity sha512-DDrIA7GXtmHXr1VCcx9HivA39eprYBIFxbQEHI6NyraRDxCGpxAFiYQAT/1Y0vh1C+o2vfBiy4IuPoXxtTZCAQ==
+  dependencies:
+    "@typescript-eslint/scope-manager" "5.27.0"
+    "@typescript-eslint/type-utils" "5.27.0"
+    "@typescript-eslint/utils" "5.27.0"
+    debug "^4.3.4"
     functional-red-black-tree "^1.0.1"
-    regexpp "^3.1.0"
-    semver "^7.3.5"
+    ignore "^5.2.0"
+    regexpp "^3.2.0"
+    semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@typescript-eslint/experimental-utils@4.30.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.30.0.tgz#9e49704fef568432ae16fc0d6685c13d67db0fd5"
-  integrity sha512-K8RNIX9GnBsv5v4TjtwkKtqMSzYpjqAQg/oSphtxf3xxdt6T0owqnpojztjjTcatSteH3hLj3t/kklKx87NPqw==
+"@typescript-eslint/parser@^4.2.0", "@typescript-eslint/parser@^5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.27.0.tgz#62bb091ed5cf9c7e126e80021bb563dcf36b6b12"
+  integrity sha512-8oGjQF46c52l7fMiPPvX4It3u3V3JipssqDfHQ2hcR0AeR8Zge+OYyKUCm5b70X72N1qXt0qgHenwN6Gc2SXZA==
   dependencies:
-    "@types/json-schema" "^7.0.7"
-    "@typescript-eslint/scope-manager" "4.30.0"
-    "@typescript-eslint/types" "4.30.0"
-    "@typescript-eslint/typescript-estree" "4.30.0"
+    "@typescript-eslint/scope-manager" "5.27.0"
+    "@typescript-eslint/types" "5.27.0"
+    "@typescript-eslint/typescript-estree" "5.27.0"
+    debug "^4.3.4"
+
+"@typescript-eslint/scope-manager@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.27.0.tgz#a272178f613050ed62f51f69aae1e19e870a8bbb"
+  integrity sha512-VnykheBQ/sHd1Vt0LJ1JLrMH1GzHO+SzX6VTXuStISIsvRiurue/eRkTqSrG0CexHQgKG8shyJfR4o5VYioB9g==
+  dependencies:
+    "@typescript-eslint/types" "5.27.0"
+    "@typescript-eslint/visitor-keys" "5.27.0"
+
+"@typescript-eslint/type-utils@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.27.0.tgz#36fd95f6747412251d79c795b586ba766cf0974b"
+  integrity sha512-vpTvRRchaf628Hb/Xzfek+85o//zEUotr1SmexKvTfs7czXfYjXVT/a5yDbpzLBX1rhbqxjDdr1Gyo0x1Fc64g==
+  dependencies:
+    "@typescript-eslint/utils" "5.27.0"
+    debug "^4.3.4"
+    tsutils "^3.21.0"
+
+"@typescript-eslint/types@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.27.0.tgz#c3f44b9dda6177a9554f94a74745ca495ba9c001"
+  integrity sha512-lY6C7oGm9a/GWhmUDOs3xAVRz4ty/XKlQ2fOLr8GAIryGn0+UBOoJDWyHer3UgrHkenorwvBnphhP+zPmzmw0A==
+
+"@typescript-eslint/typescript-estree@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.27.0.tgz#7965f5b553c634c5354a47dcce0b40b94611e995"
+  integrity sha512-QywPMFvgZ+MHSLRofLI7BDL+UczFFHyj0vF5ibeChDAJgdTV8k4xgEwF0geFhVlPc1p8r70eYewzpo6ps+9LJQ==
+  dependencies:
+    "@typescript-eslint/types" "5.27.0"
+    "@typescript-eslint/visitor-keys" "5.27.0"
+    debug "^4.3.4"
+    globby "^11.1.0"
+    is-glob "^4.0.3"
+    semver "^7.3.7"
+    tsutils "^3.21.0"
+
+"@typescript-eslint/utils@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.27.0.tgz#d0021cbf686467a6a9499bd0589e19665f9f7e71"
+  integrity sha512-nZvCrkIJppym7cIbP3pOwIkAefXOmfGPnCM0LQfzNaKxJHI6VjI8NC662uoiPlaf5f6ymkTy9C3NQXev2mdXmA==
+  dependencies:
+    "@types/json-schema" "^7.0.9"
+    "@typescript-eslint/scope-manager" "5.27.0"
+    "@typescript-eslint/types" "5.27.0"
+    "@typescript-eslint/typescript-estree" "5.27.0"
     eslint-scope "^5.1.1"
     eslint-utils "^3.0.0"
 
-"@typescript-eslint/parser@^4.2.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.30.0.tgz#6abd720f66bd790f3e0e80c3be77180c8fcb192d"
-  integrity sha512-HJ0XuluSZSxeboLU7Q2VQ6eLlCwXPBOGnA7CqgBnz2Db3JRQYyBDJgQnop6TZ+rsbSx5gEdWhw4rE4mDa1FnZg==
+"@typescript-eslint/visitor-keys@5.27.0":
+  version "5.27.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.27.0.tgz#97aa9a5d2f3df8215e6d3b77f9d214a24db269bd"
+  integrity sha512-46cYrteA2MrIAjv9ai44OQDUoCZyHeGIc4lsjCUX2WT6r4C+kidz1bNiR4017wHOPUythYeH+Sc7/cFP97KEAA==
   dependencies:
-    "@typescript-eslint/scope-manager" "4.30.0"
-    "@typescript-eslint/types" "4.30.0"
-    "@typescript-eslint/typescript-estree" "4.30.0"
-    debug "^4.3.1"
+    "@typescript-eslint/types" "5.27.0"
+    eslint-visitor-keys "^3.3.0"
 
-"@typescript-eslint/scope-manager@4.30.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.30.0.tgz#1a3ffbb385b1a06be85cd5165a22324f069a85ee"
-  integrity sha512-VJ/jAXovxNh7rIXCQbYhkyV2Y3Ac/0cVHP/FruTJSAUUm4Oacmn/nkN5zfWmWFEanN4ggP0vJSHOeajtHq3f8A==
+"@web/config-loader@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@web/config-loader/-/config-loader-0.1.3.tgz#8325ea54f75ef2ee7166783e64e66936db25bff7"
+  integrity sha512-XVKH79pk4d3EHRhofete8eAnqto1e8mCRAqPV00KLNFzCWSe8sWmLnqKCqkPNARC6nksMaGrATnA5sPDRllMpQ==
   dependencies:
-    "@typescript-eslint/types" "4.30.0"
-    "@typescript-eslint/visitor-keys" "4.30.0"
+    semver "^7.3.4"
 
-"@typescript-eslint/types@4.30.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.30.0.tgz#fb9d9b0358426f18687fba82eb0b0f869780204f"
-  integrity sha512-YKldqbNU9K4WpTNwBqtAerQKLLW/X2A/j4yw92e3ZJYLx+BpKLeheyzoPfzIXHfM8BXfoleTdiYwpsvVPvHrDw==
-
-"@typescript-eslint/typescript-estree@4.30.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.30.0.tgz#ae57833da72a753f4846cd3053758c771670c2ac"
-  integrity sha512-6WN7UFYvykr/U0Qgy4kz48iGPWILvYL34xXJxvDQeiRE018B7POspNRVtAZscWntEPZpFCx4hcz/XBT+erenfg==
+"@web/dev-server-core@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.19.tgz#b61f9a0b92351371347a758b30ba19e683c72e94"
+  integrity sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==
   dependencies:
-    "@typescript-eslint/types" "4.30.0"
-    "@typescript-eslint/visitor-keys" "4.30.0"
-    debug "^4.3.1"
-    globby "^11.0.3"
-    is-glob "^4.0.1"
-    semver "^7.3.5"
-    tsutils "^3.21.0"
+    "@types/koa" "^2.11.6"
+    "@types/ws" "^7.4.0"
+    "@web/parse5-utils" "^1.2.0"
+    chokidar "^3.4.3"
+    clone "^2.1.2"
+    es-module-lexer "^1.0.0"
+    get-stream "^6.0.0"
+    is-stream "^2.0.0"
+    isbinaryfile "^4.0.6"
+    koa "^2.13.0"
+    koa-etag "^4.0.0"
+    koa-send "^5.0.1"
+    koa-static "^5.0.0"
+    lru-cache "^6.0.0"
+    mime-types "^2.1.27"
+    parse5 "^6.0.1"
+    picomatch "^2.2.2"
+    ws "^7.4.2"
 
-"@typescript-eslint/visitor-keys@4.30.0":
-  version "4.30.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.30.0.tgz#a47c6272fc71b0c627d1691f68eaecf4ad71445e"
-  integrity sha512-pNaaxDt/Ol/+JZwzP7MqWc8PJQTUhZwoee/PVlQ+iYoYhagccvoHnC9e4l+C/krQYYkENxznhVSDwClIbZVxRw==
+"@web/dev-server-esbuild@^0.3.2":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.2.tgz#d4f43c1677123021f6c5805beaac902318f7e083"
+  integrity sha512-Jn9b+Rs1ck4QN+ksue6qFdvUc2r/+NHpMW0R86W4Kqw5WjE7dT44pCGkKNfB8Fph4dNi0MgDaMhIkW2fcSpogA==
   dependencies:
-    "@typescript-eslint/types" "4.30.0"
-    eslint-visitor-keys "^2.0.0"
+    "@mdn/browser-compat-data" "^4.0.0"
+    "@web/dev-server-core" "^0.3.19"
+    esbuild "^0.12 || ^0.13 || ^0.14"
+    parse5 "^6.0.1"
+    ua-parser-js "^1.0.2"
 
-acorn-jsx@^5.3.1:
+"@web/dev-server-rollup@^0.3.19":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.3.19.tgz#188f3a37bcc38f4dc1b208663b14ab2d17321a57"
+  integrity sha512-IwiwI+fyX0YuvAOldStlYJ+Zm/JfSCk9OSGIs7+fWbOYysEHwkEVvBwoPowaclSZA44Tobvqt+6ej9udbbZ/WQ==
+  dependencies:
+    "@rollup/plugin-node-resolve" "^13.0.4"
+    "@web/dev-server-core" "^0.3.19"
+    nanocolors "^0.2.1"
+    parse5 "^6.0.1"
+    rollup "^2.67.0"
+    whatwg-url "^11.0.0"
+
+"@web/dev-server@^0.1.33":
+  version "0.1.33"
+  resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.33.tgz#0f0723257823b6f51fc7e02704549162128acd1e"
+  integrity sha512-ge8fL6TbeUeDxfbiB0EiZl+KE+EjEc9gAur0OxOQvNKUZFOkqWn1s2X/6AuaN+aQnUAeUebvChzyDuQwtBuKWg==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/command-line-args" "^5.0.0"
+    "@web/config-loader" "^0.1.3"
+    "@web/dev-server-core" "^0.3.19"
+    "@web/dev-server-rollup" "^0.3.19"
+    camelcase "^6.2.0"
+    command-line-args "^5.1.1"
+    command-line-usage "^6.1.1"
+    debounce "^1.2.0"
+    deepmerge "^4.2.2"
+    ip "^1.1.5"
+    nanocolors "^0.2.1"
+    open "^8.0.2"
+    portfinder "^1.0.28"
+
+"@web/parse5-utils@^1.2.0":
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.0.tgz#e2e9e98b31a4ca948309f74891bda8d77399f6bd"
+  integrity sha512-Pgkx3ECc8EgXSlS5EyrgzSOoUbM6P8OKS471HLAyvOBcP1NCBn0to4RN/OaKASGq8qa3j+lPX9H14uA5AHEnQg==
+  dependencies:
+    "@types/parse5" "^6.0.1"
+    parse5 "^6.0.1"
+
+accepts@^1.3.5:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
+  dependencies:
+    mime-types "~2.1.34"
+    negotiator "0.6.3"
+
+acorn-jsx@^5.3.2:
   version "5.3.2"
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
-acorn@^7.4.0:
-  version "7.4.1"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
-  integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
+acorn@^8.7.1:
+  version "8.7.1"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30"
+  integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==
 
 ajv@^6.10.0, ajv@^6.12.4:
   version "6.12.6"
@@ -314,16 +639,6 @@
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
-ajv@^8.0.1:
-  version "8.6.2"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.2.tgz#2fb45e0e5fcbc0813326c1c3da535d1881bb0571"
-  integrity sha512-9807RlWAgT564wT+DjeyU5OFMPjmzxVobvDFmNAhY+5zD6A2ly3jDp6sgnfyDtlIQ+7H97oc/DGCzzfu9rjw9w==
-  dependencies:
-    fast-deep-equal "^3.1.1"
-    json-schema-traverse "^1.0.0"
-    require-from-string "^2.0.2"
-    uri-js "^4.2.2"
-
 ansi-align@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb"
@@ -331,11 +646,6 @@
   dependencies:
     string-width "^3.0.0"
 
-ansi-colors@^4.1.1:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
-  integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
-
 ansi-escapes@^4.2.1:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
@@ -353,6 +663,11 @@
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
   integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
 
+ansi-regex@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
 ansi-styles@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@@ -367,12 +682,18 @@
   dependencies:
     color-convert "^2.0.1"
 
-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==
+anymatch@~3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
+  integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
   dependencies:
-    sprintf-js "~1.0.2"
+    normalize-path "^3.0.0"
+    picomatch "^2.0.4"
+
+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==
 
 arr-diff@^4.0.0:
   version "4.0.0"
@@ -389,16 +710,26 @@
   resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
   integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
 
-array-includes@^3.1.3:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a"
-  integrity sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==
+array-back@^3.0.1, array-back@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
+  integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+
+array-back@^4.0.1, array-back@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
+  integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
+
+array-includes@^3.1.4:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.5.tgz#2c320010db8d31031fd2a5f6b3bbd4b1aad31bdb"
+  integrity sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.3"
-    es-abstract "^1.18.0-next.2"
+    define-properties "^1.1.4"
+    es-abstract "^1.19.5"
     get-intrinsic "^1.1.1"
-    is-string "^1.0.5"
+    is-string "^1.0.7"
 
 array-union@^2.1.0:
   version "2.1.0"
@@ -410,14 +741,15 @@
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
   integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
 
-array.prototype.flat@^1.2.4:
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123"
-  integrity sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==
+array.prototype.flat@^1.2.5:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz#0b0c1567bf57b38b56b4c97b8aa72ab45e4adc7b"
+  integrity sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==
   dependencies:
-    call-bind "^1.0.0"
+    call-bind "^1.0.2"
     define-properties "^1.1.3"
-    es-abstract "^1.18.0-next.1"
+    es-abstract "^1.19.2"
+    es-shim-unscopables "^1.0.0"
 
 arrify@^1.0.1:
   version "1.0.1"
@@ -429,10 +761,12 @@
   resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
   integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
 
-astral-regex@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
-  integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
+async@^2.6.4:
+  version "2.6.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+  integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
+  dependencies:
+    lodash "^4.17.14"
 
 atob@^2.1.2:
   version "2.1.2"
@@ -457,10 +791,10 @@
     mixin-deep "^1.2.0"
     pascalcase "^0.1.1"
 
-boolbase@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
-  integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
+binary-extensions@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
+  integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
 boxen@^5.0.0:
   version "5.0.1"
@@ -476,7 +810,7 @@
     widest-line "^3.1.0"
     wrap-ansi "^7.0.0"
 
-brace-expansion@^1.0.0, brace-expansion@^1.1.7:
+brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
   integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
@@ -500,7 +834,7 @@
     split-string "^3.0.2"
     to-regex "^3.0.1"
 
-braces@^3.0.1:
+braces@^3.0.1, braces@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
   integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@@ -512,6 +846,11 @@
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
   integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
+builtin-modules@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
+  integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
+
 cache-base@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
@@ -527,6 +866,14 @@
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
+cache-content-type@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
+  integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==
+  dependencies:
+    mime-types "^2.1.18"
+    ylru "^1.2.0"
+
 cacheable-request@^6.0.0:
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912"
@@ -577,7 +924,7 @@
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
   integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
 
-chalk@^2.0.0, chalk@^2.4.2:
+chalk@^2.0.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==
@@ -599,17 +946,20 @@
   resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
   integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
 
-cheerio@1.0.0-rc.2:
-  version "1.0.0-rc.2"
-  resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db"
-  integrity sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=
+chokidar@^3.4.3:
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
+  integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
   dependencies:
-    css-select "~1.2.0"
-    dom-serializer "~0.1.0"
-    entities "~1.1.1"
-    htmlparser2 "^3.9.1"
-    lodash "^4.15.0"
-    parse5 "^3.0.1"
+    anymatch "~3.1.2"
+    braces "~3.0.2"
+    glob-parent "~5.1.2"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.6.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
 
 ci-info@^2.0.0:
   version "2.0.0"
@@ -659,6 +1009,16 @@
   dependencies:
     mimic-response "^1.0.0"
 
+clone@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
+  integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
+
+co@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
+  integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==
+
 collection-visit@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
@@ -691,15 +1051,35 @@
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
+command-line-args@^5.1.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+  integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
+  dependencies:
+    array-back "^3.1.0"
+    find-replace "^3.0.0"
+    lodash.camelcase "^4.3.0"
+    typical "^4.0.0"
+
+command-line-usage@^6.1.1:
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.3.tgz#428fa5acde6a838779dfa30e44686f4b6761d957"
+  integrity sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==
+  dependencies:
+    array-back "^4.0.2"
+    chalk "^2.4.2"
+    table-layout "^1.0.2"
+    typical "^5.2.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==
 
-comment-parser@1.1.5:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.1.5.tgz#453627ef8f67dbcec44e79a9bd5baa37f0bce9b2"
-  integrity sha512-RePCE4leIhBlmrqiYTvaqEeGYg7qpSl4etaIabKtdOQVi+mSTIBBklGUwIr79GXYnl3LpMwmDw4KeR2stNc6FA==
+comment-parser@1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.3.1.tgz#3d7ea3adaf9345594aedee6563f422348f165c1b"
+  integrity sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==
 
 component-emitter@^1.2.1:
   version "1.3.0"
@@ -723,11 +1103,42 @@
     write-file-atomic "^3.0.0"
     xdg-basedir "^4.0.0"
 
+content-disposition@~0.5.2:
+  version "0.5.4"
+  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
+  integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
+  dependencies:
+    safe-buffer "5.2.1"
+
+content-type@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
+  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+
+cookies@~0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
+  integrity sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==
+  dependencies:
+    depd "~2.0.0"
+    keygrip "~1.1.0"
+
 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=
 
+cross-spawn@^6.0.5:
+  version "6.0.5"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
+  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
+  dependencies:
+    nice-try "^1.0.4"
+    path-key "^2.0.1"
+    semver "^5.5.0"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
 cross-spawn@^7.0.2, cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -742,20 +1153,10 @@
   resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
   integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
 
-css-select@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858"
-  integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=
-  dependencies:
-    boolbase "~1.0.0"
-    css-what "2.1"
-    domutils "1.5.1"
-    nth-check "~1.0.1"
-
-css-what@2.1:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
-  integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
+debounce@^1.2.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
+  integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
 
 debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
   version "2.6.9"
@@ -764,20 +1165,27 @@
   dependencies:
     ms "2.0.0"
 
-debug@^3.2.7:
+debug@^3.1.0, debug@^3.2.7:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
   integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
   dependencies:
     ms "^2.1.1"
 
-debug@^4.0.1, debug@^4.1.1, debug@^4.3.1:
+debug@^4.1.1:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
   integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
   dependencies:
     ms "2.1.2"
 
+debug@^4.3.2, debug@^4.3.4:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
+  dependencies:
+    ms "2.1.2"
+
 decamelize-keys@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9"
@@ -803,7 +1211,12 @@
   dependencies:
     mimic-response "^1.0.0"
 
-deep-extend@^0.6.0:
+deep-equal@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
+  integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==
+
+deep-extend@^0.6.0, deep-extend@~0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
   integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
@@ -813,11 +1226,21 @@
   resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
   integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
 
+deepmerge@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
+  integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
+
 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"
   integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==
 
+define-lazy-prop@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
+  integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
+
 define-properties@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
@@ -825,6 +1248,14 @@
   dependencies:
     object-keys "^1.0.12"
 
+define-properties@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1"
+  integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==
+  dependencies:
+    has-property-descriptors "^1.0.0"
+    object-keys "^1.1.1"
+
 define-property@^0.2.5:
   version "0.2.5"
   resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116"
@@ -847,6 +1278,26 @@
     is-descriptor "^1.0.2"
     isobject "^3.0.1"
 
+delegates@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
+
+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 sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
+
+destroy@^1.0.4:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
 didyoumean2@4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/didyoumean2/-/didyoumean2-4.1.0.tgz#f813cb7c82c249443e599be077f76e88f24b85e4"
@@ -877,14 +1328,6 @@
   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==
-  dependencies:
-    domelementtype "^2.0.1"
-    entities "^2.0.0"
-
 dom-serializer@^1.0.1:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91"
@@ -894,55 +1337,26 @@
     domhandler "^4.2.0"
     entities "^2.0.0"
 
-dom-serializer@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0"
-  integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==
-  dependencies:
-    domelementtype "^1.3.0"
-    entities "^1.1.1"
-
-domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
-  integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
-
 domelementtype@^2.0.1, domelementtype@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57"
   integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
 
-domhandler@^2.3.0:
-  version "2.4.2"
-  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803"
-  integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==
-  dependencies:
-    domelementtype "1"
-
-domhandler@^4.0.0, domhandler@^4.2.0:
+domhandler@^4.2.0:
   version "4.2.2"
   resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.2.tgz#e825d721d19a86b8c201a35264e226c678ee755f"
   integrity sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w==
   dependencies:
     domelementtype "^2.2.0"
 
-domutils@1.5.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
-  integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=
+domhandler@^4.2.2:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c"
+  integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==
   dependencies:
-    dom-serializer "0"
-    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==
-  dependencies:
-    dom-serializer "0"
-    domelementtype "1"
-
-domutils@^2.5.2:
+domutils@^2.8.0:
   version "2.8.0"
   resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135"
   integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==
@@ -963,6 +1377,11 @@
   resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
   integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
 
+ee-first@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+  integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
+
 emoji-regex@^7.0.1:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
@@ -973,6 +1392,11 @@
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
   integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
 
+encodeurl@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
+
 end-of-stream@^1.1.0:
   version "1.4.4"
   resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
@@ -980,23 +1404,16 @@
   dependencies:
     once "^1.4.0"
 
-enquirer@^2.3.5:
-  version "2.3.6"
-  resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d"
-  integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==
-  dependencies:
-    ansi-colors "^4.1.1"
-
-entities@^1.1.1, entities@~1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
-  integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
-
 entities@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
   integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
 
+entities@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4"
+  integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==
+
 error-ex@^1.3.1:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
@@ -1004,22 +1421,54 @@
   dependencies:
     is-arrayish "^0.2.1"
 
-es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2, es-abstract@^1.18.2:
-  version "1.18.5"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.5.tgz#9b10de7d4c206a3581fd5b2124233e04db49ae19"
-  integrity sha512-DDggyJLoS91CkJjgauM5c0yZMjiD1uK3KcaCeAmffGwZ+ODWzOkPN4QwRbsK5DOFf06fywmyLci3ZD8jLGhVYA==
+es-abstract@^1.19.0, es-abstract@^1.19.2, es-abstract@^1.19.5:
+  version "1.20.1"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814"
+  integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA==
+  dependencies:
+    call-bind "^1.0.2"
+    es-to-primitive "^1.2.1"
+    function-bind "^1.1.1"
+    function.prototype.name "^1.1.5"
+    get-intrinsic "^1.1.1"
+    get-symbol-description "^1.0.0"
+    has "^1.0.3"
+    has-property-descriptors "^1.0.0"
+    has-symbols "^1.0.3"
+    internal-slot "^1.0.3"
+    is-callable "^1.2.4"
+    is-negative-zero "^2.0.2"
+    is-regex "^1.1.4"
+    is-shared-array-buffer "^1.0.2"
+    is-string "^1.0.7"
+    is-weakref "^1.0.2"
+    object-inspect "^1.12.0"
+    object-keys "^1.1.1"
+    object.assign "^4.1.2"
+    regexp.prototype.flags "^1.4.3"
+    string.prototype.trimend "^1.0.5"
+    string.prototype.trimstart "^1.0.5"
+    unbox-primitive "^1.0.2"
+
+es-abstract@^1.19.1:
+  version "1.19.1"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3"
+  integrity sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==
   dependencies:
     call-bind "^1.0.2"
     es-to-primitive "^1.2.1"
     function-bind "^1.1.1"
     get-intrinsic "^1.1.1"
+    get-symbol-description "^1.0.0"
     has "^1.0.3"
     has-symbols "^1.0.2"
     internal-slot "^1.0.3"
-    is-callable "^1.2.3"
+    is-callable "^1.2.4"
     is-negative-zero "^2.0.1"
-    is-regex "^1.1.3"
-    is-string "^1.0.6"
+    is-regex "^1.1.4"
+    is-shared-array-buffer "^1.0.1"
+    is-string "^1.0.7"
+    is-weakref "^1.0.1"
     object-inspect "^1.11.0"
     object-keys "^1.1.1"
     object.assign "^4.1.2"
@@ -1027,6 +1476,18 @@
     string.prototype.trimstart "^1.0.4"
     unbox-primitive "^1.0.1"
 
+es-module-lexer@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.0.3.tgz#f0d8d35b36d13024110000d5e6fadc8eeaeb66b8"
+  integrity sha512-iC67eXHToclrlVhQfpRawDiF8D8sQxNxmbqw5oebegOaJkyx/w9C/k57/5e6yJR2zIByRt9OXdqX50DV2t6ZKw==
+
+es-shim-unscopables@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241"
+  integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==
+  dependencies:
+    has "^1.0.3"
+
 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"
@@ -1036,11 +1497,143 @@
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
+esbuild-android-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
+  integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
+
+esbuild-android-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
+  integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
+
+esbuild-darwin-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
+  integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
+
+esbuild-darwin-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
+  integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
+
+esbuild-freebsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
+  integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
+
+esbuild-freebsd-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
+  integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
+
+esbuild-linux-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
+  integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
+
+esbuild-linux-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652"
+  integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
+
+esbuild-linux-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
+  integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
+
+esbuild-linux-arm@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
+  integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
+
+esbuild-linux-mips64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
+  integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
+
+esbuild-linux-ppc64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
+  integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
+
+esbuild-linux-riscv64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
+  integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
+
+esbuild-linux-s390x@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
+  integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
+
+esbuild-netbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
+  integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
+
+esbuild-openbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
+  integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
+
+esbuild-sunos-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
+  integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
+
+esbuild-windows-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
+  integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
+
+esbuild-windows-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
+  integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
+
+esbuild-windows-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
+  integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
+
+"esbuild@^0.12 || ^0.13 || ^0.14":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
+  integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
+  optionalDependencies:
+    "@esbuild/linux-loong64" "0.14.54"
+    esbuild-android-64 "0.14.54"
+    esbuild-android-arm64 "0.14.54"
+    esbuild-darwin-64 "0.14.54"
+    esbuild-darwin-arm64 "0.14.54"
+    esbuild-freebsd-64 "0.14.54"
+    esbuild-freebsd-arm64 "0.14.54"
+    esbuild-linux-32 "0.14.54"
+    esbuild-linux-64 "0.14.54"
+    esbuild-linux-arm "0.14.54"
+    esbuild-linux-arm64 "0.14.54"
+    esbuild-linux-mips64le "0.14.54"
+    esbuild-linux-ppc64le "0.14.54"
+    esbuild-linux-riscv64 "0.14.54"
+    esbuild-linux-s390x "0.14.54"
+    esbuild-netbsd-64 "0.14.54"
+    esbuild-openbsd-64 "0.14.54"
+    esbuild-sunos-64 "0.14.54"
+    esbuild-windows-32 "0.14.54"
+    esbuild-windows-64 "0.14.54"
+    esbuild-windows-arm64 "0.14.54"
+
 escape-goat@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
   integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
 
+escape-html@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+  integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
+
 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"
@@ -1069,13 +1662,13 @@
     debug "^3.2.7"
     resolve "^1.20.0"
 
-eslint-module-utils@^2.6.2:
-  version "2.6.2"
-  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.2.tgz#94e5540dd15fe1522e8ffa3ec8db3b7fa7e7a534"
-  integrity sha512-QG8pcgThYOuqxupd06oYTZoNOGaUdTY1PqK+oS6ElF6vs4pBdk/aYxFVQQXzcrAqp9m7cl7lb2ubazX+g16k2Q==
+eslint-module-utils@^2.7.3:
+  version "2.7.3"
+  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz#ad7e3a10552fdd0642e1e55292781bd6e34876ee"
+  integrity sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==
   dependencies:
     debug "^3.2.7"
-    pkg-dir "^2.0.0"
+    find-up "^2.1.0"
 
 eslint-plugin-es@^3.0.0:
   version "3.0.1"
@@ -1085,51 +1678,49 @@
     eslint-utils "^2.0.0"
     regexpp "^3.0.0"
 
-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==
+eslint-plugin-html@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-6.2.0.tgz#715bc00b50bbd0d996e28f953c289a5ebec69d43"
+  integrity sha512-vi3NW0E8AJombTvt8beMwkL1R/fdRWl4QSNRNMhVQKWm36/X0KF0unGNAY4mqUF06mnwVWZcIcerrCnfn9025g==
   dependencies:
-    htmlparser2 "^6.0.1"
+    htmlparser2 "^7.1.2"
 
-eslint-plugin-import@^2.22.1:
-  version "2.24.2"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.24.2.tgz#2c8cd2e341f3885918ee27d18479910ade7bb4da"
-  integrity sha512-hNVtyhiEtZmpsabL4neEj+6M5DCLgpYyG9nzJY8lZQeQXEn5UPW1DpUdsMHMXsq98dbNm7nt1w9ZMSVpfJdi8Q==
+eslint-plugin-import@^2.26.0:
+  version "2.26.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz#f812dc47be4f2b72b478a021605a59fc6fe8b88b"
+  integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==
   dependencies:
-    array-includes "^3.1.3"
-    array.prototype.flat "^1.2.4"
+    array-includes "^3.1.4"
+    array.prototype.flat "^1.2.5"
     debug "^2.6.9"
     doctrine "^2.1.0"
     eslint-import-resolver-node "^0.3.6"
-    eslint-module-utils "^2.6.2"
-    find-up "^2.0.0"
+    eslint-module-utils "^2.7.3"
     has "^1.0.3"
-    is-core-module "^2.6.0"
-    minimatch "^3.0.4"
-    object.values "^1.1.4"
-    pkg-up "^2.0.0"
-    read-pkg-up "^3.0.0"
-    resolve "^1.20.0"
-    tsconfig-paths "^3.11.0"
+    is-core-module "^2.8.1"
+    is-glob "^4.0.3"
+    minimatch "^3.1.2"
+    object.values "^1.1.5"
+    resolve "^1.22.0"
+    tsconfig-paths "^3.14.1"
 
-eslint-plugin-jsdoc@^32.3.0:
-  version "32.3.4"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-32.3.4.tgz#6888f3b2dbb9f73fb551458c639a4e8c84fe9ddc"
-  integrity sha512-xSWfsYvffXnN0OkwLnB7MoDDDDjqcp46W7YlY1j7JyfAQBQ+WnGCfLov3gVNZjUGtK9Otj8mEhTZTqJu4QtIGA==
+eslint-plugin-jsdoc@^39.6.4:
+  version "39.6.4"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-39.6.4.tgz#b940aebd3eea26884a0d341785d2dc3aba6a38a7"
+  integrity sha512-fskvdLCfwmPjHb6e+xNGDtGgbF8X7cDwMtVLAP2WwSf9Htrx68OAx31BESBM1FAwsN2HTQyYQq7m4aW4Q4Nlag==
   dependencies:
-    comment-parser "1.1.5"
-    debug "^4.3.1"
-    jsdoctypeparser "^9.0.0"
-    lodash "^4.17.21"
-    regextras "^0.7.1"
-    semver "^7.3.5"
+    "@es-joy/jsdoccomment" "~0.36.1"
+    comment-parser "1.3.1"
+    debug "^4.3.4"
+    escape-string-regexp "^4.0.0"
+    esquery "^1.4.0"
+    semver "^7.3.8"
     spdx-expression-parse "^3.0.1"
 
-eslint-plugin-lit@^1.5.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.5.1.tgz#e5b86fee4aeb6023ad4bb90b3d9e462ca8eff755"
-  integrity sha512-pYB0QM11uyOk5L55QfGhBmWi8a56PkNsnx+zVpY4bxz9YVquEo4BeRnFmf9AwFyT89rhGud9QruFhM2xJ4piwg==
+eslint-plugin-lit@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.6.1.tgz#e1f51fe9e580d4095b58cc4bc4dc6b44409af6b0"
+  integrity sha512-BpPoWVhf8dQ/Sz5Pi9NlqbGoH5BcMcVyXhi2XTx2XGMAO9U2lS+GTSsqJjI5hL3OuxCicNiUEWXazAwi9cAGxQ==
   dependencies:
     parse5 "^6.0.1"
     parse5-htmlparser2-tree-adapter "^6.0.1"
@@ -1147,17 +1738,24 @@
     resolve "^1.10.1"
     semver "^6.1.0"
 
-eslint-plugin-prettier@^3.1.4, eslint-plugin-prettier@^3.4.0:
+eslint-plugin-prettier@^3.1.4:
   version "3.4.1"
   resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.4.1.tgz#e9ddb200efb6f3d05ffe83b1665a716af4a387e5"
   integrity sha512-htg25EUYUeIhKHXjOinK4BgCcDwtLHjqaxCDsMy5nbnUMkKFvIhMVCp+5GFUXQ4Nr8lBsPqtGAqBenbpFqAA2g==
   dependencies:
     prettier-linter-helpers "^1.0.0"
 
-eslint-plugin-regex@^1.8.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-regex/-/eslint-plugin-regex-1.8.0.tgz#4bd111cf5235fb76a4a7f77d7ffcb7b3777b8a77"
-  integrity sha512-rmzVvpoxHKgvcYDo9d1X9RMFOtyOV3A6taD3KWE6gIID2dHoc8RPd0YAjDSJ0LG35wnehQBfsNB+F7q4eYqXqw==
+eslint-plugin-prettier@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.0.0.tgz#8b99d1e4b8b24a762472b4567992023619cb98e0"
+  integrity sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==
+  dependencies:
+    prettier-linter-helpers "^1.0.0"
+
+eslint-plugin-regex@^1.9.0:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-regex/-/eslint-plugin-regex-1.9.0.tgz#4eb4f903edeeec3d641a7fcc10ad4d5209cb783d"
+  integrity sha512-T7/Rn6qp/Wp9VlLraimUvGW81wkJ661wFicHyHrm4iSJfl33yyUFJEwknYjFjrUTXAsYA6wCCvPpBss/gOlVNA==
 
 eslint-scope@^5.1.1:
   version "5.1.1"
@@ -1167,7 +1765,15 @@
     esrecurse "^4.3.0"
     estraverse "^4.1.1"
 
-eslint-utils@^2.0.0, eslint-utils@^2.1.0:
+eslint-scope@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.1.tgz#fff34894c2f65e5226d3041ac480b4513a163642"
+  integrity sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==
+  dependencies:
+    esrecurse "^4.3.0"
+    estraverse "^5.2.0"
+
+eslint-utils@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27"
   integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
@@ -1181,7 +1787,7 @@
   dependencies:
     eslint-visitor-keys "^2.0.0"
 
-eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0:
+eslint-visitor-keys@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e"
   integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==
@@ -1191,65 +1797,60 @@
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303"
   integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==
 
-eslint@^7.10.0, eslint@^7.24.0:
-  version "7.32.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d"
-  integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==
+eslint-visitor-keys@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826"
+  integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
+
+eslint@^7.10.0, eslint@^8.16.0:
+  version "8.16.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.16.0.tgz#6d936e2d524599f2a86c708483b4c372c5d3bbae"
+  integrity sha512-MBndsoXY/PeVTDJeWsYj7kLZ5hQpJOfMYLsF6LicLHQWbRDG19lK5jOix4DPl8yY4SUFcE3txy86OzFLWT+yoA==
   dependencies:
-    "@babel/code-frame" "7.12.11"
-    "@eslint/eslintrc" "^0.4.3"
-    "@humanwhocodes/config-array" "^0.5.0"
+    "@eslint/eslintrc" "^1.3.0"
+    "@humanwhocodes/config-array" "^0.9.2"
     ajv "^6.10.0"
     chalk "^4.0.0"
     cross-spawn "^7.0.2"
-    debug "^4.0.1"
+    debug "^4.3.2"
     doctrine "^3.0.0"
-    enquirer "^2.3.5"
     escape-string-regexp "^4.0.0"
-    eslint-scope "^5.1.1"
-    eslint-utils "^2.1.0"
-    eslint-visitor-keys "^2.0.0"
-    espree "^7.3.1"
+    eslint-scope "^7.1.1"
+    eslint-utils "^3.0.0"
+    eslint-visitor-keys "^3.3.0"
+    espree "^9.3.2"
     esquery "^1.4.0"
     esutils "^2.0.2"
     fast-deep-equal "^3.1.3"
     file-entry-cache "^6.0.1"
     functional-red-black-tree "^1.0.1"
-    glob-parent "^5.1.2"
-    globals "^13.6.0"
-    ignore "^4.0.6"
+    glob-parent "^6.0.1"
+    globals "^13.15.0"
+    ignore "^5.2.0"
     import-fresh "^3.0.0"
     imurmurhash "^0.1.4"
     is-glob "^4.0.0"
-    js-yaml "^3.13.1"
+    js-yaml "^4.1.0"
     json-stable-stringify-without-jsonify "^1.0.1"
     levn "^0.4.1"
     lodash.merge "^4.6.2"
-    minimatch "^3.0.4"
+    minimatch "^3.1.2"
     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"
+    regexpp "^3.2.0"
+    strip-ansi "^6.0.1"
     strip-json-comments "^3.1.0"
-    table "^6.0.9"
     text-table "^0.2.0"
     v8-compile-cache "^2.0.3"
 
-espree@^7.3.0, espree@^7.3.1:
-  version "7.3.1"
-  resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6"
-  integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==
+espree@^9.3.2:
+  version "9.3.2"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.2.tgz#f58f77bd334731182801ced3380a8cc859091596"
+  integrity sha512-D211tC7ZwouTIuY5x9XnS0E9sWNChB7IYKX/Xp5eQj3nFXhqmiUDB9q27y76oFl8jTg3pXcQx/bpxMfs3CIZbA==
   dependencies:
-    acorn "^7.4.0"
-    acorn-jsx "^5.3.1"
-    eslint-visitor-keys "^1.3.0"
-
-esprima@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
-  integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
+    acorn "^8.7.1"
+    acorn-jsx "^5.3.2"
+    eslint-visitor-keys "^3.3.0"
 
 esquery@^1.4.0:
   version "1.4.0"
@@ -1275,11 +1876,21 @@
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
   integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==
 
+estree-walker@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
+  integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
+
 esutils@^2.0.2:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
+etag@^1.8.1:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+  integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
+
 execa@^5.0.0:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
@@ -1368,7 +1979,7 @@
     merge2 "^1.2.3"
     micromatch "^3.1.10"
 
-fast-glob@^3.1.1, fast-glob@^3.2.2:
+fast-glob@^3.2.2:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1"
   integrity sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==
@@ -1379,6 +1990,17 @@
     merge2 "^1.3.0"
     micromatch "^4.0.4"
 
+fast-glob@^3.2.9:
+  version "3.2.11"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9"
+  integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.2"
+    merge2 "^1.3.0"
+    micromatch "^4.0.4"
+
 fast-json-stable-stringify@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
@@ -1427,7 +2049,14 @@
   dependencies:
     to-regex-range "^5.0.1"
 
-find-up@^2.0.0, find-up@^2.1.0:
+find-replace@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
+  integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
+  dependencies:
+    array-back "^3.0.1"
+
+find-up@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7"
   integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c=
@@ -1467,6 +2096,11 @@
   dependencies:
     map-cache "^0.2.2"
 
+fresh@~0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+  integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
+
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -1482,11 +2116,26 @@
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
   integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
 
+function.prototype.name@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621"
+  integrity sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.1.3"
+    es-abstract "^1.19.0"
+    functions-have-names "^1.2.2"
+
 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"
   integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
 
+functions-have-names@^1.2.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
+  integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
+
 get-caller-file@^2.0.1:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
@@ -1520,6 +2169,14 @@
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
   integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
 
+get-symbol-description@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6"
+  integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==
+  dependencies:
+    call-bind "^1.0.2"
+    get-intrinsic "^1.1.1"
+
 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"
@@ -1533,13 +2190,20 @@
     is-glob "^3.1.0"
     path-dirname "^1.0.0"
 
-glob-parent@^5.1.2:
+glob-parent@^5.1.2, glob-parent@~5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
   integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
   dependencies:
     is-glob "^4.0.1"
 
+glob-parent@^6.0.1:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
+  integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
+  dependencies:
+    is-glob "^4.0.3"
+
 glob-to-regexp@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
@@ -1564,25 +2228,30 @@
   dependencies:
     ini "2.0.0"
 
-globals@^13.6.0, globals@^13.9.0:
-  version "13.11.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-13.11.0.tgz#40ef678da117fe7bd2e28f1fab24951bd0255be7"
-  integrity sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g==
+globals@^13.15.0:
+  version "13.15.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-13.15.0.tgz#38113218c907d2f7e98658af246cef8b77e90bac"
+  integrity sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==
   dependencies:
     type-fest "^0.20.2"
 
-globby@^11.0.3:
-  version "11.0.4"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5"
-  integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==
+globby@^11.1.0:
+  version "11.1.0"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
+  integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==
   dependencies:
     array-union "^2.1.0"
     dir-glob "^3.0.1"
-    fast-glob "^3.1.1"
-    ignore "^5.1.4"
-    merge2 "^1.3.0"
+    fast-glob "^3.2.9"
+    ignore "^5.2.0"
+    merge2 "^1.4.1"
     slash "^3.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@^9.6.0:
   version "9.6.0"
   resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85"
@@ -1637,6 +2306,11 @@
   resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113"
   integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==
 
+has-bigints@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa"
+  integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==
+
 has-flag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
@@ -1647,11 +2321,23 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
+has-property-descriptors@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861"
+  integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==
+  dependencies:
+    get-intrinsic "^1.1.1"
+
 has-symbols@^1.0.1, has-symbols@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
   integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
 
+has-symbols@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
+  integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+
 has-tostringtag@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
@@ -1714,33 +2400,50 @@
   dependencies:
     lru-cache "^6.0.0"
 
-htmlparser2@^3.9.1:
-  version "3.10.1"
-  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
-  integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==
-  dependencies:
-    domelementtype "^1.3.1"
-    domhandler "^2.3.0"
-    domutils "^1.5.1"
-    entities "^1.1.1"
-    inherits "^2.0.1"
-    readable-stream "^3.1.1"
-
-htmlparser2@^6.0.1:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7"
-  integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==
+htmlparser2@^7.1.2:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-7.2.0.tgz#8817cdea38bbc324392a90b1990908e81a65f5a5"
+  integrity sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==
   dependencies:
     domelementtype "^2.0.1"
-    domhandler "^4.0.0"
-    domutils "^2.5.2"
-    entities "^2.0.0"
+    domhandler "^4.2.2"
+    domutils "^2.8.0"
+    entities "^3.0.1"
+
+http-assert@^1.3.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f"
+  integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==
+  dependencies:
+    deep-equal "~1.0.1"
+    http-errors "~1.8.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"
   integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
 
+http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0:
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
+  integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses ">= 1.5.0 < 2"
+    toidentifier "1.0.1"
+
+http-errors@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
+  integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==
+  dependencies:
+    depd "~1.1.2"
+    inherits "2.0.3"
+    setprototypeof "1.1.0"
+    statuses ">= 1.4.0 < 2"
+
 human-signals@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
@@ -1753,16 +2456,16 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-ignore@^4.0.6:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
-  integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
-
-ignore@^5.1.1, ignore@^5.1.4:
+ignore@^5.1.1:
   version "5.1.8"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
   integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
 
+ignore@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
+  integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
+
 import-fresh@^3.0.0, import-fresh@^3.2.1:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
@@ -1794,11 +2497,16 @@
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@^2.0.1, inherits@^2.0.3:
+inherits@2, inherits@2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
+inherits@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+  integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
+
 ini@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5"
@@ -1837,6 +2545,11 @@
     has "^1.0.3"
     side-channel "^1.0.4"
 
+ip@^1.1.5:
+  version "1.1.8"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
+  integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==
+
 is-accessor-descriptor@^0.1.6:
   version "0.1.6"
   resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
@@ -1863,6 +2576,13 @@
   dependencies:
     has-bigints "^1.0.1"
 
+is-binary-path@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+  dependencies:
+    binary-extensions "^2.0.0"
+
 is-boolean-object@^1.1.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719"
@@ -1876,7 +2596,14 @@
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
   integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
 
-is-callable@^1.1.4, is-callable@^1.2.3:
+is-builtin-module@^3.1.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.0.tgz#bb0310dfe881f144ca83f30100ceb10cf58835e0"
+  integrity sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==
+  dependencies:
+    builtin-modules "^3.3.0"
+
+is-callable@^1.1.4, is-callable@^1.2.4:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
   integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
@@ -1888,13 +2615,27 @@
   dependencies:
     ci-info "^2.0.0"
 
-is-core-module@^2.2.0, is-core-module@^2.5.0, is-core-module@^2.6.0:
+is-core-module@^2.2.0, is-core-module@^2.5.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.6.0.tgz#d7553b2526fe59b92ba3e40c8df757ec8a709e19"
   integrity sha512-wShG8vs60jKfPWpF2KZRaAtvt3a20OAn7+IJ6hLPECpSABLcKtFKTTI4ZtH5QcBruBHlq+WsdHWyz0BCZW7svQ==
   dependencies:
     has "^1.0.3"
 
+is-core-module@^2.8.1:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"
+  integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==
+  dependencies:
+    has "^1.0.3"
+
+is-core-module@^2.9.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
+  integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
+  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"
@@ -1934,6 +2675,11 @@
     is-data-descriptor "^1.0.0"
     kind-of "^6.0.2"
 
+is-docker@^2.0.0, is-docker@^2.1.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
+  integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
+
 is-extendable@^0.1.0, is-extendable@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
@@ -1961,6 +2707,13 @@
   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.10"
+  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72"
+  integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==
+  dependencies:
+    has-tostringtag "^1.0.0"
+
 is-glob@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
@@ -1975,6 +2728,13 @@
   dependencies:
     is-extglob "^2.1.1"
 
+is-glob@^4.0.3, is-glob@~4.0.1:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
 is-installed-globally@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520"
@@ -1983,11 +2743,21 @@
     global-dirs "^3.0.0"
     is-path-inside "^3.0.2"
 
+is-module@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
+  integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
+
 is-negative-zero@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24"
   integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==
 
+is-negative-zero@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150"
+  integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==
+
 is-npm@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8"
@@ -2034,7 +2804,7 @@
   dependencies:
     isobject "^3.0.1"
 
-is-regex@^1.1.3:
+is-regex@^1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
   integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==
@@ -2042,12 +2812,24 @@
     call-bind "^1.0.2"
     has-tostringtag "^1.0.0"
 
+is-shared-array-buffer@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6"
+  integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==
+
+is-shared-array-buffer@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79"
+  integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==
+  dependencies:
+    call-bind "^1.0.2"
+
 is-stream@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
   integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
 
-is-string@^1.0.5, is-string@^1.0.6:
+is-string@^1.0.5, is-string@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd"
   integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==
@@ -2066,11 +2848,32 @@
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
   integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
 
+is-weakref@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.1.tgz#842dba4ec17fa9ac9850df2d6efbc1737274f2a2"
+  integrity sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==
+  dependencies:
+    call-bind "^1.0.0"
+
+is-weakref@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2"
+  integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==
+  dependencies:
+    call-bind "^1.0.2"
+
 is-windows@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
   integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
 
+is-wsl@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
+  integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
+  dependencies:
+    is-docker "^2.0.0"
+
 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"
@@ -2081,6 +2884,11 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
   integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
 
+isbinaryfile@^4.0.6:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
+  integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==
+
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -2103,18 +2911,17 @@
   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.14.1"
-  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
-  integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
+js-yaml@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
+  integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
   dependencies:
-    argparse "^1.0.7"
-    esprima "^4.0.0"
+    argparse "^2.0.1"
 
-jsdoctypeparser@^9.0.0:
-  version "9.0.0"
-  resolved "https://registry.yarnpkg.com/jsdoctypeparser/-/jsdoctypeparser-9.0.0.tgz#8c97e2fb69315eb274b0f01377eaa5c940bd7b26"
-  integrity sha512-jrTA2jJIL6/DAEILBEh2/w9QxCuwmvNXIry39Ay/HVfhE3o2yVV0U44blYkqdHA/OKloJEqvJy0xU+GSdE2SIw==
+jsdoc-type-pratt-parser@~3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-3.1.0.tgz#a4a56bdc6e82e5865ffd9febc5b1a227ff28e67e"
+  integrity sha512-MgtD0ZiCDk9B+eI73BextfRrVQl0oyzRG8B2BjORts6jbunj4ScKPcyXGTbB6eXL4y9TzxCm6hyeLq/2ASzNdw==
 
 json-buffer@3.0.0:
   version "3.0.0"
@@ -2136,11 +2943,6 @@
   resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
   integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
 
-json-schema-traverse@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
-  integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
-
 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"
@@ -2160,6 +2962,13 @@
   dependencies:
     minimist "^1.2.5"
 
+keygrip@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
+  integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
+  dependencies:
+    tsscmp "1.0.6"
+
 keyv@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
@@ -2191,6 +3000,72 @@
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
   integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
 
+koa-compose@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877"
+  integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==
+
+koa-convert@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5"
+  integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==
+  dependencies:
+    co "^4.6.0"
+    koa-compose "^4.1.0"
+
+koa-etag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/koa-etag/-/koa-etag-4.0.0.tgz#2c2bb7ae69ca1ac6ced09ba28dcb78523c810414"
+  integrity sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==
+  dependencies:
+    etag "^1.8.1"
+
+koa-send@^5.0.0, koa-send@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/koa-send/-/koa-send-5.0.1.tgz#39dceebfafb395d0d60beaffba3a70b4f543fe79"
+  integrity sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==
+  dependencies:
+    debug "^4.1.1"
+    http-errors "^1.7.3"
+    resolve-path "^1.4.0"
+
+koa-static@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/koa-static/-/koa-static-5.0.0.tgz#5e92fc96b537ad5219f425319c95b64772776943"
+  integrity sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==
+  dependencies:
+    debug "^3.1.0"
+    koa-send "^5.0.0"
+
+koa@^2.13.0:
+  version "2.13.4"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e"
+  integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==
+  dependencies:
+    accepts "^1.3.5"
+    cache-content-type "^1.0.0"
+    content-disposition "~0.5.2"
+    content-type "^1.0.4"
+    cookies "~0.8.0"
+    debug "^4.3.2"
+    delegates "^1.0.0"
+    depd "^2.0.0"
+    destroy "^1.0.4"
+    encodeurl "^1.0.2"
+    escape-html "^1.0.3"
+    fresh "~0.5.2"
+    http-assert "^1.3.0"
+    http-errors "^1.6.3"
+    is-generator-function "^1.0.7"
+    koa-compose "^4.1.0"
+    koa-convert "^2.0.0"
+    on-finished "^2.3.0"
+    only "~0.0.2"
+    parseurl "^1.3.2"
+    statuses "^1.5.0"
+    type-is "^1.6.16"
+    vary "^1.1.2"
+
 latest-version@^5.1.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face"
@@ -2255,10 +3130,10 @@
   dependencies:
     p-locate "^4.1.0"
 
-lodash.clonedeep@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
-  integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
+lodash.camelcase@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+  integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
 
 lodash.deburr@^4.1.0:
   version "4.1.0"
@@ -2270,12 +3145,7 @@
   resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
   integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
 
-lodash.truncate@^4.4.2:
-  version "4.4.2"
-  resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
-  integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=
-
-lodash@4.17.21, lodash@^4.15.0, lodash@^4.17.19, lodash@^4.17.21:
+lodash@^4.17.14, lodash@^4.17.19:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -2331,6 +3201,16 @@
   dependencies:
     object-visit "^1.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 sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
+
+memorystream@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2"
+  integrity sha1-htcJCzDORV1j+64S3aUaR93K+bI=
+
 meow@^9.0.0:
   version "9.0.0"
   resolved "https://registry.yarnpkg.com/meow/-/meow-9.0.0.tgz#cd9510bc5cac9dee7d03c73ee1f9ad959f4ea364"
@@ -2354,7 +3234,7 @@
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
   integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
 
-merge2@^1.2.3, merge2@^1.3.0:
+merge2@^1.2.3, merge2@^1.3.0, merge2@^1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
   integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
@@ -2386,6 +3266,18 @@
     braces "^3.0.1"
     picomatch "^2.2.3"
 
+mime-db@1.52.0:
+  version "1.52.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34:
+  version "2.1.35"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
 mimic-fn@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
@@ -2401,20 +3293,20 @@
   resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
   integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
 
-minimatch@3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774"
-  integrity sha1-Kk5AkLlrLbBqnX3wEFWmKnfJt3Q=
-  dependencies:
-    brace-expansion "^1.0.0"
-
 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"
 
+minimatch@^3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
+  dependencies:
+    brace-expansion "^1.1.7"
+
 minimist-options@4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
@@ -2429,6 +3321,11 @@
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
+minimist@^1.2.6:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
+  integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
+
 mixin-deep@^1.2.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
@@ -2437,6 +3334,13 @@
     for-in "^1.0.2"
     is-extendable "^1.0.1"
 
+mkdirp@^0.5.6:
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+  dependencies:
+    minimist "^1.2.6"
+
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -2457,6 +3361,11 @@
   resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
   integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
 
+nanocolors@^0.2.1:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/nanocolors/-/nanocolors-0.2.13.tgz#dfd1ed0bfab05e9fe540eb6874525f0a1684099b"
+  integrity sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==
+
 nanomatch@^1.2.9:
   version "1.2.13"
   resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@@ -2484,6 +3393,16 @@
   resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
   integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=
 
+negotiator@0.6.3:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
+  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+
+nice-try@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
+  integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
+
 normalize-package-data@^2.3.2, 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"
@@ -2504,11 +3423,31 @@
     semver "^7.3.4"
     validate-npm-package-license "^3.0.1"
 
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
 normalize-url@^4.1.0:
   version "4.5.1"
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a"
   integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==
 
+npm-run-all@^4.1.5:
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.5.tgz#04476202a15ee0e2e214080861bff12a51d98fba"
+  integrity sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==
+  dependencies:
+    ansi-styles "^3.2.1"
+    chalk "^2.4.1"
+    cross-spawn "^6.0.5"
+    memorystream "^0.3.1"
+    minimatch "^3.0.4"
+    pidtree "^0.3.0"
+    read-pkg "^3.0.0"
+    shell-quote "^1.6.1"
+    string.prototype.padend "^3.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"
@@ -2516,13 +3455,6 @@
   dependencies:
     path-key "^3.0.0"
 
-nth-check@~1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"
-  integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==
-  dependencies:
-    boolbase "~1.0.0"
-
 object-copy@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
@@ -2537,6 +3469,11 @@
   resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1"
   integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==
 
+object-inspect@^1.12.0:
+  version "1.12.2"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
+  integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
+
 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"
@@ -2566,14 +3503,21 @@
   dependencies:
     isobject "^3.0.1"
 
-object.values@^1.1.4:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.4.tgz#0d273762833e816b693a637d30073e7051535b30"
-  integrity sha512-TnGo7j4XSnKQoK3MfvkzqKCi0nVe/D9I9IjwTNYdb/fxYHpjrluHVOgw0AF6jrRFGMPHdfuidR09tIDiIvnaSg==
+object.values@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac"
+  integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==
   dependencies:
     call-bind "^1.0.2"
     define-properties "^1.1.3"
-    es-abstract "^1.18.2"
+    es-abstract "^1.19.1"
+
+on-finished@^2.3.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+  dependencies:
+    ee-first "1.1.1"
 
 once@^1.3.0, once@^1.3.1, once@^1.4.0:
   version "1.4.0"
@@ -2589,6 +3533,20 @@
   dependencies:
     mimic-fn "^2.1.0"
 
+only@~0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
+  integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==
+
+open@^8.0.2:
+  version "8.4.0"
+  resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8"
+  integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==
+  dependencies:
+    define-lazy-prop "^2.0.0"
+    is-docker "^2.1.1"
+    is-wsl "^2.2.0"
+
 optionator@^0.9.1:
   version "0.9.1"
   resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499"
@@ -2696,18 +3654,16 @@
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2"
   integrity sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==
 
-parse5@^3.0.1:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c"
-  integrity sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==
-  dependencies:
-    "@types/node" "*"
-
 parse5@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
   integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
 
+parseurl@^1.3.2:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
 pascalcase@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
@@ -2728,17 +3684,22 @@
   resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
   integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
 
-path-is-absolute@^1.0.0:
+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"
   integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
 
+path-key@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
+  integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
+
 path-key@^3.0.0, path-key@^3.1.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
   integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
 
-path-parse@^1.0.6:
+path-parse@^1.0.6, path-parse@^1.0.7:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
@@ -2755,29 +3716,34 @@
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
   integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
 
+picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+  integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
 picomatch@^2.2.3:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
   integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
 
+pidtree@^0.3.0:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.3.1.tgz#ef09ac2cc0533df1f3250ccf2c4d366b0d12114a"
+  integrity sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==
+
 pify@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
   integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
 
-pkg-dir@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
-  integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=
+portfinder@^1.0.28:
+  version "1.0.29"
+  resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.29.tgz#d06ff886f4ff91274ed3e25c7e6b0c68d2a0735a"
+  integrity sha512-Z5+DarHWCKlufshB9Z1pN95oLtANoY5Wn9X3JGELGyQ6VhEcBfT2t+1fGUBq7MwUant6g/mqowH+4HifByPbiQ==
   dependencies:
-    find-up "^2.1.0"
-
-pkg-up@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f"
-  integrity sha1-yBmscoBZpGHKscOImivjxJoATX8=
-  dependencies:
-    find-up "^2.1.0"
+    async "^2.6.4"
+    debug "^3.2.7"
+    mkdirp "^0.5.6"
 
 posix-character-classes@^0.1.0:
   version "0.1.1"
@@ -2801,20 +3767,10 @@
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@2.3.1:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.1.tgz#76903c3f8c4449bc9ac597acefa24dc5ad4cbea6"
-  integrity sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==
-
-prettier@^2.1.2:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.2.tgz#ef280a05ec253712e486233db5c6f23441e7342d"
-  integrity sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==
-
-progress@^2.0.0:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
-  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+prettier@2.6.2, prettier@^2.1.2:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032"
+  integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==
 
 protobufjs@6.8.8:
   version "6.8.8"
@@ -2843,7 +3799,7 @@
     end-of-stream "^1.1.0"
     once "^1.3.1"
 
-punycode@^2.1.0:
+punycode@^2.1.0, punycode@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
@@ -2875,14 +3831,6 @@
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
 
-read-pkg-up@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07"
-  integrity sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=
-  dependencies:
-    find-up "^2.0.0"
-    read-pkg "^3.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"
@@ -2911,14 +3859,12 @@
     parse-json "^5.0.0"
     type-fest "^0.6.0"
 
-readable-stream@^3.1.1:
+readdirp@~3.6.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==
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+  integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
   dependencies:
-    inherits "^2.0.3"
-    string_decoder "^1.1.1"
-    util-deprecate "^1.0.1"
+    picomatch "^2.2.1"
 
 redent@^3.0.0:
   version "3.0.0"
@@ -2928,6 +3874,11 @@
     indent-string "^4.0.0"
     strip-indent "^3.0.0"
 
+reduce-flatten@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
+  integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
+
 regenerator-runtime@^0.13.4:
   version "0.13.9"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
@@ -2941,16 +3892,20 @@
     extend-shallow "^3.0.2"
     safe-regex "^1.1.0"
 
-regexpp@^3.0.0, regexpp@^3.1.0:
+regexp.prototype.flags@^1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac"
+  integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.1.3"
+    functions-have-names "^1.2.2"
+
+regexpp@^3.0.0, regexpp@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
   integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
 
-regextras@^0.7.1:
-  version "0.7.1"
-  resolved "https://registry.yarnpkg.com/regextras/-/regextras-0.7.1.tgz#be95719d5f43f9ef0b9fa07ad89b7c606995a3b2"
-  integrity sha512-9YXf6xtW+qzQ+hcMQXx95MOvfqXFgsKDZodX3qZB0x2n5Z94ioetIITsBtvJbiOyxa/6s9AtyweBLCdPmPko/w==
-
 registry-auth-token@^4.0.0:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250"
@@ -2980,11 +3935,6 @@
   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
   integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
 
-require-from-string@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
-  integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
-
 require-main-filename@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
@@ -3000,6 +3950,14 @@
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
   integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
 
+resolve-path@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
+  integrity sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==
+  dependencies:
+    http-errors "~1.6.2"
+    path-is-absolute "1.0.1"
+
 resolve-url@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
@@ -3013,6 +3971,24 @@
     is-core-module "^2.2.0"
     path-parse "^1.0.6"
 
+resolve@^1.19.0:
+  version "1.22.1"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
+  integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
+  dependencies:
+    is-core-module "^2.9.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
+resolve@^1.22.0:
+  version "1.22.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
+  integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
+  dependencies:
+    is-core-module "^2.8.1"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
 responselike@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"
@@ -3052,6 +4028,13 @@
   optionalDependencies:
     fsevents "~2.3.2"
 
+rollup@^2.67.0:
+  version "2.77.3"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.77.3.tgz#8f00418d3a2740036e15deb653bed1a90ee0cc12"
+  integrity sha512-/qxNTG7FbmefJWoeeYJFbHehJ2HNWnjkAFRKzWN/45eNBBF/r8lo992CwcJXEzyVxs5FmfId+vTSTQDb+bxA+g==
+  optionalDependencies:
+    fsevents "~2.3.2"
+
 run-async@^2.4.0:
   version "2.4.1"
   resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
@@ -3071,7 +4054,7 @@
   dependencies:
     tslib "^1.9.0"
 
-safe-buffer@~5.2.0:
+safe-buffer@5.2.1:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -3095,7 +4078,7 @@
   dependencies:
     semver "^6.3.0"
 
-"semver@2 || 3 || 4 || 5":
+"semver@2 || 3 || 4 || 5", semver@^5.5.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
@@ -3110,13 +4093,27 @@
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
-semver@^7.2.1, semver@^7.3.4, semver@^7.3.5:
+semver@^7.3.4:
   version "7.3.5"
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
   integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
   dependencies:
     lru-cache "^6.0.0"
 
+semver@^7.3.7:
+  version "7.3.7"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
+  integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
+  dependencies:
+    lru-cache "^6.0.0"
+
+semver@^7.3.8:
+  version "7.3.8"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
+  integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
+  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"
@@ -3132,6 +4129,23 @@
     is-plain-object "^2.0.3"
     split-string "^3.0.1"
 
+setprototypeof@1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
+  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
+
+setprototypeof@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
+shebang-command@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
+  integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=
+  dependencies:
+    shebang-regex "^1.0.0"
+
 shebang-command@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@@ -3139,11 +4153,21 @@
   dependencies:
     shebang-regex "^3.0.0"
 
+shebang-regex@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
+  integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
+
 shebang-regex@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
   integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 
+shell-quote@^1.6.1:
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123"
+  integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==
+
 side-channel@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
@@ -3163,15 +4187,6 @@
   resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
   integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
 
-slice-ansi@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b"
-  integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==
-  dependencies:
-    ansi-styles "^4.0.0"
-    astral-regex "^2.0.0"
-    is-fullwidth-code-point "^3.0.0"
-
 snapdragon-node@^2.0.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
@@ -3282,11 +4297,6 @@
   dependencies:
     extend-shallow "^3.0.0"
 
-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=
-
 static-extend@^0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
@@ -3295,6 +4305,11 @@
     define-property "^0.2.5"
     object-copy "^0.1.0"
 
+"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+  integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
+
 string-width@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
@@ -3313,6 +4328,15 @@
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
+string.prototype.padend@^3.0.0:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.1.3.tgz#997a6de12c92c7cb34dc8a201a6c53d9bd88a5f1"
+  integrity sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.1.3"
+    es-abstract "^1.19.1"
+
 string.prototype.trimend@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80"
@@ -3321,6 +4345,15 @@
     call-bind "^1.0.2"
     define-properties "^1.1.3"
 
+string.prototype.trimend@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz#914a65baaab25fbdd4ee291ca7dde57e869cb8d0"
+  integrity sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.1.4"
+    es-abstract "^1.19.5"
+
 string.prototype.trimstart@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed"
@@ -3329,12 +4362,14 @@
     call-bind "^1.0.2"
     define-properties "^1.1.3"
 
-string_decoder@^1.1.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
-  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+string.prototype.trimstart@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz#5466d93ba58cfa2134839f81d7f42437e8c01fef"
+  integrity sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg==
   dependencies:
-    safe-buffer "~5.2.0"
+    call-bind "^1.0.2"
+    define-properties "^1.1.4"
+    es-abstract "^1.19.5"
 
 strip-ansi@^5.1.0:
   version "5.2.0"
@@ -3350,6 +4385,13 @@
   dependencies:
     ansi-regex "^5.0.0"
 
+strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
 strip-bom@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
@@ -3391,17 +4433,20 @@
   dependencies:
     has-flag "^4.0.0"
 
-table@^6.0.9:
-  version "6.7.1"
-  resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2"
-  integrity sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg==
+supports-preserve-symlinks-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
+table-layout@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04"
+  integrity sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==
   dependencies:
-    ajv "^8.0.1"
-    lodash.clonedeep "^4.5.0"
-    lodash.truncate "^4.4.2"
-    slice-ansi "^4.0.0"
-    string-width "^4.2.0"
-    strip-ansi "^6.0.0"
+    array-back "^4.0.1"
+    deep-extend "~0.6.0"
+    typical "^5.2.0"
+    wordwrapjs "^4.0.0"
 
 terser@^5.6.1:
   version "5.7.2"
@@ -3466,6 +4511,18 @@
     regex-not "^1.0.2"
     safe-regex "^1.1.0"
 
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
+tr46@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9"
+  integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==
+  dependencies:
+    punycode "^2.1.1"
+
 trim-newlines@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
@@ -3483,14 +4540,14 @@
   resolved "https://registry.yarnpkg.com/ts-simple-type/-/ts-simple-type-1.0.7.tgz#03930af557528dd40eaa121913c7035a0baaacf8"
   integrity sha512-zKmsCQs4dZaeSKjEA7pLFDv7FHHqAFLPd0Mr//OIJvu8M+4p4bgSFJwZSEBEg3ec9W7RzRz1vi8giiX0+mheBQ==
 
-tsconfig-paths@^3.11.0:
-  version "3.11.0"
-  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.11.0.tgz#954c1fe973da6339c78e06b03ce2e48810b65f36"
-  integrity sha512-7ecdYDnIdmv639mmDwslG6KQg1Z9STTz1j7Gcz0xa+nshh/gKDAHcPxRbWOsA3SPp0tXP2leTcY9Kw+NAkfZzA==
+tsconfig-paths@^3.14.1:
+  version "3.14.1"
+  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a"
+  integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==
   dependencies:
     "@types/json5" "^0.0.29"
     json5 "^1.0.1"
-    minimist "^1.2.0"
+    minimist "^1.2.6"
     strip-bom "^3.0.0"
 
 tslib@^1.8.1, tslib@^1.9.0:
@@ -3498,30 +4555,18 @@
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
-tsutils@2.27.2:
-  version "2.27.2"
-  resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.27.2.tgz#60ba88a23d6f785ec4b89c6e8179cac9b431f1c7"
-  integrity sha512-qf6rmT84TFMuxAKez2pIfR8UCai49iQsfB7YWVjV1bKpy/d0PWT5rEOSM6La9PiHZ0k1RRZQiwVdVJfQ3BPHgg==
-  dependencies:
-    tslib "^1.8.1"
+tsscmp@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
+  integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
 
-tsutils@^3.21.0:
+tsutils@3.21.0, tsutils@^3.21.0:
   version "3.21.0"
   resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
   integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==
   dependencies:
     tslib "^1.8.1"
 
-twinkie@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/twinkie/-/twinkie-1.1.3.tgz#1a6f0cd11c59e245bc2d16c7c9fc1ec13e477229"
-  integrity sha512-8Y1U/UCtf8JC4snuV4KAo4e9nwJcKZUoMVOApihJzua4JJWeGB/2RYqAusKk3cUczJeZRGzirHpP1hkArcbA8A==
-  dependencies:
-    "@types/minimatch" "3.0.3"
-    cheerio "1.0.0-rc.2"
-    minimatch "3.0.3"
-    typescript "4.0.5"
-
 type-check@^0.4.0, type-check@~0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@@ -3554,6 +4599,14 @@
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
   integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
 
+type-is@^1.6.16:
+  version "1.6.18"
+  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+  dependencies:
+    media-typer "0.3.0"
+    mime-types "~2.1.24"
+
 typedarray-to-buffer@^3.1.5:
   version "3.1.5"
   resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
@@ -3561,16 +4614,31 @@
   dependencies:
     is-typedarray "^1.0.0"
 
-typescript@4.0.5, typescript@4.3.2:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805"
-  integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==
-
 typescript@^3.8.3:
   version "3.9.10"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8"
   integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==
 
+typescript@^4.7.2:
+  version "4.7.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.2.tgz#1f9aa2ceb9af87cca227813b4310fff0b51593c4"
+  integrity sha512-Mamb1iX2FDUpcTRzltPxgWMKy3fhg0TN378ylbktPGPK/99KbDtMQ4W1hwgsbPAsG3a0xKa1vmw4VKZQbkvz5A==
+
+typical@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
+  integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+
+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==
+
+ua-parser-js@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775"
+  integrity sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg==
+
 unbox-primitive@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
@@ -3581,6 +4649,16 @@
     has-symbols "^1.0.2"
     which-boxed-primitive "^1.0.2"
 
+unbox-primitive@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"
+  integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==
+  dependencies:
+    call-bind "^1.0.2"
+    has-bigints "^1.0.2"
+    has-symbols "^1.0.3"
+    which-boxed-primitive "^1.0.2"
+
 union-value@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
@@ -3650,11 +4728,6 @@
   resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
   integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
 
-util-deprecate@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
-  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
-
 v8-compile-cache@^2.0.3:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
@@ -3668,6 +4741,11 @@
     spdx-correct "^3.0.0"
     spdx-expression-parse "^3.0.0"
 
+vary@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+  integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+
 vscode-css-languageservice@4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/vscode-css-languageservice/-/vscode-css-languageservice-4.3.0.tgz#40c797d664ab6188cace33cfbb19b037580a9318"
@@ -3718,6 +4796,19 @@
     typescript "^3.8.3"
     yargs "^15.3.1"
 
+webidl-conversions@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
+  integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
+
+whatwg-url@^11.0.0:
+  version "11.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018"
+  integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==
+  dependencies:
+    tr46 "^3.0.0"
+    webidl-conversions "^7.0.0"
+
 which-boxed-primitive@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
@@ -3734,6 +4825,13 @@
   resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
   integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
 
+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:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
@@ -3753,6 +4851,14 @@
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
+wordwrapjs@^4.0.0:
+  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.2.0"
+
 wrap-ansi@^6.2.0:
   version "6.2.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@@ -3786,6 +4892,11 @@
     signal-exit "^3.0.2"
     typedarray-to-buffer "^3.1.5"
 
+ws@^7.4.2:
+  version "7.5.9"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
+  integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
+
 xdg-basedir@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
@@ -3830,3 +4941,8 @@
     which-module "^2.0.0"
     y18n "^4.0.0"
     yargs-parser "^18.1.2"
+
+ylru@^1.2.0:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
+  integrity sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==